OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / model_cache.gd

234 lines · gd ref:
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)