1#![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
45pub const SNOW_TLISP: &str = include_str!("../assets/snow.tlisp");
47
48pub const SNOW_WGSL: &str = include_str!("../assets/snow.wgsl");
50
51pub const SNOW_MATERIAL_NAME: &str = "snow";
55
56pub const SNOW_UNIFORM_RESOURCE: &str = "frame";
58
59pub const SNOW_UNIFORM_SIZE: usize = 64;
63
64#[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], params: [0.0, 3.0, 0.0, 0.0], resolution: [800.0, 600.0, 0.0, 0.0],
91 cursor: [-1.0, -1.0, 0.0, 0.0], }
93 }
94}
95
96impl SnowParams {
97 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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
218pub struct SnowEffect {
222 graph: CompiledGraph,
223}
224
225impl SnowEffect {
226 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 #[must_use]
238 pub fn compiled_graph(&self) -> &CompiledGraph {
239 &self.graph
240 }
241
242 #[must_use]
245 pub fn into_compiled_graph(self) -> CompiledGraph {
246 self.graph
247 }
248
249 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 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
291fn 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 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 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}