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