From 7455b35ffdf6311d3023f39af0a4284926864541 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 1 Nov 2025 22:42:42 -0400 Subject: [PATCH 1/8] adding a counter of reify per sec --- hexagon_launcher/src/main.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 482e775..43fb8b2 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -18,12 +18,26 @@ use stardust_xr_fusion::{ spatial::Transform, }; use std::f32::consts::{FRAC_PI_2, PI}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::time::Duration; + +static REIFY_COUNT: AtomicUsize = AtomicUsize::new(0); + 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); + tracing::info!(reify_per_sec = v, "hexagon reify rate"); + } + }); + let registry = tracing_subscriber::registry(); #[cfg(feature = "tracy")] let registry = registry.with({ @@ -89,6 +103,9 @@ impl ClientState for HexagonLauncher { impl Reify for HexagonLauncher { #[tracing::instrument(skip_all)] fn reify(&self) -> impl Element { + // increment counter so we can observe how often reify is called + REIFY_COUNT.fetch_add(1, Ordering::Relaxed); + // Build UI based on current state Grabbable::new( Shape::Cylinder(CylinderShape { -- 2.49.1 From 7319578b8b243bbe1a4cdba009a6b267637500af Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 1 Nov 2025 23:31:18 -0400 Subject: [PATCH 2/8] Added a counter for the ms complete of reify loop --- hexagon_launcher/src/main.rs | 145 +++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 58 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 43fb8b2..a6a57b1 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -19,9 +19,12 @@ use stardust_xr_fusion::{ }; 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); use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; @@ -34,7 +37,15 @@ async fn main() { loop { tokio::time::sleep(Duration::from_secs(1)).await; let v = REIFY_COUNT.swap(0, Ordering::Relaxed); - tracing::info!(reify_per_sec = v, "hexagon reify rate"); + 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" + ); } }); @@ -103,64 +114,82 @@ impl ClientState for HexagonLauncher { impl Reify for HexagonLauncher { #[tracing::instrument(skip_all)] fn reify(&self) -> impl Element { - // increment counter so we can observe how often reify is called - REIFY_COUNT.fetch_add(1, Ordering::Relaxed); + // 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(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| { + // log & count access to per-app substate + APP_REIFY_COUNT.fetch_add(1, Ordering::Relaxed); + tracing::trace!(index = i, "accessing app substate"); + state.apps.get_mut(i) + })) + }) + }) + .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 } } -- 2.49.1 From a978b2ad681cf80c876afd19cea4c4399f049b95 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 1 Nov 2025 23:50:43 -0400 Subject: [PATCH 3/8] cache hex position --- hexagon_launcher/src/main.rs | 74 +++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index a6a57b1..cf02bd7 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -25,6 +25,8 @@ 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}; @@ -75,6 +77,9 @@ pub struct HexagonLauncher { #[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]>, } impl Default for HexagonLauncher { @@ -84,6 +89,7 @@ impl Default for HexagonLauncher { pos: [0.0; 3].into(), rot: Quat::IDENTITY.into(), apps: Vec::new(), + positions: Vec::new(), } } } @@ -109,6 +115,11 @@ impl ClientState for HexagonLauncher { // Sort by name self.apps .sort_by_key(|app| app.app.name().unwrap_or_default().to_string()); + + // 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(); } } impl Reify for HexagonLauncher { @@ -167,24 +178,59 @@ impl Reify for HexagonLauncher { )) .build(), ) - .children( + // 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().map(|(i, app)| { - Spatial::default() - .pos(Hex::spiral(i + 1).get_coords()) - .build() - .child(app.reify_substate(move |state: &mut HexagonLauncher| { - // log & count access to per-app substate - APP_REIFY_COUNT.fetch_add(1, Ordering::Relaxed); - tracing::trace!(index = i, "accessing app substate"); - state.apps.get_mut(i) - })) - }) + self.apps + .iter() + .enumerate() + .take(take_n) + .map(|(i, app)| { + Spatial::default() + .pos(self.positions[i]) + .build() + .child(app.reify_substate(move |state: &mut HexagonLauncher| { + // log & count access to per-app substate + APP_REIFY_COUNT.fetch_add(1, Ordering::Relaxed); + tracing::trace!(index = i, "accessing app substate"); + state.apps.get_mut(i) + })) + }) }) .into_iter() - .flatten(), - ) + .flatten() + }) ; let elapsed = start.elapsed().as_nanos() as u64; -- 2.49.1 From 4a761f27dd8d051b5229e2307e8e3f8d29b919d0 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 2 Nov 2025 00:23:06 -0400 Subject: [PATCH 4/8] adding more caching features --- hexagon_launcher/src/main.rs | 39 +++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index cf02bd7..41ae7b4 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use single::{APP_SIZE, App, BTN_COLOR, BTN_SELECTED_COLOR, MODEL_SCALE}; use stardust_xr_asteroids::{ ClientState, CustomElement, Element, Migrate, Reify, Transformable, client, - elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial}, + elements::{Button, Grabbable, Model, ModelPart}, }; use stardust_xr_fusion::{ drawable::MaterialParameter, @@ -108,10 +108,6 @@ 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()); @@ -120,6 +116,39 @@ impl ClientState for HexagonLauncher { 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() { + let _ = Model::direct(gltf_path.to_string_lossy().to_string()); + } 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 _ = 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()), + )) + .build(); + } + }); } } impl Reify for HexagonLauncher { -- 2.49.1 From 3cc68df2d9a8646bc7afa118a1084eca7aa0a7ea Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 2 Nov 2025 00:32:46 -0400 Subject: [PATCH 5/8] more optimisation whatever idk really know what I am doing --- hexagon_launcher/src/main.rs | 142 +++++++++++----- single/src/app.rs | 307 ++++++++++++++++++++--------------- 2 files changed, 277 insertions(+), 172 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 41ae7b4..ffba1af 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -8,8 +8,8 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use single::{APP_SIZE, App, BTN_COLOR, BTN_SELECTED_COLOR, MODEL_SCALE}; use stardust_xr_asteroids::{ - ClientState, CustomElement, Element, Migrate, Reify, Transformable, client, - elements::{Button, Grabbable, Model, ModelPart}, + ClientState, CustomElement, Element, Migrate, Reify, Transformable, client, + elements::{Button, Grabbable, Model, ModelPart, PointerMode, Spatial}, }; use stardust_xr_fusion::{ drawable::MaterialParameter, @@ -17,6 +17,8 @@ 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; @@ -70,16 +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, - #[serde(skip)] - /// cached world coordinates for each app hex - positions: Vec<[f32; 3]>, + /// lightweight immutable snapshots for fast per-frame reify + snapshots: Vec, +} + +#[derive(Clone)] +struct Snapshot { + name: String, + cached_texture: Option, + cached_gltf: Option, } impl Default for HexagonLauncher { @@ -90,6 +102,7 @@ impl Default for HexagonLauncher { rot: Quat::IDENTITY.into(), apps: Vec::new(), positions: Vec::new(), + snapshots: Vec::new(), } } } @@ -133,23 +146,36 @@ impl ClientState for HexagonLauncher { 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() { - let _ = Model::direct(gltf_path.to_string_lossy().to_string()); - } 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 _ = 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()), - )) - .build(); - } - }); - } + 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(), + cached_texture: a.cached_texture.get().cloned(), + cached_gltf: a.cached_gltf.get().cloned(), + }) + .collect(); + } } impl Reify for HexagonLauncher { #[tracing::instrument(skip_all)] @@ -245,16 +271,56 @@ impl Reify for HexagonLauncher { .iter() .enumerate() .take(take_n) - .map(|(i, app)| { - Spatial::default() - .pos(self.positions[i]) - .build() - .child(app.reify_substate(move |state: &mut HexagonLauncher| { - // log & count access to per-app substate - APP_REIFY_COUNT.fetch_add(1, Ordering::Relaxed); - tracing::trace!(index = i, "accessing app substate"); - state.apps.get_mut(i) - })) + .map(|(i, _app)| { + // use snapshot instead of reify_substate (cheap, immutable) + let snap = self.snapshots[i].clone(); + let pos = self.positions[i]; + // build spatial + cheap model from snapshot (no per-app state access) + let mut spatial = Spatial::default().pos(pos).build(); ++ ++ // attach model from snapshot (gltf preferred, else namespaced + texture) ++ if let Some(gltf) = snap.cached_gltf { ++ if let Ok(builder) = Model::direct(gltf.to_string_lossy().to_string()) { ++ spatial = spatial.child(builder.transform(Transform::from_rotation_scale( ++ Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), ++ [MODEL_SCALE; 3], ++ )).build()); ++ } ++ } else { ++ 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), ++ )); ++ } ++ spatial = spatial.child(mb.build()); ++ } ++ ++ // attach a Button that mutates real state when used (captures index) ++ spatial.child( ++ Button::new(move |state: &mut HexagonLauncher| { ++ // example: toggle open / or launch the app via state.apps[i] ++ // keep mutation here, but we avoid doing this per-frame. ++ // if you need to launch: state.apps[i].launch(...); ++ tracing::debug!(index = i, "app button pressed"); ++ }) ++ .pos([0.0, 0.0, 0.0]) ++ .size([0.01; 2]) ++ .build(), ++ ) }) }) .into_iter() 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(), + ), + ) + } } -- 2.49.1 From 97052a0d1a4bd26f25ec22a8e041e861915694fd Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 2 Nov 2025 00:33:07 -0400 Subject: [PATCH 6/8] remove stray '+' tokens, derive Debug for Snapshot, and convert name to String --- hexagon_launcher/src/main.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index ffba1af..284912e 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -87,7 +87,7 @@ pub struct HexagonLauncher { snapshots: Vec, } -#[derive(Clone)] +#[derive(Debug, Clone)] struct Snapshot { name: String, cached_texture: Option, @@ -170,16 +170,17 @@ impl ClientState for HexagonLauncher { .apps .iter() .map(|a| Snapshot { - name: a.app.name().unwrap_or_default(), - 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 { +- name: a.app.name().unwrap_or_default(), ++ 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(); -- 2.49.1 From f3474cc10005b9ff6e7cfc201e1296bf3c4227a9 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 2 Nov 2025 00:40:30 -0400 Subject: [PATCH 7/8] patching last commit --- hexagon_launcher/src/main.rs | 103 ++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 284912e..35cc9e2 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -170,13 +170,12 @@ impl ClientState for HexagonLauncher { .apps .iter() .map(|a| Snapshot { -- name: a.app.name().unwrap_or_default(), -+ name: a.app.name().unwrap_or_default().to_string(), - cached_texture: a.cached_texture.get().cloned(), - cached_gltf: a.cached_gltf.get().cloned(), - }) - .collect(); - } + 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)] @@ -278,50 +277,52 @@ impl ClientState for HexagonLauncher { let pos = self.positions[i]; // build spatial + cheap model from snapshot (no per-app state access) let mut spatial = Spatial::default().pos(pos).build(); -+ -+ // attach model from snapshot (gltf preferred, else namespaced + texture) -+ if let Some(gltf) = snap.cached_gltf { -+ if let Ok(builder) = Model::direct(gltf.to_string_lossy().to_string()) { -+ spatial = spatial.child(builder.transform(Transform::from_rotation_scale( -+ Quat::from_rotation_x(PI / 2.0) * Quat::from_rotation_y(PI), -+ [MODEL_SCALE; 3], -+ )).build()); -+ } -+ } else { -+ 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), -+ )); -+ } -+ spatial = spatial.child(mb.build()); -+ } -+ -+ // attach a Button that mutates real state when used (captures index) -+ spatial.child( -+ Button::new(move |state: &mut HexagonLauncher| { -+ // example: toggle open / or launch the app via state.apps[i] -+ // keep mutation here, but we avoid doing this per-frame. -+ // if you need to launch: state.apps[i].launch(...); -+ tracing::debug!(index = i, "app button pressed"); -+ }) -+ .pos([0.0, 0.0, 0.0]) -+ .size([0.01; 2]) -+ .build(), -+ ) + + // attach model from snapshot (gltf preferred, else namespaced + texture) + if let Some(gltf) = snap.cached_gltf { + if let Ok(builder) = Model::direct(gltf.to_string_lossy().to_string()) { + spatial = spatial.child( + builder + .transform(Transform::from_rotation_scale( + Quat::from_rotation_x(PI / 2.0) + * Quat::from_rotation_y(PI), + [MODEL_SCALE; 3], + )) + .build(), + ); + } + } else { + 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), + )); + } + spatial = spatial.child(mb.build()); + } + + // attach a Button that mutates real state when used (captures index) + spatial.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() -- 2.49.1 From 80fa52493d4ad6b7bb8444d367c2848ddcef0a2b Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 2 Nov 2025 00:43:26 -0400 Subject: [PATCH 8/8] alright last patch, I think I just doubled the framerate... --- hexagon_launcher/src/main.rs | 97 ++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/hexagon_launcher/src/main.rs b/hexagon_launcher/src/main.rs index 35cc9e2..cf7dd68 100644 --- a/hexagon_launcher/src/main.rs +++ b/hexagon_launcher/src/main.rs @@ -6,7 +6,7 @@ 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}, @@ -225,11 +225,7 @@ impl ClientState for HexagonLauncher { )) .part(ModelPart::new("Hex").mat_param( "color", - MaterialParameter::Color(if self.open { - BTN_SELECTED_COLOR - } else { - BTN_COLOR - }), + MaterialParameter::Color(DEFAULT_HEX_COLOR), )) .build(), ) @@ -275,47 +271,74 @@ impl ClientState for HexagonLauncher { // use snapshot instead of reify_substate (cheap, immutable) let snap = self.snapshots[i].clone(); let pos = self.positions[i]; - // build spatial + cheap model from snapshot (no per-app state access) - let mut spatial = Spatial::default().pos(pos).build(); - // attach model from snapshot (gltf preferred, else namespaced + texture) - if let Some(gltf) = snap.cached_gltf { - if let Ok(builder) = Model::direct(gltf.to_string_lossy().to_string()) { - spatial = spatial.child( - builder + // 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], )) - .build(), - ); + .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()) + } } - } else { - 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), - )); + 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()) } - spatial = spatial.child(mb.build()); - } + }; // attach a Button that mutates real state when used (captures index) - spatial.child( + with_model.child( Button::new(move |state: &mut HexagonLauncher| { tracing::debug!(index = i, "app button pressed"); }) -- 2.49.1