Fix varregion scene capture and legacy rendering
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
#![cfg(feature = "live-grid")]
|
||||
|
||||
use libremetaverse_types::{Vector3, compat::CancellationTokenSource};
|
||||
use libremetaverse_types::{
|
||||
Vector3,
|
||||
compat::{CancellationToken, CancellationTokenSource},
|
||||
};
|
||||
use metacrate_grid_agent::{
|
||||
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
|
||||
LibremetaverseSceneSource, SecretString, SessionSignal, VisionLimits, VisionService,
|
||||
LibremetaverseSceneSource, SceneEntityKind, SceneSnapshot, SceneSource, SecretString,
|
||||
SessionSignal, VisionFuture, VisionLimits, VisionService,
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
@@ -14,196 +18,144 @@ use std::{
|
||||
};
|
||||
|
||||
#[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>> {
|
||||
#[ignore = "logs the primary agent into the configured live varregion"]
|
||||
async fn live_primary_login_resolves_varregion_dimensions() -> 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 owner = LibremetaverseClientOwner::new()?;
|
||||
let (mut session, cancellation) =
|
||||
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
||||
let result = async {
|
||||
wait_ready("primary", &mut session, &cancellation).await?;
|
||||
let simulator = owner
|
||||
.client()
|
||||
.network()
|
||||
.current_sim()
|
||||
.ok_or("primary simulator unavailable")?;
|
||||
let size = simulator.region_size();
|
||||
println!(
|
||||
"LIVE_PRIMARY_REGION=region:{:?},handle:{},size:{}x{},position:{:?}",
|
||||
simulator.name,
|
||||
simulator.handle,
|
||||
size.0,
|
||||
size.1,
|
||||
owner.agent().sim_position()
|
||||
);
|
||||
if size != (1024, 1024) {
|
||||
return Err(format!("expected 1024x1024 varregion, got {}x{}", size.0, size.1).into());
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let _ = session.logout(cancellation.token()).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "records the primary region's asynchronous map-name reply"]
|
||||
async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<dyn Error>> {
|
||||
let environment = live_environment()?;
|
||||
let owner = LibremetaverseClientOwner::new()?;
|
||||
let (mut session, cancellation) =
|
||||
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
||||
let result = async {
|
||||
wait_ready("primary", &mut session, &cancellation).await?;
|
||||
let simulator = owner
|
||||
.client()
|
||||
.network()
|
||||
.current_sim()
|
||||
.ok_or("primary simulator unavailable")?;
|
||||
let name = simulator.name.clone();
|
||||
let handle = simulator.handle;
|
||||
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let _subscription = owner
|
||||
.client()
|
||||
.grid()
|
||||
.subscribe_grid_region(Arc::new(move |event| {
|
||||
let _ = sender.send(event.region());
|
||||
}));
|
||||
let started = Instant::now();
|
||||
owner
|
||||
.client()
|
||||
.grid()
|
||||
.request_map_region(name.clone(), libremetaverse::GridLayerType::Objects)?;
|
||||
let deadline = started + Duration::from_secs(15);
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let Ok(Some(region)) = tokio::time::timeout(remaining, receiver.recv()).await else {
|
||||
break;
|
||||
};
|
||||
println!(
|
||||
"LIVE_MAP_NAME_EVENT=elapsed_ms:{},name:{:?},handle:{},size:{}x{}",
|
||||
started.elapsed().as_millis(),
|
||||
region.name,
|
||||
region.region_handle,
|
||||
region.size_x,
|
||||
region.size_y
|
||||
);
|
||||
if region.region_handle == handle && (region.size_x, region.size_y) == (1024, 1024) {
|
||||
return Ok::<(), Box<dyn Error>>(());
|
||||
}
|
||||
}
|
||||
Err(format!("no explicit 1024x1024 map-name reply for {name:?}").into())
|
||||
}
|
||||
.await;
|
||||
let _ = session.logout(cancellation.token()).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "logs the primary agent into the configured live grid and writes a JPEG"]
|
||||
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
|
||||
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
|
||||
let environment = live_environment()?;
|
||||
let renderer_owner = LibremetaverseClientOwner::new()?;
|
||||
let (mut renderer_session, renderer_cancel) =
|
||||
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
|
||||
|
||||
let result = async {
|
||||
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
|
||||
let 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();
|
||||
}
|
||||
}
|
||||
let settled_position = renderer_owner.agent().sim_position();
|
||||
let region_size = renderer_simulator.region_size();
|
||||
println!(
|
||||
"LIVE_RENDER_LOCATION=region:{:?},size:{}x{},position:{settled_position:?}",
|
||||
renderer_simulator.name, region_size.0, region_size.1
|
||||
);
|
||||
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)?;
|
||||
let view_forward = Vector3::mul_with_vector3_quaternion(
|
||||
Vector3::unit_x(),
|
||||
renderer_owner.agent().sim_rotation(),
|
||||
);
|
||||
let (camera_position, camera_target) =
|
||||
avatar_forward_camera(settled_position, view_forward)?;
|
||||
renderer_owner
|
||||
.agent()
|
||||
.movement
|
||||
.camera
|
||||
.look_at_with_vector3_vector3(camera_position, camera_target)?;
|
||||
renderer_owner
|
||||
.agent()
|
||||
.movement
|
||||
.camera
|
||||
.set_vertical_fov_angle(65_f32.to_radians())?;
|
||||
|
||||
let limits = VisionLimits {
|
||||
minimum_interval: Duration::ZERO,
|
||||
..VisionLimits::default()
|
||||
};
|
||||
println!(
|
||||
"LIVE_RENDER_SCOPE=max_distance_meters:{}",
|
||||
limits.max_distance_meters
|
||||
);
|
||||
let vision = VisionService::new(
|
||||
Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits)),
|
||||
Arc::new(DiagnosticSceneSource {
|
||||
inner: LibremetaverseSceneSource::new(&renderer_owner, limits),
|
||||
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
|
||||
}),
|
||||
limits,
|
||||
)?
|
||||
.prefer_gpu();
|
||||
@@ -216,8 +168,10 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
|
||||
let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(8)
|
||||
.clamp(1, 16);
|
||||
.unwrap_or(12)
|
||||
.clamp(1, 24);
|
||||
let mut previous_missing = u32::MAX;
|
||||
let mut stable_missing = 0_u32;
|
||||
for frame in 1..=frames {
|
||||
let next = vision
|
||||
.capture(
|
||||
@@ -228,7 +182,16 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
|
||||
.await?;
|
||||
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
|
||||
std::fs::write(&output, &next.jpeg)?;
|
||||
if next.completeness.textures_missing == previous_missing {
|
||||
stable_missing += 1;
|
||||
} else {
|
||||
previous_missing = next.completeness.textures_missing;
|
||||
stable_missing = 0;
|
||||
}
|
||||
capture = Some(next);
|
||||
if previous_missing == 0 || (frame >= 8 && stable_missing >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let capture = capture.ok_or("live renderer produced no frame")?;
|
||||
println!("LIVE_RENDER_OUTPUT={}", output.display());
|
||||
@@ -238,10 +201,63 @@ async fn live_bevy_capture_renders_the_target_accounts_scene() -> Result<(), Box
|
||||
.await;
|
||||
|
||||
let _ = renderer_session.logout(renderer_cancel.token()).await;
|
||||
let _ = target_session.logout(target_cancel.token()).await;
|
||||
result
|
||||
}
|
||||
|
||||
struct DiagnosticSceneSource {
|
||||
inner: LibremetaverseSceneSource,
|
||||
material_override: Option<String>,
|
||||
}
|
||||
|
||||
impl SceneSource for DiagnosticSceneSource {
|
||||
fn capture_scene(
|
||||
&self,
|
||||
generation: u64,
|
||||
cancellation: CancellationToken,
|
||||
) -> VisionFuture<'_, SceneSnapshot> {
|
||||
Box::pin(async move {
|
||||
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
|
||||
if let Some(material_override) = &self.material_override {
|
||||
for renderable in scene
|
||||
.entities
|
||||
.iter_mut()
|
||||
.filter(|entity| entity.kind == SceneEntityKind::Object)
|
||||
.flat_map(|entity| &mut entity.renderables)
|
||||
{
|
||||
match &mut renderable.material {
|
||||
metacrate_rendering_wgpu::Material::BlinnPhong(material) => {
|
||||
if material_override == "solid" {
|
||||
*material = metacrate_rendering_wgpu::BlinnPhongMaterial {
|
||||
diffuse_color_srgb: [255, 0, 255, 255],
|
||||
fullbright: true,
|
||||
double_sided: true,
|
||||
..Default::default()
|
||||
};
|
||||
} else if material_override == "unlit-white" {
|
||||
material.diffuse_color_srgb = [255; 4];
|
||||
material.fullbright = true;
|
||||
}
|
||||
}
|
||||
metacrate_rendering_wgpu::Material::Pbr(material) => {
|
||||
if material_override == "solid" {
|
||||
*material = metacrate_rendering_wgpu::PbrMaterial {
|
||||
base_color_srgb: [0, 255, 255, 255],
|
||||
emissive_factor_srgb: [0, 255, 255],
|
||||
metallic_factor: 0.0,
|
||||
roughness_factor: 1.0,
|
||||
double_sided: true,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(scene)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(
|
||||
owner: &LibremetaverseClientOwner,
|
||||
environment: &BTreeMap<String, String>,
|
||||
@@ -336,108 +352,56 @@ fn required(
|
||||
.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>> {
|
||||
fn avatar_forward_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());
|
||||
return Err("avatar facing direction unavailable".into());
|
||||
}
|
||||
let position = Vector3 {
|
||||
x: avatar.x,
|
||||
y: avatar.y,
|
||||
z: avatar.z + 2.0,
|
||||
z: avatar.z + 1.6,
|
||||
};
|
||||
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,
|
||||
x: position.x + forward.x / length * 24.0,
|
||||
y: position.y + forward.y / length * 24.0,
|
||||
z: position.z + forward.z / length * 24.0,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_camera_uses_avatar_eye_position_and_facing() {
|
||||
let avatar = Vector3 {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
z: 30.0,
|
||||
};
|
||||
let (position, target) = avatar_forward_camera(avatar, Vector3::unit_y()).unwrap();
|
||||
assert_eq!(
|
||||
position,
|
||||
Vector3 {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
z: 31.6
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
target,
|
||||
Vector3 {
|
||||
x: 10.0,
|
||||
y: 44.0,
|
||||
z: 31.6
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user