darkly 0.5.0

A GPU-native paint engine on wgpu: brushes, layers, blend modes, masks, selections, and undo.
Documentation
// Transform-commit: sample a source texture through an inverse affine matrix
// and composite onto a layer/mask texture with shader-side Porter-Duff.
//
// The destination is copied to a temp texture before this pass runs. The shader
// reads both the transformed source and the dest copy, computes correct
// straight-alpha source-over, and outputs with REPLACE blend. This avoids the
// premultiplied-stored-as-straight bug that hardware alpha blending causes on
// straight-alpha layer textures (see compositing lessons learned #4).

struct VertexOutput {
    @builtin(position) position: vec4f,
    @location(0) uv: vec2f,
}

@vertex fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
    var out: VertexOutput;
    let uv = vec2f(f32((idx << 1u) & 2u), f32(idx & 2u));
    out.position = vec4f(uv * 2.0 - 1.0, 0.0, 1.0);
    out.uv = vec2f(uv.x, 1.0 - uv.y);
    return out;
}

@group(0) @binding(0) var t_source: texture_2d<f32>;
@group(0) @binding(1) var t_sampler: sampler;

struct Uniforms {
    // Inverse affine matrix rows: [a, b, tx, _pad] and [c, d, ty, _pad]
    inv_row0: vec4f,
    inv_row1: vec4f,
    // Source texture origin in canvas pixel coords
    source_origin: vec2f,
    // Source texture dimensions in pixels
    source_size: vec2f,
    // Canvas-space offset of the render target's (0,0) pixel.
    target_offset: vec2f,
    // Render target pixel dimensions.
    target_size: vec2f,
    // Full document canvas dimensions in pixels.
    canvas_size: vec2f,
    opacity: f32,
    // Format flag: 0.0 = RGBA passthrough, 1.0 = R8 (output the R channel
    // straight — see the fix in fs_main).
    is_r8: f32,
}
@group(0) @binding(2) var<uniform> u: Uniforms;

// Destination copy (straight alpha) — for shader-side Porter-Duff.
@group(1) @binding(0) var t_dest: texture_2d<f32>;

@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f {
    // Convert target UV to canvas pixel position via the target's canvas-space
    // origin and size. For paste-extent layers, target_offset != 0.
    let canvas_pos = u.target_offset + in.uv * u.target_size;

    // Transform canvas position to source-local coordinates
    let local = canvas_pos - u.source_origin;

    // Apply inverse affine to find source position
    let src_x = u.inv_row0.x * local.x + u.inv_row0.y * local.y + u.inv_row0.z;
    let src_y = u.inv_row1.x * local.x + u.inv_row1.y * local.y + u.inv_row1.z;

    // Normalize to UV space
    let src_uv = vec2f(src_x, src_y) / u.source_size;

    // Outside source bounds — discard (let the target retain its pixels)
    if (any(src_uv < vec2f(0.0)) || any(src_uv >= vec2f(1.0))) {
        discard;
    }

    // Source texture is premultiplied for correct bilinear interpolation.
    let fg_pm = textureSampleLevel(t_source, t_sampler, src_uv, 0.0);
    let fg_a = fg_pm.a * u.opacity;
    let fg_pre = fg_pm.rgb * u.opacity;

    if (fg_a <= 0.0) {
        discard;
    }

    // Read destination (straight alpha — the layer's existing pixels).
    let bg = textureLoad(t_dest, vec2i(in.position.xy), 0);

    // Porter-Duff source-over (premultiplied fg, straight bg → straight output).
    let blended = source_over(fg_pre, fg_a, bg);

    if (u.is_r8 > 0.5) {
        // R8 target: source and dest are both single-channel; the value
        // we want is already in `.r`. The earlier RGB→luminance dot was a
        // bug for R8 inputs — sampling an R8Unorm texture as vec4 yields
        // (R, 0, 0, 1), so a luminance dot multiplied every committed
        // pixel by 0.2126, darkening the mask each commit.
        return vec4f(blended.r, blended.r, blended.r, blended.a);
    } else {
        // RGBA mode: output straight-alpha color.
        return blended;
    }
}