diff --git a/Source/DataSources/ModelGraphics.js b/Source/DataSources/ModelGraphics.js index 1b0c2e07bdbd..2687602ad981 100644 --- a/Source/DataSources/ModelGraphics.js +++ b/Source/DataSources/ModelGraphics.js @@ -44,6 +44,8 @@ function createArticulationStagePropertyBag(value) { * @property {PropertyBag | Object.} [nodeTransformations] An object, where keys are names of nodes, and values are {@link TranslationRotationScale} Properties describing the transformation to apply to that node. The transformation is applied after the node's existing transformation as specified in the glTF, and does not replace the node's existing transformation. * @property {PropertyBag | Object.} [articulations] An object, where keys are composed of an articulation name, a single space, and a stage name, and the values are numeric properties. * @property {Property | ClippingPlaneCollection} [clippingPlanes] A property specifying the {@link ClippingPlaneCollection} used to selectively disable rendering the model. + * @property {Property | ModelOutlineGenerationMode} [options.outlineGenerationMode] A property that determines whether outlines should be generated for this model. + * @property {Property | Number} [options.outlineGenerationMinimumAngle] A property that if generating outlines for this model, determines what the minimum angle between the normals of two faces has to be for the edge between them to receive an outline. */ /** @@ -108,6 +110,8 @@ function ModelGraphics(options) { this._articulationsSubscription = undefined; this._clippingPlanes = undefined; this._clippingPlanesSubscription = undefined; + this._outlineGenerationMode = undefined; + this._outlineGenerationMinimumAngle = undefined; this.merge(defaultValue(options, defaultValue.EMPTY_OBJECT)); } @@ -312,6 +316,22 @@ Object.defineProperties(ModelGraphics.prototype, { */ clippingPlanes: createPropertyDescriptor("clippingPlanes"), + /** + * A property that determines whether outlines should be generated for this model. + * @memberof ModelGraphics.prototype + * @type {Property|undefined} + */ + outlineGenerationMode: createPropertyDescriptor("outlineGenerationMode"), + + /** + * A property that if generating outlines for this model, determines what the minimum angle between the normals of two faces has to be for the edge between them to receive an outline. + * @memberof ModelGraphics.prototype + * @type {Property|undefined} + */ + outlineGenerationMinimumAngle: createPropertyDescriptor( + "outlineGenerationMinimumAngle" + ), + /** * A property specifying the {@link Axis} up axis of the model. * @memberOf ModelGraphics.prototype @@ -357,6 +377,8 @@ ModelGraphics.prototype.clone = function (result) { result.nodeTransformations = this.nodeTransformations; result.articulations = this.articulations; result.clippingPlanes = this.clippingPlanes; + result.outlineGenerationMode = this.outlineGenerationMode; + result.outlineGenerationMinimumAngle = this.outlineGenerationMinimumAngle; return result; }; @@ -427,6 +449,14 @@ ModelGraphics.prototype.merge = function (source) { this.clippingPlanes, source.clippingPlanes ); + this.outlineGenerationMode = defaultValue( + this.outlineGenerationMode, + source.outlineGenerationMode + ); + this.outlineGenerationMinimumAngle = defaultValue( + this.outlineGenerationMinimumAngle, + source.outlineGenerationMinimumAngle + ); var sourceNodeTransformations = source.nodeTransformations; if (defined(sourceNodeTransformations)) { diff --git a/Source/DataSources/ModelVisualizer.js b/Source/DataSources/ModelVisualizer.js index 142e723d0d60..18327d63fd71 100644 --- a/Source/DataSources/ModelVisualizer.js +++ b/Source/DataSources/ModelVisualizer.js @@ -123,6 +123,14 @@ ModelVisualizer.prototype.update = function (time) { defaultIncrementallyLoadTextures ), scene: this._scene, + outlineGenerationMode: Property.getValueOrDefault( + modelGraphics._outlineGenerationMode, + time + ), + outlineGenerationMinimumAngle: Property.getValueOrDefault( + modelGraphics._outlineGenerationMinimumAngle, + time + ), }); model.id = entity; primitives.add(model); @@ -224,6 +232,14 @@ ModelVisualizer.prototype.update = function (time) { modelGraphics._forwardAxis, time ); + model._outlineGenerationMode = Property.getValueOrUndefined( + modelGraphics._outlineGenerationMode, + time + ); + model._outlineGenerationMinimumAngle = Property.getValueOrUndefined( + modelGraphics._outlineGenerationMinimumAngle, + time + ); if (model.ready) { var runAnimations = Property.getValueOrDefault( diff --git a/Source/Scene/Batched3DModel3DTileContent.js b/Source/Scene/Batched3DModel3DTileContent.js index 5e38ecf48cce..ad716c085a52 100644 --- a/Source/Scene/Batched3DModel3DTileContent.js +++ b/Source/Scene/Batched3DModel3DTileContent.js @@ -433,6 +433,8 @@ function initialize(content, arrayBuffer, byteOffset) { sphericalHarmonicCoefficients: tileset.sphericalHarmonicCoefficients, specularEnvironmentMaps: tileset.specularEnvironmentMaps, backFaceCulling: tileset.backFaceCulling, + outlineGenerationMode: tileset.outlineGenerationMode, + outlineGenerationMinimumAngle: tileset.outlineGenerationMinimumAngle }); content._model.readyPromise.then(function (model) { model.activeAnimations.addAll({ @@ -541,6 +543,8 @@ Batched3DModel3DTileContent.prototype.update = function (tileset, frameState) { this._model.sphericalHarmonicCoefficients = this._tileset.sphericalHarmonicCoefficients; this._model.specularEnvironmentMaps = this._tileset.specularEnvironmentMaps; this._model.backFaceCulling = this._tileset.backFaceCulling; + this._model.outlineGenerationMode = this._tileset.outlineGenerationMode; + this._model.outlineGenerationMinimumAngle = this._tileset.outlineGenerationMinimumAngle; this._model.debugWireframe = this._tileset.debugWireframe; // Update clipping planes diff --git a/Source/Scene/Cesium3DTileset.js b/Source/Scene/Cesium3DTileset.js index b03ca969c1fd..8b88b6f25ca3 100644 --- a/Source/Scene/Cesium3DTileset.js +++ b/Source/Scene/Cesium3DTileset.js @@ -43,6 +43,7 @@ import StencilConstants from "./StencilConstants.js"; import TileBoundingRegion from "./TileBoundingRegion.js"; import TileBoundingSphere from "./TileBoundingSphere.js"; import TileOrientedBoundingBox from "./TileOrientedBoundingBox.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; /** * A {@link https://github.com/CesiumGS/3d-tiles/tree/master/specification|3D Tiles tileset}, @@ -90,6 +91,8 @@ import TileOrientedBoundingBox from "./TileOrientedBoundingBox.js"; * @param {Cartesian3[]} [options.sphericalHarmonicCoefficients] The third order spherical harmonic coefficients used for the diffuse color of image-based lighting. * @param {String} [options.specularEnvironmentMaps] A URL to a KTX file that contains a cube map of the specular lighting and the convoluted specular mipmaps. * @param {Boolean} [options.backFaceCulling=true] Whether to cull back-facing geometry. When true, back face culling is determined by the glTF material's doubleSided property; when false, back face culling is disabled. + * @param {ModelOutlineGenerationMode} [options.outlineGenerationMode] Determines whether outlines should be generated for this model. + * @param {Number} [options.outlineGenerationMinimumAngle] If generating outlines for this model, determines what the minimum angle between the normals of two faces has to be for the edge between them to receive an outline. * @param {String} [options.debugHeatmapTilePropertyName] The tile variable to colorize as a heatmap. All rendered tiles will be colorized relative to each other's specified variable value. * @param {Boolean} [options.debugFreezeFrame=false] For debugging only. Determines if only the tiles from last frame should be used for rendering. * @param {Boolean} [options.debugColorizeTiles=false] For debugging only. When true, assigns a random color to each tile. @@ -760,6 +763,41 @@ function Cesium3DTileset(options) { */ this.backFaceCulling = defaultValue(options.backFaceCulling, true); + /** + * Determines whether outlines should be generated for this tileset. + * + * @type {ModelOutlineGenerationMode} + * + * @default ModelOutlineGenerationMode.USE_GLTF_SETTINGS + * + * @see ModelOutlineGenerator + */ + this.outlineGenerationMode = defaultValue( + options.outlineGenerationMode, + ModelOutlineGenerationMode.USE_GLTF_SETTINGS + ); + + /** + * If generating outlines for this model, determines what the minimum angle + * between the normals of two faces has to be for the edge between them to + * receive an outline. + * + * This follows @see Cesium3DTileset.outlineGenerationMode — if outlineGenerationMode is + * OFF or USE_GLTF_SETTINGS, this value will be ignored. If undefined, it will + * use the value from the glTF if it exists, or otherwise the default value + * specified by ModelOutlineGenerator. + * + * @type {number} + * + * @default undefined + * + * @see ModelOutlineGenerator + */ + this.outlineGenerationMinimumAngle = defaultValue( + options.outlineGenerationMinimumAngle, + undefined + ); + /** * This property is for debugging only; it is not optimized for production use. *

diff --git a/Source/Scene/Model.js b/Source/Scene/Model.js index c047d37a8962..be3b5897d317 100644 --- a/Source/Scene/Model.js +++ b/Source/Scene/Model.js @@ -75,6 +75,7 @@ import processModelMaterialsCommon from "./processModelMaterialsCommon.js"; import processPbrMaterials from "./processPbrMaterials.js"; import SceneMode from "./SceneMode.js"; import ShadowMode from "./ShadowMode.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; var boundingSphereCartesian3Scratch = new Cartesian3(); @@ -317,6 +318,41 @@ function Model(options) { */ this.silhouetteSize = defaultValue(options.silhouetteSize, 0.0); + /** + * Determines whether outlines should be generated for this model. + * + * @type {ModelOutlineGenerationMode} + * + * @default ModelOutlineGenerationMode.USE_GLTF_SETTINGS + * + * @see ModelOutlineGenerator + */ + this.outlineGenerationMode = defaultValue( + options.outlineGenerationMode, + ModelOutlineGenerationMode.USE_GLTF_SETTINGS + ); + + /** + * If generating outlines for this model, determines what the minimum angle + * between the normals of two faces has to be for the edge between them to + * receive an outline. + * + * This follows @see Model.outlineGenerationMode — if outlineGenerationMode is + * OFF or USE_GLTF_SETTINGS, this value will be ignored. If undefined, it will + * use the value from the glTF if it exists, or otherwise the default value + * specified by ModelOutlineGenerator. + * + * @type {number} + * + * @default undefined + * + * @see ModelOutlineGenerator + */ + this.outlineGenerationMinimumAngle = defaultValue( + options.outlineGenerationMinimumAngle, + undefined + ); + /** * The 4x4 transformation matrix that transforms the model from model to world coordinates. * When this is the identity matrix, the model is drawn in world coordinates, i.e., Earth's WGS84 coordinates. @@ -1385,6 +1421,8 @@ function containsGltfMagic(uint8Array) { * @param {Boolean} [options.dequantizeInShader=true] Determines if a {@link https://github.com/google/draco|Draco} encoded model is dequantized on the GPU. This decreases total memory usage for encoded models. * @param {Credit|String} [options.credit] A credit for the model, which is displayed on the canvas. * @param {Boolean} [options.backFaceCulling=true] Whether to cull back-facing geometry. When true, back face culling is determined by the material's doubleSided property; when false, back face culling is disabled. Back faces are not culled if {@link Model#color} is translucent or {@link Model#silhouetteSize} is greater than 0.0. + * @param {ModelOutlineGenerationMode} [options.outlineGenerationMode] Determines whether outlines should be generated for this model. + * @param {Number} [options.outlineGenerationMinimumAngle] If generating outlines for this model, determines what the minimum angle between the normals of two faces has to be for the edge between them to receive an outline. * * @returns {Model} The newly created model. * @@ -2454,16 +2492,15 @@ function createProgram(programToCreate, model, context) { var drawVS = modifyShader(vs, programId, model._vertexShaderLoaded); var drawFS = modifyShader(fs, programId, model._fragmentShaderLoaded); - - if (isOutline) { - drawFS = drawFS.replace( - "czm_writeLogDepth();", - " czm_writeLogDepth();\n" + - "#if defined(LOG_DEPTH) && !defined(DISABLE_LOG_DEPTH_FRAGMENT_WRITE)\n" + - " gl_FragDepthEXT -= 5e-5;\n" + - "#endif" - ); - } + if (isOutline) { + drawFS = drawFS.replace( + "czm_writeLogDepth();", + " czm_writeLogDepth();\n" + + "#if defined(LOG_DEPTH) && !defined(DISABLE_LOG_DEPTH_FRAGMENT_WRITE)\n" + + " gl_FragDepthEXT -= 5e-5;\n" + + "#endif" + ); + } if (!defined(model._uniformMapLoaded)) { drawFS = "uniform vec4 czm_pickColor;\n" + drawFS; } @@ -2582,17 +2619,16 @@ function recreateProgram(programToCreate, model, context) { var drawVS = modifyShader(vs, programId, model._vertexShaderLoaded); var drawFS = modifyShader(finalFS, programId, model._fragmentShaderLoaded); - - var isOutline = program.isOutline; - if (isOutline) { - drawFS = drawFS.replace( - "czm_writeLogDepth();", - " czm_writeLogDepth();\n" + - "#if defined(LOG_DEPTH) && !defined(DISABLE_LOG_DEPTH_FRAGMENT_WRITE)\n" + - " gl_FragDepthEXT -= 5e-5;\n" + - "#endif" - ); - } + var isOutline = program.isOutline; + if (isOutline) { + drawFS = drawFS.replace( + "czm_writeLogDepth();", + " czm_writeLogDepth();\n" + + "#if defined(LOG_DEPTH) && !defined(DISABLE_LOG_DEPTH_FRAGMENT_WRITE)\n" + + " gl_FragDepthEXT -= 5e-5;\n" + + "#endif" + ); + } if (!defined(model._uniformMapLoaded)) { drawFS = "uniform vec4 czm_pickColor;\n" + drawFS; } @@ -5276,6 +5312,7 @@ Model.prototype.update = function (frameState) { var options = { addBatchIdToGeneratedShaders: this._addBatchIdToGeneratedShaders, + outlineGenerationMode: this.outlineGenerationMode, }; processModelMaterialsCommon(gltf, options); diff --git a/Source/Scene/ModelOutlineGenerationMode.js b/Source/Scene/ModelOutlineGenerationMode.js new file mode 100644 index 000000000000..63bdd009649f --- /dev/null +++ b/Source/Scene/ModelOutlineGenerationMode.js @@ -0,0 +1,18 @@ +/** + * Defines different modes for automatically generating outlines for models. + * + * USE_GLTF_SETTINGS will follow whatever is set in the glTF underlying the model. + * OFF forces outlines to not be generated, overriding what is specified in the model. + * ON forces outlines to be generated, overriding what is specified in the model. + * + * @enum {Number} + * + * @see Model.generateOutlines + */ +var ModelOutlineGenerationMode = { + OFF: 0, + ON: 1, + USE_GLTF_SETTINGS: 2, +}; + +export default Object.freeze(ModelOutlineGenerationMode); diff --git a/Source/Scene/ModelOutlineGenerator.js b/Source/Scene/ModelOutlineGenerator.js new file mode 100644 index 000000000000..dc0d400b5d18 --- /dev/null +++ b/Source/Scene/ModelOutlineGenerator.js @@ -0,0 +1,483 @@ +import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; +import WebGLConstants from "../Core/WebGLConstants.js"; +import defined from "../Core/defined.js"; +import Cartesian3 from "../Core/Cartesian3.js"; +import ModelOutlineGenerationMode from "../Scene/ModelOutlineGenerationMode.js"; + +// glTF does not allow an index value of 65535 because this is the primitive +// restart value in some APIs. +var MAX_GLTF_UINT16_INDEX = 65534; +function ModelOutlineGenerator() {} + +/** + * Determines which edges in a model should be outlined. + * It does this by adding the index buffer expected by CESIUM_primitive_outline + * extension that determines which edges to outline. + * + * Note that this is reasonably performance expensive, and not recommended for + * use on large meshes. + * @returns true if there are edges to outline, false otherwise. + * @private + */ +ModelOutlineGenerator.generateOutlinesForModel = function (model) { + if ( + defined(model.extensionsRequired.KHR_draco_mesh_compression) || + defined(model.extensionsUsed.KHR_draco_mesh_compression) + ) { + // Draco compressed meshes are not supported + return false; + } + + var gltf = model.gltf; + var outlineAny = false; + ForEach.mesh(gltf, function (mesh, meshId) { + ForEach.meshPrimitive(mesh, function (_primitive, primitiveId) { + outlineAny = outlinePrimitive(model, meshId, primitiveId) || outlineAny; + }); + }); + return outlineAny; +}; + +function outlinePrimitive(model, meshId, primitiveId) { + var gltf = model.gltf; + var mesh = gltf.meshes[meshId]; + var primitive = mesh.primitives[primitiveId]; + var accessors = gltf.accessors; + var bufferViews = gltf.bufferViews; + var triangleIndexAccessorGltf = accessors[primitive.indices]; + var triangleIndexBufferViewGltf; + var indexedTriangleMode = false; + if (defined(triangleIndexAccessorGltf)) { + triangleIndexBufferViewGltf = + bufferViews[triangleIndexAccessorGltf.bufferView]; + indexedTriangleMode = true; + } + var positionAccessorGltf = accessors[primitive.attributes.POSITION]; + var positionBufferViewGltf = bufferViews[positionAccessorGltf.bufferView]; + var normalAccessorGltf = accessors[primitive.attributes.NORMAL]; + if (!defined(normalAccessorGltf)) { + // Can't outline this model because it has no normals + return false; + } + var normalBufferViewGltf = bufferViews[normalAccessorGltf.bufferView]; + + if (!defined(normalBufferViewGltf.byteStride)) { + normalBufferViewGltf.byteStride = Float32Array.BYTES_PER_ELEMENT * 3; + } + if (!defined(positionBufferViewGltf.byteStride)) { + positionBufferViewGltf.byteStride = Float32Array.BYTES_PER_ELEMENT * 3; + } + + var loadResources = model._loadResources; + + var triangleIndexBufferView; + if (indexedTriangleMode) { + triangleIndexBufferView = loadResources.getBuffer( + triangleIndexBufferViewGltf + ); + } + + var positionBufferView = loadResources.getBuffer(positionBufferViewGltf); + var positions = new Float32Array( + positionBufferView.buffer, + positionBufferView.byteOffset + positionAccessorGltf.byteOffset, + positionBufferViewGltf.length + ); + + var normalBufferView = loadResources.getBuffer(normalBufferViewGltf); + var normals = new Float32Array( + normalBufferView.buffer, + normalBufferView.byteOffset + normalAccessorGltf.byteOffset, + normalBufferViewGltf.length + ); + var vertexNormalGetter = generateVertexAttributeGetter( + normals, + normalBufferViewGltf.byteStride / Float32Array.BYTES_PER_ELEMENT + ); + + var triangleIndices; + if (indexedTriangleMode) { + triangleIndices = + triangleIndexAccessorGltf.componentType === WebGLConstants.UNSIGNED_SHORT + ? new Uint16Array( + triangleIndexBufferView.buffer, + triangleIndexBufferView.byteOffset + + triangleIndexAccessorGltf.byteOffset, + triangleIndexAccessorGltf.count + ) + : new Uint32Array( + triangleIndexBufferView.buffer, + triangleIndexBufferView.byteOffset + + triangleIndexAccessorGltf.byteOffset, + triangleIndexAccessorGltf.count + ); + } + + /* + * To figure out which faces are adjacent in this mesh, we put its edges into a directed half edge map. + * + * The version used here is adapted from the one described in [this paper](https://www.graphics.rwth-aachen.de/media/papers/directed.pdf). + * A + * / ^ + * / \ + * v 1 \ + * B -----> C + * <----- + * \ 2 ^ + * \ / + * v / + * D + * Each face is represented by 3 directed half edges. For example, face 1 is made up of: + * A -> B + * B -> C + * C -> A + * + * Each edge has a neighbor connecting the same vertices but in the opposite direction. In the diagram above, B->C's neighbor is C->B. + * For each of a face's half edges, we can get its' neighbor, and therefore the face that neighbor belongs to. + * + */ + var halfEdgeMap = new Map(); + var vertexPositionGetter = generateVertexAttributeGetter( + positions, + positionBufferViewGltf.byteStride / Float32Array.BYTES_PER_ELEMENT + ); + + // Populate our half edge map + if (indexedTriangleMode) { + for (var i = 0; i < triangleIndexAccessorGltf.count; i += 3) { + addTriangleToEdgeGraph( + halfEdgeMap, + undefined, + i, + triangleIndices, + vertexPositionGetter + ); + } + } else { + for (var j = 0; j < positionAccessorGltf.count; j += 3) { + addTriangleToEdgeGraph( + halfEdgeMap, + j, + undefined, + undefined, + vertexPositionGetter + ); + } + } + + var minimumAngle = defined(model.outlineGenerationMinimumAngle) + ? model.outlineGenerationMinimumAngle + : Math.PI / 20; + + if ( + defined(mesh.primitives[primitiveId].extensions) && + defined(mesh.primitives[primitiveId].extensions.CESIUM_primitive_outline) && + defined( + mesh.primitives[primitiveId].extensions.CESIUM_primitive_outline + .outlineWhenAngleBetweenFaceNormalsExceeds + ) && + model.outlineGenerationMode === ModelOutlineGenerationMode.USE_GLTF_SETTINGS + ) { + minimumAngle = + mesh.primitives[primitiveId].extensions.CESIUM_primitive_outline + .outlineWhenAngleBetweenFaceNormalsExceeds; + } + + var outlineIndexBuffer = findEdgesToOutline( + halfEdgeMap, + vertexNormalGetter, + triangleIndices, + minimumAngle + ); + + if (outlineIndexBuffer.length === 0) { + //No edges to outline + return false; + } + + // Add new buffer to gltf + var bufferId = + gltf.buffers.push({ + byteLength: outlineIndexBuffer.byteLength, + extras: { + _pipeline: { + source: outlineIndexBuffer.buffer, + }, + }, + }) - 1; + loadResources.buffers[bufferId] = outlineIndexBuffer; + + // Add new bufferview + var bufferViewId = + bufferViews.push({ + buffer: bufferId, + byteOffset: 0, + byteLength: outlineIndexBuffer.byteLength, + target: WebGLConstants.ELEMENT_ARRAY_BUFFER, + }) - 1; + + // Add new accessor + var accessorId = + accessors.push({ + bufferView: bufferViewId, + byteOffset: 0, + componentType: + outlineIndexBuffer instanceof Uint16Array + ? WebGLConstants.UNSIGNED_SHORT + : WebGLConstants.UNSIGNED_INT, + count: outlineIndexBuffer.length, // start and end for each line + }) - 1; + + mesh.primitives[primitiveId].extensions = { + CESIUM_primitive_outline: { + indices: accessorId, + }, + }; + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + + return true; +} + +/** + * Generates a function for getting the attributes of a vertex with a particular + * index from a glTF vertex array + * @private + */ +function generateVertexAttributeGetter(vertexArray, elementsPerVertex) { + return function (index) { + if (elementsPerVertex * index > vertexArray.length) { + console.log("whoops"); + } + return [ + vertexArray[elementsPerVertex * index], + vertexArray[elementsPerVertex * index + 1], + vertexArray[elementsPerVertex * index + 2], + ]; + }; +} + +/** + * Adds a single triangle to the directed half edge map. + * @param {*} halfEdgeMap + * @param {*} firstVertexIndex + * @param {*} triangleStartIndex + * @param {*} triangleIndices + * @param {*} vertexPositionGetter + * @private + */ +function addTriangleToEdgeGraph( + halfEdgeMap, + firstVertexIndex, + triangleStartIndex, // in indexedTriangle mode, this is an index into the + // index buffer. + triangleIndices, + vertexPositionGetter +) { + var vertexIndexA, vertexIndexB, vertexIndexC; + // Each vertex in the triangle + if (defined(triangleStartIndex) && defined(triangleIndices)) { + vertexIndexA = triangleIndices[triangleStartIndex]; + vertexIndexB = triangleIndices[triangleStartIndex + 1]; + vertexIndexC = triangleIndices[triangleStartIndex + 2]; + } else if (defined(firstVertexIndex)) { + vertexIndexA = firstVertexIndex; + vertexIndexB = firstVertexIndex + 1; + vertexIndexC = firstVertexIndex + 2; + } else { + // throw new DeveloperError( + // "Either firstVertexIndex, or triangleStartIndex and triangleIndices, must be provided." + // ); + } + + // For topologically "well behaved" meshes, adding half edges in both + // directions isn't necessary-- every half-edge's twin exists in another face. + // But in non-2-manifold meshes (where more than 2 faces share an edge), or + // meshes with unconnected faces, this assumption doesn't hold. + var edgePairs = [ + [vertexIndexA, vertexIndexB], + [vertexIndexB, vertexIndexC], + [vertexIndexC, vertexIndexA], + [vertexIndexC, vertexIndexB], + [vertexIndexB, vertexIndexA], + [vertexIndexA, vertexIndexC], + ]; + + for (var i = 0; i < edgePairs.length; i++) { + var pair = edgePairs[i]; + addHalfEdge( + halfEdgeMap, + vertexPositionGetter, + pair[0], + pair[1], + defined(triangleIndices) ? triangleIndices[triangleStartIndex] : undefined + ); + } +} + +function addHalfEdge( + halfEdgeMap, + vertexPositionGetter, + sourceVertexIdx, + destinationVertexIdx, + triangleStartIndex +) { + var halfEdge = { + sourceVertex: vertexPositionGetter(sourceVertexIdx), + destinationVertex: vertexPositionGetter(destinationVertexIdx), + originalIdx: [sourceVertexIdx], + destinationIdx: [destinationVertexIdx], + }; + if (defined(triangleStartIndex)) { + halfEdge.triangleStartIndex = [triangleStartIndex]; + } + var mapIdx = generateMapKey( + halfEdge.sourceVertex, + halfEdge.destinationVertex + ); + var halfEdgeFromMap = halfEdgeMap.get(mapIdx); + if (halfEdgeFromMap) { + halfEdgeFromMap.originalIdx.push(sourceVertexIdx); + halfEdgeFromMap.destinationIdx.push(destinationVertexIdx); + if (defined(triangleStartIndex)) { + halfEdgeFromMap.triangleStartIndex.push(triangleStartIndex); + } + } else { + halfEdgeMap.set(mapIdx, halfEdge); + } + return halfEdge; +} + +function generateMapKey(sourceVertex, destinationVertex) { + return ( + "" + + sourceVertex[0] + + sourceVertex[1] + + sourceVertex[2] + + "#" + + destinationVertex[0] + + destinationVertex[1] + + destinationVertex[2] + ); +} + +function getNeighboringEdge(halfEdgeMap, edge) { + var neighborIdx = generateMapKey(edge.destinationVertex, edge.sourceVertex); + return halfEdgeMap.get(neighborIdx); +} + +// Returns index of first vertex of triangle +function getFirstVertexOfFaces(halfEdge, triangleIndices) { + var faces = []; + if (halfEdge.triangleStartIndex) { + // Indexed triangle mode + faces.push(...halfEdge.triangleStartIndex); + } else { + for (var j = 0; j < halfEdge.originalIdx.length; j++) { + // Unindexed triangle mode + var triangleStart = + halfEdge.originalIdx[j] - (halfEdge.originalIdx[j] % 3); + faces.push(triangleStart); + } + } + return faces; +} + +/** + * From a directed half edge map, determines which edges should be outlined. + * @private + */ +function findEdgesToOutline( + halfEdgeMap, + vertexNormalGetter, + triangleIndices, + minimumAngle +) { + var outlineThese = []; + var checked = new Set(); + var allEdges = Array.from(halfEdgeMap.values()); + var maxIndex = 0; + for (var edgeIdx = 0; edgeIdx < allEdges.length; edgeIdx++) { + var edge = allEdges[edgeIdx]; + if ( + checked.has(generateMapKey(edge.sourceVertex, edge.destinationVertex)) || + checked.has(generateMapKey(edge.destinationVertex, edge.sourceVertex)) + ) { + continue; + } + var neighbor = getNeighboringEdge(halfEdgeMap, edge); + if (!defined(neighbor)) { + continue; + } + // For performance reasons we want to cap the number of vertices we check + // for each edge. Some meshes can have a lot of duplicate vertices in the + // same spot, so we don't want to outline those many, many times. + // This number is arbitrary. + var numIndicesToCheck = 21; + if (edge.originalIdx.length > numIndicesToCheck) { + edge.originalIdx = edge.originalIdx.slice(0, numIndicesToCheck); + } + if (neighbor.originalIdx.length > numIndicesToCheck) { + neighbor.originalIdx = neighbor.originalIdx.slice(0, numIndicesToCheck); + } + + // Get all the faces that share this edge + var primaryEdgeFaces = getFirstVertexOfFaces(edge, triangleIndices); + var neighborEdgeFaces = getFirstVertexOfFaces(neighbor, triangleIndices); + var outline = false; + var startVertex; + var endVertex; + for (var i = 0; i < primaryEdgeFaces.length; i++) { + if (outline) { + break; + } + var faceNormal = vertexNormalGetter(primaryEdgeFaces[i]); + for (var j = 0; j < neighborEdgeFaces.length; j++) { + if (primaryEdgeFaces[i] === neighborEdgeFaces[j]) { + continue; + } + var neighborNormal = vertexNormalGetter(neighborEdgeFaces[j]); + if (!defined(faceNormal) || !defined(neighborNormal)) { + continue; + } + var angleBetween; + try { + angleBetween = Cartesian3.angleBetween( + Cartesian3.fromArray(faceNormal), + Cartesian3.fromArray(neighborNormal) + ); + } catch (error) { + console.log( + "Error trying to find the angle between two faces' normals: " + + error + ); + continue; + } + if (angleBetween > minimumAngle && angleBetween < Math.PI) { + // Outline this edge + startVertex = edge.originalIdx[0]; + endVertex = edge.destinationIdx[0]; + outlineThese.push(startVertex); + outlineThese.push(endVertex); + maxIndex = Math.max(maxIndex, startVertex, endVertex); + outline = true; + break; + // We don't need to check any other faces that share this edge, + // we already know we need to outline it + } + } + } + + checked.add(generateMapKey(edge.sourceVertex, edge.destinationVertex)); + checked.add( + generateMapKey(neighbor.sourceVertex, neighbor.destinationVertex) + ); + } + + if (maxIndex > MAX_GLTF_UINT16_INDEX) { + // The largest index won't fit in a Uint16, so use a Uint32 + return new Uint32Array(outlineThese); + } + return new Uint16Array(outlineThese); +} + +export default ModelOutlineGenerator; diff --git a/Source/Scene/ModelOutlineLoader.js b/Source/Scene/ModelOutlineLoader.js index d8561055255c..219664690091 100644 --- a/Source/Scene/ModelOutlineLoader.js +++ b/Source/Scene/ModelOutlineLoader.js @@ -7,6 +7,9 @@ import TextureMagnificationFilter from "../Renderer/TextureMagnificationFilter.j import TextureMinificationFilter from "../Renderer/TextureMinificationFilter.js"; import TextureWrap from "../Renderer/TextureWrap.js"; import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; +import ModelOutlineGenerator from "./ModelOutlineGenerator.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; +import WebGLConstants from "../Core/WebGLConstants.js"; // glTF does not allow an index value of 65535 because this is the primitive // restart value in some APIs. @@ -29,6 +32,51 @@ ModelOutlineLoader.hasExtension = function (model) { ); }; +/** + * Returns true if outlines should be generated for the model. + * @private + */ +ModelOutlineLoader.shouldGenerateOutlines = function (model) { + if (model.outlineGenerationMode === ModelOutlineGenerationMode.OFF) { + return false; + } + + if (model.outlineGenerationMode === ModelOutlineGenerationMode.ON) { + return true; + } + + var outlineGenerationRequestedInGltf = false; + if (ModelOutlineLoader.hasExtension(model)) { + ForEach.mesh(model.gltf, function (mesh, _meshId) { + if (outlineGenerationRequestedInGltf) { + return true; // break + } + + ForEach.meshPrimitive(mesh, function (primitive, _primitiveId) { + if ( + defined(primitive.extensions) && + defined(primitive.extensions.CESIUM_primitive_outline) && + defined( + primitive.extensions.CESIUM_primitive_outline + .outlineWhenAngleBetweenFaceNormalsExceeds + ) + ) { + outlineGenerationRequestedInGltf = true; + return true; // break + } + }); + }); + } + if ( + model.generateOutlines === ModelOutlineGenerationMode.USE_GLTF_SETTINGS && + outlineGenerationRequestedInGltf + ) { + return true; + } + + return false; +}; + /** * Arranges to outline any primitives with the CESIUM_primitive_outline extension. * It is expected that all buffer data is loaded and available in @@ -37,10 +85,20 @@ ModelOutlineLoader.hasExtension = function (model) { * @private */ ModelOutlineLoader.outlinePrimitives = function (model) { - if (!ModelOutlineLoader.hasExtension(model)) { + if ( + !ModelOutlineLoader.hasExtension(model) && + !ModelOutlineLoader.shouldGenerateOutlines(model) + ) { return; } + if (ModelOutlineLoader.shouldGenerateOutlines(model)) { + if (!ModelOutlineGenerator.generateOutlinesForModel(model)) { + // This model doesn't need outlines loaded for it + return; + } + } + var gltf = model.gltf; // Assumption: A single bufferView contains a single zero-indexed range of vertices. @@ -177,33 +235,90 @@ function addOutline( } var triangleIndexAccessorGltf = accessors[primitive.indices]; - var triangleIndexBufferViewGltf = - bufferViews[triangleIndexAccessorGltf.bufferView]; + var needToCreateIndices = !defined(triangleIndexAccessorGltf); + + var triangleIndexBufferViewGltf = needToCreateIndices + ? {} + : bufferViews[triangleIndexAccessorGltf.bufferView]; + var edgeIndexAccessorGltf = accessors[edgeIndicesAccessorId]; var edgeIndexBufferViewGltf = bufferViews[edgeIndexAccessorGltf.bufferView]; var loadResources = model._loadResources; - var triangleIndexBufferView = loadResources.getBuffer( - triangleIndexBufferViewGltf - ); + + var triangleIndexBufferView; + if (!needToCreateIndices) { + // Generate an index buffer, and use that to create the accessor and bufferview + triangleIndexBufferView = loadResources.getBuffer( + triangleIndexBufferViewGltf + ); + } + var edgeIndexBufferView = loadResources.getBuffer(edgeIndexBufferViewGltf); - var triangleIndices = - triangleIndexAccessorGltf.componentType === 5123 - ? new Uint16Array( - triangleIndexBufferView.buffer, - triangleIndexBufferView.byteOffset + - triangleIndexAccessorGltf.byteOffset, - triangleIndexAccessorGltf.count - ) - : new Uint32Array( - triangleIndexBufferView.buffer, - triangleIndexBufferView.byteOffset + - triangleIndexAccessorGltf.byteOffset, - triangleIndexAccessorGltf.count - ); + var triangleIndices; + if (needToCreateIndices) { + triangleIndices = + numVertices <= MAX_GLTF_UINT16_INDEX + ? new Uint16Array(numVertices) + : new Uint32Array(numVertices); + for (var i = 0; i < triangleIndices.length; ++i) { + triangleIndices[i] = i; + } + + // Update the model to include this new buffer + var bufferId = + gltf.buffers.push({ + byteLength: triangleIndices.byteLength, + extras: { + _pipeline: { + source: triangleIndices.buffer, + }, + }, + }) - 1; + + triangleIndexBufferViewGltf = { + buffer: bufferId, + byteLength: triangleIndices.byteLength, + byteOffset: 0, + target: WebGLConstants.ELEMENT_ARRAY_BUFFER, + }; + var bufferViewId = gltf.bufferViews.push(triangleIndexBufferViewGltf) - 1; + + triangleIndexAccessorGltf = { + bufferView: bufferViewId, + componentType: + numVertices <= MAX_GLTF_UINT16_INDEX + ? WebGLConstants.UNSIGNED_SHORT + : WebGLConstants.UNSIGNED_INT, + count: triangleIndices.length, + max: numVertices, + min: 0, + type: "SCALAR", + }; + gltf.accessors.push(triangleIndexAccessorGltf); + + loadResources.buffers[bufferId] = triangleIndices; + triangleIndexBufferView = triangleIndices; + } else { + triangleIndices = + triangleIndexAccessorGltf.componentType === WebGLConstants.UNSIGNED_SHORT + ? new Uint16Array( + triangleIndexBufferView.buffer, + triangleIndexBufferView.byteOffset + + triangleIndexAccessorGltf.byteOffset, + triangleIndexAccessorGltf.count + ) + : new Uint32Array( + triangleIndexBufferView.buffer, + triangleIndexBufferView.byteOffset + + triangleIndexAccessorGltf.byteOffset, + triangleIndexAccessorGltf.count + ); + } + var edgeIndices = - edgeIndexAccessorGltf.componentType === 5123 + edgeIndexAccessorGltf.componentType === WebGLConstants.UNSIGNED_SHORT ? new Uint16Array( edgeIndexBufferView.buffer, edgeIndexBufferView.byteOffset + edgeIndexAccessorGltf.byteOffset, @@ -283,7 +398,7 @@ function addOutline( ) { // We outgrew a 16-bit index buffer, switch to 32-bit. triangleIndices = new Uint32Array(triangleIndices); - triangleIndexAccessorGltf.componentType = 5125; // UNSIGNED_INT + triangleIndexAccessorGltf.componentType = WebGLConstants.UNSIGNED_INT; triangleIndexBufferViewGltf.buffer = gltf.buffers.push({ byteLength: triangleIndices.byteLength, diff --git a/Source/Scene/ModelUtility.js b/Source/Scene/ModelUtility.js index 8bf81bfd81cd..4e1e8044c525 100644 --- a/Source/Scene/ModelUtility.js +++ b/Source/Scene/ModelUtility.js @@ -16,6 +16,7 @@ import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; import hasExtension from "../ThirdParty/GltfPipeline/hasExtension.js"; import AttributeType from "./AttributeType.js"; import Axis from "./Axis.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; /** * @private @@ -59,7 +60,10 @@ ModelUtility.getAssetVersion = function (gltf) { * @param {Object} gltf A javascript object containing a glTF asset. * @returns {Object} The glTF asset with modified materials. */ -ModelUtility.splitIncompatibleMaterials = function (gltf) { +ModelUtility.splitIncompatibleMaterials = function ( + gltf, + outlineGenerationMode +) { var accessors = gltf.accessors; var materials = gltf.materials; var primitiveInfoByMaterial = {}; @@ -85,8 +89,10 @@ ModelUtility.splitIncompatibleMaterials = function (gltf) { var hasTexCoord1 = hasTexCoords && defined(primitive.attributes.TEXCOORD_1); var hasOutline = - defined(primitive.extensions) && - defined(primitive.extensions.CESIUM_primitive_outline); + outlineGenerationMode === ModelOutlineGenerationMode.ON || + (ModelOutlineGenerationMode.USE_GLTF_SETTINGS && + defined(primitive.extensions) && + defined(primitive.extensions.CESIUM_primitive_outline)); var primitiveInfo = primitiveInfoByMaterial[materialIndex]; if (!defined(primitiveInfo)) { diff --git a/Source/Scene/processModelMaterialsCommon.js b/Source/Scene/processModelMaterialsCommon.js index 5ce0bf0f226a..bb59571836d1 100644 --- a/Source/Scene/processModelMaterialsCommon.js +++ b/Source/Scene/processModelMaterialsCommon.js @@ -6,12 +6,13 @@ import addToArray from "../ThirdParty/GltfPipeline/addToArray.js"; import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; import hasExtension from "../ThirdParty/GltfPipeline/hasExtension.js"; import ModelUtility from "./ModelUtility.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; /** * @private */ function processModelMaterialsCommon(gltf, options) { - options = defaultValue(options, defaultValue.EMPTY_OBJECT); + options = defaultValue(options, {outlineGenerationMode: ModelOutlineGenerationMode.USE_GLTF_SETTINGS}); if (!defined(gltf)) { return; @@ -41,7 +42,7 @@ function processModelMaterialsCommon(gltf, options) { var lightParameters = generateLightParameters(gltf); - var primitiveByMaterial = ModelUtility.splitIncompatibleMaterials(gltf); + var primitiveByMaterial = ModelUtility.splitIncompatibleMaterials(gltf, options.outlineGenerationMode); var techniques = {}; var generatedTechniques = false; diff --git a/Source/Scene/processPbrMaterials.js b/Source/Scene/processPbrMaterials.js index 140d9ec53cdc..45eb3c004aef 100644 --- a/Source/Scene/processPbrMaterials.js +++ b/Source/Scene/processPbrMaterials.js @@ -6,12 +6,13 @@ import addToArray from "../ThirdParty/GltfPipeline/addToArray.js"; import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; import hasExtension from "../ThirdParty/GltfPipeline/hasExtension.js"; import ModelUtility from "./ModelUtility.js"; +import ModelOutlineGenerationMode from "./ModelOutlineGenerationMode.js"; /** * @private */ function processPbrMaterials(gltf, options) { - options = defaultValue(options, defaultValue.EMPTY_OBJECT); + options = defaultValue(options, {outlineGenerationMode: ModelOutlineGenerationMode.USE_GLTF_SETTINGS}); // No need to create new techniques if they already exist, // the shader should handle these values @@ -46,7 +47,7 @@ function processPbrMaterials(gltf, options) { gltf.extensionsUsed.push("KHR_techniques_webgl"); gltf.extensionsRequired.push("KHR_techniques_webgl"); - var primitiveByMaterial = ModelUtility.splitIncompatibleMaterials(gltf); + var primitiveByMaterial = ModelUtility.splitIncompatibleMaterials(gltf, options.outlineGenerationMode); ForEach.material(gltf, function (material, materialIndex) { var generatedMaterialValues = {}; diff --git a/package.json b/package.json index 086d03415807..87c0e20e6e1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "terriajs-cesium", - "version": "1.73.1", + "version": "1.73.1-outlines-1", "description": "Cesium for TerriaJS.", "homepage": "http://cesium.com/cesiumjs/", "license": "Apache-2.0",