ailloli_ui_openxr/openxr_runtime/
ray_overlay.rs

1//! Vulkan-backed, alpha-blended ray visualization as an OpenXR quad layer.
2
3use ash::vk::{self, Handle};
4use openxr as xr;
5
6use crate::math::Vec3;
7
8use super::error::OpenXrRuntimeError;
9use super::swapchain::{select_swapchain_format, OpenXrSwapchainFormat};
10use super::ui_layer::OpenXrExternalVulkanContext;
11
12/// Default ray texture width in pixels.
13///
14/// # Examples
15///
16/// ```
17/// assert_eq!(ailloli_ui_openxr::OPENXR_RAY_TEXTURE_WIDTH, 16);
18/// ```
19pub const OPENXR_RAY_TEXTURE_WIDTH: u32 = 16;
20/// Default ray texture height in pixels.
21///
22/// # Examples
23///
24/// ```
25/// assert_eq!(ailloli_ui_openxr::OPENXR_RAY_TEXTURE_HEIGHT, 256);
26/// ```
27pub const OPENXR_RAY_TEXTURE_HEIGHT: u32 = 256;
28/// Default physical ray-layer width in metres.
29///
30/// # Examples
31///
32/// ```
33/// assert_eq!(ailloli_ui_openxr::OPENXR_RAY_WIDTH_METERS, 0.003);
34/// ```
35pub const OPENXR_RAY_WIDTH_METERS: f32 = 0.003;
36/// Default minimum displayed ray length in metres.
37///
38/// # Examples
39///
40/// ```
41/// assert_eq!(ailloli_ui_openxr::OPENXR_RAY_MIN_LENGTH_METERS, 0.05);
42/// ```
43pub const OPENXR_RAY_MIN_LENGTH_METERS: f32 = 0.05;
44/// Default maximum displayed ray length in metres.
45///
46/// # Examples
47///
48/// ```
49/// assert_eq!(ailloli_ui_openxr::OPENXR_RAY_MAX_LENGTH_METERS, 3.0);
50/// ```
51pub const OPENXR_RAY_MAX_LENGTH_METERS: f32 = 3.0;
52
53/// Error-context label for ray staging allocations.
54const RAY_STAGING_USAGE: &str = "OpenXR ray overlay";
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57/// Whether the selected ray intersects the UI panel.
58///
59/// # Examples
60///
61/// ```
62/// use ailloli_ui_openxr::OpenXrRayHitKind;
63/// assert_ne!(OpenXrRayHitKind::Miss, OpenXrRayHitKind::Ui);
64/// ```
65pub enum OpenXrRayHitKind {
66    /// Ray does not hit the UI and is rendered translucent gray.
67    Miss,
68    /// Ray hits the UI and is rendered green.
69    Ui,
70}
71
72#[derive(Debug, Clone, Copy)]
73/// World-space ray selected for optional visualization.
74///
75/// Origin and distance use metres. Direction is normalized when building the
76/// layer, with negative Z fallback for a near-zero vector.
77///
78/// # Examples
79///
80/// ```
81/// use ailloli_ui_openxr::{OpenXrRayHitKind, OpenXrRaySample};
82/// use ailloli_ui_openxr::math::Vec3;
83/// let sample = OpenXrRaySample::new(Vec3::default(), Vec3::new(0.0, 0.0, -1.0), OpenXrRayHitKind::Ui, 1.5);
84/// assert_eq!(sample.hit_distance, 1.5);
85/// ```
86pub struct OpenXrRaySample {
87    /// Ray origin in world metres.
88    pub origin: Vec3,
89    /// World-space ray direction.
90    pub direction: Vec3,
91    /// Miss/UI color classification.
92    pub hit_kind: OpenXrRayHitKind,
93    /// Unclamped hit or display distance in metres.
94    pub hit_distance: f32,
95}
96
97impl OpenXrRaySample {
98    /// Creates a ray sample without normalizing or clamping values.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use ailloli_ui_openxr::{OpenXrRayHitKind, OpenXrRaySample};
104    /// use ailloli_ui_openxr::math::Vec3;
105    /// let sample = OpenXrRaySample::new(Vec3::new(1.0, 2.0, 3.0), Vec3::default(), OpenXrRayHitKind::Miss, 99.0);
106    /// assert_eq!(sample.origin.x, 1.0);
107    /// ```
108    pub fn new(
109        origin: Vec3,
110        direction: Vec3,
111        hit_kind: OpenXrRayHitKind,
112        hit_distance: f32,
113    ) -> Self {
114        Self {
115            origin,
116            direction,
117            hit_kind,
118            hit_distance,
119        }
120    }
121}
122
123#[derive(Debug, Clone, Copy)]
124/// Texture, physical geometry, visibility, and length limits for a ray layer.
125///
126/// Texture dimensions are clamped to at least one during overlay construction.
127/// Effective minimum length is at least 1 mm and maximum is at least minimum.
128///
129/// # Examples
130///
131/// ```
132/// use ailloli_ui_openxr::OpenXrRayOverlayOptions;
133/// let options = OpenXrRayOverlayOptions::default();
134/// assert_eq!((options.texture_width, options.texture_height), (16, 256));
135/// assert_eq!((options.min_length_m, options.max_length_m), (0.05, 3.0));
136/// ```
137pub struct OpenXrRayOverlayOptions {
138    /// Generated texture width in pixels.
139    pub texture_width: u32,
140    /// Generated texture height in pixels.
141    pub texture_height: u32,
142    /// Submitted quad width in metres.
143    pub ray_width_m: f32,
144    /// Minimum submitted quad length in metres.
145    pub min_length_m: f32,
146    /// Maximum submitted quad length in metres.
147    pub max_length_m: f32,
148    /// Eyes to which the ray is visible.
149    pub eye_visibility: xr::EyeVisibility,
150    /// Composition flags; defaults enable unpremultiplied source alpha.
151    pub layer_flags: xr::CompositionLayerFlags,
152}
153
154impl Default for OpenXrRayOverlayOptions {
155    fn default() -> Self {
156        Self {
157            texture_width: OPENXR_RAY_TEXTURE_WIDTH,
158            texture_height: OPENXR_RAY_TEXTURE_HEIGHT,
159            ray_width_m: OPENXR_RAY_WIDTH_METERS,
160            min_length_m: OPENXR_RAY_MIN_LENGTH_METERS,
161            max_length_m: OPENXR_RAY_MAX_LENGTH_METERS,
162            eye_visibility: xr::EyeVisibility::BOTH,
163            layer_flags: xr::CompositionLayerFlags::BLEND_TEXTURE_SOURCE_ALPHA
164                | xr::CompositionLayerFlags::UNPREMULTIPLIED_ALPHA,
165        }
166    }
167}
168
169/// Persistently mapped host-visible upload buffer for generated ray pixels.
170struct RayStagingBuffer {
171    /// Host-visible Vulkan buffer used as the texture upload source.
172    buffer: vk::Buffer,
173    /// Device allocation bound to `buffer`.
174    memory: vk::DeviceMemory,
175    /// Persistently mapped start address valid for the allocation lifetime.
176    mapped_ptr: *mut u8,
177    /// Allocation capacity in bytes.
178    size: vk::DeviceSize,
179}
180
181/// Swapchain and upload resources for a standalone ray composition layer.
182///
183/// Texture pixels are regenerated only when hit classification changes. Drop
184/// waits for the owned Vulkan device to become idle before unmapping and freeing
185/// staging resources.
186///
187/// # Examples
188///
189/// ```no_run
190/// use ailloli_ui_openxr::OpenXrRayOverlay;
191/// fn update(overlay: &mut OpenXrRayOverlay) { let _ = overlay; }
192/// ```
193pub struct OpenXrRayOverlay {
194    /// Vulkan device owning buffer, image, and synchronization resources.
195    device: ash::Device,
196    /// OpenXR Vulkan swapchain containing overlay images.
197    handle: xr::Swapchain<xr::Vulkan>,
198    /// Runtime-selected Vulkan image format.
199    format: OpenXrSwapchainFormat,
200    /// Borrowed-from-runtime swapchain images in stable index order.
201    images: Vec<vk::Image>,
202    /// Persistently mapped CPU-to-GPU upload buffer.
203    staging: RayStagingBuffer,
204    /// Image containing uploaded pixels and ready for layer submission.
205    ready_image_index: Option<u32>,
206    /// Ray-hit category represented by the currently uploaded texture.
207    uploaded_hit: Option<OpenXrRayHitKind>,
208    /// Whether at least one valid texture upload has completed.
209    has_texture: bool,
210    /// Texture extent, quad size, pose, color, and style policy.
211    options: OpenXrRayOverlayOptions,
212}
213
214impl OpenXrRayOverlay {
215    /// Creates the ray swapchain and persistently mapped staging buffer.
216    ///
217    /// Texture dimensions are clamped to at least one. `context` must belong to
218    /// the Vulkan device backing `session` and must include memory properties.
219    ///
220    /// # Errors
221    ///
222    /// Returns format, swapchain, image enumeration, memory-property, buffer,
223    /// allocation, binding, or mapping failures.
224    ///
225    /// # Examples
226    ///
227    /// ```no_run
228    /// use ailloli_ui_openxr::{OpenXrExternalVulkanContext, OpenXrRayOverlay, OpenXrRayOverlayOptions, OpenXrRuntimeError};
229    /// fn create(session: &openxr::Session<openxr::Vulkan>, context: OpenXrExternalVulkanContext<'_>) -> Result<OpenXrRayOverlay, OpenXrRuntimeError> {
230    ///     OpenXrRayOverlay::new(session, context, OpenXrRayOverlayOptions::default())
231    /// }
232    /// ```
233    pub fn new(
234        session: &xr::Session<xr::Vulkan>,
235        context: OpenXrExternalVulkanContext<'_>,
236        options: OpenXrRayOverlayOptions,
237    ) -> Result<Self, OpenXrRuntimeError> {
238        let texture_width = options.texture_width.max(1);
239        let texture_height = options.texture_height.max(1);
240        let format = select_swapchain_format(session)?;
241        let handle = session
242            .create_swapchain(&xr::SwapchainCreateInfo {
243                create_flags: xr::SwapchainCreateFlags::EMPTY,
244                usage_flags: xr::SwapchainUsageFlags::TRANSFER_DST
245                    | xr::SwapchainUsageFlags::SAMPLED,
246                format: format.xr,
247                sample_count: 1,
248                width: texture_width,
249                height: texture_height,
250                face_count: 1,
251                array_size: 1,
252                mip_count: 1,
253            })
254            .map_err(|result| OpenXrRuntimeError::CreateSwapchain {
255                usage: "ray TRANSFER_DST | SAMPLED",
256                result,
257            })?;
258        let images = handle
259            .enumerate_images()
260            .map_err(|result| OpenXrRuntimeError::EnumerateSwapchainImages { result })?
261            .into_iter()
262            .map(|image| vk::Image::from_raw(image as _))
263            .collect::<Vec<_>>();
264        let memory_properties =
265            context
266                .memory_properties
267                .ok_or(OpenXrRuntimeError::MissingVulkanMemoryProperties {
268                    usage: RAY_STAGING_USAGE,
269                })?;
270        let staging_size = (texture_width as vk::DeviceSize)
271            .saturating_mul(texture_height as vk::DeviceSize)
272            .saturating_mul(4);
273        let staging = RayStagingBuffer::new(context.device, memory_properties, staging_size)?;
274
275        Ok(Self {
276            device: context.device.clone(),
277            handle,
278            format,
279            images,
280            staging,
281            ready_image_index: None,
282            uploaded_hit: None,
283            has_texture: false,
284            options: OpenXrRayOverlayOptions {
285                texture_width,
286                texture_height,
287                ..options
288            },
289        })
290    }
291
292    /// Uploads the generated texture when its hit classification changed.
293    ///
294    /// Repeated calls for the already-uploaded kind are a no-op. Upload records
295    /// and synchronously waits for a one-time Vulkan queue submission.
296    ///
297    /// # Errors
298    ///
299    /// Returns acquisition, staging upload, queue, image-index, or release errors.
300    ///
301    /// # Examples
302    ///
303    /// ```no_run
304    /// use ailloli_ui_openxr::{OpenXrExternalVulkanContext, OpenXrRayHitKind, OpenXrRayOverlay, OpenXrRuntimeError};
305    /// fn upload(overlay: &mut OpenXrRayOverlay, context: OpenXrExternalVulkanContext<'_>) -> Result<(), OpenXrRuntimeError> {
306    ///     overlay.ensure_texture(context, OpenXrRayHitKind::Ui)
307    /// }
308    /// ```
309    pub fn ensure_texture(
310        &mut self,
311        context: OpenXrExternalVulkanContext<'_>,
312        hit_kind: OpenXrRayHitKind,
313    ) -> Result<(), OpenXrRuntimeError> {
314        if self.uploaded_hit == Some(hit_kind) && self.has_texture {
315            return Ok(());
316        }
317
318        let rgba = ray_rgba(hit_kind, self.options);
319        self.upload_rgba(context, &rgba, hit_kind)
320    }
321
322    /// Builds a quad layer centered along the clamped ray segment.
323    ///
324    /// Returns `None` until a texture upload succeeded. The layer borrows the
325    /// overlay swapchain and reference space and uses the sample's distance,
326    /// clamped to configured minimum/maximum metres.
327    ///
328    /// # Examples
329    ///
330    /// ```no_run
331    /// use ailloli_ui_openxr::{OpenXrRayOverlay, OpenXrRaySample};
332    /// fn build<'a>(overlay: &'a OpenXrRayOverlay, space: &'a openxr::Space, sample: &OpenXrRaySample) -> Option<openxr::CompositionLayerQuad<'a, openxr::Vulkan>> {
333    ///     overlay.build_layer(space, sample)
334    /// }
335    /// ```
336    pub fn build_layer<'a>(
337        &'a self,
338        reference_space: &'a xr::Space,
339        sample: &OpenXrRaySample,
340    ) -> Option<xr::CompositionLayerQuad<'a, xr::Vulkan>> {
341        if !self.has_texture {
342            return None;
343        }
344        let _ = self.ready_image_index?;
345
346        let length = clamp_ray_length(sample.hit_distance, self.options);
347        let direction = sample.direction.normalize_or(Vec3::new(0.0, 0.0, -1.0));
348        let mid = sample.origin + direction * (length * 0.5);
349        let rect = xr::Rect2Di {
350            offset: xr::Offset2Di { x: 0, y: 0 },
351            extent: xr::Extent2Di {
352                width: self.options.texture_width as i32,
353                height: self.options.texture_height as i32,
354            },
355        };
356
357        Some(
358            xr::CompositionLayerQuad::new()
359                .space(reference_space)
360                .eye_visibility(self.options.eye_visibility)
361                .sub_image(
362                    xr::SwapchainSubImage::new()
363                        .swapchain(&self.handle)
364                        .image_array_index(0)
365                        .image_rect(rect),
366                )
367                .pose(ray_pose(mid, direction))
368                .size(xr::Extent2Df {
369                    width: self.options.ray_width_m,
370                    height: length,
371                })
372                .layer_flags(self.options.layer_flags),
373        )
374    }
375
376    /// Copies pixels, acquires/waits an image, submits upload, then releases it.
377    ///
378    /// # Errors
379    ///
380    /// Returns the matching acquire, wait, image-index, Vulkan command, or
381    /// release [`OpenXrRuntimeError`]. Upload failure takes precedence over a
382    /// simultaneous release failure, while release is still attempted.
383    fn upload_rgba(
384        &mut self,
385        context: OpenXrExternalVulkanContext<'_>,
386        rgba: &[u8],
387        hit_kind: OpenXrRayHitKind,
388    ) -> Result<(), OpenXrRuntimeError> {
389        copy_rgba_to_staging(
390            self.staging.mapped_ptr,
391            rgba,
392            upload_swizzle(self.format.vk),
393        );
394        let image_index = self
395            .handle
396            .acquire_image()
397            .map_err(|result| OpenXrRuntimeError::AcquireSwapchainImage { result })?;
398        self.handle
399            .wait_image(xr::Duration::INFINITE)
400            .map_err(|result| OpenXrRuntimeError::WaitSwapchainImage { result })?;
401        let image = *self.images.get(image_index as usize).ok_or(
402            OpenXrRuntimeError::SwapchainImageIndexOutOfBounds {
403                index: image_index,
404                len: self.images.len(),
405            },
406        )?;
407
408        let upload_result = submit_one_time_commands(context, |command_buffer| unsafe {
409            record_rgba_upload(
410                context.device,
411                command_buffer,
412                image,
413                self.options.texture_width,
414                self.options.texture_height,
415                self.staging.buffer,
416                self.staging.size,
417            );
418        });
419        let release_result = self
420            .handle
421            .release_image()
422            .map_err(|result| OpenXrRuntimeError::ReleaseSwapchainImage { result });
423
424        upload_result?;
425        release_result?;
426
427        self.ready_image_index = Some(image_index);
428        self.uploaded_hit = Some(hit_kind);
429        self.has_texture = true;
430        Ok(())
431    }
432}
433
434impl Drop for OpenXrRayOverlay {
435    fn drop(&mut self) {
436        unsafe {
437            self.device.device_wait_idle().ok();
438            self.device.unmap_memory(self.staging.memory);
439            self.device.destroy_buffer(self.staging.buffer, None);
440            self.device.free_memory(self.staging.memory, None);
441        }
442    }
443}
444
445impl RayStagingBuffer {
446    /// Allocates, binds, and persistently maps a transfer-source buffer.
447    ///
448    /// # Errors
449    ///
450    /// Returns the matching create-buffer, missing host-visible memory type,
451    /// allocate-memory, bind-memory, or map-memory [`OpenXrRuntimeError`].
452    fn new(
453        device: &ash::Device,
454        memory_properties: &vk::PhysicalDeviceMemoryProperties,
455        size: vk::DeviceSize,
456    ) -> Result<Self, OpenXrRuntimeError> {
457        let buffer = unsafe {
458            device.create_buffer(
459                &vk::BufferCreateInfo::default()
460                    .size(size)
461                    .usage(vk::BufferUsageFlags::TRANSFER_SRC)
462                    .sharing_mode(vk::SharingMode::EXCLUSIVE),
463                None,
464            )
465        }
466        .map_err(|result| OpenXrRuntimeError::CreateStagingBuffer {
467            usage: RAY_STAGING_USAGE,
468            result,
469        })?;
470
471        let memory_requirements = unsafe { device.get_buffer_memory_requirements(buffer) };
472        let memory_type_index =
473            find_host_visible_memory_type(memory_properties, memory_requirements).ok_or(
474                OpenXrRuntimeError::NoHostVisibleMemoryType {
475                    usage: RAY_STAGING_USAGE,
476                },
477            )?;
478        let memory = unsafe {
479            device.allocate_memory(
480                &vk::MemoryAllocateInfo::default()
481                    .allocation_size(size)
482                    .memory_type_index(memory_type_index),
483                None,
484            )
485        }
486        .map_err(|result| OpenXrRuntimeError::AllocateStagingMemory {
487            usage: RAY_STAGING_USAGE,
488            result,
489        })?;
490
491        unsafe { device.bind_buffer_memory(buffer, memory, 0) }.map_err(|result| {
492            OpenXrRuntimeError::BindStagingMemory {
493                usage: RAY_STAGING_USAGE,
494                result,
495            }
496        })?;
497        let mapped_ptr = unsafe { device.map_memory(memory, 0, size, vk::MemoryMapFlags::empty()) }
498            .map_err(|result| OpenXrRuntimeError::MapStagingMemory {
499                usage: RAY_STAGING_USAGE,
500                result,
501            })?
502            .cast();
503
504        Ok(Self {
505            buffer,
506            memory,
507            mapped_ptr,
508            size,
509        })
510    }
511}
512
513/// Finds the first compatible host-visible, host-coherent memory type.
514fn find_host_visible_memory_type(
515    memory_properties: &vk::PhysicalDeviceMemoryProperties,
516    memory_requirements: vk::MemoryRequirements,
517) -> Option<u32> {
518    (0..memory_properties.memory_type_count).find(|i| {
519        let suitable = memory_requirements.memory_type_bits & (1 << i) != 0;
520        let flags = memory_properties.memory_types[*i as usize].property_flags;
521        suitable
522            && flags.contains(vk::MemoryPropertyFlags::HOST_VISIBLE)
523            && flags.contains(vk::MemoryPropertyFlags::HOST_COHERENT)
524    })
525}
526
527/// Allocates, records, submits, waits for, and frees one primary command buffer.
528///
529/// # Errors
530///
531/// Returns the matching allocate, begin, end, queue-submit, or queue-idle
532/// [`OpenXrRuntimeError`] with the original Vulkan result.
533///
534/// # Panics
535///
536/// Propagates a panic from `record`. It may also panic if Vulkan reports a
537/// successful allocation without returning the single requested command buffer.
538fn submit_one_time_commands(
539    context: OpenXrExternalVulkanContext<'_>,
540    record: impl FnOnce(vk::CommandBuffer),
541) -> Result<(), OpenXrRuntimeError> {
542    let command_buffer = unsafe {
543        context.device.allocate_command_buffers(
544            &vk::CommandBufferAllocateInfo::default()
545                .command_pool(context.command_pool)
546                .level(vk::CommandBufferLevel::PRIMARY)
547                .command_buffer_count(1),
548        )
549    }
550    .map_err(|result| OpenXrRuntimeError::AllocateCommandBuffer { result })?[0];
551
552    let result = (|| {
553        unsafe {
554            context.device.begin_command_buffer(
555                command_buffer,
556                &vk::CommandBufferBeginInfo::default()
557                    .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
558            )
559        }
560        .map_err(|result| OpenXrRuntimeError::BeginCommandBuffer { result })?;
561        record(command_buffer);
562        unsafe { context.device.end_command_buffer(command_buffer) }
563            .map_err(|result| OpenXrRuntimeError::EndCommandBuffer { result })?;
564        unsafe {
565            context.device.queue_submit(
566                context.queue,
567                &[vk::SubmitInfo::default().command_buffers(&[command_buffer])],
568                vk::Fence::null(),
569            )
570        }
571        .map_err(|result| OpenXrRuntimeError::QueueSubmit { result })?;
572        unsafe { context.device.queue_wait_idle(context.queue) }
573            .map_err(|result| OpenXrRuntimeError::QueueWaitIdle { result })?;
574        Ok(())
575    })();
576
577    unsafe {
578        context
579            .device
580            .free_command_buffers(context.command_pool, &[command_buffer]);
581    }
582    result
583}
584
585/// Records layout transitions and a complete RGBA staging-to-image copy.
586///
587/// # Safety
588///
589/// All handles must belong to `device`; the buffer must contain at least
590/// `buffer_size` valid bytes and the image must match `width` by `height`.
591unsafe fn record_rgba_upload(
592    device: &ash::Device,
593    command_buffer: vk::CommandBuffer,
594    image: vk::Image,
595    width: u32,
596    height: u32,
597    staging: vk::Buffer,
598    buffer_size: vk::DeviceSize,
599) {
600    let color_range = vk::ImageSubresourceRange {
601        aspect_mask: vk::ImageAspectFlags::COLOR,
602        base_mip_level: 0,
603        level_count: 1,
604        base_array_layer: 0,
605        layer_count: 1,
606    };
607    let barrier_to_transfer = vk::ImageMemoryBarrier::default()
608        .image(image)
609        .old_layout(vk::ImageLayout::UNDEFINED)
610        .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
611        .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
612        .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
613        .subresource_range(color_range);
614
615    device.cmd_pipeline_barrier(
616        command_buffer,
617        vk::PipelineStageFlags::TOP_OF_PIPE,
618        vk::PipelineStageFlags::TRANSFER,
619        vk::DependencyFlags::empty(),
620        &[],
621        &[],
622        &[barrier_to_transfer],
623    );
624
625    let region = vk::BufferImageCopy::default()
626        .buffer_offset(0)
627        .buffer_row_length(0)
628        .buffer_image_height(0)
629        .image_subresource(vk::ImageSubresourceLayers {
630            aspect_mask: vk::ImageAspectFlags::COLOR,
631            mip_level: 0,
632            base_array_layer: 0,
633            layer_count: 1,
634        })
635        .image_offset(vk::Offset3D::default())
636        .image_extent(vk::Extent3D {
637            width,
638            height,
639            depth: 1,
640        });
641
642    device.cmd_copy_buffer_to_image(
643        command_buffer,
644        staging,
645        image,
646        vk::ImageLayout::TRANSFER_DST_OPTIMAL,
647        &[region],
648    );
649
650    let barrier_to_shader = vk::ImageMemoryBarrier::default()
651        .image(image)
652        .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
653        .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
654        .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
655        .dst_access_mask(vk::AccessFlags::SHADER_READ)
656        .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
657        .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
658        .subresource_range(color_range);
659
660    device.cmd_pipeline_barrier(
661        command_buffer,
662        vk::PipelineStageFlags::TRANSFER,
663        vk::PipelineStageFlags::FRAGMENT_SHADER,
664        vk::DependencyFlags::empty(),
665        &[],
666        &[],
667        &[barrier_to_shader],
668    );
669
670    let _ = buffer_size;
671}
672
673#[derive(Clone, Copy)]
674/// Byte ordering required by the selected Vulkan swapchain format.
675enum RayUploadSwizzle {
676    /// Preserve RGBA source order.
677    Rgba,
678    /// Swap red and blue for BGRA destinations.
679    Bgra,
680}
681
682/// Selects BGRA channel swapping for the two supported B8 formats.
683fn upload_swizzle(format: vk::Format) -> RayUploadSwizzle {
684    match format {
685        vk::Format::B8G8R8A8_UNORM | vk::Format::B8G8R8A8_SRGB => RayUploadSwizzle::Bgra,
686        _ => RayUploadSwizzle::Rgba,
687    }
688}
689
690/// Copies generated pixels into mapped staging memory with optional R/B swap.
691///
692/// The caller guarantees `dst` is valid for `rgba.len()` writable bytes.
693fn copy_rgba_to_staging(dst: *mut u8, rgba: &[u8], swizzle: RayUploadSwizzle) {
694    match swizzle {
695        RayUploadSwizzle::Rgba => unsafe {
696            std::ptr::copy_nonoverlapping(rgba.as_ptr(), dst, rgba.len());
697        },
698        RayUploadSwizzle::Bgra => {
699            let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, rgba.len()) };
700            for (src, out) in rgba.chunks_exact(4).zip(dst_slice.chunks_exact_mut(4)) {
701                out[0] = src[2];
702                out[1] = src[1];
703                out[2] = src[0];
704                out[3] = src[3];
705            }
706        }
707    }
708}
709
710/// Clamps distance to a minimum of 1 mm and an ordered configured range.
711fn clamp_ray_length(hit_distance: f32, options: OpenXrRayOverlayOptions) -> f32 {
712    let min = options.min_length_m.max(0.001);
713    let max = options.max_length_m.max(min);
714    hit_distance.clamp(min, max)
715}
716
717/// Builds a pose at the ray midpoint with local positive Y along the ray.
718fn ray_pose(mid: Vec3, direction: Vec3) -> xr::Posef {
719    xr::Posef {
720        orientation: orientation_for_ray(direction),
721        position: xr::Vector3f {
722            x: mid.x,
723            y: mid.y,
724            z: mid.z,
725        },
726    }
727}
728
729/// Builds a roll-stable orientation whose local positive Y follows the ray.
730fn orientation_for_ray(direction: Vec3) -> xr::Quaternionf {
731    let y_axis = direction.normalize_or(Vec3::new(0.0, 0.0, -1.0));
732    let mut z_hint = Vec3::new(0.0, 1.0, 0.0);
733    if y_axis.cross(z_hint).len() < 1e-4 {
734        z_hint = Vec3::new(1.0, 0.0, 0.0);
735    }
736    let x_axis = y_axis.cross(z_hint).normalize_or(Vec3::new(1.0, 0.0, 0.0));
737    let z_axis = x_axis.cross(y_axis).normalize_or(Vec3::new(0.0, 0.0, 1.0));
738
739    rotation_matrix_to_quaternion([
740        [x_axis.x, y_axis.x, z_axis.x],
741        [x_axis.y, y_axis.y, z_axis.y],
742        [x_axis.z, y_axis.z, z_axis.z],
743    ])
744}
745
746/// Converts an orthonormal 3x3 matrix into a quaternion.
747fn rotation_matrix_to_quaternion(m: [[f32; 3]; 3]) -> xr::Quaternionf {
748    let trace = m[0][0] + m[1][1] + m[2][2];
749    if trace > 0.0 {
750        let s = (trace + 1.0).sqrt() * 2.0;
751        xr::Quaternionf {
752            w: 0.25 * s,
753            x: (m[2][1] - m[1][2]) / s,
754            y: (m[0][2] - m[2][0]) / s,
755            z: (m[1][0] - m[0][1]) / s,
756        }
757    } else if m[0][0] > m[1][1] && m[0][0] > m[2][2] {
758        let s = (1.0 + m[0][0] - m[1][1] - m[2][2]).sqrt() * 2.0;
759        xr::Quaternionf {
760            w: (m[2][1] - m[1][2]) / s,
761            x: 0.25 * s,
762            y: (m[0][1] + m[1][0]) / s,
763            z: (m[0][2] + m[2][0]) / s,
764        }
765    } else if m[1][1] > m[2][2] {
766        let s = (1.0 + m[1][1] - m[0][0] - m[2][2]).sqrt() * 2.0;
767        xr::Quaternionf {
768            w: (m[0][2] - m[2][0]) / s,
769            x: (m[0][1] + m[1][0]) / s,
770            y: 0.25 * s,
771            z: (m[1][2] + m[2][1]) / s,
772        }
773    } else {
774        let s = (1.0 + m[2][2] - m[0][0] - m[1][1]).sqrt() * 2.0;
775        xr::Quaternionf {
776            w: (m[1][0] - m[0][1]) / s,
777            x: (m[0][2] + m[2][0]) / s,
778            y: (m[1][2] + m[2][1]) / s,
779            z: 0.25 * s,
780        }
781    }
782}
783
784/// Generates edge-faded RGBA pixels, gray for miss and green for UI hit.
785fn ray_rgba(hit_kind: OpenXrRayHitKind, options: OpenXrRayOverlayOptions) -> Vec<u8> {
786    let (r, g, b, base_alpha) = match hit_kind {
787        OpenXrRayHitKind::Miss => (160u8, 160u8, 160u8, 102u8),
788        OpenXrRayHitKind::Ui => (60u8, 220u8, 90u8, 230u8),
789    };
790    let w = options.texture_width.max(1) as i32;
791    let h = options.texture_height.max(1) as i32;
792    let mut rgba = vec![0u8; (w * h * 4) as usize];
793    for y in 0..h {
794        let t = y as f32 / (h - 1).max(1) as f32;
795        let alpha = (base_alpha as f32 * (1.0 - t * 0.65)) as u8;
796        for x in 0..w {
797            let cx = (w - 1) as f32 * 0.5;
798            let dx = (x as f32 - cx).abs() / cx.max(1.0);
799            let edge = (1.0 - dx).clamp(0.0, 1.0);
800            let a = (alpha as f32 * edge) as u8;
801            let i = ((y * w + x) * 4) as usize;
802            rgba[i] = r;
803            rgba[i + 1] = g;
804            rgba[i + 2] = b;
805            rgba[i + 3] = a;
806        }
807    }
808    rgba
809}
810
811#[cfg(test)]
812/// Verifies texture size/colors/alpha, length clamping, and midpoint pose.
813mod tests {
814    use super::*;
815
816    /// Returns the first nontransparent RGBA pixel from generated ray data.
817    fn first_visible_pixel(rgba: &[u8]) -> &[u8] {
818        rgba.chunks_exact(4)
819            .find(|pixel| pixel[3] > 0)
820            .expect("visible pixel")
821    }
822
823    #[test]
824    fn ray_rgba_has_expected_len_and_alpha() {
825        let options = OpenXrRayOverlayOptions::default();
826        let rgba = ray_rgba(OpenXrRayHitKind::Ui, options);
827        assert_eq!(
828            rgba.len(),
829            (options.texture_width * options.texture_height * 4) as usize
830        );
831        assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] > 0));
832    }
833
834    #[test]
835    fn miss_ray_rgba_is_gray() {
836        let rgba = ray_rgba(OpenXrRayHitKind::Miss, OpenXrRayOverlayOptions::default());
837        let pixel = first_visible_pixel(&rgba);
838        assert_eq!(&pixel[0..3], &[160, 160, 160]);
839    }
840
841    #[test]
842    fn ui_ray_rgba_is_green() {
843        let rgba = ray_rgba(OpenXrRayHitKind::Ui, OpenXrRayOverlayOptions::default());
844        let pixel = first_visible_pixel(&rgba);
845        assert_eq!(&pixel[0..3], &[60, 220, 90]);
846    }
847
848    #[test]
849    fn ray_length_is_clamped() {
850        let options = OpenXrRayOverlayOptions::default();
851        assert_eq!(clamp_ray_length(0.001, options), options.min_length_m);
852        assert_eq!(clamp_ray_length(99.0, options), options.max_length_m);
853    }
854
855    #[test]
856    fn ray_pose_uses_midpoint_position() {
857        let pose = ray_pose(Vec3::new(0.0, 0.0, -1.5), Vec3::new(0.0, 0.0, -1.0));
858        assert!((pose.position.z + 1.5).abs() < 1e-4);
859        assert!(pose.orientation.x.is_finite());
860        assert!(pose.orientation.y.is_finite());
861        assert!(pose.orientation.z.is_finite());
862        assert!(pose.orientation.w.is_finite());
863    }
864}