perf(renderer): draw checkerboard with shader

This commit is contained in:
syntaxbullet
2026-07-04 15:43:50 +02:00
parent ef0ce15bb5
commit a030f2065a
4 changed files with 283 additions and 24 deletions

View File

@@ -1,13 +1,13 @@
import type { Artboard } from "@core/artboard"; import type { Artboard } from "@core/artboard";
import type { ViewportState } from "@editor/state"; import type { ViewportState } from "@editor/state";
import { renderCheckerboard } from "./checkerboard"; import type { CheckerboardRenderer } from "./checkerboard";
import { documentRectToScreenRect } from "./screen-rect"; import { documentRectToScreenRect } from "./screen-rect";
import type { WebGlRendererContext } from "./types"; import type { WebGlRendererContext } from "./types";
export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState) { export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState, checkerboardRenderer: CheckerboardRenderer) {
const rect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport); const rect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
if (artboard.backgroundColor === "transparent") { if (artboard.backgroundColor === "transparent") {
renderCheckerboard(context, rect, Math.max(4, Math.round(12 * viewport.zoom))); checkerboardRenderer.render(rect, Math.max(4, Math.round(12 * viewport.zoom)));
} }
} }

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import { createCheckerboardRenderer } from "./checkerboard";
import type { WebGlRendererContext } from "./types";
type FakeGlCall = {
name: string;
args: unknown[];
};
type FakeUniformLocation = {
name: string;
};
describe("checkerboard renderer", () => {
test("renders a clipped checkerboard with one draw call", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.render({ x: -5, y: 3, w: 30, h: 20 }, 12);
expect(fake.calls.filter((call) => call.name === "drawArrays")).toHaveLength(1);
expect(fake.calls.find((call) => call.name === "scissor")?.args).toEqual([0, 57, 25, 20]);
expect(fake.uniforms.get("u_origin")).toEqual([-5, 3]);
expect(fake.uniforms.get("u_squareSize")).toEqual([12]);
expect(Array.from(fake.bufferData.at(-1) ?? [])).toEqual([0, 3, 25, 3, 0, 23, 0, 23, 25, 3, 25, 23]);
});
test("skips fully clipped checkerboards", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.render({ x: 120, y: 0, w: 20, h: 20 }, 8);
expect(fake.calls.filter((call) => call.name === "drawArrays")).toHaveLength(0);
});
test("disposes WebGL resources", () => {
const fake = createFakeGl();
const renderer = createCheckerboardRenderer(createFakeContext(fake.gl, 100, 80));
renderer.dispose();
expect(fake.calls.filter((call) => call.name === "deleteBuffer")).toHaveLength(1);
expect(fake.calls.filter((call) => call.name === "deleteProgram")).toHaveLength(1);
});
});
function createFakeContext(gl: WebGL2RenderingContext, width: number, height: number): WebGlRendererContext {
return {
gl,
canvas: { width, height } as HTMLCanvasElement,
};
}
function createFakeGl() {
const calls: FakeGlCall[] = [];
const uniforms = new Map<string, number[]>();
const bufferData: Float32Array[] = [];
const shader = {};
const program = {};
const buffer = {};
const noop = () => {};
const gl = {
ARRAY_BUFFER: 0x8892,
BLEND: 0x0be2,
COMPILE_STATUS: 0x8b81,
DYNAMIC_DRAW: 0x88e8,
FLOAT: 0x1406,
FRAGMENT_SHADER: 0x8b30,
LINK_STATUS: 0x8b82,
SCISSOR_TEST: 0x0c11,
TRIANGLES: 0x0004,
VERTEX_SHADER: 0x8b31,
attachShader: noop,
bindBuffer: noop,
bufferData: (...args: [number, BufferSource, number]) => {
const data = args[1];
if (data instanceof Float32Array) bufferData.push(data);
},
compileShader: noop,
createBuffer: () => buffer,
createProgram: () => program,
createShader: () => shader,
deleteBuffer: () => {
calls.push({ name: "deleteBuffer", args: [] });
},
deleteProgram: () => {
calls.push({ name: "deleteProgram", args: [] });
},
deleteShader: noop,
disable: (capability: number) => {
calls.push({ name: "disable", args: [capability] });
},
disableVertexAttribArray: (index: number) => {
calls.push({ name: "disableVertexAttribArray", args: [index] });
},
drawArrays: (mode: number, first: number, count: number) => {
calls.push({ name: "drawArrays", args: [mode, first, count] });
},
enable: (capability: number) => {
calls.push({ name: "enable", args: [capability] });
},
enableVertexAttribArray: (index: number) => {
calls.push({ name: "enableVertexAttribArray", args: [index] });
},
getAttribLocation: () => 0,
getProgramInfoLog: () => null,
getProgramParameter: () => true,
getShaderInfoLog: () => null,
getShaderParameter: () => true,
getUniformLocation: (...args: [unknown, string]) => ({ name: args[1] }),
linkProgram: noop,
scissor: (x: number, y: number, w: number, h: number) => {
calls.push({ name: "scissor", args: [x, y, w, h] });
},
shaderSource: noop,
uniform1f: (location: FakeUniformLocation, value: number) => {
uniforms.set(location.name, [value]);
},
uniform2f: (location: FakeUniformLocation, x: number, y: number) => {
uniforms.set(location.name, [x, y]);
},
uniform4fv: (location: FakeUniformLocation, value: Iterable<number>) => {
uniforms.set(location.name, Array.from(value));
},
useProgram: noop,
vertexAttribPointer: (index: number, size: number, type: number, normalized: boolean, stride: number, offset: number) => {
calls.push({ name: "vertexAttribPointer", args: [index, size, type, normalized, stride, offset] });
},
} as unknown as WebGL2RenderingContext;
return { gl, calls, uniforms, bufferData };
}

View File

@@ -1,26 +1,148 @@
import { clearScreenRect } from "./clear-rect"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import type { ScreenRect, WebGlRendererContext } from "./types";
const darkChecker = [0.82, 0.82, 0.86, 1] as const; const darkChecker: RgbaColor = [0.82, 0.82, 0.86, 1];
const lightChecker = [0.94, 0.94, 0.97, 1] as const; const lightChecker: RgbaColor = [0.94, 0.94, 0.97, 1];
export function renderCheckerboard(context: WebGlRendererContext, rect: ScreenRect, squareSize: number) { export type CheckerboardRenderer = {
const clampedSquareSize = Math.max(1, squareSize); render(rect: ScreenRect, squareSize: number): void;
const canvasWidth = context.canvas.width; dispose(): void;
const canvasHeight = context.canvas.height; };
for (let py = rect.y; py < rect.y + rect.h; py += clampedSquareSize) { export function createCheckerboardRenderer(context: WebGlRendererContext): CheckerboardRenderer {
for (let px = rect.x; px < rect.x + rect.w; px += clampedSquareSize) { const { gl } = context;
const x = Math.max(0, px); const program = createProgram(gl);
const y = Math.max(0, py); const positionLocation = gl.getAttribLocation(program, "a_position");
const w = Math.min(px + clampedSquareSize, rect.x + rect.w, canvasWidth) - x; const canvasSizeLocation = gl.getUniformLocation(program, "u_canvasSize");
const h = Math.min(py + clampedSquareSize, rect.y + rect.h, canvasHeight) - y; const originLocation = gl.getUniformLocation(program, "u_origin");
if (w <= 0 || h <= 0) continue; const squareSizeLocation = gl.getUniformLocation(program, "u_squareSize");
const darkColorLocation = gl.getUniformLocation(program, "u_darkColor");
const lightColorLocation = gl.getUniformLocation(program, "u_lightColor");
const positionBuffer = gl.createBuffer();
const checker = if (
(Math.floor((px - rect.x) / clampedSquareSize) + Math.floor((py - rect.y) / clampedSquareSize)) % 2 === 0; positionLocation < 0 ||
!canvasSizeLocation ||
clearScreenRect(context, { x, y, w, h }, checker ? darkChecker : lightChecker); !originLocation ||
} !squareSizeLocation ||
!darkColorLocation ||
!lightColorLocation ||
!positionBuffer
) {
if (positionBuffer) gl.deleteBuffer(positionBuffer);
gl.deleteProgram(program);
throw new Error("Failed to create checkerboard renderer");
} }
return {
render(rect, squareSize) {
const drawRect = intersectScreenRectWithCanvas(rect, context.canvas);
if (!drawRect) return;
gl.enable(gl.SCISSOR_TEST);
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
gl.disable(gl.BLEND);
gl.useProgram(program);
gl.uniform2f(canvasSizeLocation, context.canvas.width, context.canvas.height);
gl.uniform2f(originLocation, rect.x, rect.y);
gl.uniform1f(squareSizeLocation, Math.max(1, squareSize));
gl.uniform4fv(darkColorLocation, darkChecker);
gl.uniform4fv(lightColorLocation, lightChecker);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, screenRectVertices(drawRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.disableVertexAttribArray(positionLocation);
},
dispose() {
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(program);
},
};
}
function intersectScreenRectWithCanvas(rect: ScreenRect, canvas: HTMLCanvasElement): ScreenRect | undefined {
const x1 = Math.max(0, rect.x);
const y1 = Math.max(0, rect.y);
const x2 = Math.min(canvas.width, rect.x + rect.w);
const y2 = Math.min(canvas.height, rect.y + rect.h);
if (x2 <= x1 || y2 <= y1) return undefined;
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}
function screenRectVertices(rect: ScreenRect) {
const x1 = rect.x;
const x2 = rect.x + rect.w;
const y1 = rect.y;
const y2 = rect.y + rect.h;
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}
function createProgram(gl: WebGL2RenderingContext) {
const vertexShader = compileShader(
gl,
gl.VERTEX_SHADER,
`#version 300 es
in vec2 a_position;
uniform vec2 u_canvasSize;
out vec2 v_screenPosition;
void main() {
vec2 clip = vec2((a_position.x / u_canvasSize.x) * 2.0 - 1.0, 1.0 - (a_position.y / u_canvasSize.y) * 2.0);
gl_Position = vec4(clip, 0.0, 1.0);
v_screenPosition = a_position;
}`,
);
const fragmentShader = compileShader(
gl,
gl.FRAGMENT_SHADER,
`#version 300 es
precision highp float;
uniform vec2 u_origin;
uniform float u_squareSize;
uniform vec4 u_darkColor;
uniform vec4 u_lightColor;
in vec2 v_screenPosition;
out vec4 outColor;
void main() {
vec2 cell = floor((v_screenPosition - u_origin) / max(1.0, u_squareSize));
float parity = mod(cell.x + cell.y, 2.0);
outColor = mix(u_darkColor, u_lightColor, parity);
}`,
);
const program = gl.createProgram();
if (!program) throw new Error("Failed to create checkerboard shader program");
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) ?? "Unknown checkerboard program link error";
gl.deleteProgram(program);
throw new Error(message);
}
return program;
}
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) throw new Error("Failed to create shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) ?? "Unknown shader compile error";
gl.deleteShader(shader);
throw new Error(message);
}
return shader;
} }

View File

@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
import type { EditorState } from "@editor/state"; import type { EditorState } from "@editor/state";
import { renderArtboard } from "./artboard"; import { renderArtboard } from "./artboard";
import { createBrushPreviewRenderer } from "./brush-preview"; import { createBrushPreviewRenderer } from "./brush-preview";
import { createCheckerboardRenderer } from "./checkerboard";
import { createImageTextureRenderer } from "./image-textures"; import { createImageTextureRenderer } from "./image-textures";
import { renderLayers } from "./layers"; import { renderLayers } from "./layers";
import { renderSelectionOverlay } from "./selection"; import { renderSelectionOverlay } from "./selection";
@@ -31,6 +32,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
} }
const rendererContext: WebGlRendererContext = { gl: context, canvas }; const rendererContext: WebGlRendererContext = { gl: context, canvas };
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext); const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
let lastFrame: RenderFrame | undefined; let lastFrame: RenderFrame | undefined;
let rerenderQueued = false; let rerenderQueued = false;
@@ -58,7 +60,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
context.enable(context.SCISSOR_TEST); context.enable(context.SCISSOR_TEST);
for (const artboard of frame.document.artboards) { for (const artboard of frame.document.artboards) {
if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport); if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport, checkerboardRenderer);
} }
imageTextureRenderer.syncAssets(frame.document.assets); imageTextureRenderer.syncAssets(frame.document.assets);
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer); renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
@@ -72,6 +74,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
context.disable(context.SCISSOR_TEST); context.disable(context.SCISSOR_TEST);
}, },
dispose() { dispose() {
checkerboardRenderer.dispose();
imageTextureRenderer.dispose(); imageTextureRenderer.dispose();
brushPreviewRenderer?.dispose(); brushPreviewRenderer?.dispose();
}, },