diff --git a/CHANGES.md b/CHANGES.md index ed244a0b0c4a..e54821aef1ad 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,7 @@ #### Additions :tada: +- Added support for the [EXT_mesh_primitive_edge_visibility](https://github.com/KhronosGroup/glTF/pull/2479) glTF extension. [#12765](https://github.com/CesiumGS/cesium/issues/12765) - Adds an async factory method for the Material class that allows callers to wait on resource loading. [#10566](https://github.com/CesiumGS/cesium/issues/10566) ## 1.133.1 - 2025-09-08 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index be84d97baf2d..edfc62ee1620 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -102,6 +102,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Cesiu - [Paul Connelly](https://github.com/pmconne) - [Jason Crow](https://github.com/jason-crow) - [Erin Ingram](https://github.com/eringram) + - [Daniel Zhong](https://github.com/danielzhong) - [Mark Schlosser](https://github.com/markschlosseratbentley) - [Flightradar24 AB](https://www.flightradar24.com) - [Aleksei Kalmykov](https://github.com/kalmykov) diff --git a/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb b/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb new file mode 100644 index 000000000000..4cd0855473b9 Binary files /dev/null and b/Specs/Data/Models/glTF-2.0/EdgeVisibility/glTF-Binary/EdgeVisibility.glb differ diff --git a/Specs/getWebGLStub.js b/Specs/getWebGLStub.js index d66ea6a9384b..dce6bc83ac34 100644 --- a/Specs/getWebGLStub.js +++ b/Specs/getWebGLStub.js @@ -154,6 +154,11 @@ const instancedArraysStub = { vertexAttribDivisorANGLE: noop, }; +// WEBGL_draw_buffers +const drawBuffersStub = { + drawBuffersWEBGL: noop, +}; + function noop() {} function createStub() { @@ -208,6 +213,11 @@ function getExtensionStub(name) { return {}; } + // EdgeFramebuffer and other MRT features require draw buffers + if (name === "WEBGL_draw_buffers") { + return drawBuffersStub; + } + // No other extensions are stubbed. return null; } diff --git a/packages/engine/Source/Renderer/AutomaticUniforms.js b/packages/engine/Source/Renderer/AutomaticUniforms.js index 29801f986f7a..59807ea9d456 100644 --- a/packages/engine/Source/Renderer/AutomaticUniforms.js +++ b/packages/engine/Source/Renderer/AutomaticUniforms.js @@ -161,6 +161,67 @@ const AutomaticUniforms = { }, }), + /** + * An automatic GLSL uniform representing a texture containing edge IDs + * from the 3D Tiles edge rendering pass. Used for edge detection and + * avoiding z-fighting between edges and surfaces. + * + * @example + * // GLSL declaration + * uniform sampler2D czm_edgeIdTexture; + * + * // Get the edge ID at the current fragment + * vec2 coords = gl_FragCoord.xy / czm_viewport.zw; + * vec4 edgeId = texture(czm_edgeIdTexture, coords); + */ + czm_edgeIdTexture: new AutomaticUniform({ + size: 1, + datatype: WebGLConstants.SAMPLER_2D, + getValue: function (uniformState) { + return uniformState.edgeIdTexture; + }, + }), + + /** + * An automatic GLSL uniform containing the edge color texture. + * This texture contains the edge content rendered during the CESIUM_3D_TILE_EDGES pass. + * + * @example + * // GLSL declaration + * uniform sampler2D czm_edgeColorTexture; + * + * // Sample the edge color at the current fragment + * vec2 coords = gl_FragCoord.xy / czm_viewport.zw; + * vec4 edgeColor = texture(czm_edgeColorTexture, coords); + */ + czm_edgeColorTexture: new AutomaticUniform({ + size: 1, + datatype: WebGLConstants.SAMPLER_2D, + getValue: function (uniformState) { + return uniformState.edgeColorTexture; + }, + }), + + /** + * An automatic GLSL uniform containing the packed depth texture produced by the + * edge visibility pass. The depth is packed via czm_packDepth and should be + * unpacked with czm_unpackDepth. + * + * @example + * // GLSL declaration + * uniform sampler2D czm_edgeDepthTexture; + * + * vec2 coords = gl_FragCoord.xy / czm_viewport.zw; + * float d = czm_unpackDepth(texture(czm_edgeDepthTexture, coords)); + */ + czm_edgeDepthTexture: new AutomaticUniform({ + size: 1, + datatype: WebGLConstants.SAMPLER_2D, + getValue: function (uniformState) { + return uniformState.edgeDepthTexture; + }, + }), + /** * An automatic GLSL uniform representing a 4x4 model transformation matrix that * transforms model coordinates to world coordinates. diff --git a/packages/engine/Source/Renderer/Pass.js b/packages/engine/Source/Renderer/Pass.js index 6a8a37d3d213..e33d0ecc947a 100644 --- a/packages/engine/Source/Renderer/Pass.js +++ b/packages/engine/Source/Renderer/Pass.js @@ -15,14 +15,15 @@ const Pass = { COMPUTE: 1, GLOBE: 2, TERRAIN_CLASSIFICATION: 3, - CESIUM_3D_TILE: 4, - CESIUM_3D_TILE_CLASSIFICATION: 5, - CESIUM_3D_TILE_CLASSIFICATION_IGNORE_SHOW: 6, - OPAQUE: 7, - TRANSLUCENT: 8, - VOXELS: 9, - GAUSSIAN_SPLATS: 10, - OVERLAY: 11, - NUMBER_OF_PASSES: 12, + CESIUM_3D_TILE_EDGES: 4, + CESIUM_3D_TILE: 5, + CESIUM_3D_TILE_CLASSIFICATION: 6, + CESIUM_3D_TILE_CLASSIFICATION_IGNORE_SHOW: 7, + OPAQUE: 8, + TRANSLUCENT: 9, + VOXELS: 10, + GAUSSIAN_SPLATS: 11, + OVERLAY: 12, + NUMBER_OF_PASSES: 13, }; export default Object.freeze(Pass); diff --git a/packages/engine/Source/Renderer/UniformState.js b/packages/engine/Source/Renderer/UniformState.js index 6a72cc180c99..3d1d7d06f95a 100644 --- a/packages/engine/Source/Renderer/UniformState.js +++ b/packages/engine/Source/Renderer/UniformState.js @@ -25,6 +25,18 @@ function UniformState() { * @type {Texture} */ this.globeDepthTexture = undefined; + /** + * @type {Texture} + */ + this.edgeIdTexture = undefined; + /** + * @type {Texture} + */ + this.edgeColorTexture = undefined; + /** + * @type {Texture} + */ + this.edgeDepthTexture = undefined; // packed depth color attachment from edge pass /** * @type {number} */ diff --git a/packages/engine/Source/Scene/EdgeFramebuffer.js b/packages/engine/Source/Scene/EdgeFramebuffer.js new file mode 100644 index 000000000000..f0be1891883c --- /dev/null +++ b/packages/engine/Source/Scene/EdgeFramebuffer.js @@ -0,0 +1,245 @@ +import defined from "../Core/defined.js"; +import destroyObject from "../Core/destroyObject.js"; +import PixelFormat from "../Core/PixelFormat.js"; +import Color from "../Core/Color.js"; +import PixelDatatype from "../Renderer/PixelDatatype.js"; +import FramebufferManager from "../Renderer/FramebufferManager.js"; +import ClearCommand from "../Renderer/ClearCommand.js"; + +/** + * Creates and manages framebuffers for edge visibility rendering. + * + * @param {Object} options Object with the following properties: + * + * @alias EdgeFramebuffer + * @constructor + * + * @private + */ +function EdgeFramebuffer(options) { + options = options || {}; + + // Create framebuffer manager with multiple render targets (MRT) + // Color attachment 0: edge color output (visualization / debug) + // Color attachment 1: R: edge type, G: featureId (metadata / ids) + // Color attachment 2: packed depth (czm_packDepth) for edge fragments + this._framebufferManager = new FramebufferManager({ + colorAttachmentsLength: 3, // MRT: Color + ID + Depth (packed RGBA) + createColorAttachments: true, + depthStencil: true, + supportsDepthTexture: true, + color: true, + }); + + this._framebuffer = undefined; + this._colorTexture = undefined; + this._idTexture = undefined; + this._depthTexture = undefined; // packed depth color attachment (location = 2) + this._depthStencilTexture = undefined; + + this._clearCommand = new ClearCommand({ + color: new Color(0.0, 0.0, 0.0, 0.0), + depth: 1.0, + stencil: 0, + owner: this, + }); +} + +Object.defineProperties(EdgeFramebuffer.prototype, { + /** + * Gets the framebuffer for edge rendering. + * @memberof EdgeFramebuffer.prototype + * @type {Framebuffer} + * @readonly + */ + framebuffer: { + get: function () { + return this._framebuffer; + }, + }, + + /** + * Gets the color texture. + * @memberof EdgeFramebuffer.prototype + * @type {Texture} + * @readonly + */ + colorTexture: { + get: function () { + return this._colorTexture; + }, + }, + + /** + * Gets the ID texture. + * @memberof EdgeFramebuffer.prototype + * @type {Texture} + * @readonly + */ + idTexture: { + get: function () { + return this._idTexture; + }, + }, + + /** + * Gets the packed depth texture written during the edge pass. + * @memberof EdgeFramebuffer.prototype + * @type {Texture} + * @readonly + */ + depthTexture: { + get: function () { + return this._depthTexture; + }, + }, + + /** + * Gets the depth-stencil texture. + * @memberof EdgeFramebuffer.prototype + * @type {Texture} + * @readonly + */ + depthStencilTexture: { + get: function () { + return this._depthStencilTexture; + }, + }, +}); + +/** + * Updates the framebuffer. + * + * @param {Context} context The context. + * @param {Viewport} viewport The viewport. + * @param {boolean} hdr Whether HDR is enabled. + * @param {Texture} [existingColorTexture] Optional existing color texture to reuse. + * @param {Texture} [existingDepthTexture] Optional existing depth texture to reuse. + * + * @returns {boolean} True if the framebuffer was updated; otherwise, false. + */ +EdgeFramebuffer.prototype.update = function ( + context, + viewport, + hdr, + existingColorTexture, + existingDepthTexture, +) { + const width = viewport.width; + const height = viewport.height; + + const pixelDatatype = hdr + ? context.halfFloatingPointTexture + ? PixelDatatype.HALF_FLOAT + : PixelDatatype.FLOAT + : PixelDatatype.UNSIGNED_BYTE; + + const changed = this._framebufferManager.update( + context, + width, + height, + 1, // No MSAA + pixelDatatype, + PixelFormat.RGBA, + ); + + // Always assign framebuffer if FramebufferManager has one + if (this._framebufferManager.framebuffer) { + this._framebuffer = this._framebufferManager.framebuffer; + + // Get the textures from the framebuffer manager or use existing ones + this._colorTexture = defined(existingColorTexture) + ? existingColorTexture + : this._framebufferManager.getColorTexture(0); // Color attachment 0 + this._idTexture = this._framebufferManager.getColorTexture(1); // Color attachment 1: ID texture + this._depthTexture = this._framebufferManager.getColorTexture(2); // Color attachment 2: packed depth + this._depthStencilTexture = defined(existingDepthTexture) + ? existingDepthTexture + : this._framebufferManager.getDepthStencilTexture(); + } + + return changed; +}; + +/** + * Clears the framebuffer using ClearCommand. + * @deprecated Use getClearCommand() instead for proper MRT clearing. + * + * @param {Context} context The context. + * @param {PassState} passState The pass state. + * @param {Color} clearColor The clear color. + */ +EdgeFramebuffer.prototype.clear = function (context, passState, clearColor) { + const clearCommand = this.getClearCommand(clearColor); + clearCommand.execute(context, passState); +}; + +/** + * Gets the clear command for this framebuffer. + * + * @param {Color} [clearColor] The clear color to use. If undefined, uses the default. + * @returns {ClearCommand} The clear command. + */ +EdgeFramebuffer.prototype.getClearCommand = function (clearColor) { + this._clearCommand.framebuffer = this._framebuffer; + + if (defined(clearColor)) { + Color.clone(clearColor, this._clearCommand.color); + } + + return this._clearCommand; +}; + +/** + * Gets the edge framebuffer, creating it if necessary. + * + * @param {Context} context The context. + * @param {Viewport} viewport The viewport. + * @param {Texture} [existingColorTexture] Optional existing color texture to reuse. + * @param {Texture} [existingDepthTexture] Optional existing depth texture to reuse. + * + * @returns {Framebuffer} The edge framebuffer. + */ +EdgeFramebuffer.prototype.getFramebuffer = function ( + context, + viewport, + existingColorTexture, + existingDepthTexture, +) { + this.update( + context, + viewport, + false, + existingColorTexture, + existingDepthTexture, + ); + return this._framebuffer; +}; + +/** + * Returns true if this object was destroyed; otherwise, false. + * + * @returns {boolean} True if this object was destroyed; otherwise, false. + */ +EdgeFramebuffer.prototype.isDestroyed = function () { + return false; +}; + +/** + * Destroys the WebGL resources held by this object. Destroying an object allows for deterministic + * release of WebGL resources, instead of relying on the garbage collector to destroy this object. + *

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