跪拜 Guibai
← Back to the summary

Cesium Dual-Frustum Gimbal Preview Replaces Guesswork with Visual Aiming for Drone Routes

Our company works in the drone business, and one part of it is route planning: users place waypoints on a map and assign actions to each waypoint, such as flying there to take a photo.

Taking a photo inevitably involves one problem — where exactly the gimbal camera is pointing. The three parameters of pitch angle, yaw angle, and whether to zoom determine whether the photo captures the target or just a patch of grass.

In the early days, we just provided three input boxes for users to fill in numbers. After filling them in, no one knew if they were correct; we could only fly a mission to see the results and then come back to adjust. DJI's ground station software handles this in a very mature way: draw the camera's view frustum on the map, and place a hawk-eye preview in the lower right corner; you can align it by dragging. We implemented a version based on this idea.

This article explains how this system was built: the overall structure and key code. In fact, our completed project is much more polished than this, with all route functions and other features basically in place. This time, I'm just taking this technical point out to discuss it separately. Corrections are welcome.

The code uses Cesium and is ultimately a single-file HTML that runs locally with a double-click. The article includes key code snippets; the complete file is too long, so those who need it can find it at the end of the article.

The effect is roughly: a camera with two frustums on the 3D map on the left, and a small hawk-eye window in the lower right corner. Dragging the view in the hawk-eye window makes the frustums on the map rotate together.

image.png

image.png

image.png

1. Why Just Filling in Numbers Doesn't Work

When configuring a gimbal at a waypoint, users actually need to answer three things:

These three values are easy to understand individually, but they become abstract when combined. No one can calculate what exactly will be in the frame with a pitch of -30°, a yaw of 70°, and a 3x zoom.

Drawing the view frustum onto the map makes it intuitive: you can see at a glance how large an area that semi-transparent cone covers on the ground. Paired with a hawk-eye window, it's like previewing the camera feed in advance, allowing you to adjust by dragging.

Parameter adjustment shifts from "guessing numbers" to "see where you're pointing, shoot what you see" — that's the entire purpose of this feature.


2. Overall Architecture

The tech stack is very restrained: one HTML file, Cesium, and two Viewers.

The most critical rule: the data flow must be unidirectional.

User operation (drag / scroll wheel / slider)
    ↓
Modify state
    ↓
scheduleRender()
    ↓
Main map redraws frustums + Hawk-eye camera aligns + HUD refreshes readings

Never let the main map and hawk-eye calculate independently. I initially took a shortcut and directly read hawkViewer.camera.heading after adjusting the angle in the hawk-eye to update the frustum in reverse. As a result, they would mismatch at certain angles, and debugging took a long time. Later, I unified it so that "only the state is the source of truth," and the problem disappeared.

The state looks like this:

const state = {
  placed: false,          // Whether the camera has been placed
  lon: 114.0663243,
  lat: 30.4834225,
  groundHeight: 0,        // Terrain elevation at the ground point (absolute height)
  relativeHeight: 120,    // Camera height above ground
  heading: 70,            // Yaw angle
  pitch: 0,               // Pitch angle, negative values point downwards
  roll: 0,
  ptz: 5,                 // Zoom factor
  isFocus: false,         // false=wide-angle mode, true=zoom mode
  terrainDistance: 0,     // Distance from the line-of-sight center to the ground
  far: 300,               // Frustum far plane
};

The render entry point uses requestAnimationFrame for batching, because pointermove events fire very densely during dragging, and rebuilding frustum primitives every time would cause noticeable frame drops:

let renderHandle = 0;

/** Call this after state changes; multiple calls within the same frame are merged into one */
function scheduleRender() {
  if (renderHandle) return;
  renderHandle = requestAnimationFrame(() => {
    renderHandle = 0;
    renderMainScene();  // Main map: height pole + dual frustums
    renderHawkScene();  // Hawk-eye: camera orientation + field of view
    renderHud();        // UI: sliders, framing box, readings
  });
}

3. How to Draw the Frustum

In Cesium, frustums are drawn using FrustumGeometry (filled) paired with FrustumOutlineGeometry (outline).

function addFrustum({ fov, far, color, outlineColor }) {
  const origin = getCameraPosition();
  const orientation = getFrustumOrientation();

  const frustum = new Cesium.PerspectiveFrustum({
    fov: Cesium.Math.toRadians(fov),
    aspectRatio: 16 / 9,   // Camera sensor aspect ratio
    near: 0.5,
    far: far,
  });

  const fill = mainViewer.scene.primitives.add(
    new Cesium.Primitive({
      geometryInstances: new Cesium.GeometryInstance({
        geometry: new Cesium.FrustumGeometry({
          frustum, origin, orientation,
          vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
        }),
        attributes: {
          color: Cesium.ColorGeometryInstanceAttribute.fromColor(
            Cesium.Color.fromCssColorString(color)
          ),
        },
      }),
      appearance: new Cesium.PerInstanceColorAppearance({
        flat: true, translucent: true, closed: true,
      }),
      // Create synchronously, otherwise the frustum flickers with a delay during dragging
      asynchronous: false,
    })
  );
  // The outline is similar, using FrustumOutlineGeometry
}

One detail worth mentioning: asynchronous defaults to true, meaning Cesium offloads geometry creation to a worker. This is fine for static scenes, but during dragging, geometry is rebuilt every frame, and async causes the frustum to flicker. Setting it to false stabilizes it.

How far to extend the far plane also needs calculation. Too short, and the frustum floats in mid-air; too long, and it pierces through the Earth:

/**
 * Estimate the approximate line-of-sight distance to the ground based on pitch angle,
 * then enlarge it slightly so the frustum visually appears to "hit the ground."
 * When pitch is near 0°, the line of sight barely hits the ground, so fall back to a fixed multiple of the height.
 */
function computeFar() {
  const pitchRad = Math.abs(Cesium.Math.toRadians(state.pitch));
  const toGround = pitchRad > 0.05
    ? state.relativeHeight / Math.sin(pitchRad)
    : state.relativeHeight * 4;

  return clamp(toGround * 1.35, 80, 2000);
}

4. Why Two Frustums Are Drawn

This is the most frequently asked question, so I'll explain it separately.

Green is the current actual field of view, which follows the zoom level; it becomes narrower as the zoom factor increases. Yellow is a wide-angle reference frame with a fixed field of view (28° in the demo), which does not change with zoom.

Yellow is not a second set of parameters; it's a ruler: it lets you know "the wide-angle lens could originally capture this large an area, and now after zooming, only this small middle portion is captured." This comparison is very useful for fixed-point evidence collection tasks. Don't set the yellow frustum's FOV too large, otherwise the ground coverage will look exaggerated.

The relationship between zoom and field of view uses a very simple approximation:

/**
 * Zoom factor → Field of View.
 * Higher zoom means narrower FOV, capped not to exceed the wide-angle reference cone,
 * otherwise the frustum flares out too much at low zoom, looking like a blur on the map.
 */
function fovOf(ptz) {
  return Math.min(28, round1(120 / ptz));
}

Both frustums originate from the same camera starting point. The yellow frustum has a larger FOV, with the green one nested inside. The far planes are nearly the same length (yellow is only slightly shorter to prevent z-fighting). Never make the yellow far plane too short, otherwise they will look like two separate segments "one in front of the other" instead of an outer frame enclosing an inner one:

addFrustum({ fov: fovOf(state.ptz), far: state.far, ... });        // Green: current FOV
addFrustum({ fov: 28,               far: state.far * 0.98, ... }); // Yellow: wide-angle reference

So when switching to 1X wide-angle, the two frustums almost overlap. This is normal, not a rendering duplication.


5. First Pitfall: Frustum Pointing Skyward

Initially, I directly fed the camera's heading / pitch to FrustumGeometry, and the frustum pointed straight up into the sky; adjusting the pitch did nothing.

The reason is that FrustumGeometry's orientation convention is different from Camera.setView. The correct approach requires two steps, both indispensable:

function getFrustumOrientation() {
  const position = getCameraPosition();

  const hpr = new Cesium.HeadingPitchRoll(
    Cesium.Math.toRadians(state.heading),
    Cesium.Math.toRadians(state.roll),   // Second parameter is roll
    Cesium.Math.toRadians(state.pitch)   // Third parameter is pitch
  );

  const orientation = Cesium.Transforms.headingPitchRollQuaternion(position, hpr);

  // Key: Apply an additional -90° rotation around the local X-axis; without this, it points skyward
  const tilt = Cesium.Quaternion.fromAxisAngle(
    Cesium.Cartesian3.UNIT_X,
    -Cesium.Math.PI_OVER_TWO
  );

  return Cesium.Quaternion.multiply(orientation, tilt, new Cesium.Quaternion());
}

Note that the second and third parameters of HeadingPitchRoll are swapped. This is not a typo; it's to coordinate with that subsequent -90° rotation. After adding this, a negative pitch value correctly makes the frustum point toward the ground.


6. Second Pitfall: Hawk-eye Scroll Wheel Works, but Dragging Doesn't Respond

I initially bound events to Cesium's canvas. Scroll wheel zoom worked fine, but mouse dragging had no effect at all.

The reason is that mouse events on the canvas are intercepted by Cesium's internal ScreenSpaceEventHandler. Rather than fighting it, it's better to bypass it — overlay a transparent div on the hawk-eye panel and let this layer take over pointer events:

.hawk-drag-layer {
  position: absolute;
  /* Leave 28px margins to expose the sliders, preventing click hijacking */
  top: 28px; left: 28px; right: 28px; bottom: 0;
  z-index: 3;
  cursor: grab;
  background: transparent;
  touch-action: none;
}

The drag conversion is done like this: horizontal movement changes yaw, vertical movement changes pitch:

layer.addEventListener("pointermove", (e) => {
  if (!dragging) return;

  const dx = e.clientX - lastX;
  const dy = lastY - e.clientY;  // Screen Y is positive downwards, flip it here
  lastX = e.clientX;
  lastY = e.clientY;

  const deltaHeading = (dx / 320) * 120;
  const deltaPitch   = (dy / 240) * 60;

  // Only modify one axis at a time: if horizontal movement is larger, change yaw; if vertical, change pitch.
  // Modifying both simultaneously makes diagonal dragging rotate in two directions at once, making it hard to aim at the target.
  if (Math.abs(deltaHeading) >= Math.abs(deltaPitch)) {
    state.heading = round1(clamp(toHeading180(state.heading + deltaHeading), -180, 180));
  } else {
    state.pitch = round1(clamp(state.pitch + deltaPitch, -90, 35));
  }

  scheduleRender();
});

The "only modify one axis at a time" check is quite necessary. If both axes respond simultaneously, a slightly diagonal mouse movement rotates the view in two directions at once, making it very hard for the user to point the lens at the desired location.

Also, remember to disable all default navigation on the hawk-eye Viewer, otherwise it will move on its own, causing a situation where "the view drifts but the frustum doesn't move":

const controller = hawkViewer.scene.screenSpaceCameraController;
controller.enableInputs = false;
controller.enableRotate = false;
controller.enableTranslate = false;
controller.enableZoom = false;
controller.enableTilt = false;
controller.enableLook = false;

7. A Few Small Details

Zoom Must Use Multiplication/Division

function zoomByWheel(direction) {
  const ratio = state.ptz >= 100 ? 1.2 : 1.25;

  let next = direction > 0
    ? Math.min(160, state.ptz * ratio)
    : Math.max(1, state.ptz / ratio);

  state.ptz = normalizePtz(next);
  state.isFocus = true;   // Scrolling the wheel indicates the user wants to zoom, auto-switch mode
  scheduleRender();
}

Zoom is perceptually exponential: 1X→2X and 100X→101X are completely different things. Using addition/subtraction makes the low-zoom range too slow and the high-zoom range too fast; multiplication/division is necessary.

Height Pole Solves the Spatial Perception Problem

When the camera is suspended in mid-air, users simply cannot judge how high it is above the ground. Drawing a dashed vertical pole up from the ground point makes it clear:

heightPoleEntity = mainViewer.entities.add({
  polyline: {
    positions: [groundPosition, cameraPosition],
    width: 2,
    material: new Cesium.PolylineDashMaterialProperty({
      color: Cesium.Color.fromCssColorString("rgba(45,140,240,0.95)"),
      dashLength: 14,
    }),
  },
});

The three entities — ground point, height pole, and camera point — are created only once. Afterwards, only their coordinates are updated; do not delete and recreate them every frame.

Don't Override the Hawk-eye Camera's aspectRatio

This pitfall is small but very subtle. When setting the field of view for the hawk-eye camera, I also set aspectRatio to 16:9, which stretched the image — because the hawk-eye panel itself is 420×300. Just let it follow the canvas's own ratio:

const frustum = hawkViewer.camera.frustum;
if (frustum instanceof Cesium.PerspectiveFrustum) {
  frustum.fov = Cesium.Math.toRadians(state.isFocus ? fovOf(state.ptz) : 28);
  // Do not override aspectRatio, otherwise the image will be stretched
}

Ground Distance Needs an Upper Limit

Use a ray-ellipsoid intersection to measure the distance from the line-of-sight center to the ground. But when the pitch is near 0°, the ray actually hits the horizon, calculating a distance of over 30,000 meters, which looks nonsensical when displayed:

function measureTerrainDistance() {
  const ray = new Cesium.Ray(hawkViewer.camera.position, hawkViewer.camera.direction);
  const hit = Cesium.IntersectionTests.rayEllipsoid(ray, hawkViewer.scene.globe.ellipsoid);

  // If over 20km, consider it as having hit beyond the horizon; displaying "--" on the UI is more reasonable
  if (hit && hit.start > 0 && hit.start < 20000) {
    return round1(hit.start);
  }
  return 0;
}

8. What This Version Does and Doesn't Do

What's done: placing a camera on a pole at the ground point, dual frustums, hawk-eye dragging and scroll wheel, slider quick adjustments, real-time main map linkage.

What's not done: complete route editing and waypoint action persistence, import/export of route files, differences in gimbal capabilities across drone models (pitch range, zoom limits, etc., are currently hardcoded as constants).

So it's more suitable for understanding this pipeline, or as a starting sample to integrate into your own route configuration, where you'd then add the business layer.


9. Final Thoughts

When it comes to configuring a gimbal, the difficulty has never been the formulas, but whether users can match the parameters to the actual image.

The view frustum solves "being able to see," and the hawk-eye solves "being able to adjust." Putting the two together significantly improves the efficiency of configuring waypoint actions. After completing this version, our internal trials basically no longer need test flights to verify angles.


Need the Complete Code

The complete implementation is a single-file HTML, about 900 lines, which runs locally with a double-click (requires an internet connection to load Cesium and basemaps). It's too long to fit in the article; those who need it, leave a comment or send me a private message, and I'll send it to you separately.

After getting it, remember to replace the Cesium Ion Token inside with your own.

If this was helpful, give it a like to let me know, and I'll write about the waypoint action part next time.