Skip to main content

alpha2_render/
registry.rs

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