Skip to main content

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}