Skip to content

Commit

Permalink
feat(troika-3d-text): add sdfGlyphSize option on TextMesh
Browse files Browse the repository at this point in the history
Implements #58. This allows controlling the resolution of each glyph's
SDF texture, per TextMesh instance. This can be useful when using a font
with very fine details, or when displaying text at very large scales
where certain artifacts like slightly rounded corners are more obvious.
  • Loading branch information
lojjic committed Jun 20, 2020
1 parent 89ed2f8 commit 978ef53
Show file tree
Hide file tree
Showing 7 changed files with 84 additions and 44 deletions.
6 changes: 6 additions & 0 deletions packages/troika-3d-text/README.md
Expand Up @@ -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'`.
Expand Down
54 changes: 34 additions & 20 deletions packages/troika-3d-text/src/FontProcessor.js
Expand Up @@ -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]: {
Expand All @@ -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
Expand Down Expand Up @@ -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))
})
}
}
Expand All @@ -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)
})
}
}


Expand All @@ -155,6 +168,7 @@ export function createFontProcessor(fontParser, sdfGenerator, config) {
{
text='',
font=defaultFontUrl,
sdfGlyphSize=64,
fontSize=1,
letterSpacing=0,
lineHeight='normal',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 11 additions & 12 deletions 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

Expand Down Expand Up @@ -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
Expand All @@ -60,20 +59,20 @@ 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
const textureMaxFontX = glyphObj.xMax + fontUnitsMaxDist + fontUnitsPerXTexel
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
Expand Down Expand Up @@ -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),
Expand All @@ -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
}
}
}
Expand Down
18 changes: 11 additions & 7 deletions packages/troika-3d-text/src/TextBuilder.js
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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,
Expand Down Expand Up @@ -261,7 +266,6 @@ const fontProcessorWorkerModule = defineWorkerModule({
const sdfGenerator = createSDFGenerator(
createGlyphSegmentsQuadtree,
{
sdfTextureSize: config.sdfGlyphSize,
sdfDistancePercent
}
)
Expand Down
1 change: 1 addition & 0 deletions packages/troika-3d-text/src/facade/Text3DFacade.js
Expand Up @@ -22,6 +22,7 @@ const TEXT_MESH_PROPS = [
'clipRect',
'orientation',
'glyphGeometryDetail',
'sdfGlyphSize',
'debugSDF'
]

Expand Down
16 changes: 14 additions & 2 deletions packages/troika-3d-text/src/three/TextMesh.js
Expand Up @@ -45,7 +45,8 @@ const SYNCABLE_PROPS = [
'whiteSpace',
'anchorX',
'anchorY',
'colorRanges'
'colorRanges',
'sdfGlyphSize'
]

const COPYABLE_PROPS = SYNCABLE_PROPS.concat(
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions packages/troika-examples/text/TextExample.jsx
Expand Up @@ -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',
Expand Down Expand Up @@ -106,6 +107,7 @@ class TextExample extends React.Component {
shadows: false,
selectable: false,
colorRanges: false,
sdfGlyphSize: 6,
debugSDF: false
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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},
]
}
] }
Expand Down

0 comments on commit 978ef53

Please sign in to comment.