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