Sync paint strokes across the network via command replication
Replicate brush strokes as self-contained commands instead of texture pixels: the owner emits each committed stamp (local placement plus brush state, byte-quantized) and remotes replay it through the same draw path, reproducing identical maps without streaming megabytes. - MeshPainter.Net.cs: PaintStampNet serializable stamp, OnStampCommitted owner-capture event (no-op without listener), ApplyNetworkStamp remote replay, seedable spray for deterministic speckle. - MeshPainter.cs: emit stamp after drawing, seed spray only when synced. - PlayerPaintSync.cs: NetworkBehaviour batching owner stamps reliably (ServerRpc -> ClientRpc) with echo guard; remote disables local painting. - Player/Hider/Hunter prefabs: PlayerPaintSync plus NGO GlobalObjectIdHash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f2f4e5999
commit
69e185d969
8 files changed
+285
-6
No files matched your search
@@ -12,13 +12,17 @@ PrefabInstance:
|
||||
propertyPath: scale
|
||||
value: 0.5
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 1557561356852236266, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: GlobalObjectIdHash
|
||||
value: 708302045
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 4587432968399885263, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: m_Enabled
|
||||
value: 1
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 5384024074013944205, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: Player Variant
|
||||
value: Hider
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: m_LocalScale.x
|
||||
|
||||
@@ -16,6 +16,10 @@ PrefabInstance:
|
||||
propertyPath: sprintSpeedMultiplier
|
||||
value: 2
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 1557561356852236266, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: GlobalObjectIdHash
|
||||
value: 1884035523
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 5384024074013944205, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: Hunter
|
||||
|
||||
@@ -49,6 +49,7 @@ GameObject:
|
||||
- component: {fileID: 6847253018511389861}
|
||||
- component: {fileID: 8574096554986372233}
|
||||
- component: {fileID: 8646586982149381366}
|
||||
- component: {fileID: 4234684093581208771}
|
||||
m_Layer: 0
|
||||
m_Name: Player
|
||||
m_TagString: Player
|
||||
@@ -111,7 +112,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 463b6be548abf464287096e1e92fcd01, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::PlayerCharacterController
|
||||
persistPlayer: 1
|
||||
persistPlayer: 0
|
||||
playerSaveId:
|
||||
moveSpeed: 2
|
||||
sprintSpeedMultiplier: 3.2
|
||||
@@ -224,7 +225,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||
GlobalObjectIdHash: 0
|
||||
GlobalObjectIdHash: 1963377418
|
||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||
DeferredDespawnTick: 0
|
||||
Ownership: 1
|
||||
@@ -310,6 +311,20 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::PlayerAnimationSync
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
--- !u!114 &4234684093581208771
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5384024074013944205}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f1b1a094f669d6b458370f63773462ed, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::PlayerPaintSync
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
maxStampsPerFrame: 64
|
||||
--- !u!1 &6840099669803454162
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -437,7 +452,7 @@ MonoBehaviour:
|
||||
brushShader: {fileID: 0}
|
||||
dilateShader: {fileID: 0}
|
||||
packMsShader: {fileID: 0}
|
||||
persistPaint: 1
|
||||
persistPaint: 0
|
||||
saveId:
|
||||
pipette: 0
|
||||
spray: 0
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// One committed brush stamp in a form that can be replicated across the network. Painting is
|
||||
/// synced as commands (these stamps), not as pixels: the owner already resolves every stamp into
|
||||
/// local object space (hit point, radius, tangent frame) inside <see cref="MeshPainter.PaintAtScreen"/>,
|
||||
/// so a remote peer just replays the SAME stamp through its own backend onto the identical mesh/UVs
|
||||
/// and gets a near-identical result - for a fraction of the bandwidth of streaming the 12 MB of maps.
|
||||
///
|
||||
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless and
|
||||
/// robust to ordering/loss (sent reliably). Channel data (metallic/smoothness/occlusion + opacities)
|
||||
/// quantizes to bytes with no real loss because the paint maps are 8-bit anyway.
|
||||
/// </summary>
|
||||
public struct PaintStampNet : INetworkSerializable
|
||||
{
|
||||
// Channel/mode bit flags (Channels field).
|
||||
public const byte FlagAlbedo = 1 << 0;
|
||||
public const byte FlagMetallicSmoothness = 1 << 1;
|
||||
public const byte FlagOcclusion = 1 << 2;
|
||||
public const byte FlagEmission = 1 << 3;
|
||||
public const byte FlagSpray = 1 << 4;
|
||||
|
||||
// Placement (object-local space).
|
||||
public Vector3 P; // hit point
|
||||
public float R; // 3D brush radius
|
||||
public Vector3 U, V, N; // tangent frame (right / up / normal)
|
||||
|
||||
public byte Channels; // active channels + spray flag
|
||||
public int BrushIndex; // active brush mask index (resolved-mask painting)
|
||||
public int SpraySeed; // RNG seed so a spray stamp scatters identically on every peer
|
||||
|
||||
public Color32 BrushColor; // albedo color (alpha = opacity)
|
||||
public Color32 EmissionColor; // emission color (alpha = opacity)
|
||||
public byte Metallic, Smoothness, Occlusion; // 0..255 = 0..1
|
||||
public byte MSOpacity, OccOpacity; // 0..255 = 0..1
|
||||
|
||||
public void NetworkSerialize<T>(BufferSerializer<T> s) where T : IReaderWriter
|
||||
{
|
||||
s.SerializeValue(ref P);
|
||||
s.SerializeValue(ref R);
|
||||
s.SerializeValue(ref U);
|
||||
s.SerializeValue(ref V);
|
||||
s.SerializeValue(ref N);
|
||||
s.SerializeValue(ref Channels);
|
||||
s.SerializeValue(ref BrushIndex);
|
||||
s.SerializeValue(ref SpraySeed);
|
||||
s.SerializeValue(ref BrushColor);
|
||||
s.SerializeValue(ref EmissionColor);
|
||||
s.SerializeValue(ref Metallic);
|
||||
s.SerializeValue(ref Smoothness);
|
||||
s.SerializeValue(ref Occlusion);
|
||||
s.SerializeValue(ref MSOpacity);
|
||||
s.SerializeValue(ref OccOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
/// <summary>
|
||||
/// Fires once per committed brush stamp on the owner (after the stamp has been drawn locally),
|
||||
/// carrying everything a remote peer needs to replay it. Only the owner's PlayerPaintSync
|
||||
/// subscribes; with no subscriber this is a no-op, so single-player keeps zero overhead.
|
||||
/// </summary>
|
||||
public event System.Action<PaintStampNet> OnStampCommitted;
|
||||
|
||||
// Last spray seed used by the owner this stamp (captured into the replicated stamp).
|
||||
private int _lastSpraySeed;
|
||||
|
||||
private static byte ToByte01(float v) => (byte)(Mathf.Clamp01(v) * 255f + 0.5f);
|
||||
|
||||
/// <summary>True if anyone is listening for replicated stamps (the owner is networked + painting).</summary>
|
||||
private bool WantsStampCapture => OnStampCommitted != null;
|
||||
|
||||
/// <summary>Builds the replicated stamp from the current brush state + resolved placement and raises the event.</summary>
|
||||
private void EmitStamp(Vector3 p, float r, Vector3 u, Vector3 v, Vector3 n)
|
||||
{
|
||||
var handler = OnStampCommitted;
|
||||
if (handler == null) return;
|
||||
|
||||
byte ch = 0;
|
||||
if (paintAlbedo) ch |= PaintStampNet.FlagAlbedo;
|
||||
if (paintMetallicSmoothness) ch |= PaintStampNet.FlagMetallicSmoothness;
|
||||
if (paintOcclusion) ch |= PaintStampNet.FlagOcclusion;
|
||||
if (paintEmission) ch |= PaintStampNet.FlagEmission;
|
||||
if (spray) ch |= PaintStampNet.FlagSpray;
|
||||
|
||||
handler(new PaintStampNet
|
||||
{
|
||||
P = p, R = r, U = u, V = v, N = n,
|
||||
Channels = ch,
|
||||
BrushIndex = activeBrushIndex,
|
||||
SpraySeed = _lastSpraySeed,
|
||||
BrushColor = brushColor,
|
||||
EmissionColor = emissionColor,
|
||||
Metallic = ToByte01(metallic),
|
||||
Smoothness = ToByte01(smoothness),
|
||||
Occlusion = ToByte01(occlusion),
|
||||
MSOpacity = ToByte01(metallicSmoothnessOpacity),
|
||||
OccOpacity = ToByte01(occlusionOpacity),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bakes the spray mask from a fixed RNG seed so every peer scatters the identical pattern. The
|
||||
/// global Unity RNG state is saved and restored so nothing else in the frame is perturbed.
|
||||
/// </summary>
|
||||
private void BuildSprayStampMaskSeeded(int seed)
|
||||
{
|
||||
var prev = Random.state;
|
||||
Random.InitState(seed);
|
||||
BuildSprayStampMask();
|
||||
Random.state = prev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a replicated brush stamp on a remote avatar: adopts the stamp's brush state, rebuilds
|
||||
/// the matching mask (resolved or seeded spray) and runs it through the active backend exactly like
|
||||
/// a local stamp. Mutating the public brush fields is safe here because the remote painter never
|
||||
/// paints from local input (PaintingEnabled is false on remotes).
|
||||
/// </summary>
|
||||
public void ApplyNetworkStamp(in PaintStampNet s)
|
||||
{
|
||||
if (!_setupValid) return;
|
||||
|
||||
paintAlbedo = (s.Channels & PaintStampNet.FlagAlbedo) != 0;
|
||||
paintMetallicSmoothness = (s.Channels & PaintStampNet.FlagMetallicSmoothness) != 0;
|
||||
paintOcclusion = (s.Channels & PaintStampNet.FlagOcclusion) != 0;
|
||||
paintEmission = (s.Channels & PaintStampNet.FlagEmission) != 0;
|
||||
spray = (s.Channels & PaintStampNet.FlagSpray) != 0;
|
||||
|
||||
brushColor = s.BrushColor;
|
||||
emissionColor = s.EmissionColor;
|
||||
metallic = s.Metallic / 255f;
|
||||
smoothness = s.Smoothness / 255f;
|
||||
occlusion = s.Occlusion / 255f;
|
||||
metallicSmoothnessOpacity = s.MSOpacity / 255f;
|
||||
occlusionOpacity = s.OccOpacity / 255f;
|
||||
activeBrushIndex = s.BrushIndex;
|
||||
|
||||
if (spray) BuildSprayStampMaskSeeded(s.SpraySeed);
|
||||
else { ResolveActiveMask(); UseResolvedStampMask(); }
|
||||
|
||||
if (_gpuActive)
|
||||
{
|
||||
BeginGpuStroke();
|
||||
DrawBrushGpu(s.P, s.R, s.U, s.V, s.N);
|
||||
EndGpuStroke();
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetCpuDirty();
|
||||
PaintStamp3D(s.P, s.R, s.U, s.V, s.N);
|
||||
EndCpuStroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff9c722ff799831408a3475fe7fd57a6
|
||||
@@ -704,12 +704,19 @@ public partial class MeshPainter : MonoBehaviour
|
||||
return;
|
||||
|
||||
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
|
||||
// standard mask. Both backends read it through the _stamp* fields.
|
||||
if (spray) BuildSprayStampMask();
|
||||
// standard mask. Both backends read it through the _stamp* fields. When this stamp is
|
||||
// being replicated (networked owner), the spray is seeded so remotes scatter identically.
|
||||
if (spray)
|
||||
{
|
||||
if (WantsStampCapture) { _lastSpraySeed = Random.Range(1, int.MaxValue); BuildSprayStampMaskSeeded(_lastSpraySeed); }
|
||||
else BuildSprayStampMask();
|
||||
}
|
||||
else UseResolvedStampMask();
|
||||
|
||||
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal);
|
||||
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
|
||||
|
||||
EmitStamp(pLocal, radiusLocal, u, v, nLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Replicates live mesh painting across the network as brush-stamp commands (not pixels). The owner
|
||||
/// captures every committed stamp from its <see cref="MeshPainter"/>, batches the frame's stamps and
|
||||
/// forwards them; remote avatars replay each stamp through their own painter onto the identical
|
||||
/// mesh/UVs. Position/animation come from the other sync components; this only carries the tiny paint
|
||||
/// commands (~tens of bytes each), so two players see each other paint live without streaming the
|
||||
/// 12 MB of paint maps.
|
||||
///
|
||||
/// Live-only milestone: a client that joins AFTER painting happened does not receive the prior strokes
|
||||
/// (no snapshot baseline yet) - it starts seeing paint from the moment it connects.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerPaintSync : NetworkBehaviour
|
||||
{
|
||||
[Tooltip("Max brush stamps forwarded to the network per frame. Pathological fast strokes (hundreds " +
|
||||
"of interpolated sub-stamps in one frame) are decimated to this to bound the RPC size; local " +
|
||||
"painting stays full fidelity.")]
|
||||
[SerializeField] private int maxStampsPerFrame = 64;
|
||||
|
||||
private MeshPainter _painter;
|
||||
private readonly List<PaintStampNet> _pending = new List<PaintStampNet>();
|
||||
|
||||
private void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!_painter)
|
||||
return;
|
||||
|
||||
if (IsOwner)
|
||||
{
|
||||
_painter.OnStampCommitted += OnStampCommitted;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remote replica: never paint from this machine's mouse; paint only from replicated stamps.
|
||||
_painter.PaintingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (_painter && IsOwner)
|
||||
_painter.OnStampCommitted -= OnStampCommitted;
|
||||
}
|
||||
|
||||
private void OnStampCommitted(PaintStampNet stamp) => _pending.Add(stamp);
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (!IsOwner || _pending.Count == 0)
|
||||
return;
|
||||
|
||||
SubmitStampsServerRpc(BuildBatch());
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Returns this frame's stamps, uniformly decimated to maxStampsPerFrame if a fast stroke overran it.</summary>
|
||||
private PaintStampNet[] BuildBatch()
|
||||
{
|
||||
int count = _pending.Count;
|
||||
if (count <= maxStampsPerFrame)
|
||||
return _pending.ToArray();
|
||||
|
||||
var outp = new PaintStampNet[maxStampsPerFrame];
|
||||
for (int i = 0; i < maxStampsPerFrame; i++)
|
||||
outp[i] = _pending[(int)((long)i * count / maxStampsPerFrame)];
|
||||
return outp;
|
||||
}
|
||||
|
||||
[ServerRpc]
|
||||
private void SubmitStampsServerRpc(PaintStampNet[] stamps) => ApplyStampsClientRpc(stamps);
|
||||
|
||||
[ClientRpc]
|
||||
private void ApplyStampsClientRpc(PaintStampNet[] stamps)
|
||||
{
|
||||
// The owner already painted these locally; only remotes replay them.
|
||||
if (IsOwner || !_painter)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < stamps.Length; i++)
|
||||
_painter.ApplyNetworkStamp(stamps[i]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f1b1a094f669d6b458370f63773462ed
|
||||
Reference in new issue
Block a user