Voxel Raytracer Devlog

Shader occupancy optimization 2

2026-08-07

Commit: 2975a50

Another optimization pass is done to the shader to improve occupancy. Grid cell coordinates are converted to int16 (since the largest possible grid extent is 1024) and the loop counters are eliminated.

Before:

  • FS Occupancy: 36.05 %
  • Register allocation: 88

After:

  • FS Occupancy: 39.71 %
  • Register allocation: 75

Additionally, ALU instruction count dropped by 10 %.

sponza-1024-1

Metric Before After Uplift
FPS 111.59 137.20 +22.94%
Average GPU walltime 13.31 ms 10.58 ms −20.57%

sponza-1024-2

Metric Before After Uplift
FPS 142.99 142.66 −0.23%
Average GPU walltime 5.49 ms 5.07 ms −7.67%

The improvement to the first scene is almost as large as the hybrid renderer update itself 🎉

Converting the branchless trick inside the loop to a big if-else eliminates the axis mask and further reduces the register allocation, but this made the framerate worse. The branchless trick is worth keeping.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Converted this
float3 const axis_mask = step(tnext, min(tnext.yzx, tnext.zxy));
tnext += axis_mask * tdelta;
brick_cell += int3(axis_mask) * step_dir;

// To this
if (tnext.x < tnext.y)
{
    if (tnext.x < tnext.z)
    {
        brick_cell.x += step_dir.x;
        tnext.x += tdelta.x;
    }
    else
    {
        brick_cell.z += step_dir.z;
        tnext.z += tdelta.z;
    }
}
else if (tnext.y < tnext.z)
{
    brick_cell.y += step_dir.y;
    tnext.y += tdelta.y;
}
else
{
    brick_cell.z += step_dir.z;
    tnext.z += tdelta.z;
}

Rasterize brick AABBs

2026-08-06

Commit: c5b6bf4

A hybrid renderer is implemented, significantly improving rendering performance. A billion voxels can now be rendered in real time 🎉 Each frame

  • quads are generated for camera-facing brick faces
  • the brick quads are rasterized into a depth buffer
  • ray tracing uses the rasterized depth as the ray origin for DDA

Here is a visualization of the rasterized brick AABB depth buffer along with the final render using ephtracy’s monu9.vox from ephtracy/voxel-model.

rasterized

ray-traced

Rasterized brick AABBs allow skipping over empty space much faster than traversing through bricks with DDA. While not necessary for rendering static models here, the quads are regenerated every frame to support dynamic voxel grids for future applications.

The higher-resolution 1024x1024x1024 voxelization of the Sponza Atrium scene is now used to measure performance. Measurements are done using a Macbook Air (M2), on a 3840 x 1600 144 Hz monitor.

sponza-1024-1

sponza-1024-1

Metric Before After Uplift
FPS 86.51 111.59 +28.99%
Average GPU walltime 18.51 ms 13.31 ms −28.07%

sponza-1024-2

sponza-1024-2

Metric Before After Uplift
FPS 104.64 142.99 +36.64%
Average GPU walltime 14.42 ms 5.49 ms −61.91%

Some notes on performance:

  • when close to the model surface, a tighter fitting brick size (such as 4x4x4) improves performance
  • when viewing the model from afar, a loose brick size (the current 8x8x8) is better
  • rendering slows down significantly when looking along a surface inside a brick, as the DDA algorithm has to do the full brick + voxel traversal as before

Revert “Voxel occupancy mask”

2026-08-05

Commit: 8cb6aae

Reverts the previous change. It was cherry-picked from an exploration branch, and when systematically measuring the performance against the current parent commit, it performed worse in every scene. Oops.

The smaller brick granularity results in more bricks being visited along the ray, which slows down rendering especially when viewing the model from afar.

It performs better than the current implemention with VX_BRICK_EXT set to 4 in some scenes, so there may be merit to circle back to this idea in the future.

Voxel occupancy mask

2026-08-04

Commit: fdf44f3

The brick extent is reduced to 4x4x4. A voxel occupancy mask is introduced alongside the voxel grid:

1
2
3
4
Texture3D<uint>        voxels : register(t0, space2);
-Texture3D<uint>        bricks : register(t1, space2);
+Texture3D<uint2>       voxel_masks : register(t1, space2);
StructuredBuffer<uint> palette_rgba : register(t2, space2);

Each brick contains 64 voxels. If we store voxel occupancy using a single bit, a single 64-bit integer is enough to store the occupancy of a single brick and quickly skip over the entire brick without an additional data structure:

1
2
3
4
5
6
7
8
9
uint2 const voxel_mask = voxel_masks.Load(int4(brick_cell, 0)).rg;
if (any(voxel_mask != uint2(0u, 0u)))
{
    uint const voxel = trace_brick(origin, dir, inv_dir, tdelta, brick_cell, voxel_mask);
    if (voxel > 0u)
    {
        return voxel;
    }
}

trace_brick no longer needs to lookup occupancy in a texture, since it can now just use the voxel_mask value we loaded. If we find an occupied voxel along the ray, we can then look up the voxel grid and return the palette color:

1
2
3
4
5
6
7
8
uint const voxel_idx = (uint)(local_cell.x + local_cell.y * VX_BRICK_EXT +
                              local_cell.z * VX_BRICK_EXT * VX_BRICK_EXT);
uint const word = voxel_mask[voxel_idx >> 5u];
uint const bit = 1u << (voxel_idx & 31u);
if ((word & bit) != 0u)
{
    return voxels.Load(int4(brick_min + local_cell, 0)).r;
}

Shader occupancy optimization

2026-08-01

Commits:

Fewer variables are carried over the loops and tdelta was deduplicated, reducing the register allocation by 20. In particular, using just the min cell inside both nested loops has a large impact:

1
2
3
4
5
6
7
8
9
// Instead of this:
int3 min_cell, max_cell;
if (any(cell < min_cell) || any(cell > max_cell))
{

// Do this:
int3 local_cell;
if (any((uint3)local_cell >= (uint)VX_BRICK_EXT))
{

The middle two commits clean up some unnecessary work. The occupancy metrics for the 2-level DDA shader improve significantly:

  • FS occupancy counter: 42.37 % (up from 35.43 %)
  • Register allocation: 72 (down from 92)

A modest occupancy bump! Here is how the renderer performs in practise on a Macbook Air (M2). This time, I measured the numbers on a 144 Hz ultrawide monitor running at 3840 x 1600:

monu2-1

Metric Before After Uplift
FPS 143.20 144.00 +0.56%
Average GPU walltime 6.58 ms 5.37 ms −18.32%

sponza-256-1

Metric Before After Uplift
FPS 59.78 72.83 +21.83%
Average GPU walltime 28.39 ms 22.51 ms −20.71%

sponza-256-2

Metric Before After Uplift
FPS 109.53 143.94 +31.42%
Average GPU walltime 13.40 ms 7.32 ms −45.33%

The smaller monu2 scene (128³ resolution) performs comfortably at 144 Hz refresh rates and beyond. But nearby voxels in the sponza-256-1 scene/view are still expensive to render.

Interlude: performance

2026-07-31

This update does not contain any changes in vxray.

The voxel ray tracing has stable performance now, but could it run better? The Metal profiler reveals the following story when rendering the same scene and view used previously in Naive DDA.

performance-1

In particular, the shader’s occupancy is rather low:

  • FS occupancy counter: 35.43 %
  • Register allocation: 92

Before 2-level DDA, these numbers were:

  • FS occupancy counter: 71.39 %
  • Register allocation: 42

The DDA shader became a lot larger due to the nested loops. More state is being carried over the loop in registers. Here is a great post explaining why the occupancy (and register allocation) matters: https://interplayoflight.wordpress.com/2020/11/11/what-is-shader-occupancy-and-why-do-we-care-about-it/ In short, the GPU is able to schedule fewer groups of work on an execution unit concurrently, so memory stalls affect the performance negatively.

Reducing the size of the live state could bring the occupancy back up and improve the performance. But the ALU usage is also very high indicating that a lot of work is still being done despite the 2-level grid.

Fullscreen triangle

2026-07-27

Commit: dc0422d

Since per-pixel voxel ray tracing is expensive work, the fullscreen quad is replaced with a fullscreen triangle to avoid duplicated ray tracing along the quad edge. This is a good blog post explaining the method.

While it’s more optimal, there is no measurable difference at the granularity used here.

2-level DDA

2026-07-24

Commit: ed4004c

An additional brick grid is introduced, indicating whether an 8 x 8 x 8 group of voxels (a brick) is occupied. Like the voxel grid, the brick grid is a 3D texture:

1
2
Texture3D<uint> voxels : register(t0, space2);
Texture3D<uint> bricks : register(t1, space2);

And similarly, tracing the brick grid is identical to the voxel grid. However, when a brick is occupied, the brick is also traced using DDA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    for (int i = 0; i < 3 * brick_grid_ext; ++i)
    {
        if (any(brick_cell < min_brick_cell) || any(brick_cell > max_brick_cell))
        {
            return 0u;
        }

        if (brick_at(brick_cell) > 0u)
        {
            uint const voxel = trace_brick(origin, dir, inv_dir, inv_abs_dir, brick_cell);
            if (voxel > 0u)
            {
                return voxel;
            }
        }

        float3 const axis_mask = step(tnext, min(tnext.yzx, tnext.zxy));
        tnext += axis_mask * tdelta;
        brick_cell += int3(axis_mask) * step_dir;
    }

The FPS and average GPU walltime is measured at three locations on a Macbook Air (M2):

sponza-256-1

sponza-256-1

Metric Before After Uplift
FPS 60.02 60.05 +0.04%
Average GPU walltime 14.80 ms 14.63 ms −1.16%

sponza-256-2

sponza-256-2

Metric Before After Uplift
FPS 21.07 60.04 +184.93%
Average GPU walltime 84.48 ms 14.59 ms −82.73%

monu2-1

monu2-1

Metric Before After Uplift
FPS 46.79 60.02 +28.29%
Average GPU walltime 32.50 ms 14.23 ms −56.21%

Funnily enough, empty space was the heaviest to render before, as the kernel visited every single voxel along the ray before exiting.

With the brick grid in place, the rendering performance is now very steady, if not amazingly performant yet.

Camera presets

2026-07-22

Commit: f9e89c4

A simple camera preset file is introduced. Pressing F2 prints the current camera orientation as text:

1
2
3
position = 49.3564072 11.355504 87.3916855
yaw = 1.79596877
pitch = 0.212940127

Passing the text as a file to vxray spawns the camera at that position and orientation. This makes it easier to compare the performance at fixed locations with different models.

Voxelized test scenes

2026-07-21

This update does not contain changes in vxray.

Using my fork of voxquant, the Sponza Atrium mesh is voxelized into Mavigavoxel .vox files at different resolutions. A small modification is applied to the voxquant tool. Color palettes are padded up to 256 entries, in order to satisfy conservative Magicavoxel importers like the one I am using.

Here’s how I replaced the voxquant tool that I installed using cargo with my fork, cloned locally:

cargo install --path crates/voxquant --force

The Sponza Atrium scene at a 1024 grid resolution

voxel sponza

3D voxel storage texture

2026-07-10

Commit: 67bf4a0

A 3D texture is used for storing the voxel grid.

1
2
-ByteAddressBuffer      voxels : register(t0, space2);
+Texture3D<uint>        voxels : register(t0, space2);

Reading voxels from the texture is more straightforward than the byte buffer:

1
2
3
4
5
6
7
8
9
-uint voxel_at(int3 const p)
-{
-    int const  i = p.x + p.y * uniforms.grid_ext + p.z * uniforms.grid_ext * uniforms.grid_ext;
-    uint const byte_idx = (uint)i;
-    uint const word = voxels.Load(byte_idx & ~3u);
-    uint const byte_shift = 8u * (byte_idx & 3u);
-    return (word >> byte_shift) & 255u;
-}
+uint voxel_at(int3 const p) { return voxels.Load(int4(p, 0)).r; }

The voxels are laid out in memory as v = x + y * grid_extent + z * grid_extent². This makes it difficult to utilize the cache, as the ray tracing may have to access neighboring voxels grid_extent² apart from each other in memory. Using a 3D texture should help as it is designed for spatially adjacent data access.

The performance was measured again on a Macbook Air (M2).

Metric Before After Delta
FPS 37.9 41.8 10.3%
Avg on-GPU 41.94 ms 35.99 ms -14.2%

Using a 3D texture results in less time spent on the GPU, although the improvement is not huge at this stage. A huge number of voxels are still being traversed by the algorithm.

Pack voxels into byte buffer

2026-07-08

Commit: 25b6fd2

Voxels are stored as bytes instead of wasteful uints:

1
2
-StructuredBuffer<uint> voxels : register(t0, space2);
+ByteAddressBuffer      voxels : register(t0, space2);

Voxels are palette indices, with an index of 0 indicating empty space (hence, in Magicavoxel files, only palette indices > 0 are valid). The palette has 255 entries, so using a byte per voxel is sufficient.

Although peak memory consumption is reduced, there is no impact on runtime performance on a Macbook Air (M2):

Metric Before After Delta
FPS 39.97 40.17 +0.20 / +0.5%
On-GPU avg 37.07 ms 39.22 ms +2.15 ms / 5.8% slower
Peak app memory 115.80 MiB 104.09 MiB -11.70 MiB / -10.1%
Metal allocated size 61.50 MiB 61.50 MiB 0.00 MiB

Naive DDA

2026-07-04

Commit: 0014bd0

Render Magicavoxel models with naive DDA.

  • Adds ogt_vox.h for parsing Magicavoxel files.
  • Introduces DDA rendering in the pixel shader.

Here’s the gist of the DDA algorithm. For a ray with origin o and direction d, the point at t along the ray is given by

p = o + t * d

The first x-intersection is given by

t = (ceil(o.x) - o.x) / d.x

and similarly for the first y-intersection:

t = (ceil(o.y) - o.y) / d.y

Subsequent x and y-axis intersections are 1 unit apart: t = (ceil(o.x) - o.x) / d.x + (1 / d.x). The DDA algorithm keeps track of which intersection occurred first, and steps along the axis in that direction. The algorithm loops through the grid cells until a stopping condition is met, such as exiting the grid.

Here is a gist of the DDA algorithm, implemented in the pixel shader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    float3 const entry = origin + tmin * dir;
    // ...
    int3 const   start_cell = clamp(int3(entry), min_cell, max_cell);
    // ...
    float3 const next = float3(start_cell) + max(float3(step_dir), float3(0.0, 0.0, 0.0));
    int3         cell = start_cell;
    float3       tnext = (next - entry) * inv_dir;
    float3 const tdelta = inv_abs_dir;
    // ...
    for (int i = 0; i < 3 * uniforms.grid_ext; ++i)
    {
        if (any(cell < min_cell) || any(cell > max_cell))
        {
            return 0u;
        }

        uint const v = voxel_at(cell);
        if (v > 0u)
        {
            return v;
        }

        // Branchless trick: https://www.shadertoy.com/view/4dX3zl
        float3 const axis_mask = step(tnext, min(tnext.yzx, tnext.zxy));
        tnext += axis_mask * tdelta;
        cell += int3(axis_mask) * step_dir;
    }
    // ...

monu2.vox from ephtracy/voxel-model:

naive-dda

Hello, SDL!

2026-06-28

Commit: 03feb13

All projects have to start somewhere. The rendering infrastructure is set up to render a fullscreen quad.

  • SDL_GPU is used as the rendering abstraction. The Metal backend is used on macOS, and the Vulkan backend on Windows.
  • SDL_shadercross is used to compile/transpile HLSL shaders to SPIR-V and Metal.
  • Shaders are compiled at build time and embedded as byte arrays into the source code.

A window opens displaying the UV coordinates as the color.

fullscreen-quad

Contents