engawa-wgpu 0.1.6

wgpu-backed Dispatcher impl for engawa render graphs. Compiles Material → wgpu::RenderPipeline; walks the compiled graph; dispatches fullscreen-effect passes against any wgpu::TextureView (most commonly a garasu::HeadlessTarget for tests + a winit surface for live).
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
//! Aurora — the Borealis signature curtain: slow-drifting
//! vertical light bands with noise-driven shimmer, composited
//! over the scene at low opacity and concentrated toward the
//! TOP of the frame (sky above a horizon line — the prompt area
//! below the horizon is never touched).
//!
//! ## Quality tiers — the measured cost story
//!
//! [`AuroraQuality`] (uniform word `tier.x`) selects the
//! cost/beauty point. Two GPU suites in `tests/catalog_gpu.rs`
//! (`gpu_tests` feature) pin the tier contract on a real
//! adapter: the perf smoke dispatches every tier and asserts the
//! cost ordering Off ≤ Low ≤ Medium ≤ High, and the pixel proofs
//! read the rendered bytes back — Medium visibly draws above the
//! horizon, the prompt area below the horizon stays scene
//! byte-exact, and `Off` / out-of-contract words (≥ 4) return
//! the scene:
//!
//! | tier | shader work per sky pixel | intent |
//! |---|---|---|
//! | `Off` (0) | scene pass-through (byte-exact) | runtime kill-switch |
//! | `Low` (1) | 2 × value noise, cheap mix | trivially < 0.3 ms @1440p-class |
//! | `Medium` (2) | 3-octave fbm + 1-octave shimmer | the shipped default |
//! | `High` (3) | 4-octave fbm + 12-step ray-march shimmer | the gorgeous one |
//!
//! ## `Off` vs omitting the node — the documented tradeoff
//!
//! `quality = Off` keeps the node in the graph; the shader
//! early-outs after one `textureSample`, so the pass costs
//! ~a fullscreen blit (bandwidth, zero ALU). That buys the
//! consumer a **rebuild-free toggle** — a perf governor can flip
//! the uniform word per frame without recompiling the graph.
//! TRUE zero cost requires omitting the node (zero nodes), which
//! is a graph rebuild. Rule of thumb: governors use `Off`;
//! persistent user settings omit the node.
//!
//! ## `reduce_motion` contract
//!
//! The CONSUMER omits the node entirely (zero nodes) when the
//! platform reduce-motion setting is on — the same contract as
//! snow and `glow_on_bell`. `Off` is the perf-governing path, not
//! the accessibility path.
//!
//! ## Clock
//!
//! The shader is stateless; the consumer supplies time via
//! [`AuroraParams::set_time`] each frame — the same `frame.x`
//! plumbing as snow.
//!
//! ## Colors
//!
//! The three stops are physically-inspired **uniform params**,
//! not shader constants: [`DEFAULT_GREEN`] approximates the
//! oxygen 557.7 nm emission line (the auroral green), [`DEFAULT_CYAN`]
//! the high-altitude cyan wash, [`DEFAULT_VIOLET`] the nitrogen
//! ~427.8 nm edge. The Borealis theme's exact ishou hexes plug in
//! at mado wiring time via [`AuroraParams::with_colors`].

use bytemuck::{Pod, Zeroable};
use engawa::{Effect, Material, Node, ResourceId};

use super::{post_material, SCENE};

pub const EFFECT_NAME: &str = "aurora";
pub const PRIORITY: u16 = 450;
pub const PARAMS_RESOURCE: &str = "aurora:params";
pub const WGSL: &str = include_str!("wgsl/aurora.wgsl");

/// Curtain base stop — linear-rgb approximation of the oxygen
/// 557.7 nm emission line (the canonical auroral green).
pub const DEFAULT_GREEN: [f32; 3] = [0.10, 0.95, 0.35];
/// Curtain mid stop — high-altitude cyan wash.
pub const DEFAULT_CYAN: [f32; 3] = [0.05, 0.60, 0.70];
/// Curtain edge stop — nitrogen ~427.8 nm violet.
pub const DEFAULT_VIOLET: [f32; 3] = [0.45, 0.18, 0.85];

/// Typed quality tier — the only Rust-side way to mint the
/// `tier.x` uniform word, so an out-of-contract word is
/// unrepresentable on the typed surface. The Pod-bytes ingress
/// can still mint any `u32`; the WGSL degrades words `>= 4` to
/// pass-through (colorblind's default-arm posture).
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AuroraQuality {
    /// Shader early-outs to a byte-exact scene pass-through.
    /// Costs ~one fullscreen blit (the node still dispatches);
    /// see the module docs for the tradeoff vs omitting the node.
    Off = 0,
    /// Single-octave value-noise curtain — the frame-budget
    /// floor tier a perf governor steps down to.
    Low = 1,
    /// 3-octave fbm curtain + one-octave shimmer — the shipped
    /// default.
    Medium = 2,
    /// 4-octave fbm + 12-step vertical ray-march shimmer — the
    /// gorgeous one.
    High = 3,
}

impl AuroraQuality {
    /// The uniform word the WGSL switches on.
    #[must_use]
    pub const fn as_u32(self) -> u32 {
        self as u32
    }

    /// Typed read-back of a uniform word; `None` for
    /// out-of-contract words (which the shader renders as
    /// pass-through).
    #[must_use]
    pub const fn from_u32(word: u32) -> Option<Self> {
        match word {
            0 => Some(Self::Off),
            1 => Some(Self::Low),
            2 => Some(Self::Medium),
            3 => Some(Self::High),
            _ => None,
        }
    }
}

/// Per-frame aurora uniform. 96 bytes, std140-friendly (every
/// field is a vec4-aligned 16-byte tuple).
///
/// Tuple layout:
/// * `frame        = (time_seconds, intensity, drift, shimmer)`
/// * `geometry     = (horizon, band_scale, res_x, res_y)`
///   - `horizon`: screen-space y (0 = top, 1 = bottom) below
///     which the curtain is zero — keeps the aurora above the
///     prompt-line area.
/// * `color_green  = (r, g, b, _)` — curtain base stop
/// * `color_cyan   = (r, g, b, _)` — curtain mid stop
/// * `color_violet = (r, g, b, _)` — curtain edge stop
/// * `tier         = (quality, _, _, _)` as `vec4<u32>`
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
pub struct AuroraParams {
    pub frame: [f32; 4],
    pub geometry: [f32; 4],
    pub color_green: [f32; 4],
    pub color_cyan: [f32; 4],
    pub color_violet: [f32; 4],
    pub tier: [u32; 4],
}

impl Default for AuroraParams {
    fn default() -> Self {
        Self {
            frame: [0.0, 0.35, 1.0, 0.5], // time, intensity, drift, shimmer
            geometry: [0.62, 2.4, 800.0, 600.0], // horizon, band_scale, res
            color_green: [DEFAULT_GREEN[0], DEFAULT_GREEN[1], DEFAULT_GREEN[2], 0.0],
            color_cyan: [DEFAULT_CYAN[0], DEFAULT_CYAN[1], DEFAULT_CYAN[2], 0.0],
            color_violet: [DEFAULT_VIOLET[0], DEFAULT_VIOLET[1], DEFAULT_VIOLET[2], 0.0],
            tier: [AuroraQuality::Medium as u32, 0, 0, 0],
        }
    }
}

impl AuroraParams {
    /// Set `time_seconds`. Drives all drift + shimmer 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 opacity gain. 0..1, default 0.35 — the curtain is
    /// sky dressing; the scene always reads through (the shader
    /// additionally caps coverage at `MAX_ALPHA = 0.5`).
    #[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);
    }

    /// Drift-speed multiplier over the slow base rate. 0..4,
    /// default 1.0 (a curtain crosses one noise cell in ~50 s).
    #[must_use]
    pub fn with_drift(mut self, d: f32) -> Self {
        self.frame[2] = d.clamp(0.0, 4.0);
        self
    }
    pub fn set_drift(&mut self, d: f32) {
        self.frame[2] = d.clamp(0.0, 4.0);
    }

    /// Shimmer amount. 0..1, default 0.5. Low tier ignores it
    /// (that is the budget); Medium pays one extra noise octave;
    /// High pays the full vertical ray march.
    #[must_use]
    pub fn with_shimmer(mut self, s: f32) -> Self {
        self.frame[3] = s.clamp(0.0, 1.0);
        self
    }
    pub fn set_shimmer(&mut self, s: f32) {
        self.frame[3] = s.clamp(0.0, 1.0);
    }

    /// Horizon line in screen-space y (0 = top, 1 = bottom);
    /// the curtain is zero below it. 0.05..1, default 0.62.
    #[must_use]
    pub fn with_horizon(mut self, h: f32) -> Self {
        self.geometry[0] = h.clamp(0.05, 1.0);
        self
    }
    pub fn set_horizon(&mut self, h: f32) {
        self.geometry[0] = h.clamp(0.05, 1.0);
    }

    /// Horizontal curtain frequency. 0.5..8, default 2.4.
    #[must_use]
    pub fn with_band_scale(mut self, b: f32) -> Self {
        self.geometry[1] = b.clamp(0.5, 8.0);
        self
    }
    pub fn set_band_scale(&mut self, b: f32) {
        self.geometry[1] = b.clamp(0.5, 8.0);
    }

    /// Target resolution in physical pixels (aspect correction).
    #[must_use]
    pub fn with_resolution(mut self, [w, h]: [f32; 2]) -> Self {
        self.geometry[2] = w;
        self.geometry[3] = h;
        self
    }
    pub fn set_resolution(&mut self, [w, h]: [f32; 2]) {
        self.geometry[2] = w;
        self.geometry[3] = h;
    }

    /// Select the quality tier (typed — out-of-contract words
    /// cannot be minted through this surface).
    #[must_use]
    pub fn with_quality(mut self, q: AuroraQuality) -> Self {
        self.tier[0] = q.as_u32();
        self
    }
    pub fn set_quality(&mut self, q: AuroraQuality) {
        self.tier[0] = q.as_u32();
    }

    /// Typed read-back of the tier word; `None` if the word was
    /// minted out-of-contract via the Pod-bytes ingress.
    #[must_use]
    pub const fn quality(&self) -> Option<AuroraQuality> {
        AuroraQuality::from_u32(self.tier[0])
    }

    /// Replace the three spectrum stops (linear rgb) — this is
    /// where the Borealis ishou hexes plug in at mado wiring
    /// time.
    #[must_use]
    pub fn with_colors(
        mut self,
        green: [f32; 3],
        cyan: [f32; 3],
        violet: [f32; 3],
    ) -> Self {
        self.color_green = [green[0], green[1], green[2], 0.0];
        self.color_cyan = [cyan[0], cyan[1], cyan[2], 0.0];
        self.color_violet = [violet[0], violet[1], violet[2], 0.0];
        self
    }
}

#[must_use]
pub fn material(input: &ResourceId) -> Material {
    post_material(EFFECT_NAME, WGSL, input, PARAMS_RESOURCE)
}

#[must_use]
pub fn effect() -> Effect {
    Effect {
        name: EFFECT_NAME.to_string(),
        enabled: true,
        priority: PRIORITY,
        material: material(&SCENE.into()),
    }
}

#[must_use]
pub fn lower(input: &ResourceId, output: &ResourceId) -> Vec<Node> {
    vec![Node::fullscreen_effect(
        EFFECT_NAME,
        material(input),
        input.clone(),
        output.clone(),
    )]
}

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

    // Defaults and tier words are exact constants — bit equality
    // is the intended assertion, no epsilon involved.
    #[allow(clippy::float_cmp)]
    #[test]
    fn default_color_stops_are_pinned() {
        let p = AuroraParams::default();
        assert_eq!(&p.color_green[..3], &DEFAULT_GREEN);
        assert_eq!(&p.color_cyan[..3], &DEFAULT_CYAN);
        assert_eq!(&p.color_violet[..3], &DEFAULT_VIOLET);
        assert_eq!(p.color_green[3], 0.0);
        // The physically-inspired ordering: green is the
        // brightest/greenest stop, violet the bluest.
        assert!(p.color_green[1] > p.color_cyan[1]);
        assert!(p.color_violet[2] > p.color_green[2]);
    }

    #[test]
    fn tier_constants_match_the_wgsl_contract() {
        assert_eq!(AuroraQuality::Off.as_u32(), 0);
        assert_eq!(AuroraQuality::Low.as_u32(), 1);
        assert_eq!(AuroraQuality::Medium.as_u32(), 2);
        assert_eq!(AuroraQuality::High.as_u32(), 3);
        // The WGSL consts must pin the same words — the uniform
        // is the wire contract between the two.
        assert!(WGSL.contains("const QUALITY_OFF: u32 = 0u"));
        assert!(WGSL.contains("const QUALITY_LOW: u32 = 1u"));
        assert!(WGSL.contains("const QUALITY_MEDIUM: u32 = 2u"));
        assert!(WGSL.contains("const QUALITY_HIGH: u32 = 3u"));
    }

    #[test]
    fn quality_round_trips_and_rejects_out_of_contract_words() {
        for q in [
            AuroraQuality::Off,
            AuroraQuality::Low,
            AuroraQuality::Medium,
            AuroraQuality::High,
        ] {
            assert_eq!(AuroraQuality::from_u32(q.as_u32()), Some(q));
        }
        assert_eq!(AuroraQuality::from_u32(4), None);
        assert_eq!(AuroraQuality::from_u32(u32::MAX), None);

        // The pub-field / Pod-bytes ingress can mint any word; the
        // typed read-back surfaces it as None instead of inventing
        // a tier (the WGSL renders it as pass-through).
        let p = AuroraParams { tier: [7, 0, 0, 0], ..AuroraParams::default() };
        assert_eq!(p.quality(), None);
    }

    #[allow(clippy::float_cmp)]
    #[test]
    fn defaults_pin_the_recommended_mado_shape() {
        let p = AuroraParams::default();
        assert_eq!(p.quality(), Some(AuroraQuality::Medium), "shipped default tier");
        assert_eq!(p.frame[1], 0.35, "default opacity — sky dressing, scene reads through");
        assert_eq!(p.frame[2], 1.0, "default drift multiplier");
        assert_eq!(p.frame[3], 0.5, "default shimmer");
        assert_eq!(p.geometry[0], 0.62, "default horizon — above the prompt area");
        assert_eq!(p.geometry[1], 2.4, "default band scale");
    }

    #[allow(clippy::float_cmp)]
    #[test]
    fn builders_clamp_within_range() {
        let p = AuroraParams::default()
            .with_intensity(2.0)
            .with_drift(-1.0)
            .with_shimmer(9.0)
            .with_horizon(0.0)
            .with_band_scale(100.0);
        assert_eq!(p.frame[1], 1.0);
        assert_eq!(p.frame[2], 0.0);
        assert_eq!(p.frame[3], 1.0);
        assert_eq!(p.geometry[0], 0.05);
        assert_eq!(p.geometry[1], 8.0);
    }

    #[test]
    fn params_layout_is_96_bytes_of_six_vec4s() {
        assert_eq!(size_of::<AuroraParams>(), 96);
        assert_eq!(align_of::<AuroraParams>(), 4);
    }

    #[test]
    fn wgsl_pins_the_craft_contract() {
        assert!(WGSL.len() > 1000, "aurora.wgsl looks suspiciously small");
        assert!(WGSL.contains("@fragment"));
        // Tiered noise ladder.
        assert!(WGSL.contains("fn vnoise"));
        assert!(WGSL.contains("fn fbm"));
        assert!(WGSL.contains("fn ray_factor"), "High tier's vertical ray march");
        // Scene sampled in uniform control flow before branching,
        // composited in-shader (blend-free dispatcher pipelines).
        assert!(WGSL.contains("textureSample(input_tex"));
        assert!(WGSL.contains("scene.rgb * (1.0 - alpha)"), "premultiplied over");
        // Banding-free: spatial dither keyed on the pixel position
        // with NO time term (frame-stable — no temporal noise).
        assert!(WGSL.contains("hash12(in.pos.xy)"), "spatial dither anchor");
        // Off + out-of-contract degrade to pass-through.
        assert!(WGSL.contains("quality == QUALITY_OFF || quality > QUALITY_HIGH"));
    }
}