Complete Bevy OpenSim scene rendering
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-22 17:41:39 +02:00
parent f6f5abe464
commit 692894cac9
19 changed files with 6489 additions and 717 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
/tests/semver-port/target/
/artifacts/
/linden/
/crates/metacrate-grid-agent/linden/
.env
.env.*
!.env.example

3423
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,9 @@ behavior:
max_walk_distance_meters: 8
max_attention_distance_meters: 96
idle_look_enabled: true
vision:
max_distance_meters: 64
reconnect:
initial_delay_milliseconds: 1000
maximum_delay_seconds: 60

View File

@@ -175,6 +175,7 @@ impl LibremetaverseClientOwner {
let network = client.network();
let _grid = client.grid();
let _terrain = client.terrain();
let _objects = client.objects();
let agent = Arc::new(
libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| {
BackendError::Configuration {

View File

@@ -318,6 +318,7 @@ pub struct AgentConfig {
pub limits: Limits,
pub storage_path: PathBuf,
pub behavior: BehaviorSettings,
pub vision: crate::vision::VisionLimits,
pub reconnect: crate::session::ReconnectPolicy,
pub conversation: ConversationSettings,
pub interaction: crate::interaction::InteractionSettings,
@@ -362,6 +363,14 @@ impl AgentConfig {
if !self.behavior.is_valid() {
return Err(ConfigError::InvalidBehavior);
}
if !self.vision.valid() {
return Err(ConfigError::UnsafeLimit {
field: "vision.max_distance_meters",
value: self.vision.max_distance_meters as usize,
minimum: 1,
maximum: 1_024,
});
}
if (!self.control.listen.ip().is_loopback() && self.control.remote_tls.is_none())
|| !self.control.limits.is_valid()
{
@@ -1031,6 +1040,7 @@ struct FileConfig {
limits: RawLimits,
storage_path: Option<PathBuf>,
behavior: RawBehavior,
vision: RawVision,
reconnect: RawReconnect,
conversation: RawConversation,
interaction: RawInteraction,
@@ -1115,6 +1125,12 @@ struct RawBehavior {
idle_look_enabled: Option<bool>,
}
#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawVision {
max_distance_meters: Option<u32>,
}
#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawReconnect {
@@ -1567,6 +1583,10 @@ fn resolve<E: Environment>(
max_attention_distance_meters: raw.behavior.max_attention_distance_meters.unwrap_or(96),
idle_look_enabled: raw.behavior.idle_look_enabled.unwrap_or(true),
},
vision: crate::vision::VisionLimits {
max_distance_meters: raw.vision.max_distance_meters.unwrap_or(64),
..crate::vision::VisionLimits::default()
},
reconnect,
conversation,
interaction,
@@ -1964,6 +1984,35 @@ mod tests {
assert!(paths.data_directory.ends_with("grid-agent"));
}
#[test]
fn vision_distance_is_configurable_and_bounded() {
let configured = temporary_file(
"vision-distance.yml",
"llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nvision:\n max_distance_meters: 96\n",
);
let config = ConfigLoader::new()
.with_file(&configured)
.with_environment(MapEnvironment::default())
.load()
.expect("configured vision distance");
assert_eq!(config.vision.max_distance_meters, 96);
let unsafe_distance = temporary_file(
"unsafe-vision-distance.yml",
"llm:\n endpoint_url: https://llm.invalid/chat\n api_key: placeholder\nvision:\n max_distance_meters: 0\n",
);
assert!(matches!(
ConfigLoader::new()
.with_file(&unsafe_distance)
.with_environment(MapEnvironment::default())
.load(),
Err(ConfigError::UnsafeLimit {
field: "vision.max_distance_meters",
..
})
));
}
#[cfg(unix)]
#[test]
fn group_readable_secret_files_fail_closed() {

View File

@@ -436,6 +436,7 @@ pub enum InteractionObservation {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractionError {
UnsafeLimits,
Persistence(String),
MalformedInbound,
QueueClosed,
ObservationClosed,
@@ -449,6 +450,7 @@ impl fmt::Display for InteractionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::UnsafeLimits => "unsafe interaction limits",
Self::Persistence(_) => "interaction persistence failed",
Self::MalformedInbound => "malformed inbound interaction",
Self::QueueClosed => "interaction input queue is closed",
Self::ObservationClosed => "interaction observation queue is closed",
@@ -456,7 +458,11 @@ impl fmt::Display for InteractionError {
Self::TaskPanicked => "interaction task panicked",
Self::Conversation => "conversation memory rejected interaction data",
Self::Boundary(_) => "interaction boundary rejected data",
})
})?;
if let Self::Persistence(detail) = self {
write!(formatter, ": {detail}")?;
}
Ok(())
}
}
@@ -1986,6 +1992,8 @@ impl PolicyLlmResponder {
.validate()
.map_err(|_| InteractionError::UnsafeLimits)?;
let active_tools = Arc::new(Mutex::new(BTreeMap::new()));
std::fs::create_dir_all(&storage_path)
.map_err(|error| InteractionError::Persistence(error.to_string()))?;
let mut builder = mentra::Runtime::builder()
.with_runtime_identifier(MENTRA_RUNTIME_IDENTIFIER)
.with_store(mentra::runtime::HybridRuntimeStore::new(
@@ -2000,10 +2008,10 @@ impl PolicyLlmResponder {
}
let runtime = builder
.build()
.map_err(|_| InteractionError::UnsafeLimits)?;
.map_err(|error| InteractionError::Persistence(error.to_string()))?;
let agents = runtime
.resume(MENTRA_RUNTIME_IDENTIFIER)
.map_err(|_| InteractionError::UnsafeLimits)?
.map_err(|error| InteractionError::Persistence(error.to_string()))?
.into_iter()
.filter(|agent| agent.name().starts_with(MENTRA_AGENT_PREFIX))
.map(|agent| {

View File

@@ -617,7 +617,7 @@ fn start_live_interactions(
LibremetaverseScriptInventory, LlmClient, MemoryPolicyAudit, Observability,
ObservabilityLimits, PerceptionBackend, PolicyAuditSink, PolicyGateway, PolicyLimits,
PolicyLlmResponder, ScriptDeliveryBackend, ScriptDeliverySettings, SystemRoamingRandom,
ToolLoopLimits, UnifiedPolicyAudit, VisionAugmentedResponder, VisionLimits, VisionService,
ToolLoopLimits, UnifiedPolicyAudit, VisionAugmentedResponder, VisionService,
behavior_policy_tools, build_policy_tools, landmark_policy_tools, perception_policy_tools,
script_delivery_policy_tool,
};
@@ -739,7 +739,7 @@ fn start_live_interactions(
now,
config.storage_path.join("mentra"),
)?);
let vision_limits = VisionLimits::default();
let vision_limits = config.vision;
let vision = Arc::new(
VisionService::new(
Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)),

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ impl SceneSource for FakeScene {
fn uuid(value: u32) -> UUID {
UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap()
}
fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle {
SceneTriangle {
vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]],
@@ -85,24 +86,68 @@ fn scene_faces_are_shaded_without_changing_alpha() {
#[cfg(feature = "live-grid")]
#[test]
fn scene_broad_phase_rejects_object_centers_behind_the_camera() {
assert!(scene_center_in_front(
libremetaverse_types::Vector3 {
x: 4.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0]
));
assert!(!scene_center_in_front(
fn scene_visibility_uses_configured_distance_and_avatar_view() {
assert!(scene_within_distance(
libremetaverse_types::Vector3 {
x: -4.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0]
64.0,
));
assert!(!scene_within_distance(
libremetaverse_types::Vector3 {
x: 65.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
64.0,
));
assert!(scene_center_visible(
libremetaverse_types::Vector3 {
x: 4.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0],
60_f32.to_radians(),
16.0 / 9.0,
64.0,
));
assert!(!scene_center_visible(
libremetaverse_types::Vector3 {
x: -4.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0],
60_f32.to_radians(),
16.0 / 9.0,
64.0,
));
assert!(scene_center_focused(
libremetaverse_types::Vector3 {
x: 4.0,
y: 0.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0],
64.0,
));
assert!(!scene_center_focused(
libremetaverse_types::Vector3 {
x: 4.0,
y: 4.0,
z: 0.0
},
[0.0; 3],
[1.0, 0.0, 0.0],
64.0,
));
}
fn scene() -> SceneSnapshot {
@@ -123,6 +168,7 @@ fn scene() -> SceneSnapshot {
kind: SceneEntityKind::Resident,
display_name: "Private Resident".into(),
triangles: vec![triangle(4.0, [255, 0, 0, 255])],
renderables: Vec::new(),
texture_available: false,
},
SceneEntity {
@@ -130,9 +176,11 @@ fn scene() -> SceneSnapshot {
kind: SceneEntityKind::Object,
display_name: "Near".into(),
triangles: vec![triangle(2.0, [0, 255, 0, 255])],
renderables: Vec::new(),
texture_available: true,
},
],
textures: Vec::new(),
completeness: SnapshotCompleteness {
textures_missing: 1,
terrain_available: false,
@@ -190,7 +238,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
#[cfg(feature = "live-grid")]
#[tokio::test(flavor = "multi_thread")]
async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() {
let Ok(renderer) = metacrate_rendering_wgpu::Renderer::new().await else {
let Ok(renderer) = metacrate_rendering_wgpu::Renderer::new_blocking() else {
return;
};
let limits = VisionLimits {
@@ -205,15 +253,21 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available
.iter()
.flat_map(|entity| entity.triangles.iter().cloned())
.collect::<Vec<_>>();
let renderables = scene_renderable_from_triangles(&triangles)
.into_iter()
.collect::<Vec<_>>();
let rgba = tokio::task::spawn_blocking(move || {
renderer.render(
scene.camera,
&triangles,
&renderables,
&scene.textures,
[72, 96, 120, 255],
metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
},
)

View File

@@ -104,7 +104,8 @@ fn vision_path_selects_only_reviewed_rust_renderers_and_codec() {
assert!(manifest.contains("metacrate-rendering-wgpu ="));
let renderer = fs::read_to_string(root.join("../metacrate-rendering-wgpu/Cargo.toml"))
.expect("wgpu renderer manifest");
assert!(renderer.contains("wgpu = { version = \"30.0.1\""));
assert!(renderer.contains("bevy = { version = \"0.19.1\", default-features = false"));
assert!(renderer.contains("wgpu = [\"bevy-engine\"]"));
}
#[test]

View File

@@ -0,0 +1,91 @@
use base64::Engine as _;
use mentra::{BuiltinProvider, ContentBlock, ModelInfo, Runtime};
use metacrate_grid_agent::{AgentConfig, LlmClient};
use std::{collections::BTreeMap, error::Error, path::PathBuf};
#[tokio::test]
#[ignore = "sends the live renderer JPEG to the configured vision model through Mentra SSE"]
async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let mut connection = AgentConfig::offline(
required(&environment, "OPENAPI_URL")?,
required(&environment, "OPENAPI_KEY")?,
)?
.llm;
connection.model = Some(required(&environment, "OPENAPI_MODEL")?.to_owned());
let client = LlmClient::new(connection);
let runtime = Runtime::empty_builder()
.with_store(mentra::runtime::VolatileRuntimeStore::default())
.with_registered_provider(client.mentra_provider())
.build()?;
let root = std::env::temp_dir().join(format!("metacrate-live-vision-{}", std::process::id()));
let mut config = mentra::AgentConfig {
system: Some("You are in a virtual world.".to_owned()),
..Default::default()
};
config.compaction.transcript_dir = root.join("transcripts");
config.task.tasks_dir = root.join("tasks");
config.team.team_dir = root.join("teams");
config.workspace.base_dir = root;
let mut agent = runtime.spawn_with_config(
"live-render-review",
ModelInfo::new(client.configured_model(), BuiltinProvider::OpenAI),
config,
)?;
let jpeg = std::fs::read(
std::env::var_os("METACRATE_LIVE_RENDER_OUTPUT").map_or_else(
|| std::env::temp_dir().join("metacrate-live-render.jpg"),
PathBuf::from,
),
)?;
let image = format!(
"data:image/jpeg;base64,{}",
base64::engine::general_purpose::STANDARD.encode(jpeg)
);
let response = agent
.send(vec![
ContentBlock::text(
"Describe this rendered virtual-world scene. State whether it contains recognizable textured terrain and mesh scenery, and identify any obvious rendering corruption.",
),
ContentBlock::image_url(image),
])
.await?;
for block in response.content {
if let ContentBlock::Text { text } = block {
println!("LIVE_LLM_VISION={text}");
}
}
Ok(())
}
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
for line in std::fs::read_to_string(path)?.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.entry(key.trim().to_owned()).or_insert_with(|| {
value
.trim()
.trim_matches(|character| character == '\'' || character == '"')
.to_owned()
});
}
Ok(values)
}
fn required<'a>(
environment: &'a BTreeMap<String, String>,
key: &'static str,
) -> Result<&'a str, Box<dyn Error>> {
environment
.get(key)
.filter(|value| !value.is_empty())
.map(String::as_str)
.ok_or_else(|| format!("missing live test setting {key}").into())
}

View File

@@ -0,0 +1,443 @@
#![cfg(feature = "live-grid")]
use libremetaverse_types::{Vector3, compat::CancellationTokenSource};
use metacrate_grid_agent::{
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
LibremetaverseSceneSource, SecretString, SessionSignal, VisionLimits, VisionService,
};
use std::{
collections::BTreeMap,
error::Error,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs two dedicated accounts into the configured live grid and writes a JPEG"]
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let target_owner = LibremetaverseClientOwner::new()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let (mut target_session, target_cancel) = login(
&target_owner,
&environment,
"GRID_TEST_UNPRIVILEGED_USER",
"GRID_TEST_UNPRIVILEGED_PASSWORD",
)
.await?;
let (mut renderer_session, renderer_cancel) = match login(
&renderer_owner,
&environment,
"GRID_TEST_AUTHORIZED_USER",
"GRID_TEST_AUTHORIZED_PASSWORD",
)
.await
{
Ok(login) => login,
Err(error) => {
let _ = target_session.logout(target_cancel.token()).await;
return Err(error);
}
};
let result = async {
wait_ready("target", &mut target_session, &target_cancel).await?;
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
let renderer_start_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("renderer start simulator unavailable")?;
let renderer_start_handle = renderer_start_simulator.handle;
let renderer_start_position = conventional_start(&renderer_start_simulator);
let target_simulator = target_owner
.client()
.network()
.current_sim()
.ok_or("target simulator unavailable")?;
let target_position = target_owner.agent().sim_position();
let arrival = Vector3 {
x: target_position.x - 10.0,
y: target_position.y - 10.0,
z: target_position.z + 2.0,
};
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
target_simulator.handle,
arrival,
target_position,
Some(renderer_cancel.token()),
)
.await?
{
return Err("teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
renderer_start_position,
Vector3 {
x: renderer_start_position.x + 10.0,
y: renderer_start_position.y,
z: renderer_start_position.z,
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("return teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
let mut renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("renderer simulator unavailable")?;
let mut settled_position = renderer_owner.agent().sim_position();
if let Some(ground) = terrain_ground(
&renderer_simulator,
renderer_start_position.x,
renderer_start_position.y,
) {
let destination = Vector3 {
x: renderer_start_position.x,
y: renderer_start_position.y,
z: ground + 2.0,
};
if (settled_position.z - destination.z).abs() > 1.0 {
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
destination,
Vector3 {
x: destination.x + 10.0,
..destination
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("ground-settle teleport did not complete".into());
}
tokio::time::sleep(Duration::from_secs(5)).await;
renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("settled renderer simulator unavailable")?;
settled_position = renderer_owner.agent().sim_position();
}
}
wait_scene_settled(&renderer_owner).await?;
let mut view_forward = Vector3::unit_x();
if let Some((destination, forward)) = scenic_location(&renderer_simulator) {
if !renderer_owner
.agent()
.teleport_with_u_int64_vector3_vector3_cancellation_token(
renderer_start_handle,
destination,
Vector3 {
x: destination.x + forward.x,
y: destination.y + forward.y,
z: destination.z + forward.z,
},
Some(renderer_cancel.token()),
)
.await?
{
return Err("scenic teleport did not complete".into());
}
wait_scene_settled(&renderer_owner).await?;
renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("scenic renderer simulator unavailable")?;
settled_position = renderer_owner.agent().sim_position();
view_forward = forward;
}
{
let prims = renderer_simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let largest_scale = prims.values().fold(0.0_f32, |largest, prim| {
largest.max(prim.scale.x.max(prim.scale.y).max(prim.scale.z))
});
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
.agent()
.movement
.camera
.look_at_with_vector3_vector3(camera_position, camera_target)?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let vision = VisionService::new(
Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits)),
limits,
)?
.prefer_gpu();
vision.set_generation(1);
let mut capture = None;
let output = std::env::var_os("METACRATE_LIVE_RENDER_OUTPUT").map_or_else(
|| std::env::temp_dir().join("metacrate-live-render.jpg"),
PathBuf::from,
);
let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(8)
.clamp(1, 16);
for frame in 1..=frames {
let next = vision
.capture(
&format!("live-renderer-evidence-{frame}"),
1,
renderer_cancel.token(),
)
.await?;
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
std::fs::write(&output, &next.jpeg)?;
capture = Some(next);
}
let capture = capture.ok_or("live renderer produced no frame")?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
Ok::<(), Box<dyn Error>>(())
}
.await;
let _ = renderer_session.logout(renderer_cancel.token()).await;
let _ = target_session.logout(target_cancel.token()).await;
result
}
async fn login(
owner: &LibremetaverseClientOwner,
environment: &BTreeMap<String, String>,
user: &'static str,
password: &'static str,
) -> Result<(Box<dyn GridSession>, CancellationTokenSource), Box<dyn Error>> {
let cancellation = CancellationTokenSource::new();
let backend = owner.session_backend(GridConnection {
login_url: EndpointUrl::parse("grid.login_url", &required(environment, "GRID_LOGIN_URL")?)?,
avatar_name: required(environment, user)?,
password: SecretString::new("grid.password", required(environment, password)?)?,
})?;
let session = backend.login(1, cancellation.token()).await?;
Ok((session, cancellation))
}
async fn wait_ready(
account: &str,
session: &mut Box<dyn GridSession>,
cancellation: &CancellationTokenSource,
) -> Result<(), Box<dyn Error>> {
let signal = tokio::time::timeout(
Duration::from_secs(30),
session.next_signal(cancellation.token()),
)
.await
.map_err(|_| format!("timed out waiting for {account} session readiness"))?;
match signal {
SessionSignal::Ready => Ok(()),
signal => Err(format!("unexpected {account} session signal: {signal:?}").into()),
}
}
async fn wait_scene_settled(owner: &LibremetaverseClientOwner) -> Result<(), Box<dyn Error>> {
let deadline = Instant::now() + Duration::from_secs(30);
let minimum = Instant::now() + Duration::from_secs(10);
let mut previous = 0;
let mut stable = 0;
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("simulator unavailable while settling scene")?;
let count = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len();
if count == previous && count != 0 {
stable += 1;
} else {
stable = 0;
previous = count;
}
if (Instant::now() >= minimum && stable >= 3) || Instant::now() >= deadline {
return Ok(());
}
}
}
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
for line in std::fs::read_to_string(path)?.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.entry(key.trim().to_owned()).or_insert_with(|| {
value
.trim()
.trim_matches(|character| character == '\'' || character == '"')
.to_owned()
});
}
Ok(values)
}
fn required(
environment: &BTreeMap<String, String>,
key: &'static str,
) -> Result<String, Box<dyn Error>> {
environment
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| format!("missing live test setting {key}").into())
}
#[allow(clippy::cast_precision_loss)] // Simulator dimensions are small region coordinates.
fn conventional_start(simulator: &libremetaverse::Simulator) -> Vector3 {
let x = 128.0_f32.min(simulator.size_x as f32 - 2.0);
let y = 128.0_f32.min(simulator.size_y as f32 - 2.0);
Vector3 { x, y, z: 50.0 }
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)] // Coordinates are already clamped inside the non-negative simulator extent.
fn terrain_ground(simulator: &libremetaverse::Simulator, x: f32, y: f32) -> Option<f32> {
let patches = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let patch_x = (x as usize / 16) as i32;
let patch_y = (y as usize / 16) as i32;
let local_x = x as usize % 16;
let local_y = y as usize % 16;
patches
.iter()
.find(|patch| patch.x == patch_x && patch.y == patch_y)
.and_then(|patch| patch.data.get(local_y * 16 + local_x))
.copied()
}
#[allow(clippy::cast_precision_loss)] // Terrain patch indexes are small region coordinates.
fn scenic_location(simulator: &libremetaverse::Simulator) -> Option<(Vector3, Vector3)> {
let roots = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.filter(|prim| prim.parent_id == 0)
.cloned()
.collect::<Vec<_>>();
let patches = simulator
.terrain
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
patches
.iter()
.filter(|patch| patch.data.len() == 256)
.filter_map(|patch| {
let position = Vector3 {
x: patch.x as f32 * 16.0 + 8.0,
y: patch.y as f32 * 16.0 + 8.0,
z: patch.data[8 * 16 + 8] + 2.0,
};
if roots.iter().any(|prim| {
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
let dx = prim.position.x - position.x;
let dy = prim.position.y - position.y;
let dz = prim.position.z - position.z;
(dx * dx + dy * dy + dz * dz).sqrt() < extent * 0.5 + 3.0
}) {
return None;
}
let mut score = 0.0_f32;
let mut forward = Vector3::zero();
for prim in &roots {
let dx = prim.position.x - position.x;
let dy = prim.position.y - position.y;
let horizontal = (dx * dx + dy * dy).sqrt();
let dz = prim.position.z - position.z;
let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z);
if !(10.0..=64.0).contains(&horizontal)
|| dz.abs() > 20.0
|| !(2.0..=32.0).contains(&extent)
{
continue;
}
let weight = extent.min(10.0) / horizontal;
score += extent.min(10.0);
forward.x += dx * weight;
forward.y += dy * weight;
forward.z += dz.clamp(-horizontal * 0.35, horizontal * 0.35) * weight;
}
(score > 0.0).then_some((score, position, forward))
})
.max_by(|left, right| left.0.total_cmp(&right.0))
.map(|(_, position, forward)| (position, forward))
}
fn avatar_camera(avatar: Vector3, forward: Vector3) -> Result<(Vector3, Vector3), Box<dyn Error>> {
let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt();
if !length.is_finite() || length < f32::EPSILON {
return Err("renderer start direction unavailable".into());
}
let position = Vector3 {
x: avatar.x,
y: avatar.y,
z: avatar.z + 2.0,
};
Ok((
position,
Vector3 {
x: position.x + forward.x / length * 30.0,
y: position.y + forward.y / length * 30.0,
z: position.z + forward.z / length * 30.0,
},
))
}

View File

@@ -9,11 +9,13 @@ description = "Reusable headless wgpu scene renderer and projection camera"
publish = false
[dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["std", "dx12", "metal", "gles", "vulkan", "wgsl"], optional = true }
bevy = { version = "0.19.1", default-features = false, features = ["3d_bevy_render", "default_app", "multi_threaded", "pbr_specular_textures"], optional = true }
[features]
default = []
wgpu = ["dep:wgpu"]
bevy-engine = ["dep:bevy"]
wgpu = ["bevy-engine"]
windowed = ["bevy-engine", "bevy/bevy_winit", "bevy/wayland", "bevy/x11"]
[lints]
workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
#import bevy_pbr::forward_io::{VertexOutput, FragmentOutput}
struct LegacyUniform {
diffuse_color: vec4<f32>,
specular_color: vec4<f32>,
diffuse_scale_offset: vec4<f32>,
normal_scale_offset: vec4<f32>,
specular_scale_offset: vec4<f32>,
rotations: vec4<f32>,
surface: vec4<f32>,
camera_position: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> material: LegacyUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var normal_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var specular_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var specular_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> {
let centered = (uv - vec2(0.5)) * scale_offset.xy;
let sine = sin(rotation);
let cosine = cos(rotation);
return vec2(
centered.x * cosine - centered.y * sine,
centered.x * sine + centered.y * cosine,
) + vec2(0.5) + scale_offset.zw;
}
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
let diffuse_uv = transformed_uv(in.uv, material.diffuse_scale_offset, material.rotations.x);
let texel = textureSample(diffuse_texture, diffuse_sampler, diffuse_uv);
var base = material.diffuse_color * texel * in.color;
if material.surface.w >= 0.0 && base.a < material.surface.w {
discard;
}
var normal = normalize(in.world_normal) * select(-1.0, 1.0, is_front);
if material.surface.z > 0.5 {
let normal_uv = transformed_uv(in.uv, material.normal_scale_offset, material.rotations.y);
let tangent_normal = textureSample(normal_texture, normal_sampler, normal_uv).xyz * 2.0 - 1.0;
let tangent = normalize(in.world_tangent.xyz);
let bitangent = normalize(cross(normal, tangent)) * in.world_tangent.w;
normal = normalize(mat3x3(tangent, bitangent, normal) * tangent_normal);
}
let light_direction = normalize(vec3(0.35, 0.82, 0.45));
let view_direction = normalize(material.camera_position.xyz - in.world_position.xyz);
let half_direction = normalize(light_direction + view_direction);
let diffuse_light = max(dot(normal, light_direction), 0.0);
let specular_uv = transformed_uv(in.uv, material.specular_scale_offset, material.rotations.z);
let specular_map = textureSample(specular_texture, specular_sampler, specular_uv).rgb;
let specular_light = pow(max(dot(normal, half_direction), 0.0), material.rotations.w);
let lit = base.rgb * (0.55 + 0.45 * diffuse_light)
+ material.specular_color.rgb * specular_map * specular_light
+ material.specular_color.rgb * material.surface.x * 0.15;
var out: FragmentOutput;
out.color = vec4(select(lit, base.rgb, material.surface.y > 0.5), base.a);
return out;
}

View File

@@ -1,4 +1,4 @@
//! Cross-platform projection camera and headless `wgpu` triangle renderer.
//! Reusable, windowless `OpenSim` scene rendering on stable `wgpu`.
#![allow(clippy::missing_errors_doc)]
@@ -19,14 +19,9 @@ impl Camera {
up: [f32; 3],
vertical_fov_degrees: f32,
) -> Result<Self, RenderError> {
let forward = normalize([
target[0] - position[0],
target[1] - position[1],
target[2] - position[2],
])?;
let camera = Self {
position,
forward,
forward: normalize(sub(target, position))?,
up: normalize(up)?,
vertical_fov_degrees,
};
@@ -51,10 +46,149 @@ impl Camera {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Vertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub tex_coord: [f32; 2],
pub color_srgb: [u8; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mesh {
pub vertices: Vec<Vertex>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Triangle {
pub vertices: [[f32; 3]; 3],
pub colors_srgb: [[u8; 4]; 3],
pub struct Texture {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UvTransform {
pub scale: [f32; 2],
pub offset: [f32; 2],
pub rotation_radians: f32,
}
impl Default for UvTransform {
fn default() -> Self {
Self {
scale: [1.0; 2],
offset: [0.0; 2],
rotation_radians: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct TextureSlot {
pub texture: Option<usize>,
pub transform: UvTransform,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask {
cutoff: f32,
},
Blend,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BlinnPhongMaterial {
pub diffuse_color_srgb: [u8; 4],
pub diffuse: TextureSlot,
pub normal: TextureSlot,
pub specular: TextureSlot,
pub specular_color_srgb: [u8; 3],
pub shininess: f32,
pub environment_intensity: f32,
pub fullbright: bool,
pub double_sided: bool,
pub alpha_mode: AlphaMode,
}
impl Default for BlinnPhongMaterial {
fn default() -> Self {
Self {
diffuse_color_srgb: [255; 4],
diffuse: TextureSlot::default(),
normal: TextureSlot::default(),
specular: TextureSlot::default(),
specular_color_srgb: [0; 3],
shininess: 32.0,
environment_intensity: 0.0,
fullbright: false,
double_sided: false,
alpha_mode: AlphaMode::Opaque,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PbrMaterial {
pub base_color_srgb: [u8; 4],
pub base_color: TextureSlot,
pub normal: TextureSlot,
/// glTF convention: roughness in G and metallic in B.
pub metallic_roughness: TextureSlot,
pub emissive: TextureSlot,
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor_srgb: [u8; 3],
pub double_sided: bool,
pub alpha_mode: AlphaMode,
}
impl Default for PbrMaterial {
fn default() -> Self {
Self {
base_color_srgb: [255; 4],
base_color: TextureSlot::default(),
normal: TextureSlot::default(),
metallic_roughness: TextureSlot::default(),
emissive: TextureSlot::default(),
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor_srgb: [0; 3],
double_sided: false,
alpha_mode: AlphaMode::Opaque,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Material {
BlinnPhong(BlinnPhongMaterial),
Pbr(PbrMaterial),
}
impl Default for Material {
fn default() -> Self {
Self::BlinnPhong(BlinnPhongMaterial::default())
}
}
impl Material {
#[must_use]
pub fn alpha_mode(&self) -> AlphaMode {
match self {
Self::BlinnPhong(material) => material.alpha_mode,
Self::Pbr(material) => material.alpha_mode,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Renderable {
pub mesh: Mesh,
pub material: Material,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -62,16 +196,19 @@ pub struct RenderLimits {
pub width: u32,
pub height: u32,
pub max_triangles: usize,
pub max_texture_bytes: usize,
pub max_texture_pixels: usize,
pub far_distance: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RenderError {
AdapterUnavailable,
InvalidScene,
ResourceLimit,
TimedOut,
Readback,
Device(String),
}
impl std::fmt::Display for RenderError {
@@ -82,18 +219,27 @@ impl std::fmt::Display for RenderError {
Self::ResourceLimit => "render resource limit exceeded",
Self::TimedOut => "wgpu render timed out",
Self::Readback => "wgpu readback failed",
})
Self::Device(_) => "wgpu device failed",
})?;
if let Self::Device(detail) = self {
write!(formatter, ": {detail}")?;
}
Ok(())
}
}
impl std::error::Error for RenderError {}
fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|axis| left[axis] - right[axis])
}
fn normalize(value: [f32; 3]) -> Result<[f32; 3], RenderError> {
let length = dot(value, value).sqrt();
if length < 0.0001 {
if length < 0.0001 || !length.is_finite() {
return Err(RenderError::InvalidScene);
}
Ok([value[0] / length, value[1] / length, value[2] / length])
Ok(value.map(|component| component / length))
}
fn dot(left: [f32; 3], right: [f32; 3]) -> f32 {
@@ -109,318 +255,30 @@ fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
}
#[cfg(feature = "wgpu")]
pub struct Renderer {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::RenderPipeline,
}
mod gpu;
#[cfg(feature = "wgpu")]
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x4];
#[cfg(feature = "wgpu")]
impl Renderer {
pub async fn new() -> Result<Self, RenderError> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
..Default::default()
})
.await
.map_err(|_| RenderError::AdapterUnavailable)?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("metacrate-rendering-wgpu"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
memory_hints: wgpu::MemoryHints::MemoryUsage,
..Default::default()
})
.await
.map_err(|_| RenderError::AdapterUnavailable)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("metacrate-rendering-wgpu-shader"),
source: wgpu::ShaderSource::Wgsl(
r"
struct VertexInput { @location(0) position: vec3<f32>, @location(1) color: vec4<f32>, };
struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32>, };
@vertex fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.position = vec4<f32>(input.position, 1.0);
output.color = input.color;
return output;
}
@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
"
.into(),
),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("metacrate-rendering-wgpu-layout"),
bind_group_layouts: &[],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("metacrate-rendering-wgpu-pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 7 * std::mem::size_of::<f32>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &VERTEX_ATTRIBUTES,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8UnormSrgb,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Ok(Self {
device,
queue,
pipeline,
})
}
#[allow(clippy::too_many_lines)]
pub fn render(
&self,
camera: Camera,
triangles: &[Triangle],
background_srgb: [u8; 4],
limits: RenderLimits,
) -> Result<Vec<u8>, RenderError> {
let (vertices, vertex_count) = gpu_vertices(camera, triangles, limits)?;
let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("metacrate-rendering-wgpu-vertices"),
size: vertices.len().max(4) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if !vertices.is_empty() {
self.queue.write_buffer(&vertex_buffer, 0, &vertices);
}
let extent = wgpu::Extent3d {
width: limits.width,
height: limits.height,
depth_or_array_layers: 1,
};
let color = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("metacrate-rendering-wgpu-color"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let depth = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("metacrate-rendering-wgpu-depth"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let row_bytes = limits
.width
.checked_mul(4)
.ok_or(RenderError::ResourceLimit)?;
let padded_row_bytes = row_bytes.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
* wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("metacrate-rendering-wgpu-readback"),
size: u64::from(padded_row_bytes)
.checked_mul(u64::from(limits.height))
.ok_or(RenderError::ResourceLimit)?,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let color_view = color.create_view(&wgpu::TextureViewDescriptor::default());
let depth_view = depth.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("metacrate-rendering-wgpu-commands"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("metacrate-rendering-wgpu-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: f64::from(background_srgb[0]) / 255.0,
g: f64::from(background_srgb[1]) / 255.0,
b: f64::from(background_srgb[2]) / 255.0,
a: f64::from(background_srgb[3]) / 255.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
if vertex_count != 0 {
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.draw(0..vertex_count, 0..1);
}
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &color,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_row_bytes),
rows_per_image: Some(limits.height),
},
},
extent,
);
let submission = self.queue.submit([encoder.finish()]);
let slice = readback.slice(..);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = sender.send(result);
});
self.device
.poll(wgpu::PollType::Wait {
submission_index: Some(submission),
timeout: Some(std::time::Duration::from_secs(5)),
})
.map_err(|_| RenderError::TimedOut)?;
receiver
.recv_timeout(std::time::Duration::from_secs(1))
.map_err(|_| RenderError::TimedOut)?
.map_err(|_| RenderError::Readback)?;
let mapped = slice
.get_mapped_range()
.map_err(|_| RenderError::Readback)?;
let mut rgba = Vec::with_capacity(row_bytes as usize * limits.height as usize);
for row in mapped
.chunks_exact(padded_row_bytes as usize)
.take(limits.height as usize)
{
rgba.extend_from_slice(&row[..row_bytes as usize]);
}
drop(mapped);
readback.unmap();
Ok(rgba)
}
}
#[cfg(feature = "wgpu")]
#[allow(clippy::cast_precision_loss)] // Render dimensions and distance are bounded well below f32's exact integer range.
fn gpu_vertices(
camera: Camera,
triangles: &[Triangle],
limits: RenderLimits,
) -> Result<(Vec<u8>, u32), RenderError> {
if limits.width == 0
|| limits.height == 0
|| limits.far_distance == 0
|| triangles.len() > limits.max_triangles
|| triangles
.iter()
.flat_map(|triangle| triangle.vertices.iter().flatten())
.any(|value| !value.is_finite())
{
return Err(RenderError::InvalidScene);
}
let basis = camera.basis()?;
let aspect = limits.width as f32 / limits.height as f32;
let focal = 1.0 / (camera.vertical_fov_degrees.to_radians() / 2.0).tan();
let mut bytes = Vec::with_capacity(triangles.len() * 3 * 7 * std::mem::size_of::<f32>());
let mut count = 0u32;
for triangle in triangles {
let projected = triangle.vertices.map(|world| {
let delta = [
world[0] - camera.position[0],
world[1] - camera.position[1],
world[2] - camera.position[2],
];
let z = dot(delta, basis.2);
[
dot(delta, basis.0) * focal / aspect / z,
dot(delta, basis.1) * focal / z,
(z / limits.far_distance as f32).clamp(0.0, 1.0),
z,
]
});
if projected.iter().any(|vertex| vertex[3] <= 0.05) {
continue;
}
for (vertex, color) in projected.into_iter().zip(triangle.colors_srgb) {
let color = color.map(|channel| f32::from(channel) / 255.0);
for value in vertex[..3].iter().chain(color.iter()) {
bytes.extend_from_slice(&value.to_ne_bytes());
}
count = count.checked_add(1).ok_or(RenderError::ResourceLimit)?;
}
}
Ok((bytes, count))
}
pub use gpu::Renderer;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn look_at_builds_a_valid_projection_camera() {
fn camera_and_both_material_models_are_explicit() {
let camera =
Camera::look_at([1.0, 2.0, 3.0], [5.0, 2.0, 3.0], [0.0, 0.0, 1.0], 60.0).unwrap();
assert!(
camera
.forward
.into_iter()
.iter()
.zip([1.0, 0.0, 0.0])
.all(|(actual, expected)| (actual - expected).abs() < f32::EPSILON)
);
assert!(Camera::look_at([0.0; 3], [0.0; 3], [0.0, 0.0, 1.0], 60.0).is_err());
assert!(matches!(Material::default(), Material::BlinnPhong(_)));
assert!(matches!(
Material::Pbr(PbrMaterial::default()),
Material::Pbr(_)
));
}
}

View File

@@ -0,0 +1,85 @@
#import bevy_pbr::{
forward_io::{VertexOutput, FragmentOutput},
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::{alpha_discard, apply_normal_mapping, apply_pbr_lighting, calculate_tbn_mikktspace, main_pass_post_lighting_processing},
pbr_types::STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT,
}
struct PbrExtension {
base_scale_offset: vec4<f32>,
normal_scale_offset: vec4<f32>,
metallic_roughness_scale_offset: vec4<f32>,
emissive_scale_offset: vec4<f32>,
rotations: vec4<f32>,
maps: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> extension: PbrExtension;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var base_color_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var base_color_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var normal_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var metallic_roughness_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var metallic_roughness_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var emissive_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(108) var emissive_sampler: sampler;
fn transformed_uv(uv: vec2<f32>, scale_offset: vec4<f32>, rotation: f32) -> vec2<f32> {
let centered = (uv - vec2(0.5)) * scale_offset.xy;
let sine = sin(rotation);
let cosine = cos(rotation);
return vec2(
centered.x * cosine - centered.y * sine,
centered.x * sine + centered.y * cosine,
) + vec2(0.5) + scale_offset.zw;
}
@fragment
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
var pbr_input = pbr_input_from_standard_material(in, is_front);
if extension.maps.x > 0.5 {
let uv = transformed_uv(in.uv, extension.base_scale_offset, extension.rotations.x);
pbr_input.material.base_color *= textureSample(base_color_texture, base_color_sampler, uv);
}
if extension.maps.y > 0.5 {
let uv = transformed_uv(in.uv, extension.normal_scale_offset, extension.rotations.y);
let sampled_normal = textureSample(normal_texture, normal_sampler, uv).rgb;
let tbn = calculate_tbn_mikktspace(pbr_input.world_normal, in.world_tangent);
let double_sided = (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT) != 0u;
pbr_input.N = apply_normal_mapping(
pbr_input.material.flags,
tbn,
double_sided,
is_front,
sampled_normal,
);
}
if extension.maps.z > 0.5 {
let uv = transformed_uv(
in.uv,
extension.metallic_roughness_scale_offset,
extension.rotations.z,
);
let metallic_roughness = textureSample(
metallic_roughness_texture,
metallic_roughness_sampler,
uv,
);
pbr_input.material.perceptual_roughness *= metallic_roughness.g;
pbr_input.material.metallic *= metallic_roughness.b;
}
if extension.maps.w > 0.5 {
let uv = transformed_uv(in.uv, extension.emissive_scale_offset, extension.rotations.w);
pbr_input.material.emissive *= textureSample(emissive_texture, emissive_sampler, uv);
}
pbr_input.material.base_color = alpha_discard(
pbr_input.material,
pbr_input.material.base_color,
);
var out: FragmentOutput;
out.color = apply_pbr_lighting(pbr_input);
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
return out;
}

View File

@@ -57,21 +57,31 @@ facility, not a requirement for autonomous operation.
## Images
Vision captures are reconstructed viewport images rather than screenshots.
The live service keeps one headless `wgpu` device, renders the current scene to
an offscreen color/depth target, reads the frame back, encodes it directly as
JPEG, and attaches it as a Mentra image content block. GPU setup and readback
run outside the simulator event path. If no compatible adapter is available or
a frame fails, the same scene is rendered by the deterministic software
rasterizer. The default 320x180 frame, quality, entity, triangle, texture-fetch,
decoded-pixel, byte, rate, and concurrency limits are validated before runtime.
JPEG avoids the much larger PNG payloads.
`metacrate-rendering-wgpu` owns a dedicated-thread, headless Bevy/wgpu device,
renders the current scene to an offscreen color/depth target, reads the frame
back, encodes it directly as JPEG, and attaches it as a Mentra image content
block. GPU setup, scene conversion, and readback run outside the simulator event
path. If no compatible adapter is available or a frame fails, the deterministic
software rasterizer handles the same snapshot. The default 640x360 frame and
the entity, triangle, texture, JPEG, time, and concurrency safety ceilings are
validated before runtime. JPEG avoids the much larger PNG payloads.
The current renderer handles legacy prim geometry, approximate texture colors,
simple avatars, and flat terrain/water. `behavior_camera_set` gives an
authorized model a bounded position, target, and vertical field of view;
`behavior_camera_reset` restores the avatar-facing view. Viewer-grade
mesh/sculpt rendering, full UV materials, lighting, authoritative terrain, and
avatar/attachment fidelity remain tracked in the renderer issue.
The live source reconstructs authoritative terrain heights and detail textures,
legacy prims, sculpt maps, uploaded meshes, linksets, legacy Blinn-Phong and
PBR materials, independent UV transforms, water, and privacy-marked avatar
cards. It keeps bounded decoded-texture and geometry caches per region; Bevy
also retains unchanged GPU textures and meshes between frames. Textures are
decoded through the pure-Rust JPEG-2000 path, reduced to viewport-appropriate
resolution, and uploaded with filtered mip chains. The view radius is
configurable as `vision.max_distance_meters` and defaults to 64 meters. World
objects are selected by that distance; avatar attachments are rigged through
their linkset hierarchy and included only when the camera is focused on the
avatar, after world-object capacity is reserved.
`behavior_camera_set` gives an authorized model a bounded position, target, and
vertical field of view; `behavior_camera_reset` restores the avatar-facing
view. Full avatar body/attachment mesh fidelity and baking remain separately
tracked work.
## Bounds and cancellation

View File

@@ -226,9 +226,10 @@ The example records all current queue, message, conversation, tool, behavior,
reconnect, and interaction defaults. Important defaults include 256 grid events,
32 control commands, 512 observations, four concurrent inference requests,
16 tool calls, 512 active senders/sessions, a two-minute model window, and bounded
10-second shutdown. Vision defaults to a 320x180 headless-wgpu JPEG with a
deterministic software fallback and bounded entities, triangles, texture work,
JPEG bytes, time, and concurrency. Authorized `behavior_camera_set` calls are
10-second shutdown. Vision defaults to a 640x360 headless Bevy/wgpu JPEG, a
64-meter configurable view radius, a persistent 512 MiB decoded-texture cache,
and a deterministic software fallback with bounded entities, triangles, asset
work, JPEG bytes, time, and concurrency. Authorized `behavior_camera_set` calls are
limited to 96 meters from the avatar by default, require a distinct target, and
accept a 20-120 degree vertical field of view; `behavior_camera_reset` restores
the avatar-facing 60-degree view.