How to Implement World-Stable Heading Projection for Icons in CesiumJS
The gods-eye-view repository solves world-stable heading projection by computing a screen-space rotation from an entity’s ENU forward vector projected onto the camera’s basis, then smoothing the result with a dead-band filter.
Maintaining a consistent icon heading in CesiumJS regardless of camera angle requires world-stable heading projection that decouples the billboard’s orientation from the current view. In the bilawalsidhu/gods-eye-view project, this behavior is implemented in src/data/iconOrientation.js through a set of geometric helpers that transform a world-space course vector into a stable screen rotation. These utilities ensure that icons always point along their true ground track, even when the underlying forward probe falls behind the camera or drifts off-screen.
Core Helpers in src/data/iconOrientation.js
Three inter-related functions in src/data/iconOrientation.js form the backbone of the projection pipeline.
screenProjectedRotation(scene, position, courseDeg, previous?)
This function computes a rotation in radians that aligns an icon with its real-world heading. It first builds a forward probe vector of magnitude FORWARD_PROBE_M (2000 m) in the entity’s local ENU frame using Cesium.Transforms.eastNorthUpToFixedFrame. After transforming this vector into world space, it projects the result onto camera.rightWC and camera.upWC to obtain screen-space components dx and dy. The returned value is atan2(-dx, -dy), which orients the icon so its up direction matches the projected course.
When the probe is off-screen, behind the camera, or yields very small screen components, the helper falls back to the supplied previous rotation to avoid sudden flips.
stabilizeScreenRotation(previous, next, deadbandRad?)
To suppress sub-degree jitter caused by floating-point noise, this helper calculates the shortest wrapped angular difference using atan2(sinΔ, cosΔ). If the absolute difference falls below the dead-band threshold—defaulting to approximately 0.5° represented by ROTATION_DEADBAND_RAD—the previous rotation is kept. Otherwise, the new rotation is accepted, providing visually stable output across frames.
cameraPoseSignature(camera)
This utility generates an inexpensive string signature by rounding the camera position to roughly 10 m and its angles to about 0.06°. The rendering loop compares this signature frame-to-frame to skip expensive recalculations whenever the camera has not moved appreciably.
How World-Stable Heading Projection Works
The projection achieves its stability through five architectural decisions implemented in src/data/iconOrientation.js.
-
Fixed-magnitude world probe. By constructing the forward vector in the ENU frame with a constant length of
FORWARD_PROBE_M = 2000 m, the pipeline guarantees a camera-independent magnitude before any screen-space math occurs. -
Camera-basis projection. Rather than relying on
worldToWindowCoordinates—which fails when the probe point lies behind the camera—the algorithm dots the world forward vector withcamera.rightWCandcamera.upWC. This yields a valid 2-D screen direction for extreme viewing angles and tracked-entity orbits. -
Orthographic approximation. The projection is exact at the viewport center and falls back to an orthographic approximation elsewhere. This ensures continuous rotation when the forward probe goes off-screen, eliminating sudden jumps.
-
Noise guardrails. Two thresholds protect against unstable results:
MIN_SCREEN_COMPONENT_Mdiscards projections with negligible screen components, whileROTATION_DEADBAND_RADfilters out sub-degree rotational noise. -
Horizon utilities. Although separate from heading logic, the same file exports
horizonOccluderandskyBackdropFactorfor labels positioned near the horizon, keeping related geospatial visual effects in one module.
Step-by-Step Implementation
Follow these steps to integrate the orientation pipeline into a CesiumJS application.
-
Import
screenProjectedRotation,stabilizeScreenRotation, andcameraPoseSignaturefromsrc/data/iconOrientation.js. -
Inside the per-frame update, call
screenProjectedRotationwith thescene, the entity’s worldposition, its heading in degrees clockwise from north (courseDeg), and the previous rotation value. -
Pass the raw rotation into
stabilizeScreenRotationto suppress jitter before assigning it to the billboard. -
Cache calculations by comparing the output of
cameraPoseSignature(scene.camera)to the previous frame’s signature; skip recomputation when they match.
Complete Working Example
The snippet below demonstrates a minimal integration that updates a billboard each frame using the helpers from src/data/iconOrientation.js:
import * as Cesium from 'cesium';
import {
screenProjectedRotation,
stabilizeScreenRotation,
cameraPoseSignature,
} from './data/iconOrientation.js';
// Assume `viewer` is an existing Cesium.Viewer
const scene = viewer.scene;
let lastCamSig = '';
let prevRot = null;
// Create a test billboard
const billboard = scene.primitives.add(
new Cesium.BillboardCollection().add({
image: 'plane.png',
position: Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 1000),
// rotation will be set each frame
})
);
// Entity’s course (e.g., 45° clockwise from north)
const courseDeg = 45;
scene.preRender.addEventListener(() => {
const camSig = cameraPoseSignature(scene.camera);
if (camSig !== lastCamSig) {
const pos = billboard.position;
const rawRot = screenProjectedRotation(scene, pos, courseDeg, prevRot);
prevRot = stabilizeScreenRotation(prevRot, rawRot);
billboard.rotation = prevRot;
lastCamSig = camSig;
}
});
Real-World Usage in the Repository
The pipeline is consumed across several data modules in bilawalsidhu/gods-eye-view:
src/data/aircraftIcons.js– Applies the orientation helpers to aircraft billboards.src/data/flights.js– Uses the rotation pipeline for commercial flight icons.src/data/aisLiveVessles.js– Adapts the utilities for AIS vessel markers.src/camera.js– Provides the camera basis vectors (rightWC,upWC) thatscreenProjectedRotationrelies on.
Summary
- The world-stable heading projection lives in
src/data/iconOrientation.jsand centers on three helpers:screenProjectedRotation,stabilizeScreenRotation, andcameraPoseSignature. - A fixed 2000 m ENU forward probe is projected onto
camera.rightWCandcamera.upWCto produce a screen-space angle that remains valid behind the camera. - Sub-degree jitter is eliminated by
stabilizeScreenRotationvia a default dead-band of roughly0.5°. - Camera-pose caching through
cameraPoseSignatureavoids redundant math when the view is static. - The same file also supplies horizon and sky-backdrop utilities for advanced labeling effects.
Frequently Asked Questions
Why not use worldToWindowCoordinates for heading projection?
The worldToWindowCoordinates approach fails when the forward probe point lies behind the camera, which is a common failure mode during tracked-entity orbits. The gods-eye-view implementation instead projects the world vector onto camera.rightWC and camera.upWC, producing a valid screen-space direction regardless of whether the probe is in front of or behind the camera.
How does the system prevent icon jitter from floating-point noise?
The stabilizeScreenRotation helper computes the shortest wrapped angular difference with atan2(sinΔ, cosΔ). If the change is smaller than the default dead-band of about 0.5°, it retains the previous rotation, suppressing sub-degree jitter.
What is the purpose of FORWARD_PROBE_M = 2000?
This constant defines the length of the forward vector built in the entity’s ENU frame before transformation to world space. A fixed, camera-independent magnitude ensures that the projected screen components remain stable and do not vary with distance or view angle.
When should cameraPoseSignature be checked?
Check the signature at the start of your render loop. If it matches the previous frame’s signature, you can skip recomputing rotations for all icons because the camera pose has not changed appreciably.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →