frus-gpu 0.2.1

frus — GPU context and 2D rendering engine (Milestone 0)
Documentation
// **Layer** compositing: a full-screen quad samples the layer's already-rendered
// texture and recomposes it at the group opacity, with clipping. The sample is
// already linear, the texture being sRGB, so nothing is converted back.
//
// A layer may also carry a **colour filter** and a **mask**, and those are applied
// here, between the sample and the composite. Both are defined on sRGB-encoded,
// straight-alpha values — the space colours are authored in — so the fragment
// un-premultiplies, converts to sRGB, filters, and puts it all back. With no filter
// that round trip is skipped entirely and the output is what it always was.
//
// The third effect, the image filter, is not here: it needs a pixel's neighbours,
// so it runs as a separate pre-pass over the layer texture before this one samples
// it (see `fx.wgsl`).

struct Viewport {
    size: vec2<f32>,
};

@group(0) @binding(0)
var<uniform> viewport: Viewport;

@group(1) @binding(0)
var tex: texture_2d<f32>;
@group(1) @binding(1)
var samp: sampler;
// The clip mask (ClipPath): the path rendered in white; solid white otherwise.
@group(1) @binding(2)
var mask: texture_2d<f32>;

// The layer's colour filter and shader mask. Per layer rather than per instance
// because a colour matrix alone is twenty numbers, and vertex attributes are a
// scarcer resource than a uniform slice: each layer already has its own bind group
// for its texture, so this rides along at no extra cost.
struct FilterParams {
    // (colour kind, mask kind, colour blend, mask blend). Colour kind: 0 none,
    // 1 matrix, 2 a colour blended in. Mask kind: 0 none, 1 linear, 2 radial.
    flags: vec4<f32>,
    // The colour matrix, row by row, then the constant column.
    m0: vec4<f32>,
    m1: vec4<f32>,
    m2: vec4<f32>,
    m3: vec4<f32>,
    m4: vec4<f32>,
    // The colour of a `Mode` colour fx.
    mode_color: vec4<f32>,
    // Linear: (from.x, from.y, to.x, to.y). Radial: (center.x, center.y, radius, _).
    mask_geom: vec4<f32>,
    mask_c0: vec4<f32>,
    mask_c1: vec4<f32>,
};

@group(1) @binding(3)
var<uniform> fx: FilterParams;

struct VertexInput {
    @location(0) unit_pos: vec2<f32>,
};

struct InstanceInput {
    @location(1) clip: vec4<f32>,    // x, y, width, height (px)
    @location(2) inv_lin: vec4<f32>, // affine inverse, linear part: ia, ib, ic, id
    @location(3) inv_tr_op: vec4<f32>, // ie, if, opacity, _
    @location(4) shape: vec4<f32>,   // kind (0=rect,1=rrect,2=oval), _, _, _
    @location(5) radii: vec4<f32>,   // rrect radii: tl, tr, br, bl
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    @location(0) uv: vec2<f32>,
    @location(1) frag_px: vec2<f32>,
    @location(2) @interpolate(flat) clip: vec4<f32>,
    @location(3) @interpolate(flat) opacity: f32,
    @location(4) @interpolate(flat) inv_lin: vec4<f32>,
    @location(5) @interpolate(flat) inv_tr: vec2<f32>,
    @location(6) @interpolate(flat) shape: vec4<f32>,
    @location(7) @interpolate(flat) radii: vec4<f32>,
};

@vertex
fn vs_main(vert: VertexInput, inst: InstanceInput) -> VertexOutput {
    // The unit quad covers the whole screen, and the layer texture is the size of
    // the surface, so uv is simply the normalised position.
    let ndc = vec2<f32>(vert.unit_pos.x * 2.0 - 1.0, 1.0 - vert.unit_pos.y * 2.0);
    var out: VertexOutput;
    out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
    out.uv = vert.unit_pos;
    out.frag_px = vert.unit_pos * viewport.size;
    out.clip = inst.clip;
    out.opacity = inst.inv_tr_op.z;
    out.inv_lin = inst.inv_lin;
    out.inv_tr = inst.inv_tr_op.xy;
    out.shape = inst.shape;
    out.radii = inst.radii;
    return out;
}

// Signed distance to a rounded rectangle centred at the origin, of half-size
// `half` and radius `r`: negative inside, positive outside. The basis of rounded
// corners.
fn sd_rounded_box(p: vec2<f32>, half: vec2<f32>, r: f32) -> f32 {
    let q = abs(p) - half + vec2<f32>(r, r);
    return length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - r;
}

// The corner radius matching `p`'s quadrant (centred, y pointing down).
// radii = (tl, tr, br, bl).
fn corner_radius(p: vec2<f32>, radii: vec4<f32>) -> f32 {
    if (p.x < 0.0) {
        return select(radii.w, radii.x, p.y < 0.0); // left: top → tl, bottom → bl
    }
    return select(radii.z, radii.y, p.y < 0.0);     // right: top → tr, bottom → br
}

// linear -> sRGB. The inverse of what the sampler already did for us; needed
// because the filters below are defined on the encoded values, not on light.
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
    let lower = c * 12.92;
    let higher = 1.055 * pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.4)) - 0.055;
    return select(higher, lower, c <= vec3<f32>(0.0031308));
}

fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
    let lower = c / 12.92;
    let higher = pow((c + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
    return select(higher, lower, c <= vec3<f32>(0.04045));
}

// One channel of a separable blend, on **straight** values.
fn separable(sc: f32, dc: f32, mode: u32) -> f32 {
    if (mode == 13u) {                      // Multiply
        return sc * dc;
    } else if (mode == 14u) {               // Screen
        return sc + dc - sc * dc;
    } else if (mode == 15u) {               // Overlay: Hard-light with the roles swapped
        if (dc <= 0.5) {
            return 2.0 * sc * dc;
        }
        return 1.0 - 2.0 * (1.0 - sc) * (1.0 - dc);
    } else if (mode == 16u) {               // Darken
        return min(sc, dc);
    }
    return max(sc, dc);                     // 17 Lighten
}

// `src` over `dst` under `mode`, both **premultiplied**. The Porter-Duff modes are
// linear in the premultiplied values, which is why they are one line each; the
// separable modes are not, so they are unpremultiplied first and the standard
// composition is spelled out.
fn blend(src: vec4<f32>, dst: vec4<f32>, mode: u32) -> vec4<f32> {
    let sa = src.a;
    let da = dst.a;
    if (mode == 0u) { return src; }                                  // Src
    if (mode == 1u) { return dst; }                                  // Dst
    if (mode == 2u) { return src + dst * (1.0 - sa); }               // SrcOver
    if (mode == 3u) { return dst + src * (1.0 - da); }               // DstOver
    if (mode == 4u) { return src * da; }                             // SrcIn
    if (mode == 5u) { return dst * sa; }                             // DstIn
    if (mode == 6u) { return src * (1.0 - da); }                     // SrcOut
    if (mode == 7u) { return dst * (1.0 - sa); }                     // DstOut
    if (mode == 8u) { return src * da + dst * (1.0 - sa); }          // SrcAtop
    if (mode == 9u) { return dst * sa + src * (1.0 - da); }          // DstAtop
    if (mode == 10u) {                                               // Xor
        return src * (1.0 - da) + dst * (1.0 - sa);
    }
    if (mode == 11u) {                                               // Plus
        return min(src + dst, vec4<f32>(1.0));
    }
    if (mode == 12u) {                                               // Modulate
        return src * dst;
    }
    // The separable set: unpremultiply, blend per channel, recompose.
    let sc = select(src.rgb / sa, vec3<f32>(0.0), sa <= 0.0);
    let dc = select(dst.rgb / da, vec3<f32>(0.0), da <= 0.0);
    let b = vec3<f32>(
        separable(sc.r, dc.r, mode),
        separable(sc.g, dc.g, mode),
        separable(sc.b, dc.b, mode),
    );
    let rgb = (1.0 - da) * src.rgb + (1.0 - sa) * dst.rgb + sa * da * b;
    return vec4<f32>(rgb, sa + da - sa * da);
}

// The mask's colour at a screen pixel: a two-stop fade, straight and sRGB-encoded
// like the colours it was authored from.
fn mask_color(p: vec2<f32>, kind: f32) -> vec4<f32> {
    var t: f32;
    if (kind < 1.5) {
        // Linear: the projection of `p` onto the from-to segment, clamped.
        let start = fx.mask_geom.xy;
        let axis = fx.mask_geom.zw - start;
        let len2 = dot(axis, axis);
        t = select(dot(p - start, axis) / len2, 0.0, len2 <= 0.0);
    } else {
        // Radial: the distance from the centre, over the radius.
        let center = fx.mask_geom.xy;
        let radius = fx.mask_geom.z;
        t = select(length(p - center) / radius, 1.0, radius <= 0.0);
    }
    return mix(fx.mask_c0, fx.mask_c1, clamp(t, 0.0, 1.0));
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    // The layer transform: the texture holds the content **flat** — untransformed —
    // at its screen position. To paint the layer transformed by M we sample at the
    // **counter-transformed** position M⁻¹(p), so screen pixel p receives the content
    // that, transformed by M, lands on p. With `inv_lin = ia,ib,ic,id` and
    // `inv_tr = ie,if`: src = (ia·x + ic·y + ie, ib·x + id·y + if).
    let p = in.frag_px;
    let src_px = vec2<f32>(
        in.inv_lin.x * p.x + in.inv_lin.z * p.y + in.inv_tr.x,
        in.inv_lin.y * p.x + in.inv_lin.w * p.y + in.inv_tr.y,
    );
    let src_uv = src_px / viewport.size;

    // Outside the texture after counter-rotation: nothing, a transparent edge.
    let in_bounds = f32(
        src_uv.x >= 0.0 && src_uv.x <= 1.0 && src_uv.y >= 0.0 && src_uv.y <= 1.0
    );
    // Clip coverage, tested on the screen pixel and **inscribed** in `clip`: a hard
    // rectangle (kind 0), rounded corners (kind 1) or an ellipse (kind 2). The curved
    // shapes are antialiased over roughly 1 px through the signed distance.
    let center = in.clip.xy + in.clip.zw * 0.5;
    let half = in.clip.zw * 0.5;
    let q = in.frag_px - center;
    let kind = in.shape.x;
    // The rectangular test, shared by a bare rect and a path's **bounds**.
    let in_rect = f32(
        in.frag_px.x >= in.clip.x
        && in.frag_px.x <= in.clip.x + in.clip.z
        && in.frag_px.y >= in.clip.y
        && in.frag_px.y <= in.clip.y + in.clip.w
    );
    var clip_cov: f32;
    if (kind < 0.5) {
        clip_cov = in_rect; // a hard rectangle, the original behaviour
    } else if (kind < 1.5) {
        // Rounded corners, **per corner**: the quadrant's radius, capped at half the
        // smaller dimension.
        let r = min(corner_radius(q, in.radii), min(half.x, half.y));
        let d = sd_rounded_box(q, half, r);
        clip_cov = 1.0 - smoothstep(-0.5, 0.5, d);
    } else if (kind < 2.5) {
        // The inscribed ellipse: an approximate, gradient-normalised edge distance.
        let e = q / max(half, vec2<f32>(1.0, 1.0));
        let g = length(e);
        let d = (g - 1.0) * min(half.x, half.y);
        clip_cov = 1.0 - smoothstep(-0.5, 0.5, d);
    } else {
        clip_cov = in_rect; // a path: rectangular bounds; the shape comes from the mask
    }
    // The coverage mask: solid white outside ClipPath → a neutral multiplication.
    let mask_a = textureSample(mask, samp, in.uv).a;
    let inside_clip = clip_cov * mask_a;
    let sample = textureSample(tex, samp, src_uv);
    // The group opacity and every coverage term, as one factor.
    let cover = in.opacity * inside_clip * in_bounds;
    let color_kind = fx.flags.x;
    let mask_kind = fx.flags.y;
    if (color_kind < 0.5 && mask_kind < 0.5) {
        // The overwhelmingly common case: no filter at all.
        //
        // The layer texture is **premultiplied** — it was painted over a transparent
        // target, which is what the (SrcAlpha, 1-SrcAlpha) blend leaves behind — so
        // scaling both halves by the coverage and letting the target blend with
        // (One, 1-SrcAlpha) is the correct "over", with no double-blending inside the
        // layer and none at its edge either.
        return vec4<f32>(sample.rgb * cover, sample.a * cover);
    }

    // The layer texture holds premultiplied linear light. The filters are defined on
    // straight, sRGB-encoded values, so we go there and come back.
    let a = sample.a;
    let straight = select(sample.rgb / a, vec3<f32>(0.0), a <= 0.0);
    var c = vec4<f32>(linear_to_srgb(straight), a);

    if (color_kind > 0.5 && color_kind < 1.5) {
        // The 5x4 matrix, on (r, g, b, a, 1).
        let v = c;
        c = vec4<f32>(
            dot(fx.m0, v) + fx.m4.x,
            dot(fx.m1, v) + fx.m4.y,
            dot(fx.m2, v) + fx.m4.z,
            dot(fx.m3, v) + fx.m4.w,
        );
        c = clamp(c, vec4<f32>(0.0), vec4<f32>(1.0));
    } else if (color_kind > 1.5) {
        // A colour blended into every pixel.
        let src = vec4<f32>(fx.mode_color.rgb * fx.mode_color.a, fx.mode_color.a);
        let dst = vec4<f32>(c.rgb * c.a, c.a);
        let out = blend(src, dst, u32(fx.flags.z));
        c = vec4<f32>(select(out.rgb / out.a, vec3<f32>(0.0), out.a <= 0.0), out.a);
    }

    if (mask_kind > 0.5) {
        let m = mask_color(in.frag_px, mask_kind);
        let src = vec4<f32>(m.rgb * m.a, m.a);
        let dst = vec4<f32>(c.rgb * c.a, c.a);
        let out = blend(src, dst, u32(fx.flags.w));
        c = vec4<f32>(select(out.rgb / out.a, vec3<f32>(0.0), out.a <= 0.0), out.a);
    }

    // Back to premultiplied linear, in the same convention as the fast path above.
    let lin = srgb_to_linear(clamp(c.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
    return vec4<f32>(lin * c.a * cover, c.a * cover);
}