fixedUpdate method

  1. @override
void fixedUpdate(
  1. double fixedDt
)
override

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