import type { Asset } from "@core/asset"; import type { ScreenRect, WebGlRendererContext } from "./types"; export type ImageTextureRenderer = { syncAssets(assets: readonly Asset[]): void; render(asset: Asset, rect: ScreenRect, clipRect?: 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(); let disposed = false; 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 { syncAssets(assets) { const activeKeys = new Set(assets.map(textureKey)); for (const [key, entry] of textures) { if (!activeKeys.has(key)) { disposeEntry(gl, entry); textures.delete(key); } } }, render(asset, rect, clipRect) { const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); if (entry.status !== "ready") return false; gl.enable(gl.SCISSOR_TEST); gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h); 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); return true; }, dispose() { disposed = true; for (const entry of textures.values()) disposeEntry(gl, entry); gl.deleteBuffer(positionBuffer); gl.deleteBuffer(texCoordBuffer); gl.deleteProgram(program); }, }; } function getTextureEntry( context: WebGlRendererContext, textures: Map, asset: Asset, invalidate: () => void, isDisposed: () => boolean, ): TextureEntry { const key = textureKey(asset); const cached = textures.get(key); if (cached) return cached; const image = new Image(); textures.set(key, { status: "loading", image }); image.onload = () => { if (isDisposed()) return; const texture = createTexture(context.gl, image); disposeEntry(context.gl, textures.get(key)); textures.set(key, { status: "ready", texture }); invalidate(); }; image.onerror = () => { if (isDisposed()) return; textures.set(key, { status: "error" }); invalidate(); }; image.src = asset.source; return { status: "loading", image }; } function textureKey(asset: Asset) { return `${asset.id}:${asset.source}`; } function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry | undefined) { if (!entry) return; if (entry.status === "ready") { gl.deleteTexture(entry.texture); return; } if (entry.status === "loading") { entry.image.onload = null; entry.image.onerror = null; } } function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined { const x1 = Math.max(a.x, b.x); const y1 = Math.max(a.y, b.y); const x2 = Math.min(a.x + a.w, b.x + b.w); const y2 = Math.min(a.y + a.h, b.y + b.h); if (x2 <= x1 || y2 <= y1) return undefined; return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; } 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; }