diff --git a/packages/app/component-tests/new-session-wordmark.spec.ts b/packages/app/component-tests/new-session-wordmark.spec.ts index f12af9ca9cb..fbe86e5a67d 100644 --- a/packages/app/component-tests/new-session-wordmark.spec.ts +++ b/packages/app/component-tests/new-session-wordmark.spec.ts @@ -3,12 +3,15 @@ import { expect, story } from "../../storybook/playwright/story" // A cell of the "c" in the wordmark (column 40, row 2 of the 78×14 pin grid). const PIN = { x: 40.5 / 78, y: 2.5 / 14 } +// An empty cell above the "o", clear of every pin, side and shadow at rest. +const EMPTY = { x: 5.5 / 78, y: 0.5 / 14 } story("draws the wordmark as pins and stops animating once settled", async ({ mount, page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }) await mount("app-new-session-wordmark--pin-board") - await expect.poll(() => pinPixel(page)).toMatchObject({ alpha: 255 }) + const empty = await pinPixel(page, EMPTY) + await expect.poll(() => pinPixel(page)).not.toEqual(empty) await expect.poll(() => requestedFrames(page)).toBe(0) }) @@ -30,7 +33,8 @@ story("knocks pins out from the panel but never from the composer", async ({ mou story("shows a static pin board with reduced motion", async ({ mount, page }) => { await page.emulateMedia({ reducedMotion: "reduce" }) await mount("app-new-session-wordmark--pin-board") - await expect.poll(() => pinPixel(page)).toMatchObject({ alpha: 255 }) + const empty = await pinPixel(page, EMPTY) + await expect.poll(() => pinPixel(page)).not.toEqual(empty) await expect.poll(() => requestedFrames(page)).toBe(0) const before = await pinPixel(page) @@ -41,17 +45,21 @@ story("shows a static pin board with reduced motion", async ({ mount, page }) => expect(await requestedFrames(page)).toBe(0) }) -// Reads the back canvas pixel under the PIN cell, wherever the canvas currently sits. -function pinPixel(page: Page) { - return page.evaluate((pin) => { - const box = document.querySelector('[data-slot="wordmark-box"]')!.getBoundingClientRect() - const canvas = document.querySelector('canvas[data-slot="new-session-pins"]')! - const rect = canvas.getBoundingClientRect() - const x = Math.floor((box.left + box.width * pin.x - rect.left) * (canvas.width / rect.width)) - const y = Math.floor((box.top + box.height * pin.y - rect.top) * (canvas.height / rect.height)) - const d = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data - return { red: d[0], green: d[1], blue: d[2], alpha: d[3] } - }, PIN) +// Reads the on-screen pixel at a grid cell. The board may be drawn with WebGL, whose canvas cannot be read +// back once presented, so the pixel comes from a screenshot decoded in the page. +async function pinPixel(page: Page, cell = PIN) { + const box = (await page.locator('[data-slot="wordmark-box"]').boundingBox())! + const shot = await page.screenshot({ + clip: { x: box.x + box.width * cell.x, y: box.y + box.height * cell.y, width: 1, height: 1 }, + }) + return page.evaluate(async (png) => { + const image = await createImageBitmap(await (await fetch(`data:image/png;base64,${png}`)).blob()) + const canvas = new OffscreenCanvas(1, 1) + const context = canvas.getContext("2d")! + context.drawImage(image, 0, 0) + const d = context.getImageData(0, 0, 1, 1).data + return { red: d[0], green: d[1], blue: d[2] } + }, shot.toString("base64")) } // Counts animation frames the page requests while we watch the next 20 frames. diff --git a/packages/app/src/new-session/pin-board.tsx b/packages/app/src/new-session/pin-board.tsx index 22949e25315..f8760e2a382 100644 --- a/packages/app/src/new-session/pin-board.tsx +++ b/packages/app/src/new-session/pin-board.tsx @@ -1,4 +1,5 @@ import { onCleanup, onMount } from "solid-js" +import { createQuads, type Quads, type Rgba } from "./pin-quads" import { PinSound } from "./pin-sound" // The wordmark as a 78×14 pin grid: 1 = counter shadow, 2 = "open", 3 = "code". @@ -95,8 +96,8 @@ export function NewSessionPinBoard(props: { logo: HTMLElement; sound: () => bool function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: HTMLElement, sound: () => boolean) { const stage = back.parentElement! - const bctx = back.getContext("2d")! - const fctx = front.getContext("2d")! + // The pins behind the composer and the ones popped in front of it are each one layer of quads. + const layers = { back: createQuads(back, () => wake()), front: createQuads(front, () => wake()) } const still = matchMedia("(prefers-reduced-motion: reduce)").matches const pins: Pin[] = GRID.flatMap((row, r) => [...row].flatMap((ch, c) => (ch === "." ? [] : [createPin(c, r, Number(ch))])), @@ -403,52 +404,43 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: } const draw = (now: number) => { - const r = view.region - const dpr = view.dpr - bctx.setTransform(dpr, 0, 0, dpr, -r.x * dpr, -r.y * dpr) - bctx.clearRect(r.x, r.y, r.w, r.h) + const back = layers.back + back.begin() drawBoard(now) const loose = pins.filter((p) => p.state !== HOME) const popped = loose.filter((p) => popOf(p, now) > 0.06).sort((a, b) => popOf(a, now) - popOf(b, now)) const flat = loose.filter((p) => popOf(p, now) <= 0.06) - bctx.fillStyle = "#000" for (const p of loose) { const pop = popOf(p, now) const offset = view.depth * (0.35 + pop * 5) const size = view.size * (1 + pop * 1.6) * (1 + pop * 0.4) - bctx.globalAlpha = colors.shadow * (1 - pop * 0.65) - bctx.fillRect( + back.rect( p.x + tilt.x * view.depth - LIGHT[0] * offset - size / 2, p.y + tilt.y * view.depth - LIGHT[1] * offset - size / 2, size, size, + [0, 0, 0, colors.shadow[3] * (1 - pop * 0.65)], ) } - bctx.globalAlpha = 1 // Cubes further along the extrusion direction sit in front, so stacked pins overlap correctly. flat.sort((a, b) => a.x * tilt.x + a.y * tilt.y - (b.x * tilt.x + b.y * tilt.y)) - for (const p of flat) { - drawCube(bctx, p, 1, true) - drawCube(bctx, p, 1, false) - } + for (const p of flat) drawCube(back, p, 1) + back.draw(view.region, view.dpr) setFront(popped.length > 0) if (!popped.length) return - fctx.setTransform(dpr, 0, 0, dpr, 0, 0) - fctx.clearRect(0, 0, view.w, view.h) - for (const p of popped) { - const scale = 1 + popOf(p, now) * 1.6 - drawCube(fctx, p, scale, true) - drawCube(fctx, p, scale, false) - } + layers.front.begin() + for (const p of popped) drawCube(layers.front, p, 1 + popOf(p, now) * 1.6) + layers.front.draw({ x: 0, y: 0, w: view.w, h: view.h }, view.dpr) } // Pins are prisms from their front face back to the board. Drawing them in order of their // grid position along the extrusion direction is a correct painter's order for any heights, - // and pins on the same diagonal never overlap, so each diagonal is filled as one batch. + // since pins on the same diagonal never overlap. const drawBoard = (now: number) => { + const quads = layers.back const sx = tilt.x >= 0 ? 1 : -1 const sy = tilt.y >= 0 ? 1 : -1 const ox = tilt.x * view.depth @@ -457,32 +449,33 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: const size = view.size const age = now - clock.born - bctx.fillStyle = "#000" - bctx.globalAlpha = colors.shadow - bctx.beginPath() for (const p of pins) { if (p.state !== HOME || age < (p.c / COLS) * 0.45) continue - bctx.rect(p.hx + ox - LIGHT[0] * 2.5 - half - 1, p.hy + oy - LIGHT[1] * 2.5 - half - 1, size + 2, size + 2) + quads.shadow( + p.hx + ox - LIGHT[0] * 2.5 - half - 1, + p.hy + oy - LIGHT[1] * 2.5 - half - 1, + size + 2, + size + 2, + colors.shadow, + ) } - bctx.fill() - bctx.globalAlpha = 1 // An empty socket: a dark rim with its floor visible through the opening, shifted by the tilt. - bctx.fillStyle = colors.socketRim - bctx.beginPath() - for (const p of pins) if (p.state !== HOME) bctx.rect(p.hx + ox - half, p.hy + oy - half, size, size) - bctx.fill() - bctx.fillStyle = colors.socketFloor - bctx.beginPath() + for (const p of pins) + if (p.state !== HOME) quads.rect(p.hx + ox - half, p.hy + oy - half, size, size, colors.socketRim) for (const p of pins) { if (p.state === HOME) continue - const x0 = p.hx + ox - half + Math.max(0, ox * 0.45) - const y0 = p.hy + oy - half + Math.max(0, oy * 0.45) const w = size - Math.abs(ox * 0.45) const h = size - Math.abs(oy * 0.45) - if (w > 0 && h > 0) bctx.rect(x0, y0, w, h) + if (w > 0 && h > 0) + quads.rect( + p.hx + ox - half + Math.max(0, ox * 0.45), + p.hy + oy - half + Math.max(0, oy * 0.45), + w, + h, + colors.socketFloor, + ) } - bctx.fill() const key = (sx > 0 ? 1 : 0) + (sy > 0 ? 2 : 0) const order = @@ -496,65 +489,44 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: const sideX = sx > 0 ? Math.round(Math.max(0, LIGHT[0]) * SHADES) : 0 const sideY = sy < 0 ? Math.round(Math.max(0, -LIGHT[1]) * SHADES) : 0 - const flush = (count: number) => { + // Each diagonal goes out grouped by colour, so the 2D fallback can fill each run as one path. + const flush = () => { for (let lv = 1; lv <= 3; lv++) { - bctx.fillStyle = colors.side[lv][sideX] - bctx.beginPath() - for (let i = 0; i < count; i++) { - const p = band[i] + for (const p of band) { if (p.lv !== lv) continue const fx = p.hx + ox * p.fd + sx * half - const fy = p.hy + oy * p.fd - bctx.moveTo(fx, fy - half) - bctx.lineTo(p.hx + ox + sx * half, p.hy + oy - half) - bctx.lineTo(p.hx + ox + sx * half, p.hy + oy + half) - bctx.lineTo(fx, fy + half) - bctx.closePath() + quads.quad(fx, p.hy + oy * p.fd - half, ox * (1 - p.fd), oy * (1 - p.fd), 0, size, colors.side[lv][sideX]) } - bctx.fill() - bctx.fillStyle = colors.side[lv][sideY] - bctx.beginPath() - for (let i = 0; i < count; i++) { - const p = band[i] + for (const p of band) { if (p.lv !== lv) continue - const fx = p.hx + ox * p.fd const fy = p.hy + oy * p.fd + sy * half - bctx.moveTo(fx - half, fy) - bctx.lineTo(fx + half, fy) - bctx.lineTo(p.hx + ox + half, p.hy + oy + sy * half) - bctx.lineTo(p.hx + ox - half, p.hy + oy + sy * half) - bctx.closePath() + quads.quad(p.hx + ox * p.fd - half, fy, size, 0, ox * (1 - p.fd), oy * (1 - p.fd), colors.side[lv][sideY]) } - bctx.fill() - } - for (let i = 0; i < count; i++) { - const p = band[i] - bctx.fillStyle = colors.face[p.lv][shadeOf(Math.max(p.e, p.z))] - bctx.fillRect(p.hx + ox * p.fd - half, p.hy + oy * p.fd - half, size, size) } + for (const p of band) + quads.rect( + p.hx + ox * p.fd - half, + p.hy + oy * p.fd - half, + size, + size, + colors.face[p.lv][shadeOf(Math.max(p.e, p.z))], + ) if (size < 4) return - bctx.fillStyle = colors.highlight - bctx.beginPath() - for (let i = 0; i < count; i++) { - const p = band[i] + // The lit and shaded edges are split at the corners so no pixel is tinted twice by one of them. + for (const p of band) { if (p.lv === 1) continue const x = p.hx + ox * p.fd - half const y = p.hy + oy * p.fd - half - bctx.rect(x, y, size, 1) - bctx.rect(x + size - 1, y, 1, size) + quads.rect(x, y, size, 1, colors.highlight) + quads.rect(x + size - 1, y + 1, 1, size - 1, colors.highlight) } - bctx.fill() - bctx.fillStyle = colors.lowlight - bctx.beginPath() - for (let i = 0; i < count; i++) { - const p = band[i] + for (const p of band) { if (p.lv === 1) continue const x = p.hx + ox * p.fd - half const y = p.hy + oy * p.fd - half - bctx.rect(x, y + size - 1, size, 1) - bctx.rect(x, y, 1, size) + quads.rect(x, y + size - 1, size, 1, colors.lowlight) + quads.rect(x, y, 1, size - 1, colors.lowlight) } - bctx.fill() } let current = NaN @@ -567,18 +539,18 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: p.fd = 1 - height * (1 - Math.pow(1 - grow, 3)) const k = p.c * sx + p.r * sy if (k !== current && band.length) { - flush(band.length) + flush() band.length = 0 } current = k band.push(p) } - if (band.length) flush(band.length) + if (band.length) flush() } // Loose pins are extruded squares. Each visible side is lit by its own normal, so tumbling pins flicker. // Counter pins are recessed and nearly black on the board; out of it they use the loose shade so they read as cubes. - const drawCube = (ctx: CanvasRenderingContext2D, p: Pin, scale: number, sides: boolean) => { + const drawCube = (layer: Quads, p: Pin, scale: number) => { const lv = p.lv === 1 ? LOOSE : p.lv const radius = (view.size * scale) / Math.SQRT2 for (let j = 0; j < 4; j++) { @@ -588,33 +560,21 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: } const ox = tilt.x * view.depth * scale const oy = tilt.y * view.depth * scale - if (sides) { - for (let j = 0; j < 4; j++) { - const ax = corners[j * 2] - const ay = corners[j * 2 + 1] - const bx = corners[((j + 1) % 4) * 2] - const by = corners[((j + 1) % 4) * 2 + 1] - const nx = (ax + bx) / 2 - p.x - const ny = (ay + by) / 2 - p.y - if (nx * tilt.x + ny * tilt.y <= 0) continue - const n = Math.hypot(nx, ny) || 1 - ctx.fillStyle = colors.side[lv][Math.round(Math.max(0, (nx * LIGHT[0] + ny * LIGHT[1]) / n) * SHADES)] - ctx.beginPath() - ctx.moveTo(ax, ay) - ctx.lineTo(bx, by) - ctx.lineTo(bx + ox, by + oy) - ctx.lineTo(ax + ox, ay + oy) - ctx.closePath() - ctx.fill() - } - return + for (let j = 0; j < 4; j++) { + const ax = corners[j * 2] + const ay = corners[j * 2 + 1] + const bx = corners[((j + 1) % 4) * 2] + const by = corners[((j + 1) % 4) * 2 + 1] + const nx = (ax + bx) / 2 - p.x + const ny = (ay + by) / 2 - p.y + if (nx * tilt.x + ny * tilt.y <= 0) continue + const n = Math.hypot(nx, ny) || 1 + const shade = Math.round(Math.max(0, (nx * LIGHT[0] + ny * LIGHT[1]) / n) * SHADES) + layer.quad(ax, ay, bx - ax, by - ay, ox, oy, colors.side[lv][shade]) } - ctx.fillStyle = colors.face[lv][shadeOf(p.e)] - ctx.beginPath() - ctx.moveTo(corners[0], corners[1]) - for (let j = 1; j < 4; j++) ctx.lineTo(corners[j * 2], corners[j * 2 + 1]) - ctx.closePath() - ctx.fill() + const x = corners[0] + const y = corners[1] + layer.quad(x, y, corners[2] - x, corners[3] - y, corners[6] - x, corners[7] - y, colors.face[lv][shadeOf(p.e)]) } const toStage = (event: PointerEvent) => { @@ -708,6 +668,8 @@ function mountPinBoard(back: HTMLCanvasElement, front: HTMLCanvasElement, logo: stage.removeEventListener("selectstart", onSelect) stage.removeEventListener("dragstart", onSelect) window.removeEventListener("blur", onUp) + layers.back.dispose() + layers.front.dispose() } } @@ -769,21 +731,21 @@ function readColors(stage: HTMLElement) { const dark = bg[0] * 0.2126 + bg[1] * 0.7152 + bg[2] * 0.0722 < 128 const black = [0, 0, 0] const shades = (fn: (t: number) => readonly number[]) => - Array.from({ length: SHADES + 1 }, (_, i) => css(fn(i / SHADES))) + Array.from({ length: SHADES + 1 }, (_, i) => color(fn(i / SHADES))) // Sides are the pin's own colour in shadow, so they stay darker than its face in either theme. return { side: levels.map((level) => shades((t) => mix(black, level, 0.3 + 0.5 * t))), face: levels.map((level, lv) => shades((t) => mix(level, brand, t * (lv === 1 ? 0.4 : 0.85)))), - socketRim: css(mix(bg, black, dark ? 0.6 : 0.16)), - socketFloor: css(mix(bg, black, dark ? 0.35 : 0.08)), - shadow: dark ? 0.3 : 0.12, - highlight: "rgba(255, 255, 255, 0.2)", - lowlight: "rgba(0, 0, 0, 0.2)", + socketRim: color(mix(bg, black, dark ? 0.6 : 0.16)), + socketFloor: color(mix(bg, black, dark ? 0.35 : 0.08)), + shadow: color(black, dark ? 0.3 : 0.12), + highlight: color([255, 255, 255], 0.2), + lowlight: color(black, 0.2), } } const mix = (a: readonly number[], b: readonly number[], t: number) => a.map((v, i) => v + (b[i] - v) * t) -const css = (c: readonly number[]) => `rgb(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])})` +const color = (c: readonly number[], alpha = 1): Rgba => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2]), alpha] const lerp = (a: number, b: number, t: number) => a + (b - a) * t const clamp = (v: number) => Math.max(-1, Math.min(1, v)) const shadeOf = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * SHADES) diff --git a/packages/app/src/new-session/pin-quads.ts b/packages/app/src/new-session/pin-quads.ts new file mode 100644 index 00000000000..ca97e2c2765 --- /dev/null +++ b/packages/app/src/new-session/pin-quads.ts @@ -0,0 +1,248 @@ +// Draws a pin board layer as a list of filled parallelograms. With WebGL2 the whole list is at most two +// instanced draws, so a frame costs about the same however many pins there are. A 2D canvas spends a +// GPU draw call on most shapes, which kept weak machines busy. Without hardware WebGL2 it falls back to one 2D +// path fill per run of same-coloured quads, as drawing straight to the canvas would. + +export type Rgba = readonly [number, number, number, number] +export type Quads = ReturnType + +type Region = { x: number; y: number; w: number; h: number } + +const FLOATS = 10 + +const VERTEX = `#version 300 es +in vec2 corner; +in vec2 origin; +in vec2 u; +in vec2 v; +in vec4 color; +uniform vec2 view; +uniform vec2 size; +uniform float scale; +out vec2 st; +out vec2 span; +out vec4 tint; +void main() { + vec2 du = u * scale; + vec2 dv = v * scale; + float area = max(abs(du.x * dv.y - du.y * dv.x), 1e-4); + // span is the quad's width across each pair of edges in device pixels; the quad grows a pixel + // past every edge so partly covered pixels get drawn. + span = vec2(area / max(length(dv), 1e-4), area / max(length(du), 1e-4)); + st = mix(-1.0 / span, 1.0 + 1.0 / span, corner); + vec2 p = (origin - view) * scale + du * st.x + dv * st.y; + gl_Position = vec4(p.x / size.x * 2.0 - 1.0, 1.0 - p.y / size.y * 2.0, 0.0, 1.0); + tint = color; +}` + +// Coverage is the overlap of the pixel with the quad along each axis, which is exact for axis-aligned rects. +const FRAGMENT = `#version 300 es +precision highp float; +in vec2 st; +in vec2 span; +in vec4 tint; +out vec4 outColor; +void main() { + vec2 d = st * span; + vec2 c = clamp(min(d + 0.5, span) - max(d - 0.5, 0.0), 0.0, 1.0); + outColor = tint * (c.x * c.y); +}` + +export function createQuads(canvas: HTMLCanvasElement, restored: () => void) { + // Each quad's colour is also kept as given, for the 2D fallback. + const data = { list: new Float32Array(FLOATS * 1024), tints: [] as Rgba[], count: 0, shadows: 0 } + // The context is created on first draw, so a layer that never shows pins never holds one. + const surface: { + current?: { gl: WebGL2RenderingContext; gpu: ReturnType } | { ctx: CanvasRenderingContext2D } + } = {} + const styles = new WeakMap() + + const push = (x: number, y: number, ux: number, uy: number, vx: number, vy: number, c: Rgba) => { + if ((data.count + 1) * FLOATS > data.list.length) { + const grown = new Float32Array(data.list.length * 2) + grown.set(data.list) + data.list = grown + } + const list = data.list + data.tints[data.count] = c + const i = data.count++ * FLOATS + const a = c[3] + list[i] = x + list[i + 1] = y + list[i + 2] = ux + list[i + 3] = uy + list[i + 4] = vx + list[i + 5] = vy + list[i + 6] = (c[0] / 255) * a + list[i + 7] = (c[1] / 255) * a + list[i + 8] = (c[2] / 255) * a + list[i + 9] = a + } + + return { + begin() { + data.count = 0 + data.shadows = 0 + }, + /** Shadows, all in one colour, go first and merge where they overlap instead of darkening. */ + shadow(x: number, y: number, w: number, h: number, c: Rgba) { + push(x, y, w, 0, 0, h, c) + data.shadows = data.count + }, + rect(x: number, y: number, w: number, h: number, c: Rgba) { + push(x, y, w, 0, 0, h, c) + }, + /** The parallelogram with a corner at (x, y) and edges u and v. */ + quad(x: number, y: number, ux: number, uy: number, vx: number, vy: number, c: Rgba) { + push(x, y, ux, uy, vx, vy, c) + }, + draw(region: Region, scale: number) { + surface.current ??= open() + if ("gl" in surface.current) return drawGL(surface.current.gl, surface.current.gpu, region, scale) + drawCanvas(surface.current.ctx, region, scale) + }, + dispose() { + if (surface.current && "gl" in surface.current) + surface.current.gl.getExtension("WEBGL_lose_context")?.loseContext() + }, + } + + function open() { + // Software WebGL is refused: the browser would read every frame back to composite it, which costs more than 2D. + const gl = canvas.getContext("webgl2", { + premultipliedAlpha: true, + antialias: false, + depth: false, + failIfMajorPerformanceCaveat: true, + }) + if (gl) return { gl, gpu: setup(gl) } + return { ctx: canvas.getContext("2d")! } + } + + function drawGL(gl: WebGL2RenderingContext, gpu: ReturnType, region: Region, scale: number) { + if (gl.isContextLost()) return + if (!gpu.ready) gpu.init() + gl.viewport(0, 0, canvas.width, canvas.height) + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + if (!data.count) return + gl.useProgram(gpu.program) + gl.bindVertexArray(gpu.vao) + gl.uniform2f(gpu.view, region.x, region.y) + gl.uniform2f(gpu.size, canvas.width, canvas.height) + gl.uniform1f(gpu.scale, scale) + gl.bindBuffer(gl.ARRAY_BUFFER, gpu.instances) + // The GPU buffer is reused across frames and only reallocated when the quad list outgrows it. + if (gpu.capacity < data.list.length) { + gpu.capacity = data.list.length + gl.bufferData(gl.ARRAY_BUFFER, gpu.capacity * 4, gl.DYNAMIC_DRAW) + } + gl.bufferSubData(gl.ARRAY_BUFFER, 0, data.list, 0, data.count * FLOATS) + gl.enable(gl.BLEND) + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) + if (data.shadows) { + gl.blendEquation(gl.MAX) + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, data.shadows) + } + gl.blendEquation(gl.FUNC_ADD) + if (data.count > data.shadows) { + gpu.offset(data.shadows) + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, data.count - data.shadows) + gpu.offset(0) + } + } + + // Each run of quads in one colour is one path fill. The board emits quads grouped by colour, so this makes + // about as many fills as drawing straight to the canvas would. A shared path also merges overlapping shadows. + function drawCanvas(ctx: CanvasRenderingContext2D, region: Region, scale: number) { + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.setTransform(scale, 0, 0, scale, -region.x * scale, -region.y * scale) + const list = data.list + for (let n = 0; n < data.count; ) { + const c = data.tints[n] + ctx.fillStyle = styles.get(c) ?? styles.set(c, `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${c[3]})`).get(c)! + ctx.beginPath() + for (; n < data.count && data.tints[n] === c; n++) { + const i = n * FLOATS + const x = list[i] + const y = list[i + 1] + if (list[i + 3] === 0 && list[i + 4] === 0) { + ctx.rect(x, y, list[i + 2], list[i + 5]) + continue + } + ctx.moveTo(x, y) + ctx.lineTo(x + list[i + 2], y + list[i + 3]) + ctx.lineTo(x + list[i + 2] + list[i + 4], y + list[i + 3] + list[i + 5]) + ctx.lineTo(x + list[i + 4], y + list[i + 5]) + ctx.closePath() + } + ctx.fill() + } + } + // GPU objects are rebuilt after the context is lost and restored. + function setup(gl: WebGL2RenderingContext) { + const gpu = { + ready: false, + capacity: 0, + program: null as WebGLProgram | null, + vao: null as WebGLVertexArrayObject | null, + instances: null as WebGLBuffer | null, + view: null as WebGLUniformLocation | null, + size: null as WebGLUniformLocation | null, + scale: null as WebGLUniformLocation | null, + attributes: [] as { at: number; size: number; offset: number }[], + init() { + const program = gl.createProgram()! + for (const [type, source] of [ + [gl.VERTEX_SHADER, VERTEX], + [gl.FRAGMENT_SHADER, FRAGMENT], + ] as const) { + const shader = gl.createShader(type)! + gl.shaderSource(shader, source) + gl.compileShader(shader) + gl.attachShader(program, shader) + } + gl.linkProgram(program) + gpu.program = program + gpu.view = gl.getUniformLocation(program, "view") + gpu.size = gl.getUniformLocation(program, "size") + gpu.scale = gl.getUniformLocation(program, "scale") + gpu.vao = gl.createVertexArray() + gl.bindVertexArray(gpu.vao) + const corners = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, corners) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW) + const corner = gl.getAttribLocation(program, "corner") + gl.enableVertexAttribArray(corner) + gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0) + gpu.instances = gl.createBuffer() + gpu.capacity = 0 + gl.bindBuffer(gl.ARRAY_BUFFER, gpu.instances) + gpu.attributes = [ + { at: gl.getAttribLocation(program, "origin"), size: 2, offset: 0 }, + { at: gl.getAttribLocation(program, "u"), size: 2, offset: 2 }, + { at: gl.getAttribLocation(program, "v"), size: 2, offset: 4 }, + { at: gl.getAttribLocation(program, "color"), size: 4, offset: 6 }, + ] + for (const a of gpu.attributes) { + gl.enableVertexAttribArray(a.at) + gl.vertexAttribDivisor(a.at, 1) + } + gpu.offset(0) + gpu.ready = true + }, + // Points the per-quad attributes at the given first quad, so one buffer serves both draws. + offset(first: number) { + for (const a of gpu.attributes) + gl.vertexAttribPointer(a.at, a.size, gl.FLOAT, false, FLOATS * 4, (first * FLOATS + a.offset) * 4) + }, + } + canvas.addEventListener("webglcontextlost", (event) => event.preventDefault()) + canvas.addEventListener("webglcontextrestored", () => { + gpu.ready = false + restored() + }) + return gpu + } +}