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}