GPU Mesh Voxelizer Part 4: Mesh Translation and Rotation

Rotated Voxel Monkey

In this article, we continue developing our GPU-based mesh voxelizer. The previous posts in the series are available here: part1part2, and part3. Previously our model had to be placed at the origin of the scene, with no rotation. We’ll start by fixing that by cleaning up our different space conversions. After that, we’ll refactor our voxels to use a custom struct rather than a generic float4. Doing so allows us to be more descriptive with our code and makes it easier to add new data.

Allowing rotation and translation

Until now, I’ve been fast and loose about local space versus world space. That’s because I’ve been working with a model centred at the origin with no rotation, which makes the two spaces identical. However, now seems like a good time to fix that. So, after experimenting with several possibilities, I decided to store the voxels in world space. That means we can do all the conversions in our compute shader, and the renderer will render them as-is. By the way, if you aren’t familiar with local space (also known as object space) versus world space, I’ll give a brief explanation.

The space you’re in determines the position of your origin. For example, every mesh has an origin (also knows as a pivot), where each vertex’s position is relative to that origin. However, your scene also has an origin. Once you drag a mesh into the scene, its origin becomes relative to the scene’s origin. That means the vertices are relative to the object’s origin relative to the scene’s origin. So, when positions are relative to the mesh’s pivot, we call that local space (or object space). Then, when positions are relative to the scene’s pivot, we call that world space.

So back to our voxels, as I mentioned, we’re going to store the voxel positions directly in world space. However, the triangles we’re checking against are in local space. So we’ll calculate each voxel’s AABB in world space, convert it to local space, and test for an intersection. Then if the test succeeds, we’ll store the world space position. So, we’ll pass the world space position of the minimum bounds to calculate the voxel position and the WorldToLocal matrix to convert it to local space for testing. Let’s fix the code.

void VoxelizeMeshWithGPU()
{
    Profiler.BeginSample("Voxelize Mesh (GPU)");

    Bounds bounds = _meshCollider.bounds;
    _boundsMin = bounds.min;
...

In the previous version, we converted bounds.min to local space. Let’s remove that conversion. Later, when we pass all the variables to the compute shader, let’s pass the WorldToLocal matrix.

var voxelizeKernel = _voxelizeComputeShader.FindKernel("VoxelizeMesh");
_voxelizeComputeShader.SetInt("_GridWidth", xGridSize);
_voxelizeComputeShader.SetInt("_GridHeight", yGridSize);
_voxelizeComputeShader.SetInt("_GridDepth", zGridSize);

_voxelizeComputeShader.SetFloat("_CellHalfSize", _halfSize);

_voxelizeComputeShader.SetMatrix("_WorldToLocalMatrix", transform.worldToLocalMatrix);
_voxelizeComputeShader.SetBuffer(voxelizeKernel, Voxels, _voxelsBuffer);
_voxelizeComputeShader.SetBuffer(voxelizeKernel, "_MeshVertices", _meshVerticesBuffer);
_voxelizeComputeShader.SetBuffer(voxelizeKernel, "_MeshTriangleIndices", _meshTrianglesBuffer);
_voxelizeComputeShader.SetInt("_TriangleCount", _meshFilter.sharedMesh.triangles.Length);

_voxelizeComputeShader.SetVector(BoundsMin, _boundsMin);

Next, in the compute shader, declare the _WorldToLocal matrix.

...
RWStructuredBuffer<Voxel> _Voxels;
StructuredBuffer<float3> _MeshVertices;
StructuredBuffer<int> _MeshTriangleIndices;

float4x4 _WorldToLocalMatrix;

int _TriangleCount;

float4 _BoundsMin;

float _CellHalfSize;
int _GridWidth;
int _GridHeight;
int _GridDepth;
...

Then, use it to convert the AABB center to local space.

...
void VoxelizeMesh(uint3 id : SV_DispatchThreadID)
{
    if (id.x >= _GridWidth || id.y >= _GridHeight || id.z >= _GridDepth) return;

    const float cellSize = _CellHalfSize * 2.0;

    const float3 centerPos = float3(
		id.x * cellSize + _CellHalfSize + _BoundsMin.x,
                id.y * cellSize + _CellHalfSize + _BoundsMin.y,
                id.z * cellSize + _CellHalfSize + _BoundsMin.z);

    AABB aabb;
    aabb.center = mul(_WorldToLocalMatrix, float4(centerPos.xyz, 1.0));
    aabb.extents = float3(_CellHalfSize, _CellHalfSize, _CellHalfSize);
...

So now, our voxel/triangle intersections are calculated in object space, but are voxels are stored in world space. It won’t look right, though, because our shaders expect them to be in local space when they render them. By the way, you might be wondering why I turn centerPos into a float4 before performing the matrix multiplication. That’s because the transformation matrix is 4 by 4, so you must multiply it by a vector of length 4. Additionally, because it’s a position, the w component of the vector is 1. If it were 0, it would be a direction because when performing the multiplication, the 0 would cancel out the translation portion of the transformation matrix.

Rendering the voxels in world space

Let’s start with the points in the Voxel.shader file. Previously we used a matrix to convert them to world space. Now we can skip that step. Let’s remove the _LocalToWorld matrix altogether.

StructuredBuffer<float4> _Positions;
float4 _Color;
float4 _CollisionColor;

v2f vert(uint vertex_id : SV_VertexID, uint instance_id : SV_InstanceID)
{
    v2f o;
    float4 pos = _Positions[instance_id];
    float isSolid = pos.w;
    o.color = lerp(_Color, _CollisionColor, isSolid);
    o.position = UnityWorldToClipPos(float4(pos.xyz, 1.0));
    o.size = 5;
    return o;
}

Let’s fix the VoxelBlock.shader next. This shader is a bit trickier because it’s a surface shader. If you surface shaders automatically generate a bunch of boilerplate code. The problem is that part of the generated code multiplies the vertex position by the unity_ObjectToWorld matrix. Since our vertices will already be in world space, we don’t want that. So we’ll fix it by overriding the unity_ObjectToWorld matrix by the identity matrix, which results in no transformation.

void setup()
{
    #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    float4 position = _Positions[unity_InstanceID];
    float isSolid = position.w;

    _Matrix = float4x4(
        1, 0, 0, position.x,
        0, 1, 0, position.y,
        0, 0, 1, position.z,
        0, 0, 0, 1
    );
    
    _Clip = -1.0 + isSolid;
    
    unity_ObjectToWorld = float4x4(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    );
    #endif
}

We make the switch in the setup function since that runs first. Now we can move and rotate our mesh freely without breaking the voxelizer. While we’re in the spirit of cleaning up, let’s refactor our voxels to use a custom struct.

Using a custom voxel struct

Until now, we’ve packed all our data into a float4. Let’s create a custom Voxel type instead. Doing so will make our code a little less cryptic and make it easier to add more data later if needed. First, I moved all my shaders and compute shader into the same folder, which simplifies including common files. Now, create a new VoxelCommon.cginc file. Here is where we’ll declare our Voxel type.

// VoxelCommon.cginc
struct Voxel
{
    float3 position;
    float isSolid;
};

And in VoxelizedMesh.cs, we’ll declare an identical type that lives in the C# side.

// VoxelizedMesh.cs
public struct Voxel
{
    Vector3 position;
    float isSolid;
}

Again on the C# side, let’s change everything from Vector4 to Voxel, rename _gridPoints to _voxels and finally renamed _Positions to _Voxels.

// VoxelizedMesh.cs
...
//Renamed VoxelGridPoints and _Positions
static readonly int Voxels = Shader.PropertyToID("_Voxels");

//Renamed _gridPoints
Voxel[] _voxels;
...
// VoxelizedMesh.cs
...
if (_voxels == null || _voxels.Length != xGridSize * yGridSize * zGridSize ||
    _voxelsBuffer == null)
{
    _voxels = new Voxel[xGridSize * yGridSize * zGridSize];
    resizeVoxelPointsBuffer = true;
}
...

In the shaders, include VoxelCommon.cginc file and rename the _Positions buffer.

// VoxelizeMesh.compute
#pragma kernel VoxelizeMesh
#include "VoxelCommon.cginc"

RWStructuredBuffer<Voxel> _Voxels;
StructuredBuffer<float3> _MeshVertices;
StructuredBuffer<int> _MeshTriangleIndices;
...
// Voxel.shader
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "VoxelCommon.cginc"
...
StructuredBuffer<Voxel> _Voxels;
float4 _Color;
float4 _CollisionColor;
// VoxelBlock.shader
#pragma surface surf Standard vertex:vert fullforwardshadows addshadow
#pragma instancing_options procedural:setup
#include "VoxelCommon.cginc"
...
#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
StructuredBuffer<Voxel> _Voxels;
float4x4 _Matrix;
#endif

Finally, modify all the shaders to store position in .position and whether the voxel is solid in .isSolid.

// VoxelizeMesh.compute
const int voxelIndex = id.x + _GridWidth * (id.y + _GridHeight * id.z);

const float3 position = float3(_BoundsMin.x + id.x * cellSize,
                               _BoundsMin.y + id.y * cellSize,
                               _BoundsMin.z + id.z * cellSize);

_Voxels[voxelIndex].position = position;
_Voxels[voxelIndex].isSolid = intersects ? 1.0 : 0.0;
// Voxel.shader
v2f vert(uint vertex_id : SV_VertexID, uint instance_id : SV_InstanceID)
{
    v2f o;
    float3 pos = _Voxels[instance_id].position;
    float isSolid = _Voxels[instance_id].isSolid;
    o.color = lerp(_Color, _CollisionColor, isSolid);
    o.position = UnityWorldToClipPos(float4(pos.xyz, 1.0));
    o.size = 5;
    return o;
}
// VoxelBlock.shader
void setup()
{
    #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    float3 position = _Voxels[unity_InstanceID].position;
    float isSolid = _Voxels[unity_InstanceID].isSolid;

    _Matrix = float4x4(
        1, 0, 0, position.x,
        0, 1, 0, position.y,
        0, 0, 1, position.z,
        0, 0, 0, 1
    );
    
    _Clip = -1.0 + isSolid;
    
    unity_ObjectToWorld = float4x4(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    );
    #endif
}

Great! All that refactoring will make our lives easier in the future. With that out of the way, I’m ready to start filling the inside voxels. However, I haven’t found my ideal solution yet, and this article is already long, so we’ll cover that next time.

Rotated Voxel Monkey

The complete project is here on GitHub. If you like this kind of thing, join my mailing list to be notified when the next part is released.

Leave A Comment