219 lines
7.6 KiB
JavaScript
219 lines
7.6 KiB
JavaScript
import {
|
|
PlaneGeometry,
|
|
InstancedBufferGeometry,
|
|
InstancedBufferAttribute,
|
|
Sphere,
|
|
Box3,
|
|
} from 'three'
|
|
|
|
const templateGeometries = {}
|
|
|
|
function getTemplateGeometry(detail) {
|
|
let geom = templateGeometries[detail]
|
|
if (!geom) {
|
|
geom = templateGeometries[detail] = new PlaneGeometry(1, 1, detail, detail).translate(0.5, 0.5, 0)
|
|
}
|
|
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.
|
|
}
|
|
|
|
set detail(detail) {
|
|
if (detail !== this._detail) {
|
|
this._detail = detail
|
|
if (typeof detail !== 'number' || detail < 1) {
|
|
detail = 1
|
|
}
|
|
let 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
|
|
this.updateAttributeData(glyphBoundsAttrName, glyphBounds, 4)
|
|
this.updateAttributeData(glyphIndexAttrName, glyphAtlasIndices, 1)
|
|
this.updateAttributeData(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
|
|
let chunks = this._chunkedBounds
|
|
if (chunks) {
|
|
for (let i = chunks.length; i--;) {
|
|
count = chunks[i].end
|
|
let 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
|
|
}
|
|
|
|
/**
|
|
* Utility for updating instance attributes with automatic resizing
|
|
*/
|
|
updateAttributeData(attrName, newArray, itemSize) {
|
|
const attr = this.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 {
|
|
this.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 this._maxInstanceCount //for r117+, could be fragile
|
|
this.dispose() //for r118+, more robust feeling, but more heavy-handed than I'd like
|
|
}
|
|
} else if (attr) {
|
|
this.deleteAttribute(attrName)
|
|
}
|
|
}
|
|
}
|
|
|
|
export {
|
|
GlyphsGeometry,
|
|
glyphBoundsAttrName,
|
|
glyphColorAttrName,
|
|
glyphIndexAttrName,
|
|
}
|