scopedvr-map-core/viewport/glb_mesh_store.gd
slyphax 83af25b6fb VR-Host: main läuft ohne Fenster, Modelle aus dem Projekt, Terrain in Stücken (nur Quest)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:38:42 +01:00

467 lines
19 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends RefCounted
class_name GlbMeshStore
## Echte GLB-Renderings für Bibliotheks-Objekte im Editor-Viewport
## (Karte t_9b50b231). Der Viewport-Bauer zeigt bisher nur den
## Kollisions-Platzhalter (MeshShapes.make_placeholder) — diese statische
## Loader-Schicht holt stattdessen das ECHTE Modell aus dem Mesh-Lager
## (Sibling-Repos, gleiche Quellen wie tools/gen_thumbs.gd) und fällt ohne
## GLB auf den Platzhalter zurück.
##
## Messkonvention wie addons/modular_builder/scopedvr_export.gd
## `normalisiere`: das geladene Mesh wird in Fußpunkt-Koordinaten gebacken —
## Unterkante auf y = 0, XZ um den Mittelpunkt zentriert. Pack-GLBs haben
## ihren Mesh-Ursprung oft in der Mitte, deshalb wird die AABB-Unterkante
## GERECHNET und nie blind platziert. Damit gilt wie beim Compound-Platzhalter:
## MeshShapes.half_height(mesh) == 0 und marker.position == Fußpunkt.
##
## Plattform-Regel (PLAN.md): nur relative Kandidaten-Pfade, konfigurierbar
## über ProjectSettings — läuft identisch auf Linux und Windows.
##
## Performance: EIN Parse pro OID, Ergebnis gecacht — der Rebuild bei jedem
## Platzieren/Undo liest nur noch aus dem Cache.
## ProjectSettings-Schlüssel für die GLB-Quellen (relativ zum Editor-Projekt).
const QUELLEN_SETTING := "scopedvr/editor/glb_quellen"
## Voreinstellung = dieselben Quellen wie tools/gen_thumbs.gd (Sibling-Repos).
const DEFAULT_QUELLEN := ["../scopedvr-match-server/assets/objekte", "../scopedvr-client/assets/objekte"]
## Mitgelieferter Modell-Ordner (Karte t_12cf94fe): direkt neben der App
## (im Editor = Projektordner, in der exportierten App = neben der EXE).
## Der sync über tools/sync_models.sh legt hier die GLBs + Pack-Texturen
## ab — dann läuft der Map Builder auf einem fremden PC OHNE Sibling-Repos.
## Der Ordner trägt eine .gdignore (kein Import/PCK-Aufnahme; FileAccess
## mit absolutem Pfad funktioniert trotzdem).
const MODELS_DIR := "models"
## Klarer Hinweistext (Karte t_12cf94fe, Auftrag 3), wenn Modelle fehlen:
## wird als Klartext-Meldung gezeigt — kein stiller Platzhalter.
## Notfall-Modus: Terrain/Bauteile brauchen keine GLBs und funktionieren immer.
const FEHLT_TEXT := "Für die Anzeige fehlen die Modelle. So bekommst du sie:\n" \
+ " 1. tools/sync_models.sh aus dem Map-Editor-Ordner laufen lassen\n" \
+ " (kopiert die Modelle in den Ordner %s),\n" \
+ " 2. oder in den Einstellungen einen Ordner mit den Objekt-GLBs setzen.\n" \
+ "Terrain und Bauteile funktionieren trotzdem vollständig (Notfall-Modus)."
## Registry (A6): liefert das Feld "glb" für die Kategorie-verschachtelte Ablage.
const MANIFEST_PATH := "res://library/objects.json"
static var _manifest: Dictionary = {}
static var _manifest_geladen := false
# WYSIWYG-Fix (t_c8748efc, korrigiert in t_279df760): GLTFDocument löst externe
# Textur-URIs (Sibling-Repos, außerhalb res://) NICHT auf — im Editor-Viewport
# blieben die GLB-Objekte untexturiert/Editor-Farbe, während sie im Spiel
# getextured sind. Flächen OHNE albedo_texture bekommen hier die Textur, die das
# ITHappy-Pack für ihren MATERIALNAMEN vorsieht — Zuordnung identisch zu
# tools/PACK_TEXTURE_MAPPING.md (Client-Repo, verbindlich):
# military_base (+_00x) = Palette (Demonstration_Textures1.png, 1024²)
# picture_1/2/3 = echtes Multicam (Demonstration_picture_N.png, 2048²)
# Wichtig: Nichts wird überschrieben — GLTF-aufgelöste Texturen bleiben unangetastet.
# (Der frühere Inhalt-Vergleich + Kategorie-Camo stammte aus aa94bde und hat auch
# korrekte Pack-Texturen wieder durch Camo ersetzt — das war der Fehler.)
const PACK_TEX_PALETTE := "Demonstration_Textures1.png"
const PACK_TEX_PICTURE_1 := "Demonstration_picture_1.png"
const PACK_TEX_PICTURE_2 := "Demonstration_picture_2.png"
const PACK_TEX_PICTURE_3 := "Demonstration_picture_3.png"
# ImageTexture pro Textur-Pfad gecacht — sonst erzeugt jede der 291 GLBs
# ihre eigene 2048²-Textur (~16 MB VRAM je Stück statt 2 × 16 MB gesamt).
# false = Ladeversuch fehlgeschlagen (kein endloser Retry + Warn-Flut).
static var _camo_tex_cache: Dictionary = {}
## Dekodierte Multicam-Bilder (RAM, 2 × 2048² ≈ 32 MB) für den Inhalts-
## Vergleich gegen GLTF-eigene Textur-Instanzen.
static var _camo_bild_cache: Dictionary = {}
## Urteil pro GLTF-Textur-Instanz (instance_id → geteilte Textur | false),
## damit der 16-MB-Datenvergleich nur einmal je Instanz läuft.
static var _camo_ersetzung_cache: Dictionary = {}
# Cache pro OID: Mesh (normalisiert) oder `false` = GLB gefunden, aber nicht
# ladbar (Parse-Fehler — kein endloser Retry pro Rebuild). Fehlende Datei
# bleibt UNGECACHED (nur ein billiger Datei-Check pro Rebuild; legt man die
# GLB später ab, erscheint sie ohne Editor-Neustart).
static var _cache: Dictionary = {}
## Kandidaten-Pfade der Quellen in Ladereihenfolge (Karte t_12cf94fe,
## Auftrag 1 — EINE Stelle, erster Treffer gewinnt):
## 1. in den Einstellungen gesetzter Pfad (QUELLEN_SETTING),
## 2. bisheriges Verhalten = Sibling-Repos (DEFAULT_QUELLEN),
## 3. mitgelieferter Ordner models/ direkt neben der App (MODELS_DIR).
## Doppelnennungen werden gefiltert (Reihenfolge bleibt erhalten); ein
## ungültiger Settings-Wert (kein Array) fällt komplett auf die Vorgabe.
static func quellen() -> Array:
var out: Array = []
var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN)
if setting is Array or setting is PackedStringArray:
for q in setting:
_quelle_add(out, q)
for q in DEFAULT_QUELLEN:
_quelle_add(out, q)
_quelle_add(out, MODELS_DIR)
return out
## Gültigen Quellpfad dedupliziert anhängen (typisiert, keine impliziten casts).
static func _quelle_add(out: Array, q: Variant) -> void:
if not (q is String):
return
var s := (q as String).strip_edges()
if s.is_empty() or out.has(s):
return
out.append(s)
## Absoluter Pfad des mitgelieferten Modell-Ordners (für die Klartext-
## Meldung und tools/sync_models.sh-Verweis). Im Editor = Projektordner,
## in der exportierten App = Ordner der EXE.
static func models_pfad() -> String:
return ProjectSettings.globalize_path("res://").path_join(MODELS_DIR)
## Erster EXISTIERENDER Dateipfad für die OID (ohne Laden/Parse — billiger
## Datei-Check über alle Kandidaten in Quellen-Reihenfolge). Leerer String =
## kein Quellpfad hat die Datei. Für Klartext-Meldungen/Probe-Logs: zeigt,
## AUS WELCHEM Ordner die Modelle kommen.
static func probe_source_fuer(oid: String) -> String:
if oid.is_empty():
return ""
for pfad in _kandidaten(oid):
if FileAccess.file_exists(pfad):
return pfad
return ""
## Ausgefüllter Klartext-Hinweis (FEHLT_TEXT + echter models/-Pfad) —
## diese EINE Funktion liefert die Meldung, die die App zeigt, wenn
## Modelle fehlen (Karte t_12cf94fe, Auftrag 3).
static func fehl_text() -> String:
return FEHLT_TEXT % models_pfad()
## Kandidaten-Pfade (absolut) in Ladereihenfolge: Registry-`glb` (A6, Kategorie-
## Ablage assets/objekte/military_desert/<kategorie>/<oid>.glb) VOR der flachen
## Alt-Ablage <quelle>/<oid>.glb. Die Quelle ist ein Mesh-Lager (<repo>/assets/
## objekte) — für den verschachtelten Pfad wird sie auf die Repo-Wurzel gekürzt.
static func _kandidaten(oid: String) -> Array:
var basis := ProjectSettings.globalize_path("res://")
var out: Array = []
var rel := str(_eintrag(oid).get("glb", ""))
if rel.begins_with("res://"):
var sub := rel.substr(6)
# Operator-Korrektur 20.10. (Eigenständigkeits-Beweis): der mitgelieferte
# models/-Ordner NEBEN der App enthält die Pack-Struktur DIREKT
# (models/military_desert/props/box_010.glb) — nicht unter
# models/assets/objekte/. Ohne den zweiten Kandidaten unten werden ALLE
# verschachtelten Pack-Objekte in der eigenständigen App nicht gefunden
# (belegt: barrel_001/box_010 -> leer, fehlende_glbs=2 trotz 334 GLBs).
var sub_kurz := sub.trim_prefix("assets/objekte/")
for quelle in quellen():
var q := str(quelle)
var marke := "/assets/objekte"
var wurzel := q.substr(0, q.length() - marke.length()) if q.ends_with(marke) else q
out.append(basis.path_join(wurzel).path_join(sub))
if sub_kurz != sub:
out.append(basis.path_join(wurzel).path_join(sub_kurz))
for quelle in quellen():
out.append(basis.path_join(str(quelle)).path_join(oid + ".glb"))
return out
## Registry-Eintrag der OID (einmal gelesen, dann gecacht).
static func _eintrag(oid: String) -> Dictionary:
if not _manifest_geladen:
_manifest_geladen = true
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH))
if parsed is Dictionary:
_manifest = (parsed as Dictionary).get("objects", {})
return _manifest.get(oid, {})
## Echtes Mesh für eine Objekt-ID (normalisiert, Fußpunkt-Koordinaten) oder
## null = keine brauchbare GLB → Aufrufer zeigt den Platzhalter.
static func mesh_fuer(oid: String) -> Mesh:
if oid.is_empty():
return null
# Bauteile (bau_*) entstehen aus dem Katalog — identisch zu den GLBs, die
# der Client lädt (tools/gen_bauteile.gd), aber ohne Modell-Ordner nötig.
if BauKatalog.ist_bau(oid):
return BauKatalog.mesh(oid)
if _cache.has(oid):
return _cache[oid] if _cache[oid] is Mesh else null
# Im Spiel sind die Modelle importierter Teil des Projekts.
var im_projekt := _projekt_pfad(oid)
if not im_projekt.is_empty():
var szene: Node = (load(im_projekt) as PackedScene).instantiate()
var aus_projekt := _zusammenbacken(szene)
szene.free()
_cache[oid] = aus_projekt if aus_projekt != null else false
return aus_projekt
for pfad in _kandidaten(oid):
if FileAccess.file_exists(pfad):
var m := _glb_mesh(pfad)
if m != null:
camo_tex_setzen(m, pfad)
_cache[oid] = m
return m
# Datei da, aber kaputt → NEGATIV cachen (Parse-Fehler wiederholt
# sich sonst bei jedem Rebuild und kostet Zeit).
_cache[oid] = false
return null
return null
static func _projekt_pfad(oid: String) -> String:
var rel := str(_eintrag(oid).get("glb", ""))
if rel.begins_with("res://") and ResourceLoader.exists(rel, "PackedScene"):
return rel
return ""
## Liegt für die OID überhaupt eine GLB-Datei im Lager? (Billiger Check ohne
## Parse — für Statuszeilen-Hinweise beim Platzieren.)
static func fehlt(oid: String) -> bool:
if oid.is_empty():
return true
if BauKatalog.ist_bau(oid):
return false
if _cache.has(oid):
return not (_cache[oid] is Mesh)
if not _projekt_pfad(oid).is_empty():
return false
for pfad in _kandidaten(oid):
if FileAccess.file_exists(pfad):
return false
return true
## true = KEINE Quelle (Einstellungen/Siblings/models/) enthält irgend eine
## GLB-Datei → die App hat nirgends echte Modelle. Für das Klartext-Banner
## beim Start (Karte t_12cf94fe, Auftrag 3). Nicht-cachend, einmal pro Start.
static func lager_ist_leer() -> bool:
for quelle in quellen():
if _quelle_hat_glb(ProjectSettings.globalize_path("res://").path_join(str(quelle))):
return false
return true
## Enthält der Ordner (rekursiv) mindestens eine .glb-Datei?
static func _quelle_hat_glb(basis: String) -> bool:
var d := DirAccess.open(basis)
if d == null:
return false
d.list_dir_begin()
var name := d.get_next()
while name != "":
var pfad := basis.path_join(name)
if d.current_is_dir():
if _quelle_hat_glb(pfad):
return true
elif name.ends_with(".glb"):
return true
name = d.get_next()
return false
## Cache leeren (Tests / nach manuellem Eingriff ins Mesh-Lager).
static func cache_leeren() -> void:
_cache.clear()
_camo_tex_cache.clear()
_camo_bild_cache.clear()
_camo_ersetzung_cache.clear()
_manifest.clear()
_manifest_geladen = false
# ------------------------------------------------------------ Laden --------
## GLB OHNE Import-System laden (GLTFDocument, wie gen_thumbs) und ALLE
## MeshInstance3D der Szene in EIN ArrayMesh zusammenbacken (Jedes Teil-Node
## mit seiner Node-Transform; Materialien je Oberfläche bleiben erhalten —
## echte Texturen statt Editor-Farbe). null = Load-Fehlschlag.
static func _glb_mesh(pfad: String) -> Mesh:
var gltf := GLTFDocument.new()
var state := GLTFState.new()
if gltf.append_from_file(pfad, state) != OK:
push_warning("GlbMeshStore: GLB nicht ladbar: %s" % pfad)
return null
var szene: Node = gltf.generate_scene(state)
if szene == null:
return null
var m := _zusammenbacken(szene)
# Das Mesh ist eine Resource (RefCounted) — das Freigeben der Szene
# berührt die Referenz im Rückgabewert nicht.
szene.free()
return m
## Alle Mesh-Instanzen der Szene → ein ArrayMesh (Transforms angewandt,
## Materialien übernommen), dann normalisiert (Fußpunkt-Konvention).
static func _zusammenbacken(szene: Node) -> Mesh:
var roh := ArrayMesh.new()
_sammeln(szene, Transform3D.IDENTITY, roh)
if roh.get_surface_count() == 0:
return null
return _normalisiert(roh)
static func _sammeln(n: Node, xf: Transform3D, out: ArrayMesh) -> void:
if n is Node3D:
xf = xf * (n as Node3D).transform
if n is MeshInstance3D and (n as MeshInstance3D).mesh != null:
var m: Mesh = (n as MeshInstance3D).mesh
for s in m.get_surface_count():
var arrays: Array = m.surface_get_arrays(s)
if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array):
continue
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for i in verts.size():
verts[i] = xf * verts[i]
arrays[Mesh.ARRAY_VERTEX] = verts
if arrays[Mesh.ARRAY_NORMAL] is PackedVector3Array:
var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
for i in norms.size():
norms[i] = (xf.basis * norms[i]).normalized()
arrays[Mesh.ARRAY_NORMAL] = norms
# Statisch fürs Editor-Viewport: Skin-Daten fliegen raus (sonst
# verlangt add_surface_from_arrays ein Skeleton).
arrays[Mesh.ARRAY_BONES] = null
arrays[Mesh.ARRAY_WEIGHTS] = null
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var mat: Material = m.surface_get_material(s)
if mat != null:
out.surface_set_material(out.get_surface_count() - 1, mat)
for c in n.get_children():
_sammeln(c, xf, out)
## Fußpunkt-Konvention (wie scopedvr_export.normalisiere): Unterkante y = 0,
## XZ um den AABB-Mittelpunkt zentriert. Materialien bleiben an den Flächen.
static func _normalisiert(mesh: ArrayMesh) -> ArrayMesh:
var aabb := mesh.get_aabb()
var offset := Vector3(-aabb.get_center().x, -aabb.position.y, -aabb.get_center().z)
var out := ArrayMesh.new()
out.resource_name = mesh.resource_name
for s in mesh.get_surface_count():
var arrays: Array = mesh.surface_get_arrays(s)
if arrays.is_empty() or not (arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array):
continue
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for i in verts.size():
verts[i] = verts[i] + offset
arrays[Mesh.ARRAY_VERTEX] = verts
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var mat: Material = mesh.surface_get_material(s)
if mat != null:
out.surface_set_material(out.get_surface_count() - 1, mat)
return out
# ------------------------------------------------------- Camo-Textur --------
## Camo-Fix (t_50a07d4b): Multicam-Textur von der Platte laden und auf alle
## Flächen ohne albedo_texture setzen (GLTFDocument löst externe URIs der
## Sibling-Repos nicht auf). Zuordnung exakt wie tools/CAMO_TEXTURE_MAPPING.md
## bzw. gen_thumbs._camo_tex_setzen — diese Funktion ist ab jetzt DIE EINE
## Quelle der Zuordnung (gen_thumbs delegiert hierher). Objekte außerhalb
## military_desert/<kategorie>/<oid>.glb bleiben unangetastet. Fehlt die
## Textur-Datei, wird klar gewarnt (push_warning) statt still weiß zu rendern.
##
## EINE-Quelle-Dedup (t_50a07d4b): GLTFDocument lädt externe Texturen
## inzwischen selbst — aber als EIGENE ImageTexture-Instanz JEDES Parses
## (291 GLBs = 291 × 16 MB VRAM statt 2 × 16 MB, bewiesen per Probe:
## iid pro Parse unterschiedlich, Inhalt identisch zur Datei). Deshalb wird
## hier ZUSÄTZLICH geprüft: stimmt der Inhalt einer GLTF-Textur mit einer
## Multicam-Datei überein, ersetzen wir sie durch DIE eine geteilte Instanz
## aus dem Cache — gleiche Optik (WYSIWYG bleibt), eine Quelle statt Kopien.
## Eingebettete/Fremd-Texturen (Alt-Bestand, Waffen-GLBs) bleiben unangetastet.
static func camo_tex_setzen(mesh: Mesh, glb_pfad: String) -> void:
# Siehe Kopf-Kommentar: nur Flächen OHNE aufgelöste Textur werden versorgt.
var tex_dir := glb_pfad.get_base_dir().path_join("../../../textures/military_desert")
for s in mesh.get_surface_count():
var mat := mesh.surface_get_material(s)
if not (mat is BaseMaterial3D):
continue
var bm := mat as BaseMaterial3D
if bm.albedo_texture != null:
continue
var datei := pack_datei_fuer(bm.resource_name)
var tex := _camo_tex(tex_dir.path_join(datei))
if tex != null:
bm.albedo_texture = tex
else:
push_warning("GlbMeshStore: Pack-Textur fehlt fuer Material '%s': %s" % [bm.resource_name, datei])
## Materialname (GLTF) -> Pack-Texturdatei. Quelle: tools/PACK_TEXTURE_MAPPING.md (Client).
static func pack_datei_fuer(material_name: String) -> String:
match material_name:
"picture_1":
return PACK_TEX_PICTURE_1
"picture_2":
return PACK_TEX_PICTURE_2
"picture_3":
return PACK_TEX_PICTURE_3
"military_base":
return PACK_TEX_PALETTE
if material_name.begins_with("military_base"):
return PACK_TEX_PALETTE
return PACK_TEX_PALETTE
static func _camo_image(pfad: String, warnen: bool) -> Image:
if _camo_bild_cache.has(pfad):
var v: Variant = _camo_bild_cache[pfad]
return v if v is Image else null
if not FileAccess.file_exists(pfad):
if warnen:
push_warning("GlbMeshStore: Camo-Textur fehlt: %s" % pfad)
_camo_bild_cache[pfad] = false
return null
var img := Image.load_from_file(pfad)
if img == null:
if warnen:
push_warning("GlbMeshStore: Camo-Textur nicht ladbar: %s" % pfad)
_camo_bild_cache[pfad] = false
return null
_camo_bild_cache[pfad] = img
return img
## DIE EINE geteilte ImageTexture für eine Multicam-Datei (VRAM-Cache).
## Alle getextureden GLB-Flächen teilen sich genau diese Instanz.
static func _camo_tex(tex_pfad: String) -> ImageTexture:
if _camo_tex_cache.has(tex_pfad):
var v: Variant = _camo_tex_cache[tex_pfad]
return v if v is ImageTexture else null
var img := _camo_image(tex_pfad, true)
if img == null:
_camo_tex_cache[tex_pfad] = false
return null
var tex := ImageTexture.create_from_image(img)
_camo_tex_cache[tex_pfad] = tex
return tex
## Ersetzt eine GLTF-eigene ImageTexture durch die geteilte Multicam-Instanz,
## WENN der Inhalt (Größe, Format, Pixeldaten) mit einer der Kandidaten-
## Dateien übereinstimmt. null = kein Match → Textur bleibt unangetastet
## (eingebettete/Fremd-Texturen). Urteil je Instanz gecacht.
static func _camo_ersetzung_fuer(tex: Texture2D, kandidaten: Array) -> ImageTexture:
if not (tex is ImageTexture):
return null
var iid := tex.get_instance_id()
if _camo_ersetzung_cache.has(iid):
var v: Variant = _camo_ersetzung_cache[iid]
return v if v is ImageTexture else null
var img: Image = (tex as ImageTexture).get_image()
if img == null:
_camo_ersetzung_cache[iid] = false
return null
var out: ImageTexture = null
for pfad in kandidaten:
var vergleich := _camo_image(str(pfad), false)
if vergleich == null:
continue
if img.get_width() == vergleich.get_width() \
and img.get_height() == vergleich.get_height() \
and img.get_format() == vergleich.get_format() \
and img.data == vergleich.data:
out = _camo_tex(str(pfad))
break
_camo_ersetzung_cache[iid] = out if out != null else false
return out