Trying impostors in Three.js
/ experiment notes
I watched Thomas Murphy’s talk about impostor cards and got curious about how the idea could fit into Nilo. Could I build something similar with Three.js and TSL?
I started in Python because I’m more familiar with it and can prototype faster there. I wanted to work through the capture directions and projection math before moving the experiment into Three.js and TSL.
The idea is to render pictures of a model ahead of time, then use those pictures to draw a much cheaper stand-in. A single billboard works from one direction. Murphy’s approach uses six orthographic views and three object-space planes to give the image more shape as the camera moves.
I started with a separate project. I wanted to compare the original mesh with the approximation, orbit around both, and see where it fell apart before trying to bring it into the editor.
1. the modeloriginal geometry
2. capture six anglespack them into one texture
3. draw the impostorsample the atlas on a quadWorking through it in Python
The six views are just the positive and negative directions of each axis. Keeping their order fixed makes it easier to follow a capture through to its tile in the atlas. These snippets are small Python illustrations of the math, rather than the full renderer.
directions = [
(1, 0, 0), (-1, 0, 0), # +x, -x
(0, 1, 0), (0, -1, 0), # +y, -y
(0, 0, 1), (0, 0, -1), # +z, -z
]
tile_size = 512
for view, direction in enumerate(directions):
column = view % 3
row = view // 3
tile_origin = (column * tile_size, row * tile_size)The next piece is finding where a viewing ray meets an axis-aligned plane. A ray is an origin plus a direction multiplied by a distance. For a plane at a fixed X, Y, or Z coordinate, solving that one coordinate gives the intersection.
def intersect_plane(origin, direction, axis, offset=0.0):
component = direction[axis]
if abs(component) < 1e-6:
return None # the ray is parallel to the plane
distance = (offset - origin[axis]) / component
if distance <= 0:
return None # the hit is behind the camera
point = tuple(
origin[i] + distance * direction[i]
for i in range(3)
)
return distance, pointThat point can be projected into the corresponding capture’s image coordinates. The shader still has to check whether it lands inside the tile and whether the sampled pixel belongs to the object. The Python version is a useful way to follow those steps one at a time; the browser version does them per pixel on the GPU.
Six pictures, three planes
The prototype bakes the model from the positive and negative X, Y, and Z directions into a 1536 × 1024 atlas. At runtime, a camera-facing quad runs a TSL material. For each pixel, it intersects the viewing ray with three virtual planes and looks up the corresponding atlas views.
This is still a simplified version of the talk. My shader samples three views and chooses a valid intersection. Murphy describes combining the projection before a single texture sample. That difference matters, and I haven’t reproduced that part yet.
The quad also has flat depth. The lighting is baked into the pictures, and seams become visible from some angles. It gives me something concrete to experiment with, but it isn’t a finished replacement for the mesh.
The first benchmark was misleading
I added a page that renders the same number of reference models and impostors. At first, each copy was a separate object. With 20,000 copies, both versions submitted about 20,000 draw calls. The impostor used much less GPU time, but the CPU work was enough to make its frame rate worse.
Reducing triangles hadn’t removed the cost of submitting all those objects. I changed both versions to use instancing and moved the impostor’s camera-facing transform into the shader.
In one WebGPU run with 5,000 helmets at 1920 × 1080, the reference submitted about 77 million triangles per frame, compared with 10,000 for the impostors. Both used two draw calls, including the background. The pooled median GPU time was about 4.44 times lower for the impostor.
That result is from one model and one machine, with shadows disabled. It measures the cost of the current approximation, not equal image quality. The benchmark runs reference, impostor, impostor, reference, with warm-up before each run and GPU timestamps collected separately from the frame-time samples.
Where it could fit in Nilo
I’m thinking about distant, static objects in worlds built from generated assets. Keep the mesh nearby, where its shape matters, and switch to an impostor when it occupies fewer pixels on screen.
Nilo’s shader work gives us a way to express the material. The rest would need an asset pipeline: bake the views, save the atlas alongside the model, batch compatible objects, and choose when to switch representations. Changing the material alone wouldn’t reduce the original geometry.
I’d keep the mesh for editing and close inspection. The useful test would be a real scene with many different assets, rather than thousands of copies of the same helmet.
What I want to try next
Depth is the first thing I want to improve. Captured depth could help correct the texture projection and the depth written by the impostor. More capture directions, perhaps 16 or 32, could improve the appearance between views, at the cost of a larger atlas.
I also want to compare those options against the compact six-view approach. How much texture memory does each need? Where do silhouettes break? At what distance does the difference stop being noticeable?
There are already Three.js and TSL impostor projects. I’m treating this as an experiment in understanding and integration. Any claim of a new technique would need a comparison with the existing work first.
Model: Damaged Helmet, by theblueturtle and adapted by ctxwing. See the model attribution.
All posts