@lightningtv/renderer
Version:
Lightning 3 Renderer
611 lines • 24.8 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 { createBound, intersectRect, copyRect, boundsOverlap, convertBoundToRect, } from '../../../lib/utils.js';
import { TextRenderer, } from '../TextRenderer.js';
import { SdfTrFontFace } from '../../font-face-types/SdfTrFontFace/SdfTrFontFace.js';
import { FLOATS_PER_GLYPH } from './internal/constants.js';
import { getStartConditions } from './internal/getStartConditions.js';
import { layoutText } from './internal/layoutText.js';
import { setRenderWindow, } from './internal/setRenderWindow.js';
import {} from '../../TrFontManager.js';
import { assertTruthy, mergeColorAlpha } from '../../../../utils.js';
import { WebGlRenderOp } from '../../../renderers/webgl/WebGlRenderOp.js';
import { BufferCollection } from '../../../renderers/webgl/internal/BufferCollection.js';
import { Sdf } from '../../../shaders/webgl/SdfShader.js';
import { EventEmitter } from '../../../../common/EventEmitter.js';
import { WebGlRenderer } from '../../../renderers/webgl/WebGlRenderer.js';
import { calcDefaultLineHeight } from '../../TextRenderingUtils.js';
/**
* Ephemeral rect object used for calculations
*/
const tmpRect = {
x: 0,
y: 0,
width: 0,
height: 0,
};
/**
* Singleton class for rendering text using signed distance fields.
*
* @remarks
* SdfTextRenderer supports both single-channel and multi-channel signed distance fields.
*/
export class SdfTextRenderer extends TextRenderer {
/**
* Map of font family names to a set of font faces.
*/
ssdfFontFamilies = {};
msdfFontFamilies = {};
fontFamilyArray = [
this.ssdfFontFamilies,
this.msdfFontFamilies,
];
sdfShader;
rendererBounds;
type = 'sdf';
constructor(stage) {
super(stage);
this.stage.shManager.registerShaderType('Sdf', Sdf);
this.sdfShader = this.stage.shManager.createShader('Sdf');
this.rendererBounds = {
x1: 0,
y1: 0,
x2: this.stage.options.appWidth,
y2: this.stage.options.appHeight,
};
}
//#region Overrides
getPropertySetters() {
return {
fontFamily: (state, value) => {
state.props.fontFamily = value;
this.releaseFontFace(state);
this.invalidateLayoutCache(state);
},
fontWeight: (state, value) => {
state.props.fontWeight = value;
this.releaseFontFace(state);
this.invalidateLayoutCache(state);
},
fontStyle: (state, value) => {
state.props.fontStyle = value;
this.releaseFontFace(state);
this.invalidateLayoutCache(state);
},
fontStretch: (state, value) => {
state.props.fontStretch = value;
this.releaseFontFace(state);
this.invalidateLayoutCache(state);
},
fontSize: (state, value) => {
state.props.fontSize = value;
this.invalidateLayoutCache(state);
},
text: (state, value) => {
state.props.text = value;
this.invalidateLayoutCache(state);
},
textAlign: (state, value) => {
state.props.textAlign = value;
this.invalidateLayoutCache(state);
},
color: (state, value) => {
state.props.color = value;
},
x: (state, value) => {
state.props.x = value;
if (state.elementBounds.valid) {
this.setElementBoundsX(state);
// Only schedule an update if the text is not already rendered
// (renderWindow is invalid) and the element possibly overlaps the screen
// This is to avoid unnecessary updates when we know text is off-screen
if (!state.renderWindow.valid &&
boundsOverlap(state.elementBounds, this.rendererBounds)) {
this.scheduleUpdateState(state);
}
}
},
y: (state, value) => {
state.props.y = value;
if (state.elementBounds.valid) {
this.setElementBoundsY(state);
// See x() for explanation
if (!state.renderWindow.valid &&
boundsOverlap(state.elementBounds, this.rendererBounds)) {
this.scheduleUpdateState(state);
}
}
},
contain: (state, value) => {
state.props.contain = value;
this.invalidateLayoutCache(state);
},
width: (state, value) => {
state.props.width = value;
// Only invalidate layout cache if we're containing in the horizontal direction
if (state.props.contain !== 'none') {
this.invalidateLayoutCache(state);
}
},
height: (state, value) => {
state.props.height = value;
// Only invalidate layout cache if we're containing in the vertical direction
if (state.props.contain === 'both') {
this.invalidateLayoutCache(state);
}
},
offsetY: (state, value) => {
state.props.offsetY = value;
this.invalidateLayoutCache(state);
},
scrollable: (state, value) => {
state.props.scrollable = value;
this.invalidateLayoutCache(state);
},
scrollY: (state, value) => {
state.props.scrollY = value;
// Scrolling doesn't need to invalidate any caches, but it does need to
// schedule an update
this.scheduleUpdateState(state);
},
letterSpacing: (state, value) => {
state.props.letterSpacing = value;
this.invalidateLayoutCache(state);
},
lineHeight: (state, value) => {
state.props.lineHeight = value;
state.resLineHeight = undefined;
this.invalidateLayoutCache(state);
},
maxLines: (state, value) => {
state.props.maxLines = value;
this.invalidateLayoutCache(state);
},
textBaseline: (state, value) => {
state.props.textBaseline = value;
this.invalidateLayoutCache(state);
},
verticalAlign: (state, value) => {
state.props.verticalAlign = value;
this.invalidateLayoutCache(state);
},
overflowSuffix: (state, value) => {
state.props.overflowSuffix = value;
this.invalidateLayoutCache(state);
},
debug: (state, value) => {
state.props.debug = value;
},
};
}
canRenderFont(props) {
// TODO: Support matching on font stretch, weight and style (if/when needed)
// For now we just match on the font family name
// '$$SDF_FAILURE_TEST$$' is used to test the 'failure' event coming from text
const { fontFamily } = props;
return (fontFamily in this.ssdfFontFamilies ||
fontFamily in this.msdfFontFamilies ||
fontFamily === '$$SDF_FAILURE_TEST$$');
}
isFontFaceSupported(fontFace) {
return fontFace instanceof SdfTrFontFace;
}
addFontFace(fontFace) {
// Make sure the font face is an SDF font face (it should have already passed
// the `isFontFaceSupported` check)
assertTruthy(fontFace instanceof SdfTrFontFace);
const familyName = fontFace.fontFamily;
const fontFamiles = fontFace.type === 'ssdf'
? this.ssdfFontFamilies
: fontFace.type === 'msdf'
? this.msdfFontFamilies
: undefined;
if (!fontFamiles) {
console.warn(`Invalid font face type: ${fontFace.type}`);
return;
}
let faceSet = fontFamiles[familyName];
if (!faceSet) {
faceSet = new Set();
fontFamiles[familyName] = faceSet;
}
faceSet.add(fontFace);
}
createState(props) {
return {
props,
status: 'initialState',
updateScheduled: false,
emitter: new EventEmitter(),
lineCache: [],
forceFullLayoutCalc: false,
renderWindow: {
screen: {
x1: 0,
y1: 0,
x2: 0,
y2: 0,
},
sdf: {
x1: 0,
y1: 0,
x2: 0,
y2: 0,
},
firstLineIdx: 0,
numLines: 0,
valid: false,
},
elementBounds: {
x1: 0,
y1: 0,
x2: 0,
y2: 0,
valid: false,
},
clippingRect: {
x: 0,
y: 0,
width: 0,
height: 0,
valid: false,
},
bufferNumFloats: 0,
bufferNumQuads: 0,
vertexBuffer: undefined,
webGlBuffers: null,
bufferUploaded: false,
textH: undefined,
textW: undefined,
distanceRange: 0,
trFontFace: undefined,
isRenderable: false,
resLineHeight: undefined,
debugData: {
updateCount: 0,
layoutCount: 0,
lastLayoutNumCharacters: 0,
layoutSum: 0,
drawSum: 0,
drawCount: 0,
bufferSize: 0,
},
};
}
updateState(state) {
let { trFontFace } = state;
const { textH, lineCache, debugData, forceFullLayoutCalc } = state;
debugData.updateCount++;
// On the first update call we need to set the status to loading
if (state.status === 'initialState') {
this.setStatus(state, 'loading');
}
// Resolve font face if we haven't yet
if (trFontFace === undefined) {
trFontFace = this.resolveFontFace(state.props);
state.trFontFace = trFontFace;
if (trFontFace === undefined) {
const msg = `SdfTextRenderer: Could not resolve font face for family: '${state.props.fontFamily}'`;
console.error(msg);
this.setStatus(state, 'failed', new Error(msg));
return;
}
trFontFace.texture.setRenderableOwner(state, true);
}
// If the font hasn't been loaded yet, stop here.
// Listen for the 'loaded' event and forward fontLoaded event
if (trFontFace.loaded === false) {
trFontFace.once('loaded', () => {
this.scheduleUpdateState(state);
});
return;
}
// If the font is loaded then so should the data
assertTruthy(trFontFace.data, 'Font face data should be loaded');
assertTruthy(trFontFace.metrics, 'Font face metrics should be loaded');
const { text, fontSize, x, y, contain, width, height, verticalAlign, scrollable, overflowSuffix, maxLines, } = state.props;
// scrollY only has an effect when contain === 'both' and scrollable === true
const scrollY = contain === 'both' && scrollable ? state.props.scrollY : 0;
const { renderWindow } = state;
/**
* The font size of the SDF font face (the basis for SDF space units)
*/
const sdfFontSize = trFontFace.data.info.size;
/**
* Divide screen space units by this to get the SDF space units
* Mulitple SDF space units by this to get screen space units
*/
const fontSizeRatio = fontSize / sdfFontSize;
// If not already resolved, resolve the line height and store it in the state
let resLineHeight = state.resLineHeight;
if (resLineHeight === undefined) {
const lineHeight = state.props.lineHeight;
// If lineHeight is undefined, use the maxCharHeight from the font face
if (lineHeight === undefined) {
resLineHeight = calcDefaultLineHeight(trFontFace.metrics, fontSize);
}
else {
resLineHeight = lineHeight;
}
state.resLineHeight = resLineHeight;
}
// Needed in renderWindow calculation
const sdfLineHeight = resLineHeight / fontSizeRatio;
state.distanceRange =
fontSizeRatio * trFontFace.data.distanceField.distanceRange;
// Allocate buffers if needed
const neededLength = text.length * FLOATS_PER_GLYPH;
let vertexBuffer = state.vertexBuffer;
if (!vertexBuffer || vertexBuffer.length < neededLength) {
vertexBuffer = new Float32Array(neededLength * 2);
}
const elementBounds = state.elementBounds;
if (!elementBounds.valid) {
this.setElementBoundsX(state);
this.setElementBoundsY(state);
elementBounds.valid = true;
}
// Return early if we're still viewing inside the established render window
// No need to re-render what we've already rendered
// (Only if there's an established renderWindow and we're not suppressing early exit)
if (!forceFullLayoutCalc && renderWindow.valid) {
const rwScreen = renderWindow.screen;
if (x + rwScreen.x1 <= elementBounds.x1 &&
x + rwScreen.x2 >= elementBounds.x2 &&
y - scrollY + rwScreen.y1 <= elementBounds.y1 &&
y - scrollY + rwScreen.y2 >= elementBounds.y2) {
this.setStatus(state, 'loaded');
return;
}
// Otherwise invalidate the renderWindow so it can be redone
renderWindow.valid = false;
this.setStatus(state, 'loading');
}
const { offsetY, textAlign } = state.props;
// Create a new renderWindow if needed
if (!renderWindow.valid) {
const isPossiblyOnScreen = boundsOverlap(elementBounds, this.rendererBounds);
if (!isPossiblyOnScreen) {
// If the element is not possibly on screen, we can skip the layout and rendering completely
return;
}
setRenderWindow(renderWindow, x, y, scrollY, resLineHeight, contain === 'both' ? elementBounds.y2 - elementBounds.y1 : 0, elementBounds, fontSizeRatio);
// console.log('newRenderWindow', renderWindow);
}
const start = getStartConditions(sdfFontSize, sdfLineHeight, trFontFace, verticalAlign, offsetY, fontSizeRatio, renderWindow, lineCache, textH);
if (!start) {
// Nothing to render, return early, but still mark as loaded (since the text is just scrolled
// out of view)
this.setStatus(state, 'loaded');
return;
}
const { letterSpacing } = state.props;
const out2 = layoutText(start.lineIndex, start.sdfX, start.sdfY, text, textAlign, width, height, fontSize, resLineHeight, letterSpacing, vertexBuffer, contain, lineCache, renderWindow.sdf, trFontFace, forceFullLayoutCalc, scrollable, overflowSuffix, maxLines);
state.bufferUploaded = false;
state.bufferNumFloats = out2.bufferNumFloats;
state.bufferNumQuads = out2.bufferNumQuads;
state.vertexBuffer = vertexBuffer;
state.renderWindow = renderWindow;
debugData.lastLayoutNumCharacters = out2.layoutNumCharacters;
debugData.bufferSize = vertexBuffer.byteLength;
// If we didn't exit early, we know we have completely computed w/h
if (out2.fullyProcessed) {
state.textW = out2.maxX * fontSizeRatio;
state.textH = out2.numLines * sdfLineHeight * fontSizeRatio;
}
// if (state.props.debug.printLayoutTime) {
// debugData.layoutSum += performance.now() - updateStartTime;
// debugData.layoutCount++;
// }
this.setStatus(state, 'loaded');
}
renderQuads(node) {
const state = node.trState;
if (!state.vertexBuffer) {
// Nothing to draw
return;
}
const renderer = this.stage.renderer;
assertTruthy(renderer instanceof WebGlRenderer);
const { fontSize, color, contain, scrollable, zIndex, debug } = state.props;
// scrollY only has an effect when contain === 'both' and scrollable === true
const scrollY = contain === 'both' && scrollable ? state.props.scrollY : 0;
const { textW = 0, textH = 0, distanceRange, vertexBuffer, bufferUploaded, trFontFace, elementBounds, } = state;
let { webGlBuffers } = state;
if (!webGlBuffers) {
const glw = renderer.glw;
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
const webGlBuffer = glw.createBuffer();
assertTruthy(webGlBuffer);
state.webGlBuffers = new BufferCollection([
{
buffer: webGlBuffer,
attributes: {
a_position: {
name: 'a_position',
size: 2, // 2 components per iteration
type: glw.FLOAT, // the data is 32bit floats
normalized: false, // don't normalize the data
stride, // 0 = move forward size * sizeof(type) each iteration to get the next position
offset: 0, // start at the beginning of the buffer
},
a_textureCoords: {
name: 'a_textureCoords',
size: 2,
type: glw.FLOAT,
normalized: false,
stride,
offset: 2 * Float32Array.BYTES_PER_ELEMENT,
},
},
},
]);
state.bufferUploaded = false;
assertTruthy(state.webGlBuffers);
webGlBuffers = state.webGlBuffers;
}
if (!bufferUploaded) {
const glw = renderer.glw;
const buffer = webGlBuffers?.getBuffer('a_textureCoords') ?? null;
glw.arrayBufferData(buffer, vertexBuffer, glw.STATIC_DRAW);
state.bufferUploaded = true;
}
assertTruthy(trFontFace);
if (scrollable && contain === 'both') {
assertTruthy(elementBounds.valid);
const elementRect = convertBoundToRect(elementBounds, tmpRect);
if (node.clippingRect.valid) {
state.clippingRect.valid = true;
node.clippingRect = intersectRect(node.clippingRect, elementRect, state.clippingRect);
}
else {
state.clippingRect.valid = true;
node.clippingRect = copyRect(elementRect, state.clippingRect);
}
}
const renderOp = new WebGlRenderOp(renderer, {
sdfShaderProps: {
transform: node.globalTransform.getFloatArr(),
color: mergeColorAlpha(color, node.worldAlpha),
size: fontSize / (trFontFace.data?.info.size || 0),
scrollY,
distanceRange,
debug: debug.sdfShaderDebug,
},
sdfBuffers: state.webGlBuffers,
shader: this.sdfShader,
alpha: node.worldAlpha,
clippingRect: node.clippingRect,
height: textH,
width: textW,
rtt: false,
parentHasRenderTexture: node.parentHasRenderTexture,
framebufferDimensions: node.framebufferDimensions,
}, 0);
const texture = state.trFontFace?.texture;
assertTruthy(texture);
const ctxTexture = texture.ctxTexture;
renderOp.addTexture(ctxTexture);
renderOp.length = state.bufferNumFloats;
renderOp.numQuads = state.bufferNumQuads;
renderer.addRenderOp(renderOp);
// if (!debug.disableScissor) {
// renderer.enableScissor(
// visibleRect.x,
// visibleRect.y,
// visibleRect.w,
// visibleRect.h,
// );
// }
// Draw the arrays
// gl.drawArrays(
// gl.TRIANGLES, // Primitive type
// 0,
// bufferNumVertices, // Number of verticies
// );
// renderer.disableScissor();
// if (debug.showElementRect) {
// this.renderer.drawBorder(
// Colors.Blue,
// elementRect.x,
// elementRect.y,
// elementRect.w,
// elementRect.h,
// );
// }
// if (debug.showVisibleRect) {
// this.renderer.drawBorder(
// Colors.Green,
// visibleRect.x,
// visibleRect.y,
// visibleRect.w,
// visibleRect.h,
// );
// }
// if (debug.showRenderWindow && renderWindow) {
// this.renderer.drawBorder(
// Colors.Red,
// x + renderWindow.x1,
// y + renderWindow.y1 - scrollY,
// x + renderWindow.x2 - (x + renderWindow.x1),
// y + renderWindow.y2 - scrollY - (y + renderWindow.y1 - scrollY),
// );
// }
// if (debug.printLayoutTime) {
// debugData.drawSum += performance.now() - drawStartTime;
// debugData.drawCount++;
// }
}
setIsRenderable(state, renderable) {
super.setIsRenderable(state, renderable);
state.trFontFace?.texture.setRenderableOwner(state, renderable);
}
destroyState(state) {
super.destroyState(state);
// If there's a Font Face assigned we must free the owner relation to its texture
state.trFontFace?.texture.setRenderableOwner(state, false);
}
//#endregion Overrides
resolveFontFace(props) {
return this.stage.fontManager.resolveFontFace(this.fontFamilyArray, props, 'sdf');
}
/**
* Release the loaded SDF font face
*
* @param state
*/
releaseFontFace(state) {
state.resLineHeight = undefined;
if (state.trFontFace) {
state.trFontFace.texture.setRenderableOwner(state, false);
state.trFontFace = undefined;
}
}
/**
* Invalidate the layout cache stored in the state. This will cause the text
* to be re-layed out on the next update.
*
* @remarks
* This also invalidates the visible window cache.
*
* @param state
*/
invalidateLayoutCache(state) {
state.renderWindow.valid = false;
state.elementBounds.valid = false;
state.textH = undefined;
state.textW = undefined;
state.lineCache = [];
this.setStatus(state, 'loading');
this.scheduleUpdateState(state);
}
setElementBoundsX(state) {
const { x, contain, width } = state.props;
const { elementBounds } = state;
elementBounds.x1 = x;
elementBounds.x2 = contain !== 'none' ? x + width : Infinity;
}
setElementBoundsY(state) {
const { y, contain, height } = state.props;
const { elementBounds } = state;
elementBounds.y1 = y;
elementBounds.y2 = contain === 'both' ? y + height : Infinity;
}
}
//# sourceMappingURL=SdfTextRenderer.js.map