From 0886f854e4695b8519052ce8e9b2e776ea9224d5 Mon Sep 17 00:00:00 2001 From: danielzhong <32878167+danielzhong@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:37:50 -0400 Subject: [PATCH 1/4] init --- .../Model/EdgeVisibilityPipelineStage.js | 108 ++++++------------ 1 file changed, 38 insertions(+), 70 deletions(-) diff --git a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js index eae237cec6c8..55c6b9932720 100644 --- a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js +++ b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js @@ -5,9 +5,7 @@ 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 Cartesian2 from "../../Core/Cartesian2.js"; import Cartesian3 from "../../Core/Cartesian3.js"; -import AttributeCompression from "../../Core/AttributeCompression.js"; import Pass from "../../Renderer/Pass.js"; import ShaderDestination from "../../Renderer/ShaderDestination.js"; import EdgeVisibilityStageFS from "../../Shaders/Model/EdgeVisibilityStageFS.js"; @@ -358,6 +356,14 @@ function buildTriangleAdjacency(primitive) { Cartesian3.subtract(scratchP1, scratchP0, scratchE1); Cartesian3.subtract(scratchP2, scratchP0, scratchE2); Cartesian3.cross(scratchE1, scratchE2, scratchCross); + + // Skip degenerate triangles (zero-area): their cross product is the zero + // vector, which cannot be normalized and would produce NaN face normals. + const crossMagnitudeSquared = Cartesian3.magnitudeSquared(scratchCross); + if (crossMagnitudeSquared === 0.0) { + continue; + } + Cartesian3.normalize(scratchCross, scratchCross); faceNormals[base] = scratchCross.x; @@ -397,58 +403,32 @@ function generateEdgeFaceNormals( const hasGLBSilhouetteNormals = defined(edgeVisibility) && defined(edgeVisibility.silhouetteNormals); - let silhouetteNormalsUint32 = null; - const scratchDecodedA = new Cartesian3(); - const scratchDecodedB = new Cartesian3(); - if (hasGLBSilhouetteNormals) { - // GLB stores VEC3 BYTE as normalized normal vectors (signed bytes). - // Decode from signed bytes to normalized vectors, then re-encode to 16-bit octahedral format. - const decodeSignedByte = (val) => 2 * ((val + 128) / 255) - 1; + // Decode GLB silhouette normals from signed-byte VEC3 to plain float Cartesian3 once, + // so each edge lookup is a direct array read with no further conversion. + let silhouetteNormalsFloat = null; - // Re-encode each VEC3 BYTE to 16-bit oct-encoded normal using AttributeCompression - const uint16Normals = new Uint16Array( - edgeVisibility.silhouetteNormals.length, - ); + if (hasGLBSilhouetteNormals) { + const raw = edgeVisibility.silhouetteNormals; + silhouetteNormalsFloat = new Array(raw.length); const scratchNormal = new Cartesian3(); - const scratchEncoded = new Cartesian2(); - - for (let i = 0; i < edgeVisibility.silhouetteNormals.length; i++) { - const vec3 = edgeVisibility.silhouetteNormals[i]; - // Decode from signed byte to normal vector - scratchNormal.x = decodeSignedByte(vec3.x); - scratchNormal.y = decodeSignedByte(vec3.y); - scratchNormal.z = decodeSignedByte(vec3.z); + for (let i = 0; i < raw.length; i++) { + const vec3 = raw[i]; + // Signed byte → float: map [0,255] → [-1,1] + scratchNormal.x = 2 * ((vec3.x + 128) / 255) - 1; + scratchNormal.y = 2 * ((vec3.y + 128) / 255) - 1; + scratchNormal.z = 2 * ((vec3.z + 128) / 255) - 1; - // Normalize to unit vector - const magnitude = Cartesian3.magnitude(scratchNormal); - if (magnitude > 0) { + if (Cartesian3.magnitudeSquared(scratchNormal) > 0) { Cartesian3.normalize(scratchNormal, scratchNormal); } else { - // Handle zero vector - use default up vector scratchNormal.x = 0; scratchNormal.y = 0; scratchNormal.z = 1; } - // Use Cesium's octahedral encoding (returns 0-255 integers) - AttributeCompression.octEncodeInRange(scratchNormal, 255, scratchEncoded); - - // Convert to 16-bit integer: (y << 8) | x - const byteX = scratchEncoded.x & 0xff; - const byteY = scratchEncoded.y & 0xff; - uint16Normals[i] = (byteY << 8) | byteX; - } - - // Pack pairs into Uint32Array (little-endian: normalA|normalB<<16) - const numPairs = Math.floor(uint16Normals.length / 2); - silhouetteNormalsUint32 = new Uint32Array(numPairs); - - for (let i = 0; i < numPairs; i++) { - const normalA = uint16Normals[i * 2]; - const normalB = uint16Normals[i * 2 + 1]; - silhouetteNormalsUint32[i] = normalA | (normalB << 16); + silhouetteNormalsFloat[i] = Cartesian3.clone(scratchNormal); } } @@ -466,37 +446,25 @@ function generateEdgeFaceNormals( // Use GLB silhouetteNormals for type=1 (SILHOUETTE) edges if available if ( hasGLBSilhouetteNormals && - silhouetteNormalsUint32 && + silhouetteNormalsFloat && edgeType === 1 && mateVertexIndex >= 0 ) { - // Each OctEncodedNormalPair is stored as one Uint32 value - // Uint32 contains 4 bytes: [byte0, byte1, byte2, byte3] - // normalA = byte0 | (byte1 << 8) - little endian - // normalB = byte2 | (byte3 << 8) - little endian - - if (mateVertexIndex < silhouetteNormalsUint32.length) { - const uint32Value = silhouetteNormalsUint32[mateVertexIndex]; - - // Uint32 contains 4 bytes: [xA, yA, xB, yB] - // Extract and decode using octDecode (rangeMax=255) - AttributeCompression.octDecode( - uint32Value & 0xff, // xA - (uint32Value >> 8) & 0xff, // yA - scratchDecodedA, - ); - AttributeCompression.octDecode( - (uint32Value >> 16) & 0xff, // xB - (uint32Value >> 24) & 0xff, // yB - scratchDecodedB, - ); - - nAx = scratchDecodedA.x; - nAy = scratchDecodedA.y; - nAz = scratchDecodedA.z; - nBx = scratchDecodedB.x; - nBy = scratchDecodedB.y; - nBz = scratchDecodedB.z; + const pairBase = mateVertexIndex * 2; + if ( + pairBase + 1 < silhouetteNormalsFloat.length && + defined(silhouetteNormalsFloat[pairBase]) && + defined(silhouetteNormalsFloat[pairBase + 1]) + ) { + const nA = silhouetteNormalsFloat[pairBase]; + const nB = silhouetteNormalsFloat[pairBase + 1]; + + nAx = nA.x; + nAy = nA.y; + nAz = nA.z; + nBx = nB.x; + nBy = nB.y; + nBz = nB.z; usedGLBNormals = true; } From 02972fd245494b0410891322df882ee2e285b048 Mon Sep 17 00:00:00 2001 From: danielzhong <32878167+danielzhong@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:17:31 -0400 Subject: [PATCH 2/4] fix --- .../Model/EdgeVisibilityPipelineStage.js | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js index 55c6b9932720..7e971a14d398 100644 --- a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js +++ b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js @@ -357,10 +357,13 @@ function buildTriangleAdjacency(primitive) { Cartesian3.subtract(scratchP2, scratchP0, scratchE2); Cartesian3.cross(scratchE1, scratchE2, scratchCross); - // Skip degenerate triangles (zero-area): their cross product is the zero - // vector, which cannot be normalized and would produce NaN face normals. + // Skip degenerate triangles: a zero or non-finite cross product cannot be + // safely normalized and would produce invalid face normals. const crossMagnitudeSquared = Cartesian3.magnitudeSquared(scratchCross); - if (crossMagnitudeSquared === 0.0) { + if ( + crossMagnitudeSquared === 0.0 || + !Number.isFinite(crossMagnitudeSquared) + ) { continue; } @@ -404,18 +407,18 @@ function generateEdgeFaceNormals( const hasGLBSilhouetteNormals = defined(edgeVisibility) && defined(edgeVisibility.silhouetteNormals); - // Decode GLB silhouette normals from signed-byte VEC3 to plain float Cartesian3 once, - // so each edge lookup is a direct array read with no further conversion. + // Decode GLB silhouette normals from signed-byte VEC3 to a flat Float32Array + // (3 floats per normal) to avoid per-normal object allocation and GC pressure. let silhouetteNormalsFloat = null; if (hasGLBSilhouetteNormals) { const raw = edgeVisibility.silhouetteNormals; - silhouetteNormalsFloat = new Array(raw.length); + silhouetteNormalsFloat = new Float32Array(raw.length * 3); const scratchNormal = new Cartesian3(); for (let i = 0; i < raw.length; i++) { const vec3 = raw[i]; - // Signed byte → float: map [0,255] → [-1,1] + // Signed byte → float: map [-128,127] → [-1,1] via [0,255] scratchNormal.x = 2 * ((vec3.x + 128) / 255) - 1; scratchNormal.y = 2 * ((vec3.y + 128) / 255) - 1; scratchNormal.z = 2 * ((vec3.z + 128) / 255) - 1; @@ -428,7 +431,9 @@ function generateEdgeFaceNormals( scratchNormal.z = 1; } - silhouetteNormalsFloat[i] = Cartesian3.clone(scratchNormal); + silhouetteNormalsFloat[i * 3] = scratchNormal.x; + silhouetteNormalsFloat[i * 3 + 1] = scratchNormal.y; + silhouetteNormalsFloat[i * 3 + 2] = scratchNormal.z; } } @@ -451,20 +456,16 @@ function generateEdgeFaceNormals( mateVertexIndex >= 0 ) { const pairBase = mateVertexIndex * 2; - if ( - pairBase + 1 < silhouetteNormalsFloat.length && - defined(silhouetteNormalsFloat[pairBase]) && - defined(silhouetteNormalsFloat[pairBase + 1]) - ) { - const nA = silhouetteNormalsFloat[pairBase]; - const nB = silhouetteNormalsFloat[pairBase + 1]; - - nAx = nA.x; - nAy = nA.y; - nAz = nA.z; - nBx = nB.x; - nBy = nB.y; - nBz = nB.z; + if ((pairBase + 1) * 3 + 2 < silhouetteNormalsFloat.length) { + const baseA = pairBase * 3; + const baseB = (pairBase + 1) * 3; + + nAx = silhouetteNormalsFloat[baseA]; + nAy = silhouetteNormalsFloat[baseA + 1]; + nAz = silhouetteNormalsFloat[baseA + 2]; + nBx = silhouetteNormalsFloat[baseB]; + nBy = silhouetteNormalsFloat[baseB + 1]; + nBz = silhouetteNormalsFloat[baseB + 2]; usedGLBNormals = true; } From 95452f66320b1602e16a7e6da912de1ba62fb557 Mon Sep 17 00:00:00 2001 From: danielzhong <32878167+danielzhong@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:26:54 -0400 Subject: [PATCH 3/4] fix --- .../Source/Scene/Model/EdgeVisibilityPipelineStage.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js index 7e971a14d398..163a68cc0729 100644 --- a/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js +++ b/packages/engine/Source/Scene/Model/EdgeVisibilityPipelineStage.js @@ -367,7 +367,11 @@ function buildTriangleAdjacency(primitive) { continue; } - Cartesian3.normalize(scratchCross, scratchCross); + Cartesian3.multiplyByScalar( + scratchCross, + 1.0 / Math.sqrt(crossMagnitudeSquared), + scratchCross, + ); faceNormals[base] = scratchCross.x; faceNormals[base + 1] = scratchCross.y; From b4e31d5237a18334f5bd4b6a2ab0779ac6d0e124 Mon Sep 17 00:00:00 2001 From: danielzhong <32878167+danielzhong@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:29:28 -0400 Subject: [PATCH 4/4] changes.md --- CHANGES.md | 1 + ...EdgeVisibilityPipelineStageDecodingSpec.js | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1e6286c8375c..1708c174f139 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ - Fix JSDoc for SkyBox.show to correctly declare it as a prototype property for TypeScript compatibility. [#13357](https://github.com/CesiumGS/cesium/pull/13357) - Fixed lighting affecting `EquirectangularPanorama`. [#13369](https://github.com/CesiumGS/cesium/pull/13369) +- Fixed a `DeveloperError` thrown when loading 3D tiles containing degenerate (zero-area) triangles with edge visibility data. [#13421](https://github.com/CesiumGS/cesium/pull/13421) ## 1.140 - 2026-04-01 diff --git a/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js b/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js index 2ef32f67bde6..2fb1e83916b1 100644 --- a/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js +++ b/packages/engine/Specs/Scene/Model/EdgeVisibilityPipelineStageDecodingSpec.js @@ -563,6 +563,66 @@ describe("Scene/Model/EdgeVisibilityPipelineStage", function () { expect(positionBuffer.sizeInBytes).toBe(12 * 3 * 4); }); + it("does not throw for degenerate (zero-area) triangles", function () { + // A degenerate triangle has two identical vertices, so its cross product is + // the zero vector. Normalizing it used to throw a DeveloperError. + const positions = new Float32Array([ + 0.0, + 0.0, + 0.0, // vertex 0 + 1.0, + 0.0, + 0.0, // vertex 1 + 1.0, + 0.0, + 0.0, // vertex 2 == vertex 1 (degenerate) + ]); + const indices = new Uint16Array([0, 1, 2]); + const visibilityBuffer = new Uint8Array([0b00101010]); // 3 HARD edges + + const primitive = { + attributes: [ + { + semantic: VertexAttributeSemantic.POSITION, + componentDatatype: ComponentDatatype.FLOAT, + count: 3, + typedArray: positions, + buffer: Buffer.createVertexBuffer({ + context: context, + typedArray: positions, + usage: BufferUsage.STATIC_DRAW, + }), + strideInBytes: 12, + offsetInBytes: 0, + }, + ], + indices: { + indexDatatype: IndexDatatype.UNSIGNED_SHORT, + count: 3, + typedArray: indices, + buffer: Buffer.createIndexBuffer({ + context: context, + typedArray: indices, + usage: BufferUsage.STATIC_DRAW, + indexDatatype: IndexDatatype.UNSIGNED_SHORT, + }), + }, + mode: PrimitiveType.TRIANGLES, + edgeVisibility: { visibility: visibilityBuffer }, + }; + + const renderResources = createMockRenderResources(primitive); + const frameState = createMockFrameState(); + + expect(function () { + EdgeVisibilityPipelineStage.process( + renderResources, + primitive, + frameState, + ); + }).not.toThrow(); + }); + it("validates BENTLEY_materials_line_style support", function () { const primitive = createTestPrimitive(); const renderResources = createMockRenderResources(primitive);