Buckets:
| 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: <blank> | |
| # 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<Text, PackingInfo>} | |
| */ | |
| 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} | |
| } | |
Xet Storage Details
- Size:
- 15.8 kB
- Xet hash:
- b876cf69f6ce486ab197fe86dc67ffa734be53b2f9b192ded3b40e0771a1d933
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.