diff --git a/CHANGES.md b/CHANGES.md index 91195760a7bd..f6763d8c259e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/packages/engine/Source/Core/TerrainMesh.js b/packages/engine/Source/Core/TerrainMesh.js index fe485dfdc031..cf2d6c076bac 100644 --- a/packages/engine/Source/Core/TerrainMesh.js +++ b/packages/engine/Source/Core/TerrainMesh.js @@ -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. @@ -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); @@ -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; }; /** @@ -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; }; /** @@ -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; diff --git a/packages/engine/Source/Core/TerrainPicker.js b/packages/engine/Source/Core/TerrainPicker.js index 33b0e53592b8..51c97ff47a38 100644 --- a/packages/engine/Source/Core/TerrainPicker.js +++ b/packages/engine/Source/Core/TerrainPicker.js @@ -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; @@ -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. * @@ -35,7 +36,7 @@ function TerrainPicker(vertices, indices, encoding) { /** * The terrain mesh's vertex buffer. - * @type {Float32Array} + * @type {Float64Array} */ this._vertices = vertices; /** @@ -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. @@ -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); } @@ -434,6 +435,7 @@ function getClosestTriangleInNode( encoding, mode, projection, + ray, vertices, indices[3 * triIndex], scratchTrianglePoints[0], @@ -442,6 +444,7 @@ function getClosestTriangleInNode( encoding, mode, projection, + ray, vertices, indices[3 * triIndex + 1], scratchTrianglePoints[1], @@ -450,6 +453,7 @@ function getClosestTriangleInNode( encoding, mode, projection, + ray, vertices, indices[3 * triIndex + 2], scratchTrianglePoints[2], @@ -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. @@ -512,6 +517,7 @@ function getVertexPosition( encoding, mode, projection, + ray, vertices, index, result, @@ -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; + const k = Math.round((ray.origin.y - position.y) / worldWidth); + position.y += k * worldWidth; return position; } @@ -544,7 +558,7 @@ function getVertexPosition( * @param {Matrix4} inverseTransform * @param {TerrainNode} node * @param {Uint32Array} triangleIndices - * @param {Float32Array} trianglePositions + * @param {Float64Array} trianglePositions * @returns {Promise} A promise that resolves when the triangles have been added to the child nodes. * @private */ @@ -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 diff --git a/packages/engine/Source/Scene/Camera.js b/packages/engine/Source/Scene/Camera.js index c903dbb5e13a..d29567035520 100644 --- a/packages/engine/Source/Scene/Camera.js +++ b/packages/engine/Source/Scene/Camera.js @@ -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; } diff --git a/packages/engine/Source/Scene/Globe.js b/packages/engine/Source/Scene/Globe.js index 1eb935420bc6..65fc101d926d 100644 --- a/packages/engine/Source/Scene/Globe.js +++ b/packages/engine/Source/Scene/Globe.js @@ -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)) { @@ -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, diff --git a/packages/engine/Source/Scene/QuadtreePrimitive.js b/packages/engine/Source/Scene/QuadtreePrimitive.js index 7924b7794c08..6d1230395379 100644 --- a/packages/engine/Source/Scene/QuadtreePrimitive.js +++ b/packages/engine/Source/Scene/QuadtreePrimitive.js @@ -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 @@ -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) { + tileFunction(tile); } }; @@ -347,6 +348,7 @@ QuadtreePrimitive.prototype.beginFrame = function (frameState) { } this._tileReplacementQueue.markStartOfRenderFrame(); + this._tilesRenderedThisFrame.clear(); }; /** @@ -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) { diff --git a/packages/engine/Source/Workers/incrementallyBuildTerrainPicker.js b/packages/engine/Source/Workers/incrementallyBuildTerrainPicker.js index 48ffed588da0..9e1a36c8c486 100644 --- a/packages/engine/Source/Workers/incrementallyBuildTerrainPicker.js +++ b/packages/engine/Source/Workers/incrementallyBuildTerrainPicker.js @@ -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.) @@ -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++) { @@ -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); diff --git a/packages/engine/Specs/Core/TerrainMeshSpec.js b/packages/engine/Specs/Core/TerrainMeshSpec.js new file mode 100644 index 000000000000..7eb741cdb7b8 --- /dev/null +++ b/packages/engine/Specs/Core/TerrainMeshSpec.js @@ -0,0 +1,171 @@ +import { + TerrainMesh, + Cartesian3, + Rectangle, + BoundingSphere, + TerrainEncoding, + Ray, + SceneMode, + GeographicProjection, +} from "../../index.js"; + +describe("Core/TerrainMeshSpec", function () { + describe("picking transforms", function () { + let ray; + let projection; + let mesh; + + beforeEach(function () { + ray = new Ray(new Cartesian3(1, 2, 3), new Cartesian3(4, 5, 6)); + projection = new GeographicProjection(); + const center = new Cartesian3(0, 0, 0); + + mesh = new TerrainMesh( + center, + new Float32Array([0, 0, 0, 0, 0, 0]), // Vertices - one vertex: X,Y,Z,H,U,V + new Uint16Array([0, 0, 0]), // Indices - one triangle + 0, // indexCountWithoutSkirts + 1, // vertexCountWithoutSkirts + 0, // minimumHeight + 0, // maximumHeight + Rectangle.fromRadians(0, 0, 1, 1), + new BoundingSphere(center, 1.0), + new Cartesian3(0, 0, 0), // occludeePointInScaledSpace + 6, // stride + undefined, // orientedBoundingBox + new TerrainEncoding(center), + [], + [], + [], + [], + ); + + // Mock out the dependency on TerrainPicker + spyOn(mesh._terrainPicker, "rayIntersect").and.returnValue(undefined); + }); + + it("uses the 3D transform when picking in 3D mode", function () { + const expectedTransform = mesh.getTransform( + SceneMode.SCENE3D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE3D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expectedTransform, + false, + SceneMode.SCENE3D, + projection, + ); + }); + + it("uses the 2D transform when picking in 2D mode", function () { + const expectedTransform = mesh.getTransform( + SceneMode.SCENE2D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE2D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expectedTransform, + false, + SceneMode.SCENE2D, + projection, + ); + }); + + it("recomputes the transform after scene mode changes", function () { + const expected3DTransform = mesh.getTransform( + SceneMode.SCENE3D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE3D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected3DTransform, + false, + SceneMode.SCENE3D, + projection, + ); + + mesh.updateSceneMode(SceneMode.SCENE2D); + const expected2DTransform = mesh.getTransform( + SceneMode.SCENE2D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE2D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected2DTransform, + false, + SceneMode.SCENE2D, + projection, + ); + }); + + it("recomputes the transform after exaggeration changes", function () { + const expected3DTransform = mesh.getTransform( + SceneMode.SCENE3D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE3D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected3DTransform, + false, + SceneMode.SCENE3D, + projection, + ); + + mesh.updateExaggeration(2.0, 1.0); + const expected2DTransform = mesh.getTransform( + SceneMode.SCENE2D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE2D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected2DTransform, + false, + SceneMode.SCENE2D, + projection, + ); + }); + + it("recomputes the transform after a pick in a different scene mode", function () { + const expected3DTransform = mesh.getTransform( + SceneMode.SCENE3D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE3D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected3DTransform, + false, + SceneMode.SCENE3D, + projection, + ); + + const expected2DTransform = mesh.getTransform( + SceneMode.SCENE2D, + projection, + ); + mesh.pick(ray, false, SceneMode.SCENE2D, projection); + + expect(mesh._terrainPicker.rayIntersect).toHaveBeenCalledWith( + ray, + expected2DTransform, + false, + SceneMode.SCENE2D, + projection, + ); + }); + }); +}); diff --git a/packages/engine/Specs/Scene/CameraSpec.js b/packages/engine/Specs/Scene/CameraSpec.js index 6cda5632cd20..0806dd1f253a 100644 --- a/packages/engine/Specs/Scene/CameraSpec.js +++ b/packages/engine/Specs/Scene/CameraSpec.js @@ -3396,6 +3396,40 @@ describe("Scene/Camera", function () { expect(ray.direction).toEqual(tempCamera.directionWC); }); + it("gets a pick ray in orthographic in 2D infinite scroll mode off the map", function () { + const frustum = new OrthographicOffCenterFrustum(); + frustum.left = -10.0; + frustum.right = 10.0; + frustum.bottom = -10.0; + frustum.top = 10.0; + frustum.near = 1.0; + frustum.far = 21.0; + camera.frustum = frustum; + + // Put the camera into 2D infinite scroll mode + scene.mapMode2D = MapMode2D.INFINITE_SCROLL; + camera.update(SceneMode.SCENE2D); + + const maxHorizontal = camera._maxCoord.x; + + // Move camera position beyond the horizontal max so wrapping should occur + camera.position = new Cartesian3(0.0, maxHorizontal * 1.5 + 50.0, 1.0); + camera.update(SceneMode.SCENE2D); + + const windowCoord = new Cartesian2( + scene.canvas.clientWidth * 0.5, + scene.canvas.clientHeight * 0.5, + ); + const ray = camera.getPickRay(windowCoord); + + const expectedY = + CesiumMath.mod(camera.positionWC.y + maxHorizontal, 2.0 * maxHorizontal) - + maxHorizontal; + + expect(ray).toBeDefined(); + expect(ray.origin.y).toEqualEpsilon(expectedY, CesiumMath.EPSILON10); + }); + it("gets magnitude in 2D", function () { const ellipsoid = Ellipsoid.WGS84; const projection = new GeographicProjection(ellipsoid); diff --git a/packages/engine/Specs/Scene/QuadtreePrimitiveSpec.js b/packages/engine/Specs/Scene/QuadtreePrimitiveSpec.js index b6a5806ac6a0..4030b4d5fd11 100644 --- a/packages/engine/Specs/Scene/QuadtreePrimitiveSpec.js +++ b/packages/engine/Specs/Scene/QuadtreePrimitiveSpec.js @@ -1279,6 +1279,71 @@ describe("Scene/QuadtreePrimitive", function () { expect(quadtree._tilesToRender[7]).toBe(west.southwestChild); }); }); + + it("accumulates rendered tiles across multiple render calls in one frame", function () { + const tileProvider = createSpyTileProvider(); + tileProvider.getReady.and.returnValue(true); + tileProvider.computeTileVisibility.and.returnValue(Visibility.FULL); + tileProvider.computeDistanceToTile.and.returnValue(1e-15); + + // Load the root tiles. + tileProvider.loadTile.and.callFake(function (frameState, tile) { + tile.state = QuadtreeTileLoadState.DONE; + tile.renderable = true; + tile.data = { + pick: function () { + return undefined; + }, + }; + }); + + // Prevent refinement for the first render so only root tiles are selected. + tileProvider.canRefine.and.returnValue(false); + + const quadtree = new QuadtreePrimitive({ + tileProvider: tileProvider, + }); + + // Perform the normal two-phase updates so root tiles are actually loaded. + quadtree.update(scene.frameState); + quadtree.beginFrame(scene.frameState); + quadtree.render(scene.frameState); + quadtree.endFrame(scene.frameState); + + quadtree.update(scene.frameState); + quadtree.beginFrame(scene.frameState); + quadtree.render(scene.frameState); + quadtree.endFrame(scene.frameState); + + // Now start a single frame and call render() twice before ending the frame. + quadtree.beginFrame(scene.frameState); + quadtree.render(scene.frameState); + + const firstCount = quadtree._tilesRenderedThisFrame.size; + expect(firstCount).toBeGreaterThan(0); + + // Allow refinement and make the children of the first root tile renderable so the second render call will select additional, unique tiles. + tileProvider.canRefine.and.callFake(function (tile) { + return tile.renderable; + }); + const firstRoot = quadtree._levelZeroTiles[0]; + firstRoot.children.forEach(function (child) { + child.state = QuadtreeTileLoadState.DONE; + child.renderable = true; + child.data = { + pick: function () { + return undefined; + }, + }; + }); + + // Second render call in the same frame (should add unique tiles) + quadtree.render(scene.frameState); + const secondCount = quadtree._tilesRenderedThisFrame.size; + + expect(secondCount).toBeGreaterThan(firstCount); + quadtree.endFrame(scene.frameState); + }); }, "WebGL", );