mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
struct Settings {
    exposure: f32,
    bloom: f32,
    curve: u32,
}

const NEUTRAL: u32 = 0u;
const ACES: u32 = 1u;

// The fraction of the larger mip an upsample keeps; the rest is the
// smaller one spread over it, so the chain neither grows nor loses light.
const SPREAD: f32 = 0.5;

@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;

@group(1) @binding(1) var<uniform> settings: Settings;

@group(2) @binding(0) var scattered: texture_2d<f32>;

struct Fragment {
    @builtin(position) position: vec4<f32>,
    @location(0) uv: vec2<f32>,
}

// One triangle large enough to cover the target, so no vertex buffer is bound.
@vertex
fn fullscreen(@builtin(vertex_index) index: u32) -> Fragment {
    let corner = vec2<f32>(f32((index << 1u) & 2u), f32(index & 2u));

    var fragment: Fragment;
    fragment.position = vec4<f32>(corner * 2.0 - 1.0, 0.0, 1.0);
    fragment.uv = vec2<f32>(corner.x, 1.0 - corner.y);
    return fragment;
}

fn tap(uv: vec2<f32>, offset: vec2<f32>, texel: vec2<f32>) -> vec3<f32> {
    return textureSample(source, source_sampler, uv + offset * texel).rgb;
}

// Thirteen taps in four overlapping squares, scaled to sum to one.
@fragment
fn downsample(fragment: Fragment) -> @location(0) vec4<f32> {
    let texel = 1.0 / vec2<f32>(textureDimensions(source));
    let uv = fragment.uv;

    let corners = tap(uv, vec2(-2.0, -2.0), texel)
        + tap(uv, vec2(2.0, -2.0), texel)
        + tap(uv, vec2(-2.0, 2.0), texel)
        + tap(uv, vec2(2.0, 2.0), texel);
    let sides = tap(uv, vec2(0.0, -2.0), texel)
        + tap(uv, vec2(-2.0, 0.0), texel)
        + tap(uv, vec2(2.0, 0.0), texel)
        + tap(uv, vec2(0.0, 2.0), texel);
    let inner = tap(uv, vec2(-1.0, -1.0), texel)
        + tap(uv, vec2(1.0, -1.0), texel)
        + tap(uv, vec2(-1.0, 1.0), texel)
        + tap(uv, vec2(1.0, 1.0), texel);

    let color = tap(uv, vec2(0.0, 0.0), texel) * 0.125
        + corners * 0.03125
        + sides * 0.0625
        + inner * 0.125;
    return vec4<f32>(color, 1.0);
}

// Nine taps in a tent, blended over the larger mip by SPREAD.
@fragment
fn upsample(fragment: Fragment) -> @location(0) vec4<f32> {
    let texel = 1.0 / vec2<f32>(textureDimensions(source));
    let uv = fragment.uv;

    let corners = tap(uv, vec2(-1.0, -1.0), texel)
        + tap(uv, vec2(1.0, -1.0), texel)
        + tap(uv, vec2(-1.0, 1.0), texel)
        + tap(uv, vec2(1.0, 1.0), texel);
    let sides = tap(uv, vec2(0.0, -1.0), texel)
        + tap(uv, vec2(-1.0, 0.0), texel)
        + tap(uv, vec2(1.0, 0.0), texel)
        + tap(uv, vec2(0.0, 1.0), texel);

    let color = (tap(uv, vec2(0.0, 0.0), texel) * 4.0 + sides * 2.0 + corners) / 16.0;
    return vec4<f32>(color, SPREAD);
}

// The Khronos PBR neutral curve: it bounds the peak channel and leaves
// the ratios between channels alone until it has to desaturate.
fn neutral(color: vec3<f32>) -> vec3<f32> {
    let start = 0.8 - 0.04;
    let desaturation = 0.15;

    let darkest = min(color.r, min(color.g, color.b));
    var offset = 0.04;
    if darkest < 0.08 {
        offset = darkest - 6.25 * darkest * darkest;
    }
    var lifted = color - offset;

    let peak = max(lifted.r, max(lifted.g, lifted.b));
    if peak < start {
        return lifted;
    }

    let span = 1.0 - start;
    let compressed = 1.0 - span * span / (peak + span - start);
    lifted *= compressed / peak;

    let wash = 1.0 - 1.0 / (desaturation * (peak - compressed) + 1.0);
    return mix(lifted, vec3<f32>(compressed), wash);
}

// The Narkowicz fit of the ACES filmic curve.
fn aces(color: vec3<f32>) -> vec3<f32> {
    let mapped = (color * (2.51 * color + 0.03)) / (color * (2.43 * color + 0.59) + 0.14);
    return clamp(mapped, vec3<f32>(0.0), vec3<f32>(1.0));
}

@fragment
fn tonemap(fragment: Fragment) -> @location(0) vec4<f32> {
    var color = textureSample(source, source_sampler, fragment.uv).rgb;
    if settings.bloom > 0.0 {
        let bloom = textureSample(scattered, source_sampler, fragment.uv).rgb;
        color = mix(color, bloom, settings.bloom);
    }
    color *= settings.exposure;

    switch settings.curve {
        case NEUTRAL: {
            color = neutral(color);
        }
        case ACES: {
            color = aces(color);
        }
        default: {
            color = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0));
        }
    }
    return vec4<f32>(color, 1.0);
}