Skip to main content

alpha2_render/
registry.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt::Write;
3
4use alpha2_gpu::CapabilityTier;
5
6use crate::effect::EffectKey;
7use crate::loop_bound::{BUDGET, MAX_LOOP_ITERATIONS, blank_comments, bound_for_loops};
8use crate::uniform::{PARAM_SLOT_BYTES, RUNTIME_UNIFORM_WORDS, param_uniform_bytes};
9
10use crate::{
11    DiagnosticSeverity, EffectDef, EffectId, EffectInputs, EffectKind, EffectResourceKind,
12    ParamSpec, ParamType, ParamUnit, ParamValue, Renderer, ResourceSpec, ShaderDiagnostic,
13    ShaderDiagnostics, UniformField, UniformLayout,
14};
15
16const MAX_REGISTERED_EFFECTS: usize = 256;
17const MAX_WGSL_BODY_BYTES: usize = 32 * 1024;
18const MAX_WGSL_NESTING: usize = 64;
19/// Operator characters one statement may hold. A flat chain such as
20/// `x+x+...+x` has no delimiters to nest, but it is as deep as it is long, and
21/// naga lowers it by recursing once per operand: about 2,500 operands
22/// overflow a 1 MiB stack (a Windows main thread, wasm32) and abort the
23/// process instead of failing the registration. Engine bodies stay under 32.
24const MAX_WGSL_STATEMENT_OPERATORS: usize = 256;
25/// The identifier prefix of everything the generated wrapper declares.
26const RESERVED_PREFIX: &str = "alpha2_";
27const MAX_EFFECT_RESOURCES: usize = 8;
28/// A resource's `ResourceMetadata_<name>` uniform: `domain_min` and
29/// `domain_max`, two `vec4f`.
30const RESOURCE_METADATA_BYTES: usize = 32;
31
32/// Scopes for every class of error that creating a shader module or a
33/// pipeline can raise.
34///
35/// Validation alone is not enough: `wgpu` hands an error only to a scope of
36/// its own class, and a backend compiler that rejects the code generated from
37/// valid WGSL (Metal's for MSL, DXC or FXC for HLSL, a GL driver or `ANGLE`
38/// for GLSL) reports an internal error, as a device out of memory reports its
39/// own class. Uncaught, either goes to the uncaptured-error handler and the
40/// effect registers with an invalid pipeline, which then invalidates every
41/// submission that draws it.
42struct CreationErrorScopes([wgpu::ErrorScopeGuard; 3]);
43
44impl CreationErrorScopes {
45    fn push(device: &wgpu::Device) -> Self {
46        Self([
47            device.push_error_scope(wgpu::ErrorFilter::Internal),
48            device.push_error_scope(wgpu::ErrorFilter::OutOfMemory),
49            device.push_error_scope(wgpu::ErrorFilter::Validation),
50        ])
51    }
52
53    /// Closes the scopes now, in the reverse order of their pushes, and
54    /// returns what they caught when the device answers.
55    fn pop(self) -> ScopeErrors {
56        let [internal, out_of_memory, validation] = self.0;
57        let validation = validation.pop();
58        let out_of_memory = out_of_memory.pop();
59        let internal = internal.pop();
60        Box::pin(async move {
61            [validation.await, out_of_memory.await, internal.await]
62                .into_iter()
63                .flatten()
64                .collect()
65        })
66    }
67}
68
69/// A device's answer still to come. Browser WebGPU answers on its own event
70/// loop, so these are not `Send` there; natively they are.
71#[cfg(not(target_arch = "wasm32"))]
72type Answer<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
73#[cfg(target_arch = "wasm32")]
74type Answer<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T>>>;
75
76type ScopeErrors = Answer<Vec<wgpu::Error>>;
77
78/// A runtime effect whose shader module and pipeline exist on the device,
79/// with the device's verdict on them still to come: [`Renderer::compile_effect`]
80/// made it, [`PendingEffect::compiled`] waits for the verdict, and
81/// [`Renderer::register_compiled`] adds what passed.
82///
83/// It borrows nothing from the renderer, so waiting for a browser's shader
84/// compiler (a GPU process round trip) leaves the renderer free to render,
85/// to compile other effects, or to be dropped.
86pub struct PendingEffect {
87    compiled: CompiledEffect,
88    compilation: Answer<wgpu::CompilationInfo>,
89    shader_errors: ScopeErrors,
90    pipeline_errors: ScopeErrors,
91}
92
93impl std::fmt::Debug for PendingEffect {
94    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        formatter
96            .debug_struct("PendingEffect")
97            .field("id", &self.compiled.id)
98            .finish_non_exhaustive()
99    }
100}
101
102impl PendingEffect {
103    /// Waits for the device's verdict on the shader and the pipeline.
104    ///
105    /// # Errors
106    /// Returns the shader's diagnostics when it failed to compile, else the
107    /// errors creating its pipeline raised.
108    pub async fn compiled(self) -> Result<CompiledEffect, ShaderDiagnostics> {
109        let mut diagnostics = compilation_diagnostics(self.compilation.await);
110        for error in self.shader_errors.await {
111            diagnostics.messages.push(ShaderDiagnostic {
112                severity: DiagnosticSeverity::Error,
113                message: error.to_string(),
114                line: None,
115                column: None,
116            });
117        }
118        // A pipeline made from a shader that failed fails too; the shader's own
119        // diagnostics say why.
120        if diagnostics
121            .messages
122            .iter()
123            .any(|message| message.severity == DiagnosticSeverity::Error)
124        {
125            return Err(diagnostics);
126        }
127        let errors = self.pipeline_errors.await;
128        if !errors.is_empty() {
129            return Err(ShaderDiagnostics {
130                messages: errors
131                    .into_iter()
132                    .map(|error| ShaderDiagnostic {
133                        severity: DiagnosticSeverity::Error,
134                        message: error.to_string(),
135                        line: None,
136                        column: None,
137                    })
138                    .collect(),
139            });
140        }
141        Ok(self.compiled)
142    }
143}
144
145/// A runtime effect the device compiled, ready for
146/// [`Renderer::register_compiled`] on the renderer that compiled it.
147pub struct CompiledEffect {
148    device: wgpu::Device,
149    id: EffectId,
150    effect: RegisteredEffect,
151}
152
153impl std::fmt::Debug for CompiledEffect {
154    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        formatter
156            .debug_struct("CompiledEffect")
157            .field("id", &self.id)
158            .finish_non_exhaustive()
159    }
160}
161
162impl CompiledEffect {
163    /// The identity the effect registers under.
164    #[must_use]
165    pub const fn id(&self) -> &EffectId {
166        &self.id
167    }
168}
169
170#[derive(Debug, Default)]
171pub(crate) struct EffectRegistry {
172    entries: HashMap<EffectId, RegisteredEffect>,
173    builtins: HashSet<EffectId>,
174    internal: HashSet<EffectId>,
175}
176
177impl EffectRegistry {
178    /// Refuses `id` when it is registered already or the registry is full.
179    fn check_room(&self, id: &EffectId) -> Result<(), ShaderDiagnostics> {
180        if self.entries.contains_key(id) {
181            return Err(ShaderDiagnostics::error(format!(
182                "effect {} version {} is already registered",
183                id.as_str(),
184                id.version()
185            )));
186        }
187        if self.entries.len() >= MAX_REGISTERED_EFFECTS {
188            return Err(ShaderDiagnostics::error(format!(
189                "effect registry is full (maximum {MAX_REGISTERED_EFFECTS})"
190            )));
191        }
192        Ok(())
193    }
194}
195
196#[derive(Debug)]
197pub(crate) struct RegisteredEffect {
198    pub(crate) definition: EffectDef,
199    pub(crate) uniform_layout: UniformLayout,
200    /// Whether the body can read the frame time ([`body_reads_time`]).
201    reads_time: bool,
202    _shader: wgpu::ShaderModule,
203    pub(crate) bind_group_layout: wgpu::BindGroupLayout,
204    pub(crate) pipeline: wgpu::RenderPipeline,
205    pub(crate) sampler: wgpu::Sampler,
206}
207
208impl Renderer {
209    /// Validates, compiles, and registers a runtime effect definition:
210    /// [`Self::compile_effect`], [`PendingEffect::compiled`], then
211    /// [`Self::register_compiled`].
212    ///
213    /// The returned future works on native and browser WebGPU backends because
214    /// compilation diagnostics are asynchronous in the WebGPU API. It holds
215    /// the renderer for as long as the device takes to answer; a host that
216    /// must use the renderer meanwhile calls the three steps itself.
217    ///
218    /// # Errors
219    /// Returns schema or WGSL diagnostics without mutating the registry.
220    pub async fn register_effect(
221        &mut self,
222        definition: EffectDef,
223    ) -> Result<EffectId, ShaderDiagnostics> {
224        let compiled = self.compile_effect(definition)?.compiled().await?;
225        self.register_compiled(compiled)
226    }
227
228    /// Validates a runtime effect definition and creates its shader module
229    /// and pipeline, without waiting for the device's verdict on them.
230    ///
231    /// Everything is created, and every error scope pushed and popped, within
232    /// this call, so registrations that overlap never share a scope.
233    ///
234    /// # Errors
235    /// Returns schema diagnostics, or refuses an effect already registered or
236    /// a full registry, without touching the device.
237    pub fn compile_effect(
238        &self,
239        definition: EffectDef,
240    ) -> Result<PendingEffect, ShaderDiagnostics> {
241        validate_definition(&definition)?;
242        self.validate_parameter_uniform(&definition)?;
243        if definition.tier_min == CapabilityTier::TierA
244            && self.gpu.capabilities().tier == CapabilityTier::TierB
245        {
246            return Err(ShaderDiagnostics::error(
247                "effect requires capability tier A on a tier B adapter",
248            ));
249        }
250        let id = EffectId::new(definition.id.clone(), definition.version);
251        self.effects.check_room(&id)?;
252        let uniform_layout = uniform_layout(&definition.params);
253        let source = build_shader(&definition);
254        let device = self.gpu.device();
255        let shader_scope = CreationErrorScopes::push(device);
256        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
257            label: Some(&definition.id),
258            source: wgpu::ShaderSource::Wgsl(source.into()),
259        });
260        let compilation: Answer<wgpu::CompilationInfo> = Box::pin(shader.get_compilation_info());
261        let shader_errors = shader_scope.pop();
262        let pipeline_scope = CreationErrorScopes::push(device);
263        let bind_group_layout =
264            runtime_bind_group_layout(device, &definition.params, &definition.resources);
265        let pipeline = runtime_pipeline(
266            device,
267            &bind_group_layout,
268            &shader,
269            self.intermediate_format(),
270        );
271        let pipeline_errors = pipeline_scope.pop();
272        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
273            label: Some("alpha2 runtime effect sampler"),
274            address_mode_u: wgpu::AddressMode::ClampToEdge,
275            address_mode_v: wgpu::AddressMode::ClampToEdge,
276            mag_filter: wgpu::FilterMode::Linear,
277            min_filter: wgpu::FilterMode::Linear,
278            ..Default::default()
279        });
280        Ok(PendingEffect {
281            compiled: CompiledEffect {
282                device: device.clone(),
283                id,
284                effect: RegisteredEffect {
285                    reads_time: body_reads_time(&definition.wgsl_body),
286                    definition,
287                    uniform_layout,
288                    _shader: shader,
289                    bind_group_layout,
290                    pipeline,
291                    sampler,
292                },
293            },
294            compilation,
295            shader_errors,
296            pipeline_errors,
297        })
298    }
299
300    /// Adds an effect this renderer compiled.
301    ///
302    /// # Errors
303    /// Refuses an effect compiled on another device, one registered while it
304    /// compiled, and a registry that filled meanwhile.
305    pub fn register_compiled(
306        &mut self,
307        compiled: CompiledEffect,
308    ) -> Result<EffectId, ShaderDiagnostics> {
309        if compiled.device != *self.gpu.device() {
310            return Err(ShaderDiagnostics::error(format!(
311                "effect {} version {} was compiled on another GPU device",
312                compiled.id.as_str(),
313                compiled.id.version()
314            )));
315        }
316        self.effects.check_room(&compiled.id)?;
317        self.effects
318            .entries
319            .insert(compiled.id.clone(), compiled.effect);
320        Ok(compiled.id)
321    }
322
323    /// Refuses parameters whose uniform this device cannot bind.
324    ///
325    /// Every parameter is one 16-byte slot of a single uniform binding, and a
326    /// device caps a binding's size (16 KiB on the WebGL2 limits every alpha2
327    /// device asks for). The pipeline compiles past the cap; it is the bind
328    /// group of each frame that fails, invalidating its submission, so the
329    /// registration refuses it instead.
330    fn validate_parameter_uniform(&self, definition: &EffectDef) -> Result<(), ShaderDiagnostics> {
331        let limit = self.limits.max_uniform_buffer_binding_size;
332        let bytes = u64::try_from(param_uniform_bytes(definition.params.len())).unwrap_or(u64::MAX);
333        if bytes > limit {
334            let most = limit / u64::try_from(PARAM_SLOT_BYTES).unwrap_or(u64::MAX);
335            return Err(ShaderDiagnostics::error(format!(
336                "effect declares {} parameters, a {bytes}-byte uniform past this device's \
337                 {limit}-byte uniform binding limit (at most {most} parameters)",
338                definition.params.len()
339            )));
340        }
341        Ok(())
342    }
343
344    /// Returns the generated host uniform layout for a registered effect.
345    #[must_use]
346    pub fn effect_uniform_layout(&self, id: &EffectId) -> Option<&UniformLayout> {
347        self.effects
348            .entries
349            .get(id)
350            .map(|entry| &entry.uniform_layout)
351    }
352
353    /// Returns the immutable definition for a registered effect.
354    #[must_use]
355    pub fn effect_definition(&self, id: &EffectId) -> Option<&EffectDef> {
356        self.effects.entries.get(id).map(|entry| &entry.definition)
357    }
358
359    /// Whether what `id` draws can change with the frame time alone: whether
360    /// its body, or for a built-in drawn as several passes the body of any
361    /// of them, can read `time()` or `time_ticks()` ([`EffectRuntime::time`]). An
362    /// effect that is not registered answers `true`.
363    ///
364    /// The answer comes from the WGSL itself, so it cannot disagree with the
365    /// shader: a body that never names `time`, `time_ticks` or `runtime`, the
366    /// uniform they read, cannot reach the frame time, and every other body is
367    /// taken to read it. A caller may then pass any time to an effect that
368    /// answers `false` (the timeline passes zero), so its result is found
369    /// again in another frame from the same inputs and parameters.
370    ///
371    /// [`EffectRuntime::time`]: crate::EffectRuntime::time
372    #[must_use]
373    pub fn effect_reads_time(&self, id: &EffectId) -> bool {
374        if let Some(passes) = crate::gaussian_blur::expanded_passes(id.as_str()) {
375            return passes
376                .iter()
377                .any(|pass| self.effect_reads_time(&EffectId::from_static(pass, 1)));
378        }
379        self.effects
380            .entries
381            .get(id)
382            .is_none_or(|entry| entry.reads_time)
383    }
384
385    /// Returns whether an exact effect version has been compiled.
386    #[must_use]
387    pub fn is_effect_registered(&self, id: &EffectId) -> bool {
388        self.effects.entries.contains_key(id)
389    }
390
391    /// Returns whether an effect may be referenced by a project document.
392    #[must_use]
393    pub fn is_effect_project_visible(&self, id: &EffectId) -> bool {
394        self.effects.entries.contains_key(id) && !self.effects.internal.contains(id)
395    }
396
397    /// The identity a project's reference to effect `id` version `version`
398    /// draws with: the one it is registered under, when a project may
399    /// reference it ([`Self::is_effect_project_visible`]). Looking it up and
400    /// cloning it allocate nothing, so a frame names its effects without
401    /// copying their text.
402    #[must_use]
403    pub fn project_effect_id(&self, id: &str, version: u32) -> Option<&EffectId> {
404        let key: &dyn EffectKey = &(id, version);
405        self.effects
406            .entries
407            .get_key_value(key)
408            .map(|(id, _)| id)
409            .filter(|id| !self.effects.internal.contains(*id))
410    }
411
412    /// Removes one compiled runtime effect and releases its GPU resources.
413    pub fn unregister_effect(&mut self, id: &EffectId) -> bool {
414        if self.effects.builtins.contains(id) {
415            return false;
416        }
417        let removed = self.effects.entries.remove(id).is_some();
418        if removed {
419            self.clear_result_cache_for(crate::CacheInvalidation::Effects);
420        }
421        removed
422    }
423
424    /// Removes every host-registered runtime effect.
425    pub fn clear_effects(&mut self) {
426        self.effects
427            .entries
428            .retain(|id, _| self.effects.builtins.contains(id));
429        self.clear_result_cache_for(crate::CacheInvalidation::Effects);
430    }
431
432    /// Confirms that all requested effect versions have compiled pipelines.
433    ///
434    /// Registration eagerly compiles pipelines, so this operation is
435    /// deterministic and primarily validates a project's dependency set.
436    ///
437    /// # Errors
438    /// Returns a diagnostic naming the first unregistered effect.
439    pub fn warmup(&self, effects: &[EffectId]) -> Result<(), ShaderDiagnostics> {
440        for effect in effects {
441            if !self.is_effect_registered(effect) {
442                return Err(ShaderDiagnostics::error(format!(
443                    "effect {} version {} is not registered",
444                    effect.as_str(),
445                    effect.version()
446                )));
447            }
448        }
449        Ok(())
450    }
451}
452
453impl EffectRegistry {
454    pub(crate) fn get(&self, id: &EffectId) -> Option<&RegisteredEffect> {
455        self.entries.get(id)
456    }
457
458    pub(crate) fn len(&self) -> usize {
459        self.entries.len()
460    }
461
462    pub(crate) fn mark_builtin(&mut self, id: EffectId) {
463        debug_assert!(self.entries.contains_key(&id));
464        self.builtins.insert(id);
465    }
466
467    pub(crate) fn mark_internal(&mut self, id: EffectId) {
468        debug_assert!(self.entries.contains_key(&id));
469        self.internal.insert(id);
470    }
471}
472
473fn runtime_bind_group_layout(
474    device: &wgpu::Device,
475    params: &[ParamSpec],
476    resources: &[ResourceSpec],
477) -> wgpu::BindGroupLayout {
478    let mut entries = vec![
479        runtime_texture_entry(0, wgpu::TextureViewDimension::D2),
480        runtime_texture_entry(1, wgpu::TextureViewDimension::D2),
481        wgpu::BindGroupLayoutEntry {
482            binding: 2,
483            visibility: wgpu::ShaderStages::FRAGMENT,
484            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
485            count: None,
486        },
487        // At the sizes the generated structs take and each pass writes: a pipeline whose
488        // shader reads past them fails to compile instead of reading an arena's next slot.
489        runtime_uniform_entry(3, param_uniform_bytes(params.len())),
490        runtime_uniform_entry(4, RUNTIME_UNIFORM_WORDS * size_of::<f32>()),
491    ];
492    for (index, resource) in resources.iter().enumerate() {
493        let binding = u32::try_from(index).unwrap_or(0) * 2 + 5;
494        let dimension = match resource.kind {
495            EffectResourceKind::Lut3d => wgpu::TextureViewDimension::D3,
496        };
497        entries.push(runtime_texture_entry(binding, dimension));
498        entries.push(runtime_uniform_entry(binding + 1, RESOURCE_METADATA_BYTES));
499    }
500    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
501        label: Some("alpha2 runtime effect layout"),
502        entries: &entries,
503    })
504}
505
506fn runtime_texture_entry(
507    binding: u32,
508    view_dimension: wgpu::TextureViewDimension,
509) -> wgpu::BindGroupLayoutEntry {
510    wgpu::BindGroupLayoutEntry {
511        binding,
512        visibility: wgpu::ShaderStages::FRAGMENT,
513        ty: wgpu::BindingType::Texture {
514            sample_type: wgpu::TextureSampleType::Float { filterable: true },
515            view_dimension,
516            multisampled: false,
517        },
518        count: None,
519    }
520}
521
522fn runtime_uniform_entry(binding: u32, bytes: usize) -> wgpu::BindGroupLayoutEntry {
523    wgpu::BindGroupLayoutEntry {
524        binding,
525        visibility: wgpu::ShaderStages::FRAGMENT,
526        ty: wgpu::BindingType::Buffer {
527            ty: wgpu::BufferBindingType::Uniform,
528            has_dynamic_offset: false,
529            min_binding_size: u64::try_from(bytes)
530                .ok()
531                .and_then(std::num::NonZeroU64::new),
532        },
533        count: None,
534    }
535}
536
537fn runtime_pipeline(
538    device: &wgpu::Device,
539    bind_group_layout: &wgpu::BindGroupLayout,
540    shader: &wgpu::ShaderModule,
541    format: wgpu::TextureFormat,
542) -> wgpu::RenderPipeline {
543    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
544        label: Some("alpha2 runtime effect pipeline layout"),
545        bind_group_layouts: &[Some(bind_group_layout)],
546        immediate_size: 0,
547    });
548    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
549        label: Some("alpha2 runtime effect pipeline"),
550        layout: Some(&layout),
551        vertex: wgpu::VertexState {
552            module: shader,
553            entry_point: Some("alpha2_vs"),
554            buffers: &[],
555            compilation_options: wgpu::PipelineCompilationOptions::default(),
556        },
557        fragment: Some(wgpu::FragmentState {
558            module: shader,
559            entry_point: Some("alpha2_fs"),
560            targets: &[Some(format.into())],
561            compilation_options: wgpu::PipelineCompilationOptions::default(),
562        }),
563        primitive: wgpu::PrimitiveState::default(),
564        depth_stencil: None,
565        multisample: wgpu::MultisampleState::default(),
566        multiview_mask: None,
567        cache: None,
568    })
569}
570
571pub(crate) fn uniform_layout(params: &[ParamSpec]) -> UniformLayout {
572    let fields = params
573        .iter()
574        .enumerate()
575        .map(|(index, param)| UniformField {
576            name: param.name.clone(),
577            offset: u32::try_from(index).expect("parameter count fits u32") * 16,
578            size: 16,
579        })
580        .collect::<Vec<_>>();
581    UniformLayout {
582        size: u32::try_from(param_uniform_bytes(params.len())).expect("parameter uniform fits u32"),
583        fields,
584    }
585}
586
587/// Whether an effect body can read the frame time. The prelude gives it
588/// only through `time()` and `time_ticks()`, which read the `runtime`
589/// uniform, and WGSL has no other way to reach any of them than by naming it,
590/// so a body in which none of the names occurs as a word cannot read it.
591/// Everything else counts as reading it, comments and a local that shadows
592/// the name included: a false answer would freeze a picture that should
593/// change, a true one only redraws it.
594///
595/// A word here is a run of letters, digits, and underscores. Every character
596/// that may follow an identifier in WGSL (space, punctuation, an operator,
597/// a comment) ends one, so every `time`, `time_ticks` or `runtime` token is
598/// such a word.
599pub(crate) fn body_reads_time(body: &str) -> bool {
600    body.split(|character: char| !(character.is_alphanumeric() || character == '_'))
601        .any(|word| matches!(word, "time" | "time_ticks" | "runtime"))
602}
603
604pub(crate) fn build_shader(definition: &EffectDef) -> String {
605    let param_fields = if definition.params.is_empty() {
606        "    _padding: vec4f,\n".to_owned()
607    } else {
608        definition
609            .params
610            .iter()
611            .fold(String::new(), |mut output, param| {
612                writeln!(
613                    output,
614                    "    {}: {},",
615                    param.name,
616                    wgsl_type(param.param_type)
617                )
618                .expect("writing to a String cannot fail");
619                output
620            })
621    };
622    let uniform_fields =
623        (0..definition.params.len().max(1)).fold(String::new(), |mut output, index| {
624            writeln!(output, "    slot_{index}: vec4u,").expect("writing to a String cannot fail");
625            output
626        });
627    let param_values = if definition.params.is_empty() {
628        "vec4f(0.0)".to_owned()
629    } else {
630        definition
631            .params
632            .iter()
633            .enumerate()
634            .map(|(index, param)| uniform_value(index, param.param_type))
635            .collect::<Vec<_>>()
636            .join(", ")
637    };
638    let call = match definition.kind {
639        EffectKind::Effect => "effect(input.uv, sample_src(input.uv))",
640        EffectKind::Transition => "alpha2_transition(input.uv)",
641        EffectKind::Generator => "generate(input.uv)",
642    };
643    let transition_wrapper = match definition.kind {
644        EffectKind::Effect | EffectKind::Generator => "",
645        EffectKind::Transition => {
646            r"fn alpha2_transition(uv: vec2f) -> vec4f {
647    if (progress() <= 0.0) { return from_color(uv); }
648    if (progress() >= 1.0) { return to_color(uv); }
649    return transition(uv);
650}"
651        }
652    };
653    let resource_declarations = resource_declarations(&definition.resources);
654    format!(
655        r"@group(0) @binding(0) var source_a: texture_2d<f32>;
656@group(0) @binding(1) var source_b: texture_2d<f32>;
657@group(0) @binding(2) var source_sampler: sampler;
658struct EffectParams {{
659{param_fields}}};
660struct EffectParamUniforms {{
661{uniform_fields}}};
662@group(0) @binding(3) var<uniform> param_uniforms: EffectParamUniforms;
663var<private> params: EffectParams;
664var<private> {BUDGET}: u32;
665struct RuntimeValues {{ resolution_time: vec4f, transition: vec4f, ticks: vec4u, }};
666@group(0) @binding(4) var<uniform> runtime: RuntimeValues;
667{resource_declarations}
668
669fn sample_src(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_a, source_sampler, uv, 0.0); }}
670fn from_color(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_a, source_sampler, uv, 0.0); }}
671fn to_color(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_b, source_sampler, uv, 0.0); }}
672fn resolution() -> vec2f {{ return runtime.resolution_time.xy; }}
673fn time() -> f32 {{ return runtime.resolution_time.z; }}
674fn time_ticks() -> vec2u {{ return runtime.ticks.xy; }}
675fn progress() -> f32 {{ return runtime.transition.x; }}
676fn ratio() -> f32 {{ return runtime.transition.y; }}
677fn max_filter_samples() -> f32 {{ return runtime.transition.z; }}
678fn load_params() -> EffectParams {{ return EffectParams({param_values}); }}
679
680{body}
681
682{transition_wrapper}
683
684struct VertexOutput {{ @builtin(position) position: vec4f, @location(0) uv: vec2f, }};
685@vertex fn alpha2_vs(@builtin(vertex_index) index: u32) -> VertexOutput {{
686    var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
687    var u = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));
688    return VertexOutput(vec4f(p[index], 0.0, 1.0), u[index]);
689}}
690@fragment fn alpha2_fs(input: VertexOutput) -> @location(0) vec4f {{
691    params = load_params();
692    {BUDGET} = {MAX_LOOP_ITERATIONS}u;
693    return {call};
694}}
695",
696        // Registration validated the body, which includes delimiting its loops, so this
697        // only falls back for a definition that skipped it.
698        body =
699            bound_for_loops(&definition.wgsl_body).unwrap_or_else(|_| definition.wgsl_body.clone()),
700        resource_declarations = resource_declarations,
701        transition_wrapper = transition_wrapper,
702    )
703}
704
705fn resource_declarations(resources: &[ResourceSpec]) -> String {
706    resources
707        .iter()
708        .enumerate()
709        .map(|(index, resource)| match resource.kind {
710            EffectResourceKind::Lut3d => format!(
711                "struct ResourceMetadata_{} {{ domain_min: vec4f, domain_max: vec4f, }};\n\
712                 @group(0) @binding({}) var resource_{}: texture_3d<f32>;\n\
713                 @group(0) @binding({}) var<uniform> resource_{}_metadata: ResourceMetadata_{};\n\
714                 fn sample_{}(rgb: vec3f) -> vec3f {{\n\
715                     let edge = f32(textureDimensions(resource_{}).x);\n\
716                     let normalized = clamp((rgb - resource_{}_metadata.domain_min.xyz) /\n\
717                         (resource_{}_metadata.domain_max.xyz - resource_{}_metadata.domain_min.xyz),\n\
718                         vec3f(0.0), vec3f(1.0));\n\
719                     let coord = (normalized * (edge - 1.0) + 0.5) / edge;\n\
720                     return textureSampleLevel(resource_{}, source_sampler, coord, 0.0).rgb;\n\
721                 }}",
722                resource.name,
723                index * 2 + 5,
724                resource.name,
725                index * 2 + 6,
726                resource.name,
727                resource.name,
728                resource.name,
729                resource.name,
730                resource.name,
731                resource.name,
732                resource.name,
733                resource.name,
734            ),
735        })
736        .collect::<Vec<_>>()
737        .join("\n")
738}
739
740fn uniform_value(index: usize, param_type: ParamType) -> String {
741    match param_type {
742        ParamType::Float => format!("bitcast<f32>(param_uniforms.slot_{index}.x)"),
743        ParamType::Vec2 => format!("bitcast<vec2f>(param_uniforms.slot_{index}.xy)"),
744        ParamType::Vec4 | ParamType::Color => {
745            format!("bitcast<vec4f>(param_uniforms.slot_{index})")
746        }
747        ParamType::Enum | ParamType::Bool => format!("param_uniforms.slot_{index}.x"),
748    }
749}
750
751fn wgsl_type(param_type: ParamType) -> &'static str {
752    match param_type {
753        ParamType::Float => "f32",
754        ParamType::Vec2 => "vec2f",
755        ParamType::Vec4 | ParamType::Color => "vec4f",
756        ParamType::Enum | ParamType::Bool => "u32",
757    }
758}
759
760fn validate_definition(definition: &EffectDef) -> Result<(), ShaderDiagnostics> {
761    if !valid_identifier(&definition.id) {
762        return Err(ShaderDiagnostics::error(
763            "effect id must contain only ASCII letters, digits, '-', '_', or '.'",
764        ));
765    }
766    if definition.version == 0 {
767        return Err(ShaderDiagnostics::error("effect version must be non-zero"));
768    }
769    validate_wgsl_body(&definition.wgsl_body)?;
770    if !matches!(
771        (definition.kind, definition.inputs),
772        (EffectKind::Effect, EffectInputs::One)
773            | (EffectKind::Transition, EffectInputs::Two)
774            | (EffectKind::Generator, EffectInputs::None)
775    ) {
776        return Err(ShaderDiagnostics::error(
777            "effects require one input, transitions two, and generators none",
778        ));
779    }
780    let mut names = std::collections::HashSet::new();
781    for param in &definition.params {
782        validate_param(param)?;
783        if !names.insert(param.name.as_str()) {
784            return Err(ShaderDiagnostics::error(format!(
785                "duplicate parameter name {}",
786                param.name
787            )));
788        }
789    }
790    if definition.resources.len() > MAX_EFFECT_RESOURCES {
791        return Err(ShaderDiagnostics::error(format!(
792            "effect declares more than {MAX_EFFECT_RESOURCES} resources"
793        )));
794    }
795    let mut resource_names = std::collections::HashSet::new();
796    // What the wrapper declares for each resource, at module scope beside its own names:
797    // none may be one of those or another resource's.
798    let mut declared = std::collections::HashSet::new();
799    for resource in &definition.resources {
800        let generated = resource_declarations_of(&resource.name);
801        if !valid_wgsl_identifier(&resource.name)
802            || names.contains(resource.name.as_str())
803            || !resource_names.insert(resource.name.as_str())
804            || !generated
805                .iter()
806                .all(|name| valid_wgsl_identifier(name) && declared.insert(name.clone()))
807        {
808            return Err(ShaderDiagnostics::error(format!(
809                "invalid or duplicate effect resource name {}",
810                resource.name
811            )));
812        }
813    }
814    Ok(())
815}
816
817/// The module-scope names [`build_shader`] declares for a resource named
818/// `name`.
819fn resource_declarations_of(name: &str) -> [String; 4] {
820    [
821        format!("resource_{name}"),
822        format!("resource_{name}_metadata"),
823        format!("sample_{name}"),
824        format!("ResourceMetadata_{name}"),
825    ]
826}
827
828fn validate_wgsl_body(body: &str) -> Result<(), ShaderDiagnostics> {
829    if body.len() > MAX_WGSL_BODY_BYTES {
830        return Err(ShaderDiagnostics::error(format!(
831            "WGSL body exceeds {MAX_WGSL_BODY_BYTES} bytes"
832        )));
833    }
834    if body
835        .chars()
836        .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
837    {
838        return Err(ShaderDiagnostics::error(
839            "WGSL body contains unsupported control characters",
840        ));
841    }
842    // Comments may say anything; only code is checked, for balance as for tokens.
843    let code = blank_comments(body);
844    if let Some(identifier) = identifiers(&code).find(|word| word.starts_with(RESERVED_PREFIX)) {
845        return Err(ShaderDiagnostics::error(format!(
846            "WGSL identifier {identifier} is reserved: names starting with {RESERVED_PREFIX} belong to the generated wrapper"
847        )));
848    }
849    if code.split([';', '{', '}']).any(|statement| {
850        statement.bytes().filter(|byte| is_operator(*byte)).count() > MAX_WGSL_STATEMENT_OPERATORS
851    }) {
852        return Err(ShaderDiagnostics::error(format!(
853            "a WGSL statement has more than {MAX_WGSL_STATEMENT_OPERATORS} operators; split it with `let`"
854        )));
855    }
856    let mut delimiters = Vec::new();
857    for character in code.chars() {
858        match character {
859            '{' | '(' | '[' => {
860                delimiters.push(character);
861                if delimiters.len() > MAX_WGSL_NESTING {
862                    return Err(ShaderDiagnostics::error(format!(
863                        "WGSL nesting exceeds {MAX_WGSL_NESTING} levels"
864                    )));
865                }
866            }
867            '}' | ')' | ']' => {
868                let matches = matches!(
869                    (delimiters.pop(), character),
870                    (Some('{'), '}') | (Some('('), ')') | (Some('['), ']')
871                );
872                if !matches {
873                    return Err(ShaderDiagnostics::error(
874                        "WGSL body has unbalanced delimiters",
875                    ));
876                }
877            }
878            _ => {}
879        }
880    }
881    if !delimiters.is_empty() {
882        return Err(ShaderDiagnostics::error(
883            "WGSL body has unbalanced delimiters",
884        ));
885    }
886
887    let forbidden = [
888        "@binding",
889        "@compute",
890        "@group",
891        "atomic",
892        "enable",
893        "loop",
894        "override",
895        "var<storage",
896        "var<uniform",
897        "var<workgroup",
898        "while",
899    ];
900    for token in code.split(|character: char| {
901        !(character.is_ascii_alphanumeric() || matches!(character, '_' | '@' | '<'))
902    }) {
903        if forbidden.contains(&token) {
904            return Err(ShaderDiagnostics::error(format!(
905                "WGSL token {token} is not allowed in a runtime effect"
906            )));
907        }
908    }
909    bound_for_loops(body)?;
910    crate::call_budget::check_invocation_calls(&code)
911}
912
913/// The identifiers in `code`: words that start with a letter or `_`, so the
914/// digits and suffixes of a number are not one.
915fn identifiers(code: &str) -> impl Iterator<Item = &str> {
916    code.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
917        .filter(|word| word.starts_with(|first: char| first.is_ascii_alphabetic() || first == '_'))
918}
919
920/// Counts every character that can make an expression one level deeper:
921/// operators, member access, and indexing. It overcounts (the `.` of `1.0`,
922/// the `<` of `vec4<f32>`), which only lowers the effective limit.
923const fn is_operator(byte: u8) -> bool {
924    matches!(
925        byte,
926        b'+' | b'-'
927            | b'*'
928            | b'/'
929            | b'%'
930            | b'&'
931            | b'|'
932            | b'^'
933            | b'!'
934            | b'~'
935            | b'<'
936            | b'>'
937            | b'='
938            | b'.'
939            | b'['
940    )
941}
942
943fn validate_param(param: &ParamSpec) -> Result<(), ShaderDiagnostics> {
944    if !valid_wgsl_identifier(&param.name) {
945        return Err(ShaderDiagnostics::error(format!(
946            "invalid WGSL parameter name {}",
947            param.name
948        )));
949    }
950    let compatible = matches!(
951        (&param.param_type, &param.default, &param.unit),
952        (
953            ParamType::Float,
954            ParamValue::Float(_),
955            ParamUnit::Normalized | ParamUnit::Degrees
956        ) | (ParamType::Vec2, ParamValue::Vec2(_), ParamUnit::Normalized)
957            | (ParamType::Vec4, ParamValue::Vec4(_), ParamUnit::Normalized)
958            | (ParamType::Color, ParamValue::Color(_), ParamUnit::Color)
959            | (ParamType::Enum, ParamValue::Enum(_), ParamUnit::Enum)
960            | (ParamType::Bool, ParamValue::Bool(_), ParamUnit::Bool)
961    );
962    if !compatible {
963        return Err(ShaderDiagnostics::error(format!(
964            "parameter {} has incompatible type, default, and unit",
965            param.name
966        )));
967    }
968    if !finite_value(&param.default)
969        || param.min.is_some_and(|value| !value.is_finite())
970        || param.max.is_some_and(|value| !value.is_finite())
971        || param
972            .step
973            .is_some_and(|value| !value.is_finite() || value <= 0.0)
974        || matches!((param.min, param.max), (Some(min), Some(max)) if min > max)
975    {
976        return Err(ShaderDiagnostics::error(format!(
977            "parameter {} has an invalid numeric range",
978            param.name
979        )));
980    }
981    if !matches!(
982        param.param_type,
983        ParamType::Float | ParamType::Vec2 | ParamType::Vec4
984    ) && (param.min.is_some() || param.max.is_some() || param.step.is_some())
985    {
986        return Err(ShaderDiagnostics::error(format!(
987            "only float, vec2, or vec4 parameter {} may declare a numeric range",
988            param.name
989        )));
990    }
991    if matches!(
992        param.param_type,
993        ParamType::Float | ParamType::Vec2 | ParamType::Vec4
994    ) && !value_in_range(&param.default, param.min, param.max)
995    {
996        return Err(ShaderDiagnostics::error(format!(
997            "parameter {} default is outside its numeric range",
998            param.name
999        )));
1000    }
1001    if let ParamValue::Color(values) = &param.default
1002        && !values.iter().all(|value| (0.0..=1.0).contains(value))
1003    {
1004        return Err(ShaderDiagnostics::error(format!(
1005            "color parameter {} default must be inside 0..=1",
1006            param.name
1007        )));
1008    }
1009    if param.param_type == ParamType::Enum && param.enum_values.is_empty() {
1010        return Err(ShaderDiagnostics::error(format!(
1011            "enum parameter {} requires named values",
1012            param.name
1013        )));
1014    }
1015    if let ParamValue::Enum(value) = &param.default
1016        && usize::try_from(*value).map_or(true, |index| index >= param.enum_values.len())
1017    {
1018        return Err(ShaderDiagnostics::error(format!(
1019            "enum parameter {} default is out of range",
1020            param.name
1021        )));
1022    }
1023    Ok(())
1024}
1025
1026fn value_in_range(value: &ParamValue, min: Option<f32>, max: Option<f32>) -> bool {
1027    let in_range = |value: &f32| {
1028        min.is_none_or(|minimum| *value >= minimum) && max.is_none_or(|maximum| *value <= maximum)
1029    };
1030    match value {
1031        ParamValue::Float(value) => in_range(value),
1032        ParamValue::Vec2(values) => values.iter().all(in_range),
1033        ParamValue::Vec4(values) => values.iter().all(in_range),
1034        _ => true,
1035    }
1036}
1037
1038fn finite_value(value: &ParamValue) -> bool {
1039    match value {
1040        ParamValue::Float(value) => value.is_finite(),
1041        ParamValue::Vec2(values) => values.iter().all(|value| value.is_finite()),
1042        ParamValue::Vec4(values) | ParamValue::Color(values) => {
1043            values.iter().all(|value| value.is_finite())
1044        }
1045        ParamValue::Enum(_) | ParamValue::Bool(_) => true,
1046    }
1047}
1048
1049fn valid_identifier(value: &str) -> bool {
1050    !value.is_empty()
1051        && value
1052            .bytes()
1053            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1054}
1055
1056fn valid_wgsl_identifier(value: &str) -> bool {
1057    let mut bytes = value.bytes();
1058    bytes
1059        .next()
1060        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1061        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1062        && !value.starts_with(RESERVED_PREFIX)
1063        && !matches!(
1064            value,
1065            "params"
1066                | "resolution"
1067                | "time"
1068                | "time_ticks"
1069                | "progress"
1070                | "ratio"
1071                | "sample_src"
1072                | "from_color"
1073                | "to_color"
1074                | "load_params"
1075                | "max_filter_samples"
1076                | "source_a"
1077                | "source_b"
1078                | "source_sampler"
1079                | "param_uniforms"
1080                | "runtime"
1081                | "EffectParams"
1082        )
1083}
1084
1085fn compilation_diagnostics(info: wgpu::CompilationInfo) -> ShaderDiagnostics {
1086    ShaderDiagnostics {
1087        messages: info
1088            .messages
1089            .into_iter()
1090            .map(|message| {
1091                let severity = match message.message_type {
1092                    wgpu::CompilationMessageType::Error => DiagnosticSeverity::Error,
1093                    wgpu::CompilationMessageType::Warning => DiagnosticSeverity::Warning,
1094                    wgpu::CompilationMessageType::Info => DiagnosticSeverity::Info,
1095                };
1096                ShaderDiagnostic {
1097                    severity,
1098                    message: message.message,
1099                    line: message.location.map(|location| location.line_number),
1100                    column: message.location.map(|location| location.line_position),
1101                }
1102            })
1103            .collect(),
1104    }
1105}
1106
1107#[cfg(test)]
1108#[path = "registry_tests.rs"]
1109mod tests;