UNPKG

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
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>; }