CC Package and Physics

This commit is contained in:
2026-06-02 08:56:26 -07:00
parent a5af81c8a8
commit 2ee30c01fd
37 changed files with 1295 additions and 142 deletions
@@ -0,0 +1,48 @@
using System;
using Unity.CharacterController;
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Tunables for the top-down kinematic character (M5b: Unity Character Controller package). Twin-stick /
/// planar: NO jump, NO air control, NO gravity, NO view pitch. The per-tick target speed comes from the
/// data-driven <see cref="EffectiveCharacterStats.MoveSpeed"/> via <see cref="PlayerControlSystem"/>; this
/// component only carries the smoothing sharpness + step/slope handling the CC processor needs. Authored
/// on the player prefab by <c>PlayerCharacterAuthoring</c>. Not a ghost — movement replicates through the
/// owner-predicted LocalTransform (re-simulated on the owner, interpolated on remotes).
/// </summary>
[Serializable]
public struct CharacterComponent : IComponentData
{
/// <summary>How quickly RelativeVelocity is lerped toward the target velocity on the ground.</summary>
public float GroundedMovementSharpness;
/// <summary>Step/slope handling params (defaults; planar ground so mostly inert).</summary>
public BasicStepAndSlopeHandlingParameters StepAndSlopeHandling;
public static CharacterComponent GetDefault()
{
return new CharacterComponent
{
GroundedMovementSharpness = 15f,
StepAndSlopeHandling = BasicStepAndSlopeHandlingParameters.GetDefault(),
};
}
}
/// <summary>
/// Per-tick, NON-replicated control for the CC processor, derived each predicted tick from the replicated
/// <see cref="PlayerInput"/> by <see cref="PlayerControlSystem"/>. Derived (not authored, not a ghost): a
/// pure function of replicated input + recomputed stats, so it reproduces identically on server, owning
/// client, and across rollback re-simulation.
/// </summary>
[Serializable]
public struct CharacterControl : IComponentData
{
/// <summary>World-space desired velocity (already scaled by MoveSpeed, Y == 0). The processor lerps
/// RelativeVelocity toward this each tick.</summary>
public float3 MoveVelocity;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69a9d5a5573f2804d987602849f43554
@@ -0,0 +1,24 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, Burst-friendly math for top-down character control, factored out for EditMode unit testing
/// (no World/system needed). Maps a twin-stick move axis + speed to a planar world velocity.
/// </summary>
public static class CharacterControlMath
{
/// <summary>
/// Twin-stick move (x,y) on the XZ plane scaled by <paramref name="speed"/>. Input longer than unit
/// length is clamped (so diagonals aren't faster than cardinals); shorter is left proportional
/// (analog sticks). Returns a world velocity with Y == 0.
/// </summary>
public static float3 DesiredMovement(float2 move, float speed)
{
float lenSq = math.lengthsq(move);
if (lenSq > 1f)
move = math.normalize(move);
return new float3(move.x, 0f, move.y) * speed;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8ada5ffb04316ba48851d44f5b3340f2
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using Unity.CharacterController;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Ghost-variant registration for the kinematic character (M5b). <see cref="CharacterInterpolation"/> is
/// presentation-only fixed-step smoothing that must exist ONLY on predicted clients: on the server it
/// would interfere with LocalToWorld, and on interpolated remote ghosts it would double up with netcode's
/// own snapshot interpolation. <c>BakeCharacter</c> adds it to every prefab version, so we force it
/// predicted-client-only here.
/// <para>
/// We deliberately do NOT override the default variants for <c>LocalTransform</c> or
/// <c>PhysicsVelocity</c> (the CC sample's DontSerializeVariant on LocalTransform is global and would
/// break the non-character ghosts in this project — projectiles, dummies, pickups — which rely on stock
/// LocalTransform replication). The character's position therefore replicates via the normal
/// owner-predicted LocalTransform path, like every other ghost.
/// </para>
/// </summary>
public sealed partial class CharacterGhostVariantsSystem : DefaultVariantSystemBase
{
protected override void RegisterDefaultVariants(Dictionary<ComponentType, Rule> defaultVariants)
{
defaultVariants.Add(typeof(CharacterInterpolation), Rule.ForAll(typeof(CharacterInterpolation_GhostVariant)));
}
}
[GhostComponentVariation(typeof(CharacterInterpolation))]
[GhostComponent(PrefabType = GhostPrefabType.PredictedClient)]
public struct CharacterInterpolation_GhostVariant
{
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6383b28a0d1f44649b514ece99ee4915
@@ -0,0 +1,104 @@
using Unity.Burst;
using Unity.Burst.Intrinsics;
using Unity.CharacterController;
using Unity.Entities;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Fixed-step kinematic character update. Runs in <see cref="KinematicCharacterPhysicsUpdateGroup"/>
/// (which sits after the physics build); under Netcode-for-Entities the physics groups are relocated into
/// <see cref="Unity.NetCode.PredictedFixedStepSimulationSystemGroup"/>, so the character integrates inside
/// the predicted loop — deterministic, rollback-safe, identical on server + owning client. Builds a
/// <see cref="KinematicCharacterDataAccess"/> per entity and runs the processor's PhysicsUpdate. Filtered
/// to <see cref="Simulate"/> so only predicted ghosts step.
/// </summary>
[UpdateInGroup(typeof(KinematicCharacterPhysicsUpdateGroup))]
[BurstCompile]
public partial struct CharacterPhysicsUpdateSystem : ISystem
{
EntityQuery m_CharacterQuery;
CharacterUpdateContext m_Context;
KinematicCharacterUpdateContext m_BaseContext;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_CharacterQuery = KinematicCharacterUtilities.GetBaseCharacterQueryBuilder()
.WithAll<CharacterComponent, CharacterControl>()
.Build(ref state);
m_Context = new CharacterUpdateContext();
m_Context.OnSystemCreate(ref state);
m_BaseContext = new KinematicCharacterUpdateContext();
m_BaseContext.OnSystemCreate(ref state);
state.RequireForUpdate(m_CharacterQuery);
state.RequireForUpdate<PhysicsWorldSingleton>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_Context.OnSystemUpdate(ref state);
m_BaseContext.OnSystemUpdate(ref state, SystemAPI.Time, SystemAPI.GetSingleton<PhysicsWorldSingleton>());
var job = new CharacterPhysicsUpdateJob
{
Context = m_Context,
BaseContext = m_BaseContext,
};
state.Dependency = job.Schedule(state.Dependency);
}
[BurstCompile]
[WithAll(typeof(Simulate))]
public partial struct CharacterPhysicsUpdateJob : IJobEntity, IJobEntityChunkBeginEnd
{
public CharacterUpdateContext Context;
public KinematicCharacterUpdateContext BaseContext;
void Execute(
Entity entity,
RefRW<LocalTransform> localTransform,
RefRW<KinematicCharacterProperties> characterProperties,
RefRW<KinematicCharacterBody> characterBody,
RefRW<PhysicsCollider> physicsCollider,
RefRW<CharacterComponent> characterComponent,
RefRW<CharacterControl> characterControl,
DynamicBuffer<KinematicCharacterHit> characterHitsBuffer,
DynamicBuffer<StatefulKinematicCharacterHit> statefulHitsBuffer,
DynamicBuffer<KinematicCharacterDeferredImpulse> deferredImpulsesBuffer,
DynamicBuffer<KinematicVelocityProjectionHit> velocityProjectionHits)
{
var processor = new CharacterProcessor
{
CharacterDataAccess = new KinematicCharacterDataAccess(
entity,
localTransform,
characterProperties,
characterBody,
physicsCollider,
characterHitsBuffer,
statefulHitsBuffer,
deferredImpulsesBuffer,
velocityProjectionHits),
CharacterComponent = characterComponent,
CharacterControl = characterControl,
};
processor.PhysicsUpdate(ref Context, ref BaseContext);
}
public bool OnChunkBegin(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
{
BaseContext.EnsureCreationOfTmpCollections();
return true;
}
public void OnChunkEnd(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask, bool chunkWasExecuted) { }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70d14da3eeeaec24896586d71fd22fdc
@@ -0,0 +1,213 @@
using Unity.CharacterController;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
namespace ProjectM.Simulation
{
/// <summary>
/// Per-update "global" data the character processor needs (lookups, singletons). Empty for the minimal
/// top-down character — kept so the CC update signature is satisfied and future lookups have a home.
/// </summary>
public struct CharacterUpdateContext
{
public void OnSystemCreate(ref SystemState state) { }
public void OnSystemUpdate(ref SystemState state) { }
}
/// <summary>
/// Top-down kinematic character processor (CC 1.4.2 pattern: <see cref="IKinematicCharacterProcessor{T}"/>
/// + a <see cref="KinematicCharacterDataAccess"/> built in the job, driving the static
/// <see cref="KinematicCharacterUtilities"/> Update_* sequence). Stripped of all FPS features: no jump,
/// no air movement, no gravity, no view. <see cref="PhysicsUpdate"/> runs the canonical CC update
/// sequence; <see cref="HandleVelocityControl"/> lerps RelativeVelocity toward the desired planar
/// velocity <see cref="PlayerControlSystem"/> wrote. Rotation/facing is owned by <see cref="PlayerAimSystem"/>.
/// </summary>
public struct CharacterProcessor : IKinematicCharacterProcessor<CharacterUpdateContext>
{
public KinematicCharacterDataAccess CharacterDataAccess;
public RefRW<CharacterComponent> CharacterComponent;
public RefRW<CharacterControl> CharacterControl;
public void PhysicsUpdate(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
ref CharacterComponent characterComponent = ref CharacterComponent.ValueRW;
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref float3 characterPosition = ref CharacterDataAccess.LocalTransform.ValueRW.Position;
KinematicCharacterUtilities.Update_Initialize(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.DeferredImpulsesBuffer,
CharacterDataAccess.VelocityProjectionHits,
baseContext.Time.DeltaTime);
KinematicCharacterUtilities.Update_ParentMovement(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
ref characterPosition,
characterBody.WasGroundedBeforeCharacterUpdate);
KinematicCharacterUtilities.Update_Grounding(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.VelocityProjectionHits,
CharacterDataAccess.CharacterHitsBuffer,
ref characterPosition);
HandleVelocityControl(ref context, ref baseContext);
KinematicCharacterUtilities.Update_PreventGroundingFromFutureSlopeChange(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
in characterComponent.StepAndSlopeHandling);
KinematicCharacterUtilities.Update_GroundPushing(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.DeferredImpulsesBuffer,
float3.zero);
KinematicCharacterUtilities.Update_MovementAndDecollisions(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
ref characterBody,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
CharacterDataAccess.VelocityProjectionHits,
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.DeferredImpulsesBuffer,
ref characterPosition);
KinematicCharacterUtilities.Update_MovingPlatformDetection(ref baseContext, ref characterBody);
KinematicCharacterUtilities.Update_ParentMomentum(ref baseContext, ref characterBody,
CharacterDataAccess.LocalTransform.ValueRO.Position);
KinematicCharacterUtilities.Update_ProcessStatefulCharacterHits(
CharacterDataAccess.CharacterHitsBuffer,
CharacterDataAccess.StatefulHitsBuffer);
}
void HandleVelocityControl(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
float deltaTime = baseContext.Time.DeltaTime;
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref CharacterComponent characterComponent = ref CharacterComponent.ValueRW;
CharacterControl characterControl = CharacterControl.ValueRO;
// Planar twin-stick: smoothly approach the desired world velocity (already speed-scaled, Y==0).
float3 targetVelocity = characterControl.MoveVelocity;
CharacterControlUtilities.StandardGroundMove_Interpolated(
ref characterBody.RelativeVelocity,
targetVelocity,
characterComponent.GroundedMovementSharpness,
deltaTime,
math.up(),
math.up());
}
public void VariableUpdate(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext) { }
public void UpdateGroundingUp(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext)
{
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
KinematicCharacterUtilities.Default_UpdateGroundingUp(
ref characterBody,
CharacterDataAccess.LocalTransform.ValueRO.Rotation);
}
public bool CanCollideWithHit(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in BasicHit hit)
{
return PhysicsUtilities.IsCollidable(hit.Material);
}
public bool IsGroundedOnHit(ref CharacterUpdateContext context, ref KinematicCharacterUpdateContext baseContext, in BasicHit hit, int groundingEvaluationType)
{
CharacterComponent characterComponent = CharacterComponent.ValueRO;
return KinematicCharacterUtilities.Default_IsGroundedOnHit(
in this, ref context, ref baseContext,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.CharacterBody.ValueRO,
CharacterDataAccess.CharacterProperties.ValueRO,
in hit,
in characterComponent.StepAndSlopeHandling,
groundingEvaluationType);
}
public void OnMovementHit(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref KinematicCharacterHit hit,
ref float3 remainingMovementDirection,
ref float remainingMovementLength,
float3 originalVelocityDirection,
float hitDistance)
{
ref KinematicCharacterBody characterBody = ref CharacterDataAccess.CharacterBody.ValueRW;
ref float3 characterPosition = ref CharacterDataAccess.LocalTransform.ValueRW.Position;
CharacterComponent characterComponent = CharacterComponent.ValueRO;
KinematicCharacterUtilities.Default_OnMovementHit(
in this, ref context, ref baseContext,
ref characterBody,
CharacterDataAccess.CharacterEntity,
CharacterDataAccess.CharacterProperties.ValueRO,
CharacterDataAccess.PhysicsCollider.ValueRO,
CharacterDataAccess.LocalTransform.ValueRO,
ref characterPosition,
CharacterDataAccess.VelocityProjectionHits,
ref hit,
ref remainingMovementDirection,
ref remainingMovementLength,
originalVelocityDirection,
hitDistance,
characterComponent.StepAndSlopeHandling.StepHandling,
characterComponent.StepAndSlopeHandling.MaxStepHeight,
characterComponent.StepAndSlopeHandling.CharacterWidthForStepGroundingCheck);
}
public void OverrideDynamicHitMasses(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref PhysicsMass characterMass,
ref PhysicsMass otherMass,
BasicHit hit)
{
}
public void ProjectVelocityOnHits(
ref CharacterUpdateContext context,
ref KinematicCharacterUpdateContext baseContext,
ref float3 velocity,
ref bool characterIsGrounded,
ref BasicHit characterGroundHit,
in DynamicBuffer<KinematicVelocityProjectionHit> velocityProjectionHits,
float3 originalVelocityDirection)
{
CharacterComponent characterComponent = CharacterComponent.ValueRO;
KinematicCharacterUtilities.Default_ProjectVelocityOnHits(
ref velocity,
ref characterIsGrounded,
ref characterGroundHit,
in velocityProjectionHits,
originalVelocityDirection,
characterComponent.StepAndSlopeHandling.ConstrainVelocityToGroundPlane,
in CharacterDataAccess.CharacterBody.ValueRO);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2bcdc39be82b4c4dbc85b799da194f3
@@ -0,0 +1,34 @@
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Bridges replicated <see cref="PlayerInput"/> + data-driven <see cref="EffectiveCharacterStats"/> into
/// the CC processor's per-tick <see cref="CharacterControl"/>. Replaces the M5 <c>PlayerMoveSystem</c>:
/// instead of writing PhysicsVelocity, it writes the desired world velocity that
/// <see cref="CharacterProcessor"/> lerps toward inside the fixed-step character update. Runs in
/// <see cref="PredictedSimulationSystemGroup"/> after <see cref="StatRecomputeSystem"/> (fresh MoveSpeed)
/// and before the predicted fixed-step group (where the character physics steps). Derived purely from
/// replicated input + recomputed stats → identical on server, owning client, and across rollback.
/// Filtered to <see cref="Simulate"/> for predicted ghosts only.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(StatRecomputeSystem))]
[BurstCompile]
public partial struct PlayerControlSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (control, input, stats) in
SystemAPI.Query<RefRW<CharacterControl>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
control.ValueRW.MoveVelocity =
CharacterControlMath.DesiredMovement(input.ValueRO.Move, stats.ValueRO.MoveSpeed);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d70cdf70e3ae53440aeb7274bdb27793
@@ -1,41 +0,0 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Simulation
{
/// <summary>
/// Canonical predicted system: advances each player's planar (XZ) position from twin-stick Move
/// input. Runs inside the prediction loop on the owning client (re-simulated on rollback) and
/// once per tick on the server; filtered to <see cref="Simulate"/> so only predicted ghosts move.
/// Deterministic by construction: uses <c>SystemAPI.Time.DeltaTime</c> (the fixed tick step)
/// only — no wall-clock, no <c>System.Random</c>. Move is clamped to unit length so diagonal
/// keyboard movement is not faster than cardinal. Move speed is the data-driven
/// <see cref="EffectiveCharacterStats.MoveSpeed"/> (authored base + active modifiers), recomputed
/// each tick by <see cref="StatRecomputeSystem"/> which runs before this system.
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[BurstCompile]
public partial struct PlayerMoveSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime;
foreach (var (transform, input, stats) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
{
float2 move = input.ValueRO.Move;
if (math.lengthsq(move) > 1f)
move = math.normalize(move);
float3 delta = new float3(move.x, 0f, move.y) * stats.ValueRO.MoveSpeed * dt;
transform.ValueRW.Position += delta;
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 35cd3eacccc2f4172b557b2807a6df22