UNPKG

@lightningjs/renderer

Version:
515 lines (449 loc) 13.7 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 type { CoreTextureManager } from '../CoreTextureManager.js'; import type { SubTextureProps } from './SubTexture.js'; import type { Dimensions } from '../../common/CommonTypes.js'; import { EventEmitter } from '../../common/EventEmitter.js'; import type { CoreContextTexture } from '../renderers/CoreContextTexture.js'; import type { TextureError } from '../TextureError.js'; /** * Event handler for when a Texture is freed */ export type TextureFreedEventHandler = (target: any) => void; /** * Event handler for when a Texture is loading */ export type TextureLoadingEventHandler = (target: any) => void; /** * Event handler for when a Texture is loaded */ export type TextureLoadedEventHandler = ( target: any, dimensions: Readonly<Dimensions>, ) => void; /** * Represents compressed texture data. */ export interface CompressedData { /** * GLenum spcifying compression format */ glInternalFormat: number; /** * All mipmap levels */ mipmaps: ArrayBuffer[]; /** * Supported container types ('pvr', 'ktx', 'astc'). */ type: 'pvr' | 'ktx' | 'astc'; /** * The width of the compressed texture in pixels. Defaults to 0. * * @default 0 */ width: number; /** * The height of the compressed texture in pixels. **/ height: number; /** * block info */ blockInfo: { width: number; height: number; bytes: number; }; } /** * Event handler for when a Texture fails to load */ export type TextureFailedEventHandler = (target: any, error: Error) => void; /** * TextureData that is used to populate a CoreContextTexture */ export interface TextureData { /** * The texture data */ data: | ImageBitmap | ImageData | SubTextureProps | CompressedData | HTMLImageElement | Uint8Array | null; /** * Premultiply alpha when uploading texture data to the GPU * * @defaultValue `false` */ premultiplyAlpha?: boolean | null; } export type TextureState = | 'initial' // Before anything is loaded | 'loading' // Loading texture data and uploading to GPU | 'loaded' // Fully loaded and usable | 'failed' // Failed to load | 'freed'; // Released and must be reloaded export enum TextureType { 'generic' = 0, 'color' = 1, 'image' = 2, 'noise' = 3, 'renderToTexture' = 4, 'subTexture' = 5, } /** * Represents a source of texture data for a CoreContextTexture. * * @remarks * Texture sources are used to populate a CoreContextTexture when that texture * is loaded. Texture data retrieved by the CoreContextTexture by the * `getTextureData` method. It's the responsibility of the concerete `Texture` * subclass to implement this method appropriately. */ export abstract class Texture extends EventEmitter { /** * The dimensions of the texture * * @remarks * Until the texture data is loaded for the first time the value will be * `null`. */ private _dimensions: Dimensions | null = null; private _error: TextureError | null = null; // aggregate state public state: TextureState = 'initial'; readonly renderableOwners: any[] = []; readonly renderable: boolean = false; public type: TextureType = TextureType.generic; public preventCleanup = false; public ctxTexture: CoreContextTexture | undefined; public textureData: TextureData | null = null; /** * Memory used by this texture in bytes * * @remarks * This is tracked by the TextureMemoryManager and updated when the texture * is loaded/freed. Set to 0 when texture is not loaded. */ public memUsed = 0; public retryCount = 0; public maxRetryCount: number; /** * Timestamp when texture was created (for startup grace period) */ private createdAt: number = Date.now(); /** * Flag to track if grace period has expired to avoid repeated Date.now() calls */ private gracePeriodExpired: boolean = false; /** * Grace period in milliseconds to prevent premature cleanup during app startup * This helps prevent race conditions when bounds calculation is delayed */ private static readonly STARTUP_GRACE_PERIOD = 2000; // 2 seconds constructor(protected txManager: CoreTextureManager) { super(); this.maxRetryCount = txManager.maxRetryCount; } get dimensions(): Dimensions | null { return this._dimensions; } get error(): TextureError | null { return this._error; } /** * Checks if the texture is within the startup grace period. * During this period, textures are protected from cleanup to prevent * race conditions during app initialization. */ isWithinStartupGracePeriod(): boolean { // If grace period already expired, return false immediately if (this.gracePeriodExpired) { return false; } // Check if grace period has expired now const hasExpired = Date.now() - this.createdAt >= Texture.STARTUP_GRACE_PERIOD; if (hasExpired) { // Cache the result to avoid future Date.now() calls this.gracePeriodExpired = true; return false; } return true; } /** * Checks if the texture can be safely cleaned up. * Considers the renderable state, startup grace period, and renderable owners. */ canBeCleanedUp(): boolean { // Never cleanup if explicitly prevented if (this.preventCleanup) { return false; } // Don't cleanup if still within startup grace period if (this.isWithinStartupGracePeriod()) { return false; } // Don't cleanup if not renderable if (this.renderable === true) { return false; } // Don't cleanup if there are still renderable owners if (this.renderableOwners.length > 0) { return false; } // Safe to cleanup return true; } /** * Add/remove an owner to/from the Texture based on its renderability. * * @remarks * Any object can own a texture, be it a CoreNode or even the state object * from a Text Renderer. * * When the reference to the texture that an owner object holds is replaced * or cleared it must call this with `renderable=false` to release the owner * association. * * @param owner * @param renderable */ setRenderableOwner(owner: string | number, renderable: boolean): void { const oldSize = this.renderableOwners.length; const hasOwnerIndex = this.renderableOwners.indexOf(owner); if (renderable === true) { if (hasOwnerIndex === -1) { // Add the owner to the set this.renderableOwners.push(owner); } const newSize = this.renderableOwners.length; if (oldSize !== newSize && newSize === 1) { (this.renderable as boolean) = true; this.onChangeIsRenderable?.(true); this.load(); } } else { if (hasOwnerIndex !== -1) { this.renderableOwners.splice(hasOwnerIndex, 1); } const newSize = this.renderableOwners.length; if (oldSize !== newSize && newSize === 0) { (this.renderable as boolean) = false; this.onChangeIsRenderable?.(false); // note, not doing a cleanup here, cleanup is managed by the Stage/TextureMemoryManager // when it deems appropriate based on memory pressure } } } load(): void { if (this.retryCount > this.maxRetryCount) { // We've exceeded the max retry count, do not attempt to load again return; } this.txManager.loadTexture(this); } /** * Event called when the Texture becomes renderable or unrenderable. * * @remarks * Used by subclasses like SubTexture propogate then renderability of the * Texture to other referenced Textures. * * @param isRenderable `true` if this Texture has renderable owners. */ onChangeIsRenderable?(isRenderable: boolean): void; /** * Load the core context texture for this Texture. * The ctxTexture is created by the renderer and lives on the GPU. * * @returns */ loadCtxTexture(): CoreContextTexture { if (this.ctxTexture === undefined) { this.ctxTexture = this.txManager.renderer.createCtxTexture(this); } return this.ctxTexture; } /** * Free the core context texture for this Texture. * * @remarks * The ctxTexture is created by the renderer and lives on the GPU. */ free(): void { this.ctxTexture?.free(); } /** * Release the texture data and core context texture for this Texture without changing state. * * @remarks * The ctxTexture is created by the renderer and lives on the GPU. */ release(): void { this.ctxTexture?.release(); this.ctxTexture = undefined; this.freeTextureData(); } /** * Destroy the texture. * * @remarks * This method is called when the texture is no longer needed and should be * cleaned up. */ destroy(): void { // Only free GPU resources if we're in a state where they exist if (this.state === 'loaded') { this.free(); } // Always free texture data regardless of state this.freeTextureData(); } /** * Free the source texture data for this Texture. * * @remarks * The texture data is the source data that is used to populate the CoreContextTexture. * e.g. ImageData that is downloaded from a URL. */ freeTextureData(): void { queueMicrotask(() => { this.textureData = null; }); } public setState( state: TextureState, errorOrDimensions?: Error | Dimensions, ): void { if (this.state === state) { return; } let payload: Error | Dimensions | null = null; if (state === 'loaded') { // Clear any previous error when successfully loading this._error = null; if ( errorOrDimensions !== undefined && 'width' in errorOrDimensions === true && 'height' in errorOrDimensions === true && errorOrDimensions.width !== undefined && errorOrDimensions.height !== undefined ) { this._dimensions = errorOrDimensions; } payload = this._dimensions; } else if (state === 'failed') { this._error = errorOrDimensions as Error; payload = this._error; // increment the retry count for the texture // this is used to compare against maxRetryCount, if set // to determine if we should try loading again this.retryCount += 1; queueMicrotask(() => { this.release(); }); } else if (state === 'loading') { // Clear error and reset dimensions when starting to load // This ensures stale dimensions from previous loads don't persist this._error = null; this._dimensions = null; } else { this._error = null; } // emit the new state this.state = state; this.emit(state, payload); } /** * Get the texture data for this texture. * * @remarks * This method is called by the CoreContextTexture when the texture is loaded. * The texture data is then used to populate the CoreContextTexture. * * @returns * The texture data for this texture. */ async getTextureData(): Promise<TextureData> { if (this.textureData === null) { this.textureData = await this.getTextureSource(); } return this.textureData; } /** * Get the texture source for this texture. * * @remarks * This method is called by the CoreContextTexture when the texture is loaded. * The texture source is then used to populate the CoreContextTexture. */ abstract getTextureSource(): Promise<TextureData>; /** * Make a cache key for this texture. * * @remarks * Each concrete `Texture` subclass must implement this method to provide an * appropriate cache key for the texture type including the texture's * properties that uniquely identify a copy of the texture. If the texture * type does not support caching, then this method should return `false`. * * @param props * @returns * A cache key for this texture or `false` if the texture type does not * support caching. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars static makeCacheKey(props: unknown): string | false { return false; } /** * Resolve the default values for the texture's properties. * * @remarks * Each concrete `Texture` subclass must implement this method to provide * default values for the texture's optional properties. * * @param props * @returns * The default values for the texture's properties. */ static resolveDefaults( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: unknown, ): Record<string, unknown> { return {}; } /** * Retry the texture by resetting retryCount and setting state to 'initial'. * * @remarks * This allows the texture to be loaded again. */ public retry(): void { this.release(); this.retryCount = 0; this.load(); } }