Buckets:
| import { Color, Texture, LinearFilter } from 'three' | |
| import { defineWorkerModule } from 'troika-worker-utils' | |
| import { fontResolverWorkerModule } from "./FontResolver.js"; | |
| import { createTypesetter } from './Typesetter.js' | |
| import { generateSDF, warmUpSDFCanvas, resizeWebGLCanvasWithoutClearing } from './SDFGenerator.js' | |
| import bidiFactory from 'bidi-js' | |
| const CONFIG = { | |
| defaultFontURL: null, | |
| unicodeFontsURL: null, | |
| sdfGlyphSize: 64, | |
| sdfMargin: 1 / 16, | |
| sdfExponent: 9, | |
| textureWidth: 2048, | |
| useWorker: true, | |
| } | |
| const tempColor = /*#__PURE__*/new Color() | |
| let hasRequested = false | |
| function now() { | |
| return (self.performance || Date).now() | |
| } | |
| /** | |
| * Customizes the text builder configuration. This must be called prior to the first font processing | |
| * request, and applies to all fonts. | |
| * | |
| * @param {String} config.defaultFontURL - The URL of the default font to use for text processing | |
| * requests, in case none is specified or the specifiede font fails to load or parse. | |
| * Defaults to "Roboto Regular" from Google Fonts. | |
| * @param {String} config.unicodeFontsURL - A custom location for the fallback unicode-font-resolver | |
| * data and font files, if you don't want to use the default CDN. See | |
| * https://github.com/lojjic/unicode-font-resolver for details. It can also be | |
| * configured per text instance, but this lets you do it once globally. | |
| * @param {Number} config.sdfGlyphSize - The default size of each glyph's SDF (signed distance field) | |
| * texture used for rendering. Must be a power-of-two number, and applies to all fonts, | |
| * but note that this can also be overridden per call to `getTextRenderInfo()`. | |
| * Larger sizes can improve the quality of glyph rendering by increasing the sharpness | |
| * of corners and preventing loss of very thin lines, at the expense of memory. Defaults | |
| * to 64 which is generally a good balance of size and quality. | |
| * @param {Number} config.sdfExponent - The exponent used when encoding the SDF values. A higher exponent | |
| * shifts the encoded 8-bit values to achieve higher precision/accuracy at texels nearer | |
| * the glyph's path, with lower precision further away. Defaults to 9. | |
| * @param {Number} config.sdfMargin - How much space to reserve in the SDF as margin outside the glyph's | |
| * path, as a percentage of the SDF width. A larger margin increases the quality of | |
| * extruded glyph outlines, but decreases the precision available for the glyph itself. | |
| * Defaults to 1/16th of the glyph size. | |
| * @param {Number} config.textureWidth - The width of the SDF texture; must be a power of 2. Defaults to | |
| * 2048 which is a safe maximum texture dimension according to the stats at | |
| * https://webglstats.com/webgl/parameter/MAX_TEXTURE_SIZE and should allow for a | |
| * reasonably large number of glyphs (default glyph size of 64^2 and safe texture size of | |
| * 2048^2, times 4 channels, allows for 4096 glyphs.) This can be increased if you need to | |
| * increase the glyph size and/or have an extraordinary number of glyphs. | |
| * @param {Boolean} config.useWorker - Whether to run typesetting in a web worker. Defaults to true. | |
| */ | |
| function configureTextBuilder(config) { | |
| if (hasRequested) { | |
| console.warn('configureTextBuilder called after first font request; will be ignored.') | |
| } else { | |
| assign(CONFIG, config) | |
| } | |
| } | |
| /** | |
| * Repository for all font SDF atlas textures and their glyph mappings. There is a separate atlas for | |
| * each sdfGlyphSize. Each atlas has a single Texture that holds all glyphs for all fonts. | |
| * | |
| * { | |
| * [sdfGlyphSize]: { | |
| * glyphCount: number, | |
| * sdfGlyphSize: number, | |
| * sdfTexture: Texture, | |
| * sdfCanvas: HTMLCanvasElement, | |
| * contextLost: boolean, | |
| * glyphsByFont: Map<fontURL, Map<glyphID, {path, atlasIndex, sdfViewBox}>> | |
| * } | |
| * } | |
| */ | |
| const atlases = Object.create(null) | |
| /** | |
| * @typedef {object} TroikaTextRenderInfo - Format of the result from `getTextRenderInfo`. | |
| * @property {TypesetParams} parameters - The normalized input arguments to the render call. | |
| * @property {Texture} sdfTexture - The SDF atlas texture. | |
| * @property {number} sdfGlyphSize - The size of each glyph's SDF; see `configureTextBuilder`. | |
| * @property {number} sdfExponent - The exponent used in encoding the SDF's values; see `configureTextBuilder`. | |
| * @property {Float32Array} glyphBounds - List of [minX, minY, maxX, maxY] quad bounds for each glyph. | |
| * @property {Float32Array} glyphAtlasIndices - List holding each glyph's index in the SDF atlas. | |
| * @property {Uint8Array} [glyphColors] - List holding each glyph's [r, g, b] color, if `colorRanges` was supplied. | |
| * @property {Float32Array} [caretPositions] - A list of caret positions for all characters in the string; each is | |
| * four elements: the starting X, the ending X, the bottom Y, and the top Y for the caret. | |
| * @property {number} [caretHeight] - An appropriate height for all selection carets. | |
| * @property {number} ascender - The font's ascender metric. | |
| * @property {number} descender - The font's descender metric. | |
| * @property {number} capHeight - The font's cap height metric, based on the height of Latin capital letters. | |
| * @property {number} xHeight - The font's x height metric, based on the height of Latin lowercase letters. | |
| * @property {number} lineHeight - The final computed lineHeight measurement. | |
| * @property {number} topBaseline - The y position of the top line's baseline. | |
| * @property {Array<number>} blockBounds - The total [minX, minY, maxX, maxY] rect of the whole text block; | |
| * this can include extra vertical space beyond the visible glyphs due to lineHeight, and is | |
| * equivalent to the dimensions of a block-level text element in CSS. | |
| * @property {Array<number>} visibleBounds - The total [minX, minY, maxX, maxY] rect of the whole text block; | |
| * unlike `blockBounds` this is tightly wrapped to the visible glyph paths. | |
| * @property {Array<object>} chunkedBounds - List of bounding rects for each consecutive set of N glyphs, | |
| * in the format `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`. | |
| * @property {object} timings - Timing info for various parts of the rendering logic including SDF | |
| * generation, typesetting, etc. | |
| * @frozen | |
| */ | |
| /** | |
| * @callback getTextRenderInfo~callback | |
| * @param {TroikaTextRenderInfo} textRenderInfo | |
| */ | |
| /** | |
| * Main entry point for requesting the data needed to render a text string with given font parameters. | |
| * This is an asynchronous call, performing most of the logic in a web worker thread. | |
| * @param {TypesetParams} args | |
| * @param {getTextRenderInfo~callback} callback | |
| */ | |
| function getTextRenderInfo(args, callback) { | |
| hasRequested = true | |
| args = assign({}, args) | |
| const totalStart = now() | |
| // Convert relative URL to absolute so it can be resolved in the worker, and add fallbacks. | |
| // In the future we'll allow args.font to be a list with unicode ranges too. | |
| const { defaultFontURL } = CONFIG | |
| const fonts = []; | |
| if (defaultFontURL) { | |
| fonts.push({label: 'default', src: toAbsoluteURL(defaultFontURL)}) | |
| } | |
| if (args.font) { | |
| fonts.push({label: 'user', src: toAbsoluteURL(args.font)}) | |
| } | |
| args.font = fonts | |
| // Normalize text to a string | |
| args.text = '' + args.text | |
| args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize | |
| args.unicodeFontsURL = args.unicodeFontsURL || CONFIG.unicodeFontsURL | |
| // Normalize colors | |
| if (args.colorRanges != null) { | |
| let colors = {} | |
| for (let key in args.colorRanges) { | |
| if (args.colorRanges.hasOwnProperty(key)) { | |
| let val = args.colorRanges[key] | |
| if (typeof val !== 'number') { | |
| val = tempColor.set(val).getHex() | |
| } | |
| colors[key] = val | |
| } | |
| } | |
| args.colorRanges = colors | |
| } | |
| Object.freeze(args) | |
| // Init the atlas if needed | |
| const {textureWidth, sdfExponent} = CONFIG | |
| const {sdfGlyphSize} = args | |
| const glyphsPerRow = (textureWidth / sdfGlyphSize * 4) | |
| let atlas = atlases[sdfGlyphSize] | |
| if (!atlas) { | |
| const canvas = document.createElement('canvas') | |
| canvas.width = textureWidth | |
| canvas.height = sdfGlyphSize * 256 / glyphsPerRow // start tall enough to fit 256 glyphs | |
| atlas = atlases[sdfGlyphSize] = { | |
| glyphCount: 0, | |
| sdfGlyphSize, | |
| sdfCanvas: canvas, | |
| sdfTexture: new Texture( | |
| canvas, | |
| undefined, | |
| undefined, | |
| undefined, | |
| LinearFilter, | |
| LinearFilter | |
| ), | |
| contextLost: false, | |
| glyphsByFont: new Map() | |
| } | |
| atlas.sdfTexture.generateMipmaps = false | |
| initContextLossHandling(atlas) | |
| } | |
| const {sdfTexture, sdfCanvas} = atlas | |
| // Issue request to the typesetting engine in the worker | |
| const typeset = CONFIG.useWorker ? typesetInWorker : typesetOnMainThread | |
| typeset(args).then(result => { | |
| const {glyphIds, glyphFontIndices, fontData, glyphPositions, fontSize, timings} = result | |
| const neededSDFs = [] | |
| const glyphBounds = new Float32Array(glyphIds.length * 4) | |
| let boundsIdx = 0 | |
| let positionsIdx = 0 | |
| const quadsStart = now() | |
| const fontGlyphMaps = fontData.map(font => { | |
| let map = atlas.glyphsByFont.get(font.src) | |
| if (!map) { | |
| atlas.glyphsByFont.set(font.src, map = new Map()) | |
| } | |
| return map | |
| }) | |
| glyphIds.forEach((glyphId, i) => { | |
| const fontIndex = glyphFontIndices[i] | |
| const {src: fontSrc, unitsPerEm} = fontData[fontIndex] | |
| let glyphInfo = fontGlyphMaps[fontIndex].get(glyphId) | |
| // If this is a glyphId not seen before, add it to the atlas | |
| if (!glyphInfo) { | |
| const {path, pathBounds} = result.glyphData[fontSrc][glyphId] | |
| // Margin around path edges in SDF, based on a percentage of the glyph's max dimension. | |
| // Note we add an extra 0.5 px over the configured value because the outer 0.5 doesn't contain | |
| // useful interpolated values and will be ignored anyway. | |
| const fontUnitsMargin = Math.max(pathBounds[2] - pathBounds[0], pathBounds[3] - pathBounds[1]) | |
| / sdfGlyphSize * (CONFIG.sdfMargin * sdfGlyphSize + 0.5) | |
| const atlasIndex = atlas.glyphCount++ | |
| const sdfViewBox = [ | |
| pathBounds[0] - fontUnitsMargin, | |
| pathBounds[1] - fontUnitsMargin, | |
| pathBounds[2] + fontUnitsMargin, | |
| pathBounds[3] + fontUnitsMargin, | |
| ] | |
| fontGlyphMaps[fontIndex].set(glyphId, (glyphInfo = { path, atlasIndex, sdfViewBox })) | |
| // Collect those that need SDF generation | |
| neededSDFs.push(glyphInfo) | |
| } | |
| // Calculate bounds for renderable quads | |
| // TODO can we get this back off the main thread? | |
| const {sdfViewBox} = glyphInfo | |
| const posX = glyphPositions[positionsIdx++] | |
| const posY = glyphPositions[positionsIdx++] | |
| const fontSizeMult = fontSize / unitsPerEm | |
| glyphBounds[boundsIdx++] = posX + sdfViewBox[0] * fontSizeMult | |
| glyphBounds[boundsIdx++] = posY + sdfViewBox[1] * fontSizeMult | |
| glyphBounds[boundsIdx++] = posX + sdfViewBox[2] * fontSizeMult | |
| glyphBounds[boundsIdx++] = posY + sdfViewBox[3] * fontSizeMult | |
| // Convert glyphId to SDF index for the shader | |
| glyphIds[i] = glyphInfo.atlasIndex | |
| }) | |
| timings.quads = (timings.quads || 0) + (now() - quadsStart) | |
| const sdfStart = now() | |
| timings.sdf = {} | |
| // Grow the texture height by power of 2 if needed | |
| const currentHeight = sdfCanvas.height | |
| const neededRows = Math.ceil(atlas.glyphCount / glyphsPerRow) | |
| const neededHeight = Math.pow(2, Math.ceil(Math.log2(neededRows * sdfGlyphSize))) | |
| if (neededHeight > currentHeight) { | |
| // Since resizing the canvas clears its render buffer, it needs special handling to copy the old contents over | |
| console.info(`Increasing SDF texture size ${currentHeight}->${neededHeight}`) | |
| resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight) | |
| // As of Three r136 textures cannot be resized once they're allocated on the GPU, we must dispose to reallocate it | |
| sdfTexture.dispose() | |
| } | |
| Promise.all(neededSDFs.map(glyphInfo => | |
| generateGlyphSDF(glyphInfo, atlas, args.gpuAccelerateSDF).then(({timing}) => { | |
| timings.sdf[glyphInfo.atlasIndex] = timing | |
| }) | |
| )).then(() => { | |
| if (neededSDFs.length && !atlas.contextLost) { | |
| safariPre15Workaround(atlas) | |
| sdfTexture.needsUpdate = true | |
| } | |
| timings.sdfTotal = now() - sdfStart | |
| timings.total = now() - totalStart | |
| // console.log(`SDF - ${timings.sdfTotal}, Total - ${timings.total - timings.fontLoad}`) | |
| // Invoke callback with the text layout arrays and updated texture | |
| callback(Object.freeze({ | |
| parameters: args, | |
| sdfTexture, | |
| sdfGlyphSize, | |
| sdfExponent, | |
| glyphBounds, | |
| glyphAtlasIndices: glyphIds, | |
| glyphColors: result.glyphColors, | |
| caretPositions: result.caretPositions, | |
| chunkedBounds: result.chunkedBounds, | |
| ascender: result.ascender, | |
| descender: result.descender, | |
| lineHeight: result.lineHeight, | |
| capHeight: result.capHeight, | |
| xHeight: result.xHeight, | |
| topBaseline: result.topBaseline, | |
| blockBounds: result.blockBounds, | |
| visibleBounds: result.visibleBounds, | |
| timings: result.timings, | |
| })) | |
| }) | |
| }) | |
| // While the typesetting request is being handled, go ahead and make sure the atlas canvas context is | |
| // "warmed up"; the first request will be the longest due to shader program compilation so this gets | |
| // a head start on that process before SDFs actually start getting processed. | |
| Promise.resolve().then(() => { | |
| if (!atlas.contextLost) { | |
| warmUpSDFCanvas(sdfCanvas) | |
| } | |
| }) | |
| } | |
| function generateGlyphSDF({path, atlasIndex, sdfViewBox}, {sdfGlyphSize, sdfCanvas, contextLost}, useGPU) { | |
| if (contextLost) { | |
| // If the context is lost there's nothing we can do, just quit silently and let it | |
| // get regenerated when the context is restored | |
| return Promise.resolve({timing: -1}) | |
| } | |
| const {textureWidth, sdfExponent} = CONFIG | |
| const maxDist = Math.max(sdfViewBox[2] - sdfViewBox[0], sdfViewBox[3] - sdfViewBox[1]) | |
| const squareIndex = Math.floor(atlasIndex / 4) | |
| const x = squareIndex % (textureWidth / sdfGlyphSize) * sdfGlyphSize | |
| const y = Math.floor(squareIndex / (textureWidth / sdfGlyphSize)) * sdfGlyphSize | |
| const channel = atlasIndex % 4 | |
| return generateSDF(sdfGlyphSize, sdfGlyphSize, path, sdfViewBox, maxDist, sdfExponent, sdfCanvas, x, y, channel, useGPU) | |
| } | |
| function initContextLossHandling(atlas) { | |
| const canvas = atlas.sdfCanvas | |
| /* | |
| // Begin context loss simulation | |
| if (!window.WebGLDebugUtils) { | |
| let script = document.getElementById('WebGLDebugUtilsScript') | |
| if (!script) { | |
| script = document.createElement('script') | |
| script.id = 'WebGLDebugUtils' | |
| document.head.appendChild(script) | |
| script.src = 'https://cdn.jsdelivr.net/gh/KhronosGroup/WebGLDeveloperTools@b42e702/src/debug/webgl-debug.js' | |
| } | |
| script.addEventListener('load', () => { | |
| initContextLossHandling(atlas) | |
| }) | |
| return | |
| } | |
| window.WebGLDebugUtils.makeLostContextSimulatingCanvas(canvas) | |
| canvas.loseContextInNCalls(500) | |
| canvas.addEventListener('webglcontextrestored', (event) => { | |
| canvas.loseContextInNCalls(5000) | |
| }) | |
| // End context loss simulation | |
| */ | |
| canvas.addEventListener('webglcontextlost', (event) => { | |
| console.log('Context Lost', event) | |
| event.preventDefault() | |
| atlas.contextLost = true | |
| }) | |
| canvas.addEventListener('webglcontextrestored', (event) => { | |
| console.log('Context Restored', event) | |
| atlas.contextLost = false | |
| // Regenerate all glyphs into the restored canvas: | |
| const promises = [] | |
| atlas.glyphsByFont.forEach(glyphMap => { | |
| glyphMap.forEach(glyph => { | |
| promises.push(generateGlyphSDF(glyph, atlas, true)) | |
| }) | |
| }) | |
| Promise.all(promises).then(() => { | |
| safariPre15Workaround(atlas) | |
| atlas.sdfTexture.needsUpdate = true | |
| }) | |
| }) | |
| } | |
| /** | |
| * Preload a given font and optionally pre-generate glyph SDFs for one or more character sequences. | |
| * This can be useful to avoid long pauses when first showing text in a scene, by preloading the | |
| * needed fonts and glyphs up front along with other assets. | |
| * | |
| * @param {object} options | |
| * @param {string} options.font - URL of the font file to preload. If not given, the default font will | |
| * be loaded. | |
| * @param {string|string[]} options.characters - One or more character sequences for which to pre- | |
| * generate glyph SDFs. Note that this will honor ligature substitution, so you may need | |
| * to specify ligature sequences in addition to their individual characters to get all | |
| * possible glyphs, e.g. `["t", "h", "th"]` to get the "t" and "h" glyphs plus the "th" ligature. | |
| * @param {number} options.sdfGlyphSize - The size at which to prerender the SDF textures for the | |
| * specified `characters`. | |
| * @param {function} callback - A function that will be called when the preloading is complete. | |
| */ | |
| function preloadFont({font, characters, sdfGlyphSize}, callback) { | |
| let text = Array.isArray(characters) ? characters.join('\n') : '' + characters | |
| getTextRenderInfo({ font, sdfGlyphSize, text }, callback) | |
| } | |
| // Local assign impl so we don't have to import troika-core | |
| function assign(toObj, fromObj) { | |
| for (let key in fromObj) { | |
| if (fromObj.hasOwnProperty(key)) { | |
| toObj[key] = fromObj[key] | |
| } | |
| } | |
| return toObj | |
| } | |
| // Utility for making URLs absolute | |
| let linkEl | |
| function toAbsoluteURL(path) { | |
| if (!linkEl) { | |
| linkEl = typeof document === 'undefined' ? {} : document.createElement('a') | |
| } | |
| linkEl.href = path | |
| return linkEl.href | |
| } | |
| /** | |
| * Safari < v15 seems unable to use the SDF webgl canvas as a texture. This applies a workaround | |
| * where it reads the pixels out of that canvas and uploads them as a data texture instead, at | |
| * a slight performance cost. | |
| */ | |
| function safariPre15Workaround(atlas) { | |
| // Use createImageBitmap support as a proxy for Safari<15, all other mainstream browsers | |
| // have supported it for a long while so any false positives should be minimal. | |
| if (typeof createImageBitmap !== 'function') { | |
| console.info('Safari<15: applying SDF canvas workaround') | |
| const {sdfCanvas, sdfTexture} = atlas | |
| const {width, height} = sdfCanvas | |
| const gl = atlas.sdfCanvas.getContext('webgl') | |
| let pixels = sdfTexture.image.data | |
| if (!pixels || pixels.length !== width * height * 4) { | |
| pixels = new Uint8Array(width * height * 4) | |
| sdfTexture.image = {width, height, data: pixels} | |
| sdfTexture.flipY = false | |
| sdfTexture.isDataTexture = true | |
| } | |
| gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels) | |
| } | |
| } | |
| const typesetterWorkerModule = /*#__PURE__*/defineWorkerModule({ | |
| name: 'Typesetter', | |
| dependencies: [ | |
| createTypesetter, | |
| fontResolverWorkerModule, | |
| bidiFactory, | |
| ], | |
| init(createTypesetter, fontResolver, bidiFactory) { | |
| return createTypesetter(fontResolver, bidiFactory()) | |
| } | |
| }) | |
| const typesetInWorker = /*#__PURE__*/defineWorkerModule({ | |
| name: 'Typesetter', | |
| dependencies: [ | |
| typesetterWorkerModule, | |
| ], | |
| init(typesetter) { | |
| return function(args) { | |
| return new Promise(resolve => { | |
| typesetter.typeset(args, resolve) | |
| }) | |
| } | |
| }, | |
| getTransferables(result) { | |
| // Mark array buffers as transferable to avoid cloning during postMessage | |
| const transferables = [] | |
| for (let p in result) { | |
| if (result[p] && result[p].buffer) { | |
| transferables.push(result[p].buffer) | |
| } | |
| } | |
| return transferables | |
| } | |
| }) | |
| const typesetOnMainThread = typesetInWorker.onMainThread | |
| function dumpSDFTextures() { | |
| Object.keys(atlases).forEach(size => { | |
| const canvas = atlases[size].sdfCanvas | |
| const {width, height} = canvas | |
| console.log("%c.", ` | |
| background: url(${canvas.toDataURL()}); | |
| background-size: ${width}px ${height}px; | |
| color: transparent; | |
| font-size: 0; | |
| line-height: ${height}px; | |
| padding-left: ${width}px; | |
| `) | |
| }) | |
| } | |
| export { | |
| configureTextBuilder, | |
| getTextRenderInfo, | |
| preloadFont, | |
| typesetterWorkerModule, | |
| dumpSDFTextures | |
| } | |
Xet Storage Details
- Size:
- 20.4 kB
- Xet hash:
- 8a354a8e274bcea79531b450003020423d370779b4e5f82d5a0bee0189056fdb
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.