@lightningjs/renderer
Version:
Lightning 3 Renderer
580 lines • 21.9 kB
JavaScript
/*
* 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 { CoreNode, CoreNodeRenderState, UpdateType, } from './CoreNode.js';
import { Matrix3d } from './lib/Matrix3d.js';
import { BufferCollection } from './renderers/webgl/internal/BufferCollection.js';
import { mergeColorAlpha } from '../utils.js';
export var TextConstraint;
(function (TextConstraint) {
TextConstraint[TextConstraint["none"] = 0] = "none";
TextConstraint[TextConstraint["width"] = 1] = "width";
TextConstraint[TextConstraint["height"] = 2] = "height";
TextConstraint[TextConstraint["both"] = 3] = "both";
})(TextConstraint || (TextConstraint = {}));
export class CoreTextNode extends CoreNode {
textRenderer;
fontHandler;
_layoutGenerated = false;
_waitingForFont = false;
_containType = TextConstraint.none;
_sdfBuffer = null;
_sdfQuadCollection = null;
_sdfShaderProps = null;
// Text renderer properties - stored directly on the node
textProps;
_renderInfo = null;
constructor(stage, props, 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);
}
onTextureLoaded = (_, 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,
});
}
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.
*/
releaseSdfBuffer() {
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;
}
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 = 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
*/
update(delta, parentClippingRect) {
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
*/
updateIsRenderable() {
// 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
*/
handleRenderResult(result) {
// 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'),
});
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'),
});
return;
}
this.texture = this.stage.txManager.createTexture('ImageTexture', {
premultiplyAlpha: true,
src: result.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'),
});
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];
}
this._renderInfo = result;
queueMicrotask(this.emitTextLoadedEvent);
}
// Reusable bound method for emitting loaded event
emitTextLoadedEvent = () => {
if (this._renderInfo === null)
return; // Guard against unexpected null
this.emit('loaded', {
type: 'text',
dimensions: {
w: this._renderInfo.width,
h: this._renderInfo.height,
},
});
};
/**
* Override renderQuads to handle SDF vs Canvas rendering
*/
renderQuads(renderer) {
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.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,
normalized: false,
stride: 4 * Float32Array.BYTES_PER_ELEMENT,
offset: 0,
},
a_textureCoords: {
name: 'a_textureCoords',
size: 2,
type: glw.FLOAT,
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);
}
updateRenderState(renderState) {
super.updateRenderState(renderState);
if (this._renderInfo !== null &&
renderState === CoreNodeRenderState.OutOfBounds) {
this.releaseSdfBuffer();
}
}
destroy() {
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
*/
get quadBufferCollection() {
return this._sdfQuadCollection || super.quadBufferCollection;
}
/**
* used in webgl SDF shader to get the SDF shader props for rendering text quads
*/
get sdfShaderProps() {
return this._sdfShaderProps;
}
get isSdfRenderOp() {
return this.textRenderer.type === 'sdf';
}
draw(renderer) {
if (this.textRenderer.type === 'canvas') {
super.draw(renderer);
return;
}
const { glw, stage } = renderer;
const canvas = stage.platform.canvas;
const shader = this.props.shader;
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);
}
set w(value) {
this.maxWidth = value;
}
get w() {
return this.props.w;
}
set h(value) {
this.maxHeight = value;
}
get h() {
return this.props.h;
}
get maxWidth() {
return this.textProps.maxWidth;
}
set maxWidth(value) {
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) {
if (this.textProps.maxHeight !== value) {
this.textProps.maxHeight = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get contain() {
return this.textProps.contain;
}
set contain(value) {
if (this.textProps.contain !== value) {
this.textProps.contain = value;
this._containType = TextConstraint[value];
this.setUpdateType(UpdateType.Local);
}
}
get text() {
return this.textProps.text;
}
set text(value) {
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() {
return this.textProps.fontSize;
}
set fontSize(value) {
if (this.textProps.fontSize !== value) {
this.textProps.fontSize = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get fontFamily() {
return this.textProps.fontFamily;
}
set fontFamily(value) {
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() {
return this.textProps.fontStyle;
}
set fontStyle(value) {
if (this.textProps.fontStyle !== value) {
this.textProps.fontStyle = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get textAlign() {
return this.textProps.textAlign;
}
set textAlign(value) {
if (this.textProps.textAlign !== value) {
this.textProps.textAlign = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get letterSpacing() {
return this.textProps.letterSpacing;
}
set letterSpacing(value) {
if (this.textProps.letterSpacing !== value) {
this.textProps.letterSpacing = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get lineHeight() {
return this.textProps.lineHeight;
}
set lineHeight(value) {
if (this.textProps.lineHeight !== value) {
this.textProps.lineHeight = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get maxLines() {
return this.textProps.maxLines;
}
set maxLines(value) {
if (this.textProps.maxLines !== value) {
this.textProps.maxLines = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get verticalAlign() {
return this.textProps.verticalAlign;
}
set verticalAlign(value) {
if (this.textProps.verticalAlign !== value) {
this.textProps.verticalAlign = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get overflowSuffix() {
return this.textProps.overflowSuffix;
}
set overflowSuffix(value) {
if (this.textProps.overflowSuffix !== value) {
this.textProps.overflowSuffix = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get wordBreak() {
return this.textProps.wordBreak;
}
set wordBreak(value) {
if (this.textProps.wordBreak !== value) {
this.textProps.wordBreak = value;
this._layoutGenerated = false;
this.setUpdateType(UpdateType.Local);
}
}
get offsetY() {
return this.textProps.offsetY;
}
set offsetY(value) {
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) {
if (this.textProps.forceLoad !== value) {
this.textProps.forceLoad = value;
this.setUpdateType(UpdateType.Local);
}
}
get renderInfo() {
return this._renderInfo;
}
}
//# sourceMappingURL=CoreTextNode.js.map