Particles are hard on transparency. A column of smoke is hundreds of overlapping quads. Drawn as triangles, every layer costs a blend into the render targets at every pixel it covers. The quads also have to sort against the glass they drift through, which breaks down when the smoke is both in front of and behind the same pane.
When implementing transparency in my new engine Cinnabar, I decided to handle this with compute sprites: camera-facing squares that never touch the hardware rasterizer. A few compute passes bin them into 32-pixel screen tiles. Then each pixel is visited once, every sprite that covers it is added up in registers, and the result is written once. The design follows Johan Köhler’s Practical Order Independent Transparency, with one important difference. Where that paper resolves its particles with weighted blended OIT, Cinnabar adds them into the same moments that its glass already writes.
Moments are sums
Cinnabar’s transparent surfaces use moment-based OIT (Münstermann, Krumpen, Klein and Peters, 2018), which runs in two geometry passes. The first pass adds up, at each pixel, the optical depth of every transparent surface and the power moments of its depth, weighted by that optical depth:
The second pass shades each surface and scales it by the transmittance in front of it, which it reconstructs from and the four moments by solving a small Hankel system. A composite then puts the sum over the opaque scene. View depth is warped onto a log scale before the moments are taken, so precision is spread evenly from the near plane to 500 m:
// warp_depth maps a view depth to [-1, 1], on a log scale from the near plane to MOMENTS_FAR.
float warp_depth(float view_depth, float near) {
float t = log(max(view_depth, near) / near) / log(MOMENTS_FAR / near);
return saturate(t) * 2 - 1;
}
// optical_depth is a surface's share of b0.
float optical_depth(float alpha) {
return -log(1 - min(alpha, MOMENT_MAX_ALPHA));
}
// depth_moments are a surface's moments at its warped depth z, weighted by its optical depth.
float4 depth_moments(float z, float depth) {
float z2 = z * z;
return float4(z, z2, z2 * z, z2 * z2) * depth;
}
The important property here is that everything in both passes is a sum. Addition is commutative, so it doesn’t matter who adds what, or in what order. A rasterized triangle can add its contribution through the blend unit, and a compute shader can add 200 sprites’ contributions in registers and do a single read-modify-write. The sprites need no separate composite and no sort against the meshes; they become more layers in the moments that the glass already writes.
Where sprites come from
A frame draws up to MAX_SPRITES (65,536) sprites, held as 80-byte records in a single GPU buffer with a counter beside it. They come from two places.
The CPU hands the frame a plain slice in Frame_Desc.sprites. Each frame, the renderer writes those records into the upload ring relative to the camera; World_Position is 64-bit fixed point and scenes might sit 1.5×10¹¹ m from the origin. It also resolves each texture GUID to a bindless index. A copy at the start of the sprite passes puts them at the front of the buffer and sets the counter.
The GPU appends more after them. A pass reserves room before the world pass with reserve_sprites(f, count), which raises the frame’s capacity and hands back the records buffer and the counter. Its shader then appends with an atomic increment. The particle system’s update pass works this way: every living particle evaluates its emitter’s color, size and emission curves and appends one sprite.
The CPU doesn’t know how many sprites the GPU will append, only how many it reserved. So the sprite passes size their buffers by the capacity, and work from the count in the counter. A single-thread arguments pass turns the count into the arguments of indirect dispatches, so the per-sprite passes launch exactly as many groups as there are sprites:
[numthreads(1, 1, 1)]
void cs_main() {
uint count = sprite_count(push.count, push.capacity);
Sprite_Arguments a;
a.bin = uint3((count + 63) / 64, 1, 1);
a.light = uint3(min(count, SPRITE_LIGHT_COLUMNS), (count + SPRITE_LIGHT_COLUMNS - 1) / SPRITE_LIGHT_COLUMNS, 1);
a.padding = 0;
cn_rw_buffer(push.arguments).Store<Sprite_Arguments>(0, a);
}
Where the passes go
The sprite work is six compute passes, attached to the transparency passes in the render graph. In the renderer, the sprite passes nest inside the moment-based path; a frame with sprites but no transparent meshes still takes that path:
sprites := has_sprites(f)
moments := (has_group(f, .Moments) || sprites) && pipeline_ready(r.platform, &r.transparency_composite)
// ...
if moments {
// The moments pass: clears the targets, draws the transparent meshes.
p := add_render_pass(g, "transparent moments", draw_moments, f)
// ...
if sprites {
add_sprite_bin_passes(f) // arguments, tiles, bin
add_sprite_moments_pass(f)
}
}
if moments {
// The transparent pass: shades each mesh by the transmittance in front of it.
p := add_render_pass(g, "transparent", draw_transparent, f)
// ...
if sprites {
add_sprite_shade_passes(f, cf.accumulated) // light, shade
}
p = add_compute_pass(g, "transparency composite", run_composite, cf)
}
Tiles
The screen is cut into 32×32-pixel tiles. Each tile has one bit per sprite, packed into 32-bit words. The tiles pass runs a group per tile. It finds the farthest opaque depth in the tile, so the bin pass can reject sprites hidden behind solid geometry, and it clears the tile’s bits.
// The smallest reversed depth in the tile, as its bits: depths are not negative, so their bits order as they do.
groupshared uint farthest;
// Each thread takes 4x4 pixels of the tile.
[numthreads(8, 8, 1)]
void cs_main(uint3 group : SV_GroupID, uint3 thread : SV_GroupThreadID, uint index : SV_GroupIndex) {
// ... farthest = asuint(1.0f); barrier; each thread takes the min over its 4x4 pixels ...
InterlockedMin(farthest, asuint(WaveActiveMin(nearest)));
uint tile = group.y * push.tiles.x + group.x;
RWByteAddressBuffer bins = cn_rw_buffer(push.bins);
for (uint w = index; w < push.words; w += 64) {
bins.Store((tile * push.words + w) * 4, 0);
}
// ... barrier; thread 0 stores farthest ...
}
Two small details here. Cinnabar uses reversed Z with the far plane at infinity, so the farthest surface has the smallest depth value, and the reduction is a min. Group-shared atomics don’t take floats, but non-negative IEEE floats order the same way as their bit patterns, so InterlockedMin on asuint works. A wave-level min first means only one atomic per wave reaches shared memory.
Binning
Each thread projects its sprite with the camera’s jittered projection, the same one the meshes use, so temporal AA resolves sprite edges with everything else. It writes a 64-byte Screen_Sprite with everything the per-pixel passes need, then ORs its bit into every tile its square covers where it can be in front of the opaque surface.
The bounds test is conservative. A rotated square marks every tile its axis-aligned bounds overlap, so a 45° sprite marks some corner tiles it doesn’t touch, and the depth test uses the sprite’s nearest possible point. Both errors only cost a little wasted work per pixel. The per-pixel passes do the exact test.
Moments, per pixel
This is where the tile size pays off. The per-pixel passes run 8×8 groups, and a 32-pixel tile holds exactly 4×4 of them, so every thread in a group reads the same bin words. WaveReadLaneFirst makes the load wave-uniform: the compiler can keep the bits in a scalar register, and the while loop over set bits runs in lockstep with no divergence from the walk itself. The loop stops at the words that the frame’s count reaches, not its capacity, so reserved room that went unused costs nothing here.
uint words = used_words();
for (uint w = 0; w < words; w++) {
// Every thread of the group is in the tile, so the loop is uniform.
uint bits = WaveReadLaneFirst(bins.Load((tile * push.words + w) * 4));
while (bits != 0) {
uint index = w * 32 + firstbitlow(bits);
bits &= bits - 1;
Screen_Sprite s = load_screen_sprite(index);
float2 q = sprite_quad(s, float2(pixel) + 0.5);
if (s.depth >= opaque || any(abs(q) > 1)) { continue; }
if (overdraw) { b0 += 1; continue; }
float alpha = sprite_alpha(s, sprite_texel(s, q, frame.sampler_index), opaque);
float depth = optical_depth(alpha);
b0 += depth;
moments += depth_moments(s.z, depth);
}
}
// One read-modify-write per pixel, however many sprites covered it.
zeroth[pixel] = zeroth[pixel] + b0;
moment_sums[pixel] = moment_sums[pixel] + moments;
sprite_quad runs the rasterizer’s job in reverse. axis holds the rotation’s cosine and sine divided by the radius in pixels, so two dot products take a pixel center to the sprite’s local square. The same coordinates drive the texture lookup, or a procedural puff of when there is no texture. Soft particles are one multiply: alpha fades linearly over softness meters in front of the opaque depth, so a puff that sinks into the floor doesn’t cut a hard line.
This pass runs after the mesh moments pass, so the read-modify-write is a plain load and store. Nothing else writes those texels at the same time, and the render graph orders the passes.
Light, once per sprite
Shading each sprite at every covered pixel would repeat the full light loop dozens of times per pixel. Like the paper’s particle lightmap, Cinnabar lights a coarse 8×8 grid of points on each sprite instead, one group per sprite and one thread per point. The results go into two RGBA16_Float atlases, 128 sprites to a row:
- Direct light: every directional light, plus the punctual lights in the point’s cluster, each with its shadow maps. A puff that drifts into shadow darkens across itself, and a lamp behind a wall doesn’t light it.
- Indirect light: the GI’s diffuse result at the opaque surface behind the point, or the hemispheric ambient term when GI is off.
- Fog: the froxel fog between the point and the camera, using the same
fog_atlookup as the forward material template.
A sprite has no real normal. It faces the camera, and light can reach it from either side. The constant 0.5 is the mean of over the two faces, which treats the puff as an isotropic slab. The light texture’s alpha carries the fog transmittance, so the shade pass can dim the emission by the same fog without a second lookup.
The atlas rows round up to a power of two. The render graph’s transient textures are cached by description, so a sprite count that changes every frame doesn’t allocate a new atlas every frame.
Shade, per pixel
The shade pass walks the same bits as the moments pass, but now the moments are complete: they hold every glass surface and every sprite. Each sprite reconstructs the transmittance in front of itself, samples its light bilinearly from its 8×8 block, and adds to the transparent pass’s sum exactly as a glass fragment does.
After that, the composite divides the sum by its alpha and blends it over the scene using the total transmittance . It doesn’t know sprites exist.
Against weighted blended OIT
Köhler’s paper resolves its particles with weighted blended OIT (McGuire and Bavoil, 2013), so that’s the natural reference. WBOIT is also built from sums. It accumulates and , multiplies up a revealage , and composites the weighted average color over the scene by that revealage.
Cinnabar’s resolve has the same shape. Because , the composite’s is exactly WBOIT’s revealage, and the composite divides the sum by its alpha exactly as WBOIT does. The two methods differ in one factor per surface:
- Moment-based: , the transmittance in front of the surface, bounded from the four moments of everything at that pixel.
- Weighted blended: , a fixed falloff with view depth that doesn’t look at the other layers at all.
That makes a reference easy to build: swap the factor in the transparent mesh shader and the sprite shade pass, and leave everything else alone. With the swap behind a runtime flag, both resolves render the same frozen frame, so any difference between them comes from the resolve alone.
For a game that only draws smoke, WBOIT would give nearly the same picture for half the passes and about a third of the memory. Cinnabar pays for moments because particles share the frame with glass, ribbons and emissive effects, and that’s where the weighted average stops being a good stand-in for the order.
Summary
Most of the leverage comes from not building a second transparency system. Moment-based OIT is a pair of sums with a reconstruction step, and anything that can add to those sums takes part in the resolve. Writing the particle rasterizer in compute, binned by tile and bitmask, means each pixel does one read-modify-write per pass however many sprites cover it. Feeding it into the moments means the sprites sort against glass for free. And because the passes read their sprites from a buffer with a GPU-side count, a GPU particle simulation can feed them directly, with no readback.
References
- Johan Köhler. Practical Order Independent Transparency. Activision Research tech report ATVI-TR-16-02, 2016. The tiled compute particle rasterizer and the particle lightmap.
- Cedrick Münstermann, Stefan Krumpen, Reinhard Klein and Christoph Peters. Moment-Based Order-Independent Transparency. Proc. ACM Comput. Graph. Interact. Tech. 1(1), 2018. The moments, the warp and
transmittance_at. - Morgan McGuire and Louis Bavoil. Weighted Blended Order-Independent Transparency. JCGT 2(2), 2013. The resolve that Köhler’s paper uses, and the depth weight in the comparison.