scopedvr-map-editor/src/viewport/glb_mesh_store.gd

365 lines
15 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"]
## 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