Files
wsg/lib/src/core/renderer.rs
T
Jérôme Bousquié 54a482e354 PBR
2026-09-25 14:40:47 +02:00

2064 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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<bool>,
/// 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<u32>,
/// 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<bool>,
/// 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<Vec<u32>>,
/// 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<HdrPipeline>,
/// 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<BloomPipeline>,
/// 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<wgpu::Texture>,
/// MSAA color view used as the main pass color attachment when MSAA is active.
msaa_color_view: Option<wgpu::TextureView>,
/// MSAA depth texture (N samples). `None` when MSAA is disabled.
msaa_depth_texture: Option<wgpu::Texture>,
/// MSAA depth view used as the main pass depth attachment when MSAA is active.
msaa_depth_view: Option<wgpu::TextureView>,
/// Fog configuration (Étape 25). `None` = fog disabled (zero overhead).
fog: Option<super::fog::FogConfig>,
/// DoF configuration (Étape 26). `None` = DoF disabled (zero overhead).
dof: Option<super::dof::DoFConfig>,
/// DoF pipeline (Étape 26). Present only when DoF + HDR are both active.
dof_pipeline: Option<super::dof::DoFPipeline>,
}
/// 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<ToneMapper>,
bloom_config: Option<BloomConfig>,
msaa_config: Option<MsaaConfig>,
fog: Option<super::fog::FogConfig>,
dof_config: Option<super::dof::DoFConfig>,
) -> 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<super::fog::FogConfig>) {
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<super::dof::DoFConfig>) {
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<usize>,
) {
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<usize>,
) -> 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<u32> = 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<Arc<Material>> = 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<Vec<u8>> = 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<u32> = (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<u32> {
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<K: Eq + Hash + Clone>(keys: &[K]) -> Vec<Vec<usize>> {
let mut groups: Vec<(K, Vec<usize>)> = 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<f32> 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<f32> 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::<u32>(&[]), Vec::<Vec<usize>>::new());
}
#[test]
fn batch_slots_each_index_exactly_once() {
let keys: Vec<u32> = (0..50).map(|i| i % 4).collect();
let groups = batch_slots(&keys);
let mut all: Vec<usize> = groups.iter().flatten().copied().collect();
all.sort();
assert_eq!(all, (0..50).collect::<Vec<_>>());
}
#[test]
fn batch_slots_deterministic_repeated() {
let keys: Vec<u32> = vec![2, 0, 1, 2, 0, 1, 3];
assert_eq!(batch_slots(&keys), batch_slots(&keys));
}
}