concinnity_core/components/sdf_volume.rs
1//! Runtime behavior for the SdfVolume asset. The authored schema (the
2//! SdfVolume struct, its Default, `cone_ratio`, and `SDF_PARAMS_LEN`) lives in
3//! concinnity-asset; this file keeps the `Component` impl, the blob-residency
4//! helper the engine init uses, and the runtime step-count clamp bounds. The
5//! JSON-args source selection, validation, and the bake-time clamp live in
6//! concinnity-world (`source_args`, `check::sdf_volume`, `validate::sdf_volume`).
7//! The schema type + `SDF_PARAMS_LEN` are re-exported so
8//! `crate::components::sdf_volume::*` paths (the render backends' uniform structs)
9//! keep resolving.
10
11pub use concinnity_asset::{SDF_PARAMS_LEN, SdfVolume};
12
13use crate::ecs::asset_id::AssetId;
14use crate::ecs::{Component, PayloadLocator};
15
16/// Hard cap on the per-volume cone-march step count. Matches the
17/// runtime kernel's loop bound; values above this are clamped.
18pub const SDF_MAX_STEPS_CEILING: u32 = 256;
19
20/// Lower bound on the per-volume cone-march step count. Below this the
21/// march doesn't have enough budget to converge on anything interesting.
22pub const SDF_MAX_STEPS_FLOOR: u32 = 8;
23
24impl Component for SdfVolume {
25 const NAME: &'static str = "SdfVolume";
26
27 fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
28 Ok(crate::blob::decode_exact(bytes)?)
29 }
30
31 fn inject_name(&mut self, id: AssetId) {
32 self.asset_id = id;
33 }
34
35 fn inject_locator(&mut self, locator: PayloadLocator) {
36 self.locator = Some(locator);
37 }
38}
39
40/// Blob indices that hold an `SdfVolume` fragment-shader payload.
41///
42/// The graphics-system init drains `SdfVolume`s and reads their payload
43/// bytes via the locator. The release sweep earlier in the same init
44/// frees every blob whose contents have already been consumed, but
45/// because the SDF drain runs *after* that sweep, any blob holding only
46/// an SDF payload would be freed before being read. (When the world
47/// has other small assets, the SDF shader bytes typically share a blob
48/// with a kept asset and survive by accident; a world whose SDF shader
49/// ends up alone in its blob exposes the bug as "SdfVolume payload
50/// FileIo, skipping" with no surface drawn.) This helper lets the
51/// release sweep keep SDF blobs resident, matching the
52/// `audio_clip_blob_indices` pattern.
53pub fn sdf_volume_blob_indices(
54 ctx: &crate::ecs::PipelineContext,
55) -> alloc::collections::BTreeSet<u32> {
56 ctx.query::<SdfVolume>()
57 .filter_map(|v| v.locator.as_ref().map(|l| l.blob_index))
58 .collect()
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 #[test]
66 fn defaults_are_sensible() {
67 let v = SdfVolume::default();
68 assert_eq!(v.centre, [0.0, 0.0, 0.0]);
69 assert_eq!(v.extent, [1.0, 1.0, 1.0]);
70 assert_eq!(v.max_gradient, 1.0);
71 assert_eq!(v.max_steps, 64);
72 assert_eq!(v.max_distance, 30.0);
73 assert!(v.receive_shadows);
74 assert!(!v.cast_shadows);
75 assert!(v.visible);
76 assert_eq!(v.params.len(), SDF_PARAMS_LEN);
77 assert_eq!(v.cone_ratio(), 1.0);
78 }
79
80 #[test]
81 fn cone_ratio_inverts_gradient() {
82 let v = SdfVolume {
83 max_gradient: 2.0,
84 ..Default::default()
85 };
86 assert!((v.cone_ratio() - 0.5).abs() < 1e-6);
87 }
88
89 #[test]
90 fn volumetric_default_is_off() {
91 let v = SdfVolume::default();
92 assert!(!v.volumetric);
93 }
94}