diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts new file mode 100644 index 0000000..c44e2cd --- /dev/null +++ b/renderer/image-textures.ts @@ -0,0 +1,173 @@ +import type { Asset } from "@core/asset"; +import type { ScreenRect, WebGlRendererContext } from "./types"; + +export type ImageTextureRenderer = { + render(asset: Asset, rect: ScreenRect): boolean; + dispose(): void; +}; + +type TextureEntry = + | { status: "loading"; image: HTMLImageElement } + | { status: "ready"; texture: WebGLTexture } + | { status: "error" }; + +export function createImageTextureRenderer(context: WebGlRendererContext, invalidate: () => void): ImageTextureRenderer { + const { gl } = context; + const program = createProgram(gl); + const positionLocation = gl.getAttribLocation(program, "a_position"); + const texCoordLocation = gl.getAttribLocation(program, "a_texCoord"); + const samplerLocation = gl.getUniformLocation(program, "u_image"); + const positionBuffer = gl.createBuffer(); + const texCoordBuffer = gl.createBuffer(); + const textures = new Map(); + + if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer"); + + gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW); + + return { + render(asset, rect) { + const entry = getTextureEntry(context, textures, asset, invalidate); + if (entry.status !== "ready") return false; + + gl.disable(gl.SCISSOR_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(program); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, entry.texture); + gl.uniform1i(samplerLocation, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(positionLocation); + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); + gl.enableVertexAttribArray(texCoordLocation); + gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.disable(gl.BLEND); + gl.enable(gl.SCISSOR_TEST); + return true; + }, + dispose() { + for (const entry of textures.values()) { + if (entry.status === "ready") gl.deleteTexture(entry.texture); + } + gl.deleteBuffer(positionBuffer); + gl.deleteBuffer(texCoordBuffer); + gl.deleteProgram(program); + }, + }; +} + +function getTextureEntry( + context: WebGlRendererContext, + textures: Map, + asset: Asset, + invalidate: () => void, +): TextureEntry { + const cached = textures.get(asset.id); + if (cached) return cached; + + const image = new Image(); + textures.set(asset.id, { status: "loading", image }); + image.onload = () => { + const texture = createTexture(context.gl, image); + textures.set(asset.id, { status: "ready", texture }); + invalidate(); + }; + image.onerror = () => { + textures.set(asset.id, { status: "error" }); + invalidate(); + }; + image.src = asset.source; + + return { status: "loading", image }; +} + +function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) { + const texture = gl.createTexture(); + if (!texture) throw new Error("Failed to create image texture"); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + + return texture; +} + +function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) { + const x1 = (rect.x / canvas.width) * 2 - 1; + const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1; + const y1 = 1 - (rect.y / canvas.height) * 2; + const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2; + + 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; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; + }`, + ); + const fragmentShader = compileShader( + gl, + gl.FRAGMENT_SHADER, + `#version 300 es + precision mediump float; + uniform sampler2D u_image; + in vec2 v_texCoord; + out vec4 outColor; + void main() { + outColor = texture(u_image, v_texCoord); + }`, + ); + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create image 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 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/layers.ts b/renderer/layers.ts index a2f44ac..6d6e05c 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -3,23 +3,24 @@ import type { Layer } from "@core/layer"; import type { ViewportState } from "@editor/state"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { clearScreenRect } from "./clear-rect"; +import type { ImageTextureRenderer } from "./image-textures"; import { documentRectToScreenRect } from "./screen-rect"; import type { RgbaColor, WebGlRendererContext } from "./types"; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; -export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState) { +export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) { for (const artboard of document.artboards) { - for (const layer of artboard.layers) renderLayer(context, document, viewport, layer); + for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer); } } -function renderLayer(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, layer: Layer) { +function renderLayer(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, layer: Layer, imageTextureRenderer: ImageTextureRenderer) { if (!layer.visible) return; if (layer.type === "group") { - for (const child of layer.children) renderLayer(context, document, viewport, child); + for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer); return; } @@ -27,6 +28,9 @@ function renderLayer(context: WebGlRendererContext, document: ImageDocument, vie if (!bounds) return; const rect = documentRectToScreenRect(context.canvas, bounds, viewport); + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + if (asset && imageTextureRenderer.render(asset, rect)) return; + clearScreenRect(context, rect, imageLayerColor); clearScreenRect(context, { x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, imageLayerInsetColor); } diff --git a/renderer/renderer.ts b/renderer/renderer.ts index 65f9d75..c47033b 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -1,6 +1,7 @@ import type { ImageDocument } from "@core/document"; import type { EditorState } from "@editor/state"; import { renderArtboard } from "./artboard"; +import { createImageTextureRenderer } from "./image-textures"; import { renderLayers } from "./layers"; import { renderSelectionOverlay } from "./selection"; import { renderTransformControls } from "./transform-controls"; @@ -29,9 +30,20 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke } const rendererContext: WebGlRendererContext = { gl: context, canvas }; + let lastFrame: RenderFrame | undefined; + let rerenderQueued = false; + const imageTextureRenderer = createImageTextureRenderer(rendererContext, () => { + if (!lastFrame || rerenderQueued) return; + rerenderQueued = true; + requestAnimationFrame(() => { + rerenderQueued = false; + if (lastFrame) renderer.render(lastFrame); + }); + }); - return { + const renderer: ImageRenderer = { render(frame) { + lastFrame = frame; const { w, h } = frame.editor.viewport.size; if (canvas.width !== w) canvas.width = w; @@ -46,13 +58,17 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke for (const artboard of frame.document.artboards) { renderArtboard(rendererContext, artboard, frame.editor.viewport); } - renderLayers(rendererContext, frame.document, frame.editor.viewport); + renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer); renderSelectionOverlay(rendererContext, frame.document, frame.editor); renderTransformControls(rendererContext, frame.document, frame.editor); context.disable(context.SCISSOR_TEST); }, - dispose() {}, + dispose() { + imageTextureRenderer.dispose(); + }, }; + + return renderer; }