UNPKG

@lightningjs/renderer

Version:
725 lines (656 loc) 23.9 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2025 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 { Stage } from '../Stage.js'; import type { CoreRenderer } from '../renderers/CoreRenderer.js'; import type { FontHandler, SdfRenderInfo, TextLineStruct, TextRenderInfo, TextLayout, TextRenderProps, } from './TextRenderer.js'; import type { CoreTextNodeProps } from '../CoreTextNode.js'; import { getLayoutCacheKey, hasZeroWidthSpace } from './Utils.js'; import * as SdfFontHandler from './SdfFontHandler.js'; import { WebGlRenderer } from '../renderers/webgl/WebGlRenderer.js'; import { Sdf, SdfPlain } from '../shaders/webgl/SdfShader.js'; import type { WebGlShaderNode } from '../renderers/webgl/WebGlShaderNode.js'; import { mapTextLayout } from './TextLayoutEngine.js'; import type { WebGlCtxTexture } from '../renderers/webgl/WebGlCtxTexture.js'; import { parseRichText, ParseResult } from './RichTextParser.js'; import { SDF_PLAIN_GLYPH_STRIDE, SDF_RICH_GLYPH_STRIDE, } from '../renderers/webgl/SdfBuffer.js'; // Design-unit glyph record strides consumed by WebGlRenderer.addSdfQuads. // plain (8 floats): x, y, w, h, u, v, uw, vh // rich (12 floats): x, y, w, h, u, v, uw, vh, shearTop, shearBot, packed_span_color, style // Horizontal shear factor for fake italic: tan(14°). // Applied to glyph and decoration vertices in design-unit space. const ITALIC_SHEAR = Math.tan((14 * Math.PI) / 180); // White (0xFFFFFFFF as 0xRRGGBBAA) packed little-endian: all UNSIGNED_BYTE channels = 255 → 1.0 // When v_color = vec4(1,1,1,1) the span color has no effect; the node color passes through unchanged. const _PACKED_WHITE = 0xffffffff; // Module-level ParseResult singleton — safe because generateTextLayout is synchronous. const _richTextResult = new ParseResult(); // Type definition to match interface const type = 'sdf' as const; let sdfShader: WebGlShaderNode | null = null; let sdfPlainShader: WebGlShaderNode | null = null; // Initialize the SDF text renderer const init = (stage: Stage): void => { SdfFontHandler.init(); // Register both SDF shader variants with the shader manager stage.shManager.registerShaderType('Sdf', Sdf); stage.shManager.registerShaderType('SdfPlain', SdfPlain); sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode; sdfPlainShader = stage.shManager.createShader('SdfPlain') as WebGlShaderNode; }; const font: FontHandler = SdfFontHandler; const renderInfoCache = new Map<string, SdfRenderInfo>(); /** * Convert a 0xRRGGBBAA color to a little-endian uint32 suitable for an * UNSIGNED_BYTE normalized vec4 attribute. * * Memory layout (little-endian): byte 0 = R, byte 1 = G, byte 2 = B, byte 3 = A. * WebGL reads a_color[0..3] as (R/255, G/255, B/255, A/255). */ const _packColor = (rgba: number): number => { const r = (rgba >>> 24) & 0xff; const g = (rgba >>> 16) & 0xff; const b = (rgba >>> 8) & 0xff; const a = rgba & 0xff; return (r | (g << 8) | (b << 16) | (a << 24)) >>> 0; }; /** * SDF text renderer using MSDF/SDF fonts with WebGL * * @param props - Text rendering properties * @returns TextRenderInfo (cached after first call per unique layout key) */ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { const cacheKey = getLayoutCacheKey(props); let renderInfo = renderInfoCache.get(cacheKey); if (renderInfo !== undefined) { return renderInfo; } // Calculate text layout and generate glyph data for caching const layout = generateTextLayout( props, SdfFontHandler.getFontData(props.fontFamily)!, ); renderInfo = { type, layout, width: layout.width, height: layout.height, remainingLines: layout.remainingLines, hasRemainingText: layout.hasRemainingText, atlasTexture: SdfFontHandler.getAtlas(props.fontFamily)! .ctxTexture as WebGlCtxTexture, } as SdfRenderInfo; renderInfoCache.set(cacheKey, renderInfo); // For SDF renderer, ImageData is null since we render via WebGL return renderInfo; }; /** * Submit SDF glyphs to the renderer's shared batched buffer. * Called from CoreTextNode during rendering. * * Three paths: * 1. **Exact cache hit** — layout, transform, color, and alpha haven't * changed. The cached pre-transformed Float32Array is mem-copied directly * into the shared SDF buffer (no per-glyph matrix math). * 2. **Translation hit** — only tx/ty changed (the scroll path). The cached * vertices are copied with the position delta applied; the cache keeps its * original base so nothing is re-snapshotted. * 3. **Cache miss** — re-computes per-glyph world-space vertices via * `addSdfQuads`, then snapshots the result into the cache. */ const renderQuads = ( renderer: CoreRenderer, layout: TextLayout, _vertexBuffer: Float32Array | null, renderProps: TextRenderProps, ): void => { const fontFamily = renderProps.fontFamily; const atlasTexture = SdfFontHandler.getAtlas(fontFamily); if (atlasTexture === null) { return; } const webGlRenderer = renderer as WebGlRenderer; const cache = renderProps.sdfCache; const ctxTexture = atlasTexture.ctxTexture as WebGlCtxTexture; // Select the shared buffer + shader variant for this layout. The two SDF // GPU layouts can never share a draw call (different strides), so each is // tracked in its own SdfBuffer with its own shader node. const isRich = layout.richText === true; const sdfBuffer = isRich ? webGlRenderer.sdfBufferRich : webGlRenderer.sdfBufferPlain; const shader = isRich ? sdfShader! : sdfPlainShader!; // --- Cache-hit fast paths ----------------------------------------------- if (cache !== undefined && cache.vertices !== null) { const ct = cache.transform; const t = renderProps.globalTransform; if ( cache.layoutRef === layout && cache.color === renderProps.color && cache.alpha === renderProps.worldAlpha && ct[0] === t[0] && ct[1] === t[1] && ct[2] === t[3] && ct[3] === t[4] ) { const dx = t[6]! - ct[4]!; const dy = t[7]! - ct[5]!; if (dx === 0 && dy === 0) { // Fully static: mem-copy the cached vertices as-is. The GPU copy is // only reusable when the bytes this node is about to write are // identical to what was last uploaded at these offsets. Two events // break that: a reorder that moves this node's quad range (offset // shift), or a prior translated write that placed non-identical bytes // at the current offset. Either forces a full buffer re-upload. const at = sdfBuffer.quadCount; if (at !== cache.lastStartQuad || cache.lastWriteDirty === true) { sdfBuffer.changed = true; } cache.lastStartQuad = at; cache.lastWriteDirty = false; webGlRenderer.addSdfCachedQuads( sdfBuffer, cache.vertices, cache.glyphCount, ctxTexture, renderProps.clippingRect, renderProps.worldAlpha, layout.width, layout.height, renderProps.parentHasRenderTexture, renderProps.framebufferDimensions, shader, ); return; } // Pure translation (the scroll path): same glyphs, same scale/rotation, // only tx/ty moved. Copy the cached vertices shifted by the delta from // the cached base transform. The cache keeps its original base, so // every frame recomputes from the same reference — no drift and no // per-frame re-snapshot. cache.lastStartQuad = sdfBuffer.quadCount; cache.lastWriteDirty = true; webGlRenderer.addSdfTranslatedQuads( sdfBuffer, cache.vertices, cache.glyphCount, dx, dy, ctxTexture, renderProps.clippingRect, renderProps.worldAlpha, layout.width, layout.height, renderProps.parentHasRenderTexture, renderProps.framebufferDimensions, shader, ); return; } } // --- Cache-miss slow path ----------------------------------------------- const startIdx = sdfBuffer.idx; const startQuad = sdfBuffer.quadCount; webGlRenderer.addSdfQuads( sdfBuffer, layout.glyphs, layout.glyphCount, layout.fontScale, renderProps.globalTransform, renderProps.color, renderProps.worldAlpha, layout.distanceRange, ctxTexture, renderProps.clippingRect, layout.width, layout.height, renderProps.parentHasRenderTexture, renderProps.framebufferDimensions, shader, ); // Snapshot the written vertex data into the cache for future frames if (cache !== undefined) { const endIdx = sdfBuffer.idx; const len = endIdx - startIdx; if (len > 0) { if (cache.vertices === null || cache.vertices.length !== len) { cache.vertices = new Float32Array(len); } cache.vertices.set(sdfBuffer.fBuffer.subarray(startIdx, endIdx)); cache.glyphCount = layout.glyphCount; cache.color = renderProps.color; cache.alpha = renderProps.worldAlpha; cache.layoutRef = layout; // The snapshot lives at `startQuad` in the shared buffer; `addSdfQuads` // already forced a full re-upload, so the GPU is in sync with the fresh // bytes and the dirty marker is cleared. cache.lastStartQuad = startQuad; cache.lastWriteDirty = false; const t = renderProps.globalTransform; const ct = cache.transform; ct[0] = t[0]!; ct[1] = t[1]!; ct[2] = t[3]!; ct[3] = t[4]!; ct[4] = t[6]!; ct[5] = t[7]!; } } }; /** * Write one 12-float decoration record (underline or strikethrough). * * Uses u = -1.0 as a UV sentinel so the fragment shader branches to solid fill * instead of the SDF glyph path (v_texcoord.x < 0.0). Decorations are never * bold (style = 0.0) but may carry an italic shear. * * `shear1` / `shear2` are the x-deltas to add at y1 / y2 respectively for * italic lean; pass 0 for both when the span is not italic. * * All positions are in design-unit space (the CPU transform scales by * fontScale and applies the node transform). */ const _writeDecoRecord = ( glyphs: Float32Array, u32Glyphs: Uint32Array, di: number, x1: number, x2: number, y1: number, y2: number, color: number, shear1: number, shear2: number, ): number => { glyphs[di] = x1; glyphs[di + 1] = y1; glyphs[di + 2] = x2 - x1; glyphs[di + 3] = y2 - y1; glyphs[di + 4] = -1.0; glyphs[di + 5] = 0.0; glyphs[di + 6] = 0.0; glyphs[di + 7] = 0.0; glyphs[di + 8] = shear1; glyphs[di + 9] = shear2; u32Glyphs[di + 10] = color; glyphs[di + 11] = 0.0; return di + SDF_RICH_GLYPH_STRIDE; }; /** * Generate complete text layout with glyph positioning for caching. * * Two-pass approach: * Pass 1 — count exact glyphs (calling getGlyph to avoid degenerate quads) and * decoration quads needed by richText spans. * Pass 2 — write one design-unit record per glyph (8 or 12 floats) and, for * richText, one record per decoration quad, into a single * pre-allocated Float32Array / Uint32Array view pair. * * Glyph records are written first (in character order), then all decoration * records, preserving the legacy draw order so decorations always render on * top of glyphs. */ const generateTextLayout = ( props: CoreTextNodeProps, fontCache: SdfFontHandler.SdfFont, ): TextLayout => { const fontSize = props.fontSize; const fontFamily = props.fontFamily; const lineHeight = props.lineHeight; const metrics = SdfFontHandler.getFontMetrics(fontFamily, fontSize); const fontData = fontCache.data; const commonFontData = fontData.common; const designFontSize = fontData.info.size; const atlasWidth = commonFontData.scaleW; const atlasHeight = commonFontData.scaleH; // Pixel scale from design units to rendered pixels. const fontScale = fontSize / designFontSize; const letterSpacing = props.letterSpacing / fontScale; const maxWidth = props.maxWidth / fontScale; const maxHeight = props.maxHeight; // --- Rich text: parse BB-code and use stripped text for layout --- const richText = props.richText === true; let layoutText = props.text; if (richText === true) { parseRichText(props.text, _richTextResult); layoutText = _richTextResult.stripped; } const [ lines, remainingLines, hasRemainingText, _bareLineHeight, lineHeightPx, effectiveWidth, effectiveHeight, ] = mapTextLayout( SdfFontHandler.measureText, metrics, layoutText, props.textAlign, fontFamily, lineHeight, props.overflowSuffix, props.wordBreak, letterSpacing, props.maxLines, maxWidth, maxHeight, ); // --- Pre-compute decoration offsets in design-unit space --- // commonFontData.base is the BMFont "base" value: the y-distance from the top of the // character cell to the alphabetic baseline, expressed in design units. Using it // directly is more accurate than deriving the baseline from metrics.ascender, which // comes from a different metrics source and can be off by several design units. const base = commonFontData.base; const decoThickness = Math.max(1, Math.round(fontSize / 20)) / fontScale; // Underline: 10 % of fontSize below the alphabetic baseline. const decoUnderlineOffset = base + Math.max(1 / fontScale, Math.round(fontSize * 0.1) / fontScale); // Strikethrough: 75 % of base from the line top ≈ visual midpoint of lowercase letters. const decoStrikeOffset = Math.round(base * 0.75); const lineAmount = lines.length; if (richText === false) { // --- PLAIN PATH (richText=false): 8 floats/record, single counting pass --- // Pass 1: count glyphs (calling getGlyph to skip null entries, matching rich pass 1 behaviour) let glyphCount = 0; for (let i = 0; i < lineAmount; i++) { const textLine = (lines[i] as TextLineStruct)[0]; for (const char of textLine) { if (hasZeroWidthSpace(char) === true) continue; const codepoint = char.codePointAt(0); if (codepoint === undefined) continue; if (SdfFontHandler.getGlyph(fontFamily, codepoint) === null) continue; glyphCount++; } } const glyphs = new Float32Array(glyphCount * SDF_PLAIN_GLYPH_STRIDE); let go = 0; let currentX = 0; let currentY = 0; for (let i = 0; i < lineAmount; i++) { const line = lines[i] as TextLineStruct; const textLine = line[0]; let prevGlyphId = 0; currentX = line[3]; currentY = line[4] / fontScale; for (const char of textLine) { if (hasZeroWidthSpace(char) === true) continue; const codepoint = char.codePointAt(0); if (codepoint === undefined) continue; const glyph = SdfFontHandler.getGlyph(fontFamily, codepoint); if (glyph === null) continue; if (prevGlyphId !== 0) { currentX += SdfFontHandler.getKerning( fontFamily, prevGlyphId, glyph.id, ); } const x1 = currentX + glyph.xoffset; const y1 = currentY + glyph.yoffset; const u1 = glyph.x / atlasWidth; const v1 = glyph.y / atlasHeight; // Design-unit glyph record (x, y, w, h, u, v, uw, vh) glyphs[go] = x1; glyphs[go + 1] = y1; glyphs[go + 2] = glyph.width; glyphs[go + 3] = glyph.height; glyphs[go + 4] = u1; glyphs[go + 5] = v1; glyphs[go + 6] = glyph.width / atlasWidth; glyphs[go + 7] = glyph.height / atlasHeight; go += SDF_PLAIN_GLYPH_STRIDE; currentX += glyph.xadvance + letterSpacing; prevGlyphId = glyph.id; } } return { glyphs, glyphCount, totalQuadCount: glyphCount, richText: false, distanceRange: fontScale * fontData.distanceField.distanceRange, width: effectiveWidth * fontScale, height: effectiveHeight, fontScale: fontScale, lineHeight: lineHeightPx, fontFamily, remainingLines, hasRemainingText, }; } // --- RICH PATH (richText=true): 12 floats/record, two-pass --- let glyphCount = 0; let decoQuadCount = 0; let strippedPos = 0; let curSpanIdx = 0; for (let i = 0; i < lineAmount; i++) { const textLine = (lines[i] as TextLineStruct)[0]; for (const char of textLine) { if (hasZeroWidthSpace(char) === true) { strippedPos++; continue; } const codepoint = char.codePointAt(0); if (codepoint === undefined) { strippedPos++; continue; } const glyph = SdfFontHandler.getGlyph(fontFamily, codepoint); if (glyph === null) { strippedPos++; continue; } glyphCount++; // Advance span cursor past any spans that ended before this position. // curSpanIdx is always < spanCount after the loop; non-null assertions are safe. while ( curSpanIdx < _richTextResult.spanCount - 1 && strippedPos >= _richTextResult.spans[curSpanIdx]!.end ) { curSpanIdx++; } const span = _richTextResult.spans[curSpanIdx]!; if (span.underline === true) decoQuadCount++; if (span.strikethrough === true) decoQuadCount++; strippedPos++; } } const totalQuadCount = glyphCount + decoQuadCount; // --- Single allocation for the entire record payload --- // Layout: [glyph records (glyphCount × 12)] [deco records (decoQuadCount × 12)] const glyphs = new Float32Array(totalQuadCount * SDF_RICH_GLYPH_STRIDE); // Uint32Array view of the same ArrayBuffer for packed-color writes at slot 10 of each record. const u32Glyphs = new Uint32Array(glyphs.buffer); // Write cursors (float indices into glyphs / u32Glyphs). let gi = 0; // glyph region: 0 … glyphCount*12-1 let di = glyphCount * SDF_RICH_GLYPH_STRIDE; // deco region: starts after all glyph records // Reset rich-text tracking for pass 2. strippedPos = 0; curSpanIdx = 0; // --- PASS 2: write records --- let currentX = 0; let currentY = 0; for (let i = 0; i < lineAmount; i++) { const line = lines[i] as TextLineStruct; const textLine = line[0]; let prevGlyphId = 0; currentX = line[3]; // Convert pixel Y coordinate to design-unit space. currentY = line[4] / fontScale; // Alphabetic baseline in design-unit space for this line (used for italic shear). const baseline = currentY + base; for (const char of textLine) { if (hasZeroWidthSpace(char) === true) { strippedPos++; continue; } const codepoint = char.codePointAt(0); if (codepoint === undefined) { strippedPos++; continue; } const glyph = SdfFontHandler.getGlyph(fontFamily, codepoint); if (glyph === null) { strippedPos++; continue; } // --- Determine per-record color and style --- let packedColor = _PACKED_WHITE; let spanUnderline = false; let spanStrikethrough = false; let spanBold = false; let spanItalic = false; while ( curSpanIdx < _richTextResult.spanCount - 1 && strippedPos >= _richTextResult.spans[curSpanIdx]!.end ) { curSpanIdx++; } const span = _richTextResult.spans[curSpanIdx]!; packedColor = span.color !== 0 ? _packColor(span.color) : _PACKED_WHITE; spanUnderline = span.underline; spanStrikethrough = span.strikethrough; spanBold = span.bold; spanItalic = span.italic; // --- Kerning --- if (prevGlyphId !== 0) { currentX += SdfFontHandler.getKerning( fontFamily, prevGlyphId, glyph.id, ); } // Glyph bounding box in design units. const x1 = currentX + glyph.xoffset; const y1 = currentY + glyph.yoffset; const y2 = y1 + glyph.height; // Atlas UV coordinates. const u1 = glyph.x / atlasWidth; const v1 = glyph.y / atlasHeight; // Capture decoration X extents before advancing currentX. const decoX1 = currentX; const advance = glyph.xadvance + letterSpacing; // --- Italic horizontal shear: delta-x per vertex at y1 / y2 --- // shear = (baseline_y - vertex_y) * tan(14°) // Positive at y < baseline (above baseline → lean right at top). // Negative at y > baseline (below baseline → lean left at bottom). const shearTop = spanItalic ? (baseline - y1) * ITALIC_SHEAR : 0; const shearBot = spanItalic ? (baseline - y2) * ITALIC_SHEAR : 0; // Bold style flag passed to fragment shader for SDF threshold shift. const style = spanBold ? 1.0 : 0.0; // --- Write 12-float glyph record (x, y, w, h, u, v, uw, vh, shearTop, shearBot, packedColor, style) --- glyphs[gi] = x1; glyphs[gi + 1] = y1; glyphs[gi + 2] = glyph.width; glyphs[gi + 3] = glyph.height; glyphs[gi + 4] = u1; glyphs[gi + 5] = v1; glyphs[gi + 6] = glyph.width / atlasWidth; glyphs[gi + 7] = glyph.height / atlasHeight; glyphs[gi + 8] = shearTop; glyphs[gi + 9] = shearBot; u32Glyphs[gi + 10] = packedColor; glyphs[gi + 11] = style; gi += SDF_RICH_GLYPH_STRIDE; // Advance the glyph cursor. currentX += advance; prevGlyphId = glyph.id; // --- Write decoration records (richText only) --- if (spanUnderline === true) { const dy1 = currentY + decoUnderlineOffset; const dy2 = dy1 + decoThickness; const dShear1 = spanItalic ? (baseline - dy1) * ITALIC_SHEAR : 0; const dShear2 = spanItalic ? (baseline - dy2) * ITALIC_SHEAR : 0; di = _writeDecoRecord( glyphs, u32Glyphs, di, decoX1, decoX1 + advance, dy1, dy2, packedColor, dShear1, dShear2, ); } if (spanStrikethrough === true) { const dy1 = currentY + decoStrikeOffset; const dy2 = dy1 + decoThickness; const dShear1 = spanItalic ? (baseline - dy1) * ITALIC_SHEAR : 0; const dShear2 = spanItalic ? (baseline - dy2) * ITALIC_SHEAR : 0; di = _writeDecoRecord( glyphs, u32Glyphs, di, decoX1, decoX1 + advance, dy1, dy2, packedColor, dShear1, dShear2, ); } strippedPos++; } } // Convert final dimensions to pixel space for the layout. return { glyphs, glyphCount: totalQuadCount, totalQuadCount, richText: true, distanceRange: fontScale * fontData.distanceField.distanceRange, width: effectiveWidth * fontScale, height: effectiveHeight, fontScale: fontScale, lineHeight: lineHeightPx, fontFamily, remainingLines, hasRemainingText, }; }; const clearCache = (): void => { renderInfoCache.clear(); }; /** * SDF Text Renderer - implements TextRenderer interface */ const SdfTextRenderer = { type, font, renderText, renderQuads, init, clearCache, }; export default SdfTextRenderer;