Tree Gen 1.29.0: LOD-Kette (Punkt 5) + Aufraeumen/Pruefen (Punkt 6)

Punkt 5 - LOD-Kette fuer den Stand-Baum
  Haken "LOD-Kette erzeugen" liefert <Name>_LODGroup.fbx mit _LOD0..3 unter
  einem fbx_type=LodGroup-Empty, wie die Fels-Pipeline. NUR der Stand-Baum -
  die Faell-Teile leben Sekunden als Physik-Actor, dort reicht LOD0.

  Blatt-Reduktion ueber die DICHTE, nicht ueber Decimate: eine Card besteht aus
  zwei Dreiecken und laesst sich nicht vereinfachen, man muss weniger davon
  setzen. Die verbleibenden werden groesser, damit die Krone nicht ausduennt.

    LOD0  578 Rinde / 170 Blatt / 1306 Tris
    LOD1  477 / 118 /  912
    LOD2  364 /  66 /  575
    LOD3  221 /  28 /  302   (16 % der Cards von LOD0)

  Kugel-Normalen auf JEDER Stufe gemessen, nicht nur LOD0 - LOD1-3 haben eine
  eigene Card-Verteilung. Am reimportierten FBX: 100 % radial auf allen vier.

  Zwei Reihenfolge-Sachen: das Rinden-Decimate laeuft VOR dem Verschmelzen,
  danach wuerde es die Blatt-Cards mit einschmelzen. Und der Ursprung kommt aus
  LOD0 (Unreal platziert danach); alle Stufen werden um denselben Betrag
  verschoben, sonst springt der Baum beim LOD-Wechsel.

Punkt 6 - Aufraeumen und Pruefen
  Blatt-Streuung ueber alle neun Presets nachgemessen. Der Max-Radius-Filter
  arbeitet richtig: 0-22 % der Cards landen am Stamm (meist unter 9 %), und
  alle Presets bleiben unter dem 2500-Tri-Budget (Maximum weide 2246).

  Messfalle, die ich dabei selbst produziert habe: zuerst hatte ich den Anteil
  der VERTICES unter der Radius-Schwelle gemessen - beim Busch 95 %, sah nach
  einem Problem aus. Das misst aber die Vertex-Verteilung, nicht wo Blaetter
  landen; ein Busch besteht fast nur aus duennen Zweigen. Die richtige Frage
  beantwortet erst ast_id.

  Der Kaktus erzeugt regulaer 0 Cards (kein Ast duenner als Max Radius, botanisch
  richtig). Das lief still durch und man stand vor einer leeren _Leaf-Ebene -
  jetzt sagt der Operator es und nennt den Regler beim Namen.

  Panels: das Export-Panel trug Stand-Baum, LODs und Faell-Teile in einem
  Kasten. Die Teile haben jetzt "5 - Faell-Teile" fuer sich.

Neu: tests/test_baum_lod.py. 22 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 07:55:45 +02:00
co-authored by Claude Opus 4.8
parent 95ddcb40b3
commit 914a1af967
9 changed files with 391 additions and 39 deletions
+140 -29
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Tree Generator",
"author": "D4rkst3r",
"version": (1, 28, 0),
"version": (1, 29, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
@@ -1405,6 +1405,21 @@ def make_leaves(context, tree_obj, card_obj, name=None, card_coll=None, **overri
# Spielfertiger Export: verschmolzener Stand-Baum
# ---------------------------------------------------------------------------
# LOD-Stufen fuer den Stand-Baum. Die Blatt-Reduktion laeuft ueber die DICHTE,
# nicht ueber Decimate: eine Card ist ein Billboard aus zwei Dreiecken - die
# laesst sich nicht sinnvoll vereinfachen, man muss weniger davon setzen. Damit
# die Krone dabei nicht ausduennt, werden die verbleibenden Cards groesser.
# LOD3 ist der Impostor-Ersatz: wenige, dafuer sehr grosse Cards.
#
# Dichte Card-Groesse Rinden-Decimate
LOD_STUFEN = (
(1.00, 1.00, 1.00), # LOD0 - wie der Stand-Baum
(0.60, 1.15, 0.70), # LOD1
(0.30, 1.45, 0.45), # LOD2
(0.12, 2.00, 0.25), # LOD3
)
# Slot 0 = Rinde, Slot 1 = Blaetter. Die Reihenfolge ist Teil des Vertrags -
# die UE-Skripte mappen ueber den Slot-Namen, aber der Index entscheidet, welche
# Faces welches Material bekommen.
@@ -1464,7 +1479,7 @@ def _sphere_normals(ob, slot_index, center=None):
return center
def _merge_for_export(context, tree_ob, leaf_ob, name):
def _merge_for_export(context, tree_ob, leaf_ob, name, bark_decimate=1.0):
"""Baut EINE Export-Kopie aus Stamm + Blattebene mit zwei Material-Slots.
Die getrennten Objekte im Blend bleiben unangetastet - das ist die
@@ -1486,6 +1501,22 @@ def _merge_for_export(context, tree_ob, leaf_ob, name):
return ob
stamm = _kopie(tree_ob)
# Rinde VOR dem Verschmelzen vereinfachen. Danach ginge es nicht mehr: ein
# Decimate ueber das fertige Mesh wuerde auch die Blatt-Cards einschmelzen,
# und eine halbe Card ist keine Card mehr.
if bark_decimate < 0.999 and len(stamm.data.polygons) > 8:
dec = stamm.modifiers.new("LODdec", 'DECIMATE')
dec.ratio = max(0.02, bark_decimate)
dg = context.evaluated_depsgraph_get()
neu = bpy.data.meshes.new_from_object(
stamm.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg)
alt = stamm.data
stamm.modifiers.remove(dec)
stamm.data = neu
if alt.users == 0:
bpy.data.meshes.remove(alt)
blatt = _kopie(leaf_ob) if leaf_ob is not None else None
rinde = _get_or_create_mat(TREE_MATERIALS[0], (0.29, 0.21, 0.14, 1.0))
@@ -1837,6 +1868,14 @@ class TreeGenSettings(PropertyGroup):
name="Export-Ordner", default="", subtype='DIR_PATH',
description="Zielordner fuer die spielfertigen FBX",
)
generate_lods: BoolProperty(
name="LOD-Kette erzeugen", default=False,
description=("Exportiert LOD0-LOD3 als LOD-Gruppe (Empty mit "
"fbx_type=LodGroup) wie in der Fels-Pipeline. Die Blaetter "
"werden je Stufe duenner gestreut und dafuer groesser - "
"eine Card aus zwei Dreiecken laesst sich nicht vereinfachen"),
)
sphere_normals: BoolProperty(
name="Kugel-Normalen auf Blaetter", default=True,
description=("Setzt die Blatt-Normalen radial vom Kronen-Schwerpunkt nach "
@@ -1940,7 +1979,7 @@ def _origin_bottom_center(ob):
ob.matrix_basis = Matrix.Identity(4)
ob.matrix_parent_inverse = Matrix.Identity(4)
if not me.vertices:
return
return Matrix.Identity(4)
xs = [v.co.x for v in me.vertices]
ys = [v.co.y for v in me.vertices]
zs = [v.co.z for v in me.vertices]
@@ -1950,6 +1989,7 @@ def _origin_bottom_center(ob):
-min(zs),
))
me.transform(versatz)
return versatz
@@ -1997,25 +2037,78 @@ class TREEGEN_OT_export_game(Operator):
umbenannt.append((quelle, quelle.name))
quelle.name = quelle.name + ".__src"
merge, aufraeumen = _merge_for_export(
context, baum, blatt, umbenannt[0][1])
basis_name = umbenannt[0][1]
stufen = LOD_STUFEN if s.generate_lods else LOD_STUFEN[:1]
gruppe = None
lods = []
# Kugel-Normalen auf die Blatt-Faces (Slot 1).
mitte = None
if s.sphere_normals and len(merge.data.materials) > 1:
mitte = _sphere_normals(merge, 1)
for i, (dichte, groesse, dec) in enumerate(stufen):
lod_name = ("%s_LOD%d" % (basis_name, i)) if s.generate_lods \
else basis_name
# Ursprung: Bbox-Unterseite Mitte, wie in der Fels-Pipeline.
_origin_bottom_center(merge)
# Blatt-Ebene je Stufe NEU streuen statt sie zu dezimieren:
# eine Card besteht aus zwei Dreiecken, die laesst sich nicht
# vereinfachen - man muss weniger davon setzen. Die
# verbleibenden werden groesser, damit die Krone nicht
# ausduennt.
lod_blatt = blatt
if blatt is not None and s.generate_lods and i > 0:
lod_blatt = make_leaves(
context, baum, s.leaf_card,
name="%s__leaflod%d" % (basis_name, i),
card_coll=s.leaf_cards,
Density=s.leaf_density * dichte,
Size=s.leaf_size * groesse,
**{"Size Var": s.leaf_size_var,
"Max Radius": s.leaf_max_radius,
"Tilt": s.leaf_tilt, "Seed": s.seed})
context.view_layer.update()
aufraeumen.append(lod_blatt.name)
merge, gemacht = _merge_for_export(
context, baum, lod_blatt, lod_name, bark_decimate=dec)
aufraeumen.extend(gemacht)
# Kugel-Normalen auf JEDER Stufe - LOD1..3 haben eine eigene
# Card-Verteilung, die Normalen muessen dazu passen.
mitte = None
if s.sphere_normals and len(merge.data.materials) > 1:
mitte = _sphere_normals(merge, 1)
if mitte is not None:
print("[TreeGen] %s: Kugel-Normalen um (%.2f, %.2f, %.2f)"
% (merge.name, mitte.x, mitte.y, mitte.z))
lods.append(merge)
# Ursprung aus LOD0 - Unreal platziert nach LOD0. Alle Stufen um
# denselben Betrag verschieben, damit sie deckungsgleich bleiben.
versatz = _origin_bottom_center(lods[0])
for weitere in lods[1:]:
weitere.data.transform(versatz)
weitere.matrix_basis = Matrix.Identity(4)
weitere.matrix_parent_inverse = Matrix.Identity(4)
_deselect(context)
merge.select_set(True)
context.view_layer.objects.active = merge
if s.generate_lods:
gruppe = bpy.data.objects.new(basis_name + "_LODGroup", None)
gruppe.empty_display_type = 'PLAIN_AXES'
gruppe["fbx_type"] = "LodGroup" # von Unreal ausgewertet
context.scene.collection.objects.link(gruppe)
aufraeumen.append(gruppe.name)
for lod in lods:
lod.parent = gruppe
lod.matrix_parent_inverse = Matrix.Identity(4)
lod.select_set(True)
gruppe.select_set(True)
context.view_layer.objects.active = gruppe
ziel = os.path.join(verzeichnis, gruppe.name + ".fbx")
else:
lods[0].select_set(True)
context.view_layer.objects.active = lods[0]
bpy.ops.export_scene.fbx(
filepath=ziel,
use_selection=True,
object_types={'MESH'},
object_types={'MESH', 'EMPTY'}, # EMPTY = die LodGroup
use_mesh_modifiers=False, # ist bereits ausgewertet
mesh_smooth_type='OFF', # OFF = Custom Normals bleiben
use_custom_props=True,
@@ -2026,9 +2119,6 @@ class TREEGEN_OT_export_game(Operator):
apply_scale_options='FBX_SCALE_ALL',
)
geschrieben += 1
if mitte is not None:
print("[TreeGen] %s: Kugel-Normalen um (%.2f, %.2f, %.2f)"
% (merge.name, mitte.x, mitte.y, mitte.z))
except Exception as exc: # ein Baum darf den Rest nicht killen
fehler += 1
print("[TreeGen] Export '%s' fehlgeschlagen: %s" % (baum.name, exc))
@@ -2285,7 +2375,16 @@ class TREEGEN_OT_leaves(Operator):
msg = ("Blattwerk fuer %d Baum/Baeume: %d Stamm + %d Blatt = %d Tris"
% (len(trees), total_tree, total_leaf, total))
limit = s.leaf_budget * max(len(trees), 1)
if s.leaf_budget and total > limit:
if total_leaf == 0:
# Passiert regulaer beim Kaktus (kein Ast duenner als Max Radius) -
# aber auch, wenn Max Radius fuer den Baum zu klein steht. Beides
# sieht in der Szene gleich aus: eine leere _Leaf-Ebene. Ohne
# Hinweis sucht man den Fehler im Material.
self.report({'WARNING'},
"Keine Blaetter gestreut - kein Ast ist duenner als "
"Max Radius (%.3f m). Beim Kaktus ist das richtig, "
"sonst Max Radius erhoehen." % s.leaf_max_radius)
elif s.leaf_budget and total > limit:
self.report({'WARNING'}, msg + " - UEBER Budget (%d). Dichte senken." % limit)
else:
self.report({'INFO'}, msg)
@@ -2495,6 +2594,26 @@ class VIEW3D_PT_tree_layers(_TreeSub):
layout.prop(s, "make_frucht")
class VIEW3D_PT_tree_pieces(_TreeSub):
"""Eigenes Unter-Panel: das Export-Panel war mit Stand-Baum, LODs und
Faell-Teilen ueberladen - drei Themen in einem Kasten."""
bl_label = "5 - Faell-Teile"
def draw(self, context):
layout = self.layout
s = context.scene.tree_gen_settings
layout.prop(s, "piece_trunk_segments")
layout.prop(s, "piece_ucx")
row = layout.row()
row.active = s.piece_ucx
row.prop(s, "piece_ucx_verts")
layout.prop(s, "piece_export_combined")
layout.prop(s, "piece_keep")
layout.separator()
layout.operator("treegen.export_pieces", icon='MOD_BUILD')
layout.label(text="Pivot je Teil = Massenmittelpunkt", icon='INFO')
class VIEW3D_PT_tree_export(_TreeSub):
bl_label = "4 - Export"
@@ -2507,19 +2626,10 @@ class VIEW3D_PT_tree_export(_TreeSub):
row.prop(s, "generate_lightmap_uv")
layout.separator()
layout.prop(s, "sphere_normals")
layout.prop(s, "generate_lods")
layout.prop(s, "export_dir")
layout.operator("treegen.export_game", icon='EXPORT')
layout.separator()
box = layout.box()
box.label(text="Faell-Teile", icon='MOD_BUILD')
box.prop(s, "piece_trunk_segments")
box.prop(s, "piece_ucx")
row = box.row()
row.active = s.piece_ucx
row.prop(s, "piece_ucx_verts")
box.prop(s, "piece_export_combined")
box.prop(s, "piece_keep")
box.operator("treegen.export_pieces", icon='MOD_BUILD')
layout.label(text="Slot 0 = %s, Slot 1 = %s"
% (TREE_MATERIALS[0], TREE_MATERIALS[1]), icon='MATERIAL')
@@ -2537,6 +2647,7 @@ classes = (
VIEW3D_PT_tree_leaves,
VIEW3D_PT_tree_layers,
VIEW3D_PT_tree_export,
VIEW3D_PT_tree_pieces,
)