492 lines
15 KiB
JavaScript
492 lines
15 KiB
JavaScript
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}
|
|
}
|
|
|