Files
stylized-rock-generator/tests/test_baum_lod.py
T
D4rkst3randClaude Opus 4.8 2c339b5d0c Tree Gen 1.31.0: LOD1-3 trugen die Standposition mit (aus UE gemeldet)
Fehler: _origin_bottom_center backt zuerst die Weltmatrix ins Mesh und wendet
DANN die Zentrierung an - zurueck gibt es aber nur die Zentrierung. Den Stufen
1-3 wurde nur diese aufgedrueckt, ihre Weltmatrix blieb ungebacken. Ergebnis:
sie landeten bei MINUS der Standposition. Gemeldet fuer einen Baum bei x = 18 m,
dessen LOD1-3 bei x = -1894 cm sassen - exakt die Standposition, in Zentimetern
wegen FBX_SCALE_ALL. Jetzt backt jede Stufe erst ihre EIGENE Weltmatrix, dann
kommt die gemeinsame Zentrierung aus LOD0.

Warum der Test das nicht gefunden hat: er baute den Baum am URSPRUNG. Dort ist
minus null eben null. Der Baum steht jetzt bewusst bei (18, -7.5), und je Stufe
wird die X/Y-Mitte im reimportierten FBX gemessen - an derselben Stelle wie die
Radialitaets-Pruefung.

Gegenprobe gemacht: mit zurueckgedrehtem Fix meldet der Test LOD1-3 bei
-17.87/7.46, also exakt minus der Standposition, und faellt mit 6 Fehlern durch.
Mit Fix liegen alle Stufen bei 0.000-0.135 um den Ursprung.

Ausserdem Dichte 170 -> 150. Grund: mit der REALEN Diamond-Card (6 Tris, nicht
die 2-Tri-Attrappe aus meinen Sweeps) landet die Eiche bei Dichte 170 auf 4206
Tris - 2778 Krone plus 1428 Stamm - und damit ueber dem 4000er-Budget aus
ASSETS.md Teil 6. Die abgenommene Krone (2778) und das dokumentierte Budget
(4000) sind also nicht gleichzeitig haltbar. 150 liegt im abgesegneten
Sweetspot 130-170 und haelt das Budget mit 3816. Wenn der Look bei 170 wichtiger
ist, muesste das Budget auf ~4300 - das ist eine Entscheidung der Spielseite.

Nebenbei: test_leaves hatte leaf_size fest auf 0.22 stehen (alter
Blaettchen-Wert) und pruefte gegen ein fest verdrahtetes 2500er-Budget. Beides
kommt jetzt aus den Einstellungen.

23 Tests bestehen.

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

220 lines
8.6 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)
# --- 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")