UNPKG

@lightningtv/renderer

Version:
842 lines (761 loc) 25 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 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; } }