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/ /tests/semver-port/target/
/artifacts/ /artifacts/
/linden/ /linden/
/crates/metacrate-grid-agent/linden/
.env .env
.env.* .env.*
!.env.example !.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_walk_distance_meters: 8
max_attention_distance_meters: 96 max_attention_distance_meters: 96
idle_look_enabled: true idle_look_enabled: true
vision:
max_distance_meters: 64
reconnect: reconnect:
initial_delay_milliseconds: 1000 initial_delay_milliseconds: 1000
maximum_delay_seconds: 60 maximum_delay_seconds: 60

View File

@@ -175,6 +175,7 @@ impl LibremetaverseClientOwner {
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 agent = Arc::new( let agent = Arc::new(
libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| { libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| {
BackendError::Configuration { BackendError::Configuration {

View File

@@ -318,6 +318,7 @@ pub struct AgentConfig {
pub limits: Limits, pub limits: Limits,
pub storage_path: PathBuf, pub storage_path: PathBuf,
pub behavior: BehaviorSettings, pub behavior: BehaviorSettings,
pub vision: crate::vision::VisionLimits,
pub reconnect: crate::session::ReconnectPolicy, pub reconnect: crate::session::ReconnectPolicy,
pub conversation: ConversationSettings, pub conversation: ConversationSettings,
pub interaction: crate::interaction::InteractionSettings, pub interaction: crate::interaction::InteractionSettings,
@@ -362,6 +363,14 @@ impl AgentConfig {
if !self.behavior.is_valid() { if !self.behavior.is_valid() {
return Err(ConfigError::InvalidBehavior); 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()) if (!self.control.listen.ip().is_loopback() && self.control.remote_tls.is_none())
|| !self.control.limits.is_valid() || !self.control.limits.is_valid()
{ {
@@ -1031,6 +1040,7 @@ struct FileConfig {
limits: RawLimits, limits: RawLimits,
storage_path: Option<PathBuf>, storage_path: Option<PathBuf>,
behavior: RawBehavior, behavior: RawBehavior,
vision: RawVision,
reconnect: RawReconnect, reconnect: RawReconnect,
conversation: RawConversation, conversation: RawConversation,
interaction: RawInteraction, interaction: RawInteraction,
@@ -1115,6 +1125,12 @@ struct RawBehavior {
idle_look_enabled: Option<bool>, 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)] #[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct RawReconnect { struct RawReconnect {
@@ -1567,6 +1583,10 @@ fn resolve<E: Environment>(
max_attention_distance_meters: raw.behavior.max_attention_distance_meters.unwrap_or(96), max_attention_distance_meters: raw.behavior.max_attention_distance_meters.unwrap_or(96),
idle_look_enabled: raw.behavior.idle_look_enabled.unwrap_or(true), 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, reconnect,
conversation, conversation,
interaction, interaction,
@@ -1964,6 +1984,35 @@ mod tests {
assert!(paths.data_directory.ends_with("grid-agent")); 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)] #[cfg(unix)]
#[test] #[test]
fn group_readable_secret_files_fail_closed() { fn group_readable_secret_files_fail_closed() {

View File

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

View File

@@ -617,7 +617,7 @@ fn start_live_interactions(
LibremetaverseScriptInventory, LlmClient, MemoryPolicyAudit, Observability, LibremetaverseScriptInventory, LlmClient, MemoryPolicyAudit, Observability,
ObservabilityLimits, PerceptionBackend, PolicyAuditSink, PolicyGateway, PolicyLimits, ObservabilityLimits, PerceptionBackend, PolicyAuditSink, PolicyGateway, PolicyLimits,
PolicyLlmResponder, ScriptDeliveryBackend, ScriptDeliverySettings, SystemRoamingRandom, 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, behavior_policy_tools, build_policy_tools, landmark_policy_tools, perception_policy_tools,
script_delivery_policy_tool, script_delivery_policy_tool,
}; };
@@ -739,7 +739,7 @@ fn start_live_interactions(
now, now,
config.storage_path.join("mentra"), config.storage_path.join("mentra"),
)?); )?);
let vision_limits = VisionLimits::default(); let vision_limits = config.vision;
let vision = Arc::new( let vision = Arc::new(
VisionService::new( VisionService::new(
Arc::new(LibremetaverseSceneSource::new(owner, vision_limits)), 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 { fn uuid(value: u32) -> UUID {
UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap() UUID::new_with_string(format!("00000000-0000-4000-8000-{value:012}")).unwrap()
} }
fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle { fn triangle(z: f32, color: [u8; 4]) -> SceneTriangle {
SceneTriangle { SceneTriangle {
vertices: [[-1.0, -1.0, z], [1.0, -1.0, z], [0.0, 1.0, z]], 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")] #[cfg(feature = "live-grid")]
#[test] #[test]
fn scene_broad_phase_rejects_object_centers_behind_the_camera() { fn scene_visibility_uses_configured_distance_and_avatar_view() {
assert!(scene_center_in_front( 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]
));
assert!(!scene_center_in_front(
libremetaverse_types::Vector3 { libremetaverse_types::Vector3 {
x: -4.0, x: -4.0,
y: 0.0, y: 0.0,
z: 0.0 z: 0.0
}, },
[0.0; 3], [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 { fn scene() -> SceneSnapshot {
@@ -123,6 +168,7 @@ fn scene() -> SceneSnapshot {
kind: SceneEntityKind::Resident, kind: SceneEntityKind::Resident,
display_name: "Private Resident".into(), display_name: "Private Resident".into(),
triangles: vec![triangle(4.0, [255, 0, 0, 255])], triangles: vec![triangle(4.0, [255, 0, 0, 255])],
renderables: Vec::new(),
texture_available: false, texture_available: false,
}, },
SceneEntity { SceneEntity {
@@ -130,9 +176,11 @@ fn scene() -> SceneSnapshot {
kind: SceneEntityKind::Object, kind: SceneEntityKind::Object,
display_name: "Near".into(), display_name: "Near".into(),
triangles: vec![triangle(2.0, [0, 255, 0, 255])], triangles: vec![triangle(2.0, [0, 255, 0, 255])],
renderables: Vec::new(),
texture_available: true, texture_available: true,
}, },
], ],
textures: Vec::new(),
completeness: SnapshotCompleteness { completeness: SnapshotCompleteness {
textures_missing: 1, textures_missing: 1,
terrain_available: false, terrain_available: false,
@@ -190,7 +238,7 @@ async fn golden_scene_is_deterministic_depth_ordered_and_privacy_marked() {
#[cfg(feature = "live-grid")] #[cfg(feature = "live-grid")]
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available() { 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; return;
}; };
let limits = VisionLimits { let limits = VisionLimits {
@@ -205,15 +253,21 @@ async fn offscreen_wgpu_renders_depth_ordered_scene_when_an_adapter_is_available
.iter() .iter()
.flat_map(|entity| entity.triangles.iter().cloned()) .flat_map(|entity| entity.triangles.iter().cloned())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let renderables = scene_renderable_from_triangles(&triangles)
.into_iter()
.collect::<Vec<_>>();
let rgba = tokio::task::spawn_blocking(move || { let rgba = tokio::task::spawn_blocking(move || {
renderer.render( renderer.render(
scene.camera, scene.camera,
&triangles, &renderables,
&scene.textures,
[72, 96, 120, 255], [72, 96, 120, 255],
metacrate_rendering_wgpu::RenderLimits { metacrate_rendering_wgpu::RenderLimits {
width: limits.width, width: limits.width,
height: limits.height, height: limits.height,
max_triangles: limits.max_triangles, max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512, 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 =")); assert!(manifest.contains("metacrate-rendering-wgpu ="));
let renderer = fs::read_to_string(root.join("../metacrate-rendering-wgpu/Cargo.toml")) let renderer = fs::read_to_string(root.join("../metacrate-rendering-wgpu/Cargo.toml"))
.expect("wgpu renderer manifest"); .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] #[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 publish = false
[dependencies] [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] [features]
default = [] default = []
wgpu = ["dep:wgpu"] bevy-engine = ["dep:bevy"]
wgpu = ["bevy-engine"]
windowed = ["bevy-engine", "bevy/bevy_winit", "bevy/wayland", "bevy/x11"]
[lints] [lints]
workspace = true 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)] #![allow(clippy::missing_errors_doc)]
@@ -19,14 +19,9 @@ impl Camera {
up: [f32; 3], up: [f32; 3],
vertical_fov_degrees: f32, vertical_fov_degrees: f32,
) -> Result<Self, RenderError> { ) -> Result<Self, RenderError> {
let forward = normalize([
target[0] - position[0],
target[1] - position[1],
target[2] - position[2],
])?;
let camera = Self { let camera = Self {
position, position,
forward, forward: normalize(sub(target, position))?,
up: normalize(up)?, up: normalize(up)?,
vertical_fov_degrees, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct Triangle { pub struct Texture {
pub vertices: [[f32; 3]; 3], pub width: u32,
pub colors_srgb: [[u8; 4]; 3], 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -62,16 +196,19 @@ pub struct RenderLimits {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub max_triangles: usize, pub max_triangles: usize,
pub max_texture_bytes: usize,
pub max_texture_pixels: usize,
pub far_distance: u32, pub far_distance: u32,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum RenderError { pub enum RenderError {
AdapterUnavailable, AdapterUnavailable,
InvalidScene, InvalidScene,
ResourceLimit, ResourceLimit,
TimedOut, TimedOut,
Readback, Readback,
Device(String),
} }
impl std::fmt::Display for RenderError { impl std::fmt::Display for RenderError {
@@ -82,18 +219,27 @@ impl std::fmt::Display for RenderError {
Self::ResourceLimit => "render resource limit exceeded", Self::ResourceLimit => "render resource limit exceeded",
Self::TimedOut => "wgpu render timed out", Self::TimedOut => "wgpu render timed out",
Self::Readback => "wgpu readback failed", 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 {} 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> { fn normalize(value: [f32; 3]) -> Result<[f32; 3], RenderError> {
let length = dot(value, value).sqrt(); let length = dot(value, value).sqrt();
if length < 0.0001 { if length < 0.0001 || !length.is_finite() {
return Err(RenderError::InvalidScene); 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 { 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")] #[cfg(feature = "wgpu")]
pub struct Renderer { mod gpu;
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::RenderPipeline,
}
#[cfg(feature = "wgpu")] #[cfg(feature = "wgpu")]
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] = pub use gpu::Renderer;
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))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn look_at_builds_a_valid_projection_camera() { fn camera_and_both_material_models_are_explicit() {
let camera = 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(); 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!( assert!(
camera camera
.forward .forward
.into_iter() .iter()
.zip([1.0, 0.0, 0.0]) .zip([1.0, 0.0, 0.0])
.all(|(actual, expected)| (actual - expected).abs() < f32::EPSILON) .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!(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 ## Images
Vision captures are reconstructed viewport images rather than screenshots. Vision captures are reconstructed viewport images rather than screenshots.
The live service keeps one headless `wgpu` device, renders the current scene to `metacrate-rendering-wgpu` owns a dedicated-thread, headless Bevy/wgpu device,
an offscreen color/depth target, reads the frame back, encodes it directly as renders the current scene to an offscreen color/depth target, reads the frame
JPEG, and attaches it as a Mentra image content block. GPU setup and readback back, encodes it directly as JPEG, and attaches it as a Mentra image content
run outside the simulator event path. If no compatible adapter is available or block. GPU setup, scene conversion, and readback run outside the simulator event
a frame fails, the same scene is rendered by the deterministic software path. If no compatible adapter is available or a frame fails, the deterministic
rasterizer. The default 320x180 frame, quality, entity, triangle, texture-fetch, software rasterizer handles the same snapshot. The default 640x360 frame and
decoded-pixel, byte, rate, and concurrency limits are validated before runtime. the entity, triangle, texture, JPEG, time, and concurrency safety ceilings are
JPEG avoids the much larger PNG payloads. validated before runtime. JPEG avoids the much larger PNG payloads.
The current renderer handles legacy prim geometry, approximate texture colors, The live source reconstructs authoritative terrain heights and detail textures,
simple avatars, and flat terrain/water. `behavior_camera_set` gives an legacy prims, sculpt maps, uploaded meshes, linksets, legacy Blinn-Phong and
authorized model a bounded position, target, and vertical field of view; PBR materials, independent UV transforms, water, and privacy-marked avatar
`behavior_camera_reset` restores the avatar-facing view. Viewer-grade cards. It keeps bounded decoded-texture and geometry caches per region; Bevy
mesh/sculpt rendering, full UV materials, lighting, authoritative terrain, and also retains unchanged GPU textures and meshes between frames. Textures are
avatar/attachment fidelity remain tracked in the renderer issue. 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 ## 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, 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 320x180 headless-wgpu JPEG with a 10-second shutdown. Vision defaults to a 640x360 headless Bevy/wgpu JPEG, a
deterministic software fallback and bounded entities, triangles, texture work, 64-meter configurable view radius, a persistent 512 MiB decoded-texture cache,
JPEG bytes, time, and concurrency. Authorized `behavior_camera_set` calls are 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 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.