UNPKG

@lightningjs/renderer

Version:
735 lines (634 loc) 21.6 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2023 Comcast Cable Communications Management, LLC. * * Licensed under the Apache License, Version 2.0 (the License); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { 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; } }