528 lines
25 KiB
Python
528 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
|
|
x = s * L / 2
|
|
pts = [V(x, -yo, -0.6), V(x, yo, -0.6), V(x, yo, deck_z(x) + WALL_H), V(x, -yo, deck_z(x) + WALL_H)]
|
|
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
|