UNPKG

@lightningjs/renderer

Version:
571 lines (509 loc) 16.9 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2023 Comcast Cable Communications Management, LLC. * * 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 { ImageWorkerManager } from './lib/ImageWorker.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import { ColorTexture } from './textures/ColorTexture.js'; import { ImageTexture } from './textures/ImageTexture.js'; import { NoiseTexture } from './textures/NoiseTexture.js'; import { SubTexture } from './textures/SubTexture.js'; import { RenderTexture } from './textures/RenderTexture.js'; import { Texture, TextureType } from './textures/Texture.js'; import { EventEmitter } from '../common/EventEmitter.js'; import { getTimeStamp } from './platform.js'; import type { Stage } from './Stage.js'; import { validateCreateImageBitmap, type CreateImageBitmapSupport, } from './lib/validateImageBitmap.js'; import { TextureError, TextureErrorCode } from './TextureError.js'; /** * Augmentable map of texture class types * * @remarks * This interface can be augmented by other modules/apps to add additional * texture types. The ones included directly here are the ones that are * included in the core library. */ export interface TextureMap { ColorTexture: typeof ColorTexture; ImageTexture: typeof ImageTexture; NoiseTexture: typeof NoiseTexture; SubTexture: typeof SubTexture; RenderTexture: typeof RenderTexture; } export type ExtractProps<Type> = Type extends { z$__type__Props: infer Props } ? Props : never; /** * Contains information about the texture manager's internal state * for debugging purposes. */ export interface TextureManagerDebugInfo { keyCacheSize: number; } export interface TextureManagerSettings { numImageWorkers: number; createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full'; maxRetryCount: number; } export type ResizeModeOptions = | { /** * Specifies that the image should be resized to cover the specified dimensions. */ type: 'cover'; /** * The horizontal clipping position * To clip the left, set clipX to 0. To clip the right, set clipX to 1. * clipX 0.5 will clip a equal amount from left and right * * @defaultValue 0.5 */ clipX?: number; /** * The vertical clipping position * To clip the top, set clipY to 0. To clip the bottom, set clipY to 1. * clipY 0.5 will clip a equal amount from top and bottom * * @defaultValue 0.5 */ clipY?: number; } | { /** * Specifies that the image should be resized to fit within the specified dimensions. */ type: 'contain'; }; /** * Universal options for all texture types * * @remarks * Texture Options provide a way to specify options that are relevant to the * texture loading process (including caching) and specifically for how a * texture is rendered within a specific Node (or set of Nodes). * * They are not used in determining the cache key for a texture (except if * the `cacheKey` option is provided explicitly to oveerride the default * cache key for the texture instance) nor are they stored/referenced within * the texture instance itself. Instead, the options are stored/referenced * within individual Nodes. So a single texture instance can be used in * multiple Nodes each using a different set of options. */ export interface TextureOptions { /** * Preload the texture immediately even if it's not being rendered to the * screen. * * @remarks * This allows the texture to be used immediately without any delay when it * is first needed for rendering. Otherwise the loading process will start * when the texture is first rendered, which may cause a delay in that texture * being shown properly. * * @defaultValue `false` */ preload?: boolean; /** * Prevent clean up of the texture when it is no longer being used. * * @remarks * This is useful when you want to keep the texture in memory for later use. * Regardless of whether the texture is being used or not, it will not be * cleaned up. * * @defaultValue `false` */ preventCleanup?: boolean; /** * Number of times to retry loading a failed texture * * @remarks * When a texture fails to load, Lightning will retry up to this many times * before permanently giving up. Each retry will clear the texture ownership * and then re-establish it to trigger a new load attempt. * * Set to null to disable retries. Set to 0 to always try once and never retry. * This is typically only used on ImageTexture instances. * */ maxRetryCount?: number | null; /** * Flip the texture horizontally when rendering * * @defaultValue `false` */ flipX?: boolean; /** * Flip the texture vertically when rendering * * @defaultValue `false` */ flipY?: boolean; /** * You can use resizeMode to determine the clipping automatically from the width * and height of the source texture. This can be convenient if you are unsure about * the exact image sizes but want the image to cover a specific area. * * The resize modes cover and contain are supported */ resizeMode?: ResizeModeOptions; } export class CoreTextureManager extends EventEmitter { /** * Map of textures by cache key */ keyCache: Map<string, Texture> = new Map(); /** * Map of cache keys by texture */ inverseKeyCache: WeakMap<Texture, string> = new WeakMap(); /** * Map of texture constructors by their type name */ txConstructors: Partial<TextureMap> = {}; public maxRetryCount: number; private priorityQueue: Array<Texture> = []; private uploadTextureQueue: Array<Texture> = []; private initialized = false; private stage: Stage; private numImageWorkers: number; imageWorkerManager: ImageWorkerManager | null = null; hasCreateImageBitmap = !!self.createImageBitmap; imageBitmapSupported = { basic: false, options: false, full: false, }; hasWorker = !!self.Worker; /** * Renderer that this texture manager is associated with * * @remarks * This MUST be set before the texture manager is used. Otherwise errors * will occur when using the texture manager. */ renderer!: CoreRenderer; /** * The current frame time in milliseconds * * @remarks * This is used to populate the `lastRenderableChangeTime` property of * {@link Texture} instances when their renderable state changes. * * Set by stage via `updateFrameTime` method. */ frameTime = 0; constructor(stage: Stage, settings: TextureManagerSettings) { super(); const { numImageWorkers, createImageBitmapSupport, maxRetryCount } = settings; this.stage = stage; this.numImageWorkers = numImageWorkers; this.maxRetryCount = maxRetryCount; if (createImageBitmapSupport === 'auto') { validateCreateImageBitmap() .then((result) => { this.initialize(result); }) .catch(() => { console.warn( '[Lightning] createImageBitmap is not supported on this browser. ImageTexture will be slower.', ); // initialized without image worker manager and createImageBitmap this.initialized = true; this.emit('initialized'); }); } else { this.initialize({ basic: createImageBitmapSupport === 'basic', options: createImageBitmapSupport === 'options', full: createImageBitmapSupport === 'full', }); } this.registerTextureType('ImageTexture', ImageTexture); this.registerTextureType('ColorTexture', ColorTexture); this.registerTextureType('NoiseTexture', NoiseTexture); this.registerTextureType('SubTexture', SubTexture); this.registerTextureType('RenderTexture', RenderTexture); } registerTextureType<Type extends keyof TextureMap>( textureType: Type, textureClass: TextureMap[Type], ): void { this.txConstructors[textureType] = textureClass; } private initialize(support: CreateImageBitmapSupport) { this.hasCreateImageBitmap = support.basic || support.options || support.full; this.imageBitmapSupported = support; if (!this.hasCreateImageBitmap) { console.warn( '[Lightning] createImageBitmap is not supported on this browser. ImageTexture will be slower.', ); } if ( this.hasCreateImageBitmap && this.hasWorker && this.numImageWorkers > 0 ) { this.imageWorkerManager = new ImageWorkerManager( this.numImageWorkers, support, ); } else { console.warn( '[Lightning] Imageworker is 0 or not supported on this browser. Image loading will be slower.', ); } this.initialized = true; this.emit('initialized'); } /** * Enqueue a texture for uploading to the GPU. * * @param texture - The texture to upload */ enqueueUploadTexture(texture: Texture): void { if (this.uploadTextureQueue.includes(texture) === false) { this.uploadTextureQueue.push(texture); } } /** * Create a texture * * @param textureType - The type of texture to create * @param props - The properties to use for the texture */ createTexture<Type extends keyof TextureMap>( textureType: Type, props: ExtractProps<TextureMap[Type]>, ): InstanceType<TextureMap[Type]> { let texture: Texture | undefined; const TextureClass = this.txConstructors[textureType]; if (!TextureClass) { throw new TextureError( TextureErrorCode.TEXTURE_TYPE_NOT_REGISTERED, `Texture type "${textureType}" is not registered`, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument const cacheKey = TextureClass.makeCacheKey(props as any); if (cacheKey && this.keyCache.has(cacheKey)) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion texture = this.keyCache.get(cacheKey)!; } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any texture = new TextureClass(this, props as any); if (cacheKey) { this.initTextureToCache(texture, cacheKey); } } return texture as InstanceType<TextureMap[Type]>; } /** * Override loadTexture to use the batched approach. * * @param texture - The texture to load * @param immediate - Whether to prioritize the texture for immediate loading */ async loadTexture(texture: Texture, priority?: boolean): Promise<void> { if (texture.type === TextureType.subTexture) { // ignore subtextures - they get loaded through their parent return; } if (texture.state === 'loaded') { // if the texture is already loaded, just return return; } if (texture.state === 'loading') { return; } // if we're not initialized, just queue the texture into the priority queue if (this.initialized === false) { this.priorityQueue.push(texture); return; } texture.setState('loading'); // Get texture data - early return on failure const textureDataResult = await texture.getTextureData().catch((err) => { console.error(err); texture.setState('failed'); return null; }); // Early return if texture data fetch failed if (textureDataResult === null || texture.state === 'failed') { return; } // Handle non-image textures: upload immediately const shouldUploadImmediately = texture.type !== TextureType.image || priority === true; if (shouldUploadImmediately === true) { await this.uploadTexture(texture).catch((err) => { console.error(`Failed to upload texture:`, err); texture.setState('failed'); }); return; } // Queue image textures for throttled upload this.enqueueUploadTexture(texture); } /** * Upload a texture to the GPU * * @param texture Texture to upload * @returns Promise that resolves when the texture is fully loaded */ async uploadTexture(texture: Texture): Promise<void> { if ( this.stage.txMemManager.doNotExceedCriticalThreshold === true && this.stage.txMemManager.criticalCleanupRequested === true ) { // we're at a critical memory threshold, don't upload textures texture.setState( 'failed', new TextureError(TextureErrorCode.MEMORY_THRESHOLD_EXCEEDED), ); return; } if (texture.state === 'failed' || texture.state === 'freed') { // don't upload failed or freed textures return; } if (texture.state === 'loaded') { // already loaded return; } if (texture.textureData === null) { texture.setState( 'failed', new TextureError( TextureErrorCode.TEXTURE_DATA_NULL, 'Texture data is null, cannot upload texture', ), ); return; } const coreContext = texture.loadCtxTexture(); if (coreContext !== null && coreContext.state === 'loaded') { texture.setState('loaded'); return; } await coreContext.load(); } /** * Check if a texture is being processed */ isProcessingTexture(texture: Texture): boolean { return this.uploadTextureQueue.includes(texture) === true; } /** * Process a limited number of uploads. * * @param maxProcessingTime - The maximum processing time in milliseconds */ async processSome(maxProcessingTime: number): Promise<void> { if (this.initialized === false) { return; } const startTime = getTimeStamp(); // Process priority queue while ( this.priorityQueue.length > 0 && getTimeStamp() - startTime < maxProcessingTime ) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const texture = this.priorityQueue.pop()!; try { await texture.getTextureData(); await this.uploadTexture(texture); } catch (error) { console.error('Failed to process priority texture:', error); // Continue with next texture instead of stopping entire queue } } // Process uploads - await each upload to prevent GPU overload while ( this.uploadTextureQueue.length > 0 && getTimeStamp() - startTime < maxProcessingTime ) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const texture = this.uploadTextureQueue.shift()!; try { await this.uploadTexture(texture); } catch (error) { console.error('Failed to upload texture:', error); // Continue with next texture instead of stopping entire queue } } } public hasUpdates(): boolean { return this.uploadTextureQueue.length > 0; } /** * Initialize a texture to the cache * * @param texture Texture to cache * @param cacheKey Cache key for the texture */ initTextureToCache(texture: Texture, cacheKey: string) { const { keyCache, inverseKeyCache } = this; keyCache.set(cacheKey, texture); inverseKeyCache.set(texture, cacheKey); } /** * Get a texture from the cache * * @param cacheKey */ getTextureFromCache(cacheKey: string): Texture | undefined { return this.keyCache.get(cacheKey); } /** * Remove a texture from the cache * * @remarks * Called by Texture Cleanup when a texture is freed. * * @param texture */ removeTextureFromCache(texture: Texture) { const { inverseKeyCache, keyCache } = this; const cacheKey = inverseKeyCache.get(texture); if (cacheKey) { keyCache.delete(cacheKey); } } /** * Resolve a parent texture from the cache or fallback to the provided texture. * * @param texture - The provided texture to resolve. * @returns The cached or provided texture. */ resolveParentTexture(texture: ImageTexture): Texture { if (!texture?.props) { return texture; } const cacheKey = ImageTexture.makeCacheKey(texture.props); const cachedTexture = cacheKey ? this.getTextureFromCache(cacheKey) : undefined; return cachedTexture ?? texture; } }