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, 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/// 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::bake::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        }
400        self.requirements = req;
401    }
402}
403
404// Force off every feature that only decorates a 3D scene. All of these are
405// existing init-time gates in the backends, so zeroing them here means every
406// backend skips the matching resources with no backend-side changes.
407fn trim_scene_features(
408    shadows: &mut ShadowParams,
409    post: &mut PostSettings,
410    fx: &mut WorldFx,
411    planar_planes: &mut usize,
412) {
413    shadows.map_size = 0;
414    post.taa_enabled = false;
415    post.ssao = None;
416    post.ssr = None;
417    post.ssgi = None;
418    post.rt_reflections = None;
419    post.auto_exposure = None;
420    post.temporal_upscaling = false;
421    post.occlusion_two_pass = false;
422    fx.fog = None;
423    *planar_planes = 0;
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn empty_scene() -> SceneData<'static> {
431        SceneData {
432            vertices: &[],
433            indices: &[],
434            draw_objects: Vec::new(),
435            instanced_clusters: Vec::new(),
436            n_skinned: 0,
437            n_chunk_max: 0,
438        }
439    }
440
441    fn empty_fx() -> WorldFx {
442        WorldFx {
443            decals: Vec::new(),
444            particles: Vec::new(),
445            fog: None,
446            water_surfaces: Vec::new(),
447            glass_panels: Vec::new(),
448            sdf_volumes: Vec::new(),
449        }
450    }
451
452    fn full_post() -> PostSettings {
453        PostSettings {
454            post_process: PostProcessTunables::DEFAULT,
455            taa_enabled: true,
456            ssao: Some(SsaoSettings::resolve(0.5, 1.0)),
457            ssr: None,
458            ssgi: None,
459            rt_reflections: None,
460            rt_dynamic: RtDynamicMode::Auto,
461            rt_skinned_geometry: true,
462            reflection_blur_scale: 2,
463            auto_exposure: None,
464            auto_exposure_bias_ev: 0.0,
465            hdr_display: false,
466            hdr_pq: false,
467            temporal_upscaling: true,
468            upscale_scale: 0.5,
469            upscale_backend: UpscalerBackend::Auto,
470            occlusion_two_pass: true,
471        }
472    }
473
474    #[test]
475    fn text_only_world_derives_no_scene() {
476        let req = RenderRequirements::derive(&empty_scene(), &empty_fx());
477        assert!(!req.scene);
478    }
479
480    #[test]
481    fn minimal_carries_only_a_window_and_its_atlases() {
482        let window = Window::default();
483        let atlas = vec![(2u32, 2u32, vec![255u8; 2 * 2 * 4])];
484        let init = BackendInit::minimal(&window, atlas);
485
486        // The text pipeline is the one thing it keeps: backends gate that pass
487        // on a non-empty atlas list.
488        assert_eq!(init.media.text_atlases.len(), 1);
489        // One shader entry with empty stage bytes, so every backend resolves it
490        // to its built-in default program rather than leaving bucket 0 unbuilt.
491        assert_eq!(init.shaders.len(), 1);
492        assert!(init.shaders[0].vert.is_empty());
493        assert!(!init.shaders[0].deferred);
494        // No scene content, so `resolve_requirements` ran and trimmed the
495        // scene-scoped features.
496        assert!(!init.requirements.scene);
497        assert_eq!(init.shadows.map_size, 0);
498        assert!(!init.post.taa_enabled);
499        assert!(init.post.ssao.is_none());
500        assert_eq!(init.planar_planes, 0);
501    }
502
503    #[test]
504    fn any_scene_content_derives_scene() {
505        let mut scene = empty_scene();
506        scene.n_skinned = 1;
507        assert!(RenderRequirements::derive(&scene, &empty_fx()).scene);
508
509        let mut scene = empty_scene();
510        scene.n_chunk_max = 8;
511        assert!(RenderRequirements::derive(&scene, &empty_fx()).scene);
512
513        // FX content alone is scene content too (a water-only world still
514        // renders into the HDR scene chain).
515        let scene = empty_scene();
516        let mut fx = empty_fx();
517        fx.water_surfaces.push(WaterSurface::default());
518        assert!(RenderRequirements::derive(&scene, &fx).scene);
519    }
520
521    #[test]
522    fn sceneless_world_trims_scene_features() {
523        let mut shadows = ShadowParams {
524            map_size: 2048,
525            update: ShadowUpdate::default(),
526            distance: 120,
527            cascades: 4,
528        };
529        let mut post = full_post();
530        let mut fx = empty_fx();
531        let mut planar = 3usize;
532        trim_scene_features(&mut shadows, &mut post, &mut fx, &mut planar);
533        assert_eq!(shadows.map_size, 0);
534        assert!(!post.taa_enabled);
535        assert!(post.ssao.is_none());
536        assert!(!post.temporal_upscaling);
537        assert!(!post.occlusion_two_pass);
538        assert!(fx.fog.is_none());
539        assert_eq!(planar, 0);
540    }
541
542    #[test]
543    fn scene_world_keeps_settings() {
544        // A world with content must pass its resolved settings through
545        // untouched: derivation flags the scene, and nothing is trimmed.
546        let mut scene = empty_scene();
547        scene.n_skinned = 2;
548        let fx = empty_fx();
549        let req = RenderRequirements::derive(&scene, &fx);
550        assert!(req.scene);
551    }
552}