UNPKG

@needle-tools/three

Version:

JavaScript 3D library

730 lines (525 loc) 20.9 kB
import { AnimationClip, ColorKeyframeTrack, InterpolateDiscrete, InterpolateLinear, NumberKeyframeTrack, PropertyBinding, QuaternionKeyframeTrack, VectorKeyframeTrack, SkinnedMesh } from 'three'; // DUPLICATED from GLTFLoader.js const ANIMATION_TARGET_TYPE = { node: 'node', material: 'material', camera: 'camera', light: 'light', }; const KHR_ANIMATION_POINTER = 'KHR_animation_pointer'; // DUPLICATED from GLTFLoader.js const INTERPOLATION = { // We use a custom interpolant (GLTFCubicSplineInterpolation) for CUBICSPLINE tracks. Each // keyframe track will be initialized with a default interpolation type, then modified. CUBICSPLINE: undefined, LINEAR: InterpolateLinear, STEP: InterpolateDiscrete }; // HACK monkey patching findNode to ensure we can map to other types required by KHR_animation_pointer. const find = PropertyBinding.findNode; const _animationPointerDebug = false; let _havePatchedPropertyBindings = false; /** * Animation Pointer Extension * * Draft Specification: https://github.com/ux3d/glTF/tree/extensions/KHR_animation_pointer/extensions/2.0/Khronos/KHR_animation_pointer */ class GLTFAnimationPointerExtension { constructor( parser ) { this.parser = parser; this.name = KHR_ANIMATION_POINTER; this.animationPointerResolver = null; } setAnimationPointerResolver( animationPointerResolver ) { this.animationPointerResolver = animationPointerResolver; return this; } _patchPropertyBindingFindNode() { if ( _havePatchedPropertyBindings ) return; _havePatchedPropertyBindings = true; // "node" is the Animator component in our case // "path" is the animated property path, just with translated material names. PropertyBinding.findNode = function ( node, path ) { if ( path.startsWith( '.materials.' ) ) { if ( _animationPointerDebug ) console.log( 'FIND', path ); const remainingPath = path.substring( '.materials.'.length ).substring( path.indexOf( '.' ) ); const nextIndex = remainingPath.indexOf( '.' ); const uuid = nextIndex < 0 ? remainingPath : remainingPath.substring( 0, nextIndex ); let res = null; node.traverse( x => { if ( res !== null || ( x.type !== 'Mesh' && x.type !== 'SkinnedMesh' ) ) return; if ( x[ 'material' ] && ( x[ 'material' ].uuid === uuid || x[ 'material' ].name === uuid ) ) { res = x[ 'material' ]; if ( _animationPointerDebug ) console.log( res, remainingPath ); if ( res !== null ) { if ( remainingPath.endsWith( '.map' ) ) res = res[ 'map' ]; else if ( remainingPath.endsWith( '.emissiveMap' ) ) res = res[ 'emissiveMap' ]; // TODO other texture slots only make sense if three.js actually supports them // (currently only .map can have repeat/offset) } } } ); return res; } else if ( path.startsWith( '.nodes.' ) || path.startsWith( '.lights.' ) || path.startsWith( '.cameras.' ) ) { const sections = path.split( '.' ); let currentTarget = undefined; for ( let i = 1; i < sections.length; i ++ ) { const val = sections[ i ]; const isUUID = val.length == 36; if ( isUUID ) { // access by UUID currentTarget = node.getObjectByProperty( 'uuid', val ); } else if ( currentTarget && currentTarget[ val ] ) { // access by index const index = Number.parseInt( val ); let key = val; if ( index >= 0 ) key = index; currentTarget = currentTarget[ key ]; if ( _animationPointerDebug ) console.log( currentTarget ); } else { // access by node name const foundNode = node.getObjectByName( val ); if ( foundNode ) currentTarget = foundNode; } } if ( ! currentTarget ) { const originalFindResult = find( node, sections[ 2 ] ); if ( ! originalFindResult ) console.warn( KHR_ANIMATION_POINTER + ': Property binding not found', path, node, node.name, sections ); return originalFindResult; } if ( _animationPointerDebug ) console.log( 'NODE', path, currentTarget ); return currentTarget; } return find( node, path ); }; } /* DUPLICATE of functionality in GLTFLoader */ loadAnimationTargetFromChannel( animationChannel ) { const target = animationChannel.target; const name = target.node !== undefined ? target.node : target.id; // NOTE: target.id is deprecated. return this.parser.getDependency( 'node', name ); } loadAnimationTargetFromChannelWithAnimationPointer( animationChannel ) { if ( ! this._havePatchedPropertyBindings ) this._patchPropertyBindingFindNode(); const target = animationChannel.target; const useExtension = target.extensions && target.extensions[ KHR_ANIMATION_POINTER ] && target.path && target.path === 'pointer'; if ( ! useExtension ) return null; let targetProperty = undefined; // check if this is a extension animation let type = ANIMATION_TARGET_TYPE.node; let targetId = undefined; if ( useExtension ) { const ext = target.extensions[ KHR_ANIMATION_POINTER ]; let path = ext.pointer; if ( _animationPointerDebug ) console.log( 'Original path: ' + path, target ); if ( ! path ) { console.warn( 'Invalid path', ext, target ); return; } if ( path.startsWith( '/materials/' ) ) type = ANIMATION_TARGET_TYPE.material; else if ( path.startsWith( '/extensions/KHR_lights_punctual/lights/' ) ) type = ANIMATION_TARGET_TYPE.light; else if ( path.startsWith( '/cameras/' ) ) type = ANIMATION_TARGET_TYPE.camera; targetId = this._tryResolveTargetId( path, type ); if ( targetId === null || isNaN( targetId ) ) { console.warn( 'Failed resolving animation node id: ' + targetId, path ); return; } else { if ( _animationPointerDebug ) console.log( 'Resolved node ID for ' + type, targetId ); } // TODO could be parsed better switch ( type ) { case ANIMATION_TARGET_TYPE.material: const pathIndex = ( '/materials/' + targetId.toString() + '/' ).length; const pathStart = path.substring( 0, pathIndex ); targetProperty = path.substring( pathIndex ); switch ( targetProperty ) { // Core Spec PBR Properties case 'pbrMetallicRoughness/baseColorFactor': targetProperty = 'color'; break; case 'pbrMetallicRoughness/roughnessFactor': targetProperty = 'roughness'; break; case 'pbrMetallicRoughness/metallicFactor': targetProperty = 'metalness'; break; case 'emissiveFactor': targetProperty = 'emissive'; break; case 'alphaCutoff': targetProperty = 'alphaTest'; break; case 'occlusionTexture/strength': targetProperty = 'aoMapIntensity'; break; case 'normalTexture/scale': targetProperty = 'normalScale'; break; // Core Spec + KHR_texture_transform case 'pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/scale': targetProperty = 'map/repeat'; break; case 'pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/offset': targetProperty = 'map/offset'; break; // UV transforms for anything but map doesn't seem to currently be supported in three.js case 'emissiveTexture/extensions/KHR_texture_transform/scale': targetProperty = 'emissiveMap/repeat'; break; case 'emissiveTexture/extensions/KHR_texture_transform/offset': targetProperty = 'emissiveMap/offset'; break; // KHR_materials_emissive_strength case 'extensions/KHR_materials_emissive_strength/emissiveStrength': targetProperty = 'emissiveIntensity'; break; // KHR_materials_transmission case 'extensions/KHR_materials_transmission/transmissionFactor': targetProperty = 'transmission'; break; // KHR_materials_ior case 'extensions/KHR_materials_ior/ior': targetProperty = 'ior'; break; // KHR_materials_volume case 'extensions/KHR_materials_volume/thicknessFactor': targetProperty = 'thickness'; break; case 'extensions/KHR_materials_volume/attenuationColor': targetProperty = 'attenuationColor'; break; case 'extensions/KHR_materials_volume/attenuationDistance': targetProperty = 'attenuationDistance'; break; // KHR_materials_iridescence case 'extensions/KHR_materials_iridescence/iridescenceFactor': targetProperty = 'iridescence'; break; case 'extensions/KHR_materials_iridescence/iridescenceIor': targetProperty = 'iridescenceIOR'; break; case 'extensions/KHR_materials_iridescence/iridescenceThicknessMinimum': targetProperty = 'iridescenceThicknessRange[0]'; break; case 'extensions/KHR_materials_iridescence/iridescenceThicknessMaximum': targetProperty = 'iridescenceThicknessRange[1]'; break; // KHR_materials_clearcoat case 'extensions/KHR_materials_clearcoat/clearcoatFactor': targetProperty = 'clearcoat'; break; case 'extensions/KHR_materials_clearcoat/clearcoatRoughnessFactor': targetProperty = 'clearcoatRoughness'; break; // KHR_materials_sheen case 'extensions/KHR_materials_sheen/sheenColorFactor': targetProperty = 'sheenColor'; break; case 'extensions/KHR_materials_sheen/sheenRoughnessFactor': targetProperty = 'sheenRoughness'; break; // KHR_materials_specular case 'extensions/KHR_materials_specular/specularFactor': targetProperty = 'specularIntensity'; break; case 'extensions/KHR_materials_specular/specularColorFactor': targetProperty = 'specularColor'; break; } path = pathStart + targetProperty; if ( _animationPointerDebug ) console.log( 'PROPERTY PATH', pathStart, targetProperty, path ); break; case ANIMATION_TARGET_TYPE.node: const pathIndexNode = ( '/nodes/' + targetId.toString() + '/' ).length; const pathStartNode = path.substring( 0, pathIndexNode ); targetProperty = path.substring( pathIndexNode ); switch ( targetProperty ) { case 'translation': targetProperty = 'position'; break; case 'rotation': targetProperty = 'quaternion'; break; case 'scale': targetProperty = 'scale'; break; case 'weights': targetProperty = 'morphTargetInfluences'; break; } path = pathStartNode + targetProperty; break; case ANIMATION_TARGET_TYPE.light: const pathIndexLight = ( '/extensions/KHR_lights_punctual/lights/' + targetId.toString() + '/' ).length; targetProperty = path.substring( pathIndexLight ); switch ( targetProperty ) { case 'color': break; case 'intensity': break; case 'spot/innerConeAngle': // TODO would need to set .penumbra, but requires calculations on every animation change (?) targetProperty = 'penumbra'; break; case 'spot/outerConeAngle': targetProperty = 'angle'; break; case 'range': targetProperty = 'distance'; break; } path = '/lights/' + targetId.toString() + '/' + targetProperty; break; case ANIMATION_TARGET_TYPE.camera: const pathIndexCamera = ( '/cameras/' + targetId.toString() + '/' ).length; const pathStartCamera = path.substring( 0, pathIndexCamera ); targetProperty = path.substring( pathIndexCamera ); switch ( targetProperty ) { case 'perspective/yfov': targetProperty = 'fov'; break; case 'perspective/znear': case 'orthographic/znear': targetProperty = 'near'; break; case 'perspective/zfar': case 'orthographic/zfar': targetProperty = 'far'; break; case 'perspective/aspect': targetProperty = 'aspect'; break; // these two write to the same target property since three.js orthographic camera only supports 'zoom'. // TODO should there be a warning for either of them? E.g. a warning for "xmag" so that "yfov" + "ymag" work by default? case 'orthographic/xmag': targetProperty = 'zoom'; break; case 'orthographic/ymag': targetProperty = 'zoom'; break; } path = pathStartCamera + targetProperty; break; } const pointerResolver = this.animationPointerResolver; if ( pointerResolver && pointerResolver.resolvePath ) { path = pointerResolver.resolvePath( path ); } target.extensions[ KHR_ANIMATION_POINTER ].pointer = path; } if ( targetId === null || isNaN( targetId ) ) { console.warn( 'Failed resolving animation node id: ' + targetId, target ); return; } let depPromise; if ( type === ANIMATION_TARGET_TYPE.node ) depPromise = this.parser.getDependency( 'node', targetId ); else if ( type === ANIMATION_TARGET_TYPE.material ) depPromise = this.parser.getDependency( 'material', targetId ); else if ( type === ANIMATION_TARGET_TYPE.light ) depPromise = this.parser.getDependency( 'light', targetId ); else if ( type === ANIMATION_TARGET_TYPE.camera ) depPromise = this.parser.getDependency( 'camera', targetId ); else console.error( 'Unhandled type', type ); return depPromise; } createAnimationTracksWithAnimationPointer( node, inputAccessor, outputAccessor, sampler, target ) { const useExtension = target.extensions && target.extensions[ KHR_ANIMATION_POINTER ] && target.path && target.path === 'pointer'; if ( ! useExtension ) return null; let animationPointerPropertyPath = target.extensions[ KHR_ANIMATION_POINTER ].pointer; if ( ! animationPointerPropertyPath ) return null; const tracks = []; animationPointerPropertyPath = animationPointerPropertyPath.replaceAll( '/', '.' ); // replace node/material/camera/light ID by UUID const parts = animationPointerPropertyPath.split( '.' ); const hasName = node.name !== undefined && node.name !== null; var nodeTargetName = hasName ? node.name : node.uuid; parts[ 2 ] = nodeTargetName; // specially handle the morphTargetInfluences property for multi-material meshes // in which case the target object is a Group and the children are the actual targets // see NE-3311 if ( parts[ 3 ] === 'morphTargetInfluences' ) { if ( node.type === 'Group' ) { if ( _animationPointerDebug ) console.log( 'Detected multi-material skinnedMesh export', animationPointerPropertyPath, node ); // We assume the children are skinned meshes for ( const ch of node.children ) { if ( ch instanceof SkinnedMesh && ch.morphTargetInfluences ) { parts[ 3 ] = ch.name; parts[ 4 ] = 'morphTargetInfluences'; __createTrack( this.parser ); } } return tracks; } } // default __createTrack( this.parser ); /** Create a new track using the current parts array */ function __createTrack( parser ) { animationPointerPropertyPath = parts.join( '.' ); if ( _animationPointerDebug ) console.log( node, inputAccessor, outputAccessor, target, animationPointerPropertyPath ); let TypedKeyframeTrack; switch ( outputAccessor.itemSize ) { case 1: TypedKeyframeTrack = NumberKeyframeTrack; break; case 2: case 3: TypedKeyframeTrack = VectorKeyframeTrack; break; case 4: if ( animationPointerPropertyPath.endsWith( '.quaternion' ) ) TypedKeyframeTrack = QuaternionKeyframeTrack; else TypedKeyframeTrack = ColorKeyframeTrack; break; } const interpolation = sampler.interpolation !== undefined ? INTERPOLATION[ sampler.interpolation ] : InterpolateLinear; let outputArray = parser._getArrayFromAccessor( outputAccessor ); // convert fov values from radians to degrees if ( animationPointerPropertyPath.endsWith( '.fov' ) ) { outputArray = outputArray.map( value => value / Math.PI * 180 ); } const track = new TypedKeyframeTrack( animationPointerPropertyPath, inputAccessor.array, outputArray, interpolation ); // Override interpolation with custom factory method. if ( interpolation === 'CUBICSPLINE' ) { parser._createCubicSplineTrackInterpolant( track ); } tracks.push( track ); // glTF has opacity animation as last component of baseColorFactor, // so we need to split that up here and create a separate opacity track if that is animated. if ( animationPointerPropertyPath && outputAccessor.itemSize === 4 && animationPointerPropertyPath.startsWith( '.materials.' ) && animationPointerPropertyPath.endsWith( '.color' ) ) { const opacityArray = new Float32Array( outputArray.length / 4 ); for ( let j = 0, jl = outputArray.length / 4; j < jl; j += 1 ) { opacityArray[ j ] = outputArray[ j * 4 + 3 ]; } const opacityTrack = new TypedKeyframeTrack( animationPointerPropertyPath.replace( '.color', '.opacity' ), inputAccessor.array, opacityArray, interpolation ); // Override interpolation with custom factory method. if ( interpolation === 'CUBICSPLINE' ) { parser._createCubicSplineTrackInterpolant( track ); } tracks.push( opacityTrack ); } } return tracks; } _tryResolveTargetId( path, type ) { let name = ''; if ( type === 'node' ) { name = path.substring( '/nodes/'.length ); } else if ( type === 'material' ) { name = path.substring( '/materials/'.length ); } else if ( type === 'light' ) { name = path.substring( '/extensions/KHR_lights_punctual/lights/'.length ); } else if ( type === 'camera' ) { name = path.substring( '/cameras/'.length ); } name = name.substring( 0, name.indexOf( '/' ) ); const index = Number.parseInt( name ); return index; } /* MOSTLY DUPLICATE of GLTFLoader.loadAnimation, but also tries to resolve KHR_animation_pointer. */ /** * Specification: https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#animations * @param {number} animationIndex * @return {Promise<AnimationClip>} */ loadAnimation( animationIndex ) { const me = this; const json = this.parser.json; const parser = this.parser; const animationDef = json.animations[ animationIndex ]; const animationName = animationDef.name ? animationDef.name : 'animation_' + animationIndex; const pendingNodes = []; const pendingInputAccessors = []; const pendingOutputAccessors = []; const pendingSamplers = []; const pendingTargets = []; for ( let i = 0, il = animationDef.channels.length; i < il; i ++ ) { const channel = animationDef.channels[ i ]; const sampler = animationDef.samplers[ channel.sampler ]; const target = channel.target; const input = animationDef.parameters !== undefined ? animationDef.parameters[ sampler.input ] : sampler.input; const output = animationDef.parameters !== undefined ? animationDef.parameters[ sampler.output ] : sampler.output; let nodeDependency = me.loadAnimationTargetFromChannelWithAnimationPointer( channel ); if ( ! nodeDependency ) nodeDependency = me.loadAnimationTargetFromChannel( channel ); pendingNodes.push( nodeDependency ); pendingInputAccessors.push( parser.getDependency( 'accessor', input ) ); pendingOutputAccessors.push( parser.getDependency( 'accessor', output ) ); pendingSamplers.push( sampler ); pendingTargets.push( target ); } return Promise.all( [ Promise.all( pendingNodes ), Promise.all( pendingInputAccessors ), Promise.all( pendingOutputAccessors ), Promise.all( pendingSamplers ), Promise.all( pendingTargets ) ] ).then( function ( dependencies ) { const nodes = dependencies[ 0 ]; const inputAccessors = dependencies[ 1 ]; const outputAccessors = dependencies[ 2 ]; const samplers = dependencies[ 3 ]; const targets = dependencies[ 4 ]; const tracks = []; for ( let i = 0, il = nodes.length; i < il; i ++ ) { const node = nodes[ i ]; const inputAccessor = inputAccessors[ i ]; const outputAccessor = outputAccessors[ i ]; const sampler = samplers[ i ]; const target = targets[ i ]; if ( node === undefined ) continue; if ( node.updateMatrix ) { node.updateMatrix(); node.matrixAutoUpdate = true; } let createdTracks = me.createAnimationTracksWithAnimationPointer( node, inputAccessor, outputAccessor, sampler, target ); if ( ! createdTracks ) createdTracks = parser._createAnimationTracks( node, inputAccessor, outputAccessor, sampler, target ); if ( createdTracks ) { for ( let k = 0; k < createdTracks.length; k ++ ) { tracks.push( createdTracks[ k ] ); } } } return new AnimationClip( animationName, undefined, tracks ); } ); } } export { GLTFAnimationPointerExtension };