Examples Gallery
GitHub

gn-sdf-remesh

A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.

Rendered headless by the example itself — click to zoom.

witnesses An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.
blender --background --python examples/gn-sdf-remesh/gn_sdf_remesh.py --

A runnable example that remeshes an input mesh through an OpenVDB SDF grid using the build_remesh_via_sdf pattern from the geometry-nodes-python skill: GeometryNodeMeshToSDFGridGeometryNodeGridToMesh at the SDF zero-level, attached as a NODES modifier and evaluated via the depsgraph.

Which fix it witnesses: an SDF grid is meshed with Grid to Mesh, not Volume to Mesh (the Mesh to SDF Grid output is a grid socket; Volume to Mesh takes a volume-geometry socket, so wiring the grid there is an invalid link that yields no geometry). Grid to Mesh has the matching grid input.

Materials gotcha: geometry generated by Geometry Nodes carries no material, so the input mesh's material is dropped on remesh (the result renders with the default white). Re-apply it inside the tree with a Set Material node (GeometryNodeSetMaterial) on the remeshed output — this example does that and asserts the material survives onto the evaluated mesh.

Run

# Cheap correctness check only (no render) — the CI smoke check:
blender --background --python gn_sdf_remesh.py --

# Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts):
blender --background --python gn_sdf_remesh.py -- --output remesh.png
blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles

By default it runs only the frame-independent correctness check: the depsgraph-evaluated vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It exits non-zero on failure — the same check the blender-smoke workflow runs on Blender 4.5 LTS and 5.1.

Source

examples/gn-sdf-remesh/gn_sdf_remesh.py View on GitHub →
"""Geometry Nodes SDF remesh -- a runnable BDT example.

Builds the `build_remesh_via_sdf` pattern from the geometry-nodes-python skill
(`GeometryNodeMeshToSDFGrid` -> `GeometryNodeGridToMesh` at the SDF zero-level), attaches it
as a NODES modifier to an input mesh, and evaluates via the depsgraph. It witnesses the F2
fix: an SDF grid is meshed with **Grid to Mesh**, not Volume to Mesh.

By default it runs only the cheap, frame-independent correctness check (no render): the
evaluated vertex count must be > 0 AND differ from the base mesh -- proving the remesh
produced geometry. Exits non-zero on failure. This is the check the CI smoke gate runs on
both builds.

    blender --background --python gn_sdf_remesh.py --                  # correctness check only
    blender --background --python gn_sdf_remesh.py -- --output r.png   # also render the result
    blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles  # GPU-less
"""
import bpy, sys, os, argparse

def get_eevee_engine_id():
    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'

def build_remesh_via_sdf(voxel_size=0.1, threshold=0.0, material=None):
    tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree')
    tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry')
    tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
    gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput')
    mesh_to_sdf = tree.nodes.new('GeometryNodeMeshToSDFGrid')
    grid_to_mesh = tree.nodes.new('GeometryNodeGridToMesh')
    mesh_to_sdf.inputs["Voxel Size"].default_value = voxel_size
    grid_to_mesh.inputs["Threshold"].default_value = threshold
    tree.links.new(gi.outputs["Geometry"], mesh_to_sdf.inputs["Mesh"])
    link = tree.links.new(mesh_to_sdf.outputs["SDF Grid"], grid_to_mesh.inputs["Grid"])
    # GN-generated geometry carries no material, so the input mesh's material is dropped on
    # remesh. Re-apply it inside the tree with a Set Material node (the GN-native fix).
    out_socket = grid_to_mesh.outputs["Mesh"]
    if material is not None:
        set_mat = tree.nodes.new('GeometryNodeSetMaterial')
        set_mat.inputs["Material"].default_value = material
        tree.links.new(out_socket, set_mat.inputs["Geometry"])
        out_socket = set_mat.outputs["Geometry"]
    tree.links.new(out_socket, go.inputs["Geometry"])
    return tree, link.is_valid

def build():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    bpy.ops.mesh.primitive_torus_add(location=(0, 0, 0.55), major_radius=1.2, minor_radius=0.5)
    obj = bpy.context.active_object
    for p in obj.data.polygons:
        p.use_smooth = True
    mat = bpy.data.materials.new("Ceramic"); mat.use_nodes = True
    b = mat.node_tree.nodes.get('Principled BSDF')
    b.inputs['Base Color'].default_value = (0.42, 0.028, 0.045, 1)  # crimson ceramic
    b.inputs['Roughness'].default_value = 0.22
    obj.data.materials.append(mat)
    return obj

def render_still(obj, path, engine):
    import bmesh
    sc = bpy.context.scene
    fme = bpy.data.meshes.new("Floor"); bm = bmesh.new()
    bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free()
    fmat = bpy.data.materials.new("Studio"); fmat.use_nodes = True
    fb = fmat.node_tree.nodes.get('Principled BSDF')
    fb.inputs['Base Color'].default_value = (0.055, 0.06, 0.07, 1)  # dark graphite studio
    fb.inputs['Roughness'].default_value = 0.55
    fme.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", fme.copy()); wall.location = (0, 9.0, 0)
    wall.rotation_euler = (1.5708, 0, 0); bpy.context.collection.objects.link(wall)
    w = bpy.data.worlds.new("W"); w.use_nodes = True
    w.node_tree.nodes["Background"].inputs[0].default_value = (0.01, 0.011, 0.014, 1); sc.world = w
    aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 0.55); bpy.context.collection.objects.link(aim)
    cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -6.5, 2.2)
    bpy.context.collection.objects.link(cam); sc.camera = cam
    c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y'
    # key, cool fill, warm rim
    for nm, loc, en, sz, col in [("K", (-4, -5, 7), 1100, 5.0, (1.0, 0.98, 0.95)),
                                 ("F2", (5, -4, 2), 220, 7.0, (0.85, 0.9, 1.0)),
                                 ("R", (2.5, 6, 4), 700, 3.0, (1.0, 0.72, 0.45))]:
        ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = sz; ld.color = col
        lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo)
        lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y'
    sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id()
    if sc.render.engine == 'CYCLES':
        try: sc.cycles.samples = 32
        except Exception: pass
    else:
        try: sc.eevee.taa_render_samples = 64
        except Exception: pass
    sc.render.resolution_x = 1280; sc.render.resolution_y = 720
    sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path
    bpy.ops.render.render(write_still=True)
    return os.path.exists(path) and os.path.getsize(path) > 0

def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser()
    p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG")
    p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
    args = p.parse_args(argv)

    eid = get_eevee_engine_id()
    expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
    bpy.context.scene.render.engine = eid
    if bpy.context.scene.render.engine != expected:
        print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5

    obj = build()
    base = len(obj.data.vertices)
    src_mat = obj.data.materials[0] if obj.data.materials else None
    tree, link_valid = build_remesh_via_sdf(material=src_mat)
    obj.modifiers.new("sdf", 'NODES').node_group = tree
    dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg)
    m = ev.to_mesh(); evc = len(m.vertices)
    mat_names = [mm.name for mm in m.materials if mm is not None]
    ev.to_mesh_clear()
    print(f"link_valid={link_valid} base_vcount={base} eval_vcount={evc} materials={mat_names}")
    if not (link_valid and evc > 0 and evc != base):
        print("ERROR: SDF remesh produced no/unchanged geometry", file=sys.stderr); return 3
    # the Set Material node must carry the input material onto the remeshed result
    if src_mat is not None and src_mat.name not in mat_names:
        print(f"ERROR: material '{src_mat.name}' dropped by remesh", file=sys.stderr); return 6

    if args.output:
        if not render_still(obj, args.output, args.engine):
            print("ERROR: render produced no file", file=sys.stderr); return 4
        print(f"rendered {args.output} ({os.path.getsize(args.output)} bytes)")
    print("gn-sdf-remesh OK")
    return 0

if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        import traceback; traceback.print_exc()
        print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1)