//! # Renderer Module — Executor Layer (WGPU Command Execution) //! //! The **Executor** layer of the architecture. Executes WGPU rendering commands — orchestrates draw calls by binding //! Material pipelines and Mesh vertex buffers into a RenderPass, then submits commands to the GPU queue. //! Does not own hardware resources (Device, Queue); receives references when called by the orchestrator (main.rs). //! Does not own RenderPipelines or shaders — those are managed by PipelineCache and accessed through Material. //! Does not own Surface/TextureView — acquired from Context::begin_frame(). //! //! ## Interaction with Other Modules //! - **context**: receives Device/Queue references and TextureView; does not call begin/end_frame itself. //! - **pipeline_cache**: indirectly via Material — Renderer uses pipelines that PipelineCache compiled. //! - **mesh**: passes vertex/index buffers into set_vertex_buffer/set_index_buffer during draw. //! - **material**: provides the RenderPipeline reference via set_pipeline during draw. //! //! ## Architecture Notes (per ARCHI_APP.md) //! - **Execution Phase**: Renderer executes per-frame render loops. During this phase it iterates Scene entities //! and draws each one by binding the appropriate Material+Mesh pair. //! - **Performance**: the main pass batches draws by material (Étape 18), so the pipeline + //! texture state changes happen once per distinct material, not once per entity. //! - **Low-Level Access**: Advanced users can bypass Scene and call Renderer directly for custom rendering paths. use crate::camera::Camera; use crate::core::Context; use crate::lights::{Lights, MAX_LIGHTS}; use crate::core::Frame; use crate::core::Frustum; use crate::core::lod::{lod_level, projected_radius_px}; use crate::pipeline::{ DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout, create_shadow_uniform_layout, create_uniform_bind_group_layouts, }; use crate::resources::uniform::{ BBOX_SLOT_SIZE, BBoxSlot, CULL_UNIFORMS_SIZE, DRAW_SLOT_SIZE, DrawSlot, FRAME_UNIFORMS_SIZE, LOD_TABLE_SIZE, LodTable, MAT_SLOT_SIZE, MatSlot, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE, TRANSFORM_SLOT_SIZE, TransformSlot, }; use crate::resources::{ CullUniforms, FrameUniforms, Material, Mesh, ObjectUniform, ShadowUniform, }; use crate::scene::Scene; use crate::core::bloom::{BloomConfig, BloomPipeline}; use crate::core::hdr::ToneMapper; use crate::core::msaa::MsaaConfig; use crate::utils::conf::{ GPU_DRIVEN_SHADER, GPU_WORKGROUP_SIZE, LOD_THRESHOLDS, MAX_ENTITIES, MAX_LOD_LEVELS, TONEMAP_SHADER, }; use glam::{Mat4, Quat, Vec3, Vec4}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::hash::Hash; use std::sync::Arc; /// The Executor layer of the architecture. Holds shared references to Device and Queue from Context, /// plus the surface texture format. Executes WGPU rendering commands by binding Materials and Meshes /// into RenderPasses during each frame. Does not own raw hardware resources (they are Arc-cloned from Context). /// Since Step 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the /// Renderer owns the matching GPU buffers and `BindGroup`s and binds them around every draw call. pub struct Renderer { /// GPU command submission queue — holds an Arc clone from Context; shared with other Context users. queue: wgpu::Queue, /// GPU device — creates buffers, textures, pipelines; holds an Arc clone from Context. device: wgpu::Device, /// Surface texture output format — stored here so it can be passed to PipelineCache on Material creation. format: wgpu::TextureFormat, /// z-buffer texture backing `depth_view` (Step 9). Held here only to keep the GPU resource /// alive for the whole application lifetime (a `TextureView` alone does not guarantee the /// underlying `Texture` stays valid in wgpu). Not read directly (hence `_` prefix → no /// `dead_code`); reused when the depth texture is recreated at resize (ROADMAP Phase 4.4). _depth_texture: wgpu::Texture, /// Depth attachment view used by both render passes (`render`, `render_scene`). Format /// `DEPTH_FORMAT` (Depth32Float) — matches every pipeline's `DepthStencilState` (D1). depth_view: wgpu::TextureView, /// Shared per-frame uniform buffer handle — kept so the camera matrices can be rewritten each /// frame (`render_scene`) and shipped to the GPU before the frame bind group is used. frame_buffer: wgpu::Buffer, /// Shared per-frame uniform buffer + bind group (camera + lights). Written each frame (`render_scene`). frame_bind_group: wgpu::BindGroup, /// Shared per-object bind group (identity model) used by the low-level `render` path. shared_object_bind_group: wgpu::BindGroup, /// Flat (unlit) rendering flag, exposed via [`Renderer::set_unlit`]. When true, `options.x` of the /// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D /// rendering is thus a special case of the 3D lit path (DRAFT Step 5). Defaults to `false` (lit). unlit: bool, // Step 14 (DRAFT 3.2) — shadow mapping resources, owned by the Renderer like the depth texture. /// Backing GPU shadow-map texture (D2), kept alive for the whole application lifetime. Sized /// `SHADOW_MAP_SIZE²`, `DEPTH_FORMAT`, used as the shadow pass depth attachment **and** bound /// for sampling in the main pass (`RENDER_ATTACHMENT | TEXTURE_BINDING`). _shadow_texture: wgpu::Texture, /// Depth view of the shadow map, bound into `shadow_bind_group` (group 3) for the PCF test. shadow_view: wgpu::TextureView, /// Group-3 bind group (comparison sampler + shadow depth texture) bound on every main draw call. shadow_bind_group: wgpu::BindGroup, /// Per-frame uniform buffer holding the shadow-casting light's `view_proj` (D3). Rewritten /// each frame before the shadow pass so the depth-only pipeline sees the current light pose. shadow_uniform_buffer: wgpu::Buffer, /// Group-0 bind group of the shadow pipeline (the light `view_proj`, D4). shadow_uniform_bind_group: wgpu::BindGroup, /// Depth-only pipeline rendering the scene from the shadow light's point of view (D4). shadow_pipeline: wgpu::RenderPipeline, // ---- Phase 3 (Step 15) — GPU-driven rendering: compute pipelines + slot buffers + bind groups ---- /// Compute pipeline deriving per-entity world matrices from the transform buffer (Step 15.5). compute_matrices_pipeline: wgpu::ComputePipeline, /// Compute pipeline culling entities + filling the indirect draw args (Step 15.6). cull_pipeline: wgpu::ComputePipeline, /// GPU world-matrix slots (`STORAGE | COPY_DST`), written by `compute_matrices`, bound per-slot /// via `matrix_object_bg`. matrix_buffer: wgpu::Buffer, /// GPU transform slots (`STORAGE | COPY_DST`), read by both compute passes; rewritten by the CPU each frame. transform_buffer: wgpu::Buffer, /// GPU local-space bounding boxes (`STORAGE | COPY_DST`), read by `cull`; uploaded once per mesh set. bbox_buffer: wgpu::Buffer, /// GPU indirect draw args (`STORAGE | INDIRECT`), written by `cull`, read by the indirect renders. draw_args_buffer: wgpu::Buffer, /// GPU cull uniforms (`UNIFORM | COPY_DST`): frustum planes + control flags; rewritten each frame. cull_uniform_buffer: wgpu::Buffer, /// GPU per-slot LOD levels (`STORAGE | COPY_DST`): one u32 per entity slot, the CPU's per-frame /// level decision (Step 19, D8); read by `cull` (group 2, binding 3). lod_levels_buffer: wgpu::Buffer, /// GPU per-mesh LOD tables (`STORAGE | COPY_DST`): one 80-byte [`LodTable`] per mesh in /// `mesh_order` order; read by `cull` (group 2, binding 4) to map a level to its draw args. lod_tables_buffer: wgpu::Buffer, /// `compute_matrices`/`cull` group 0 (transform buffer, storage read) — shared by both compute passes. transform_bg: wgpu::BindGroup, /// `compute_matrices` group 1 (matrix buffer, storage read_write). matrices_bg: wgpu::BindGroup, /// `cull` group 2 (cull uniforms + bboxes + draw args). cull_bundle_bg: wgpu::BindGroup, /// Shared object bind group (group 1, dynamic) binding ONE 64-byte matrix slice of the GPU matrix /// buffer — every entity's render/shadow draw binds this with a per-slot dynamic offset /// (`slot_index * MAT_SLOT_SIZE`, 256-aligned) to select its slot. matrix_object_bg: wgpu::BindGroup, /// Whether GPU frustum culling is enabled (off by default until validated, Step 15.6). /// Interior-mutable so `set_culling` can toggle it from an immutable `&Renderer` (matching the /// Renderer's all-`&self` API). Read each frame by `render_scene` when building the cull uniforms. cull_enabled: Cell, /// Number of `set_pipeline` calls in the LAST `render_scene` main pass. Since Étape 18 the /// main pass batches by material, so this equals the number of DISTINCT materials drawn that /// frame. Interior-mutable (all-`&self` API); exposed through `debug_dump` for the /// state-change A/B measurement (Étape 18 verification). debug_pipeline_switches: Cell, /// Whether LOD is enabled (Step 19, D8). When `false` the CPU writes level 0 for every slot /// each frame, and the GPU indirect args are byte-identical to the pre-LOD behavior /// (level-0 rows carry the full-mesh counts). Interior-mutable (all-`&self` API). lod_enabled: Cell, /// Per-slot level of the PREVIOUS frame — the hysteresis state of [`lod_level`] (Step 19, D4): /// going coarser requires a 20 % dead band measured against this value. Interior-mutable /// (all-`&self` API); resized when the slot count grows (entity append). last_lod_levels: RefCell>, /// Viewport height in pixels (Step 19, D9): the unit of the LOD projected-size test. Set from /// the initial surface size in `new` and refreshed by `resize_depth` on window resize. viewport_height: u32, /// Shadow mapping configuration (map size, biases, frustum). Set at construction time; /// `map_size` determines the shadow texture allocation, the rest are used per-frame. shadow_config: super::shadow::ShadowConfig, /// HDR pipeline (Étape 20). Present only when HDR is enabled via `AppBuilder::with_hdr`. /// When `None`, the main pass renders directly to the surface (LDR, zero overhead). hdr: Option, /// Bloom pipeline (Étape 23). Present only when both HDR and bloom are active. /// When `None`, the TM pass reads the HDR texture directly (no bloom, zero overhead). bloom: Option, /// Bloom configuration (used per-frame for uniform writes). Only meaningful when bloom is active. bloom_config: BloomConfig, /// MSAA configuration (Étape 24). `sample_count = 1` means MSAA is disabled (zero overhead). msaa_config: MsaaConfig, /// MSAA color texture (N samples). `None` when MSAA is disabled. msaa_color_texture: Option, /// MSAA color view used as the main pass color attachment when MSAA is active. msaa_color_view: Option, /// MSAA depth texture (N samples). `None` when MSAA is disabled. msaa_depth_texture: Option, /// MSAA depth view used as the main pass depth attachment when MSAA is active. msaa_depth_view: Option, /// Fog configuration (Étape 25). `None` = fog disabled (zero overhead). fog: Option, /// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead). dof: Option, /// DoF pipeline (Étape 26). Present only when DoF + HDR are both active. dof_pipeline: Option, } /// Internal HDR pipeline state: offscreen `Rgba16Float` texture + tone mapping render pipeline. /// Allocated in `Renderer::new` when HDR is active; recreated on resize. struct HdrPipeline { /// Offscreen HDR color texture (`Rgba16Float`), sized to the surface. texture: wgpu::Texture, /// View of the HDR texture, used as the main pass color attachment. view: wgpu::TextureView, /// Tone mapping render pipeline (fullscreen triangle + ACES/Reinhard curve). pipeline: wgpu::RenderPipeline, /// Bind group for the TM pass (HDR texture + sampler + uniform with exposure & viewport). bind_group: wgpu::BindGroup, /// TM uniform buffer (32 bytes: exposure + viewport). Re-written each frame for live exposure. uniform_buffer: wgpu::Buffer, /// Bind group layout for the TM pass (reused on resize to recreate the bind group). layout: wgpu::BindGroupLayout, /// Sampler for the HDR texture (linear, clamp). sampler: wgpu::Sampler, /// Viewport width in pixels (for the TM uniform's pad.xy). width: u32, /// Viewport height in pixels. height: u32, } impl Renderer { /// Creates a Renderer by cloning Device and Queue Arc references from the Context, plus capturing /// the surface format, and allocates the shared frame + object uniform buffers and their bind groups. /// Inputs: context (borrowed reference to Context providing GPU resource handles), format (surface /// texture format), width (surface width in pixels) and height (surface height in pixels) — the /// latter two size the depth texture allocated here (Step 9). /// Returns a new Renderer instance sharing the same underlying GPU resources as Context. /// Called once at application startup during scene setup. The Renderer shares these resources via Arc; /// Context retains ownership and can continue using them after this call. pub fn new( context: &Context, format: wgpu::TextureFormat, width: u32, height: u32, shadow_config: &super::shadow::ShadowConfig, hdr: Option, bloom_config: Option, msaa_config: Option, fog: Option, dof_config: Option, ) -> Self { let queue: wgpu::Queue = context.queue.clone(); let device: wgpu::Device = context.device.clone(); let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device); // Step 9 (DRAFT 9.1): depth texture + view, allocated once at the initial surface // size (D3). The isolated helper keeps the Phase 4.4 recreate trivial. let (depth_texture, depth_view) = create_depth_texture(&device, width, height, dof_config.is_some()); // Shared frame uniforms: identity camera + white directional light, lit mode by default. // Values become meaningful once an active camera is wired (Step 4.3); for now the default // is a coherent scene when a shader actually reads them, and irrelevant to shaders that don't. let frame_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("frame uniform buffer"), size: FRAME_UNIFORMS_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let frame_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("frame bind group"), layout: &frame_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: frame_buffer.as_entire_binding(), }], }); // Shared per-object bind group (identity model) for the low-level `render` path. let object_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("shared object uniform buffer"), size: OBJECT_UNIFORM_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let identity_object = ObjectUniform { model: glam::Mat4::IDENTITY, emissive: glam::Vec4::ZERO, pbr: glam::Vec4::ZERO, }; queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object)); let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("shared object bind group"), layout: &object_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: object_buffer.as_entire_binding(), }], }); // Step 14 (DRAFT 3.2): shadow mapping resources — shadow map texture/view, comparison // sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the // depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8). let (shadow_texture, shadow_view) = create_shadow_map(&device, shadow_config.map_size); let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("shadow comparison sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, // The shadow map uses WebGPU `[0,1]` clip depth (glam `directx`/WebGPU module), so the // depth stored in the map and the fragment depth computed in the main-pass shader share // the same convention (smaller = closer to the light ; the map is cleared to 1.0 = far). // A surface is LIT when it is no farther from the light than the recorded blocker, i.e. // `current_depth <= stored_depth`. `textureSampleCompare` returns 1 when the sampler's // compare function holds for `compare_op(depth_ref, sampled)`, so `LessEqual` is the // correct choice: `depth_ref (= current_depth - bias) <= stored_depth` → lit. Using // `GreaterEqual` here inverts the test (shadowed regions render lit, directly-lit // surfaces self-shadow to black) — the regression seen in the Step 14 `shadow_test`. compare: Some(wgpu::CompareFunction::LessEqual), ..Default::default() }); let shadow_map_layout = create_shadow_map_bind_group_layout(&device); let shadow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("shadow map bind group"), layout: &shadow_map_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Sampler(&shadow_sampler), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&shadow_view), }, ], }); let shadow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("shadow uniform buffer"), size: SHADOW_UNIFORM_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let shadow_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("shadow uniform bind group"), layout: &create_shadow_uniform_layout(&device), entries: &[wgpu::BindGroupEntry { binding: 0, resource: shadow_uniform_buffer.as_entire_binding(), }], }); let shadow_pipeline = build_shadow_pipeline(&device, &object_layout); // Phase 3 (Step 15) — GPU-driven rendering. One compute shader module with two entry points // (`compute_matrices`, `cull`); a single explicit 3-group pipeline layout is shared by both // pipelines so they bind the same transforms / matrices / cull buffers (DRAFT Step 15.5). let gpu_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gpu-driven compute shader"), source: wgpu::ShaderSource::Wgsl(GPU_DRIVEN_SHADER.into()), }); // Group 0: transform slots (storage read). Group 1: world matrices (storage read_write). let gpu_transforms_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu transforms layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, 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, }], }); let gpu_matrices_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu matrices layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, 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, }], }); // Group 2: cull uniforms (uniform) + bounding boxes (storage read) + draw args (storage rw). let gpu_cull_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu cull layout"), entries: &[ 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, }, 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, }, wgpu::BindGroupLayoutEntry { binding: 2, 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, }, // Step 19 (LOD): per-slot levels (storage read) + per-mesh tables (storage read). wgpu::BindGroupLayoutEntry { binding: 3, 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, }, wgpu::BindGroupLayoutEntry { binding: 4, 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, }, ], }); let gpu_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("gpu pipeline layout"), bind_group_layouts: &[ Some(&gpu_transforms_layout), Some(&gpu_matrices_layout), Some(&gpu_cull_layout), ], immediate_size: 0, }); let compute_matrices_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("compute_matrices pipeline"), layout: Some(&gpu_pipeline_layout), module: &gpu_shader, entry_point: Some("compute_matrices"), compilation_options: Default::default(), cache: None, }); let cull_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("cull pipeline"), layout: Some(&gpu_pipeline_layout), module: &gpu_shader, entry_point: Some("cull"), compilation_options: Default::default(), cache: None, }); // Fixed-capacity slot buffers (allocated once). Transform + cull-uniform buffers are rewritten // by the CPU each frame; matrix + draw-args buffers are GPU-written; the bbox buffer is uploaded // once per mesh set. let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU transform slots"), size: MAX_ENTITIES as u64 * TRANSFORM_SLOT_SIZE, // COPY_SRC: lets `debug_dump` read the slots back via copy + map. usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); let matrix_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU world matrices"), size: MAX_ENTITIES as u64 * MAT_SLOT_SIZE, // COPY_SRC: lets `debug_dump` read the GPU-written slots back via copy + map. // COPY_DST: lets the CPU write emissive values into the slot padding (Étape 22). usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bbox_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU bounding boxes"), size: MAX_ENTITIES as u64 * BBOX_SLOT_SIZE, // COPY_SRC: lets `debug_dump` read the boxes back via copy + map. usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); let draw_args_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU indirect draw args"), size: MAX_ENTITIES as u64 * DRAW_SLOT_SIZE, // COPY_SRC: lets `debug_dump` read the GPU-written args back via copy + map. usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); let cull_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU cull uniforms"), size: CULL_UNIFORMS_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); // Step 19 (LOD): per-slot levels (one u32 per entity slot, CPU-written each frame) and // per-mesh tables (one 80-byte LodTable per mesh, mesh_order order). `COPY_SRC` lets // `debug_dump` read them back. The cull pass maps each slot's level to its draw args. let lod_levels_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU LOD levels"), size: MAX_ENTITIES as u64 * 4, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); let lod_tables_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("GPU LOD tables"), size: MAX_ENTITIES as u64 * LOD_TABLE_SIZE, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); // Bind groups against the explicit layouts. `transform_bg` is shared by both compute passes // (group 0); `matrices_bg` by `compute_matrices` (group 1); `cull_bundle_bg` by `cull` // (group 2). `matrix_object_bg` uses the render pipeline's dynamic object layout (group 1) and // binds a single 64-byte slice of the matrix buffer — per-entity draws select the slice's 256-byte // slot via a dynamic offset (a whole-buffer binding would cap the dynamic offset at 0). let transform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu transforms bind group"), layout: &gpu_transforms_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: transform_buffer.as_entire_binding(), }], }); let matrices_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu matrices bind group"), layout: &gpu_matrices_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: matrix_buffer.as_entire_binding(), }], }); let cull_bundle_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu cull bundle bind group"), layout: &gpu_cull_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: cull_uniform_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: bbox_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 2, resource: draw_args_buffer.as_entire_binding(), }, // Step 19 (LOD): the level + table buffers (whole-buffer bindings). wgpu::BindGroupEntry { binding: 3, resource: lod_levels_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 4, resource: lod_tables_buffer.as_entire_binding(), }, ], }); let matrix_object_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("GPU matrix object bind group"), layout: &object_layout, entries: &[wgpu::BindGroupEntry { binding: 0, // Bind ONE 64-byte matrix (not the whole buffer) so the per-slot dynamic offset can // slide across the 256-byte slots. The offset is `slot_index * MAT_SLOT_SIZE` (256-aligned). resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &matrix_buffer, offset: 0, // One 64-byte matrix (OBJECT_UNIFORM_SIZE is a non-zero const, so the unwrap is safe). size: Some(std::num::NonZeroU64::new(OBJECT_UNIFORM_SIZE).unwrap()), }), }], }); let mut renderer = Self { queue, device, format, _depth_texture: depth_texture, depth_view, frame_buffer, frame_bind_group, shared_object_bind_group, unlit: false, _shadow_texture: shadow_texture, shadow_view, shadow_bind_group, shadow_uniform_buffer, shadow_uniform_bind_group, shadow_pipeline, compute_matrices_pipeline, cull_pipeline, matrix_buffer, transform_buffer, bbox_buffer, draw_args_buffer, cull_uniform_buffer, lod_levels_buffer, lod_tables_buffer, transform_bg, matrices_bg, cull_bundle_bg, matrix_object_bg, cull_enabled: Cell::new(false), debug_pipeline_switches: Cell::new(0), lod_enabled: Cell::new(true), last_lod_levels: RefCell::new(Vec::new()), viewport_height: height, shadow_config: shadow_config.clone(), hdr: None, bloom: None, bloom_config: bloom_config.clone().unwrap_or_default(), // When MSAA is not requested (None), store sample_count=1 (disabled). // Using `Default` here would give 4 and incorrectly trigger MSAA allocation. msaa_config: msaa_config.unwrap_or(MsaaConfig { sample_count: 1 }), msaa_color_texture: None, msaa_color_view: None, msaa_depth_texture: None, msaa_depth_view: None, fog, dof: dof_config, dof_pipeline: None, }; // Seed the shared frame buffer with an identity camera + current unlit flag so the low-level // `render` path (which has no window/camera) sees coherent values before `render_scene` runs. renderer.write_default_frame_uniforms(); // Étape 20: allocate the HDR pipeline (offscreen texture + TM pipeline) when enabled. renderer.hdr = hdr.map(|tm| create_hdr_pipeline(&renderer.device, &renderer.queue, width, height, tm, format)); // Étape 23: allocate the bloom pipeline when both HDR and bloom are active. if bloom_config.is_some() { if let Some(hdr) = &mut renderer.hdr { let bloom = BloomPipeline::new(&renderer.device, width, height, &hdr.view); // Recreate the TM bind group to read from the bloom composite texture. let (bg, _buf) = create_hdr_bind_group( &renderer.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height, ); hdr.bind_group = bg; renderer.bloom = Some(bloom); renderer.bloom_config = bloom_config.clone().unwrap(); } } // Étape 24: allocate MSAA textures when sample_count > 1. // The MSAA color texture uses the same format as the main target (HDR or surface). if renderer.msaa_config.sample_count > 1 { let sc = renderer.msaa_config.sample_count; let color_format = if renderer.hdr.is_some() { wgpu::TextureFormat::Rgba16Float } else { format }; let msaa_tex = renderer.device.create_texture(&wgpu::TextureDescriptor { label: Some("MSAA color texture"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: sc, dimension: wgpu::TextureDimension::D2, format: color_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); let (msaa_depth_tex, msaa_depth_view) = create_msaa_depth_texture(&renderer.device, width, height, sc); renderer.msaa_color_texture = Some(msaa_tex); renderer.msaa_color_view = Some(msaa_view); renderer.msaa_depth_texture = Some(msaa_depth_tex); renderer.msaa_depth_view = Some(msaa_depth_view); } // Étape 26: allocate the DoF pipeline when DoF + HDR are both active. if renderer.dof.is_some() { if let Some(hdr) = &mut renderer.hdr { // The color source for DoF is the HDR texture (or bloom composite if bloom is active). let color_tex: &wgpu::Texture = if let Some(bloom) = &renderer.bloom { bloom.composite_texture() } else { &hdr.texture }; let color_view = color_tex.create_view(&Default::default()); let dof_pipe = super::dof::DoFPipeline::new( &renderer.device, width, height, &renderer.depth_view, &color_view, ); // Recreate the TM bind group to read from the DoF output texture. let (bg, _buf) = create_hdr_bind_group( &renderer.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height, ); hdr.bind_group = bg; renderer.dof_pipeline = Some(dof_pipe); } else { eprintln!("[WSG] DoF requires HDR: call with_hdr() before with_dof(). DoF disabled."); renderer.dof = None; } } renderer } /// Writes the shared per-frame uniform buffer using an identity camera (view = proj = identity) /// and the current [`Renderer::set_unlit`] flag. This is the initial state for the low-level /// `render` path, which is independent of any window and therefore has no camera or aspect ratio. /// Called at construction and whenever the renderer transitions between lit and unlit mode. fn write_default_frame_uniforms(&self) { let frame = FrameUniforms { options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], // Step 14 (D2): no active shadow caster in the low-level path — sentinel index // MAX_LIGHTS disables the shadow term in the shader even if options.y were set. shadow_light_index: MAX_LIGHTS as u32, ..FrameUniforms::default() }; self.queue .write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame)); } /// Toggles flat (unlit) rendering. When true, the `standard` shader returns vertex colors as-is /// (`options.x = 1`), so flat 2D rendering is a special case of the 3D lit path (DRAFT Step 5: /// "2D ⊂ 3D"). Rewrites the shared frame buffer immediately so the low-level `render` path picks /// up the change; the `render_scene` path reads the flag each frame in `write_frame_uniforms`. /// Inputs: unlit — true for flat rendering, false (default) for Phong-lit rendering. pub fn set_unlit(&mut self, unlit: bool) { self.unlit = unlit; self.write_default_frame_uniforms(); } /// Sets the fog configuration at runtime (Étape 25). `None` disables fog. /// Takes effect on the next `render_scene` call. pub fn set_fog(&mut self, fog: Option) { self.fog = fog; } /// Sets the DoF configuration at runtime (Étape 26). `None` disables DoF. /// Only effective when DoF was enabled at construction (pipeline already allocated). pub fn set_dof(&mut self, config: Option) { if self.dof_pipeline.is_some() { self.dof = config; } } /// Recreates the depth texture at a new size, used on window resize (ROADMAP Phase 4.4). /// The previous depth texture is dropped when its field is replaced — no leak, no double /// allocation. The helper `create_depth_texture` (Step 9, D3) is reused so the recreate stays /// trivial. Inputs: width/height — the new surface dimensions in pixels. pub fn resize_depth(&mut self, width: u32, height: u32) { let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height, self.dof_pipeline.is_some()); self._depth_texture = depth_texture; self.depth_view = depth_view; // Step 19 (D9): refresh the viewport height — the unit of the LOD projected-size test. self.viewport_height = height; // Étape 20: recreate the HDR texture + bind group at the new size (D10). if let Some(hdr) = &mut self.hdr { let (tex, view) = create_hdr_texture(&self.device, width, height); let (bg, buf) = create_hdr_bind_group(&self.device, &hdr.layout, &hdr.sampler, &tex, width, height); hdr.texture = tex; hdr.view = view; hdr.bind_group = bg; hdr.uniform_buffer = buf; hdr.width = width; hdr.height = height; } // Étape 23: resize bloom textures + re-point TM bind group at the composite. if self.bloom.is_some() { if let Some(hdr) = &mut self.hdr { let bloom = self.bloom.as_mut().unwrap(); bloom.resize(&self.device, width, height, &hdr.view); let (bg, _buf) = create_hdr_bind_group( &self.device, &hdr.layout, &hdr.sampler, bloom.composite_texture(), width, height, ); hdr.bind_group = bg; } } // Étape 24: recreate MSAA textures at the new size. if self.msaa_config.sample_count > 1 { let sc = self.msaa_config.sample_count; let color_format = if self.hdr.is_some() { wgpu::TextureFormat::Rgba16Float } else { self.format }; let msaa_tex = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("MSAA color texture"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: sc, dimension: wgpu::TextureDimension::D2, format: color_format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); let (msaa_depth_tex, msaa_depth_view) = create_msaa_depth_texture(&self.device, width, height, sc); self.msaa_color_texture = Some(msaa_tex); self.msaa_color_view = Some(msaa_view); self.msaa_depth_texture = Some(msaa_depth_tex); self.msaa_depth_view = Some(msaa_depth_view); } // Étape 26: resize DoF textures + re-point TM bind group at the DoF output. if self.dof_pipeline.is_some() { if let Some(hdr) = &mut self.hdr { let color_tex: &wgpu::Texture = if let Some(bloom) = &self.bloom { bloom.composite_texture() } else { &hdr.texture }; let color_view = color_tex.create_view(&Default::default()); let dof_pipe = self.dof_pipeline.as_mut().unwrap(); dof_pipe.resize(&self.device, width, height, &self.depth_view, &color_view); let (bg, _buf) = create_hdr_bind_group( &self.device, &hdr.layout, &hdr.sampler, dof_pipe.output_texture(), width, height, ); hdr.bind_group = bg; } } } /// Updates the stored surface texture format after a surface reconfigure (ROADMAP Phase 4.4). /// Used when `Context::configure` returns a different format so the Renderer stays in sync /// with the surface. Inputs: format — the new surface texture format. pub fn set_format(&mut self, format: wgpu::TextureFormat) { self.format = format; } /// Rewrites the shared per-frame uniform buffer from the scene's active camera, its global /// light list, its ambient color, and the current viewport aspect, then returns the frame bind /// group wired to that buffer. Called at the start of every `render_scene` so the GPU sees the /// latest camera matrices, camera position, and lighting (Step 4.3, Steps 12–13). /// /// The light array is packed via `Lights::into_frame_array` (directionals first, then point, /// then spot lights). Inputs: camera (the scene's active camera), lights (the scene's global /// light list), ambient (the scene's ambient hemisphere color, rgb), aspect (viewport width / /// height), shadow_caster (the packed-array index of the shadow-casting light, from /// `Scene::shadow_caster`, or `None` when shadows are disabled / the caster is a point light). fn write_frame_uniforms( &self, camera: &Camera, lights: &Lights, ambient: [f32; 3], aspect: f32, shadow_caster: Option, ) { let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array(); // Step 14 (DRAFT 3.2): derive the shadow light's view_proj and shadow flags (D3). let (shadow_light_index, light_view_proj, shadow_params, shadow_on) = match self.shadow_light_view_proj(lights, shadow_caster) { Some((index, vp)) => ( index as u32, vp, Vec4::new( self.shadow_config.map_size as f32, self.shadow_config.depth_bias, self.shadow_config.slope_bias, 0.0, ), 1, ), None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0), }; let frame = FrameUniforms { view: camera.view_matrix(), proj: camera.projection_matrix(aspect), cam_pos: camera.position.extend(1.0), ambient: Vec4::new(ambient[0], ambient[1], ambient[2], 1.0), lights: light_array, num_directional, num_point, num_spot, shadow_light_index, light_view_proj, shadow_params, options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0], // Étape 25: fog params (disabled by default → fog_a.x = 0). fog_a: self.fog.as_ref().map(|f| f.pack(true).0).unwrap_or(glam::Vec4::ZERO), fog_b: self.fog.as_ref().map(|f| f.pack(true).1).unwrap_or(glam::Vec4::ZERO), }; self.queue .write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame)); } /// Computes the light-space orthographic view-projection of the shadow-casting light, plus its /// packed-array index. The volume covered is an orthographic box of half-size /// `SHADOW_SCENE_RADIUS` centered on the scene origin (SHADOW_SCENE_CENTER), oriented so its /// `-z` axis aligns with the light's travel direction (light → scene). Placing the eye behind /// the scene along the light path keeps the frustum locked to the light orientation even when /// the directional light's `position` is arbitrary. The projection uses `near = 0.0` / /// `far = SHADOW_SCENE_RADIUS` so the depth written by the shadow pass matches the `depth` the /// main-pass shader compares (D3). Returns `None` when no valid caster is selected (shadows /// disabled, index out of bounds, or the caster is a point light — D6). fn shadow_light_view_proj( &self, lights: &Lights, caster: Option, ) -> Option<(usize, Mat4)> { let index = caster?; if index >= lights.len() { return None; } let light = lights.get(index)?; // Directional and spot lights carry a direction; point lights would need a 6-face cubemap // shadow, which is out of scope (D6), so we reject them. // Directional lights carry their direction in `position_dir.xyz` (from the surface toward // the light, see `directional_light`/shader); `dir_angle` is zero for them. Spot lights // carry the cone axis (from the light toward the scene) in `dir_angle.xyz`. The shadow // camera must look along the light's **travel direction** (light → scene), i.e. the negation // of the surface→light vector for directional lights. let dir = match light.light_type() { crate::lights::LightType::Directional => Vec3::new( -light.position_dir.x, -light.position_dir.y, -light.position_dir.z, ), crate::lights::LightType::Spot => { Vec3::new(light.dir_angle.x, light.dir_angle.y, light.dir_angle.z) } crate::lights::LightType::Point => return None, }; let r = self.shadow_config.scene_radius; let target = Vec3::from(self.shadow_config.scene_center); // Eye one scene-radius behind the target along the light path, so distance(target)=r and // every point in the box has depth within [near=0, far=r]. let eye = target - dir * r; // Avoid a degenerate basis when the light points straight down/up (parallel up vector). let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y }; let view = glam::camera::rh::view::look_at_mat4(eye, target, up); // Orthographic box of half-size r, near 0, far 2·r (D1/D3), in the WebGPU `[0,1]` NDC // convention (glam `directx` module), which matches the depth range wgpu writes to the // shadow map and the `current_depth` computed by the main-pass shader. The eye sits one // scene-radius behind the target, so the box [−r, r] around the target spans a depth range // of [0, 2r] from the eye: `far = 2·r` covers the whole box (and the shadows cast behind // it), whereas `far = r` would clip the far half. let proj = glam::camera::rh::proj::directx::orthographic(-r, r, -r, r, 0.0, 2.0 * r); Some((index, proj * view)) } /// Orchestrates rendering of a single object: binds Material pipeline + Mesh vertex data into a RenderPass, /// then submits commands to the GPU queue for execution. Called per-frame by the orchestrator (main.rs). /// Inputs: view (TextureView color attachment target), mesh (geometry to render), material (shader+pipeline). /// Internal steps: 1) create CommandEncoder → 2) begin RenderPass with color attachment → /// 3) set_pipeline(material.pipeline) → 4) set_vertex_buffer(mesh.vertex_buffer) → /// 5) draw_indexed or draw based on index buffer presence → 6) drop render_pass end scope → /// 7) submit encoder via queue. pub fn render(&self, view: &wgpu::TextureView, mesh: &Mesh, material: &Material) { // Create per-frame command encoder; its lifetime is scoped to this function only. let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("render encoder"), }); // RenderPass borrows encoder mutably — must end (drop) before encoder.finish() below. // This scope boundary enforces Rust's borrow checker rules for GPU synchronization. { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], // Step 9 (DRAFT 9.2): depth attachment via the shared view (D1: clear 1.0 // = max depth far away at frame start, then Store to keep it). depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), ..Default::default() }); draw_entity( &mut render_pass, mesh, material, &self.frame_bind_group, &self.shared_object_bind_group, 0, // object offset 0 — the identity object buffer (low-level path, no slot). &self.shadow_bind_group, ); } self.queue.submit(std::iter::once(encoder.finish())); } /// Renders every entity in `scene` into the given color view, fully GPU-driven (Phase 3, Step 15). /// Per frame the CPU rewrites only the transform slots + cull uniforms; the GPU then derives the /// world matrices (`compute_matrices`), culls + fills the indirect draw args (`cull`), and the /// main + shadow render passes are 100% indirect (a culled/inactive slot's args are zero → a no-op /// draw). This removes the CPU-side per-entity loop from the render hot path. /// Inputs: view — the frame's texture view color attachment; scene — the scene whose entities are /// drawn; aspect — the viewport aspect ratio (width/height) for the camera's perspective projection. pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32, exposure: f32) { // 1. Rewrite the shared frame uniform buffer (camera view/proj, position, lights, shadow flags). self.write_frame_uniforms( scene.camera(), scene.lights(), scene.ambient(), aspect, scene.shadow_caster(), ); // 2. Rewrite the GPU transform slots — the single source of truth for world matrices, and the // only per-entity CPU→GPU copy each frame (packed TRS, 64 B per slot). let transform_slots = scene.packed_transform_slots(); self.queue.write_buffer( &self.transform_buffer, 0, bytemuck::cast_slice(&transform_slots), ); // 2b. LOD (Step 19, D8): the CPU picks each slot's detail level from the projected // bounding-sphere radius (asymmetric hysteresis in `math::lod`), then uploads the // per-slot levels + per-mesh tables the `cull` pass maps level → indirect args. // LOD disabled ⇒ every level is 0, and level-0 rows carry the full-mesh counts, so // the GPU args are byte-identical to the pre-LOD behavior (D8 compatibility). let camera = scene.camera(); let cam_view = camera.view_matrix(); let cam_proj = camera.projection_matrix(aspect); let bboxes = scene.mesh_bboxes(); let lod_levels: Vec = if self.lod_enabled.get() { self.compute_lod_levels(scene, &cam_view, &cam_proj, &transform_slots, &bboxes) } else { vec![0u32; transform_slots.len()] }; self.queue.write_buffer( &self.lod_levels_buffer, 0, bytemuck::cast_slice(&lod_levels), ); let lod_tables = scene.mesh_lod_tables(); self.queue.write_buffer( &self.lod_tables_buffer, 0, bytemuck::cast_slice(&lod_tables), ); // 3. Upload the local-space bounding boxes (small; the mesh set is static in practice, but // re-uploading each frame keeps the mesh-index → bbox mapping correct if meshes are added). self.queue .write_buffer(&self.bbox_buffer, 0, bytemuck::cast_slice(&bboxes)); // 4. Compute the view frustum from the camera's view-projection and write the cull uniforms // (six unit planes + the num_slots / culling control flags). let view_proj = cam_proj * cam_view; let frustum = Frustum::from_view_proj(&view_proj); let cull_uniforms = CullUniforms::from_frustum(&frustum, scene.num_slots() as u32, self.cull_enabled.get()); self.queue.write_buffer( &self.cull_uniform_buffer, 0, bytemuck::bytes_of(&cull_uniforms), ); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("scene encoder"), }); // 5. Dispatch the two compute passes (Step 15.5): `compute_matrices` derives each entity's // world matrix into the matrix buffer, then `cull` fills the indirect draw args (a zero // count for a culled/inactive slot). Both read the transform buffer written above. let workgroups = MAX_ENTITIES.div_ceil(GPU_WORKGROUP_SIZE); { let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("compute_matrices pass"), timestamp_writes: None, }); // Bind ALL THREE groups: the shared layout has a non-null bind group at every index, // so WebGPU requires each to be set even when the entry point doesn't read it. The // cull bundle (group 2) is unused by this entry point but must still be bound. pass.set_pipeline(&self.compute_matrices_pipeline); pass.set_bind_group(0, &self.transform_bg, &[]); pass.set_bind_group(1, &self.matrices_bg, &[]); pass.set_bind_group(2, &self.cull_bundle_bg, &[]); pass.dispatch_workgroups(workgroups, 1, 1); } { let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("cull pass"), timestamp_writes: None, }); // Same: bind all three. Group 1 (matrices) is unused by the cull entry point (it // computes the world-space centre from the transform directly) but must be bound. pass.set_pipeline(&self.cull_pipeline); pass.set_bind_group(0, &self.transform_bg, &[]); pass.set_bind_group(1, &self.matrices_bg, &[]); pass.set_bind_group(2, &self.cull_bundle_bg, &[]); pass.dispatch_workgroups(workgroups, 1, 1); } // 6. Run the depth-only shadow pass (indirect) first when a light casts shadows (DRAFT 3.2/D4); // it reads the same matrix + draw-args buffers. No-op when shadows are off. self.render_shadow_map(&mut encoder, scene); // 7. Main render pass: one indirect draw per active slot, batched by material (Étape 18). // The matrix + draw-args are read via per-slot offsets; a culled/inactive slot's args // are zero, so its draw is a no-op. State changes (pipeline + texture bind group @2) // are hoisted out of the slot loop: one per DISTINCT material, not one per entity. // Étape 20: when HDR is active, the color attachment targets the offscreen HDR texture // instead of the surface; the TM pass (step 8) then copies it to the surface. // Étape 24: when MSAA is active, the color attachment targets the MSAA texture and // resolves into the single-sample target (HDR or swapchain). The depth is also MSAA. let (color_view, resolve_target, depth_attach) = if let Some(msaa_view) = &self.msaa_color_view { // MSAA active: render into MSAA, resolve to single-sample target. let resolve = match &self.hdr { Some(h) => Some(h.view.clone()), None => Some(view.clone()), }; let depth = self.msaa_depth_view.as_ref().unwrap(); (msaa_view.clone(), resolve, depth) } else { // No MSAA: current behavior. let color = match &self.hdr { Some(h) => &h.view, None => view, }; (color.clone(), None, &self.depth_view) }; { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("scene render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &color_view, resolve_target: resolve_target.as_ref(), depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: depth_attach, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), ..Default::default() }); // Batching by material (Étape 18): the material's Arc pointer is the group key — the // same Material shares one pipeline AND one texture bind group (@2), so both state // changes are frozen within a group. Groups appear in order of first appearance in // stable slot order (D2), so the draw order stays deterministic frame to frame. All // pipelines are opaque (BlendState::REPLACE), so reordering draws is visually neutral // (D4 — if transparent blending is ever added, see the constraint in the user docs). let slots: Vec<_> = scene.iter_slot_draws().filter(|s| s.active).collect(); // Materialize the Material Arcs first: the pointer keys below must stay valid for the // whole pass, and `default_material()` returns a clone that would otherwise be dropped // at the end of the closure (D1). let materials: Vec> = slots .iter() .map(|s| { s.mesh .material() .cloned() .unwrap_or_else(|| scene.default_material()) }) .collect(); let keys: Vec<*const Material> = materials.iter().map(Arc::as_ptr).collect(); let groups = batch_slots(&keys); self.debug_pipeline_switches.set(groups.len() as u32); for group in &groups { // The first slot of a group carries the group's material (all slots in the group // share the same Arc pointer). let material = &materials[group[0]]; render_pass.set_pipeline(&material.pipeline); render_pass.set_bind_group(0, &self.frame_bind_group, &[]); render_pass.set_bind_group(2, &material.texture_bind_group, &[]); render_pass.set_bind_group(3, &self.shadow_bind_group, &[]); for &pos in group { let slot = &slots[pos]; let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32; let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE; // Group 1 (dynamic): the 64-byte matrix slice for this slot. render_pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]); render_pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..)); // Step 19: the draw command follows the CHOSEN level's indexedness, not L0's — // an Auto-mode mesh may mix indexed levels (e.g. L0 indexed, L1+ non-indexed). // Level 0 (LOD off, or a single-level mesh) reproduces the pre-LOD command. let row = slot .mesh .lod_rows() .get(lod_levels[slot.slot_index] as usize) .copied() .unwrap_or_default(); if row.index_count > 0 { if let Some(index_buffer) = &slot.mesh.index_buffer { render_pass.set_index_buffer( index_buffer.slice(..), wgpu::IndexFormat::Uint16, ); } render_pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset); } else { render_pass.draw_indirect(&self.draw_args_buffer, indirect_offset); } } } } // 8. Étape 22 (6.1): write the current exposure into the TM uniform buffer (per-frame, // so live adjustments via keyboard take effect immediately). // 8b. Étape 22 (6.2): write each active slot's emissive into the matrix buffer padding // (bytes 64-79). The compute pass only overwrites bytes 0-63 (the matrix), so the // emissive persists. This must happen before the encoder submit (CPU→GPU copy). if let Some(hdr) = &self.hdr { let uniform_data = [ exposure, 0.0, 0.0, 0.0, hdr.width as f32, hdr.height as f32, 0.0, 0.0, ]; self.queue.write_buffer(&hdr.uniform_buffer, 0, bytemuck::cast_slice(&uniform_data)); } // Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79). // The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists. // PBR (Étape 27): metallic/roughness at bytes 80-95 (always written for correctness). for slot in scene.iter_slot_draws().filter(|s| s.active) { let mat = slot .mesh .material() .cloned() .unwrap_or_else(|| scene.default_material()); if mat.emissive != [0.0; 4] { let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64; self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive)); } // PBR params (metallic, roughness) — always written (buffer init to 0 is wrong for PBR). let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0]; let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64; self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data)); } // 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite). // Only runs when both HDR and bloom are active. The composite texture becomes // the input to the TM pass (the TM bind group was re-pointed at construction). if let Some(bloom) = &self.bloom { bloom.record_passes(&mut encoder, &self.queue, &self.bloom_config); } // 8d. Étape 26: DoF passes (CoC → Blur). // Only runs when DoF + HDR are active and DoF config is set. // The DoF output texture becomes the input to the TM pass. if let Some(dof_pipe) = &self.dof_pipeline { if let Some(dof_cfg) = &self.dof { // Update the shared uniform buffer. dof_pipe.update_uniform(&self.queue, dof_cfg, 0.1, 100.0); // Pass 1: CoC (depth → R16Float radius texture). { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("dof coc pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: dof_pipe.coc_view(), resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_pipeline(dof_pipe.coc_pipeline()); pass.set_bind_group(0, dof_pipe.coc_bind_group(), &[]); pass.draw(0..3, 0..1); } // Pass 2: Blur (color + CoC → blurred Rgba16Float output). { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("dof blur pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: dof_pipe.output_view(), resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); pass.set_pipeline(dof_pipe.blur_pipeline()); pass.set_bind_group(0, dof_pipe.blur_bind_group(), &[]); pass.draw(0..3, 0..1); } } } // 9. Étape 20: tone mapping pass — renders a fullscreen triangle that reads the HDR // texture (or the bloom composite when bloom is active, or DoF output when DoF is active), // applies exposure + tone mapping curve, and writes to the surface. if let Some(hdr) = &self.hdr { let mut tm_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("tone mapping pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, ..Default::default() }); tm_pass.set_pipeline(&hdr.pipeline); tm_pass.set_bind_group(0, &hdr.bind_group, &[]); tm_pass.draw(0..3, 0..1); } self.queue.submit(std::iter::once(encoder.finish())); } /// Debug helper (Phase 3 black-window investigation): reads back the first `n` slots of the /// transform / matrix / indirect-draw-args / bbox buffers (plus the cull uniforms) and prints /// them to stderr. Call it from a render callback *after* `render_scene` so the compute /// passes of the current frame have been submitted. /// /// **Synchronous on purpose** (blocks the calling thread until every staging buffer is read /// back and unmapped). Two wgpu-core rules make this necessary (both cost a frozen render /// loop if violated — observed empirically with this very tool): /// /// 1. `Queue::write_buffer` on a buffer with a **pending map** fails with /// `TransferError::BufferNotAvailable` (and `render_scene` writes the transform / bbox / /// cull buffers every frame). A detached-thread readback that leaves its maps pending /// when the next frame starts corrupts/skips that frame (demo froze after 3 async dumps). /// 2. Map callbacks are only fired by the queue `maintain`, which runs **inside** /// `Queue::submit` — a thread blocked waiting on its own callbacks can never trigger it /// (demo froze on the first purely-synchronous dump). /// /// This implementation closes both loops itself: it submits the copies, requests the maps, /// then pumps the queue with empty submits (each one runs a `maintain` and fires whatever /// callbacks are due) until every map has completed, and only then unmaps. At the point it /// returns, all staging buffers are `Idle` again, so the next frame's `write_buffer` calls /// are safe. Each dump briefly stalls the render loop (a few ms). #[doc(hidden)] pub fn debug_dump(&self, n: u32) { let device = self.device.clone(); let queue = self.queue.clone(); let matrix_buf = self.matrix_buffer.clone(); let draw_args_buf = self.draw_args_buffer.clone(); let cull_buf = self.cull_uniform_buffer.clone(); let transform_buf = self.transform_buffer.clone(); let bbox_buf = self.bbox_buffer.clone(); let lod_levels_buf = self.lod_levels_buffer.clone(); let lod_tables_buf = self.lod_tables_buffer.clone(); let n = n.min(MAX_ENTITIES as u32).max(1); { // Creates a MAP_READ staging buffer and records a copy of `size` bytes from `src` // into the caller's encoder. NOTE: the map must only be requested AFTER the submit — // wgpu-core rejects a submission that references a buffer in a non-idle map state. fn readback( device: &wgpu::Device, src: &wgpu::Buffer, size: u64, enc: &mut wgpu::CommandEncoder, ) -> wgpu::Buffer { let read = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dbg readback"), size, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); enc.copy_buffer_to_buffer(src, 0, &read, 0, size); read } let specs: [(&wgpu::Buffer, u64); 7] = [ (&matrix_buf, n as u64 * MAT_SLOT_SIZE), (&draw_args_buf, n as u64 * DRAW_SLOT_SIZE), (&transform_buf, n as u64 * TRANSFORM_SLOT_SIZE), (&bbox_buf, n as u64 * BBOX_SLOT_SIZE), (&cull_buf, CULL_UNIFORMS_SIZE), (&lod_levels_buf, n as u64 * 4), (&lod_tables_buf, n as u64 * LOD_TABLE_SIZE), ]; let (tx, rx) = std::sync::mpsc::channel::<()>(); let mut reads = Vec::with_capacity(specs.len()); { let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("dbg readback encoder"), }); for (src, size) in &specs { reads.push(readback(&device, src, *size, &mut enc)); } queue.submit([enc.finish()]); } // Only now request the maps (wgpu 30 has no async `map` future — one message each). for read in &reads { let size = read.size(); let tx = tx.clone(); read.map_async(wgpu::MapMode::Read, 0..size, move |_| { let _ = tx.send(()); }); } // Pump the queue until every map callback has fired. The callbacks are delivered by // the `maintain` that runs inside `Queue::submit` — and this thread is the only one // that will submit from now on (the caller is blocked here), so it must pump itself. // Empty submits are cheap: each one polls the GPU and fires whatever is due. loop { let mut done = 0; while rx.try_recv().is_ok() { done += 1; } if done == specs.len() { break; } queue.submit([]); std::thread::sleep(std::time::Duration::from_millis(2)); } let data: Vec> = reads .iter() .map(|b| { b.slice(..) .get_mapped_range() .expect("dbg mapped range") .to_vec() }) .collect(); for b in &reads { b.unmap(); } let ( mat_data, args_data, tr_data, bb_data, cull_data, lod_levels_data, lod_tables_data, ) = ( &data[0], &data[1], &data[2], &data[3], &data[4], &data[5], &data[6], ); for i in 0..n { let off = (i as u64 * TRANSFORM_SLOT_SIZE) as usize; let t: TransformSlot = bytemuck::pod_read_unaligned(&tr_data[off..off + TRANSFORM_SLOT_SIZE as usize]); eprintln!( "[dbg] transform[{i}] translation={:?} flags={:?} rotation={:?} scale={:?}", t.translation, t.flags, t.rotation, t.scale ); } for i in 0..n { let off = (i as u64 * BBOX_SLOT_SIZE) as usize; let b: BBoxSlot = bytemuck::pod_read_unaligned(&bb_data[off..off + BBOX_SLOT_SIZE as usize]); eprintln!("[dbg] bbox[{i}] min={:?} max={:?}", b.min, b.max); } for i in 0..n { let off = (i as u64 * MAT_SLOT_SIZE) as usize; let m: MatSlot = bytemuck::pod_read_unaligned(&mat_data[off..off + MAT_SLOT_SIZE as usize]); eprintln!("[dbg] matrix[{i}] m = {:?}", m.m); } for i in 0..n { let off = (i as u64 * DRAW_SLOT_SIZE) as usize; let d: DrawSlot = bytemuck::pod_read_unaligned(&args_data[off..off + DRAW_SLOT_SIZE as usize]); eprintln!("[dbg] draw_args[{i}].a = {:?}", d.a); } let c: CullUniforms = bytemuck::pod_read_unaligned(&cull_data[..]); eprintln!( "[dbg] cull: num_slots={} culling={}", c.num_slots, c.culling ); for (i, p) in c.planes.iter().enumerate() { eprintln!("[dbg] plane[{i}] = {p:?}"); } // Step 19 (LOD): the CPU-decided per-slot levels and the per-mesh tables the GPU // maps level → indirect args from. let levels: Vec = (0..n) .map(|i| { bytemuck::pod_read_unaligned( &lod_levels_data[i as usize * 4..i as usize * 4 + 4], ) }) .collect(); eprintln!( "[dbg] lod: enabled={} viewport_height={} levels={:?}", self.lod_enabled.get(), self.viewport_height, levels ); for i in 0..n { let off = (i as u64 * LOD_TABLE_SIZE) as usize; let t: LodTable = bytemuck::pod_read_unaligned( &lod_tables_data[off..off + LOD_TABLE_SIZE as usize], ); eprintln!("[dbg] lod_table[{}] count={} rows={:?}", i, t.count, t.rows); } eprintln!( "[dbg] pipeline switches (this frame's main pass) = {}", self.debug_pipeline_switches.get() ); eprintln!("[dbg] done"); } } /// Renders every entity of `scene` from the shadow-casting light's point of view into the /// shadow depth map (Step 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at /// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is /// `None`. Phase 3 (Step 15): like the main pass, the shadow pass is 100% indirect — it reads the /// GPU world matrices (group 1, dynamic offset) and the GPU indirect draw args, so transforms and /// culling match the main pass exactly. Inputs: encoder (the shared command encoder for the frame, /// with the compute passes already dispatched), scene (entities to cast). fn render_shadow_map(&self, encoder: &mut wgpu::CommandEncoder, scene: &Scene) { let caster = match scene.shadow_caster() { Some(c) => c, None => return, }; // Recompute the light's view_proj and write it into the shadow uniform buffer so the // depth-only vertex shader transforms vertices into light-clip space (D4). let (_light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(caster)) { Some(pair) => pair, None => return, }; let shadow_uniform = ShadowUniform { view_proj: vp }; self.queue.write_buffer( &self.shadow_uniform_buffer, 0, bytemuck::bytes_of(&shadow_uniform), ); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("shadow map render pass"), color_attachments: &[], // Depth-only: the shadow map is the sole attachment. Clear 1.0 so fragments beyond // `far` read as "fully distant" and never occlude lit surfaces (D4). depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.shadow_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), ..Default::default() }); pass.set_pipeline(&self.shadow_pipeline); // Group 0: the shadow light view_proj (D4) — the shadow pipeline's only uniform group. pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]); // Phase 3: one indirect draw per active slot; group 1 (dynamic) selects the matrix slice and // the draw-args offset. The shadow pipeline has no texture/sampler groups. for slot in scene.iter_slot_draws() { if !slot.active { continue; } let object_offset = (slot.slot_index as u64 * MAT_SLOT_SIZE) as u32; let indirect_offset = slot.slot_index as u64 * DRAW_SLOT_SIZE; pass.set_bind_group(1, &self.matrix_object_bg, &[object_offset]); pass.set_vertex_buffer(0, slot.mesh.vertex_buffer.slice(..)); if slot.has_index { if let Some(index_buffer) = &slot.mesh.index_buffer { pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); } pass.draw_indexed_indirect(&self.draw_args_buffer, indirect_offset); } else { pass.draw_indirect(&self.draw_args_buffer, indirect_offset); } } drop(pass); } /// Presents the rendered frame by submitting the acquired surface texture to the GPU queue. /// The frame must have been obtained via Context::begin_frame() or Frame::try_new(); calling present() /// twice on the same texture is undefined behavior. Called by the orchestrator after render(). pub fn present(&self, frame: Frame) { self.queue.present(frame.surface_texture); } /// Returns a reference to the owned Device for direct access when needed (e.g., PipelineCache creation). /// Called internally during scene setup; not typically used by external code. pub fn device(&self) -> &wgpu::Device { &self.device } /// Returns the surface texture output format used for rendering. /// Called internally during Material/PipelineCache initialization to ensure pipeline compatibility. pub fn format(&self) -> wgpu::TextureFormat { self.format } /// Enables or disables GPU frustum culling (Phase 3, Step 15.6). Culling is off by default: with /// it disabled, the `cull` pass copies every active slot's draw count (nothing is culled), so the /// scene renders identically to the pre-Phase-3 CPU loop. Enabling it makes `cull` test each /// slot's world-space bounding box against the camera frustum and zero the draw args of culled /// slots (so their indirect draws become no-ops). Inputs: enabled (true = cull, false = draw all). pub fn set_culling(&self, enabled: bool) { self.cull_enabled.set(enabled); } /// Enables or disables LOD (Step 19, D8). When disabled the CPU writes level 0 for every slot /// each frame; level-0 rows carry the full-mesh draw counts, so the GPU indirect args are /// byte-identical to the pre-LOD behavior (the scene renders exactly as before). When enabled /// the CPU picks each slot's level from the projected bounding-sphere radius (with the /// asymmetric hysteresis of `math::lod::lod_level`) and the `cull` pass maps level → args. /// Enabled by default. Inputs: enabled (true = LOD on, false = always level 0). pub fn set_lod_enabled(&self, enabled: bool) { self.lod_enabled.set(enabled); } /// Updates the bloom configuration at runtime (Étape 23). /// Takes effect on the next frame (uniforms are re-written each frame in `record_passes`). pub fn set_bloom_config(&mut self, config: &BloomConfig) { self.bloom_config = config.clone(); } /// Returns `true` if MSAA is active (sample_count > 1). (Étape 24) pub fn msaa_enabled(&self) -> bool { self.msaa_config.sample_count > 1 } /// Returns the current MSAA sample count (1 = disabled). (Étape 24) pub fn msaa_sample_count(&self) -> u32 { self.msaa_config.sample_count } /// Computes the per-slot LOD levels for this frame (Step 19, D8): for each ACTIVE slot, the /// entity's bounding sphere — the **same sphere** the GPU frustum culling uses (D8: bbox /// center + max half-extent × max scale component, rotated by the entity's quaternion) — is /// projected to screen pixels ([`projected_radius_px`]), and [`lod_level`] turns that radius /// into a level with asymmetric hysteresis (D4: the `last` level is the previous frame's /// choice, kept in `self.last_lod_levels`). /// /// Inactive (tombstoned) slots and single-level meshes get level 0 (and their hysteresis state /// resets, so a re-added entity starts fresh). The result has one entry per transform slot. fn compute_lod_levels( &self, scene: &Scene, view: &Mat4, proj: &Mat4, transform_slots: &[TransformSlot], bboxes: &[BBoxSlot], ) -> Vec { let height = self.viewport_height.max(1) as f32; let mut last = self.last_lod_levels.borrow_mut(); if last.len() != transform_slots.len() { // Entity slots are append-only, but a resize keeps the old levels for the surviving // slots (their hysteresis is meaningful) and zero-fills the new ones. let keep = last.len().min(transform_slots.len()); let tail = last.split_off(keep); last.extend(std::iter::repeat(0).take(transform_slots.len() - keep)); drop(tail); } let mut levels = vec![0u32; transform_slots.len()]; for (i, t) in transform_slots.iter().enumerate() { if !t.is_active() { last[i] = 0; continue; // level 0 (zeroed vec); reset hysteresis for the tombstone } let mesh_idx = t.flags[0] as usize; let mesh = scene.mesh_by_index(mesh_idx as u32); let max_level = (mesh.num_lod_levels() as u32) .saturating_sub(1) .min(MAX_LOD_LEVELS - 1); if max_level == 0 { last[i] = 0; continue; // single-level mesh: always L0 } let b = &bboxes[mesh_idx]; let center = Vec3::new( (b.min[0] + b.max[0]) * 0.5, (b.min[1] + b.max[1]) * 0.5, (b.min[2] + b.max[2]) * 0.5, ); let half = Vec3::new( (b.max[0] - b.min[0]) * 0.5, (b.max[1] - b.min[1]) * 0.5, (b.max[2] - b.min[2]) * 0.5, ); // Mirror the WGSL cull pass exactly (D8): radius = |half-extents| × max(scale). let radius = half.length() * t.scale[0].max(t.scale[1].max(t.scale[2])); let center_world = Vec3::from_array(t.translation) + Quat::from_array(t.rotation) * center; let r_px = projected_radius_px(center_world, radius, *view, *proj, height); let lvl = lod_level(r_px, last[i], max_level, &LOD_THRESHOLDS); last[i] = lvl; levels[i] = lvl; } levels } } /// Allocates the depth texture + view backing the render passes' `depth_stencil_attachment` /// (Step 9, DRAFT 9.1). Format is the shared `DEPTH_FORMAT` (Depth32Float, D1) so it always /// matches every pipeline's `DepthStencilState`. Sized to the surface (width x height), single /// mip, no MSAA, used strictly as a render target. /// /// Exposed as a standalone helper so the depth texture can be recreated cheaply at resize /// (ROADMAP Phase 4.4) without touching the render-pass logic. /// Inputs: device (GPU resource creator), width (surface width), height (surface height). /// Returns the (texture, view) pair; the caller keeps both alive. fn create_depth_texture( device: &wgpu::Device, width: u32, height: u32, texturable: bool, ) -> (wgpu::Texture, wgpu::TextureView) { let mut usage = wgpu::TextureUsages::RENDER_ATTACHMENT; if texturable { usage |= wgpu::TextureUsages::TEXTURE_BINDING; } let depth_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("depth texture"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage, view_formats: &[], }); let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); (depth_texture, depth_view) } /// Creates an MSAA depth texture (N samples) with a view (Étape 24). Used when MSAA is active: /// the main pass needs a multi-sampled depth buffer matching the MSAA color attachment. fn create_msaa_depth_texture( device: &wgpu::Device, width: u32, height: u32, sample_count: u32, ) -> (wgpu::Texture, wgpu::TextureView) { let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("MSAA depth texture"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); (tex, view) } /// Allocates the shadow-map texture + view backing the depth-only shadow pass's /// `depth_stencil_attachment` (Step 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single /// mip, no MSAA. Unlike the screen depth texture this one is flagged **both** `RENDER_ATTACHMENT` /// (shadow pass writes depth) **and** `TEXTURE_BINDING` (main pass samples it via the group-3 /// comparison sampler). Allocated once at the default resolution; resizing is deferred (D8). /// Inputs: device (GPU resource creator), size (shadow map edge length in pixels). /// Returns the (texture, view) pair; the caller keeps both alive. fn create_shadow_map(device: &wgpu::Device, size: u32) -> (wgpu::Texture, wgpu::TextureView) { let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("shadow map"), size: wgpu::Extent3d { width: size, height: size, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); (shadow_texture, shadow_view) } /// Binds a Material pipeline, the four shared bind groups, and Mesh buffers into an active render /// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`. /// The frame (@0), object (@1), texture (@2) and shadow-map (@3) bind groups are **required** by /// every pipeline layout (Step 3: a single layout for all — Step 10: texture group — Step 14: /// shadow group) — they must be bound even if the shader does not read them. Draws indexed geometry /// when an index buffer exists, otherwise falls back to a non-indexed draw. /// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline + texture bind /// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/ /// identity model), shadow_bind_group (reserved group-3 shadow-map bind group, unused by the /// depth-only shadow pipeline but required by the standard pipeline layout). fn draw_entity( pass: &mut wgpu::RenderPass<'_>, mesh: &Mesh, material: &Material, frame_bind_group: &wgpu::BindGroup, object_bind_group: &wgpu::BindGroup, object_offset: u32, shadow_bind_group: &wgpu::BindGroup, ) { if mesh.num_vertices == 0 { // No vertices — nothing to render. return; } pass.set_pipeline(&material.pipeline); pass.set_bind_group(0, frame_bind_group, &[]); // Phase 3 (D12): the object (model) binding is dynamic — `object_offset` selects the 64-byte // slice. The low-level path passes 0 (the shared identity buffer); the GPU-driven path uses its // own inline `set_bind_group` calls (not this helper) with a per-slot `slot_index * MAT_SLOT_SIZE`. pass.set_bind_group(1, object_bind_group, &[object_offset]); // Step 10 (DRAFT 10.4): texture group — the Material owns its bind group (placeholder // white if it has no texture, D1/D2). Always bindable since it is attached to every pipeline. pass.set_bind_group(2, &material.texture_bind_group, &[]); // Step 14: shadow group — always bound to stay conformant with the unified layout, whether // the pipeline is lit or not (group @3 is still required by all standard pipelines). pass.set_bind_group(3, shadow_bind_group, &[]); pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); if let Some(index_buffer) = &mesh.index_buffer { pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); pass.draw_indexed(0..mesh.num_indices, 0, 0..1); } else { pass.draw(0..mesh.num_vertices, 0..1); } } /// Groups the positions of a key slice for material batching (Étape 18, D2/D5). Groups appear in /// order of first key occurrence; indices within a group keep input order; every input index /// appears exactly once. Pure and GPU-free, so it is unit-testable with integer keys. The `Clone` /// bound only serves to keep an owned copy of each group's key (the real keys are `*const T`, /// i.e. `Copy`). fn batch_slots(keys: &[K]) -> Vec> { let mut groups: Vec<(K, Vec)> = Vec::new(); let mut index: HashMap<&K, usize> = HashMap::new(); for (i, k) in keys.iter().enumerate() { let g = *index.entry(k).or_insert_with(|| { groups.push((k.clone(), Vec::new())); groups.len() - 1 }); groups[g].1.push(i); } groups.into_iter().map(|(_, idxs)| idxs).collect() } /// Allocates the offscreen HDR color texture (`Rgba16Float`) + view at the given size (Étape 20, D3). /// Used both at initial allocation and on resize. fn create_hdr_texture(device: &wgpu::Device, width: u32, height: u32) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("hdr texture"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); (texture, view) } /// Creates the tone mapping bind group + uniform buffer: HDR texture (binding 0) + sampler (binding 1) /// + uniform (binding 2). The uniform contains exposure (1.0) and viewport size (pad.xy). /// Returns both the bind group and the uniform buffer (so the exposure can be re-written per frame). fn create_hdr_bind_group( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, sampler: &wgpu::Sampler, texture: &wgpu::Texture, width: u32, height: u32, ) -> (wgpu::BindGroup, wgpu::Buffer) { // Write the uniform: exposure = 1.0, pad.xy = viewport size. // WGSL uniform layout: f32 at offset 0 (4B), vec3 at offset 16 (16B, aligned to 16). // Total = 32 bytes. We pack as 8 f32s: [exposure, 0, 0, 0, w, h, 0, 0]. let uniform_data = [ 1.0f32, // exposure (offset 0) 0.0, 0.0, 0.0, // padding to align vec3 to offset 16 width as f32, height as f32, 0.0, // pad: vec3 at offset 16 0.0, // trailing pad to 32 bytes ]; let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("tm uniform"), size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: true, }); { let mut w = uniform_buffer.slice(..).get_mapped_range_mut().expect("mapped buffer"); w.copy_from_slice(bytemuck::cast_slice(&uniform_data)); drop(w); uniform_buffer.unmap(); } let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("tm bind group"), layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&texture.create_view(&wgpu::TextureViewDescriptor::default())), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None, }), }, ], }); (bind_group, uniform_buffer) } /// Creates the full HDR pipeline (Étape 20): offscreen texture + TM pipeline + bind group. /// The pipeline uses the `TONEMAP_SHADER` with the entry point selected by the `ToneMapper` variant. fn create_hdr_pipeline( device: &wgpu::Device, _queue: &wgpu::Queue, width: u32, height: u32, tonemapper: ToneMapper, format: wgpu::TextureFormat, ) -> HdrPipeline { // 1. Offscreen HDR texture + view. let (texture, view) = create_hdr_texture(device, width, height); // 2. Sampler (linear, clamp). let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("hdr sampler"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, ..Default::default() }); // 3. Bind group layout: texture (0) + sampler (1) + uniform (2). let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("hdr bgl"), entries: &[ 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, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, 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, }, ], }); // 4. Render pipeline: fullscreen triangle (no vertex buffer) + selected TM entry point. let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("tonemap shader"), source: wgpu::ShaderSource::Wgsl(TONEMAP_SHADER.into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("hdr pipeline layout"), bind_group_layouts: &[Some(&layout)], ..Default::default() }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("tone mapping pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some(tonemapper.entry_point()), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState::from(format))], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: None, multisample: Default::default(), multiview_mask: None, cache: None, }); // 5. Bind group with the initial texture + viewport size. let (bind_group, uniform_buffer) = create_hdr_bind_group(device, &layout, &sampler, &texture, width, height); HdrPipeline { texture, view, pipeline, bind_group, uniform_buffer, layout, sampler, width, height, } } #[cfg(test)] mod tests { use super::*; #[test] fn batch_slots_groups_by_first_occurrence() { assert_eq!( batch_slots(&[1u32, 2, 1, 3, 2]), vec![vec![0, 2], vec![1, 4], vec![3]] ); } #[test] fn batch_slots_single_group() { assert_eq!(batch_slots(&[7u32, 7, 7]), vec![vec![0, 1, 2]]); } #[test] fn batch_slots_all_distinct() { assert_eq!(batch_slots(&[1u32, 2, 3]), vec![vec![0], vec![1], vec![2]]); } #[test] fn batch_slots_empty() { assert_eq!(batch_slots::(&[]), Vec::>::new()); } #[test] fn batch_slots_each_index_exactly_once() { let keys: Vec = (0..50).map(|i| i % 4).collect(); let groups = batch_slots(&keys); let mut all: Vec = groups.iter().flatten().copied().collect(); all.sort(); assert_eq!(all, (0..50).collect::>()); } #[test] fn batch_slots_deterministic_repeated() { let keys: Vec = vec![2, 0, 1, 2, 0, 1, 3]; assert_eq!(batch_slots(&keys), batch_slots(&keys)); } }