extends Node
## ⚠ NO `class_name`: registered as the autoload `ModelCache` (a global class of the
## same name would hide the singleton — see asset_sync.gd).
##
## RUNTIME .glb MODELS, LOADED ONCE AND SHARED (hyper-games #93, models plan).
##
## A placed model names its bytes by CONTENT HASH (user, 2026-10-04: exact bytes, so
## republishing a name never swaps everyone's copies). This cache turns a hash into a
## PackedScene, once per process, and every copy instantiates it — meshes and
## materials are shared resources, so a hundred chairs are one chair's memory.
##
## request(oid) -> fetching -> queued -> ready | failed
##
## FETCH: ContentOverlay, by hash, into user://content/<oid>.glb (content-addressed:
## a file at that name IS that hash, so a second request is free). Readiness is the
## overlay's content_ready for THIS hash — NOT AssetSync.asset_ready, which fires as
## soon as a pointer replicates, before any bytes exist.
##
## CHECK: GlbCheck, before Godot parses anything (uris, counts, image headers).
##
## PARSE ON THE MAIN THREAD, ONE MODEL PER FRAME. Not a worker: this project runs
## rendering/driver/threads/thread_model=0 (RENDER_THREAD_UNSAFE, core/os/os.h), and
## GLTFDocument creates textures through the RenderingServer while it parses. The
## queue keeps it to one parse per frame, the placeholder covers the wait, and each
## parse is timed (entry.parse_ms) so the cost is a number, not a guess.
signal model_state(oid: String)
const CONTENT_DIR := "user://content"
var overlay: Object = null
var _entries: Dictionary = {} # oid -> {state, reason, received, total, scene, triangles, parse_ms}
var _queue: Array[String] = []
var _shapes: Dictionary = {} # "oid|mode|scale" -> {shapes, hide, named_found}
var _hulls: Dictionary = {} # oid -> decompose result at scale 1 (VHACD runs once)
func _ready() -> void:
if overlay == null:
overlay = get_node_or_null(^"/root/ContentOverlay")
if overlay != null:
overlay.content_ready.connect(_on_ready)
overlay.content_error.connect(_on_error)
overlay.content_progress.connect(_on_progress)
static func is_oid(s: String) -> bool:
if s.length() != 64:
return false
for c in s:
if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")):
return false
return true
static func path_of(oid: String) -> String:
return CONTENT_DIR.path_join(oid + ".glb")
## Start (or continue) getting a model; returns its entry. Safe to call every fold:
## a ready / in-flight entry is returned as is, and a FAILED fetch is retried (a peer
## that holds it may have joined since). A failed CHECK is final — the bytes are what
## they are.
func request(oid: String) -> Dictionary:
if _entries.has(oid):
var e: Dictionary = _entries[oid]
if e["state"] != "failed" or bool(e.get("final", false)):
return e
var entry := {"state": "fetching", "reason": "", "received": 0, "total": 0,
"scene": null, "triangles": 0, "parse_ms": 0.0, "final": false}
_entries[oid] = entry
if not is_oid(oid):
return _fail(oid, "bad asset id", true)
if FileAccess.file_exists(path_of(oid)):
_enqueue(oid)
return entry
if overlay == null or not overlay.is_ready():
return _fail(oid, "asset transfer unavailable", false)
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CONTENT_DIR))
if String(overlay.request(oid, oid + ".glb")) == "":
return _fail(oid, "could not start the download", false)
model_state.emit(oid)
return entry
func status(oid: String) -> Dictionary:
return _entries.get(oid, {})
## A fresh copy of a ready model, or null.
func instance(oid: String) -> Node3D:
var e: Dictionary = _entries.get(oid, {})
if String(e.get("state", "")) != "ready":
return null
return (e["scene"] as PackedScene).instantiate() as Node3D
## The model's BASE (model space) — the point a scale is taken about, so a model grows
## from where it stands (user, 2026-10-04). Found once at parse (ModelCollision.base_of).
func base(oid: String) -> Vector3:
return (_entries.get(oid, {}) as Dictionary).get("base", Vector3.ZERO)
## Collision for a ready model in one mode at one scale, built once and shared by every
## copy (shapes are resources). {shapes, hide, named_found}.
func shapes(oid: String, mode: String, scale: float) -> Dictionary:
var key := "%s|%s|%s" % [oid, mode, String.num(scale, 4)]
if _shapes.has(key):
return _shapes[key]
var e: Dictionary = _entries.get(oid, {})
if String(e.get("state", "")) != "ready":
return {"shapes": [], "hide": [], "named_found": 0}
var root := (e["scene"] as PackedScene).instantiate()
var out: Dictionary
if mode == "decompose":
# VHACD is the slow one: run it once per model, rescale the points per scale.
if not _hulls.has(oid):
_hulls[oid] = ModelCollision.build(root, "decompose", 1.0)
out = {"shapes": [], "hide": ModelCollision.hidden_paths(root), "named_found": 0, "mode": "decompose"}
var sx := ModelCollision.scale_about(base(oid), scale)
for s: Variant in (_hulls[oid] as Dictionary)["shapes"]:
var c := ConvexPolygonShape3D.new()
var pts := PackedVector3Array()
for p in (s as ConvexPolygonShape3D).points:
pts.append(sx * p)
c.points = pts
(out["shapes"] as Array).append(c)
else:
out = ModelCollision.build(root, mode, scale, base(oid))
root.free()
_shapes[key] = out
return out
func _enqueue(oid: String) -> void:
(_entries[oid] as Dictionary)["state"] = "queued"
if not _queue.has(oid):
_queue.append(oid)
model_state.emit(oid)
func _fail(oid: String, reason: String, final: bool) -> Dictionary:
var e: Dictionary = _entries[oid]
e["state"] = "failed"
e["reason"] = reason
e["final"] = final
push_warning("[ModelCache] %s: %s" % [oid.substr(0, 12), reason])
model_state.emit(oid)
return e
func _process(_d: float) -> void:
if _queue.is_empty():
return
_parse(_queue.pop_front())
## Check + parse + pack one model. Public as parse_now for gates that cannot wait frames.
func parse_now(oid: String) -> void:
_queue.erase(oid)
_parse(oid)
func _parse(oid: String) -> void:
var e: Dictionary = _entries.get(oid, {})
if e.is_empty():
return
var t0 := Time.get_ticks_usec()
var bytes := FileAccess.get_file_as_bytes(path_of(oid))
if bytes.is_empty():
_fail(oid, "downloaded file is missing or empty", false)
return
var v := GlbCheck.check(bytes)
if not bool(v["ok"]):
_fail(oid, String(v["reason"]), true)
return
var doc := GLTFDocument.new()
var st := GLTFState.new()
# Base path "" and GlbCheck has refused every uri, so nothing resolves against disk.
var err := doc.append_from_buffer(bytes, "", st)
if err != OK:
_fail(oid, "Godot could not read it (error %d)" % err, true)
return
var root := doc.generate_scene(st) as Node3D
if root == null:
_fail(oid, "the file has no scene", true)
return
_own(root, root)
e["base"] = ModelCollision.base_of(root)
var packed := PackedScene.new()
if packed.pack(root) != OK:
root.free()
_fail(oid, "could not pack the scene", true)
return
root.free()
e["scene"] = packed
e["triangles"] = int(v["triangles"])
e["parse_ms"] = float(Time.get_ticks_usec() - t0) / 1000.0
e["state"] = "ready"
print("[ModelCache] %s ready: %d tris, parsed in %.1f ms" % [oid.substr(0, 12), int(e["triangles"]), float(e["parse_ms"])])
model_state.emit(oid)
static func _own(n: Node, root: Node) -> void:
for c in n.get_children():
c.owner = root
_own(c, root)
func _on_ready(content_hash: String, _path: String) -> void:
if _entries.has(content_hash) and String(_entries[content_hash]["state"]) == "fetching":
_enqueue(content_hash)
func _on_error(content_hash: String, reason: String) -> void:
if _entries.has(content_hash) and String(_entries[content_hash]["state"]) == "fetching":
_fail(content_hash, "not available from any peer (%s)" % reason, false)
## The download's own size is the first place the 64 MB limit can bite — before the
## bytes are on disk. A holder that announces more is cancelled, not waited out.
func _on_progress(content_hash: String, received: int, total: int) -> void:
if not _entries.has(content_hash):
return
var e: Dictionary = _entries[content_hash]
e["received"] = received
e["total"] = total
if total > GlbCheck.MAX_BYTES or received > GlbCheck.MAX_BYTES:
if overlay != null and overlay.has_method("cancel"):
overlay.cancel(content_hash)
_fail(content_hash, "file too large (over %d MB)" % (GlbCheck.MAX_BYTES >> 20), true)
return
model_state.emit(content_hash)