"""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("/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