diff --git a/CHANGES.md b/CHANGES.md
index ed244a0b0c4a..e54821aef1ad 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -15,6 +15,7 @@
#### Additions :tada:
+- Added support for the [EXT_mesh_primitive_edge_visibility](https://github.com/KhronosGroup/glTF/pull/2479) glTF extension. [#12765](https://github.com/CesiumGS/cesium/issues/12765)
- Adds an async factory method for the Material class that allows callers to wait on resource loading. [#10566](https://github.com/CesiumGS/cesium/issues/10566)
## 1.133.1 - 2025-09-08
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index be84d97baf2d..edfc62ee1620 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -102,6 +102,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Cesiu
- [Paul Connelly](https://github.com/pmconne)
- [Jason Crow](https://github.com/jason-crow)
- [Erin Ingram](https://github.com/eringram)
+ - [Daniel Zhong](https://github.com/danielzhong)
- [Mark Schlosser](https://github.com/markschlosseratbentley)
- [Flightradar24 AB](https://www.flightradar24.com)
- [Aleksei Kalmykov](https://github.com/kalmykov)
diff --git a/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb b/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb
new file mode 100644
index 000000000000..4cd0855473b9
Binary files /dev/null and b/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb differ
diff --git a/Specs/getWebGLStub.js b/Specs/getWebGLStub.js
index d66ea6a9384b..dce6bc83ac34 100644
--- a/Specs/getWebGLStub.js
+++ b/Specs/getWebGLStub.js
@@ -154,6 +154,11 @@ const instancedArraysStub = {
vertexAttribDivisorANGLE: noop,
};
+// WEBGL_draw_buffers
+const drawBuffersStub = {
+ drawBuffersWEBGL: noop,
+};
+
function noop() {}
function createStub() {
@@ -208,6 +213,11 @@ function getExtensionStub(name) {
return {};
}
+ // EdgeFramebuffer and other MRT features require draw buffers
+ if (name === "WEBGL_draw_buffers") {
+ return drawBuffersStub;
+ }
+
// No other extensions are stubbed.
return null;
}
diff --git a/packages/engine/Source/Renderer/AutomaticUniforms.js b/packages/engine/Source/Renderer/AutomaticUniforms.js
index 29801f986f7a..59807ea9d456 100644
--- a/packages/engine/Source/Renderer/AutomaticUniforms.js
+++ b/packages/engine/Source/Renderer/AutomaticUniforms.js
@@ -161,6 +161,67 @@ const AutomaticUniforms = {
},
}),
+ /**
+ * An automatic GLSL uniform representing a texture containing edge IDs
+ * from the 3D Tiles edge rendering pass. Used for edge detection and
+ * avoiding z-fighting between edges and surfaces.
+ *
+ * @example
+ * // GLSL declaration
+ * uniform sampler2D czm_edgeIdTexture;
+ *
+ * // Get the edge ID at the current fragment
+ * vec2 coords = gl_FragCoord.xy / czm_viewport.zw;
+ * vec4 edgeId = texture(czm_edgeIdTexture, coords);
+ */
+ czm_edgeIdTexture: new AutomaticUniform({
+ size: 1,
+ datatype: WebGLConstants.SAMPLER_2D,
+ getValue: function (uniformState) {
+ return uniformState.edgeIdTexture;
+ },
+ }),
+
+ /**
+ * An automatic GLSL uniform containing the edge color texture.
+ * This texture contains the edge content rendered during the CESIUM_3D_TILE_EDGES pass.
+ *
+ * @example
+ * // GLSL declaration
+ * uniform sampler2D czm_edgeColorTexture;
+ *
+ * // Sample the edge color at the current fragment
+ * vec2 coords = gl_FragCoord.xy / czm_viewport.zw;
+ * vec4 edgeColor = texture(czm_edgeColorTexture, coords);
+ */
+ czm_edgeColorTexture: new AutomaticUniform({
+ size: 1,
+ datatype: WebGLConstants.SAMPLER_2D,
+ getValue: function (uniformState) {
+ return uniformState.edgeColorTexture;
+ },
+ }),
+
+ /**
+ * An automatic GLSL uniform containing the packed depth texture produced by the
+ * edge visibility pass. The depth is packed via czm_packDepth and should be
+ * unpacked with czm_unpackDepth.
+ *
+ * @example
+ * // GLSL declaration
+ * uniform sampler2D czm_edgeDepthTexture;
+ *
+ * vec2 coords = gl_FragCoord.xy / czm_viewport.zw;
+ * float d = czm_unpackDepth(texture(czm_edgeDepthTexture, coords));
+ */
+ czm_edgeDepthTexture: new AutomaticUniform({
+ size: 1,
+ datatype: WebGLConstants.SAMPLER_2D,
+ getValue: function (uniformState) {
+ return uniformState.edgeDepthTexture;
+ },
+ }),
+
/**
* An automatic GLSL uniform representing a 4x4 model transformation matrix that
* transforms model coordinates to world coordinates.
diff --git a/packages/engine/Source/Renderer/Pass.js b/packages/engine/Source/Renderer/Pass.js
index 6a8a37d3d213..e33d0ecc947a 100644
--- a/packages/engine/Source/Renderer/Pass.js
+++ b/packages/engine/Source/Renderer/Pass.js
@@ -15,14 +15,15 @@ const Pass = {
COMPUTE: 1,
GLOBE: 2,
TERRAIN_CLASSIFICATION: 3,
- CESIUM_3D_TILE: 4,
- CESIUM_3D_TILE_CLASSIFICATION: 5,
- CESIUM_3D_TILE_CLASSIFICATION_IGNORE_SHOW: 6,
- OPAQUE: 7,
- TRANSLUCENT: 8,
- VOXELS: 9,
- GAUSSIAN_SPLATS: 10,
- OVERLAY: 11,
- NUMBER_OF_PASSES: 12,
+ CESIUM_3D_TILE_EDGES: 4,
+ CESIUM_3D_TILE: 5,
+ CESIUM_3D_TILE_CLASSIFICATION: 6,
+ CESIUM_3D_TILE_CLASSIFICATION_IGNORE_SHOW: 7,
+ OPAQUE: 8,
+ TRANSLUCENT: 9,
+ VOXELS: 10,
+ GAUSSIAN_SPLATS: 11,
+ OVERLAY: 12,
+ NUMBER_OF_PASSES: 13,
};
export default Object.freeze(Pass);
diff --git a/packages/engine/Source/Renderer/UniformState.js b/packages/engine/Source/Renderer/UniformState.js
index 6a72cc180c99..3d1d7d06f95a 100644
--- a/packages/engine/Source/Renderer/UniformState.js
+++ b/packages/engine/Source/Renderer/UniformState.js
@@ -25,6 +25,18 @@ function UniformState() {
* @type {Texture}
*/
this.globeDepthTexture = undefined;
+ /**
+ * @type {Texture}
+ */
+ this.edgeIdTexture = undefined;
+ /**
+ * @type {Texture}
+ */
+ this.edgeColorTexture = undefined;
+ /**
+ * @type {Texture}
+ */
+ this.edgeDepthTexture = undefined; // packed depth color attachment from edge pass
/**
* @type {number}
*/
diff --git a/packages/engine/Source/Scene/EdgeFramebuffer.js b/packages/engine/Source/Scene/EdgeFramebuffer.js
new file mode 100644
index 000000000000..f0be1891883c
--- /dev/null
+++ b/packages/engine/Source/Scene/EdgeFramebuffer.js
@@ -0,0 +1,245 @@
+import defined from "../Core/defined.js";
+import destroyObject from "../Core/destroyObject.js";
+import PixelFormat from "../Core/PixelFormat.js";
+import Color from "../Core/Color.js";
+import PixelDatatype from "../Renderer/PixelDatatype.js";
+import FramebufferManager from "../Renderer/FramebufferManager.js";
+import ClearCommand from "../Renderer/ClearCommand.js";
+
+/**
+ * Creates and manages framebuffers for edge visibility rendering.
+ *
+ * @param {Object} options Object with the following properties:
+ *
+ * @alias EdgeFramebuffer
+ * @constructor
+ *
+ * @private
+ */
+function EdgeFramebuffer(options) {
+ options = options || {};
+
+ // Create framebuffer manager with multiple render targets (MRT)
+ // Color attachment 0: edge color output (visualization / debug)
+ // Color attachment 1: R: edge type, G: featureId (metadata / ids)
+ // Color attachment 2: packed depth (czm_packDepth) for edge fragments
+ this._framebufferManager = new FramebufferManager({
+ colorAttachmentsLength: 3, // MRT: Color + ID + Depth (packed RGBA)
+ createColorAttachments: true,
+ depthStencil: true,
+ supportsDepthTexture: true,
+ color: true,
+ });
+
+ this._framebuffer = undefined;
+ this._colorTexture = undefined;
+ this._idTexture = undefined;
+ this._depthTexture = undefined; // packed depth color attachment (location = 2)
+ this._depthStencilTexture = undefined;
+
+ this._clearCommand = new ClearCommand({
+ color: new Color(0.0, 0.0, 0.0, 0.0),
+ depth: 1.0,
+ stencil: 0,
+ owner: this,
+ });
+}
+
+Object.defineProperties(EdgeFramebuffer.prototype, {
+ /**
+ * Gets the framebuffer for edge rendering.
+ * @memberof EdgeFramebuffer.prototype
+ * @type {Framebuffer}
+ * @readonly
+ */
+ framebuffer: {
+ get: function () {
+ return this._framebuffer;
+ },
+ },
+
+ /**
+ * Gets the color texture.
+ * @memberof EdgeFramebuffer.prototype
+ * @type {Texture}
+ * @readonly
+ */
+ colorTexture: {
+ get: function () {
+ return this._colorTexture;
+ },
+ },
+
+ /**
+ * Gets the ID texture.
+ * @memberof EdgeFramebuffer.prototype
+ * @type {Texture}
+ * @readonly
+ */
+ idTexture: {
+ get: function () {
+ return this._idTexture;
+ },
+ },
+
+ /**
+ * Gets the packed depth texture written during the edge pass.
+ * @memberof EdgeFramebuffer.prototype
+ * @type {Texture}
+ * @readonly
+ */
+ depthTexture: {
+ get: function () {
+ return this._depthTexture;
+ },
+ },
+
+ /**
+ * Gets the depth-stencil texture.
+ * @memberof EdgeFramebuffer.prototype
+ * @type {Texture}
+ * @readonly
+ */
+ depthStencilTexture: {
+ get: function () {
+ return this._depthStencilTexture;
+ },
+ },
+});
+
+/**
+ * Updates the framebuffer.
+ *
+ * @param {Context} context The context.
+ * @param {Viewport} viewport The viewport.
+ * @param {boolean} hdr Whether HDR is enabled.
+ * @param {Texture} [existingColorTexture] Optional existing color texture to reuse.
+ * @param {Texture} [existingDepthTexture] Optional existing depth texture to reuse.
+ *
+ * @returns {boolean} True if the framebuffer was updated; otherwise, false.
+ */
+EdgeFramebuffer.prototype.update = function (
+ context,
+ viewport,
+ hdr,
+ existingColorTexture,
+ existingDepthTexture,
+) {
+ const width = viewport.width;
+ const height = viewport.height;
+
+ const pixelDatatype = hdr
+ ? context.halfFloatingPointTexture
+ ? PixelDatatype.HALF_FLOAT
+ : PixelDatatype.FLOAT
+ : PixelDatatype.UNSIGNED_BYTE;
+
+ const changed = this._framebufferManager.update(
+ context,
+ width,
+ height,
+ 1, // No MSAA
+ pixelDatatype,
+ PixelFormat.RGBA,
+ );
+
+ // Always assign framebuffer if FramebufferManager has one
+ if (this._framebufferManager.framebuffer) {
+ this._framebuffer = this._framebufferManager.framebuffer;
+
+ // Get the textures from the framebuffer manager or use existing ones
+ this._colorTexture = defined(existingColorTexture)
+ ? existingColorTexture
+ : this._framebufferManager.getColorTexture(0); // Color attachment 0
+ this._idTexture = this._framebufferManager.getColorTexture(1); // Color attachment 1: ID texture
+ this._depthTexture = this._framebufferManager.getColorTexture(2); // Color attachment 2: packed depth
+ this._depthStencilTexture = defined(existingDepthTexture)
+ ? existingDepthTexture
+ : this._framebufferManager.getDepthStencilTexture();
+ }
+
+ return changed;
+};
+
+/**
+ * Clears the framebuffer using ClearCommand.
+ * @deprecated Use getClearCommand() instead for proper MRT clearing.
+ *
+ * @param {Context} context The context.
+ * @param {PassState} passState The pass state.
+ * @param {Color} clearColor The clear color.
+ */
+EdgeFramebuffer.prototype.clear = function (context, passState, clearColor) {
+ const clearCommand = this.getClearCommand(clearColor);
+ clearCommand.execute(context, passState);
+};
+
+/**
+ * Gets the clear command for this framebuffer.
+ *
+ * @param {Color} [clearColor] The clear color to use. If undefined, uses the default.
+ * @returns {ClearCommand} The clear command.
+ */
+EdgeFramebuffer.prototype.getClearCommand = function (clearColor) {
+ this._clearCommand.framebuffer = this._framebuffer;
+
+ if (defined(clearColor)) {
+ Color.clone(clearColor, this._clearCommand.color);
+ }
+
+ return this._clearCommand;
+};
+
+/**
+ * Gets the edge framebuffer, creating it if necessary.
+ *
+ * @param {Context} context The context.
+ * @param {Viewport} viewport The viewport.
+ * @param {Texture} [existingColorTexture] Optional existing color texture to reuse.
+ * @param {Texture} [existingDepthTexture] Optional existing depth texture to reuse.
+ *
+ * @returns {Framebuffer} The edge framebuffer.
+ */
+EdgeFramebuffer.prototype.getFramebuffer = function (
+ context,
+ viewport,
+ existingColorTexture,
+ existingDepthTexture,
+) {
+ this.update(
+ context,
+ viewport,
+ false,
+ existingColorTexture,
+ existingDepthTexture,
+ );
+ return this._framebuffer;
+};
+
+/**
+ * Returns true if this object was destroyed; otherwise, false.
+ *
+ * @returns {boolean} True if this object was destroyed; otherwise, false.
+ */
+EdgeFramebuffer.prototype.isDestroyed = function () {
+ return false;
+};
+
+/**
+ * Destroys the WebGL resources held by this object. Destroying an object allows for deterministic
+ * release of WebGL resources, instead of relying on the garbage collector to destroy this object.
+ *
+ * Once an object is destroyed, it should not be used; calling any function other than
+ * isDestroyed
will result in a {@link DeveloperError} exception. Therefore,
+ * assign the return value (undefined
) to the object as done in the example.
+ *
+ * @exception {DeveloperError} This object was destroyed, i.e., destroy() was called.
+ */
+EdgeFramebuffer.prototype.destroy = function () {
+ this._framebufferManager =
+ this._framebufferManager && this._framebufferManager.destroy();
+ this._clearCommand = undefined;
+ return destroyObject(this);
+};
+
+export default EdgeFramebuffer;
diff --git a/packages/engine/Source/Scene/FrameState.js b/packages/engine/Source/Scene/FrameState.js
index 36313c10269e..5adeee50bff3 100644
--- a/packages/engine/Source/Scene/FrameState.js
+++ b/packages/engine/Source/Scene/FrameState.js
@@ -454,6 +454,18 @@ function FrameState(context, creditDisplay, jobScheduler) {
* @type {PickedMetadataInfo|undefined}
*/
this.pickedMetadataInfo = undefined;
+
+ /**
+ * Internal toggle indicating that at least one primitive for this frame requested
+ * edge visibility rendering (EXT_mesh_primitive_edge_visibility). This allows
+ * lazy allocation/activation of the edge MRT without storing a Scene reference
+ * on the frame state (avoids passing entire Scene through internal APIs).
+ * Set by model pipeline stages when they encounter edge visibility data.
+ * Consumed by Scene to flip its _enableEdgeVisibility flag.
+ * @type {boolean}
+ * @private
+ */
+ this.edgeVisibilityRequested = false;
}
/**
diff --git a/packages/engine/Source/Scene/GltfLoader.js b/packages/engine/Source/Scene/GltfLoader.js
index 0139d89bc9e0..81509d9c1a88 100644
--- a/packages/engine/Source/Scene/GltfLoader.js
+++ b/packages/engine/Source/Scene/GltfLoader.js
@@ -1441,13 +1441,18 @@ function loadIndices(
loader,
accessorId,
primitive,
- draco,
hasFeatureIds,
needsPostProcessing,
frameState,
) {
const accessor = loader.gltfJson.accessors[accessorId];
const bufferViewId = accessor.bufferView;
+ // Infer compression / extensions directly from the glTF primitive instead of passing in flags
+ const extensions = primitive.extensions ?? Frozen.EMPTY_OBJECT;
+ const draco = extensions.KHR_draco_mesh_compression;
+ const hasEdgeVisibility = defined(
+ extensions.EXT_mesh_primitive_edge_visibility,
+ );
if (!defined(draco) && !defined(bufferViewId)) {
return undefined;
@@ -1470,7 +1475,10 @@ function loadIndices(
const outputTypedArrayOnly = loadAttributesAsTypedArray;
const outputBuffer = !outputTypedArrayOnly;
const outputTypedArray =
- loadAttributesAsTypedArray || loadForCpuOperations || loadForClassification;
+ loadAttributesAsTypedArray ||
+ loadForCpuOperations ||
+ loadForClassification ||
+ hasEdgeVisibility;
// Determine what to load right now:
//
@@ -2036,8 +2044,44 @@ function loadPrimitive(loader, gltfPrimitive, hasInstances, frameState) {
);
}
+ // Edge Visibility
+ const edgeVisibilityExtension = extensions.EXT_mesh_primitive_edge_visibility;
+ const hasEdgeVisibility = defined(edgeVisibilityExtension);
+ if (hasEdgeVisibility) {
+ const visibilityAccessor =
+ loader.gltfJson.accessors[edgeVisibilityExtension.visibility];
+ if (!defined(visibilityAccessor)) {
+ throw new RuntimeError("Edge visibility accessor not found!");
+ }
+ const visibilityValues = loadAccessor(loader, visibilityAccessor);
+ primitive.edgeVisibility = {
+ visibility: visibilityValues,
+ material: edgeVisibilityExtension.material,
+ };
+
+ // Load silhouette normals
+ if (defined(edgeVisibilityExtension.silhouetteNormals)) {
+ const silhouetteNormalsAccessor =
+ loader.gltfJson.accessors[edgeVisibilityExtension.silhouetteNormals];
+ if (defined(silhouetteNormalsAccessor)) {
+ const silhouetteNormalsValues = loadAccessor(
+ loader,
+ silhouetteNormalsAccessor,
+ );
+ primitive.edgeVisibility.silhouetteNormals = silhouetteNormalsValues;
+ }
+ }
+
+ // Load line strings
+ if (defined(edgeVisibilityExtension.lineStrings)) {
+ primitivePlan.edgeVisibility.lineStrings =
+ edgeVisibilityExtension.lineStrings;
+ }
+ }
+
//support the latest glTF spec and the legacy extension
const spzExtension = fetchSpzExtensionFrom(extensions);
+
if (defined(spzExtension)) {
needsPostProcessing = true;
primitivePlan.needsGaussianSplats = true;
@@ -2108,7 +2152,6 @@ function loadPrimitive(loader, gltfPrimitive, hasInstances, frameState) {
loader,
indices,
gltfPrimitive,
- draco,
hasFeatureIds,
needsPostProcessing,
frameState,
diff --git a/packages/engine/Source/Scene/Model/EdgeDetectionPipelineStage.js b/packages/engine/Source/Scene/Model/EdgeDetectionPipelineStage.js
new file mode 100644
index 000000000000..0b2ebfa0f443
--- /dev/null
+++ b/packages/engine/Source/Scene/Model/EdgeDetectionPipelineStage.js
@@ -0,0 +1,42 @@
+import EdgeDetectionStageFS from "../../Shaders/Model/EdgeDetectionStageFS.js";
+
+/**
+ * Performs the screen-space edge visibility / composition pass. This stage does not
+ * build edge geometry itself; that work is handled earlier by {@link EdgeVisibilityPipelineStage},
+ * which extracts unique model edges and writes them during a dedicated edge render pass
+ * into edge ID / color targets. The fragment logic added here then:
+ *
+ * - Samples the edge render targets (edge color + per-edge feature ID)
+ * - Compares per-edge feature IDs with underlying surface feature IDs to suppress
+ * edges that belong to filtered or hidden features
+ * - Performs depth-based tests (e.g., against globe or scene depth) to discard
+ * occluded edges
+ *
+ * In summary: EdgeVisibilityPipelineStage = generate & encode edges; this stage = decide which of
+ * those encoded edges are actually visible in the final frame and composite them.
+ *
+ * @namespace EdgeDetectionPipelineStage
+ * @private
+ */
+const EdgeDetectionPipelineStage = {
+ name: "EdgeDetectionPipelineStage",
+};
+
+/**
+ * Process a primitive by injecting the fragment shader logic that consumes the
+ * intermediate edge buffers produced by the edge geometry pass. It adds code to:
+ *
+ * - Read edge color / edge ID MRT outputs
+ * - Apply depth & feature ID based rejection
+ * - Emit final edge color for composition
+ *
+ * @param {PrimitiveRenderResources} renderResources The render resources for the primitive
+ * @private
+ */
+EdgeDetectionPipelineStage.process = function (renderResources) {
+ const shaderBuilder = renderResources.shaderBuilder;
+
+ shaderBuilder.addFragmentLines([EdgeDetectionStageFS]);
+};
+
+export default EdgeDetectionPipelineStage;
diff --git a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js
new file mode 100644
index 000000000000..768a04f7c6f4
--- /dev/null
+++ b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js
@@ -0,0 +1,800 @@
+import Buffer from "../../Renderer/Buffer.js";
+import BufferUsage from "../../Renderer/BufferUsage.js";
+import VertexArray from "../../Renderer/VertexArray.js";
+import defined from "../../Core/defined.js";
+import IndexDatatype from "../../Core/IndexDatatype.js";
+import ComponentDatatype from "../../Core/ComponentDatatype.js";
+import PrimitiveType from "../../Core/PrimitiveType.js";
+import Cartesian3 from "../../Core/Cartesian3.js";
+import Pass from "../../Renderer/Pass.js";
+import ShaderDestination from "../../Renderer/ShaderDestination.js";
+import EdgeVisibilityStageFS from "../../Shaders/Model/EdgeVisibilityStageFS.js";
+import ModelUtility from "./ModelUtility.js";
+import ModelReader from "./ModelReader.js";
+import VertexAttributeSemantic from "../VertexAttributeSemantic.js";
+
+/**
+ * Builds derived line geometry for model edges using EXT_mesh_primitive_edge_visibility data.
+ * It parses the encoded edge visibility bits, creates a separate edge-domain vertex array with
+ * per-edge attributes (edge type, optional feature ID, silhouette normal, adjacent face normals),
+ * sets up the required shader defines / varyings, and stores the resulting line list geometry on
+ * the render resources for a later edge rendering pass.
+ *
+ * @namespace EdgeVisibilityPipelineStage
+ * @private
+ */
+const EdgeVisibilityPipelineStage = {
+ name: "EdgeVisibilityPipelineStage",
+};
+
+/**
+ * Process a primitive to derive edge geometry and shader bindings. This modifies the render resources by:
+ *
+ * - Adding shader defines (
HAS_EDGE_VISIBILITY
, HAS_EDGE_VISIBILITY_MRT
)
+ * - Injecting the fragment shader logic that outputs edge color / feature information
+ * - Adding per-vertex attributes: edge type, optional feature ID, silhouette normal, and adjacent face normals
+ * - Adding varyings to pass these attributes to the fragment stage
+ * - Creating and storing a derived line list vertex array in
renderResources.edgeGeometry
+ *
+ * If the primitive does not contain edge visibility data, the function returns early.
+ *
+ * @param {PrimitiveRenderResources} renderResources The render resources for the primitive
+ * @param {ModelComponents.Primitive} primitive The primitive to be rendered
+ * @param {FrameState} frameState The frame state
+ * @private
+ */
+EdgeVisibilityPipelineStage.process = function (
+ renderResources,
+ primitive,
+ frameState,
+) {
+ if (!defined(primitive.edgeVisibility)) {
+ return;
+ }
+
+ // Fallback request: mark that edge visibility is needed this frame.
+ frameState.edgeVisibilityRequested = true;
+
+ const shaderBuilder = renderResources.shaderBuilder;
+
+ // Add shader defines and fragment code
+ shaderBuilder.addDefine(
+ "HAS_EDGE_VISIBILITY",
+ undefined,
+ ShaderDestination.BOTH,
+ );
+ shaderBuilder.addDefine(
+ "HAS_EDGE_VISIBILITY_MRT",
+ undefined,
+ ShaderDestination.FRAGMENT,
+ );
+ shaderBuilder.addFragmentLines(EdgeVisibilityStageFS);
+
+ // Add a uniform to distinguish between original geometry pass and edge pass
+ shaderBuilder.addUniform("bool", "u_isEdgePass", ShaderDestination.BOTH);
+
+ // Add edge type attribute and varying
+ const edgeTypeLocation = shaderBuilder.addAttribute("float", "a_edgeType");
+ shaderBuilder.addVarying("float", "v_edgeType", "flat");
+
+ // Add edge feature ID attribute and varying
+ const edgeFeatureIdLocation = shaderBuilder.addAttribute(
+ "float",
+ "a_edgeFeatureId",
+ );
+
+ // Add silhouette normal attribute and varying for silhouette edges
+ const silhouetteNormalLocation = shaderBuilder.addAttribute(
+ "vec3",
+ "a_silhouetteNormal",
+ );
+ shaderBuilder.addVarying("vec3", "v_silhouetteNormalView", "flat");
+
+ // Add face normal attributes for silhouette detection
+ const faceNormalALocation = shaderBuilder.addAttribute(
+ "vec3",
+ "a_faceNormalA",
+ );
+ const faceNormalBLocation = shaderBuilder.addAttribute(
+ "vec3",
+ "a_faceNormalB",
+ );
+ shaderBuilder.addVarying("vec3", "v_faceNormalAView", "flat");
+ shaderBuilder.addVarying("vec3", "v_faceNormalBView", "flat");
+
+ // Add varying for view space position for perspective-correct silhouette detection
+
+ // Pass edge type, silhouette normal, and face normals from vertex to fragment shader
+ shaderBuilder.addFunctionLines("setDynamicVaryingsVS", [
+ "#ifdef HAS_EDGE_VISIBILITY",
+ " if (u_isEdgePass) {",
+ " v_edgeType = a_edgeType;",
+ "#ifdef HAS_EDGE_FEATURE_ID",
+ " v_featureId_0 = a_edgeFeatureId;",
+ "#endif",
+ " // Transform normals from model space to view space",
+ " v_silhouetteNormalView = czm_normal * a_silhouetteNormal;",
+ " v_faceNormalAView = czm_normal * a_faceNormalA;",
+ " v_faceNormalBView = czm_normal * a_faceNormalB;",
+ " }",
+ "#endif",
+ ]);
+
+ // Build triangle adjacency (mapping edges to adjacent triangles) and compute per-triangle face normals.
+ const adjacencyData = buildTriangleAdjacency(primitive);
+
+ const edgeResult = extractVisibleEdges(primitive);
+
+ if (
+ !defined(edgeResult) ||
+ !defined(edgeResult.edgeIndices) ||
+ edgeResult.edgeIndices.length === 0
+ ) {
+ return;
+ }
+
+ // Generate paired face normals for each unique edge (used to classify silhouette edges in the shader).
+ const edgeFaceNormals = generateEdgeFaceNormals(
+ adjacencyData,
+ edgeResult.edgeIndices,
+ );
+
+ // Create edge-domain line list geometry (2 vertices per edge) with all required attributes.
+ const edgeGeometry = createCPULineEdgeGeometry(
+ edgeResult.edgeIndices,
+ edgeResult.edgeData,
+ renderResources,
+ frameState.context,
+ edgeTypeLocation,
+ silhouetteNormalLocation,
+ faceNormalALocation,
+ faceNormalBLocation,
+ edgeFeatureIdLocation,
+ primitive.edgeVisibility,
+ edgeFaceNormals,
+ );
+
+ if (!defined(edgeGeometry)) {
+ return;
+ }
+
+ if (edgeGeometry.hasEdgeFeatureIds) {
+ shaderBuilder.addDefine(
+ "HAS_EDGE_FEATURE_ID",
+ undefined,
+ ShaderDestination.BOTH,
+ );
+ }
+
+ // Set default value for u_isEdgePass uniform (false for original geometry pass). A later pass overrides this.
+ renderResources.uniformMap.u_isEdgePass = function () {
+ return false;
+ };
+
+ // Store edge geometry metadata so the renderer can issue a separate edges pass.
+ renderResources.edgeGeometry = {
+ vertexArray: edgeGeometry.vertexArray,
+ indexCount: edgeGeometry.indexCount,
+ primitiveType: PrimitiveType.LINES,
+ pass: Pass.CESIUM_3D_TILE_EDGES,
+ };
+};
+
+/**
+ * Build triangle adjacency information and per-triangle face normals in model space.
+ * The adjacency map associates an undirected edge (minIndex,maxIndex) with the indices
+ * of up to two adjacent triangles. Face normals are normalized and stored sequentially.
+ *
+ * @param {ModelComponents.Primitive} primitive The primitive containing triangle index + position data
+ * @returns {{edgeMap:Map, faceNormals:Float32Array, triangleCount:number}}
+ * @private
+ */
+function buildTriangleAdjacency(primitive) {
+ const indices = primitive.indices;
+ if (!defined(indices)) {
+ return {
+ edgeMap: new Map(),
+ faceNormals: new Float32Array(0),
+ triangleCount: 0,
+ };
+ }
+
+ const triangleIndexArray = indices.typedArray;
+ const triangleCount = Math.floor(triangleIndexArray.length / 3);
+
+ // Get vertex positions for face normal calculation
+ const positionAttribute = ModelUtility.getAttributeBySemantic(
+ primitive,
+ VertexAttributeSemantic.POSITION,
+ );
+
+ // Retrieve raw (possibly quantized) position data. If the attribute is quantized
+ // we must dequantize on the CPU here because we compute face normals and silhouette
+ // classification data before the vertex shader's dequantization stage runs.
+ let positions = defined(positionAttribute.typedArray)
+ ? positionAttribute.typedArray
+ : ModelReader.readAttributeAsTypedArray(positionAttribute);
+
+ const quantization = positionAttribute.quantization;
+ if (defined(quantization) && !quantization.octEncoded) {
+ const count = positions.length; // length is 3 * vertexCount
+ const dequantized = new Float32Array(count);
+ const offset = quantization.quantizedVolumeOffset;
+ const step = quantization.quantizedVolumeStepSize;
+ for (let i = 0; i < count; i += 3) {
+ dequantized[i] = offset.x + positions[i] * step.x;
+ dequantized[i + 1] = offset.y + positions[i + 1] * step.y;
+ dequantized[i + 2] = offset.z + positions[i + 2] * step.z;
+ }
+ positions = dequantized;
+ }
+
+ // Build edge map: key = "min,max", value = [triangleA, triangleB?]
+ const edgeMap = new Map();
+
+ // Calculate face normals for each triangle (model space)
+ const faceNormals = new Float32Array(triangleCount * 3);
+
+ // Scratch vectors to avoid heap allocations per triangle
+ const scratchP0 = new Cartesian3();
+ const scratchP1 = new Cartesian3();
+ const scratchP2 = new Cartesian3();
+ const scratchE1 = new Cartesian3();
+ const scratchE2 = new Cartesian3();
+ const scratchCross = new Cartesian3();
+
+ function processEdge(a, b, triIndex) {
+ const edgeKey = `${a < b ? a : b},${a < b ? b : a}`;
+ let list = edgeMap.get(edgeKey);
+ if (!defined(list)) {
+ list = [];
+ edgeMap.set(edgeKey, list);
+ }
+ if (list.length < 2) {
+ list.push(triIndex);
+ }
+ }
+
+ for (let t = 0; t < triangleCount; t++) {
+ const base = t * 3;
+ const i0 = triangleIndexArray[base];
+ const i1 = triangleIndexArray[base + 1];
+ const i2 = triangleIndexArray[base + 2];
+
+ const i0o = i0 * 3;
+ const i1o = i1 * 3;
+ const i2o = i2 * 3;
+
+ scratchP0.x = positions[i0o];
+ scratchP0.y = positions[i0o + 1];
+ scratchP0.z = positions[i0o + 2];
+ scratchP1.x = positions[i1o];
+ scratchP1.y = positions[i1o + 1];
+ scratchP1.z = positions[i1o + 2];
+ scratchP2.x = positions[i2o];
+ scratchP2.y = positions[i2o + 1];
+ scratchP2.z = positions[i2o + 2];
+
+ Cartesian3.subtract(scratchP1, scratchP0, scratchE1);
+ Cartesian3.subtract(scratchP2, scratchP0, scratchE2);
+ Cartesian3.cross(scratchE1, scratchE2, scratchCross);
+ Cartesian3.normalize(scratchCross, scratchCross);
+
+ faceNormals[base] = scratchCross.x;
+ faceNormals[base + 1] = scratchCross.y;
+ faceNormals[base + 2] = scratchCross.z;
+
+ // Edges
+ processEdge(i0, i1, t);
+ processEdge(i1, i2, t);
+ processEdge(i2, i0, t);
+ }
+
+ return { edgeMap, faceNormals, triangleCount };
+}
+
+/**
+ * For each unique edge produce a pair of face normals (A,B). For boundary edges where only a single
+ * adjacent triangle exists, the second normal is synthesized as the negation of the first to allow
+ * the shader to reason about front/back facing transitions uniformly.
+ *
+ * @param {{edgeMap:Map, faceNormals:Float32Array}} adjacencyData The adjacency data from buildTriangleAdjacency
+ * @param {number[]} edgeIndices Packed array of 2 vertex indices per edge
+ * @returns {Float32Array} Packed array: 6 floats per edge (normalA.xyz, normalB.xyz)
+ * @private
+ */
+function generateEdgeFaceNormals(adjacencyData, edgeIndices) {
+ const { edgeMap, faceNormals } = adjacencyData;
+ const numEdges = edgeIndices.length / 2;
+
+ // Each edge needs 2 face normals (left and right side)
+ const edgeFaceNormals = new Float32Array(numEdges * 6); // 2 normals * 3 components each
+
+ for (let i = 0; i < numEdges; i++) {
+ const a = edgeIndices[i * 2];
+ const b = edgeIndices[i * 2 + 1];
+ const edgeKey = `${a < b ? a : b},${a < b ? b : a}`;
+ const triangleList = edgeMap.get(edgeKey);
+
+ // Expect at least one triangle; silently skip if not found (defensive)
+ if (!defined(triangleList) || triangleList.length === 0) {
+ continue;
+ }
+
+ const tA = triangleList[0];
+ const aBase = tA * 3;
+ const nAx = faceNormals[aBase];
+ const nAy = faceNormals[aBase + 1];
+ const nAz = faceNormals[aBase + 2];
+
+ let nBx;
+ let nBy;
+ let nBz;
+ if (triangleList.length > 1) {
+ const tB = triangleList[1];
+ const bBase = tB * 3;
+ nBx = faceNormals[bBase];
+ nBy = faceNormals[bBase + 1];
+ nBz = faceNormals[bBase + 2];
+ } else {
+ // Boundary edge – synthesize opposite normal
+ nBx = -nAx;
+ nBy = -nAy;
+ nBz = -nAz;
+ }
+
+ const baseIdx = i * 6;
+ edgeFaceNormals[baseIdx] = nAx;
+ edgeFaceNormals[baseIdx + 1] = nAy;
+ edgeFaceNormals[baseIdx + 2] = nAz;
+ edgeFaceNormals[baseIdx + 3] = nBx;
+ edgeFaceNormals[baseIdx + 4] = nBy;
+ edgeFaceNormals[baseIdx + 5] = nBz;
+ }
+
+ return edgeFaceNormals;
+}
+
+/**
+ * Parse the EXT_mesh_primitive_edge_visibility 2-bit edge encoding and extract
+ * a unique set of edges that should be considered for rendering. Edge types:
+ *
+ * - 0 HIDDEN - skipped
+ * - 1 SILHOUETTE - candidates for conditional display based on facing
+ * - 2 HARD - always displayed
+ * - 3 REPEATED - secondary encoding for a hard edge (treated same as 2)
+ *
+ * Deduplicates edges shared by adjacent triangles and records per-edge metadata.
+ *
+ * @param {ModelComponents.Primitive} primitive The primitive with EXT_mesh_primitive_edge_visibility data
+ * @returns {{edgeIndices:number[], edgeData:Object[], silhouetteEdgeCount:number}} Edge extraction result
+ * @private
+ */
+function extractVisibleEdges(primitive) {
+ const edgeVisibility = primitive.edgeVisibility;
+ const visibility = edgeVisibility.visibility;
+ const indices = primitive.indices;
+
+ if (!defined(visibility) || !defined(indices)) {
+ return [];
+ }
+
+ const triangleIndexArray = indices.typedArray;
+ const vertexCount = primitive.attributes[0].count;
+ const edgeIndices = [];
+ const edgeData = [];
+ const seenEdgeHashes = new Set();
+ let silhouetteEdgeCount = 0;
+
+ // Process triangles and extract edges (2 bits per edge)
+ let edgeIndex = 0;
+ const totalIndices = triangleIndexArray.length;
+
+ for (let i = 0; i + 2 < totalIndices; i += 3) {
+ const v0 = triangleIndexArray[i];
+ const v1 = triangleIndexArray[i + 1];
+ const v2 = triangleIndexArray[i + 2];
+ for (let e = 0; e < 3; e++) {
+ let a, b;
+ if (e === 0) {
+ a = v0;
+ b = v1;
+ } else if (e === 1) {
+ a = v1;
+ b = v2;
+ } else if (e === 2) {
+ a = v2;
+ b = v0;
+ }
+ const byteIndex = Math.floor(edgeIndex / 4);
+ const bitPairOffset = (edgeIndex % 4) * 2;
+ edgeIndex++;
+
+ if (byteIndex >= visibility.length) {
+ break;
+ }
+
+ const byte = visibility[byteIndex];
+ const visibility2Bit = (byte >> bitPairOffset) & 0x3;
+
+ // Only include visible edge types according to EXT_mesh_primitive_edge_visibility spec
+ let shouldIncludeEdge = false;
+ switch (visibility2Bit) {
+ case 0: // HIDDEN - never draw
+ shouldIncludeEdge = false;
+ break;
+ case 1: // SILHOUETTE - conditionally visible (front-facing vs back-facing)
+ shouldIncludeEdge = true;
+ break;
+ case 2: // HARD - always draw (primary encoding)
+ shouldIncludeEdge = true;
+ break;
+ case 3: // REPEATED - always draw (secondary encoding of a hard edge already encoded as 2)
+ shouldIncludeEdge = true;
+ break;
+ }
+
+ if (shouldIncludeEdge) {
+ const small = Math.min(a, b);
+ const big = Math.max(a, b);
+ const hash = small * vertexCount + big;
+
+ if (!seenEdgeHashes.has(hash)) {
+ seenEdgeHashes.add(hash);
+ edgeIndices.push(a, b);
+
+ let mateVertexIndex = -1;
+ if (visibility2Bit === 1) {
+ mateVertexIndex = silhouetteEdgeCount;
+ silhouetteEdgeCount++;
+ }
+
+ edgeData.push({
+ edgeType: visibility2Bit,
+ triangleIndex: Math.floor(i / 3),
+ edgeIndex: e,
+ mateVertexIndex: mateVertexIndex,
+ currentTriangleVertices: [v0, v1, v2],
+ });
+ }
+ }
+ }
+ }
+
+ return { edgeIndices, edgeData, silhouetteEdgeCount };
+}
+
+/**
+ * Create a derived line list geometry representing edges. A new vertex domain is used so we can pack
+ * per-edge attributes (silhouette normal, face normal pair, edge type, optional feature ID) without
+ * modifying or duplicating the original triangle mesh. Two vertices are generated per unique edge.
+ *
+ * @param {number[]} edgeIndices Packed array [a0,b0, a1,b1, ...] of vertex indices into the source mesh
+ * @param {Object[]} edgeData Array of edge metadata including edge type and silhouette normal lookup index
+ * @param {PrimitiveRenderResources} renderResources The render resources for the primitive
+ * @param {Context} context The WebGL rendering context
+ * @param {number} edgeTypeLocation Shader attribute location for the edge type
+ * @param {number} silhouetteNormalLocation Shader attribute location for input silhouette normal
+ * @param {number} faceNormalALocation Shader attribute location for face normal A
+ * @param {number} faceNormalBLocation Shader attribute location for face normal B
+ * @param {number} edgeFeatureIdLocation Shader attribute location for optional edge feature ID
+ * @param {Object} edgeVisibility Edge visibility extension object (may contain silhouetteNormals[])
+ * @param {Float32Array} edgeFaceNormals Packed face normals (6 floats per edge)
+ * @returns {Object|undefined} Object with {vertexArray, indexBuffer, indexCount} or undefined on failure
+ * @private
+ */
+function createCPULineEdgeGeometry(
+ edgeIndices,
+ edgeData,
+ renderResources,
+ context,
+ edgeTypeLocation,
+ silhouetteNormalLocation,
+ faceNormalALocation,
+ faceNormalBLocation,
+ edgeFeatureIdLocation,
+ edgeVisibility,
+ edgeFaceNormals,
+) {
+ if (!defined(edgeIndices) || edgeIndices.length === 0) {
+ return undefined;
+ }
+
+ const numEdges = edgeData.length;
+ const vertsPerEdge = 2;
+ const totalVerts = numEdges * vertsPerEdge;
+
+ // Always use location 0 for position to avoid conflicts
+ const positionLocation = 0;
+
+ // Get original vertex positions
+ const positionAttribute = ModelUtility.getAttributeBySemantic(
+ renderResources.runtimePrimitive.primitive,
+ VertexAttributeSemantic.POSITION,
+ );
+ const srcPos = defined(positionAttribute.typedArray)
+ ? positionAttribute.typedArray
+ : ModelReader.readAttributeAsTypedArray(positionAttribute);
+
+ // Create edge-domain vertices (2 per edge)
+ const edgePosArray = new Float32Array(totalVerts * 3);
+ const edgeTypeArray = new Float32Array(totalVerts);
+ const silhouetteNormalArray = new Float32Array(totalVerts * 3);
+ const faceNormalAArray = new Float32Array(totalVerts * 3);
+ const faceNormalBArray = new Float32Array(totalVerts * 3);
+ let p = 0;
+
+ const maxSrcVertex = srcPos.length / 3 - 1;
+
+ for (let i = 0; i < numEdges; i++) {
+ const a = edgeIndices[i * 2];
+ const b = edgeIndices[i * 2 + 1];
+
+ // Validate vertex indices
+ if (a < 0 || b < 0 || a > maxSrcVertex || b > maxSrcVertex) {
+ // Fill with zeros to maintain indexing
+ edgePosArray[p++] = 0;
+ edgePosArray[p++] = 0;
+ edgePosArray[p++] = 0;
+ edgePosArray[p++] = 0;
+ edgePosArray[p++] = 0;
+ edgePosArray[p++] = 0;
+ edgeTypeArray[i * 2] = 0;
+ edgeTypeArray[i * 2 + 1] = 0;
+ // Fill with default values
+ const normalIdx = i * 2;
+ silhouetteNormalArray[normalIdx * 3] = 0;
+ silhouetteNormalArray[normalIdx * 3 + 1] = 0;
+ silhouetteNormalArray[normalIdx * 3 + 2] = 1;
+ silhouetteNormalArray[(normalIdx + 1) * 3] = 0;
+ silhouetteNormalArray[(normalIdx + 1) * 3 + 1] = 0;
+ silhouetteNormalArray[(normalIdx + 1) * 3 + 2] = 1;
+
+ // Fill face normals with default values
+ faceNormalAArray[normalIdx * 3] = 0;
+ faceNormalAArray[normalIdx * 3 + 1] = 0;
+ faceNormalAArray[normalIdx * 3 + 2] = 1;
+ faceNormalAArray[(normalIdx + 1) * 3] = 0;
+ faceNormalAArray[(normalIdx + 1) * 3 + 1] = 0;
+ faceNormalAArray[(normalIdx + 1) * 3 + 2] = 1;
+
+ faceNormalBArray[normalIdx * 3] = 0;
+ faceNormalBArray[normalIdx * 3 + 1] = 0;
+ faceNormalBArray[normalIdx * 3 + 2] = 1;
+ faceNormalBArray[(normalIdx + 1) * 3] = 0;
+ faceNormalBArray[(normalIdx + 1) * 3 + 1] = 0;
+ faceNormalBArray[(normalIdx + 1) * 3 + 2] = 1;
+ continue;
+ }
+
+ const ax = srcPos[a * 3];
+ const ay = srcPos[a * 3 + 1];
+ const az = srcPos[a * 3 + 2];
+ const bx = srcPos[b * 3];
+ const by = srcPos[b * 3 + 1];
+ const bz = srcPos[b * 3 + 2];
+
+ // Add edge endpoints
+ edgePosArray[p++] = ax;
+ edgePosArray[p++] = ay;
+ edgePosArray[p++] = az;
+ edgePosArray[p++] = bx;
+ edgePosArray[p++] = by;
+ edgePosArray[p++] = bz;
+
+ const rawType = edgeData[i].edgeType;
+ const t = rawType / 255.0;
+
+ edgeTypeArray[i * 2] = t;
+ edgeTypeArray[i * 2 + 1] = t;
+
+ // Add silhouette normal for silhouette edges (type 1)
+ let normalX = 0,
+ normalY = 0,
+ normalZ = 1; // Default normal pointing up
+
+ if (rawType === 1 && defined(edgeVisibility.silhouetteNormals)) {
+ const mateVertexIndex = edgeData[i].mateVertexIndex;
+ if (
+ mateVertexIndex >= 0 &&
+ mateVertexIndex < edgeVisibility.silhouetteNormals.length
+ ) {
+ const silhouetteNormals = edgeVisibility.silhouetteNormals;
+ const normal = silhouetteNormals[mateVertexIndex];
+
+ if (defined(normal)) {
+ normalX = normal.x;
+ normalY = normal.y;
+ normalZ = normal.z;
+ }
+ }
+ }
+
+ // Set silhouette normal for both edge endpoints
+ const normalIdx = i * 2;
+ silhouetteNormalArray[normalIdx * 3] = normalX;
+ silhouetteNormalArray[normalIdx * 3 + 1] = normalY;
+ silhouetteNormalArray[normalIdx * 3 + 2] = normalZ;
+ silhouetteNormalArray[(normalIdx + 1) * 3] = normalX;
+ silhouetteNormalArray[(normalIdx + 1) * 3 + 1] = normalY;
+ silhouetteNormalArray[(normalIdx + 1) * 3 + 2] = normalZ;
+
+ // Set face normals for both edge endpoints
+ const faceNormalIdx = i * 6; // 6 floats per edge (2 normals * 3 components)
+ const normalAX = edgeFaceNormals[faceNormalIdx];
+ const normalAY = edgeFaceNormals[faceNormalIdx + 1];
+ const normalAZ = edgeFaceNormals[faceNormalIdx + 2];
+ const normalBX = edgeFaceNormals[faceNormalIdx + 3];
+ const normalBY = edgeFaceNormals[faceNormalIdx + 4];
+ const normalBZ = edgeFaceNormals[faceNormalIdx + 5];
+
+ // Face normal A for both endpoints
+ faceNormalAArray[normalIdx * 3] = normalAX;
+ faceNormalAArray[normalIdx * 3 + 1] = normalAY;
+ faceNormalAArray[normalIdx * 3 + 2] = normalAZ;
+ faceNormalAArray[(normalIdx + 1) * 3] = normalAX;
+ faceNormalAArray[(normalIdx + 1) * 3 + 1] = normalAY;
+ faceNormalAArray[(normalIdx + 1) * 3 + 2] = normalAZ;
+
+ // Face normal B for both endpoints
+ faceNormalBArray[normalIdx * 3] = normalBX;
+ faceNormalBArray[normalIdx * 3 + 1] = normalBY;
+ faceNormalBArray[normalIdx * 3 + 2] = normalBZ;
+ faceNormalBArray[(normalIdx + 1) * 3] = normalBX;
+ faceNormalBArray[(normalIdx + 1) * 3 + 1] = normalBY;
+ faceNormalBArray[(normalIdx + 1) * 3 + 2] = normalBZ;
+ }
+
+ // Create vertex buffers
+ const edgePosBuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: edgePosArray,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+ const edgeTypeBuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: edgeTypeArray,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+ const silhouetteNormalBuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: silhouetteNormalArray,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+ const faceNormalABuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: faceNormalAArray,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+ const faceNormalBBuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: faceNormalBArray,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+
+ // Create sequential indices for line pairs
+ const useU32 = totalVerts > 65534;
+ const idx = new Array(totalVerts);
+ for (let i = 0; i < totalVerts; i++) {
+ idx[i] = i;
+ }
+
+ const indexBuffer = Buffer.createIndexBuffer({
+ context,
+ typedArray: useU32 ? new Uint32Array(idx) : new Uint16Array(idx),
+ usage: BufferUsage.STATIC_DRAW,
+ indexDatatype: useU32
+ ? IndexDatatype.UNSIGNED_INT
+ : IndexDatatype.UNSIGNED_SHORT,
+ });
+
+ // Create vertex array with position, edge type, silhouette normal, and face normal attributes
+ const attributes = [
+ {
+ index: positionLocation,
+ vertexBuffer: edgePosBuffer,
+ componentsPerAttribute: 3,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ },
+ {
+ index: edgeTypeLocation,
+ vertexBuffer: edgeTypeBuffer,
+ componentsPerAttribute: 1,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ },
+ {
+ index: silhouetteNormalLocation,
+ vertexBuffer: silhouetteNormalBuffer,
+ componentsPerAttribute: 3,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ },
+ {
+ index: faceNormalALocation,
+ vertexBuffer: faceNormalABuffer,
+ componentsPerAttribute: 3,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ },
+ {
+ index: faceNormalBLocation,
+ vertexBuffer: faceNormalBBuffer,
+ componentsPerAttribute: 3,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ },
+ ];
+
+ // Get feature ID from original geometry
+ const primitive = renderResources.runtimePrimitive.primitive;
+ const getFeatureIdForEdge = function () {
+ // Try to get the first feature ID from the original primitive
+ if (defined(primitive.featureIds) && primitive.featureIds.length > 0) {
+ const firstFeatureIdSet = primitive.featureIds[0];
+
+ // Handle FeatureIdAttribute objects directly using setIndex
+ if (defined(firstFeatureIdSet.setIndex)) {
+ const featureIdAttribute = primitive.attributes.find(
+ (attr) =>
+ attr.semantic === VertexAttributeSemantic.FEATURE_ID &&
+ attr.setIndex === firstFeatureIdSet.setIndex,
+ );
+
+ if (defined(featureIdAttribute)) {
+ const featureIds = defined(featureIdAttribute.typedArray)
+ ? featureIdAttribute.typedArray
+ : ModelReader.readAttributeAsTypedArray(featureIdAttribute);
+
+ // Create edge feature ID buffer based on edge indices
+ const edgeFeatureIds = new Float32Array(totalVerts);
+ for (let i = 0; i < numEdges; i++) {
+ const a = edgeIndices[i * 2];
+ const featureId = a < featureIds.length ? featureIds[a] : 0;
+ edgeFeatureIds[i * 2] = featureId;
+ edgeFeatureIds[i * 2 + 1] = featureId;
+ }
+
+ return edgeFeatureIds;
+ }
+ }
+ }
+
+ return undefined;
+ };
+
+ const edgeFeatureIds = getFeatureIdForEdge();
+ const hasEdgeFeatureIds = defined(edgeFeatureIds);
+
+ if (hasEdgeFeatureIds) {
+ const edgeFeatureIdBuffer = Buffer.createVertexBuffer({
+ context,
+ typedArray: edgeFeatureIds,
+ usage: BufferUsage.STATIC_DRAW,
+ });
+
+ attributes.push({
+ index: edgeFeatureIdLocation,
+ vertexBuffer: edgeFeatureIdBuffer,
+ componentsPerAttribute: 1,
+ componentDatatype: ComponentDatatype.FLOAT,
+ normalize: false,
+ });
+ }
+
+ const vertexArray = new VertexArray({ context, indexBuffer, attributes });
+
+ if (!vertexArray || totalVerts === 0 || totalVerts % 2 !== 0) {
+ return undefined;
+ }
+
+ return {
+ vertexArray,
+ indexBuffer,
+ indexCount: totalVerts,
+ hasEdgeFeatureIds,
+ };
+}
+
+export default EdgeVisibilityPipelineStage;
diff --git a/packages/engine/Source/Scene/Model/ModelDrawCommand.js b/packages/engine/Source/Scene/Model/ModelDrawCommand.js
index f858f6620a89..f03a8a84382d 100644
--- a/packages/engine/Source/Scene/Model/ModelDrawCommand.js
+++ b/packages/engine/Source/Scene/Model/ModelDrawCommand.js
@@ -50,6 +50,9 @@ function ModelDrawCommand(options) {
const runtimePrimitive = renderResources.runtimePrimitive;
this._runtimePrimitive = runtimePrimitive;
+ // Store render resources for edge command creation
+ this._primitiveRenderResources = renderResources;
+
// If the command is translucent, or if the primitive's material is
// double-sided, then back-face culling is automatically disabled for
// the command. The user value for back-face culling will be ignored.
@@ -73,6 +76,8 @@ function ModelDrawCommand(options) {
const needsSilhouetteCommands = hasSilhouette;
+ const needsEdgeCommands = defined(renderResources.edgeGeometry);
+
this._command = command;
// None of the derived commands (non-2D) use a different model matrix
@@ -96,6 +101,7 @@ function ModelDrawCommand(options) {
this._needsTranslucentCommand = needsTranslucentCommand;
this._needsSkipLevelOfDetailCommands = needsSkipLevelOfDetailCommands;
this._needsSilhouetteCommands = needsSilhouetteCommands;
+ this._needsEdgeCommands = needsEdgeCommands;
// Derived commands
this._originalCommand = undefined;
@@ -104,6 +110,7 @@ function ModelDrawCommand(options) {
this._skipLodStencilCommand = undefined;
this._silhouetteModelCommand = undefined;
this._silhouetteColorCommand = undefined;
+ this._edgeCommand = undefined;
// All derived commands (including 2D commands)
this._derivedCommands = [];
@@ -215,6 +222,19 @@ function initialize(drawCommand) {
derivedCommands.push(drawCommand._silhouetteModelCommand);
derivedCommands.push(drawCommand._silhouetteColorCommand);
}
+
+ if (drawCommand._needsEdgeCommands) {
+ const renderResources = drawCommand._primitiveRenderResources;
+ drawCommand._edgeCommand = new ModelDerivedCommand({
+ command: deriveEdgeCommand(command, renderResources, model),
+ updateShadows: false,
+ updateBackFaceCulling: false,
+ updateCullFace: false,
+ updateDebugShowBoundingVolume: false,
+ });
+
+ derivedCommands.push(drawCommand._edgeCommand);
+ }
}
Object.defineProperties(ModelDrawCommand.prototype, {
@@ -586,6 +606,24 @@ ModelDrawCommand.prototype.pushSilhouetteCommands = function (
return result;
};
+/**
+ * @param {FrameState} frameState The frame state.
+ * @param {DrawCommand[]} result The draw commands to push to.
+ * @returns {DrawCommand[]} The modified command list.
+ *
+ * @private
+ */
+ModelDrawCommand.prototype.pushEdgeCommands = function (frameState, result) {
+ if (!defined(this._edgeCommand)) {
+ return result;
+ }
+
+ const use2D = shouldUse2DCommands(this, frameState);
+ pushCommand(result, this._edgeCommand, use2D);
+
+ return result;
+};
+
function pushCommand(commandList, derivedCommand, use2D) {
commandList.push(derivedCommand.command);
if (use2D) {
@@ -644,6 +682,7 @@ function derive2DCommands(drawCommand) {
derive2DCommand(drawCommand, drawCommand._skipLodStencilCommand);
derive2DCommand(drawCommand, drawCommand._silhouetteModelCommand);
derive2DCommand(drawCommand, drawCommand._silhouetteColorCommand);
+ derive2DCommand(drawCommand, drawCommand._edgeCommand);
}
function deriveTranslucentCommand(command) {
@@ -752,6 +791,36 @@ function deriveSilhouetteColorCommand(command, model) {
return silhouetteColorCommand;
}
+function deriveEdgeCommand(command, renderResources) {
+ const edgeGeometry = renderResources.edgeGeometry;
+ const edgeCommand = DrawCommand.shallowClone(command);
+
+ // Use the edge geometry instead of the original geometry
+ edgeCommand.vertexArray = edgeGeometry.vertexArray;
+ edgeCommand.primitiveType = edgeGeometry.primitiveType;
+ edgeCommand.count = edgeGeometry.indexCount;
+
+ // Use the edge shader program if available
+ if (defined(edgeGeometry.shaderProgram)) {
+ edgeCommand.shaderProgram = edgeGeometry.shaderProgram;
+ }
+
+ // Set pass for edge rendering (use the pass specified in edgeGeometry)
+ edgeCommand.pass = edgeGeometry.pass;
+
+ // Override uniformMap to set u_isEdgePass to true for the edge pass
+ const uniformMap = clone(command.uniformMap);
+ uniformMap.u_isEdgePass = function () {
+ return true; // This is the edge pass
+ };
+ edgeCommand.uniformMap = uniformMap;
+
+ edgeCommand.castShadows = false;
+ edgeCommand.receiveShadows = false;
+
+ return edgeCommand;
+}
+
function updateSkipLodStencilCommand(drawCommand, tile, use2D) {
const stencilDerivedComand = drawCommand._skipLodStencilCommand;
const stencilCommand = stencilDerivedComand.command;
diff --git a/packages/engine/Source/Scene/Model/ModelRuntimePrimitive.js b/packages/engine/Source/Scene/Model/ModelRuntimePrimitive.js
index 82ff55bacc0b..44c5284d7305 100644
--- a/packages/engine/Source/Scene/Model/ModelRuntimePrimitive.js
+++ b/packages/engine/Source/Scene/Model/ModelRuntimePrimitive.js
@@ -10,6 +10,8 @@ import CPUStylingPipelineStage from "./CPUStylingPipelineStage.js";
import CustomShaderMode from "./CustomShaderMode.js";
import CustomShaderPipelineStage from "./CustomShaderPipelineStage.js";
import DequantizationPipelineStage from "./DequantizationPipelineStage.js";
+import EdgeDetectionPipelineStage from "./EdgeDetectionPipelineStage.js";
+import EdgeVisibilityPipelineStage from "./EdgeVisibilityPipelineStage.js";
import FeatureIdPipelineStage from "./FeatureIdPipelineStage.js";
import GeometryPipelineStage from "./GeometryPipelineStage.js";
import ImageryPipelineStage from "./ImageryPipelineStage.js";
@@ -244,6 +246,8 @@ ModelRuntimePrimitive.prototype.configurePipeline = function (frameState) {
const hasOutlines =
model._enableShowOutline && defined(primitive.outlineCoordinates);
+ const hasEdgeVisibility = defined(primitive.edgeVisibility);
+
const featureIdFlags = inspectFeatureIds(model, node, primitive);
const hasClassification = defined(model.classificationType);
@@ -324,6 +328,13 @@ ModelRuntimePrimitive.prototype.configurePipeline = function (frameState) {
pipelineStages.push(PrimitiveOutlinePipelineStage);
}
+ if (hasEdgeVisibility) {
+ // Indicate to Scene (after primitive updates) that the edge MRT should be enabled.
+ frameState.edgeVisibilityRequested = true;
+ pipelineStages.push(EdgeVisibilityPipelineStage);
+ pipelineStages.push(EdgeDetectionPipelineStage);
+ }
+
pipelineStages.push(AlphaPipelineStage);
pipelineStages.push(PrimitiveStatisticsPipelineStage);
diff --git a/packages/engine/Source/Scene/Model/ModelSceneGraph.js b/packages/engine/Source/Scene/Model/ModelSceneGraph.js
index d2814904d4ec..c6a877062020 100644
--- a/packages/engine/Source/Scene/Model/ModelSceneGraph.js
+++ b/packages/engine/Source/Scene/Model/ModelSceneGraph.js
@@ -985,6 +985,7 @@ function updatePrimitiveShowBoundingVolume(runtimePrimitive, options) {
}
const scratchSilhouetteCommands = [];
+const scratchEdgeCommands = [];
const scratchPushDrawCommandOptions = {
frameState: undefined,
hasSilhouette: undefined,
@@ -1006,6 +1007,10 @@ ModelSceneGraph.prototype.pushDrawCommands = function (frameState) {
const silhouetteCommands = scratchSilhouetteCommands;
silhouetteCommands.length = 0;
+ // Gather edge commands for the edge pass
+ const edgeCommands = scratchEdgeCommands;
+ edgeCommands.length = 0;
+
// Since this function is called each frame, the options object is
// preallocated in a scratch variable
const pushDrawCommandOptions = scratchPushDrawCommandOptions;
@@ -1020,6 +1025,7 @@ ModelSceneGraph.prototype.pushDrawCommands = function (frameState) {
);
addAllToArray(frameState.commandList, silhouetteCommands);
+ addAllToArray(frameState.commandList, edgeCommands);
};
// Callback is defined here to avoid allocating a closure in the render loop
@@ -1029,6 +1035,7 @@ function pushPrimitiveDrawCommands(runtimePrimitive, options) {
const passes = frameState.passes;
const silhouetteCommands = scratchSilhouetteCommands;
+ const edgeCommands = scratchEdgeCommands;
const primitiveDrawCommand = runtimePrimitive.drawCommand;
primitiveDrawCommand.pushCommands(frameState, frameState.commandList);
@@ -1039,6 +1046,11 @@ function pushPrimitiveDrawCommands(runtimePrimitive, options) {
if (hasSilhouette && !passes.pick) {
primitiveDrawCommand.pushSilhouetteCommands(frameState, silhouetteCommands);
}
+
+ // Add edge commands to the edge pass
+ if (defined(primitiveDrawCommand.pushEdgeCommands)) {
+ primitiveDrawCommand.pushEdgeCommands(frameState, edgeCommands);
+ }
}
/**
diff --git a/packages/engine/Source/Scene/OIT.js b/packages/engine/Source/Scene/OIT.js
index 572120d40510..d4495269f928 100644
--- a/packages/engine/Source/Scene/OIT.js
+++ b/packages/engine/Source/Scene/OIT.js
@@ -531,10 +531,20 @@ function getTranslucentShaderProgram(context, shaderProgram, keyword, source) {
// Discarding the fragment in main is a workaround for ANGLE D3D9
// shader compilation errors.
+ //
+ // CESIUM_REDIRECTED_COLOR_OUTPUT: A general-purpose flag to indicate that this shader
+ // is a derived/modified version created by Cesium's rendering pipeline.
+ // This flag can be used to avoid color attachment conflicts when shaders
+ // need different output declarations for different rendering passes.
+ // For example, MRT (Multiple Render Targets) features can check this flag
+ // to conditionally declare their output variables only when not conflicting
+ // with the derived shader's output layout.
fs.sources.splice(
0,
0,
- `vec4 czm_out_FragColor;\n` + `bool czm_discard = false;\n`,
+ `#define CESIUM_REDIRECTED_COLOR_OUTPUT\n` +
+ `vec4 czm_out_FragColor;\n` +
+ `bool czm_discard = false;\n`,
);
const fragDataMatches = [...source.matchAll(/out_FragData_(\d+)/g)];
diff --git a/packages/engine/Source/Scene/Scene.js b/packages/engine/Source/Scene/Scene.js
index f19d80396c8d..676d8680486e 100644
--- a/packages/engine/Source/Scene/Scene.js
+++ b/packages/engine/Source/Scene/Scene.js
@@ -761,6 +761,15 @@ function Scene(options) {
*/
this.light = new SunLight();
+ /**
+ * Whether or not to enable edge visibility rendering for 3D tiles.
+ * When enabled, creates a framebuffer with multiple render targets
+ * for advanced edge detection and visibility techniques.
+ * @type {boolean}
+ * @default false
+ */
+ this._enableEdgeVisibility = false;
+
// Give frameState, camera, and screen space camera controller initial state before rendering
updateFrameNumber(this, 0.0, JulianDate.now());
this.updateFrameState();
@@ -2567,6 +2576,48 @@ function performTranslucent3DTilesClassification(
);
}
+function performCesium3DTileEdgesPass(scene, passState, frustumCommands) {
+ scene.context.uniformState.updatePass(Pass.CESIUM_3D_TILE_EDGES);
+
+ const originalFramebuffer = passState.framebuffer;
+
+ scene.context.uniformState.edgeColorTexture = scene.context.defaultTexture;
+ scene.context.uniformState.edgeIdTexture = scene.context.defaultTexture;
+ scene.context.uniformState.edgeDepthTexture = scene.context.defaultTexture;
+
+ // Set edge framebuffer for rendering
+ if (
+ scene._enableEdgeVisibility &&
+ defined(scene._view) &&
+ defined(scene._view.edgeFramebuffer)
+ ) {
+ passState.framebuffer = scene._view.edgeFramebuffer.framebuffer;
+ }
+
+ // performPass
+ const commands = frustumCommands.commands[Pass.CESIUM_3D_TILE_EDGES];
+ const commandCount = frustumCommands.indices[Pass.CESIUM_3D_TILE_EDGES];
+
+ // clear edge framebuffer
+ if (
+ scene._enableEdgeVisibility &&
+ defined(scene._view) &&
+ defined(scene._view.edgeFramebuffer)
+ ) {
+ const clearCommand = scene._view.edgeFramebuffer.getClearCommand(
+ new Color(0.0, 0.0, 0.0, 0.0),
+ );
+ clearCommand.execute(scene.context, passState);
+ }
+
+ // Then execute edge rendering commands
+ for (let j = 0; j < commandCount; ++j) {
+ executeCommand(commands[j], scene, passState);
+ }
+
+ passState.framebuffer = originalFramebuffer;
+}
+
/**
* Execute the draw commands for all the render passes.
*
@@ -2709,6 +2760,48 @@ function executeCommands(scene, passState) {
}
let commandCount;
+
+ // Draw edges FIRST - before binding textures to avoid feedback loop
+ performCesium3DTileEdgesPass(scene, passState, frustumCommands);
+
+ if (
+ scene._enableEdgeVisibility &&
+ defined(scene._view) &&
+ defined(scene._view.edgeFramebuffer)
+ ) {
+ // Get edge color texture (attachment 0)
+ const colorTexture = scene._view.edgeFramebuffer.colorTexture;
+ if (defined(colorTexture)) {
+ scene.context.uniformState.edgeColorTexture = colorTexture;
+ } else {
+ scene.context.uniformState.edgeColorTexture =
+ scene.context.defaultTexture;
+ }
+
+ // Get edge ID texture (attachment 1)
+ const idTexture = scene._view.edgeFramebuffer.idTexture;
+ if (defined(idTexture)) {
+ scene.context.uniformState.edgeIdTexture = idTexture;
+ } else {
+ scene.context.uniformState.edgeIdTexture = scene.context.defaultTexture;
+ }
+
+ // Get edge depth texture (attachment 2)
+ const edgeDepthTexture = scene._view.edgeFramebuffer.depthTexture;
+ if (defined(edgeDepthTexture)) {
+ scene.context.uniformState.edgeDepthTexture = edgeDepthTexture;
+ } else {
+ scene.context.uniformState.edgeDepthTexture =
+ scene.context.defaultTexture;
+ }
+ } else {
+ scene.context.uniformState.edgeColorTexture =
+ scene.context.defaultTexture;
+ scene.context.uniformState.edgeIdTexture = scene.context.defaultTexture;
+ scene.context.uniformState.edgeDepthTexture =
+ scene.context.defaultTexture;
+ }
+
if (!useInvertClassification || picking || renderTranslucentDepthForPick) {
// Common/fastest path. Draw 3D Tiles and classification normally.
@@ -3595,9 +3688,20 @@ function updateShadowMaps(scene) {
function updateAndRenderPrimitives(scene) {
const frameState = scene._frameState;
+ // Reset per-frame edge visibility request flag before primitives update
+ frameState.edgeVisibilityRequested = false;
+
scene._groundPrimitives.update(frameState);
scene._primitives.update(frameState);
+ // If any primitive requested edge visibility this frame, flip the scene flag lazily.
+ if (
+ frameState.edgeVisibilityRequested &&
+ scene._enableEdgeVisibility === false
+ ) {
+ scene._enableEdgeVisibility = true;
+ }
+
updateDebugFrustumPlanes(scene);
updateShadowMaps(scene);
@@ -3714,6 +3818,13 @@ function updateAndClearFramebuffers(scene, passState, clearColor) {
const useInvertClassification = (environmentState.useInvertClassification =
!picking && defined(passState.framebuffer) && scene.invertClassification);
+
+ // Update edge framebuffer for 3D tile edge rendering
+ const useEdgeFramebuffer = !picking && scene._enableEdgeVisibility;
+ if (useEdgeFramebuffer) {
+ view.edgeFramebuffer.update(context, view.viewport, scene._hdr);
+ }
+
if (useInvertClassification) {
let depthFramebuffer;
if (frameState.invertClassificationColor.alpha === 1.0) {
diff --git a/packages/engine/Source/Scene/View.js b/packages/engine/Source/Scene/View.js
index ec8cb870f681..c8d2e3ef5ae8 100644
--- a/packages/engine/Source/Scene/View.js
+++ b/packages/engine/Source/Scene/View.js
@@ -10,6 +10,7 @@ import ClearCommand from "../Renderer/ClearCommand.js";
import Pass from "../Renderer/Pass.js";
import PassState from "../Renderer/PassState.js";
import Camera from "./Camera.js";
+import EdgeFramebuffer from "./EdgeFramebuffer.js";
import FrustumCommands from "./FrustumCommands.js";
import GlobeDepth from "./GlobeDepth.js";
import GlobeTranslucencyFramebuffer from "./GlobeTranslucencyFramebuffer.js";
@@ -63,6 +64,7 @@ function View(scene, camera, viewport) {
this.pickFramebuffer = new PickFramebuffer(context);
this.pickDepthFramebuffer = new PickDepthFramebuffer();
this.sceneFramebuffer = new SceneFramebuffer();
+ this.edgeFramebuffer = new EdgeFramebuffer();
this.globeDepth = globeDepth;
this.globeTranslucencyFramebuffer = new GlobeTranslucencyFramebuffer();
this.oit = oit;
@@ -445,6 +447,7 @@ View.prototype.destroy = function () {
this.pickDepthFramebuffer && this.pickDepthFramebuffer.destroy();
this.sceneFramebuffer =
this.sceneFramebuffer && this.sceneFramebuffer.destroy();
+ this.edgeFramebuffer = this.edgeFramebuffer && this.edgeFramebuffer.destroy();
this.globeDepth = this.globeDepth && this.globeDepth.destroy();
this.oit = this.oit && this.oit.destroy();
this.translucentTileClassification =
diff --git a/packages/engine/Source/Shaders/Model/EdgeDetectionStageFS.glsl b/packages/engine/Source/Shaders/Model/EdgeDetectionStageFS.glsl
new file mode 100644
index 000000000000..f6fbfe5ca555
--- /dev/null
+++ b/packages/engine/Source/Shaders/Model/EdgeDetectionStageFS.glsl
@@ -0,0 +1,58 @@
+void edgeDetectionStage(inout vec4 color, inout FeatureIds featureIds) {
+ if (u_isEdgePass) {
+ return;
+ }
+
+ vec2 screenCoord = gl_FragCoord.xy / czm_viewport.zw;
+
+ vec4 edgeColor = texture(czm_edgeColorTexture, screenCoord);
+ vec4 edgeId = texture(czm_edgeIdTexture, screenCoord);
+
+ // Packed window-space depth from edge pass (0..1)
+ float edgeDepthWin = czm_unpackDepth(texture(czm_edgeDepthTexture, screenCoord));
+
+ // Near / far for current frustum
+ float n = czm_currentFrustum.x;
+ float f = czm_currentFrustum.y;
+
+ // geometry depth in eye coordinate
+ vec4 geomEC = czm_windowToEyeCoordinates(gl_FragCoord);
+ float geomDepthLinear = -geomEC.z;
+
+ // Convert edge depth to linear depth
+ float z_ndc_edge = edgeDepthWin * 2.0 - 1.0;
+ float edgeDepthLinear = (2.0 * n * f) / (f + n - z_ndc_edge * (f - n));
+
+ float d = abs(edgeDepthLinear - geomDepthLinear);
+
+ // Adaptive epsilon using linear depth fwidth for robustness
+ float pixelStepLinear = fwidth(geomDepthLinear);
+ float rel = geomDepthLinear * 0.0005;
+ float eps = max(n * 1e-4, max(pixelStepLinear * 1.5, rel));
+
+ // If Edge isn't behind any geometry and the pixel has edge data
+ if (d < eps && edgeId.r > 0.0) {
+#ifdef HAS_EDGE_FEATURE_ID
+ float edgeFeatureId = edgeId.g;
+ float currentFeatureId = float(featureIds.featureId_0);
+#endif
+ float globeDepth = czm_unpackDepth(texture(czm_globeDepthTexture, screenCoord));
+ // Background / sky / globe: always show edge
+ bool isBackground = geomDepthLinear > globeDepth;
+ bool drawEdge = isBackground;
+
+#ifdef HAS_EDGE_FEATURE_ID
+ bool hasEdgeFeature = edgeFeatureId > 0.0;
+ bool hasCurrentFeature = currentFeatureId > 0.0;
+ bool featuresMatch = edgeFeatureId == currentFeatureId;
+
+ drawEdge = drawEdge || !hasEdgeFeature || !hasCurrentFeature || featuresMatch;
+#else
+ drawEdge = true;
+#endif
+
+ if (drawEdge) {
+ color = edgeColor;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/Source/Shaders/Model/EdgeVisibilityStageFS.glsl b/packages/engine/Source/Shaders/Model/EdgeVisibilityStageFS.glsl
new file mode 100644
index 000000000000..99081026c07f
--- /dev/null
+++ b/packages/engine/Source/Shaders/Model/EdgeVisibilityStageFS.glsl
@@ -0,0 +1,83 @@
+// CESIUM_REDIRECTED_COLOR_OUTPUT flag is used to avoid color attachment conflicts
+// when shaders are processed by different rendering pipelines (e.g., OIT).
+// Only declare MRT outputs when not in a derived shader context.
+#if defined(HAS_EDGE_VISIBILITY_MRT) && !defined(CESIUM_REDIRECTED_COLOR_OUTPUT)
+layout(location = 1) out vec4 out_id; // edge id / metadata
+layout(location = 2) out vec4 out_edgeDepth; // packed depth
+#endif
+
+void edgeVisibilityStage(inout vec4 color, inout FeatureIds featureIds)
+{
+#ifdef HAS_EDGE_VISIBILITY
+
+ if (!u_isEdgePass) {
+ return;
+ }
+
+ float edgeTypeInt = v_edgeType * 255.0;
+
+ // Color code different edge types
+ vec4 edgeColor = vec4(0.0);
+
+ if (edgeTypeInt < 0.5) { // HIDDEN (0)
+ edgeColor = vec4(0.0, 0.0, 0.0, 0.0); // Transparent for hidden edges
+ }
+ else if (edgeTypeInt > 0.5 && edgeTypeInt < 1.5) { // SILHOUETTE (1) - Conditional visibility
+ // Proper silhouette detection using face normals
+ vec3 normalA = normalize(v_faceNormalAView);
+ vec3 normalB = normalize(v_faceNormalBView);
+
+ // Calculate view direction using existing eye-space position varying (v_positionEC)
+ vec3 viewDir = -normalize(v_positionEC);
+
+ // Calculate dot products to determine triangle facing
+ float dotA = dot(normalA, viewDir);
+ float dotB = dot(normalB, viewDir);
+
+ const float eps = 1e-3;
+ bool frontA = dotA > eps;
+ bool backA = dotA < -eps;
+ bool frontB = dotB > eps;
+ bool backB = dotB < -eps;
+
+ // True silhouette: one triangle front-facing, other back-facing
+ bool oppositeFacing = (frontA && backB) || (backA && frontB);
+
+ // Exclude edges where both triangles are nearly grazing (perpendicular to view)
+ // This handles the top-view cylinder case where both normals are ~horizontal
+ bool bothNearGrazing = (abs(dotA) <= eps && abs(dotB) <= eps);
+
+ if (!(oppositeFacing && !bothNearGrazing)) {
+ discard; // Not a true silhouette edge
+ } else {
+ // True silhouette
+ edgeColor = vec4(1.0, 0.0, 0.0, 1.0);
+ }
+ }
+ else if (edgeTypeInt > 1.5 && edgeTypeInt < 2.5) { // HARD (2) - BRIGHT GREEN
+ edgeColor = vec4(0.0, 1.0, 0.0, 1.0); // Extra bright green
+ }
+ else if (edgeTypeInt > 2.5 && edgeTypeInt < 3.5) { // REPEATED (3)
+ edgeColor = vec4(0.0, 0.0, 1.0, 1.0);
+ } else {
+ edgeColor = vec4(0.0, 0.0, 0.0, 0.0);
+ }
+
+ // Temporary color: white
+ edgeColor = vec4(1.0, 1.0, 1.0, 1.0);
+ color = edgeColor;
+
+ #if defined(HAS_EDGE_VISIBILITY_MRT) && !defined(CESIUM_REDIRECTED_COLOR_OUTPUT)
+ // Write edge metadata
+ out_id = vec4(0.0);
+ out_id.r = edgeTypeInt; // Edge type (0-3)
+#ifdef HAS_EDGE_FEATURE_ID
+ out_id.g = float(featureIds.featureId_0); // Feature ID if available
+#else
+ out_id.g = 0.0;
+#endif
+ // Pack depth into separate MRT attachment
+ out_edgeDepth = czm_packDepth(gl_FragCoord.z);
+ #endif
+#endif
+}
diff --git a/packages/engine/Source/Shaders/Model/ModelFS.glsl b/packages/engine/Source/Shaders/Model/ModelFS.glsl
index 33f106ea02b9..76d3469ef094 100644
--- a/packages/engine/Source/Shaders/Model/ModelFS.glsl
+++ b/packages/engine/Source/Shaders/Model/ModelFS.glsl
@@ -119,6 +119,11 @@ void main()
atmosphereStage(color, attributes);
#endif
+ #ifdef HAS_EDGE_VISIBILITY
+ edgeVisibilityStage(color, featureIds);
+ edgeDetectionStage(color, featureIds);
+ #endif
+
#endif
// When not picking metadata END
//========================================================================
diff --git a/packages/engine/Source/Shaders/Model/PrimitiveOutlineStageFS.glsl b/packages/engine/Source/Shaders/Model/PrimitiveOutlineStageFS.glsl
index 8b5c9afe784d..817b83223dc2 100644
--- a/packages/engine/Source/Shaders/Model/PrimitiveOutlineStageFS.glsl
+++ b/packages/engine/Source/Shaders/Model/PrimitiveOutlineStageFS.glsl
@@ -13,4 +13,3 @@ void primitiveOutlineStage(inout czm_modelMaterial material) {
material.diffuse = mix(material.diffuse, model_outlineColor.rgb, model_outlineColor.a * outlineness);
}
-
diff --git a/packages/engine/Specs/Scene/EdgeFramebufferSpec.js b/packages/engine/Specs/Scene/EdgeFramebufferSpec.js
new file mode 100644
index 000000000000..b4cd26e9a49c
--- /dev/null
+++ b/packages/engine/Specs/Scene/EdgeFramebufferSpec.js
@@ -0,0 +1,169 @@
+import { EdgeFramebuffer, Texture } from "../../index.js";
+import createScene from "../../../../Specs/createScene.js";
+
+describe("Scene/EdgeFramebuffer", function () {
+ let scene;
+ let context;
+
+ beforeAll(function () {
+ scene = createScene();
+ context = scene.context;
+ });
+
+ afterAll(function () {
+ scene.destroyForSpecs();
+ });
+
+ it("constructs", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ expect(edgeFramebuffer).toBeDefined();
+ expect(edgeFramebuffer.isDestroyed()).toBe(false);
+ });
+
+ it("creates framebuffer with correct dimensions", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ expect(edgeFramebuffer.framebuffer).toBeDefined();
+ expect(edgeFramebuffer.colorTexture.width).toBe(viewport.width);
+ expect(edgeFramebuffer.colorTexture.height).toBe(viewport.height);
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("creates multiple render targets", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ const framebuffer = edgeFramebuffer.framebuffer;
+ expect(framebuffer.numberOfColorAttachments).toBeGreaterThan(1);
+
+ expect(framebuffer.getColorTexture(0)).toBeDefined(); // Edge color texture
+ expect(framebuffer.getColorTexture(1)).toBeDefined(); // Edge ID texture
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("updates framebuffer when dimensions change", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ let viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+ const originalFramebuffer = edgeFramebuffer.framebuffer;
+
+ viewport = { width: 512, height: 512 };
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ expect(edgeFramebuffer.framebuffer).not.toBe(originalFramebuffer);
+ expect(edgeFramebuffer.colorTexture.width).toBe(viewport.width);
+ expect(edgeFramebuffer.colorTexture.height).toBe(viewport.height);
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("does not update framebuffer when dimensions are the same", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+ const originalFramebuffer = edgeFramebuffer.framebuffer;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+ expect(edgeFramebuffer.framebuffer).toBe(originalFramebuffer);
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("provides access to edge color texture", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ const edgeColorTexture = edgeFramebuffer.colorTexture;
+ expect(edgeColorTexture).toBeDefined();
+ expect(edgeColorTexture instanceof Texture).toBe(true);
+ expect(edgeColorTexture.width).toBe(viewport.width);
+ expect(edgeColorTexture.height).toBe(viewport.height);
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("provides access to edge ID texture", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ const edgeIdTexture = edgeFramebuffer.idTexture;
+ expect(edgeIdTexture).toBeDefined();
+ expect(edgeIdTexture instanceof Texture).toBe(true);
+ expect(edgeIdTexture.width).toBe(viewport.width);
+ expect(edgeIdTexture.height).toBe(viewport.height);
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("clears the framebuffer", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+
+ expect(function () {
+ const clearCommand = edgeFramebuffer.getClearCommand();
+ expect(clearCommand).toBeDefined();
+ }).not.toThrow();
+
+ edgeFramebuffer.destroy();
+ });
+
+ it("destroys properly", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const viewport = { width: 256, height: 256 };
+ const hdr = false;
+
+ edgeFramebuffer.update(context, viewport, hdr);
+ expect(edgeFramebuffer.isDestroyed()).toBe(false);
+
+ edgeFramebuffer.destroy();
+ expect(edgeFramebuffer.isDestroyed()).toBe(true);
+ });
+
+ it("can be destroyed multiple times without errors", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ expect(edgeFramebuffer.isDestroyed()).toBe(false);
+
+ edgeFramebuffer.destroy();
+ expect(edgeFramebuffer.isDestroyed()).toBe(true);
+
+ // Second destroy should not throw but object is already destroyed
+ expect(edgeFramebuffer.isDestroyed()).toBe(true);
+ });
+
+ it("handles invalid dimensions gracefully", function () {
+ const edgeFramebuffer = new EdgeFramebuffer();
+ const hdr = false;
+
+ expect(function () {
+ edgeFramebuffer.update(context, { width: 0, height: 256 }, hdr);
+ }).toThrowDeveloperError();
+
+ expect(function () {
+ edgeFramebuffer.update(context, { width: 256, height: 0 }, hdr);
+ }).toThrowDeveloperError();
+
+ edgeFramebuffer.destroy();
+ });
+});
diff --git a/packages/engine/Specs/Scene/GltfLoaderSpec.js b/packages/engine/Specs/Scene/GltfLoaderSpec.js
index 9bd24e569adb..41ac57e6330d 100644
--- a/packages/engine/Specs/Scene/GltfLoaderSpec.js
+++ b/packages/engine/Specs/Scene/GltfLoaderSpec.js
@@ -130,6 +130,8 @@ describe(
"./Data/Models/glTF-2.0/BoxClearcoat/glTF/BoxClearcoat.gltf";
const meshPrimitiveRestartTestData =
"./Data/Models/glTF-2.0/MeshPrimitiveRestart/glTF/MeshPrimitiveRestart.gltf";
+ const edgeVisibilityTestData =
+ "./Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb";
let scene;
const gltfLoaders = [];
@@ -4261,6 +4263,132 @@ describe(
expect(loadedPrimitives.length).toBe(8);
});
+ it("loads model with EXT_mesh_primitive_edge_visibility extension", async function () {
+ const gltfLoader = await loadGltf(edgeVisibilityTestData);
+ const components = gltfLoader.components;
+ const scene = components.scene;
+
+ expect(scene).toBeDefined();
+ expect(scene.nodes).toBeDefined();
+ expect(scene.nodes.length).toBeGreaterThan(0);
+
+ const primitive = scene.nodes[0].primitives[0];
+ expect(primitive).toBeDefined();
+
+ expect(primitive.edgeVisibility).toBeDefined();
+ expect(primitive.edgeVisibility.visibility).toBeDefined();
+ expect(primitive.edgeVisibility.silhouetteNormals).toBeDefined();
+ expect(primitive.edgeVisibility.silhouetteNormals.length).toBeGreaterThan(
+ 0,
+ );
+ });
+
+ it("processes edge visibility data correctly", async function () {
+ const gltfLoader = await loadGltf(edgeVisibilityTestData);
+ const components = gltfLoader.components;
+ const scene = components.scene;
+ const primitive = scene.nodes[0].primitives[0];
+
+ const edgeVisibility = primitive.edgeVisibility;
+ expect(edgeVisibility).toBeDefined();
+
+ const visibilityData = edgeVisibility.visibility;
+ expect(visibilityData).toBeDefined();
+ expect(visibilityData.length).toBeGreaterThan(0);
+
+ let hasValidVisibilityValues = false;
+ for (let i = 0; i < visibilityData.length; i++) {
+ const value = visibilityData[i];
+ if (value !== 0) {
+ hasValidVisibilityValues = true;
+ expect(value).toBeGreaterThanOrEqual(0);
+ expect(value).toBeLessThanOrEqual(255);
+ }
+ }
+ expect(hasValidVisibilityValues).toBe(true);
+ });
+
+ it("loads primitive without edge visibility extension", async function () {
+ const gltfLoader = await loadGltf(triangle);
+ const components = gltfLoader.components;
+ const scene = components.scene;
+ const primitive = scene.nodes[0].primitives[0];
+
+ expect(primitive.edgeVisibility).toBeUndefined();
+ });
+
+ it("validates edge visibility bitfield format", async function () {
+ const gltfLoader = await loadGltf(edgeVisibilityTestData);
+ const components = gltfLoader.components;
+ const scene = components.scene;
+ const primitive = scene.nodes[0].primitives[0];
+
+ const edgeVisibility = primitive.edgeVisibility;
+ expect(edgeVisibility).toBeDefined();
+ expect(edgeVisibility.visibility).toBeDefined();
+
+ const visibilityData = edgeVisibility.visibility;
+ expect(visibilityData.length).toBeGreaterThan(0);
+
+ let hasVisibleEdges = false;
+ for (let i = 0; i < visibilityData.length; i++) {
+ const byte = visibilityData[i];
+
+ for (let bit = 0; bit < 8; bit += 2) {
+ const edgeVisibility = (byte >> bit) & 0x3;
+ expect(edgeVisibility).toBeGreaterThanOrEqual(0);
+ expect(edgeVisibility).toBeLessThanOrEqual(3);
+ if (edgeVisibility === 1 || edgeVisibility === 2) {
+ hasVisibleEdges = true;
+ }
+ }
+ }
+ expect(hasVisibleEdges).toBe(true);
+ });
+
+ it("handles edge visibility silhouette normals", async function () {
+ const gltfLoader = await loadGltf(edgeVisibilityTestData);
+ const components = gltfLoader.components;
+ const scene = components.scene;
+ const primitive = scene.nodes[0].primitives[0];
+
+ const edgeVisibility = primitive.edgeVisibility;
+ expect(edgeVisibility).toBeDefined();
+ expect(edgeVisibility.silhouetteNormals).toBeDefined();
+
+ const silhouetteNormals = edgeVisibility.silhouetteNormals;
+ expect(silhouetteNormals.length).toBeGreaterThan(0);
+
+ for (let i = 0; i < silhouetteNormals.length; i++) {
+ const normal = silhouetteNormals[i];
+ expect(normal).toBeDefined();
+ expect(normal.x).toBeDefined();
+ expect(normal.y).toBeDefined();
+ expect(normal.z).toBeDefined();
+ expect(typeof normal.x).toBe("number");
+ expect(typeof normal.y).toBe("number");
+ expect(typeof normal.z).toBe("number");
+ expect(isNaN(normal.x)).toBe(false);
+ expect(isNaN(normal.y)).toBe(false);
+ expect(isNaN(normal.z)).toBe(false);
+ }
+ });
+
+ it("validates edge visibility data loading", async function () {
+ const gltfLoader = await loadGltf(edgeVisibilityTestData);
+ const primitive = gltfLoader.components.scene.nodes[0].primitives[0];
+
+ expect(primitive.edgeVisibility).toBeDefined();
+ expect(primitive.edgeVisibility.visibility).toBeDefined();
+ expect(primitive.edgeVisibility.visibility.length).toBeGreaterThan(0);
+
+ if (primitive.edgeVisibility.silhouetteNormals) {
+ expect(
+ primitive.edgeVisibility.silhouetteNormals.length,
+ ).toBeGreaterThanOrEqual(0);
+ }
+ });
+
it("parses copyright field", function () {
return loadGltf(boxWithCredits).then(function (gltfLoader) {
const components = gltfLoader.components;
diff --git a/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js b/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js
new file mode 100644
index 000000000000..482664be9df3
--- /dev/null
+++ b/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js
@@ -0,0 +1,513 @@
+import {
+ Buffer,
+ BufferUsage,
+ ComponentDatatype,
+ IndexDatatype,
+ PrimitiveType,
+ ShaderBuilder,
+ ShaderDestination,
+ VertexAttributeSemantic,
+} from "../../../index.js";
+import createContext from "../../../../../Specs/createContext.js";
+import EdgeVisibilityPipelineStage from "../../../Source/Scene/Model/EdgeVisibilityPipelineStage.js";
+
+describe("Scene/Model/EdgeVisibilityPipelineStage", function () {
+ let context;
+
+ beforeAll(function () {
+ context = createContext();
+ });
+
+ afterAll(function () {
+ context.destroyForSpecs();
+ });
+
+ function createTestEdgeVisibilityData() {
+ // Test case from GltfLoader: Simple 2-triangle quad with shared silhouette edge
+ // Triangles: [0,1,2, 0,2,3]
+ // Edge visibility: [VISIBLE,HIDDEN,SILHOUETTE, HIDDEN,VISIBLE,HIDDEN] = [2,0,1, 0,2,0]
+ // Expected bytes: [18, 2] = [00010010, 00000010]
+ const testVisibilityBuffer = new Uint8Array([18, 2]);
+
+ return {
+ visibility: testVisibilityBuffer,
+ silhouetteNormals: new Float32Array([
+ 0.0,
+ 0.0,
+ 1.0, // Edge 0 silhouette normal
+ 0.0,
+ 1.0,
+ 0.0, // Edge 2 silhouette normal
+ ]),
+ };
+ }
+
+ function createTestPrimitive() {
+ // Create a simple 2-triangle quad
+ // Vertices: (0,0,0), (1,0,0), (1,1,0), (0,1,0)
+ // Triangles: [0,1,2], [0,2,3]
+ const positions = new Float32Array([
+ 0.0,
+ 0.0,
+ 0.0, // vertex 0
+ 1.0,
+ 0.0,
+ 0.0, // vertex 1
+ 1.0,
+ 1.0,
+ 0.0, // vertex 2
+ 0.0,
+ 1.0,
+ 0.0, // vertex 3
+ ]);
+
+ const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
+
+ const primitive = {
+ attributes: [
+ {
+ semantic: VertexAttributeSemantic.POSITION,
+ componentDatatype: ComponentDatatype.FLOAT,
+ count: 4,
+ typedArray: positions,
+ buffer: Buffer.createVertexBuffer({
+ context: context,
+ typedArray: positions,
+ usage: BufferUsage.STATIC_DRAW,
+ }),
+ strideInBytes: 12,
+ offsetInBytes: 0,
+ },
+ ],
+ indices: {
+ indexDatatype: IndexDatatype.UNSIGNED_SHORT,
+ count: 6,
+ typedArray: indices,
+ buffer: Buffer.createIndexBuffer({
+ context: context,
+ typedArray: indices,
+ usage: BufferUsage.STATIC_DRAW,
+ indexDatatype: IndexDatatype.UNSIGNED_SHORT,
+ }),
+ },
+ mode: PrimitiveType.TRIANGLES,
+ edgeVisibility: createTestEdgeVisibilityData(),
+ };
+
+ return primitive;
+ }
+
+ function createMockRenderResources(primitive) {
+ const shaderBuilder = new ShaderBuilder();
+
+ // Pre-add the required function that EdgeVisibilityPipelineStage expects
+ shaderBuilder.addFunction(
+ "setDynamicVaryingsVS",
+ "void setDynamicVaryingsVS()\n{\n}",
+ ShaderDestination.VERTEX,
+ );
+
+ return {
+ shaderBuilder: shaderBuilder,
+ uniformMap: {},
+ runtimePrimitive: {
+ primitive: primitive,
+ },
+ };
+ }
+
+ function createMockFrameState() {
+ return {
+ context: context,
+ };
+ }
+
+ it("decodes edge visibility test data correctly", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ // Process the primitive through EdgeVisibilityPipelineStage
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ // Verify edge geometry was created
+ expect(renderResources.edgeGeometry).toBeDefined();
+ expect(renderResources.edgeGeometry.vertexArray).toBeDefined();
+ expect(renderResources.edgeGeometry.indexCount).toBeGreaterThan(0);
+ expect(renderResources.edgeGeometry.primitiveType).toBe(
+ PrimitiveType.LINES,
+ );
+ });
+
+ it("extracts correct edge visibility values from test buffer", function () {
+ const testVisibilityBuffer = new Uint8Array([18, 2]);
+
+ // Test decoding of each edge manually
+ // Expected pattern: [HARD(2), HIDDEN(0), SILHOUETTE(1), HIDDEN(0), HARD(2), HIDDEN(0)]
+
+ // Edge 0: bits 0-1 of byte 0 (18 = 00010010)
+ const edge0 = (testVisibilityBuffer[0] >> 0) & 0x3;
+ expect(edge0).toBe(2); // HARD edge
+
+ // Edge 1: bits 2-3 of byte 0
+ const edge1 = (testVisibilityBuffer[0] >> 2) & 0x3;
+ expect(edge1).toBe(0); // HIDDEN edge
+
+ // Edge 2: bits 4-5 of byte 0
+ const edge2 = (testVisibilityBuffer[0] >> 4) & 0x3;
+ expect(edge2).toBe(1); // SILHOUETTE edge
+
+ // Edge 3: bits 6-7 of byte 0
+ const edge3 = (testVisibilityBuffer[0] >> 6) & 0x3;
+ expect(edge3).toBe(0); // HIDDEN edge
+
+ // Edge 4: bits 0-1 of byte 1 (2 = 00000010)
+ const edge4 = (testVisibilityBuffer[1] >> 0) & 0x3;
+ expect(edge4).toBe(2); // HARD edge
+
+ // Edge 5: bits 2-3 of byte 1
+ const edge5 = (testVisibilityBuffer[1] >> 2) & 0x3;
+ expect(edge5).toBe(0); // HIDDEN edge
+ });
+
+ it("processes triangle edges in correct order", function () {
+ const primitive = createTestPrimitive();
+ const indices = primitive.indices.typedArray; // [0,1,2, 0,2,3]
+
+ // Verify triangle structure
+ expect(indices.length).toBe(6);
+
+ // Triangle 0: vertices [0,1,2]
+ expect(indices[0]).toBe(0);
+ expect(indices[1]).toBe(1);
+ expect(indices[2]).toBe(2);
+
+ // Triangle 1: vertices [0,2,3]
+ expect(indices[3]).toBe(0);
+ expect(indices[4]).toBe(2);
+ expect(indices[5]).toBe(3);
+
+ // Expected edges from triangles:
+ // Triangle 0: edges (0,1), (1,2), (2,0)
+ // Triangle 1: edges (0,2), (2,3), (3,0)
+ // Total 6 edges with visibility pattern [2,0,1, 0,2,0]
+ });
+
+ it("filters edges based on visibility values", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ // With visibility pattern [2,0,1, 0,2,0]:
+ // - Edge 0 (HARD, value 2): should be included
+ // - Edge 1 (HIDDEN, value 0): should be excluded
+ // - Edge 2 (SILHOUETTE, value 1): should be included
+ // - Edge 3 (HIDDEN, value 0): should be excluded
+ // - Edge 4 (HARD, value 2): should be included
+ // - Edge 5 (HIDDEN, value 0): should be excluded
+
+ expect(renderResources.edgeGeometry).toBeDefined();
+
+ // Expected 3 unique visible edges: (0,1), (0,2), (2,3)
+ // Each edge creates 2 indices (line primitive), so indexCount should be 6
+ expect(renderResources.edgeGeometry.indexCount).toBe(6);
+ expect(renderResources.edgeGeometry.indexCount % 2).toBe(0); // Even number for lines
+ expect(renderResources.edgeGeometry.primitiveType).toBe(
+ PrimitiveType.LINES,
+ );
+ });
+
+ it("handles silhouette edges correctly", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ // Verify silhouette-specific attributes are added
+ const shaderBuilder = renderResources.shaderBuilder;
+ const shaderProgram = shaderBuilder.buildShaderProgram(context);
+
+ // Check for edge visibility defines in the shader program
+ expect(shaderProgram._vertexShaderText).toContain("HAS_EDGE_VISIBILITY");
+ expect(shaderProgram._fragmentShaderText).toContain(
+ "HAS_EDGE_VISIBILITY_MRT",
+ );
+
+ // Check for silhouette-related attributes
+ expect(shaderProgram._vertexShaderText).toContain("a_silhouetteNormal");
+ expect(shaderProgram._vertexShaderText).toContain("a_faceNormalA");
+ expect(shaderProgram._vertexShaderText).toContain("a_faceNormalB");
+ });
+
+ it("creates proper edge geometry buffers", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ expect(renderResources.edgeGeometry).toBeDefined();
+
+ const vertexArray = renderResources.edgeGeometry.vertexArray;
+ expect(vertexArray).toBeDefined();
+ expect(vertexArray.indexBuffer).toBeDefined();
+ expect(vertexArray._attributes).toBeDefined();
+
+ // Verify the vertex array has the expected attributes for edge rendering
+ const attributes = vertexArray._attributes;
+ expect(attributes.length).toBeGreaterThan(0);
+ });
+
+ it("handles edge deduplication correctly", function () {
+ const primitive = createTestPrimitive();
+
+ // Manually extract edges to test deduplication logic
+ const indices = primitive.indices.typedArray; // [0,1,2, 0,2,3]
+ const testVisibility = primitive.edgeVisibility.visibility; // [18, 2]
+
+ const expectedEdges = new Set();
+ const visibilityValues = [];
+ let edgeIndex = 0;
+
+ // Process each triangle's edges and collect expected results
+ for (let i = 0; i + 2 < indices.length; i += 3) {
+ const v0 = indices[i];
+ const v1 = indices[i + 1];
+ const v2 = indices[i + 2];
+
+ const triangleEdges = [
+ [v0, v1], // Edge 0 of current triangle
+ [v1, v2], // Edge 1 of current triangle
+ [v2, v0], // Edge 2 of current triangle
+ ];
+
+ for (let e = 0; e < 3; e++) {
+ const byteIndex = Math.floor(edgeIndex / 4);
+ const bitPairOffset = (edgeIndex % 4) * 2;
+ const visibility2Bit =
+ (testVisibility[byteIndex] >> bitPairOffset) & 0x3;
+ visibilityValues.push(visibility2Bit);
+
+ if (visibility2Bit !== 0) {
+ // Not HIDDEN
+ const [a, b] = triangleEdges[e];
+ const edgeKey = `${Math.min(a, b)},${Math.max(a, b)}`;
+ expectedEdges.add(edgeKey);
+ }
+
+ edgeIndex++;
+ }
+ }
+
+ // Verify the expected visibility pattern [HARD(2), HIDDEN(0), SILHOUETTE(1), HIDDEN(0), HARD(2), HIDDEN(0)]
+ expect(visibilityValues).toEqual([2, 0, 1, 0, 2, 0]);
+
+ // Expected visible edges after deduplication:
+ // Triangle 0: edges (0,1)[HARD], (1,2)[HIDDEN], (2,0)[SILHOUETTE] → visible: (0,1), (0,2)
+ // Triangle 1: edges (0,2)[HIDDEN], (2,3)[HARD], (3,0)[HIDDEN] → visible: (2,3)
+ // Total unique visible edges: (0,1), (0,2), (2,3)
+ const expectedEdgeKeys = new Set(["0,1", "0,2", "2,3"]);
+ expect(expectedEdges).toEqual(expectedEdgeKeys);
+ expect(expectedEdges.size).toBe(3);
+ });
+
+ it("sets up uniforms correctly", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ expect(renderResources.uniformMap.u_isEdgePass).toBeDefined();
+ expect(renderResources.uniformMap.u_isEdgePass()).toBe(false);
+ });
+
+ it("validates primitive VAO vs edge VAO structure", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ // Original primitive VAO (triangles)
+ const originalIndices = primitive.indices.typedArray; // [0,1,2, 0,2,3]
+ expect(originalIndices.length).toBe(6); // 6 indices for 2 triangles
+ expect(primitive.mode).toBe(PrimitiveType.TRIANGLES);
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ // Edge VAO (lines)
+ expect(renderResources.edgeGeometry).toBeDefined();
+ expect(renderResources.edgeGeometry.primitiveType).toBe(
+ PrimitiveType.LINES,
+ );
+
+ // With visibility pattern [2,0,1, 0,2,0] → 3 visible edges
+ // Each edge creates 2 vertices, so 6 vertices total
+ expect(renderResources.edgeGeometry.indexCount).toBe(6); // 3 edges × 2 vertices per edge
+ });
+
+ it("validates edge VAO has 6 vertices for 3 visible edges", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ const edgeVertexArray = renderResources.edgeGeometry.vertexArray;
+ expect(edgeVertexArray).toBeDefined();
+
+ // Verify vertex array structure
+ const attributes = edgeVertexArray._attributes;
+ expect(attributes.length).toBeGreaterThan(0);
+
+ // Check that we have the expected vertex buffers
+ let positionAttribute = null;
+ let edgeTypeAttribute = null;
+ let silhouetteNormalAttribute = null;
+ let faceNormalAAttribute = null;
+ let faceNormalBAttribute = null;
+
+ for (let i = 0; i < attributes.length; i++) {
+ const attr = attributes[i];
+ if (attr.index === 0) {
+ // Position at location 0
+ positionAttribute = attr;
+ } else if (attr.componentsPerAttribute === 1) {
+ // Edge type (float)
+ edgeTypeAttribute = attr;
+ } else if (attr.componentsPerAttribute === 3) {
+ // Normals (vec3)
+ if (!silhouetteNormalAttribute) {
+ silhouetteNormalAttribute = attr;
+ } else if (!faceNormalAAttribute) {
+ faceNormalAAttribute = attr;
+ } else if (!faceNormalBAttribute) {
+ faceNormalBAttribute = attr;
+ }
+ }
+ }
+
+ expect(positionAttribute).toBeDefined();
+ expect(edgeTypeAttribute).toBeDefined();
+ expect(silhouetteNormalAttribute).toBeDefined();
+ expect(faceNormalAAttribute).toBeDefined();
+ expect(faceNormalBAttribute).toBeDefined();
+
+ // Verify buffer properties
+ expect(positionAttribute.componentsPerAttribute).toBe(3); // vec3
+ expect(positionAttribute.componentDatatype).toBe(ComponentDatatype.FLOAT);
+
+ expect(edgeTypeAttribute.componentsPerAttribute).toBe(1); // float
+ expect(edgeTypeAttribute.componentDatatype).toBe(ComponentDatatype.FLOAT);
+
+ expect(silhouetteNormalAttribute.componentsPerAttribute).toBe(3); // vec3
+ expect(silhouetteNormalAttribute.componentDatatype).toBe(
+ ComponentDatatype.FLOAT,
+ );
+ });
+
+ it("validates edge VAO vertex data correctness", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ const edgeVertexArray = renderResources.edgeGeometry.vertexArray;
+
+ // With our test data:
+ // - 3 visible edges: (0,1)[HARD], (0,2)[SILHOUETTE], (2,3)[HARD]
+ // - Each edge has 2 vertices
+ // - Total: 6 vertices in edge domain
+
+ // Verify index buffer
+ expect(edgeVertexArray.indexBuffer).toBeDefined();
+ expect(renderResources.edgeGeometry.indexCount).toBe(6);
+
+ // Expected vertex positions in edge domain:
+ // Edge 0: vertices (0,1) → positions: (0,0,0), (1,0,0)
+ // Edge 1: vertices (0,2) → positions: (0,0,0), (1,1,0)
+ // Edge 2: vertices (2,3) → positions: (1,1,0), (0,1,0)
+
+ // The edge VAO creates a separate vertex domain with 6 vertices total
+ const indexBuffer = edgeVertexArray.indexBuffer;
+ expect(indexBuffer).toBeDefined();
+ });
+
+ it("validates silhouette normal VAO data values", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ const edgeVertexArray = renderResources.edgeGeometry.vertexArray;
+ const attributes = edgeVertexArray._attributes;
+
+ // Find silhouette normal attribute buffer
+ let silhouetteNormalBuffer = null;
+ for (let i = 0; i < attributes.length; i++) {
+ const attr = attributes[i];
+ // Look for vec3 attribute that's not position (index 0)
+ if (attr.componentsPerAttribute === 3 && attr.index !== 0) {
+ silhouetteNormalBuffer = attr.vertexBuffer;
+ break;
+ }
+ }
+
+ expect(silhouetteNormalBuffer).toBeDefined();
+
+ expect(silhouetteNormalBuffer.sizeInBytes).toBe(6 * 3 * 4);
+ });
+
+ it("validates edge type VAO data values", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ const edgeVertexArray = renderResources.edgeGeometry.vertexArray;
+ const attributes = edgeVertexArray._attributes;
+
+ // Find edge type attribute buffer (componentsPerAttribute === 1)
+ let edgeTypeBuffer = null;
+ for (let i = 0; i < attributes.length; i++) {
+ const attr = attributes[i];
+ if (attr.componentsPerAttribute === 1) {
+ edgeTypeBuffer = attr.vertexBuffer;
+ break;
+ }
+ }
+
+ expect(edgeTypeBuffer).toBeDefined();
+
+ expect(edgeTypeBuffer.sizeInBytes).toBe(6 * 4);
+ });
+
+ it("validates edge position VAO data values", function () {
+ const primitive = createTestPrimitive();
+ const renderResources = createMockRenderResources(primitive);
+ const frameState = createMockFrameState();
+
+ EdgeVisibilityPipelineStage.process(renderResources, primitive, frameState);
+
+ const edgeVertexArray = renderResources.edgeGeometry.vertexArray;
+ const attributes = edgeVertexArray._attributes;
+
+ // Find position attribute (index === 0)
+ let positionBuffer = null;
+ for (let i = 0; i < attributes.length; i++) {
+ const attr = attributes[i];
+ if (attr.index === 0) {
+ positionBuffer = attr.vertexBuffer;
+ break;
+ }
+ }
+
+ expect(positionBuffer).toBeDefined();
+ expect(positionBuffer.sizeInBytes).toBe(6 * 3 * 4);
+ });
+});
diff --git a/packages/engine/Specs/Scene/Model/EdgeVisibilityRenderingSpec.js b/packages/engine/Specs/Scene/Model/EdgeVisibilityRenderingSpec.js
new file mode 100644
index 000000000000..7a3729d04725
--- /dev/null
+++ b/packages/engine/Specs/Scene/Model/EdgeVisibilityRenderingSpec.js
@@ -0,0 +1,220 @@
+import { Cartesian3, Model, Pass, Transforms } from "../../../index.js";
+
+import createScene from "../../../../../Specs/createScene.js";
+import pollToPromise from "../../../../../Specs/pollToPromise.js";
+
+describe("Scene/Model/EdgeVisibilityRendering", function () {
+ let scene;
+ const edgeVisibilityTestData =
+ "./Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb";
+
+ beforeAll(function () {
+ scene = createScene();
+ });
+
+ afterAll(function () {
+ scene.destroyForSpecs();
+ });
+
+ afterEach(function () {
+ scene.primitives.removeAll();
+ scene._enableEdgeVisibility = true;
+ });
+
+ function waitForModelReady(model) {
+ return pollToPromise(function () {
+ scene.renderForSpecs();
+ return model.ready;
+ });
+ }
+
+ async function loadEdgeVisibilityModel() {
+ const model = await Model.fromGltfAsync({
+ url: edgeVisibilityTestData,
+ modelMatrix: Transforms.eastNorthUpToFixedFrame(
+ Cartesian3.fromDegrees(0.0, 0.0, 100.0),
+ ),
+ });
+
+ scene.primitives.add(model);
+ await waitForModelReady(model);
+ return model;
+ }
+
+ it("validates u_isEdgePass uniform and framebuffer attachments", async function () {
+ // Skip this test in WebGL stub environment
+ if (!!window.webglStub) {
+ pending("Skipping test in WebGL stub environment");
+ }
+
+ await loadEdgeVisibilityModel();
+
+ scene._enableEdgeVisibility = true;
+ scene.renderForSpecs();
+
+ const commands = scene.frameState.commandList;
+ let edgeCommand = null;
+ let regularCommand = null;
+
+ for (let i = 0; i < commands.length; i++) {
+ const command = commands[i];
+ if (command.pass === Pass.CESIUM_3D_TILE_EDGES) {
+ edgeCommand = command;
+ } else if (command.pass === Pass.CESIUM_3D_TILE) {
+ regularCommand = command;
+ }
+ }
+
+ expect(edgeCommand).toBeDefined();
+ expect(regularCommand).toBeDefined();
+
+ if (
+ edgeCommand &&
+ edgeCommand.uniformMap &&
+ edgeCommand.uniformMap.u_isEdgePass
+ ) {
+ expect(edgeCommand.uniformMap.u_isEdgePass()).toBe(true);
+ }
+
+ if (
+ regularCommand &&
+ regularCommand.uniformMap &&
+ regularCommand.uniformMap.u_isEdgePass
+ ) {
+ expect(regularCommand.uniformMap.u_isEdgePass()).toBe(false);
+ }
+
+ // Verify edge framebuffer attachments are not default textures
+ expect(scene._view.edgeFramebuffer).toBeDefined();
+ const edgeFramebuffer = scene._view.edgeFramebuffer;
+ expect(edgeFramebuffer.colorTexture).not.toBe(scene.context.defaultTexture);
+
+ if (edgeFramebuffer._supportsMRT && edgeFramebuffer.idTexture) {
+ expect(edgeFramebuffer.idTexture).not.toBe(scene.context.defaultTexture);
+ }
+ });
+
+ it("validates EdgeVisibility shader code and uniforms", async function () {
+ // Skip this test in WebGL stub environment
+ if (!!window.webglStub) {
+ pending("Skipping test in WebGL stub environment");
+ }
+
+ await loadEdgeVisibilityModel();
+
+ scene._enableEdgeVisibility = true;
+ scene.renderForSpecs();
+
+ const commands = scene.frameState.commandList;
+ let edgeCommand = null;
+
+ for (let i = 0; i < commands.length; i++) {
+ const command = commands[i];
+ if (command.pass === Pass.CESIUM_3D_TILE_EDGES) {
+ edgeCommand = command;
+ break;
+ }
+ }
+
+ expect(edgeCommand).toBeDefined();
+
+ const vertexShader = edgeCommand.shaderProgram._vertexShaderText;
+ const fragmentShader = edgeCommand.shaderProgram._fragmentShaderText;
+
+ // Verify EdgeVisibility stage shader defines
+ expect(vertexShader).toContain("HAS_EDGE_VISIBILITY");
+ expect(fragmentShader).toContain("HAS_EDGE_VISIBILITY");
+ expect(fragmentShader).toContain("HAS_EDGE_VISIBILITY_MRT");
+
+ // Verify edge visibility uniforms and attributes
+ expect(vertexShader).toContain("u_isEdgePass");
+ expect(vertexShader).toContain("a_edgeType");
+ expect(vertexShader).toContain("a_silhouetteNormal");
+ expect(vertexShader).toContain("a_faceNormalA");
+ expect(vertexShader).toContain("a_faceNormalB");
+
+ // Verify varying variables for normal calculations
+ expect(vertexShader).toContain("v_edgeType");
+ expect(vertexShader).toContain("v_faceNormalAView");
+ expect(vertexShader).toContain("v_faceNormalBView");
+
+ // Verify fragment shader edge type color coding
+ expect(fragmentShader).toContain("v_edgeType * 255.0");
+
+ // Verify silhouette normal calculation
+ expect(fragmentShader).toContain("normalize(v_faceNormalAView)");
+ expect(fragmentShader).toContain("normalize(v_faceNormalBView)");
+ expect(fragmentShader).toContain("dot(normalA, viewDir)");
+ expect(fragmentShader).toContain("dot(normalB, viewDir)");
+
+ // Verify MRT output (color attachment 1)
+ expect(fragmentShader).toContain("out_id");
+ expect(fragmentShader).toContain("featureIds.featureId_0");
+
+ expect(edgeCommand.uniformMap.u_isEdgePass()).toBe(true);
+ });
+
+ it("validates EdgeDetection shader code and texture sampling", async function () {
+ // Skip this test in WebGL stub environment
+ if (!!window.webglStub) {
+ pending("Skipping test in WebGL stub environment");
+ }
+
+ await loadEdgeVisibilityModel();
+
+ scene._enableEdgeVisibility = true;
+ scene.renderForSpecs();
+
+ const commands = scene.frameState.commandList;
+ let edgeCommand = null;
+
+ for (let i = 0; i < commands.length; i++) {
+ const command = commands[i];
+ if (command.pass === Pass.CESIUM_3D_TILE_EDGES) {
+ edgeCommand = command;
+ break;
+ }
+ }
+
+ expect(edgeCommand).toBeDefined();
+
+ const fragmentShader = edgeCommand.shaderProgram._fragmentShaderText;
+
+ // Verify EdgeDetection stage shader includes edge detection function
+ expect(fragmentShader).toContain("edgeDetectionStage");
+ expect(fragmentShader).toContain("u_isEdgePass");
+
+ // Verify screen coordinate calculation
+ expect(fragmentShader).toContain("gl_FragCoord.xy / czm_viewport.zw");
+
+ // Verify texture sampling from EdgeVisibility pass output
+ expect(fragmentShader).toContain(
+ "texture(czm_edgeColorTexture, screenCoord)",
+ );
+ expect(fragmentShader).toContain("texture(czm_edgeIdTexture, screenCoord)");
+ expect(fragmentShader).toContain(
+ "texture(czm_globeDepthTexture, screenCoord)",
+ );
+
+ // Verify edge ID and feature ID comparison
+ expect(fragmentShader).toContain("edgeId.r > 0.0");
+ expect(fragmentShader).toContain("edgeId.g"); // edgeFeatureId
+ expect(fragmentShader).toContain("featureIds.featureId_0"); // currentFeatureId
+ expect(fragmentShader).toContain("edgeFeatureId != currentFeatureId");
+
+ // Verify depth comparison for background/globe rendering
+ expect(fragmentShader).toContain("czm_unpackDepth");
+ expect(fragmentShader).toContain("gl_FragCoord.z > globeDepth");
+
+ // Verify color inheritance from edge pass
+ expect(fragmentShader).toContain("color = edgeColor");
+
+ // Verify the uniforms reference correct textures
+ const uniformState = scene.context.uniformState;
+ const edgeFramebuffer = scene._view.edgeFramebuffer;
+ expect(uniformState.edgeColorTexture).toBe(edgeFramebuffer.colorTexture);
+ if (edgeFramebuffer._supportsMRT) {
+ expect(uniformState.edgeIdTexture).toBe(edgeFramebuffer.idTexture);
+ }
+ });
+});