@Cedric I tried to patch the _createSurfaceConstraint function, it seems working okay-ish (not perfect) there are still some issue, flickering etc. Is it correct direction I’m trying to do? Also are there any updates from your implementation?
import {Vector3} from '@babylonjs/core/Maths/math.vector';
import {Quaternion} from '@babylonjs/core/Maths/math.vector';
import {PhysicsMotionType} from '@babylonjs/core/Physics/v2/IPhysicsEnginePlugin';
// Store previous positions per body for velocity calculation
const bodyPositionTracking = new Map();
// Helper function: compare two Vector3 with epsilon tolerance
function vecEquals(v1, v2) {
if (v1 === v2) return true;
if (!v1 || !v2) return false;
const dx = v1.x - v2.x;
const dy = v1.y - v2.y;
const dz = v1.z - v2.z;
const distSquared = dx * dx + dy * dy + dz * dz;
return distSquared < 9.999999439624929e-11;
}
function patchCharacterController(PhysicsCharacterController) {
PhysicsCharacterController.prototype._createSurfaceConstraint = function (contact, timeTravelled) {
const constraint = {
planeNormal: contact.normal.clone(),
planeDistance: contact.distance,
staticFriction: this.staticFriction,
dynamicFriction: this.dynamicFriction,
extraUpStaticFriction: 0,
extraDownStaticFriction: 0,
velocity: Vector3.Zero(),
angularVelocity: Vector3.Zero(),
priority: 0,
};
const maxSlopeCosEps = 0.1;
const maxSlopeCosine = Math.max(this.maxSlopeCosine, maxSlopeCosEps);
const normalDotUp = contact.normal.dot(this.up);
const contactPosition = contact.position.clone();
if (normalDotUp > maxSlopeCosine) {
const com = this.getPosition();
const contactArm = this._tmpVecs[20];
contact.position.subtractToRef(com, contactArm);
const scale = contact.normal.dot(contactArm);
contactPosition.x = com.x + this.up.x * scale;
contactPosition.y = com.y + this.up.y * scale;
contactPosition.z = com.z + this.up.z * scale;
}
const motionType = contact.bodyB.body.getMotionType(contact.bodyB.index);
if (motionType !== PhysicsMotionType.STATIC) {
const bodyTransformNode = contact.bodyB.body.transformNode;
// const bodyId = contact.bodyB.body._pluginData?.hpBodyId || bodyTransformNode.uniqueId;
const bodyId = bodyTransformNode.uniqueId;
// Get current position
bodyTransformNode.computeWorldMatrix(true);
const currentPos = bodyTransformNode.getAbsolutePosition();
// Retrieve tracking data
let tracking = bodyPositionTracking.get(bodyId);
// Get current rotation
const currentRot = bodyTransformNode.rotationQuaternion
? bodyTransformNode.rotationQuaternion.clone()
: Quaternion.RotationYawPitchRoll(
bodyTransformNode.rotation.y,
bodyTransformNode.rotation.x,
bodyTransformNode.rotation.z
);
if (!tracking) {
// Initialize tracking
tracking = {
prevPos: currentPos.clone(),
prevRot: currentRot.clone(),
linearVel: Vector3.Zero(),
angularVel: Vector3.Zero(),
};
bodyPositionTracking.set(bodyId, tracking);
}
// Use the fixed physics timestep directly (timeTravelled is the substep duration)
// This is frame-independent because it's based on fixed physics timestep, not wall-clock
const dt = this._scene.getPhysicsEngine().getSubTimeStep() / 1000;
// Check if this is a new physics frame
const isNewFrame = tracking.frameId !== window.frameId;
// Only calculate velocity if this contact existed in the previous frame
// This avoids huge delta spikes when first making contact or after gaps
if (isNewFrame && tracking.frameId + 1 === window.frameId) {
// Consecutive frames: calculate velocity from position change
if (!vecEquals(currentPos, tracking.prevPos)) {
const posDelta = new Vector3();
currentPos.subtractToRef(tracking.prevPos, posDelta);
const deltaLength = posDelta.length();
if (deltaLength > 1.0) {
console.log('LARGE JUMP DETECTED:', {
bodyId: bodyId,
bodyName: bodyTransformNode.name,
delta: posDelta,
deltaLength: deltaLength,
prevPos: tracking.prevPos,
currentPos: currentPos,
prevFrame: tracking.frameId,
currentFrame: window.frameId,
motionType: motionType,
dt: dt
});
}
posDelta.scaleToRef(1.0 / dt, tracking.linearVel);
} else {
tracking.linearVel.set(0, 0, 0);
}
// Update tracking for next frame
tracking.prevPos.copyFrom(currentPos);
} else if (isNewFrame) {
// Frame gap detected: reset velocity and position
tracking.linearVel.set(0, 0, 0);
tracking.prevPos.copyFrom(currentPos);
}
// else: same frame, multiple contacts - reuse existing velocity
// Update frame ID
tracking.frameId = window.frameId;
// Calculate angular velocity from rotation change (same frame logic as linear)
if (isNewFrame && tracking.frameId === window.frameId && !currentRot.equals(tracking.prevRot)) {
// Rotation changed: calculate ω from quaternion delta
const invPrevRot = Quaternion.Inverse(tracking.prevRot);
const deltaRot = currentRot.multiply(invPrevRot);
deltaRot.normalize();
// Extract axis-angle from quaternion
const angle = 2.0 * Math.acos(Math.max(-1, Math.min(1, deltaRot.w)));
const sinHalfAngle = Math.sqrt(Math.max(0, 1.0 - deltaRot.w * deltaRot.w));
if (sinHalfAngle > 1e-6 && Math.abs(angle) > 1e-6) {
// ω = axis * (angle / dt)
const axis = new Vector3(
deltaRot.x / sinHalfAngle,
deltaRot.y / sinHalfAngle,
deltaRot.z / sinHalfAngle
);
// Normalize angle to [-π, π]
let normalizedAngle = angle;
if (normalizedAngle > Math.PI) {
normalizedAngle -= 2.0 * Math.PI;
}
axis.scaleToRef(normalizedAngle / dt, tracking.angularVel);
} else {
tracking.angularVel.set(0, 0, 0);
}
tracking.prevRot.copyFrom(currentRot);
} else if (isNewFrame) {
// No rotation or frame gap: zero angular velocity
tracking.angularVel.set(0, 0, 0);
tracking.prevRot.copyFrom(currentRot);
}
// else: same frame, reuse existing angular velocity
// For ANIMATED bodies, use only our tracked velocities (no fallback to avoid stale values)
// For DYNAMIC bodies, fallback to physics engine velocities if no movement detected
let linearVel, angularVel;
if (motionType === PhysicsMotionType.ANIMATED) {
// Kinematic bodies: trust only our tracked velocity
linearVel = tracking.linearVel;
angularVel = tracking.angularVel;
} else {
// Dynamic bodies: use tracked or fallback to physics velocity
linearVel = tracking.linearVel.lengthSquared() > 1e-10
? tracking.linearVel
: contact.bodyB.body.getLinearVelocity(contact.bodyB.index);
angularVel = tracking.angularVel.lengthSquared() > 1e-10
? tracking.angularVel
: contact.bodyB.body.getAngularVelocity(contact.bodyB.index);
}
const massProps = contact.bodyB.body.getMassProperties(contact.bodyB.index);
const centerOfMass = this._tmpVecs[21];
if (massProps.centerOfMass) {
Vector3.TransformCoordinatesToRef(
massProps.centerOfMass,
bodyTransformNode.computeWorldMatrix(true),
centerOfMass
);
} else {
centerOfMass.copyFrom(currentPos);
}
const arm = this._tmpVecs[22];
contactPosition.subtractToRef(centerOfMass, arm);
Vector3.CrossToRef(angularVel, arm, constraint.velocity);
constraint.velocity.addInPlace(linearVel);
constraint.angularVelocity.copyFrom(angularVel);
}
const shift = constraint.velocity.dot(constraint.planeNormal) * timeTravelled;
constraint.planeDistance -= shift;
if (motionType == PhysicsMotionType.STATIC) {
constraint.priority = 2;
} else if (motionType == PhysicsMotionType.ANIMATED) {
constraint.priority = 1;
}
return constraint;
};
}
export {patchCharacterController};