engawa-snow 0.1.5

Flagship snow effect for engawa. Layered parallax snowflakes + cursor-deflection + typing-pulse + accumulation pile, authored in engawa-lisp + WGSL, dispatched through engawa-wgpu. Embeds shader + graph; exposes typed SnowParams uniform and apply() helper for per-frame state push.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! engawa-snow — flagship snow effect, end-to-end demonstration
//! of engawa + engawa-lisp + engawa-wgpu.
//!
//! ```text
//!   ┌──────────────┐    ┌──────────────────┐    ┌──────────────┐
//!   │ snow.tlisp   │──► │ engawa::Render-  │──► │ engawa-wgpu  │
//!   │ snow.wgsl    │    │ Graph (compiled) │    │ pipeline     │
//!   └──────────────┘    └──────────────────┘    └──────────────┘
//!         │                                            ▲
//!         └─── embedded via include_str! ──────────────┘
//! ```
//!
//! Operators get one struct (`SnowEffect::new()`), a typed
//! per-frame state push (`SnowParams`), and a `compiled_graph()`
//! / `material_name()` pair to feed any engawa Dispatcher.
//!
//! ## Usage
//!
//! ```
//! use engawa_snow::{SnowEffect, SnowParams};
//!
//! let _effect = SnowEffect::new().expect("snow lisp lowers cleanly");
//! let mut state = SnowParams::default()
//!     .with_resolution([800.0, 600.0])
//!     .with_intensity(0.85)
//!     .with_layer_count(3.0);
//!
//! // each frame:
//! state.set_time(1.0);
//! state.set_cursor([100.0, 200.0]);
//! state.set_wind(0.3);
//! // queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&state));
//! ```
//!
//! Pairs with shikumi's notify watcher if you want hot-reload
//! of the snow.tlisp / snow.wgsl pair — re-call `SnowEffect::new()`
//! and swap the compiled graph in place.

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/engawa-snow/0.1.0")]

use bytemuck::{Pod, Zeroable};
use engawa::{CompiledGraph, Material, RenderGraph, ShaderSource};

/// The snow.tlisp graph topology — embedded at compile time.
pub const SNOW_TLISP: &str = include_str!("../assets/snow.tlisp");

/// The snow.wgsl fragment shader — embedded at compile time.
pub const SNOW_WGSL: &str = include_str!("../assets/snow.wgsl");

/// Material name as authored in snow.tlisp. Use this to look
/// up the material in the compiled graph or to bind a wgpu
/// pipeline by name.
pub const SNOW_MATERIAL_NAME: &str = "snow";

/// Uniform-buffer resource name as authored in snow.tlisp.
pub const SNOW_UNIFORM_RESOURCE: &str = "frame";

/// Uniform-buffer byte size declared in snow.tlisp.
/// `SnowParams` is exactly this many bytes; if you change the
/// struct, update the .tlisp.
pub const SNOW_UNIFORM_SIZE: usize = 64;

/// Per-frame snow uniform. 64 bytes, std140-friendly (every
/// field is a vec4-aligned tuple of f32s). Push via
/// `queue.write_buffer(buf, 0, bytemuck::bytes_of(&params))`.
///
/// Tuple layout (4 floats each):
/// * `frame      = (time_seconds, intensity, wind, typing_pulse)`
/// * `params     = (accumulation, layer_count, temperature, _)`
///   - `temperature`: 0 = freezing (pile grows from incoming
///     snowfall, no melt), 0.5 = neutral (no growth, no melt),
///     1 = warm (pile melts visibly + tint shifts cool-blue).
/// * `resolution = (width, height, _, _)`
/// * `cursor     = (x, y, _, _)`  in pixel coords; (<0, <0) = none
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
pub struct SnowParams {
    pub frame: [f32; 4],
    pub params: [f32; 4],
    pub resolution: [f32; 4],
    pub cursor: [f32; 4],
}

impl Default for SnowParams {
    fn default() -> Self {
        Self {
            frame: [0.0, 1.0, 0.0, 0.0],     // time, intensity, wind, typing_pulse
            params: [0.0, 3.0, 0.0, 0.0],    // accumulation, layer_count, temperature, _
            resolution: [800.0, 600.0, 0.0, 0.0],
            cursor: [-1.0, -1.0, 0.0, 0.0],  // no cursor
        }
    }
}

impl SnowParams {
    /// Set `time_seconds`. Drives all motion.
    #[must_use]
    pub fn with_time(mut self, t: f32) -> Self {
        self.frame[0] = t;
        self
    }
    pub fn set_time(&mut self, t: f32) {
        self.frame[0] = t;
    }

    /// Master gain. 0..1, default 1.0.
    #[must_use]
    pub fn with_intensity(mut self, i: f32) -> Self {
        self.frame[1] = i.clamp(0.0, 1.0);
        self
    }
    pub fn set_intensity(&mut self, i: f32) {
        self.frame[1] = i.clamp(0.0, 1.0);
    }

    /// Horizontal wind. -1..1. Drives snowflake drift +
    /// accumulation pile shape.
    #[must_use]
    pub fn with_wind(mut self, w: f32) -> Self {
        self.frame[2] = w.clamp(-1.0, 1.0);
        self
    }
    pub fn set_wind(&mut self, w: f32) {
        self.frame[2] = w.clamp(-1.0, 1.0);
    }

    /// Typing pulse. 0..1. Decays toward 0 between keystrokes;
    /// caller is responsible for the decay (typical: pulse =
    /// (pulse * 0.92).max(0.0) per frame).
    #[must_use]
    pub fn with_typing_pulse(mut self, p: f32) -> Self {
        self.frame[3] = p.clamp(0.0, 1.0);
        self
    }
    pub fn set_typing_pulse(&mut self, p: f32) {
        self.frame[3] = p.clamp(0.0, 1.0);
    }
    /// Inject a fresh typing pulse, taking the max of the
    /// existing pulse so a slow decay doesn't swallow a rapid
    /// burst.
    pub fn pulse_typing(&mut self, p: f32) {
        self.frame[3] = self.frame[3].max(p.clamp(0.0, 1.0));
    }

    /// Ground accumulation. 0..1. 0 = no pile; 1 = thick pile
    /// covering ~25% of screen height.
    #[must_use]
    pub fn with_accumulation(mut self, a: f32) -> Self {
        self.params[0] = a.clamp(0.0, 1.0);
        self
    }
    pub fn set_accumulation(&mut self, a: f32) {
        self.params[0] = a.clamp(0.0, 1.0);
    }

    /// Number of parallax layers. 1..3.
    #[must_use]
    pub fn with_layer_count(mut self, n: f32) -> Self {
        self.params[1] = n.clamp(1.0, 3.0);
        self
    }
    pub fn set_layer_count(&mut self, n: f32) {
        self.params[1] = n.clamp(1.0, 3.0);
    }

    /// Temperature (0..1). 0 = freezing — pile accumulates from
    /// incoming snowfall, no melt. 0.5 = neutral. 1 = warm —
    /// pile melts visibly (shrinks over time, tint shifts to
    /// cool blue).
    #[must_use]
    pub fn with_temperature(mut self, t: f32) -> Self {
        self.params[2] = t.clamp(0.0, 1.0);
        self
    }
    pub fn set_temperature(&mut self, t: f32) {
        self.params[2] = t.clamp(0.0, 1.0);
    }

    /// Screen resolution in pixels. Used for aspect-correct
    /// noise + cursor normalization.
    #[must_use]
    pub fn with_resolution(mut self, [w, h]: [f32; 2]) -> Self {
        self.resolution[0] = w;
        self.resolution[1] = h;
        self
    }
    pub fn set_resolution(&mut self, [w, h]: [f32; 2]) {
        self.resolution[0] = w;
        self.resolution[1] = h;
    }

    /// Cursor position in pixels. Pass (-1, -1) to disable
    /// cursor deflection. Drives the near-layer deflection
    /// ring + (in future) wind injection.
    #[must_use]
    pub fn with_cursor(mut self, [x, y]: [f32; 2]) -> Self {
        self.cursor[0] = x;
        self.cursor[1] = y;
        self
    }
    pub fn set_cursor(&mut self, [x, y]: [f32; 2]) {
        self.cursor[0] = x;
        self.cursor[1] = y;
    }
}

#[derive(Debug, thiserror::Error)]
pub enum SnowError {
    #[error("engawa-lisp error: {0}")]
    Lisp(#[from] engawa_lisp::EngawaLispError),
    #[error("engawa compile error: {0}")]
    Compile(#[from] engawa::EngawaError),
    #[error("expected material '{0}' in snow.tlisp but didn't find it after lower")]
    MissingMaterial(String),
}

/// The shipped snow effect. Holds the compiled engawa
/// `RenderGraph`; consumers feed it to any `Dispatcher` impl
/// (engawa-wgpu, or a custom one).
pub struct SnowEffect {
    graph: CompiledGraph,
}

impl SnowEffect {
    /// Build the snow effect: parse snow.tlisp via engawa-lisp,
    /// substitute the embedded WGSL for the `path` reference,
    /// compile the graph. Pure; no GPU work.
    pub fn new() -> Result<Self, SnowError> {
        let raw = engawa_lisp::parse_and_lower(SNOW_TLISP)?;
        let raw = substitute_shader(raw, SNOW_WGSL)?;
        let graph = raw.compile()?;
        Ok(Self { graph })
    }

    /// Borrow the compiled graph for dispatch.
    #[must_use]
    pub fn compiled_graph(&self) -> &CompiledGraph {
        &self.graph
    }

    /// Move out the compiled graph (if the consumer wants
    /// ownership).
    #[must_use]
    pub fn into_compiled_graph(self) -> CompiledGraph {
        self.graph
    }

    /// Borrow the bare snow `Material` — use this when you want
    /// to compose snow as an *overlay* on top of an existing
    /// render target (e.g. mado painting snow on top of text)
    /// instead of running the default clear+snow graph.
    pub fn material(&self) -> &Material {
        self.graph
            .iter_nodes()
            .find_map(|n| n.material.as_ref())
            .expect("snow graph always contains the snow material")
    }

    /// Build a compiled overlay graph: a single fullscreen-effect
    /// node that reads `scene` (the existing surface contents) and
    /// writes `out` (the same surface, composited on top). Consumers
    /// bind both `scene` and `out` to the same wgpu `TextureView` at
    /// dispatch time — engawa-wgpu uses `LoadOp::Load` so this acts
    /// as an in-place overlay.
    pub fn overlay_graph() -> Result<CompiledGraph, SnowError> {
        let mat = SnowEffect::new()?.material().clone();
        use engawa::{Node, ResourceKind};
        let g = RenderGraph::default()
            .with_resource(
                "scene",
                ResourceKind::Texture { width: None, height: None },
            )
            .with_resource(
                "out",
                ResourceKind::Texture { width: None, height: None },
            )
            .with_input("scene")
            .with_output("out")
            .with_node(Node::fullscreen_effect(
                "snow-overlay",
                mat,
                "scene",
                "out",
            ))
            .compile()?;
        Ok(g)
    }
}

/// Walk the lowered graph; for any node whose material is
/// `snow` with a `ShaderSource::Path`, replace with inline WGSL.
fn substitute_shader(
    mut graph: RenderGraph,
    wgsl: &str,
) -> Result<RenderGraph, SnowError> {
    let mut found = false;
    for node in &mut graph.nodes {
        if let Some(mat) = node.material.as_mut() {
            if mat.name == SNOW_MATERIAL_NAME {
                mat.shader = ShaderSource::inline(wgsl.to_string());
                found = true;
            }
        }
    }
    if !found {
        return Err(SnowError::MissingMaterial(SNOW_MATERIAL_NAME.to_string()));
    }
    Ok(graph)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn snow_params_default_is_64_bytes() {
        assert_eq!(std::mem::size_of::<SnowParams>(), SNOW_UNIFORM_SIZE);
    }

    #[test]
    fn snow_params_is_pod() {
        // bytemuck::Pod is the actual proof — this would fail
        // to compile if the derive didn't match.
        let p = SnowParams::default();
        let bytes = bytemuck::bytes_of(&p);
        assert_eq!(bytes.len(), SNOW_UNIFORM_SIZE);
    }

    #[test]
    fn builders_clamp_within_range() {
        let p = SnowParams::default()
            .with_intensity(2.0)
            .with_wind(-99.0)
            .with_typing_pulse(5.5)
            .with_accumulation(-0.5)
            .with_layer_count(99.0);
        assert_eq!(p.frame[1], 1.0);
        assert_eq!(p.frame[2], -1.0);
        assert_eq!(p.frame[3], 1.0);
        assert_eq!(p.params[0], 0.0);
        assert_eq!(p.params[1], 3.0);
    }

    #[test]
    fn pulse_typing_takes_max_not_overwrite() {
        let mut p = SnowParams::default().with_typing_pulse(0.6);
        p.pulse_typing(0.3);
        assert_eq!(p.frame[3], 0.6, "rapid existing pulse must survive a smaller injected one");
        p.pulse_typing(0.9);
        assert_eq!(p.frame[3], 0.9);
    }

    #[test]
    fn snow_effect_parses_and_compiles() {
        let e = SnowEffect::new().expect("snow.tlisp + snow.wgsl must round-trip");
        assert_eq!(e.compiled_graph().node_count(), 2);
    }

    #[test]
    fn snow_effect_node_order_is_clear_then_snow_pass() {
        let e = SnowEffect::new().unwrap();
        let names: Vec<_> = e
            .compiled_graph()
            .iter_nodes()
            .map(|n| n.id.as_str().to_string())
            .collect();
        assert_eq!(names, vec!["clear-scene", "snow-pass"]);
    }

    #[test]
    fn snow_effect_material_has_uniform_binding() {
        let e = SnowEffect::new().unwrap();
        let snow_pass = e
            .compiled_graph()
            .iter_nodes()
            .find(|n| n.id.as_str() == "snow-pass")
            .unwrap();
        let mat = snow_pass.material.as_ref().unwrap();
        assert_eq!(mat.name, SNOW_MATERIAL_NAME);
        assert_eq!(mat.bindings.len(), 1);
        assert_eq!(mat.bindings[0].binding, 0);
        assert_eq!(mat.bindings[0].resource.as_str(), SNOW_UNIFORM_RESOURCE);
    }

    #[test]
    fn snow_effect_shader_is_substituted_inline_not_path() {
        let e = SnowEffect::new().unwrap();
        let snow_pass = e
            .compiled_graph()
            .iter_nodes()
            .find(|n| n.id.as_str() == "snow-pass")
            .unwrap();
        let mat = snow_pass.material.as_ref().unwrap();
        match &mat.shader {
            ShaderSource::Inline { wgsl } => {
                assert!(wgsl.contains("fn fs_main"), "embedded shader must include fs_main");
                assert!(wgsl.contains("SnowParams"), "embedded shader must declare SnowParams");
            }
            ShaderSource::Path { path } => panic!("shader should be substituted inline, still path: {path}"),
        }
    }

    #[test]
    fn overlay_graph_compiles_with_single_snow_node() {
        let g = SnowEffect::overlay_graph().expect("overlay graph compiles");
        assert_eq!(g.node_count(), 1);
        let n = g.iter_nodes().next().unwrap();
        assert_eq!(n.id.as_str(), "snow-overlay");
        let mat = n.material.as_ref().unwrap();
        assert_eq!(mat.name, SNOW_MATERIAL_NAME);
    }

    #[test]
    fn snow_wgsl_is_well_formed_minimum() {
        // Cheap structural test: ensure the asset isn't empty
        // and contains the key WGSL anchors. Catches accidental
        // truncation of the embedded asset.
        assert!(SNOW_WGSL.len() > 1000, "snow.wgsl looks suspiciously small");
        assert!(SNOW_WGSL.contains("@fragment"));
        assert!(SNOW_WGSL.contains("fn snow_layer"));
        assert!(SNOW_WGSL.contains("fn pile_particles"));
        assert!(SNOW_WGSL.contains("fn fractal_dendrite"));
        assert!(SNOW_WGSL.contains("fn grade"));
    }
}