Skip to content
Open
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 @@ -20,6 +20,7 @@
- Auto-normalize non-unit `alignedAxis` in `BillboardCollection` instead of silently ignoring it. [#6596](https://github.com/CesiumGS/cesium/issues/6596)
- Fixed SPZ-compressed Gaussian splat loading to read the compressed payload from the buffer view declared by `KHR_gaussian_splatting_compression_spz_2`, preventing incorrect cache reuse for assets with SPZ payloads in different buffer views. [#12847](https://github.com/CesiumGS/cesium/issues/12847)
- Fixed incorrect parameter order when calling `clamp` in `PolylineGlowMaterial` shader, which caused the alpha value to always be 1.0 regardless of the glow intensity.
- Fixed terrain-clamped billboards, labels, and points being mispositioned and not properly rendering in 2D/Columbus view. [#5042](https://github.com/CesiumGS/cesium/issues/5042) [#12531](https://github.com/CesiumGS/cesium/issues/12531)

## 1.143 - 2026-07-01

Expand Down
75 changes: 39 additions & 36 deletions packages/engine/Source/Scene/Billboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,10 @@ Object.defineProperties(Billboard.prototype, {
value,
this._actualClampedPosition,
);
if (this._mode === SceneMode.SCENE3D) {
// In 3D mode the actual (rendered) position is the same as clamped position (ECEF).
Cartesian3.clone(value, this._actualPosition);
}
makeDirty(this, POSITION_INDEX);
},
},
Expand Down Expand Up @@ -1163,30 +1167,22 @@ Billboard._updateClamping = function (collection, owner) {
return;
}

function updateFunction(clampedPosition) {
const updatedClampedPosition = ellipsoid.cartographicToCartesian(
clampedPosition,
owner._clampedPosition,
);

function handleSceneUpdateHeight(clampedPositionCartographic) {
// Apply the height offset in cartographic space
if (isHeightReferenceRelative(owner._heightReference)) {
if (owner._mode === SceneMode.SCENE3D) {
clampedPosition.height += position.height;
ellipsoid.cartographicToCartesian(
clampedPosition,
updatedClampedPosition,
);
} else {
updatedClampedPosition.x += position.height;
}
clampedPositionCartographic.height += position.height;
}

owner._clampedPosition = updatedClampedPosition;
// Assign via the setter, as the setter marks the position as dirty.
owner._clampedPosition = ellipsoid.cartographicToCartesian(
clampedPositionCartographic,
owner._clampedPosition,
);
}

owner._removeCallbackFunc = scene.updateHeight(
position,
updateFunction,
handleSceneUpdateHeight,
owner._heightReference,
);

Expand All @@ -1196,7 +1192,7 @@ Billboard._updateClamping = function (collection, owner) {
scratchCartographic.height = height;
}

updateFunction(scratchCartographic);
handleSceneUpdateHeight(scratchCartographic);
};

/**
Expand Down Expand Up @@ -1351,16 +1347,17 @@ Billboard.prototype._setTranslate = function (value) {
}
};

// `_actualPosition` is the billboard's position in the current render frame:
// ECEF in 3D, and the projected map coordinate in 2D/Columbus View.
// It is kept current by `recomputeActualPositions` in 2D/Columbus View, and —
// for clamped billboards in 3D — by the `_clampedPosition` setter (3D has no
// per-frame actual-position recompute).
Billboard.prototype._getActualPosition = function () {
return defined(this._clampedPosition)
? this._clampedPosition
: this._actualPosition;
return this._actualPosition;
};

Billboard.prototype._setActualPosition = function (value) {
if (!defined(this._clampedPosition)) {
Cartesian3.clone(value, this._actualPosition);
}
Cartesian3.clone(value, this._actualPosition);
makeDirty(this, POSITION_INDEX);
};

Expand All @@ -1375,7 +1372,17 @@ Billboard._computeActualPosition = function (
if (frameState.mode !== billboard._mode) {
billboard._updateClamping();
}
return billboard._clampedPosition;

// Clamped position is already our rendering position when in 3D
if (frameState.mode === SceneMode.SCENE3D) {
return billboard._clampedPosition;
}

// in 2D and Columbus View we instead project the ECEF coordinate into the current map frame.
return SceneTransforms.computeActualEllipsoidPosition(
frameState,
billboard._clampedPosition,
);
} else if (frameState.mode === SceneMode.SCENE3D) {
return position;
}
Expand Down Expand Up @@ -1462,20 +1469,16 @@ Billboard.prototype.computeScreenSpacePosition = function (scene, result) {
Cartesian2.clone(this._pixelOffset, scratchPixelOffset);
Cartesian2.add(scratchPixelOffset, this._translate, scratchPixelOffset);

const position = this._clampedPosition ?? this._position;

let modelMatrix = billboardCollection.modelMatrix;
let position = this._position;
if (defined(this._clampedPosition)) {
position = this._clampedPosition;
if (scene.mode !== SceneMode.SCENE3D) {
// position needs to be in world coordinates
const projection = scene.mapProjection;
const ellipsoid = projection.ellipsoid;
const cart = projection.unproject(position, scratchCartographic);
position = ellipsoid.cartographicToCartesian(cart, scratchCartesian3);
modelMatrix = Matrix4.IDENTITY;
}

if (this._clampedPosition && scene.mode !== SceneMode.SCENE3D) {
// The model matrix isn't applied when rendering clamped in 2D/CV (see BillboardCollection#update)
modelMatrix = Matrix4.IDENTITY;
}

// _computeScreenSpacePosition always expects ECEF position, so no unprojection required.
const windowCoordinates = Billboard._computeScreenSpacePosition(
modelMatrix,
position,
Expand Down
57 changes: 40 additions & 17 deletions packages/engine/Source/Scene/QuadtreePrimitive.js
Original file line number Diff line number Diff line change
Expand Up @@ -1444,15 +1444,18 @@ function updateHeights(primitive, frameState) {
defined(terrainData) && terrainData.wasCreatedByUpsampling();

if (tile.level > data.level && !upsampledGeometryFromParent) {
let position;
// find cached entry
let positionCarto;
// Cached as a cartographic (converted below). A cartographic is
// inherently mode-independent, so a single entry is valid in every
// scene mode (the raw pick is ECEF in 3D but projected in 2D/CV).
const cachedData = tile.getPositionCacheEntry(
data.positionCartographic,
primitive.maximumScreenSpaceError,
);
if (defined(cachedData)) {
// cache hit
position = cachedData;
// cache hit; clone into a scratch so the callback can never mutate
// the cached entry (which would corrupt it for every other tile).
positionCarto = Cartographic.clone(cachedData, scratchCartographic);
} else {
if (!defined(data.positionOnEllipsoidSurface)) {
// cartesian has to be on the ellipsoid surface for `ellipsoid.geodeticSurfaceNormal`
Expand Down Expand Up @@ -1518,7 +1521,7 @@ function updateHeights(primitive, frameState) {
Cartesian3.clone(Cartesian3.UNIT_X, scratchRay.direction);
}

position = tile.data.pick(
const position = tile.data.pick(
scratchRay,
mode,
projection,
Expand All @@ -1527,21 +1530,41 @@ function updateHeights(primitive, frameState) {
);

if (defined(position)) {
// `pick` wrote into the module-level `scratchPosition`, so `position`
// aliases it — clone before caching or the next pick mutates every entry.
tile.setPositionCacheEntry(
data.positionCartographic,
primitive.maximumScreenSpaceError,
Cartesian3.clone(position),
);
// Convert the mode-frame pick result to a mode-independent
// cartographic before caching, so a cached entry is valid in every
// scene mode.
if (mode === SceneMode.SCENE3D) {
// In 3D the pick result is already ECEF.
positionCarto = ellipsoid.cartesianToCartographic(
position,
scratchCartographic,
);
} else {
// In 2D and Columbus View the pick result is in the projected map frame, laid out as
// (height, easting, northing). Un-swizzle it back to the projection's native
// (easting, northing, height) layout and unproject to recover the true cartographic.
const projected = Cartesian3.fromElements(
position.y,
position.z,
position.x,
scratchPosition,
);
positionCarto = projection.unproject(
projected,
scratchCartographic,
);
}
if (defined(positionCarto)) {
tile.setPositionCacheEntry(
data.positionCartographic,
primitive.maximumScreenSpaceError,
Cartographic.clone(positionCarto),
);
}
}
}
if (defined(position)) {
if (defined(positionCarto)) {
if (defined(data.callback)) {
const positionCarto = ellipsoid.cartesianToCartographic(
position,
scratchCartographic,
);
data.callback(positionCarto);
}
data.level = tile.level;
Expand Down
10 changes: 10 additions & 0 deletions packages/engine/Source/Scene/SceneTransforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,16 @@ const projectedPosition = new Cartesian3();
const positionInCartographic = new Cartographic();

/**
* Transforms a position in world coordinates to the coordinate frame used to render the current scene mode.
*
* In 3D this is the same as the input ECEF coordinate.
* In 2D and Columbus View this is the projected map frame laid out as (height, easting, northing).
* In 2D the height is flattened to 0.
*
* @param {FrameState} frameState
* @param {Cartesian3} position The world-space (ECEF) position.
* @param {Cartesian3} [result]
* @returns {Cartesian3|undefined}
* @private
*/
SceneTransforms.computeActualEllipsoidPosition = function (
Expand Down
4 changes: 3 additions & 1 deletion packages/engine/Source/Shaders/BillboardCollectionVS.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ void main()
v_compressed.y = enableDepthCheck;

#ifdef VS_THREE_POINT_DEPTH_CHECK
if (lengthSq < (u_threePointDepthTestDistance * u_threePointDepthTestDistance) && (enableDepthCheck == 1.0)) {
// In 2D, the globe and billboards are coplanar, but the depth reconstruction from getGlobeDepth is imprecise
// It suffered error greater than the depthsilon of 10.0, causing the verts to be discarded for clamped billboards.
if (czm_sceneMode != czm_sceneMode2D && lengthSq < (u_threePointDepthTestDistance * u_threePointDepthTestDistance) && (enableDepthCheck == 1.0)) {
float depthsilon = 10.0;
vec2 depthOrigin;
// Horizontal origin for labels comes from a special attribute. If that value is 0, this is a billboard - use the regular origin.
Expand Down
94 changes: 94 additions & 0 deletions packages/engine/Specs/Scene/BillboardCollectionSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
BlendOption,
HeightReference,
HorizontalOrigin,
SceneMode,
TextureAtlas,
VerticalOrigin,
SplitDirection,
Expand Down Expand Up @@ -2705,6 +2706,99 @@ describe("Scene/BillboardCollection", function () {
expect(b._clampedPosition).toBeUndefined();
});

it("uses a projected position (not the ECEF clamped position) in 2D", function () {
// Regression: in 2D a clamped billboard kept its Earth-centered
// _clampedPosition as the render-frame position and was drawn off the
// map. In 2D/CV the render frame is the projected map position.
spyOn(scene, "updateHeight");

const position = Cartesian3.fromDegrees(-72.0, 40.0);
const b = billboardsWithHeight.add({
heightReference: HeightReference.CLAMP_TO_GROUND,
position: position,
});

// In 3D the render-frame position is the ECEF clamped position itself.
scene.renderForSpecs();
expect(b._clampedPosition).toBeDefined();
expect(b._getActualPosition()).toEqual(b._clampedPosition);

scene.morphTo2D(0.0);
scene.camera.setView({ destination: Rectangle.MAX_VALUE });
return pollToPromise(function () {
scene.renderForSpecs();
return scene.mode === SceneMode.SCENE2D;
}).then(function () {
const projection = scene.mapProjection;
const carto = projection.ellipsoid.cartesianToCartographic(
b._clampedPosition,
);
const projected = projection.project(carto);
// 2D flattens the height to the map datum: (0, easting, northing).
const expected = new Cartesian3(0.0, projected.x, projected.y);

const actual = b._getActualPosition();
expect(actual).toEqualEpsilon(expected, CesiumMath.EPSILON6);
// And crucially NOT the raw ECEF clamped position.
expect(
Cartesian3.equalsEpsilon(
actual,
b._clampedPosition,
CesiumMath.EPSILON3,
),
).toBe(false);
});
});

it("uses a projected position (not the ECEF clamped position) in Columbus View", function () {
// Columbus View keeps the real height, unlike 2D which flattens it,
// so the render-frame position is (height, easting, northing). It must
// still be the projected map position, not the raw ECEF clamped one.
spyOn(scene, "updateHeight");
spyOn(scene, "getHeight").and.returnValue(500.0);

const position = Cartesian3.fromDegrees(-72.0, 40.0);
const b = billboardsWithHeight.add({
heightReference: HeightReference.CLAMP_TO_GROUND,
position: position,
});

scene.renderForSpecs();
expect(b._clampedPosition).toBeDefined();

scene.morphToColumbusView(0.0);
scene.camera.setView({ destination: Rectangle.MAX_VALUE });
return pollToPromise(function () {
scene.renderForSpecs();
return scene.mode === SceneMode.COLUMBUS_VIEW;
}).then(function () {
const projection = scene.mapProjection;
const carto = projection.ellipsoid.cartesianToCartographic(
b._clampedPosition,
);
const projected = projection.project(carto);
// Columbus View preserves the height: (height, easting, northing).
const expected = new Cartesian3(
projected.z,
projected.x,
projected.y,
);

const actual = b._getActualPosition();
expect(actual).toEqualEpsilon(expected, CesiumMath.EPSILON6);
// The height must be preserved (not flattened to 0 like 2D).
expect(actual.x).toBeGreaterThan(0.0);
// And crucially NOT the raw ECEF clamped position.
expect(
Cartesian3.equalsEpsilon(
actual,
b._clampedPosition,
CesiumMath.EPSILON3,
),
).toBe(false);
});
});

it("removes callback after disableDepthTest", function () {
const removeCallback = jasmine.createSpy();
spyOn(scene, "updateHeight").and.returnValue(removeCallback);
Expand Down
Loading