@lightningjs/renderer
Version:
Lightning 3 Renderer
735 lines (634 loc) • 21.6 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 type {
FontHandler,
TextRenderer,
TrProps,
TextRenderInfo,
} from './text-rendering/TextRenderer.js';
import {
CoreNode,
CoreNodeRenderState,
UpdateType,
type CoreNodeProps,
} from './CoreNode.js';
import type { Stage } from './Stage.js';
import type {
NodeTextFailedPayload,
NodeTextLoadedPayload,
NodeTextureLoadedPayload,
} from '../common/CommonTypes.js';
import type { RectWithValid } from './lib/utils.js';
import type { CoreRenderer } from './renderers/CoreRenderer.js';
import type { TextureLoadedEventHandler } from './textures/Texture.js';
import { Matrix3d } from './lib/Matrix3d.js';
import { BufferCollection } from './renderers/webgl/internal/BufferCollection.js';
import type { SdfShaderProps } from './shaders/webgl/SdfShader.js';
import type { WebGlRenderer } from './renderers/webgl/WebGlRenderer.js';
import type { WebGlCtxTexture } from './renderers/webgl/WebGlCtxTexture.js';
import { mergeColorAlpha } from '../utils.js';
export interface CoreTextNodeProps extends CoreNodeProps, TrProps {
/**
* Force Text Node to use a specific Text Renderer
*/
textRendererOverride?: string | null;
forceLoad: boolean;
}
export enum TextConstraint {
'none' = 0,
'width' = 1,
'height' = 2,
'both' = 3,
}
export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
private textRenderer: TextRenderer;
private fontHandler: FontHandler;
private _layoutGenerated = false;
private _waitingForFont = false;
private _containType: TextConstraint = TextConstraint.none;
private _sdfBuffer: WebGLBuffer | null = null;
private _sdfQuadCollection: BufferCollection | null = null;
private _sdfShaderProps: Partial<SdfShaderProps> | null = null;
// Text renderer properties - stored directly on the node
textProps: CoreTextNodeProps;
private _renderInfo: TextRenderInfo | null = null;
constructor(
stage: Stage,
props: CoreTextNodeProps,
textRenderer: TextRenderer,
) {
super(stage, props);
this.textRenderer = textRenderer;
this.fontHandler = textRenderer.font;
// Initialize text properties from props
// Props are guaranteed to have all defaults resolved by Stage.createTextNode
this.textProps = props;
this._containType = TextConstraint[props.contain];
this.setUpdateType(UpdateType.All);
}
protected override onTextureLoaded: TextureLoadedEventHandler = (
_,
dimensions,
) => {
// 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);
}
this.setUpdateType(UpdateType.IsRenderable);
};
/**
* Delete the cached WebGLBuffer held by the SDF renderer ref and reset the
* ref so the next renderQuads call allocates a fresh one.
* Safe to call from destroy() or on text change.
*/
private releaseSdfBuffer(): void {
const buf = this._sdfBuffer;
if (buf === null) return;
this.stage.renderer.deleteBuffer(buf);
this._sdfBuffer = null;
this._sdfQuadCollection = null;
}
allowTextGeneration() {
const p = this.props.parent;
if (p === null) {
return false;
}
if (p.worldAlpha > 0 && p.renderState > CoreNodeRenderState.OutOfBounds) {
return true;
}
return false;
}
override updateLocalTransform() {
const p = this.props;
let { x, y, w, h } = p;
const mountX = p.mountX;
const mountY = p.mountY;
let mountTranslateX = p.mountX * w;
let mountTranslateY = p.mountY * h;
let localTextTransform: Matrix3d | null = null;
const tProps = this.textProps;
const { textAlign, verticalAlign, maxWidth, maxHeight } = tProps;
const contain = this._containType;
const hasMaxWidth = maxWidth > 0;
const hasMaxHeight = maxHeight > 0;
if (contain > 0 && (hasMaxWidth || hasMaxHeight)) {
let containX = 0;
let containY = 0;
if (contain & TextConstraint.width && hasMaxWidth === true) {
if (textAlign === 'right') {
containX = maxWidth - w;
} else if (textAlign === 'center') {
containX = (maxWidth - w) * 0.5;
}
mountTranslateX = mountX * maxWidth;
}
if (contain & TextConstraint.height && maxHeight > 0) {
if (verticalAlign === 'bottom') {
containY = maxHeight - h;
} else if (verticalAlign === 'middle') {
containY = (maxHeight - h) * 0.5;
}
mountTranslateY = mountY * maxHeight;
}
localTextTransform = Matrix3d.translate(containX, containY);
}
if (p.rotation !== 0 || p.scaleX !== 1 || p.scaleY !== 1) {
const scaleRotate = Matrix3d.rotate(p.rotation).scale(p.scaleX, p.scaleY);
const pivotW =
contain & TextConstraint.width && maxWidth > 0 ? maxWidth : w;
const pivotH =
contain & TextConstraint.height && maxHeight > 0 ? maxHeight : h;
const pivotTranslateX = p.pivotX * pivotW;
const pivotTranslateY = p.pivotY * pivotH;
this.localTransform = Matrix3d.translate(
x - mountTranslateX + pivotTranslateX,
y - mountTranslateY + pivotTranslateY,
this.localTransform,
)
.multiply(scaleRotate)
.translate(-pivotTranslateX, -pivotTranslateY);
} else {
this.localTransform = Matrix3d.translate(
x - mountTranslateX,
y - mountTranslateY,
this.localTransform,
);
}
if (localTextTransform !== null) {
this.localTransform = this.localTransform.multiply(localTextTransform);
}
}
/**
* Override CoreNode's update method to handle text-specific updates
*/
override update(delta: number, parentClippingRect: RectWithValid): void {
const hasValidText =
typeof this.textProps.text === 'string' && this.textProps.text.length > 0;
if (
hasValidText === true &&
(this.textProps.forceLoad === true ||
this.allowTextGeneration() === true) &&
this._layoutGenerated === false
) {
if (this.fontHandler.isFontLoaded(this.textProps.fontFamily) === true) {
this._waitingForFont = false;
this._renderInfo = null; // Clear any previous render info before generating new layout
this.releaseSdfBuffer(); // Free the cached WebGLBuffer
const resp = this.textRenderer.renderText(this.textProps);
this.handleRenderResult(resp);
this._layoutGenerated = true;
} else if (this._waitingForFont === false) {
this.fontHandler.waitingForFont(this.textProps.fontFamily, this);
this._waitingForFont = true;
}
} else if (hasValidText === false) {
this.props.w = 0;
this.props.h = 0;
// If text is invalid, ensure node is not renderable
this.setRenderable(false);
this._layoutGenerated = false;
this._renderInfo = null;
this.releaseSdfBuffer(); // Free the cached WebGLBuffer
}
// First run the standard CoreNode update
super.update(delta, parentClippingRect);
}
/**
* Override is renderable check for SDF text nodes
*/
override updateIsRenderable(): void {
// Guard: Text nodes are never renderable without valid text
const hasValidText =
typeof this.textProps.text === 'string' && this.textProps.text.length > 0;
const renderInfo = this._renderInfo;
if (hasValidText === false || renderInfo === null) {
this.setRenderable(false);
return;
}
// SDF text nodes are always renderable if they have a valid layout
if (renderInfo.type === 'canvas') {
super.updateIsRenderable();
return;
}
this.setRenderable(true);
}
/**
* Handle the result of text rendering for both Canvas and SDF renderers
*/
private handleRenderResult(result: TextRenderInfo): void {
// Host paths on top
const textRendererType = result.type;
let width = result.width;
let height = result.height;
// Handle zero-dimension case (can happen with certain text inputs or font issues)
if (width === 0 || height === 0) {
this.emit('failed', {
type: 'text',
error: new Error('Text rendering failed, width or height zero'),
} satisfies NodeTextFailedPayload);
return;
}
// Handle Canvas renderer (uses ImageData)
if (textRendererType === 'canvas') {
if (result.imageData === undefined) {
this.emit('failed', {
type: 'text',
error: new Error(
'Canvas text rendering failed, no image data returned',
),
} satisfies NodeTextFailedPayload);
return;
}
this.texture = this.stage.txManager.createTexture('ImageTexture', {
premultiplyAlpha: true,
src: result.imageData as ImageData,
});
this.props.w = width;
this.props.h = height;
// It isn't renderable until the texture is loaded we have to set it to false here to avoid it
// being detected as a renderable default color node in the next frame
// it will be corrected once the texture is loaded
this.setRenderable(false);
if (this.renderState > CoreNodeRenderState.OutOfBounds) {
// We do want the texture to load immediately
this.texture.setRenderableOwner(this._id, true);
}
} else {
const layout = result.layout;
// For SDF, we rely on the presence of a valid layout to determine renderability
if (layout === undefined) {
this.emit('failed', {
type: 'text',
error: new Error(
'SDF text rendering failed, no layout data returned',
),
} satisfies NodeTextFailedPayload);
return;
}
this.props.w = width;
this.props.h = height;
this.setUpdateType(UpdateType.Local);
this.setRenderable(true);
this.numQuads = layout.glyphCount;
this._sdfShaderProps = {
size: layout.fontScale,
distanceRange: layout.distanceRange,
};
this.renderOpTextures = [result.atlasTexture as WebGlCtxTexture];
}
this._renderInfo = result;
queueMicrotask(this.emitTextLoadedEvent);
}
// Reusable bound method for emitting loaded event
private emitTextLoadedEvent = () => {
if (this._renderInfo === null) return; // Guard against unexpected null
this.emit('loaded', {
type: 'text',
dimensions: {
w: this._renderInfo.width,
h: this._renderInfo.height,
},
} satisfies NodeTextLoadedPayload);
};
/**
* Override renderQuads to handle SDF vs Canvas rendering
*/
override renderQuads(renderer: CoreRenderer): void {
if (this.parentHasRenderTexture === true) {
const rtt = renderer.renderToTextureActive;
if (rtt === false || this.parentRenderTexture !== renderer.activeRttNode)
return;
}
// Early return if no renderInfo
if (this._renderInfo === null) {
return;
}
// Canvas renderer: use standard texture rendering via CoreNode
if (this._renderInfo.type === 'canvas') {
super.renderQuads(renderer);
return;
}
if (this._sdfBuffer === null) {
const glw = (this.stage.renderer as WebGlRenderer).glw;
this._sdfBuffer = glw.createBuffer();
if (this._sdfBuffer === null) {
console.error('Failed to create WebGL buffer for SDF text rendering');
return;
}
glw.arrayBufferData(
this._sdfBuffer,
this._renderInfo.layout.vertexBuffer,
glw.STATIC_DRAW,
);
this._sdfQuadCollection = new BufferCollection([
{
buffer: this._sdfBuffer,
attributes: {
a_position: {
name: 'a_position',
size: 2,
type: glw.FLOAT as number,
normalized: false,
stride: 4 * Float32Array.BYTES_PER_ELEMENT,
offset: 0,
},
a_textureCoords: {
name: 'a_textureCoords',
size: 2,
type: glw.FLOAT as number,
normalized: false,
stride: 4 * Float32Array.BYTES_PER_ELEMENT,
offset: 2 * Float32Array.BYTES_PER_ELEMENT,
},
},
},
]);
}
this.sdfShaderProps!.transform = this.globalTransform!.getFloatArr();
this.sdfShaderProps!.color = mergeColorAlpha(
this.props.color,
this.worldAlpha,
);
this.textRenderer.renderQuads(this);
}
override updateRenderState(renderState: CoreNodeRenderState): void {
super.updateRenderState(renderState);
if (
this._renderInfo !== null &&
renderState === CoreNodeRenderState.OutOfBounds
) {
this.releaseSdfBuffer();
}
}
override destroy(): void {
if (this._waitingForFont === true && this.fontHandler) {
this.fontHandler.stopWaitingForFont(this.textProps.fontFamily, this);
}
// Clear cached layout and vertex buffer
this._renderInfo = null;
this.releaseSdfBuffer(); // Delete the cached WebGLBuffer before losing stage ref
this.fontHandler = null!; // Clear reference to avoid memory leaks
this.textRenderer = null!; // Clear reference to avoid memory leaks
super.destroy();
}
/**
* used in webgl SDF shader to get the quad buffer collection for rendering text quads
*/
override get quadBufferCollection(): BufferCollection {
return this._sdfQuadCollection || super.quadBufferCollection;
}
/**
* used in webgl SDF shader to get the SDF shader props for rendering text quads
*/
get sdfShaderProps(): SdfShaderProps {
return this._sdfShaderProps as SdfShaderProps;
}
override get isSdfRenderOp(): boolean {
return this.textRenderer.type === 'sdf';
}
override draw(renderer: WebGlRenderer) {
if (this.textRenderer.type === 'canvas') {
super.draw(renderer);
return;
}
const { glw, stage } = renderer;
const canvas = stage.platform!.canvas!;
const shader = this.props.shader as any;
stage.shManager.useShader(shader.program);
shader.program.bindRenderOp(this);
const clippingRect = this.clippingRect;
// Clipping
if (clippingRect.valid === true) {
const pixelRatio = this.parentHasRenderTexture ? 1 : stage.pixelRatio;
const clipX = Math.round(clippingRect.x * pixelRatio);
const clipWidth = Math.round(clippingRect.w * pixelRatio);
const clipHeight = Math.round(clippingRect.h * pixelRatio);
let clipY = Math.round(
canvas.height - clipHeight - clippingRect.y * pixelRatio,
);
// if parent has render texture, we need to adjust the scissor rect
// to be relative to the parent's framebuffer
if (this.parentHasRenderTexture) {
const parentFramebufferDimensions = this.parentFramebufferDimensions;
clipY =
parentFramebufferDimensions !== null
? parentFramebufferDimensions.h - this.props.h
: 0;
}
glw.setScissorTest(true);
glw.scissor(clipX, clipY, clipWidth, clipHeight);
} else {
glw.setScissorTest(false);
}
// SDF rendering uses drawArrays with explicit triangle vertices (6 vertices per quad)
// Note: buffers should be bound by bindRenderOp -> bindBufferCollection
glw.drawArrays(glw.TRIANGLES, 0, 6 * this.numQuads);
}
override set w(value: number) {
this.maxWidth = value;
}
override get w(): number {
return this.props.w;
}
override set h(value: number) {
this.maxHeight = value;
}
override get h(): number {
return this.props.h;
}
get maxWidth() {
return this.textProps.maxWidth;
}
set maxWidth(value: number) {
if (this.textProps.maxWidth !== value) {
this.textProps.maxWidth = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
// Property getters and setters
get maxHeight() {
return this.textProps.maxHeight;
}
set maxHeight(value: number) {
if (this.textProps.maxHeight !== value) {
this.textProps.maxHeight = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get contain(): TrProps['contain'] {
return this.textProps.contain;
}
set contain(value: TrProps['contain']) {
if (this.textProps.contain !== value) {
this.textProps.contain = value;
this._containType = TextConstraint[value];
this.setUpdateType(UpdateType.Local);
}
}
get text(): string {
return this.textProps.text;
}
set text(value: string) {
let normalizedValue = value;
if (value === undefined || value === null) {
normalizedValue = '';
} else if (typeof value !== 'string') {
normalizedValue = String(value);
}
if (this.textProps.text !== normalizedValue) {
this.textProps.text = normalizedValue;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get fontSize(): number {
return this.textProps.fontSize;
}
set fontSize(value: number) {
if (this.textProps.fontSize !== value) {
this.textProps.fontSize = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get fontFamily(): string {
return this.textProps.fontFamily;
}
set fontFamily(value: string) {
if (this.textProps.fontFamily !== value) {
if (this._waitingForFont === true) {
this.fontHandler.stopWaitingForFont(this.textProps.fontFamily, this);
}
this.textProps.fontFamily = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get fontStyle(): TrProps['fontStyle'] {
return this.textProps.fontStyle;
}
set fontStyle(value: TrProps['fontStyle']) {
if (this.textProps.fontStyle !== value) {
this.textProps.fontStyle = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get textAlign(): TrProps['textAlign'] {
return this.textProps.textAlign;
}
set textAlign(value: TrProps['textAlign']) {
if (this.textProps.textAlign !== value) {
this.textProps.textAlign = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get letterSpacing(): number {
return this.textProps.letterSpacing;
}
set letterSpacing(value: number) {
if (this.textProps.letterSpacing !== value) {
this.textProps.letterSpacing = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get lineHeight(): number {
return this.textProps.lineHeight;
}
set lineHeight(value: number) {
if (this.textProps.lineHeight !== value) {
this.textProps.lineHeight = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get maxLines(): number {
return this.textProps.maxLines;
}
set maxLines(value: number) {
if (this.textProps.maxLines !== value) {
this.textProps.maxLines = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get verticalAlign(): TrProps['verticalAlign'] {
return this.textProps.verticalAlign;
}
set verticalAlign(value: TrProps['verticalAlign']) {
if (this.textProps.verticalAlign !== value) {
this.textProps.verticalAlign = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get overflowSuffix(): string {
return this.textProps.overflowSuffix;
}
set overflowSuffix(value: string) {
if (this.textProps.overflowSuffix !== value) {
this.textProps.overflowSuffix = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get wordBreak(): TrProps['wordBreak'] {
return this.textProps.wordBreak;
}
set wordBreak(value: TrProps['wordBreak']) {
if (this.textProps.wordBreak !== value) {
this.textProps.wordBreak = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get offsetY(): number {
return this.textProps.offsetY;
}
set offsetY(value: number) {
if (this.textProps.offsetY !== value) {
this.textProps.offsetY = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get forceLoad() {
return this.textProps.forceLoad;
}
set forceLoad(value: boolean) {
if (this.textProps.forceLoad !== value) {
this.textProps.forceLoad = value;
this.setUpdateType(UpdateType.Local);
}
}
get renderInfo(): TextRenderInfo | null {
return this._renderInfo;
}
}