#![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};
pub const SNOW_TLISP: &str = include_str!("../assets/snow.tlisp");
pub const SNOW_WGSL: &str = include_str!("../assets/snow.wgsl");
pub const SNOW_MATERIAL_NAME: &str = "snow";
pub const SNOW_UNIFORM_RESOURCE: &str = "frame";
pub const SNOW_UNIFORM_SIZE: usize = 64;
#[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], params: [0.0, 3.0, 0.0, 0.0], resolution: [800.0, 600.0, 0.0, 0.0],
cursor: [-1.0, -1.0, 0.0, 0.0], }
}
}
impl SnowParams {
#[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;
}
#[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);
}
#[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);
}
#[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);
}
pub fn pulse_typing(&mut self, p: f32) {
self.frame[3] = self.frame[3].max(p.clamp(0.0, 1.0));
}
#[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);
}
#[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);
}
#[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);
}
#[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;
}
#[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),
}
pub struct SnowEffect {
graph: CompiledGraph,
}
impl SnowEffect {
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 })
}
#[must_use]
pub fn compiled_graph(&self) -> &CompiledGraph {
&self.graph
}
#[must_use]
pub fn into_compiled_graph(self) -> CompiledGraph {
self.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")
}
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)
}
}
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() {
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() {
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"));
}
}