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
46 changes: 25 additions & 21 deletions packages/engine/Source/Scene/BillboardCollection.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import EncodedCartesian3 from "../Core/EncodedCartesian3.js";
import IndexDatatype from "../Core/IndexDatatype.js";
import CesiumMath from "../Core/Math.js";
import Matrix4 from "../Core/Matrix4.js";
import WebGLConstants from "../Core/WebGLConstants.js";
import Buffer from "../Renderer/Buffer.js";
import BufferUsage from "../Renderer/BufferUsage.js";
import ContextLimits from "../Renderer/ContextLimits.js";
Expand All @@ -34,6 +33,7 @@ import SceneMode from "./SceneMode.js";
import SDFSettings from "./SDFSettings.js";
import TextureAtlas from "../Renderer/TextureAtlas.js";
import VerticalOrigin from "./VerticalOrigin.js";
import Ellipsoid from "../Core/Ellipsoid.js";

const SHOW_INDEX = Billboard.SHOW_INDEX;
const POSITION_INDEX = Billboard.POSITION_INDEX;
Expand Down Expand Up @@ -315,6 +315,8 @@ function BillboardCollection(options) {
];

this._highlightColor = Color.clone(Color.WHITE); // Only used by Vector3DTilePoints
this._coarseDepthTestDistance = Ellipsoid.default.minimumRadius / 100;
this._threePointDepthTestDistance = Ellipsoid.default.minimumRadius / 1000;

this._uniforms = {
u_atlas: () => {
Expand All @@ -323,6 +325,16 @@ function BillboardCollection(options) {
u_highlightColor: () => {
return this._highlightColor;
},
// An eye-space distance, beyond which, the billboard is simply tested against a camera-facing plane at the ellipsoid's center,
// rather than against a depth texture. Note: only if the disableDepthTestingDistance property permits.
u_coarseDepthTestDistance: () => {
return this._coarseDepthTestDistance;
},
// Within this distance, if the billboard is clamped to the ground, we'll depth-test 3 key points.
// If any key point is visible, the whole billboard will be visible.
u_threePointDepthTestDistance: () => {
return this._threePointDepthTestDistance;
},
};

const scene = this._scene;
Expand Down Expand Up @@ -1372,9 +1384,6 @@ function writeCompressedAttribute3(
const clampToGround =
isHeightReferenceClamp(billboard.heightReference) &&
frameState.context.depthTexture;
if (!defined(disableDepthTestDistance)) {
disableDepthTestDistance = clampToGround ? 5000.0 : 0.0;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is what I mentioned above. Removing in favor of always using a 5km distance.

}

disableDepthTestDistance *= disableDepthTestDistance;
if (clampToGround || disableDepthTestDistance > 0.0) {
Expand Down Expand Up @@ -2030,8 +2039,7 @@ BillboardCollection.prototype.update = function (frameState) {
) {
this._rsOpaque = RenderState.fromCache({
depthTest: {
enabled: true,
func: WebGLConstants.LESS,
enabled: false,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The crux of this PR's change. Disable automatic depth testing and do it manually in the shaders so we have more control of how it happens.

Note: Given the comment below about translucency, I'm a little wary I may have introduced regressions, as I don't do a LEQUAL check in the shaders, but it seems to work better this way (see test case in PR description)

Copy link
Contributor Author

@mzschwartz5 mzschwartz5 Sep 3, 2025

Choose a reason for hiding this comment

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

Also note- we still leave automatic write to the depth buffer enabled, we just don't test against it automatically.

},
depthMask: true,
});
Expand All @@ -2042,7 +2050,6 @@ BillboardCollection.prototype.update = function (frameState) {
// If OPAQUE_AND_TRANSLUCENT is in use, only the opaque pass gets the benefit of the depth buffer,
// not the translucent pass. Otherwise, if the TRANSLUCENT pass is on its own, it turns on
// a depthMask in lieu of full depth sorting (because it has opaque-ish fragments that look bad in OIT).
// When the TRANSLUCENT depth mask is in use, label backgrounds require the depth func to be LEQUAL.
const useTranslucentDepthMask =
this._blendOption === BlendOption.TRANSLUCENT;

Expand All @@ -2052,10 +2059,7 @@ BillboardCollection.prototype.update = function (frameState) {
) {
this._rsTranslucent = RenderState.fromCache({
depthTest: {
enabled: true,
func: useTranslucentDepthMask
? WebGLConstants.LEQUAL
: WebGLConstants.LESS,
enabled: false,
},
depthMask: useTranslucentDepthMask,
blending: BlendingState.ALPHA_BLEND,
Expand Down Expand Up @@ -2141,9 +2145,9 @@ BillboardCollection.prototype.update = function (frameState) {
}
if (this._shaderClampToGround) {
if (supportVSTextureReads) {
vs.defines.push("VERTEX_DEPTH_CHECK");
vs.defines.push("VS_THREE_POINT_DEPTH_CHECK");
} else {
vs.defines.push("FRAGMENT_DEPTH_CHECK");
vs.defines.push("FS_THREE_POINT_DEPTH_CHECK");
}
}

Expand All @@ -2162,9 +2166,9 @@ BillboardCollection.prototype.update = function (frameState) {
});
if (this._shaderClampToGround) {
if (supportVSTextureReads) {
fs.defines.push("VERTEX_DEPTH_CHECK");
fs.defines.push("VS_THREE_POINT_DEPTH_CHECK");
} else {
fs.defines.push("FRAGMENT_DEPTH_CHECK");
fs.defines.push("FS_THREE_POINT_DEPTH_CHECK");
}
}

Expand All @@ -2187,9 +2191,9 @@ BillboardCollection.prototype.update = function (frameState) {
});
if (this._shaderClampToGround) {
if (supportVSTextureReads) {
fs.defines.push("VERTEX_DEPTH_CHECK");
fs.defines.push("VS_THREE_POINT_DEPTH_CHECK");
} else {
fs.defines.push("FRAGMENT_DEPTH_CHECK");
fs.defines.push("FS_THREE_POINT_DEPTH_CHECK");
}
}
if (this._sdf) {
Expand All @@ -2212,9 +2216,9 @@ BillboardCollection.prototype.update = function (frameState) {
});
if (this._shaderClampToGround) {
if (supportVSTextureReads) {
fs.defines.push("VERTEX_DEPTH_CHECK");
fs.defines.push("VS_THREE_POINT_DEPTH_CHECK");
} else {
fs.defines.push("FRAGMENT_DEPTH_CHECK");
fs.defines.push("FS_THREE_POINT_DEPTH_CHECK");
}
}
if (this._sdf) {
Expand All @@ -2237,9 +2241,9 @@ BillboardCollection.prototype.update = function (frameState) {
});
if (this._shaderClampToGround) {
if (supportVSTextureReads) {
fs.defines.push("VERTEX_DEPTH_CHECK");
fs.defines.push("VS_THREE_POINT_DEPTH_CHECK");
} else {
fs.defines.push("FRAGMENT_DEPTH_CHECK");
fs.defines.push("FS_THREE_POINT_DEPTH_CHECK");
}
}
if (this._sdf) {
Expand Down
164 changes: 102 additions & 62 deletions packages/engine/Source/Shaders/BillboardCollectionFS.glsl
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would recommend reviewing the VS before the FS.

Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
uniform sampler2D u_atlas;
uniform float u_coarseDepthTestDistance;
uniform float u_threePointDepthTestDistance;

#ifdef VECTOR_TILE
uniform vec4 u_highlightColor;
Expand All @@ -14,18 +16,33 @@ in vec4 v_outlineColor;
in float v_outlineWidth;
#endif

#ifdef FRAGMENT_DEPTH_CHECK
in vec4 v_compressed; // x: eyeDepth, y: applyTranslate & enableDepthCheck, z: dimensions, w: imageSize
const float SHIFT_LEFT1 = 2.0;
const float SHIFT_RIGHT1 = 1.0 / 2.0;

Comment on lines +19 to +22
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These changes, among several others in this PR, are just moving variables outside of macro checks. These used to be only used if #ifdef FRAGMENT_DEPTH_CHECK was defined, but now they're used all the time

#ifdef FS_THREE_POINT_DEPTH_CHECK
in vec4 v_textureCoordinateBounds; // the min and max x and y values for the texture coordinates
in vec4 v_originTextureCoordinateAndTranslate; // texture coordinate at the origin, billboard translate (used for label glyphs)
in vec4 v_compressed; // x: eyeDepth, y: applyTranslate & enableDepthCheck, z: dimensions, w: imageSize
in mat2 v_rotationMatrix;

const float SHIFT_LEFT12 = 4096.0;
const float SHIFT_LEFT1 = 2.0;

const float SHIFT_RIGHT12 = 1.0 / 4096.0;
const float SHIFT_RIGHT1 = 1.0 / 2.0;
#endif

float getGlobeDepthAtCoords(vec2 st)
{
float logDepthOrDepth = czm_unpackDepth(texture(czm_globeDepthTexture, st));
if (logDepthOrDepth == 0.0)
{
return 0.0; // not on the globe
}
Comment on lines +33 to +39
Copy link
Contributor Author

@mzschwartz5 mzschwartz5 Sep 3, 2025

Choose a reason for hiding this comment

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

This isn't a new function, really. It just breaks the old getGlobeDepth function into two pieces, so we can call this piece separately.


vec4 eyeCoordinate = czm_windowToEyeCoordinates(gl_FragCoord.xy, logDepthOrDepth);
return eyeCoordinate.z / eyeCoordinate.w;
}

#ifdef FS_THREE_POINT_DEPTH_CHECK
float getGlobeDepth(vec2 adjustedST, vec2 depthLookupST, bool applyTranslate, vec2 dimensions, vec2 imageSize)
{
vec2 lookupVector = imageSize * (depthLookupST - adjustedST);
Expand All @@ -42,19 +59,10 @@ float getGlobeDepth(vec2 adjustedST, vec2 depthLookupST, bool applyTranslate, ve
}

vec2 st = ((lookupVector - translation + labelOffset) + gl_FragCoord.xy) / czm_viewport.zw;
float logDepthOrDepth = czm_unpackDepth(texture(czm_globeDepthTexture, st));

if (logDepthOrDepth == 0.0)
{
return 0.0; // not on the globe
}

vec4 eyeCoordinate = czm_windowToEyeCoordinates(gl_FragCoord.xy, logDepthOrDepth);
return eyeCoordinate.z / eyeCoordinate.w;
return getGlobeDepthAtCoords(st);
}
#endif


#ifdef SDF

// Get the distance from the edge of a glyph at a given position sampling an SDF texture.
Expand Down Expand Up @@ -85,10 +93,89 @@ vec4 getSDFColor(vec2 position, float outlineWidth, vec4 outlineColor, float smo
}
#endif

#ifdef FS_THREE_POINT_DEPTH_CHECK
void doThreePointDepthTest(float eyeDepth, bool applyTranslate) {

if (eyeDepth < -u_threePointDepthTestDistance) return;
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

The rest of this file has a few other occurrences. Please do a pass.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do. Usually prettier catches me on those but I guess not in shader code!
(My personal preference for return-only if statements is to omit braces, so these are bound to continue to leak through in the future...)

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair! I'd love to eventually get our shader tooling up to par with what we have for JS.

float temp = v_compressed.z;
temp = temp * SHIFT_RIGHT12;

vec2 dimensions;
dimensions.y = (temp - floor(temp)) * SHIFT_LEFT12;
dimensions.x = floor(temp);

temp = v_compressed.w;
temp = temp * SHIFT_RIGHT12;

vec2 imageSize;
imageSize.y = (temp - floor(temp)) * SHIFT_LEFT12;
imageSize.x = floor(temp);

vec2 adjustedST = v_textureCoordinates - v_textureCoordinateBounds.xy;
adjustedST = adjustedST / vec2(v_textureCoordinateBounds.z - v_textureCoordinateBounds.x, v_textureCoordinateBounds.w - v_textureCoordinateBounds.y);

float epsilonEyeDepth = v_compressed.x + czm_epsilon1;
float globeDepth1 = getGlobeDepth(adjustedST, v_originTextureCoordinateAndTranslate.xy, applyTranslate, dimensions, imageSize);

// negative values go into the screen
if (globeDepth1 == 0.0 || globeDepth1 < epsilonEyeDepth) return;

float globeDepth2 = getGlobeDepth(adjustedST, vec2(0.0, 1.0), applyTranslate, dimensions, imageSize); // top left corner
if (globeDepth2 == 0.0 || globeDepth2 < epsilonEyeDepth) return;

float globeDepth3 = getGlobeDepth(adjustedST, vec2(1.0, 1.0), applyTranslate, dimensions, imageSize); // top right corner
if (globeDepth3 == 0.0 || globeDepth3 < epsilonEyeDepth) return;

// All three key points are occluded, discard the fragment (and by extension the entire billboard)
discard;
}
#endif

void doDepthTest() {
float temp = v_compressed.y;
temp = temp * SHIFT_RIGHT1;
float temp2 = (temp - floor(temp)) * SHIFT_LEFT1;
bool enableDepthCheck = temp2 != 0.0;
if (!enableDepthCheck) return;

float eyeDepth = v_compressed.x;

#ifdef FS_THREE_POINT_DEPTH_CHECK
// If the billboard is clamped to the ground and within a given distance, we do a 3-point depth test. This test is performed in the vertex shader, unless
// vertex texture sampling is not supported, in which case we do it here.
bool applyTranslate = floor(temp) != 0.0;
doThreePointDepthTest(eyeDepth, applyTranslate);

#elif defined(VS_THREE_POINT_DEPTH_CHECK)
// Since discarding vertices is not possible, the vertex shader sets eyeDepth to 0 to indicate the depth test failed. Apply the discard here.
if (eyeDepth > -u_threePointDepthTestDistance) {
if (eyeDepth == 0.0) {
discard;
}
return;
}
#endif

// Automatic depth testing of billboards is disabled (@see BillboardCollection#update).
// Instead, we do one of two types of manual depth tests (potentially in addition to the test above), depending on the camera's distance to the billboard fragment.
// If we're far away, we just compare against a flat, camera-facing depth-plane at the ellipsoid's center.
// If we're close, we compare against the globe depth texture (which includes depth from the 3D tile pass).
vec2 fragSt = gl_FragCoord.xy / czm_viewport.zw;
float globeDepth = getGlobeDepthAtCoords(fragSt);
if (globeDepth == 0.0) return; // Not on globe

float distanceToEllipsoidCenter = -length(czm_viewerPositionWC); // depth is negative by convention
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, didn't notice this until now— Does length(czm_viewerPositionWC) make sense here? I think PositionWC will be world coordinates, while isn't eyeDepth in eye coordinates?

Out of curiosity, I was able to do a quick test and get a fallback depth based on computing the depth of the intersection with the ellipsoid (see Kevin's horizon culling blog post for more info) through each vertex. I pushed up my patch to billboard-horizon-patch.

It seems to be working pretty well for mid-level horizon views as well as at the global scale:

This branch Ellipsoid test
Image Image
Image Image

And it seems to be working well for 3D Tiles as well, so in that branch I was able to remove the custom depthTestDistance callbacks in the Moon and Mars example.

Thoughts?

Copy link
Contributor Author

@mzschwartz5 mzschwartz5 Oct 21, 2025

Choose a reason for hiding this comment

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

So, I do think length(czm_viewerPositionWC) makes sense here- basically, we want to compare the distance between the camera and the globe center, to the distance between the camera and billboard. While position depends on frame of reference, distance is invariant under rigid transforms. czm_viewerPositionWC is the position of the camera in world coords, so taking its length gets us the camera's distance relative to the globe center, which is the same in the world frame as in eye space. Does that make sense?

Separately (but perhaps making the above irrelevant) I didn't even consider horizon culling, and your images of it look aesthetically better to me. I think we should take your patch. (I wonder- was horizon culling being done on billboards before this PR? I think not, right?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Re: mars and moon, I was thinking about removing the depth distance check after this PR, even as I was writing the Mars sandcastle. I'm eager for that because I don't think that stop gap worked particularly well.

Copy link
Contributor

Choose a reason for hiding this comment

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

While position depends on frame of reference, distance is invariant under rigid transforms.

Thanks for the explanation! That makes sense in retrospect.

I wonder- was horizon culling being done on billboards before this PR? I think not, right?

Not to my knowledge, no.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should take your patch.

👍 I'll go ahead and merge this PR then open a new one with my patch for your review.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After more thought, I think my above comment was wrong (well, what I said about position vs. distance is true, but the conclusion is wrong). My code was just comparing two distances (camera-to-globe-center, to camera-to-billboard). But really it should have been projecting the camera-to-billboard vector onto the camera-to-globe vector, and comparing the length of the projected vector to the camera-to-globe distance.

And, for that math, the reference frames must be the same - so I think you were right to be confused. In any case, the horizon culling approach will fix that and be more precise. Just wanted to clarify for posterity, and so I'm not gaslighting anyone :)

float testDistance = (eyeDepth > -u_coarseDepthTestDistance) ? globeDepth : distanceToEllipsoidCenter;
if (eyeDepth < testDistance) {
discard;
}
}

void main()
{
if (v_splitDirection < 0.0 && gl_FragCoord.x > czm_splitPosition) discard;
if (v_splitDirection > 0.0 && gl_FragCoord.x < czm_splitPosition) discard;
doDepthTest();

vec4 color = texture(u_atlas, v_textureCoordinates);

Expand Down Expand Up @@ -158,51 +245,4 @@ void main()
#ifdef LOG_DEPTH
czm_writeLogDepth();
#endif

#ifdef FRAGMENT_DEPTH_CHECK
float temp = v_compressed.y;

temp = temp * SHIFT_RIGHT1;

float temp2 = (temp - floor(temp)) * SHIFT_LEFT1;
bool enableDepthTest = temp2 != 0.0;
bool applyTranslate = floor(temp) != 0.0;

if (enableDepthTest) {
temp = v_compressed.z;
temp = temp * SHIFT_RIGHT12;

vec2 dimensions;
dimensions.y = (temp - floor(temp)) * SHIFT_LEFT12;
dimensions.x = floor(temp);

temp = v_compressed.w;
temp = temp * SHIFT_RIGHT12;

vec2 imageSize;
imageSize.y = (temp - floor(temp)) * SHIFT_LEFT12;
imageSize.x = floor(temp);

vec2 adjustedST = v_textureCoordinates - v_textureCoordinateBounds.xy;
adjustedST = adjustedST / vec2(v_textureCoordinateBounds.z - v_textureCoordinateBounds.x, v_textureCoordinateBounds.w - v_textureCoordinateBounds.y);

float epsilonEyeDepth = v_compressed.x + czm_epsilon1;
float globeDepth1 = getGlobeDepth(adjustedST, v_originTextureCoordinateAndTranslate.xy, applyTranslate, dimensions, imageSize);

// negative values go into the screen
if (globeDepth1 != 0.0 && globeDepth1 > epsilonEyeDepth)
{
float globeDepth2 = getGlobeDepth(adjustedST, vec2(0.0, 1.0), applyTranslate, dimensions, imageSize); // top left corner
if (globeDepth2 != 0.0 && globeDepth2 > epsilonEyeDepth)
{
float globeDepth3 = getGlobeDepth(adjustedST, vec2(1.0, 1.0), applyTranslate, dimensions, imageSize); // top right corner
if (globeDepth3 != 0.0 && globeDepth3 > epsilonEyeDepth)
{
discard;
}
}
}
}
#endif

}
}
Loading
Loading