Skip to main content

concinnity_core/components/
validate.rs

1//! Named bake-time validators for the data-only assets. Each function clamps or
2//! normalizes an asset's authored value into a self-consistent runtime value.
3//! The authoring registry names the function via `validate: <fn>` and applies
4//! it while baking the blob record; a runtime bake applies the same function
5//! before installing the value. The runtime never runs these on a loaded
6//! world -- a baked record is already validated.
7
8use alloc::string::{String, ToString};
9
10use crate::components::{
11    Decal, DirectionalLight, GlassPanel, GlassPanelGeometry, InstancedProp, MAX_WATER_WAVES,
12    Material, ParticleEmitter, PhysicsJoint, PhysicsJointKind, PointLight, Prop, RectAreaLight,
13    ReflectionProbe, RigidBody, SPOT_MAX_ANGLE_DEG, SdfVolume, SpotLight, SpotLightGeometry,
14    VolumetricFog, VoxelChunk, WaterSurface, WaterWave,
15};
16use crate::math::sqrt;
17
18// Extension of the file name at the end of `path`, or "" when it has none.
19// The no_std stand-in for `std::path::Path::extension`.
20fn path_extension(path: &str) -> &str {
21    let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
22    match file.rsplit_once('.') {
23        Some((stem, ext)) if !stem.is_empty() => ext,
24        _ => "",
25    }
26}
27
28// Resolve the fragment shader source path for the current build backend from a
29// volume's `fragment_shaders` map (preferred) or its `fragment_shader`
30// fallback.
31fn sdf_current_platform_source(v: &SdfVolume) -> Option<String> {
32    let platform = crate::platform::Platform::current();
33    if let Some(map) = &v.fragment_shaders
34        && let Some(src) = map.get(platform.key()).filter(|s| !s.is_empty())
35    {
36        return Some(src.clone());
37    }
38    if v.fragment_shader.is_empty() {
39        return None;
40    }
41    if platform.accepts_ext(path_extension(&v.fragment_shader)) {
42        Some(v.fragment_shader.clone())
43    } else {
44        None
45    }
46}
47
48/// Normalize an authored volume for the runtime: clamp the raymarch knobs to
49/// sane bounds, force shadows off for translucent volumetrics (they write no
50/// depth), and collapse the per-backend `fragment_shaders` map to the current
51/// backend's `fragment_shader` (the DirectX raymarch pass filters volumes by
52/// that path's extension). The step-count bounds stay with the schema: they
53/// double as the runtime kernel's loop bound.
54pub fn sdf_volume(mut v: SdfVolume) -> SdfVolume {
55    use crate::components::sdf_volume::{SDF_MAX_STEPS_CEILING, SDF_MAX_STEPS_FLOOR};
56    // Extents must be positive: a zero or negative extent would produce an
57    // inside-out bounding box no fragment ever enters.
58    for axis in v.extent.iter_mut() {
59        if !axis.is_finite() || *axis <= 0.0 {
60            *axis = 1.0;
61        }
62    }
63    if !v.max_gradient.is_finite() || v.max_gradient <= 0.0 {
64        v.max_gradient = 1.0;
65    }
66    v.max_steps = v
67        .max_steps
68        .clamp(SDF_MAX_STEPS_FLOOR, SDF_MAX_STEPS_CEILING);
69    if !v.max_distance.is_finite() || v.max_distance < 0.1 {
70        v.max_distance = 0.1;
71    }
72    if v.volumetric {
73        v.cast_shadows = false;
74    }
75    if let Some(src) = sdf_current_platform_source(&v) {
76        v.fragment_shader = src;
77    }
78    v
79}
80
81/// Clamp a `PointLight`'s authored fields into their valid ranges.
82pub fn point_light(mut args: PointLight) -> PointLight {
83    args.intensity = args.intensity.max(0.0);
84    args.range = args.range.max(0.0);
85    args
86}
87
88/// Clamp a `SpotLight`'s authored fields into their valid ranges.
89pub fn spot_light(mut args: SpotLight) -> SpotLight {
90    args.intensity = args.intensity.max(0.0);
91    args.range = args.range.max(0.0);
92    args.direction = args.unit_direction();
93    args.outer_angle = args.outer_angle.clamp(0.0, SPOT_MAX_ANGLE_DEG);
94    args.inner_angle = args.inner_angle.clamp(0.0, args.outer_angle);
95    args
96}
97
98/// Clamp a `RectAreaLight`'s authored fields into their valid ranges.
99pub fn rect_area_light(mut args: RectAreaLight) -> RectAreaLight {
100    args.intensity = args.intensity.max(0.0);
101    args.range = args.range.max(0.0);
102    // A degenerate normal would collapse the panel's tangent frame; a zero
103    // half-extent would collapse its area and divide by zero in the integrator.
104    let n = args.normal;
105    let len = sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
106    args.normal = if len < 1e-6 {
107        [0.0, 0.0, 1.0]
108    } else {
109        [n[0] / len, n[1] / len, n[2] / len]
110    };
111    args.half_size[0] = args.half_size[0].max(1e-3);
112    args.half_size[1] = args.half_size[1].max(1e-3);
113    args
114}
115
116/// Clamp a `DirectionalLight`'s authored fields into their valid ranges.
117pub fn directional_light(mut args: DirectionalLight) -> DirectionalLight {
118    args.intensity = args.intensity.max(0.0);
119    args
120}
121
122/// Clamp a `Material`'s authored fields into their valid ranges. Material is a
123/// data resource, not a registered component, so no generated `from_args` runs
124/// this -- the material compilers call it explicitly before baking the bytes.
125pub fn material(mut args: Material) -> Material {
126    args.roughness = args.roughness.clamp(0.0, 1.0);
127    args.metallic = args.metallic.clamp(0.0, 1.0);
128    args.macro_variation = args.macro_variation.clamp(0.0, 1.0);
129    args.terrain_blend = args.terrain_blend.clamp(0.0, 1.0);
130    args.secondary_blend_sharpness = args.secondary_blend_sharpness.clamp(0.0, 1.0);
131    args.alpha_cutoff = args.alpha_cutoff.clamp(0.0, 1.0);
132    args.opacity = args.opacity.clamp(0.0, 1.0);
133    // See-through glass is by definition transparent; opting into it implies
134    // the transparent pass even if the author only set `see_through`.
135    if args.see_through {
136        args.transparent = true;
137    }
138    args
139}
140
141/// Clamp a `GlassPanel`'s authored fields into their valid ranges.
142pub fn glass_panel(mut args: GlassPanel) -> GlassPanel {
143    args.normal = args.unit_normal();
144    args.half_size[0] = args.half_size[0].max(1e-3);
145    args.half_size[1] = args.half_size[1].max(1e-3);
146    args.opacity = args.opacity.clamp(0.0, 1.0);
147    args.refraction_strength = args.refraction_strength.max(0.0);
148    args.fresnel_power = args.fresnel_power.max(0.0);
149    args
150}
151
152/// Clamp a `WaterSurface`'s authored fields into their valid ranges.
153pub fn water_surface(mut args: WaterSurface) -> WaterSurface {
154    args.subdivisions = args.subdivisions.clamp(8, 255);
155    if args.waves.len() > MAX_WATER_WAVES {
156        args.waves.truncate(MAX_WATER_WAVES);
157    }
158    if args.waves.is_empty() {
159        args.waves.push(WaterWave::default());
160    }
161    args
162}
163
164/// Clamp a `PhysicsJoint`'s authored fields into their valid ranges.
165pub fn joint(mut args: PhysicsJoint) -> PhysicsJoint {
166    // Normalise the kind string so `to_args` round-trips cleanly.
167    if let Some(k) = PhysicsJointKind::from_str_norm(&args.kind) {
168        args.kind = k.as_str().to_string();
169    }
170    args
171}
172
173/// Clamp a `Decal`'s authored fields into their valid ranges.
174pub fn decal(mut args: Decal) -> Decal {
175    // Clamp the alpha to [0, 1] so a stray > 1 doesn't blow out the
176    // composite. The size components are left as-authored: a non-positive
177    // value silently disables the decal in the gfx-side resolver.
178    args.tint[3] = args.tint[3].clamp(0.0, 1.0);
179    args
180}
181
182/// Clamp a `ReflectionProbe`'s authored fields into their valid ranges.
183pub fn reflection_probe(mut args: ReflectionProbe) -> ReflectionProbe {
184    // Half-extents are sizes: keep them non-negative so the influence box is
185    // never inverted.
186    for e in &mut args.half_extents {
187        *e = e.max(0.0);
188    }
189    args
190}
191
192/// Reset a `RigidBody`'s runtime state on construction.
193pub fn rigid_body(mut args: RigidBody) -> RigidBody {
194    args.is_grounded = true;
195    args
196}
197
198/// Clamp a `Prop`'s authored fields into their valid ranges.
199pub fn prop(mut args: Prop) -> Prop {
200    args.cull_distance = args.cull_distance.max(0.0);
201    args.is_held = false;
202    args
203}
204
205/// Clamp a `ParticleEmitter`'s authored fields into their valid ranges.
206pub fn particle_emitter(mut args: ParticleEmitter) -> ParticleEmitter {
207    // Asset-side floor: keep every authored field in a self-consistent
208    // range. The gfx-side `build_particle_records` adds its own clamps
209    // for fields that affect GPU buffer sizing.
210    args.spread_deg = args.spread_deg.clamp(0.0, 180.0);
211    args.speed_min = args.speed_min.max(0.0);
212    if !args.speed_max.is_finite() || args.speed_max < args.speed_min {
213        args.speed_max = args.speed_min;
214    }
215    if !args.lifetime_min.is_finite() || args.lifetime_min <= 0.0 {
216        args.lifetime_min = 0.001;
217    }
218    if !args.lifetime_max.is_finite() || args.lifetime_max < args.lifetime_min {
219        args.lifetime_max = args.lifetime_min;
220    }
221    args.spawn_rate = args.spawn_rate.max(0.0);
222    args.max_particles = args.max_particles.clamp(1, 65_536);
223    args.size_start = args.size_start.max(0.0);
224    args.size_end = args.size_end.max(0.0);
225    for c in args.color_start.iter_mut().chain(args.color_end.iter_mut()) {
226        if !c.is_finite() {
227            *c = 0.0;
228        }
229    }
230    args
231}
232
233/// Clamp a `VolumetricFog`'s authored fields into their valid ranges.
234pub fn volumetric_fog(mut args: VolumetricFog) -> VolumetricFog {
235    // Density / falloff / ambient floor at 0; max_distance must stay
236    // positive so the gfx-side resolver does not divide by zero when
237    // computing the per-step length.
238    args.density = args.density.max(0.0);
239    args.height_falloff = args.height_falloff.max(0.0);
240    args.ambient = args.ambient.max(0.0);
241    if args.max_distance <= 0.0 || !args.max_distance.is_finite() {
242        args.max_distance = 1.0;
243    }
244    // Henyey-Greenstein blows up at |g| = 1; clamp inside the open
245    // interval so the closed-form `(1 - g²)` factor stays positive.
246    args.phase_g = args.phase_g.clamp(-0.95, 0.95);
247    args
248}
249
250/// Clamp an `InstancedProp`'s authored fields into their valid ranges.
251pub fn instanced_prop(mut args: InstancedProp) -> InstancedProp {
252    args.cull_distance = args.cull_distance.max(0.0);
253    args
254}
255
256/// Clamp a `VoxelChunk`'s authored fields into their valid ranges.
257pub fn voxel_chunk(mut args: VoxelChunk) -> VoxelChunk {
258    args.block_size = args.block_size.max(0.0);
259    if args.lod_levels == 0 {
260        args.lod_levels = 1;
261    }
262    args.lod_levels = args.lod_levels.min(8);
263    args
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn material_clamps_and_see_through_implies_transparent() {
272        let m = material(Material {
273            roughness: 2.0,
274            metallic: -1.0,
275            opacity: 1.5,
276            see_through: true,
277            ..Material::default()
278        });
279        assert_eq!(m.roughness, 1.0);
280        assert_eq!(m.metallic, 0.0);
281        assert_eq!(m.opacity, 1.0);
282        assert!(m.transparent);
283    }
284
285    #[test]
286    fn water_surface_clamps_subdivisions_and_guarantees_a_wave() {
287        let w = water_surface(WaterSurface {
288            subdivisions: 3,
289            waves: alloc::vec::Vec::new(),
290            ..WaterSurface::default()
291        });
292        assert_eq!(w.subdivisions, 8);
293        assert_eq!(w.waves.len(), 1);
294    }
295
296    #[test]
297    fn lights_floor_intensity_and_range_at_zero() {
298        let p = point_light(PointLight {
299            intensity: -2.0,
300            range: -1.0,
301            ..PointLight::default()
302        });
303        assert_eq!((p.intensity, p.range), (0.0, 0.0));
304        let d = directional_light(DirectionalLight {
305            intensity: -1.0,
306            ..DirectionalLight::default()
307        });
308        assert_eq!(d.intensity, 0.0);
309    }
310
311    #[test]
312    fn path_extension_matches_file_name_semantics() {
313        assert_eq!(path_extension("shaders/blob.metal"), "metal");
314        assert_eq!(path_extension("blob.hlsl"), "hlsl");
315        assert_eq!(path_extension("dir.v2/shader"), "");
316        assert_eq!(path_extension(".hidden"), "");
317        assert_eq!(path_extension("noext"), "");
318    }
319}