/** * Pool of offscreen canvases for block-face compositing. * Reuses a fixed set of canvases instead of creating new ones per render. */ const POOL_SIZE = 8 const OUTPUT_SIZE = 64 const pool: HTMLCanvasElement[] = [] const inUse = new Set() function getCanvas(): HTMLCanvasElement { const free = pool.find((c) => !inUse.has(c)) if (free) { inUse.add(free) return free } if (pool.length < POOL_SIZE) { const c = document.createElement('canvas') c.width = OUTPUT_SIZE c.height = OUTPUT_SIZE pool.push(c) inUse.add(c) return c } const c = pool[pool.length - 1] inUse.add(c) return c } function releaseCanvas(c: HTMLCanvasElement): void { inUse.delete(c) } /** Slice rect [x, y, width, height] */ type Slice = [number, number, number, number] /** * Composite top/left/right block faces into an isometric-style icon. * Uses a pool of canvases; not recreated each time. */ export function renderBlockIcon( source: HTMLImageElement | string, top: Slice, left: Slice, right: Slice, ): Promise { return new Promise((resolve, reject) => { const isUrl = typeof source === 'string' const img = isUrl ? new Image() : (source as HTMLImageElement) const draw = (image: HTMLImageElement) => { let canvas: HTMLCanvasElement | null = null try { canvas = getCanvas() const ctx = canvas.getContext('2d') if (!ctx) throw new Error('No 2d context') ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE) // Parameters from the original minecraft-inventory-gui renderer const A = 0.9 // horizontal compression (makes blocks narrower/taller) const K = 0.5 // skew factor const s = OUTPUT_SIZE / 2 - 2 const cubeWidth = 2 * A * s const cubeHeight = 2 * K * s + s const ox = Math.round((OUTPUT_SIZE - cubeWidth) / 2) const topY = Math.round((OUTPUT_SIZE - cubeHeight) / 2) const ex = ox const ey = topY + K * s const [tx, ty, tw, th] = top const [lx, ly, lw, lh] = left const [rx, ry, rw, rh] = right ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' // Top face ctx.save() ctx.setTransform(A, -K, A, K, ex, ey) ctx.drawImage(image, tx, ty, tw, th, 0, 0, s, s) ctx.restore() // Left face (lightly darkened) ctx.save() ctx.setTransform(A, K, 0, 1, ex, ey) ctx.drawImage(image, lx, ly, lw, lh, 0, 0, s, s) ctx.fillStyle = 'rgba(0, 0, 0, 0.35)' ctx.fillRect(0, 0, s, s) ctx.restore() // Right face (darkened — light comes from upper-left) ctx.save() ctx.setTransform(A, -K, 0, 1, A * s + ex, K * s + ey) ctx.drawImage(image, rx, ry, rw, rh, 0, 0, s, s) ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' ctx.fillRect(0, 0, s, s) ctx.restore() const dataUrl = canvas.toDataURL('image/png') resolve(dataUrl) } catch (e) { reject(e) } finally { if (canvas) releaseCanvas(canvas) } } if (isUrl) { img.onload = () => draw(img) img.onerror = () => reject(new Error('Failed to load block texture')) img.crossOrigin = 'anonymous' img.src = source } else if ( (img).complete && (img).naturalWidth > 0 ) { draw(img) } else { img.onload = () => draw(img) img.onerror = () => reject(new Error('Block texture image failed')) } }) }