utils_TextureResourceManager.ts
/*
* Copyright 2026 Michael Gevlich
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Handler, ImageSource, WebGLTextureExt } from "../webgl/Handler";
import { fnv1a32, normalizeUri } from "./shared";
import { getTextureResourceMeta } from "./textureResourceMeta";
interface TextureResourceEntry {
key: string;
texture: WebGLTextureExt;
refCount: number;
}
export interface RendererTextureRequest {
image: ImageSource;
resourceKey?: string;
sourceUri?: string;
mimeType?: string;
byteLength?: number;
internalFormat?: number | null;
texParami?: number | null;
}
/**
* Converts a nullable key component into a stable string field.
*
* Used by texture resource keys to make absent/default GPU parameters explicit.
*
* Examples:
* - toKeyField("fmt", undefined) => "fmt:default"
* - toKeyField("wrap", null) => "wrap:default"
* - toKeyField("mime", "image/png") => "mime:image/png"
*
* The "default" value means that the texture will be created using the
* default behavior of Handler.createTextureDefault(...) for this parameter.
*/
function toKeyField(name: string, value: string | number | null | undefined): string {
if (value === undefined || value === null || value === "") {
return `${name}:default`;
}
return `${name}:${value}`;
}
/**
* Manages shared WebGL textures with reference counting.
* Reuses textures for the same resource key and deletes them when no owners remain.
*/
export class TextureResourceManager {
protected _handler: Handler;
protected _entriesByKey: Map<string, TextureResourceEntry> = new Map();
protected _keysByTexture: WeakMap<WebGLTextureExt, string> = new WeakMap();
protected _objectKeys: WeakMap<ImageSource, number> = new WeakMap();
protected _objectKeyCounter: number = 0;
/**
* @param handler WebGL handler used to create and delete textures.
*/
constructor(handler: Handler) {
this._handler = handler;
}
/**
* Returns an existing texture for the same resource or creates a new one.
* Increases reference count for reused textures.
* @param params Texture source and GPU creation parameters.
* @returns Managed WebGL texture or null if creation failed.
*/
public acquireTexture(params: RendererTextureRequest): WebGLTextureExt | null {
const key = this._buildResourceKey(params);
const existing = this._entriesByKey.get(key);
if (existing) {
// Reuse already-uploaded GPU texture for the same resource key.
existing.refCount++;
return existing.texture;
}
const texture = this._handler.createTextureDefault(params.image, params.internalFormat, params.texParami);
if (!texture) {
return null;
}
this._entriesByKey.set(key, {
key,
texture,
refCount: 1
});
this._keysByTexture.set(texture, key);
return texture;
}
/**
* Releases one texture reference.
* Deletes GPU texture only when the reference count reaches zero.
* @param texture Managed texture previously returned by acquireTexture.
*/
public releaseTexture(texture: WebGLTextureExt | null | undefined): void {
if (!texture) {
return;
}
const key = this._keysByTexture.get(texture);
// Do not touch unmanaged textures.
if (!key) {
return;
}
const entry = this._entriesByKey.get(key);
if (!entry) {
this._keysByTexture.delete(texture);
return;
}
entry.refCount--;
if (entry.refCount <= 0) {
// Physical GPU deletion happens only when the last owner releases the texture.
this._entriesByKey.delete(key);
this._keysByTexture.delete(texture);
this._handler.deleteTexture(entry.texture);
}
}
/**
* Deletes all managed GPU textures and clears manager state.
*/
public clear(): void {
for (const entry of this._entriesByKey.values()) {
this._handler.deleteTexture(entry.texture);
}
this._entriesByKey.clear();
this._keysByTexture = new WeakMap();
}
/**
* Returns number of unique managed texture entries.
* @returns Number of entries in the manager.
*/
public getEntryCount(): number {
return this._entriesByKey.size;
}
/**
* Returns debug statistics for managed textures.
* @returns Object with number of entries and total reference count.
*/
public getStats(): { entries: number; refs: number } {
let refs = 0;
for (const entry of this._entriesByKey.values()) {
refs += entry.refCount;
}
return {
entries: this._entriesByKey.size,
refs
};
}
/**
* Builds a stable texture resource key.
*
* The key is composed of two logical parts:
*
* 1. Source image identity
* Describes where the original image comes from or how it can be uniquely identified.
*
* Supported prefixes:
*
* - res:
* A precomputed resource key provided by the GLTF parser.
* This is the preferred path for GLTF/GLB textures.
*
* Example:
* res:gltf-image|bytes:9a4c1f20|mime:image/png|len:24576
*
* - uri:
* A normalized external image URI.
* Used when the texture comes from an external image file.
* The URI should be resolved relative to the GLTF file path.
*
* Example:
* uri:https://example.com/models/tree/textures/baseColor.png
*
* - fp:
* A content fingerprint calculated from image pixels.
* This is an expensive fallback path because it may require canvas readback.
* It can also fail for cross-origin images.
*
* Example:
* fp:7f21ab09
*
* - obj:
* A last-resort object identity key.
* Used only when no resourceKey, URI, or content fingerprint is available.
* This avoids accidental sharing between unrelated images.
*
* Example:
* obj:3
*
* 2. GPU texture creation parameters.
* Describes how the source image is uploaded to WebGL.
* The same source image may require different GPU textures if creation parameters differ.
*
* Supported fields:
*
* - fmt:
* Internal texture format passed to Handler.createTextureDefault(...).
* If absent, "fmt:default" means the default internal format is used.
*
* Example:
* fmt:default
* fmt:6408
*
* - wrap:
* Texture wrapping/filtering parameter passed to Handler.createTextureDefault(...).
* If absent, "wrap:default" means the default texture parameter is used.
*
* Example:
* wrap:default
* wrap:10497
*
* - mime:
* MIME type of the original image, if available.
* Used as an additional discriminator and for debugging.
*
* Example:
* mime:image/png
*
* - len:
* Original image byte length, if available.
* Used as an additional cheap discriminator, but not as the primary identity.
*
* Example:
* len:24576
*
* Important:
*
* - GLTF texture/image name is intentionally not used as a primary identity key.
* Names may be missing, duplicated, or different for the same binary image.
*
* - The preferred identity source is resourceKey from the GLTF parser.
*
* - The fallback order is:
* resourceKey -> normalized URI -> content fingerprint -> object identity.
*
* - The final key must distinguish not only the source image, but also the GPU upload
* parameters that may affect the resulting WebGLTexture.
*/
protected _buildResourceKey(params: RendererTextureRequest): string {
const imageMeta = getTextureResourceMeta(params.image);
const resourceKey = params.resourceKey ?? imageMeta?.resourceKey;
const sourceUri = params.sourceUri ?? imageMeta?.sourceUri;
const mimeType = params.mimeType ?? imageMeta?.mimeType;
const byteLength = params.byteLength ?? imageMeta?.byteLength;
const gpuParts: string[] = [toKeyField("fmt", params.internalFormat), toKeyField("wrap", params.texParami)];
if (resourceKey) {
return [`res:${resourceKey}`, ...gpuParts].join("|");
}
const metaParts: string[] = [toKeyField("mime", mimeType), toKeyField("len", byteLength)];
const uri = sourceUri ? normalizeUri(sourceUri) : this._getExternalImageUri(params.image);
if (uri) {
return [`uri:${uri}`, ...metaParts, ...gpuParts].join("|");
}
// Content fingerprint is an expensive fallback path:
// it may trigger canvas readback and can fail for cross-origin images.
// GLTF/GLB should normally provide resourceKey/URI earlier in the pipeline.
const fingerprint = this._fingerprintImage(params.image);
if (fingerprint) {
const resolvedByteLength = byteLength ?? fingerprint.byteLength;
return [
`fp:${fingerprint.hash}`,
toKeyField("mime", mimeType),
toKeyField("len", resolvedByteLength),
...gpuParts
].join("|");
}
// Last-resort fallback: unique key per source object identity.
// This avoids accidental sharing when URI and content fingerprint are unavailable.
return [`obj:${this._getObjectKey(params.image)}`, ...metaParts, ...gpuParts].join("|");
}
protected _getObjectKey(image: ImageSource): number {
const existing = this._objectKeys.get(image);
if (existing !== undefined) {
return existing;
}
const key = ++this._objectKeyCounter;
this._objectKeys.set(image, key);
return key;
}
protected _getExternalImageUri(image: ImageSource): string | null {
if (!(image instanceof HTMLImageElement)) {
return null;
}
const src = image.src?.trim();
if (!src || src.startsWith("blob:")) {
return null;
}
return normalizeUri(src);
}
protected _fingerprintImage(image: ImageSource): { hash: string; byteLength: number } | null {
if (image instanceof ImageData) {
return {
hash: fnv1a32(image.data),
byteLength: image.data.byteLength
};
}
const size = this._getImageSize(image);
if (!size) {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
try {
ctx.drawImage(image as CanvasImageSource, 0, 0, size.width, size.height);
const imageData = ctx.getImageData(0, 0, size.width, size.height);
return {
hash: fnv1a32(imageData.data),
byteLength: imageData.data.byteLength
};
} catch {
return null;
}
}
protected _getImageSize(image: ImageSource): { width: number; height: number } | null {
if (image instanceof ImageData) {
return { width: image.width, height: image.height };
}
const htmlImage = image as HTMLImageElement;
const width = htmlImage.naturalWidth || (image as any).width;
const height = htmlImage.naturalHeight || (image as any).height;
if (!width || !height) {
return null;
}
return { width, height };
}
}