Skip to main content

concinnity_asset/
sdf_volume.rs

1// Raymarched signed-distance-field volume schema.
2//
3// Authors a world-space bounding box plus a user-written fragment shader
4// (containing the SDF `map` and per-point `shade` functions). At init the
5// backend builds a per-volume render pipeline that sphere-traces the SDF inside
6// the box; hits write opaque colour into `hdr_resolve` and update the main depth
7// attachment so the raymarched surface composites with rasterised geometry
8// naturally.
9//
10// The user writes one `.metal` file that defines two functions:
11//
12// ```metal
13// float map(float3 p, constant SdfParams& params, float time);
14// SdfSurface shade(float3 p, float3 normal,
15//                  constant SdfParams& params, float time);
16// ```
17//
18// The engine prepends a header (`raymarch_helpers.metal`: IQ primitive library,
19// `sdfNormal`, `coneRaymarch`, PBR helpers) and appends a template
20// (`raymarch_template.metal`: vertex + `fragment_main` that reconstructs the
21// ray, samples main depth for early-out, calls the user's `map` + `shade`,
22// applies PBR + shadow, writes colour + depth). The wrapped source compiles at
23// runtime, matching how the water / fog / decal / particle passes load their own
24// MSL. The build pipeline reads the user's source file and packs the raw bytes
25// as this volume's payload, so production `cn run` worlds don't need the .metal
26// file on disk at runtime: the bytes ride in the blob.
27
28use crate::{AssetId, PayloadLocator};
29use alloc::collections::BTreeMap;
30use alloc::string::String;
31
32/// Per-volume parameter slots packed into a single fixed-size uniform
33/// block. The user shader casts the bound buffer to its own typed
34/// struct; the engine just transports the bytes. Sized to comfortably
35/// fit a flow-water shader (flow speed, wave coefficients, deep + shallow
36/// colours, foam params, ...) without forcing schema design.
37pub const SDF_PARAMS_LEN: usize = 32;
38
39/// A raymarched signed-distance-field volume. It occupies a world-space
40/// bounding box; a user-authored fragment shader sphere-traces an SDF inside
41/// the box, composites correctly with the surrounding scene through the depth
42/// buffer, and shades hits with the engine's lighting helpers.
43///
44/// The fragment shader is selected per backend: a `fragment_shaders` map keyed
45/// by `"metal"` / `"hlsl"` / `"glsl"` lets one volume target multiple backends,
46/// and the build only requires the entry for the backend it is building for. A
47/// single `fragment_shader` path is the fallback when no map entry matches.
48///
49/// ```rust
50/// # use concinnity_asset::SdfVolume;
51/// SdfVolume {
52///     centre: [0.0, 2.0, -4.0],
53///     extent: [2.0, 2.0, 2.0],
54///     max_gradient: 1.0,
55///     max_steps: 64,
56///     max_distance: 12.0,
57///     ..Default::default()
58/// };
59/// ```
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61#[serde(default)]
62pub struct SdfVolume {
63    /// Asset identity; injected via `inject_name`. Not part of `args`.
64    #[serde(skip)]
65    pub asset_id: AssetId,
66    /// World-space centre of the bounding box.
67    pub centre: [f32; 3],
68    /// XYZ half-widths of the bounding box. The raymarch is clipped to the box,
69    /// so the SDF only has to be well-defined inside this region.
70    pub extent: [f32; 3],
71    /// Single-platform fragment shader source path (e.g.
72    /// `"shaders/chrome_blob.metal"`), resolved relative to the project's
73    /// `assets/` at build time. Used when `fragment_shaders` has no entry for
74    /// the building backend; the file extension must match the backend
75    /// (`.metal` / `.hlsl`). The file defines the SDF's `map` and `shade`
76    /// functions.
77    #[serde(default)]
78    pub fragment_shader: String,
79    /// Per-backend fragment shader source paths keyed by `"metal"`, `"hlsl"`,
80    /// or `"glsl"`. Takes priority over `fragment_shader`, letting one volume
81    /// target multiple backends from a single declaration.
82    #[serde(default)]
83    pub fragment_shaders: Option<BTreeMap<String, String>>,
84    /// Worst-case gradient of the SDF, used to size the cone-march step. `1.0`
85    /// is correct for any well-formed SDF; higher values shorten the step but
86    /// stay safe. Must be > 0.
87    pub max_gradient: f32,
88    /// Maximum cone-march steps per pixel. Clamped to `[8, 256]`.
89    pub max_steps: u32,
90    /// Maximum march distance in metres. Must be ≥ 0.1.
91    pub max_distance: f32,
92    /// Generic parameter block passed to the shader as a uniform buffer; the
93    /// shader interprets it however it likes. Up to 32 values.
94    pub params: [f32; SDF_PARAMS_LEN],
95    /// When true, the volume casts shadows onto the surrounding scene. Disable
96    /// for translucent / volumetric effects that shouldn't block light.
97    pub cast_shadows: bool,
98    /// When true (the default), the volume is shadowed by the scene. Set to
99    /// false for unlit / always-bright effects (energy fields, etc.).
100    pub receive_shadows: bool,
101    /// When true, the volume renders as a participating medium (clouds, smoke,
102    /// fog blobs, energy fields) instead of an opaque surface. The shader must
103    /// define `sampleVolume(p, params, time)` returning per-point density,
104    /// scattering colour, and emission instead of `map` / `shade`. Volumetrics
105    /// never cast shadows (`cast_shadows` is forced off). The medium fills the
106    /// whole bounding box, so don't overlap it with geometry it should render
107    /// behind.
108    pub volumetric: bool,
109    /// When false the volume is skipped each frame.
110    pub visible: bool,
111    /// Injected at load time from the blob def. Carries the user
112    /// shader source bytes packed at build time.
113    #[serde(skip)]
114    pub locator: Option<PayloadLocator>,
115}
116
117impl Default for SdfVolume {
118    fn default() -> Self {
119        Self {
120            asset_id: AssetId::default(),
121            centre: [0.0, 0.0, 0.0],
122            extent: [1.0, 1.0, 1.0],
123            fragment_shader: String::new(),
124            fragment_shaders: None,
125            max_gradient: 1.0,
126            max_steps: 64,
127            max_distance: 30.0,
128            params: [0.0; SDF_PARAMS_LEN],
129            cast_shadows: false,
130            receive_shadows: true,
131            volumetric: false,
132            visible: true,
133            locator: None,
134        }
135    }
136}
137
138impl SdfVolume {
139    /// Effective cone-march step ratio derived from the Lipschitz
140    /// constant. A 1-Lipschitz SDF (gradient ≤ 1) cone-marches at
141    /// ratio 1; larger gradients shorten the step proportionally.
142    pub fn cone_ratio(&self) -> f32 {
143        1.0 / self.max_gradient.max(f32::EPSILON)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use alloc::string::ToString;
151
152    #[test]
153    fn a_blank_volume_is_a_visible_unit_box_that_receives_shadows() {
154        let v = SdfVolume::default();
155        assert_eq!(v.centre, [0.0, 0.0, 0.0]);
156        assert_eq!(v.extent, [1.0, 1.0, 1.0]);
157        assert_eq!(v.max_steps, 64);
158        assert_eq!(v.max_distance, 30.0);
159        assert_eq!(v.params, [0.0; SDF_PARAMS_LEN]);
160        assert!(v.visible);
161        assert!(v.receive_shadows);
162        // Raymarched surfaces do not write the shadow map by default.
163        assert!(!v.cast_shadows);
164        assert!(!v.volumetric);
165        assert!(v.locator.is_none());
166    }
167
168    #[test]
169    fn a_one_lipschitz_field_cone_marches_at_full_ratio() {
170        assert_eq!(SdfVolume::default().cone_ratio(), 1.0);
171    }
172
173    #[test]
174    fn a_steeper_gradient_shortens_the_step_proportionally() {
175        let v = SdfVolume {
176            max_gradient: 4.0,
177            ..SdfVolume::default()
178        };
179        assert_eq!(v.cone_ratio(), 0.25);
180    }
181
182    #[test]
183    fn a_zero_or_negative_gradient_cannot_divide_by_zero() {
184        // An authored 0 would otherwise make the step ratio infinite and hang
185        // the march, so the divisor is floored at epsilon.
186        for max_gradient in [0.0, -1.0] {
187            let v = SdfVolume {
188                max_gradient,
189                ..SdfVolume::default()
190            };
191            assert!(v.cone_ratio().is_finite(), "{max_gradient}");
192            assert_eq!(v.cone_ratio(), 1.0 / f32::EPSILON);
193        }
194    }
195
196    #[test]
197    fn per_backend_shader_sources_parse_and_round_trip_through_postcard() {
198        let v: SdfVolume = serde_json::from_str(
199            r#"{"centre":[0,2,0],"extent":[3,3,3],"max_gradient":2.0,
200                "fragment_shaders":{"metal":"blob.metal","hlsl":"blob.hlsl"},
201                "cast_shadows":true,"visible":false}"#,
202        )
203        .unwrap();
204        assert_eq!(v.cone_ratio(), 0.5);
205        assert!(v.cast_shadows);
206        assert!(!v.visible);
207        let per_backend = v.fragment_shaders.as_ref().expect("per-backend sources");
208        assert_eq!(per_backend["metal"], "blob.metal");
209        assert_eq!(per_backend["hlsl"], "blob.hlsl");
210        // The single-source field stays empty when the map is used.
211        assert!(v.fragment_shader.is_empty());
212
213        let bytes = postcard::to_allocvec(&v).unwrap();
214        let back: SdfVolume = postcard::from_bytes(&bytes).unwrap();
215        assert_eq!(back.extent, [3.0, 3.0, 3.0]);
216        assert_eq!(
217            back.fragment_shaders.expect("per-backend sources")["metal"],
218            "blob.metal"
219        );
220        // Identity and payload location are injected at load, never authored.
221        assert_eq!(back.asset_id, AssetId::default());
222        assert!(back.locator.is_none());
223    }
224
225    #[test]
226    fn a_single_source_volume_leaves_the_per_backend_map_absent() {
227        let v: SdfVolume = serde_json::from_str(r#"{"fragment_shader":"blob.metal"}"#).unwrap();
228        assert_eq!(v.fragment_shader, "blob.metal".to_string());
229        assert!(v.fragment_shaders.is_none());
230        // `params` is a fixed-width uniform block, so a short array is a length
231        // mismatch rather than a partial fill.
232        assert!(serde_json::from_str::<SdfVolume>(r#"{"params":[1.5]}"#).is_err());
233    }
234}