// scene.rs — GPU scene renderer for GameObjectScene.
//
// Manages shared mesh buffers (one per PrimitiveKind), per-object uniform pools,
// and render / picking passes. Pre-allocated, bounded at MAX_GPU_OBJECTS.
// No per-frame heap allocation in the render hot path.
//
// CODESPEC §0.3: No hidden work. Buffers pre-allocated at upload time.
// CODESPEC §3.1: Transforms packed contiguously in upload order.

use std::collections::HashMap;

use dreamwell_engine::game_object::{GameObjectScene, MeshBinding, PrimitiveKind};

use crate::camera::Camera;
use crate::formats::DEPTH_FORMAT;
use crate::gltf::GltfImportResult;
use crate::lod::{GpuLodMesh, LodConfig, LodLevel};
use crate::primitives::{PrimitiveShape, DEFAULT_PRIMITIVE_COLOR, PBR_VERTEX_LAYOUT};
use crate::shader;
use crate::texture::{self, GpuTexture};

/// Hard cap on GPU-side objects. Matches engine's MAX_SCENE_OBJECTS.
pub const MAX_GPU_OBJECTS: usize = 65536;

/// Maximum objects for Traditional Render Path (CPU submission).
/// Beyond this, GPU-driven indirect path is required.
const MAX_TRP_OBJECTS: usize = 256;

/// Render path selection for the scene renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderPath {
    /// Traditional Render Pipeline (TRP).
    /// Per-object draw_indexed() with ring buffer + dynamic uniform offsets.
    /// CPU cost: O(N) draw calls, O(N) uniform staging. Always available.
    Traditional,
    /// Dreamwell Render Pipeline (DRP).
    /// GPU-driven: storage buffer objects + compute cull + multi_draw_indexed_indirect.
    /// CPU cost: O(1) per frame for static scenes.
    /// Requires: INDIRECT_FIRST_INSTANCE feature.
    Dreamwell,
    /// Ray-Traced Render Pipeline (RTX).
    /// Hybrid rendering: rasterized primary visibility (PBR) + ray-traced shadows + ray-traced GI.
    /// Uses SHaRC-style spatially-hashed radiance cache for global illumination.
    /// Requires: EXPERIMENTAL_RAY_QUERY feature.
    RayTraced,
}

// ── Uniform layout (matches pbr.wgsl) ───────────────────────────────────

/// Maximum point lights uploaded per frame.
pub const MAX_POINT_LIGHTS: usize = 64;

/// Maximum spot lights uploaded per frame.
pub const MAX_SPOT_LIGHTS: usize = 32;

/// Maximum directional lights uploaded per frame.
pub const MAX_DIRECTIONAL_LIGHTS: usize = 4;

/// GPU-side point light. Matches LightArray.lights[i] in pbr.wgsl.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuPointLight {
    pub position: [f32; 3],
    pub range: f32,
    pub color: [f32; 3],
    pub intensity: f32,
}

/// Fixed-size array of point lights for GPU upload.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct LightArray {
    lights: [GpuPointLight; MAX_POINT_LIGHTS],
}

// ── PBR GPU types (matches pbr.wgsl layout) ──────────────────────────

/// GPU-side directional light. 32 bytes, matches pbr.wgsl DirectionalLight.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuDirectionalLight {
    pub direction: [f32; 3],
    pub intensity: f32,
    pub color: [f32; 3],
    pub _pad: f32,
}

/// GPU-side spot light. 80 bytes, matches pbr.wgsl SpotLight std140 layout.
/// WGSL vec3 requires 16-byte alignment in uniform arrays, creating padding.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuSpotLight {
    pub position: [f32; 3],  // offset 0
    pub range: f32,          // offset 12
    pub direction: [f32; 3], // offset 16
    pub intensity: f32,      // offset 28
    pub color: [f32; 3],     // offset 32
    pub inner_cos: f32,      // offset 44
    pub outer_cos: f32,      // offset 48
    pub _gap: [f32; 3],      // offset 52 — implicit std140 padding to align next vec3
    pub _pad: [f32; 3],      // offset 64 — matches WGSL _pad: vec3<f32>
    pub _struct_pad: f32,    // offset 76 — round struct to 80 (16-byte alignment)
}

/// PBR scene uniforms. Matches pbr.wgsl @group(0) @binding(0).
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PbrUniforms {
    pub view_proj: [f32; 16],
    pub model: [f32; 16],
    pub camera_pos: [f32; 3],
    pub point_light_count: u32,
    pub spot_light_count: u32,
    pub directional_light_count: u32,
    pub _pad_counts: [u32; 2],
    pub base_color_factor: [f32; 4],
    pub emissive_factor: [f32; 3],
    pub emissive_strength: f32,
    pub roughness: f32,
    pub metallic: f32,
    pub occlusion_strength: f32,
    pub normal_scale: f32,
    pub alpha_cutoff: f32,
    pub has_base_color_tex: u32,
    pub has_normal_tex: u32,
    pub has_roughness_metalness_tex: u32,
    pub has_occlusion_tex: u32,
    pub has_emissive_tex: u32,
    /// Padding to 256 bytes to match WGSL std140 struct alignment.
    pub _pad: [f32; 6],
}

/// Fixed-size array of directional lights for GPU upload.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct DirectionalLightArray {
    lights: [GpuDirectionalLight; MAX_DIRECTIONAL_LIGHTS],
}

/// Fixed-size array of spot lights for GPU upload.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct SpotLightArray {
    lights: [GpuSpotLight; MAX_SPOT_LIGHTS],
}

#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct PickUniforms {
    view_proj: [f32; 16],
    model: [f32; 16],
    object_id: u32,
    _pad: [u32; 3],
}

// ── Shared mesh ────────────────────────────────────────────────────────

/// One shared vertex+index buffer per PrimitiveKind used in the scene.
pub(crate) struct SharedMesh {
    pub(crate) vertex_buffer: wgpu::Buffer,
    pub(crate) index_buffer: wgpu::Buffer,
    pub(crate) index_count: u32,
}

fn engine_to_gpu_shape(kind: PrimitiveKind) -> PrimitiveShape {
    match kind {
        PrimitiveKind::Cube => PrimitiveShape::Cube,
        PrimitiveKind::Sphere => PrimitiveShape::Sphere,
        PrimitiveKind::Cylinder => PrimitiveShape::Cylinder,
        PrimitiveKind::Cone => PrimitiveShape::Cone,
        PrimitiveKind::Plane => PrimitiveShape::Plane,
        PrimitiveKind::Torus => PrimitiveShape::Torus,
        PrimitiveKind::Capsule => PrimitiveShape::Capsule,
        PrimitiveKind::Pyramid => PrimitiveShape::Pyramid,
        PrimitiveKind::Wedge => PrimitiveShape::Wedge,
    }
}

/// Parse roughness and metallic from a `material.pbr=r,g,b,a,rough,metal` property tag.
/// Returns `None` if no matching tag is found or if parsing fails.
fn parse_material_tag(tags: &[String]) -> Option<(f32, f32)> {
    for tag in tags {
        if let Some(vals) = tag.strip_prefix("material.pbr=") {
            let parts: Vec<&str> = vals.split(',').collect();
            if parts.len() >= 6 {
                let roughness = parts[4].parse::<f32>().ok()?;
                let metallic = parts[5].parse::<f32>().ok()?;
                return Some((roughness, metallic));
            }
        }
    }
    None
}

/// Upload a PBR mesh (64-byte PbrVertex) to GPU buffers.
fn upload_pbr_mesh(device: &wgpu::Device, mesh: &crate::primitives::PbrMesh) -> SharedMesh {
    use wgpu::util::DeviceExt;
    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
        label: Some(mesh.name),
        contents: bytemuck::cast_slice(&mesh.vertices),
        usage: wgpu::BufferUsages::VERTEX,
    });
    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
        label: Some(mesh.name),
        contents: bytemuck::cast_slice(&mesh.indices),
        usage: wgpu::BufferUsages::INDEX,
    });
    SharedMesh {
        vertex_buffer,
        index_buffer,
        index_count: mesh.indices.len() as u32,
    }
}

// ── Per-object slot ────────────────────────────────────────────────────

// Fields uniform_buffer and pbr_bind_group are retained for per-material
// bind group wiring when DreamSurface glTF materials are active.
#[allow(dead_code)]
pub(crate) struct ObjectSlot {
    /// Per-object uniform buffer (retained for per-material bind group wiring).
    pub(crate) uniform_buffer: wgpu::Buffer,
    pub(crate) pick_uniform_buffer: wgpu::Buffer,
    pub(crate) pick_bind_group: wgpu::BindGroup,
    /// Per-material PBR bind group — reserved for glTF per-material texture binding.
    /// Currently unused for primitive objects (ring buffer + shared material instead).
    pub(crate) pbr_bind_group: Option<wgpu::BindGroup>,
    pub(crate) mesh_kind: PrimitiveKind,
    pub(crate) _color: [f32; 4],
    pub(crate) roughness: f32,
    pub(crate) metallic: f32,
    pub(crate) model_matrix: [f32; 16],
    pub(crate) visible: bool,
    pub(crate) object_id: u64,
    /// Index into gltf_surface_materials for per-material texture binding.
    pub(crate) material_index: Option<usize>,
}

// Per-material bind group wiring methods. Used when DreamSurface glTF materials
// are active for per-object texture binding. Retained for v1.1 material pipeline.
#[allow(dead_code)]
impl ObjectSlot {
    pub(crate) fn uniform_buffer(&self) -> &wgpu::Buffer {
        &self.uniform_buffer
    }
    pub(crate) fn set_pbr_bind_group(&mut self, bg: wgpu::BindGroup) {
        self.pbr_bind_group = Some(bg);
    }
    pub(crate) fn pbr_bind_group(&self) -> Option<&wgpu::BindGroup> {
        self.pbr_bind_group.as_ref()
    }
}

/// Default PBR fallback textures for untextured primitives.
struct PbrDefaultTextures {
    base_color: GpuTexture,
    flat_normal: GpuTexture,
    roughness_metalness: GpuTexture,
    occlusion: GpuTexture,
    emissive: GpuTexture,
    /// Retained for bind group lifetime; the sampler is bound in `pbr_default_material_bg`.
    _sampler: wgpu::Sampler,
}

// ── GpuScene ───────────────────────────────────────────────────────────

/// GPU scene renderer. Shared between editor play-preview and runtime.
///
/// Safety invariants:
/// - All buffers are wgpu-managed (no unsafe, no raw pointers).
/// - Object count bounded at `MAX_GPU_OBJECTS`.
/// - Mesh buffers shared across instances (one upload per PrimitiveKind).
/// - Drop frees all GPU resources via wgpu's Drop impls.
/// DrawIndexedIndirect arguments (20 bytes, matches wgpu DrawIndexedIndirect layout).
#[repr(C)]
#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct DrawIndexedIndirectArgs {
    pub index_count: u32,
    pub instance_count: u32,
    pub first_index: u32,
    pub base_vertex: i32,
    pub first_instance: u32,
}

pub struct GpuScene {
    meshes: HashMap<PrimitiveKind, SharedMesh>,
    /// LOD mesh sets — up to 3 detail levels per PrimitiveKind.
    lod_meshes: HashMap<PrimitiveKind, GpuLodMesh>,
    /// LOD configuration (distance thresholds).
    pub lod_config: LodConfig,
    /// Pre-allocated indirect draw buffer for multi-draw indirect.
    indirect_draw_buf: Option<wgpu::Buffer>,
    /// Whether multi_draw_indexed_indirect is available.
    pub multi_draw_supported: bool,
    slots: Vec<ObjectSlot>,
    /// Free list of recycled slot indices (Clean Compute: no per-spawn allocation).
    free_slots: Vec<usize>,
    /// Pre-allocated slot buffer pool. Buffers are reused across `upload()` calls
    /// to avoid per-upload GPU buffer creation/destruction churn.
    pool_uniform_bufs: Vec<wgpu::Buffer>,
    pool_pick_bufs: Vec<wgpu::Buffer>,
    // Picking pipeline
    pick_pipeline: wgpu::RenderPipeline,
    pick_bind_group_layout: wgpu::BindGroupLayout,
    /// Retained for pipeline re-creation on render target format change.
    surface_format: wgpu::TextureFormat,
    // PBR pipeline variants — lazy-initialized via ensure_pbr_pipelines()
    pbr_opaque_pipeline: Option<wgpu::RenderPipeline>,
    pbr_opaque_double_sided_pipeline: Option<wgpu::RenderPipeline>,
    pbr_masked_pipeline: Option<wgpu::RenderPipeline>,
    pbr_transparent_pipeline: Option<wgpu::RenderPipeline>,
    pbr_bgl_scene: Option<wgpu::BindGroupLayout>,
    pbr_bgl_material: Option<wgpu::BindGroupLayout>,
    pbr_bgl_ibl: Option<wgpu::BindGroupLayout>,
    /// IBL bind group (irradiance + specular + BRDF LUT). Created on first PBR render after IBL init.
    pbr_ibl_bind_group: Option<wgpu::BindGroup>,
    /// Shared directional light buffer for PBR passes.
    pbr_directional_buf: Option<wgpu::Buffer>,
    /// Shared point light buffer for PBR passes.
    pbr_point_buf: Option<wgpu::Buffer>,
    /// Shared spot light buffer for PBR passes.
    pbr_spot_buf: Option<wgpu::Buffer>,
    /// Default PBR fallback textures (created in ensure_pbr_pipelines).
    pbr_default_textures: Option<PbrDefaultTextures>,
    /// Default PBR material bind group (all fallback textures) for untextured PBR rendering.
    pbr_default_material_bg: Option<wgpu::BindGroup>,
    /// Ring buffer for per-object PBR uniforms (single contiguous upload per frame).
    uniform_ring: Option<wgpu::Buffer>,
    /// Bind group for ring buffer with dynamic offset.
    uniform_ring_bg: Option<wgpu::BindGroup>,
    /// Scene lights collected per frame.
    pub scene_lights: crate::lights::SceneLights,
    /// Default material roughness for primitives.
    pub default_roughness: f32,
    /// Default material metallic for primitives.
    pub default_metallic: f32,
    /// True if the slot pool needs rebuilding from the scene.
    dirty: bool,
    /// Active render path (Traditional or GpuDriven).
    pub active_render_path: RenderPath,
    /// Cached glTF mesh buffers (populated by upload_gltf, indexed by primitive order).
    gltf_mesh_cache: Vec<SharedMesh>,
    /// Maps slot index to glTF mesh cache index (for glTF rendering).
    gltf_slot_mesh_map: Vec<usize>,
    /// Per-glTF-material surface bind groups (DreamSurface). Indexed by material index.
    gltf_surface_materials: Vec<crate::dream_surface::SurfaceMaterial>,
    /// Shared default textures for materials without assigned textures.
    surface_defaults: Option<crate::dream_surface::PbrDefaultTextures>,
    // RT PBR integration (@group(3))
    pbr_bgl_rt: Option<wgpu::BindGroupLayout>,
    pbr_rt_bind_group: Option<wgpu::BindGroup>,
    rt_config_buf: Option<wgpu::Buffer>,
    /// Clean Compute: pre-allocated 1x1 fallback texture for missing SSAO/SSR views.
    /// Created once at init, never recreated per-frame.
    rt_fallback_tex: Option<wgpu::Texture>,
    rt_fallback_view: Option<wgpu::TextureView>,
    /// Cached RT PBR bind group (invalidated on resize).
    cached_rt_pbr_bg: Option<wgpu::BindGroup>,
    /// MSAA sample count for pipeline creation. 1 = no MSAA, 4 = 4x MSAA.
    pub msaa_samples: u32,
    /// Pre-allocated staging buffer for render_pbr — reused every frame (Clean Compute).
    staging_buffer: Vec<u8>,
    /// Pre-allocated LOD selection buffer — reused every frame (Clean Compute).
    slot_lod_buffer: Vec<(usize, LodLevel)>,
    /// Pre-allocated indirect args buffer — reused every frame (Clean Compute).
    indirect_args_buffer: Vec<DrawIndexedIndirectArgs>,
}

impl GpuScene {
    /// Lumens value that maps to 1.0 intensity on GPU. 800 lm ≈ 60W incandescent.
    const LUMENS_PER_UNIT: f32 = 800.0;

    /// Maximum cached glTF meshes before LRU eviction.
    pub const MAX_GLTF_MESHES: usize = 1000;

    /// Surface format used to create this scene's pipelines.
    pub fn surface_format(&self) -> wgpu::TextureFormat {
        self.surface_format
    }

    /// Create a new GPU scene renderer. Compiles picking pipeline, allocates nothing else.
    /// PBR pipelines are lazily initialized via `ensure_pbr_pipelines()`.
    pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
        let (pick_pipeline, pick_bind_group_layout) = Self::build_pick_pipeline(device);

        Self {
            meshes: HashMap::new(),
            lod_meshes: HashMap::new(),
            lod_config: LodConfig::default(),
            indirect_draw_buf: None,
            multi_draw_supported: false,
            slots: Vec::new(),
            free_slots: Vec::with_capacity(256),
            pool_uniform_bufs: Vec::new(),
            pool_pick_bufs: Vec::new(),
            pick_pipeline,
            pick_bind_group_layout,
            surface_format,
            pbr_opaque_pipeline: None,
            pbr_opaque_double_sided_pipeline: None,
            pbr_masked_pipeline: None,
            pbr_transparent_pipeline: None,
            pbr_bgl_scene: None,
            pbr_bgl_material: None,
            pbr_bgl_ibl: None,
            pbr_ibl_bind_group: None,
            pbr_directional_buf: None,
            pbr_point_buf: None,
            pbr_spot_buf: None,
            pbr_default_textures: None,
            pbr_default_material_bg: None,
            uniform_ring: None,
            uniform_ring_bg: None,
            scene_lights: crate::lights::SceneLights::new(),
            default_roughness: 0.5,
            default_metallic: 0.0,
            dirty: true,
            active_render_path: RenderPath::Traditional,
            gltf_mesh_cache: Vec::new(),
            gltf_slot_mesh_map: Vec::new(),
            gltf_surface_materials: Vec::new(),
            surface_defaults: None,
            pbr_bgl_rt: None,
            pbr_rt_bind_group: None,
            rt_fallback_tex: None,
            rt_fallback_view: None,
            rt_config_buf: None,
            cached_rt_pbr_bg: None,
            msaa_samples: 1,
            staging_buffer: Vec::with_capacity(1024 * std::mem::size_of::<PbrUniforms>()),
            slot_lod_buffer: Vec::with_capacity(1024),
            indirect_args_buffer: Vec::with_capacity(1024),
        }
    }

    /// Invalidate resolution-dependent cached bind groups on window resize.
    pub fn resize(&mut self) {
        self.cached_rt_pbr_bg = None;
    }

    /// Upload a GameObjectScene to GPU. Rebuilds per-object slots, reusing
    /// pooled GPU buffers where possible to avoid per-upload allocation churn.
    /// Only call when scene content changes (not every frame).
    pub fn upload(&mut self, device: &wgpu::Device, scene: &GameObjectScene) {
        // Collect which PrimitiveKinds are used.
        let mut needed: std::collections::HashSet<PrimitiveKind> = std::collections::HashSet::new();
        for obj in &scene.objects {
            if let MeshBinding::Primitive { kind, .. } = &obj.mesh {
                needed.insert(*kind);
            }
        }

        // Upload missing shared meshes. Always use PBR vertex format (64 bytes with tangent + UV).
        // Also generate LOD mesh sets for distance-based LOD selection.
        for kind in &needed {
            if !self.meshes.contains_key(kind) {
                let shape = engine_to_gpu_shape(*kind);
                let mesh = shape.generate_pbr(DEFAULT_PRIMITIVE_COLOR);
                self.meshes.insert(*kind, upload_pbr_mesh(device, &mesh));

                // Generate and upload LOD mesh set
                let lod_set = shape.generate_lod_set(DEFAULT_PRIMITIVE_COLOR);
                let gpu_lod = GpuLodMesh::upload(device, &lod_set);
                self.lod_meshes.insert(*kind, gpu_lod);
            }
        }

        // Count how many visible primitive objects we need slots for.
        let limit = scene.objects.len().min(MAX_GPU_OBJECTS);
        let mut needed_count = 0usize;
        for obj in scene.objects.iter().take(limit) {
            if matches!(obj.mesh, MeshBinding::Primitive { .. }) && obj.visible {
                needed_count += 1;
            }
        }

        // Grow the buffer pool if needed (only allocate new buffers beyond current pool size).
        // Uniform buffers are sized for PbrUniforms.
        while self.pool_uniform_bufs.len() < needed_count {
            self.pool_uniform_bufs
                .push(device.create_buffer(&wgpu::BufferDescriptor {
                    label: Some("scene_object_uniform"),
                    size: std::mem::size_of::<PbrUniforms>() as u64,
                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                    mapped_at_creation: false,
                }));
        }
        while self.pool_pick_bufs.len() < needed_count {
            self.pool_pick_bufs.push(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("scene_object_pick_uniform"),
                size: std::mem::size_of::<PickUniforms>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }));
        }

        // Rebuild per-object slots using pooled buffers.
        // Bind groups must be recreated (they reference specific buffer handles)
        // but the underlying GPU buffers are reused.
        self.slots.clear();
        let mut slot_idx = 0usize;
        for obj in scene.objects.iter().take(limit) {
            let MeshBinding::Primitive { kind, color } = &obj.mesh else {
                continue;
            };
            if !obj.visible {
                continue;
            }

            let uniform_buffer = &self.pool_uniform_bufs[slot_idx];
            let pick_uniform_buffer = &self.pool_pick_bufs[slot_idx];

            let pick_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("scene_object_pick_bg"),
                layout: &self.pick_bind_group_layout,
                entries: &[wgpu::BindGroupEntry {
                    binding: 0,
                    resource: pick_uniform_buffer.as_entire_binding(),
                }],
            });

            // Parse per-object roughness/metallic from material.pbr property tag.
            let (roughness, metallic) =
                parse_material_tag(&obj.property_tags).unwrap_or((self.default_roughness, self.default_metallic));

            // ObjectSlot borrows from pool — the buffers are owned by pool_*_bufs.
            // We clone the buffer handle (wgpu::Buffer is internally Arc'd).
            // PBR bind groups are created lazily by `create_pbr_bind_groups()`.
            self.slots.push(ObjectSlot {
                uniform_buffer: uniform_buffer.clone(),
                pick_uniform_buffer: pick_uniform_buffer.clone(),
                pick_bind_group,
                pbr_bind_group: None,
                mesh_kind: *kind,
                _color: *color,
                roughness,
                metallic,
                model_matrix: obj.transform.to_matrix(),
                visible: true,
                object_id: obj.id,
                material_index: None, // primitives use default material
            });
            slot_idx += 1;
        }

        // Sort slots by mesh_kind for draw call batching (consecutive same-kind objects
        // share vertex/index buffer binds, reducing state changes in render_pbr).
        self.slots.sort_by_key(|s| s.mesh_kind as u32);

        // Fallback light extraction: scan scene for Light objects.
        // Provides a safety net when Loom extraction is unavailable or fails.
        // Loom extraction in weaver.rs overrides this with authoritative data when it runs.
        if self.scene_lights.total_count() == 0 {
            for obj in &scene.objects {
                if obj.has_component(dreamwell_engine::game_object::ComponentKind::Light) {
                    let pos = obj.transform.position;
                    let len = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt();
                    if len > 0.001 {
                        self.scene_lights
                            .add_directional(dreamwell_engine::lighting::DirectionalLightDesc {
                                direction: [-pos[0] / len, -pos[1] / len, -pos[2] / len],
                                color: [1.0, 1.0, 1.0],
                                intensity_lux: 1000.0,
                            });
                    }
                }
            }
            // If still empty, add a default sun so the scene isn't totally dark.
            if self.scene_lights.total_count() == 0 {
                self.scene_lights
                    .add_directional(dreamwell_engine::lighting::DirectionalLightDesc::default());
            }
        }

        self.dirty = false;
    }

    /// Update transforms from pre-computed world matrices (from `propagate_transforms()`).
    /// Use this when the scene has a hierarchy.
    pub fn sync_world_matrices(&mut self, scene: &GameObjectScene, world_matrices: &[[f32; 16]]) {
        let mut slot_idx = 0;
        for (i, obj) in scene.objects.iter().enumerate() {
            if !obj.visible || matches!(obj.mesh, MeshBinding::None | MeshBinding::Custom { .. }) {
                continue;
            }
            if slot_idx >= self.slots.len() {
                break;
            }
            if let Some(wm) = world_matrices.get(i) {
                self.slots[slot_idx].model_matrix = *wm;
            }
            slot_idx += 1;
        }
    }

    /// Update transforms, visibility, color, and materials from a scene without rebuilding slots.
    /// Call every frame when objects move but the scene structure is unchanged.
    pub fn sync_transforms(&mut self, scene: &GameObjectScene) {
        let mut slot_idx = 0;
        for obj in &scene.objects {
            if matches!(obj.mesh, MeshBinding::None | MeshBinding::Custom { .. }) {
                continue;
            }
            // Skip objects that were invisible at upload time (no slot allocated).
            if !obj.visible {
                continue;
            }
            if slot_idx >= self.slots.len() {
                break;
            }
            self.slots[slot_idx].model_matrix = obj.transform.to_matrix();
            // Sync color from MeshBinding.
            if let MeshBinding::Primitive { color, .. } = &obj.mesh {
                self.slots[slot_idx]._color = *color;
            }
            // Sync roughness/metallic from property tags.
            if let Some((roughness, metallic)) = parse_material_tag(&obj.property_tags) {
                self.slots[slot_idx].roughness = roughness;
                self.slots[slot_idx].metallic = metallic;
            }
            slot_idx += 1;
        }
    }

    /// Render object IDs for GPU picking. Each object gets a unique u32 color.
    pub fn render_picking(&self, queue: &wgpu::Queue, pass: &mut wgpu::RenderPass<'_>, camera: &Camera) {
        if self.slots.is_empty() {
            return;
        }

        let vp = mat4_mul(&camera.projection_matrix_array(), &camera.view_matrix_array());

        pass.set_pipeline(&self.pick_pipeline);

        for (i, slot) in self.slots.iter().enumerate() {
            if !slot.visible {
                continue;
            }
            let Some(mesh) = self.meshes.get(&slot.mesh_kind) else {
                continue;
            };

            let uniforms = PickUniforms {
                view_proj: vp,
                model: slot.model_matrix,
                object_id: (i as u32).saturating_add(1), // 1-based (0 = background)
                _pad: [0; 3],
            };
            queue.write_buffer(&slot.pick_uniform_buffer, 0, bytemuck::bytes_of(&uniforms));

            pass.set_bind_group(0, &slot.pick_bind_group, &[]);
            pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
            pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
            pass.draw_indexed(0..mesh.index_count, 0, 0..1);
        }
    }

    /// Resolve a 1-based picking ID to the engine-side object ID.
    pub fn resolve_pick(&self, pick_id: u32) -> Option<u64> {
        if pick_id == 0 {
            return None;
        }
        self.slots.get((pick_id - 1) as usize).map(|s| s.object_id)
    }

    /// Number of GPU-side renderable objects.
    /// Whether the scene has any renderable objects.
    pub fn is_empty(&self) -> bool {
        self.slots.is_empty()
    }

    pub fn object_count(&self) -> usize {
        self.slots.len()
    }

    /// Return the number of recycled slots available for reuse.
    pub fn free_slot_count(&self) -> usize {
        self.free_slots.len()
    }

    /// Return a slot to the free pool for reuse (Clean Compute).
    /// Marks the slot as invisible so it is skipped during rendering.
    pub fn recycle_slot(&mut self, index: usize) {
        if index < self.slots.len() {
            self.slots[index].visible = false;
            self.free_slots.push(index);
        }
    }

    /// Acquire a slot index from the free pool for reuse.
    /// Returns `Some(index)` if a recycled slot is available, `None` if the
    /// caller must append a new `ObjectSlot` to `slots` (cold-path allocation).
    pub fn acquire_slot(&mut self) -> Option<usize> {
        self.free_slots.pop()
    }

    /// Clear all GPU object slots and shared meshes. Releases GPU buffers via Drop.
    /// Pool buffers are retained for reuse on next upload.
    /// Call on play mode exit to free per-object GPU resources.
    pub fn clear(&mut self) {
        self.slots.clear();
        self.free_slots.clear();
        self.meshes.clear();
        // LOD meshes must be cleared too — they're shape-specific.
        for (_, lod) in self.lod_meshes.drain() {
            lod.destroy();
        }
        // Note: pool_uniform_bufs and pool_pick_bufs are intentionally retained
        // so the next upload() reuses them without re-allocation.
        self.dirty = true;
    }

    /// Mark scene as needing re-upload.
    pub fn mark_dirty(&mut self) {
        self.dirty = true;
    }

    pub fn is_dirty(&self) -> bool {
        self.dirty
    }

    /// Iterate visible object slots (for shadow pass).
    pub(crate) fn visible_slots(&self) -> impl Iterator<Item = &ObjectSlot> {
        self.slots.iter().filter(|s| s.visible)
    }

    /// Get the shared mesh for a slot's primitive kind.
    pub(crate) fn mesh_for_slot(&self, slot: &ObjectSlot) -> Option<&SharedMesh> {
        self.meshes.get(&slot.mesh_kind)
    }

    /// Upload a glTF import to GPU. Creates per-mesh vertex/index buffers and per-node slots.
    /// Materials use the default PBR fallback textures with base_color_factor from glTF material.
    /// Call `ensure_pbr_pipelines()` before this to guarantee fallback textures exist.
    pub fn upload_gltf(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, import: &GltfImportResult) {
        use wgpu::util::DeviceExt;

        // Clear existing scene
        self.slots.clear();

        // Upload each glTF mesh as a SharedMesh keyed by a synthetic PrimitiveKind.
        // We reuse PrimitiveKind::Cube as the "glTF mesh" marker and differentiate by mesh index.
        // Store glTF meshes in a separate list to avoid conflicting with primitive meshes.
        let mut gltf_meshes: Vec<SharedMesh> = Vec::new();

        for gltf_mesh in &import.meshes {
            for prim in &gltf_mesh.primitives {
                // Convert GltfVertex (32B: pos+normal+uv) to PbrVertex (64B: pos+normal+tangent+uv+color)
                let pbr_verts: Vec<crate::primitives::PbrVertex> = prim
                    .vertices
                    .iter()
                    .map(|v| {
                        crate::primitives::PbrVertex {
                            position: v.position,
                            normal: v.normal,
                            tangent: [1.0, 0.0, 0.0, 1.0], // default tangent
                            tex_coord: v.tex_coords,
                            color: [1.0, 1.0, 1.0, 1.0],
                        }
                    })
                    .collect();

                let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                    label: Some(&gltf_mesh.name),
                    contents: bytemuck::cast_slice(&pbr_verts),
                    usage: wgpu::BufferUsages::VERTEX,
                });
                let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                    label: Some(&gltf_mesh.name),
                    contents: bytemuck::cast_slice(&prim.indices),
                    usage: wgpu::BufferUsages::INDEX,
                });

                gltf_meshes.push(SharedMesh {
                    vertex_buffer,
                    index_buffer,
                    index_count: prim.indices.len() as u32,
                });
            }
        }

        // Create object slots for each scene node that references a mesh.
        // Flatten: each node × each primitive = one slot.
        let mut slot_idx = 0usize;
        let _prim_offset = 0usize; // reserved for future multi-mesh LOD indexing

        // Pre-compute per-mesh primitive offset
        let mut mesh_prim_offsets: Vec<usize> = Vec::new();
        let mut offset = 0;
        for gltf_mesh in &import.meshes {
            mesh_prim_offsets.push(offset);
            offset += gltf_mesh.primitives.len();
        }

        for node in &import.nodes {
            let Some(mesh_idx) = node.mesh_index else { continue };
            if mesh_idx >= import.meshes.len() {
                continue;
            }

            let gltf_mesh = &import.meshes[mesh_idx];
            let base_offset = mesh_prim_offsets[mesh_idx];

            // Build model matrix from node TRS
            let t = glam::Vec3::from(node.translation);
            let r = glam::Quat::from_xyzw(node.rotation[0], node.rotation[1], node.rotation[2], node.rotation[3]);
            let s = glam::Vec3::from(node.scale);
            let model = glam::Mat4::from_scale_rotation_translation(s, r, t);

            for (prim_idx, prim) in gltf_mesh.primitives.iter().enumerate() {
                if slot_idx >= MAX_GPU_OBJECTS {
                    break;
                }

                let mesh_ref_idx = base_offset + prim_idx;
                if mesh_ref_idx >= gltf_meshes.len() {
                    continue;
                }

                // Get material (or default)
                let mat = prim
                    .material_index
                    .and_then(|i| import.materials.get(i))
                    .cloned()
                    .unwrap_or_default();

                // Allocate per-object uniform buffer
                let uniform_buffer = if slot_idx < self.pool_uniform_bufs.len() {
                    self.pool_uniform_bufs[slot_idx].clone()
                } else {
                    let buf = device.create_buffer(&wgpu::BufferDescriptor {
                        label: Some("gltf_obj_uniform"),
                        size: 256,
                        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                        mapped_at_creation: false,
                    });
                    self.pool_uniform_bufs.push(buf.clone());
                    buf
                };

                let pick_uniform_buffer = if slot_idx < self.pool_pick_bufs.len() {
                    self.pool_pick_bufs[slot_idx].clone()
                } else {
                    let buf = device.create_buffer(&wgpu::BufferDescriptor {
                        label: Some("gltf_pick_uniform"),
                        size: 256,
                        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                        mapped_at_creation: false,
                    });
                    self.pool_pick_bufs.push(buf.clone());
                    buf
                };

                let pick_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
                    label: Some("gltf_pick_bg"),
                    layout: &self.pick_bind_group_layout,
                    entries: &[wgpu::BindGroupEntry {
                        binding: 0,
                        resource: pick_uniform_buffer.as_entire_binding(),
                    }],
                });

                self.slots.push(ObjectSlot {
                    uniform_buffer,
                    pick_uniform_buffer,
                    pick_bind_group,
                    pbr_bind_group: None,
                    mesh_kind: PrimitiveKind::Cube, // Not used for glTF — render_pbr uses gltf_mesh_cache
                    _color: mat.base_color_factor,
                    roughness: mat.roughness_factor,
                    metallic: mat.metallic_factor,
                    model_matrix: model.to_cols_array(),
                    visible: true,
                    object_id: slot_idx as u64 + 1,
                    material_index: prim.material_index,
                });

                slot_idx += 1;
            }
        }

        // LRU eviction: cap mesh cache at MAX_GLTF_MESHES.
        // Evict oldest (front) entries when over capacity.
        if gltf_meshes.len() > Self::MAX_GLTF_MESHES {
            let evict_count = gltf_meshes.len() - Self::MAX_GLTF_MESHES;
            // Evicted meshes are dropped, which calls wgpu Buffer::drop (dealloc).
            gltf_meshes.drain(0..evict_count);
            log::warn!(
                "gltf_mesh_cache: evicted {evict_count} meshes (cap: {})",
                Self::MAX_GLTF_MESHES
            );
        }

        // Store glTF meshes for rendering (indexed by slot position)
        self.gltf_mesh_cache = gltf_meshes;
        self.gltf_slot_mesh_map = (0..slot_idx).collect();

        // DreamSurface: create per-material bind groups for all 5 PBR texture slots.
        // This is the cold-path integration — all texture upload happens here at import time.
        if let Some(ref bgl) = self.pbr_bgl_material {
            // Initialize surface defaults once (1x1 fallback textures)
            if self.surface_defaults.is_none() {
                self.surface_defaults = Some(crate::dream_surface::PbrDefaultTextures::new(device, queue));
            }
            let defaults = self.surface_defaults.as_ref().unwrap();
            self.gltf_surface_materials.clear();
            for mat in &import.materials {
                let surface_mat = crate::dream_surface::SurfaceMaterial::from_gltf(device, queue, mat, defaults, bgl);
                self.gltf_surface_materials.push(surface_mat);
            }
            if !self.gltf_surface_materials.is_empty() {
                log::info!(
                    "DreamSurface: {} per-material bind groups created",
                    self.gltf_surface_materials.len()
                );
            }
        }

        self.dirty = false;

        log::info!(
            "upload_gltf: {} slots from {} nodes, {} meshes, {} total primitives",
            slot_idx,
            import.nodes.len(),
            import.meshes.len(),
            import.meshes.iter().map(|m| m.primitives.len()).sum::<usize>()
        );
    }

    /// Explicitly destroy all GPU resources.
    pub fn destroy(&mut self) {
        // Slots hold cloned (Arc'd) buffer handles — drop them first.
        self.slots.clear();
        // Destroy pooled buffers (the actual GPU allocations).
        for buf in self.pool_uniform_bufs.drain(..) {
            buf.destroy();
        }
        for buf in self.pool_pick_bufs.drain(..) {
            buf.destroy();
        }
        for (_, mesh) in self.meshes.drain() {
            mesh.vertex_buffer.destroy();
            mesh.index_buffer.destroy();
        }
        // LOD meshes
        for (_, lod) in self.lod_meshes.drain() {
            lod.destroy();
        }
        // Indirect draw buffer
        if let Some(buf) = self.indirect_draw_buf.take() {
            buf.destroy();
        }
        // Ring buffer
        if let Some(buf) = self.uniform_ring.take() {
            buf.destroy();
        }
        self.uniform_ring_bg = None;
        // PBR light buffers
        if let Some(buf) = self.pbr_directional_buf.take() {
            buf.destroy();
        }
        if let Some(buf) = self.pbr_point_buf.take() {
            buf.destroy();
        }
        if let Some(buf) = self.pbr_spot_buf.take() {
            buf.destroy();
        }
        // PBR default textures
        if let Some(mut textures) = self.pbr_default_textures.take() {
            textures.base_color.destroy();
            textures.flat_normal.destroy();
            textures.roughness_metalness.destroy();
            textures.occlusion.destroy();
            textures.emissive.destroy();
        }
        self.pbr_default_material_bg = None;
    }

    // ── PBR pipeline management ────────────────────────────────────────

    /// Lazily create all PBR pipeline variants, bind group layouts, and default
    /// fallback textures for untextured PBR rendering.
    /// `target_format` should be `HDR_FORMAT` when post-processing is active,
    /// or `surface_format` when rendering directly to the swapchain.
    /// Safe to call multiple times — no-op if already initialized.
    pub fn ensure_pbr_pipelines(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        target_format: wgpu::TextureFormat,
    ) {
        if self.pbr_opaque_pipeline.is_some() {
            return;
        }

        // Group 0: scene uniforms + 3 light buffers
        let pbr_bgl_scene = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("pbr_bgl_scene"),
            entries: &[
                // @binding(0) PbrUniforms (dynamic offset for ring buffer)
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: true,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // @binding(1) DirectionalLightArray
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // @binding(2) LightArray (point)
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // @binding(3) SpotLightArray
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
            ],
        });

        // Group 1: 5 material textures + 1 sampler
        let pbr_bgl_material = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("pbr_bgl_material"),
            entries: &[
                // @binding(0) base_color texture
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // @binding(1) normal texture
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // @binding(2) roughness_metalness texture
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // @binding(3) occlusion texture
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // @binding(4) emissive texture
                wgpu::BindGroupLayoutEntry {
                    binding: 4,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // @binding(5) sampler
                wgpu::BindGroupLayoutEntry {
                    binding: 5,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });

        // Group 2: IBL textures (irradiance, specular, BRDF LUT, sampler)
        let pbr_bgl_ibl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("pbr_bgl_ibl"),
            entries: &[
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::Cube,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::Cube,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
                    count: None,
                },
            ],
        });

        use wgpu::util::DeviceExt;

        // @group(3): RT output textures (GI + shadow mask + config uniform).
        // Always bound — shader checks rt_config flags before sampling.
        let pbr_bgl_rt = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("pbr_bgl_rt"),
            entries: &[
                // binding 0: RT GI output (texture_2d<f32>, filterable for fallback Rgba8Unorm compat)
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // binding 1: RT shadow mask (texture_2d<f32>, filterable for fallback compat)
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // binding 2: RT config uniform (RtPbrConfig: gi_enabled, shadow_enabled, intensity, ssao, ssr, pad)
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // binding 3: SSAO texture (DL-07)
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // binding 4: SSR texture (DL-07)
                wgpu::BindGroupLayoutEntry {
                    binding: 4,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
                // binding 5: SSGI texture (TD-005)
                wgpu::BindGroupLayoutEntry {
                    binding: 5,
                    visibility: wgpu::ShaderStages::FRAGMENT,
                    ty: wgpu::BindingType::Texture {
                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
                        view_dimension: wgpu::TextureViewDimension::D2,
                        multisampled: false,
                    },
                    count: None,
                },
            ],
        });

        // Create RT fallback resources: 1x1 black textures + disabled config.
        // Used when RT is not active — shader reads zeros and skips RT sampling.
        let rt_fallback_tex = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("pbr_rt_fallback"),
            size: wgpu::Extent3d {
                width: 1,
                height: 1,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: wgpu::TextureFormat::Rgba8Unorm,
            usage: wgpu::TextureUsages::TEXTURE_BINDING,
            view_formats: &[],
        });
        let rt_fallback_view = rt_fallback_tex.create_view(&wgpu::TextureViewDescriptor::default());

        // RtPbrConfig: { rt_gi_enabled: 0, rt_shadow_enabled: 0, rt_gi_intensity: 0.0,
        //               ssao_enabled: 0, ssr_enabled: 0, _pad: [0,0,0] }
        let rt_config_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("pbr_rt_config"),
            contents: &[0u8; 32], // all zeros = all flags disabled (8 u32s)
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });

        // Clean Compute: store persistent fallback for reuse in update_rt_bind_group_full().
        // Create a second view from the same texture for self-storage (avoids per-frame recreation).
        let rt_fallback_view_persistent = rt_fallback_tex.create_view(&wgpu::TextureViewDescriptor::default());
        self.rt_fallback_view = Some(rt_fallback_view_persistent);
        self.rt_fallback_tex = Some(rt_fallback_tex);

        let pbr_rt_fallback_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("pbr_rt_fallback_bg"),
            layout: &pbr_bgl_rt,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: wgpu::BindingResource::TextureView(&rt_fallback_view),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::TextureView(&rt_fallback_view),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: rt_config_buf.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: wgpu::BindingResource::TextureView(&rt_fallback_view),
                },
                wgpu::BindGroupEntry {
                    binding: 4,
                    resource: wgpu::BindingResource::TextureView(&rt_fallback_view),
                },
                wgpu::BindGroupEntry {
                    binding: 5,
                    resource: wgpu::BindingResource::TextureView(&rt_fallback_view),
                },
            ],
        });

        self.pbr_bgl_rt = Some(pbr_bgl_rt.clone());
        self.pbr_rt_bind_group = Some(pbr_rt_fallback_bg);
        self.rt_config_buf = Some(rt_config_buf);

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("pbr_pipeline_layout"),
            bind_group_layouts: &[
                Some(&pbr_bgl_scene),
                Some(&pbr_bgl_material),
                Some(&pbr_bgl_ibl),
                Some(&pbr_bgl_rt),
            ],
            immediate_size: 0,
        });

        let shader_src = include_str!("../shaders/pbr.wgsl");
        let (module, _info) = shader::load_wgsl(device, "pbr_shader", shader_src);

        // Build 4 pipeline variants targeting the specified render format.
        let sc = self.msaa_samples;
        self.pbr_opaque_pipeline = Some(build_pbr_pipeline_variant(
            device,
            &pipeline_layout,
            &module,
            target_format,
            Some(wgpu::Face::Back),
            true,
            None,
            "pbr_opaque",
            sc,
        ));
        self.pbr_opaque_double_sided_pipeline = Some(build_pbr_pipeline_variant(
            device,
            &pipeline_layout,
            &module,
            target_format,
            None,
            true,
            None,
            "pbr_opaque_double_sided",
            sc,
        ));
        self.pbr_masked_pipeline = Some(build_pbr_pipeline_variant(
            device,
            &pipeline_layout,
            &module,
            target_format,
            None,
            true,
            None,
            "pbr_masked",
            sc,
        ));
        self.pbr_transparent_pipeline = Some(build_pbr_pipeline_variant(
            device,
            &pipeline_layout,
            &module,
            target_format,
            None,
            false,
            Some(wgpu::BlendState {
                color: wgpu::BlendComponent {
                    src_factor: wgpu::BlendFactor::SrcAlpha,
                    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
                    operation: wgpu::BlendOperation::Add,
                },
                alpha: wgpu::BlendComponent {
                    src_factor: wgpu::BlendFactor::One,
                    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
                    operation: wgpu::BlendOperation::Add,
                },
            }),
            "pbr_transparent",
            sc,
        ));

        // Create default fallback textures for untextured PBR rendering.
        let base_color = texture::default_white_texture(device, queue);
        let flat_normal = texture::default_flat_normal(device, queue);
        let roughness_metalness = texture::default_roughness_metalness(device, queue);
        let occlusion = texture::default_white_ao(device, queue);
        let emissive = texture::default_black(device, queue);

        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("pbr_default_sampler"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            ..Default::default()
        });

        let material_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("pbr_default_material"),
            layout: &pbr_bgl_material,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: wgpu::BindingResource::TextureView(&base_color.view),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::TextureView(&flat_normal.view),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: wgpu::BindingResource::TextureView(&roughness_metalness.view),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: wgpu::BindingResource::TextureView(&occlusion.view),
                },
                wgpu::BindGroupEntry {
                    binding: 4,
                    resource: wgpu::BindingResource::TextureView(&emissive.view),
                },
                wgpu::BindGroupEntry {
                    binding: 5,
                    resource: wgpu::BindingResource::Sampler(&sampler),
                },
            ],
        });

        self.pbr_default_textures = Some(PbrDefaultTextures {
            base_color,
            flat_normal,
            roughness_metalness,
            occlusion,
            emissive,
            _sampler: sampler,
        });
        self.pbr_default_material_bg = Some(material_bg);

        self.pbr_bgl_scene = Some(pbr_bgl_scene);
        self.pbr_bgl_material = Some(pbr_bgl_material);
        self.pbr_bgl_ibl = Some(pbr_bgl_ibl);
    }

    /// Create the IBL bind group from precomputed IBL textures.
    /// No-op if IBL textures are not available or bind group already exists.
    pub fn create_ibl_bind_group(&mut self, device: &wgpu::Device, ibl: &crate::ibl::IblPrecompute) {
        if self.pbr_ibl_bind_group.is_some() {
            return;
        }
        let Some(ref bgl) = self.pbr_bgl_ibl else { return };
        let Some(irradiance) = ibl.irradiance_view() else {
            return;
        };
        let Some(specular) = ibl.specular_view() else { return };
        let Some(brdf_lut) = ibl.brdf_lut_view() else { return };

        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("pbr_ibl_sampler"),
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            mipmap_filter: wgpu::MipmapFilterMode::Linear,
            ..Default::default()
        });

        self.pbr_ibl_bind_group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("pbr_ibl_bg"),
            layout: bgl,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: wgpu::BindingResource::TextureView(irradiance),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::TextureView(specular),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: wgpu::BindingResource::TextureView(brdf_lut),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: wgpu::BindingResource::Sampler(&sampler),
                },
            ],
        }));
    }

    /// Upload PBR light buffers from `self.scene_lights`. Lazily creates buffers.
    pub fn upload_pbr_lights(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
        // Ensure directional light buffer exists.
        if self.pbr_directional_buf.is_none() {
            self.pbr_directional_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("pbr_directional_lights"),
                size: std::mem::size_of::<DirectionalLightArray>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }));
        }
        // Ensure point light buffer exists.
        if self.pbr_point_buf.is_none() {
            self.pbr_point_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("pbr_point_lights"),
                size: std::mem::size_of::<LightArray>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }));
        }
        // Ensure spot light buffer exists.
        if self.pbr_spot_buf.is_none() {
            self.pbr_spot_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("pbr_spot_lights"),
                size: std::mem::size_of::<SpotLightArray>() as u64,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }));
        }

        // Write directional lights
        let mut dir_arr = DirectionalLightArray {
            lights: [GpuDirectionalLight {
                direction: [0.0; 3],
                intensity: 0.0,
                color: [0.0; 3],
                _pad: 0.0,
            }; MAX_DIRECTIONAL_LIGHTS],
        };
        for (i, dl) in self
            .scene_lights
            .directional
            .iter()
            .take(MAX_DIRECTIONAL_LIGHTS)
            .enumerate()
        {
            dir_arr.lights[i] = GpuDirectionalLight {
                direction: dl.direction,
                intensity: dl.intensity_lux,
                color: dl.color,
                _pad: 0.0,
            };
        }
        let Some(ref dir_buf) = self.pbr_directional_buf else {
            return;
        };
        queue.write_buffer(dir_buf, 0, bytemuck::bytes_of(&dir_arr));

        // Write point lights
        let mut point_arr = LightArray {
            lights: [GpuPointLight {
                position: [0.0; 3],
                range: 0.0,
                color: [0.0; 3],
                intensity: 0.0,
            }; MAX_POINT_LIGHTS],
        };
        for (i, pl) in self.scene_lights.point.iter().take(MAX_POINT_LIGHTS).enumerate() {
            point_arr.lights[i] = GpuPointLight {
                position: pl.position,
                range: pl.range,
                color: pl.color,
                intensity: pl.intensity_lumens / Self::LUMENS_PER_UNIT,
            };
        }
        let Some(ref point_buf) = self.pbr_point_buf else {
            return;
        };
        queue.write_buffer(point_buf, 0, bytemuck::bytes_of(&point_arr));

        // Write spot lights
        let mut spot_arr = SpotLightArray {
            lights: [GpuSpotLight {
                position: [0.0; 3],
                range: 0.0,
                direction: [0.0; 3],
                intensity: 0.0,
                color: [0.0; 3],
                inner_cos: 0.0,
                outer_cos: 0.0,
                _gap: [0.0; 3],
                _pad: [0.0; 3],
                _struct_pad: 0.0,
            }; MAX_SPOT_LIGHTS],
        };
        for (i, sl) in self.scene_lights.spot.iter().take(MAX_SPOT_LIGHTS).enumerate() {
            spot_arr.lights[i] = GpuSpotLight {
                position: sl.position,
                range: sl.range,
                direction: sl.direction,
                intensity: sl.intensity_lumens / Self::LUMENS_PER_UNIT,
                color: sl.color,
                inner_cos: sl.inner_cos,
                outer_cos: sl.outer_cos,
                _gap: [0.0; 3],
                _pad: [0.0; 3],
                _struct_pad: 0.0,
            };
        }
        let Some(ref spot_buf) = self.pbr_spot_buf else { return };
        queue.write_buffer(spot_buf, 0, bytemuck::bytes_of(&spot_arr));
    }

    /// Create ring buffer and PBR bind group for dynamic uniform offset rendering.
    /// Requires `ensure_pbr_pipelines()` and `upload_pbr_lights()` to have been called.
    /// Safe to call every frame — no-op if already initialized.
    pub fn create_pbr_bind_groups(&mut self, device: &wgpu::Device) {
        let Some(ref pbr_bgl) = self.pbr_bgl_scene else { return };
        let Some(ref dir_buf) = self.pbr_directional_buf else {
            return;
        };
        let Some(ref point_buf) = self.pbr_point_buf else {
            return;
        };
        let Some(ref spot_buf) = self.pbr_spot_buf else { return };

        // Create ring buffer if not yet initialized
        if self.uniform_ring.is_none() {
            let ring_size = (MAX_GPU_OBJECTS as u64) * std::mem::size_of::<PbrUniforms>() as u64;
            let ring = device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("pbr_uniform_ring"),
                size: ring_size,
                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });

            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("pbr_ring_bg"),
                layout: pbr_bgl,
                entries: &[
                    wgpu::BindGroupEntry {
                        binding: 0,
                        resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
                            buffer: &ring,
                            offset: 0,
                            size: Some(std::num::NonZeroU64::new(std::mem::size_of::<PbrUniforms>() as u64).unwrap()),
                        }),
                    },
                    wgpu::BindGroupEntry {
                        binding: 1,
                        resource: dir_buf.as_entire_binding(),
                    },
                    wgpu::BindGroupEntry {
                        binding: 2,
                        resource: point_buf.as_entire_binding(),
                    },
                    wgpu::BindGroupEntry {
                        binding: 3,
                        resource: spot_buf.as_entire_binding(),
                    },
                ],
            });

            self.uniform_ring = Some(ring);
            self.uniform_ring_bg = Some(bg);
        }
    }

    /// Render all visible primitive objects using the PBR opaque pipeline.
    /// Traditional Render Pipeline (v1.0.0 LTS, RenderPath::Traditional).
    ///
    /// Per-object draw_indexed() with ring buffer and dynamic uniform offsets.
    /// Full PBR Cook-Torrance GGX with IBL split-sum, 3-level LOD, sorted by
    /// (mesh_kind, lod_level) for minimal buffer rebinds.
    ///
    /// CPU cost: O(N) — one staging + draw call per visible object.
    /// GPU cost: standard hardware rasterization via pbr.wgsl.
    ///
    /// This path works on all hardware. It is the fallback when
    /// INDIRECT_FIRST_INSTANCE or mesh shader features are unavailable.
    pub fn render_pbr(&mut self, queue: &wgpu::Queue, pass: &mut wgpu::RenderPass<'_>, camera: &Camera) {
        let Some(ref pipeline) = self.pbr_opaque_pipeline else {
            return;
        };
        let Some(ref material_bg) = self.pbr_default_material_bg else {
            return;
        };
        let Some(ref ibl_bg) = self.pbr_ibl_bind_group else {
            return;
        };
        let Some(ref ring_buf) = self.uniform_ring else { return };
        let Some(ref ring_bg) = self.uniform_ring_bg else {
            return;
        };

        if self.slots.is_empty() {
            return;
        }

        // Clean Compute: cap CPU traversal. Scenes > 256 objects should use GPU-driven path.
        let max_cpu_objects = self.slots.len().min(MAX_TRP_OBJECTS);

        let vp = mat4_mul(&camera.projection_matrix_array(), &camera.view_matrix_array());
        let cam_pos = camera.position_array();
        let pl_count = self.scene_lights.point.len().min(MAX_POINT_LIGHTS) as u32;
        let sl_count = self.scene_lights.spot.len().min(MAX_SPOT_LIGHTS) as u32;
        let dl_count = self.scene_lights.directional.len().min(MAX_DIRECTIONAL_LIGHTS) as u32;
        let uniform_size = std::mem::size_of::<PbrUniforms>();

        // Clean Compute: reuse pre-allocated staging buffer — no per-frame heap allocation.
        self.staging_buffer.clear();
        let mut draw_count = 0u32;

        // Clean Compute: reuse pre-allocated LOD selection buffer.
        self.slot_lod_buffer.clear();

        for i in 0..max_cpu_objects {
            let slot = &self.slots[i];
            if !slot.visible {
                continue;
            }

            // LOD selection based on camera distance
            let obj_pos = crate::lod::position_from_matrix(&slot.model_matrix);
            let dist_sq = crate::lod::distance_squared(cam_pos, obj_pos);
            let dist = dist_sq.sqrt();

            let lod_level = match self.lod_config.select(dist) {
                Some(level) => level,
                None => continue, // Beyond cull distance — skip
            };

            // DreamSurface: use per-material texture flags when available
            let (has_bc, has_nm, has_rm, has_ao, has_em, emissive_f, normal_s, occ_s) =
                if let Some(mat_idx) = slot.material_index {
                    if let Some(sm) = self.gltf_surface_materials.get(mat_idx) {
                        (
                            sm.has_base_color as u32,
                            sm.has_normal as u32,
                            sm.has_roughness_metalness as u32,
                            sm.has_occlusion as u32,
                            sm.has_emissive as u32,
                            sm.emissive_factor,
                            sm.normal_scale,
                            sm.occlusion_strength,
                        )
                    } else {
                        (0, 0, 0, 0, 0, [0.0; 3], 1.0, 1.0)
                    }
                } else {
                    (0, 0, 0, 0, 0, [0.0; 3], 1.0, 1.0)
                };

            let uniforms = PbrUniforms {
                view_proj: vp,
                model: slot.model_matrix,
                camera_pos: cam_pos,
                point_light_count: pl_count,
                spot_light_count: sl_count,
                directional_light_count: dl_count,
                _pad_counts: [0; 2],
                base_color_factor: slot._color,
                emissive_factor: emissive_f,
                emissive_strength: if emissive_f[0] > 0.0 || emissive_f[1] > 0.0 || emissive_f[2] > 0.0 {
                    1.0
                } else {
                    0.0
                },
                roughness: slot.roughness,
                metallic: slot.metallic,
                occlusion_strength: occ_s,
                normal_scale: normal_s,
                alpha_cutoff: 0.5,
                has_base_color_tex: has_bc,
                has_normal_tex: has_nm,
                has_roughness_metalness_tex: has_rm,
                has_occlusion_tex: has_ao,
                has_emissive_tex: has_em,
                _pad: [0.0; 6],
            };
            self.staging_buffer.extend_from_slice(bytemuck::bytes_of(&uniforms));
            self.slot_lod_buffer.push((i, lod_level));
            draw_count += 1;
        }

        if draw_count == 0 {
            return;
        }

        // Single contiguous upload to ring buffer
        queue.write_buffer(ring_buf, 0, &self.staging_buffer);

        // Draw grouped by mesh_kind + LOD — one vertex/index buffer bind per group.
        // Each draw uses a dynamic uniform offset into the ring buffer.
        pass.set_pipeline(pipeline);
        pass.set_bind_group(1, material_bg, &[]);
        pass.set_bind_group(2, ibl_bg, &[]);
        // @group(3): RT outputs (GI + shadow mask + config). Always bound — shader checks flags.
        if let Some(ref rt_bg) = self.pbr_rt_bind_group {
            pass.set_bind_group(3, rt_bg, &[]);
        }

        let mut draw_idx = 0u32;
        let mut current_mesh_bind: Option<(PrimitiveKind, LodLevel)> = None;

        for lod_i in 0..self.slot_lod_buffer.len() {
            let (slot_idx, lod_level) = self.slot_lod_buffer[lod_i];
            let slot = &self.slots[slot_idx];

            // Check if this slot has a glTF mesh (uploaded via upload_gltf).
            // glTF slots are mapped via gltf_slot_mesh_map to gltf_mesh_cache entries.
            let has_gltf_mesh = slot_idx < self.gltf_slot_mesh_map.len();

            if has_gltf_mesh {
                // Bind the real glTF vertex/index buffers for this primitive
                let cache_idx = self.gltf_slot_mesh_map[slot_idx];
                if cache_idx < self.gltf_mesh_cache.len() {
                    let gltf_mesh = &self.gltf_mesh_cache[cache_idx];
                    pass.set_vertex_buffer(0, gltf_mesh.vertex_buffer.slice(..));
                    pass.set_index_buffer(gltf_mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
                    // DreamSurface: per-material bind group with all 5 PBR textures
                    if let Some(mat_idx) = slot.material_index {
                        if let Some(sm) = self.gltf_surface_materials.get(mat_idx) {
                            pass.set_bind_group(1, &sm.bind_group, &[]);
                        }
                    }
                    let offset = draw_idx * uniform_size as u32;
                    pass.set_bind_group(0, ring_bg, &[offset]);
                    pass.draw_indexed(0..gltf_mesh.index_count, 0, 0..1);
                    draw_idx += 1;
                    // Restore default material bind group for next primitive draw
                    pass.set_bind_group(1, material_bg, &[]);
                    continue;
                }
            }

            // Standard primitive mesh path (LOD + base mesh lookup)
            let bind_key = (slot.mesh_kind, lod_level);
            if current_mesh_bind != Some(bind_key) {
                if let Some(lod_mesh) = self.lod_meshes.get(&slot.mesh_kind) {
                    pass.set_vertex_buffer(0, lod_mesh.vertex_buffer(lod_level).slice(..));
                    pass.set_index_buffer(lod_mesh.index_buffer(lod_level).slice(..), wgpu::IndexFormat::Uint32);
                } else if let Some(mesh) = self.meshes.get(&slot.mesh_kind) {
                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
                } else {
                    draw_idx += 1;
                    continue;
                }
                current_mesh_bind = Some(bind_key);
            }

            let index_count = if let Some(lod_mesh) = self.lod_meshes.get(&slot.mesh_kind) {
                lod_mesh.index_count(lod_level)
            } else if let Some(mesh) = self.meshes.get(&slot.mesh_kind) {
                mesh.index_count
            } else {
                draw_idx += 1;
                continue;
            };

            let offset = draw_idx * uniform_size as u32;
            pass.set_bind_group(0, ring_bg, &[offset]);
            pass.draw_indexed(0..index_count, 0, 0..1);
            draw_idx += 1;
        }
    }

    /// Build and upload indirect draw commands for multi-draw-indexed-indirect rendering.
    /// Returns the number of draw commands written.
    /// The indirect buffer can be consumed by `render_pbr_indirect()`.
    ///
    /// Clean Compute note: this function iterates all slots with CPU-side LOD/distance
    /// checks. For scenes exceeding MAX_TRP_OBJECTS, the GPU quantum cull pass
    /// (`quantum_cull.wgsl`) should replace this CPU work entirely — the compute
    /// shader performs per-meshlet frustum + LOD + distance culling and writes
    /// DrawIndexedIndirectArgs directly, eliminating CPU traversal.
    pub fn build_indirect_draws(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, camera: &Camera) -> u32 {
        if self.slots.is_empty() {
            return 0;
        }

        let cam_pos = camera.position_array();
        let args_size = std::mem::size_of::<DrawIndexedIndirectArgs>();

        // Clean Compute: reuse pre-allocated indirect args buffer — no per-frame heap allocation.
        self.indirect_args_buffer.clear();

        for i in 0..self.slots.len() {
            let slot = &self.slots[i];
            if !slot.visible {
                continue;
            }

            let obj_pos = crate::lod::position_from_matrix(&slot.model_matrix);
            let dist = crate::lod::distance_squared(cam_pos, obj_pos).sqrt();

            let lod_level = match self.lod_config.select(dist) {
                Some(level) => level,
                None => {
                    // Culled — write zero-instance draw
                    self.indirect_args_buffer.push(DrawIndexedIndirectArgs {
                        index_count: 0,
                        instance_count: 0,
                        first_index: 0,
                        base_vertex: 0,
                        first_instance: 0,
                    });
                    continue;
                }
            };

            let index_count = if let Some(lod_mesh) = self.lod_meshes.get(&slot.mesh_kind) {
                lod_mesh.index_count(lod_level)
            } else if let Some(mesh) = self.meshes.get(&slot.mesh_kind) {
                mesh.index_count
            } else {
                0
            };

            let first_instance = self.indirect_args_buffer.len() as u32;
            self.indirect_args_buffer.push(DrawIndexedIndirectArgs {
                index_count,
                instance_count: 1,
                first_index: 0,
                base_vertex: 0,
                first_instance,
            });
        }

        let draw_count = self.indirect_args_buffer.len() as u32;
        if draw_count == 0 {
            return 0;
        }

        // Ensure indirect buffer is large enough
        let needed_size = (draw_count as u64) * args_size as u64;
        let needs_recreate = match &self.indirect_draw_buf {
            Some(buf) => buf.size() < needed_size,
            None => true,
        };
        if needs_recreate {
            if let Some(old) = self.indirect_draw_buf.take() {
                old.destroy();
            }
            self.indirect_draw_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("scene_indirect_draws"),
                size: needed_size.max(args_size as u64),
                usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            }));
        }

        if let Some(ref buf) = self.indirect_draw_buf {
            queue.write_buffer(buf, 0, bytemuck::cast_slice(&self.indirect_args_buffer));
        }

        draw_count
    }

    /// Enable/disable multi-draw indirect support.
    /// Call with `true` if the adapter supports `wgpu::Features::MULTI_DRAW_INDIRECT`.
    pub fn set_multi_draw_support(&mut self, supported: bool) {
        self.multi_draw_supported = supported;
    }

    /// Update the RT + screen-space PBR bind group with real output views.
    /// Call after RT/screen-space passes have been dispatched, before render_pbr().
    /// When views are None, uses the fallback (disabled) textures.
    pub fn update_rt_bind_group(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        gi_view: Option<&wgpu::TextureView>,
        shadow_view: Option<&wgpu::TextureView>,
        rt_gi_enabled: bool,
        rt_shadow_enabled: bool,
        rt_gi_intensity: f32,
    ) {
        self.update_rt_bind_group_full(
            device,
            queue,
            gi_view,
            shadow_view,
            None,
            None,
            None,
            rt_gi_enabled,
            rt_shadow_enabled,
            rt_gi_intensity,
            false,
            false,
            false,
        );
    }

    /// Update the RT + screen-space PBR bind group with all optional views (DL-07 + TD-005).
    pub fn update_rt_bind_group_full(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        gi_view: Option<&wgpu::TextureView>,
        shadow_view: Option<&wgpu::TextureView>,
        ssao_view: Option<&wgpu::TextureView>,
        ssr_view: Option<&wgpu::TextureView>,
        ssgi_view: Option<&wgpu::TextureView>,
        rt_gi_enabled: bool,
        rt_shadow_enabled: bool,
        rt_gi_intensity: f32,
        ssao_enabled: bool,
        ssr_enabled: bool,
        ssgi_enabled: bool,
    ) {
        let Some(ref bgl) = self.pbr_bgl_rt else { return };
        let Some(ref config_buf) = self.rt_config_buf else {
            return;
        };

        // Update config uniform (RtPbrConfig: 8 u32s = 32 bytes)
        let config_data: [u32; 8] = [
            if rt_gi_enabled { 1 } else { 0 },
            if rt_shadow_enabled { 1 } else { 0 },
            rt_gi_intensity.to_bits(),
            if ssao_enabled { 1 } else { 0 },
            if ssr_enabled { 1 } else { 0 },
            if ssgi_enabled { 1 } else { 0 },
            0,
            0, // _pad vec2
        ];
        queue.write_buffer(config_buf, 0, bytemuck::cast_slice(&config_data));

        // Clean Compute: use pre-allocated fallback view (never create textures per-frame).
        let Some(ref fallback_v) = self.rt_fallback_view else {
            return;
        };

        // Build bind group with available views, using persistent fallback for missing ones.
        if let (Some(gi_v), Some(shadow_v)) = (gi_view, shadow_view) {
            let ssao_v = ssao_view.unwrap_or(fallback_v);
            let ssr_v = ssr_view.unwrap_or(fallback_v);
            let ssgi_v = ssgi_view.unwrap_or(fallback_v);

            self.pbr_rt_bind_group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
                label: Some("pbr_rt_active_bg"),
                layout: bgl,
                entries: &[
                    wgpu::BindGroupEntry {
                        binding: 0,
                        resource: wgpu::BindingResource::TextureView(gi_v),
                    },
                    wgpu::BindGroupEntry {
                        binding: 1,
                        resource: wgpu::BindingResource::TextureView(shadow_v),
                    },
                    wgpu::BindGroupEntry {
                        binding: 2,
                        resource: config_buf.as_entire_binding(),
                    },
                    wgpu::BindGroupEntry {
                        binding: 3,
                        resource: wgpu::BindingResource::TextureView(ssao_v),
                    },
                    wgpu::BindGroupEntry {
                        binding: 4,
                        resource: wgpu::BindingResource::TextureView(ssr_v),
                    },
                    wgpu::BindGroupEntry {
                        binding: 5,
                        resource: wgpu::BindingResource::TextureView(ssgi_v),
                    },
                ],
            }));
        }
    }

    /// Auto-detect the best render path for this device's capabilities.
    /// Returns GpuDriven if INDIRECT_FIRST_INSTANCE is available, Traditional otherwise.
    pub fn auto_detect_render_path(device: &wgpu::Device) -> RenderPath {
        let features = device.features();
        if features.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE) {
            log::info!("Auto-detected: INDIRECT_FIRST_INSTANCE available → Dreamwell pipeline");
            RenderPath::Dreamwell
        } else {
            log::info!("Auto-detected: INDIRECT_FIRST_INSTANCE unavailable → Traditional pipeline");
            RenderPath::Traditional
        }
    }

    /// Read-only access to directional light buffer (for DVR bind group creation).
    pub fn pbr_directional_buf_ref(&self) -> Option<&wgpu::Buffer> {
        self.pbr_directional_buf.as_ref()
    }

    /// Read-only access to point light buffer (for DVR bind group creation).
    pub fn pbr_point_buf_ref(&self) -> Option<&wgpu::Buffer> {
        self.pbr_point_buf.as_ref()
    }

    /// Get the merged mesh vertex and index buffers for DVR rendering.
    /// Returns None if no LOD meshes are loaded (DVR requires the merged buffer).
    pub fn merged_mesh_for_dvr(&self) -> Option<(&wgpu::Buffer, &wgpu::Buffer)> {
        // Use the first LOD mesh set's LOD 0 buffers as the merged buffer source.
        // In practice, the GpuDrivenPipeline's MergedMeshBuffer should be used,
        // but this provides a fallback from the scene's own mesh data.
        if let Some(lod_mesh) = self.lod_meshes.values().next() {
            Some((
                lod_mesh.vertex_buffer(crate::lod::LodLevel::Full),
                lod_mesh.index_buffer(crate::lod::LodLevel::Full),
            ))
        } else if let Some(mesh) = self.meshes.values().next() {
            Some((&mesh.vertex_buffer, &mesh.index_buffer))
        } else {
            None
        }
    }

    /// Number of PBR pipeline variants currently initialized (0 or 4).
    pub fn pbr_pipeline_count(&self) -> usize {
        let mut count = 0;
        if self.pbr_opaque_pipeline.is_some() {
            count += 1;
        }
        if self.pbr_opaque_double_sided_pipeline.is_some() {
            count += 1;
        }
        if self.pbr_masked_pipeline.is_some() {
            count += 1;
        }
        if self.pbr_transparent_pipeline.is_some() {
            count += 1;
        }
        count
    }

    // ── Pipeline builders ──────────────────────────────────────────────

    fn build_pick_pipeline(device: &wgpu::Device) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
        let shader_src = include_str!("../shaders/picking.wgsl");
        let (module, _info) = shader::load_wgsl(device, "scene_picking", shader_src);

        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("scene_pick_bgl"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("scene_pick_layout"),
            bind_group_layouts: &[Some(&bind_group_layout)],
            immediate_size: 0,
        });

        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("scene_pick_pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &module,
                entry_point: Some("vs_main"),
                compilation_options: Default::default(),
                buffers: &[PBR_VERTEX_LAYOUT],
            },
            fragment: Some(wgpu::FragmentState {
                module: &module,
                entry_point: Some("fs_main"),
                compilation_options: Default::default(),
                targets: &[Some(wgpu::ColorTargetState {
                    format: wgpu::TextureFormat::Rgba32Uint,
                    blend: None,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                front_face: wgpu::FrontFace::Ccw,
                cull_mode: Some(wgpu::Face::Back),
                ..Default::default()
            },
            depth_stencil: Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                depth_write_enabled: Some(true),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
            multisample: wgpu::MultisampleState::default(),
            multiview_mask: None,
            cache: None,
        });

        (pipeline, bind_group_layout)
    }
}

// ── PBR pipeline variant builder ──────────────────────────────────────

fn build_pbr_pipeline_variant(
    device: &wgpu::Device,
    layout: &wgpu::PipelineLayout,
    module: &wgpu::ShaderModule,
    surface_format: wgpu::TextureFormat,
    cull_mode: Option<wgpu::Face>,
    depth_write: bool,
    blend: Option<wgpu::BlendState>,
    label: &str,
    sample_count: u32,
) -> wgpu::RenderPipeline {
    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
        label: Some(label),
        layout: Some(layout),
        vertex: wgpu::VertexState {
            module,
            entry_point: Some("vs_main"),
            compilation_options: Default::default(),
            buffers: &[PBR_VERTEX_LAYOUT],
        },
        fragment: Some(wgpu::FragmentState {
            module,
            entry_point: Some("fs_main"),
            compilation_options: Default::default(),
            targets: &[Some(wgpu::ColorTargetState {
                format: surface_format,
                blend,
                write_mask: wgpu::ColorWrites::ALL,
            })],
        }),
        primitive: wgpu::PrimitiveState {
            topology: wgpu::PrimitiveTopology::TriangleList,
            front_face: wgpu::FrontFace::Ccw,
            cull_mode,
            ..Default::default()
        },
        depth_stencil: Some(wgpu::DepthStencilState {
            format: DEPTH_FORMAT,
            depth_write_enabled: Some(depth_write),
            depth_compare: Some(wgpu::CompareFunction::Less),
            stencil: wgpu::StencilState::default(),
            bias: wgpu::DepthBiasState::default(),
        }),
        multisample: wgpu::MultisampleState {
            count: sample_count,
            mask: !0,
            alpha_to_coverage_enabled: false,
        },
        multiview_mask: None,
        cache: None,
    })
}

// ── Camera helpers ─────────────────────────────────────────────────────

trait CameraArrays {
    fn view_matrix_array(&self) -> [f32; 16];
    fn projection_matrix_array(&self) -> [f32; 16];
    fn position_array(&self) -> [f32; 3];
}

impl CameraArrays for Camera {
    fn view_matrix_array(&self) -> [f32; 16] {
        self.view_matrix.to_cols_array()
    }
    fn projection_matrix_array(&self) -> [f32; 16] {
        self.projection_matrix.to_cols_array()
    }
    fn position_array(&self) -> [f32; 3] {
        self.position.to_array()
    }
}

fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
    dreamwell_engine::transform::mat4_mul(a, b)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn engine_to_gpu_shape_roundtrip() {
        for kind in PrimitiveKind::ALL {
            let shape = engine_to_gpu_shape(*kind);
            assert_eq!(shape.name(), kind.name());
        }
    }

    #[test]
    fn mat4_identity_mul() {
        #[rustfmt::skip]
        let id = [
            1.0, 0.0, 0.0, 0.0,
            0.0, 1.0, 0.0, 0.0,
            0.0, 0.0, 1.0, 0.0,
            0.0, 0.0, 0.0, 1.0,
        ];
        let result = mat4_mul(&id, &id);
        for (a, b) in result.iter().zip(id.iter()) {
            assert!((a - b).abs() < 1e-6);
        }
    }

    #[test]
    fn max_gpu_objects_matches_engine() {
        assert_eq!(MAX_GPU_OBJECTS, dreamwell_engine::game_object::MAX_SCENE_OBJECTS);
    }

    #[test]
    fn pbr_uniforms_size() {
        // PbrUniforms must be a multiple of 16 bytes for std140 alignment.
        let size = std::mem::size_of::<PbrUniforms>();
        assert_eq!(size % 16, 0, "PbrUniforms size ({size}) must be 16-byte aligned");
    }

    #[test]
    fn gpu_directional_light_size() {
        assert_eq!(std::mem::size_of::<GpuDirectionalLight>(), 32);
    }

    #[test]
    fn gpu_spot_light_size() {
        assert_eq!(std::mem::size_of::<GpuSpotLight>(), 80);
    }

    #[test]
    fn max_lights_match_scene_lights() {
        assert_eq!(MAX_POINT_LIGHTS, 64);
        assert_eq!(MAX_SPOT_LIGHTS, 32);
        assert_eq!(MAX_DIRECTIONAL_LIGHTS, 4);
        // Verify consistency with SceneLights constants.
        assert_eq!(MAX_POINT_LIGHTS, crate::lights::SceneLights::MAX_POINT);
        assert_eq!(MAX_SPOT_LIGHTS, crate::lights::SceneLights::MAX_SPOT);
        assert_eq!(MAX_DIRECTIONAL_LIGHTS, crate::lights::SceneLights::MAX_DIRECTIONAL);
    }
}
