UNPKG

@lightningjs/renderer

Version:
1,655 lines (1,494 loc) 87.2 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 { assertTruthy, getNewId, mergeColorAlphaPremultiplied, } from '../utils.js'; import type { TextureOptions } from './CoreTextureManager.js'; import type { WebGlRenderer } from './renderers/webgl/WebGlRenderer.js'; import type { WebGlCtxTexture } from './renderers/webgl/WebGlCtxTexture.js'; import { QUAD_VERTEX_STRIDE, type BufferCollection, } from './renderers/webgl/internal/BufferCollection.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import type { Stage } from './Stage.js'; import { type Texture, type TextureCoords, type TextureFailedEventHandler, type TextureFreedEventHandler, type TextureLoadedEventHandler, } from './textures/Texture.js'; import type { Dimensions, NodeTextureFailedPayload, NodeTextureFreedPayload, NodeTextureLoadedPayload, NodeRenderablePayload, } from '../common/CommonTypes.js'; import { EventEmitter } from '../common/EventEmitter.js'; import { copyRect, intersectRect, type Bound, type RectWithValid, createBound, boundInsideBound, boundLargeThanBound, createPreloadBounds, } from './lib/utils.js'; import { Matrix3d } from './lib/Matrix3d.js'; import { RenderCoords } from './lib/RenderCoords.js'; import type { AnimationSettings } from './animations/CoreAnimation.js'; import type { IAnimationController } from '../common/IAnimationController.js'; import type { CoreShaderNode } from './renderers/CoreShaderNode.js'; import { AutosizeMode, Autosizer } from './Autosizer.js'; import { bucketSortByZIndex, removeChild } from './lib/collectionUtils.js'; export enum CoreNodeRenderState { Init = 0, OutOfBounds = 2, InBounds = 4, InViewport = 8, } const NO_CLIPPING_RECT: RectWithValid = Object.freeze({ x: 0, y: 0, w: 0, h: 0, valid: false, clipRadius: 0, }); const CoreNodeRenderStateMap: Map<CoreNodeRenderState, string> = new Map(); CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init'); CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds'); CoreNodeRenderStateMap.set(CoreNodeRenderState.InBounds, 'inBounds'); CoreNodeRenderStateMap.set(CoreNodeRenderState.InViewport, 'inViewport'); export enum UpdateType { /** * Child updates */ Children = 1, /** * localTransform * * @remarks * CoreNode Properties Updated: * - `localTransform` */ Local = 2, /** * globalTransform * * * @remarks * CoreNode Properties Updated: * - `globalTransform` * - `renderBounds` * - `renderCoords` */ Global = 4, /** * Clipping rect update * * @remarks * CoreNode Properties Updated: * - `clippingRect` */ Clipping = 8, /** * Sort Z-Index Children update * * @remarks * CoreNode Properties Updated: * - `children` (sorts children by their `calcZIndex`) */ SortZIndexChildren = 16, /** * Premultiplied Colors update * * @remarks * CoreNode Properties Updated: * - `premultipliedColorTl` * - `premultipliedColorTr` * - `premultipliedColorBl` * - `premultipliedColorBr` */ PremultipliedColors = 32, /** * World Alpha update * * @remarks * CoreNode Properties Updated: * - `worldAlpha` = `parent.worldAlpha` * `alpha` */ WorldAlpha = 64, /** * Render State update * * @remarks * CoreNode Properties Updated: * - `renderState` */ RenderState = 128, /** * Is Renderable update * * @remarks * CoreNode Properties Updated: * - `isRenderable` */ IsRenderable = 256, /** * Render Texture update */ RenderTexture = 512, /** * Track if parent has render texture */ ParentRenderTexture = 1024, /** * Render Bounds update */ RenderBounds = 2048, /** * RecalcUniforms */ RecalcUniforms = 4096, /** * Autosize update */ Autosize = 8192, /** * None */ None = 0, /** * All */ All = 16383, } /** * Bitmask of UpdateType flags that represent a visually significant change * within a node. Used to gate notifyParentRTTOfUpdate() so that RTT surfaces * are only marked dirty when something actually visible changed, rather than * on every update() cycle that merely propagates child traversal. * * Excluded flags (non-visual cascade/bookkeeping): * Children, RenderBounds, RenderState, ParentRenderTexture, Autosize */ const RTT_NOTIFY_MASK = UpdateType.Local | UpdateType.Global | UpdateType.Clipping | UpdateType.SortZIndexChildren | UpdateType.PremultipliedColors | UpdateType.WorldAlpha | UpdateType.IsRenderable | UpdateType.RenderTexture | UpdateType.RecalcUniforms; /** * A custom data map which can be stored on an CoreNode * * @remarks * This is a map of key-value pairs that can be stored on an INode. It is used * to store custom data that can be used by the application. * The data stored can only be of type string, number or boolean. */ export type CustomDataMap = { [key: string]: string | number | boolean | undefined; }; /** * Writable properties of a Node. */ export interface CoreNodeProps { /** * The x coordinate of the Node's Mount Point. * * @remarks * See {@link mountX} and {@link mountY} for more information about setting * the Mount Point. * * @default `0` */ x: number; /** * The y coordinate of the Node's Mount Point. * * @remarks * See {@link mountX} and {@link mountY} for more information about setting * the Mount Point. * * @default `0` */ y: number; /** * The width of the Node. * @warning This will be deprecated in favor of `w` and `h` properties in the future. * * @default `0` */ w: number; /** * The height of the Node. * @warning This will be deprecated in favor of `w` and `h` properties in the future. * * @default `0` */ h: number; /** * The alpha opacity of the Node. * * @remarks * The alpha value is a number between 0 and 1, where 0 is fully transparent * and 1 is fully opaque. * * @default `1` */ alpha: number; /** * Autosize * * @remarks * When enabled, the Node automatically resizes based on its content * * **Texture Autosize Mode:** * - When the Node has a texture, it automatically resizes to match the * texture's dimensions when the texture loads * - This ensures images display at their natural size without manual sizing * - Text Nodes always use this mode regardless of this setting * * **Children Autosize Mode:** * - When the Node has no texture but contains children, it automatically * resizes to encompass all children's bounds * - Calculates the bounding box that contains all child positions, dimensions, * and transforms (scale, rotation, mount/pivot points) * - Creates container behavior where the parent grows to fit its content * - Updates dynamically as children are added, removed, or transformed * * **Mode Selection Logic:** * - Texture mode takes precedence over children mode * - Mode switches automatically when texture is added/removed * - If no texture and no children, autosize has no effect * * **Performance:** * - Children mode uses efficient transform caching and differential updates * - Only recalculates when child transforms actually change * - Minimal memory allocation with factory function patterns * * * @default `false` */ autosize: boolean; /** * Margin around the Node's bounds for preloading * * @default `null` */ boundsMargin: number | [number, number, number, number] | null; /** * Clipping Mode * * @remarks * Enable Clipping Mode when you want to prevent the drawing of a Node and * its descendants from overflowing outside of the Node's x/y/width/height * bounds. * * For WebGL, clipping is implemented using the high-performance WebGL * operation scissor. As a consequence, clipping does not work for * non-rectangular areas. So, if the element is rotated * (by itself or by any of its ancestors), clipping will not work as intended. * * TODO: Add support for non-rectangular clipping either automatically or * via Render-To-Texture. * * @default `false` */ clipping: boolean; /** * Rounded corner radius for clipping (WebGL only). * * @remarks * When set to a value greater than 0 and `clipping` is `true`, children are * clipped to a rounded rectangle instead of a sharp rectangle. The stencil * buffer is used to achieve this — the scissor test still provides the coarse * axis-aligned bounds for performance, and the stencil pass applies the * rounded corners. * * Has no effect on the Canvas renderer. * Has no effect when `clipping` is `false`. * Has no effect when the node is rotated. * * @default `0` */ clipRadius: number; /** * The color of the Node. * * @remarks * The color value is a number in the format 0xRRGGBBAA, where RR is the red * component, GG is the green component, BB is the blue component, and AA is * the alpha component. * * Gradient colors may be set by setting the different color sub-properties: * {@link colorTop}, {@link colorBottom}, {@link colorLeft}, {@link colorRight}, * {@link colorTl}, {@link colorTr}, {@link colorBr}, {@link colorBl} accordingly. * * @default `0xffffffff` (opaque white) */ color: number; /** * The color of the top edge of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorTop: number; /** * The color of the bottom edge of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorBottom: number; /** * The color of the left edge of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorLeft: number; /** * The color of the right edge of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorRight: number; /** * The color of the top-left corner of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorTl: number; /** * The color of the top-right corner of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorTr: number; /** * The color of the bottom-right corner of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorBr: number; /** * The color of the bottom-left corner of the Node for gradient rendering. * * @remarks * See {@link color} for more information about color values and gradient * rendering. */ colorBl: number; /** * The Node's parent Node. * * @remarks * The value `null` indicates that the Node has no parent. This may either be * because the Node is the root Node of the scene graph, or because the Node * has been removed from the scene graph. * * In order to make sure that a Node can be rendered on the screen, it must * be added to the scene graph by setting it's parent property to a Node that * is already in the scene graph such as the root Node. * * @default `null` */ parent: CoreNode | null; /** * The Node's z-index. * * @remarks * Max z-index of children under the same parent determines which child * is rendered on top. Higher z-index means the Node is rendered on top of * children with lower z-index. * * Max value is 1000 and min value is -1000. Values outside of this range will be clamped. */ zIndex: number; /** * The Node's Texture. * * @remarks * The `texture` defines a rasterized image that is contained within the * {@link width} and {@link height} dimensions of the Node. If null, the * Node will use an opaque white {@link ColorTexture} when being drawn, which * essentially enables colors (including gradients) to be drawn. * * If set, by default, the texture will be drawn, as is, stretched to the * dimensions of the Node. This behavior can be modified by setting the TBD * and TBD properties. * * To create a Texture in order to set it on this property, call * {@link RendererMain.createTexture}. * * If the {@link src} is set on a Node, the Node will use the * {@link ImageTexture} by default and the Node will simply load the image at * the specified URL. * * Note: If this is a Text Node, the Texture will be managed by the Node's * {@link TextRenderer} and should not be set explicitly. */ texture: Texture | null; /** * Options to associate with the Node's Texture */ textureOptions: TextureOptions; /** * The Node's shader * * @remarks * The `shader` defines a {@link Shader} used to draw the Node. By default, * the Default Shader is used which simply draws the defined {@link texture} * or {@link color}(s) within the Node without any special effects. * * To create a Shader in order to set it on this property, call * {@link RendererMain.createShader}. * * Note: If this is a Text Node, the Shader will be managed by the Node's * {@link TextRenderer} and should not be set explicitly. */ shader: CoreShaderNode<any> | null; /** * Image URL * * @remarks * When set, the Node's {@link texture} is automatically set to an * {@link ImageTexture} using the source image URL provided (with all other * settings being defaults) */ src: string | null; /** * Scale to render the Node at * * @remarks * The scale value multiplies the provided {@link width} and {@link height} * of the Node around the Node's Pivot Point (defined by the {@link pivot} * props). * * Behind the scenes, setting this property sets both the {@link scaleX} and * {@link scaleY} props to the same value. * * NOTE: When the scaleX and scaleY props are explicitly set to different values, * this property returns `null`. Setting `null` on this property will have no * effect. * * @default 1.0 */ scale: number | null; /** * Scale to render the Node at (X-Axis) * * @remarks * The scaleX value multiplies the provided {@link width} of the Node around * the Node's Pivot Point (defined by the {@link pivot} props). * * @default 1.0 */ scaleX: number; /** * Scale to render the Node at (Y-Axis) * * @remarks * The scaleY value multiplies the provided {@link height} of the Node around * the Node's Pivot Point (defined by the {@link pivot} props). * * @default 1.0 */ scaleY: number; /** * Combined position of the Node's Mount Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Mount Point at the top-left corner of the Node. * - `0.5` defines it at the center of the Node. * - `1.0` defines it at the bottom-right corner of the node. * * Use the {@link mountX} and {@link mountY} props seperately for more control * of the Mount Point. * * When assigned, the same value is also passed to both the {@link mountX} and * {@link mountY} props. * * @default 0 (top-left) */ mount: number; /** * X position of the Node's Mount Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Mount Point's X position as the left-most edge of the * Node * - `0.5` defines it as the horizontal center of the Node * - `1.0` defines it as the right-most edge of the Node. * * The combination of {@link mountX} and {@link mountY} define the Mount Point * * @default 0 (left-most edge) */ mountX: number; /** * Y position of the Node's Mount Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Mount Point's Y position as the top-most edge of the * Node * - `0.5` defines it as the vertical center of the Node * - `1.0` defines it as the bottom-most edge of the Node. * * The combination of {@link mountX} and {@link mountY} define the Mount Point * * @default 0 (top-most edge) */ mountY: number; /** * Combined position of the Node's Pivot Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Pivot Point at the top-left corner of the Node. * - `0.5` defines it at the center of the Node. * - `1.0` defines it at the bottom-right corner of the node. * * Use the {@link pivotX} and {@link pivotY} props seperately for more control * of the Pivot Point. * * When assigned, the same value is also passed to both the {@link pivotX} and * {@link pivotY} props. * * @default 0.5 (center) */ pivot: number; /** * X position of the Node's Pivot Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Pivot Point's X position as the left-most edge of the * Node * - `0.5` defines it as the horizontal center of the Node * - `1.0` defines it as the right-most edge of the Node. * * The combination of {@link pivotX} and {@link pivotY} define the Pivot Point * * @default 0.5 (centered on x-axis) */ pivotX: number; /** * Y position of the Node's Pivot Point * * @remarks * The value can be any number between `0.0` and `1.0`: * - `0.0` defines the Pivot Point's Y position as the top-most edge of the * Node * - `0.5` defines it as the vertical center of the Node * - `1.0` defines it as the bottom-most edge of the Node. * * The combination of {@link pivotX} and {@link pivotY} define the Pivot Point * * @default 0.5 (centered on y-axis) */ pivotY: number; /** * Rotation of the Node (in Radians) * * @remarks * Sets the amount to rotate the Node by around it's Pivot Point (defined by * the {@link pivot} props). Positive values rotate the Node clockwise, while * negative values rotate it counter-clockwise. * * Example values: * - `-Math.PI / 2`: 90 degree rotation counter-clockwise * - `0`: No rotation * - `Math.PI / 2`: 90 degree rotation clockwise * - `Math.PI`: 180 degree rotation clockwise * - `3 * Math.PI / 2`: 270 degree rotation clockwise * - `2 * Math.PI`: 360 rotation clockwise */ rotation: number; /** * Whether the Node is rendered to a texture * * @remarks * TBD * * @default false */ rtt: boolean; /** * Node data element for custom data storage (optional) * * @remarks * This property is used to store custom data on the Node as a key/value data store. * Data values are limited to string, numbers, booleans. Strings will be truncated * to a 2048 character limit for performance reasons. * * This is not a data storage mechanism for large amounts of data please use a * dedicated data storage mechanism for that. * * The custom data will be reflected in the inspector as part of `data-*` attributes * * @default `undefined` */ data?: CustomDataMap; /** * Image Type to explicitly set the image type that is being loaded * * @remarks * This property must be used with a `src` that points at an image. In some cases * the extension doesn't provide a reliable representation of the image type. In such * cases set the ImageType explicitly. * * `regular` is used for normal images such as png, jpg, etc * `compressed` is used for ETC1/ETC2 compressed images with a PVR or KTX container * `svg` is used for scalable vector graphics * * @default `undefined` */ imageType?: 'regular' | 'compressed' | 'svg' | null; /** * She width of the rectangle from which the Image Texture will be extracted. * This value can be negative. If not provided, the image's source natural * width will be used. */ srcWidth?: number; /** * The height of the rectangle from which the Image Texture will be extracted. * This value can be negative. If not provided, the image's source natural * height will be used. */ srcHeight?: number; /** * The x coordinate of the reference point of the rectangle from which the Texture * will be extracted. `width` and `height` are provided. And only works when * createImageBitmap is available. Only works when createImageBitmap is supported on the browser. */ srcX?: number; /** * The y coordinate of the reference point of the rectangle from which the Texture * will be extracted. Only used when source `srcWidth` width and `srcHeight` height * are provided. Only works when createImageBitmap is supported on the browser. */ srcY?: number; /** * Mark the node as interactive so we can perform hit tests on it * when pointer events are registered. * @default false */ interactive?: boolean; } /** * Grab all the number properties of type T */ type NumberProps<T> = { [Key in keyof T as NonNullable<T[Key]> extends number ? Key : never]: number; }; /** * Properties of a Node used by the animate() function */ export interface CoreNodeAnimateProps extends NumberProps<CoreNodeProps> { /** * Shader properties to animate */ shaderProps: Record<string, number>; // TODO: textureProps: Record<string, number>; } /** * A visual Node in the Renderer scene graph. * * @remarks * CoreNode is an internally used class that represents a Renderer Node in the * scene graph. See INode.ts for the public APIs exposed to Renderer users * that include generic types for Shaders. */ export class CoreNode extends EventEmitter { readonly children: CoreNode[] = []; protected _id: number = getNewId(); readonly props: CoreNodeProps; public readonly isCoreNode: boolean = true as const; // WebGL Render Op State public renderOpBufferIdx: number = 0; public numQuads: number = 0; public renderOpTextures: WebGlCtxTexture[] = []; public stencilDepth: number = 0; // Permanent slot of this node's quad in the renderer's quad buffer. // -1 until assigned; reassigned contiguously whenever the render list is // rebuilt (see WebGlRenderer.invalidateQuadBuffer). public quadBufferIndex: number = -1; // Whether the node's quad bytes differ from what the GPU buffer holds. // Set when visual data (transforms, colors, alpha, texture) changes and // cleared once the slot is re-uploaded. public isQuadDirty = false; private hasShaderUpdater = false; public hasShaderTimeFn = false; private hasColorProps = false; private zIndexMin = 0; private zIndexMax = 0; public previousZIndex = -1; public updateType = UpdateType.All; public childUpdateType = UpdateType.None; public globalTransform?: Matrix3d; public localTransform?: Matrix3d; public sceneGlobalTransform?: Matrix3d; public renderCoords?: RenderCoords; public sceneRenderCoords?: RenderCoords; public renderBound?: Bound; public strictBound?: Bound; public preloadBound?: Bound; public clippingRect: RectWithValid = NO_CLIPPING_RECT; public textureCoords?: TextureCoords; public updateShaderUniforms: boolean = false; public isRenderable = false; public renderState: CoreNodeRenderState = CoreNodeRenderState.Init; /** * True when the node has no rotation, no scale, no mount offset, and no * contain-resize. The vast majority of nodes in a typical TV UI are * "simple" — their local transform is a pure translation. */ public isSimple = true; /** * True when localTransform is known to be in identity-shape (ta=1, tb=0, * tc=0, td=1) so that subsequent updateLocalTransform calls can skip the * 4 redundant field writes and use setTranslate() instead. */ public _localIsTranslate = false; /** * True when globalTransform is a pure translation. Read by child nodes in * update() to determine if the translate-only global fast path applies. */ public _globalIsTranslate = false; /** * Cached result of the contain-resize check, updated in updateIsSimple(). */ private _hasContainResize = false; public worldAlpha = 1; public premultipliedColorTl = 0; public premultipliedColorTr = 0; public premultipliedColorBl = 0; public premultipliedColorBr = 0; public calcZIndex = 0; public hasRTTupdates = false; public parentHasRenderTexture = false; public rttParent: CoreNode | null = null; /** * only used when rtt = true */ public framebufferDimensions: Dimensions | null = null; /**Autosize properties */ autosizer: Autosizer | null = null; parentAutosizer: Autosizer | null = null; public destroyed = false; constructor(readonly stage: Stage, props: CoreNodeProps) { super(); // Initialize the renderOpTextures array with a capacity of 16 (typical max textures) this.renderOpTextures = []; //inital update type let initialUpdateType = UpdateType.Local | UpdateType.RenderBounds | UpdateType.RenderState; const p = (this.props = {} as CoreNodeProps); // Fast-path assign only known keys p.x = props.x; p.y = props.y; p.w = props.w; p.h = props.h; p.alpha = props.alpha; p.autosize = props.autosize; p.clipping = props.clipping; p.clipRadius = props.clipRadius; p.color = props.color; p.colorTop = props.colorTop; p.colorBottom = props.colorBottom; p.colorLeft = props.colorLeft; p.colorRight = props.colorRight; p.colorTl = props.colorTl; p.colorTr = props.colorTr; p.colorBl = props.colorBl; p.colorBr = props.colorBr; //check if any color props are set for premultiplied color updates if ( props.color > 0 || props.colorTop > 0 || props.colorBottom > 0 || props.colorLeft > 0 || props.colorRight > 0 || props.colorTl > 0 || props.colorTr > 0 || props.colorBl > 0 || props.colorBr > 0 ) { this.hasColorProps = true; initialUpdateType |= UpdateType.PremultipliedColors; } p.scaleX = props.scaleX; p.scaleY = props.scaleY; p.rotation = props.rotation; p.pivotX = props.pivotX; p.pivotY = props.pivotY; p.mountX = props.mountX; p.mountY = props.mountY; p.mount = props.mount; p.pivot = props.pivot; p.zIndex = props.zIndex; p.textureOptions = props.textureOptions; p.data = props.data; p.imageType = props.imageType; p.srcX = props.srcX; p.srcY = props.srcY; p.srcWidth = props.srcWidth; p.srcHeight = props.srcHeight; p.parent = props.parent; p.texture = null; p.shader = null; p.src = null; p.rtt = false; p.boundsMargin = null; // Only set non-default values if (props.zIndex !== 0) { this.zIndex = props.zIndex; } if (props.parent !== null) { props.parent.addChild(this); } // Assign saved values through setters only when they differ from // defaults. In the common path most nodes are created with null texture, // default shader, no src, rtt=false, no boundsMargin, and // interactive=false — skipping the setter avoids equality checks, // setUpdateType traversals, and redundant texture/shader operations. if (props.texture !== null) { this.texture = props.texture; } if (props.shader === null || props.shader === this.stage.defShaderNode) { // Default shader — bypass the setter entirely; just point props at // the default shader node without triggering setUpdateType or // attachNode. p.shader = this.stage.defShaderNode; } else { this.shader = props.shader; } if (props.src !== null) { this.src = props.src; } if (props.rtt !== false) { this.rtt = props.rtt; } if (props.boundsMargin !== null) { this.boundsMargin = props.boundsMargin; } if (props.interactive !== false) { this.interactive = props.interactive; } // Initialize autosize if enabled if (p.autosize === true) { this.autosizer = new Autosizer(this); } this.setUpdateType(initialUpdateType); // if the default texture isn't loaded yet, wait for it to load // this only happens when the node is created before the stage is ready const dt = stage.defaultTexture; if (dt !== null && dt.state !== 'loaded') { dt.once('loaded', () => this.setUpdateType(UpdateType.IsRenderable)); } this.updateIsSimple(); } //#region Textures loadTexture(): void { if (this.props.texture === null) { return; } // If texture is already loaded / failed, trigger loaded event manually // so that users get a consistent event experience. // We do this in a microtask to allow listeners to be attached in the same // synchronous task after calling loadTexture() queueMicrotask(this.loadTextureTask); } /** * Task for queueMicrotask to loadTexture * * @remarks * This method is called in a microtask to release the texture. */ private loadTextureTask = (): void => { const texture = this.props.texture as Texture; //it is possible that texture is null here if user sets the texture to null right after loadTexture call if (texture === null) { return; } if (this.textureOptions.preload === true) { this.stage.txManager.loadTexture(texture); } texture.preventCleanup = this.props.textureOptions?.preventCleanup ?? false; texture.on('loaded', this.onTextureLoaded); texture.on('failed', this.onTextureFailed); texture.on('freed', this.onTextureFreed); if (texture.state === 'loaded') { this.onTextureLoaded(texture, texture.dimensions!); } else if (texture.state === 'failed') { this.onTextureFailed(texture, texture.error!); } else if (texture.state === 'freed') { this.onTextureFreed(texture); } }; unloadTexture(): void { if (this.texture === null) { return; } const texture = this.texture; texture.off('loaded', this.onTextureLoaded); texture.off('failed', this.onTextureFailed); texture.off('freed', this.onTextureFreed); texture.setRenderableOwner(this._id, false); } protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => { if (this.autosizer !== null) { this.autosizer.update(); } this.setUpdateType(UpdateType.IsRenderable); // Texture was loaded. In case the RAF loop has already stopped, we request // a render to ensure the texture is rendered. this.stage.requestRender(); // If parent has a render texture, flag that we need to update if (this.parentHasRenderTexture) { this.notifyParentRTTOfUpdate(); } // ignore 1x1 pixel textures if (dimensions.w > 1 && dimensions.h > 1) { this.emit('loaded', { type: 'texture', dimensions, } satisfies NodeTextureLoadedPayload); } if ( this.stage.calculateTextureCoord === true && this.props.textureOptions !== null ) { this.textureCoords = this.stage.renderer.getTextureCoords!(this); } // Trigger a local update if the texture is loaded and the resizeMode is 'contain' if (this.props.textureOptions?.resizeMode?.type === 'contain') { this.setUpdateType(UpdateType.Local); } }; private onTextureFailed: TextureFailedEventHandler = (_, error) => { // immediately set isRenderable to false, so that we handle the error // without waiting for the next frame loop this.isRenderable = false; this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); // If parent has a render texture, flag that we need to update if (this.parentHasRenderTexture) { this.notifyParentRTTOfUpdate(); } if ( this.texture !== null && this.texture.retryCount > this.texture.maxRetryCount ) { this.emit('failed', { type: 'texture', error, } satisfies NodeTextureFailedPayload); } }; private onTextureFreed: TextureFreedEventHandler = () => { // immediately set isRenderable to false, so that we handle the error // without waiting for the next frame loop this.isRenderable = false; this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); // If parent has a render texture, flag that we need to update if (this.parentHasRenderTexture) { this.notifyParentRTTOfUpdate(); } this.emit('freed', { type: 'texture', } satisfies NodeTextureFreedPayload); }; //#endregion Textures /** * Change types types is used to determine the scope of the changes being applied * * @remarks * See {@link UpdateType} for more information on each type * * @param type */ setUpdateType(type: UpdateType): void { this.updateType |= type; const parent = this.props.parent; if (parent === null || parent === undefined) return; // Short-circuit: if parent already has Children flag set, skip the // recursive call up the parent chain. This prevents redundant traversal // when many siblings mark the same parent dirty in a single frame. if (parent.updateType & UpdateType.Children) return; parent.setUpdateType(UpdateType.Children); } /** * Recompute the isSimple flag and the cached contain-resize check. * * @remarks * Called from property setters that affect whether the node is "simple" * (rotation, scaleX, scaleY, mountX, mountY, texture, textureOptions). * This runs on the cold setter path, not per-frame. */ updateIsSimple(): void { const p = this.props; this._hasContainResize = p.texture !== null && p.textureOptions !== null && p.textureOptions.resizeMode?.type === 'contain'; this.isSimple = p.rotation === 0 && p.scaleX === 1 && p.scaleY === 1 && p.mountX === 0 && p.mountY === 0 && this._hasContainResize === false; } updateLocalTransform() { const p = this.props; const { x, y } = p; if (this.isSimple === true) { // Fast path: no rotation, no scale, no mount, no contain-resize. // The local transform is a pure translation. if (this._localIsTranslate === true) { // Matrix is already identity-shape — only tx/ty need updating. this.localTransform!.setTranslate(x, y); return; } // First time on the simple path (or transitioning from non-simple): // write full identity + translation, then mark for future setTranslate. this.localTransform = Matrix3d.translate(x, y, this.localTransform); this._localIsTranslate = true; return; } // Non-simple path: need w/h for mount/pivot calculations. const { w, h } = p; const mountTranslateX = p.mountX * w; const mountTranslateY = p.mountY * h; const rotation = p.rotation; const scaleX = p.scaleX; const scaleY = p.scaleY; if (rotation !== 0) { // Full rotation (+ optional scale + pivot). // Reuse Matrix3d.temp to avoid allocation. const scaleRotate = Matrix3d.rotate(rotation, Matrix3d.temp).scale( scaleX, scaleY, ); const pivotTranslateX = p.pivotX * w; const pivotTranslateY = p.pivotY * h; this.localTransform = Matrix3d.translate( x - mountTranslateX + pivotTranslateX, y - mountTranslateY + pivotTranslateY, this.localTransform, ) .multiply(scaleRotate) .translate(-pivotTranslateX, -pivotTranslateY); } else if (scaleX !== 1 || scaleY !== 1) { // Scale (+ optional pivot) without rotation — skip the rotate matrix // and the 8-mul multiply; .scale() is a 4-mul in-place op. const pivotTranslateX = p.pivotX * w; const pivotTranslateY = p.pivotY * h; this.localTransform = Matrix3d.translate( x - mountTranslateX + pivotTranslateX, y - mountTranslateY + pivotTranslateY, this.localTransform, ) .scale(scaleX, scaleY) .translate(-pivotTranslateX, -pivotTranslateY); } else { // Mount only — pure translation. this.localTransform = Matrix3d.translate( x - mountTranslateX, y - mountTranslateY, this.localTransform, ); } // Handle 'contain' resize mode (cached check) const texture = p.texture; if ( this._hasContainResize === true && texture !== null && texture.dimensions !== null ) { let resizeModeScaleX = 1; let resizeModeScaleY = 1; let extraX = 0; let extraY = 0; const { w: tw, h: th } = texture.dimensions; const txAspectRatio = tw / th; const nodeAspectRatio = w / h; if (txAspectRatio > nodeAspectRatio) { const scaleX = w / tw; const scaledTxHeight = th * scaleX; extraY = (h - scaledTxHeight) / 2; resizeModeScaleY = scaledTxHeight / h; } else { const scaleY = h / th; const scaledTxWidth = tw * scaleY; extraX = (w - scaledTxWidth) / 2; resizeModeScaleX = scaledTxWidth / w; } this.localTransform .translate(extraX, extraY) .scale(resizeModeScaleX, resizeModeScaleY); } this._localIsTranslate = false; } /** * @todo: test for correct calculation flag * @param delta */ update(delta: number, parentClippingRect: RectWithValid): void { const props = this.props; const parent = props.parent; const parentHasRenderTexture = this.parentHasRenderTexture; const hasParent = props.parent !== null; let newRenderState: CoreNodeRenderState | null = null; let updateType = this.updateType; let childUpdateType = this.childUpdateType; let updateParent = false; //this needs to be handled before setting updateTypes are reset if (updateType & UpdateType.Autosize && this.autosizer !== null) { this.autosizer.update(); } // reset update type this.updateType = 0; this.childUpdateType = 0; if (updateType & UpdateType.Local) { this.updateLocalTransform(); updateType |= UpdateType.Global; updateParent = hasParent; } // Handle specific RTT updates at this node level if (updateType & UpdateType.RenderTexture && this.rtt === true) { this.hasRTTupdates = true; } if (updateType & UpdateType.Global) { const lt = this.localTransform!; const gt = this.globalTransform ?? (this.globalTransform = new Matrix3d()); let fastPathApplied = false; if (this.parentHasRenderTexture === true && parent?.rtt === true) { // RTT root: reset globalTransform for correct RTT rendering Matrix3d.identity(gt); // Maintain a full scene global transform for bounds detection this.sceneGlobalTransform = Matrix3d.copy( parent?.globalTransform || Matrix3d.identity(Matrix3d.temp), this.sceneGlobalTransform, ).translateOrMultiply(lt); this._globalIsTranslate = this.isSimple; } else if ( this.parentHasRenderTexture === true && parent?.rtt === false ) { // RTT chain: propagate sceneGlobalTransform from parent this.sceneGlobalTransform = Matrix3d.copy( parent?.sceneGlobalTransform || lt, this.sceneGlobalTransform, ).translateOrMultiply(lt); Matrix3d.copy(parent?.globalTransform || lt, gt); this._globalIsTranslate = false; } else { // Common non-RTT path const parentGT = parent?.globalTransform; if ( this.isSimple === true && parent !== null && parent._globalIsTranslate === true && parentGT !== undefined ) { // Translate-only fast path: both parent global and local are pure // translations. The global transform collapses to 2 additions. if (this._globalIsTranslate === false) { gt.ta = 1; gt.tb = 0; gt.tc = 0; gt.td = 1; } gt.setTranslate(parentGT.tx + lt.tx, parentGT.ty + lt.ty); this._globalIsTranslate = true; fastPathApplied = true; } else { Matrix3d.copy(parentGT || lt, gt); this._globalIsTranslate = this.isSimple === true && parent !== null && parent._globalIsTranslate === true; } } if (fastPathApplied === false) { if (parent !== null) { if (this.isSimple === true) { gt.translate(lt.tx, lt.ty); } else { gt.translateOrMultiply(lt); } } } this.calculateRenderCoords(); this.updateBoundingRect(); updateType |= UpdateType.RenderState; updateParent = hasParent; //only propagate children updates if not autosizing if ((updateType & UpdateType.Autosize) === 0) { updateType |= UpdateType.Children; childUpdateType |= UpdateType.Global; } if (this.clipping === true) { updateType |= UpdateType.Clipping | UpdateType.RenderBounds; updateParent = hasParent; childUpdateType |= UpdateType.RenderBounds; } } if (updateType & UpdateType.RenderBounds) { this.createRenderBounds(); updateType |= UpdateType.RenderState | UpdateType.Children; updateParent = hasParent; childUpdateType |= UpdateType.RenderBounds; } if (updateType & UpdateType.RenderState) { newRenderState = this.checkRenderBounds(); updateType |= UpdateType.IsRenderable; updateParent = hasParent; // if we're not going out of bounds, update the render state // this is done so the update loop can finish before we mark a node // as out of bounds if (newRenderState !== CoreNodeRenderState.OutOfBounds) { this.updateRenderState(newRenderState); } } if (updateType & UpdateType.WorldAlpha) { this.worldAlpha = (parent?.worldAlpha ?? 1) * this.props.alpha; updateType |= UpdateType.PremultipliedColors | UpdateType.Children | UpdateType.IsRenderable; updateParent = hasParent; childUpdateType |= UpdateType.WorldAlpha; } if (updateType & UpdateType.IsRenderable) { this.updateIsRenderable(); } // Handle autosize updates when children transforms change if ( updateType & UpdateType.Global && this.isRenderable === true && this.parentAutosizer !== null ) { this.parentAutosizer.patch(this.id); } if (updateType & UpdateType.Clipping) { this.calculateClippingRect(parentClippingRect); updateType |= UpdateType.Children; updateParent = hasParent; childUpdateType |= UpdateType.Clipping | UpdateType.RenderBounds; } if (updateType & UpdateType.PremultipliedColors) { const alpha = this.worldAlpha; const tl = props.colorTl; const tr = props.colorTr; const bl = props.colorBl; const br = props.colorBr; // Fast equality check (covers all 4 corners) const same = tl === tr && tl === bl && tl === br; const merged = mergeColorAlphaPremultiplied(tl, alpha, true); this.premultipliedColorTl = merged; if (same === true) { this.premultipliedColorTr = this.premultipliedColorBl = this.premultipliedColorBr = merged; } else { this.premultipliedColorTr = mergeColorAlphaPremultiplied( tr, alpha, true, ); this.premultipliedColorBl = mergeColorAlphaPremultiplied( bl, alpha, true, ); this.premultipliedColorBr = mergeColorAlphaPremultiplied( br, alpha, true, ); } } // Mark the quad dirty only when visual data (transforms, colors, alpha) // actually changed so the renderer re-uploads only modified slots. if ( updateType & (UpdateType.Global | UpdateType.PremultipliedColors | UpdateType.WorldAlpha) ) { this.isQuadDirty = true; } if (this.renderState === CoreNodeRenderState.OutOfBounds) { // Delay updating children until the node is in bounds this.updateType = updateType; this.childUpdateType = childUpdateType; return; } if (updateParent === true) { parent!.setUpdateType(UpdateType.Children); } if ( updateType & UpdateType.RecalcUniforms && this.hasShaderUpdater === true ) { this.updateShaderUniforms = true; } if (this.isRenderable === true && this.updateShaderUniforms === true) { this.updateShaderUniforms = false; //this exists because the boolean hasShaderUpdater === true this.shader!.update!(); } if (updateType & UpdateType.Children && this.children.length > 0) { let childClippingRect = this.clippingRect; if (this.rtt === true) { childClippingRect = NO_CLIPPING_RECT; } for (let i = 0, length = this.children.length; i < length; i++) { const child = this.children[i] as CoreNode; if (childUpdateType !== 0) { child.setUpdateType(childUpdateType); } if (child.updateType === 0) { continue; } child.update(delta, childClippingRect); } } // If the node has an RTT parent and a visually relevant change occurred (or a // nested RTT child already flagged this node via hasRTTupdates), notify the // nearest RTT ancestor so it re-renders its surface. // Guarded by RTT_NOTIFY_MASK to avoid redundant notifications on frames where // only child-traversal bookkeeping flags (Children, RenderBounds, etc.) are set. if ( parentHasRenderTexture === true && (this.hasRTTupdates === true || (updateType & RTT_NOTIFY_MASK) !== 0) ) { this.notifyParentRTTOfUpdate(); } //Resort children if needed if (updateType & UpdateType.SortZIndexChildren) { // reorder z-index this.sortChildren(); } // If we're out of bounds, apply the render state now // this is done so nodes can finish their entire update loop before // being marked as out of bounds if (newRenderState === CoreNodeRenderState.OutOfBounds) { this.updateRenderState(newRenderState); this.updateIsRenderable(); if ( this.rtt === true && newRenderState === CoreNodeRenderState.OutOfBounds ) { // notify children that we are going out of bounds // we have to do this now before we stop processing the render tree this.notifyChildrenRTTOfUpdate(newRenderState); } } } private findParentRTTNode(): CoreNode | null { let rttNode: CoreNode | null = this.parent; while (rttNode && !rttNode.rtt) { rttNode = rttNode.parent; } return rttNode; } private notifyChildrenRTTOfUpdate(renderState: CoreNodeRenderState) { for (const child of this.children) { // force child to update render state child.updateRenderState(renderState); child.updateIsRenderable(); child.notifyChildrenRTTOfUpdate(renderState); } } protected notifyParentRTTOfUpdate() { if (this.parent === null) { return; } const rttNode = this.rttParent || this.findParentRTTNode(); if (!rttNode) { return; } // If an RTT node is found, mark it for re-rendering rttNode.hasRTTupdates = true; rttNode.setUpdateType(UpdateType.RenderTexture); // if rttNode is nested, also make it update its RTT parent if (rttNode.parentHasRenderTexture === true) { rttNode.notifyParentRTTOfUpdate(); } } checkRenderBounds(): CoreNodeRenderState { if (boundInsideBound(this.renderBound!, this.strictBound!)) { return CoreNodeRenderState.InViewport; } if (boundInsideBound(this.renderBound!, this.preloadBound!)) { return CoreNodeRenderState.InBounds; } // check if we're larger then our parent, we're definitely in the viewport if (boundLargeThanBound(this.renderBound!, this.strictBound!)) { return CoreNodeRenderState.InViewport; } // check if we dont have dimensions, take our parent's render state if (this.parent !== null && (this.props.w === 0 || this.props.h === 0)) { return this.parent.renderState; } return CoreNodeRenderState.OutOfBounds; } updateBoundingRect() { const transform = (this.sceneGlobalTransform || this.globalTransform) as Matrix3d; const renderCoords = (this.sceneRenderCoords || this.renderCoords) as RenderCoords; if (transform.tb === 0 || transform.tc === 0) { this.renderBound = createBound( renderCoords.x1, renderCoords.y1, renderCoords.x3,