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
// Catalog fragment — 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).
//
// Craft contract (each bullet is anchored by a unit test in
// src/catalog/aurora.rs):
//
//   * QUALITY TIERS — P.tier.x selects the cost/beauty point:
//       0 Off    — early-out: the scene passes through
//                  byte-exact. The pass still costs one
//                  fullscreen textureSample + copy (~a blit);
//                  consumers wanting TRUE zero cost omit the
//                  node from the graph instead.
//       1 Low    — single-octave value-noise curtain, cheap mix.
//       2 Medium — 3-octave fbm curtain + one-octave shimmer.
//       3 High   — 4-octave fbm + vertical ray-march shimmer
//                  (MARCH_STEPS fold-displaced samples).
//     Out-of-contract quality words (>= 4) degrade to
//     pass-through — the same posture as colorblind's default
//     arm: a Pod-bytes-minted word must never invent a tier.
//
//   * BANDING-FREE — a spatial (frame-stable) hash dither at
//     ±0.5/255 is added where the aurora contributes, breaking
//     8-bit banding in the slow alpha gradients. The dither has
//     NO time term: it is identical every frame, so it cannot
//     introduce temporal noise. It is gated by alpha so pixels
//     the aurora does not touch stay byte-exact.
//
//   * TEMPORAL STABILITY — drift is continuous time through
//     C1-continuous value noise (smoothstep-interpolated) with
//     no fract()/wrap discontinuities and no per-frame random
//     phase, so slow drift speeds cannot strobe or pop.
//
//   * PREMULTIPLIED OVER — the composite happens in-shader
//     (scene.rgb * (1 - a) + curtain_premul), because
//     WgpuDispatcher pipelines are blend-free ping-pong passes
//     (same delta as the snow absorption).

struct AuroraParams {
    frame: vec4<f32>,        // (time_seconds, intensity, drift, shimmer)
    geometry: vec4<f32>,     // (horizon, band_scale, res_x, res_y)
    color_green: vec4<f32>,  // curtain base — oxygen 557.7 nm (linear rgb, w unused)
    color_cyan: vec4<f32>,   // curtain mid  — high-altitude cyan
    color_violet: vec4<f32>, // curtain edge — nitrogen violet
    tier: vec4<u32>,         // (quality, _, _, _)
};

@group(0) @binding(0) var input_tex: texture_2d<f32>;
@group(0) @binding(1) var input_samp: sampler;
@group(0) @binding(2) var<uniform> P: AuroraParams;

const QUALITY_OFF: u32 = 0u;
const QUALITY_LOW: u32 = 1u;
const QUALITY_MEDIUM: u32 = 2u;
const QUALITY_HIGH: u32 = 3u;

// Hard ceiling on aurora coverage — the scene always reads
// through the curtain (it is sky dressing, not content).
const MAX_ALPHA: f32 = 0.5;
// High-tier vertical ray-march sample count.
const MARCH_STEPS: i32 = 12;
// Curtain lower border: sharp ramp-in width + upward decay rate
// (real curtains have a crisp bottom edge and a diffuse top).
const BORDER_SOFT: f32 = 0.06;
const DECAY_RATE: f32 = 2.6;

// ── hashing & noise ────────────────────────────────────────────

fn hash12(p: vec2<f32>) -> f32 {
    var q = fract(p * vec2<f32>(0.1031, 0.1030));
    q = q + dot(q, q.yx + 33.33);
    return fract((q.x + q.y) * q.x);
}

// C1-continuous value noise — the smoothstep fade keeps the
// derivative continuous so slow drift cannot shear or pop.
fn vnoise(p: vec2<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let a = hash12(i);
    let b = hash12(i + vec2<f32>(1.0, 0.0));
    let c = hash12(i + vec2<f32>(0.0, 1.0));
    let d = hash12(i + vec2<f32>(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// fbm with a runtime octave count. The count derives from the
// quality uniform, so control flow stays uniform.
fn fbm(p: vec2<f32>, octaves: i32) -> f32 {
    var v = 0.0;
    var amp = 0.5;
    var q = p;
    for (var i: i32 = 0; i < octaves; i = i + 1) {
        v = v + amp * vnoise(q);
        // Non-integer lacunarity + offset de-correlates octaves
        // (no lattice-alignment artifacts).
        q = q * 2.03 + vec2<f32>(19.7, 7.3);
        amp = amp * 0.5;
    }
    return v;
}

// ── High tier: vertical ray-march shimmer ─────────────────────
//
// Walks up the curtain accumulating fold-displaced noise: the
// fold field bends the sampling column (curtain pleats), the
// inner noise reads ray structure at ~9x band frequency. Lower
// samples dominate (1 - fi * 0.7 weight) so the bottom border
// stays the brightest — matching real auroral curtains.
fn ray_factor(xb: f32, drift_t: f32) -> f32 {
    var acc = 0.0;
    var wsum = 0.0;
    for (var i: i32 = 0; i < MARCH_STEPS; i = i + 1) {
        let fi = f32(i) / f32(MARCH_STEPS);
        let fold = (fbm(vec2<f32>(xb * 0.35 + fi * 0.8, drift_t * 0.4), 2) - 0.5) * 1.4;
        let s = vnoise(vec2<f32>(xb * 9.0 + fold * 3.0, fi * 2.0 - drift_t * 1.7));
        let wt = 1.0 - fi * 0.7;
        acc = acc + s * wt;
        wsum = wsum + wt;
    }
    let r = acc / max(wsum, 0.0001);
    // Contrasty streak mask centred near 1.0 so shimmer
    // modulates the curtain instead of dimming it.
    return 0.55 + 0.9 * smoothstep(0.35, 0.75, r);
}

// ── main ───────────────────────────────────────────────────────

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    // Scene sampled FIRST, in trivially uniform control flow,
    // before any branching (naga uniformity analysis).
    let scene = textureSample(input_tex, input_samp, in.uv);

    let quality = P.tier.x;
    // Off-equivalent early-out + out-of-contract degrade: both
    // return the scene byte-exact (no dither, no grade).
    if (quality == QUALITY_OFF || quality > QUALITY_HIGH) {
        return scene;
    }

    let horizon = clamp(P.geometry.x, 0.05, 1.0);
    // Sky altitude: 1 at the top of the frame, 0 at the horizon
    // line, negative below it (no aurora over the prompt area).
    let alt = 1.0 - in.uv.y / horizon;
    if (alt <= 0.0) {
        return scene;
    }

    let aspect = P.geometry.z / max(P.geometry.w, 1.0);
    let xb = in.uv.x * aspect * P.geometry.y;
    let drift_t = P.frame.x * 0.02 * P.frame.z;
    let shimmer = clamp(P.frame.w, 0.0, 1.0);

    // The lower border wanders slowly per-column — one cheap
    // octave at every tier; the wander is what sells the curtain.
    let border = 0.08 + 0.22 * vnoise(vec2<f32>(xb * 0.31 + drift_t * 0.5, drift_t * 0.23));
    let rel = alt - border;
    if (rel <= 0.0) {
        return scene;
    }
    // Sharp lower edge, diffuse upward decay.
    let profile = smoothstep(0.0, BORDER_SOFT, rel) * exp(-rel * DECAY_RATE);

    // Curtain density by tier (normalised to ~[0, 1]).
    let curtain_p = vec2<f32>(xb + drift_t, alt * 1.2 + drift_t * 0.31);
    var density: f32;
    if (quality == QUALITY_LOW) {
        density = vnoise(curtain_p);
    } else if (quality == QUALITY_MEDIUM) {
        density = fbm(curtain_p, 3) / 0.875;
    } else {
        density = fbm(curtain_p, 4) / 0.9375;
    }
    var curtain = smoothstep(0.30, 0.78, density);
    curtain = curtain * curtain;

    // Shimmer: High pays the ray march; Medium one extra octave
    // of column noise; Low none — that is the budget.
    if (quality == QUALITY_HIGH) {
        curtain = curtain * mix(1.0, ray_factor(xb, drift_t), shimmer);
    } else if (quality == QUALITY_MEDIUM) {
        let cheap = 0.7 + 0.6 * vnoise(vec2<f32>(xb * 7.0, drift_t * 1.3));
        curtain = curtain * mix(1.0, cheap, shimmer * 0.6);
    }

    let intensity = clamp(P.frame.y, 0.0, 1.0);
    let alpha = min(profile * curtain * intensity, MAX_ALPHA);

    // Altitude-keyed spectrum: oxygen green at the lower border,
    // cyan through the middle, nitrogen violet at the high edge.
    let th = clamp(rel * 1.6, 0.0, 1.0);
    var col = mix(P.color_green.rgb, P.color_cyan.rgb, smoothstep(0.0, 0.55, th));
    col = mix(col, P.color_violet.rgb, smoothstep(0.5, 1.0, th));

    // Spatial (frame-stable) dither, gated to where the aurora
    // contributes so untouched pixels stay byte-exact.
    let dith = (hash12(in.pos.xy) - 0.5) / 255.0;
    let dith_gate = smoothstep(0.0, 0.015, alpha);

    // Premultiplied over.
    let rgb = scene.rgb * (1.0 - alpha) + col * alpha + vec3<f32>(dith * dith_gate);
    return vec4<f32>(rgb, scene.a);
}