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