Files
stylized-rock-generator/tests/test_baum_lod.py
T
D4rkst3randClaude Opus 4.8 d170e4067f Tree Gen 1.32.0: Deckung pro Stufe, mitwachsender Radius-Filter, LOD als Teilset
Drei Befunde aus der Turnhalle abgearbeitet.

1) Deckung PRO STUFE statt globaler Formel
   Die alte Rechnung (Dichte durch faktor^2) hielt den Setzling vor der
   Kahlheit, gab ihm aber die Deckung eines Altbaums. Jetzt Tabelle
   LEAF_STUFEN. S4 traegt die volle Dichte 170, S2 liegt 15 % darunter, S1
   deutlich. Der Dichte-Anteil bei S1/S2 sieht hoch aus - "Dichte" ist Punkte
   pro m2 ZWEIGFLAECHE, und die waechst ueber die Stufen dramatisch. Was zaehlt
   ist die Anzahl: 17 / 43 / 67 / 130 Cards, Kante 0.19 / 0.29 / 0.37 / 0.45.
   Budget S3/S4 auf 4300 (die Spielseite hebt ASSETS.md nach dem 170er-Export
   entsprechend an).

2) Radius-Filter skaliert mit dem Wachstum (der S3-Befund)
   Der Filter trennt duenne Zweige von dickem Geaest - aber mit starren 0.04 m
   greift er bei jungen Baeumen nicht: gemessen sassen bei S2 81 % der Cards am
   Stamm, bei S3 58 %. Jetzt 48 bzw. 52 %.

   Dabei eine eigene Fehldeutung korrigiert: bei S1 bleiben ~88 % am Stamm, und
   das ist KEIN Defekt. Nachgemessen ist die zulaessige Flaeche dort fuer jeden
   Schwellwert identisch (0.013 m2) - der Saemling hat kein dickes Geaest, an
   dem sich etwas trennen liesse. Blaetter am Stiel sind bei einem Saemling der
   Normalfall. Ein erster Sweep hatte mich in die Irre gefuehrt, weil ich
   growth_t=0 auf einen AUSGEWACHSENEN Baum gesetzt hatte - der hat die volle
   Zweigflaeche und verhaelt sich voellig anders als ein echter Setzling.

3) LOD-Reduktion als TEILSET der LOD0-Cards
   Bisher wurde je Stufe neu gestreut - die Cards sassen an anderen Stellen und
   die Silhouette sprang beim LOD-Wechsel um. Jetzt wird ausgeduennt: jede
   verbleibende Card liegt genau dort, wo sie in LOD0 schon war, die
   Ueberlebenden werden groesser. Gearbeitet wird auf zusammenhaengenden
   INSELN, nicht auf Faces - eine Card muss ganz bleiben oder ganz verschwinden.
   Auswahl ist seed-stabil.

   Gemessen am reimportierten FBX: 260 / 156 / 78 / 32 Blatt-Faces, und 100 %
   der Cards auf LOD1-3 sitzen auf einer LOD0-Position. Der Versatz zu LOD0 ist
   dabei kleiner geworden als beim Neustreuen.

23 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 09:44:52 +02:00

282 lines
11 KiB
Python

"""Punkt 5: LOD-Kette fuer den Stand-Baum.
Die Blatt-Reduktion laeuft ueber die DICHTE, nicht ueber Decimate: eine Card
besteht aus zwei Dreiecken und laesst sich nicht vereinfachen - man muss weniger
davon setzen und die verbleibenden groesser machen.
Gemessen wird am reimportierten FBX, und die Kugel-Normalen werden auf JEDER
Stufe geprueft: LOD1-3 haben eine eigene Card-Verteilung, die Normalen muessen
dazu passen. Nur LOD0 zu pruefen wuerde genau das verfehlen.
WICHTIG: Der Baum steht hier NICHT am Ursprung. Eine fruehere Fassung dieses
Tests baute ihn bei (0,0,0) - und ist damit an einem Fehler vorbeigelaufen, den
die Spielseite gefunden hat: LOD1-3 landeten bei MINUS der Standposition, weil
ihre Weltmatrix nie ins Mesh gebacken wurde. Bei x = 0 faellt das nicht auf,
weil minus null eben null ist.
Aufruf: blender --background --factory-startup --python tests/test_baum_lod.py
"""
import os
import sys
import tempfile
import importlib.util
import bpy
from mathutils import Vector
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
spec = importlib.util.spec_from_file_location(
"tg", os.path.join(ROOT, "stylized_tree_generator.py"))
tg = importlib.util.module_from_spec(spec)
sys.modules["tg"] = tg
spec.loader.exec_module(tg)
tg.register()
fails = []
OUT = tempfile.mkdtemp()
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
me = bpy.data.meshes.new("Leaf_Card")
me.from_pydata([(-0.5, 0, 0), (0, 0, 0.7), (0.5, 0, 0), (0, 0, -0.1)],
[], [(0, 1, 2), (0, 2, 3)])
me.update()
card = bpy.data.objects.new("Leaf_Card", me)
bpy.context.scene.collection.objects.link(card)
s = bpy.context.scene.tree_gen_settings
s.preset = 'baum'
s.use_growth = False
s.apply_modifier = True
s.art_name = "Eiche"
s.variant = 1
s.export_dir = OUT
s.sphere_normals = True
s.generate_lods = True
bpy.ops.object.treegen_create()
baum = [o for o in bpy.context.scene.objects
if o.type == 'MESH' and o.name.startswith("Eiche_01")
and not o.name.endswith(("_Leaf", "_Frucht"))][0]
# Der Baum wird BEWUSST weit vom Ursprung gestellt - siehe Kopf der Datei.
STANDORT = (18.0, -7.5, 0.0)
baum.location = STANDORT
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
s.leaf_card = card
bpy.ops.object.treegen_leaves()
# Die Blattebene muss mit an die Standposition - sie folgt dem Stamm.
_bl = bpy.data.objects.get(baum.name + "_Leaf")
if _bl is not None:
_bl.location = STANDORT
for o in bpy.context.scene.objects:
o.select_set(o is baum)
bpy.context.view_layer.objects.active = baum
namen_vorher = sorted(o.name for o in bpy.context.scene.objects)
bpy.ops.treegen.export_game()
if sorted(o.name for o in bpy.context.scene.objects) != namen_vorher:
fehlt = [n for n in namen_vorher
if n not in [o.name for o in bpy.context.scene.objects]]
neu = [o.name for o in bpy.context.scene.objects if o.name not in namen_vorher]
fails.append("Szene veraendert - fehlt: %s, neu: %s" % (fehlt, neu))
dateien = sorted(f for f in os.listdir(OUT) if f.lower().endswith(".fbx"))
lodfile = [f for f in dateien if "_LODGroup" in f]
print("Export: %s" % dateien)
if not lodfile:
print("ERGEBNIS: FEHLER: keine _LODGroup-Datei")
sys.exit(1)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=os.path.join(OUT, lodfile[0]))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
empties = [o for o in bpy.context.scene.objects if o.type == 'EMPTY']
stufen = sorted(meshes, key=lambda o: o.name)
print("")
print("Baum stand bei x=%.1f, y=%.1f - die Stufen muessen trotzdem um den "
"Ursprung liegen." % (STANDORT[0], STANDORT[1]))
print("")
print("%-24s %7s %7s %7s %9s %9s %9s"
% ("Stufe", "Rinde", "Blatt", "Tris", "Radial", "X-Mitte", "Y-Mitte"))
if len(stufen) != len(tg.LOD_STUFEN):
fails.append("%d LOD-Meshes, erwartet %d" % (len(stufen), len(tg.LOD_STUFEN)))
if not empties:
fails.append("Kein LodGroup-Empty im FBX - Unreal erkennt die Kette nicht")
blatt_zahlen = []
rinde_zahlen = []
spannen = []
for ob in stufen:
m = ob.data
rinde = sum(1 for p in m.polygons if p.material_index == 0)
laub = sum(1 for p in m.polygons if p.material_index == 1)
tris = sum(len(p.vertices) - 2 for p in m.polygons)
blatt_zahlen.append(laub)
rinde_zahlen.append(rinde)
# Kugel-Normalen auf DIESER Stufe nachmessen.
quote = 0.0
blatt_polys = [p for p in m.polygons if p.material_index == 1]
if blatt_polys:
pts = [m.vertices[vi].co for p in blatt_polys for vi in p.vertices]
mitte = 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)))
if hasattr(m, "calc_normals_split"):
m.calc_normals_split()
treffer = gesamt = 0
for p in blatt_polys:
for li in p.loop_indices:
n = Vector(m.loops[li].normal)
radial = m.vertices[m.loops[li].vertex_index].co - mitte
if n.length_squared < 1e-9 or radial.length_squared < 1e-9:
continue
gesamt += 1
if n.normalized().dot(radial.normalized()) > 0.8:
treffer += 1
quote = 100.0 * treffer / gesamt if gesamt else 0.0
# X/Y-Spanne dieser Stufe im FBX. Alle Stufen muessen deckungsgleich um
# den Ursprung liegen - eine verschobene Stufe waere in UE beim
# LOD-Wechsel schlagartig woanders.
co = [ob.matrix_world @ v.co for v in m.vertices]
lo = [min(c[i] for c in co) for i in range(3)]
hi = [max(c[i] for c in co) for i in range(3)]
xm = (lo[0] + hi[0]) * 0.5
ym = (lo[1] + hi[1]) * 0.5
spannen.append((ob.name, xm, ym, lo, hi))
print("%-24s %7d %7d %7d %8.0f%% %9.3f %9.3f"
% (ob.name, rinde, laub, tris, quote, xm, ym))
if len(m.materials) != 2:
fails.append("%s: %d Slots, erwartet 2" % (ob.name, len(m.materials)))
if blatt_polys and quote < 90.0:
fails.append("%s: nur %.0f%% der Blatt-Normalen radial - die "
"Kugel-Normalen fehlen auf dieser Stufe" % (ob.name, quote))
if not blatt_polys:
fails.append("%s: keine Blatt-Faces" % ob.name)
# --- Die Ketten-Eigenschaft: es muss nach oben duenner werden -------------
print("")
print("Blatt-Faces je Stufe: %s" % blatt_zahlen)
print("Rinden-Faces je Stufe: %s" % rinde_zahlen)
for i in range(1, len(blatt_zahlen)):
if blatt_zahlen[i] >= blatt_zahlen[i - 1]:
fails.append("LOD%d hat %d Blatt-Faces, LOD%d hatte %d - die Kette wird "
"nicht duenner" % (i, blatt_zahlen[i], i - 1,
blatt_zahlen[i - 1]))
for i in range(1, len(rinde_zahlen)):
if rinde_zahlen[i] > rinde_zahlen[i - 1]:
fails.append("LOD%d hat mehr Rinden-Faces als LOD%d" % (i, i - 1))
# LOD3 muss deutlich guenstiger sein als LOD0, sonst lohnt die Kette nicht.
if blatt_zahlen and blatt_zahlen[0] > 0:
anteil = 100.0 * blatt_zahlen[-1] / blatt_zahlen[0]
print("LOD3 traegt %.0f%% der Cards von LOD0" % anteil)
if anteil > 30.0:
fails.append("LOD3 hat noch %.0f%% der Cards - als Impostor-Ersatz zu "
"teuer" % anteil)
# --- Die Cards muessen ein TEILSET von LOD0 sein -------------------------
# Das ist der Test gegen den Silhouetten-Sprung: wuerde je Stufe neu gestreut,
# saessen die Cards an anderen Stellen und die Krone spraenge beim LOD-Wechsel
# sichtbar um. Als Teilset liegt jede verbleibende Card dort, wo sie in LOD0
# schon war.
def card_mitten(ob):
"""Mittelpunkte der Blatt-Inseln (eine Insel = eine Card)."""
m = ob.data
faces = [p for p in m.polygons if p.material_index == 1]
if not faces:
return []
# Inseln ueber gemeinsame Vertices finden
von_vert = {}
for p in faces:
for vi in p.vertices:
von_vert.setdefault(vi, []).append(p.index)
gesehen = set()
mitten = []
for p in faces:
if p.index in gesehen:
continue
stapel = [p.index]
gesehen.add(p.index)
insel = []
while stapel:
fi = stapel.pop()
f = m.polygons[fi]
insel.append(f)
for vi in f.vertices:
for nb in von_vert.get(vi, ()):
if nb not in gesehen:
gesehen.add(nb)
stapel.append(nb)
mitte = Vector((0.0, 0.0, 0.0))
for f in insel:
mitte += f.center
mitten.append(mitte / len(insel))
return mitten
mitten0 = card_mitten(stufen[0])
print("")
print("LOD0 hat %d Cards" % len(mitten0))
for ob in stufen[1:]:
mitten = card_mitten(ob)
if not mitten0 or not mitten:
continue
treffer = 0
for m1 in mitten:
nah = min((m1 - m0).length for m0 in mitten0)
# Die Card wurde vergroessert, ihre Mitte bleibt aber - ein paar
# Zentimeter Toleranz reichen.
if nah < 0.15:
treffer += 1
quote = 100.0 * treffer / len(mitten)
print("%-24s %3d Cards, %.0f%% davon sitzen auf einer LOD0-Card"
% (ob.name, len(mitten), quote))
if quote < 90.0:
fails.append("%s: nur %.0f%% der Cards sitzen auf LOD0-Positionen - "
"die Stufe wurde neu gestreut statt ausgeduennt, die "
"Silhouette springt beim LOD-Wechsel" % (ob.name, quote))
# --- Alle Stufen deckungsgleich am Ursprung ------------------------------
# Das ist der Test fuer den aus UE gemeldeten Fehler: LOD1-3 sassen bei MINUS
# der Standposition, weil ihre Weltmatrix nie ins Mesh gebacken wurde.
print("")
for name, xm, ym, lo, hi in spannen:
if abs(xm) > 0.25 or abs(ym) > 0.25:
fails.append("%s: Mitte bei %.2f/%.2f statt am Ursprung - die Stufe "
"traegt die Standposition des Baums (%.1f/%.1f) mit"
% (name, xm, ym, STANDORT[0], STANDORT[1]))
if abs(lo[2]) > 0.15:
fails.append("%s: steht %.3f m ueber/unter dem Ursprung - der Baum "
"wuerde beim LOD-Wechsel springen" % (name, lo[2]))
# Und die Stufen muessen ZUEINANDER passen, nicht nur je einzeln zum Ursprung.
if len(spannen) > 1:
_, x0, y0, lo0, hi0 = spannen[0]
for name, xm, ym, lo, hi in spannen[1:]:
if abs(xm - x0) > 0.30 or abs(ym - y0) > 0.30:
fails.append("%s liegt %.2f/%.2f neben LOD0 - beim LOD-Wechsel "
"wuerde der Baum springen" % (name, xm - x0, ym - y0))
print("Versatz zu LOD0: %s"
% ", ".join("%s %.3f/%.3f" % (n.split("_")[-1], x - x0, y - y0)
for n, x, y, _, _ in spannen[1:]))
print("")
if fails:
print("ERGEBNIS: %d FEHLER" % len(fails))
for x in fails:
print(" - " + x)
sys.exit(1)
print("ERGEBNIS: ALLE CHECKS OK")