diff --git a/CHANGES.md b/CHANGES.md index 57660a7b..70f6ef4b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,11 @@ Change Log ========== +### HEAD + +* Added removal of unused materials, nodes and meshes. [#465](https://github.com/AnalyticalGraphicsInc/gltf-pipeline/pull/465) +* Added `keepUnusedElements` flag to keep unused materials, nodes and meshes. [#465](https://github.com/AnalyticalGraphicsInc/gltf-pipeline/pull/465) + ### 2.1.3 - 2019-03-21 * Fixed a crash when saving separate resources that would exceed the Node buffer size limit. [#468](https://github.com/AnalyticalGraphicsInc/gltf-pipeline/pull/468) diff --git a/README.md b/README.md index 3ea9c246..122731a6 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ processGltf(gltf, options) |`--separate`, `-s`|Write separate buffers, shaders, and textures instead of embedding them in the glTF.|No, default `false`| |`--separateTextures`, `-t`|Write out separate textures only.|No, default `false`| |`--stats`|Print statistics to console for output glTF file.|No, default `false`| +|`--keepUnusedElements`|Keep unused materials, nodes and meshes.|No, default `false`| |`--draco.compressMeshes`, `-d`|Compress the meshes using Draco. Adds the KHR_draco_mesh_compression extension.|No, default `false`| |`--draco.compressionLevel`|Draco compression level [0-10], most is 10, least is 0. A value of 0 will apply sequential encoding and preserve face order.|No, default `7`| |`--draco.quantizePositionBits`|Quantization bits for position attribute when using Draco compression.|No, default `14`| diff --git a/bin/gltf-pipeline.js b/bin/gltf-pipeline.js index 93d6efdf..f4b5ed51 100644 --- a/bin/gltf-pipeline.js +++ b/bin/gltf-pipeline.js @@ -69,6 +69,11 @@ const argv = yargs type: 'boolean', default: defaults.stats }, + keepUnusedElements: { + describe: 'Keep unused materials, nodes and meshes.', + type: 'boolean', + default: defaults.keepUnusedElements + }, 'draco.compressMeshes': { alias: 'd', describe: 'Compress the meshes using Draco. Adds the KHR_draco_mesh_compression extension.', diff --git a/lib/ForEach.js b/lib/ForEach.js index 93a64482..c2953b3a 100644 --- a/lib/ForEach.js +++ b/lib/ForEach.js @@ -348,6 +348,21 @@ ForEach.skin = function(gltf, handler) { return ForEach.topLevel(gltf, 'skins', handler); }; +ForEach.skinJoint = function(skin, handler) { + const joints = skin.joints; + if (defined(joints)) { + const jointsLength = joints.length; + for (let i = 0; i < jointsLength; i++) { + const joint = joints[i]; + const value = handler(joint); + + if (defined(value)) { + return value; + } + } + } +}; + ForEach.techniqueAttribute = function(technique, handler) { const attributes = technique.attributes; for (const attributeName in attributes) { diff --git a/lib/compressDracoMeshes.js b/lib/compressDracoMeshes.js index 61b398a5..7c5faa46 100644 --- a/lib/compressDracoMeshes.js +++ b/lib/compressDracoMeshes.js @@ -226,7 +226,7 @@ function compressDracoMeshes(gltf, options) { } else { addExtensionsRequired(gltf, 'KHR_draco_mesh_compression'); } - removeUnusedElements(gltf); + removeUnusedElements(gltf, ['accessor', 'bufferView', 'buffer']); if (uncompressedFallback) { assignMergedBufferNames(gltf); diff --git a/lib/processGltf.js b/lib/processGltf.js index 08f057ba..bed45c07 100644 --- a/lib/processGltf.js +++ b/lib/processGltf.js @@ -7,6 +7,7 @@ const getStatistics = require('./getStatistics'); const readResources = require('./readResources'); const removeDefaults = require('./removeDefaults'); const removePipelineExtras = require('./removePipelineExtras'); +const removeUnusedElements = require('./removeUnusedElements'); const updateVersion = require('./updateVersion'); const writeResources = require('./writeResources'); const compressDracoMeshes = require('./compressDracoMeshes'); @@ -82,6 +83,11 @@ function getStages(options) { if (defined(options.dracoOptions)) { stages.push(compressDracoMeshes); } + if (!options.keepUnusedElements) { + stages.push(function(gltf, options) { + removeUnusedElements(gltf); + }); + } return stages; } @@ -113,6 +119,12 @@ processGltf.defaults = { * @default false */ stats: false, + /** + * Keep unused 'node', 'mesh' and 'material' elements. + * @type Boolean + * @default false + */ + keepUnusedElements: false, /** * Gets or sets whether to compress the meshes using Draco. Adds the KHR_draco_mesh_compression extension. * @type Boolean diff --git a/lib/removeUnusedElements.js b/lib/removeUnusedElements.js index cc6a4e66..b3c991ce 100644 --- a/lib/removeUnusedElements.js +++ b/lib/removeUnusedElements.js @@ -3,29 +3,38 @@ const Cesium = require('cesium'); const ForEach = require('./ForEach'); const hasExtension = require('./hasExtension'); +const defaultValue = Cesium.defaultValue; const defined = Cesium.defined; module.exports = removeUnusedElements; +const allElementTypes = ['mesh', 'node', 'material', 'accessor', 'bufferView', 'buffer']; + /** * Removes unused elements from gltf. - * This function currently only works for accessors, buffers, and bufferViews. * * @param {Object} gltf A javascript object containing a glTF asset. + * @param {String[]} [elementTypes=['mesh', 'node', 'material', 'accessor', 'bufferView', 'buffer']] Element types to be removed. Needs to be a subset of ['mesh', 'node', 'material', 'accessor', 'bufferView', 'buffer'], other items will be ignored. * * @private */ -function removeUnusedElements(gltf) { - removeUnusedElementsByType(gltf, 'accessor'); - removeUnusedElementsByType(gltf, 'bufferView'); - removeUnusedElementsByType(gltf, 'buffer'); +function removeUnusedElements(gltf, elementTypes) { + elementTypes = defaultValue(elementTypes, allElementTypes); + allElementTypes.forEach(function(type) { + if (elementTypes.indexOf(type) > -1) { + removeUnusedElementsByType(gltf, type); + } + }); return gltf; } const TypeToGltfElementName = { accessor: 'accessors', buffer: 'buffers', - bufferView: 'bufferViews' + bufferView: 'bufferViews', + node: 'nodes', + material: 'materials', + mesh: 'meshes' }; function removeUnusedElementsByType(gltf, type) { @@ -145,8 +154,8 @@ Remove.bufferView = function(gltf, bufferViewId) { }); if (hasExtension(gltf, 'KHR_draco_mesh_compression')) { - ForEach.mesh(gltf, function (mesh) { - ForEach.meshPrimitive(mesh, function (primitive) { + ForEach.mesh(gltf, function(mesh) { + ForEach.meshPrimitive(mesh, function(primitive) { if (defined(primitive.extensions) && defined(primitive.extensions.KHR_draco_mesh_compression)) { if (primitive.extensions.KHR_draco_mesh_compression.bufferView > bufferViewId) { @@ -158,6 +167,88 @@ Remove.bufferView = function(gltf, bufferViewId) { } }; +Remove.mesh = function(gltf, meshId) { + const meshes = gltf.meshes; + meshes.splice(meshId, 1); + + ForEach.node(gltf, function(node) { + if (defined(node.mesh)) { + if (node.mesh > meshId) { + node.mesh--; + } else if (node.mesh === meshId) { + // Remove reference to deleted mesh + delete node.mesh; + } + } + }); +}; + +Remove.node = function(gltf, nodeId) { + const nodes = gltf.nodes; + nodes.splice(nodeId, 1); + + // Shift all node references + ForEach.skin(gltf, function(skin) { + if (defined(skin.skeleton) && skin.skeleton > nodeId) { + skin.skeleton--; + } + + skin.joints = skin.joints.map(function(x) { + return x > nodeId ? x - 1 : x; + }); + }); + ForEach.animation(gltf, function(animation) { + ForEach.animationChannel(animation, function(channel) { + if (defined(channel.target) && defined(channel.target.node) && (channel.target.node > nodeId)) { + channel.target.node--; + } + }); + }); + ForEach.technique(gltf, function(technique) { + ForEach.techniqueUniform(technique, function(uniform) { + if (defined(uniform.node) && uniform.node > nodeId) { + uniform.node--; + } + }); + }); + ForEach.node(gltf, function(node) { + if (!defined(node.children)) { + return; + } + + node.children = node.children + .filter(function(x) { + return x !== nodeId; // Remove + }) + .map(function(x) { + return x > nodeId ? x - 1 : x; // Shift indices + }); + }); + ForEach.scene(gltf, function(scene) { + scene.nodes = scene.nodes + .filter(function(x) { + return x !== nodeId; // Remove + }) + .map(function(x) { + return x > nodeId ? x - 1 : x; // Shift indices + }); + }); +}; + +Remove.material = function(gltf, materialId) { + const materials = gltf.materials; + materials.splice(materialId, 1); + + // Shift other material ids + ForEach.mesh(gltf, function(mesh) { + ForEach.meshPrimitive(mesh, function(primitive) { + if (defined(primitive.material) && primitive.material > materialId) { + primitive.material--; + } + }); + }); +}; + /** * Contains functions for getting a list of element ids in use by the glTF asset. * @constructor @@ -260,3 +351,81 @@ getListOfElementsIdsInUse.bufferView = function(gltf) { return usedBufferViewIds; }; + +getListOfElementsIdsInUse.mesh = function(gltf) { + const usedMeshIds = {}; + ForEach.node(gltf, function(node) { + if (defined(node.mesh && defined(gltf.meshes))) { + const mesh = gltf.meshes[node.mesh]; + if (defined(mesh) && defined(mesh.primitives) && (mesh.primitives.length > 0)) { + usedMeshIds[node.mesh] = true; + } + } + }); + + return usedMeshIds; +}; + +// Check if node is empty. It is considered empty if neither referencing +// mesh, camera, extensions and has no children +function nodeIsEmpty(gltf, node) { + if (defined(node.mesh) || defined(node.camera) || defined(node.skin) + || defined(node.weights) || defined(node.extras) + || (defined(node.extensions) && node.extensions.length !== 0)) { + return false; + } + + // Empty if no children or children are all empty nodes + return !defined(node.children) + || node.children.filter(function(n) { + return !nodeIsEmpty(gltf, gltf.nodes[n]); + }).length === 0; +} + +getListOfElementsIdsInUse.node = function(gltf) { + const usedNodeIds = {}; + ForEach.node(gltf, function(node, nodeId) { + if (!nodeIsEmpty(gltf, node)) { + usedNodeIds[nodeId] = true; + } + }); + ForEach.skin(gltf, function(skin) { + if (defined(skin.skeleton)) { + usedNodeIds[skin.skeleton] = true; + } + + ForEach.skinJoint(skin, function(joint) { + usedNodeIds[joint] = true; + }); + }); + ForEach.animation(gltf, function(animation) { + ForEach.animationChannel(animation, function(channel) { + if (defined(channel.target) && defined(channel.target.node)) { + usedNodeIds[channel.target.node] = true; + } + }); + }); + ForEach.technique(gltf, function(technique) { + ForEach.techniqueUniform(technique, function(uniform) { + if (defined(uniform.node)) { + usedNodeIds[uniform.node] = true; + } + }); + }); + + return usedNodeIds; +}; + +getListOfElementsIdsInUse.material = function(gltf) { + const usedMaterialIds = {}; + + ForEach.mesh(gltf, function(mesh) { + ForEach.meshPrimitive(mesh, function(primitive) { + if (defined(primitive.material)) { + usedMaterialIds[primitive.material] = true; + } + }); + }); + + return usedMaterialIds; +}; diff --git a/lib/splitPrimitives.js b/lib/splitPrimitives.js index b68ed29b..676c38da 100644 --- a/lib/splitPrimitives.js +++ b/lib/splitPrimitives.js @@ -90,7 +90,7 @@ function splitPrimitives(gltf) { } } } - removeUnusedElements(gltf); + removeUnusedElements(gltf, ['accessor', 'bufferView', 'buffer']); } return gltf; diff --git a/lib/updateVersion.js b/lib/updateVersion.js index e2372308..37089570 100644 --- a/lib/updateVersion.js +++ b/lib/updateVersion.js @@ -814,7 +814,7 @@ function moveByteStrideToBufferView(gltf) { } // Remove unused buffer views - removeUnusedElements(gltf); + removeUnusedElements(gltf, ['accessor', 'bufferView', 'buffer']); } function requirePositionAccessorMinMax(gltf) { diff --git a/lib/writeResources.js b/lib/writeResources.js index edbc5887..f7ea215a 100644 --- a/lib/writeResources.js +++ b/lib/writeResources.js @@ -55,7 +55,7 @@ function writeResources(gltf, options) { }); // Buffers need to be written last because images and shaders may write to new buffers - removeUnusedElements(gltf); + removeUnusedElements(gltf, ['acessor', 'bufferView', 'buffer']); mergeBuffers(gltf, options.name); ForEach.buffer(gltf, function(buffer, bufferId) { diff --git a/specs/lib/removeUnusedElementsSpec.js b/specs/lib/removeUnusedElementsSpec.js index 0b99fcd8..4111e4eb 100644 --- a/specs/lib/removeUnusedElementsSpec.js +++ b/specs/lib/removeUnusedElementsSpec.js @@ -8,9 +8,38 @@ const WebGLConstants = Cesium.WebGLConstants; const gltf = { nodes: [ { + name: 'skin', skin: 0, mesh: 0, translation: [0.0, 0.0, 0.0] + }, + { + name: 'used', + mesh: 0, + children: [2, 5] + }, + { + name: 'unused' + }, + { + name: 'nodeWithEmptyMesh', + mesh: 1 + }, + { + name: 'unusedParent', + children: [2] + }, + { + name: 'camera', + camera: 0 + }, + { + name: 'light', + extensions : { + KHR_lights_punctual : { + light : 0 + } + } } ], buffers: [ @@ -207,6 +236,7 @@ const gltf = { ], meshes: [ { + name: 'mesh0', primitives: [ { attributes: { @@ -225,9 +255,20 @@ const gltf = { } ], indices: 7, - mode: WebGLConstants.TRIANGLES + mode: WebGLConstants.TRIANGLES, + material: 1 } ] + }, + { + name: 'mesh1', + primitives: [] + } + ], + cameras: [ + { + name: 'cam', + type: 'perspective' } ], skins: [ @@ -268,39 +309,98 @@ const gltf = { bufferView: 11, mimeType: 'image/png' } + ], + extensions: { + KHR_lights_punctual : { + lights: [ + { + name: 'sun', + type: 'directional' + } + ] + } + }, + materials: [ + { + name: 'unused' + }, + { + name: 'used' + } + ], + scenes: [ + { + nodes: [2, 3] + } ] }; describe('removeUnusedElements', () => { - it('removes unused accessors, bufferViews, and buffers', () => { - delete gltf.animations; - delete gltf.skins; - gltf.meshes[0].primitives[0].targets.splice(0, 1); - gltf.images.splice(1, 2); - removeUnusedElements(gltf); + delete gltf.animations; + delete gltf.skins; + gltf.meshes[0].primitives[0].targets.splice(0, 1); + gltf.images.splice(1, 2); + removeUnusedElements(gltf); - const remainingAccessorNames = ['positions', 'normals', 'texcoords', 'positions-target1', 'normals-target1', 'indices']; - const remainingAcessorBufferViewIds = [0, 1, 2, 3, 4, 5]; - const remainingBufferViewNames = ['positions', 'normals', 'texcoords', 'positions-target1', 'normals-target1', 'indices', 'image0']; - const remainingBufferViewBufferIds = [0, 0, 0, 0, 0, 0, 1]; - const remainingBufferNames = ['mesh', 'image01']; + const remainingAccessorNames = ['positions', 'normals', 'texcoords', 'positions-target1', 'normals-target1', 'indices']; + const remainingAcessorBufferViewIds = [0, 1, 2, 3, 4, 5]; + const remainingBufferViewNames = ['positions', 'normals', 'texcoords', 'positions-target1', 'normals-target1', 'indices', 'image0']; + const remainingBufferViewBufferIds = [0, 0, 0, 0, 0, 0, 1]; + const remaining = { + nodes: ['skin', 'used', 'camera', 'light'], + cameras: ['cam'], + meshes: ['mesh0'], + buffers: ['mesh', 'image01'], + lights: ['sun'], + materials: ['used'] + }; + + it('correctly removes/keeps accessors', () => { expect(gltf.accessors.length).toBe(remainingAccessorNames.length); - expect(gltf.bufferViews.length).toBe(remainingBufferViewNames.length); - expect(gltf.buffers.length).toBe(remainingBufferNames.length); ForEach.accessor(gltf, (accessor, index) => { expect(accessor.name).toBe(remainingAccessorNames[index]); expect(accessor.bufferView).toBe(remainingAcessorBufferViewIds[index]); }); + }); + + it('correctly removes/keeps bufferViews', () => { + expect(gltf.bufferViews.length).toBe(remainingBufferViewNames.length); ForEach.bufferView(gltf, (bufferView, index) => { expect(bufferView.name).toBe(remainingBufferViewNames[index]); expect(bufferView.buffer).toBe(remainingBufferViewBufferIds[index]); }); + }); + + ['materials', 'nodes', 'cameras', 'meshes', 'buffers'].forEach(k => { + it('correctly removes/keeps ' + k, () => { + expect(Object.keys(gltf)).toContain(k); + expect(gltf[k].length).toBe(remaining[k].length); + + // Check that at least the remaining elements are present + ForEach.topLevel(gltf, k, (element) => { + expect(remaining[k]).toContain(element.name); + }); + + // Check that all the elements should actually remain + remaining[k].forEach((name) => { + expect(gltf[k].map(x => x.name)).toContain(name); + }); + }); + }); + + it('correctly removes/keeps lights', () => { + expect(Object.keys(gltf)).toContain('extensions'); + expect(Object.keys(gltf.extensions)).toContain('KHR_lights_punctual'); + expect(Object.keys(gltf.extensions.KHR_lights_punctual)).toContain('lights'); + + expect(gltf.extensions.KHR_lights_punctual.lights.length) + .toBe(remaining.lights.length); - ForEach.buffer(gltf, (buffer, index) => { - expect(buffer.name).toBe(remainingBufferNames[index]); + gltf.extensions.KHR_lights_punctual.lights.forEach((element, index) => { + expect(remaining['lights']).toContain(element.name); }); }); });