414 lines
17 KiB
C#
Raw Normal View History

2025-09-24 11:24:38 +05:00
namespace Fusion.Addons.KCC
{
using UnityEngine;
public interface ISetGravity : IKCCStage<ISetGravity> {} // Dedicated stage to set KCCData.Gravity property.
public interface ISetDynamicVelocity : IKCCStage<ISetDynamicVelocity> {} // Dedicated stage to set KCCData.DynamicVelocity property.
public interface ISetKinematicDirection : IKCCStage<ISetKinematicDirection> {} // Dedicated stage to set KCCData.KinematicDirection property.
public interface ISetKinematicTangent : IKCCStage<ISetKinematicTangent> {} // Dedicated stage to set KCCData.KinematicTangent property.
public interface ISetKinematicSpeed : IKCCStage<ISetKinematicSpeed> {} // Dedicated stage to set KCCData.KinematicSpeed property.
public interface ISetKinematicVelocity : IKCCStage<ISetKinematicVelocity> {} // Dedicated stage to set KCCData.KinematicVelocity property.
/// <summary>
/// Movement implementation for default environment. This imlementation doesn't suffer from position error accumulation due to render delta time integration.
/// In render update, all properties (speed, velocities, ...) are calculated with fixed delta time. Render delta time is the used for calculation of actual position delta.
/// Results of this processor in Fixed/Render updates should always be in sync.
/// </summary>
public partial class EnvironmentProcessor : KCCProcessor, IPrepareData, ISetDynamicVelocity, ISetKinematicDirection, ISetKinematicTangent, ISetKinematicSpeed, ISetKinematicVelocity, IAfterMoveStep
{
// CONSTANTS
public static readonly int DefaultPriority = 1000;
// PUBLIC MEMBERS
[Header("General")]
[Tooltip("Maximum allowed speed the KCC can move with player input.")]
public float KinematicSpeed = 8.0f;
[Tooltip("Custom jump multiplier.")]
public float JumpMultiplier = 1.0f;
[Tooltip("Custom gravity. Physics.gravity is used if default.")]
public Vector3 Gravity = default;
[Tooltip("Relative environment priority. Default environment processor priority is 1000.")]
public int RelativePriority = 0;
[Header("Ground")]
[Tooltip("Maximum angle of walkable ground.")]
public float MaxGroundAngle = 60.0f;
[Tooltip("Dynamic velocity is decelerated by actual dynamic speed multiplied by this. The faster KCC moves, the more deceleration is applied.")]
public float DynamicGroundFriction = 20.0f;
[Tooltip("Kinematic velocity is accelerated by calculated kinematic speed multiplied by this.")]
public float KinematicGroundAcceleration = 50.0f;
[Tooltip("Kinematic velocity is decelerated by actual kinematic speed multiplied by this. The faster KCC moves, the more deceleration is applied.")]
public float KinematicGroundFriction = 35.0f;
[Header("Air")]
[Tooltip("Dynamic velocity is decelerated by actual dynamic speed multiplied by this. The faster KCC moves, the more deceleration is applied.")]
public float DynamicAirFriction = 2.0f;
[Tooltip("Kinematic velocity is accelerated by calculated kinematic speed multiplied by this.")]
public float KinematicAirAcceleration = 5.0f;
[Tooltip("Kinematic velocity is decelerated by actual kinematic speed multiplied by this. The faster KCC moves, the more deceleration is applied.")]
public float KinematicAirFriction = 2.0f;
// KCCProcessor INTERFACE
public override float GetPriority(KCC kcc) => DefaultPriority + RelativePriority;
// IPrepareData INTERFACE
public virtual void Execute(PrepareData stage, KCC kcc, KCCData data)
{
// Default processor priority is set to 1000.
// This means the processor will be usually executed first and we can prepare base values for override / multiplication.
// All processors with priority higher than 1000 will see values from previous update.
data.Gravity = Gravity != default ? Gravity : Physics.gravity;
data.MaxGroundAngle = MaxGroundAngle;
// Calculating all data directly in this stage is possible, however it might be limiting.
// It is better to create a custom stage for each property that is affected by multiple processors and suppressing is required.
// When the dedicated stage is created, it is strongly recommended to update property (except an initial state) only from that stage.
// For example initialize KCCData.Gravity once in IPrepareData and update in all other processors only from ISetGravity stage.
kcc.ExecuteStage<ISetGravity>();
kcc.ExecuteStage<ISetDynamicVelocity>();
kcc.ExecuteStage<ISetKinematicDirection>();
kcc.ExecuteStage<ISetKinematicTangent>();
kcc.ExecuteStage<ISetKinematicSpeed>();
kcc.ExecuteStage<ISetKinematicVelocity>();
// Suppress other processors for this stage.
SuppressOtherProcessors(kcc);
}
// ISetDynamicVelocity INTERFACE
public virtual void Execute(ISetDynamicVelocity stage, KCC kcc, KCCData data)
{
// This code path can be executed in both fixed/render updates.
// Next fixed/render value is based on values from last fixed update to not introduce error accumulation due to render delta time integration.
// ERROR ACCUMULATION EXAMPLE
// ========================================
// Input: x(0) = 1.0f;
// Operation: x(n + 1) = x(n) * (1.0f + data.DeltaTime);
// Delta time for fixed update (60Hz): 0.016667f
// Delta time for render update (240Hz): 0.004167f
// RENDER UPDATE PREDICTION (240Hz, 4 frames)
// ========================================
// Result (frame 1): x(r1) = x(0) + x(0) * 0.004167f = 1.0f * (1.0f + 0.004167f) = 1.004167f
// Result (frame 2): x(r2) = x(r1) + x(r1) * 0.004167f = 1.004167f * (1.0f + 0.004167f) = 1.008351f
// Result (frame 3): x(r3) = x(r2) + x(r2) * 0.004167f = 1.008351f * (1.0f + 0.004167f) = 1.012552f
// Result (frame 4): x(r4) = x(r3) + x(r3) * 0.004167f = 1.012552f * (1.0f + 0.004167f) = 1.016771f
// FIXED UPDATE PREDICTION (60Hz, 1 tick)
// ========================================
// Result (tick 1): x(f1) = x(0) + x(0) * 0.016667f = 1.0f * (1.0f + 0.016667f) = 1.016667f
// THE PROBLEM
// ========================================
// Ideally we want mutiple render updates (total delta time equal to single fixed update) to match the fixed update: x(f1) == x(r4).
// Example above has a render update error: e(r) = x(f1) - x(r4) = 1.016667f - 1.016771f = -0.000104f
// This is only a demonstration and the error can result to a perceivable jitter, depends on error amplification.
// THE SOLUTION
// ========================================
// The trick is to make all intermediate calculations with FIXED update delta time (0.016667f).
// And use RENDER update delta time (0.004167f) only to calculate final position delta.
// This way 4 render position deltas will match 1 fixed position delta.
// This is the reason why fixedData is sometimes used instead of data (except values calculated in recently executed processors chain, these are safe to use).
// We explicitly need data from fixed update.
KCCData fixedData = kcc.FixedData;
float fixedDeltaTime = fixedData.DeltaTime;
Vector3 dynamicVelocity = fixedData.DynamicVelocity;
if (fixedData.IsGrounded == false || (fixedData.IsSteppingUp == false && (fixedData.IsSnappingToGround == true || fixedData.GroundDistance > 0.001f)))
{
// Applying gravity only while in the air (not grounded) and not stepping up, ground snapping can be active.
dynamicVelocity += data.Gravity * fixedDeltaTime;
}
if (data.JumpImpulse.IsZero() == false && JumpMultiplier > 0.0f)
{
Vector3 jumpDirection = data.JumpImpulse.normalized;
// Elimination of dynamic velocity in direction of jump, otherwise the jump trajectory would be distorted.
dynamicVelocity -= Vector3.Scale(dynamicVelocity, jumpDirection);
// Applying jump impulse.
dynamicVelocity += data.JumpImpulse * JumpMultiplier / kcc.Rigidbody.mass;
// Increase jump counter.
// For fixed updates this counter will be 1 at max.
// For render updates this counter can reach higher values, indicating how many times the JumpImpulse was applied on top of fixed data.
++data.JumpFrames;
}
// Apply external forces.
dynamicVelocity += data.ExternalVelocity;
dynamicVelocity += data.ExternalAcceleration * fixedDeltaTime;
dynamicVelocity += (data.ExternalImpulse / kcc.Rigidbody.mass);
dynamicVelocity += (data.ExternalForce / kcc.Rigidbody.mass) * fixedDeltaTime;
if (dynamicVelocity.IsZero() == false)
{
if (dynamicVelocity.IsAlmostZero(0.001f) == true)
{
// Clamping values near zero.
dynamicVelocity = default;
}
else
{
// Applying ground (XYZ) and air (XZ) friction.
if (fixedData.IsGrounded == true)
{
Vector3 frictionAxis = Vector3.one;
if (fixedData.GroundDistance > 0.001f || fixedData.IsSnappingToGround == true)
{
frictionAxis.y = default;
}
dynamicVelocity += KCCPhysicsUtility.GetFriction(dynamicVelocity, dynamicVelocity, frictionAxis, fixedData.GroundNormal, fixedData.KinematicSpeed, true, 0.0f, 0.0f, DynamicGroundFriction, fixedDeltaTime);
}
else
{
dynamicVelocity += KCCPhysicsUtility.GetFriction(dynamicVelocity, dynamicVelocity, new Vector3(1.0f, 0.0f, 1.0f), fixedData.KinematicSpeed, true, 0.0f, 0.0f, DynamicAirFriction, fixedDeltaTime);
}
}
}
data.DynamicVelocity = dynamicVelocity;
if (kcc.IsInFixedUpdate == true)
{
// Consume one-time effects only in fixed update.
// For render prediction we need them to be applied on top of fixed data in all frames.
data.JumpImpulse = default;
data.ExternalVelocity = default;
data.ExternalImpulse = default;
}
// Forces applied over-time are reset always. These are set every tick/frame.
data.ExternalAcceleration = default;
data.ExternalForce = default;
SuppressOtherProcessors(kcc);
}
// ISetKinematicDirection INTERFACE
public virtual void Execute(ISetKinematicDirection stage, KCC kcc, KCCData data)
{
// Setting the direction we WANT to move, simply filtering out Y axis from InputDirection is enough.
data.KinematicDirection = data.InputDirection.OnlyXZ();
SuppressOtherProcessors(kcc);
}
// ISetKinematicTangent INTERFACE
public virtual void Execute(ISetKinematicTangent stage, KCC kcc, KCCData data)
{
// Setting the direction we will move with.
KCCData fixedData = kcc.FixedData;
if (fixedData.IsGrounded == true)
{
// The character is grounded.
if (data.KinematicDirection.IsAlmostZero(0.0001f) == false && KCCPhysicsUtility.ProjectOnGround(fixedData.GroundNormal, data.KinematicDirection, out Vector3 projectedMoveDirection) == true)
{
// Use projected kinematic direction on ground when possible.
data.KinematicTangent = projectedMoveDirection.normalized;
}
else
{
// Otherwise use ground tangent => steepest descent.
data.KinematicTangent = fixedData.GroundTangent;
}
}
else
{
// The character is floating in the air.
if (data.KinematicDirection.IsAlmostZero(0.0001f) == false)
{
// Use kinematic direction directly.
data.KinematicTangent = data.KinematicDirection.normalized;
}
else
{
// No direction set, use character forward.
data.KinematicTangent = data.TransformDirection;
}
}
SuppressOtherProcessors(kcc);
}
// ISetKinematicSpeed INTERFACE
public virtual void Execute(ISetKinematicSpeed stage, KCC kcc, KCCData data)
{
data.KinematicSpeed = KinematicSpeed;
SuppressOtherProcessors(kcc);
}
// ISetKinematicVelocity INTERFACE
public virtual void Execute(ISetKinematicVelocity stage, KCC kcc, KCCData data)
{
// This code path can be executed in both fixed/render updates.
// Next fixed/render value is based on values from last fixed update to not introduce error accumulation due to render delta time integration.
// Details are described above in SetDynamicVelocity().
KCCData fixedData = kcc.FixedData;
float fixedDeltaTime = fixedData.DeltaTime;
Vector3 kinematicVelocity = fixedData.KinematicVelocity;
if (fixedData.IsGrounded == true)
{
// The character is grounded.
if (kinematicVelocity.IsAlmostZero() == false && KCCPhysicsUtility.ProjectOnGround(fixedData.GroundNormal, kinematicVelocity, out Vector3 projectedKinematicVelocity) == true)
{
// Project current velocity on ground.
kinematicVelocity = projectedKinematicVelocity.normalized * kinematicVelocity.magnitude;
}
if (data.KinematicDirection.IsAlmostZero() == true)
{
// No kinematic direction
// Just apply friction (XYZ) and exit early.
data.KinematicVelocity = kinematicVelocity + KCCPhysicsUtility.GetFriction(kinematicVelocity, kinematicVelocity, Vector3.one, fixedData.GroundNormal, data.KinematicSpeed, true, 0.0f, 0.0f, KinematicGroundFriction, fixedDeltaTime);
SuppressOtherProcessors(kcc);
return;
}
}
else
{
// The character is floating in the air.
if (data.KinematicDirection.IsAlmostZero() == true)
{
// No kinematic direction
// Just apply friction (XZ) and exit early.
data.KinematicVelocity = kinematicVelocity + KCCPhysicsUtility.GetFriction(kinematicVelocity, kinematicVelocity, new Vector3(1.0f, 0.0f, 1.0f), data.KinematicSpeed, true, 0.0f, 0.0f, KinematicAirFriction, fixedDeltaTime);
SuppressOtherProcessors(kcc);
return;
}
}
// Following section calculates ground/air acceleration and friction relatively to move direction and kinematic tangent.
// And combines them together with base kinematic velocity.
Vector3 moveDirection = kinematicVelocity;
if (moveDirection.IsZero() == true)
{
moveDirection = data.KinematicTangent;
}
Vector3 acceleration;
Vector3 friction;
if (fixedData.IsGrounded == true)
{
acceleration = KCCPhysicsUtility.GetAcceleration(kinematicVelocity, data.KinematicTangent, Vector3.one, data.KinematicSpeed, false, data.KinematicDirection.magnitude, 0.0f, KinematicGroundAcceleration, 0.0f, fixedDeltaTime);
friction = KCCPhysicsUtility.GetFriction(kinematicVelocity, moveDirection, Vector3.one, fixedData.GroundNormal, data.KinematicSpeed, false, 0.0f, 0.0f, KinematicGroundFriction, fixedDeltaTime);
}
else
{
acceleration = KCCPhysicsUtility.GetAcceleration(kinematicVelocity, data.KinematicTangent, Vector3.one, data.KinematicSpeed, false, data.KinematicDirection.magnitude, 0.0f, KinematicAirAcceleration, 0.0f, fixedDeltaTime);
friction = KCCPhysicsUtility.GetFriction(kinematicVelocity, moveDirection, new Vector3(1.0f, 0.0f, 1.0f), data.KinematicSpeed, false, 0.0f, 0.0f, KinematicAirFriction, fixedDeltaTime);
}
kinematicVelocity = KCCPhysicsUtility.CombineAccelerationAndFriction(kinematicVelocity, acceleration, friction);
// Clamp velocity.
if (kinematicVelocity.sqrMagnitude > data.KinematicSpeed * data.KinematicSpeed)
{
kinematicVelocity = kinematicVelocity / Vector3.Magnitude(kinematicVelocity) * data.KinematicSpeed;
}
// Reset Y axis to get stable jump height results even if moving downwards.
if (data.JumpFrames > 0 && kinematicVelocity.y < 0.0f)
{
kinematicVelocity.y = 0.0f;
}
data.KinematicVelocity = kinematicVelocity;
SuppressOtherProcessors(kcc);
}
// IAfterMoveStep INTERFACE
public virtual void Execute(AfterMoveStep stage, KCC kcc, KCCData data)
{
// This code path can be executed multiple times in single update if CCD is active (Continuous Collision Detection).
KCCData fixedData = kcc.FixedData;
if (data.IsGrounded == true)
{
if (fixedData.WasGrounded == true && data.IsSnappingToGround == false && data.DynamicVelocity.y < 0.0f && data.DynamicVelocity.OnlyXZ().IsAlmostZero() == true)
{
// Reset dynamic velocity Y axis while grounded (to not accumulate gravity indefinitely and clamp to precise zero).
data.DynamicVelocity.y = 0.0f;
}
if (fixedData.WasGrounded == false)
{
if (data.KinematicVelocity.OnlyXZ().IsAlmostZero() == true)
{
// Reset Y axis after getting grounded and there is no horizontal movement.
data.KinematicVelocity.y = 0.0f;
}
else
{
// Otherwise try projecting kinematic velocity onto ground.
if (KCCPhysicsUtility.ProjectOnGround(data.GroundNormal, data.KinematicVelocity, out Vector3 projectedKinematicVelocity) == true)
{
data.KinematicVelocity = projectedKinematicVelocity.normalized * data.KinematicVelocity.magnitude;
}
}
}
}
else
{
if (fixedData.WasGrounded == false && data.DynamicVelocity.y > 0.0f && data.DeltaTime > 0.0f)
{
Vector3 currentVelocity;
if (kcc.IsInFixedUpdate == true)
{
currentVelocity = (data.TargetPosition - data.BasePosition) / data.DeltaTime;
}
else
{
currentVelocity = (data.TargetPosition - fixedData.TargetPosition) / (fixedData.DeltaTime * data.Alpha);
}
if (currentVelocity.y.IsAlmostZero() == true)
{
// Clamping dynamic up velocity if there is no real position change => hitting a roof.
data.DynamicVelocity.y = 0.0f;
}
}
}
SuppressOtherProcessors(kcc);
}
// PROTECTED METHODS
protected virtual void SuppressOtherProcessors(KCC kcc)
{
kcc.SuppressProcessors<EnvironmentProcessor>();
}
}
}