Rendering

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.

TypeBest forDepth-writesAnimated
OctahedralOpaque props and foliage, any view angleYesNo
Card-clusterVegetation and billboardsNo (standard alpha)No
Voxel (surfel)Dense foliage at extreme distanceYesNo

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:

FieldNotes
frame_countNumber of frames per axis used for baking
capture_typeFullSphere (0) or Hemisphere (1)
resolutionAtlas size in pixels
sphere_radiusBounding sphere radius
offsetBounding sphere centre (object space)
atlasSampler2D CPU copy of the packed atlas
rtThree.js WebGLMultipleRenderTargets holding the three MRT textures
cutoutConvex 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:

ClassSphere coverageNotes
OctahedralUvEncoderFull sphereMaps the octahedron net to [0,1]²
HemiOctahedralUvEncoderUpper hemisphereDenser 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:

FieldNotes
planesFlat array of [nx, ny, nz, offset, ...] plane descriptors
plane_countNumber of planes
geometryThree.js BufferGeometry — the final card geometry
face_assignmentsPer-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):

OffsetSizeContent
03position.xyz (object space)
33normal.xyz (unit length, object space)
63albedo.rgb (linear)
93ORM (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/):

ShaderPurpose
VoxelImpostorShaderV0Unlit — baked albedo only
VoxelImpostorShaderLitV0PBR lit, casts and receives shadows
VoxelImpostorShaderDepthV0Shadow-cast depth pass for the lit shader
VoxelImpostorShaderNormalsV0Debug — object-space normal as RGB
VoxelImpostorShaderViewportDepthV0Debug — viewport depth as grayscale
Asset typepoint_budgetframesresolution
Small prop (barrel, crate)2 04812512
Building / house16 384161 024
Vegetation (tree, large)32 768242 048
Hero / detailed mesh65 536322 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.

convex prop needing depth and shadows?Octahedral impostorsilhouette fits a few planes?Card-cluster impostorVoxel surfel impostoryesyesnono