more webxr support
This commit is contained in:
@@ -12,12 +12,12 @@ linked = ["openxr/linked"]
|
||||
vulkan = ["dep:ash"]
|
||||
|
||||
[dependencies]
|
||||
xr_api = { path = "./xr_api" }
|
||||
async-std = "1.12.0"
|
||||
bevy = "0.12.1"
|
||||
paste = "1.0.14"
|
||||
wgpu = "0.17.1"
|
||||
wgpu-hal = "0.17.1"
|
||||
winit = "0.28.7"
|
||||
|
||||
[target.'cfg(target_family = "unix")'.dependencies]
|
||||
openxr = { version = "0.17.1", features = ["mint"] }
|
||||
@@ -29,8 +29,9 @@ openxr = { version = "0.17.1", features = ["mint", "static"] }
|
||||
ash = { version = "0.37.3", optional = true }
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
wasm-bindgen = "0.2.87"
|
||||
web-sys = { version = "0.3.61", features = [
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2.91"
|
||||
web-sys = { version = "0.3.68", features = [
|
||||
# STANDARD
|
||||
'console',
|
||||
'Document',
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
//! A simple 3D scene with light shining over a cube sitting on a plane.
|
||||
|
||||
use bevy::{
|
||||
core_pipeline::clear_color::ClearColorConfig, prelude::*, render::camera::RenderTarget,
|
||||
};
|
||||
use bevy_oxr::{DefaultXrPlugins, LEFT_XR_TEXTURE_HANDLE};
|
||||
use bevy::prelude::*;
|
||||
use bevy_oxr::webxr::XrInitPlugin;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultXrPlugins)
|
||||
.add_plugins((DefaultPlugins, XrInitPlugin))
|
||||
.add_systems(Startup, setup)
|
||||
.run();
|
||||
}
|
||||
142
src/actions.rs
Normal file
142
src/actions.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
pub mod oculus_touch;
|
||||
|
||||
mod private {
|
||||
use bevy::math::Vec2;
|
||||
|
||||
use crate::types::{Haptic, Pose};
|
||||
|
||||
pub trait Sealed {}
|
||||
|
||||
impl Sealed for bool {}
|
||||
impl Sealed for f32 {}
|
||||
impl Sealed for Vec2 {}
|
||||
impl Sealed for Pose {}
|
||||
impl Sealed for Haptic {}
|
||||
}
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub trait ActionType: private::Sealed {}
|
||||
|
||||
impl<T: private::Sealed> ActionType for T {}
|
||||
|
||||
pub trait ActionPathTrait {
|
||||
type PathType: ActionType;
|
||||
fn path(&self) -> Cow<'_, str>;
|
||||
fn name(&self) -> Cow<'_, str>;
|
||||
}
|
||||
|
||||
pub struct ActionPath<T: ActionType> {
|
||||
path: &'static str,
|
||||
name: &'static str,
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: ActionType> ActionPathTrait for ActionPath<T> {
|
||||
type PathType = T;
|
||||
|
||||
fn path(&self) -> Cow<'_, str> {
|
||||
self.path.into()
|
||||
}
|
||||
|
||||
fn name(&self) -> Cow<'_, str> {
|
||||
self.name.into()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! actions {
|
||||
// create path struct
|
||||
(
|
||||
$($subpath:literal),*
|
||||
$id:ident {
|
||||
path: $path:literal;
|
||||
}
|
||||
) => {};
|
||||
|
||||
// handle action path attrs
|
||||
(
|
||||
$($subpath:literal),*
|
||||
$id:ident {
|
||||
path: $path:literal;
|
||||
name: $name:literal;
|
||||
path_type: $path_type:ty;
|
||||
}
|
||||
) => {
|
||||
paste::paste! {
|
||||
pub const [<$id:snake:upper>]: crate::actions::ActionPath<$path_type> = crate::actions::ActionPath {
|
||||
path: concat!($($subpath,)* $path),
|
||||
name: $name,
|
||||
_marker: std::marker::PhantomData,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// handle action path attrs
|
||||
(
|
||||
$($subpath:literal),*
|
||||
$id:ident {
|
||||
path: $path:literal;
|
||||
name: $name:literal;
|
||||
path_type: $path_type:ty;
|
||||
$($children:tt)*
|
||||
}
|
||||
) => {
|
||||
crate::path::actions! {
|
||||
$($subpath),*
|
||||
$id {
|
||||
path: $path;
|
||||
name: $name;
|
||||
path_type: $path_type;
|
||||
}
|
||||
}
|
||||
|
||||
crate::path::actions! {
|
||||
$($subpath),*
|
||||
$id {
|
||||
path: $path;
|
||||
$($children)*
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// handle children
|
||||
(
|
||||
$($subpath:literal),*
|
||||
$id:ident {
|
||||
path: $path:literal;
|
||||
$($children:tt)*
|
||||
}
|
||||
) => {
|
||||
pub mod $id {
|
||||
crate::actions::actions! {
|
||||
$($subpath,)* $path
|
||||
$($children)*
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// handle siblings
|
||||
(
|
||||
$($subpath:literal),*
|
||||
$id:ident {
|
||||
path: $path:literal;
|
||||
$($attrs:tt)*
|
||||
}
|
||||
$($siblings:tt)*
|
||||
) => {
|
||||
crate::actions::actions! {
|
||||
$($subpath),*
|
||||
$id {
|
||||
path: $path;
|
||||
$($attrs)*
|
||||
}
|
||||
}
|
||||
crate::actions::actions! {
|
||||
$($subpath),*
|
||||
$($siblings)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use actions;
|
||||
238
src/actions/oculus_touch.rs
Normal file
238
src/actions/oculus_touch.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
super::actions! {
|
||||
"/user"
|
||||
hand {
|
||||
path: "/hand";
|
||||
left {
|
||||
path: "/left";
|
||||
input {
|
||||
path: "/input";
|
||||
x {
|
||||
path: "/x";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "x_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "x_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
y {
|
||||
path: "/y";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "y_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "y_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
menu {
|
||||
path: "/menu";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "menu_click";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
squeeze {
|
||||
path: "/squeeze";
|
||||
value {
|
||||
path: "/value";
|
||||
name: "left_grip_val";
|
||||
path_type: f32;
|
||||
}
|
||||
}
|
||||
trigger {
|
||||
path: "/trigger";
|
||||
value {
|
||||
path: "/value";
|
||||
name: "left_trigger_val";
|
||||
path_type: f32;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "left_trigger_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
thumbstick {
|
||||
path: "/thumbstick";
|
||||
x {
|
||||
path: "/x";
|
||||
name: "left_thumbstick_x";
|
||||
path_type: f32;
|
||||
}
|
||||
y {
|
||||
path: "/y";
|
||||
name: "left_thumbstick_y";
|
||||
path_type: f32;
|
||||
}
|
||||
click {
|
||||
path: "/click";
|
||||
name: "left_thumbstick_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "left_thumbstick_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
thumbrest {
|
||||
path: "/thumbrest";
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "left_thumbrest_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
grip {
|
||||
path: "/grip";
|
||||
pose {
|
||||
path: "/pose";
|
||||
name: "left_grip_pose";
|
||||
path_type: crate::types::Pose;
|
||||
}
|
||||
}
|
||||
aim {
|
||||
path: "/aim";
|
||||
pose {
|
||||
path: "/pose";
|
||||
name: "left_aim_pose";
|
||||
path_type: crate::types::Pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
output {
|
||||
path: "/output";
|
||||
haptic {
|
||||
path: "/haptic";
|
||||
name: "left_controller_haptic";
|
||||
path_type: crate::types::Haptic;
|
||||
}
|
||||
}
|
||||
}
|
||||
right {
|
||||
path: "/right";
|
||||
input {
|
||||
path: "/input";
|
||||
a {
|
||||
path: "/a";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "a_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "a_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
b {
|
||||
path: "/b";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "b_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "b_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
system {
|
||||
path: "/system";
|
||||
click {
|
||||
path: "/click";
|
||||
name: "system_click";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
squeeze {
|
||||
path: "/squeeze";
|
||||
value {
|
||||
path: "/value";
|
||||
name: "right_grip_val";
|
||||
path_type: f32;
|
||||
}
|
||||
}
|
||||
trigger {
|
||||
path: "/trigger";
|
||||
value {
|
||||
path: "/value";
|
||||
name: "right_trigger_val";
|
||||
path_type: f32;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "right_trigger_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
thumbstick {
|
||||
path: "/thumbstick";
|
||||
x {
|
||||
path: "/x";
|
||||
name: "right_thumbstick_x";
|
||||
path_type: f32;
|
||||
}
|
||||
y {
|
||||
path: "/y";
|
||||
name: "right_thumbstick_y";
|
||||
path_type: f32;
|
||||
}
|
||||
click {
|
||||
path: "/click";
|
||||
name: "right_thumbstick_click";
|
||||
path_type: bool;
|
||||
}
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "right_thumbstick_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
thumbrest {
|
||||
path: "/thumbrest";
|
||||
touch {
|
||||
path: "/touch";
|
||||
name: "right_thumbrest_touch";
|
||||
path_type: bool;
|
||||
}
|
||||
}
|
||||
grip {
|
||||
path: "/grip";
|
||||
pose {
|
||||
path: "/pose";
|
||||
name: "right_grip_pose";
|
||||
path_type: crate::types::Pose;
|
||||
}
|
||||
}
|
||||
aim {
|
||||
path: "/aim";
|
||||
pose {
|
||||
path: "/pose";
|
||||
name: "right_aim_pose";
|
||||
path_type: crate::types::Pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
output {
|
||||
path: "/output";
|
||||
haptic {
|
||||
path: "/haptic";
|
||||
name: "right_controller_haptic";
|
||||
path_type: crate::types::Haptic;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
370
src/lib.rs
370
src/lib.rs
@@ -1,366 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::{
|
||||
app::PluginGroupBuilder,
|
||||
core_pipeline::tonemapping::{DebandDither, Tonemapping},
|
||||
math::Vec3A,
|
||||
prelude::*,
|
||||
render::{
|
||||
camera::{
|
||||
CameraProjection, CameraProjectionPlugin, CameraRenderGraph, ManualTextureView,
|
||||
ManualTextureViewHandle, ManualTextureViews, RenderTarget,
|
||||
},
|
||||
pipelined_rendering::PipelinedRenderingPlugin,
|
||||
primitives::Frustum,
|
||||
renderer::{render_system, RenderAdapter, RenderAdapterInfo, RenderInstance, RenderQueue},
|
||||
view::{ColorGrading, VisibleEntities},
|
||||
Render, RenderApp, RenderPlugin,
|
||||
},
|
||||
window::PresentMode,
|
||||
};
|
||||
use xr_api::prelude::*;
|
||||
|
||||
pub const LEFT_XR_TEXTURE_HANDLE: ManualTextureViewHandle = ManualTextureViewHandle(1208214591);
|
||||
pub const RIGHT_XR_TEXTURE_HANDLE: ManualTextureViewHandle = ManualTextureViewHandle(3383858418);
|
||||
|
||||
pub struct XrPlugin;
|
||||
|
||||
impl Plugin for XrPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let instance = Entry::new()
|
||||
.create_instance(ExtensionSet { vulkan: true })
|
||||
.unwrap();
|
||||
let session = instance
|
||||
.create_session(SessionCreateInfo {
|
||||
texture_format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let (device, queue, adapter_info, adapter, instance) =
|
||||
session.get_render_resources().unwrap();
|
||||
|
||||
let input = session.create_input(Bindings::OculusTouch).unwrap();
|
||||
|
||||
let left_primary_button = input
|
||||
.create_action(input::hand_left::PrimaryButton::CLICK)
|
||||
.unwrap();
|
||||
|
||||
let left_hand_pose = input.create_action(input::hand_left::Grip::POSE).unwrap();
|
||||
|
||||
app.insert_non_send_resource(left_primary_button);
|
||||
app.insert_non_send_resource(left_hand_pose);
|
||||
app.insert_non_send_resource(session.clone());
|
||||
app.add_plugins((
|
||||
RenderPlugin {
|
||||
render_creation: bevy::render::settings::RenderCreation::Manual(
|
||||
device.into(),
|
||||
RenderQueue(Arc::new(queue)),
|
||||
RenderAdapterInfo(adapter_info),
|
||||
RenderAdapter(Arc::new(adapter)),
|
||||
RenderInstance(Arc::new(instance)),
|
||||
),
|
||||
},
|
||||
CameraProjectionPlugin::<XRProjection>::default(),
|
||||
));
|
||||
|
||||
app.add_systems(PreUpdate, begin_frame);
|
||||
app.add_systems(Last, locate_views);
|
||||
app.add_systems(Startup, setup);
|
||||
let render_app = app.sub_app_mut(RenderApp);
|
||||
render_app.insert_non_send_resource(session);
|
||||
render_app.add_systems(Render, end_frame.after(render_system));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct XrCameraBundle {
|
||||
pub camera: Camera,
|
||||
pub camera_render_graph: CameraRenderGraph,
|
||||
pub xr_projection: PerspectiveProjection,
|
||||
pub visible_entities: VisibleEntities,
|
||||
pub frustum: Frustum,
|
||||
pub transform: Transform,
|
||||
pub global_transform: GlobalTransform,
|
||||
pub camera_3d: Camera3d,
|
||||
pub tonemapping: Tonemapping,
|
||||
pub dither: DebandDither,
|
||||
pub color_grading: ColorGrading,
|
||||
pub xr_camera_type: XrCameraType,
|
||||
}
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Component)]
|
||||
pub enum XrCameraType {
|
||||
Xr(Eye),
|
||||
Flatscreen,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
|
||||
pub enum Eye {
|
||||
Left = 0,
|
||||
Right = 1,
|
||||
}
|
||||
|
||||
impl XrCameraBundle {
|
||||
pub fn new(eye: Eye) -> Self {
|
||||
Self {
|
||||
camera: Camera {
|
||||
order: -1,
|
||||
target: RenderTarget::TextureView(match eye {
|
||||
Eye::Left => LEFT_XR_TEXTURE_HANDLE,
|
||||
Eye::Right => RIGHT_XR_TEXTURE_HANDLE,
|
||||
}),
|
||||
viewport: None,
|
||||
..default()
|
||||
},
|
||||
camera_render_graph: CameraRenderGraph::new(bevy::core_pipeline::core_3d::graph::NAME),
|
||||
xr_projection: Default::default(),
|
||||
visible_entities: Default::default(),
|
||||
frustum: Default::default(),
|
||||
transform: Default::default(),
|
||||
global_transform: Default::default(),
|
||||
camera_3d: Default::default(),
|
||||
tonemapping: Default::default(),
|
||||
dither: DebandDither::Enabled,
|
||||
color_grading: Default::default(),
|
||||
xr_camera_type: XrCameraType::Xr(eye),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Component, Reflect)]
|
||||
#[reflect(Component, Default)]
|
||||
pub struct XRProjection {
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
#[reflect(ignore)]
|
||||
pub fov: Fov,
|
||||
}
|
||||
|
||||
impl Default for XRProjection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
near: 0.1,
|
||||
far: 1000.,
|
||||
fov: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CameraProjection for XRProjection {
|
||||
// =============================================================================
|
||||
// math code adapted from
|
||||
// https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/master/src/common/xr_linear.h
|
||||
// Copyright (c) 2017 The Khronos Group Inc.
|
||||
// Copyright (c) 2016 Oculus VR, LLC.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// =============================================================================
|
||||
fn get_projection_matrix(&self) -> Mat4 {
|
||||
// symmetric perspective for debugging
|
||||
// let x_fov = (self.fov.angle_left.abs() + self.fov.angle_right.abs());
|
||||
// let y_fov = (self.fov.angle_up.abs() + self.fov.angle_down.abs());
|
||||
// return Mat4::perspective_infinite_reverse_rh(y_fov, x_fov / y_fov, self.near);
|
||||
|
||||
let fov = self.fov;
|
||||
let is_vulkan_api = false; // FIXME wgpu probably abstracts this
|
||||
let near_z = self.near;
|
||||
let far_z = -1.; // use infinite proj
|
||||
// let far_z = self.far;
|
||||
|
||||
let tan_angle_left = fov.angle_left.tan();
|
||||
let tan_angle_right = fov.angle_right.tan();
|
||||
|
||||
let tan_angle_down = fov.angle_down.tan();
|
||||
let tan_angle_up = fov.angle_up.tan();
|
||||
|
||||
let tan_angle_width = tan_angle_right - tan_angle_left;
|
||||
|
||||
// Set to tanAngleDown - tanAngleUp for a clip space with positive Y
|
||||
// down (Vulkan). Set to tanAngleUp - tanAngleDown for a clip space with
|
||||
// positive Y up (OpenGL / D3D / Metal).
|
||||
// const float tanAngleHeight =
|
||||
// graphicsApi == GRAPHICS_VULKAN ? (tanAngleDown - tanAngleUp) : (tanAngleUp - tanAngleDown);
|
||||
let tan_angle_height = if is_vulkan_api {
|
||||
tan_angle_down - tan_angle_up
|
||||
} else {
|
||||
tan_angle_up - tan_angle_down
|
||||
};
|
||||
|
||||
// Set to nearZ for a [-1,1] Z clip space (OpenGL / OpenGL ES).
|
||||
// Set to zero for a [0,1] Z clip space (Vulkan / D3D / Metal).
|
||||
// const float offsetZ =
|
||||
// (graphicsApi == GRAPHICS_OPENGL || graphicsApi == GRAPHICS_OPENGL_ES) ? nearZ : 0;
|
||||
// FIXME handle enum of graphics apis
|
||||
let offset_z = 0.;
|
||||
|
||||
let mut cols: [f32; 16] = [0.0; 16];
|
||||
|
||||
if far_z <= near_z {
|
||||
// place the far plane at infinity
|
||||
cols[0] = 2. / tan_angle_width;
|
||||
cols[4] = 0.;
|
||||
cols[8] = (tan_angle_right + tan_angle_left) / tan_angle_width;
|
||||
cols[12] = 0.;
|
||||
|
||||
cols[1] = 0.;
|
||||
cols[5] = 2. / tan_angle_height;
|
||||
cols[9] = (tan_angle_up + tan_angle_down) / tan_angle_height;
|
||||
cols[13] = 0.;
|
||||
|
||||
cols[2] = 0.;
|
||||
cols[6] = 0.;
|
||||
cols[10] = -1.;
|
||||
cols[14] = -(near_z + offset_z);
|
||||
|
||||
cols[3] = 0.;
|
||||
cols[7] = 0.;
|
||||
cols[11] = -1.;
|
||||
cols[15] = 0.;
|
||||
|
||||
// bevy uses the _reverse_ infinite projection
|
||||
// https://dev.theomader.com/depth-precision/
|
||||
let z_reversal = Mat4::from_cols_array_2d(&[
|
||||
[1f32, 0., 0., 0.],
|
||||
[0., 1., 0., 0.],
|
||||
[0., 0., -1., 0.],
|
||||
[0., 0., 1., 1.],
|
||||
]);
|
||||
|
||||
return z_reversal * Mat4::from_cols_array(&cols);
|
||||
} else {
|
||||
// normal projection
|
||||
cols[0] = 2. / tan_angle_width;
|
||||
cols[4] = 0.;
|
||||
cols[8] = (tan_angle_right + tan_angle_left) / tan_angle_width;
|
||||
cols[12] = 0.;
|
||||
|
||||
cols[1] = 0.;
|
||||
cols[5] = 2. / tan_angle_height;
|
||||
cols[9] = (tan_angle_up + tan_angle_down) / tan_angle_height;
|
||||
cols[13] = 0.;
|
||||
|
||||
cols[2] = 0.;
|
||||
cols[6] = 0.;
|
||||
cols[10] = -(far_z + offset_z) / (far_z - near_z);
|
||||
cols[14] = -(far_z * (near_z + offset_z)) / (far_z - near_z);
|
||||
|
||||
cols[3] = 0.;
|
||||
cols[7] = 0.;
|
||||
cols[11] = -1.;
|
||||
cols[15] = 0.;
|
||||
}
|
||||
|
||||
Mat4::from_cols_array(&cols)
|
||||
}
|
||||
|
||||
fn update(&mut self, _width: f32, _height: f32) {}
|
||||
|
||||
fn far(&self) -> f32 {
|
||||
self.far
|
||||
}
|
||||
|
||||
fn get_frustum_corners(&self, z_near: f32, z_far: f32) -> [Vec3A; 8] {
|
||||
let tan_angle_left = self.fov.angle_left.tan();
|
||||
let tan_angle_right = self.fov.angle_right.tan();
|
||||
|
||||
let tan_angle_bottom = self.fov.angle_down.tan();
|
||||
let tan_angle_top = self.fov.angle_up.tan();
|
||||
|
||||
// NOTE: These vertices are in the specific order required by [`calculate_cascade`].
|
||||
[
|
||||
Vec3A::new(tan_angle_right, tan_angle_bottom, 1.0) * z_near, // bottom right
|
||||
Vec3A::new(tan_angle_right, tan_angle_top, 1.0) * z_near, // top right
|
||||
Vec3A::new(tan_angle_left, tan_angle_top, 1.0) * z_near, // top left
|
||||
Vec3A::new(tan_angle_left, tan_angle_bottom, 1.0) * z_near, // bottom left
|
||||
Vec3A::new(tan_angle_right, tan_angle_bottom, 1.0) * z_far, // bottom right
|
||||
Vec3A::new(tan_angle_right, tan_angle_top, 1.0) * z_far, // top right
|
||||
Vec3A::new(tan_angle_left, tan_angle_top, 1.0) * z_far, // top left
|
||||
Vec3A::new(tan_angle_left, tan_angle_bottom, 1.0) * z_far, // bottom left
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct Cameras(Entity, Entity);
|
||||
|
||||
fn setup(mut commands: Commands) {
|
||||
let left = commands.spawn(XrCameraBundle::new(Eye::Left)).id();
|
||||
let right = commands.spawn(XrCameraBundle::new(Eye::Right)).id();
|
||||
commands.insert_resource(Cameras(left, right));
|
||||
}
|
||||
|
||||
pub fn begin_frame(session: NonSend<Session>, action: NonSend<Action<Pose>>) {
|
||||
session.begin_frame().unwrap();
|
||||
}
|
||||
|
||||
fn locate_views(
|
||||
session: NonSend<Session>,
|
||||
mut manual_texture_views: ResMut<ManualTextureViews>,
|
||||
cameras: Res<Cameras>,
|
||||
mut transforms: Query<(&mut Transform)>,
|
||||
) {
|
||||
let (left_view, right_view) = session.locate_views().unwrap();
|
||||
|
||||
let left = ManualTextureView {
|
||||
texture_view: left_view.texture_view().unwrap().into(),
|
||||
size: left_view.resolution(),
|
||||
format: left_view.format(),
|
||||
};
|
||||
let right = ManualTextureView {
|
||||
texture_view: right_view.texture_view().unwrap().into(),
|
||||
size: right_view.resolution(),
|
||||
format: right_view.format(),
|
||||
};
|
||||
|
||||
if let Ok(mut transform) = transforms.get_mut(cameras.0) {
|
||||
let Pose {
|
||||
translation,
|
||||
rotation,
|
||||
} = left_view.pose();
|
||||
|
||||
transform.translation = translation;
|
||||
transform.rotation = rotation;
|
||||
}
|
||||
|
||||
if let Ok(mut transform) = transforms.get_mut(cameras.1) {
|
||||
let Pose {
|
||||
translation,
|
||||
rotation,
|
||||
} = right_view.pose();
|
||||
|
||||
transform.translation = translation;
|
||||
transform.rotation = rotation;
|
||||
}
|
||||
|
||||
manual_texture_views.insert(RIGHT_XR_TEXTURE_HANDLE, right);
|
||||
manual_texture_views.insert(LEFT_XR_TEXTURE_HANDLE, left);
|
||||
}
|
||||
|
||||
pub fn end_frame(session: NonSend<Session>) {
|
||||
session.end_frame().unwrap();
|
||||
}
|
||||
|
||||
pub struct DefaultXrPlugins;
|
||||
|
||||
impl PluginGroup for DefaultXrPlugins {
|
||||
fn build(self) -> PluginGroupBuilder {
|
||||
DefaultPlugins
|
||||
.build()
|
||||
.disable::<RenderPlugin>()
|
||||
.disable::<PipelinedRenderingPlugin>()
|
||||
.add_before::<RenderPlugin, _>(XrPlugin)
|
||||
.set(WindowPlugin {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
primary_window: Some(Window {
|
||||
transparent: true,
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
..default()
|
||||
}),
|
||||
#[cfg(target_os = "android")]
|
||||
primary_window: None,
|
||||
#[cfg(target_os = "android")]
|
||||
exit_condition: bevy::window::ExitCondition::DontExit,
|
||||
#[cfg(target_os = "android")]
|
||||
close_when_requested: true,
|
||||
..default()
|
||||
})
|
||||
}
|
||||
}
|
||||
pub mod actions;
|
||||
pub mod types;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub mod webxr;
|
||||
|
||||
8
src/types.rs
Normal file
8
src/types.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use bevy::math::{Quat, Vec3};
|
||||
|
||||
pub struct Pose {
|
||||
pub translation: Vec3,
|
||||
pub rotation: Quat,
|
||||
}
|
||||
|
||||
pub struct Haptic;
|
||||
175
src/webxr.rs
Normal file
175
src/webxr.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::app::{App, Plugin, PluginsState};
|
||||
use bevy::ecs::entity::Entity;
|
||||
use bevy::ecs::query::With;
|
||||
use bevy::ecs::world::World;
|
||||
use bevy::log::{error, info};
|
||||
use bevy::render::RenderApp;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use bevy::winit::WinitWindows;
|
||||
use js_sys::Object;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{
|
||||
HtmlCanvasElement, WebGl2RenderingContext, XrFrame, XrReferenceSpace, XrReferenceSpaceType,
|
||||
XrRenderStateInit, XrSession, XrSessionMode, XrWebGlLayer,
|
||||
};
|
||||
use winit::platform::web::WindowExtWebSys;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FutureXrSession(Rc<Mutex<Option<Result<(XrSession, XrReferenceSpace), JsValue>>>>);
|
||||
|
||||
pub struct XrInitPlugin;
|
||||
|
||||
impl Plugin for XrInitPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let canvas = get_canvas(&mut app.world).unwrap();
|
||||
let future_session = FutureXrSession(Default::default());
|
||||
app.set_runner(webxr_runner);
|
||||
app.insert_non_send_resource(future_session.clone());
|
||||
bevy::tasks::IoTaskPool::get().spawn_local(async move {
|
||||
let result = init_webxr(
|
||||
canvas,
|
||||
XrSessionMode::ImmersiveVr,
|
||||
XrReferenceSpaceType::Local,
|
||||
)
|
||||
.await;
|
||||
*future_session.0.lock().unwrap() = Some(result);
|
||||
});
|
||||
}
|
||||
|
||||
fn ready(&self, app: &App) -> bool {
|
||||
app.world
|
||||
.get_non_send_resource::<FutureXrSession>()
|
||||
.and_then(|fxr| fxr.0.try_lock().map(|locked| locked.is_some()).ok())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn finish(&self, app: &mut App) {
|
||||
info!("finishing");
|
||||
|
||||
if let Some(Ok((session, reference_space))) = app
|
||||
.world
|
||||
.remove_non_send_resource::<FutureXrSession>()
|
||||
.and_then(|fxr| fxr.0.lock().unwrap().take())
|
||||
{
|
||||
app.insert_non_send_resource(session.clone())
|
||||
.insert_non_send_resource(reference_space.clone());
|
||||
app.sub_app_mut(RenderApp)
|
||||
.insert_non_send_resource(session)
|
||||
.insert_non_send_resource(reference_space);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn webxr_runner(mut app: App) {
|
||||
fn set_timeout(f: &Closure<dyn FnMut()>, dur: Duration) {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
f.as_ref().unchecked_ref(),
|
||||
dur.as_millis() as i32,
|
||||
)
|
||||
.expect("Should register `setTimeout`.");
|
||||
}
|
||||
let run_xr_inner = Rc::new(RefCell::new(None));
|
||||
let run_xr: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = run_xr_inner.clone();
|
||||
*run_xr.borrow_mut() = Some(Closure::new(move || {
|
||||
let app = &mut app;
|
||||
if app.plugins_state() == PluginsState::Ready {
|
||||
app.finish();
|
||||
app.cleanup();
|
||||
run_xr_app(std::mem::take(app));
|
||||
} else {
|
||||
set_timeout(
|
||||
run_xr_inner.borrow().as_ref().unwrap(),
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
}
|
||||
}));
|
||||
set_timeout(run_xr.borrow().as_ref().unwrap(), Duration::from_millis(1));
|
||||
}
|
||||
|
||||
fn run_xr_app(mut app: App) {
|
||||
let session = app.world.non_send_resource::<XrSession>().clone();
|
||||
let inner_closure: Rc<RefCell<Option<Closure<dyn FnMut(f64, XrFrame)>>>> =
|
||||
Rc::new(RefCell::new(None));
|
||||
let closure = inner_closure.clone();
|
||||
*closure.borrow_mut() = Some(Closure::new(move |_time, frame: XrFrame| {
|
||||
let session = frame.session();
|
||||
app.insert_non_send_resource(frame);
|
||||
info!("update");
|
||||
app.update();
|
||||
session.request_animation_frame(
|
||||
inner_closure
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unchecked_ref(),
|
||||
);
|
||||
}));
|
||||
session.request_animation_frame(closure.borrow().as_ref().unwrap().as_ref().unchecked_ref());
|
||||
}
|
||||
|
||||
fn get_canvas(world: &mut World) -> Option<HtmlCanvasElement> {
|
||||
let window_entity = world
|
||||
.query_filtered::<Entity, With<PrimaryWindow>>()
|
||||
.get_single(world)
|
||||
.ok()?;
|
||||
let windows = world.get_non_send_resource::<WinitWindows>()?;
|
||||
Some(windows.get_window(window_entity)?.canvas())
|
||||
}
|
||||
|
||||
async fn init_webxr(
|
||||
canvas: HtmlCanvasElement,
|
||||
mode: XrSessionMode,
|
||||
reference_type: XrReferenceSpaceType,
|
||||
) -> Result<(XrSession, XrReferenceSpace), JsValue> {
|
||||
let xr = web_sys::window().unwrap().navigator().xr();
|
||||
|
||||
let supports_session = JsFuture::from(xr.is_session_supported(mode)).await?;
|
||||
if supports_session == false {
|
||||
error!("XR session {:?} not supported", mode);
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"XR session {:?} not supported",
|
||||
mode
|
||||
)));
|
||||
}
|
||||
|
||||
info!("creating session");
|
||||
let session: XrSession = JsFuture::from(xr.request_session(mode)).await?.into();
|
||||
|
||||
info!("creating gl");
|
||||
let gl: WebGl2RenderingContext = {
|
||||
let gl_attribs = Object::new();
|
||||
js_sys::Reflect::set(
|
||||
&gl_attribs,
|
||||
&JsValue::from_str("xrCompatible"),
|
||||
&JsValue::TRUE,
|
||||
)?;
|
||||
canvas
|
||||
.get_context_with_context_options("webgl2", &gl_attribs)?
|
||||
.ok_or(JsValue::from_str(
|
||||
"Unable to create WebGL rendering context",
|
||||
))?
|
||||
.dyn_into()?
|
||||
};
|
||||
|
||||
let xr_gl_layer = XrWebGlLayer::new_with_web_gl2_rendering_context(&session, &gl)?;
|
||||
let mut render_state_init = XrRenderStateInit::new();
|
||||
render_state_init.base_layer(Some(&xr_gl_layer));
|
||||
session.update_render_state_with_state(&render_state_init);
|
||||
info!("creating ref space");
|
||||
let reference_space = JsFuture::from(session.request_reference_space(reference_type))
|
||||
.await?
|
||||
.into();
|
||||
|
||||
info!("finished");
|
||||
Ok((session, reference_space))
|
||||
}
|
||||
@@ -8,7 +8,6 @@ default = ["linked"]
|
||||
linked = ["openxr/linked"]
|
||||
|
||||
[dependencies]
|
||||
ash = "0.37.3"
|
||||
futures = "0.3.29"
|
||||
glam = "0.24.1"
|
||||
hashbrown = "0.14"
|
||||
@@ -20,6 +19,7 @@ wgpu-hal = "0.17.1"
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
openxr = "0.17.1"
|
||||
ash = "0.37.3"
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
wasm-bindgen = "0.2.87"
|
||||
|
||||
@@ -14,10 +14,7 @@ pub struct Entry(Rc<dyn EntryTrait>);
|
||||
impl Entry {
|
||||
/// Constructs a new Xr entry
|
||||
pub fn new() -> Self {
|
||||
#[cfg(target_family = "wasm")]
|
||||
return crate::backend::webxr::WebXrEntry::new().into();
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
return crate::backend::oxr::OXrEntry::new().into();
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,14 @@ pub trait SessionTrait {
|
||||
fn headset_location(&self) -> Result<Pose>;
|
||||
/// Request input modules with the specified bindings.
|
||||
fn create_input(&self, bindings: Bindings) -> Result<Input>;
|
||||
/// Blocks until a rendering frame is available, then returns the views for the left and right eyes.
|
||||
/// Wait until a frame is ready to render to.
|
||||
fn wait_frame(&self) -> Result<FrameData>;
|
||||
/// Begin rendering work for the frame.
|
||||
fn begin_frame(&self) -> Result<()>;
|
||||
/// Locate the views of each eye.
|
||||
fn locate_views(&self) -> Result<(View, View)>;
|
||||
/// Submits rendering work for this frame.
|
||||
fn end_frame(&self) -> Result<()>;
|
||||
fn end_frame(&self, data: FrameData) -> Result<()>;
|
||||
/// Gets the resolution of a single eye.
|
||||
fn resolution(&self) -> UVec2;
|
||||
/// Gets the texture format for the session.
|
||||
|
||||
@@ -145,7 +145,7 @@ impl SessionTrait for OXrSession {
|
||||
.into())
|
||||
}
|
||||
|
||||
fn begin_frame(&self) -> Result<()> {
|
||||
fn wait_frame(&self) -> Result<FrameData> {
|
||||
{
|
||||
let mut bindings = self.bindings.lock().unwrap();
|
||||
if !bindings.sessions_attached {
|
||||
@@ -239,6 +239,10 @@ impl SessionTrait for OXrSession {
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(FrameData)
|
||||
}
|
||||
|
||||
fn begin_frame(&self) -> Result<()> {
|
||||
{
|
||||
let _span = info_span!("xr_begin_frame").entered();
|
||||
self.swapchain.begin().unwrap()
|
||||
@@ -292,7 +296,7 @@ impl SessionTrait for OXrSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn end_frame(&self) -> Result<()> {
|
||||
fn end_frame(&self, data: FrameData) -> Result<()> {
|
||||
{
|
||||
let _span = info_span!("xr_release_image").entered();
|
||||
self.swapchain.release_image().unwrap();
|
||||
|
||||
@@ -1,208 +1 @@
|
||||
use std::sync::{
|
||||
mpsc::{channel, Sender},
|
||||
Mutex,
|
||||
};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
use wasm_bindgen_futures::js_sys;
|
||||
use web_sys::{XrFrame, XrInputSource};
|
||||
|
||||
mod utils;
|
||||
|
||||
use utils::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebXrEntry(web_sys::XrSystem);
|
||||
|
||||
impl WebXrEntry {
|
||||
pub fn new() -> Self {
|
||||
Self(
|
||||
web_sys::window()
|
||||
.expect("No window available in current environment")
|
||||
.navigator()
|
||||
.xr(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryTrait for WebXrEntry {
|
||||
fn available_extensions(&self) -> Result<ExtensionSet> {
|
||||
Ok(ExtensionSet::default())
|
||||
}
|
||||
|
||||
fn create_instance(&self, exts: ExtensionSet) -> Result<Instance> {
|
||||
Ok(WebXrInstance {
|
||||
entry: self.clone(),
|
||||
exts,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebXrInstance {
|
||||
entry: WebXrEntry,
|
||||
exts: ExtensionSet,
|
||||
}
|
||||
|
||||
impl InstanceTrait for WebXrInstance {
|
||||
fn entry(&self) -> Entry {
|
||||
self.entry.clone().into()
|
||||
}
|
||||
|
||||
fn enabled_extensions(&self) -> ExtensionSet {
|
||||
self.exts
|
||||
}
|
||||
|
||||
fn create_session(&self, _info: SessionCreateInfo) -> Result<Session> {
|
||||
Ok(WebXrSession {
|
||||
instance: self.clone().into(),
|
||||
session: self
|
||||
.entry
|
||||
.0
|
||||
.request_session(web_sys::XrSessionMode::ImmersiveVr)
|
||||
.resolve()
|
||||
.map_err(|_| XrError::Placeholder)?,
|
||||
end_frame_sender: Mutex::default(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebXrSession {
|
||||
instance: Instance,
|
||||
session: web_sys::XrSession,
|
||||
end_frame_sender: Mutex<Option<Sender<()>>>,
|
||||
}
|
||||
|
||||
impl SessionTrait for WebXrSession {
|
||||
fn instance(&self) -> &Instance {
|
||||
&self.instance
|
||||
}
|
||||
|
||||
fn create_input(&self, bindings: Bindings) -> Result<Input> {
|
||||
Ok(WebXrInput {
|
||||
devices: self.session.input_sources(),
|
||||
bindings,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
fn begin_frame(&self) -> Result<(View, View)> {
|
||||
let mut end_frame_sender = self.end_frame_sender.lock().unwrap();
|
||||
if end_frame_sender.is_some() {
|
||||
Err(XrError::Placeholder)?
|
||||
}
|
||||
let (tx, rx) = channel::<()>();
|
||||
let (tx_end, rx_end) = channel::<()>();
|
||||
*end_frame_sender = Some(tx_end);
|
||||
let on_frame: Closure<dyn FnMut(f64, XrFrame)> =
|
||||
Closure::new(move |_time: f64, _frame: XrFrame| {
|
||||
tx.send(()).ok();
|
||||
rx_end.recv().ok();
|
||||
});
|
||||
|
||||
self.session
|
||||
.request_animation_frame(on_frame.as_ref().unchecked_ref());
|
||||
|
||||
rx.recv().ok();
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn end_frame(&self) -> Result<()> {
|
||||
let mut end_frame_sender = self.end_frame_sender.lock().unwrap();
|
||||
match std::mem::take(&mut *end_frame_sender) {
|
||||
Some(sender) => sender.send(()).ok(),
|
||||
None => Err(XrError::Placeholder)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_render_resources(
|
||||
&self,
|
||||
) -> Option<(
|
||||
wgpu::Device,
|
||||
wgpu::Queue,
|
||||
wgpu::AdapterInfo,
|
||||
wgpu::Adapter,
|
||||
wgpu::Instance,
|
||||
)> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebXrInput {
|
||||
devices: web_sys::XrInputSourceArray,
|
||||
bindings: Bindings,
|
||||
}
|
||||
|
||||
impl From<web_sys::XrHandedness> for Handedness {
|
||||
fn from(value: web_sys::XrHandedness) -> Self {
|
||||
match value {
|
||||
web_sys::XrHandedness::None => Handedness::None,
|
||||
web_sys::XrHandedness::Left => Handedness::Left,
|
||||
web_sys::XrHandedness::Right => Handedness::Right,
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebXrInput {
|
||||
fn get_controller(&self, handedness: Handedness) -> Option<web_sys::XrInputSource> {
|
||||
js_sys::try_iter(&self.devices).ok()??.find_map(|dev| {
|
||||
if let Ok(dev) = dev {
|
||||
let dev: XrInputSource = dev.into();
|
||||
if Into::<Handedness>::into(dev.handedness()) == handedness {
|
||||
Some(dev)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InputTrait for WebXrInput {
|
||||
fn get_haptics(&self, path: ActionPath) -> Result<Action<Haptic>> {
|
||||
// let haptics = self
|
||||
// .get_controller(path.handedness)
|
||||
// .ok_or(XrError::Placeholder)?
|
||||
// .gamepad()
|
||||
// .ok_or(XrError::Placeholder)?
|
||||
// .haptic_actuators()
|
||||
// .iter()
|
||||
// .next()
|
||||
// .ok_or(XrError::Placeholder)?
|
||||
// .into();
|
||||
// Ok(WebXrHaptics(haptics, path).into())
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_pose(&self, _path: ActionPath) -> Result<Action<Pose>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_float(&self, _path: ActionPath) -> Result<Action<f32>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_bool(&self, _path: ActionPath) -> Result<Action<bool>> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OXrActionInput(openxr::Action);
|
||||
|
||||
pub struct WebXrHaptics(web_sys::GamepadHapticActuator, ActionPath);
|
||||
|
||||
impl ActionTrait for WebXrHaptics {
|
||||
fn id(&self) -> ActionPath {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
impl HapticTrait for WebXrHaptics {}
|
||||
|
||||
@@ -10,6 +10,8 @@ pub struct ExtensionSet {
|
||||
pub vulkan: bool,
|
||||
}
|
||||
|
||||
pub struct FrameData;
|
||||
|
||||
pub struct SessionCreateInfo {
|
||||
/// preferred texture format
|
||||
pub texture_format: wgpu::TextureFormat,
|
||||
|
||||
Reference in New Issue
Block a user