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.
242 lines (241 loc) • 14.4 kB
TypeScript
import * as THREE from 'three/webgpu';
import type { VirtualMesh, VirtualMeshInstances, VirtualMeshOptions } from './VirtualMesh.js';
import { OcclusionCulling } from './OcclusionCulling.js';
import type { VirtualMeshData } from '../preprocess/buildVirtualMesh.js';
import { GeometryPool } from './GeometryPool.js';
import { type VirtualGeometryImport, type VirtualGeometryImportOptions } from '../import/fromObject3D.js';
export { VG_DEBUG_MODES } from '../constants.js';
export interface VirtualGeometryStats {
drawnMeshlets: number;
drawnTriangles: number;
/** Triangles if every instance were drawn at full detail (no LOD, no culling). */
fullDetailTriangles: number;
instances: number;
/** Instances that survived instance culling (frustum, sub-pixel). */
visibleInstances: number;
/** Triangles the LOD cut asked for. Above drawnTriangles means some mesh ran out of index buffer. */
requestedTriangles: number;
/** True when a mesh dropped meshlets for lack of capacity (visible as flicker). */
overflow: boolean;
/** Fullest draw buffer of any mesh, as a fraction of its capacity (above 1: overflow). */
capacityUse: number;
/** Instances hidden from the camera by occlusion culling. */
occludedInstances: number;
/** Triangles drawn into shadow maps (the coarser shadow cut). */
shadowTriangles: number;
/**
* Factor the GPU capacity controller applies to the error threshold of the camera cut (largest over pools): 1
* while the draw buffers have room, more while they would overflow at the requested threshold.
*/
thresholdScale: number;
}
export interface VirtualGeometryOptions {
/** Max triangles drawn per frame per pool (camera). Default 10M (120 MB of indices, under the 128 MB binding limit). */
maxTriangles?: number;
/** Max triangles drawn into shadow maps per frame per pool. Default 4M. */
maxShadowTriangles?: number;
/** Max meshlets drawn per frame per pool. Default 1M. */
maxMeshlets?: number;
/**
* Largest storage-buffer binding a shared pool may use, in bytes. A pool that would exceed it is closed and a new
* one opened. Default 120 MB (WebGPU guarantees 128 MB). A single mesh larger than this gets a pool of its own,
* which needs a device created with higher limits: see `virtualGeometryLimits()`.
*/
maxPoolBytes?: number;
}
export declare class VirtualGeometry {
readonly viewMatrix: THREE.UniformNode<"mat4", THREE.Matrix4>;
readonly projectionMatrix: THREE.UniformNode<"mat4", THREE.Matrix4>;
/** `projection[1][1] * viewportHeight / 2`: converts view-space error to pixels. */
readonly projScale: THREE.UniformNode<"float", number>;
readonly near: THREE.UniformNode<"float", number>;
/** Max projected error, in pixels, a meshlet may have to be drawn (the finest end of the LOD blend band). */
readonly errorThreshold: THREE.UniformNode<"float", number>;
/**
* Lever: width of the LOD blend band, as a factor on the error threshold (default 2). Instead of switching each
* cluster to its parent at one distance (a pop), every pixel shows the cut for its own threshold between t and
* t * lodBlend, from a per-pixel pattern, so detail moves from one level to the next a few pixels at a time.
*
* Automatic: at an effective threshold of about a pixel (the default) a level switch moves the surface by at most
* a pixel and does not show, so levels switch directly and blending costs nothing. The band opens as the effective
* threshold rises (when you set a higher one, or the draw buffers force a coarser one), and is this wide from
* 2.5 px up. Blending costs while it is open: clusters in the band are drawn by two levels, with a discard. 1 turns
* it off.
*/
lodBlend: number;
/**
* Lever: change the blend pattern every frame (default false). Turn it on together with temporal anti-aliasing
* (e.g. three's TRAA), which averages the pattern into a smooth crossfade; without TAA a moving pattern shimmers.
*/
lodBlendTemporal: boolean;
/** `max(1, lodBlend)` (set every frame). */
readonly lodBlendMax: THREE.UniformNode<"float", number>;
/** `maxErrorThreshold / errorThreshold`: the most the GPU capacity controller may scale the threshold by. */
readonly lodScaleLimit: THREE.UniformNode<"float", number>;
/**
* Lever: target number of drawn triangles per frame. When > 0, the error threshold is adjusted (gliding, see
* adaptThreshold) to keep the drawn triangle count near this budget. Off by default: a fixed threshold gives
* the steadiest image, because LODs then only switch with distance, never all at once.
*/
triangleBudget: number;
/** Bounds for the automatically adjusted error threshold, in pixels. */
thresholdRange: [number, number];
/**
* The highest error threshold, in pixels, that the engine may switch to on its own to keep draw buffers from
* overflowing. Default 64. Lower it to keep detail no matter what: if the scene still asks for more triangles than
* the buffers hold, some clusters are then dropped (raise `maxTriangles` instead if that happens).
*
* The capacity controller runs on the GPU, per pool: when a cut would not fit its draw buffers it is selected again
* at a coarser threshold in the same frame, and between frames the threshold glides back by a percent per frame
* while there is room. `errorThreshold` keeps the value you set; `lastStats.thresholdScale` shows the factor.
*/
maxErrorThreshold: number;
/** Latest GPU readback, updated every `statsInterval` frames (every other frame while a draw buffer is nearly full). */
lastStats: VirtualGeometryStats | null;
statsInterval: number;
/** Frames updated so far. */
frameCount: number;
/** Consecutive stats readbacks with the drawn triangles outside the budget's deadband. */
private outOfBand;
/** Threshold the controllers glide toward, a few percent per frame (null: none), and the value last written. */
private thresholdTarget;
private thresholdWritten;
/** Threshold when the pending stats readback was issued: the cut it describes was made with about this value. */
private statsThreshold;
private statsPending;
readonly frustumCulling: THREE.UniformNode<"uint", number>;
readonly debugMode: THREE.UniformNode<"uint", number>;
/**
* Instances whose bounding sphere projects to a smaller radius than this (pixels) are skipped. 0 disables.
* The sphere is never smaller than the object, so at 0.7 nothing wider than ~1.4 px on screen is dropped.
*/
readonly minPixelRadius: THREE.UniformNode<"float", number>;
readonly frustumPlanes: THREE.UniformNode<"vec4", THREE.Vector4>[];
/**
* Direction * length that light travels (e.g. sun direction * 300). When non-zero, frustum culling
* keeps meshlets whose shadow, swept along this vector, can reach the view frustum, so shadow
* casters just outside the view still cast shadows. Zero disables it.
*/
readonly shadowSweep: THREE.UniformNode<"vec3", THREE.Vector3>;
/**
* Set `shadowSweep` automatically from the shadow cameras of the lights that render the meshes (direction
* of an orthographic shadow camera, length = its larger extent). Turn off to set `shadowSweep` yourself.
*/
autoShadowSweep: boolean;
/**
* Shadow maps use a coarser cut: the error threshold times this. Shadows are soft and seen from the camera
* at a distance, so this saves most of the shadow-pass triangles with no visible change. 1 = camera detail.
*/
readonly shadowErrorScale: THREE.UniformNode<"float", number>;
private readonly detectedSweep;
private detectedSweepFrame;
/**
* Occlusion culling (on by default): geometry hidden behind solid meshes is not drawn. Levers:
* `occlusion.enabled`, `occlusion.resolutionScale`, and per mesh `occluder` / `occlusionCulling`.
*/
readonly occlusion: OcclusionCulling;
/** Skip LOD selection/culling and keep drawing the last selection (for inspecting the cut). */
freeze: boolean;
/**
* Run `update()` automatically before every render of a scene that contains this context's meshes, with the
* camera that render uses (default true). Shadow-map renders reuse the cut of the camera being rendered.
* Calling `update()` yourself still works: a render right after it with the same camera does not repeat it.
*/
autoUpdate: boolean;
/**
* Measure the error threshold in CSS pixels (default) instead of device pixels. On a 2x display,
* device pixels would request ~4x the triangles for detail nobody can see.
*/
errorInCssPixels: boolean;
readonly meshes: VirtualMesh[];
/** Shared buffers and compute passes; meshes go into the first pool with room (see GeometryPool). */
readonly pools: GeometryPool[];
private readonly capacity;
private readonly maxPoolBytes;
constructor(options?: VirtualGeometryOptions);
private readonly frustum;
private readonly projScreen;
private readonly size;
/** Compute nodes dispatched this frame (reused to avoid per-frame allocation). */
private readonly computeList;
private readonly dispatchedPools;
/** Frames where `update` ran no compute pass because nothing the cut depends on changed. */
skippedFrames: number;
/** Inputs of the cut as of the last frame that changed them (NaN until the first frame). */
private readonly cutInputs;
private readonly cutInputsScratch;
/** Scenes whose onBeforeRender runs the auto update (installed the first time one of our meshes renders in them). */
private readonly sceneHooks;
/** `renderer.info.render.calls` and camera of the last automatic and the last manual update. */
private autoCall;
private autoCamera;
private manualCall;
private manualCamera;
private updatingAuto;
/**
* Converts every static mesh under `object` (e.g. `gltf.scene`) to VirtualGeometry geometry, in place: meshes sharing
* geometry and material become one VirtualMesh with many instances, materials are converted to node materials
* with their textures, and the originals stop rendering. Skinned/morphing meshes, points and lines are left
* alone. Returns the created meshes and a `dispose()` that restores the originals. See `VirtualGeometryImportOptions`.
*/
add(object: THREE.Object3D, options?: VirtualGeometryImportOptions): Promise<VirtualGeometryImport>;
/** Create a renderable mesh from a built DAG. Add the result to any three.js scene. */
createMesh(data: VirtualMeshData, material: THREE.NodeMaterial, instances: VirtualMeshInstances, options?: VirtualMeshOptions): VirtualMesh;
/**
* Lever: compile a pool's compute pipelines in the background before its first dispatch (default), so adding
* meshes never freezes the page; they appear once ready. False: compile on first use (a hitch).
*/
asyncCompile: boolean;
private compilePool;
/** Builds pools whose buffers grew and points their meshes at the new buffers. */
private preparePools;
private readonly checkedPools;
/**
* Disables pools whose buffers the device cannot bind (a mesh with a pool of its own, on a device created with
* default limits): their compute passes are skipped and their meshes hidden, with one message saying how to fix it.
*/
private checkDeviceLimits;
/**
* Optional, for loading screens: compiles every pipeline the scene needs (compute and render), so the first
* frames neither freeze nor show meshes popping in. Call after adding the meshes, before the first render.
*/
compileAsync(renderer: THREE.WebGPURenderer, scene: THREE.Object3D, camera: THREE.Camera): Promise<void>;
/** Called by meshes from onBeforeShadow: remembers the light direction for `autoShadowSweep`. */
noteShadowCamera(shadowCamera: THREE.Camera): void;
/** Puts the mesh's geometry and instances into the first pool with room (a new pool when none has). */
register(mesh: VirtualMesh, instances: VirtualMeshInstances): void;
/**
* Auto update, from a VirtualMesh's onBeforeRender. The first time, this also hooks the scene's onBeforeRender,
* which runs before the render pass starts (so shadow maps rendered inside the pass already see the new cut);
* later frames update from there. A compute submitted from inside a pass still runs before that pass on the
* GPU, because the pass is submitted when it ends.
*/
private autoUpdateFor;
unregister(mesh: VirtualMesh): void;
/** Dispose every mesh created by this context. */
dispose(): void;
/**
* Runs LOD selection + culling on the GPU for `camera`. With `autoUpdate` (default) the renderer calls this
* for you before each render; call it yourself before `renderer.render` when `autoUpdate` is false.
* When the camera, the settings and every mesh's instances are unchanged, the compute passes are
* skipped and the previous cut (index buffer + draw args) is drawn again.
*/
update(renderer: THREE.WebGPURenderer, camera: THREE.PerspectiveCamera): void;
/**
* True when anything the GPU cut reads from the context differs from the last frame that was computed
* (and records the new values). Exact comparison on purpose: an unmoved camera recomputes the same
* matrices bit for bit, while any real change, including a budget-controller step, differs.
*/
private cutInputsChanged;
/**
* Budget controller (only with `triangleBudget`): picks the threshold to glide toward (see stepThreshold) from a
* stats readback. Drawn triangles grow roughly with 1/threshold^2. Capacity is not its job: the GPU controller keeps
* every cut inside its draw buffers (see `maxErrorThreshold`).
*/
private adaptThreshold;
private glideTo;
/** Every frame: moves the threshold a few percent toward the controllers' target. */
private stepThreshold;
/** GPU -> CPU readback of the last culling result. Async; never call every frame. One small readback per pool. */
readStats(renderer: THREE.WebGPURenderer): Promise<VirtualGeometryStats>;
}