concinnity_render/backend_init.rs
1//! Grouped construction inputs for the render backends, plus the requirements
2//! derivation that trims scene-scoped features when a world has no 3D content.
3//! GraphicsSystem init assembles a `BackendInit` from the drained world assets,
4//! calls `resolve_requirements()`, and hands it to the backend constructor
5//! selected at compile time (Metal / DirectX / Vulkan). Every backend receives
6//! the same struct; each reads the fields its feature set consumes.
7
8use crate::auto_exposure::AutoExposureSettings;
9use crate::components::{
10 GlassPanel, SdfVolume, ShadowUpdate, UpscalerBackend, WaterSurface, Window,
11};
12use crate::decal::DecalRecord;
13use crate::mesh_payload::Vertex;
14use crate::particles::ParticleEmitterRecord;
15use crate::render_types::{
16 AreaLightData, DrawObject, GpuLight, InstancedCluster, LightUniforms, PostProcessTunables,
17 SpotShadowData,
18};
19use crate::rt_geom::RtDynamicMode;
20use crate::rt_reflections::RtReflectionSettings;
21use crate::ssao::SsaoSettings;
22use crate::ssgi::SsgiSettings;
23use crate::ssr::SsrSettings;
24use crate::volumetric_fog::FogSettings;
25use alloc::string::String;
26use alloc::vec;
27use alloc::vec::Vec;
28
29/// Static scene geometry and the draw lists built over it.
30pub struct SceneData<'a> {
31 /// The world's shared static vertex buffer.
32 pub vertices: &'a [Vertex],
33 /// The world's shared static index buffer.
34 pub indices: &'a [u32],
35 /// One draw record per static placement.
36 pub draw_objects: Vec<DrawObject>,
37 /// One record per instanced-prop cluster.
38 pub instanced_clusters: Vec<InstancedCluster>,
39 /// Skinned draw-object count (the world's `SkinnedMesh` count). Sizes each
40 /// backend's shared GPU-cull buffers for the merged total (static +
41 /// instances + skinned) at init; the skinned geometry itself is uploaded
42 /// later via `upload_skinned`.
43 pub n_skinned: usize,
44 /// Worst-case resident chunk count for a streaming VoxelWorld (0
45 /// otherwise). Reserves a chunk record region in the shared GPU-cull
46 /// buffers at init; resident chunks fold into the indirect path each
47 /// frame. Honoured by DirectX + Vulkan; Metal's per-frame rebuild already
48 /// covers chunks, so it needs no reserve.
49 pub n_chunk_max: usize,
50}
51
52/// Compiled shader payloads. Each backend loads the format its toolchain
53/// produced (metallib / DXBC / SPIR-V).
54#[derive(Clone, Copy)]
55pub struct ShaderBytes<'a> {
56 /// Compiled main-pass vertex shader.
57 pub vert: &'a [u8],
58 /// Compiled main-pass fragment shader.
59 pub frag: &'a [u8],
60 /// Compiled shadow-pass vertex shader; consumed by DirectX / Vulkan. Metal
61 /// compiles its shadow shader internally (shadow.metal) and ignores it.
62 pub shadow: &'a [u8],
63 /// Compiled GPU-instanced vertex shader; empty slice = no instanced
64 /// pipeline (any InstancedProp in the world will fail to render).
65 pub vert_instanced: &'a [u8],
66 /// This entry's payload was not decoded because a scene other than the start
67 /// scene owns it: the backend leaves the bucket's pipeline unbuilt and the
68 /// streaming pump installs it when that scene pins. Distinct from empty stage
69 /// bytes, which a backend whose built-in default ships no compiled payload
70 /// (Vulkan's inline GLSL) also sees for an engine-default program.
71 pub deferred: bool,
72}
73
74/// Decoded image payloads: texture pools, glyph atlases, and the serialised
75/// IBL / grading payloads (None = the backend binds identity fallbacks).
76pub struct MediaPayloads<'a> {
77 /// Decoded textures for the shared handle-indexed pool: one `TextureImage`
78 /// per slot carrying its GPU format and mip chain. Every texture -- albedo,
79 /// normal map, emissive/ORM, terrain secondary -- lives here once at its
80 /// handle; the backend appends a flat-normal fallback past the last entry for
81 /// normal-less draws. RGBA8 images regenerate mips on upload; block-
82 /// compressed images upload their chain verbatim.
83 pub textures: &'a [crate::build::texture::TextureImage],
84 /// Glyph atlas textures for text rendering; empty = no text support.
85 pub text_atlases: Vec<(u32, u32, Vec<u8>)>,
86 /// Serialised EnvironmentMap payload (irradiance + prefilter cubemaps).
87 /// None disables IBL; the runtime binds 1x1 grey fallback cubes.
88 pub env_map_bytes: Option<&'a [u8]>,
89 /// Serialised ColorLut payload (3D grading LUT). None = identity LUT.
90 pub color_lut_bytes: Option<&'a [u8]>,
91}
92
93/// Shadow-mapping knobs from GraphicsConfig. `map_size == 0` disables the
94/// shadow pipeline and cascade array entirely.
95#[derive(Copy, Clone, Debug)]
96pub struct ShadowParams {
97 /// Shadow map edge in texels; 0 disables shadows entirely.
98 pub map_size: u32,
99 /// Cascade re-render policy: hybrid amortizes far cascades across frames.
100 pub update: ShadowUpdate,
101 /// Shadow distance in world units, capped at the camera far plane by the
102 /// per-frame cascade split.
103 pub distance: u32,
104 /// Cascade count (1..=4) the per-frame split + schedule render.
105 pub cascades: u32,
106}
107
108/// Post-process and display settings resolved from PostProcessConfig (plus
109/// the user's persisted overrides, the quality-preset ceiling, and the
110/// launch's render requests). Every Option here is an init-time gate: None
111/// allocates nothing.
112pub struct PostSettings {
113 /// Composite tunables pushed to the post pass. The backend pairs them with
114 /// the display-output flags it negotiates below (`hdr_display` / `hdr_pq`)
115 /// to build the uniform the shaders read.
116 pub post_process: PostProcessTunables,
117 /// Whether the temporal anti-aliasing pass runs.
118 pub taa_enabled: bool,
119 /// Screen-space ambient occlusion, or `None` when off.
120 pub ssao: Option<SsaoSettings>,
121 /// Screen-space reflections, or `None` when off.
122 pub ssr: Option<SsrSettings>,
123 /// Screen-space global illumination, or `None` when off.
124 pub ssgi: Option<SsgiSettings>,
125 /// Requires an RT-capable GPU; backends fall back to SSR without one.
126 pub rt_reflections: Option<RtReflectionSettings>,
127 /// How the ray-tracing acceleration structure tracks moving props. Inert
128 /// when `rt_reflections` is None.
129 pub rt_dynamic: RtDynamicMode,
130 /// Whether skinned meshes join the ray-tracing acceleration structure.
131 /// False leaves the BVH over static + instanced geometry only, so nothing
132 /// animated appears in a ray-traced reflection.
133 pub rt_skinned_geometry: bool,
134 /// Per-axis divisor for the roughness-aware reflection blur target.
135 pub reflection_blur_scale: u32,
136 /// Auto-exposure, or `None` when off.
137 pub auto_exposure: Option<AutoExposureSettings>,
138 /// Authored exposure_ev carried as a bias on the adapted EV when
139 /// auto-exposure is on; otherwise baked into post_process.exposure.
140 pub auto_exposure_bias_ev: f32,
141 /// HDR display request; each backend gates it on its own EDR / colour-
142 /// space capability probe and falls back to SDR with a warning.
143 pub hdr_display: bool,
144 /// PQ-encoded HDR output; honoured by Metal today, accepted elsewhere.
145 pub hdr_pq: bool,
146 /// Whether temporal upscaling runs.
147 pub temporal_upscaling: bool,
148 /// Per-axis input-to-output ratio; ignored when upscaling is off.
149 pub upscale_scale: f32,
150 /// Upscaler selector for DirectX / Vulkan (FSR3 / DLSS / XeSS); Metal
151 /// always uses MetalFX and ignores it.
152 pub upscale_backend: UpscalerBackend,
153 /// Two-pass Hi-Z occlusion request; gated on the bindless cull path.
154 pub occlusion_two_pass: bool,
155}
156
157/// World-authored effect content drained from components. Empty / None means
158/// the backend builds no pipelines or pools for that feature.
159pub struct WorldFx {
160 /// Projected decals declared by the world.
161 pub decals: Vec<DecalRecord>,
162 /// Particle emitters declared by the world.
163 pub particles: Vec<ParticleEmitterRecord>,
164 /// Volumetric fog settings, or `None` when the world declares none.
165 pub fog: Option<FogSettings>,
166 /// Transparent water surfaces; rendered by Metal today, accepted by the
167 /// other backends for parity until their water ports land.
168 pub water_surfaces: Vec<WaterSurface>,
169 /// Refractive glass panels declared by the world.
170 pub glass_panels: Vec<GlassPanel>,
171 /// Raymarched SDF volumes as (volume, compiled fragment source bytes,
172 /// asset label for error messages).
173 pub sdf_volumes: Vec<(SdfVolume, Vec<u8>, String)>,
174}
175
176/// Everything a backend constructor needs, assembled once by GraphicsSystem
177/// init after the world's assets have been drained and settings resolved.
178pub struct BackendInit<'a> {
179 /// The window the backend opens.
180 pub window: &'a Window,
181 /// Debug-layer toggle for the DirectX / Vulkan validation layers.
182 pub validation: bool,
183 /// Frames the backend keeps in flight.
184 pub frames_in_flight: usize,
185 /// Whether presentation waits for vertical blank.
186 pub vsync: bool,
187 /// Linear RGBA the target is cleared to.
188 pub clear_color: [f32; 4],
189 /// True only under `cn debug`: disk-first shader resolution + watcher.
190 pub hot_reload: bool,
191 /// Keep the presented frame blit-readable so `screenshot` can capture it.
192 /// On under the dev loop, and armed by `cn run --screenshot`; production
193 /// otherwise pays nothing for it (Metal leaves the drawable
194 /// framebuffer-only and retains nothing).
195 pub capture: bool,
196 /// The world's static geometry and draw lists.
197 pub scene: SceneData<'a>,
198 /// One entry per world Shader, indexed by the dense ShaderHandle value a
199 /// DrawObject's `shader_bucket` carries; entry 0 is the world default
200 /// program. Never empty for a rendering world.
201 pub shaders: Vec<ShaderBytes<'a>>,
202 /// Compiled media payloads (textures, fonts, environment maps).
203 pub media: MediaPayloads<'a>,
204 /// The fixed directional / point light arrays.
205 pub light_uniforms: LightUniforms,
206 /// Every local light (point + spot + area) for the clustered forward pass,
207 /// uploaded to a per-scene GpuLight storage buffer. The first MAX_POINT_LIGHTS
208 /// point lights are also mirrored into `light_uniforms.point` for the
209 /// raymarch / fog / probe paths that still read the fixed array.
210 pub local_lights: Vec<GpuLight>,
211 /// One entry per spot shadow map slice, indexed by `GpuLight.shadow_index`.
212 /// Empty when no spot light casts shadows, in which case the backend skips
213 /// allocating the shadow array entirely.
214 pub spot_shadows: Vec<SpotShadowData>,
215 /// One entry per rectangular area light, indexed by `GpuLight.data_index`.
216 /// Empty when the world declares none.
217 pub area_lights: Vec<AreaLightData>,
218 /// Shadow-mapping settings.
219 pub shadows: ShadowParams,
220 /// Scene-sampler max anisotropy, clamped to the GPU's range at init.
221 pub anisotropy: u32,
222 /// Distinct planar-reflection plane budget from the quality preset / GPU
223 /// tier ceiling; reflectors past it fall back to the probe cube.
224 pub planar_planes: usize,
225 /// Post-process and display settings.
226 pub post: PostSettings,
227 /// World-authored effect content.
228 pub fx: WorldFx,
229 /// Derived by `resolve_requirements()`; the conservative default assumes a
230 /// full scene so a caller that skips resolution never under-allocates.
231 pub requirements: RenderRequirements,
232}
233
234/// The swapchain-level configuration a backend bakes into its window / surface
235/// at construction: the ring depth and the HDR-output request that together fix
236/// the drawable pixel format and frames-in-flight sizing. A live world swap
237/// (`RenderBackend::reload_world`) can only reuse the existing window when these
238/// are unchanged; a difference forces a full backend rebuild (a new window).
239/// Kept small + `Eq` so the swap decision is one comparison.
240#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
241pub struct SwapchainConfig {
242 /// Frames the backend keeps in flight.
243 pub frames_in_flight: usize,
244 /// Whether the swapchain requests an HDR pixel format.
245 pub hdr_display: bool,
246 /// Whether HDR output is PQ-encoded.
247 pub hdr_pq: bool,
248}
249
250/// What the world's content requires of the renderer. Derived from the
251/// assembled scene + fx data, backend-agnostic, so all three backends make
252/// identical trimming decisions.
253#[derive(Copy, Clone, Debug)]
254pub struct RenderRequirements {
255 /// True when any 3D scene content exists (meshes, instances, skinned
256 /// meshes, streamed chunks, water, glass, SDF volumes, particles, or
257 /// decals). False = the world renders UI / text only: the backend skips
258 /// the scene pipelines and the frame collapses to a clear + composite.
259 pub scene: bool,
260}
261
262impl Default for RenderRequirements {
263 fn default() -> Self {
264 RenderRequirements { scene: true }
265 }
266}
267
268impl RenderRequirements {
269 /// Derive the requirements a scene plus its effect content imposes.
270 pub fn derive(scene: &SceneData, fx: &WorldFx) -> Self {
271 let scene_present = !scene.vertices.is_empty()
272 || !scene.draw_objects.is_empty()
273 || !scene.instanced_clusters.is_empty()
274 || scene.n_skinned > 0
275 || scene.n_chunk_max > 0
276 || !fx.water_surfaces.is_empty()
277 || !fx.glass_panels.is_empty()
278 || !fx.sdf_volumes.is_empty()
279 || !fx.particles.is_empty()
280 || !fx.decals.is_empty();
281 RenderRequirements {
282 scene: scene_present,
283 }
284 }
285}
286
287impl<'a> BackendInit<'a> {
288 /// A backend carrying nothing but a window and glyph atlases: no geometry,
289 /// no textures, no lights, no effects. The single shader entry has empty
290 /// stage bytes, which every backend resolves to its built-in default
291 /// program. `resolve_requirements` then trims every scene-scoped feature.
292 ///
293 /// This is the startup error screen's path, which has to stand up a window
294 /// with no compiled world data at all. Keeping it here means the field
295 /// defaulting is maintained beside the struct it fills.
296 pub fn minimal(window: &'a Window, text_atlases: Vec<(u32, u32, Vec<u8>)>) -> Self {
297 let mut init = Self {
298 window,
299 validation: false,
300 frames_in_flight: 2,
301 vsync: true,
302 clear_color: [0.0, 0.0, 0.0, 1.0],
303 hot_reload: false,
304 capture: false,
305 scene: SceneData {
306 vertices: &[],
307 indices: &[],
308 draw_objects: Vec::new(),
309 instanced_clusters: Vec::new(),
310 n_skinned: 0,
311 n_chunk_max: 0,
312 },
313 shaders: vec![ShaderBytes {
314 vert: &[],
315 frag: &[],
316 shadow: &[],
317 vert_instanced: &[],
318 deferred: false,
319 }],
320 media: MediaPayloads {
321 textures: &[],
322 text_atlases,
323 env_map_bytes: None,
324 color_lut_bytes: None,
325 },
326 light_uniforms: LightUniforms::DEFAULT,
327 local_lights: Vec::new(),
328 spot_shadows: Vec::new(),
329 area_lights: Vec::new(),
330 shadows: ShadowParams {
331 map_size: 0,
332 update: ShadowUpdate::default(),
333 distance: 0,
334 cascades: 1,
335 },
336 anisotropy: 1,
337 planar_planes: 0,
338 post: PostSettings {
339 post_process: PostProcessTunables::DEFAULT,
340 taa_enabled: false,
341 ssao: None,
342 ssr: None,
343 ssgi: None,
344 rt_reflections: None,
345 rt_dynamic: RtDynamicMode::Auto,
346 rt_skinned_geometry: true,
347 reflection_blur_scale: 1,
348 auto_exposure: None,
349 auto_exposure_bias_ev: 0.0,
350 hdr_display: false,
351 hdr_pq: false,
352 temporal_upscaling: false,
353 upscale_scale: 1.0,
354 upscale_backend: UpscalerBackend::Auto,
355 occlusion_two_pass: false,
356 },
357 fx: WorldFx {
358 decals: Vec::new(),
359 particles: Vec::new(),
360 fog: None,
361 water_surfaces: Vec::new(),
362 glass_panels: Vec::new(),
363 sdf_volumes: Vec::new(),
364 },
365 requirements: Default::default(),
366 };
367 init.resolve_requirements();
368 init
369 }
370
371 /// The swapchain-level configuration this world needs. Compared against a
372 /// transplanted backend's `RenderBackend::hot_swap_config` to decide whether
373 /// a live SAVE can reuse the existing window (`reload_world`) or must rebuild.
374 pub fn swapchain_config(&self) -> SwapchainConfig {
375 SwapchainConfig {
376 // Normalise to at least 1 to match how the backends size their ring
377 // buffers (e.g. Metal stores `frames_in_flight.max(1)`), so an
378 // out-of-range authored 0 does not read as a swapchain change vs a
379 // backend that already clamped it, spuriously forcing a full rebuild.
380 frames_in_flight: self.frames_in_flight.max(1),
381 hdr_display: self.post.hdr_display,
382 hdr_pq: self.post.hdr_pq,
383 }
384 }
385
386 /// Derive the requirements from the assembled content and trim
387 /// scene-scoped features accordingly. Runtime spawning can only clone
388 /// assets already declared in the world, so the derivation here is
389 /// complete: a world with no scene content at init can never grow one.
390 pub fn resolve_requirements(&mut self) {
391 let req = RenderRequirements::derive(&self.scene, &self.fx);
392 if !req.scene {
393 trim_scene_features(
394 &mut self.shadows,
395 &mut self.post,
396 &mut self.fx,
397 &mut self.planar_planes,
398 );
399 tracing::info!(
400 "render requirements: no 3D scene content; scene-scoped features disabled"
401 );
402 }
403 self.requirements = req;
404 }
405}
406
407// Force off every feature that only decorates a 3D scene. All of these are
408// existing init-time gates in the backends, so zeroing them here means every
409// backend skips the matching resources with no backend-side changes.
410fn trim_scene_features(
411 shadows: &mut ShadowParams,
412 post: &mut PostSettings,
413 fx: &mut WorldFx,
414 planar_planes: &mut usize,
415) {
416 shadows.map_size = 0;
417 post.taa_enabled = false;
418 post.ssao = None;
419 post.ssr = None;
420 post.ssgi = None;
421 post.rt_reflections = None;
422 post.auto_exposure = None;
423 post.temporal_upscaling = false;
424 post.occlusion_two_pass = false;
425 fx.fog = None;
426 *planar_planes = 0;
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn empty_scene() -> SceneData<'static> {
434 SceneData {
435 vertices: &[],
436 indices: &[],
437 draw_objects: Vec::new(),
438 instanced_clusters: Vec::new(),
439 n_skinned: 0,
440 n_chunk_max: 0,
441 }
442 }
443
444 fn empty_fx() -> WorldFx {
445 WorldFx {
446 decals: Vec::new(),
447 particles: Vec::new(),
448 fog: None,
449 water_surfaces: Vec::new(),
450 glass_panels: Vec::new(),
451 sdf_volumes: Vec::new(),
452 }
453 }
454
455 fn full_post() -> PostSettings {
456 PostSettings {
457 post_process: PostProcessTunables::DEFAULT,
458 taa_enabled: true,
459 ssao: Some(SsaoSettings::resolve(0.5, 1.0)),
460 ssr: None,
461 ssgi: None,
462 rt_reflections: None,
463 rt_dynamic: RtDynamicMode::Auto,
464 rt_skinned_geometry: true,
465 reflection_blur_scale: 2,
466 auto_exposure: None,
467 auto_exposure_bias_ev: 0.0,
468 hdr_display: false,
469 hdr_pq: false,
470 temporal_upscaling: true,
471 upscale_scale: 0.5,
472 upscale_backend: UpscalerBackend::Auto,
473 occlusion_two_pass: true,
474 }
475 }
476
477 #[test]
478 fn text_only_world_derives_no_scene() {
479 let req = RenderRequirements::derive(&empty_scene(), &empty_fx());
480 assert!(!req.scene);
481 }
482
483 #[test]
484 fn minimal_carries_only_a_window_and_its_atlases() {
485 let window = Window::default();
486 let atlas = vec![(2u32, 2u32, vec![255u8; 2 * 2 * 4])];
487 let init = BackendInit::minimal(&window, atlas);
488
489 // The text pipeline is the one thing it keeps: backends gate that pass
490 // on a non-empty atlas list.
491 assert_eq!(init.media.text_atlases.len(), 1);
492 // One shader entry with empty stage bytes, so every backend resolves it
493 // to its built-in default program rather than leaving bucket 0 unbuilt.
494 assert_eq!(init.shaders.len(), 1);
495 assert!(init.shaders[0].vert.is_empty());
496 assert!(!init.shaders[0].deferred);
497 // No scene content, so `resolve_requirements` ran and trimmed the
498 // scene-scoped features.
499 assert!(!init.requirements.scene);
500 assert_eq!(init.shadows.map_size, 0);
501 assert!(!init.post.taa_enabled);
502 assert!(init.post.ssao.is_none());
503 assert_eq!(init.planar_planes, 0);
504 }
505
506 #[test]
507 fn any_scene_content_derives_scene() {
508 let mut scene = empty_scene();
509 scene.n_skinned = 1;
510 assert!(RenderRequirements::derive(&scene, &empty_fx()).scene);
511
512 let mut scene = empty_scene();
513 scene.n_chunk_max = 8;
514 assert!(RenderRequirements::derive(&scene, &empty_fx()).scene);
515
516 // FX content alone is scene content too (a water-only world still
517 // renders into the HDR scene chain).
518 let scene = empty_scene();
519 let mut fx = empty_fx();
520 fx.water_surfaces.push(WaterSurface::default());
521 assert!(RenderRequirements::derive(&scene, &fx).scene);
522 }
523
524 #[test]
525 fn sceneless_world_trims_scene_features() {
526 let mut shadows = ShadowParams {
527 map_size: 2048,
528 update: ShadowUpdate::default(),
529 distance: 120,
530 cascades: 4,
531 };
532 let mut post = full_post();
533 let mut fx = empty_fx();
534 let mut planar = 3usize;
535 trim_scene_features(&mut shadows, &mut post, &mut fx, &mut planar);
536 assert_eq!(shadows.map_size, 0);
537 assert!(!post.taa_enabled);
538 assert!(post.ssao.is_none());
539 assert!(!post.temporal_upscaling);
540 assert!(!post.occlusion_two_pass);
541 assert!(fx.fog.is_none());
542 assert_eq!(planar, 0);
543 }
544
545 #[test]
546 fn scene_world_keeps_settings() {
547 // A world with content must pass its resolved settings through
548 // untouched: derivation flags the scene, and nothing is trimmed.
549 let mut scene = empty_scene();
550 scene.n_skinned = 2;
551 let fx = empty_fx();
552 let req = RenderRequirements::derive(&scene, &fx);
553 assert!(req.scene);
554 }
555}