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>
662 lines
27 KiB
Python
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')
|