File size: 5,576 Bytes
84e8204 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | 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(),
}
|