feat(renderer): render image textures

This commit is contained in:
syntaxbullet
2026-07-03 16:23:38 +02:00
parent 71e97388da
commit bc0fbea029
3 changed files with 200 additions and 7 deletions

173
renderer/image-textures.ts Normal file
View File

@@ -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<string, TextureEntry>();
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<string, TextureEntry>,
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;
}

View File

@@ -3,23 +3,24 @@ import type { Layer } from "@core/layer";
import type { ViewportState } from "@editor/state"; import type { ViewportState } from "@editor/state";
import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { resolveTransformTargetBounds } from "@editor/transform-targets";
import { clearScreenRect } from "./clear-rect"; import { clearScreenRect } from "./clear-rect";
import type { ImageTextureRenderer } from "./image-textures";
import { documentRectToScreenRect } from "./screen-rect"; import { documentRectToScreenRect } from "./screen-rect";
import type { RgbaColor, WebGlRendererContext } from "./types"; import type { RgbaColor, WebGlRendererContext } from "./types";
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1]; const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 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 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.visible) return;
if (layer.type === "group") { 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; return;
} }
@@ -27,6 +28,9 @@ function renderLayer(context: WebGlRendererContext, document: ImageDocument, vie
if (!bounds) return; if (!bounds) return;
const rect = documentRectToScreenRect(context.canvas, bounds, viewport); 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, 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); 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);
} }

View File

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document"; 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 { createImageTextureRenderer } from "./image-textures";
import { renderLayers } from "./layers"; import { renderLayers } from "./layers";
import { renderSelectionOverlay } from "./selection"; import { renderSelectionOverlay } from "./selection";
import { renderTransformControls } from "./transform-controls"; import { renderTransformControls } from "./transform-controls";
@@ -29,9 +30,20 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
} }
const rendererContext: WebGlRendererContext = { gl: context, canvas }; 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) { render(frame) {
lastFrame = frame;
const { w, h } = frame.editor.viewport.size; const { w, h } = frame.editor.viewport.size;
if (canvas.width !== w) canvas.width = w; 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) { for (const artboard of frame.document.artboards) {
renderArtboard(rendererContext, artboard, frame.editor.viewport); 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); renderSelectionOverlay(rendererContext, frame.document, frame.editor);
renderTransformControls(rendererContext, frame.document, frame.editor); renderTransformControls(rendererContext, frame.document, frame.editor);
context.disable(context.SCISSOR_TEST); context.disable(context.SCISSOR_TEST);
}, },
dispose() {}, dispose() {
imageTextureRenderer.dispose();
},
}; };
return renderer;
} }