From 35a27140256ed806a8fd08bcebaea386e9bd2da0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 10 Aug 2026 02:36:28 +0300 Subject: [PATCH] fix(camera): keep the eye's coordinates out of the view basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drawing placed at UTM shimmered as the camera moved. The relative-to-eye path exists so the large eye translation never enters an f32 matrix, and it zeroed the translation column to make sure. But the basis was built first, by `look_at(eye, target)` with both positions cast to f32 — and at (639792, 4517057) that grid is half a metre across in y. Rounding both ends and then subtracting them left a direction that changed as the camera moved, so the whole view basis jittered. Zeroing the translation afterwards came too late to help: the damage was in the rotation. It never needed the positions. The eye is `target + rotation·Z·distance`, so the direction between them is the rotation's own Z — a unit vector carrying no coordinate at all. Two tests. Where the camera stands must not reach the matrix, which is the property being restored; and at the origin, where the old construction had no large coordinates to lose, the two must agree exactly — which is what says this kept the same basis rather than turning the view round. Co-Authored-By: Claude Opus 5 --- src/scene/view/camera.rs | 81 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 287ca5c2a..b96a69415 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -276,8 +276,20 @@ impl Camera { let aspect = bounds.width / bounds.height; let up_dir = self.rotation * Vec3::Y; - let mut view = look_at_mat4(self.eye().as_vec3(), self.target.as_vec3(), up_dir); - // Zero the translation column → pure rotation (world→view basis). + // The basis, from the rotation alone. Zeroing the translation column + // afterwards is not enough: `look_at` builds the basis out of + // `target - eye`, and handing it two absolute positions cast to f32 + // rounds both to the f32 grid first. At UTM that grid is half a metre + // across, so the difference — and with it the whole view basis — + // jitters as the camera moves, which is the shimmer a survey-placed + // drawing showed. + // + // It never needed them. `eye` is `target + rotation·Z·distance`, so + // the direction between the two is the rotation's own Z, a unit + // vector that carries no coordinate at all. + let mut view = look_at_mat4(Vec3::ZERO, -(self.rotation * Vec3::Z), up_dir); + // Already zero, since the eye is the origin here; kept so the invariant + // survives someone reworking the line above. view.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); let proj = match self.projection { @@ -865,3 +877,68 @@ mod tests { ); } } + +#[cfg(test)] +mod rte_tests { + use super::*; + use iced::Rectangle; + + /// The relative-to-eye view carries rotation and nothing else, so where the + /// camera stands must not reach it at all. + /// + /// It used to. The basis came from `look_at(eye, target)` with both cast to + /// f32, and at survey coordinates that grid is half a metre across — so the + /// difference between them, and the whole basis, jittered as the camera + /// moved. A drawing placed at UTM shimmered. + #[test] + fn the_view_basis_does_not_depend_on_where_the_camera_stands() { + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1600.0, + height: 900.0, + }; + let mut at_origin = Camera::default(); + at_origin.target = glam::DVec3::ZERO; + + let mut at_utm = at_origin.clone(); + at_utm.target = glam::DVec3::new(639_792.184_2, 4_517_057.531_7, 12.5); + + let here = at_origin.view_proj_rte(bounds); + let there = at_utm.view_proj_rte(bounds); + for (a, b) in here.to_cols_array().iter().zip(there.to_cols_array().iter()) { + assert_eq!(a, b, "the camera's position reached the view matrix"); + } + } + + /// And it is the same basis as before, which the origin proves: there the + /// old construction had no large coordinates to lose, so the two must + /// agree exactly. Anything else would mean the refactor turned the view + /// round rather than just keeping the coordinates out of it. + #[test] + fn the_basis_matches_what_two_positions_gave_where_they_were_exact() { + for (yaw, pitch) in [(0.0, 1.2), (0.7, 0.3), (-2.1, -0.9)] { + let mut camera = Camera::default(); + camera.target = glam::DVec3::ZERO; + camera.rotation = + Quat::from_rotation_z(yaw) * Quat::from_rotation_x(pitch); + let up_dir = camera.rotation * Vec3::Y; + + let mut old = look_at_mat4( + camera.eye().as_vec3(), + camera.target.as_vec3(), + up_dir, + ); + old.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); + let new = { + let mut view = + look_at_mat4(Vec3::ZERO, -(camera.rotation * Vec3::Z), up_dir); + view.w_axis = glam::vec4(0.0, 0.0, 0.0, 1.0); + view + }; + for (a, b) in old.to_cols_array().iter().zip(new.to_cols_array().iter()) { + assert!((a - b).abs() < 1e-6, "yaw {yaw} pitch {pitch}: {a} vs {b}"); + } + } + } +}