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
16const MAX_REGISTERED_EFFECTS: usize = 256;
18const MAX_WGSL_BODY_BYTES: usize = 32 * 1024;
19const MAX_WGSL_NESTING: usize = 64;
20const MAX_WGSL_STATEMENT_OPERATORS: usize = 256;
26const RESERVED_PREFIX: &str = "alpha2_";
28const MAX_EFFECT_RESOURCES: usize = 8;
29pub const MAX_EFFECT_INPUTS: usize = 8;
32#[allow(clippy::cast_possible_truncation)]
35const INPUT_BINDING_BASE: u32 = 5 + 2 * MAX_EFFECT_RESOURCES as u32;
36const RESOURCE_METADATA_BYTES: usize = 32;
38
39struct 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 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#[cfg(not(target_arch = "wasm32"))]
73const D3D12_COMPILE_STACK: usize = 8 << 20;
74
75#[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#[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
107pub 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 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 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
169pub 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 #[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 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 pub(crate) cost: crate::effect_cost::EffectCost,
226 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[must_use]
445 pub fn is_effect_registered(&self, id: &EffectId) -> bool {
446 self.effects.entries.contains_key(id)
447 }
448
449 #[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 #[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 #[must_use]
469 pub fn is_builtin_effect(&self, id: &EffectId) -> bool {
470 self.effects.builtins.contains(id)
471 }
472
473 pub fn unregister_effect(&mut self, id: &EffectId) -> bool {
475 if self.effects.builtins.contains(id) {
476 return false;
477 }
478 let removed = self.effects.entries.remove(id).is_some();
479 if removed {
480 self.clear_result_cache_for(crate::CacheInvalidation::Effects);
481 }
482 removed
483 }
484
485 pub fn clear_effects(&mut self) {
487 self.effects
488 .entries
489 .retain(|id, _| self.effects.builtins.contains(id));
490 self.clear_result_cache_for(crate::CacheInvalidation::Effects);
491 }
492
493 pub fn warmup(&self, effects: &[EffectId]) -> Result<(), ShaderDiagnostics> {
499 for effect in effects {
500 let Some(registered) = self.effects.get(effect) else {
501 return Err(ShaderDiagnostics::error(format!(
502 "effect {} version {} is not registered",
503 effect.as_str(),
504 effect.version()
505 )));
506 };
507 self.effect_pipeline(registered);
508 for pass in crate::gaussian_blur::expanded_passes(effect.as_str()).unwrap_or_default() {
509 if let Some(registered) = self.effects.get(&EffectId::from_static(pass, 1)) {
510 self.effect_pipeline(registered);
511 }
512 }
513 }
514 Ok(())
515 }
516}
517
518impl EffectRegistry {
519 pub(crate) fn get(&self, id: &EffectId) -> Option<&RegisteredEffect> {
520 self.entries.get(id)
521 }
522
523 pub(crate) fn len(&self) -> usize {
524 self.entries.len()
525 }
526
527 pub(crate) fn mark_builtin(&mut self, id: EffectId) {
528 debug_assert!(self.entries.contains_key(&id));
529 self.builtins.insert(id);
530 }
531
532 pub(crate) fn mark_internal(&mut self, id: EffectId) {
533 debug_assert!(self.entries.contains_key(&id));
534 self.internal.insert(id);
535 }
536}
537
538pub(crate) fn registered_effect(definition: EffectDef) -> RegisteredEffect {
540 RegisteredEffect {
541 uniform_layout: uniform_layout(&definition.params),
542 reads_time: body_reads_time(&definition.wgsl_body),
543 cost: crate::effect_cost::invocation_cost(&blank_comments(&definition.wgsl_body)),
544 definition,
545 pipeline: std::sync::OnceLock::new(),
546 }
547}
548
549fn effect_shader(device: &wgpu::Device, definition: &EffectDef) -> wgpu::ShaderModule {
550 device.create_shader_module(wgpu::ShaderModuleDescriptor {
551 label: Some(&definition.id),
552 source: wgpu::ShaderSource::Wgsl(build_shader(definition).into()),
553 })
554}
555
556fn effect_pipeline(
557 device: &wgpu::Device,
558 definition: &EffectDef,
559 shader: wgpu::ShaderModule,
560 format: wgpu::TextureFormat,
561) -> EffectPipeline {
562 let bind_group_layout = runtime_bind_group_layout(
563 device,
564 &definition.params,
565 &definition.resources,
566 &definition.inputs,
567 );
568 let pipeline = runtime_pipeline(device, &bind_group_layout, &shader, format);
569 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
570 label: Some("alpha2 runtime effect sampler"),
571 address_mode_u: wgpu::AddressMode::ClampToEdge,
572 address_mode_v: wgpu::AddressMode::ClampToEdge,
573 mag_filter: wgpu::FilterMode::Linear,
574 min_filter: wgpu::FilterMode::Linear,
575 ..Default::default()
576 });
577 EffectPipeline {
578 _shader: shader,
579 bind_group_layout,
580 pipeline,
581 sampler,
582 }
583}
584
585fn runtime_bind_group_layout(
586 device: &wgpu::Device,
587 params: &[ParamSpec],
588 resources: &[ResourceSpec],
589 inputs: &[InputSpec],
590) -> wgpu::BindGroupLayout {
591 let mut entries = vec![
592 runtime_texture_entry(0, wgpu::TextureViewDimension::D2),
593 runtime_texture_entry(1, wgpu::TextureViewDimension::D2),
594 wgpu::BindGroupLayoutEntry {
595 binding: 2,
596 visibility: wgpu::ShaderStages::FRAGMENT,
597 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
598 count: None,
599 },
600 runtime_uniform_entry(3, param_uniform_bytes(params.len())),
603 runtime_uniform_entry(4, RUNTIME_UNIFORM_WORDS * size_of::<f32>()),
604 ];
605 for (index, resource) in resources.iter().enumerate() {
606 let binding = u32::try_from(index).unwrap_or(0) * 2 + 5;
607 let dimension = match resource.kind {
608 EffectResourceKind::Lut3d => wgpu::TextureViewDimension::D3,
609 };
610 entries.push(runtime_texture_entry(binding, dimension));
611 entries.push(runtime_uniform_entry(binding + 1, RESOURCE_METADATA_BYTES));
612 }
613 for index in 0..inputs.len() {
614 entries.push(runtime_texture_entry(
615 input_binding(index),
616 wgpu::TextureViewDimension::D2,
617 ));
618 }
619 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
620 label: Some("alpha2 runtime effect layout"),
621 entries: &entries,
622 })
623}
624
625pub(crate) fn input_binding(index: usize) -> u32 {
626 INPUT_BINDING_BASE + u32::try_from(index).unwrap_or(0)
627}
628
629fn runtime_texture_entry(
630 binding: u32,
631 view_dimension: wgpu::TextureViewDimension,
632) -> wgpu::BindGroupLayoutEntry {
633 wgpu::BindGroupLayoutEntry {
634 binding,
635 visibility: wgpu::ShaderStages::FRAGMENT,
636 ty: wgpu::BindingType::Texture {
637 sample_type: wgpu::TextureSampleType::Float { filterable: true },
638 view_dimension,
639 multisampled: false,
640 },
641 count: None,
642 }
643}
644
645fn runtime_uniform_entry(binding: u32, bytes: usize) -> wgpu::BindGroupLayoutEntry {
646 wgpu::BindGroupLayoutEntry {
647 binding,
648 visibility: wgpu::ShaderStages::FRAGMENT,
649 ty: wgpu::BindingType::Buffer {
650 ty: wgpu::BufferBindingType::Uniform,
651 has_dynamic_offset: false,
652 min_binding_size: u64::try_from(bytes)
653 .ok()
654 .and_then(std::num::NonZeroU64::new),
655 },
656 count: None,
657 }
658}
659
660fn runtime_pipeline(
661 device: &wgpu::Device,
662 bind_group_layout: &wgpu::BindGroupLayout,
663 shader: &wgpu::ShaderModule,
664 format: wgpu::TextureFormat,
665) -> wgpu::RenderPipeline {
666 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
667 label: Some("alpha2 runtime effect pipeline layout"),
668 bind_group_layouts: &[Some(bind_group_layout)],
669 immediate_size: 0,
670 });
671 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
672 label: Some("alpha2 runtime effect pipeline"),
673 layout: Some(&layout),
674 vertex: wgpu::VertexState {
675 module: shader,
676 entry_point: Some("alpha2_vs"),
677 buffers: &[],
678 compilation_options: wgpu::PipelineCompilationOptions::default(),
679 },
680 fragment: Some(wgpu::FragmentState {
681 module: shader,
682 entry_point: Some("alpha2_fs"),
683 targets: &[Some(format.into())],
684 compilation_options: wgpu::PipelineCompilationOptions::default(),
685 }),
686 primitive: wgpu::PrimitiveState::default(),
687 depth_stencil: None,
688 multisample: wgpu::MultisampleState::default(),
689 multiview_mask: None,
690 cache: None,
691 })
692}
693
694pub(crate) fn uniform_layout(params: &[ParamSpec]) -> UniformLayout {
695 let fields = params
696 .iter()
697 .enumerate()
698 .map(|(index, param)| UniformField {
699 name: param.name.clone(),
700 offset: u32::try_from(index).expect("parameter count fits u32") * 16,
701 size: 16,
702 })
703 .collect::<Vec<_>>();
704 UniformLayout {
705 size: u32::try_from(param_uniform_bytes(params.len())).expect("parameter uniform fits u32"),
706 fields,
707 }
708}
709
710pub(crate) fn body_reads_time(body: &str) -> bool {
717 body.split(|character: char| !(character.is_alphanumeric() || character == '_'))
718 .any(|word| matches!(word, "time" | "time_ticks" | "runtime"))
719}
720
721pub(crate) fn build_shader(definition: &EffectDef) -> String {
722 let param_fields = if definition.params.is_empty() {
723 " _padding: vec4f,\n".to_owned()
724 } else {
725 definition
726 .params
727 .iter()
728 .fold(String::new(), |mut output, param| {
729 writeln!(
730 output,
731 " {}: {},",
732 param.name,
733 wgsl_type(param.param_type)
734 )
735 .expect("writing to a String cannot fail");
736 output
737 })
738 };
739 let uniform_fields =
740 (0..definition.params.len().max(1)).fold(String::new(), |mut output, index| {
741 writeln!(output, " slot_{index}: vec4u,").expect("writing to a String cannot fail");
742 output
743 });
744 let param_values = if definition.params.is_empty() {
745 "vec4f(0.0)".to_owned()
746 } else {
747 definition
748 .params
749 .iter()
750 .enumerate()
751 .map(|(index, param)| uniform_value(index, param.param_type))
752 .collect::<Vec<_>>()
753 .join(", ")
754 };
755 let call = match definition.kind {
756 EffectKind::Effect => "effect(input.uv, sample_src(input.uv))",
757 EffectKind::Transition => "alpha2_transition(input.uv)",
758 EffectKind::Generator => "generate(input.uv)",
759 };
760 let transition_wrapper = match definition.kind {
761 EffectKind::Effect | EffectKind::Generator => "",
762 EffectKind::Transition => {
763 r"fn alpha2_transition(uv: vec2f) -> vec4f {
764 if (progress() <= 0.0) { return from_color(uv); }
765 if (progress() >= 1.0) { return to_color(uv); }
766 return transition(uv);
767}"
768 }
769 };
770 let resource_declarations = resource_declarations(&definition.resources);
771 let input_declarations = input_declarations(&definition.inputs);
772 format!(
773 r"@group(0) @binding(0) var source_a: texture_2d<f32>;
774@group(0) @binding(1) var source_b: texture_2d<f32>;
775@group(0) @binding(2) var source_sampler: sampler;
776struct EffectParams {{
777{param_fields}}};
778struct EffectParamUniforms {{
779{uniform_fields}}};
780@group(0) @binding(3) var<uniform> param_uniforms: EffectParamUniforms;
781var<private> params: EffectParams;
782var<private> {BUDGET}: u32;
783struct RuntimeValues {{ resolution_time: vec4f, transition: vec4f, ticks: vec4u, }};
784@group(0) @binding(4) var<uniform> runtime: RuntimeValues;
785{resource_declarations}{input_declarations}
786
787fn sample_src(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_a, source_sampler, uv, 0.0); }}
788fn from_color(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_a, source_sampler, uv, 0.0); }}
789fn to_color(uv: vec2f) -> vec4f {{ return textureSampleLevel(source_b, source_sampler, uv, 0.0); }}
790fn resolution() -> vec2f {{ return runtime.resolution_time.xy; }}
791fn time() -> f32 {{ return runtime.resolution_time.z; }}
792fn time_ticks() -> vec2u {{ return runtime.ticks.xy; }}
793fn progress() -> f32 {{ return runtime.transition.x; }}
794fn ratio() -> f32 {{ return runtime.transition.y; }}
795fn max_filter_samples() -> f32 {{ return runtime.transition.z; }}
796fn load_params() -> EffectParams {{ return EffectParams({param_values}); }}
797
798{body}
799
800{transition_wrapper}
801
802struct VertexOutput {{ @builtin(position) position: vec4f, @location(0) uv: vec2f, }};
803@vertex fn alpha2_vs(@builtin(vertex_index) index: u32) -> VertexOutput {{
804 var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
805 var u = array<vec2f, 3>(vec2f(0.0, 1.0), vec2f(2.0, 1.0), vec2f(0.0, -1.0));
806 return VertexOutput(vec4f(p[index], 0.0, 1.0), u[index]);
807}}
808@fragment fn alpha2_fs(input: VertexOutput) -> @location(0) vec4f {{
809 params = load_params();
810 {BUDGET} = {MAX_LOOP_ITERATIONS}u;
811 return {call};
812}}
813",
814 body =
816 bound_for_loops(&definition.wgsl_body).unwrap_or_else(|_| definition.wgsl_body.clone()),
817 resource_declarations = resource_declarations,
818 input_declarations = input_declarations,
819 transition_wrapper = transition_wrapper,
820 )
821}
822
823fn resource_declarations(resources: &[ResourceSpec]) -> String {
824 resources
825 .iter()
826 .enumerate()
827 .map(|(index, resource)| match resource.kind {
828 EffectResourceKind::Lut3d => format!(
829 "struct ResourceMetadata_{} {{ domain_min: vec4f, domain_max: vec4f, }};\n\
830 @group(0) @binding({}) var resource_{}: texture_3d<f32>;\n\
831 @group(0) @binding({}) var<uniform> resource_{}_metadata: ResourceMetadata_{};\n\
832 fn sample_{}(rgb: vec3f) -> vec3f {{\n\
833 let edge = f32(textureDimensions(resource_{}).x);\n\
834 let normalized = clamp((rgb - resource_{}_metadata.domain_min.xyz) /\n\
835 (resource_{}_metadata.domain_max.xyz - resource_{}_metadata.domain_min.xyz),\n\
836 vec3f(0.0), vec3f(1.0));\n\
837 let coord = (normalized * (edge - 1.0) + 0.5) / edge;\n\
838 return textureSampleLevel(resource_{}, source_sampler, coord, 0.0).rgb;\n\
839 }}",
840 resource.name,
841 index * 2 + 5,
842 resource.name,
843 index * 2 + 6,
844 resource.name,
845 resource.name,
846 resource.name,
847 resource.name,
848 resource.name,
849 resource.name,
850 resource.name,
851 resource.name,
852 ),
853 })
854 .collect::<Vec<_>>()
855 .join("\n")
856}
857
858fn input_declarations(inputs: &[InputSpec]) -> String {
860 inputs
861 .iter()
862 .enumerate()
863 .fold(String::new(), |mut output, (index, input)| {
864 let name = &input.name;
865 write!(
866 output,
867 "\n@group(0) @binding({binding}) var input_{name}: texture_2d<f32>;\n\
868 fn sample_{name}(uv: vec2f) -> vec4f {{ return textureSampleLevel(input_{name}, source_sampler, uv, 0.0); }}\n\
869 fn size_{name}() -> vec2f {{ return vec2f(textureDimensions(input_{name})); }}",
870 binding = input_binding(index),
871 )
872 .expect("writing to a String cannot fail");
873 output
874 })
875}
876
877fn uniform_value(index: usize, param_type: ParamType) -> String {
878 match param_type {
879 ParamType::Float => format!("bitcast<f32>(param_uniforms.slot_{index}.x)"),
880 ParamType::Vec2 => format!("bitcast<vec2f>(param_uniforms.slot_{index}.xy)"),
881 ParamType::Vec4 | ParamType::Color => {
882 format!("bitcast<vec4f>(param_uniforms.slot_{index})")
883 }
884 ParamType::Enum | ParamType::Bool => format!("param_uniforms.slot_{index}.x"),
885 }
886}
887
888fn wgsl_type(param_type: ParamType) -> &'static str {
889 match param_type {
890 ParamType::Float => "f32",
891 ParamType::Vec2 => "vec2f",
892 ParamType::Vec4 | ParamType::Color => "vec4f",
893 ParamType::Enum | ParamType::Bool => "u32",
894 }
895}
896
897fn validate_definition(definition: &EffectDef) -> Result<(), ShaderDiagnostics> {
898 if !valid_identifier(&definition.id) {
899 return Err(ShaderDiagnostics::error(
900 "effect id must contain only ASCII letters, digits, '-', '_', or '.'",
901 ));
902 }
903 if definition.version == 0 {
904 return Err(ShaderDiagnostics::error("effect version must be non-zero"));
905 }
906 validate_wgsl_body(&definition.wgsl_body)?;
907 if !definition.inputs.is_empty() && definition.kind != EffectKind::Effect {
908 return Err(ShaderDiagnostics::error(
909 "only effects declare named inputs; a transition samples from and to",
910 ));
911 }
912 if definition.inputs.len() > MAX_EFFECT_INPUTS {
913 return Err(ShaderDiagnostics::error(format!(
914 "effect declares more than {MAX_EFFECT_INPUTS} named inputs"
915 )));
916 }
917 let mut names = std::collections::HashSet::new();
918 for param in &definition.params {
919 validate_param(param)?;
920 if !names.insert(param.name.as_str()) {
921 return Err(ShaderDiagnostics::error(format!(
922 "duplicate parameter name {}",
923 param.name
924 )));
925 }
926 }
927 if definition.resources.len() > MAX_EFFECT_RESOURCES {
928 return Err(ShaderDiagnostics::error(format!(
929 "effect declares more than {MAX_EFFECT_RESOURCES} resources"
930 )));
931 }
932 let mut resource_names = std::collections::HashSet::new();
933 let mut declared = std::collections::HashSet::new();
935 for resource in &definition.resources {
936 let generated = resource_declarations_of(&resource.name);
937 if !valid_wgsl_identifier(&resource.name)
938 || names.contains(resource.name.as_str())
939 || !resource_names.insert(resource.name.as_str())
940 || !generated
941 .iter()
942 .all(|name| valid_wgsl_identifier(name) && declared.insert(name.clone()))
943 {
944 return Err(ShaderDiagnostics::error(format!(
945 "invalid or duplicate effect resource name {}",
946 resource.name
947 )));
948 }
949 }
950 let mut input_names = std::collections::HashSet::new();
952 for input in &definition.inputs {
953 let generated = input_declarations_of(&input.name);
954 if !valid_wgsl_identifier(&input.name)
955 || names.contains(input.name.as_str())
956 || resource_names.contains(input.name.as_str())
957 || !input_names.insert(input.name.as_str())
958 || !generated
959 .iter()
960 .all(|name| valid_wgsl_identifier(name) && declared.insert(name.clone()))
961 {
962 return Err(ShaderDiagnostics::error(format!(
963 "invalid or duplicate effect input name {}",
964 input.name
965 )));
966 }
967 }
968 Ok(())
969}
970
971fn input_declarations_of(name: &str) -> [String; 3] {
974 [
975 format!("input_{name}"),
976 format!("sample_{name}"),
977 format!("size_{name}"),
978 ]
979}
980
981fn resource_declarations_of(name: &str) -> [String; 4] {
983 [
984 format!("resource_{name}"),
985 format!("resource_{name}_metadata"),
986 format!("sample_{name}"),
987 format!("ResourceMetadata_{name}"),
988 ]
989}
990
991fn validate_wgsl_body(body: &str) -> Result<(), ShaderDiagnostics> {
992 if body.len() > MAX_WGSL_BODY_BYTES {
993 return Err(ShaderDiagnostics::error(format!(
994 "WGSL body exceeds {MAX_WGSL_BODY_BYTES} bytes"
995 )));
996 }
997 if body
998 .chars()
999 .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
1000 {
1001 return Err(ShaderDiagnostics::error(
1002 "WGSL body contains unsupported control characters",
1003 ));
1004 }
1005 let code = blank_comments(body);
1007 if let Some(identifier) = identifiers(&code).find(|word| word.starts_with(RESERVED_PREFIX)) {
1008 return Err(ShaderDiagnostics::error(format!(
1009 "WGSL identifier {identifier} is reserved: names starting with {RESERVED_PREFIX} belong to the generated wrapper"
1010 )));
1011 }
1012 if code.split([';', '{', '}']).any(|statement| {
1013 statement.bytes().filter(|byte| is_operator(*byte)).count() > MAX_WGSL_STATEMENT_OPERATORS
1014 }) {
1015 return Err(ShaderDiagnostics::error(format!(
1016 "a WGSL statement has more than {MAX_WGSL_STATEMENT_OPERATORS} operators; split it with `let`"
1017 )));
1018 }
1019 let mut delimiters = Vec::new();
1020 for character in code.chars() {
1021 match character {
1022 '{' | '(' | '[' => {
1023 delimiters.push(character);
1024 if delimiters.len() > MAX_WGSL_NESTING {
1025 return Err(ShaderDiagnostics::error(format!(
1026 "WGSL nesting exceeds {MAX_WGSL_NESTING} levels"
1027 )));
1028 }
1029 }
1030 '}' | ')' | ']' => {
1031 let matches = matches!(
1032 (delimiters.pop(), character),
1033 (Some('{'), '}') | (Some('('), ')') | (Some('['), ']')
1034 );
1035 if !matches {
1036 return Err(ShaderDiagnostics::error(
1037 "WGSL body has unbalanced delimiters",
1038 ));
1039 }
1040 }
1041 _ => {}
1042 }
1043 }
1044 if !delimiters.is_empty() {
1045 return Err(ShaderDiagnostics::error(
1046 "WGSL body has unbalanced delimiters",
1047 ));
1048 }
1049
1050 let forbidden = [
1051 "@binding",
1052 "@compute",
1053 "@group",
1054 "atomic",
1055 "enable",
1056 "loop",
1057 "override",
1058 "var<storage",
1059 "var<uniform",
1060 "var<workgroup",
1061 "while",
1062 ];
1063 for token in code.split(|character: char| {
1064 !(character.is_ascii_alphanumeric() || matches!(character, '_' | '@' | '<'))
1065 }) {
1066 if forbidden.contains(&token) {
1067 return Err(ShaderDiagnostics::error(format!(
1068 "WGSL token {token} is not allowed in a runtime effect"
1069 )));
1070 }
1071 }
1072 bound_for_loops(body)?;
1073 crate::call_budget::check_invocation_calls(&code)?;
1074 crate::effect_cost::check_invocation_cost(&code).map(drop)
1075}
1076
1077fn identifiers(code: &str) -> impl Iterator<Item = &str> {
1079 code.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
1080 .filter(|word| word.starts_with(|first: char| first.is_ascii_alphabetic() || first == '_'))
1081}
1082
1083const fn is_operator(byte: u8) -> bool {
1086 matches!(
1087 byte,
1088 b'+' | b'-'
1089 | b'*'
1090 | b'/'
1091 | b'%'
1092 | b'&'
1093 | b'|'
1094 | b'^'
1095 | b'!'
1096 | b'~'
1097 | b'<'
1098 | b'>'
1099 | b'='
1100 | b'.'
1101 | b'['
1102 )
1103}
1104
1105fn validate_param(param: &ParamSpec) -> Result<(), ShaderDiagnostics> {
1106 if !valid_wgsl_identifier(¶m.name) {
1107 return Err(ShaderDiagnostics::error(format!(
1108 "invalid WGSL parameter name {}",
1109 param.name
1110 )));
1111 }
1112 let compatible = matches!(
1113 (¶m.param_type, ¶m.default, ¶m.unit),
1114 (
1115 ParamType::Float,
1116 ParamValue::Float(_),
1117 ParamUnit::Normalized | ParamUnit::Degrees
1118 ) | (ParamType::Vec2, ParamValue::Vec2(_), ParamUnit::Normalized)
1119 | (ParamType::Vec4, ParamValue::Vec4(_), ParamUnit::Normalized)
1120 | (ParamType::Color, ParamValue::Color(_), ParamUnit::Color)
1121 | (ParamType::Enum, ParamValue::Enum(_), ParamUnit::Enum)
1122 | (ParamType::Bool, ParamValue::Bool(_), ParamUnit::Bool)
1123 );
1124 if !compatible {
1125 return Err(ShaderDiagnostics::error(format!(
1126 "parameter {} has incompatible type, default, and unit",
1127 param.name
1128 )));
1129 }
1130 if !finite_value(¶m.default)
1131 || param.min.is_some_and(|value| !value.is_finite())
1132 || param.max.is_some_and(|value| !value.is_finite())
1133 || param
1134 .step
1135 .is_some_and(|value| !value.is_finite() || value <= 0.0)
1136 || matches!((param.min, param.max), (Some(min), Some(max)) if min > max)
1137 {
1138 return Err(ShaderDiagnostics::error(format!(
1139 "parameter {} has an invalid numeric range",
1140 param.name
1141 )));
1142 }
1143 if !matches!(
1144 param.param_type,
1145 ParamType::Float | ParamType::Vec2 | ParamType::Vec4
1146 ) && (param.min.is_some() || param.max.is_some() || param.step.is_some())
1147 {
1148 return Err(ShaderDiagnostics::error(format!(
1149 "only float, vec2, or vec4 parameter {} may declare a numeric range",
1150 param.name
1151 )));
1152 }
1153 if matches!(
1154 param.param_type,
1155 ParamType::Float | ParamType::Vec2 | ParamType::Vec4
1156 ) && !value_in_range(¶m.default, param.min, param.max)
1157 {
1158 return Err(ShaderDiagnostics::error(format!(
1159 "parameter {} default is outside its numeric range",
1160 param.name
1161 )));
1162 }
1163 if let ParamValue::Color(values) = ¶m.default
1164 && !values.iter().all(|value| (0.0..=1.0).contains(value))
1165 {
1166 return Err(ShaderDiagnostics::error(format!(
1167 "color parameter {} default must be inside 0..=1",
1168 param.name
1169 )));
1170 }
1171 if param.param_type == ParamType::Enum && param.enum_values.is_empty() {
1172 return Err(ShaderDiagnostics::error(format!(
1173 "enum parameter {} requires named values",
1174 param.name
1175 )));
1176 }
1177 if let ParamValue::Enum(value) = ¶m.default
1178 && usize::try_from(*value).map_or(true, |index| index >= param.enum_values.len())
1179 {
1180 return Err(ShaderDiagnostics::error(format!(
1181 "enum parameter {} default is out of range",
1182 param.name
1183 )));
1184 }
1185 Ok(())
1186}
1187
1188fn value_in_range(value: &ParamValue, min: Option<f32>, max: Option<f32>) -> bool {
1189 let in_range = |value: &f32| {
1190 min.is_none_or(|minimum| *value >= minimum) && max.is_none_or(|maximum| *value <= maximum)
1191 };
1192 match value {
1193 ParamValue::Float(value) => in_range(value),
1194 ParamValue::Vec2(values) => values.iter().all(in_range),
1195 ParamValue::Vec4(values) => values.iter().all(in_range),
1196 _ => true,
1197 }
1198}
1199
1200fn finite_value(value: &ParamValue) -> bool {
1201 match value {
1202 ParamValue::Float(value) => value.is_finite(),
1203 ParamValue::Vec2(values) => values.iter().all(|value| value.is_finite()),
1204 ParamValue::Vec4(values) | ParamValue::Color(values) => {
1205 values.iter().all(|value| value.is_finite())
1206 }
1207 ParamValue::Enum(_) | ParamValue::Bool(_) => true,
1208 }
1209}
1210
1211fn valid_identifier(value: &str) -> bool {
1212 !value.is_empty()
1213 && value
1214 .bytes()
1215 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1216}
1217
1218fn valid_wgsl_identifier(value: &str) -> bool {
1219 let mut bytes = value.bytes();
1220 bytes
1221 .next()
1222 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1223 && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1224 && !value.starts_with(RESERVED_PREFIX)
1225 && !matches!(
1226 value,
1227 "params"
1228 | "resolution"
1229 | "time"
1230 | "time_ticks"
1231 | "progress"
1232 | "ratio"
1233 | "sample_src"
1234 | "from_color"
1235 | "to_color"
1236 | "load_params"
1237 | "max_filter_samples"
1238 | "source_a"
1239 | "source_b"
1240 | "source_sampler"
1241 | "param_uniforms"
1242 | "runtime"
1243 | "EffectParams"
1244 )
1245}
1246
1247fn compilation_diagnostics(info: wgpu::CompilationInfo) -> ShaderDiagnostics {
1248 ShaderDiagnostics {
1249 messages: info
1250 .messages
1251 .into_iter()
1252 .map(|message| {
1253 let severity = match message.message_type {
1254 wgpu::CompilationMessageType::Error => DiagnosticSeverity::Error,
1255 wgpu::CompilationMessageType::Warning => DiagnosticSeverity::Warning,
1256 wgpu::CompilationMessageType::Info => DiagnosticSeverity::Info,
1257 };
1258 ShaderDiagnostic {
1259 severity,
1260 message: message.message,
1261 line: message.location.map(|location| location.line_number),
1262 column: message.location.map(|location| location.line_position),
1263 }
1264 })
1265 .collect(),
1266 }
1267}
1268
1269#[cfg(test)]
1270#[path = "registry_tests.rs"]
1271mod tests;