Tree Gen 1.26.0: spielfertiger Stand-Baum + Kugel-Normalen (Punkt 1 + 2)

Punkt 1 - "Spielfertig exportieren"
  Stamm und _Leaf-Ebene werden zu EINEM Mesh mit zwei Material-Slots
  verschmolzen (Slot 0 = M_Bark, Slot 1 = M_Foliage). Die getrennten Objekte
  im Blend bleiben die Arbeitsebene - verschmolzen wird nur eine Export-Kopie.
  Ursprung Bbox-Unterseite Mitte und FBX-Einstellungen wie in der
  Fels-Pipeline (use_selection, FBX_SCALE_ALL).

Punkt 2 - Kugel-Normalen auf die Blatt-Cards
  Alle Blatt-Normalen zeigen radial vom Kronen-Schwerpunkt nach aussen, damit
  die Krone als EIN weiches Volumen liest statt als Haufen flacher Bleche.
  Der Schwerpunkt wird nur aus den BLATT-Faces gebildet - der Stamm wuerde die
  Mitte nach unten ziehen und die Kugel verkippen.

  Bewusst direkt ueber normals_split_custom_set statt ueber den
  Normal-Edit-Modifier: der Modifier muesste vor dem Export angewendet werden,
  und angewendete Modifier plus Custom Normals ist genau die Kombination, bei
  der der FBX-Export gerne flachbuegelt. Am REIMPORTIERTEN FBX gemessen:
  mittlere Radialitaet 1.000, 100 % der 510 Blatt-Loops ueber 0.8. Gegenprobe
  Rinde 0.043 - die bleibt unangetastet.

Drei Fallen unterwegs:
- Ueber bmesh.from_mesh zusammenfuegen haengt die Geometrie zwar an, wirft
  aber die MATERIAL-INDIZES weg: danach lagen alle 748 Faces auf Index 0,
  Blaetter und Rinde ununterscheidbar. Blenders join fuehrt die Slot-Listen
  zusammen und rechnet die Indizes korrekt um.
- join loescht das zweite Objekt; jede gehaltene Referenz darauf ist danach
  tot. Die Aufraeumliste fuehrt deshalb NAMEN statt Objekte.
- Die Export-Kopie muss exakt so heissen wie der Baum (Unreal leitet den
  Asset-Namen davon ab). Solange das Original den Namen haelt, haengt Blender
  ".001" an - Original wird fuer die Exportdauer beiseite benannt, wie bei den
  Felsen.

Neu: tests/test_baum_export.py misst die Normalen im EXPORTIERTEN FBX, nicht
am Szenenobjekt - nur so faellt ein plattgebuegelter Custom-Normal auf.
19 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 06:42:59 +02:00
co-authored by Claude Opus 4.8
parent 74df6dfa1b
commit 46b4e9ee34
7 changed files with 491 additions and 9 deletions
+287 -1
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Tree Generator",
"author": "D4rkst3r",
"version": (1, 25, 0),
"version": (1, 26, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Tree Gen",
"description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen",
@@ -18,6 +18,9 @@ bl_info = {
# im Viewport ziehen, der Baum aktualisiert sich sofort.
import bpy
import bmesh
import os
from mathutils import Matrix, Vector
import sys
import os
@@ -1311,6 +1314,130 @@ def make_leaves(context, tree_obj, card_obj, name=None, **overrides):
# ---------------------------------------------------------------------------
# Spielfertiger Export: verschmolzener Stand-Baum
# ---------------------------------------------------------------------------
# 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.
TREE_MATERIALS = ("M_Bark", "M_Foliage")
def _get_or_create_mat(name, color):
mat = bpy.data.materials.get(name)
if mat is None:
mat = bpy.data.materials.new(name)
mat.diffuse_color = color
return mat
def _sphere_normals(ob, slot_index, center=None):
"""Setzt die Normalen der Faces in `slot_index` RADIAL von einem Mittelpunkt.
Der wichtigste Stylized-Trick fuer Blatt-Cards: statt der flachen
Card-Normalen (jede Card wirkt wie ein eigenes flaches Blech, die Krone
flimmert im Licht) zeigen alle Blatt-Normalen nach aussen wie bei einer
Kugel. Die Krone liest sich dadurch als EIN weiches Volumen.
Bewusst direkt ueber `normals_split_custom_set` statt ueber den
Normal-Edit-Modifier: der Modifier muesste vor dem Export angewendet werden,
und angewendete Modifier plus Custom Normals ist genau die Kombination, bei
der der FBX-Export gerne flachbuegelt. Direkt gesetzte Loop-Normalen gehen
unveraendert in die Datei.
"""
me = ob.data
if not me.polygons:
return None
if center is None:
# Schwerpunkt NUR der Blatt-Faces - der Stamm wuerde die Mitte nach
# unten ziehen und die Kugel verkippen.
pts = [me.vertices[vi].co for p in me.polygons
if p.material_index == slot_index for vi in p.vertices]
if not pts:
return None
center = Vector((sum(p.x for p in pts) / len(pts),
sum(p.y for p in pts) / len(pts),
sum(p.z for p in pts) / len(pts)))
# Vorhandene Loop-Normalen als Ausgangslage, dann nur die Blatt-Loops ersetzen.
me.calc_normals_split() if hasattr(me, "calc_normals_split") else None
normalen = [Vector(l.normal) for l in me.loops]
for poly in me.polygons:
if poly.material_index != slot_index:
continue
for li in poly.loop_indices:
v = me.vertices[me.loops[li].vertex_index].co
richtung = v - center
if richtung.length_squared < 1e-12:
richtung = Vector((0.0, 0.0, 1.0))
normalen[li] = richtung.normalized()
me.normals_split_custom_set([tuple(n) for n in normalen])
return center
def _merge_for_export(context, tree_ob, leaf_ob, name):
"""Baut EINE Export-Kopie aus Stamm + Blattebene mit zwei Material-Slots.
Die getrennten Objekte im Blend bleiben unangetastet - das ist die
Arbeits-Ebene. Verschmolzen wird nur diese Kopie.
"""
# Die Aufraeumliste fuehrt NAMEN, keine Objekte: `join` loescht das zweite
# Objekt, und danach wirft schon der Zugriff auf `.name` einer gehaltenen
# Referenz ("StructRNA of type Object has been removed").
made = []
def _kopie(src):
dg = context.evaluated_depsgraph_get()
me = bpy.data.meshes.new_from_object(
src.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg)
ob = bpy.data.objects.new(src.name + "__exp", me)
ob.matrix_world = src.matrix_world.copy()
context.scene.collection.objects.link(ob)
made.append(ob.name)
return ob
stamm = _kopie(tree_ob)
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))
laub = _get_or_create_mat(TREE_MATERIALS[1], (0.18, 0.34, 0.14, 1.0))
stamm.data.materials.clear()
stamm.data.materials.append(rinde)
for p in stamm.data.polygons:
p.material_index = 0
if blatt is not None and blatt.data.polygons:
# FALLE: Ueber `bmesh.from_mesh` zusammenzufuegen haengt die Geometrie
# zwar an, wirft dabei aber die MATERIAL-INDIZES weg - gemessen lagen
# danach alle 748 Faces auf Index 0, Blaetter und Rinde ununterscheidbar.
# Blenders `join` macht genau das richtig: es fuehrt die Slot-Listen
# zusammen und rechnet die Indizes um. Das Blatt-Mesh traegt nur seinen
# eigenen Slot und landet nach dem Join automatisch auf Index 1.
blatt.data.materials.clear()
blatt.data.materials.append(laub)
for p in blatt.data.polygons:
p.material_index = 0
# join LOESCHT das zweite Objekt. Danach ist jede Referenz darauf tot
# ("StructRNA of type Object has been removed") - also vorher merken.
blatt_name = blatt.name
with context.temp_override(active_object=stamm,
selected_editable_objects=[stamm, blatt]):
bpy.ops.object.join()
if blatt_name not in bpy.data.objects:
made = [n for n in made if n != blatt_name]
# Der Stamm wird umbenannt - die Aufraeumliste muss mitziehen.
made = [name if n == stamm.name else n for n in made]
stamm.name = name
stamm.data.name = name
return stamm, made
# ---------------------------------------------------------------------------
# Addon-UI
# ---------------------------------------------------------------------------
@@ -1382,6 +1509,17 @@ class TreeGenSettings(PropertyGroup):
name="_Frucht-Ebene anlegen", default=False,
description="Zusaetzlich <Name>_Frucht (gleicher Ursprung) fuer die Frucht-Ebene",
)
export_dir: StringProperty(
name="Export-Ordner", default="", subtype='DIR_PATH',
description="Zielordner fuer die spielfertigen FBX",
)
sphere_normals: BoolProperty(
name="Kugel-Normalen auf Blaetter", default=True,
description=("Setzt die Blatt-Normalen radial vom Kronen-Schwerpunkt nach "
"aussen. Ohne das wirkt jede Card wie ein eigenes flaches "
"Blech und die Krone flimmert im Licht"),
)
apply_modifier: BoolProperty(
name="Modifier anwenden", default=False,
description="Ergebnis als normales Mesh einfrieren (fuer den Export)",
@@ -1447,6 +1585,147 @@ def _add_layer(context, ob, suffix):
return new
def _safe_name(name):
keep = "-_.() "
return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Tree"
def _deselect(context):
for o in list(context.view_layer.objects):
try:
o.select_set(False)
except (ReferenceError, RuntimeError):
pass
def _origin_bottom_center(ob):
"""Ursprung auf Bbox-Unterseite Mitte - identisch zur Fels-Pipeline.
Die Transformation wandert dabei in die Mesh-Daten und das Objekt steht
danach auf Identitaet: der FBX-Export schreibt die Objekt-Transformation
sonst MIT, und das Asset traegt seine Szenenposition mit ins Spiel.
"""
me = ob.data
me.transform(ob.matrix_world)
ob.matrix_basis = Matrix.Identity(4)
ob.matrix_parent_inverse = Matrix.Identity(4)
if not me.vertices:
return
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]
versatz = Matrix.Translation((
-(min(xs) + max(xs)) * 0.5,
-(min(ys) + max(ys)) * 0.5,
-min(zs),
))
me.transform(versatz)
class TREEGEN_OT_export_game(Operator):
"""Exportiert den ausgewaehlten Baum spielfertig: EIN Mesh, zwei Slots.
Stamm und _Leaf-Ebene bleiben im Blend getrennt - das ist die Arbeitsebene.
Verschmolzen wird nur die Export-Kopie.
"""
bl_idname = "treegen.export_game"
bl_label = "Spielfertig exportieren"
bl_description = ("Verschmilzt Stamm + Blattebene zu einem Mesh mit zwei "
"Material-Slots, setzt Kugel-Normalen auf die Blaetter "
"und schreibt eine FBX")
bl_options = {'REGISTER'}
def execute(self, context):
s = context.scene.tree_gen_settings
verzeichnis = bpy.path.abspath(s.export_dir) if s.export_dir else ""
if not verzeichnis or not os.path.isdir(verzeichnis):
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
return {'CANCELLED'}
# Baeume = ausgewaehlte Meshes, die KEINE Zusatzebene sind.
baeume = [o for o in context.selected_objects
if o.type == 'MESH'
and not o.name.endswith(("_Leaf", "_Frucht"))]
if not baeume:
self.report({'ERROR'}, "Keinen Baum ausgewaehlt (Stamm-Objekt waehlen).")
return {'CANCELLED'}
geschrieben, fehler = 0, 0
for baum in baeume:
blatt = bpy.data.objects.get(baum.name + "_Leaf")
ziel = os.path.join(verzeichnis, _safe_name(baum.name) + ".fbx")
aufraeumen = []
umbenannt = []
try:
# Die Export-Kopie muss EXAKT so heissen wie der Baum - Unreal
# leitet den Asset-Namen davon ab. Solange das Original den
# Namen haelt, haengt Blender ".001" an die Kopie. Also das
# Original fuer die Dauer des Exports beiseite benennen.
for quelle in (baum, blatt):
if quelle is not None:
umbenannt.append((quelle, quelle.name))
quelle.name = quelle.name + ".__src"
merge, aufraeumen = _merge_for_export(
context, baum, blatt, umbenannt[0][1])
# 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)
# Ursprung: Bbox-Unterseite Mitte, wie in der Fels-Pipeline.
_origin_bottom_center(merge)
_deselect(context)
merge.select_set(True)
context.view_layer.objects.active = merge
bpy.ops.export_scene.fbx(
filepath=ziel,
use_selection=True,
object_types={'MESH'},
use_mesh_modifiers=False, # ist bereits ausgewertet
mesh_smooth_type='OFF', # OFF = Custom Normals bleiben
use_custom_props=True,
colors_type='SRGB',
add_leaf_bones=False,
bake_anim=False,
path_mode='AUTO',
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))
import traceback
traceback.print_exc()
finally:
for n in aufraeumen:
o = bpy.data.objects.get(n)
if o is not None:
me = o.data
bpy.data.objects.remove(o, do_unlink=True)
if me is not None and me.users == 0:
bpy.data.meshes.remove(me)
# Erst die Kopien weg, DANN die Namen zurueck - sonst kollidieren sie.
for quelle, alt_name in umbenannt:
quelle.name = alt_name
if fehler:
self.report({'WARNING'}, "%d FBX geschrieben, %d fehlgeschlagen "
"(Konsole)." % (geschrieben, fehler))
else:
self.report({'INFO'}, "%d spielfertige FBX nach %s."
% (geschrieben, verzeichnis))
return {'FINISHED'}
class TREEGEN_OT_randomize(Operator):
bl_idname = "object.treegen_randomize"
bl_label = "Zufaellig"
@@ -1725,11 +2004,18 @@ class VIEW3D_PT_tree_export(_TreeSub):
row = layout.row()
row.active = s.apply_modifier
row.prop(s, "generate_lightmap_uv")
layout.separator()
layout.prop(s, "sphere_normals")
layout.prop(s, "export_dir")
layout.operator("treegen.export_game", icon='EXPORT')
layout.label(text="Slot 0 = %s, Slot 1 = %s"
% (TREE_MATERIALS[0], TREE_MATERIALS[1]), icon='MATERIAL')
classes = (
TreeGenSettings,
TREEGEN_OT_randomize,
TREEGEN_OT_export_game,
TREEGEN_OT_leaves,
TREEGEN_OT_create,
# Eltern-Panel zuerst, sonst findet Blender die bl_parent_id nicht.