perf(renderer): draw checkerboard with shader
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import type { Artboard } from "@core/artboard";
|
||||
import type { ViewportState } from "@editor/state";
|
||||
import { renderCheckerboard } from "./checkerboard";
|
||||
import type { CheckerboardRenderer } from "./checkerboard";
|
||||
import { documentRectToScreenRect } from "./screen-rect";
|
||||
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);
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
134
renderer/checkerboard.test.ts
Normal file
134
renderer/checkerboard.test.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,26 +1,148 @@
|
||||
import { clearScreenRect } from "./clear-rect";
|
||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
|
||||
const darkChecker = [0.82, 0.82, 0.86, 1] as const;
|
||||
const lightChecker = [0.94, 0.94, 0.97, 1] as const;
|
||||
const darkChecker: RgbaColor = [0.82, 0.82, 0.86, 1];
|
||||
const lightChecker: RgbaColor = [0.94, 0.94, 0.97, 1];
|
||||
|
||||
export function renderCheckerboard(context: WebGlRendererContext, rect: ScreenRect, squareSize: number) {
|
||||
const clampedSquareSize = Math.max(1, squareSize);
|
||||
const canvasWidth = context.canvas.width;
|
||||
const canvasHeight = context.canvas.height;
|
||||
export type CheckerboardRenderer = {
|
||||
render(rect: ScreenRect, squareSize: number): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
for (let py = rect.y; py < rect.y + rect.h; py += clampedSquareSize) {
|
||||
for (let px = rect.x; px < rect.x + rect.w; px += clampedSquareSize) {
|
||||
const x = Math.max(0, px);
|
||||
const y = Math.max(0, py);
|
||||
const w = Math.min(px + clampedSquareSize, rect.x + rect.w, canvasWidth) - x;
|
||||
const h = Math.min(py + clampedSquareSize, rect.y + rect.h, canvasHeight) - y;
|
||||
if (w <= 0 || h <= 0) continue;
|
||||
export function createCheckerboardRenderer(context: WebGlRendererContext): CheckerboardRenderer {
|
||||
const { gl } = context;
|
||||
const program = createProgram(gl);
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const canvasSizeLocation = gl.getUniformLocation(program, "u_canvasSize");
|
||||
const originLocation = gl.getUniformLocation(program, "u_origin");
|
||||
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 =
|
||||
(Math.floor((px - rect.x) / clampedSquareSize) + Math.floor((py - rect.y) / clampedSquareSize)) % 2 === 0;
|
||||
|
||||
clearScreenRect(context, { x, y, w, h }, checker ? darkChecker : lightChecker);
|
||||
if (
|
||||
positionLocation < 0 ||
|
||||
!canvasSizeLocation ||
|
||||
!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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { renderArtboard } from "./artboard";
|
||||
import { createBrushPreviewRenderer } from "./brush-preview";
|
||||
import { createCheckerboardRenderer } from "./checkerboard";
|
||||
import { createImageTextureRenderer } from "./image-textures";
|
||||
import { renderLayers } from "./layers";
|
||||
import { renderSelectionOverlay } from "./selection";
|
||||
@@ -31,6 +32,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
}
|
||||
|
||||
const rendererContext: WebGlRendererContext = { gl: context, canvas };
|
||||
const checkerboardRenderer = createCheckerboardRenderer(rendererContext);
|
||||
const brushPreviewRenderer = createOptionalBrushPreviewRenderer(rendererContext);
|
||||
let lastFrame: RenderFrame | undefined;
|
||||
let rerenderQueued = false;
|
||||
@@ -58,7 +60,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
context.enable(context.SCISSOR_TEST);
|
||||
|
||||
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);
|
||||
renderLayers(rendererContext, frame.document, frame.editor, imageTextureRenderer);
|
||||
@@ -72,6 +74,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
||||
context.disable(context.SCISSOR_TEST);
|
||||
},
|
||||
dispose() {
|
||||
checkerboardRenderer.dispose();
|
||||
imageTextureRenderer.dispose();
|
||||
brushPreviewRenderer?.dispose();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user