Skip to main content

oxframe_2d/
shader.rs

1//! A shader node: WGSL `Params` plus `shade`, checked with naga.
2//!
3//! The waves preset is rasterized on the CPU, so a check without a GPU
4//! still paints it. Both backends then draw that image, which keeps the
5//! preset the same on either one. The lens_drops preset reads the frame
6//! drawn before it (see [`crate::post`]). A custom `shade` is type-checked
7//! on every draw (a changed file is picked up without a rebuild) and is
8//! not executed.
9
10use std::cell::Cell;
11
12use kurbo::{Affine, Point};
13use oxframe_core::{Color, Signal};
14
15use crate::draw::Primitive;
16use crate::graph::{NodeData, NodeKind};
17use crate::image::RasterImage;
18
19/// Where the WGSL text comes from. Stored on a shader node.
20#[doc(hidden)]
21#[derive(Clone)]
22pub enum ShaderCode {
23    /// Source kept on the node.
24    Wgsl(String),
25    /// Read again on every draw, so an edit shows up without a rebuild.
26    File(std::path::PathBuf),
27}
28
29/// Which built-in a shader node runs. Stored on a shader node.
30#[doc(hidden)]
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Preset {
33    /// User WGSL: checked, not executed.
34    Custom,
35    /// [`waves`](crate::waves): painted inside the node's box.
36    Waves,
37    /// [`lens_drops`](crate::lens_drops): changes the frame drawn before it.
38    LensDrops,
39}
40
41impl Preset {
42    /// `"custom"`, `"waves"` or `"lens_drops"`.
43    pub fn name(self) -> &'static str {
44        match self {
45            Self::Custom => "custom",
46            Self::Waves => "waves",
47            Self::LensDrops => "lens_drops",
48        }
49    }
50}
51
52/// Parameters of the lens_drops preset, all numbers.
53const LENS_PARAMS: [&str; 3] = ["wetness", "seed", "drop_size"];
54
55/// Inputs the lens_drops preset reads.
56const LENS_INPUTS: [&str; 1] = ["scene"];
57
58/// One value passed to `Params`. Stored on a shader node.
59#[doc(hidden)]
60pub struct Uniform {
61    pub name: String,
62    pub value: UniformValue,
63}
64
65/// A number, a point, a color, or a bool stored as 0 or 1.
66#[doc(hidden)]
67#[allow(dead_code)]
68pub enum UniformValue {
69    /// An `f32` field.
70    Num(Signal<f64>),
71    /// A `vec2<f32>` field.
72    Vec2(Signal<kurbo::Vec2>),
73    /// A `vec4<f32>` color field.
74    Color(Signal<Color>),
75    /// A `bool` field, stored as 0 or 1 so it can be animated.
76    Bool(Signal<f64>),
77}
78
79/// A WGSL problem found while painting.
80#[derive(Debug, Clone, PartialEq)]
81pub struct ShaderDiag {
82    /// `.name(..)`, or the node kind and id.
83    pub node: String,
84    /// `file:line` of the `add` call, when it was recorded.
85    pub source: Option<String>,
86    /// `shader_error`, `param_unknown`, or `shader_slow`.
87    pub rule: &'static str,
88    /// What to change.
89    pub hint: String,
90}
91
92const WAVES: &str = r#"struct Params { amp: f32, speed: f32, deep: vec4<f32>, foam: vec4<f32> }
93@group(1) @binding(0) var<uniform> p: Params;
94
95fn shade(i: ShadeIn) -> vec4<f32> {
96    let h = sin(i.world.x * 0.008 + ox.time * p.speed) * p.amp;
97    let t = smoothstep(h - 0.02, h + 0.02, i.uv.y - 0.5);
98    return mix(p.foam, p.deep, t);
99}
100"#;
101
102/// WGSL of the waves preset.
103pub(crate) fn waves_wgsl() -> &'static str {
104    WAVES
105}
106
107#[cfg(not(target_arch = "wasm32"))]
108const PRELUDE: &str = r#"struct Ox {
109    time: f32,
110    frame: f32,
111    resolution: vec2<f32>,
112    size: vec2<f32>,
113    seed: f32,
114    view: mat4x4<f32>,
115    progress: f32,
116}
117@group(0) @binding(0) var<uniform> ox: Ox;
118
119struct ShadeIn {
120    uv: vec2<f32>,
121    local: vec2<f32>,
122    world: vec2<f32>,
123    screen: vec2<f32>,
124}
125
126"#;
127
128#[cfg(not(target_arch = "wasm32"))]
129const EPILOGUE: &str = r#"
130struct _OxVertexOut {
131    @builtin(position) pos: vec4<f32>,
132    @location(0) uv: vec2<f32>,
133    @location(1) local: vec2<f32>,
134    @location(2) world: vec2<f32>,
135    @location(3) screen: vec2<f32>,
136}
137
138@vertex
139fn _ox_vs(@builtin(vertex_index) index: u32) -> _OxVertexOut {
140    var positions = array<vec2<f32>, 3>(
141        vec2<f32>(-1.0, -1.0),
142        vec2<f32>(3.0, -1.0),
143        vec2<f32>(-1.0, 3.0),
144    );
145    let p = positions[index];
146    var out: _OxVertexOut;
147    out.pos = vec4<f32>(p, 0.0, 1.0);
148    out.uv = vec2<f32>(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5);
149    out.local = out.uv;
150    out.world = out.uv;
151    out.screen = out.uv;
152    return out;
153}
154
155@fragment
156fn _ox_fs(in: _OxVertexOut) -> @location(0) vec4<f32> {
157    var s: ShadeIn;
158    s.uv = in.uv;
159    s.local = in.local;
160    s.world = in.world;
161    s.screen = in.screen;
162    return shade(s);
163}
164"#;
165
166thread_local! {
167    static DRAFT: Cell<bool> = const { Cell::new(false) };
168    static BUDGET_MS: Cell<f64> = const { Cell::new(250.0) };
169    static DIAGS: std::cell::RefCell<Vec<ShaderDiag>> = const { std::cell::RefCell::new(Vec::new()) };
170}
171
172pub(crate) fn clear_diags() {
173    DIAGS.with(|d| d.borrow_mut().clear());
174}
175
176pub(crate) fn push_diag(diag: ShaderDiag) {
177    DIAGS.with(|d| d.borrow_mut().push(diag));
178}
179
180pub(crate) fn take_diags() -> Vec<ShaderDiag> {
181    DIAGS.with(|d| std::mem::take(&mut *d.borrow_mut()))
182}
183
184/// Restores the previous draft flag when dropped.
185pub struct ShaderDraft {
186    prev: bool,
187}
188
189impl Drop for ShaderDraft {
190    fn drop(&mut self) {
191        DRAFT.with(|d| d.set(self.prev));
192    }
193}
194
195/// Paints shader nodes at half resolution until dropped.
196///
197/// Timings and positions stay the same. `oxframe check` uses this so a
198/// shader layer does not dominate the budget.
199pub fn draft_shaders() -> ShaderDraft {
200    let prev = DRAFT.with(Cell::get);
201    DRAFT.with(|d| d.set(true));
202    ShaderDraft { prev }
203}
204
205/// Milliseconds a shader raster may take before `shader_slow`.
206pub fn set_shader_budget_ms(ms: f64) {
207    BUDGET_MS.with(|b| b.set(ms));
208}
209
210fn draft() -> bool {
211    DRAFT.with(Cell::get)
212}
213
214fn budget_ms() -> f64 {
215    BUDGET_MS.with(Cell::get)
216}
217
218/// Compile and parameter problems for this node, and a video that could
219/// not be opened. Empty for other nodes.
220pub(crate) fn problems(n: &NodeData) -> Vec<ShaderDiag> {
221    if let NodeKind::Video { data, .. } = &n.kind {
222        return data
223            .problem
224            .iter()
225            .map(|p| ShaderDiag {
226                node: label(n),
227                source: n.source.clone(),
228                rule: "video_unavailable",
229                hint: format!("{}: {p}", data.path.display()),
230            })
231            .collect();
232    }
233    let NodeKind::Shader {
234        code,
235        uniforms,
236        preset,
237        inputs,
238        ..
239    } = &n.kind
240    else {
241        return Vec::new();
242    };
243    let label = label(n);
244    let source = n.source.clone();
245    let diag = |hint: String| {
246        vec![ShaderDiag {
247            node: label.clone(),
248            source: source.clone(),
249            rule: "param_unknown",
250            hint,
251        }]
252    };
253    let known: &[&str] = match preset {
254        Preset::LensDrops => &LENS_INPUTS,
255        Preset::Waves => &[],
256        // Inputs of a custom shade are not executed, so any name is kept.
257        Preset::Custom => &[],
258    };
259    if *preset != Preset::Custom {
260        for (name, _) in inputs {
261            if !known.contains(&name.as_str()) {
262                return diag(unknown("input", name, known));
263            }
264        }
265    }
266    if *preset == Preset::LensDrops {
267        for u in uniforms {
268            if !LENS_PARAMS.contains(&u.name.as_str()) {
269                return diag(unknown("parameter", &u.name, &LENS_PARAMS));
270            }
271            if !matches!(u.value, UniformValue::Num(_)) {
272                return diag(format!("`{}` of lens_drops is a number.", u.name));
273            }
274        }
275        return Vec::new();
276    }
277    let text = match load(code) {
278        Ok(text) => text,
279        Err(hint) => {
280            return vec![ShaderDiag {
281                node: label,
282                source,
283                rule: "shader_error",
284                hint,
285            }];
286        }
287    };
288    match check_wgsl(&text, uniforms) {
289        Ok(()) => Vec::new(),
290        Err(diag) => vec![ShaderDiag {
291            node: label,
292            source,
293            rule: diag.0,
294            hint: diag.1,
295        }],
296    }
297}
298
299/// Raster of a waves node, or nothing when this node is not the preset.
300pub(crate) fn raster(
301    n: &NodeData,
302    size: (f64, f64),
303    tf: Affine,
304    time: f64,
305    opacity: f64,
306) -> Option<Primitive> {
307    let NodeKind::Shader {
308        preset, uniforms, ..
309    } = &n.kind
310    else {
311        return None;
312    };
313    if *preset != Preset::Waves {
314        return None;
315    }
316    let (w, h) = (size.0.max(1.0), size.1.max(1.0));
317    let scale = if draft() { 0.5 } else { 1.0 };
318    let iw = ((w * scale).round() as u32).clamp(1, 1920);
319    let ih = ((h * scale).round() as u32).clamp(1, 1080);
320    let (amp, speed, deep, foam) = wave_uniforms(uniforms);
321    let started = Stopwatch::start();
322    let mut rgba = vec![0u8; iw as usize * ih as usize * 4];
323    for py in 0..ih {
324        for px in 0..iw {
325            let lx = (f64::from(px) + 0.5) / f64::from(iw) * w - w / 2.0;
326            let ly = (f64::from(py) + 0.5) / f64::from(ih) * h - h / 2.0;
327            let world = tf * Point::new(lx, ly);
328            let uv_y = (f64::from(py) + 0.5) / f64::from(ih);
329            let crest = (world.x * 0.008 + time * speed).sin() * amp;
330            let t = smoothstep(crest - 0.02, crest + 0.02, uv_y - 0.5);
331            let c = mix(foam, deep, t, opacity);
332            let i = (py as usize * iw as usize + px as usize) * 4;
333            rgba[i] = byte(c.r);
334            rgba[i + 1] = byte(c.g);
335            rgba[i + 2] = byte(c.b);
336            rgba[i + 3] = byte(c.a);
337        }
338    }
339    let ms = started.millis();
340    if ms > budget_ms() {
341        push_diag(ShaderDiag {
342            node: label(n),
343            source: n.source.clone(),
344            rule: "shader_slow",
345            hint: format!(
346                "This shader took {ms:.0} ms in draft. The budget is {:.0} ms.",
347                budget_ms()
348            ),
349        });
350    }
351    let rect = kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);
352    Some(Primitive::Image {
353        image: std::rc::Rc::new(RasterImage {
354            width: iw,
355            height: ih,
356            id: hash_bytes(&rgba),
357            rgba,
358        }),
359        rect,
360        opacity: 1.0,
361    })
362}
363
364/// The frame marker of a lens_drops node, or nothing for other shaders.
365///
366/// `area` is the node's box in scene coordinates. The node reads the frame
367/// drawn before it whatever its input is named: a backdrop outside a layer
368/// is the frame.
369pub(crate) fn read_frame(
370    n: &NodeData,
371    size: (f64, f64),
372    tf: Affine,
373    time: f64,
374    opacity: f64,
375) -> Option<crate::post::Shade> {
376    let NodeKind::Shader {
377        preset: Preset::LensDrops,
378        uniforms,
379        ..
380    } = &n.kind
381    else {
382        return None;
383    };
384    let num = |name: &str, default: f64| {
385        uniforms
386            .iter()
387            .find_map(|u| match (&u.name[..], &u.value) {
388                (m, UniformValue::Num(v)) if m == name => Some(v.get()),
389                _ => None,
390            })
391            .unwrap_or(default)
392    };
393    let (w, h) = (size.0.max(0.0), size.1.max(0.0));
394    let area = tf.transform_rect_bbox(kurbo::Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0));
395    Some(crate::post::Shade {
396        op: crate::post::ShadeOp::LensDrops {
397            wetness: num("wetness", 0.6),
398            seed: num("seed", 0.0).max(0.0) as u64,
399            scale: num("drop_size", 1.0),
400            time,
401        },
402        opacity,
403        area: Some(area),
404    })
405}
406
407/// `no input `x`. Did you mean `y`? Available: …` for a preset.
408fn unknown(what: &str, name: &str, known: &[&str]) -> String {
409    let list = if known.is_empty() {
410        "none".to_owned()
411    } else {
412        known.join(", ")
413    };
414    match closest(name, known) {
415        Some(n) => format!("no {what} `{name}`. Did you mean `{n}`? Available: {list}."),
416        None => format!("no {what} `{name}`. Available: {list}."),
417    }
418}
419
420/// Wall clock for `shader_slow`.
421///
422/// `Instant::now()` panics on `wasm32-unknown-unknown`, which ended the
423/// playground's waves example in "unreachable executed". A check never runs
424/// in the browser, so there the watch reads 0 and `shader_slow` stays quiet.
425#[cfg(not(target_arch = "wasm32"))]
426struct Stopwatch(std::time::Instant);
427
428#[cfg(target_arch = "wasm32")]
429struct Stopwatch;
430
431impl Stopwatch {
432    fn start() -> Self {
433        #[cfg(not(target_arch = "wasm32"))]
434        {
435            Self(std::time::Instant::now())
436        }
437        #[cfg(target_arch = "wasm32")]
438        {
439            Self
440        }
441    }
442
443    fn millis(&self) -> f64 {
444        #[cfg(not(target_arch = "wasm32"))]
445        {
446            self.0.elapsed().as_secs_f64() * 1000.0
447        }
448        #[cfg(target_arch = "wasm32")]
449        {
450            0.0
451        }
452    }
453}
454
455fn wave_uniforms(uniforms: &[Uniform]) -> (f64, f64, Color, Color) {
456    let mut amp = 0.05;
457    let mut speed = 1.0;
458    let mut deep = Color::BLACK;
459    let mut foam = Color::WHITE;
460    for u in uniforms {
461        match (&u.name[..], &u.value) {
462            ("amp", UniformValue::Num(v)) => amp = v.get(),
463            ("speed", UniformValue::Num(v)) => speed = v.get(),
464            ("deep", UniformValue::Color(v)) => deep = v.get(),
465            ("foam", UniformValue::Color(v)) => foam = v.get(),
466            _ => {}
467        }
468    }
469    (amp, speed, deep, foam)
470}
471
472fn load(code: &ShaderCode) -> Result<String, String> {
473    match code {
474        ShaderCode::Wgsl(text) => Ok(text.clone()),
475        ShaderCode::File(path) => read_file(path),
476    }
477}
478
479#[cfg(not(target_arch = "wasm32"))]
480fn read_file(path: &std::path::Path) -> Result<String, String> {
481    std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
482}
483
484#[cfg(target_arch = "wasm32")]
485fn read_file(path: &std::path::Path) -> Result<String, String> {
486    let _ = path;
487    Err("a shader file is read by the native renderer".into())
488}
489
490fn label(n: &NodeData) -> String {
491    n.name
492        .clone()
493        .unwrap_or_else(|| format!("{}#{}", n.kind.name(), n.id.0))
494}
495
496#[cfg(target_arch = "wasm32")]
497fn check_wgsl(_text: &str, _uniforms: &[Uniform]) -> Result<(), (&'static str, String)> {
498    Ok(())
499}
500
501#[cfg(not(target_arch = "wasm32"))]
502fn check_wgsl(text: &str, uniforms: &[Uniform]) -> Result<(), (&'static str, String)> {
503    let full = format!("{PRELUDE}{text}{EPILOGUE}");
504    let module = naga::front::wgsl::parse_str(&full).map_err(|e| {
505        (
506            "shader_error",
507            shift_lines(&e.emit_to_string(&full), prelude_lines()),
508        )
509    })?;
510    naga::valid::Validator::new(
511        naga::valid::ValidationFlags::all(),
512        naga::valid::Capabilities::all(),
513    )
514    .validate(&module)
515    .map_err(|e| ("shader_error", shift_lines(&e.to_string(), prelude_lines())))?;
516    let fields = params_of(&module).ok_or((
517        "shader_error",
518        "the shader has no `struct Params`".to_string(),
519    ))?;
520    match params(uniforms, &fields) {
521        Ok(()) => Ok(()),
522        Err(hint) => Err(("param_unknown", hint)),
523    }
524}
525
526#[cfg(not(target_arch = "wasm32"))]
527fn prelude_lines() -> usize {
528    PRELUDE.lines().count()
529}
530
531/// naga reports lines in the wrapped source. Subtract the prelude so the
532/// number points at the user's text.
533#[cfg(not(target_arch = "wasm32"))]
534fn shift_lines(message: &str, by: usize) -> String {
535    let chars: Vec<char> = message.chars().collect();
536    let mut out = String::with_capacity(message.len());
537    let mut i = 0;
538    while i < chars.len() {
539        if chars[i] == ':' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
540            let start = i + 1;
541            let mut end = start;
542            while end < chars.len() && chars[end].is_ascii_digit() {
543                end += 1;
544            }
545            let digits: String = chars[start..end].iter().collect();
546            let line: usize = digits.parse().unwrap_or(0);
547            out.push(':');
548            out.push_str(&line.saturating_sub(by).to_string());
549            i = end;
550        } else {
551            out.push(chars[i]);
552            i += 1;
553        }
554    }
555    out
556}
557
558#[cfg(not(target_arch = "wasm32"))]
559enum WgslTy {
560    F32,
561    Vec2,
562    Vec4,
563    Bool,
564    Other,
565}
566
567#[cfg(not(target_arch = "wasm32"))]
568fn params_of(module: &naga::Module) -> Option<Vec<(String, WgslTy)>> {
569    for (_, ty) in module.types.iter() {
570        if ty.name.as_deref() != Some("Params") {
571            continue;
572        }
573        let naga::TypeInner::Struct { members, .. } = &ty.inner else {
574            continue;
575        };
576        return Some(
577            members
578                .iter()
579                .map(|m| {
580                    let name = m.name.clone().unwrap_or_default();
581                    (name, wgsl_ty(&module.types[m.ty].inner))
582                })
583                .collect(),
584        );
585    }
586    None
587}
588
589#[cfg(not(target_arch = "wasm32"))]
590fn wgsl_ty(inner: &naga::TypeInner) -> WgslTy {
591    match inner {
592        naga::TypeInner::Scalar(s) if s.kind == naga::ScalarKind::Float && s.width == 4 => {
593            WgslTy::F32
594        }
595        naga::TypeInner::Scalar(s) if s.kind == naga::ScalarKind::Bool => WgslTy::Bool,
596        naga::TypeInner::Vector { size, scalar }
597            if scalar.kind == naga::ScalarKind::Float
598                && scalar.width == 4
599                && *size == naga::VectorSize::Bi =>
600        {
601            WgslTy::Vec2
602        }
603        naga::TypeInner::Vector { size, scalar }
604            if scalar.kind == naga::ScalarKind::Float
605                && scalar.width == 4
606                && *size == naga::VectorSize::Quad =>
607        {
608            WgslTy::Vec4
609        }
610        _ => WgslTy::Other,
611    }
612}
613
614#[cfg(not(target_arch = "wasm32"))]
615fn params(uniforms: &[Uniform], fields: &[(String, WgslTy)]) -> Result<(), String> {
616    let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
617    let list = names.join(", ");
618    for u in uniforms {
619        let Some((_, ty)) = fields.iter().find(|(n, _)| n == &u.name) else {
620            let near = closest(&u.name, &names);
621            let hint = match near {
622                Some(n) => format!(
623                    "no parameter `{}`. Did you mean `{n}`? Available: {list}.",
624                    u.name
625                ),
626                None => format!("no parameter `{}`. Available: {list}.", u.name),
627            };
628            return Err(hint);
629        };
630        if !type_matches(&u.value, ty) {
631            return Err(format!(
632                "`{}` is {} in Params, but the scene sets {}.",
633                u.name,
634                ty_name(ty),
635                value_name(&u.value)
636            ));
637        }
638    }
639    for (name, _) in fields {
640        if !uniforms.iter().any(|u| u.name == *name) {
641            return Err(format!(
642                "Params.{name} has no value. Set it with `.uniform(\"{name}\", ...)`."
643            ));
644        }
645    }
646    Ok(())
647}
648
649#[cfg(not(target_arch = "wasm32"))]
650fn type_matches(value: &UniformValue, ty: &WgslTy) -> bool {
651    matches!(
652        (value, ty),
653        (UniformValue::Num(_), WgslTy::F32)
654            | (UniformValue::Vec2(_), WgslTy::Vec2)
655            | (UniformValue::Color(_), WgslTy::Vec4)
656            | (UniformValue::Bool(_), WgslTy::Bool)
657    )
658}
659
660#[cfg(not(target_arch = "wasm32"))]
661fn ty_name(ty: &WgslTy) -> &'static str {
662    match ty {
663        WgslTy::F32 => "f32",
664        WgslTy::Vec2 => "vec2<f32>",
665        WgslTy::Vec4 => "vec4<f32>",
666        WgslTy::Bool => "bool",
667        WgslTy::Other => "another type",
668    }
669}
670
671#[cfg(not(target_arch = "wasm32"))]
672fn value_name(value: &UniformValue) -> &'static str {
673    match value {
674        UniformValue::Num(_) => "a number",
675        UniformValue::Vec2(_) => "a point",
676        UniformValue::Color(_) => "a color",
677        UniformValue::Bool(_) => "a bool",
678    }
679}
680
681fn closest<'a>(name: &str, options: &[&'a str]) -> Option<&'a str> {
682    options
683        .iter()
684        .copied()
685        .filter(|o| edit(name, o) <= 3 && *o != name)
686        .min_by_key(|o| edit(name, o))
687}
688
689fn edit(a: &str, b: &str) -> usize {
690    let a: Vec<char> = a.chars().collect();
691    let b: Vec<char> = b.chars().collect();
692    let mut prev: Vec<usize> = (0..=b.len()).collect();
693    let mut cur = vec![0; b.len() + 1];
694    for (i, ca) in a.iter().enumerate() {
695        cur[0] = i + 1;
696        for (j, cb) in b.iter().enumerate() {
697            let cost = usize::from(ca != cb);
698            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
699        }
700        std::mem::swap(&mut prev, &mut cur);
701    }
702    prev[b.len()]
703}
704
705fn smoothstep(e0: f64, e1: f64, x: f64) -> f64 {
706    let t = if (e1 - e0).abs() < 1e-9 {
707        if x < e0 { 0.0 } else { 1.0 }
708    } else {
709        ((x - e0) / (e1 - e0)).clamp(0.0, 1.0)
710    };
711    t * t * (3.0 - 2.0 * t)
712}
713
714fn mix(foam: Color, deep: Color, t: f64, opacity: f64) -> Color {
715    let t = t as f32;
716    let a = opacity.clamp(0.0, 1.0) as f32;
717    Color {
718        r: foam.r + (deep.r - foam.r) * t,
719        g: foam.g + (deep.g - foam.g) * t,
720        b: foam.b + (deep.b - foam.b) * t,
721        a: (foam.a + (deep.a - foam.a) * t) * a,
722    }
723}
724
725fn byte(c: f32) -> u8 {
726    (c.clamp(0.0, 1.0) * 255.0).round() as u8
727}
728
729fn hash_bytes(bytes: &[u8]) -> u64 {
730    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
731    for &b in bytes {
732        h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
733    }
734    h
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::builder::NodeBuilder;
741    use crate::{SceneGraph, View};
742    use std::rc::Rc;
743
744    fn paint(f: impl FnOnce(View)) -> Rc<SceneGraph> {
745        let graph = SceneGraph::new(320, 180);
746        graph.enter(|| f(View::current()));
747        graph
748    }
749
750    fn image_of(graph: &SceneGraph) -> Rc<RasterImage> {
751        let items = graph.draw_list();
752        for item in &items {
753            if let Primitive::Image { image, .. } = &item.prim {
754                return image.clone();
755            }
756        }
757        panic!("waves did not paint, diags: {:?}", graph.shader_diags());
758    }
759
760    #[test]
761    fn waves_put_foam_above_deep_water() {
762        let graph = paint(|view| {
763            view.add(crate::waves().width(200.0).height(80.0));
764        });
765        let image = image_of(&graph);
766        let px = |x: u32, y: u32| {
767            let i = (y * image.width + x) as usize * 4;
768            image.rgba[i + 2]
769        };
770        let mid = image.width / 2;
771        assert!(
772            px(mid, 2) > px(mid, image.height - 3),
773            "the crest is the lighter foam"
774        );
775        assert!(
776            graph.shader_diags().is_empty(),
777            "{:?}",
778            graph.shader_diags()
779        );
780    }
781
782    #[test]
783    fn the_crest_moves_along_the_wave() {
784        let graph = paint(|view| {
785            view.add(crate::waves().amp(0.2).width(200.0).height(80.0));
786        });
787        let a = image_of(&graph).rgba.clone();
788        graph.set_time(1.0);
789        let b = image_of(&graph).rgba.clone();
790        assert_ne!(a, b);
791    }
792
793    #[test]
794    fn a_misspelled_uniform_names_the_real_one() {
795        let graph = paint(|view| {
796            view.add(
797                crate::Shader::source(waves_wgsl())
798                    .uniform("ampp", 0.1)
799                    .uniform("speed", 1.0)
800                    .uniform_color("deep", Color::BLACK)
801                    .uniform_color("foam", Color::WHITE)
802                    .width(40.0)
803                    .height(20.0)
804                    .name("sea"),
805            );
806        });
807        let _ = graph.draw_list();
808        let diag = graph
809            .shader_diags()
810            .into_iter()
811            .find(|d| d.rule == "param_unknown")
812            .expect("param_unknown");
813        assert_eq!(diag.node, "sea");
814        assert!(diag.hint.contains("amp"), "{diag:?}");
815    }
816
817    #[test]
818    fn a_broken_shader_is_an_error() {
819        let graph = paint(|view| {
820            view.add(
821                crate::Shader::source("this is not wgsl")
822                    .width(40.0)
823                    .height(20.0),
824            );
825        });
826        let _ = graph.draw_list();
827        assert!(
828            graph
829                .shader_diags()
830                .iter()
831                .any(|d| d.rule == "shader_error"),
832            "{:?}",
833            graph.shader_diags()
834        );
835    }
836
837    #[cfg(not(target_arch = "wasm32"))]
838    #[test]
839    fn editing_the_file_is_seen_on_the_next_draw() {
840        let dir = std::env::temp_dir().join(format!("oxframe-shader-{}", std::process::id()));
841        std::fs::create_dir_all(&dir).unwrap();
842        let path = dir.join("sea.wgsl");
843        std::fs::write(&path, "this is not wgsl").unwrap();
844        let graph = paint(|view| {
845            view.add(crate::Shader::file(&path).width(40.0).height(20.0));
846        });
847        let _ = graph.draw_list();
848        assert!(
849            graph
850                .shader_diags()
851                .iter()
852                .any(|d| d.rule == "shader_error"),
853            "{:?}",
854            graph.shader_diags()
855        );
856        std::fs::write(&path, waves_wgsl()).unwrap();
857        let _ = graph.draw_list();
858        let diags = graph.shader_diags();
859        assert!(diags.iter().all(|d| d.rule != "shader_error"), "{diags:?}");
860        let _ = std::fs::remove_dir_all(&dir);
861    }
862
863    fn rules(graph: &SceneGraph) -> Vec<&'static str> {
864        let _ = graph.draw_list();
865        graph.shader_diags().iter().map(|d| d.rule).collect()
866    }
867
868    fn reads_frame(graph: &SceneGraph) -> bool {
869        graph
870            .draw_list()
871            .iter()
872            .any(|i| matches!(i.prim, Primitive::ReadFrame(_)))
873    }
874
875    #[test]
876    fn lens_drops_read_the_frame() {
877        let graph = paint(|view| {
878            view.add(
879                crate::lens_drops()
880                    .fullscreen()
881                    .input("scene", crate::Input::frame()),
882            );
883        });
884        assert!(graph.has_post());
885        assert!(reads_frame(&graph));
886        assert!(rules(&graph).is_empty(), "{:?}", graph.shader_diags());
887        let passes = crate::pass_graph(&graph.draw_list());
888        assert!(
889            passes
890                .iter()
891                .any(|p| p.kind == crate::PassKind::Effect && p.shader == Some("lens_drops"))
892        );
893    }
894
895    #[test]
896    fn a_misspelled_input_or_parameter_names_the_real_one() {
897        let graph = paint(|view| {
898            view.add(
899                crate::lens_drops()
900                    .input("scen", crate::Input::frame())
901                    .name("lens"),
902            );
903        });
904        let _ = graph.draw_list();
905        let diag = &graph.shader_diags()[0];
906        assert_eq!((diag.rule, diag.node.as_str()), ("param_unknown", "lens"));
907        assert!(diag.hint.contains("`scene`"), "{diag:?}");
908
909        let graph = paint(|view| {
910            view.add(crate::lens_drops().uniform("wetnes", 1.0));
911        });
912        let _ = graph.draw_list();
913        let diag = &graph.shader_diags()[0];
914        assert!(diag.hint.contains("`wetness`"), "{diag:?}");
915
916        let graph = paint(|view| {
917            view.add(crate::waves().input("scene", crate::Input::frame()));
918        });
919        assert_eq!(rules(&graph), ["param_unknown"]);
920    }
921
922    #[test]
923    fn a_lens_inside_a_fading_group_is_an_input_cycle() {
924        let graph = paint(|view| {
925            let group = view.add(crate::Group::new().opacity(0.5));
926            group.add(crate::Rect::new().size(20.0));
927            group.add(crate::lens_drops().fullscreen());
928        });
929        assert_eq!(rules(&graph), ["input_cycle"]);
930        assert!(!reads_frame(&graph), "the lens is skipped");
931    }
932
933    #[test]
934    fn an_effect_inside_an_effect_is_an_input_cycle() {
935        let graph = paint(|view| {
936            let outer = view.add(crate::Group::new());
937            let inner = outer.add(crate::Rect::new().size(20.0));
938            outer.effect(crate::ripple());
939            inner.effect(crate::ripple());
940        });
941        assert_eq!(rules(&graph), ["input_cycle"]);
942        let items = graph.draw_list();
943        let begins = items
944            .iter()
945            .filter(|i| matches!(i.prim, Primitive::BeginEffect(_)))
946            .count();
947        assert_eq!(begins, 1, "only the outer effect runs");
948        let passes = crate::pass_graph(&items);
949        assert!(
950            passes
951                .iter()
952                .any(|p| p.kind == crate::PassKind::Effect && p.shader == Some("ripple"))
953        );
954    }
955
956    #[test]
957    fn effect_signals_animate_the_marker() {
958        let graph = SceneGraph::new(320, 180);
959        let effect = graph.enter(|| {
960            View::current()
961                .add(crate::Rect::new().size(40.0))
962                .effect(crate::ripple().strength(0.0))
963        });
964        let strength = |graph: &SceneGraph| {
965            graph.draw_list().iter().find_map(|i| match &i.prim {
966                Primitive::BeginEffect(s) => match s.op {
967                    crate::post::ShadeOp::Ripple { strength, .. } => Some(strength),
968                    _ => None,
969                },
970                _ => None,
971            })
972        };
973        assert_eq!(strength(&graph), Some(0.0));
974        graph.enter(|| effect.strength().set(0.7));
975        assert_eq!(strength(&graph), Some(0.7));
976    }
977
978    #[test]
979    fn a_slow_raster_is_reported() {
980        let graph = paint(|view| {
981            view.add(crate::waves().width(180.0).height(80.0).name("sea"));
982        });
983        set_shader_budget_ms(0.0);
984        let _ = graph.draw_list();
985        set_shader_budget_ms(250.0);
986        assert!(
987            graph.shader_diags().iter().any(|d| d.rule == "shader_slow"),
988            "{:?}",
989            graph.shader_diags()
990        );
991    }
992}