// GPU-driven rendering — storage buffer objects + multi_draw_indexed_indirect.
//
// Replaces per-object draw_indexed() loop with:
//   1. Upload all object data to a storage buffer (one write_buffer per frame)
//   2. GPU compute shader builds DrawIndexedIndirectArgs (frustum cull + LOD)
//   3. One multi_draw_indexed_indirect() call per mesh group
//
// The PBR vertex shader reads objects[instance_index] from the storage buffer.
// CPU cost: O(1) draw calls. GPU handles visibility + LOD + draw arg generation.
//
// Requires: Features::INDIRECT_FIRST_INSTANCE (for instance_index addressing).
// Falls back to per-object render_pbr() when unavailable.

use crate::camera::Camera;
use crate::lod::LodConfig;
use crate::primitives::PrimitiveShape;
use dreamwell_engine::game_object::PrimitiveKind;

/// Per-object data uploaded to GPU storage buffer (128 bytes, std430).
/// The PBR vertex shader reads objects[instance_index] to get per-object transforms and material.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuObjectData {
    pub model: [f32; 16],          // 64 bytes — world transform
    pub base_color: [f32; 4],      // 16 bytes — RGBA material color
    pub roughness: f32,            // 4 bytes
    pub metallic: f32,             // 4 bytes
    pub emissive_strength: f32,    // 4 bytes
    pub normal_scale: f32,         // 4 bytes
    pub bounding_center: [f32; 3], // 12 bytes — world-space center for GPU cull
    pub bounding_radius: f32,      // 4 bytes
    pub mesh_id: u32,              // 4 bytes — index into mesh descriptor table
    pub _pad: [u32; 3],            // 12 bytes — align to 128
}

/// Per-mesh descriptor (16 bytes). Maps mesh_id to index/vertex offsets
/// in the merged buffer. Used by the GPU cull shader to write correct
/// DrawIndexedIndirectArgs.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MeshDescriptor {
    pub index_count: u32,
    pub first_index: u32,
    pub base_vertex: i32,
    pub _pad: u32,
}

/// Scene-wide uniforms for GPU-driven path (shared per frame, not per object).
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuSceneUniforms {
    pub view_proj: [f32; 16],         // 64 bytes
    pub camera_pos: [f32; 3],         // 12 bytes
    pub point_light_count: u32,       // 4 bytes
    pub spot_light_count: u32,        // 4 bytes
    pub directional_light_count: u32, // 4 bytes
    pub object_count: u32,            // 4 bytes
    pub _pad: u32,                    // 4 bytes — 96 bytes total
}

/// GPU cull parameters for the draw-builder compute shader.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuCullParams {
    pub view_proj: [f32; 16], // 64 bytes — for frustum extraction
    pub camera_pos: [f32; 3], // 12 bytes
    pub object_count: u32,    // 4 bytes
    pub lod1_distance: f32,   // 4 bytes
    pub lod2_distance: f32,   // 4 bytes
    pub cull_distance: f32,   // 4 bytes
    pub _pad: f32,            // 4 bytes — 96 bytes total
}

/// Instanced draw command — same layout as DrawIndexedIndirectArgs (20 bytes).
/// Represents a group of objects sharing the same mesh, drawn in one call.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct InstancedDrawCommand {
    pub index_count: u32,
    pub instance_count: u32,
    pub first_index: u32,
    pub base_vertex: i32,
    pub first_instance: u32,
}

/// Merged vertex+index buffer for all primitive meshes at all LOD levels.
/// Each (PrimitiveKind, LodLevel) maps to a MeshDescriptor.
pub struct MergedMeshBuffer {
    pub vertex_buffer: wgpu::Buffer,
    pub index_buffer: wgpu::Buffer,
    /// Mesh descriptors: mesh_id → (index_count, first_index, base_vertex).
    /// Layout: PrimitiveKind::ALL.len() × LodLevel::COUNT entries.
    /// mesh_id = kind_index * 3 + lod_level
    pub descriptors: Vec<MeshDescriptor>,
    pub descriptor_buffer: wgpu::Buffer,
    pub total_vertices: u32,
    pub total_indices: u32,
}

impl MergedMeshBuffer {
    /// Build a merged vertex+index buffer containing all primitive shapes at all LOD levels.
    pub fn build(device: &wgpu::Device) -> Self {
        use crate::lod::LodLevel;
        use crate::primitives::{PbrVertex, DEFAULT_PRIMITIVE_COLOR};
        use wgpu::util::DeviceExt;

        let mut all_vertices: Vec<PbrVertex> = Vec::new();
        let mut all_indices: Vec<u32> = Vec::new();
        let mut descriptors: Vec<MeshDescriptor> = Vec::new();

        for shape in PrimitiveShape::ALL {
            let lod_set = shape.generate_lod_set(DEFAULT_PRIMITIVE_COLOR);

            for lod in LodLevel::ALL {
                let mesh = lod_set.mesh(lod);
                let base_vertex = all_vertices.len() as i32;
                let first_index = all_indices.len() as u32;
                let index_count = mesh.indices.len() as u32;

                all_vertices.extend_from_slice(&mesh.vertices);
                all_indices.extend_from_slice(&mesh.indices);

                descriptors.push(MeshDescriptor {
                    index_count,
                    first_index,
                    base_vertex,
                    _pad: 0,
                });
            }
        }

        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("merged_vertex_buffer"),
            contents: bytemuck::cast_slice(&all_vertices),
            usage: wgpu::BufferUsages::VERTEX,
        });

        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("merged_index_buffer"),
            contents: bytemuck::cast_slice(&all_indices),
            usage: wgpu::BufferUsages::INDEX,
        });

        let descriptor_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("mesh_descriptors"),
            contents: bytemuck::cast_slice(&descriptors),
            usage: wgpu::BufferUsages::STORAGE,
        });

        let total_vertices = all_vertices.len() as u32;
        let total_indices = all_indices.len() as u32;

        log::info!(
            "Merged mesh buffer: {} shapes × {} LODs = {} descriptors, {} vertices, {} indices",
            PrimitiveShape::ALL.len(),
            LodLevel::COUNT,
            descriptors.len(),
            total_vertices,
            total_indices,
        );

        Self {
            vertex_buffer,
            index_buffer,
            descriptors,
            descriptor_buffer,
            total_vertices,
            total_indices,
        }
    }

    /// Build a merged vertex+index buffer using PrimitiveVertex (40 bytes).
    /// Used by the DVR dreamlet pipeline which doesn't need tangent/texcoord.
    /// Same LOD mesh descriptors as `build()`, but vertex data is 40B instead of 64B.
    pub fn build_primitive(device: &wgpu::Device) -> Self {
        use crate::lod::LodLevel;
        use crate::primitives::{PrimitiveVertex, DEFAULT_PRIMITIVE_COLOR};
        use wgpu::util::DeviceExt;

        let mut all_vertices: Vec<PrimitiveVertex> = Vec::new();
        let mut all_indices: Vec<u32> = Vec::new();
        let mut descriptors: Vec<MeshDescriptor> = Vec::new();

        for shape in PrimitiveShape::ALL {
            let lod_set = shape.generate_lod_set(DEFAULT_PRIMITIVE_COLOR);

            for lod in LodLevel::ALL {
                let mesh = lod_set.mesh(lod);
                let base_vertex = all_vertices.len() as i32;
                let first_index = all_indices.len() as u32;
                let index_count = mesh.indices.len() as u32;

                // Convert PbrVertex → PrimitiveVertex (drop tangent/texcoord)
                all_vertices.extend(mesh.vertices.iter().map(|v| PrimitiveVertex {
                    position: v.position,
                    normal: v.normal,
                    color: v.color,
                }));
                all_indices.extend_from_slice(&mesh.indices);

                descriptors.push(MeshDescriptor {
                    index_count,
                    first_index,
                    base_vertex,
                    _pad: 0,
                });
            }
        }

        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("merged_primitive_vertex_buffer"),
            contents: bytemuck::cast_slice(&all_vertices),
            usage: wgpu::BufferUsages::VERTEX,
        });

        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("merged_primitive_index_buffer"),
            contents: bytemuck::cast_slice(&all_indices),
            usage: wgpu::BufferUsages::INDEX,
        });

        let descriptor_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("mesh_descriptors_primitive"),
            contents: bytemuck::cast_slice(&descriptors),
            usage: wgpu::BufferUsages::STORAGE,
        });

        let total_vertices = all_vertices.len() as u32;
        let total_indices = all_indices.len() as u32;

        log::info!(
            "Merged primitive mesh buffer: {} shapes × {} LODs = {} descriptors, {} vertices (40B), {} indices",
            PrimitiveShape::ALL.len(),
            LodLevel::COUNT,
            descriptors.len(),
            total_vertices,
            total_indices,
        );

        Self {
            vertex_buffer,
            index_buffer,
            descriptors,
            descriptor_buffer,
            total_vertices,
            total_indices,
        }
    }

    /// Get the mesh_id for a given primitive kind and LOD level.
    pub fn mesh_id(kind: PrimitiveKind, lod: crate::lod::LodLevel) -> u32 {
        let kind_index = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap_or(0);
        (kind_index as u32) * 3 + (lod as u32)
    }

    /// Get the mesh_id for a given primitive kind at LOD 0 (full detail).
    pub fn mesh_id_lod0(kind: PrimitiveKind) -> u32 {
        Self::mesh_id(kind, crate::lod::LodLevel::Full)
    }

    pub fn destroy(self) {
        self.vertex_buffer.destroy();
        self.index_buffer.destroy();
        self.descriptor_buffer.destroy();
    }
}

/// GPU-driven draw pipeline state.
pub struct GpuDrivenPipeline {
    // Object data
    pub object_buffer: Option<wgpu::Buffer>,
    pub object_count: u32,
    pub object_capacity: u32,

    // Indirect draw args (written by GPU cull shader)
    pub indirect_buffer: Option<wgpu::Buffer>,

    // Merged mesh
    pub merged_mesh: Option<MergedMeshBuffer>,

    // GPU cull compute pipeline
    pub cull_pipeline: Option<wgpu::ComputePipeline>,
    pub cull_bgl: Option<wgpu::BindGroupLayout>,

    // Scene uniform buffer (shared per-frame data)
    pub scene_uniform_buf: Option<wgpu::Buffer>,

    // Feature support
    pub enabled: bool,
}

impl Default for GpuDrivenPipeline {
    fn default() -> Self {
        Self {
            object_buffer: None,
            object_count: 0,
            object_capacity: 0,
            indirect_buffer: None,
            merged_mesh: None,
            cull_pipeline: None,
            cull_bgl: None,
            scene_uniform_buf: None,
            enabled: false,
        }
    }
}

impl GpuDrivenPipeline {
    /// Initialize the GPU-driven pipeline (merged meshes, cull shader, buffers).
    pub fn init(&mut self, device: &wgpu::Device, max_objects: u32) {
        // Build merged mesh buffer
        self.merged_mesh = Some(MergedMeshBuffer::build(device));

        // Object data storage buffer
        let obj_size = (max_objects as u64) * std::mem::size_of::<GpuObjectData>() as u64;
        self.object_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("gpu_object_data"),
            size: obj_size.max(128),
            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        }));
        self.object_capacity = max_objects;

        // Indirect draw args buffer
        // DrawIndexedIndirectArgs is 20 bytes (5 × u32)
        let indirect_size = (max_objects as u64) * 20;
        self.indirect_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("gpu_indirect_draws"),
            size: indirect_size.max(20),
            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        }));

        // Scene uniform buffer
        self.scene_uniform_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("gpu_scene_uniforms"),
            size: std::mem::size_of::<GpuSceneUniforms>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        }));

        // GPU cull compute pipeline
        let cull_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("gpu_cull_bgl"),
            entries: &[
                // binding 0: cull params (uniform)
                wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // binding 1: object data (storage, read-only)
                wgpu::BindGroupLayoutEntry {
                    binding: 1,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // binding 2: mesh descriptors (storage, read-only)
                wgpu::BindGroupLayoutEntry {
                    binding: 2,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: true },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
                // binding 3: indirect draw args (storage, read-write)
                wgpu::BindGroupLayoutEntry {
                    binding: 3,
                    visibility: wgpu::ShaderStages::COMPUTE,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Storage { read_only: false },
                        has_dynamic_offset: false,
                        min_binding_size: None,
                    },
                    count: None,
                },
            ],
        });

        let cull_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("gpu_cull.wgsl"),
            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/gpu_cull.wgsl").into()),
        });

        let cull_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("gpu_cull_layout"),
            bind_group_layouts: &[Some(&cull_bgl)],
            immediate_size: 0,
        });

        self.cull_pipeline = Some(device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
            label: Some("gpu_cull_compute"),
            layout: Some(&cull_layout),
            module: &cull_shader,
            entry_point: Some("cull_main"),
            compilation_options: Default::default(),
            cache: None,
        }));

        self.cull_bgl = Some(cull_bgl);
        self.enabled = true;

        log::info!("GPU-driven pipeline initialized (max {} objects)", max_objects);
    }

    /// Upload object data to GPU storage buffer. Call once at scene load,
    /// or when objects are added/removed. NOT per-frame.
    /// Clamps to buffer capacity if objects exceed the allocated limit.
    pub fn upload_objects(&self, queue: &wgpu::Queue, objects: &[GpuObjectData]) {
        let Some(ref obj_buf) = self.object_buffer else { return };
        if objects.is_empty() {
            return;
        }
        let safe_count = objects.len().min(self.object_capacity as usize);
        if objects.len() > self.object_capacity as usize {
            log::warn!(
                "gpu_driven: {} objects exceeds capacity {}, clamping",
                objects.len(),
                self.object_capacity
            );
        }
        queue.write_buffer(obj_buf, 0, bytemuck::cast_slice(&objects[..safe_count]));
    }

    /// Dispatch GPU cull compute pass. Only uploads camera uniform (96 bytes).
    /// Object data must already be on the GPU via `upload_objects()`.
    ///
    /// CPU cost: O(1) — one 96-byte uniform write + one compute dispatch.
    /// GPU cost: O(N/64) workgroups — each thread culls one object.
    pub fn dispatch_cull_gpu_only(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        object_count: u32,
        camera: &Camera,
        lod_config: &LodConfig,
    ) {
        let Some(ref obj_buf) = self.object_buffer else { return };
        let Some(ref indirect_buf) = self.indirect_buffer else {
            return;
        };
        let Some(ref mesh) = self.merged_mesh else { return };
        let Some(ref cull_pipe) = self.cull_pipeline else {
            return;
        };
        let Some(ref cull_bgl) = self.cull_bgl else { return };
        let Some(ref scene_buf) = self.scene_uniform_buf else {
            return;
        };

        if object_count == 0 {
            return;
        }

        // ONLY write: 96-byte camera/frustum uniform. Object data is persistent.
        let vp = (camera.projection_matrix * camera.view_matrix).to_cols_array();
        let cull_params = GpuCullParams {
            view_proj: vp,
            camera_pos: camera.position.to_array(),
            object_count,
            lod1_distance: lod_config.lod1_distance,
            lod2_distance: lod_config.lod2_distance,
            cull_distance: lod_config.cull_distance,
            _pad: 0.0,
        };
        queue.write_buffer(scene_buf, 0, bytemuck::bytes_of(&cull_params));

        // Create bind group (lightweight CPU descriptor, no GPU alloc)
        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("gpu_cull_bg"),
            layout: cull_bgl,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: scene_buf.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: obj_buf.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 2,
                    resource: mesh.descriptor_buffer.as_entire_binding(),
                },
                wgpu::BindGroupEntry {
                    binding: 3,
                    resource: indirect_buf.as_entire_binding(),
                },
            ],
        });

        // Dispatch cull — GPU builds all DrawIndexedIndirectArgs
        let workgroups = object_count.div_ceil(64);
        let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
            label: Some("gpu_cull"),
            timestamp_writes: None,
        });
        pass.set_pipeline(cull_pipe);
        pass.set_bind_group(0, &bg, &[]);
        pass.dispatch_workgroups(workgroups, 1, 1);
    }

    /// Legacy: Upload object data AND dispatch cull in one call.
    /// Use `upload_objects` + `dispatch_cull_gpu_only` for the zero-CPU path.
    pub fn dispatch_cull(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        objects: &[GpuObjectData],
        camera: &Camera,
        lod_config: &LodConfig,
    ) {
        self.upload_objects(queue, objects);
        self.dispatch_cull_gpu_only(device, queue, encoder, objects.len() as u32, camera, lod_config);
    }

    /// Render using multi_draw_indexed_indirect. Objects were culled by dispatch_cull.
    /// Clamps object_count to allocated capacity to prevent indirect buffer overflow.
    pub fn render(&self, pass: &mut wgpu::RenderPass<'_>, object_count: u32) {
        let Some(ref mesh) = self.merged_mesh else { return };
        let Some(ref indirect_buf) = self.indirect_buffer else {
            return;
        };

        if object_count == 0 {
            return;
        }

        let safe_count = object_count.min(self.object_capacity);

        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
        pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
        pass.multi_draw_indexed_indirect(indirect_buf, 0, safe_count);
    }

    /// Build instanced indirect draw commands on CPU.
    /// Groups objects by mesh_id (LOD-resolved), producing one draw per mesh group
    /// with instance_count equal to the number of visible objects in that group.
    ///
    /// Objects MUST be sorted by mesh_id before calling this method.
    /// Returns (draw_count, draw_commands) for use with multi_draw_indexed_indirect.
    pub fn build_instanced_draws(
        &self,
        objects: &[GpuObjectData],
        camera: &Camera,
        lod_config: &crate::lod::LodConfig,
    ) -> (u32, Vec<InstancedDrawCommand>) {
        if objects.is_empty() {
            return (0, Vec::new());
        }
        let Some(ref mesh) = self.merged_mesh else {
            return (0, Vec::new());
        };

        let cam_pos = camera.position.to_array();
        let mut groups: Vec<InstancedDrawCommand> = Vec::new();

        // Determine per-object visibility and effective mesh descriptor
        struct VisibleObject {
            obj_index: u32,
            desc_index: u32, // mesh_id + lod offset
        }
        let mut visible: Vec<VisibleObject> = Vec::with_capacity(objects.len());

        for (i, obj) in objects.iter().enumerate() {
            let dx = obj.bounding_center[0] - cam_pos[0];
            let dy = obj.bounding_center[1] - cam_pos[1];
            let dz = obj.bounding_center[2] - cam_pos[2];
            let dist = (dx * dx + dy * dy + dz * dz).sqrt();

            let lod = match lod_config.select(dist) {
                Some(l) => l,
                None => continue, // culled
            };

            let desc_index = obj.mesh_id + lod as u32;
            visible.push(VisibleObject {
                obj_index: i as u32,
                desc_index,
            });
        }

        // Sort by descriptor index for grouping (stable sort preserves object order)
        visible.sort_by_key(|v| v.desc_index);

        // Group consecutive objects with the same descriptor
        let mut i = 0;
        while i < visible.len() {
            let desc_idx = visible[i].desc_index;
            let first_instance = visible[i].obj_index;
            let mut count = 1u32;

            // Count consecutive objects with the same mesh descriptor
            while i + (count as usize) < visible.len() && visible[i + (count as usize)].desc_index == desc_idx {
                count += 1;
            }

            let desc = &mesh.descriptors[desc_idx as usize];
            groups.push(InstancedDrawCommand {
                index_count: desc.index_count,
                instance_count: count,
                first_index: desc.first_index,
                base_vertex: desc.base_vertex,
                first_instance,
            });

            i += count as usize;
        }

        let draw_count = groups.len() as u32;
        (draw_count, groups)
    }

    /// Upload instanced draw commands and render with multi_draw_indexed_indirect.
    pub fn upload_instanced_draws(&self, queue: &wgpu::Queue, commands: &[InstancedDrawCommand]) {
        let Some(ref buf) = self.indirect_buffer else { return };
        if commands.is_empty() {
            return;
        }
        queue.write_buffer(buf, 0, bytemuck::cast_slice(commands));
    }

    pub fn destroy(&mut self) {
        if let Some(buf) = self.object_buffer.take() {
            buf.destroy();
        }
        if let Some(buf) = self.indirect_buffer.take() {
            buf.destroy();
        }
        if let Some(buf) = self.scene_uniform_buf.take() {
            buf.destroy();
        }
        if let Some(mesh) = self.merged_mesh.take() {
            mesh.destroy();
        }
        self.enabled = false;
    }
}

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

    #[test]
    fn gpu_object_data_size() {
        assert_eq!(std::mem::size_of::<GpuObjectData>(), 128);
    }

    #[test]
    fn mesh_descriptor_size() {
        assert_eq!(std::mem::size_of::<MeshDescriptor>(), 16);
    }

    #[test]
    fn gpu_scene_uniforms_size() {
        assert_eq!(std::mem::size_of::<GpuSceneUniforms>(), 96);
    }

    #[test]
    fn gpu_cull_params_size() {
        assert_eq!(std::mem::size_of::<GpuCullParams>(), 96);
    }

    #[test]
    fn instanced_draw_command_size() {
        assert_eq!(std::mem::size_of::<InstancedDrawCommand>(), 20);
    }

    #[test]
    fn mesh_id_layout() {
        // 9 shapes × 3 LODs = 27 entries
        assert_eq!(
            MergedMeshBuffer::mesh_id(PrimitiveKind::Cube, crate::lod::LodLevel::Full),
            0
        );
        assert_eq!(
            MergedMeshBuffer::mesh_id(PrimitiveKind::Cube, crate::lod::LodLevel::Medium),
            1
        );
        assert_eq!(
            MergedMeshBuffer::mesh_id(PrimitiveKind::Cube, crate::lod::LodLevel::Low),
            2
        );
        assert_eq!(
            MergedMeshBuffer::mesh_id(PrimitiveKind::Sphere, crate::lod::LodLevel::Full),
            3
        );
    }
}
