Sync mesh lean offset and disable concealment meter on remote avatars

Replicate the composed wall-lean/ground-raise/ceiling-lean mesh offset
(meshOffsetTarget.localPosition) as an owner-authoritative NetworkVariable
so remote avatars lean and raise instead of staying rigid; remote eases it
onto the visual root. Disable ConcealmentMeter on remotes (its per-frame
skinned bake, voxelization and physics overlaps only feed the local player).
SkinnedColliderBaker stays active on remotes because the paint replay
rasterizes its live BakedMesh for UV mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 15:59:25 +02:00
1 parent ae84bb99dd
commit eedba5f0e6
2 files changed
+78 -19

No files matched your search

+11
View File
@@ -28,5 +28,16 @@ public class NetworkPlayerSetup : NetworkBehaviour
if (controller) controller.enabled = false;
var cc = GetComponent<CharacterController>();
if (cc) cc.enabled = false;
// Owner cleanup: the concealment meter is a per-frame skinned-mesh bake + voxelization +
// physics overlap whose result only feeds the LOCAL player (HUD / gameplay). A remote avatar's
// concealment is computed on its own owner's machine, so running it here is pure wasted CPU.
var concealment = GetComponentInChildren<ConcealmentMeter>(true);
if (concealment) concealment.enabled = false;
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes. MeshPainter's stamp replay
// (ApplyNetworkStamp -> DrawBrushGpu) rasterizes the baker's live, per-frame BakedMesh to map a
// 3D brush placement into UV space; freezing the bake would mis-map replicated paint onto any
// non-rest pose. Its bake is the necessary cost of correct remote paint, not wasted work.
}
}
+67 -19
View File
@@ -2,23 +2,33 @@ using Unity.Netcode;
using UnityEngine;
/// <summary>
/// Replicates the pose system's animation state across the network. The owner publishes its selected
/// pose index and resolved locomotion category (idle / walk / run / fall); remote avatars switch their
/// <see cref="PlayerPoseController"/> to network-driven mode and play the matching clip, so a remote
/// player shows the same pose and walk/run/fall animation the owner sees — not just a sliding idle.
/// Replicates the visual body state that the disabled-on-remote controller / pose system would
/// otherwise drive: the owner publishes its selected pose index, resolved locomotion category
/// (idle / walk / run / fall) AND the wall-lean / ground-raise / ceiling mesh offset, so a remote
/// avatar shows the same pose, walk/run/fall animation and leans into walls / raises off the ground
/// like the owner — not just a sliding, upright idle.
///
/// Position/rotation come from the NetworkTransform; this only carries the small animation state. Both
/// values are owner-authoritative and change rarely (a pose cycle, a locomotion transition), so the
/// NetworkVariables send a delta only when the value actually changes.
/// Body position/rotation come from the NetworkTransform; this only carries the small extra state on
/// top. Pose index and locomotion change rarely (a pose cycle, a locomotion transition), so those
/// NetworkVariables send a delta only when the value actually changes. The mesh offset is the visual
/// model's child-local position (<see cref="PlayerCharacterController.MeshOffsetTarget"/>) — a tiny
/// vector the owner eases continuously; the remote eases onto the replicated value so it doesn't
/// tick-step (the body itself is already interpolated by the NetworkTransform).
///
/// Runs after the PlayerPoseController (execution order 100) so the owner reads this frame's resolved
/// state; remote avatars apply via OnValueChanged, independent of frame order.
/// state; remote avatars apply via OnValueChanged / per-frame easing, independent of frame order.
/// </summary>
[RequireComponent(typeof(PlayerPoseController))]
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
[DefaultExecutionOrder(200)]
public class PlayerAnimationSync : NetworkBehaviour
{
[Tooltip("How fast a remote avatar eases its mesh lean/raise offset onto the replicated value " +
"(1/second). Higher = tighter follow, lower = smoother. The body itself is interpolated " +
"by the NetworkTransform; this only smooths the small child-local lean offset.")]
[SerializeField] private float meshOffsetFollow = 18f;
private readonly NetworkVariable<int> _poseIndex =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
@@ -26,12 +36,27 @@ public class PlayerAnimationSync : NetworkBehaviour
private readonly NetworkVariable<byte> _locomotion =
new(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private PlayerPoseController _pose;
// Visual model's child-local lean/raise offset: wall lean + ground raise + ceiling lean, all folded
// by the controller into MeshOffsetTarget.localPosition. Owner-authoritative like the rest. Already in
// the root's local space, so it stays correct under the synced body rotation (wall/ceiling) as-is.
private readonly NetworkVariable<Vector3> _meshOffset =
new(Vector3.zero, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
void Awake() => _pose = GetComponent<PlayerPoseController>();
private PlayerPoseController _pose;
private PlayerCharacterController _controller;
private Transform _offsetTarget;
void Awake()
{
_pose = GetComponent<PlayerPoseController>();
_controller = GetComponent<PlayerCharacterController>();
}
public override void OnNetworkSpawn()
{
// Resolved after all Awakes ran: the controller resolves MeshOffsetTarget in its own Awake.
_offsetTarget = _controller ? _controller.MeshOffsetTarget : null;
if (IsOwner)
return;
@@ -40,6 +65,9 @@ public class PlayerAnimationSync : NetworkBehaviour
_poseIndex.OnValueChanged += OnPoseIndexChanged;
_locomotion.OnValueChanged += OnLocomotionChanged;
ApplyRemote(); // spawn-time values are already replicated
// Seed the lean offset to the replicated value so a mid-lean late join doesn't pop from base.
if (_offsetTarget) _offsetTarget.localPosition = _meshOffset.Value;
}
public override void OnNetworkDespawn()
@@ -50,16 +78,36 @@ public class PlayerAnimationSync : NetworkBehaviour
void Update()
{
if (!IsOwner || !_pose)
return;
if (IsOwner)
{
if (!_pose)
return;
// Publish the owner's resolved animation state. Assigning the same value is a no-op for
// replication (the NetworkVariable dirties only on an actual change), but guard anyway to keep
// intent clear.
int index = _pose.CurrentIndex;
byte loco = (byte)_pose.CurrentLocomotion;
if (_poseIndex.Value != index) _poseIndex.Value = index;
if (_locomotion.Value != loco) _locomotion.Value = loco;
// Publish the owner's resolved animation state. Assigning the same value is a no-op for
// replication (the NetworkVariable dirties only on an actual change).
int index = _pose.CurrentIndex;
byte loco = (byte)_pose.CurrentLocomotion;
if (_poseIndex.Value != index) _poseIndex.Value = index;
if (_locomotion.Value != loco) _locomotion.Value = loco;
// Mesh lean/raise offset: the controller owns MeshOffsetTarget.localPosition (sole writer),
// so this frame's value is final by execution order 200.
if (_offsetTarget)
{
Vector3 lp = _offsetTarget.localPosition;
if (_meshOffset.Value != lp) _meshOffset.Value = lp;
}
return;
}
// Remote avatar: ease the visual model onto the replicated lean/raise offset. The controller is
// disabled here, so this is the sole writer of MeshOffsetTarget.localPosition on the remote (the
// pose reset already excludes that node). Easing avoids tick-rate stepping on the small offset.
if (_offsetTarget)
{
float k = 1f - Mathf.Exp(-meshOffsetFollow * Time.deltaTime);
_offsetTarget.localPosition = Vector3.Lerp(_offsetTarget.localPosition, _meshOffset.Value, k);
}
}
private void OnPoseIndexChanged(int previous, int current) => ApplyRemote();