Merge branch 'main' into alark/async-picking

This commit is contained in:
Adam Larkeryd 2025-10-21 10:49:37 +09:00
commit 4439df1658
40 changed files with 2677 additions and 32 deletions

View File

@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: install npm packages

View File

@ -27,7 +27,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: npm install

View File

@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: npm install
@ -34,7 +34,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: npm install
@ -51,7 +51,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: npm install
@ -67,7 +67,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 20
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '20'
- name: npm install

View File

@ -9,7 +9,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: "22"
- name: npm install
@ -34,7 +34,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: "22"
- name: npm install

View File

@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: install node 22
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '22'
- name: npm install

View File

@ -1,6 +1,6 @@
# Change Log
## 1.135 - 2025-11-01
## 1.135 - 2025-11-03
### @cesium/engine
@ -9,6 +9,10 @@
- `scene.drillPick` now uses a breadth-first search strategy instead of depth-first. This may change which entities are picked when
using large values of `width` and `height` when providing a `limit`, prioritizing entities closer to the camera.
#### Additions :tada:
- Added support for [EXT_mesh_primitive_edge_visibility](https://github.com/KhronosGroup/glTF/pull/2479) glTF extension. [#12765](https://github.com/CesiumGS/cesium/issues/12765)
#### Fixes :wrench:
- Fixed parsing content bounding volumes contained in 3D Tiles 1.1 subtree files. [#12972](https://github.com/CesiumGS/cesium/pull/12972)

View File

@ -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)
- [Adam Larkeryd](https://github.com/alarkbentley)
- [Flightradar24 AB](https://www.flightradar24.com)

View File

@ -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;
}

View File

@ -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.

View File

@ -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);

View File

@ -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}
*/

View File

@ -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.
* <br /><br />
* Once an object is destroyed, it should not be used; calling any function other than
* <code>isDestroyed</code> will result in a {@link DeveloperError} exception. Therefore,
* assign the return value (<code>undefined</code>) 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;

View File

@ -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;
}
/**

View File

@ -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,

View File

@ -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:
* <ul>
* <li>Samples the edge render targets (edge color + per-edge feature ID)</li>
* <li>Compares per-edge feature IDs with underlying surface feature IDs to suppress
* edges that belong to filtered or hidden features</li>
* <li>Performs depth-based tests (e.g., against globe or scene depth) to discard
* occluded edges</li>
* </ul>
* 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:
* <ul>
* <li>Read edge color / edge ID MRT outputs</li>
* <li>Apply depth & feature ID based rejection</li>
* <li>Emit final edge color for composition</li>
* </ul>
* @param {PrimitiveRenderResources} renderResources The render resources for the primitive
* @private
*/
EdgeDetectionPipelineStage.process = function (renderResources) {
const shaderBuilder = renderResources.shaderBuilder;
shaderBuilder.addFragmentLines([EdgeDetectionStageFS]);
};
export default EdgeDetectionPipelineStage;

View File

@ -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:
* <ul>
* <li>Adding shader defines (<code>HAS_EDGE_VISIBILITY</code>, <code>HAS_EDGE_VISIBILITY_MRT</code>)</li>
* <li>Injecting the fragment shader logic that outputs edge color / feature information</li>
* <li>Adding per-vertex attributes: edge type, optional feature ID, silhouette normal, and adjacent face normals</li>
* <li>Adding varyings to pass these attributes to the fragment stage</li>
* <li>Creating and storing a derived line list vertex array in <code>renderResources.edgeGeometry</code></li>
* </ul>
* 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<string, number[]>, 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<string,number[]>, 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:
* <ul>
* <li>0 HIDDEN - skipped</li>
* <li>1 SILHOUETTE - candidates for conditional display based on facing</li>
* <li>2 HARD - always displayed</li>
* <li>3 REPEATED - secondary encoding for a hard edge (treated same as 2)</li>
* </ul>
* 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;

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
}
/**

View File

@ -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)];

View File

@ -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) {

View File

@ -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 =

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passCesium3DTile = 4.0;
const float czm_passCesium3DTile = 5.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passCesium3DTileClassification = 5.0;
const float czm_passCesium3DTileClassification = 6.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passCesium3DTileClassificationIgnoreShow = 6.0;
const float czm_passCesium3DTileClassificationIgnoreShow = 7.0;

View File

@ -0,0 +1,10 @@
/**
* The automatic GLSL constant for {@link Pass#CESIUM_3D_TILE_EDGES}
*
* @name czm_passCesium3DTileEdges
* @glslConstant
*
* @see czm_pass
*/
const float czm_passCesium3DTileEdges = 4.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passGaussianSplats = 10.0;
const float czm_passGaussianSplats = 11.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passOpaque = 7.0;
const float czm_passOpaque = 8.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passOverlay = 11.0;
const float czm_passOverlay = 12.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passTranslucent = 8.0;
const float czm_passTranslucent = 9.0;

View File

@ -6,4 +6,4 @@
*
* @see czm_pass
*/
const float czm_passVoxels = 9.0;
const float czm_passVoxels = 10.0;

View File

@ -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;
}
}
}

View File

@ -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
}

View File

@ -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
//========================================================================

View File

@ -13,4 +13,3 @@ void primitiveOutlineStage(inout czm_modelMaterial material) {
material.diffuse = mix(material.diffuse, model_outlineColor.rgb, model_outlineColor.a * outlineness);
}

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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);
});
});

View File

@ -0,0 +1,233 @@
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 regularCommand = null;
// Prefer the regular 3D Tiles pass command
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
if (command.pass === Pass.CESIUM_3D_TILE) {
regularCommand = command;
break;
}
}
// Fallback to content search if not found by pass
if (!regularCommand) {
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
if (
cmd.shaderProgram &&
typeof cmd.shaderProgram._fragmentShaderText === "string" &&
cmd.shaderProgram._fragmentShaderText.indexOf(
"edgeDetectionStage",
) !== -1
) {
regularCommand = cmd;
break;
}
}
}
expect(regularCommand).toBeDefined();
const fragmentShader = regularCommand.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("czm_edgeColorTexture");
expect(fragmentShader).toContain("czm_edgeIdTexture");
expect(fragmentShader).toContain("czm_globeDepthTexture");
// Verify edge ID presence and feature IDs visibility logic exists
expect(fragmentShader).toContain("edgeId.r > 0.0");
expect(fragmentShader).toContain("edgeId.g");
expect(fragmentShader).toContain("featureIds.featureId_0");
// Verify depth usage for background/globe rendering
expect(fragmentShader).toContain("czm_unpackDepth");
expect(fragmentShader).toContain("geomDepthLinear > globeDepth");
// Verify the color can inherit 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);
}
});
});