scripts/_bpyx.py
"""_bpyx - tiny shared helpers for blender-agent-skills scripts (Blender 5.2+).
Source of truth: shared/_bpyx.py. Copies in skills/*/scripts/ are synced by
tools/sync_shared.py - never edit a copy.
Every skill script follows one contract:
run(**kwargs) -> dict # call from MCP / Python (import the module)
blender -b f.blend -P x.py -- --arg v # CLI: prints one line "@@JSON@@{...}"
"""
import argparse
import json
import sys
import bpy
MARK = "@@JSON@@"
def argv():
"""Arguments after '--' (Blender ignores them)."""
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def _default(o):
if hasattr(o, "__len__") and not isinstance(o, str):
try:
return [round(v, 5) if isinstance(v, float) else v for v in o]
except TypeError:
pass
if isinstance(o, (set, frozenset)):
return sorted(o)
return str(o)
def emit(data):
"""Print compact JSON on one marked line (parsed by run_in_blender.py)."""
print(MARK + json.dumps(data, separators=(",", ":"), default=_default), flush=True)
return data
def parser(doc):
"""ArgumentParser whose help is the script docstring's first line."""
return argparse.ArgumentParser(description=(doc or "").strip().splitlines()[0])
def cli(run, p):
"""CLI entry: parse args after '--', run, emit JSON. Exit 1 on failure."""
args = p.parse_args(argv())
try:
result = run(**vars(args))
save = getattr(args, "save", None)
if save:
bpy.ops.wm.save_as_mainfile(filepath=bpy.path.abspath(save))
result["saved"] = save
emit(result)
except Exception as e: # report, don't dump a traceback into agent context
emit({"ok": False, "error": f"{type(e).__name__}: {e}"})
if bpy.app.background:
sys.exit(1)
def add_common(p, save=True):
"""--objects / --save flags shared by most scripts."""
p.add_argument("--objects", nargs="*", default=None,
help="object names (default: selected, else all meshes)")
if save:
p.add_argument("--save", default=None, help="save .blend to this path after run")
return p
def objects(names=None, types=("MESH",)):
"""Resolve names -> objects. No names: selected of `types`, else all visible of `types`."""
if names:
missing = [n for n in names if n not in bpy.data.objects]
if missing:
raise KeyError(f"objects not found: {missing}")
return [bpy.data.objects[n] for n in names]
pool = [o for o in bpy.context.scene.objects if not types or o.type in types]
sel = [o for o in pool if o.select_get()]
return sel or [o for o in pool if o.visible_get()]
def object_mode():
"""Leave edit/sculpt/pose modes; most data API work needs OBJECT mode."""
ob = bpy.context.view_layer.objects.active
if ob and ob.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def select_only(objs, active=None):
"""Select exactly `objs` and make `active` (default first) active."""
for o in bpy.context.view_layer.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objs[0] if objs else None)
def node(tree, type_):
"""First node of `type_` (e.g. 'BSDF_PRINCIPLED'). Names are localized - never look up by name."""
return next((n for n in tree.nodes if n.type == type_), None)
def enum_ids(owner, prop):
"""Valid identifiers of an enum property (works on types or instances)."""
return [i.identifier for i in owner.bl_rna.properties[prop].enum_items]
def set_enum(owner, prop, value):
"""Assign an enum; on failure raise listing valid values (dynamic enums under-report)."""
try:
setattr(owner, prop, value)
except TypeError as e:
raise ValueError(f"{prop}={value!r} invalid: {e}") from None
def mesh_tris(ob, evaluated=True):
"""Triangle count (evaluated = with modifiers)."""
if evaluated:
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
try:
me.calc_loop_triangles()
return len(me.loop_triangles)
finally:
eo.to_mesh_clear()
me = ob.data
me.calc_loop_triangles()
return len(me.loop_triangles)
def r(v, n=4):
"""Round a float or a vector for compact JSON."""
if isinstance(v, float):
return round(v, n)
return [round(x, n) for x in v]
scripts/kit_check.py
"""Validate modular kit pieces so they snap together: applied transforms, footprint on the grid,
pivot convention, open edges only on the piece's outer faces (seams), consistent texel density,
naming. --fix applies rotation/scale and moves each origin to the pivot convention.
Pivot conventions: corner = bounding-box min (x, y, z), the usual kit/Roblox choice;
bottom-center = center of the bottom face; center = bounding-box center.
Usage:
blender -b f.blend -P kit_check.py -- [--objects SM_Wall_4m SM_Floor_4m] [--grid 0.5]
[--pivot corner|bottom-center|center] [--texture 2048] [--tol 0.001] [--prefix SM_] [--fix]
[--save out.blend]
MCP: import kit_check; print(kit_check.run(objects=["SM_Wall_4m"], grid=0.5, fix=True))
"""
import math
import os
import sys
import bmesh
import bpy
import numpy as np
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
def _on_grid(v, grid, tol):
return abs(v / grid - round(v / grid)) * grid <= tol
def _pivot_target(lo, hi, pivot):
if pivot == "corner":
return Vector(lo)
c = (Vector(lo) + Vector(hi)) / 2
return Vector((c.x, c.y, lo[2])) if pivot == "bottom-center" else c
def _texel_density(ob, texture):
me = ob.data
if not me.uv_layers:
return None
me.calc_loop_triangles()
uv = me.uv_layers.active.uv
mw = ob.matrix_world
a3 = a2 = 0.0
for t in me.loop_triangles:
p = [mw @ me.vertices[v].co for v in t.vertices]
q = [Vector((*uv[li].vector, 0)) for li in t.loops]
a3 += (p[1] - p[0]).cross(p[2] - p[0]).length / 2
a2 += (q[1] - q[0]).cross(q[2] - q[0]).length / 2
return math.sqrt(a2 / a3) * texture if a3 > 0 and a2 > 0 else None
def _fix(ob, pivot):
"""Apply rotation+scale, then move the origin to the pivot without moving the geometry."""
me = ob.data
loc, rot, scl = ob.matrix_world.decompose()
me.transform(Matrix.LocRotScale(None, rot, scl))
ob.matrix_world = Matrix.Translation(loc)
co = np.empty(len(me.vertices) * 3, np.float32)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
off = _pivot_target(co.min(0), co.max(0), pivot)
me.transform(Matrix.Translation(-off))
ob.location = loc + off
me.update()
def run(objects=None, grid=0.5, pivot="corner", texture=2048, tol=0.001, prefix="SM_", fix=False, **_):
bx.object_mode()
if pivot not in ("corner", "bottom-center", "center"):
raise ValueError("--pivot must be corner, bottom-center or center")
obs = [o for o in bx.objects(objects) if o.type == "MESH"]
pieces, tds = {}, {}
for ob in obs:
if fix:
_fix(ob, pivot)
e, w = [], []
loc, rot, scl = ob.matrix_world.decompose()
if any(abs(s - 1) > 1e-4 for s in scl):
e.append(f"unapplied scale {bx.r(list(scl), 3)}")
if rot.angle > 1e-4:
e.append(f"unapplied rotation {round(math.degrees(rot.angle), 2)} deg")
me = ob.data
co = np.empty(len(me.vertices) * 3, np.float32)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
lo, hi = co.min(0), co.max(0)
dims = hi - lo
# lengths must tile; anything thinner than one cell is a thickness (walls, trims) and may be off-grid
bad_dims = [round(float(d), 4) for d in dims[:2] if d >= grid - tol and not _on_grid(float(d), grid, tol)]
if bad_dims:
e.append(f"footprint {bx.r(dims.tolist(), 4)} m is not a multiple of the {grid} m grid")
if not _on_grid(float(dims[2]), grid, tol):
w.append(f"height {round(float(dims[2]), 4)} m is off-grid (fine for props, not for walls)")
tgt = _pivot_target(lo, hi, pivot)
if tgt.length > tol:
e.append(f"pivot is not at the {pivot}: offset {bx.r(list(tgt), 4)} m (--fix)")
if not all(_on_grid(float(c), grid, tol) for c in loc[:2]):
w.append(f"placed off-grid at {bx.r(list(loc), 4)}")
bm = bmesh.new()
bm.from_mesh(me)
open_v = {v for ed in bm.edges if ed.is_boundary for v in ed.verts}
axes = [i for i in range(3) if dims[i] > tol] # a flat tile's Z faces would contain every vertex
inner = [v for v in open_v if not any(abs(v.co[i] - b[i]) <= tol for b in (lo, hi) for i in axes)]
seam = [v.co.copy() for v in open_v if v not in set(inner)]
bm.free()
if inner:
e.append(f"{len(inner)} open-edge verts inside the piece (holes; seams must lie on its outer faces)")
if seam and not all(_on_grid(float(c[i]), grid, tol) for c in seam for i in range(2)
if abs(c[i] - lo[i]) > tol and abs(c[i] - hi[i]) > tol):
w.append("seam verts off the grid: neighbours won't weld")
if prefix and not ob.name.startswith(prefix):
w.append(f"name lacks {prefix!r} prefix")
td = _texel_density(ob, texture)
if td is None:
w.append("no UVs")
else:
tds[ob.name] = td
pieces[ob.name] = {"dims_m": bx.r(dims.tolist(), 4), "tris": bx.mesh_tris(ob, False),
"texel_px_per_m": round(td, 1) if td else None, "errors": e, "warnings": w}
if len(tds) > 1:
med = float(np.median(list(tds.values())))
for n, td in tds.items():
if abs(td - med) / med > 0.25:
pieces[n]["warnings"].append(f"texel density {round(td)} px/m vs kit median {round(med)} (>25% off)")
ok = not any(p["errors"] for p in pieces.values())
return {"ok": True, "pass": ok, "grid_m": grid, "pivot": pivot, "fixed": fix, "pieces": pieces}
if __name__ == "__main__":
p = bx.add_common(bx.parser(__doc__))
p.add_argument("--grid", type=float, default=0.5)
p.add_argument("--pivot", default="corner", choices=("corner", "bottom-center", "center"))
p.add_argument("--texture", type=int, default=2048, help="texture px for texel density")
p.add_argument("--tol", type=float, default=0.001)
p.add_argument("--prefix", default="SM_")
p.add_argument("--fix", action="store_true")
bx.cli(run, p)
scripts/scatter.py
"""Biome scatter with Geometry Nodes: Poisson-disk instances of a collection on a surface, filtered by
max slope, height band and an optional density mask, with random yaw/scale and partial normal
alignment. Instances stay instances (cheap, GPU-instanced in glTF) unless --realize.
One modifier per layer (--name), e.g. trees on gentle slopes, rocks on steep ones, grass in a mask.
Height is in the target's object space. The result counts the instances and checks every one
against the slope and height limits.
Usage:
blender -b f.blend -P scatter.py -- --target Terrain --collection Trees [--density 0.02]
[--min-distance 4] [--max-slope 25] [--height-min -1e9] [--height-max 1e9] [--mask VG|ATTR]
[--scale 0.8 1.2] [--align 0.0] [--seed 0] [--realize] [--name ENV_Trees]
[--export-points //trees.json] [--save out.blend]
MCP: import scatter; print(scatter.run(target="Terrain", collection="Rocks", density=0.1, max_slope=60))
"""
import json
import math
import os
import sys
import bpy
from mathutils import Vector, geometry
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
TREE = "ENV_Scatter"
def _on(socks, name):
"""Enabled socket by name: Random Value keeps one hidden Min/Max/Value per data type (5.2 renamed ids)."""
return next(s for s in socks if s.enabled and s.name == name)
def build_tree():
ng = bpy.data.node_groups.get(TREE)
if ng is not None:
return ng
ng = bpy.data.node_groups.new(TREE, "GeometryNodeTree")
ng.is_modifier = True
for nm, kind, d in (("Geometry", "Geometry", None), ("Collection", "Collection", None),
("Density", "Float", 0.1), ("Distance Min", "Float", 1.0), ("Max Slope", "Float", 30.0),
("Height Min", "Float", -1e9), ("Height Max", "Float", 1e9), ("Mask", "Float", 1.0),
("Scale Min", "Float", 0.8), ("Scale Max", "Float", 1.2), ("Align", "Float", 0.0),
("Seed", "Int", 0), ("Realize", "Bool", False)):
s = ng.interface.new_socket(nm, in_out="INPUT", socket_type=f"NodeSocket{kind}")
if d is not None:
s.default_value = d
ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
L = ng.links.new
def N(type_, **props):
node = ng.nodes.new(type_)
for k, v in props.items():
setattr(node, k, v)
return node
gi, go = N("NodeGroupInput"), N("NodeGroupOutput")
I = gi.outputs # noqa: E741
dist = N("GeometryNodeDistributePointsOnFaces", distribute_method="POISSON")
L(I["Geometry"], dist.inputs["Mesh"])
L(I["Density"], dist.inputs["Density Max"])
L(I["Distance Min"], dist.inputs["Distance Min"])
L(I["Mask"], dist.inputs["Density Factor"])
L(I["Seed"], dist.inputs["Seed"])
# keep points whose surface normal is within Max Slope of up and whose height is inside the band
sep_n = N("ShaderNodeSeparateXYZ")
L(dist.outputs["Normal"], sep_n.inputs[0])
cosmax = N("ShaderNodeMath", operation="COSINE")
rad = N("ShaderNodeMath", operation="RADIANS")
L(I["Max Slope"], rad.inputs[0])
L(rad.outputs[0], cosmax.inputs[0])
flat = N("FunctionNodeCompare", data_type="FLOAT", operation="GREATER_EQUAL")
L(sep_n.outputs["Z"], _on(flat.inputs, "A"))
L(cosmax.outputs[0], _on(flat.inputs, "B"))
sep_p = N("ShaderNodeSeparateXYZ")
L(N("GeometryNodeInputPosition").outputs[0], sep_p.inputs[0])
above = N("FunctionNodeCompare", data_type="FLOAT", operation="GREATER_EQUAL")
L(sep_p.outputs["Z"], _on(above.inputs, "A"))
L(I["Height Min"], _on(above.inputs, "B"))
below = N("FunctionNodeCompare", data_type="FLOAT", operation="LESS_EQUAL")
L(sep_p.outputs["Z"], _on(below.inputs, "A"))
L(I["Height Max"], _on(below.inputs, "B"))
a1 = N("FunctionNodeBooleanMath", operation="AND")
L(flat.outputs[0], a1.inputs[0])
L(above.outputs[0], a1.inputs[1])
keep = N("FunctionNodeBooleanMath", operation="AND")
L(a1.outputs[0], keep.inputs[0])
L(below.outputs[0], keep.inputs[1])
# rotation: lean Z toward the surface normal by Align, then a random yaw about the local Z
yaw = N("FunctionNodeRandomValue", data_type="FLOAT")
_on(yaw.inputs, "Max").default_value = 2 * math.pi
L(I["Seed"], _on(yaw.inputs, "Seed"))
eul = N("ShaderNodeCombineXYZ")
L(_on(yaw.outputs, "Value"), eul.inputs["Z"])
e2r = N("FunctionNodeEulerToRotation")
L(eul.outputs[0], e2r.inputs[0])
align = N("FunctionNodeAlignRotationToVector")
align.axis = "Z"
L(I["Align"], align.inputs["Factor"])
L(dist.outputs["Normal"], align.inputs["Vector"])
rot = N("FunctionNodeRotateRotation")
rot.rotation_space = "LOCAL"
L(align.outputs[0], rot.inputs["Rotation"])
L(e2r.outputs[0], rot.inputs["Rotate By"])
scl = N("FunctionNodeRandomValue", data_type="FLOAT")
L(I["Scale Min"], _on(scl.inputs, "Min"))
L(I["Scale Max"], _on(scl.inputs, "Max"))
sd = N("ShaderNodeMath", operation="ADD")
L(I["Seed"], sd.inputs[0])
sd.inputs[1].default_value = 7
L(sd.outputs[0], _on(scl.inputs, "Seed"))
col = N("GeometryNodeCollectionInfo")
col.transform_space = "ORIGINAL"
L(I["Collection"], col.inputs["Collection"])
col.inputs["Separate Children"].default_value = True
col.inputs["Reset Children"].default_value = True # each child instanced at its own origin
inst = N("GeometryNodeInstanceOnPoints")
L(dist.outputs["Points"], inst.inputs["Points"])
L(keep.outputs[0], inst.inputs["Selection"])
L(col.outputs[0], inst.inputs["Instance"])
inst.inputs["Pick Instance"].default_value = True
pick = N("FunctionNodeRandomValue", data_type="INT")
_on(pick.inputs, "Min").default_value, _on(pick.inputs, "Max").default_value = 0, 1 << 20
L(I["Seed"], _on(pick.inputs, "Seed"))
L(_on(pick.outputs, "Value"), inst.inputs["Instance Index"])
L(rot.outputs[0], inst.inputs["Rotation"])
L(_on(scl.outputs, "Value"), inst.inputs["Scale"])
real = N("GeometryNodeRealizeInstances")
L(inst.outputs[0], real.inputs["Geometry"])
sw = N("GeometryNodeSwitch", input_type="GEOMETRY")
L(I["Realize"], sw.inputs["Switch"])
L(inst.outputs[0], sw.inputs["False"])
L(real.outputs[0], sw.inputs["True"])
join = N("GeometryNodeJoinGeometry")
L(sw.outputs[0], join.inputs[0])
L(I["Geometry"], join.inputs[0])
L(join.outputs[0], go.inputs[0])
return ng
def _instances(target):
dg = bpy.context.evaluated_depsgraph_get()
return [(i.matrix_world.copy(), i.object.original.name) for i in dg.object_instances
if i.is_instance and i.parent and i.parent.original == target]
def run(target=None, collection=None, density=0.1, min_distance=1.0, max_slope=30.0, height_min=-1e9,
height_max=1e9, mask=None, scale=(0.8, 1.2), align=0.0, seed=0, realize=False, name=None,
export_points=None, **_):
bx.object_mode()
if not target or not collection:
raise ValueError("--target and --collection are required")
tgt, coll = bpy.data.objects[target], bpy.data.collections[collection]
if not coll.all_objects:
raise ValueError(f"collection {collection!r} is empty")
name = name or f"ENV_{collection}"
ng = build_tree()
md = tgt.modifiers.get(name) or tgt.modifiers.new(name, "NODES")
md.node_group = ng
ids = {i.name: i.identifier for i in ng.interface.items_tree if getattr(i, "in_out", "") == "INPUT"}
ins = md.properties.inputs
for k, v in (("Collection", coll), ("Density", density), ("Distance Min", min_distance), ("Max Slope", max_slope),
("Height Min", height_min), ("Height Max", height_max), ("Scale Min", scale[0]),
("Scale Max", scale[1]), ("Align", align), ("Seed", seed), ("Realize", realize)):
getattr(ins, ids[k]).value = v
m = getattr(ins, ids["Mask"])
if mask:
m.type, m.attribute_name = "ATTRIBUTE", mask
else:
m.type, m.value = "VALUE", 1.0
for c in (coll,): # the source collection shouldn't render at the origin
lc = _layer_coll(bpy.context.view_layer.layer_collection, c.name)
if lc is not None:
lc.exclude = True
res = {"ok": True, "target": target, "modifier": name, "collection": collection}
if realize:
me = tgt.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
res["tris_total"] = sum(len(p.vertices) - 2 for p in me.polygons)
tgt.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh_clear()
return res
inst = _instances(tgt)
res["instances"] = len(inst)
res["by_source"] = {}
for _m, src in inst:
res["by_source"][src] = res["by_source"].get(src, 0) + 1
if inst: # verify the filters on the surface under each instance, with GN's interpolated normal
surf = tgt.evaluated_get(bpy.context.evaluated_depsgraph_get()) # instances aren't in its mesh
me = surf.to_mesh()
me.calc_loop_triangles()
cn = me.corner_normals
by_poly = {}
for t in me.loop_triangles:
by_poly.setdefault(t.polygon_index, []).append(t)
tinv = tgt.matrix_world.inverted()
worst, zs = 0.0, []
for mw, _src in inst[:: max(1, len(inst) // 500)]:
p = tinv @ mw.translation
ok, loc, _n, fi = surf.closest_point_on_mesh(p)
if not ok:
continue
best = None
for t in by_poly.get(fi, []):
a, b, c = (me.vertices[v].co for v in t.vertices)
w = geometry.barycentric_transform(loc, a, b, c, Vector((1, 0, 0)), Vector((0, 1, 0)), Vector((0, 0, 1)))
if best is None or min(w) > min(best[1]):
best = (t, w)
t, w = best
n = sum((cn[li].vector * wi for li, wi in zip(t.loops, w)), Vector()).normalized()
worst = max(worst, math.degrees(math.acos(max(-1.0, min(1.0, n.z)))))
zs.append(p.z)
surf.to_mesh_clear()
res["slope_max_deg"] = round(worst, 2)
res["height_range"] = [round(min(zs), 3), round(max(zs), 3)]
area = sum(pl.area for pl in tgt.data.polygons) * abs(tgt.matrix_world.determinant()) ** (2 / 3)
res["per_m2"] = round(len(inst) / max(area, 1e-9), 5)
else:
res["warning"] = "no instances: density too low, or slope/height/mask filters exclude everything"
if export_points and inst: # world transforms for engine foliage/instancing tools (Z-up, meters)
path = bpy.path.abspath(export_points)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
rows = []
for mw, src in inst:
loc, rot, scl = mw.decompose()
rows.append({"source": src, "location": [round(v, 4) for v in loc],
"rotation_quat_wxyz": [round(v, 5) for v in rot], "scale": [round(v, 4) for v in scl]})
with open(path, "w", encoding="utf-8") as f:
json.dump({"up": "Z", "units": "m", "instances": rows}, f)
res["points_file"] = path
return res
def _layer_coll(lc, name):
if lc.collection.name == name:
return lc
for ch in lc.children:
f = _layer_coll(ch, name)
if f is not None:
return f
return None
if __name__ == "__main__":
p = bx.parser(__doc__)
p.add_argument("--target", required=True)
p.add_argument("--collection", required=True)
for k, d in (("density", 0.1), ("min-distance", 1.0), ("max-slope", 30.0), ("height-min", -1e9),
("height-max", 1e9), ("align", 0.0)):
p.add_argument(f"--{k}", type=float, default=d)
p.add_argument("--mask", default=None, help="vertex group / float attribute = density factor")
p.add_argument("--scale", type=float, nargs=2, default=(0.8, 1.2))
p.add_argument("--seed", type=int, default=0)
p.add_argument("--realize", action="store_true")
p.add_argument("--name", default=None)
p.add_argument("--export-points", default=None, help="JSON of instance world transforms")
p.add_argument("--save", default=None)
bx.cli(run, p)
scripts/terrain.py
"""Game-ready terrain: noise (fbm, ridged mountains, rolling hills) or a heightmap image on a grid,
thermal erosion, terraces, flattened play areas, slope/height attributes, an auto slope/height
material, and engine exports (16-bit heightmap + RGBA splat map, same grid).
Grid --res is vertices per side: use 2^n + 1 (129, 257, 513, 1025) so heightmaps import 1:1 into
Unity Terrain / UE Landscape. Attributes ENV_Slope (deg / 90) and ENV_Height (0-1) drive the material
and blender-environment-kits scatter.py.
Usage:
blender -b f.blend -P terrain.py -- [--size 200] [--res 257] [--height 30] [--type fbm|ridged|hills]
[--scale 80] [--octaves 6] [--seed 0] [--heightmap hm.png] [--erosion 20] [--terrace 0]
[--flatten 0 0 15] [--material] [--export-heightmap //T_Height.png|.raw] [--export-splat //T_Splat.png]
[--name Terrain] [--save out.blend]
MCP: import terrain; print(terrain.run(size=200, height=30, type="ridged", erosion=20, material=True))
"""
import math
import os
import sys
import bpy
import numpy as np
from mathutils import Vector, noise
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
ROCK_DEG, SNOW_H = 35.0, 0.75 # rock above 35 deg, snow above 75% height on gentler slopes
def _noise_field(res, size, kind, scale, octaves, seed):
off = Vector((seed * 31.7, seed * 17.3, seed * 7.1))
xs = np.linspace(-size / 2, size / 2, res)
h = np.empty((res, res), np.float64)
basis = "PERLIN_ORIGINAL"
for j, y in enumerate(xs):
for i, x in enumerate(xs):
p = Vector((x / scale, y / scale, 0.0)) + off
if kind == "ridged":
h[j, i] = noise.ridged_multi_fractal(p, 1.0, 2.0, octaves, 1.0, 2.0, noise_basis=basis)
elif kind == "hills":
h[j, i] = noise.hetero_terrain(p, 1.0, 2.0, min(octaves, 4), 0.5, noise_basis=basis)
else:
h[j, i] = noise.fractal(p, 1.0, 2.0, octaves, noise_basis=basis)
return h
def _heightmap(path, res):
img = bpy.data.images.load(bpy.path.abspath(path), check_existing=True)
img.colorspace_settings.is_data = True
w, hgt = img.size
a = np.empty(w * hgt * 4, np.float32)
img.pixels.foreach_get(a)
a = a.reshape(hgt, w, 4)[..., 0]
yi = np.linspace(0, hgt - 1, res).round().astype(int)
xi = np.linspace(0, w - 1, res).round().astype(int)
return a[np.ix_(yi, xi)].astype(np.float64)
def _erode(h, cell, steps, talus_deg=33.0, rate=0.25):
"""Thermal erosion: material above the talus angle slides to lower neighbours (4-neighbourhood)."""
talus = math.tan(math.radians(talus_deg)) * cell
for _ in range(steps):
move = np.zeros_like(h)
for ax, sh in ((0, 1), (0, -1), (1, 1), (1, -1)):
nb = np.roll(h, sh, axis=ax)
d = h - nb - talus
d[d < 0] = 0
if ax == 0:
d[0 if sh == 1 else -1, :] = 0 # no wrap-around at the borders
else:
d[:, 0 if sh == 1 else -1] = 0
m = d * rate * 0.25
move -= m
move += np.roll(m, -sh, axis=ax)
h = h + move
return h
def _slope_deg(h, cell):
gy, gx = np.gradient(h, cell)
return np.degrees(np.arctan(np.hypot(gx, gy)))
def _material(name):
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
nt = mat.node_tree
bsdf = bx.node(nt, "BSDF_PRINCIPLED")
for n in [n for n in nt.nodes if n.name.startswith("ENV_T_")]:
nt.nodes.remove(n)
def nn(t, **kw):
n = nt.nodes.new(t)
n.name = "ENV_T_" + n.name
for k, v in kw.items():
setattr(n, k, v)
return n
sl = nn("ShaderNodeAttribute", attribute_type="GEOMETRY", attribute_name="ENV_Slope")
ht = nn("ShaderNodeAttribute", attribute_type="GEOMETRY", attribute_name="ENV_Height")
tc = nn("ShaderNodeTexCoord")
nz = nn("ShaderNodeTexNoise")
nt.links.new(tc.outputs["Object"], nz.inputs["Vector"])
nz.inputs["Scale"].default_value = 0.15
nz.inputs["Detail"].default_value = 6.0
def ramp(src, lo, hi):
m = nn("ShaderNodeMapRange", clamp=True)
add = nn("ShaderNodeMath", operation="MULTIPLY_ADD")
nt.links.new(nz.outputs["Factor"], add.inputs[0])
add.inputs[1].default_value = 0.12
nt.links.new(src, add.inputs[2])
nt.links.new(add.outputs[0], m.inputs[0])
m.inputs[1].default_value, m.inputs[2].default_value = lo, hi
return m.outputs[0]
rock = ramp(sl.outputs["Fac"], ROCK_DEG / 90 - 0.03, ROCK_DEG / 90 + 0.03)
snow = ramp(ht.outputs["Fac"], SNOW_H - 0.03, SNOW_H + 0.03)
snow_ok = nn("ShaderNodeMath", operation="MULTIPLY")
nt.links.new(snow, snow_ok.inputs[0])
nt.links.new(ramp(sl.outputs["Fac"], 0.5, 0.42), snow_ok.inputs[1]) # snow slides off > ~40 deg
def mix(fac, a, b):
m = nn("ShaderNodeMix", data_type="RGBA")
nt.links.new(fac, m.inputs[0])
for i, v in ((6, a), (7, b)):
if isinstance(v, bpy.types.NodeSocket):
nt.links.new(v, m.inputs[i])
else:
m.inputs[i].default_value = v
return m.outputs[2]
grass = mix(nz.outputs["Factor"], (0.045, 0.075, 0.02, 1), (0.08, 0.1, 0.03, 1)) # measured-range grass albedo
col = mix(snow_ok.outputs[0], mix(rock, grass, (0.2, 0.18, 0.16, 1)), (0.8, 0.82, 0.86, 1))
nt.links.new(col, bsdf.inputs["Base Color"])
rough = mix(rock, (0.85, 0.85, 0.85, 1), (0.7, 0.7, 0.7, 1))
bw = nn("ShaderNodeRGBToBW")
nt.links.new(rough, bw.inputs[0])
nt.links.new(bw.outputs[0], bsdf.inputs["Roughness"])
return mat
def _save_png16(path, rgba, res, bw):
img = bpy.data.images.new(os.path.basename(path), res, res, alpha=not bw, float_buffer=True)
img.colorspace_settings.is_data = True
img.pixels.foreach_set(rgba.astype(np.float32).ravel())
sc = bpy.context.scene
st, vt = sc.render.image_settings, sc.view_settings.view_transform
prev = (st.file_format, st.color_mode, st.color_depth)
st.file_format, st.color_mode, st.color_depth = "PNG", "BW" if bw else "RGBA", "16"
sc.view_settings.view_transform = "Raw" # write data values, not a display transform
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
img.save_render(path, scene=sc)
finally:
st.file_format, st.color_mode, st.color_depth = prev
sc.view_settings.view_transform = vt
bpy.data.images.remove(img)
return path
def run(size=200.0, res=257, height=30.0, type="fbm", scale=80.0, octaves=6, seed=0, heightmap=None,
erosion=0, terrace=0.0, flatten=None, material=False, export_heightmap=None, export_splat=None,
name="Terrain", **_):
bx.object_mode()
cell = size / (res - 1)
h = _heightmap(heightmap, res) if heightmap else _noise_field(res, size, type, scale, octaves, seed)
h = (h - h.min()) / max(np.ptp(h), 1e-9) * height
if erosion:
h = _erode(h, cell, int(erosion))
if terrace > 0:
frac = (h / terrace) % 1.0
h = (np.floor(h / terrace) + np.clip((frac - 0.75) / 0.25, 0, 1)) * terrace # flats with short risers
xs = np.linspace(-size / 2, size / 2, res)
X, Y = np.meshgrid(xs, xs)
if flatten:
cx, cy, rad = flatten
d = np.hypot(X - cx, Y - cy)
target = h[np.abs(d) <= rad].mean() if (d <= rad).any() else h.mean()
w = np.clip((d - rad) / (rad * 0.6), 0, 1)
w = w * w * (3 - 2 * w) # smoothstep blend back into the terrain
h = target + (h - target) * w
slope = _slope_deg(h, cell)
hn = (h - h.min()) / max(np.ptp(h), 1e-9)
me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name)
me.clear_geometry()
me.vertices.add(res * res)
me.vertices.foreach_set("co", np.stack([X, Y, h], -1).astype(np.float32).ravel())
q = np.arange(res * res).reshape(res, res)
quads = np.stack([q[:-1, :-1], q[:-1, 1:], q[1:, 1:], q[1:, :-1]], -1).reshape(-1, 4)
me.loops.add(quads.size)
me.loops.foreach_set("vertex_index", quads.ravel().astype(np.int32))
me.polygons.add(len(quads))
me.polygons.foreach_set("loop_start", np.arange(0, quads.size, 4, dtype=np.int32))
me.update()
me.validate()
uv = me.uv_layers.new(name="UVMap")
uvs = np.stack([(X + size / 2) / size, (Y + size / 2) / size], -1).reshape(-1, 2)
uv.uv.foreach_set("vector", uvs[quads.ravel()].astype(np.float32).ravel())
for an, val in (("ENV_Slope", slope / 90.0), ("ENV_Height", hn)):
at = me.attributes.get(an) or me.attributes.new(an, "FLOAT", "POINT")
at.data.foreach_set("value", val.astype(np.float32).ravel())
me.shade_smooth()
ob = bpy.data.objects.get(name)
if ob is None:
ob = bpy.data.objects.new(name, me)
bpy.context.scene.collection.objects.link(ob)
res_d = {"ok": True, "object": ob.name, "size_m": size, "res": res, "cell_m": round(cell, 4),
"tris": int(len(quads) * 2), "height_range_m": [round(float(h.min()), 3), round(float(h.max()), 3)],
"slope_mean_deg": round(float(slope.mean()), 2), "rock_pct": round(float((slope > ROCK_DEG).mean()) * 100, 1)}
if material:
mat = _material("M_" + name)
me.materials.clear()
me.materials.append(mat)
res_d["material"] = mat.name
if export_heightmap: # row 0 = -Y (south) edge; .raw = 16-bit little-endian (Unity), .png = 16-bit gray (UE)
path = bpy.path.abspath(export_heightmap)
if path.lower().endswith(".raw"):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
np.round(hn * 65535).astype("<u2").tofile(path)
res_d["heightmap"] = path
else:
g = np.repeat(hn[..., None], 4, -1)
g[..., 3] = 1
res_d["heightmap"] = _save_png16(path, g, res, True)
res_d["heightmap_z_scale_m"] = round(float(np.ptp(h)), 4)
if export_splat: # R grass, G rock, B snow, weights sum to 1
rock = np.clip((slope - ROCK_DEG + 2) / 4, 0, 1)
snow = np.clip((hn - SNOW_H + 0.03) / 0.06, 0, 1) * np.clip((40 - slope) / 4, 0, 1) * (1 - rock)
grass = np.clip(1 - rock - snow, 0, 1)
res_d["splat"] = _save_png16(bpy.path.abspath(export_splat), np.stack([grass, rock, snow, np.ones_like(grass)], -1), res, False)
if res > 1025:
res_d["warning"] = "res > 1025: split into tiles/chunks for engines and LODs"
return res_d
if __name__ == "__main__":
p = bx.parser(__doc__)
for k, t, d in (("size", float, 200.0), ("res", int, 257), ("height", float, 30.0), ("scale", float, 80.0),
("octaves", int, 6), ("seed", int, 0), ("erosion", int, 0), ("terrace", float, 0.0)):
p.add_argument(f"--{k}", type=t, default=d)
p.add_argument("--type", default="fbm", choices=("fbm", "ridged", "hills"))
p.add_argument("--heightmap", default=None)
p.add_argument("--flatten", type=float, nargs=3, default=None, metavar=("X", "Y", "RADIUS"))
p.add_argument("--material", action="store_true")
p.add_argument("--export-heightmap", default=None)
p.add_argument("--export-splat", default=None)
p.add_argument("--name", default="Terrain")
p.add_argument("--save", default=None)
bx.cli(run, p)
SKILL.md
---
name: blender-environment-kits
description: Game environments in Blender 5.2 - terrain from noise or heightmaps (erosion, terraces, play areas, slope/height materials, 16-bit heightmaps + splat maps for UE/Unity), biome scatter by slope/height/mask with Poisson spacing, modular kit metrics and validation (grid, pivots, seams, texel density). Use for levels, landscapes, forests, modular buildings, dungeons.
license: MIT
metadata:
author: blender-agent-skills
version: "1.2.0"
---
# Environment kits
The execution model is in `blender-core`. Custom GN tools are in `blender-geometry-nodes`, materials in `blender-texturing` (trims, vertex blends), and mood/weather in `blender-time-of-day`/`blender-weather-fx`.
## One call each
```
scripts/terrain.py -- --size 400 --res 513 --height 60 --type ridged --erosion 30 --flatten 0 0 40 --material
scripts/terrain.py -- --heightmap //hm.png --size 1000 --res 1009 --height 120 --export-splat //T_Splat.png
scripts/scatter.py -- --target Terrain --collection Trees --density 0.02 --min-distance 5 --max-slope 25 --height-max 45
scripts/scatter.py -- --target Terrain --collection Rocks --density 0.05 --max-slope 70 --align 0.8 --name ENV_Rocks
scripts/kit_check.py -- --objects SM_Wall_4m SM_Floor_4m SM_Door_4m --grid 0.5 --fix
```
## Terrain (terrain.py)
- Grid of `--res`² verts over `--size` m. Use **2^n + 1** for Unity (513/1025/2049, `.raw` 16-bit LE) and UE Landscape's recommended sizes (505/1009/2017/4033, 16-bit PNG). Above 1025, tile it.
- `--type fbm` (rolling), `ridged` (mountains), `hills` (hetero terrain). `--scale` = feature size m. `--erosion N` = thermal erosion steps (talus 33°): slopes relax and debris fills valleys. `--terrace` m = stepped flats. `--flatten X Y R` = a level play/build area blended back with a smoothstep.
- Writes `ENV_Slope` (deg/90) and `ENV_Height` (0–1) point attributes. `--material` builds grass/rock/snow from them: rock above 35°, snow above 75% height on slopes below ~40°, with noise-broken edges.
- Engines: `--export-heightmap` (PNG = 16-bit gray, `.raw` = 16-bit little-endian, row 0 = −Y edge; flip vertically in Unity if mirrored) plus `heightmap_z_scale_m` for the import Z scale. `--export-splat` = 16-bit RGBA weights (R grass, G rock, B snow, sum 1) for terrain layers. Both are written Raw (no display transform): measured round-trip error < 0.001.
## Scatter (scatter.py)
- One GN modifier per layer: Poisson disk (`--density` per m² max, `--min-distance` m), kept where the **interpolated surface normal** is within `--max-slope` and Z is inside `--height-min/max` (object space). `--mask` = vertex group or float attribute as density factor (paint it, or use `ENV_Height`).
- Collection children are instanced at their own origins with random yaw, `--scale` range, and `--align` (0 = upright trees, 0.6–1 = rocks hugging the slope). The source collection is excluded from the view layer.
- The result checks every instance against the real surface (`slope_max_deg`, `height_range`) and counts per source. Instances stay instances. `--realize` only when an exporter or boolean needs real mesh.
- Densities: forest trees 0.01–0.05/m² (min distance 4–8 m), bushes 0.05–0.2, rocks 0.01–0.1, grass clumps 2–20/m² (near camera only). Layer big → small: trees, then bushes masked away from trunks, then grass.
- Engines: glTF `export_gpu_instances=True` (EXT_mesh_gpu_instancing), or `--export-points file.json` (world transforms, Z-up m, wxyz quats) for UE foliage/PCG or Unity instancing scripts. FBX needs realized geometry or one object per instance.
## Modular kits (kit_check.py)
- **Metrics first**: pick a grid (0.5 m snap, 1/2/4 m modules) and keep it everywhere. Walls 3–4 m tall interior (player ~1.8 m eye 1.6 m, doors ≥ 1 × 2.2 m), floors 4 × 4 m, stairs 0.18 m rise / 0.28 m run. UE works in cm (×100 on import), Unity in m.
- **Pivot** convention (`--pivot corner` default, `bottom-center`, `center`) so pieces snap by increments. Footprint lengths are multiples of the grid. Thinner-than-a-cell dims (wall thickness, trims) may be off-grid.
- Open edges only on the piece's outer faces (seams); holes inside are errors. Seam verts must be on the grid so neighbours weld visually. Texel density within 25% of the kit median (`--texture` px). `SM_` names (`--prefix`).
- `--fix` applies rotation/scale and moves each origin to the pivot without moving geometry. Linked duplicates share mesh data, so fix the source once.
- Break repetition with trims and decals (`blender-texturing`), vertex-color blends, and rotated/mirrored variants (mirrors flip normals, so check with `blender-asset-qa`). Hide seams with trim pieces over joints.
## Gotchas
- Apply terrain/kit scale before scattering. Densities and slopes are world-based.
- Scatter height is in the target's object space. Keep terrains at the origin or offset `--height-*`.
- Huge Poisson counts are slow. Scatter per tile/area, or lower the density in the viewport and raise it for renders.
- Validate terrain + scatter with `blender-asset-qa` (floating instances on cliffs, z-fighting decals) and `blender-game-optimization` (instances, LODs, draw calls).