#![recursion_limit = "256"] #![allow(clippy::empty_docs)] #![allow(clippy::too_many_arguments)] #![allow(clippy::type_complexity)] mod core; mod nodes; mod objects; mod session; mod spectator_cam; pub mod tracking_offset; #[cfg(feature = "wayland")] mod wayland; use crate::nodes::drawable::sky::SkyPlugin; use crate::nodes::input; use crate::spectator_cam::SpectatorCameraPlugin; use bevy::MinimalPlugins; use bevy::a11y::AccessibilityPlugin; use bevy::app::{App, ScheduleRunnerPlugin, TerminalCtrlCHandlerPlugin}; use bevy::asset::{AssetMetaCheck, UnapprovedPathMode}; use bevy::audio::AudioPlugin; use bevy::core_pipeline::CorePipelinePlugin; use bevy::core_pipeline::oit::OrderIndependentTransparencySettings; use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::diagnostic::DiagnosticsPlugin; use bevy::ecs::schedule::{ExecutorKind, ScheduleLabel}; use bevy::gizmos::GizmoPlugin; use bevy::gltf::GltfPlugin; use bevy::input::InputPlugin; use bevy::pbr::PbrPlugin; use bevy::render::pipelined_rendering::{ PipelinedRenderThreadOnCreateCallback, PipelinedRenderingPlugin, }; use bevy::render::settings::{Backends, RenderCreation, WgpuSettings}; use bevy::render::{RenderDebugFlags, RenderPlugin}; use bevy::scene::ScenePlugin; use bevy::window::{CompositeAlphaMode, PresentMode}; use bevy::winit::{WakeUp, WinitPlugin}; use bevy_dmabuf::import::DmabufImportPlugin; use bevy_mod_openxr::action_set_attaching::OxrActionAttachingPlugin; use bevy_mod_openxr::action_set_syncing::OxrActionSyncingPlugin; use bevy_mod_openxr::add_xr_plugins; use bevy_mod_openxr::exts::OxrExtensions; use bevy_mod_openxr::features::overlay::OxrOverlaySettings; use bevy_mod_openxr::graphics::{GraphicsBackend, OxrManualGraphicsConfig}; use bevy_mod_openxr::init::{OxrInitPlugin, should_run_frame_loop}; use bevy_mod_openxr::reference_space::OxrReferenceSpacePlugin; use bevy_mod_openxr::render::{OxrRenderPlugin, OxrWaitFrameSystem}; use bevy_mod_openxr::resources::{OxrFrameState, OxrFrameWaiter, OxrSessionConfig}; use bevy_mod_openxr::types::AppInfo; use bevy_mod_xr::camera::XrProjection; use bevy_mod_xr::session::{XrFirst, XrHandleEvents, XrSessionPlugin}; use clap::Parser; use core::client::{Client, tick_internal_client}; use core::entity_handle::EntityHandlePlugin; use core::task; use directories::ProjectDirs; use nodes::audio::AudioNodePlugin; use nodes::drawable::lines::LinesNodePlugin; use nodes::drawable::model::ModelNodePlugin; use nodes::drawable::text::TextNodePlugin; use nodes::fields::FieldDebugGizmoPlugin; use nodes::spatial::SpatialNodePlugin; use objects::hmd::HmdPlugin; use objects::input::mouse_pointer::FlatscreenInputPlugin; use objects::input::oxr_controller::ControllerPlugin; use objects::input::oxr_hand::HandPlugin; use objects::play_space::PlaySpacePlugin; use openxr::{EnvironmentBlendMode, ReferenceSpaceType}; use session::{launch_start, save_session}; use stardust_xr::schemas::dbus::object_registry::ObjectRegistry; use stardust_xr::server::LockedSocket; use std::ops::DerefMut as _; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, OnceLock}; use tokio::net::UnixListener; use tokio::sync::Notify; use tokio::task::JoinError; use tracing::metadata::LevelFilter; use tracing::{error, info}; use tracing_subscriber::filter::Directive; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use tracking_offset::TrackingOffsetPlugin; use wayland::{Wayland, WaylandPlugin}; use zbus::Connection; use zbus::fdo::ObjectManager; use bevy::prelude::*; #[derive(Debug, Clone, Parser)] #[clap(author, version, about, long_about = None)] struct CliArgs { /// Force flatscreen mode and use the mouse pointer as a 3D pointer #[clap(short, long, action)] force_flatscreen: bool, /// Replaces the flatscreen mode with a first person spectator camera #[clap(short, long, action)] spectator: bool, /// Creates a transparent window fot the flatscreen mode #[clap(short, long, action)] transparent_flatscreen: bool, /// If monado insists on emulating them, set this flag...we want the raw input #[clap(long)] disable_controllers: bool, /// If monado insists on emulating , set this flag...we want the raw input #[clap(long)] disable_hands: bool, /// Run Stardust XR as an overlay with given priority #[clap(id = "PRIORITY", short = 'o', long = "overlay", action)] overlay_priority: Option, /// Debug the clients started by the server #[clap(short = 'd', long = "debug", action)] debug_launched_clients: bool, /// Run a script when ready for clients to connect. If this is not set the script at $HOME/.config/stardust/startup will be ran if it exists. #[clap(id = "PATH", short = 'e', long = "execute-startup-script", action)] startup_script: Option, /// Restore the session with the given ID (or `latest`), ignoring the startup script. Sessions are stored in directories at `~/.local/state/stardust/`. #[clap(id = "SESSION_ID", long = "restore", action)] restore: Option, } pub type BevyMaterial = StandardMaterial; static STARDUST_INSTANCE: OnceLock = OnceLock::new(); // #[tokio::main(flavor = "current_thread")] #[tokio::main] async fn main() -> Result { color_eyre::install().unwrap(); let registry = tracing_subscriber::registry(); #[cfg(feature = "profile_app")] let registry = registry.with( tracing_tracy::TracyLayer::new(tracing_tracy::DefaultConfig::default()) .with_filter(LevelFilter::DEBUG), ); #[cfg(feature = "profile_tokio")] let registry = registry.with(console_subscriber::spawn()); let log_layer = fmt::Layer::new() .with_thread_names(true) .with_ansi(true) .with_line_number(true) .with_filter( EnvFilter::builder() .with_default_directive(LevelFilter::WARN.into()) .from_env_lossy() .add_directive(Directive::from_str("bevy_mesh_text_3d::text_glyphs=off").unwrap()), ); registry.with(log_layer).init(); let cli_args = CliArgs::parse(); let locked_socket = LockedSocket::get_free().expect("Unable to find a free stardust socket path"); STARDUST_INSTANCE.set(locked_socket.socket_path.file_name().unwrap().to_string_lossy().into_owned()).expect("Someone hasn't done their job, yell at Nova because how is this set multiple times what the hell"); info!( socket_path = ?locked_socket.socket_path.display(), "Stardust socket created" ); let socket = UnixListener::bind(locked_socket.socket_path) .expect("Couldn't spawn stardust server at {socket_path}"); task::new(|| "Stardust socket accept loop", async move { loop { let Ok((stream, _)) = socket.accept().await else { continue; }; if let Err(e) = Client::from_connection(stream) { error!(?e, "Unable to create client from connection"); } } }) .unwrap(); info!("Init client join loop"); let project_dirs = ProjectDirs::from("", "", "stardust"); if project_dirs.is_none() { error!( "Unable to get Stardust project directories, default skybox and startup script will not work." ); } let dbus_connection = Connection::session() .await .expect("Could not open dbus session"); // why is this requested here? should there be a specific server bus name that we check // instead? dbus_connection .request_name("org.stardustxr.HMD") .await .expect( "Another instance of the server is running. This is not supported currently (but is planned).", ); dbus_connection .object_server() .at("/", ObjectManager) .await .expect("Couldn't add the object manager"); let object_registry = ObjectRegistry::new(&dbus_connection).await; let _wayland = Wayland::new().expect("Couldn't create Wayland instance"); let ready_notifier = Arc::new(Notify::new()); let io_loop = tokio::task::spawn_blocking({ let ready_notifier = ready_notifier.clone(); let project_dirs = project_dirs.clone(); let cli_args = cli_args.clone(); let dbus_connection = dbus_connection.clone(); move || { bevy_loop( ready_notifier, project_dirs, cli_args, dbus_connection, object_registry, ) } }); ready_notifier.notified().await; let mut startup_children = project_dirs .as_ref() .map(|project_dirs| launch_start(&cli_args, project_dirs)) .unwrap_or_default(); let return_value = io_loop.await; info!("Stopping..."); if let Some(project_dirs) = project_dirs { save_session(&project_dirs).await; } for mut startup_child in startup_children.drain(..) { let _ = startup_child.kill(); } info!("Cleanly shut down Stardust"); return_value } // static DEFAULT_SKYTEX: OnceLock = OnceLock::new(); // static DEFAULT_SKYLIGHT: OnceLock = OnceLock::new(); #[derive(ScheduleLabel, Hash, Debug, PartialEq, Eq, Clone, Copy)] pub struct PreFrameWait; #[derive(Resource, Deref)] pub struct ObjectRegistryRes(Arc); #[derive(Resource, Deref)] pub struct DbusConnection(Connection); fn bevy_loop( ready_notifier: Arc, _project_dirs: Option, args: CliArgs, dbus_connection: Connection, object_registry: Arc, ) -> AppExit { let mut app = App::new(); app.insert_resource(DbusConnection(dbus_connection)); app.insert_resource(OxrManualGraphicsConfig { fallback_backend: GraphicsBackend::Vulkan(()), vk_instance_exts: Vec::new(), vk_device_exts: bevy_dmabuf::required_device_extensions(), }); app.add_plugins(AssetPlugin { meta_check: AssetMetaCheck::Never, unapproved_path_mode: UnapprovedPathMode::Allow, ..default() }); let mut plugins = MinimalPlugins .build() .add(DiagnosticsPlugin) .add(TransformPlugin) .add(InputPlugin) .add(AccessibilityPlugin); plugins = plugins .add(TerminalCtrlCHandlerPlugin) // bevy_mod_openxr will replace this, TODO: figure out how to mix this with // bevy-dmabuf .add(RenderPlugin { render_creation: RenderCreation::Automatic(WgpuSettings { backends: Some(Backends::VULKAN), ..Default::default() }), ..Default::default() }) .add(ImagePlugin::default()) .add(CorePipelinePlugin) // theoretically we shouldn't need this because of bevy_sk, but everything is tangled in // there and idk what we actually need to run .add(PbrPlugin { // this seems to only apply to StandardMaterial, we don't use that prepass_enabled: true, add_default_deferred_lighting_plugin: false, use_gpu_instance_buffer_builder: true, debug_flags: RenderDebugFlags::default(), }) // required for gltf .add(ScenePlugin) .add(GltfPlugin::default()) // .add(AnimationPlugin) .add(AudioPlugin::default()) .add(GizmoPlugin) .add(WindowPlugin::default()) .add(DmabufImportPlugin); let mut task_pool_plugin = TaskPoolPlugin::default(); // make tokio work let handle = tokio::runtime::Handle::current(); let enter_runtime_context = Arc::new(move || { // TODO: this might be a memory leak std::mem::forget(handle.enter()); }); task_pool_plugin.task_pool_options.io.on_thread_spawn = Some(enter_runtime_context.clone()); task_pool_plugin.task_pool_options.compute.on_thread_spawn = Some(enter_runtime_context.clone()); task_pool_plugin .task_pool_options .async_compute .on_thread_spawn = Some(enter_runtime_context.clone()); plugins = plugins.set(task_pool_plugin); if std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok() { let mut plugin = WinitPlugin::::default(); plugin.run_on_any_thread = true; plugins = plugins.add(plugin).disable::(); plugins = match args.spectator { true => plugins.add(SpectatorCameraPlugin), false => plugins.add(FlatscreenInputPlugin), }; } app.insert_resource(PipelinedRenderThreadOnCreateCallback( enter_runtime_context.clone(), )); app.add_plugins( if !args.force_flatscreen { add_xr_plugins(plugins) .set(OxrInitPlugin { app_info: AppInfo { name: "Stardust XR".into(), version: bevy_mod_openxr::types::Version(0, 44, 1), }, exts: { // all OpenXR extensions can be requested here let mut exts = OxrExtensions::default(); exts.enable_hand_tracking(); if args.overlay_priority.is_some() { exts.enable_extx_overlay(); } exts.khr_convert_timespec_time = true; exts }, ..default() }) .set(OxrRenderPlugin { default_wait_frame: false, ..default() }) .set(OxrReferenceSpacePlugin { default_primary_ref_space: ReferenceSpaceType::LOCAL, }) // Disable a bunch of unneeded plugins // we don't do any action stuff that needs to integrate with the ecosystem .disable::() .disable::() } else { // enable a event plugins = plugins.add(XrSessionPlugin { auto_handle: false }); bevy_dmabuf::wgpu_init::add_dmabuf_init_plugin(plugins) } .set(WindowPlugin { primary_window: Some(Window { transparent: args.transparent_flatscreen, present_mode: PresentMode::AutoNoVsync, composite_alpha_mode: if args.transparent_flatscreen { CompositeAlphaMode::PreMultiplied } else { CompositeAlphaMode::Auto }, title: "StardustXR server flatscreen mode".to_string(), ..default() }), ..default() }), ); app.add_plugins(PipelinedRenderingPlugin); app.add_plugins(bevy_sk::hand::HandPlugin); app.add_plugins(bevy_equirect::EquirectangularPlugin); // app.add_plugins(HandGizmosPlugin); app.world_mut().resource_mut::().brightness = 1000.0; if let Some(priority) = args.overlay_priority { app.insert_resource(OxrOverlaySettings { session_layer_placement: priority, ..default() }); } app.insert_resource(OxrSessionConfig { blend_mode_preference: vec![ EnvironmentBlendMode::ALPHA_BLEND, EnvironmentBlendMode::ADDITIVE, EnvironmentBlendMode::OPAQUE, ], ..default() }); let mut pre_frame_wait = Schedule::new(PreFrameWait); pre_frame_wait.set_executor_kind(ExecutorKind::MultiThreaded); app.add_schedule(pre_frame_wait); app.insert_resource(ClearColor(Color::BLACK.with_alpha(0.0))); app.insert_resource(ObjectRegistryRes(object_registry)); #[cfg(feature = "bevy_debugging")] { use bevy::remote::{RemotePlugin, http::RemoteHttpPlugin}; app.add_plugins((RemotePlugin::default(), RemoteHttpPlugin::default())); } // the Stardust server plugins // infra plugins app.add_plugins(EntityHandlePlugin); // node plugins app.add_plugins(( SpatialNodePlugin, ModelNodePlugin, TextNodePlugin, LinesNodePlugin, AudioNodePlugin, // not really a node ig? at least for now SkyPlugin, )); // object plugins app.add_plugins((PlaySpacePlugin, HandPlugin, ControllerPlugin, HmdPlugin)); // feature plugins app.add_plugins((WaylandPlugin, TrackingOffsetPlugin, FieldDebugGizmoPlugin)); app.add_systems(PostStartup, move || { ready_notifier.notify_waiters(); }); app.add_observer(cam_settings); app.add_systems( XrFirst, xr_step .in_set(OxrWaitFrameSystem) .in_set(XrHandleEvents::FrameLoop), ); app.run() } fn cam_settings( trigger: Trigger, mut query: Query<(Entity, &mut Projection, &mut Msaa, &mut Tonemapping), With>, mut cmds: Commands, ) { let Ok((entity, mut projection, mut msaa, mut tonemapping)) = query.get_mut(trigger.target()) else { return; }; info!("modifying cam"); match projection.deref_mut() { Projection::Perspective(perspective_projection) => perspective_projection.near = 0.003, Projection::Orthographic(orthographic_projection) => orthographic_projection.near = 0.003, Projection::Custom(custom_projection) => { if let Some(xr) = custom_projection.get_mut::() { xr.near = 0.003 } else { error_once!("unknown custom camera projection"); } } } *msaa = Msaa::Off; *tonemapping = Tonemapping::None; cmds.entity(entity) .insert(OrderIndependentTransparencySettings::default()); } fn xr_step(world: &mut World) { // we are targeting the frame after the wait if let Some(mut state) = world.get_resource_mut::() { state.predicted_display_time = openxr::Time::from_nanos( state.predicted_display_time.as_nanos() + state.predicted_display_period.as_nanos(), ); } // update things like the Xr input methods world.run_schedule(PreFrameWait); input::process_input(); let time = world.resource::().delta_secs_f64(); nodes::root::Root::send_frame_events(time); let should_wait = world .run_system_cached(should_run_frame_loop) .unwrap_or(false); // we might want to do an adaptive sleep when not OpenXR waiting if should_wait { world.resource_scope::(|world, mut waiter| { let state = waiter .wait() .inspect_err(|err| error!("failed to wait OpenXR frame: {err}")) .ok(); if let Some(state) = state { world.insert_resource(OxrFrameState(state)); } }); } tick_internal_client(); } pub fn get_time(pipelined: bool, state: &OxrFrameState) -> openxr::Time { if pipelined { openxr::Time::from_nanos( state.predicted_display_time.as_nanos() + state.predicted_display_period.as_nanos(), ) } else { state.predicted_display_time } }