|
| 1 | +class_name FoveaStreamingManager |
| 2 | +extends RefCounted |
| 3 | + |
| 4 | +const GPUCullerPipelineScript = preload("res://addons/foveacore/scripts/advanced/gpu_culler_pipeline.gd") |
| 5 | + |
| 6 | +## FoveaEngine : FoveaStreamingManager |
| 7 | +## Gère le chargement out-of-core asynchrone des chunks spatiaux de splats. |
| 8 | +## Limite l'utilisation de la RAM CPU via un cache LRU. |
| 9 | + |
| 10 | +# On stocke les informations de positionnement du fichier .fovea |
| 11 | +class StreamingAsset: |
| 12 | + var path: String |
| 13 | + var splat_start_offset: int |
| 14 | + var chunks: Array = [] # Array[SpatialChunk] |
| 15 | + var total_splats: int |
| 16 | + |
| 17 | +# Configuration des budgets |
| 18 | +var max_ram_splats: int = 1000000 # Environ 16 Mo de RAM CPU max pour les splats |
| 19 | +var max_uploads_per_frame: int = 32768 |
| 20 | +var has_newly_loaded_chunks: bool = false |
| 21 | + |
| 22 | +# État interne |
| 23 | +var _assets: Dictionary = {} # fovea_path -> StreamingAsset |
| 24 | +var _lru_cache: Array[String] = [] # Clés sous forme de "path_chunkIndex" |
| 25 | +var _loading_keys: Dictionary = {} # Clés en cours de chargement asynchrone -> true |
| 26 | +var _lock: Mutex = Mutex.new() |
| 27 | +var _current_ram_splats: int = 0 |
| 28 | + |
| 29 | +func _init() -> void: |
| 30 | + pass |
| 31 | + |
| 32 | +# Enregistre un asset sans charger tous les octets bruts |
| 33 | +func register_asset(fovea_path: String, aabb_min: Vector3, aabb_max: Vector3, raw_bytes: PackedByteArray = PackedByteArray()) -> StreamingAsset: |
| 34 | + _lock.lock() |
| 35 | + if _assets.has(fovea_path): |
| 36 | + var asset = _assets[fovea_path] |
| 37 | + _lock.unlock() |
| 38 | + return asset |
| 39 | + _lock.unlock() |
| 40 | + |
| 41 | + var file := FileAccess.open(fovea_path, FileAccess.READ) |
| 42 | + if file == null: |
| 43 | + push_error("FoveaStreamingManager: Impossible d'ouvrir en lecture: " + fovea_path) |
| 44 | + return null |
| 45 | + |
| 46 | + # Lire les métadonnées de l'en-tête (72 octets) |
| 47 | + var magic := file.get_buffer(8).get_string_from_utf8() |
| 48 | + if magic != "FOVEA_3D": |
| 49 | + file.close() |
| 50 | + push_error("FoveaStreamingManager: Magic bytes invalides dans: " + fovea_path) |
| 51 | + return null |
| 52 | + |
| 53 | + var version := file.get_32() |
| 54 | + var total_splats := file.get_32() |
| 55 | + var color_codebook_size := file.get_32() |
| 56 | + var covar_codebook_size := file.get_32() |
| 57 | + file.close() |
| 58 | + |
| 59 | + var splat_start_offset = 72 + color_codebook_size * 12 + covar_codebook_size * 32 |
| 60 | + |
| 61 | + var asset = StreamingAsset.new() |
| 62 | + asset.path = fovea_path |
| 63 | + asset.splat_start_offset = splat_start_offset |
| 64 | + asset.total_splats = total_splats |
| 65 | + |
| 66 | + # Si raw_bytes n'est pas fourni, on le lit du fichier temporairement pour indexer |
| 67 | + var bytes := raw_bytes |
| 68 | + if bytes.is_empty(): |
| 69 | + var temp_file := FileAccess.open(fovea_path, FileAccess.READ) |
| 70 | + if temp_file: |
| 71 | + temp_file.seek(splat_start_offset) |
| 72 | + bytes = temp_file.get_buffer(total_splats * 16) |
| 73 | + temp_file.close() |
| 74 | + |
| 75 | + # Diviser l'espace en chunks et calculer les file_slices |
| 76 | + asset.chunks = _precompute_slices(asset, bytes, aabb_min, aabb_max) |
| 77 | + |
| 78 | + _lock.lock() |
| 79 | + _assets[fovea_path] = asset |
| 80 | + _lock.unlock() |
| 81 | + |
| 82 | + print("FoveaStreamingManager: Asset '%s' enregistre pour streaming (%d splats, %d chunks)." % [ |
| 83 | + fovea_path.get_file(), total_splats, asset.chunks.size() |
| 84 | + ]) |
| 85 | + return asset |
| 86 | + |
| 87 | +func unregister_asset(fovea_path: String) -> void: |
| 88 | + _lock.lock() |
| 89 | + if _assets.has(fovea_path): |
| 90 | + var asset = _assets[fovea_path] |
| 91 | + for chunk in asset.chunks: |
| 92 | + _current_ram_splats -= chunk.raw_bytes.size() / 16 |
| 93 | + chunk.raw_bytes = PackedByteArray() |
| 94 | + chunk.is_loaded = false |
| 95 | + _assets.erase(fovea_path) |
| 96 | + |
| 97 | + # Nettoyer LRU |
| 98 | + var new_lru: Array[String] = [] |
| 99 | + for key in _lru_cache: |
| 100 | + if not key.begins_with(fovea_path + "_"): |
| 101 | + new_lru.append(key) |
| 102 | + _lru_cache = new_lru |
| 103 | + _lock.unlock() |
| 104 | + |
| 105 | +# Calcule les tranches de fichier pour chaque chunk |
| 106 | +func _precompute_slices(asset: StreamingAsset, bytes: PackedByteArray, aabb_min: Vector3, aabb_max: Vector3) -> Array: |
| 107 | + var chunks: Array = [] |
| 108 | + chunks.resize(4096) |
| 109 | + |
| 110 | + var size_cell := (aabb_max - aabb_min) / 16.0 |
| 111 | + var SpatialChunkScript = GPUCullerPipelineScript.SpatialChunk |
| 112 | + for i in range(4096): |
| 113 | + var chunk = SpatialChunkScript.new() |
| 114 | + chunk.index = i |
| 115 | + var cx := i & 15 |
| 116 | + var cy := (i >> 4) & 15 |
| 117 | + var cz := (i >> 8) & 15 |
| 118 | + var pos_cell := aabb_min + Vector3(cx, cy, cz) * size_cell |
| 119 | + chunk.aabb = AABB(pos_cell, size_cell) |
| 120 | + chunk.raw_bytes = PackedByteArray() |
| 121 | + chunk.is_loaded = false |
| 122 | + # Stocker les tranches de fichier (offset absolu, taille en bytes) |
| 123 | + chunk.set_meta("file_slices", []) |
| 124 | + chunks[i] = chunk |
| 125 | + |
| 126 | + var total_splats = bytes.size() / 16 |
| 127 | + var buckets: Array[Array] = [] |
| 128 | + buckets.resize(4096) |
| 129 | + for i in range(4096): |
| 130 | + buckets[i] = [] |
| 131 | + |
| 132 | + for i in range(total_splats): |
| 133 | + var offset := i * 16 |
| 134 | + var qx := bytes.decode_u16(offset) |
| 135 | + var qy := bytes.decode_u16(offset + 2) |
| 136 | + var qz := bytes.decode_u16(offset + 4) |
| 137 | + |
| 138 | + var cell_x := clamp(int(float(qx) / 65535.0 * 16.0), 0, 15) |
| 139 | + var cell_y := clamp(int(float(qy) / 65535.0 * 16.0), 0, 15) |
| 140 | + var cell_z := clamp(int(float(qz) / 65535.0 * 16.0), 0, 15) |
| 141 | + |
| 142 | + var chunk_idx = cell_x + (cell_y << 4) + (cell_z << 8) |
| 143 | + buckets[chunk_idx].append(i) |
| 144 | + |
| 145 | + for i in range(4096): |
| 146 | + var indices: Array = buckets[i] |
| 147 | + if indices.is_empty(): |
| 148 | + continue |
| 149 | + var chunk = chunks[i] |
| 150 | + var slices: Array = [] |
| 151 | + |
| 152 | + var start_idx: int = indices[0] |
| 153 | + var count: int = 1 |
| 154 | + for j in range(1, indices.size()): |
| 155 | + var idx: int = indices[j] |
| 156 | + if idx == start_idx + count: |
| 157 | + count += 1 |
| 158 | + else: |
| 159 | + var file_offset = asset.splat_start_offset + start_idx * 16 |
| 160 | + slices.append({ "offset": file_offset, "size": count * 16 }) |
| 161 | + start_idx = idx |
| 162 | + count = 1 |
| 163 | + |
| 164 | + var file_offset = asset.splat_start_offset + start_idx * 16 |
| 165 | + slices.append({ "offset": file_offset, "size": count * 16 }) |
| 166 | + chunk.set_meta("file_slices", slices) |
| 167 | + |
| 168 | + return chunks |
| 169 | + |
| 170 | +# Met à jour la priorité des chunks et gère le chargement/déchargement |
| 171 | +func update_streaming(camera: Camera3D, chunk_load_radius: float = 20.0) -> void: |
| 172 | + if camera == null: |
| 173 | + return |
| 174 | + var cam_pos = camera.global_position |
| 175 | + var gaze_dir := -camera.global_transform.basis.z.normalized() |
| 176 | + |
| 177 | + var chunks_to_load: Array[Dictionary] = [] |
| 178 | + |
| 179 | + _lock.lock() |
| 180 | + for fovea_path in _assets: |
| 181 | + var asset: StreamingAsset = _assets[fovea_path] |
| 182 | + for chunk in asset.chunks: |
| 183 | + var slices: Array = chunk.get_meta("file_slices") |
| 184 | + if slices.is_empty(): |
| 185 | + continue |
| 186 | + |
| 187 | + var dist = _distance_to_aabb(cam_pos, chunk.aabb) |
| 188 | + var key = fovea_path + "_" + str(chunk.index) |
| 189 | + |
| 190 | + if dist <= chunk_load_radius: |
| 191 | + # Si le chunk n'est pas chargé et pas encore en cours de chargement |
| 192 | + if not chunk.is_loaded and not _loading_keys.has(key): |
| 193 | + # Calcul de priorité : plus proche et dans le regard = priorité haute (valeur faible) |
| 194 | + var to_chunk: Vector3 = (chunk.aabb.position + chunk.aabb.size * 0.5 - cam_pos).normalized() |
| 195 | + var gaze_align: float = gaze_dir.dot(to_chunk) # [-1..1] |
| 196 | + var priority: float = dist - 5.0 * gaze_align # Plus de poids à l'alignement gaze |
| 197 | + |
| 198 | + chunks_to_load.append({ |
| 199 | + "asset": asset, |
| 200 | + "chunk": chunk, |
| 201 | + "key": key, |
| 202 | + "priority": priority |
| 203 | + }) |
| 204 | + elif chunk.is_loaded: |
| 205 | + # Mettre à jour LRU si déjà chargé |
| 206 | + _touch_lru(key) |
| 207 | + else: |
| 208 | + # Si le chunk est chargé mais en dehors du rayon, on l'éligible à l'éviction immédiate |
| 209 | + # (L'éviction sera faite par le budget de RAM) |
| 210 | + pass |
| 211 | + _lock.unlock() |
| 212 | + |
| 213 | + # Trier les chunks par priorité (tri ascendant : plus petit en premier) |
| 214 | + chunks_to_load.sort_custom(func(a, b): |
| 215 | + return a.priority < b.priority |
| 216 | + ) |
| 217 | + |
| 218 | + # Lancer le chargement des chunks prioritaires |
| 219 | + for item in chunks_to_load: |
| 220 | + _request_chunk_load(item.asset, item.chunk, item.key) |
| 221 | + |
| 222 | +# Lancer une tâche asynchrone pour charger un chunk |
| 223 | +func _request_chunk_load(asset: StreamingAsset, chunk, key: String) -> void: |
| 224 | + _lock.lock() |
| 225 | + _loading_keys[key] = true |
| 226 | + _lock.unlock() |
| 227 | + |
| 228 | + # Charger asynchronement via un thread |
| 229 | + var thread := Thread.new() |
| 230 | + thread.start(func() -> void: |
| 231 | + _async_load_thread_func(asset, chunk, key, thread) |
| 232 | + ) |
| 233 | + |
| 234 | +# Fonction de thread d'arrière-plan |
| 235 | +func _async_load_thread_func(asset: StreamingAsset, chunk, key: String, thread: Thread) -> void: |
| 236 | + var slices: Array = chunk.get_meta("file_slices") |
| 237 | + var chunk_bytes := PackedByteArray() |
| 238 | + |
| 239 | + # Ouvrir le fichier en lecture |
| 240 | + var file := FileAccess.open(asset.path, FileAccess.READ) |
| 241 | + if file != null: |
| 242 | + for slice in slices: |
| 243 | + file.seek(slice.offset) |
| 244 | + chunk_bytes.append_array(file.get_buffer(slice.size)) |
| 245 | + file.close() |
| 246 | + |
| 247 | + _lock.lock() |
| 248 | + chunk.raw_bytes = chunk_bytes |
| 249 | + chunk.is_loaded = true |
| 250 | + has_newly_loaded_chunks = true |
| 251 | + _loading_keys.erase(key) |
| 252 | + |
| 253 | + var splat_count = chunk_bytes.size() / 16 |
| 254 | + _current_ram_splats += splat_count |
| 255 | + _touch_lru(key) |
| 256 | + |
| 257 | + # Vérifier et appliquer le budget RAM (éviction LRU) |
| 258 | + _enforce_ram_budget() |
| 259 | + _lock.unlock() |
| 260 | + |
| 261 | + # Attendre la fin du thread proprement |
| 262 | + call_deferred("_cleanup_thread", thread) |
| 263 | + |
| 264 | +func _cleanup_thread(thread: Thread) -> void: |
| 265 | + thread.wait_to_finish() |
| 266 | + |
| 267 | +func _touch_lru(key: String) -> void: |
| 268 | + _lru_cache.erase(key) |
| 269 | + _lru_cache.append(key) |
| 270 | + |
| 271 | +# Libère la RAM CPU des chunks les plus anciens si on dépasse le budget |
| 272 | +func _enforce_ram_budget() -> void: |
| 273 | + while _current_ram_splats > max_ram_splats and not _lru_cache.is_empty(): |
| 274 | + var oldest_key := _lru_cache[0] |
| 275 | + _lru_cache.remove_at(0) |
| 276 | + |
| 277 | + # Parser oldest_key ("fovea_path_chunkIndex") |
| 278 | + var last_underscore := oldest_key.rfind("_") |
| 279 | + if last_underscore == -1: |
| 280 | + continue |
| 281 | + var fovea_path := oldest_key.substr(0, last_underscore) |
| 282 | + var chunk_idx := oldest_key.substr(last_underscore + 1).to_int() |
| 283 | + |
| 284 | + if _assets.has(fovea_path): |
| 285 | + var asset: StreamingAsset = _assets[fovea_path] |
| 286 | + if chunk_idx < asset.chunks.size(): |
| 287 | + var chunk = asset.chunks[chunk_idx] |
| 288 | + var evicted_splats = chunk.raw_bytes.size() / 16 |
| 289 | + chunk.raw_bytes = PackedByteArray() |
| 290 | + chunk.is_loaded = false |
| 291 | + _current_ram_splats -= evicted_splats |
| 292 | + print("FoveaStreamingManager: Eviction LRU de %s (chunk %d, -%d splats). RAM=%d splats." % [ |
| 293 | + fovea_path.get_file(), chunk_idx, evicted_splats, _current_ram_splats |
| 294 | + ]) |
| 295 | + |
| 296 | +func _distance_to_aabb(point: Vector3, aabb: AABB) -> float: |
| 297 | + var closest_point := Vector3( |
| 298 | + clamp(point.x, aabb.position.x, aabb.end.x), |
| 299 | + clamp(point.y, aabb.position.y, aabb.end.y), |
| 300 | + clamp(point.z, aabb.position.z, aabb.end.z) |
| 301 | + ) |
| 302 | + return point.distance_to(closest_point) |
0 commit comments