codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
Documentation
// Linear light in, a picture out.
//
// The 3D passes draw radiance, not colour: a bright highlight is allowed to
// come out at 4.0, because that is what the light did. A screen cannot show
// 4.0, and clamping it to 1.0 turns every highlight into the same flat white
// disc with a hard edge. This pass is where that range is fitted into what a
// display can show, once, for the whole frame.
//
// The curve is the Khronos PBR Neutral tone mapper. It leaves anything a
// screen can already show almost exactly as it is -- so an authored colour
// still reads as the colour that was authored -- and only bends the range
// above that, rolling highlights off towards white instead of clipping them.
// ACES would be punchier and would also shift the hues of the pieces, which
// is a thing to notice on a board whose two sides are told apart by colour.

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

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
};

// One triangle over the whole frame, from the vertex index alone; the same
// trick the grid uses, and for the same reason: no buffer, and no seam.
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput {
    var out: VertexOutput;
    out.clip_position = vec4<f32>(
        f32(i32(index) % 2) * 4.0 - 1.0,
        f32(i32(index) / 2) * 4.0 - 1.0,
        0.0,
        1.0,
    );
    return out;
}

const START_COMPRESSION: f32 = 0.8 - 0.04;
const DESATURATION: f32 = 0.15;

fn pbr_neutral(color_in: vec3<f32>) -> vec3<f32> {
    var color = color_in;

    // Lift the darkest channel off the floor, which is what keeps a deep
    // shadow from going flat black before the curve has done anything.
    let x = min(color.r, min(color.g, color.b));
    let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
    color = color - offset;

    // Below this, the display can show it: leave it alone.
    let peak = max(color.r, max(color.g, color.b));
    if peak < START_COMPRESSION {
        return color;
    }

    // A hyperbola from here to white, so however bright the light was the
    // result lands under 1.0 rather than clipping at it.
    let d = 1.0 - START_COMPRESSION;
    let new_peak = 1.0 - d * d / (peak + d - START_COMPRESSION);
    color = color * (new_peak / peak);

    // The brightest part of a real highlight loses its colour on the way to
    // white. Without this a compressed red stays red and reads as paint.
    let g = 1.0 - 1.0 / (DESATURATION * (peak - new_peak) + 1.0);
    return mix(color, vec3<f32>(new_peak), g);
}

// The pass is 1:1 with the image it reads, so the pixel is fetched rather
// than sampled: no filtering to choose, and no sampler to bind.
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    let hdr_color = textureLoad(hdr, vec2<i32>(in.clip_position.xy), 0);
    // Still linear on the way out. The surface is an sRGB format, so the
    // hardware does the encode as it writes.
    return vec4<f32>(pbr_neutral(hdr_color.rgb), hdr_color.a);
}