Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Fixed label sizing for some fonts and characters [#9767](https://github.com/CesiumGS/cesium/issues/9767)
- Fixed a type error when accessing the ellipsoid of a viewer [#13123](https://github.com/CesiumGS/cesium/pull/13123)
- Fixed a bug where entities have not been clustered correctly [#13064](https://github.com/CesiumGS/cesium/pull/13064)
- Fixes multiple issues causing undefined pick results in 2D/CV scene modes [#13083](https://github.com/CesiumGS/cesium/issues/13083)

## 1.137 - 2026-01-05

Expand Down
19 changes: 11 additions & 8 deletions packages/engine/Source/Core/TerrainMesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ function TerrainMesh(
this._transform = new Matrix4();

/**
* True if the transform needs to be recomputed (due to changes in exaggeration or scene mode).
* @type {boolean}
* The scene mode used the last time a pick was performed on this terrain mesh.
* @type {SceneMode}
*/
this._recomputeTransform = true;
this._lastPickSceneMode = undefined;

/**
* The terrain picker for this mesh, used for ray intersection tests.
Expand All @@ -199,10 +199,10 @@ function TerrainMesh(
* @private
*/
TerrainMesh.prototype.getTransform = function (mode, projection) {
if (!this._recomputeTransform) {
if (this._lastPickSceneMode === mode) {
return this._transform;
}
this._recomputeTransform = false;
this._terrainPicker.needsRebuild = true;

if (!defined(mode) || mode === SceneMode.SCENE3D) {
return computeTransform(this, this._transform);
Expand Down Expand Up @@ -322,13 +322,16 @@ function computeTransform2D(mesh, projection, result) {
* @private
*/
TerrainMesh.prototype.pick = function (ray, cullBackFaces, mode, projection) {
return this._terrainPicker.rayIntersect(
const intersection = this._terrainPicker.rayIntersect(
ray,
this.getTransform(mode, projection),
cullBackFaces,
mode,
projection,
);

this._lastPickSceneMode = mode;
return intersection;
};

/**
Expand All @@ -345,7 +348,7 @@ TerrainMesh.prototype.updateExaggeration = function (
// to trigger a rebuild on the terrain picker.
this._terrainPicker._vertices = this.vertices;
this._terrainPicker.needsRebuild = true;
this._recomputeTransform = true;
this._lastPickSceneMode = undefined;
};

/**
Expand All @@ -355,7 +358,7 @@ TerrainMesh.prototype.updateExaggeration = function (
*/
TerrainMesh.prototype.updateSceneMode = function (mode) {
this._terrainPicker.needsRebuild = true;
this._recomputeTransform = true;
this._lastPickSceneMode = undefined;
};

export default TerrainMesh;
31 changes: 24 additions & 7 deletions packages/engine/Source/Core/TerrainPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SceneMode from "../Scene/SceneMode.js";
import Interval from "./Interval.js";
import Check from "./Check.js";
import DeveloperError from "./DeveloperError.js";
import CesiumMath from "./Math.js";

// Terrain picker can be 4 levels deep (0-3)
const MAXIMUM_TERRAIN_PICKER_LEVEL = 3;
Expand All @@ -20,7 +21,7 @@ const MAXIMUM_TERRAIN_PICKER_LEVEL = 3;
* @alias TerrainPicker
* @constructor
*
* @param {Float32Array} vertices The terrain mesh's vertex buffer.
* @param {Float64Array} vertices The terrain mesh's vertex buffer.
* @param {Uint8Array|Uint16Array|Uint32Array} indices The terrain mesh's index buffer.
* @param {TerrainEncoding} encoding The terrain mesh's vertex encoding.
*
Expand All @@ -35,7 +36,7 @@ function TerrainPicker(vertices, indices, encoding) {

/**
* The terrain mesh's vertex buffer.
* @type {Float32Array}
* @type {Float64Array}
*/
this._vertices = vertices;
/**
Expand Down Expand Up @@ -268,7 +269,7 @@ function createAABBForNode(x, y, level) {
/**
* Packs triangle vertex positions and index into provided buffers, for the worker to process.
* (The worker does tests to organize triangles into child nodes of the quadtree.)
* @param {Float32Array} trianglePositionsBuffer The buffer to pack triangle vertex positions into.
* @param {Float64Array} trianglePositionsBuffer The buffer to pack triangle vertex positions into.
* @param {Uint32Array} triangleIndicesBuffer The buffer to pack triangle indices into.
* @param {Cartesian3[]} trianglePositions The triangle's vertex positions.
* @param {number} triangleIndex The triangle's index in the overall tile's index buffer.
Expand Down Expand Up @@ -424,7 +425,7 @@ function getClosestTriangleInNode(
let triangleIndices;
if (shouldBuildChildren) {
// If the tree can be built deeper, prepare buffers to store triangle data for child nodes
trianglePositions = new Float32Array(triangleCount * 9); // 3 vertices per triangle * 3 floats per vertex
trianglePositions = new Float64Array(triangleCount * 9); // 3 vertices per triangle * 3 floats per vertex
triangleIndices = new Uint32Array(triangleCount);
}

Expand All @@ -434,6 +435,7 @@ function getClosestTriangleInNode(
encoding,
mode,
projection,
ray,
vertices,
indices[3 * triIndex],
scratchTrianglePoints[0],
Expand All @@ -442,6 +444,7 @@ function getClosestTriangleInNode(
encoding,
mode,
projection,
ray,
vertices,
indices[3 * triIndex + 1],
scratchTrianglePoints[1],
Expand All @@ -450,6 +453,7 @@ function getClosestTriangleInNode(
encoding,
mode,
projection,
ray,
vertices,
indices[3 * triIndex + 2],
scratchTrianglePoints[2],
Expand Down Expand Up @@ -502,7 +506,8 @@ const scratchCartographic = new Cartographic();
* @param {TerrainEncoding} encoding The terrain encoding.
* @param {SceneMode} mode The scene mode (2D/3D/Columbus View).
* @param {MapProjection} projection The map projection.
* @param {Float32Array} vertices The vertex buffer of the terrain mesh.
* @param {Ray} ray The pick ray being tested (used here as a reference to resolve antimeridian wrapping in 2D/Columbus View).
* @param {Float64Array} vertices The terrain mesh's vertex buffer.
* @param {Number} index The index of the vertex to get.
* @param {Cartesian3} result The decoded, exaggerated, and possibly projected vertex position.
* @returns {Cartesian3} The result vertex position.
Expand All @@ -512,6 +517,7 @@ function getVertexPosition(
encoding,
mode,
projection,
ray,
vertices,
index,
result,
Expand All @@ -535,6 +541,14 @@ function getVertexPosition(
result,
);

// Due to wrapping in 2D/CV modes, near the antimeridian, the vertex
// position may correspond to the other side of the world from the ray origin.
// Compare the vertex position to the ray origin and adjust it accordingly.
// A spherical approximation is sufficient for cylindrical projections,
// like mercator and geographic.
const worldWidth = CesiumMath.TWO_PI * projection.ellipsoid.maximumRadius;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to use a spherical approximation and not the true ellipsoid circumference. Is that adequate for the use case here, or should the true circumference be used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point.... I need to think about this. It does seem to "work" in all my testing but is it correct?

Perhaps the reason it's valid (at least in the testing I've done) is because our two common projections (mercator and web geographic) are cylindrical projection, where this math is exact.

It might not be valid for other projections.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, conveniently those are the only two 2D projections we support. 😄 So maybe good enough, and just worth a comment explaining why.

If in doubt, using a root mean square approximation should be accurate enough for most real world applications since the radii are generally close in value. But that could be overkill given the above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the issue is that MapProjection is an interface technically, so people could in theory extend it with their own projection implementations that may not be cylindrical.

In practice this hasn't happened in 15+ years so... (there are plenty of places that assume our two projections are the ONLY projections, for better or for worse).

Will leave a comment though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So 15 years was the limit: #13149
I could imagine that people would have liked to use other projections, but accepted that this is simply not possible, despite MapProjection being an interface.

const k = Math.round((ray.origin.y - position.y) / worldWidth);
position.y += k * worldWidth;
return position;
}

Expand All @@ -544,7 +558,7 @@ function getVertexPosition(
* @param {Matrix4} inverseTransform
* @param {TerrainNode} node
* @param {Uint32Array} triangleIndices
* @param {Float32Array} trianglePositions
* @param {Float64Array} trianglePositions
* @returns {Promise<void>} A promise that resolves when the triangles have been added to the child nodes.
* @private
*/
Expand Down Expand Up @@ -596,7 +610,10 @@ async function addTrianglesToChildrenNodes(
// Assign these to the child nodes
const result = await incrementallyBuildTerrainPickerPromise;
result.intersectingTrianglesArrays.forEach((buffer, index) => {
node.children[index].intersectingTriangles = new Uint32Array(buffer);
// Guard against case where tree is reset while waiting for worker
if (defined(node.children[index])) {
node.children[index].intersectingTriangles = new Uint32Array(buffer);
}
});

// The node's triangles have been distributed to its children
Expand Down
11 changes: 11 additions & 0 deletions packages/engine/Source/Scene/Camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -3005,6 +3005,17 @@ function getPickRayOrthographic(camera, windowPosition, result) {

Cartesian3.clone(camera.directionWC, result.direction);

// Account for wrap-around in 2D infinite scroll mode
if (
camera._mode === SceneMode.SCENE2D &&
camera._scene.mapMode2D === MapMode2D.INFINITE_SCROLL
) {
const maxHorizontal = camera._maxCoord.x;
origin.y =
CesiumMath.mod(origin.y + maxHorizontal, 2.0 * maxHorizontal) -
maxHorizontal;
}

return result;
}

Expand Down
13 changes: 3 additions & 10 deletions packages/engine/Source/Scene/Globe.js
Original file line number Diff line number Diff line change
Expand Up @@ -723,14 +723,7 @@ Globe.prototype.pickWorldCoordinates = function (
const sphereIntersections = scratchArray;
sphereIntersections.length = 0;

const tilesToRender = this._surface._tilesToRender;
let length = tilesToRender.length;

let tile;
let i;

for (i = 0; i < length; ++i) {
tile = tilesToRender[i];
for (const tile of this._surface._tilesRenderedThisFrame) {
const surfaceTile = tile.data;

if (!defined(surfaceTile)) {
Expand Down Expand Up @@ -776,8 +769,8 @@ Globe.prototype.pickWorldCoordinates = function (
sphereIntersections.sort(createComparePickTileFunction(ray.origin));

let intersection;
length = sphereIntersections.length;
for (i = 0; i < length; ++i) {
const length = sphereIntersections.length;
for (let i = 0; i < length; ++i) {
intersection = sphereIntersections[i].pick(
ray,
scene.mode,
Expand Down
9 changes: 6 additions & 3 deletions packages/engine/Source/Scene/QuadtreePrimitive.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function QuadtreePrimitive(options) {
const tilingScheme = this._tileProvider.tilingScheme;
const ellipsoid = tilingScheme.ellipsoid;

this._tilesRenderedThisFrame = new Set(); // collect all tiles selected to render (useful when multiple render calls are made in a single frame (as in 2D mode))
this._tilesToRender = [];
this._tileLoadQueueHigh = []; // high priority tiles are preventing refinement
this._tileLoadQueueMedium = []; // medium priority tiles are being rendered
Expand Down Expand Up @@ -256,9 +257,9 @@ QuadtreePrimitive.prototype.forEachLoadedTile = function (tileFunction) {
* function is passed a reference to the tile as its only parameter.
*/
QuadtreePrimitive.prototype.forEachRenderedTile = function (tileFunction) {
const tilesRendered = this._tilesToRender;
for (let i = 0, len = tilesRendered.length; i < len; ++i) {
tileFunction(tilesRendered[i]);
const tilesRendered = this._tilesRenderedThisFrame;
for (const tile of tilesRendered) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, how many tiles are in this list? If it's a "small" number, then for..of is probably ok.

Copy link
Contributor Author

@mzschwartz5 mzschwartz5 Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another instance where I don't think it's a hot path for performance, but it's an easy change to make, so might as well change it.

tileFunction(tile);
}
};

Expand Down Expand Up @@ -347,6 +348,7 @@ QuadtreePrimitive.prototype.beginFrame = function (frameState) {
}

this._tileReplacementQueue.markStartOfRenderFrame();
this._tilesRenderedThisFrame.clear();
};

/**
Expand Down Expand Up @@ -1303,6 +1305,7 @@ function screenSpaceError2D(primitive, frameState, tile) {

function addTileToRenderList(primitive, tile) {
primitive._tilesToRender.push(tile);
primitive._tilesRenderedThisFrame.add(tile);
}

function processTileLoadQueue(primitive, frameState) {
Expand Down
18 changes: 16 additions & 2 deletions packages/engine/Source/Workers/incrementallyBuildTerrainPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const scratchTrianglePoints = [
];
const scratchTriangleAABB = new AxisAlignedBoundingBox();

const TILE_AABB_MAX = new Cartesian3(0.5, 0.5, 0.5);
const TILE_AABB_MIN = new Cartesian3(-0.5, -0.5, -0.5);

/**
* Builds the next layer of the terrain picker's quadtree by determining which triangles intersect
* each of the four child nodes. (Essentially distributing the parent's triangles to its children.)
Expand Down Expand Up @@ -42,7 +45,7 @@ function incrementallyBuildTerrainPicker(parameters, transferableObjects) {
);

const triangleIndices = new Uint32Array(parameters.triangleIndices);
const trianglePositions = new Float32Array(parameters.trianglePositions);
const trianglePositions = new Float64Array(parameters.trianglePositions);
const intersectingTrianglesArrays = Array.from({ length: 4 }, () => []);

for (let j = 0; j < triangleIndices.length; j++) {
Expand Down Expand Up @@ -102,7 +105,18 @@ function createAABBFromTriangle(inverseTransform, trianglePoints) {
trianglePoints[2],
);

return AxisAlignedBoundingBox.fromPoints(trianglePoints, scratchTriangleAABB);
const aabb = AxisAlignedBoundingBox.fromPoints(
trianglePoints,
scratchTriangleAABB,
);

// In 2D mode, sometimes the height-scale of a tile is 0. See {@link TerrainMesh#computeTransform2D}.
// This makes the inverseTransform degenerate, so we set the height-scale to 1 to be prevent that. However, this is artificial and
// can lead to the triangle's AABB extending beyond the (height) bounds of the tile's AABB.
// Thus, we clamp the triangle's AABB to the tile's local-space AABB.
Cartesian3.clamp(aabb.minimum, TILE_AABB_MIN, TILE_AABB_MAX, aabb.minimum);
Cartesian3.clamp(aabb.maximum, TILE_AABB_MIN, TILE_AABB_MAX, aabb.maximum);
return aabb;
}

export default createTaskProcessorWorker(incrementallyBuildTerrainPicker);
Loading