ailloli_ui_openxr/openxr_runtime/external_host.rs
1//! Reusable UI, input, swapchain, and ray composition for an external frame loop.
2
3use ailloli_ui_core::math::Scale;
4use ailloli_ui_core::{Color, Size};
5use ailloli_ui_render_vulkan::{VulkanRendererOptions, VulkanRendererStats};
6use ailloli_ui_runtime::app::RuntimeHandle;
7use ailloli_ui_runtime::component::IntoView;
8use openxr as xr;
9
10use super::composer::{OpenXrQuadComposer, OpenXrQuadLayerOptions};
11use super::error::OpenXrRuntimeError;
12use super::input::{
13 OpenXrActionInput, OpenXrActionInputFrame, OpenXrInputCapabilities, OpenXrUiInputOptions,
14};
15use super::ray_overlay::{OpenXrRayOverlay, OpenXrRayOverlayOptions};
16use super::session_loop::combine_render_release;
17use super::swapchain::OpenXrQuadSwapchain;
18use super::ui_layer::{OpenXrExternalHostFrame, OpenXrExternalVulkanContext, OpenXrUiLayer};
19
20#[derive(Clone, Copy)]
21/// Enables and configures the optional controller/hand ray layer.
22///
23/// # Examples
24///
25/// ```
26/// use ailloli_ui_openxr::OpenXrExternalUiHostRayOptions;
27/// assert!(OpenXrExternalUiHostRayOptions::default().enabled);
28/// ```
29pub struct OpenXrExternalUiHostRayOptions {
30 /// Whether to allocate and submit the ray overlay.
31 pub enabled: bool,
32 /// Ray texture and quad geometry options.
33 pub overlay: OpenXrRayOverlayOptions,
34}
35
36impl Default for OpenXrExternalUiHostRayOptions {
37 fn default() -> Self {
38 Self {
39 enabled: true,
40 overlay: OpenXrRayOverlayOptions::default(),
41 }
42 }
43}
44
45#[derive(Clone, Copy)]
46/// Complete configuration for the externally driven UI host.
47///
48/// Defaults match the built-in smoke host: 1024x576 physical pixels, DPR 1,
49/// blue clear, enabled input, and an enabled ray overlay.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_openxr::OpenXrExternalUiHostOptions;
55/// let options = OpenXrExternalUiHostOptions::default();
56/// assert_eq!((options.pixel_width, options.pixel_height), (1024, 576));
57/// assert!(options.ray.enabled);
58/// ```
59pub struct OpenXrExternalUiHostOptions {
60 /// UI swapchain width in physical pixels.
61 pub pixel_width: u32,
62 /// UI swapchain height in physical pixels.
63 pub pixel_height: u32,
64 /// Vulkan clear color.
65 pub clear: Color,
66 /// Logical-to-physical UI scale.
67 pub scale: Scale,
68 /// UI quad pose and physical dimensions.
69 pub layer: OpenXrQuadLayerOptions,
70 /// Vulkan UI renderer options.
71 pub renderer: VulkanRendererOptions,
72 /// OpenXR input-source configuration.
73 pub input: OpenXrUiInputOptions,
74 /// Optional ray-overlay configuration.
75 pub ray: OpenXrExternalUiHostRayOptions,
76}
77
78impl Default for OpenXrExternalUiHostOptions {
79 fn default() -> Self {
80 Self {
81 pixel_width: 1024,
82 pixel_height: 576,
83 clear: Color::f32(0.05, 0.20, 0.80, 1.0),
84 scale: Scale::new(1.0),
85 layer: OpenXrQuadLayerOptions::default(),
86 renderer: VulkanRendererOptions::default(),
87 input: OpenXrUiInputOptions::default(),
88 ray: OpenXrExternalUiHostRayOptions::default(),
89 }
90 }
91}
92
93/// Frame inputs where OpenXR handles and Vulkan context share one lifetime.
94///
95/// # Examples
96///
97/// ```no_run
98/// use ailloli_ui_openxr::OpenXrExternalUiHostFrame;
99/// fn consume(_frame: OpenXrExternalUiHostFrame<'_>) {}
100/// ```
101pub type OpenXrExternalUiHostFrame<'a> = OpenXrExternalUiHostFrameParts<'a, 'a>;
102
103/// Borrowed host state required to render one externally driven frame.
104///
105/// `frame_time_ms` defaults to zero and `input_focused` defaults to true.
106/// The context lifetime may be shorter than the OpenXR handle lifetime.
107///
108/// # Examples
109///
110/// ```no_run
111/// use ailloli_ui_openxr::{OpenXrExternalUiHostFrameParts, OpenXrExternalVulkanContext};
112/// fn make<'a>(instance: &'a openxr::Instance, session: &'a openxr::Session<openxr::Vulkan>, space: &'a openxr::Space, context: OpenXrExternalVulkanContext<'a>, time: openxr::Time) -> OpenXrExternalUiHostFrameParts<'a, 'a> {
113/// OpenXrExternalUiHostFrameParts::new(instance, session, space, context, time)
114/// }
115/// ```
116pub struct OpenXrExternalUiHostFrameParts<'a, 'ctx> {
117 /// Instance used for action-profile and hand-extension calls.
118 pub instance: &'a xr::Instance,
119 /// Running Vulkan-backed session.
120 pub session: &'a xr::Session<xr::Vulkan>,
121 /// Space in which UI layer and input rays are expressed.
122 pub reference_space: &'a xr::Space,
123 /// Borrowed Vulkan device, queue, pool, and optional memory properties.
124 pub context: OpenXrExternalVulkanContext<'ctx>,
125 /// Runtime-predicted display time used to locate input.
126 pub predicted_display_time: xr::Time,
127 /// Host frame interval in milliseconds; zero is allowed.
128 pub frame_time_ms: u128,
129 /// Whether input is accepted; false clears retained UI and action state.
130 pub input_focused: bool,
131}
132
133impl<'a, 'ctx> OpenXrExternalUiHostFrameParts<'a, 'ctx> {
134 /// Creates frame parts with zero frame time and focused input.
135 ///
136 /// # Examples
137 ///
138 /// ```no_run
139 /// use ailloli_ui_openxr::{OpenXrExternalUiHostFrameParts, OpenXrExternalVulkanContext};
140 /// fn make<'a, 'ctx>(instance: &'a openxr::Instance, session: &'a openxr::Session<openxr::Vulkan>, space: &'a openxr::Space, context: OpenXrExternalVulkanContext<'ctx>, time: openxr::Time) -> OpenXrExternalUiHostFrameParts<'a, 'ctx> {
141 /// OpenXrExternalUiHostFrameParts::new(instance, session, space, context, time)
142 /// }
143 /// ```
144 pub fn new(
145 instance: &'a xr::Instance,
146 session: &'a xr::Session<xr::Vulkan>,
147 reference_space: &'a xr::Space,
148 context: OpenXrExternalVulkanContext<'ctx>,
149 predicted_display_time: xr::Time,
150 ) -> Self {
151 Self {
152 instance,
153 session,
154 reference_space,
155 context,
156 predicted_display_time,
157 frame_time_ms: 0,
158 input_focused: true,
159 }
160 }
161
162 /// Sets the UI runtime frame time in milliseconds.
163 ///
164 /// # Examples
165 ///
166 /// ```no_run
167 /// use ailloli_ui_openxr::OpenXrExternalUiHostFrameParts;
168 /// fn timed<'a, 'ctx>(frame: OpenXrExternalUiHostFrameParts<'a, 'ctx>) -> OpenXrExternalUiHostFrameParts<'a, 'ctx> { frame.with_frame_time_ms(16) }
169 /// ```
170 pub fn with_frame_time_ms(mut self, frame_time_ms: u128) -> Self {
171 self.frame_time_ms = frame_time_ms;
172 self
173 }
174
175 /// Sets whether this frame may poll and route input.
176 ///
177 /// # Examples
178 ///
179 /// ```no_run
180 /// use ailloli_ui_openxr::OpenXrExternalUiHostFrameParts;
181 /// fn unfocused<'a, 'ctx>(frame: OpenXrExternalUiHostFrameParts<'a, 'ctx>) -> OpenXrExternalUiHostFrameParts<'a, 'ctx> { frame.with_input_focused(false) }
182 /// ```
183 pub fn with_input_focused(mut self, input_focused: bool) -> Self {
184 self.input_focused = input_focused;
185 self
186 }
187}
188
189/// Rendered UI outputs and borrowed layers ready for `end_frame`.
190///
191/// The layer values borrow the host's swapchains; consume them before mutably
192/// borrowing the host for another frame.
193///
194/// # Examples
195///
196/// ```no_run
197/// use ailloli_ui_openxr::OpenXrExternalUiHostFrameResult;
198/// fn action_count<A>(result: &OpenXrExternalUiHostFrameResult<'_, A>) -> usize { result.actions.len() }
199/// ```
200pub struct OpenXrExternalUiHostFrameResult<'a, A> {
201 /// Renderer counters for the completed UI frame.
202 pub stats: VulkanRendererStats,
203 /// Runtime actions drained after processing the frame.
204 pub actions: Vec<A>,
205 /// Selected pointer, optional ray, and source metadata.
206 pub input: OpenXrActionInputFrame,
207 /// UI quad composition layer; always present after success.
208 pub ui_layer: xr::CompositionLayerQuad<'a, xr::Vulkan>,
209 /// Ray composition layer when overlay and a selected ray are present.
210 pub ray_layer: Option<xr::CompositionLayerQuad<'a, xr::Vulkan>>,
211}
212
213impl<A> OpenXrExternalUiHostFrameResult<'_, A> {
214 /// Returns UI then optional ray as erased composition-layer references.
215 ///
216 /// The vector length is one without a ray and two with a ray. This allocates
217 /// one small vector per call.
218 ///
219 /// # Examples
220 ///
221 /// ```no_run
222 /// use ailloli_ui_openxr::OpenXrExternalUiHostFrameResult;
223 /// fn layers<A>(result: &OpenXrExternalUiHostFrameResult<'_, A>) -> usize { result.layer_refs().len() }
224 /// ```
225 pub fn layer_refs(&self) -> Vec<&xr::CompositionLayerBase<'_, xr::Vulkan>> {
226 let mut layers: Vec<&xr::CompositionLayerBase<'_, xr::Vulkan>> =
227 Vec::with_capacity(external_host_layer_ref_count(self.ray_layer.is_some()));
228 let ui_layer: &xr::CompositionLayerBase<'_, xr::Vulkan> = &self.ui_layer;
229 layers.push(ui_layer);
230 if let Some(ray_layer) = self.ray_layer.as_ref() {
231 let ray_layer: &xr::CompositionLayerBase<'_, xr::Vulkan> = ray_layer;
232 layers.push(ray_layer);
233 }
234 layers
235 }
236}
237
238/// Reusable renderer/input compositor driven by an application-owned XR loop.
239///
240/// It owns UI and optional ray swapchains but does not wait, begin, or end XR
241/// frames. Callers provide predicted time and submit returned layer references.
242///
243/// # Examples
244///
245/// ```no_run
246/// use ailloli_ui_openxr::OpenXrExternalUiHost;
247/// fn clear<A: 'static>(host: &mut OpenXrExternalUiHost<A>) { host.clear_input(); }
248/// ```
249pub struct OpenXrExternalUiHost<A> {
250 /// Optional Vulkan ray overlay, absent when the feature is disabled.
251 ray: Option<OpenXrRayOverlay>,
252 /// Provider-neutral retained UI layer and Vulkan renderer.
253 ui: OpenXrUiLayer<A>,
254 /// Optional internally managed OpenXR action input source.
255 input: Option<OpenXrActionInput>,
256 /// Quad-layer composition policy and pose smoothing state.
257 composer: OpenXrQuadComposer,
258 /// OpenXR Vulkan swapchain supplying the UI quad texture.
259 swapchain: OpenXrQuadSwapchain,
260 /// Immutable host, UI, input, ray, and composition options.
261 options: OpenXrExternalUiHostOptions,
262}
263
264impl<A: 'static> OpenXrExternalUiHost<A> {
265 /// Allocates swapchains, renderer, input actions, and optional ray resources.
266 ///
267 /// The caller retains ownership of the instance, session, and Vulkan context.
268 /// Input actions are attached to `session` during construction.
269 ///
270 /// # Errors
271 ///
272 /// Returns swapchain, renderer, action, session-attachment, or ray-resource
273 /// initialization failures. Zero pixel extents are rejected.
274 ///
275 /// # Examples
276 ///
277 /// ```no_run
278 /// use ailloli_ui_openxr::{OpenXrExternalUiHost, OpenXrExternalUiHostOptions, OpenXrExternalVulkanContext, OpenXrInputCapabilities, OpenXrRuntimeError};
279 /// use ailloli_ui_runtime::{app::RuntimeHandle, component::IntoView};
280 /// fn build<A: 'static>(instance: &openxr::Instance, session: &openxr::Session<openxr::Vulkan>, context: OpenXrExternalVulkanContext<'_>, handle: RuntimeHandle<A>, root: impl IntoView<A>) -> Result<OpenXrExternalUiHost<A>, OpenXrRuntimeError> {
281 /// OpenXrExternalUiHost::new(instance, session, context, OpenXrInputCapabilities::new(false, false), handle, root, OpenXrExternalUiHostOptions::default())
282 /// }
283 /// ```
284 pub fn new(
285 instance: &xr::Instance,
286 session: &xr::Session<xr::Vulkan>,
287 context: OpenXrExternalVulkanContext<'_>,
288 capabilities: OpenXrInputCapabilities,
289 runtime_handle: RuntimeHandle<A>,
290 root: impl IntoView<A>,
291 options: OpenXrExternalUiHostOptions,
292 ) -> Result<Self, OpenXrRuntimeError> {
293 let swapchain = OpenXrQuadSwapchain::new_with_device(
294 session,
295 context.device,
296 options.pixel_width,
297 options.pixel_height,
298 )?;
299 let composer = OpenXrQuadComposer::new(options.layer);
300 let ui = OpenXrUiLayer::new(
301 runtime_handle,
302 root,
303 super::ui_layer::OpenXrUiLayerOptions {
304 pixel_width: options.pixel_width,
305 pixel_height: options.pixel_height,
306 clear: options.clear,
307 scale: options.scale,
308 renderer: options.renderer,
309 },
310 )?;
311
312 let mut input = OpenXrActionInput::new_external(instance, capabilities, options.input)?;
313 if let Some(input) = input.as_mut() {
314 input.attach_session(session)?;
315 }
316
317 let ray = if options.ray.enabled {
318 Some(OpenXrRayOverlay::new(
319 session,
320 context,
321 options.ray.overlay,
322 )?)
323 } else {
324 None
325 };
326
327 Ok(Self {
328 ray,
329 ui,
330 input,
331 composer,
332 swapchain,
333 options,
334 })
335 }
336
337 /// Polls input, renders UI, releases the acquired image, and builds layers.
338 ///
339 /// When input is unfocused, retained pointer state is cleared and the frame
340 /// contains no input. UI image release is attempted even when rendering
341 /// fails; if both fail, the render error takes precedence.
342 ///
343 /// # Errors
344 ///
345 /// Returns input polling, ray upload, swapchain, rendering, or release errors.
346 ///
347 /// # Examples
348 ///
349 /// ```no_run
350 /// use ailloli_ui_openxr::{OpenXrExternalUiHost, OpenXrExternalUiHostFrameParts, OpenXrExternalUiHostFrameResult, OpenXrRuntimeError};
351 /// fn render<'a, A: 'static>(host: &'a mut OpenXrExternalUiHost<A>, frame: OpenXrExternalUiHostFrameParts<'a, '_>) -> Result<OpenXrExternalUiHostFrameResult<'a, A>, OpenXrRuntimeError> {
352 /// host.render_frame(frame)
353 /// }
354 /// ```
355 pub fn render_frame<'a>(
356 &'a mut self,
357 frame: OpenXrExternalUiHostFrameParts<'a, '_>,
358 ) -> Result<OpenXrExternalUiHostFrameResult<'a, A>, OpenXrRuntimeError> {
359 let logical_size = self.logical_size();
360 let input = if frame.input_focused {
361 match self.input.as_mut() {
362 Some(input) => input.poll_frame(
363 frame.instance,
364 frame.session,
365 frame.reference_space,
366 self.options.layer,
367 frame.predicted_display_time,
368 logical_size,
369 )?,
370 None => OpenXrActionInputFrame::empty(),
371 }
372 } else {
373 self.clear_input();
374 OpenXrActionInputFrame::empty()
375 };
376
377 if let (Some(ray), Some(sample)) = (self.ray.as_mut(), input.ray_sample) {
378 ray.ensure_texture(frame.context, sample.hit_kind)?;
379 }
380
381 let acquired = self.swapchain.acquire_wait()?;
382 let render_result = {
383 let target = self.swapchain.frame_target(&acquired);
384 let host_frame = OpenXrExternalHostFrame::new(frame.context, target)
385 .with_pointer_frame(Some(&input.pointer_frame))
386 .with_frame_time_ms(frame.frame_time_ms);
387 self.ui.layout_paint_render(host_frame).map(|_| ())
388 };
389 let release_result = self.swapchain.release();
390 combine_render_release(render_result, release_result)?;
391
392 let stats = self.ui.renderer_stats();
393 let actions = self.ui.take_actions();
394 let ui_layer = self
395 .composer
396 .build_layer(frame.reference_space, &self.swapchain);
397 let ray_layer = input.ray_sample.and_then(|sample| {
398 self.ray
399 .as_ref()
400 .and_then(|ray| ray.build_layer(frame.reference_space, &sample))
401 });
402
403 Ok(OpenXrExternalUiHostFrameResult {
404 stats,
405 actions,
406 input,
407 ui_layer,
408 ray_layer,
409 })
410 }
411
412 /// Clears retained UI pointer state and OpenXR source locks.
413 ///
414 /// No release events are synthesized.
415 ///
416 /// # Examples
417 ///
418 /// ```no_run
419 /// use ailloli_ui_openxr::OpenXrExternalUiHost;
420 /// fn clear<A: 'static>(host: &mut OpenXrExternalUiHost<A>) { host.clear_input(); }
421 /// ```
422 pub fn clear_input(&mut self) {
423 self.ui.clear_input();
424 if let Some(input) = self.input.as_mut() {
425 input.clear();
426 }
427 }
428
429 /// Logs active left and right interaction profiles when input is enabled.
430 ///
431 /// Disabled input is a no-op.
432 ///
433 /// # Examples
434 ///
435 /// ```no_run
436 /// use ailloli_ui_openxr::OpenXrExternalUiHost;
437 /// fn log<A: 'static>(host: &OpenXrExternalUiHost<A>, instance: &openxr::Instance, session: &openxr::Session<openxr::Vulkan>) { host.log_interaction_profiles(instance, session); }
438 /// ```
439 pub fn log_interaction_profiles(
440 &self,
441 instance: &xr::Instance,
442 session: &xr::Session<xr::Vulkan>,
443 ) {
444 if let Some(input) = self.input.as_ref() {
445 input.log_interaction_profiles(instance, session);
446 }
447 }
448
449 /// Returns UI logical dimensions derived from pixel extent and DPR.
450 ///
451 /// # Examples
452 ///
453 /// ```no_run
454 /// use ailloli_ui_core::Size;
455 /// use ailloli_ui_openxr::OpenXrExternalUiHost;
456 /// fn size<A: 'static>(host: &OpenXrExternalUiHost<A>) -> Size { host.logical_size() }
457 /// ```
458 pub fn logical_size(&self) -> Size {
459 self.ui.logical_size()
460 }
461
462 /// Replaces the quad pose and geometry used for input mapping and submission.
463 ///
464 /// This does not recreate pixel swapchains or renderer resources.
465 ///
466 /// # Examples
467 ///
468 /// ```no_run
469 /// use ailloli_ui_openxr::{OpenXrExternalUiHost, OpenXrQuadLayerOptions};
470 /// fn move_panel<A: 'static>(host: &mut OpenXrExternalUiHost<A>) { host.set_layer_options(OpenXrQuadLayerOptions::default()); }
471 /// ```
472 pub fn set_layer_options(&mut self, layer: OpenXrQuadLayerOptions) {
473 self.options.layer = layer;
474 self.composer = OpenXrQuadComposer::new(layer);
475 }
476
477 /// Returns counters from the most recently completed renderer frame.
478 ///
479 /// # Examples
480 ///
481 /// ```no_run
482 /// use ailloli_ui_openxr::OpenXrExternalUiHost;
483 /// use ailloli_ui_render_vulkan::VulkanRendererStats;
484 /// fn stats<A: 'static>(host: &OpenXrExternalUiHost<A>) -> VulkanRendererStats { host.renderer_stats() }
485 /// ```
486 pub fn renderer_stats(&self) -> VulkanRendererStats {
487 self.ui.renderer_stats()
488 }
489}
490
491/// Computes the exact small-vector capacity for UI plus optional ray layer.
492fn external_host_layer_ref_count(has_ray_layer: bool) -> usize {
493 1 + usize::from(has_ray_layer)
494}
495
496#[cfg(test)]
497/// Verifies defaults, ray disabling, and layer ordering/count.
498mod tests {
499 use super::*;
500 use crate::openxr_runtime::OpenXrPointerSelectionPolicy;
501
502 #[test]
503 fn external_ui_host_options_default_keeps_smoke_defaults() {
504 let options = OpenXrExternalUiHostOptions::default();
505 assert_eq!(options.pixel_width, 1024);
506 assert_eq!(options.pixel_height, 576);
507 assert_eq!(options.clear.to_array(), [0.05, 0.20, 0.80, 1.0]);
508 assert_eq!(options.scale.dpr, 1.0);
509 assert!(options.input.enabled);
510 assert_eq!(
511 options.input.pointer_selection,
512 OpenXrPointerSelectionPolicy::PreferRightController
513 );
514 assert!(options.ray.enabled);
515 }
516
517 #[test]
518 fn external_ui_host_ray_can_be_disabled_in_options() {
519 let options = OpenXrExternalUiHostOptions {
520 ray: OpenXrExternalUiHostRayOptions {
521 enabled: false,
522 ..OpenXrExternalUiHostRayOptions::default()
523 },
524 ..OpenXrExternalUiHostOptions::default()
525 };
526 assert!(!options.ray.enabled);
527 assert_eq!(
528 options.ray.overlay.texture_width,
529 OpenXrRayOverlayOptions::default().texture_width
530 );
531 }
532
533 #[test]
534 fn external_ui_host_layer_ref_count_matches_ray_presence() {
535 assert_eq!(external_host_layer_ref_count(false), 1);
536 assert_eq!(external_host_layer_ref_count(true), 2);
537 }
538}