diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
index 4ad01356d7..679b5b70e8 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalAvatarDriver.cs
@@ -509,8 +509,9 @@ public void GetBoneRotAndPos(quaternion RootRotation, Animator anim, HumanBodyBo
/// Offset vector applied to the base position.
public float3 CalculateFallbackOffset(HumanBodyBones bone, float fallbackHeight, float3 heightPercentage)
{
+ Vector3 playerUp = BasisLocalPlayer.localToWorldMatrix.MultiplyVector(Vector3.up).normalized;
Vector3 height = fallbackHeight * heightPercentage;
- return bone == HumanBodyBones.Hips ? math.mul(height, -Vector3.up) : math.mul(height, Vector3.up);
+ return bone == HumanBodyBones.Hips ? math.mul(height, -playerUp) : math.mul(height, playerUp);
}
///
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
index 227ace6eaf..7a856ccc80 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalCharacterDriver.cs
@@ -15,7 +15,7 @@ public class BasisLocalCharacterDriver
{
public BasisLocalPlayer LocalPlayer;
[System.NonSerialized] public BasisLocalAnimatorDriver LocalAnimatorDriver;
- public CharacterController characterController;
+ public BasisKinematicCharacterController characterController;
public Vector3 bottomPointLocalSpace;
public Vector3 LastBottomPoint;
public bool groundedPlayer;
@@ -103,6 +103,23 @@ public void SetMode(Mode mode)
public Quaternion CurrentRotation;
public CollisionFlags Flags;
public float radius;
+ ///
+ /// The direction gravity pulls the character. Defaults to Vector3.down.
+ /// Changing this allows the character to walk on walls/ceilings.
+ ///
+ public Vector3 GravityDirection
+ {
+ get => characterController != null ? characterController.GravityDirection : Vector3.down;
+ set
+ {
+ if (characterController != null)
+ characterController.GravityDirection = value;
+ }
+ }
+ ///
+ /// The up direction for the character, opposite of GravityDirection.
+ ///
+ public Vector3 UpDirection => characterController != null ? characterController.UpDirection : Vector3.up;
public Vector2 MovementVector { get; private set; }
///
/// A value between 0 and 1 representing the relative speed of player movement.
@@ -162,8 +179,10 @@ public void Initialize(BasisLocalPlayer localPlayer)
LocalPlayer = localPlayer;
BasisLocalPlayerTransform = localPlayer.transform;
LocalAnimatorDriver = localPlayer.LocalAnimatorDriver;
+ characterController.PlayerInitialize();
characterController.minMoveDistance = 0;
characterController.skinWidth = 0.01f;
+ characterController.OnKCCColliderHit = OnKCCHit;
if (!HasEvents)
{
HasEvents = true;
@@ -175,11 +194,10 @@ public void Initialize(BasisLocalPlayer localPlayer)
SetMode(Mode.Walk);
}
- public void OnControllerColliderHit(ControllerColliderHit hit)
+ private void OnKCCHit(KCCHitInfo hit)
{
if (CanPushRigidbodys)
{
- // Check if the hit object has a Rigidbody and if it is not kinematic
Rigidbody body = hit.collider.attachedRigidbody;
if (body == null || body.isKinematic)
@@ -187,10 +205,9 @@ public void OnControllerColliderHit(ControllerColliderHit hit)
return;
}
- // Ensure we're only pushing objects in the horizontal plane
- Vector3 pushDir = new Vector3(hit.moveDirection.x, 0, hit.moveDirection.z);
+ // Project push direction onto the plane perpendicular to gravity (horizontal)
+ Vector3 pushDir = hit.moveDirection - UpDirection * Vector3.Dot(hit.moveDirection, UpDirection);
- // Apply the force to the object
body.AddForce(pushDir * pushPower, ForceMode.Impulse);
}
}
@@ -263,7 +280,7 @@ public void SimulateMovement(float DeltaTime)
// Get the current rotation and position of the player
Vector3 pivot = BasisLocalBoneDriver.EyeControl.OutgoingWorldData.position;
- Vector3 upAxis = Vector3.up;
+ Vector3 upAxis = UpDirection;
// Calculate direction from the pivot to the current position
Vector3 directionToPivot = CurrentPosition - pivot;
@@ -279,7 +296,7 @@ public void SimulateMovement(float DeltaTime)
BasisLocalPlayerTransform.SetPositionAndRotation(FinalRotation, rotation * CurrentRotation);
float HeightOffset = (characterController.height / 2) - characterController.radius;
- bottomPointLocalSpace = FinalRotation + (characterController.center - new Vector3(0, HeightOffset, 0));
+ bottomPointLocalSpace = FinalRotation + (characterController.center - upAxis * HeightOffset);
Quaternion newRot = rotation * CurrentRotation;
Vector3 newPos = FinalRotation;
@@ -387,16 +404,20 @@ public void SetMovementVector(Vector2 movement)
}
public void HandleMovement(float DeltaTime)
{
- // Cache current rotation and zero out x and z components
+ // Cache current rotation and flatten to the plane perpendicular to gravity
currentRotation = BasisLocalBoneDriver.HeadControl.OutgoingWorldData.rotation;
- Vector3 rotationEulerAngles = currentRotation.eulerAngles;
- rotationEulerAngles.x = 0;
- rotationEulerAngles.z = 0;
-
- Quaternion flattenedRotation = Quaternion.Euler(rotationEulerAngles);
+ Vector3 up = UpDirection;
+ Vector3 flatForward = currentRotation * Vector3.forward;
+ flatForward -= up * Vector3.Dot(flatForward, up);
+ if (flatForward.sqrMagnitude < 0.0001f)
+ {
+ flatForward = -(currentRotation * Vector3.up);
+ flatForward -= up * Vector3.Dot(flatForward, up);
+ }
+ Quaternion flattenedRotation = Quaternion.LookRotation(flatForward.normalized, up);
if (CrouchBlendDelta != 0) UpdateCrouchBlend(CrouchBlendDelta);
- // Calculate horizontal movement direction
+ // Calculate horizontal movement direction (in the character's gravity-relative plane)
Vector3 horizontalMoveDirection = new Vector3(MovementVector.x, 0, MovementVector.y).normalized;
CurrentSpeed = math.lerp(MinimumMovementSpeed, MaximumMovementSpeed, MovementSpeedScale) + MinimumMovementSpeed * MovementSpeedBoost;
@@ -426,7 +447,8 @@ public void HandleMovement(float DeltaTime)
HasJumpAction = false;
- totalMoveDirection.y = currentVerticalSpeed * DeltaTime;
+ // Apply vertical speed along the gravity up axis instead of world Y
+ totalMoveDirection += up * (currentVerticalSpeed * DeltaTime);
// Move character
Flags = characterController.Move(totalMoveDirection);
@@ -482,7 +504,7 @@ public void CalculateCharacterSize()
}
// Clamp stepOffset to something sane relative to height
- float maxStep = (finalHeight + 2f * characterController.radius) - 0.001f;
+ float maxStep = (finalHeight + 2f * radius) - 0.001f;
maxStep = Mathf.Max(0f, maxStep);
maxStep = Mathf.Min(maxStep, finalHeight * 0.25f);
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalRigDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalRigDriver.cs
index 10787bacf7..3797b8171d 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalRigDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalRigDriver.cs
@@ -309,6 +309,7 @@ public void SimulateIKDestinations(float deltaTime)
}
timeAccumulator += Mathf.Max(deltaTime, 1e-6f);
+ Vector3 cachedPlayerUp = localPlayer.LocalCharacterDriver.UpDirection;
BasisFullBodyData data = BasisFullIKConstraint.data;
@@ -375,7 +376,7 @@ public void SimulateIKDestinations(float deltaTime)
hipsRot = EuroRot[S_Hips] ? fRotHips.Filter(hipsRot, timeAccumulator) : FallbackRot(ref sRotHips, hipsRot, deltaTime);
}
- hipsPos.y -= localPlayer.LocalCharacterDriver.landingCrouchEffect;
+ hipsPos -= cachedPlayerUp * localPlayer.LocalCharacterDriver.landingCrouchEffect;
data.PositionHips = hipsPos;
data.RotationHips = hipsRot;
@@ -525,9 +526,7 @@ public void SimulateIKDestinations(float deltaTime)
bool hipsHaveTracker = BasisLocalBoneDriver.HipsControl.HasTracked == BasisHasTracked.HasTracker;
if (!hipsHaveTracker)
{
- data.PositionHips = new Vector3(data.PositionHips.x,
- data.PositionHips.y + footDriver.ComputeHipBob() * footIKBlendWeight,
- data.PositionHips.z);
+ data.PositionHips += cachedPlayerUp * (footDriver.ComputeHipBob() * footIKBlendWeight);
}
}
@@ -568,7 +567,7 @@ public void SimulateIKDestinations(float deltaTime)
}
else if (footIKBlendWeightLeft > 0.001f && footDriverReady)
{
- Quaternion targetRotL = ComputeKneeHintRotation(data.PositionHips, data.LeftFootPosition, footDriver.LeftKneeHint);
+ Quaternion targetRotL = ComputeKneeHintRotation(data.PositionHips, data.LeftFootPosition, footDriver.LeftKneeHint, cachedPlayerUp);
float kneeRotAlpha = 1f - Mathf.Exp(-8f * deltaTime);
smoothedLeftKneeRot = Quaternion.Slerp(smoothedLeftKneeRot, targetRotL, kneeRotAlpha);
data.PositionLeftLowerLeg = footDriver.LeftKneeHint;
@@ -602,7 +601,7 @@ public void SimulateIKDestinations(float deltaTime)
}
else if (footIKBlendWeightRight > 0.001f && footDriverReady)
{
- Quaternion targetRotR = ComputeKneeHintRotation(data.PositionHips, data.RightFootPosition, footDriver.RightKneeHint);
+ Quaternion targetRotR = ComputeKneeHintRotation(data.PositionHips, data.RightFootPosition, footDriver.RightKneeHint, cachedPlayerUp);
float kneeRotAlpha = 1f - Mathf.Exp(-8f * deltaTime);
smoothedRightKneeRot = Quaternion.Slerp(smoothedRightKneeRot, targetRotR, kneeRotAlpha);
@@ -743,6 +742,7 @@ public void SimulateIKDestinations(float deltaTime)
data.KneeBendPrefRight = (hipsRot * Vector3.right);
data.SpineBendNormal = (fwd * spineBendNormalWeights.x + outR * spineBendNormalWeights.y + up * spineBendNormalWeights.z).normalized;
+ data.PlayerUp = cachedPlayerUp;
// Commit & evaluate
BasisFullIKConstraint.data = data;
@@ -1094,7 +1094,7 @@ private static Vector3 ComputeKneeBendNormal(Vector3 hip, Vector3 foot, Vector3
/// Forward = knee→foot direction, Up = derived from the bend plane.
/// This prevents snapping that occurs with Quaternion.identity.
///
- private static Quaternion ComputeKneeHintRotation(Vector3 hip, Vector3 foot, Vector3 kneeHint)
+ private static Quaternion ComputeKneeHintRotation(Vector3 hip, Vector3 foot, Vector3 kneeHint, Vector3 playerUp)
{
Vector3 kneeToFoot = foot - kneeHint;
Vector3 kneeToHip = hip - kneeHint;
@@ -1110,7 +1110,7 @@ private static Quaternion ComputeKneeHintRotation(Vector3 hip, Vector3 foot, Vec
Vector3 up = Vector3.Cross(fwd, bendNormal);
if (up.sqrMagnitude < 1e-8f)
- up = Vector3.up;
+ up = playerUp;
else
up.Normalize();
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalVirtualSpineDriver.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalVirtualSpineDriver.cs
index 3a8a202e79..0cb3888ec9 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalVirtualSpineDriver.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisLocalVirtualSpineDriver.cs
@@ -137,6 +137,11 @@ public void OnSimulate()
float dt = Time.deltaTime;
Matrix4x4 parentMatrix = BasisLocalPlayer.localToWorldMatrix;
+ // OutGoingData is in local space where Y is always the character's up axis,
+ // regardless of world orientation. Use Vector3.up for all local-space operations.
+ // (World-space player up is handled by the rig driver / FBIK animation job.)
+ Vector3 worldUp = Vector3.up;
+
// =========================
// 1) HEAD & NECK (top cues)
// =========================
@@ -146,27 +151,19 @@ public void OnSimulate()
neck.OutGoingData.rotation = SmoothSlerp(neck.OutGoingData.rotation, head.OutGoingData.rotation, NeckRotationSpeed, dt);
// Positions for head/neck come from their tracker-driven targets + offsets
- ApplyPositionControl(head, parentMatrix, torsoLock: false);
- ApplyPositionControl(neck, parentMatrix, torsoLock: false);
+ ApplyPositionControl(head, parentMatrix, torsoLock: false, worldUp);
+ ApplyPositionControl(neck, parentMatrix, torsoLock: false, worldUp);
Vector3 neckPosWorld = neck.OutGoingData.position;
- // ===========================================
- // 2) HIPS: build from neck and preserved span
- // ===========================================
- // Determine a stable world up
- Vector3 worldUp = parentMatrix.MultiplyVector(Vector3.up).normalized;
- if (worldUp.sqrMagnitude < 1e-6f) worldUp = Vector3.up;
-
// Preserve total length neck→hips, except when overridden.
Vector3 idealHips = HipsFreezeToTpose ? hips.TposeLocalScaled.position : neckPosWorld - worldUp * _lenTotal;
// Add small forward bias using head yaw, which also applies to the hips, except when overridden.
- Quaternion headYaw = HipsFreezeToTpose ? Quaternion.identity : ExtractYawRotation(head.OutGoingData.rotation);
+ Quaternion headYaw = HipsFreezeToTpose ? Quaternion.identity : ExtractYawRotation(head.OutGoingData.rotation, worldUp);
idealHips += (headYaw * Vector3.forward) * (HipsForwardBias * BasisHeightDriver.AvatarToDefaultRatioScaledWithAvatarScale);
-
- // Blend XZ with tracked hips for authority retention
+ // Blend horizontal position with tracked hips for authority retention
Vector3 trackedHips = hips.Target.OutGoingData.position;
Vector3 blendedHips = idealHips;
if (HipsXZFollowBlend > 0f)
@@ -177,14 +174,14 @@ public void OnSimulate()
// Hips rotation follows head yaw, damped.
Quaternion hipsYawTarget = headYaw;
- hips.OutGoingData.rotation = ExtractYawRotation(SmoothSlerp(hips.OutGoingData.rotation, hipsYawTarget, HipsRotationSpeed, dt));
+ hips.OutGoingData.rotation = ExtractYawRotation(SmoothSlerp(hips.OutGoingData.rotation, hipsYawTarget, HipsRotationSpeed, dt), worldUp);
hips.OutGoingData.position = blendedHips;
hips.ApplyWorldAndLast(parentMatrix);
// =======================================================
// 3) Fill the middle: chest & spine positions and yaws
// =======================================================
- Quaternion neckYaw = ExtractYawRotation(neck.OutGoingData.rotation);
+ Quaternion neckYaw = ExtractYawRotation(neck.OutGoingData.rotation, worldUp);
Quaternion hipsYaw = hips.OutGoingData.rotation; // already yaw-only
Vector3 neckToHips = hips.OutGoingData.position - neck.OutGoingData.position;
@@ -193,8 +190,8 @@ public void OnSimulate()
if (distNeckToHips < 1e-5f)
{
// Guard: fall back to tracker-driven positions
- ApplyPositionControl(chest, parentMatrix, torsoLock: true);
- ApplyPositionControl(spine, parentMatrix, torsoLock: true);
+ ApplyPositionControl(chest, parentMatrix, torsoLock: true, worldUp);
+ ApplyPositionControl(spine, parentMatrix, torsoLock: true, worldUp);
}
else
{
@@ -211,15 +208,15 @@ public void OnSimulate()
// Smooth rotations
chest.OutGoingData.rotation = ExtractYawRotation(
- SmoothSlerp(chest.OutGoingData.rotation, chestYawTarget, ChestRotationSpeed, dt)
+ SmoothSlerp(chest.OutGoingData.rotation, chestYawTarget, ChestRotationSpeed, dt), worldUp
);
spine.OutGoingData.rotation = ExtractYawRotation(
- SmoothSlerp(spine.OutGoingData.rotation, spineYawTarget, SpineRotationSpeed, dt)
+ SmoothSlerp(spine.OutGoingData.rotation, spineYawTarget, SpineRotationSpeed, dt), worldUp
);
- // Apply positions with offsets (torsoLock removes vertical offset)
- ApplyPositionWithGivenBase(chest, parentMatrix, chestPos, torsoLock: true);
- ApplyPositionWithGivenBase(spine, parentMatrix, spinePos, torsoLock: true);
+ // Apply positions with offsets (torsoLock removes up-axis offset)
+ ApplyPositionWithGivenBase(chest, parentMatrix, chestPos, torsoLock: true, worldUp);
+ ApplyPositionWithGivenBase(spine, parentMatrix, spinePos, torsoLock: true, worldUp);
}
// Finalize head/neck
@@ -229,18 +226,21 @@ public void OnSimulate()
///
/// Applies tracker-driven position plus offset for a bone control,
- /// optionally locking vertical to TPose baseline and yaw-only rotation.
+ /// optionally locking the up-axis component to TPose baseline and yaw-only rotation.
///
- private void ApplyPositionControl(BasisLocalBoneControl boneControl, Matrix4x4 parentMatrix, bool torsoLock)
+ private void ApplyPositionControl(BasisLocalBoneControl boneControl, Matrix4x4 parentMatrix, bool torsoLock, Vector3 up)
{
Quaternion rot = boneControl.Target.OutGoingData.rotation;
- if (torsoLock) rot = ExtractYawRotation(rot);
+ if (torsoLock) rot = ExtractYawRotation(rot, up);
Vector3 localOffset = boneControl.ScaledOffset;
if (torsoLock) localOffset.y = 0f;
Vector3 desired = boneControl.Target.OutGoingData.position + (rot * localOffset);
- if (torsoLock) desired.y = boneControl.TposeLocalScaled.position.y;
+ if (torsoLock)
+ {
+ desired.y = boneControl.TposeLocalScaled.position.y;
+ }
boneControl.OutGoingData.position = desired;
boneControl.ApplyWorldAndLast(parentMatrix);
@@ -249,16 +249,19 @@ private void ApplyPositionControl(BasisLocalBoneControl boneControl, Matrix4x4 p
///
/// Applies position using a provided world base position and the control's yaw/offset rules.
///
- private void ApplyPositionWithGivenBase(BasisLocalBoneControl boneControl, Matrix4x4 parentMatrix, Vector3 basePositionWorld, bool torsoLock)
+ private void ApplyPositionWithGivenBase(BasisLocalBoneControl boneControl, Matrix4x4 parentMatrix, Vector3 basePositionWorld, bool torsoLock, Vector3 up)
{
Quaternion rot = boneControl.OutGoingData.rotation;
- if (torsoLock) rot = ExtractYawRotation(rot);
+ if (torsoLock) rot = ExtractYawRotation(rot, up);
Vector3 localOffset = boneControl.ScaledOffset;
if (torsoLock) localOffset.y = 0f;
Vector3 desired = basePositionWorld + (rot * localOffset);
- if (torsoLock) desired.y = boneControl.TposeLocalScaled.position.y;
+ if (torsoLock)
+ {
+ desired.y = boneControl.TposeLocalScaled.position.y;
+ }
boneControl.OutGoingData.position = desired;
boneControl.ApplyWorldAndLast(parentMatrix);
@@ -274,14 +277,14 @@ private static Quaternion SmoothSlerp(Quaternion current, Quaternion target, flo
}
///
- /// Extracts yaw-only rotation (around global up) from a full quaternion.
+ /// Extracts yaw-only rotation (around the given up axis) from a full quaternion.
///
- private static Quaternion ExtractYawRotation(Quaternion rotation)
+ private static Quaternion ExtractYawRotation(Quaternion rotation, Vector3 up)
{
Vector3 f = rotation * Vector3.forward;
- f.y = 0f;
+ f -= up * Vector3.Dot(f, up); // project onto plane perpendicular to up
if (f.sqrMagnitude < 1e-6f) f = Vector3.forward;
f.Normalize();
- return Quaternion.LookRotation(f, Vector3.up);
+ return Quaternion.LookRotation(f, up);
}
}
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisFlyMovementMode.cs b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisFlyMovementMode.cs
index f760ac0989..1ea7798ccb 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisFlyMovementMode.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisFlyMovementMode.cs
@@ -14,7 +14,7 @@ public void Enter(BasisLocalCharacterDriver ctx)
{
if (ctx.characterController != null)
{
- ctx.characterController.detectCollisions = true; // solid, but no gravity
+ ctx.characterController.detectCollisions = true;
ctx.characterController.enabled = true;
}
ctx.currentVerticalSpeed = 0f;
@@ -24,16 +24,18 @@ public void Exit(BasisLocalCharacterDriver ctx) { }
public void Tick(BasisLocalCharacterDriver ctx, float dt)
{
- // Project head forward onto horizontal plane (avoids gimbal lock near ±90° pitch)
+ Vector3 up = ctx.UpDirection;
+
+ // Project head forward onto the plane perpendicular to gravity
Quaternion headRot = BasisLocalBoneDriver.HeadControl.OutgoingWorldData.rotation;
Vector3 flatForward = headRot * Vector3.forward;
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
if (flatForward.sqrMagnitude < 0.0001f)
{
flatForward = -(headRot * Vector3.up);
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
}
- Quaternion facing = Quaternion.LookRotation(flatForward.normalized, Vector3.up);
+ Quaternion facing = Quaternion.LookRotation(flatForward.normalized, up);
// Planar
Vector3 planar = new Vector3(ctx.MovementVector.x, 0, ctx.MovementVector.y).normalized;
@@ -44,8 +46,8 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
Vector3 move = facing * planar * ctx.CurrentSpeed * dt;
- // ===== Vertical input (held) =====
- move.y = ctx.GetVerticalMovement() * ctx.CurrentSpeed * dt;
+ // Vertical input along gravity-relative up axis
+ move += up * (ctx.GetVerticalMovement() * ctx.CurrentSpeed * dt);
// Clear tap
ctx.HasJumpAction = false;
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs
new file mode 100644
index 0000000000..20a9180b3f
--- /dev/null
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs
@@ -0,0 +1,634 @@
+using UnityEngine;
+
+namespace Basis.Scripts.BasisCharacterController
+{
+ ///
+ /// A kinematic character controller that replaces Unity's built-in CharacterController.
+ /// Uses a CapsuleCollider + kinematic Rigidbody so that the player can be rotated
+ /// freely, enabling custom gravity directions (not Y-axis locked).
+ ///
+ [RequireComponent(typeof(Rigidbody))]
+ [RequireComponent(typeof(CapsuleCollider))]
+ public class BasisKinematicCharacterController : MonoBehaviour
+ {
+ // Capsule Collider
+ public float _height = 2f;
+ public float _radius = 0.3f;
+ public Vector3 _center = new Vector3(0f, 1f, 0f);
+
+ // CharacterController Parameters
+ public float _skinWidth = 0.01f;
+ public float _stepOffset = 0.3f;
+ public float _minMoveDistance = 0f;
+ public float _slopeLimit = 45f;
+ public bool _detectCollisions = true;
+
+ // Gravity
+ public Vector3 _gravityDirection = Vector3.down;
+
+ // Runtime Info
+ public Rigidbody _rigidbody;
+ public CapsuleCollider _capsule;
+ public bool isGrounded;
+ private Vector3 _groundNormal = Vector3.up;
+ private CollisionFlags _lastFlags;
+
+ // Collision
+ // 16 hits/overlaps covers worst-case scenarios for capsule casts in typical
+ // game environments. NonAlloc silently drops excess results but 16 is generous —
+ // most casts hit 1-3 colliders. Increase only if levels have extreme collider density.
+ private const int MaxHits = 16;
+ private const int MaxOverlaps = 16;
+ private const int MaxDepenetrationIterations = 4;
+ private const int MaxMoveIterations = 4;
+ private readonly RaycastHit[] _hitBuffer = new RaycastHit[MaxHits];
+ private readonly Collider[] _overlapBuffer = new Collider[MaxOverlaps];
+ private const float GroundProbeExtra = 0.04f;
+ public delegate void KCCColliderHit(KCCHitInfo hit);
+ public KCCColliderHit OnKCCColliderHit;
+
+ // Cached collision mask — rebuilt once per Move() instead of per-cast.
+ private int _collisionMask;
+ private bool _collisionMaskDirty = true;
+
+ // Cached per-Move() to avoid repeated Transform property access.
+ private Quaternion _cachedRotation;
+
+ //Public Get Set
+
+ public float height
+ {
+ get => _height;
+ set
+ {
+ _height = Mathf.Max(value, 0.001f);
+ SyncCapsule();
+ }
+ }
+
+ public float radius
+ {
+ get => _radius;
+ set
+ {
+ _radius = Mathf.Max(value, 0.001f);
+ SyncCapsule();
+ }
+ }
+
+ public Vector3 center
+ {
+ get => _center;
+ set
+ {
+ _center = value;
+ SyncCapsule();
+ }
+ }
+
+ public float skinWidth
+ {
+ get => _skinWidth;
+ set => _skinWidth = Mathf.Max(value, 0.001f);
+ }
+
+ public float stepOffset
+ {
+ get => _stepOffset;
+ set => _stepOffset = Mathf.Max(value, 0f);
+ }
+
+ public float minMoveDistance
+ {
+ get => _minMoveDistance;
+ set => _minMoveDistance = Mathf.Max(value, 0f);
+ }
+
+ public float slopeLimit
+ {
+ get => _slopeLimit;
+ set => _slopeLimit = Mathf.Clamp(value, 0f, 90f);
+ }
+
+ public bool detectCollisions
+ {
+ get => _detectCollisions;
+ set
+ {
+ _detectCollisions = value;
+ _capsule.enabled = value;
+ }
+ }
+
+ ///
+ /// The ground surface normal from the last Move() call. Only valid when isGrounded is true.
+ ///
+ public Vector3 groundNormal => _groundNormal;
+
+ ///
+ /// The direction gravity pulls the character. Defaults to Vector3.down.
+ /// Setting this allows the character to walk on walls/ceilings.
+ /// Must be normalized.
+ ///
+ public Vector3 GravityDirection
+ {
+ get => _gravityDirection;
+ set => _gravityDirection = value.normalized;
+ }
+
+ ///
+ /// The "up" direction for this character, opposite of gravity.
+ /// Guaranteed to be normalized; falls back to Vector3.up if gravity direction is zero.
+ ///
+ public Vector3 UpDirection
+ {
+ get
+ {
+ Vector3 up = -_gravityDirection;
+ if (up.sqrMagnitude < 0.0001f) up = Vector3.up;
+ return up.normalized;
+ }
+ }
+
+ #region UNITY LIFECYCLE
+
+ public void PlayerInitialize()
+ {
+ // Ensure gravity direction is valid (may be zero if deserialized from a fresh component)
+ if (_gravityDirection.sqrMagnitude < 0.0001f)
+ _gravityDirection = Vector3.down;
+ SyncCapsule();
+ }
+
+ private void SyncCapsule()
+ {
+ _capsule.direction = 1; // Y-axis
+ _capsule.center = _center;
+ _capsule.radius = _radius;
+ _capsule.height = _height;
+ _capsule.isTrigger = false;
+ }
+
+ // ── Core Move method ────────────────────────────────────────────
+
+ ///
+ /// Moves the character by with full collision
+ /// resolution. Returns CollisionFlags indicating which sides were hit.
+ /// Gravity/jump velocity should already be included in motion.
+ ///
+ public CollisionFlags Move(Vector3 motion)
+ {
+ _lastFlags = CollisionFlags.None;
+ _collisionMaskDirty = true; // Refresh mask once per Move()
+
+ if (!enabled || !_detectCollisions)
+ {
+ transform.position += motion;
+ isGrounded = false;
+ return _lastFlags;
+ }
+
+ // Cache rotation once per Move() — used by GetCapsuleEnds, GroundProbe, Depenetrate.
+ transform.GetPositionAndRotation(out Vector3 pos, out _cachedRotation);
+
+ if (motion.sqrMagnitude < _minMoveDistance * _minMoveDistance)
+ {
+ GroundProbe(pos);
+ return _lastFlags;
+ }
+
+ Vector3 up = UpDirection;
+ float cosSlope = Mathf.Cos(_slopeLimit * Mathf.Deg2Rad);
+ float verticalComponent = Vector3.Dot(motion, up);
+ Vector3 verticalMotion = up * verticalComponent;
+ Vector3 horizontalMotion = motion - verticalMotion;
+ bool movingDown = verticalComponent < 0f;
+
+ // ── Grounded behaviour: slope projection + ground snap ──────
+ if (isGrounded && movingDown)
+ {
+ float groundDot = Vector3.Dot(_groundNormal, up);
+ bool walkableSlope = groundDot >= cosSlope;
+
+ if (walkableSlope)
+ {
+ if (horizontalMotion.sqrMagnitude > 0.00001f)
+ {
+ horizontalMotion = Vector3.ProjectOnPlane(horizontalMotion, _groundNormal);
+ float origLen = (motion - verticalMotion).magnitude;
+ float projLen = horizontalMotion.magnitude;
+ if (projLen > 0.00001f)
+ horizontalMotion = horizontalMotion * (origLen / projLen);
+ }
+ verticalMotion = Vector3.zero;
+ }
+ }
+
+ // ── Horizontal movement with step-up fallback ───────────────
+ if (horizontalMotion.sqrMagnitude > 0.00001f)
+ {
+ Vector3 beforeSlide = pos;
+ pos = SimpleMove(pos, horizontalMotion, ref _lastFlags, up, cosSlope, isHorizontal: true);
+
+ // If grounded and slide made little horizontal progress, try stepping up
+ if (isGrounded && _stepOffset > 0f && movingDown)
+ {
+ Vector3 traveled = pos - beforeSlide;
+ Vector3 horizontalTraveled = traveled - up * Vector3.Dot(traveled, up);
+ Vector3 horizontalWanted = horizontalMotion - up * Vector3.Dot(horizontalMotion, up);
+ float wantedLen = horizontalWanted.magnitude;
+
+ if (wantedLen > 0.001f && horizontalTraveled.magnitude < wantedLen * 0.5f)
+ {
+ // Blocked — try step-up from the pre-slide position
+ Vector3 stepPos = beforeSlide;
+ if (TryStepUp(ref stepPos, horizontalMotion, up, cosSlope))
+ {
+ pos = stepPos;
+ }
+ }
+ }
+ }
+
+ // vertical movement
+ if (verticalMotion.sqrMagnitude > 0.00001f)
+ {
+ pos = SimpleMove(pos, verticalMotion, ref _lastFlags, up, cosSlope, isHorizontal: false);
+ }
+
+ // snap to ground
+ if (isGrounded && verticalComponent <= 0f)
+ {
+ pos = GroundSnap(pos, up, cosSlope);
+ }
+
+ // Depenetration if clipping with a collider
+ pos = Depenetrate(pos);
+
+ // Single transform write per Move() call
+ transform.position = pos;
+
+ // Ground probe
+ GroundProbe(pos);
+
+ return _lastFlags;
+ }
+
+ private Vector3 SimpleMove(Vector3 position, Vector3 motion, ref CollisionFlags flags, Vector3 up, float cosSlope, bool isHorizontal)
+ {
+ if (motion.sqrMagnitude < 0.00001f) return position;
+
+ Vector3 remaining = motion;
+ for (int i = 0; i < MaxMoveIterations && remaining.sqrMagnitude > 0.00001f; i++)
+ {
+ float dist = remaining.magnitude;
+ Vector3 dir = remaining / dist;
+
+ GetCapsuleEnds(position, out Vector3 p1, out Vector3 p2);
+ float castRadius = _radius - _skinWidth;
+ if (castRadius < 0.001f) castRadius = 0.001f;
+
+ int hitCount = Physics.CapsuleCastNonAlloc(
+ p1, p2, castRadius,
+ dir, _hitBuffer,
+ dist + _skinWidth,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ if (!FindClosestHit(hitCount, out RaycastHit closestHit))
+ {
+ position += remaining;
+ break;
+ }
+
+ // Move up to the hit point (minus skin)
+ float safeDistance = Mathf.Max(closestHit.distance - _skinWidth, 0f);
+ position += dir * safeDistance;
+
+ // Classify collision
+ Vector3 hitNormal = closestHit.normal;
+ float dotUp = Vector3.Dot(hitNormal, up);
+
+ if (dotUp > 0.7f)
+ flags |= CollisionFlags.Below;
+ else if (dotUp < -0.7f)
+ flags |= CollisionFlags.Above;
+ else
+ flags |= CollisionFlags.Sides;
+
+ FireHitCallback(closestHit, dir, dist);
+
+ // move along the surface
+ remaining -= dir * safeDistance;
+ remaining = Vector3.ProjectOnPlane(remaining, hitNormal);
+
+ // For horizontal movement on steep slopes, prevent climbing
+ if (isHorizontal && dotUp > 0f && dotUp < cosSlope)
+ {
+ float upComponent = Vector3.Dot(remaining, up);
+ if (upComponent > 0f)
+ remaining -= up * upComponent;
+ }
+ }
+
+ return position;
+ }
+
+ private bool TryStepUp(ref Vector3 pos, Vector3 horizontalMotion, Vector3 up, float cosSlope)
+ {
+ float castRadius = _radius - _skinWidth;
+ if (castRadius < 0.001f) castRadius = 0.001f;
+
+ Vector3 hDir = horizontalMotion.normalized;
+ // Use a generous forward distance so we clear the step edge.
+ // At minimum cast the frame motion, but ensure we cast at least
+ // radius + skin so the capsule actually clears the step lip.
+ float hDist = Mathf.Max(horizontalMotion.magnitude, _radius + _skinWidth);
+
+ // Phase 1: Cast UP to find ceiling clearance
+ float maxUpDist = _stepOffset;
+ GetCapsuleEnds(pos, out Vector3 up1, out Vector3 up2);
+ int hitCount = Physics.CapsuleCastNonAlloc(
+ up1, up2, castRadius,
+ up, _hitBuffer,
+ maxUpDist + _skinWidth,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+ if (FindClosestHit(hitCount, out RaycastHit ceilingHit))
+ {
+ maxUpDist = Mathf.Max(ceilingHit.distance - _skinWidth, 0f);
+ }
+ if (maxUpDist < 0.01f)
+ return false; // No room to step up
+
+ Vector3 elevated = pos + up * maxUpDist;
+
+ // Phase 2: Cast FORWARD at elevated height
+ GetCapsuleEnds(elevated, out Vector3 ep1, out Vector3 ep2);
+ hitCount = Physics.CapsuleCastNonAlloc(
+ ep1, ep2, castRadius,
+ hDir, _hitBuffer,
+ hDist + _skinWidth,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ float forwardDist = hDist;
+ if (FindClosestHit(hitCount, out RaycastHit forwardHit))
+ {
+ forwardDist = Mathf.Max(forwardHit.distance - _skinWidth, 0f);
+ }
+ // Need at least a tiny forward movement to land on top of the step
+ if (forwardDist < _skinWidth)
+ return false;
+
+ Vector3 forwarded = elevated + hDir * Mathf.Min(forwardDist, hDist);
+
+ // Phase 3: Cast DOWN to find the step surface
+ GetCapsuleEnds(forwarded, out Vector3 dp1, out Vector3 dp2);
+ float downDist = maxUpDist + GroundProbeExtra;
+ hitCount = Physics.CapsuleCastNonAlloc(
+ dp1, dp2, castRadius,
+ -up, _hitBuffer,
+ downDist,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ if (!FindClosestHit(hitCount, out RaycastHit stepHit))
+ return false; // No ground found after stepping
+
+ // Verify the surface is walkable
+ float dotUp = Vector3.Dot(stepHit.normal, up);
+ if (dotUp < cosSlope)
+ return false;
+
+ // Snap down to the step surface
+ float snapDown = Mathf.Max(stepHit.distance - _skinWidth, 0f);
+ Vector3 finalPos = forwarded - up * snapDown;
+
+ // Must have actually gained height
+ float heightGain = Vector3.Dot(finalPos - pos, up);
+ if (heightGain < 0.001f)
+ return false;
+
+ pos = finalPos;
+ _lastFlags |= CollisionFlags.Below;
+ return true;
+ }
+
+ ///
+ /// When grounded and not jumping, cast downward to anchor the character
+ /// to the ground surface. Prevents floating over small bumps and slopes.
+ ///
+ private Vector3 GroundSnap(Vector3 position, Vector3 up, float cosSlope)
+ {
+ GetCapsuleEnds(position, out Vector3 p1, out Vector3 p2);
+ float castRadius = _radius - _skinWidth;
+ if (castRadius < 0.001f) castRadius = 0.001f;
+
+ // Snap distance: enough to cover step offset + skin + small gap
+ float snapDist = _stepOffset + _skinWidth + GroundProbeExtra;
+ int hitCount = Physics.CapsuleCastNonAlloc(
+ p1, p2, castRadius,
+ -up, _hitBuffer,
+ snapDist,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ if (FindClosestHit(hitCount, out RaycastHit snapHit))
+ {
+ float dotUp = Vector3.Dot(snapHit.normal, up);
+ if (dotUp >= cosSlope)
+ {
+ float drop = Mathf.Max(snapHit.distance - _skinWidth, 0f);
+ if (drop > 0.0001f)
+ {
+ position -= up * drop;
+ _lastFlags |= CollisionFlags.Below;
+ }
+ }
+ }
+
+ return position;
+ }
+
+ private Vector3 Depenetrate(Vector3 position)
+ {
+ for (int iter = 0; iter < MaxDepenetrationIterations; iter++)
+ {
+ GetCapsuleEnds(position, out Vector3 p1, out Vector3 p2);
+
+ int overlapCount = Physics.OverlapCapsuleNonAlloc(
+ p1, p2, _radius,
+ _overlapBuffer,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ bool resolved = true;
+ for (int i = 0; i < overlapCount; i++)
+ {
+ Collider other = _overlapBuffer[i];
+ if (other == _capsule) continue;
+
+ if (Physics.ComputePenetration(
+ _capsule, position, _cachedRotation,
+ other, other.transform.position, other.transform.rotation,
+ out Vector3 dir, out float dist))
+ {
+ position += dir * (dist + 0.001f);
+ resolved = false;
+ }
+ }
+
+ if (resolved) break;
+ }
+
+ return position;
+ }
+
+ private void GroundProbe(Vector3 pos)
+ {
+ if (!_detectCollisions || !enabled)
+ {
+ isGrounded = false;
+ _groundNormal = UpDirection;
+ return;
+ }
+
+ Vector3 up = UpDirection;
+
+ // Cast a small sphere downward from the bottom of the capsule
+ Vector3 worldCenter = pos + _cachedRotation * _center;
+ float halfHeight = (_height * 0.5f) - _radius;
+ Vector3 bottom = worldCenter - up * halfHeight;
+
+ float castRadius = _radius - _skinWidth;
+ if (castRadius < 0.001f) castRadius = 0.001f;
+
+ float probeOffset = _skinWidth + 0.01f;
+ int hitCount = Physics.SphereCastNonAlloc(
+ bottom + up * probeOffset,
+ castRadius,
+ -up,
+ _hitBuffer,
+ probeOffset + GroundProbeExtra,
+ GetCollisionMask(),
+ QueryTriggerInteraction.Ignore
+ );
+
+ isGrounded = false;
+ _groundNormal = up;
+ float cosSlope = Mathf.Cos(_slopeLimit * Mathf.Deg2Rad);
+ float closestDist = float.MaxValue;
+
+ for (int i = 0; i < hitCount; i++)
+ {
+ if (_hitBuffer[i].collider == _capsule) continue;
+ float dotUp = Vector3.Dot(_hitBuffer[i].normal, up);
+ if (dotUp >= cosSlope && _hitBuffer[i].distance < closestDist)
+ {
+ closestDist = _hitBuffer[i].distance;
+ isGrounded = true;
+ _groundNormal = _hitBuffer[i].normal;
+ }
+ }
+ }
+ #endregion
+
+ #region HELPERS
+
+ private void GetCapsuleEnds(Vector3 position, out Vector3 point1, out Vector3 point2)
+ {
+ Vector3 worldCenter = position + _cachedRotation * _center;
+ Vector3 capsuleUp = _cachedRotation * Vector3.up;
+ float halfHeight = (_height * 0.5f) - _radius;
+ if (halfHeight < 0f) halfHeight = 0f;
+ point1 = worldCenter + capsuleUp * halfHeight;
+ point2 = worldCenter - capsuleUp * halfHeight;
+ }
+
+ private int GetCollisionMask()
+ {
+ if (_collisionMaskDirty)
+ {
+ int layer = gameObject.layer;
+ int mask = 0;
+ for (int i = 0; i < 32; i++)
+ {
+ if (!Physics.GetIgnoreLayerCollision(layer, i))
+ mask |= (1 << i);
+ }
+ _collisionMask = mask;
+ _collisionMaskDirty = false;
+ }
+ return _collisionMask;
+ }
+
+ ///
+ /// Call if the object's layer or physics layer collision matrix changes at runtime.
+ /// The mask is automatically refreshed at the start of each Move() call.
+ ///
+ public void InvalidateCollisionMask()
+ {
+ _collisionMaskDirty = true;
+ }
+
+ private bool FindClosestHit(int hitCount, out RaycastHit closest)
+ {
+ float closestDist = float.MaxValue;
+ closest = default;
+ bool found = false;
+ for (int i = 0; i < hitCount; i++)
+ {
+ if (_hitBuffer[i].collider == _capsule) continue;
+ if (_hitBuffer[i].distance < closestDist)
+ {
+ closestDist = _hitBuffer[i].distance;
+ closest = _hitBuffer[i];
+ found = true;
+ }
+ }
+ return found;
+ }
+
+ private bool HasValidHit(int hitCount)
+ {
+ for (int i = 0; i < hitCount; i++)
+ {
+ if (_hitBuffer[i].collider != _capsule) return true;
+ }
+ return false;
+ }
+
+ private void FireHitCallback(RaycastHit hit, Vector3 moveDir, float moveDist)
+ {
+ if (OnKCCColliderHit == null) return;
+ KCCHitInfo info;
+ info.collider = hit.collider;
+ info.point = hit.point;
+ info.normal = hit.normal;
+ info.moveDirection = moveDir;
+ info.moveLength = moveDist;
+ OnKCCColliderHit(info);
+ }
+
+ #endregion
+ }
+
+ ///
+ /// Hit information passed to the KCC collision callback, mirroring ControllerColliderHit.
+ ///
+ public struct KCCHitInfo
+ {
+ public Collider collider;
+ public Vector3 point;
+ public Vector3 normal;
+ public Vector3 moveDirection;
+ public float moveLength;
+ }
+}
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs.meta b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs.meta
new file mode 100644
index 0000000000..feb57957d0
--- /dev/null
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisKinematicCharacterController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3f50f4d9a7b7b5c488ef048f8e073021
\ No newline at end of file
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisNoClipMovementMode.cs b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisNoClipMovementMode.cs
index 5b42d041a3..0385c9cb25 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisNoClipMovementMode.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisNoClipMovementMode.cs
@@ -17,14 +17,14 @@ public class BasisNoClipMovementMode : IMovementMode
public void Enter(BasisLocalCharacterDriver ctx)
{
- // Fully ghost the CharacterController so it won’t push/depenetrate
+ // Disable the KCC's collision detection so it won't push/depenetrate
if (ctx.characterController != null)
{
ctx.characterController.detectCollisions = false;
ctx.characterController.enabled = false;
}
- // Ensure a trigger-only probe exists and matches the CC size
+ // Ensure a trigger-only probe exists and matches the KCC size
EnsureTriggerProbe(ctx);
// enable the trigger probe
@@ -38,7 +38,7 @@ public void Enter(BasisLocalCharacterDriver ctx)
public void Exit(BasisLocalCharacterDriver ctx)
{
- // Re-enable CC for other modes
+ // Re-enable KCC for other modes
if (ctx.characterController != null)
{
ctx.characterController.detectCollisions = true;
@@ -52,16 +52,18 @@ public void Exit(BasisLocalCharacterDriver ctx)
public void Tick(BasisLocalCharacterDriver ctx, float dt)
{
- // Project head forward onto horizontal plane (avoids gimbal lock near ±90° pitch)
+ Vector3 up = ctx.UpDirection;
+
+ // Project head forward onto the plane perpendicular to gravity
Quaternion headRot = BasisLocalBoneDriver.HeadControl.OutgoingWorldData.rotation;
Vector3 flatForward = headRot * Vector3.forward;
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
if (flatForward.sqrMagnitude < 0.0001f)
{
flatForward = -(headRot * Vector3.up);
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
}
- Quaternion facing = Quaternion.LookRotation(flatForward.normalized, Vector3.up);
+ Quaternion facing = Quaternion.LookRotation(flatForward.normalized, up);
// Same speed model you already use
ctx.CurrentSpeed =
@@ -72,8 +74,8 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
Vector3 planar = new Vector3(ctx.MovementVector.x, 0f, ctx.MovementVector.y).normalized;
Vector3 move = facing * planar * ctx.CurrentSpeed * dt;
- // Vertical input
- move.y = ctx.GetVerticalMovement() * ctx.CurrentSpeed * dt;
+ // Vertical input along gravity-relative up axis
+ move += up * (ctx.GetVerticalMovement() * ctx.CurrentSpeed * dt);
ctx.HasJumpAction = false;
if (ctx.MovementLock) move = Vector3.zero;
@@ -87,13 +89,13 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
_triggerBody.position = ctx.BasisLocalPlayerTransform.position;
_triggerBody.rotation = ctx.BasisLocalPlayerTransform.rotation;
}
- var cc = ctx.characterController;
- if (cc != null && _triggerCapsule != null)
+ var kcc = ctx.characterController;
+ if (kcc != null && _triggerCapsule != null)
{
- _triggerCapsule.center = cc.center;
- _triggerCapsule.radius = cc.radius;
- _triggerCapsule.height = cc.height;
- _triggerCapsule.direction = 1; // Y axis like CharacterController
+ _triggerCapsule.center = kcc.center;
+ _triggerCapsule.radius = kcc.radius;
+ _triggerCapsule.height = kcc.height;
+ _triggerCapsule.direction = 1; // Y axis
}
// Sync state
ctx.BasisLocalPlayerTransform.GetPositionAndRotation(out ctx.CurrentPosition, out ctx.CurrentRotation);
@@ -114,14 +116,14 @@ private void EnsureTriggerProbe(BasisLocalCharacterDriver ctx)
_triggerCapsule = BasisHelpers.GetOrAddComponent(go);
_triggerCapsule.isTrigger = true;
- // Match CC dimensions so overlaps are accurate
- var cc = ctx.characterController;
- if (cc != null)
+ // Match KCC dimensions so overlaps are accurate
+ var kcc = ctx.characterController;
+ if (kcc != null)
{
- _triggerCapsule.center = cc.center;
- _triggerCapsule.radius = cc.radius;
- _triggerCapsule.height = cc.height;
- _triggerCapsule.direction = 1; // Y axis like CharacterController
+ _triggerCapsule.center = kcc.center;
+ _triggerCapsule.radius = kcc.radius;
+ _triggerCapsule.height = kcc.height;
+ _triggerCapsule.direction = 1; // Y axis
}
// Make sure physics queries will consider triggers (usually true by default)
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisWalkMovementMode.cs b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisWalkMovementMode.cs
index 23a1bffa37..f5cccb644f 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisWalkMovementMode.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/BasisWalkMovementMode.cs
@@ -27,16 +27,18 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
ctx.UpdateCrouchBlend(ctx.CrouchBlendDelta);
}
- // Project head forward onto horizontal plane (avoids gimbal lock near ±90° pitch)
+ Vector3 up = ctx.UpDirection;
+
+ // Project head forward onto the plane perpendicular to gravity (avoids gimbal lock near ±90° pitch)
Quaternion headRot = BasisLocalBoneDriver.HeadControl.OutgoingWorldData.rotation;
Vector3 flatForward = headRot * Vector3.forward;
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
if (flatForward.sqrMagnitude < 0.0001f)
{
flatForward = -(headRot * Vector3.up);
- flatForward.y = 0f;
+ flatForward -= up * Vector3.Dot(flatForward, up);
}
- Quaternion facing = Quaternion.LookRotation(flatForward.normalized, Vector3.up);
+ Quaternion facing = Quaternion.LookRotation(flatForward.normalized, up);
Vector3 inputDir = new Vector3(ctx.MovementVector.x, 0, ctx.MovementVector.y).normalized;
@@ -48,7 +50,6 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
// Ground & gravity
ctx.GroundCheck(dt);
-
if (ctx.CanJump && ctx.HasJumpAction && !ctx.MovementLock)
{
ctx.currentVerticalSpeed = Mathf.Sqrt(ctx.jumpHeight * -2f * ctx.gravityValue);
@@ -61,14 +62,17 @@ public void Tick(BasisLocalCharacterDriver ctx, float dt)
}
ctx.currentVerticalSpeed = Mathf.Max(ctx.currentVerticalSpeed, -Mathf.Abs(ctx.gravityValue));
+
ctx.HasJumpAction = false;
- move.y = ctx.currentVerticalSpeed * dt;
+ // Apply vertical speed along gravity-relative up axis instead of world Y
+ move += up * (ctx.currentVerticalSpeed * dt);
if (ctx.MovementLock)
{
- move.x = 0;
- move.z = 0;
+ // Zero out horizontal component but keep vertical (gravity)
+ Vector3 verticalPart = up * Vector3.Dot(move, up);
+ move = verticalPart;
}
ctx.Flags = ctx.characterController.Move(move);
diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/IMovementMode.cs b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/IMovementMode.cs
index 1f8e3b2f00..f320c2d4ed 100644
--- a/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/IMovementMode.cs
+++ b/Basis/Packages/com.basis.framework/Drivers/Local/Character Controller/IMovementMode.cs
@@ -18,7 +18,7 @@ public interface IMovementMode
void Exit(BasisLocalCharacterDriver ctx);
// Per-frame simulation of displacement and vertical speed
- // Should call CharacterController.Move when Collision==Solid, or set transform directly when Ghost
+ // Should call BasisKinematicCharacterController.Move when Collision==Solid, or set transform directly when Ghost
void Tick(BasisLocalCharacterDriver ctx, float deltaTime);
}
}
diff --git a/Basis/Packages/com.basis.sdk/Prefabs/Players/LocalPlayer.prefab b/Basis/Packages/com.basis.sdk/Prefabs/Players/LocalPlayer.prefab
index 1966def44c..51abb167e3 100644
--- a/Basis/Packages/com.basis.sdk/Prefabs/Players/LocalPlayer.prefab
+++ b/Basis/Packages/com.basis.sdk/Prefabs/Players/LocalPlayer.prefab
@@ -327,11 +327,13 @@ MonoBehaviour:
SpriteMicrophoneOff: {fileID: 21300000, guid: f184b79ab72cc224e94720390fb038c2, type: 3}
VRdesiredNormXY: {x: -0.42, y: -0.52}
VRextraViewportPad: 0.022
- iconHalfRU: {x: 0, y: 0}
+ IconPositionOffset: {x: 0, y: 0}
LocalIsTransmitting: 0
UnMutedMutedIconColorActive: {r: 1, g: 1, b: 1, a: 0.67058825}
UnMutedMutedIconColorInactive: {r: 0.5, g: 0.5, b: 0.5, a: 0.67058825}
MutedColor: {r: 1, g: 0, b: 0, a: 0.67058825}
+ ShoutColorActive: {r: 1, g: 0.92156863, b: 0.015686275, a: 1}
+ ShoutColorInactive: {r: 0.6, g: 0.6, b: 0, a: 1}
StartingScale: {x: 0, y: 0, z: 0}
largerScale: {x: 0, y: 0, z: 0}
MuteSound: {fileID: 8300000, guid: 5b9ed5013ea1941499840ac97ad2b8e9, type: 3}
@@ -340,6 +342,8 @@ MonoBehaviour:
duration: 0.35
halfDuration: 0
CameraDriver: {fileID: 22857711927985140}
+ avatarPreviewDriver:
+ CameraFieldOfView: 40
--- !u!114 &3806033592985501336
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -484,7 +488,9 @@ GameObject:
m_Component:
- component: {fileID: 303201899784742928}
- component: {fileID: 4587526044252889482}
- - component: {fileID: 5548795088377030369}
+ - component: {fileID: 8369693708823570861}
+ - component: {fileID: 3011869999757657}
+ - component: {fileID: 7825922350359905173}
m_Layer: 3
m_Name: LocalPlayer
m_TagString: Untagged
@@ -520,11 +526,13 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 19f412872e0df964494751723e9f3838, type: 3}
m_Name:
m_EditorClassIdentifier:
+ PlayerPlatform:
DisplayName:
UUID:
SafeDisplayName:
BasisAvatar: {fileID: 0}
AvatarTransform: {fileID: 0}
+ AvatarAnimatorTransform: {fileID: 0}
PlayerSelf: {fileID: 0}
FaceIsVisible: 0
FaceRenderer: {fileID: 0}
@@ -541,9 +549,18 @@ MonoBehaviour:
BasisBundleDescription:
AssetBundleName:
AssetBundleDescription:
+ AssetBundleIcon: {fileID: 0}
BasisBundleGenerated: []
ImageBase64:
DateOfCreation:
+ Bounds:
+ m_Center: {x: 0, y: 0, z: 0}
+ m_Extents: {x: 0, y: 0, z: 0}
+ MetaData:
+ TrianglesCount: 0
+ MaterialCount: 0
+ BonesCount: 0
+ ComponentNames: []
LocalCameraDriver: {fileID: 22857711927985140}
LocalBoneDriver:
ControlsLength: 0
@@ -566,17 +583,70 @@ MonoBehaviour:
m_Active: 0
BasisFullIKConstraint: {fileID: 0}
timeAccumulator: 0
- OutputkneeBendPrefLeftWeights: {x: 0, y: 0, z: 0}
- OutputkneeBendPrefRightWeights: {x: 0, y: 0, z: 0}
- kneeBendPrefLeftWeights: {x: 0, y: 1, z: -0.2}
- kneeBendPrefRightWeights: {x: 0, y: 1, z: 0.2}
elbowBendPrefLeftWeights: {x: 0, y: 1, z: 0}
elbowBendPrefRightWeights: {x: 0, y: 1, z: 0}
spineBendNormalWeights: {x: 0, y: 1, z: 0}
- ScrossLeggedModifier: {x: 1.25, y: 0.25}
+ BasisLocalFootDriver:
+ predictionFactor: 0.9
+ velocityBiasFactor: 0.18
+ leadOffsetFactor: 0.6
+ maxVelocityOffsetFraction: 0.35
+ maxPredictionFraction: 0.35
+ plantedLerpSpeed: 40
+ rotationLerpSpeed: 16
+ velocitySmoothAccel: 10
+ velocitySmoothDecel: 50
+ bodyFwdRateMoving: 6
+ bodyFwdRateStationary: 2.5
+ kneeHintLerpSpeed: 10
+ maxFootTiltDegrees: 35
+ maxFootYawDegrees: 18
+ raySphereRadiusMul: 0.3
+ footHeightOffsetMul: 0.2
+ stepTriggerMul: 0.08
+ strideScaleMul: 0.06
+ stepHeightMul: 0.18
+ stepDurSlowMul: 0.3
+ stepDurFastMul: 0.18
+ fastSpeedMul: 1.2
+ stepArcLiftExp: 0.6
+ stepArcDropExp: 1.4
+ stepHeightMinFraction: 0.4
+ idleSpeedThreshold: 0.05
+ idleBoostFraction: 0.5
+ maxPlantedYawDegrees: 35
+ idealSideEnforceFraction: 0.3
+ stepTargetSideFraction: 0.15
+ footSideEnforceFraction: 0.2
+ maxVerticalDriftFraction: 0.25
+ kneeForwardPushFraction: 0.4
+ kneeMinSideFraction: 0.05
+ bodyFwdHipsWeight: 3
+ bodyFwdChestWeight: 2
+ bodyFwdHeadWeight: 1
+ hipBobFraction: 0.02
+ stanceWidth: 0
+ hipToFoot: 0
+ leftLegLen: 0
+ rightLegLen: 0
+ leftThighLen: 0
+ leftShinLen: 0
+ rightThighLen: 0
+ rightShinLen: 0
+ footLength: 0
+ ankleHeight: 0
+ upperLegToFootVertical: 0
+ stepTriggerDist: 0
+ strideScale: 0
+ stepHeightCalc: 0
+ stepDurSlow: 0
+ stepDurFast: 0
+ raySphereRadius: 0
+ footHeightOffset: 0
+ fastSpeedRef: 0
LocalCharacterDriver:
LocalPlayer: {fileID: 4587526044252889482}
- characterController: {fileID: 5548795088377030369}
+ characterController: {fileID: 7825922350359905173}
bottomPointLocalSpace: {x: 0, y: 0, z: 0}
LastBottomPoint: {x: 0, y: 0, z: 0}
groundedPlayer: 0
@@ -590,22 +660,29 @@ MonoBehaviour:
LastWasGrounded: 1
IsFalling: 0
IsJumpHeld: 0
+ IsDescendHeld: 0
HasJumpAction: 0
jumpHeight: 1
currentVerticalSpeed: 0
+ landingDescentSpeed: 15
+ landingRecoverySpeed: 6
+ landingImpactScale: 0.06
+ maxLandingCrouchEffect: 0.35
+ coyoteTimeDuration: 0.15
+ fallingGracePeriod: 0.1
Rotation: {x: 0, y: 0}
- RotationSpeed: 200
HasEvents: 0
pushPower: 1
CurrentPosition: {x: 0, y: 0, z: 0}
CurrentRotation: {x: 0, y: 0, z: 0, w: 0}
Flags: 0
+ radius: 0
k__BackingField: 0
k__BackingField: 0
CrouchBlend: 1
CrouchBlendDelta: 0
CanPushRigidbodys: 0
- BasisLocalPlayerTransform: {fileID: 0}
+ BasisLocalPlayerTransform: {fileID: 303201899784742928}
CurrentSpeed: 0
LocalSeatDriver:
UseDefaultMasking: 1
@@ -617,6 +694,9 @@ MonoBehaviour:
m_Bits: 456
maxDownProbe: 3
maxUpProbe: 1
+ DebugDrawGizmos: 1
+ DebugPointRadius: 0.03
+ DebugAxisLength: 0.12
LocalAnimatorDriver:
basisAnimatorVariableApply:
Animator: {fileID: 0}
@@ -680,7 +760,6 @@ MonoBehaviour:
centerBias: 2.5
perEyeVarianceDeg: 0.4
occasionalCenterReturn: 1
- HasEyeSchedule: 0
LocalHandDriver:
LeftHand:
ThumbPercentage: {x: 0, y: 0}
@@ -715,148 +794,7 @@ MonoBehaviour:
RightMiddle: []
RightRing: []
RightLittle: []
- LastLeftThumbPercentage: {x: -1.1, y: -1.1}
- LastLeftIndexPercentage: {x: -1.1, y: -1.1}
- LastLeftMiddlePercentage: {x: -1.1, y: -1.1}
- LastLeftRingPercentage: {x: -1.1, y: -1.1}
- LastLeftLittlePercentage: {x: -1.1, y: -1.1}
- LastRightThumbPercentage: {x: -1.1, y: -1.1}
- LastRightIndexPercentage: {x: -1.1, y: -1.1}
- LastRightMiddlePercentage: {x: -1.1, y: -1.1}
- LastRightRingPercentage: {x: -1.1, y: -1.1}
- LastRightLittlePercentage: {x: -1.1, y: -1.1}
- LeftThumbAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- LeftIndexAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- LeftMiddleAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- LeftRingAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- LeftLittleAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- RightThumbAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- RightIndexAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- RightMiddleAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- RightRingAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
- RightLittleAdditional:
- PoseData:
- LeftThumb: []
- LeftIndex: []
- LeftMiddle: []
- LeftRing: []
- LeftLittle: []
- RightThumb: []
- RightIndex: []
- RightMiddle: []
- RightRing: []
- RightLittle: []
- Coord: {x: 0, y: 0}
LerpSpeed: 22
- Poses: []
LocalVisemeDriver:
smoothAmount: 70
HasViseme:
@@ -873,10 +811,14 @@ MonoBehaviour:
BlendShapeInfos: []
HasJob: 0
blendShapeCount: 0
+ UseOpenLipSync: 0
+ EligibleForOpenLipSync: 0
+ TrackedAudioSource: {fileID: 0}
phonemeBlendShapeTable: []
WasSuccessful: 0
HashInstanceID: -1
uLipSyncEnabledState: 1
+ InVisemeRange: 1
FacialBlinkDriver:
Override: 0
meshRenderer: {fileID: 0}
@@ -894,8 +836,35 @@ MonoBehaviour:
NextBlinkTime: 0
BlinkStartTime: 0
OpenStartTime: 0
---- !u!143 &5548795088377030369
-CharacterController:
+--- !u!54 &8369693708823570861
+Rigidbody:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8538648612911191307}
+ serializedVersion: 5
+ m_Mass: 1
+ m_LinearDamping: 0
+ m_AngularDamping: 0.05
+ m_CenterOfMass: {x: 0, y: 0, z: 0}
+ m_InertiaTensor: {x: 1, y: 1, z: 1}
+ m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ImplicitCom: 1
+ m_ImplicitTensor: 1
+ m_UseGravity: 0
+ m_IsKinematic: 1
+ m_Interpolate: 0
+ m_Constraints: 0
+ m_CollisionDetection: 0
+--- !u!136 &3011869999757657
+CapsuleCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
@@ -909,13 +878,35 @@ CharacterController:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
+ m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
- serializedVersion: 3
+ serializedVersion: 2
+ m_Radius: 0.3
m_Height: 2
- m_Radius: 0.15
- m_SlopeLimit: 65
- m_StepOffset: 0.5
- m_SkinWidth: 0.01
- m_MinMoveDistance: 0.001
+ m_Direction: 1
m_Center: {x: 0, y: 0, z: 0}
+--- !u!114 &7825922350359905173
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 8538648612911191307}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 3f50f4d9a7b7b5c488ef048f8e073021, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Basis Framework::Basis.Scripts.BasisCharacterController.BasisKinematicCharacterController
+ _height: 2
+ _radius: 0.3
+ _center: {x: 0, y: 1, z: 0}
+ _skinWidth: 0.01
+ _stepOffset: 0.3
+ _minMoveDistance: 0
+ _slopeLimit: 45
+ _detectCollisions: 1
+ _gravityDirection: {x: 0, y: -1, z: 0}
+ _rigidbody: {fileID: 8369693708823570861}
+ _capsule: {fileID: 3011869999757657}
+ isGrounded: 0