mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): draw the new session pin board with WebGL instancing (#57031)
This commit is contained in:
committed by
GitHub
Unverified
parent
ac0fa47e99
commit
9458fcd62f
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user