Complete Bevy OpenSim scene rendering
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
3423
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
91
crates/metacrate-grid-agent/tests/live_llm_vision.rs
Normal file
91
crates/metacrate-grid-agent/tests/live_llm_vision.rs
Normal 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())
|
||||||
|
}
|
||||||
443
crates/metacrate-grid-agent/tests/live_renderer.rs
Normal file
443
crates/metacrate-grid-agent/tests/live_renderer.rs
Normal 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,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
1100
crates/metacrate-rendering-wgpu/src/gpu.rs
Normal file
1100
crates/metacrate-rendering-wgpu/src/gpu.rs
Normal file
File diff suppressed because it is too large
Load Diff
64
crates/metacrate-rendering-wgpu/src/legacy_material.wgsl
Normal file
64
crates/metacrate-rendering-wgpu/src/legacy_material.wgsl
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
crates/metacrate-rendering-wgpu/src/pbr_material.wgsl
Normal file
85
crates/metacrate-rendering-wgpu/src/pbr_material.wgsl
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user