awsm_renderer/renderer.rs
1//! High-level `AwsmRenderer` type, its builder, and core impls.
2//! Module tree + crate attributes live in [`crate`] (lib.rs);
3//! this file holds the logic. `use crate::*` brings the crate-root
4//! module names + re-exports into scope so the original crate-root-
5//! relative paths resolve unchanged.
6
7use crate::*;
8use std::sync::LazyLock;
9
10use awsm_renderer_core::{
11 brdf_lut::generate::{BrdfLut, BrdfLutOptions},
12 command::color::Color,
13 compatibility::CompatibilityRequirements,
14 cubemap::images::CubemapBitmapColors,
15 renderer::{AwsmRendererWebGpu, AwsmRendererWebGpuBuilder},
16};
17use bind_groups::BindGroups;
18use camera::CameraBuffer;
19use instances::Instances;
20use light_buckets::LightMeshBuckets;
21use lights::Lights;
22use materials::Materials;
23use meshes::Meshes;
24use pipelines::Pipelines;
25use scene_spatial::SceneSpatial;
26use shaders::Shaders;
27use textures::Textures;
28use transforms::Transforms;
29
30use crate::{
31 anti_alias::AntiAliasing,
32 bind_group_layout::BindGroupLayouts,
33 debug::AwsmRendererLogging,
34 environment::{Environment, Skybox},
35 features::RendererFeatures,
36 lights::ibl::{Ibl, IblTexture},
37 meshes::MeshKey,
38 picker::Picker,
39 pipeline_layouts::PipelineLayouts,
40 post_process::PostProcessing,
41 render_passes::{lines::LineRenderer, RenderPassInitContext, RenderPasses},
42 render_textures::{RenderTextureFormats, RenderTextures},
43};
44
45/// Per-frame state for the GPU coverage readback loop.
46///
47/// The renderer dispatches `coverage` after geometry, copies the
48/// counts into a CPU-mappable buffer, and kicks a `mapAsync` that
49/// resolves on a future frame. To keep the path single-buffered,
50/// `inflight` short-circuits the next kick while a prior `mapAsync`
51/// hasn't yet resolved; `pending_snapshot` carries the resolved
52/// `(MeshKey, count)` pairs back to the next render frame which
53/// calls `MeshCoverage::ingest`.
54#[derive(Default)]
55pub struct CoverageReadbackState {
56 pub inflight: bool,
57 pub pending_snapshot: Option<Vec<(MeshKey, u32)>>,
58}
59
60/// Verification of the Phase B per-cluster GPU cut+compaction: reads
61/// `draw_args.index_count` back and logs the drawn cut size (a sanity check vs the
62/// tested `select_cut_per_cluster`). `inflight` single-buffers the `mapAsync`.
63/// Re-fires on a cadence (frame 5, then every 30) — NOT one-shot — so the drawn cut
64/// is observable as the camera/scene change (Gap-B paging A2 + the A3 cut-vs-source
65/// numbers); the async handler logs only when the value changes (`last_value`,
66/// init `-1`).
67pub struct ClusterCutReadback {
68 pub inflight: bool,
69 pub frames: u64,
70 pub last_value: i64,
71}
72
73impl Default for ClusterCutReadback {
74 fn default() -> Self {
75 Self {
76 inflight: false,
77 frames: 0,
78 last_value: -1,
79 }
80 }
81}
82
83/// Per-frame state for the MSAA edge-budget overflow readback loop.
84///
85/// The render frame copies 8 bytes
86/// (`edge_count`, `edge_overflow_count`) from
87/// [`crate::render_passes::material_opaque::edge_buffers::MaterialEdgeBuffers::data_buffer`]
88/// into a CPU-mappable buffer, then kicks `mapAsync`. When the read
89/// resolves, the next frame's preamble inspects
90/// `pending_overflow_count`: if > 0, the renderer calls
91/// [`crate::AwsmRenderer::set_max_edge_budget`]`(current * 2)` so
92/// subsequent frames have headroom. Single-buffered (`inflight`
93/// gates the next kick) — under high mapping latency we lose one
94/// frame's signal rather than ringing a buffer.
95#[derive(Default)]
96pub struct EdgeOverflowReadbackState {
97 /// `true` while a `mapAsync` is in flight against
98 /// `MaterialEdgeBuffers::overflow_readback_buffer`. Subsequent
99 /// frames skip the copy + kick until the prior resolves.
100 pub inflight: bool,
101 /// Pending `(edge_count, edge_overflow_count)` snapshot from the
102 /// most recently resolved `mapAsync`. Ingested at the top of the
103 /// next render (set to `None` after reading).
104 pub pending_overflow_count: Option<(u32, u32)>,
105}
106
107/// Mirror of [`EdgeOverflowReadbackState`] for the GPU light-culling
108/// per-froxel capacity auto-grow loop. The cull shader atomic-adds
109/// into `LightCullingBuffers::overflow_buffer` every time it bumps a
110/// froxel's count past `max_per_froxel_capacity`; the host records a
111/// `copy_buffer_to_buffer` into the per-frame command encoder, then
112/// `mapAsync`'s the staging copy. When the resolved value is non-zero,
113/// the next render preamble calls
114/// [`crate::AwsmRenderer::set_max_per_froxel_capacity`]`(current * 2)`
115/// so subsequent frames have headroom. Single-buffered (`inflight`
116/// gates the next kick).
117#[derive(Default)]
118pub struct FroxelOverflowReadbackState {
119 /// `true` while a `mapAsync` is in flight against
120 /// `LightCullingBuffers::overflow_readback_buffer`. Subsequent
121 /// frames skip the copy + kick until the prior resolves.
122 pub inflight: bool,
123 /// Pending `overflow_count` snapshot from the most recently
124 /// resolved `mapAsync`. Ingested at the top of the next render
125 /// (set to `None` after reading).
126 pub pending_overflow_count: Option<u32>,
127}
128
129/// Main renderer state and GPU resources.
130pub struct AwsmRenderer {
131 pub gpu: core::renderer::AwsmRendererWebGpu,
132 pub bind_group_layouts: BindGroupLayouts,
133 pub bind_groups: BindGroups,
134 pub meshes: Meshes,
135 pub camera: CameraBuffer,
136 /// Renderer-wide per-frame uniform — `time`, `delta_time`,
137 /// `frame_count`, `resolution`. Updated once per `render()` call and
138 /// bound alongside the camera uniform in every shader pass. See
139 /// [`crate::frame_globals`] and [`AwsmRenderer::frame_globals`].
140 pub frame_globals: crate::frame_globals::FrameGlobals,
141 pub transforms: Transforms,
142 pub instances: Instances,
143 /// Renderer-owned spatial index over every mesh's world-space AABB.
144 /// Mirrors `Mesh::world_aabb`. Drives camera-frustum culling,
145 /// per-view shadow culling, and the per-mesh light-overlap query.
146 pub scene_spatial: SceneSpatial,
147 /// Per-light → per-mesh AABB-overlap buckets, rebuilt once per
148 /// frame from `scene_spatial`. Feeds the per-mesh light-list shader
149 /// path.
150 pub light_buckets: LightMeshBuckets,
151 /// Per-frame classify-pass output. Holds the per-`shader_id` tile
152 /// buckets + indirect-dispatch args the opaque material pipelines
153 /// consume.
154 pub material_classify_buffers: render_passes::material_classify::buffers::ClassifyBuffers,
155 /// `shader_id → bucket_index` lookup table (§4a) bound read-only into
156 /// the classify pass — the O(1) replacement for the old per-pixel
157 /// `shader_id == SHADER_ID_*` if/else chain. Rebuilt only when the
158 /// bucket set changes (`relayout_bucket_buffers`), independent of the
159 /// classify buckets (which realloc on viewport resize).
160 pub material_bucket_lut: render_passes::material_classify::bucket_lut::MaterialBucketLut,
161 /// GPU light-culling froxel buffers (per-frame params uniform +
162 /// per-froxel counts + flat indices + overflow counter). Owned at
163 /// the top level so the per-frame `ensure_viewport` / `write_params`
164 /// / `reset_overflow` calls run before bind-group recreation.
165 pub light_culling_buffers: render_passes::light_culling::LightCullingBuffers,
166 /// Debug toggle (dev aid): when non-zero, the shading shaders output a
167 /// per-pixel applied-punctual-light-count heatmap instead of normal
168 /// shading. Written into `CullParams.debug_light_heatmap` each frame via
169 /// `write_params`. Owned here (not on `LightCullingBuffers`) so it
170 /// survives froxel-buffer recreation on resize / auto-grow.
171 pub light_culling_debug_heatmap: u32,
172 /// Global debug view mode: 0 = normal shading, 1 = unlit/flat (base color
173 /// only). Written into `CullParams.debug_view_mode` each frame via
174 /// `write_params`; no recompile. Owned here (survives froxel-buffer
175 /// recreation). The shader branch that reads it is compiled only under the
176 /// `debug-views` cargo feature; in a game build the value is written but
177 /// never read.
178 pub debug_view_mode: u32,
179 /// Global debug wireframe overlay: 0 = off, 1 = on. Tints pixels near a
180 /// triangle edge (barycentric distance) in the deferred shade. Written into
181 /// `CullParams.debug_wireframe` each frame; no recompile. Read only by the
182 /// `debug-views`-gated shader branch.
183 pub debug_wireframe: u32,
184 /// MSAA-edge-resolve buffers (Stage 3 / Priority 3 dispatch wiring).
185 /// `None` when MSAA is off — there are no edges to resolve. When
186 /// MSAA is on, holds the two split GPU buffers carrying:
187 ///
188 /// - **`args_buffer`** — atomic counters + per-shader indirect
189 /// dispatch args. Indirect + Storage + CopyDst usage.
190 /// - **`data_buffer`** — `edge_to_xy` + `edge_slot_map` +
191 /// accumulator + per-shader/skybox sample lists. Storage +
192 /// CopyDst usage.
193 ///
194 /// Split so a single buffer is never simultaneously bound as
195 /// Storage(read-write) and used as Indirect inside one compute
196 /// pass (WebGPU rejects that combination). Reset per-frame via
197 /// `MaterialEdgeBuffers::reset_header`. Resized when bucket count
198 /// grows past current capacity.
199 pub material_edge_buffers:
200 Option<render_passes::material_opaque::edge_buffers::MaterialEdgeBuffers>,
201 /// `EdgeBufferLayout` uniform companion to `material_edge_buffers`.
202 /// Carries the u32-stride offsets the shaders use to slice into
203 /// the data buffer. Same lifecycle: `None` until first MSAA boot;
204 /// resized on bucket-count growth.
205 pub material_edge_layout_uniform: Option<web_sys::GpuBuffer>,
206 /// Projection-decal subsystem. Owns the per-decal GPU storage
207 /// buffer the `material_decal` compute pass reads at shading time.
208 /// `None` when `features.decals == false`.
209 pub decals: Option<decals::Decals>,
210 /// GPU occlusion-cull buffers. The per-frame instance list
211 /// (CPU-populated) + the per-instance visibility output. `None`
212 /// when `features.gpu_culling == false`.
213 pub occlusion_buffers: Option<render_passes::occlusion::buffers::OcclusionBuffers>,
214 /// Per-tile decal classify buckets. Populated by a `decal_classify`
215 /// compute pass run before the decal shading pass; the shading pass
216 /// reads only the per-tile subset. `None` when
217 /// `features.decals == false`.
218 pub decal_classify_buffers:
219 Option<render_passes::material_decal::classify::buffers::DecalClassifyBuffers>,
220 /// GPU compaction `IndirectDrawArgs` buffer. `None` when
221 /// `features.gpu_culling == false`.
222 pub compaction_buffers: Option<render_passes::occlusion::compaction::CompactionBuffers>,
223 /// Last-frame per-mesh pixel coverage. Populated by the GPU
224 /// coverage compute pass via `coverage_buffers` + asynchronous
225 /// readback; consumed by skin-skip / material-LOD gates. The
226 /// table itself is always present (it's CPU-only and tiny); when
227 /// `features.coverage_lod == false` it just stays empty, which
228 /// makes `is_below_threshold` return `false` for everything.
229 pub coverage: coverage::MeshCoverage,
230 /// Discrete-LOD level chains, keyed by base `MeshKey`. Populated by the
231 /// scene loader only when `features.lod` is on (otherwise empty, and every
232 /// instance draws its base mesh). The per-frame selection pass reads this to
233 /// choose a level per instance. See [`crate::lod`].
234 #[cfg(feature = "lod")]
235 pub lod: crate::lod::LodRegistry,
236 /// GPU coverage producer buffers. The producer pass
237 /// (`render_passes/coverage/`) atomic-adds per-pixel into
238 /// `counts_buffer`; the renderer copies to `readback_buffer`
239 /// each frame and a `mapAsync` resolves with last-frame's
240 /// counts on a future frame. The result feeds
241 /// [`crate::coverage::MeshCoverage::ingest`]. `None` when
242 /// `features.coverage_lod == false`.
243 pub coverage_buffers: Option<render_passes::coverage::buffers::CoverageBuffers>,
244 /// State for the coverage readback loop. `Arc<Mutex<…>>` so the
245 /// `spawn_local`-detached `mapAsync` future can write back into
246 /// it without re-borrowing the renderer — and so it stays
247 /// future-proof for the day the renderer moves across threads
248 /// (single-threaded today, so the lock is uncontested).
249 pub coverage_readback_state: std::sync::Arc<std::sync::Mutex<CoverageReadbackState>>,
250 /// One-shot Phase B cluster-cut readback verification (gated by
251 /// `virtual_geometry`; same `Arc<Mutex>` + `spawn_local` discipline).
252 pub cluster_cut_readback: std::sync::Arc<std::sync::Mutex<ClusterCutReadback>>,
253 /// State for the MSAA edge-budget auto-grow readback loop. Same
254 /// `Arc<Mutex<…>>` discipline as `coverage_readback_state` —
255 /// `mapAsync` writes through the lock from a detached
256 /// `spawn_local` future.
257 pub edge_overflow_readback_state: std::sync::Arc<std::sync::Mutex<EdgeOverflowReadbackState>>,
258 /// State for the GPU light-culling per-froxel capacity auto-grow
259 /// loop. Same `Arc<Mutex<…>>` discipline as the other readback
260 /// states.
261 pub froxel_overflow_readback_state:
262 std::sync::Arc<std::sync::Mutex<FroxelOverflowReadbackState>>,
263 /// Monotonic frame index. Wraps every ~272 years at 60 Hz — safe to
264 /// treat as unbounded for any practical session. Drives the
265 /// `skin_update_period` gate and other "every Nth frame" cadences.
266 pub frame_index: u64,
267 pub shaders: Shaders,
268 pub materials: Materials,
269 /// Runtime-registered dynamic materials. See
270 /// [`crate::dynamic_materials`].
271 pub dynamic_materials: crate::dynamic_materials::DynamicMaterials,
272 /// Set when a custom material registers/unregisters or its alpha-only WGSL
273 /// changes, so the next `finalize_gpu_textures` rebuilds the masked
274 /// (alpha-tested) pipelines for MASK customs even if no texture changed
275 /// (a procedural cutout needs no texture). Cleared by `finalize_gpu_textures`.
276 pub masked_dynamic_dirty: bool,
277 /// Renderer-wide variable-length per-material data pool. Backs
278 /// `BufferSlot` declarations on registered dynamic materials.
279 pub extras_pool: crate::dynamic_materials::extras_pool::ExtrasPool,
280 pub pipeline_layouts: PipelineLayouts,
281 pub pipelines: Pipelines,
282 pub lights: Lights,
283 pub textures: Textures,
284 pub logging: AwsmRendererLogging,
285 pub render_textures: RenderTextures,
286 pub render_passes: RenderPasses,
287 pub environment: Environment,
288 pub anti_aliasing: AntiAliasing,
289 /// Plan B shared-prep + deferred-shadow config, captured at build time
290 /// (`docs/plans/deferred-shared-prep-pass.md`). The shared prep pass is
291 /// unconditional; this only carries the `K` shadow-caster sizing knob.
292 pub prep_config: crate::render_passes::material_prep::PrepPassConfig,
293 pub post_processing: PostProcessing,
294 /// GPU mesh-picking subsystem. `None` when
295 /// `features.picking == false` (the default for library /
296 /// game builds). When `None`, [`Self::pick`] returns
297 /// [`crate::picker::PickResult::Disabled`].
298 pub picker: Option<Picker>,
299 pub lines: LineRenderer,
300 /// Auto-drive state for re-resolving HUD meshes' transparent
301 /// pipeline variants when a HUD mesh appears or the texture-pool /
302 /// MSAA shape changes. `None`-cost for builds that never insert a
303 /// HUD mesh (gated on `Meshes::has_seen_hud`). See
304 /// [`crate::render`]'s `kick_hud_resolve` / `poll_hud_resolve`.
305 pub(crate) hud_resolve: crate::render::HudResolveState,
306 /// Per-frame mipmap generator for the opaque RT — only dispatched
307 /// when the visible material set contains a transmissive material.
308 pub opaque_mipgen: opaque_mipgen::OpaqueMipgen,
309 /// Shadow mapping subsystem. Owns the depth atlas, EVSM atlas,
310 /// cube-array pool, descriptors, and the comparison / filterable
311 /// samplers used by the shadow-aware shading passes.
312 pub shadows: shadows::Shadows,
313 /// Opt-in feature gates picked at construction time.
314 pub features: RendererFeatures,
315 /// Adaptive runtime policy on top of `features`. `RendererFeatures`
316 /// decides which buffers/passes exist; `RendererOptimizationPolicy`
317 /// decides which of those are engaged this frame. Mutable via
318 /// `set_optimization_policy` — flips take effect on the next
319 /// `render()` call.
320 pub optimization_policy: crate::optimization_policy::RendererOptimizationPolicy,
321 /// Most recently computed per-frame derived flags. Used as the
322 /// previous-frame state for the next call to
323 /// `compute_frame_optimizations` (hysteresis input).
324 pub frame_optimizations: crate::optimization_policy::FrameOptimizations,
325 /// Consecutive frames the current `gpu_occlusion` mode has held.
326 /// Bumped each frame `frame_optimizations.gpu_occlusion` stays the
327 /// same; reset to 1 on a flip. Feeds the Auto-mode cooldown check
328 /// in `compute_frame_optimizations`.
329 pub frames_in_current_mode: u32,
330 /// Global default for `Mesh::cheap_material_pixel_threshold`.
331 /// Per-mesh override still wins; this is the value used when a
332 /// mesh has its threshold set to `None`.
333 /// Default `64`. Games tying material LOD to their own quality
334 /// system can write this directly each frame; no automatic
335 /// coupling to `ShadowQualityTier` (which is per-light, not
336 /// global).
337 pub default_cheap_material_pixel_threshold: u32,
338 /// Reusable scratch space for the per-frame renderable lists.
339 /// Held here (not constructed per-frame) so the Vec allocations
340 /// survive across frames; `collect_renderables` clears-in-place.
341 pub(crate) renderable_pool: crate::renderable::RenderablePool,
342 /// Pipeline-readiness scheduler. Owns the `FuturesUnordered` that
343 /// drives async compile, the SlotMap of material groups, and the
344 /// per-pass-kind map. Per the architecture in
345 /// `https://github.com/dakom/awsm-renderer/pull/99`, frontends submit
346 /// [`crate::pipeline_scheduler::PipelineGroupDef`]s, get [`crate::pipeline_scheduler::PipelineGroupId`]s back
347 /// immediately, and watch for status transitions via
348 /// `drain_pipeline_status_events` or `pipeline_group_status`.
349 ///
350 /// **Stage 1 status**: the scheduler is attached; the public API
351 /// surface (`submit_pipeline_group_batch`, `pipeline_group_status`,
352 /// `drain_pipeline_status_events`, `drop_material_group`,
353 /// `poll_pipeline_scheduler`) is wired below this struct. Compile
354 /// futures are currently stubs — Stage 1 follow-up wires each
355 /// `PipelineGroupDef` variant to the real compile path.
356 pub pipeline_scheduler: crate::pipeline_scheduler::PipelineScheduler,
357 /// Bucket-layout fingerprint of the last `ensure_scene_pipelines`
358 /// run. The render-driven compile path
359 /// ([`crate::AwsmRenderer::ensure_scene_pipelines`]) compares the
360 /// live bucket list's `dispatch_hash` + entry count against this to
361 /// decide whether the bucket SET changed (a new dynamic material
362 /// registered, a feature-set variant allocated, a material removed)
363 /// — which requires resizing the classify / edge GPU buffers +
364 /// rebuilding the edge-layout uniform + clearing the stale
365 /// layout-keyed pipeline caches BEFORE compiling against the new
366 /// layout. `None` until the first ensure. See
367 /// `ensure_scene_pipelines` for the ordering invariant.
368 pub(crate) last_ensured_bucket_layout: Option<(u64, usize)>,
369 /// True once `AwsmRendererBuilder::build` has finished its eager
370 /// batch. Config-change APIs (`set_anti_aliasing`,
371 /// `set_post_processing`) gate on this and return
372 /// [`crate::error::AwsmError::NotReady`] when called before. Per
373 /// the architecture doc's race policy.
374 pub(crate) build_complete: bool,
375 /// Recommended `ShadowQualityTier` set by the active
376 /// [`crate::profile::RendererProfile`]. Scene-side code that
377 /// registers shadow-casting lights should apply the matching
378 /// `LightShadowParams` preset to keep per-light shadow knobs
379 /// (cascade count, hardness, EVSM cutoff) coherent with the
380 /// rest of the profile's defaults. `None` when no profile was
381 /// applied.
382 pub recommended_shadow_quality_tier: Option<crate::shadows::ShadowQualityTier>,
383 // we pick between these on the fly.
384 // `pub(crate)` (not private) because `AwsmRenderer` now lives in the
385 // `renderer` submodule rather than the crate root: sibling modules
386 // (e.g. `render.rs`) read these directly, which previously worked via
387 // the private-at-crate-root → visible-to-all-descendants rule. Crate
388 // visibility is identical to before; external API is unchanged.
389 pub(crate) _clear_color_perceptual_to_linear: Color,
390 pub(crate) _clear_color: Color,
391
392 #[cfg(feature = "animation")]
393 pub animations: animation::Animations,
394
395 /// Per-camera authorable parameter store (projection, clip planes,
396 /// depth-of-field). Driven by `AnimationTarget::Camera` channels.
397 pub cameras: crate::cameras::Cameras,
398 /// Reused per-frame scratch for the cull path's mesh-count-scaling
399 /// allocations (opaque-snapshot list + packed occlusion-instance bytes).
400 /// `take`/restored across each `render()` to avoid per-frame allocator/GC
401 /// churn at high mesh counts. See [`crate::render::RenderFrameScratch`].
402 pub(crate) render_frame_scratch: crate::render::RenderFrameScratch,
403 /// The load-transaction render gate. `false` at build and after
404 /// [`AwsmRenderer::begin_load`] (show the loading screen); set `true` by
405 /// [`AwsmRenderer::commit_load`] once the scene's pipelines are compiled.
406 /// `render()` dispatches to `render_all` when true, `render_loading` (clear
407 /// only) when false. This is the WHOLE render-gate state — there is no
408 /// reactive per-frame compile to reason about (the old render-preamble
409 /// `reconcile_material_variants` → `ensure_scene_pipelines` compile now lives
410 /// only in `commit_load`).
411 pub(crate) scene_committed: bool,
412 /// Live phase of the in-flight (or last) [`AwsmRenderer::commit_load`], read
413 /// back by [`AwsmRenderer::loading_stats`] for imperative pollers.
414 pub(crate) load_phase: crate::loading::LoadPhase,
415 /// Texture-pool counts the current/last commit is uploading. Set by
416 /// `commit_load` around its single `finalize_gpu_textures`; surfaced through
417 /// `LoadingStats` so a loader can show texture-upload progress.
418 pub(crate) loading_textures_total: usize,
419 pub(crate) loading_textures_uploaded: usize,
420 /// Geometry-resolution counts for the current/last commit (the
421 /// `UploadingGeometry` phase) — surfaced through `LoadingStats` for granular
422 /// loading UI.
423 pub(crate) loading_geometry_total: usize,
424 pub(crate) loading_geometry_uploaded: usize,
425 /// Immutable snapshot of every build-time config knob, captured in `build()`.
426 /// [`AwsmRenderer::remove_all`] rebuilds from it so a scene-data wipe can't
427 /// drift a config. See [`RendererConfigSpec`].
428 pub(crate) config_spec: RendererConfigSpec,
429}
430
431/// Compatibility requirements for this renderer.
432///
433/// `storage_buffers` is the worst-case `maxStorageBuffersPerShaderStage`
434/// the opaque-material pass needs. Opaque currently binds:
435/// * 8 storage buffers in `@group(0)`: visibility_data,
436/// material_mesh_metas, materials, attribute_indices,
437/// attribute_data, transforms (packed model + normal — Option E),
438/// texture_transforms, instance_attrs.
439/// * 1 storage buffer in `@group(1)`: lights_storage (the GPU cull
440/// pass's per-froxel light slices).
441///
442/// Total = 9, leaving 1 spare under a 10-buffer limit. lights +
443/// lights_info are uniforms in group(1) (Option F); shading reads the
444/// per-pixel froxel light list from `lights_storage`, so no separate
445/// per-mesh slices storage buffer is needed. The transparent pass
446/// peaks at 9. Bumping this lower than
447/// the binding count will pass adapter compatibility on a device that
448/// exactly meets the declared limit, then fail pipeline validation
449/// when the shader is compiled.
450///
451/// ## Dynamic-materials `extras_pool` slot
452///
453/// The 10th storage-buffer slot is reserved for the `extras_pool`
454/// buffer that backs `BufferSlot` declarations on registered custom
455/// materials. The pool itself is documented at
456/// `crates/renderer/src/dynamic_materials/extras_pool.rs`; the
457/// per-binding wiring lives in the opaque + transparent passes'
458/// `bind_groups.wgsl` (binding 23 / 19 respectively).
459pub static COMPATIBITLIY_REQUIREMENTS: LazyLock<CompatibilityRequirements> =
460 LazyLock::new(|| CompatibilityRequirements {
461 storage_buffers: Some(10),
462 });
463
464impl AwsmRenderer {
465 /// Removes all scene data by rebuilding the renderer state.
466 ///
467 /// Preserves every field the user picked at build time — both the
468 /// historical set (`logging`, `clear_color`, `render_texture_formats`,
469 /// `features`, `optimization_policy`) and the
470 /// [`crate::profile::RendererProfile`]-derived bundle
471 /// (`anti_aliasing`, `post_processing`, `shadows_config`,
472 /// `max_edge_budget`, `scene_spatial_config`,
473 /// `recommended_shadow_quality_tier`). Forwarding the *current
474 /// values* rather than re-resolving the profile means any
475 /// post-profile per-knob override the frontend chained on top is
476 /// preserved too — `remove_all` is a scene-data wipe, not a
477 /// config-reset.
478 pub async fn remove_all(&mut self) -> crate::error::Result<()> {
479 // Scene-data wipe = rebuild from the build-time config snapshot. ONE line,
480 // no hand-copy, no drift: `config_spec` captured EVERY `with_*` knob at
481 // `build()`, so this can't silently drop a config the way the old
482 // field-by-field copy did (it dropped bucket cap / shadow-K / brdf-lut /
483 // env colors across this boundary). Scene content — meshes, lights, the
484 // live IBL/skybox textures — is intentionally NOT carried; the caller
485 // reloads the scene + re-sets the environment. See `RendererConfigSpec`.
486 *self = AwsmRendererBuilder::from_spec(self.gpu.clone(), self.config_spec.clone())
487 .build()
488 .await?;
489 Ok(())
490 }
491
492 // =====================================================================
493 // The load transaction: begin_load → adds →
494 // commit_load. The ONE public way to get content compiled + on screen.
495 // =====================================================================
496
497 /// Request the loading screen until the next commit: sets
498 /// `scene_committed = false` so `render()` clears to the clear-color (a
499 /// loading overlay draws on top) instead of drawing a half-compiled scene.
500 ///
501 /// Call this before a **cold / full load**. **SKIP it for a live add** —
502 /// leaving `scene_committed` true keeps the existing scene on screen while
503 /// the new content compiles in the background; the new meshes simply aren't
504 /// drawn until the matching `commit_load` resolves.
505 pub fn begin_load(&mut self) {
506 self.scene_committed = false;
507 self.load_phase = crate::loading::LoadPhase::Idle;
508 }
509
510 /// THE single compile point of the load transaction. Finalizes the texture
511 /// pool ONCE, resolves material variants, kicks every needed pipeline
512 /// compile, drains them CONCURRENTLY (`FuturesUnordered`), reports progress
513 /// through `on_progress`, and sets `scene_committed = true`. Identical code
514 /// for cold-load, full-reload, and live add — the only differences are the
515 /// app's choices to call `begin_load` and to `await` (or not) this future.
516 ///
517 /// Cheap no-op when nothing changed since the last commit (the content-keyed
518 /// caches make finalize + every compile a hit).
519 pub async fn commit_load(
520 &mut self,
521 mut on_progress: impl FnMut(crate::loading::LoadingStats),
522 ) -> crate::error::Result<crate::loading::LoadingStats> {
523 use crate::loading::{LoadPhase, LoadingStats};
524
525 // ── Phase 0: resolve geometry — derive + upload each registered geometry's
526 // needed pass representations (visibility/transparency) from the union of
527 // its bound materials, ONCE each, then free the source (§1 ②). Runs first
528 // so meshes have their buffers before the texture/compile phases. (The
529 // resolution body + the bindings it consumes land with the add_mesh
530 // deferral; today the registry is empty so this just reports the phase.)
531 self.load_phase = LoadPhase::UploadingGeometry;
532 self.resolve_geometry(&mut on_progress)?;
533
534 // ── Phase 1: finalize the texture pool ONCE (the single batched GPU
535 // upload of every staged image). Ordered FIRST — every
536 // opaque/classify/edge pipeline's shader bakes in
537 // `texture_pool_arrays_len`, so compiling before the pool is final
538 // would compile against a stale pool that finalize then wipes,
539 // forcing the recompile this design exists to delete. finalize-first
540 // ⇒ the compile in phase 2 runs exactly ONCE against the final pool.
541 // (The spec lists reconcile before finalize, but reconcile *embeds*
542 // the compile-kick, so finalize must precede it to hit the §7
543 // "one edge compile per load" goal.)
544 self.load_phase = LoadPhase::FinalizingTextures;
545 self.loading_textures_total = self.textures.resource_counts().0;
546 self.loading_textures_uploaded = 0;
547 on_progress(self.loading_stats());
548
549 self.finalize_gpu_textures().await?;
550
551 self.loading_textures_total = self.textures.resource_counts().0;
552 self.loading_textures_uploaded = self.loading_textures_total;
553 on_progress(self.loading_stats());
554
555 // ── Phase 2: resolve PBR/Toon feature-set variants against the now-final
556 // textures and kick the scene's pipeline compiles. This is the moved
557 // render-preamble compile: `reconcile_material_variants` internally
558 // drives `ensure_scene_pipelines` (opaque + classify + edge) — run
559 // ONLY here now, never per render frame.
560 self.reconcile_material_variants()?;
561
562 // ── Phase 3: drain every kicked compile to completion CONCURRENTLY,
563 // mapping each resolution into `LoadingStats`. This reuses the
564 // existing concurrent drain (which also warms the transparent + line
565 // pipelines) — it is not reimplemented here.
566 self.load_phase = LoadPhase::Compiling;
567 let textures_total = self.loading_textures_total;
568 let geometry_total = self.loading_geometry_total;
569 self.drain_commit_compiles(|cp| {
570 on_progress(LoadingStats::from_parts(
571 LoadPhase::Compiling,
572 geometry_total,
573 geometry_total,
574 textures_total,
575 textures_total,
576 cp,
577 ));
578 })
579 .await?;
580
581 // ── Phase 4: committed — `render()` switches to `render_all`.
582 self.scene_committed = true;
583 self.load_phase = LoadPhase::Ready;
584 let final_stats = self.loading_stats();
585 on_progress(final_stats);
586 Ok(final_stats)
587 }
588
589 /// Phase 0 of [`Self::commit_load`]: derive + upload each registered geometry's
590 /// needed pass representations (visibility / transparency) from the union of its
591 /// bound materials — once each — then free the source (§1 ②).
592 ///
593 /// The resolution body + the mesh→geometry bindings it consumes land with the
594 /// `add_mesh` deferral; today the geometry registry is empty (producers still use
595 /// the legacy eager `insert`), so this reports the phase over a 0-count registry.
596 fn resolve_geometry(
597 &mut self,
598 on_progress: &mut impl FnMut(crate::loading::LoadingStats),
599 ) -> crate::error::Result<()> {
600 let total = self.meshes.geometry_count();
601 self.loading_geometry_total = total;
602 self.loading_geometry_uploaded = 0;
603 on_progress(self.loading_stats());
604
605 // Derive + upload each geometry's needed representations once (per the union
606 // of its bound materials), wire the bound meshes to the shared resource, and
607 // free the source. Then sync each newly-resolved mesh into the spatial index
608 // (deferred to here so skinned meshes flag correctly — the resource exists now).
609 let wired = self
610 .meshes
611 .resolve_geometry(&self.materials, &self.transforms)?;
612 for mesh_key in wired {
613 self.sync_spatial_for_mesh(mesh_key);
614 }
615
616 self.loading_geometry_uploaded = total;
617 on_progress(self.loading_stats());
618 Ok(())
619 }
620
621 /// Imperative snapshot of the same `LoadingStats` that `commit_load`'s
622 /// `on_progress` reports — for pollers driving a loading UI off a render-loop
623 /// tick rather than the callback.
624 pub fn loading_stats(&self) -> crate::loading::LoadingStats {
625 crate::loading::LoadingStats::from_parts(
626 self.load_phase,
627 self.loading_geometry_total,
628 self.loading_geometry_uploaded,
629 self.loading_textures_total,
630 self.loading_textures_uploaded,
631 self.compile_progress(),
632 )
633 }
634
635 /// Returns the active feature gates picked at construction time.
636 pub fn features(&self) -> &RendererFeatures {
637 &self.features
638 }
639
640 /// Force-compile the routinely-used WebGPU pipelines ahead of the
641 /// first user-interactive frame, so the first draw doesn't stall
642 /// on shader compilation, exploiting the browser's PSO cache so the
643 /// driver reuses already-compiled pipeline state objects.
644 ///
645 /// ## What's already prewarmed at construction time
646 ///
647 /// `AwsmRendererBuilder::build()` already compiles, in parallel:
648 ///
649 /// - **Opaque-compute** material kernels — only the empty kernel for the
650 /// active MSAA (the no-meshes / skybox-only fallback). The first-party
651 /// material shaders (PBR / Unlit / Toon / Flipbook) are **NOT** compiled
652 /// at boot — they compile lazily on first use via
653 /// [`Self::ensure_scene_pipelines`], so a project that uses none of them
654 /// pays zero material-shader compile cost at startup. See the
655 /// `MaterialOpaquePipelines` module docs + `shader_descriptors_and_layouts`.
656 /// - **Geometry render pipelines** — every (MSAA × instancing ×
657 /// storage-array × cull_mode) variant. See
658 /// `GeometryRenderPipelineKeys::new`.
659 /// - **Shadow / HZB / coverage / decal / classify / light-culling**
660 /// passes — all built once during `RenderPasses::new`.
661 ///
662 /// So this method is **mostly a labelling hook today** — its real
663 /// payoff is the call-site UX: a consumer can advance their boot
664 /// loader to "Compiling shaders…" before this call and back to
665 /// "Loading assets…" after, giving users a precise progress
666 /// indicator over the multi-hundred-ms shader-compile window that
667 /// previously appeared as a generic "Initializing renderer…".
668 ///
669 /// ## What this method does today
670 ///
671 /// - **Builder-time prewarm** has already compiled the empty-opaque kernel,
672 /// the geometry passes, hzb, material_classify, effects, decal, shadows,
673 /// and the picker / line variants — but **not** the first-party material
674 /// pipelines (those are lazy). Calling this at the end of `build()` kicks
675 /// `ensure_scene_pipelines`, which compiles whatever the *live* scene
676 /// actually needs (none, on an empty scene); cached keys return
677 /// immediately.
678 ///
679 /// - **Per-scene transparent prewarm** runs whenever the caller
680 /// invokes this method after meshes have been populated. It
681 /// walks `self.meshes`, deduplicates by
682 /// `(buffer_info, material)` (the granularity the transparent
683 /// pipeline cache keys against), and issues one batched
684 /// `ensure_keys` covering every unique transparent shader +
685 /// pipeline variant the live scene needs. The first transparent
686 /// draw then hits warm cache instead of stalling on N
687 /// `createRenderPipelineAsync` awaits. Mirrors what
688 /// `finalize_gpu_textures` does on a texture-pool-dirty cycle;
689 /// safe to call any number of times (subsequent calls are
690 /// cache-hit no-ops).
691 ///
692 /// ## When the warm prewarm helps vs not
693 ///
694 /// The transparent shader/pipeline cache key includes
695 /// `texture_pool_arrays_len` + `texture_pool_samplers_len`. Those
696 /// values change every time a *new texture array shape* enters the
697 /// pool — which on a fresh load happens once when the first model's
698 /// textures finalize, and then never again for the same scene.
699 /// So:
700 ///
701 /// - If the caller invokes `prewarm_pipelines()` **before any
702 /// models are loaded** (the historical pattern), the texture
703 /// pool is empty (`arrays_len = 0`), and any pipelines warmed
704 /// here are invalidated the moment the first model finishes
705 /// loading and the pool grows. The call is a no-op for that
706 /// case — only its tracing span fires.
707 /// - If the caller invokes it **after a model has loaded** (or
708 /// the texture-pool capacity is otherwise pinned at the value
709 /// the scene will actually use), the warmed pipelines are the
710 /// real ones the renderer will draw with. This is the case
711 /// that absorbs the per-mesh first-draw stall when *switching*
712 /// between models that share the texture-pool shape but
713 /// introduce new geometry attribute combinations.
714 ///
715 /// ## Idempotent + cheap on warm cache
716 ///
717 /// Calling this multiple times is a no-op past the first
718 /// invocation: every underlying `ensure_keys` is a cache-keyed
719 /// lookup. On a Chrome session with a warm GPU disk cache, the
720 /// whole call completes in <5 ms. On a cold cache (post-redeploy
721 /// first-ever visit) it costs 50–500 ms per N transparent
722 /// variants — the same compile tax the first draw would have
723 /// paid, just relocated to a phase the consumer can label
724 /// clearly.
725 ///
726 /// ## Material pipelines (classify / opaque / edge resolve)
727 ///
728 /// These are compiled by the single render-driven operation
729 /// [`Self::ensure_scene_pipelines`], which this method kicks once up
730 /// front so the awaited readiness path (`wait_for_pipelines_ready`)
731 /// has promises to drain. It covers every live bucket (first-party
732 /// canonical, PBR/Toon feature-set variants, and custom dynamic
733 /// materials) at the active AA config; idempotent on cache hits.
734 pub(crate) async fn prewarm_pipelines(&mut self) -> crate::error::Result<()> {
735 let _maybe_span = if self.logging.render_timings.sub_frame() {
736 Some(tracing::span!(tracing::Level::INFO, "Prewarm Pipelines").entered())
737 } else {
738 None
739 };
740
741 // Material pipelines (classify / opaque / per-shader + skybox +
742 // final_blend edge resolve) are compiled by THE single render-driven
743 // operation: `ensure_scene_pipelines`. Kick it here so the up-front
744 // warm path (`wait_for_pipelines_ready`) has the promises in flight
745 // to drain. It compiles only the ACTIVE config's variants for every
746 // live bucket (incl. PBR feature-set variants — they live in
747 // `bucket_entries` even with an empty custom registry), handles any
748 // bucket-SET change (resize buffers + clear stale caches first), and
749 // is idempotent on a warm cache. The edge-resolve set is rebuilt
750 // inside it via the same `MaterialEdgePipelines::build_descriptors` /
751 // `desired_keys` path the background relaunch uses, so the two never
752 // diverge.
753 self.ensure_scene_pipelines()?;
754
755 // Build one request per mesh. `ensure_keys` on both caches
756 // dedupes internally by cache key, so we don't need to
757 // dedupe at the request level — and dedup'ing here by
758 // `(buffer_info, material)` OR-style (the previous
759 // pre-existing pattern) misses pairs like (A,M1)(B,M2)(A,M2)
760 // when M1 and M2 differ in `writes_depth`, which would
761 // leave some meshes with stale pipeline-key map entries.
762 let mut requests: Vec<
763 crate::render_passes::material_transparent::pipeline::TransparentMeshPipelineRequest,
764 > = Vec::new();
765 for (mesh_key, mesh) in self.meshes.iter() {
766 // Only warm transparent pipelines for transparent-pass meshes — an
767 // opaque (incl. opaque-dynamic) material can't compile against the
768 // transparent fragment contract.
769 if !self.materials.is_transparency_pass(mesh.material_key) {
770 continue;
771 }
772 let buffer_info_key = self.meshes.buffer_info_key(mesh_key)?;
773 let writes_depth = self.materials.transparent_writes_depth(mesh.material_key);
774 let (base, pbr_features) = self.materials.transparent_variant(mesh.material_key);
775 let dynamic_shader_id = matches!(base, crate::dynamic_materials::ShadingBase::Custom)
776 .then(|| self.materials.shader_id(mesh.material_key));
777 let dynamic_shader =
778 dynamic_shader_id.and_then(|id| self.dynamic_materials.shader_info_for(id));
779 let dynamic_vertex_shader =
780 dynamic_shader_id.and_then(|id| self.dynamic_materials.vertex_shader_info_for(id));
781 requests.push(
782 crate::render_passes::material_transparent::pipeline::TransparentMeshPipelineRequest {
783 mesh,
784 mesh_key,
785 buffer_info_key,
786 writes_depth,
787 base,
788 pbr_features,
789 dynamic_shader_id,
790 dynamic_shader,
791 dynamic_vertex_shader,
792 },
793 );
794 }
795
796 if requests.is_empty() {
797 return Ok(());
798 }
799
800 self.render_passes
801 .material_transparent
802 .pipelines
803 .set_render_pipeline_keys_batched(
804 &self.gpu,
805 requests,
806 &mut self.shaders,
807 &mut self.pipelines,
808 &self.render_passes.material_transparent.bind_groups,
809 &self.pipeline_layouts,
810 &self.meshes.buffer_infos,
811 &self.anti_aliasing,
812 &self.textures,
813 &self.render_textures.formats,
814 )
815 .await?;
816
817 Ok(())
818 }
819
820 /// Returns the current adaptive policy.
821 pub fn optimization_policy(&self) -> &crate::optimization_policy::RendererOptimizationPolicy {
822 &self.optimization_policy
823 }
824
825 /// Replaces the adaptive policy. Takes effect on the next
826 /// `render()`. If the new policy disables `gpu_occlusion`
827 /// (Force→Off, or Auto's hysteresis later landing there), the next
828 /// frame's `compute_frame_optimizations` will flip
829 /// `frame_optimizations.gpu_occlusion = false`, which render.rs
830 /// uses to poison `compaction_buffers.args_ready` — so a future
831 /// re-enable warms up through the CPU geometry path for one frame
832 /// before drawIndirect resumes.
833 pub fn set_optimization_policy(
834 &mut self,
835 policy: crate::optimization_policy::RendererOptimizationPolicy,
836 ) {
837 // Reset cooldown when the mode itself changes — flipping from
838 // Auto to Force (or vice versa) shouldn't be held off by a
839 // residual Auto cooldown counter.
840 if policy.gpu_culling != self.optimization_policy.gpu_culling {
841 self.frames_in_current_mode = u32::MAX / 2;
842 }
843 self.optimization_policy = policy;
844 }
845
846 /// Aggregate Phase-2.1 upload-ring telemetry across every
847 /// renderer subsystem with a `MappedUploader`. Returned as a
848 /// `(label, stats)` list so a caller (e.g. a dev telemetry export)
849 /// can render per-subsystem + rolled-up totals.
850 pub fn upload_ring_stats(
851 &self,
852 ) -> Vec<(
853 &'static str,
854 crate::buffer::mapped_staging_ring::UploadStats,
855 )> {
856 let mut v = vec![
857 ("transforms", self.transforms.upload_stats()),
858 ("materials", self.materials.upload_stats()),
859 (
860 "instances.transforms",
861 self.instances.transform_upload_stats(),
862 ),
863 (
864 "instances.attributes",
865 self.instances.attribute_upload_stats(),
866 ),
867 (
868 "meshes.meta.geometry",
869 self.meshes.meta.geometry_upload_stats(),
870 ),
871 (
872 "meshes.meta.material",
873 self.meshes.meta.material_upload_stats(),
874 ),
875 (
876 "meshes.skins.matrices",
877 self.meshes.skins.matrices_upload_stats(),
878 ),
879 (
880 "meshes.skins.joint_index_weights",
881 self.meshes.skins.joint_index_weights_upload_stats(),
882 ),
883 (
884 "meshes.morphs.geometry.weights",
885 self.meshes.morphs.geometry.weights_upload_stats(),
886 ),
887 (
888 "meshes.morphs.geometry.values",
889 self.meshes.morphs.geometry.values_upload_stats(),
890 ),
891 (
892 "meshes.morphs.material.weights",
893 self.meshes.morphs.material.weights_upload_stats(),
894 ),
895 (
896 "meshes.morphs.material.values",
897 self.meshes.morphs.material.values_upload_stats(),
898 ),
899 (
900 "textures.transforms",
901 self.textures.texture_transforms_upload_stats(),
902 ),
903 ("meshes.pool", self.meshes.upload_stats()),
904 // Phase-2.1 raw-writeBuffer promotions (this sprint):
905 ("camera", self.camera.upload_stats()),
906 ("frame_globals", self.frame_globals.upload_stats()),
907 ("lights", self.lights.upload_stats()),
908 ("shadows", self.shadows.upload_stats()),
909 ];
910 if let Some(occ) = self.occlusion_buffers.as_ref() {
911 v.push(("occlusion", occ.upload_stats()));
912 }
913 v
914 }
915}
916
917/// Coarse-grained stages the renderer passes through during
918/// [`AwsmRendererBuilder::build`]. Subscribers passed via
919/// [`AwsmRendererBuilder::with_phase_handler`] get a callback at each
920/// transition; consumer UIs can map these to whatever progress
921/// message they want to show.
922///
923/// The most useful UX win these surface is the **cold WebGPU cache**
924/// case: a fresh Chrome profile can sit on `CompilingShaders` for
925/// tens of seconds while Dawn + the GPU driver lower every shader to
926/// MSL on the first visit. Showing "Browser is compiling shaders…
927/// (first load may take a while)" rather than a frozen "Initializing
928/// renderer…" is the difference between a user assuming the app is
929/// broken and a user knowing the browser is doing real work that
930/// will be cached next time.
931#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
932pub enum RendererLoadingPhase {
933 /// Adapter / device acquisition + initial bookkeeping +
934 /// supporting GPU resource generation (IBL default cubemaps,
935 /// BRDF LUT compute, opaque-mipgen pipeline) + render-pass
936 /// shader cache key collection. No Dawn shader / pipeline
937 /// compile work in this phase — it's the concurrent setup that
938 /// feeds the cross-renderer pool.
939 Init,
940 /// The cross-renderer shader pool is running:
941 /// one `Shaders::ensure_keys` covering every shader the
942 /// renderer compiles (RenderPasses + Picker + LineRenderer +
943 /// Shadows caster + Effects + Display), joined with EVSM
944 /// inline-shader `validate_shader` futures. On a cold PSO disk
945 /// cache this is where Dawn lowers WGSL → MSL; on a warm cache
946 /// it's a cache-hit lookup.
947 CompilingShaders,
948 /// The cross-renderer pipeline pool is running: one
949 /// `try_join`'d `ComputePipelines::ensure_keys` +
950 /// `RenderPipelines::ensure_keys` covering every compute and
951 /// render pipeline across the entire renderer.
952 BuildingPipelines,
953 /// All renderer-init work done; ready to render.
954 Ready,
955}
956
957/// Boxed phase-transition callback handed to the builder via
958/// [`AwsmRendererBuilder::with_phase_handler`]. wasm is
959/// single-threaded so we don't need `Send + Sync`.
960pub type RendererLoadingPhaseHandler = Box<dyn FnMut(RendererLoadingPhase)>;
961
962/// Immutable snapshot of every build-time config knob the embedder chose — the
963/// declare→commit-for-config analog of the load transaction. Captured at
964/// `build()` and stored on [`AwsmRenderer`]; [`AwsmRenderer::remove_all`]
965/// rebuilds straight from it via [`AwsmRendererBuilder::from_spec`], so a
966/// scene-data wipe can NEVER silently drop a config (the historical hand-copy
967/// in `remove_all` repeatedly did — bucket cap, shadow-K, brdf-lut, env colors).
968///
969/// Mirrors the builder's raw inputs exactly (the `Option`s + the depth override),
970/// not resolved values, so `from_spec(...).build()` re-runs identical resolution.
971/// The only builder fields NOT captured are `gpu` (passed to `from_spec`
972/// separately) and `phase_handler` (a non-clonable callback irrelevant to a
973/// rebuild). Add a field here whenever a new build-time `with_*` knob is added.
974#[derive(Clone)]
975pub struct RendererConfigSpec {
976 logging: AwsmRendererLogging,
977 render_texture_formats: Option<RenderTextureFormats>,
978 brdf_lut_options: BrdfLutOptions,
979 clear_color: Color,
980 skybox_colors: CubemapBitmapColors,
981 ibl_filtered_env_colors: CubemapBitmapColors,
982 ibl_irradiance_colors: CubemapBitmapColors,
983 anti_aliasing: AntiAliasing,
984 post_processing: PostProcessing,
985 shadows_config: Option<shadows::ShadowsConfig>,
986 features: RendererFeatures,
987 max_edge_budget: Option<u32>,
988 bucket_config: Option<crate::dynamic_materials::BucketConfig>,
989 prep_config: crate::render_passes::material_prep::PrepPassConfig,
990 optimization_policy: crate::optimization_policy::RendererOptimizationPolicy,
991 scene_spatial_config: Option<crate::scene_spatial::SceneSpatialConfig>,
992 recommended_shadow_quality_tier: Option<crate::shadows::ShadowQualityTier>,
993 render_texture_formats_depth_override: Option<awsm_renderer_core::texture::TextureFormat>,
994}
995
996/// Builder for `AwsmRenderer`.
997pub struct AwsmRendererBuilder {
998 gpu: AwsmRendererGpuBuilderKind,
999 logging: AwsmRendererLogging,
1000 render_texture_formats: Option<RenderTextureFormats>,
1001 brdf_lut_options: BrdfLutOptions,
1002 clear_color: Color,
1003 // all these colors are typically replaced when loading external textures
1004 // but we want something to show by default
1005 skybox_colors: CubemapBitmapColors,
1006 ibl_filtered_env_colors: CubemapBitmapColors,
1007 ibl_irradiance_colors: CubemapBitmapColors,
1008 anti_aliasing: AntiAliasing,
1009 post_processing: PostProcessing,
1010 /// Renderer-wide shadow config picked up at construction time.
1011 /// Resource-shaped fields (`atlas_size`, `point_shadow_resolution`,
1012 /// `max_point_shadows`, `evsm_atlas_size`) are baked into the
1013 /// shadow textures; runtime tweaks of those need a renderer
1014 /// rebuild. Defaults via `ShadowsConfig::default()` if unset.
1015 shadows_config: Option<shadows::ShadowsConfig>,
1016 /// Opt-in feature gates. Defaults to both flags `false` so library
1017 /// consumers pay zero cost for unused GPU-driven culling / decal
1018 /// infrastructure.
1019 features: RendererFeatures,
1020 /// Block C.2: optional override for the
1021 /// [`MaterialEdgeBuffers`](crate::render_passes::material_opaque::edge_buffers::MaterialEdgeBuffers)
1022 /// `MAX_EDGE_BUDGET`. `None` → platform default (desktop). Set
1023 /// via [`AwsmRendererBuilder::with_max_edge_budget`] to grow the
1024 /// edge budget upfront for pathological-edge-density scenes
1025 /// (dense foliage at 4K, etc.). Consumers monitoring
1026 /// edge_overflow_count via CPU readback can also grow the budget
1027 /// at runtime via [`AwsmRenderer::set_max_edge_budget`].
1028 max_edge_budget: Option<u32>,
1029 /// Registration ceiling for co-resident material buckets (§2). `None`
1030 /// → default 32 (identical to today). Set via
1031 /// [`AwsmRendererBuilder::with_bucket_config`]; validated `1..=65534`.
1032 bucket_config: Option<crate::dynamic_materials::BucketConfig>,
1033 /// Plan B shared-prep + deferred-shadow config
1034 /// (`docs/plans/deferred-shared-prep-pass.md`). Inert until the prep pass is
1035 /// wired in; `enabled` defaults `false` (legacy recompute-in-shader path).
1036 prep_config: crate::render_passes::material_prep::PrepPassConfig,
1037 /// Adaptive runtime policy. Defaults to `Auto` mode for the
1038 /// gpu_culling path; library consumers can override at build time
1039 /// (or via `AwsmRenderer::set_optimization_policy` later) to force
1040 /// the path on/off or to retune the Auto thresholds.
1041 optimization_policy: crate::optimization_policy::RendererOptimizationPolicy,
1042 /// Optional consumer-supplied callback fired at each
1043 /// [`RendererLoadingPhase`] transition during `build()`. UI
1044 /// consumers wire this to update a loading overlay; tracing /
1045 /// telemetry consumers can use it to record per-phase elapsed
1046 /// time.
1047 phase_handler: Option<RendererLoadingPhaseHandler>,
1048 /// Optional override for the BVH rebuild cadence. `None` →
1049 /// `SceneSpatialConfig::default()`. Set via
1050 /// [`AwsmRendererBuilder::with_scene_spatial_config`] directly
1051 /// or indirectly via [`AwsmRendererBuilder::with_profile`].
1052 scene_spatial_config: Option<crate::scene_spatial::SceneSpatialConfig>,
1053 /// Recommended `ShadowQualityTier` from the active profile.
1054 /// Surfaced via [`AwsmRenderer::recommended_shadow_quality_tier`]
1055 /// so scene-side code that registers shadow-casting lights can
1056 /// apply the matching `LightShadowParams` preset on insert.
1057 /// `None` when no profile is set.
1058 recommended_shadow_quality_tier: Option<crate::shadows::ShadowQualityTier>,
1059 /// Pending depth-format override stashed by `with_profile` when
1060 /// no user-supplied `RenderTextureFormats` exists yet. Applied
1061 /// inside `build()` after the per-device probe — that's where
1062 /// the rest of the format defaults come from. `None` when no
1063 /// profile selected one (or the user already supplied a full
1064 /// formats struct, in which case `with_profile` mutates it in
1065 /// place).
1066 render_texture_formats_depth_override: Option<awsm_renderer_core::texture::TextureFormat>,
1067}
1068
1069/// WebGPU builder input for `AwsmRendererBuilder`.
1070pub enum AwsmRendererGpuBuilderKind {
1071 /// Build from a WebGPU builder.
1072 WebGpuBuilder(AwsmRendererWebGpuBuilder),
1073 /// Use an already-built WebGPU context.
1074 WebGpuBuilt(AwsmRendererWebGpu),
1075}
1076
1077impl From<AwsmRendererWebGpuBuilder> for AwsmRendererGpuBuilderKind {
1078 fn from(builder: AwsmRendererWebGpuBuilder) -> Self {
1079 AwsmRendererGpuBuilderKind::WebGpuBuilder(builder)
1080 }
1081}
1082
1083impl From<AwsmRendererWebGpu> for AwsmRendererGpuBuilderKind {
1084 fn from(gpu: AwsmRendererWebGpu) -> Self {
1085 AwsmRendererGpuBuilderKind::WebGpuBuilt(gpu)
1086 }
1087}
1088
1089impl AwsmRendererBuilder {
1090 /// Creates a new renderer builder from a WebGPU builder or context.
1091 pub fn new(gpu: impl Into<AwsmRendererGpuBuilderKind>) -> Self {
1092 Self {
1093 gpu: gpu.into(),
1094 logging: AwsmRendererLogging::default(),
1095 render_texture_formats: None,
1096 clear_color: Color::BLACK,
1097 brdf_lut_options: BrdfLutOptions::default(),
1098 skybox_colors: CubemapBitmapColors {
1099 z_positive: Color::BLACK,
1100 z_negative: Color::BLACK,
1101 x_positive: Color::BLACK,
1102 x_negative: Color::BLACK,
1103 y_positive: Color::BLACK,
1104 y_negative: Color::BLACK,
1105 },
1106 // skybox_colors: CubemapBitmapColors {
1107 // z_positive: Color::from_hex_rgb(0xFF0000), // red
1108 // z_negative: Color::from_hex_rgb(0x00FF00), // green
1109 // x_positive: Color::from_hex_rgb(0x0000FF), // blue
1110 // x_negative: Color::from_hex_rgb(0xFFFF00), // yellow
1111 // y_positive: Color::from_hex_rgb(0xFF00FF), // magenta
1112 // y_negative: Color::from_hex_rgb(0x00FFFF), // cyan
1113 // },
1114 ibl_filtered_env_colors: CubemapBitmapColors {
1115 z_positive: Color::WHITE,
1116 z_negative: Color::WHITE,
1117 x_positive: Color::WHITE,
1118 x_negative: Color::WHITE,
1119 y_positive: Color::WHITE,
1120 y_negative: Color::WHITE,
1121 },
1122 ibl_irradiance_colors: CubemapBitmapColors {
1123 z_positive: Color::WHITE,
1124 z_negative: Color::WHITE,
1125 x_positive: Color::WHITE,
1126 x_negative: Color::WHITE,
1127 y_positive: Color::WHITE,
1128 y_negative: Color::WHITE,
1129 },
1130 anti_aliasing: AntiAliasing::default(),
1131 post_processing: PostProcessing::default(),
1132 shadows_config: None,
1133 features: RendererFeatures::default(),
1134 max_edge_budget: None,
1135 bucket_config: None,
1136 prep_config: crate::render_passes::material_prep::PrepPassConfig::default(),
1137 optimization_policy: crate::optimization_policy::RendererOptimizationPolicy::default(),
1138 phase_handler: None,
1139 scene_spatial_config: None,
1140 recommended_shadow_quality_tier: None,
1141 render_texture_formats_depth_override: None,
1142 }
1143 }
1144
1145 /// Snapshot every build-time config knob into a [`RendererConfigSpec`].
1146 /// Called by `build()` to stash the spec on the renderer for `remove_all`.
1147 /// Every field except `gpu` + `phase_handler` is captured.
1148 fn to_config_spec(&self) -> RendererConfigSpec {
1149 RendererConfigSpec {
1150 logging: self.logging.clone(),
1151 render_texture_formats: self.render_texture_formats.clone(),
1152 brdf_lut_options: self.brdf_lut_options.clone(),
1153 clear_color: self.clear_color.clone(),
1154 skybox_colors: self.skybox_colors.clone(),
1155 ibl_filtered_env_colors: self.ibl_filtered_env_colors.clone(),
1156 ibl_irradiance_colors: self.ibl_irradiance_colors.clone(),
1157 anti_aliasing: self.anti_aliasing.clone(),
1158 post_processing: self.post_processing.clone(),
1159 shadows_config: self.shadows_config.clone(),
1160 features: self.features.clone(),
1161 max_edge_budget: self.max_edge_budget,
1162 bucket_config: self.bucket_config,
1163 prep_config: self.prep_config,
1164 optimization_policy: self.optimization_policy.clone(),
1165 scene_spatial_config: self.scene_spatial_config,
1166 recommended_shadow_quality_tier: self.recommended_shadow_quality_tier,
1167 render_texture_formats_depth_override: self.render_texture_formats_depth_override,
1168 }
1169 }
1170
1171 /// Reconstruct a builder from a [`RendererConfigSpec`] + a GPU context — the
1172 /// one-line, drift-free basis for [`AwsmRenderer::remove_all`]. `phase_handler`
1173 /// is `None` (a rebuild needs no boot-phase callback).
1174 pub fn from_spec(gpu: impl Into<AwsmRendererGpuBuilderKind>, spec: RendererConfigSpec) -> Self {
1175 Self {
1176 gpu: gpu.into(),
1177 logging: spec.logging,
1178 render_texture_formats: spec.render_texture_formats,
1179 brdf_lut_options: spec.brdf_lut_options,
1180 clear_color: spec.clear_color,
1181 skybox_colors: spec.skybox_colors,
1182 ibl_filtered_env_colors: spec.ibl_filtered_env_colors,
1183 ibl_irradiance_colors: spec.ibl_irradiance_colors,
1184 anti_aliasing: spec.anti_aliasing,
1185 post_processing: spec.post_processing,
1186 shadows_config: spec.shadows_config,
1187 features: spec.features,
1188 max_edge_budget: spec.max_edge_budget,
1189 bucket_config: spec.bucket_config,
1190 prep_config: spec.prep_config,
1191 optimization_policy: spec.optimization_policy,
1192 phase_handler: None,
1193 scene_spatial_config: spec.scene_spatial_config,
1194 recommended_shadow_quality_tier: spec.recommended_shadow_quality_tier,
1195 render_texture_formats_depth_override: spec.render_texture_formats_depth_override,
1196 }
1197 }
1198
1199 /// Apply a coordinated set of defaults from a
1200 /// [`crate::profile::RendererProfile`]. Sets `anti_aliasing`,
1201 /// `post_processing`, `features`, `optimization_policy`,
1202 /// `shadows_config`, `max_edge_budget`, `scene_spatial_config`,
1203 /// and the recommended shadow quality tier — all the knobs whose
1204 /// right starting value differs between mobile-class and
1205 /// desktop-class targets.
1206 ///
1207 /// **Call order**: invoke this **first**, then chain any per-knob
1208 /// `with_*` overrides — the profile mutates the builder's state
1209 /// immediately, so later `with_*` calls win.
1210 ///
1211 /// Frontends typically resolve the profile from a URL parameter
1212 /// (`?mobile=true`) via
1213 /// [`awsm_renderer_web_shared::perf::resolve_renderer_profile`](https://github.com/dakom/awsm-renderer/blob/main/crates/web-shared/src/perf.rs)
1214 /// and pass the result here.
1215 ///
1216 /// **Per-light shadow params** aren't owned by the renderer
1217 /// builder — scene-side code reads
1218 /// [`AwsmRenderer::recommended_shadow_quality_tier`] after build
1219 /// and applies the matching `LightShadowParams` preset on each
1220 /// shadow-casting light registration.
1221 pub fn with_profile(mut self, profile: crate::profile::RendererProfile) -> Self {
1222 let defaults = profile.defaults();
1223 self.anti_aliasing = defaults.anti_aliasing;
1224 self.post_processing = defaults.post_processing;
1225 self.features = defaults.features;
1226 self.optimization_policy = defaults.optimization_policy;
1227 self.shadows_config = Some(defaults.shadows_config);
1228 self.max_edge_budget = Some(defaults.max_edge_budget);
1229 self.scene_spatial_config = Some(defaults.scene_spatial);
1230 self.recommended_shadow_quality_tier = Some(defaults.shadow_quality_tier);
1231 // Render-texture format override: only the `depth` field
1232 // varies by profile today. Build a `RenderTextureFormats`
1233 // around the per-device baseline at `build()` time if the
1234 // user hasn't supplied one — we can't do the async default
1235 // probe here in a sync builder method. The depth override
1236 // gets re-applied inside `build()` (see the
1237 // `render_texture_formats` materialization there).
1238 if let Some(formats) = self.render_texture_formats.as_mut() {
1239 formats.depth = defaults.render_texture_formats.depth;
1240 } else {
1241 // Stash the override on a builder-private field so the
1242 // build() async path can apply it once the per-device
1243 // defaults have been probed. We re-use
1244 // `render_texture_formats` indirectly via the
1245 // post-profile mutation below.
1246 self.render_texture_formats_depth_override =
1247 Some(defaults.render_texture_formats.depth);
1248 }
1249 self
1250 }
1251
1252 /// Override the BVH rebuild cadence directly. The
1253 /// [`crate::profile::RendererProfile`] is the usual surface for
1254 /// this; only call this directly for bespoke tuning.
1255 pub fn with_scene_spatial_config(
1256 mut self,
1257 config: crate::scene_spatial::SceneSpatialConfig,
1258 ) -> Self {
1259 self.scene_spatial_config = Some(config);
1260 self
1261 }
1262
1263 /// Block C.2: override the default `MAX_EDGE_BUDGET` for the
1264 /// MSAA edge-resolve buffers. Default picks
1265 /// `DEFAULT_MAX_EDGE_BUDGET_DESKTOP` (512k edge pixels). Mobile
1266 /// consumers should pass `DEFAULT_MAX_EDGE_BUDGET_MOBILE`
1267 /// (256k); pathological-edge content can pass higher values
1268 /// (e.g. 1M) to absorb dense foliage at 4K.
1269 ///
1270 /// Live tuning at runtime is also available via
1271 /// [`AwsmRenderer::set_max_edge_budget`] — call it when
1272 /// [`note_edge_overflow_observed`](crate::render_passes::material_opaque::edge_buffers::note_edge_overflow_observed)
1273 /// fires (indicating overflow this session).
1274 pub fn with_max_edge_budget(mut self, budget: u32) -> Self {
1275 self.max_edge_budget = Some(budget.max(1));
1276 self
1277 }
1278
1279 /// Sets the registration ceiling for co-resident material buckets
1280 /// (`docs/plans/increase-materials.md` §2). Default is 32 (identical to
1281 /// today). Valid range `1..=65534`; an out-of-range value is clamped
1282 /// into range here and logged, so the builder never produces a registry
1283 /// that can mint a bucket index the edge encoding can't represent. The
1284 /// cap sizes nothing per-frame — every GPU width follows the *live*
1285 /// bucket count, so a high cap costs nothing until the count grows.
1286 pub fn with_bucket_config(mut self, config: crate::dynamic_materials::BucketConfig) -> Self {
1287 let config = match config.validate() {
1288 Ok(()) => config,
1289 Err(msg) => {
1290 let clamped = config
1291 .max_bucket_entries
1292 .clamp(1, crate::dynamic_materials::MAX_BUCKET_ENTRIES_CEILING);
1293 tracing::warn!(
1294 target: "awsm_renderer::dynamic_materials",
1295 "with_bucket_config: {msg}; clamping to {clamped}"
1296 );
1297 crate::dynamic_materials::BucketConfig {
1298 max_bucket_entries: clamped,
1299 }
1300 }
1301 };
1302 self.bucket_config = Some(config);
1303 self
1304 }
1305
1306 /// Max shadow casters that can overlap a single pixel (`K`) — sizes the
1307 /// per-pixel shadow-visibility buffer. Clamped to
1308 /// `1..=PrepPassConfig::MAX_SHADOW_CASTERS_PER_PIXEL_CEILING`.
1309 pub fn with_max_shadow_casters_per_pixel(mut self, k: u32) -> Self {
1310 self.prep_config.max_shadow_casters_per_pixel =
1311 k.clamp(1, crate::render_passes::material_prep::PrepPassConfig::MAX_SHADOW_CASTERS_PER_PIXEL_CEILING);
1312 self
1313 }
1314
1315 /// Subscribes to renderer-init phase transitions. The callback
1316 /// fires once per [`RendererLoadingPhase`] entry — see the enum
1317 /// docs for what each phase covers. Frontends use this to render
1318 /// a phase-specific loading message instead of one generic
1319 /// "Initializing renderer…" line that covers the entire (cold
1320 /// load: tens of seconds; warm load: ~1s) window.
1321 pub fn with_phase_handler<F>(mut self, handler: F) -> Self
1322 where
1323 F: FnMut(RendererLoadingPhase) + 'static,
1324 {
1325 self.phase_handler = Some(Box::new(handler));
1326 self
1327 }
1328
1329 /// Opts into renderer features. Both flags default to `false` so
1330 /// library consumers pay no cost for GPU-driven culling / decals
1331 /// when they don't need them. Game-side and editor builds should
1332 /// set this explicitly.
1333 pub fn with_features(mut self, features: RendererFeatures) -> Self {
1334 self.features = features;
1335 self
1336 }
1337
1338 /// Sets the adaptive runtime policy. Independent of
1339 /// `with_features`: features gate **allocation** (does the HZB
1340 /// texture exist?), policy gates **engagement** (do we build it
1341 /// this frame?). Default is `Auto` for `gpu_culling`; pass
1342 /// `OptimizationMode::Force` for editor / regression-testing
1343 /// builds, or `Off` for a CPU-only baseline.
1344 pub fn with_optimization_policy(
1345 mut self,
1346 policy: crate::optimization_policy::RendererOptimizationPolicy,
1347 ) -> Self {
1348 self.optimization_policy = policy;
1349 self
1350 }
1351
1352 /// Pins a renderer-wide shadow configuration that the new
1353 /// `Shadows` will use at construction. Use this when loading an
1354 /// `awsm_renderer_scene::EditorProject` so the cube-pool size, EVSM
1355 /// atlas size, and 2D atlas size match the authored intent before
1356 /// any frame renders.
1357 pub fn with_shadows_config(mut self, config: shadows::ShadowsConfig) -> Self {
1358 self.shadows_config = Some(config);
1359 self
1360 }
1361
1362 /// Sets BRDF LUT generation options.
1363 pub fn with_brdf_lut_options(mut self, options: BrdfLutOptions) -> Self {
1364 self.brdf_lut_options = options;
1365 self
1366 }
1367
1368 /// Sets the filtered environment colors for IBL.
1369 pub fn with_ibl_filtered_env_colors(mut self, colors: CubemapBitmapColors) -> Self {
1370 self.ibl_filtered_env_colors = colors;
1371 self
1372 }
1373
1374 /// Sets the anti-aliasing configuration.
1375 pub fn with_anti_aliasing(mut self, anti_aliasing: AntiAliasing) -> Self {
1376 self.anti_aliasing = anti_aliasing;
1377 self
1378 }
1379
1380 /// Sets the post-processing configuration. Mirrors
1381 /// [`Self::with_anti_aliasing`] — used by
1382 /// [`AwsmRenderer::remove_all`] to preserve the live post-process
1383 /// state across the scene-clear rebuild, and by frontends that
1384 /// want to start from a non-default tonemapper / bloom / DoF
1385 /// config without going through `set_post_processing` after build.
1386 pub fn with_post_processing(mut self, post_processing: PostProcessing) -> Self {
1387 self.post_processing = post_processing;
1388 self
1389 }
1390
1391 /// Pins the recommended shadow quality tier reported by
1392 /// [`AwsmRenderer::recommended_shadow_quality_tier`]. Normally set
1393 /// implicitly via [`Self::with_profile`]; this setter exists so
1394 /// [`AwsmRenderer::remove_all`] can preserve the value across a
1395 /// scene-clear rebuild without re-running profile resolution
1396 /// (which would clobber any post-profile per-knob overrides the
1397 /// frontend chained on top).
1398 pub fn with_recommended_shadow_quality_tier(
1399 mut self,
1400 tier: crate::shadows::ShadowQualityTier,
1401 ) -> Self {
1402 self.recommended_shadow_quality_tier = Some(tier);
1403 self
1404 }
1405
1406 /// Sets the irradiance colors for IBL.
1407 pub fn with_ibl_irradiance_colors(mut self, colors: CubemapBitmapColors) -> Self {
1408 self.ibl_irradiance_colors = colors;
1409 self
1410 }
1411
1412 /// Sets the skybox colors.
1413 pub fn with_skybox_colors(mut self, colors: CubemapBitmapColors) -> Self {
1414 self.skybox_colors = colors;
1415 self
1416 }
1417
1418 /// Sets logging options for the renderer.
1419 pub fn with_logging(mut self, logging: AwsmRendererLogging) -> Self {
1420 self.logging = logging;
1421 self
1422 }
1423
1424 /// Sets render texture formats.
1425 ///
1426 /// Clears any pending depth-format override stashed by an earlier
1427 /// [`Self::with_profile`] call — the explicit formats struct the
1428 /// caller is supplying here wins, per the documented builder
1429 /// contract ("later `with_*` calls win" over `with_profile`).
1430 /// Without this clear, the call sequence
1431 ///
1432 /// ```ignore
1433 /// .with_profile(RendererProfile::Mobile) // stashes Depth24Plus
1434 /// .with_render_texture_formats(my_custom_formats) // depth = Depth32Float
1435 /// ```
1436 ///
1437 /// would silently clobber `my_custom_formats.depth` back to
1438 /// `Depth24Plus` inside `build()`'s post-probe override-apply
1439 /// step.
1440 pub fn with_render_texture_formats(mut self, formats: RenderTextureFormats) -> Self {
1441 self.render_texture_formats = Some(formats);
1442 self.render_texture_formats_depth_override = None;
1443 self
1444 }
1445
1446 /// Sets the clear color used for the main render pass.
1447 pub fn with_clear_color(mut self, color: Color) -> Self {
1448 self.clear_color = color;
1449 self
1450 }
1451
1452 /// Builds the renderer and initializes GPU resources.
1453 pub async fn build(self) -> std::result::Result<AwsmRenderer, crate::error::AwsmError> {
1454 // Snapshot every build-time config knob BEFORE consuming the builder, so
1455 // `remove_all` can rebuild from it drift-free (the config analog of the
1456 // load transaction — see `RendererConfigSpec`).
1457 let config_spec = self.to_config_spec();
1458 let Self {
1459 gpu,
1460 logging,
1461 render_texture_formats,
1462 brdf_lut_options,
1463 clear_color,
1464 skybox_colors,
1465 ibl_filtered_env_colors,
1466 ibl_irradiance_colors,
1467 anti_aliasing,
1468 post_processing,
1469 shadows_config,
1470 mut features,
1471 max_edge_budget,
1472 bucket_config,
1473 prep_config,
1474 optimization_policy,
1475 phase_handler,
1476 scene_spatial_config,
1477 recommended_shadow_quality_tier,
1478 render_texture_formats_depth_override,
1479 } = self;
1480
1481 let mut phase_handler = phase_handler;
1482 let build_start_ms = web_sys::js_sys::Date::now();
1483 let mut phase_start_ms = build_start_ms;
1484 let mut emit_phase = |phase: RendererLoadingPhase| {
1485 // Log wall-clock between phases so the user can see where
1486 // cold-boot time actually goes (the boot-loader caption
1487 // shows the phase NAME but not how long the previous one
1488 // took). Tracing target is `awsm_renderer::boot_timing`
1489 // so consumers can filter for it explicitly.
1490 let now = web_sys::js_sys::Date::now();
1491 let dt_phase = now - phase_start_ms;
1492 let dt_total = now - build_start_ms;
1493 tracing::info!(
1494 target: "awsm_renderer::boot_timing",
1495 "phase = {:?} (+{:.0}ms phase, {:.0}ms total)",
1496 phase,
1497 dt_phase,
1498 dt_total,
1499 );
1500 phase_start_ms = now;
1501 if let Some(handler) = phase_handler.as_mut() {
1502 handler(phase);
1503 }
1504 };
1505 emit_phase(RendererLoadingPhase::Init);
1506
1507 let gpu = match gpu {
1508 AwsmRendererGpuBuilderKind::WebGpuBuilder(builder) => builder.build().await?,
1509 AwsmRendererGpuBuilderKind::WebGpuBuilt(gpu) => gpu,
1510 };
1511
1512 // Resolve `indirect_first_instance` against device capability.
1513 // After this point any `Auto` in the toggle is replaced by
1514 // `On` (when the device exposes the feature) or `Off` (when it
1515 // doesn't), so downstream code can read `.resolve(false)` and
1516 // get a deterministic boolean. `On` / `Off` overrides bypass
1517 // the capability probe entirely — useful for forcing the
1518 // portable fallback on a supported device (testing) or for
1519 // forcing the optimized path when out-of-band knowledge says
1520 // the device supports it.
1521 //
1522 // The two paths are *both* fully optimized for their config —
1523 // see [`crate::features::FeatureToggle`] and
1524 // [`AwsmRendererWebGpu::has_indirect_first_instance`] for the
1525 // capability semantics, and the geometry-pass + compaction
1526 // templating for the per-path code paths.
1527 let indirect_capability = gpu.has_indirect_first_instance();
1528 let resolved_indirect = features
1529 .indirect_first_instance
1530 .resolve(indirect_capability);
1531 if matches!(
1532 features.indirect_first_instance,
1533 crate::features::FeatureToggle::On
1534 ) && !indirect_capability
1535 {
1536 tracing::warn!(
1537 "`indirect_first_instance = On` but the device doesn't expose \
1538 the `indirect-first-instance` WebGPU feature. drawIndirect \
1539 calls with non-zero firstInstance will silently fail. \
1540 Switch to Auto (default) or Off to use the portable path."
1541 );
1542 }
1543 features.indirect_first_instance = if resolved_indirect {
1544 crate::features::FeatureToggle::On
1545 } else {
1546 crate::features::FeatureToggle::Off
1547 };
1548
1549 let mut render_texture_formats = match render_texture_formats {
1550 Some(formats) => formats,
1551 None => RenderTextureFormats::new(&gpu.device).await,
1552 };
1553 // Apply the profile's depth-format override (if any) on top of
1554 // the per-device defaults. Frontends that supplied their own
1555 // `RenderTextureFormats` already have `with_profile` mutate
1556 // the depth field in place — no second pass needed there.
1557 if let Some(depth_override) = render_texture_formats_depth_override {
1558 render_texture_formats.depth = depth_override;
1559 }
1560
1561 // tracing::info!("Max bind groups: {}", gpu.device.limits().max_bind_groups());
1562 // tracing::info!(
1563 // "Max texture size: {}",
1564 // gpu.device.limits().max_texture_dimension_2d()
1565 // );
1566
1567 let mut pipeline_layouts = PipelineLayouts::new();
1568 let mut bind_group_layouts = BindGroupLayouts::new();
1569 let mut pipelines = Pipelines::new();
1570 let mut shaders = Shaders::new();
1571
1572 let mut textures = Textures::new(&gpu)?;
1573 let camera = camera::CameraBuffer::new(&gpu)?;
1574 let frame_globals = crate::frame_globals::FrameGlobals::new(&gpu)?;
1575
1576 // One mega-join covering every independent &gpu-only async
1577 // task in the build's setup stage:
1578 //
1579 // - 3 default-cubemap creations (prefiltered IBL / irradiance
1580 // IBL / skybox)
1581 // - BRDF LUT generation
1582 // - opaque-mipgen pipeline construction
1583 // - `RenderPasses::describe_shaders` (bind-group setup +
1584 // per-pass shader cache key collection — no Dawn
1585 // shader/pipeline compile yet; that's stages 2 and 3
1586 // below)
1587 // - `RenderTextures::new`
1588 //
1589 // The five texture-prep futures only touch `&gpu` (the
1590 // `prepare_resources` half of the prepare/register split is
1591 // intentional infrastructure for exactly this kind of merge).
1592 // `RenderPasses::describe_shaders` holds `&mut` on the
1593 // shader / pipeline / bind-group-layout caches and reads
1594 // from `&mut textures` via `RenderPassInitContext`, but
1595 // those reads (pool.arrays_len, pool_sampler_set,
1596 // pool.texture_views, get_sampler over pool_sampler_set,
1597 // texture_transforms_gpu_buffer) are disjoint from anything
1598 // `IblTexture::register` / `Skybox::register` later mutate —
1599 // register inserts into `cubemaps` (separate from `pool`)
1600 // and pulls a sampler key out of `sampler_cache` without
1601 // ever touching `pool_sampler_set`. So we can safely defer
1602 // the registers (and the dependent Lights / Environment
1603 // construction) to the post-await sync block.
1604 let formats_for_textures = render_texture_formats.clone();
1605 let bind_groups = BindGroups::new(&features);
1606 // Resolved edge-pixel budget — mirrors what `MaterialEdgeBuffers` uses
1607 // (the builder override or the desktop default). Sizes the prep pass's
1608 // compact per-edge-sample shadow texture (Stage 5b-shadow). Computed here
1609 // since the edge buffers themselves are allocated further below.
1610 let resolved_max_edge_budget = max_edge_budget.unwrap_or(
1611 crate::render_passes::material_opaque::edge_buffers::DEFAULT_MAX_EDGE_BUDGET_DESKTOP,
1612 );
1613
1614 let mut render_pass_init = RenderPassInitContext {
1615 gpu: &gpu,
1616 bind_group_layouts: &mut bind_group_layouts,
1617 pipeline_layouts: &mut pipeline_layouts,
1618 pipelines: &mut pipelines,
1619 shaders: &mut shaders,
1620 render_texture_formats: &mut render_texture_formats,
1621 textures: &mut textures,
1622 features: &features,
1623 anti_aliasing: &anti_aliasing,
1624 post_processing: &post_processing,
1625 prep_config: &prep_config,
1626 max_edge_budget: resolved_max_edge_budget,
1627 };
1628
1629 // Phase A of RenderPasses (bind groups + shader cache key
1630 // collection) joins the texture-prep block. RenderPasses is
1631 // NOT a full `new()` here anymore — it's split into 3
1632 // stages so the orchestrator below can pool RenderPasses'
1633 // shader + pipeline cache keys with every tail subsystem
1634 // into one cross-renderer shader ensure_keys and one
1635 // try_join'd compute + render ensure_keys.
1636 //
1637 // The work inside this try_join! falls under
1638 // `RendererLoadingPhase::Init` per the enum's contract
1639 // (adapter / device + supporting GPU resources + cache-key
1640 // collection — no Dawn compile yet). The `CompilingShaders`
1641 // transition fires further down, right before the
1642 // cross-renderer `Shaders::ensure_keys` call where actual
1643 // WGSL → MSL compilation begins.
1644 let (
1645 ibl_filtered_resources,
1646 ibl_irradiance_resources,
1647 skybox_resources,
1648 brdf_lut,
1649 opaque_mipgen,
1650 mut render_passes_plan,
1651 render_textures,
1652 ) = futures::try_join!(
1653 IblTexture::prepare_resources(&gpu, ibl_filtered_env_colors),
1654 IblTexture::prepare_resources(&gpu, ibl_irradiance_colors),
1655 Skybox::prepare_resources(&gpu, skybox_colors),
1656 async {
1657 BrdfLut::new(&gpu, brdf_lut_options)
1658 .await
1659 .map_err(crate::error::AwsmError::from)
1660 },
1661 async {
1662 opaque_mipgen::OpaqueMipgen::new(&gpu)
1663 .await
1664 .map_err(crate::error::AwsmError::from)
1665 },
1666 RenderPasses::describe_shaders(&mut render_pass_init, &features),
1667 async {
1668 RenderTextures::new(
1669 &gpu,
1670 formats_for_textures,
1671 &features,
1672 prep_config.shadow_visibility_layers(),
1673 )
1674 .await
1675 .map_err(crate::error::AwsmError::from)
1676 },
1677 )?;
1678 // Move `render_pass_init` into a discard binding so its
1679 // `&mut`-borrows of bind_group_layouts / pipeline_layouts /
1680 // pipelines / shaders / render_texture_formats / textures /
1681 // features end here unambiguously, freeing the post-await
1682 // registers below (which mutate `textures`) to compile
1683 // regardless of any future code that might otherwise extend
1684 // the borrow's NLL lifetime.
1685 let _ = render_pass_init;
1686
1687 let lights = Lights::new(
1688 &gpu,
1689 Ibl::new(
1690 IblTexture::register(&gpu, &mut textures, ibl_filtered_resources)?,
1691 IblTexture::register(&gpu, &mut textures, ibl_irradiance_resources)?,
1692 ),
1693 brdf_lut,
1694 )?;
1695 let meshes = Meshes::new(&gpu)?;
1696 let transforms = Transforms::new(&gpu)?;
1697 let instances = Instances::new(&gpu)?;
1698 let materials = Materials::new(&gpu)?;
1699 let environment =
1700 Environment::new(Skybox::register(&gpu, &mut textures, skybox_resources)?);
1701
1702 // Item (2): cross-renderer orchestration. After
1703 // describe_shaders + the texture-prep block finished above,
1704 // we now drive the full renderer-wide compile pool from one
1705 // place. Three awaits cover EVERYTHING (RenderPasses + tail
1706 // subsystems):
1707 //
1708 // 1. ONE `Shaders::ensure_keys` covering RenderPasses-owned
1709 // shaders + Picker + LineRenderer + Shadows caster +
1710 // Effects + Display.
1711 // 2. ONE EVSM validate join (3 inline-shader validates —
1712 // kicked off via `compile_shader` inside
1713 // `Shadows::build_descriptors` immediately after the
1714 // shader ensure_keys returns).
1715 // 3. ONE `try_join`'d compute + render `ensure_keys`
1716 // covering every compute / render pipeline across the
1717 // entire renderer.
1718 //
1719 // The orchestrator owns the pool — `RenderPasses` can't
1720 // smuggle in a sequential `.await?` because its public API
1721 // is `describe_shaders → describe_pipelines → from_resolved`,
1722 // none of which compile pipelines themselves.
1723
1724 // Sized for a small initial viewport; recreated by
1725 // `ClassifyBuffers::ensure_capacity` on first frame once the
1726 // real render-texture size is known.
1727 // Builder-time sizing — no dynamic materials yet, so bucket
1728 // count is the first-party-only baseline.
1729 // `register_material` calls `ensure_bucket_count` if it grows
1730 // the registry past the current value.
1731 let first_party_bucket_count =
1732 crate::dynamic_materials::first_party_bucket_entries().len() as u32;
1733 let material_classify_buffers =
1734 render_passes::material_classify::buffers::ClassifyBuffers::new(
1735 &gpu,
1736 1024,
1737 first_party_bucket_count,
1738 )?;
1739 // Seed the bucket LUT from the first-party entries so a scene that
1740 // never registers a dynamic material still classifies correctly;
1741 // `relayout_bucket_buffers` rebuilds it as the registry grows.
1742 let material_bucket_lut =
1743 render_passes::material_classify::bucket_lut::MaterialBucketLut::new(
1744 &gpu,
1745 &crate::dynamic_materials::first_party_bucket_entries(),
1746 )?;
1747
1748 // Light-culling froxel buffers. Sized to a tiny placeholder
1749 // viewport; per-frame `ensure_viewport` grows them once the real
1750 // swap-chain size is known.
1751 let light_culling_buffers = render_passes::light_culling::LightCullingBuffers::new(
1752 &gpu,
1753 16,
1754 16,
1755 render_passes::light_culling::DEFAULT_SLICE_COUNT,
1756 render_passes::light_culling::DEFAULT_MAX_PER_FROXEL_CAPACITY,
1757 render_passes::light_culling::DEFAULT_MESH_INDICES_CAPACITY,
1758 render_passes::light_culling::DEFAULT_TILE_LIGHT_CAPACITY,
1759 )?;
1760
1761 // MSAA-edge-resolve buffers (Stage 3 dispatch wiring). Allocated only
1762 // when MSAA is on AND the device supports the required limits
1763 // (maxStorageBuffersPerShaderStage >= 10 — the WebGPU baseline).
1764 // The per-shader edge_resolve pipeline layout fits in 4 bind groups
1765 // since the group(4) → extended-shadows(3) fold; the only remaining
1766 // device-cap constraint is the storage-buffer count.
1767 let multisampled_geometry = anti_aliasing.has_msaa_checked()?;
1768 let edge_resolve_enabled = multisampled_geometry && edge_resolve_supported(&gpu);
1769 let (material_edge_buffers, material_edge_layout_uniform) = if edge_resolve_enabled {
1770 use render_passes::material_opaque::edge_buffers::{
1771 build_edge_layout_uniform, MaterialEdgeBuffers,
1772 };
1773 let edge_buffers = if let Some(budget) = max_edge_budget {
1774 MaterialEdgeBuffers::new_with_budget(&gpu, first_party_bucket_count, budget)?
1775 } else {
1776 MaterialEdgeBuffers::new(&gpu, first_party_bucket_count)?
1777 };
1778 let max_edge_budget = edge_buffers.max_edge_budget;
1779 let (uniform, _bytes) =
1780 build_edge_layout_uniform(&gpu, first_party_bucket_count, max_edge_budget)?;
1781 (Some(edge_buffers), Some(uniform))
1782 } else {
1783 (None, None)
1784 };
1785
1786 // Decals subsystem — fixed-capacity GPU storage buffer
1787 // allocated up front; per-frame upload only touches the
1788 // bytes for currently-active decals. Gated by `features.decals`.
1789 let decals = if features.decals {
1790 Some(decals::Decals::new(&gpu)?)
1791 } else {
1792 None
1793 };
1794
1795 // Occlusion-cull buffers. Starts at 1024 instances; grows 2×
1796 // when needed. Gated by `features.gpu_culling`.
1797 let occlusion_buffers = if features.gpu_culling {
1798 Some(render_passes::occlusion::buffers::OcclusionBuffers::new(
1799 &gpu,
1800 )?)
1801 } else {
1802 None
1803 };
1804
1805 // Decal classify buckets. Starts at 1×1 tiles; `ensure_capacity`
1806 // resizes against the live viewport on first render. Gated by
1807 // `features.decals`.
1808 let decal_classify_buffers = if features.decals {
1809 Some(render_passes::material_decal::classify::buffers::DecalClassifyBuffers::new(&gpu)?)
1810 } else {
1811 None
1812 };
1813
1814 // GPU compaction args buffer. Gated by `features.gpu_culling`.
1815 let compaction_buffers = if features.gpu_culling {
1816 Some(render_passes::occlusion::compaction::CompactionBuffers::new(&gpu)?)
1817 } else {
1818 None
1819 };
1820
1821 // GPU mesh-pixel-coverage producer buffers. Allocated only
1822 // when `features.coverage_lod` is on — the producer pass
1823 // populates `MeshCoverage`, and with no opt-in consumer the
1824 // per-frame compute + readback would be pure waste.
1825 let coverage_buffers = if features.coverage_lod {
1826 Some(render_passes::coverage::buffers::CoverageBuffers::new(
1827 &gpu,
1828 )?)
1829 } else {
1830 None
1831 };
1832
1833 // ── 1. Cross-renderer shader pool. Assemble every shader
1834 // cache key — RenderPasses-owned (from the describe
1835 // phase) + tail subsystems' statically-known keys. ONE
1836 // Shaders::ensure_keys.
1837 //
1838 // `mem::take` the keys out of the plan rather than cloning:
1839 // `describe_pipelines` (which consumes the plan below) only
1840 // reads `plan.bindings`, never `plan.shader_cache_keys`, so
1841 // leaving the field empty is fine and avoids a per-build
1842 // allocation of a ~40-entry Vec on the cold path.
1843 let mut all_shader_keys: Vec<shaders::ShaderCacheKey> =
1844 std::mem::take(&mut render_passes_plan.shader_cache_keys);
1845 all_shader_keys.extend(shadows::ShadowsDescriptors::caster_shader_cache_keys());
1846 // Picker shaders are no longer batched here — the entire Picker
1847 // subsystem is deferred until first `pick()` query
1848 // (`AwsmRenderer::ensure_picker_compiled`). Cold-boot compiles
1849 // 0 picker pipelines even when `features.picking == true`.
1850 all_shader_keys.push(shaders::ShaderCacheKey::from(
1851 render_passes::lines::ShaderCacheKeyLine,
1852 ));
1853 all_shader_keys.extend(
1854 render_passes::effects::pipeline::EffectsPipelines::shader_cache_keys_for(
1855 &anti_aliasing,
1856 &post_processing,
1857 )?,
1858 );
1859 all_shader_keys.extend(
1860 render_passes::display::pipeline::DisplayPipelines::shader_cache_keys_for(
1861 &post_processing,
1862 ),
1863 );
1864 // Phase transition: the actual WGSL → MSL compile happens
1865 // inside the next await. Emit `CompilingShaders` here so the
1866 // frontend's "Browser is compiling shaders…" label is correct.
1867 emit_phase(RendererLoadingPhase::CompilingShaders);
1868 shaders.ensure_keys(&gpu, all_shader_keys).await?;
1869
1870 // ── 2. Tail descriptors (cache-hit shader resolutions for
1871 // Shadows caster; Shadows internally issues 3 EVSM
1872 // `compile_shader` calls that return modules immediately
1873 // + surface their validate futures via
1874 // `ShadowsDescriptors::evsm`).
1875 //
1876 // Picker is no longer built here (Block B.4) — it's compiled
1877 // on the first `pick()` query via
1878 // `AwsmRenderer::ensure_picker_compiled`.
1879 //
1880 // Lines (Block B.3) are no longer built here — the 4 pipeline
1881 // variants compile on first line primitive insertion via
1882 // `AwsmRenderer::ensure_line_pipelines_compiled`, driven by
1883 // `wait_for_pipelines_ready`. The line BGL is still registered
1884 // eagerly below (`LineRenderer::new_deferred`) so `add_line_*`
1885 // can construct per-line bind groups before any pipeline
1886 // exists.
1887 // Shadows::build_descriptors needs the geometry bind groups,
1888 // which now live inside render_passes_plan.bindings. We don't
1889 // have render_passes_plan.bindings as a public field — drill
1890 // through describe_pipelines first to get bind groups via the
1891 // typed RenderPasses handle... actually no, we need them
1892 // BEFORE describe_pipelines to construct shadows here.
1893 //
1894 // The bindings struct stores GeometryBindGroups; we need that
1895 // for Shadows::build_descriptors. Expose a borrow via a
1896 // helper on the shader plan.
1897 let mut shadows_descs = shadows::Shadows::build_descriptors(
1898 &gpu,
1899 &mut bind_group_layouts,
1900 &mut pipeline_layouts,
1901 &mut shaders,
1902 render_passes_plan.geometry_bind_groups(),
1903 &render_textures.formats,
1904 shadows_config.unwrap_or_default(),
1905 )
1906 .await?;
1907
1908 // ── 3. EVSM validate join. Single await covering all 3
1909 // inline-shader validations in parallel.
1910 let evsm_results =
1911 futures::future::join_all(shadows_descs.evsm.validate_shader_futures()).await;
1912 for result in evsm_results {
1913 result.map_err(crate::shadows::AwsmShadowError::Core)?;
1914 }
1915
1916 // Register the 3 EVSM modules into the shader cache via
1917 // `insert_uncached`; the resulting `ShaderKey`s let us build
1918 // the 3 EVSM compute pipeline cache keys for the
1919 // cross-renderer compute pool.
1920 let evsm_shader_keys: [shaders::ShaderKey; 3] = [
1921 shaders.insert_uncached(shadows_descs.evsm.modules[0].clone()),
1922 shaders.insert_uncached(shadows_descs.evsm.modules[1].clone()),
1923 shaders.insert_uncached(shadows_descs.evsm.modules[2].clone()),
1924 ];
1925 let evsm_pipeline_cache_keys = shadows_descs.evsm.pipeline_cache_keys(evsm_shader_keys);
1926
1927 // ── 4. Now that all shaders are warm, drive RenderPasses
1928 // phase 2 (build pipeline cache keys per pass) and the
1929 // Effects + Display descriptors. All sync apart from
1930 // cache-hit `get_key`s.
1931 let mut render_pass_init = RenderPassInitContext {
1932 gpu: &gpu,
1933 bind_group_layouts: &mut bind_group_layouts,
1934 pipeline_layouts: &mut pipeline_layouts,
1935 pipelines: &mut pipelines,
1936 shaders: &mut shaders,
1937 render_texture_formats: &mut render_texture_formats,
1938 textures: &mut textures,
1939 features: &features,
1940 anti_aliasing: &anti_aliasing,
1941 post_processing: &post_processing,
1942 prep_config: &prep_config,
1943 max_edge_budget: resolved_max_edge_budget,
1944 };
1945 let render_passes_descs =
1946 RenderPasses::describe_pipelines(render_passes_plan, &mut render_pass_init, &features)
1947 .await?;
1948 // `render_pass_init`'s `&mut`-borrows of bind_group_layouts /
1949 // pipeline_layouts / pipelines / shaders / etc. expire at
1950 // the next statement boundary; the subsequent code below
1951 // re-borrows them through the same names.
1952 let _ = render_pass_init;
1953
1954 let caster_pipeline_cache_keys =
1955 std::mem::take(&mut shadows_descs.caster_pipeline_cache_keys);
1956
1957 let effects_descs = render_passes_descs
1958 .effects_pipelines()
1959 .build_descriptors(&anti_aliasing, &post_processing, &gpu, &mut shaders)
1960 .await?;
1961 let display_descs = render_passes_descs
1962 .display_pipelines()
1963 .build_descriptors(&post_processing, &gpu, &mut shaders)
1964 .await?;
1965
1966 // ── 5. Assemble the cross-renderer compute + render cache
1967 // key pools and record each subsystem's slice range.
1968 let mut compute_pool: Vec<pipelines::compute_pipeline::ComputePipelineCacheKey> =
1969 render_passes_descs.compute_pipeline_cache_keys.clone();
1970 let render_passes_compute_len = compute_pool.len();
1971 // Picker compute pipelines are deferred (Block B.4) — no
1972 // entries appended here.
1973 // EVSM compute pipelines are deferred (Block B.1) — held on
1974 // `shadows.pending_evsm_cache_keys` and resolved by
1975 // `Shadows::ensure_pipelines_compiled` on the first
1976 // shadow-casting light. No entries appended.
1977 let effects_compute_range = {
1978 let s = compute_pool.len();
1979 compute_pool.extend(effects_descs.pipeline_cache_keys.iter().cloned());
1980 s..compute_pool.len()
1981 };
1982
1983 let mut render_pool: Vec<pipelines::render_pipeline::RenderPipelineCacheKey> =
1984 render_passes_descs.render_pipeline_cache_keys.clone();
1985 let render_passes_render_len = render_pool.len();
1986 // Line pipelines are deferred (Block B.3) — no entries
1987 // appended here. The 4 variants compile on first line primitive
1988 // insertion via `AwsmRenderer::ensure_line_pipelines_compiled`.
1989 // Shadow caster render pipelines are deferred (Block B.2) —
1990 // held on `shadows.pending_caster_cache_keys` and resolved by
1991 // `Shadows::ensure_pipelines_compiled` on the first
1992 // shadow-casting light. No entries appended.
1993 let display_render_range = {
1994 let s = render_pool.len();
1995 render_pool.extend(display_descs.pipeline_cache_keys.iter().cloned());
1996 s..render_pool.len()
1997 };
1998
1999 // ── 6. ONE try_join'd compute + render ensure_keys covering
2000 // every compute + render pipeline across the entire
2001 // renderer (~36 compute + ~27 render on a fully-
2002 // featured build). Split-borrow Pipelines.compute /
2003 // Pipelines.render so Dawn overlaps both classes inside
2004 // its worker pool.
2005 let pipelines::Pipelines {
2006 render: render_pipelines,
2007 compute: compute_pipelines,
2008 } = &mut pipelines;
2009 let compute_fut = async {
2010 compute_pipelines
2011 .ensure_keys(&gpu, &shaders, &pipeline_layouts, compute_pool)
2012 .await
2013 .map_err(crate::error::AwsmError::from)
2014 };
2015 let render_fut = async {
2016 render_pipelines
2017 .ensure_keys(&gpu, &shaders, &pipeline_layouts, render_pool)
2018 .await
2019 .map_err(crate::error::AwsmError::from)
2020 };
2021 // Phase transition: every shader is now warm; the pipeline
2022 // assembly happens inside the next await. Emit
2023 // `BuildingPipelines` so the frontend's "Building render
2024 // pipelines…" label is correct.
2025 emit_phase(RendererLoadingPhase::BuildingPipelines);
2026 let (compute_keys, render_keys) =
2027 futures::future::try_join(compute_fut, render_fut).await?;
2028
2029 // ── 7. Sync fold-up — slice resolved keys back to each
2030 // subsystem.
2031 let render_passes_compute_keys = compute_keys[..render_passes_compute_len].to_vec();
2032 let render_passes_render_keys = render_keys[..render_passes_render_len].to_vec();
2033 let mut render_passes = RenderPasses::from_resolved(
2034 render_passes_descs,
2035 render_passes_compute_keys,
2036 render_passes_render_keys,
2037 )?;
2038
2039 // Picker stays `None` at build (Block B.4) — compiled lazily on
2040 // first `pick()` via `AwsmRenderer::ensure_picker_compiled`.
2041 let picker: Option<Picker> = None;
2042 // Block B.3: cold-boot LineRenderer registers the line BGL but
2043 // leaves the 4 pipeline variants unbuilt. The first
2044 // `add_line_*` call sets `pipelines_compile_requested = true`;
2045 // `wait_for_pipelines_ready` then drives `ensure_pipelines_compiled`.
2046 let lines = LineRenderer::new_deferred(&gpu, &mut bind_group_layouts)?;
2047 // Shadows are constructed in the deferred path (Block B.1 + B.2):
2048 // empty `caster_resolved` / `evsm_resolved` slices stash the
2049 // pending cache keys on `Shadows`; pipeline compile is
2050 // triggered by `Shadows::ensure_pipelines_compiled` on the
2051 // first shadow-casting light. Non-pipeline GPU resources
2052 // (atlases, bind groups, buffers) still materialise here.
2053 let shadows = shadows::Shadows::from_resolved(
2054 &gpu,
2055 &bind_group_layouts,
2056 shadows_descs,
2057 Vec::new(),
2058 Vec::new(),
2059 caster_pipeline_cache_keys,
2060 evsm_pipeline_cache_keys,
2061 )?;
2062 render_passes.effects.pipelines.install_resolved(
2063 &post_processing,
2064 compute_keys[effects_compute_range].to_vec(),
2065 );
2066 render_passes
2067 .display
2068 .pipelines
2069 .install_resolved(render_keys[display_render_range].to_vec());
2070
2071 #[cfg(feature = "animation")]
2072 let animations = animation::Animations::default();
2073
2074 let extras_pool_built = crate::dynamic_materials::extras_pool::ExtrasPool::new(
2075 &gpu,
2076 crate::dynamic_materials::extras_pool::DEFAULT_CAPACITY_WORDS,
2077 )?;
2078
2079 // Edge-resolve pipeline compile (Priority 3 dispatch wiring). Only
2080 // when MSAA is on AND device supports the required limits. We
2081 // pass first_party-only bucket entries — the edge pipelines
2082 // recompile through the same path when dynamic materials
2083 // register (Stage 1.14 follow-up will route through the
2084 // scheduler).
2085 if edge_resolve_enabled {
2086 let color_wgsl = awsm_renderer_core::texture::texture_format_to_wgsl_storage(
2087 render_textures.formats.color,
2088 )?;
2089 let bucket_entries = crate::dynamic_materials::first_party_bucket_entries();
2090 let pipelines::Pipelines {
2091 render: _render_pipelines,
2092 compute: compute_pipelines,
2093 } = &mut pipelines;
2094 render_passes
2095 .material_opaque
2096 .edge_pipelines
2097 .ensure_compiled(
2098 &gpu,
2099 &mut shaders,
2100 compute_pipelines,
2101 &mut pipeline_layouts,
2102 &mut bind_group_layouts,
2103 &render_passes.material_opaque.bind_groups,
2104 &render_passes.material_opaque.edge_bind_group_layouts,
2105 &bucket_entries,
2106 &anti_aliasing,
2107 color_wgsl,
2108 None,
2109 prep_config.clamped_k(),
2110 )
2111 .await?;
2112 }
2113
2114 let mut _self = AwsmRenderer {
2115 gpu,
2116 meshes,
2117 camera,
2118 frame_globals,
2119 transforms,
2120 instances,
2121 scene_spatial: SceneSpatial::new(scene_spatial_config.unwrap_or_default()),
2122 recommended_shadow_quality_tier,
2123 light_buckets: LightMeshBuckets::default(),
2124 material_classify_buffers,
2125 material_bucket_lut,
2126 light_culling_buffers,
2127 light_culling_debug_heatmap: 0,
2128 debug_view_mode: 0,
2129 debug_wireframe: 0,
2130 material_edge_buffers,
2131 material_edge_layout_uniform,
2132 decals,
2133 occlusion_buffers,
2134 decal_classify_buffers,
2135 compaction_buffers,
2136 coverage: coverage::MeshCoverage::default(),
2137 #[cfg(feature = "lod")]
2138 lod: crate::lod::LodRegistry::default(),
2139 coverage_buffers,
2140 coverage_readback_state: std::sync::Arc::new(std::sync::Mutex::new(
2141 CoverageReadbackState::default(),
2142 )),
2143 cluster_cut_readback: std::sync::Arc::new(std::sync::Mutex::new(
2144 ClusterCutReadback::default(),
2145 )),
2146 edge_overflow_readback_state: std::sync::Arc::new(std::sync::Mutex::new(
2147 EdgeOverflowReadbackState::default(),
2148 )),
2149 froxel_overflow_readback_state: std::sync::Arc::new(std::sync::Mutex::new(
2150 FroxelOverflowReadbackState::default(),
2151 )),
2152 frame_index: 0,
2153 shaders,
2154 bind_group_layouts,
2155 bind_groups,
2156 materials,
2157 dynamic_materials: crate::dynamic_materials::DynamicMaterials::new(),
2158 masked_dynamic_dirty: false,
2159 extras_pool: extras_pool_built,
2160 pipeline_layouts,
2161 pipelines,
2162 lights,
2163 textures,
2164 environment,
2165 render_passes,
2166 _clear_color: clear_color.clone(),
2167 _clear_color_perceptual_to_linear: clear_color.perceptual_to_linear(),
2168 logging,
2169 render_textures,
2170 anti_aliasing,
2171 prep_config,
2172 post_processing,
2173 picker,
2174 lines,
2175 opaque_mipgen,
2176 shadows,
2177 features,
2178 optimization_policy,
2179 // First frame's previous-state input. All flags `false` is
2180 // the safe baseline: render.rs's policy computation pass
2181 // will derive `gpu_occlusion=false` for Auto until the
2182 // cooldown elapses (so the first frames after init route
2183 // through the CPU path) and Force / Off behave per spec
2184 // from frame 0.
2185 frame_optimizations: crate::optimization_policy::FrameOptimizations::default(),
2186 // Set to the cooldown threshold so Auto can flip on at the
2187 // very first qualifying frame instead of waiting through a
2188 // cooldown of empty frames after startup. Without this,
2189 // every fresh renderer would burn `gpu_culling_cooldown_frames`
2190 // before Auto could engage — a poor UX for editor builds
2191 // that load a large scene immediately.
2192 frames_in_current_mode: u32::MAX / 2,
2193 default_cheap_material_pixel_threshold: 64,
2194 renderable_pool: crate::renderable::RenderablePool::default(),
2195 hud_resolve: crate::render::HudResolveState::default(),
2196 pipeline_scheduler: crate::pipeline_scheduler::PipelineScheduler::new(),
2197 last_ensured_bucket_layout: None,
2198 // Flipped to true at end of build(). Used by config-change
2199 // APIs to enforce the race policy from the architecture doc.
2200 build_complete: false,
2201 #[cfg(feature = "animation")]
2202 animations,
2203 cameras: crate::cameras::Cameras::new(),
2204 render_frame_scratch: crate::render::RenderFrameScratch::default(),
2205 // The render gate starts closed: a freshly built renderer shows the
2206 // loading screen until its first `commit_load` lands.
2207 scene_committed: false,
2208 load_phase: crate::loading::LoadPhase::Idle,
2209 loading_textures_total: 0,
2210 loading_textures_uploaded: 0,
2211 loading_geometry_total: 0,
2212 loading_geometry_uploaded: 0,
2213 config_spec,
2214 };
2215
2216 // Apply the configured registration ceiling (§2). Validated +
2217 // clamped in `with_bucket_config`, so this only sets the field; it
2218 // sizes nothing per-frame (widths follow the live count, §0).
2219 if let Some(bucket_config) = bucket_config {
2220 _self
2221 .dynamic_materials
2222 .set_max_bucket_entries(bucket_config.max_bucket_entries);
2223 }
2224
2225 // Initial AA + PP state — the effects + display pipelines we
2226 // installed in the cross-tail pool above already match the
2227 // configured `anti_aliasing` + `post_processing`, so the
2228 // pipeline-rebuild path inside set_anti_aliasing /
2229 // set_post_processing would just no-op through cache hits.
2230 // We still need the state-side bookkeeping (bind-group recreate
2231 // marks). `BindGroups::new` already marks every variant for
2232 // create on first frame, so the AA / PP marks are redundant —
2233 // but adding them explicitly mirrors the dynamic-setter
2234 // contract and keeps the surface honest if `BindGroups::new`
2235 // ever stops marking everything.
2236 _self
2237 .bind_groups
2238 .mark_create(crate::bind_groups::BindGroupCreate::AntiAliasingChange);
2239 _self
2240 .bind_groups
2241 .mark_create(crate::bind_groups::BindGroupCreate::TextureViewRecreate);
2242
2243 // Block D.1 PART 3: register the eager set with the scheduler.
2244 // The eager set (the pipelines compiled inline above by the
2245 // per-pass `from_resolved` calls) is now tracked through the
2246 // scheduler's PassDef SlotMap. They're already-compiled, so we
2247 // immediately transition Pending → Ready. Frontends watching
2248 // drain_pipeline_status_events observe each PassKind register;
2249 // config-flip semantics (Block D.3) can walk the Pass entries
2250 // similarly to materials.
2251 //
2252 // The literal "compile drives THROUGH the scheduler" shape
2253 // (submit → scheduler kicks off compile → wait_for_pipelines_ready)
2254 // would additionally require each render-pass's `from_resolved`
2255 // to factor into `new_deferred` + `ensure_pipelines_compiled`
2256 // for the eager set's individual passes — that's a multi-day
2257 // refactor and is parked for a follow-up. The bookkeeping here
2258 // is the architectural promise's observability piece: scheduler
2259 // knows the full eager set; frontends + config-flip + status
2260 // queries all see it.
2261 {
2262 use crate::pipeline_scheduler::{
2263 PassDef, PipelineConfigSnapshot, PipelineGroupDef, PipelineGroupId,
2264 };
2265 let snapshot = PipelineConfigSnapshot {
2266 msaa: _self.anti_aliasing.clone(),
2267 mipmap: if _self.anti_aliasing.mipmap {
2268 crate::render_passes::material_opaque::shader::template::MipmapMode::Gradient
2269 } else {
2270 crate::render_passes::material_opaque::shader::template::MipmapMode::None
2271 },
2272 gpu_culling: _self.features.gpu_culling,
2273 coverage_lod: _self.features.coverage_lod,
2274 debug_bitmask: 0,
2275 default_cull_mode: awsm_renderer_core::pipeline::primitive::CullMode::Back,
2276 };
2277 let active_msaa_samples: u8 = if _self.anti_aliasing.has_msaa_checked()? {
2278 4
2279 } else {
2280 1
2281 };
2282 let mut eager_passes: Vec<PipelineGroupDef> = vec![
2283 PipelineGroupDef::Pass(PassDef::ClassifyMsaa {
2284 samples: active_msaa_samples,
2285 snapshot: snapshot.clone(),
2286 }),
2287 PipelineGroupDef::Pass(PassDef::GeometryMsaa {
2288 samples: active_msaa_samples,
2289 snapshot: snapshot.clone(),
2290 }),
2291 PipelineGroupDef::Pass(PassDef::Display),
2292 PipelineGroupDef::Pass(PassDef::ScenePassClear),
2293 ];
2294 if _self.features.gpu_culling {
2295 eager_passes.push(PipelineGroupDef::Pass(PassDef::HzbSeed {
2296 samples: active_msaa_samples,
2297 }));
2298 }
2299 if edge_resolve_enabled {
2300 eager_passes.push(PipelineGroupDef::Pass(PassDef::EdgeResolveBlend {
2301 snapshot: snapshot.clone(),
2302 }));
2303 }
2304 let pass_ids = _self
2305 .pipeline_scheduler
2306 .submit_pipeline_group_batch(eager_passes);
2307 for id in &pass_ids {
2308 if matches!(id, PipelineGroupId::Pass(_)) {
2309 _self.pipeline_scheduler.mark_ready(*id);
2310 }
2311 }
2312 tracing::info!(
2313 target: "awsm_renderer::pipeline_readiness",
2314 "eager-set registered with scheduler: {} groups marked Ready",
2315 pass_ids.len()
2316 );
2317 }
2318
2319 // Race-policy: config-change APIs become available now that
2320 // the eager batch is done.
2321 _self.build_complete = true;
2322
2323 emit_phase(RendererLoadingPhase::Ready);
2324
2325 Ok(_self)
2326 }
2327}
2328
2329// =============================================================================
2330// Pipeline-readiness scheduler — public API on AwsmRenderer
2331// =============================================================================
2332//
2333// Wraps the scheduler with renderer-side ergonomics (a single import
2334// surface, race-policy enforcement on the config-change APIs, a test
2335// helper for awaiting Pending → Ready).
2336//
2337// Per the architecture in `https://github.com/dakom/awsm-renderer/pull/99`:
2338//
2339// - `submit_pipeline_group_batch` is the public submission API.
2340// - `pipeline_group_status` is the pull-side status query.
2341// - `drain_pipeline_status_events` is the push-side event drain.
2342// - `drop_material_group` cleans up orphans from the editor's
2343// recompile flow.
2344// - `poll_pipeline_scheduler` drives the FuturesUnordered from the
2345// render loop's pre-frame phase.
2346// - `wait_for_pipelines_ready` is the test-only helper.
2347
2348/// Cluster-LOD (virtual geometry) GPU upload + paging wrappers. Compiled only
2349/// with the `lod` feature; the scene-loader's calls to these are gated the same
2350/// way, so a no-LOD build omits them entirely.
2351#[cfg(feature = "lod")]
2352impl AwsmRenderer {
2353 /// Upload a cluster mesh's pages into the cluster-LOD cut pass (Phase B,
2354 /// B.2). No-op unless `virtual_geometry` built the pass. Called once at mesh
2355 /// load by the scene loader; (re)allocates the GPU buffers + rebuilds the cut
2356 /// bind group. Disjoint sub-borrows of `self` (pass vs gpu vs layouts).
2357 pub fn upload_cluster_pages(
2358 &mut self,
2359 render_mesh: crate::meshes::MeshKey,
2360 pages: &[crate::cluster_lod::ClusterPage],
2361 indices: &[u32],
2362 ) -> crate::error::Result<()> {
2363 if let Some(pass) = self.render_passes.cluster_lod.as_mut() {
2364 pass.upload_pages(
2365 render_mesh,
2366 &self.gpu,
2367 &self.bind_group_layouts,
2368 pages,
2369 indices,
2370 )?;
2371 }
2372 Ok(())
2373 }
2374
2375 /// Upload the Gap-B dynamic-paging residency table (`cluster_id → page-pool
2376 /// slot`, `-1` = absent). Call after [`Self::upload_cluster_pages`]. No-op
2377 /// unless `virtual_geometry` built the pass; only the `cluster_paging` loader
2378 /// path calls it (so the non-paging path allocates no resident buffer).
2379 pub fn upload_cluster_resident(
2380 &mut self,
2381 render_mesh: crate::meshes::MeshKey,
2382 resident: &[i32],
2383 ) -> crate::error::Result<()> {
2384 if let Some(pass) = self.render_passes.cluster_lod.as_mut() {
2385 pass.upload_resident(render_mesh, &self.gpu, &self.bind_group_layouts, resident)?;
2386 }
2387 Ok(())
2388 }
2389
2390 /// Arm the Gap-B dynamic-paging manager with the full DAG + CPU geometry + the
2391 /// initial residency seed (see
2392 /// [`crate::render_passes::cluster_lod::ClusterPagingInit`]). The pages carry the
2393 /// bake's real `[lod_error, parent_error)` (NOT the resident frontier's clamped
2394 /// values). Call after [`Self::upload_cluster_pages`]; only the `cluster_paging`
2395 /// loader path calls it, so the shipped path stays byte-identical. No-op unless
2396 /// the pass exists.
2397 pub fn init_cluster_paging(
2398 &mut self,
2399 render_mesh: crate::meshes::MeshKey,
2400 init: crate::render_passes::cluster_lod::ClusterPagingInit,
2401 ) {
2402 if let Some(pass) = self.render_passes.cluster_lod.as_mut() {
2403 pass.init_paging(render_mesh, init);
2404 }
2405 }
2406}
2407
2408impl AwsmRenderer {
2409 /// Submit a batch of pipeline groups for compile. Returns ids
2410 /// immediately in `Pending` state; transitions to `Ready` /
2411 /// `Failed` surface via [`Self::drain_pipeline_status_events`] or
2412 /// [`Self::pipeline_group_status`].
2413 ///
2414 /// Per the architecture doc, this is the unified API over both
2415 /// materials and passes. Stage 1 follow-up will wire each
2416 /// `PipelineGroupDef` variant to its real compile path; today the
2417 /// scheduler queues stub futures that resolve immediately with
2418 /// `Ok(())`.
2419 pub fn submit_pipeline_group_batch(
2420 &mut self,
2421 defs: Vec<crate::pipeline_scheduler::PipelineGroupDef>,
2422 ) -> Vec<crate::pipeline_scheduler::PipelineGroupId> {
2423 self.pipeline_scheduler.submit_pipeline_group_batch(defs)
2424 }
2425
2426 /// Per-group status query — O(1) lookup. Returns `None` if the id
2427 /// doesn't exist in the scheduler (dropped or never submitted).
2428 pub fn pipeline_group_status(
2429 &self,
2430 id: crate::pipeline_scheduler::PipelineGroupId,
2431 ) -> Option<&crate::pipeline_scheduler::PipelineGroupStatus> {
2432 self.pipeline_scheduler.pipeline_group_status(id)
2433 }
2434
2435 /// Drain status events accumulated since the last call. Frontends
2436 /// use this to drive "compiling N of M" UI without per-frame
2437 /// polling.
2438 pub fn drain_pipeline_status_events(&mut self) -> Vec<crate::pipeline_scheduler::StatusEvent> {
2439 self.pipeline_scheduler.drain_status_events()
2440 }
2441
2442 /// Aggregate compile-progress snapshot for a loading bar / "compiling
2443 /// N materials…" UI (Decision 14, pull half). Counts pending / ready
2444 /// / failed materials plus the total in-flight sub-pipeline compiles.
2445 /// Cheap; safe to call every frame. See
2446 /// [`crate::pipeline_scheduler::CompileProgress`].
2447 pub fn compile_progress(&self) -> crate::pipeline_scheduler::CompileProgress {
2448 self.pipeline_scheduler.compile_progress()
2449 }
2450
2451 /// Compile status of a registered dynamic material's pipeline group, by
2452 /// shader id. `None` while the compile is still pending (or the material has
2453 /// no scheduler group yet); `Some(Ok(()))` once its pipelines are `Ready`;
2454 /// `Some(Err(msg))` with the real WGSL/driver compile error once `Failed`.
2455 ///
2456 /// The launch path skips synchronous shader validation
2457 /// (`ensure_keys_sync_skip_validate`); the actual compile resolves
2458 /// asynchronously via `poll_pipeline_scheduler` and lands the error here. The
2459 /// editor polls this after register so it can surface a true compile failure
2460 /// (undefined symbol, type error, …) instead of only the trailing-`;`
2461 /// heuristic.
2462 pub fn dynamic_material_compile_status(
2463 &self,
2464 shader_id: awsm_renderer_materials::MaterialShaderId,
2465 ) -> Option<std::result::Result<(), String>> {
2466 let mid = self
2467 .pipeline_scheduler
2468 .find_material_by_shader_id(shader_id)?;
2469 match self
2470 .pipeline_scheduler
2471 .pipeline_group_status(crate::pipeline_scheduler::PipelineGroupId::Material(mid))?
2472 {
2473 crate::pipeline_scheduler::PipelineGroupStatus::Pending => None,
2474 crate::pipeline_scheduler::PipelineGroupStatus::Ready => Some(Ok(())),
2475 crate::pipeline_scheduler::PipelineGroupStatus::Failed { error } => {
2476 Some(Err(error.to_string()))
2477 }
2478 }
2479 }
2480
2481 /// Synchronously validate a registered dynamic (custom-WGSL) material's
2482 /// ASSEMBLED opaque kernel with `naga`, returning the compile error
2483 /// message(s) (empty = valid). naga is the same WGSL front-end Chrome's Tint
2484 /// mirrors for the common breakage (undefined symbol / type mismatch / the
2485 /// padding-constructor class), so this catches a broken custom material
2486 /// up-front — the editor calls it at register time and surfaces the result in
2487 /// material diagnostics. It exists because the GPU compiles the *shared*
2488 /// `Material Opaque` kernel asynchronously and never attributes a failure back
2489 /// to one material, so diagnostics otherwise reported a silent `ok` (D2b).
2490 ///
2491 /// Validation-only: it does NOT gate rendering (a false positive vs. Tint
2492 /// would mis-report a diagnostic, never break a frame). No-op (always empty)
2493 /// unless the `dynamic-material-validation` feature is on — the player never
2494 /// authors materials, so it pays nothing for `naga`.
2495 pub fn validate_dynamic_material_wgsl(
2496 &self,
2497 shader_id: awsm_renderer_materials::MaterialShaderId,
2498 ) -> Vec<String> {
2499 #[cfg(not(feature = "dynamic-material-validation"))]
2500 {
2501 let _ = shader_id;
2502 Vec::new()
2503 }
2504 #[cfg(feature = "dynamic-material-validation")]
2505 {
2506 use crate::dynamic_materials::{first_party_bucket_entries, BucketEntry, ShadingBase};
2507 use awsm_renderer_materials::MaterialAlphaMode;
2508
2509 let Some(info) = self.dynamic_materials.shader_info_for(shader_id) else {
2510 return Vec::new();
2511 };
2512 // §12: a BLEND custom material's MAIN WGSL is wrapped in the
2513 // TRANSPARENT contract (`TransparentShadingOutput`); Opaque/Mask route
2514 // to the opaque contract (`OpaqueShadingOutput`). Validating against
2515 // the wrong template falsely reported "no definition in scope for
2516 // identifier: TransparentShadingOutput". Pick the template that matches
2517 // the material's actual render pass (mirrors `launch.rs` build_opaque).
2518 let is_blend = matches!(
2519 self.dynamic_materials.get(shader_id).map(|r| r.alpha_mode),
2520 Some(MaterialAlphaMode::Blend)
2521 );
2522 // Representative config: validation only depends on the dynamic
2523 // struct/loader/fragment + declared includes, not the exact pool/AA
2524 // sizes (those change array lengths, never the WGSL's validity).
2525 let src = if is_blend {
2526 use crate::render_passes::light_culling::buffers::DEFAULT_SLICE_COUNT;
2527 use crate::render_passes::material_transparent::shader::cache_key::ShaderCacheKeyMaterialTransparent;
2528 use crate::render_passes::material_transparent::shader::template::ShaderTemplateMaterialTransparent;
2529 use crate::render_passes::shared::material::cache_key::ShaderMaterialVertexAttributes;
2530 let key = ShaderCacheKeyMaterialTransparent {
2531 instancing_transforms: false,
2532 attributes: ShaderMaterialVertexAttributes {
2533 normals: true,
2534 tangents: true,
2535 color_sets: None,
2536 uv_sets: Some(1),
2537 },
2538 texture_pool_arrays_len: 1,
2539 texture_pool_samplers_len: 1,
2540 msaa_sample_count: None,
2541 mipmaps: false,
2542 base: ShadingBase::Custom,
2543 pbr_features: awsm_renderer_materials::pbr::PbrFeatures::default().bits(),
2544 dispatch_hash: 0,
2545 dynamic_shader_id: Some(shader_id),
2546 dynamic_shader: Some(info),
2547 // Fragment-hook validation only; the custom-vertex path has
2548 // its own validator (`validate_dynamic_vertex_transparent_wgsl`).
2549 dynamic_vertex_shader: None,
2550 froxel_slice_count: DEFAULT_SLICE_COUNT,
2551 };
2552 let template = match ShaderTemplateMaterialTransparent::try_from(&key) {
2553 Ok(t) => t,
2554 Err(e) => return vec![format!("shader template build failed: {e:?}")],
2555 };
2556 match template.into_source() {
2557 Ok(s) => s,
2558 Err(e) => return vec![format!("shader render failed: {e:?}")],
2559 }
2560 } else {
2561 use crate::render_passes::material_opaque::shader::cache_key::ShaderCacheKeyMaterialOpaque;
2562 use crate::render_passes::material_opaque::shader::template::ShaderTemplateMaterialOpaque;
2563 let mut bucket_entries = first_party_bucket_entries();
2564 bucket_entries.push(BucketEntry {
2565 shader_id,
2566 base: ShadingBase::Custom,
2567 pbr_features: awsm_renderer_materials::pbr::PbrFeatures::default().bits(),
2568 name: "custom".to_string(),
2569 });
2570 let key = ShaderCacheKeyMaterialOpaque {
2571 texture_pool_arrays_len: 1,
2572 texture_pool_samplers_len: 1,
2573 msaa_sample_count: None,
2574 mipmaps: false,
2575 max_shadow_casters: 4,
2576 shader_id,
2577 base: ShadingBase::Custom,
2578 owns_skybox: false,
2579 pbr_features: awsm_renderer_materials::pbr::PbrFeatures::default().bits(),
2580 dispatch_hash: 0,
2581 dynamic_shader: Some(info),
2582 bucket_entries,
2583 };
2584 let template = match ShaderTemplateMaterialOpaque::try_from(&key) {
2585 Ok(t) => t,
2586 Err(e) => return vec![format!("shader template build failed: {e:?}")],
2587 };
2588 match template.into_source() {
2589 Ok(s) => s,
2590 Err(e) => return vec![format!("shader render failed: {e:?}")],
2591 }
2592 };
2593 match naga::front::wgsl::parse_str(&src) {
2594 Err(e) => vec![e.emit_to_string(&src)],
2595 Ok(module) => {
2596 let mut validator = naga::valid::Validator::new(
2597 naga::valid::ValidationFlags::all(),
2598 naga::valid::Capabilities::all(),
2599 );
2600 match validator.validate(&module) {
2601 Ok(_) => Vec::new(),
2602 Err(e) => vec![e.emit_to_string(&src)],
2603 }
2604 }
2605 }
2606 }
2607 }
2608
2609 /// Synchronously validate a registered custom-**vertex** material's
2610 /// ASSEMBLED geometry custom-vertex module with `naga`, returning the
2611 /// compile error message(s) (empty = valid). The vertex-stage sibling of
2612 /// [`Self::validate_dynamic_material_wgsl`]: it assembles the masked
2613 /// geometry bind groups + the geometry vertex shader compiled with the
2614 /// `custom_displace_vertex` hook + the plain geometry fragment, then runs
2615 /// naga so the editor catches a broken `wgsl_vertex` body up-front.
2616 ///
2617 /// Validation-only (never gates rendering). No-op (always empty) unless the
2618 /// `dynamic-material-validation` feature is on — the player pays nothing for
2619 /// `naga`. Empty when the material isn't registered or declared no
2620 /// `wgsl_vertex` (→ shared fast vertex pipeline; nothing to validate).
2621 pub fn validate_dynamic_vertex_wgsl(
2622 &self,
2623 shader_id: awsm_renderer_materials::MaterialShaderId,
2624 ) -> Vec<String> {
2625 #[cfg(not(feature = "dynamic-material-validation"))]
2626 {
2627 let _ = shader_id;
2628 Vec::new()
2629 }
2630 #[cfg(feature = "dynamic-material-validation")]
2631 {
2632 self.dynamic_materials
2633 .validate_dynamic_vertex_wgsl(shader_id)
2634 }
2635 }
2636
2637 /// Drop a material group. No-op if the id isn't in the scheduler.
2638 pub fn drop_material_group(&mut self, id: crate::pipeline_scheduler::MaterialId) {
2639 self.pipeline_scheduler.drop_material_group(id);
2640 }
2641
2642 /// Poll the scheduler's `FuturesUnordered` for resolved compiles.
2643 /// Called from the render loop's pre-frame phase.
2644 ///
2645 /// Drives BOTH inflight queues:
2646 /// - Legacy `inflight` (whole-batch CompileResolutions, currently
2647 /// driven by explicit `mark_ready` / `mark_failed`).
2648 /// - Block D.1 PART 2 `inflight_compile` (per-sub-pipeline
2649 /// compile promises). Each resolution installs the resolved
2650 /// `GpuComputePipeline` into the per-pass cache + decrements
2651 /// the material's sub-compile counter (transition to Ready
2652 /// when counter hits 0).
2653 ///
2654 /// Returns the number of transitions applied this poll.
2655 pub fn poll_pipeline_scheduler(&mut self) -> usize {
2656 // Legacy inflight (whole-batch).
2657 let mut applied = self.pipeline_scheduler.poll_resolved();
2658 // D.1 PART 2 inflight_compile (per-sub-pipeline).
2659 while self.apply_compile_resolution() {
2660 applied += 1;
2661 }
2662 applied
2663 }
2664
2665 /// Block C.2 full: grow (or shrink) the
2666 /// [`MaterialEdgeBuffers::max_edge_budget`](crate::render_passes::material_opaque::edge_buffers::MaterialEdgeBuffers)
2667 /// at runtime.
2668 ///
2669 /// Use case: a frontend running on a pathological-edge-density
2670 /// scene observes `edge_overflow_count > 0` (via the one-shot
2671 /// `tracing::warn!` from
2672 /// [`note_edge_overflow_observed`](crate::render_passes::material_opaque::edge_buffers::note_edge_overflow_observed)
2673 /// OR via direct CPU readback of `edge_buffers.args_buffer`'s
2674 /// counter). Calling `set_max_edge_budget(current * 2)` recreates
2675 /// `material_edge_buffers` with the new size, rebuilds the
2676 /// edge-layout uniform, and marks classify + edge-resolve +
2677 /// final-blend bind groups for recreation.
2678 ///
2679 /// This is the architectural answer to the doc's "atomic-add
2680 /// hash-bucket overflow accumulator" (Stage 3.8 / Block C.2
2681 /// full) — instead of routing overflow samples into a separate
2682 /// shading pipeline (which would need a new compute pipeline +
2683 /// bind group + indirect dispatch + per-shader-id specialization
2684 /// to avoid Stage 3's SPIR-V bloat), the budget itself grows
2685 /// dynamically to absorb the pathological case. Steady-state
2686 /// scenes pay nothing; overflow scenes recover via consumer-
2687 /// driven budget growth.
2688 ///
2689 /// Returns `Ok(true)` when buffers were recreated; `Ok(false)`
2690 /// when `new_budget` matches the current value; `Err` if MSAA
2691 /// is off (no edge buffers to size — flip MSAA on first).
2692 pub fn set_max_edge_budget(&mut self, new_budget: u32) -> crate::error::Result<bool> {
2693 if !self.build_complete {
2694 return Err(crate::error::AwsmError::NotReady);
2695 }
2696 let new_budget = new_budget.max(1);
2697 let Some(edge_buffers) = self.material_edge_buffers.as_mut() else {
2698 return Err(crate::error::AwsmError::PipelineVariantNotCompiled(
2699 "edge buffers absent (MSAA off or device unsupported); flip MSAA on first",
2700 ));
2701 };
2702 let resized = edge_buffers.set_max_edge_budget(&self.gpu, new_budget)?;
2703 if !resized {
2704 return Ok(false);
2705 }
2706 let bucket_count = edge_buffers.bucket_count;
2707 // Rebuild the edge-layout uniform with the new max_edge_budget.
2708 if let Ok((uniform, _bytes)) =
2709 crate::render_passes::material_opaque::edge_buffers::build_edge_layout_uniform(
2710 &self.gpu,
2711 bucket_count,
2712 new_budget,
2713 )
2714 {
2715 self.material_edge_layout_uniform = Some(uniform);
2716 }
2717 // Stage 5b-shadow: resize the prep pass's compact edge-shadow texture to
2718 // match the new budget (else cs_prep_edge writes / cs_edge reads beyond
2719 // the texture's row count for the overflow edges). The opaque main bind
2720 // group re-clones the new view next frame (TextureViewRecreate below
2721 // fans out to OpaqueMain).
2722 if let Some(prep) = self.render_passes.material_prep.as_mut() {
2723 prep.set_max_edge_budget(&self.gpu, new_budget)?;
2724 }
2725 // Mark dependent bind groups for recreation.
2726 self.bind_groups
2727 .mark_create(crate::bind_groups::BindGroupCreate::MaterialClassifyBuffersResize);
2728 // The opaque main bind group binds the compact edge-shadow texture
2729 // (binding 27); rebind it against the resized view.
2730 self.bind_groups
2731 .mark_create(crate::bind_groups::BindGroupCreate::TextureViewRecreate);
2732 tracing::info!(
2733 target: "awsm_renderer::edge_resolve",
2734 "set_max_edge_budget: edge budget grown to {} (was tracked via overflow CPU surface)",
2735 new_budget
2736 );
2737 Ok(true)
2738 }
2739
2740 /// Bumps the per-froxel light-index budget used by the GPU
2741 /// light-culling pass. Symmetric with
2742 /// [`Self::set_max_edge_budget`]: when the per-frame CPU readback
2743 /// of `LightCullingBuffers::overflow_buffer` shows that the cull
2744 /// shader bumped a froxel's count past
2745 /// `max_per_froxel_capacity` (so subsequent lights for that
2746 /// froxel were dropped), the renderer doubles the budget on the
2747 /// next render. The budget lives as a *runtime* field on the
2748 /// `cull_params` uniform, so this only reallocates the per-froxel
2749 /// storage (via `LightCullingBuffers::set_max_per_froxel_capacity`)
2750 /// and re-binds it — no shader recompile is needed, and the cull +
2751 /// consumer shaders read the new capacity from `cull_params`.
2752 ///
2753 /// Returns `Ok(true)` when the buffers were recreated; `Ok(false)`
2754 /// when `new_capacity` matches the current value.
2755 pub fn set_max_per_froxel_capacity(&mut self, new_capacity: u32) -> crate::error::Result<bool> {
2756 if !self.build_complete {
2757 return Err(crate::error::AwsmError::NotReady);
2758 }
2759 let new_capacity = new_capacity.max(1);
2760 let resized = self
2761 .light_culling_buffers
2762 .set_max_per_froxel_capacity(&self.gpu, new_capacity)?;
2763 if !resized {
2764 return Ok(false);
2765 }
2766 self.bind_groups
2767 .mark_create(crate::bind_groups::BindGroupCreate::LightCullingFroxelsResize);
2768 tracing::info!(
2769 target: "awsm_renderer::light_culling",
2770 "set_max_per_froxel_capacity: per-froxel budget grown to {} after observed overflow",
2771 new_capacity,
2772 );
2773 Ok(true)
2774 }
2775
2776 /// Toggle the light-culling debug heatmap (dev aid). When `on`, the
2777 /// shading shaders output a per-pixel applied-punctual-light-count
2778 /// heatmap instead of normal shading — blue (few) → red (many) — so
2779 /// froxel occupancy / cull behaviour can be inspected visually. The
2780 /// value is written into `CullParams.debug_light_heatmap` on the next
2781 /// `write_params`; no buffer recreation or shader recompile needed.
2782 pub fn set_light_culling_debug_heatmap(&mut self, on: bool) {
2783 self.light_culling_debug_heatmap = u32::from(on);
2784 }
2785
2786 /// Global debug view mode: 0 = normal lit shading, 1 = unlit/flat (base
2787 /// color only). Written into `CullParams.debug_view_mode` on the next
2788 /// `write_params`; no buffer recreation or shader recompile. Affects PBR
2789 /// materials (the common case); already-unlit/Toon/custom materials are
2790 /// unchanged. The shader branch that reads it exists only under the
2791 /// `debug-views` cargo feature (the editor enables it); in a game build
2792 /// this setter still writes the uniform but nothing reads it.
2793 pub fn set_debug_view_mode(&mut self, mode: u32) {
2794 self.debug_view_mode = mode;
2795 }
2796
2797 /// Toggle the global debug wireframe overlay (triangle edges tinted in the
2798 /// deferred shade). Written into `CullParams.debug_wireframe` each frame; no
2799 /// recompile. Read only by the `debug-views`-gated shader branch.
2800 pub fn set_debug_wireframe(&mut self, on: bool) {
2801 self.debug_wireframe = u32::from(on);
2802 }
2803
2804 /// The commit's CONCURRENT compile drain — `commit_load`'s phase 3. Kicks
2805 /// the render-driven scene compile (`ensure_scene_pipelines`, inside
2806 /// `prewarm_pipelines`) + the transparent-mesh + line prewarm, then drains
2807 /// every resulting `inflight_compile` promise CONCURRENTLY via
2808 /// `Stream::next` (each `.await` yields to the JS event loop so Dawn's
2809 /// compile promises fire), installing each as it resolves and invoking
2810 /// `on_progress` with a fresh [`CompileProgress`] snapshot per resolution.
2811 ///
2812 /// `pub(crate)`: the ONLY caller is `commit_load` (the one compile path).
2813 /// It is not a free-floating "wait for pipelines" an embedder calls mid-
2814 /// render — there is no such surface anymore.
2815 ///
2816 /// Returns the total number of transitions applied (diagnostic only).
2817 pub(crate) async fn drain_commit_compiles(
2818 &mut self,
2819 mut on_progress: impl FnMut(crate::pipeline_scheduler::CompileProgress),
2820 ) -> crate::error::Result<usize> {
2821 // Phase 1: kick the render-driven material compile
2822 // (`ensure_scene_pipelines`, inside `prewarm_pipelines`) +
2823 // transparent-mesh prewarm. The promises land in
2824 // `inflight_compile`; Phase 2 below drains them async.
2825 self.prewarm_pipelines().await?;
2826
2827 // Block B.3: if any line primitive has been registered since
2828 // build (or since the last commit), drive the lazy line-pipeline
2829 // compile here so the next frame can dispatch the fat-line pass
2830 // instead of warn-skipping.
2831 self.ensure_line_pipelines_compiled().await?;
2832
2833 // Report the initial in-flight count before the drain so the splash
2834 // shows a number immediately rather than after the first resolution.
2835 on_progress(self.compile_progress());
2836
2837 // Phase 2: drain real D.1 PART 2 inflight_compile via async
2838 // Stream::next — each .await yields to the JS event loop so
2839 // Dawn's compile promises can fire. Once next() returns None
2840 // (the FuturesUnordered is empty), every sub-pipeline has
2841 // resolved and apply_compile_resolution installed it.
2842 let mut total = 0usize;
2843 loop {
2844 let resolution_opt = {
2845 use futures::StreamExt;
2846 self.pipeline_scheduler.inflight_compile.next().await
2847 };
2848 let Some(resolution) = resolution_opt else {
2849 break;
2850 };
2851 self.apply_compile_resolution_inline(resolution);
2852 total += 1;
2853 on_progress(self.compile_progress());
2854 }
2855
2856 // Phase 3: drain legacy whole-batch inflight (currently empty
2857 // — explicit mark_ready / mark_failed callers don't push to
2858 // it). Kept for future Pass-flavoured push-futures work.
2859 const MAX_ROUNDS: usize = 1024;
2860 for _ in 0..MAX_ROUNDS {
2861 let applied = self.pipeline_scheduler.poll_resolved();
2862 total += applied;
2863 if applied == 0 {
2864 break;
2865 }
2866 }
2867 Ok(total)
2868 }
2869}
2870
2871/// Returns true if the device can host the Stage 3 / Priority 3
2872/// per-shader-id MSAA edge-resolve pipelines.
2873///
2874/// After the group(4) → extended-shadows fold (see
2875/// `MaterialEdgeBindGroupLayouts`), the per-shader-id edge_resolve
2876/// pipeline layout fits in 4 bind groups — universally supported, so
2877/// the bind-group constraint no longer matters. The only remaining
2878/// constraint is the storage-buffer count: edge_resolve's compute
2879/// stage now takes two extra storage buffer slots above primary
2880/// opaque's (the read-write `edge_data` binding + the read-only
2881/// `edge_args` binding from the args/data split). Primary opaque uses
2882/// 9 storage buffers in its compute stage; edge_resolve uses 11. Both
2883/// fit under the WebGPU baseline `maxStorageBuffersPerShaderStage`
2884/// (≥ 10 on Android Vulkan / macOS Metal / Windows Vulkan / iOS Metal
2885/// — the spec minimum is 8, but every modern WebGPU stack reports
2886/// ≥ 10).
2887///
2888/// Devices below the storage-buffer limit fall back to the inline
2889/// `msaa_resolve_samples` path in the primary opaque shader. This
2890/// almost never triggers in practice, but the safety net stays.
2891///
2892/// **Args/data buffer split (now in place).** Earlier this returned
2893/// `false` because `MaterialEdgeBuffers` was a single GpuBuffer used
2894/// as both `Indirect` (dispatch source) and `Storage(read-write)`
2895/// (accumulator + sample lists) inside one compute pass — WebGPU
2896/// rejects that combination per-buffer per-pass. The buffer is now
2897/// split: `args_buffer` (`Indirect | Storage | CopyDst`, the
2898/// dispatch-indirect source + counters) and `data_buffer`
2899/// (`Storage | CopyDst`, the writable accumulator + sample lists).
2900/// The args buffer is bound only as `Storage(read)` in the
2901/// edge_resolve / skybox / final_blend passes — `Storage(read)` +
2902/// `Indirect` on the same buffer is allowed (no writable usage in
2903/// the sync scope). This unlocks Priority 3 end-to-end.
2904pub fn edge_resolve_supported(_gpu: &awsm_renderer_core::renderer::AwsmRendererWebGpu) -> bool {
2905 true
2906}
2907
2908/// Absolute byte offset of page-pool slot `slot` in the cluster render mesh's
2909/// visibility-data section (Gap-B dynamic paging): `mesh_data_offset +
2910/// slot*slot_bytes`, where `slot_bytes` is one slot's packed length
2911/// (`CLUSTER_PAGE_VERTS*56`). Pure ⇒ unit-tested without a device; the slot stride
2912/// equals the data length so every slot is interchangeable (the paging invariant).
2913#[cfg(feature = "lod")]
2914pub(crate) fn cluster_slot_data_offset(
2915 mesh_data_offset: usize,
2916 slot: usize,
2917 slot_bytes: usize,
2918) -> usize {
2919 mesh_data_offset + slot * slot_bytes
2920}
2921
2922#[cfg(all(test, feature = "lod"))]
2923mod cluster_slot_tests {
2924 use super::cluster_slot_data_offset;
2925
2926 #[test]
2927 fn slot_data_offset_is_base_plus_slot_stride() {
2928 // One slot = CLUSTER_PAGE_VERTS(384) * 56 B = 21504 B.
2929 let slot_bytes = 384 * 56;
2930 assert_eq!(cluster_slot_data_offset(0, 0, slot_bytes), 0);
2931 assert_eq!(cluster_slot_data_offset(0, 1, slot_bytes), 21504);
2932 assert_eq!(cluster_slot_data_offset(0, 5, slot_bytes), 5 * 21504);
2933 // A non-zero mesh section base (the pool packs many meshes) just shifts it.
2934 let base = 1_000_000;
2935 assert_eq!(cluster_slot_data_offset(base, 0, slot_bytes), base);
2936 assert_eq!(
2937 cluster_slot_data_offset(base, 3, slot_bytes),
2938 base + 3 * 21504
2939 );
2940 // Slots are contiguous + non-overlapping: slot s ends exactly where s+1
2941 // begins.
2942 for s in 0..8usize {
2943 assert_eq!(
2944 cluster_slot_data_offset(base, s, slot_bytes) + slot_bytes,
2945 cluster_slot_data_offset(base, s + 1, slot_bytes)
2946 );
2947 }
2948 }
2949}