diff --git a/Assets/Prefabs/PaintHudPanel.prefab b/Assets/Prefabs/PaintHudPanel.prefab
index 7dd8edc..e9307f6 100644
--- a/Assets/Prefabs/PaintHudPanel.prefab
+++ b/Assets/Prefabs/PaintHudPanel.prefab
@@ -3527,7 +3527,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
- m_SizeDelta: {x: 0, y: 0}
+ m_SizeDelta: {x: 100, y: 0}
m_Pivot: {x: 0.5, y: 1}
--- !u!114 &6115439559885354559
MonoBehaviour:
@@ -3542,10 +3542,10 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup
m_Padding:
- m_Left: 6
- m_Right: 6
- m_Top: 6
- m_Bottom: 6
+ m_Left: 4
+ m_Right: 4
+ m_Top: 4
+ m_Bottom: 4
m_ChildAlignment: 0
m_Spacing: 3
m_ChildForceExpandWidth: 1
diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs
index 6e77258..eb12a5e 100644
--- a/Assets/Scripts/MeshPainter.Backend.cs
+++ b/Assets/Scripts/MeshPainter.Backend.cs
@@ -35,6 +35,19 @@ public partial class MeshPainter
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
}
+ ///
+ /// Tears down the active backend and rebuilds it from externally supplied canonical maps (load /
+ /// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for
+ /// the new content. The input arrays are never mutated (SetupBackend clones its seed).
+ ///
+ private void ReseedCanonical(Color32[][] canonical)
+ {
+ if (_gpuActive) TeardownGpu();
+ else TeardownCpu();
+ SetupBackend(useGpu, canonical);
+ _hasLastScreen = false;
+ }
+
/// Reads a map from the active backend into canonical (material) layout.
private Color32[] ReadCanonical(int mi)
{
diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs
index 46c5f7c..0a6759e 100644
--- a/Assets/Scripts/MeshPainter.Net.cs
+++ b/Assets/Scripts/MeshPainter.Net.cs
@@ -155,6 +155,10 @@ public partial class MeshPainter
PaintStamp3D(s.P, s.R, s.U, s.V, s.N);
EndCpuStroke();
}
+
+ // Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same
+ // history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner).
+ MarkStrokePainted();
}
// --- Late-join snapshot: the whole painted state as a compressed blob ---
@@ -210,8 +214,12 @@ public partial class MeshPainter
return true;
}
- /// Decompresses a snapshot and re-seeds the active backend with it. Caller must ensure IsPaintReady.
- public void ApplyPaintSnapshot(byte[] compressed)
+ ///
+ /// Decompresses a snapshot, re-seeds the active backend with it, and makes it this peer's sole history
+ /// baseline under the authority's (so subsequent checkpoint/restore
+ /// commands line up). Caller must ensure IsPaintReady.
+ ///
+ public void ApplyPaintSnapshot(byte[] compressed, int baselineId)
{
if (!_setupValid || compressed == null || compressed.Length == 0) return;
@@ -226,10 +234,8 @@ public partial class MeshPainter
// Re-seed exactly like SwitchBackend, but with externally supplied maps, so the island/dilation
// seeding path runs and the edge padding is rebuilt correctly for the loaded content.
- if (_gpuActive) TeardownGpu();
- else TeardownCpu();
- SetupBackend(useGpu, canonical);
- _hasLastScreen = false;
+ ReseedCanonical(canonical);
+ SetHistoryBaseline(canonical, baselineId);
}
private Color32[][] DecodeSnapshot(byte[] compressed)
diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs
index 1ddb08b..10dde16 100644
--- a/Assets/Scripts/MeshPainter.Persistence.cs
+++ b/Assets/Scripts/MeshPainter.Persistence.cs
@@ -121,10 +121,11 @@ public partial class MeshPainter
// Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path
// runs and the edge padding is rebuilt correctly for the loaded content.
- if (_gpuActive) TeardownGpu();
- else TeardownCpu();
- SetupBackend(useGpu, canonical);
- _hasLastScreen = false;
+ ReseedCanonical(canonical);
+
+ // A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
+ // baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
+ ResetHistoryToCurrent(canonical);
return true;
}
diff --git a/Assets/Scripts/MeshPainter.Undo.cs b/Assets/Scripts/MeshPainter.Undo.cs
new file mode 100644
index 0000000..1d5df5a
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Undo.cs
@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+using UnityEngine;
+#if ENABLE_INPUT_SYSTEM
+using UnityEngine.InputSystem;
+#endif
+
+public partial class MeshPainter
+{
+ // --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer ---
+ //
+ // One stroke (mouse-down -> mouse-up) = one undo step. Each entry is the full canonical map set AFTER
+ // that edit, restored by re-seeding the backend (same path as load / late-join snapshot). The baseline
+ // at index 0 is the material seed (or a loaded / snapshotted baseline).
+ //
+ // Networked design: every replica keeps the SAME history, so undo/redo replicates as a tiny command
+ // instead of re-broadcasting the whole painting. The owner assigns each committed stroke a monotonic
+ // CHECKPOINT ID; remotes replay the same stamps and capture history at the same boundaries (driven by a
+ // network checkpoint marker) under the owner's id. Undo/redo carries the TARGET id; a peer that holds
+ // that id restores locally, a peer that doesn't (a late joiner asked to undo past its join) falls back
+ // to a snapshot. PlayerPaintSync drives all of this through OnHistoryCheckpoint / OnHistoryRestore.
+ //
+ // Memory: consecutive states differ only in the maps a stroke actually painted, so unchanged maps are
+ // shared by reference with the previous entry (copy-on-write). This is safe because both backends
+ // CLONE their seed input (SeedCpuPixels / SeedGpuMap) and ReadCanonical returns fresh arrays - nothing
+ // ever writes back into a stored state array.
+
+ [Header("Undo")]
+ [Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " +
+ "the oldest kept state is the undo floor. Higher = more undo depth but more memory (only the " +
+ "maps a stroke changed are stored per step).")]
+ [Min(1)]
+ public int maxUndoSteps = 20;
+
+ ///
+ /// Fires on the owner when a stroke is committed as a new history entry, carrying its checkpoint id.
+ /// PlayerPaintSync forwards a marker so every remote captures the same entry under the same id. No
+ /// subscriber = no-op (single-player).
+ ///
+ public event System.Action OnHistoryCheckpoint;
+
+ ///
+ /// Fires on the owner when the painting is restored to a history entry (undo / redo), carrying that
+ /// entry's checkpoint id. PlayerPaintSync forwards the id so peers reproduce the move locally (or fetch
+ /// a snapshot if they lack that id). No subscriber = no-op (single-player).
+ ///
+ public event System.Action OnHistoryRestore;
+
+ private List _undoStates; // index 0 = oldest kept state; null until InitUndoHistory ran
+ private List _undoIds; // parallel checkpoint id per state (owner-assigned, peer-shared)
+ private int _undoIndex; // the state the painter currently shows
+ private int _nextCheckpointId; // owner: next id to assign; remotes set ids from the network
+
+ private bool _strokeActive; // a stroke has painted >=1 stamp and is not yet committed to history
+ private int _strokeDirtyMaps; // bitmask of map indices touched by the current stroke
+
+ /// The checkpoint id of the state currently shown. Used as the baseline tag when snapshotting.
+ public int CurrentCheckpointId =>
+ (_undoIds != null && _undoIndex >= 0 && _undoIndex < _undoIds.Count) ? _undoIds[_undoIndex] : 0;
+
+ /// Seeds the history with the material baseline (id 0). Called once after the backend is set up.
+ private void InitUndoHistory()
+ {
+ if (_seedCanonical == null) return;
+ SetSingleBaseline(_seedCanonical, 0);
+ _nextCheckpointId = 1;
+ }
+
+ ///
+ /// Rebuilds the whole history to a single baseline entry holding (per-map null
+ /// falls back to the live backend), tagged . Clears all undo/redo. Used by the
+ /// material seed, a load (owner) and an applied snapshot (remote). Takes ownership of the passed arrays.
+ ///
+ private void SetSingleBaseline(Color32[][] maps, int id)
+ {
+ if (_undoStates == null) _undoStates = new List(Mathf.Max(2, maxUndoSteps + 1));
+ if (_undoIds == null) _undoIds = new List(Mathf.Max(2, maxUndoSteps + 1));
+ _undoStates.Clear();
+ _undoIds.Clear();
+
+ int n = textureSize * textureSize;
+ var baseline = new Color32[MAP_COUNT][];
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ var s = (maps != null) ? maps[mi] : null;
+ baseline[mi] = (s != null && s.Length == n) ? s : ReadCanonical(mi);
+ }
+ _undoStates.Add(baseline);
+ _undoIds.Add(id);
+ _undoIndex = 0;
+ _strokeActive = false;
+ _strokeDirtyMaps = 0;
+ }
+
+ /// Owner-only: replace the whole history with the current maps as the sole baseline (used by load).
+ private int ResetHistoryToCurrent(Color32[][] maps)
+ {
+ int id = _nextCheckpointId++;
+ SetSingleBaseline(maps, id);
+ return id;
+ }
+
+ /// Remote-only: make the just-applied snapshot maps the sole history baseline under the authority's id.
+ public void SetHistoryBaseline(Color32[][] maps, int baselineId)
+ {
+ if (!_setupValid) return;
+ SetSingleBaseline(maps, baselineId);
+ }
+
+ /// Marks that the current stroke painted a stamp (called per committed stamp, local or replicated).
+ private void MarkStrokePainted()
+ {
+ _strokeActive = true;
+ if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO;
+ if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO;
+ if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM;
+ }
+
+ ///
+ /// Owner: commits an in-progress stroke as one undo step and announces its checkpoint id (no-op if
+ /// nothing was painted). Remotes use RemoteCommitCheckpoint instead.
+ ///
+ private void EndStrokeIfActive()
+ {
+ if (!_strokeActive) return;
+
+ // Stroke held but nothing actually painted (no active channel / no stamp landed): drop it without
+ // consuming a checkpoint id, so the owner only ever announces ids it really holds an entry for.
+ if (_strokeDirtyMaps == 0)
+ {
+ _strokeActive = false;
+ return;
+ }
+
+ int id = _nextCheckpointId++;
+ CaptureUndoState(_strokeDirtyMaps, id);
+ _strokeActive = false;
+ _strokeDirtyMaps = 0;
+ OnHistoryCheckpoint?.Invoke(id);
+ }
+
+ ///
+ /// Remote: commits the stamps replayed since the last checkpoint as one undo step under the owner's id,
+ /// keeping this peer's history in lockstep. Always records the id - even if (rarely) no stamp landed
+ /// here for the stroke - so every replica's history stays 1:1 aligned with the owner's and a later
+ /// undo/redo to this id reproduces locally instead of forcing a snapshot fallback.
+ ///
+ public void RemoteCommitCheckpoint(int id)
+ {
+ if (!_setupValid) return;
+ CaptureUndoState(_strokeDirtyMaps, id, forceEntry: true);
+ _strokeActive = false;
+ _strokeDirtyMaps = 0;
+ }
+
+ ///
+ /// Snapshots the current maps as a new history entry under , dropping any redo
+ /// branch. With an entry is added even when nothing changed (it then
+ /// shares every map with the previous entry via COW) - used by remote checkpoints to keep ids aligned.
+ ///
+ private void CaptureUndoState(int dirtyMaps, int id, bool forceEntry = false)
+ {
+ if (_undoStates == null) return;
+ if (dirtyMaps == 0 && !forceEntry) return;
+
+ // A fresh edit invalidates everything ahead of the cursor (the redo branch).
+ if (_undoIndex < _undoStates.Count - 1)
+ {
+ int from = _undoIndex + 1;
+ int n = _undoStates.Count - from;
+ _undoStates.RemoveRange(from, n);
+ _undoIds.RemoveRange(from, n);
+ }
+
+ Color32[][] prev = _undoStates[_undoIndex];
+ var state = new Color32[MAP_COUNT][];
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ bool changed = (dirtyMaps & (1 << mi)) != 0;
+ state[mi] = changed ? ReadCanonical(mi) : prev[mi]; // COW: share maps the stroke didn't touch
+ }
+
+ _undoStates.Add(state);
+ _undoIds.Add(id);
+ _undoIndex = _undoStates.Count - 1;
+
+ // Bound memory: keep at most maxUndoSteps edits beyond the oldest kept state.
+ int maxEntries = Mathf.Max(1, maxUndoSteps) + 1;
+ while (_undoStates.Count > maxEntries)
+ {
+ _undoStates.RemoveAt(0);
+ _undoIds.RemoveAt(0);
+ _undoIndex--;
+ }
+ }
+
+ /// Owner: steps back one edit and announces the restore. Returns false if already at the floor.
+ public bool Undo()
+ {
+ if (_undoStates == null || _undoIndex <= 0) return false;
+ _undoIndex--;
+ RestoreCurrentState();
+ return true;
+ }
+
+ /// Owner: steps forward one edit (after an undo). Returns false if already at the newest state.
+ public bool Redo()
+ {
+ if (_undoStates == null || _undoIndex >= _undoStates.Count - 1) return false;
+ _undoIndex++;
+ RestoreCurrentState();
+ return true;
+ }
+
+ private void RestoreCurrentState()
+ {
+ ReseedCanonical(_undoStates[_undoIndex]);
+ _strokeActive = false;
+ _strokeDirtyMaps = 0;
+ OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id
+ }
+
+ ///
+ /// Remote: restores to the history entry tagged . Returns false (and does nothing)
+ /// if this peer doesn't hold that id - the caller then falls back to fetching a snapshot.
+ ///
+ public bool RemoteRestoreTo(int id)
+ {
+ if (_undoStates == null || _undoIds == null) return false;
+ int idx = _undoIds.IndexOf(id);
+ if (idx < 0) return false;
+
+ _undoIndex = idx;
+ ReseedCanonical(_undoStates[idx]);
+ _strokeActive = false;
+ _strokeDirtyMaps = 0;
+ return true;
+ }
+
+ /// Reads Ctrl+Z / Ctrl+Y this frame. Modifier-gated so plain Z/Y never trigger it.
+ private void GetUndoRedoInput(out bool undo, out bool redo)
+ {
+ undo = false;
+ redo = false;
+#if ENABLE_INPUT_SYSTEM
+ var kb = Keyboard.current;
+ if (kb != null)
+ {
+ if (kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed)
+ {
+ undo = kb.zKey.wasPressedThisFrame;
+ redo = kb.yKey.wasPressedThisFrame;
+ }
+ return;
+ }
+#endif
+#if ENABLE_LEGACY_INPUT_MANAGER
+ if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl))
+ {
+ undo = Input.GetKeyDown(KeyCode.Z);
+ redo = Input.GetKeyDown(KeyCode.Y);
+ }
+#endif
+ }
+}
diff --git a/Assets/Scripts/MeshPainter.Undo.cs.meta b/Assets/Scripts/MeshPainter.Undo.cs.meta
new file mode 100644
index 0000000..8068925
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Undo.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9d7335962dd88b844bdaac92a7cb8d85
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index ac92f74..611c11b 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -303,6 +303,7 @@ public partial class MeshPainter : MonoBehaviour
// HUD library (MeshPainter.Persistence), never auto-loaded on start.
SetupBackend(useGpu, _seedCanonical);
ConfigureMaterialPbr();
+ InitUndoHistory(); // baseline = material seed, captured before any stroke
EnsureProceduralMask();
ResolveActiveMask();
@@ -592,10 +593,22 @@ public partial class MeshPainter : MonoBehaviour
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
if (!PaintingEnabled)
{
+ EndStrokeIfActive(); // finalize an in-progress stroke if the mode was left mid-paint
_hasLastScreen = false;
return;
}
+ // Undo / redo (Ctrl+Z / Ctrl+Y): only here, since PaintingEnabled is true only on the local
+ // owner in paint mode. Commit any in-progress stroke first so it becomes its own history step.
+ GetUndoRedoInput(out bool wantUndo, out bool wantRedo);
+ if (wantUndo || wantRedo)
+ {
+ EndStrokeIfActive();
+ if (wantUndo) Undo();
+ else Redo();
+ return;
+ }
+
if (updateColliderEveryFrame && _skinned)
{
BakeColliderMesh();
@@ -617,6 +630,7 @@ public partial class MeshPainter : MonoBehaviour
// entirely (no painting, no brush preview ring while active).
if (pipette)
{
+ EndStrokeIfActive(); // switching to the eyedropper ends any active stroke
if (pressed)
{
if (!_pickHeld) PickAtScreen(screenPos);
@@ -635,6 +649,7 @@ public partial class MeshPainter : MonoBehaviour
if (!pressed)
{
+ EndStrokeIfActive(); // mouse released -> commit the stroke as one undo step
_hasLastScreen = false;
return;
}
@@ -717,6 +732,7 @@ public partial class MeshPainter : MonoBehaviour
else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal);
EmitStamp(pLocal, radiusLocal, u, v, nLocal);
+ MarkStrokePainted(); // a stamp landed -> this stroke is undoable
}
///
diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs
index 8fa9338..1dea744 100644
--- a/Assets/Scripts/PlayerPaintSync.cs
+++ b/Assets/Scripts/PlayerPaintSync.cs
@@ -11,11 +11,21 @@ using UnityEngine;
/// commands (~tens of bytes each), so two players see each other paint live without streaming the
/// 12 MB of paint maps.
///
-/// Late join: a client that connects AFTER painting happened also gets the accumulated result. On
-/// spawn it pulls a one-time compressed snapshot of the whole map set from the server (chunked,
-/// throttled to respect the transport's reliable window), buffers any live stamps that arrive during
-/// the transfer, and after applying the snapshot replays the buffered stamps newer than the snapshot's
-/// cutoff sequence - so no stroke is lost or double-applied across the join window.
+/// Undo/redo replicates as commands too, not pixels: every replica keeps the SAME per-stroke history
+/// (see MeshPainter.Undo), so the owner just forwards a per-stroke CHECKPOINT marker and an undo/redo
+/// RESTORE id. Each peer reproduces the move from its own history. A peer that lacks the target id (a
+/// late joiner asked to undo past its join) heals by pulling a fresh snapshot from the OWNER - the only
+/// guaranteed-correct source of the post-restore state, so this works for a remote client and for the
+/// host's own replica alike. Stamps are capped per frame but their overflow carries to the next frame
+/// rather than being decimated, and checkpoint/restore markers never overtake the stamps they finalize,
+/// so every replica's history stays aligned and the heal path stays a rare exception, not the norm.
+///
+/// Late join: a client that connects AFTER painting happened gets the accumulated result. On spawn it
+/// pulls a one-time compressed snapshot of the whole map set from the server (chunked, throttled to
+/// respect the transport's reliable window), buffers any live events that arrive during the transfer,
+/// and after applying the snapshot replays the buffered events newer than the snapshot's cutoff
+/// sequence - so no stroke is lost or double-applied across the join window. The applied snapshot also
+/// becomes the joiner's history baseline.
///
/// Emission intensity is the one paint property that is NOT baked into the maps (it's a global
/// multiplier on the material's _EmissionColor), so it can't ride the stamp/snapshot path. It rides its
@@ -26,10 +36,16 @@ using UnityEngine;
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.")]
+ "of interpolated sub-stamps in one frame) are capped to this to bound the RPC size; the " +
+ "overflow is CARRIED OVER to the next frame (never dropped), so remotes replay the identical " +
+ "stamp set and undo/redo history stays aligned. Local painting is always full fidelity.")]
[SerializeField] private int maxStampsPerFrame = 64;
+ [Tooltip("Log paint-sync events (stamps/checkpoints/restores sent & applied, snapshot transfers) to " +
+ "the console with a per-peer tag. Handy for diagnosing undo/redo replication; turn off for " +
+ "quiet play.")]
+ [SerializeField] private bool logSync = true;
+
[Tooltip("Snapshot transfer chunk payload (bytes). Kept under the transport MTU so each chunk is a " +
"single packet (no extra fragmentation).")]
[SerializeField] private int snapshotChunkBytes = 1200;
@@ -39,7 +55,19 @@ public class PlayerPaintSync : NetworkBehaviour
[SerializeField] private int snapshotChunksPerFrame = 8;
private MeshPainter _painter;
- private readonly List _pending = new List();
+
+ // Single owner-side FIFO of everything to forward, in the exact order it was produced: brush stamps
+ // interleaved with stroke checkpoints and undo/redo restores. Flushed in order in LateUpdate so a
+ // checkpoint/restore never overtakes the stamps it refers to, and so the per-frame stamp cap can carry
+ // overflow to the next frame without ever reordering or dropping a command.
+ private enum OutKind { Stamp, Checkpoint, Restore }
+ private struct OutItem
+ {
+ public OutKind kind;
+ public PaintStampNet stamp; // Stamp only
+ public int id; // Checkpoint / Restore only
+ }
+ private readonly List _outgoing = new List();
// Global emission-intensity multiplier (MeshPainter.emissionIntensity) — a per-player material value,
// not part of the painted maps. Owner-authoritative like the rest; the remote applies it to its
@@ -47,17 +75,28 @@ public class PlayerPaintSync : NetworkBehaviour
private readonly NetworkVariable _emissionIntensity =
new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
- // Monotonic batch counter on the owner; on every peer, the highest batch sequence accounted for
- // (applied to a remote replica / sent by the owner). Used as the snapshot's cutoff marker.
+ // Monotonic message counter on the owner (one per stamp batch / checkpoint / restore); on every peer,
+ // the highest sequence accounted for (applied to a remote replica / sent by the owner). Used as the
+ // snapshot's cutoff marker so the join window neither loses nor double-applies events.
private int _seq;
private int _seqHorizon;
- // --- Joining client: snapshot reassembly + live-stamp buffering until the baseline is applied ---
- private bool _awaitingSnapshot; // buffer live stamps instead of applying them
+ // --- Joining client: snapshot reassembly + live-event buffering until the baseline is applied ---
+ private enum NetKind { Stamps, Checkpoint, Restore }
+ private struct NetEvent
+ {
+ public int seq;
+ public NetKind kind;
+ public PaintStampNet[] stamps; // Stamps only
+ public int id; // Checkpoint / Restore only
+ }
+
+ private bool _awaitingSnapshot; // buffer live events instead of applying them
private byte[] _snapBuffer; // reassembled compressed snapshot
- private int _snapCapturedSeq; // batches <= this are already baked into the snapshot
+ private int _snapCapturedSeq; // events <= this are already baked into the snapshot
+ private int _snapBaselineId; // checkpoint id the snapshot maps represent (history baseline)
private bool _snapReadyToApply; // last chunk arrived; apply once the painter is ready
- private readonly List<(int seq, PaintStampNet[] stamps)> _bufferedBatches = new List<(int, PaintStampNet[])>();
+ private readonly List _buffered = new List();
private void Awake() => _painter = GetComponentInChildren(true);
@@ -69,6 +108,8 @@ public class PlayerPaintSync : NetworkBehaviour
if (IsOwner)
{
_painter.OnStampCommitted += OnStampCommitted;
+ _painter.OnHistoryCheckpoint += OnLocalCheckpoint;
+ _painter.OnHistoryRestore += OnLocalRestore;
}
else
{
@@ -84,6 +125,7 @@ public class PlayerPaintSync : NetworkBehaviour
if (!IsServer)
{
_awaitingSnapshot = true;
+ LogSync("late joiner: requesting baseline snapshot");
RequestSnapshotRpc();
}
}
@@ -92,10 +134,34 @@ public class PlayerPaintSync : NetworkBehaviour
public override void OnNetworkDespawn()
{
if (_painter && IsOwner)
+ {
_painter.OnStampCommitted -= OnStampCommitted;
+ _painter.OnHistoryCheckpoint -= OnLocalCheckpoint;
+ _painter.OnHistoryRestore -= OnLocalRestore;
+ }
_emissionIntensity.OnValueChanged -= OnEmissionChanged;
}
+ // Owner history events -> appended to the ordered outgoing FIFO, flushed in LateUpdate.
+ private void OnLocalCheckpoint(int id) => _outgoing.Add(new OutItem { kind = OutKind.Checkpoint, id = id });
+ private void OnLocalRestore(int id) => _outgoing.Add(new OutItem { kind = OutKind.Restore, id = id });
+
+ // Per-peer log tag: which client this is and its role, so multi-window logs are easy to read.
+ private string SyncTag
+ {
+ get
+ {
+ string role = IsOwner ? "owner" : (IsServer ? "host" : "client");
+ ulong id = NetworkManager ? NetworkManager.LocalClientId : 0;
+ return $"[PaintSync c{id} {role}]";
+ }
+ }
+
+ private void LogSync(string msg)
+ {
+ if (logSync) Debug.Log($"{SyncTag} {msg}", this);
+ }
+
private void OnEmissionChanged(float previous, float current)
{
if (_painter) _painter.emissionIntensity = current;
@@ -103,17 +169,19 @@ public class PlayerPaintSync : NetworkBehaviour
// ---- Live stamp stream (owner -> server -> remotes) ----
- private void OnStampCommitted(PaintStampNet stamp) => _pending.Add(stamp);
+ private void OnStampCommitted(PaintStampNet stamp) =>
+ _outgoing.Add(new OutItem { kind = OutKind.Stamp, stamp = stamp });
private void LateUpdate()
{
- // Apply a fully-received snapshot once the painter has finished setup, then flush the stamps
+ // Apply a fully-received snapshot once the painter has finished setup, then flush the events
// that arrived during the transfer. Deferred to here so apply+replay happen atomically in order.
if (_snapReadyToApply && _painter && _painter.IsPaintReady)
{
_snapReadyToApply = false;
- _painter.ApplyPaintSnapshot(_snapBuffer);
- FlushBufferedBatches();
+ _painter.ApplyPaintSnapshot(_snapBuffer, _snapBaselineId);
+ LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events");
+ FlushBuffered();
_snapBuffer = null;
_awaitingSnapshot = false;
}
@@ -126,31 +194,88 @@ public class PlayerPaintSync : NetworkBehaviour
if (_painter && _emissionIntensity.Value != _painter.emissionIntensity)
_emissionIntensity.Value = _painter.emissionIntensity;
- if (_pending.Count == 0)
+ FlushOutgoing(unlimited: false);
+ }
+
+ ///
+ /// Sends the outgoing FIFO in order: runs of consecutive stamps are batched (capped per frame unless
+ /// ), checkpoints/restores go out as tiny markers in their exact position.
+ /// Each item gets its own monotonic sequence. With the per-frame cap reached, the unsent tail (stamps
+ /// and any markers behind them) stays queued for the next frame - never reordered, never dropped.
+ ///
+ private void FlushOutgoing(bool unlimited)
+ {
+ if (_outgoing.Count == 0)
return;
- int seq = ++_seq;
- _seqHorizon = seq;
- SubmitStampsServerRpc(seq, BuildBatch());
- _pending.Clear();
+ int cap = Mathf.Max(1, maxStampsPerFrame);
+ int sentStamps = 0;
+ int consumed = 0;
+
+ while (consumed < _outgoing.Count)
+ {
+ OutKind kind = _outgoing[consumed].kind;
+
+ if (kind == OutKind.Stamp)
+ {
+ if (!unlimited && sentStamps >= cap)
+ break; // per-frame budget spent; carry the remainder (this stamp on) to next frame
+
+ int budget = unlimited ? int.MaxValue : (cap - sentStamps);
+ int runEnd = consumed;
+ while (runEnd < _outgoing.Count
+ && _outgoing[runEnd].kind == OutKind.Stamp
+ && (runEnd - consumed) < budget)
+ runEnd++;
+
+ int take = runEnd - consumed;
+ var batch = new PaintStampNet[take];
+ for (int k = 0; k < take; k++)
+ batch[k] = _outgoing[consumed + k].stamp;
+
+ int seq = ++_seq;
+ _seqHorizon = seq;
+ SubmitStampsServerRpc(seq, batch);
+ sentStamps += take;
+ consumed = runEnd;
+ }
+ else
+ {
+ int id = _outgoing[consumed].id;
+ int seq = ++_seq;
+ _seqHorizon = seq;
+ if (kind == OutKind.Restore)
+ {
+ SubmitRestoreServerRpc(seq, id);
+ LogSync($"sent RESTORE to checkpoint {id} (seq {seq})");
+ }
+ else
+ {
+ SubmitCheckpointServerRpc(seq, id);
+ LogSync($"sent checkpoint {id} (seq {seq})");
+ }
+ consumed++;
+ }
+ }
+
+ if (consumed > 0)
+ _outgoing.RemoveRange(0, consumed);
+
+ if (!unlimited && _outgoing.Count > 0)
+ LogSync($"stamp cap ({cap}) reached; {_outgoing.Count} command(s) carried over to next frame");
}
- /// 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;
- }
+ // ---- Stamp / checkpoint / restore commands (owner -> server -> remotes) ----
[ServerRpc]
private void SubmitStampsServerRpc(int seq, PaintStampNet[] stamps) => ApplyStampsClientRpc(seq, stamps);
+ [ServerRpc]
+ private void SubmitCheckpointServerRpc(int seq, int id) => ApplyCheckpointClientRpc(seq, id);
+
+ [ServerRpc]
+ private void SubmitRestoreServerRpc(int seq, int id) => ApplyRestoreClientRpc(seq, id);
+
[ClientRpc]
private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
{
@@ -162,8 +287,7 @@ public class PlayerPaintSync : NetworkBehaviour
if (_awaitingSnapshot)
{
- // Defer until the baseline is applied; the cutoff sorts out which ones to keep.
- _bufferedBatches.Add((seq, stamps));
+ _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps });
return;
}
@@ -171,19 +295,115 @@ public class PlayerPaintSync : NetworkBehaviour
_painter.ApplyNetworkStamp(stamps[i]);
}
- private void FlushBufferedBatches()
+ [ClientRpc]
+ private void ApplyCheckpointClientRpc(int seq, int id)
+ {
+ if (IsOwner || !_painter)
+ return;
+
+ _seqHorizon = seq;
+
+ if (_awaitingSnapshot)
+ {
+ _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id });
+ return;
+ }
+
+ _painter.RemoteCommitCheckpoint(id);
+ LogSync($"committed checkpoint {id} (seq {seq})");
+ }
+
+ [ClientRpc]
+ private void ApplyRestoreClientRpc(int seq, int id)
+ {
+ if (IsOwner || !_painter)
+ return;
+
+ _seqHorizon = seq;
+
+ if (_awaitingSnapshot)
+ {
+ _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Restore, id = id });
+ return;
+ }
+
+ // Reproduce the undo/redo from this peer's own history; if it doesn't hold that checkpoint (an
+ // undo past this peer's join point), fetch a fresh snapshot of the resulting state instead.
+ if (_painter.RemoteRestoreTo(id))
+ {
+ LogSync($"reproduced RESTORE to checkpoint {id} (seq {seq})");
+ }
+ else
+ {
+ LogSync($"cannot reproduce checkpoint {id} (not in local history, seq {seq}) -> owner snapshot fallback");
+ RequestSnapshotFallback();
+ }
+ }
+
+ private void FlushBuffered()
{
if (_painter)
{
- for (int b = 0; b < _bufferedBatches.Count; b++)
+ for (int b = 0; b < _buffered.Count; b++)
{
- var (seq, stamps) = _bufferedBatches[b];
- if (seq <= _snapCapturedSeq) continue; // already inside the snapshot
- for (int i = 0; i < stamps.Length; i++)
- _painter.ApplyNetworkStamp(stamps[i]);
+ var ev = _buffered[b];
+ if (ev.seq <= _snapCapturedSeq) continue; // already inside the snapshot baseline
+
+ if (ev.kind == NetKind.Stamps)
+ {
+ for (int i = 0; i < ev.stamps.Length; i++)
+ _painter.ApplyNetworkStamp(ev.stamps[i]);
+ }
+ else if (ev.kind == NetKind.Checkpoint)
+ {
+ _painter.RemoteCommitCheckpoint(ev.id);
+ }
+ else // Restore
+ {
+ if (!_painter.RemoteRestoreTo(ev.id))
+ {
+ // Can't reproduce -> the fresh snapshot we request next supersedes the rest.
+ LogSync($"buffered RESTORE to checkpoint {ev.id} not reproducible -> owner snapshot fallback");
+ _buffered.Clear();
+ RequestSnapshotFallback();
+ return;
+ }
+ }
}
}
- _bufferedBatches.Clear();
+ _buffered.Clear();
+ }
+
+ ///
+ /// A peer that can't reproduce a restore from its own history asks the OWNER - the only guaranteed
+ /// correct source of the post-restore state - for a fresh snapshot, addressed back to just this peer.
+ /// Works uniformly for a remote client and for the host's own replica (which can't snapshot itself);
+ /// the previous host short-circuit silently dropped the heal and left the host diverged.
+ ///
+ private void RequestSnapshotFallback()
+ {
+ _awaitingSnapshot = true;
+ RequestOwnerSnapshotRpc();
+ }
+
+ [Rpc(SendTo.Owner, InvokePermission = RpcInvokePermission.Everyone)]
+ private void RequestOwnerSnapshotRpc(RpcParams p = default)
+ {
+ // Runs on the owner. Drain any carried-over commands first so the snapshot's cutoff covers them
+ // (they would otherwise replay on top of a snapshot that already contains them), then capture the
+ // authoritative current maps and ship them to just the requester.
+ if (!_painter)
+ return;
+
+ FlushOutgoing(unlimited: true);
+
+ if (!_painter.TryCaptureSnapshot(out byte[] data))
+ return;
+
+ ulong requester = p.Receive.SenderClientId;
+ int baselineId = _painter.CurrentCheckpointId;
+ LogSync($"serving fallback snapshot to client {requester} (cutoff {_seq}, baseline {baselineId}, {data.Length} bytes)");
+ StartCoroutine(UploadSnapshot(data, _seq, baselineId, broadcast: false, target: requester));
}
// ---- Late-join snapshot transfer (server -> the one joining client) ----
@@ -195,16 +415,19 @@ public class PlayerPaintSync : NetworkBehaviour
return;
// Everything the captured maps already contain: the owner's send counter for our own avatar,
- // otherwise the last batch this server replica has applied.
+ // otherwise the last message this server replica has applied. The maps represent the painter's
+ // current history entry, so its checkpoint id tags the baseline the joiner adopts.
int capturedSeq = IsOwner ? _seq : _seqHorizon;
- StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq));
+ int baselineId = _painter.CurrentCheckpointId;
+ LogSync($"serving late-join snapshot to client {p.Receive.SenderClientId} (cutoff {capturedSeq}, baseline {baselineId}, {data.Length} bytes)");
+ StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq, baselineId));
}
- private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq)
+ private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq, int baselineId)
{
var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = targets } };
- BeginSnapshotClientRpc(data.Length, capturedSeq, send);
+ BeginSnapshotClientRpc(data.Length, capturedSeq, baselineId, send);
int sentThisFrame = 0;
for (int off = 0; off < data.Length; off += snapshotChunkBytes)
@@ -225,12 +448,14 @@ public class PlayerPaintSync : NetworkBehaviour
}
[ClientRpc]
- private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, ClientRpcParams p = default)
+ private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, int baselineId, ClientRpcParams p = default)
{
_snapBuffer = new byte[totalBytes];
_snapCapturedSeq = capturedSeq;
+ _snapBaselineId = baselineId;
_snapReadyToApply = false;
_awaitingSnapshot = true;
+ LogSync($"receiving snapshot ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})");
}
[ClientRpc]
@@ -252,14 +477,18 @@ public class PlayerPaintSync : NetworkBehaviour
// ---- Load re-sync (owner -> server -> every other peer) ----
//
- // Loading a saved painting replaces the owner's whole map set at once (no stamps), so the live
- // stream cannot carry it. The owner captures a snapshot, uploads it to the server chunked (same
- // throttling as the download), and the server re-broadcasts it to every other peer through the
- // existing snapshot client RPCs - including the host's own replica via its client-id target. A
- // cutoff sequence drops any in-flight/buffered stamps already superseded by the loaded baseline.
+ // Loading a saved painting replaces the owner's whole map set at once (no stamps) and clears its undo
+ // history, so the live stream cannot carry it. The owner captures a snapshot, uploads it to the server
+ // chunked (same throttling as the download), and the server re-broadcasts it to every other peer
+ // through the existing snapshot client RPCs - including the host's own replica via its client-id
+ // target. The snapshot's baseline id makes every peer reset its history to the loaded baseline too. A
+ // cutoff sequence drops any in-flight/buffered events already superseded by the loaded baseline.
private byte[] _uploadBuffer; // server-side reassembly of the owner's uploaded snapshot
private int _uploadCutoff;
+ private int _uploadBaselineId;
+ private bool _uploadBroadcast; // true = fan out to every peer but the owner (load); false = single target
+ private ulong _uploadTarget; // the single requester when !_uploadBroadcast (fallback heal)
/// Owner-only: broadcast the just-loaded painting to all other peers. No-op offline / single-player.
public void BroadcastLoadedPaint()
@@ -267,14 +496,20 @@ public class PlayerPaintSync : NetworkBehaviour
if (!IsSpawned || !IsOwner || !_painter) return;
if (!_painter.TryCaptureSnapshot(out byte[] data)) return;
- int cutoff = ++_seq; // every stamp up to here is now superseded by the loaded baseline
+ // The loaded baseline replaces the whole painting and clears history, so any still-queued local
+ // stamps/checkpoints from before the load are stale - drop them rather than stream them on top.
+ _outgoing.Clear();
+
+ int cutoff = ++_seq; // every event up to here is now superseded by the loaded baseline
_seqHorizon = cutoff;
- StartCoroutine(UploadSnapshot(data, cutoff));
+ int baselineId = _painter.CurrentCheckpointId; // the load's fresh baseline id
+ LogSync($"broadcasting loaded painting to all peers (cutoff {cutoff}, baseline {baselineId}, {data.Length} bytes)");
+ StartCoroutine(UploadSnapshot(data, cutoff, baselineId, broadcast: true, target: 0));
}
- private IEnumerator UploadSnapshot(byte[] data, int cutoff)
+ private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target)
{
- BeginUploadServerRpc(data.Length, cutoff);
+ BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target);
int sentThisFrame = 0;
for (int off = 0; off < data.Length; off += snapshotChunkBytes)
@@ -295,10 +530,13 @@ public class PlayerPaintSync : NetworkBehaviour
}
[ServerRpc]
- private void BeginUploadServerRpc(int totalBytes, int cutoff)
+ private void BeginUploadServerRpc(int totalBytes, int cutoff, int baselineId, bool broadcast, ulong target)
{
_uploadBuffer = totalBytes > 0 ? new byte[totalBytes] : null;
_uploadCutoff = cutoff;
+ _uploadBaselineId = baselineId;
+ _uploadBroadcast = broadcast;
+ _uploadTarget = target;
}
[ServerRpc]
@@ -314,11 +552,12 @@ public class PlayerPaintSync : NetworkBehaviour
{
if (_uploadBuffer == null) return;
- // Fan out to every connected peer except the owner (who already loaded it locally). The host,
- // when it is not the owner, is included by its client id and applies it like any other remote.
- ulong[] targets = OtherClientIds(OwnerClientId);
+ // Load broadcast: fan out to every connected peer except the owner (who already loaded it
+ // locally). Fallback heal: send only to the single requester. The host, when it is not the owner,
+ // is included by its client id and applies it like any other remote.
+ ulong[] targets = _uploadBroadcast ? OtherClientIds(OwnerClientId) : new[] { _uploadTarget };
if (targets.Length > 0)
- StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff));
+ StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff, _uploadBaselineId));
_uploadBuffer = null;
}