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.
|
|
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 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

| 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

| 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.

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:
|
|
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:
|
|
The FPS and average GPU walltime is measured at three locations on a Macbook Air (M2):
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

| Metric | Before | After | Uplift |
|---|---|---|---|
| FPS | 21.07 | 60.04 | +184.93% |
| Average GPU walltime | 84.48 ms | 14.59 ms | −82.73% |
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:
|
|
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

3D voxel storage texture
2026-07-10
Commit: 67bf4a0
A 3D texture is used for storing the voxel grid.
|
|
Reading voxels from the texture is more straightforward than the byte buffer:
|
|
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:
|
|
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:
|
|
monu2.vox from ephtracy/voxel-model:

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.
