diff --git a/README.md b/README.md index d18b544..257f60e 100644 --- a/README.md +++ b/README.md @@ -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.setSizeAttenuation(true); // sizeAttenuation=false requires billboard=true +label.setAnchorPoint(x, y); +label.setLineHeight(0.5); + +// cleanup... +labelPool.release(label); +``` diff --git a/package.json b/package.json index 4aa7848..0348632 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ }, "type": "module", "exports": { - ".": "./dist/index.js" + ".": "./dist/webgl/index.js", + "./webgpu": "./dist/webgpu/index.js" }, "files": [ "dist", diff --git a/src/ILabelMaterial.ts b/src/ILabelMaterial.ts new file mode 100644 index 0000000..7f77be1 --- /dev/null +++ b/src/ILabelMaterial.ts @@ -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; +} diff --git a/src/Label.ts b/src/Label.ts index 2a15606..c49778f 100644 --- a/src/Label.ts +++ b/src/Label.ts @@ -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. @@ -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", () => { @@ -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(); } @@ -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(); @@ -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; @@ -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); } /** @@ -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); } } diff --git a/src/LabelPool.stories.tsx b/src/LabelPool.stories.tsx index 825932f..1cf46da 100644 --- a/src/LabelPool.stories.tsx +++ b/src/LabelPool.stories.tsx @@ -2,15 +2,18 @@ import type { Meta, StoryObj } from "@storybook/react-webpack5"; import { useEffect, useRef, useState, type ReactElement } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { WebGPURenderer } from "three/webgpu"; -import type { Label } from "./Label.ts"; -import { LabelPool } from "./LabelPool.ts"; +import type { Label, LabelPoolBase } from "./common.ts"; +import { LabelPool as LabelPoolWebGL } from "./webgl/index.ts"; +import { LabelPool as LabelPoolWebGPU } from "./webgpu/index.ts"; const meta: Meta = { title: "LabelPool", component: BasicTemplate, tags: ["autodocs"], argTypes: { + renderer: { control: false }, cameraMode: { control: "inline-radio", options: ["perspective", "orthographic"] }, lineHeight: { control: { type: "range", min: 0.5, max: 20, step: 0.01 } }, scaleFactor: { control: { type: "range", min: 0, max: 2, step: 0.01 } }, @@ -43,15 +46,17 @@ const meta: Meta = { export default meta; type StorySceneOptions = { + renderer: "webgpu" | "webgpu-force-webgl" | "webgl"; logDepthBuffer: boolean; }; class StoryScene { - labelPool = new LabelPool(); + labelPool: LabelPoolBase; perspectiveCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000); orthographicCamera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 1000); scene = new THREE.Scene(); - renderer?: THREE.WebGLRenderer; + renderer?: WebGPURenderer | THREE.WebGLRenderer; + initialized = false; controls?: OrbitControls; perspective = true; @@ -62,6 +67,7 @@ class StoryScene { constructor(options: StorySceneOptions) { this.options = options; + this.labelPool = options.renderer === "webgl" ? new LabelPoolWebGL() : new LabelPoolWebGPU(); this.perspectiveCamera.position.set(4, 4, 4); this.scene.background = new THREE.Color(0xf0f0f0); this.scene.add(new THREE.AxesHelper(5)); @@ -79,6 +85,9 @@ class StoryScene { } render = () => { + if (!this.initialized) { + return; + } this.renderer?.render( this.scene, this.perspective ? this.perspectiveCamera : this.orthographicCamera, @@ -86,11 +95,30 @@ class StoryScene { }; setCanvas(canvas: HTMLCanvasElement) { - this.renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - logarithmicDepthBuffer: this.options.logDepthBuffer, - }); + if (this.options.renderer === "webgpu" || this.options.renderer === "webgpu-force-webgl") { + this.renderer = new WebGPURenderer({ + canvas, + antialias: true, + logarithmicDepthBuffer: this.options.logDepthBuffer, + forceWebGL: this.options.renderer === "webgpu-force-webgl", + }); + this.renderer.init().then( + () => { + this.initialized = true; + this.render(); + }, + (err: unknown) => { + console.error("Failed to initialize renderer", err); + }, + ); + } else { + this.renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + logarithmicDepthBuffer: this.options.logDepthBuffer, + }); + this.initialized = true; + } this.renderer.setPixelRatio(window.devicePixelRatio); this.perspectiveCamera.aspect = canvas.clientWidth / canvas.clientHeight; @@ -103,7 +131,6 @@ class StoryScene { this.orthographicCamera.position.copy(this.perspectiveCamera.position); this.orthographicCamera.rotation.copy(this.perspectiveCamera.rotation); this.orthographicCamera.updateProjectionMatrix(); - this.render(); this.controls.addEventListener("change", () => { this.orthographicCamera.position.copy(this.perspectiveCamera.position); @@ -114,6 +141,7 @@ class StoryScene { } } function BasicTemplate({ + renderer = "webgl", text, lineHeight, scaleFactor, @@ -131,6 +159,7 @@ function BasicTemplate({ positionZ, logDepthBuffer = false, }: { + renderer?: "webgpu" | "webgpu-force-webgl" | "webgl"; text: string; lineHeight: number; scaleFactor: number; @@ -150,7 +179,7 @@ function BasicTemplate({ }): ReactElement { const canvasRef = useRef(null); - const [storyScene] = useState(() => new StoryScene({ logDepthBuffer })); + const [storyScene] = useState(() => new StoryScene({ renderer, logDepthBuffer })); const [label, setLabel] = useState