From 69e185d969aaaf730c3d22642ce81bc92c153675 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 27 Jun 2026 15:02:33 +0200 Subject: [PATCH] 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 --- Assets/Prefabs/Hider.prefab | 6 +- Assets/Prefabs/Hunter.prefab | 4 + Assets/Prefabs/Player.prefab | 21 +++- Assets/Scripts/MeshPainter.Net.cs | 157 +++++++++++++++++++++++++ Assets/Scripts/MeshPainter.Net.cs.meta | 2 + Assets/Scripts/MeshPainter.cs | 11 +- Assets/Scripts/PlayerPaintSync.cs | 88 ++++++++++++++ Assets/Scripts/PlayerPaintSync.cs.meta | 2 + 8 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 Assets/Scripts/MeshPainter.Net.cs create mode 100644 Assets/Scripts/MeshPainter.Net.cs.meta create mode 100644 Assets/Scripts/PlayerPaintSync.cs create mode 100644 Assets/Scripts/PlayerPaintSync.cs.meta diff --git a/Assets/Prefabs/Hider.prefab b/Assets/Prefabs/Hider.prefab index e88df9f..4563a8d 100644 --- a/Assets/Prefabs/Hider.prefab +++ b/Assets/Prefabs/Hider.prefab @@ -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 diff --git a/Assets/Prefabs/Hunter.prefab b/Assets/Prefabs/Hunter.prefab index 10eec8e..e2fd8ec 100644 --- a/Assets/Prefabs/Hunter.prefab +++ b/Assets/Prefabs/Hunter.prefab @@ -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 diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab index 2fb0e3b..b5097b7 100644 --- a/Assets/Prefabs/Player.prefab +++ b/Assets/Prefabs/Player.prefab @@ -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 diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs new file mode 100644 index 0000000..8f83be0 --- /dev/null +++ b/Assets/Scripts/MeshPainter.Net.cs @@ -0,0 +1,157 @@ +using Unity.Netcode; +using UnityEngine; + +/// +/// 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 , +/// 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. +/// +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(BufferSerializer 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 +{ + /// + /// 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. + /// + public event System.Action 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); + + /// True if anyone is listening for replicated stamps (the owner is networked + painting). + private bool WantsStampCapture => OnStampCommitted != null; + + /// Builds the replicated stamp from the current brush state + resolved placement and raises the event. + 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), + }); + } + + /// + /// 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. + /// + private void BuildSprayStampMaskSeeded(int seed) + { + var prev = Random.state; + Random.InitState(seed); + BuildSprayStampMask(); + Random.state = prev; + } + + /// + /// 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). + /// + 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(); + } + } +} diff --git a/Assets/Scripts/MeshPainter.Net.cs.meta b/Assets/Scripts/MeshPainter.Net.cs.meta new file mode 100644 index 0000000..f9e3e29 --- /dev/null +++ b/Assets/Scripts/MeshPainter.Net.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff9c722ff799831408a3475fe7fd57a6 \ No newline at end of file diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index 377a37d..635cbd3 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -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); } /// diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs new file mode 100644 index 0000000..9313725 --- /dev/null +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; + +/// +/// Replicates live mesh painting across the network as brush-stamp commands (not pixels). The owner +/// captures every committed stamp from its , 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. +/// +[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 _pending = new List(); + + private void Awake() => _painter = GetComponentInChildren(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(); + } + + /// Returns this frame's stamps, uniformly decimated to maxStampsPerFrame if a fast stroke overran it. + 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]); + } +} diff --git a/Assets/Scripts/PlayerPaintSync.cs.meta b/Assets/Scripts/PlayerPaintSync.cs.meta new file mode 100644 index 0000000..78173c0 --- /dev/null +++ b/Assets/Scripts/PlayerPaintSync.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f1b1a094f669d6b458370f63773462ed \ No newline at end of file