Browse Source

GLTFExporter: Support individual morph target animation.

- Adds AnimationUtils.insertKeyframe().
- Adds KeyframeTrack.clone().
- Enables GLTFExporter to automatically merge animation tracks affecting
individual morph targets.
Don McCurdy 7 years ago
parent
commit
8f8daa1a94

+ 6 - 0
docs/api/en/animation/AnimationUtils.html

@@ -38,6 +38,12 @@
 		Returns an array by which times and values can be sorted.
 		</p>
 
+		<h3>[method:Number insertKeyframe]( [param:KeyframeTrack track], [param:Number time] )</h3>
+		<p>
+		Inserts a new keyframe into the track at the given time, if it doesn't already exist. Keyframe
+		values are interpolated from existing keyframes. Returns the keyframe index.
+		</p>
+
 		<h3>[method:Boolean isTypedArray]( object )</h3>
 		<p>
 		Returns *true* if the object is a typed array.

+ 5 - 0
docs/api/en/animation/KeyframeTrack.html

@@ -160,6 +160,11 @@
 		<h2>Methods</h2>
 
 
+		<h3>[method:KeyframeTrack clone]()</h3>
+		<p>
+			Returns a copy of this track.
+		</p>
+
 		<h3>[method:null createInterpolant]()</h3>
 		<p>
 			Creates a [page:LinearInterpolant LinearInterpolant], [page:CubicInterpolant CubicInterpolant]

+ 102 - 12
examples/js/exporters/GLTFExporter.js

@@ -286,6 +286,105 @@ THREE.GLTFExporter.prototype = {
 
 		}
 
+		/**
+		 * Merges KeyframeTracks that animate morph targets on a given object. In
+		 * three.js it is possible to have separate tracks for each morph target,
+		 * but in glTF a clip must animate all morph targets simultaneously.
+		 *
+		 * @param  {Array<KeyframeTrack>} sourceTracks
+		 * @param  {THREE.Object3D} root
+		 * @return {Array<KeyframeTrack>}
+		 */
+		function mergeMorphTargetTracks( sourceTracks, root ) {
+
+			var tracks = [];
+			var mergedTracks = {};
+
+			for ( var i = 0; i < sourceTracks.length; ++ i ) {
+
+				var sourceTrack = sourceTracks[ i ];
+				var sourceTrackBinding = THREE.PropertyBinding.parseTrackName( sourceTrack.name );
+				var sourceTrackNode = THREE.PropertyBinding.findNode( root, sourceTrackBinding.nodeName );
+
+				if ( sourceTrackBinding.propertyName !== 'morphTargetInfluences' ) {
+
+					// Tracks that don't affect morph targets can be kept as-is.
+					tracks.push( sourceTrack );
+					continue;
+
+				}
+
+				if ( sourceTrack.createInterpolant !== sourceTrack.InterpolantFactoryMethodDiscrete
+					&& sourceTrack.createInterpolant !== sourceTrack.InterpolantFactoryMethodLinear ) {
+
+					if ( sourceTrack.createInterpolant.isInterpolantFactoryMethodGLTFCubicSpline ) {
+
+						// This should never happen, because glTF morph target animations
+						// affect all targets already.
+						throw new Error( 'THREE.GLTFExporter: Cannot merge tracks with glTF CUBICSPLINE interpolation.' );
+
+					}
+
+					console.warn( 'THREE.GLTFExporter: Morph target interpolation mode not yet supported. Using LINEAR instead.' );
+
+					sourceTrack = sourceTrack.clone();
+					sourceTrack.setInterpolation( THREE.InterpolateLinear );
+
+				}
+
+				var targetCount = sourceTrackNode.morphTargetInfluences.length;
+				var targetIndex = sourceTrackNode.morphTargetDictionary[ sourceTrackBinding.propertyIndex ];
+
+				if ( targetIndex === undefined ) {
+
+					throw new Error( 'THREE.AnimationUtils: Morph target name not found: ' + sourceTrackBinding.propertyIndex );
+
+				}
+
+				var mergedTrack;
+
+				// If this is the first time we've seen this object, create a new
+				// track to store merged keyframe data for each morph target.
+				if ( mergedTracks[ sourceTrackNode.uuid ] === undefined ) {
+
+					mergedTrack = sourceTrack.clone();
+
+					var values = new mergedTrack.ValueBufferType( targetCount * mergedTrack.times.length );
+
+					for ( var j = 0; j < mergedTrack.times.length; j ++ ) {
+
+						values[ j * targetCount + targetIndex ] = mergedTrack.values[ j ];
+
+					}
+
+					mergedTrack.name = '.morphTargetInfluences';
+					mergedTrack.values = values;
+
+					mergedTracks[ sourceTrackNode.uuid ] = mergedTrack;
+					tracks.push( mergedTrack );
+
+					continue;
+
+				}
+
+				var sourceKeyframeIndex = 0;
+				var mergedKeyframeIndex = 0;
+
+				mergedTrack = mergedTracks[ sourceTrackNode.uuid ];
+
+				for ( var j = 0; j < sourceTrack.times.length; j ++ ) {
+
+					var keyframeIndex = THREE.AnimationUtils.insertKeyframe( mergedTrack, sourceTrack.times[ j ] );
+					mergedTrack.values[ keyframeIndex * targetCount + targetIndex ] = sourceTrack.values[ j ];
+
+				}
+
+			}
+
+			return tracks;
+
+		}
+
 		/**
 		 * 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
@@ -1408,12 +1507,13 @@ THREE.GLTFExporter.prototype = {
 
 			}
 
+			var tracks = mergeMorphTargetTracks( clip.tracks, root );
 			var channels = [];
 			var samplers = [];
 
-			for ( var i = 0; i < clip.tracks.length; ++ i ) {
+			for ( var i = 0; i < tracks.length; ++ i ) {
 
-				var track = clip.tracks[ i ];
+				var track = tracks[ i ];
 				var trackBinding = THREE.PropertyBinding.parseTrackName( track.name );
 				var trackNode = THREE.PropertyBinding.findNode( root, trackBinding.nodeName );
 				var trackProperty = PATH_PROPERTIES[ trackBinding.propertyName ];
@@ -1444,16 +1544,6 @@ THREE.GLTFExporter.prototype = {
 
 				if ( trackProperty === PATH_PROPERTIES.morphTargetInfluences ) {
 
-					if ( trackNode.morphTargetInfluences.length !== 1 &&
-						trackBinding.propertyIndex !== undefined ) {
-
-						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;
-
-					}
-
 					outputItemSize /= trackNode.morphTargetInfluences.length;
 
 				}

+ 84 - 0
src/animation/AnimationUtils.js

@@ -158,6 +158,90 @@ var AnimationUtils = {
 
 		}
 
+	},
+
+	insertKeyframe: function ( track, time ) {
+
+		var tolerance = 0.001; // 1ms
+		var valueSize = track.getValueSize();
+
+		var times = new track.TimeBufferType( track.times.length + 1 );
+		var values = new track.ValueBufferType( track.values.length + valueSize );
+		var interpolant = track.createInterpolant( new track.ValueBufferType( valueSize ) );
+
+		var index;
+
+		if ( track.times.length === 0 ) {
+
+			times[ 0 ] = time;
+
+			for ( var i = 0; i < valueSize; i ++ ) {
+
+				values[ i ] = 0;
+
+			}
+
+			index = 0;
+
+		} else if ( time < track.times[ 0 ] ) {
+
+			if ( Math.abs( track.times[ 0 ] - time ) < tolerance ) return 0;
+
+			times[ 0 ] = time;
+			times.set( track.times, 1 );
+
+			values.set( interpolant.evaluate( time ), 0 );
+			values.set( track.values, valueSize );
+
+			index = 0;
+
+		} else if ( time > track.times[ track.times.length - 1 ] ) {
+
+			if ( Math.abs( track.times[ track.times.length - 1 ] - time ) < tolerance ) {
+
+				return track.times.length - 1;
+
+			}
+
+			times[ times.length - 1 ] = time;
+			times.set( track.times, 0 );
+
+			values.set( track.values, 0 );
+			values.set( interpolant.evaluate( time ), track.values.length );
+
+			index = times.length - 1;
+
+		} else {
+
+			for ( var i = 0; i < track.times.length; i ++ ) {
+
+				if ( Math.abs( track.times[ i ] - time ) < tolerance ) return i;
+
+				if ( track.times[ i ] < time && track.times[ i + 1 ] > time ) {
+
+					times.set( track.times.slice( 0, i + 1 ), 0 );
+					times[ i + 1 ] = time;
+					times.set( track.times.slice( i + 1 ), i + 2 );
+
+					values.set( track.values.slice( 0, ( i + 1 ) * valueSize ), 0 );
+					values.set( interpolant.evaluate( time ), ( i + 1 ) * valueSize );
+					values.set( track.values.slice( ( i + 1 ) * valueSize ), ( i + 2 ) * valueSize );
+
+					index = i + 1;
+
+					break;
+
+				}
+
+			}
+
+		}
+
+		track.times = times;
+		track.values = values;
+
+		return index;
+
 	}
 
 };

+ 15 - 0
src/animation/KeyframeTrack.js

@@ -447,6 +447,21 @@ Object.assign( KeyframeTrack.prototype, {
 
 		return this;
 
+	},
+
+	clone: function () {
+
+		var times = AnimationUtils.arraySlice( this.times, 0 );
+		var values = AnimationUtils.arraySlice( this.values, 0 );
+
+		var TypedKeyframeTrack = this.constructor;
+		var track = new TypedKeyframeTrack( this.name, times, values );
+
+		// Interpolant argument to constructor is not saved, so copy the factory method directly.
+		track.createInterpolant = this.createInterpolant;
+
+		return track;
+
 	}
 
 } );

+ 65 - 0
test/unit/example/exporters/GLTFExporter.tests.js

@@ -156,6 +156,71 @@ export default QUnit.module( 'Exporters', () => {
 
     } );
 
+    QUnit.test( 'parse - individual morph targets', ( assert ) => {
+
+      var done = assert.async();
+
+      // Creates a geometry with four (4) morph targets, three (3) of which are
+      // animated by an animation clip. Because glTF requires all morph targets
+      // to be animated in unison, the exporter should write an empty track for
+      // the fourth target.
+
+      var geometry = new THREE.BufferGeometry();
+      var position = new THREE.BufferAttribute( new Float32Array( [ 0, 0, 0, 0, 0, 1, 1, 0, 1 ] ), 3 );
+      geometry.addAttribute( 'position',  position );
+      geometry.morphAttributes.position = [ position, position, position, position ];
+
+      var mesh = new THREE.Mesh( geometry );
+      mesh.morphTargetDictionary.a = 0;
+      mesh.morphTargetDictionary.b = 1;
+      mesh.morphTargetDictionary.c = 2;
+      mesh.morphTargetDictionary.d = 3;
+
+      var timesA =  [ 0, 1, 2 ];
+      var timesB =  [       2, 3, 4 ];
+      var timesC =  [             4, 5, 6 ];
+      var valuesA = [ 0, 1, 0 ];
+      var valuesB = [       0, 1, 0 ];
+      var valuesC = [             0, 1, 0 ];
+      var trackA = new THREE.VectorKeyframeTrack( '.morphTargetInfluences[a]', timesA, valuesA, THREE.InterpolateLinear );
+      var trackB = new THREE.VectorKeyframeTrack( '.morphTargetInfluences[b]', timesB, valuesB, THREE.InterpolateLinear );
+      var trackC = new THREE.VectorKeyframeTrack( '.morphTargetInfluences[c]', timesC, valuesC, THREE.InterpolateLinear );
+
+      var clip = new THREE.AnimationClip( 'clip1', undefined, [ trackA, trackB, trackC ] );
+
+      var exporter = new THREE.GLTFExporter();
+
+      exporter.parse( mesh, function ( gltf ) {
+
+        assert.equal( 1, gltf.animations.length, 'one animation' );
+        assert.equal( 1, gltf.animations[ 0 ].channels.length, 'one channel' );
+        assert.equal( 1, gltf.animations[ 0 ].samplers.length, 'one sampler' );
+
+        var channel = gltf.animations[ 0 ].channels[ 0 ];
+        var sampler = gltf.animations[ 0 ].samplers[ 0 ];
+
+        assert.smartEqual( channel, { sampler: 0, target: { node: 0, path: 'weights' } } );
+        assert.equal( sampler.interpolation, 'LINEAR' );
+
+        var input = gltf.accessors[ sampler.input ];
+        var output = gltf.accessors[ sampler.output ];
+
+        assert.equal( input.count, 7 );
+        assert.equal( input.type, 'SCALAR' );
+        assert.smartEqual( input.min, [ 0 ] );
+        assert.smartEqual( input.max, [ 6 ] );
+
+        assert.equal( output.count, 28 ); // 4 targets * 7 frames
+        assert.equal( output.type, 'SCALAR' );
+        assert.smartEqual( output.min, [ 0 ] );
+        assert.smartEqual( output.max, [ 1 ] );
+
+        done();
+
+      }, { animations: [ clip ] } );
+
+    } );
+
   } );
 
 } );

+ 60 - 1
test/unit/src/animation/AnimationUtils.tests.js

@@ -1,15 +1,74 @@
 /**
- * @author TristanVALCKE / https://github.com/Itee
+ * @author Don McCurdy / https://www.donmccurdy.com
  */
 /* global QUnit */
 
 import { AnimationUtils } from '../../../../src/animation/AnimationUtils';
+import { VectorKeyframeTrack } from '../../../../src/animation/tracks/VectorKeyframeTrack';
+import {
+	InterpolateLinear,
+	InterpolateSmooth,
+	InterpolateDiscrete
+} from '../../../../src/constants.js';
 
 export default QUnit.module( 'Animation', () => {
 
 	QUnit.module( 'AnimationUtils', () => {
 
 		// PUBLIC STUFF
+		QUnit.test( 'insertKeyframe', ( assert ) => {
+
+			var track;
+			var index;
+
+			function createTrack () {
+				return new VectorKeyframeTrack(
+					'foo.bar',
+					[ 5,    10,   15,   20,   25,   30 ],
+					[ 0, 5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0 ],
+					InterpolateLinear
+				);
+			}
+
+			track = createTrack();
+			index = AnimationUtils.insertKeyframe( track, 0 );
+			assert.equal( index, 0, 'prepend - index' );
+			assert.smartEqual( Array.from( track.times ), [ 0, 5, 10, 15, 20, 25, 30 ], 'prepend - time' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 0, 5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0 ], 'prepend - value' );
+
+			track = createTrack();
+			index = AnimationUtils.insertKeyframe( track, 7.5 );
+			assert.equal( index, 1, 'insert - index (linear)' );
+			assert.smartEqual( Array.from( track.times ), [ 5, 7.5, 10, 15, 20, 25, 30 ], 'insert - time (linear)' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 0.5, 4.5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0 ], 'insert - value (linear)' );
+
+			track = createTrack();
+			track.setInterpolation( InterpolateDiscrete );
+			index = AnimationUtils.insertKeyframe( track, 16 );
+			assert.equal( index, 3, 'insert - index (linear)' );
+			assert.smartEqual( Array.from( track.times ), [ 5, 10, 15, 16, 20, 25, 30 ], 'insert - time (discrete)' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 1, 4, 2, 3, 2, 3, 3, 2, 4, 1, 5, 0 ], 'insert - value (discrete)' );
+
+			track = createTrack();
+			index = AnimationUtils.insertKeyframe( track, 100 );
+			assert.equal( index, 6, 'append - index' );
+			assert.smartEqual( Array.from( track.times ), [ 5, 10, 15, 20, 25, 30, 100 ], 'append time' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0, 5, 0 ], 'append value' );
+
+			track = createTrack();
+			index = AnimationUtils.insertKeyframe( track, 15 );
+			assert.equal( index, 2, 'existing - index' );
+			assert.smartEqual( Array.from( track.times ), [ 5, 10, 15, 20, 25, 30 ], 'existing - time' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0 ], 'existing - value' );
+
+			track = createTrack();
+			index = AnimationUtils.insertKeyframe( track, 20.000005 );
+			assert.equal( index, 3, 'tolerance - index' );
+			assert.smartEqual( Array.from( track.times ), [ 5, 10, 15, 20, 25, 30 ], 'tolerance - time' );
+			assert.smartEqual( Array.from( track.values ), [ 0, 5, 1, 4, 2, 3, 3, 2, 4, 1, 5, 0 ], 'tolerance - value' );
+
+		} );
+
 		QUnit.todo( "arraySlice", ( assert ) => {
 
 			assert.ok( false, "everything's gonna be alright" );