fix(app): draw the new session pin board with WebGL instancing (#57031)

This commit is contained in:
Luke Parker
2026-10-19 19:36:31 +10:00
committed by GitHub
Unverified
parent ac0fa47e99
commit 9458fcd62f
3 changed files with 347 additions and 129 deletions
@@ -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<HTMLCanvasElement>('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.
+78 -116
View File
@@ -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)
+248
View File
@@ -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<typeof createQuads>
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<typeof setup> } | { ctx: CanvasRenderingContext2D }
} = {}
const styles = new WeakMap<Rgba, string>()
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<typeof setup>, 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
}
}