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[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 }