365 lines
15 KiB
GDScript
365 lines
15 KiB
GDScript
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"]
|
||
## 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
|
||
|
||
# Camo-Fix (t_50a07d4b): 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
|
||
# (WYSIWYG-Bruch, belegt in t_c8748efc). camo_tex_setzen lädt die Multicam-
|
||
# Textur DIREKT von der Platte (Image.load_from_file, ohne Import-System) und
|
||
# setzt sie auf alle Flächen ohne albedo_texture — derselbe Mechanismus wie
|
||
# im Thumbnail-Pfad (gen_thumbs.gd). Zuordnung je Kategorie exakt wie in
|
||
# tools/CAMO_TEXTURE_MAPPING.md (Client-Repo, verbindlich):
|
||
# _1 = Objekte, _2 = Boden (nature: ground*/decal*), _3 = ungenutzt.
|
||
## Textur-Praefixe der nature-Unterkategorie, die Bodentextur _2 bekommen.
|
||
const CAMO_TEX_BODEN_PRAEFIXE := ["ground", "decal"]
|
||
|
||
# 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 (ProjectSettings → Fallback-Konstante).
|
||
static func quellen() -> Array:
|
||
var setting: Variant = ProjectSettings.get_setting(QUELLEN_SETTING, DEFAULT_QUELLEN)
|
||
if not (setting is Array or setting is PackedStringArray):
|
||
return DEFAULT_QUELLEN.duplicate()
|
||
var out: Array = []
|
||
for q in setting:
|
||
if q is String and not (q as String).strip_edges().is_empty():
|
||
out.append((q as String).strip_edges())
|
||
return out if not out.is_empty() else DEFAULT_QUELLEN.duplicate()
|
||
|
||
|
||
## 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)
|
||
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))
|
||
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
|
||
if _cache.has(oid):
|
||
return _cache[oid] if _cache[oid] is Mesh else null
|
||
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
|
||
|
||
|
||
## 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 _cache.has(oid):
|
||
return not (_cache[oid] is Mesh)
|
||
for pfad in _kandidaten(oid):
|
||
if FileAccess.file_exists(pfad):
|
||
return false
|
||
return true
|
||
|
||
|
||
## 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:
|
||
# Kategorie + OID aus dem Pfad: .../military_desert/<kategorie>/<oid>.glb
|
||
var teile := glb_pfad.split("/")
|
||
var kategorie := ""
|
||
var oid := ""
|
||
for i in teile.size():
|
||
if teile[i] == "military_desert" and i + 2 < teile.size():
|
||
kategorie = teile[i + 1]
|
||
oid = teile[i + 2].get_basename()
|
||
break
|
||
if kategorie.is_empty():
|
||
return
|
||
var nr := 1
|
||
if kategorie == "nature":
|
||
for p in CAMO_TEX_BODEN_PRAEFIXE:
|
||
if oid.begins_with(p):
|
||
nr = 2
|
||
break
|
||
var tex_dir := glb_pfad.get_base_dir().path_join("../textures")
|
||
# Kategorie-Textur (Fächer OHNE albedo_texture) + Vergleichs-Set: BEIDE
|
||
# Multicam-Dateien — gebackene GLBs können Misch-Referenzen haben (z. B.
|
||
# barrel_001: _2 + _1). Die Dedup-Zuordnung geschieht pro Fläche über den
|
||
# INHALT, nicht per Kategorie-Blindregel.
|
||
var kategorie_tex_pfad := tex_dir.path_join("desert_multicam_%d.png" % nr)
|
||
var kandidaten: Array = [kategorie_tex_pfad]
|
||
var andere_nr := 2 if nr == 1 else 1
|
||
var andere_pfad := tex_dir.path_join("desert_multicam_%d.png" % andere_nr)
|
||
if FileAccess.file_exists(andere_pfad):
|
||
kandidaten.append(andere_pfad)
|
||
var kategorie_tex := _camo_tex(kategorie_tex_pfad) # null + push_warning, wenn fehlt
|
||
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
|
||
var alt := bm.albedo_texture
|
||
if alt == null:
|
||
# Fläche ohne Textur (GLTF-Fallback) → Kategorie-Textur
|
||
# (gen_thumbs-Mechanismus, t_c8748efc). Fehlt die Datei: warnt
|
||
# _camo_tex (push_warning) statt still weiß zu rendern.
|
||
if kategorie_tex != null:
|
||
bm.albedo_texture = kategorie_tex
|
||
else:
|
||
push_warning("GlbMeshStore: %s (%s) bleibt untexturiert — Camo-Textur fehlt: %s"
|
||
% [oid, kategorie, kategorie_tex_pfad])
|
||
continue
|
||
var ersetzung := _camo_ersetzung_fuer(alt, kandidaten)
|
||
if ersetzung != null:
|
||
bm.albedo_texture = ersetzung
|
||
|
||
|
||
## Dekodierte Multicam-Bilddatei (RAM-gecacht, einmal je Pfad dekodiert).
|
||
## warnen=true: fehlende/nicht ladbare Datei → push_warning (Karten-Pflicht:
|
||
## klar warnen statt still grau/weiß zu rendern). null = nicht ladbar.
|
||
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
|