diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 482e775..cf7dd68 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -6,10 +6,10 @@ use mint::{Quaternion, Vector3}; use protostar::xdg::{DesktopFile, get_desktop_files}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; -use single::{APP_SIZE, App, BTN_COLOR, BTN_SELECTED_COLOR, MODEL_SCALE}; +use single::{APP_SIZE, App, BTN_COLOR, BTN_SELECTED_COLOR, MODEL_SCALE, DEFAULT_HEX_COLOR}; use stardust_xr_asteroids::{ - ClientState, CustomElement, Element, Migrate, Reify, Transformable, client, - elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial}, + ClientState, CustomElement, Element, Migrate, Reify, Transformable, client, + elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial}, }; use stardust_xr_fusion::{ drawable::MaterialParameter, @@ -17,13 +17,42 @@ use stardust_xr_fusion::{ project_local_resources, spatial::Transform, }; +use stardust_xr_fusion::values::ResourceID; +use std::path::PathBuf; use std::f32::consts::{FRAC_PI_2, PI}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::AtomicU64; +use tokio::time::Duration; + +static REIFY_COUNT: AtomicUsize = AtomicUsize::new(0); +static REIFY_TOTAL_NS: AtomicU64 = AtomicU64::new(0); +static APP_REIFY_COUNT: AtomicUsize = AtomicUsize::new(0); +static VISIBLE_LIMIT: AtomicUsize = AtomicUsize::new(0); +const VISIBLE_STEP: usize = 12; + use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main(flavor = "current_thread")] async fn main() { color_eyre::install().unwrap(); + // spawn a background logger that prints reify calls per second + tokio::spawn(async { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let v = REIFY_COUNT.swap(0, Ordering::Relaxed); + let total_ns = REIFY_TOTAL_NS.swap(0, Ordering::Relaxed); + let app_hits = APP_REIFY_COUNT.swap(0, Ordering::Relaxed); + let avg_ns = if v > 0 { total_ns / (v as u64) } else { 0 }; + tracing::info!( + reify_per_sec = v, + avg_reify_ms = (avg_ns as f64) / 1_000_000.0, + app_reify_hits = app_hits, + "hexagon reify stats" + ); + } + }); + let registry = tracing_subscriber::registry(); #[cfg(feature = "tracy")] let registry = registry.with({ @@ -43,13 +72,26 @@ async fn main() { #[derive(Debug, Serialize, Deserialize)] pub struct HexagonLauncher { - /// if the hexagon launcher is expanded - open: bool, - pos: Vector3, - rot: Quaternion, + /// if the hexagon launcher is expanded + open: bool, + pos: Vector3, + rot: Quaternion, + #[serde(skip)] + /// position in the vector is mapped to hex coordinates + apps: Vec, + #[serde(skip)] + /// cached world coordinates for each app hex + positions: Vec<[f32; 3]>, #[serde(skip)] - /// position in the vector is mapped to hex coordinates - apps: Vec, + /// lightweight immutable snapshots for fast per-frame reify + snapshots: Vec, +} + +#[derive(Debug, Clone)] +struct Snapshot { + name: String, + cached_texture: Option, + cached_gltf: Option, } impl Default for HexagonLauncher { @@ -59,6 +101,8 @@ impl Default for HexagonLauncher { pos: [0.0; 3].into(), rot: Quat::IDENTITY.into(), apps: Vec::new(), + positions: Vec::new(), + snapshots: Vec::new(), } } } @@ -77,73 +121,241 @@ impl ClientState for HexagonLauncher { .filter_map(|d| App::new(d).ok()) .collect(); - self.apps.par_iter().for_each(|app| { - app.load_icon(); - }); - // Sort by name self.apps .sort_by_key(|app| app.app.name().unwrap_or_default().to_string()); - } -} -impl Reify for HexagonLauncher { - #[tracing::instrument(skip_all)] - fn reify(&self) -> impl Element { + + // precompute coordinates for each app to avoid recomputing per-reify + self.positions = (0..self.apps.len()) + .map(|i| Hex::spiral(i + 1).get_coords()) + .collect(); + + // Preload icons/resources off the reify path so create_model() is cheap later. + // Use rayon to parallelize filesystem/processing work. + self.apps + .par_iter() + .for_each(|app| { + // idempotent: App::load_icon uses OnceLock internally + app.load_icon(); + }); + + // Warm / prebuild heavy Model resources in parallel so the renderer + // doesn't pay parsing/creation cost during reify. + // + // We do this after load_icon above so cached_gltf / cached_texture are populated. + self.apps.par_iter().for_each(|app| { + // GLTF path warm: call Model::direct once (parser/cache warm-up) + if let Some(gltf_path) = app.cached_gltf.get() { + if let Ok(builder) = Model::direct(gltf_path.to_string_lossy().to_string()) { + let _ = CustomElement::::build(builder); + } + } else if let Some(tex) = app.cached_texture.get() { + // Raster icon path warm: build the lightweight namespaced model + // with the cached texture so material/texture creation happens now. + let builder = Model::namespaced("protostar", "hexagon/hexagon") + .part(ModelPart::new("Hex").mat_param( + "color", + MaterialParameter::Color(crate::BTN_COLOR), + )) + .part(ModelPart::new("Icon").mat_param( + "diffuse", + MaterialParameter::Texture(tex.clone()), + )); + let _ = CustomElement::::build(builder); + } + }); + + // build immutable lightweight snapshots used during reify + self.snapshots = self + .apps + .iter() + .map(|a| Snapshot { + name: a.app.name().unwrap_or_default().to_string(), + cached_texture: a.cached_texture.get().cloned(), + cached_gltf: a.cached_gltf.get().cloned(), + }) + .collect(); + } + } + impl Reify for HexagonLauncher { + #[tracing::instrument(skip_all)] + fn reify(&self) -> impl Element { + // measure reify latency and count + let start = std::time::Instant::now(); + // Build UI based on current state - Grabbable::new( - Shape::Cylinder(CylinderShape { - radius: APP_SIZE / 2.0, - length: 0.01, - }), - self.pos, - self.rot, - |state: &mut Self, pos, rot| { - state.pos = pos; - state.rot = rot; - }, - ) - .field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2))) - .pointer_mode(PointerMode::Align) - .reparentable(true) - .build() - .child( - Button::new(|state: &mut HexagonLauncher| { - state.open = !state.open; - }) - .pos([0.0, 0.0, 0.005]) - .size([APP_SIZE / 2.0; 2]) - .build(), - ) - .child( - Model::namespaced("protostar", "hexagon/hexagon") - .transform(Transform::from_rotation_scale( - Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), - [MODEL_SCALE; 3], - )) - .part(ModelPart::new("Hex").mat_param( - "color", - MaterialParameter::Color(if self.open { - BTN_SELECTED_COLOR - } else { - BTN_COLOR - }), - )) - .build(), - ) - .children( - self.open - .then(|| { - self.apps.iter().enumerate().map(|(i, app)| { - Spatial::default() - .pos(Hex::spiral(i + 1).get_coords()) - .build() - .child(app.reify_substate(move |state: &mut HexagonLauncher| { - state.apps.get_mut(i) - })) - }) - }) - .into_iter() - .flatten(), - ) + let elem = Grabbable::new( + Shape::Cylinder(CylinderShape { + radius: APP_SIZE / 2.0, + length: 0.01, + }), + self.pos, + self.rot, + |state: &mut Self, pos, rot| { + // only update if changed enough to avoid constant reify + let dx = (state.pos.x - pos.x).abs(); + let dy = (state.pos.y - pos.y).abs(); + let dz = (state.pos.z - pos.z).abs(); + if dx > 0.0005 || dy > 0.0005 || dz > 0.0005 { + tracing::trace!(?pos, "updating grab position"); + state.pos = pos; + } + // rotation updates can also be debounced if noisy + state.rot = rot; + }, + ) + .field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2))) + .pointer_mode(PointerMode::Align) + .reparentable(true) + .build() + .child( + Button::new(|state: &mut HexagonLauncher| { + state.open = !state.open; + tracing::debug!(open = state.open, "toggled hexagon open"); + }) + .pos([0.0, 0.0, 0.005]) + .size([APP_SIZE / 2.0; 2]) + .build(), + ) + .child( + Model::namespaced("protostar", "hexagon/hexagon") + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .part(ModelPart::new("Hex").mat_param( + "color", + MaterialParameter::Color(DEFAULT_HEX_COLOR), + )) + .build(), + ) + // limit how many children we build per-frame to avoid reify explosion; + // increase if performance is acceptable, or implement a pager/virtualization. + .children({ + // read configured maximum (fall back to all apps) + let env_max = std::env::var("HEX_MAX_VISIBLE") + .ok() + .and_then(|s| s.parse::().ok()); + let configured_max = env_max.unwrap_or(self.apps.len()); + // desired target: if open -> min(configured_max, apps.len()) else 0 + let desired = if self.open { + std::cmp::min(configured_max, self.apps.len()) + } else { + 0 + }; + // nudge the global visible limit toward desired to spread creation cost + let current = VISIBLE_LIMIT.load(Ordering::Relaxed); + if desired == 0 { + // closing -> quickly collapse + if current != 0 { + VISIBLE_LIMIT.store(0, Ordering::Relaxed); + } + } else if current < desired { + let add = (desired - current).min(VISIBLE_STEP); + VISIBLE_LIMIT.fetch_add(add, Ordering::Relaxed); + } else if current > desired { + // clamp down if configured max reduced + VISIBLE_LIMIT.store(desired, Ordering::Relaxed); + } + + let take_n = std::cmp::min(VISIBLE_LIMIT.load(Ordering::Relaxed), self.apps.len()); + tracing::debug!(total_apps = self.apps.len(), configured_max, visible = take_n, desired, "building visible app children"); + + self.open + .then(|| { + self.apps + .iter() + .enumerate() + .take(take_n) + .map(|(i, _app)| { + // use snapshot instead of reify_substate (cheap, immutable) + let snap = self.snapshots[i].clone(); + let pos = self.positions[i]; + + // start from a fresh spatial element + let base = Spatial::default().pos(pos).build(); + + // ensure both branches return the same element type by always + // attaching a Model child (either GLTF builder or namespaced fallback) + let with_model = match snap.cached_gltf { + Some(ref gltf) => { + if let Ok(builder) = + Model::direct(gltf.to_string_lossy().to_string()) + { + base.child( + builder + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) + * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .build(), + ) + } else { + // fallback to namespaced model if direct GLTF build fails + let mut mb = Model::namespaced("protostar", "hexagon/hexagon") + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) + * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .part(ModelPart::new("Hex").mat_param( + "color", + MaterialParameter::Color(if self.open { + BTN_SELECTED_COLOR + } else { + BTN_COLOR + }), + )); + if let Some(tex) = snap.cached_texture { + mb = mb.part(ModelPart::new("Icon").mat_param( + "diffuse", + MaterialParameter::Texture(tex), + )); + } + base.child(mb.build()) + } + } + None => { + let mut mb = Model::namespaced("protostar", "hexagon/hexagon") + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) + * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .part(ModelPart::new("Hex").mat_param( + "color", + MaterialParameter::Color(DEFAULT_HEX_COLOR), + )); + if let Some(tex) = snap.cached_texture { + mb = mb.part(ModelPart::new("Icon").mat_param( + "diffuse", + MaterialParameter::Texture(tex), + )); + } + base.child(mb.build()) + } + }; + + // attach a Button that mutates real state when used (captures index) + with_model.child( + Button::new(move |state: &mut HexagonLauncher| { + tracing::debug!(index = i, "app button pressed"); + }) + .pos([0.0, 0.0, 0.0]) + .size([0.01; 2]) + .build(), + ) + }) + }) + .into_iter() + .flatten() + }) + ; + + let elapsed = start.elapsed().as_nanos() as u64; + REIFY_TOTAL_NS.fetch_add(elapsed, Ordering::Relaxed); + REIFY_COUNT.fetch_add(1, Ordering::Relaxed); + elem } } diff --git a/single/src/app.rs b/single/src/app.rs index 0deead0..5b58d67 100644 --- a/single/src/app.rs +++ b/single/src/app.rs @@ -7,14 +7,12 @@ use stardust_xr_asteroids::elements::{ Grabbable, Lines, Model, ModelPart, PointerMode, Text, line_from_points, }; use stardust_xr_asteroids::{CustomElement, Element, Reify, Transformable}; -use stardust_xr_fusion::drawable::{TextBounds, TextFit}; -use stardust_xr_fusion::node::NodeError; +use stardust_xr_fusion::drawable::{TextBounds, TextFit, MaterialParameter, XAlign, YAlign}; +use stardust_xr_fusion::fields::{CylinderShape, Shape}; +use stardust_xr_fusion::spatial::Transform; use stardust_xr_fusion::values::ResourceID; -use stardust_xr_fusion::{ - drawable::{MaterialParameter, XAlign, YAlign}, - fields::{CylinderShape, Shape}, - spatial::Transform, -}; +use stardust_xr_fusion::node::NodeError; +use std::path::PathBuf; use std::f32::consts::{FRAC_PI_2, PI}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -24,140 +22,181 @@ use crate::{ACTIVATION_DISTANCE, APP_SIZE, DEFAULT_HEX_COLOR, MODEL_SCALE}; #[derive(Debug, Serialize, Deserialize)] pub struct App { - pub app: Application, - #[serde(skip)] - icon: OnceLock, - pos: Vector3, - rot: Quaternion, - #[serde(skip)] - launched: AtomicBool, + pub app: Application, + #[serde(skip)] + icon: OnceLock, + // cached lightweight handles derived from the icon: + // - for PNG/raster icons we cache a ResourceID::Direct for the texture + #[serde(skip)] + pub cached_texture: OnceLock, + #[serde(skip)] + pub cached_gltf: OnceLock, + pos: Vector3, + rot: Quaternion, + #[serde(skip)] + launched: AtomicBool, } impl App { - pub fn new(desktop_entry: DesktopFile) -> Result { - let app = Application::create(desktop_entry)?; - Ok(App { - app, - icon: OnceLock::default(), - pos: [0.0; 3].into(), - rot: Quat::IDENTITY.into(), - launched: AtomicBool::new(false), - }) - } + pub fn new(desktop_entry: DesktopFile) -> Result { + let app = Application::create(desktop_entry)?; + Ok(App { + app, + icon: OnceLock::default(), + cached_texture: OnceLock::default(), + cached_gltf: OnceLock::default(), + pos: [0.0; 3].into(), + rot: Quat::IDENTITY.into(), + launched: AtomicBool::new(false), + }) + } - pub fn load_icon(&self) { - if self.icon.get().is_none() - && let Some(icon) = self - .app - .icon(64, true) - .and_then(|i| i.cached_process(64).ok()) - { - let _ = self.icon.set(icon); - } - } + pub fn load_icon(&self) { + // Only attempt to load/process the icon once + if self.icon.get().is_none() { + if let Some(icon) = self + .app + .icon(64, true) + .and_then(|i| i.cached_process(64).ok()) + { + // store raw icon + let _ = self.icon.set(icon.clone()); - // Helper functions for creating app components - fn create_model(&self) -> impl Element { - match self.icon.get().as_ref().map(|i| (i.icon_type.clone(), i)) { - Some((IconType::Gltf, icon)) => Model::direct(icon.path.clone()) - .unwrap() - .transform(Transform::from_rotation_scale( - Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), - [MODEL_SCALE; 3], - )) - .build(), - other => { - let model = Model::namespaced("protostar", "hexagon/hexagon") - .transform(Transform::from_rotation_scale( - Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), - [APP_SIZE / 2.0; 3], - )) - .part( - ModelPart::new("Hex") - .mat_param("color", MaterialParameter::Color(DEFAULT_HEX_COLOR)), - ); + // cache lightweight handles derived from the icon so reify can be cheap + match &icon.icon_type { + IconType::Gltf => { + // cache the gltf path (PathBuf) so we don't call Model::direct() discovery per-reify + let _ = self.cached_gltf.set(icon.path.clone()); + } + IconType::Png => { + // cache a ResourceID::Direct for the texture + let _ = self + .cached_texture + .set(ResourceID::Direct(icon.path.clone())); + } + _ => {} + } + } + } + } - match other { - Some((IconType::Png, icon)) => model.part(ModelPart::new("Icon").mat_param( - "diffuse", - MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())), - )), - _ => model, - } - .build() - } - } - } + // Helper functions for creating app components + fn create_model(&self) -> impl Element { + // prefer cached gltf path if present (avoids repeated Model::direct parsing) + if let Some(gltf_path) = self.cached_gltf.get() { + // Model::direct accepts a string/path — convert PathBuf to String here to be safe + if let Ok(builder) = Model::direct(gltf_path.to_string_lossy().to_string()) { + return builder + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .build(); + } + } + + // fallback / raster icon path: use a namespaced hex model and attach a cached texture + let mut model = Model::namespaced("protostar", "hexagon/hexagon") + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), + [APP_SIZE / 2.0; 3], + )) + .part(ModelPart::new("Hex").mat_param( + "color", + MaterialParameter::Color(DEFAULT_HEX_COLOR), + )); + + if let Some(tex) = self.cached_texture.get() { + model = model.part(ModelPart::new("Icon").mat_param( + "diffuse", + MaterialParameter::Texture(tex.clone()), + )); + } else if let Some(icon) = self.icon.get() { + // fallback to using icon path directly if caching didn't happen for some reason + if let IconType::Png = icon.icon_type { + model = model.part(ModelPart::new("Icon").mat_param( + "diffuse", + MaterialParameter::Texture(ResourceID::Direct(icon.path.clone())), + )); + } + } + + model.build() + } } impl Reify for App { - #[tracing::instrument(skip_all)] - fn reify(&self) -> impl Element { - // The field shape for the grabbable - let field_shape = Shape::Cylinder(CylinderShape { - radius: APP_SIZE / 2.0, - length: 0.01, - }); + #[tracing::instrument(skip_all)] + fn reify(&self) -> impl Element { + // The field shape for the grabbable + let field_shape = Shape::Cylinder(CylinderShape { + radius: APP_SIZE / 2.0, + length: 0.01, + }); - let converted = Vec3::from(self.pos); - let length = converted.length(); - let direction = converted.normalize_or_zero(); + let converted = Vec3::from(self.pos); + let length = converted.length(); + let direction = converted.normalize_or_zero(); - Lines::new([line_from_points(vec![ - Vec3::from([0.0; 3]), - (length < ACTIVATION_DISTANCE) as u32 as f32 - * direction * length.clamp(0.0, ACTIVATION_DISTANCE), - ])]) - .build() - .child( - Grabbable::new( - field_shape, - self.pos, - self.rot, - move |state: &mut Self, pos, rot| { - state.pos = pos; - state.rot = rot; - }, - ) - .grab_stop({ - move |state: &mut Self| { - let pos_vec = Vec3::from(state.pos); - if pos_vec.length() > ACTIVATION_DISTANCE { - // state.app.launch(launch_space) - state.launched.store(true, Ordering::Relaxed); - } else { - state.pos = [0.0; 3].into(); - state.rot = Quat::IDENTITY.into(); - } - } - }) - .field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2))) - .pointer_mode(PointerMode::Align) - .max_distance(0.05) - .reparentable(false) - .build() - .child(self.create_model()) - .children(self.launched.load(Ordering::Relaxed).then(|| { - AppLauncher::new(&self.app) - .done(|state: &mut Self| { - state.launched.store(false, Ordering::Relaxed); - state.pos = [0.0; 3].into(); - state.rot = Quat::IDENTITY.into(); - }) - .build() - })) - .child( - Text::new(self.app.name().unwrap_or_default()) - .character_height(0.005) - .bounds(TextBounds { - bounds: [0.04, 0.04].into(), - fit: TextFit::Wrap, - anchor_align_x: XAlign::Center, - anchor_align_y: YAlign::Bottom, - }) - .align_x(XAlign::Center) - .align_y(YAlign::Bottom) - .pos([0.0, -APP_SIZE * 0.35, 0.002]) - .build(), - ), - ) - } + // cull models that are far away to avoid heavy model builds every frame + const CULL_DISTANCE: f32 = 4.0; + let should_render_model = length <= CULL_DISTANCE; + + Lines::new([line_from_points(vec![ + Vec3::from([0.0; 3]), + (length < ACTIVATION_DISTANCE) as u32 as f32 + * direction * length.clamp(0.0, ACTIVATION_DISTANCE), + ])]) + .build() + .child( + Grabbable::new( + field_shape, + self.pos, + self.rot, + move |state: &mut Self, pos, rot| { + state.pos = pos; + state.rot = rot; + }, + ) + .grab_stop({ + move |state: &mut Self| { + let pos_vec = Vec3::from(state.pos); + if pos_vec.length() > ACTIVATION_DISTANCE { + state.launched.store(true, Ordering::Relaxed); + } else { + state.pos = [0.0; 3].into(); + state.rot = Quat::IDENTITY.into(); + } + } + }) + .field_transform(Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2))) + .pointer_mode(PointerMode::Align) + .max_distance(0.05) + .reparentable(false) + .build() + // only build the (potentially expensive) model element when the hex is close enough + .children(should_render_model.then(|| self.create_model()).into_iter()) + .children(self.launched.load(Ordering::Relaxed).then(|| { + AppLauncher::new(&self.app) + .done(|state: &mut Self| { + state.launched.store(false, Ordering::Relaxed); + state.pos = [0.0; 3].into(); + state.rot = Quat::IDENTITY.into(); + }) + .build() + })) + .child( + Text::new(self.app.name().unwrap_or_default()) + .character_height(0.005) + .bounds(TextBounds { + bounds: [0.04, 0.04].into(), + fit: TextFit::Wrap, + anchor_align_x: XAlign::Center, + anchor_align_y: YAlign::Bottom, + }) + .align_x(XAlign::Center) + .align_y(YAlign::Bottom) + .pos([0.0, -APP_SIZE * 0.35, 0.002]) + .build(), + ), + ) + } }