Dual Contouring¶
Dual contouring is a dual extraction method: it places one vertex inside each cell the surface crosses and connects the vertices of neighboring cells, and it puts each vertex on the sharp edge or corner if the cell contains one. The default variant finds that point from the surface normals at the edge crossings. A second variant finds it from the signed distance samples alone.
Basic Usage¶
vertices, faces = isoext.dual_contouring(grid, level=0.0)
grid: AUniformGridorSparseGridwith values setlevel: The iso-value to extract (default: 0.0)intersection: Optional precomputed edge crossings fromget_intersection, with or without normals; computed automatically when omittedmethod:"ju"(default) or"carrera", described below
Called like this, the edge crossings and normals are estimated from the grid values, which is enough for smooth surfaces. A cube rotated so that none of its edges align with the grid, sampled on 32 cells per axis, comes out with rounded edges:
import torch
import isoext
from isoext.sdf import CuboidSDF, RotationOp, get_sdf_normal, project_to_surface
from isoext import viewer
cube = RotationOp(sdf=CuboidSDF(size=[1.0, 1.0, 1.0]), axis=[1, 1, 0], angle=30)
grid = isoext.UniformGrid([33, 33, 33])
grid.set_values(cube(grid.get_points()))
vertices, faces = isoext.dual_contouring(grid)
print(f"Vertices: {vertices.shape}")
print(f"Faces: {faces.shape}")
print(f"max vertex error: {cube(vertices).abs().max():.4f} (cell size {2 / 32:.4f})")
viewer.embed(vertices, faces, color="lightblue", flat_shading=True)
Vertices: torch.Size([2146, 3])
Faces: torch.Size([4292, 3])
max vertex error: 0.0184 (cell size 0.0625)
How It Works¶
Like surface nets, dual contouring puts one vertex inside each crossed cell and one quad around each crossed edge (see Surface Nets). The difference is where the vertex goes. Every edge crossing together with its surface normal defines a tangent plane, and the vertex is placed to minimize the total squared distance to all of the cell’s planes, a least-squares problem known as the QEF (Ju et al. [2002]). If the cell contains a sharp corner, the planes are the corner’s faces and their intersection point is the corner itself.
The cell below contains the corner of a tilted box. The gold dots are the crossings, the arrows their normals, and each translucent square is a piece of the tangent plane it defines, drawn large enough to reach their common intersection. The centroid of the crossings (green) sits away from the feature; the QEF solution (red) is the corner. When the crossings do not pin down all three axes, on flat faces or straight creases, the QEF is degenerate and the solver regularizes it toward the centroid.
centroid: [-0.6, -0.59, -0.64]
qef: [0.1, 0.15, 0.2]
Custom Normals for Sharp Features¶
The vertex placement is only as good as the normals. Estimated from
grid values they are smeared near sharp features; computed from the
SDF gradient they are exact. To supply your own, get the edge
crossings with get_intersection, attach normals to them, and pass
the result in:
intersection = isoext.get_intersection(grid)
points = intersection.get_points()
intersection.set_normals(get_sdf_normal(cube, points))
vertices, faces = isoext.dual_contouring(grid, intersection=intersection)
print(f"max vertex error: {cube(vertices).abs().max():.4f}")
viewer.embed(vertices, faces, color="salmon", flat_shading=True)
max vertex error: 0.0097
Refined Points and Unclamped Vertices¶
Two more steps make the edges exact:
project_to_surfacemoves the intersection points from their linear interpolation estimate onto the actual surface.clamp=Falselets a vertex leave its cell to sit on a sharp feature. By default vertices are kept inside their cells, which is safer but rounds edges whose feature line passes through a neighboring cell. Unclamped vertices can produce self-intersections on noisy data.
The refinement works with any field that can be evaluated at arbitrary points, including neural networks. Disabling the clamp is another matter: it is only safe for exact fields like analytic SDFs. On approximate fields, such as distance estimators or neural networks, noise in the points and normals can throw unclamped vertices far from the surface, producing what Schaefer and Warren [2002] call spikes. For those fields, and for purely sampled volumes, keep the default clamp.
With the refinement, the tangent planes pass through the exact surface and the edges come out clean:
points = project_to_surface(cube, intersection.get_points())
intersection.set_points(points)
intersection.set_normals(get_sdf_normal(cube, points))
vertices, faces = isoext.dual_contouring(grid, intersection=intersection, clamp=False)
print(f"max vertex error: {cube(vertices).abs().max():.4f}")
viewer.embed(vertices, faces, color="gold", flat_shading=True)
max vertex error: 0.0002
References¶
Tao Ju, Frank Losasso, Scott Schaefer, and Joe Warren. Dual contouring of hermite data. In Proceedings of the 29th Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH '02), 339–346. 2002. doi:10.1145/566570.566586.
Scott Schaefer and Joe Warren. Dual contouring: “the secret sauce”. Technical Report TR 02-408, Rice University, 2002. URL: https://www.cs.rice.edu/~jwarren/papers/techreport02408.pdf.
Xiana Carrera, Ningna Wang, Christopher Batty, Oded Stein, and Silvia Sellán. Dual contouring of signed distance data. In Proceedings of the Special Interest Group on Computer Graphics and Interactive Techniques Conference Conference Papers (SIGGRAPH '26). 2026. doi:10.1145/3799902.3811116.