cannon-es-control
Version:
A lightweight 3D physics engine written in JavaScript with control system tools
671 lines (542 loc) • 22 kB
text/typescript
import type { Body } from '../objects/Body'
import { Vec3 } from '../math/Vec3'
import { Quaternion } from '../math/Quaternion'
import { Ray } from '../collision/Ray'
import { WheelInfo } from '../objects/WheelInfo'
import type { WheelInfoOptions } from '../objects/WheelInfo'
import type { Transform } from '../math/Transform'
import type { Constraint } from '../constraints/Constraint'
import type { World } from '../world/World'
export type RaycastVehicleOptions = ConstructorParameters<typeof RaycastVehicle>[0]
/**
* Vehicle helper class that casts rays from the wheel positions towards the ground and applies forces.
*/
export class RaycastVehicle {
/** The car chassis body. */
chassisBody: Body
/** The wheels. */
wheelInfos: WheelInfo[]
/** Will be set to true if the car is sliding. */
sliding: boolean
world: World | null
/** Index of the right axis. x=0, y=1, z=2 */
indexRightAxis: number
/** Index of the forward axis. x=0, y=1, z=2 */
indexForwardAxis: number
/** Index of the up axis. x=0, y=1, z=2 */
indexUpAxis: number
/** The constraints. */
constraints: Constraint[]
/** Optional pre-step callback. */
preStepCallback: () => void
currentVehicleSpeedKmHour: number
constructor(options: {
/** The car chassis body. */
chassisBody: Body
/** Index of the right axis. x=0, y=1, z=2 */
indexRightAxis?: number
/** Index of the forward axis. x=0, y=1, z=2 */
indexForwardAxis?: number
/** Index of the up axis. x=0, y=1, z=2 */
indexUpAxis?: number
}) {
this.chassisBody = options.chassisBody
this.wheelInfos = []
this.sliding = false
this.world = null
this.indexRightAxis = typeof options.indexRightAxis !== 'undefined' ? options.indexRightAxis : 2
this.indexForwardAxis = typeof options.indexForwardAxis !== 'undefined' ? options.indexForwardAxis : 0
this.indexUpAxis = typeof options.indexUpAxis !== 'undefined' ? options.indexUpAxis : 1
this.constraints = []
this.preStepCallback = () => {}
this.currentVehicleSpeedKmHour = 0
}
/**
* Add a wheel. For information about the options, see `WheelInfo`.
*/
addWheel(options: WheelInfoOptions = {}): number {
const info = new WheelInfo(options)
const index = this.wheelInfos.length
this.wheelInfos.push(info)
return index
}
/**
* Set the steering value of a wheel.
*/
setSteeringValue(value: number, wheelIndex: number): void {
const wheel = this.wheelInfos[wheelIndex]
wheel.steering = value
}
/**
* Set the wheel force to apply on one of the wheels each time step
*/
applyEngineForce(value: number, wheelIndex: number): void {
this.wheelInfos[wheelIndex].engineForce = value
}
/**
* Set the braking force of a wheel
*/
setBrake(brake: number, wheelIndex: number): void {
this.wheelInfos[wheelIndex].brake = brake
}
/**
* Add the vehicle including its constraints to the world.
*/
addToWorld(world: World): void {
world.addBody(this.chassisBody)
const that = this
this.preStepCallback = () => {
that.updateVehicle(world.dt)
}
world.addEventListener('preStep', this.preStepCallback)
this.world = world
}
/**
* Get one of the wheel axles, world-oriented.
*/
private getVehicleAxisWorld(axisIndex: number, result: Vec3): void {
result.set(axisIndex === 0 ? 1 : 0, axisIndex === 1 ? 1 : 0, axisIndex === 2 ? 1 : 0)
this.chassisBody.vectorToWorldFrame(result, result)
}
updateVehicle(timeStep: number): void {
const wheelInfos = this.wheelInfos
const numWheels = wheelInfos.length
const chassisBody = this.chassisBody
for (let i = 0; i < numWheels; i++) {
this.updateWheelTransform(i)
}
this.currentVehicleSpeedKmHour = 3.6 * chassisBody.velocity.length()
const forwardWorld = new Vec3()
this.getVehicleAxisWorld(this.indexForwardAxis, forwardWorld)
if (forwardWorld.dot(chassisBody.velocity) < 0) {
this.currentVehicleSpeedKmHour *= -1
}
// simulate suspension
for (let i = 0; i < numWheels; i++) {
this.castRay(wheelInfos[i])
}
this.updateSuspension(timeStep)
const impulse = new Vec3()
const relpos = new Vec3()
for (let i = 0; i < numWheels; i++) {
//apply suspension force
const wheel = wheelInfos[i]
let suspensionForce = wheel.suspensionForce
if (suspensionForce > wheel.maxSuspensionForce) {
suspensionForce = wheel.maxSuspensionForce
}
wheel.raycastResult.hitNormalWorld.scale(suspensionForce * timeStep, impulse)
wheel.raycastResult.hitPointWorld.vsub(chassisBody.position, relpos)
chassisBody.applyImpulse(impulse, relpos)
}
this.updateFriction(timeStep)
const hitNormalWorldScaledWithProj = new Vec3()
const fwd = new Vec3()
const vel = new Vec3()
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
//const relpos = new Vec3();
//wheel.chassisConnectionPointWorld.vsub(chassisBody.position, relpos);
chassisBody.getVelocityAtWorldPoint(wheel.chassisConnectionPointWorld, vel)
// Hack to get the rotation in the correct direction
let m = 1
switch (this.indexUpAxis) {
case 1:
m = -1
break
}
if (wheel.isInContact) {
this.getVehicleAxisWorld(this.indexForwardAxis, fwd)
const proj = fwd.dot(wheel.raycastResult.hitNormalWorld)
wheel.raycastResult.hitNormalWorld.scale(proj, hitNormalWorldScaledWithProj)
fwd.vsub(hitNormalWorldScaledWithProj, fwd)
const proj2 = fwd.dot(vel)
wheel.deltaRotation = (m * proj2 * timeStep) / wheel.radius
}
if ((wheel.sliding || !wheel.isInContact) && wheel.engineForce !== 0 && wheel.useCustomSlidingRotationalSpeed) {
// Apply custom rotation when accelerating and sliding
wheel.deltaRotation = (wheel.engineForce > 0 ? 1 : -1) * wheel.customSlidingRotationalSpeed * timeStep
}
// Lock wheels
if (Math.abs(wheel.brake) > Math.abs(wheel.engineForce)) {
wheel.deltaRotation = 0
}
wheel.rotation += wheel.deltaRotation // Use the old value
wheel.deltaRotation *= 0.99 // damping of rotation when not in contact
}
}
updateSuspension(deltaTime: number): void {
const chassisBody = this.chassisBody
const chassisMass = chassisBody.mass
const wheelInfos = this.wheelInfos
const numWheels = wheelInfos.length
for (let w_it = 0; w_it < numWheels; w_it++) {
const wheel = wheelInfos[w_it]
if (wheel.isInContact) {
let force
// Spring
const susp_length = wheel.suspensionRestLength
const current_length = wheel.suspensionLength
const length_diff = susp_length - current_length
force = wheel.suspensionStiffness * length_diff * wheel.clippedInvContactDotSuspension
// Damper
const projected_rel_vel = wheel.suspensionRelativeVelocity
let susp_damping
if (projected_rel_vel < 0) {
susp_damping = wheel.dampingCompression
} else {
susp_damping = wheel.dampingRelaxation
}
force -= susp_damping * projected_rel_vel
wheel.suspensionForce = force * chassisMass
if (wheel.suspensionForce < 0) {
wheel.suspensionForce = 0
}
} else {
wheel.suspensionForce = 0
}
}
}
/**
* Remove the vehicle including its constraints from the world.
*/
removeFromWorld(world: World): void {
const constraints = this.constraints
world.removeBody(this.chassisBody)
world.removeEventListener('preStep', this.preStepCallback)
this.world = null
}
castRay(wheel: WheelInfo): number {
const rayvector = castRay_rayvector
const target = castRay_target
this.updateWheelTransformWorld(wheel)
const chassisBody = this.chassisBody
let depth = -1
const raylen = wheel.suspensionRestLength + wheel.radius
wheel.directionWorld.scale(raylen, rayvector)
const source = wheel.chassisConnectionPointWorld
source.vadd(rayvector, target)
const raycastResult = wheel.raycastResult
const param = 0
raycastResult.reset()
// Turn off ray collision with the chassis temporarily
const oldState = chassisBody.collisionResponse
chassisBody.collisionResponse = false
// Cast ray against world
this.world!.rayTest(source, target, raycastResult)
chassisBody.collisionResponse = oldState
const object = raycastResult.body
wheel.raycastResult.groundObject = 0
if (object) {
depth = raycastResult.distance
wheel.raycastResult.hitNormalWorld = raycastResult.hitNormalWorld
wheel.isInContact = true
const hitDistance = raycastResult.distance
wheel.suspensionLength = hitDistance - wheel.radius
// clamp on max suspension travel
const minSuspensionLength = wheel.suspensionRestLength - wheel.maxSuspensionTravel
const maxSuspensionLength = wheel.suspensionRestLength + wheel.maxSuspensionTravel
if (wheel.suspensionLength < minSuspensionLength) {
wheel.suspensionLength = minSuspensionLength
}
if (wheel.suspensionLength > maxSuspensionLength) {
wheel.suspensionLength = maxSuspensionLength
wheel.raycastResult.reset()
}
const denominator = wheel.raycastResult.hitNormalWorld.dot(wheel.directionWorld)
const chassis_velocity_at_contactPoint = new Vec3()
chassisBody.getVelocityAtWorldPoint(wheel.raycastResult.hitPointWorld, chassis_velocity_at_contactPoint)
const projVel = wheel.raycastResult.hitNormalWorld.dot(chassis_velocity_at_contactPoint)
if (denominator >= -0.1) {
wheel.suspensionRelativeVelocity = 0
wheel.clippedInvContactDotSuspension = 1 / 0.1
} else {
const inv = -1 / denominator
wheel.suspensionRelativeVelocity = projVel * inv
wheel.clippedInvContactDotSuspension = inv
}
} else {
//put wheel info as in rest position
wheel.suspensionLength = wheel.suspensionRestLength + 0 * wheel.maxSuspensionTravel
wheel.suspensionRelativeVelocity = 0.0
wheel.directionWorld.scale(-1, wheel.raycastResult.hitNormalWorld)
wheel.clippedInvContactDotSuspension = 1.0
}
return depth
}
updateWheelTransformWorld(wheel: WheelInfo): void {
wheel.isInContact = false
const chassisBody = this.chassisBody
chassisBody.pointToWorldFrame(wheel.chassisConnectionPointLocal, wheel.chassisConnectionPointWorld)
chassisBody.vectorToWorldFrame(wheel.directionLocal, wheel.directionWorld)
chassisBody.vectorToWorldFrame(wheel.axleLocal, wheel.axleWorld)
}
/**
* Update one of the wheel transform.
* Note when rendering wheels: during each step, wheel transforms are updated BEFORE the chassis; ie. their position becomes invalid after the step. Thus when you render wheels, you must update wheel transforms before rendering them. See raycastVehicle demo for an example.
* @param wheelIndex The wheel index to update.
*/
updateWheelTransform(wheelIndex: number): void {
const up = tmpVec4
const right = tmpVec5
const fwd = tmpVec6
const wheel = this.wheelInfos[wheelIndex]
this.updateWheelTransformWorld(wheel)
wheel.directionLocal.scale(-1, up)
right.copy(wheel.axleLocal)
up.cross(right, fwd)
fwd.normalize()
right.normalize()
// Rotate around steering over the wheelAxle
const steering = wheel.steering
const steeringOrn = new Quaternion()
steeringOrn.setFromAxisAngle(up, steering)
const rotatingOrn = new Quaternion()
rotatingOrn.setFromAxisAngle(right, wheel.rotation)
// World rotation of the wheel
const q = wheel.worldTransform.quaternion
this.chassisBody.quaternion.mult(steeringOrn, q)
q.mult(rotatingOrn, q)
q.normalize()
// world position of the wheel
const p = wheel.worldTransform.position
p.copy(wheel.directionWorld)
p.scale(wheel.suspensionLength, p)
p.vadd(wheel.chassisConnectionPointWorld, p)
}
/**
* Get the world transform of one of the wheels
*/
getWheelTransformWorld(wheelIndex: number): Transform {
return this.wheelInfos[wheelIndex].worldTransform
}
updateFriction(timeStep: number): void {
const surfNormalWS_scaled_proj = updateFriction_surfNormalWS_scaled_proj
//calculate the impulse, so that the wheels don't move sidewards
const wheelInfos = this.wheelInfos
const numWheels = wheelInfos.length
const chassisBody = this.chassisBody
const forwardWS = updateFriction_forwardWS
const axle = updateFriction_axle
let numWheelsOnGround = 0
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
const groundObject = wheel.raycastResult.body
if (groundObject) {
numWheelsOnGround++
}
wheel.sideImpulse = 0
wheel.forwardImpulse = 0
if (!forwardWS[i]) {
forwardWS[i] = new Vec3()
}
if (!axle[i]) {
axle[i] = new Vec3()
}
}
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
const groundObject = wheel.raycastResult.body
if (groundObject) {
const axlei = axle[i]
const wheelTrans = this.getWheelTransformWorld(i)
// Get world axle
wheelTrans.vectorToWorldFrame(directions[this.indexRightAxis], axlei)
const surfNormalWS = wheel.raycastResult.hitNormalWorld
const proj = axlei.dot(surfNormalWS)
surfNormalWS.scale(proj, surfNormalWS_scaled_proj)
axlei.vsub(surfNormalWS_scaled_proj, axlei)
axlei.normalize()
surfNormalWS.cross(axlei, forwardWS[i])
forwardWS[i].normalize()
wheel.sideImpulse = resolveSingleBilateral(
chassisBody,
wheel.raycastResult.hitPointWorld,
groundObject,
wheel.raycastResult.hitPointWorld,
axlei
)
wheel.sideImpulse *= sideFrictionStiffness2
}
}
const sideFactor = 1
const fwdFactor = 0.5
this.sliding = false
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
const groundObject = wheel.raycastResult.body
let rollingFriction = 0
wheel.slipInfo = 1
if (groundObject) {
const defaultRollingFrictionImpulse = 0
const maxImpulse = wheel.brake ? wheel.brake : defaultRollingFrictionImpulse
// btWheelContactPoint contactPt(chassisBody,groundObject,wheelInfraycastInfo.hitPointWorld,forwardWS[wheel],maxImpulse);
// rollingFriction = calcRollingFriction(contactPt);
rollingFriction = calcRollingFriction(
chassisBody,
groundObject,
wheel.raycastResult.hitPointWorld,
forwardWS[i],
maxImpulse
)
rollingFriction += wheel.engineForce * timeStep
// rollingFriction = 0;
const factor = maxImpulse / rollingFriction
wheel.slipInfo *= factor
}
//switch between active rolling (throttle), braking and non-active rolling friction (nthrottle/break)
wheel.forwardImpulse = 0
wheel.skidInfo = 1
if (groundObject) {
wheel.skidInfo = 1
const maximp = wheel.suspensionForce * timeStep * wheel.frictionSlip
const maximpSide = maximp
const maximpSquared = maximp * maximpSide
wheel.forwardImpulse = rollingFriction //wheelInfo.engineForce* timeStep;
const x = (wheel.forwardImpulse * fwdFactor) / wheel.forwardAcceleration
const y = (wheel.sideImpulse * sideFactor) / wheel.sideAcceleration
const impulseSquared = x * x + y * y
wheel.sliding = false
if (impulseSquared > maximpSquared) {
this.sliding = true
wheel.sliding = true
const factor = maximp / Math.sqrt(impulseSquared)
wheel.skidInfo *= factor
}
}
}
if (this.sliding) {
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
if (wheel.sideImpulse !== 0) {
if (wheel.skidInfo < 1) {
wheel.forwardImpulse *= wheel.skidInfo
wheel.sideImpulse *= wheel.skidInfo
}
}
}
}
// apply the impulses
for (let i = 0; i < numWheels; i++) {
const wheel = wheelInfos[i]
const rel_pos = new Vec3()
wheel.raycastResult.hitPointWorld.vsub(chassisBody.position, rel_pos)
// cannons applyimpulse is using world coord for the position
//rel_pos.copy(wheel.raycastResult.hitPointWorld);
if (wheel.forwardImpulse !== 0) {
const impulse = new Vec3()
forwardWS[i].scale(wheel.forwardImpulse, impulse)
chassisBody.applyImpulse(impulse, rel_pos)
}
if (wheel.sideImpulse !== 0) {
const groundObject = wheel.raycastResult.body!
const rel_pos2 = new Vec3()
wheel.raycastResult.hitPointWorld.vsub(groundObject.position, rel_pos2)
//rel_pos2.copy(wheel.raycastResult.hitPointWorld);
const sideImp = new Vec3()
axle[i].scale(wheel.sideImpulse, sideImp)
// Scale the relative position in the up direction with rollInfluence.
// If rollInfluence is 1, the impulse will be applied on the hitPoint (easy to roll over), if it is zero it will be applied in the same plane as the center of mass (not easy to roll over).
chassisBody.vectorToLocalFrame(rel_pos, rel_pos)
rel_pos['xyz'[this.indexUpAxis] as 'x' | 'y' | 'z'] *= wheel.rollInfluence
chassisBody.vectorToWorldFrame(rel_pos, rel_pos)
chassisBody.applyImpulse(sideImp, rel_pos)
//apply friction impulse on the ground
sideImp.scale(-1, sideImp)
groundObject.applyImpulse(sideImp, rel_pos2)
}
}
}
}
const tmpVec1 = new Vec3()
const tmpVec2 = new Vec3()
const tmpVec3 = new Vec3()
const tmpVec4 = new Vec3()
const tmpVec5 = new Vec3()
const tmpVec6 = new Vec3()
const tmpRay = new Ray()
const torque = new Vec3()
const castRay_rayvector = new Vec3()
const castRay_target = new Vec3()
const directions = [new Vec3(1, 0, 0), new Vec3(0, 1, 0), new Vec3(0, 0, 1)]
const updateFriction_surfNormalWS_scaled_proj = new Vec3()
const updateFriction_axle: Vec3[] = []
const updateFriction_forwardWS: Vec3[] = []
const sideFrictionStiffness2 = 1
const calcRollingFriction_vel1 = new Vec3()
const calcRollingFriction_vel2 = new Vec3()
const calcRollingFriction_vel = new Vec3()
function calcRollingFriction(
body0: Body,
body1: Body,
frictionPosWorld: Vec3,
frictionDirectionWorld: Vec3,
maxImpulse: number
): number {
let j1 = 0
const contactPosWorld = frictionPosWorld
// const rel_pos1 = new Vec3();
// const rel_pos2 = new Vec3();
const vel1 = calcRollingFriction_vel1
const vel2 = calcRollingFriction_vel2
const vel = calcRollingFriction_vel
// contactPosWorld.vsub(body0.position, rel_pos1);
// contactPosWorld.vsub(body1.position, rel_pos2);
body0.getVelocityAtWorldPoint(contactPosWorld, vel1)
body1.getVelocityAtWorldPoint(contactPosWorld, vel2)
vel1.vsub(vel2, vel)
const vrel = frictionDirectionWorld.dot(vel)
const denom0 = computeImpulseDenominator(body0, frictionPosWorld, frictionDirectionWorld)
const denom1 = computeImpulseDenominator(body1, frictionPosWorld, frictionDirectionWorld)
const relaxation = 1
const jacDiagABInv = relaxation / (denom0 + denom1)
// calculate j that moves us to zero relative velocity
j1 = -vrel * jacDiagABInv
if (maxImpulse < j1) {
j1 = maxImpulse
}
if (j1 < -maxImpulse) {
j1 = -maxImpulse
}
return j1
}
const computeImpulseDenominator_r0 = new Vec3()
const computeImpulseDenominator_c0 = new Vec3()
const computeImpulseDenominator_vec = new Vec3()
const computeImpulseDenominator_m = new Vec3()
function computeImpulseDenominator(body: Body, pos: Vec3, normal: Vec3): number {
const r0 = computeImpulseDenominator_r0
const c0 = computeImpulseDenominator_c0
const vec = computeImpulseDenominator_vec
const m = computeImpulseDenominator_m
pos.vsub(body.position, r0)
r0.cross(normal, c0)
body.invInertiaWorld.vmult(c0, m)
m.cross(r0, vec)
return body.invMass + normal.dot(vec)
}
const resolveSingleBilateral_vel1 = new Vec3()
const resolveSingleBilateral_vel2 = new Vec3()
const resolveSingleBilateral_vel = new Vec3()
// bilateral constraint between two dynamic objects
function resolveSingleBilateral(body1: Body, pos1: Vec3, body2: Body, pos2: Vec3, normal: Vec3): number {
const normalLenSqr = normal.lengthSquared()
if (normalLenSqr > 1.1) {
return 0 // no impulse
}
// const rel_pos1 = new Vec3();
// const rel_pos2 = new Vec3();
// pos1.vsub(body1.position, rel_pos1);
// pos2.vsub(body2.position, rel_pos2);
const vel1 = resolveSingleBilateral_vel1
const vel2 = resolveSingleBilateral_vel2
const vel = resolveSingleBilateral_vel
body1.getVelocityAtWorldPoint(pos1, vel1)
body2.getVelocityAtWorldPoint(pos2, vel2)
vel1.vsub(vel2, vel)
const rel_vel = normal.dot(vel)
const contactDamping = 0.2
const massTerm = 1 / (body1.invMass + body2.invMass)
const impulse = -contactDamping * rel_vel * massTerm
return impulse
}