Skip to main content

viewport_lib/resources/mesh_sidecar/
registry.rs

1//! Deformer registry: composes registered `wgsl_body` strings into the
2//! mesh-family shader sources and validates the result before the renderer
3//! swaps pipelines.
4//!
5//! At registration time the descriptor's body is identifier-prefixed by the
6//! deformer name, spliced above the shader's entry points, and called from
7//! the appropriate stage hook (`viewport_deform_object_space` or
8//! `viewport_deform_world_space`) inside a per-slot flag branch. Each
9//! affected shader is validated by creating a throwaway shader module under
10//! an error scope; failures roll back the registration with the naga
11//! message returned to the caller.
12
13use crate::error::{ViewportError, ViewportResult};
14use crate::renderer::shader_hashes::SHADERS;
15
16/// Where in the deformation chain a deformer executes.
17#[derive(Copy, Clone, Debug, Eq, PartialEq)]
18pub enum DeformStage {
19    /// Before the model transform. Positions are object space.
20    ObjectSpace,
21    /// After the model transform. Positions are world space.
22    WorldSpace,
23}
24
25/// Hardware-side maximum slot count, mirrored from
26/// [`crate::resources::mesh_sidecar::deform::DEFORM_SLOT_COUNT`] so the
27/// registry can validate against it without pulling the sidecar module into
28/// the public API.
29#[allow(dead_code)]
30pub(crate) const REGISTRY_SLOT_COUNT: usize =
31    crate::resources::mesh_sidecar::deform::DEFORM_SLOT_COUNT;
32
33/// First slot index reserved for internal in-crate deformers. Slots in
34/// `[0, DEFORM_SLOT_COUNT_PUB)` are allocated to host registrations through
35/// `register_deformer`; slots in `[DEFORM_SLOT_COUNT_PUB, REGISTRY_SLOT_COUNT)`
36/// are reserved for shipped-in-crate deformers (skinning, displacement) that
37/// register at renderer construction.
38pub(crate) const REGISTRY_INTERNAL_SLOT_START: usize =
39    crate::resources::mesh_sidecar::deform::DEFORM_INTERNAL_SLOT_START;
40
41/// Maximum number of deformer slots the registry exposes to hosts.
42pub const DEFORM_SLOT_COUNT_PUB: usize = REGISTRY_INTERNAL_SLOT_START;
43
44/// Number of `vec4<f32>` parameter words per slot in the shared deformation
45/// header uniform.
46pub const DEFORM_PARAMS_PER_SLOT_PUB: usize =
47    crate::resources::mesh_sidecar::deform::DEFORM_PARAMS_PER_SLOT;
48
49/// Description of a deformer to register against the mesh shader family.
50///
51/// `wgsl_body` defines:
52///
53/// ```text
54/// fn <name>_deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex { ... }
55/// ```
56///
57/// The composer prefixes every top-level identifier it finds inside
58/// `wgsl_body` with `<name>__` to keep multiple deformers independent. The
59/// body may read `deform_data` / `deform_instance_data` via the
60/// `deform_read_*` helpers in `deform.wgsl`, and read its slot's parameter
61/// region via `deform_header.slot_params[ctx.slot * 4 + k]`.
62///
63/// # Hook contract
64///
65/// The shipped `deform.wgsl` declares:
66///
67/// ```text
68/// struct DeformVertex {
69///     position: vec3<f32>,
70///     normal: vec3<f32>,
71///     vertex_index: u32,
72/// };
73///
74/// struct DeformContext {
75///     model: mat4x4<f32>,
76///     object_origin: vec3<f32>,
77///     time_seconds: f32,
78///     flags: u32,
79///     slot: u32,
80/// };
81/// ```
82///
83/// These signatures are stable: additive changes only. Existing fields will
84/// not be renamed, retyped, or removed. New fields may be appended; bodies
85/// compiled against an older view continue to work because the WGSL struct
86/// init they don't touch is filled by the caller.
87///
88/// `position` is object-space on entry to an `ObjectSpace` body and
89/// world-space on entry to a `WorldSpace` body. `vertex_index` is the
90/// `@builtin(vertex_index)` from the vertex stage. `ctx.slot` is the slot
91/// the registry assigned to this deformer; use it to address `deform_data`
92/// and `deform_instance_data` rather than baking in a literal.
93///
94/// # Composition order
95///
96/// Deformers run in two stages: every [`DeformStage::ObjectSpace`] body
97/// first, then every [`DeformStage::WorldSpace`] body. Within a stage,
98/// bodies execute in ascending `priority` (lower runs first); ties break by
99/// `name`. The in-crate skinning deformer registers at
100/// `DEFORM_PRIORITY_SKINNING = -1000` so morph-class deformers registered
101/// at the default priority of `0` run after it. This ordering is part of
102/// the contract: a body that wants to run before skinning must register
103/// with a priority strictly less than `-1000`.
104///
105/// # Pass scope
106///
107/// Every registered deformer runs in every mesh-family pass: the main solid
108/// and transparent passes, the OIT accumulate pass, the instanced and
109/// instanced-OIT pipelines, the shadow pass, and the outline-mask pass. A
110/// deformer cannot opt out of a single pass; the composed pipeline is
111/// shared across them.
112///
113/// In particular: shadows always deform. A skinned or wind-swept mesh
114/// casts a shadow that matches its deformed silhouette. The outline mask
115/// follows the deformed silhouette for the same reason. GPU picking, which
116/// uses the same mesh family, picks the deformed mesh.
117///
118/// Bodies that have nothing to do for a draw (no slot data attached,
119/// flag bit clear) are gated off by the per-slot flag branch the composer
120/// wraps the call in, so the cost on undeformed draws is the branch
121/// predicate, not the body.
122#[derive(Clone, Debug)]
123pub struct DeformerDesc {
124    /// Deformer name. Must be a valid WGSL identifier and unique across the
125    /// registry. The composer uses it as the prefix for every top-level
126    /// declaration spliced from `wgsl_body`.
127    pub name: &'static str,
128    /// Which side of the model transform the body runs on.
129    pub stage: DeformStage,
130    /// Execution order within the stage; lower runs first. Ties break by name.
131    pub priority: i32,
132    /// WGSL body defining `fn <name>_deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex`
133    /// plus any helper declarations. All top-level identifiers in this body
134    /// are prefixed with `<name>__` at composition time.
135    pub wgsl_body: String,
136    /// Bytes per vertex in the slot storage buffer (validated on attach).
137    pub per_vertex_stride: u32,
138}
139
140/// Handle to a registered deformer. The inner index is the slot the registry
141/// assigned; only used internally to address the storage buffer and flag bit.
142#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
143pub struct DeformerId(pub(crate) usize);
144
145impl DeformerId {
146    /// Returns the slot index the registry assigned at registration.
147    pub fn slot(&self) -> usize {
148        self.0
149    }
150}
151
152/// Stored registration: keeps the descriptor (for re-composition on device
153/// reset) plus the slot index that owns this deformer.
154#[derive(Clone, Debug)]
155pub(crate) struct StoredDeformer {
156    pub desc: DeformerDesc,
157    pub slot: usize,
158}
159
160/// Mesh-family base shaders that carry the deformation hook contract.
161pub(crate) const MESH_FAMILY_SHADERS: &[&str] = &[
162    "mesh.wgsl",
163    "mesh_oit.wgsl",
164    "mesh_instanced.wgsl",
165    "mesh_instanced_oit.wgsl",
166    "shadow.wgsl",
167    "outline_mask.wgsl",
168];
169
170fn shader_source(name: &str) -> Option<&'static str> {
171    SHADERS.iter().find(|s| s.name == name).map(|s| s.source)
172}
173
174/// Build the slot call sequence to splice into a stage marker region.
175///
176/// Each registered deformer at `stage` is emitted as:
177///
178/// ```text
179/// if ((ctx.flags & (1u << SLOT)) != 0u) {
180///     out = <name>__deform(out, ctx);
181/// }
182/// ```
183fn build_stage_calls(stored: &[StoredDeformer], stage: DeformStage) -> String {
184    let mut entries: Vec<&StoredDeformer> =
185        stored.iter().filter(|d| d.desc.stage == stage).collect();
186    entries.sort_by(|a, b| {
187        a.desc
188            .priority
189            .cmp(&b.desc.priority)
190            .then_with(|| a.desc.name.cmp(b.desc.name))
191    });
192    let mut out = String::new();
193    for d in entries {
194        out.push_str(&format!(
195            "    if ((ctx.flags & (1u << {slot}u)) != 0u) {{\n        var ctx_{name} = ctx;\n        ctx_{name}.slot = {slot}u;\n        out = {name}__deform(out, ctx_{name});\n    }}\n",
196            slot = d.slot,
197            name = d.desc.name,
198        ));
199    }
200    out
201}
202
203/// Rewrite the slot marker region for one stage with the provided call block.
204fn rewrite_marker(source: &str, marker: &str, body: &str) -> Option<String> {
205    let open = format!("// <viewport-deform-slots:{marker}>");
206    let close = format!("// </viewport-deform-slots:{marker}>");
207    let open_idx = source.find(&open)?;
208    let close_idx = source.find(&close)?;
209    if close_idx < open_idx {
210        return None;
211    }
212    let mut out = String::with_capacity(source.len() + body.len());
213    out.push_str(&source[..open_idx + open.len()]);
214    out.push('\n');
215    out.push_str(body);
216    out.push_str(&source[close_idx..]);
217    Some(out)
218}
219
220/// Prefix every top-level `fn`, `struct`, `var`, `let`, `const`, and `alias`
221/// identifier appearing in `body` with `<name>__`, mapping references inside
222/// the same body so they stay self-consistent. Simple regex-free walker:
223/// finds `fn <ident>(`, `struct <ident>`, `let <ident>`, `const <ident>`
224/// declarations and rewrites uses of the same identifier within the body.
225fn identifier_prefix(name: &str, body: &str) -> String {
226    let mut decls: Vec<String> = Vec::new();
227    for tok in ["fn ", "struct ", "alias ", "const ", "var "] {
228        let mut search_from = 0;
229        while let Some(rel) = body[search_from..].find(tok) {
230            let i = search_from + rel + tok.len();
231            let rest = &body[i..];
232            let end = rest
233                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
234                .unwrap_or(rest.len());
235            if end > 0 {
236                let ident = rest[..end].to_string();
237                if !ident.is_empty() && !decls.contains(&ident) {
238                    decls.push(ident);
239                }
240            }
241            search_from = i + end;
242        }
243    }
244    // Replace whole-word occurrences of each declared identifier with the
245    // prefixed form. Process longest-first so substring matches don't shadow.
246    decls.sort_by_key(|s| std::cmp::Reverse(s.len()));
247    let mut out = body.to_string();
248    for ident in &decls {
249        let prefixed = format!("{name}__{ident}");
250        out = replace_whole_word(&out, ident, &prefixed);
251    }
252    out
253}
254
255fn replace_whole_word(haystack: &str, needle: &str, replacement: &str) -> String {
256    if needle.is_empty() {
257        return haystack.to_string();
258    }
259    let bytes = haystack.as_bytes();
260    let needle_bytes = needle.as_bytes();
261    let mut out = String::with_capacity(haystack.len());
262    let mut i = 0;
263    while i < bytes.len() {
264        if i + needle_bytes.len() <= bytes.len()
265            && &bytes[i..i + needle_bytes.len()] == needle_bytes
266        {
267            let before_ok = i == 0 || {
268                let c = bytes[i - 1] as char;
269                !(c.is_alphanumeric() || c == '_')
270            };
271            let after_idx = i + needle_bytes.len();
272            let after_ok = after_idx == bytes.len() || {
273                let c = bytes[after_idx] as char;
274                !(c.is_alphanumeric() || c == '_')
275            };
276            if before_ok && after_ok {
277                out.push_str(replacement);
278                i = after_idx;
279                continue;
280            }
281        }
282        out.push(bytes[i] as char);
283        i += 1;
284    }
285    out
286}
287
288/// Compose one shader's preprocessed source with the registered deformers.
289/// Splices every registered body (identifier-prefixed) into the shader after
290/// the `deform.wgsl` include, then rewrites the two stage markers to call
291/// each body inside a flag-bit branch.
292pub(crate) fn compose_shader(base: &str, stored: &[StoredDeformer]) -> String {
293    let mut bodies = String::new();
294    for d in stored {
295        bodies.push_str(&format!(
296            "\n// deformer: {name} (slot {slot}, stage {stage:?})\n",
297            name = d.desc.name,
298            slot = d.slot,
299            stage = d.desc.stage,
300        ));
301        bodies.push_str(&identifier_prefix(d.desc.name, &d.desc.wgsl_body));
302        bodies.push('\n');
303    }
304
305    // Splice bodies in just before the first viewport_deform_object_space fn
306    // definition (which is inside the deform.wgsl include).
307    let anchor = "fn viewport_deform_object_space(";
308    let with_bodies = match base.find(anchor) {
309        Some(idx) => {
310            let mut s = String::with_capacity(base.len() + bodies.len());
311            s.push_str(&base[..idx]);
312            s.push_str(&bodies);
313            s.push_str(&base[idx..]);
314            s
315        }
316        None => return base.to_string(),
317    };
318
319    let object_calls = build_stage_calls(stored, DeformStage::ObjectSpace);
320    let world_calls = build_stage_calls(stored, DeformStage::WorldSpace);
321
322    let after_obj = rewrite_marker(&with_bodies, "object", &object_calls).unwrap_or(with_bodies);
323    rewrite_marker(&after_obj, "world", &world_calls).unwrap_or(after_obj)
324}
325
326/// Assign a slot in the host range `[0, DEFORM_SLOT_COUNT_PUB)` to a new
327/// descriptor. Returns the lowest unused host slot or `DeformSlotsExhausted`
328/// when the host range is full. Internal in-crate deformers use
329/// [`allocate_internal_slot`] instead.
330pub(crate) fn allocate_slot(stored: &[StoredDeformer]) -> ViewportResult<usize> {
331    let mut used = 0u32;
332    for d in stored {
333        used |= 1u32 << d.slot;
334    }
335    for slot in 0..REGISTRY_INTERNAL_SLOT_START {
336        if used & (1u32 << slot) == 0 {
337            return Ok(slot);
338        }
339    }
340    Err(ViewportError::DeformSlotsExhausted {
341        max: REGISTRY_INTERNAL_SLOT_START,
342    })
343}
344
345/// Assign a slot in the internal range
346/// `[DEFORM_SLOT_COUNT_PUB, REGISTRY_SLOT_COUNT)`. Used at renderer
347/// construction to register the in-crate deformers (skinning, displacement)
348/// without consuming a host slot.
349#[allow(dead_code)]
350pub(crate) fn allocate_internal_slot(stored: &[StoredDeformer]) -> ViewportResult<usize> {
351    let mut used = 0u32;
352    for d in stored {
353        used |= 1u32 << d.slot;
354    }
355    for slot in REGISTRY_INTERNAL_SLOT_START..REGISTRY_SLOT_COUNT {
356        if used & (1u32 << slot) == 0 {
357            return Ok(slot);
358        }
359    }
360    Err(ViewportError::DeformSlotsExhausted {
361        max: REGISTRY_SLOT_COUNT - REGISTRY_INTERNAL_SLOT_START,
362    })
363}
364
365/// Validate `name` is a fresh, non-empty WGSL-ish identifier.
366pub(crate) fn validate_name(stored: &[StoredDeformer], name: &str) -> ViewportResult<()> {
367    if name.is_empty()
368        || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
369        || name
370            .chars()
371            .next()
372            .map(|c| c.is_ascii_digit())
373            .unwrap_or(true)
374    {
375        return Err(ViewportError::DeformShaderInvalid {
376            reason: "deformer name must be a non-empty WGSL identifier (letters, digits, underscore; not starting with a digit)".to_string(),
377        });
378    }
379    if stored.iter().any(|d| d.desc.name == name) {
380        return Err(ViewportError::DeformNameTaken {
381            name: name.to_string(),
382        });
383    }
384    Ok(())
385}
386
387/// Run wgpu's shader-module path under an error scope. Returns `Ok(module)`
388/// on success or a populated `DeformShaderInvalid` otherwise.
389pub(crate) fn validate_with_wgpu(
390    device: &wgpu::Device,
391    label: &str,
392    source: &str,
393) -> ViewportResult<wgpu::ShaderModule> {
394    device.push_error_scope(wgpu::ErrorFilter::Validation);
395    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
396        label: Some(label),
397        source: wgpu::ShaderSource::Wgsl(source.into()),
398    });
399    let captured = block_on_simple(device.pop_error_scope());
400    if let Some(err) = captured {
401        return Err(ViewportError::DeformShaderInvalid {
402            reason: format!("{label}: {err}"),
403        });
404    }
405    Ok(module)
406}
407
408/// Look up a preprocessed shader source by name. Returns `None` for unknown
409/// shader names (callers treat this as an internal error).
410pub(crate) fn lookup_source(name: &str) -> Option<&'static str> {
411    shader_source(name)
412}
413
414/// Tiny sync executor that polls a future until it resolves. wgpu's
415/// `pop_error_scope` resolves on the next driver poll, which the device
416/// itself drives; spinning here is fine because validation completes
417/// without going through the device's command queue.
418fn block_on_simple<F: std::future::Future>(mut fut: F) -> F::Output {
419    use std::pin::Pin;
420    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
421
422    const VTABLE: RawWakerVTable = RawWakerVTable::new(
423        |_| RawWaker::new(std::ptr::null(), &VTABLE),
424        |_| {},
425        |_| {},
426        |_| {},
427    );
428    let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
429    let mut cx = Context::from_waker(&waker);
430    // SAFETY: we own `fut` on the stack and never move it after this point.
431    let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
432    loop {
433        if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
434            return v;
435        }
436        std::thread::yield_now();
437    }
438}
439
440// ---------------------------------------------------------------------------
441// Tests
442// ---------------------------------------------------------------------------
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn desc(name: &'static str, stage: DeformStage, priority: i32, body: &str) -> StoredDeformer {
449        StoredDeformer {
450            desc: DeformerDesc {
451                name,
452                stage,
453                priority,
454                wgsl_body: body.to_string(),
455                per_vertex_stride: 4,
456            },
457            slot: 0,
458        }
459    }
460
461    #[test]
462    fn allocate_slot_walks_lowest_free_index() {
463        let mut stored = Vec::<StoredDeformer>::new();
464        assert_eq!(allocate_slot(&stored).unwrap(), 0);
465        let mut d = desc("a", DeformStage::ObjectSpace, 0, "");
466        d.slot = 0;
467        stored.push(d);
468        assert_eq!(allocate_slot(&stored).unwrap(), 1);
469        let mut d = desc("b", DeformStage::ObjectSpace, 0, "");
470        d.slot = 2;
471        stored.push(d);
472        assert_eq!(allocate_slot(&stored).unwrap(), 1);
473    }
474
475    #[test]
476    fn allocate_slot_returns_exhausted_when_host_range_full() {
477        let mut stored = Vec::<StoredDeformer>::new();
478        for slot in 0..REGISTRY_INTERNAL_SLOT_START {
479            let mut d = desc(
480                Box::leak(format!("d{slot}").into_boxed_str()),
481                DeformStage::ObjectSpace,
482                0,
483                "",
484            );
485            d.slot = slot;
486            stored.push(d);
487        }
488        let err = allocate_slot(&stored).unwrap_err();
489        assert!(matches!(
490            err,
491            ViewportError::DeformSlotsExhausted { max } if max == REGISTRY_INTERNAL_SLOT_START
492        ));
493    }
494
495    #[test]
496    fn allocate_internal_slot_uses_reserved_range() {
497        let stored = Vec::<StoredDeformer>::new();
498        let slot = allocate_internal_slot(&stored).unwrap();
499        assert!(slot >= REGISTRY_INTERNAL_SLOT_START && slot < REGISTRY_SLOT_COUNT);
500    }
501
502    #[test]
503    fn validate_name_rejects_duplicate() {
504        let mut stored = Vec::<StoredDeformer>::new();
505        stored.push(desc("wind", DeformStage::WorldSpace, 0, ""));
506        let err = validate_name(&stored, "wind").unwrap_err();
507        assert!(matches!(err, ViewportError::DeformNameTaken { .. }));
508    }
509
510    #[test]
511    fn validate_name_rejects_invalid_identifier() {
512        let stored = Vec::<StoredDeformer>::new();
513        for bad in &["", "1wind", "wind!", " wind"] {
514            assert!(matches!(
515                validate_name(&stored, bad),
516                Err(ViewportError::DeformShaderInvalid { .. })
517            ));
518        }
519        assert!(validate_name(&stored, "wind_2").is_ok());
520    }
521
522    #[test]
523    fn identifier_prefix_renames_locals() {
524        let body = "fn deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex {\n    return v;\n}\nfn helper() -> f32 { return 1.0; }\n";
525        let out = identifier_prefix("wind", body);
526        assert!(out.contains("fn wind__deform("));
527        assert!(out.contains("fn wind__helper()"));
528        // DeformVertex / DeformContext are external: must not get prefixed.
529        assert!(out.contains("DeformVertex"));
530        assert!(!out.contains("wind__DeformVertex"));
531    }
532
533    #[test]
534    fn build_stage_calls_orders_by_priority_then_name() {
535        let mut stored = vec![
536            desc("z_late", DeformStage::ObjectSpace, 10, ""),
537            desc("a_early", DeformStage::ObjectSpace, -5, ""),
538            desc("m_mid", DeformStage::ObjectSpace, 0, ""),
539            desc("world_only", DeformStage::WorldSpace, 0, ""),
540        ];
541        for (i, d) in stored.iter_mut().enumerate() {
542            d.slot = i;
543        }
544        let obj = build_stage_calls(&stored, DeformStage::ObjectSpace);
545        let pos_a = obj.find("a_early__deform").unwrap();
546        let pos_m = obj.find("m_mid__deform").unwrap();
547        let pos_z = obj.find("z_late__deform").unwrap();
548        assert!(pos_a < pos_m);
549        assert!(pos_m < pos_z);
550        assert!(!obj.contains("world_only__deform"));
551        let w = build_stage_calls(&stored, DeformStage::WorldSpace);
552        assert!(w.contains("world_only__deform"));
553    }
554
555    #[test]
556    fn compose_shader_zero_deformers_is_identity_passthrough() {
557        let Some(src) = lookup_source("mesh.wgsl") else {
558            // shader catalog not yet populated in test target; skip.
559            return;
560        };
561        let composed = compose_shader(src, &[]);
562        assert!(composed.contains("// <viewport-deform-slots:object>"));
563        // No deformer calls injected.
564        assert!(!composed.contains("__deform("));
565    }
566
567    #[test]
568    fn compose_shader_splices_one_body_and_wires_call() {
569        let Some(src) = lookup_source("mesh.wgsl") else {
570            return;
571        };
572        let mut d = desc(
573            "wind",
574            DeformStage::WorldSpace,
575            0,
576            "fn deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex {\n    return v;\n}\n",
577        );
578        d.slot = 2;
579        let composed = compose_shader(src, &std::slice::from_ref(&d));
580        assert!(composed.contains("fn wind__deform("));
581        // World marker wired, object marker still empty.
582        assert!(composed.contains("out = wind__deform(out, ctx_wind);"));
583        assert!(composed.contains("ctx.flags & (1u << 2u)"));
584        assert!(composed.contains("ctx_wind.slot = 2u;"));
585        // Identifier of helper struct DeformVertex stays unprefixed.
586        assert!(composed.contains("DeformVertex"));
587    }
588}