Files
Vibe-HD-2D/tools/blender_scenery.py
Gabrieal Jimmy 16c03afd16 Rain and more
2026-10-02 13:04:49 -05:00

530 lines
25 KiB
Python

"""Builds the HD-2D scenery models (trees, bushes, rocks) procedurally in
Blender and exports one .glb per model into assets/models/.
blender -b -P tools/blender_scenery.py # headless
# or, inside a running Blender (e.g. via the Blender MCP):
exec(open("<repo>/tools/blender_scenery.py").read())
Textures come from tools/scenery_textures.py (assets/models/src/*.png) and are
embedded into the .glb files.
Conventions the engine relies on (see engine/src/Model.cpp):
* COLOR_0.rgb linear tint / baked ambient occlusion (multiplies the texture)
* COLOR_0.a wind weight (0 = rigid, 1 = fully swaying foliage)
* materials named "foliage*" are alpha-tested, double-sided and translucent
* custom normals on foliage point away from the canopy centre, which gives
the soft, rounded shading of stylized trees instead of flat cards
"""
import math
import os
import random
import bpy
import mathutils
from mathutils import Vector
REPO = os.environ.get("HD2D_REPO") or os.path.dirname(os.path.dirname(os.path.abspath(
__file__ if "__file__" in globals() else "/home/gabe/Documents/hd_2d_engine/tools/x")))
SRC = os.path.join(REPO, "assets", "models", "src")
OUT = os.path.join(REPO, "assets", "models")
# ----------------------------------------------------------------- builder --
class MeshBuilder:
"""Accumulates faces with per-corner uv / color / normal and material ids."""
def __init__(self):
self.verts, self.faces, self.uvs, self.cols, self.nrms, self.mats = [], [], [], [], [], []
def face(self, pts, uvs, cols, nrms, mat):
base = len(self.verts)
self.verts.extend(pts)
self.faces.append(list(range(base, base + len(pts))))
self.uvs.extend(uvs)
self.cols.extend(cols)
self.nrms.extend(nrms)
self.mats.append(mat)
def build(self, name, materials, smooth=True):
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(v) for v in self.verts], [], self.faces)
for m in materials:
me.materials.append(m)
me.polygons.foreach_set("material_index", self.mats)
me.polygons.foreach_set("use_smooth", [smooth] * len(self.faces))
uv = me.uv_layers.new(name="UVMap")
col = me.color_attributes.new("Col", "FLOAT_COLOR", "CORNER")
for li, loop in enumerate(me.loops): # from_pydata keeps corner order
uv.data[li].uv = self.uvs[li]
col.data[li].color = self.cols[li]
me.color_attributes.active_color = col
me.validate()
me.normals_split_custom_set([tuple(n) for n in self.nrms])
ob = bpy.data.objects.new(name, me)
COLLECTION.objects.link(ob)
return ob
def material(name, image, alpha=False, tint_from_vcol=True, emission=0.0):
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
bsdf.inputs["Roughness"].default_value = 0.85
tex = nt.nodes.new("ShaderNodeTexImage")
path = os.path.join(SRC, image)
tex.image = bpy.data.images.load(path, check_existing=True)
color = tex.outputs["Color"]
if tint_from_vcol:
vc = nt.nodes.new("ShaderNodeVertexColor")
vc.layer_name = "Col"
mul = nt.nodes.new("ShaderNodeMix")
mul.data_type = "RGBA"
mul.blend_type = "MULTIPLY"
mul.inputs["Factor"].default_value = 1.0
nt.links.new(color, mul.inputs["A"])
nt.links.new(vc.outputs["Color"], mul.inputs["B"])
color = mul.outputs["Result"]
nt.links.new(color, bsdf.inputs["Base Color"])
if alpha:
cut = nt.nodes.new("ShaderNodeMath") # Round -> exported as alphaMode MASK
cut.operation = "ROUND"
nt.links.new(tex.outputs["Alpha"], cut.inputs[0])
nt.links.new(cut.outputs[0], bsdf.inputs["Alpha"])
mat.surface_render_method = "DITHERED"
if emission > 0.0: # exported as emissiveTexture + KHR_materials_emissive_strength
nt.links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
bsdf.inputs["Emission Strength"].default_value = emission
mat.use_backface_culling = not alpha # exported as glTF doubleSided
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def frame(t):
t = t.normalized()
up = Vector((0, 0, 1)) if abs(t.z) < 0.95 else Vector((1, 0, 0))
a = t.cross(up).normalized()
return a, t.cross(a).normalized()
def tube(mb, pts, radii, mat, sides=10, ao=(0.6, 1.0), wind=lambda p: 0.0, texel=1.2):
"""Tapered tube along a polyline, with cylindrical bark UVs."""
rings, length = [], 0.0
circ = 2 * math.pi * sum(radii) / len(radii)
urep = max(1, round(circ / 0.7))
for i, p in enumerate(pts):
t = (pts[min(i + 1, len(pts) - 1)] - pts[max(i - 1, 0)])
a, b = frame(t)
if i:
length += (p - pts[i - 1]).length
ring = []
for j in range(sides + 1):
ang = j / sides * math.tau
n = a * math.cos(ang) + b * math.sin(ang)
ring.append((p + n * radii[i], n, (j / sides * urep, length / texel)))
rings.append(ring)
for i in range(len(rings) - 1):
k0 = ao[0] + (ao[1] - ao[0]) * min(1.0, i / max(1, len(rings) - 2))
k1 = ao[0] + (ao[1] - ao[0]) * min(1.0, (i + 1) / max(1, len(rings) - 2))
for j in range(sides):
quad = [rings[i][j], rings[i][j + 1], rings[i + 1][j + 1], rings[i + 1][j]]
ks = [k0, k0, k1, k1]
mb.face([q[0] for q in quad], [q[2] for q in quad],
[(k, k, k, wind(q[0])) for q, k in zip(quad, ks)], [q[1] for q in quad], mat)
def ico(subdiv):
"""Unit icosphere vertices + triangles."""
t = (1 + 5 ** 0.5) / 2
v = [Vector(x).normalized() for x in [(-1, t, 0), (1, t, 0), (-1, -t, 0), (1, -t, 0), (0, -1, t), (0, 1, t),
(0, -1, -t), (0, 1, -t), (t, 0, -1), (t, 0, 1), (-t, 0, -1), (-t, 0, 1)]]
f = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11), (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6),
(7, 1, 8), (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9), (4, 9, 5), (2, 4, 11), (6, 2, 10),
(8, 6, 7), (9, 8, 1)]
for _ in range(subdiv):
cache, nf = {}, []
def mid(a, b):
key = (min(a, b), max(a, b))
if key not in cache:
cache[key] = len(v)
v.append(((v[a] + v[b]) / 2).normalized())
return cache[key]
for a, b, c in f:
ab, bc, ca = mid(a, b), mid(b, c), mid(c, a)
nf += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)]
f = nf
return v, f
def blend_normal(p, centre, local):
return ((p - centre).normalized() * 0.65 + local * 0.35).normalized()
def clump(mb, rng, c, r, canopy_c, canopy_r, mat_core, mat_cards, cards_per_r2=42, card_scale=1.0,
wind=1.0, ao_floor=0.45, core=True):
"""One leafy lump: an opaque leaf-mass core plus alpha-tested leaf cards."""
def ao_at(p):
h = (p.z - (canopy_c.z - canopy_r)) / (2 * canopy_r)
return ao_floor + (1 - ao_floor) * max(0.0, min(1.0, h)) ** 0.8
if core:
v, f = ico(2)
for tri in f:
ps = [c + v[i] * r * 0.8 for i in tri]
uvs = [(math.atan2(v[i].y, v[i].x) / math.pi * 1.5, v[i].z * 1.5) for i in tri]
if max(u for u, _ in uvs) - min(u for u, _ in uvs) > 1.5: # fix the atan2 seam
uvs = [(u + 3.0 if u < 0 else u, w) for u, w in uvs]
cols = [(ao_at(p) * 0.75,) * 3 + (wind,) for p in ps]
mb.face(ps, uvs, cols, [blend_normal(p, canopy_c, v[i]) for p, i in zip(ps, tri)], mat_core)
n = int(cards_per_r2 * r * r) + 6
for _ in range(n):
d = Vector((rng.gauss(0, 1), rng.gauss(0, 1), rng.gauss(0, 1) * 0.8 + 0.35)).normalized()
p = c + d * r * rng.uniform(0.72, 1.0)
cn = (d + Vector((rng.gauss(0, 0.5), rng.gauss(0, 0.5), rng.gauss(0, 0.5)))).normalized()
a, b = frame(cn)
roll = rng.random() * math.tau
a, b = a * math.cos(roll) + b * math.sin(roll), b * math.cos(roll) - a * math.sin(roll)
s = r * rng.uniform(0.85, 1.2) * card_scale
corners = [p - a * s / 2 - b * s / 2, p + a * s / 2 - b * s / 2, p + a * s / 2 + b * s / 2, p - a * s / 2 + b * s / 2]
tint = rng.uniform(0.88, 1.1)
mb.face(corners, [(0, 0), (1, 0), (1, 1), (0, 1)],
[(ao_at(q) * tint,) * 3 + (wind,) for q in corners], [blend_normal(q, canopy_c, d) for q in corners],
mat_cards)
# ------------------------------------------------------------------ models --
def make_oak(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
trunk_h = rng.uniform(2.4, 2.9)
lean = Vector((rng.uniform(-0.25, 0.25), rng.uniform(-0.25, 0.25), 0))
pts = [Vector((0, 0, -0.3)), Vector((0, 0, 0.0)), Vector((0, 0, 0.25))]
pts += [Vector((0, 0, z)) + lean * (z / trunk_h) ** 2 for z in (0.8, 1.4, 2.0, trunk_h)]
radii = [0.52, 0.42, 0.33, 0.28, 0.26, 0.24, 0.2]
wind_trunk = lambda p: max(0.0, min(1.0, (p.z - 1.2) / 4.0)) * 0.25
tube(mb, pts, radii, 0, ao=(0.45, 1.0), wind=wind_trunk)
top = pts[-1]
canopy_c = top + Vector((0, 0, 1.25))
canopy_r = 2.2
clumps = [(canopy_c, 1.45), (canopy_c + Vector((0, 0, 0.95)), 1.05)]
n = rng.randint(5, 7)
for k in range(n):
ang = k / n * math.tau + rng.uniform(-0.3, 0.3)
dist = rng.uniform(1.05, 1.45)
c = canopy_c + Vector((math.cos(ang) * dist, math.sin(ang) * dist, rng.uniform(-0.65, 0.35)))
clumps.append((c, rng.uniform(0.85, 1.15)))
# A branch from the trunk top toward each outer clump.
start = top + Vector((0, 0, rng.uniform(-0.6, 0.0)))
mid = start.lerp(c, 0.5) + Vector((0, 0, 0.25))
tube(mb, [start, mid, start.lerp(c, 0.85)], [0.14, 0.09, 0.05], 0, sides=6, ao=(0.7, 0.9), wind=wind_trunk)
for c, r in clumps:
clump(mb, rng, c, r, canopy_c, canopy_r, 1, 2)
return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_oak"]])
def make_pine(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
height = rng.uniform(5.8, 6.6)
pts = [Vector((0, 0, z)) for z in (-0.3, 0.3, 1.5, 3.0, 4.5, height)]
radii = [0.3, 0.22, 0.18, 0.13, 0.08, 0.02]
tube(mb, pts, radii, 0, sides=8, ao=(0.5, 0.9), wind=lambda p: max(0.0, (p.z - 1.0) / height) * 0.3)
# Dense core cone so gaps between fronds read as deep needles, not sky.
sides, tiers = 12, 8
z0, z1 = 0.9, height - 0.1
for i in range(tiers):
za, zb = z0 + (z1 - z0) * i / tiers, z0 + (z1 - z0) * (i + 1) / tiers
ra, rb = 1.15 * (1 - i / tiers) + 0.05, 1.15 * (1 - (i + 1) / tiers) + 0.05
for j in range(sides):
a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau
quad = [Vector((math.cos(a0) * ra, math.sin(a0) * ra, za)), Vector((math.cos(a1) * ra, math.sin(a1) * ra, za)),
Vector((math.cos(a1) * rb, math.sin(a1) * rb, zb)), Vector((math.cos(a0) * rb, math.sin(a0) * rb, zb))]
uvs = [(j / sides * 3, za / 1.5), ((j + 1) / sides * 3, za / 1.5), ((j + 1) / sides * 3, zb / 1.5), (j / sides * 3, zb / 1.5)]
nrm = [Vector((q.x, q.y, 0.6)).normalized() for q in quad]
k = [0.5 + 0.4 * (q.z / height) for q in quad]
mb.face(quad, uvs, [(kk * 0.6, kk * 0.9, kk * 0.8, 0.5) for kk in k], nrm, 1)
h = 0.85
tier = 0
while h < height - 0.3:
frac = (h - 0.85) / (height - 1.1)
length = 2.5 * (1 - frac) ** 0.95 + 0.45
count = 11 if frac < 0.7 else 7
for k in range(count):
ang = k / count * math.tau + tier * 0.55 + rng.uniform(-0.2, 0.2)
out = Vector((math.cos(ang), math.sin(ang), 0))
side = Vector((-out.y, out.x, 0))
roll = rng.uniform(-0.35, 0.35)
across = (side * math.cos(roll) + Vector((0, 0, 1)) * math.sin(roll)) * length * 0.32
base = Vector((0, 0, h + rng.uniform(-0.08, 0.08)))
droop = rng.uniform(0.25, 0.4)
segs = [base + out * length * t + Vector((0, 0, length * (0.12 * t - droop * t * t))) for t in (0, 0.34, 0.67, 1.0)]
# Flat card seen from above + a narrower vertical cross card for volume from the side.
cross = (out.cross(across).normalized()) * length * 0.2
for spread, kc in ((across, 1.0), (cross, 0.85)):
for s in range(3):
t0, t1 = s / 3, (s + 1) / 3
quad = [segs[s] - spread, segs[s + 1] - spread, segs[s + 1] + spread, segs[s] + spread]
uvs = [(t0, 0), (t1, 0), (t1, 1), (t0, 1)]
up = Vector((0, 0, 1))
nrm = [(out * 0.45 + up * 0.9).normalized()] * 4
ks = [(0.55 + 0.45 * t) * kc for t in (t0, t1, t1, t0)]
kh = 0.75 + 0.25 * frac
w = [t * (0.5 + 0.5 * frac) for t in (t0, t1, t1, t0)]
mb.face(quad, uvs, [(kk * kh, kk * kh, kk * kh, ww) for kk, ww in zip(ks, w)], nrm, 2)
h += rng.uniform(0.32, 0.4)
tier += 1
return mb.build(name, [MAT["bark"], MAT["foliage_core"], MAT["foliage_pine"]])
def make_bush(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
centre = Vector((0, 0, 0.42))
clumps = [(centre, 0.55)]
n = rng.randint(3, 5)
for k in range(n):
ang = k / n * math.tau + rng.uniform(-0.4, 0.4)
d = rng.uniform(0.35, 0.5)
clumps.append((Vector((math.cos(ang) * d, math.sin(ang) * d, rng.uniform(0.28, 0.45))), rng.uniform(0.36, 0.5)))
for c, r in clumps:
clump(mb, rng, c, r, centre, 0.75, 0, 1, cards_per_r2=60, card_scale=1.25, wind=0.35, ao_floor=0.35)
return mb.build(name, [MAT["foliage_core"], MAT["foliage_bush"]])
def make_rock(name, seed):
rng = random.Random(seed)
mb = MeshBuilder()
v, f = ico(2)
off = Vector((rng.random() * 100, rng.random() * 100, rng.random() * 100))
scale = Vector((rng.uniform(0.9, 1.15), rng.uniform(0.7, 0.9), rng.uniform(0.5, 0.7))) * 0.55
pos = []
for p in v:
k = 1 + 0.34 * mathutils.noise.noise(p * 1.3 + off) + 0.1 * mathutils.noise.noise(p * 3.5 + off)
q = Vector((p.x * scale.x, p.y * scale.y, p.z * scale.z)) * k
q.z = max(q.z, -0.12)
pos.append(q)
# Smooth vertex normals from the displaced shape.
nrm = [Vector((0, 0, 0)) for _ in pos]
for a, b, c in f:
fn = (pos[b] - pos[a]).cross(pos[c] - pos[a])
for i in (a, b, c):
nrm[i] += fn
nrm = [n.normalized() for n in nrm]
for tri in f:
ps = [pos[i] for i in tri]
fn = (ps[1] - ps[0]).cross(ps[2] - ps[0]).normalized()
ax = max(range(3), key=lambda i: abs(fn[i])) # box projection
uvs = [((p.y, p.z) if ax == 0 else (p.x, p.z) if ax == 1 else (p.x, p.y)) for p in ps]
uvs = [(u * 2.2, w * 2.2) for u, w in uvs]
cols = []
for i in tri:
nz = nrm[i].z
moss = max(0.0, min(1.0, (nz - 0.45) / 0.4)) * (0.6 + 0.4 * mathutils.noise.noise(pos[i] * 3 + off))
ao = 0.55 + 0.45 * max(0.0, min(1.0, (pos[i].z + 0.12) / 0.4))
tint = Vector((1, 1, 1)).lerp(Vector((0.55, 1.05, 0.32)), max(0.0, min(1.0, moss)))
cols.append((tint.x * ao, tint.y * ao, tint.z * ao, 0.0))
# Mostly faceted: chunky stylized stone that still catches soft light.
mb.face(ps, uvs, cols, [(fn * 0.65 + nrm[i] * 0.35).normalized() for i in tri], 0)
return mb.build(name, [MAT["rock"]])
# ----------------------------------------------------------------- bridge --
# Deck geometry; demo/DemoScene.cpp mirrors these numbers for walking on it.
BRIDGE_LEN, BRIDGE_W, BRIDGE_RISE = 17.0, 2.6, 0.7
WALL_T, WALL_H = 0.28, 0.5
def deck_z(x):
u = max(-1.0, min(1.0, 2 * x / BRIDGE_LEN))
return 0.05 + BRIDGE_RISE * (1 - u * u)
def ao_stone(z):
return 0.5 + 0.5 * max(0.0, min(1.0, (z + 0.6) / 1.6))
def quad_face(mb, pts, normal, mat, uv_axes, uv_scale, ao=ao_stone):
"""Planar quad with world-projected UVs (uv_axes picks two of x/y/z)."""
uvs = [(p[uv_axes[0]] / uv_scale, p[uv_axes[1]] / uv_scale) for p in pts]
mb.face(pts, uvs, [(ao(p.z),) * 3 + (0.0,) for p in pts], [normal] * 4, mat)
def box(mb, lo, hi, mat, uv_scale=2.0):
x0, y0, z0 = lo
x1, y1, z1 = hi
V = lambda x, y, z: Vector((x, y, z))
quad_face(mb, [V(x0, y0, z1), V(x1, y0, z1), V(x1, y1, z1), V(x0, y1, z1)], Vector((0, 0, 1)), mat, (0, 1), uv_scale)
quad_face(mb, [V(x0, y1, z0), V(x1, y1, z0), V(x1, y0, z0), V(x0, y0, z0)], Vector((0, 0, -1)), mat, (0, 1), uv_scale)
quad_face(mb, [V(x0, y0, z0), V(x1, y0, z0), V(x1, y0, z1), V(x0, y0, z1)], Vector((0, -1, 0)), mat, (0, 2), uv_scale)
quad_face(mb, [V(x1, y1, z0), V(x0, y1, z0), V(x0, y1, z1), V(x1, y1, z1)], Vector((0, 1, 0)), mat, (0, 2), uv_scale)
quad_face(mb, [V(x0, y1, z0), V(x0, y0, z0), V(x0, y0, z1), V(x0, y1, z1)], Vector((-1, 0, 0)), mat, (1, 2), uv_scale)
quad_face(mb, [V(x1, y0, z0), V(x1, y1, z0), V(x1, y1, z1), V(x1, y0, z1)], Vector((1, 0, 0)), mat, (1, 2), uv_scale)
def lathe(mb, profile, centre, mat, sides=16, planar=False):
"""Surface of revolution around +Z from (radius, z) pairs (bottom to top)."""
for i in range(len(profile) - 1):
(r0, z0), (r1, z1) = profile[i], profile[i + 1]
slope = Vector((z1 - z0, 0, -(r1 - r0))).normalized() # outward normal in the r/z plane
for j in range(sides):
a0, a1 = j / sides * math.tau, (j + 1) / sides * math.tau
corners = ((r0, z0, a0), (r0, z0, a1), (r1, z1, a1), (r1, z1, a0))
pts = [centre + Vector((math.cos(a) * r, math.sin(a) * r, z)) for r, z, a in corners]
nrm = [Vector((math.cos(a) * slope.x, math.sin(a) * slope.x, slope.z)) for _, _, a in corners]
uvs = ([((p - centre).x * 1.5, (p - centre).y * 1.5) for p in pts] if planar
else [(a / math.tau * 3, z * 2) for _, z, a in corners])
mb.face(pts, uvs, [(1.0, 1.0, 1.0, 0.0)] * 4, nrm, mat)
def make_bridge(name, seed):
"""Arched stone bridge along +X with parapets, posts, entrance pillars and
two braziers at the -X entrance. Fire sockets: fire_0, fire_1."""
mb = MeshBuilder()
STONE, DECK, IRON, COALS = 0, 1, 2, 3
L, W = BRIDGE_LEN, BRIDGE_W
piers = [-L / 4, 0.0, L / 4]
pier_half = 0.45
abut = L / 2 - 1.3
def under_z(x):
if abs(x) >= abut:
return -0.6
for p in piers:
if abs(x - p) <= pier_half:
return -2.6
edges = [-abut] + [e for p in piers for e in (p - pier_half, p + pier_half)] + [abut]
for a, b in zip(edges[0::2], edges[1::2]):
if a <= x <= b:
u = (x - (a + b) / 2) / ((b - a) / 2)
spring, top = -0.9, deck_z(x) - 0.42
return spring + (top - spring) * math.sqrt(max(0.0, 1 - u * u))
return -0.6
xs = [-L / 2 + i * 0.1 for i in range(int(round(L / 0.1)) + 1)]
yo, yi = W / 2, W / 2 - WALL_T
V = lambda x, y, z: Vector((x, y, z))
for x0, x1 in zip(xs[:-1], xs[1:]):
d0, d1 = deck_z(x0), deck_z(x1)
u0, u1 = under_z(x0), under_z(x1)
t0, t1 = d0 + WALL_H, d1 + WALL_H
up = Vector((-(d1 - d0), 0, x1 - x0)).normalized()
# Deck (cobbles) between the parapets.
quad_face(mb, [V(x0, -yi, d0), V(x1, -yi, d1), V(x1, yi, d1), V(x0, yi, d0)], up, DECK, (0, 1), 2.2,
ao=lambda z: 1.0)
for s in (-1, 1):
# Outer face: from the arch soffit up to the parapet top (CCW seen from outside).
pts = [V(x0, s * yo, u0), V(x1, s * yo, u1), V(x1, s * yo, t1), V(x0, s * yo, t0)]
quad_face(mb, pts if s < 0 else pts[::-1], V(0, s, 0), STONE, (0, 2), 2.0)
# Inner parapet face + parapet cap.
pts = [V(x0, s * yi, d0), V(x1, s * yi, d1), V(x1, s * yi, t1), V(x0, s * yi, t0)]
quad_face(mb, pts if s > 0 else pts[::-1], V(0, -s, 0), STONE, (0, 2), 2.0, ao=lambda z: 0.8)
cap = [V(x0, s * yi, t0), V(x1, s * yi, t1), V(x1, s * yo, t1), V(x0, s * yo, t0)]
quad_face(mb, cap if s > 0 else cap[::-1], up, STONE, (0, 1), 2.0, ao=lambda z: 1.0)
# Arch soffit, seen in the water reflection.
quad_face(mb, [V(x0, yo, u0), V(x1, yo, u1), V(x1, -yo, u1), V(x0, -yo, u0)], V(0, 0, -1), STONE, (0, 1), 2.0,
ao=lambda z: 0.45)
for s in (-1, 1): # end faces: below the deck, plus the parapet ends (the entrance stays open)
x = s * L / 2
d = deck_z(x)
for y0, y1, z0, z1 in ((-yo, yo, -0.6, d), (-yo, -yi, d, d + WALL_H), (yi, yo, d, d + WALL_H)):
pts = [V(x, y0, z0), V(x, y1, z0), V(x, y1, z1), V(x, y0, z1)]
quad_face(mb, pts if s > 0 else pts[::-1], V(s, 0, 0), STONE, (1, 2), 2.0)
# Cutwaters on the piers, posts along the parapets.
for p in piers:
box(mb, (p - pier_half - 0.05, -yo - 0.22, -2.6), (p + pier_half + 0.05, yo + 0.22, -0.05), STONE)
box(mb, (p - pier_half - 0.1, -yo - 0.28, -0.05), (p + pier_half + 0.1, yo + 0.28, 0.08), STONE)
for k in range(1, 6):
x = -L / 2 + k * L / 6
for s in (-1, 1):
y0, y1 = sorted((s * (yi - 0.04), s * (yo + 0.06)))
box(mb, (x - 0.2, y0, deck_z(x) - 0.1), (x + 0.2, y1, deck_z(x) + WALL_H + 0.22), STONE)
box(mb, (x - 0.25, y0 - 0.04, deck_z(x) + WALL_H + 0.22), (x + 0.25, y1 + 0.04, deck_z(x) + WALL_H + 0.3), STONE)
# Entrance pillars at both ends; braziers on the -X pair.
fires = []
for end in (-1, 1):
x = end * (L / 2 - 0.35)
for s in (-1, 1):
y = s * (yo + 0.1)
box(mb, (x - 0.38, y - 0.38, -0.6), (x + 0.38, y + 0.38, 1.25), STONE)
box(mb, (x - 0.46, y - 0.46, 1.25), (x + 0.46, y + 0.46, 1.4), STONE)
if end < 0:
c = Vector((x, y, 1.4))
lathe(mb, [(0.16, 0.0), (0.1, 0.08), (0.08, 0.2), (0.14, 0.26), (0.34, 0.38), (0.42, 0.52), (0.38, 0.54),
(0.37, 0.5)], c, IRON)
lathe(mb, [(0.37, 0.5), (0.0, 0.46)], c, COALS, planar=True) # glowing coal bed
fires.append(c + Vector((0, 0, 0.5)))
ob = mb.build(name, [MAT["stone"], MAT["cobble"], MAT["iron"], MAT["coals"]], smooth=False)
for i, p in enumerate(fires):
socket = bpy.data.objects.new(f"fire_{i}", None)
socket.empty_display_size = 0.3
socket.location = p
COLLECTION.objects.link(socket)
socket.parent = ob
return ob
# ------------------------------------------------------------------- main --
def export(objects, path):
"""One .glb with every model as a named root node, so textures are stored once.
Child empties are exported too: the engine reads them as named sockets."""
bpy.ops.object.select_all(action="DESELECT")
for ob in objects:
ob.select_set(True)
for child in ob.children:
child.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_yup=True,
export_apply=True, export_normals=True, export_texcoords=True,
export_vertex_color="ACTIVE", export_image_format="AUTO", export_materials="EXPORT")
MODELS = [
("oak_a", make_oak, 11), ("oak_b", make_oak, 23), ("pine_a", make_pine, 5), ("pine_b", make_pine, 17),
("bush_a", make_bush, 3), ("bush_b", make_bush, 9), ("rock_a", make_rock, 2), ("rock_b", make_rock, 8),
("bridge", make_bridge, 0),
]
# Everything lives in its own collection; nothing else in the open file is touched.
COLLECTION = bpy.data.collections.get("HD2D_Scenery")
if COLLECTION is None:
COLLECTION = bpy.data.collections.new("HD2D_Scenery")
bpy.context.scene.collection.children.link(COLLECTION)
for ob in list(COLLECTION.objects):
me = ob.data
bpy.data.objects.remove(ob, do_unlink=True)
if me and me.users == 0:
bpy.data.meshes.remove(me)
MAT = {
"bark": material("bark", "bark.png"),
"rock": material("rock", "rock.png"),
"foliage_core": material("foliage_core", "leaves_dense.png"),
"foliage_oak": material("foliage_oak", "leaves_oak.png", alpha=True),
"foliage_bush": material("foliage_bush", "leaves_bush.png", alpha=True),
"foliage_pine": material("foliage_pine", "pine_frond.png", alpha=True),
"stone": material("stone", "stone_blocks.png"),
"cobble": material("cobble", "cobble.png"),
"iron": material("iron", "iron.png"),
"coals": material("coals", "coals.png", emission=2.5),
}
os.makedirs(OUT, exist_ok=True)
objects = []
for name, fn, seed in MODELS:
ob = fn(name, seed)
objects.append(ob)
print("built", name, len(ob.data.polygons), "faces")
export(objects, os.path.join(OUT, "scenery.glb")) # all at the origin: the engine uses node names
for i, ob in enumerate(objects):
ob.location.x = i * 6.0 # lay out side by side for previewing in Blender