three-virtual-geometry
Version:
Virtual geometry for three.js WebGPU: automatic cluster LOD and GPU-driven culling for billions of triangles. Independent implementation, not affiliated with Epic Games.
236 lines (235 loc) • 12.5 kB
TypeScript
/**
* Shared GPU buffers and compute passes for many VirtualMeshes.
*
* Every mesh's vertices, meshlets, level table and instances are appended to one set of storage buffers, and one
* fixed set of compute passes selects the cut for all of them. CPU cost, shader count and pipeline compilation
* therefore do not grow with the number of unique meshes: a level with hundreds of different props costs the
* same 12 dispatches per frame as a scene with one. Each mesh still gets its own indexed indirect draw (its own
* material), into a region of the pool's shared index buffer laid out each frame by a prefix sum.
*
* Per frame (all on the GPU):
* 0. reset - clears the counters.
* 1. cells - one thread per cell (CELL_SIZE instances of one mesh, Morton-ordered): frustum, sub-pixel and
* draw-distance tests on the cell's bounding sphere. Visible cells are appended to a list.
* 2. instance - one workgroup per visible cell: per-instance tests, occlusion, then the LOD level range per
* cut (camera and shadow). Instances whose few meshlets are all certain to pass emit them
* directly (fast path); the others go to a work list.
* 3. meshlet - one workgroup per work item: per-meshlet frustum/occlusion culling and the LOD test
* `parentError > t && error <= t`, widened to the blend band [t, t * k] when the threshold is high
* enough for level switches to show (see lodBlend.ts). Selected meshlets get a draw slot, their blend range and a range inside their
* mesh's region of the index buffer.
* 4. prefix - one thread: lays the meshes' regions out one after another and writes each mesh's draw args.
* 5. expand - one workgroup per drawn meshlet: writes 3 indices per triangle. An index encodes
* `drawSlot * 128 + meshletLocalVertex`; the material's positionNode decodes it and pulls the
* vertex from storage buffers, so any three.js NodeMaterial works unchanged.
*
* A pool is bounded by WebGPU's storage-buffer binding size; VirtualGeometry opens another pool when one is full.
* Buffers grow by reallocation (the nodes are rebuilt, and the pipelines recompiled in the background).
*
* Based on nanite-webgpu (MIT, Marcin Matuszczyk): cullMeshletsPass + rasterizeHwPass.
*/
import * as THREE from 'three/webgpu';
import type { VirtualMeshData } from '../preprocess/buildVirtualMesh.js';
import type { VirtualGeometry } from './VirtualGeometry.js';
type Node = any;
/** World-space normal of the current vertex, usable in material color/roughness nodes. */
export declare const vgWorldNormal: THREE.PropertyNode<"vec3">;
export declare const vgMeshletVarying: THREE.PropertyNode<"float">;
export declare const vgLodVarying: THREE.PropertyNode<"float">;
export declare const vgInstanceVarying: THREE.PropertyNode<"float">;
export declare const vgTintVarying: THREE.PropertyNode<"vec3">;
/** World position of the current vertex's instance origin (vertex stage): e.g. to make whole trees sway from their base. */
export declare const vgInstanceOrigin: THREE.PropertyNode<"vec3">;
/** Bits of an index used for the meshlet-local vertex; the rest is the draw slot. */
export declare const LOCAL_VERTEX_BITS: number;
/** Instances per cell. A cell is a contiguous range of one mesh's Morton-sorted instances. */
export declare const CELL_SIZE = 128;
/** Fraction of `maxDrawDistance` over which instances shrink to nothing before they are culled. */
export declare const DRAW_DISTANCE_FADE = 0.15;
/** Stand-in for an infinite draw distance on the GPU. */
export declare const NO_DRAW_DISTANCE = 1e+30;
export interface PoolCapacity {
/** Max meshlets drawn per frame, per cut. */
meshlets: number;
/** Max triangles drawn per frame by the camera cut (its index buffer). */
triangles: number;
/** Max triangles drawn per frame into shadow maps. */
shadowTriangles: number;
}
/** CPU-side record of one mesh's place in the pool. */
export interface PoolEntry {
slot: number;
data: VirtualMeshData;
vertexBase: number;
meshletBase: number;
meshletVertexBase: number;
triangleBase: number;
levelBase: number;
levelCount: number;
instanceBase: number;
instanceCount: number;
cellBase: number;
cellCount: number;
uvScale: [number, number];
uvOffset: [number, number];
}
/** Growable typed array with a GPU attribute; growth replaces the attribute (the pool then rebuilds its nodes). */
declare class GrowBuffer<T extends Float32Array | Uint32Array> {
private readonly make;
readonly itemSize: number;
/** Largest capacity (items) whose binding stays under the pool's byte limit. */
private readonly maxItems;
array: T;
attribute: THREE.StorageBufferAttribute;
used: number;
constructor(make: (n: number) => T, itemSize: number, capacity: number,
/** Largest capacity (items) whose binding stays under the pool's byte limit. */
maxItems?: number);
get capacity(): number;
/** Reserves `count` items; returns the first, or -1 when the buffer must grow first. */
reserve(count: number): number;
/** Reallocates to hold at least `needed` items (keeps the contents). */
grow(needed: number): void;
/** Marks items [first, first + count) for upload. */
touch(first: number, count: number): void;
}
export declare class GeometryPool {
private readonly context;
readonly entries: (PoolEntry | null)[];
/** Highest used mesh slot + 1 (the prefix pass loops over this many). */
readonly meshSlots: THREE.UniformNode<"uint", number>;
readonly cellCount: THREE.UniformNode<"uint", number>;
readonly maxBytes: number;
readonly capacity: PoolCapacity;
readonly vertexData: GrowBuffer<Uint32Array>;
readonly vertexAttrs: GrowBuffer<Uint32Array>;
readonly meshletBounds: GrowBuffer<Float32Array>;
readonly meshletInfo: GrowBuffer<Uint32Array>;
readonly meshletVertices: GrowBuffer<Uint32Array>;
readonly meshletTriangles: GrowBuffer<Uint32Array>;
readonly meshTable: GrowBuffer<Float32Array>;
readonly instances: GrowBuffer<Float32Array>;
readonly cells: GrowBuffer<Float32Array>;
private meshSlotCapacity;
private levelUsed;
private countersAttribute;
/** First word of the capacity controller's state in the counters buffer (see S_SCALE). */
private lodStateBase;
/** From the last readback: the GPU controller is still moving its scale, so the cut must be redone every frame. */
controllerActive: boolean;
drawArgsAttribute: THREE.IndirectStorageBufferAttribute;
cameraIndex: THREE.StorageBufferAttribute;
shadowIndex: THREE.StorageBufferAttribute;
computeNodes: THREE.ComputeNode[];
positionNode: Node;
/** Bumped on every rebuild: meshes re-point their geometry and material when it changes. */
version: number;
private needsRebuild;
private indexBuffersReady;
pipelineState: 'none' | 'compiling' | 'ready';
/** The cut needs recomputing (camera moved, instances or settings changed). */
dirty: boolean;
/** True once a cut was computed (its camera index buffer can then serve as occluders). */
hasCut: boolean;
/** Holds one mesh too large for a shared pool, sized for it (no other mesh joins). */
dedicated: boolean;
/** Set when the device cannot bind this pool's buffers: it is skipped entirely and its meshes are hidden. */
disabled: boolean;
constructor(context: VirtualGeometry, capacity: PoolCapacity, maxBytes: number);
/** Bytes the geometry of `data` takes in this pool (the largest single binding is what must fit). */
static bytesOf(data: VirtualMeshData): number;
/** True when `data` (and `instanceCount` instances) still fit under the binding-size limit. */
fits(data: VirtualMeshData, instanceCount: number): boolean;
/** Appends a mesh's geometry and instances. Returns its entry (`slot` is its index in the mesh table). */
add(data: VirtualMeshData, matrices: Float32Array, colors: Float32Array | undefined, gpuToInput: Uint32Array): PoolEntry;
/** Frees a mesh's slot (its buffer ranges are reclaimed when the pool is next repacked). */
remove(entry: PoolEntry): void;
private growMeshSlots;
private writeGeometry;
/** Writes a mesh record: bounding spheres, levers, level table location, uv packing, flags. */
writeMeshRecord(e: PoolEntry, o: {
maxDrawDistance: number;
minPixelRadius: number;
enabled: number;
shadow: number;
occlusion: number;
}): void;
/** Updates some of a mesh's per-frame levers (only what changed is uploaded). */
writeFlags(e: PoolEntry, o: {
maxDrawDistance?: number;
minPixelRadius?: number;
enabled?: number;
shadow?: number;
occlusion?: number;
}): void;
readFlag(e: PoolEntry, index: 'maxDrawDistance' | 'minPixelRadius' | 'enabled' | 'shadow' | 'occlusion'): number;
private writeInstances;
/** Writes one instance matrix (GPU order index within the mesh). Call `commitInstances` afterwards. */
setMatrix(e: PoolEntry, gpuIndex: number, matrix: THREE.Matrix4): void;
/** Uploads changed instances and refreshes the bounds of the given cells. */
commitInstances(e: PoolEntry, cells: Iterable<number>): void;
/**
* Bounding sphere of one cell's instances: the AABB of their world-space object spheres gives the center, and
* the radius reaches every instance sphere. Both radii are inflated a little so float32 rounding on the GPU can
* never make the cell test stricter than the per-instance test.
*/
private computeCell;
/**
* Index buffers are written by compute and read by draws. three.js picks GPU buffer usage from the first use,
* so create them as index buffers (INDEX | STORAGE) before any compute. Also rebuilds the nodes after growth.
*/
prepare(renderer: THREE.WebGPURenderer): void;
/** Builds the nodes if the pool's buffers grew since the last build (or it was never built). */
ensureBuilt(): void;
/** Recreates the per-frame buffers and every node (after the pool's buffers grew). */
build(): void;
/** Stats of the last computed cut, from a readback of the counters buffer. */
readCounters(renderer: THREE.WebGPURenderer): Promise<{
camera: {
selected: number;
drawn: number;
survived: number;
requestedTriangles: number;
};
shadow: {
selected: number;
drawn: number;
survived: number;
requestedTriangles: number;
};
occluded: number;
perMesh: (c: number, slot: number) => number;
scale: number[];
fill: number[];
}>;
/**
* Byte offset of a mesh's draw args for a cut (0: camera, 1: shadow) and part (0: meshlets drawn over every pixel,
* 1: meshlets inside an LOD blend band, drawn with the blend mask) in `drawArgsAttribute`.
*/
drawArgsOffset(cut: number, slot: number, part?: number): number;
dispose(): void;
}
/**
* World-space bounding sphere of the instance whose column-major matrix starts at `base`, computed the same way
* the instance pass does: center = model * sphere center, radius = sphere radius * max column length.
*/
export declare function instanceSphere(m: ArrayLike<number>, base: number, sphere: number[], out: Float64Array): void;
export declare function encodeOctNormal(nx: number, ny: number, nz: number): number;
/** xyz as float bits + octahedral-encoded normal, 4 x u32 per vertex. */
export declare function packVertices(positions: Float32Array, normals: Float32Array): Uint32Array;
/**
* uv per vertex as two unorm16 in one u32, quantized over the bounds of all UVs (also tiling UVs outside 0..1).
* Decode: unpackUnorm2x16(packed) * scale + offset. One step is 1/65535 of the range: well below a texel of a 4K
* texture for 0..1 UVs.
*/
export declare function packUvs(uvs: Float32Array): {
packed: Uint32Array;
scale: [number, number];
offset: [number, number];
};
/**
* Instance indices sorted along a 30-bit Morton curve over the instance centers, so that consecutive slots are
* spatially close and a fixed-size run of slots forms a compact cell.
*/
export declare function mortonOrder(matrices: Float32Array, sphere: number[]): Uint32Array;
export {};