fixedUpdate method
Called once per fixed physics step while the component is mounted,
enabled, and loaded. fixedDt is the fixed timestep of the
surrounding PhysicsWorld, not the frame interval.
Runs before update for the same frame and may run several times per frame when the renderer falls behind the physics rate. Most components should not override this; it exists for behavior that must advance on the physics clock (kinematic body controllers, character motion drivers). Mutation follows the same traversal rules as update.
Implementation
@override
void fixedUpdate(double fixedDt) {
if (fixedDt <= 0.0 || !isAttached) return;
// Update timers
if (_coyoteTimer > 0.0) _coyoteTimer -= fixedDt;
if (_jumpBufferTimer > 0.0) _jumpBufferTimer -= fixedDt;
if (_landingTimer > 0.0) _landingTimer -= fixedDt;
final currentPos = (node.globalTransform * vm.Vector4(0, 0, 0, 1)).xyz;
// 1. Ground detection probe via scene raycast
var detectedGround = false;
var groundY = currentPos.y - footOffset;
var norm = vm.Vector3(0, 1, 0);
// Cast downward ray starting 0.4m above the character base / feet
final rayStart = currentPos + vm.Vector3(0, 0.4 - footOffset, 0);
final ray = vm.Ray.originDirection(rayStart, vm.Vector3(0, -1, 0));
final hit = raycastNode(
_rootNode,
ray,
maxDistance: 0.8,
layerMask: groundLayerMask,
where: (n) => !_isExcluded(n),
);
if (hit != null && hit.distance <= 0.7) {
detectedGround = true;
groundY = rayStart.y - hit.distance;
norm = hit.worldNormal;
} else if (groundPlaneHeight != null &&
currentPos.y <= groundPlaneHeight! + footOffset + 0.05) {
detectedGround = true;
groundY = groundPlaneHeight!;
}
if (detectedGround &&
velocity.y <= 0.0 &&
(isGrounded || currentPos.y <= groundY + footOffset + 0.25)) {
if (!isGrounded) {
velocity.y = 0.0;
if (_airborneTime > 0.08) {
_landingTimer = landingJumpDelay;
}
_airborneTime = 0.0;
}
isGrounded = true;
_coyoteTimer = coyoteTimeWindow;
groundNormal = norm;
} else {
isGrounded = false;
_airborneTime += fixedDt;
}
// 2. Slope slide calculation
final slopeAngle =
math.acos(groundNormal.y.clamp(-1.0, 1.0)) * 180.0 / math.pi;
final isTooSteep = slopeAngle > maxSlopeAngleDegrees;
// 3. Process jump
if (_jumpBufferTimer > 0.0 &&
(isGrounded || _coyoteTimer > 0.0) &&
_landingTimer <= 0.0 &&
!isTooSteep) {
velocity.y = jumpVelocity;
isGrounded = false;
_coyoteTimer = 0.0;
_jumpBufferTimer = 0.0;
} else if (!isGrounded) {
velocity.y -= gravity * fixedDt;
}
// 4. Horizontal movement calculation (exponential smoothing)
final targetSpeed = walkSpeed * (_isRunning ? runMultiplier : 1.0);
final inputLen = _moveInput.length;
if (inputLen > 0.01) {
var inputX = _moveInput.x;
var inputZ = _moveInput.y;
if (_cameraHeadingYaw != null) {
final sinY = math.sin(_cameraHeadingYaw!);
final cosY = math.cos(_cameraHeadingYaw!);
final rotatedX = inputX * cosY + inputZ * sinY;
final rotatedZ = -inputX * sinY + inputZ * cosY;
inputX = rotatedX;
inputZ = rotatedZ;
}
final desiredVel = vm.Vector2(inputX, inputZ).normalized() * targetSpeed;
final desiredVelX = desiredVel.x;
final desiredVelZ = desiredVel.y;
final accelRate = isGrounded ? 15.0 : 4.0;
final t = 1.0 - math.exp(-accelRate * fixedDt);
velocity.x += (desiredVelX - velocity.x) * t;
velocity.z += (desiredVelZ - velocity.z) * t;
final targetYaw = math.atan2(desiredVelX, desiredVelZ);
var angleDiff = targetYaw - _currentYaw;
while (angleDiff > math.pi) {
angleDiff -= 2 * math.pi;
}
while (angleDiff < -math.pi) {
angleDiff += 2 * math.pi;
}
final rotT = 1.0 - math.exp(-turnSpeed * fixedDt);
_currentYaw += angleDiff * rotT;
} else {
final friction = isGrounded ? 12.0 : 2.0;
final t = 1.0 - math.exp(-friction * fixedDt);
velocity.x += (0.0 - velocity.x) * t;
velocity.z += (0.0 - velocity.z) * t;
}
// Slope sliding
if (isGrounded && isTooSteep) {
final slideDir = vm.Vector3(
groundNormal.x,
0,
groundNormal.z,
).normalized();
velocity.x += slideDir.x * gravity * fixedDt;
velocity.z += slideDir.z * gravity * fixedDt;
}
// 5. Obstacle collision detection and horizontal deflection
var horizMove = vm.Vector3(velocity.x, 0.0, velocity.z) * fixedDt;
if (obstacleRadius > 0.0 && horizMove.length2 > 1e-6) {
final moveDir = horizMove.normalized();
final perpLeft = vm.Vector3(-moveDir.z, 0.0, moveDir.x);
final flankOffset = obstacleRadius * 0.75;
final maxProbeDist = obstacleRadius + horizMove.length;
// Check multiple vertical levels and lateral flanks across the collision capsule
final probeOffsets = [
// Feet / lower body level
vm.Vector3(0.0, 0.3 - footOffset, 0.0),
perpLeft * flankOffset + vm.Vector3(0.0, 0.3 - footOffset, 0.0),
perpLeft * -flankOffset + vm.Vector3(0.0, 0.3 - footOffset, 0.0),
// Mid torso / waist level
vm.Vector3(0.0, 0.85 - footOffset, 0.0),
perpLeft * flankOffset + vm.Vector3(0.0, 0.85 - footOffset, 0.0),
perpLeft * -flankOffset + vm.Vector3(0.0, 0.85 - footOffset, 0.0),
// Upper torso / shoulder level
vm.Vector3(0.0, math.min(1.4, obstacleHeight - 0.2) - footOffset, 0.0),
perpLeft * flankOffset +
vm.Vector3(
0.0,
math.min(1.4, obstacleHeight - 0.2) - footOffset,
0.0,
),
perpLeft * -flankOffset +
vm.Vector3(
0.0,
math.min(1.4, obstacleHeight - 0.2) - footOffset,
0.0,
),
];
SceneRaycastHit? closestHit;
for (final offset in probeOffsets) {
final probeStart = currentPos + offset;
final hit = raycastNode(
_rootNode,
vm.Ray.originDirection(probeStart, moveDir),
maxDistance: maxProbeDist,
layerMask: groundLayerMask,
where: (n) => !_isExcluded(n),
);
if (hit != null &&
(closestHit == null || hit.distance < closestHit.distance)) {
closestHit = hit;
}
}
if (closestHit != null && closestHit.distance < maxProbeDist) {
final wallNormal = closestHit.worldNormal;
final dot = horizMove.dot(wallNormal);
if (dot < 0.0) {
horizMove -= wallNormal * dot;
velocity.x = horizMove.x / fixedDt;
velocity.z = horizMove.z / fixedDt;
}
}
}
// 6. Apply displacement converting world position back to parent local space
var newWorldPos =
currentPos + vm.Vector3(horizMove.x, velocity.y * fixedDt, horizMove.z);
if (isGrounded) {
newWorldPos.y = groundY + footOffset;
}
final newWorldRot = vm.Quaternion.axisAngle(
vm.Vector3(0, 1, 0),
_currentYaw,
);
final worldMat = vm.Matrix4.compose(newWorldPos, newWorldRot, node.scale);
final parent = node.parent;
if (parent != null) {
final invParent = parent.globalTransform.clone()..invert();
node.localTransform = invParent * worldMat;
} else {
node.localTransform = worldMat;
}
}