Skip to main content

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