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;