diff --git a/renderer/artboard.ts b/renderer/artboard.ts index c7e3229..d6a8116 100644 --- a/renderer/artboard.ts +++ b/renderer/artboard.ts @@ -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))); } } diff --git a/renderer/checkerboard.test.ts b/renderer/checkerboard.test.ts new file mode 100644 index 0000000..100509e --- /dev/null +++ b/renderer/checkerboard.test.ts @@ -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(); + 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) => { + 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 }; +} diff --git a/renderer/checkerboard.ts b/renderer/checkerboard.ts index 11677b1..ff46177 100644 --- a/renderer/checkerboard.ts +++ b/renderer/checkerboard.ts @@ -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; } diff --git a/renderer/renderer.ts b/renderer/renderer.ts index 5100eba..1d0d9d4 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -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(); },