saumwacht-godot/assets-source/luneraria/g2/lune_bpy.py
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

662 lines
27 KiB
Python

"""Shared Blender helpers for the G2 strategic buildings of Champions of Luneraria.
Every G2 generator (Ancient, towers, boss grotto, Schlundkrone) builds its geometry from
these primitives, so the pieces share one design language: bevelled hard-surface stone and
metal, faceted crystals, rune bands with UVs for the glyph shader and rock masses from
noise-displaced spheres. After building, `finalize` sets split normals by angle and bakes an
ambient-occlusion term into the vertex colour `Col` (exported as COLOR_0); the Godot shaders
multiply it into stone and metal and use it for wear.
Material names (LN_*) are the contract with scripts/luneraria/view/lune_materials.gd; the
Blender colours only serve the .blend preview. Godot coordinates are written through
G(x, y, z) (x right, y up, z towards the viewer); Blender is Z-up.
"""
from pathlib import Path
import bpy, bmesh, json, hashlib, struct
from math import sin, cos, pi, sqrt, atan2
from mathutils import Vector, Matrix, noise
from mathutils.bvhtree import BVHTree
P = Path(__file__).resolve().parents[3]
SRC = Path(__file__).resolve().parent
OUT = P / 'assets/luneraria/g2'
V = Vector
def reset():
_materials.clear()
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
bpy.context.scene.render.fps = 30
OUT.mkdir(parents=True, exist_ok=True)
def G(x, y, z):
return V((x, -z, y))
def to_godot(v):
return (v.x, v.z, -v.y)
def lin(c):
return tuple(x ** 2.2 for x in c)
# Display colour (sRGB), roughness, metallic, emission strength.
PALETTE = {
'LN_Stone_Pale': ((.80, .76, .68), .88, 0.0, 0.0),
'LN_Stone_Dark': ((.26, .26, .29), .9, 0.0, 0.0),
'LN_Stone_Grotto': ((.46, .43, .47), .92, 0.0, 0.0),
'LN_Rock': ((.40, .39, .37), .95, 0.0, 0.0),
'LN_Metal_Team': ((.74, .76, .80), .34, .9, 0.0),
'LN_Metal_Iron': ((.22, .22, .24), .55, .8, 0.0),
'LN_Metal_Bronze': ((.62, .44, .22), .42, .9, 0.0),
'LN_Crystal_Team': ((.55, .80, 1.0), .08, 0.0, 3.0),
'LN_Crystal_Grotto': ((.70, .50, 1.0), .08, 0.0, 3.0),
'LN_Rune_Team': ((.55, .80, 1.0), .5, 0.0, 2.0),
'LN_Rune_Grotto': ((.70, .50, 1.0), .5, 0.0, 2.0),
'LN_Cloth_Team': ((.20, .40, .80), .85, 0.0, 0.0),
'LN_Wood': ((.36, .25, .16), .8, 0.0, 0.0),
'LN_Cave': ((.03, .02, .05), 1.0, 0.0, 0.0),
'LN_Water_Glow': ((.45, .35, .80), .1, 0.0, 1.2),
'LN_Boss_Hide': ((.24, .25, .29), .84, 0.0, 0.0),
'LN_Boss_Bone': ((.80, .77, .69), .62, 0.0, 0.0),
'LN_Boss_Bronze': ((.60, .44, .24), .42, .85, 0.0),
'LN_Crystal_Boss': ((.70, .50, 1.0), .08, 0.0, 3.0),
}
_materials = {}
def material(name):
if name in _materials:
return _materials[name]
color, rough, metal, emission = PALETTE[name]
m = bpy.data.materials.new(name)
c = lin(color)
m.diffuse_color = (*c, 1)
m.use_nodes = True
nodes, links = m.node_tree.nodes, m.node_tree.links
bsdf = nodes.get('Principled BSDF')
bsdf.inputs['Roughness'].default_value = rough
bsdf.inputs['Metallic'].default_value = metal
# Baked occlusion (vertex colour Col) darkens the preview like it does in Godot.
attribute = nodes.new('ShaderNodeVertexColor')
attribute.layer_name = 'Col'
mix = nodes.new('ShaderNodeMix')
mix.data_type = 'RGBA'
mix.blend_type = 'MULTIPLY'
mix.inputs['Factor'].default_value = 1.0
mix.inputs['A'].default_value = (*c, 1)
links.new(attribute.outputs['Color'], mix.inputs['B'])
links.new(mix.outputs['Result'], bsdf.inputs['Base Color'])
if emission:
bsdf.inputs['Emission Color'].default_value = (*c, 1)
bsdf.inputs['Emission Strength'].default_value = emission
_materials[name] = m
return m
# ---------------------------------------------------------------- objects
def make(name, verts, faces, mat, uvs=None, smooth=False):
"""Object from Godot-space vertices (tuples or Vectors) and faces; uvs per vertex."""
me = bpy.data.meshes.new(name)
me.from_pydata([tuple(G(*v)) for v in verts], [], faces)
me.update()
me.materials.append(material(mat))
if uvs is not None:
layer = me.uv_layers.new(name='UVMap')
for poly in me.polygons:
for li in poly.loop_indices:
layer.data[li].uv = uvs[me.loops[li].vertex_index]
for poly in me.polygons:
poly.use_smooth = smooth
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def bevel(ob, offset=.03, segments=2, angle=35.0, profile=.5):
"""Bevels every edge sharper than `angle` degrees (hard-surface highlight edges)."""
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.normal_update()
limit = angle * pi / 180.0
edges = [e for e in bm.edges if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > limit]
if edges:
bmesh.ops.bevel(bm, geom=edges, offset=offset, offset_type='OFFSET', segments=segments, profile=profile,
affect='EDGES', clamp_overlap=True)
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
return ob
def join(objects, name, origin=(0, 0, 0)):
"""Joins objects into one named object whose origin sits at the Godot point `origin`."""
objects = [o for o in objects if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objects:
o.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
ob = bpy.context.view_layer.objects.active
ob.name = name
ob.data.name = name
pivot = G(*origin)
ob.data.transform(Matrix.Translation(-pivot))
ob.location = pivot
return ob
# ---------------------------------------------------------------- primitives
def lathe(name, profile, sides, mat, center=(0, 0, 0), rotation=0.0, cap_top=True, cap_bottom=True, jitter=0.0, seed=0.0,
smooth=False, uv_band=None):
"""Revolves a (radius, height) profile. uv_band=(cell_length) gives UVs u along the
circumference in cells and v along the profile (0..1) for rune bands."""
cx, cy, cz = center
verts, faces, uvs = [], [], []
n = len(profile)
total = 0.0
lengths = [0.0]
for j in range(1, n):
total += ((profile[j][0] - profile[j - 1][0]) ** 2 + (profile[j][1] - profile[j - 1][1]) ** 2) ** .5
lengths.append(total)
for j, (r, y) in enumerate(profile):
for k in range(sides + (1 if uv_band else 0)):
a = 2 * pi * (k % sides) / sides + rotation
rr = r * (1 + jitter * noise.noise(V((cos(a) * 2.3, y * 1.7, sin(a) * 2.3 + seed)))) if jitter else r
verts.append((cx + cos(a) * rr, cy + y, cz + sin(a) * rr))
if uv_band:
circumference = 2 * pi * max(r, .01)
cells = max(1, round(circumference / uv_band))
uvs.append((cells * k / sides, lengths[j] / max(total, 1e-6)))
ring = sides + (1 if uv_band else 0)
for j in range(n - 1):
for k in range(sides):
a0, b0 = j * ring + k, j * ring + (k + 1) % ring if not uv_band else j * ring + k + 1
faces.append((a0, b0, b0 + ring, a0 + ring))
if cap_bottom:
faces.append(tuple(reversed(range(sides))))
if cap_top:
faces.append(tuple(range((n - 1) * ring, (n - 1) * ring + sides)))
ob = make(name, verts, faces, mat, uvs if uv_band else None, smooth)
# Faces must point outwards whatever the profile direction was.
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
return ob
def box(name, center, size, mat, yaw=0.0, pitch=0.0, roll=0.0, bev=.025, segments=1):
hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(pitch, 3, 'X') @ Matrix.Rotation(roll, 3, 'Z')
c = V(center)
verts = []
for dy in (-1, 1):
for dz in (-1, 1):
for dx in (-1, 1):
verts.append(tuple(c + rot @ V((dx * hx, dy * hy, dz * hz))))
faces = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
ob = make(name, verts, faces, mat)
_outward(ob)
if bev > 0:
bevel(ob, bev, segments, 30.0)
return ob
def _outward(ob):
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
def prism(name, outline, y0, y1, mat, bev=.02, segments=1, top_scale=1.0):
"""Extrudes a closed Godot-xz outline from y0 to y1 (top optionally scaled around its centre)."""
n = len(outline)
cx = sum(p[0] for p in outline) / n
cz = sum(p[1] for p in outline) / n
verts = [(x, y0, z) for x, z in outline]
verts += [(cx + (x - cx) * top_scale, y1, cz + (z - cz) * top_scale) for x, z in outline]
faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)]
faces += [tuple(reversed(range(n))), tuple(range(n, 2 * n))]
ob = make(name, verts, faces, mat)
_outward(ob)
if bev > 0:
bevel(ob, bev, segments, 30.0)
return ob
def sweep(name, points, widths, thicknesses, mat, up_hint=(0, 1, 0), steps=24, lens=0.0, closed_ends=True, uv_cells=None,
smooth=True, twist=0.0):
"""Sweeps a rectangle (optionally lens-shaped) along a Catmull-Rom path through Godot points.
widths/thicknesses are sampled along the path. uv_cells gives rune UVs (u along, v across)."""
pts = [V(p) for p in points]
def cat(t):
seg = min(len(pts) - 2, int(t * (len(pts) - 1)))
u = t * (len(pts) - 1) - seg
a, b, c, d = [pts[max(0, min(len(pts) - 1, q))] for q in (seg - 1, seg, seg + 1, seg + 2)]
return .5 * ((2 * b) + (-a + c) * u + (2 * a - 5 * b + 4 * c - d) * u * u + (-a + 3 * b - 3 * c + d) * u * u * u)
def sample(values, t):
f = t * (len(values) - 1)
i = min(len(values) - 2, int(f))
return values[i] * (1 - (f - i)) + values[i + 1] * (f - i)
# Rectangle corners with optional lens bulge: 8 points around the section.
section = [(-1, -1), (0, -1 - lens), (1, -1), (1, 0), (1, 1), (0, 1 + lens), (-1, 1), (-1, 0)]
verts, faces, uvs = [], [], []
along = 0.0
previous = None
for j in range(steps + 1):
t = j / steps
p = cat(t)
tangent = (cat(min(1, t + .01)) - cat(max(0, t - .01))).normalized()
hint = V(up_hint)
side = tangent.cross(hint)
if side.length < 1e-4:
side = tangent.cross(V((1, 0, 0)))
side.normalize()
normal = side.cross(tangent).normalized()
if twist:
ang = twist * t
side, normal = side * cos(ang) + normal * sin(ang), normal * cos(ang) - side * sin(ang)
if previous is not None:
along += (p - previous).length
previous = p
w, th = sample(widths, t) * .5, sample(thicknesses, t) * .5
for k, (sx, sy) in enumerate(section):
verts.append(tuple(p + side * sx * w + normal * sy * th))
uvs.append(((along / uv_cells) if uv_cells else t, k / 8.0))
if j:
base = (j - 1) * 8
for k in range(8):
a0, b0 = base + k, base + (k + 1) % 8
faces.append((a0, b0, b0 + 8, a0 + 8))
if closed_ends:
faces.append(tuple(reversed(range(8))))
faces.append(tuple(range(steps * 8, steps * 8 + 8)))
ob = make(name, verts, faces, mat, uvs, smooth)
_outward(ob)
return ob
def crystal(name, center, radius, top, bottom, mat, sides=6, tilt=(0.0, 0.0), twist=0.0, seed=0, shoulder=.62):
"""Faceted crystal: a prism with a pointed crown and a shorter pointed foot. Facet radii
vary slightly per side so the highlights break up."""
c = V(center)
rot = Matrix.Rotation(tilt[0], 3, 'X') @ Matrix.Rotation(tilt[1], 3, 'Z')
verts = [tuple(c + rot @ V((0, top, 0)))]
rings = [(shoulder * top, 1.0), (-shoulder * bottom * .5, 1.0)]
for y, scale in rings:
for k in range(sides):
a = 2 * pi * k / sides + twist
rr = radius * scale * (1 + .12 * noise.noise(V((k * 1.7, y, seed))))
verts.append(tuple(c + rot @ V((cos(a) * rr, y, sin(a) * rr))))
verts.append(tuple(c + rot @ V((0, -bottom, 0))))
faces = [(0, 1 + (k + 1) % sides, 1 + k) for k in range(sides)]
for k in range(sides):
faces.append((1 + k, 1 + (k + 1) % sides, 1 + sides + (k + 1) % sides, 1 + sides + k))
last = 1 + 2 * sides
faces += [(last, 1 + sides + k, 1 + sides + (k + 1) % sides) for k in range(sides)]
ob = make(name, verts, faces, mat)
_outward(ob)
return ob
def blob(name, center, radii, mat, seed=0.0, subdiv=3, amp=.25, flatten_below=None, yaw=0.0, crag=0.0):
"""Rock mass: icosphere displaced by fractal noise (and optional terraced crags)."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y')
for v in bm.verts:
d = v.co.normalized()
n = noise.fractal(d * 1.6 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 4)
r = 1.0 + amp * n
if crag:
r += crag * abs(noise.noise(d * 3.1 + V((seed * 1.3, 0, 0))))
p = V((d.x * radii[0] * r, d.y * radii[1] * r, d.z * radii[2] * r))
if flatten_below is not None and p.y < flatten_below:
p.y = flatten_below + (p.y - flatten_below) * .15
v.co = G(*(c + rot @ p))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def chunk(name, center, size, mat, seed=0.0, yaw=0.0, tilt=0.0, sink=.25):
"""Broken masonry fragment: a low-poly displaced icosphere with a flat bed, irregular and
angular (rubble, debris, ruins). `sink` buries the bottom into the ground."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.0)
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(tilt, 3, 'X')
sx, sy, sz = size
for v in bm.verts:
d = v.co.normalized()
r = 1.0 + .28 * noise.noise(d * 2.1 + V((seed, seed * 1.9, seed * .4)))
p = V((d.x * sx * r, d.y * sy * r, d.z * sz * r))
# Flatten faces into cut planes so fragments look split, not rounded.
p.x = max(-sx * .82, min(sx * .82, p.x))
p.z = max(-sz * .82, min(sz * .82, p.z))
p.y = max(-sy * (1 - sink), min(sy * .9, p.y))
v.co = G(*(c + rot @ p))
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def cliff(name, center, radii, mat, seed=0.0, cuts=7, subdiv=2, amp=.16, yaw=0.0, lean=(0.0, 0.0), flat_bottom=None):
"""Faceted cliff rock in the low-poly language of the map rocks: a displaced icosphere cut
by a few random planes (split faces), leaning optionally. Flat shading comes from
finalize()'s angle split. flat_bottom (Godot y, absolute) flattens everything below it."""
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0)
rng = seed * 7.31
planes = []
for k in range(cuts):
# Cut normals biased sideways and up, so faces read as cleft rock, not as a gem.
a = 2 * pi * ((k * .618 + seed * .17) % 1.0)
tilt = .25 + .9 * ((k * .377 + seed * .29) % 1.0)
n = V((cos(a) * sin(tilt * 1.2), cos(tilt * 1.2) * .6, sin(a) * sin(tilt * 1.2))).normalized()
planes.append((n, .6 + .26 * ((k * .731 + rng) % 1.0)))
c = V(center)
rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(lean[0], 3, 'X') @ Matrix.Rotation(lean[1], 3, 'Z')
for v in bm.verts:
d = v.co.normalized()
r = 1.0 + amp * noise.fractal(d * 1.9 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 3)
q = d * r
for n, off in planes:
over = q.dot(n) - off
if over > 0:
q -= n * over
p = V((q.x * radii[0], q.y * radii[1], q.z * radii[2]))
w = c + rot @ p
if flat_bottom is not None and w.y < flat_bottom:
w.y = flat_bottom
v.co = G(*w)
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
me.materials.append(material(mat))
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
return ob
def ring(name, center, radius, width, thickness, mat, sides=48, axis_tilt=(0.0, 0.0), uv_cell=None, bev=.02):
"""Flat ring (rectangular section) around the Godot y axis, tilted by (x, z) angles.
The outer band carries rune UVs when uv_cell is given."""
c = V(center)
rot = Matrix.Rotation(axis_tilt[0], 3, 'X') @ Matrix.Rotation(axis_tilt[1], 3, 'Z')
section = [(radius - width / 2, -thickness / 2), (radius + width / 2, -thickness / 2),
(radius + width / 2, thickness / 2), (radius - width / 2, thickness / 2)]
verts, faces, uvs = [], [], []
cells = max(1, round(2 * pi * radius / uv_cell)) if uv_cell else 1
for k in range(sides + 1):
a = 2 * pi * k / sides
for s, (r, y) in enumerate(section):
verts.append(tuple(c + rot @ V((cos(a) * r, y, sin(a) * r))))
uvs.append((cells * k / sides, 0.0 if s in (0, 1) else 1.0))
for k in range(sides):
for s in range(4):
a0, b0 = k * 4 + s, k * 4 + (s + 1) % 4
faces.append((a0, b0, b0 + 4, a0 + 4))
ob = make(name, verts, faces, mat, uvs)
_outward(ob)
if bev > 0:
bevel(ob, bev, 1, 30.0)
return ob
def band(name, center, radius, y0, y1, mat, sides=48, uv_cell=.35, inset=0.0):
"""Vertical cylindrical band (rune frieze) with UVs: u in glyph cells, v across."""
c = V(center)
verts, faces, uvs = [], [], []
cells = max(1, round(2 * pi * radius / uv_cell))
for k in range(sides + 1):
a = 2 * pi * k / sides
for s, y in enumerate((y0, y1)):
verts.append((c.x + cos(a) * (radius + inset), c.y + y, c.z + sin(a) * (radius + inset)))
uvs.append((cells * k / sides, float(s)))
for k in range(sides):
faces.append((k * 2, k * 2 + 2, k * 2 + 3, k * 2 + 1))
ob = make(name, verts, faces, mat, uvs)
_outward(ob)
return ob
def panel(name, center, width, height, normal_yaw, mat, depth=.02, uv_cells=(1, 1)):
"""Thin rectangular inlay facing the Godot direction normal_yaw (for rune panels)."""
c = V(center)
right = V((cos(normal_yaw), 0, -sin(normal_yaw)))
out = V((sin(normal_yaw), 0, cos(normal_yaw)))
up = V((0, 1, 0))
verts, uvs = [], []
for sy in (-1, 1):
for sx in (-1, 1):
verts.append(tuple(c + right * sx * width / 2 + up * sy * height / 2 + out * depth))
uvs.append(((sx + 1) / 2 * uv_cells[0], (sy + 1) / 2 * uv_cells[1]))
faces = [(0, 1, 3, 2)]
ob = make(name, verts, faces, mat, uvs)
return ob
def disc_grid(name, center, radius, mat, rings=6, sides=48, y=0.0, uv_scale=None):
"""Flat disc (Godot y up) with polar UVs: u = angle cells, v = radius (for floor runes)."""
c = V(center)
verts, faces, uvs = [(c.x, c.y + y, c.z)], [], [(0.0, 0.0)]
for j in range(1, rings + 1):
r = radius * j / rings
for k in range(sides + 1):
a = 2 * pi * k / sides
verts.append((c.x + cos(a) * r, c.y + y, c.z + sin(a) * r))
uvs.append((k / sides * (uv_scale or 1.0), j / rings))
for k in range(sides):
faces.append((0, 1 + k + 1, 1 + k))
ring_n = sides + 1
for j in range(rings - 1):
for k in range(sides):
a0 = 1 + j * ring_n + k
faces.append((a0, a0 + 1, a0 + 1 + ring_n, a0 + ring_n))
ob = make(name, verts, faces, mat, uvs)
for poly in ob.data.polygons:
poly.use_smooth = True
# Faces up.
bm = bmesh.new()
bm.from_mesh(ob.data)
for f in bm.faces:
f.normal_update()
if f.normal.z < 0:
f.normal_flip()
bm.to_mesh(ob.data)
bm.free()
return ob
def banner(name, width, height, mat, shape='swallowtail', segments=(6, 12), top=(0, 0, 0), yaw=0.0):
"""Hanging cloth below the Godot point `top`: UV v runs 0 at the pole to 1 at the hem, so
the cloth shader weights the wind by v. shape: 'swallowtail' (Team A, cool) or 'flame'
(Team B, warm: three flame tongues)."""
nx, ny = segments
t = V(top)
rot = Matrix.Rotation(yaw, 3, 'Y')
verts, uvs, faces = [], [], []
for j in range(ny + 1):
v = j / ny
for i in range(nx + 1):
u = i / nx
x = (u - .5) * width
y = -v * height
if j == ny:
if shape == 'swallowtail':
y += height * .28 * (1 - abs(u - .5) * 2)
else:
y -= height * .12 * max(0.0, cos((u - .5) * 3 * 2 * pi)) ** 2
verts.append(tuple(t + rot @ V((x, y, 0))))
uvs.append((u, v))
for j in range(ny):
for i in range(nx):
a = j * (nx + 1) + i
faces.append((a, a + 1, a + nx + 2, a + nx + 1))
ob = make(name, verts, faces, mat, uvs, smooth=True)
return ob
# ---------------------------------------------------------------- finishing
def finalize(objects, sharp_angle=40.0, ao=True, rays=24, reach=2.2, ground=0.0, occluders=None, seed=7):
"""Split normals by angle, then bake ambient occlusion into the vertex colour Col.
Occlusion is traced against all `occluders` (default: the objects themselves) plus the
ground plane at Godot height `ground`, so feet of walls and inner corners darken."""
for ob in objects:
me = ob.data
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.to_mesh(me)
bm.free()
me.shade_smooth()
me.set_sharp_from_angle(angle=sharp_angle * pi / 180.0)
me.update()
if not ao:
for ob in objects:
_fill_color(ob, lambda i: 1.0)
return
sources = occluders if occluders is not None else objects
verts, polys = [], []
for ob in sources:
mw = ob.matrix_world
base = len(verts)
verts += [mw @ v.co for v in ob.data.vertices]
polys += [tuple(base + i for i in p.vertices) for p in ob.data.polygons]
tree = BVHTree.FromPolygons(verts, polys, epsilon=0.0)
# Fixed, well spread hemisphere directions (golden spiral), rotated per vertex.
dirs = []
for i in range(rays):
z = 1 - (i + .5) / rays
r = sqrt(max(0.0, 1 - z * z))
a = i * 2.39996323
dirs.append(V((cos(a) * r, sin(a) * r, z)))
ground_z = ground # Blender z == Godot y
for ob in objects:
mw = ob.matrix_world
nm = mw.to_3x3().inverted().transposed()
values = []
for v in ob.data.vertices:
p = mw @ v.co
n = (nm @ v.normal).normalized()
t = n.orthogonal().normalized()
b = n.cross(t)
hit = 0.0
origin = p + n * .012
for d in dirs:
w = t * d.x + b * d.y + n * d.z
blocked = False
if w.z < -1e-4 and origin.z >= ground_z:
dist = (origin.z - ground_z) / -w.z
blocked = dist < reach
if not blocked:
loc, _, _, dist = tree.ray_cast(origin, w, reach)
blocked = loc is not None
if blocked:
hit += d.z
weight = sum(d.z for d in dirs)
values.append(max(0.0, 1.0 - hit / weight))
_fill_color(ob, lambda i, values=values: .28 + .72 * values[i] ** .8)
def _fill_color(ob, fn):
me = ob.data
attr = me.color_attributes.get('Col') or me.color_attributes.new(name='Col', type='FLOAT_COLOR', domain='POINT')
for i, item in enumerate(attr.data):
a = fn(i)
item.color = (a, a, a, 1.0)
me.color_attributes.active_color = attr
me.color_attributes.render_color_index = me.color_attributes.find('Col')
def triangulate(ob):
bm = bmesh.new()
bm.from_mesh(ob.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(ob.data)
bm.free()
def triangles(objects):
total = 0
for ob in objects:
total += sum(len(p.vertices) - 2 for p in ob.data.polygons)
return total
def export(objects, glb_name, blend_name, extra_objects=()):
"""Saves the .blend and exports the given objects (plus armatures) as GLB. Returns the
parsed glTF JSON and the raw bytes for the manifest."""
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / blend_name))
bpy.ops.object.select_all(action='DESELECT')
for ob in list(objects) + list(extra_objects):
ob.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
path = OUT / glb_name
bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, export_materials='EXPORT',
export_yup=True, export_animations=False, export_vertex_color='ACTIVE', export_normals=True,
export_apply=False)
raw = path.read_bytes()
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
return doc, raw
def glb_triangles(doc):
return sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
def bounds_godot(objects):
lo = [1e9, 1e9, 1e9]
hi = [-1e9, -1e9, -1e9]
for ob in objects:
for v in ob.data.vertices:
g = to_godot(ob.matrix_world @ v.co)
for i in range(3):
lo[i] = min(lo[i], g[i])
hi[i] = max(hi[i], g[i])
return {'min': [round(x, 4) for x in lo], 'max': [round(x, 4) for x in hi]}
def record(asset_id, glb_name, blend_name, generator, objects, doc, raw, **extra):
nodes = sorted(n.get('name', '') for n in doc.get('nodes', []))
entry = {'id': asset_id, 'resource_path': 'res://assets/luneraria/g2/' + glb_name,
'source': 'assets-source/luneraria/g2/' + blend_name, 'generator': 'assets-source/luneraria/g2/' + generator,
'triangles': glb_triangles(doc), 'nodes': nodes, 'materials': sorted(m['name'] for m in doc.get('materials', [])),
'bounds_godot': bounds_godot(objects), 'pivot': 'ground centre (0, 0, 0)', 'units': 'metres (Godot 1 unit = 1 m)',
'sha256': hashlib.sha256(raw).hexdigest()}
entry.update(extra)
return entry
def write_manifest(entries):
"""Merges entries (by id) into assets/luneraria/g2/g2_manifest.json."""
path = OUT / 'g2_manifest.json'
data = json.loads(path.read_text()) if path.exists() else {'assets': []}
by_id = {e['id']: e for e in data['assets']}
for e in entries:
by_id[e['id']] = e
data['assets'] = [by_id[k] for k in sorted(by_id)]
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n')