Fix varregion scene capture and legacy rendering
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 10:18:19 +02:00
parent 19ebee129b
commit d85f7d22f2
23 changed files with 1313 additions and 516 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
jobs = 4

View File

@@ -1,3 +1,4 @@
All code and API mappings must be cross-platform; do not use macOS-only APIs unless equivalent Linux and Windows implementations are provided.
Gitea Actions workflows must target only `ubuntu-latest`; do not add macOS or Windows Gitea jobs.
Crates named `libremetaverse-*` are restricted to compatibility mappings of the C# LibreMetaverse libraries. New MetaCrate-native functionality must use a `metacrate-*` crate name; the reusable wgpu renderer is `metacrate-rendering-wgpu`.
Every intentional correction or adaptation in a `libremetaverse-*` crate must be recorded in that crate's `CHANGES.md` so it can be preserved or re-evaluated when importing newer C# LibreMetaverse sources.

1
Cargo.lock generated
View File

@@ -4612,6 +4612,7 @@ dependencies = [
"async-trait",
"base64",
"crossterm",
"directories",
"jpeg-decoder",
"jpeg-encoder",
"libremetaverse",

View File

@@ -0,0 +1,39 @@
# MetaCrate compatibility deviations
This file records intentional differences from the C# LibreMetaverse source. Preserve or
re-evaluate these changes when importing a newer upstream version.
## OpenSim varregion dimensions
- `GridRegion` retains the optional `MapBlockReply.Size` dimensions. Missing or zero dimensions
fall back to 256 x 256 metres.
- `Simulator::region_size()` exposes dimensions resolved after login and is marked `must_use`,
while the mapped public `size_x` and `size_y` fields remain unchanged for API compatibility.
- A matching map-region event updates the current simulator's resolved dimensions. Movement,
border prediction, terrain lookup, and terrain archive export use the resolved dimensions.
- The cache distinguishes explicit `Size` blocks from the 256 x 256 fallback. Startup waits for
an explicit size until timeout, and a later fallback reply cannot downgrade a resolved size.
- Region lookup no longer synthesizes the current simulator as a 256 x 256 `GridRegion`; it
requests the authoritative map block.
Files: `src/grid_manager.rs`, `src/network_manager.rs`, `src/agent_movement.rs`,
`src/asset_archive.rs`.
## Asynchronous region-reply correlation
- Name and handle lookups subscribe to the continuously dispatched `GridRegion` event stream
before sending a request.
- Each lookup accepts only the matching name or handle. Unrelated grid, object, avatar, or map
traffic cannot complete the request early.
- Cancellation and the mapped map-request timeout still bound the one-shot waiter.
- The default map-request timeout is 15 seconds rather than 5 seconds because OpenSim map
replies on the live varregion can arrive after 7-8 seconds during login.
Files: `src/grid_manager.rs`, `src/client_core.rs`.
## OpenSim asset transport fallback
- When the OpenSim asset capability returns no payload for an asset, the request falls back to
the existing UDP asset transfer path instead of reporting a missing asset immediately.
File: `src/asset_manager.rs`.

View File

@@ -1768,13 +1768,14 @@ impl AgentMovementRuntime {
if velocity == Vector3::zero() {
return;
}
let (region_size_x, region_size_y) = simulator.region_size();
let west = if velocity.x < -0.1 {
-position.x / velocity.x
} else {
f32::MAX
};
let east = if velocity.x > 0.1 {
(simulator.size_x as f32 - position.x) / velocity.x
(region_size_x as f32 - position.x) / velocity.x
} else {
f32::MAX
};
@@ -1784,7 +1785,7 @@ impl AgentMovementRuntime {
f32::MAX
};
let north = if velocity.y > 0.1 {
(simulator.size_y as f32 - position.y) / velocity.y
(region_size_y as f32 - position.y) / velocity.y
} else {
f32::MAX
};
@@ -1822,8 +1823,9 @@ impl AgentMovementRuntime {
}
let x = u32::try_from(simulator.handle >> 32).unwrap_or_default();
let y = u32::try_from(simulator.handle & u64::from(u32::MAX)).unwrap_or_default();
let size_x = simulator.size_x as f32;
let size_y = simulator.size_y as f32;
let (size_x, size_y) = simulator.region_size();
let size_x = size_x as f32;
let size_y = size_y as f32;
if velocity.x < -0.5 && position.x < 128.0 {
self.establish_child(x.wrapping_sub(256), y, BorderCrossingDirection::West);
} else if velocity.x > 0.5 && position.x > size_x - 128.0 {

View File

@@ -1385,8 +1385,9 @@ pub(crate) fn save_terrain(sim: crate::Simulator, terrain_path: String) -> Resul
let name = sim.name.clone();
let safe_name = name.replace(['/', '\\'], "_");
let mut file = File::create(root.join(format!("{safe_name}.r32"))).map_err(io_error)?;
for y in 0..sim.size_y {
for x in 0..sim.size_x {
let (size_x, size_y) = sim.region_size();
for y in 0..size_y {
for x in 0..size_x {
let mut height = 0.0;
if !sim.terrain_height_at_point(x as i32, y as i32, &mut height)? {
height = 0.0;

View File

@@ -795,7 +795,22 @@ impl AssetManager {
&format!("{}_id", self.asset_type_to_string(type_)?),
asset_id,
);
self.fetch(uri, None, token.clone()).await?
if let Some(bytes) = self.fetch(uri, None, token.clone()).await? {
Some(bytes)
} else {
self.request_asset_udp(
asset_id,
UUID::zero(),
UUID::zero(),
UUID::zero(),
type_,
priority,
source_type,
UUID::random()?,
token.clone(),
)
.await?
}
} else {
self.request_asset_udp(
asset_id,

View File

@@ -1722,7 +1722,7 @@ pub(crate) fn timing_settings_defaults() -> TimingSettings {
interpolation_interval: 250,
login_timeout: 60_000,
logout_timeout: 5_000,
map_request_timeout: 5_000,
map_request_timeout: 15_000,
resend_timeout: 4_000,
simulator_timeout: 30_000,
teleport_timeout: 40_000,
@@ -1823,7 +1823,7 @@ mod tests {
assert_eq!(settings.timing.login_timeout, 60_000);
assert_eq!(settings.timing.resend_timeout, 4_000);
assert_eq!(settings.timing.simulator_timeout, 30_000);
assert_eq!(settings.timing.map_request_timeout, 5_000);
assert_eq!(settings.timing.map_request_timeout, 15_000);
assert_eq!(settings.timing.agent_update_interval, 500);
assert_eq!(settings.timing.interpolation_interval, 250);
assert_eq!(settings.packets.max_pending_acks, 10);

View File

@@ -71,6 +71,8 @@ pub struct GridRegion {
pub name: String,
pub region_flags: RegionFlags,
pub region_handle: u64,
pub size_x: u32,
pub size_y: u32,
pub water_height: u8,
pub x: i32,
pub y: i32,
@@ -367,6 +369,7 @@ struct GridEvents {
struct CachedRegion {
region: GridRegion,
seen: SystemTime,
explicit_size: bool,
}
pub(crate) struct GridManagerInner {
client: Arc<GridClient>,
@@ -664,39 +667,38 @@ impl GridManager {
layer: GridLayerType,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<Option<GridRegion>>, Error> {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
let key = name.trim().to_ascii_lowercase();
if key.is_empty() {
return Err(Error::Argument);
}
if let Some(region) = self.inner.region_by_name(&key) {
return Ok(Some(Some(region)));
}
if let Some(simulator) = self
if let Some(region) = self
.inner
.client
.network()
.current_sim()
.filter(|simulator| simulator.name.eq_ignore_ascii_case(&key))
.region_by_name(&key)
.filter(|region| self.inner.region_size_is_explicit(region.region_handle))
{
return Ok(Some(Some(GridRegion {
access: simulator.access,
agents: 0,
map_image_id: UUID::zero(),
name: simulator.name.clone(),
region_flags: simulator.flags,
region_handle: simulator.handle,
water_height: simulator.water_height.clamp(0.0, f32::from(u8::MAX)) as u8,
x: ((simulator.handle >> 32) / 256) as i32,
y: ((simulator.handle & u64::from(u32::MAX)) / 256) as i32,
})));
}
let notified = self.inner.notify.notified();
self.request_map_region(name, layer)?;
if let Some(region) = self.inner.region_by_name(&key) {
return Ok(Some(Some(region)));
}
self.wait_for(cancellation_token, notified).await?;
Ok(Some(self.inner.region_by_name(&key)))
let event_key = key.clone();
let inner = Arc::clone(&self.inner);
let (_subscription, receiver) = self.region_reply_receiver(move |region| {
region.name.eq_ignore_ascii_case(&event_key)
&& inner.region_size_is_explicit(region.region_handle)
});
self.request_map_region(name, layer)?;
if let Some(region) = self
.inner
.region_by_name(&key)
.filter(|region| self.inner.region_size_is_explicit(region.region_handle))
{
return Ok(Some(Some(region)));
}
let region = self
.wait_for_region_reply(receiver, token)
.await?
.or_else(|| self.inner.region_by_name(&key));
Ok(Some(region))
}
pub async fn get_grid_region_with_u_int64_grid_layer_type_cancellation_token(
&self,
@@ -704,18 +706,30 @@ impl GridManager {
layer: GridLayerType,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<Option<GridRegion>>, Error> {
if let Some(region) = self.inner.region_by_handle(handle) {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
if let Some(region) = self
.inner
.region_by_handle(handle)
.filter(|_| self.inner.region_size_is_explicit(handle))
{
return Ok(Some(Some(region)));
}
let x = u16::try_from((handle >> 32) / 256).map_err(|_| Error::Argument)?;
let y = u16::try_from((handle & u64::from(u32::MAX)) / 256).map_err(|_| Error::Argument)?;
let notified = self.inner.notify.notified();
let inner = Arc::clone(&self.inner);
let (_subscription, receiver) = self.region_reply_receiver(move |region| {
region.region_handle == handle && inner.region_size_is_explicit(handle)
});
self.request_map_blocks(layer, x, y, x, y, true)?;
if let Some(region) = self.inner.region_by_handle(handle) {
return Ok(Some(Some(region)));
}
self.wait_for(cancellation_token, notified).await?;
Ok(Some(self.inner.region_by_handle(handle)))
let region = self
.wait_for_region_reply(receiver, token)
.await?
.or_else(|| self.inner.region_by_handle(handle));
Ok(Some(region))
}
pub async fn map_items(
&self,
@@ -751,6 +765,40 @@ impl GridManager {
.max(1) as u64;
tokio::select! { () = notified => Ok(()), () = token.cancelled() => Err(Error::Cancelled), () = tokio::time::sleep(Duration::from_millis(millis)) => Ok(()) }
}
fn region_reply_receiver(
&self,
matches: impl Fn(&GridRegion) -> bool + Send + Sync + 'static,
) -> (Subscription, oneshot::Receiver<GridRegion>) {
let (sender, receiver) = oneshot::channel();
let sender = Arc::new(Mutex::new(Some(sender)));
let callback = Arc::clone(&sender);
let subscription = self.subscribe_grid_region(Arc::new(move |event| {
if matches(&event.region)
&& let Some(sender) = mutex(&callback).take()
{
let _ = sender.send(event.region);
}
}));
(subscription, receiver)
}
async fn wait_for_region_reply(
&self,
receiver: oneshot::Receiver<GridRegion>,
token: CancellationToken,
) -> Result<Option<GridRegion>, Error> {
let millis = self
.inner
.client
.settings_ref()
.timing
.map_request_timeout
.max(1) as u64;
tokio::select! {
region = receiver => Ok(region.ok()),
() = token.cancelled() => Err(Error::Cancelled),
() = tokio::time::sleep(Duration::from_millis(millis)) => Ok(None),
}
}
pub fn regions_read_only(&self) -> HashMap<String, GridRegion> {
self.inner.prune();
read(&self.inner.regions)
@@ -814,10 +862,35 @@ impl GridManagerInner {
.get(&handle)
.map(|value| value.region.clone())
}
fn insert_region(&self, region: GridRegion) {
fn region_size_is_explicit(&self, handle: u64) -> bool {
self.prune();
read(&self.handles)
.get(&handle)
.is_some_and(|value| value.explicit_size)
}
fn insert_region(&self, mut region: GridRegion, mut explicit_size: bool) {
if !explicit_size
&& let Some(cached) = read(&self.handles)
.get(&region.region_handle)
.filter(|cached| cached.explicit_size)
.cloned()
{
region.size_x = cached.region.size_x;
region.size_y = cached.region.size_y;
explicit_size = true;
}
if let Some(simulator) = self
.client
.network()
.native_current_sim()
.filter(|simulator| simulator.handle == region.region_handle)
{
simulator.native_set_region_size(region.size_x, region.size_y);
}
let cached = CachedRegion {
region: region.clone(),
seen: self.client.time_provider.get_utc_now(),
explicit_size,
};
write(&self.regions).insert(region.name.to_ascii_lowercase(), cached.clone());
write(&self.handles).insert(region.region_handle, cached);
@@ -834,20 +907,27 @@ impl GridManagerInner {
if let Ok(packet) =
crate::packets::MapBlockReplyPacket::new_from_bytes(&event.data, &mut pos)
{
for block in packet.data.into_iter().take(65_535) {
for (index, block) in packet.data.into_iter().take(65_535).enumerate() {
let global_x = u32::from(block.x) * 256;
let global_y = u32::from(block.y) * 256;
self.insert_region(GridRegion {
let (size_x, size_y, explicit_size) =
map_block_size(packet.size.get(index));
self.insert_region(
GridRegion {
access: SimAccess(block.access),
agents: block.agents,
map_image_id: block.map_image_id,
name: wire_string(&block.name),
region_flags: RegionFlags(u64::from(block.region_flags)),
region_handle: (u64::from(global_x) << 32) | u64::from(global_y),
size_x,
size_y,
water_height: block.water_height,
x: i32::from(block.x),
y: i32::from(block.y),
});
},
explicit_size,
);
}
}
}
@@ -953,6 +1033,18 @@ impl GridManagerInner {
}
}
}
fn map_block_size(size: Option<&crate::packets::MapBlockReplyPacketSizeBlock>) -> (u32, u32, bool) {
size.filter(|size| size.size_x != 0 && size.size_y != 0)
.map_or(
(
Simulator::DEFAULT_REGION_SIZE_X,
Simulator::DEFAULT_REGION_SIZE_Y,
false,
),
|size| (u32::from(size.size_x), u32::from(size.size_y), true),
)
}
fn grid_item_type(value: u32) -> Option<GridItemType> {
Some(match value {
1 => GridItemType::Telehub,
@@ -1124,22 +1216,100 @@ mod tests {
.build()
.unwrap();
let manager = GridManager::native_new(Arc::new(client)).unwrap();
manager.inner.insert_region(GridRegion {
manager.inner.insert_region(
GridRegion {
access: SimAccess::PG,
agents: 0,
map_image_id: UUID::zero(),
name: "Example".into(),
region_flags: RegionFlags::NONE,
region_handle: (256_u64 << 32) | 512,
size_x: 256,
size_y: 256,
water_height: 20,
x: 1,
y: 2,
});
},
false,
);
assert!(manager.inner.region_by_name("example").is_some());
seconds.store(1_000 + CACHE_TTL.as_secs() + 1, Ordering::Relaxed);
assert!(manager.inner.region_by_name("example").is_none());
}
#[test]
fn map_block_size_uses_varregion_data_and_standard_fallback() {
let mut size =
crate::packets::MapBlockReplyPacketSizeBlock::new_with_constructor().unwrap();
size.size_x = 1024;
size.size_y = 1024;
assert_eq!(map_block_size(Some(&size)), (1024, 1024, true));
assert_eq!(map_block_size(None), (256, 256, false));
}
#[test]
fn fallback_map_block_does_not_downgrade_an_explicit_size() {
let manager = GridManager::native_new(Arc::new(GridClient::new().unwrap())).unwrap();
let region = GridRegion {
access: SimAccess::PG,
agents: 0,
map_image_id: UUID::zero(),
name: "Varregion".into(),
region_flags: RegionFlags::NONE,
region_handle: (256_u64 << 32) | 512,
size_x: 1024,
size_y: 1024,
water_height: 20,
x: 1,
y: 2,
};
manager.inner.insert_region(region.clone(), true);
manager.inner.insert_region(
GridRegion {
size_x: 256,
size_y: 256,
..region
},
false,
);
let cached = manager
.inner
.region_by_handle((256_u64 << 32) | 512)
.unwrap();
assert_eq!((cached.size_x, cached.size_y), (1024, 1024));
}
#[tokio::test]
async fn region_reply_receiver_filters_the_event_stream() {
let client = Arc::new(GridClient::new().unwrap());
let manager = GridManager::native_new(client).unwrap();
let handle = (256_u64 << 32) | 512;
let desired = GridRegion {
access: SimAccess::PG,
agents: 0,
map_image_id: UUID::zero(),
name: "Varregion".into(),
region_flags: RegionFlags::NONE,
region_handle: handle,
size_x: 1024,
size_y: 1024,
water_height: 20,
x: 1,
y: 2,
};
let (_subscription, mut receiver) =
manager.region_reply_receiver(move |region| region.region_handle == handle);
let mut unrelated = desired.clone();
unrelated.region_handle += 1;
manager
.inner
.events
.region
.emit(GridRegionEventArgs { region: unrelated });
assert!(receiver.try_recv().unwrap().is_none());
manager.inner.events.region.emit(GridRegionEventArgs {
region: desired.clone(),
});
assert_eq!(receiver.await.unwrap(), desired);
}
#[test]
fn simulator_features_preserve_unknown_fields() {
let features = SimulatorFeatures::default();
let body = OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([

View File

@@ -1183,6 +1183,7 @@ pub struct SimulatorData {
pub sim_version: String,
pub size_x: u32,
pub size_y: u32,
resolved_size: RwLock<(u32, u32)>,
pub stats: SimulatorSimStats,
pub terrain: RwLock<Vec<TerrainPatch>>,
pub terrain_base0: UUID,
@@ -1379,6 +1380,19 @@ impl Simulator {
*write(&self.data.prey_id) = value;
}
/// Returns the map-resolved region dimensions. Before the startup query
/// completes this contains the login dimensions or the 256 m fallback.
#[must_use]
pub fn region_size(&self) -> (u32, u32) {
*read(&self.data.resolved_size)
}
pub(crate) fn native_set_region_size(&self, size_x: u32, size_y: u32) {
if size_x != 0 && size_y != 0 {
*write(&self.data.resolved_size) = (size_x, size_y);
}
}
pub fn native_new(
client: GridClient,
address: std::net::SocketAddr,
@@ -1429,6 +1443,10 @@ impl Simulator {
sim_version: String::new(),
size_x: size_x.unwrap_or(Self::DEFAULT_REGION_SIZE_X),
size_y: size_y.unwrap_or(Self::DEFAULT_REGION_SIZE_Y),
resolved_size: RwLock::new((
size_x.unwrap_or(Self::DEFAULT_REGION_SIZE_X),
size_y.unwrap_or(Self::DEFAULT_REGION_SIZE_Y),
)),
stats: SimulatorSimStats::default(),
terrain: RwLock::new(Vec::new()),
terrain_base0: UUID::zero(),
@@ -1636,10 +1654,11 @@ impl Simulator {
return Ok(false);
};
let terrain = read(&self.terrain);
let (size_x, _) = self.region_size();
let per_edge = if terrain.len() > 256 {
256
} else {
usize::try_from(self.size_x.max(16) / 16).map_err(|_| Error::Argument)?
usize::try_from(size_x.max(16) / 16).map_err(|_| Error::Argument)?
};
if usize::try_from(x / 16).map_err(|_| Error::Argument)? >= per_edge
|| usize::try_from(y / 16).map_err(|_| Error::Argument)?
@@ -4405,4 +4424,14 @@ mod tests {
);
assert!((height - 27.0).abs() < f32::EPSILON);
}
#[test]
fn queried_region_size_replaces_the_login_fallback() {
let client = GridClient::new().expect("client");
let simulator =
Simulator::new(client, "127.0.0.1:14003".parse().unwrap(), 13, None, None).unwrap();
assert_eq!(simulator.region_size(), (256, 256));
simulator.native_set_region_size(1024, 1024);
assert_eq!(simulator.region_size(), (1024, 1024));
}
}

View File

@@ -12,6 +12,7 @@ publish = false
async-trait = "0.1.89"
base64 = "0.22"
crossterm = "0.29"
directories = { version = "6.0.0", optional = true }
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, optional = true }
libremetaverse-imaging = { version = "0.0.1", path = "../libremetaverse-imaging", default-features = false, features = ["rust-j2k"], optional = true }
libremetaverse-rendering-simple = { version = "0.0.1", path = "../libremetaverse-rendering-simple", optional = true }
@@ -39,7 +40,7 @@ tokio = { version = "1.53.1", features = ["io-util", "net", "rt-multi-thread", "
[features]
default = []
live-grid = ["dep:libremetaverse", "dep:libremetaverse-imaging", "dep:libremetaverse-rendering-simple", "metacrate-rendering-wgpu/wgpu"]
live-grid = ["dep:directories", "dep:libremetaverse", "dep:libremetaverse-imaging", "dep:libremetaverse-rendering-simple", "metacrate-rendering-wgpu/wgpu"]
[lints]
workspace = true

View File

@@ -94,6 +94,7 @@ impl OfflineGridBackend {
#[cfg(feature = "live-grid")]
pub struct LibremetaverseClientOwner {
client: libremetaverse::GridClient,
grid: libremetaverse::GridManager,
network: libremetaverse::NetworkManager,
agent: Arc<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
@@ -131,6 +132,7 @@ impl fmt::Debug for LibremetaverseClientOwner {
#[cfg(feature = "live-grid")]
#[derive(Clone)]
pub struct LibremetaverseSessionBackend {
grid: libremetaverse::GridManager,
network: libremetaverse::NetworkManager,
agent: Arc<libremetaverse::AgentManager>,
throttle: libremetaverse::AgentThrottle,
@@ -165,7 +167,10 @@ impl LibremetaverseClientOwner {
///
/// Returns a backend configuration error if the shared client defaults are invalid.
pub fn new() -> Result<Self, BackendError> {
let mut settings = libremetaverse::Settings::default();
settings.asset_cache_mut().dir = metacrate_cache_dir()?.to_string_lossy().into_owned();
let client = libremetaverse::GridClientBuilder::default()
.with_settings(settings)
.build()
.map_err(|_| BackendError::Configuration {
component: "libremetaverse client defaults",
@@ -173,7 +178,7 @@ impl LibremetaverseClientOwner {
// The compatibility client caches its network manager weakly. Retain the
// composition root before managers register packet subscriptions on it.
let network = client.network();
let _grid = client.grid();
let grid = client.grid();
let _terrain = client.terrain();
let _objects = client.objects();
let agent = Arc::new(
@@ -185,6 +190,7 @@ impl LibremetaverseClientOwner {
);
Ok(Self {
client,
grid,
network,
agent,
delivery_generation: Arc::new(std::sync::RwLock::new(None)),
@@ -281,6 +287,7 @@ impl LibremetaverseClientOwner {
});
}
Ok(LibremetaverseSessionBackend {
grid: self.grid.clone(),
network: self.network.clone(),
agent: Arc::clone(&self.agent),
throttle: libremetaverse::AgentThrottle::new_with_grid_client(self.client.clone())
@@ -324,6 +331,15 @@ impl LibremetaverseClientOwner {
}
}
#[cfg(feature = "live-grid")]
fn metacrate_cache_dir() -> Result<std::path::PathBuf, BackendError> {
directories::ProjectDirs::from("", "", "metacrate")
.map(|directories| directories.cache_dir().to_owned())
.ok_or(BackendError::Configuration {
component: "platform cache directory",
})
}
#[cfg(feature = "live-grid")]
impl LibremetaverseWorldSnapshotSource {
#[must_use]
@@ -668,15 +684,15 @@ impl crate::behavior::EmbodimentSink for LibremetaverseEmbodimentSink {
.network()
.native_current_sim()
.ok_or(crate::behavior::BehaviorError::NotReady)?;
let (region_size_x, region_size_y) = simulator.region_size();
let current_position = self.agent.sim_position();
let reported_bounds_contain_agent = f64::from(current_position.x)
<= f64::from(simulator.size_x)
&& f64::from(current_position.y) <= f64::from(simulator.size_y);
<= f64::from(region_size_x)
&& f64::from(current_position.y) <= f64::from(region_size_y);
if point.x < 0.0
|| point.y < 0.0
|| (reported_bounds_contain_agent
&& (point.x > f64::from(simulator.size_x)
|| point.y > f64::from(simulator.size_y)))
&& (point.x > f64::from(region_size_x) || point.y > f64::from(region_size_y)))
{
return Err(crate::behavior::BehaviorError::RegionBoundary);
}
@@ -1108,12 +1124,25 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
)
})?;
self.throttle
.set_with_simulator(Some(simulator))
.set_with_simulator(Some(simulator.clone()))
.map_err(|_| {
crate::session::SessionFailure::new(
crate::session::SessionFailureKind::TransientTransport,
)
})?;
let readiness_simulator = simulator.clone();
let _ = tokio::task::spawn_blocking(move || {
readiness_simulator.native_is_event_queue_running(Some(true))
})
.await;
let _ = self
.grid
.get_grid_region_with_string_grid_layer_type_cancellation_token(
simulator.name.clone(),
libremetaverse::GridLayerType::Objects,
Some(cancellation.clone()),
)
.await;
native_reset_camera(&self.agent).map_err(|_| {
crate::session::SessionFailure::new(
crate::session::SessionFailureKind::TransientTransport,
@@ -1519,4 +1548,16 @@ mod tests {
crate::session::SessionFailureKind::InvalidCredentials,
);
}
#[cfg(feature = "live-grid")]
#[test]
fn asset_cache_is_platform_scoped_to_metacrate() {
let cache = metacrate_cache_dir().expect("platform cache directory");
assert!(cache.is_absolute());
assert!(
cache
.components()
.any(|part| part.as_os_str() == "metacrate")
);
}
}

View File

@@ -1157,8 +1157,9 @@ impl BuildGrid for LibremetaverseBuildGrid {
return Err(BuildError::OutOfRegion);
}
let base = self.agent.sim_position();
let (region_size_x, region_size_y) = simulator.region_size();
let reported_bounds_contain_agent =
base.x <= simulator.size_x as f32 && base.y <= simulator.size_y as f32;
base.x <= region_size_x as f32 && base.y <= region_size_y as f32;
for offset in positions {
let point = libremetaverse_types::Vector3 {
x: base.x + offset[0] as f32 / 1000.0,
@@ -1168,7 +1169,7 @@ impl BuildGrid for LibremetaverseBuildGrid {
if point.x < 0.0
|| point.y < 0.0
|| (reported_bounds_contain_agent
&& (point.x > simulator.size_x as f32 || point.y > simulator.size_y as f32))
&& (point.x > region_size_x as f32 || point.y > region_size_y as f32))
{
return Err(BuildError::OutOfRegion);
}

View File

@@ -95,36 +95,36 @@ pub struct VisionLimits {
impl Default for VisionLimits {
fn default() -> Self {
Self {
width: 640,
height: 360,
width: 1920,
height: 1080,
max_distance_meters: 64,
max_entities: 512,
max_triangles: 131_072,
max_entities: 2_048,
max_triangles: 1_048_576,
max_texture_fetches: 1_024,
max_texture_bytes: 512 * 1024 * 1024,
max_decode_pixels: 134_217_728,
max_jpeg_bytes: 512 * 1024,
max_decode_pixels: 268_435_456,
max_jpeg_bytes: 2 * 1024 * 1024,
max_concurrent_captures: 1,
capture_timeout: Duration::from_mins(2),
capture_timeout: Duration::from_mins(8),
minimum_interval: Duration::from_secs(5),
}
}
}
impl VisionLimits {
pub(crate) fn valid(self) -> bool {
(64..=640).contains(&self.width)
&& (64..=480).contains(&self.height)
&& self.width as usize * self.height as usize <= 307_200
(64..=1_920).contains(&self.width)
&& (64..=1_080).contains(&self.height)
&& self.width as usize * self.height as usize <= 2_073_600
&& (1..=1_024).contains(&self.max_distance_meters)
&& (1..=1_024).contains(&self.max_entities)
&& (1..=262_144).contains(&self.max_triangles)
&& (1..=4_096).contains(&self.max_entities)
&& (1..=1_048_576).contains(&self.max_triangles)
&& self.max_texture_fetches <= 1_024
&& self.max_texture_bytes <= 512 * 1024 * 1024
&& self.max_decode_pixels <= 134_217_728
&& (4_096..=1024 * 1024).contains(&self.max_jpeg_bytes)
&& self.max_decode_pixels <= 536_870_912
&& (4_096..=4 * 1024 * 1024).contains(&self.max_jpeg_bytes)
&& (1..=4).contains(&self.max_concurrent_captures)
&& !self.capture_timeout.is_zero()
&& self.capture_timeout <= Duration::from_mins(5)
&& self.capture_timeout <= Duration::from_mins(10)
&& self.minimum_interval <= Duration::from_mins(10)
}
}
@@ -867,8 +867,8 @@ fn environment_color(scene: &SceneSnapshot) -> [u8; 4] {
let phase = (scene.observed_unix_millis / 1000 % 86_400) as f32 / 86_400.0;
let light = (phase * std::f32::consts::TAU)
.sin()
.mul_add(24.0, 72.0)
.clamp(32.0, 112.0) as u8;
.mul_add(20.0, 125.0)
.clamp(96.0, 160.0) as u8;
[
light,
light.saturating_add(24),
@@ -888,7 +888,7 @@ fn encode_jpeg(
.flat_map(|pixel| pixel[..3].iter().copied())
.collect::<Vec<_>>();
let mut output = Vec::new();
jpeg_encoder::Encoder::new(&mut output, 82)
jpeg_encoder::Encoder::new(&mut output, 92)
.encode(
&rgb,
width as u16,
@@ -1000,16 +1000,28 @@ async fn request_asset_bytes(
asset_type: libremetaverse_types::AssetType,
cancellation: CancellationToken,
) -> Option<Vec<u8>> {
tokio::time::timeout(
Duration::from_secs(15),
assets.request_asset_with_uuid_asset_type_boolean_cancellation_token(
tokio::time::timeout(Duration::from_secs(8), async {
match asset_type {
libremetaverse_types::AssetType::Texture => {
let _ = assets
.request_image(id, None, Some(cancellation.clone()))
.await;
}
libremetaverse_types::AssetType::Mesh => {
let _ = assets.request_mesh(id, Some(cancellation.clone())).await;
}
_ => {}
}
assets
.request_asset_with_uuid_asset_type_boolean_cancellation_token(
id,
asset_type,
false,
Some(cancellation),
),
)
.await
})
.await
.ok()
.and_then(Result::ok)
.flatten()
@@ -1128,8 +1140,26 @@ impl SceneSource for LibremetaverseSceneSource {
(world, attachments)
};
world.sort_by(|left, right| {
let left_visible = scene_center_visible(
left.position,
camera_position,
camera_forward,
camera.vertical_fov_angle(),
self.limits.width as f32 / self.limits.height as f32,
maximum_distance,
);
let right_visible = scene_center_visible(
right.position,
camera_position,
camera_forward,
camera.vertical_fov_angle(),
self.limits.width as f32 / self.limits.height as f32,
maximum_distance,
);
right_visible.cmp(&left_visible).then_with(|| {
scene_distance_squared(left.position, camera_position)
.total_cmp(&scene_distance_squared(right.position, camera_position))
})
});
attachments.sort_by(|left, right| {
scene_distance_squared(left.position, camera_position)
@@ -1149,21 +1179,47 @@ impl SceneSource for LibremetaverseSceneSource {
avatars.truncate(avatar_limit);
let region_id = simulator.region_id;
let region_name = simulator.name.clone();
{
let mut cache = lock(&self.cache);
if cache.region_id != Some(region_id) {
cache.region_id = Some(region_id);
cache.textures.clear();
cache.texture_pixels = 0;
cache.geometry.clear();
cache.access = 0;
}
}
let water = simulator.water_height;
let region_size = (simulator.size_x as f32, simulator.size_y as f32);
let (region_size_x, region_size_y) = simulator.region_size();
let region_size = (region_size_x as f32, region_size_y as f32);
let terrain = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let terrain_available = terrain.iter().any(|patch| patch.data.len() == 256);
let terrain_texture_ids = [
let terrain_materials = TerrainMaterials {
texture_ids: [
simulator.terrain_detail0,
simulator.terrain_detail1,
simulator.terrain_detail2,
simulator.terrain_detail3,
];
let mut texture_ids = terrain_texture_ids
],
start_heights: [
simulator.terrain_start_height00,
simulator.terrain_start_height01,
simulator.terrain_start_height10,
simulator.terrain_start_height11,
],
height_ranges: [
simulator.terrain_height_range00,
simulator.terrain_height_range01,
simulator.terrain_height_range10,
simulator.terrain_height_range11,
],
};
let mut texture_ids = terrain_materials
.texture_ids
.into_iter()
.filter(|id| *id != UUID::zero())
.collect::<Vec<_>>();
@@ -1200,12 +1256,21 @@ impl SceneSource for LibremetaverseSceneSource {
}
let mesh_ids = prims
.iter()
.filter(|prim| {
let signature = geometry_signature(prim, camera_position);
!lock(&self.cache)
.geometry
.get(&prim.id)
.is_some_and(|cached| cached.signature == signature)
})
.filter_map(|prim| prim.sculpt.as_ref())
.filter(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh)
.map(|sculpt| sculpt.sculpt_texture)
.filter(|id| *id != UUID::zero())
.fold(Vec::new(), |mut ids, id| {
if !ids.contains(&id) && ids.len() < self.limits.max_texture_fetches / 2 {
// ponytail: stage 256 new meshes per frame; switch to an adaptive
// scheduler if live convergence proves this ceiling too slow.
if !ids.contains(&id) && ids.len() < 256 {
ids.push(id);
}
ids
@@ -1216,19 +1281,14 @@ impl SceneSource for LibremetaverseSceneSource {
let legacy_materials = if legacy_material_ids.is_empty() {
Vec::new()
} else {
tokio::time::timeout(
Duration::from_secs(5),
self.client
.objects()
.request_materials_with_simulator_cancellation_token(
simulator.clone(),
Some(cancellation.clone()),
),
)
.await
.ok()
.and_then(Result::ok)
.map_or_else(Vec::new, Iterator::collect)
.map_or_else(|_| Vec::new(), Iterator::collect)
};
for material in &legacy_materials {
for id in [material.normal_map(), material.specular_map()] {
@@ -1300,23 +1360,19 @@ impl SceneSource for LibremetaverseSceneSource {
texture_ids.retain(|id| *id != UUID::zero());
let scene_texture_ids = texture_ids.clone();
{
let mut cache = lock(&self.cache);
if cache.region_id != Some(region_id) {
cache.region_id = Some(region_id);
cache.textures.clear();
cache.texture_pixels = 0;
cache.geometry.clear();
cache.access = 0;
}
let cache = lock(&self.cache);
texture_ids.retain(|id| !cache.textures.contains_key(id));
}
texture_ids.truncate(
self.limits.max_texture_fetches.saturating_sub(
self.limits
.max_texture_fetches
.saturating_sub(
mesh_ids
.len()
.saturating_add(pbr_material_ids.len())
.saturating_add(legacy_material_fetches),
),
)
.min(256),
);
let texture_fetches = texture_ids
.len()
@@ -1395,7 +1451,7 @@ impl SceneSource for LibremetaverseSceneSource {
let decode_bytes = self.limits.max_texture_bytes;
let max_cached_geometry = self.limits.max_entities;
let cache = Arc::clone(&self.cache);
let (entities, textures, decoded_texture_pixels) =
let (entities, textures, decoded_texture_pixels, geometry_truncated) =
tokio::task::spawn_blocking(move || {
native_entities(
prims,
@@ -1403,7 +1459,7 @@ impl SceneSource for LibremetaverseSceneSource {
water,
region_size,
&terrain,
terrain_texture_ids,
terrain_materials,
maximum,
encoded_textures,
encoded_meshes,
@@ -1414,6 +1470,7 @@ impl SceneSource for LibremetaverseSceneSource {
&scene_texture_ids,
max_cached_geometry,
camera_position,
maximum_distance,
)
})
.await
@@ -1447,7 +1504,7 @@ impl SceneSource for LibremetaverseSceneSource {
entities,
textures,
completeness: SnapshotCompleteness {
objects_truncated,
objects_truncated: objects_truncated || geometry_truncated,
avatars_truncated,
textures_missing,
terrain_available,
@@ -1580,6 +1637,14 @@ fn world_primitive(
None
}
#[cfg(feature = "live-grid")]
#[derive(Clone, Copy)]
pub(crate) struct TerrainMaterials {
pub(crate) texture_ids: [UUID; 4],
pub(crate) start_heights: [f32; 4],
pub(crate) height_ranges: [f32; 4],
}
#[cfg(feature = "live-grid")]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // Single blocking scene-conversion boundary.
fn native_entities(
@@ -1588,7 +1653,7 @@ fn native_entities(
water: f32,
region_size: (f32, f32),
terrain: &[libremetaverse::TerrainPatch],
terrain_texture_ids: [UUID; 4],
terrain_materials: TerrainMaterials,
maximum: usize,
encoded_textures: Vec<(UUID, Vec<u8>)>,
encoded_meshes: Vec<(UUID, Vec<u8>)>,
@@ -1599,7 +1664,8 @@ fn native_entities(
scene_texture_ids: &[UUID],
max_cached_geometry: usize,
camera_position: [f32; 3],
) -> Result<(Vec<SceneEntity>, Vec<SceneTexture>, usize), VisionError> {
maximum_distance: f32,
) -> Result<(Vec<SceneEntity>, Vec<SceneTexture>, usize, bool), VisionError> {
let (textures, pixels_consumed) = cached_textures(
cache,
scene_texture_ids,
@@ -1627,10 +1693,16 @@ fn native_entities(
let mut entities = Vec::new();
let mut triangles = 0usize;
let mut gpu_triangles = 0usize;
let mut geometry_truncated = false;
let avatar_triangle_reserve = avatars.len().saturating_mul(2).min(maximum / 4);
let terrain_triangle_reserve = ((maximum_distance.ceil() as usize).saturating_mul(2))
.saturating_pow(2)
.saturating_mul(2)
.min(maximum / 2);
let object_triangle_limit = maximum
.saturating_div(2)
.saturating_sub(avatar_triangle_reserve);
.saturating_sub(terrain_triangle_reserve)
.saturating_sub(avatar_triangle_reserve)
.saturating_sub(2);
for prim in prims {
if triangles >= object_triangle_limit || gpu_triangles >= object_triangle_limit {
break;
@@ -1640,16 +1712,7 @@ fn native_entities(
|properties| properties.name.clone(),
);
let detail = scene_detail_level(&prim, camera_position);
let signature = GeometrySignature {
construction: prim.prim_data.clone(),
sculpt: prim.sculpt.clone(),
textures: prim
.textures
.as_ref()
.and_then(|textures| textures.get_bytes().ok())
.unwrap_or_default(),
detail,
};
let signature = geometry_signature(&prim, camera_position);
let mesh = cached_geometry(cache, prim.id, &signature).or_else(|| {
let generated = if let Some(sculpt) = &prim.sculpt {
if sculpt.type_() == libremetaverse_types::SculptType::Mesh {
@@ -1687,6 +1750,24 @@ fn native_entities(
))
});
let Some(mesh) = mesh else { continue };
let object_triangles = mesh
.faces
.iter()
.map(|face| {
face.indices
.chunks_exact(3)
.filter(|indices| {
indices
.iter()
.all(|index| usize::from(*index) < face.vertices.len())
})
.count()
})
.sum::<usize>();
if gpu_triangles.saturating_add(object_triangles) > object_triangle_limit {
geometry_truncated = true;
continue;
}
let mut output = Vec::new();
let mut renderables = Vec::new();
let mut texture_complete = true;
@@ -1701,7 +1782,6 @@ fn native_entities(
let texture = textures.get(&face.texture_face.texture_id());
if face.texture_face.texture_id() != UUID::zero() && texture.is_none() {
texture_complete = false;
continue;
}
let gpu_vertices = face
.vertices
@@ -1716,7 +1796,6 @@ fn native_entities(
.iter()
.all(|index| usize::from(*index) < gpu_vertices.len())
})
.take(object_triangle_limit.saturating_sub(gpu_triangles))
.flatten()
.map(|index| u32::from(*index))
.collect::<Vec<_>>();
@@ -1767,9 +1846,6 @@ fn native_entities(
}
let shade = triangle_shade(vertices);
colors = colors.map(|color| shade_color(color, shade));
if triangles >= object_triangle_limit {
break;
}
triangles += 1;
output.push(SceneTriangle {
vertices,
@@ -1834,7 +1910,9 @@ fn native_entities(
region_size,
water,
&textures,
terrain_texture_ids,
terrain_materials,
camera_position,
maximum_distance,
maximum.saturating_sub(triangles).saturating_sub(2),
);
if !terrain_triangles.is_empty() {
@@ -1847,7 +1925,8 @@ fn native_entities(
display_name: "simulator terrain".into(),
triangles: terrain_triangles,
renderables,
texture_available: terrain_texture_ids
texture_available: terrain_materials
.texture_ids
.iter()
.any(|id| textures.contains_key(id)),
});
@@ -1884,7 +1963,7 @@ fn native_entities(
.values()
.map(|texture| texture.scene_texture())
.collect();
Ok((entities, gpu_textures, pixels_consumed))
Ok((entities, gpu_textures, pixels_consumed, geometry_truncated))
}
#[cfg(feature = "live-grid")]
@@ -1938,16 +2017,30 @@ fn store_geometry(
}
#[cfg(feature = "live-grid")]
fn scene_detail_level(
fn geometry_signature(prim: &libremetaverse::Primitive, camera: [f32; 3]) -> GeometrySignature {
GeometrySignature {
construction: prim.prim_data.clone(),
sculpt: prim.sculpt.clone(),
textures: prim
.textures
.as_ref()
.and_then(|textures| textures.get_bytes().ok())
.unwrap_or_default(),
detail: scene_detail_level(prim, camera),
}
}
#[cfg(feature = "live-grid")]
pub(crate) fn scene_detail_level(
prim: &libremetaverse::Primitive,
camera: [f32; 3],
) -> libremetaverse::rendering::DetailLevel {
let distance = scene_distance_squared(prim.position, camera).sqrt();
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
if distance < 12.0 && extent < 16.0 {
libremetaverse::rendering::DetailLevel::High
if distance < 32.0 || extent >= 16.0 {
libremetaverse::rendering::DetailLevel::Highest
} else {
libremetaverse::rendering::DetailLevel::Medium
libremetaverse::rendering::DetailLevel::High
}
}
@@ -1989,7 +2082,7 @@ fn native_gpu_vertex(
#[cfg(feature = "live-grid")]
#[allow(clippy::too_many_lines)] // Direct mapping of the two supported material models.
fn face_material(
pub(crate) fn face_material(
face: &libremetaverse::PrimitiveTextureEntryFace,
textures: &std::collections::BTreeMap<UUID, usize>,
legacy_materials: &std::collections::BTreeMap<UUID, libremetaverse::materials::LegacyMaterial>,
@@ -2037,6 +2130,7 @@ fn face_material(
],
double_sided: material.double_sided(),
alpha_mode: match material.alpha_mode() {
libremetaverse::assets::GltfAlphaMode::Opaque if tint.a < 0.999 => AlphaMode::Blend,
libremetaverse::assets::GltfAlphaMode::Opaque => AlphaMode::Opaque,
libremetaverse::assets::GltfAlphaMode::Blend => AlphaMode::Blend,
libremetaverse::assets::GltfAlphaMode::Mask => AlphaMode::Mask {
@@ -2046,6 +2140,15 @@ fn face_material(
});
}
let rgba = face.rgba();
let automatic_alpha = || {
if rgba.a < 0.999 {
AlphaMode::Blend
} else if texture_has_alpha {
AlphaMode::Mask { cutoff: 0.5 }
} else {
AlphaMode::Opaque
}
};
let legacy = legacy_materials.get(&face.material_id());
let shininess = match face.shiny() {
libremetaverse::Shininess::None => 1.0,
@@ -2096,10 +2199,16 @@ fn face_material(
rotation_radians: material.specular_map_rotation() as f32,
},
}),
specular_color_srgb: legacy.map_or([255; 3], |material| {
specular_color_srgb: legacy.map_or_else(
|| match face.shiny() {
libremetaverse::Shininess::None => [0; 3],
_ => [255; 3],
},
|material| {
let color = material.specular_color();
[unit_byte(color.r), unit_byte(color.g), unit_byte(color.b)]
}),
},
),
shininess: legacy.map_or(shininess, |material| {
f32::from(material.specular_exponent()).max(1.0)
}),
@@ -2111,28 +2220,33 @@ fn face_material(
material.diffuse_alpha_mode()
== libremetaverse::materials::LegacyMaterialAlphaMode::Emissive
}),
alpha_mode: legacy.map_or_else(
|| {
if rgba.a < 0.999 || texture_has_alpha {
alpha_mode: legacy.map_or_else(automatic_alpha, |material| {
if rgba.a < 0.999 {
AlphaMode::Blend
} else {
AlphaMode::Opaque
}
},
|material| match material.diffuse_alpha_mode() {
match material.diffuse_alpha_mode() {
libremetaverse::materials::LegacyMaterialAlphaMode::Blend
| libremetaverse::materials::LegacyMaterialAlphaMode::Emissive => AlphaMode::Blend,
| libremetaverse::materials::LegacyMaterialAlphaMode::Emissive => {
AlphaMode::Blend
}
libremetaverse::materials::LegacyMaterialAlphaMode::Mask => AlphaMode::Mask {
cutoff: f32::from(material.alpha_mask_cutoff()) / 255.0,
},
libremetaverse::materials::LegacyMaterialAlphaMode::Default => {
if rgba.a < 0.999 || texture_has_alpha {
AlphaMode::Blend
libremetaverse::materials::LegacyMaterialAlphaMode::Default
| libremetaverse::materials::LegacyMaterialAlphaMode::None => automatic_alpha(),
}
}
}),
alpha_cutoff: legacy.map_or_else(
|| texture_has_alpha.then_some(0.5),
|material| {
if material.diffuse_alpha_mode()
== libremetaverse::materials::LegacyMaterialAlphaMode::Mask
{
Some(f32::from(material.alpha_mask_cutoff()) / 255.0)
} else {
AlphaMode::Opaque
texture_has_alpha.then_some(0.5)
}
}
libremetaverse::materials::LegacyMaterialAlphaMode::None => AlphaMode::Opaque,
},
),
..Default::default()
@@ -2185,7 +2299,7 @@ pub(crate) fn scene_renderable_from_triangles(
}
#[cfg(feature = "live-grid")]
struct DecodedTexture {
pub(crate) struct DecodedTexture {
width: usize,
height: usize,
red: Vec<u8>,
@@ -2268,7 +2382,7 @@ fn cached_textures(
max_bytes: usize,
max_cached_pixels: usize,
) -> (std::collections::BTreeMap<UUID, Arc<DecodedTexture>>, usize) {
const MAX_PIXELS_PER_CAPTURE: usize = 16_777_216;
const MAX_PIXELS_PER_CAPTURE: usize = 67_108_864;
let (decoded, _) = decode_textures(
encoded,
max_bytes,
@@ -2389,11 +2503,17 @@ fn decode_textures(
break;
}
let options = libremetaverse_imaging::J2kDecodeOptions::default()
.with_discard_levels(1)
.with_limits(max_bytes.min(bytes.len().max(1)), remaining_pixels);
let Ok(image) = libremetaverse_imaging::RustJ2kCodec::decode_bytes(&bytes, options) else {
.with_limits(max_bytes.min(bytes.len().max(1)), 33_554_432);
let Ok(mut image) = libremetaverse_imaging::RustJ2kCodec::decode_bytes(&bytes, options)
else {
continue;
};
// A 512px source still exceeds the screen footprint of almost every
// face in an agent snapshot, while letting dense UGC scenes converge
// without retaining gigabytes of decoded 1024px textures.
if fit_texture_for_view(&mut image, 512).is_err() {
continue;
}
let Ok(width) = usize::try_from(image.width) else {
continue;
};
@@ -2422,6 +2542,44 @@ fn decode_textures(
(output, max_pixels - remaining_pixels)
}
#[cfg(feature = "live-grid")]
pub(crate) fn fit_texture_for_view(
image: &mut libremetaverse_imaging::ManagedImage,
maximum_edge: i32,
) -> Result<(), libremetaverse_types::Error> {
let largest = image.width.max(image.height);
if largest <= maximum_edge {
return Ok(());
}
let width = (i64::from(image.width) * i64::from(maximum_edge) / i64::from(largest))
.max(1)
.try_into()
.map_err(|_| libremetaverse_types::Error::Argument)?;
let height = (i64::from(image.height) * i64::from(maximum_edge) / i64::from(largest))
.max(1)
.try_into()
.map_err(|_| libremetaverse_types::Error::Argument)?;
if !image.alpha.is_empty() {
for color in [&mut image.red, &mut image.green, &mut image.blue] {
for (sample, alpha) in color.iter_mut().zip(&image.alpha) {
*sample = u8::try_from(u16::from(*sample) * u16::from(*alpha) / 255)
.expect("premultiplied color fits in a byte");
}
}
}
image.resize_bilinear(width, height)?;
if !image.alpha.is_empty() {
for color in [&mut image.red, &mut image.green, &mut image.blue] {
for (sample, alpha) in color.iter_mut().zip(&image.alpha) {
if *alpha != 0 {
*sample = (u16::from(*sample) * 255 / u16::from(*alpha)).min(255) as u8;
}
}
}
}
Ok(())
}
#[cfg(feature = "live-grid")]
fn multiply_color(tint: [u8; 4], texture: [u8; 4]) -> [u8; 4] {
std::array::from_fn(|channel| {
@@ -2441,50 +2599,67 @@ pub(crate) fn terrain_height(
}
#[cfg(feature = "live-grid")]
fn terrain_triangles(
#[allow(clippy::too_many_arguments)]
pub(crate) fn terrain_triangles(
terrain: &[libremetaverse::TerrainPatch],
region_size: (f32, f32),
water: f32,
textures: &std::collections::BTreeMap<UUID, Arc<DecodedTexture>>,
texture_ids: [UUID; 4],
materials: TerrainMaterials,
camera_position: [f32; 3],
maximum_distance: f32,
maximum: usize,
) -> Vec<SceneTriangle> {
let (width, height, patch_width) = terrain_layout(terrain, region_size);
if maximum < 2 || width < 2 || height < 2 {
return Vec::new();
}
let heights = terrain
.iter()
.flat_map(|patch| patch.data.iter().copied())
.filter(|value| value.is_finite())
.collect::<Vec<_>>();
let Some((&minimum, &maximum_height)) = heights
.iter()
.min_by(|a, b| a.total_cmp(b))
.zip(heights.iter().max_by(|a, b| a.total_cmp(b)))
else {
return Vec::new();
};
let mut step = 4usize;
while width.div_ceil(step) * height.div_ceil(step) * 2 > maximum {
let radius = maximum_distance.ceil();
let minimum_x = (camera_position[0] - radius).floor().max(0.0) as usize;
let minimum_y = (camera_position[1] - radius).floor().max(0.0) as usize;
let maximum_x = (camera_position[0] + radius).ceil().max(0.0) as usize;
let maximum_y = (camera_position[1] + radius).ceil().max(0.0) as usize;
let minimum_x = minimum_x.min(width - 1);
let minimum_y = minimum_y.min(height - 1);
let maximum_x = maximum_x.min(width - 1);
let maximum_y = maximum_y.min(height - 1);
let mut step = 1usize;
while (maximum_x - minimum_x).div_ceil(step) * (maximum_y - minimum_y).div_ceil(step) * 2
> maximum
{
step *= 2;
}
let color = |x: usize, y: usize, z: f32| {
let range = (maximum_height - minimum).max(1.0);
let level = ((z - minimum) / range).clamp(0.0, 0.999);
let texture = textures.get(&texture_ids[(level * 4.0) as usize]);
if let Some(texture) = texture {
texture.sample(x as f32 / 128.0, y as f32 / 128.0)
} else if z <= water {
let east = x as f32 / region_size.0.max(1.0);
let north = y as f32 / region_size.1.max(1.0);
let start = bilinear_corners(materials.start_heights, east, north);
let range = bilinear_corners(materials.height_ranges, east, north).max(1.0);
let layer = ((z - start) / range * 3.0).clamp(0.0, 3.0);
let lower = layer.floor() as usize;
let upper = layer.ceil() as usize;
let blend = layer.fract() * layer.fract() * (3.0 - 2.0 * layer.fract());
let sample = |index: usize| {
textures.get(&materials.texture_ids[index]).map_or_else(
|| {
if z <= water {
[45, 90, 125, 255]
} else {
let light = unit_byte(0.65 + level * 0.3);
[light / 2, light, light / 3, 255]
[
[80, 105, 55, 255],
[105, 100, 65, 255],
[90, 95, 80, 255],
[125, 125, 115, 255],
][index]
}
},
|texture| texture.sample(x as f32 / 12.5, y as f32 / 12.5),
)
};
blend_color(sample(lower), sample(upper), blend)
};
let mut output = Vec::new();
for y in (0..height.saturating_sub(1)).step_by(step) {
for x in (0..width.saturating_sub(1)).step_by(step) {
for y in (minimum_y..maximum_y).step_by(step) {
for x in (minimum_x..maximum_x).step_by(step) {
if output.len().saturating_add(2) > maximum {
return output;
}
@@ -2523,6 +2698,23 @@ fn terrain_triangles(
output
}
#[cfg(feature = "live-grid")]
pub(crate) fn bilinear_corners(values: [f32; 4], east: f32, north: f32) -> f32 {
let east = east.clamp(0.0, 1.0);
let north = north.clamp(0.0, 1.0);
let south = values[0] * (1.0 - east) + values[2] * east;
let north_edge = values[1] * (1.0 - east) + values[3] * east;
south * (1.0 - north) + north_edge * north
}
#[cfg(feature = "live-grid")]
pub(crate) fn blend_color(lower: [u8; 4], upper: [u8; 4], amount: f32) -> [u8; 4] {
std::array::from_fn(|channel| {
(f32::from(lower[channel]) * (1.0 - amount) + f32::from(upper[channel]) * amount).round()
as u8
})
}
#[cfg(feature = "live-grid")]
pub(crate) fn triangle_shade(vertices: [[f32; 3]; 3]) -> f32 {
let edge_a = std::array::from_fn(|axis| vertices[1][axis] - vertices[0][axis]);

View File

@@ -59,6 +59,102 @@ fn extended_terrain_layout_uses_received_patch_coordinates() {
assert_eq!(terrain_height(&terrain, 256, 675, 538), Some(20.0));
}
#[cfg(feature = "live-grid")]
#[test]
fn terrain_uses_corner_bands_and_only_builds_the_camera_neighborhood() {
assert!((bilinear_corners([0.0, 10.0, 20.0, 30.0], 0.5, 0.5) - 15.0).abs() < f32::EPSILON);
assert_eq!(
blend_color([0, 20, 40, 255], [100, 120, 140, 255], 0.5),
[50, 70, 90, 255]
);
let terrain = (0..16)
.flat_map(|y| {
(0..16).map(move |x| libremetaverse::TerrainPatch {
data: vec![20.0; 256],
x,
y,
})
})
.collect::<Vec<_>>();
let triangles = terrain_triangles(
&terrain,
(256.0, 256.0),
19.0,
&std::collections::BTreeMap::new(),
TerrainMaterials {
texture_ids: [UUID::zero(); 4],
start_heights: [0.0; 4],
height_ranges: [30.0; 4],
},
[128.0, 128.0, 22.0],
8.0,
10_000,
);
assert_eq!(triangles.len(), 512);
assert!(
triangles.iter().all(|triangle| triangle
.vertices
.iter()
.all(|vertex| (120.0..=136.0).contains(&vertex[0])
&& (120.0..=136.0).contains(&vertex[1])))
);
}
#[cfg(feature = "live-grid")]
#[test]
fn scene_meshes_use_visual_lods_at_the_default_view_distance() {
let mut prim = libremetaverse::Primitive::new_with_constructor().unwrap();
prim.position = libremetaverse_types::Vector3 {
x: 31.0,
y: 0.0,
z: 0.0,
};
assert_eq!(
scene_detail_level(&prim, [0.0; 3]),
libremetaverse::rendering::DetailLevel::Highest
);
prim.position.x = 64.0;
assert_eq!(
scene_detail_level(&prim, [0.0; 3]),
libremetaverse::rendering::DetailLevel::High
);
}
#[cfg(feature = "live-grid")]
#[test]
fn oversized_scene_textures_are_mip_sized_without_distortion() {
let mut image = libremetaverse_imaging::ManagedImage::new(
4,
2,
libremetaverse_imaging::ManagedImageImageChannels::COLOR,
)
.unwrap();
fit_texture_for_view(&mut image, 2).unwrap();
assert_eq!((image.width, image.height), (2, 1));
assert_eq!(image.red.len(), 2);
let mut cutout = libremetaverse_imaging::ManagedImage::new(
4,
2,
libremetaverse_imaging::ManagedImageImageChannels::COLOR
| libremetaverse_imaging::ManagedImageImageChannels::ALPHA,
)
.unwrap();
cutout
.red
.copy_from_slice(&[255, 255, 0, 0, 255, 255, 0, 0]);
cutout
.green
.copy_from_slice(&[220, 220, 0, 0, 220, 220, 0, 0]);
cutout
.alpha
.copy_from_slice(&[255, 255, 0, 0, 255, 255, 0, 0]);
fit_texture_for_view(&mut cutout, 3).unwrap();
assert_eq!((cutout.width, cutout.height), (3, 1));
assert_eq!((cutout.red[1], cutout.green[1]), (255, 219));
assert_eq!(cutout.alpha[1], 128);
}
#[cfg(feature = "live-grid")]
#[test]
fn renderer_uses_the_camera_axis_that_points_at_the_requested_target() {
@@ -84,6 +180,104 @@ fn scene_faces_are_shaded_without_changing_alpha() {
assert_eq!(color[3], 77);
}
#[cfg(feature = "live-grid")]
#[test]
fn legacy_faces_preserve_face_alpha_without_inventing_specular_light() {
let mut face = libremetaverse::PrimitiveTextureEntryFace::default();
face.set_rgba(libremetaverse_types::Color4 {
r: 0.25,
g: 0.5,
b: 0.75,
a: 0.0,
});
let material = face_material(
&face,
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
&std::collections::BTreeMap::new(),
true,
);
let metacrate_rendering_wgpu::Material::BlinnPhong(material) = material else {
panic!("legacy face did not produce a Blinn-Phong material");
};
assert_eq!(material.diffuse_color_srgb, [64, 128, 191, 0]);
assert_eq!(material.specular_color_srgb, [0; 3]);
assert_eq!(
material.alpha_mode,
metacrate_rendering_wgpu::AlphaMode::Blend
);
assert_eq!(material.alpha_cutoff, Some(0.5));
}
#[cfg(feature = "live-grid")]
#[test]
fn face_alpha_overrides_an_opaque_material_mode() {
let mut face = libremetaverse::PrimitiveTextureEntryFace::default();
face.set_rgba(libremetaverse_types::Color4 {
r: 1.0,
g: 1.0,
b: 1.0,
a: 0.0,
});
let mut legacy = libremetaverse::materials::LegacyMaterial::new_with_constructor().unwrap();
legacy.set_id(face.material_id());
legacy.set_diffuse_alpha_mode(libremetaverse::materials::LegacyMaterialAlphaMode::None);
let legacy = [(legacy.id(), legacy)].into_iter().collect();
let material = face_material(
&face,
&std::collections::BTreeMap::new(),
&legacy,
&std::collections::BTreeMap::new(),
false,
);
assert_eq!(
material.alpha_mode(),
metacrate_rendering_wgpu::AlphaMode::Blend
);
face.set_rgba(libremetaverse_types::Color4::white());
let material = face_material(
&face,
&std::collections::BTreeMap::new(),
&legacy,
&std::collections::BTreeMap::new(),
true,
);
assert_eq!(
material.alpha_mode(),
metacrate_rendering_wgpu::AlphaMode::Mask { cutoff: 0.5 }
);
let metacrate_rendering_wgpu::Material::BlinnPhong(material) = material else {
panic!("legacy face did not produce a Blinn-Phong material");
};
assert_eq!(material.alpha_cutoff, Some(0.5));
face.set_rgba(libremetaverse_types::Color4 {
a: 0.5,
..libremetaverse_types::Color4::white()
});
let mut mask = libremetaverse::materials::LegacyMaterial::new_with_constructor().unwrap();
mask.set_id(face.material_id());
mask.set_diffuse_alpha_mode(libremetaverse::materials::LegacyMaterialAlphaMode::Mask);
mask.set_alpha_mask_cutoff(128);
let masks = [(mask.id(), mask)].into_iter().collect();
let material = face_material(
&face,
&std::collections::BTreeMap::new(),
&masks,
&std::collections::BTreeMap::new(),
true,
);
let metacrate_rendering_wgpu::Material::BlinnPhong(material) = material else {
panic!("legacy face did not produce a Blinn-Phong material");
};
assert_eq!(
material.alpha_mode,
metacrate_rendering_wgpu::AlphaMode::Blend
);
assert_eq!(material.alpha_cutoff, Some(128.0 / 255.0));
}
#[cfg(feature = "live-grid")]
#[test]
fn scene_visibility_uses_configured_distance_and_avatar_view() {
@@ -221,7 +415,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
assert_eq!(first.image_sha256, second.image_sha256);
assert_eq!(
first.image_sha256,
"b53d23f57ba0d6fb79856afde111ad6dc25559e810dcc028a5f107090914821d"
"63a9e3502b7ca43ac8b3560f516fcf1fbed96f74f86e9818d7d88db2cc39555b"
);
assert!(first.summary.contains("privacy-marked residents"));
assert!(!first.summary.contains("Private Resident"));

View File

@@ -2,10 +2,11 @@ use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
const ALLOWED_DEPENDENCIES: [&str; 20] = [
const ALLOWED_DEPENDENCIES: [&str; 21] = [
"async-trait",
"base64",
"crossterm",
"directories",
"libremetaverse",
"libremetaverse-imaging",
"libremetaverse-rendering-simple",

View File

@@ -45,7 +45,7 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
let response = agent
.send(vec![
ContentBlock::text(
"Describe this rendered virtual-world scene. State whether it contains recognizable textured terrain and mesh scenery, and identify any obvious rendering corruption.",
"Describe this rendered virtual-world scene. State whether it contains recognizable detailed textured terrain and mesh scenery, and identify any obvious rendering corruption. End with `QUALITY: PASS` only if the scene is clearly recognizable and has no substantial rendering corruption; otherwise end with `QUALITY: FAIL`.",
),
ContentBlock::image_url(image),
])
@@ -61,10 +61,17 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
if descriptions.is_empty() {
return Err("Luna returned no snapshot description".into());
}
let passed = descriptions
.iter()
.any(|description| description.contains("QUALITY: PASS"));
for description in descriptions {
println!("LIVE_LLM_VISION={description}");
}
if passed {
Ok(())
} else {
Err("Luna rejected the rendered scene quality".into())
}
}
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {

View File

@@ -1,9 +1,13 @@
#![cfg(feature = "live-grid")]
use libremetaverse_types::{Vector3, compat::CancellationTokenSource};
use libremetaverse_types::{
Vector3,
compat::{CancellationToken, CancellationTokenSource},
};
use metacrate_grid_agent::{
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
LibremetaverseSceneSource, SecretString, SessionSignal, VisionLimits, VisionService,
LibremetaverseSceneSource, SceneEntityKind, SceneSnapshot, SceneSource, SecretString,
SessionSignal, VisionFuture, VisionLimits, VisionService,
};
use std::{
collections::BTreeMap,
@@ -14,196 +18,144 @@ use std::{
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs two dedicated accounts into the configured live grid and writes a JPEG"]
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box<dyn Error>> {
#[ignore = "logs the primary agent into the configured live varregion"]
async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let target_owner = LibremetaverseClientOwner::new()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut target_session, target_cancel) = login(
&target_owner,
&environment,
"GRID_TEST_UNPRIVILEGED_USER",
"GRID_TEST_UNPRIVILEGED_PASSWORD",
)
.await?;
let (mut renderer_session, renderer_cancel) = match login(
&renderer_owner,
&environment,
"GRID_TEST_AUTHORIZED_USER",
"GRID_TEST_AUTHORIZED_PASSWORD",
)
.await
{
Ok(login) => login,
Err(error) => {
let _ = target_session.logout(target_cancel.token()).await;
return Err(error);
let owner = LibremetaverseClientOwner::new()?;
let (mut session, cancellation) =
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let result = async {
wait_ready("primary", &mut session, &cancellation).await?;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("primary simulator unavailable")?;
let size = simulator.region_size();
println!(
"LIVE_PRIMARY_REGION=region:{:?},handle:{},size:{}x{},position:{:?}",
simulator.name,
simulator.handle,
size.0,
size.1,
owner.agent().sim_position()
);
if size != (1024, 1024) {
return Err(format!("expected 1024x1024 varregion, got {}x{}", size.0, size.1).into());
}
Ok(())
}
.await;
let _ = session.logout(cancellation.token()).await;
result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "records the primary region's asynchronous map-name reply"]
async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let owner = LibremetaverseClientOwner::new()?;
let (mut session, cancellation) =
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let result = async {
wait_ready("primary", &mut session, &cancellation).await?;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("primary simulator unavailable")?;
let name = simulator.name.clone();
let handle = simulator.handle;
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let _subscription = owner
.client()
.grid()
.subscribe_grid_region(Arc::new(move |event| {
let _ = sender.send(event.region());
}));
let started = Instant::now();
owner
.client()
.grid()
.request_map_region(name.clone(), libremetaverse::GridLayerType::Objects)?;
let deadline = started + Duration::from_secs(15);
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
let Ok(Some(region)) = tokio::time::timeout(remaining, receiver.recv()).await else {
break;
};
println!(
"LIVE_MAP_NAME_EVENT=elapsed_ms:{},name:{:?},handle:{},size:{}x{}",
started.elapsed().as_millis(),
region.name,
region.region_handle,
region.size_x,
region.size_y
);
if region.region_handle == handle && (region.size_x, region.size_y) == (1024, 1024) {
return Ok::<(), Box<dyn Error>>(());
}
}
Err(format!("no explicit 1024x1024 map-name reply for {name:?}").into())
}
.await;
let _ = session.logout(cancellation.token()).await;
result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs the primary agent into the configured live grid and writes a JPEG"]
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
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 result = async {
wait_ready("target", &mut target_session, &target_cancel).await?;
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
let renderer_start_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("renderer start simulator unavailable")?;
let renderer_start_handle = renderer_start_simulator.handle;
let renderer_start_position = conventional_start(&renderer_start_simulator);
let target_simulator = target_owner
.client()
.network()
.current_sim()
.ok_or("target simulator unavailable")?;
let target_position = target_owner.agent().sim_position();
let arrival = Vector3 {
x: target_position.x - 10.0,
y: target_position.y - 10.0,
z: target_position.z + 2.0,
};
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
target_simulator.handle,
arrival,
target_position,
Some(renderer_cancel.token()),
)
.await?
{
return Err("teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
renderer_start_position,
Vector3 {
x: renderer_start_position.x + 10.0,
y: renderer_start_position.y,
z: renderer_start_position.z,
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("return teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
let mut renderer_simulator = renderer_owner
let renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("renderer simulator unavailable")?;
let mut settled_position = renderer_owner.agent().sim_position();
if let Some(ground) = terrain_ground(
&renderer_simulator,
renderer_start_position.x,
renderer_start_position.y,
) {
let destination = Vector3 {
x: renderer_start_position.x,
y: renderer_start_position.y,
z: ground + 2.0,
};
if (settled_position.z - destination.z).abs() > 1.0 {
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
destination,
Vector3 {
x: destination.x + 10.0,
..destination
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("ground-settle teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("settled renderer simulator unavailable")?;
settled_position = renderer_owner.agent().sim_position();
}
}
wait_scene_settled(&renderer_owner).await?;
let mut view_forward = Vector3::unit_x();
if let Some((destination, forward)) = scenic_location(&renderer_simulator) {
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
destination,
Vector3 {
x: destination.x + forward.x,
y: destination.y + forward.y,
z: destination.z + forward.z,
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("scenic teleport did not complete".into());
}
wait_scene_settled(&renderer_owner).await?;
renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("scenic renderer simulator unavailable")?;
settled_position = renderer_owner.agent().sim_position();
view_forward = forward;
}
{
let prims = renderer_simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let largest_scale = prims.values().fold(0.0_f32, |largest, prim| {
largest.max(prim.scale.x.max(prim.scale.y).max(prim.scale.z))
});
let settled_position = renderer_owner.agent().sim_position();
let region_size = renderer_simulator.region_size();
println!(
"LIVE_SCENE_CACHE=prims:{} roots:{} children:{} attachments:{} sculpted:{} largest_scale:{largest_scale:.2}",
prims.len(),
prims.values().filter(|prim| prim.parent_id == 0).count(),
prims.values().filter(|prim| prim.parent_id != 0).count(),
prims.values().filter(|prim| prim.is_attachment).count(),
prims.values().filter(|prim| prim.sculpt.is_some()).count(),
"LIVE_RENDER_LOCATION=region:{:?},size:{}x{},position:{settled_position:?}",
renderer_simulator.name, region_size.0, region_size.1
);
println!(
"LIVE_SCENE_GEOMETRY=mesh:{} sculpt:{} legacy:{}",
prims
.values()
.filter(|prim| prim.sculpt.as_ref().is_some_and(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh))
.count(),
prims
.values()
.filter(|prim| prim.sculpt.as_ref().is_some_and(|sculpt| sculpt.type_() != libremetaverse_types::SculptType::Mesh))
.count(),
prims.values().filter(|prim| prim.sculpt.is_none()).count(),
wait_scene_settled(&renderer_owner).await?;
let view_forward = Vector3::mul_with_vector3_quaternion(
Vector3::unit_x(),
renderer_owner.agent().sim_rotation(),
);
}
let (camera_position, camera_target) = avatar_camera(settled_position, view_forward)?;
let (camera_position, camera_target) =
avatar_forward_camera(settled_position, view_forward)?;
renderer_owner
.agent()
.movement
.camera
.look_at_with_vector3_vector3(camera_position, camera_target)?;
renderer_owner
.agent()
.movement
.camera
.set_vertical_fov_angle(65_f32.to_radians())?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
println!(
"LIVE_RENDER_SCOPE=max_distance_meters:{}",
limits.max_distance_meters
);
let vision = VisionService::new(
Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits)),
Arc::new(DiagnosticSceneSource {
inner: LibremetaverseSceneSource::new(&renderer_owner, limits),
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
}),
limits,
)?
.prefer_gpu();
@@ -216,8 +168,10 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(8)
.clamp(1, 16);
.unwrap_or(12)
.clamp(1, 24);
let mut previous_missing = u32::MAX;
let mut stable_missing = 0_u32;
for frame in 1..=frames {
let next = vision
.capture(
@@ -228,7 +182,16 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
.await?;
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
std::fs::write(&output, &next.jpeg)?;
if next.completeness.textures_missing == previous_missing {
stable_missing += 1;
} else {
previous_missing = next.completeness.textures_missing;
stable_missing = 0;
}
capture = Some(next);
if previous_missing == 0 || (frame >= 8 && stable_missing >= 3) {
break;
}
}
let capture = capture.ok_or("live renderer produced no frame")?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
@@ -238,10 +201,63 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
.await;
let _ = renderer_session.logout(renderer_cancel.token()).await;
let _ = target_session.logout(target_cancel.token()).await;
result
}
struct DiagnosticSceneSource {
inner: LibremetaverseSceneSource,
material_override: Option<String>,
}
impl SceneSource for DiagnosticSceneSource {
fn capture_scene(
&self,
generation: u64,
cancellation: CancellationToken,
) -> VisionFuture<'_, SceneSnapshot> {
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
.iter_mut()
.filter(|entity| entity.kind == SceneEntityKind::Object)
.flat_map(|entity| &mut entity.renderables)
{
match &mut renderable.material {
metacrate_rendering_wgpu::Material::BlinnPhong(material) => {
if material_override == "solid" {
*material = metacrate_rendering_wgpu::BlinnPhongMaterial {
diffuse_color_srgb: [255, 0, 255, 255],
fullbright: true,
double_sided: true,
..Default::default()
};
} else if material_override == "unlit-white" {
material.diffuse_color_srgb = [255; 4];
material.fullbright = true;
}
}
metacrate_rendering_wgpu::Material::Pbr(material) => {
if material_override == "solid" {
*material = metacrate_rendering_wgpu::PbrMaterial {
base_color_srgb: [0, 255, 255, 255],
emissive_factor_srgb: [0, 255, 255],
metallic_factor: 0.0,
roughness_factor: 1.0,
double_sided: true,
..Default::default()
};
}
}
}
}
}
Ok(scene)
})
}
}
async fn login(
owner: &LibremetaverseClientOwner,
environment: &BTreeMap<String, String>,
@@ -336,108 +352,56 @@ fn required(
.ok_or_else(|| format!("missing live test setting {key}").into())
}
#[allow(clippy::cast_precision_loss)] // Simulator dimensions are small region coordinates.
fn conventional_start(simulator: &libremetaverse::Simulator) -> Vector3 {
let x = 128.0_f32.min(simulator.size_x as f32 - 2.0);
let y = 128.0_f32.min(simulator.size_y as f32 - 2.0);
Vector3 { x, y, z: 50.0 }
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)] // Coordinates are already clamped inside the non-negative simulator extent.
fn terrain_ground(simulator: &libremetaverse::Simulator, x: f32, y: f32) -> Option<f32> {
let patches = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let patch_x = (x as usize / 16) as i32;
let patch_y = (y as usize / 16) as i32;
let local_x = x as usize % 16;
let local_y = y as usize % 16;
patches
.iter()
.find(|patch| patch.x == patch_x && patch.y == patch_y)
.and_then(|patch| patch.data.get(local_y * 16 + local_x))
.copied()
}
#[allow(clippy::cast_precision_loss)] // Terrain patch indexes are small region coordinates.
fn scenic_location(simulator: &libremetaverse::Simulator) -> Option<(Vector3, Vector3)> {
let roots = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.filter(|prim| prim.parent_id == 0)
.cloned()
.collect::<Vec<_>>();
let patches = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
patches
.iter()
.filter(|patch| patch.data.len() == 256)
.filter_map(|patch| {
let position = Vector3 {
x: patch.x as f32 * 16.0 + 8.0,
y: patch.y as f32 * 16.0 + 8.0,
z: patch.data[8 * 16 + 8] + 2.0,
};
if roots.iter().any(|prim| {
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
let dx = prim.position.x - position.x;
let dy = prim.position.y - position.y;
let dz = prim.position.z - position.z;
(dx * dx + dy * dy + dz * dz).sqrt() < extent * 0.5 + 3.0
}) {
return None;
}
let mut score = 0.0_f32;
let mut forward = Vector3::zero();
for prim in &roots {
let dx = prim.position.x - position.x;
let dy = prim.position.y - position.y;
let horizontal = (dx * dx + dy * dy).sqrt();
let dz = prim.position.z - position.z;
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
if !(10.0..=64.0).contains(&horizontal)
|| dz.abs() > 20.0
|| !(2.0..=32.0).contains(&extent)
{
continue;
}
let weight = extent.min(10.0) / horizontal;
score += extent.min(10.0);
forward.x += dx * weight;
forward.y += dy * weight;
forward.z += dz.clamp(-horizontal * 0.35, horizontal * 0.35) * weight;
}
(score > 0.0).then_some((score, position, forward))
})
.max_by(|left, right| left.0.total_cmp(&right.0))
.map(|(_, position, forward)| (position, forward))
}
fn avatar_camera(avatar: Vector3, forward: Vector3) -> Result<(Vector3, Vector3), Box<dyn Error>> {
fn avatar_forward_camera(
avatar: Vector3,
forward: Vector3,
) -> Result<(Vector3, Vector3), Box<dyn Error>> {
let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt();
if !length.is_finite() || length < f32::EPSILON {
return Err("renderer start direction unavailable".into());
return Err("avatar facing direction unavailable".into());
}
let position = Vector3 {
x: avatar.x,
y: avatar.y,
z: avatar.z + 2.0,
z: avatar.z + 1.6,
};
Ok((
position,
Vector3 {
x: position.x + forward.x / length * 30.0,
y: position.y + forward.y / length * 30.0,
z: position.z + forward.z / length * 30.0,
x: position.x + forward.x / length * 24.0,
y: position.y + forward.y / length * 24.0,
z: position.z + forward.z / length * 24.0,
},
))
}
#[test]
fn evidence_camera_uses_avatar_eye_position_and_facing() {
let avatar = Vector3 {
x: 10.0,
y: 20.0,
z: 30.0,
};
let (position, target) = avatar_forward_camera(avatar, Vector3::unit_y()).unwrap();
assert_eq!(
position,
Vector3 {
x: 10.0,
y: 20.0,
z: 31.6
}
);
assert_eq!(
target,
Vector3 {
x: 10.0,
y: 44.0,
z: 31.6
}
);
}

View File

@@ -33,6 +33,9 @@ use std::{
time::Duration,
};
const WORLD_AMBIENT_BRIGHTNESS: f32 = 400.0;
const WORLD_ENVIRONMENT_BRIGHTNESS: f32 = 600.0;
enum RenderCommand {
Frame {
camera: Camera,
@@ -291,6 +294,7 @@ struct BevyBackend {
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
pbr_handles: Vec<Handle<PbrBevyMaterial>>,
legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
environment_map: EnvironmentMapLight,
target: Option<Handle<Image>>,
}
@@ -314,6 +318,22 @@ impl BevyBackend {
let plugins = plugins.disable::<bevy::winit::WinitPlugin>();
app.add_plugins(plugins)
.add_plugins((LegacyMaterialPlugin, PbrMaterialPlugin));
app.insert_resource(GlobalAmbientLight {
color: Color::WHITE,
brightness: WORLD_AMBIENT_BRIGHTNESS,
..default()
});
let environment_map = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let mut light = EnvironmentMapLight::hemispherical_gradient(
&mut images,
Color::srgb_u8(150, 180, 220),
Color::srgb_u8(175, 175, 165),
Color::srgb_u8(95, 90, 70),
);
light.intensity = WORLD_ENVIRONMENT_BRIGHTNESS;
light
};
app.finish();
app.cleanup();
if !app.world().contains_resource::<RenderDevice>() {
@@ -327,6 +347,7 @@ impl BevyBackend {
mesh_cache: HashMap::new(),
pbr_handles: Vec::new(),
legacy_handles: Vec::new(),
environment_map,
target: None,
})
}
@@ -422,6 +443,7 @@ impl BevyBackend {
..Default::default()
}),
transform,
self.environment_map.clone(),
FrameEntity,
))
.id();
@@ -630,22 +652,29 @@ fn rgba_mip_chain(texture: &Texture) -> (Vec<u8>, u32) {
let mut next = vec![0_u8; next_width * next_height * 4];
for y in 0..next_height {
for x in 0..next_width {
let mut channels = [0_u32; 4];
let mut weighted_rgb = [0_u32; 3];
let mut alpha = 0_u32;
let mut samples = 0_u32;
for source_y in y * 2..(y * 2 + 2).min(height) {
for source_x in x * 2..(x * 2 + 2).min(width) {
let source = (source_y * width + source_x) * 4;
for channel in 0..4 {
channels[channel] += u32::from(level[source + channel]);
let sample_alpha = u32::from(level[source + 3]);
alpha += sample_alpha;
for channel in 0..3 {
weighted_rgb[channel] +=
u32::from(level[source + channel]) * sample_alpha;
}
samples += 1;
}
}
let target = (y * next_width + x) * 4;
for channel in 0..4 {
next[target + channel] = u8::try_from(channels[channel] / samples)
.expect("the average of bytes fits in a byte");
for channel in 0..3 {
next[target + channel] =
u8::try_from(weighted_rgb[channel].checked_div(alpha).unwrap_or(0))
.expect("the alpha-weighted average of bytes fits in a byte");
}
next[target + 3] =
u8::try_from(alpha / samples).expect("the average of bytes fits in a byte");
}
}
output.extend_from_slice(&next);
@@ -770,10 +799,13 @@ fn legacy_as_bevy(
slot.transform.offset[1],
)
};
let cutoff = match material.alpha_mode {
let cutoff = material.alpha_cutoff.map_or_else(
|| match material.alpha_mode {
SceneAlphaMode::Mask { cutoff } => cutoff.clamp(0.0, 1.0),
_ => -1.0,
};
},
|cutoff| cutoff.clamp(0.0, 1.0),
);
LegacyBevyMaterial {
uniform: LegacyUniform {
diffuse_color: scene_color(material.diffuse_color_srgb).to_linear(),
@@ -967,6 +999,20 @@ fn validate_scene(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shaders_use_their_native_texture_transform_conventions() {
let legacy = include_str!("legacy_material.wgsl");
assert!(legacy.contains("(centered.x * cosine + centered.y * sine) * scale_offset.x"));
assert!(legacy.contains("(-centered.x * sine + centered.y * cosine) * scale_offset.y"));
assert!(legacy.contains("vec2(uv.x, 1.0 - uv.y)"));
assert!(legacy.contains("texel.a < material.surface.w"));
let pbr = include_str!("pbr_material.wgsl");
assert!(pbr.contains("let scaled = uv * scale_offset.xy"));
assert!(pbr.contains("scaled.x * cosine - scaled.y * sine"));
assert!(pbr.contains("scaled.x * sine + scaled.y * cosine"));
}
use crate::{Mesh as SceneMesh, TextureSlot, UvTransform, Vertex};
use std::sync::Arc;
@@ -976,16 +1022,25 @@ mod tests {
width: 2,
height: 2,
rgba: vec![
0, 0, 0, 0, 100, 100, 100, 100, 200, 200, 200, 200, 255, 255, 255, 255,
0, 0, 0, 255, 100, 100, 100, 255, 200, 200, 200, 255, 255, 255, 255, 255,
],
};
let (mips, levels) = rgba_mip_chain(&texture);
assert_eq!(levels, 2);
assert_eq!(mips.len(), 20);
assert_eq!(&mips[16..], &[138, 138, 138, 138]);
assert_eq!(&mips[16..], &[138, 138, 138, 255]);
let cutout = Texture {
width: 2,
height: 2,
rgba: vec![255, 220, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
let (mips, _) = rgba_mip_chain(&cutout);
assert_eq!(&mips[16..], &[255, 220, 0, 63]);
}
#[test]
#[allow(clippy::too_many_lines)] // One public render sequence checks reuse and orientation.
fn bevy_headless_renders_repeated_frames_through_the_public_api() {
let renderer = match Renderer::new_blocking() {
Ok(renderer) => Arc::new(renderer),
@@ -1030,6 +1085,67 @@ mod tests {
solid_texture([255, 180, 0, 255]),
solid_texture([30, 20, 10, 255]),
];
let background = renderer
.render(
camera,
&[],
&[],
[40, 80, 120, 255],
RenderLimits {
width: 96,
height: 64,
max_triangles: 0,
max_texture_bytes: 0,
max_texture_pixels: 0,
far_distance: 64,
},
)
.unwrap();
assert!(
background
.chunks_exact(4)
.all(|pixel| pixel == [40, 80, 120, 255]),
"empty scene did not preserve its clear color"
);
let oriented = renderer
.render(
camera,
&[renderable(Material::BlinnPhong(BlinnPhongMaterial {
diffuse: TextureSlot {
texture: Some(0),
..Default::default()
},
fullbright: true,
..Default::default()
}))],
&[Texture {
width: 2,
height: 2,
rgba: vec![
255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 255,
],
}],
[0, 0, 0, 255],
RenderLimits {
width: 96,
height: 64,
max_triangles: 2,
max_texture_bytes: 16,
max_texture_pixels: 4,
far_distance: 64,
},
)
.unwrap();
let top = &oriented[(20 * 96 + 48) * 4..][..4];
let bottom = &oriented[(44 * 96 + 48) * 4..][..4];
assert!(
top[0] > top[2],
"legacy texture top row is vertically flipped"
);
assert!(
bottom[2] > bottom[0],
"legacy texture bottom row is vertically flipped"
);
for renderable in frames {
let rgba = renderer
.render(

View File

@@ -20,28 +20,36 @@ struct LegacyUniform {
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var specular_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> {
let centered = (uv - vec2(0.5)) * scale_offset.xy;
let centered = uv - vec2(0.5);
let sine = sin(rotation);
let cosine = cos(rotation);
return vec2(
centered.x * cosine - centered.y * sine,
centered.x * sine + centered.y * cosine,
(centered.x * cosine + centered.y * sine) * scale_offset.x,
(-centered.x * sine + centered.y * cosine) * scale_offset.y,
) + vec2(0.5) + scale_offset.zw;
}
fn legacy_sample_uv(uv: vec2<f32>) -> vec2<f32> {
return vec2(uv.x, 1.0 - uv.y);
}
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
let diffuse_uv = transformed_uv(in.uv, material.diffuse_scale_offset, material.rotations.x);
let texel = textureSample(diffuse_texture, diffuse_sampler, diffuse_uv);
let texel = textureSample(diffuse_texture, diffuse_sampler, legacy_sample_uv(diffuse_uv));
var base = material.diffuse_color * texel * in.color;
if material.surface.w >= 0.0 && base.a < material.surface.w {
if material.surface.w >= 0.0 && texel.a < material.surface.w {
discard;
}
var normal = normalize(in.world_normal) * select(-1.0, 1.0, is_front);
if material.surface.z > 0.5 {
let normal_uv = transformed_uv(in.uv, material.normal_scale_offset, material.rotations.y);
let tangent_normal = textureSample(normal_texture, normal_sampler, normal_uv).xyz * 2.0 - 1.0;
let tangent_normal = textureSample(
normal_texture,
normal_sampler,
legacy_sample_uv(normal_uv),
).xyz * 2.0 - 1.0;
let tangent = normalize(in.world_tangent.xyz);
let bitangent = normalize(cross(normal, tangent)) * in.world_tangent.w;
normal = normalize(mat3x3(tangent, bitangent, normal) * tangent_normal);
@@ -52,7 +60,11 @@ fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> Fragment
let half_direction = normalize(light_direction + view_direction);
let diffuse_light = max(dot(normal, light_direction), 0.0);
let specular_uv = transformed_uv(in.uv, material.specular_scale_offset, material.rotations.z);
let specular_map = textureSample(specular_texture, specular_sampler, specular_uv).rgb;
let specular_map = textureSample(
specular_texture,
specular_sampler,
legacy_sample_uv(specular_uv),
).rgb;
let specular_light = pow(max(dot(normal, half_direction), 0.0), material.rotations.w);
let lit = base.rgb * (0.55 + 0.45 * diffuse_light)
+ material.specular_color.rgb * specular_map * specular_light

View File

@@ -112,6 +112,8 @@ pub struct BlinnPhongMaterial {
pub fullbright: bool,
pub double_sided: bool,
pub alpha_mode: AlphaMode,
/// Optional diffuse-texture cutout applied before face-alpha blending.
pub alpha_cutoff: Option<f32>,
}
impl Default for BlinnPhongMaterial {
@@ -127,6 +129,7 @@ impl Default for BlinnPhongMaterial {
fullbright: false,
double_sided: false,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: None,
}
}
}

View File

@@ -25,13 +25,13 @@ struct PbrExtension {
@group(#{MATERIAL_BIND_GROUP}) @binding(108) var emissive_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> {
let centered = (uv - vec2(0.5)) * scale_offset.xy;
let scaled = uv * scale_offset.xy;
let sine = sin(rotation);
let cosine = cos(rotation);
return vec2(
centered.x * cosine - centered.y * sine,
centered.x * sine + centered.y * cosine,
) + vec2(0.5) + scale_offset.zw;
scaled.x * cosine - scaled.y * sine,
scaled.x * sine + scaled.y * cosine,
) + scale_offset.zw;
}
@fragment

View File

@@ -53,6 +53,10 @@ the default file is used only when it already exists:
Relative paths in a YAML document resolve relative to that document where specified; service
deployments should use absolute paths.
Downloaded grid assets use the platform cache directory: `$XDG_CACHE_HOME/metacrate`
or `$HOME/.cache/metacrate` on Linux, `$HOME/Library/Caches/metacrate` on macOS,
and `%LOCALAPPDATA%\metacrate\cache` on Windows.
## Endpoint, grid identity, and authority
`llm.endpoint_url` is the OpenAI-compatible provider base URL. Mentra owns the
@@ -226,10 +230,11 @@ The example records all current queue, message, conversation, tool, behavior,
reconnect, and interaction defaults. Important defaults include 256 grid events,
32 control commands, 512 observations, four concurrent inference requests,
16 tool calls, 512 active senders/sessions, a two-minute model window, and bounded
10-second shutdown. Vision defaults to a 640x360 headless Bevy/wgpu JPEG, a
64-meter configurable view radius, a persistent 512 MiB decoded-texture cache,
and a deterministic software fallback with bounded entities, triangles, asset
work, JPEG bytes, time, and concurrency. Authorized `behavior_camera_set` calls are
10-second shutdown. Vision defaults to a 1920x1080 headless Bevy/wgpu JPEG, a
64-meter configurable view radius, a bounded in-memory decoded-texture cache,
a persistent platform asset cache, and a deterministic software fallback with
bounded entities, triangles, asset work, JPEG bytes, time, and concurrency.
Authorized `behavior_camera_set` calls are
limited to 96 meters from the avatar by default, require a distinct target, and
accept a 20-120 degree vertical field of view; `behavior_camera_reset` restores
the avatar-facing 60-degree view.