Fix varregion scene capture and legacy rendering
This commit is contained in:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[build]
|
||||||
|
jobs = 4
|
||||||
@@ -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
1
Cargo.lock
generated
@@ -4612,6 +4612,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"directories",
|
||||||
"jpeg-decoder",
|
"jpeg-decoder",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
|
|||||||
39
crates/libremetaverse/CHANGES.md
Normal file
39
crates/libremetaverse/CHANGES.md
Normal 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`.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(®ion.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([
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user