Ease a late zoomed tilt to its new limit instead of jumping there

From 400 km, a tilt to -5° that a zoom out x2.5 caught at -8.0005° reported -8°, and the next frame jumped to -9.407° and ended there. The zoom kept the camera's pitch as the goal when it was already past the new range's horizon limit, but at the same pivot pitch the longer range looks down more steeply, so the next lookAt dropped the camera 1.4° at once.

The zoom now re-solves the pivot pitch so the new range keeps the camera's pitch, and aims the tilt at the asked angle or the new limit, even when that limit is below where the camera stands: the tilt eases down to it. The zoom's reply names that end, and the camera reaches it with no frame moving more than 0.1°.
This commit is contained in:
Sameh Khamis committed 2026-11-05 16:31:46 -08:00
1 parent 198c2faf4f
commit 3438a88cc7
3 files changed
+72 -13

No files matched your search

+29
View File
@@ -686,3 +686,32 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async
);
assert.match(zoom.note, /horizon/);
});
// Late in the tilt the camera is already past the angle the new range's
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8° and the
// next frame jumped to -9.4°): it eases to that limit with no jump, and the
// zoom's reply names the angle it ends at, not the one it stood at.
test('a late zoom out during a tilt eases to the new limit and reports it', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
let last = pitchDeg(h.camera);
let maxStep = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
last = pitchDeg(h.camera);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(maxStep < 0.5, `pitch jumped ${maxStep}° in one frame`);
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
// Replies give whole degrees: the reported angle is the reached one rounded.
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
+42 -12
View File
@@ -1378,12 +1378,22 @@ function tiltClearanceBinds(range) {
*/
function tiltUpLimit({ target, pivot }) {
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
const pitch = Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range));
// The camera's position for that pivot pitch, as lookAt places it.
return Math.min(
PITCH_MAX,
pivotCameraPitch(
target,
pivot,
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
),
);
}
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
function pivotCameraPitch(target, { heading, range }, pitch) {
const offset = new Cesium.Cartesian3(
-pivot.range * Math.cos(pitch) * Math.sin(pivot.heading),
-pivot.range * Math.cos(pitch) * Math.cos(pivot.heading),
-pivot.range * Math.sin(pitch),
-range * Math.cos(pitch) * Math.sin(heading),
-range * Math.cos(pitch) * Math.cos(heading),
-range * Math.sin(pitch),
);
const eye = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
@@ -1394,9 +1404,8 @@ function tiltUpLimit({ target, pivot }) {
Cesium.Cartesian3.normalize(look, look);
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
return Math.min(
PITCH_MAX,
Math.asin(Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1)),
return Math.asin(
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
);
}
@@ -2092,16 +2101,37 @@ export function adjustOrbitRange(factor) {
/**
* A zoom changed the range a tilt to an angle holds, and with it the place's
* horizon limit: aim the tilt at the angle asked for or the new limit,
* whichever comes first, from where the camera is now. Returns the end angle
* and a note for the reply when that end moved, otherwise nothing.
* whichever comes first, from where the camera is now. The pivot pitch is
* re-solved so the new range keeps the camera's pitch, and a limit the camera
* is already past is eased down to rather than jumped to. Returns the end
* angle and a note for the reply when that end moved, otherwise nothing.
*/
function retargetTiltAfterZoom(m) {
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
return {};
const from = _viewer.camera.pitch;
if (!(m.askedPitch > from)) return {};
const limit = tiltUpLimit({ target: m.target, pivot: m.hpr });
const goal = Math.min(m.askedPitch, Math.max(from, limit));
if (m.hpr) {
// At the same pivot pitch a longer range looks down more steeply (the
// Earth curves away): find the pivot pitch that holds this camera pitch.
for (let i = 0; i < 4; i += 1) {
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
if (Math.abs(error) < 1e-7) break;
m.hpr.pitch = Cesium.Math.clamp(
m.hpr.pitch + error,
-Cesium.Math.PI_OVER_TWO,
0,
);
}
}
const limit =
m.hpr && !tiltClearanceBinds(m.hpr.range)
? Math.min(
PITCH_MAX,
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
)
: PITCH_MAX;
const goal = Math.min(m.askedPitch, limit);
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
m.goalPitch = goal;
m.startPitch = from;