There is something satisfying about taking a wide panoramic photo and then being able to look around inside it — dragging, pinching, and tilting your phone as though you are standing at the very spot the photo was taken. This post is a technical breakdown of exactly how that works in the PanoramaViewer component I built for this site.
We will cover:
The viewer is an Astro component (PanoramaViewer.astro) that accepts an image source and renders it inside a WebGL canvas using Three.js.
The component hooks into two Astro lifecycle events:
astro:page-load — re-run setup after View Transitions navigationastro:before-swap — dispose GPU resources before the DOM is replacedThree distinct coordinate systems interact in this viewer:
WebGL / Three.js world space:
+Y (up)
│
│
│
└──── +X (right)
/
/
+Z (toward viewer)
This is a right-handed coordinate system. The camera starts at the origin looking down -Z.
Spherical coordinates (view state):
lon (λ) = horizontal angle from initial heading, in degrees
lat (φ) = vertical angle from horizon, in degrees
Mapping to Cartesian (for understanding, not used directly):
x = r · cos(φ) · sin(λ)
y = r · sin(φ)
z = r · cos(φ) · cos(λ)
Device orientation (sensor frame):
+Z (up, toward sky when phone flat on table)
│
│
└──── +X (right edge of phone)
/
/
+Y (toward top of phone)
The transformation between these three systems is the central mathematical challenge of the viewer.
An equirectangular (plate carrée) image uses a direct mapping between geographic coordinates and pixel positions:
u=2πλ+π,v=ππ/2−ϕWhere:
This mapping preserves neither area nor angle (it is neither equal-area nor conformal). It introduces severe distortion at the poles, where a single pixel row maps to an entire circle of latitude. This is why we clamp the vertical look angle to 85° — beyond that, the pole singularity makes the texture visually meaningless.
Three.js’s SphereGeometry generates UV coordinates that follow the equirectangular convention:
For a vertex at spherical position (θ,ϕ) where θ is the polar angle from +Y and ϕ is the azimuthal angle:
u=2πϕ,v=1−πθThis means the texture’s left edge maps to ϕ=0, right edge to ϕ=2π, top to θ=0 (north pole), bottom to θ=π (south pole).
When rendering on BackSide, the triangle winding order reverses. For an observer inside the sphere looking outward, the U-axis appears flipped — text reads backwards, left and right are swapped.
The geometric reason: consider a triangle with vertices A,B,C wound counter-clockwise as seen from outside. From inside, the same vertices appear clockwise. Three.js handles the culling correctly with BackSide, but the UV interpolation still follows the original winding, producing a horizontal mirror.
The fix:
tex.wrapS = RepeatWrapping; // enable wrapping modes
tex.repeat.x = -1; // negate U → horizontal flip
Setting repeat.x = -1 inverts the texture coordinate along S (horizontal), effectively un-mirroring the image. RepeatWrapping is required because negative repeat values cause coordinates to exceed the [0,1] range.
For images with aspect ratio ≤ 3:1, we construct a sphere. The key insight is handling partial spheres — images that don’t cover the full 360° × 180°.
Full sphere (2:1 ratio, e.g., 8000×4000 px):
θstart=0,θlength=πPartial sphere (ratio between 1.5:1 and 3:1):
The vertical angular coverage is proportional to how much of the “ideal” 2:1 height the image provides:
const fullHeight = imgW / 2; // pixel height for a full 180° sphere
Why imgW / 2? A full equirectangular image covers 360° horizontally and 180° vertically. If the width represents 360°, then the height that would represent 180° is exactly half the width (since 180°/360°=1/2).
const thetaLength = Math.min(Math.PI, (imgH / fullHeight) * Math.PI);
This is the vertical arc in radians. The Math.min(π, ...) guard prevents exceeding a hemisphere if the image is taller than 2:1.
const thetaStart = (Math.PI - thetaLength) / 2;
We centre the partial sphere vertically. If we have 120° of vertical coverage, the band runs from 30° to 150° (measured from the north pole), placing the horizon at the centre.
Geometry parameters explained:
new SphereGeometry(
500, // radius — arbitrary; only rotation matters
60, // widthSegments — 6° per segment at equator
40, // heightSegments — ~4.5° per segment vertically
0, // phiStart — begin at 0° longitude
Math.PI * 2, // phiLength — full 360° sweep
thetaStart, // thetaStart — top edge polar angle
thetaLength // thetaLength — vertical arc
);
The segment counts determine tessellation quality. At 60 horizontal segments, each quad subtends 6° — small enough that the polygonal silhouette is invisible at any reasonable FOV (the minimum vertex-to-vertex distance at radius 500 is 2×500×sin(3°)≈52 units, far smaller than any visible pixel).
For aspect ratios > 3:1, spherical mapping fails — an image that is 10:1 would waste most sphere surface on empty polar caps. Instead, we use a cylinder.
Height derivation:
The cylinder’s circumference equals the angular coverage of the image (360° horizontal). The height must preserve the image’s aspect ratio when the texture wraps around:
C=2πr=2π×500≈3141.59 units h=C×WimgHimgFor a 9000×1000 image (9:1 ratio): h=3141.59×90001000≈349 units.
This means the cylinder’s height-to-circumference ratio matches the image’s height-to-width ratio, ensuring each pixel maps to an equal-area quad on the cylinder surface.
Vertical look clamping for cylinders:
const vertAngleDeg = (imgH / imgW) * 360;
// For 9:1 image: vertAngleDeg = 40°
LAT_MAX = Math.min(85, vertAngleDeg / 2 - 5);
// LAT_MAX = min(85, 15) = 15°
The - 5 margin prevents the camera from seeing past the cylinder’s open edges, which would show the void (black background).
FOV adjustment for strip panoramas:
fov = Math.min(FOV_MAX, Math.max(FOV_MIN, vertAngleDeg * 0.9));
If the cylinder only covers 40° vertically, we set the initial FOV to 36° — filling the viewport without exposing the edges. This creates an immediate “peephole” effect that feels intentional.
The choice of radius is arbitrary because the camera never moves — it only rotates. Perspective division in the projection matrix cancels out the radius:
Projected size∝rr=1Any radius works, but very small values (e.g., 1) can cause floating-point precision issues with the near/far clipping planes, and very large values waste depth buffer precision. 500 is a pragmatic middle ground.
Three.js’s PerspectiveCamera constructs a perspective projection matrix:
Where:
The relationship between FOV and magnification:
magnification=tan(fov/2)1| FOV | Magnification | Equivalent lens (35mm) |
|---|---|---|
| 110° | 0.57× | ~12mm ultra-wide |
| 90° | 1.0× | ~18mm wide |
| 60° | 1.73× | ~35mm normal |
| 30° | 3.73× | ~75mm telephoto |
The viewer’s FOV range (30°–110°) provides roughly a 6.5× zoom range.
The camera rotation is encoded as Euler angles with order "YXZ":
camera.rotation.order = "YXZ";
camera.rotation.set(lat * DEG2RAD, -lon * DEG2RAD, 0, "YXZ");
Decomposed into rotation matrices:
R=RY(−λ)⋅RX(ϕ)⋅RZ(0) RY(−λ)=cosλ0sinλ010−sinλ0cosλ RX(ϕ)=1000cosϕsinϕ0−sinϕcosϕThe YXZ order means: first rotate around Y (yaw), then around the new X (pitch). This is crucial — if we applied pitch first (XYZ order), then yaw would rotate around the world Y axis rather than the camera’s local Y axis, causing unintuitive behaviour when looking up/down.
The negation in -lon * DEG2RAD compensates for the left-hand vs. right-hand rotation convention. Dragging right increments lon (turn right), but in Three.js’s right-handed system, a positive Y rotation turns the camera left. The negation corrects this.
The critical formula for converting pixel drag to angular rotation:
const fovScale = fov / 90;
const dx = (e.clientX - pointerX) * DRAG_SENSITIVITY * fovScale;
Why fovScale? Consider what one pixel represents at different zoom levels:
At fov = 90°, the viewport spans 90° vertically. If the canvas is 800px tall, one pixel ≈ 0.1125°.
At fov = 30°, one pixel ≈ 0.0375° — three times finer.
Without fovScale, dragging would feel three times faster when zoomed in, making precise control impossible. The correction:
Where S=0.105 is the base sensitivity constant.
The momentum system models a simple friction-decayed velocity:
On drag (each pointermove):
velocityX = -dx; // instantaneous velocity = displacement this frame
velocityY = dy;
lon += velocityX;
lat += velocityY;
After release (each animation frame):
velocityX *= FRICTION; // FRICTION = 0.924
velocityY *= FRICTION;
lon += velocityX;
lat += velocityY;
This is a discrete-time first-order system. The velocity at frame n after release:
vn=v0×fnWhere f=0.924. Solving for the number of frames to reach fraction p of initial velocity:
n=ln(f)ln(p)=ln(0.924)ln(p)| Decay to | Frames | Time at 60fps |
|---|---|---|
| 50% | 8.8 | 146ms |
| 10% | 29.1 | 485ms |
| 1% | 58.2 | 970ms |
Minimum velocity threshold:
if (Math.abs(velocityX) > MIN_VELOCITY || Math.abs(velocityY) > MIN_VELOCITY) {
// continue momentum
} else {
// velocity is imperceptible; stop updating
}
MIN_VELOCITY = 0.01°/frame corresponds to 0.6°/sec — far below perceptual threshold. This prevents the animation loop from making unnecessary render calls when the panorama has effectively stopped.
Pinch distance between two fingers:
d=(Δx)2+(Δy)2=Math.hypot(x1−x2,y1−y2)Change in FOV proportional to change in distance:
Δfov=(dprev−dcurr)×0.1When fingers move apart (dcurr>dprev), Δfov is negative → FOV decreases → zoom in. This matches the natural mental model of “spreading to magnify.”
The viewer tracks all active pointers in a Set<number>:
const activePointers = new Set<number>();
This solves several edge cases:
The isPinching flag prevents the pointermove handler from treating two-finger gestures as drag:
el.addEventListener("pointermove", (e) => {
if (activePointers.size === 0 || isPinching) return;
// ... drag logic only runs for single-pointer
});
Modern smartphones contain:
The browser’s sensor fusion algorithm (typically a complementary or Kalman filter) combines these to produce the DeviceOrientationEvent with three Euler angles: alpha, beta, gamma.
Important limitations:
alpha (compass) is relative to magnetic north — noisy near metalbeta has a discontinuity at ±180° (phone flipped upside down)gamma is limited to ±90° — cannot distinguish 45° and 135° tiltThe W3C spec defines the device coordinate system:
Phone held in portrait, screen facing user:
+Y (top edge)
│
│
●──── +X (right edge)
/
+Z (out of screen, toward user)
The Earth frame for alpha/beta/gamma:
alpha = 0°: phone pointing northbeta = 0°: phone flat on tablegamma = 0°: phone not tilted left/rightThe Euler rotation order defined by the spec is Z-X’-Y” (intrinsic rotations):
alpha around Earth’s Z axis (compass heading)beta around the new device X axis (front-back tilt)gamma around the new device Y axis (left-right tilt)This is the most mathematically dense part of the viewer. Let us trace through setQuaternionFromDeviceOrientation step by step.
Step 1: Euler → Quaternion in device frame
motionEuler.set(beta, alpha, -gamma, "YXZ");
target.setFromEuler(motionEuler);
Wait — the spec says Z-X’-Y”, but we are using "YXZ"? And the angles are reordered?
This is because Three.js uses extrinsic (fixed-axis) rotation for its Euler class, while the device spec uses intrinsic (body-axis) rotations. The equivalence:
Rintrinsic(Z,X′,Y′′)=Rextrinsic(Y,X,Z)Reversing the order converts intrinsic to extrinsic. And in Three.js Euler notation:
beta (X rotation)alpha (Y rotation)-gamma (Z rotation, negated due to axis convention)The negation of gamma accounts for the difference between the W3C right-hand rotation around Y and Three.js’s convention.
Step 2: Camera adjustment quaternion
target.multiply(motionCameraAdjustment);
// motionCameraAdjustment = new Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5))
This quaternion represents a -90° rotation around the X axis. In quaternion form:
q=cos(−45°)+sin(−45°)⋅i^=22+(−22)⋅i^In Three.js Quaternion(x, y, z, w) format: (−0.5,0,0,0.5).
Why -90° around X? The device orientation API assumes the “default” device posture is flat on a table (screen up, Z pointing to sky). In this posture, the camera should look at the horizon (forward along -Z in camera space).
A phone lying flat has its screen-normal (+Z_device) pointing up (+Y_world). We need to rotate so that “device Z = up” becomes “camera -Z = forward”. A -90° pitch around X does exactly this:
Step 3: Screen orientation compensation
target.multiply(motionScreenQuaternion.setFromAxisAngle(motionZAxis, -orient));
When the device rotates from portrait to landscape, the screen pixels rotate but the gyroscope doesn’t know about this. The screen.orientation.angle (0, 90, 180, 270) tells us how much the rendering has rotated, and we apply an equal-and-opposite Z rotation:
orient = 0° → no correctionorient = 90° → rotate -90° around Zorient = 180° → rotate -180° around Zorient = 270° → rotate -270° around ZThe device orientation gives us an absolute orientation (relative to magnetic north + gravity). But the user might have their phone pointing east while looking north in the panorama. We need a mapping from device space to view space.
Calibration formula:
qcorrection=qcamera⋅qdevice−1Where:
Application each frame:
qtarget=qcorrection⋅qdevice,newProof that this works:
At calibration time, qdevice,new=qdevice:
qtarget=qcamera⋅qdevice−1⋅qdevice=qcamera⋅I=qcameraThe target equals the current camera — no movement occurs. Correct.
Now if the device rotates by some Δq (so qdevice,new=qdevice⋅Δq):
qtarget=qcamera⋅qdevice−1⋅qdevice⋅Δq=qcamera⋅ΔqThe camera rotates by Δq — the same rotation as the device. Correct.
This is why the correction quaternion is a full 3-axis offset. A simpler yaw-only correction would fail when the phone tilts — pitch/roll offsets would accumulate as error.
The correction quaternion is recomputed whenever hasMotionTarget is cleared:
function resetMotionOrigin() {
hasMotionTarget = false; // next orientation event will recalibrate
velocityX = 0;
velocityY = 0;
}
Recalibration happens after three scenarios:
On the next deviceorientation event after hasMotionTarget = false:
if (!hasMotionTarget) {
motionCorrectionQuaternion
.copy(camera.quaternion)
.multiply(motionDeviceQuaternion.clone().invert());
}
This makes the new device position correspond to the current view — seamless transitions with no visible jump.
The problem with linear interpolation (LERP) on quaternions:
Quaternions represent rotations on the unit hypersphere S3 in 4D space. Linear interpolation in 4D does not follow the shortest path on the sphere — it “cuts through” the interior, producing non-unit quaternions (which must be renormalized) and non-constant angular velocity.
SLERP definition:
For unit quaternions q0 and q1 with angle Ω between them:
slerp(q0,q1,t)=sinΩsin((1−t)Ω)q0+sinΩsin(tΩ)q1Where:
Ω=arccos(q0⋅q1)The dot product q0⋅q1 is the 4D inner product. When the quaternions are close (Ω≈0), SLERP degenerates numerically — Three.js falls back to NLERP (normalize-after-lerp) in this case.
Exponential smoothing via repeated SLERP:
The viewer applies SLERP with a fixed t=0.18 every frame:
camera.quaternion.slerp(motionTargetQuaternion, 0.18);
This is not a one-shot interpolation from start to end. It is an exponential chase filter:
qn+1=slerp(qn,qtarget,t)The angular distance decreases geometrically each frame:
Ωn=(1−t)n⋅Ω0At t=0.18:
The convergence threshold:
if (1 - Math.abs(camera.quaternion.dot(motionTargetQuaternion)) > 0.000001) {
camera.quaternion.slerp(motionTargetQuaternion, MOTION_SMOOTHING);
}
The expression 1−∣q1⋅q2∣ measures angular distance. For unit quaternions:
∣q1⋅q2∣=cos(Ω/2)So 1−∣q1⋅q2∣>0.000001 corresponds to:
Ω>2arccos(1−0.000001)≈0.16°Below this threshold, we skip the SLERP and consider the camera “arrived” — preventing infinite micro-updates that waste GPU cycles.
After the camera is rotated by SLERP (in quaternion space), we must sync back to the lon/lat representation so that:
camera.rotation.set(...)) starts from the right positionfunction syncLonLatFromCamera() {
motionEuler.setFromQuaternion(camera.quaternion, "YXZ");
lon = -motionEuler.y / DEG2RAD;
lat = Math.max(-LAT_MAX, Math.min(LAT_MAX, motionEuler.x / DEG2RAD));
}
This decomposes the camera quaternion back into Euler angles using the same “YXZ” order, then extracts lon and lat. The clamping on lat ensures the vertical limits are respected even when the gyroscope reports an orientation beyond the allowed range.
The interaction between manual drag and gyroscope-driven motion is the most subtle engineering challenge. Here is the complete state machine:
The camera orientation is stored in two forms simultaneously:
lon, lat) — used during manual controlcamera.quaternion) — used during gyro trackingThese must stay synchronized. The rules:
| Action | Updates | Syncs to |
|---|---|---|
| Pointer drag | lon, lat → camera Euler | (quaternion computed internally by Three.js) |
| Gyro event | motionTargetQuaternion | After SLERP: syncLonLatFromCamera() |
| Keyboard | lon, lat → camera Euler | (quaternion computed internally) |
The manualViewDirty flag ensures that camera.rotation.set() is only called when the Euler values have changed (not when gyro SLERP is active):
if (moved || needsRender) {
if (manualViewDirty) {
camera.rotation.set(lat * DEG2RAD, -lon * DEG2RAD, 0, "YXZ");
manualViewDirty = false;
}
renderer.render(scene, camera);
}
During gyro tracking, manualViewDirty is false — the camera quaternion is set directly by SLERP without going through the Euler path. This prevents the two systems from fighting.
If the user drags while motion is active:
pointermove fires → lon/lat update → manualViewDirty = truedeviceorientation events keep firing → motionTargetQuaternion updatesSolution: The SLERP only runs when activePointers.size === 0:
if (motionEnabled && activePointers.size === 0 && !isPinching && hasMotionTarget) {
camera.quaternion.slerp(motionTargetQuaternion, MOTION_SMOOTHING);
}
On pointerup, resetMotionOrigin() clears hasMotionTarget. The next deviceorientation event recalibrates — the new correction maps the device’s current orientation to wherever the user dragged to. No jump occurs.
A pinch gesture involves two fingers and often causes the user to inadvertently rotate the device. Without protection, this causes:
Three-layer protection:
Layer 1 — Suppress orientation events during pinch:
function onDeviceOrientation(e) {
if (isPinching) return; // drop the event entirely
// ...
}
Layer 2 — Lock screen angle during pinch:
el.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) {
cachedScreenAngle = screenAngle(); // freeze current value
}
});
The screenAngle() function returns the cached value during pinch:
function screenAngle() {
if (cachedScreenAngle !== null) return cachedScreenAngle;
return screen.orientation?.angle ?? window.orientation ?? 0;
}
This prevents a rotation axis shift mid-gesture if the browser reports an orientation change (e.g., threshold between portrait and landscape is crossed during the pinch).
Layer 3 — Recalibrate on pinch end:
el.addEventListener("touchend", (e) => {
if (e.touches.length < 2 && isPinching) {
isPinching = false;
cachedScreenAngle = null; // unlock
if (motionEnabled) resetMotionOrigin(); // recalibrate
}
});
When a pinch ends and one finger remains, the pointermove handler would see a huge delta between the current finger position and where pointerX/Y were last set (by the initial pointerdown). This causes a violent pan jump.
Fix: Re-sync pointer position to remaining finger:
if (e.touches.length === 1) {
pointerX = e.touches[0].clientX;
pointerY = e.touches[0].clientY;
}
When motion mode is active and the user flicks (drag-release with velocity), both momentum and SLERP want to control the camera. The order of operations in animate():
But after a drag+release, resetMotionOrigin() clears hasMotionTarget. So SLERP is skipped until the next orientation event triggers recalibration. During this gap (typically 1 frame at 60Hz), momentum runs freely. Then the first recalibrated SLERP smoothly takes over, incorporating the momentum-shifted position as the new baseline.
Cause: GPU driver crash, too many active WebGL contexts (browsers limit to ~8-16), extremely old hardware, or GPU blocklisted by the browser.
Detection:
try {
renderer = new WebGLRenderer({ canvas, antialias: false, alpha: false });
} catch (e) {
// GPU unavailable
}
Recovery:
Possible causes:
Detection: Three.js’s TextureLoader.load error callback:
texLoader.load(src, onSuccess, onProgress, (err) => {
// Determine specific failure cause
if (!navigator.onLine) {
message = "You appear to be offline.";
} else if (loadTimedOut) {
message = "Loading timed out.";
} else {
message = "Failed to load panorama. The file may be missing or inaccessible.";
}
});
Recovery:
Cause: Slow connection + large image (16K panoramas can be 15-30MB).
Detection: 30-second timer that fires independently of the load callbacks:
const LOAD_TIMEOUT_MS = 30000;
const loadTimer = setTimeout(() => {
loadTimedOut = true;
errorEl.textContent = "Loading is taking longer than expected.";
}, LOAD_TIMEOUT_MS);
Behaviour: The timeout does NOT cancel the load — it only shows a warning. If the image eventually loads, clearTimeout(loadTimer) runs in the success callback and the viewer proceeds normally. This handles the case where the connection is merely slow, not dead.
On iOS 13+, DeviceOrientationEvent requires explicit permission:
const MotionEvent = window.DeviceOrientationEvent as DeviceOrientationEventWithPermission;
if (MotionEvent?.requestPermission) {
const permission = await MotionEvent.requestPermission();
if (permission !== "granted") return; // silently bail
}
Behaviour: If the user denies permission, the motion button simply doesn’t activate. No error shown — the manual drag/keyboard controls remain fully functional.
Detection:
if ("DeviceOrientationEvent" in window && window.matchMedia("(pointer: coarse)").matches) {
motionButton.hidden = false; // show button
}
Two conditions must be met:
"DeviceOrientationEvent" in window)If either condition fails, the motion button stays hidden — the user never sees a feature that wouldn’t work.
Sensor events can fire with null values (e.g., magnetometer not ready):
function onDeviceOrientation(e) {
if (e.alpha === null || e.beta === null || e.gamma === null) return;
// ...
}
This guard prevents NaN from propagating into the quaternion math. A single NaN in a quaternion corrupts all subsequent SLERP computations (NaN is infectious in floating-point arithmetic).
Problem: When the tab is hidden, requestAnimationFrame stops firing on most browsers. When it resumes, accumulated deviceorientation events could cause a sudden jump.
Solution:
const onVisChange = () => {
paused = document.hidden;
if (!paused) needsRender = true;
};
When the tab becomes visible again, needsRender = true triggers a single render. The SLERP naturally smooths any accumulated orientation difference — the camera glides to the new device orientation over ~200ms rather than jumping.
Problem: Astro’s View Transitions replace the DOM but don’t automatically clean up WebGL contexts, event listeners, or GPU memory.
Solution:
const cleanup = () => {
destroyed = true; // stops animation loop
ro.disconnect(); // ResizeObserver
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
document.removeEventListener("visibilitychange", onVisChange);
motionButton?.removeEventListener("click", onMotionButtonClick);
window.removeEventListener("deviceorientation", onDeviceOrientation, true);
scene.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose(); // free GPU vertex buffers
if (obj.material.map) obj.material.map.dispose(); // free texture VRAM
obj.material.dispose(); // free material uniform buffers
}
});
renderer.dispose(); // release WebGL context
};
document.addEventListener("astro:before-swap", cleanup, { once: true });
The destroyed = true flag ensures the requestAnimationFrame loop exits immediately:
function animate() {
if (destroyed) return; // no further frames scheduled
requestAnimationFrame(animate);
// ...
}
Problem: With View Transitions, the astro:page-load event fires on the same page if the user navigates back. The component script would run again, creating a second WebGL context on the same canvas.
Solution:
if (el.dataset.panoInitialized === "true") return;
el.dataset.panoInitialized = "true";
The data attribute persists in the DOM across script re-executions within the same page lifecycle, preventing duplicate setup.
Problem: High-DPI devices (3×, 4×) would create enormous framebuffers (e.g., 4K × 4K canvas), consuming excessive GPU memory and thermal budget.
Solution:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Clamping at 2× ensures sharp rendering on most devices while preventing the pathological 4× case. The visual difference between 2× and 3× is imperceptible for a panorama texture (the texture resolution is the limiting factor, not the canvas resolution).
The animation loop runs at the display refresh rate but only issues GPU draw calls when necessary:
The needsRender flag is set by external events (resize, zoom, initial load) that don’t flow through the momentum/SLERP/keyboard paths. This ensures a render happens even when the camera didn’t move but the viewport changed.
GPU power savings: On a static view (no interaction, motion off), zero gl.drawArrays calls are made. The requestAnimationFrame callback still runs (for input polling), but costs only a few microseconds of CPU time. The GPU is effectively idle.
The geometry is deceptively simple — a sphere or cylinder with an image on the inside. The real engineering is in the synchronization layer: making quaternion-space gyro tracking coexist peacefully with Euler-space manual control, handling the dozen edge cases where these two systems could conflict, and failing gracefully when hardware or network conditions are not ideal.
Presentation
A deep dive into the geometry, projection math, and gyroscope-driven motion mode behind a WebGL panorama viewer built as an Astro component using Three.js.
June 24, 2026