Centralize world and viewport game loops
This commit is contained in:
@@ -18,6 +18,7 @@ libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging"
|
||||
libremetaverse-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true }
|
||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||
metacrate-lsl-tools = { version = "0.0.1", path = "../metacrate-lsl-tools" }
|
||||
metacrate-game-loop = { version = "0.0.1", path = "../metacrate-game-loop" }
|
||||
metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu" }
|
||||
mentra = { version = "0.18.3", default-features = false }
|
||||
jpeg-encoder = "0.6.1"
|
||||
|
||||
@@ -107,6 +107,7 @@ pub struct LibremetaverseClientOwner {
|
||||
pub struct LibremetaverseWorldSnapshotSource {
|
||||
client: libremetaverse::GridClient,
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
world: metacrate_game_loop::WorldReader,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -143,6 +144,7 @@ pub struct LibremetaverseSessionBackend {
|
||||
interaction: Option<crate::interaction::InteractionIngress>,
|
||||
perception: Option<crate::perception::PerceptionIngress>,
|
||||
behavior: Option<crate::behavior::BehaviorIngress>,
|
||||
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
@@ -319,6 +321,7 @@ impl LibremetaverseClientOwner {
|
||||
interaction,
|
||||
perception,
|
||||
behavior,
|
||||
framework_readiness: None,
|
||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||
})
|
||||
}
|
||||
@@ -332,10 +335,14 @@ impl LibremetaverseClientOwner {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource {
|
||||
pub fn world_snapshot_source(
|
||||
&self,
|
||||
world: metacrate_game_loop::WorldReader,
|
||||
) -> LibremetaverseWorldSnapshotSource {
|
||||
LibremetaverseWorldSnapshotSource {
|
||||
client: self.client.clone(),
|
||||
agent: Arc::clone(&self.agent),
|
||||
world,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +356,18 @@ impl LibremetaverseClientOwner {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl LibremetaverseSessionBackend {
|
||||
#[must_use]
|
||||
pub fn with_framework_readiness(
|
||||
mut self,
|
||||
readiness: Arc<crate::session::FrameworkReadiness>,
|
||||
) -> Self {
|
||||
self.framework_readiness = Some(readiness);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
|
||||
directories::ProjectDirs::from("", "", "metacrate")
|
||||
@@ -362,10 +381,9 @@ fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
|
||||
impl LibremetaverseWorldSnapshotSource {
|
||||
#[must_use]
|
||||
pub fn current_region_handle(&self) -> Option<u64> {
|
||||
self.client
|
||||
.network()
|
||||
.native_current_sim()
|
||||
.map(|simulator| simulator.handle)
|
||||
self.world
|
||||
.latest()
|
||||
.and_then(|world| world.value.region.as_ref().map(|region| region.handle))
|
||||
}
|
||||
|
||||
// One atomic projection keeps native cache reads together. Terrain lookup
|
||||
@@ -386,19 +404,24 @@ impl LibremetaverseWorldSnapshotSource {
|
||||
{
|
||||
return Err(crate::perception::PerceptionError::NotReady);
|
||||
}
|
||||
let world = self
|
||||
.world
|
||||
.latest()
|
||||
.ok_or(crate::perception::PerceptionError::NotReady)?;
|
||||
if world.value.region.as_ref().map(|region| region.id) != Some(simulator.region_id) {
|
||||
return Err(crate::perception::PerceptionError::NotReady);
|
||||
}
|
||||
let agent_position = self.agent.sim_position();
|
||||
let position = crate::perception::WorldPosition {
|
||||
x: f64::from(agent_position.x),
|
||||
y: f64::from(agent_position.y),
|
||||
z: f64::from(agent_position.z),
|
||||
};
|
||||
let avatar_cache = simulator
|
||||
.objects_avatars
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let avatars_truncated = avatar_cache.len() > 2_048;
|
||||
let avatars_truncated = world.value.avatars.len() > 2_048;
|
||||
let mut avatars = BTreeMap::new();
|
||||
for (local_id, avatar) in avatar_cache
|
||||
for (local_id, avatar) in world
|
||||
.value
|
||||
.avatars
|
||||
.iter()
|
||||
.filter(|(_, avatar)| avatar.id != UUID::zero())
|
||||
{
|
||||
@@ -414,15 +437,12 @@ impl LibremetaverseWorldSnapshotSource {
|
||||
avatars.pop_last();
|
||||
}
|
||||
}
|
||||
drop(avatar_cache);
|
||||
let avatars = avatars.into_values().collect();
|
||||
let object_cache = simulator
|
||||
.objects_primitives
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let objects_truncated = object_cache.len() > 2_048;
|
||||
let objects_truncated = world.value.primitives.len() > 2_048;
|
||||
let mut objects = BTreeMap::new();
|
||||
for (local_id, primitive) in object_cache
|
||||
for (local_id, primitive) in world
|
||||
.value
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|(_, primitive)| primitive.id != UUID::zero())
|
||||
{
|
||||
@@ -452,7 +472,6 @@ impl LibremetaverseWorldSnapshotSource {
|
||||
objects.pop_last();
|
||||
}
|
||||
}
|
||||
drop(object_cache);
|
||||
let objects = objects.into_values().collect();
|
||||
let parcel = simulator
|
||||
.parcels
|
||||
@@ -500,7 +519,7 @@ impl LibremetaverseWorldSnapshotSource {
|
||||
let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client);
|
||||
Ok(crate::perception::WorldSnapshot {
|
||||
generation,
|
||||
observed_unix_millis: crate::perception::unix_millis_now(),
|
||||
observed_unix_millis: world.value.observed_unix_millis,
|
||||
region_id: simulator.region_id,
|
||||
region_name: crate::perception::sanitize_untrusted(&simulator.name),
|
||||
agent: crate::perception::AgentSnapshot {
|
||||
@@ -1029,6 +1048,8 @@ struct LibremetaverseSession {
|
||||
interaction: Option<crate::interaction::InteractionIngress>,
|
||||
perception: Option<crate::perception::PerceptionIngress>,
|
||||
behavior: Option<crate::behavior::BehaviorIngress>,
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
framework_readiness: Option<Arc<crate::session::FrameworkReadiness>>,
|
||||
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
@@ -1043,7 +1064,7 @@ impl crate::session::GridSession for LibremetaverseSession {
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
|
||||
Box::pin(async move {
|
||||
tokio::select! {
|
||||
let signal = tokio::select! {
|
||||
signal = self.signals.recv() => signal.unwrap_or_else(|| {
|
||||
crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
@@ -1056,7 +1077,36 @@ impl crate::session::GridSession for LibremetaverseSession {
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
),
|
||||
),
|
||||
};
|
||||
if matches!(signal, crate::session::SessionSignal::Ready) {
|
||||
if let Some(readiness) = &self.framework_readiness
|
||||
&& !readiness.wait_ready(self.generation, cancellation).await
|
||||
{
|
||||
return crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
),
|
||||
);
|
||||
}
|
||||
*self
|
||||
.delivery_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(self.generation);
|
||||
if let Some(interaction) = &self.interaction {
|
||||
let _ = interaction.try_connected(self.generation, self.agent.agent_id());
|
||||
}
|
||||
if let (Some(perception), Some(simulator)) =
|
||||
(&self.perception, self.network.native_current_sim())
|
||||
{
|
||||
let _ = perception.connected(self.generation, simulator.region_id);
|
||||
}
|
||||
if let (Some(behavior), Some(simulator)) =
|
||||
(&self.behavior, self.network.native_current_sim())
|
||||
{
|
||||
let _ = behavior.connected(self.generation, simulator.region_id);
|
||||
}
|
||||
}
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1220,34 +1270,12 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
));
|
||||
}));
|
||||
let ready_sender = sender.clone();
|
||||
let ready_interaction = self.interaction.clone();
|
||||
let ready_perception = self.perception.clone();
|
||||
let ready_behavior = self.behavior.clone();
|
||||
let ready_network = self.network.clone();
|
||||
let ready_agent = Arc::clone(&self.agent);
|
||||
let ready_generation = Arc::clone(&self.delivery_generation);
|
||||
let ready_once = Arc::new(AtomicBool::new(false));
|
||||
let callback_ready = Arc::clone(&ready_once);
|
||||
let ready = self
|
||||
.network
|
||||
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
||||
if !callback_ready.swap(true, Ordering::AcqRel) {
|
||||
*ready_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
||||
if let Some(interaction) = &ready_interaction {
|
||||
let _ = interaction.try_connected(generation, ready_agent.agent_id());
|
||||
}
|
||||
if let (Some(perception), Some(simulator)) =
|
||||
(&ready_perception, ready_network.native_current_sim())
|
||||
{
|
||||
let _ = perception.connected(generation, simulator.region_id);
|
||||
}
|
||||
if let (Some(behavior), Some(simulator)) =
|
||||
(&ready_behavior, ready_network.native_current_sim())
|
||||
{
|
||||
let _ = behavior.connected(generation, simulator.region_id);
|
||||
}
|
||||
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
}));
|
||||
@@ -1299,23 +1327,6 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
.unwrap_or(false)
|
||||
&& !ready_once.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
*self
|
||||
.delivery_generation
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation);
|
||||
if let Some(interaction) = &self.interaction {
|
||||
let _ = interaction.try_connected(generation, self.agent.agent_id());
|
||||
}
|
||||
if let (Some(perception), Some(simulator)) =
|
||||
(&self.perception, self.network.native_current_sim())
|
||||
{
|
||||
let _ = perception.connected(generation, simulator.region_id);
|
||||
}
|
||||
if let (Some(behavior), Some(simulator)) =
|
||||
(&self.behavior, self.network.native_current_sim())
|
||||
{
|
||||
let _ = behavior.connected(generation, simulator.region_id);
|
||||
}
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
||||
@@ -1326,6 +1337,8 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
interaction: self.interaction.clone(),
|
||||
perception: self.perception.clone(),
|
||||
behavior: self.behavior.clone(),
|
||||
agent: Arc::clone(&self.agent),
|
||||
framework_readiness: self.framework_readiness.clone(),
|
||||
delivery_generation: Arc::clone(&self.delivery_generation),
|
||||
});
|
||||
Ok(session)
|
||||
|
||||
@@ -164,9 +164,9 @@ pub use script_delivery::{
|
||||
};
|
||||
pub use service::{AgentService, ServiceError, ServiceHandle, ServiceState};
|
||||
pub use session::{
|
||||
GridSession, GridSessionBackend, ReconnectPolicy, SessionControl, SessionFailure,
|
||||
SessionFailureKind, SessionFuture, SessionObservation, SessionReason, SessionSignal,
|
||||
SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
|
||||
FrameworkReadiness, GridSession, GridSessionBackend, ReconnectPolicy, SessionControl,
|
||||
SessionFailure, SessionFailureKind, SessionFuture, SessionObservation, SessionReason,
|
||||
SessionSignal, SessionState, SessionStatus, SessionSupervisor, SessionSupervisorError,
|
||||
SessionSupervisorHandle, SessionWork, WorkDisposition, WorkKind,
|
||||
};
|
||||
pub use tool_loop::{ToolExecution, ToolExecutor, ToolFuture, ToolLoopError, ToolLoopLimits};
|
||||
|
||||
@@ -273,9 +273,9 @@ async fn run_live(
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
use metacrate_grid_agent::{
|
||||
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
|
||||
GridSessionBackend, LibremetaverseClientOwner, OperatingMode, RuntimeControlCommand,
|
||||
SessionControl, SessionObservation, SessionState, SessionSupervisor, TcpControlConfig,
|
||||
TcpControlServer, WorldSnapshotSource,
|
||||
FrameworkReadiness, GridSessionBackend, LibremetaverseClientOwner, OperatingMode,
|
||||
RuntimeControlCommand, SessionControl, SessionObservation, SessionReason, SessionState,
|
||||
SessionSupervisor, TcpControlConfig, TcpControlServer, WorldSnapshotSource,
|
||||
};
|
||||
|
||||
let connection = config.grid.clone().ok_or_else(|| {
|
||||
@@ -285,15 +285,20 @@ async fn run_live(
|
||||
config.vision.asset_cache_max_bytes,
|
||||
)?;
|
||||
let mut live = start_live_interactions(&config, &owner).await?;
|
||||
let framework_readiness = Arc::new(FrameworkReadiness::default());
|
||||
let backend = match owner.session_backend_with_agent_services(
|
||||
connection,
|
||||
live.interaction.ingress(),
|
||||
live.perception.clone(),
|
||||
live.behavior.ingress(),
|
||||
) {
|
||||
Ok(backend) => backend,
|
||||
Ok(backend) => backend.with_framework_readiness(Arc::clone(&framework_readiness)),
|
||||
Err(error) => {
|
||||
live.vision.stop_viewport().await;
|
||||
live.scene_source.stop_prefetch().await;
|
||||
if let Some(world_loop) = live.world_loop.take() {
|
||||
let _ = world_loop.shutdown();
|
||||
}
|
||||
live.interaction.shutdown().await?;
|
||||
live.behavior.shutdown().await?;
|
||||
return Err(error.into());
|
||||
@@ -313,6 +318,26 @@ async fn run_live(
|
||||
let readiness = tokio::time::timeout(config.timeouts.startup, async {
|
||||
loop {
|
||||
match handle.next_observation().await {
|
||||
Some(
|
||||
event @ SessionObservation::Transition {
|
||||
status,
|
||||
reason: SessionReason::LoginSucceeded,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
live.vision.set_generation(status.generation);
|
||||
record_session_observation(&live.observability, &event);
|
||||
prepare_live_framework(
|
||||
&live,
|
||||
&framework_readiness,
|
||||
status.generation,
|
||||
config
|
||||
.timeouts
|
||||
.startup
|
||||
.saturating_sub(Duration::from_secs(5)),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(event @ SessionObservation::Transition { status, .. })
|
||||
if status.agent_ready =>
|
||||
{
|
||||
@@ -351,7 +376,11 @@ async fn run_live(
|
||||
Err(_) => Err(CliError("timed out waiting for full grid readiness".into())),
|
||||
};
|
||||
if let Err(error) = readiness {
|
||||
live.vision.stop_viewport().await;
|
||||
live.scene_source.stop_prefetch().await;
|
||||
if let Some(world_loop) = live.world_loop.take() {
|
||||
let _ = world_loop.shutdown();
|
||||
}
|
||||
let session_result = handle.shutdown().await;
|
||||
let interaction_result = live.interaction.shutdown().await;
|
||||
let behavior_result = live.behavior.shutdown().await;
|
||||
@@ -360,7 +389,11 @@ async fn run_live(
|
||||
behavior_result?;
|
||||
return Err(error.into());
|
||||
}
|
||||
live.vision.stop_viewport().await;
|
||||
live.scene_source.stop_prefetch().await;
|
||||
if let Some(world_loop) = live.world_loop.take() {
|
||||
let _ = world_loop.shutdown();
|
||||
}
|
||||
let session_result = handle.shutdown().await;
|
||||
let interaction_result = live.interaction.shutdown().await;
|
||||
let behavior_result = live.behavior.shutdown().await;
|
||||
@@ -454,6 +487,16 @@ async fn run_live(
|
||||
if let SessionObservation::Transition { status, reason, retry_in } = event {
|
||||
live.vision.set_generation(status.generation);
|
||||
control_target.update_session(status);
|
||||
if reason == SessionReason::LoginSucceeded
|
||||
&& let Err(error) = prepare_live_framework(
|
||||
&live,
|
||||
&framework_readiness,
|
||||
status.generation,
|
||||
config.timeouts.startup.saturating_sub(Duration::from_secs(5)),
|
||||
).await
|
||||
{
|
||||
eprintln!("framework initialization failed: {error}");
|
||||
}
|
||||
if status.agent_ready
|
||||
&& let Ok(snapshot) = live
|
||||
.world
|
||||
@@ -485,6 +528,34 @@ async fn run_live(
|
||||
status.transport_connected,
|
||||
status.agent_ready,
|
||||
);
|
||||
if status.agent_ready {
|
||||
let world = live
|
||||
.world_loop
|
||||
.as_ref()
|
||||
.and_then(metacrate_game_loop::LoopHandle::latest_world);
|
||||
let world_sequence = world.as_ref().map_or(0, |world| world.sequence);
|
||||
let primitives = world
|
||||
.as_ref()
|
||||
.map_or(0, |world| world.value.primitives.len());
|
||||
let avatars = world.as_ref().map_or(0, |world| world.value.avatars.len());
|
||||
let dropped_world_events = live
|
||||
.world_loop
|
||||
.as_ref()
|
||||
.map_or(0, metacrate_game_loop::LoopHandle::dropped_world_events);
|
||||
let frames = live
|
||||
.vision
|
||||
.viewport_stats()
|
||||
.map_or(0, |stats| stats.completed_frames);
|
||||
println!(
|
||||
"READY generation={} world_sequence={} world_primitives={} world_avatars={} dropped_world_events={} viewport_frames={} viewport_target_fps=10",
|
||||
status.generation,
|
||||
world_sequence,
|
||||
primitives,
|
||||
avatars,
|
||||
dropped_world_events,
|
||||
frames
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = live.interaction.next_observation() => {
|
||||
@@ -567,7 +638,11 @@ async fn run_live(
|
||||
let _ = live.observability.record(shutdown_event);
|
||||
control_target.mark_stopping();
|
||||
live.landmark_roaming.shutdown().await;
|
||||
live.vision.stop_viewport().await;
|
||||
live.scene_source.stop_prefetch().await;
|
||||
if let Some(world_loop) = live.world_loop.take() {
|
||||
let _ = world_loop.shutdown();
|
||||
}
|
||||
let session_result = handle.shutdown().await;
|
||||
let interaction_result = live.interaction.shutdown().await;
|
||||
let behavior_result = live.behavior.shutdown().await;
|
||||
@@ -590,6 +665,55 @@ async fn run_live(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
async fn prepare_live_framework(
|
||||
live: &LiveInteractions,
|
||||
readiness: &metacrate_grid_agent::FrameworkReadiness,
|
||||
generation: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<(), CliError> {
|
||||
println!("INITIALIZING generation={generation} waiting_for=world_state,viewport target_fps=10");
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let world_ready = live
|
||||
.world_loop
|
||||
.as_ref()
|
||||
.and_then(metacrate_game_loop::LoopHandle::latest_world)
|
||||
.is_some_and(|world| world.value.region.is_some());
|
||||
if world_ready && live.scene_source.prefetch_passes() != 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
live.vision.start_viewport(
|
||||
generation,
|
||||
libremetaverse_types::compat::CancellationToken::default(),
|
||||
);
|
||||
loop {
|
||||
if live
|
||||
.vision
|
||||
.viewport_stats()
|
||||
.is_some_and(|stats| stats.completed_frames >= 2 && stats.render_errors == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CliError("timed out waiting for stable world state and viewport".into()))?;
|
||||
readiness.mark_ready(generation);
|
||||
let stats = live.vision.viewport_stats().unwrap_or_default();
|
||||
println!(
|
||||
"INITIALIZED generation={} viewport_frames={} last_render_ms={} missed_deadlines={} world_state=stable",
|
||||
generation,
|
||||
stats.completed_frames,
|
||||
stats.last_render.as_millis(),
|
||||
stats.missed_frame_deadlines,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
struct LiveInteractions {
|
||||
interaction: metacrate_grid_agent::InteractionHandle,
|
||||
@@ -608,6 +732,8 @@ struct LiveInteractions {
|
||||
Arc<metacrate_grid_agent::VisionService<metacrate_grid_agent::LibremetaverseSceneSource>>,
|
||||
scene_source: Arc<metacrate_grid_agent::LibremetaverseSceneSource>,
|
||||
world: Arc<metacrate_grid_agent::LibremetaverseWorldSnapshotSource>,
|
||||
_world_events: metacrate_game_loop::LibreMetaverseWorldEvents,
|
||||
world_loop: Option<metacrate_game_loop::LoopHandle>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -630,7 +756,25 @@ async fn start_live_interactions(
|
||||
};
|
||||
|
||||
let conversation = Arc::new(ConversationStore::from_config(config)?);
|
||||
let world = Arc::new(owner.world_snapshot_source());
|
||||
let (world_events, world_receiver) =
|
||||
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
|
||||
owner.client(),
|
||||
owner.agent(),
|
||||
config.vision.max_distance_meters,
|
||||
4_096,
|
||||
)
|
||||
.ok_or_else(|| CliError("world event queue capacity is zero".into()))?;
|
||||
let world_loop = metacrate_game_loop::start(
|
||||
metacrate_game_loop::LoopConfig {
|
||||
event_queue_capacity: 4_096,
|
||||
..metacrate_game_loop::LoopConfig::default()
|
||||
},
|
||||
world_receiver,
|
||||
(),
|
||||
None,
|
||||
)
|
||||
.ok_or_else(|| CliError("world game loop initialization failed".into()))?;
|
||||
let world = Arc::new(owner.world_snapshot_source(world_loop.world_reader()));
|
||||
let perception = Arc::new(PerceptionBackend::new(
|
||||
world.clone(),
|
||||
Arc::clone(&conversation),
|
||||
@@ -752,7 +896,7 @@ async fn start_live_interactions(
|
||||
let vision =
|
||||
Arc::new(VisionService::new(Arc::clone(&scene_source), vision_limits)?.prefer_gpu());
|
||||
if !vision.initialize_renderer().await {
|
||||
eprintln!("WARNING: wgpu renderer unavailable; visual captures use software fallback");
|
||||
return Err(CliError("wgpu viewport renderer initialization failed".into()).into());
|
||||
}
|
||||
let responder = Arc::new(VisionAugmentedResponder::new(vision.clone(), responder));
|
||||
let sink = Arc::new(owner.interaction_sink());
|
||||
@@ -785,6 +929,8 @@ async fn start_live_interactions(
|
||||
vision,
|
||||
scene_source,
|
||||
world,
|
||||
_world_events: world_events,
|
||||
world_loop: Some(world_loop),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Instant;
|
||||
|
||||
@@ -25,6 +25,39 @@ const MAX_SHUTDOWN: Duration = Duration::from_mins(1);
|
||||
|
||||
pub type SessionFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// Generation-scoped gate between transport readiness and autonomous agent readiness.
|
||||
pub struct FrameworkReadiness {
|
||||
generation: watch::Sender<u64>,
|
||||
}
|
||||
|
||||
impl Default for FrameworkReadiness {
|
||||
fn default() -> Self {
|
||||
let (generation, _) = watch::channel(0);
|
||||
Self { generation }
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkReadiness {
|
||||
pub fn mark_ready(&self, generation: u64) {
|
||||
if generation != 0 {
|
||||
self.generation.send_replace(generation);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_ready(&self, generation: u64, cancellation: CancellationToken) -> bool {
|
||||
let mut ready = self.generation.subscribe();
|
||||
loop {
|
||||
if *ready.borrow() == generation {
|
||||
return true;
|
||||
}
|
||||
tokio::select! {
|
||||
() = cancellation.cancelled() => return false,
|
||||
changed = ready.changed() => if changed.is_err() { return false; },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum SessionState {
|
||||
@@ -215,7 +248,7 @@ impl Default for ReconnectPolicy {
|
||||
Self {
|
||||
initial_delay: Duration::from_secs(1),
|
||||
maximum_delay: Duration::from_mins(1),
|
||||
readiness_timeout: Duration::from_secs(30),
|
||||
readiness_timeout: Duration::from_mins(2),
|
||||
stable_reset_after: Duration::from_mins(2),
|
||||
shutdown_deadline: Duration::from_secs(10),
|
||||
jitter_basis_points: 2_000,
|
||||
|
||||
@@ -557,3 +557,16 @@ async fn shutdown_is_cancellation_driven_during_connect_backoff_paused_and_onlin
|
||||
assert_eq!(stats.flushes.load(Ordering::Acquire), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn framework_readiness_releases_only_the_matching_generation() {
|
||||
let readiness = Arc::new(FrameworkReadiness::default());
|
||||
let waiting = Arc::clone(&readiness);
|
||||
let task =
|
||||
tokio::spawn(async move { waiting.wait_ready(2, CancellationToken::default()).await });
|
||||
readiness.mark_ready(1);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!task.is_finished());
|
||||
readiness.mark_ready(2);
|
||||
assert!(task.await.unwrap());
|
||||
}
|
||||
|
||||
@@ -72,12 +72,14 @@ pub struct SceneBuildTimings {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SceneSnapshot {
|
||||
pub generation: u64,
|
||||
/// Changes only when the persistent render scene changes; camera movement does not change it.
|
||||
pub scene_sequence: u64,
|
||||
pub observed_unix_millis: u64,
|
||||
pub region_id: UUID,
|
||||
pub region_name: String,
|
||||
pub camera: CameraPose,
|
||||
pub entities: Vec<SceneEntity>,
|
||||
pub textures: Vec<SceneTexture>,
|
||||
pub entities: Arc<[SceneEntity]>,
|
||||
pub textures: Arc<[SceneTexture]>,
|
||||
pub completeness: SnapshotCompleteness,
|
||||
/// Work already performed by the scene source; validated again before rendering.
|
||||
pub texture_fetches: usize,
|
||||
@@ -235,7 +237,9 @@ pub struct VisionService<S: SceneSource> {
|
||||
#[cfg(feature = "live-grid")]
|
||||
gpu_preferred: bool,
|
||||
#[cfg(feature = "live-grid")]
|
||||
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_rendering_wgpu::Renderer>>>,
|
||||
gpu: tokio::sync::OnceCell<Option<Arc<metacrate_game_loop::ViewportLoop>>>,
|
||||
#[cfg(feature = "live-grid")]
|
||||
viewport_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl<S: SceneSource> VisionService<S> {
|
||||
@@ -258,6 +262,8 @@ impl<S: SceneSource> VisionService<S> {
|
||||
gpu_preferred: false,
|
||||
#[cfg(feature = "live-grid")]
|
||||
gpu: tokio::sync::OnceCell::new(),
|
||||
#[cfg(feature = "live-grid")]
|
||||
viewport_task: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
/// Prefer cached offscreen `wgpu` rendering, falling back to the
|
||||
@@ -276,7 +282,7 @@ impl<S: SceneSource> VisionService<S> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
async fn renderer(&self) -> Option<Arc<metacrate_rendering_wgpu::Renderer>> {
|
||||
async fn renderer(&self) -> Option<Arc<metacrate_game_loop::ViewportLoop>> {
|
||||
self.gpu
|
||||
.get_or_init(|| async {
|
||||
tokio::task::spawn_blocking(bevy_renderer_new)
|
||||
@@ -288,6 +294,85 @@ impl<S: SceneSource> VisionService<S> {
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Returns the last fully completed viewport without waiting for or
|
||||
/// interfering with the next render.
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[must_use]
|
||||
pub fn latest_viewport(
|
||||
&self,
|
||||
) -> Option<metacrate_game_loop::Published<metacrate_game_loop::ViewportFrame>> {
|
||||
self.gpu.get()?.as_ref()?.latest()
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[must_use]
|
||||
pub fn viewport_stats(&self) -> Option<metacrate_game_loop::ViewportStats> {
|
||||
Some(self.gpu.get()?.as_ref()?.stats())
|
||||
}
|
||||
|
||||
/// Starts the scene feeder. The viewport crate owns the fixed-rate render
|
||||
/// loop and completed-frame publication after submission.
|
||||
#[cfg(feature = "live-grid")]
|
||||
pub fn start_viewport(self: &Arc<Self>, generation: u64, cancellation: CancellationToken) {
|
||||
if let Some(task) = lock(&self.viewport_task).take() {
|
||||
task.abort();
|
||||
}
|
||||
let service = Arc::downgrade(self);
|
||||
*lock(&self.viewport_task) = Some(tokio::spawn(async move {
|
||||
loop {
|
||||
if cancellation.is_cancellation_requested() {
|
||||
break;
|
||||
}
|
||||
let Some(service) = service.upgrade() else {
|
||||
break;
|
||||
};
|
||||
if let Ok(scene) = service
|
||||
.source
|
||||
.capture_scene(generation, cancellation.clone())
|
||||
.await
|
||||
{
|
||||
service.submit_viewport_scene(&scene).await;
|
||||
}
|
||||
drop(service);
|
||||
tokio::select! {
|
||||
() = cancellation.cancelled() => break,
|
||||
() = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
pub async fn stop_viewport(&self) {
|
||||
let task = lock(&self.viewport_task).take();
|
||||
if let Some(task) = task {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
pub async fn wait_viewport_ready(&self, timeout: Duration) -> bool {
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
if self.latest_viewport().is_some() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
async fn submit_viewport_scene(&self, scene: &SceneSnapshot) {
|
||||
let Some(viewport) = self.renderer().await else {
|
||||
return;
|
||||
};
|
||||
viewport.submit(viewport_scene(scene, self.limits));
|
||||
}
|
||||
pub fn set_generation(&self, generation: u64) {
|
||||
if self.current_generation.swap(generation, Ordering::AcqRel) != generation {
|
||||
self.cancel_active();
|
||||
@@ -429,55 +514,16 @@ impl<S: SceneSource> VisionService<S> {
|
||||
if self.gpu_preferred
|
||||
&& let Some(renderer) = self.renderer().await
|
||||
{
|
||||
let mut renderables = scene
|
||||
.entities
|
||||
.iter()
|
||||
.flat_map(|entity| {
|
||||
entity
|
||||
.renderables
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(move |renderable| (entity.id.to_string(), renderable))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
renderables.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
let renderables = renderables
|
||||
.into_iter()
|
||||
.map(|(_, renderable)| renderable)
|
||||
.collect::<Vec<_>>();
|
||||
let gpu_scene = scene.clone();
|
||||
let limits = self.limits;
|
||||
if let Ok(Ok(capture)) = tokio::task::spawn_blocking(move || {
|
||||
renderer
|
||||
.render(
|
||||
gpu_scene.camera,
|
||||
&renderables,
|
||||
&gpu_scene.textures,
|
||||
environment_color(&gpu_scene),
|
||||
metacrate_rendering_wgpu::RenderLimits {
|
||||
width: limits.width,
|
||||
height: limits.height,
|
||||
max_triangles: limits.max_triangles,
|
||||
max_texture_bytes: limits.max_texture_bytes,
|
||||
max_texture_pixels: limits.max_decode_pixels,
|
||||
far_distance: 512,
|
||||
},
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
metacrate_rendering_wgpu::RenderError::InvalidScene => {
|
||||
VisionError::InvalidScene
|
||||
}
|
||||
metacrate_rendering_wgpu::RenderError::ResourceLimit => {
|
||||
VisionError::ResourceLimit
|
||||
}
|
||||
metacrate_rendering_wgpu::RenderError::TimedOut => VisionError::TimedOut,
|
||||
_ => VisionError::Encode,
|
||||
})
|
||||
.and_then(|rgba| finish_capture(&gpu_scene, limits, &rgba))
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Ok(capture);
|
||||
renderer.submit(viewport_scene(&scene, self.limits));
|
||||
let deadline = Instant::now() + self.limits.capture_timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(frame) = renderer.latest()
|
||||
&& frame.value.scene_sequence == scene.scene_sequence
|
||||
&& frame.value.camera == scene.camera
|
||||
{
|
||||
return finish_capture(&scene, self.limits, &frame.value.rgba);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
render_scene_software(&scene, self.limits)
|
||||
@@ -486,8 +532,46 @@ impl<S: SceneSource> VisionService<S> {
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn bevy_renderer_new()
|
||||
-> Result<metacrate_rendering_wgpu::Renderer, metacrate_rendering_wgpu::RenderError> {
|
||||
metacrate_rendering_wgpu::Renderer::new_blocking()
|
||||
-> Result<metacrate_game_loop::ViewportLoop, metacrate_rendering_wgpu::RenderError> {
|
||||
metacrate_game_loop::ViewportLoop::start(metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL)
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn viewport_scene(
|
||||
scene: &SceneSnapshot,
|
||||
limits: VisionLimits,
|
||||
) -> metacrate_game_loop::ViewportScene {
|
||||
let mut renderables = scene
|
||||
.entities
|
||||
.iter()
|
||||
.flat_map(|entity| {
|
||||
entity
|
||||
.renderables
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(move |renderable| (entity.id.to_string(), renderable))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
renderables.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
metacrate_game_loop::ViewportScene {
|
||||
sequence: scene.scene_sequence,
|
||||
camera: scene.camera,
|
||||
renderables: renderables
|
||||
.into_iter()
|
||||
.map(|(_, renderable)| renderable)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
textures: Arc::clone(&scene.textures),
|
||||
background_srgb: environment_color(scene),
|
||||
limits: metacrate_rendering_wgpu::RenderLimits {
|
||||
width: limits.width,
|
||||
height: limits.height,
|
||||
max_triangles: limits.max_triangles,
|
||||
max_texture_bytes: limits.max_texture_bytes,
|
||||
max_texture_pixels: limits.max_decode_pixels,
|
||||
far_distance: 512,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-erased runtime control surface. It deliberately exposes metadata and
|
||||
@@ -746,7 +830,7 @@ fn validate_scene(scene: &SceneSnapshot, limits: VisionLimits) -> Result<(), Vis
|
||||
return Err(VisionError::ResourceLimit);
|
||||
}
|
||||
let mut count = 0usize;
|
||||
for entity in &scene.entities {
|
||||
for entity in scene.entities.iter() {
|
||||
count = count
|
||||
.checked_add(entity.triangles.len())
|
||||
.ok_or(VisionError::ResourceLimit)?;
|
||||
@@ -965,8 +1049,11 @@ pub struct LibremetaverseSceneSource {
|
||||
agent: Arc<libremetaverse::AgentManager>,
|
||||
limits: VisionLimits,
|
||||
cache: Arc<Mutex<SceneCache>>,
|
||||
latest_scene: metacrate_game_loop::Latest<SceneSnapshot>,
|
||||
scene_build: tokio::sync::Mutex<()>,
|
||||
prefetch_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
prefetch_passes: AtomicU64,
|
||||
scene_sequence: AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -1013,8 +1100,11 @@ impl LibremetaverseSceneSource {
|
||||
agent: owner.agent(),
|
||||
limits,
|
||||
cache: Arc::new(Mutex::new(SceneCache::default())),
|
||||
latest_scene: metacrate_game_loop::Latest::default(),
|
||||
scene_build: tokio::sync::Mutex::new(()),
|
||||
prefetch_task: Mutex::new(None),
|
||||
prefetch_passes: AtomicU64::new(0),
|
||||
scene_sequence: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,11 +1121,15 @@ impl LibremetaverseSceneSource {
|
||||
break;
|
||||
};
|
||||
if source.client.network().current_sim().is_some()
|
||||
&& source
|
||||
.capture_scene(0, CancellationToken::default())
|
||||
.await
|
||||
.is_ok()
|
||||
&& let Ok(scene) = source.capture_scene(0, CancellationToken::default()).await
|
||||
{
|
||||
if source
|
||||
.latest_scene
|
||||
.load()
|
||||
.is_none_or(|published| !same_render_scene(&published.value, &scene))
|
||||
{
|
||||
source.latest_scene.publish(scene);
|
||||
}
|
||||
source.prefetch_passes.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
drop(source);
|
||||
@@ -1057,6 +1151,40 @@ impl LibremetaverseSceneSource {
|
||||
pub fn prefetch_passes(&self) -> u64 {
|
||||
self.prefetch_passes.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn cached_scene(
|
||||
&self,
|
||||
generation: u64,
|
||||
simulator: &libremetaverse::Simulator,
|
||||
) -> Option<SceneSnapshot> {
|
||||
let published = self.latest_scene.load()?;
|
||||
if generation == 0 || published.value.region_id != simulator.region_id {
|
||||
return None;
|
||||
}
|
||||
let camera = &self.agent.movement.camera;
|
||||
let position = camera.position();
|
||||
let forward = native_camera_forward(camera);
|
||||
let up = camera.up_axis();
|
||||
let mut scene = (*published.value).clone();
|
||||
scene.generation = generation;
|
||||
scene.observed_unix_millis = unix_millis_now();
|
||||
scene.camera = CameraPose {
|
||||
position: [position.x, position.y, position.z],
|
||||
forward: [forward.x, forward.y, forward.z],
|
||||
up: [up.x, up.y, up.z],
|
||||
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
|
||||
};
|
||||
scene.timings = SceneBuildTimings::default();
|
||||
Some(scene)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn same_render_scene(left: &SceneSnapshot, right: &SceneSnapshot) -> bool {
|
||||
left.region_id == right.region_id
|
||||
&& left.entities == right.entities
|
||||
&& left.textures == right.textures
|
||||
&& left.completeness == right.completeness
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
@@ -1124,6 +1252,13 @@ impl SceneSource for LibremetaverseSceneSource {
|
||||
.network()
|
||||
.current_sim()
|
||||
.ok_or(VisionError::StaleGeneration)?;
|
||||
if let Some(scene) = self.cached_scene(generation, &simulator) {
|
||||
return Ok(scene);
|
||||
}
|
||||
let _scene_build = self.scene_build.lock().await;
|
||||
if let Some(scene) = self.cached_scene(generation, &simulator) {
|
||||
return Ok(scene);
|
||||
}
|
||||
let scene_deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let terrain_ready = simulator
|
||||
@@ -1624,11 +1759,8 @@ impl SceneSource for LibremetaverseSceneSource {
|
||||
.unwrap_or(u32::MAX);
|
||||
Ok(SceneSnapshot {
|
||||
generation,
|
||||
observed_unix_millis: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| {
|
||||
duration.as_millis().try_into().unwrap_or(u64::MAX)
|
||||
}),
|
||||
scene_sequence: self.scene_sequence.fetch_add(1, Ordering::AcqRel) + 1,
|
||||
observed_unix_millis: unix_millis_now(),
|
||||
region_id,
|
||||
region_name,
|
||||
camera: CameraPose {
|
||||
@@ -1637,8 +1769,8 @@ impl SceneSource for LibremetaverseSceneSource {
|
||||
up: [up.x, up.y, up.z],
|
||||
vertical_fov_degrees: camera.vertical_fov_angle().to_degrees(),
|
||||
},
|
||||
entities,
|
||||
textures,
|
||||
entities: entities.into(),
|
||||
textures: textures.into(),
|
||||
completeness: SnapshotCompleteness {
|
||||
objects_truncated: objects_truncated || geometry_truncated,
|
||||
avatars_truncated,
|
||||
@@ -1660,6 +1792,14 @@ impl SceneSource for LibremetaverseSceneSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_millis_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |duration| {
|
||||
duration.as_millis().try_into().unwrap_or(u64::MAX)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
pub(crate) fn native_camera_forward(
|
||||
camera: &libremetaverse::AgentManagerAgentMovementAgentCamera,
|
||||
|
||||
@@ -9,6 +9,7 @@ use libremetaverse_types::{
|
||||
compat::{CancellationToken, CancellationTokenSource},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
struct FakeScene {
|
||||
scene: SceneSnapshot,
|
||||
@@ -363,6 +364,7 @@ fn scene_visibility_uses_configured_distance_and_avatar_view() {
|
||||
fn scene() -> SceneSnapshot {
|
||||
SceneSnapshot {
|
||||
generation: 7,
|
||||
scene_sequence: 1,
|
||||
observed_unix_millis: 1_700_000_000_000,
|
||||
region_id: uuid(1),
|
||||
region_name: "Fixture Region".into(),
|
||||
@@ -389,8 +391,9 @@ fn scene() -> SceneSnapshot {
|
||||
renderables: Vec::new(),
|
||||
texture_available: true,
|
||||
},
|
||||
],
|
||||
textures: Vec::new(),
|
||||
]
|
||||
.into(),
|
||||
textures: Vec::new().into(),
|
||||
completeness: SnapshotCompleteness {
|
||||
textures_missing: 1,
|
||||
terrain_available: false,
|
||||
@@ -449,7 +452,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() {
|
||||
let Ok(renderer) = metacrate_rendering_wgpu::Renderer::new_blocking() else {
|
||||
let Ok(renderer) = metacrate_game_loop::ViewportLoop::start(Duration::from_millis(100)) else {
|
||||
return;
|
||||
};
|
||||
let limits = VisionLimits {
|
||||
@@ -467,25 +470,30 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available
|
||||
let renderables = scene_renderable_from_triangles(&triangles)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let rgba = tokio::task::spawn_blocking(move || {
|
||||
renderer.render(
|
||||
scene.camera,
|
||||
&renderables,
|
||||
&scene.textures,
|
||||
[72, 96, 120, 255],
|
||||
metacrate_rendering_wgpu::RenderLimits {
|
||||
width: limits.width,
|
||||
height: limits.height,
|
||||
max_triangles: limits.max_triangles,
|
||||
max_texture_bytes: limits.max_texture_bytes,
|
||||
max_texture_pixels: limits.max_decode_pixels,
|
||||
far_distance: 512,
|
||||
},
|
||||
)
|
||||
renderer.submit(metacrate_game_loop::ViewportScene {
|
||||
sequence: scene.scene_sequence,
|
||||
camera: scene.camera,
|
||||
renderables: renderables.into(),
|
||||
textures: Arc::clone(&scene.textures),
|
||||
background_srgb: [72, 96, 120, 255],
|
||||
limits: metacrate_rendering_wgpu::RenderLimits {
|
||||
width: limits.width,
|
||||
height: limits.height,
|
||||
max_triangles: limits.max_triangles,
|
||||
max_texture_bytes: limits.max_texture_bytes,
|
||||
max_texture_pixels: limits.max_decode_pixels,
|
||||
far_distance: 512,
|
||||
},
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while renderer.stats().completed_frames < 2 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("render worker")
|
||||
.expect("offscreen render");
|
||||
.expect("fixed-rate viewport did not publish repeated frames");
|
||||
let rgba = renderer.latest().unwrap().value.rgba.clone();
|
||||
assert_eq!(renderer.stats().render_errors, 0);
|
||||
assert_eq!(rgba.len(), 64 * 64 * 4);
|
||||
let center = (32 * 64 + 32) * 4;
|
||||
assert!(rgba[center + 1] > 180 && rgba[center] < 80 && rgba[center + 2] < 80);
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const ALLOWED_DEPENDENCIES: [&str; 21] = [
|
||||
const ALLOWED_DEPENDENCIES: [&str; 22] = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"crossterm",
|
||||
@@ -10,6 +10,7 @@ const ALLOWED_DEPENDENCIES: [&str; 21] = [
|
||||
"libremetaverse",
|
||||
"libremetaverse-imaging",
|
||||
"libremetaverse-rendering-simple",
|
||||
"metacrate-game-loop",
|
||||
"metacrate-lsl-tools",
|
||||
"metacrate-rendering-wgpu",
|
||||
"mentra",
|
||||
@@ -170,8 +171,6 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
|
||||
".subscribe_im(",
|
||||
".chat(",
|
||||
".instant_message_with_uuid_string(",
|
||||
".objects_avatars",
|
||||
".objects_primitives",
|
||||
".parcels",
|
||||
".environment()",
|
||||
".inventory()",
|
||||
@@ -183,6 +182,15 @@ fn live_session_adapter_reuses_native_lifecycle_and_messaging_managers() {
|
||||
"missing native lifecycle {required}"
|
||||
);
|
||||
}
|
||||
let world = fs::read_to_string(root.join("../metacrate-grid-world/src/lib.rs"))
|
||||
.expect("central world source");
|
||||
for required in [".objects_avatars", ".objects_primitives", ".terrain"] {
|
||||
assert!(
|
||||
world.contains(required),
|
||||
"missing centralized native world cache {required}"
|
||||
);
|
||||
}
|
||||
assert!(backend.contains("metacrate_game_loop::WorldReader"));
|
||||
assert!(!backend.contains("reqwest"));
|
||||
}
|
||||
|
||||
|
||||
@@ -159,12 +159,27 @@ async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<
|
||||
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
|
||||
let environment = live_environment()?;
|
||||
let renderer_owner = LibremetaverseClientOwner::new()?;
|
||||
let (mut renderer_session, renderer_cancel) =
|
||||
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
||||
let limits = VisionLimits {
|
||||
minimum_interval: Duration::ZERO,
|
||||
..VisionLimits::default()
|
||||
};
|
||||
let (_world_events, world_receiver) =
|
||||
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
|
||||
renderer_owner.client(),
|
||||
renderer_owner.agent(),
|
||||
limits.max_distance_meters,
|
||||
4_096,
|
||||
)
|
||||
.ok_or("live world event subscription failed")?;
|
||||
let world_loop = metacrate_game_loop::start(
|
||||
metacrate_game_loop::LoopConfig::default(),
|
||||
world_receiver,
|
||||
(),
|
||||
None,
|
||||
)
|
||||
.ok_or("live world loop failed to start")?;
|
||||
let (mut renderer_session, renderer_cancel) =
|
||||
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
||||
let scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
|
||||
let prefetch_started = Instant::now();
|
||||
scene_source.start_prefetch();
|
||||
@@ -183,6 +198,27 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
|
||||
renderer_simulator.name, region_size.0, region_size.1
|
||||
);
|
||||
wait_scene_settled(&renderer_owner).await?;
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while world_loop
|
||||
.latest_world()
|
||||
.is_none_or(|world| world.value.region.is_none())
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "central world state did not become ready")?;
|
||||
let world = world_loop
|
||||
.latest_world()
|
||||
.ok_or("central world snapshot disappeared")?;
|
||||
println!(
|
||||
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
|
||||
world.sequence,
|
||||
world.value.primitives.len(),
|
||||
world.value.avatars.len(),
|
||||
world.value.terrain.len(),
|
||||
world_loop.dropped_world_events(),
|
||||
);
|
||||
let view_forward = Vector3::mul_with_vector3_quaternion(
|
||||
Vector3::unit_x(),
|
||||
renderer_owner.agent().sim_rotation(),
|
||||
@@ -273,6 +309,31 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
|
||||
}
|
||||
}
|
||||
let capture = capture.ok_or("live renderer produced no frame")?;
|
||||
let viewport_before = vision
|
||||
.viewport_stats()
|
||||
.ok_or("viewport loop did not expose statistics")?;
|
||||
let soak_started = Instant::now();
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let soak_elapsed = soak_started.elapsed();
|
||||
let viewport_after = vision
|
||||
.viewport_stats()
|
||||
.ok_or("viewport loop stopped exposing statistics")?;
|
||||
let soak_frames = viewport_after
|
||||
.completed_frames
|
||||
.saturating_sub(viewport_before.completed_frames);
|
||||
let soak_missed = viewport_after
|
||||
.missed_frame_deadlines
|
||||
.saturating_sub(viewport_before.missed_frame_deadlines);
|
||||
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
|
||||
println!(
|
||||
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
|
||||
soak_elapsed.as_millis(),
|
||||
viewport_after.render_errors,
|
||||
viewport_after.last_render.as_millis(),
|
||||
);
|
||||
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
|
||||
return Err("stable viewport did not sustain its 10 Hz target".into());
|
||||
}
|
||||
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
|
||||
println!("LIVE_RENDER_OUTPUT={}", output.display());
|
||||
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
|
||||
@@ -281,10 +342,14 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
|
||||
.await;
|
||||
|
||||
scene_source.stop_prefetch().await;
|
||||
world_loop
|
||||
.shutdown()
|
||||
.map_err(|_| "world loop thread panicked")?;
|
||||
let _ = renderer_session.logout(renderer_cancel.token()).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
|
||||
async fn profile_warm_capture(
|
||||
source: &LibremetaverseSceneSource,
|
||||
limits: VisionLimits,
|
||||
@@ -311,9 +376,10 @@ async fn profile_warm_capture(
|
||||
.into_iter()
|
||||
.map(|(_, renderable)| renderable)
|
||||
.collect::<Vec<_>>();
|
||||
let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
|
||||
let preparation = preparation_started.elapsed();
|
||||
let renderer_started = Instant::now();
|
||||
let renderer = metacrate_rendering_wgpu::Renderer::new_blocking()?;
|
||||
let renderer = metacrate_game_loop::ViewportRuntime::new_blocking()?;
|
||||
let renderer_initialization = renderer_started.elapsed();
|
||||
let render_limits = metacrate_rendering_wgpu::RenderLimits {
|
||||
width: limits.width,
|
||||
@@ -323,24 +389,38 @@ async fn profile_warm_capture(
|
||||
max_texture_pixels: limits.max_decode_pixels,
|
||||
far_distance: 512,
|
||||
};
|
||||
let first = renderer.render_profiled(
|
||||
let first = renderer.render_scene(
|
||||
scene.scene_sequence,
|
||||
scene.camera,
|
||||
&renderables,
|
||||
&scene.textures,
|
||||
|| Arc::clone(&renderables),
|
||||
Arc::clone(&scene.textures),
|
||||
[125, 149, 173, 255],
|
||||
render_limits,
|
||||
)?;
|
||||
let warm_started = Instant::now();
|
||||
let warm = renderer.render_profiled(
|
||||
let refresh_started = Instant::now();
|
||||
let refresh = renderer.render_scene(
|
||||
scene.scene_sequence.saturating_add(1),
|
||||
scene.camera,
|
||||
&renderables,
|
||||
&scene.textures,
|
||||
|| Arc::clone(&renderables),
|
||||
Arc::clone(&scene.textures),
|
||||
[125, 149, 173, 255],
|
||||
render_limits,
|
||||
)?;
|
||||
let refresh_total = refresh_started.elapsed();
|
||||
let warm_started = Instant::now();
|
||||
let warm = renderer.render_scene(
|
||||
scene.scene_sequence.saturating_add(1),
|
||||
scene.camera,
|
||||
|| panic!("unchanged scene was transferred to the renderer again"),
|
||||
Arc::clone(&scene.textures),
|
||||
[125, 149, 173, 255],
|
||||
render_limits,
|
||||
)?;
|
||||
let warm_render_total = warm_started.elapsed();
|
||||
let jpeg_started = Instant::now();
|
||||
let rgb = warm
|
||||
.frame
|
||||
.value
|
||||
.rgba
|
||||
.chunks_exact(4)
|
||||
.flat_map(|pixel| pixel[..3].iter().copied())
|
||||
@@ -354,7 +434,7 @@ async fn profile_warm_capture(
|
||||
)?;
|
||||
let jpeg_encode = jpeg_started.elapsed();
|
||||
println!(
|
||||
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
|
||||
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},scene_refresh_total_ms:{},scene_refresh:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
|
||||
scene_total.as_millis(),
|
||||
scene.timings.terrain_wait.as_millis(),
|
||||
scene
|
||||
@@ -368,11 +448,16 @@ async fn profile_warm_capture(
|
||||
renderer_initialization.as_millis(),
|
||||
render_total(&first.timings).as_millis(),
|
||||
first.timings,
|
||||
refresh_total.as_millis(),
|
||||
refresh.timings,
|
||||
warm_render_total.as_millis(),
|
||||
warm.timings,
|
||||
jpeg_encode.as_millis(),
|
||||
total_started.elapsed().as_millis(),
|
||||
);
|
||||
if refresh_total >= metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL {
|
||||
return Err("cached scene refresh exceeded the viewport frame interval".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -400,8 +485,7 @@ impl SceneSource for DiagnosticSceneSource {
|
||||
Box::pin(async move {
|
||||
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
|
||||
if let Some(material_override) = &self.material_override {
|
||||
for renderable in scene
|
||||
.entities
|
||||
for renderable in Arc::make_mut(&mut scene.entities)
|
||||
.iter_mut()
|
||||
.filter(|entity| entity.kind == SceneEntityKind::Object)
|
||||
.flat_map(|entity| &mut entity.renderables)
|
||||
|
||||
Reference in New Issue
Block a user