Skip to main content

concinnity_engine/gfx/graphics_system/
mod.rs

1// src/gfx/graphics_system/mod.rs
2//
3// GraphicsSystem: the 3D renderer driver. An internal system (not a declarable
4// asset); `World::start` constructs one when the world declares a
5// `GraphicsConfig`. Deliberately a directory rather than a single file; the
6// system is large enough that splitting it by responsibility is worth it:
7//   mod.rs       struct + System/Debug trait impls (init/step delegate out)
8//   init.rs      run_init: one-time backend + draw-list setup
9//   lines.rs     published world-space lines -> ribbon geometry
10//   frame.rs     run_step: extraction of the frame's draw inputs into the
11//                owned RenderSnapshot (the only per-frame world reads)
12//   submit.rs    replay of one RenderSnapshot onto the backend (no world
13//                access by construction)
14//   streaming.rs texture / normal-map / mesh / voxel-world streaming setup
15//                (the per-frame drive lives in gfx::streaming_system)
16//   scene.rs     scene-flow wiring + scene visibility
17//   helpers.rs   shared free functions
18
19use concinnity_host::store::paths::StateTree;
20
21use crate::components::{PostProcessResolve, Window};
22use crate::ecs::asset_id::AssetId;
23use crate::ecs::{PipelineContext, StepResult, System};
24use crate::gfx::backend::RenderBackend;
25use crate::gfx::{scene_flow, text};
26use std::time::Instant;
27
28const IDENTITY4: [[f32; 4]; 4] = crate::gfx::draw_list::IDENTITY4;
29
30// Initialises the GPU backend and draws frame data.
31//
32// Components drained during init():
33//   Window          -- window title, size, and mode
34//   GraphicsConfig  -- frames-in-flight, clear color, max frames
35//   Mesh            -- raw inline geometry payloads (keyed by asset name)
36//   ProceduralMesh  -- generator-built geometry payloads (keyed by asset name)
37//   Model           -- multi-mesh model definitions (keyed by asset name)
38//   Prop            -- scene objects referencing a Mesh/ProceduralMesh or Model
39//   Shader          -- compiled shader payloads (vertex, fragment, instanced)
40//   Texture         -- one or more compiled RGBA texture payloads (keyed by asset name)
41//
42// Components queried (not drained) each step():
43//   Camera3D       -- current view matrix and projection parameters
44//
45// Build process:
46//   Each Mesh is deserialized and kept in a name-keyed map. For each Prop the
47//   corresponding mesh is looked up and appended to the shared vertex/index
48//   buffers; a DrawObject records its slice offsets, model matrix, and texture
49//   slot. One implicit DrawObject is also created for any Mesh that has no Prop
50//   referencing it (e.g. the room itself), placed at the world origin.
51//
52// Input polling + the FrameInput deposit live in InputSystem, scheduled
53// immediately after this system (the OS event pump runs inside draw_frame on
54// Metal, so sampling right after the draw is freshest). Camera3DSystem queries
55// the deposit to update Camera3D, then writes the new view matrix back in time
56// for the next frame, so it runs after both.
57
58// One viewport-pick candidate captured at init: the prop's asset id, its
59// entity (the live GlobalTransform source), and its local-space bounds.
60struct PickCandidate {
61    asset_id: AssetId,
62    entity: crate::ecs::Entity,
63    local_min: [f32; 3],
64    local_max: [f32; 3],
65}
66
67/// Drives the render backend: builds it at init, submits a frame per step.
68pub struct GraphicsSystem {
69    // Where this world reads its source assets and writes its settings, or
70    // `None` for a world with no state tree.
71    state: Option<StateTree>,
72    window_args: Window,
73    clear_color: [f32; 4],
74    frames_in_flight: usize,
75    vsync: bool,
76    // Frame-rate cap in FPS (GraphicsConfig.fps_cap; 0 = unlimited). Applied by
77    // the App-level frame pacer, which reads it through the `FrameRateCap`
78    // resource this system publishes (at init and on the settings row's live
79    // change). Held here so the settings row can cycle from the current value.
80    // Independent of the quality preset (a user/hardware preference, like vsync).
81    fps_cap: u32,
82    // The display modes the Resolution row offers, shaped at init from the
83    // backend's enumeration (or the static fallback when it cannot enumerate)
84    // and published once as the `DisplayModes` resource for the dropdown list.
85    display_modes: Vec<crate::gfx::display_mode::DisplayMode>,
86    // The user's chosen fullscreen display mode, persisted as `resolution`.
87    // `None` = never chosen: the display keeps its own mode and the row shows
88    // `current_mode`. Fullscreen-only: windowed sizes come from the window
89    // (authored / dragged) and borderless covers the display, so the row is
90    // grayed + inert outside Fullscreen and never resizes the window.
91    resolution: Option<crate::gfx::display_mode::DisplayMode>,
92    // The mode the display was running at init (the row's display value until
93    // the user chooses one). `None` when the backend cannot read it.
94    current_mode: Option<crate::gfx::display_mode::DisplayMode>,
95    // The Resolution row's labels with their authored colors, captured at init
96    // so window-mode changes can gray the row out and restore it (mirrors
97    // `perf_sub_row_labels`).
98    resolution_row_labels: Vec<(AssetId, [f32; 3])>,
99    // Stats-HUD display state (GraphicsSettings perf_stats / show_fps / show_vram;
100    // default shown). `perf_stats` is the master "Display performance stats"
101    // toggle; the per-readout flags gate the FPS / VRAM chips under it. Published
102    // each frame as the `HudPrefs` resource for StatHudSystem, and persisted +
103    // applied live by the settings-menu rows. When the master is off the two
104    // sub-rows are grayed (their captured labels in `perf_sub_row_labels`) and
105    // made inert (the `DisabledSettingRows` resource read by UiInputSystem).
106    perf_stats: bool,
107    show_fps: bool,
108    show_vram: bool,
109    // The TextLabel ids of the show_fps / show_vram rows with their authored
110    // colors, captured at init so the master toggle can gray them and restore
111    // them (the menu's HitRegions are drained after init, so the row -> label map
112    // is captured once rather than re-queried).
113    perf_sub_row_labels: Vec<(AssetId, [f32; 3])>,
114    max_frames: Option<u64>,
115    shadow_map_size: u32,
116    shadow_update: crate::components::ShadowUpdate,
117    // Shadow distance in world units (GraphicsConfig.shadow_distance). Applied
118    // live via set_shadow_distance (the per-frame cascade-split math reads it);
119    // preset-governed (a manual change flips the master preset to Custom).
120    shadow_distance: u32,
121    // Active shadow cascade count, 1..=4 (GraphicsConfig.shadow_cascades). Applied
122    // live via set_shadow_cascades (the per-frame split + schedule read it);
123    // preset-governed (a manual change flips the master preset to Custom).
124    shadow_cascades: u32,
125    // Scene-sampler max anisotropy. Restart-required (the sampler is built once at
126    // backend init from this), so this is display/persist state; the value reaches
127    // the backend through the ctor. Preset-governed (a manual change flips the
128    // master preset to Custom).
129    anisotropy: u32,
130    failed: bool,
131    start_time: Option<Instant>,
132    frame_count: u64,
133    // Per-class recovery for failed frames; see `frame_policy`.
134    frame_policy: frame_policy::FramePolicy,
135    // A togglable menu (a Screen) coexists with a controlled Camera3D. When set,
136    // cursor capture is driven each frame by whether a menu screen is active
137    // (release while open, capture otherwise) rather than fixed at startup.
138    menu_mode: bool,
139    // Current render-scale (upscaling) quality, seeded at init from the world's
140    // PostProcessConfig overridden by any persisted choice. The settings row
141    // cycles + persists it; it is restart-required, so this is display/persist
142    // state only (the upscaler is sized once at init).
143    render_scale: crate::components::UpscaleQuality,
144    // Current upscaler backend (Auto / FSR3 / DLSS / XeSS), seeded at init from
145    // the world's PostProcessConfig overridden by any persisted choice. Like
146    // render_scale this is restart-required display/persist state (the upscaler
147    // is selected + built once at init); DirectX / Vulkan only.
148    upscale_backend: crate::components::UpscalerBackend,
149    // The render backend while init constructs and wires it. Boxed
150    // `dyn RenderBackend` so the setup logic in init.rs / streaming.rs /
151    // scene.rs runs as one cfg-free path across Metal, DirectX, and Vulkan.
152    // At the end of a successful init it is parked in the world's
153    // `ActiveRenderBackend` resource, where every per-step user (this system's
154    // frame encode, InputSystem's poll) takes and returns it; `None` from then
155    // on.
156    backend: Option<Box<dyn RenderBackend>>,
157    // active scene-flow bookkeeping while init builds it; handed to the shared
158    // `ActiveSceneFlow` resource at the end of init (SettingsSystem jumps it,
159    // this system ticks it). None when no Scene assets were declared.
160    scene_flow: Option<scene_flow::SceneFlow>,
161    // Per-entity scene-visibility snapshot, refreshed (buffers reused) every
162    // frame a fade runs and on scene-visibility applies.
163    scene_visibility: scene::SceneVisibilityScratch,
164    // Overlay build inputs assembled during init() and handed to OverlaySystem
165    // (as the `OverlayAssets` resource) at its end; empty afterwards. Fonts is
166    // the atlas data keyed by handle; sprite_texture_slots maps a Sprite's
167    // texture into the text-atlas pool (appended after the font atlases); the
168    // chip id lists and scroll clip bands drive the per-frame HUD layout.
169    loaded_fonts: text::FontSet,
170    sprite_texture_slots: crate::gfx::overlay_maps::TextureSlots,
171    debug_hud_chips: Vec<AssetId>,
172    stat_hud_chips: Vec<AssetId>,
173    // Viewport-pick candidates captured at init, one per prop entity, only
174    // when a `PickIndex` resource was present (the editor's opt-in). The frame
175    // step refreshes the published index from these + the live transforms;
176    // empty in a shipped runtime, which skips the refresh entirely.
177    pick_candidates: Vec<PickCandidate>,
178    // Streaming pools built during init (shared albedo+normal texture pool,
179    // mesh geometry, and voxel-world chunks), each `Some` only when a
180    // `StreamingConfig` / `VoxelWorld` was declared and the backend supports it
181    // (Metal). Init scratch: they are moved into the parked `StreamingState`
182    // resource at the end of init, where StreamingSystem drives them each frame,
183    // so they are `None` here from then on.
184    texture_streamer: Option<crate::gfx::streaming::texture::TextureStreamer>,
185    mesh_streamer: Option<crate::gfx::streaming::mesh::MeshStreamer>,
186    // Maps a streamed mesh's id to its DrawObject index, so completed loads
187    // and evictions are applied to the right draw. Empty when not streaming.
188    mesh_stream_draw_indices: Vec<usize>,
189    chunk_stream: Option<crate::gfx::streaming_system::ChunkStreamState>,
190    // Shader buckets whose pipeline init deferred, with the payload source the
191    // pump reads when their scene pins. Init scratch like the pools above.
192    shader_warmup: Option<crate::gfx::streaming::shader::ShaderWarmup>,
193    // Which scene exclusively owns each deferred bucket, so scene residency
194    // can claim it as a member.
195    deferred_shader_scenes: Vec<(u32, AssetId)>,
196    // Source catalogues captured at init for asset hot-reload, handed off to
197    // the `cn debug` binary's reload machinery (which owns the watcher + the
198    // live `AssetHotReloadState`). `Some` only under `cn debug` with at least
199    // one file-backed asset / world.jsonl; taken once by the debug drive via
200    // `take_hot_reload_sources`. `cn run` never captures these; production
201    // reads asset payloads from the compiled blob and never re-touches disk.
202    pending_hot_reload_sources: Option<hot_reload_sources::HotReloadSources>,
203    // Texture-name map captured at init for runtime decal / emitter spawn to
204    // resolve an authored Texture name to its live pool slot. `Some` only under
205    // `cn debug`; read-only after init.
206    world_reload: Option<WorldReloadState>,
207    // The persisted settings-menu graphics overrides as they stood at init
208    // (each field `None` when the user never changed that row). Held so the
209    // live-lighting seam can re-derive a knob exactly as init did: an authoring
210    // edit to a row the user has overridden moves the authored baseline only,
211    // matching what a relaunch of the edited world would show.
212    persisted_graphics: crate::config::GraphicsSettings,
213    // Whether the world declared enabled fog at init, so the backend built the
214    // fog pass. A backend that never built it cannot be handed fog live.
215    fog_built: bool,
216    // Last `VolumetricFog` settings pushed to the backend, used by the
217    // world.jsonl reload pass to dedupe: if the resolved value matches what's
218    // already live, the reload skips the trait call and the log entry. Tracks
219    // both `None` (no fog / disabled) and `Some(settings)`. Initialised by
220    // `run_init` to whatever was passed into the backend constructor.
221    last_fog_settings: Option<crate::gfx::volumetric_fog::FogSettings>,
222    // Live post-process parameters (bloom / exposure / vignette / LUT blend),
223    // the source of truth for slider settings. Seeded at init from the world's
224    // resolved PostProcessConfig (with any persisted overrides applied); a
225    // slider drag mutates a field here and pushes the whole struct to the
226    // backend via `update_post_process`.
227    post_process: crate::gfx::render_types::PostProcessTunables,
228    // Live ambient (IBL) light scale, the source of truth for the Ambient
229    // slider. Lives in the backend's `LightUniforms` (not `PostProcessParams`),
230    // so it is held + pushed separately via `set_ambient_intensity`. Seeded at
231    // init from the world's `PostProcessConfig.ambient_intensity` (with any
232    // persisted override applied) and pushed to the backend once after it is
233    // built.
234    ambient_intensity: f32,
235    // The world's resolved PostProcessConfig with the user's persisted
236    // quality-toggle overrides applied (defaulted when the world declares none).
237    // The source of truth for the Quality-group toggles: a toggle flips the
238    // matching field here, re-derives the per-feature settings, and pushes them
239    // to the backend's live rebuild. The non-toggle fields (exposure, bloom,
240    // ambient) keep their authored values here; the sliders own those via
241    // `post_process` / `ambient_intensity` instead.
242    post_config: crate::components::PostProcessConfig,
243    // Slider rows in the world, captured at init from their drag HitRegions +
244    // handle Sprites. Drives the handle position + value-label update when a
245    // slider changes, and the one-time sync of both to the live value at init.
246    sliders: Vec<SliderViz>,
247    // Cycle rows' setting key -> value-label id, captured at init from their
248    // `setting:<key>:next` HitRegions (drained by UiInputSystem afterwards). Lets
249    // a runtime change relabel a row other than the one clicked: the master
250    // "Graphics Quality" preset relabels the quality toggles + render scale it
251    // re-derives, and an individual quality-row change relabels the master row.
252    cycle_value_labels: std::collections::HashMap<String, AssetId>,
253    // Per-element clip bands (reference space) captured at init from the world's
254    // ScrollPanels: each scroll-content element id maps to its panel's content
255    // band, so the draw path scissors it and off-band rows do not bleed over the
256    // panel chrome. Empty when no ScrollPanel was declared; handed to
257    // OverlaySystem (inside `OverlayAssets`) at the end of init.
258    clip_rects: crate::gfx::overlay_maps::ClipRects,
259    // Live gameplay movement key map (the source of truth for the Controls-tab
260    // rebind rows). Seeded at init from the persisted `ControlsSettings.keymap`
261    // or the engine default, pushed to the backend once after it is built, and
262    // updated (with a swap) + re-pushed + persisted on each rebind.
263    keymap: crate::gfx::keymap::KeyMap,
264    // Rebind rows in the world, captured at init from their `setting:key_*:rebind`
265    // HitRegions. Maps each rebindable action to its value `TextLabel`, so a
266    // rebind (and the swap it may trigger) can refresh both affected row labels.
267    rebind_rows: Vec<RebindViz>,
268    // Live gamepad action -> button map. Seeded at init from the persisted
269    // `ControlsSettings.gamepad_map` or the engine default; InputSystem applies
270    // it (the gamepad is polled engine-side, so no backend push).
271    gamepad_map: crate::components::GamepadMap,
272    // Gamepad rebind rows in the world, captured at init from their
273    // `setting:pad_*:rebind` HitRegions, like `rebind_rows`.
274    pad_rebind_rows: Vec<PadRebindViz>,
275    // Device capability flags, queried from the backend once it is built. Drives
276    // the capability gating at init: a settings row whose feature the device
277    // cannot provide (e.g. ray-traced reflections without hardware ray tracing)
278    // is grayed out and made inert. Held in memory only, never persisted.
279    caps: crate::gfx::backend::DeviceCapabilities,
280    // Coarse GPU performance profile, probed before the backend is built so the
281    // auto-config quality ceiling can influence the render targets / effect
282    // pipelines sized at backend init. Held in memory only, never persisted.
283    gpu_profile: crate::gfx::backend::GpuProfile,
284    // The live master "Graphics Quality" preset the settings-menu row cycles.
285    // Seeded at init from the persisted choice (or `Auto` on first launch);
286    // changing a preset re-derives the quality toggles + render scale under its
287    // ceiling, and changing any individual quality row flips this to `Custom`.
288    quality_preset: crate::gfx::quality_preset::QualityPreset,
289    // The world's authored PostProcessConfig before the user overrides + preset
290    // ceiling are applied (defaulted when the world declares none). The pristine
291    // baseline a live preset change re-clamps from, so up-shifting a preset
292    // restores the world's features and down-shifting clamps them off.
293    authored_post_config: crate::components::PostProcessConfig,
294    // Display-output / upscaling preferences (the Display settings rows). Resolved
295    // at init from the world's `PostProcessConfig` overridden by any persisted
296    // choice, passed to the backend ctor, and held here so the rows display +
297    // cycle them. Restart-required (swapchain format / render targets are sized
298    // once at init), so a runtime change only persists + relabels; independent of
299    // the quality preset.
300    temporal_upscaling: bool,
301    hdr_display: bool,
302    hdr_pq: bool,
303    // The world's authored shadow knobs before the user overrides + preset ceiling
304    // (defaulted when the world declares no GraphicsConfig). The pristine baseline
305    // a live preset change re-clamps from, like `authored_post_config`. The live
306    // values are `shadow_map_size` / `shadow_update` above.
307    authored_shadow_map_size: u32,
308    authored_shadow_update: crate::components::ShadowUpdate,
309    // The world's authored shadow distance, the baseline a live preset change
310    // re-clamps from. The live value is `shadow_distance` above.
311    authored_shadow_distance: u32,
312    // The world's authored shadow cascade count, the baseline a live preset
313    // change re-clamps from. The live value is `shadow_cascades` above.
314    authored_shadow_cascades: u32,
315    // The world's authored anisotropy degree before the user override + preset
316    // ceiling, the baseline a live preset change re-clamps from (like
317    // `authored_shadow_map_size`). The live value is `anisotropy` above.
318    authored_anisotropy: u32,
319    // System / streaming restart preferences (the Advanced "Frame Buffering",
320    // "Occlusion Culling", and "Texture Quality" rows). Resolved at init from the
321    // world's config overridden by any persisted choice, passed to the backend
322    // ctor / streamer, and held here so the rows display + cycle them. Restart-
323    // required, independent of the quality preset. `frames_in_flight` lives above.
324    occlusion_two_pass: bool,
325    texture_cap: u32,
326    texture_budget: u32,
327    // Reused scratch + change-tracking for the per-frame transform propagation
328    // (`transform_propagation::propagate_transforms_cached`): buffers are refilled in place
329    // and the pass is skipped on frames where no Transform / Parent changed.
330    transform_cache: crate::gfx::transform_propagation::TransformCache,
331    // The sky angle the directional-light set was last carried at. `None` until
332    // the first frame, so a world whose sky never turns carries it exactly once.
333    pushed_sky_angle: Option<f32>,
334    // Last-pushed model matrix per draw slot / skinned instance: a static
335    // slot costs a compare instead of a snapshot entry, and each family
336    // crosses the backend trait once per frame.
337    model_push: model_push::ModelPushCache,
338    skinned_model_push: model_push::ModelPushCache,
339    // The owned per-frame draw inputs `extract` fills from world state and
340    // `submit` replays onto the backend. Held here so its buffers keep their
341    // capacity across frames; taken out of `self` for the duration of one
342    // step.
343    snapshot: crate::gfx::snapshot::RenderSnapshot,
344    // Logical viewport size the line builder maps ribbon widths with. Seeded
345    // from the backend at init, refreshed each frame from `FrameInput`.
346    viewport: (f32, f32),
347    // Test-only injection seam: pre-resolved settings, a fabricated GPU
348    // profile, and a mock backend factory, so unit tests can drive
349    // run_init / run_step without a GPU device or the on-disk settings store.
350    #[cfg(test)]
351    pub(crate) test_hooks: Option<crate::gfx::mock_backend::TestHooks>,
352}
353
354// One key-rebind row's runtime bookkeeping: the action it rebinds and the value
355// `TextLabel` showing its bound key. Built at init (`init_rebind_rows`) from the
356// row's `setting:key_*:rebind` HitRegion (`action` -> `Bindable`, `label`) and
357// handed to SettingsState, which drives the live rebind drain.
358pub(crate) struct RebindViz {
359    pub(crate) action: crate::gfx::keymap::Bindable,
360    pub(crate) value_id: AssetId,
361}
362
363// One gamepad-rebind row's runtime bookkeeping, mirroring `RebindViz`: built at
364// init (`init_pad_rebind_rows`) from the row's `setting:pad_*:rebind` HitRegion
365// and handed to SettingsState for the button-rebind drain.
366pub(crate) struct PadRebindViz {
367    pub(crate) action: crate::components::GamepadAction,
368    pub(crate) value_id: AssetId,
369}
370
371// One slider row's runtime bookkeeping: the engine setting it controls, the
372// track geometry it maps a fraction onto, and the handle Sprite + value
373// TextLabel it drives. Built at init (`init_sliders`) from the row's
374// `setting:<key>:drag` HitRegion (track `x`/`width`, `label`, `drag_handle`) and
375// the handle Sprite's width, then handed to SettingsState for the slider drain.
376pub(crate) struct SliderViz {
377    pub(crate) key: String,
378    pub(crate) track_x: f32,
379    pub(crate) track_w: f32,
380    pub(crate) handle_w: f32,
381    pub(crate) handle_id: AssetId,
382    pub(crate) value_id: AssetId,
383}
384
385/// Init-time asset-resolution tables consulted by the world.jsonl hot-reload
386/// pass when applying adds and non-transform edits. Captured at init and
387/// never mutated afterwards: the reload path cannot introduce new
388/// Materials / Textures / Meshes / Models on the fly (those need a process
389/// restart), but every authored Prop that points at an asset already in the
390/// init world resolves through these maps without re-running build.
391/// Built by init, read only by the `cn debug` binary's world.jsonl reload pass,
392/// so its fields read as dead under `cargo check --lib`.
393pub struct WorldReloadState {
394    /// Texture asset name -> live pool slot, so runtime decal / emitter spawn
395    /// (`cn debug`) can resolve an authored Texture name to its slot.
396    pub texture_name_to_slot: std::collections::HashMap<AssetId, usize>,
397}
398
399/// Disjoint mutable screen of the `GraphicsSystem` fields the hot-reload passes
400/// edit in one tick: the active backend, the texture-name map for runtime
401/// decal / emitter spawn, and the fog bookkeeping the world.jsonl reload pass
402/// dedupes against. Returned by [`GraphicsSystem::hot_reload_apply_parts`] so the
403/// binary-only `DebugHook::tick` drive can apply the reload passes from outside
404/// the per-system step without the library depending on it. The reload
405/// catalogue and in-flight state live on the debug side
406/// (`crate::debug::hot_reload`), built from
407/// [`HotReloadSources`](crate::gfx::graphics_system::hot_reload_sources::HotReloadSources).
408/// The library never constructs this; the fields are read from the debug
409/// drive alone.
410pub struct HotReloadApplyParts<'a> {
411    /// The live render backend.
412    pub backend: &'a mut dyn RenderBackend,
413    /// The in-flight world reload, when one is running.
414    pub world_reload: &'a Option<WorldReloadState>,
415    /// The fog settings last pushed, so a reload can detect a change.
416    pub last_fog_settings: &'a mut Option<crate::gfx::volumetric_fog::FogSettings>,
417}
418
419impl std::fmt::Debug for GraphicsSystem {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        f.debug_struct("GraphicsSystem")
422            .field("frame_count", &self.frame_count)
423            .field("failed", &self.failed)
424            .finish()
425    }
426}
427
428impl GraphicsSystem {
429    /// Fresh renderer driver with no backend yet, reading and writing under
430    /// `tree`. Config (frames-in-flight, clear color, `max_frames`, shadow-map
431    /// size) is read from the world's `GraphicsConfig` in [`System::init`].
432    pub fn new(tree: Option<&StateTree>) -> Self {
433        // The schema's own defaults, so a world with no GraphicsConfig sees the
434        // same values as one that declares an all-default component.
435        let gfx = crate::components::GraphicsConfig::default();
436        Self {
437            state: tree.cloned(),
438            window_args: Default::default(),
439            clear_color: gfx.clear_color,
440            frames_in_flight: gfx.frames_in_flight as usize,
441            vsync: gfx.vsync,
442            fps_cap: gfx.fps_cap,
443            display_modes: Vec::new(),
444            resolution: None,
445            current_mode: None,
446            resolution_row_labels: Vec::new(),
447            perf_stats: true,
448            show_fps: true,
449            show_vram: true,
450            perf_sub_row_labels: Vec::new(),
451            max_frames: gfx.max_frames,
452            shadow_map_size: gfx.shadow_map_size,
453            shadow_update: gfx.shadow_update,
454            shadow_distance: gfx.shadow_distance,
455            shadow_cascades: gfx.shadow_cascades,
456            anisotropy: gfx.anisotropy,
457            failed: false,
458            start_time: None,
459            frame_count: 0,
460            frame_policy: frame_policy::FramePolicy::default(),
461            menu_mode: false,
462            render_scale: crate::components::UpscaleQuality::default(),
463            upscale_backend: crate::components::UpscalerBackend::default(),
464            backend: None,
465            scene_flow: None,
466            scene_visibility: Default::default(),
467            loaded_fonts: text::FontSet::default(),
468            sprite_texture_slots: crate::gfx::overlay_maps::TextureSlots::new(),
469            debug_hud_chips: Vec::new(),
470            stat_hud_chips: Vec::new(),
471            pick_candidates: Vec::new(),
472            texture_streamer: None,
473            mesh_streamer: None,
474            mesh_stream_draw_indices: Vec::new(),
475            chunk_stream: None,
476            shader_warmup: None,
477            deferred_shader_scenes: Vec::new(),
478            pending_hot_reload_sources: None,
479            world_reload: None,
480            persisted_graphics: crate::config::GraphicsSettings::default(),
481            fog_built: false,
482            last_fog_settings: None,
483            post_process: crate::gfx::render_types::PostProcessTunables::DEFAULT,
484            // Matches PostProcessConfig's ambient_intensity default; overwritten
485            // at init from the world / persisted store.
486            ambient_intensity: 1.0,
487            // Default until init resolves the world's config + persisted toggles.
488            post_config: crate::components::PostProcessConfig::default(),
489            sliders: Vec::new(),
490            cycle_value_labels: std::collections::HashMap::new(),
491            clip_rects: crate::gfx::overlay_maps::ClipRects::new(),
492            keymap: crate::gfx::keymap::KeyMap::default(),
493            rebind_rows: Vec::new(),
494            gamepad_map: crate::components::GamepadMap::default(),
495            pad_rebind_rows: Vec::new(),
496            // All-capable until the backend reports otherwise at init.
497            caps: crate::gfx::backend::DeviceCapabilities::ALL,
498            // Conservative until probed at init.
499            gpu_profile: crate::gfx::backend::GpuProfile::UNKNOWN,
500            // Seeded at init from the persisted preset (Auto on first launch).
501            quality_preset: crate::gfx::quality_preset::QualityPreset::Auto,
502            // Defaulted until init captures the world's authored config.
503            authored_post_config: crate::components::PostProcessConfig::default(),
504            // Resolved at init from the world's config + persisted overrides.
505            temporal_upscaling: false,
506            hdr_display: false,
507            hdr_pq: false,
508            authored_shadow_map_size: gfx.shadow_map_size,
509            authored_shadow_update: gfx.shadow_update,
510            authored_shadow_distance: gfx.shadow_distance,
511            authored_shadow_cascades: gfx.shadow_cascades,
512            authored_anisotropy: gfx.anisotropy,
513            occlusion_two_pass: crate::components::PostProcessConfig::default().occlusion_two_pass,
514            texture_cap: 96,
515            texture_budget: 4,
516            transform_cache: crate::gfx::transform_propagation::TransformCache::default(),
517            pushed_sky_angle: None,
518            model_push: model_push::ModelPushCache::default(),
519            skinned_model_push: model_push::ModelPushCache::default(),
520            snapshot: crate::gfx::snapshot::RenderSnapshot::default(),
521            viewport: (0.0, 0.0),
522            #[cfg(test)]
523            test_hooks: None,
524        }
525    }
526
527    // The persisted settings store consulted at init. Reads the on-disk file
528    // in production; a test-injected copy takes its place so unit tests never
529    // read (or depend on) the developer's real settings.
530    fn persisted_settings(&self) -> crate::config::Settings {
531        #[cfg(test)]
532        if let Some(hooks) = &self.test_hooks {
533            return hooks.settings.clone();
534        }
535        crate::config::Settings::load(self.state.as_ref())
536    }
537
538    // The `assets/` a bare source filename is searched under: the running
539    // world's own, or nothing for a world with no state tree (which leaves a
540    // bare filename unresolved rather than searched from the cwd).
541    pub(crate) fn assets_dir(&self) -> Option<std::path::PathBuf> {
542        self.state.as_ref().map(StateTree::assets_dir)
543    }
544
545    // Detect the GPU performance profile for quality auto-config. Probes the
546    // real device in production; a test-injected profile takes its place so
547    // unit tests never create a GPU handle.
548    fn detect_gpu_profile(&self) -> crate::gfx::backend::GpuProfile {
549        #[cfg(test)]
550        if let Some(hooks) = &self.test_hooks {
551            return hooks.gpu_profile;
552        }
553        crate::device::probe_gpu_profile()
554    }
555
556    // Seed and persist the first-launch `Auto` quality preset. Skipped under
557    // the test injection seam so tests never write the settings file.
558    fn seed_first_launch_preset(&self) {
559        #[cfg(test)]
560        if self.test_hooks.is_some() {
561            return;
562        }
563        let mut s = crate::config::Settings::load(self.state.as_ref());
564        s.graphics.quality_preset = Some(crate::gfx::quality_preset::QualityPreset::Auto);
565        if let Err(e) = s.save(self.state.as_ref()) {
566            tracing::warn!("first-launch quality preset save failed: {e}");
567        }
568    }
569
570    // The mode the Resolution row displays and cycles from: the user's choice,
571    // else the display's own mode, else the authored window size (a backend
572    // that cannot read the display; snaps to the nearest listed mode).
573    fn effective_resolution(&self) -> crate::gfx::display_mode::DisplayMode {
574        self.resolution
575            .or(self.current_mode)
576            .unwrap_or(crate::gfx::display_mode::DisplayMode {
577                width: self.window_args.width,
578                height: self.window_args.height,
579                refresh_hz: 0,
580            })
581    }
582}
583
584impl System for GraphicsSystem {
585    fn init(&mut self, ctx: &mut PipelineContext) {
586        self.run_init(ctx);
587    }
588
589    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
590        self.run_step(ctx)
591    }
592}
593
594impl GraphicsSystem {
595    /// Disjoint mutable screen of the backend + hot-reload bookkeeping the
596    /// binary-only `DebugHook::tick` reload drive applies changes through. The
597    /// caller supplies the backend (borrowed from the world's parked slot via
598    /// `World::systems_and_render_backend`) since this system yields it after
599    /// init. The library never calls this (the asset hot-reload drive lives in
600    /// the `cn debug` binary), so it reads as dead code under
601    /// `cargo check --lib`.
602    pub fn hot_reload_apply_parts<'a>(
603        &'a mut self,
604        backend: &'a mut dyn RenderBackend,
605    ) -> HotReloadApplyParts<'a> {
606        HotReloadApplyParts {
607            backend,
608            world_reload: &self.world_reload,
609            last_fog_settings: &mut self.last_fog_settings,
610        }
611    }
612
613    /// Take the init-captured hot-reload source catalogues, leaving `None`
614    /// behind. The `cn debug` drive calls this once on its first tick to build
615    /// the filesystem watcher + `AssetHotReloadState`. `None` under `cn run`,
616    /// or when no file-backed asset / world.jsonl was declared.
617    pub fn take_hot_reload_sources(&mut self) -> Option<hot_reload_sources::HotReloadSources> {
618        self.pending_hot_reload_sources.take()
619    }
620
621    // Stand up the albedo-texture streaming subsystem when a StreamingConfig
622    // was declared. Every streamable slot is evicted to a placeholder now; the
623    // streamer brings them back resident over the next frames, nearest first.
624    //
625    // The payload source depends on where the world came from: a disk-backed
626    // `cn run` world re-reads each payload from its blob file (no RAM copy), an
627    // in-memory `cn debug` world keeps the payloads RAM-resident.
628}
629
630// Quality-toggle plumbing shared by init (value-label sync + initial overlay)
631// and the per-frame drain. Centralising the key -> `PostProcessConfig` field
632// mapping here keeps the three call sites (read state, flip state, derive the
633// backend settings) from drifting apart.
634
635// The current on/off state of quality toggle `key` in `cfg`, or `None` for a
636// key that is not a quality toggle.
637pub(crate) fn quality_toggle_on(
638    cfg: &crate::components::PostProcessConfig,
639    key: &str,
640) -> Option<bool> {
641    match key {
642        "ssao" => Some(cfg.ssao),
643        "ssr" => Some(cfg.ssr),
644        "ray_traced_reflections" => Some(cfg.ray_traced_reflections),
645        "ssgi" => Some(cfg.indirect_lighting == crate::components::IndirectLighting::Ssgi),
646        "auto_exposure" => Some(cfg.auto_exposure),
647        _ => None,
648    }
649}
650
651// Flip quality toggle `key` to `on` in `cfg`. Unknown keys are ignored.
652pub(crate) fn set_quality_toggle(
653    cfg: &mut crate::components::PostProcessConfig,
654    key: &str,
655    on: bool,
656) {
657    match key {
658        "ssao" => cfg.ssao = on,
659        "ssr" => cfg.ssr = on,
660        "ray_traced_reflections" => cfg.ray_traced_reflections = on,
661        "ssgi" => {
662            cfg.indirect_lighting = if on {
663                crate::components::IndirectLighting::Ssgi
664            } else {
665                crate::components::IndirectLighting::Ibl
666            }
667        }
668        "auto_exposure" => cfg.auto_exposure = on,
669        _ => {}
670    }
671}
672
673// Whether `key` is one of the cycle (dropdown) quality knobs governed by the
674// preset ceiling like the boolean toggles (a manual change flips the preset to
675// Custom). The set lives in `settings::QUALITY_CYCLE_KEYS`.
676pub(crate) fn is_quality_cycle(key: &str) -> bool {
677    crate::gfx::settings::QUALITY_CYCLE_KEYS.contains(&key)
678}
679
680// The current menu option index of cycle quality knob `key` in `cfg`, or `None`
681// for a key that is not a cycle quality knob.
682pub(crate) fn quality_cycle_index(
683    cfg: &crate::components::PostProcessConfig,
684    key: &str,
685) -> Option<usize> {
686    use crate::gfx::settings;
687    match key {
688        "aa_mode" => Some(settings::aa_mode_index(cfg.aa_mode)),
689        "ssgi_resolution" => Some(settings::ssgi_resolution_index(cfg.ssgi_resolution)),
690        "ssgi_rays" => Some(settings::ssgi_rays_index(cfg.ssgi_rays)),
691        "ssgi_steps" => Some(settings::ssgi_steps_index(cfg.ssgi_steps)),
692        "reflection_blur_resolution" => Some(settings::reflection_blur_index(
693            cfg.reflection_blur_resolution,
694        )),
695        _ => None,
696    }
697}
698
699// Set cycle quality knob `key` in `cfg` from a menu option index. Unknown keys
700// are ignored.
701pub(crate) fn set_quality_cycle(
702    cfg: &mut crate::components::PostProcessConfig,
703    key: &str,
704    index: usize,
705) {
706    use crate::gfx::settings;
707    match key {
708        "aa_mode" => cfg.aa_mode = settings::aa_mode_at(index),
709        "ssgi_resolution" => cfg.ssgi_resolution = settings::ssgi_resolution_at(index),
710        "ssgi_rays" => cfg.ssgi_rays = settings::ssgi_rays_at(index),
711        "ssgi_steps" => cfg.ssgi_steps = settings::ssgi_steps_at(index),
712        "reflection_blur_resolution" => {
713            cfg.reflection_blur_resolution = settings::reflection_blur_at(index)
714        }
715        _ => {}
716    }
717}
718
719// Clamp cycle quality knob `key` in `cfg` DOWN under the ceiling (coarser
720// resolution / smaller count; never raises), a no-op when the user explicitly
721// overrode it. Shared by the init clamp and the live preset re-derive so both
722// produce the same result.
723pub(crate) fn clamp_quality_cycle(
724    cfg: &mut crate::components::PostProcessConfig,
725    key: &str,
726    ceiling: &crate::gfx::quality_preset::QualityCeiling,
727    overridden: bool,
728) {
729    if overridden {
730        return;
731    }
732    use crate::gfx::quality_preset::{
733        clamp_aa_mode, coarser_reflection_blur, coarser_ssgi_resolution,
734    };
735    match key {
736        "aa_mode" => cfg.aa_mode = clamp_aa_mode(cfg.aa_mode, ceiling.aa_mode),
737        "ssgi_resolution" => {
738            cfg.ssgi_resolution =
739                coarser_ssgi_resolution(cfg.ssgi_resolution, ceiling.ssgi_resolution)
740        }
741        "ssgi_rays" => cfg.ssgi_rays = cfg.ssgi_rays.min(ceiling.ssgi_rays),
742        "ssgi_steps" => cfg.ssgi_steps = cfg.ssgi_steps.min(ceiling.ssgi_steps),
743        "reflection_blur_resolution" => {
744            cfg.reflection_blur_resolution = coarser_reflection_blur(
745                cfg.reflection_blur_resolution,
746                ceiling.reflection_blur_resolution,
747            )
748        }
749        _ => {}
750    }
751}
752
753// Derive the backend's per-feature `QualitySettings` from a resolved config.
754// Mirrors the init-time derivation (the same `*_settings()` methods), so a
755// live rebuild reproduces exactly what a launch with this config would build.
756pub(crate) fn derive_quality_settings(
757    cfg: &crate::components::PostProcessConfig,
758) -> crate::gfx::backend::QualitySettings {
759    crate::gfx::backend::QualitySettings {
760        taa: cfg.aa_mode.taa_enabled(),
761        ssao: cfg.ssao_settings(),
762        ssr: cfg.ssr_settings(),
763        rt_reflections: cfg.rt_reflection_settings(),
764        ssgi: cfg.ssgi_settings(),
765        reflection_blur_scale: cfg.reflection_blur_divisor(),
766        auto_exposure: cfg.auto_exposure_settings(),
767        auto_exposure_bias_ev: cfg.exposure_ev,
768    }
769}
770
771pub(crate) mod character_shape;
772mod frame;
773pub(crate) mod frame_policy;
774mod helpers;
775pub mod hot_reload_sources;
776mod init;
777mod lines;
778mod model_push;
779pub(crate) mod scene;
780mod streaming;
781pub(crate) mod submit;
782#[cfg(test)]
783mod tests;