Skip to main content

khora_agents/ui_agent/
mod.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//! Defines the UiAgent — owns `LaneKind::Ui` lanes only.
16//!
17//! Per CLAD, an Agent owns exactly one `LaneKind` and stores **only** its
18//! own GORNA/strategy state.  All shared services (graphics device, render
19//! system, fonts, text renderer, texture assets, the per-frame `UiScene`,
20//! the UI image atlas) are looked up from the engine
21//! [`Runtime`](khora_core::Runtime) each frame.
22//!
23//! The persistent `AssetUUID → AtlasRect` cache and the GPU
24//! [`TextureAtlas`](khora_core::renderer::api::util::TextureAtlas) used
25//! to live on this agent as `image_cache` and `image_atlas` fields. They
26//! now both live in [`UiImageAtlas`](khora_data::ui::UiImageAtlas) (a
27//! `Resource`). The agent owns **no** GPU state and no buffered output.
28
29use std::any::Any;
30use std::sync::{Arc, RwLock};
31use std::time::Duration;
32
33use khora_core::agent::{Agent, AgentAccess, AgentImportance, ExecutionPhase, ExecutionTiming};
34use khora_core::context::EngineContext;
35use khora_core::control::gorna::{
36    AgentId, AgentStatus, NegotiationRequest, NegotiationResponse, ResourceBudget, StrategyId,
37    StrategyOption,
38};
39use khora_core::lane::Ref;
40use khora_core::lane::{ColorTarget, Lane, LaneContext, Slot};
41use khora_core::renderer::api::core::FrameContext;
42use khora_core::renderer::api::text::TextRenderer;
43use khora_core::renderer::GraphicsDevice;
44use khora_data::assets::Assets;
45use khora_data::render::{PassContribution, PassDescriptor, ResourceId, UiPassSlot};
46use khora_data::ui::{UiAtlasMap, UiImageAtlas, UiScene};
47use khora_lanes::render_lane::UiRenderLane;
48
49/// The agent responsible for the UI subsystem (`LaneKind::Ui`).
50///
51/// Holds **only** its own GORNA / strategy state. The GPU texture atlas
52/// and the persistent `AssetUUID → AtlasRect` cache both live in the
53/// [`UiImageAtlas`](khora_data::ui::UiImageAtlas) Resource. Every other
54/// dependency (graphics device, render system, font cache, text renderer,
55/// per-frame `UiScene`) is looked up from `EngineContext::runtime` per
56/// frame.
57pub struct UiAgent {
58    /// UI render strategy lane.
59    render_lane: Option<Box<dyn Lane>>,
60    /// Time budget assigned by GORNA via `apply_budget`.
61    time_budget: Duration,
62    /// Current GORNA strategy ID applied via `apply_budget`.
63    current_strategy: StrategyId,
64}
65
66impl Agent for UiAgent {
67    fn id(&self) -> AgentId {
68        AgentId::Ui
69    }
70
71    /// Reads the `LaneBus` and records its own encoder, but mutates the shared
72    /// `UiImageAtlas` (GPU glyph/image uploads). It never touches the `World`,
73    /// but the shared-resource write puts it in the `SharedWorld` tier — the
74    /// scheduler runs at most one such agent per wave (alongside `Isolated`
75    /// agents), so its atlas writes never race another shared-resource writer.
76    fn access(&self) -> AgentAccess {
77        AgentAccess::SharedWorld
78    }
79
80    /// Buffers its pass into the [`UiPassSlot`] deck slot.
81    fn deck_writes(&self) -> Vec<std::any::TypeId> {
82        vec![std::any::TypeId::of::<UiPassSlot>()]
83    }
84
85    fn negotiate(&mut self, _request: NegotiationRequest) -> NegotiationResponse {
86        let strategies = vec![StrategyOption {
87            id: StrategyId::Balanced,
88            estimated_time: Duration::from_micros(500),
89            estimated_vram: 1024 * 1024,
90        }];
91
92        NegotiationResponse {
93            strategies,
94            timing_adjustment: None,
95        }
96    }
97
98    fn apply_budget(&mut self, budget: ResourceBudget) {
99        self.current_strategy = budget.strategy_id;
100        self.time_budget = budget.time_limit;
101    }
102
103    fn on_initialize(&mut self, context: &mut EngineContext<'_>) {
104        // Build the render lane and run its one-shot GPU initialization.
105        if self.render_lane.is_none() {
106            self.render_lane = Some(Box::new(UiRenderLane::new()));
107        }
108        if let (Some(lane), Some(device)) = (
109            self.render_lane.as_ref(),
110            context
111                .runtime
112                .backends
113                .get::<Arc<dyn GraphicsDevice>>()
114                .cloned(),
115        ) {
116            let mut init_ctx = LaneContext::new();
117            init_ctx.insert(device);
118            // Forward the PipelineSystem backend so the UI lane can resolve its
119            // bespoke layouts + pipeline through it.
120            if let Some(ps) = context
121                .runtime
122                .resources
123                .get::<Arc<dyn khora_core::renderer::traits::PipelineSystem>>()
124                .cloned()
125            {
126                init_ctx.insert(ps);
127            }
128            if let Err(e) = lane.on_initialize(&mut init_ctx) {
129                log::error!("UiAgent: Failed to initialize UiRenderLane: {}", e);
130            }
131        }
132
133        // Lazily allocate the GPU texture atlas inside the UiImageAtlas
134        // resource (one-shot, idempotent).
135        if let (Some(atlas_res), Some(device)) = (
136            context
137                .runtime
138                .resources
139                .get::<Arc<UiImageAtlas>>()
140                .cloned(),
141            context
142                .runtime
143                .backends
144                .get::<Arc<dyn GraphicsDevice>>()
145                .cloned(),
146        ) {
147            atlas_res.ensure_atlas(device.as_ref());
148        }
149    }
150
151    fn execute(&mut self, context: &mut EngineContext<'_>) {
152        // Look up everything from services every frame.
153        let Some(device_arc) = context.runtime.backends.get::<Arc<dyn GraphicsDevice>>() else {
154            return;
155        };
156        let device: Arc<dyn GraphicsDevice> = (*device_arc).clone();
157
158        // Read the per-frame UiScene from the LaneBus (UiFlow).
159        let Some(ui_scene): Option<&UiScene> = context.bus.get() else {
160            log::warn!("UiAgent: no UiScene in LaneBus (UiFlow not run?)");
161            return;
162        };
163
164        let Some(fctx) = context.runtime.resources.get::<Arc<FrameContext>>() else {
165            log::warn!("UiAgent: no FrameContext in services");
166            return;
167        };
168        let Some(color_target) = fctx.get::<ColorTarget>().map(|a| *a) else {
169            log::warn!("UiAgent: ColorTarget missing in FrameContext");
170            return;
171        };
172
173        let textures: Option<Arc<RwLock<Assets<khora_core::renderer::api::resource::CpuTexture>>>> =
174            context
175                .runtime
176                .resources
177                .get::<Arc<RwLock<Assets<khora_core::renderer::api::resource::CpuTexture>>>>()
178                .map(|arc| (*arc).clone());
179
180        let text_renderer: Option<Arc<dyn TextRenderer>> = context
181            .runtime
182            .backends
183            .get::<Arc<dyn TextRenderer>>()
184            .map(|arc| (*arc).clone());
185
186        // The persistent `AssetUUID → AtlasRect` cache and the GPU
187        // texture atlas both live in the `UiImageAtlas` Resource. We
188        // lock the atlas mutex once for the duration of upload + render
189        // so the same `&mut TextureAtlas` is visible to both the
190        // per-image upload loop below and the render lane via
191        // `LaneContext`.
192        let atlas_resource: Option<Arc<UiImageAtlas>> = context
193            .runtime
194            .resources
195            .get::<Arc<UiImageAtlas>>()
196            .cloned();
197        let mut atlas_guard = atlas_resource.as_ref().and_then(|r| r.lock_atlas());
198
199        // Resolve per-frame atlas rects for any UI images that aren't yet
200        // in the cache. Builds an immutable per-frame `UiAtlasMap`
201        // exposed to the lane through `LaneContext` (no in-place
202        // mutation of the bus-published `UiScene`).
203        let mut atlas_map = UiAtlasMap::new();
204        if let (Some(guard), Some(textures), Some(res)) = (
205            atlas_guard.as_mut(),
206            textures.as_ref(),
207            atlas_resource.as_ref(),
208        ) {
209            if let Some(atlas) = guard.as_mut() {
210                for node in ui_scene.nodes.iter() {
211                    let Some(image) = node.image else { continue };
212                    if let Some(rect) = res.get_rect(&image.texture) {
213                        atlas_map.insert(image.texture, rect);
214                        continue;
215                    }
216                    if let Ok(assets) = textures.read() {
217                        if let Some(cpu_tex) = assets.get(&image.texture) {
218                            if let Some(rect) = atlas.allocate_and_upload(
219                                device.as_ref(),
220                                cpu_tex.size.width,
221                                cpu_tex.size.height,
222                                &cpu_tex.pixels,
223                                cpu_tex.format.bytes_per_pixel(),
224                            ) {
225                                res.insert_rect(image.texture, rect);
226                                atlas_map.insert(image.texture, rect);
227                            }
228                        }
229                    }
230                }
231            }
232        }
233
234        // Run the render lane into a fresh command buffer; the FrameGraph
235        // submits it after the scene pass.
236        let Some(lane) = self.render_lane.as_ref() else {
237            return;
238        };
239
240        let mut encoder = device.create_command_encoder(Some("Khora UI Encoder"));
241        {
242            let mut ctx = LaneContext::new();
243            ctx.insert(device.clone());
244            if let Some(tr) = &text_renderer {
245                ctx.insert(tr.clone());
246            }
247            // The atlas reference threaded into LaneContext is borrowed
248            // from `atlas_guard` (the MutexGuard locked above for the
249            // duration of this frame's UI work).
250            if let Some(guard) = atlas_guard.as_mut() {
251                if let Some(atlas) = guard.as_mut() {
252                    ctx.insert(Slot::new(atlas));
253                }
254            }
255            // SAFETY: ui_scene is borrowed from the LaneBus, alive for the
256            // full frame; ctx (which holds the Ref) is dropped well before.
257            ctx.insert(Ref::new(ui_scene));
258            ctx.insert(Ref::new(&atlas_map));
259
260            // SAFETY: encoder is alive for this block; ctx is dropped before
261            // encoder.finish() consumes it.
262            let encoder_slot = Slot::new(encoder.as_mut());
263            ctx.insert(unsafe {
264                std::mem::transmute::<
265                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
266                    Slot<dyn khora_core::renderer::traits::CommandEncoder>,
267                >(encoder_slot)
268            });
269            ctx.insert(color_target);
270
271            if let Err(e) = lane.execute(&mut ctx) {
272                log::error!("UiAgent: UiRenderLane execution failed: {}", e);
273            }
274        }
275        // Drop the LaneContext (with its Slot<TextureAtlas>) before
276        // releasing `atlas_guard` so the borrow chain unwinds cleanly.
277        drop(atlas_guard);
278        let Some(cmd_buf) = encoder.finish() else {
279            log::error!("UiAgent: encoder.finish() returned None — skipping UiPass submission");
280            return;
281        };
282
283        // Buffer the pass into the deck; the engine folds it into the FrameGraph
284        // (after the scene pass, before overlay) once the wave completes.
285        context.deck.slot::<UiPassSlot>().0 = Some(PassContribution {
286            descriptor: PassDescriptor::new("UiPass")
287                .reads(ResourceId::Color)
288                .writes(ResourceId::Color),
289            command_buffer: cmd_buf,
290        });
291    }
292
293    fn report_status(&self) -> AgentStatus {
294        AgentStatus {
295            agent_id: self.id(),
296            health_score: 1.0,
297            current_strategy: self.current_strategy,
298            is_stalled: false,
299            message: format!("strategy={:?}", self.current_strategy),
300        }
301    }
302
303    fn as_any(&self) -> &dyn Any {
304        self
305    }
306
307    fn as_any_mut(&mut self) -> &mut dyn Any {
308        self
309    }
310
311    fn execution_timing(&self) -> ExecutionTiming {
312        ExecutionTiming {
313            allowed_phases: vec![ExecutionPhase::OUTPUT],
314            default_phase: ExecutionPhase::OUTPUT,
315            priority: 0.8,
316            importance: AgentImportance::Important,
317            fixed_timestep: None,
318            dependencies: Vec::new(),
319        }
320    }
321}
322
323impl Default for UiAgent {
324    fn default() -> Self {
325        Self {
326            render_lane: None,
327            time_budget: Duration::ZERO,
328            current_strategy: StrategyId::Balanced,
329        }
330    }
331}