Method Comparisons

The field is only known at the grid corners, and every extraction method reconstructs the surface from those samples. They all answer the same two questions: where do the mesh vertices go, and how are they connected? Each method’s page explains its own answer. The comparisons below put the answers side by side, on fields of a few cells where every vertex is visible.

import torch
import isoext
from isoext import viewer
from isoext.sdf import get_sdf_normal, project_to_surface

Two Families

Every method places its geometry using the edge crossings: wherever a grid edge runs from an inside corner to an outside one, the surface crosses it, at a point interpolated from the two corner values. The primal methods, Marching Cubes and Marching Tetrahedra, put their vertices at the crossings themselves and cut triangles out of each cell by table lookup. The dual methods put one vertex inside each crossed cell – Surface Nets at the centroid of the cell’s crossings, Dual Contouring at the point that best fits the SDF samples or the normals, and Dual Marching Cubes one per surface piece rather than one per cell – and join them with one quad around each crossed edge.

The families are easy to tell apart on the smallest possible sphere. One inside sample on 2x2x2 cells comes out as an octahedron under marching cubes, with its 6 vertices on the grid edges, and as a cube under surface nets, with its 8 vertices inside the cells. The two meshes are duals of each other:

tiny = isoext.UniformGrid([3, 3, 3])
tiny.set_values(tiny.get_points().norm(dim=-1) - 0.7)

v, f = isoext.marching_cubes(tiny)
print(f"marching_cubes: {v.shape[0]} vertices, {f.shape[0]} triangles")
viewer.embed(v, f, wireframe=True, color="orange", height=320, grid=tiny)
marching_cubes: 6 vertices, 8 triangles
v, f = isoext.surface_nets(tiny)
print(f"surface_nets:   {v.shape[0]} vertices, {f.shape[0]} triangles")
viewer.embed(v, f, wireframe=True, color="seagreen", height=320, grid=tiny)
surface_nets:   8 vertices, 12 triangles

How the Methods Relate

Method

Vertices

Faces

Ambiguities

Marching cubes

on grid edges

per cell, from a table

resolved by the variant

Marching tetrahedra

on tetrahedron edges

per tetrahedron

none

Surface nets

one per cell, at the centroid

one quad per crossed edge

none

Dual contouring

one per cell, fitted to the SDF samples or the normals

one quad per crossed edge

none

Dual marching cubes

one per patch, centroid or QEF

one quad per crossed edge

resolved by the variant

All of them run on the same grids, share the same edge crossings, and answer the same two questions from the top of the page. The primal methods answer with vertices at the crossings themselves, so the mesh interpolates the samples exactly. The dual methods answer with faces there instead, which tends to give better-shaped triangles and, given SDF samples or good normals, sharp features – at the cost of vertex positions that are estimated rather than interpolated.

The guarantees differ too. Every method produces a closed mesh when the surface stays inside the grid (lorensen excepted: its tables can crack). Manifoldness is the primal family’s structural advantage: every edge borders exactly two triangles and the mesh never touches itself. The dual methods can fail that in two ways. A pinch is two surface sheets forced through a single vertex: a cell crossed by several sheets gets only one vertex in surface nets and dual contouring, so the sheets meet there in an hourglass. A tunnel edge is four quads sharing one edge, which happens when the surface passes through a cell face twice. Dual marching cubes eliminates the pinches (one vertex per sheet) but keeps the tunnel edges.

The criteria below follow the comparison table of Shen et al. [2023], which surveys these methods as bases for gradient-based mesh optimization; each entry here is backed by a demonstration on this page or a measurement in the test suite.

Method

Sharp features

Uniform triangles

Intersection-free

Manifold

Marching cubes

no

no

yes

yes

Marching tetrahedra

no

no

yes

yes

Surface nets

no

yes

yes

pinches and tunnel edges

Dual contouring

yes

yes

no

pinches and tunnel edges

Dual marching cubes

with normals

yes

yes

tunnel edges only

Dual contouring gets sharp features from normals on the intersection (ju, the default) or, with its carrera variant, from the SDF samples alone; dual marching cubes needs the normals, as shown below. Uniform triangles refers to the triangle shapes: the marching methods produce slivers wherever a crossing sits close to a corner (their worst triangles have edges differing by an order of magnitude), while the dual methods’ vertices sit centrally in their cells. Intersection-free fails for dual contouring because its vertices may leave their cells: for ju with clamp=False, and always for carrera (see Dual Contouring).

Seeing the Failure Cases

Each failure fits on a tiny grid. In the scenes below the same field is extracted three ways, side by side: marching cubes in blue on the left, dual contouring in gold in the middle, dual marching cubes in red on the right.

Hide code cell source

def compare(grid, its=None, side="front", height=320, dc_method="ju"):
    span = torch.tensor([2.8, 0.0, 0.0], device="cuda")
    lo = grid.get_points().reshape(-1, 3).amin(dim=0)
    hi = grid.get_points().reshape(-1, 3).amax(dim=0)
    methods = [
        (isoext.marching_cubes, "steelblue", -1),
        (isoext.dual_contouring, "goldenrod", 0),
        (isoext.dual_marching_cubes, "crimson", 1),
    ]
    results = []
    for fn, color, k in methods:
        kwargs = {"method": dc_method} if k == 0 else {}
        if its is not None and k != -1:
            kwargs["intersection"] = its
        v, f = fn(grid, **kwargs)
        print(f"{fn.__name__:20s} {len(v):3d} vertices, {len(f):3d} triangles")
        results.append((v + k * span, f, color, k))

    center = (lo + hi) / 2

    def draw(s):
        for v, f, color, k in results[1:]:
            viewer.add_mesh(s, v, f, color=color, flat_shading=True, side=side, name=f"/m{k}")
        for fn, _, k in methods:
            viewer.add_label(s, fn.__name__, (float(center[0]) + k * 2.8, float(center[1]), float(hi[2]) + 0.4))
        for k in (-1, 0, 1):
            g = isoext.UniformGrid(
                list(grid.get_values().shape), aabb_min=(lo + k * span).tolist(), aabb_max=(hi + k * span).tolist()
            )
            g.set_values(grid.get_values())
            viewer.add_grid(s, g, name=f"/g{k}")

    v0, f0, c0, _ = results[0]
    frame = torch.stack([lo - span - 0.3, hi + span + 0.3])
    return viewer.embed(v0, f0, color=c0, flat_shading=True, side=side, draw=draw, frame=frame, height=height)

Pinches

Three shallow blobs at mutually diagonal corners of the center cell. Marching cubes extracts three separate pieces, and so does dual marching cubes: the center cell holds three patches and gets three vertices. Dual contouring can only give each cell one vertex, so the sheets pinch together into a single piece – it has five fewer vertices than dual marching cubes, and each missing vertex is a pinch point:

t = 1 / 3
blobs = isoext.UniformGrid([4, 4, 4])
p = blobs.get_points()
centers = torch.tensor([[-t, -t, -t], [-t, t, t], [t, -t, t]], device="cuda")
blobs.set_values((p[..., None, :] - centers).norm(dim=-1).amin(-1) - 0.25)
compare(blobs)
marching_cubes        18 vertices,  24 triangles
dual_contouring       19 vertices,  36 triangles
dual_marching_cubes   24 vertices,  36 triangles

Sharp Features

A tilted box corner (Dual Contouring shows how the QEF finds it). With refined crossings and SDF normals, both dual methods put a vertex exactly on the corner point; marching cubes cannot leave the grid edges and chamfers it, leaving its nearest vertex a full cell away from the true corner:

Hide code cell source

planes = torch.nn.functional.normalize(
    torch.tensor(
        [
            [1.0, 0.3, -0.2],
            [-0.25, 1.0, 0.3],
            [0.2, -0.3, 1.0],
        ],
        device="cuda",
    ),
    dim=-1,
)
corner = torch.tensor([0.1, 0.15, 0.2], device="cuda")
corner_box = lambda q: ((q - corner) @ planes.T).amax(dim=-1)

sharp = isoext.UniformGrid([4, 4, 4])
sharp.set_values(corner_box(sharp.get_points()))
its = isoext.get_intersection(sharp)
pts = project_to_surface(corner_box, its.get_points())
its.set_points(pts)
its.set_normals(get_sdf_normal(corner_box, pts))
compare(sharp, its, side="double")
marching_cubes        18 vertices,  21 triangles
dual_contouring       11 vertices,  10 triangles
dual_marching_cubes   11 vertices,  10 triangles

Tunnel Edges

Two deep blobs on a face diagonal. The bilinear face test joins them through the shared face, so every method extracts one connected dumbbell – marching cubes stays manifold, but for both dual methods the dual edge across that face is used by four quads. This tunnel edge is the one non-manifold configuration dual marching cubes keeps from the dual family:

pair = isoext.UniformGrid([4, 4, 4])
p = pair.get_points()
centers = torch.tensor([[-t, -t, -t], [-t, t, t]], device="cuda")
pair.set_values((p[..., None, :] - centers).norm(dim=-1).amin(-1) - 0.5)
compare(pair)
marching_cubes        12 vertices,  20 triangles
dual_contouring       14 vertices,  24 triangles
dual_marching_cubes   14 vertices,  24 triangles

Performance compares the methods’ speed, and the papers behind every method are collected in References.

References

[1]

Tianchang Shen, Jacob Munkberg, Jon Hasselgren, Kangxue Yin, Zian Wang, Wenzheng Chen, Zan Gojcic, Sanja Fidler, Nicholas Sharp, and Jun Gao. Flexible isosurface extraction for gradient-based mesh optimization. ACM Transactions on Graphics, 2023. doi:10.1145/3592430.