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:
+306
-5
@@ -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')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user