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. 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. 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`. 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", "async-trait",
"base64", "base64",
"crossterm", "crossterm",
"directories",
"jpeg-decoder", "jpeg-decoder",
"jpeg-encoder", "jpeg-encoder",
"libremetaverse", "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() { if velocity == Vector3::zero() {
return; return;
} }
let (region_size_x, region_size_y) = simulator.region_size();
let west = if velocity.x < -0.1 { let west = if velocity.x < -0.1 {
-position.x / velocity.x -position.x / velocity.x
} else { } else {
f32::MAX f32::MAX
}; };
let east = if velocity.x > 0.1 { 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 { } else {
f32::MAX f32::MAX
}; };
@@ -1784,7 +1785,7 @@ impl AgentMovementRuntime {
f32::MAX f32::MAX
}; };
let north = if velocity.y > 0.1 { 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 { } else {
f32::MAX f32::MAX
}; };
@@ -1822,8 +1823,9 @@ impl AgentMovementRuntime {
} }
let x = u32::try_from(simulator.handle >> 32).unwrap_or_default(); 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 y = u32::try_from(simulator.handle & u64::from(u32::MAX)).unwrap_or_default();
let size_x = simulator.size_x as f32; let (size_x, size_y) = simulator.region_size();
let size_y = simulator.size_y as f32; let size_x = size_x as f32;
let size_y = size_y as f32;
if velocity.x < -0.5 && position.x < 128.0 { if velocity.x < -0.5 && position.x < 128.0 {
self.establish_child(x.wrapping_sub(256), y, BorderCrossingDirection::West); self.establish_child(x.wrapping_sub(256), y, BorderCrossingDirection::West);
} else if velocity.x > 0.5 && position.x > size_x - 128.0 { } 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 name = sim.name.clone();
let safe_name = name.replace(['/', '\\'], "_"); let safe_name = name.replace(['/', '\\'], "_");
let mut file = File::create(root.join(format!("{safe_name}.r32"))).map_err(io_error)?; let mut file = File::create(root.join(format!("{safe_name}.r32"))).map_err(io_error)?;
for y in 0..sim.size_y { let (size_x, size_y) = sim.region_size();
for x in 0..sim.size_x { for y in 0..size_y {
for x in 0..size_x {
let mut height = 0.0; let mut height = 0.0;
if !sim.terrain_height_at_point(x as i32, y as i32, &mut height)? { if !sim.terrain_height_at_point(x as i32, y as i32, &mut height)? {
height = 0.0; height = 0.0;

View File

@@ -795,7 +795,22 @@ impl AssetManager {
&format!("{}_id", self.asset_type_to_string(type_)?), &format!("{}_id", self.asset_type_to_string(type_)?),
asset_id, 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 { } else {
self.request_asset_udp( self.request_asset_udp(
asset_id, asset_id,

View File

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

View File

@@ -71,6 +71,8 @@ pub struct GridRegion {
pub name: String, pub name: String,
pub region_flags: RegionFlags, pub region_flags: RegionFlags,
pub region_handle: u64, pub region_handle: u64,
pub size_x: u32,
pub size_y: u32,
pub water_height: u8, pub water_height: u8,
pub x: i32, pub x: i32,
pub y: i32, pub y: i32,
@@ -367,6 +369,7 @@ struct GridEvents {
struct CachedRegion { struct CachedRegion {
region: GridRegion, region: GridRegion,
seen: SystemTime, seen: SystemTime,
explicit_size: bool,
} }
pub(crate) struct GridManagerInner { pub(crate) struct GridManagerInner {
client: Arc<GridClient>, client: Arc<GridClient>,
@@ -664,39 +667,38 @@ impl GridManager {
layer: GridLayerType, layer: GridLayerType,
cancellation_token: Option<CancellationToken>, cancellation_token: Option<CancellationToken>,
) -> Result<Option<Option<GridRegion>>, Error> { ) -> 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(); let key = name.trim().to_ascii_lowercase();
if key.is_empty() { if key.is_empty() {
return Err(Error::Argument); return Err(Error::Argument);
} }
if let Some(region) = self.inner.region_by_name(&key) { if let Some(region) = self
return Ok(Some(Some(region)));
}
if let Some(simulator) = self
.inner .inner
.client .region_by_name(&key)
.network() .filter(|region| self.inner.region_size_is_explicit(region.region_handle))
.current_sim()
.filter(|simulator| simulator.name.eq_ignore_ascii_case(&key))
{ {
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))); return Ok(Some(Some(region)));
} }
self.wait_for(cancellation_token, notified).await?; let event_key = key.clone();
Ok(Some(self.inner.region_by_name(&key))) 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( pub async fn get_grid_region_with_u_int64_grid_layer_type_cancellation_token(
&self, &self,
@@ -704,18 +706,30 @@ impl GridManager {
layer: GridLayerType, layer: GridLayerType,
cancellation_token: Option<CancellationToken>, cancellation_token: Option<CancellationToken>,
) -> Result<Option<Option<GridRegion>>, Error> { ) -> 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))); return Ok(Some(Some(region)));
} }
let x = u16::try_from((handle >> 32) / 256).map_err(|_| Error::Argument)?; 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 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)?; self.request_map_blocks(layer, x, y, x, y, true)?;
if let Some(region) = self.inner.region_by_handle(handle) { if let Some(region) = self.inner.region_by_handle(handle) {
return Ok(Some(Some(region))); return Ok(Some(Some(region)));
} }
self.wait_for(cancellation_token, notified).await?; let region = self
Ok(Some(self.inner.region_by_handle(handle))) .wait_for_region_reply(receiver, token)
.await?
.or_else(|| self.inner.region_by_handle(handle));
Ok(Some(region))
} }
pub async fn map_items( pub async fn map_items(
&self, &self,
@@ -751,6 +765,40 @@ impl GridManager {
.max(1) as u64; .max(1) as u64;
tokio::select! { () = notified => Ok(()), () = token.cancelled() => Err(Error::Cancelled), () = tokio::time::sleep(Duration::from_millis(millis)) => Ok(()) } 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> { pub fn regions_read_only(&self) -> HashMap<String, GridRegion> {
self.inner.prune(); self.inner.prune();
read(&self.inner.regions) read(&self.inner.regions)
@@ -814,10 +862,35 @@ impl GridManagerInner {
.get(&handle) .get(&handle)
.map(|value| value.region.clone()) .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 { let cached = CachedRegion {
region: region.clone(), region: region.clone(),
seen: self.client.time_provider.get_utc_now(), seen: self.client.time_provider.get_utc_now(),
explicit_size,
}; };
write(&self.regions).insert(region.name.to_ascii_lowercase(), cached.clone()); write(&self.regions).insert(region.name.to_ascii_lowercase(), cached.clone());
write(&self.handles).insert(region.region_handle, cached); write(&self.handles).insert(region.region_handle, cached);
@@ -834,20 +907,27 @@ impl GridManagerInner {
if let Ok(packet) = if let Ok(packet) =
crate::packets::MapBlockReplyPacket::new_from_bytes(&event.data, &mut pos) 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_x = u32::from(block.x) * 256;
let global_y = u32::from(block.y) * 256; let global_y = u32::from(block.y) * 256;
self.insert_region(GridRegion { let (size_x, size_y, explicit_size) =
access: SimAccess(block.access), map_block_size(packet.size.get(index));
agents: block.agents, self.insert_region(
map_image_id: block.map_image_id, GridRegion {
name: wire_string(&block.name), access: SimAccess(block.access),
region_flags: RegionFlags(u64::from(block.region_flags)), agents: block.agents,
region_handle: (u64::from(global_x) << 32) | u64::from(global_y), map_image_id: block.map_image_id,
water_height: block.water_height, name: wire_string(&block.name),
x: i32::from(block.x), region_flags: RegionFlags(u64::from(block.region_flags)),
y: i32::from(block.y), 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> { fn grid_item_type(value: u32) -> Option<GridItemType> {
Some(match value { Some(match value {
1 => GridItemType::Telehub, 1 => GridItemType::Telehub,
@@ -1124,22 +1216,100 @@ mod tests {
.build() .build()
.unwrap(); .unwrap();
let manager = GridManager::native_new(Arc::new(client)).unwrap(); let manager = GridManager::native_new(Arc::new(client)).unwrap();
manager.inner.insert_region(GridRegion { manager.inner.insert_region(
access: SimAccess::PG, GridRegion {
agents: 0, access: SimAccess::PG,
map_image_id: UUID::zero(), agents: 0,
name: "Example".into(), map_image_id: UUID::zero(),
region_flags: RegionFlags::NONE, name: "Example".into(),
region_handle: (256_u64 << 32) | 512, region_flags: RegionFlags::NONE,
water_height: 20, region_handle: (256_u64 << 32) | 512,
x: 1, size_x: 256,
y: 2, size_y: 256,
}); water_height: 20,
x: 1,
y: 2,
},
false,
);
assert!(manager.inner.region_by_name("example").is_some()); assert!(manager.inner.region_by_name("example").is_some());
seconds.store(1_000 + CACHE_TTL.as_secs() + 1, Ordering::Relaxed); seconds.store(1_000 + CACHE_TTL.as_secs() + 1, Ordering::Relaxed);
assert!(manager.inner.region_by_name("example").is_none()); assert!(manager.inner.region_by_name("example").is_none());
} }
#[test] #[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() { fn simulator_features_preserve_unknown_fields() {
let features = SimulatorFeatures::default(); let features = SimulatorFeatures::default();
let body = OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([ 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 sim_version: String,
pub size_x: u32, pub size_x: u32,
pub size_y: u32, pub size_y: u32,
resolved_size: RwLock<(u32, u32)>,
pub stats: SimulatorSimStats, pub stats: SimulatorSimStats,
pub terrain: RwLock<Vec<TerrainPatch>>, pub terrain: RwLock<Vec<TerrainPatch>>,
pub terrain_base0: UUID, pub terrain_base0: UUID,
@@ -1379,6 +1380,19 @@ impl Simulator {
*write(&self.data.prey_id) = value; *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( pub fn native_new(
client: GridClient, client: GridClient,
address: std::net::SocketAddr, address: std::net::SocketAddr,
@@ -1429,6 +1443,10 @@ impl Simulator {
sim_version: String::new(), sim_version: String::new(),
size_x: size_x.unwrap_or(Self::DEFAULT_REGION_SIZE_X), size_x: size_x.unwrap_or(Self::DEFAULT_REGION_SIZE_X),
size_y: size_y.unwrap_or(Self::DEFAULT_REGION_SIZE_Y), 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(), stats: SimulatorSimStats::default(),
terrain: RwLock::new(Vec::new()), terrain: RwLock::new(Vec::new()),
terrain_base0: UUID::zero(), terrain_base0: UUID::zero(),
@@ -1636,10 +1654,11 @@ impl Simulator {
return Ok(false); return Ok(false);
}; };
let terrain = read(&self.terrain); let terrain = read(&self.terrain);
let (size_x, _) = self.region_size();
let per_edge = if terrain.len() > 256 { let per_edge = if terrain.len() > 256 {
256 256
} else { } 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 if usize::try_from(x / 16).map_err(|_| Error::Argument)? >= per_edge
|| usize::try_from(y / 16).map_err(|_| Error::Argument)? || usize::try_from(y / 16).map_err(|_| Error::Argument)?
@@ -4405,4 +4424,14 @@ mod tests {
); );
assert!((height - 27.0).abs() < f32::EPSILON); 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" async-trait = "0.1.89"
base64 = "0.22" base64 = "0.22"
crossterm = "0.29" crossterm = "0.29"
directories = { version = "6.0.0", optional = true }
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, 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-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 } 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] [features]
default = [] 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] [lints]
workspace = true workspace = true

View File

@@ -94,6 +94,7 @@ impl OfflineGridBackend {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
pub struct LibremetaverseClientOwner { pub struct LibremetaverseClientOwner {
client: libremetaverse::GridClient, client: libremetaverse::GridClient,
grid: libremetaverse::GridManager,
network: libremetaverse::NetworkManager, network: libremetaverse::NetworkManager,
agent: Arc<libremetaverse::AgentManager>, agent: Arc<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>, delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
@@ -131,6 +132,7 @@ impl fmt::Debug for LibremetaverseClientOwner {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
#[derive(Clone)] #[derive(Clone)]
pub struct LibremetaverseSessionBackend { pub struct LibremetaverseSessionBackend {
grid: libremetaverse::GridManager,
network: libremetaverse::NetworkManager, network: libremetaverse::NetworkManager,
agent: Arc<libremetaverse::AgentManager>, agent: Arc<libremetaverse::AgentManager>,
throttle: libremetaverse::AgentThrottle, throttle: libremetaverse::AgentThrottle,
@@ -165,7 +167,10 @@ impl LibremetaverseClientOwner {
/// ///
/// Returns a backend configuration error if the shared client defaults are invalid. /// Returns a backend configuration error if the shared client defaults are invalid.
pub fn new() -> Result<Self, BackendError> { 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() let client = libremetaverse::GridClientBuilder::default()
.with_settings(settings)
.build() .build()
.map_err(|_| BackendError::Configuration { .map_err(|_| BackendError::Configuration {
component: "libremetaverse client defaults", component: "libremetaverse client defaults",
@@ -173,7 +178,7 @@ impl LibremetaverseClientOwner {
// The compatibility client caches its network manager weakly. Retain the // The compatibility client caches its network manager weakly. Retain the
// composition root before managers register packet subscriptions on it. // composition root before managers register packet subscriptions on it.
let network = client.network(); let network = client.network();
let _grid = client.grid(); let grid = client.grid();
let _terrain = client.terrain(); let _terrain = client.terrain();
let _objects = client.objects(); let _objects = client.objects();
let agent = Arc::new( let agent = Arc::new(
@@ -185,6 +190,7 @@ impl LibremetaverseClientOwner {
); );
Ok(Self { Ok(Self {
client, client,
grid,
network, network,
agent, agent,
delivery_generation: Arc::new(std::sync::RwLock::new(None)), delivery_generation: Arc::new(std::sync::RwLock::new(None)),
@@ -281,6 +287,7 @@ impl LibremetaverseClientOwner {
}); });
} }
Ok(LibremetaverseSessionBackend { Ok(LibremetaverseSessionBackend {
grid: self.grid.clone(),
network: self.network.clone(), network: self.network.clone(),
agent: Arc::clone(&self.agent), agent: Arc::clone(&self.agent),
throttle: libremetaverse::AgentThrottle::new_with_grid_client(self.client.clone()) 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")] #[cfg(feature = "live-grid")]
impl LibremetaverseWorldSnapshotSource { impl LibremetaverseWorldSnapshotSource {
#[must_use] #[must_use]
@@ -668,15 +684,15 @@ impl crate::behavior::EmbodimentSink for LibremetaverseEmbodimentSink {
.network() .network()
.native_current_sim() .native_current_sim()
.ok_or(crate::behavior::BehaviorError::NotReady)?; .ok_or(crate::behavior::BehaviorError::NotReady)?;
let (region_size_x, region_size_y) = simulator.region_size();
let current_position = self.agent.sim_position(); let current_position = self.agent.sim_position();
let reported_bounds_contain_agent = f64::from(current_position.x) let reported_bounds_contain_agent = f64::from(current_position.x)
<= f64::from(simulator.size_x) <= f64::from(region_size_x)
&& f64::from(current_position.y) <= f64::from(simulator.size_y); && f64::from(current_position.y) <= f64::from(region_size_y);
if point.x < 0.0 if point.x < 0.0
|| point.y < 0.0 || point.y < 0.0
|| (reported_bounds_contain_agent || (reported_bounds_contain_agent
&& (point.x > f64::from(simulator.size_x) && (point.x > f64::from(region_size_x) || point.y > f64::from(region_size_y)))
|| point.y > f64::from(simulator.size_y)))
{ {
return Err(crate::behavior::BehaviorError::RegionBoundary); return Err(crate::behavior::BehaviorError::RegionBoundary);
} }
@@ -1108,12 +1124,25 @@ impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
) )
})?; })?;
self.throttle self.throttle
.set_with_simulator(Some(simulator)) .set_with_simulator(Some(simulator.clone()))
.map_err(|_| { .map_err(|_| {
crate::session::SessionFailure::new( crate::session::SessionFailure::new(
crate::session::SessionFailureKind::TransientTransport, 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(|_| { native_reset_camera(&self.agent).map_err(|_| {
crate::session::SessionFailure::new( crate::session::SessionFailure::new(
crate::session::SessionFailureKind::TransientTransport, crate::session::SessionFailureKind::TransientTransport,
@@ -1519,4 +1548,16 @@ mod tests {
crate::session::SessionFailureKind::InvalidCredentials, 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); return Err(BuildError::OutOfRegion);
} }
let base = self.agent.sim_position(); let base = self.agent.sim_position();
let (region_size_x, region_size_y) = simulator.region_size();
let reported_bounds_contain_agent = 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 { for offset in positions {
let point = libremetaverse_types::Vector3 { let point = libremetaverse_types::Vector3 {
x: base.x + offset[0] as f32 / 1000.0, x: base.x + offset[0] as f32 / 1000.0,
@@ -1168,7 +1169,7 @@ impl BuildGrid for LibremetaverseBuildGrid {
if point.x < 0.0 if point.x < 0.0
|| point.y < 0.0 || point.y < 0.0
|| (reported_bounds_contain_agent || (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); return Err(BuildError::OutOfRegion);
} }

View File

@@ -95,36 +95,36 @@ pub struct VisionLimits {
impl Default for VisionLimits { impl Default for VisionLimits {
fn default() -> Self { fn default() -> Self {
Self { Self {
width: 640, width: 1920,
height: 360, height: 1080,
max_distance_meters: 64, max_distance_meters: 64,
max_entities: 512, max_entities: 2_048,
max_triangles: 131_072, max_triangles: 1_048_576,
max_texture_fetches: 1_024, max_texture_fetches: 1_024,
max_texture_bytes: 512 * 1024 * 1024, max_texture_bytes: 512 * 1024 * 1024,
max_decode_pixels: 134_217_728, max_decode_pixels: 268_435_456,
max_jpeg_bytes: 512 * 1024, max_jpeg_bytes: 2 * 1024 * 1024,
max_concurrent_captures: 1, max_concurrent_captures: 1,
capture_timeout: Duration::from_mins(2), capture_timeout: Duration::from_mins(8),
minimum_interval: Duration::from_secs(5), minimum_interval: Duration::from_secs(5),
} }
} }
} }
impl VisionLimits { impl VisionLimits {
pub(crate) fn valid(self) -> bool { pub(crate) fn valid(self) -> bool {
(64..=640).contains(&self.width) (64..=1_920).contains(&self.width)
&& (64..=480).contains(&self.height) && (64..=1_080).contains(&self.height)
&& self.width as usize * self.height as usize <= 307_200 && 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_distance_meters)
&& (1..=1_024).contains(&self.max_entities) && (1..=4_096).contains(&self.max_entities)
&& (1..=262_144).contains(&self.max_triangles) && (1..=1_048_576).contains(&self.max_triangles)
&& self.max_texture_fetches <= 1_024 && self.max_texture_fetches <= 1_024
&& self.max_texture_bytes <= 512 * 1024 * 1024 && self.max_texture_bytes <= 512 * 1024 * 1024
&& self.max_decode_pixels <= 134_217_728 && self.max_decode_pixels <= 536_870_912
&& (4_096..=1024 * 1024).contains(&self.max_jpeg_bytes) && (4_096..=4 * 1024 * 1024).contains(&self.max_jpeg_bytes)
&& (1..=4).contains(&self.max_concurrent_captures) && (1..=4).contains(&self.max_concurrent_captures)
&& !self.capture_timeout.is_zero() && !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) && 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 phase = (scene.observed_unix_millis / 1000 % 86_400) as f32 / 86_400.0;
let light = (phase * std::f32::consts::TAU) let light = (phase * std::f32::consts::TAU)
.sin() .sin()
.mul_add(24.0, 72.0) .mul_add(20.0, 125.0)
.clamp(32.0, 112.0) as u8; .clamp(96.0, 160.0) as u8;
[ [
light, light,
light.saturating_add(24), light.saturating_add(24),
@@ -888,7 +888,7 @@ fn encode_jpeg(
.flat_map(|pixel| pixel[..3].iter().copied()) .flat_map(|pixel| pixel[..3].iter().copied())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut output = Vec::new(); let mut output = Vec::new();
jpeg_encoder::Encoder::new(&mut output, 82) jpeg_encoder::Encoder::new(&mut output, 92)
.encode( .encode(
&rgb, &rgb,
width as u16, width as u16,
@@ -1000,15 +1000,27 @@ async fn request_asset_bytes(
asset_type: libremetaverse_types::AssetType, asset_type: libremetaverse_types::AssetType,
cancellation: CancellationToken, cancellation: CancellationToken,
) -> Option<Vec<u8>> { ) -> Option<Vec<u8>> {
tokio::time::timeout( tokio::time::timeout(Duration::from_secs(8), async {
Duration::from_secs(15), match asset_type {
assets.request_asset_with_uuid_asset_type_boolean_cancellation_token( libremetaverse_types::AssetType::Texture => {
id, let _ = assets
asset_type, .request_image(id, None, Some(cancellation.clone()))
false, .await;
Some(cancellation), }
), 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 .await
.ok() .ok()
.and_then(Result::ok) .and_then(Result::ok)
@@ -1128,8 +1140,26 @@ impl SceneSource for LibremetaverseSceneSource {
(world, attachments) (world, attachments)
}; };
world.sort_by(|left, right| { world.sort_by(|left, right| {
scene_distance_squared(left.position, camera_position) let left_visible = scene_center_visible(
.total_cmp(&scene_distance_squared(right.position, camera_position)) 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| { attachments.sort_by(|left, right| {
scene_distance_squared(left.position, camera_position) scene_distance_squared(left.position, camera_position)
@@ -1149,21 +1179,47 @@ impl SceneSource for LibremetaverseSceneSource {
avatars.truncate(avatar_limit); avatars.truncate(avatar_limit);
let region_id = simulator.region_id; let region_id = simulator.region_id;
let region_name = simulator.name.clone(); 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 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 let terrain = simulator
.terrain .terrain
.read() .read()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.clone(); .clone();
let terrain_available = terrain.iter().any(|patch| patch.data.len() == 256); let terrain_available = terrain.iter().any(|patch| patch.data.len() == 256);
let terrain_texture_ids = [ let terrain_materials = TerrainMaterials {
simulator.terrain_detail0, texture_ids: [
simulator.terrain_detail1, simulator.terrain_detail0,
simulator.terrain_detail2, simulator.terrain_detail1,
simulator.terrain_detail3, 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() .into_iter()
.filter(|id| *id != UUID::zero()) .filter(|id| *id != UUID::zero())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1200,12 +1256,21 @@ impl SceneSource for LibremetaverseSceneSource {
} }
let mesh_ids = prims let mesh_ids = prims
.iter() .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_map(|prim| prim.sculpt.as_ref())
.filter(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh) .filter(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh)
.map(|sculpt| sculpt.sculpt_texture) .map(|sculpt| sculpt.sculpt_texture)
.filter(|id| *id != UUID::zero()) .filter(|id| *id != UUID::zero())
.fold(Vec::new(), |mut ids, id| { .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.push(id);
} }
ids ids
@@ -1216,19 +1281,14 @@ impl SceneSource for LibremetaverseSceneSource {
let legacy_materials = if legacy_material_ids.is_empty() { let legacy_materials = if legacy_material_ids.is_empty() {
Vec::new() Vec::new()
} else { } else {
tokio::time::timeout( self.client
Duration::from_secs(5), .objects()
self.client .request_materials_with_simulator_cancellation_token(
.objects() simulator.clone(),
.request_materials_with_simulator_cancellation_token( Some(cancellation.clone()),
simulator.clone(), )
Some(cancellation.clone()), .await
), .map_or_else(|_| Vec::new(), Iterator::collect)
)
.await
.ok()
.and_then(Result::ok)
.map_or_else(Vec::new, Iterator::collect)
}; };
for material in &legacy_materials { for material in &legacy_materials {
for id in [material.normal_map(), material.specular_map()] { for id in [material.normal_map(), material.specular_map()] {
@@ -1300,23 +1360,19 @@ impl SceneSource for LibremetaverseSceneSource {
texture_ids.retain(|id| *id != UUID::zero()); texture_ids.retain(|id| *id != UUID::zero());
let scene_texture_ids = texture_ids.clone(); let scene_texture_ids = texture_ids.clone();
{ {
let mut cache = lock(&self.cache); let 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;
}
texture_ids.retain(|id| !cache.textures.contains_key(id)); texture_ids.retain(|id| !cache.textures.contains_key(id));
} }
texture_ids.truncate( texture_ids.truncate(
self.limits.max_texture_fetches.saturating_sub( self.limits
mesh_ids .max_texture_fetches
.len() .saturating_sub(
.saturating_add(pbr_material_ids.len()) mesh_ids
.saturating_add(legacy_material_fetches), .len()
), .saturating_add(pbr_material_ids.len())
.saturating_add(legacy_material_fetches),
)
.min(256),
); );
let texture_fetches = texture_ids let texture_fetches = texture_ids
.len() .len()
@@ -1395,7 +1451,7 @@ impl SceneSource for LibremetaverseSceneSource {
let decode_bytes = self.limits.max_texture_bytes; let decode_bytes = self.limits.max_texture_bytes;
let max_cached_geometry = self.limits.max_entities; let max_cached_geometry = self.limits.max_entities;
let cache = Arc::clone(&self.cache); 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 || { tokio::task::spawn_blocking(move || {
native_entities( native_entities(
prims, prims,
@@ -1403,7 +1459,7 @@ impl SceneSource for LibremetaverseSceneSource {
water, water,
region_size, region_size,
&terrain, &terrain,
terrain_texture_ids, terrain_materials,
maximum, maximum,
encoded_textures, encoded_textures,
encoded_meshes, encoded_meshes,
@@ -1414,6 +1470,7 @@ impl SceneSource for LibremetaverseSceneSource {
&scene_texture_ids, &scene_texture_ids,
max_cached_geometry, max_cached_geometry,
camera_position, camera_position,
maximum_distance,
) )
}) })
.await .await
@@ -1447,7 +1504,7 @@ impl SceneSource for LibremetaverseSceneSource {
entities, entities,
textures, textures,
completeness: SnapshotCompleteness { completeness: SnapshotCompleteness {
objects_truncated, objects_truncated: objects_truncated || geometry_truncated,
avatars_truncated, avatars_truncated,
textures_missing, textures_missing,
terrain_available, terrain_available,
@@ -1580,6 +1637,14 @@ fn world_primitive(
None 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")] #[cfg(feature = "live-grid")]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // Single blocking scene-conversion boundary. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] // Single blocking scene-conversion boundary.
fn native_entities( fn native_entities(
@@ -1588,7 +1653,7 @@ fn native_entities(
water: f32, water: f32,
region_size: (f32, f32), region_size: (f32, f32),
terrain: &[libremetaverse::TerrainPatch], terrain: &[libremetaverse::TerrainPatch],
terrain_texture_ids: [UUID; 4], terrain_materials: TerrainMaterials,
maximum: usize, maximum: usize,
encoded_textures: Vec<(UUID, Vec<u8>)>, encoded_textures: Vec<(UUID, Vec<u8>)>,
encoded_meshes: Vec<(UUID, Vec<u8>)>, encoded_meshes: Vec<(UUID, Vec<u8>)>,
@@ -1599,7 +1664,8 @@ fn native_entities(
scene_texture_ids: &[UUID], scene_texture_ids: &[UUID],
max_cached_geometry: usize, max_cached_geometry: usize,
camera_position: [f32; 3], 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( let (textures, pixels_consumed) = cached_textures(
cache, cache,
scene_texture_ids, scene_texture_ids,
@@ -1627,10 +1693,16 @@ fn native_entities(
let mut entities = Vec::new(); let mut entities = Vec::new();
let mut triangles = 0usize; let mut triangles = 0usize;
let mut gpu_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 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 let object_triangle_limit = maximum
.saturating_div(2) .saturating_sub(terrain_triangle_reserve)
.saturating_sub(avatar_triangle_reserve); .saturating_sub(avatar_triangle_reserve)
.saturating_sub(2);
for prim in prims { for prim in prims {
if triangles >= object_triangle_limit || gpu_triangles >= object_triangle_limit { if triangles >= object_triangle_limit || gpu_triangles >= object_triangle_limit {
break; break;
@@ -1640,16 +1712,7 @@ fn native_entities(
|properties| properties.name.clone(), |properties| properties.name.clone(),
); );
let detail = scene_detail_level(&prim, camera_position); let detail = scene_detail_level(&prim, camera_position);
let signature = GeometrySignature { let signature = geometry_signature(&prim, camera_position);
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 mesh = cached_geometry(cache, prim.id, &signature).or_else(|| { let mesh = cached_geometry(cache, prim.id, &signature).or_else(|| {
let generated = if let Some(sculpt) = &prim.sculpt { let generated = if let Some(sculpt) = &prim.sculpt {
if sculpt.type_() == libremetaverse_types::SculptType::Mesh { if sculpt.type_() == libremetaverse_types::SculptType::Mesh {
@@ -1687,6 +1750,24 @@ fn native_entities(
)) ))
}); });
let Some(mesh) = mesh else { continue }; 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 output = Vec::new();
let mut renderables = Vec::new(); let mut renderables = Vec::new();
let mut texture_complete = true; let mut texture_complete = true;
@@ -1701,7 +1782,6 @@ fn native_entities(
let texture = textures.get(&face.texture_face.texture_id()); let texture = textures.get(&face.texture_face.texture_id());
if face.texture_face.texture_id() != UUID::zero() && texture.is_none() { if face.texture_face.texture_id() != UUID::zero() && texture.is_none() {
texture_complete = false; texture_complete = false;
continue;
} }
let gpu_vertices = face let gpu_vertices = face
.vertices .vertices
@@ -1716,7 +1796,6 @@ fn native_entities(
.iter() .iter()
.all(|index| usize::from(*index) < gpu_vertices.len()) .all(|index| usize::from(*index) < gpu_vertices.len())
}) })
.take(object_triangle_limit.saturating_sub(gpu_triangles))
.flatten() .flatten()
.map(|index| u32::from(*index)) .map(|index| u32::from(*index))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1767,9 +1846,6 @@ fn native_entities(
} }
let shade = triangle_shade(vertices); let shade = triangle_shade(vertices);
colors = colors.map(|color| shade_color(color, shade)); colors = colors.map(|color| shade_color(color, shade));
if triangles >= object_triangle_limit {
break;
}
triangles += 1; triangles += 1;
output.push(SceneTriangle { output.push(SceneTriangle {
vertices, vertices,
@@ -1834,7 +1910,9 @@ fn native_entities(
region_size, region_size,
water, water,
&textures, &textures,
terrain_texture_ids, terrain_materials,
camera_position,
maximum_distance,
maximum.saturating_sub(triangles).saturating_sub(2), maximum.saturating_sub(triangles).saturating_sub(2),
); );
if !terrain_triangles.is_empty() { if !terrain_triangles.is_empty() {
@@ -1847,7 +1925,8 @@ fn native_entities(
display_name: "simulator terrain".into(), display_name: "simulator terrain".into(),
triangles: terrain_triangles, triangles: terrain_triangles,
renderables, renderables,
texture_available: terrain_texture_ids texture_available: terrain_materials
.texture_ids
.iter() .iter()
.any(|id| textures.contains_key(id)), .any(|id| textures.contains_key(id)),
}); });
@@ -1884,7 +1963,7 @@ fn native_entities(
.values() .values()
.map(|texture| texture.scene_texture()) .map(|texture| texture.scene_texture())
.collect(); .collect();
Ok((entities, gpu_textures, pixels_consumed)) Ok((entities, gpu_textures, pixels_consumed, geometry_truncated))
} }
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
@@ -1938,16 +2017,30 @@ fn store_geometry(
} }
#[cfg(feature = "live-grid")] #[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, prim: &libremetaverse::Primitive,
camera: [f32; 3], camera: [f32; 3],
) -> libremetaverse::rendering::DetailLevel { ) -> libremetaverse::rendering::DetailLevel {
let distance = scene_distance_squared(prim.position, camera).sqrt(); let distance = scene_distance_squared(prim.position, camera).sqrt();
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z); let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
if distance < 12.0 && extent < 16.0 { if distance < 32.0 || extent >= 16.0 {
libremetaverse::rendering::DetailLevel::High libremetaverse::rendering::DetailLevel::Highest
} else { } else {
libremetaverse::rendering::DetailLevel::Medium libremetaverse::rendering::DetailLevel::High
} }
} }
@@ -1989,7 +2082,7 @@ fn native_gpu_vertex(
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
#[allow(clippy::too_many_lines)] // Direct mapping of the two supported material models. #[allow(clippy::too_many_lines)] // Direct mapping of the two supported material models.
fn face_material( pub(crate) fn face_material(
face: &libremetaverse::PrimitiveTextureEntryFace, face: &libremetaverse::PrimitiveTextureEntryFace,
textures: &std::collections::BTreeMap<UUID, usize>, textures: &std::collections::BTreeMap<UUID, usize>,
legacy_materials: &std::collections::BTreeMap<UUID, libremetaverse::materials::LegacyMaterial>, legacy_materials: &std::collections::BTreeMap<UUID, libremetaverse::materials::LegacyMaterial>,
@@ -2037,6 +2130,7 @@ fn face_material(
], ],
double_sided: material.double_sided(), double_sided: material.double_sided(),
alpha_mode: match material.alpha_mode() { 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::Opaque => AlphaMode::Opaque,
libremetaverse::assets::GltfAlphaMode::Blend => AlphaMode::Blend, libremetaverse::assets::GltfAlphaMode::Blend => AlphaMode::Blend,
libremetaverse::assets::GltfAlphaMode::Mask => AlphaMode::Mask { libremetaverse::assets::GltfAlphaMode::Mask => AlphaMode::Mask {
@@ -2046,6 +2140,15 @@ fn face_material(
}); });
} }
let rgba = face.rgba(); 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 legacy = legacy_materials.get(&face.material_id());
let shininess = match face.shiny() { let shininess = match face.shiny() {
libremetaverse::Shininess::None => 1.0, libremetaverse::Shininess::None => 1.0,
@@ -2096,10 +2199,16 @@ fn face_material(
rotation_radians: material.specular_map_rotation() as f32, rotation_radians: material.specular_map_rotation() as f32,
}, },
}), }),
specular_color_srgb: legacy.map_or([255; 3], |material| { specular_color_srgb: legacy.map_or_else(
let color = material.specular_color(); || match face.shiny() {
[unit_byte(color.r), unit_byte(color.g), unit_byte(color.b)] 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| { shininess: legacy.map_or(shininess, |material| {
f32::from(material.specular_exponent()).max(1.0) f32::from(material.specular_exponent()).max(1.0)
}), }),
@@ -2111,28 +2220,33 @@ fn face_material(
material.diffuse_alpha_mode() material.diffuse_alpha_mode()
== libremetaverse::materials::LegacyMaterialAlphaMode::Emissive == libremetaverse::materials::LegacyMaterialAlphaMode::Emissive
}), }),
alpha_mode: legacy.map_or_else( alpha_mode: legacy.map_or_else(automatic_alpha, |material| {
|| { if rgba.a < 0.999 {
if rgba.a < 0.999 || texture_has_alpha { AlphaMode::Blend
AlphaMode::Blend } else {
} else { match material.diffuse_alpha_mode() {
AlphaMode::Opaque libremetaverse::materials::LegacyMaterialAlphaMode::Blend
} | libremetaverse::materials::LegacyMaterialAlphaMode::Emissive => {
},
|material| match material.diffuse_alpha_mode() {
libremetaverse::materials::LegacyMaterialAlphaMode::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 AlphaMode::Blend
} else {
AlphaMode::Opaque
} }
libremetaverse::materials::LegacyMaterialAlphaMode::Mask => AlphaMode::Mask {
cutoff: f32::from(material.alpha_mask_cutoff()) / 255.0,
},
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 {
texture_has_alpha.then_some(0.5)
} }
libremetaverse::materials::LegacyMaterialAlphaMode::None => AlphaMode::Opaque,
}, },
), ),
..Default::default() ..Default::default()
@@ -2185,7 +2299,7 @@ pub(crate) fn scene_renderable_from_triangles(
} }
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
struct DecodedTexture { pub(crate) struct DecodedTexture {
width: usize, width: usize,
height: usize, height: usize,
red: Vec<u8>, red: Vec<u8>,
@@ -2268,7 +2382,7 @@ fn cached_textures(
max_bytes: usize, max_bytes: usize,
max_cached_pixels: usize, max_cached_pixels: usize,
) -> (std::collections::BTreeMap<UUID, Arc<DecodedTexture>>, 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( let (decoded, _) = decode_textures(
encoded, encoded,
max_bytes, max_bytes,
@@ -2389,11 +2503,17 @@ fn decode_textures(
break; break;
} }
let options = libremetaverse_imaging::J2kDecodeOptions::default() let options = libremetaverse_imaging::J2kDecodeOptions::default()
.with_discard_levels(1) .with_limits(max_bytes.min(bytes.len().max(1)), 33_554_432);
.with_limits(max_bytes.min(bytes.len().max(1)), remaining_pixels); let Ok(mut image) = libremetaverse_imaging::RustJ2kCodec::decode_bytes(&bytes, options)
let Ok(image) = libremetaverse_imaging::RustJ2kCodec::decode_bytes(&bytes, options) else { else {
continue; 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 { let Ok(width) = usize::try_from(image.width) else {
continue; continue;
}; };
@@ -2422,6 +2542,44 @@ fn decode_textures(
(output, max_pixels - remaining_pixels) (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")] #[cfg(feature = "live-grid")]
fn multiply_color(tint: [u8; 4], texture: [u8; 4]) -> [u8; 4] { fn multiply_color(tint: [u8; 4], texture: [u8; 4]) -> [u8; 4] {
std::array::from_fn(|channel| { std::array::from_fn(|channel| {
@@ -2441,50 +2599,67 @@ pub(crate) fn terrain_height(
} }
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
fn terrain_triangles( #[allow(clippy::too_many_arguments)]
pub(crate) fn terrain_triangles(
terrain: &[libremetaverse::TerrainPatch], terrain: &[libremetaverse::TerrainPatch],
region_size: (f32, f32), region_size: (f32, f32),
water: f32, water: f32,
textures: &std::collections::BTreeMap<UUID, Arc<DecodedTexture>>, textures: &std::collections::BTreeMap<UUID, Arc<DecodedTexture>>,
texture_ids: [UUID; 4], materials: TerrainMaterials,
camera_position: [f32; 3],
maximum_distance: f32,
maximum: usize, maximum: usize,
) -> Vec<SceneTriangle> { ) -> Vec<SceneTriangle> {
let (width, height, patch_width) = terrain_layout(terrain, region_size); let (width, height, patch_width) = terrain_layout(terrain, region_size);
if maximum < 2 || width < 2 || height < 2 { if maximum < 2 || width < 2 || height < 2 {
return Vec::new(); return Vec::new();
} }
let heights = terrain let radius = maximum_distance.ceil();
.iter() let minimum_x = (camera_position[0] - radius).floor().max(0.0) as usize;
.flat_map(|patch| patch.data.iter().copied()) let minimum_y = (camera_position[1] - radius).floor().max(0.0) as usize;
.filter(|value| value.is_finite()) let maximum_x = (camera_position[0] + radius).ceil().max(0.0) as usize;
.collect::<Vec<_>>(); let maximum_y = (camera_position[1] + radius).ceil().max(0.0) as usize;
let Some((&minimum, &maximum_height)) = heights let minimum_x = minimum_x.min(width - 1);
.iter() let minimum_y = minimum_y.min(height - 1);
.min_by(|a, b| a.total_cmp(b)) let maximum_x = maximum_x.min(width - 1);
.zip(heights.iter().max_by(|a, b| a.total_cmp(b))) let maximum_y = maximum_y.min(height - 1);
else { let mut step = 1usize;
return Vec::new(); while (maximum_x - minimum_x).div_ceil(step) * (maximum_y - minimum_y).div_ceil(step) * 2
}; > maximum
let mut step = 4usize; {
while width.div_ceil(step) * height.div_ceil(step) * 2 > maximum {
step *= 2; step *= 2;
} }
let color = |x: usize, y: usize, z: f32| { let color = |x: usize, y: usize, z: f32| {
let range = (maximum_height - minimum).max(1.0); let east = x as f32 / region_size.0.max(1.0);
let level = ((z - minimum) / range).clamp(0.0, 0.999); let north = y as f32 / region_size.1.max(1.0);
let texture = textures.get(&texture_ids[(level * 4.0) as usize]); let start = bilinear_corners(materials.start_heights, east, north);
if let Some(texture) = texture { let range = bilinear_corners(materials.height_ranges, east, north).max(1.0);
texture.sample(x as f32 / 128.0, y as f32 / 128.0) let layer = ((z - start) / range * 3.0).clamp(0.0, 3.0);
} else if z <= water { let lower = layer.floor() as usize;
[45, 90, 125, 255] let upper = layer.ceil() as usize;
} else { let blend = layer.fract() * layer.fract() * (3.0 - 2.0 * layer.fract());
let light = unit_byte(0.65 + level * 0.3); let sample = |index: usize| {
[light / 2, light, light / 3, 255] textures.get(&materials.texture_ids[index]).map_or_else(
} || {
if z <= water {
[45, 90, 125, 255]
} else {
[
[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(); let mut output = Vec::new();
for y in (0..height.saturating_sub(1)).step_by(step) { for y in (minimum_y..maximum_y).step_by(step) {
for x in (0..width.saturating_sub(1)).step_by(step) { for x in (minimum_x..maximum_x).step_by(step) {
if output.len().saturating_add(2) > maximum { if output.len().saturating_add(2) > maximum {
return output; return output;
} }
@@ -2523,6 +2698,23 @@ fn terrain_triangles(
output 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")] #[cfg(feature = "live-grid")]
pub(crate) fn triangle_shade(vertices: [[f32; 3]; 3]) -> f32 { pub(crate) fn triangle_shade(vertices: [[f32; 3]; 3]) -> f32 {
let edge_a = std::array::from_fn(|axis| vertices[1][axis] - vertices[0][axis]); 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)); 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")] #[cfg(feature = "live-grid")]
#[test] #[test]
fn renderer_uses_the_camera_axis_that_points_at_the_requested_target() { 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); 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")] #[cfg(feature = "live-grid")]
#[test] #[test]
fn scene_visibility_uses_configured_distance_and_avatar_view() { 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, second.image_sha256);
assert_eq!( assert_eq!(
first.image_sha256, first.image_sha256,
"b53d23f57ba0d6fb79856afde111ad6dc25559e810dcc028a5f107090914821d" "63a9e3502b7ca43ac8b3560f516fcf1fbed96f74f86e9818d7d88db2cc39555b"
); );
assert!(first.summary.contains("privacy-marked residents")); assert!(first.summary.contains("privacy-marked residents"));
assert!(!first.summary.contains("Private Resident")); assert!(!first.summary.contains("Private Resident"));

View File

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

View File

@@ -45,7 +45,7 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
let response = agent let response = agent
.send(vec![ .send(vec![
ContentBlock::text( 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), ContentBlock::image_url(image),
]) ])
@@ -61,10 +61,17 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
if descriptions.is_empty() { if descriptions.is_empty() {
return Err("Luna returned no snapshot description".into()); return Err("Luna returned no snapshot description".into());
} }
let passed = descriptions
.iter()
.any(|description| description.contains("QUALITY: PASS"));
for description in descriptions { for description in descriptions {
println!("LIVE_LLM_VISION={description}"); println!("LIVE_LLM_VISION={description}");
} }
Ok(()) if passed {
Ok(())
} else {
Err("Luna rejected the rendered scene quality".into())
}
} }
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> { fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {

View File

@@ -1,9 +1,13 @@
#![cfg(feature = "live-grid")] #![cfg(feature = "live-grid")]
use libremetaverse_types::{Vector3, compat::CancellationTokenSource}; use libremetaverse_types::{
Vector3,
compat::{CancellationToken, CancellationTokenSource},
};
use metacrate_grid_agent::{ use metacrate_grid_agent::{
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner, EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
LibremetaverseSceneSource, SecretString, SessionSignal, VisionLimits, VisionService, LibremetaverseSceneSource, SceneEntityKind, SceneSnapshot, SceneSource, SecretString,
SessionSignal, VisionFuture, VisionLimits, VisionService,
}; };
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
@@ -14,196 +18,144 @@ use std::{
}; };
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs two dedicated accounts into the configured live grid and writes a JPEG"] #[ignore = "logs the primary agent into the configured live varregion"]
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order. async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box<dyn Error>> {
async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?; let environment = live_environment()?;
let target_owner = LibremetaverseClientOwner::new()?; let owner = LibremetaverseClientOwner::new()?;
let renderer_owner = LibremetaverseClientOwner::new()?; let (mut session, cancellation) =
let (mut target_session, target_cancel) = login( login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
&target_owner, let result = async {
&environment, wait_ready("primary", &mut session, &cancellation).await?;
"GRID_TEST_UNPRIVILEGED_USER", let simulator = owner
"GRID_TEST_UNPRIVILEGED_PASSWORD", .client()
) .network()
.await?; .current_sim()
let (mut renderer_session, renderer_cancel) = match login( .ok_or("primary simulator unavailable")?;
&renderer_owner, let size = simulator.region_size();
&environment, println!(
"GRID_TEST_AUTHORIZED_USER", "LIVE_PRIMARY_REGION=region:{:?},handle:{},size:{}x{},position:{:?}",
"GRID_TEST_AUTHORIZED_PASSWORD", simulator.name,
) simulator.handle,
.await size.0,
{ size.1,
Ok(login) => login, owner.agent().sim_position()
Err(error) => { );
let _ = target_session.logout(target_cancel.token()).await; if size != (1024, 1024) {
return Err(error); 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 { let result = async {
wait_ready("target", &mut target_session, &target_cancel).await?;
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?; wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
let renderer_start_simulator = renderer_owner let renderer_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
.client() .client()
.network() .network()
.current_sim() .current_sim()
.ok_or("renderer simulator unavailable")?; .ok_or("renderer simulator unavailable")?;
let mut settled_position = renderer_owner.agent().sim_position(); let settled_position = renderer_owner.agent().sim_position();
if let Some(ground) = terrain_ground( let region_size = renderer_simulator.region_size();
&renderer_simulator, println!(
renderer_start_position.x, "LIVE_RENDER_LOCATION=region:{:?},size:{}x{},position:{settled_position:?}",
renderer_start_position.y, renderer_simulator.name, region_size.0, region_size.1
) { );
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?; wait_scene_settled(&renderer_owner).await?;
let mut view_forward = Vector3::unit_x(); let view_forward = Vector3::mul_with_vector3_quaternion(
if let Some((destination, forward)) = scenic_location(&renderer_simulator) { Vector3::unit_x(),
if !renderer_owner renderer_owner.agent().sim_rotation(),
.agent() );
.teleport_with_u_int64_vector3_vector3_cancellation_token( let (camera_position, camera_target) =
renderer_start_handle, avatar_forward_camera(settled_position, view_forward)?;
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))
});
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(),
);
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(),
);
}
let (camera_position, camera_target) = avatar_camera(settled_position, view_forward)?;
renderer_owner renderer_owner
.agent() .agent()
.movement .movement
.camera .camera
.look_at_with_vector3_vector3(camera_position, camera_target)?; .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 { let limits = VisionLimits {
minimum_interval: Duration::ZERO, minimum_interval: Duration::ZERO,
..VisionLimits::default() ..VisionLimits::default()
}; };
println!(
"LIVE_RENDER_SCOPE=max_distance_meters:{}",
limits.max_distance_meters
);
let vision = VisionService::new( 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, limits,
)? )?
.prefer_gpu(); .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") let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
.ok() .ok()
.and_then(|value| value.parse::<u32>().ok()) .and_then(|value| value.parse::<u32>().ok())
.unwrap_or(8) .unwrap_or(12)
.clamp(1, 16); .clamp(1, 24);
let mut previous_missing = u32::MAX;
let mut stable_missing = 0_u32;
for frame in 1..=frames { for frame in 1..=frames {
let next = vision let next = vision
.capture( .capture(
@@ -228,7 +182,16 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
.await?; .await?;
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary); println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
std::fs::write(&output, &next.jpeg)?; 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); capture = Some(next);
if previous_missing == 0 || (frame >= 8 && stable_missing >= 3) {
break;
}
} }
let capture = capture.ok_or("live renderer produced no frame")?; let capture = capture.ok_or("live renderer produced no frame")?;
println!("LIVE_RENDER_OUTPUT={}", output.display()); println!("LIVE_RENDER_OUTPUT={}", output.display());
@@ -238,10 +201,63 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
.await; .await;
let _ = renderer_session.logout(renderer_cancel.token()).await; let _ = renderer_session.logout(renderer_cancel.token()).await;
let _ = target_session.logout(target_cancel.token()).await;
result 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( async fn login(
owner: &LibremetaverseClientOwner, owner: &LibremetaverseClientOwner,
environment: &BTreeMap<String, String>, environment: &BTreeMap<String, String>,
@@ -336,108 +352,56 @@ fn required(
.ok_or_else(|| format!("missing live test setting {key}").into()) .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( #[allow(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::cast_possible_wrap, clippy::cast_possible_wrap,
clippy::cast_sign_loss clippy::cast_sign_loss
)] // Coordinates are already clamped inside the non-negative simulator extent. )] // Coordinates are already clamped inside the non-negative simulator extent.
fn terrain_ground(simulator: &libremetaverse::Simulator, x: f32, y: f32) -> Option<f32> { fn avatar_forward_camera(
let patches = simulator avatar: Vector3,
.terrain forward: Vector3,
.read() ) -> Result<(Vector3, Vector3), Box<dyn Error>> {
.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>> {
let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt(); let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt();
if !length.is_finite() || length < f32::EPSILON { if !length.is_finite() || length < f32::EPSILON {
return Err("renderer start direction unavailable".into()); return Err("avatar facing direction unavailable".into());
} }
let position = Vector3 { let position = Vector3 {
x: avatar.x, x: avatar.x,
y: avatar.y, y: avatar.y,
z: avatar.z + 2.0, z: avatar.z + 1.6,
}; };
Ok(( Ok((
position, position,
Vector3 { Vector3 {
x: position.x + forward.x / length * 30.0, x: position.x + forward.x / length * 24.0,
y: position.y + forward.y / length * 30.0, y: position.y + forward.y / length * 24.0,
z: position.z + forward.z / length * 30.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, time::Duration,
}; };
const WORLD_AMBIENT_BRIGHTNESS: f32 = 400.0;
const WORLD_ENVIRONMENT_BRIGHTNESS: f32 = 600.0;
enum RenderCommand { enum RenderCommand {
Frame { Frame {
camera: Camera, camera: Camera,
@@ -291,6 +294,7 @@ struct BevyBackend {
mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>, mesh_cache: HashMap<u64, Vec<CachedGpuMesh>>,
pbr_handles: Vec<Handle<PbrBevyMaterial>>, pbr_handles: Vec<Handle<PbrBevyMaterial>>,
legacy_handles: Vec<Handle<LegacyBevyMaterial>>, legacy_handles: Vec<Handle<LegacyBevyMaterial>>,
environment_map: EnvironmentMapLight,
target: Option<Handle<Image>>, target: Option<Handle<Image>>,
} }
@@ -314,6 +318,22 @@ impl BevyBackend {
let plugins = plugins.disable::<bevy::winit::WinitPlugin>(); let plugins = plugins.disable::<bevy::winit::WinitPlugin>();
app.add_plugins(plugins) app.add_plugins(plugins)
.add_plugins((LegacyMaterialPlugin, PbrMaterialPlugin)); .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.finish();
app.cleanup(); app.cleanup();
if !app.world().contains_resource::<RenderDevice>() { if !app.world().contains_resource::<RenderDevice>() {
@@ -327,6 +347,7 @@ impl BevyBackend {
mesh_cache: HashMap::new(), mesh_cache: HashMap::new(),
pbr_handles: Vec::new(), pbr_handles: Vec::new(),
legacy_handles: Vec::new(), legacy_handles: Vec::new(),
environment_map,
target: None, target: None,
}) })
} }
@@ -422,6 +443,7 @@ impl BevyBackend {
..Default::default() ..Default::default()
}), }),
transform, transform,
self.environment_map.clone(),
FrameEntity, FrameEntity,
)) ))
.id(); .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]; let mut next = vec![0_u8; next_width * next_height * 4];
for y in 0..next_height { for y in 0..next_height {
for x in 0..next_width { 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; let mut samples = 0_u32;
for source_y in y * 2..(y * 2 + 2).min(height) { for source_y in y * 2..(y * 2 + 2).min(height) {
for source_x in x * 2..(x * 2 + 2).min(width) { for source_x in x * 2..(x * 2 + 2).min(width) {
let source = (source_y * width + source_x) * 4; let source = (source_y * width + source_x) * 4;
for channel in 0..4 { let sample_alpha = u32::from(level[source + 3]);
channels[channel] += u32::from(level[source + channel]); alpha += sample_alpha;
for channel in 0..3 {
weighted_rgb[channel] +=
u32::from(level[source + channel]) * sample_alpha;
} }
samples += 1; samples += 1;
} }
} }
let target = (y * next_width + x) * 4; let target = (y * next_width + x) * 4;
for channel in 0..4 { for channel in 0..3 {
next[target + channel] = u8::try_from(channels[channel] / samples) next[target + channel] =
.expect("the average of bytes fits in a byte"); 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); output.extend_from_slice(&next);
@@ -770,10 +799,13 @@ fn legacy_as_bevy(
slot.transform.offset[1], slot.transform.offset[1],
) )
}; };
let cutoff = match material.alpha_mode { let cutoff = material.alpha_cutoff.map_or_else(
SceneAlphaMode::Mask { cutoff } => cutoff.clamp(0.0, 1.0), || match material.alpha_mode {
_ => -1.0, SceneAlphaMode::Mask { cutoff } => cutoff.clamp(0.0, 1.0),
}; _ => -1.0,
},
|cutoff| cutoff.clamp(0.0, 1.0),
);
LegacyBevyMaterial { LegacyBevyMaterial {
uniform: LegacyUniform { uniform: LegacyUniform {
diffuse_color: scene_color(material.diffuse_color_srgb).to_linear(), diffuse_color: scene_color(material.diffuse_color_srgb).to_linear(),
@@ -967,6 +999,20 @@ fn validate_scene(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 crate::{Mesh as SceneMesh, TextureSlot, UvTransform, Vertex};
use std::sync::Arc; use std::sync::Arc;
@@ -976,16 +1022,25 @@ mod tests {
width: 2, width: 2,
height: 2, height: 2,
rgba: vec![ 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); let (mips, levels) = rgba_mip_chain(&texture);
assert_eq!(levels, 2); assert_eq!(levels, 2);
assert_eq!(mips.len(), 20); 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] #[test]
#[allow(clippy::too_many_lines)] // One public render sequence checks reuse and orientation.
fn bevy_headless_renders_repeated_frames_through_the_public_api() { fn bevy_headless_renders_repeated_frames_through_the_public_api() {
let renderer = match Renderer::new_blocking() { let renderer = match Renderer::new_blocking() {
Ok(renderer) => Arc::new(renderer), Ok(renderer) => Arc::new(renderer),
@@ -1030,6 +1085,67 @@ mod tests {
solid_texture([255, 180, 0, 255]), solid_texture([255, 180, 0, 255]),
solid_texture([30, 20, 10, 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 { for renderable in frames {
let rgba = renderer let rgba = renderer
.render( .render(

View File

@@ -20,28 +20,36 @@ struct LegacyUniform {
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var specular_sampler: sampler; @group(#{MATERIAL_BIND_GROUP}) @binding(6) var specular_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> { 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 sine = sin(rotation);
let cosine = cos(rotation); let cosine = cos(rotation);
return vec2( return vec2(
centered.x * cosine - centered.y * sine, (centered.x * cosine + centered.y * sine) * scale_offset.x,
centered.x * sine + centered.y * cosine, (-centered.x * sine + centered.y * cosine) * scale_offset.y,
) + vec2(0.5) + scale_offset.zw; ) + vec2(0.5) + scale_offset.zw;
} }
fn legacy_sample_uv(uv: vec2<f32>) -> vec2<f32> {
return vec2(uv.x, 1.0 - uv.y);
}
@fragment @fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { 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 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; 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; discard;
} }
var normal = normalize(in.world_normal) * select(-1.0, 1.0, is_front); var normal = normalize(in.world_normal) * select(-1.0, 1.0, is_front);
if material.surface.z > 0.5 { if material.surface.z > 0.5 {
let normal_uv = transformed_uv(in.uv, material.normal_scale_offset, material.rotations.y); 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 tangent = normalize(in.world_tangent.xyz);
let bitangent = normalize(cross(normal, tangent)) * in.world_tangent.w; let bitangent = normalize(cross(normal, tangent)) * in.world_tangent.w;
normal = normalize(mat3x3(tangent, bitangent, normal) * tangent_normal); 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 half_direction = normalize(light_direction + view_direction);
let diffuse_light = max(dot(normal, light_direction), 0.0); 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_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 specular_light = pow(max(dot(normal, half_direction), 0.0), material.rotations.w);
let lit = base.rgb * (0.55 + 0.45 * diffuse_light) let lit = base.rgb * (0.55 + 0.45 * diffuse_light)
+ material.specular_color.rgb * specular_map * specular_light + material.specular_color.rgb * specular_map * specular_light

View File

@@ -112,6 +112,8 @@ pub struct BlinnPhongMaterial {
pub fullbright: bool, pub fullbright: bool,
pub double_sided: bool, pub double_sided: bool,
pub alpha_mode: AlphaMode, pub alpha_mode: AlphaMode,
/// Optional diffuse-texture cutout applied before face-alpha blending.
pub alpha_cutoff: Option<f32>,
} }
impl Default for BlinnPhongMaterial { impl Default for BlinnPhongMaterial {
@@ -127,6 +129,7 @@ impl Default for BlinnPhongMaterial {
fullbright: false, fullbright: false,
double_sided: false, double_sided: false,
alpha_mode: AlphaMode::Opaque, 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; @group(#{MATERIAL_BIND_GROUP}) @binding(108) var emissive_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> { 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 sine = sin(rotation);
let cosine = cos(rotation); let cosine = cos(rotation);
return vec2( return vec2(
centered.x * cosine - centered.y * sine, scaled.x * cosine - scaled.y * sine,
centered.x * sine + centered.y * cosine, scaled.x * sine + scaled.y * cosine,
) + vec2(0.5) + scale_offset.zw; ) + scale_offset.zw;
} }
@fragment @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 Relative paths in a YAML document resolve relative to that document where specified; service
deployments should use absolute paths. 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 ## Endpoint, grid identity, and authority
`llm.endpoint_url` is the OpenAI-compatible provider base URL. Mentra owns the `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, reconnect, and interaction defaults. Important defaults include 256 grid events,
32 control commands, 512 observations, four concurrent inference requests, 32 control commands, 512 observations, four concurrent inference requests,
16 tool calls, 512 active senders/sessions, a two-minute model window, and bounded 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 10-second shutdown. Vision defaults to a 1920x1080 headless Bevy/wgpu JPEG, a
64-meter configurable view radius, a persistent 512 MiB decoded-texture cache, 64-meter configurable view radius, a bounded in-memory decoded-texture cache,
and a deterministic software fallback with bounded entities, triangles, asset a persistent platform asset cache, and a deterministic software fallback with
work, JPEG bytes, time, and concurrency. Authorized `behavior_camera_set` calls are 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 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 accept a 20-120 degree vertical field of view; `behavior_camera_reset` restores
the avatar-facing 60-degree view. the avatar-facing 60-degree view.