diff --git a/packages/troika-3d-text/README.md b/packages/troika-3d-text/README.md index 5a2bb9b2..3ca6db38 100644 --- a/packages/troika-3d-text/README.md +++ b/packages/troika-3d-text/README.md @@ -195,6 +195,12 @@ Defines how text wraps if the `whiteSpace` property is `'normal'`. Can be either Default: `'normal'` +#### `sdfGlyphSize` + +Allows overriding the default size of each glyph's SDF (signed distance field) used when rendering this text instance. This must be a power-of-two number. 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 increased memory footprint and longer SDF generation time. + +Default: `64` + #### `textAlign` The horizontal alignment of each line of text within the overall text bounding box. Can be one of `'left'`, `'right'`, `'center'`, or `'justify'`. diff --git a/packages/troika-3d-text/src/FontProcessor.js b/packages/troika-3d-text/src/FontProcessor.js index d80cf3b5..90215af7 100644 --- a/packages/troika-3d-text/src/FontProcessor.js +++ b/packages/troika-3d-text/src/FontProcessor.js @@ -45,10 +45,10 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { /** * @private - * Holds the loaded data for all fonts + * Holds data about font glyphs and how they relate to SDF atlases * * { - * fontUrl: { + * 'fontUrl@sdfSize': { * fontObj: {}, //result of the fontParser * glyphs: { * [glyphIndex]: { @@ -62,6 +62,11 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { * } * } */ + const fontAtlases = Object.create(null) + + /** + * Holds parsed font objects by url + */ const fonts = Object.create(null) const INF = Infinity @@ -112,23 +117,20 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { */ function loadFont(fontUrl, callback) { if (!fontUrl) fontUrl = defaultFontUrl - let atlas = fonts[fontUrl] - if (atlas) { + let font = fonts[fontUrl] + if (font) { // if currently loading font, add to callbacks, otherwise execute immediately - if (atlas.onload) { - atlas.onload.push(callback) + if (font.pending) { + font.pending.push(callback) } else { - callback() + callback(font) } } else { - const loadingAtlas = fonts[fontUrl] = {onload: [callback]} + fonts[fontUrl] = {pending: [callback]} doLoadFont(fontUrl, fontObj => { - atlas = fonts[fontUrl] = { - fontObj: fontObj, - glyphs: {}, - glyphCount: 0 - } - loadingAtlas.onload.forEach(cb => cb()) + let callbacks = fonts[fontUrl].pending + fonts[fontUrl] = fontObj + callbacks.forEach(cb => cb(fontObj)) }) } } @@ -138,11 +140,22 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { * Get the atlas data for a given font url, loading it from the network and initializing * its atlas data objects if necessary. */ - function getSdfAtlas(fontUrl, callback) { + function getSdfAtlas(fontUrl, sdfGlyphSize, callback) { if (!fontUrl) fontUrl = defaultFontUrl - loadFont(fontUrl, () => { - callback(fonts[fontUrl]) - }) + let atlasKey = `${fontUrl}@${sdfGlyphSize}` + let atlas = fontAtlases[atlasKey] + if (atlas) { + callback(atlas) + } else { + loadFont(fontUrl, fontObj => { + atlas = fontAtlases[atlasKey] || (fontAtlases[atlasKey] = { + fontObj: fontObj, + glyphs: {}, + glyphCount: 0 + }) + callback(atlas) + }) + } } @@ -155,6 +168,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { { text='', font=defaultFontUrl, + sdfGlyphSize=64, fontSize=1, letterSpacing=0, lineHeight='normal', @@ -186,7 +200,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { maxWidth = +maxWidth lineHeight = lineHeight || 'normal' - getSdfAtlas(font, atlas => { + getSdfAtlas(font, sdfGlyphSize, atlas => { const fontObj = atlas.fontObj const hasMaxWidth = isFinite(maxWidth) let newGlyphs = null @@ -435,7 +449,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) { let glyphAtlasInfo = atlas.glyphs[glyphObj.index] if (!glyphAtlasInfo) { const sdfStart = now() - const glyphSDFData = sdfGenerator(glyphObj) + const glyphSDFData = sdfGenerator(glyphObj, sdfGlyphSize) timings.sdf[text.charAt(glyphInfo.charIndex)] = now() - sdfStart // Assign this glyph the next available atlas index diff --git a/packages/troika-3d-text/src/SDFGenerator.js b/packages/troika-3d-text/src/SDFGenerator.js index b9fea7ed..969d9e97 100644 --- a/packages/troika-3d-text/src/SDFGenerator.js +++ b/packages/troika-3d-text/src/SDFGenerator.js @@ -1,15 +1,12 @@ /** * Initializes and returns a function to generate an SDF texture for a given glyph. * @param {function} createGlyphSegmentsQuadtree - factory for a GlyphSegmentsQuadtree implementation. - * @param {number} config.sdfTextureSize - the length of one side of the resulting texture image. - * Larger images encode more details. Should be a power of 2. * @param {number} config.sdfDistancePercent - see docs for SDF_DISTANCE_PERCENT in TextBuilder.js * * @return {function(Object): {renderingBounds: [minX, minY, maxX, maxY], textureData: Uint8Array}} */ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { const { - sdfTextureSize, sdfDistancePercent } = config @@ -45,12 +42,14 @@ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { /** * Generate an SDF texture segment for a single glyph. * @param {object} glyphObj + * @param {number} sdfSize - the length of one side of the SDF image. + * Larger images encode more details. Must be a power of 2. * @return {{textureData: Uint8Array, renderingBounds: *[]}} */ - function generateSDF(glyphObj) { + function generateSDF(glyphObj, sdfSize) { //console.time('glyphSDF') - const textureData = new Uint8Array(sdfTextureSize * sdfTextureSize) + const textureData = new Uint8Array(sdfSize * sdfSize) // Determine mapping between glyph grid coords and sdf grid coords const glyphW = glyphObj.xMax - glyphObj.xMin @@ -60,8 +59,8 @@ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { const fontUnitsMaxDist = Math.max(glyphW, glyphH) * sdfDistancePercent // Use that, extending to the texture edges, to find conversion ratios between texture units and font units - const fontUnitsPerXTexel = (glyphW + fontUnitsMaxDist * 2) / sdfTextureSize - const fontUnitsPerYTexel = (glyphH + fontUnitsMaxDist * 2) / sdfTextureSize + const fontUnitsPerXTexel = (glyphW + fontUnitsMaxDist * 2) / sdfSize + const fontUnitsPerYTexel = (glyphH + fontUnitsMaxDist * 2) / sdfSize const textureMinFontX = glyphObj.xMin - fontUnitsMaxDist - fontUnitsPerXTexel const textureMinFontY = glyphObj.yMin - fontUnitsMaxDist - fontUnitsPerYTexel @@ -69,11 +68,11 @@ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { const textureMaxFontY = glyphObj.yMax + fontUnitsMaxDist + fontUnitsPerYTexel function textureXToFontX(x) { - return textureMinFontX + (textureMaxFontX - textureMinFontX) * x / sdfTextureSize + return textureMinFontX + (textureMaxFontX - textureMinFontX) * x / sdfSize } function textureYToFontY(y) { - return textureMinFontY + (textureMaxFontY - textureMinFontY) * y / sdfTextureSize + return textureMinFontY + (textureMaxFontY - textureMinFontY) * y / sdfSize } if (glyphObj.pathCommandCount) { //whitespace chars will have no commands, so we can skip all this @@ -134,8 +133,8 @@ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { // For each target SDF texel, find the distance from its center to its nearest line segment, // map that distance to an alpha value, and write that alpha to the texel - for (let sdfX = 0; sdfX < sdfTextureSize; sdfX++) { - for (let sdfY = 0; sdfY < sdfTextureSize; sdfY++) { + for (let sdfX = 0; sdfX < sdfSize; sdfX++) { + for (let sdfY = 0; sdfY < sdfSize; sdfY++) { const signedDist = lineSegmentsIndex.findNearestSignedDistance( textureXToFontX(sdfX + 0.5), textureYToFontY(sdfY + 0.5), @@ -144,7 +143,7 @@ function createSDFGenerator(createGlyphSegmentsQuadtree, config) { //if (!isFinite(signedDist)) throw 'infinite distance!' let alpha = isFinite(signedDist) ? Math.round(255 * (1 + signedDist / fontUnitsMaxDist) * 0.5) : signedDist alpha = Math.max(0, Math.min(255, alpha)) //clamp - textureData[sdfY * sdfTextureSize + sdfX] = alpha + textureData[sdfY * sdfSize + sdfX] = alpha } } } diff --git a/packages/troika-3d-text/src/TextBuilder.js b/packages/troika-3d-text/src/TextBuilder.js index 595dd930..e385d962 100644 --- a/packages/troika-3d-text/src/TextBuilder.js +++ b/packages/troika-3d-text/src/TextBuilder.js @@ -24,8 +24,9 @@ let hasRequested = false * @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 {Number} config.sdfGlyphSize - The size of each glyph's SDF (signed distance field) texture - * that is used for rendering. Must be a power-of-two number, and applies to all fonts. + * @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. @@ -71,7 +72,7 @@ const atlases = Object.create(null) * @typedef {object} TroikaTextRenderInfo - Format of the result from `getTextRenderInfo`. * @property {object} parameters - The normalized input arguments to the render call. * @property {DataTexture} sdfTexture - The SDF atlas texture. - * @property {number} sdfGlyphSize - See `configureTextBuilder#config.sdfGlyphSize` + * @property {number} sdfGlyphSize - The size of each glyph's SDF. * @property {number} sdfMinDistancePercent - See `SDF_DISTANCE_PERCENT` * @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. @@ -115,6 +116,8 @@ function getTextRenderInfo(args, callback) { // Normalize text to a string args.text = '' + args.text + args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize + // Normalize colors if (args.colorRanges != null) { let colors = {} @@ -133,10 +136,12 @@ function getTextRenderInfo(args, callback) { Object.freeze(args) // Init the atlas for this font if needed - const {sdfGlyphSize, textureWidth} = CONFIG - let atlas = atlases[args.font] + const {textureWidth} = CONFIG + const {sdfGlyphSize} = args + let atlasKey = `${args.font}@${sdfGlyphSize}` + let atlas = atlases[atlasKey] if (!atlas) { - atlas = atlases[args.font] = { + atlas = atlases[atlasKey] = { sdfTexture: new DataTexture( new Uint8Array(sdfGlyphSize * textureWidth), textureWidth, @@ -261,7 +266,6 @@ const fontProcessorWorkerModule = defineWorkerModule({ const sdfGenerator = createSDFGenerator( createGlyphSegmentsQuadtree, { - sdfTextureSize: config.sdfGlyphSize, sdfDistancePercent } ) diff --git a/packages/troika-3d-text/src/facade/Text3DFacade.js b/packages/troika-3d-text/src/facade/Text3DFacade.js index 7fcbf7eb..ddc3652a 100644 --- a/packages/troika-3d-text/src/facade/Text3DFacade.js +++ b/packages/troika-3d-text/src/facade/Text3DFacade.js @@ -22,6 +22,7 @@ const TEXT_MESH_PROPS = [ 'clipRect', 'orientation', 'glyphGeometryDetail', + 'sdfGlyphSize', 'debugSDF' ] diff --git a/packages/troika-3d-text/src/three/TextMesh.js b/packages/troika-3d-text/src/three/TextMesh.js index f41bf4fa..127ab770 100644 --- a/packages/troika-3d-text/src/three/TextMesh.js +++ b/packages/troika-3d-text/src/three/TextMesh.js @@ -45,7 +45,8 @@ const SYNCABLE_PROPS = [ 'whiteSpace', 'anchorX', 'anchorY', - 'colorRanges' + 'colorRanges', + 'sdfGlyphSize' ] const COPYABLE_PROPS = SYNCABLE_PROPS.concat( @@ -233,6 +234,16 @@ class TextMesh extends Mesh { */ this.glyphGeometryDetail = 1 + /** + * @member {number|null} sdfGlyphSize + * The size of each glyph's SDF (signed distance field) used for rendering. This must be a + * power-of-two number. Defaults to 64 which is generally a good balance of size and quality + * for most fonts. 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 + * increased memory footprint and longer SDF generation time. + */ + this.sdfGlyphSize = null + this.debugSDF = false } @@ -266,7 +277,8 @@ class TextMesh extends Mesh { anchorX: this.anchorX, anchorY: this.anchorY, colorRanges: this.colorRanges, - includeCaretPositions: true //TODO parameterize + includeCaretPositions: true, //TODO parameterize + sdfGlyphSize: this.sdfGlyphSize }, textRenderInfo => { this._isSyncing = false diff --git a/packages/troika-examples/text/TextExample.jsx b/packages/troika-examples/text/TextExample.jsx index 5d6f5900..791ec2b0 100644 --- a/packages/troika-examples/text/TextExample.jsx +++ b/packages/troika-examples/text/TextExample.jsx @@ -18,11 +18,12 @@ import { ExampleConfigurator } from '../_shared/ExampleConfigurator.js' const FONTS = { 'Roboto': 'https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxM.woff', 'Noto Sans': 'https://fonts.gstatic.com/s/notosans/v7/o-0IIpQlx3QUlC5A4PNr5TRG.woff', - //too thin: 'Alex Brush': 'https://fonts.gstatic.com/s/alexbrush/v8/SZc83FzrJKuqFbwMKk6EhUXz6w.woff', + 'Alex Brush': 'https://fonts.gstatic.com/s/alexbrush/v8/SZc83FzrJKuqFbwMKk6EhUXz6w.woff', 'Comfortaa': 'https://fonts.gstatic.com/s/comfortaa/v12/1Ptsg8LJRfWJmhDAuUs4TYFs.woff', 'Cookie': 'https://fonts.gstatic.com/s/cookie/v8/syky-y18lb0tSbf9kgqU.woff', 'Cutive Mono': 'https://fonts.gstatic.com/s/cutivemono/v6/m8JWjfRfY7WVjVi2E-K9H6RCTmg.woff', 'Gabriela': 'https://fonts.gstatic.com/s/gabriela/v6/qkBWXvsO6sreR8E-b8m5xL0.woff', + 'Monoton': 'https://fonts.gstatic.com/s/monoton/v9/5h1aiZUrOngCibe4fkU.woff', 'Philosopher': 'https://fonts.gstatic.com/s/philosopher/v9/vEFV2_5QCwIS4_Dhez5jcWBuT0s.woff', 'Quicksand': 'https://fonts.gstatic.com/s/quicksand/v7/6xKtdSZaM9iE8KbpRA_hK1QL.woff', 'Trirong': 'https://fonts.gstatic.com/s/trirong/v3/7r3GqXNgp8wxdOdOn4so3g.woff', @@ -106,6 +107,7 @@ class TextExample extends React.Component { shadows: false, selectable: false, colorRanges: false, + sdfGlyphSize: 6, debugSDF: false } @@ -190,6 +192,7 @@ class TextExample extends React.Component { scaleZ: state.textScale || 1, rotateX: 0, rotateZ: 0, + sdfGlyphSize: Math.pow(2, state.sdfGlyphSize), colorRanges: state.colorRanges ? TEXTS[state.text].split('').reduce((out, char, i) => { if (i === 0 || /\s/.test(char)) { out[i] = (Math.floor(Math.pow(Math.sin(i), 2) * 256) << 16) @@ -264,14 +267,15 @@ class TextExample extends React.Component { {type: 'boolean', path: "animRotate", label: "Rotate"}, {type: 'boolean', path: "fog", label: "Fog"}, {type: 'boolean', path: "shadows", label: "Shadows"}, - {type: 'boolean', path: "debugSDF", label: "Show SDF"}, {type: 'boolean', path: "colorRanges", label: "colorRanges (WIP)"}, {type: 'boolean', path: "selectable", label: "Selectable (WIP)"}, {type: 'number', path: "fontSize", label: "fontSize", min: 0.01, max: 0.2, step: 0.01}, {type: 'number', path: "textScale", label: "scale", min: 0.1, max: 10, step: 0.1}, {type: 'number', path: "maxWidth", min: 1, max: 5, step: 0.01}, {type: 'number', path: "lineHeight", min: 1, max: 2, step: 0.01}, - {type: 'number', path: "letterSpacing", min: -0.1, max: 0.5, step: 0.01} + {type: 'number', path: "letterSpacing", min: -0.1, max: 0.5, step: 0.01}, + {type: 'boolean', path: "debugSDF", label: "Show SDF"}, + {type: 'number', path: "sdfGlyphSize", label: 'SDF size (2^n):', min: 3, max: 8}, ] } ] }