"""Map dressing for Champions of Luneraria: river bridge, curb stone, trees, rocks, banner and edge cliffs, exported as separate named meshes in one GLB. Godot instances them with MultiMeshes. Stone, bark and leaves use ENV_* names so the Kluft shaders (with wind on foliage) apply at runtime. Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_map_props.py Godot frame per prop: y up, prop origin at ground level; the bridge spans local +x. """ from pathlib import Path import bpy, bmesh, json, hashlib, struct from math import sin, cos, pi from mathutils import Vector, noise P = Path(__file__).resolve().parents[2] SRC = Path(__file__).resolve().parent OUT = P / 'assets/luneraria' bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.preferences.filepaths.save_version = 0 def G(x, y, z): return Vector((x, -z, y)) def material(name, color, rough=.9, metal=0.0, emission=0.0): m = bpy.data.materials.new(name) m.diffuse_color = (*color, 1) m.use_nodes = True bs = m.node_tree.nodes.get('Principled BSDF') bs.inputs['Base Color'].default_value = (*color, 1) bs.inputs['Roughness'].default_value = rough bs.inputs['Metallic'].default_value = metal if emission: bs.inputs['Emission Color'].default_value = (*color, 1) bs.inputs['Emission Strength'].default_value = emission return m M = { 'limestone': material('ENV_limestone', (.283, .246, .178)), 'paving': material('ENV_paving', (.19, .185, .15), .92), 'cliff': material('ENV_cliff', (.133, .153, .138)), 'bark': material('ENV_bark', (.087, .056, .032), .93), 'leaf_dark': material('ENV_leaf_dark', (.031, .07, .04), .89), 'leaf_mid': material('ENV_leaf_mid', (.084, .144, .054), .85), 'leaf_light': material('ENV_leaf_light', (.258, .305, .091), .79), 'soil': material('ENV_soil', (.10, .15, .065), .97), 'brass': material('ENV_brass', (.515, .366, .153), .45, .7), 'cloth': material('PR_Banner', (.9, .9, .9), .8), } groups = {} def add(group, name, vs, fs, kind, smooth=False): me = bpy.data.meshes.new(name) me.from_pydata([tuple(v) for v in vs], [], fs) me.update() me.materials.append(M[kind]) for p in me.polygons: p.use_smooth = smooth ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) groups.setdefault(group, []).append(ob) return ob def box(group, name, c, size, kind, yaw=0.0): cx, cy, cz = c hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2 vs = [] for dy in (-1, 1): for dz in (-1, 1): for dx in (-1, 1): x, z = dx * hx, dz * hz vs.append(G(cx + x * cos(yaw) - z * sin(yaw), cy + dy * hy, cz + x * sin(yaw) + z * cos(yaw))) return add(group, name, vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)], kind) def lathe(group, name, profile, sides, kind, c=(0, 0, 0), jitter=0.0, seed=0.0, smooth=False): cx, cy, cz = c vs, fs = [], [] for r, y in profile: for k in range(sides): a = 2 * pi * k / sides rr = r * (1 + jitter * noise.noise(Vector((cos(a) * 2, y * 1.3, sin(a) * 2 + seed)))) vs.append(G(cx + cos(a) * rr, cy + y, cz + sin(a) * rr)) n = len(profile) for j in range(n - 1): for k in range(sides): a, b = j * sides + k, j * sides + (k + 1) % sides fs.append((a, b, b + sides, a + sides)) fs += [tuple(reversed(range(sides))), tuple(range((n - 1) * sides, n * sides))] return add(group, name, vs, fs, kind, smooth) def blob(group, name, c, size, kind, seed, subdiv=2, amp=.3, floor=None, smooth=False): bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0) cc = G(*c) for v in bm.verts: d = v.co.normalized() r = 1 + amp * noise.noise(d * 1.7 + Vector((seed, seed * .7, seed * .3))) gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2] if floor is not None and gy < floor * size[1]: gy = floor * size[1] v.co = cc + Vector((gx, -gz, gy)) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() me.materials.append(M[kind]) for p in me.polygons: p.use_smooth = smooth ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) groups.setdefault(group, []).append(ob) return ob def tube(group, name, points, radii, kind, sides=8, steps=10, seed=0.0, gnarl=0.0): pts = [G(*p) for p in points] vs, fs = [], [] for j in range(steps + 1): t = j / steps seg = min(len(pts) - 2, int(t * (len(pts) - 1))) u = t * (len(pts) - 1) - seg p = pts[seg].lerp(pts[seg + 1], u) tangent = (pts[seg + 1] - pts[seg]).normalized() ref = Vector((0, 0, 1)) if abs(tangent.z) < .9 else Vector((1, 0, 0)) a1 = tangent.cross(ref).normalized() a2 = tangent.cross(a1) rs = min(len(radii) - 2, int(t * (len(radii) - 1))) ru = t * (len(radii) - 1) - rs radius = radii[rs] * (1 - ru) + radii[rs + 1] * ru for k in range(sides): ang = 2 * pi * k / sides w = 1 + gnarl * noise.noise(Vector((cos(ang), t * 5, sin(ang) + seed))) vs.append(p + (a1 * cos(ang) + a2 * sin(ang)) * radius * w) for j in range(steps): for k in range(sides): a, b = j * sides + k, j * sides + (k + 1) % sides fs.append((a, b, b + sides, a + sides)) fs += [tuple(reversed(range(sides))), tuple(range(steps * sides, (steps + 1) * sides))] return add(group, name, vs, fs, kind, True) # ---------------------------------------------------------------- bridge (spans local x, 11 m long, 7 m wide) # The simulation is flat: the deck sits at ground level so units walk on it, not inside it. L, Wd = 11.0, 7.0 for i in range(12): x = -L / 2 + (i + .5) * L / 12 box('Bridge', 'Deck_%d' % i, (x, .05, 0), (L / 12 + .02, .1, Wd), 'paving') for s_ in (-1, 1): box('Bridge', 'Parapet_%d_%d' % (i, s_), (x, .42, s_ * (Wd / 2 - .22)), (L / 12 + .03, .62, .38), 'limestone') for x in (-L / 2 + .4, -1.8, 1.8, L / 2 - .4): for s_ in (-1, 1): lathe('Bridge', 'Post_%d_%d' % (int(x * 10), s_), [(.32, 0), (.32, 1.05), (.26, 1.15), (.2, 1.3)], 6, 'limestone', (x, 0, s_ * (Wd / 2 - .22))) lathe('Bridge', 'Cap_%d_%d' % (int(x * 10), s_), [(.22, 0), (.22, .1), (.14, .16)], 6, 'brass', (x, 1.3, s_ * (Wd / 2 - .22))) for x in (-2.6, 2.6): for s_ in (-1, 1): box('Bridge', 'Pier_%d_%d' % (int(x), s_), (x, -.6, s_ * (Wd / 2 + .15)), (1.1, 1.4, .5), 'limestone') # ---------------------------------------------------------------- curb stone (placed along lane edges) box('Curb', 'Curb', (0, .12, 0), (1.3, .26, .42), 'limestone') # ---------------------------------------------------------------- trees for v, (h, seed) in enumerate([(3.8, 1.0), (4.6, 2.0), (3.2, 3.0)]): group = 'Pine_%d' % v tube(group, 'Trunk', [(0, 0, 0), (0, h * .55, 0), (0, h, 0)], [.24, .17, .08], 'bark', 8, 6, seed, .1) for k in range(5): y = h * (.28 + k * .15) r = (1.35 - k * .22) * (1 + .08 * v) lathe(group, 'Cone_%d' % k, [(r, y), (r * .75, y + .35), (.05, y + h * .26)], 9, ('leaf_dark', 'leaf_mid')[k % 2], (0, 0, 0), .12, seed + k) for v, (h, seed) in enumerate([(3.4, 4.0), (4.0, 5.0), (2.8, 6.0)]): group = 'Oak_%d' % v tube(group, 'Trunk', [(0, 0, 0), (.15, h * .5, .05), (-.1, h * .85, 0)], [.34, .24, .15], 'bark', 9, 8, seed, .15) for k in range(3): ang = k * 2.1 + seed tube(group, 'Branch_%d' % k, [(0, h * .55, 0), (cos(ang) * .9, h * .78, sin(ang) * .9)], [.13, .06], 'bark', 6, 4, seed + k, .1) for k in range(9): ang = k * 2.4 + seed rr = .55 + .5 * ((k * .618) % 1.0) s = .95 - .2 * ((k * .37) % 1.0) blob(group, 'Leaves_%d' % k, (cos(ang) * rr, h * .88 + .5 * ((k * .53) % 1.0), sin(ang) * rr), (s, s * .78, s), ('leaf_mid', 'leaf_light', 'leaf_dark')[k % 3], seed * 10 + k, 2, .28) for k in range(4): ang = k * 1.57 + seed tube(group, 'Root_%d' % k, [(0, .25, 0), (cos(ang) * .7, .05, sin(ang) * .7)], [.14, .05], 'bark', 6, 4, seed + k) # ---------------------------------------------------------------- rocks and shrubs for v, (size, seed) in enumerate([((1.2, .8, 1.0), 7.0), ((1.8, 1.1, 1.3), 8.0), ((.8, .5, .7), 9.0)]): group = 'Rock_%d' % v blob(group, 'Rock', (0, size[1] * .3, 0), size, 'cliff', seed, 2, .34, -.35) blob(group, 'Moss', (size[0] * .15, size[1] * .82, 0), (size[0] * .55, size[1] * .25, size[2] * .5), 'leaf_dark', seed + 1, 1, .3, -.2) for k in range(2): blob(group, 'Pebble_%d' % k, (size[0] * (.9 + .3 * k), .1, size[2] * (.5 - k)), (.28, .18, .24), 'limestone', seed + 3 + k, 1, .3, -.3) for v in range(2): blob('Shrub_%d' % v, 'Shrub', (0, .3, 0), (.8 + .3 * v, .55, .7 + .2 * v), ('leaf_mid', 'leaf_dark')[v], 11 + v, 2, .35, -.3) # ---------------------------------------------------------------- banner (team colour at runtime via PR_Banner) lathe('Banner', 'Pole', [(.07, 0), (.07, 4.2), (.1, 4.3), (.02, 4.5)], 6, 'brass') vs, fs = [], [] for j in range(6): for i in range(4): x = .08 + i * .35 y = 4.1 - j * .45 z = .08 * sin(i * 1.3 + j * .6) vs.append(G(x, y - (.25 * (i / 3) if j == 5 else 0), z)) for j in range(5): for i in range(3): a = j * 4 + i fs.append((a, a + 1, a + 5, a + 4)) add('Banner', 'Cloth', vs, fs, 'cloth', True) # ---------------------------------------------------------------- edge cliffs (a 12 m wall segment, local x) for i in range(7): x = -6 + i * 2 h = 3.4 + 1.6 * noise.noise(Vector((i * .7, 3.1, 0))) blob('EdgeCliff', 'Block_%d' % i, (x, h * .45, 0), (1.6, h * .6, 1.8), 'cliff', 20 + i, 2, .3, -.5) blob('EdgeCliff', 'Grass_%d' % i, (x, h * .85, -.3), (1.2, .35, 1.3), 'leaf_mid', 30 + i, 1, .3, -.2) # Join each group into one mesh object per prop and triangulate. exported = [] for group, objects in groups.items(): bpy.ops.object.select_all(action='DESELECT') for ob in objects: ob.select_set(True) bpy.context.view_layer.objects.active = objects[0] bpy.ops.object.join() joined = bpy.context.object joined.name = group bm = bmesh.new() bm.from_mesh(joined.data) bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(joined.data) bm.free() exported.append(joined) bpy.ops.object.select_all(action='DESELECT') for ob in exported: ob.select_set(True) bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'map_props.blend')) bpy.ops.export_scene.gltf(filepath=str(OUT / 'map_props.glb'), export_format='GLB', use_selection=True, export_materials='EXPORT', export_yup=True, export_animations=False) raw = (OUT / 'map_props.glb').read_bytes() doc = json.loads(raw[20:20 + struct.unpack_from('