Skip to main content

concinnity_core/components/
sdf_volume.rs

1//! The SdfVolume asset: the authored schema (the struct, its `Default`,
2//! `cone_ratio`, and `SDF_PARAMS_LEN`), the `Component` impl, the blob-residency
3//! helper the engine init uses, and the runtime step-count clamp bounds. The
4//! JSON-args source selection, validation, and the bake-time clamp live in
5//! concinnity-world (`source_args`, `check::sdf_volume`, `validate::sdf_volume`).
6
7use crate::ecs::Component;
8use crate::ecs::PayloadLocator;
9use crate::ecs::asset_id::AssetId;
10use alloc::collections::BTreeMap;
11use alloc::string::String;
12
13/// Per-volume parameter slots packed into a single fixed-size uniform
14/// block. The user shader casts the bound buffer to its own typed
15/// struct; the engine just transports the bytes. Sized to comfortably
16/// fit a flow-water shader (flow speed, wave coefficients, deep + shallow
17/// colours, foam params, ...) without forcing schema design.
18pub const SDF_PARAMS_LEN: usize = 32;
19
20/// A raymarched signed-distance-field volume. It occupies a world-space
21/// bounding box; a user-authored fragment shader sphere-traces an SDF inside
22/// the box, composites correctly with the surrounding scene through the depth
23/// buffer, and shades hits with the engine's lighting helpers.
24///
25/// The fragment shader is selected per backend: a `fragment_shaders` map keyed
26/// by `"metal"` / `"hlsl"` / `"glsl"` lets one volume target multiple backends,
27/// and the build only requires the entry for the backend it is building for. A
28/// single `fragment_shader` path is the fallback when no map entry matches.
29///
30/// ```rust
31/// # use concinnity_core::components::SdfVolume;
32/// SdfVolume {
33///     centre: [0.0, 2.0, -4.0],
34///     extent: [2.0, 2.0, 2.0],
35///     max_gradient: 1.0,
36///     max_steps: 64,
37///     max_distance: 12.0,
38///     ..Default::default()
39/// };
40/// ```
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42#[serde(default)]
43pub struct SdfVolume {
44    /// Asset identity; injected via `inject_name`. Not part of `args`.
45    #[serde(skip)]
46    pub asset_id: AssetId,
47    /// World-space centre of the bounding box.
48    pub centre: [f32; 3],
49    /// XYZ half-widths of the bounding box. The raymarch is clipped to the box,
50    /// so the SDF only has to be well-defined inside this region.
51    pub extent: [f32; 3],
52    /// Single-platform fragment shader source path (e.g.
53    /// `"shaders/chrome_blob.metal"`), resolved relative to the project's
54    /// `assets/` at build time. Used when `fragment_shaders` has no entry for
55    /// the building backend; the file extension must match the backend
56    /// (`.metal` / `.hlsl`). The file defines the SDF's `map` and `shade`
57    /// functions.
58    #[serde(default)]
59    pub fragment_shader: String,
60    /// Per-backend fragment shader source paths keyed by `"metal"`, `"hlsl"`,
61    /// or `"glsl"`. Takes priority over `fragment_shader`, letting one volume
62    /// target multiple backends from a single declaration.
63    #[serde(default)]
64    pub fragment_shaders: Option<BTreeMap<String, String>>,
65    /// Worst-case gradient of the SDF, used to size the cone-march step. `1.0`
66    /// is correct for any well-formed SDF; higher values shorten the step but
67    /// stay safe. Must be > 0.
68    pub max_gradient: f32,
69    /// Maximum cone-march steps per pixel. Clamped to `[8, 256]`.
70    pub max_steps: u32,
71    /// Maximum march distance in metres. Must be ≥ 0.1.
72    pub max_distance: f32,
73    /// Generic parameter block passed to the shader as a uniform buffer; the
74    /// shader interprets it however it likes. Up to 32 values.
75    pub params: [f32; SDF_PARAMS_LEN],
76    /// When true, the volume casts shadows onto the surrounding scene. Disable
77    /// for translucent / volumetric effects that shouldn't block light.
78    pub cast_shadows: bool,
79    /// When true (the default), the volume is shadowed by the scene. Set to
80    /// false for unlit / always-bright effects (energy fields, etc.).
81    pub receive_shadows: bool,
82    /// When true, the volume renders as a participating medium (clouds, smoke,
83    /// fog blobs, energy fields) instead of an opaque surface. The shader must
84    /// define `sampleVolume(p, params, time)` returning per-point density,
85    /// scattering colour, and emission instead of `map` / `shade`. Volumetrics
86    /// never cast shadows (`cast_shadows` is forced off). The medium fills the
87    /// whole bounding box, so don't overlap it with geometry it should render
88    /// behind.
89    pub volumetric: bool,
90    /// When false the volume is skipped each frame.
91    pub visible: bool,
92    /// Injected at load time from the blob def. Carries the user
93    /// shader source bytes packed at build time.
94    #[serde(skip)]
95    pub locator: Option<PayloadLocator>,
96}
97
98impl Default for SdfVolume {
99    fn default() -> Self {
100        Self {
101            asset_id: AssetId::default(),
102            centre: [0.0, 0.0, 0.0],
103            extent: [1.0, 1.0, 1.0],
104            fragment_shader: String::new(),
105            fragment_shaders: None,
106            max_gradient: 1.0,
107            max_steps: 64,
108            max_distance: 30.0,
109            params: [0.0; SDF_PARAMS_LEN],
110            cast_shadows: false,
111            receive_shadows: true,
112            volumetric: false,
113            visible: true,
114            locator: None,
115        }
116    }
117}
118
119impl SdfVolume {
120    /// Effective cone-march step ratio derived from the Lipschitz
121    /// constant. A 1-Lipschitz SDF (gradient ≤ 1) cone-marches at
122    /// ratio 1; larger gradients shorten the step proportionally.
123    pub fn cone_ratio(&self) -> f32 {
124        1.0 / self.max_gradient.max(f32::EPSILON)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use alloc::string::ToString;
132
133    #[test]
134    fn a_blank_volume_is_a_visible_unit_box_that_receives_shadows() {
135        let v = SdfVolume::default();
136        assert_eq!(v.centre, [0.0, 0.0, 0.0]);
137        assert_eq!(v.extent, [1.0, 1.0, 1.0]);
138        assert_eq!(v.max_steps, 64);
139        assert_eq!(v.max_distance, 30.0);
140        assert_eq!(v.params, [0.0; SDF_PARAMS_LEN]);
141        assert!(v.visible);
142        assert!(v.receive_shadows);
143        // Raymarched surfaces do not write the shadow map by default.
144        assert!(!v.cast_shadows);
145        assert!(!v.volumetric);
146        assert!(v.locator.is_none());
147    }
148
149    #[test]
150    fn a_one_lipschitz_field_cone_marches_at_full_ratio() {
151        assert_eq!(SdfVolume::default().cone_ratio(), 1.0);
152    }
153
154    #[test]
155    fn a_steeper_gradient_shortens_the_step_proportionally() {
156        let v = SdfVolume {
157            max_gradient: 4.0,
158            ..SdfVolume::default()
159        };
160        assert_eq!(v.cone_ratio(), 0.25);
161    }
162
163    #[test]
164    fn a_zero_or_negative_gradient_cannot_divide_by_zero() {
165        // An authored 0 would otherwise make the step ratio infinite and hang
166        // the march, so the divisor is floored at epsilon.
167        for max_gradient in [0.0, -1.0] {
168            let v = SdfVolume {
169                max_gradient,
170                ..SdfVolume::default()
171            };
172            assert!(v.cone_ratio().is_finite(), "{max_gradient}");
173            assert_eq!(v.cone_ratio(), 1.0 / f32::EPSILON);
174        }
175    }
176
177    #[test]
178    fn per_backend_shader_sources_parse_and_round_trip_through_postcard() {
179        let v: SdfVolume = serde_json::from_str(
180            r#"{"centre":[0,2,0],"extent":[3,3,3],"max_gradient":2.0,
181                "fragment_shaders":{"metal":"blob.metal","hlsl":"blob.hlsl"},
182                "cast_shadows":true,"visible":false}"#,
183        )
184        .unwrap();
185        assert_eq!(v.cone_ratio(), 0.5);
186        assert!(v.cast_shadows);
187        assert!(!v.visible);
188        let per_backend = v.fragment_shaders.as_ref().expect("per-backend sources");
189        assert_eq!(per_backend["metal"], "blob.metal");
190        assert_eq!(per_backend["hlsl"], "blob.hlsl");
191        // The single-source field stays empty when the map is used.
192        assert!(v.fragment_shader.is_empty());
193
194        let bytes = postcard::to_allocvec(&v).unwrap();
195        let back: SdfVolume = postcard::from_bytes(&bytes).unwrap();
196        assert_eq!(back.extent, [3.0, 3.0, 3.0]);
197        assert_eq!(
198            back.fragment_shaders.expect("per-backend sources")["metal"],
199            "blob.metal"
200        );
201        // Identity and payload location are injected at load, never authored.
202        assert_eq!(back.asset_id, AssetId::default());
203        assert!(back.locator.is_none());
204    }
205
206    #[test]
207    fn a_single_source_volume_leaves_the_per_backend_map_absent() {
208        let v: SdfVolume = serde_json::from_str(r#"{"fragment_shader":"blob.metal"}"#).unwrap();
209        assert_eq!(v.fragment_shader, "blob.metal".to_string());
210        assert!(v.fragment_shaders.is_none());
211        // `params` is a fixed-width uniform block, so a short array is a length
212        // mismatch rather than a partial fill.
213        assert!(serde_json::from_str::<SdfVolume>(r#"{"params":[1.5]}"#).is_err());
214    }
215}
216
217/// Hard cap on the per-volume cone-march step count. Matches the
218/// runtime kernel's loop bound; values above this are clamped.
219pub const SDF_MAX_STEPS_CEILING: u32 = 256;
220
221/// Lower bound on the per-volume cone-march step count. Below this the
222/// march doesn't have enough budget to converge on anything interesting.
223pub const SDF_MAX_STEPS_FLOOR: u32 = 8;
224
225impl Component for SdfVolume {
226    const NAME: &'static str = "SdfVolume";
227
228    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
229        Ok(crate::blob::decode_exact(bytes)?)
230    }
231
232    fn inject_name(&mut self, id: AssetId) {
233        self.asset_id = id;
234    }
235
236    fn inject_locator(&mut self, locator: PayloadLocator) {
237        self.locator = Some(locator);
238    }
239}
240
241/// Blob indices that hold an `SdfVolume` fragment-shader payload.
242///
243/// The graphics-system init drains `SdfVolume`s and reads their payload
244/// bytes via the locator. The release sweep earlier in the same init
245/// frees every blob whose contents have already been consumed, but
246/// because the SDF drain runs *after* that sweep, any blob holding only
247/// an SDF payload would be freed before being read. (When the world
248/// has other small assets, the SDF shader bytes typically share a blob
249/// with a kept asset and survive by accident; a world whose SDF shader
250/// ends up alone in its blob exposes the bug as "SdfVolume payload
251/// FileIo, skipping" with no surface drawn.) This helper lets the
252/// release sweep keep SDF blobs resident, matching the
253/// `audio_clip_blob_indices` pattern.
254pub fn sdf_volume_blob_indices(
255    ctx: &crate::ecs::PipelineContext,
256) -> alloc::collections::BTreeSet<u32> {
257    ctx.query::<SdfVolume>()
258        .filter_map(|v| v.locator.as_ref().map(|l| l.blob_index))
259        .collect()
260}
261
262#[cfg(test)]
263mod runtime_tests {
264    use super::*;
265
266    #[test]
267    fn defaults_are_sensible() {
268        let v = SdfVolume::default();
269        assert_eq!(v.centre, [0.0, 0.0, 0.0]);
270        assert_eq!(v.extent, [1.0, 1.0, 1.0]);
271        assert_eq!(v.max_gradient, 1.0);
272        assert_eq!(v.max_steps, 64);
273        assert_eq!(v.max_distance, 30.0);
274        assert!(v.receive_shadows);
275        assert!(!v.cast_shadows);
276        assert!(v.visible);
277        assert_eq!(v.params.len(), SDF_PARAMS_LEN);
278        assert_eq!(v.cone_ratio(), 1.0);
279    }
280
281    #[test]
282    fn cone_ratio_inverts_gradient() {
283        let v = SdfVolume {
284            max_gradient: 2.0,
285            ..Default::default()
286        };
287        assert!((v.cone_ratio() - 0.5).abs() < 1e-6);
288    }
289
290    #[test]
291    fn volumetric_default_is_off() {
292        let v = SdfVolume::default();
293        assert!(!v.volumetric);
294    }
295}