Rendered headless by the example itself — click to zoom.
blender --background --python examples/collision-hull-proxy/collision_hull_proxy.py --
A runnable example that builds a fire hydrant street prop — lathed barrel, bonnet dome, three outlet caps, operating nut — plus the collision a game prop pipeline ingests: a compound of four convex hull pieces, one per part group, each built with bmesh.ops.convex_hull from a coarse collision cage, following mesh-editing-and-bmesh.
Pipeline arc neighbor: LODs in lod-decimate-chain, tangent space in triangulate-tangents, export in gltf-export-roundtrip — collision bounds are the piece an engine's physics ingest checks first, and the piece whose failure rejects the whole prop.
Scope: this witnesses the bpy-level contract a prop pipeline relies on (watertight convex pieces that enclose the render mesh within budget). It is not an engine exporter and does not produce a shippable FiveM/engine asset — it proves the geometry properties such an asset must have.
What it witnesses: engines (GTA/FiveM-style prop workflows included) ingest collision as a *compound of convex pieces*, each piece under a per-piece face limit (255 is the common cap). The example builds that structure and derives every property from closed forms, per piece:
- Containment. Every render-mesh vertex lies on the inner side of every face plane of its piece's hull — max excursion 4.4e-08 (tol 2e-4). This holds by construction, not luck: the cage is a coarse lathe whose rings are inflated by
sec(π/n), so each n-gon cage ring *circumscribes* its render ring exactly. Proud details (a rim, a nut) must be paid for in cage rows; concave details (the grooves) are free — the hull bridges over them. That trade-off is the collision-authoring lesson. - Convexity. The same plane test restricted to the hull's own vertices (4.8e-08) — what a convex solver assumes when it uploads the piece.
- Watertight + manifold. Every hull edge borders exactly two faces, and V − E + F = 2: a convex hull is a topological sphere.
- Outward winding. Positive signed volume by the divergence theorem (body piece 1.022381) — inverted collision is a physics no-op.
- Budget. Every piece ≤ 255 faces: body 70, pumper 60, side caps 60+60; the compound totals 250. The naive approach fails this — a hull of the *dense render mesh* measures 380 faces, over budget, which is why real pipelines hull a cage, never the render mesh.
What each check catches on failure: an artist editing the render mesh after the hull was generated (probe: pumper lug stretched 2×, exit 3, measured escape 0.730000); inverted hull winding (probe: polygons flipped, exit 5, signed volume −1.022381, the exact negation); a non-watertight piece (probe: one face deleted, exit 4, three border edges).
Version witness: output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same piece counts, same volumes, same excursions. bmesh.ops.convex_hull and foreach_get are stable across the pair.
The render shows the prop inside its four faceted translucent hull pieces: if any piece failed to enclose its geometry, painted metal would poke through the shell in frame.
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python collision_hull_proxy.py --
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python collision_hull_proxy.py -- --output hydrant.png
blender --background --python collision_hull_proxy.py -- --output hydrant.png --engine cycles
It exits non-zero on failure (render geometry escaping a hull, inverted winding, a non-watertight or non-convex piece, Euler drift, or a piece over the 255-face budget). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
Source
"""A fire hydrant prop plus its convex collision proxies — a runnable example. Witnesses the collision contract every game prop pipeline (engines generally, FiveM/GTA-style prop workflows specifically) depends on: a prop's collision is a COMPOUND of convex pieces, each piece a watertight, outward-wound hull that fully encloses its render geometry and stays inside the engine's per-piece face budget. Each piece is built with `bmesh.ops.convex_hull` from a coarse collision cage — never from the dense render mesh, whose hull would blow the budget — and the check derives every property from closed forms: containment — every render-mesh vertex on the inner side of every face plane of its piece's hull (max excursion printed, tol 2e-4) convexity — the same plane test restricted to the hull's own vertices watertight — every hull edge borders exactly two faces winding — positive signed volume (divergence theorem) manifold — Euler characteristic V - E + F == 2 (a convex hull is a topological sphere) budget — every piece <= 255 faces (the common per-piece convex limit engines impose on collision meshes) The cage is a coarser lathe whose profile rings are inflated by sec(pi / n) so its n-gon rings circumscribe the render rings exactly — that is why containment holds with a measured margin of ~0 instead of by luck. Concave details (grooves) never touch the hull; proud details cost hull faces. That trade-off IS the collision-authoring lesson. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python collision_hull_proxy.py -- # check only blender --background --python collision_hull_proxy.py -- --output h.png # + render """ import bpy, bmesh, sys, os, math, argparse from mathutils import Vector SEGMENTS = 48 # lathe resolution of the render mesh CAGE_SEG_BODY = 8 # collision cage resolutions — the hull face budget CAGE_SEG_CAP = 8 # is spent through these, not through SEGMENTS CAGE_SEG_NUT = 5 HULL_BUDGET = 255 # engine per-piece convex face limit TOL = 2e-4 # plane-test tolerance: cage circumscribes render exactly # --------------------------------------------------------------------------- # Prop construction. The hydrant is lathed around Z; caps are lathed around Z # too and tipped onto their sides by the object rotation. All dimensions are # invented for this prop — no real-world design is referenced. # --------------------------------------------------------------------------- # (radius, height) render profile of body + bonnet, bottom pole to top pole. # Ribs are GROOVES: concave details are free under a convex hull, proud ones # would have to be paid for in cage rows. BODY_PROFILE = [ (0.00, 0.000), (0.60, 0.000), # base flange edge (0.60, 0.045), (0.575, 0.055), # flange groove (concave) (0.575, 0.070), (0.60, 0.080), (0.60, 0.095), # flange top edge (0.48, 0.115), # taper (convex curve, lies inside the cage chord) (0.40, 0.155), (0.37, 0.205), # neck (0.35, 0.320), # barrel (0.346, 0.545), (0.328, 0.555), # barrel groove (concave) (0.328, 0.575), (0.346, 0.590), (0.345, 0.880), # barrel top (0.348, 0.930), (0.336, 0.950), # upper groove (concave) (0.336, 1.000), (0.348, 1.030), (0.36, 1.090), # shoulder under bonnet (0.388, 1.110), # bonnet brim (proud — paid for with a cage row) (0.362, 1.132), # under-brim tuck (concave) (0.35, 1.150), # bonnet dome (concave curve — cage keeps every row) (0.30, 1.220), (0.21, 1.280), (0.10, 1.315), (0.00, 1.325), ] # Cage silhouette: every convex corner of the render profile, nothing else. # sec(pi/8) inflation makes each 8-gon cage ring circumscribe its render ring. CAGE_BODY_PROFILE = [ (0.00, 0.000), (0.60, 0.000), (0.60, 0.095), (0.37, 0.205), (0.345, 0.880), (0.36, 1.090), (0.388, 1.110), # the brim is proud: it must appear in the cage (0.35, 1.150), (0.30, 1.220), (0.21, 1.280), (0.10, 1.315), (0.00, 1.325), ] NUT_PROFILE = [(0.115, 1.325), (0.095, 1.44), (0.0, 1.44)] # Side caps at z=0.76, pumper (bigger) at z=0.66. A cap's outlet direction # equals its azimuth (degrees from +X); the camera sits near azimuth 292, so # the pumper faces it and the side caps read on the flanks. SIDE_CAPS = [(0.66, 315.0), (0.76, 45.0), (0.76, 165.0)] PUMPER = 0 # index of the large front outlet in SIDE_CAPS def cap_profiles(r_cap, reach): """Render and cage (r, z) profiles for one outlet cap. The rim flare is 1.10x the barrel radius so the dense rim rows lie inside the cage chord.""" render = [ (0.0, 0.0), (r_cap, 0.0), (r_cap, reach - 0.05), (r_cap * 1.10, reach - 0.03), # rim (r_cap * 1.10, reach), (r_cap * 0.72, reach + 0.045), # cap dome (0.0, reach + 0.06), ] cage = [ (0.0, 0.0), (r_cap, 0.0), (r_cap * 1.10, reach - 0.03), (r_cap * 1.10, reach), (r_cap * 0.72, reach + 0.045), (0.0, reach + 0.06), ] return render, cage def signed_volume(me): """Divergence-theorem volume; positive when face winding points outward.""" vol = 0.0 for p in me.polygons: vs = [me.vertices[i].co for i in p.vertices] v0 = vs[0] for i in range(1, len(vs) - 1): vol += v0.dot(vs[i].cross(vs[i + 1])) / 6.0 return vol def lathe_object(name, profile, segments, inflate=1.0): """Spin an (r, z) profile around Z. r == 0 ends become pole vertices, so the mesh closes with fans instead of degenerate rings. `inflate` scales radii only — the collision cage uses sec(pi / segments) so its polygon rings circumscribe the render rings exactly.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: bot = top = None if profile[0][0] == 0.0: bot = bm.verts.new((0.0, 0.0, profile[0][1])) profile = profile[1:] if profile[-1][0] == 0.0: top = bm.verts.new((0.0, 0.0, profile[-1][1])) profile = profile[:-1] rings = [] for i in range(segments): a = 2.0 * math.pi * i / segments ca, sa = math.cos(a), math.sin(a) rings.append([bm.verts.new((r * inflate * ca, r * inflate * sa, z)) for r, z in profile]) for i in range(segments): j = (i + 1) % segments for k in range(len(profile) - 1): bm.faces.new((rings[i][k], rings[j][k], rings[j][k + 1], rings[i][k + 1])) if bot is not None: bm.faces.new((rings[j][0], rings[i][0], bot)) if top is not None: bm.faces.new((rings[i][-1], rings[j][-1], top)) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) # recalc picks a consistent winding but not a direction; outward is # pinned below by the same signed-volume form the check uses. bm.to_mesh(me) finally: bm.free() obj = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(obj) if signed_volume(me) < 0.0: for p in me.polygons: p.flip() return obj def make_material(name, color, metallic, roughness): mat = bpy.data.materials.new(name) mat.use_nodes = True bsdf = mat.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (*color, 1.0) bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness return mat def build_hydrant(): """Builds the prop and its collision cages. Returns a list of collision groups: {"name", "render": [objects], "cage": [objects]} — one convex hull piece per group, the compound-collision structure engines ingest.""" bpy.ops.wm.read_factory_settings(use_empty=True) red = make_material("EnamelRed", (0.48, 0.035, 0.022), 0.15, 0.30) yellow = make_material("EnamelYellow", (0.82, 0.53, 0.05), 0.15, 0.32) iron = make_material("CastIron", (0.05, 0.052, 0.06), 0.9, 0.48) iron_light = make_material("CastIronLight", (0.14, 0.145, 0.16), 0.85, 0.42) inflate_body = 1.0 / math.cos(math.pi / CAGE_SEG_BODY) inflate_cap = 1.0 / math.cos(math.pi / CAGE_SEG_CAP) inflate_nut = 1.0 / math.cos(math.pi / CAGE_SEG_NUT) # --- body group: body + bonnet + operating nut --- body = lathe_object("Body", BODY_PROFILE, SEGMENTS) body.data.materials.append(red) body.data.materials.append(yellow) for p in body.data.polygons: # the bonnet dome is the yellow profile tail if p.center.z > 1.09: p.material_index = 1 nut = lathe_object("Nut", NUT_PROFILE, 5) nut.data.materials.append(iron_light) body_cage = lathe_object("BodyCage", CAGE_BODY_PROFILE, CAGE_SEG_BODY, inflate_body) nut_cage = lathe_object("NutCage", NUT_PROFILE, CAGE_SEG_NUT, inflate_nut) groups = [{"name": "body", "render": [body, nut], "cage": [body_cage, nut_cage]}] # --- one group per outlet cap --- for idx, (z, azim) in enumerate(SIDE_CAPS): big = idx == PUMPER r_cap = 0.19 if big else 0.145 reach = 0.62 if big else 0.56 prof_r, prof_c = cap_profiles(r_cap, reach) cap = lathe_object("PumperCap" if big else f"SideCap{idx}", prof_r, 24) cap.data.materials.append(red) lug_prof = [(0.075, reach + 0.05), (0.07, reach + 0.11), (0.0, reach + 0.11)] lug = lathe_object(f"Lug{idx}", lug_prof, 6) lug.data.materials.append(iron) cap_cage = lathe_object(f"CapCage{idx}", prof_c, CAGE_SEG_CAP, inflate_cap) lug_cage = lathe_object(f"LugCage{idx}", lug_prof, CAGE_SEG_CAP, inflate_cap) rot = (math.radians(90), 0.0, math.radians(azim + 90.0)) for o in (cap, lug, cap_cage, lug_cage): o.rotation_euler = rot o.location = (0.0, 0.0, z) groups.append({"name": "pumper" if big else f"side{idx}", "render": [cap, lug], "cage": [cap_cage, lug_cage]}) return groups # --------------------------------------------------------------------------- # Hull construction and the contract checks. # --------------------------------------------------------------------------- def collect_points(objects): """World-space vertex cloud, read via foreach_get.""" bpy.context.view_layer.update() # matrix_world lags RNA writes until evaluated pts = [] for o in objects: me = o.data buf = [0.0] * (len(me.vertices) * 3) me.vertices.foreach_get("co", buf) mw = o.matrix_world for i in range(0, len(buf), 3): pts.append(mw @ Vector((buf[i], buf[i + 1], buf[i + 2]))) return pts def build_hull(name, points): """Convex hull of a point cloud via bmesh.ops.convex_hull.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: verts = [bm.verts.new(p) for p in points] bmesh.ops.convex_hull(bm, input=verts) # the op leaves interior input verts behind; drop any vert not # referenced by a face so V - E + F is meaningful used = {v for f in bm.faces for v in f.verts} for v in list(bm.verts): if v not in used: bm.verts.remove(v) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me) finally: bm.free() obj = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(obj) return obj def max_plane_excursion(me, points): """Largest signed distance of any point to the OUTER side of any face plane. <= 0 means every point is inside or on the hull.""" worst = -math.inf where = None for p in me.polygons: n = p.normal c = p.center for q in points: d = (q - c).dot(n) if d > worst: worst = d where = (p.index, tuple(round(v, 4) for v in q)) return worst, where def check(pieces): """pieces: [(group_name, hull_obj, render_points)]. Returns exit code.""" total_faces = 0 for name, hull, points in pieces: me = hull.data nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) total_faces += nf bm = bmesh.new() # watertight: every edge borders exactly two faces try: bm.from_mesh(me) bad_edges = sum(1 for e in bm.edges if len(e.link_faces) != 2) finally: bm.free() if bad_edges: print(f"ERROR: piece {name}: {bad_edges} hull edge(s) do not border " f"exactly two faces — proxy is not watertight", file=sys.stderr) return 4 vol = signed_volume(me) # winding: positive signed volume if vol <= 0.0: print(f"ERROR: piece {name}: signed volume {vol:.6f} <= 0 — hull " f"winding is inverted", file=sys.stderr) return 5 contain_err, where = max_plane_excursion(me, points) if contain_err > TOL: print(f"ERROR: piece {name}: render vertex escapes its hull by " f"{contain_err:.6f} (tol {TOL}) at face {where[0]}, point " f"{where[1]} — proxy does not enclose the render mesh", file=sys.stderr) return 3 hull_pts = [v.co.copy() for v in me.vertices] hull_err, _ = max_plane_excursion(me, hull_pts) if hull_err > TOL: print(f"ERROR: piece {name}: hull vertex off its own face plane by " f"{hull_err:.6f} — proxy is not convex", file=sys.stderr) return 6 euler = nv - ne + nf # a convex hull is a topological sphere if euler != 2: print(f"ERROR: piece {name}: Euler characteristic {euler} != 2 — " f"hull is not a closed manifold sphere", file=sys.stderr) return 7 if nf > HULL_BUDGET: print(f"ERROR: piece {name}: {nf} faces, over the {HULL_BUDGET}-face " f"per-piece collision budget", file=sys.stderr) return 8 print(f"piece {name}: verts={nv} edges={ne} faces={nf} watertight=True " f"euler=2 volume={vol:.6f} containment={contain_err:.3e} " f"convexity={hull_err:.3e} budget={nf}<={HULL_BUDGET}") print(f"compound: pieces={len(pieces)} total_faces={total_faces} " f"(per-piece budget {HULL_BUDGET}; engine ingests the compound)") return 0 # --------------------------------------------------------------------------- # Render: dark-studio staging per docs/VISUAL-STYLE.md. The hull pieces draw # as faceted translucent shells with a thin wire overlay; if a piece failed # to enclose its geometry, painted metal would poke through the shell. # --------------------------------------------------------------------------- def eevee_engine_id(): return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' def render_still(groups, pieces, path, engine): scene = bpy.context.scene for g in groups: for o in g["render"]: for p in o.data.polygons: p.use_smooth = True for g in groups: # cages exist only to build hulls; never rendered for o in g["cage"]: o.hide_render = True # translucent shell: Transparent mixed over Principled works identically # in EEVEE and Cycles without touching blend-method RNA (renamed in 4.2) shell = bpy.data.materials.new("HullShell") shell.use_nodes = True nt = shell.node_tree nt.nodes.clear() out = nt.nodes.new("ShaderNodeOutputMaterial") mix = nt.nodes.new("ShaderNodeMixShader") mix.inputs[0].default_value = 0.14 # 86% transparent: output = # (1-Fac)*Transparent + Fac*Principled transp = nt.nodes.new("ShaderNodeBsdfTransparent") bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled") bsdf.inputs["Base Color"].default_value = (0.16, 0.55, 0.75, 1.0) bsdf.inputs["Roughness"].default_value = 0.12 nt.links.new(transp.outputs[0], mix.inputs[1]) nt.links.new(bsdf.outputs[0], mix.inputs[2]) nt.links.new(mix.outputs[0], out.inputs[0]) wire_mat = make_material("HullWire", (0.35, 0.85, 1.0), 0.0, 0.4) wbsdf = wire_mat.node_tree.nodes["Principled BSDF"] wbsdf.inputs["Emission Color"].default_value = (0.2, 0.65, 0.85, 1.0) wbsdf.inputs["Emission Strength"].default_value = 0.6 for name, hull, _ in pieces: hull.data.materials.append(shell) for p in hull.data.polygons: p.use_smooth = False # facets must read as facets hull.data.materials.append(wire_mat) wire = bpy.data.objects.new(f"{name}Wire", hull.data) mod = wire.modifiers.new("Wire", 'WIREFRAME') mod.thickness = 0.010 mod.material_offset = 1 scene.collection.objects.link(wire) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) bm.to_mesh(floor_me) finally: bm.free() fmat = make_material("Studio", (0.03, 0.032, 0.037), 0.0, 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, 11.0, 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) # key/fill/rim/wedge per docs/VISUAL-STYLE.md light("Key", (-3.5, -4.5, 5.5), 500.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35)) light("Fill", (5.0, -3.5, 2.5), 130.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50)) light("Rim", (1.5, 4.5, 3.5), 300.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170)) light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) # small warm glint off the camera-left shoulder: lifts the pumper cap face light("Glint", (-1.5, -4.0, 3.5), 260.0, 2.0, (1.0, 0.9, 0.75), (55, 0, -15)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 52.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (2.05, -4.5, 1.85) scene.collection.objects.link(cam) target = bpy.data.objects.new("Aim", None) target.location = (0.0, 0.0, 0.72) scene.collection.objects.link(target) con = cam.constraints.new('TRACK_TO') con.target = target scene.camera = cam scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id() if engine == 'cycles': scene.cycles.samples = 64 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 = 'PNG' scene.render.filepath = path # AgX would flatten the enamel toward pastel (docs/VISUAL-STYLE.md) scene.view_settings.view_transform = 'Standard' 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 a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") args = p.parse_args(argv) groups = build_hydrant() pieces = [] for g in groups: hull = build_hull(f"{g['name']}Hull", collect_points(g["cage"])) pieces.append((g["name"], hull, collect_points(g["render"]))) code = check(pieces) if code: return code if args.output: if not render_still(groups, pieces, os.path.abspath(args.output), args.engine): print("ERROR: render produced no file", file=sys.stderr) return 9 print(f"rendered still {args.output}") print("collision-hull-proxy OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)