@lightningjs/renderer
Version:
Lightning 3 Renderer
1,655 lines (1,494 loc) • 87.2 kB
text/typescript
/*
* 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,