Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@ Render text in 3D using Signed Distance Fields

![demo of three-text](https://user-images.githubusercontent.com/14237/185552879-3301a9d0-d749-4b48-8fcd-ddfd8d0871f3.gif)

## Usage examples
## Usage

View live example [in Storybook](https://foxglove-three-text-storybook.pages.dev/?path=/story/labelpool--basic). View example usage at [LabelPool.stories.tsx](./src/LabelPool.stories.tsx).

To run Storybook locally, run `yarn storybook`.

- For WebGLRenderer: `import { LabelPool } from "@foxglove/three-text";`
- For WebGPURenderer: `import { LabelPool } from "@foxglove/three-text/webgpu";`

```ts
const labelPool = new LabelPool();
const label = labelPool.acquire();
label.setText("hello");
label.setColor(r, g, b, a);
label.setBackgroundColor(r, g, b, a);
label.setBillboard(true);
label.setSizeAttenutation(true); // sizeAttenuation=false requires billboard=true
label.setAnchorPoint(x, y);
label.setLineHeight(0.5);

// cleanup...
labelPool.release(label);
```
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"type": "module",
"exports": {
".": "./dist/index.js"
".": "./dist/webgl/index.js",
"./webgpu": "./dist/webgpu/index.js"
},
"files": [
"dist",
Expand Down
13 changes: 13 additions & 0 deletions src/ILabelMaterial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type * as THREE from "three";

export interface ILabelMaterial extends THREE.Material {
readonly picking: boolean;
setTextureSize(width: number, height: number): void;
setLabelSize(width: number, height: number): void;
setColor(r: number, g: number, b: number, a: number): void;
setBackgroundColor(r: number, g: number, b: number, a: number): void;
setBillboard(billboard: boolean): void;
setSizeAttenuation(sizeAttenuation: boolean): void;
setAnchorPoint(x: number, y: number): void;
setScale(scale: number): void;
}
240 changes: 129 additions & 111 deletions src/Label.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as THREE from "three";

import { LabelMaterial } from "./LabelMaterial.ts";
import { LabelPool } from "./LabelPool.ts";

const tempVec2 = new THREE.Vector2();
import type { ILabelMaterial } from "./ILabelMaterial.ts";
import type { LabelPoolBase } from "./LabelPoolBase.ts";

/**
* Since THREE.js r151, InstancedMesh supports bounding sphere calculations using instanceMatrix.
Expand All @@ -21,67 +19,84 @@ class InstancedMeshWithBasicBoundingSphere extends THREE.InstancedMesh {
}
}

export class Label extends THREE.Object3D {
text = "";
export abstract class Label extends THREE.Object3D {
#text = "";
mesh: THREE.InstancedMesh;
geometry: THREE.InstancedBufferGeometry;
material: LabelMaterial;
pickingMaterial: LabelMaterial;

instanceAttrData: Float32Array;
instanceAttrBuffer: THREE.InstancedInterleavedBuffer;

instanceBoxPosition: THREE.InterleavedBufferAttribute;
instanceCharPosition: THREE.InterleavedBufferAttribute;
instanceUv: THREE.InterleavedBufferAttribute;
instanceBoxSize: THREE.InterleavedBufferAttribute;
instanceCharSize: THREE.InterleavedBufferAttribute;

lineHeight = 1;

public labelPool: LabelPool;

constructor(labelPool: LabelPool) {
material: ILabelMaterial;
pickingMaterial: ILabelMaterial;

#instanceAttrData: Float32Array;
#instanceAttrBuffer: THREE.InstancedInterleavedBuffer;

#instanceBoxPosition: THREE.InterleavedBufferAttribute;
#instanceCharPosition: THREE.InterleavedBufferAttribute;
#instanceUv: THREE.InterleavedBufferAttribute;
#instanceBoxSize: THREE.InterleavedBufferAttribute;
#instanceCharSize: THREE.InterleavedBufferAttribute;

#lineHeight = 1;

protected abstract createMaterial(atlasTexture: THREE.DataTexture): ILabelMaterial;
protected abstract createPickingMaterial(): ILabelMaterial;

public labelPool: LabelPoolBase;

static QUAD_POINTS: THREE.Vector3Tuple[] = [
[0, 0, 0],
[0, 1, 0],
[1, 0, 0],
[1, 0, 0],
[0, 1, 0],
[1, 1, 0],
];
static QUAD_POSITIONS = new THREE.BufferAttribute(new Float32Array(this.QUAD_POINTS.flat()), 3);
static QUAD_UVS = new THREE.BufferAttribute(
new Float32Array(this.QUAD_POINTS.flatMap(([x, y]) => [x, 1 - y])),
2,
);

constructor(labelPool: LabelPoolBase) {
super();
this.labelPool = labelPool;

this.geometry = new THREE.InstancedBufferGeometry();

this.geometry.setAttribute("position", LabelPool.QUAD_POSITIONS);
this.geometry.setAttribute("uv", LabelPool.QUAD_UVS);

this.instanceAttrData = new Float32Array();
this.instanceAttrBuffer = new THREE.InstancedInterleavedBuffer(this.instanceAttrData, 10, 1);
this.instanceBoxPosition = new THREE.InterleavedBufferAttribute(this.instanceAttrBuffer, 2, 0);
this.instanceCharPosition = new THREE.InterleavedBufferAttribute(this.instanceAttrBuffer, 2, 2);
this.instanceUv = new THREE.InterleavedBufferAttribute(this.instanceAttrBuffer, 2, 4);
this.instanceBoxSize = new THREE.InterleavedBufferAttribute(this.instanceAttrBuffer, 2, 6);
this.instanceCharSize = new THREE.InterleavedBufferAttribute(this.instanceAttrBuffer, 2, 8);
this.geometry.setAttribute("instanceBoxPosition", this.instanceBoxPosition);
this.geometry.setAttribute("instanceCharPosition", this.instanceCharPosition);
this.geometry.setAttribute("instanceUv", this.instanceUv);
this.geometry.setAttribute("instanceBoxSize", this.instanceBoxSize);
this.geometry.setAttribute("instanceCharSize", this.instanceCharSize);

this.material = new LabelMaterial({ atlasTexture: labelPool.atlasTexture });
this.pickingMaterial = new LabelMaterial({ picking: true });

this.mesh = new InstancedMeshWithBasicBoundingSphere(this.geometry, this.material, 0);
this.geometry.setAttribute("position", Label.QUAD_POSITIONS);
this.geometry.setAttribute("uv", Label.QUAD_UVS);

this.#instanceAttrData = new Float32Array();
this.#instanceAttrBuffer = new THREE.InstancedInterleavedBuffer(this.#instanceAttrData, 10, 1);
this.#instanceBoxPosition = new THREE.InterleavedBufferAttribute(
this.#instanceAttrBuffer,
2,
0,
);
this.#instanceCharPosition = new THREE.InterleavedBufferAttribute(
this.#instanceAttrBuffer,
2,
2,
);
this.#instanceUv = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 4);
this.#instanceBoxSize = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 6);
this.#instanceCharSize = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 8);
this.geometry.setAttribute("instanceBoxPosition", this.#instanceBoxPosition);
this.geometry.setAttribute("instanceCharPosition", this.#instanceCharPosition);
this.geometry.setAttribute("instanceUv", this.#instanceUv);
this.geometry.setAttribute("instanceBoxSize", this.#instanceBoxSize);
this.geometry.setAttribute("instanceCharSize", this.#instanceCharSize);

this.material = this.createMaterial(labelPool.atlasTexture);
this.pickingMaterial = this.createPickingMaterial();

this.mesh = new InstancedMeshWithBasicBoundingSphere(this.geometry, this.material, 1);
this.mesh.userData.pickingMaterial = this.pickingMaterial;

this.mesh.onBeforeRender = (renderer, _scene, _camera, _geometry, _material, _group) => {
renderer.getSize(tempVec2);
this.material.uniforms.uCanvasSize!.value[0] = tempVec2.x;
this.material.uniforms.uCanvasSize!.value[1] = tempVec2.y;
this.pickingMaterial.uniforms.uCanvasSize!.value[0] = tempVec2.x;
this.pickingMaterial.uniforms.uCanvasSize!.value[1] = tempVec2.y;
};

this.add(this.mesh);

labelPool.addEventListener("scaleFactorChange", () => {
// Trigger recalculation of scale uniform
this.setLineHeight(this.lineHeight);
this.setLineHeight(this.#lineHeight);
});

labelPool.addEventListener("atlasChange", () => {
Expand All @@ -91,9 +106,15 @@ export class Label extends THREE.Object3D {
}

private _handleAtlasChange() {
this.material.uniforms.uTextureSize!.value[0] = this.labelPool.atlasTexture.image.width;
this.material.uniforms.uTextureSize!.value[1] = this.labelPool.atlasTexture.image.height;
this.setLineHeight(this.lineHeight);
this.material.setTextureSize(
this.labelPool.atlasTexture.image.width,
this.labelPool.atlasTexture.image.height,
);
this.pickingMaterial.setTextureSize(
this.labelPool.atlasTexture.image.width,
this.labelPool.atlasTexture.image.height,
);
this.setLineHeight(this.#lineHeight);
this._needsUpdateLayout = true;
this._updateLayoutIfNeeded();
}
Expand All @@ -107,20 +128,37 @@ export class Label extends THREE.Object3D {

private reallocateAttributeBufferIfNeeded(numChars: number) {
const requiredLength = numChars * 10 * Float32Array.BYTES_PER_ELEMENT;
if (this.instanceAttrData.byteLength < requiredLength) {
this.instanceAttrData = new Float32Array(requiredLength);
this.instanceAttrBuffer = new THREE.InstancedInterleavedBuffer(this.instanceAttrData, 10, 1);
this.instanceBoxPosition.data = this.instanceAttrBuffer;
this.instanceCharPosition.data = this.instanceAttrBuffer;
this.instanceUv.data = this.instanceAttrBuffer;
this.instanceBoxSize.data = this.instanceAttrBuffer;
this.instanceCharSize.data = this.instanceAttrBuffer;
if (this.#instanceAttrData.byteLength < requiredLength) {
this.#instanceAttrData = new Float32Array(requiredLength);
this.#instanceAttrBuffer = new THREE.InstancedInterleavedBuffer(
this.#instanceAttrData,
10,
1,
);
this.#instanceBoxPosition = new THREE.InterleavedBufferAttribute(
this.#instanceAttrBuffer,
2,
0,
);
this.#instanceCharPosition = new THREE.InterleavedBufferAttribute(
this.#instanceAttrBuffer,
2,
2,
);
this.#instanceUv = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 4);
this.#instanceBoxSize = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 6);
this.#instanceCharSize = new THREE.InterleavedBufferAttribute(this.#instanceAttrBuffer, 2, 8);
this.geometry.setAttribute("instanceBoxPosition", this.#instanceBoxPosition);
this.geometry.setAttribute("instanceCharPosition", this.#instanceCharPosition);
this.geometry.setAttribute("instanceUv", this.#instanceUv);
this.geometry.setAttribute("instanceBoxSize", this.#instanceBoxSize);
this.geometry.setAttribute("instanceCharSize", this.#instanceCharSize);
}
}

setText(text: string): void {
if (text !== this.text) {
this.text = text;
if (text !== this.#text) {
this.#text = text;
this._needsUpdateLayout = true;
this.labelPool.updateAtlas(text);
this._updateLayoutIfNeeded();
Expand All @@ -132,11 +170,9 @@ export class Label extends THREE.Object3D {
if (!this._needsUpdateLayout) {
return;
}
const layoutInfo = this.labelPool.fontManager.layout(this.text);
this.material.uniforms.uLabelSize!.value[0] = layoutInfo.width;
this.material.uniforms.uLabelSize!.value[1] = layoutInfo.height;
this.pickingMaterial.uniforms.uLabelSize!.value[0] = layoutInfo.width;
this.pickingMaterial.uniforms.uLabelSize!.value[1] = layoutInfo.height;
const layoutInfo = this.labelPool.fontManager.layout(this.#text);
this.material.setLabelSize(layoutInfo.width, layoutInfo.height);
this.pickingMaterial.setLabelSize(layoutInfo.width, layoutInfo.height);

this.geometry.instanceCount = this.mesh.count = layoutInfo.chars.length;

Expand All @@ -145,56 +181,40 @@ export class Label extends THREE.Object3D {
let i = 0;
for (const char of layoutInfo.chars) {
// instanceBoxPosition
this.instanceAttrData[i++] = char.left;
this.instanceAttrData[i++] = layoutInfo.height - char.boxTop - char.boxHeight;
this.#instanceAttrData[i++] = char.left;
this.#instanceAttrData[i++] = layoutInfo.height - char.boxTop - char.boxHeight;
// instanceCharPosition
this.instanceAttrData[i++] = char.left;
this.instanceAttrData[i++] =
this.#instanceAttrData[i++] = char.left;
this.#instanceAttrData[i++] =
layoutInfo.height - char.boxTop - char.boxHeight + char.top - char.boxTop;
// instanceUv
this.instanceAttrData[i++] = char.atlasX;
this.instanceAttrData[i++] = char.atlasY;
this.#instanceAttrData[i++] = char.atlasX;
this.#instanceAttrData[i++] = char.atlasY;
// instanceBoxSize
this.instanceAttrData[i++] = char.xAdvance;
this.instanceAttrData[i++] = char.boxHeight;
this.#instanceAttrData[i++] = char.xAdvance;
this.#instanceAttrData[i++] = char.boxHeight;
// instanceCharSize
this.instanceAttrData[i++] = char.width;
this.instanceAttrData[i++] = char.height;
this.#instanceAttrData[i++] = char.width;
this.#instanceAttrData[i++] = char.height;
}
this.instanceAttrBuffer.needsUpdate = true;
this.#instanceAttrBuffer.needsUpdate = true;
this._needsUpdateLayout = false;
}

/** Values should be in working (linear-srgb) color space */
setColor(r: number, g: number, b: number, a = 1): void {
this.material.uniforms.uColor!.value[0] = r;
this.material.uniforms.uColor!.value[1] = g;
this.material.uniforms.uColor!.value[2] = b;
this.material.uniforms.uColor!.value[3] = a;
this.#updateTransparency();
this.material.setColor(r, g, b, a);
}

/** Values should be in working (linear-srgb) color space */
setBackgroundColor(r: number, g: number, b: number, a = 1): void {
this.material.uniforms.uBackgroundColor!.value[0] = r;
this.material.uniforms.uBackgroundColor!.value[1] = g;
this.material.uniforms.uBackgroundColor!.value[2] = b;
this.material.uniforms.uBackgroundColor!.value[3] = a;
this.#updateTransparency();
}

#updateTransparency(): void {
const bgOpacity = this.material.uniforms.uBackgroundColor!.value[3];
const fgOpacity = this.material.uniforms.uColor!.value[3];
const transparent = bgOpacity < 1 || fgOpacity < 1;
this.material.transparent = transparent;
this.material.depthWrite = !transparent;
this.material.setBackgroundColor(r, g, b, a);
}

// eslint-disable-next-line @foxglove/no-boolean-parameters
setBillboard(billboard: boolean): void {
this.material.uniforms.uBillboard!.value = billboard;
this.pickingMaterial.uniforms.uBillboard!.value = billboard;
this.material.setBillboard(billboard);
this.pickingMaterial.setBillboard(billboard);
}

/**
Expand All @@ -203,23 +223,21 @@ export class Label extends THREE.Object3D {
*/
// eslint-disable-next-line @foxglove/no-boolean-parameters
setSizeAttenuation(sizeAttenuation: boolean): void {
this.material.uniforms.uSizeAttenuation!.value = sizeAttenuation;
this.pickingMaterial.uniforms.uSizeAttenuation!.value = sizeAttenuation;
this.material.setSizeAttenuation(sizeAttenuation);
this.pickingMaterial.setSizeAttenuation(sizeAttenuation);
}

setAnchorPoint(x: number, y: number): void {
this.material.uniforms.uAnchorPoint!.value[0] = x;
this.material.uniforms.uAnchorPoint!.value[1] = y;
this.pickingMaterial.uniforms.uAnchorPoint!.value[0] = x;
this.pickingMaterial.uniforms.uAnchorPoint!.value[1] = y;
this.material.setAnchorPoint(x, y);
this.pickingMaterial.setAnchorPoint(x, y);
}

setLineHeight(lineHeight: number): void {
this.lineHeight = lineHeight;
this.#lineHeight = lineHeight;
const scale =
(this.lineHeight * this.labelPool.scaleFactor) /
(this.#lineHeight * this.labelPool.scaleFactor) /
this.labelPool.fontManager.atlasData.lineHeight;
this.material.uniforms.uScale!.value = scale;
this.pickingMaterial.uniforms.uScale!.value = scale;
this.material.setScale(scale);
this.pickingMaterial.setScale(scale);
}
}
Loading