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