From 8ce29f0545eb13d7be781fe01574f9ed7ada8be4 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Tue, 26 May 2026 15:55:37 -0400 Subject: [PATCH] fix(svg): DPR-aware rasterization, source-region crop, zero-copy upload - Rasterize SVGs at stage.pixelRatio so they stay sharp on HiDPI/4K displays instead of being upscaled from a logical-pixel raster. - Use the 9-arg drawImage form for sx/sy/sw/sh, matching the source-region crop semantics documented on ImageTextureProps. The previous code drew the SVG at the destination size then cropped via getImageData, which produced a destination crop (top-left slice) rather than sampling the intended source region. - Return an ImageBitmap when createImageBitmap is available so the GL uploader (which already accepts ImageBitmap/HTMLImageElement) skips the forced getImageData CPU readback and the w*h*4 byte allocation. Falls back to ImageData on Chrome <50. - Set img.crossOrigin = 'anonymous' for non-base64 SVG sources so cross- origin SVGs don't taint the canvas and trigger SecurityError on read. Co-Authored-By: Claude Opus 4.7 --- src/core/CoreTextureManager.ts | 4 ++ src/core/lib/textureSvg.ts | 88 +++++++++++++++++++++---------- src/core/textures/ImageTexture.ts | 15 +----- 3 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 96ecd2d..ef70e88 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -245,6 +245,10 @@ export class CoreTextureManager extends EventEmitter { public platform: Platform; + get pixelRatio(): number { + return this.stage.pixelRatio; + } + imageWorkerManager: ImageWorkerManager | null = null; hasCreateImageBitmap = false; imageBitmapSupported = { diff --git a/src/core/lib/textureSvg.ts b/src/core/lib/textureSvg.ts index c72a675..b5faeb1 100644 --- a/src/core/lib/textureSvg.ts +++ b/src/core/lib/textureSvg.ts @@ -1,5 +1,6 @@ import { assertTruthy } from '../../utils.js'; import { type TextureData } from '../textures/Texture.js'; +import { isBase64Image } from './utils.js'; /** * Tests if the given location is a SVG @@ -14,11 +15,21 @@ export function isSvgImage(url: string): boolean { } /** - * Loads a SVG image - * @param url - * @returns + * Loads a SVG image and rasterizes it for use as a texture. + * + * @remarks + * Rasterizes at `pixelRatio` to keep the texture sharp on HiDPI / 4K displays. + * `width`/`height` are interpreted as the logical (CSS-pixel) target size; the + * backing canvas is allocated at `width * pixelRatio` × `height * pixelRatio`. + * + * When `sw`/`sh` are provided they describe a source-region crop on the SVG + * (not a crop of the destination canvas) and are sampled via the 9-arg form of + * drawImage. + * + * Returns an `ImageBitmap` when available (zero CPU readback, transferable), + * falling back to `ImageData` on older browsers without `createImageBitmap`. */ -export const loadSvg = ( +export const loadSvg = async ( url: string, width: number | null, height: number | null, @@ -26,34 +37,55 @@ export const loadSvg = ( sy: number | null, sw: number | null, sh: number | null, + pixelRatio: number, ): Promise => { - return new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - assertTruthy(ctx); - - ctx.imageSmoothingEnabled = true; - const img = new Image(); - img.onload = () => { - const x = sx ?? 0; - const y = sy ?? 0; - const w = width || img.width; - const h = height || img.height; - - canvas.width = w; - canvas.height = h; - ctx.drawImage(img, 0, 0, w, h); - - resolve({ - data: ctx.getImageData(x, y, sw ?? w, sh ?? h), - premultiplyAlpha: false, - }); - }; + const img = new Image(); + if (isBase64Image(url) === false) { + img.crossOrigin = 'anonymous'; + } + await new Promise((resolve, reject) => { + img.onload = () => resolve(); img.onerror = (err) => { - reject(err); + reject( + err instanceof Error ? err : new Error(`SVG loading failed: ${url}`), + ); }; - img.src = url; }); + + const targetW = width || img.naturalWidth || img.width; + const targetH = height || img.naturalHeight || img.height; + const ratio = pixelRatio > 0 ? pixelRatio : 1; + const physW = Math.max(1, Math.ceil(targetW * ratio)); + const physH = Math.max(1, Math.ceil(targetH * ratio)); + + const canvas = document.createElement('canvas'); + canvas.width = physW; + canvas.height = physH; + const ctx = canvas.getContext('2d'); + assertTruthy(ctx); + + if (sw !== null && sh !== null) { + ctx.drawImage(img, sx ?? 0, sy ?? 0, sw, sh, 0, 0, physW, physH); + } else { + ctx.drawImage(img, 0, 0, physW, physH); + } + + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(canvas); + return { + data: bitmap, + premultiplyAlpha: false, + }; + } catch { + // fall through to ImageData + } + } + + return { + data: ctx.getImageData(0, 0, physW, physH), + premultiplyAlpha: false, + }; }; diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index 00cb538..44904ab 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -314,19 +314,7 @@ export class ImageTexture extends Texture { return this.loadImage(absoluteSrc); } - if (type === 'svg') { - return loadSvg( - absoluteSrc, - this.props.w, - this.props.h, - this.props.sx, - this.props.sy, - this.props.sw, - this.props.sh, - ); - } - - if (isSvgImage(src) === true) { + if (type === 'svg' || isSvgImage(src) === true) { return loadSvg( absoluteSrc, this.props.w, @@ -335,6 +323,7 @@ export class ImageTexture extends Texture { this.props.sy, this.props.sw, this.props.sh, + this.txManager.pixelRatio, ); }