Skip to main content

engawa_wgpu/catalog/
mod.rs

1//! Typed effect catalog — the built-in post-process effects
2//! every engawa consumer composes from (engawa roadmap v0.4,
3//! realised here in the wgpu backend crate so the WGSL lives
4//! next to the dispatcher that runs it).
5//!
6//! Each effect ships four artifacts:
7//!
8//! 1. embedded WGSL (`include_str!` from `src/catalog/wgsl/`),
9//! 2. a `#[repr(C)]` `bytemuck` Pod+Zeroable `…Params` struct
10//!    (the typed payload for [`crate::FrameUniforms`]),
11//! 3. a constructor returning an [`engawa::Effect`] plus the
12//!    canonical `lower(input, output) -> Vec<Node>` lowering
13//!    (priorities in the post range 200..=799),
14//! 4. a `(defeffect …)` tatara-lisp form at `effects/<name>.tlisp`
15//!    declaring the params byte size.
16//!
17//! ## Mechanical registry
18//!
19//! [`CatalogEffect::ALL`] is **derived from the enum variants**
20//! (`pleme-allvariants-derive`) — never hand-listed. The matrix
21//! forcing test (`tests/catalog_matrix.rs`) keeps one row per
22//! effect and asserts `MATRIX.len() == ALL.len()`, so a new
23//! variant cannot land without a matrix row: the derive grows
24//! `ALL`, the len-equality fails, and every exhaustive `match`
25//! below refuses to compile until the new effect is wired.
26//!
27//! ## Resource conventions
28//!
29//! Single-input effects bind: input texture `@binding(0)`, the
30//! shared [`CATALOG_SAMPLER`] `@binding(1)`, the effect's params
31//! uniform `@binding(2)`. The canonical graph shape is
32//! [`SCENE`] → effect → [`OUT`]; consumers ping-pong via
33//! [`crate::TexturePool`] leases.
34
35pub mod aurora;
36pub mod bloom;
37pub mod colorblind;
38pub mod crt;
39pub mod glow_on_bell;
40pub mod grain;
41pub mod scanlines;
42pub mod snow;
43
44use engawa::{
45    BindingKind, Effect, Material, Node, RenderGraph, ResourceId, ResourceKind,
46    ShaderSource, UniformBinding,
47};
48use pleme_allvariants_derive::AllVariants;
49
50/// Canonical scene-input resource id — the texture the catalog
51/// effect reads (the consumer's rendered frame so far).
52pub const SCENE: &str = "scene";
53
54/// Canonical output resource id — the texture the catalog
55/// effect writes (next ping-pong target or the surface).
56pub const OUT: &str = "out";
57
58/// One shared filtering sampler every catalog material binds —
59/// consumers create a single `wgpu::Sampler` and bind it here.
60pub const CATALOG_SAMPLER: &str = "catalog:sampler";
61
62/// Standard single-input post-process material: input texture
63/// at binding 0, shared sampler at 1, params uniform at 2.
64pub(crate) fn post_material(
65    name: &str,
66    wgsl: &str,
67    input: &ResourceId,
68    params_resource: &str,
69) -> Material {
70    Material {
71        name: name.to_string(),
72        shader: ShaderSource::inline(wgsl),
73        bindings: vec![
74            UniformBinding {
75                binding: 0,
76                kind: BindingKind::Texture,
77                resource: input.clone(),
78            },
79            UniformBinding {
80                binding: 1,
81                kind: BindingKind::Sampler,
82                resource: CATALOG_SAMPLER.into(),
83            },
84            UniformBinding {
85                binding: 2,
86                kind: BindingKind::Uniform,
87                resource: params_resource.into(),
88            },
89        ],
90    }
91}
92
93/// The catalog registry. `ALL` is emitted by the derive — adding
94/// a variant mechanically grows the registry and breaks every
95/// exhaustive match below until the effect is fully wired.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, AllVariants)]
97pub enum CatalogEffect {
98    Colorblind,
99    Crt,
100    Scanlines,
101    Bloom,
102    GlowOnBell,
103    Snow,
104    Aurora,
105    Grain,
106}
107
108impl CatalogEffect {
109    /// Operator-facing effect name (matches the `(defeffect …)`
110    /// form name and the YAML toggle key).
111    #[must_use]
112    pub const fn name(self) -> &'static str {
113        match self {
114            Self::Colorblind => colorblind::EFFECT_NAME,
115            Self::Crt => crt::EFFECT_NAME,
116            Self::Scanlines => scanlines::EFFECT_NAME,
117            Self::Bloom => bloom::EFFECT_NAME,
118            Self::GlowOnBell => glow_on_bell::EFFECT_NAME,
119            Self::Snow => snow::EFFECT_NAME,
120            Self::Aurora => aurora::EFFECT_NAME,
121            Self::Grain => grain::EFFECT_NAME,
122        }
123    }
124
125    /// Render-order priority — all catalog effects live in
126    /// engawa's post range (200..=799).
127    #[must_use]
128    pub const fn priority(self) -> u16 {
129        match self {
130            Self::Colorblind => colorblind::PRIORITY,
131            Self::Crt => crt::PRIORITY,
132            Self::Scanlines => scanlines::PRIORITY,
133            Self::Bloom => bloom::PRIORITY,
134            Self::GlowOnBell => glow_on_bell::PRIORITY,
135            Self::Snow => snow::PRIORITY,
136            Self::Aurora => aurora::PRIORITY,
137            Self::Grain => grain::PRIORITY,
138        }
139    }
140
141    /// Resource id of the effect's params uniform buffer.
142    #[must_use]
143    pub const fn params_resource(self) -> &'static str {
144        match self {
145            Self::Colorblind => colorblind::PARAMS_RESOURCE,
146            Self::Crt => crt::PARAMS_RESOURCE,
147            Self::Scanlines => scanlines::PARAMS_RESOURCE,
148            Self::Bloom => bloom::PARAMS_RESOURCE,
149            Self::GlowOnBell => glow_on_bell::PARAMS_RESOURCE,
150            Self::Snow => snow::PARAMS_RESOURCE,
151            Self::Aurora => aurora::PARAMS_RESOURCE,
152            Self::Grain => grain::PARAMS_RESOURCE,
153        }
154    }
155
156    /// `size_of` the effect's Params struct — must equal the
157    /// `(params-size N)` declared in the effect's `.tlisp` form
158    /// (enforced by the matrix test).
159    #[must_use]
160    pub const fn params_size(self) -> usize {
161        match self {
162            Self::Colorblind => size_of::<colorblind::ColorblindParams>(),
163            Self::Crt => size_of::<crt::CrtParams>(),
164            Self::Scanlines => size_of::<scanlines::ScanlinesParams>(),
165            Self::Bloom => size_of::<bloom::BloomParams>(),
166            Self::GlowOnBell => size_of::<glow_on_bell::GlowOnBellParams>(),
167            Self::Snow => size_of::<snow::SnowParams>(),
168            Self::Aurora => size_of::<aurora::AuroraParams>(),
169            Self::Grain => size_of::<grain::GrainParams>(),
170        }
171    }
172
173    /// Repo-relative path of the effect's `(defeffect …)` form.
174    #[must_use]
175    pub const fn tlisp_path(self) -> &'static str {
176        match self {
177            Self::Colorblind => "effects/colorblind.tlisp",
178            Self::Crt => "effects/crt.tlisp",
179            Self::Scanlines => "effects/scanlines.tlisp",
180            Self::Bloom => "effects/bloom.tlisp",
181            Self::GlowOnBell => "effects/glow_on_bell.tlisp",
182            Self::Snow => "effects/snow.tlisp",
183            Self::Aurora => "effects/aurora.tlisp",
184            Self::Grain => "effects/grain.tlisp",
185        }
186    }
187
188    /// Default params, bytemuck-encoded — ready to seed a
189    /// uniform buffer or a [`crate::FrameUniforms`] entry. Going
190    /// through `bytemuck::bytes_of` is also the matrix test's
191    /// Pod proof: a non-Pod Params would not compile here.
192    #[must_use]
193    pub fn default_params_bytes(self) -> Vec<u8> {
194        match self {
195            Self::Colorblind => {
196                bytemuck::bytes_of(&colorblind::ColorblindParams::default()).to_vec()
197            }
198            Self::Crt => bytemuck::bytes_of(&crt::CrtParams::default()).to_vec(),
199            Self::Scanlines => {
200                bytemuck::bytes_of(&scanlines::ScanlinesParams::default()).to_vec()
201            }
202            Self::Bloom => bytemuck::bytes_of(&bloom::BloomParams::default()).to_vec(),
203            Self::GlowOnBell => {
204                bytemuck::bytes_of(&glow_on_bell::GlowOnBellParams::default()).to_vec()
205            }
206            Self::Snow => bytemuck::bytes_of(&snow::SnowParams::default()).to_vec(),
207            Self::Aurora => bytemuck::bytes_of(&aurora::AuroraParams::default()).to_vec(),
208            Self::Grain => bytemuck::bytes_of(&grain::GrainParams::default()).to_vec(),
209        }
210    }
211
212    /// The operator-facing toggle unit (engawa `Effect`). For
213    /// multi-node effects (bloom) this carries the material
214    /// that lands on the output; [`CatalogEffect::lower`] is the
215    /// canonical node surface either way.
216    #[must_use]
217    pub fn effect(self) -> Effect {
218        match self {
219            Self::Colorblind => colorblind::effect(),
220            Self::Crt => crt::effect(),
221            Self::Scanlines => scanlines::effect(),
222            Self::Bloom => bloom::effect(),
223            Self::GlowOnBell => glow_on_bell::effect(),
224            Self::Snow => snow::effect(),
225            Self::Aurora => aurora::effect(),
226            Self::Grain => grain::effect(),
227        }
228    }
229
230    /// Canonical Effect → Node lowering: read `input`, write
231    /// `output`, plus the effect's internal ping-pong nodes
232    /// (bloom emits 4 nodes; everything else 1).
233    #[must_use]
234    pub fn lower(self, input: &ResourceId, output: &ResourceId) -> Vec<Node> {
235        match self {
236            Self::Colorblind => colorblind::lower(input, output),
237            Self::Crt => crt::lower(input, output),
238            Self::Scanlines => scanlines::lower(input, output),
239            Self::Bloom => bloom::lower(input, output),
240            Self::GlowOnBell => glow_on_bell::lower(input, output),
241            Self::Snow => snow::lower(input, output),
242            Self::Aurora => aurora::lower(input, output),
243            Self::Grain => grain::lower(input, output),
244        }
245    }
246
247    /// Intermediate (node-produced) resources the lowering
248    /// introduces beyond `input`/`output` — the consumer leases
249    /// these from a [`crate::TexturePool`] and the graph
250    /// declares them.
251    #[must_use]
252    pub fn aux_resources(self) -> Vec<(&'static str, ResourceKind)> {
253        match self {
254            Self::Bloom => vec![
255                (
256                    bloom::BRIGHT_RESOURCE,
257                    ResourceKind::Texture { width: None, height: None },
258                ),
259                (
260                    bloom::BLUR_H_RESOURCE,
261                    ResourceKind::Texture { width: None, height: None },
262                ),
263                (
264                    bloom::BLUR_V_RESOURCE,
265                    ResourceKind::Texture { width: None, height: None },
266                ),
267            ],
268            Self::Colorblind
269            | Self::Crt
270            | Self::Scanlines
271            | Self::GlowOnBell
272            | Self::Snow
273            | Self::Aurora
274            | Self::Grain => Vec::new(),
275        }
276    }
277
278    /// The canonical single-effect graph: [`SCENE`] (input) →
279    /// effect nodes → [`OUT`], with the sampler + params uniform
280    /// declared as graph inputs. `graph().compile()` succeeding
281    /// is the matrix test's wiring proof.
282    #[must_use]
283    pub fn graph(self) -> RenderGraph {
284        let scene: ResourceId = SCENE.into();
285        let out: ResourceId = OUT.into();
286        let params_size =
287            u32::try_from(self.params_size()).expect("catalog params structs are tiny");
288        let mut g = RenderGraph::default()
289            .with_resource(SCENE, ResourceKind::Texture { width: None, height: None })
290            .with_resource(OUT, ResourceKind::Texture { width: None, height: None })
291            .with_resource(CATALOG_SAMPLER, ResourceKind::Sampler)
292            .with_resource(
293                self.params_resource(),
294                ResourceKind::Uniform { size_bytes: params_size },
295            )
296            .with_input(SCENE)
297            .with_input(CATALOG_SAMPLER)
298            .with_input(self.params_resource())
299            .with_output(OUT);
300        for (id, kind) in self.aux_resources() {
301            g = g.with_resource(id, kind);
302        }
303        for node in self.lower(&scene, &out) {
304            g = g.with_node(node);
305        }
306        g
307    }
308}