Skip to content

Instantly share code, notes, and snippets.

@sketchpunk
Last active November 17, 2022 16:25
Show Gist options
  • Save sketchpunk/b92822e1cdb0e2444706e4e575671083 to your computer and use it in GitHub Desktop.
Save sketchpunk/b92822e1cdb0e2444706e4e575671083 to your computer and use it in GitHub Desktop.
Movement functions for use with ThreeJS Camera, Also inc Springs, Mouse/Keboard Event Handlers
// Move camera hased on camera view's axis clamped to the XZ plane
function panStepXZ( camera, xSteps, zSteps, initPos=null ){
const rot = camera.quaternion.toArray();
const pos = initPos?.slice() || camera.position.toArray();
const fwd = vec3.transformQuat( [0,0,0], [0,0,-1], rot ); // compute forward
const rit = vec3.cross( [0,0,0], fwd, [0,1,0] ); // get left
vec3.cross( fwd, [0,1,0], rit ); // clamp forward to XZ plane by using UP
vec3.scaleAndAdd( pos, pos, fwd, zSteps );
vec3.scaleAndAdd( pos, pos, rit, xSteps );
// camera.position.fromArray( pos );
return pos;
}
// Move camera based on camera view's UP and RIGHT Axis
function screenPanStep( camera, xSteps, ySteps, initPos=null ){
const rot = camera.quaternion.toArray();
const pos = initPos?.slice() || camera.position.toArray();
const rit = vec3.transformQuat( [0,0,0], [1,0,0], rot );
const up = vec3.transformQuat( [0,0,0], [0,1,0], rot );
vec3.scaleAndAdd( pos, pos, rit, xSteps );
vec3.scaleAndAdd( pos, pos, up, ySteps );
// camera.position.fromArray( pos );
return pos;
}
// Move camera based on camera view's FORWARD and RIGHT Axis
function screenStep( camera, xSteps, zSteps, initPos=null ){
const rot = camera.quaternion.toArray();
const pos = initPos?.slice() || camera.position.toArray();
const fwd = vec3.transformQuat( [0,0,0], [0,0,-1], rot );
const rit = vec3.transformQuat( [0,0,0], [1,0,0], rot );
vec3.scaleAndAdd( pos, pos, fwd, zSteps );
vec3.scaleAndAdd( pos, pos, rit, xSteps );
camera.position.fromArray( pos );
}
// Rotate camera for look movement. X Rotation is clamped so
// user can not keep rotating around, plus the final rotation
// is clamped to world up to prevent any disorientation.
function lookStep( camera, xRad=0, yRad=0, initRot=null ){
const rot = initRot?.slice() || camera.quaternion.toArray();
const q = [0,0,0,1];
const fwd = [0,0,0];
// -----------------------
// Compute Y Rotation
if( yRad ){
quat.setAxisAngle( q, [0,1,0], yRad );
quat.mul( rot, q, rot );
}
// -----------------------
// Compute X Rotation
if( xRad ){
// Clamp Forward to XZ Plane
const xzDir = vec3.transformQuat( [0,0,0], [0,0,1], rot );
xzDir[1] = 0;
vec3.normalize( xzDir, xzDir );
// Do X Rotation
const rit = vec3.transformQuat( [0,0,0], [1,0,0], rot ); // Local X Axis
quat.setAxisAngle( q, rit, xRad ); // Create Localized X Rotation in worldspace
const rotX = quat.mul( [0,0,0,1], q, rot ); // Add X rotation to Y's
// Clamp Rotation between UP & DOWN
vec3.transformQuat( fwd, [0,0,1], rotX );
//console.log( vec3.dot( fwd, xzDir ), vec3.angle( fwd, xzDir ) * 180 / Math.PI );
if( vec3.dot( fwd, xzDir ) < 0.01 ){ // Dot Product of 0.01 is about 89.38 degrees
// Compute Clamped Forward Direction
quat.setAxisAngle( q, rit, 89.38 * Math.PI / 180 * Math.sign( -fwd[1] ) );
const cFwd = vec3.transformQuat( [0,0,0], xzDir, q );
// Rotate Fwd to Clamped Forward Direction
quat.rotationTo( q, fwd, cFwd );
quat.mul( rotX, q, rotX );
}
// Save clamped X Rotation as final rotation
quat.copy( rot, rotX );
}
// -----------------------
// Z rotation can creap in with a lot of mouse movements & build up over time.
// Clamping rotation to world up prevents any disorentation from z rotation.
vec3.transformQuat( fwd, [0,0,1], rot );
// camera.quaternion.fromArray( lookDirection( rot, fwd, [0,1,0] ) );
return lookDirection( rot, fwd, [0,1,0] )
}
// Compute rotation from a look direction & up direction
function lookDirection( out, dir, up=[0,1,0] ){
const z = dir.slice();
const x = vec3.cross([0, 0, 0], up, z);
const y = vec3.cross([0, 0, 0], z, x);
vec3.normalize( x, x );
vec3.normalize( y, y );
vec3.normalize( z, z );
// Format: column-major, when typed out it looks like row-major
quat.fromMat3( out, [ ...x, ...y, ...z ] );
return quat.normalize( out, out );
}
// Create a Ray projection from the mouse position, then move
// the camera in that direction. Great for moving toward what
// the mouse is pointing at.
function mouseRayStep( e, camera, steps, initPos=null ){
const coord = mouseCoord( e );
const pos = initPos?.slice() || camera.position.toArray();
const rect = e.target.getBoundingClientRect(); // Need canvas's size for ray projection
const [ rayStart, rayEnd ] = mouseScreenProjectionRay(
coord[0], coord[1],
rect.width, rect.height,
camera.projectionMatrix.toArray(), camera.matrixWorld.toArray()
);
const dir = vec3.sub( [0,0,0], rayEnd, rayStart ); // Get Ray Direction
vec3.normalize( dir, dir );
vec3.scaleAndAdd( pos, pos, dir, steps ); // Step movement on ray
// camera.position.fromArray( pos );
return pos;
}
// Using spherical coordinates in DEGREEs to orbit around a target, then pointing
// the camera to look at the target
function sphericalOrbitLook( camera, lon, lat, radius, target=[0,0,0] ){
const phi = ( 90 - lat ) * Math.PI / 180;
const theta = ( lon + 180 ) * Math.PI / 180;
const pos = [
target[ 0 ] -(radius * Math.sin( phi ) * Math.sin(theta)),
target[ 1 ] + radius * Math.cos( phi ),
target[ 2 ] -(radius * Math.sin( phi ) * Math.cos(theta)),
];
camera.position.fromArray( pos );
camera.quaternion.fromArray( lookRotation( [0,0,0,1], pos, target ));
}
// Compute look rotation from the camera toward a target position
function lookRotation( out, eye, target=[0,0,0], up=[0,1,0] ){
// Forward is inverted, will face correct direction when converted
// to a ViewMatrix as it'll invert the Forward direction anyway
const z = vec3.sub( [0,0,0], eye, target );
const x = vec3.cross([0, 0, 0], up, z);
const y = vec3.cross([0, 0, 0], z, x);
vec3.normalize( x, x );
vec3.normalize( y, y );
vec3.normalize( z, z );
// Format: column-major, when typed out it looks like row-major
quat.fromMat3( out, [ ...x, ...y, ...z ] );
return quat.normalize( out, out );
}
// Orbit movement around a target position & rotate to look at the target
function orbitStep( camera, xRad, yRad, initPos=null, target=[0,0,0] ){
const pos = initPos?.slice() || camera.position.toArray();
const dir = vec3.sub( [0,0,0], pos, target );
const axis = [0,1,0];
const q = [0,0,0,1];
// World Y Rotation
if( yRad !== 0 ){
quat.setAxisAngle( q, axis, yRad );
vec3.transformQuat( dir, dir, q );
}
// Local X Rotation
if( xRad !== 0 ){
const xzDir = vec3.normalize( [0,0,0], [ dir[0], 0, dir[2] ] );
vec3.cross( axis, [0,1,0], dir ); // Compute X Axis using direction as Z Axis with World UP
vec3.normalize( axis, axis );
quat.setAxisAngle( q, axis, xRad );
vec3.transformQuat( dir, dir, q );
// Clamp between UP and DOWN without flipping Z Direction
const nDir = vec3.normalize( [0,0,0], dir );
if( vec3.dot( nDir, xzDir ) < 0.01 ){
const len = vec3.length( dir );
quat.setAxisAngle( q, axis, 89.99 * Math.PI / 180 * Math.sign( -nDir[1] ) ); // Max angle rotation
vec3.transformQuat( dir, xzDir, q ); // Rotate XZ dir to max dir
vec3.scale( dir, dir, len ); // XZ is a unit vector, apply original distance
}
}
vec3.add( pos, dir, target );
camera.position.fromArray( pos );
camera.quaternion.fromArray( lookRotation( q, pos, target ) );
}
function KeyboardEvents(){
let self;
const keys = new Map(); // State of a key press, lets user hold down multiple buttons
// #region HANDLERS
const onKeyDown = e=>{ keys.set( e.key, true ); };
const onKeyUp = e=>{ keys.set( e.key, false ); };
// #endregion
// #region MAIN
let isEnabled = false;
self = {
enable : ()=>{
if( isEnabled ) return self;
window.addEventListener( 'keydown', onKeyDown, true );
window.addEventListener( 'keyup', onKeyUp, true );
isEnabled = true;
return self;
},
disable : ()=>{
if( !isEnabled ) return self;
window.removeEventListener( 'keydown', onKeyDown, true );
window.removeEventListener( 'keyup', onKeyUp, true );
isEnabled = false;
return self;
},
isDown( key ){ return ( keys.has( e.key ) )? keys.get( key ) : false; },
isShift(){ return !!keys.get( 'Shift' ); },
isControl(){ return !!keys.get( 'Control' ); },
getWASDAxis(){
return [
( keys.get( 'a' ) )? -1 : ( keys.get( 'd' ) )? 1 : 0,
( keys.get( 's' ) )? -1 : ( keys.get( 'w' ) )? 1 : 0,
];
},
getArrowAxis(){
return [
( keys.get( 'ArrowLeft' ) )? -1 : ( keys.get( 'ArrowRight' ) )? 1 : 0,
( keys.get( 'ArrowDown' ) )? -1 : ( keys.get( 'ArrowUp' ) ) ? 1 : 0,
];
},
};
return self.enable();
// #endregion
}
function MouseEvents( canvas ){
let self;
// #region HELPERS
const mouseCoord = e=>{
const rect = canvas.getBoundingClientRect(); // need canvas sceen location & size
const x = e.clientX - rect.x; // canvas x position
const y = e.clientY - rect.y; // canvas y position
return [x, y];
}
// #endregion
// #region HANDLERS
let pointerId = null;
let initCoord = null;
const onWheel = e=>{ self?.wheel( e, mouseCoord( e ) ); };
const onPointerDown = e=>{
//e.stopPropagation();
pointerId = e.pointerId;
initCoord = mouseCoord( e );
if( self.pointerDown ) self.pointerDown( e, initCoord );
canvas.addEventListener( 'pointermove', onPointerMove, true );
canvas.addEventListener( 'pointerup', onPointerUp, true );
e.target.focus();
};
const onPointerUp = e=>{
canvas.releasePointerCapture( pointerId );
canvas.removeEventListener( 'pointermove', onPointerMove, true );
canvas.removeEventListener( 'pointerup', onPointerUp, true );
const coord = mouseCoord( e );
const dx = coord[ 0 ] - initCoord[ 0 ];
const dy = coord[ 1 ] - initCoord[ 1 ];
if( self.pointerUp ) self.pointerUp( e, initCoord, [dx,dy] );
};
const onPointerMove = e=>{
canvas.setPointerCapture( pointerId ); // Keep receiving events
// e.preventDefault();
// e.stopPropagation();
const coord = mouseCoord( e );
const dx = coord[ 0 ] - initCoord[ 0 ];
const dy = coord[ 1 ] - initCoord[ 1 ];
if( self.pointerMove ) self.pointerMove( e, initCoord, [dx,dy] );
};
// #endregion
// #region MAIN
let isEnabled = false;
self = {
pointerDown : null, // ( e, coord )
pointerMove : null, // ( e, coord, delta )
pointerUp : null, // ( e, coord, delta )
wheel : null, // ( e )
enable : ()=>{
if( isEnabled ) return self;
canvas.addEventListener( 'wheel', onWheel, true );
canvas.addEventListener( 'pointerdown', onPointerDown, true );
isEnabled = true;
return self;
},
disable : ()=>{
if( !isEnabled ) return self;
canvas.releasePointerCapture( pointerId );
canvas.removeEventListener( 'wheel', onWheel, true );
canvas.removeEventListener( 'pointerdown', onPointerDown, true );
canvas.removeEventListener( 'pointermove', onPointerMove, true );
canvas.removeEventListener( 'pointerup', onPointerUp, true );
isEnabled = false;
return self;
},
};
return self.enable();
// #endregion
}
function mouseCoord( e ){
const rect = e.target.getBoundingClientRect(); // target should be canvas
const x = e.clientX - rect.x; // canvas x position
const y = e.clientY - rect.y; // canvas y position
return [x, y];
}
function mouseScreenProjectionRay( x, y, w, h, projMatrix, camMatrix ){
// http://antongerdelan.net/opengl/raycasting.html
// Normalize Device Coordinate
const nx = x / w * 2 - 1;
const ny = 1 - y / h * 2;
// inverseWorldMatrix = invert( ProjectionMatrix * ViewMatrix ) OR
// inverseWorldMatrix = localMatrix * invert( ProjectionMatrix )
const invMatrix = [ 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0 ];
mat4.invert( invMatrix, projMatrix )
mat4.mul( invMatrix, camMatrix, invMatrix );
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// https://stackoverflow.com/questions/20140711/picking-in-3d-with-ray-tracing-using-ninevehgl-or-opengl-i-phone/20143963#20143963
// Clip Cords would be [nx,ny,-1,1];
const clipNear = [ nx, ny, -1, 1 ]; // Ray Start
const clipFar = [ nx, ny, 1, 1 ]; // Ray End
// using 4d Homogeneous Clip Coordinates
vec4.transformMat4( clipNear, clipNear, invMatrix );
vec4.transformMat4( clipFar, clipFar, invMatrix );
// Normalize by using W component
for( let i=0; i < 3; i++){
clipNear[ i ] /= clipNear[ 3 ];
clipFar [ i ] /= clipFar [ 3 ];
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
return [ clipNear, clipFar ];
}
// http://allenchou.net/2014/04/game-math-interpolating-quaternions-with-circular-blending/
// https://gafferongames.com/post/spring_physics/
// http://allenchou.net/2015/04/game-math-more-on-numeric-springing/
// http://allenchou.net/2015/04/game-math-precise-control-over-numeric-springing/
/** Implicit Euler Spring */
function SpringIEVec( size=3 ){
let self;
let osc_ps = Math.PI * 2; // Oscillation per Second : How many Cycles (Pi*2) per second.
let damping = 1; // How much to slow down : Value between 0 and 1, 1 creates critical damping.
let epsilon = 0.0001
const val = new Array( size ).fill( 0 );
const tar = val.slice();
const vel = val.slice();
// #region Oscillation & Damping
const setOscPerSec = ( sec )=>{ osc_ps = Math.PI * 2 * sec; return self; };
const setDamping = ( d )=>{ damping = d; return self; };
// Damp Time, in seconds to damp. So damp 0.5 for every 2 seconds.
// With the idea that for every 2 seconds, about 0.5 damping has been applied
// IMPORTANT : Need to set OSC Per Sec First
const dampRatio = ( d, sec )=>{
damping = Math.log( d ) / ( -osc_ps * sec );
return self;
};
// Reduce oscillation by half in X amount of seconds
// IMPORTANT : Need to set OSC Per Sec First
const dampHalflife = ( sec )=>{
damping = 0.6931472 / ( osc_ps * sec ); // float zeta = -ln(0.5f) / ( omega * lambda );
return self;
};
// Critical Damping with a speed control of how fast the cycle to run
const dampExpo = ( sec )=>{
osc_ps = 0.6931472 / sec; // -Log(0.5) but in terms of OCS its 39.7 degrees over time
damping = 1;
return self
};
// #endregion
// #region Resetting
const reset = ( v=null )=>{
zero( vel );
if( v != null ){
copy( val, v );
copy( tar, v );
}else{
zero( val );
zero( tar );
}
return self;
}
// #endregion
// #region Quaternion Usage
// Reset quaternions have a special starting value
const quatReset = ( v=null )=>{
quat.identity( vel );
if( v != null ){
vec3.copy( val, v );
vec3.copy( tar, v );
}else{
quat.identity( val );
quat.identity( tar );
}
return self;
}
// Special target setting, Need to check if the target is on the
// same hemisphere as the value, if not it needs to be negated.
const setQuatTarget = ( q )=>{
quat.copy( tar, q );
if( quat.dot( val, tar ) < 0 ) vec4.negate( tar, tar );
}
// #endregion
// #region Utils
const hasVelocity = ()=>{
for( let v of vel ) if( v !== 0 ) return true;
return false;
}
const sqrDist = ( a, b )=>{
let rtn = 0;
for( let i=0; i < size; i++ ) rtn += ( a[i] - b[i] )**2;
return rtn;
}
const sqrLen = ( a )=>{
let rtn = 0;
for( let i=0; i < size; i++ ) rtn += a[i]**2;
return rtn;
}
const copy = ( a, b )=>{ for( let i=0; i < size; i++ ) a[i] = b[i]; }
const zero = ( a )=>{ for( let i=0; i < size; i++ ) a[i] = 0; }
// #endregion
// #region MAIN
const update = ( dt )=>{
if( !hasVelocity() && sqrDist( tar, val ) === 0 ) return false;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if( sqrLen( vel ) < epsilon && sqrDist( tar, val ) < epsilon ){
zero( vel );
copy( val, tar );
return true;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
const friction = 1.0 + 2.0 * dt * damping * osc_ps;
const dt_osc = dt * osc_ps**2;
const dt2_osc = dt * dt_osc;
const det_inv = 1.0 / ( friction + dt2_osc );
for( let i=0; i < size; i++ ){
vel[i] = ( vel[i] + dt_osc * ( tar[i] - val[i] ) ) * det_inv;
val[i] = ( friction * val[i] + dt * vel[i] + dt2_osc * tar[i] ) * det_inv;
}
return true;
}
// #endregion
self = {
getTarget : ()=>{ return tar.slice(); },
setTarget : ( v )=>{ copy( tar, v ); return self; },
getValue : ()=>{ return val.slice(); },
getNormValue : ()=>{
const rtn = val.slice();
let len = sqrLen( rtn );
if( len > 0 ) len = 1 / Math.sqrt( len );
for( let i=0; i < size; i++ ) rtn[ i ] *= len;
return rtn;
},
setQuatTarget,
setOscPerSec,
setDamping,
dampRatio,
dampHalflife,
dampExpo,
reset,
quatReset,
update,
};
return self;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment