Marching Cubes
Voxels are great for physics and storage, but they look terrible. To make a game look realistic, we need to convert the blocky grid back into a smooth, continuous polygon mesh. The industry standard algorithm for this is called Marching Cubes.
The Isosurface:
0.5). 3. We want to draw a 3D skin exactly where the grid's value equals 0.5. 4. The algorithm "marches" through every single cube in the grid, one by one. 5. If it finds a cube where the skin should pass through, it generates 1 to 5 tiny triangles inside that cube.1# Scalar Fields to Meshes2def extract_isosurface(voxel_grid, threshold):3 """The magic behind Medical MRI scans and Minecraft mods."""45 mesh = Mesh()67 # 1. Loop through every single box in the grid8 for voxel in voxel_grid:9 # 2. Does the surface pass through this specific box?10 if voxel_contains_surface(voxel, threshold):1112 # 3. If yes, generate triangles for just this box!13 triangles = marching_cubes(voxel)14 mesh.add(triangles)1516 return mesh
To figure out what the triangles should look like inside a single cube, we only need to look at the cube's8 Corners. The algorithm uses clever Binary Math (Bitwise Operations) to classify the cube lightning-fast.
The 8-Bit Index:
10010110). 4. An 8-bit number has exactly 256 possible values (from 0 to 255). 5. This means there are exactly 256 possible ways a surface can intersect a cube!1# Classifying the 8 Corners2def get_cube_index(cube_corners, threshold):3 """Generates an 8-bit binary number (0 to 255)."""45 cube_index = 067 # Check all 8 corners of the cube8 for i in range(8):9 corner_value = cube_corners[i].value1011 # If the corner is INSIDE the surface12 if corner_value < threshold:13 # Flip the i-th bit to 1!14 # Example: 1 << 0 = 1, 1 << 1 = 2, 1 << 2 = 4...15 cube_index |= (1 << i)1617 return cube_index
Calculating exactly where a plane intersects a cube requires heavy 3D math. If we do that for 1 million cubes 60 times a second, the computer will crash. Instead, we cheat.
Pre-computed Answers:
142). 4. Instead of doing math, the computer just reads Array[142] and instantly gets the list of cube edges that need to be connected to form the triangles!1# The Lookup Table (LUT)2def lookup_triangles(cube_index):3 """Avoids calculating geometry in real-time."""45 # We pre-calculate all 256 possible triangle configurations6 # and store them in a massive Array/Dictionary when the game loads.78 # TRIANGLE_TABLE[0] = [] # Completely outside, no triangles9 # TRIANGLE_TABLE[255] = [] # Completely inside, no triangles10 # TRIANGLE_TABLE[1] = [ [0, 8, 3] ] # One corner cut off1112 # We simply use our 8-bit index to look up the answer instantly!13 intersected_edges = TRIANGLE_TABLE[cube_index]1415 return intersected_edges
The Lookup Table tells us which edges the skin passes through. But it doesn't tell us whereon the edge it passes through. If we just place vertices at the exact center of every edge, our mesh will look jagged (like Minecraft).
Linear Interpolation (Lerp):
0.1. Corner B has a value of 0.9. 3. Our Isosurface Threshold is 0.5. 4. Since 0.5 is exactly halfway between 0.1 and 0.9, we place the vertex exactly in the middle of the physical edge! 5. If Corner A was 0.4 instead, the threshold (0.5) would be much closer to Corner A. We slide the vertex closer to Corner A. This creates perfectly smooth curves!1# Finding the Exact Intersection2def interpolate_edge(corner_A, corner_B, threshold):3 """Slides the vertex along the edge for a perfect fit."""45 # 1. Get the scalar values at both ends of the edge6 val_A = corner_A.value7 val_B = corner_B.value89 # 2. How far along the edge is our threshold?10 # e.g., if A=0.2, B=0.8, and threshold=0.5, then ratio = 0.5 (exactly halfway)11 ratio = (threshold - val_A) / (val_B - val_A)1213 # 3. Use that ratio to mix the XYZ coordinates14 x = corner_A.x + ratio * (corner_B.x - corner_A.x)15 y = corner_A.y + ratio * (corner_B.y - corner_A.y)16 z = corner_A.z + ratio * (corner_B.z - corner_A.z)1718 return Point(x, y, z)
We now have the exact XYZ coordinates of all the intersection points floating on the edges of our cube. The final step inside this cube is to connect those dots to form physical polygon Triangles.
Winding Order:
1# Connecting the Dots2def generate_cube_geometry(intersected_edges, corner_values):3 """Turns the edge dots into solid triangles."""45 triangles = []67 # The Lookup Table returns edges in sets of 3.8 # e.g., [Edge_0, Edge_8, Edge_3, Edge_1, Edge_9, Edge_2]910 # We step through the list 3 edges at a time11 for i in range(0, len(intersected_edges), 3):1213 # Interpolate exact XYZ for the 3 edges14 pt1 = interpolate_edge(intersected_edges[i])15 pt2 = interpolate_edge(intersected_edges[i+1])16 pt3 = interpolate_edge(intersected_edges[i+2])1718 # Connect them to form a triangle!19 triangles.append(Triangle(pt1, pt2, pt3))2021 return triangles
If we just stop at generating triangles, the mesh will look faceted and low-poly, because the lighting will be perfectly flat across each triangle. We want it to look buttery smooth. We do this by calculatingVertex Normals based on the grid data.
Central Differences:
1# Calculating Smooth Normals2def calculate_normal(x, y, z):3 """How to make a jagged mesh look perfectly smooth."""45 # 1. We look at the grid values immediately around our point6 # We check +X and -X, +Y and -Y, +Z and -Z78 # 2. We calculate the difference (Gradient)9 nx = value_at(x - 1, y, z) - value_at(x + 1, y, z)10 ny = value_at(x, y - 1, z) - value_at(x, y + 1, z)11 nz = value_at(x, y, z - 1) - value_at(x, y, z + 1)1213 # 3. Combine them into a Vector and Normalize it (length = 1.0)14 normal = Vector3(nx, ny, nz).normalize()1516 return normal
We have reached the end of the pipeline. We started with a messy Point Cloud, discretized it intoVoxels, mapped it to a solid Scalar Field, and finally used Marching Cubesto generate a brand new, perfectly manifold, smooth 3D polygon mesh.
The Geometry Pipeline:
1# Full Marching Cubes Pipeline2def generate_bunny_mesh(voxel_grid, threshold):3 """From blocky Voxels back to a smooth Mesh."""45 mesh = Mesh()67 # Run the algorithm on the grid8 for voxel in voxel_grid:9 # Get the 8 corner values10 corners = get_corners(voxel)1112 # Get the 8-bit index13 idx = get_cube_index(corners, threshold)1415 # Look up which edges are cut16 edges = lookup_triangles(idx)1718 # Interpolate exact points and form triangles19 triangles = generate_cube_geometry(edges, corners)2021 mesh.add(triangles)2223 # Calculate smooth lighting normals24 mesh.calculate_smooth_normals()2526 return mesh