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