Rock Gen 2.14.0: Export-Haertung (Wunschliste 1-3) + LOD/Vertex-Farben geklaert

Punkt 1 - Ursprung erzwingen (keine Option mehr)
  Der Export schrieb die Objekt-Transformation mit ins FBX. Dadurch trug das
  25er-Set seine Szenen-Rasterpositionen (bis 20 m) im Asset und die Steine
  schwebten im Spiel neben ihrem Platzierungspunkt. Jetzt wandert jedes Objekt
  an den Welt-Ursprung, Origin unten-mittig der Bbox. Am REIMPORTIERTEN FBX
  gemessen: Bbox-Mitte 0.000/0.000, min.Z 0.000.

Punkt 2 - flache Steine hinlegen
  Kuerzeste Bbox-Achse zeigt nach oben. Gedreht wird nur, wenn es etwas bringt:
  bei 2.53/1.40/1.54 wuerde Kippen die Form nur willkuerlich drehen.

Punkt 3 - UCX-Kollisionshuellen
  Konvexe Huelle als UCX_<Name> im selben FBX, Budget einstellbar (Default 32
  Verts). Gemessen 29-32 Verts, geschlossen, umschliesst den Stein.

Alles auf temporaeren KOPIEN - die Szene des Users bleibt unveraendert
(Test prueft Namen und Objektzahl vorher/nachher).

Offener Punkt der Spielseite beantwortet: LOD-Gruppen + Vertex-Farben gingen
NICHT. Der LOD-Zweig in make_one_rock sprang komplett am Cavity-Bake vorbei -
gemessen: kein Farbattribut auf einer der vier Stufen, dazu fehlten Origin und
Material. Jetzt wird auf LOD0 gebacken, BEVOR dupliziert wird; das Decimate
interpoliert mit, damit alle Stufen dieselbe Cavity zeigen und der LOD-Wechsel
nicht poppt. Nach Roundtrip: LOD0 40 V R 0.30..1.00, LOD1 21 V, LOD2 11 V,
LOD3 6 V - alle mit Farben.

Vier Fallen, die der Test aufgedeckt hat:
- Blender haengt an Kopien ".001" an; Unreal ordnet UCX ueber den EXAKTEN
  Mesh-Namen zu -> Original bekommt fuer die Exportdauer einen Hilfsnamen.
- bmesh.from_mesh bringt die Originalflaechen mit, convex_hull legt seine
  Huelle obendrauf -> 16-21 offene Kanten. Nur die PUNKTE einlesen.
- Jede Vereinfachung schneidet Ecken ab, die Huelle wird KLEINER als der Stein
  (der Spieler wuerde einsinken). Jetzt exakt so weit aufgeblasen, dass wieder
  alles hineinpasst - fuer Kollision ist minimal zu gross unkritisch.
- ob.matrix_world = Identity loescht die Transformation NICHT bei geparenteten
  Objekten; bei einer LOD-Gruppe auf (13/-7.5/2.2) landete das Mesh bei MINUS
  dieser Position. matrix_basis und matrix_parent_inverse direkt nullen.

Neu: tests/test_export_robust.py misst am reimportierten FBX statt an der
Szene - nur so faellt eine mitgeschriebene Transformation ueberhaupt auf.
15 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:02:14 +02:00
co-authored by Claude Opus 4.8
parent 26131f9346
commit 20a751edca
8 changed files with 619 additions and 13 deletions
+306 -5
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 13, 0),
"version": (2, 14, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen",
"description": (
@@ -13,6 +13,7 @@ bl_info = {
}
import bpy
import bmesh
import os
import json
import math
@@ -24,6 +25,7 @@ from bpy.props import (
StringProperty, PointerProperty,
)
from bpy.types import Operator, Panel, PropertyGroup
from mathutils import Matrix, Vector
# ---------------------------------------------------------------------------
@@ -651,6 +653,16 @@ def _build_lod_group(context, settings, index, location, rng, base_name=None):
lod0 = _build_rock_mesh(context, settings, index, location, rng, name=base_name)
_apply_hardsurface(context, lod0, settings)
_apply_random_transform(context, lod0, settings, rng)
# Vertex-Farben JETZT backen, vor dem Duplizieren. Zwei Gruende:
# 1. Der LOD-Zweig sprang vorher komplett am Bake vorbei - LOD-Gruppen kamen
# OHNE Vertex-Farben aus dem Export (gemessen: kein Farbattribut auf einer
# der vier Stufen). Genau der offene Punkt der Spielseite.
# 2. Das Decimate interpoliert die Farben mit. Wuerde je Stufe neu gebacken,
# haette jede Stufe ihre eigene Cavity und der LOD-Wechsel wuerde poppen.
if settings.bake_vertex_cavity:
_bake_vertex_cavity(context, lod0, settings)
if settings.assign_material:
_assign_material(lod0, index)
lod0.name = base_name + "_LOD0"
@@ -1032,6 +1044,25 @@ class RockGenSettings(PropertyGroup):
name="Nur Auswahl exportieren", default=False,
description="Exportiert nur die aktuell selektierten Rocks statt aller",
)
# Ursprung unten-mittig + Nullpunkt ist KEINE Option, sondern passiert immer -
# ein Stein, der seine Szenenposition mit ins Asset nimmt, ist schlicht kaputt.
export_lay_flat: BoolProperty(
name="Flache Steine hinlegen", default=True,
description=("Dreht vor dem Export die kuerzeste Bbox-Achse nach oben, "
"damit plattige Steine liegen statt zu stehen. Im Spiel gibt "
"es nur zufaelligen Yaw - die Ausrichtung ist endgueltig"),
)
export_ucx: BoolProperty(
name="UCX-Kollision mitexportieren", default=True,
description=("Legt je Stein eine konvexe Huelle 'UCX_<Name>' ins FBX. "
"Unreal nimmt sie automatisch als Simple Collision"),
)
ucx_max_verts: IntProperty(
name="UCX max. Verts", default=32, min=8, max=128,
description=("Obergrenze fuer die Kollisionshuelle. Simple Collision soll "
"wenige Ebenen haben - eine volle Huelle waere keine "
"Vereinfachung"),
)
# --- Zufall ---
random_amount: FloatProperty(
@@ -1143,6 +1174,245 @@ class OBJECT_OT_generate_rocks(Operator):
# FBX-Export (UE-ready)
# ---------------------------------------------------------------------------
def _lay_flat_matrix(dims, tolerance=0.9):
"""Rotation, die die KUERZESTE Bbox-Achse nach oben dreht (flache Seite unten).
Im Spiel gibt es nur zufaelligen Yaw - die gebackene Ausrichtung ist also
endgueltig. Plattige Steine muessen deshalb schon im FBX liegen.
Gedreht wird nur, wenn die kuerzeste Achse deutlich kuerzer ist als die
aktuelle Hoehe (Toleranz). Sonst wuerde ein annaehernd wuerfeliger Stein
willkuerlich gekippt, obwohl er richtig steht.
"""
dx, dy, dz = dims
shortest = min(dx, dy, dz)
if shortest >= dz * tolerance:
return None # steht schon richtig (oder ist rundlich)
if shortest == dx:
return Matrix.Rotation(math.radians(90.0), 4, 'Y')
return Matrix.Rotation(math.radians(90.0), 4, 'X')
def _bake_transform(ob, world, extra=None):
"""Backt die uebergebene Weltmatrix in die Mesh-Daten und nullt das Objekt.
Noetig, weil der FBX-Export die Objekt-Transformation MITSCHREIBT: das
25er-Set trug seine Szenen-Raster-Positionen (bis 20 m) im Asset und schwebte
im Spiel neben dem Platzierungspunkt.
FALLE: `ob.matrix_world = Identity` loescht die Transformation NICHT, wenn das
Objekt ein Eltern-Objekt hat - Blender rechnet dann eine lokale Gegen-Matrix
aus, damit die Welt-Position stimmt. Bei einer LOD-Gruppe an Position
(13/-7.5/2.2) landete das Mesh dadurch bei MINUS derselben Position. Deshalb
werden matrix_basis UND matrix_parent_inverse direkt genullt.
"""
mat = extra @ world if extra is not None else world
ob.data.transform(mat)
# Bei gespiegelter Skalierung dreht sich die Flaechen-Orientierung um.
if mat.determinant() < 0.0:
ob.data.flip_normals()
ob.matrix_basis = Matrix.Identity(4)
ob.matrix_parent_inverse = Matrix.Identity(4)
def _bbox_of(objs):
"""Gemeinsame Bounding-Box mehrerer Meshes in deren (bereits gebackenen) Koordinaten."""
lo = [float("inf")] * 3
hi = [float("-inf")] * 3
for o in objs:
if o.type != 'MESH':
continue
for v in o.data.vertices:
for i in range(3):
lo[i] = min(lo[i], v.co[i])
hi[i] = max(hi[i], v.co[i])
if lo[0] == float("inf"):
return None, None
return Vector(lo), Vector(hi)
def _make_ucx(context, src_objs, mesh_name, max_verts=32):
"""Konvexe Huelle als `UCX_<Name>` - Unreal nimmt sie als Simple Collision.
Wichtig fuer die Physik-Bruchstuecke: "Complex as Simple" taugt dafuer nicht.
Die Huelle wird auf max_verts eingedampft (planares Aufloesen), weil UE fuer
Simple Collision wenige Ebenen will - eine 150-Vert-Huelle ist keine
Vereinfachung mehr.
"""
# FALLE: nur die PUNKTE einlesen, nicht das ganze Mesh. Mit from_mesh kommen
# die Originalflaechen mit, und convex_hull legt seine Huelle dann obendrauf -
# das Ergebnis hatte gemessen 16-21 offene Kanten und war keine geschlossene
# Huelle mehr.
bm = bmesh.new()
for o in src_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(v.co)
if not bm.verts:
bm.free()
return None
bm.verts.ensure_lookup_table()
res = bmesh.ops.convex_hull(bm, input=bm.verts[:], use_existing_faces=False)
# Punkte im Inneren und uebrig gebliebene Geometrie gehoeren nicht zur Huelle.
weg = list(set(res.get("geom_interior", []) + res.get("geom_unused", [])))
if weg:
bmesh.ops.delete(bm, geom=weg, context='VERTS')
bm.verts.ensure_lookup_table()
bm.normal_update()
# Alle Quellpunkte merken - die Huelle muss sie danach noch umschliessen.
quelle = [v.co.copy() for v in bm.verts]
# Aufs Budget eindampfen. Jede Vereinfachung schneidet zwangslaeufig Ecken ab,
# die Huelle wird dadurch KLEINER als der Stein - eine Kollision, die kleiner
# ist als das Mesh, laesst den Spieler einsinken. Deshalb wird sie danach
# exakt so weit aufgeblasen, dass wieder alles hineinpasst: fuer Kollision ist
# minimal zu gross unkritisch, zu klein nicht.
angle = math.radians(1.0)
while len(bm.verts) > max_verts and angle < math.radians(60.0):
bmesh.ops.dissolve_limit(
bm, angle_limit=angle, verts=bm.verts[:], edges=bm.edges[:])
bmesh.ops.delete(
bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS')
bm.verts.ensure_lookup_table()
if not bm.faces:
break
angle += math.radians(3.0)
bm.normal_update()
if bm.faces and quelle:
mitte = Vector((0.0, 0.0, 0.0))
for v in bm.verts:
mitte += v.co
mitte /= max(len(bm.verts), 1)
# Kleinster Faktor, mit dem jede Ebene alle Quellpunkte einschliesst.
faktor = 1.0
for f in bm.faces:
n = f.normal
if n.length_squared < 1e-12:
continue
d = n.dot(f.verts[0].co)
basis = d - n.dot(mitte)
if basis <= 1e-9:
continue
for co in quelle:
noetig = (n.dot(co) - n.dot(mitte)) / basis
if noetig > faktor:
faktor = noetig
if faktor > 1.0:
for v in bm.verts:
v.co = mitte + (v.co - mitte) * faktor
bmesh.ops.triangulate(bm, faces=bm.faces[:])
me = bpy.data.meshes.new("UCX_" + mesh_name)
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new("UCX_" + mesh_name, me)
context.scene.collection.objects.link(ob)
return ob
def _prepare_export_copies(context, top, lay_flat=True, want_ucx=True,
ucx_max_verts=32):
"""Baut eine export-fertige Kopie der Hierarchie. Gibt (objekte, aufraeumen).
Auf KOPIEN, damit die Szene des Users unangetastet bleibt - Punkt 1 und 2
veraendern die Geometrie ja dauerhaft.
"""
made = []
renamed = [] # (Original, urspruenglicher Name) zum Zuruecksetzen
# JETZT merken: gleich traegt das Original einen Hilfsnamen, und die
# Kollisionshuelle haette sonst "UCX_<Name>.__src" geheissen.
export_name = _safe_filename(top.name)
def _copy(src):
# FALLE: Blender vergibt Namen eindeutig - eine Kopie von "Rock_A_S_01"
# heisst "Rock_A_S_01.001". Unreal ordnet die Kollision aber ueber den
# EXAKTEN Mesh-Namen zu (UCX_<Name>), und der Asset-Name haengt auch
# daran. Deshalb bekommt das Original fuer die Dauer des Exports einen
# Hilfsnamen und die Kopie den echten.
name = src.name
src.name = name + ".__src"
renamed.append((src, name))
cp = src.copy()
if src.data is not None:
cp.data = src.data.copy()
cp.data.name = name
cp.name = name
context.scene.collection.objects.link(cp)
made.append(cp)
return cp
src_children = [c for c in top.children_recursive if c.type == 'MESH']
top_copy = _copy(top)
child_copies = []
for c in src_children:
cc = _copy(c)
cc.parent = top_copy
cc.matrix_parent_inverse = c.matrix_parent_inverse.copy()
child_copies.append(cc)
# FALLE: matrix_world wird faul berechnet. Direkt nach dem Setzen von parent
# und matrix_parent_inverse liefert es noch alte Werte - bei LOD-Gruppen wurde
# dadurch die Gruppenposition (13/-7.5/2.2) NICHT herausgerechnet und landete
# im Asset. Ein view_layer-Update erzwingt die Neuberechnung.
context.view_layer.update()
meshes = [o for o in ([top_copy] + child_copies) if o.type == 'MESH']
# Weltmatrizen EINSAMMELN, bevor irgendetwas genullt wird - jedes Nullen
# veraendert die Matrizen der Kinder.
welt = {o: o.matrix_world.copy() for o in meshes}
# 2) Hinlegen: EINE Drehung fuer alle LOD-Stufen desselben Steins, sonst
# liegen die Stufen unterschiedlich herum.
extra = None
if lay_flat and meshes:
ref = meshes[0]
co = [welt[ref] @ v.co for v in ref.data.vertices]
if co:
dims = [max(c[i] for c in co) - min(c[i] for c in co) for i in range(3)]
extra = _lay_flat_matrix(dims)
# 1) Transform backen -> alle Objekte stehen auf Identitaet, das Mesh traegt alles.
for o in meshes:
_bake_transform(o, welt[o], extra)
if top_copy.type != 'MESH':
top_copy.matrix_basis = Matrix.Identity(4)
top_copy.matrix_parent_inverse = Matrix.Identity(4)
context.view_layer.update()
# 1) Ursprung auf unten-mittig der gemeinsamen Bbox.
lo, hi = _bbox_of(meshes)
if lo is not None:
shift = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z))
off = Matrix.Translation(shift)
for o in meshes:
o.data.transform(off)
# 3) UCX. Bei einer LOD-Gruppe darf die Huelle NICHT unter das LodGroup-Empty:
# Unreal wuerde jedes Kind als LOD-Stufe lesen. Also als Geschwister.
if want_ucx and meshes:
ucx = _make_ucx(context, [meshes[0]], export_name,
max_verts=ucx_max_verts)
if ucx is not None:
made.append(ucx)
def cleanup():
for o in reversed(made):
data = o.data
bpy.data.objects.remove(o, do_unlink=True)
if data is not None and data.users == 0:
if isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
# Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie.
for ob, name in renamed:
ob.name = name
return made, cleanup
def _collect_export_targets(context, settings):
"""Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent."""
tops = []
@@ -1188,12 +1458,30 @@ class OBJECT_OT_export_rocks_fbx(Operator):
_ensure_object_mode(context)
exported, failed = 0, 0
for top in targets:
objs = list(top.children_recursive) + [top]
# Dateiname VOR dem Kopieren merken: waehrend des Exports traegt das
# Original einen Hilfsnamen (siehe _prepare_export_copies).
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
# Punkt 1-3: Ursprung erzwingen, flache Steine hinlegen, UCX bauen.
# Laeuft auf Kopien, die Szene des Users bleibt unveraendert.
try:
objs, cleanup = _prepare_export_copies(
context, top,
lay_flat=settings.export_lay_flat,
want_ucx=settings.export_ucx,
ucx_max_verts=settings.ucx_max_verts,
)
except Exception as exc: # ein Stein darf den Batch nicht killen
failed += 1
print("[RockGen] Export-Vorbereitung '%s' fehlgeschlagen: %s"
% (top.name, exc))
traceback.print_exc()
continue
_deselect_all(context)
for o in objs:
o.select_set(True)
context.view_layer.objects.active = top
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
context.view_layer.objects.active = objs[0]
try:
bpy.ops.export_scene.fbx(
filepath=filepath,
@@ -1212,7 +1500,12 @@ class OBJECT_OT_export_rocks_fbx(Operator):
exported += 1
except RuntimeError as exc:
failed += 1
print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" % (top.name, exc))
print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s"
% (os.path.basename(filepath), exc))
finally:
# MUSS auch bei Fehlern laufen: sonst bleiben die Kopien in der
# Szene stehen und die Originale behalten ihren Hilfsnamen.
cleanup()
if failed:
self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, failed))
@@ -1938,6 +2231,14 @@ class VIEW3D_PT_rock_export(_RockSub):
s = context.scene.rock_gen_settings
layout.prop(s, "export_dir")
layout.prop(s, "export_selected_only")
layout.separator()
layout.prop(s, "export_lay_flat")
layout.prop(s, "export_ucx")
row = layout.row()
row.active = s.export_ucx
row.prop(s, "ucx_max_verts")
layout.label(text="Ursprung unten-mittig: immer", icon='CHECKMARK')
layout.separator()
layout.operator("object.export_rocks_fbx", icon='EXPORT')