diff --git a/CHANGES.md b/CHANGES.md index 518d3b3fc407..42acd20f33aa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ - Added `Scene.cameraUnderground` for checking whether the camera is underneath the globe. [#8765](https://github.com/CesiumGS/cesium/pull/8765) - Added `RequestScheduler` to the public API; this allows users to have more control over the requests made by CesiumJS. [#8384](https://github.com/CesiumGS/cesium/issues/8384) +- Added support for high-quality edges on solid geometry in glTF models. [#8776](https://github.com/CesiumGS/cesium/pull/8776) ##### Fixes :wrench: diff --git a/Source/Renderer/Texture.js b/Source/Renderer/Texture.js index c7ad15a4a85e..d4d117da35e1 100644 --- a/Source/Renderer/Texture.js +++ b/Source/Renderer/Texture.js @@ -276,6 +276,32 @@ function Texture(options) { pixelDatatype, arrayBufferView ); + + if (defined(source.mipLevels)) { + var mipWidth = width; + var mipHeight = height; + for (var i = 0; i < source.mipLevels.length; ++i) { + mipWidth = Math.floor(mipWidth / 2) | 0; + if (mipWidth < 1) { + mipWidth = 1; + } + mipHeight = Math.floor(mipHeight / 2) | 0; + if (mipHeight < 1) { + mipHeight = 1; + } + gl.texImage2D( + textureTarget, + i + 1, + internalFormat, + mipWidth, + mipHeight, + 0, + pixelFormat, + pixelDatatype, + source.mipLevels[i] + ); + } + } } } else if (defined(source.framebuffer)) { gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); diff --git a/Source/Scene/Model.js b/Source/Scene/Model.js index 3dc30187e82d..6720690cb6db 100644 --- a/Source/Scene/Model.js +++ b/Source/Scene/Model.js @@ -68,6 +68,7 @@ import ModelLoadResources from "./ModelLoadResources.js"; import ModelMaterial from "./ModelMaterial.js"; import ModelMesh from "./ModelMesh.js"; import ModelNode from "./ModelNode.js"; +import ModelOutlineLoader from "./ModelOutlineLoader.js"; import ModelUtility from "./ModelUtility.js"; import OctahedralProjectedCubeMap from "./OctahedralProjectedCubeMap.js"; import processModelMaterialsCommon from "./processModelMaterialsCommon.js"; @@ -3603,6 +3604,13 @@ function createUniformMaps(model, context) { u.values = uniforms.values; // material parameter name -> ModelMaterial for modifying the parameter at runtime u.jointMatrixUniformName = uniforms.jointMatrixUniformName; u.morphWeightsUniformName = uniforms.morphWeightsUniformName; + + if (defined(technique.attributes.a_outlineCoordinates)) { + var outlineTexture = ModelOutlineLoader.createTexture(model, context); + u.uniformMap.u_outlineTexture = function () { + return outlineTexture; + }; + } }); } @@ -5249,6 +5257,7 @@ Model.prototype.update = function (frameState) { loadResources.resourcesParsed && loadResources.pendingShaderLoads === 0 ) { + ModelOutlineLoader.outlinePrimitives(this); createResources(this, frameState); } } diff --git a/Source/Scene/ModelOutlineLoader.js b/Source/Scene/ModelOutlineLoader.js new file mode 100644 index 000000000000..eb50d33f5889 --- /dev/null +++ b/Source/Scene/ModelOutlineLoader.js @@ -0,0 +1,759 @@ +import defined from "../Core/defined.js"; +import PixelFormat from "../Core/PixelFormat.js"; +import ContextLimits from "../Renderer/ContextLimits.js"; +import Sampler from "../Renderer/Sampler.js"; +import Texture from "../Renderer/Texture.js"; +import TextureMagnificationFilter from "../Renderer/TextureMagnificationFilter.js"; +import TextureMinificationFilter from "../Renderer/TextureMinificationFilter.js"; +import TextureWrap from "../Renderer/TextureWrap.js"; +import ForEach from "../ThirdParty/GltfPipeline/ForEach.js"; + +/** + * Creates face outlines for glTF primitives with the `CESIUM_primitive_outline` extension. + * @private + */ +function ModelOutlineLoader() {} + +/** + * Returns true if the model uses or requires CESIUM_primitive_outline. + * @private + */ +ModelOutlineLoader.hasExtension = function (model) { + return ( + defined(model.extensionsRequired.CESIUM_primitive_outline) || + defined(model.extensionsUsed.CESIUM_primitive_outline) + ); +}; + +/** + * Arranges to outline any primitives with the CESIUM_primitive_outline extension. + * It is expected that all buffer data is loaded and available in + * `extras._pipeline.source` before this function is called, and that vertex + * and index WebGL buffers are not yet created. + * @private + */ +ModelOutlineLoader.outlinePrimitives = function (model) { + if (!ModelOutlineLoader.hasExtension(model)) { + return; + } + + var gltf = model.gltf; + + // Assumption: A single bufferView contains a single zero-indexed range of vertices. + // No trickery with using large accessor byteOffsets to store multiple zero-based + // ranges of vertices in a single bufferView. Use separate bufferViews for that, + // you monster. + // Note that interleaved vertex attributes (e.g. position0, normal0, uv0, + // position1, normal1, uv1, ...) _are_ supported and should not be confused with + // the above. + + var vertexNumberingScopes = []; + + ForEach.mesh(gltf, function (mesh, meshId) { + ForEach.meshPrimitive(mesh, function (primitive, primitiveId) { + if (!defined(primitive.extensions)) { + return; + } + + var outlineData = primitive.extensions.CESIUM_primitive_outline; + if (!defined(outlineData)) { + return; + } + + var vertexNumberingScope = getVertexNumberingScope(model, primitive); + if (vertexNumberingScope === undefined) { + return; + } + + if (vertexNumberingScopes.indexOf(vertexNumberingScope) < 0) { + vertexNumberingScopes.push(vertexNumberingScope); + } + + // Add the outline to this primitive + addOutline( + model, + meshId, + primitiveId, + outlineData.indices, + vertexNumberingScope + ); + }); + }); + + // Update all relevant bufferViews to include the duplicate vertices that are + // needed for outlining. + for (var i = 0; i < vertexNumberingScopes.length; ++i) { + updateBufferViewsWithNewVertices( + model, + vertexNumberingScopes[i].bufferViews + ); + } + + // Remove data not referenced by any bufferViews anymore. + compactBuffers(model); +}; + +ModelOutlineLoader.createTexture = function (model, context) { + var cache = context.cache.modelOutliningCache; + if (!defined(cache)) { + cache = context.cache.modelOutliningCache = {}; + } + + if (defined(cache.outlineTexture)) { + return cache.outlineTexture; + } + + var maxSize = Math.min(4096, ContextLimits.maximumTextureSize); + + var size = maxSize; + var levelZero = createTexture(size); + + var mipLevels = []; + + while (size > 1) { + size >>= 1; + mipLevels.push(createTexture(size)); + } + + var texture = new Texture({ + context: context, + source: { + arrayBufferView: levelZero, + mipLevels: mipLevels, + }, + width: maxSize, + height: 1, + pixelFormat: PixelFormat.LUMINANCE, + sampler: new Sampler({ + wrapS: TextureWrap.CLAMP_TO_EDGE, + wrapT: TextureWrap.CLAMP_TO_EDGE, + minificationFilter: TextureMinificationFilter.LINEAR_MIPMAP_LINEAR, + magnificationFilter: TextureMagnificationFilter.LINEAR, + }), + }); + + cache.outlineTexture = texture; + + return texture; +}; + +function addOutline( + model, + meshId, + primitiveId, + edgeIndicesAccessorId, + vertexNumberingScope +) { + var vertexCopies = vertexNumberingScope.vertexCopies; + var extraVertices = vertexNumberingScope.extraVertices; + var outlineCoordinates = vertexNumberingScope.outlineCoordinates; + + var gltf = model.gltf; + var mesh = gltf.meshes[meshId]; + var primitive = mesh.primitives[primitiveId]; + var accessors = gltf.accessors; + var bufferViews = gltf.bufferViews; + + // Find the number of vertices in this primitive by looking at + // the first attribute. Others are required to be the same. + var numVertices; + for (var semantic in primitive.attributes) { + if (primitive.attributes.hasOwnProperty(semantic)) { + var attributeId = primitive.attributes[semantic]; + var accessor = accessors[attributeId]; + if (defined(accessor)) { + numVertices = accessor.count; + break; + } + } + } + + if (!defined(numVertices)) { + return undefined; + } + + var triangleIndexAccessorGltf = accessors[primitive.indices]; + var triangleIndexBufferViewGltf = + bufferViews[triangleIndexAccessorGltf.bufferView]; + var edgeIndexAccessorGltf = accessors[edgeIndicesAccessorId]; + var edgeIndexBufferViewGltf = bufferViews[edgeIndexAccessorGltf.bufferView]; + + var loadResources = model._loadResources; + var 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 edgeIndices = + edgeIndexAccessorGltf.componentType === 5123 + ? new Uint16Array( + edgeIndexBufferView.buffer, + edgeIndexBufferView.byteOffset + edgeIndexAccessorGltf.byteOffset, + edgeIndexAccessorGltf.count + ) + : new Uint32Array( + edgeIndexBufferView.buffer, + edgeIndexBufferView.byteOffset + edgeIndexAccessorGltf.byteOffset, + edgeIndexAccessorGltf.count + ); + + // Make a hash table for quick lookups of whether an edge exists between two + // vertices. The hash is a sparse array indexed by + // `smallerVertexIndex * totalNumberOfVertices + biggerVertexIndex` + // A value of 1 indicates an edge exists between the two vertex indices; any + // other value indicates that it does not. We store the + // `edgeSmallMultipler` - that is, the number of vertices in the equation + // above - at index 0 for easy access to it later. + + var edgeSmallMultiplier = numVertices; + + var edges = [edgeSmallMultiplier]; + var i; + for (i = 0; i < edgeIndices.length; i += 2) { + var a = edgeIndices[i]; + var b = edgeIndices[i + 1]; + var small = Math.min(a, b); + var big = Math.max(a, b); + edges[small * edgeSmallMultiplier + big] = 1; + } + + // For each triangle, adjust vertex data so that the correct edges are outlined. + for (i = 0; i < triangleIndices.length; i += 3) { + var i0 = triangleIndices[i]; + var i1 = triangleIndices[i + 1]; + var i2 = triangleIndices[i + 2]; + + var all = false; // set this to true to draw a full wireframe. + var has01 = all || isHighlighted(edges, i0, i1); + var has12 = all || isHighlighted(edges, i1, i2); + var has20 = all || isHighlighted(edges, i2, i0); + + var unmatchableVertexIndex = matchAndStoreCoordinates( + outlineCoordinates, + i0, + i1, + i2, + has01, + has12, + has20 + ); + while (unmatchableVertexIndex >= 0) { + // Copy the unmatchable index and try again. + var copy; + if (unmatchableVertexIndex === i0) { + copy = vertexCopies[i0]; + } else if (unmatchableVertexIndex === i1) { + copy = vertexCopies[i1]; + } else { + copy = vertexCopies[i2]; + } + + if (copy === undefined) { + copy = numVertices + extraVertices.length; + + var original = unmatchableVertexIndex; + while (original >= numVertices) { + original = extraVertices[original - numVertices]; + } + extraVertices.push(original); + vertexCopies[unmatchableVertexIndex] = copy; + } + + if (copy >= 65536 && triangleIndices instanceof Uint16Array) { + // We outgrew a 16-bit index buffer, switch to 32-bit. + triangleIndices = new Uint32Array(triangleIndices); + triangleIndexAccessorGltf.componentType = 5125; // UNSIGNED_INT + triangleIndexBufferViewGltf.buffer = + gltf.buffers.push({ + byteLength: triangleIndices.byteLength, + extras: { + _pipeline: { + source: triangleIndices.buffer, + }, + }, + }) - 1; + triangleIndexBufferViewGltf.byteLength = triangleIndices.byteLength; + triangleIndexBufferViewGltf.byteOffset = 0; + model._loadResources.buffers[ + triangleIndexBufferViewGltf.buffer + ] = new Uint8Array( + triangleIndices.buffer, + 0, + triangleIndices.byteLength + ); + } + + if (unmatchableVertexIndex === i0) { + i0 = copy; + triangleIndices[i] = copy; + } else if (unmatchableVertexIndex === i1) { + i1 = copy; + triangleIndices[i + 1] = copy; + } else { + i2 = copy; + triangleIndices[i + 2] = copy; + } + + if (defined(triangleIndexAccessorGltf.max)) { + triangleIndexAccessorGltf.max[0] = Math.max( + triangleIndexAccessorGltf.max[0], + copy + ); + } + + unmatchableVertexIndex = matchAndStoreCoordinates( + outlineCoordinates, + i0, + i1, + i2, + has01, + has12, + has20 + ); + } + } +} + +// Each vertex has three coordinates, a, b, and c. +// a is the coordinate that applies to edge 2-0 for the vertex. +// b is the coordinate that applies to edge 0-1 for the vertex. +// c is the coordinate that applies to edge 1-2 for the vertex. + +// A single triangle with all edges highlighted: +// +// | a | b | c | +// | 1 | 1 | 0 | +// 0 +// / \ +// / \ +// edge 0-1 / \ edge 2-0 +// / \ +// / \ +// | a | b | c | 1-----------2 | a | b | c | +// | 0 | 1 | 1 | edge 1-2 | 1 | 0 | 1 | +// +// There are 6 possible orderings of coordinates a, b, and c: +// 0 - abc +// 1 - acb +// 2 - bac +// 3 - bca +// 4 - cab +// 5 - cba + +// All vertices must use the _same ordering_ for the edges to be rendered +// correctly. So we compute a bitmask for each vertex, where the bit at +// each position indicates whether that ordering works (i.e. doesn't +// conflict with already-assigned coordinates) for that vertex. + +// Then we can find an ordering that works for all three vertices with a +// bitwise AND. + +function computeOrderMask(outlineCoordinates, vertexIndex, a, b, c) { + var startIndex = vertexIndex * 3; + var first = outlineCoordinates[startIndex]; + var second = outlineCoordinates[startIndex + 1]; + var third = outlineCoordinates[startIndex + 2]; + + if (first === undefined) { + // If one coordinate is undefined, they all are, and all orderings are fine. + return 63; // 0b111111; + } + + return ( + ((first === a && second === b && third === c) << 0) + + ((first === a && second === c && third === b) << 1) + + ((first === b && second === a && third === c) << 2) + + ((first === b && second === c && third === a) << 3) + + ((first === c && second === a && third === b) << 4) + + ((first === c && second === b && third === a) << 5) + ); +} + +// popcount for integers 0-63, inclusive. +// i.e. how many 1s are in the binary representation of the integer. +function popcount0to63(value) { + return ( + (value & 1) + + ((value >> 1) & 1) + + ((value >> 2) & 1) + + ((value >> 3) & 1) + + ((value >> 4) & 1) + + ((value >> 5) & 1) + ); +} + +function matchAndStoreCoordinates( + outlineCoordinates, + i0, + i1, + i2, + has01, + has12, + has20 +) { + var a0 = has20 ? 1.0 : 0.0; + var b0 = has01 ? 1.0 : 0.0; + var c0 = 0.0; + + var i0Mask = computeOrderMask(outlineCoordinates, i0, a0, b0, c0); + if (i0Mask === 0) { + return i0; + } + + var a1 = 0.0; + var b1 = has01 ? 1.0 : 0.0; + var c1 = has12 ? 1.0 : 0.0; + + var i1Mask = computeOrderMask(outlineCoordinates, i1, a1, b1, c1); + if (i1Mask === 0) { + return i1; + } + + var a2 = has20 ? 1.0 : 0.0; + var b2 = 0.0; + var c2 = has12 ? 1.0 : 0.0; + + var i2Mask = computeOrderMask(outlineCoordinates, i2, a2, b2, c2); + if (i2Mask === 0) { + return i2; + } + + var workingOrders = i0Mask & i1Mask & i2Mask; + + var a, b, c; + + if (workingOrders & (1 << 0)) { + // 0 - abc + a = 0; + b = 1; + c = 2; + } else if (workingOrders & (1 << 1)) { + // 1 - acb + a = 0; + c = 1; + b = 2; + } else if (workingOrders & (1 << 2)) { + // 2 - bac + b = 0; + a = 1; + c = 2; + } else if (workingOrders & (1 << 3)) { + // 3 - bca + b = 0; + c = 1; + a = 2; + } else if (workingOrders & (1 << 4)) { + // 4 - cab + c = 0; + a = 1; + b = 2; + } else if (workingOrders & (1 << 5)) { + // 5 - cba + c = 0; + b = 1; + a = 2; + } else { + // No ordering works. + // Report the most constrained vertex as unmatched so we copy that one. + var i0Popcount = popcount0to63(i0Mask); + var i1Popcount = popcount0to63(i1Mask); + var i2Popcount = popcount0to63(i2Mask); + if (i0Popcount < i1Popcount && i0Popcount < i2Popcount) { + return i0; + } else if (i1Popcount < i2Popcount) { + return i1; + } + return i2; + } + + var i0Start = i0 * 3; + outlineCoordinates[i0Start + a] = a0; + outlineCoordinates[i0Start + b] = b0; + outlineCoordinates[i0Start + c] = c0; + + var i1Start = i1 * 3; + outlineCoordinates[i1Start + a] = a1; + outlineCoordinates[i1Start + b] = b1; + outlineCoordinates[i1Start + c] = c1; + + var i2Start = i2 * 3; + outlineCoordinates[i2Start + a] = a2; + outlineCoordinates[i2Start + b] = b2; + outlineCoordinates[i2Start + c] = c2; + + return -1; +} + +function isHighlighted(edges, i0, i1) { + var edgeSmallMultiplier = edges[0]; + var index = Math.min(i0, i1) * edgeSmallMultiplier + Math.max(i0, i1); + + // If i0 and i1 are both 0, then our index will be 0 and we'll end up + // accessing the edgeSmallMultiplier that we've sneakily squirreled away + // in index 0. But it makes no sense to have an edge between vertex 0 and + // itself, so for any edgeSmallMultiplier other than 1 we'll return the + // correct answer: false. If edgeSmallMultiplier is 1, that means there is + // only a single vertex, so no danger of forming a meaningful triangle + // with that. + return edges[index] === 1; +} + +function createTexture(size) { + var texture = new Uint8Array(size); + texture[size - 1] = 192; + if (size === 8) { + texture[size - 1] = 96; + } else if (size === 4) { + texture[size - 1] = 48; + } else if (size === 2) { + texture[size - 1] = 24; + } else if (size === 1) { + texture[size - 1] = 12; + } + return texture; +} + +function updateBufferViewsWithNewVertices(model, bufferViews) { + var gltf = model.gltf; + var loadResources = model._loadResources; + + var i, j; + for (i = 0; i < bufferViews.length; ++i) { + var bufferView = bufferViews[i]; + var vertexNumberingScope = bufferView.extras._pipeline.vertexNumberingScope; + + // Let the temporary data be garbage collected. + bufferView.extras._pipeline.vertexNumberingScope = undefined; + + var newVertices = vertexNumberingScope.extraVertices; + + var sourceData = loadResources.getBuffer(bufferView); + var byteStride = bufferView.byteStride || 4; + var newVerticesLength = newVertices.length; + var destData = new Uint8Array( + sourceData.byteLength + newVerticesLength * byteStride + ); + + // Copy the original vertices + destData.set(sourceData); + + // Copy the vertices added for outlining + for (j = 0; j < newVerticesLength; ++j) { + var sourceIndex = newVertices[j] * byteStride; + var destIndex = sourceData.length + j * byteStride; + for (var k = 0; k < byteStride; ++k) { + destData[destIndex + k] = destData[sourceIndex + k]; + } + } + + // This bufferView is an independent buffer now. Update the model accordingly. + bufferView.byteOffset = 0; + bufferView.byteLength = destData.byteLength; + + var bufferId = + gltf.buffers.push({ + byteLength: destData.byteLength, + extras: { + _pipeline: { + source: destData.buffer, + }, + }, + }) - 1; + + bufferView.buffer = bufferId; + loadResources.buffers[bufferId] = destData; + + // Update the accessors to reflect the added vertices. + var accessors = vertexNumberingScope.accessors; + for (j = 0; j < accessors.length; ++j) { + var accessorId = accessors[j]; + gltf.accessors[accessorId].count += newVerticesLength; + } + + if (!vertexNumberingScope.createdOutlines) { + // Create the buffers, views, and accessors for the outline texture coordinates. + var outlineCoordinates = vertexNumberingScope.outlineCoordinates; + var outlineCoordinateBuffer = new Float32Array(outlineCoordinates); + var bufferIndex = + model.gltf.buffers.push({ + byteLength: outlineCoordinateBuffer.byteLength, + extras: { + _pipeline: { + source: outlineCoordinateBuffer.buffer, + }, + }, + }) - 1; + loadResources.buffers[bufferIndex] = new Uint8Array( + outlineCoordinateBuffer.buffer, + 0, + outlineCoordinateBuffer.byteLength + ); + + var bufferViewIndex = + model.gltf.bufferViews.push({ + buffer: bufferIndex, + byteLength: outlineCoordinateBuffer.byteLength, + byteOffset: 0, + byteStride: 3 * Float32Array.BYTES_PER_ELEMENT, + target: 34962, + }) - 1; + + var accessorIndex = + model.gltf.accessors.push({ + bufferView: bufferViewIndex, + byteOffset: 0, + componentType: 5126, + count: outlineCoordinateBuffer.length / 3, + type: "VEC3", + min: [0.0, 0.0, 0.0], + max: [1.0, 1.0, 1.0], + }) - 1; + + var primitives = vertexNumberingScope.primitives; + for (j = 0; j < primitives.length; ++j) { + primitives[j].attributes._OUTLINE_COORDINATES = accessorIndex; + } + + loadResources.vertexBuffersToCreate.enqueue(bufferViewIndex); + + vertexNumberingScope.createdOutlines = true; + } + } +} + +function compactBuffers(model) { + var gltf = model.gltf; + var loadResources = model._loadResources; + + var i; + for (i = 0; i < gltf.buffers.length; ++i) { + var buffer = gltf.buffers[i]; + var bufferViewsUsingThisBuffer = gltf.bufferViews.filter( + usesBuffer.bind(undefined, i) + ); + var newLength = bufferViewsUsingThisBuffer.reduce(function ( + previous, + current + ) { + return previous + current.byteLength; + }, + 0); + if (newLength === buffer.byteLength) { + continue; + } + + var newBuffer = new Uint8Array(newLength); + var offset = 0; + for (var j = 0; j < bufferViewsUsingThisBuffer.length; ++j) { + var bufferView = bufferViewsUsingThisBuffer[j]; + var sourceData = loadResources.getBuffer(bufferView); + newBuffer.set(sourceData, offset); + + bufferView.byteOffset = offset; + offset += sourceData.byteLength; + } + + loadResources.buffers[i] = newBuffer; + buffer.extras._pipeline.source = newBuffer.buffer; + buffer.byteLength = newLength; + } +} + +function usesBuffer(bufferId, bufferView) { + return bufferView.buffer === bufferId; +} + +function getVertexNumberingScope(model, primitive) { + var attributes = primitive.attributes; + if (attributes === undefined) { + return undefined; + } + + var gltf = model.gltf; + + var vertexNumberingScope; + + // Initialize common details for all bufferViews used by this primitive's vertices. + // All bufferViews used by this primitive must use a common vertex numbering scheme. + for (var semantic in attributes) { + if (!attributes.hasOwnProperty(semantic)) { + continue; + } + + var accessorId = attributes[semantic]; + var accessor = gltf.accessors[accessorId]; + var bufferViewId = accessor.bufferView; + var bufferView = gltf.bufferViews[bufferViewId]; + + if (!defined(bufferView.extras)) { + bufferView.extras = {}; + } + if (!defined(bufferView.extras._pipeline)) { + bufferView.extras._pipeline = {}; + } + + if (!defined(bufferView.extras._pipeline.vertexNumberingScope)) { + bufferView.extras._pipeline.vertexNumberingScope = vertexNumberingScope || { + // Each element in this array is: + // a) undefined, if the vertex at this index has no copies + // b) the index of the copy. + vertexCopies: [], + + // Extra vertices appended after the ones originally included in the model. + // Each element is the index of the vertex that this one is a copy of. + extraVertices: [], + + // The texture coordinates used for outlining, three floats per vertex. + outlineCoordinates: [], + + // The IDs of accessors that use this vertex numbering. + accessors: [], + + // The IDs of bufferViews that use this vertex numbering. + bufferViews: [], + + // The primitives that use this vertex numbering. + primitives: [], + + // True if the buffer for the outlines has already been created. + createdOutlines: false, + }; + } else if ( + vertexNumberingScope !== undefined && + bufferView.extras._pipeline.vertexNumberingScope !== vertexNumberingScope + ) { + // Conflicting vertex numbering, let's give up. + return undefined; + } + + vertexNumberingScope = bufferView.extras._pipeline.vertexNumberingScope; + + if (vertexNumberingScope.bufferViews.indexOf(bufferView) < 0) { + vertexNumberingScope.bufferViews.push(bufferView); + } + + if (vertexNumberingScope.accessors.indexOf(accessorId) < 0) { + vertexNumberingScope.accessors.push(accessorId); + } + } + + vertexNumberingScope.primitives.push(primitive); + + return vertexNumberingScope; +} + +export default ModelOutlineLoader; diff --git a/Source/Scene/ModelUtility.js b/Source/Scene/ModelUtility.js index 6a04d11c73bb..1f41ba6356ab 100644 --- a/Source/Scene/ModelUtility.js +++ b/Source/Scene/ModelUtility.js @@ -82,6 +82,9 @@ ModelUtility.splitIncompatibleMaterials = function (gltf) { var hasNormals = defined(primitive.attributes.NORMAL); var hasTangents = defined(primitive.attributes.TANGENT); var hasTexCoords = defined(primitive.attributes.TEXCOORD_0); + var hasOutline = + defined(primitive.extensions) && + defined(primitive.extensions.CESIUM_primitive_outline); var primitiveInfo = primitiveInfoByMaterial[materialIndex]; if (!defined(primitiveInfo)) { @@ -96,6 +99,7 @@ ModelUtility.splitIncompatibleMaterials = function (gltf) { hasNormals: hasNormals, hasTangents: hasTangents, hasTexCoords: hasTexCoords, + hasOutline: hasOutline, }; } else if ( primitiveInfo.skinning.skinned !== isSkinned || @@ -104,12 +108,14 @@ ModelUtility.splitIncompatibleMaterials = function (gltf) { primitiveInfo.hasMorphTargets !== hasMorphTargets || primitiveInfo.hasNormals !== hasNormals || primitiveInfo.hasTangents !== hasTangents || - primitiveInfo.hasTexCoords !== hasTexCoords + primitiveInfo.hasTexCoords !== hasTexCoords || + primitiveInfo.hasOutline !== hasOutline ) { // This primitive uses the same material as another one that either: // * Isn't skinned // * Uses a different type to store joints and weights // * Doesn't have vertex colors, morph targets, normals, tangents, or texCoords + // * Doesn't have a CESIUM_primitive_outline extension. var clonedMaterial = clone(material, true); // Split this off as a separate material materialIndex = addToArray(materials, clonedMaterial); @@ -125,6 +131,7 @@ ModelUtility.splitIncompatibleMaterials = function (gltf) { hasNormals: hasNormals, hasTangents: hasTangents, hasTexCoords: hasTexCoords, + hasOutline: hasOutline, }; } }); diff --git a/Source/Scene/processPbrMaterials.js b/Source/Scene/processPbrMaterials.js index 748fd62491cd..b38298c42d59 100644 --- a/Source/Scene/processPbrMaterials.js +++ b/Source/Scene/processPbrMaterials.js @@ -236,6 +236,7 @@ function generateTechnique( var hasNormals = false; var hasTangents = false; var hasTexCoords = false; + var hasOutline = false; var isUnlit = false; if (defined(primitiveInfo)) { @@ -246,6 +247,7 @@ function generateTechnique( hasNormals = primitiveInfo.hasNormals; hasTangents = primitiveInfo.hasTangents; hasTexCoords = primitiveInfo.hasTexCoords; + hasOutline = primitiveInfo.hasOutline; } var morphTargets; @@ -365,6 +367,10 @@ function generateTechnique( } } + if (hasOutline) { + fragmentShader += "uniform sampler2D u_outlineTexture;\n"; + } + // Add attributes with semantics var vertexShaderMain = ""; if (hasSkinning) { @@ -414,10 +420,21 @@ function generateTechnique( semantic: "POSITION", }, }; + + if (hasOutline) { + techniqueAttributes.a_outlineCoordinates = { + semantic: "_OUTLINE_COORDINATES", + }; + } + vertexShader += "attribute vec3 a_position;\n"; if (hasNormals) { vertexShader += "varying vec3 v_positionEC;\n"; } + if (hasOutline) { + vertexShader += "attribute vec3 a_outlineCoordinates;\n"; + vertexShader += "varying vec3 v_outlineCoordinates;\n"; + } // Morph Target Weighting vertexShaderMain += " vec3 weightedPosition = a_position;\n"; @@ -480,6 +497,10 @@ function generateTechnique( } vertexShaderMain += " gl_Position = u_projectionMatrix * position;\n"; + if (hasOutline) { + vertexShaderMain += " v_outlineCoordinates = a_outlineCoordinates;\n"; + } + // Final normal computation if (hasNormals) { techniqueAttributes.a_normal = { @@ -512,6 +533,10 @@ function generateTechnique( fragmentShader += "varying vec4 v_tangent;\n"; } + if (hasOutline) { + fragmentShader += "varying vec3 v_outlineCoordinates;\n"; + } + var fragmentShaderMain = ""; // Add texture coordinates if the material uses them @@ -1102,6 +1127,19 @@ function generateTechnique( fragmentShader += " color = LINEARtoSRGB(color);\n"; + if (hasOutline) { + fragmentShader += " float outlineness = max(\n"; + fragmentShader += + " texture2D(u_outlineTexture, vec2(v_outlineCoordinates.x, 0.5)).r,\n"; + fragmentShader += " max(\n"; + fragmentShader += + " texture2D(u_outlineTexture, vec2(v_outlineCoordinates.y, 0.5)).r,\n"; + fragmentShader += + " texture2D(u_outlineTexture, vec2(v_outlineCoordinates.z, 0.5)).r));\n"; + fragmentShader += + " color = mix(color, vec3(0.0, 0.0, 0.0), outlineness);\n"; + } + if (defined(alphaMode)) { if (alphaMode === "MASK") { fragmentShader += " if (baseColorWithAlpha.a < u_alphaCutoff) {\n"; diff --git a/Specs/Scene/GltfBuilder.js b/Specs/Scene/GltfBuilder.js new file mode 100644 index 000000000000..1ce603862428 --- /dev/null +++ b/Specs/Scene/GltfBuilder.js @@ -0,0 +1,465 @@ +import { defined, RuntimeError } from "../../Source/Cesium.js"; +import findAccessorMinMax from "../../Source/ThirdParty/GltfPipeline/findAccessorMinMax.js"; + +/** + * A fluent interface for programmatically building a glTF. + * @alias GltfBuilder + * @constructor + * @private + */ +function GltfBuilder() { + this.gltf = { + asset: { + generator: "cesium-tests", + version: "2.0", + }, + extensionsUsed: [], + accessors: [], + buffers: [], + bufferViews: [], + materials: [], + meshes: [], + nodes: [ + { + mesh: 0, + }, + ], + scenes: [ + { + nodes: [0], + }, + ], + scene: 0, + }; + + this.bufferBuilders = []; +} + +/** + * Creates a new buffer. + * @param {string} [name] The name of the buffer. + * @returns {GltfBufferBuilder} + */ +GltfBuilder.prototype.buffer = function (name) { + var index = + this.gltf.buffers.push({ + name: name, + byteLength: 0, + }) - 1; + var bufferBuilder = new GltfBufferBuilder(this, index); + this.bufferBuilders.push(bufferBuilder); + return bufferBuilder; +}; + +/** + * Creates a new mesh. + * @param {string} [name] The name of the mesh. + * @returns {GltfMeshBuilder} + */ +GltfBuilder.prototype.mesh = function (name) { + var index = + this.gltf.meshes.push({ + name: name, + primitives: [], + }) - 1; + + var meshBuilder = new GltfMeshBuilder(this, index); + return meshBuilder; +}; + +/** + * Creates a new material. + * @param {string} [name] The name of the material. + * @returns {GltfMaterialBuilder} + */ +GltfBuilder.prototype.material = function (name) { + var index = + this.gltf.materials.push({ + name: name, + }) - 1; + var materialBuilder = new GltfMaterialBuilder(this, index); + return materialBuilder; +}; + +/** + * Gets the built glTF JSON from this builder. Calling this a second + * time will cause the glTF returned in the first call to be invalidated. + * Specifically, the `uri` properties of its buffers will no longer be + * resolvable. + * + * After calling this method, be sure to call {@link GltfBuilder#destroy} + * when done with the glTF, to a void leaking buffer memory. + */ +GltfBuilder.prototype.toGltf = function () { + for (var i = 0; i < this.bufferBuilders.length; ++i) { + var bufferBuilder = this.bufferBuilders[i]; + + var byteLength = bufferBuilder.viewBuilders.reduce(function ( + byteLength, + viewBuilder + ) { + return byteLength + viewBuilder.bufferView.byteLength; + }, + 0); + + var buffer = new ArrayBuffer(byteLength); + var nextStart = 0; + + for (var j = 0; j < bufferBuilder.viewBuilders.length; ++j) { + var viewBuilder = bufferBuilder.viewBuilders[j]; + viewBuilder.bufferView.byteOffset = nextStart; + var destBuffer = + viewBuilder.componentType === 5126 + ? new Float32Array(buffer, nextStart, viewBuilder._data.length) + : new Uint16Array(buffer, nextStart, viewBuilder._data.length); + destBuffer.set(viewBuilder._data); + nextStart += viewBuilder.bufferView.byteLength; + } + + bufferBuilder.buffer.byteLength = byteLength; + + if (bufferBuilder.buffer.uri) { + URL.revokeObjectURL(bufferBuilder.buffer.uri); + } + + bufferBuilder.buffer.uri = URL.createObjectURL(new Blob([buffer])); + + bufferBuilder.buffer.extras = { + _pipeline: { + source: new Uint8Array(buffer, 0, buffer.byteLength), + }, + }; + } + + var gltf = this.gltf; + gltf.accessors.forEach(function (accessor) { + var minMax = findAccessorMinMax(gltf, accessor); + accessor.min = minMax.min; + accessor.max = minMax.max; + }); + + this.bufferBuilders.forEach(function (builder) { + delete builder.buffer.extras; + }); + + return this.gltf; +}; + +/** + * Frees memory allocated for buffers in {@link GltfBuilder@toGltf}. + */ +GltfBuilder.prototype.destroy = function () { + this.bufferBuilders.forEach(function (bufferBuilder) { + URL.revokeObjectURL(bufferBuilder.buffer.uri); + bufferBuilder.buffer.uri = undefined; + }); +}; + +/** + * A fluent interface for programmatically building a glTF material. + * @param {GltfBuilder} gltfBuilder The glTF builder. + * @param {number} materialIndex The index of this material within the glTF. + * @private + */ +function GltfMaterialBuilder(gltfBuilder, materialIndex) { + this.gltfBuilder = gltfBuilder; + this.materialIndex = materialIndex; + this.material = this.gltfBuilder.gltf.materials[materialIndex]; +} + +/** + * Defines the material using JSON. + * @param {*} json The JSON definition of the material. + * @returns {GltfMaterialBuilder} + */ +GltfMaterialBuilder.prototype.json = function (json) { + for (var property in json) { + if (!json.hasOwnProperty(property)) { + continue; + } + + this.material[property] = json[property]; + } + + return this; +}; + +/** + * A fluent interface for building a glTF mesh. + * @param {GltfBuilder} gltfBuilder The glTF builder. + * @param {number} meshIndex The index of this mesh within the glTF. + * @private + */ +function GltfMeshBuilder(gltfBuilder, meshIndex) { + this.gltfBuilder = gltfBuilder; + this.meshIndex = meshIndex; + this.mesh = gltfBuilder.gltf.meshes[this.meshIndex]; +} + +/** + * Creates a new primitive within this mesh. + * @param {string} [name] The name of the primitive. + * @returns {GltfPrimitiveBuilder} + */ +GltfMeshBuilder.prototype.primitive = function (name) { + var index = + this.mesh.primitives.push({ + name: name, + attributes: {}, + }) - 1; + + var meshBuilder = new GltfPrimitiveBuilder(this, index); + return meshBuilder; +}; + +/** + * A fluent interface for building a glTF primitive. + * @param {GltfMeshBuilder} gltfMeshBuilder The mesh builder. + * @param {number} primitiveIndex The index of this primitive within the mesh. + * @private + */ +function GltfPrimitiveBuilder(gltfMeshBuilder, primitiveIndex) { + this.gltfMeshBuilder = gltfMeshBuilder; + this.primitiveIndex = primitiveIndex; + this.primitive = this.gltfMeshBuilder.mesh.primitives[this.primitiveIndex]; +} + +/** + * Adds a new attribute to the primitive. + * @param {string} semantic The semantic of the attribute. + * @param {string} accessorName The name of the accessor referenced by this attribute. + * @returns {GltfPrimitiveBuilder} + */ +GltfPrimitiveBuilder.prototype.attribute = function (semantic, accessorName) { + var gltf = this.gltfMeshBuilder.gltfBuilder.gltf; + var accessorId = findAccessorByName(gltf, accessorName); + if (accessorId < 0) { + throw new RuntimeError("Accessor named " + accessorName + " not found."); + } + + this.primitive.attributes[semantic] = accessorId; + return this; +}; + +/** + * Sets the name of the accessor providing this primitive's indices. + * @param {string} accessorName The name of the accessor providing the indices. + * @returns {GltfPrimitiveBuilder} + */ +GltfPrimitiveBuilder.prototype.indices = function (accessorName) { + var gltf = this.gltfMeshBuilder.gltfBuilder.gltf; + var accessorId = findAccessorByName(gltf, accessorName); + if (accessorId < 0) { + throw new RuntimeError("Accessor named " + accessorName + " not found."); + } + + this.primitive.indices = accessorId; + return this; +}; + +/** + * Indicates that this primitive is TRIANGLES. + * @returns {GltfPrimitiveBuilder} + */ +GltfPrimitiveBuilder.prototype.triangles = function () { + this.primitive.mode = 4; + return this; +}; + +/** + * Sets the material applied to this primitive. + * @param {string} materialName The name of the material. + * @returns {GltfPrimitiveBuilder} + */ +GltfPrimitiveBuilder.prototype.material = function (materialName) { + var gltf = this.gltfMeshBuilder.gltfBuilder.gltf; + + for (var i = 0; i < gltf.materials.length; ++i) { + if (gltf.materials[i].name === materialName) { + this.primitive.material = i; + return this; + } + } + + throw new RuntimeError("Material named " + materialName + " not found."); +}; + +/** + * A fluent interface for building a glTF buffer. + * @param {GltfBuilder} gltfBuilder The glTF builder. + * @param {number} bufferIndex The index of this buffer in the glTF. + * @private + */ +function GltfBufferBuilder(gltfBuilder, bufferIndex) { + this.gltfBuilder = gltfBuilder; + this.bufferIndex = bufferIndex; + this.buffer = this.gltfBuilder.gltf.buffers[bufferIndex]; + this.viewBuilders = []; +} + +/** + * Creates a new vertex bufferView in this buffer. + * @param {string} name The name of the bufferView. + * @returns {GltfBufferViewBuilder} + */ +GltfBufferBuilder.prototype.vertexBuffer = function (name) { + var index = + this.gltfBuilder.gltf.bufferViews.push({ + name: name, + buffer: this.bufferIndex, + byteLength: 0, + target: 34962, + }) - 1; + var viewBuilder = new GltfBufferViewBuilder(this, index, 5126); + this.viewBuilders.push(viewBuilder); + return viewBuilder; +}; + +/** + * Creates a new index bufferView in this buffer. + * @param {string} name The name of the bufferView. + * @returns {GltfBufferViewBuilder} + */ +GltfBufferBuilder.prototype.indexBuffer = function (name) { + var index = + this.gltfBuilder.gltf.bufferViews.push({ + name: name, + buffer: this.bufferIndex, + byteLength: 0, + target: 34963, + }) - 1; + var viewBuilder = new GltfBufferViewBuilder(this, index, 5123); + this.viewBuilders.push(viewBuilder); + return viewBuilder; +}; + +/** + * A fluent interface for building a glTF bufferView. + * @param {GltfBufferBuilder} bufferBuilder The buffer builder. + * @param {GltfBufferViewBuilder} bufferViewIndex The bufferView builder. + * @param {number} componentType The glTF `componentType` of this bufferView. + * @private + */ +function GltfBufferViewBuilder(bufferBuilder, bufferViewIndex, componentType) { + this.bufferBuilder = bufferBuilder; + this.bufferViewIndex = bufferViewIndex; + this.bufferView = this.bufferBuilder.gltfBuilder.gltf.bufferViews[ + this.bufferViewIndex + ]; + this.componentType = componentType; + this.elementStride = 0; + this.nextOffset = 0; + this._data = undefined; +} + +Object.defineProperties(GltfBufferViewBuilder.prototype, { + /** + * Gets the number of bytes in each numeric element of this bufferView. + */ + elementByteLength: { + get: function () { + return this.componentType === 5126 ? 4 : 2; + }, + }, +}); + +/** + * Defines a `VEC3` element in this bufferView. + * @param {string} name The name of the accessor for this element. + * @returns {GltfBufferViewBuilder} + */ +GltfBufferViewBuilder.prototype.vec3 = function (name) { + var gltf = this.bufferBuilder.gltfBuilder.gltf; + gltf.accessors.push({ + name: name, + bufferView: this.bufferViewIndex, + byteOffset: this.nextOffset, + componentType: this.componentType, + count: 0, + type: "VEC3", + }); + + this.elementStride += 3; + + var newStride = 3 * this.elementByteLength; + if (!defined(this.bufferView.byteStride)) { + this.bufferView.byteStride = newStride; + } else { + this.bufferView.byteStride += newStride; + } + + this.nextOffset += newStride; + + return this; +}; + +/** + * Defines a `SCALAR` element in this bufferView. + * @param {string} name The name of the accessor for this element. + * @returns {GltfBufferViewBuilder} + */ +GltfBufferViewBuilder.prototype.scalar = function (name) { + var gltf = this.bufferBuilder.gltfBuilder.gltf; + gltf.accessors.push({ + name: name, + bufferView: this.bufferViewIndex, + byteOffset: this.nextOffset, + componentType: this.componentType, + count: 0, + type: "SCALAR", + }); + + this.elementStride += 1; + + var newStride = this.elementByteLength; + if (!defined(this.bufferView.byteStride)) { + this.bufferView.byteStride = newStride; + } else { + this.bufferView.byteStride += newStride; + } + + this.nextOffset += newStride; + + return this; +}; + +/** + * Provides this bufferView's data. All elements should be defined with + * {@link GltfBufferViewBuilder#vec3} and {@link GltfBufferViewBuilder#scalar} + * before calling this method. + * @param {Array|Float32Array|Uint16Array|Uint32Array} data The data. + * @returns {GltfBufferViewBuilder} + */ +GltfBufferViewBuilder.prototype.data = function (data) { + this.bufferView.byteLength = data.length * this.elementByteLength; + this._data = data; + + var count = data.length / this.elementStride; + + var gltf = this.bufferBuilder.gltfBuilder.gltf; + var bufferViewIndex = this.bufferViewIndex; + + gltf.accessors + .filter(function (accessor) { + return accessor.bufferView === bufferViewIndex; + }) + .forEach(function (accessor) { + accessor.count = count; + }); + + return this; +}; + +function findAccessorByName(gltf, accessorName) { + var accessors = gltf.accessors; + + for (var i = 0; i < accessors.length; ++i) { + if (accessors[i].name === accessorName) { + return i; + } + } + + return -1; +} + +export default GltfBuilder; diff --git a/Specs/Scene/ModelOutlineLoaderSpec.js b/Specs/Scene/ModelOutlineLoaderSpec.js new file mode 100644 index 000000000000..4ca993cd6c29 --- /dev/null +++ b/Specs/Scene/ModelOutlineLoaderSpec.js @@ -0,0 +1,767 @@ +import { Cartesian3, Model, when } from "../../Source/Cesium.js"; +import createScene from "../createScene.js"; +import pollToPromise from "../pollToPromise.js"; +import GltfBuilder from "./GltfBuilder.js"; + +describe( + "Scene/ModelOutlineLoader", + function () { + var scene; + var primitives; + + beforeAll(function () { + scene = createScene(); + primitives = scene.primitives; + }); + + afterAll(function () { + scene.destroyForSpecs(); + }); + + it("does nothing if no primitives are outlined", function () { + var vertices = []; + var indices = []; + var edges = []; + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + + var meshBuilder = builder.mesh(); + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var gltf = builder.toGltf(); + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + expect(gltf.buffers.length).toBe(1); + expect(gltf.accessors.length).toBe(4); + expect(gltf.accessors[0].count).toBe(9); + expect(gltf.accessors[1].count).toBe(9); + expect(gltf.accessors[2].count).toBe(9); + expect( + Object.keys(gltf.meshes[0].primitives[0].attributes).length + ).toBe(3); + expect(gltf.accessors[3].count).toBe(indices.length); + builder.destroy(); + }); + }); + + it("duplicates vertices as needed and adds outline attribute", function () { + var vertices = []; + var indices = []; + var edges = []; + createTrickyModel(vertices, indices, edges, 2, true, true, true); + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + bufferBuilder.indexBuffer("edgeIndices").scalar("edgeIndex").data(edges); + + var meshBuilder = builder.mesh(); + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var gltf = builder.toGltf(); + + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + gltf.meshes[0].primitives[0].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + var primitive = gltf.meshes[0].primitives[0]; + expect(gltf.accessors.length).toBe(6); + expect(gltf.accessors[0].count).toBeGreaterThan(9); + expect(gltf.accessors[1].count).toBeGreaterThan(9); + expect(gltf.accessors[2].count).toBeGreaterThan(9); + expect(gltf.accessors[0].count).toBe(gltf.accessors[1].count); + expect(gltf.accessors[1].count).toBe(gltf.accessors[2].count); + expect(Object.keys(primitive.attributes).length).toBe(4); + expect(primitive.attributes._OUTLINE_COORDINATES).toBeDefined(); + expect(gltf.accessors[3].count).toBe(indices.length); + + // Make sure the outline coordinates match the edges. + var accessorId = primitive.attributes._OUTLINE_COORDINATES; + var accessor = gltf.accessors[accessorId]; + var bufferView = gltf.bufferViews[accessor.bufferView]; + var buffer = gltf.buffers[bufferView.buffer]; + + var outlineCoordinates = new Float32Array( + buffer.extras._pipeline.source, + bufferView.byteOffset, + accessor.count * 3 + ); + + var triangleAccessor = gltf.accessors.filter(function (accessor) { + return accessor.name === "index"; + })[0]; + var triangleBufferView = gltf.bufferViews[triangleAccessor.bufferView]; + var triangleBuffer = gltf.buffers[triangleBufferView.buffer]; + + var triangleIndices = new Uint16Array( + triangleBuffer.extras._pipeline.source, + triangleBufferView.byteOffset, + triangleAccessor.count + ); + + for (var i = 0; i < triangleIndices.length; i += 3) { + var i0 = triangleIndices[i]; + var i1 = triangleIndices[i + 1]; + var i2 = triangleIndices[i + 2]; + + var i0Original = indices[i]; + var i1Original = indices[i + 1]; + var i2Original = indices[i + 2]; + + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 1.0, 1.0, 0.0) + ).toBe(hasEdge(edges, i0Original, i1Original)); + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 0.0, 1.0, 1.0) + ).toBe(hasEdge(edges, i1Original, i2Original)); + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 1.0, 0.0, 1.0) + ).toBe(hasEdge(edges, i2Original, i0Original)); + + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 1.0, 0.0, 0.0) + ).toBe(false); + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 0.0, 1.0, 0.0) + ).toBe(false); + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 0.0, 0.0, 1.0) + ).toBe(false); + expect( + hasCoordinates(outlineCoordinates, i0, i1, i2, 1.0, 1.0, 1.0) + ).toBe(false); + } + builder.destroy(); + }); + }); + + function hasEdge(edges, i0, i1) { + var min = Math.min(i0, i1); + var max = Math.max(i0, i1); + + for (var i = 0; i < edges.length; i += 2) { + var low = Math.min(edges[i], edges[i + 1]); + var high = Math.max(edges[i], edges[i + 1]); + + if (low === min && high === max) { + return true; + } + } + + return false; + } + + function hasCoordinates(outlineCoordinates, i0, i1, i2, o0, o1, o2) { + var a0 = outlineCoordinates[i0 * 3]; + var b0 = outlineCoordinates[i0 * 3 + 1]; + var c0 = outlineCoordinates[i0 * 3 + 2]; + var a1 = outlineCoordinates[i1 * 3]; + var b1 = outlineCoordinates[i1 * 3 + 1]; + var c1 = outlineCoordinates[i1 * 3 + 2]; + var a2 = outlineCoordinates[i2 * 3]; + var b2 = outlineCoordinates[i2 * 3 + 1]; + var c2 = outlineCoordinates[i2 * 3 + 2]; + + return ( + (a0 === o0 && a1 === o1 && a2 === o2) || + (b0 === o0 && b1 === o1 && b2 === o2) || + (c0 === o0 && c1 === o1 && c2 === o2) + ); + } + + it("ignores extension on primitive if it's not in extensionsUsed", function () { + var vertices = []; + var indices = []; + var edges = []; + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + bufferBuilder.indexBuffer("edgeIndices").scalar("edgeIndex").data(edges); + + var meshBuilder = builder.mesh(); + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var gltf = builder.toGltf(); + + gltf.meshes[0].primitives[0].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + expect(gltf.buffers.length).toBe(1); + expect(gltf.accessors.length).toBe(5); + expect(gltf.accessors[0].count).toBe(9); + expect(gltf.accessors[1].count).toBe(9); + expect(gltf.accessors[2].count).toBe(9); + expect( + Object.keys(gltf.meshes[0].primitives[0].attributes).length + ).toBe(3); + expect(gltf.accessors[3].count).toBe(indices.length); + builder.destroy(); + }); + }); + + it("doesn't break if extensionsUsed lists the extension but it's not really used", function () { + var vertices = []; + var indices = []; + var edges = []; + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + + var meshBuilder = builder.mesh(); + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var gltf = builder.toGltf(); + + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + expect(gltf.buffers.length).toBe(1); + expect(gltf.accessors.length).toBe(4); + expect(gltf.accessors[0].count).toBe(9); + expect(gltf.accessors[1].count).toBe(9); + expect(gltf.accessors[2].count).toBe(9); + expect( + Object.keys(gltf.meshes[0].primitives[0].attributes).length + ).toBe(3); + expect(gltf.accessors[3].count).toBe(indices.length); + builder.destroy(); + }); + }); + + it("handles vertices that are shared between two outlined primitives", function () { + var vertices = []; + var indices = []; + var edges = []; + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + bufferBuilder.indexBuffer("edgeIndices").scalar("edgeIndex").data(edges); + + var meshBuilder = builder.mesh(); + + // Create two primitives, both using the same vertex buffer, but the + // second one only uses the positions. + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var secondPrimitiveBuilder = meshBuilder.primitive(); + secondPrimitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .indices("index"); + + var gltf = builder.toGltf(); + + // Add the outline extension to both primitives. + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + gltf.meshes[0].primitives[0].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + gltf.meshes[0].primitives[1].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + expect(gltf.accessors.length).toBe(6); + expect(gltf.accessors[0].count).toBeGreaterThan(9); + expect(gltf.accessors[1].count).toBeGreaterThan(9); + expect(gltf.accessors[2].count).toBeGreaterThan(9); + expect(gltf.accessors[0].count).toBe(gltf.accessors[1].count); + expect(gltf.accessors[1].count).toBe(gltf.accessors[2].count); + + var firstPrimitive = gltf.meshes[0].primitives[0]; + expect(Object.keys(firstPrimitive.attributes).length).toBe(4); + expect(firstPrimitive.attributes._OUTLINE_COORDINATES).toBeDefined(); + + var secondPrimitive = gltf.meshes[0].primitives[1]; + expect(Object.keys(secondPrimitive.attributes).length).toBe(2); + expect(secondPrimitive.attributes._OUTLINE_COORDINATES).toBeDefined(); + + expect(gltf.accessors[3].count).toBe(indices.length); + builder.destroy(); + }); + }); + + it("handles vertices that are shared between an outlined and a non-outlined primitive", function () { + var vertices = []; + var indices = []; + var edges = []; + createModel(vertices, indices, edges, 1, true, true, true); + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + bufferBuilder.indexBuffer("edgeIndices").scalar("edgeIndex").data(edges); + + var meshBuilder = builder.mesh(); + + // Create two primitives, both using the same vertex buffer, but the + // second one only uses the positions. + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var secondPrimitiveBuilder = meshBuilder.primitive(); + secondPrimitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .indices("index"); + + var gltf = builder.toGltf(); + + // Add the outline extension only to the first primitive. + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + gltf.meshes[0].primitives[0].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + expect(gltf.accessors.length).toBe(6); + expect(gltf.accessors[0].count).toBeGreaterThan(9); + expect(gltf.accessors[1].count).toBeGreaterThan(9); + expect(gltf.accessors[2].count).toBeGreaterThan(9); + expect(gltf.accessors[0].count).toBe(gltf.accessors[1].count); + expect(gltf.accessors[1].count).toBe(gltf.accessors[2].count); + + var firstPrimitive = gltf.meshes[0].primitives[0]; + expect(Object.keys(firstPrimitive.attributes).length).toBe(4); + expect(firstPrimitive.attributes._OUTLINE_COORDINATES).toBeDefined(); + + var secondPrimitive = gltf.meshes[0].primitives[1]; + expect(Object.keys(secondPrimitive.attributes).length).toBe(1); + expect(secondPrimitive.attributes._OUTLINE_COORDINATES).toBeUndefined(); + + expect(gltf.accessors[3].count).toBe(indices.length); + + builder.destroy(); + }); + }); + + it("switches to 32-bit indices if more than 65536 vertices are required", function () { + var vertices = []; + var indices = []; + var edges = []; + + // Tricky model is 9 vertices. Add copies of it until we're just under 65636 vertices. + for (var i = 0; vertices.length / 7 + 9 <= 65536; ++i) { + createTrickyModel(vertices, indices, edges, 2, true, true, true); + } + + var builder = createGltfBuilder(); + + var bufferBuilder = builder.buffer(); + + bufferBuilder + .vertexBuffer("vertices") + .vec3("position") + .vec3("normal") + .scalar("batchID") + .data(vertices); + + bufferBuilder.indexBuffer("indices").scalar("index").data(indices); + bufferBuilder.indexBuffer("edgeIndices").scalar("edgeIndex").data(edges); + + var meshBuilder = builder.mesh(); + var primitiveBuilder = meshBuilder.primitive(); + primitiveBuilder + .triangles() + .material("default") + .attribute("POSITION", "position") + .attribute("NORMAL", "normal") + .attribute("_BATCHID", "batchID") + .indices("index"); + + var gltf = builder.toGltf(); + + gltf.extensionsUsed.push("CESIUM_primitive_outline"); + gltf.meshes[0].primitives[0].extensions = { + CESIUM_primitive_outline: { + indices: gltf.accessors.length - 1, + }, + }; + + var model = new Model({ + gltf: gltf, + }); + + primitives.add(model); + + return waitForReady(scene, model).then(function () { + var gltf = model.gltf; + var primitive = gltf.meshes[0].primitives[0]; + var triangleIndexAccessor = gltf.accessors[primitive.indices]; + + // The accessor should now be 32-bit and reference higher-numbered vertices. + expect(triangleIndexAccessor.componentType).toBe(5125); // UNSIGNED_INT + expect(triangleIndexAccessor.max[0]).toBeGreaterThan(65536); + expect(triangleIndexAccessor.byteOffset).toBe(0); + + var bufferView = gltf.bufferViews[triangleIndexAccessor.bufferView]; + var buffer = gltf.buffers[bufferView.buffer]; + var data = buffer.extras._pipeline.source; + var indexBuffer = new Uint32Array( + data, + data.byteOffset + bufferView.byteOffset, + triangleIndexAccessor.count + ); + + // All the original indices should be the same. + for (var i = 0; i < indices.length; ++i) { + // All indices in the original range should match the original ones + if (indexBuffer[i] < vertices.length / 7) { + expect(indexBuffer[i]).toBe(indices[i]); + } + } + + builder.destroy(); + }); + }); + }, + "WebGL" +); + +function createGltfBuilder() { + var builder = new GltfBuilder(); + + builder.material("default").json({ + pbrMetallicRoughness: { + baseColorFactor: [1.0, 1.0, 1.0, 1.0], + metallicFactor: 0.5, + roughnessFactor: 0.125, + }, + }); + + return builder; +} + +function createModel( + vertices, + indices, + edges, + batchID, + includePositions, + includeNormals, + includeBatchID +) { + var vertexStride = + (includePositions ? 3 : 0) + + (includeNormals ? 3 : 0) + + (includeBatchID ? 1 : 0); + + var normal = new Cartesian3(0.0, 0.0, 1.0); + + function addVertex(position) { + if (includePositions) { + vertices.push(position.x, position.y, position.z); + } + if (includeNormals) { + vertices.push(normal.x, normal.y, normal.z); + } + if (includeBatchID) { + vertices.push(batchID); + } + + return vertices.length / vertexStride - 1; + } + + // 6---7---8 + // | \ | / | + // 3---4---5 + // | / | \ | + // 0---1---2 + + var p0 = new Cartesian3(-1.0, -1.0, 0.0); + var p1 = new Cartesian3(0.0, -1.0, 0.0); + var p2 = new Cartesian3(1.0, -1.0, 0.0); + var p3 = new Cartesian3(-1.0, 0.0, 0.0); + var p4 = new Cartesian3(0.0, 0.0, 0.0); + var p5 = new Cartesian3(1.0, 0.0, 0.0); + var p6 = new Cartesian3(-1.0, 1.0, 0.0); + var p7 = new Cartesian3(0.0, 1.0, 0.0); + var p8 = new Cartesian3(1.0, 1.0, 0.0); + + var i0 = addVertex(p0); + var i1 = addVertex(p1); + var i2 = addVertex(p2); + var i3 = addVertex(p3); + var i4 = addVertex(p4); + var i5 = addVertex(p5); + var i6 = addVertex(p6); + var i7 = addVertex(p7); + var i8 = addVertex(p8); + + indices.push(i0, i1, i4); + indices.push(i0, i4, i3); + indices.push(i1, i2, i4); + indices.push(i4, i2, i5); + indices.push(i3, i4, i6); + indices.push(i6, i4, i7); + indices.push(i4, i5, i8); + indices.push(i4, i8, i7); + + edges.push(i0, i1); + edges.push(i1, i2); + edges.push(i2, i5); + edges.push(i5, i8); + edges.push(i8, i7); + edges.push(i7, i6); + edges.push(i6, i3); +} + +function createTrickyModel( + vertices, + indices, + edges, + batchID, + includePositions, + includeNormals, + includeBatchID +) { + // This model is carefully constructed to require tricky vertex duplication + // for outlining. + + var vertexStride = + (includePositions ? 3 : 0) + + (includeNormals ? 3 : 0) + + (includeBatchID ? 1 : 0); + + var normal = new Cartesian3(0.0, 0.0, 1.0); + + function addVertex(position) { + if (includePositions) { + vertices.push(position.x, position.y, position.z); + } + if (includeNormals) { + vertices.push(normal.x, normal.y, normal.z); + } + if (includeBatchID) { + vertices.push(batchID); + } + + return vertices.length / vertexStride - 1; + } + + // 1-2 5-4 + // \| |/ + // 0-----3 + // \ / + // \ / + // 6 + // / \ + // / \ + // 7-----8 + + var p0 = new Cartesian3(-1.0, 1.0, 0.0); + var p1 = new Cartesian3(-2.0, 2.0, 0.0); + var p2 = new Cartesian3(-1.0, 2.0, 0.0); + var p3 = new Cartesian3(1.0, 1.0, 0.0); + var p4 = new Cartesian3(2.0, 2.0, 0.0); + var p5 = new Cartesian3(1.0, 2.0, 0.0); + var p6 = new Cartesian3(0.0, 0.0, 0.0); + var p7 = new Cartesian3(-1.0, -1.0, 0.0); + var p8 = new Cartesian3(-1.0, 1.0, 0.0); + + var i0 = addVertex(p0); + var i1 = addVertex(p1); + var i2 = addVertex(p2); + var i3 = addVertex(p3); + var i4 = addVertex(p4); + var i5 = addVertex(p5); + var i6 = addVertex(p6); + var i7 = addVertex(p7); + var i8 = addVertex(p8); + + indices.push(i0, i2, i1); + indices.push(i3, i4, i5); + indices.push(i6, i7, i8); + indices.push(i0, i6, i3); + + edges.push(i0, i1); + edges.push(i0, i2); + edges.push(i3, i5); + edges.push(i3, i4); + edges.push(i6, i7); + edges.push(i6, i8); + edges.push(i0, i3); + edges.push(i0, i6); + edges.push(i3, i6); +} + +function waitForReady(scene, model) { + return pollToPromise( + function () { + // Render scene to progressively load the model + scene.renderForSpecs(); + return model.ready; + }, + { timeout: 10000 } + ) + .then(function () { + return model; + }) + .otherwise(function () { + return when.reject(model); + }); +}