import { Text } from "./Text.js"; import { DataTexture, FloatType, RGBAFormat, Vector2, Box3, Color, DynamicDrawUsage } from "three"; import { glyphBoundsAttrName, glyphIndexAttrName } from "./GlyphsGeometry"; import { createDerivedMaterial } from "troika-three-utils"; import { createTextDerivedMaterial } from "./TextDerivedMaterial"; const syncStartEvent = { type: "syncstart" }; const syncCompleteEvent = { type: "synccomplete" }; const memberIndexAttrName = "aTroikaTextBatchMemberIndex"; /* Data texture packing strategy: # Common: 0-15: matrix 16-19: uTroikaTotalBounds 20-23: uTroikaClipRect 24: diffuse (color/outlineColor) 25: uTroikaFillOpacity (fillOpacity/outlineOpacity) 26: uTroikaCurveRadius 27: # Main: 28: uTroikaStrokeWidth 29: uTroikaStrokeColor 30: uTroikaStrokeOpacity # Outline: 28-29: uTroikaPositionOffset 30: uTroikaEdgeOffset 31: uTroikaBlurRadius */ const floatsPerMember = 32; const tempBox3 = new Box3(); const tempColor = new Color(); /** * @experimental * * A specialized `Text` implementation that accepts any number of `Text` children * and automatically batches them together to render in a single draw call. * * The `material` of each child `Text` will be ignored, and the `material` of the * `BatchedText` will be used for all of them instead. * * NOTE: This only works in WebGL2 or where the OES_texture_float extension is available. */ export class BatchedText extends Text { constructor () { super(); /** * @typedef {Object} PackingInfo * @property {number} index - the packing order index when last packed, or -1 * @property {boolean} dirty - whether it has synced since last pack */ /** * @type {Map} */ this._members = new Map(); this._dataTextures = {} this._onMemberSynced = (e) => { this._members.get(e.target).dirty = true; }; } /** * @override * Batch any Text objects added as children */ add (...objects) { for (let i = 0; i < objects.length; i++) { if (objects[i] instanceof Text) { this.addText(objects[i]); } else { super.add(objects[i]); } } return this; } /** * @override */ remove (...objects) { for (let i = 0; i < objects.length; i++) { if (objects[i] instanceof Text) { this.removeText(objects[i]); } else { super.remove(objects[i]); } } return this; } /** * @param {Text} text */ addText (text) { if (!this._members.has(text)) { this._members.set(text, { index: -1, glyphCount: -1, dirty: true }); text.addEventListener("synccomplete", this._onMemberSynced); } } /** * @param {Text} text */ removeText (text) { this._needsRepack = true text.removeEventListener("synccomplete", this._onMemberSynced); this._members.delete(text); } /** * Use the custom derivation with extra batching logic */ createDerivedMaterial (baseMaterial) { return createBatchedTextMaterial(baseMaterial); } updateMatrixWorld (force) { super.updateMatrixWorld(force); this.updateBounds(); } /** * Update the batched geometry bounds to hold all members */ updateBounds () { // Update member local matrices and the overall bounds const bbox = this.geometry.boundingBox.makeEmpty(); this._members.forEach((_, text) => { if (text.matrixAutoUpdate) text.updateMatrix(); // ignore world matrix tempBox3.copy(text.geometry.boundingBox).applyMatrix4(text.matrix); bbox.union(tempBox3); }); bbox.getBoundingSphere(this.geometry.boundingSphere); } /** @override */ hasOutline() { // Iterator.some() not supported in Safari for (let member of this._members.keys()) { if (member.hasOutline()) return true; } return false; } /** * @override * Copy member matrices and uniform values into the data texture */ _prepareForRender (material) { const isOutline = material.isTextOutlineMaterial material.uniforms.uTroikaIsOutline.value = isOutline // Resize the texture to fit in powers of 2 let texture = this._dataTextures[isOutline ? 'outline' : 'main']; const dataLength = Math.pow(2, Math.ceil(Math.log2(this._members.size * floatsPerMember))); if (!texture || dataLength !== texture.image.data.length) { // console.log(`resizing: ${dataLength}`); if (texture) texture.dispose(); const width = Math.min(dataLength / 4, 1024); texture = this._dataTextures[isOutline ? 'outline' : 'main'] = new DataTexture( new Float32Array(dataLength), width, dataLength / 4 / width, RGBAFormat, FloatType ); } const texData = texture.image.data; const setTexData = (index, value) => { if (value !== texData[index]) { texData[index] = value; texture.needsUpdate = true; } } this._members.forEach(({ index, dirty }, text) => { if (index > -1) { const startIndex = index * floatsPerMember // Matrix const matrix = text.matrix.elements; for (let i = 0; i < 16; i++) { setTexData(startIndex + i, matrix[i]) } // Let the member populate the uniforms, since that does all the appropriate // logic and handling of defaults, and we'll just grab the results from there text._prepareForRender(material) const { uTroikaTotalBounds, uTroikaClipRect, uTroikaPositionOffset, uTroikaEdgeOffset, uTroikaBlurRadius, uTroikaStrokeWidth, uTroikaStrokeColor, uTroikaStrokeOpacity, uTroikaFillOpacity, uTroikaCurveRadius, } = material.uniforms; // Total bounds for uv for (let i = 0; i < 4; i++) { setTexData(startIndex + 16 + i, uTroikaTotalBounds.value.getComponent(i)); } // Clip rect for (let i = 0; i < 4; i++) { setTexData(startIndex + 20 + i, uTroikaClipRect.value.getComponent(i)); } // Color let color = isOutline ? (text.outlineColor || 0) : text.color; if (color == null) color = this.color; if (color == null) color = this.material.color; if (color == null) color = 0xffffff; setTexData(startIndex + 24, tempColor.set(color).getHex()); // Fill opacity / outline opacity setTexData(startIndex + 25, uTroikaFillOpacity.value) // Curve radius setTexData(startIndex + 26, uTroikaCurveRadius.value) if (isOutline) { // Outline properties setTexData(startIndex + 28, uTroikaPositionOffset.value.x); setTexData(startIndex + 29, uTroikaPositionOffset.value.y); setTexData(startIndex + 30, uTroikaEdgeOffset.value); setTexData(startIndex + 31, uTroikaBlurRadius.value); } else { // Stroke properties setTexData(startIndex + 28, uTroikaStrokeWidth.value); setTexData(startIndex + 29, tempColor.set(uTroikaStrokeColor.value).getHex()); setTexData(startIndex + 30, uTroikaStrokeOpacity.value); } } }); material.setMatrixTexture(texture); // For the non-member-specific uniforms: super._prepareForRender(material); } sync (callback) { // TODO: skip members updating their geometries, just use textRenderInfo directly // Trigger sync on all members that need it let syncPromises = this._needsRepack ? [] : null; this._needsRepack = false; this._members.forEach((packingInfo, text) => { if (packingInfo.dirty || text._needsSync) { packingInfo.dirty = false; (syncPromises || (syncPromises = [])).push(new Promise(resolve => { if (text._needsSync) { text.sync(resolve); } else { resolve(); } })); } }); // If any needed syncing, wait for them and then repack the batched geometry if (syncPromises) { this.dispatchEvent(syncStartEvent); Promise.all(syncPromises).then(() => { const { geometry } = this; const batchedAttributes = geometry.attributes; let memberIndexes = batchedAttributes[memberIndexAttrName] && batchedAttributes[memberIndexAttrName].array || new Uint16Array(0); let batchedGlyphIndexes = batchedAttributes[glyphIndexAttrName] && batchedAttributes[glyphIndexAttrName].array || new Float32Array(0); let batchedGlyphBounds = batchedAttributes[glyphBoundsAttrName] && batchedAttributes[glyphBoundsAttrName].array || new Float32Array(0); // Initial pass to collect total glyph count and resize the arrays if needed let totalGlyphCount = 0; this._members.forEach((packingInfo, { textRenderInfo }) => { if (textRenderInfo) { totalGlyphCount += textRenderInfo.glyphAtlasIndices.length; this._textRenderInfo = textRenderInfo; // TODO - need this, but be smarter } }); if (totalGlyphCount !== memberIndexes.length) { memberIndexes = cloneAndResize(memberIndexes, totalGlyphCount); batchedGlyphIndexes = cloneAndResize(batchedGlyphIndexes, totalGlyphCount); batchedGlyphBounds = cloneAndResize(batchedGlyphBounds, totalGlyphCount * 4); } // Populate batch arrays let memberIndex = 0; let glyphIndex = 0; this._members.forEach((packingInfo, { textRenderInfo }) => { if (textRenderInfo) { const glyphCount = textRenderInfo.glyphAtlasIndices.length; memberIndexes.fill(memberIndex, glyphIndex, glyphIndex + glyphCount); // TODO can skip these for members that are not dirty or shifting overall position: batchedGlyphIndexes.set(textRenderInfo.glyphAtlasIndices, glyphIndex, glyphIndex + glyphCount); batchedGlyphBounds.set(textRenderInfo.glyphBounds, glyphIndex * 4, (glyphIndex + glyphCount) * 4); glyphIndex += glyphCount; packingInfo.index = memberIndex++; } }); // Update the geometry attributes geometry.updateAttributeData(memberIndexAttrName, memberIndexes, 1); geometry.getAttribute(memberIndexAttrName).setUsage(DynamicDrawUsage); geometry.updateAttributeData(glyphIndexAttrName, batchedGlyphIndexes, 1); geometry.updateAttributeData(glyphBoundsAttrName, batchedGlyphBounds, 4); this.updateBounds(); this.dispatchEvent(syncCompleteEvent); if (callback) { callback(); } }); } } copy (source) { if (source instanceof BatchedText) { super.copy(source); this._members.forEach((_, text) => this.removeText(text)); source._members.forEach((_, text) => this.addText(text)); } return this; } dispose () { super.dispose(); Object.values(this._dataTextures).forEach(tex => tex.dispose()) } } function cloneAndResize (source, newLength) { const copy = new source.constructor(newLength); copy.set(source.subarray(0, newLength)); return copy; } function createBatchedTextMaterial (baseMaterial) { const texUniformName = "uTroikaMatricesTexture"; const texSizeUniformName = "uTroikaMatricesTextureSize"; // Due to how vertexTransform gets injected, the matrix transforms must happen // in the base material of TextDerivedMaterial, but other transforms to its // shader must come after, so we sandwich it between two derivations. // Transform the vertex position let batchMaterial = createDerivedMaterial(baseMaterial, { chained: true, uniforms: { [texSizeUniformName]: { value: new Vector2() }, [texUniformName]: { value: null } }, // language=GLSL vertexDefs: ` uniform highp sampler2D ${texUniformName}; uniform vec2 ${texSizeUniformName}; attribute float ${memberIndexAttrName}; vec4 troikaBatchTexel(float offset) { offset += ${memberIndexAttrName} * ${floatsPerMember.toFixed(1)} / 4.0; float w = ${texSizeUniformName}.x; vec2 uv = (vec2(mod(offset, w), floor(offset / w)) + 0.5) / ${texSizeUniformName}; return texture2D(${texUniformName}, uv); } `, // language=GLSL prefix="void main() {" suffix="}" vertexTransform: ` mat4 matrix = mat4( troikaBatchTexel(0.0), troikaBatchTexel(1.0), troikaBatchTexel(2.0), troikaBatchTexel(3.0) ); position.xyz = (matrix * vec4(position, 1.0)).xyz; `, }); // Add the text shaders batchMaterial = createTextDerivedMaterial(batchMaterial); // Now make other changes to the derived text shader code batchMaterial = createDerivedMaterial(batchMaterial, { chained: true, uniforms: { uTroikaIsOutline: {value: false}, }, customRewriter(shaders) { // Convert some text shader uniforms to varyings const varyingUniforms = [ 'uTroikaTotalBounds', 'uTroikaClipRect', 'uTroikaPositionOffset', 'uTroikaEdgeOffset', 'uTroikaBlurRadius', 'uTroikaStrokeWidth', 'uTroikaStrokeColor', 'uTroikaStrokeOpacity', 'uTroikaFillOpacity', 'uTroikaCurveRadius', 'diffuse' ] varyingUniforms.forEach(uniformName => { shaders = uniformToVarying(shaders, uniformName) }) return shaders }, // language=GLSL vertexDefs: ` uniform bool uTroikaIsOutline; vec3 troikaFloatToColor(float v) { return mod(floor(vec3(v / 65536.0, v / 256.0, v)), 256.0) / 256.0; } `, // language=GLSL prefix="void main() {" suffix="}" vertexTransform: ` uTroikaTotalBounds = troikaBatchTexel(4.0); uTroikaClipRect = troikaBatchTexel(5.0); vec4 data = troikaBatchTexel(6.0); diffuse = troikaFloatToColor(data.x); uTroikaFillOpacity = data.y; uTroikaCurveRadius = data.z; data = troikaBatchTexel(7.0); if (uTroikaIsOutline) { if (data == vec4(0.0)) { // degenerate if zero outline position = vec3(0.0); } else { uTroikaPositionOffset = data.xy; uTroikaEdgeOffset = data.z; uTroikaBlurRadius = data.w; } } else { uTroikaStrokeWidth = data.x; uTroikaStrokeColor = troikaFloatToColor(data.y); uTroikaStrokeOpacity = data.z; } `, }); batchMaterial.setMatrixTexture = (texture) => { batchMaterial.uniforms[texUniformName].value = texture; batchMaterial.uniforms[texSizeUniformName].value.set(texture.image.width, texture.image.height); }; return batchMaterial; } /** * Turn a uniform into a varying/writeable value. * - If the uniform was used in the fragment shader, it will become a varying in both shaders. * - If the uniform was only used in the vertex shader, it will become a writeable var. */ export function uniformToVarying({vertexShader, fragmentShader}, uniformName, varyingName = uniformName) { const uniformRE = new RegExp(`uniform\\s+(bool|float|vec[234]|mat[34])\\s+${uniformName}\\b`) let type let hadFragmentUniform = false fragmentShader = fragmentShader.replace(uniformRE, ($0, $1) => { hadFragmentUniform = true return `varying ${type = $1} ${varyingName}` }) let hadVertexUniform = false vertexShader = vertexShader.replace(uniformRE, (_, $1) => { hadVertexUniform = true return `${hadFragmentUniform ? 'varying' : ''} ${type = $1} ${varyingName}` }) if (!hadVertexUniform) { vertexShader = `${hadFragmentUniform ? 'varying' : ''} ${type} ${varyingName};\n${vertexShader}` } return {vertexShader, fragmentShader} }