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