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/// An append-only registry handle: deformers are registered once and kept for
143/// the session.
144#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
145pub struct DeformerId(pub(crate) usize);
146
147impl DeformerId {
148    /// Returns the slot index the registry assigned at registration.
149    pub fn slot(&self) -> usize {
150        self.0
151    }
152}
153
154/// Stored registration: keeps the descriptor (for re-composition on device
155/// reset) plus the slot index that owns this deformer.
156#[derive(Clone, Debug)]
157pub(crate) struct StoredDeformer {
158    pub desc: DeformerDesc,
159    pub slot: usize,
160}
161
162/// Mesh-family base shaders that carry the deformation hook contract.
163pub(crate) const MESH_FAMILY_SHADERS: &[&str] = &[
164    "mesh.wgsl",
165    "mesh_oit.wgsl",
166    "mesh_instanced.wgsl",
167    "mesh_instanced_oit.wgsl",
168    "shadow.wgsl",
169    "outline_mask.wgsl",
170];
171
172fn shader_source(name: &str) -> Option<&'static str> {
173    SHADERS.iter().find(|s| s.name == name).map(|s| s.source)
174}
175
176/// Build the slot call sequence to splice into a stage marker region.
177///
178/// Each registered deformer at `stage` is emitted as:
179///
180/// ```text
181/// if ((ctx.flags & (1u << SLOT)) != 0u) {
182///     out = <name>__deform(out, ctx);
183/// }
184/// ```
185fn build_stage_calls(stored: &[StoredDeformer], stage: DeformStage) -> String {
186    let mut entries: Vec<&StoredDeformer> =
187        stored.iter().filter(|d| d.desc.stage == stage).collect();
188    entries.sort_by(|a, b| {
189        a.desc
190            .priority
191            .cmp(&b.desc.priority)
192            .then_with(|| a.desc.name.cmp(b.desc.name))
193    });
194    let mut out = String::new();
195    for d in entries {
196        out.push_str(&format!(
197            "    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",
198            slot = d.slot,
199            name = d.desc.name,
200        ));
201    }
202    out
203}
204
205/// Rewrite the slot marker region for one stage with the provided call block.
206fn rewrite_marker(source: &str, marker: &str, body: &str) -> Option<String> {
207    let open = format!("// <viewport-deform-slots:{marker}>");
208    let close = format!("// </viewport-deform-slots:{marker}>");
209    let open_idx = source.find(&open)?;
210    let close_idx = source.find(&close)?;
211    if close_idx < open_idx {
212        return None;
213    }
214    let mut out = String::with_capacity(source.len() + body.len());
215    out.push_str(&source[..open_idx + open.len()]);
216    out.push('\n');
217    out.push_str(body);
218    out.push_str(&source[close_idx..]);
219    Some(out)
220}
221
222/// Prefix every top-level `fn`, `struct`, `var`, `let`, `const`, and `alias`
223/// identifier appearing in `body` with `<name>__`, mapping references inside
224/// the same body so they stay self-consistent. Simple regex-free walker:
225/// finds `fn <ident>(`, `struct <ident>`, `let <ident>`, `const <ident>`
226/// declarations and rewrites uses of the same identifier within the body.
227fn identifier_prefix(name: &str, body: &str) -> String {
228    let mut decls: Vec<String> = Vec::new();
229    for tok in ["fn ", "struct ", "alias ", "const ", "var "] {
230        let mut search_from = 0;
231        while let Some(rel) = body[search_from..].find(tok) {
232            let i = search_from + rel + tok.len();
233            let rest = &body[i..];
234            let end = rest
235                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
236                .unwrap_or(rest.len());
237            if end > 0 {
238                let ident = rest[..end].to_string();
239                if !ident.is_empty() && !decls.contains(&ident) {
240                    decls.push(ident);
241                }
242            }
243            search_from = i + end;
244        }
245    }
246    // Replace whole-word occurrences of each declared identifier with the
247    // prefixed form. Process longest-first so substring matches don't shadow.
248    decls.sort_by_key(|s| std::cmp::Reverse(s.len()));
249    let mut out = body.to_string();
250    for ident in &decls {
251        let prefixed = format!("{name}__{ident}");
252        out = replace_whole_word(&out, ident, &prefixed);
253    }
254    out
255}
256
257fn replace_whole_word(haystack: &str, needle: &str, replacement: &str) -> String {
258    if needle.is_empty() {
259        return haystack.to_string();
260    }
261    let bytes = haystack.as_bytes();
262    let needle_bytes = needle.as_bytes();
263    let mut out = String::with_capacity(haystack.len());
264    let mut i = 0;
265    while i < bytes.len() {
266        if i + needle_bytes.len() <= bytes.len()
267            && &bytes[i..i + needle_bytes.len()] == needle_bytes
268        {
269            let before_ok = i == 0 || {
270                let c = bytes[i - 1] as char;
271                !(c.is_alphanumeric() || c == '_')
272            };
273            let after_idx = i + needle_bytes.len();
274            let after_ok = after_idx == bytes.len() || {
275                let c = bytes[after_idx] as char;
276                !(c.is_alphanumeric() || c == '_')
277            };
278            if before_ok && after_ok {
279                out.push_str(replacement);
280                i = after_idx;
281                continue;
282            }
283        }
284        out.push(bytes[i] as char);
285        i += 1;
286    }
287    out
288}
289
290/// Compose one shader's preprocessed source with the registered deformers.
291/// Splices every registered body (identifier-prefixed) into the shader after
292/// the `deform.wgsl` include, then rewrites the two stage markers to call
293/// each body inside a flag-bit branch.
294pub(crate) fn compose_shader(base: &str, stored: &[StoredDeformer]) -> String {
295    let mut bodies = String::new();
296    for d in stored {
297        bodies.push_str(&format!(
298            "\n// deformer: {name} (slot {slot}, stage {stage:?})\n",
299            name = d.desc.name,
300            slot = d.slot,
301            stage = d.desc.stage,
302        ));
303        bodies.push_str(&identifier_prefix(d.desc.name, &d.desc.wgsl_body));
304        bodies.push('\n');
305    }
306
307    // Splice bodies in just before the first viewport_deform_object_space fn
308    // definition (which is inside the deform.wgsl include).
309    let anchor = "fn viewport_deform_object_space(";
310    let with_bodies = match base.find(anchor) {
311        Some(idx) => {
312            let mut s = String::with_capacity(base.len() + bodies.len());
313            s.push_str(&base[..idx]);
314            s.push_str(&bodies);
315            s.push_str(&base[idx..]);
316            s
317        }
318        None => return base.to_string(),
319    };
320
321    let object_calls = build_stage_calls(stored, DeformStage::ObjectSpace);
322    let world_calls = build_stage_calls(stored, DeformStage::WorldSpace);
323
324    let after_obj = rewrite_marker(&with_bodies, "object", &object_calls).unwrap_or(with_bodies);
325    rewrite_marker(&after_obj, "world", &world_calls).unwrap_or(after_obj)
326}
327
328/// Assign a slot in the host range `[0, DEFORM_SLOT_COUNT_PUB)` to a new
329/// descriptor. Returns the lowest unused host slot or `DeformSlotsExhausted`
330/// when the host range is full. Internal in-crate deformers use
331/// [`allocate_internal_slot`] instead.
332pub(crate) fn allocate_slot(stored: &[StoredDeformer]) -> ViewportResult<usize> {
333    let mut used = 0u32;
334    for d in stored {
335        used |= 1u32 << d.slot;
336    }
337    for slot in 0..REGISTRY_INTERNAL_SLOT_START {
338        if used & (1u32 << slot) == 0 {
339            return Ok(slot);
340        }
341    }
342    Err(ViewportError::DeformSlotsExhausted {
343        max: REGISTRY_INTERNAL_SLOT_START,
344    })
345}
346
347/// Assign a slot in the internal range
348/// `[DEFORM_SLOT_COUNT_PUB, REGISTRY_SLOT_COUNT)`. Used at renderer
349/// construction to register the in-crate deformers (skinning, displacement)
350/// without consuming a host slot.
351#[allow(dead_code)]
352pub(crate) fn allocate_internal_slot(stored: &[StoredDeformer]) -> ViewportResult<usize> {
353    let mut used = 0u32;
354    for d in stored {
355        used |= 1u32 << d.slot;
356    }
357    for slot in REGISTRY_INTERNAL_SLOT_START..REGISTRY_SLOT_COUNT {
358        if used & (1u32 << slot) == 0 {
359            return Ok(slot);
360        }
361    }
362    Err(ViewportError::DeformSlotsExhausted {
363        max: REGISTRY_SLOT_COUNT - REGISTRY_INTERNAL_SLOT_START,
364    })
365}
366
367/// Validate `name` is a fresh, non-empty WGSL-ish identifier.
368pub(crate) fn validate_name(stored: &[StoredDeformer], name: &str) -> ViewportResult<()> {
369    if name.is_empty()
370        || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
371        || name
372            .chars()
373            .next()
374            .map(|c| c.is_ascii_digit())
375            .unwrap_or(true)
376    {
377        return Err(ViewportError::DeformShaderInvalid {
378            reason: "deformer name must be a non-empty WGSL identifier (letters, digits, underscore; not starting with a digit)".to_string(),
379        });
380    }
381    if stored.iter().any(|d| d.desc.name == name) {
382        return Err(ViewportError::DeformNameTaken {
383            name: name.to_string(),
384        });
385    }
386    Ok(())
387}
388
389/// Run wgpu's shader-module path under an error scope. Returns `Ok(module)`
390/// on success or a populated `DeformShaderInvalid` otherwise.
391pub(crate) fn validate_with_wgpu(
392    device: &wgpu::Device,
393    label: &str,
394    source: &str,
395) -> ViewportResult<wgpu::ShaderModule> {
396    device.push_error_scope(wgpu::ErrorFilter::Validation);
397    let module = crate::resources::builders::wgsl_module(device, label, source);
398    let captured = block_on_simple(device.pop_error_scope());
399    if let Some(err) = captured {
400        return Err(ViewportError::DeformShaderInvalid {
401            reason: format!("{label}: {err}"),
402        });
403    }
404    Ok(module)
405}
406
407/// Look up a preprocessed shader source by name. Returns `None` for unknown
408/// shader names (callers treat this as an internal error).
409pub(crate) fn lookup_source(name: &str) -> Option<&'static str> {
410    shader_source(name)
411}
412
413/// Tiny sync executor that polls a future until it resolves. wgpu's
414/// `pop_error_scope` resolves on the next driver poll, which the device
415/// itself drives; spinning here is fine because validation completes
416/// without going through the device's command queue.
417fn block_on_simple<F: std::future::Future>(mut fut: F) -> F::Output {
418    use std::pin::Pin;
419    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
420
421    const VTABLE: RawWakerVTable = RawWakerVTable::new(
422        |_| RawWaker::new(std::ptr::null(), &VTABLE),
423        |_| {},
424        |_| {},
425        |_| {},
426    );
427    let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
428    let mut cx = Context::from_waker(&waker);
429    // SAFETY: we own `fut` on the stack and never move it after this point.
430    let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
431    loop {
432        if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
433            return v;
434        }
435        std::thread::yield_now();
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn desc(name: &'static str, stage: DeformStage, priority: i32, body: &str) -> StoredDeformer {
448        StoredDeformer {
449            desc: DeformerDesc {
450                name,
451                stage,
452                priority,
453                wgsl_body: body.to_string(),
454                per_vertex_stride: 4,
455            },
456            slot: 0,
457        }
458    }
459
460    #[test]
461    fn allocate_slot_walks_lowest_free_index() {
462        let mut stored = Vec::<StoredDeformer>::new();
463        assert_eq!(allocate_slot(&stored).unwrap(), 0);
464        let mut d = desc("a", DeformStage::ObjectSpace, 0, "");
465        d.slot = 0;
466        stored.push(d);
467        assert_eq!(allocate_slot(&stored).unwrap(), 1);
468        let mut d = desc("b", DeformStage::ObjectSpace, 0, "");
469        d.slot = 2;
470        stored.push(d);
471        assert_eq!(allocate_slot(&stored).unwrap(), 1);
472    }
473
474    #[test]
475    fn allocate_slot_returns_exhausted_when_host_range_full() {
476        let mut stored = Vec::<StoredDeformer>::new();
477        for slot in 0..REGISTRY_INTERNAL_SLOT_START {
478            let mut d = desc(
479                Box::leak(format!("d{slot}").into_boxed_str()),
480                DeformStage::ObjectSpace,
481                0,
482                "",
483            );
484            d.slot = slot;
485            stored.push(d);
486        }
487        let err = allocate_slot(&stored).unwrap_err();
488        assert!(matches!(
489            err,
490            ViewportError::DeformSlotsExhausted { max } if max == REGISTRY_INTERNAL_SLOT_START
491        ));
492    }
493
494    #[test]
495    fn allocate_internal_slot_uses_reserved_range() {
496        let stored = Vec::<StoredDeformer>::new();
497        let slot = allocate_internal_slot(&stored).unwrap();
498        assert!(slot >= REGISTRY_INTERNAL_SLOT_START && slot < REGISTRY_SLOT_COUNT);
499    }
500
501    #[test]
502    fn validate_name_rejects_duplicate() {
503        let mut stored = Vec::<StoredDeformer>::new();
504        stored.push(desc("wind", DeformStage::WorldSpace, 0, ""));
505        let err = validate_name(&stored, "wind").unwrap_err();
506        assert!(matches!(err, ViewportError::DeformNameTaken { .. }));
507    }
508
509    #[test]
510    fn validate_name_rejects_invalid_identifier() {
511        let stored = Vec::<StoredDeformer>::new();
512        for bad in &["", "1wind", "wind!", " wind"] {
513            assert!(matches!(
514                validate_name(&stored, bad),
515                Err(ViewportError::DeformShaderInvalid { .. })
516            ));
517        }
518        assert!(validate_name(&stored, "wind_2").is_ok());
519    }
520
521    #[test]
522    fn identifier_prefix_renames_locals() {
523        let body = "fn deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex {\n    return v;\n}\nfn helper() -> f32 { return 1.0; }\n";
524        let out = identifier_prefix("wind", body);
525        assert!(out.contains("fn wind__deform("));
526        assert!(out.contains("fn wind__helper()"));
527        // DeformVertex / DeformContext are external: must not get prefixed.
528        assert!(out.contains("DeformVertex"));
529        assert!(!out.contains("wind__DeformVertex"));
530    }
531
532    #[test]
533    fn build_stage_calls_orders_by_priority_then_name() {
534        let mut stored = vec![
535            desc("z_late", DeformStage::ObjectSpace, 10, ""),
536            desc("a_early", DeformStage::ObjectSpace, -5, ""),
537            desc("m_mid", DeformStage::ObjectSpace, 0, ""),
538            desc("world_only", DeformStage::WorldSpace, 0, ""),
539        ];
540        for (i, d) in stored.iter_mut().enumerate() {
541            d.slot = i;
542        }
543        let obj = build_stage_calls(&stored, DeformStage::ObjectSpace);
544        let pos_a = obj.find("a_early__deform").unwrap();
545        let pos_m = obj.find("m_mid__deform").unwrap();
546        let pos_z = obj.find("z_late__deform").unwrap();
547        assert!(pos_a < pos_m);
548        assert!(pos_m < pos_z);
549        assert!(!obj.contains("world_only__deform"));
550        let w = build_stage_calls(&stored, DeformStage::WorldSpace);
551        assert!(w.contains("world_only__deform"));
552    }
553
554    #[test]
555    fn compose_shader_zero_deformers_is_identity_passthrough() {
556        let Some(src) = lookup_source("mesh.wgsl") else {
557            // shader catalog not yet populated in test target; skip.
558            return;
559        };
560        let composed = compose_shader(src, &[]);
561        assert!(composed.contains("// <viewport-deform-slots:object>"));
562        // No deformer calls injected.
563        assert!(!composed.contains("__deform("));
564    }
565
566    #[test]
567    fn compose_shader_splices_one_body_and_wires_call() {
568        let Some(src) = lookup_source("mesh.wgsl") else {
569            return;
570        };
571        let mut d = desc(
572            "wind",
573            DeformStage::WorldSpace,
574            0,
575            "fn deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex {\n    return v;\n}\n",
576        );
577        d.slot = 2;
578        let composed = compose_shader(src, &std::slice::from_ref(&d));
579        assert!(composed.contains("fn wind__deform("));
580        // World marker wired, object marker still empty.
581        assert!(composed.contains("out = wind__deform(out, ctx_wind);"));
582        assert!(composed.contains("ctx.flags & (1u << 2u)"));
583        assert!(composed.contains("ctx_wind.slot = 2u;"));
584        // Identifier of helper struct DeformVertex stays unprefixed.
585        assert!(composed.contains("DeformVertex"));
586    }
587}