diff --git a/src/components/components/CommonComponents.js b/src/components/components/CommonComponents.js index d22b34c0..24517f75 100644 --- a/src/components/components/CommonComponents.js +++ b/src/components/components/CommonComponents.js @@ -8,7 +8,7 @@ import Mixins from './Mixins'; import {updateEntity, getClipboardRepresentation} from '../../actions/entity'; import Events from '../../lib/Events'; import Clipboard from 'clipboard'; -import {saveString} from '../../lib/utils'; +import {saveBlob} from '../../lib/utils'; // @todo Take this out and use updateEntity? function changeId (componentName, value) { @@ -68,10 +68,10 @@ export default class CommonComponents extends React.Component { exportToGLTF () { const entity = this.props.entity; - AFRAME.INSPECTOR.exporters.gltf.parse(entity.object3D, function (result) { - var output = JSON.stringify(result, null, 2); - saveString(output, (entity.id || 'entity') + '.gltf', 'application/json'); - }); + AFRAME.INSPECTOR.exporters.gltf.parse(entity.object3D, function (buffer) { + const blob = new Blob([buffer], {type: 'application/octet-stream'}); + saveBlob(blob, (entity.id || 'entity') + '.glb'); + }, {binary: true}); } render () { diff --git a/src/components/scenegraph/Toolbar.js b/src/components/scenegraph/Toolbar.js index a8488741..7850391f 100644 --- a/src/components/scenegraph/Toolbar.js +++ b/src/components/scenegraph/Toolbar.js @@ -3,11 +3,17 @@ import React from 'react'; import Clipboard from 'clipboard'; import {getSceneName, generateHtml} from '../../lib/exporter'; import Events from '../../lib/Events.js'; -import {saveString} from '../../lib/utils'; +import {saveBlob, saveString} from '../../lib/utils'; import MotionCapture from './MotionCapture'; const LOCALSTORAGE_MOCAP_UI = 'aframeinspectormocapuienabled'; +function filterHelpers (scene, visible) { + scene.traverse((o) => { + if (o.userData.source === 'INSPECTOR') { o.visible = visible; } + }); +} + /** * Tools and actions. */ @@ -35,10 +41,14 @@ export default class Toolbar extends React.Component { } exportSceneToGLTF () { ga('send', 'event', 'SceneGraph', 'exportGLTF'); - INSPECTOR.exporters.gltf.parse(AFRAME.scenes[0].object3D, function (result) { - var output = JSON.stringify(result, null, 2); - saveString(output, 'scene.gltf', 'application/json'); - }); + const sceneName = getSceneName(AFRAME.scenes[0]); + const scene = AFRAME.scenes[0].object3D; + filterHelpers(scene, false); + INSPECTOR.exporters.gltf.parse(scene, function (buffer) { + filterHelpers(scene, true); + const blob = new Blob([buffer], {type: 'application/octet-stream'}); + saveBlob(blob, sceneName + '.glb'); + }, {binary: true}); } exportSceneToHTML () { diff --git a/src/lib/inspector.js b/src/lib/inspector.js index b222713b..43ccf15b 100644 --- a/src/lib/inspector.js +++ b/src/lib/inspector.js @@ -100,6 +100,7 @@ Inspector.prototype = { this.scene = this.sceneEl.object3D; this.helpers = {}; this.sceneHelpers = new THREE.Scene(); + this.sceneHelpers.userData.source = 'INSPECTOR'; this.sceneHelpers.visible = true; // false; this.inspectorActive = false; @@ -178,8 +179,10 @@ Inspector.prototype = { var picker = new THREE.Mesh(geometry, material); picker.name = 'picker'; picker.userData.object = object; + picker.userData.source = 'INSPECTOR'; helper.add(picker); helper.fromObject = object; + helper.userData.source = 'INSPECTOR'; this.sceneHelpers.add(helper); this.helpers[parentId][object.id] = helper; diff --git a/src/lib/utils.js b/src/lib/utils.js index 8f7909fd..dfc830ad 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -84,17 +84,17 @@ function injectJS (url, onLoad, onError) { } function saveString (text, filename, mimeType) { + saveBlob(new Blob([ text ], { type: mimeType }), filename); +} + +function saveBlob (blob, filename) { var link = document.createElement('a'); link.style.display = 'none'; document.body.appendChild(link); - function save (blob, filename) { - link.href = URL.createObjectURL(blob); - link.download = filename || 'ascene.html'; - link.click(); - // URL.revokeObjectURL(url); breaks Firefox... - } - - save(new Blob([ text ], { type: mimeType }), filename); + link.href = URL.createObjectURL(blob); + link.download = filename || 'ascene.html'; + link.click(); + // URL.revokeObjectURL(url); breaks Firefox... } module.exports = { @@ -105,5 +105,6 @@ module.exports = { os: getOS(), injectCSS: injectCSS, injectJS: injectJS, - saveString: saveString + saveString: saveString, + saveBlob: saveBlob, }; diff --git a/src/lib/vendor/GLTFExporter.js b/src/lib/vendor/GLTFExporter.js index 8d8183e1..271f9664 100644 --- a/src/lib/vendor/GLTFExporter.js +++ b/src/lib/vendor/GLTFExporter.js @@ -1,10 +1,12 @@ /** * @author fernandojsg / http://fernandojsg.com + * @author Don McCurdy / https://www.donmccurdy.com + * @author Takahiro / https://github.com/takahirox */ - //------------------------------------------------------------------------------ - // Constants - //------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ +// Constants +//------------------------------------------------------------------------------ var WEBGL_CONSTANTS = { POINTS: 0x0000, LINES: 0x0001, @@ -37,7 +39,14 @@ var THREE_TO_WEBGL = { 1006: WEBGL_CONSTANTS.LINEAR, 1007: WEBGL_CONSTANTS.LINEAR_MIPMAP_NEAREST, 1008: WEBGL_CONSTANTS.LINEAR_MIPMAP_LINEAR - }; +}; + +var PATH_PROPERTIES = { + scale: 'scale', + position: 'translation', + quaternion: 'rotation', + morphTargetInfluences: 'weights' +}; //------------------------------------------------------------------------------ // GLTF Exporter @@ -53,38 +62,57 @@ THREE.GLTFExporter.prototype = { * @param {THREE.Scene or [THREE.Scenes]} input THREE.Scene or Array of THREE.Scenes * @param {Function} onDone Callback on completed * @param {Object} options options - * trs: Exports position, rotation and scale instead of matrix */ parse: function ( input, onDone, options ) { var DEFAULT_OPTIONS = { + binary: false, trs: false, onlyVisible: true, - truncateDrawRange: true + truncateDrawRange: true, + embedImages: true, + animations: [], + forceIndices: false, + forcePowerOfTwoTextures: false }; options = Object.assign( {}, DEFAULT_OPTIONS, options ); + if ( options.animations.length > 0 ) { + + // Only TRS properties, and not matrices, may be targeted by animation. + options.trs = true; + + } + var outputJSON = { asset: { version: "2.0", - generator: "THREE.JS GLTFExporter" // @QUESTION Does it support spaces? + generator: "THREE.GLTFExporter" - } + } - }; + }; var byteOffset = 0; - var dataViews = []; + var buffers = []; + var pending = []; + var nodeMap = new Map(); + var skins = []; + var extensionsUsed = {}; var cachedData = { - images: {}, - materials: {} + attributes: new Map(), + materials: new Map(), + textures: new Map(), + images: new Map() }; + var cachedCanvas; + /** * Compare two arrays */ @@ -94,22 +122,52 @@ THREE.GLTFExporter.prototype = { * @param {Array} array2 Array 2 to compare * @return {Boolean} Returns true if both arrays are equal */ - function equalArray ( array1, array2 ) { + function equalArray( array1, array2 ) { + + return ( array1.length === array2.length ) && array1.every( function ( element, index ) { + + return element === array2[ index ]; + + } ); + + } + + /** + * Converts a string to an ArrayBuffer. + * @param {string} text + * @return {ArrayBuffer} + */ + function stringToArrayBuffer( text ) { + + if ( window.TextEncoder !== undefined ) { + + return new TextEncoder().encode( text ).buffer; + + } - return ( array1.length === array2.length ) && array1.every( function( element, index ) { + var array = new Uint8Array( new ArrayBuffer( text.length ) ); - return element === array2[ index ]; + for ( var i = 0, il = text.length; i < il; i ++ ) { - }); + var value = text.charCodeAt( i ); + + // Replacing multi-byte character with space(0x20). + array[ i ] = value > 0xFF ? 0x20 : value; + + } + + return array.buffer; } /** - * Get the min and he max vectors from the given attribute - * @param {THREE.WebGLAttribute} attribute Attribute to find the min/max + * Get the min and max vectors from the given attribute + * @param {THREE.BufferAttribute} attribute Attribute to find the min/max in range from start to start + count + * @param {Integer} start + * @param {Integer} count * @return {Object} Object containing the `min` and `max` values (As an array of attribute.itemSize components) */ - function getMinMax ( attribute ) { + function getMinMax( attribute, start, count ) { var output = { @@ -118,9 +176,9 @@ THREE.GLTFExporter.prototype = { }; - for ( var i = 0; i < attribute.count; i++ ) { + for ( var i = start; i < start + count; i ++ ) { - for ( var a = 0; a < attribute.itemSize; a++ ) { + for ( var a = 0; a < attribute.itemSize; a ++ ) { var value = attribute.array[ i * attribute.itemSize + a ]; output.min[ a ] = Math.min( output.min[ a ], value ); @@ -131,107 +189,280 @@ THREE.GLTFExporter.prototype = { } return output; + } /** - * Process a buffer to append to the default one. - * @param {THREE.BufferAttribute} attribute Attribute to store - * @param {Integer} componentType Component type (Unsigned short, unsigned int or float) - * @return {Integer} Index of the buffer created (Currently always 0) + * Checks if image size is POT. + * + * @param {Image} image The image to be checked. + * @returns {Boolean} Returns true if image size is POT. + * + */ + function isPowerOfTwo( image ) { + + return THREE.Math.isPowerOfTwo( image.width ) && THREE.Math.isPowerOfTwo( image.height ); + + } + + /** + * Checks if normal attribute values are normalized. + * + * @param {THREE.BufferAttribute} normal + * @returns {Boolean} + * */ - function processBuffer ( attribute, componentType, start, count ) { + function isNormalizedNormalAttribute( normal ) { - if ( !outputJSON.buffers ) { + if ( cachedData.attributes.has( normal ) ) { - outputJSON.buffers = [ + return false; - { + } - byteLength: 0, - uri: '' + var v = new THREE.Vector3(); - } + for ( var i = 0, il = normal.count; i < il; i ++ ) { - ]; + // 0.0005 is from glTF-validator + if ( Math.abs( v.fromArray( normal.array, i * 3 ).length() - 1.0 ) > 0.0005 ) return false; } - var offset = 0; - var componentSize = componentType === WEBGL_CONSTANTS.UNSIGNED_SHORT ? 2 : 4; + return true; - // Create a new dataview and dump the attribute's array into it - var byteLength = count * attribute.itemSize * componentSize; + } - var dataView = new DataView( new ArrayBuffer( byteLength ) ); + /** + * Creates normalized normal buffer attribute. + * + * @param {THREE.BufferAttribute} normal + * @returns {THREE.BufferAttribute} + * + */ + function createNormalizedNormalAttribute( normal ) { - for ( var i = start; i < start + count; i++ ) { + if ( cachedData.attributes.has( normal ) ) { - for (var a = 0; a < attribute.itemSize; a++ ) { + return cachedData.attributes.get( normal ); - var value = attribute.array[ i * attribute.itemSize + a ]; + } - if ( componentType === WEBGL_CONSTANTS.FLOAT ) { + var attribute = normal.clone(); - dataView.setFloat32( offset, value, true ); + var v = new THREE.Vector3(); - } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_INT ) { + for ( var i = 0, il = attribute.count; i < il; i ++ ) { - dataView.setUint8( offset, value, true ); + v.fromArray( attribute.array, i * 3 ); - } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_SHORT ) { + if ( v.x === 0 && v.y === 0 && v.z === 0 ) { - dataView.setUint16( offset, value, true ); + // if values can't be normalized set (1, 0, 0) + v.setX( 1.0 ); - } + } else { - offset += componentSize; + v.normalize(); + + } + + v.toArray( attribute.array, i * 3 ); + + } + + cachedData.attributes.set( normal, attribute ); + + return attribute; + + } + + /** + * Get the required size + padding for a buffer, rounded to the next 4-byte boundary. + * https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#data-alignment + * + * @param {Integer} bufferSize The size the original buffer. + * @returns {Integer} new buffer size with required padding. + * + */ + function getPaddedBufferSize( bufferSize ) { + + return Math.ceil( bufferSize / 4 ) * 4; + + } + + /** + * Returns a buffer aligned to 4-byte boundary. + * + * @param {ArrayBuffer} arrayBuffer Buffer to pad + * @param {Integer} paddingByte (Optional) + * @returns {ArrayBuffer} The same buffer if it's already aligned to 4-byte boundary or a new buffer + */ + function getPaddedArrayBuffer( arrayBuffer, paddingByte ) { + + paddingByte = paddingByte || 0; + + var paddedLength = getPaddedBufferSize( arrayBuffer.byteLength ); + + if ( paddedLength !== arrayBuffer.byteLength ) { + + var array = new Uint8Array( paddedLength ); + array.set( new Uint8Array( arrayBuffer ) ); + + if ( paddingByte !== 0 ) { + + for ( var i = arrayBuffer.byteLength; i < paddedLength; i ++ ) { + + array[ i ] = paddingByte; + + } } + return array.buffer; + + } + + return arrayBuffer; + + } + + /** + * Serializes a userData. + * + * @param {THREE.Object3D|THREE.Material} object + * @returns {Object} + */ + function serializeUserData( object ) { + + try { + + return JSON.parse( JSON.stringify( object.userData ) ); + + } catch ( error ) { + + console.warn( 'THREE.GLTFExporter: userData of \'' + object.name + '\' ' + + 'won\'t be serialized because of JSON.stringify error - ' + error.message ); + + return {}; + } - // We just use one buffer - dataViews.push( dataView ); + } + + /** + * Process a buffer to append to the default one. + * @param {ArrayBuffer} buffer + * @return {Integer} + */ + function processBuffer( buffer ) { + + if ( ! outputJSON.buffers ) { + + outputJSON.buffers = [ { byteLength: 0 } ]; + + } + + // All buffers are merged before export. + buffers.push( buffer ); - // Always using just one buffer return 0; + } /** * Process and generate a BufferView - * @param {[type]} data [description] - * @return {[type]} [description] + * @param {THREE.BufferAttribute} attribute + * @param {number} componentType + * @param {number} start + * @param {number} count + * @param {number} target (Optional) Target usage of the BufferView + * @return {Object} */ - function processBufferView ( data, componentType, start, count ) { - - var isVertexAttributes = componentType === WEBGL_CONSTANTS.FLOAT; + function processBufferView( attribute, componentType, start, count, target ) { - if ( !outputJSON.bufferViews ) { + if ( ! outputJSON.bufferViews ) { outputJSON.bufferViews = []; } - var componentSize = componentType === WEBGL_CONSTANTS.UNSIGNED_SHORT ? 2 : 4; - // Create a new dataview and dump the attribute's array into it - var byteLength = count * data.itemSize * componentSize; + + var componentSize; + + if ( componentType === WEBGL_CONSTANTS.UNSIGNED_BYTE ) { + + componentSize = 1; + + } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_SHORT ) { + + componentSize = 2; + + } else { + + componentSize = 4; + + } + + var byteLength = getPaddedBufferSize( count * attribute.itemSize * componentSize ); + var dataView = new DataView( new ArrayBuffer( byteLength ) ); + var offset = 0; + + for ( var i = start; i < start + count; i ++ ) { + + for ( var a = 0; a < attribute.itemSize; a ++ ) { + + // @TODO Fails on InterleavedBufferAttribute, and could probably be + // optimized for normal BufferAttribute. + var value = attribute.array[ i * attribute.itemSize + a ]; + + if ( componentType === WEBGL_CONSTANTS.FLOAT ) { + + dataView.setFloat32( offset, value, true ); + + } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_INT ) { + + dataView.setUint32( offset, value, true ); + + } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_SHORT ) { + + dataView.setUint16( offset, value, true ); + + } else if ( componentType === WEBGL_CONSTANTS.UNSIGNED_BYTE ) { + + dataView.setUint8( offset, value ); + + } + + offset += componentSize; + + } + + } var gltfBufferView = { - buffer: processBuffer( data, componentType, start, count ), + buffer: processBuffer( dataView.buffer ), byteOffset: byteOffset, - byteLength: byteLength, - byteStride: data.itemSize * componentSize, - target: isVertexAttributes ? WEBGL_CONSTANTS.ARRAY_BUFFER : WEBGL_CONSTANTS.ELEMENT_ARRAY_BUFFER + byteLength: byteLength }; + if ( target !== undefined ) gltfBufferView.target = target; + + if ( target === WEBGL_CONSTANTS.ARRAY_BUFFER ) { + + // Only define byteStride for vertex attributes. + gltfBufferView.byteStride = attribute.itemSize * componentSize; + + } + byteOffset += byteLength; outputJSON.bufferViews.push( gltfBufferView ); - // @TODO Ideally we'll have just two bufferviews: 0 is for vertex attributes, 1 for indices + // @TODO Merge bufferViews where possible. var output = { id: outputJSON.bufferViews.length - 1, @@ -244,26 +475,63 @@ THREE.GLTFExporter.prototype = { } /** - * Process attribute to generate an accessor - * @param {THREE.WebGLAttribute} attribute Attribute to process - * @return {Integer} Index of the processed accessor on the "accessors" array + * Process and generate a BufferView from an image Blob. + * @param {Blob} blob + * @return {Promise} */ - function processAccessor ( attribute, geometry ) { + function processBufferViewImage( blob ) { - if ( !outputJSON.accessors ) { + if ( ! outputJSON.bufferViews ) { - outputJSON.accessors = []; + outputJSON.bufferViews = []; } - var types = [ + return new Promise( function ( resolve ) { + + var reader = new window.FileReader(); + reader.readAsArrayBuffer( blob ); + reader.onloadend = function () { + + var buffer = getPaddedArrayBuffer( reader.result ); + + var bufferView = { + buffer: processBuffer( buffer ), + byteOffset: byteOffset, + byteLength: buffer.byteLength + }; + + byteOffset += buffer.byteLength; + + outputJSON.bufferViews.push( bufferView ); + + resolve( outputJSON.bufferViews.length - 1 ); + + }; + + } ); + + } + + /** + * Process attribute to generate an accessor + * @param {THREE.BufferAttribute} attribute Attribute to process + * @param {THREE.BufferGeometry} geometry (Optional) Geometry used for truncated draw range + * @param {Integer} start (Optional) + * @param {Integer} count (Optional) + * @return {Integer} Index of the processed accessor on the "accessors" array + */ + function processAccessor( attribute, geometry, start, count ) { + + var types = { - 'SCALAR', - 'VEC2', - 'VEC3', - 'VEC4' + 1: 'SCALAR', + 2: 'VEC2', + 3: 'VEC3', + 4: 'VEC4', + 16: 'MAT4' - ]; + }; var componentType; @@ -280,24 +548,54 @@ THREE.GLTFExporter.prototype = { componentType = WEBGL_CONSTANTS.UNSIGNED_SHORT; + } else if ( attribute.array.constructor === Uint8Array ) { + + componentType = WEBGL_CONSTANTS.UNSIGNED_BYTE; + } else { throw new Error( 'THREE.GLTFExporter: Unsupported bufferAttribute component type.' ); } - var minMax = getMinMax( attribute ); - - var start = 0; - var count = attribute.count; + if ( start === undefined ) start = 0; + if ( count === undefined ) count = attribute.count; // @TODO Indexed buffer geometry with drawRange not supported yet - if ( options.truncateDrawRange && geometry.index === null ) { - start = geometry.drawRange.start; - count = geometry.drawRange.count !== Infinity ? geometry.drawRange.count : attribute.count; + if ( options.truncateDrawRange && geometry !== undefined && geometry.index === null ) { + + var end = start + count; + var end2 = geometry.drawRange.count === Infinity + ? attribute.count + : geometry.drawRange.start + geometry.drawRange.count; + + start = Math.max( start, geometry.drawRange.start ); + count = Math.min( end, end2 ) - start; + + if ( count < 0 ) count = 0; + + } + + // Skip creating an accessor if the attribute doesn't have data to export + if ( count === 0 ) { + + return null; + + } + + var minMax = getMinMax( attribute, start, count ); + + var bufferViewTarget; + + // If geometry isn't provided, don't infer the target usage of the bufferView. For + // animation samplers, target must not be set. + if ( geometry !== undefined ) { + + bufferViewTarget = attribute === geometry.index ? WEBGL_CONSTANTS.ELEMENT_ARRAY_BUFFER : WEBGL_CONSTANTS.ARRAY_BUFFER; + } - var bufferView = processBufferView( attribute, componentType, start, count ); + var bufferView = processBufferView( attribute, componentType, start, count, bufferViewTarget ); var gltfAccessor = { @@ -307,10 +605,16 @@ THREE.GLTFExporter.prototype = { count: count, max: minMax.max, min: minMax.min, - type: types[ attribute.itemSize - 1 ] + type: types[ attribute.itemSize ] }; + if ( ! outputJSON.accessors ) { + + outputJSON.accessors = []; + + } + outputJSON.accessors.push( gltfAccessor ); return outputJSON.accessors.length - 1; @@ -319,40 +623,98 @@ THREE.GLTFExporter.prototype = { /** * Process image - * @param {Texture} map Texture to process + * @param {Image} image to process + * @param {Integer} format of the image (e.g. THREE.RGBFormat, THREE.RGBAFormat etc) + * @param {Boolean} flipY before writing out the image * @return {Integer} Index of the processed texture in the "images" array */ - function processImage ( map ) { + function processImage( image, format, flipY ) { + + if ( ! cachedData.images.has( image ) ) { - if ( cachedData.images[ map.uuid ] ) { + cachedData.images.set( image, {} ); - return cachedData.images[ map.uuid ]; + } + + var cachedImages = cachedData.images.get( image ); + var mimeType = format === THREE.RGBAFormat ? 'image/png' : 'image/jpeg'; + var key = mimeType + ":flipY/" + flipY.toString(); + + if ( cachedImages[ key ] !== undefined ) { + + return cachedImages[ key ]; } - if ( !outputJSON.images ) { + if ( ! outputJSON.images ) { outputJSON.images = []; } - var gltfImage = {}; + var gltfImage = { mimeType: mimeType }; if ( options.embedImages ) { - // @TODO { bufferView, mimeType } + var canvas = cachedCanvas = cachedCanvas || document.createElement( 'canvas' ); + + canvas.width = image.width; + canvas.height = image.height; + + if ( options.forcePowerOfTwoTextures && ! isPowerOfTwo( image ) ) { + + console.warn( 'GLTFExporter: Resized non-power-of-two image.', image ); + + canvas.width = THREE.Math.floorPowerOfTwo( canvas.width ); + canvas.height = THREE.Math.floorPowerOfTwo( canvas.height ); + + } + + var ctx = canvas.getContext( '2d' ); + + if ( flipY === true ) { + + ctx.translate( 0, canvas.height ); + ctx.scale( 1, - 1 ); + + } + + ctx.drawImage( image, 0, 0, canvas.width, canvas.height ); + + if ( options.binary === true ) { + + pending.push( new Promise( function ( resolve ) { + + canvas.toBlob( function ( blob ) { + + processBufferViewImage( blob ).then( function ( bufferViewIndex ) { + + gltfImage.bufferView = bufferViewIndex; + + resolve(); + + } ); + + }, mimeType ); + + } ) ); + + } else { + + gltfImage.uri = canvas.toDataURL( mimeType ); + + } } else { - // @TODO base64 based on options - gltfImage.uri = map.image.src; + gltfImage.uri = image.src; } outputJSON.images.push( gltfImage ); var index = outputJSON.images.length - 1; - cachedData.images[ map.uuid ] = index; + cachedImages[ key ] = index; return index; @@ -363,9 +725,9 @@ THREE.GLTFExporter.prototype = { * @param {Texture} map Texture to process * @return {Integer} Index of the processed texture in the "samplers" array */ - function processSampler ( map ) { + function processSampler( map ) { - if ( !outputJSON.samplers ) { + if ( ! outputJSON.samplers ) { outputJSON.samplers = []; @@ -378,382 +740,773 @@ THREE.GLTFExporter.prototype = { wrapS: THREE_TO_WEBGL[ map.wrapS ], wrapT: THREE_TO_WEBGL[ map.wrapT ] - }; + }; + + outputJSON.samplers.push( gltfSampler ); + + return outputJSON.samplers.length - 1; + + } + + /** + * Process texture + * @param {Texture} map Map to process + * @return {Integer} Index of the processed texture in the "textures" array + */ + function processTexture( map ) { + + if ( cachedData.textures.has( map ) ) { + + return cachedData.textures.get( map ); + + } + + if ( ! outputJSON.textures ) { + + outputJSON.textures = []; + + } + + var gltfTexture = { + + sampler: processSampler( map ), + source: processImage( map.image, map.format, map.flipY ) + + }; + + outputJSON.textures.push( gltfTexture ); + + var index = outputJSON.textures.length - 1; + cachedData.textures.set( map, index ); + + return index; + + } + + /** + * Process material + * @param {THREE.Material} material Material to process + * @return {Integer} Index of the processed material in the "materials" array + */ + function processMaterial( material ) { + + if ( cachedData.materials.has( material ) ) { + + return cachedData.materials.get( material ); + + } + + if ( ! outputJSON.materials ) { + + outputJSON.materials = []; + + } + + if ( material.isShaderMaterial ) { + + console.warn( 'GLTFExporter: THREE.ShaderMaterial not supported.' ); + return null; + + } + + // @QUESTION Should we avoid including any attribute that has the default value? + var gltfMaterial = { + + pbrMetallicRoughness: {} + + }; + + if ( material.isMeshBasicMaterial ) { + + gltfMaterial.extensions = { KHR_materials_unlit: {} }; + + extensionsUsed[ 'KHR_materials_unlit' ] = true; + + } else if ( ! material.isMeshStandardMaterial ) { + + console.warn( 'GLTFExporter: Use MeshStandardMaterial or MeshBasicMaterial for best results.' ); + + } + + // pbrMetallicRoughness.baseColorFactor + var color = material.color.toArray().concat( [ material.opacity ] ); + + if ( ! equalArray( color, [ 1, 1, 1, 1 ] ) ) { + + gltfMaterial.pbrMetallicRoughness.baseColorFactor = color; + + } + + if ( material.isMeshStandardMaterial ) { + + gltfMaterial.pbrMetallicRoughness.metallicFactor = material.metalness; + gltfMaterial.pbrMetallicRoughness.roughnessFactor = material.roughness; + + } else if ( material.isMeshBasicMaterial ) { + + gltfMaterial.pbrMetallicRoughness.metallicFactor = 0.0; + gltfMaterial.pbrMetallicRoughness.roughnessFactor = 0.9; + + } else { + + gltfMaterial.pbrMetallicRoughness.metallicFactor = 0.5; + gltfMaterial.pbrMetallicRoughness.roughnessFactor = 0.5; + + } + + // pbrMetallicRoughness.metallicRoughnessTexture + if ( material.metalnessMap || material.roughnessMap ) { + + if ( material.metalnessMap === material.roughnessMap ) { + + gltfMaterial.pbrMetallicRoughness.metallicRoughnessTexture = { + + index: processTexture( material.metalnessMap ) + + }; + + } else { + + console.warn( 'THREE.GLTFExporter: Ignoring metalnessMap and roughnessMap because they are not the same Texture.' ); + + } + + } + + // pbrMetallicRoughness.baseColorTexture + if ( material.map ) { + + gltfMaterial.pbrMetallicRoughness.baseColorTexture = { + + index: processTexture( material.map ) + + }; + + } + + if ( material.isMeshBasicMaterial || + material.isLineBasicMaterial || + material.isPointsMaterial ) { + + } else { + + // emissiveFactor + var emissive = material.emissive.clone().multiplyScalar( material.emissiveIntensity ).toArray(); + + if ( ! equalArray( emissive, [ 0, 0, 0 ] ) ) { + + gltfMaterial.emissiveFactor = emissive; + + } + + // emissiveTexture + if ( material.emissiveMap ) { + + gltfMaterial.emissiveTexture = { + + index: processTexture( material.emissiveMap ) + + }; + + } + + } + + // normalTexture + if ( material.normalMap ) { + + gltfMaterial.normalTexture = { + + index: processTexture( material.normalMap ) + + }; + + if ( material.normalScale.x !== - 1 ) { + + if ( material.normalScale.x !== material.normalScale.y ) { + + console.warn( 'THREE.GLTFExporter: Normal scale components are different, ignoring Y and exporting X.' ); + + } + + gltfMaterial.normalTexture.scale = material.normalScale.x; + + } + + } + + // occlusionTexture + if ( material.aoMap ) { + + gltfMaterial.occlusionTexture = { + + index: processTexture( material.aoMap ) + + }; + + if ( material.aoMapIntensity !== 1.0 ) { + + gltfMaterial.occlusionTexture.strength = material.aoMapIntensity; + + } + + } + + // alphaMode + if ( material.transparent || material.alphaTest > 0.0 ) { + + gltfMaterial.alphaMode = material.opacity < 1.0 ? 'BLEND' : 'MASK'; + + // Write alphaCutoff if it's non-zero and different from the default (0.5). + if ( material.alphaTest > 0.0 && material.alphaTest !== 0.5 ) { + + gltfMaterial.alphaCutoff = material.alphaTest; + + } + + } + + // doubleSided + if ( material.side === THREE.DoubleSide ) { + + gltfMaterial.doubleSided = true; + + } + + if ( material.name !== '' ) { + + gltfMaterial.name = material.name; + + } + + if ( Object.keys( material.userData ).length > 0 ) { + + gltfMaterial.extras = serializeUserData( material ); + + } + + outputJSON.materials.push( gltfMaterial ); + + var index = outputJSON.materials.length - 1; + cachedData.materials.set( material, index ); + + return index; + + } + + /** + * Process mesh + * @param {THREE.Mesh} mesh Mesh to process + * @return {Integer} Index of the processed mesh in the "meshes" array + */ + function processMesh( mesh ) { + + var geometry = mesh.geometry; + + var mode; + + // Use the correct mode + if ( mesh.isLineSegments ) { + + mode = WEBGL_CONSTANTS.LINES; + + } else if ( mesh.isLineLoop ) { + + mode = WEBGL_CONSTANTS.LINE_LOOP; + + } else if ( mesh.isLine ) { + + mode = WEBGL_CONSTANTS.LINE_STRIP; + + } else if ( mesh.isPoints ) { + + mode = WEBGL_CONSTANTS.POINTS; + + } else { + + if ( ! geometry.isBufferGeometry ) { + + var geometryTemp = new THREE.BufferGeometry(); + geometryTemp.fromGeometry( geometry ); + geometry = geometryTemp; + + } + + if ( mesh.drawMode === THREE.TriangleFanDrawMode ) { + + console.warn( 'GLTFExporter: TriangleFanDrawMode and wireframe incompatible.' ); + mode = WEBGL_CONSTANTS.TRIANGLE_FAN; + + } else if ( mesh.drawMode === THREE.TriangleStripDrawMode ) { + + mode = mesh.material.wireframe ? WEBGL_CONSTANTS.LINE_STRIP : WEBGL_CONSTANTS.TRIANGLE_STRIP; + + } else { + + mode = mesh.material.wireframe ? WEBGL_CONSTANTS.LINES : WEBGL_CONSTANTS.TRIANGLES; + + } + + } + + var gltfMesh = {}; + + var attributes = {}; + var primitives = []; + var targets = []; + + // Conversion between attributes names in threejs and gltf spec + var nameConversion = { + + uv: 'TEXCOORD_0', + uv2: 'TEXCOORD_1', + color: 'COLOR_0', + skinWeight: 'WEIGHTS_0', + skinIndex: 'JOINTS_0' + + }; + + var originalNormal = geometry.getAttribute( 'normal' ); + + if ( originalNormal !== undefined && ! isNormalizedNormalAttribute( originalNormal ) ) { + + console.warn( 'THREE.GLTFExporter: Creating normalized normal attribute from the non-normalized one.' ); + + geometry.addAttribute( 'normal', createNormalizedNormalAttribute( originalNormal ) ); + + } + + // @QUESTION Detect if .vertexColors = THREE.VertexColors? + // For every attribute create an accessor + for ( var attributeName in geometry.attributes ) { + + var attribute = geometry.attributes[ attributeName ]; + attributeName = nameConversion[ attributeName ] || attributeName.toUpperCase(); + + // JOINTS_0 must be UNSIGNED_BYTE or UNSIGNED_SHORT. + var array = attribute.array; + if ( attributeName === 'JOINTS_0' && + ! ( array instanceof Uint16Array ) && + ! ( array instanceof Uint8Array ) ) { + + console.warn( 'GLTFExporter: Attribute "skinIndex" converted to type UNSIGNED_SHORT.' ); + attribute = new THREE.BufferAttribute( new Uint16Array( array ), attribute.itemSize, attribute.normalized ); + + } + + if ( attributeName.substr( 0, 5 ) !== 'MORPH' ) { + + var accessor = processAccessor( attribute, geometry ); + if ( accessor !== null ) { + + attributes[ attributeName ] = accessor; + + } + + } + + } + + if ( originalNormal !== undefined ) geometry.addAttribute( 'normal', originalNormal ); + + // Skip if no exportable attributes found + if ( Object.keys( attributes ).length === 0 ) { + + return null; + + } - outputJSON.samplers.push( gltfSampler ); + // Morph targets + if ( mesh.morphTargetInfluences !== undefined && mesh.morphTargetInfluences.length > 0 ) { - return outputJSON.samplers.length - 1; + var weights = []; + var targetNames = []; + var reverseDictionary = {}; - } + if ( mesh.morphTargetDictionary !== undefined ) { - /** - * Process texture - * @param {Texture} map Map to process - * @return {Integer} Index of the processed texture in the "textures" array - */ - function processTexture ( map ) { + for ( var key in mesh.morphTargetDictionary ) { - if (!outputJSON.textures) { + reverseDictionary[ mesh.morphTargetDictionary[ key ] ] = key; - outputJSON.textures = []; + } - } + } - var gltfTexture = { + for ( var i = 0; i < mesh.morphTargetInfluences.length; ++ i ) { - sampler: processSampler( map ), - source: processImage( map ) + var target = {}; - }; + var warned = false; - outputJSON.textures.push( gltfTexture ); + for ( var attributeName in geometry.morphAttributes ) { - return outputJSON.textures.length - 1; + // glTF 2.0 morph supports only POSITION/NORMAL/TANGENT. + // Three.js doesn't support TANGENT yet. - } + if ( attributeName !== 'position' && attributeName !== 'normal' ) { - /** - * Process material - * @param {THREE.Material} material Material to process - * @return {Integer} Index of the processed material in the "materials" array - */ - function processMaterial ( material ) { + if ( ! warned ) { - if ( cachedData.materials[ material.uuid ] ) { + console.warn( 'GLTFExporter: Only POSITION and NORMAL morph are supported.' ); + warned = true; - return cachedData.materials[ material.uuid ]; + } - } + continue; - if ( !outputJSON.materials ) { + } - outputJSON.materials = []; + var attribute = geometry.morphAttributes[ attributeName ][ i ]; - } + // Three.js morph attribute has absolute values while the one of glTF has relative values. + // + // glTF 2.0 Specification: + // https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#morph-targets - if ( material instanceof THREE.ShaderMaterial ) { + var baseAttribute = geometry.attributes[ attributeName ]; + // Clones attribute not to override + var relativeAttribute = attribute.clone(); - console.warn( 'GLTFExporter: THREE.ShaderMaterial not supported.' ); - return null; + for ( var j = 0, jl = attribute.count; j < jl; j ++ ) { - } + relativeAttribute.setXYZ( + j, + attribute.getX( j ) - baseAttribute.getX( j ), + attribute.getY( j ) - baseAttribute.getY( j ), + attribute.getZ( j ) - baseAttribute.getZ( j ) + ); + } - if ( !( material instanceof THREE.MeshStandardMaterial ) ) { + target[ attributeName.toUpperCase() ] = processAccessor( relativeAttribute, geometry ); - console.warn( 'GLTFExporter: Currently just THREE.StandardMaterial is supported. Material conversion may lose information.' ); + } - } + targets.push( target ); - // @QUESTION Should we avoid including any attribute that has the default value? - var gltfMaterial = { + weights.push( mesh.morphTargetInfluences[ i ] ); + if ( mesh.morphTargetDictionary !== undefined ) targetNames.push( reverseDictionary[ i ] ); - pbrMetallicRoughness: {} + } - }; + gltfMesh.weights = weights; - // pbrMetallicRoughness.baseColorFactor - var color = material.color.toArray().concat( [ material.opacity ] ); + if ( targetNames.length > 0 ) { - if ( !equalArray( color, [ 1, 1, 1, 1 ] ) ) { + gltfMesh.extras = {}; + gltfMesh.extras.targetNames = targetNames; - gltfMaterial.pbrMetallicRoughness.baseColorFactor = color; + } } - if ( material instanceof THREE.MeshStandardMaterial ) { + var extras = ( Object.keys( geometry.userData ).length > 0 ) ? serializeUserData( geometry ) : undefined; - gltfMaterial.pbrMetallicRoughness.metallicFactor = material.metalness; - gltfMaterial.pbrMetallicRoughness.roughnessFactor = material.roughness; + var forceIndices = options.forceIndices; + var isMultiMaterial = Array.isArray( mesh.material ); + + if ( isMultiMaterial && geometry.groups.length === 0 ) return null; - } else { + if ( ! forceIndices && geometry.index === null && isMultiMaterial ) { - gltfMaterial.pbrMetallicRoughness.metallicFactor = 0.5; - gltfMaterial.pbrMetallicRoughness.roughnessFactor = 0.5; + // temporal workaround. + console.warn( 'THREE.GLTFExporter: Creating index for non-indexed multi-material mesh.' ); + forceIndices = true; } - // pbrMetallicRoughness.baseColorTexture - if ( material.map ) { + var didForceIndices = false; - gltfMaterial.pbrMetallicRoughness.baseColorTexture = { + if ( geometry.index === null && forceIndices ) { - index: processTexture( material.map ) + var indices = []; - }; + for ( var i = 0, il = geometry.attributes.position.count; i < il; i ++ ) { - } + indices[ i ] = i; + + } - if ( material instanceof THREE.MeshBasicMaterial || - material instanceof THREE.LineBasicMaterial || - material instanceof THREE.PointsMaterial ) { + geometry.setIndex( indices ); - } else { + didForceIndices = true; - // emissiveFactor - var emissive = material.emissive.clone().multiplyScalar( material.emissiveIntensity ).toArray(); + } - if ( !equalArray( emissive, [ 0, 0, 0 ] ) ) { + var materials = isMultiMaterial ? mesh.material : [ mesh.material ]; + var groups = isMultiMaterial ? geometry.groups : [ { materialIndex: 0, start: undefined, count: undefined } ]; - gltfMaterial.emissiveFactor = emissive; + for ( var i = 0, il = groups.length; i < il; i ++ ) { - } + var primitive = { + mode: mode, + attributes: attributes, + }; - // emissiveTexture - if ( material.emissiveMap ) { + if ( extras ) primitive.extras = extras; - gltfMaterial.emissiveTexture = { + if ( targets.length > 0 ) primitive.targets = targets; - index: processTexture( material.emissiveMap ) + if ( geometry.index !== null ) { - }; + primitive.indices = processAccessor( geometry.index, geometry, groups[ i ].start, groups[ i ].count ); } - } + var material = processMaterial( materials[ groups[ i ].materialIndex ] ); - // normalTexture - if ( material.normalMap ) { + if ( material !== null ) { - gltfMaterial.normalTexture = { + primitive.material = material; - index: processTexture( material.normalMap ) + } - }; + primitives.push( primitive ); - if ( material.normalScale.x !== -1 ) { + } - if ( material.normalScale.x !== material.normalScale.y ) { + if ( didForceIndices ) { - console.warn('GLTFExporter: Normal scale components are different, ignoring Y and exporting X'); + geometry.setIndex( null ); - } + } - gltfMaterial.normalTexture.scale = material.normalScale.x; + gltfMesh.primitives = primitives; - } + if ( ! outputJSON.meshes ) { - } + outputJSON.meshes = []; - // occlusionTexture - if ( material.aoMap ) { + } - gltfMaterial.occlusionTexture = { + outputJSON.meshes.push( gltfMesh ); - index: processTexture( material.aoMap ) + return outputJSON.meshes.length - 1; - }; + } - if ( material.aoMapIntensity !== 1.0 ) { + /** + * Process camera + * @param {THREE.Camera} camera Camera to process + * @return {Integer} Index of the processed mesh in the "camera" array + */ + function processCamera( camera ) { - gltfMaterial.occlusionTexture.strength = material.aoMapIntensity; + if ( ! outputJSON.cameras ) { - } + outputJSON.cameras = []; } - // alphaMode - if ( material.transparent ) { + var isOrtho = camera.isOrthographicCamera; - gltfMaterial.alphaMode = 'MASK'; // @FIXME We should detect MASK or BLEND + var gltfCamera = { - if ( material.alphaTest !== 0.5 ) { + type: isOrtho ? 'orthographic' : 'perspective' - gltfMaterial.alphaCutoff = material.alphaTest; + }; - } + if ( isOrtho ) { - } + gltfCamera.orthographic = { - // doubleSided - if ( material.side === THREE.DoubleSide ) { + xmag: camera.right * 2, + ymag: camera.top * 2, + zfar: camera.far <= 0 ? 0.001 : camera.far, + znear: camera.near < 0 ? 0 : camera.near - gltfMaterial.doubleSided = true; + }; - } + } else { - if ( material.name ) { + gltfCamera.perspective = { - gltfMaterial.name = material.name; + aspectRatio: camera.aspect, + yfov: THREE.Math.degToRad( camera.fov ) / camera.aspect, + zfar: camera.far <= 0 ? 0.001 : camera.far, + znear: camera.near < 0 ? 0 : camera.near + + }; } - outputJSON.materials.push( gltfMaterial ); + if ( camera.name !== '' ) { - var index = outputJSON.materials.length - 1; - cachedData.materials[ material.uuid ] = index; + gltfCamera.name = camera.type; - return index; + } + + outputJSON.cameras.push( gltfCamera ); + + return outputJSON.cameras.length - 1; } /** - * Process mesh - * @param {THREE.Mesh} mesh Mesh to process - * @return {Integer} Index of the processed mesh in the "meshes" array + * Creates glTF animation entry from AnimationClip object. + * + * Status: + * - Only properties listed in PATH_PROPERTIES may be animated. + * + * @param {THREE.AnimationClip} clip + * @param {THREE.Object3D} root + * @return {number} */ - function processMesh( mesh ) { + function processAnimation( clip, root ) { - if ( !outputJSON.meshes ) { + if ( ! outputJSON.animations ) { - outputJSON.meshes = []; + outputJSON.animations = []; } - var geometry = mesh.geometry; - var mode; + var channels = []; + var samplers = []; - // Use the correct mode - if ( mesh instanceof THREE.LineSegments ) { + for ( var i = 0; i < clip.tracks.length; ++ i ) { - mode = WEBGL_CONSTANTS.LINES; + var track = clip.tracks[ i ]; + var trackBinding = THREE.PropertyBinding.parseTrackName( track.name ); + var trackNode = THREE.PropertyBinding.findNode( root, trackBinding.nodeName ); + var trackProperty = PATH_PROPERTIES[ trackBinding.propertyName ]; - } else if ( mesh instanceof THREE.LineLoop ) { + if ( trackBinding.objectName === 'bones' ) { - mode = WEBGL_CONSTANTS.LINE_LOOP; + if ( trackNode.isSkinnedMesh === true ) { - } else if ( mesh instanceof THREE.Line ) { + trackNode = trackNode.skeleton.getBoneByName( trackBinding.objectIndex ); - mode = WEBGL_CONSTANTS.LINE_STRIP; + } else { - } else if ( mesh instanceof THREE.Points ) { + trackNode = undefined; - mode = WEBGL_CONSTANTS.POINTS; + } - } else { + } - if ( !geometry.isBufferGeometry ) { + if ( ! trackNode || ! trackProperty ) { - var geometryTemp = new THREE.BufferGeometry(); - geometryTemp.fromGeometry( geometry ); - geometry = geometryTemp; + console.warn( 'THREE.GLTFExporter: Could not export animation track "%s".', track.name ); + return null; } - if ( mesh.drawMode === THREE.TriangleFanDrawMode ) { + var inputItemSize = 1; + var outputItemSize = track.values.length / track.times.length; - console.warn( 'GLTFExporter: TriangleFanDrawMode and wireframe incompatible.' ); - mode = WEBGL_CONSTANTS.TRIANGLE_FAN; + if ( trackProperty === PATH_PROPERTIES.morphTargetInfluences ) { - } else if ( mesh.drawMode === THREE.TriangleStripDrawMode ) { + if ( trackNode.morphTargetInfluences.length !== 1 && + trackBinding.propertyIndex !== undefined ) { - mode = mesh.material.wireframe ? WEBGL_CONSTANTS.LINE_STRIP : WEBGL_CONSTANTS.TRIANGLE_STRIP; + console.warn( 'THREE.GLTFExporter: Skipping animation track "%s". ' + + 'Morph target keyframe tracks must target all available morph targets ' + + 'for the given mesh.', track.name ); + continue; - } else { + } - mode = mesh.material.wireframe ? WEBGL_CONSTANTS.LINES : WEBGL_CONSTANTS.TRIANGLES; + outputItemSize /= trackNode.morphTargetInfluences.length; } - } - - var gltfMesh = { - primitives: [ - { - mode: mode, - attributes: {}, - } - ] - }; + var interpolation; - var material = processMaterial( mesh.material ); - if ( material !== null ) { + // @TODO export CubicInterpolant(InterpolateSmooth) as CUBICSPLINE - gltfMesh.primitives[ 0 ].material = material; + // Detecting glTF cubic spline interpolant by checking factory method's special property + // GLTFCubicSplineInterpolant is a custom interpolant and track doesn't return + // valid value from .getInterpolation(). + if ( track.createInterpolant.isInterpolantFactoryMethodGLTFCubicSpline === true ) { - } + interpolation = 'CUBICSPLINE'; + // itemSize of CUBICSPLINE keyframe is 9 + // (VEC3 * 3: inTangent, splineVertex, and outTangent) + // but needs to be stored as VEC3 so dividing by 3 here. + outputItemSize /= 3; - if ( geometry.index ) { + } else if ( track.getInterpolation() === THREE.InterpolateDiscrete ) { - gltfMesh.primitives[ 0 ].indices = processAccessor( geometry.index, geometry ); + interpolation = 'STEP'; - } + } else { - // We've just one primitive per mesh - var gltfAttributes = gltfMesh.primitives[ 0 ].attributes; - var attributes = geometry.attributes; + interpolation = 'LINEAR'; - // Conversion between attributes names in threejs and gltf spec - var nameConversion = { + } - uv: 'TEXCOORD_0', - uv2: 'TEXCOORD_1', - color: 'COLOR_0', - skinWeight: 'WEIGHTS_0', - skinIndex: 'JOINTS_0' + samplers.push( { - }; + input: processAccessor( new THREE.BufferAttribute( track.times, inputItemSize ) ), + output: processAccessor( new THREE.BufferAttribute( track.values, outputItemSize ) ), + interpolation: interpolation - // @QUESTION Detect if .vertexColors = THREE.VertexColors? - // For every attribute create an accessor - for ( var attributeName in geometry.attributes ) { + } ); - var attribute = geometry.attributes[ attributeName ]; - attributeName = nameConversion[ attributeName ] || attributeName.toUpperCase(); - gltfAttributes[ attributeName ] = processAccessor( attribute, geometry ); + channels.push( { - } + sampler: samplers.length - 1, + target: { + node: nodeMap.get( trackNode ), + path: trackProperty + } - outputJSON.meshes.push( gltfMesh ); + } ); - return outputJSON.meshes.length - 1; - } + } - /** - * Process camera - * @param {THREE.Camera} camera Camera to process - * @return {Integer} Index of the processed mesh in the "camera" array - */ - function processCamera( camera ) { + outputJSON.animations.push( { - if ( !outputJSON.cameras ) { + name: clip.name || 'clip_' + outputJSON.animations.length, + samplers: samplers, + channels: channels - outputJSON.cameras = []; + } ); - } + return outputJSON.animations.length - 1; - var isOrtho = camera instanceof THREE.OrthographicCamera; + } - var gltfCamera = { + function processSkin( object ) { - type: isOrtho ? 'orthographic' : 'perspective' + var node = outputJSON.nodes[ nodeMap.get( object ) ]; - }; + var skeleton = object.skeleton; + var rootJoint = object.skeleton.bones[ 0 ]; - if ( isOrtho ) { + if ( rootJoint === undefined ) return null; - gltfCamera.orthographic = { + var joints = []; + var inverseBindMatrices = new Float32Array( skeleton.bones.length * 16 ); - xmag: camera.right * 2, - ymag: camera.top * 2, - zfar: camera.far, - znear: camera.near + for ( var i = 0; i < skeleton.bones.length; ++ i ) { - }; + joints.push( nodeMap.get( skeleton.bones[ i ] ) ); - } else { + skeleton.boneInverses[ i ].toArray( inverseBindMatrices, i * 16 ); - gltfCamera.perspective = { + } - aspectRatio: camera.aspect, - yfov: THREE.Math.degToRad( camera.fov ) / camera.aspect, - zfar: camera.far, - znear: camera.near + if ( outputJSON.skins === undefined ) { - }; + outputJSON.skins = []; } - if ( camera.name ) { + outputJSON.skins.push( { - gltfCamera.name = camera.type; + inverseBindMatrices: processAccessor( new THREE.BufferAttribute( inverseBindMatrices, 16 ) ), + joints: joints, + skeleton: nodeMap.get( rootJoint ) - } + } ); - outputJSON.cameras.push( gltfCamera ); + var skinIndex = node.skin = outputJSON.skins.length - 1; + + return skinIndex; - return outputJSON.cameras.length - 1; } /** @@ -761,16 +1514,16 @@ THREE.GLTFExporter.prototype = { * @param {THREE.Object3D} node Object3D to processNode * @return {Integer} Index of the node in the nodes list */ - function processNode ( object ) { + function processNode( object ) { - if ( object instanceof THREE.Light ) { + if ( object.isLight ) { console.warn( 'GLTFExporter: Unsupported node type:', object.constructor.name ); - return false; + return null; } - if ( !outputJSON.nodes ) { + if ( ! outputJSON.nodes ) { outputJSON.nodes = []; @@ -784,19 +1537,19 @@ THREE.GLTFExporter.prototype = { var position = object.position.toArray(); var scale = object.scale.toArray(); - if ( !equalArray( rotation, [ 0, 0, 0, 1 ] ) ) { + if ( ! equalArray( rotation, [ 0, 0, 0, 1 ] ) ) { gltfNode.rotation = rotation; } - if ( !equalArray( position, [ 0, 0, 0 ] ) ) { + if ( ! equalArray( position, [ 0, 0, 0 ] ) ) { - gltfNode.position = position; + gltfNode.translation = position; } - if ( !equalArray( scale, [ 1, 1, 1 ] ) ) { + if ( ! equalArray( scale, [ 1, 1, 1 ] ) ) { gltfNode.scale = scale; @@ -805,7 +1558,7 @@ THREE.GLTFExporter.prototype = { } else { object.updateMatrix(); - if (! equalArray( object.matrix.elements, [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ] ) ) { + if ( ! equalArray( object.matrix.elements, [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ] ) ) { gltfNode.matrix = object.matrix.elements; @@ -813,35 +1566,38 @@ THREE.GLTFExporter.prototype = { } - if ( object.name ) { + // We don't export empty strings name because it represents no-name in Three.js. + if ( object.name !== '' ) { - gltfNode.name = object.name; + gltfNode.name = String( object.name ); } if ( object.userData && Object.keys( object.userData ).length > 0 ) { - try { + gltfNode.extras = serializeUserData( object ); + + } + + if ( object.isMesh || object.isLine || object.isPoints ) { - gltfNode.extras = JSON.parse( JSON.stringify( object.userData ) ); + var mesh = processMesh( object ); - } catch (e) { + if ( mesh !== null ) { - throw new Error( 'GLTFExporter: userData can\'t be serialized' ); + gltfNode.mesh = mesh; } - } + } else if ( object.isCamera ) { - if ( object instanceof THREE.Mesh || - object instanceof THREE.Line || - object instanceof THREE.Points ) { + gltfNode.camera = processCamera( object ); - gltfNode.mesh = processMesh( object ); + } - } else if ( object instanceof THREE.Camera ) { + if ( object.isSkinnedMesh ) { - gltfNode.camera = processCamera( object ); + skins.push( object ); } @@ -857,7 +1613,7 @@ THREE.GLTFExporter.prototype = { var node = processNode( child ); - if ( node !== false ) { + if ( node !== null ) { children.push( node ); @@ -878,7 +1634,10 @@ THREE.GLTFExporter.prototype = { outputJSON.nodes.push( gltfNode ); - return outputJSON.nodes.length - 1; + var nodeIndex = outputJSON.nodes.length - 1; + nodeMap.set( object, nodeIndex ); + + return nodeIndex; } @@ -888,7 +1647,7 @@ THREE.GLTFExporter.prototype = { */ function processScene( scene ) { - if ( !outputJSON.scenes ) { + if ( ! outputJSON.scenes ) { outputJSON.scenes = []; outputJSON.scene = 0; @@ -901,7 +1660,7 @@ THREE.GLTFExporter.prototype = { }; - if ( scene.name ) { + if ( scene.name !== '' ) { gltfScene.name = scene.name; @@ -919,7 +1678,7 @@ THREE.GLTFExporter.prototype = { var node = processNode( child ); - if ( node !== false ) { + if ( node !== null ) { nodes.push( node ); @@ -941,12 +1700,12 @@ THREE.GLTFExporter.prototype = { * Creates a THREE.Scene to hold a list of objects and parse it * @param {Array} objects List of objects to process */ - function processObjects ( objects ) { + function processObjects( objects ) { var scene = new THREE.Scene(); scene.name = 'AuxScene'; - for ( var i = 0; i < objects.length; i++ ) { + for ( var i = 0; i < objects.length; i ++ ) { // We push directly to children instead of calling `add` to prevent // modify the .parent and break its original scene and hierarchy @@ -963,7 +1722,8 @@ THREE.GLTFExporter.prototype = { input = input instanceof Array ? input : [ input ]; var objectsWithoutScene = []; - for ( var i = 0; i < input.length; i++ ) { + + for ( var i = 0; i < input.length; i ++ ) { if ( input[ i ] instanceof THREE.Scene ) { @@ -983,34 +1743,114 @@ THREE.GLTFExporter.prototype = { } + for ( var i = 0; i < skins.length; ++ i ) { + + processSkin( skins[ i ] ); + + } + + for ( var i = 0; i < options.animations.length; ++ i ) { + + processAnimation( options.animations[ i ], input[ 0 ] ); + + } + } processInput( input ); - // Generate buffer - // Create a new blob with all the dataviews from the buffers - var blob = new Blob( dataViews, { type: 'application/octet-stream' } ); + Promise.all( pending ).then( function () { - // Update the bytlength of the only main buffer and update the uri with the base64 representation of it - if ( outputJSON.buffers && outputJSON.buffers.length > 0 ) { + // Merge buffers. + var blob = new Blob( buffers, { type: 'application/octet-stream' } ); - outputJSON.buffers[ 0 ].byteLength = blob.size; - var objectURL = URL.createObjectURL( blob ); + // Declare extensions. + var extensionsUsedList = Object.keys( extensionsUsed ); + if ( extensionsUsedList.length > 0 ) outputJSON.extensionsUsed = extensionsUsedList; - var reader = new window.FileReader(); - reader.readAsDataURL( blob ); - reader.onloadend = function() { + if ( outputJSON.buffers && outputJSON.buffers.length > 0 ) { - var base64data = reader.result; - outputJSON.buffers[ 0 ].uri = base64data; - onDone( outputJSON ); + // Update bytelength of the single buffer. + outputJSON.buffers[ 0 ].byteLength = blob.size; - }; + var reader = new window.FileReader(); - } else { + if ( options.binary === true ) { - onDone ( outputJSON ); + // https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#glb-file-format-specification + + var GLB_HEADER_BYTES = 12; + var GLB_HEADER_MAGIC = 0x46546C67; + var GLB_VERSION = 2; + + var GLB_CHUNK_PREFIX_BYTES = 8; + var GLB_CHUNK_TYPE_JSON = 0x4E4F534A; + var GLB_CHUNK_TYPE_BIN = 0x004E4942; + + reader.readAsArrayBuffer( blob ); + reader.onloadend = function () { + + // Binary chunk. + var binaryChunk = getPaddedArrayBuffer( reader.result ); + var binaryChunkPrefix = new DataView( new ArrayBuffer( GLB_CHUNK_PREFIX_BYTES ) ); + binaryChunkPrefix.setUint32( 0, binaryChunk.byteLength, true ); + binaryChunkPrefix.setUint32( 4, GLB_CHUNK_TYPE_BIN, true ); + + // JSON chunk. + var jsonChunk = getPaddedArrayBuffer( stringToArrayBuffer( JSON.stringify( outputJSON ) ), 0x20 ); + var jsonChunkPrefix = new DataView( new ArrayBuffer( GLB_CHUNK_PREFIX_BYTES ) ); + jsonChunkPrefix.setUint32( 0, jsonChunk.byteLength, true ); + jsonChunkPrefix.setUint32( 4, GLB_CHUNK_TYPE_JSON, true ); + + // GLB header. + var header = new ArrayBuffer( GLB_HEADER_BYTES ); + var headerView = new DataView( header ); + headerView.setUint32( 0, GLB_HEADER_MAGIC, true ); + headerView.setUint32( 4, GLB_VERSION, true ); + var totalByteLength = GLB_HEADER_BYTES + + jsonChunkPrefix.byteLength + jsonChunk.byteLength + + binaryChunkPrefix.byteLength + binaryChunk.byteLength; + headerView.setUint32( 8, totalByteLength, true ); + + var glbBlob = new Blob( [ + header, + jsonChunkPrefix, + jsonChunk, + binaryChunkPrefix, + binaryChunk + ], { type: 'application/octet-stream' } ); + + var glbReader = new window.FileReader(); + glbReader.readAsArrayBuffer( glbBlob ); + glbReader.onloadend = function () { + + onDone( glbReader.result ); + + }; + + }; + + } else { + + reader.readAsDataURL( blob ); + reader.onloadend = function () { + + var base64data = reader.result; + outputJSON.buffers[ 0 ].uri = base64data; + onDone( outputJSON ); + + }; + + } + + } else { + + onDone( outputJSON ); + + } + + } ); - } } + };