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 // Last-pushed model matrix per draw slot / skinned instance: a static
332 // slot costs a compare instead of a snapshot entry, and each family
333 // crosses the backend trait once per frame.
334 model_push: model_push::ModelPushCache,
335 skinned_model_push: model_push::ModelPushCache,
336 // The owned per-frame draw inputs `extract` fills from world state and
337 // `submit` replays onto the backend. Held here so its buffers keep their
338 // capacity across frames; taken out of `self` for the duration of one
339 // step.
340 snapshot: crate::gfx::snapshot::RenderSnapshot,
341 // Logical viewport size the line builder maps ribbon widths with. Seeded
342 // from the backend at init, refreshed each frame from `FrameInput`.
343 viewport: (f32, f32),
344 // Test-only injection seam: pre-resolved settings, a fabricated GPU
345 // profile, and a mock backend factory, so unit tests can drive
346 // run_init / run_step without a GPU device or the on-disk settings store.
347 #[cfg(test)]
348 pub(crate) test_hooks: Option<crate::gfx::mock_backend::TestHooks>,
349}
350
351// One key-rebind row's runtime bookkeeping: the action it rebinds and the value
352// `TextLabel` showing its bound key. Built at init (`init_rebind_rows`) from the
353// row's `setting:key_*:rebind` HitRegion (`action` -> `Bindable`, `label`) and
354// handed to SettingsState, which drives the live rebind drain.
355pub(crate) struct RebindViz {
356 pub(crate) action: crate::gfx::keymap::Bindable,
357 pub(crate) value_id: AssetId,
358}
359
360// One gamepad-rebind row's runtime bookkeeping, mirroring `RebindViz`: built at
361// init (`init_pad_rebind_rows`) from the row's `setting:pad_*:rebind` HitRegion
362// and handed to SettingsState for the button-rebind drain.
363pub(crate) struct PadRebindViz {
364 pub(crate) action: crate::components::GamepadAction,
365 pub(crate) value_id: AssetId,
366}
367
368// One slider row's runtime bookkeeping: the engine setting it controls, the
369// track geometry it maps a fraction onto, and the handle Sprite + value
370// TextLabel it drives. Built at init (`init_sliders`) from the row's
371// `setting:<key>:drag` HitRegion (track `x`/`width`, `label`, `drag_handle`) and
372// the handle Sprite's width, then handed to SettingsState for the slider drain.
373pub(crate) struct SliderViz {
374 pub(crate) key: String,
375 pub(crate) track_x: f32,
376 pub(crate) track_w: f32,
377 pub(crate) handle_w: f32,
378 pub(crate) handle_id: AssetId,
379 pub(crate) value_id: AssetId,
380}
381
382/// Init-time asset-resolution tables consulted by the world.jsonl hot-reload
383/// pass when applying adds and non-transform edits. Captured at init and
384/// never mutated afterwards: the reload path cannot introduce new
385/// Materials / Textures / Meshes / Models on the fly (those need a process
386/// restart), but every authored Prop that points at an asset already in the
387/// init world resolves through these maps without re-running build.
388/// Built by init, read only by the `cn debug` binary's world.jsonl reload pass,
389/// so its fields read as dead under `cargo check --lib`.
390pub struct WorldReloadState {
391 /// Texture asset name -> live pool slot, so runtime decal / emitter spawn
392 /// (`cn debug`) can resolve an authored Texture name to its slot.
393 pub texture_name_to_slot: std::collections::HashMap<AssetId, usize>,
394}
395
396/// Disjoint mutable screen of the `GraphicsSystem` fields the hot-reload passes
397/// edit in one tick: the active backend, the texture-name map for runtime
398/// decal / emitter spawn, and the fog bookkeeping the world.jsonl reload pass
399/// dedupes against. Returned by [`GraphicsSystem::hot_reload_apply_parts`] so the
400/// binary-only `DebugHook::tick` drive can apply the reload passes from outside
401/// the per-system step without the library depending on it. The reload
402/// catalogue and in-flight state live on the debug side
403/// (`crate::debug::hot_reload`), built from
404/// [`HotReloadSources`](crate::gfx::graphics_system::hot_reload_sources::HotReloadSources).
405/// The library never constructs this; the fields are read from the debug
406/// drive alone.
407pub struct HotReloadApplyParts<'a> {
408 /// The live render backend.
409 pub backend: &'a mut dyn RenderBackend,
410 /// The in-flight world reload, when one is running.
411 pub world_reload: &'a Option<WorldReloadState>,
412 /// The fog settings last pushed, so a reload can detect a change.
413 pub last_fog_settings: &'a mut Option<crate::gfx::volumetric_fog::FogSettings>,
414}
415
416impl std::fmt::Debug for GraphicsSystem {
417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 f.debug_struct("GraphicsSystem")
419 .field("frame_count", &self.frame_count)
420 .field("failed", &self.failed)
421 .finish()
422 }
423}
424
425impl GraphicsSystem {
426 /// Fresh renderer driver with no backend yet, reading and writing under
427 /// `tree`. Config (frames-in-flight, clear color, `max_frames`, shadow-map
428 /// size) is read from the world's `GraphicsConfig` in [`System::init`].
429 pub fn new(tree: Option<&StateTree>) -> Self {
430 // The schema's own defaults, so a world with no GraphicsConfig sees the
431 // same values as one that declares an all-default component.
432 let gfx = crate::components::GraphicsConfig::default();
433 Self {
434 state: tree.cloned(),
435 window_args: Default::default(),
436 clear_color: gfx.clear_color,
437 frames_in_flight: gfx.frames_in_flight as usize,
438 vsync: gfx.vsync,
439 fps_cap: gfx.fps_cap,
440 display_modes: Vec::new(),
441 resolution: None,
442 current_mode: None,
443 resolution_row_labels: Vec::new(),
444 perf_stats: true,
445 show_fps: true,
446 show_vram: true,
447 perf_sub_row_labels: Vec::new(),
448 max_frames: gfx.max_frames,
449 shadow_map_size: gfx.shadow_map_size,
450 shadow_update: gfx.shadow_update,
451 shadow_distance: gfx.shadow_distance,
452 shadow_cascades: gfx.shadow_cascades,
453 anisotropy: gfx.anisotropy,
454 failed: false,
455 start_time: None,
456 frame_count: 0,
457 frame_policy: frame_policy::FramePolicy::default(),
458 menu_mode: false,
459 render_scale: crate::components::UpscaleQuality::default(),
460 upscale_backend: crate::components::UpscalerBackend::default(),
461 backend: None,
462 scene_flow: None,
463 scene_visibility: Default::default(),
464 loaded_fonts: text::FontSet::default(),
465 sprite_texture_slots: crate::gfx::overlay_maps::TextureSlots::new(),
466 debug_hud_chips: Vec::new(),
467 stat_hud_chips: Vec::new(),
468 pick_candidates: Vec::new(),
469 texture_streamer: None,
470 mesh_streamer: None,
471 mesh_stream_draw_indices: Vec::new(),
472 chunk_stream: None,
473 shader_warmup: None,
474 deferred_shader_scenes: Vec::new(),
475 pending_hot_reload_sources: None,
476 world_reload: None,
477 persisted_graphics: crate::config::GraphicsSettings::default(),
478 fog_built: false,
479 last_fog_settings: None,
480 post_process: crate::gfx::render_types::PostProcessTunables::DEFAULT,
481 // Matches PostProcessConfig's ambient_intensity default; overwritten
482 // at init from the world / persisted store.
483 ambient_intensity: 1.0,
484 // Default until init resolves the world's config + persisted toggles.
485 post_config: crate::components::PostProcessConfig::default(),
486 sliders: Vec::new(),
487 cycle_value_labels: std::collections::HashMap::new(),
488 clip_rects: crate::gfx::overlay_maps::ClipRects::new(),
489 keymap: crate::gfx::keymap::KeyMap::default(),
490 rebind_rows: Vec::new(),
491 gamepad_map: crate::components::GamepadMap::default(),
492 pad_rebind_rows: Vec::new(),
493 // All-capable until the backend reports otherwise at init.
494 caps: crate::gfx::backend::DeviceCapabilities::ALL,
495 // Conservative until probed at init.
496 gpu_profile: crate::gfx::backend::GpuProfile::UNKNOWN,
497 // Seeded at init from the persisted preset (Auto on first launch).
498 quality_preset: crate::gfx::quality_preset::QualityPreset::Auto,
499 // Defaulted until init captures the world's authored config.
500 authored_post_config: crate::components::PostProcessConfig::default(),
501 // Resolved at init from the world's config + persisted overrides.
502 temporal_upscaling: false,
503 hdr_display: false,
504 hdr_pq: false,
505 authored_shadow_map_size: gfx.shadow_map_size,
506 authored_shadow_update: gfx.shadow_update,
507 authored_shadow_distance: gfx.shadow_distance,
508 authored_shadow_cascades: gfx.shadow_cascades,
509 authored_anisotropy: gfx.anisotropy,
510 occlusion_two_pass: crate::components::PostProcessConfig::default().occlusion_two_pass,
511 texture_cap: 96,
512 texture_budget: 4,
513 transform_cache: crate::gfx::transform_propagation::TransformCache::default(),
514 model_push: model_push::ModelPushCache::default(),
515 skinned_model_push: model_push::ModelPushCache::default(),
516 snapshot: crate::gfx::snapshot::RenderSnapshot::default(),
517 viewport: (0.0, 0.0),
518 #[cfg(test)]
519 test_hooks: None,
520 }
521 }
522
523 // The persisted settings store consulted at init. Reads the on-disk file
524 // in production; a test-injected copy takes its place so unit tests never
525 // read (or depend on) the developer's real settings.
526 fn persisted_settings(&self) -> crate::config::Settings {
527 #[cfg(test)]
528 if let Some(hooks) = &self.test_hooks {
529 return hooks.settings.clone();
530 }
531 crate::config::Settings::load(self.state.as_ref())
532 }
533
534 // The `assets/` a bare source filename is searched under: the running
535 // world's own, or nothing for a world with no state tree (which leaves a
536 // bare filename unresolved rather than searched from the cwd).
537 pub(crate) fn assets_dir(&self) -> Option<std::path::PathBuf> {
538 self.state.as_ref().map(StateTree::assets_dir)
539 }
540
541 // Detect the GPU performance profile for quality auto-config. Probes the
542 // real device in production; a test-injected profile takes its place so
543 // unit tests never create a GPU handle.
544 fn detect_gpu_profile(&self) -> crate::gfx::backend::GpuProfile {
545 #[cfg(test)]
546 if let Some(hooks) = &self.test_hooks {
547 return hooks.gpu_profile;
548 }
549 crate::device::probe_gpu_profile()
550 }
551
552 // Seed and persist the first-launch `Auto` quality preset. Skipped under
553 // the test injection seam so tests never write the settings file.
554 fn seed_first_launch_preset(&self) {
555 #[cfg(test)]
556 if self.test_hooks.is_some() {
557 return;
558 }
559 let mut s = crate::config::Settings::load(self.state.as_ref());
560 s.graphics.quality_preset = Some(crate::gfx::quality_preset::QualityPreset::Auto);
561 if let Err(e) = s.save(self.state.as_ref()) {
562 tracing::warn!("first-launch quality preset save failed: {e}");
563 }
564 }
565
566 // The mode the Resolution row displays and cycles from: the user's choice,
567 // else the display's own mode, else the authored window size (a backend
568 // that cannot read the display; snaps to the nearest listed mode).
569 fn effective_resolution(&self) -> crate::gfx::display_mode::DisplayMode {
570 self.resolution
571 .or(self.current_mode)
572 .unwrap_or(crate::gfx::display_mode::DisplayMode {
573 width: self.window_args.width,
574 height: self.window_args.height,
575 refresh_hz: 0,
576 })
577 }
578}
579
580impl System for GraphicsSystem {
581 fn init(&mut self, ctx: &mut PipelineContext) {
582 self.run_init(ctx);
583 }
584
585 fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
586 self.run_step(ctx)
587 }
588}
589
590impl GraphicsSystem {
591 /// Disjoint mutable screen of the backend + hot-reload bookkeeping the
592 /// binary-only `DebugHook::tick` reload drive applies changes through. The
593 /// caller supplies the backend (borrowed from the world's parked slot via
594 /// `World::systems_and_render_backend`) since this system yields it after
595 /// init. The library never calls this (the asset hot-reload drive lives in
596 /// the `cn debug` binary), so it reads as dead code under
597 /// `cargo check --lib`.
598 pub fn hot_reload_apply_parts<'a>(
599 &'a mut self,
600 backend: &'a mut dyn RenderBackend,
601 ) -> HotReloadApplyParts<'a> {
602 HotReloadApplyParts {
603 backend,
604 world_reload: &self.world_reload,
605 last_fog_settings: &mut self.last_fog_settings,
606 }
607 }
608
609 /// Take the init-captured hot-reload source catalogues, leaving `None`
610 /// behind. The `cn debug` drive calls this once on its first tick to build
611 /// the filesystem watcher + `AssetHotReloadState`. `None` under `cn run`,
612 /// or when no file-backed asset / world.jsonl was declared.
613 pub fn take_hot_reload_sources(&mut self) -> Option<hot_reload_sources::HotReloadSources> {
614 self.pending_hot_reload_sources.take()
615 }
616
617 // Stand up the albedo-texture streaming subsystem when a StreamingConfig
618 // was declared. Every streamable slot is evicted to a placeholder now; the
619 // streamer brings them back resident over the next frames, nearest first.
620 //
621 // The payload source depends on where the world came from: a disk-backed
622 // `cn run` world re-reads each payload from its blob file (no RAM copy), an
623 // in-memory `cn debug` world keeps the payloads RAM-resident.
624}
625
626// Quality-toggle plumbing shared by init (value-label sync + initial overlay)
627// and the per-frame drain. Centralising the key -> `PostProcessConfig` field
628// mapping here keeps the three call sites (read state, flip state, derive the
629// backend settings) from drifting apart.
630
631// The current on/off state of quality toggle `key` in `cfg`, or `None` for a
632// key that is not a quality toggle.
633pub(crate) fn quality_toggle_on(
634 cfg: &crate::components::PostProcessConfig,
635 key: &str,
636) -> Option<bool> {
637 match key {
638 "ssao" => Some(cfg.ssao),
639 "ssr" => Some(cfg.ssr),
640 "ray_traced_reflections" => Some(cfg.ray_traced_reflections),
641 "ssgi" => Some(cfg.indirect_lighting == crate::components::IndirectLighting::Ssgi),
642 "auto_exposure" => Some(cfg.auto_exposure),
643 _ => None,
644 }
645}
646
647// Flip quality toggle `key` to `on` in `cfg`. Unknown keys are ignored.
648pub(crate) fn set_quality_toggle(
649 cfg: &mut crate::components::PostProcessConfig,
650 key: &str,
651 on: bool,
652) {
653 match key {
654 "ssao" => cfg.ssao = on,
655 "ssr" => cfg.ssr = on,
656 "ray_traced_reflections" => cfg.ray_traced_reflections = on,
657 "ssgi" => {
658 cfg.indirect_lighting = if on {
659 crate::components::IndirectLighting::Ssgi
660 } else {
661 crate::components::IndirectLighting::Ibl
662 }
663 }
664 "auto_exposure" => cfg.auto_exposure = on,
665 _ => {}
666 }
667}
668
669// Whether `key` is one of the cycle (dropdown) quality knobs governed by the
670// preset ceiling like the boolean toggles (a manual change flips the preset to
671// Custom). The set lives in `settings::QUALITY_CYCLE_KEYS`.
672pub(crate) fn is_quality_cycle(key: &str) -> bool {
673 crate::gfx::settings::QUALITY_CYCLE_KEYS.contains(&key)
674}
675
676// The current menu option index of cycle quality knob `key` in `cfg`, or `None`
677// for a key that is not a cycle quality knob.
678pub(crate) fn quality_cycle_index(
679 cfg: &crate::components::PostProcessConfig,
680 key: &str,
681) -> Option<usize> {
682 use crate::gfx::settings;
683 match key {
684 "aa_mode" => Some(settings::aa_mode_index(cfg.aa_mode)),
685 "ssgi_resolution" => Some(settings::ssgi_resolution_index(cfg.ssgi_resolution)),
686 "ssgi_rays" => Some(settings::ssgi_rays_index(cfg.ssgi_rays)),
687 "ssgi_steps" => Some(settings::ssgi_steps_index(cfg.ssgi_steps)),
688 "reflection_blur_resolution" => Some(settings::reflection_blur_index(
689 cfg.reflection_blur_resolution,
690 )),
691 _ => None,
692 }
693}
694
695// Set cycle quality knob `key` in `cfg` from a menu option index. Unknown keys
696// are ignored.
697pub(crate) fn set_quality_cycle(
698 cfg: &mut crate::components::PostProcessConfig,
699 key: &str,
700 index: usize,
701) {
702 use crate::gfx::settings;
703 match key {
704 "aa_mode" => cfg.aa_mode = settings::aa_mode_at(index),
705 "ssgi_resolution" => cfg.ssgi_resolution = settings::ssgi_resolution_at(index),
706 "ssgi_rays" => cfg.ssgi_rays = settings::ssgi_rays_at(index),
707 "ssgi_steps" => cfg.ssgi_steps = settings::ssgi_steps_at(index),
708 "reflection_blur_resolution" => {
709 cfg.reflection_blur_resolution = settings::reflection_blur_at(index)
710 }
711 _ => {}
712 }
713}
714
715// Clamp cycle quality knob `key` in `cfg` DOWN under the ceiling (coarser
716// resolution / smaller count; never raises), a no-op when the user explicitly
717// overrode it. Shared by the init clamp and the live preset re-derive so both
718// produce the same result.
719pub(crate) fn clamp_quality_cycle(
720 cfg: &mut crate::components::PostProcessConfig,
721 key: &str,
722 ceiling: &crate::gfx::quality_preset::QualityCeiling,
723 overridden: bool,
724) {
725 if overridden {
726 return;
727 }
728 use crate::gfx::quality_preset::{
729 clamp_aa_mode, coarser_reflection_blur, coarser_ssgi_resolution,
730 };
731 match key {
732 "aa_mode" => cfg.aa_mode = clamp_aa_mode(cfg.aa_mode, ceiling.aa_mode),
733 "ssgi_resolution" => {
734 cfg.ssgi_resolution =
735 coarser_ssgi_resolution(cfg.ssgi_resolution, ceiling.ssgi_resolution)
736 }
737 "ssgi_rays" => cfg.ssgi_rays = cfg.ssgi_rays.min(ceiling.ssgi_rays),
738 "ssgi_steps" => cfg.ssgi_steps = cfg.ssgi_steps.min(ceiling.ssgi_steps),
739 "reflection_blur_resolution" => {
740 cfg.reflection_blur_resolution = coarser_reflection_blur(
741 cfg.reflection_blur_resolution,
742 ceiling.reflection_blur_resolution,
743 )
744 }
745 _ => {}
746 }
747}
748
749// Derive the backend's per-feature `QualitySettings` from a resolved config.
750// Mirrors the init-time derivation (the same `*_settings()` methods), so a
751// live rebuild reproduces exactly what a launch with this config would build.
752pub(crate) fn derive_quality_settings(
753 cfg: &crate::components::PostProcessConfig,
754) -> crate::gfx::backend::QualitySettings {
755 crate::gfx::backend::QualitySettings {
756 taa: cfg.aa_mode.taa_enabled(),
757 ssao: cfg.ssao_settings(),
758 ssr: cfg.ssr_settings(),
759 rt_reflections: cfg.rt_reflection_settings(),
760 ssgi: cfg.ssgi_settings(),
761 reflection_blur_scale: cfg.reflection_blur_divisor(),
762 auto_exposure: cfg.auto_exposure_settings(),
763 auto_exposure_bias_ev: cfg.exposure_ev,
764 }
765}
766
767pub(crate) mod character_shape;
768mod frame;
769pub(crate) mod frame_policy;
770mod helpers;
771pub mod hot_reload_sources;
772mod init;
773mod lines;
774mod model_push;
775pub(crate) mod scene;
776mod streaming;
777pub(crate) mod submit;
778#[cfg(test)]
779mod tests;