@lightningtv/renderer
Version:
Lightning 3 Renderer
842 lines (761 loc) • 25 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 Bound,
type Rect,
createBound,
type BoundWithValid,
intersectRect,
type RectWithValid,
copyRect,
boundsOverlap,
convertBoundToRect,
} from '../../../lib/utils.js';
import {
TextRenderer,
type TrProps,
type TextRendererState,
type TrFontProps,
type TrPropSetters,
} 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,
type SdfRenderWindow,
} from './internal/setRenderWindow.js';
import type { TrFontFace } from '../../font-face-types/TrFontFace.js';
import { type FontFamilyMap } from '../../TrFontManager.js';
import { assertTruthy, mergeColorAlpha } from '../../../../utils.js';
import type { Stage } from '../../../Stage.js';
import { WebGlRenderOp } from '../../../renderers/webgl/WebGlRenderOp.js';
import { BufferCollection } from '../../../renderers/webgl/internal/BufferCollection.js';
import { Sdf, type SdfShaderProps } from '../../../shaders/webgl/SdfShader.js';
import type { WebGlCtxTexture } from '../../../renderers/webgl/WebGlCtxTexture.js';
import { EventEmitter } from '../../../../common/EventEmitter.js';
import type { Matrix3d } from '../../../lib/Matrix3d.js';
import type { Dimensions } from '../../../../common/CommonTypes.js';
import { WebGlRenderer } from '../../../renderers/webgl/WebGlRenderer.js';
import { calcDefaultLineHeight } from '../../TextRenderingUtils.js';
import type { WebGlShaderProgram } from '../../../renderers/webgl/WebGlShaderProgram.js';
import type { WebGlShaderNode } from '../../../renderers/webgl/WebGlShaderNode.js';
import type { CoreTextNode } from '../../../CoreTextNode.js';
declare module '../TextRenderer.js' {
interface TextRendererMap {
sdf: SdfTextRenderer;
}
// Add prefixed SDF-specific props to TextRendererDebugProps
interface TextRendererDebugProps {
sdfShaderDebug: boolean;
}
}
export interface LineCacheItem {
codepointIndex: number;
maxY: number;
maxX: number;
}
export interface SdfTextRendererState extends TextRendererState {
/**
* Cache for layout resume points indexed by the `curY` for each line
* in the render sequence.
*
* Allows faster rendering by skipping parts of the layout loop that are
* outside of the renderWindow.
*/
lineCache: LineCacheItem[];
renderWindow: SdfRenderWindow;
elementBounds: BoundWithValid;
clippingRect: RectWithValid;
bufferNumFloats: number;
bufferNumQuads: number;
vertexBuffer: Float32Array | undefined;
webGlBuffers: BufferCollection | null;
bufferUploaded: boolean;
distanceRange: number;
trFontFace: SdfTrFontFace | undefined;
/**
* Resolved line height in logical screen pixel units
*/
resLineHeight: number | undefined;
}
/**
* Ephemeral rect object used for calculations
*/
const tmpRect: Rect = {
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<SdfTextRendererState> {
/**
* Map of font family names to a set of font faces.
*/
private ssdfFontFamilies: FontFamilyMap = {};
private msdfFontFamilies: FontFamilyMap = {};
private fontFamilyArray: FontFamilyMap[] = [
this.ssdfFontFamilies,
this.msdfFontFamilies,
];
private sdfShader: WebGlShaderNode;
private rendererBounds: Bound;
public type: 'canvas' | 'sdf' = 'sdf';
constructor(stage: Stage) {
super(stage);
this.stage.shManager.registerShaderType('Sdf', Sdf);
this.sdfShader = this.stage.shManager.createShader(
'Sdf',
) as WebGlShaderNode;
this.rendererBounds = {
x1: 0,
y1: 0,
x2: this.stage.options.appWidth,
y2: this.stage.options.appHeight,
};
}
//#region Overrides
getPropertySetters(): Partial<TrPropSetters<SdfTextRendererState>> {
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;
},
};
}
override canRenderFont(props: TrFontProps): boolean {
// 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$$'
);
}
override isFontFaceSupported(fontFace: TrFontFace): boolean {
return fontFace instanceof SdfTrFontFace;
}
override addFontFace(fontFace: TrFontFace): void {
// 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 as string}`);
return;
}
let faceSet = fontFamiles[familyName];
if (!faceSet) {
faceSet = new Set();
fontFamiles[familyName] = faceSet;
}
faceSet.add(fontFace);
}
override createState(props: TrProps): SdfTextRendererState {
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,
},
};
}
override updateState(state: SdfTextRendererState): void {
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');
}
override renderQuads(node: CoreTextNode): void {
const state = node.trState as SdfTextRendererState;
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 as BufferCollection,
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 as WebGlCtxTexture);
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++;
// }
}
override setIsRenderable(
state: SdfTextRendererState,
renderable: boolean,
): void {
super.setIsRenderable(state, renderable);
state.trFontFace?.texture.setRenderableOwner(state, renderable);
}
override destroyState(state: SdfTextRendererState): void {
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
public resolveFontFace(props: TrFontProps): SdfTrFontFace | undefined {
return this.stage.fontManager.resolveFontFace(
this.fontFamilyArray,
props,
'sdf',
) as SdfTrFontFace | undefined;
}
/**
* Release the loaded SDF font face
*
* @param state
*/
protected releaseFontFace(state: SdfTextRendererState) {
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
*/
protected invalidateLayoutCache(state: SdfTextRendererState): void {
state.renderWindow.valid = false;
state.elementBounds.valid = false;
state.textH = undefined;
state.textW = undefined;
state.lineCache = [];
this.setStatus(state, 'loading');
this.scheduleUpdateState(state);
}
protected setElementBoundsX(state: SdfTextRendererState): void {
const { x, contain, width } = state.props;
const { elementBounds } = state;
elementBounds.x1 = x;
elementBounds.x2 = contain !== 'none' ? x + width : Infinity;
}
protected setElementBoundsY(state: SdfTextRendererState): void {
const { y, contain, height } = state.props;
const { elementBounds } = state;
elementBounds.y1 = y;
elementBounds.y2 = contain === 'both' ? y + height : Infinity;
}
}