Rendered headless by the example itself — click to zoom.
blender --background --python showcase/terrain-scatter/terrain_scatter.py --
A showcase piece, not an example. Geometry Nodes sine-hill Mesh Grid with an Index-jittered Instance-on-Points scatter, realized cubes replaced by closed-form displaced icospheres seated on sampled dirt Z, then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
GN still builds the hill and the instance grid. After realize+slabify, cube islands are deleted and each centroid is reseated: stone zmin equals sampled dirt Z minus a named bite, then verts clamp above the slab floor. That is why rocks sit on the hill instead of punching through the slab as bevelled crates.
It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.
Composes skills geometry-nodes-python, mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package). Hygiene combinatorics match examples/mesh-hygiene-audit (copied, not imported).
Intended size: 1.80 m square hill tile, ~0.16 m sine amplitude, nine seated stones; outer AABB 1.800 × 1.800 × 0.552 m.
Budgets
Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 1400–2800 | 1758 / 1758 / 1758 | | LOD1 ratio | 0.32–0.62 of base | 0.4994 / 0.4994 / 0.4994 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 2 distinct, ≥24 stone faces | 2 slots, 720 stone | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.800, 1.800, 0.552) m ± 0.015 | (1.8000, 1.8000, 0.5523), zmin 0 | | Collider tris | ≤ 120 | 81 | | Export | written, size > 0 | 158156 / 158156 / 158140 bytes |
Base triangles rose from 1194 to 1758 in the quality pass: 9-vert hill became a 21-vert grid, and bevelled cubes became subdiv-2 icospheres. Outer Z dropped from 0.655 m (crate corners swinging through the slab) to 0.552 m of seated stone on the hill.
DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh matched on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
Hygiene
Recomputed from the generated mesh, not asserted about the script.
| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 | | Stone shells | 9 | 9 | | Per-stone faces | ≥ 40 | 80 | | Stone floor zmin | ≥ 0.012 m | 0.11334 | | Seat offset (zmin − dirt Z) | ≤ 0.02 m | −0.03500 |
Falsifiers
Each violates one named budget. All were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.
| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --poke-rock | stone floor zmin | 17 | | --float-rocks | seat offset vs sampled dirt Z | 18 | | --box-rocks | per-stone face floor | 19 |
Run
blender --background --python terrain_scatter.py --
blender --background --python terrain_scatter.py -- --skip-decimate
blender --background --python terrain_scatter.py -- --stray-vert
blender --background --python terrain_scatter.py -- --lift-z
blender --background --python terrain_scatter.py -- --poke-rock
blender --background --python terrain_scatter.py -- --float-rocks
blender --background --python terrain_scatter.py -- --box-rocks
blender --background --python terrain_scatter.py -- --output terrain.png
Smoke passes no flags.
Exit codes
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or stone faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Hygiene (--stray-vert lands here) | | 16 | Grounded zmin (--lift-z) | | 17 | Stone floor poke (--poke-rock) | | 18 | Float above host (--float-rocks) | | 19 | Stone shell faces (--box-rocks) |
Source
"""Game-ready GN terrain scatter — a showcase piece, not an example. Asserts budget conformance of a Geometry Nodes hill grid with instanced rocks after composing shipped pipeline pieces: GN construction, UVs, two materials, high-to-low normal bake, LOD chain, convex collider, Unity glTF export. GN still builds the sine hill and the Index-jittered instance grid. Realized cubes are replaced with closed-form displaced icospheres seated on sampled dirt Z, then clamped above the slab floor. Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. Each falsifier violates one named budget: ``--skip-decimate`` LOD, ``--stray-vert`` hygiene, ``--lift-z`` zmin, ``--poke-rock`` stone floor, ``--float-rocks`` seat, ``--box-rocks`` stone shell faces. No RNG. Hills are a closed-form sine product; scatter is an Index-jittered instance grid. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a ratio band, not an exact count. blender --background --python terrain_scatter.py -- blender --background --python terrain_scatter.py -- --skip-decimate blender --background --python terrain_scatter.py -- --output terrain.png """ import argparse import math import os import sys import tempfile import traceback import bmesh import bpy from mathutils import Euler, Vector _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) sys.path.insert(0, os.path.join(_REPO, "examples")) sys.dont_write_bytecode = True import gallery_framing # noqa: E402 PATCH = 1.80 FREQ = 2.2 AMP = 0.16 ROCK_SIZE = (0.22, 0.17, 0.13) ROCK_GRID = 3 ROCK_SPAN = 1.15 SLAB_LIFT = 0.10 ROCK_BITE = 0.035 ROCK_ZMIN = 0.012 N_ROCKS = 9 BBOX_TOL = 0.015 OUTER_SIZE = (1.800, 1.800, 0.552) BASE_TRIS_MIN = 1400 BASE_TRIS_MAX = 2800 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 LOD2_RATIO_MAX = 0.35 LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 COLLIDER_TRIS_MAX = 120 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 STONE_FACES_MIN = 24 STONE_SHELL_FACES_MIN = 40 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 ZFIGHT_EPS = 1e-4 ZFIGHT_COS = 0.998 ROCK_SEAT_MAX = 0.02 FLOAT_LIFT = 0.12 POKE_BITE = 0.22 LIFT_Z = 0.05 DIRT_IDX = 0 STONE_IDX = 1 def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) return code def triangle_count(mesh): mesh.calc_loop_triangles() return len(mesh.loop_triangles) def evaluated_triangle_count(obj): depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) eval_mesh = eval_obj.to_mesh() try: eval_mesh.calc_loop_triangles() return len(eval_mesh.loop_triangles) finally: eval_obj.to_mesh_clear() def deselect_all(): for ob in list(bpy.context.view_layer.objects): if ob is None: continue ob.select_set(False) def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cube(bm, size=1.0) verts = geo["verts"] rot = Euler(euler).to_matrix() origin = Vector(loc) for v in verts: p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) v.co = rot @ p + origin faces = {f for v in verts for f in v.link_faces} for f in faces: f.material_index = mat_idx return verts def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) n = len(faces) cols = max(1, math.ceil(math.sqrt(n))) rows = max(1, math.ceil(n / cols)) cell_w = 1.0 / cols cell_h = 1.0 / rows pad_u = margin * cell_w * 0.5 pad_v = margin * cell_h * 0.5 usable_w = cell_w - 2.0 * pad_u usable_h = cell_h - 2.0 * pad_v for i, face in enumerate(faces): col = i % cols row = i // cols nrm = face.normal ax = abs(nrm.x) ay = abs(nrm.y) az = abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co if az >= ax and az >= ay: coords.append((co.x, co.y)) elif ax >= ay: coords.append((co.y, co.z)) else: coords.append((co.x, co.z)) xs = [c[0] for c in coords] ys = [c[1] for c in coords] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) dx = max(maxx - minx, 1e-8) dy = max(maxy - miny, 1e-8) origin_u = col * cell_w + pad_u origin_v = row * cell_h + pad_v for loop, (x, y) in zip(face.loops, coords): loop[uv].uv = ( origin_u + (x - minx) / dx * usable_w, origin_v + (y - miny) / dy * usable_h, ) def hill_z_socket(tree, freq, amp): pos = tree.nodes.new("GeometryNodeInputPosition") sep = tree.nodes.new("ShaderNodeSeparateXYZ") tree.links.new(pos.outputs["Position"], sep.inputs[0]) mx = tree.nodes.new("ShaderNodeMath") mx.operation = "MULTIPLY" mx.inputs[1].default_value = freq my = tree.nodes.new("ShaderNodeMath") my.operation = "MULTIPLY" my.inputs[1].default_value = freq tree.links.new(sep.outputs["X"], mx.inputs[0]) tree.links.new(sep.outputs["Y"], my.inputs[0]) sine = tree.nodes.new("ShaderNodeMath") sine.operation = "SINE" cose = tree.nodes.new("ShaderNodeMath") cose.operation = "COSINE" tree.links.new(mx.outputs[0], sine.inputs[0]) tree.links.new(my.outputs[0], cose.inputs[0]) prod = tree.nodes.new("ShaderNodeMath") prod.operation = "MULTIPLY" tree.links.new(sine.outputs[0], prod.inputs[0]) tree.links.new(cose.outputs[0], prod.inputs[1]) add1 = tree.nodes.new("ShaderNodeMath") add1.operation = "ADD" add1.inputs[1].default_value = 1.0 tree.links.new(prod.outputs[0], add1.inputs[0]) sc = tree.nodes.new("ShaderNodeMath") sc.operation = "MULTIPLY" sc.inputs[1].default_value = amp tree.links.new(add1.outputs[0], sc.inputs[0]) return sc.outputs[0] def build_scatter_tree(verts, dirt, stone): tree = bpy.data.node_groups.new("TerrainScatter", "GeometryNodeTree") tree.interface.new_socket( name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry", ) go = tree.nodes.new("NodeGroupOutput") hill = hill_z_socket(tree, FREQ, AMP) off = tree.nodes.new("ShaderNodeCombineXYZ") tree.links.new(hill, off.inputs["Z"]) grid = tree.nodes.new("GeometryNodeMeshGrid") grid.inputs["Size X"].default_value = PATCH grid.inputs["Size Y"].default_value = PATCH grid.inputs["Vertices X"].default_value = verts grid.inputs["Vertices Y"].default_value = verts set_pos = tree.nodes.new("GeometryNodeSetPosition") tree.links.new(grid.outputs["Mesh"], set_pos.inputs["Geometry"]) tree.links.new(off.outputs["Vector"], set_pos.inputs["Offset"]) lift = tree.nodes.new("GeometryNodeTransform") lift.inputs["Translation"].default_value = (0.0, 0.0, SLAB_LIFT) tree.links.new(set_pos.outputs["Geometry"], lift.inputs["Geometry"]) shade_t = tree.nodes.new("GeometryNodeSetShadeSmooth") shade_t.inputs["Shade Smooth"].default_value = False tree.links.new(lift.outputs["Geometry"], shade_t.inputs["Geometry"]) sm_d = tree.nodes.new("GeometryNodeSetMaterial") sm_d.inputs["Material"].default_value = dirt tree.links.new(shade_t.outputs["Geometry"], sm_d.inputs["Geometry"]) pts = tree.nodes.new("GeometryNodeMeshGrid") pts.inputs["Size X"].default_value = ROCK_SPAN pts.inputs["Size Y"].default_value = ROCK_SPAN pts.inputs["Vertices X"].default_value = ROCK_GRID pts.inputs["Vertices Y"].default_value = ROCK_GRID idx = tree.nodes.new("GeometryNodeInputIndex") jx = tree.nodes.new("ShaderNodeMath") jx.operation = "MULTIPLY" jx.inputs[1].default_value = 1.73 tree.links.new(idx.outputs["Index"], jx.inputs[0]) jxs = tree.nodes.new("ShaderNodeMath") jxs.operation = "SINE" tree.links.new(jx.outputs[0], jxs.inputs[0]) jxm = tree.nodes.new("ShaderNodeMath") jxm.operation = "MULTIPLY" jxm.inputs[1].default_value = 0.22 tree.links.new(jxs.outputs[0], jxm.inputs[0]) jy = tree.nodes.new("ShaderNodeMath") jy.operation = "MULTIPLY" jy.inputs[1].default_value = 2.19 tree.links.new(idx.outputs["Index"], jy.inputs[0]) jyc = tree.nodes.new("ShaderNodeMath") jyc.operation = "COSINE" tree.links.new(jy.outputs[0], jyc.inputs[0]) jym = tree.nodes.new("ShaderNodeMath") jym.operation = "MULTIPLY" jym.inputs[1].default_value = 0.22 tree.links.new(jyc.outputs[0], jym.inputs[0]) extra = tree.nodes.new("ShaderNodeMath") extra.operation = "ADD" extra.inputs[1].default_value = SLAB_LIFT tree.links.new(hill, extra.inputs[0]) jitter = tree.nodes.new("ShaderNodeCombineXYZ") tree.links.new(jxm.outputs[0], jitter.inputs["X"]) tree.links.new(jym.outputs[0], jitter.inputs["Y"]) tree.links.new(extra.outputs[0], jitter.inputs["Z"]) set_pts = tree.nodes.new("GeometryNodeSetPosition") tree.links.new(pts.outputs["Mesh"], set_pts.inputs["Geometry"]) tree.links.new(jitter.outputs["Vector"], set_pts.inputs["Offset"]) cube = tree.nodes.new("GeometryNodeMeshCube") cube.inputs["Size"].default_value = ROCK_SIZE iop = tree.nodes.new("GeometryNodeInstanceOnPoints") tree.links.new(set_pts.outputs["Geometry"], iop.inputs["Points"]) tree.links.new(cube.outputs["Mesh"], iop.inputs["Instance"]) ang = tree.nodes.new("ShaderNodeMath") ang.operation = "MULTIPLY" ang.inputs[1].default_value = 0.67 tree.links.new(idx.outputs["Index"], ang.inputs[0]) rot = tree.nodes.new("ShaderNodeCombineXYZ") tree.links.new(ang.outputs[0], rot.inputs["Z"]) tree.links.new(rot.outputs["Vector"], iop.inputs["Rotation"]) mod = tree.nodes.new("ShaderNodeMath") mod.operation = "MODULO" mod.inputs[1].default_value = 3.0 tree.links.new(idx.outputs["Index"], mod.inputs[0]) div = tree.nodes.new("ShaderNodeMath") div.operation = "DIVIDE" div.inputs[1].default_value = 3.0 tree.links.new(mod.outputs[0], div.inputs[0]) mul_s = tree.nodes.new("ShaderNodeMath") mul_s.operation = "MULTIPLY" mul_s.inputs[1].default_value = 0.22 tree.links.new(div.outputs[0], mul_s.inputs[0]) add_s = tree.nodes.new("ShaderNodeMath") add_s.operation = "ADD" add_s.inputs[1].default_value = 0.84 tree.links.new(mul_s.outputs[0], add_s.inputs[0]) scl = tree.nodes.new("ShaderNodeCombineXYZ") tree.links.new(add_s.outputs[0], scl.inputs["X"]) tree.links.new(add_s.outputs[0], scl.inputs["Y"]) tree.links.new(add_s.outputs[0], scl.inputs["Z"]) tree.links.new(scl.outputs["Vector"], iop.inputs["Scale"]) realize = tree.nodes.new("GeometryNodeRealizeInstances") tree.links.new(iop.outputs["Instances"], realize.inputs["Geometry"]) sm_s = tree.nodes.new("GeometryNodeSetMaterial") sm_s.inputs["Material"].default_value = stone tree.links.new(realize.outputs["Geometry"], sm_s.inputs["Geometry"]) join = tree.nodes.new("GeometryNodeJoinGeometry") tree.links.new(sm_d.outputs["Geometry"], join.inputs[0]) tree.links.new(sm_s.outputs["Geometry"], join.inputs[0]) shade = tree.nodes.new("GeometryNodeSetShadeSmooth") shade.inputs["Shade Smooth"].default_value = False tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"]) tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"]) return tree def eval_to_mesh(obj, name): bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() ev = obj.evaluated_get(dg) em = ev.to_mesh() try: me = bpy.data.meshes.new(name) me.from_pydata( [tuple(v.co) for v in em.vertices], [], [tuple(p.vertices) for p in em.polygons], ) src_idx = [p.material_index for p in em.polygons] me.update() for poly, idx in zip(me.polygons, src_idx): poly.material_index = idx finally: ev.to_mesh_clear() return me def slabify(bm, floor_z=0.0): bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) boundary = [e for e in bm.edges if e.is_boundary] if not boundary: return ret = bmesh.ops.extrude_edge_only(bm, edges=boundary) verts = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMVert)] for v in verts: v.co.z = floor_z bottom = [ e for e in bm.edges if e.is_boundary and abs(e.verts[0].co.z - floor_z) < 1e-5 and abs(e.verts[1].co.z - floor_z) < 1e-5 ] if bottom: try: bmesh.ops.edgeloop_fill(bm, edges=bottom) except Exception: bmesh.ops.contextual_create(bm, geom=bottom) for f in bm.faces: if f.material_index not in (DIRT_IDX, STONE_IDX): f.material_index = DIRT_IDX def nearest_dirt_z(bm, x, y): best_z = None best_d = 1e9 for v in bm.verts: if not v.link_faces: continue if not any(f.material_index == DIRT_IDX for f in v.link_faces): continue dx = v.co.x - x dy = v.co.y - y d = dx * dx + dy * dy if d < best_d: best_d = d best_z = v.co.z return 0.0 if best_z is None else best_z def add_seated_stone(bm, cx, cy, dirt_z, box_rocks, poke, float_up): sx = 0.11 + 0.025 * math.sin(cx * 8.1) sy = 0.10 + 0.022 * math.cos(cy * 6.4) sz = 0.075 + 0.020 * math.sin(cx * 4.2 + cy * 3.1) bite = POKE_BITE if poke else ROCK_BITE seat = dirt_z + FLOAT_LIFT if float_up else dirt_z - bite rot = Euler( ( 0.22 * math.sin(cx * 3.1), 0.18 * math.cos(cy * 2.7), cx * 2.4 + cy * 1.6, ) ).to_matrix() if box_rocks: verts = add_box(bm, (0.0, 0.0, 0.0), (sx * 2.0, sy * 2.0, sz * 2.0), STONE_IDX) for v in verts: v.co = rot @ v.co else: geo = bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0) verts = geo["verts"] for v in verts: p = Vector((v.co.x * sx, v.co.y * sy, v.co.z * sz)) if p.length > 1e-8: bump = 0.016 * math.sin(p.x * 26.0 + cx * 5.0) * math.cos( p.y * 21.0 + cy * 4.0 ) p += p.normalized() * bump v.co = rot @ p for f in {face for v in verts for face in v.link_faces}: f.material_index = STONE_IDX zmin = min(v.co.z for v in verts) dz = seat - zmin for v in verts: v.co.x += cx v.co.y += cy v.co.z += dz if not poke and v.co.z < ROCK_ZMIN: v.co.z = ROCK_ZMIN def masonry_from_cubes(bm, box_rocks=False, poke=False, float_up=False): stone_faces = [f for f in bm.faces if f.material_index == STONE_IDX] visited = set() islands = [] for face in stone_faces: if face in visited: continue stack = [face] island = [] while stack: cur = stack.pop() if cur in visited: continue visited.add(cur) island.append(cur) for edge in cur.edges: for other in edge.link_faces: if other.material_index == STONE_IDX and other not in visited: stack.append(other) islands.append(island) specs = [] for island in islands: verts = {v for f in island for v in f.verts} xs = [v.co.x for v in verts] ys = [v.co.y for v in verts] cx = 0.5 * (min(xs) + max(xs)) cy = 0.5 * (min(ys) + max(ys)) specs.append((cx, cy)) if stone_faces: bmesh.ops.delete(bm, geom=stone_faces, context="FACES") loose = [v for v in bm.verts if not v.link_faces] if loose: bmesh.ops.delete(bm, geom=loose, context="VERTS") bm.verts.ensure_lookup_table() bm.faces.ensure_lookup_table() for cx, cy in specs: dirt_z = nearest_dirt_z(bm, cx, cy) add_seated_stone(bm, cx, cy, dirt_z, box_rocks, poke, float_up) def build_terrain_mesh( name, grid_verts, dirt, stone, box_rocks=False, poke=False, float_up=False, ): carrier = bpy.data.meshes.new(name + "Carrier") carrier.vertices.add(1) obj = bpy.data.objects.new(name + "GN", carrier) bpy.context.collection.objects.link(obj) tree = build_scatter_tree(grid_verts, dirt, stone) mod = obj.modifiers.new("terrain_scatter", "NODES") mod.node_group = tree realized = eval_to_mesh(obj, name + "Eval") bpy.data.objects.remove(obj, do_unlink=True) bm = bmesh.new() try: bm.from_mesh(realized) bm.verts.ensure_lookup_table() bm.edges.ensure_lookup_table() bm.faces.ensure_lookup_table() slabify(bm, floor_z=0.0) bm.verts.ensure_lookup_table() bm.faces.ensure_lookup_table() masonry_from_cubes(bm, box_rocks=box_rocks, poke=poke, float_up=float_up) xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] zs = [v.co.z for v in bm.verts] cx = 0.5 * (min(xs) + max(xs)) cy = 0.5 * (min(ys) + max(ys)) zmin = min(zs) for v in bm.verts: v.co.x -= cx v.co.y -= cy v.co.z -= zmin if v.co.z < 0.0: v.co.z = 0.0 ngons = [f for f in bm.faces if len(f.verts) > 4] if ngons: bmesh.ops.triangulate(bm, faces=ngons) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: face.smooth = face.material_index == STONE_IDX me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() for poly in me.polygons: poly.use_smooth = poly.material_index == STONE_IDX finally: bm.free() out = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(out) return out def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness if noise_scale > 0.0 and wear is not None: tex = nt.nodes.new("ShaderNodeTexNoise") tex.inputs["Scale"].default_value = noise_scale tex.inputs["Detail"].default_value = 6.0 mix = nt.nodes.new("ShaderNodeMix") mix.data_type = "RGBA" mix.inputs["A"].default_value = color mix.inputs["B"].default_value = wear fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") nt.links.new(tex.outputs["Fac"], fac) nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) return mat def assign_slots(obj, dirt, stone): # Do not materials.clear() — that resets polygon material_index to 0 # on this Blender, which would drop stone faces onto dirt. mats = obj.data.materials wanted = (dirt, stone) for i, mat in enumerate(wanted): if i < len(mats): mats[i] = mat else: mats.append(mat) def world_bbox(obj): mat = obj.matrix_world pts = [mat @ v.co for v in obj.data.vertices] xs = [p.x for p in pts] ys = [p.y for p in pts] zs = [p.z for p in pts] return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: return 0.0, 0.0, 1.0, 1.0, 0, 1.0 data = uv.data us = [loop.uv[0] for loop in data] vs = [loop.uv[1] for loop in data] aabbs = [] for poly in mesh.polygons: pu = [data[i].uv[0] for i in poly.loop_indices] pv = [data[i].uv[1] for i in poly.loop_indices] aabbs.append((min(pu), min(pv), max(pu), max(pv))) overlap = 0.0 for i in range(len(aabbs)): a = aabbs[i] for j in range(i + 1, len(aabbs)): b = aabbs[j] x0 = max(a[0], b[0]) y0 = max(a[1], b[1]) x1 = min(a[2], b[2]) y1 = min(a[3], b[3]) overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() bpy.context.scene.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" mod.ratio = ratio return lod def convex_hull_collider(obj, name): mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: bm.from_mesh(obj.data) result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) interior = result.get("geom_interior") or [] unused = result.get("geom_unused") or [] if interior: bmesh.ops.delete(bm, geom=interior, context="VERTS") if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") bm.to_mesh(mesh) mesh.update() finally: bm.free() collider = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(collider) collider.matrix_world = obj.matrix_world.copy() return collider def setup_bake_image(obj, target_mat, size=BAKE_RES): if not obj.data.uv_layers: return None, None img = bpy.data.images.new("TerrainNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex tex.select = True obj.active_material_index = DIRT_IDX return img, tex def bake_normal(high, low): scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False deselect_all() high.select_set(True) low.select_set(True) bpy.context.view_layer.objects.active = low return bpy.ops.object.bake( type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, use_cage=False, normal_space="TANGENT", margin=4, margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", ) def export_unity(path, objects): deselect_all() for ob in objects: ob.select_set(True) bpy.context.view_layer.objects.active = objects[0] bpy.ops.export_scene.gltf( filepath=path, use_selection=True, export_yup=True, export_apply=True, export_draco_mesh_compression_enable=False, export_animations=False, ) def face_area(me, poly): vs = [me.vertices[i].co for i in poly.vertices] if len(vs) < 3: return 0.0 v0 = vs[0] area = 0.0 for i in range(1, len(vs) - 1): area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 return area def hygiene_audit(me): nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) bm = bmesh.new() try: bm.from_mesh(me) loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) nonman = sum(1 for e in bm.edges if not e.is_manifold) ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) doubles = len(ret.get("targetmap") or {}) finally: bm.free() return { "nv": nv, "ne": ne, "nf": nf, "ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area, "doubles": doubles, "euler": nv - ne + nf, } def zfight_pairs(me): data = [ (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) for p in me.polygons ] eps2 = ZFIGHT_EPS * ZFIGHT_EPS count = 0 for i in range(len(data)): ci, ni, vi = data[i] for j in range(i + 1, len(data)): cj, nj, vj = data[j] if (cj - ci).length_squared > eps2: continue if abs(ni.dot(nj)) <= ZFIGHT_COS: continue if vi & vj: continue count += 1 return count def shells(me): neighbors = [[] for _ in range(len(me.vertices))] for edge in me.edges: a, b = edge.vertices neighbors[a].append(b) neighbors[b].append(a) seen = [False] * len(me.vertices) groups = [] for start in range(len(me.vertices)): if seen[start]: continue seen[start] = True stack = [start] group = [] while stack: current = stack.pop() group.append(current) for nxt in neighbors[current]: if not seen[nxt]: seen[nxt] = True stack.append(nxt) groups.append(group) return groups def shell_aabb(me, group): pts = [me.vertices[i].co for i in group] return ( min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), ) def mat_of(me, group): member = set(group) for poly in me.polygons: if all(i in member for i in poly.vertices): return poly.material_index return None def shell_faces(me, group): member = set(group) return sum(1 for p in me.polygons if all(i in member for i in p.vertices)) def add_stray_vert(me): bm = bmesh.new() try: bm.from_mesh(me) bm.verts.new((0.0, 0.0, 0.55)) bm.to_mesh(me) me.update() finally: bm.free() def nearest_mesh_dirt_z(me, x, y, dirt_verts): best_z = 0.0 best_d = 1e9 for co in dirt_verts: d = (co.x - x) ** 2 + (co.y - y) ** 2 if d < best_d: best_d = d best_z = co.z return best_z def joint_audit(me): groups = shells(me) stones = [] dirt_verts = [] for poly in me.polygons: if poly.material_index != DIRT_IDX: continue for i in poly.vertices: dirt_verts.append(me.vertices[i].co) for g in groups: if mat_of(me, g) != STONE_IDX: continue a = shell_aabb(me, g) nfaces = shell_faces(me, g) cx = 0.5 * (a[0] + a[3]) cy = 0.5 * (a[1] + a[4]) dirt_z = nearest_mesh_dirt_z(me, cx, cy, dirt_verts) stones.append((a[2], nfaces, a[2] - dirt_z)) n_stones = len(stones) min_faces = min((s[1] for s in stones), default=0) poke_z = min((s[0] for s in stones), default=99.0) float_off = max((s[2] for s in stones), default=0.0) return { "n_stones": n_stones, "min_faces": min_faces, "poke_z": poke_z, "float_off": float_off, } def check( skip_decimate, lift_z=False, stray_vert=False, poke=False, float_up=False, box_rocks=False, ): bpy.ops.wm.read_factory_settings(use_empty=True) dirt = principled( "TerrainDirt", (0.30, 0.17, 0.07, 1.0), 0.0, 0.90, noise_scale=14.0, wear=(0.18, 0.10, 0.04, 1.0), ) stone = principled( "TerrainStone", (0.56, 0.53, 0.48, 1.0), 0.0, 0.76, noise_scale=20.0, wear=(0.38, 0.35, 0.30, 1.0), ) kw = dict(box_rocks=box_rocks, poke=poke, float_up=float_up) low = build_terrain_mesh("TerrainLow", 21, dirt, stone, **kw) high = build_terrain_mesh("TerrainHigh", 25, dirt, stone, **kw) assign_slots(low, dirt, stone) assign_slots(high, dirt, stone) if stray_vert: add_stray_vert(low.data) if lift_z: for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() if low.data is None or len(low.data.polygons) < 6: return fail("terrain mesh did not build", 3), None, None, None, None, None base_tris = triangle_count(low.data) mats = [s for s in low.data.materials if s is not None] nmat = len(mats) distinct_mats = len({id(s) for s in mats}) idx_counts = {} for poly in low.data.polygons: idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 print(f"measured mat_index_counts={idx_counts}") u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = world_bbox(low) size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] img, tex = setup_bake_image(low, dirt) if img is None: return fail("terrain has no UV layer", 3), None, None, None, None, None bake_result = bake_normal(high, low) lod1 = make_lod(low, "TerrainLOD1", LOD1_TARGET, skip_decimate) lod2 = make_lod(low, "TerrainLOD2", LOD2_TARGET, skip_decimate) bpy.context.view_layer.update() lod1_tris = evaluated_triangle_count(lod1) lod2_tris = evaluated_triangle_count(lod2) r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 collider_src = build_terrain_mesh( "TerrainColSrc", 21, dirt, stone, **kw ) collider = convex_hull_collider(collider_src, "TerrainCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) export_path = os.path.join( tempfile.gettempdir(), f"bdt_terrain_scatter_{os.getpid()}.glb", ) if os.path.exists(export_path): os.remove(export_path) export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" ) print( f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " f"overlap={overlap:.6f} nfaces={nfaces}" ) print( f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" ) print( f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) hyg = hygiene_audit(low.data) zf = zfight_pairs(low.data) jnt = joint_audit(low.data) print( f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}" ) print( f"measured stones={jnt['n_stones']} min_faces={jnt['min_faces']} " f"poke_z={jnt['poke_z']:.5f} float_off={jnt['float_off']:.5f}" ) if jnt["min_faces"] < STONE_SHELL_FACES_MIN: return fail( f"stone shell faces {jnt['min_faces']} < {STONE_SHELL_FACES_MIN}", 19, ), None, None, None, None, None if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4, ), None, None, None, None, None if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: return fail( f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5, ), None, None, None, None, None if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN: return fail( f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", 5, ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6, ), None, None, None, None, None if overlap > UV_OVERLAP_MAX: return fail( f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7, ), None, None, None, None, None if jnt["n_stones"] != N_ROCKS or jnt["poke_z"] < ROCK_ZMIN: return fail( f"poke stones={jnt['n_stones']} poke_z={jnt['poke_z']:.5f}", 17, ), None, None, None, None, None if jnt["float_off"] > ROCK_SEAT_MAX: return fail( f"float_off {jnt['float_off']:.5f} > {ROCK_SEAT_MAX}", 18, ), None, None, None, None, None if bb[2] > ZMIN_EPS: return fail(f"grounded zmin={bb[2]:.5f}", 16), None, None, None, None, None if ( abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL ): return fail( f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " f"off outer {OUTER_SIZE}", 8, ), None, None, None, None, None if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): return fail( f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " "(--skip-decimate is the designed fail)", 9, ), None, None, None, None, None if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): return fail( f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9, ), None, None, None, None, None if col_tris > COLLIDER_TRIS_MAX: return fail( f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11, ), None, None, None, None, None if bake_result != {"FINISHED"} or not img.has_data: return fail( f"bake failed result={bake_result} has_data={img.has_data}", 12, ), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None if ( hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] or hyg["doubles"] or hyg["ngons"] or zf ): return fail( f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}", 15, ), None, None, None, None, None return 0, low, high, dirt, tex, collider def wire_normal(mat, tex): nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] nrm = nt.nodes.new("ShaderNodeNormalMap") nrm.inputs["Strength"].default_value = 1.0 nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) def render_still(low, dirt, tex, path, engine): scene = bpy.context.scene wire_normal(dirt, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True low.rotation_euler.z = math.radians(-28.0) low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) bm.to_mesh(floor_me) finally: bm.free() fmat = bpy.data.materials.new("Floor") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, 8.5, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = ( 0.02, 0.021, 0.025, 1.0, ) scene.world = world def light(name, loc, energy, size, col, rot): ld = bpy.data.lights.new(name, "AREA") ld.energy = energy ld.size = size ld.color = col ob = bpy.data.objects.new(name, ld) ob.location = loc ob.rotation_euler = tuple(math.radians(a) for a in rot) scene.collection.objects.link(ob) light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (2.57, -3.51, 2.24) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, 0.24) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" con.up_axis = "UP_Y" scene.camera = cam scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": scene.cycles.samples = 32 scene.cycles.device = "CPU" else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = ( "WEBP" if path.lower().endswith(".webp") else "PNG" ) if path.lower().endswith(".webp"): scene.render.image_settings.quality = 90 scene.render.filepath = path scene.view_settings.view_transform = "Standard" fcode = gallery_framing.check_framing( scene, cam, hero=[low], elements=[low], stage=[floor, wall], ) if fcode: return fcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): return fail("render produced no file", 14) return 0 def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) p.add_argument("--skip-decimate", action="store_true") p.add_argument("--stray-vert", action="store_true") p.add_argument("--lift-z", action="store_true") p.add_argument("--poke-rock", action="store_true") p.add_argument("--float-rocks", action="store_true") p.add_argument("--box-rocks", action="store_true") args = p.parse_args(argv) code, low, _high, dirt, tex, _col = check( args.skip_decimate, lift_z=args.lift_z, stray_vert=args.stray_vert, poke=args.poke_rock, float_up=args.float_rocks, box_rocks=args.box_rocks, ) if code: return code if args.output: rcode = render_still(low, dirt, tex, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("terrain-scatter OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: traceback.print_exc() print(f"FATAL: {e}", file=sys.stderr) sys.exit(1)