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(),
	}