monkeystudio / scripts /core /memory_manager.gd
geter62's picture
Upload folder using huggingface_hub
84e8204 verified
Raw History Blame Contribute Delete
5.58 kB
extends Node
##
## MemoryManager - central budget authority for low-RAM (4GB) Android devices.
##
## Responsibilities:
## - Track a hard RAM budget for loadable assets and evict (LRU) when exceeded.
## - Provide lazy `acquire(id)` / `release(id)` reference counting so a heavy
## feature (hair sim, cloth, sculpt grid) only lives in RAM while in use.
## - Never hold a strong reference to an evictable asset itself; it holds the
## *path* and hands out instances, so eviction actually frees memory.
##
## Design notes:
## - Godot is ref-counted; an asset is truly freed only when every strong ref
## is dropped. Callers MUST call release() so we can drop the cache ref.
## - We poll OS memory pressure and force-evict before the OOM killer arrives.
##
signal budget_exceeded(evicted_count: int)
signal memory_warning(used_mb: float, budget_mb: float)
signal asset_loaded(id: String, cost_mb: float)
signal asset_evicted(id: String, freed_mb: float)
## Total RAM this process is allowed to use for evictable assets.
@export var asset_budget_mb: float = 512.0
## Soft threshold (fraction of budget) that triggers a warning.
@export var warn_ratio: float = 0.8
## How often (seconds) to poll the OS memory monitor.
@export var poll_interval: float = 1.0
## id -> { path, cost_mb, refs, last_used, resource }
var _entries: Dictionary = {}
## Monotonic counter used for LRU ordering.
var _clock: int = 0
var _poll_accum: float = 0.0
## When true, release() cannot drop below zero refs (double-free guard).
var _initialized := false
func _ready() -> void:
set_process(true)
_apply_platform_budget()
_initialized = true
print("[MemoryManager] budget=%.0f MB" % asset_budget_mb)
func _apply_platform_budget() -> void:
# A 64-bit OS reports total RAM in bytes; keep a conservative slice for us.
var total_mb := float(OS.get_memory_info().get("physical", 0)) / 1048576.0
if total_mb > 0.0:
# Never claim more than 1/8 of physical RAM for evictable assets.
asset_budget_mb = min(asset_budget_mb, total_mb * 0.125)
# Hard floor so tiny test environments still work.
asset_budget_mb = max(asset_budget_mb, 64.0)
## Register an asset path as loadable. Does NOT load it (that is the point: lazy).
## `cost_mb` is a caller estimate; use VertexBufferManager.estimate_cost for meshes.
func register(id: String, path: String, cost_mb: float) -> void:
assert(not _entries.has(id), "MemoryManager: duplicate id '%s'" % id)
_entries[id] = {
"path": path,
"cost_mb": cost_mb,
"refs": 0,
"last_used": 0,
"resource": null,
}
## Acquire an asset, loading it on first use. Every acquire must be matched
## with exactly one release().
func acquire(id: String) -> Resource:
if not _entries.has(id):
push_error("MemoryManager.acquire: unknown id '%s'" % id)
return null
var e: Dictionary = _entries[id]
if e["resource"] == null:
_ensure_room(e["cost_mb"])
var res: Resource = load(e["path"])
if res == null:
push_error("MemoryManager: failed to load '%s'" % e["path"])
return null
e["resource"] = res
asset_loaded.emit(id, e["cost_mb"])
e["refs"] = int(e["refs"]) + 1
e["last_used"] = _next_clock()
return e["resource"]
## Release one reference. When refs hit zero the asset becomes evictable.
func release(id: String) -> void:
if not _entries.has(id):
return
var e: Dictionary = _entries[id]
e["refs"] = maxi(0, int(e["refs"]) - 1)
e["last_used"] = _next_clock()
## Force-unload an asset regardless of refs. Only use when you know all live
## references are gone (e.g. a panel was destroyed).
func purge(id: String) -> void:
if not _entries.has(id):
return
var e: Dictionary = _entries[id]
if e["resource"] != null:
var freed := float(e["cost_mb"])
if e["refs"] == 0:
e["resource"] = null
asset_evicted.emit(id, freed)
e["refs"] = 0
func get_loaded_mb() -> float:
var total := 0.0
for id in _entries:
if _entries[id]["resource"] != null:
total += float(_entries[id]["cost_mb"])
return total
func _next_clock() -> int:
_clock += 1
return _clock
func _process(delta: float) -> void:
_poll_accum += delta
if _poll_accum < poll_interval:
return
_poll_accum = 0.0
var used := get_loaded_mb()
if used >= asset_budget_mb * warn_ratio:
memory_warning.emit(used, asset_budget_mb)
# Also react to real OS pressure independent of our accounting.
if OS.get_memory_info().get("physical", 0) > 0:
var avail_mb := float(OS.get_memory_info()["available"]) / 1048576.0 if OS.get_memory_info().has("available") else INF
if avail_mb < 128.0:
_ensure_room(asset_budget_mb * 0.25, true)
## Evict unreferenced (LRU) assets until `need_mb` of room exists.
func _ensure_room(need_mb: float, force: bool = false) -> void:
while get_loaded_mb() + need_mb > asset_budget_mb:
var victim := _pick_lru_victim()
if victim == "":
break
var e: Dictionary = _entries[victim]
var freed := float(e["cost_mb"])
e["resource"] = null
e["refs"] = 0
asset_evicted.emit(victim, freed)
if force and not _has_more_victims():
break
budget_exceeded.emit(0)
func _pick_lru_victim() -> String:
var best := ""
var best_time := 0x7FFFFFFF
for id in _entries:
var e: Dictionary = _entries[id]
if e["resource"] == null:
continue
if int(e["refs"]) > 0:
continue
if int(e["last_used"]) < best_time:
best_time = int(e["last_used"])
best = id
return best
func _has_more_victims() -> bool:
return _pick_lru_victim() != ""
func debug_snapshot() -> Dictionary:
return {
"budget_mb": asset_budget_mb,
"loaded_mb": get_loaded_mb(),
"entries": _entries.size(),
}