"""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])) # Die UCX-Stammhuelle ist KEIN Render-Mesh - sie gehoert nicht in diese Zaehlung. meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH' and not o.name.startswith("UCX_")] 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")