Skip to main content

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}