Search code examples
c#javascriptscriptingterrainvoxel

Basic Dual Contouring Theory


I've been searching on google, but cannot find anything basic. In it's most basic form, how is dual contouring (for a voxel terrain) implememted? I know what it does, and why, but cannot understand how to do it. JS or C# (preferably) is good.Has anyone used Dual contouring before and can explain it briefly?


Solution

  • Ok. So I got bored tonight and decided to give implementing dual contouring myself a shot. Like I said in the comments, all the relevant material is in section 2 of the following paper:

    In particular, the topology of the mesh is described in part 2.2 in the following section, quote:

    1. For each cube that exhibits a sign change, generate a vertex positioned at the minimizer of the quadratic function of equation 1.

    2. For each edge that exhibits a sign change, generate a quad connecting the minimizing vertices of the four cubes containing the edge.

    That's all there is to it! You solve a linear least squares problem to get a vertex for each cube, then you connect adjacent vertices with quads. So using this basic idea, I wrote a dual contouring isosurface extractor in python using numpy (partly just to satisfy my own morbid curiosity on how it worked). Here is the code:

    import numpy as np
    import numpy.linalg as la
    import scipy.optimize as opt
    import itertools as it
    
    #Cardinal directions
    dirs = [ [1,0,0], [0,1,0], [0,0,1] ]
    
    #Vertices of cube
    cube_verts = [ np.array([x, y, z])
        for x in range(2)
        for y in range(2)
        for z in range(2) ]
    
    #Edges of cube
    cube_edges = [ 
        [ k for (k,v) in enumerate(cube_verts) if v[i] == a and v[j] == b ]
        for a in range(2)
        for b in range(2)
        for i in range(3) 
        for j in range(3) if i != j ]
    
    #Use non-linear root finding to compute intersection point
    def estimate_hermite(f, df, v0, v1):
        t0 = opt.brentq(lambda t : f((1.-t)*v0 + t*v1), 0, 1)
        x0 = (1.-t0)*v0 + t0*v1
        return (x0, df(x0))
    
    #Input:
    # f = implicit function
    # df = gradient of f
    # nc = resolution
    def dual_contour(f, df, nc):
    
        #Compute vertices
        dc_verts = []
        vindex   = {}
        for x,y,z in it.product(range(nc), range(nc), range(nc)):
            o = np.array([x,y,z])
    
            #Get signs for cube
            cube_signs = [ f(o+v)>0 for v in cube_verts ]
    
            if all(cube_signs) or not any(cube_signs):
                continue
    
            #Estimate hermite data
            h_data = [ estimate_hermite(f, df, o+cube_verts[e[0]], o+cube_verts[e[1]]) 
                for e in cube_edges if cube_signs[e[0]] != cube_signs[e[1]] ]
    
            #Solve qef to get vertex
            A = [ n for p,n in h_data ]
            b = [ np.dot(p,n) for p,n in h_data ]
            v, residue, rank, s = la.lstsq(A, b)
    
            #Throw out failed solutions
            if la.norm(v-o) > 2:
                continue
    
            #Emit one vertex per every cube that crosses
            vindex[ tuple(o) ] = len(dc_verts)
            dc_verts.append(v)
    
        #Construct faces
        dc_faces = []
        for x,y,z in it.product(range(nc), range(nc), range(nc)):
            if not (x,y,z) in vindex:
                continue
    
            #Emit one face per each edge that crosses
            o = np.array([x,y,z])   
            for i in range(3):
                for j in range(i):
                    if tuple(o + dirs[i]) in vindex and tuple(o + dirs[j]) in vindex and tuple(o + dirs[i] + dirs[j]) in vindex:
                        dc_faces.append( [vindex[tuple(o)], vindex[tuple(o+dirs[i])], vindex[tuple(o+dirs[j])]] )
                        dc_faces.append( [vindex[tuple(o+dirs[i]+dirs[j])], vindex[tuple(o+dirs[j])], vindex[tuple(o+dirs[i])]] )
    
        return dc_verts, dc_faces
    

    It is not very fast because it uses the SciPy's generic non-linear root finding methods to find the edge points on the isosurface. However, it does seem to work reasonably well and in a fairly generic way. To test it, I ran it using the following test case (in the Mayavi2 visualization toolkit):

    import enthought.mayavi.mlab as mlab
    
    center = np.array([16,16,16])
    radius = 10
    
    def test_f(x):
        d = x-center
        return np.dot(d,d) - radius**2
    
    def test_df(x):
        d = x-center
        return d / sqrt(np.dot(d,d))
    
    verts, tris = dual_contour(f, df, n)
    
    mlab.triangular_mesh( 
                [ v[0] for v in verts ],
                [ v[1] for v in verts ],
                [ v[2] for v in verts ],
                tris)
    

    This defines a sphere as an implicit equation, and solves for the 0-isosurface by dual contouring. When I ran it in the toolkit, this was the result:

    enter image description here

    In conclusion, it appears to be working.