Impostors & LOD
Three impostor types — octahedral, card-cluster, and voxel — let distant or numerous objects render as pre-baked proxies at a fraction of the triangle cost.
Impostors are pre-baked proxy representations of a mesh that can be rendered at a fraction of the original triangle cost. Meep ships three impostor types, each suited to different use cases.
| Type | Best for | Depth-writes | Animated |
|---|---|---|---|
| Octahedral | Opaque props and foliage, any view angle | Yes | No |
| Card-cluster | Vegetation and billboards | No (standard alpha) | No |
| Voxel (surfel) | Dense foliage at extreme distance | Yes | No |
All three share the same two-phase model: bake (offline, CPU+GPU) and draw (runtime, GPU only). None support skeletal or vertex-shader animations — the bake captures a static snapshot.
Octahedral impostors
Based on Ryan Brucks’s 2018 “Octahedral Impostors” technique, adapted from the xraxra/IMP reference implementation.
How it works
The baker (ImpostorBaker, src/engine/graphics/impostors/octahedral/ImpostorBaker.js) renders the source mesh from frames × frames evenly distributed viewpoints arranged on a hemisphere or full sphere. Each viewpoint is an orthographic camera aimed at the mesh’s bounding sphere centre; the render output is stored in three MRT targets:
diffuse+alpha(texture[0])normal+depth(texture[1])orm— occlusion, roughness, metalness (texture[2])
All frames² tiles are packed into a single square atlas of size resolution × resolution. At runtime a six-triangle quad reconstructs the G-buffer for the current view direction by blending up to four adjacent atlas frames (bilinear on the octahedral sphere). Depth is written, so impostors participate in shadow mapping and correct depth sorting.
Baking
import { ImpostorBaker } from "@woosh/meep-engine/src/engine/graphics/impostors/octahedral/ImpostorBaker.js";
import { ImpostorCaptureType } from "@woosh/meep-engine/src/engine/graphics/impostors/octahedral/ImpostorCaptureType.js";
const baker = new ImpostorBaker();
baker.renderer = graphics.renderer;
// objects: array of { mesh: ShadedGeometry, transform: mat4 }
const desc = baker.bake({
objects,
frames: 12, // 12×12 = 144 atlas tiles; default 12
resolution: 1024, // atlas size in pixels; must be power-of-two and divisible by frames
type: ImpostorCaptureType.Hemisphere // or FullSphere
});
bake() returns an ImpostorDescription (ImpostorDescription.js) with:
| Field | Notes |
|---|---|
frame_count | Number of frames per axis used for baking |
capture_type | FullSphere (0) or Hemisphere (1) |
resolution | Atlas size in pixels |
sphere_radius | Bounding sphere radius |
offset | Bounding sphere centre (object space) |
atlas | Sampler2D CPU copy of the packed atlas |
rt | Three.js WebGLMultipleRenderTargets holding the three MRT textures |
cutout | Convex hull polygon (normalised 0–1) cropped to alpha, used to reduce overdraw |
ImpostorCaptureType.Hemisphere captures only the upper hemisphere — appropriate for objects that will never be seen from below (buildings, trees). It halves the number of wasted atlas tiles.
Encoder grid types
Two UV encoders are available:
| Class | Sphere coverage | Notes |
|---|---|---|
OctahedralUvEncoder | Full sphere | Maps the octahedron net to [0,1]² |
HemiOctahedralUvEncoder | Upper hemisphere | Denser per-tile texel budget |
Limitations
- Only PBR materials are captured (albedo, normal, metalness, roughness, AO). Emissive, transmission, and subsurface are not recorded.
- No animation support.
- Close-up use will reveal the projective artefacts inherent in any multi-view capture. Use impostors as a mid-to-far LOD, not as a near replacement.
Card-cluster impostors
Card-cluster impostors represent a mesh as a small number of textured quads (“cards”) whose planes are fitted to the mesh’s geometry. The technique is inspired by Sean Feeley’s 2019 GDC talk “Interactive Wind and Vegetation in God of War” and Xavier Decoret et al.’s 2003 “Billboard Clouds” paper.
The core data structure is FacePlaneAssignment (src/engine/graphics/impostors/card_cluster/FacePlaneAssignment.js), which records the set of fitted planes and the triangle-to-plane assignment:
| Field | Notes |
|---|---|
planes | Flat array of [nx, ny, nz, offset, ...] plane descriptors |
plane_count | Number of planes |
geometry | Three.js BufferGeometry — the final card geometry |
face_assignments | Per-triangle index into planes |
Card-cluster impostors are well-suited to foliage and debris because a handful of cards (typically 4–8) capture most of the silhouette from any angle. Alpha-testing handles transparency; there is no depth reconstruction.
Voxel (surfel) impostors
Voxel impostors replace dense alpha-tested foliage cards at extreme view distances with a cloud of near-pixel-sized instanced quads (“splats”), each carrying a full G-buffer slice — position, normal, albedo, and ORM. This approach is inspired by the Nanite Voxels representation shown by Epic and CD Projekt RED in the Witcher 4 / Unreal Engine 5.7 tech demo (Unreal Fest Stockholm 2025).
Baking
VoxelImpostorBaker (src/engine/graphics/impostors/voxel/VoxelImpostorBaker.js) reuses ImpostorBaker to produce the multi-view atlas, then reads the atlas back to CPU and bins every opaque pixel into an axis-aligned voxel grid. Each occupied cell averages the position, normal, albedo, and ORM of all pixels that voted for it. The grid resolution is tuned iteratively to stay within a point_budget.
import { VoxelImpostorBaker } from "@woosh/meep-engine/src/engine/graphics/impostors/voxel/VoxelImpostorBaker.js";
const baker = new VoxelImpostorBaker();
baker.renderer = graphics.renderer;
const desc = baker.bake({
objects,
point_budget: 32768, // maximum output splats
frames: 24, // directions for the underlying atlas bake
resolution: 2048 // atlas resolution; may be non-power-of-two here
});
The result is a VoxelImpostorDescription (VoxelImpostorDescription.js) with a Float32Array points buffer (stride 12 floats per point):
| Offset | Size | Content |
|---|---|---|
| 0 | 3 | position.xyz (object space) |
| 3 | 3 | normal.xyz (unit length, object space) |
| 6 | 3 | albedo.rgb (linear) |
| 9 | 3 | ORM (occlusion, roughness, metalness) |
Rendering
The point buffer is wrapped as an InstancedInterleavedBuffer (zero-copy). Each instance expands a unit quad in view-space XY, scaled by VoxelImpostorDescription.point_diameter (voxel_size * sqrt(2)) to cover the worst-case oblique projection of a voxel cell. Lighting uses the per-instance normal, not the quad’s geometry normal.
Available shaders (source in src/engine/graphics/impostors/voxel/shader/):
| Shader | Purpose |
|---|---|
VoxelImpostorShaderV0 | Unlit — baked albedo only |
VoxelImpostorShaderLitV0 | PBR lit, casts and receives shadows |
VoxelImpostorShaderDepthV0 | Shadow-cast depth pass for the lit shader |
VoxelImpostorShaderNormalsV0 | Debug — object-space normal as RGB |
VoxelImpostorShaderViewportDepthV0 | Debug — viewport depth as grayscale |
Recommended budgets
| Asset type | point_budget | frames | resolution |
|---|---|---|---|
| Small prop (barrel, crate) | 2 048 | 12 | 512 |
| Building / house | 16 384 | 16 | 1 024 |
| Vegetation (tree, large) | 32 768 | 24 | 2 048 |
| Hero / detailed mesh | 65 536 | 32 | 2 048 |
Limitations
Voxel impostors share the general impostor trade-offs — no animation, baked static materials, no geometry inside sealed cavities — plus one extra: camera-facing splats overlap visibly at close range before the LOD switch distance. Use as a distant LOD only.
Choosing an impostor type
Use octahedral when you need physically correct depth (shadow casting, depth-sorted transparency) and your object is roughly convex. Use card-cluster for thin vegetation or objects whose silhouette is well captured by a few planes. Use voxel when foliage is so distant that even card-clusters produce aliased lines and you need a seamless density fade.