CharacterController not moving with kinematic platform (moving plane goes through character)

Hi,

I’m using PhysicsCharacterController and facing an issue with its kinematic behavior.

In this Playground, I created a static physics body (a plane) that moves up and down constantly. When I jump on top of that plane, I expected the platform to carry the character along with it, but instead, the character seems to go through or jitter, as if the platform doesn’t push or move it properly.

It feels like the CharacterController isn’t reacting to kinematic/animated physics bodies.

How can I update or configure the CharacterController so that moving or kinematic platforms can carry, push, or affect the character’s movement?

Here’s the playground, just jump on the moving plane to see the issue: https://playground.babylonjs.com/#6J2BTU

cc @Cedric

@Cedric In the CharacterController class, there’s a _createSurfaceConstraint function.

On line 529, there’s a TODO comment saying // <todo Need hknpMotionUtil::predictPontVelocity

Could this be related to that TODO?

This is threejs example, I’d like to have moving kinematic platforms like these that carry the character: https://character-control.vercel.app/

Yes, I looks related. I’ll do my best to take a closer look in the coming days.

1 Like

That’d be great @Cedric. Then we will have a fully featured physics-based character controller. I think this is the only missing feature, otherwise, it is perfect and I love using it!

1 Like

@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};

That’s excellent news! No progress on my side. It’s still top of my list.

Did you create some test scene? I’d like to expand the documentation scene PG with platforms at some point and use it tp ease/test development.

Is this the browser “window” property? If so, and depending on what frameId is used for, this code will probably fail in a nodejs environment (e.g. unit test).

This id gets incremented in onBeforePhysicsObservable.

@Cedric here: Babylon.js Playground

But it is not perfect, when I move on a flat surface with consist of multiple physics bodies then there could be some spike (that’s why I have that frameId)

1 Like

Hi @ertugrulcetin

I finally took time to work on the feature : CharacterController update by CedricGuillemet · Pull Request #17514 · BabylonJS/Babylon.js · GitHub

I reused your technic with a map, it’s the good way to do it.

Instead of computing velocities, a delta position is computed from previous matrix with current one. Matrices make it easier to compute a delta between previous and current frame.

Let me know how it works in your case.

Note: I’m not super happy when platforms go down :confused: there is a least a 1 frame delay where the character loses the contact, falls then get in contact again.

2 Likes

Hi @Cedric,

Thank you for taking the time to implement this! I tested it in one of the playgrounds I have and it seems fine, but I’ll be able to test it in my game once it’s published to npm. My feedback will be more robust then, but it looks like a great implementation. I also noticed some flickering when the platform moves downward. Once I try it in my game, I’ll come back here and document all my findings.

Overall, great work!

In the following code;

public moveWithCollisions(displacement: Vector3): void {

....

const deltaTime = this._scene.deltaTime / 1000.0;
....

I’m setting sub step for my physics, so physics runs at 50 times per second and I interpolate my character’s position/rotation etc in the render fn.

Question is; using scene.deltaTime should be okay in this moveWithCollisions function right?

I don’t see any issues at the moment in my current patch, but I don’t know if there is any problem or not, I was skeptical from the beginning.

I found an issue: when the platform goes back and forth, it does not push the character; it just goes through it. https://playground.babylonjs.com/?snapshot=refs/pull/17514/merge#WO0H1U#167

Changed platform.position.y to platform.position.z

if moveWithCollisions is called once a frame, then it should be fine with scene deltaTime.

There is a 1 frame delay when platform goes down. IDK yet how to fix it properly.

2 Likes