From 08705de4573570ecf137b2ec8d82d958b0873e49 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:15:06 -0600 Subject: [PATCH 1/9] fix: vendor troika-three-text, resolve tree-shaking & *BufferGeometry deprecation --- package.json | 4 +- src/core/{Text.tsx => Text/index.tsx} | 2 +- .../Text/troika-three-text/DerivedMaterial.js | 396 +++++++++ src/core/Text/troika-three-text/FontParser.js | 292 +++++++ .../Text/troika-three-text/GlyphsGeometry.js | 248 ++++++ .../Text/troika-three-text/SDFGenerator.js | 133 ++++ src/core/Text/troika-three-text/Text.js | 753 ++++++++++++++++++ .../Text/troika-three-text/TextBuilder.js | 425 ++++++++++ .../troika-three-text/TextDerivedMaterial.js | 284 +++++++ src/core/Text/troika-three-text/Typesetter.js | 652 +++++++++++++++ src/core/Text/troika-three-text/index.js | 3 + .../troika-three-text/libs/typr.factory.js | 6 + .../libs/woff2otf.factory.js | 9 + .../Text/troika-three-text/selectionUtils.js | 144 ++++ src/core/Text/troika-three-text/woff2otf.js | 152 ++++ yarn.lock | 17 +- 16 files changed, 3502 insertions(+), 18 deletions(-) rename src/core/{Text.tsx => Text/index.tsx} (97%) create mode 100644 src/core/Text/troika-three-text/DerivedMaterial.js create mode 100644 src/core/Text/troika-three-text/FontParser.js create mode 100644 src/core/Text/troika-three-text/GlyphsGeometry.js create mode 100644 src/core/Text/troika-three-text/SDFGenerator.js create mode 100644 src/core/Text/troika-three-text/Text.js create mode 100644 src/core/Text/troika-three-text/TextBuilder.js create mode 100644 src/core/Text/troika-three-text/TextDerivedMaterial.js create mode 100644 src/core/Text/troika-three-text/Typesetter.js create mode 100644 src/core/Text/troika-three-text/index.js create mode 100644 src/core/Text/troika-three-text/libs/typr.factory.js create mode 100644 src/core/Text/troika-three-text/libs/woff2otf.factory.js create mode 100644 src/core/Text/troika-three-text/selectionUtils.js create mode 100644 src/core/Text/troika-three-text/woff2otf.js diff --git a/package.json b/package.json index 8acd2aef6..39a3b6953 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@babel/runtime": "^7.11.2", "@react-spring/three": "^9.3.1", "@use-gesture/react": "^10.2.0", + "bidi-js": "^1.0.2", "detect-gpu": "^4.0.36", "glsl-noise": "^0.0.0", "lodash.clamp": "^4.0.3", @@ -69,8 +70,9 @@ "suspend-react": "^0.0.8", "three-mesh-bvh": "^0.5.15", "three-stdlib": "^2.17.3", - "troika-three-text": "^0.46.4", + "troika-worker-utils": "^0.46.0", "utility-types": "^3.10.0", + "webgl-sdf-generator": "^1.1.1", "zustand": "^3.5.13" }, "devDependencies": { diff --git a/src/core/Text.tsx b/src/core/Text/index.tsx similarity index 97% rename from src/core/Text.tsx rename to src/core/Text/index.tsx index 0411b8c85..40c689290 100644 --- a/src/core/Text.tsx +++ b/src/core/Text/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Text as TextMeshImpl, preloadFont } from 'troika-three-text' +import { Text as TextMeshImpl, preloadFont } from './troika-three-text' import { ReactThreeFiber, useThree } from '@react-three/fiber' import { suspend } from 'suspend-react' diff --git a/src/core/Text/troika-three-text/DerivedMaterial.js b/src/core/Text/troika-three-text/DerivedMaterial.js new file mode 100644 index 000000000..4131f8091 --- /dev/null +++ b/src/core/Text/troika-three-text/DerivedMaterial.js @@ -0,0 +1,396 @@ +import { ShaderChunk, MeshDepthMaterial, MeshDistanceMaterial, RGBADepthPacking, UniformsUtils, MathUtils } from 'three' + +/** + * Regular expression for matching the `void main() {` opener line in GLSL. + * @type {RegExp} + */ +export const voidMainRegExp = /\bvoid\s+main\s*\(\s*\)\s*{/g + +/** + * Recursively expands all `#include ` statements within string of shader code. + * Copied from three's WebGLProgram#parseIncludes for external use. + * + * @param {string} source - The GLSL source code to evaluate + * @return {string} The GLSL code with all includes expanded + */ +export function expandShaderIncludes(source) { + const pattern = /^[ \t]*#include +<([\w\d./]+)>/gm + function replace(match, include) { + const chunk = ShaderChunk[include] + return chunk ? expandShaderIncludes(chunk) : match + } + return source.replace(pattern, replace) +} + +const epoch = Date.now() +const CONSTRUCTOR_CACHE = new WeakMap() +const SHADER_UPGRADE_CACHE = new Map() + +// Material ids must be integers, but we can't access the increment from Three's `Material` module, +// so let's choose a sufficiently large starting value that should theoretically never collide. +let materialInstanceId = 1e10 + +/** + * A utility for creating a custom shader material derived from another material's + * shaders. This allows you to inject custom shader logic and transforms into the + * builtin ThreeJS materials without having to recreate them from scratch. + * + * @param {THREE.Material} baseMaterial - the original material to derive from + * + * @param {Object} options - How the base material should be modified. + * @param {Object} options.defines - Custom `defines` for the material + * @param {Object} options.extensions - Custom `extensions` for the material, e.g. `{derivatives: true}` + * @param {Object} options.uniforms - Custom `uniforms` for use in the modified shader. These can + * be accessed and manipulated via the resulting material's `uniforms` property, just like + * in a ShaderMaterial. You do not need to repeat the base material's own uniforms here. + * @param {String} options.timeUniform - If specified, a uniform of this name will be injected into + * both shaders, and it will automatically be updated on each render frame with a number of + * elapsed milliseconds. The "zero" epoch time is not significant so don't rely on this as a + * true calendar time. + * @param {String} options.vertexDefs - Custom GLSL code to inject into the vertex shader's top-level + * definitions, above the `void main()` function. + * @param {String} options.vertexMainIntro - Custom GLSL code to inject at the top of the vertex + * shader's `void main` function. + * @param {String} options.vertexMainOutro - Custom GLSL code to inject at the end of the vertex + * shader's `void main` function. + * @param {String} options.vertexTransform - Custom GLSL code to manipulate the `position`, `normal`, + * and/or `uv` vertex attributes. This code will be wrapped within a standalone function with + * those attributes exposed by their normal names as read/write values. + * @param {String} options.fragmentDefs - Custom GLSL code to inject into the fragment shader's top-level + * definitions, above the `void main()` function. + * @param {String} options.fragmentMainIntro - Custom GLSL code to inject at the top of the fragment + * shader's `void main` function. + * @param {String} options.fragmentMainOutro - Custom GLSL code to inject at the end of the fragment + * shader's `void main` function. You can manipulate `gl_FragColor` here but keep in mind it goes + * after any of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), so if you + * want those to apply to your changes use `fragmentColorTransform` instead. + * @param {String} options.fragmentColorTransform - Custom GLSL code to manipulate the `gl_FragColor` + * output value. Will be injected near the end of the `void main` function, but before any + * of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), and before the + * `fragmentMainOutro`. + * @param {function<{vertexShader,fragmentShader}>:{vertexShader,fragmentShader}} options.customRewriter - A function + * for performing custom rewrites of the full shader code. Useful if you need to do something + * special that's not covered by the other builtin options. This function will be executed before + * any other transforms are applied. + * @param {boolean} options.chained - Set to `true` to prototype-chain the derived material to the base + * material, rather than the default behavior of copying it. This allows the derived material to + * automatically pick up changes made to the base material and its properties. This can be useful + * where the derived material is hidden from the user as an implementation detail, allowing them + * to work with the original material like normal. But it can result in unexpected behavior if not + * handled carefully. + * + * @return {THREE.Material} + * + * The returned material will also have two new methods, `getDepthMaterial()` and `getDistanceMaterial()`, + * which can be called to get a variant of the derived material for use in shadow casting. If the + * target mesh is expected to cast shadows, then you can assign these to the mesh's `customDepthMaterial` + * (for directional and spot lights) and/or `customDistanceMaterial` (for point lights) properties to + * allow the cast shadow to honor your derived shader's vertex transforms and discarded fragments. These + * will also set a custom `#define IS_DEPTH_MATERIAL` or `#define IS_DISTANCE_MATERIAL` that you can look + * for in your derived shaders with `#ifdef` to customize their behavior for the depth or distance + * scenarios, e.g. skipping antialiasing or expensive shader logic. + */ +export function createDerivedMaterial(baseMaterial, options) { + // Generate a key that is unique to the content of these `options`. We'll use this + // throughout for caching and for generating the upgraded shader code. This increases + // the likelihood that the resulting shaders will line up across multiple calls so + // their GL programs can be shared and cached. + const optionsKey = getKeyForOptions(options) + + // First check to see if we've already derived from this baseMaterial using this + // unique set of options, and if so reuse the constructor to avoid some allocations. + let ctorsByDerivation = CONSTRUCTOR_CACHE.get(baseMaterial) + if (!ctorsByDerivation) { + CONSTRUCTOR_CACHE.set(baseMaterial, (ctorsByDerivation = Object.create(null))) + } + if (ctorsByDerivation[optionsKey]) { + return new ctorsByDerivation[optionsKey]() + } + + const privateBeforeCompileProp = `_onBeforeCompile${optionsKey}` + + // Private onBeforeCompile handler that injects the modified shaders and uniforms when + // the renderer switches to this material's program + const onBeforeCompile = function (shaderInfo) { + baseMaterial.onBeforeCompile.call(this, shaderInfo) + + // Upgrade the shaders, caching the result by incoming source code + const cacheKey = this.customProgramCacheKey() + '|' + shaderInfo.vertexShader + '|' + shaderInfo.fragmentShader + let upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey] + if (!upgradedShaders) { + const upgraded = upgradeShaders(shaderInfo, options, optionsKey) + upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey] = upgraded + } + + // Inject upgraded shaders and uniforms into the program + shaderInfo.vertexShader = upgradedShaders.vertexShader + shaderInfo.fragmentShader = upgradedShaders.fragmentShader + Object.assign(shaderInfo.uniforms, this.uniforms) + + // Inject auto-updating time uniform if requested + if (options.timeUniform) { + shaderInfo.uniforms[options.timeUniform] = { + get value() { + return Date.now() - epoch + }, + } + } + + // Users can still add their own handlers on top of ours + if (this[privateBeforeCompileProp]) { + this[privateBeforeCompileProp](shaderInfo) + } + } + + const DerivedMaterial = function DerivedMaterial() { + return derive(options.chained ? baseMaterial : baseMaterial.clone()) + } + + const derive = function (base) { + // Prototype chain to the base material + const derived = Object.create(base, descriptor) + + // Store the baseMaterial for reference; this is always the original even when cloning + Object.defineProperty(derived, 'baseMaterial', { value: baseMaterial }) + + // Needs its own ids + Object.defineProperty(derived, 'id', { value: materialInstanceId++ }) + derived.uuid = MathUtils.generateUUID() + + // Merge uniforms, defines, and extensions + derived.uniforms = Object.assign({}, base.uniforms, options.uniforms) + derived.defines = Object.assign({}, base.defines, options.defines) + derived.defines[`TROIKA_DERIVED_MATERIAL_${optionsKey}`] = '' //force a program change from the base material + derived.extensions = Object.assign({}, base.extensions, options.extensions) + + // Don't inherit EventDispatcher listeners + derived._listeners = undefined + + return derived + } + + const descriptor = { + constructor: { value: DerivedMaterial }, + isDerivedMaterial: { value: true }, + + customProgramCacheKey: { + writable: true, + configurable: true, + value: function () { + return baseMaterial.customProgramCacheKey() + '|' + optionsKey + }, + }, + + onBeforeCompile: { + get() { + return onBeforeCompile + }, + set(fn) { + this[privateBeforeCompileProp] = fn + }, + }, + + copy: { + writable: true, + configurable: true, + value: function (source) { + baseMaterial.copy.call(this, source) + if (!baseMaterial.isShaderMaterial && !baseMaterial.isDerivedMaterial) { + Object.assign(this.extensions, source.extensions) + Object.assign(this.defines, source.defines) + Object.assign(this.uniforms, UniformsUtils.clone(source.uniforms)) + } + return this + }, + }, + + clone: { + writable: true, + configurable: true, + value: function () { + const newBase = new baseMaterial.constructor() + return derive(newBase).copy(this) + }, + }, + + /** + * Utility to get a MeshDepthMaterial that will honor this derived material's vertex + * transformations and discarded fragments. + */ + getDepthMaterial: { + writable: true, + configurable: true, + value: function () { + let depthMaterial = this._depthMaterial + if (!depthMaterial) { + depthMaterial = this._depthMaterial = createDerivedMaterial( + baseMaterial.isDerivedMaterial + ? baseMaterial.getDepthMaterial() + : new MeshDepthMaterial({ depthPacking: RGBADepthPacking }), + options + ) + depthMaterial.defines.IS_DEPTH_MATERIAL = '' + depthMaterial.uniforms = this.uniforms //automatically recieve same uniform values + } + return depthMaterial + }, + }, + + /** + * Utility to get a MeshDistanceMaterial that will honor this derived material's vertex + * transformations and discarded fragments. + */ + getDistanceMaterial: { + writable: true, + configurable: true, + value: function () { + let distanceMaterial = this._distanceMaterial + if (!distanceMaterial) { + distanceMaterial = this._distanceMaterial = createDerivedMaterial( + baseMaterial.isDerivedMaterial ? baseMaterial.getDistanceMaterial() : new MeshDistanceMaterial(), + options + ) + distanceMaterial.defines.IS_DISTANCE_MATERIAL = '' + distanceMaterial.uniforms = this.uniforms //automatically recieve same uniform values + } + return distanceMaterial + }, + }, + + dispose: { + writable: true, + configurable: true, + value() { + const { _depthMaterial, _distanceMaterial } = this + if (_depthMaterial) _depthMaterial.dispose() + if (_distanceMaterial) _distanceMaterial.dispose() + baseMaterial.dispose.call(this) + }, + }, + } + + ctorsByDerivation[optionsKey] = DerivedMaterial + return new DerivedMaterial() +} + +function upgradeShaders({ vertexShader, fragmentShader }, options, key) { + let { vertexDefs, vertexMainIntro, vertexMainOutro, fragmentDefs, fragmentMainIntro, fragmentMainOutro } = options + + vertexDefs = vertexDefs || '' + vertexMainIntro = vertexMainIntro || '' + vertexMainOutro = vertexMainOutro || '' + fragmentDefs = fragmentDefs || '' + fragmentMainIntro = fragmentMainIntro || '' + fragmentMainOutro = fragmentMainOutro || '' + + // Expand includes if needed + if (vertexTransform || customRewriter) { + vertexShader = expandShaderIncludes(vertexShader) + } + if (fragmentColorTransform || customRewriter) { + // We need to be able to find postprocessing chunks after include expansion in order to + // put them after the fragmentColorTransform, so mark them with comments first. Even if + // this particular derivation doesn't have a fragmentColorTransform, other derivations may, + // so we still mark them. + fragmentShader = fragmentShader.replace( + /^[ \t]*#include <((?:tonemapping|encodings|fog|premultiplied_alpha|dithering)_fragment)>/gm, + '\n//!BEGIN_POST_CHUNK $1\n$&\n//!END_POST_CHUNK\n' + ) + fragmentShader = expandShaderIncludes(fragmentShader) + } + + // Apply custom rewriter function + if (customRewriter) { + const res = customRewriter({ vertexShader, fragmentShader }) + vertexShader = res.vertexShader + fragmentShader = res.fragmentShader + } + + // The fragmentColorTransform needs to go before any postprocessing chunks, so extract + // those and re-insert them into the outro in the correct place: + if (fragmentColorTransform) { + const postChunks = [] + fragmentShader = fragmentShader.replace( + /^\/\/!BEGIN_POST_CHUNK[^]+?^\/\/!END_POST_CHUNK/gm, // [^]+? = non-greedy match of any chars including newlines + (match) => { + postChunks.push(match) + return '' + } + ) + fragmentMainOutro = `${fragmentColorTransform}\n${postChunks.join('\n')}\n${fragmentMainOutro}` + } + + // Inject auto-updating time uniform if requested + if (timeUniform) { + const code = `\nuniform float ${timeUniform};\n` + vertexDefs = code + vertexDefs + fragmentDefs = code + fragmentDefs + } + + // Inject a function for the vertexTransform and rename all usages of position/normal/uv + if (vertexTransform) { + // Hoist these defs to the very top so they work in other function defs + vertexShader = `vec3 troika_position_${key}; +vec3 troika_normal_${key}; +vec2 troika_uv_${key}; +${vertexShader} +` + vertexDefs = `${vertexDefs} +void troikaVertexTransform${key}(inout vec3 position, inout vec3 normal, inout vec2 uv) { + ${vertexTransform} +} +` + vertexMainIntro = ` +troika_position_${key} = vec3(position); +troika_normal_${key} = vec3(normal); +troika_uv_${key} = vec2(uv); +troikaVertexTransform${key}(troika_position_${key}, troika_normal_${key}, troika_uv_${key}); +${vertexMainIntro} +` + vertexShader = vertexShader.replace(/\b(position|normal|uv)\b/g, (match, match1, index, fullStr) => { + return /\battribute\s+vec[23]\s+$/.test(fullStr.substr(0, index)) ? match1 : `troika_${match1}_${key}` + }) + } + + // Inject defs and intro/outro snippets + vertexShader = injectIntoShaderCode(vertexShader, key, vertexDefs, vertexMainIntro, vertexMainOutro) + fragmentShader = injectIntoShaderCode(fragmentShader, key, fragmentDefs, fragmentMainIntro, fragmentMainOutro) + + return { + vertexShader, + fragmentShader, + } +} + +function injectIntoShaderCode(shaderCode, id, defs, intro, outro) { + if (intro || outro || defs) { + shaderCode = shaderCode.replace( + voidMainRegExp, + ` +${defs} +void troikaOrigMain${id}() {` + ) + shaderCode += ` +void main() { + ${intro} + troikaOrigMain${id}(); + ${outro} +}` + } + return shaderCode +} + +function optionsJsonReplacer(key, value) { + return key === 'uniforms' ? undefined : typeof value === 'function' ? value.toString() : value +} + +let _idCtr = 0 +const optionsHashesToIds = new Map() +function getKeyForOptions(options) { + const optionsHash = JSON.stringify(options, optionsJsonReplacer) + let id = optionsHashesToIds.get(optionsHash) + if (id == null) { + optionsHashesToIds.set(optionsHash, (id = ++_idCtr)) + } + return id +} diff --git a/src/core/Text/troika-three-text/FontParser.js b/src/core/Text/troika-three-text/FontParser.js new file mode 100644 index 000000000..7a9cb9236 --- /dev/null +++ b/src/core/Text/troika-three-text/FontParser.js @@ -0,0 +1,292 @@ +/** + * A factory wrapper parsing a font file using Typr. + * Also adds support for WOFF files (not WOFF2). + */ + +import typrFactory from './libs/typr.factory.js' +import woff2otfFactory from './libs/woff2otf.factory.js' +import { defineWorkerModule } from 'troika-worker-utils' + +function parserFactory(Typr, woff2otf) { + const cmdArgLengths = { + M: 2, + L: 2, + Q: 4, + C: 6, + Z: 0, + } + + // {joinType: "skip+step,..."} + const joiningTypeRawData = { + C: '18g,ca,368,1kz', + D: '17k,6,2,2+4,5+c,2+6,2+1,10+1,9+f,j+11,2+1,a,2,2+1,15+2,3,j+2,6+3,2+8,2,2,2+1,w+a,4+e,3+3,2,3+2,3+5,23+w,2f+4,3,2+9,2,b,2+3,3,1k+9,6+1,3+1,2+2,2+d,30g,p+y,1,1+1g,f+x,2,sd2+1d,jf3+4,f+3,2+4,2+2,b+3,42,2,4+2,2+1,2,3,t+1,9f+w,2,el+2,2+g,d+2,2l,2+1,5,3+1,2+1,2,3,6,16wm+1v', + R: '17m+3,2,2,6+3,m,15+2,2+2,h+h,13,3+8,2,2,3+1,2,p+1,x,5+4,5,a,2,2,3,u,c+2,g+1,5,2+1,4+1,5j,6+1,2,b,2+2,f,2+1,1s+2,2,3+1,7,1ez0,2,2+1,4+4,b,4,3,b,42,2+2,4,3,2+1,2,o+3,ae,ep,x,2o+2,3+1,3,5+1,6', + L: 'x9u,jff,a,fd,jv', + T: '4t,gj+33,7o+4,1+1,7c+18,2,2+1,2+1,2,21+a,2,1b+k,h,2u+6,3+5,3+1,2+3,y,2,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,3,7,6+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+d,1,1+1,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,ek,3+1,r+4,1e+4,6+5,2p+c,1+3,1,1+2,1+b,2db+2,3y,2p+v,ff+3,30+1,n9x,1+2,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,5s,6y+2,ea,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+9,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2,2b+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,470+8,at4+4,1o+6,t5,1s+3,2a,f5l+1,2+3,43o+2,a+7,1+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,1,gzau,v+2n,3l+6n', + } + + const JT_LEFT = 1, //indicates that a character joins with the subsequent character, but does not join with the preceding character. + JT_RIGHT = 2, //indicates that a character joins with the preceding character, but does not join with the subsequent character. + JT_DUAL = 4, //indicates that a character joins with the preceding character and joins with the subsequent character. + JT_TRANSPARENT = 8, //indicates that the character does not join with adjacent characters and that the character must be skipped over when the shaping engine is evaluating the joining positions in a sequence of characters. When a JT_TRANSPARENT character is encountered in a sequence, the JOINING_TYPE of the preceding character passes through. Diacritical marks are frequently assigned this value. + JT_JOIN_CAUSING = 16, //indicates that the character forces the use of joining forms with the preceding and subsequent characters. Kashidas and the Zero Width Joiner (U+200D) are both JOIN_CAUSING characters. + JT_NON_JOINING = 32 //indicates that a character does not join with the preceding or with the subsequent character., + + let joiningTypeMap + function getCharJoiningType(ch) { + if (!joiningTypeMap) { + const m = { + R: JT_RIGHT, + L: JT_LEFT, + D: JT_DUAL, + C: JT_JOIN_CAUSING, + U: JT_NON_JOINING, + T: JT_TRANSPARENT, + } + joiningTypeMap = new Map() + for (const type in joiningTypeRawData) { + let lastCode = 0 + joiningTypeRawData[type].split(',').forEach((range) => { + let [skip, step] = range.split('+') + skip = parseInt(skip, 36) + step = step ? parseInt(step, 36) : 0 + joiningTypeMap.set((lastCode += skip), m[type]) + for (let i = step; i--; ) { + joiningTypeMap.set(++lastCode, m[type]) + } + }) + } + } + return joiningTypeMap.get(ch) || JT_NON_JOINING + } + + const ISOL = 1, + INIT = 2, + FINA = 3, + MEDI = 4 + const formsToFeatures = [null, 'isol', 'init', 'fina', 'medi'] + + function detectJoiningForms(str) { + // This implements the algorithm described here: + // https://github.com/n8willis/opentype-shaping-documents/blob/master/opentype-shaping-arabic-general.md + const joiningForms = new Uint8Array(str.length) + let prevJoiningType = JT_NON_JOINING + let prevForm = ISOL + let prevIndex = -1 + for (let i = 0; i < str.length; i++) { + const code = str.codePointAt(i) + const joiningType = getCharJoiningType(code) | 0 + let form = ISOL + if (joiningType & JT_TRANSPARENT) { + continue + } + if (prevJoiningType & (JT_LEFT | JT_DUAL | JT_JOIN_CAUSING)) { + if (joiningType & (JT_RIGHT | JT_DUAL | JT_JOIN_CAUSING)) { + form = FINA + // isol->init, fina->medi + if (prevForm === ISOL || prevForm === FINA) { + joiningForms[prevIndex]++ + } + } else if (joiningType & (JT_LEFT | JT_NON_JOINING)) { + // medi->fina, init->isol + if (prevForm === INIT || prevForm === MEDI) { + joiningForms[prevIndex]-- + } + } + } else if (prevJoiningType & (JT_RIGHT | JT_NON_JOINING)) { + // medi->fina, init->isol + if (prevForm === INIT || prevForm === MEDI) { + joiningForms[prevIndex]-- + } + } + prevForm = joiningForms[i] = form + prevJoiningType = joiningType + prevIndex = i + if (code > 0xffff) i++ + } + // console.log(str.split('').map(ch => ch.codePointAt(0).toString(16))) + // console.log(str.split('').map(ch => getCharJoiningType(ch.codePointAt(0)))) + // console.log(Array.from(joiningForms).map(f => formsToFeatures[f] || 'none')) + return joiningForms + } + + function stringToGlyphs(font, str) { + const glyphIds = [] + for (let i = 0; i < str.length; i++) { + const cc = str.codePointAt(i) + if (cc > 0xffff) i++ + glyphIds.push(Typr.U.codeToGlyph(font, cc)) + } + + const gsub = font['GSUB'] + if (gsub) { + const { lookupList, featureList } = gsub + let joiningForms + const supportedFeatures = /^(rlig|liga|mset|isol|init|fina|medi|half|pres|blws)$/ + const usedLookups = [] + featureList.forEach((feature) => { + if (supportedFeatures.test(feature.tag)) { + for (let ti = 0; ti < feature.tab.length; ti++) { + if (usedLookups[feature.tab[ti]]) continue + usedLookups[feature.tab[ti]] = true + const tab = lookupList[feature.tab[ti]] + const isJoiningFeature = /^(isol|init|fina|medi)$/.test(feature.tag) + if (isJoiningFeature && !joiningForms) { + //lazy + joiningForms = detectJoiningForms(str) + } + for (let ci = 0; ci < glyphIds.length; ci++) { + if (!joiningForms || !isJoiningFeature || formsToFeatures[joiningForms[ci]] === feature.tag) { + Typr.U._applySubs(glyphIds, ci, tab, lookupList) + } + } + } + } + }) + } + + return glyphIds + } + + function firstNum(...args) { + for (let i = 0; i < args.length; i++) { + if (typeof args[i] === 'number') { + return args[i] + } + } + } + + function wrapFontObj(typrFont) { + const glyphMap = Object.create(null) + + const os2 = typrFont['OS/2'] + const hhea = typrFont.hhea + const unitsPerEm = typrFont.head.unitsPerEm + const ascender = firstNum(os2 && os2.sTypoAscender, hhea && hhea.ascender, unitsPerEm) + + const fontObj = { + unitsPerEm, + ascender, + descender: firstNum(os2 && os2.sTypoDescender, hhea && hhea.descender, 0), + capHeight: firstNum(os2 && os2.sCapHeight, ascender), + xHeight: firstNum(os2 && os2.sxHeight, ascender), + lineGap: firstNum(os2 && os2.sTypoLineGap, hhea && hhea.lineGap), + forEachGlyph(text, fontSize, letterSpacing, callback) { + let glyphX = 0 + const fontScale = (1 / fontObj.unitsPerEm) * fontSize + + const glyphIndices = stringToGlyphs(typrFont, text) + let charIndex = 0 + let prevGlyphIndex = -1 + glyphIndices.forEach((glyphIndex) => { + // Typr returns a glyph index per string codepoint, with -1s in place of those that + // were omitted due to ligature substitution. So we can track original index in the + // string via simple increment, and skip everything else when seeing a -1. + if (glyphIndex !== -1) { + let glyphObj = glyphMap[glyphIndex] + if (!glyphObj) { + const { cmds, crds } = Typr.U.glyphToPath(typrFont, glyphIndex) + + // Build path string + let path = '' + let crdsIdx = 0 + for (let i = 0, len = cmds.length; i < len; i++) { + const numArgs = cmdArgLengths[cmds[i]] + path += cmds[i] + for (let j = 1; j <= numArgs; j++) { + path += (j > 1 ? ',' : '') + crds[crdsIdx++] + } + } + + // Find extents - Glyf gives this in metadata but not CFF, and Typr doesn't + // normalize the two, so it's simplest just to iterate ourselves. + let xMin, yMin, xMax, yMax + if (crds.length) { + xMin = yMin = Infinity + xMax = yMax = -Infinity + for (let i = 0, len = crds.length; i < len; i += 2) { + const x = crds[i] + const y = crds[i + 1] + if (x < xMin) xMin = x + if (y < yMin) yMin = y + if (x > xMax) xMax = x + if (y > yMax) yMax = y + } + } else { + xMin = xMax = yMin = yMax = 0 + } + + glyphObj = glyphMap[glyphIndex] = { + index: glyphIndex, + advanceWidth: typrFont.hmtx.aWidth[glyphIndex], + xMin, + yMin, + xMax, + yMax, + path, + pathCommandCount: cmds.length, + // forEachPathCommand(callback) { + // let argsIndex = 0 + // const argsArray = [] + // for (let i = 0, len = cmds.length; i < len; i++) { + // const numArgs = cmdArgLengths[cmds[i]] + // argsArray.length = 1 + numArgs + // argsArray[0] = cmds[i] + // for (let j = 1; j <= numArgs; j++) { + // argsArray[j] = crds[argsIndex++] + // } + // callback.apply(null, argsArray) + // } + // } + } + } + + // Kerning + if (prevGlyphIndex !== -1) { + glyphX += Typr.U.getPairAdjustment(typrFont, prevGlyphIndex, glyphIndex) * fontScale + } + + callback.call(null, glyphObj, glyphX, charIndex) + + if (glyphObj.advanceWidth) { + glyphX += glyphObj.advanceWidth * fontScale + } + if (letterSpacing) { + glyphX += letterSpacing * fontSize + } + + prevGlyphIndex = glyphIndex + } + charIndex += text.codePointAt(charIndex) > 0xffff ? 2 : 1 + }) + return glyphX + }, + } + + return fontObj + } + + return function parse(buffer) { + // Look to see if we have a WOFF file and convert it if so: + const peek = new Uint8Array(buffer, 0, 4) + const tag = Typr._bin.readASCII(peek, 0, 4) + if (tag === 'wOFF') { + buffer = woff2otf(buffer) + } else if (tag === 'wOF2') { + throw new Error('woff2 fonts not supported') + } + return wrapFontObj(Typr.parse(buffer)[0]) + } +} + +const workerModule = /*#__PURE__*/ defineWorkerModule({ + name: 'Typr Font Parser', + dependencies: [typrFactory, woff2otfFactory, parserFactory], + init(typrFactory, woff2otfFactory, parserFactory) { + const Typr = typrFactory() + const woff2otf = woff2otfFactory() + return parserFactory(Typr, woff2otf) + }, +}) + +export default workerModule diff --git a/src/core/Text/troika-three-text/GlyphsGeometry.js b/src/core/Text/troika-three-text/GlyphsGeometry.js new file mode 100644 index 000000000..5c8fcd4ee --- /dev/null +++ b/src/core/Text/troika-three-text/GlyphsGeometry.js @@ -0,0 +1,248 @@ +import { + Float32BufferAttribute, + BufferGeometry, + PlaneGeometry, + InstancedBufferGeometry, + InstancedBufferAttribute, + Sphere, + Box3, + DoubleSide, + BackSide, +} from 'three' + +const templateGeometries = {} +function getTemplateGeometry(detail) { + let geom = templateGeometries[detail] + if (!geom) { + // Geometry is two planes back-to-back, which will always be rendered FrontSide only but + // appear as DoubleSide by default. FrontSide/BackSide are emulated using drawRange. + // We do it this way to avoid the performance hit of two draw calls for DoubleSide materials + // introduced by Three.js in r130 - see https://github.com/mrdoob/three.js/pull/21967 + const front = new PlaneGeometry(1, 1, detail, detail) + const back = front.clone() + const frontAttrs = front.attributes + const backAttrs = back.attributes + const combined = new BufferGeometry() + const vertCount = frontAttrs.uv.count + for (let i = 0; i < vertCount; i++) { + backAttrs.position.array[i * 3] *= -1 // flip position x + backAttrs.normal.array[i * 3 + 2] *= -1 // flip normal z + } + ;['position', 'normal', 'uv'].forEach((name) => { + combined.setAttribute( + name, + new Float32BufferAttribute([...frontAttrs[name].array, ...backAttrs[name].array], frontAttrs[name].itemSize) + ) + }) + combined.setIndex([...front.index.array, ...back.index.array.map((n) => n + vertCount)]) + combined.translate(0.5, 0.5, 0) + geom = templateGeometries[detail] = combined + } + return geom +} + +const glyphBoundsAttrName = 'aTroikaGlyphBounds' +const glyphIndexAttrName = 'aTroikaGlyphIndex' +const glyphColorAttrName = 'aTroikaGlyphColor' + +/** + @class GlyphsGeometry + + A specialized Geometry for rendering a set of text glyphs. Uses InstancedBufferGeometry to + render the glyphs using GPU instancing of a single quad, rather than constructing a whole + geometry with vertices, for much smaller attribute arraybuffers according to this math: + + Where N = number of glyphs... + + Instanced: + - position: 4 * 3 + - index: 2 * 3 + - normal: 4 * 3 + - uv: 4 * 2 + - glyph x/y bounds: N * 4 + - glyph indices: N * 1 + = 5N + 38 + + Non-instanced: + - position: N * 4 * 3 + - index: N * 2 * 3 + - normal: N * 4 * 3 + - uv: N * 4 * 2 + - glyph indices: N * 1 + = 39N + + A downside of this is the rare-but-possible lack of the instanced arrays extension, + which we could potentially work around with a fallback non-instanced implementation. + + */ +class GlyphsGeometry extends InstancedBufferGeometry { + constructor() { + super() + + this.detail = 1 + this.curveRadius = 0 + + // Define groups for rendering text outline as a separate pass; these will only + // be used when the `material` getter returns an array, i.e. outlineWidth > 0. + this.groups = [ + { start: 0, count: Infinity, materialIndex: 0 }, + { start: 0, count: Infinity, materialIndex: 1 }, + ] + + // Preallocate empty bounding objects + this.boundingSphere = new Sphere() + this.boundingBox = new Box3() + } + + computeBoundingSphere() { + // No-op; we'll sync the boundingSphere proactively when needed. + } + + computeBoundingBox() { + // No-op; we'll sync the boundingBox proactively when needed. + } + + // Since our base geometry contains triangles for both front and back sides, we can emulate + // the "side" by restricting the draw range. + setSide(side) { + const verts = this.getIndex().count + this.setDrawRange(side === BackSide ? verts / 2 : 0, side === DoubleSide ? verts : verts / 2) + } + + set detail(detail) { + if (detail !== this._detail) { + this._detail = detail + if (typeof detail !== 'number' || detail < 1) { + detail = 1 + } + const tpl = getTemplateGeometry(detail) + ;['position', 'normal', 'uv'].forEach((attr) => { + this.attributes[attr] = tpl.attributes[attr].clone() + }) + this.setIndex(tpl.getIndex().clone()) + } + } + get detail() { + return this._detail + } + + set curveRadius(r) { + if (r !== this._curveRadius) { + this._curveRadius = r + this._updateBounds() + } + } + get curveRadius() { + return this._curveRadius + } + + /** + * Update the geometry for a new set of glyphs. + * @param {Float32Array} glyphBounds - An array holding the planar bounds for all glyphs + * to be rendered, 4 entries for each glyph: x1,x2,y1,y1 + * @param {Float32Array} glyphAtlasIndices - An array holding the index of each glyph within + * the SDF atlas texture. + * @param {Array} blockBounds - An array holding the [minX, minY, maxX, maxY] across all glyphs + * @param {Array} [chunkedBounds] - An array of objects describing bounds for each chunk of N + * consecutive glyphs: `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`. This can be + * used with `applyClipRect` to choose an optimized `instanceCount`. + * @param {Uint8Array} [glyphColors] - An array holding r,g,b values for each glyph. + */ + updateGlyphs(glyphBounds, glyphAtlasIndices, blockBounds, chunkedBounds, glyphColors) { + // Update the instance attributes + updateBufferAttr(this, glyphBoundsAttrName, glyphBounds, 4) + updateBufferAttr(this, glyphIndexAttrName, glyphAtlasIndices, 1) + updateBufferAttr(this, glyphColorAttrName, glyphColors, 3) + this._blockBounds = blockBounds + this._chunkedBounds = chunkedBounds + this.instanceCount = glyphAtlasIndices.length + this._updateBounds() + } + + _updateBounds() { + const bounds = this._blockBounds + if (bounds) { + const { curveRadius, boundingBox: bbox } = this + if (curveRadius) { + const { PI, floor, min, max, sin, cos } = Math + const halfPi = PI / 2 + const twoPi = PI * 2 + const absR = Math.abs(curveRadius) + const leftAngle = bounds[0] / absR + const rightAngle = bounds[2] / absR + const minX = + floor((leftAngle + halfPi) / twoPi) !== floor((rightAngle + halfPi) / twoPi) + ? -absR + : min(sin(leftAngle) * absR, sin(rightAngle) * absR) + const maxX = + floor((leftAngle - halfPi) / twoPi) !== floor((rightAngle - halfPi) / twoPi) + ? absR + : max(sin(leftAngle) * absR, sin(rightAngle) * absR) + const maxZ = + floor((leftAngle + PI) / twoPi) !== floor((rightAngle + PI) / twoPi) + ? absR * 2 + : max(absR - cos(leftAngle) * absR, absR - cos(rightAngle) * absR) + bbox.min.set(minX, bounds[1], curveRadius < 0 ? -maxZ : 0) + bbox.max.set(maxX, bounds[3], curveRadius < 0 ? 0 : maxZ) + } else { + bbox.min.set(bounds[0], bounds[1], 0) + bbox.max.set(bounds[2], bounds[3], 0) + } + bbox.getBoundingSphere(this.boundingSphere) + } + } + + /** + * Given a clipping rect, and the chunkedBounds from the last updateGlyphs call, choose the lowest + * `instanceCount` that will show all glyphs within the clipped view. This is an optimization + * for long blocks of text that are clipped, to skip vertex shader evaluation for glyphs that would + * be clipped anyway. + * + * Note that since `drawElementsInstanced[ANGLE]` only accepts an instance count and not a starting + * offset, this optimization becomes less effective as the clipRect moves closer to the end of the + * text block. We could fix that by switching from instancing to a full geometry with a drawRange, + * but at the expense of much larger attribute buffers (see classdoc above.) + * + * @param {Vector4} clipRect + */ + applyClipRect(clipRect) { + let count = this.getAttribute(glyphIndexAttrName).count + const chunks = this._chunkedBounds + if (chunks) { + for (let i = chunks.length; i--; ) { + count = chunks[i].end + const rect = chunks[i].rect + // note: both rects are l-b-r-t + if (rect[1] < clipRect.w && rect[3] > clipRect.y && rect[0] < clipRect.z && rect[2] > clipRect.x) { + break + } + } + } + this.instanceCount = count + } +} + +function updateBufferAttr(geom, attrName, newArray, itemSize) { + const attr = geom.getAttribute(attrName) + if (newArray) { + // If length isn't changing, just update the attribute's array data + if (attr && attr.array.length === newArray.length) { + attr.array.set(newArray) + attr.needsUpdate = true + } else { + geom.setAttribute(attrName, new InstancedBufferAttribute(newArray, itemSize)) + // If the new attribute has a different size, we also have to (as of r117) manually clear the + // internal cached max instance count. See https://github.com/mrdoob/three.js/issues/19706 + // It's unclear if this is a threejs bug or a truly unsupported scenario; discussion in + // that ticket is ambiguous as to whether replacing a BufferAttribute with one of a + // different size is supported, but https://github.com/mrdoob/three.js/pull/17418 strongly + // implies it should be supported. It's possible we need to + delete geom._maxInstanceCount //for r117+, could be fragile + geom.dispose() //for r118+, more robust feeling, but more heavy-handed than I'd like + } + } else if (attr) { + geom.deleteAttribute(attrName) + } +} + +export { GlyphsGeometry } diff --git a/src/core/Text/troika-three-text/SDFGenerator.js b/src/core/Text/troika-three-text/SDFGenerator.js new file mode 100644 index 000000000..4a061aecb --- /dev/null +++ b/src/core/Text/troika-three-text/SDFGenerator.js @@ -0,0 +1,133 @@ +import { defineWorkerModule, terminateWorker } from 'troika-worker-utils' +import createSDFGenerator from 'webgl-sdf-generator' + +const now = () => (self.performance || Date).now() + +const mainThreadGenerator = createSDFGenerator() + +let warned + +/** + * Generate an SDF texture image for a single glyph path, placing the result into a webgl canvas at a + * given location and channel. Utilizes the webgl-sdf-generator external package for GPU-accelerated SDF + * generation when supported. + */ +export function generateSDF(width, height, path, viewBox, distance, exponent, canvas, x, y, channel, useWebGL = true) { + // Allow opt-out + if (!useWebGL) { + return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) + } + + // Attempt GPU-accelerated generation first + return generateSDF_GL(width, height, path, viewBox, distance, exponent, canvas, x, y, channel).then(null, (err) => { + // WebGL failed either due to a hard error or unexpected results; fall back to JS in workers + if (!warned) { + console.warn(`WebGL SDF generation failed, falling back to JS`, err) + warned = true + } + return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) + }) +} + +/** + * WebGL-based implementation executed on the main thread. Requests are executed in time-bounded + * macrotask chunks to allow render frames to execute in between. + */ +const generateSDF_GL = /*#__PURE__*/ (function () { + const queue = [] + const chunkTimeBudget = 5 //ms + let timer = 0 + function nextChunk() { + const start = now() + while (queue.length && now() - start < chunkTimeBudget) { + queue.shift()() + } + timer = queue.length ? setTimeout(nextChunk, 0) : 0 + } + return (...args) => { + return new Promise((resolve, reject) => { + queue.push(() => { + const start = now() + try { + mainThreadGenerator.webgl.generateIntoCanvas(...args) + resolve({ timing: now() - start }) + } catch (err) { + reject(err) + } + }) + if (!timer) { + timer = setTimeout(nextChunk, 0) + } + }) + } +})() + +/** + * Fallback JS-based implementation, fanned out to a number of worker threads for parallelism + */ +const generateSDF_JS_Worker = /*#__PURE__*/ (function () { + const threadCount = 4 //how many workers to spawn + const idleTimeout = 2000 //workers will be terminated after being idle this many milliseconds + const threads = {} + let callNum = 0 + return function (width, height, path, viewBox, distance, exponent, canvas, x, y, channel) { + const workerId = 'TroikaTextSDFGenerator_JS_' + (callNum++ % threadCount) + let thread = threads[workerId] + if (!thread) { + thread = threads[workerId] = { + workerModule: defineWorkerModule({ + name: workerId, + workerId, + dependencies: [createSDFGenerator, now], + init(_createSDFGenerator, now) { + const generate = _createSDFGenerator().javascript.generate + return function (...args) { + const start = now() + const textureData = generate(...args) + return { + textureData, + timing: now() - start, + } + } + }, + getTransferables(result) { + return [result.textureData.buffer] + }, + }), + requests: 0, + idleTimer: null, + } + } + + thread.requests++ + clearTimeout(thread.idleTimer) + return thread.workerModule(width, height, path, viewBox, distance, exponent).then(({ textureData, timing }) => { + // copy result data into the canvas + const start = now() + // expand single-channel data into rgba + const imageData = new Uint8Array(textureData.length * 4) + for (let i = 0; i < textureData.length; i++) { + imageData[i * 4 + channel] = textureData[i] + } + mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) + timing += now() - start + + // clean up workers after a while + if (--thread.requests === 0) { + thread.idleTimer = setTimeout(() => { + terminateWorker(workerId) + }, idleTimeout) + } + return { timing } + }) + } +})() + +export function warmUpSDFCanvas(canvas) { + if (!canvas._warm) { + mainThreadGenerator.webgl.isSupported(canvas) + canvas._warm = true + } +} + +export const resizeWebGLCanvasWithoutClearing = mainThreadGenerator.webglUtils.resizeWebGLCanvasWithoutClearing diff --git a/src/core/Text/troika-three-text/Text.js b/src/core/Text/troika-three-text/Text.js new file mode 100644 index 000000000..79ff25b11 --- /dev/null +++ b/src/core/Text/troika-three-text/Text.js @@ -0,0 +1,753 @@ +import { Color, DoubleSide, FrontSide, Matrix4, Mesh, MeshBasicMaterial, PlaneGeometry, Vector3, Vector2 } from 'three' +import { GlyphsGeometry } from './GlyphsGeometry.js' +import { createTextDerivedMaterial } from './TextDerivedMaterial.js' + +const defaultMaterial = /*#__PURE__*/ new MeshBasicMaterial({ + color: 0xffffff, + side: DoubleSide, + transparent: true, +}) +const defaultStrokeColor = 0x808080 + +const tempMat4 = /*#__PURE__*/ new Matrix4() +const tempVec3a = /*#__PURE__*/ new Vector3() +const tempVec3b = /*#__PURE__*/ new Vector3() +const tempArray = [] +const origin = /*#__PURE__*/ new Vector3() +const defaultOrient = '+x+y' + +function first(o) { + return Array.isArray(o) ? o[0] : o +} + +let getFlatRaycastMesh = () => { + const mesh = new Mesh(new PlaneGeometry(1, 1), defaultMaterial) + getFlatRaycastMesh = () => mesh + return mesh +} +let getCurvedRaycastMesh = () => { + const mesh = new Mesh(new PlaneGeometry(1, 1, 32, 1), defaultMaterial) + getCurvedRaycastMesh = () => mesh + return mesh +} + +const syncStartEvent = { type: 'syncstart' } +const syncCompleteEvent = { type: 'synccomplete' } + +const SYNCABLE_PROPS = [ + 'font', + 'fontSize', + 'letterSpacing', + 'lineHeight', + 'maxWidth', + 'overflowWrap', + 'text', + 'direction', + 'textAlign', + 'textIndent', + 'whiteSpace', + 'anchorX', + 'anchorY', + 'colorRanges', + 'sdfGlyphSize', +] + +const COPYABLE_PROPS = SYNCABLE_PROPS.concat( + 'material', + 'color', + 'depthOffset', + 'clipRect', + 'curveRadius', + 'orientation', + 'glyphGeometryDetail' +) + +/** + * @class Text + * + * A ThreeJS Mesh that renders a string of text on a plane in 3D space using signed distance + * fields (SDF). + */ +class Text extends Mesh { + constructor() { + const geometry = new GlyphsGeometry() + super(geometry, null) + + // === Text layout properties: === // + + /** + * @member {string} text + * The string of text to be rendered. + */ + this.text = '' + + /** + * @member {number|string} anchorX + * Defines the horizontal position in the text block that should line up with the local origin. + * Can be specified as a numeric x position in local units, a string percentage of the total + * text block width e.g. `'25%'`, or one of the following keyword strings: 'left', 'center', + * or 'right'. + */ + this.anchorX = 0 + + /** + * @member {number|string} anchorX + * Defines the vertical position in the text block that should line up with the local origin. + * Can be specified as a numeric y position in local units (note: down is negative y), a string + * percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings: + * 'top', 'top-baseline', 'top-cap', 'top-ex', 'middle', 'bottom-baseline', or 'bottom'. + */ + this.anchorY = 0 + + /** + * @member {number} curveRadius + * Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put + * the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave + * curvature, while negative numbers put it behind the text for a convex curvature. The centerline + * will be aligned with the text's local origin; you can use `anchorX` to offset it. + * + * Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane + * internally. You can use `glyphGeometryDetail` to add more vertices for curvature inside glyphs. + */ + this.curveRadius = 0 + + /** + * @member {string} direction + * Sets the base direction for the text. The default value of "auto" will choose a direction based + * on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction. + */ + this.direction = 'auto' + + /** + * @member {string} font + * URL of a custom font to be used. Font files can be in .ttf, .otf, or .woff (not .woff2) formats. + * Defaults to the Roboto font loaded from Google Fonts. + */ + this.font = null //will use default from TextBuilder + + /** + * @member {number} fontSize + * The size at which to render the font in local units; corresponds to the em-box height + * of the chosen `font`. + */ + this.fontSize = 0.1 + + /** + * @member {number} letterSpacing + * Sets a uniform adjustment to spacing between letters after kerning is applied. Positive + * numbers increase spacing and negative numbers decrease it. + */ + this.letterSpacing = 0 + + /** + * @member {number|string} lineHeight + * Sets the height of each line of text, as a multiple of the `fontSize`. Defaults to 'normal' + * which chooses a reasonable height based on the chosen font's ascender/descender metrics. + */ + this.lineHeight = 'normal' + + /** + * @member {number} maxWidth + * The maximum width of the text block, above which text may start wrapping according to the + * `whiteSpace` and `overflowWrap` properties. + */ + this.maxWidth = Infinity + + /** + * @member {string} overflowWrap + * Defines how text wraps if the `whiteSpace` property is `normal`. Can be either `'normal'` + * to break at whitespace characters, or `'break-word'` to allow breaking within words. + * Defaults to `'normal'`. + */ + this.overflowWrap = 'normal' + + /** + * @member {string} textAlign + * The horizontal alignment of each line of text within the overall text bounding box. + */ + this.textAlign = 'left' + + /** + * @member {number} textIndent + * Indentation for the first character of a line; see CSS `text-indent`. + */ + this.textIndent = 0 + + /** + * @member {string} whiteSpace + * Defines whether text should wrap when a line reaches the `maxWidth`. Can + * be either `'normal'` (the default), to allow wrapping according to the `overflowWrap` property, + * or `'nowrap'` to prevent wrapping. Note that `'normal'` here honors newline characters to + * manually break lines, making it behave more like `'pre-wrap'` does in CSS. + */ + this.whiteSpace = 'normal' + + // === Presentation properties: === // + + /** + * @member {THREE.Material} material + * Defines a _base_ material to be used when rendering the text. This material will be + * automatically replaced with a material derived from it, that adds shader code to + * decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing. + * By default it will derive from a simple white MeshBasicMaterial, but you can use any + * of the other mesh materials to gain other features like lighting, texture maps, etc. + * + * Also see the `color` shortcut property. + */ + this.material = null + + /** + * @member {string|number|THREE.Color} color + * This is a shortcut for setting the `color` of the text's material. You can use this + * if you don't want to specify a whole custom `material`. Also, if you do use a custom + * `material`, this color will only be used for this particuar Text instance, even if + * that same material instance is shared across multiple Text objects. + */ + this.color = null + + /** + * @member {object|null} colorRanges + * WARNING: This API is experimental and may change. + * This allows more fine-grained control of colors for individual or ranges of characters, + * taking precedence over the material's `color`. Its format is an Object whose keys each + * define a starting character index for a range, and whose values are the color for each + * range. The color value can be a numeric hex color value, a `THREE.Color` object, or + * any of the strings accepted by `THREE.Color`. + */ + this.colorRanges = null + + /** + * @member {number|string} outlineWidth + * WARNING: This API is experimental and may change. + * The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`. + * Can be specified as either an absolute number in local units, or as a percentage string e.g. + * `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`, which means + * no outline will be drawn unless an `outlineOffsetX/Y` or `outlineBlur` is set. + */ + this.outlineWidth = 0 + + /** + * @member {string|number|THREE.Color} outlineColor + * WARNING: This API is experimental and may change. + * The color of the text outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set. + * Defaults to black. + */ + this.outlineColor = 0x000000 + + /** + * @member {number} outlineOpacity + * WARNING: This API is experimental and may change. + * The opacity of the outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set. + * Defaults to `1`. + */ + this.outlineOpacity = 1 + + /** + * @member {number|string} outlineBlur + * WARNING: This API is experimental and may change. + * A blur radius applied to the outer edge of the text's outline. If the `outlineWidth` is + * zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius. + * Can be specified as either an absolute number in local units, or as a percentage string e.g. + * `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`. + */ + this.outlineBlur = 0 + + /** + * @member {number|string} outlineOffsetX + * WARNING: This API is experimental and may change. + * A horizontal offset for the text outline. + * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` + * which is treated as a percentage of the `fontSize`. Defaults to `0`. + */ + this.outlineOffsetX = 0 + + /** + * @member {number|string} outlineOffsetY + * WARNING: This API is experimental and may change. + * A vertical offset for the text outline. + * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` + * which is treated as a percentage of the `fontSize`. Defaults to `0`. + */ + this.outlineOffsetY = 0 + + /** + * @member {number|string} strokeWidth + * WARNING: This API is experimental and may change. + * The width of an inner stroke drawn inside each text glyph using the `strokeColor` and `strokeOpacity`. + * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` + * which is treated as a percentage of the `fontSize`. Defaults to `0`. + */ + this.strokeWidth = 0 + + /** + * @member {string|number|THREE.Color} strokeColor + * WARNING: This API is experimental and may change. + * The color of the text stroke, if `strokeWidth` is greater than zero. Defaults to gray. + */ + this.strokeColor = defaultStrokeColor + + /** + * @member {number} strokeOpacity + * WARNING: This API is experimental and may change. + * The opacity of the stroke, if `strokeWidth` is greater than zero. Defaults to `1`. + */ + this.strokeOpacity = 1 + + /** + * @member {number} fillOpacity + * WARNING: This API is experimental and may change. + * The opacity of the glyph's fill from 0 to 1. This behaves like the material's `opacity` but allows + * giving the fill a different opacity than the `strokeOpacity`. A fillOpacity of `0` makes the + * interior of the glyph invisible, leaving just the `strokeWidth`. Defaults to `1`. + */ + this.fillOpacity = 1 + + /** + * @member {number} depthOffset + * This is a shortcut for setting the material's `polygonOffset` and related properties, + * which can be useful in preventing z-fighting when this text is laid on top of another + * plane in the scene. Positive numbers are further from the camera, negatives closer. + */ + this.depthOffset = 0 + + /** + * @member {Array} clipRect + * If specified, defines a `[minX, minY, maxX, maxY]` of a rectangle outside of which all + * pixels will be discarded. This can be used for example to clip overflowing text when + * `whiteSpace='nowrap'`. + */ + this.clipRect = null + + /** + * @member {string} orientation + * Defines the axis plane on which the text should be laid out when the mesh has no extra + * rotation transform. It is specified as a string with two axes: the horizontal axis with + * positive pointing right, and the vertical axis with positive pointing up. By default this + * is '+x+y', meaning the text sits on the xy plane with the text's top toward positive y + * and facing positive z. A value of '+x-z' would place it on the xz plane with the text's + * top toward negative z and facing positive y. + */ + this.orientation = defaultOrient + + /** + * @member {number} glyphGeometryDetail + * Controls number of vertical/horizontal segments that make up each glyph's rectangular + * plane. Defaults to 1. This can be increased to provide more geometrical detail for custom + * vertex shader effects, for example. + */ + 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 + + /** + * @member {boolean} gpuAccelerateSDF + * When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible, + * making it much faster especially for complex glyphs, and falling back to a JavaScript version + * executed in web workers when support isn't available. It should automatically detect support, + * but it's still somewhat experimental, so you can set it to `false` to force it to use the JS + * version if you encounter issues with it. + */ + this.gpuAccelerateSDF = true + + this.debugSDF = false + } + + /** + * Updates the text rendering according to the current text-related configuration properties. + * This is an async process, so you can pass in a callback function to be executed when it + * finishes. + * @param {function} [callback] + */ + sync(callback) { + if (this._needsSync) { + this._needsSync = false + + // If there's another sync still in progress, queue + if (this._isSyncing) { + ;(this._queuedSyncs || (this._queuedSyncs = [])).push(callback) + } else { + this._isSyncing = true + this.dispatchEvent(syncStartEvent) + + getTextRenderInfo( + { + text: this.text, + font: this.font, + fontSize: this.fontSize || 0.1, + letterSpacing: this.letterSpacing || 0, + lineHeight: this.lineHeight || 'normal', + maxWidth: this.maxWidth, + direction: this.direction || 'auto', + textAlign: this.textAlign, + textIndent: this.textIndent, + whiteSpace: this.whiteSpace, + overflowWrap: this.overflowWrap, + anchorX: this.anchorX, + anchorY: this.anchorY, + colorRanges: this.colorRanges, + includeCaretPositions: true, //TODO parameterize + sdfGlyphSize: this.sdfGlyphSize, + gpuAccelerateSDF: this.gpuAccelerateSDF, + }, + (textRenderInfo) => { + this._isSyncing = false + + // Save result for later use in onBeforeRender + this._textRenderInfo = textRenderInfo + + // Update the geometry attributes + this.geometry.updateGlyphs( + textRenderInfo.glyphBounds, + textRenderInfo.glyphAtlasIndices, + textRenderInfo.blockBounds, + textRenderInfo.chunkedBounds, + textRenderInfo.glyphColors + ) + + // If we had extra sync requests queued up, kick it off + const queued = this._queuedSyncs + if (queued) { + this._queuedSyncs = null + this._needsSync = true + this.sync(() => { + queued.forEach((fn) => fn && fn()) + }) + } + + this.dispatchEvent(syncCompleteEvent) + if (callback) { + callback() + } + } + ) + } + } + } + + /** + * Initiate a sync if needed - note it won't complete until next frame at the + * earliest so if possible it's a good idea to call sync() manually as soon as + * all the properties have been set. + * @override + */ + onBeforeRender(renderer, scene, camera, geometry, material) { + this.sync() + + // This may not always be a text material, e.g. if there's a scene.overrideMaterial present + if (material.isTroikaTextMaterial) { + this._prepareForRender(material) + } + + // We need to force the material to FrontSide to avoid the double-draw-call performance hit + // introduced in Three.js r130: https://github.com/mrdoob/three.js/pull/21967 - The sidedness + // is instead applied via drawRange in the GlyphsGeometry. + material._hadOwnSide = material.hasOwnProperty('side') + this.geometry.setSide((material._actualSide = material.side)) + material.side = FrontSide + } + + onAfterRender(renderer, scene, camera, geometry, material) { + // Restore original material side + if (material._hadOwnSide) { + material.side = material._actualSide + } else { + delete material.side // back to inheriting from base material + } + } + + /** + * Shortcut to dispose the geometry specific to this instance. + * Note: we don't also dispose the derived material here because if anything else is + * sharing the same base material it will result in a pause next frame as the program + * is recompiled. Instead users can dispose the base material manually, like normal, + * and we'll also dispose the derived material at that time. + */ + dispose() { + this.geometry.dispose() + } + + /** + * @property {TroikaTextRenderInfo|null} textRenderInfo + * @readonly + * The current processed rendering data for this TextMesh, returned by the TextBuilder after + * a `sync()` call. This will be `null` initially, and may be stale for a short period until + * the asynchrous `sync()` process completes. + */ + get textRenderInfo() { + return this._textRenderInfo || null + } + + // Handler for automatically wrapping the base material with our upgrades. We do the wrapping + // lazily on _read_ rather than write to avoid unnecessary wrapping on transient values. + get material() { + let derivedMaterial = this._derivedMaterial + const baseMaterial = + this._baseMaterial || this._defaultMaterial || (this._defaultMaterial = defaultMaterial.clone()) + if (!derivedMaterial || derivedMaterial.baseMaterial !== baseMaterial) { + derivedMaterial = this._derivedMaterial = createTextDerivedMaterial(baseMaterial) + // dispose the derived material when its base material is disposed: + baseMaterial.addEventListener('dispose', function onDispose() { + baseMaterial.removeEventListener('dispose', onDispose) + derivedMaterial.dispose() + }) + } + // If text outline is configured, render it as a preliminary draw using Three's multi-material + // feature (see GlyphsGeometry which sets up `groups` for this purpose) Doing it with multi + // materials ensures the layers are always rendered consecutively in a consistent order. + // Each layer will trigger onBeforeRender with the appropriate material. + if (this.outlineWidth || this.outlineBlur || this.outlineOffsetX || this.outlineOffsetY) { + let outlineMaterial = derivedMaterial._outlineMtl + if (!outlineMaterial) { + outlineMaterial = derivedMaterial._outlineMtl = Object.create(derivedMaterial, { + id: { value: derivedMaterial.id + 0.1 }, + }) + outlineMaterial.isTextOutlineMaterial = true + outlineMaterial.depthWrite = false + outlineMaterial.map = null //??? + derivedMaterial.addEventListener('dispose', function onDispose() { + derivedMaterial.removeEventListener('dispose', onDispose) + outlineMaterial.dispose() + }) + } + return [outlineMaterial, derivedMaterial] + } else { + return derivedMaterial + } + } + set material(baseMaterial) { + if (baseMaterial && baseMaterial.isTroikaTextMaterial) { + //prevent double-derivation + this._derivedMaterial = baseMaterial + this._baseMaterial = baseMaterial.baseMaterial + } else { + this._baseMaterial = baseMaterial + } + } + + get glyphGeometryDetail() { + return this.geometry.detail + } + set glyphGeometryDetail(detail) { + this.geometry.detail = detail + } + + get curveRadius() { + return this.geometry.curveRadius + } + set curveRadius(r) { + this.geometry.curveRadius = r + } + + // Create and update material for shadows upon request: + get customDepthMaterial() { + return first(this.material).getDepthMaterial() + } + get customDistanceMaterial() { + return first(this.material).getDistanceMaterial() + } + + _prepareForRender(material) { + const isOutline = material.isTextOutlineMaterial + const uniforms = material.uniforms + const textInfo = this.textRenderInfo + if (textInfo) { + const { sdfTexture, blockBounds } = textInfo + uniforms.uTroikaSDFTexture.value = sdfTexture + uniforms.uTroikaSDFTextureSize.value.set(sdfTexture.image.width, sdfTexture.image.height) + uniforms.uTroikaSDFGlyphSize.value = textInfo.sdfGlyphSize + uniforms.uTroikaSDFExponent.value = textInfo.sdfExponent + uniforms.uTroikaTotalBounds.value.fromArray(blockBounds) + uniforms.uTroikaUseGlyphColors.value = !isOutline && !!textInfo.glyphColors + + let distanceOffset = 0 + let blurRadius = 0 + let strokeWidth = 0 + let fillOpacity + let strokeOpacity + let strokeColor + let offsetX = 0 + let offsetY = 0 + + if (isOutline) { + const { outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity } = this + distanceOffset = this._parsePercent(outlineWidth) || 0 + blurRadius = Math.max(0, this._parsePercent(outlineBlur) || 0) + fillOpacity = outlineOpacity + offsetX = this._parsePercent(outlineOffsetX) || 0 + offsetY = this._parsePercent(outlineOffsetY) || 0 + } else { + strokeWidth = Math.max(0, this._parsePercent(this.strokeWidth) || 0) + if (strokeWidth) { + strokeColor = this.strokeColor + uniforms.uTroikaStrokeColor.value.set(strokeColor == null ? defaultStrokeColor : strokeColor) + strokeOpacity = this.strokeOpacity + if (strokeOpacity == null) strokeOpacity = 1 + } + fillOpacity = this.fillOpacity + } + + uniforms.uTroikaDistanceOffset.value = distanceOffset + uniforms.uTroikaPositionOffset.value.set(offsetX, offsetY) + uniforms.uTroikaBlurRadius.value = blurRadius + uniforms.uTroikaStrokeWidth.value = strokeWidth + uniforms.uTroikaStrokeOpacity.value = strokeOpacity + uniforms.uTroikaFillOpacity.value = fillOpacity == null ? 1 : fillOpacity + uniforms.uTroikaCurveRadius.value = this.curveRadius || 0 + + const clipRect = this.clipRect + if (clipRect && Array.isArray(clipRect) && clipRect.length === 4) { + uniforms.uTroikaClipRect.value.fromArray(clipRect) + } else { + // no clipping - choose a finite rect that shouldn't ever be reached by overflowing glyphs or outlines + const pad = (this.fontSize || 0.1) * 100 + uniforms.uTroikaClipRect.value.set( + blockBounds[0] - pad, + blockBounds[1] - pad, + blockBounds[2] + pad, + blockBounds[3] + pad + ) + } + this.geometry.applyClipRect(uniforms.uTroikaClipRect.value) + } + uniforms.uTroikaSDFDebug.value = !!this.debugSDF + material.polygonOffset = !!this.depthOffset + material.polygonOffsetFactor = material.polygonOffsetUnits = this.depthOffset || 0 + + // Shortcut for setting material color via `color` prop on the mesh; this is + // applied only to the derived material to avoid mutating a shared base material. + const color = isOutline ? this.outlineColor || 0 : this.color + + if (color == null) { + delete material.color //inherit from base + } else { + const colorObj = material.hasOwnProperty('color') ? material.color : (material.color = new Color()) + if (color !== colorObj._input || typeof color === 'object') { + colorObj.set((colorObj._input = color)) + } + } + + // base orientation + let orient = this.orientation || defaultOrient + if (orient !== material._orientation) { + const rotMat = uniforms.uTroikaOrient.value + orient = orient.replace(/[^-+xyz]/g, '') + const match = orient !== defaultOrient && orient.match(/^([-+])([xyz])([-+])([xyz])$/) + if (match) { + const [, hSign, hAxis, vSign, vAxis] = match + tempVec3a.set(0, 0, 0)[hAxis] = hSign === '-' ? 1 : -1 + tempVec3b.set(0, 0, 0)[vAxis] = vSign === '-' ? -1 : 1 + tempMat4.lookAt(origin, tempVec3a.cross(tempVec3b), tempVec3b) + rotMat.setFromMatrix4(tempMat4) + } else { + rotMat.identity() + } + material._orientation = orient + } + } + + _parsePercent(value) { + if (typeof value === 'string') { + const match = value.match(/^(-?[\d.]+)%$/) + const pct = match ? parseFloat(match[1]) : NaN + value = (isNaN(pct) ? 0 : pct / 100) * this.fontSize + } + return value + } + + /** + * Translate a point in local space to an x/y in the text plane. + */ + localPositionToTextCoords(position, target = new Vector2()) { + target.copy(position) //simple non-curved case is 1:1 + const r = this.curveRadius + if (r) { + //flatten the curve + target.x = Math.atan2(position.x, Math.abs(r) - Math.abs(position.z)) * Math.abs(r) + } + return target + } + + /** + * Translate a point in world space to an x/y in the text plane. + */ + worldPositionToTextCoords(position, target = new Vector2()) { + tempVec3a.copy(position) + return this.localPositionToTextCoords(this.worldToLocal(tempVec3a), target) + } + + /** + * @override Custom raycasting to test against the whole text block's max rectangular bounds + * TODO is there any reason to make this more granular, like within individual line or glyph rects? + */ + raycast(raycaster, intersects) { + const { textRenderInfo, curveRadius } = this + if (textRenderInfo) { + const bounds = textRenderInfo.blockBounds + const raycastMesh = curveRadius ? getCurvedRaycastMesh() : getFlatRaycastMesh() + const geom = raycastMesh.geometry + const { position, uv } = geom.attributes + for (let i = 0; i < uv.count; i++) { + let x = bounds[0] + uv.getX(i) * (bounds[2] - bounds[0]) + const y = bounds[1] + uv.getY(i) * (bounds[3] - bounds[1]) + let z = 0 + if (curveRadius) { + z = curveRadius - Math.cos(x / curveRadius) * curveRadius + x = Math.sin(x / curveRadius) * curveRadius + } + position.setXYZ(i, x, y, z) + } + geom.boundingSphere = this.geometry.boundingSphere + geom.boundingBox = this.geometry.boundingBox + raycastMesh.matrixWorld = this.matrixWorld + raycastMesh.material.side = this.material.side + tempArray.length = 0 + raycastMesh.raycast(raycaster, tempArray) + for (let i = 0; i < tempArray.length; i++) { + tempArray[i].object = this + intersects.push(tempArray[i]) + } + } + } + + copy(source) { + // Prevent copying the geometry reference so we don't end up sharing attributes between instances + const geom = this.geometry + super.copy(source) + this.geometry = geom + + COPYABLE_PROPS.forEach((prop) => { + this[prop] = source[prop] + }) + return this + } + + clone() { + return new this.constructor().copy(this) + } +} + +// Create setters for properties that affect text layout: +SYNCABLE_PROPS.forEach((prop) => { + const privateKey = '_private_' + prop + Object.defineProperty(Text.prototype, prop, { + get() { + return this[privateKey] + }, + set(value) { + if (value !== this[privateKey]) { + this[privateKey] = value + this._needsSync = true + } + }, + }) +}) + +export { Text } diff --git a/src/core/Text/troika-three-text/TextBuilder.js b/src/core/Text/troika-three-text/TextBuilder.js new file mode 100644 index 000000000..07bd56cfa --- /dev/null +++ b/src/core/Text/troika-three-text/TextBuilder.js @@ -0,0 +1,425 @@ +import { Color, Texture, LinearFilter } from 'three' +import { defineWorkerModule } from 'troika-worker-utils' +import { createTypesetter } from './Typesetter.js' +import { generateSDF, warmUpSDFCanvas, resizeWebGLCanvasWithoutClearing } from './SDFGenerator.js' +import bidiFactory from 'bidi-js' +import fontParser from './FontParser.js' + +const CONFIG = { + defaultFontURL: 'https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxM.woff', //Roboto Regular + sdfGlyphSize: 64, + sdfMargin: 1 / 16, + sdfExponent: 9, + textureWidth: 2048, +} +const tempColor = /*#__PURE__*/ new Color() + +function now() { + return (self.performance || Date).now() +} + +/** + * 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> + * } + * } + */ +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 {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 + * three elements: the starting X, the ending X, and the bottom 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} 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} 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} 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 {object} args + * @param {getTextRenderInfo~callback} callback + */ +function getTextRenderInfo(args, callback) { + hasRequested = true + args = assign({}, args) + const totalStart = now() + + // Apply default font here to avoid a 'null' atlas, and convert relative + // URLs to absolute so they can be resolved in the worker + args.font = toAbsoluteURL(args.font || CONFIG.defaultFontURL) + + // Normalize text to a string + args.text = '' + args.text + + args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize + + // Normalize colors + if (args.colorRanges != null) { + const colors = {} + for (const 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 + let fontGlyphs = atlas.glyphsByFont.get(args.font) + if (!fontGlyphs) { + atlas.glyphsByFont.set(args.font, (fontGlyphs = new Map())) + } + + // Issue request to the typesetting engine in the worker + typesetInWorker(args).then((result) => { + const { glyphIds, glyphPositions, fontSize, unitsPerEm, timings } = result + const neededSDFs = [] + const glyphBounds = new Float32Array(glyphIds.length * 4) + const fontSizeMult = fontSize / unitsPerEm + let boundsIdx = 0 + let positionsIdx = 0 + const quadsStart = now() + glyphIds.forEach((glyphId, i) => { + let glyphInfo = fontGlyphs.get(glyphId) + + // If this is a glyphId not seen before, add it to the atlas + if (!glyphInfo) { + const { path, pathBounds } = result.glyphData[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, + ] + fontGlyphs.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++] + 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, + caretHeight: result.caretHeight, + 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. + */ +export function preloadFont({ font, characters, sdfGlyphSize }, callback) { + const 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 (const 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: [CONFIG, fontParser, createTypesetter, bidiFactory], + init(config, fontParser, createTypesetter, bidiFactory) { + const { defaultFontURL } = config + return createTypesetter(fontParser, bidiFactory(), { defaultFontURL }) + }, +}) + +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 = [result.glyphPositions.buffer, result.glyphIds.buffer] + if (result.caretPositions) { + transferables.push(result.caretPositions.buffer) + } + if (result.glyphColors) { + transferables.push(result.glyphColors.buffer) + } + return transferables + }, +}) diff --git a/src/core/Text/troika-three-text/TextDerivedMaterial.js b/src/core/Text/troika-three-text/TextDerivedMaterial.js new file mode 100644 index 000000000..330c429cb --- /dev/null +++ b/src/core/Text/troika-three-text/TextDerivedMaterial.js @@ -0,0 +1,284 @@ +import { createDerivedMaterial, voidMainRegExp } from './DerivedMaterial' +import { Color, Vector2, Vector4, Matrix3 } from 'three' + +// language=GLSL +const VERTEX_DEFS = ` +uniform vec2 uTroikaSDFTextureSize; +uniform float uTroikaSDFGlyphSize; +uniform vec4 uTroikaTotalBounds; +uniform vec4 uTroikaClipRect; +uniform mat3 uTroikaOrient; +uniform bool uTroikaUseGlyphColors; +uniform float uTroikaDistanceOffset; +uniform float uTroikaBlurRadius; +uniform vec2 uTroikaPositionOffset; +uniform float uTroikaCurveRadius; +attribute vec4 aTroikaGlyphBounds; +attribute float aTroikaGlyphIndex; +attribute vec3 aTroikaGlyphColor; +varying vec2 vTroikaGlyphUV; +varying vec4 vTroikaTextureUVBounds; +varying float vTroikaTextureChannel; +varying vec3 vTroikaGlyphColor; +varying vec2 vTroikaGlyphDimensions; +` + +// language=GLSL prefix="void main() {" suffix="}" +const VERTEX_TRANSFORM = ` +vec4 bounds = aTroikaGlyphBounds; +bounds.xz += uTroikaPositionOffset.x; +bounds.yw -= uTroikaPositionOffset.y; + +vec4 outlineBounds = vec4( + bounds.xy - uTroikaDistanceOffset - uTroikaBlurRadius, + bounds.zw + uTroikaDistanceOffset + uTroikaBlurRadius +); +vec4 clippedBounds = vec4( + clamp(outlineBounds.xy, uTroikaClipRect.xy, uTroikaClipRect.zw), + clamp(outlineBounds.zw, uTroikaClipRect.xy, uTroikaClipRect.zw) +); + +vec2 clippedXY = (mix(clippedBounds.xy, clippedBounds.zw, position.xy) - bounds.xy) / (bounds.zw - bounds.xy); + +position.xy = mix(bounds.xy, bounds.zw, clippedXY); + +uv = (position.xy - uTroikaTotalBounds.xy) / (uTroikaTotalBounds.zw - uTroikaTotalBounds.xy); + +float rad = uTroikaCurveRadius; +if (rad != 0.0) { + float angle = position.x / rad; + position.xz = vec2(sin(angle) * rad, rad - cos(angle) * rad); + normal.xz = vec2(sin(angle), cos(angle)); +} + +position = uTroikaOrient * position; +normal = uTroikaOrient * normal; + +vTroikaGlyphUV = clippedXY.xy; +vTroikaGlyphDimensions = vec2(bounds[2] - bounds[0], bounds[3] - bounds[1]); + +${ + '' /* NOTE: it seems important to calculate the glyph's bounding texture UVs here in the + vertex shader, rather than in the fragment shader, as the latter gives strange artifacts + on some glyphs (those in the leftmost texture column) on some systems. The exact reason + isn't understood but doing this here, then mix()-ing in the fragment shader, seems to work. */ +} +float txCols = uTroikaSDFTextureSize.x / uTroikaSDFGlyphSize; +vec2 txUvPerSquare = uTroikaSDFGlyphSize / uTroikaSDFTextureSize; +vec2 txStartUV = txUvPerSquare * vec2( + mod(floor(aTroikaGlyphIndex / 4.0), txCols), + floor(floor(aTroikaGlyphIndex / 4.0) / txCols) +); +vTroikaTextureUVBounds = vec4(txStartUV, vec2(txStartUV) + txUvPerSquare); +vTroikaTextureChannel = mod(aTroikaGlyphIndex, 4.0); +` + +// language=GLSL +const FRAGMENT_DEFS = ` +uniform sampler2D uTroikaSDFTexture; +uniform vec2 uTroikaSDFTextureSize; +uniform float uTroikaSDFGlyphSize; +uniform float uTroikaSDFExponent; +uniform float uTroikaDistanceOffset; +uniform float uTroikaFillOpacity; +uniform float uTroikaOutlineOpacity; +uniform float uTroikaBlurRadius; +uniform vec3 uTroikaStrokeColor; +uniform float uTroikaStrokeWidth; +uniform float uTroikaStrokeOpacity; +uniform bool uTroikaSDFDebug; +varying vec2 vTroikaGlyphUV; +varying vec4 vTroikaTextureUVBounds; +varying float vTroikaTextureChannel; +varying vec2 vTroikaGlyphDimensions; + +float troikaSdfValueToSignedDistance(float alpha) { + // Inverse of exponential encoding in webgl-sdf-generator + ${ + '' /* TODO - there's some slight inaccuracy here when dealing with interpolated alpha values; those + are linearly interpolated where the encoding is exponential. Look into improving this by rounding + to nearest 2 whole texels, decoding those exponential values, and linearly interpolating the result. + */ + } + float maxDimension = max(vTroikaGlyphDimensions.x, vTroikaGlyphDimensions.y); + float absDist = (1.0 - pow(2.0 * (alpha > 0.5 ? 1.0 - alpha : alpha), 1.0 / uTroikaSDFExponent)) * maxDimension; + float signedDist = absDist * (alpha > 0.5 ? -1.0 : 1.0); + return signedDist; +} + +float troikaGlyphUvToSdfValue(vec2 glyphUV) { + vec2 textureUV = mix(vTroikaTextureUVBounds.xy, vTroikaTextureUVBounds.zw, glyphUV); + vec4 rgba = texture2D(uTroikaSDFTexture, textureUV); + float ch = floor(vTroikaTextureChannel + 0.5); //NOTE: can't use round() in WebGL1 + return ch == 0.0 ? rgba.r : ch == 1.0 ? rgba.g : ch == 2.0 ? rgba.b : rgba.a; +} + +float troikaGlyphUvToDistance(vec2 uv) { + return troikaSdfValueToSignedDistance(troikaGlyphUvToSdfValue(uv)); +} + +float troikaGetAADist() { + ${ + '' /* + When the standard derivatives extension is available, we choose an antialiasing alpha threshold based + on the potential change in the SDF's alpha from this fragment to its neighbor. This strategy maximizes + readability and edge crispness at all sizes and screen resolutions. + */ + } + #if defined(GL_OES_standard_derivatives) || __VERSION__ >= 300 + return length(fwidth(vTroikaGlyphUV * vTroikaGlyphDimensions)) * 0.5; + #else + return vTroikaGlyphDimensions.x / 64.0; + #endif +} + +float troikaGetFragDistValue() { + vec2 clampedGlyphUV = clamp(vTroikaGlyphUV, 0.5 / uTroikaSDFGlyphSize, 1.0 - 0.5 / uTroikaSDFGlyphSize); + float distance = troikaGlyphUvToDistance(clampedGlyphUV); + + // Extrapolate distance when outside bounds: + distance += clampedGlyphUV == vTroikaGlyphUV ? 0.0 : + length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions); + + ${ + '' /* + // TODO more refined extrapolated distance by adjusting for angle of gradient at edge... + // This has potential but currently gives very jagged extensions, maybe due to precision issues? + float uvStep = 1.0 / uTroikaSDFGlyphSize; + vec2 neighbor1UV = clampedGlyphUV + ( + vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * sign(0.5 - vTroikaGlyphUV.y)) : + vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * sign(0.5 - vTroikaGlyphUV.x), 0.0) : + vec2(0.0) + ); + vec2 neighbor2UV = clampedGlyphUV + ( + vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * -sign(0.5 - vTroikaGlyphUV.y)) : + vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * -sign(0.5 - vTroikaGlyphUV.x), 0.0) : + vec2(0.0) + ); + float neighbor1Distance = troikaGlyphUvToDistance(neighbor1UV); + float neighbor2Distance = troikaGlyphUvToDistance(neighbor2UV); + float distToUnclamped = length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions); + float distToNeighbor = length((clampedGlyphUV - neighbor1UV) * vTroikaGlyphDimensions); + float gradientAngle1 = min(asin(abs(neighbor1Distance - distance) / distToNeighbor), PI / 2.0); + float gradientAngle2 = min(asin(abs(neighbor2Distance - distance) / distToNeighbor), PI / 2.0); + distance += (cos(gradientAngle1) + cos(gradientAngle2)) / 2.0 * distToUnclamped; + */ + } + + return distance; +} + +float troikaGetEdgeAlpha(float distance, float distanceOffset, float aaDist) { + #if defined(IS_DEPTH_MATERIAL) || defined(IS_DISTANCE_MATERIAL) + float alpha = step(-distanceOffset, -distance); + #else + + float alpha = smoothstep( + distanceOffset + aaDist, + distanceOffset - aaDist, + distance + ); + #endif + + return alpha; +} +` + +// language=GLSL prefix="void main() {" suffix="}" +const FRAGMENT_TRANSFORM = ` +float aaDist = troikaGetAADist(); +float fragDistance = troikaGetFragDistValue(); +float edgeAlpha = uTroikaSDFDebug ? + troikaGlyphUvToSdfValue(vTroikaGlyphUV) : + troikaGetEdgeAlpha(fragDistance, uTroikaDistanceOffset, max(aaDist, uTroikaBlurRadius)); + +#if !defined(IS_DEPTH_MATERIAL) && !defined(IS_DISTANCE_MATERIAL) +vec4 fillRGBA = gl_FragColor; +fillRGBA.a *= uTroikaFillOpacity; +vec4 strokeRGBA = uTroikaStrokeWidth == 0.0 ? fillRGBA : vec4(uTroikaStrokeColor, uTroikaStrokeOpacity); +if (fillRGBA.a == 0.0) fillRGBA.rgb = strokeRGBA.rgb; +gl_FragColor = mix(fillRGBA, strokeRGBA, smoothstep( + -uTroikaStrokeWidth - aaDist, + -uTroikaStrokeWidth + aaDist, + fragDistance +)); +gl_FragColor.a *= edgeAlpha; +#endif + +if (edgeAlpha == 0.0) { + discard; +} +` + +/** + * Create a material for rendering text, derived from a baseMaterial + */ +export function createTextDerivedMaterial(baseMaterial) { + const textMaterial = createDerivedMaterial(baseMaterial, { + chained: true, + extensions: { + derivatives: true, + }, + uniforms: { + uTroikaSDFTexture: { value: null }, + uTroikaSDFTextureSize: { value: new Vector2() }, + uTroikaSDFGlyphSize: { value: 0 }, + uTroikaSDFExponent: { value: 0 }, + uTroikaTotalBounds: { value: new Vector4(0, 0, 0, 0) }, + uTroikaClipRect: { value: new Vector4(0, 0, 0, 0) }, + uTroikaDistanceOffset: { value: 0 }, + uTroikaOutlineOpacity: { value: 0 }, + uTroikaFillOpacity: { value: 1 }, + uTroikaPositionOffset: { value: new Vector2() }, + uTroikaCurveRadius: { value: 0 }, + uTroikaBlurRadius: { value: 0 }, + uTroikaStrokeWidth: { value: 0 }, + uTroikaStrokeColor: { value: new Color() }, + uTroikaStrokeOpacity: { value: 1 }, + uTroikaOrient: { value: new Matrix3() }, + uTroikaUseGlyphColors: { value: true }, + uTroikaSDFDebug: { value: false }, + }, + vertexDefs: VERTEX_DEFS, + vertexTransform: VERTEX_TRANSFORM, + fragmentDefs: FRAGMENT_DEFS, + fragmentColorTransform: FRAGMENT_TRANSFORM, + customRewriter({ vertexShader, fragmentShader }) { + const uDiffuseRE = /\buniform\s+vec3\s+diffuse\b/ + if (uDiffuseRE.test(fragmentShader)) { + // Replace all instances of `diffuse` with our varying + fragmentShader = fragmentShader + .replace(uDiffuseRE, 'varying vec3 vTroikaGlyphColor') + .replace(/\bdiffuse\b/g, 'vTroikaGlyphColor') + // Make sure the vertex shader declares the uniform so we can grab it as a fallback + if (!uDiffuseRE.test(vertexShader)) { + vertexShader = vertexShader.replace( + voidMainRegExp, + 'uniform vec3 diffuse;\n$&\nvTroikaGlyphColor = uTroikaUseGlyphColors ? aTroikaGlyphColor / 255.0 : diffuse;\n' + ) + } + } + return { vertexShader, fragmentShader } + }, + }) + + // Force transparency - TODO is this reasonable? + textMaterial.transparent = true + + Object.defineProperties(textMaterial, { + isTroikaTextMaterial: { value: true }, + + // WebGLShadowMap reverses the side of the shadow material by default, which fails + // for planes, so here we force the `shadowSide` to always match the main side. + shadowSide: { + get() { + return this.side + }, + set() { + //no-op + }, + }, + }) + + return textMaterial +} diff --git a/src/core/Text/troika-three-text/Typesetter.js b/src/core/Text/troika-three-text/Typesetter.js new file mode 100644 index 000000000..dd028a8d3 --- /dev/null +++ b/src/core/Text/troika-three-text/Typesetter.js @@ -0,0 +1,652 @@ +/** + * Factory function that creates a self-contained environment for processing text typesetting requests. + * + * It is important that this function has no closure dependencies, so that it can be easily injected + * into the source for a Worker without requiring a build step or complex dependency loading. All its + * dependencies must be passed in at initialization. + * + * @param {function} fontParser - a function that accepts an ArrayBuffer of the font data and returns + * a standardized structure giving access to the font and its glyphs: + * { + * unitsPerEm: number, + * ascender: number, + * descender: number, + * capHeight: number, + * xHeight: number, + * lineGap: number, + * forEachGlyph(string, fontSize, letterSpacing, callback) { + * //invokes callback for each glyph to render, passing it an object: + * callback({ + * index: number, + * advanceWidth: number, + * xMin: number, + * yMin: number, + * xMax: number, + * yMax: number, + * path: string, + * pathCommandCount: number + * }) + * } + * } + * @param {object} bidi - the bidi.js implementation object + * @param {Object} config + * @return {Object} + */ +export function createTypesetter(fontParser, bidi, config) { + const { defaultFontURL } = config + + /** + * Holds parsed font objects by url + */ + const fonts = Object.create(null) + + const INF = Infinity + + // Set of Unicode Default_Ignorable_Code_Point characters, these will not produce visible glyphs + const DEFAULT_IGNORABLE_CHARS = + /[\u00AD\u034F\u061C\u115F-\u1160\u17B4-\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8]/ + + // Incomplete set of characters that allow line breaking after them + // In the future we may consider a full Unicode line breaking algorithm impl: https://www.unicode.org/reports/tr14 + const BREAK_AFTER_CHARS = /[\s\-\u007C\u00AD\u2010\u2012-\u2014\u2027\u2056\u2E17\u2E40]/ + + /** + * Load a given font url + */ + function doLoadFont(url, callback) { + function tryLoad() { + const onError = (err) => { + console.error(`Failure loading font ${url}${url === defaultFontURL ? '' : '; trying fallback'}`, err) + if (url !== defaultFontURL) { + url = defaultFontURL + tryLoad() + } + } + try { + const request = new XMLHttpRequest() + request.open('get', url, true) + request.responseType = 'arraybuffer' + request.onload = function () { + if (request.status >= 400) { + onError(new Error(request.statusText)) + } else if (request.status > 0) { + try { + const fontObj = fontParser(request.response) + callback(fontObj) + } catch (e) { + onError(e) + } + } + } + request.onerror = onError + request.send() + } catch (err) { + onError(err) + } + } + tryLoad() + } + + /** + * Load a given font url if needed, invoking a callback when it's loaded. If already + * loaded, the callback will be called synchronously. + */ + function loadFont(fontUrl, callback) { + if (!fontUrl) fontUrl = defaultFontURL + const font = fonts[fontUrl] + if (font) { + // if currently loading font, add to callbacks, otherwise execute immediately + if (font.pending) { + font.pending.push(callback) + } else { + callback(font) + } + } else { + fonts[fontUrl] = { pending: [callback] } + doLoadFont(fontUrl, (fontObj) => { + const callbacks = fonts[fontUrl].pending + fonts[fontUrl] = fontObj + callbacks.forEach((cb) => cb(fontObj)) + }) + } + } + + /** + * Main entry point. + * Process a text string with given font and formatting parameters, and return all info + * necessary to render all its glyphs. + */ + function typeset( + { + text = '', + font = defaultFontURL, + fontSize = 1, + letterSpacing = 0, + lineHeight = 'normal', + maxWidth = INF, + direction, + textAlign = 'left', + textIndent = 0, + whiteSpace = 'normal', + overflowWrap = 'normal', + anchorX = 0, + anchorY = 0, + includeCaretPositions = false, + chunkedBoundsSize = 8192, + colorRanges = null, + }, + callback, + metricsOnly = false + ) { + const mainStart = now() + const timings = { fontLoad: 0, typesetting: 0 } + + // Ensure newlines are normalized + if (text.indexOf('\r') > -1) { + console.info('Typesetter: got text with \\r chars; normalizing to \\n') + text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + } + + // Ensure we've got numbers not strings + fontSize = +fontSize + letterSpacing = +letterSpacing + maxWidth = +maxWidth + lineHeight = lineHeight || 'normal' + textIndent = +textIndent + + loadFont(font, (fontObj) => { + const hasMaxWidth = isFinite(maxWidth) + let glyphIds = null + let glyphPositions = null + let glyphData = null + let glyphColors = null + let caretPositions = null + let visibleBounds = null + let chunkedBounds = null + let maxLineWidth = 0 + let renderableGlyphCount = 0 + const canWrap = whiteSpace !== 'nowrap' + const { ascender, descender, unitsPerEm, lineGap, capHeight, xHeight } = fontObj + timings.fontLoad = now() - mainStart + const typesetStart = now() + + // Find conversion between native font units and fontSize units; this will already be done + // for the gx/gy values below but everything else we'll need to convert + const fontSizeMult = fontSize / unitsPerEm + + // Determine appropriate value for 'normal' line height based on the font's actual metrics + // TODO this does not guarantee individual glyphs won't exceed the line height, e.g. Roboto; should we use yMin/Max instead? + if (lineHeight === 'normal') { + lineHeight = (ascender - descender + lineGap) / unitsPerEm + } + + // Determine line height and leading adjustments + lineHeight = lineHeight * fontSize + const halfLeading = (lineHeight - (ascender - descender) * fontSizeMult) / 2 + const topBaseline = -(ascender * fontSizeMult + halfLeading) + const caretHeight = Math.min(lineHeight, (ascender - descender) * fontSizeMult) + const caretBottomOffset = ((ascender + descender) / 2) * fontSizeMult - caretHeight / 2 + + // Distribute glyphs into lines based on wrapping + let lineXOffset = textIndent + let currentLine = new TextLine() + const lines = [currentLine] + + fontObj.forEachGlyph(text, fontSize, letterSpacing, (glyphObj, glyphX, charIndex) => { + const char = text.charAt(charIndex) + const glyphWidth = glyphObj.advanceWidth * fontSizeMult + const curLineCount = currentLine.count + let nextLine + + // Calc isWhitespace and isEmpty once per glyphObj + if (!('isEmpty' in glyphObj)) { + glyphObj.isWhitespace = !!char && /\s/.test(char) + glyphObj.canBreakAfter = !!char && BREAK_AFTER_CHARS.test(char) + glyphObj.isEmpty = + glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax || DEFAULT_IGNORABLE_CHARS.test(char) + } + if (!glyphObj.isWhitespace && !glyphObj.isEmpty) { + renderableGlyphCount++ + } + + // If a non-whitespace character overflows the max width, we need to soft-wrap + if ( + canWrap && + hasMaxWidth && + !glyphObj.isWhitespace && + glyphX + glyphWidth + lineXOffset > maxWidth && + curLineCount + ) { + // If it's the first char after a whitespace, start a new line + if (currentLine.glyphAt(curLineCount - 1).glyphObj.canBreakAfter) { + nextLine = new TextLine() + lineXOffset = -glyphX + } else { + // Back up looking for a whitespace character to wrap at + for (let i = curLineCount; i--; ) { + // If we got the start of the line there's no soft break point; make hard break if overflowWrap='break-word' + if (i === 0 && overflowWrap === 'break-word') { + nextLine = new TextLine() + lineXOffset = -glyphX + break + } + // Found a soft break point; move all chars since it to a new line + else if (currentLine.glyphAt(i).glyphObj.canBreakAfter) { + nextLine = currentLine.splitAt(i + 1) + const adjustX = nextLine.glyphAt(0).x + lineXOffset -= adjustX + for (let j = nextLine.count; j--; ) { + nextLine.glyphAt(j).x -= adjustX + } + break + } + } + } + if (nextLine) { + currentLine.isSoftWrapped = true + currentLine = nextLine + lines.push(currentLine) + maxLineWidth = maxWidth //after soft wrapping use maxWidth as calculated width + } + } + + const fly = currentLine.glyphAt(currentLine.count) + fly.glyphObj = glyphObj + fly.x = glyphX + lineXOffset + fly.width = glyphWidth + fly.charIndex = charIndex + + // Handle hard line breaks + if (char === '\n') { + currentLine = new TextLine() + lines.push(currentLine) + lineXOffset = -(glyphX + glyphWidth + letterSpacing * fontSize) + textIndent + } + }) + + // Calculate width of each line (excluding trailing whitespace) and maximum block width + lines.forEach((line) => { + for (let i = line.count; i--; ) { + const { glyphObj, x, width } = line.glyphAt(i) + if (!glyphObj.isWhitespace) { + line.width = x + width + if (line.width > maxLineWidth) { + maxLineWidth = line.width + } + return + } + } + }) + + // Find overall position adjustments for anchoring + let anchorXOffset = 0 + let anchorYOffset = 0 + if (anchorX) { + if (typeof anchorX === 'number') { + anchorXOffset = -anchorX + } else if (typeof anchorX === 'string') { + anchorXOffset = + -maxLineWidth * + (anchorX === 'left' ? 0 : anchorX === 'center' ? 0.5 : anchorX === 'right' ? 1 : parsePercent(anchorX)) + } + } + if (anchorY) { + if (typeof anchorY === 'number') { + anchorYOffset = -anchorY + } else if (typeof anchorY === 'string') { + const height = lines.length * lineHeight + anchorYOffset = + anchorY === 'top' + ? 0 + : anchorY === 'top-baseline' + ? -topBaseline + : anchorY === 'top-cap' + ? -topBaseline - capHeight * fontSizeMult + : anchorY === 'top-ex' + ? -topBaseline - xHeight * fontSizeMult + : anchorY === 'middle' + ? height / 2 + : anchorY === 'bottom' + ? height + : anchorY === 'bottom-baseline' + ? height - halfLeading + descender * fontSizeMult + : parsePercent(anchorY) * height + } + } + + if (!metricsOnly) { + // Resolve bidi levels + const bidiLevelsResult = bidi.getEmbeddingLevels(text, direction) + + // Process each line, applying alignment offsets, adding each glyph to the atlas, and + // collecting all renderable glyphs into a single collection. + glyphIds = new Uint16Array(renderableGlyphCount) + glyphPositions = new Float32Array(renderableGlyphCount * 2) + glyphData = {} + visibleBounds = [INF, INF, -INF, -INF] + chunkedBounds = [] + let lineYOffset = topBaseline + if (includeCaretPositions) { + caretPositions = new Float32Array(text.length * 3) + } + if (colorRanges) { + glyphColors = new Uint8Array(renderableGlyphCount * 3) + } + let renderableGlyphIndex = 0 + let prevCharIndex = -1 + let colorCharIndex = -1 + let chunk + let currentColor + lines.forEach((line) => { + const { count: lineGlyphCount, width: lineWidth } = line + + // Ignore empty lines + if (lineGlyphCount > 0) { + // Count trailing whitespaces, we want to ignore these for certain things + let trailingWhitespaceCount = 0 + for (let i = lineGlyphCount; i-- && line.glyphAt(i).glyphObj.isWhitespace; ) { + trailingWhitespaceCount++ + } + + // Apply horizontal alignment adjustments + let lineXOffset = 0 + let justifyAdjust = 0 + if (textAlign === 'center') { + lineXOffset = (maxLineWidth - lineWidth) / 2 + } else if (textAlign === 'right') { + lineXOffset = maxLineWidth - lineWidth + } else if (textAlign === 'justify' && line.isSoftWrapped) { + // count non-trailing whitespace characters, and we'll adjust the offsets per character in the next loop + let whitespaceCount = 0 + for (let i = lineGlyphCount - trailingWhitespaceCount; i--; ) { + if (line.glyphAt(i).glyphObj.isWhitespace) { + whitespaceCount++ + } + } + justifyAdjust = (maxLineWidth - lineWidth) / whitespaceCount + } + if (justifyAdjust || lineXOffset) { + let justifyOffset = 0 + for (let i = 0; i < lineGlyphCount; i++) { + const glyphInfo = line.glyphAt(i) + const glyphObj = glyphInfo.glyphObj + glyphInfo.x += lineXOffset + justifyOffset + // Expand non-trailing whitespaces for justify alignment + if (justifyAdjust !== 0 && glyphObj.isWhitespace && i < lineGlyphCount - trailingWhitespaceCount) { + justifyOffset += justifyAdjust + glyphInfo.width += justifyAdjust + } + } + } + + // Perform bidi range flipping + const flips = bidi.getReorderSegments( + text, + bidiLevelsResult, + line.glyphAt(0).charIndex, + line.glyphAt(line.count - 1).charIndex + ) + for (let fi = 0; fi < flips.length; fi++) { + const [start, end] = flips[fi] + // Map start/end string indices to indices in the line + let left = Infinity, + right = -Infinity + for (let i = 0; i < lineGlyphCount; i++) { + if (line.glyphAt(i).charIndex >= start) { + // gte to handle removed characters + const startInLine = i + let endInLine = i + for (; endInLine < lineGlyphCount; endInLine++) { + const info = line.glyphAt(endInLine) + if (info.charIndex > end) { + break + } + if (endInLine < lineGlyphCount - trailingWhitespaceCount) { + //don't include trailing ws in flip width + left = Math.min(left, info.x) + right = Math.max(right, info.x + info.width) + } + } + for (let j = startInLine; j < endInLine; j++) { + const glyphInfo = line.glyphAt(j) + glyphInfo.x = right - (glyphInfo.x + glyphInfo.width - left) + } + break + } + } + } + + // Assemble final data arrays + let glyphObj + const setGlyphObj = (g) => (glyphObj = g) + for (let i = 0; i < lineGlyphCount; i++) { + const glyphInfo = line.glyphAt(i) + glyphObj = glyphInfo.glyphObj + const glyphId = glyphObj.index + + // Replace mirrored characters in rtl + const rtl = bidiLevelsResult.levels[glyphInfo.charIndex] & 1 //odd level means rtl + if (rtl) { + const mirrored = bidi.getMirroredCharacter(text[glyphInfo.charIndex]) + if (mirrored) { + fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj) + } + } + + // Add caret positions + if (includeCaretPositions) { + const { charIndex } = glyphInfo + const caretLeft = glyphInfo.x + anchorXOffset + const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset + caretPositions[charIndex * 3] = rtl ? caretRight : caretLeft //start edge x + caretPositions[charIndex * 3 + 1] = rtl ? caretLeft : caretRight //end edge x + caretPositions[charIndex * 3 + 2] = lineYOffset + caretBottomOffset + anchorYOffset //common bottom y + + // If we skipped any chars from the previous glyph (due to ligature subs), fill in caret + // positions for those missing char indices; currently this uses a best-guess by dividing + // the ligature's width evenly. In the future we may try to use the font's LigatureCaretList + // table to get better interior caret positions. + const ligCount = charIndex - prevCharIndex + if (ligCount > 1) { + fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount) + } + prevCharIndex = charIndex + } + + // Track current color range + if (colorRanges) { + const { charIndex } = glyphInfo + while (charIndex > colorCharIndex) { + colorCharIndex++ + if (colorRanges.hasOwnProperty(colorCharIndex)) { + currentColor = colorRanges[colorCharIndex] + } + } + } + + // Get atlas data for renderable glyphs + if (!glyphObj.isWhitespace && !glyphObj.isEmpty) { + const idx = renderableGlyphIndex++ + + // Add this glyph's path data + if (!glyphData[glyphId]) { + glyphData[glyphId] = { + path: glyphObj.path, + pathBounds: [glyphObj.xMin, glyphObj.yMin, glyphObj.xMax, glyphObj.yMax], + } + } + + // Determine final glyph position and add to glyphPositions array + const glyphX = glyphInfo.x + anchorXOffset + const glyphY = lineYOffset + anchorYOffset + glyphPositions[idx * 2] = glyphX + glyphPositions[idx * 2 + 1] = glyphY + + // Track total visible bounds + const visX0 = glyphX + glyphObj.xMin * fontSizeMult + const visY0 = glyphY + glyphObj.yMin * fontSizeMult + const visX1 = glyphX + glyphObj.xMax * fontSizeMult + const visY1 = glyphY + glyphObj.yMax * fontSizeMult + if (visX0 < visibleBounds[0]) visibleBounds[0] = visX0 + if (visY0 < visibleBounds[1]) visibleBounds[1] = visY0 + if (visX1 > visibleBounds[2]) visibleBounds[2] = visX1 + if (visY1 > visibleBounds[3]) visibleBounds[3] = visY1 + + // Track bounding rects for each chunk of N glyphs + if (idx % chunkedBoundsSize === 0) { + chunk = { start: idx, end: idx, rect: [INF, INF, -INF, -INF] } + chunkedBounds.push(chunk) + } + chunk.end++ + const chunkRect = chunk.rect + if (visX0 < chunkRect[0]) chunkRect[0] = visX0 + if (visY0 < chunkRect[1]) chunkRect[1] = visY0 + if (visX1 > chunkRect[2]) chunkRect[2] = visX1 + if (visY1 > chunkRect[3]) chunkRect[3] = visY1 + + // Add to glyph ids array + glyphIds[idx] = glyphId + + // Add colors + if (colorRanges) { + const start = idx * 3 + glyphColors[start] = (currentColor >> 16) & 255 + glyphColors[start + 1] = (currentColor >> 8) & 255 + glyphColors[start + 2] = currentColor & 255 + } + } + } + } + + // Increment y offset for next line + lineYOffset -= lineHeight + }) + + // Fill in remaining caret positions in case the final character was a ligature + if (caretPositions) { + const ligCount = text.length - prevCharIndex + if (ligCount > 1) { + fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount) + } + } + } + + // Timing stats + timings.typesetting = now() - typesetStart + + callback({ + glyphIds, //font indices for each glyph + glyphPositions, //x,y of each glyph's origin in layout + glyphData, //dict holding data about each glyph appearing in the text + caretPositions, //startX,endX,bottomY caret positions for each char + caretHeight, //height of cursor from bottom to top + glyphColors, //color for each glyph, if color ranges supplied + chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs + fontSize, //calculated em height + unitsPerEm, //font units per em + ascender: ascender * fontSizeMult, //font ascender + descender: descender * fontSizeMult, //font descender + capHeight: capHeight * fontSizeMult, //font cap-height + xHeight: xHeight * fontSizeMult, //font x-height + lineHeight, //computed line height + topBaseline, //y coordinate of the top line's baseline + blockBounds: [ + //bounds for the whole block of text, including vertical padding for lineHeight + anchorXOffset, + anchorYOffset - lines.length * lineHeight, + anchorXOffset + maxLineWidth, + anchorYOffset, + ], + visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds + timings, + }) + }) + } + + /** + * For a given text string and font parameters, determine the resulting block dimensions + * after wrapping for the given maxWidth. + * @param args + * @param callback + */ + function measure(args, callback) { + typeset( + args, + (result) => { + const [x0, y0, x1, y1] = result.blockBounds + callback({ + width: x1 - x0, + height: y1 - y0, + }) + }, + { metricsOnly: true } + ) + } + + function parsePercent(str) { + const match = str.match(/^([\d.]+)%$/) + const pct = match ? parseFloat(match[1]) : NaN + return isNaN(pct) ? 0 : pct / 100 + } + + function fillLigatureCaretPositions(caretPositions, ligStartIndex, ligCount) { + const ligStartX = caretPositions[ligStartIndex * 3] + const ligEndX = caretPositions[ligStartIndex * 3 + 1] + const ligY = caretPositions[ligStartIndex * 3 + 2] + const guessedAdvanceX = (ligEndX - ligStartX) / ligCount + for (let i = 0; i < ligCount; i++) { + const startIndex = (ligStartIndex + i) * 3 + caretPositions[startIndex] = ligStartX + guessedAdvanceX * i + caretPositions[startIndex + 1] = ligStartX + guessedAdvanceX * (i + 1) + caretPositions[startIndex + 2] = ligY + } + } + + function now() { + return (self.performance || Date).now() + } + + // Array-backed structure for a single line's glyphs data + function TextLine() { + this.data = [] + } + const textLineProps = ['glyphObj', 'x', 'width', 'charIndex'] + TextLine.prototype = { + width: 0, + isSoftWrapped: false, + get count() { + return Math.ceil(this.data.length / textLineProps.length) + }, + glyphAt(i) { + const fly = TextLine.flyweight + fly.data = this.data + fly.index = i + return fly + }, + splitAt(i) { + const newLine = new TextLine() + newLine.data = this.data.splice(i * textLineProps.length) + return newLine + }, + } + TextLine.flyweight = textLineProps.reduce( + (obj, prop, i) => { + Object.defineProperty(obj, prop, { + get() { + return this.data[this.index * textLineProps.length + i] + }, + set(val) { + this.data[this.index * textLineProps.length + i] = val + }, + }) + return obj + }, + { data: null, index: 0 } + ) + + return { + typeset, + measure, + loadFont, + } +} diff --git a/src/core/Text/troika-three-text/index.js b/src/core/Text/troika-three-text/index.js new file mode 100644 index 000000000..dc728b0bc --- /dev/null +++ b/src/core/Text/troika-three-text/index.js @@ -0,0 +1,3 @@ +// troika-three-text 0.46.3 fca9aae20e7b67cbd7ac3669dd91257ec84f1997 +export { preloadFont } from './TextBuilder.js' +export { Text } from './Text.js' diff --git a/src/core/Text/troika-three-text/libs/typr.factory.js b/src/core/Text/troika-three-text/libs/typr.factory.js new file mode 100644 index 000000000..6f3c778bb --- /dev/null +++ b/src/core/Text/troika-three-text/libs/typr.factory.js @@ -0,0 +1,6 @@ +/*! +Custom build of Typr.ts (https://github.com/fredli74/Typr.ts) for use in Troika text rendering. +Original MIT license applies: https://github.com/fredli74/Typr.ts/blob/master/LICENSE +*/ +// prettier-ignore +export default function(){return"undefined"==typeof window&&(self.window=self),function(r){"use strict";var e={parse:function(r){var t=e._bin,a=new Uint8Array(r);if("ttcf"==t.readASCII(a,0,4)){var n=4;t.readUshort(a,n),n+=2,t.readUshort(a,n),n+=2;var o=t.readUint(a,n);n+=4;for(var s=[],i=0;i>>t&1)&&e++;return e},e._lctf.readClassDef=function(r,t){var a=e._bin,n=[],o=a.readUshort(r,t);if(t+=2,1==o){var s=a.readUshort(r,t);t+=2;var i=a.readUshort(r,t);t+=2;for(var h=0;h0&&(o.featureParams=n+s);var i=a.readUshort(r,t);t+=2,o.tab=[];for(var h=0;h255?-1:e.CFF.glyphByUnicode(r,e.CFF.tableSE[t])},e.CFF.readEncoding=function(r,t,a){e._bin;var n=[".notdef"],o=r[t];if(t++,0!=o)throw"error: unknown encoding format: "+o;var s=r[t];t++;for(var i=0;i>4,p=15&v;if(15!=c&&u.push(c),15!=p&&u.push(p),15==p)break}for(var U="",g=[0,1,2,3,4,5,6,7,8,9,".","e","e-","reserved","-","endOfNumber"],S=0;S=s.xMax||s.yMin>=s.yMax)return null;if(s.noc>0){s.endPts=[];for(var i=0;i=1&&i.fmt<=2){f=o.readUshort(r,a);a+=2;var l=o.readUshort(r,a);a+=2;d=e._lctf.numOfOnes(f);var u=e._lctf.numOfOnes(l);if(1==i.fmt){i.pairsets=[];var v=o.readUshort(r,a);a+=2;for(var c=0;c=1&&i.fmt<=2){if(1==i.fmt)i.delta=o.readShort(r,a),a+=2;else if(2==i.fmt){var f=o.readUshort(r,a);a+=2,i.newg=o.readUshorts(r,a,f),a+=2*i.newg.length}}else if(4==t){i.vals=[];f=o.readUshort(r,a);a+=2;for(var d=0;d>>8;if(0!=(l&=15))throw"unknown kern table format: "+l;t=e.kern.readFormat0(r,t,h)}return h},e.kern.parseV1=function(r,t,a,n){var o=e._bin;o.readFixed(r,t),t+=4;var s=o.readUint(r,t);t+=4;for(var i={glyph1:[],rval:[]},h=0;h>>8;if(0!=(d&=15))throw"unknown kern table format: "+d;t=e.kern.readFormat0(r,t,i)}return i},e.kern.readFormat0=function(r,t,a){var n=e._bin,o=-1,s=n.readUshort(r,t);t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2;for(var i=0;i=n.map.length?0:n.map[e];if(4==n.format){for(var o=-1,s=0;se)return 0;return 65535&(0!=n.idRangeOffset[o]?n.glyphIdArray[e-n.startCount[o]+(n.idRangeOffset[o]>>1)-(n.idRangeOffset.length-o)]:e+n.idDelta[o])}if(12==n.format){if(e>n.groups[n.groups.length-1][1])return 0;for(s=0;s-1?e.U._simpleGlyph(n,a):e.U._compoGlyph(n,t,a))},e.U._simpleGlyph=function(r,t){for(var a=0;ao)){for(var v=!0,c=0,p=0;po)){for(v=!0,p=0;p>1,s.length=0,h=!0;else if("o3"==x||"o23"==x){s.length%2!=0&&!h&&(f=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0}else if("o4"==x)s.length>1&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),d&&e.U.P.closePath(o),v+=s.pop(),e.U.P.moveTo(o,u,v),d=!0;else if("o5"==x)for(;s.length>0;)u+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,u,v);else if("o6"==x||"o7"==x)for(var P=s.length,I="o6"==x,w=0;wMath.abs(y-v)?u=b+s.shift():v=y+s.shift(),e.U.P.curveTo(o,c,p,U,g,F,_),e.U.P.curveTo(o,S,m,b,y,u,v));else if("o14"==x){if(s.length>0&&!h&&(f=s.shift()+a.nominalWidthX,h=!0),4==s.length){var k=s.shift(),G=s.shift(),D=s.shift(),B=s.shift(),L=e.CFF.glyphBySE(a,D),R=e.CFF.glyphBySE(a,B);e.U._drawCFF(a.CharStrings[L],t,a,n,o),t.x=k,t.y=G,e.U._drawCFF(a.CharStrings[R],t,a,n,o)}d&&(e.U.P.closePath(o),d=!1)}else if("o19"==x||"o20"==x){s.length%2!=0&&!h&&(f=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0,l+=i+7>>3}else if("o21"==x)s.length>2&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),v+=s.pop(),u+=s.pop(),d&&e.U.P.closePath(o),e.U.P.moveTo(o,u,v),d=!0;else if("o22"==x)s.length>1&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),u+=s.pop(),d&&e.U.P.closePath(o),e.U.P.moveTo(o,u,v),d=!0;else if("o25"==x){for(;s.length>6;)u+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,u,v);c=u+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),u=U+s.shift(),v=g+s.shift(),e.U.P.curveTo(o,c,p,U,g,u,v)}else if("o26"==x)for(s.length%2&&(u+=s.shift());s.length>0;)c=u,p=v+s.shift(),u=U=c+s.shift(),v=(g=p+s.shift())+s.shift(),e.U.P.curveTo(o,c,p,U,g,u,v);else if("o27"==x)for(s.length%2&&(v+=s.shift());s.length>0;)p=v,U=(c=u+s.shift())+s.shift(),g=p+s.shift(),u=U+s.shift(),v=g,e.U.P.curveTo(o,c,p,U,g,u,v);else if("o10"==x||"o29"==x){var A="o10"==x?n:a;if(0==s.length)console.debug("error: empty stack");else{var W=s.pop(),M=A.Subrs[W+A.Bias];t.x=u,t.y=v,t.nStems=i,t.haveWidth=h,t.width=f,t.open=d,e.U._drawCFF(M,t,a,n,o),u=t.x,v=t.y,i=t.nStems,h=t.haveWidth,f=t.width,d=t.open}}else if("o30"==x||"o31"==x){var V=s.length,N=(T=0,"o31"==x);for(T+=V-(P=-3&V);T>>1|(21845&g)<<1;h=(61680&(h=(52428&h)>>>2|(13107&h)<<2))>>>4|(3855&h)<<4,c[g]=((65280&h)>>>8|(255&h)<<8)>>>1}var w=function(r,e,t){for(var a=r.length,i=0,o=new n(e);i>>v]=s}else for(f=new n(a),i=0;i>>15-r[i]);return f},d=new e(288);for(g=0;g<144;++g)d[g]=8;for(g=144;g<256;++g)d[g]=9;for(g=256;g<280;++g)d[g]=7;for(g=280;g<288;++g)d[g]=8;var m=new e(32);for(g=0;g<32;++g)m[g]=5;var b=w(d,9,1),p=w(m,5,1),y=function(r){for(var e=r[0],n=1;ne&&(e=r[n]);return e},L=function(r,e,n){var t=e/8|0;return(r[t]|r[t+1]<<8)>>(7&e)&n},U=function(r,e){var n=e/8|0;return(r[n]|r[n+1]<<8|r[n+2]<<16)>>(7&e)},k=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,e,n){var t=new Error(e||k[r]);if(t.code=r,Error.captureStackTrace&&Error.captureStackTrace(t,T),!n)throw t;return t},O=function(r,f,u){var s=r.length;if(!s||u&&!u.l&&s<5)return f||new e(0);var c=!f||u,g=!u||u.i;u||(u={}),f||(f=new e(3*s));var h,d=function(r){var n=f.length;if(r>n){var t=new e(Math.max(2*n,r));t.set(f),f=t}},m=u.f||0,k=u.p||0,O=u.b||0,A=u.l,x=u.d,E=u.m,D=u.n,M=8*s;do{if(!A){u.f=m=L(r,k,1);var S=L(r,k+1,3);if(k+=3,!S){var V=r[(I=((h=k)/8|0)+(7&h&&1)+4)-4]|r[I-3]<<8,_=I+V;if(_>s){g&&T(0);break}c&&d(O+V),f.set(r.subarray(I,_),O),u.b=O+=V,u.p=k=8*_;continue}if(1==S)A=b,x=p,E=9,D=5;else if(2==S){var j=L(r,k,31)+257,z=L(r,k+10,15)+4,C=j+L(r,k+5,31)+1;k+=14;for(var F=new e(C),P=new e(19),q=0;q>>4)<16)F[q++]=I;else{var K=0,N=0;for(16==I?(N=3+L(r,k,3),k+=2,K=F[q-1]):17==I?(N=3+L(r,k,7),k+=3):18==I&&(N=11+L(r,k,127),k+=7);N--;)F[q++]=K}}var Q=F.subarray(0,j),R=F.subarray(j);E=y(Q),D=y(R),A=w(Q,E,1),x=w(R,D,1)}else T(1);if(k>M){g&&T(0);break}}c&&d(O+131072);for(var W=(1<>>4;if((k+=15&K)>M){g&&T(0);break}if(K||T(2),Z<256)f[O++]=Z;else{if(256==Z){Y=k,A=null;break}var $=Z-254;if(Z>264){var rr=a[q=Z-257];$=L(r,k,(1<>>4;er||T(3),k+=15&er;R=l[nr];if(nr>3){rr=i[nr];R+=U(r,k)&(1<M){g&&T(0);break}c&&d(O+131072);for(var tr=O+$;Or.length)&&(i=r.length);var o=new(r instanceof n?n:r instanceof t?t:e)(i-a);return o.set(r.subarray(a,i)),o}(f,0,O)},A=new e(0);var x="undefined"!=typeof TextDecoder&&new TextDecoder;try{x.decode(A,{stream:!0}),1}catch(r){}return r.convert_streams=function(r){var e=new DataView(r),n=0;function t(){var r=e.getUint16(n);return n+=2,r}function a(){var r=e.getUint32(n);return n+=4,r}function i(r){m.setUint16(b,r),b+=2}function o(r){m.setUint32(b,r),b+=4}for(var f={signature:a(),flavor:a(),length:a(),numTables:t(),reserved:t(),totalSfntSize:a(),majorVersion:t(),minorVersion:t(),metaOffset:a(),metaLength:a(),metaOrigLength:a(),privOffset:a(),privLength:a()},u=0;Math.pow(2,u)<=f.numTables;)u++;u--;for(var v=16*Math.pow(2,u),s=16*f.numTables-v,l=12,c=[],g=0;g { + if (Math.abs(y - (rowY + caretHeight / 2)) < Math.abs(y - (closestRowY + caretHeight / 2))) { + closestRowY = rowY + } + }) + + // Then find closest caret by x within that row + caretsByRow.get(closestRowY).forEach((caret) => { + if (!closestCaret || Math.abs(x - caret.x) < Math.abs(x - closestCaret.x)) { + closestCaret = caret + } + }) + return closestCaret +} + +const _rectsCache = new WeakMap() + +/** + * Given start and end character indexes, return a list of rectangles covering all the + * characters within that selection. + * @param {TroikaTextRenderInfo} textRenderInfo + * @param {number} start - index of the first char in the selection + * @param {number} end - index of the first char after the selection + * @return {Array<{left, top, right, bottom}> | null} + */ +export function getSelectionRects(textRenderInfo, start, end) { + let rects + if (textRenderInfo) { + // Check cache - textRenderInfo is frozen so it's safe to cache based on it + const prevResult = _rectsCache.get(textRenderInfo) + if (prevResult && prevResult.start === start && prevResult.end === end) { + return prevResult.rects + } + + const { caretPositions, caretHeight } = textRenderInfo + + // Normalize + if (end < start) { + const s = start + start = end + end = s + } + start = Math.max(start, 0) + end = Math.min(end, caretPositions.length + 1) + + // Build list of rects, expanding the current rect for all characters in a run and starting + // a new rect whenever reaching a new line or a new bidi direction + rects = [] + let currentRect = null + for (let i = start; i < end; i++) { + const x1 = caretPositions[i * 3] + const x2 = caretPositions[i * 3 + 1] + const left = Math.min(x1, x2) + const right = Math.max(x1, x2) + const bottom = caretPositions[i * 3 + 2] + if (!currentRect || bottom !== currentRect.bottom || left > currentRect.right || right < currentRect.left) { + currentRect = { + left: Infinity, + right: -Infinity, + bottom: bottom, + top: bottom + caretHeight, + } + rects.push(currentRect) + } + currentRect.left = Math.min(left, currentRect.left) + currentRect.right = Math.max(right, currentRect.right) + } + + // Merge any overlapping rects, e.g. those formed by adjacent bidi runs + rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left) + for (let i = rects.length - 1; i-- > 0; ) { + const rectA = rects[i] + const rectB = rects[i + 1] + if (rectA.bottom === rectB.bottom && rectA.left <= rectB.right && rectA.right >= rectB.left) { + rectB.left = Math.min(rectB.left, rectA.left) + rectB.right = Math.max(rectB.right, rectA.right) + rects.splice(i, 1) + } + } + + _rectsCache.set(textRenderInfo, { start, end, rects }) + } + return rects +} + +const _caretsByRowCache = new WeakMap() + +function groupCaretsByRow(textRenderInfo) { + // textRenderInfo is frozen so it's safe to cache based on it + let caretsByRow = _caretsByRowCache.get(textRenderInfo) + if (!caretsByRow) { + const { caretPositions, caretHeight } = textRenderInfo + caretsByRow = new Map() + for (let i = 0; i < caretPositions.length; i += 3) { + const rowY = caretPositions[i + 2] + let rowCarets = caretsByRow.get(rowY) + if (!rowCarets) { + caretsByRow.set(rowY, (rowCarets = [])) + } + rowCarets.push({ + x: caretPositions[i], + y: rowY, + height: caretHeight, + charIndex: i / 3, + }) + // Add one more caret after the final char + if (i + 3 >= caretPositions.length) { + rowCarets.push({ + x: caretPositions[i + 1], + y: rowY, + height: caretHeight, + charIndex: i / 3 + 1, + }) + } + } + } + _caretsByRowCache.set(textRenderInfo, caretsByRow) + return caretsByRow +} diff --git a/src/core/Text/troika-three-text/woff2otf.js b/src/core/Text/troika-three-text/woff2otf.js new file mode 100644 index 000000000..03d533f64 --- /dev/null +++ b/src/core/Text/troika-three-text/woff2otf.js @@ -0,0 +1,152 @@ +/* + Copyright 2012, Steffen Hanikel (https://github.com/hanikesn) + Modified by Artemy Tregubenko, 2014 (https://github.com/arty-name/woff2otf) + Modified by Jason Johnston, 2019 (pako --> tiny-inflate) + + 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. + + A tool to convert a WOFF back to a TTF/OTF font file, in pure Javascript +*/ + +import { inflateSync } from 'fflate' + +export function convert_streams(bufferIn) { + var dataViewIn = new DataView(bufferIn) + var offsetIn = 0 + + function read2() { + var uint16 = dataViewIn.getUint16(offsetIn) + offsetIn += 2 + return uint16 + } + + function read4() { + var uint32 = dataViewIn.getUint32(offsetIn) + offsetIn += 4 + return uint32 + } + + function write2(uint16) { + dataViewOut.setUint16(offsetOut, uint16) + offsetOut += 2 + } + + function write4(uint32) { + dataViewOut.setUint32(offsetOut, uint32) + offsetOut += 4 + } + + var WOFFHeader = { + signature: read4(), + flavor: read4(), + length: read4(), + numTables: read2(), + reserved: read2(), + totalSfntSize: read4(), + majorVersion: read2(), + minorVersion: read2(), + metaOffset: read4(), + metaLength: read4(), + metaOrigLength: read4(), + privOffset: read4(), + privLength: read4(), + } + + var entrySelector = 0 + while (Math.pow(2, entrySelector) <= WOFFHeader.numTables) { + entrySelector++ + } + entrySelector-- + + var searchRange = Math.pow(2, entrySelector) * 16 + var rangeShift = WOFFHeader.numTables * 16 - searchRange + + var offset = 4 + 2 + 2 + 2 + 2 + var TableDirectoryEntries = [] + for (var i = 0; i < WOFFHeader.numTables; i++) { + TableDirectoryEntries.push({ + tag: read4(), + offset: read4(), + compLength: read4(), + origLength: read4(), + origChecksum: read4(), + }) + offset += 4 * 4 + } + + var arrayOut = new Uint8Array( + 4 + + 2 + + 2 + + 2 + + 2 + + TableDirectoryEntries.length * (4 + 4 + 4 + 4) + + TableDirectoryEntries.reduce(function (acc, entry) { + return acc + entry.origLength + 4 + }, 0) + ) + var bufferOut = arrayOut.buffer + var dataViewOut = new DataView(bufferOut) + var offsetOut = 0 + + write4(WOFFHeader.flavor) + write2(WOFFHeader.numTables) + write2(searchRange) + write2(entrySelector) + write2(rangeShift) + + TableDirectoryEntries.forEach(function (TableDirectoryEntry) { + write4(TableDirectoryEntry.tag) + write4(TableDirectoryEntry.origChecksum) + write4(offset) + write4(TableDirectoryEntry.origLength) + + TableDirectoryEntry.outOffset = offset + offset += TableDirectoryEntry.origLength + if (offset % 4 != 0) { + offset += 4 - (offset % 4) + } + }) + + var size + + TableDirectoryEntries.forEach(function (TableDirectoryEntry) { + var compressedData = bufferIn.slice( + TableDirectoryEntry.offset, + TableDirectoryEntry.offset + TableDirectoryEntry.compLength + ) + + if (TableDirectoryEntry.compLength != TableDirectoryEntry.origLength) { + var uncompressedData = new Uint8Array(TableDirectoryEntry.origLength) + inflateSync( + new Uint8Array(compressedData, 2), //skip deflate header + uncompressedData + ) + } else { + uncompressedData = new Uint8Array(compressedData) + } + + arrayOut.set(uncompressedData, TableDirectoryEntry.outOffset) + offset = TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength + + var padding = 0 + if (offset % 4 != 0) { + padding = 4 - (offset % 4) + } + arrayOut.set(new Uint8Array(padding).buffer, TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength) + + size = offset + padding + }) + + return bufferOut.slice(0, size) +} diff --git a/yarn.lock b/yarn.lock index 4318c2bdb..b512739e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11498,21 +11498,6 @@ trim@0.0.1: resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= -troika-three-text@^0.46.4: - version "0.46.4" - resolved "https://registry.yarnpkg.com/troika-three-text/-/troika-three-text-0.46.4.tgz#77627ac2ac4765d5248c857a8b42f82c25f2d034" - integrity sha512-Qsv0HhUKTZgSmAJs5wvO7YlBoJSP9TGPLmrg+K9pbQq4lseQdcevbno/WI38bwJBZ/qS56hvfqEzY0zUEFzDIw== - dependencies: - bidi-js "^1.0.2" - troika-three-utils "^0.46.0" - troika-worker-utils "^0.46.0" - webgl-sdf-generator "1.1.1" - -troika-three-utils@^0.46.0: - version "0.46.0" - resolved "https://registry.yarnpkg.com/troika-three-utils/-/troika-three-utils-0.46.0.tgz#6d97a9bf08f2260285edf2bb0be6328dd3d50eec" - integrity sha512-llHyrXAcwzr0bpg80GxsIp73N7FuImm4WCrKDJkAqcAsWmE5pfP9+Qzw+oMWK1P/AdHQ79eOrOl9NjyW4aOw0w== - troika-worker-utils@^0.46.0: version "0.46.0" resolved "https://registry.yarnpkg.com/troika-worker-utils/-/troika-worker-utils-0.46.0.tgz#1b698090af78b51a27e03881c90237a2e648d6c4" @@ -12080,7 +12065,7 @@ webgl-constants@^1.1.1: resolved "https://registry.yarnpkg.com/webgl-constants/-/webgl-constants-1.1.1.tgz#f9633ee87fea56647a60b9ce735cbdfb891c6855" integrity sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg== -webgl-sdf-generator@1.1.1: +webgl-sdf-generator@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz#3e1b422b3d87cd3cc77f2602c9db63bc0f6accbd" integrity sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA== From 986eb9b33957e7128c6e21f79fe1de6af0d25816 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:19:18 -0600 Subject: [PATCH 2/9] fix: remove IIFE in SDFGenerator --- .../Text/troika-three-text/SDFGenerator.js | 155 +++++++++--------- 1 file changed, 77 insertions(+), 78 deletions(-) diff --git a/src/core/Text/troika-three-text/SDFGenerator.js b/src/core/Text/troika-three-text/SDFGenerator.js index 4a061aecb..5f9889356 100644 --- a/src/core/Text/troika-three-text/SDFGenerator.js +++ b/src/core/Text/troika-three-text/SDFGenerator.js @@ -33,95 +33,94 @@ export function generateSDF(width, height, path, viewBox, distance, exponent, ca * WebGL-based implementation executed on the main thread. Requests are executed in time-bounded * macrotask chunks to allow render frames to execute in between. */ -const generateSDF_GL = /*#__PURE__*/ (function () { - const queue = [] - const chunkTimeBudget = 5 //ms - let timer = 0 - function nextChunk() { - const start = now() - while (queue.length && now() - start < chunkTimeBudget) { - queue.shift()() - } - timer = queue.length ? setTimeout(nextChunk, 0) : 0 +const queue = [] +const chunkTimeBudget = 5 //ms +let timer = 0 + +function nextChunk() { + const start = now() + while (queue.length && now() - start < chunkTimeBudget) { + queue.shift()() } - return (...args) => { - return new Promise((resolve, reject) => { - queue.push(() => { - const start = now() - try { - mainThreadGenerator.webgl.generateIntoCanvas(...args) - resolve({ timing: now() - start }) - } catch (err) { - reject(err) - } - }) - if (!timer) { - timer = setTimeout(nextChunk, 0) + timer = queue.length ? setTimeout(nextChunk, 0) : 0 +} + +const generateSDF_GL = (...args) => { + return new Promise((resolve, reject) => { + queue.push(() => { + const start = now() + try { + mainThreadGenerator.webgl.generateIntoCanvas(...args) + resolve({ timing: now() - start }) + } catch (err) { + reject(err) } }) - } -})() + if (!timer) { + timer = setTimeout(nextChunk, 0) + } + }) +} /** * Fallback JS-based implementation, fanned out to a number of worker threads for parallelism */ -const generateSDF_JS_Worker = /*#__PURE__*/ (function () { - const threadCount = 4 //how many workers to spawn - const idleTimeout = 2000 //workers will be terminated after being idle this many milliseconds - const threads = {} - let callNum = 0 - return function (width, height, path, viewBox, distance, exponent, canvas, x, y, channel) { - const workerId = 'TroikaTextSDFGenerator_JS_' + (callNum++ % threadCount) - let thread = threads[workerId] - if (!thread) { - thread = threads[workerId] = { - workerModule: defineWorkerModule({ - name: workerId, - workerId, - dependencies: [createSDFGenerator, now], - init(_createSDFGenerator, now) { - const generate = _createSDFGenerator().javascript.generate - return function (...args) { - const start = now() - const textureData = generate(...args) - return { - textureData, - timing: now() - start, - } +const threadCount = 4 // how many workers to spawn +const idleTimeout = 2000 // workers will be terminated after being idle this many milliseconds +const threads = {} +let callNum = 0 + +function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) { + const workerId = 'TroikaTextSDFGenerator_JS_' + (callNum++ % threadCount) + let thread = threads[workerId] + if (!thread) { + thread = threads[workerId] = { + workerModule: defineWorkerModule({ + name: workerId, + workerId, + dependencies: [createSDFGenerator, now], + init(_createSDFGenerator, now) { + const generate = _createSDFGenerator().javascript.generate + return function (...args) { + const start = now() + const textureData = generate(...args) + return { + textureData, + timing: now() - start, } - }, - getTransferables(result) { - return [result.textureData.buffer] - }, - }), - requests: 0, - idleTimer: null, - } + } + }, + getTransferables(result) { + return [result.textureData.buffer] + }, + }), + requests: 0, + idleTimer: null, } + } - thread.requests++ - clearTimeout(thread.idleTimer) - return thread.workerModule(width, height, path, viewBox, distance, exponent).then(({ textureData, timing }) => { - // copy result data into the canvas - const start = now() - // expand single-channel data into rgba - const imageData = new Uint8Array(textureData.length * 4) - for (let i = 0; i < textureData.length; i++) { - imageData[i * 4 + channel] = textureData[i] - } - mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) - timing += now() - start + thread.requests++ + clearTimeout(thread.idleTimer) + return thread.workerModule(width, height, path, viewBox, distance, exponent).then(({ textureData, timing }) => { + // copy result data into the canvas + const start = now() + // expand single-channel data into rgba + const imageData = new Uint8Array(textureData.length * 4) + for (let i = 0; i < textureData.length; i++) { + imageData[i * 4 + channel] = textureData[i] + } + mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) + timing += now() - start - // clean up workers after a while - if (--thread.requests === 0) { - thread.idleTimer = setTimeout(() => { - terminateWorker(workerId) - }, idleTimeout) - } - return { timing } - }) - } -})() + // clean up workers after a while + if (--thread.requests === 0) { + thread.idleTimer = setTimeout(() => { + terminateWorker(workerId) + }, idleTimeout) + } + return { timing } + }) +} export function warmUpSDFCanvas(canvas) { if (!canvas._warm) { From 2aa8faa950c070fd375599c7989e6ef7c36675f6 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:30:23 -0600 Subject: [PATCH 3/9] chore(TextBuilder): remove assign polyfill --- src/core/Text/troika-three-text/TextBuilder.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/core/Text/troika-three-text/TextBuilder.js b/src/core/Text/troika-three-text/TextBuilder.js index 07bd56cfa..0d66b8234 100644 --- a/src/core/Text/troika-three-text/TextBuilder.js +++ b/src/core/Text/troika-three-text/TextBuilder.js @@ -78,7 +78,7 @@ const atlases = Object.create(null) */ function getTextRenderInfo(args, callback) { hasRequested = true - args = assign({}, args) + args = Object.assign({}, args) const totalStart = now() // Apply default font here to avoid a 'null' atlas, and convert relative @@ -348,16 +348,6 @@ export function preloadFont({ font, characters, sdfGlyphSize }, callback) { getTextRenderInfo({ font, sdfGlyphSize, text }, callback) } -// Local assign impl so we don't have to import troika-core -function assign(toObj, fromObj) { - for (const key in fromObj) { - if (fromObj.hasOwnProperty(key)) { - toObj[key] = fromObj[key] - } - } - return toObj -} - // Utility for making URLs absolute let linkEl function toAbsoluteURL(path) { From 86f98c1d912b9e7530a57f7f6b339170593f50c3 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:40:26 -0600 Subject: [PATCH 4/9] chore: fix build --- src/core/Text/troika-three-text/Text.js | 1 + src/core/Text/troika-three-text/TextBuilder.js | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/Text/troika-three-text/Text.js b/src/core/Text/troika-three-text/Text.js index 79ff25b11..476383826 100644 --- a/src/core/Text/troika-three-text/Text.js +++ b/src/core/Text/troika-three-text/Text.js @@ -1,6 +1,7 @@ import { Color, DoubleSide, FrontSide, Matrix4, Mesh, MeshBasicMaterial, PlaneGeometry, Vector3, Vector2 } from 'three' import { GlyphsGeometry } from './GlyphsGeometry.js' import { createTextDerivedMaterial } from './TextDerivedMaterial.js' +import { getTextRenderInfo } from './TextBuilder.js' const defaultMaterial = /*#__PURE__*/ new MeshBasicMaterial({ color: 0xffffff, diff --git a/src/core/Text/troika-three-text/TextBuilder.js b/src/core/Text/troika-three-text/TextBuilder.js index 0d66b8234..f377df59d 100644 --- a/src/core/Text/troika-three-text/TextBuilder.js +++ b/src/core/Text/troika-three-text/TextBuilder.js @@ -77,7 +77,6 @@ const atlases = Object.create(null) * @param {getTextRenderInfo~callback} callback */ function getTextRenderInfo(args, callback) { - hasRequested = true args = Object.assign({}, args) const totalStart = now() From a89ff72f69820eae076254f76018f491aad37ff8 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:43:44 -0600 Subject: [PATCH 5/9] fix(TextBuilder): export getTextRenderInfo --- src/core/Text/troika-three-text/TextBuilder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/Text/troika-three-text/TextBuilder.js b/src/core/Text/troika-three-text/TextBuilder.js index f377df59d..1bcd04e11 100644 --- a/src/core/Text/troika-three-text/TextBuilder.js +++ b/src/core/Text/troika-three-text/TextBuilder.js @@ -76,7 +76,7 @@ const atlases = Object.create(null) * @param {object} args * @param {getTextRenderInfo~callback} callback */ -function getTextRenderInfo(args, callback) { +export function getTextRenderInfo(args, callback) { args = Object.assign({}, args) const totalStart = now() From d0672397a35f8c4b6e9c0e1d78f3f1ced8441a63 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:48:00 -0600 Subject: [PATCH 6/9] chore(DerivedMaterial): disable erroneous lint rule --- .../Text/troika-three-text/DerivedMaterial.js | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/core/Text/troika-three-text/DerivedMaterial.js b/src/core/Text/troika-three-text/DerivedMaterial.js index 4131f8091..89d3d4da7 100644 --- a/src/core/Text/troika-three-text/DerivedMaterial.js +++ b/src/core/Text/troika-three-text/DerivedMaterial.js @@ -274,14 +274,24 @@ export function createDerivedMaterial(baseMaterial, options) { } function upgradeShaders({ vertexShader, fragmentShader }, options, key) { - let { vertexDefs, vertexMainIntro, vertexMainOutro, fragmentDefs, fragmentMainIntro, fragmentMainOutro } = options - - vertexDefs = vertexDefs || '' - vertexMainIntro = vertexMainIntro || '' - vertexMainOutro = vertexMainOutro || '' - fragmentDefs = fragmentDefs || '' - fragmentMainIntro = fragmentMainIntro || '' - fragmentMainOutro = fragmentMainOutro || '' + let { + vertexDefs = '', + vertexMainIntro = '', + // eslint-disable-next-line prefer-const + vertexMainOutro = '', + // eslint-disable-next-line prefer-const + vertexTransform, + fragmentDefs = '', + // eslint-disable-next-line prefer-const + fragmentMainIntro = '', + fragmentMainOutro = '', + // eslint-disable-next-line prefer-const + fragmentColorTransform, + // eslint-disable-next-line prefer-const + customRewriter, + // eslint-disable-next-line prefer-const + timeUniform, + } = options // Expand includes if needed if (vertexTransform || customRewriter) { From ac0db58f8236a2d3142c3676795b9941b3ff77be Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:54:29 -0600 Subject: [PATCH 7/9] chore: remove unused files --- .../Text/troika-three-text/selectionUtils.js | 144 ----------------- src/core/Text/troika-three-text/woff2otf.js | 152 ------------------ 2 files changed, 296 deletions(-) delete mode 100644 src/core/Text/troika-three-text/selectionUtils.js delete mode 100644 src/core/Text/troika-three-text/woff2otf.js diff --git a/src/core/Text/troika-three-text/selectionUtils.js b/src/core/Text/troika-three-text/selectionUtils.js deleted file mode 100644 index 3a608f46d..000000000 --- a/src/core/Text/troika-three-text/selectionUtils.js +++ /dev/null @@ -1,144 +0,0 @@ -//=== Utility functions for dealing with carets and selection ranges ===// - -/** - * @typedef {object} TextCaret - * @property {number} x - x position of the caret - * @property {number} y - y position of the caret's bottom - * @property {number} height - height of the caret - * @property {number} charIndex - the index in the original input string of this caret's target - * character; the caret will be for the position _before_ that character. - */ - -/** - * Given a local x/y coordinate in the text block plane, find the nearest caret position. - * @param {TroikaTextRenderInfo} textRenderInfo - a result object from TextBuilder#getTextRenderInfo - * @param {number} x - * @param {number} y - * @return {TextCaret | null} - */ -export function getCaretAtPoint(textRenderInfo, x, y) { - let closestCaret = null - const { caretHeight } = textRenderInfo - const caretsByRow = groupCaretsByRow(textRenderInfo) - - // Find nearest row by y first - let closestRowY = Infinity - caretsByRow.forEach((carets, rowY) => { - if (Math.abs(y - (rowY + caretHeight / 2)) < Math.abs(y - (closestRowY + caretHeight / 2))) { - closestRowY = rowY - } - }) - - // Then find closest caret by x within that row - caretsByRow.get(closestRowY).forEach((caret) => { - if (!closestCaret || Math.abs(x - caret.x) < Math.abs(x - closestCaret.x)) { - closestCaret = caret - } - }) - return closestCaret -} - -const _rectsCache = new WeakMap() - -/** - * Given start and end character indexes, return a list of rectangles covering all the - * characters within that selection. - * @param {TroikaTextRenderInfo} textRenderInfo - * @param {number} start - index of the first char in the selection - * @param {number} end - index of the first char after the selection - * @return {Array<{left, top, right, bottom}> | null} - */ -export function getSelectionRects(textRenderInfo, start, end) { - let rects - if (textRenderInfo) { - // Check cache - textRenderInfo is frozen so it's safe to cache based on it - const prevResult = _rectsCache.get(textRenderInfo) - if (prevResult && prevResult.start === start && prevResult.end === end) { - return prevResult.rects - } - - const { caretPositions, caretHeight } = textRenderInfo - - // Normalize - if (end < start) { - const s = start - start = end - end = s - } - start = Math.max(start, 0) - end = Math.min(end, caretPositions.length + 1) - - // Build list of rects, expanding the current rect for all characters in a run and starting - // a new rect whenever reaching a new line or a new bidi direction - rects = [] - let currentRect = null - for (let i = start; i < end; i++) { - const x1 = caretPositions[i * 3] - const x2 = caretPositions[i * 3 + 1] - const left = Math.min(x1, x2) - const right = Math.max(x1, x2) - const bottom = caretPositions[i * 3 + 2] - if (!currentRect || bottom !== currentRect.bottom || left > currentRect.right || right < currentRect.left) { - currentRect = { - left: Infinity, - right: -Infinity, - bottom: bottom, - top: bottom + caretHeight, - } - rects.push(currentRect) - } - currentRect.left = Math.min(left, currentRect.left) - currentRect.right = Math.max(right, currentRect.right) - } - - // Merge any overlapping rects, e.g. those formed by adjacent bidi runs - rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left) - for (let i = rects.length - 1; i-- > 0; ) { - const rectA = rects[i] - const rectB = rects[i + 1] - if (rectA.bottom === rectB.bottom && rectA.left <= rectB.right && rectA.right >= rectB.left) { - rectB.left = Math.min(rectB.left, rectA.left) - rectB.right = Math.max(rectB.right, rectA.right) - rects.splice(i, 1) - } - } - - _rectsCache.set(textRenderInfo, { start, end, rects }) - } - return rects -} - -const _caretsByRowCache = new WeakMap() - -function groupCaretsByRow(textRenderInfo) { - // textRenderInfo is frozen so it's safe to cache based on it - let caretsByRow = _caretsByRowCache.get(textRenderInfo) - if (!caretsByRow) { - const { caretPositions, caretHeight } = textRenderInfo - caretsByRow = new Map() - for (let i = 0; i < caretPositions.length; i += 3) { - const rowY = caretPositions[i + 2] - let rowCarets = caretsByRow.get(rowY) - if (!rowCarets) { - caretsByRow.set(rowY, (rowCarets = [])) - } - rowCarets.push({ - x: caretPositions[i], - y: rowY, - height: caretHeight, - charIndex: i / 3, - }) - // Add one more caret after the final char - if (i + 3 >= caretPositions.length) { - rowCarets.push({ - x: caretPositions[i + 1], - y: rowY, - height: caretHeight, - charIndex: i / 3 + 1, - }) - } - } - } - _caretsByRowCache.set(textRenderInfo, caretsByRow) - return caretsByRow -} diff --git a/src/core/Text/troika-three-text/woff2otf.js b/src/core/Text/troika-three-text/woff2otf.js deleted file mode 100644 index 03d533f64..000000000 --- a/src/core/Text/troika-three-text/woff2otf.js +++ /dev/null @@ -1,152 +0,0 @@ -/* - Copyright 2012, Steffen Hanikel (https://github.com/hanikesn) - Modified by Artemy Tregubenko, 2014 (https://github.com/arty-name/woff2otf) - Modified by Jason Johnston, 2019 (pako --> tiny-inflate) - - 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. - - A tool to convert a WOFF back to a TTF/OTF font file, in pure Javascript -*/ - -import { inflateSync } from 'fflate' - -export function convert_streams(bufferIn) { - var dataViewIn = new DataView(bufferIn) - var offsetIn = 0 - - function read2() { - var uint16 = dataViewIn.getUint16(offsetIn) - offsetIn += 2 - return uint16 - } - - function read4() { - var uint32 = dataViewIn.getUint32(offsetIn) - offsetIn += 4 - return uint32 - } - - function write2(uint16) { - dataViewOut.setUint16(offsetOut, uint16) - offsetOut += 2 - } - - function write4(uint32) { - dataViewOut.setUint32(offsetOut, uint32) - offsetOut += 4 - } - - var WOFFHeader = { - signature: read4(), - flavor: read4(), - length: read4(), - numTables: read2(), - reserved: read2(), - totalSfntSize: read4(), - majorVersion: read2(), - minorVersion: read2(), - metaOffset: read4(), - metaLength: read4(), - metaOrigLength: read4(), - privOffset: read4(), - privLength: read4(), - } - - var entrySelector = 0 - while (Math.pow(2, entrySelector) <= WOFFHeader.numTables) { - entrySelector++ - } - entrySelector-- - - var searchRange = Math.pow(2, entrySelector) * 16 - var rangeShift = WOFFHeader.numTables * 16 - searchRange - - var offset = 4 + 2 + 2 + 2 + 2 - var TableDirectoryEntries = [] - for (var i = 0; i < WOFFHeader.numTables; i++) { - TableDirectoryEntries.push({ - tag: read4(), - offset: read4(), - compLength: read4(), - origLength: read4(), - origChecksum: read4(), - }) - offset += 4 * 4 - } - - var arrayOut = new Uint8Array( - 4 + - 2 + - 2 + - 2 + - 2 + - TableDirectoryEntries.length * (4 + 4 + 4 + 4) + - TableDirectoryEntries.reduce(function (acc, entry) { - return acc + entry.origLength + 4 - }, 0) - ) - var bufferOut = arrayOut.buffer - var dataViewOut = new DataView(bufferOut) - var offsetOut = 0 - - write4(WOFFHeader.flavor) - write2(WOFFHeader.numTables) - write2(searchRange) - write2(entrySelector) - write2(rangeShift) - - TableDirectoryEntries.forEach(function (TableDirectoryEntry) { - write4(TableDirectoryEntry.tag) - write4(TableDirectoryEntry.origChecksum) - write4(offset) - write4(TableDirectoryEntry.origLength) - - TableDirectoryEntry.outOffset = offset - offset += TableDirectoryEntry.origLength - if (offset % 4 != 0) { - offset += 4 - (offset % 4) - } - }) - - var size - - TableDirectoryEntries.forEach(function (TableDirectoryEntry) { - var compressedData = bufferIn.slice( - TableDirectoryEntry.offset, - TableDirectoryEntry.offset + TableDirectoryEntry.compLength - ) - - if (TableDirectoryEntry.compLength != TableDirectoryEntry.origLength) { - var uncompressedData = new Uint8Array(TableDirectoryEntry.origLength) - inflateSync( - new Uint8Array(compressedData, 2), //skip deflate header - uncompressedData - ) - } else { - uncompressedData = new Uint8Array(compressedData) - } - - arrayOut.set(uncompressedData, TableDirectoryEntry.outOffset) - offset = TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength - - var padding = 0 - if (offset % 4 != 0) { - padding = 4 - (offset % 4) - } - arrayOut.set(new Uint8Array(padding).buffer, TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength) - - size = offset + padding - }) - - return bufferOut.slice(0, size) -} From 589ea02cb63bfde47420db8383710acde9cc8d3a Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 13 Nov 2022 14:58:10 -0600 Subject: [PATCH 8/9] chore: add troika licensing, docs info --- src/core/Text/troika-three-text/LICENSE | 22 ++ src/core/Text/troika-three-text/README.md | 381 ++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/core/Text/troika-three-text/LICENSE create mode 100644 src/core/Text/troika-three-text/README.md diff --git a/src/core/Text/troika-three-text/LICENSE b/src/core/Text/troika-three-text/LICENSE new file mode 100644 index 000000000..834538483 --- /dev/null +++ b/src/core/Text/troika-three-text/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2019 ProtectWise +Copyright (c) 2021 Jason Johnston + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/core/Text/troika-three-text/README.md b/src/core/Text/troika-three-text/README.md new file mode 100644 index 000000000..173aa37d7 --- /dev/null +++ b/src/core/Text/troika-three-text/README.md @@ -0,0 +1,381 @@ +# Troika Text for Three.js + +The `troika-three-text` package provides high quality text rendering in [Three.js](https://threejs.org) scenes, using signed distance fields (SDF) and antialiasing using standard derivatives. + +Rather than relying on pre-generated SDF textures, this parses font files (.ttf, .otf, .woff) directly using [Typr](https://github.com/fredli74/Typr.ts), and generates the SDF atlas for glyphs on-the-fly as they are used. It also handles proper kerning, ligature glyph substitution, right-to-left/bidirectional layout, and joined scripts like Arabic. All font parsing, SDF generation, and glyph layout is performed in a web worker to prevent frame drops. + +Once the SDFs are generated, it assembles a geometry that positions all the glyphs, and _patches_ any Three.js Material with the proper shader code for rendering the SDFs. This means you can still benefit from all the features of Three.js's built-in materials like lighting, physically-based rendering, shadows, and fog. + +## Demos + +- [With the Troika scene management framework](https://troika-examples.netlify.com/#text) +- [With react-three-fiber](https://codesandbox.io/embed/troika-3d-text-via-react-three-fiber-ntfx2?fontsize=14) +- [With a video texture](https://bfplr.csb.app/) +- [With the Material Icons font](https://codesandbox.io/s/material-icons-in-troika-three-text-t2mu7?file=/src/index.js) + +## With Other Frameworks + +- [In the `drei` utilities for react-three-fiber](https://github.com/pmndrs/drei#text) +- [As an A-Frame component](https://github.com/lojjic/aframe-troika-text) +- [As a Web Component in three-elements](https://www.npmjs.com/package/@three-elements/text) + +## Screenshots + +![Text Rendering](../../docs/troika-three-text/images/screenshot1.png) + +![Zoomed-in](../../docs/troika-three-text/images/screenshot2.png) + +![Font with ligatures](../../docs/troika-three-text/images/screenshot3.png) + +![Text with a texture](../../docs/troika-three-text/images/screenshot4.png) + +## Installation + +Get it from [NPM](https://www.npmjs.com/package/troika-three-text): + +```sh +npm install troika-three-text +``` + +You will also need to install a compatible version of [Three.js](https://threejs.org); see the [notes on Three.js versions in the Getting Started docs](../../docs/getting-started/setup.md#threejs) for details. + +```sh +npm install three +``` + +## Usage + +```js +import { Text } from 'troika-three-text' +``` + +You can then use the `Text` class like any other Three.js mesh: + +```js +// Create: +const myText = new Text() +myScene.add(myText) + +// Set properties to configure: +myText.text = 'Hello world!' +myText.fontSize = 0.2 +myText.position.z = -2 +myText.color = 0x9966ff + +// Update the rendering: +myText.sync() +``` + +It's a good idea to call the `.sync()` method after changing any properties that would affect the text's layout. If you don't, it will be called automatically on the next render frame, but calling it yourself can get the result sooner. + +When you're done with the `Text` instance, be sure to call `dispose` on it to prevent a memory leak: + +```js +myScene.remove(myText) +myText.dispose() +``` + +## Supported properties + +Instances of `Text` support the following configuration properties: + +### `text` + +The string of text to be rendered. Newlines and repeating whitespace characters are honored. + +Default: _none_ + +### `anchorX` + +Defines the horizontal position in the text block that should line up with the local origin. Can be specified as a numeric `x` position in local units, a string percentage of the total text block width e.g. `'25%'`, or one of the following keyword strings: `'left'`, `'center'`, or `'right'`. + +Default: `0` + +### `anchorY` + +Defines the vertical position in the text block that should line up with the local origin. Can be specified as a numeric `y` position in local units (note: down is negative y), a string percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings: `'top'`, `'top-baseline'`, `'top-cap'`, `'top-ex'`, `'middle'`, `'bottom-baseline'`, or `'bottom'`. + +Default: `0` + +### `clipRect` + +If specified, defines the `[minX, minY, maxX, maxY]` of a rectangle outside of which all pixels will be discarded. This can be used for example to clip overflowing text when `whiteSpace='nowrap'`. + +Default: _none_ + +### `color` + +This is a shortcut for setting the `color` of the text's `material`. You can use this if you don't want to specify a whole custom `material` and just want to change its color. + +Use the `material` property if you want to control aspects of the material other than its color. + +Default: _none_ - uses the color of the `material` + +### `curveRadius` + +Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave curvature, while negative numbers put it behind the text for a convex curvature. The centerline will be aligned with the text's local origin; you can use `anchorX` to offset it. + +Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane internally. You can use [`glyphGeometryDetail`](#glyphgeometrydetail) to add more vertices for curvature inside glyphs. + +Default: `0` + +### `depthOffset` + +This is a shortcut for setting the material's [`polygonOffset` and related properties](https://threejs.org/docs/#api/en/materials/Material.polygonOffset), which can be useful in preventing z-fighting when this text is laid on top of another plane in the scene. Positive numbers are further from the camera, negatives closer. + +Be aware that while this can help with z-fighting, it does not affect the rendering order; if the text renders before the content behind it, you may see antialiasing pixels that appear too dark or light. You may need to also change the text mesh's `renderOrder`, or set its `z` position a fraction closer to the camera, to ensure the text renders after background objects. + +Default: `0` + +### `direction` + +Sets the base direction for the text. The default value of "auto" will choose a direction based on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction. + +Default: `'auto'` + +### `fillOpacity` + +Controls the opacity of just the glyph's fill area, separate from any configured `strokeOpacity`, `outlineOpacity`, and the material's `opacity`. A `fillOpacity` of `0` will make the fill invisible, leaving just the stroke and/or outline. + +Default: `1` + +### `font` + +The URL of a custom font file to be used. Supported font formats are: + +- .ttf +- .otf +- .woff (.woff2 is _not_ supported) + +Default: The _Roboto_ font loaded from Google Fonts CDN + +### `fontSize` + +The em-height at which to render the font, in local world units. + +Default: `0.1` + +### `glyphGeometryDetail` + +The number of vertical/horizontal segments that make up each glyph's rectangular plane. This can be increased to provide more geometrical detail for custom vertex shader effects, for example. + +Default: `1` + +### `gpuAccelerateSDF` + +When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible, making it much faster especially for complex glyphs, and falling back to a JavaScript version executed in web workers when support isn't available. It should automatically detect support, but it's still somewhat experimental, so you can set it to `false` to force it to use the JS version if you encounter issues with it. + +Default: `true` + +### `letterSpacing` + +Sets a uniform adjustment to spacing between letters after kerning is applied, in local world units. Positive numbers increase spacing and negative numbers decrease it. + +Default: `0` + +### `lineHeight` + +Sets the height of each line of text. Can either be `'normal'` which chooses a reasonable height based on the chosen font's ascender/descender metrics, or a number that is interpreted as a multiple of the `fontSize`. + +Default: `'normal'` + +### `material` + +Defines a Three.js Material _instance_ to be used as a base when rendering the text. This material will be automatically replaced with a new material derived from it, that adds shader code to decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing. + +By default it will derive from a simple white `MeshBasicMaterial, but you can use any of the other mesh materials to gain other features like lighting, texture maps, etc. + +Also see the `color` shortcut property. + +Note that because your material instance is _replaced_ by a derived material instance, any changes you make to your original material will _not_ be reflected in the derived version. If you need to modify properties of the material afterward, be sure you get a new reference to the derived version: + +```js +// Bad: +text.material = myOrigMaterial +myOrigMaterial.opacity = 0.5 + +// Good: +text.material = myOrigMaterial +text.material.opacity = 0.5 +``` + +Default: a `MeshBasicMaterial` instance + +### `maxWidth` + +The maximum width of the text block, above which text may start wrapping according to the `whiteSpace` and `overflowWrap` properties. + +Default: `Infinity`, meaning text will never wrap + +### `outlineBlur` + +Specifies a blur radius applied to the outer edge of the text's `outlineWidth`. If the `outlineWidth` is zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius. A blur plus a nonzero `outlineWidth` can give a solid outline with a fuzzy outer edge. + +The blur radius can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` which is treated as a percentage of the `fontSize`. + +Default: `0` + +### `outlineColor` + +The color to use for the text outline when `outlineWidth`, `outlineBlur`, and/or `outlineOffsetX/Y` are set. Accepts a ThreeJS `Color` object, or a number/string accepted by `Color#set`. + +Default: black + +### `outlineOffsetX`, `outlineOffsetY` + +These define a horizontal and vertical offset of the text outline. Using an offset with `outlineWidth: 0` creates a drop-shadow effect like CSS's `text-shadow`; also see `outlineBlur`. + +The offsets can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` which is treated as a percentage of the `fontSize`. + +Default: `0` + +### `outlineOpacity` + +Sets the opacity of a configured text outline, in the range `0` to `1`. + +Default: `1` + +### `outlineWidth` + +The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`. This can help improve readability when the text is displayed against a background of low or varying contrast. + +The width can be specified as either an absolute number in local units, or as a percentage string e.g. `"10%"` which is interpreted as a percentage of the `fontSize`. + +Default: `0` + +### `overflowWrap` + +Defines how text wraps if the `whiteSpace` property is `'normal'`. Can be either `'normal'` to break at whitespace characters, or `'break-word'` to allow breaking within words. + +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` + +### `strokeColor` + +The color of the text stroke, when `strokeWidth` is nonzero. Accepts a ThreeJS `Color` object, or a number/string accepted by `Color#set`. + +Default: grey + +### `strokeOpacity` + +The opacity of the text stroke, when `strokeWidth` is nonzero. Accepts a number from `0` to `1`. + +Default: `1` + +### `strokeWidth` + +Sets the width of a stroke drawn inside the edge of each text glyph, using the `strokeColor` and `strokeOpacity`. + +The width can be specified as either an absolute number in local units, or as a percentage string e.g. `"10%"` which is interpreted as a percentage of the `fontSize`. + +Default: `0` + +### `textAlign` + +The horizontal alignment of each line of text within the overall text bounding box. Can be one of `'left'`, `'right'`, `'center'`, or `'justify'`. + +Default: `'left'` + +### `textIndent` + +An indentation applied to the first character of each _hard_ newline. Behaves like CSS `text-indent`. + +Default: `0` + +### `whiteSpace` + +Defines whether text should wrap when a line reaches the `maxWidth`. Can be either `'normal'`, to allow wrapping according to the `overflowWrap` property, or `'nowrap'` to prevent wrapping. + +Note that `'normal'` in this context _does_ honor newline characters to manually break lines, making it behave more like `'pre-wrap'` does in CSS. + +Default: `'normal'` + +## Handling Asynchronous Updates + +Since the text processing occurs in a web worker, it is by definition asynchronous. This means that you can't rely on the text being visible or having a complete geometry immediately. If you need to do things like access the geometry's `boundingSphere` or the `textRenderInfo`, you will have to listen for completion. You can do this two ways: + +1. Pass a callback function when you call the `sync` method: + + ```js + myText.sync(() => { + // code to execute after sync completes... + }) + ``` + + This is best when you want to only react to _that specific_ sync call. Keep in mind that the callback will not execute if the text is already fully synced. + +2. Add a listener for the `synccomplete` event: + + ```js + myText.addEventListener('synccomplete', () => { + // code to execute after sync completes... + }) + ``` + + This will fire after _every_ sync, no matter who invoked it. This is best if you need to react to all syncs, for example to trigger a manual canvas render. + + You can also listen for the `syncstart` event if you need to react to the initiation of a sync call, e.g. to set some sort of "waiting" state while the text is being processed. + +## Preloading + +To avoid long pauses when first displaying a piece of text in your scene, you can preload fonts and optionally pre-generate the SDF textures for particular glyphs up front: + +```js +import { preloadFont } from 'troika-three-text' + +myApp.showLoadingScreen() + +preloadFont( + { + font: 'path/to/myfontfile.woff', + characters: 'abcdefghijklmnopqrstuvwxyz', + }, + () => { + myApp.showScene() + } +) +``` + +The arguments are: + +- `options` + + - `options.font` - The URL of the font file to preload. If `null` is passed, this will preload the default font. + + - `options.characters` - A string or array of string character sequences for which to pre-generate glyph SDF textures. 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" glyph. + + - `options.sdfGlyphSize` - The size at which to prerender the SDFs for the `characters` glyphs. See the `sdfGlyphSize` config property on `Text` for details about SDF sizes. If not specified, will use the default SDF size. + +- `callback` - A function that will be called when the preloading is complete. + +## Postprocessing + +It is possible to use `Text` within scenes that utilize the [postprocessing](https://github.com/vanruesc/postprocessing) library for applying image effects. However, you must enable a special mode in that library that allows `Text`'s custom material to be honored. Just do the following once somewhere in your code: + +```js +import { OverrideMaterialManager } from 'postprocessing' + +OverrideMaterialManager.workaroundEnabled = true +``` + +## Carets and Selection Ranges + +In addition to rendering text, it is possible to access positioning information for caret placement and selection ranges. To access that info, use the `getCaretAtPoint` and `getSelectionRects` utility functions. Both of these functions take a `textRenderInfo` object as input, which you can get from the `Text` object's `textRenderInfo` property after sync has completed. See "Handling Asynchronous Updates" above for how to react to sync completion events. + +### `getCaretAtPoint(textRenderInfo, x, y)` + +This returns the caret position nearest to a given x/y position in the local text plane. This is useful for placing an editing caret based on a click or ther raycasted event. The return value is an object with the following properties: + +- `x` - x position of the caret +- `y` - y position of the caret's bottom +- `height` - height of the caret, based on the current fontSize and lineHeight +- `charIndex` - the index in the original input string of this caret's target character. The caret will be for the position _before_ that character. For the final caret position, this will be equal to the string length. For ligature glyphs, this will be for the first character in the ligature sequence. + +### `getSelectionRects(textRenderInfo, start, end)` + +This returns a list of rectangles covering all the characters within a given character range. This is useful for highlighting a selection range. The return value is an array of objects, each with `{left, top, right, bottom}` properties in the local text plane. From f029279ba0e38714995cd9fda200fc1c0327ed3f Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 14 Dec 2022 23:01:42 -0600 Subject: [PATCH 9/9] chore: upgrade troika-three-text, remove vendor --- package.json | 4 +- src/core/{Text/index.tsx => Text.tsx} | 3 +- .../Text/troika-three-text/DerivedMaterial.js | 406 ---------- src/core/Text/troika-three-text/FontParser.js | 292 ------- .../Text/troika-three-text/GlyphsGeometry.js | 248 ------ src/core/Text/troika-three-text/LICENSE | 22 - src/core/Text/troika-three-text/README.md | 381 --------- .../Text/troika-three-text/SDFGenerator.js | 132 --- src/core/Text/troika-three-text/Text.js | 754 ------------------ .../Text/troika-three-text/TextBuilder.js | 414 ---------- .../troika-three-text/TextDerivedMaterial.js | 284 ------- src/core/Text/troika-three-text/Typesetter.js | 652 --------------- src/core/Text/troika-three-text/index.js | 3 - .../troika-three-text/libs/typr.factory.js | 6 - .../libs/woff2otf.factory.js | 9 - yarn.lock | 25 +- 16 files changed, 23 insertions(+), 3612 deletions(-) rename src/core/{Text/index.tsx => Text.tsx} (96%) delete mode 100644 src/core/Text/troika-three-text/DerivedMaterial.js delete mode 100644 src/core/Text/troika-three-text/FontParser.js delete mode 100644 src/core/Text/troika-three-text/GlyphsGeometry.js delete mode 100644 src/core/Text/troika-three-text/LICENSE delete mode 100644 src/core/Text/troika-three-text/README.md delete mode 100644 src/core/Text/troika-three-text/SDFGenerator.js delete mode 100644 src/core/Text/troika-three-text/Text.js delete mode 100644 src/core/Text/troika-three-text/TextBuilder.js delete mode 100644 src/core/Text/troika-three-text/TextDerivedMaterial.js delete mode 100644 src/core/Text/troika-three-text/Typesetter.js delete mode 100644 src/core/Text/troika-three-text/index.js delete mode 100644 src/core/Text/troika-three-text/libs/typr.factory.js delete mode 100644 src/core/Text/troika-three-text/libs/woff2otf.factory.js diff --git a/package.json b/package.json index 39a3b6953..725c38e54 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "@babel/runtime": "^7.11.2", "@react-spring/three": "^9.3.1", "@use-gesture/react": "^10.2.0", - "bidi-js": "^1.0.2", "detect-gpu": "^4.0.36", "glsl-noise": "^0.0.0", "lodash.clamp": "^4.0.3", @@ -70,9 +69,8 @@ "suspend-react": "^0.0.8", "three-mesh-bvh": "^0.5.15", "three-stdlib": "^2.17.3", - "troika-worker-utils": "^0.46.0", + "troika-three-text": "^0.47.1", "utility-types": "^3.10.0", - "webgl-sdf-generator": "^1.1.1", "zustand": "^3.5.13" }, "devDependencies": { diff --git a/src/core/Text/index.tsx b/src/core/Text.tsx similarity index 96% rename from src/core/Text/index.tsx rename to src/core/Text.tsx index 40c689290..816bbb56d 100644 --- a/src/core/Text/index.tsx +++ b/src/core/Text.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { Text as TextMeshImpl, preloadFont } from './troika-three-text' +// @ts-ignore +import { Text as TextMeshImpl, preloadFont } from 'troika-three-text' import { ReactThreeFiber, useThree } from '@react-three/fiber' import { suspend } from 'suspend-react' diff --git a/src/core/Text/troika-three-text/DerivedMaterial.js b/src/core/Text/troika-three-text/DerivedMaterial.js deleted file mode 100644 index 89d3d4da7..000000000 --- a/src/core/Text/troika-three-text/DerivedMaterial.js +++ /dev/null @@ -1,406 +0,0 @@ -import { ShaderChunk, MeshDepthMaterial, MeshDistanceMaterial, RGBADepthPacking, UniformsUtils, MathUtils } from 'three' - -/** - * Regular expression for matching the `void main() {` opener line in GLSL. - * @type {RegExp} - */ -export const voidMainRegExp = /\bvoid\s+main\s*\(\s*\)\s*{/g - -/** - * Recursively expands all `#include ` statements within string of shader code. - * Copied from three's WebGLProgram#parseIncludes for external use. - * - * @param {string} source - The GLSL source code to evaluate - * @return {string} The GLSL code with all includes expanded - */ -export function expandShaderIncludes(source) { - const pattern = /^[ \t]*#include +<([\w\d./]+)>/gm - function replace(match, include) { - const chunk = ShaderChunk[include] - return chunk ? expandShaderIncludes(chunk) : match - } - return source.replace(pattern, replace) -} - -const epoch = Date.now() -const CONSTRUCTOR_CACHE = new WeakMap() -const SHADER_UPGRADE_CACHE = new Map() - -// Material ids must be integers, but we can't access the increment from Three's `Material` module, -// so let's choose a sufficiently large starting value that should theoretically never collide. -let materialInstanceId = 1e10 - -/** - * A utility for creating a custom shader material derived from another material's - * shaders. This allows you to inject custom shader logic and transforms into the - * builtin ThreeJS materials without having to recreate them from scratch. - * - * @param {THREE.Material} baseMaterial - the original material to derive from - * - * @param {Object} options - How the base material should be modified. - * @param {Object} options.defines - Custom `defines` for the material - * @param {Object} options.extensions - Custom `extensions` for the material, e.g. `{derivatives: true}` - * @param {Object} options.uniforms - Custom `uniforms` for use in the modified shader. These can - * be accessed and manipulated via the resulting material's `uniforms` property, just like - * in a ShaderMaterial. You do not need to repeat the base material's own uniforms here. - * @param {String} options.timeUniform - If specified, a uniform of this name will be injected into - * both shaders, and it will automatically be updated on each render frame with a number of - * elapsed milliseconds. The "zero" epoch time is not significant so don't rely on this as a - * true calendar time. - * @param {String} options.vertexDefs - Custom GLSL code to inject into the vertex shader's top-level - * definitions, above the `void main()` function. - * @param {String} options.vertexMainIntro - Custom GLSL code to inject at the top of the vertex - * shader's `void main` function. - * @param {String} options.vertexMainOutro - Custom GLSL code to inject at the end of the vertex - * shader's `void main` function. - * @param {String} options.vertexTransform - Custom GLSL code to manipulate the `position`, `normal`, - * and/or `uv` vertex attributes. This code will be wrapped within a standalone function with - * those attributes exposed by their normal names as read/write values. - * @param {String} options.fragmentDefs - Custom GLSL code to inject into the fragment shader's top-level - * definitions, above the `void main()` function. - * @param {String} options.fragmentMainIntro - Custom GLSL code to inject at the top of the fragment - * shader's `void main` function. - * @param {String} options.fragmentMainOutro - Custom GLSL code to inject at the end of the fragment - * shader's `void main` function. You can manipulate `gl_FragColor` here but keep in mind it goes - * after any of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), so if you - * want those to apply to your changes use `fragmentColorTransform` instead. - * @param {String} options.fragmentColorTransform - Custom GLSL code to manipulate the `gl_FragColor` - * output value. Will be injected near the end of the `void main` function, but before any - * of ThreeJS's color postprocessing shader chunks (tonemapping, fog, etc.), and before the - * `fragmentMainOutro`. - * @param {function<{vertexShader,fragmentShader}>:{vertexShader,fragmentShader}} options.customRewriter - A function - * for performing custom rewrites of the full shader code. Useful if you need to do something - * special that's not covered by the other builtin options. This function will be executed before - * any other transforms are applied. - * @param {boolean} options.chained - Set to `true` to prototype-chain the derived material to the base - * material, rather than the default behavior of copying it. This allows the derived material to - * automatically pick up changes made to the base material and its properties. This can be useful - * where the derived material is hidden from the user as an implementation detail, allowing them - * to work with the original material like normal. But it can result in unexpected behavior if not - * handled carefully. - * - * @return {THREE.Material} - * - * The returned material will also have two new methods, `getDepthMaterial()` and `getDistanceMaterial()`, - * which can be called to get a variant of the derived material for use in shadow casting. If the - * target mesh is expected to cast shadows, then you can assign these to the mesh's `customDepthMaterial` - * (for directional and spot lights) and/or `customDistanceMaterial` (for point lights) properties to - * allow the cast shadow to honor your derived shader's vertex transforms and discarded fragments. These - * will also set a custom `#define IS_DEPTH_MATERIAL` or `#define IS_DISTANCE_MATERIAL` that you can look - * for in your derived shaders with `#ifdef` to customize their behavior for the depth or distance - * scenarios, e.g. skipping antialiasing or expensive shader logic. - */ -export function createDerivedMaterial(baseMaterial, options) { - // Generate a key that is unique to the content of these `options`. We'll use this - // throughout for caching and for generating the upgraded shader code. This increases - // the likelihood that the resulting shaders will line up across multiple calls so - // their GL programs can be shared and cached. - const optionsKey = getKeyForOptions(options) - - // First check to see if we've already derived from this baseMaterial using this - // unique set of options, and if so reuse the constructor to avoid some allocations. - let ctorsByDerivation = CONSTRUCTOR_CACHE.get(baseMaterial) - if (!ctorsByDerivation) { - CONSTRUCTOR_CACHE.set(baseMaterial, (ctorsByDerivation = Object.create(null))) - } - if (ctorsByDerivation[optionsKey]) { - return new ctorsByDerivation[optionsKey]() - } - - const privateBeforeCompileProp = `_onBeforeCompile${optionsKey}` - - // Private onBeforeCompile handler that injects the modified shaders and uniforms when - // the renderer switches to this material's program - const onBeforeCompile = function (shaderInfo) { - baseMaterial.onBeforeCompile.call(this, shaderInfo) - - // Upgrade the shaders, caching the result by incoming source code - const cacheKey = this.customProgramCacheKey() + '|' + shaderInfo.vertexShader + '|' + shaderInfo.fragmentShader - let upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey] - if (!upgradedShaders) { - const upgraded = upgradeShaders(shaderInfo, options, optionsKey) - upgradedShaders = SHADER_UPGRADE_CACHE[cacheKey] = upgraded - } - - // Inject upgraded shaders and uniforms into the program - shaderInfo.vertexShader = upgradedShaders.vertexShader - shaderInfo.fragmentShader = upgradedShaders.fragmentShader - Object.assign(shaderInfo.uniforms, this.uniforms) - - // Inject auto-updating time uniform if requested - if (options.timeUniform) { - shaderInfo.uniforms[options.timeUniform] = { - get value() { - return Date.now() - epoch - }, - } - } - - // Users can still add their own handlers on top of ours - if (this[privateBeforeCompileProp]) { - this[privateBeforeCompileProp](shaderInfo) - } - } - - const DerivedMaterial = function DerivedMaterial() { - return derive(options.chained ? baseMaterial : baseMaterial.clone()) - } - - const derive = function (base) { - // Prototype chain to the base material - const derived = Object.create(base, descriptor) - - // Store the baseMaterial for reference; this is always the original even when cloning - Object.defineProperty(derived, 'baseMaterial', { value: baseMaterial }) - - // Needs its own ids - Object.defineProperty(derived, 'id', { value: materialInstanceId++ }) - derived.uuid = MathUtils.generateUUID() - - // Merge uniforms, defines, and extensions - derived.uniforms = Object.assign({}, base.uniforms, options.uniforms) - derived.defines = Object.assign({}, base.defines, options.defines) - derived.defines[`TROIKA_DERIVED_MATERIAL_${optionsKey}`] = '' //force a program change from the base material - derived.extensions = Object.assign({}, base.extensions, options.extensions) - - // Don't inherit EventDispatcher listeners - derived._listeners = undefined - - return derived - } - - const descriptor = { - constructor: { value: DerivedMaterial }, - isDerivedMaterial: { value: true }, - - customProgramCacheKey: { - writable: true, - configurable: true, - value: function () { - return baseMaterial.customProgramCacheKey() + '|' + optionsKey - }, - }, - - onBeforeCompile: { - get() { - return onBeforeCompile - }, - set(fn) { - this[privateBeforeCompileProp] = fn - }, - }, - - copy: { - writable: true, - configurable: true, - value: function (source) { - baseMaterial.copy.call(this, source) - if (!baseMaterial.isShaderMaterial && !baseMaterial.isDerivedMaterial) { - Object.assign(this.extensions, source.extensions) - Object.assign(this.defines, source.defines) - Object.assign(this.uniforms, UniformsUtils.clone(source.uniforms)) - } - return this - }, - }, - - clone: { - writable: true, - configurable: true, - value: function () { - const newBase = new baseMaterial.constructor() - return derive(newBase).copy(this) - }, - }, - - /** - * Utility to get a MeshDepthMaterial that will honor this derived material's vertex - * transformations and discarded fragments. - */ - getDepthMaterial: { - writable: true, - configurable: true, - value: function () { - let depthMaterial = this._depthMaterial - if (!depthMaterial) { - depthMaterial = this._depthMaterial = createDerivedMaterial( - baseMaterial.isDerivedMaterial - ? baseMaterial.getDepthMaterial() - : new MeshDepthMaterial({ depthPacking: RGBADepthPacking }), - options - ) - depthMaterial.defines.IS_DEPTH_MATERIAL = '' - depthMaterial.uniforms = this.uniforms //automatically recieve same uniform values - } - return depthMaterial - }, - }, - - /** - * Utility to get a MeshDistanceMaterial that will honor this derived material's vertex - * transformations and discarded fragments. - */ - getDistanceMaterial: { - writable: true, - configurable: true, - value: function () { - let distanceMaterial = this._distanceMaterial - if (!distanceMaterial) { - distanceMaterial = this._distanceMaterial = createDerivedMaterial( - baseMaterial.isDerivedMaterial ? baseMaterial.getDistanceMaterial() : new MeshDistanceMaterial(), - options - ) - distanceMaterial.defines.IS_DISTANCE_MATERIAL = '' - distanceMaterial.uniforms = this.uniforms //automatically recieve same uniform values - } - return distanceMaterial - }, - }, - - dispose: { - writable: true, - configurable: true, - value() { - const { _depthMaterial, _distanceMaterial } = this - if (_depthMaterial) _depthMaterial.dispose() - if (_distanceMaterial) _distanceMaterial.dispose() - baseMaterial.dispose.call(this) - }, - }, - } - - ctorsByDerivation[optionsKey] = DerivedMaterial - return new DerivedMaterial() -} - -function upgradeShaders({ vertexShader, fragmentShader }, options, key) { - let { - vertexDefs = '', - vertexMainIntro = '', - // eslint-disable-next-line prefer-const - vertexMainOutro = '', - // eslint-disable-next-line prefer-const - vertexTransform, - fragmentDefs = '', - // eslint-disable-next-line prefer-const - fragmentMainIntro = '', - fragmentMainOutro = '', - // eslint-disable-next-line prefer-const - fragmentColorTransform, - // eslint-disable-next-line prefer-const - customRewriter, - // eslint-disable-next-line prefer-const - timeUniform, - } = options - - // Expand includes if needed - if (vertexTransform || customRewriter) { - vertexShader = expandShaderIncludes(vertexShader) - } - if (fragmentColorTransform || customRewriter) { - // We need to be able to find postprocessing chunks after include expansion in order to - // put them after the fragmentColorTransform, so mark them with comments first. Even if - // this particular derivation doesn't have a fragmentColorTransform, other derivations may, - // so we still mark them. - fragmentShader = fragmentShader.replace( - /^[ \t]*#include <((?:tonemapping|encodings|fog|premultiplied_alpha|dithering)_fragment)>/gm, - '\n//!BEGIN_POST_CHUNK $1\n$&\n//!END_POST_CHUNK\n' - ) - fragmentShader = expandShaderIncludes(fragmentShader) - } - - // Apply custom rewriter function - if (customRewriter) { - const res = customRewriter({ vertexShader, fragmentShader }) - vertexShader = res.vertexShader - fragmentShader = res.fragmentShader - } - - // The fragmentColorTransform needs to go before any postprocessing chunks, so extract - // those and re-insert them into the outro in the correct place: - if (fragmentColorTransform) { - const postChunks = [] - fragmentShader = fragmentShader.replace( - /^\/\/!BEGIN_POST_CHUNK[^]+?^\/\/!END_POST_CHUNK/gm, // [^]+? = non-greedy match of any chars including newlines - (match) => { - postChunks.push(match) - return '' - } - ) - fragmentMainOutro = `${fragmentColorTransform}\n${postChunks.join('\n')}\n${fragmentMainOutro}` - } - - // Inject auto-updating time uniform if requested - if (timeUniform) { - const code = `\nuniform float ${timeUniform};\n` - vertexDefs = code + vertexDefs - fragmentDefs = code + fragmentDefs - } - - // Inject a function for the vertexTransform and rename all usages of position/normal/uv - if (vertexTransform) { - // Hoist these defs to the very top so they work in other function defs - vertexShader = `vec3 troika_position_${key}; -vec3 troika_normal_${key}; -vec2 troika_uv_${key}; -${vertexShader} -` - vertexDefs = `${vertexDefs} -void troikaVertexTransform${key}(inout vec3 position, inout vec3 normal, inout vec2 uv) { - ${vertexTransform} -} -` - vertexMainIntro = ` -troika_position_${key} = vec3(position); -troika_normal_${key} = vec3(normal); -troika_uv_${key} = vec2(uv); -troikaVertexTransform${key}(troika_position_${key}, troika_normal_${key}, troika_uv_${key}); -${vertexMainIntro} -` - vertexShader = vertexShader.replace(/\b(position|normal|uv)\b/g, (match, match1, index, fullStr) => { - return /\battribute\s+vec[23]\s+$/.test(fullStr.substr(0, index)) ? match1 : `troika_${match1}_${key}` - }) - } - - // Inject defs and intro/outro snippets - vertexShader = injectIntoShaderCode(vertexShader, key, vertexDefs, vertexMainIntro, vertexMainOutro) - fragmentShader = injectIntoShaderCode(fragmentShader, key, fragmentDefs, fragmentMainIntro, fragmentMainOutro) - - return { - vertexShader, - fragmentShader, - } -} - -function injectIntoShaderCode(shaderCode, id, defs, intro, outro) { - if (intro || outro || defs) { - shaderCode = shaderCode.replace( - voidMainRegExp, - ` -${defs} -void troikaOrigMain${id}() {` - ) - shaderCode += ` -void main() { - ${intro} - troikaOrigMain${id}(); - ${outro} -}` - } - return shaderCode -} - -function optionsJsonReplacer(key, value) { - return key === 'uniforms' ? undefined : typeof value === 'function' ? value.toString() : value -} - -let _idCtr = 0 -const optionsHashesToIds = new Map() -function getKeyForOptions(options) { - const optionsHash = JSON.stringify(options, optionsJsonReplacer) - let id = optionsHashesToIds.get(optionsHash) - if (id == null) { - optionsHashesToIds.set(optionsHash, (id = ++_idCtr)) - } - return id -} diff --git a/src/core/Text/troika-three-text/FontParser.js b/src/core/Text/troika-three-text/FontParser.js deleted file mode 100644 index 7a9cb9236..000000000 --- a/src/core/Text/troika-three-text/FontParser.js +++ /dev/null @@ -1,292 +0,0 @@ -/** - * A factory wrapper parsing a font file using Typr. - * Also adds support for WOFF files (not WOFF2). - */ - -import typrFactory from './libs/typr.factory.js' -import woff2otfFactory from './libs/woff2otf.factory.js' -import { defineWorkerModule } from 'troika-worker-utils' - -function parserFactory(Typr, woff2otf) { - const cmdArgLengths = { - M: 2, - L: 2, - Q: 4, - C: 6, - Z: 0, - } - - // {joinType: "skip+step,..."} - const joiningTypeRawData = { - C: '18g,ca,368,1kz', - D: '17k,6,2,2+4,5+c,2+6,2+1,10+1,9+f,j+11,2+1,a,2,2+1,15+2,3,j+2,6+3,2+8,2,2,2+1,w+a,4+e,3+3,2,3+2,3+5,23+w,2f+4,3,2+9,2,b,2+3,3,1k+9,6+1,3+1,2+2,2+d,30g,p+y,1,1+1g,f+x,2,sd2+1d,jf3+4,f+3,2+4,2+2,b+3,42,2,4+2,2+1,2,3,t+1,9f+w,2,el+2,2+g,d+2,2l,2+1,5,3+1,2+1,2,3,6,16wm+1v', - R: '17m+3,2,2,6+3,m,15+2,2+2,h+h,13,3+8,2,2,3+1,2,p+1,x,5+4,5,a,2,2,3,u,c+2,g+1,5,2+1,4+1,5j,6+1,2,b,2+2,f,2+1,1s+2,2,3+1,7,1ez0,2,2+1,4+4,b,4,3,b,42,2+2,4,3,2+1,2,o+3,ae,ep,x,2o+2,3+1,3,5+1,6', - L: 'x9u,jff,a,fd,jv', - T: '4t,gj+33,7o+4,1+1,7c+18,2,2+1,2+1,2,21+a,2,1b+k,h,2u+6,3+5,3+1,2+3,y,2,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,3,7,6+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+d,1,1+1,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,ek,3+1,r+4,1e+4,6+5,2p+c,1+3,1,1+2,1+b,2db+2,3y,2p+v,ff+3,30+1,n9x,1+2,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,5s,6y+2,ea,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+9,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2,2b+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,470+8,at4+4,1o+6,t5,1s+3,2a,f5l+1,2+3,43o+2,a+7,1+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,1,gzau,v+2n,3l+6n', - } - - const JT_LEFT = 1, //indicates that a character joins with the subsequent character, but does not join with the preceding character. - JT_RIGHT = 2, //indicates that a character joins with the preceding character, but does not join with the subsequent character. - JT_DUAL = 4, //indicates that a character joins with the preceding character and joins with the subsequent character. - JT_TRANSPARENT = 8, //indicates that the character does not join with adjacent characters and that the character must be skipped over when the shaping engine is evaluating the joining positions in a sequence of characters. When a JT_TRANSPARENT character is encountered in a sequence, the JOINING_TYPE of the preceding character passes through. Diacritical marks are frequently assigned this value. - JT_JOIN_CAUSING = 16, //indicates that the character forces the use of joining forms with the preceding and subsequent characters. Kashidas and the Zero Width Joiner (U+200D) are both JOIN_CAUSING characters. - JT_NON_JOINING = 32 //indicates that a character does not join with the preceding or with the subsequent character., - - let joiningTypeMap - function getCharJoiningType(ch) { - if (!joiningTypeMap) { - const m = { - R: JT_RIGHT, - L: JT_LEFT, - D: JT_DUAL, - C: JT_JOIN_CAUSING, - U: JT_NON_JOINING, - T: JT_TRANSPARENT, - } - joiningTypeMap = new Map() - for (const type in joiningTypeRawData) { - let lastCode = 0 - joiningTypeRawData[type].split(',').forEach((range) => { - let [skip, step] = range.split('+') - skip = parseInt(skip, 36) - step = step ? parseInt(step, 36) : 0 - joiningTypeMap.set((lastCode += skip), m[type]) - for (let i = step; i--; ) { - joiningTypeMap.set(++lastCode, m[type]) - } - }) - } - } - return joiningTypeMap.get(ch) || JT_NON_JOINING - } - - const ISOL = 1, - INIT = 2, - FINA = 3, - MEDI = 4 - const formsToFeatures = [null, 'isol', 'init', 'fina', 'medi'] - - function detectJoiningForms(str) { - // This implements the algorithm described here: - // https://github.com/n8willis/opentype-shaping-documents/blob/master/opentype-shaping-arabic-general.md - const joiningForms = new Uint8Array(str.length) - let prevJoiningType = JT_NON_JOINING - let prevForm = ISOL - let prevIndex = -1 - for (let i = 0; i < str.length; i++) { - const code = str.codePointAt(i) - const joiningType = getCharJoiningType(code) | 0 - let form = ISOL - if (joiningType & JT_TRANSPARENT) { - continue - } - if (prevJoiningType & (JT_LEFT | JT_DUAL | JT_JOIN_CAUSING)) { - if (joiningType & (JT_RIGHT | JT_DUAL | JT_JOIN_CAUSING)) { - form = FINA - // isol->init, fina->medi - if (prevForm === ISOL || prevForm === FINA) { - joiningForms[prevIndex]++ - } - } else if (joiningType & (JT_LEFT | JT_NON_JOINING)) { - // medi->fina, init->isol - if (prevForm === INIT || prevForm === MEDI) { - joiningForms[prevIndex]-- - } - } - } else if (prevJoiningType & (JT_RIGHT | JT_NON_JOINING)) { - // medi->fina, init->isol - if (prevForm === INIT || prevForm === MEDI) { - joiningForms[prevIndex]-- - } - } - prevForm = joiningForms[i] = form - prevJoiningType = joiningType - prevIndex = i - if (code > 0xffff) i++ - } - // console.log(str.split('').map(ch => ch.codePointAt(0).toString(16))) - // console.log(str.split('').map(ch => getCharJoiningType(ch.codePointAt(0)))) - // console.log(Array.from(joiningForms).map(f => formsToFeatures[f] || 'none')) - return joiningForms - } - - function stringToGlyphs(font, str) { - const glyphIds = [] - for (let i = 0; i < str.length; i++) { - const cc = str.codePointAt(i) - if (cc > 0xffff) i++ - glyphIds.push(Typr.U.codeToGlyph(font, cc)) - } - - const gsub = font['GSUB'] - if (gsub) { - const { lookupList, featureList } = gsub - let joiningForms - const supportedFeatures = /^(rlig|liga|mset|isol|init|fina|medi|half|pres|blws)$/ - const usedLookups = [] - featureList.forEach((feature) => { - if (supportedFeatures.test(feature.tag)) { - for (let ti = 0; ti < feature.tab.length; ti++) { - if (usedLookups[feature.tab[ti]]) continue - usedLookups[feature.tab[ti]] = true - const tab = lookupList[feature.tab[ti]] - const isJoiningFeature = /^(isol|init|fina|medi)$/.test(feature.tag) - if (isJoiningFeature && !joiningForms) { - //lazy - joiningForms = detectJoiningForms(str) - } - for (let ci = 0; ci < glyphIds.length; ci++) { - if (!joiningForms || !isJoiningFeature || formsToFeatures[joiningForms[ci]] === feature.tag) { - Typr.U._applySubs(glyphIds, ci, tab, lookupList) - } - } - } - } - }) - } - - return glyphIds - } - - function firstNum(...args) { - for (let i = 0; i < args.length; i++) { - if (typeof args[i] === 'number') { - return args[i] - } - } - } - - function wrapFontObj(typrFont) { - const glyphMap = Object.create(null) - - const os2 = typrFont['OS/2'] - const hhea = typrFont.hhea - const unitsPerEm = typrFont.head.unitsPerEm - const ascender = firstNum(os2 && os2.sTypoAscender, hhea && hhea.ascender, unitsPerEm) - - const fontObj = { - unitsPerEm, - ascender, - descender: firstNum(os2 && os2.sTypoDescender, hhea && hhea.descender, 0), - capHeight: firstNum(os2 && os2.sCapHeight, ascender), - xHeight: firstNum(os2 && os2.sxHeight, ascender), - lineGap: firstNum(os2 && os2.sTypoLineGap, hhea && hhea.lineGap), - forEachGlyph(text, fontSize, letterSpacing, callback) { - let glyphX = 0 - const fontScale = (1 / fontObj.unitsPerEm) * fontSize - - const glyphIndices = stringToGlyphs(typrFont, text) - let charIndex = 0 - let prevGlyphIndex = -1 - glyphIndices.forEach((glyphIndex) => { - // Typr returns a glyph index per string codepoint, with -1s in place of those that - // were omitted due to ligature substitution. So we can track original index in the - // string via simple increment, and skip everything else when seeing a -1. - if (glyphIndex !== -1) { - let glyphObj = glyphMap[glyphIndex] - if (!glyphObj) { - const { cmds, crds } = Typr.U.glyphToPath(typrFont, glyphIndex) - - // Build path string - let path = '' - let crdsIdx = 0 - for (let i = 0, len = cmds.length; i < len; i++) { - const numArgs = cmdArgLengths[cmds[i]] - path += cmds[i] - for (let j = 1; j <= numArgs; j++) { - path += (j > 1 ? ',' : '') + crds[crdsIdx++] - } - } - - // Find extents - Glyf gives this in metadata but not CFF, and Typr doesn't - // normalize the two, so it's simplest just to iterate ourselves. - let xMin, yMin, xMax, yMax - if (crds.length) { - xMin = yMin = Infinity - xMax = yMax = -Infinity - for (let i = 0, len = crds.length; i < len; i += 2) { - const x = crds[i] - const y = crds[i + 1] - if (x < xMin) xMin = x - if (y < yMin) yMin = y - if (x > xMax) xMax = x - if (y > yMax) yMax = y - } - } else { - xMin = xMax = yMin = yMax = 0 - } - - glyphObj = glyphMap[glyphIndex] = { - index: glyphIndex, - advanceWidth: typrFont.hmtx.aWidth[glyphIndex], - xMin, - yMin, - xMax, - yMax, - path, - pathCommandCount: cmds.length, - // forEachPathCommand(callback) { - // let argsIndex = 0 - // const argsArray = [] - // for (let i = 0, len = cmds.length; i < len; i++) { - // const numArgs = cmdArgLengths[cmds[i]] - // argsArray.length = 1 + numArgs - // argsArray[0] = cmds[i] - // for (let j = 1; j <= numArgs; j++) { - // argsArray[j] = crds[argsIndex++] - // } - // callback.apply(null, argsArray) - // } - // } - } - } - - // Kerning - if (prevGlyphIndex !== -1) { - glyphX += Typr.U.getPairAdjustment(typrFont, prevGlyphIndex, glyphIndex) * fontScale - } - - callback.call(null, glyphObj, glyphX, charIndex) - - if (glyphObj.advanceWidth) { - glyphX += glyphObj.advanceWidth * fontScale - } - if (letterSpacing) { - glyphX += letterSpacing * fontSize - } - - prevGlyphIndex = glyphIndex - } - charIndex += text.codePointAt(charIndex) > 0xffff ? 2 : 1 - }) - return glyphX - }, - } - - return fontObj - } - - return function parse(buffer) { - // Look to see if we have a WOFF file and convert it if so: - const peek = new Uint8Array(buffer, 0, 4) - const tag = Typr._bin.readASCII(peek, 0, 4) - if (tag === 'wOFF') { - buffer = woff2otf(buffer) - } else if (tag === 'wOF2') { - throw new Error('woff2 fonts not supported') - } - return wrapFontObj(Typr.parse(buffer)[0]) - } -} - -const workerModule = /*#__PURE__*/ defineWorkerModule({ - name: 'Typr Font Parser', - dependencies: [typrFactory, woff2otfFactory, parserFactory], - init(typrFactory, woff2otfFactory, parserFactory) { - const Typr = typrFactory() - const woff2otf = woff2otfFactory() - return parserFactory(Typr, woff2otf) - }, -}) - -export default workerModule diff --git a/src/core/Text/troika-three-text/GlyphsGeometry.js b/src/core/Text/troika-three-text/GlyphsGeometry.js deleted file mode 100644 index 5c8fcd4ee..000000000 --- a/src/core/Text/troika-three-text/GlyphsGeometry.js +++ /dev/null @@ -1,248 +0,0 @@ -import { - Float32BufferAttribute, - BufferGeometry, - PlaneGeometry, - InstancedBufferGeometry, - InstancedBufferAttribute, - Sphere, - Box3, - DoubleSide, - BackSide, -} from 'three' - -const templateGeometries = {} -function getTemplateGeometry(detail) { - let geom = templateGeometries[detail] - if (!geom) { - // Geometry is two planes back-to-back, which will always be rendered FrontSide only but - // appear as DoubleSide by default. FrontSide/BackSide are emulated using drawRange. - // We do it this way to avoid the performance hit of two draw calls for DoubleSide materials - // introduced by Three.js in r130 - see https://github.com/mrdoob/three.js/pull/21967 - const front = new PlaneGeometry(1, 1, detail, detail) - const back = front.clone() - const frontAttrs = front.attributes - const backAttrs = back.attributes - const combined = new BufferGeometry() - const vertCount = frontAttrs.uv.count - for (let i = 0; i < vertCount; i++) { - backAttrs.position.array[i * 3] *= -1 // flip position x - backAttrs.normal.array[i * 3 + 2] *= -1 // flip normal z - } - ;['position', 'normal', 'uv'].forEach((name) => { - combined.setAttribute( - name, - new Float32BufferAttribute([...frontAttrs[name].array, ...backAttrs[name].array], frontAttrs[name].itemSize) - ) - }) - combined.setIndex([...front.index.array, ...back.index.array.map((n) => n + vertCount)]) - combined.translate(0.5, 0.5, 0) - geom = templateGeometries[detail] = combined - } - return geom -} - -const glyphBoundsAttrName = 'aTroikaGlyphBounds' -const glyphIndexAttrName = 'aTroikaGlyphIndex' -const glyphColorAttrName = 'aTroikaGlyphColor' - -/** - @class GlyphsGeometry - - A specialized Geometry for rendering a set of text glyphs. Uses InstancedBufferGeometry to - render the glyphs using GPU instancing of a single quad, rather than constructing a whole - geometry with vertices, for much smaller attribute arraybuffers according to this math: - - Where N = number of glyphs... - - Instanced: - - position: 4 * 3 - - index: 2 * 3 - - normal: 4 * 3 - - uv: 4 * 2 - - glyph x/y bounds: N * 4 - - glyph indices: N * 1 - = 5N + 38 - - Non-instanced: - - position: N * 4 * 3 - - index: N * 2 * 3 - - normal: N * 4 * 3 - - uv: N * 4 * 2 - - glyph indices: N * 1 - = 39N - - A downside of this is the rare-but-possible lack of the instanced arrays extension, - which we could potentially work around with a fallback non-instanced implementation. - - */ -class GlyphsGeometry extends InstancedBufferGeometry { - constructor() { - super() - - this.detail = 1 - this.curveRadius = 0 - - // Define groups for rendering text outline as a separate pass; these will only - // be used when the `material` getter returns an array, i.e. outlineWidth > 0. - this.groups = [ - { start: 0, count: Infinity, materialIndex: 0 }, - { start: 0, count: Infinity, materialIndex: 1 }, - ] - - // Preallocate empty bounding objects - this.boundingSphere = new Sphere() - this.boundingBox = new Box3() - } - - computeBoundingSphere() { - // No-op; we'll sync the boundingSphere proactively when needed. - } - - computeBoundingBox() { - // No-op; we'll sync the boundingBox proactively when needed. - } - - // Since our base geometry contains triangles for both front and back sides, we can emulate - // the "side" by restricting the draw range. - setSide(side) { - const verts = this.getIndex().count - this.setDrawRange(side === BackSide ? verts / 2 : 0, side === DoubleSide ? verts : verts / 2) - } - - set detail(detail) { - if (detail !== this._detail) { - this._detail = detail - if (typeof detail !== 'number' || detail < 1) { - detail = 1 - } - const tpl = getTemplateGeometry(detail) - ;['position', 'normal', 'uv'].forEach((attr) => { - this.attributes[attr] = tpl.attributes[attr].clone() - }) - this.setIndex(tpl.getIndex().clone()) - } - } - get detail() { - return this._detail - } - - set curveRadius(r) { - if (r !== this._curveRadius) { - this._curveRadius = r - this._updateBounds() - } - } - get curveRadius() { - return this._curveRadius - } - - /** - * Update the geometry for a new set of glyphs. - * @param {Float32Array} glyphBounds - An array holding the planar bounds for all glyphs - * to be rendered, 4 entries for each glyph: x1,x2,y1,y1 - * @param {Float32Array} glyphAtlasIndices - An array holding the index of each glyph within - * the SDF atlas texture. - * @param {Array} blockBounds - An array holding the [minX, minY, maxX, maxY] across all glyphs - * @param {Array} [chunkedBounds] - An array of objects describing bounds for each chunk of N - * consecutive glyphs: `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`. This can be - * used with `applyClipRect` to choose an optimized `instanceCount`. - * @param {Uint8Array} [glyphColors] - An array holding r,g,b values for each glyph. - */ - updateGlyphs(glyphBounds, glyphAtlasIndices, blockBounds, chunkedBounds, glyphColors) { - // Update the instance attributes - updateBufferAttr(this, glyphBoundsAttrName, glyphBounds, 4) - updateBufferAttr(this, glyphIndexAttrName, glyphAtlasIndices, 1) - updateBufferAttr(this, glyphColorAttrName, glyphColors, 3) - this._blockBounds = blockBounds - this._chunkedBounds = chunkedBounds - this.instanceCount = glyphAtlasIndices.length - this._updateBounds() - } - - _updateBounds() { - const bounds = this._blockBounds - if (bounds) { - const { curveRadius, boundingBox: bbox } = this - if (curveRadius) { - const { PI, floor, min, max, sin, cos } = Math - const halfPi = PI / 2 - const twoPi = PI * 2 - const absR = Math.abs(curveRadius) - const leftAngle = bounds[0] / absR - const rightAngle = bounds[2] / absR - const minX = - floor((leftAngle + halfPi) / twoPi) !== floor((rightAngle + halfPi) / twoPi) - ? -absR - : min(sin(leftAngle) * absR, sin(rightAngle) * absR) - const maxX = - floor((leftAngle - halfPi) / twoPi) !== floor((rightAngle - halfPi) / twoPi) - ? absR - : max(sin(leftAngle) * absR, sin(rightAngle) * absR) - const maxZ = - floor((leftAngle + PI) / twoPi) !== floor((rightAngle + PI) / twoPi) - ? absR * 2 - : max(absR - cos(leftAngle) * absR, absR - cos(rightAngle) * absR) - bbox.min.set(minX, bounds[1], curveRadius < 0 ? -maxZ : 0) - bbox.max.set(maxX, bounds[3], curveRadius < 0 ? 0 : maxZ) - } else { - bbox.min.set(bounds[0], bounds[1], 0) - bbox.max.set(bounds[2], bounds[3], 0) - } - bbox.getBoundingSphere(this.boundingSphere) - } - } - - /** - * Given a clipping rect, and the chunkedBounds from the last updateGlyphs call, choose the lowest - * `instanceCount` that will show all glyphs within the clipped view. This is an optimization - * for long blocks of text that are clipped, to skip vertex shader evaluation for glyphs that would - * be clipped anyway. - * - * Note that since `drawElementsInstanced[ANGLE]` only accepts an instance count and not a starting - * offset, this optimization becomes less effective as the clipRect moves closer to the end of the - * text block. We could fix that by switching from instancing to a full geometry with a drawRange, - * but at the expense of much larger attribute buffers (see classdoc above.) - * - * @param {Vector4} clipRect - */ - applyClipRect(clipRect) { - let count = this.getAttribute(glyphIndexAttrName).count - const chunks = this._chunkedBounds - if (chunks) { - for (let i = chunks.length; i--; ) { - count = chunks[i].end - const rect = chunks[i].rect - // note: both rects are l-b-r-t - if (rect[1] < clipRect.w && rect[3] > clipRect.y && rect[0] < clipRect.z && rect[2] > clipRect.x) { - break - } - } - } - this.instanceCount = count - } -} - -function updateBufferAttr(geom, attrName, newArray, itemSize) { - const attr = geom.getAttribute(attrName) - if (newArray) { - // If length isn't changing, just update the attribute's array data - if (attr && attr.array.length === newArray.length) { - attr.array.set(newArray) - attr.needsUpdate = true - } else { - geom.setAttribute(attrName, new InstancedBufferAttribute(newArray, itemSize)) - // If the new attribute has a different size, we also have to (as of r117) manually clear the - // internal cached max instance count. See https://github.com/mrdoob/three.js/issues/19706 - // It's unclear if this is a threejs bug or a truly unsupported scenario; discussion in - // that ticket is ambiguous as to whether replacing a BufferAttribute with one of a - // different size is supported, but https://github.com/mrdoob/three.js/pull/17418 strongly - // implies it should be supported. It's possible we need to - delete geom._maxInstanceCount //for r117+, could be fragile - geom.dispose() //for r118+, more robust feeling, but more heavy-handed than I'd like - } - } else if (attr) { - geom.deleteAttribute(attrName) - } -} - -export { GlyphsGeometry } diff --git a/src/core/Text/troika-three-text/LICENSE b/src/core/Text/troika-three-text/LICENSE deleted file mode 100644 index 834538483..000000000 --- a/src/core/Text/troika-three-text/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) 2019 ProtectWise -Copyright (c) 2021 Jason Johnston - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/core/Text/troika-three-text/README.md b/src/core/Text/troika-three-text/README.md deleted file mode 100644 index 173aa37d7..000000000 --- a/src/core/Text/troika-three-text/README.md +++ /dev/null @@ -1,381 +0,0 @@ -# Troika Text for Three.js - -The `troika-three-text` package provides high quality text rendering in [Three.js](https://threejs.org) scenes, using signed distance fields (SDF) and antialiasing using standard derivatives. - -Rather than relying on pre-generated SDF textures, this parses font files (.ttf, .otf, .woff) directly using [Typr](https://github.com/fredli74/Typr.ts), and generates the SDF atlas for glyphs on-the-fly as they are used. It also handles proper kerning, ligature glyph substitution, right-to-left/bidirectional layout, and joined scripts like Arabic. All font parsing, SDF generation, and glyph layout is performed in a web worker to prevent frame drops. - -Once the SDFs are generated, it assembles a geometry that positions all the glyphs, and _patches_ any Three.js Material with the proper shader code for rendering the SDFs. This means you can still benefit from all the features of Three.js's built-in materials like lighting, physically-based rendering, shadows, and fog. - -## Demos - -- [With the Troika scene management framework](https://troika-examples.netlify.com/#text) -- [With react-three-fiber](https://codesandbox.io/embed/troika-3d-text-via-react-three-fiber-ntfx2?fontsize=14) -- [With a video texture](https://bfplr.csb.app/) -- [With the Material Icons font](https://codesandbox.io/s/material-icons-in-troika-three-text-t2mu7?file=/src/index.js) - -## With Other Frameworks - -- [In the `drei` utilities for react-three-fiber](https://github.com/pmndrs/drei#text) -- [As an A-Frame component](https://github.com/lojjic/aframe-troika-text) -- [As a Web Component in three-elements](https://www.npmjs.com/package/@three-elements/text) - -## Screenshots - -![Text Rendering](../../docs/troika-three-text/images/screenshot1.png) - -![Zoomed-in](../../docs/troika-three-text/images/screenshot2.png) - -![Font with ligatures](../../docs/troika-three-text/images/screenshot3.png) - -![Text with a texture](../../docs/troika-three-text/images/screenshot4.png) - -## Installation - -Get it from [NPM](https://www.npmjs.com/package/troika-three-text): - -```sh -npm install troika-three-text -``` - -You will also need to install a compatible version of [Three.js](https://threejs.org); see the [notes on Three.js versions in the Getting Started docs](../../docs/getting-started/setup.md#threejs) for details. - -```sh -npm install three -``` - -## Usage - -```js -import { Text } from 'troika-three-text' -``` - -You can then use the `Text` class like any other Three.js mesh: - -```js -// Create: -const myText = new Text() -myScene.add(myText) - -// Set properties to configure: -myText.text = 'Hello world!' -myText.fontSize = 0.2 -myText.position.z = -2 -myText.color = 0x9966ff - -// Update the rendering: -myText.sync() -``` - -It's a good idea to call the `.sync()` method after changing any properties that would affect the text's layout. If you don't, it will be called automatically on the next render frame, but calling it yourself can get the result sooner. - -When you're done with the `Text` instance, be sure to call `dispose` on it to prevent a memory leak: - -```js -myScene.remove(myText) -myText.dispose() -``` - -## Supported properties - -Instances of `Text` support the following configuration properties: - -### `text` - -The string of text to be rendered. Newlines and repeating whitespace characters are honored. - -Default: _none_ - -### `anchorX` - -Defines the horizontal position in the text block that should line up with the local origin. Can be specified as a numeric `x` position in local units, a string percentage of the total text block width e.g. `'25%'`, or one of the following keyword strings: `'left'`, `'center'`, or `'right'`. - -Default: `0` - -### `anchorY` - -Defines the vertical position in the text block that should line up with the local origin. Can be specified as a numeric `y` position in local units (note: down is negative y), a string percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings: `'top'`, `'top-baseline'`, `'top-cap'`, `'top-ex'`, `'middle'`, `'bottom-baseline'`, or `'bottom'`. - -Default: `0` - -### `clipRect` - -If specified, defines the `[minX, minY, maxX, maxY]` of a rectangle outside of which all pixels will be discarded. This can be used for example to clip overflowing text when `whiteSpace='nowrap'`. - -Default: _none_ - -### `color` - -This is a shortcut for setting the `color` of the text's `material`. You can use this if you don't want to specify a whole custom `material` and just want to change its color. - -Use the `material` property if you want to control aspects of the material other than its color. - -Default: _none_ - uses the color of the `material` - -### `curveRadius` - -Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave curvature, while negative numbers put it behind the text for a convex curvature. The centerline will be aligned with the text's local origin; you can use `anchorX` to offset it. - -Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane internally. You can use [`glyphGeometryDetail`](#glyphgeometrydetail) to add more vertices for curvature inside glyphs. - -Default: `0` - -### `depthOffset` - -This is a shortcut for setting the material's [`polygonOffset` and related properties](https://threejs.org/docs/#api/en/materials/Material.polygonOffset), which can be useful in preventing z-fighting when this text is laid on top of another plane in the scene. Positive numbers are further from the camera, negatives closer. - -Be aware that while this can help with z-fighting, it does not affect the rendering order; if the text renders before the content behind it, you may see antialiasing pixels that appear too dark or light. You may need to also change the text mesh's `renderOrder`, or set its `z` position a fraction closer to the camera, to ensure the text renders after background objects. - -Default: `0` - -### `direction` - -Sets the base direction for the text. The default value of "auto" will choose a direction based on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction. - -Default: `'auto'` - -### `fillOpacity` - -Controls the opacity of just the glyph's fill area, separate from any configured `strokeOpacity`, `outlineOpacity`, and the material's `opacity`. A `fillOpacity` of `0` will make the fill invisible, leaving just the stroke and/or outline. - -Default: `1` - -### `font` - -The URL of a custom font file to be used. Supported font formats are: - -- .ttf -- .otf -- .woff (.woff2 is _not_ supported) - -Default: The _Roboto_ font loaded from Google Fonts CDN - -### `fontSize` - -The em-height at which to render the font, in local world units. - -Default: `0.1` - -### `glyphGeometryDetail` - -The number of vertical/horizontal segments that make up each glyph's rectangular plane. This can be increased to provide more geometrical detail for custom vertex shader effects, for example. - -Default: `1` - -### `gpuAccelerateSDF` - -When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible, making it much faster especially for complex glyphs, and falling back to a JavaScript version executed in web workers when support isn't available. It should automatically detect support, but it's still somewhat experimental, so you can set it to `false` to force it to use the JS version if you encounter issues with it. - -Default: `true` - -### `letterSpacing` - -Sets a uniform adjustment to spacing between letters after kerning is applied, in local world units. Positive numbers increase spacing and negative numbers decrease it. - -Default: `0` - -### `lineHeight` - -Sets the height of each line of text. Can either be `'normal'` which chooses a reasonable height based on the chosen font's ascender/descender metrics, or a number that is interpreted as a multiple of the `fontSize`. - -Default: `'normal'` - -### `material` - -Defines a Three.js Material _instance_ to be used as a base when rendering the text. This material will be automatically replaced with a new material derived from it, that adds shader code to decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing. - -By default it will derive from a simple white `MeshBasicMaterial, but you can use any of the other mesh materials to gain other features like lighting, texture maps, etc. - -Also see the `color` shortcut property. - -Note that because your material instance is _replaced_ by a derived material instance, any changes you make to your original material will _not_ be reflected in the derived version. If you need to modify properties of the material afterward, be sure you get a new reference to the derived version: - -```js -// Bad: -text.material = myOrigMaterial -myOrigMaterial.opacity = 0.5 - -// Good: -text.material = myOrigMaterial -text.material.opacity = 0.5 -``` - -Default: a `MeshBasicMaterial` instance - -### `maxWidth` - -The maximum width of the text block, above which text may start wrapping according to the `whiteSpace` and `overflowWrap` properties. - -Default: `Infinity`, meaning text will never wrap - -### `outlineBlur` - -Specifies a blur radius applied to the outer edge of the text's `outlineWidth`. If the `outlineWidth` is zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius. A blur plus a nonzero `outlineWidth` can give a solid outline with a fuzzy outer edge. - -The blur radius can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` which is treated as a percentage of the `fontSize`. - -Default: `0` - -### `outlineColor` - -The color to use for the text outline when `outlineWidth`, `outlineBlur`, and/or `outlineOffsetX/Y` are set. Accepts a ThreeJS `Color` object, or a number/string accepted by `Color#set`. - -Default: black - -### `outlineOffsetX`, `outlineOffsetY` - -These define a horizontal and vertical offset of the text outline. Using an offset with `outlineWidth: 0` creates a drop-shadow effect like CSS's `text-shadow`; also see `outlineBlur`. - -The offsets can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` which is treated as a percentage of the `fontSize`. - -Default: `0` - -### `outlineOpacity` - -Sets the opacity of a configured text outline, in the range `0` to `1`. - -Default: `1` - -### `outlineWidth` - -The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`. This can help improve readability when the text is displayed against a background of low or varying contrast. - -The width can be specified as either an absolute number in local units, or as a percentage string e.g. `"10%"` which is interpreted as a percentage of the `fontSize`. - -Default: `0` - -### `overflowWrap` - -Defines how text wraps if the `whiteSpace` property is `'normal'`. Can be either `'normal'` to break at whitespace characters, or `'break-word'` to allow breaking within words. - -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` - -### `strokeColor` - -The color of the text stroke, when `strokeWidth` is nonzero. Accepts a ThreeJS `Color` object, or a number/string accepted by `Color#set`. - -Default: grey - -### `strokeOpacity` - -The opacity of the text stroke, when `strokeWidth` is nonzero. Accepts a number from `0` to `1`. - -Default: `1` - -### `strokeWidth` - -Sets the width of a stroke drawn inside the edge of each text glyph, using the `strokeColor` and `strokeOpacity`. - -The width can be specified as either an absolute number in local units, or as a percentage string e.g. `"10%"` which is interpreted as a percentage of the `fontSize`. - -Default: `0` - -### `textAlign` - -The horizontal alignment of each line of text within the overall text bounding box. Can be one of `'left'`, `'right'`, `'center'`, or `'justify'`. - -Default: `'left'` - -### `textIndent` - -An indentation applied to the first character of each _hard_ newline. Behaves like CSS `text-indent`. - -Default: `0` - -### `whiteSpace` - -Defines whether text should wrap when a line reaches the `maxWidth`. Can be either `'normal'`, to allow wrapping according to the `overflowWrap` property, or `'nowrap'` to prevent wrapping. - -Note that `'normal'` in this context _does_ honor newline characters to manually break lines, making it behave more like `'pre-wrap'` does in CSS. - -Default: `'normal'` - -## Handling Asynchronous Updates - -Since the text processing occurs in a web worker, it is by definition asynchronous. This means that you can't rely on the text being visible or having a complete geometry immediately. If you need to do things like access the geometry's `boundingSphere` or the `textRenderInfo`, you will have to listen for completion. You can do this two ways: - -1. Pass a callback function when you call the `sync` method: - - ```js - myText.sync(() => { - // code to execute after sync completes... - }) - ``` - - This is best when you want to only react to _that specific_ sync call. Keep in mind that the callback will not execute if the text is already fully synced. - -2. Add a listener for the `synccomplete` event: - - ```js - myText.addEventListener('synccomplete', () => { - // code to execute after sync completes... - }) - ``` - - This will fire after _every_ sync, no matter who invoked it. This is best if you need to react to all syncs, for example to trigger a manual canvas render. - - You can also listen for the `syncstart` event if you need to react to the initiation of a sync call, e.g. to set some sort of "waiting" state while the text is being processed. - -## Preloading - -To avoid long pauses when first displaying a piece of text in your scene, you can preload fonts and optionally pre-generate the SDF textures for particular glyphs up front: - -```js -import { preloadFont } from 'troika-three-text' - -myApp.showLoadingScreen() - -preloadFont( - { - font: 'path/to/myfontfile.woff', - characters: 'abcdefghijklmnopqrstuvwxyz', - }, - () => { - myApp.showScene() - } -) -``` - -The arguments are: - -- `options` - - - `options.font` - The URL of the font file to preload. If `null` is passed, this will preload the default font. - - - `options.characters` - A string or array of string character sequences for which to pre-generate glyph SDF textures. 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" glyph. - - - `options.sdfGlyphSize` - The size at which to prerender the SDFs for the `characters` glyphs. See the `sdfGlyphSize` config property on `Text` for details about SDF sizes. If not specified, will use the default SDF size. - -- `callback` - A function that will be called when the preloading is complete. - -## Postprocessing - -It is possible to use `Text` within scenes that utilize the [postprocessing](https://github.com/vanruesc/postprocessing) library for applying image effects. However, you must enable a special mode in that library that allows `Text`'s custom material to be honored. Just do the following once somewhere in your code: - -```js -import { OverrideMaterialManager } from 'postprocessing' - -OverrideMaterialManager.workaroundEnabled = true -``` - -## Carets and Selection Ranges - -In addition to rendering text, it is possible to access positioning information for caret placement and selection ranges. To access that info, use the `getCaretAtPoint` and `getSelectionRects` utility functions. Both of these functions take a `textRenderInfo` object as input, which you can get from the `Text` object's `textRenderInfo` property after sync has completed. See "Handling Asynchronous Updates" above for how to react to sync completion events. - -### `getCaretAtPoint(textRenderInfo, x, y)` - -This returns the caret position nearest to a given x/y position in the local text plane. This is useful for placing an editing caret based on a click or ther raycasted event. The return value is an object with the following properties: - -- `x` - x position of the caret -- `y` - y position of the caret's bottom -- `height` - height of the caret, based on the current fontSize and lineHeight -- `charIndex` - the index in the original input string of this caret's target character. The caret will be for the position _before_ that character. For the final caret position, this will be equal to the string length. For ligature glyphs, this will be for the first character in the ligature sequence. - -### `getSelectionRects(textRenderInfo, start, end)` - -This returns a list of rectangles covering all the characters within a given character range. This is useful for highlighting a selection range. The return value is an array of objects, each with `{left, top, right, bottom}` properties in the local text plane. diff --git a/src/core/Text/troika-three-text/SDFGenerator.js b/src/core/Text/troika-three-text/SDFGenerator.js deleted file mode 100644 index 5f9889356..000000000 --- a/src/core/Text/troika-three-text/SDFGenerator.js +++ /dev/null @@ -1,132 +0,0 @@ -import { defineWorkerModule, terminateWorker } from 'troika-worker-utils' -import createSDFGenerator from 'webgl-sdf-generator' - -const now = () => (self.performance || Date).now() - -const mainThreadGenerator = createSDFGenerator() - -let warned - -/** - * Generate an SDF texture image for a single glyph path, placing the result into a webgl canvas at a - * given location and channel. Utilizes the webgl-sdf-generator external package for GPU-accelerated SDF - * generation when supported. - */ -export function generateSDF(width, height, path, viewBox, distance, exponent, canvas, x, y, channel, useWebGL = true) { - // Allow opt-out - if (!useWebGL) { - return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) - } - - // Attempt GPU-accelerated generation first - return generateSDF_GL(width, height, path, viewBox, distance, exponent, canvas, x, y, channel).then(null, (err) => { - // WebGL failed either due to a hard error or unexpected results; fall back to JS in workers - if (!warned) { - console.warn(`WebGL SDF generation failed, falling back to JS`, err) - warned = true - } - return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) - }) -} - -/** - * WebGL-based implementation executed on the main thread. Requests are executed in time-bounded - * macrotask chunks to allow render frames to execute in between. - */ -const queue = [] -const chunkTimeBudget = 5 //ms -let timer = 0 - -function nextChunk() { - const start = now() - while (queue.length && now() - start < chunkTimeBudget) { - queue.shift()() - } - timer = queue.length ? setTimeout(nextChunk, 0) : 0 -} - -const generateSDF_GL = (...args) => { - return new Promise((resolve, reject) => { - queue.push(() => { - const start = now() - try { - mainThreadGenerator.webgl.generateIntoCanvas(...args) - resolve({ timing: now() - start }) - } catch (err) { - reject(err) - } - }) - if (!timer) { - timer = setTimeout(nextChunk, 0) - } - }) -} - -/** - * Fallback JS-based implementation, fanned out to a number of worker threads for parallelism - */ -const threadCount = 4 // how many workers to spawn -const idleTimeout = 2000 // workers will be terminated after being idle this many milliseconds -const threads = {} -let callNum = 0 - -function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) { - const workerId = 'TroikaTextSDFGenerator_JS_' + (callNum++ % threadCount) - let thread = threads[workerId] - if (!thread) { - thread = threads[workerId] = { - workerModule: defineWorkerModule({ - name: workerId, - workerId, - dependencies: [createSDFGenerator, now], - init(_createSDFGenerator, now) { - const generate = _createSDFGenerator().javascript.generate - return function (...args) { - const start = now() - const textureData = generate(...args) - return { - textureData, - timing: now() - start, - } - } - }, - getTransferables(result) { - return [result.textureData.buffer] - }, - }), - requests: 0, - idleTimer: null, - } - } - - thread.requests++ - clearTimeout(thread.idleTimer) - return thread.workerModule(width, height, path, viewBox, distance, exponent).then(({ textureData, timing }) => { - // copy result data into the canvas - const start = now() - // expand single-channel data into rgba - const imageData = new Uint8Array(textureData.length * 4) - for (let i = 0; i < textureData.length; i++) { - imageData[i * 4 + channel] = textureData[i] - } - mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel)) - timing += now() - start - - // clean up workers after a while - if (--thread.requests === 0) { - thread.idleTimer = setTimeout(() => { - terminateWorker(workerId) - }, idleTimeout) - } - return { timing } - }) -} - -export function warmUpSDFCanvas(canvas) { - if (!canvas._warm) { - mainThreadGenerator.webgl.isSupported(canvas) - canvas._warm = true - } -} - -export const resizeWebGLCanvasWithoutClearing = mainThreadGenerator.webglUtils.resizeWebGLCanvasWithoutClearing diff --git a/src/core/Text/troika-three-text/Text.js b/src/core/Text/troika-three-text/Text.js deleted file mode 100644 index 476383826..000000000 --- a/src/core/Text/troika-three-text/Text.js +++ /dev/null @@ -1,754 +0,0 @@ -import { Color, DoubleSide, FrontSide, Matrix4, Mesh, MeshBasicMaterial, PlaneGeometry, Vector3, Vector2 } from 'three' -import { GlyphsGeometry } from './GlyphsGeometry.js' -import { createTextDerivedMaterial } from './TextDerivedMaterial.js' -import { getTextRenderInfo } from './TextBuilder.js' - -const defaultMaterial = /*#__PURE__*/ new MeshBasicMaterial({ - color: 0xffffff, - side: DoubleSide, - transparent: true, -}) -const defaultStrokeColor = 0x808080 - -const tempMat4 = /*#__PURE__*/ new Matrix4() -const tempVec3a = /*#__PURE__*/ new Vector3() -const tempVec3b = /*#__PURE__*/ new Vector3() -const tempArray = [] -const origin = /*#__PURE__*/ new Vector3() -const defaultOrient = '+x+y' - -function first(o) { - return Array.isArray(o) ? o[0] : o -} - -let getFlatRaycastMesh = () => { - const mesh = new Mesh(new PlaneGeometry(1, 1), defaultMaterial) - getFlatRaycastMesh = () => mesh - return mesh -} -let getCurvedRaycastMesh = () => { - const mesh = new Mesh(new PlaneGeometry(1, 1, 32, 1), defaultMaterial) - getCurvedRaycastMesh = () => mesh - return mesh -} - -const syncStartEvent = { type: 'syncstart' } -const syncCompleteEvent = { type: 'synccomplete' } - -const SYNCABLE_PROPS = [ - 'font', - 'fontSize', - 'letterSpacing', - 'lineHeight', - 'maxWidth', - 'overflowWrap', - 'text', - 'direction', - 'textAlign', - 'textIndent', - 'whiteSpace', - 'anchorX', - 'anchorY', - 'colorRanges', - 'sdfGlyphSize', -] - -const COPYABLE_PROPS = SYNCABLE_PROPS.concat( - 'material', - 'color', - 'depthOffset', - 'clipRect', - 'curveRadius', - 'orientation', - 'glyphGeometryDetail' -) - -/** - * @class Text - * - * A ThreeJS Mesh that renders a string of text on a plane in 3D space using signed distance - * fields (SDF). - */ -class Text extends Mesh { - constructor() { - const geometry = new GlyphsGeometry() - super(geometry, null) - - // === Text layout properties: === // - - /** - * @member {string} text - * The string of text to be rendered. - */ - this.text = '' - - /** - * @member {number|string} anchorX - * Defines the horizontal position in the text block that should line up with the local origin. - * Can be specified as a numeric x position in local units, a string percentage of the total - * text block width e.g. `'25%'`, or one of the following keyword strings: 'left', 'center', - * or 'right'. - */ - this.anchorX = 0 - - /** - * @member {number|string} anchorX - * Defines the vertical position in the text block that should line up with the local origin. - * Can be specified as a numeric y position in local units (note: down is negative y), a string - * percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings: - * 'top', 'top-baseline', 'top-cap', 'top-ex', 'middle', 'bottom-baseline', or 'bottom'. - */ - this.anchorY = 0 - - /** - * @member {number} curveRadius - * Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put - * the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave - * curvature, while negative numbers put it behind the text for a convex curvature. The centerline - * will be aligned with the text's local origin; you can use `anchorX` to offset it. - * - * Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane - * internally. You can use `glyphGeometryDetail` to add more vertices for curvature inside glyphs. - */ - this.curveRadius = 0 - - /** - * @member {string} direction - * Sets the base direction for the text. The default value of "auto" will choose a direction based - * on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction. - */ - this.direction = 'auto' - - /** - * @member {string} font - * URL of a custom font to be used. Font files can be in .ttf, .otf, or .woff (not .woff2) formats. - * Defaults to the Roboto font loaded from Google Fonts. - */ - this.font = null //will use default from TextBuilder - - /** - * @member {number} fontSize - * The size at which to render the font in local units; corresponds to the em-box height - * of the chosen `font`. - */ - this.fontSize = 0.1 - - /** - * @member {number} letterSpacing - * Sets a uniform adjustment to spacing between letters after kerning is applied. Positive - * numbers increase spacing and negative numbers decrease it. - */ - this.letterSpacing = 0 - - /** - * @member {number|string} lineHeight - * Sets the height of each line of text, as a multiple of the `fontSize`. Defaults to 'normal' - * which chooses a reasonable height based on the chosen font's ascender/descender metrics. - */ - this.lineHeight = 'normal' - - /** - * @member {number} maxWidth - * The maximum width of the text block, above which text may start wrapping according to the - * `whiteSpace` and `overflowWrap` properties. - */ - this.maxWidth = Infinity - - /** - * @member {string} overflowWrap - * Defines how text wraps if the `whiteSpace` property is `normal`. Can be either `'normal'` - * to break at whitespace characters, or `'break-word'` to allow breaking within words. - * Defaults to `'normal'`. - */ - this.overflowWrap = 'normal' - - /** - * @member {string} textAlign - * The horizontal alignment of each line of text within the overall text bounding box. - */ - this.textAlign = 'left' - - /** - * @member {number} textIndent - * Indentation for the first character of a line; see CSS `text-indent`. - */ - this.textIndent = 0 - - /** - * @member {string} whiteSpace - * Defines whether text should wrap when a line reaches the `maxWidth`. Can - * be either `'normal'` (the default), to allow wrapping according to the `overflowWrap` property, - * or `'nowrap'` to prevent wrapping. Note that `'normal'` here honors newline characters to - * manually break lines, making it behave more like `'pre-wrap'` does in CSS. - */ - this.whiteSpace = 'normal' - - // === Presentation properties: === // - - /** - * @member {THREE.Material} material - * Defines a _base_ material to be used when rendering the text. This material will be - * automatically replaced with a material derived from it, that adds shader code to - * decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing. - * By default it will derive from a simple white MeshBasicMaterial, but you can use any - * of the other mesh materials to gain other features like lighting, texture maps, etc. - * - * Also see the `color` shortcut property. - */ - this.material = null - - /** - * @member {string|number|THREE.Color} color - * This is a shortcut for setting the `color` of the text's material. You can use this - * if you don't want to specify a whole custom `material`. Also, if you do use a custom - * `material`, this color will only be used for this particuar Text instance, even if - * that same material instance is shared across multiple Text objects. - */ - this.color = null - - /** - * @member {object|null} colorRanges - * WARNING: This API is experimental and may change. - * This allows more fine-grained control of colors for individual or ranges of characters, - * taking precedence over the material's `color`. Its format is an Object whose keys each - * define a starting character index for a range, and whose values are the color for each - * range. The color value can be a numeric hex color value, a `THREE.Color` object, or - * any of the strings accepted by `THREE.Color`. - */ - this.colorRanges = null - - /** - * @member {number|string} outlineWidth - * WARNING: This API is experimental and may change. - * The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`. - * Can be specified as either an absolute number in local units, or as a percentage string e.g. - * `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`, which means - * no outline will be drawn unless an `outlineOffsetX/Y` or `outlineBlur` is set. - */ - this.outlineWidth = 0 - - /** - * @member {string|number|THREE.Color} outlineColor - * WARNING: This API is experimental and may change. - * The color of the text outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set. - * Defaults to black. - */ - this.outlineColor = 0x000000 - - /** - * @member {number} outlineOpacity - * WARNING: This API is experimental and may change. - * The opacity of the outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set. - * Defaults to `1`. - */ - this.outlineOpacity = 1 - - /** - * @member {number|string} outlineBlur - * WARNING: This API is experimental and may change. - * A blur radius applied to the outer edge of the text's outline. If the `outlineWidth` is - * zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius. - * Can be specified as either an absolute number in local units, or as a percentage string e.g. - * `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`. - */ - this.outlineBlur = 0 - - /** - * @member {number|string} outlineOffsetX - * WARNING: This API is experimental and may change. - * A horizontal offset for the text outline. - * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` - * which is treated as a percentage of the `fontSize`. Defaults to `0`. - */ - this.outlineOffsetX = 0 - - /** - * @member {number|string} outlineOffsetY - * WARNING: This API is experimental and may change. - * A vertical offset for the text outline. - * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` - * which is treated as a percentage of the `fontSize`. Defaults to `0`. - */ - this.outlineOffsetY = 0 - - /** - * @member {number|string} strokeWidth - * WARNING: This API is experimental and may change. - * The width of an inner stroke drawn inside each text glyph using the `strokeColor` and `strokeOpacity`. - * Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"` - * which is treated as a percentage of the `fontSize`. Defaults to `0`. - */ - this.strokeWidth = 0 - - /** - * @member {string|number|THREE.Color} strokeColor - * WARNING: This API is experimental and may change. - * The color of the text stroke, if `strokeWidth` is greater than zero. Defaults to gray. - */ - this.strokeColor = defaultStrokeColor - - /** - * @member {number} strokeOpacity - * WARNING: This API is experimental and may change. - * The opacity of the stroke, if `strokeWidth` is greater than zero. Defaults to `1`. - */ - this.strokeOpacity = 1 - - /** - * @member {number} fillOpacity - * WARNING: This API is experimental and may change. - * The opacity of the glyph's fill from 0 to 1. This behaves like the material's `opacity` but allows - * giving the fill a different opacity than the `strokeOpacity`. A fillOpacity of `0` makes the - * interior of the glyph invisible, leaving just the `strokeWidth`. Defaults to `1`. - */ - this.fillOpacity = 1 - - /** - * @member {number} depthOffset - * This is a shortcut for setting the material's `polygonOffset` and related properties, - * which can be useful in preventing z-fighting when this text is laid on top of another - * plane in the scene. Positive numbers are further from the camera, negatives closer. - */ - this.depthOffset = 0 - - /** - * @member {Array} clipRect - * If specified, defines a `[minX, minY, maxX, maxY]` of a rectangle outside of which all - * pixels will be discarded. This can be used for example to clip overflowing text when - * `whiteSpace='nowrap'`. - */ - this.clipRect = null - - /** - * @member {string} orientation - * Defines the axis plane on which the text should be laid out when the mesh has no extra - * rotation transform. It is specified as a string with two axes: the horizontal axis with - * positive pointing right, and the vertical axis with positive pointing up. By default this - * is '+x+y', meaning the text sits on the xy plane with the text's top toward positive y - * and facing positive z. A value of '+x-z' would place it on the xz plane with the text's - * top toward negative z and facing positive y. - */ - this.orientation = defaultOrient - - /** - * @member {number} glyphGeometryDetail - * Controls number of vertical/horizontal segments that make up each glyph's rectangular - * plane. Defaults to 1. This can be increased to provide more geometrical detail for custom - * vertex shader effects, for example. - */ - 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 - - /** - * @member {boolean} gpuAccelerateSDF - * When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible, - * making it much faster especially for complex glyphs, and falling back to a JavaScript version - * executed in web workers when support isn't available. It should automatically detect support, - * but it's still somewhat experimental, so you can set it to `false` to force it to use the JS - * version if you encounter issues with it. - */ - this.gpuAccelerateSDF = true - - this.debugSDF = false - } - - /** - * Updates the text rendering according to the current text-related configuration properties. - * This is an async process, so you can pass in a callback function to be executed when it - * finishes. - * @param {function} [callback] - */ - sync(callback) { - if (this._needsSync) { - this._needsSync = false - - // If there's another sync still in progress, queue - if (this._isSyncing) { - ;(this._queuedSyncs || (this._queuedSyncs = [])).push(callback) - } else { - this._isSyncing = true - this.dispatchEvent(syncStartEvent) - - getTextRenderInfo( - { - text: this.text, - font: this.font, - fontSize: this.fontSize || 0.1, - letterSpacing: this.letterSpacing || 0, - lineHeight: this.lineHeight || 'normal', - maxWidth: this.maxWidth, - direction: this.direction || 'auto', - textAlign: this.textAlign, - textIndent: this.textIndent, - whiteSpace: this.whiteSpace, - overflowWrap: this.overflowWrap, - anchorX: this.anchorX, - anchorY: this.anchorY, - colorRanges: this.colorRanges, - includeCaretPositions: true, //TODO parameterize - sdfGlyphSize: this.sdfGlyphSize, - gpuAccelerateSDF: this.gpuAccelerateSDF, - }, - (textRenderInfo) => { - this._isSyncing = false - - // Save result for later use in onBeforeRender - this._textRenderInfo = textRenderInfo - - // Update the geometry attributes - this.geometry.updateGlyphs( - textRenderInfo.glyphBounds, - textRenderInfo.glyphAtlasIndices, - textRenderInfo.blockBounds, - textRenderInfo.chunkedBounds, - textRenderInfo.glyphColors - ) - - // If we had extra sync requests queued up, kick it off - const queued = this._queuedSyncs - if (queued) { - this._queuedSyncs = null - this._needsSync = true - this.sync(() => { - queued.forEach((fn) => fn && fn()) - }) - } - - this.dispatchEvent(syncCompleteEvent) - if (callback) { - callback() - } - } - ) - } - } - } - - /** - * Initiate a sync if needed - note it won't complete until next frame at the - * earliest so if possible it's a good idea to call sync() manually as soon as - * all the properties have been set. - * @override - */ - onBeforeRender(renderer, scene, camera, geometry, material) { - this.sync() - - // This may not always be a text material, e.g. if there's a scene.overrideMaterial present - if (material.isTroikaTextMaterial) { - this._prepareForRender(material) - } - - // We need to force the material to FrontSide to avoid the double-draw-call performance hit - // introduced in Three.js r130: https://github.com/mrdoob/three.js/pull/21967 - The sidedness - // is instead applied via drawRange in the GlyphsGeometry. - material._hadOwnSide = material.hasOwnProperty('side') - this.geometry.setSide((material._actualSide = material.side)) - material.side = FrontSide - } - - onAfterRender(renderer, scene, camera, geometry, material) { - // Restore original material side - if (material._hadOwnSide) { - material.side = material._actualSide - } else { - delete material.side // back to inheriting from base material - } - } - - /** - * Shortcut to dispose the geometry specific to this instance. - * Note: we don't also dispose the derived material here because if anything else is - * sharing the same base material it will result in a pause next frame as the program - * is recompiled. Instead users can dispose the base material manually, like normal, - * and we'll also dispose the derived material at that time. - */ - dispose() { - this.geometry.dispose() - } - - /** - * @property {TroikaTextRenderInfo|null} textRenderInfo - * @readonly - * The current processed rendering data for this TextMesh, returned by the TextBuilder after - * a `sync()` call. This will be `null` initially, and may be stale for a short period until - * the asynchrous `sync()` process completes. - */ - get textRenderInfo() { - return this._textRenderInfo || null - } - - // Handler for automatically wrapping the base material with our upgrades. We do the wrapping - // lazily on _read_ rather than write to avoid unnecessary wrapping on transient values. - get material() { - let derivedMaterial = this._derivedMaterial - const baseMaterial = - this._baseMaterial || this._defaultMaterial || (this._defaultMaterial = defaultMaterial.clone()) - if (!derivedMaterial || derivedMaterial.baseMaterial !== baseMaterial) { - derivedMaterial = this._derivedMaterial = createTextDerivedMaterial(baseMaterial) - // dispose the derived material when its base material is disposed: - baseMaterial.addEventListener('dispose', function onDispose() { - baseMaterial.removeEventListener('dispose', onDispose) - derivedMaterial.dispose() - }) - } - // If text outline is configured, render it as a preliminary draw using Three's multi-material - // feature (see GlyphsGeometry which sets up `groups` for this purpose) Doing it with multi - // materials ensures the layers are always rendered consecutively in a consistent order. - // Each layer will trigger onBeforeRender with the appropriate material. - if (this.outlineWidth || this.outlineBlur || this.outlineOffsetX || this.outlineOffsetY) { - let outlineMaterial = derivedMaterial._outlineMtl - if (!outlineMaterial) { - outlineMaterial = derivedMaterial._outlineMtl = Object.create(derivedMaterial, { - id: { value: derivedMaterial.id + 0.1 }, - }) - outlineMaterial.isTextOutlineMaterial = true - outlineMaterial.depthWrite = false - outlineMaterial.map = null //??? - derivedMaterial.addEventListener('dispose', function onDispose() { - derivedMaterial.removeEventListener('dispose', onDispose) - outlineMaterial.dispose() - }) - } - return [outlineMaterial, derivedMaterial] - } else { - return derivedMaterial - } - } - set material(baseMaterial) { - if (baseMaterial && baseMaterial.isTroikaTextMaterial) { - //prevent double-derivation - this._derivedMaterial = baseMaterial - this._baseMaterial = baseMaterial.baseMaterial - } else { - this._baseMaterial = baseMaterial - } - } - - get glyphGeometryDetail() { - return this.geometry.detail - } - set glyphGeometryDetail(detail) { - this.geometry.detail = detail - } - - get curveRadius() { - return this.geometry.curveRadius - } - set curveRadius(r) { - this.geometry.curveRadius = r - } - - // Create and update material for shadows upon request: - get customDepthMaterial() { - return first(this.material).getDepthMaterial() - } - get customDistanceMaterial() { - return first(this.material).getDistanceMaterial() - } - - _prepareForRender(material) { - const isOutline = material.isTextOutlineMaterial - const uniforms = material.uniforms - const textInfo = this.textRenderInfo - if (textInfo) { - const { sdfTexture, blockBounds } = textInfo - uniforms.uTroikaSDFTexture.value = sdfTexture - uniforms.uTroikaSDFTextureSize.value.set(sdfTexture.image.width, sdfTexture.image.height) - uniforms.uTroikaSDFGlyphSize.value = textInfo.sdfGlyphSize - uniforms.uTroikaSDFExponent.value = textInfo.sdfExponent - uniforms.uTroikaTotalBounds.value.fromArray(blockBounds) - uniforms.uTroikaUseGlyphColors.value = !isOutline && !!textInfo.glyphColors - - let distanceOffset = 0 - let blurRadius = 0 - let strokeWidth = 0 - let fillOpacity - let strokeOpacity - let strokeColor - let offsetX = 0 - let offsetY = 0 - - if (isOutline) { - const { outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity } = this - distanceOffset = this._parsePercent(outlineWidth) || 0 - blurRadius = Math.max(0, this._parsePercent(outlineBlur) || 0) - fillOpacity = outlineOpacity - offsetX = this._parsePercent(outlineOffsetX) || 0 - offsetY = this._parsePercent(outlineOffsetY) || 0 - } else { - strokeWidth = Math.max(0, this._parsePercent(this.strokeWidth) || 0) - if (strokeWidth) { - strokeColor = this.strokeColor - uniforms.uTroikaStrokeColor.value.set(strokeColor == null ? defaultStrokeColor : strokeColor) - strokeOpacity = this.strokeOpacity - if (strokeOpacity == null) strokeOpacity = 1 - } - fillOpacity = this.fillOpacity - } - - uniforms.uTroikaDistanceOffset.value = distanceOffset - uniforms.uTroikaPositionOffset.value.set(offsetX, offsetY) - uniforms.uTroikaBlurRadius.value = blurRadius - uniforms.uTroikaStrokeWidth.value = strokeWidth - uniforms.uTroikaStrokeOpacity.value = strokeOpacity - uniforms.uTroikaFillOpacity.value = fillOpacity == null ? 1 : fillOpacity - uniforms.uTroikaCurveRadius.value = this.curveRadius || 0 - - const clipRect = this.clipRect - if (clipRect && Array.isArray(clipRect) && clipRect.length === 4) { - uniforms.uTroikaClipRect.value.fromArray(clipRect) - } else { - // no clipping - choose a finite rect that shouldn't ever be reached by overflowing glyphs or outlines - const pad = (this.fontSize || 0.1) * 100 - uniforms.uTroikaClipRect.value.set( - blockBounds[0] - pad, - blockBounds[1] - pad, - blockBounds[2] + pad, - blockBounds[3] + pad - ) - } - this.geometry.applyClipRect(uniforms.uTroikaClipRect.value) - } - uniforms.uTroikaSDFDebug.value = !!this.debugSDF - material.polygonOffset = !!this.depthOffset - material.polygonOffsetFactor = material.polygonOffsetUnits = this.depthOffset || 0 - - // Shortcut for setting material color via `color` prop on the mesh; this is - // applied only to the derived material to avoid mutating a shared base material. - const color = isOutline ? this.outlineColor || 0 : this.color - - if (color == null) { - delete material.color //inherit from base - } else { - const colorObj = material.hasOwnProperty('color') ? material.color : (material.color = new Color()) - if (color !== colorObj._input || typeof color === 'object') { - colorObj.set((colorObj._input = color)) - } - } - - // base orientation - let orient = this.orientation || defaultOrient - if (orient !== material._orientation) { - const rotMat = uniforms.uTroikaOrient.value - orient = orient.replace(/[^-+xyz]/g, '') - const match = orient !== defaultOrient && orient.match(/^([-+])([xyz])([-+])([xyz])$/) - if (match) { - const [, hSign, hAxis, vSign, vAxis] = match - tempVec3a.set(0, 0, 0)[hAxis] = hSign === '-' ? 1 : -1 - tempVec3b.set(0, 0, 0)[vAxis] = vSign === '-' ? -1 : 1 - tempMat4.lookAt(origin, tempVec3a.cross(tempVec3b), tempVec3b) - rotMat.setFromMatrix4(tempMat4) - } else { - rotMat.identity() - } - material._orientation = orient - } - } - - _parsePercent(value) { - if (typeof value === 'string') { - const match = value.match(/^(-?[\d.]+)%$/) - const pct = match ? parseFloat(match[1]) : NaN - value = (isNaN(pct) ? 0 : pct / 100) * this.fontSize - } - return value - } - - /** - * Translate a point in local space to an x/y in the text plane. - */ - localPositionToTextCoords(position, target = new Vector2()) { - target.copy(position) //simple non-curved case is 1:1 - const r = this.curveRadius - if (r) { - //flatten the curve - target.x = Math.atan2(position.x, Math.abs(r) - Math.abs(position.z)) * Math.abs(r) - } - return target - } - - /** - * Translate a point in world space to an x/y in the text plane. - */ - worldPositionToTextCoords(position, target = new Vector2()) { - tempVec3a.copy(position) - return this.localPositionToTextCoords(this.worldToLocal(tempVec3a), target) - } - - /** - * @override Custom raycasting to test against the whole text block's max rectangular bounds - * TODO is there any reason to make this more granular, like within individual line or glyph rects? - */ - raycast(raycaster, intersects) { - const { textRenderInfo, curveRadius } = this - if (textRenderInfo) { - const bounds = textRenderInfo.blockBounds - const raycastMesh = curveRadius ? getCurvedRaycastMesh() : getFlatRaycastMesh() - const geom = raycastMesh.geometry - const { position, uv } = geom.attributes - for (let i = 0; i < uv.count; i++) { - let x = bounds[0] + uv.getX(i) * (bounds[2] - bounds[0]) - const y = bounds[1] + uv.getY(i) * (bounds[3] - bounds[1]) - let z = 0 - if (curveRadius) { - z = curveRadius - Math.cos(x / curveRadius) * curveRadius - x = Math.sin(x / curveRadius) * curveRadius - } - position.setXYZ(i, x, y, z) - } - geom.boundingSphere = this.geometry.boundingSphere - geom.boundingBox = this.geometry.boundingBox - raycastMesh.matrixWorld = this.matrixWorld - raycastMesh.material.side = this.material.side - tempArray.length = 0 - raycastMesh.raycast(raycaster, tempArray) - for (let i = 0; i < tempArray.length; i++) { - tempArray[i].object = this - intersects.push(tempArray[i]) - } - } - } - - copy(source) { - // Prevent copying the geometry reference so we don't end up sharing attributes between instances - const geom = this.geometry - super.copy(source) - this.geometry = geom - - COPYABLE_PROPS.forEach((prop) => { - this[prop] = source[prop] - }) - return this - } - - clone() { - return new this.constructor().copy(this) - } -} - -// Create setters for properties that affect text layout: -SYNCABLE_PROPS.forEach((prop) => { - const privateKey = '_private_' + prop - Object.defineProperty(Text.prototype, prop, { - get() { - return this[privateKey] - }, - set(value) { - if (value !== this[privateKey]) { - this[privateKey] = value - this._needsSync = true - } - }, - }) -}) - -export { Text } diff --git a/src/core/Text/troika-three-text/TextBuilder.js b/src/core/Text/troika-three-text/TextBuilder.js deleted file mode 100644 index 1bcd04e11..000000000 --- a/src/core/Text/troika-three-text/TextBuilder.js +++ /dev/null @@ -1,414 +0,0 @@ -import { Color, Texture, LinearFilter } from 'three' -import { defineWorkerModule } from 'troika-worker-utils' -import { createTypesetter } from './Typesetter.js' -import { generateSDF, warmUpSDFCanvas, resizeWebGLCanvasWithoutClearing } from './SDFGenerator.js' -import bidiFactory from 'bidi-js' -import fontParser from './FontParser.js' - -const CONFIG = { - defaultFontURL: 'https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxM.woff', //Roboto Regular - sdfGlyphSize: 64, - sdfMargin: 1 / 16, - sdfExponent: 9, - textureWidth: 2048, -} -const tempColor = /*#__PURE__*/ new Color() - -function now() { - return (self.performance || Date).now() -} - -/** - * 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> - * } - * } - */ -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 {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 - * three elements: the starting X, the ending X, and the bottom 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} 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} 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} 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 {object} args - * @param {getTextRenderInfo~callback} callback - */ -export function getTextRenderInfo(args, callback) { - args = Object.assign({}, args) - const totalStart = now() - - // Apply default font here to avoid a 'null' atlas, and convert relative - // URLs to absolute so they can be resolved in the worker - args.font = toAbsoluteURL(args.font || CONFIG.defaultFontURL) - - // Normalize text to a string - args.text = '' + args.text - - args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize - - // Normalize colors - if (args.colorRanges != null) { - const colors = {} - for (const 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 - let fontGlyphs = atlas.glyphsByFont.get(args.font) - if (!fontGlyphs) { - atlas.glyphsByFont.set(args.font, (fontGlyphs = new Map())) - } - - // Issue request to the typesetting engine in the worker - typesetInWorker(args).then((result) => { - const { glyphIds, glyphPositions, fontSize, unitsPerEm, timings } = result - const neededSDFs = [] - const glyphBounds = new Float32Array(glyphIds.length * 4) - const fontSizeMult = fontSize / unitsPerEm - let boundsIdx = 0 - let positionsIdx = 0 - const quadsStart = now() - glyphIds.forEach((glyphId, i) => { - let glyphInfo = fontGlyphs.get(glyphId) - - // If this is a glyphId not seen before, add it to the atlas - if (!glyphInfo) { - const { path, pathBounds } = result.glyphData[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, - ] - fontGlyphs.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++] - 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, - caretHeight: result.caretHeight, - 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. - */ -export function preloadFont({ font, characters, sdfGlyphSize }, callback) { - const text = Array.isArray(characters) ? characters.join('\n') : '' + characters - getTextRenderInfo({ font, sdfGlyphSize, text }, callback) -} - -// 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: [CONFIG, fontParser, createTypesetter, bidiFactory], - init(config, fontParser, createTypesetter, bidiFactory) { - const { defaultFontURL } = config - return createTypesetter(fontParser, bidiFactory(), { defaultFontURL }) - }, -}) - -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 = [result.glyphPositions.buffer, result.glyphIds.buffer] - if (result.caretPositions) { - transferables.push(result.caretPositions.buffer) - } - if (result.glyphColors) { - transferables.push(result.glyphColors.buffer) - } - return transferables - }, -}) diff --git a/src/core/Text/troika-three-text/TextDerivedMaterial.js b/src/core/Text/troika-three-text/TextDerivedMaterial.js deleted file mode 100644 index 330c429cb..000000000 --- a/src/core/Text/troika-three-text/TextDerivedMaterial.js +++ /dev/null @@ -1,284 +0,0 @@ -import { createDerivedMaterial, voidMainRegExp } from './DerivedMaterial' -import { Color, Vector2, Vector4, Matrix3 } from 'three' - -// language=GLSL -const VERTEX_DEFS = ` -uniform vec2 uTroikaSDFTextureSize; -uniform float uTroikaSDFGlyphSize; -uniform vec4 uTroikaTotalBounds; -uniform vec4 uTroikaClipRect; -uniform mat3 uTroikaOrient; -uniform bool uTroikaUseGlyphColors; -uniform float uTroikaDistanceOffset; -uniform float uTroikaBlurRadius; -uniform vec2 uTroikaPositionOffset; -uniform float uTroikaCurveRadius; -attribute vec4 aTroikaGlyphBounds; -attribute float aTroikaGlyphIndex; -attribute vec3 aTroikaGlyphColor; -varying vec2 vTroikaGlyphUV; -varying vec4 vTroikaTextureUVBounds; -varying float vTroikaTextureChannel; -varying vec3 vTroikaGlyphColor; -varying vec2 vTroikaGlyphDimensions; -` - -// language=GLSL prefix="void main() {" suffix="}" -const VERTEX_TRANSFORM = ` -vec4 bounds = aTroikaGlyphBounds; -bounds.xz += uTroikaPositionOffset.x; -bounds.yw -= uTroikaPositionOffset.y; - -vec4 outlineBounds = vec4( - bounds.xy - uTroikaDistanceOffset - uTroikaBlurRadius, - bounds.zw + uTroikaDistanceOffset + uTroikaBlurRadius -); -vec4 clippedBounds = vec4( - clamp(outlineBounds.xy, uTroikaClipRect.xy, uTroikaClipRect.zw), - clamp(outlineBounds.zw, uTroikaClipRect.xy, uTroikaClipRect.zw) -); - -vec2 clippedXY = (mix(clippedBounds.xy, clippedBounds.zw, position.xy) - bounds.xy) / (bounds.zw - bounds.xy); - -position.xy = mix(bounds.xy, bounds.zw, clippedXY); - -uv = (position.xy - uTroikaTotalBounds.xy) / (uTroikaTotalBounds.zw - uTroikaTotalBounds.xy); - -float rad = uTroikaCurveRadius; -if (rad != 0.0) { - float angle = position.x / rad; - position.xz = vec2(sin(angle) * rad, rad - cos(angle) * rad); - normal.xz = vec2(sin(angle), cos(angle)); -} - -position = uTroikaOrient * position; -normal = uTroikaOrient * normal; - -vTroikaGlyphUV = clippedXY.xy; -vTroikaGlyphDimensions = vec2(bounds[2] - bounds[0], bounds[3] - bounds[1]); - -${ - '' /* NOTE: it seems important to calculate the glyph's bounding texture UVs here in the - vertex shader, rather than in the fragment shader, as the latter gives strange artifacts - on some glyphs (those in the leftmost texture column) on some systems. The exact reason - isn't understood but doing this here, then mix()-ing in the fragment shader, seems to work. */ -} -float txCols = uTroikaSDFTextureSize.x / uTroikaSDFGlyphSize; -vec2 txUvPerSquare = uTroikaSDFGlyphSize / uTroikaSDFTextureSize; -vec2 txStartUV = txUvPerSquare * vec2( - mod(floor(aTroikaGlyphIndex / 4.0), txCols), - floor(floor(aTroikaGlyphIndex / 4.0) / txCols) -); -vTroikaTextureUVBounds = vec4(txStartUV, vec2(txStartUV) + txUvPerSquare); -vTroikaTextureChannel = mod(aTroikaGlyphIndex, 4.0); -` - -// language=GLSL -const FRAGMENT_DEFS = ` -uniform sampler2D uTroikaSDFTexture; -uniform vec2 uTroikaSDFTextureSize; -uniform float uTroikaSDFGlyphSize; -uniform float uTroikaSDFExponent; -uniform float uTroikaDistanceOffset; -uniform float uTroikaFillOpacity; -uniform float uTroikaOutlineOpacity; -uniform float uTroikaBlurRadius; -uniform vec3 uTroikaStrokeColor; -uniform float uTroikaStrokeWidth; -uniform float uTroikaStrokeOpacity; -uniform bool uTroikaSDFDebug; -varying vec2 vTroikaGlyphUV; -varying vec4 vTroikaTextureUVBounds; -varying float vTroikaTextureChannel; -varying vec2 vTroikaGlyphDimensions; - -float troikaSdfValueToSignedDistance(float alpha) { - // Inverse of exponential encoding in webgl-sdf-generator - ${ - '' /* TODO - there's some slight inaccuracy here when dealing with interpolated alpha values; those - are linearly interpolated where the encoding is exponential. Look into improving this by rounding - to nearest 2 whole texels, decoding those exponential values, and linearly interpolating the result. - */ - } - float maxDimension = max(vTroikaGlyphDimensions.x, vTroikaGlyphDimensions.y); - float absDist = (1.0 - pow(2.0 * (alpha > 0.5 ? 1.0 - alpha : alpha), 1.0 / uTroikaSDFExponent)) * maxDimension; - float signedDist = absDist * (alpha > 0.5 ? -1.0 : 1.0); - return signedDist; -} - -float troikaGlyphUvToSdfValue(vec2 glyphUV) { - vec2 textureUV = mix(vTroikaTextureUVBounds.xy, vTroikaTextureUVBounds.zw, glyphUV); - vec4 rgba = texture2D(uTroikaSDFTexture, textureUV); - float ch = floor(vTroikaTextureChannel + 0.5); //NOTE: can't use round() in WebGL1 - return ch == 0.0 ? rgba.r : ch == 1.0 ? rgba.g : ch == 2.0 ? rgba.b : rgba.a; -} - -float troikaGlyphUvToDistance(vec2 uv) { - return troikaSdfValueToSignedDistance(troikaGlyphUvToSdfValue(uv)); -} - -float troikaGetAADist() { - ${ - '' /* - When the standard derivatives extension is available, we choose an antialiasing alpha threshold based - on the potential change in the SDF's alpha from this fragment to its neighbor. This strategy maximizes - readability and edge crispness at all sizes and screen resolutions. - */ - } - #if defined(GL_OES_standard_derivatives) || __VERSION__ >= 300 - return length(fwidth(vTroikaGlyphUV * vTroikaGlyphDimensions)) * 0.5; - #else - return vTroikaGlyphDimensions.x / 64.0; - #endif -} - -float troikaGetFragDistValue() { - vec2 clampedGlyphUV = clamp(vTroikaGlyphUV, 0.5 / uTroikaSDFGlyphSize, 1.0 - 0.5 / uTroikaSDFGlyphSize); - float distance = troikaGlyphUvToDistance(clampedGlyphUV); - - // Extrapolate distance when outside bounds: - distance += clampedGlyphUV == vTroikaGlyphUV ? 0.0 : - length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions); - - ${ - '' /* - // TODO more refined extrapolated distance by adjusting for angle of gradient at edge... - // This has potential but currently gives very jagged extensions, maybe due to precision issues? - float uvStep = 1.0 / uTroikaSDFGlyphSize; - vec2 neighbor1UV = clampedGlyphUV + ( - vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * sign(0.5 - vTroikaGlyphUV.y)) : - vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * sign(0.5 - vTroikaGlyphUV.x), 0.0) : - vec2(0.0) - ); - vec2 neighbor2UV = clampedGlyphUV + ( - vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * -sign(0.5 - vTroikaGlyphUV.y)) : - vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * -sign(0.5 - vTroikaGlyphUV.x), 0.0) : - vec2(0.0) - ); - float neighbor1Distance = troikaGlyphUvToDistance(neighbor1UV); - float neighbor2Distance = troikaGlyphUvToDistance(neighbor2UV); - float distToUnclamped = length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions); - float distToNeighbor = length((clampedGlyphUV - neighbor1UV) * vTroikaGlyphDimensions); - float gradientAngle1 = min(asin(abs(neighbor1Distance - distance) / distToNeighbor), PI / 2.0); - float gradientAngle2 = min(asin(abs(neighbor2Distance - distance) / distToNeighbor), PI / 2.0); - distance += (cos(gradientAngle1) + cos(gradientAngle2)) / 2.0 * distToUnclamped; - */ - } - - return distance; -} - -float troikaGetEdgeAlpha(float distance, float distanceOffset, float aaDist) { - #if defined(IS_DEPTH_MATERIAL) || defined(IS_DISTANCE_MATERIAL) - float alpha = step(-distanceOffset, -distance); - #else - - float alpha = smoothstep( - distanceOffset + aaDist, - distanceOffset - aaDist, - distance - ); - #endif - - return alpha; -} -` - -// language=GLSL prefix="void main() {" suffix="}" -const FRAGMENT_TRANSFORM = ` -float aaDist = troikaGetAADist(); -float fragDistance = troikaGetFragDistValue(); -float edgeAlpha = uTroikaSDFDebug ? - troikaGlyphUvToSdfValue(vTroikaGlyphUV) : - troikaGetEdgeAlpha(fragDistance, uTroikaDistanceOffset, max(aaDist, uTroikaBlurRadius)); - -#if !defined(IS_DEPTH_MATERIAL) && !defined(IS_DISTANCE_MATERIAL) -vec4 fillRGBA = gl_FragColor; -fillRGBA.a *= uTroikaFillOpacity; -vec4 strokeRGBA = uTroikaStrokeWidth == 0.0 ? fillRGBA : vec4(uTroikaStrokeColor, uTroikaStrokeOpacity); -if (fillRGBA.a == 0.0) fillRGBA.rgb = strokeRGBA.rgb; -gl_FragColor = mix(fillRGBA, strokeRGBA, smoothstep( - -uTroikaStrokeWidth - aaDist, - -uTroikaStrokeWidth + aaDist, - fragDistance -)); -gl_FragColor.a *= edgeAlpha; -#endif - -if (edgeAlpha == 0.0) { - discard; -} -` - -/** - * Create a material for rendering text, derived from a baseMaterial - */ -export function createTextDerivedMaterial(baseMaterial) { - const textMaterial = createDerivedMaterial(baseMaterial, { - chained: true, - extensions: { - derivatives: true, - }, - uniforms: { - uTroikaSDFTexture: { value: null }, - uTroikaSDFTextureSize: { value: new Vector2() }, - uTroikaSDFGlyphSize: { value: 0 }, - uTroikaSDFExponent: { value: 0 }, - uTroikaTotalBounds: { value: new Vector4(0, 0, 0, 0) }, - uTroikaClipRect: { value: new Vector4(0, 0, 0, 0) }, - uTroikaDistanceOffset: { value: 0 }, - uTroikaOutlineOpacity: { value: 0 }, - uTroikaFillOpacity: { value: 1 }, - uTroikaPositionOffset: { value: new Vector2() }, - uTroikaCurveRadius: { value: 0 }, - uTroikaBlurRadius: { value: 0 }, - uTroikaStrokeWidth: { value: 0 }, - uTroikaStrokeColor: { value: new Color() }, - uTroikaStrokeOpacity: { value: 1 }, - uTroikaOrient: { value: new Matrix3() }, - uTroikaUseGlyphColors: { value: true }, - uTroikaSDFDebug: { value: false }, - }, - vertexDefs: VERTEX_DEFS, - vertexTransform: VERTEX_TRANSFORM, - fragmentDefs: FRAGMENT_DEFS, - fragmentColorTransform: FRAGMENT_TRANSFORM, - customRewriter({ vertexShader, fragmentShader }) { - const uDiffuseRE = /\buniform\s+vec3\s+diffuse\b/ - if (uDiffuseRE.test(fragmentShader)) { - // Replace all instances of `diffuse` with our varying - fragmentShader = fragmentShader - .replace(uDiffuseRE, 'varying vec3 vTroikaGlyphColor') - .replace(/\bdiffuse\b/g, 'vTroikaGlyphColor') - // Make sure the vertex shader declares the uniform so we can grab it as a fallback - if (!uDiffuseRE.test(vertexShader)) { - vertexShader = vertexShader.replace( - voidMainRegExp, - 'uniform vec3 diffuse;\n$&\nvTroikaGlyphColor = uTroikaUseGlyphColors ? aTroikaGlyphColor / 255.0 : diffuse;\n' - ) - } - } - return { vertexShader, fragmentShader } - }, - }) - - // Force transparency - TODO is this reasonable? - textMaterial.transparent = true - - Object.defineProperties(textMaterial, { - isTroikaTextMaterial: { value: true }, - - // WebGLShadowMap reverses the side of the shadow material by default, which fails - // for planes, so here we force the `shadowSide` to always match the main side. - shadowSide: { - get() { - return this.side - }, - set() { - //no-op - }, - }, - }) - - return textMaterial -} diff --git a/src/core/Text/troika-three-text/Typesetter.js b/src/core/Text/troika-three-text/Typesetter.js deleted file mode 100644 index dd028a8d3..000000000 --- a/src/core/Text/troika-three-text/Typesetter.js +++ /dev/null @@ -1,652 +0,0 @@ -/** - * Factory function that creates a self-contained environment for processing text typesetting requests. - * - * It is important that this function has no closure dependencies, so that it can be easily injected - * into the source for a Worker without requiring a build step or complex dependency loading. All its - * dependencies must be passed in at initialization. - * - * @param {function} fontParser - a function that accepts an ArrayBuffer of the font data and returns - * a standardized structure giving access to the font and its glyphs: - * { - * unitsPerEm: number, - * ascender: number, - * descender: number, - * capHeight: number, - * xHeight: number, - * lineGap: number, - * forEachGlyph(string, fontSize, letterSpacing, callback) { - * //invokes callback for each glyph to render, passing it an object: - * callback({ - * index: number, - * advanceWidth: number, - * xMin: number, - * yMin: number, - * xMax: number, - * yMax: number, - * path: string, - * pathCommandCount: number - * }) - * } - * } - * @param {object} bidi - the bidi.js implementation object - * @param {Object} config - * @return {Object} - */ -export function createTypesetter(fontParser, bidi, config) { - const { defaultFontURL } = config - - /** - * Holds parsed font objects by url - */ - const fonts = Object.create(null) - - const INF = Infinity - - // Set of Unicode Default_Ignorable_Code_Point characters, these will not produce visible glyphs - const DEFAULT_IGNORABLE_CHARS = - /[\u00AD\u034F\u061C\u115F-\u1160\u17B4-\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8]/ - - // Incomplete set of characters that allow line breaking after them - // In the future we may consider a full Unicode line breaking algorithm impl: https://www.unicode.org/reports/tr14 - const BREAK_AFTER_CHARS = /[\s\-\u007C\u00AD\u2010\u2012-\u2014\u2027\u2056\u2E17\u2E40]/ - - /** - * Load a given font url - */ - function doLoadFont(url, callback) { - function tryLoad() { - const onError = (err) => { - console.error(`Failure loading font ${url}${url === defaultFontURL ? '' : '; trying fallback'}`, err) - if (url !== defaultFontURL) { - url = defaultFontURL - tryLoad() - } - } - try { - const request = new XMLHttpRequest() - request.open('get', url, true) - request.responseType = 'arraybuffer' - request.onload = function () { - if (request.status >= 400) { - onError(new Error(request.statusText)) - } else if (request.status > 0) { - try { - const fontObj = fontParser(request.response) - callback(fontObj) - } catch (e) { - onError(e) - } - } - } - request.onerror = onError - request.send() - } catch (err) { - onError(err) - } - } - tryLoad() - } - - /** - * Load a given font url if needed, invoking a callback when it's loaded. If already - * loaded, the callback will be called synchronously. - */ - function loadFont(fontUrl, callback) { - if (!fontUrl) fontUrl = defaultFontURL - const font = fonts[fontUrl] - if (font) { - // if currently loading font, add to callbacks, otherwise execute immediately - if (font.pending) { - font.pending.push(callback) - } else { - callback(font) - } - } else { - fonts[fontUrl] = { pending: [callback] } - doLoadFont(fontUrl, (fontObj) => { - const callbacks = fonts[fontUrl].pending - fonts[fontUrl] = fontObj - callbacks.forEach((cb) => cb(fontObj)) - }) - } - } - - /** - * Main entry point. - * Process a text string with given font and formatting parameters, and return all info - * necessary to render all its glyphs. - */ - function typeset( - { - text = '', - font = defaultFontURL, - fontSize = 1, - letterSpacing = 0, - lineHeight = 'normal', - maxWidth = INF, - direction, - textAlign = 'left', - textIndent = 0, - whiteSpace = 'normal', - overflowWrap = 'normal', - anchorX = 0, - anchorY = 0, - includeCaretPositions = false, - chunkedBoundsSize = 8192, - colorRanges = null, - }, - callback, - metricsOnly = false - ) { - const mainStart = now() - const timings = { fontLoad: 0, typesetting: 0 } - - // Ensure newlines are normalized - if (text.indexOf('\r') > -1) { - console.info('Typesetter: got text with \\r chars; normalizing to \\n') - text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - } - - // Ensure we've got numbers not strings - fontSize = +fontSize - letterSpacing = +letterSpacing - maxWidth = +maxWidth - lineHeight = lineHeight || 'normal' - textIndent = +textIndent - - loadFont(font, (fontObj) => { - const hasMaxWidth = isFinite(maxWidth) - let glyphIds = null - let glyphPositions = null - let glyphData = null - let glyphColors = null - let caretPositions = null - let visibleBounds = null - let chunkedBounds = null - let maxLineWidth = 0 - let renderableGlyphCount = 0 - const canWrap = whiteSpace !== 'nowrap' - const { ascender, descender, unitsPerEm, lineGap, capHeight, xHeight } = fontObj - timings.fontLoad = now() - mainStart - const typesetStart = now() - - // Find conversion between native font units and fontSize units; this will already be done - // for the gx/gy values below but everything else we'll need to convert - const fontSizeMult = fontSize / unitsPerEm - - // Determine appropriate value for 'normal' line height based on the font's actual metrics - // TODO this does not guarantee individual glyphs won't exceed the line height, e.g. Roboto; should we use yMin/Max instead? - if (lineHeight === 'normal') { - lineHeight = (ascender - descender + lineGap) / unitsPerEm - } - - // Determine line height and leading adjustments - lineHeight = lineHeight * fontSize - const halfLeading = (lineHeight - (ascender - descender) * fontSizeMult) / 2 - const topBaseline = -(ascender * fontSizeMult + halfLeading) - const caretHeight = Math.min(lineHeight, (ascender - descender) * fontSizeMult) - const caretBottomOffset = ((ascender + descender) / 2) * fontSizeMult - caretHeight / 2 - - // Distribute glyphs into lines based on wrapping - let lineXOffset = textIndent - let currentLine = new TextLine() - const lines = [currentLine] - - fontObj.forEachGlyph(text, fontSize, letterSpacing, (glyphObj, glyphX, charIndex) => { - const char = text.charAt(charIndex) - const glyphWidth = glyphObj.advanceWidth * fontSizeMult - const curLineCount = currentLine.count - let nextLine - - // Calc isWhitespace and isEmpty once per glyphObj - if (!('isEmpty' in glyphObj)) { - glyphObj.isWhitespace = !!char && /\s/.test(char) - glyphObj.canBreakAfter = !!char && BREAK_AFTER_CHARS.test(char) - glyphObj.isEmpty = - glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax || DEFAULT_IGNORABLE_CHARS.test(char) - } - if (!glyphObj.isWhitespace && !glyphObj.isEmpty) { - renderableGlyphCount++ - } - - // If a non-whitespace character overflows the max width, we need to soft-wrap - if ( - canWrap && - hasMaxWidth && - !glyphObj.isWhitespace && - glyphX + glyphWidth + lineXOffset > maxWidth && - curLineCount - ) { - // If it's the first char after a whitespace, start a new line - if (currentLine.glyphAt(curLineCount - 1).glyphObj.canBreakAfter) { - nextLine = new TextLine() - lineXOffset = -glyphX - } else { - // Back up looking for a whitespace character to wrap at - for (let i = curLineCount; i--; ) { - // If we got the start of the line there's no soft break point; make hard break if overflowWrap='break-word' - if (i === 0 && overflowWrap === 'break-word') { - nextLine = new TextLine() - lineXOffset = -glyphX - break - } - // Found a soft break point; move all chars since it to a new line - else if (currentLine.glyphAt(i).glyphObj.canBreakAfter) { - nextLine = currentLine.splitAt(i + 1) - const adjustX = nextLine.glyphAt(0).x - lineXOffset -= adjustX - for (let j = nextLine.count; j--; ) { - nextLine.glyphAt(j).x -= adjustX - } - break - } - } - } - if (nextLine) { - currentLine.isSoftWrapped = true - currentLine = nextLine - lines.push(currentLine) - maxLineWidth = maxWidth //after soft wrapping use maxWidth as calculated width - } - } - - const fly = currentLine.glyphAt(currentLine.count) - fly.glyphObj = glyphObj - fly.x = glyphX + lineXOffset - fly.width = glyphWidth - fly.charIndex = charIndex - - // Handle hard line breaks - if (char === '\n') { - currentLine = new TextLine() - lines.push(currentLine) - lineXOffset = -(glyphX + glyphWidth + letterSpacing * fontSize) + textIndent - } - }) - - // Calculate width of each line (excluding trailing whitespace) and maximum block width - lines.forEach((line) => { - for (let i = line.count; i--; ) { - const { glyphObj, x, width } = line.glyphAt(i) - if (!glyphObj.isWhitespace) { - line.width = x + width - if (line.width > maxLineWidth) { - maxLineWidth = line.width - } - return - } - } - }) - - // Find overall position adjustments for anchoring - let anchorXOffset = 0 - let anchorYOffset = 0 - if (anchorX) { - if (typeof anchorX === 'number') { - anchorXOffset = -anchorX - } else if (typeof anchorX === 'string') { - anchorXOffset = - -maxLineWidth * - (anchorX === 'left' ? 0 : anchorX === 'center' ? 0.5 : anchorX === 'right' ? 1 : parsePercent(anchorX)) - } - } - if (anchorY) { - if (typeof anchorY === 'number') { - anchorYOffset = -anchorY - } else if (typeof anchorY === 'string') { - const height = lines.length * lineHeight - anchorYOffset = - anchorY === 'top' - ? 0 - : anchorY === 'top-baseline' - ? -topBaseline - : anchorY === 'top-cap' - ? -topBaseline - capHeight * fontSizeMult - : anchorY === 'top-ex' - ? -topBaseline - xHeight * fontSizeMult - : anchorY === 'middle' - ? height / 2 - : anchorY === 'bottom' - ? height - : anchorY === 'bottom-baseline' - ? height - halfLeading + descender * fontSizeMult - : parsePercent(anchorY) * height - } - } - - if (!metricsOnly) { - // Resolve bidi levels - const bidiLevelsResult = bidi.getEmbeddingLevels(text, direction) - - // Process each line, applying alignment offsets, adding each glyph to the atlas, and - // collecting all renderable glyphs into a single collection. - glyphIds = new Uint16Array(renderableGlyphCount) - glyphPositions = new Float32Array(renderableGlyphCount * 2) - glyphData = {} - visibleBounds = [INF, INF, -INF, -INF] - chunkedBounds = [] - let lineYOffset = topBaseline - if (includeCaretPositions) { - caretPositions = new Float32Array(text.length * 3) - } - if (colorRanges) { - glyphColors = new Uint8Array(renderableGlyphCount * 3) - } - let renderableGlyphIndex = 0 - let prevCharIndex = -1 - let colorCharIndex = -1 - let chunk - let currentColor - lines.forEach((line) => { - const { count: lineGlyphCount, width: lineWidth } = line - - // Ignore empty lines - if (lineGlyphCount > 0) { - // Count trailing whitespaces, we want to ignore these for certain things - let trailingWhitespaceCount = 0 - for (let i = lineGlyphCount; i-- && line.glyphAt(i).glyphObj.isWhitespace; ) { - trailingWhitespaceCount++ - } - - // Apply horizontal alignment adjustments - let lineXOffset = 0 - let justifyAdjust = 0 - if (textAlign === 'center') { - lineXOffset = (maxLineWidth - lineWidth) / 2 - } else if (textAlign === 'right') { - lineXOffset = maxLineWidth - lineWidth - } else if (textAlign === 'justify' && line.isSoftWrapped) { - // count non-trailing whitespace characters, and we'll adjust the offsets per character in the next loop - let whitespaceCount = 0 - for (let i = lineGlyphCount - trailingWhitespaceCount; i--; ) { - if (line.glyphAt(i).glyphObj.isWhitespace) { - whitespaceCount++ - } - } - justifyAdjust = (maxLineWidth - lineWidth) / whitespaceCount - } - if (justifyAdjust || lineXOffset) { - let justifyOffset = 0 - for (let i = 0; i < lineGlyphCount; i++) { - const glyphInfo = line.glyphAt(i) - const glyphObj = glyphInfo.glyphObj - glyphInfo.x += lineXOffset + justifyOffset - // Expand non-trailing whitespaces for justify alignment - if (justifyAdjust !== 0 && glyphObj.isWhitespace && i < lineGlyphCount - trailingWhitespaceCount) { - justifyOffset += justifyAdjust - glyphInfo.width += justifyAdjust - } - } - } - - // Perform bidi range flipping - const flips = bidi.getReorderSegments( - text, - bidiLevelsResult, - line.glyphAt(0).charIndex, - line.glyphAt(line.count - 1).charIndex - ) - for (let fi = 0; fi < flips.length; fi++) { - const [start, end] = flips[fi] - // Map start/end string indices to indices in the line - let left = Infinity, - right = -Infinity - for (let i = 0; i < lineGlyphCount; i++) { - if (line.glyphAt(i).charIndex >= start) { - // gte to handle removed characters - const startInLine = i - let endInLine = i - for (; endInLine < lineGlyphCount; endInLine++) { - const info = line.glyphAt(endInLine) - if (info.charIndex > end) { - break - } - if (endInLine < lineGlyphCount - trailingWhitespaceCount) { - //don't include trailing ws in flip width - left = Math.min(left, info.x) - right = Math.max(right, info.x + info.width) - } - } - for (let j = startInLine; j < endInLine; j++) { - const glyphInfo = line.glyphAt(j) - glyphInfo.x = right - (glyphInfo.x + glyphInfo.width - left) - } - break - } - } - } - - // Assemble final data arrays - let glyphObj - const setGlyphObj = (g) => (glyphObj = g) - for (let i = 0; i < lineGlyphCount; i++) { - const glyphInfo = line.glyphAt(i) - glyphObj = glyphInfo.glyphObj - const glyphId = glyphObj.index - - // Replace mirrored characters in rtl - const rtl = bidiLevelsResult.levels[glyphInfo.charIndex] & 1 //odd level means rtl - if (rtl) { - const mirrored = bidi.getMirroredCharacter(text[glyphInfo.charIndex]) - if (mirrored) { - fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj) - } - } - - // Add caret positions - if (includeCaretPositions) { - const { charIndex } = glyphInfo - const caretLeft = glyphInfo.x + anchorXOffset - const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset - caretPositions[charIndex * 3] = rtl ? caretRight : caretLeft //start edge x - caretPositions[charIndex * 3 + 1] = rtl ? caretLeft : caretRight //end edge x - caretPositions[charIndex * 3 + 2] = lineYOffset + caretBottomOffset + anchorYOffset //common bottom y - - // If we skipped any chars from the previous glyph (due to ligature subs), fill in caret - // positions for those missing char indices; currently this uses a best-guess by dividing - // the ligature's width evenly. In the future we may try to use the font's LigatureCaretList - // table to get better interior caret positions. - const ligCount = charIndex - prevCharIndex - if (ligCount > 1) { - fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount) - } - prevCharIndex = charIndex - } - - // Track current color range - if (colorRanges) { - const { charIndex } = glyphInfo - while (charIndex > colorCharIndex) { - colorCharIndex++ - if (colorRanges.hasOwnProperty(colorCharIndex)) { - currentColor = colorRanges[colorCharIndex] - } - } - } - - // Get atlas data for renderable glyphs - if (!glyphObj.isWhitespace && !glyphObj.isEmpty) { - const idx = renderableGlyphIndex++ - - // Add this glyph's path data - if (!glyphData[glyphId]) { - glyphData[glyphId] = { - path: glyphObj.path, - pathBounds: [glyphObj.xMin, glyphObj.yMin, glyphObj.xMax, glyphObj.yMax], - } - } - - // Determine final glyph position and add to glyphPositions array - const glyphX = glyphInfo.x + anchorXOffset - const glyphY = lineYOffset + anchorYOffset - glyphPositions[idx * 2] = glyphX - glyphPositions[idx * 2 + 1] = glyphY - - // Track total visible bounds - const visX0 = glyphX + glyphObj.xMin * fontSizeMult - const visY0 = glyphY + glyphObj.yMin * fontSizeMult - const visX1 = glyphX + glyphObj.xMax * fontSizeMult - const visY1 = glyphY + glyphObj.yMax * fontSizeMult - if (visX0 < visibleBounds[0]) visibleBounds[0] = visX0 - if (visY0 < visibleBounds[1]) visibleBounds[1] = visY0 - if (visX1 > visibleBounds[2]) visibleBounds[2] = visX1 - if (visY1 > visibleBounds[3]) visibleBounds[3] = visY1 - - // Track bounding rects for each chunk of N glyphs - if (idx % chunkedBoundsSize === 0) { - chunk = { start: idx, end: idx, rect: [INF, INF, -INF, -INF] } - chunkedBounds.push(chunk) - } - chunk.end++ - const chunkRect = chunk.rect - if (visX0 < chunkRect[0]) chunkRect[0] = visX0 - if (visY0 < chunkRect[1]) chunkRect[1] = visY0 - if (visX1 > chunkRect[2]) chunkRect[2] = visX1 - if (visY1 > chunkRect[3]) chunkRect[3] = visY1 - - // Add to glyph ids array - glyphIds[idx] = glyphId - - // Add colors - if (colorRanges) { - const start = idx * 3 - glyphColors[start] = (currentColor >> 16) & 255 - glyphColors[start + 1] = (currentColor >> 8) & 255 - glyphColors[start + 2] = currentColor & 255 - } - } - } - } - - // Increment y offset for next line - lineYOffset -= lineHeight - }) - - // Fill in remaining caret positions in case the final character was a ligature - if (caretPositions) { - const ligCount = text.length - prevCharIndex - if (ligCount > 1) { - fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount) - } - } - } - - // Timing stats - timings.typesetting = now() - typesetStart - - callback({ - glyphIds, //font indices for each glyph - glyphPositions, //x,y of each glyph's origin in layout - glyphData, //dict holding data about each glyph appearing in the text - caretPositions, //startX,endX,bottomY caret positions for each char - caretHeight, //height of cursor from bottom to top - glyphColors, //color for each glyph, if color ranges supplied - chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs - fontSize, //calculated em height - unitsPerEm, //font units per em - ascender: ascender * fontSizeMult, //font ascender - descender: descender * fontSizeMult, //font descender - capHeight: capHeight * fontSizeMult, //font cap-height - xHeight: xHeight * fontSizeMult, //font x-height - lineHeight, //computed line height - topBaseline, //y coordinate of the top line's baseline - blockBounds: [ - //bounds for the whole block of text, including vertical padding for lineHeight - anchorXOffset, - anchorYOffset - lines.length * lineHeight, - anchorXOffset + maxLineWidth, - anchorYOffset, - ], - visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds - timings, - }) - }) - } - - /** - * For a given text string and font parameters, determine the resulting block dimensions - * after wrapping for the given maxWidth. - * @param args - * @param callback - */ - function measure(args, callback) { - typeset( - args, - (result) => { - const [x0, y0, x1, y1] = result.blockBounds - callback({ - width: x1 - x0, - height: y1 - y0, - }) - }, - { metricsOnly: true } - ) - } - - function parsePercent(str) { - const match = str.match(/^([\d.]+)%$/) - const pct = match ? parseFloat(match[1]) : NaN - return isNaN(pct) ? 0 : pct / 100 - } - - function fillLigatureCaretPositions(caretPositions, ligStartIndex, ligCount) { - const ligStartX = caretPositions[ligStartIndex * 3] - const ligEndX = caretPositions[ligStartIndex * 3 + 1] - const ligY = caretPositions[ligStartIndex * 3 + 2] - const guessedAdvanceX = (ligEndX - ligStartX) / ligCount - for (let i = 0; i < ligCount; i++) { - const startIndex = (ligStartIndex + i) * 3 - caretPositions[startIndex] = ligStartX + guessedAdvanceX * i - caretPositions[startIndex + 1] = ligStartX + guessedAdvanceX * (i + 1) - caretPositions[startIndex + 2] = ligY - } - } - - function now() { - return (self.performance || Date).now() - } - - // Array-backed structure for a single line's glyphs data - function TextLine() { - this.data = [] - } - const textLineProps = ['glyphObj', 'x', 'width', 'charIndex'] - TextLine.prototype = { - width: 0, - isSoftWrapped: false, - get count() { - return Math.ceil(this.data.length / textLineProps.length) - }, - glyphAt(i) { - const fly = TextLine.flyweight - fly.data = this.data - fly.index = i - return fly - }, - splitAt(i) { - const newLine = new TextLine() - newLine.data = this.data.splice(i * textLineProps.length) - return newLine - }, - } - TextLine.flyweight = textLineProps.reduce( - (obj, prop, i) => { - Object.defineProperty(obj, prop, { - get() { - return this.data[this.index * textLineProps.length + i] - }, - set(val) { - this.data[this.index * textLineProps.length + i] = val - }, - }) - return obj - }, - { data: null, index: 0 } - ) - - return { - typeset, - measure, - loadFont, - } -} diff --git a/src/core/Text/troika-three-text/index.js b/src/core/Text/troika-three-text/index.js deleted file mode 100644 index dc728b0bc..000000000 --- a/src/core/Text/troika-three-text/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// troika-three-text 0.46.3 fca9aae20e7b67cbd7ac3669dd91257ec84f1997 -export { preloadFont } from './TextBuilder.js' -export { Text } from './Text.js' diff --git a/src/core/Text/troika-three-text/libs/typr.factory.js b/src/core/Text/troika-three-text/libs/typr.factory.js deleted file mode 100644 index 6f3c778bb..000000000 --- a/src/core/Text/troika-three-text/libs/typr.factory.js +++ /dev/null @@ -1,6 +0,0 @@ -/*! -Custom build of Typr.ts (https://github.com/fredli74/Typr.ts) for use in Troika text rendering. -Original MIT license applies: https://github.com/fredli74/Typr.ts/blob/master/LICENSE -*/ -// prettier-ignore -export default function(){return"undefined"==typeof window&&(self.window=self),function(r){"use strict";var e={parse:function(r){var t=e._bin,a=new Uint8Array(r);if("ttcf"==t.readASCII(a,0,4)){var n=4;t.readUshort(a,n),n+=2,t.readUshort(a,n),n+=2;var o=t.readUint(a,n);n+=4;for(var s=[],i=0;i>>t&1)&&e++;return e},e._lctf.readClassDef=function(r,t){var a=e._bin,n=[],o=a.readUshort(r,t);if(t+=2,1==o){var s=a.readUshort(r,t);t+=2;var i=a.readUshort(r,t);t+=2;for(var h=0;h0&&(o.featureParams=n+s);var i=a.readUshort(r,t);t+=2,o.tab=[];for(var h=0;h255?-1:e.CFF.glyphByUnicode(r,e.CFF.tableSE[t])},e.CFF.readEncoding=function(r,t,a){e._bin;var n=[".notdef"],o=r[t];if(t++,0!=o)throw"error: unknown encoding format: "+o;var s=r[t];t++;for(var i=0;i>4,p=15&v;if(15!=c&&u.push(c),15!=p&&u.push(p),15==p)break}for(var U="",g=[0,1,2,3,4,5,6,7,8,9,".","e","e-","reserved","-","endOfNumber"],S=0;S=s.xMax||s.yMin>=s.yMax)return null;if(s.noc>0){s.endPts=[];for(var i=0;i=1&&i.fmt<=2){f=o.readUshort(r,a);a+=2;var l=o.readUshort(r,a);a+=2;d=e._lctf.numOfOnes(f);var u=e._lctf.numOfOnes(l);if(1==i.fmt){i.pairsets=[];var v=o.readUshort(r,a);a+=2;for(var c=0;c=1&&i.fmt<=2){if(1==i.fmt)i.delta=o.readShort(r,a),a+=2;else if(2==i.fmt){var f=o.readUshort(r,a);a+=2,i.newg=o.readUshorts(r,a,f),a+=2*i.newg.length}}else if(4==t){i.vals=[];f=o.readUshort(r,a);a+=2;for(var d=0;d>>8;if(0!=(l&=15))throw"unknown kern table format: "+l;t=e.kern.readFormat0(r,t,h)}return h},e.kern.parseV1=function(r,t,a,n){var o=e._bin;o.readFixed(r,t),t+=4;var s=o.readUint(r,t);t+=4;for(var i={glyph1:[],rval:[]},h=0;h>>8;if(0!=(d&=15))throw"unknown kern table format: "+d;t=e.kern.readFormat0(r,t,i)}return i},e.kern.readFormat0=function(r,t,a){var n=e._bin,o=-1,s=n.readUshort(r,t);t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2,n.readUshort(r,t),t+=2;for(var i=0;i=n.map.length?0:n.map[e];if(4==n.format){for(var o=-1,s=0;se)return 0;return 65535&(0!=n.idRangeOffset[o]?n.glyphIdArray[e-n.startCount[o]+(n.idRangeOffset[o]>>1)-(n.idRangeOffset.length-o)]:e+n.idDelta[o])}if(12==n.format){if(e>n.groups[n.groups.length-1][1])return 0;for(s=0;s-1?e.U._simpleGlyph(n,a):e.U._compoGlyph(n,t,a))},e.U._simpleGlyph=function(r,t){for(var a=0;ao)){for(var v=!0,c=0,p=0;po)){for(v=!0,p=0;p>1,s.length=0,h=!0;else if("o3"==x||"o23"==x){s.length%2!=0&&!h&&(f=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0}else if("o4"==x)s.length>1&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),d&&e.U.P.closePath(o),v+=s.pop(),e.U.P.moveTo(o,u,v),d=!0;else if("o5"==x)for(;s.length>0;)u+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,u,v);else if("o6"==x||"o7"==x)for(var P=s.length,I="o6"==x,w=0;wMath.abs(y-v)?u=b+s.shift():v=y+s.shift(),e.U.P.curveTo(o,c,p,U,g,F,_),e.U.P.curveTo(o,S,m,b,y,u,v));else if("o14"==x){if(s.length>0&&!h&&(f=s.shift()+a.nominalWidthX,h=!0),4==s.length){var k=s.shift(),G=s.shift(),D=s.shift(),B=s.shift(),L=e.CFF.glyphBySE(a,D),R=e.CFF.glyphBySE(a,B);e.U._drawCFF(a.CharStrings[L],t,a,n,o),t.x=k,t.y=G,e.U._drawCFF(a.CharStrings[R],t,a,n,o)}d&&(e.U.P.closePath(o),d=!1)}else if("o19"==x||"o20"==x){s.length%2!=0&&!h&&(f=s.shift()+n.nominalWidthX),i+=s.length>>1,s.length=0,h=!0,l+=i+7>>3}else if("o21"==x)s.length>2&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),v+=s.pop(),u+=s.pop(),d&&e.U.P.closePath(o),e.U.P.moveTo(o,u,v),d=!0;else if("o22"==x)s.length>1&&!h&&(f=s.shift()+n.nominalWidthX,h=!0),u+=s.pop(),d&&e.U.P.closePath(o),e.U.P.moveTo(o,u,v),d=!0;else if("o25"==x){for(;s.length>6;)u+=s.shift(),v+=s.shift(),e.U.P.lineTo(o,u,v);c=u+s.shift(),p=v+s.shift(),U=c+s.shift(),g=p+s.shift(),u=U+s.shift(),v=g+s.shift(),e.U.P.curveTo(o,c,p,U,g,u,v)}else if("o26"==x)for(s.length%2&&(u+=s.shift());s.length>0;)c=u,p=v+s.shift(),u=U=c+s.shift(),v=(g=p+s.shift())+s.shift(),e.U.P.curveTo(o,c,p,U,g,u,v);else if("o27"==x)for(s.length%2&&(v+=s.shift());s.length>0;)p=v,U=(c=u+s.shift())+s.shift(),g=p+s.shift(),u=U+s.shift(),v=g,e.U.P.curveTo(o,c,p,U,g,u,v);else if("o10"==x||"o29"==x){var A="o10"==x?n:a;if(0==s.length)console.debug("error: empty stack");else{var W=s.pop(),M=A.Subrs[W+A.Bias];t.x=u,t.y=v,t.nStems=i,t.haveWidth=h,t.width=f,t.open=d,e.U._drawCFF(M,t,a,n,o),u=t.x,v=t.y,i=t.nStems,h=t.haveWidth,f=t.width,d=t.open}}else if("o30"==x||"o31"==x){var V=s.length,N=(T=0,"o31"==x);for(T+=V-(P=-3&V);T>>1|(21845&g)<<1;h=(61680&(h=(52428&h)>>>2|(13107&h)<<2))>>>4|(3855&h)<<4,c[g]=((65280&h)>>>8|(255&h)<<8)>>>1}var w=function(r,e,t){for(var a=r.length,i=0,o=new n(e);i>>v]=s}else for(f=new n(a),i=0;i>>15-r[i]);return f},d=new e(288);for(g=0;g<144;++g)d[g]=8;for(g=144;g<256;++g)d[g]=9;for(g=256;g<280;++g)d[g]=7;for(g=280;g<288;++g)d[g]=8;var m=new e(32);for(g=0;g<32;++g)m[g]=5;var b=w(d,9,1),p=w(m,5,1),y=function(r){for(var e=r[0],n=1;ne&&(e=r[n]);return e},L=function(r,e,n){var t=e/8|0;return(r[t]|r[t+1]<<8)>>(7&e)&n},U=function(r,e){var n=e/8|0;return(r[n]|r[n+1]<<8|r[n+2]<<16)>>(7&e)},k=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,e,n){var t=new Error(e||k[r]);if(t.code=r,Error.captureStackTrace&&Error.captureStackTrace(t,T),!n)throw t;return t},O=function(r,f,u){var s=r.length;if(!s||u&&!u.l&&s<5)return f||new e(0);var c=!f||u,g=!u||u.i;u||(u={}),f||(f=new e(3*s));var h,d=function(r){var n=f.length;if(r>n){var t=new e(Math.max(2*n,r));t.set(f),f=t}},m=u.f||0,k=u.p||0,O=u.b||0,A=u.l,x=u.d,E=u.m,D=u.n,M=8*s;do{if(!A){u.f=m=L(r,k,1);var S=L(r,k+1,3);if(k+=3,!S){var V=r[(I=((h=k)/8|0)+(7&h&&1)+4)-4]|r[I-3]<<8,_=I+V;if(_>s){g&&T(0);break}c&&d(O+V),f.set(r.subarray(I,_),O),u.b=O+=V,u.p=k=8*_;continue}if(1==S)A=b,x=p,E=9,D=5;else if(2==S){var j=L(r,k,31)+257,z=L(r,k+10,15)+4,C=j+L(r,k+5,31)+1;k+=14;for(var F=new e(C),P=new e(19),q=0;q>>4)<16)F[q++]=I;else{var K=0,N=0;for(16==I?(N=3+L(r,k,3),k+=2,K=F[q-1]):17==I?(N=3+L(r,k,7),k+=3):18==I&&(N=11+L(r,k,127),k+=7);N--;)F[q++]=K}}var Q=F.subarray(0,j),R=F.subarray(j);E=y(Q),D=y(R),A=w(Q,E,1),x=w(R,D,1)}else T(1);if(k>M){g&&T(0);break}}c&&d(O+131072);for(var W=(1<>>4;if((k+=15&K)>M){g&&T(0);break}if(K||T(2),Z<256)f[O++]=Z;else{if(256==Z){Y=k,A=null;break}var $=Z-254;if(Z>264){var rr=a[q=Z-257];$=L(r,k,(1<>>4;er||T(3),k+=15&er;R=l[nr];if(nr>3){rr=i[nr];R+=U(r,k)&(1<M){g&&T(0);break}c&&d(O+131072);for(var tr=O+$;Or.length)&&(i=r.length);var o=new(r instanceof n?n:r instanceof t?t:e)(i-a);return o.set(r.subarray(a,i)),o}(f,0,O)},A=new e(0);var x="undefined"!=typeof TextDecoder&&new TextDecoder;try{x.decode(A,{stream:!0}),1}catch(r){}return r.convert_streams=function(r){var e=new DataView(r),n=0;function t(){var r=e.getUint16(n);return n+=2,r}function a(){var r=e.getUint32(n);return n+=4,r}function i(r){m.setUint16(b,r),b+=2}function o(r){m.setUint32(b,r),b+=4}for(var f={signature:a(),flavor:a(),length:a(),numTables:t(),reserved:t(),totalSfntSize:a(),majorVersion:t(),minorVersion:t(),metaOffset:a(),metaLength:a(),metaOrigLength:a(),privOffset:a(),privLength:a()},u=0;Math.pow(2,u)<=f.numTables;)u++;u--;for(var v=16*Math.pow(2,u),s=16*f.numTables-v,l=12,c=[],g=0;g