Leaves, grass and lace are shapes cut out by a texture's alpha. Blending them needs sorting, and discard gives aliased edges. multisample: { count: 4, alphaToCoverageEnabled: true } turns the fragment's alpha into a sample mask instead: alpha 0.5 covers about half the samples, and the resolve averages them into a soft edge, with no sorting and a working depth test. A white full-screen triangle drawn into a black 4x target at alphas 0.1 to 1 resolved to only five red values: 0 (alpha 0.1), 64 (0.2-0.3), 127 (0.4-0.6), 191 (0.7-0.8) and 255 (0.9-1), one per number of covered samples. Use it for cut-out edges; broad semi-transparent areas still need blending.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="330"></canvas>
<canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
// A sprig of leaves: alpha comes from a soft distance field, as a leaf texture's alpha would.
const leaves = (useDiscard) => /* wgsl */ `
@group(0) @binding(0) var<uniform> time: f32;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1), vec2f(0, 1))[v];
let a = f32(i) * 0.9 + sin(time + f32(i)) * 0.15;
let c = vec2f(cos(a), sin(a)) * 0.3;
let p = (q - 0.5) * vec2f(0.55, 0.3);
let r = vec2f(p.x * cos(a) - p.y * sin(a), p.x * sin(a) + p.y * cos(a));
return Out(vec4f(c + r, 0, 1), q);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let d = length((in.uv - 0.5) * vec2f(1, 2.2));
let alpha = clamp((0.5 - d) * 6 + 0.5, 0, 1); // soft leaf edge
${useDiscard ? 'if (alpha < 0.5) { discard; }' : ''}
return vec4f(mix(vec3f(0.15, 0.45, 0.18), vec3f(0.35, 0.65, 0.25), in.uv.x), alpha);
}`;
// The book's ramp: a white full-screen strip at alphas 0.1 to 1.0 over black.
const ramp = /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) @interpolate(flat) i: u32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1), vec2f(0, 1))[v];
if (i == 10u) { return Out(vec4f(q * 2 - 1, 0, 1), i); } // instance 10: the black backing
return Out(vec4f(-1 + (f32(i) + q.x) * 0.2, q.y * 2 - 1, 0, 1), i);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
if (in.i == 10u) { return vec4f(0, 0, 0, 1); }
return vec4f(1, 1, 1, f32(in.i + 1) / 10); // ten columns: alpha 0.1 ... 1.0
}`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const make = (code, count, alphaToCoverageEnabled) => {
const module = device.createShaderModule({ code });
return device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format }] },
multisample: { count, alphaToCoverageEnabled } });
};
const cutout = make(leaves(true), 4, false); // discard: hard, aliased edges
const covered = make(leaves(false), 4, true); // alpha becomes a sample mask
const rampPipeline = make(ramp, 4, true);
const msaa = device.createTexture({ size: [600, 330], format, sampleCount: 4, usage: GPUTextureUsage.RENDER_ATTACHMENT });
const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const g = (p) => device.createBindGroup({ layout: p.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: time } }] });
const cutoutGroup = g(cutout), coveredGroup = g(covered);
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText('if (alpha < 0.5) { discard; }', 10, 18);
ink.fillText('alphaToCoverageEnabled: true', 310, 18);
ink.fillText('alpha 0.1 ... 1.0 at 4x: only five distinct results (0-4 samples)', 10, 236);
function frame(now) {
device.queue.writeBuffer(time, 0, new Float32Array([now / 900]));
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: msaa.createView(), resolveTarget: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'discard' }] });
pass.setViewport(0, 24, 300, 190, 0, 1); pass.setPipeline(cutout); pass.setBindGroup(0, cutoutGroup); pass.draw(6, 7);
pass.setViewport(300, 24, 300, 190, 0, 1); pass.setPipeline(covered); pass.setBindGroup(0, coveredGroup); pass.draw(6, 7);
pass.setViewport(0, 246, 600, 70, 0, 1);
pass.setScissorRect(0, 246, 600, 70);
pass.setPipeline(rampPipeline);
pass.draw(6, 1, 0, 10); // black first...
pass.draw(6, 10); // ...then white at each alpha
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>