Tree Gen 1.42.0: UCX-Stammhuelle im Spielfertig-Export

Die Stand-Baeume bekommen eine konvexe Kollisionshuelle um den STAMM, vom Fuss
bis zum Kronenansatz - keine Kronen-Huelle: gegen die liefe der Spieler, bevor
er den Baum beruehrt.

Die Trennung kommt aus ast_id (0 = Stamm), dasselbe Attribut wie beim Ursprung
und bei den Faell-Teilen. Kronenansatz = tiefster Punkt mit ast_id > 0 plus ein
Zehntel Stammhoehe Luft (der tiefste Astpunkt kann eine herabgebogene Spitze
sein). Ohne Aeste ist die ganze Pflanze Stamm - beim Kaktus genau richtig.

Name und Hierarchie sind NICHT geraten, sondern am Fels-Export nachgemessen,
der in der Pipeline schon abgenommen ist: UCX_<LODGroup-Name>, als GESCHWISTER
des LodGroup-Empty. Ein Kind der Gruppe wuerde Unreal als LOD-Stufe lesen.

Zwei Befunde aus der Messung:
- Die Huelle rutschte 4-6 cm UNTER den Boden. Das Vereinfachen auf 12 Verts
  schneidet Ecken ab, das anschliessende Aufblasen skaliert um die Huellenmitte
  und schiebt dabei die Unterseite nach unten. Im Spiel waere das eine
  Kollision, die aus dem Terrain ragt. Boden wird jetzt auf Z = 0 geklemmt.
- Der erste Anlauf des Tests holte die FBX-Skala aus der Gesamthoehe - die
  Blatt-Cards ragen ueber die Rinde hinaus, die Skala kam zu gross heraus und
  die Huelle sah zu klein aus. Massstab kommt jetzt aus Material-Slot 0.

Neu: tests/test_stamm_ucx.py, vier Faelle (gerader Stamm, krummer Stamm mit
Bend 1.5, Obstbaum, einmal ohne LODs), gemessen im reimportierten FBX.
Gegenprobe ohne Boden-Klemmung: vier Fehler.

test_baum_export, test_baum_lod und test_ursprung zaehlten die Huelle als
Render-Mesh mit und schlugen zu Recht an - sie filtern jetzt UCX_.

Schalter im Export-Panel: "UCX-Stammhuelle" (an) + max. Verts (12).

27 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 06:18:29 +02:00
co-authored by Claude Opus 4.8
parent 263b8507d8
commit ae7806e082
11 changed files with 369 additions and 17 deletions
+104 -6
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Tree Generator",
"author": "D4rkst3r",
"version": (1, 41, 0),
"version": (1, 42, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
@@ -2177,17 +2177,25 @@ def _merge_for_export(context, tree_ob, leaf_ob, name, bark_decimate=1.0):
# Faell-Teile: Stammsegmente und Aeste als eigene Physik-Assets
# ---------------------------------------------------------------------------
def _tree_ucx(quell_objs, name, max_verts=32):
def _tree_ucx(quell_objs, name, max_verts=32, punkte=None):
"""Konvexe Huelle als `UCX_<Name>` - gleiches Muster wie bei den Felsen.
Nur die PUNKTE einlesen: mit `from_mesh` kaemen die Originalflaechen mit und
die Huelle laege obendrauf statt darum herum.
`punkte` uebergeht `quell_objs` und huellt genau die gegebenen Koordinaten -
dafuer, dass die Stand-Baum-Huelle nur den STAMM umschliesst und nicht die
Krone.
"""
bm = bmesh.new()
for o in quell_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(o.matrix_world @ v.co)
if punkte is not None:
for co in punkte:
bm.verts.new(co)
else:
for o in quell_objs:
if o.type == 'MESH':
for v in o.data.vertices:
bm.verts.new(o.matrix_world @ v.co)
if len(bm.verts) < 4:
bm.free()
return None
@@ -2249,6 +2257,49 @@ def _tree_ucx(quell_objs, name, max_verts=32):
return ob
def _stamm_punkte(tree_obj, matrix):
"""Stamm-Koordinaten vom Fuss bis zum KRONENANSATZ, in Weltkoordinaten.
Fuer die Kollision des Stand-Baums. Bewusst nur der Stamm: eine Huelle um
die ganze Krone waere ein Zylinder von mehreren Metern Durchmesser, gegen
den der Spieler laeuft, bevor er den Baum ueberhaupt beruehrt.
Die Trennung kommt aus `ast_id` (0 = Stamm) - dasselbe Attribut wie beim
Ursprung und bei den Faell-Teilen. Der Kronenansatz ist der tiefste Punkt
mit ast_id > 0; oberhalb davon steckt der Stamm ohnehin in der Krone.
Ohne Aeste (Kaktus-Setzling: `Branch Count 0` schaltet den Ast-Zweig des
Graphen ab, dann gibt es gar kein ast_id) ist die ganze Pflanze Stamm - und
genau die soll dann auch die Kollision sein.
"""
dg = bpy.context.evaluated_depsgraph_get()
ev = tree_obj.evaluated_get(dg)
me = ev.to_mesh()
attr = me.attributes.get("ast_id")
hat_id = (attr is not None and attr.domain == 'POINT'
and len(attr.data) == len(me.vertices))
stamm, aeste = [], []
for i, v in enumerate(me.vertices):
co = matrix @ v.co
if hat_id and attr.data[i].value > 0:
aeste.append(co)
else:
stamm.append(co)
ev.to_mesh_clear()
if not stamm:
return []
if aeste:
# Etwas Luft ueber den Ansatz: der tiefste Astpunkt kann eine
# herabgebogene Spitze sein, dann waere die Huelle zu kurz. Ein Zehntel
# der Stammhoehe reicht und bleibt weit unter der Krone.
zs = [c.z for c in stamm]
grenze = min(aeste, key=lambda c: c.z).z + (max(zs) - min(zs)) * 0.1
gekuerzt = [c for c in stamm if c.z <= grenze]
if len(gekuerzt) >= 8:
return gekuerzt
return stamm
def _mass_center(me):
"""Massenmittelpunkt als flaechengewichteter Schwerpunkt der Faces.
@@ -2485,6 +2536,17 @@ class TreeGenSettings(PropertyGroup):
name="_Frucht-Ebene anlegen", default=False,
description="Zusaetzlich <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene",
)
stand_ucx: BoolProperty(
name="UCX-Stammhuelle", default=True,
description=("Legt dem spielfertigen Baum eine konvexe Huelle um den "
"STAMM (Fuss bis Kronenansatz) ins FBX. Bewusst keine "
"Kronen-Huelle: gegen die liefe der Spieler, bevor er den "
"Baum beruehrt"),
)
stand_ucx_verts: IntProperty(
name="Stammhuelle max. Verts", default=12, min=8, max=32,
description="Ein Stamm braucht keine feine Huelle - 8-12 reichen",
)
piece_trunk_segments: IntProperty(
name="Stamm-Segmente", default=3, min=2, max=3,
description="In wie viele Stuecke der Stamm beim Faellen zerfaellt",
@@ -2790,6 +2852,33 @@ class TREEGEN_OT_export_game(Operator):
weitere.matrix_parent_inverse = Matrix.Identity(4)
weitere.data.transform(versatz)
# UCX-Stammhuelle. Die Punkte kommen aus dem QUELLBAUM, weil nur
# der noch `ast_id` traegt - die Export-Kopie ist verschmolzen
# und bei LOD1-3 zusaetzlich dezimiert. Sie durchlaufen dieselbe
# Kette wie die Stufen: eigene Weltmatrix, dann die gemeinsame
# Zentrierung `versatz`.
stamm_ucx_name = (basis_name + "_LODGroup") if s.generate_lods \
else basis_name
hull = None
if s.stand_ucx:
pts = _stamm_punkte(baum, baum.matrix_world)
pts = [versatz @ c for c in pts]
hull = _tree_ucx(None, stamm_ucx_name,
max_verts=s.stand_ucx_verts, punkte=pts)
if hull is not None:
# Der Boden der Huelle MUSS auf Z = 0 liegen. Das
# Vereinfachen schneidet Ecken ab, und das anschliessende
# Aufblasen (damit wieder alles hineinpasst) skaliert um
# die Huellenmitte - dabei rutschte die Unterseite
# gemessen 4-6 cm unter den Boden. Im Spiel waere das
# eine Kollision, die aus dem Terrain herausragt.
for v in hull.data.vertices:
if v.co.z < 0.0:
v.co.z = 0.0
aufraeumen.append(hull.name)
print("[TreeGen] %s: Stamm-UCX mit %d Verts"
% (hull.name, len(hull.data.vertices)))
_deselect(context)
if s.generate_lods:
gruppe = bpy.data.objects.new(basis_name + "_LODGroup", None)
@@ -2807,6 +2896,11 @@ class TREEGEN_OT_export_game(Operator):
else:
lods[0].select_set(True)
context.view_layer.objects.active = lods[0]
# Die Huelle wird MITEXPORTIERT, aber NICHT unter das
# LodGroup-Empty gehaengt: Unreal liest jedes Kind der Gruppe
# als LOD-Stufe. Geschwister, so wie es der Fels-Export macht.
if hull is not None:
hull.select_set(True)
bpy.ops.export_scene.fbx(
filepath=ziel,
@@ -3339,6 +3433,10 @@ class VIEW3D_PT_tree_export(_TreeSub):
layout.separator()
layout.prop(s, "sphere_normals")
layout.prop(s, "generate_lods")
layout.prop(s, "stand_ucx")
row = layout.row()
row.active = s.stand_ucx
row.prop(s, "stand_ucx_verts")
layout.prop(s, "export_dir")
layout.operator("treegen.export_game", icon='EXPORT')