Skip to main content

engawa_snow/
lib.rs

1//! engawa-snow — flagship snow effect, end-to-end demonstration
2//! of engawa + engawa-lisp + engawa-wgpu.
3//!
4//! ```text
5//!   ┌──────────────┐    ┌──────────────────┐    ┌──────────────┐
6//!   │ snow.tlisp   │──► │ engawa::Render-  │──► │ engawa-wgpu  │
7//!   │ snow.wgsl    │    │ Graph (compiled) │    │ pipeline     │
8//!   └──────────────┘    └──────────────────┘    └──────────────┘
9//!         │                                            ▲
10//!         └─── embedded via include_str! ──────────────┘
11//! ```
12//!
13//! Operators get one struct (`SnowEffect::new()`), a typed
14//! per-frame state push (`SnowParams`), and a `compiled_graph()`
15//! / `material_name()` pair to feed any engawa Dispatcher.
16//!
17//! ## Usage
18//!
19//! ```
20//! use engawa_snow::{SnowEffect, SnowParams};
21//!
22//! let _effect = SnowEffect::new().expect("snow lisp lowers cleanly");
23//! let mut state = SnowParams::default()
24//!     .with_resolution([800.0, 600.0])
25//!     .with_intensity(0.85)
26//!     .with_layer_count(3.0);
27//!
28//! // each frame:
29//! state.set_time(1.0);
30//! state.set_cursor([100.0, 200.0]);
31//! state.set_wind(0.3);
32//! // queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&state));
33//! ```
34//!
35//! Pairs with shikumi's notify watcher if you want hot-reload
36//! of the snow.tlisp / snow.wgsl pair — re-call `SnowEffect::new()`
37//! and swap the compiled graph in place.
38
39#![forbid(unsafe_code)]
40#![doc(html_root_url = "https://docs.rs/engawa-snow/0.1.0")]
41
42use bytemuck::{Pod, Zeroable};
43use engawa::{CompiledGraph, Material, RenderGraph, ShaderSource};
44
45/// The snow.tlisp graph topology — embedded at compile time.
46pub const SNOW_TLISP: &str = include_str!("../assets/snow.tlisp");
47
48/// The snow.wgsl fragment shader — embedded at compile time.
49pub const SNOW_WGSL: &str = include_str!("../assets/snow.wgsl");
50
51/// Material name as authored in snow.tlisp. Use this to look
52/// up the material in the compiled graph or to bind a wgpu
53/// pipeline by name.
54pub const SNOW_MATERIAL_NAME: &str = "snow";
55
56/// Uniform-buffer resource name as authored in snow.tlisp.
57pub const SNOW_UNIFORM_RESOURCE: &str = "frame";
58
59/// Uniform-buffer byte size declared in snow.tlisp.
60/// `SnowParams` is exactly this many bytes; if you change the
61/// struct, update the .tlisp.
62pub const SNOW_UNIFORM_SIZE: usize = 64;
63
64/// Per-frame snow uniform. 64 bytes, std140-friendly (every
65/// field is a vec4-aligned tuple of f32s). Push via
66/// `queue.write_buffer(buf, 0, bytemuck::bytes_of(&params))`.
67///
68/// Tuple layout (4 floats each):
69/// * `frame      = (time_seconds, intensity, wind, typing_pulse)`
70/// * `params     = (accumulation, layer_count, temperature, _)`
71///   - `temperature`: 0 = freezing (pile grows from incoming
72///     snowfall, no melt), 0.5 = neutral (no growth, no melt),
73///     1 = warm (pile melts visibly + tint shifts cool-blue).
74/// * `resolution = (width, height, _, _)`
75/// * `cursor     = (x, y, _, _)`  in pixel coords; (<0, <0) = none
76#[repr(C)]
77#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
78pub struct SnowParams {
79    pub frame: [f32; 4],
80    pub params: [f32; 4],
81    pub resolution: [f32; 4],
82    pub cursor: [f32; 4],
83}
84
85impl Default for SnowParams {
86    fn default() -> Self {
87        Self {
88            frame: [0.0, 1.0, 0.0, 0.0],     // time, intensity, wind, typing_pulse
89            params: [0.0, 3.0, 0.0, 0.0],    // accumulation, layer_count, temperature, _
90            resolution: [800.0, 600.0, 0.0, 0.0],
91            cursor: [-1.0, -1.0, 0.0, 0.0],  // no cursor
92        }
93    }
94}
95
96impl SnowParams {
97    /// Set `time_seconds`. Drives all motion.
98    #[must_use]
99    pub fn with_time(mut self, t: f32) -> Self {
100        self.frame[0] = t;
101        self
102    }
103    pub fn set_time(&mut self, t: f32) {
104        self.frame[0] = t;
105    }
106
107    /// Master gain. 0..1, default 1.0.
108    #[must_use]
109    pub fn with_intensity(mut self, i: f32) -> Self {
110        self.frame[1] = i.clamp(0.0, 1.0);
111        self
112    }
113    pub fn set_intensity(&mut self, i: f32) {
114        self.frame[1] = i.clamp(0.0, 1.0);
115    }
116
117    /// Horizontal wind. -1..1. Drives snowflake drift +
118    /// accumulation pile shape.
119    #[must_use]
120    pub fn with_wind(mut self, w: f32) -> Self {
121        self.frame[2] = w.clamp(-1.0, 1.0);
122        self
123    }
124    pub fn set_wind(&mut self, w: f32) {
125        self.frame[2] = w.clamp(-1.0, 1.0);
126    }
127
128    /// Typing pulse. 0..1. Decays toward 0 between keystrokes;
129    /// caller is responsible for the decay (typical: pulse =
130    /// (pulse * 0.92).max(0.0) per frame).
131    #[must_use]
132    pub fn with_typing_pulse(mut self, p: f32) -> Self {
133        self.frame[3] = p.clamp(0.0, 1.0);
134        self
135    }
136    pub fn set_typing_pulse(&mut self, p: f32) {
137        self.frame[3] = p.clamp(0.0, 1.0);
138    }
139    /// Inject a fresh typing pulse, taking the max of the
140    /// existing pulse so a slow decay doesn't swallow a rapid
141    /// burst.
142    pub fn pulse_typing(&mut self, p: f32) {
143        self.frame[3] = self.frame[3].max(p.clamp(0.0, 1.0));
144    }
145
146    /// Ground accumulation. 0..1. 0 = no pile; 1 = thick pile
147    /// covering ~25% of screen height.
148    #[must_use]
149    pub fn with_accumulation(mut self, a: f32) -> Self {
150        self.params[0] = a.clamp(0.0, 1.0);
151        self
152    }
153    pub fn set_accumulation(&mut self, a: f32) {
154        self.params[0] = a.clamp(0.0, 1.0);
155    }
156
157    /// Number of parallax layers. 1..3.
158    #[must_use]
159    pub fn with_layer_count(mut self, n: f32) -> Self {
160        self.params[1] = n.clamp(1.0, 3.0);
161        self
162    }
163    pub fn set_layer_count(&mut self, n: f32) {
164        self.params[1] = n.clamp(1.0, 3.0);
165    }
166
167    /// Temperature (0..1). 0 = freezing — pile accumulates from
168    /// incoming snowfall, no melt. 0.5 = neutral. 1 = warm —
169    /// pile melts visibly (shrinks over time, tint shifts to
170    /// cool blue).
171    #[must_use]
172    pub fn with_temperature(mut self, t: f32) -> Self {
173        self.params[2] = t.clamp(0.0, 1.0);
174        self
175    }
176    pub fn set_temperature(&mut self, t: f32) {
177        self.params[2] = t.clamp(0.0, 1.0);
178    }
179
180    /// Screen resolution in pixels. Used for aspect-correct
181    /// noise + cursor normalization.
182    #[must_use]
183    pub fn with_resolution(mut self, [w, h]: [f32; 2]) -> Self {
184        self.resolution[0] = w;
185        self.resolution[1] = h;
186        self
187    }
188    pub fn set_resolution(&mut self, [w, h]: [f32; 2]) {
189        self.resolution[0] = w;
190        self.resolution[1] = h;
191    }
192
193    /// Cursor position in pixels. Pass (-1, -1) to disable
194    /// cursor deflection. Drives the near-layer deflection
195    /// ring + (in future) wind injection.
196    #[must_use]
197    pub fn with_cursor(mut self, [x, y]: [f32; 2]) -> Self {
198        self.cursor[0] = x;
199        self.cursor[1] = y;
200        self
201    }
202    pub fn set_cursor(&mut self, [x, y]: [f32; 2]) {
203        self.cursor[0] = x;
204        self.cursor[1] = y;
205    }
206}
207
208#[derive(Debug, thiserror::Error)]
209pub enum SnowError {
210    #[error("engawa-lisp error: {0}")]
211    Lisp(#[from] engawa_lisp::EngawaLispError),
212    #[error("engawa compile error: {0}")]
213    Compile(#[from] engawa::EngawaError),
214    #[error("expected material '{0}' in snow.tlisp but didn't find it after lower")]
215    MissingMaterial(String),
216}
217
218/// The shipped snow effect. Holds the compiled engawa
219/// `RenderGraph`; consumers feed it to any `Dispatcher` impl
220/// (engawa-wgpu, or a custom one).
221pub struct SnowEffect {
222    graph: CompiledGraph,
223}
224
225impl SnowEffect {
226    /// Build the snow effect: parse snow.tlisp via engawa-lisp,
227    /// substitute the embedded WGSL for the `path` reference,
228    /// compile the graph. Pure; no GPU work.
229    pub fn new() -> Result<Self, SnowError> {
230        let raw = engawa_lisp::parse_and_lower(SNOW_TLISP)?;
231        let raw = substitute_shader(raw, SNOW_WGSL)?;
232        let graph = raw.compile()?;
233        Ok(Self { graph })
234    }
235
236    /// Borrow the compiled graph for dispatch.
237    #[must_use]
238    pub fn compiled_graph(&self) -> &CompiledGraph {
239        &self.graph
240    }
241
242    /// Move out the compiled graph (if the consumer wants
243    /// ownership).
244    #[must_use]
245    pub fn into_compiled_graph(self) -> CompiledGraph {
246        self.graph
247    }
248
249    /// Borrow the bare snow `Material` — use this when you want
250    /// to compose snow as an *overlay* on top of an existing
251    /// render target (e.g. mado painting snow on top of text)
252    /// instead of running the default clear+snow graph.
253    pub fn material(&self) -> &Material {
254        self.graph
255            .iter_nodes()
256            .find_map(|n| n.material.as_ref())
257            .expect("snow graph always contains the snow material")
258    }
259
260    /// Build a compiled overlay graph: a single fullscreen-effect
261    /// node that reads `scene` (the existing surface contents) and
262    /// writes `out` (the same surface, composited on top). Consumers
263    /// bind both `scene` and `out` to the same wgpu `TextureView` at
264    /// dispatch time — engawa-wgpu uses `LoadOp::Load` so this acts
265    /// as an in-place overlay.
266    pub fn overlay_graph() -> Result<CompiledGraph, SnowError> {
267        let mat = SnowEffect::new()?.material().clone();
268        use engawa::{Node, ResourceKind};
269        let g = RenderGraph::default()
270            .with_resource(
271                "scene",
272                ResourceKind::Texture { width: None, height: None },
273            )
274            .with_resource(
275                "out",
276                ResourceKind::Texture { width: None, height: None },
277            )
278            .with_input("scene")
279            .with_output("out")
280            .with_node(Node::fullscreen_effect(
281                "snow-overlay",
282                mat,
283                "scene",
284                "out",
285            ))
286            .compile()?;
287        Ok(g)
288    }
289}
290
291/// Walk the lowered graph; for any node whose material is
292/// `snow` with a `ShaderSource::Path`, replace with inline WGSL.
293fn substitute_shader(
294    mut graph: RenderGraph,
295    wgsl: &str,
296) -> Result<RenderGraph, SnowError> {
297    let mut found = false;
298    for node in &mut graph.nodes {
299        if let Some(mat) = node.material.as_mut() {
300            if mat.name == SNOW_MATERIAL_NAME {
301                mat.shader = ShaderSource::inline(wgsl.to_string());
302                found = true;
303            }
304        }
305    }
306    if !found {
307        return Err(SnowError::MissingMaterial(SNOW_MATERIAL_NAME.to_string()));
308    }
309    Ok(graph)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn snow_params_default_is_64_bytes() {
318        assert_eq!(std::mem::size_of::<SnowParams>(), SNOW_UNIFORM_SIZE);
319    }
320
321    #[test]
322    fn snow_params_is_pod() {
323        // bytemuck::Pod is the actual proof — this would fail
324        // to compile if the derive didn't match.
325        let p = SnowParams::default();
326        let bytes = bytemuck::bytes_of(&p);
327        assert_eq!(bytes.len(), SNOW_UNIFORM_SIZE);
328    }
329
330    #[test]
331    fn builders_clamp_within_range() {
332        let p = SnowParams::default()
333            .with_intensity(2.0)
334            .with_wind(-99.0)
335            .with_typing_pulse(5.5)
336            .with_accumulation(-0.5)
337            .with_layer_count(99.0);
338        assert_eq!(p.frame[1], 1.0);
339        assert_eq!(p.frame[2], -1.0);
340        assert_eq!(p.frame[3], 1.0);
341        assert_eq!(p.params[0], 0.0);
342        assert_eq!(p.params[1], 3.0);
343    }
344
345    #[test]
346    fn pulse_typing_takes_max_not_overwrite() {
347        let mut p = SnowParams::default().with_typing_pulse(0.6);
348        p.pulse_typing(0.3);
349        assert_eq!(p.frame[3], 0.6, "rapid existing pulse must survive a smaller injected one");
350        p.pulse_typing(0.9);
351        assert_eq!(p.frame[3], 0.9);
352    }
353
354    #[test]
355    fn snow_effect_parses_and_compiles() {
356        let e = SnowEffect::new().expect("snow.tlisp + snow.wgsl must round-trip");
357        assert_eq!(e.compiled_graph().node_count(), 2);
358    }
359
360    #[test]
361    fn snow_effect_node_order_is_clear_then_snow_pass() {
362        let e = SnowEffect::new().unwrap();
363        let names: Vec<_> = e
364            .compiled_graph()
365            .iter_nodes()
366            .map(|n| n.id.as_str().to_string())
367            .collect();
368        assert_eq!(names, vec!["clear-scene", "snow-pass"]);
369    }
370
371    #[test]
372    fn snow_effect_material_has_uniform_binding() {
373        let e = SnowEffect::new().unwrap();
374        let snow_pass = e
375            .compiled_graph()
376            .iter_nodes()
377            .find(|n| n.id.as_str() == "snow-pass")
378            .unwrap();
379        let mat = snow_pass.material.as_ref().unwrap();
380        assert_eq!(mat.name, SNOW_MATERIAL_NAME);
381        assert_eq!(mat.bindings.len(), 1);
382        assert_eq!(mat.bindings[0].binding, 0);
383        assert_eq!(mat.bindings[0].resource.as_str(), SNOW_UNIFORM_RESOURCE);
384    }
385
386    #[test]
387    fn snow_effect_shader_is_substituted_inline_not_path() {
388        let e = SnowEffect::new().unwrap();
389        let snow_pass = e
390            .compiled_graph()
391            .iter_nodes()
392            .find(|n| n.id.as_str() == "snow-pass")
393            .unwrap();
394        let mat = snow_pass.material.as_ref().unwrap();
395        match &mat.shader {
396            ShaderSource::Inline { wgsl } => {
397                assert!(wgsl.contains("fn fs_main"), "embedded shader must include fs_main");
398                assert!(wgsl.contains("SnowParams"), "embedded shader must declare SnowParams");
399            }
400            ShaderSource::Path { path } => panic!("shader should be substituted inline, still path: {path}"),
401        }
402    }
403
404    #[test]
405    fn overlay_graph_compiles_with_single_snow_node() {
406        let g = SnowEffect::overlay_graph().expect("overlay graph compiles");
407        assert_eq!(g.node_count(), 1);
408        let n = g.iter_nodes().next().unwrap();
409        assert_eq!(n.id.as_str(), "snow-overlay");
410        let mat = n.material.as_ref().unwrap();
411        assert_eq!(mat.name, SNOW_MATERIAL_NAME);
412    }
413
414    #[test]
415    fn snow_wgsl_is_well_formed_minimum() {
416        // Cheap structural test: ensure the asset isn't empty
417        // and contains the key WGSL anchors. Catches accidental
418        // truncation of the embedded asset.
419        assert!(SNOW_WGSL.len() > 1000, "snow.wgsl looks suspiciously small");
420        assert!(SNOW_WGSL.contains("@fragment"));
421        assert!(SNOW_WGSL.contains("fn snow_layer"));
422        assert!(SNOW_WGSL.contains("fn pile_particles"));
423        assert!(SNOW_WGSL.contains("fn fractal_dendrite"));
424        assert!(SNOW_WGSL.contains("fn grade"));
425    }
426}