2064 lines
102 KiB
Rust
2064 lines
102 KiB
Rust
//! # 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));
|
||
}
|
||
}
|