khora_data/ui/image_atlas.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `UiImageAtlas` — engine-level resource holding the GPU texture atlas
16//! and the persistent `AssetUUID → AtlasRect` cache used by the UI render
17//! pipeline.
18//!
19//! Previously these two pieces lived as fields on `UiAgent`, which made
20//! the agent own GPU state in violation of the "agents stay strategists,
21//! no buffered output" rule. They now live in
22//! [`khora_core::Resources`] so the agent can be recreated freely
23//! without losing the atlas, and the UiAgent code holds no GPU state at
24//! all.
25
26use std::collections::HashMap;
27use std::sync::{Mutex, MutexGuard, RwLock};
28
29use khora_core::asset::AssetUUID;
30use khora_core::renderer::api::util::{AtlasRect, TextureAtlas, TextureFormat};
31use khora_core::renderer::GraphicsDevice;
32
33/// Resource holding the UI image atlas (GPU texture) and an
34/// `AssetUUID → AtlasRect` cache mapping each uploaded UI image to its
35/// position inside the atlas.
36///
37/// Registered in [`khora_core::Resources`] at engine init; the UI agent
38/// allocates the GPU atlas lazily via [`UiImageAtlas::ensure_atlas`] and
39/// then borrows it for upload + render through [`UiImageAtlas::lock_atlas`].
40pub struct UiImageAtlas {
41 /// GPU texture atlas, allocated lazily on first call to
42 /// `ensure_atlas` once a graphics device is available.
43 atlas: Mutex<Option<TextureAtlas>>,
44 /// Persistent `AssetUUID → AtlasRect` cache.
45 cache: RwLock<HashMap<AssetUUID, AtlasRect>>,
46}
47
48impl UiImageAtlas {
49 /// Creates an empty resource. The GPU atlas itself is allocated
50 /// lazily by [`ensure_atlas`](Self::ensure_atlas) once a device is
51 /// available.
52 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 atlas: Mutex::new(None),
56 cache: RwLock::new(HashMap::new()),
57 }
58 }
59
60 /// Allocates the GPU texture atlas if not yet present. Idempotent —
61 /// safe to call every frame, the actual allocation happens once.
62 ///
63 /// Returns `true` when the atlas is available after the call,
64 /// `false` if allocation failed.
65 pub fn ensure_atlas(&self, device: &dyn GraphicsDevice) -> bool {
66 let Ok(mut guard) = self.atlas.lock() else {
67 log::error!("UiImageAtlas: mutex poisoned in ensure_atlas");
68 return false;
69 };
70 if guard.is_some() {
71 return true;
72 }
73 match TextureAtlas::new(device, 2048, TextureFormat::Rgba8Unorm, "ui_image_atlas") {
74 Ok(atlas) => {
75 *guard = Some(atlas);
76 true
77 }
78 Err(e) => {
79 log::error!("UiImageAtlas: failed to allocate GPU atlas: {:?}", e);
80 false
81 }
82 }
83 }
84
85 /// Locks the GPU atlas for the duration of a frame's upload + render.
86 ///
87 /// Returns the locked `MutexGuard<Option<TextureAtlas>>`; callers
88 /// dereference twice (`guard.as_mut()`) to reach the optional
89 /// `&mut TextureAtlas`. The guard is held for the lifetime of the
90 /// caller's local — typically `UiAgent::execute` for one frame.
91 pub fn lock_atlas(&self) -> Option<MutexGuard<'_, Option<TextureAtlas>>> {
92 match self.atlas.lock() {
93 Ok(g) => Some(g),
94 Err(e) => {
95 log::error!("UiImageAtlas: mutex poisoned: {}", e);
96 None
97 }
98 }
99 }
100
101 /// Returns the `AtlasRect` previously stored for `id`, if any.
102 pub fn get_rect(&self, id: &AssetUUID) -> Option<AtlasRect> {
103 self.cache.read().ok().and_then(|m| m.get(id).copied())
104 }
105
106 /// Stores `rect` under `id`.
107 pub fn insert_rect(&self, id: AssetUUID, rect: AtlasRect) {
108 if let Ok(mut m) = self.cache.write() {
109 m.insert(id, rect);
110 }
111 }
112
113 /// Reports whether the GPU atlas has been initialized.
114 pub fn has_atlas(&self) -> bool {
115 self.atlas.lock().ok().is_some_and(|g| g.is_some())
116 }
117
118 /// Returns the number of cache entries.
119 pub fn cache_len(&self) -> usize {
120 self.cache.read().map(|m| m.len()).unwrap_or(0)
121 }
122}
123
124impl Default for UiImageAtlas {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn empty_atlas_has_no_gpu_resource() {
136 let atlas = UiImageAtlas::new();
137 assert!(!atlas.has_atlas());
138 assert_eq!(atlas.cache_len(), 0);
139 }
140
141 #[test]
142 fn cache_insert_and_get() {
143 let atlas = UiImageAtlas::new();
144 let id = AssetUUID::new_v5("test");
145 let rect = AtlasRect::default();
146 atlas.insert_rect(id, rect);
147 assert_eq!(atlas.get_rect(&id), Some(rect));
148 assert_eq!(atlas.cache_len(), 1);
149 }
150
151 #[test]
152 fn missing_returns_none() {
153 let atlas = UiImageAtlas::new();
154 assert!(atlas.get_rect(&AssetUUID::new_v5("absent")).is_none());
155 }
156}