// // walk.js // version 1.25 // // Created by David Wooldridge, June 2015 // Copyright © 2014 - 2015 High Fidelity, Inc. // // Animates an avatar using procedural animation techniques. // // Editing tools for animation data files available here: https://github.com/DaveDubUK/walkTools // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // // animations, reach poses, reach pose parameters, transitions, transition parameters, sounds, image/s and reference files var pathToAssets = Script.getExternalPath(Script.ExternalPaths.Assets, "procedural-animator/assets/"); Script.include([ "./libraries/walkConstants.js", "./libraries/walkFilters.js", "./libraries/walkApi.js", pathToAssets + "walkAssets.js" ]); // construct Avatar, Motion and (null) Transition var avatar = new Avatar(); var motion = new Motion(); var nullTransition = new Transition(); motion.currentTransition = nullTransition; // create settings (gets initial values from avatar) Script.include("./libraries/walkSettings.js"); // Main loop Script.update.connect(function(deltaTime) { if (motion.isLive) { // assess current locomotion state motion.assess(deltaTime); // decide which animation should be playing selectAnimation(); // advance the animation cycle/s by the correct amount/s advanceAnimations(); // update the progress of any live transitions updateTransitions(); // apply translation and rotations renderMotion(); // save this frame's parameters motion.saveHistory(); } }); // helper function for selectAnimation() function setTransition(nextAnimation, playTransitionReachPoses) { var lastTransition = motion.currentTransition; var lastAnimation = avatar.currentAnimation; // if already transitioning from a blended walk need to maintain the previous walk's direction if (lastAnimation.lastDirection) { switch(lastAnimation.lastDirection) { case FORWARDS: lastAnimation = avatar.selectedWalk; break; case BACKWARDS: lastAnimation = avatar.selectedWalkBackwards; break; case LEFT: lastAnimation = avatar.selectedSideStepLeft; break; case RIGHT: lastAnimation = avatar.selectedSideStepRight; break; } } motion.currentTransition = new Transition(nextAnimation, lastAnimation, lastTransition, playTransitionReachPoses); avatar.currentAnimation = nextAnimation; // reset default first footstep if (nextAnimation === avatar.selectedWalkBlend && lastTransition === nullTransition) { avatar.nextStep = RIGHT; } } // fly animation blending: smoothing / damping filters const FLY_BLEND_DAMPING = 50; var flyUpFilter = filter.createAveragingFilter(FLY_BLEND_DAMPING); var flyDownFilter = filter.createAveragingFilter(FLY_BLEND_DAMPING); var flyForwardFilter = filter.createAveragingFilter(FLY_BLEND_DAMPING); var flyBackwardFilter = filter.createAveragingFilter(FLY_BLEND_DAMPING); // select / blend the appropriate animation for the current state of motion function selectAnimation() { var playTransitionReachPoses = true; // select appropriate animation. create transitions where appropriate switch (motion.nextState) { case STATIC: { if (avatar.distanceFromSurface < ON_SURFACE_THRESHOLD && avatar.currentAnimation !== avatar.selectedIdle) { setTransition(avatar.selectedIdle, playTransitionReachPoses); } else if (!(avatar.distanceFromSurface < ON_SURFACE_THRESHOLD) && avatar.currentAnimation !== avatar.selectedHover) { setTransition(avatar.selectedHover, playTransitionReachPoses); } motion.state = STATIC; avatar.selectedWalkBlend.lastDirection = NONE; break; } case SURFACE_MOTION: { // walk transition reach poses are currently only specified for starting to walk forwards playTransitionReachPoses = (motion.direction === FORWARDS); var isAlreadyWalking = (avatar.currentAnimation === avatar.selectedWalkBlend); switch (motion.direction) { case FORWARDS: if (avatar.selectedWalkBlend.lastDirection !== FORWARDS) { animationOperations.deepCopy(avatar.selectedWalk, avatar.selectedWalkBlend); avatar.calibration.strideLength = avatar.selectedWalk.calibration.strideLength; } avatar.selectedWalkBlend.lastDirection = FORWARDS; break; case BACKWARDS: if (avatar.selectedWalkBlend.lastDirection !== BACKWARDS) { animationOperations.deepCopy(avatar.selectedWalkBackwards, avatar.selectedWalkBlend); avatar.calibration.strideLength = avatar.selectedWalkBackwards.calibration.strideLength; } avatar.selectedWalkBlend.lastDirection = BACKWARDS; break; case LEFT: animationOperations.deepCopy(avatar.selectedSideStepLeft, avatar.selectedWalkBlend); avatar.selectedWalkBlend.lastDirection = LEFT; avatar.calibration.strideLength = avatar.selectedSideStepLeft.calibration.strideLength; break case RIGHT: animationOperations.deepCopy(avatar.selectedSideStepRight, avatar.selectedWalkBlend); avatar.selectedWalkBlend.lastDirection = RIGHT; avatar.calibration.strideLength = avatar.selectedSideStepRight.calibration.strideLength; break; default: // condition occurs when the avi goes through the floor due to collision hull errors animationOperations.deepCopy(avatar.selectedWalk, avatar.selectedWalkBlend); avatar.selectedWalkBlend.lastDirection = FORWARDS; avatar.calibration.strideLength = avatar.selectedWalk.calibration.strideLength; break; } if (!isAlreadyWalking && !motion.isComingToHalt) { setTransition(avatar.selectedWalkBlend, playTransitionReachPoses); } motion.state = SURFACE_MOTION; break; } case AIR_MOTION: { // blend the up, down, forward and backward flying animations relative to motion speed and direction animationOperations.zeroAnimation(avatar.selectedFlyBlend); // calculate influences based on velocity and direction var velocityMagnitude = Vec3.length(motion.velocity); var verticalProportion = motion.velocity.y / velocityMagnitude; var thrustProportion = motion.velocity.z / velocityMagnitude / 2; // directional components var upComponent = motion.velocity.y > 0 ? verticalProportion : 0; var downComponent = motion.velocity.y < 0 ? -verticalProportion : 0; var forwardComponent = motion.velocity.z < 0 ? -thrustProportion : 0; var backwardComponent = motion.velocity.z > 0 ? thrustProportion : 0; // smooth / damp directional components to add visual 'weight' upComponent = flyUpFilter.process(upComponent); downComponent = flyDownFilter.process(downComponent); forwardComponent = flyForwardFilter.process(forwardComponent); backwardComponent = flyBackwardFilter.process(backwardComponent); // normalise directional components var normaliser = upComponent + downComponent + forwardComponent + backwardComponent; upComponent = upComponent / normaliser; downComponent = downComponent / normaliser; forwardComponent = forwardComponent / normaliser; backwardComponent = backwardComponent / normaliser; // blend animations proportionally if (upComponent > 0) { animationOperations.blendAnimation(avatar.selectedFlyUp, avatar.selectedFlyBlend, upComponent); } if (downComponent > 0) { animationOperations.blendAnimation(avatar.selectedFlyDown, avatar.selectedFlyBlend, downComponent); } if (forwardComponent > 0) { animationOperations.blendAnimation(avatar.selectedFly, avatar.selectedFlyBlend, Math.abs(forwardComponent)); } if (backwardComponent > 0) { animationOperations.blendAnimation(avatar.selectedFlyBackwards, avatar.selectedFlyBlend, Math.abs(backwardComponent)); } if (avatar.currentAnimation !== avatar.selectedFlyBlend) { setTransition(avatar.selectedFlyBlend, playTransitionReachPoses); } motion.state = AIR_MOTION; avatar.selectedWalkBlend.lastDirection = NONE; break; } } // end switch next state of motion } // determine the length of stride. advance the frequency time wheels. advance frequency time wheels for any live transitions function advanceAnimations() { var wheelAdvance = 0; // turn the frequency time wheel if (avatar.currentAnimation === avatar.selectedWalkBlend) { // Using technique described here: http://www.gdcvault.com/play/1020583/Animation-Bootcamp-An-Indie-Approach // wrap the stride length around a 'surveyor's wheel' twice and calculate the angular speed at the given (linear) speed // omega = v / r , where r = circumference / 2 PI and circumference = 2 * stride length var speed = Vec3.length(motion.velocity); motion.frequencyTimeWheelRadius = avatar.calibration.strideLength / Math.PI; var ftWheelAngularVelocity = speed / motion.frequencyTimeWheelRadius; // calculate the degrees turned (at this angular speed) since last frame wheelAdvance = filter.radToDeg(motion.deltaTime * ftWheelAngularVelocity); } else { // turn the frequency time wheel by the amount specified for this animation wheelAdvance = filter.radToDeg(avatar.currentAnimation.calibration.frequency * motion.deltaTime); } if (motion.currentTransition !== nullTransition) { // the last animation is still playing so we turn it's frequency time wheel to maintain the animation if (motion.currentTransition.lastAnimation === motion.selectedWalkBlend) { // if at a stop angle (i.e. feet now under the avi) hold the wheel position for remainder of transition var tolerance = motion.currentTransition.lastFrequencyTimeIncrement + 0.1; if ((motion.currentTransition.lastFrequencyTimeWheelPos > (motion.currentTransition.stopAngle - tolerance)) && (motion.currentTransition.lastFrequencyTimeWheelPos < (motion.currentTransition.stopAngle + tolerance))) { motion.currentTransition.lastFrequencyTimeIncrement = 0; } } motion.currentTransition.advancePreviousFrequencyTimeWheel(motion.deltaTime); } // avoid unnaturally fast walking when landing at speed - simulates skimming / skidding if (Math.abs(wheelAdvance) > MAX_FT_WHEEL_INCREMENT) { wheelAdvance = 0; } // advance the walk wheel the appropriate amount motion.advanceFrequencyTimeWheel(wheelAdvance); // walking? then see if it's a good time to measure the stride length (needs to be at least 97% of max walking speed) const ALMOST_ONE = 0.97; if (avatar.currentAnimation === avatar.selectedWalkBlend && (Vec3.length(motion.velocity) / MAX_WALK_SPEED > ALMOST_ONE)) { var strideMaxAt = avatar.currentAnimation.calibration.strideMaxAt; const TOLERANCE = 1.0; if (motion.frequencyTimeWheelPos < (strideMaxAt + TOLERANCE) && motion.frequencyTimeWheelPos > (strideMaxAt - TOLERANCE) && motion.currentTransition === nullTransition) { // measure and save stride length var footRPos = MyAvatar.getJointPosition("RightFoot"); var footLPos = MyAvatar.getJointPosition("LeftFoot"); avatar.calibration.strideLength = Vec3.distance(footRPos, footLPos); avatar.currentAnimation.calibration.strideLength = avatar.calibration.strideLength; } else { // use the previously saved value for stride length avatar.calibration.strideLength = avatar.currentAnimation.calibration.strideLength; } } // end get walk stride length } // initialise a new transition. update progress of a live transition function updateTransitions() { if (motion.currentTransition !== nullTransition) { // is this a new transition? if (motion.currentTransition.progress === 0) { // do we have overlapping transitions? if (motion.currentTransition.lastTransition !== nullTransition) { // is the last animation for the nested transition the same as the new animation? if (motion.currentTransition.lastTransition.lastAnimation === avatar.currentAnimation) { // then sync the nested transition's frequency time wheel for a smooth animation blend motion.frequencyTimeWheelPos = motion.currentTransition.lastTransition.lastFrequencyTimeWheelPos; } } } if (motion.currentTransition.updateProgress() === TRANSITION_COMPLETE) { motion.currentTransition = nullTransition; } } } // helper function for renderMotion(). calculate the amount to lean forwards (or backwards) based on the avi's velocity var leanPitchSmoothingFilter = filter.createButterworthFilter(); function getLeanPitch() { var leanProgress = 0; if (motion.direction === DOWN || motion.direction === FORWARDS || motion.direction === BACKWARDS) { leanProgress = -motion.velocity.z / TOP_SPEED; } // use filters to shape the walking acceleration response leanProgress = leanPitchSmoothingFilter.process(leanProgress); return PITCH_MAX * leanProgress; } // helper function for renderMotion(). calculate the angle at which to bank into corners whilst turning var leanRollSmoothingFilter = filter.createButterworthFilter(); function getLeanRoll() { var leanRollProgress = 0; var linearContribution = 0; const LOG_SCALER = 8; if (Vec3.length(motion.velocity) > 0) { linearContribution = (Math.log(Vec3.length(motion.velocity) / TOP_SPEED) + LOG_SCALER) / LOG_SCALER; } var angularContribution = Math.abs(motion.yawDelta) / DELTA_YAW_MAX; leanRollProgress = linearContribution; leanRollProgress *= angularContribution; // shape the response curve leanRollProgress = filter.bezier(leanRollProgress, {x: 1, y: 0}, {x: 1, y: 0}); // which way to lean? var turnSign = (motion.yawDelta >= 0) ? 1 : -1; if (motion.direction === BACKWARDS || motion.direction === LEFT) { turnSign *= -1; } // filter progress leanRollProgress = leanRollSmoothingFilter.process(turnSign * leanRollProgress); return ROLL_MAX * leanRollProgress; } // animate the avatar using sine waves, geometric waveforms and harmonic generators function renderMotion() { // leaning in response to speed and acceleration var leanPitch = motion.state === STATIC ? 0 : getLeanPitch(); var leanRoll = motion.state === STATIC ? 0 : getLeanRoll(); var lastDirection = motion.lastDirection; // hips translations from currently playing animations var hipsTranslations = {x:0, y:0, z:0}; if (motion.currentTransition !== nullTransition) { // maintain previous direction when transitioning from a walk if (motion.currentTransition.lastAnimation === avatar.selectedWalkBlend) { motion.lastDirection = motion.currentTransition.lastDirection; } hipsTranslations = motion.currentTransition.blendTranslations(motion.frequencyTimeWheelPos, motion.lastDirection); } else { hipsTranslations = animationOperations.calculateTranslations(avatar.currentAnimation, motion.frequencyTimeWheelPos, motion.direction); } // factor any leaning into the hips offset hipsTranslations.z += avatar.calibration.hipsToFeet * Math.sin(filter.degToRad(leanPitch)); hipsTranslations.x += avatar.calibration.hipsToFeet * Math.sin(filter.degToRad(leanRoll)); // ensure skeleton offsets are within the 1m limit hipsTranslations.x = hipsTranslations.x > 1 ? 1 : hipsTranslations.x; hipsTranslations.x = hipsTranslations.x < -1 ? -1 : hipsTranslations.x; hipsTranslations.y = hipsTranslations.y > 1 ? 1 : hipsTranslations.y; hipsTranslations.y = hipsTranslations.y < -1 ? -1 : hipsTranslations.y; hipsTranslations.z = hipsTranslations.z > 1 ? 1 : hipsTranslations.z; hipsTranslations.z = hipsTranslations.z < -1 ? -1 : hipsTranslations.z; // apply translations MyAvatar.setSkeletonOffset(hipsTranslations); // play footfall sound? var producingFootstepSounds = (avatar.currentAnimation === avatar.selectedWalkBlend) && avatar.makesFootStepSounds; if (motion.currentTransition !== nullTransition && avatar.makesFootStepSounds) { if (motion.currentTransition.nextAnimation === avatar.selectedWalkBlend || motion.currentTransition.lastAnimation === avatar.selectedWalkBlend) { producingFootstepSounds = true; } } if (producingFootstepSounds) { const QUARTER_CYCLE = 90; const THREE_QUARTER_CYCLE = 270; var ftWheelPosition = motion.frequencyTimeWheelPos; if (motion.currentTransition !== nullTransition && motion.currentTransition.lastAnimation === avatar.selectedWalkBlend) { ftWheelPosition = motion.currentTransition.lastFrequencyTimeWheelPos; } if (avatar.nextStep === LEFT && ftWheelPosition > THREE_QUARTER_CYCLE) { avatar.makeFootStepSound(); } else if (avatar.nextStep === RIGHT && (ftWheelPosition < THREE_QUARTER_CYCLE && ftWheelPosition > QUARTER_CYCLE)) { avatar.makeFootStepSound(); } } // apply joint rotations for (jointName in avatar.currentAnimation.joints) { var joint = walkAssets.animationReference.joints[jointName]; var jointRotations = undefined; // ignore arms / head rotations if options are selected in the settings if (avatar.armsFree && (joint.IKChain === "LeftArm" || joint.IKChain === "RightArm")) { continue; } if (avatar.headFree && joint.IKChain === "Head") { continue; } // if there's a live transition, blend the rotations with the last animation's rotations if (motion.currentTransition !== nullTransition) { jointRotations = motion.currentTransition.blendRotations(jointName, motion.frequencyTimeWheelPos, motion.lastDirection); } else { jointRotations = animationOperations.calculateRotations(jointName, avatar.currentAnimation, motion.frequencyTimeWheelPos, motion.direction); } // apply angular velocity and speed induced leaning if (jointName === "Hips") { jointRotations.x += leanPitch; jointRotations.z += leanRoll; } // apply rotations MyAvatar.setJointRotation(jointName, Quat.fromVec3Degrees(jointRotations)); } }