Add late-joiner paint snapshot sync over chunked transfer
Send the accumulated paint maps as a one-time compressed baseline to clients that join after painting has happened, instead of streaming raw pixels. - MeshPainter.Net.cs: TryCaptureSnapshot reads the canonical maps via ReadCanonical and Deflate-compresses them; ApplyPaintSnapshot inflates and re-seeds the backend through the same island/dilation path as SwitchBackend; IsPaintReady gate. - PlayerPaintSync.cs: tag late-join transfer and live stamps with sequence numbers. Joiner requests the snapshot on spawn; server chunks and throttles it (1200 B/chunk, 8 chunks/frame) to stay under the 6144-byte payload cap and the reliable window. Joiner buffers live stamps during transfer and replays only those with seq > capturedSeq, so no stroke is lost or applied twice across the join window. Disable persistence on network-driven remotes to avoid shared-save-key clobber. Also resets the dynamic TMP font atlas glyph table (editor artifact, unrelated to networking). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
e04868f9c0
commit
ae84bb99dd
3 files changed
+287
-1275
No files matched your search
@@ -1,3 +1,5 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -154,4 +156,133 @@ public partial class MeshPainter
|
||||
EndCpuStroke();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Late-join snapshot: the whole painted state as a compressed blob ---
|
||||
//
|
||||
// Live stamps only reach peers connected at paint time; a late joiner needs the accumulated
|
||||
// result as a baseline. Instead of streaming 12 MB of raw maps, we dump the canonical maps once
|
||||
// and Deflate them (paint is largely flat -> compresses well), then PlayerPaintSync chunks the
|
||||
// blob over the network to the single joining client and re-seeds their painter with it.
|
||||
|
||||
private const uint SnapshotMagic = 0x50414e53; // 'SNAP' little-endian
|
||||
private const int SnapshotVersion = 1;
|
||||
|
||||
/// <summary>True once Start() has built the backend, so capture/apply are safe.</summary>
|
||||
public bool IsPaintReady => _setupValid;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes every paint map (canonical layout) and Deflate-compresses the result. Returns false
|
||||
/// if the painter is not set up yet. Run on the authority that holds the up-to-date maps.
|
||||
/// </summary>
|
||||
public bool TryCaptureSnapshot(out byte[] compressed)
|
||||
{
|
||||
compressed = null;
|
||||
if (!_setupValid) return false;
|
||||
|
||||
int n = textureSize * textureSize;
|
||||
using (var raw = new MemoryStream(16 + n * 4 * MAP_COUNT))
|
||||
{
|
||||
using (var w = new BinaryWriter(raw, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
w.Write(SnapshotMagic);
|
||||
w.Write(SnapshotVersion);
|
||||
w.Write(textureSize);
|
||||
w.Write(MAP_COUNT);
|
||||
|
||||
var bytes = new byte[n * 4];
|
||||
for (int mi = 0; mi < MAP_COUNT; mi++)
|
||||
{
|
||||
Color32[] px = ReadCanonical(mi);
|
||||
int count = Mathf.Min(n, px.Length);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int o = i * 4;
|
||||
bytes[o] = px[i].r;
|
||||
bytes[o + 1] = px[i].g;
|
||||
bytes[o + 2] = px[i].b;
|
||||
bytes[o + 3] = px[i].a;
|
||||
}
|
||||
w.Write(bytes, 0, count * 4);
|
||||
}
|
||||
}
|
||||
compressed = Deflate(raw.ToArray());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Decompresses a snapshot and re-seeds the active backend with it. Caller must ensure IsPaintReady.</summary>
|
||||
public void ApplyPaintSnapshot(byte[] compressed)
|
||||
{
|
||||
if (!_setupValid || compressed == null || compressed.Length == 0) return;
|
||||
|
||||
Color32[][] canonical;
|
||||
try { canonical = DecodeSnapshot(compressed); }
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Paint snapshot decode failed: {e.Message}", this);
|
||||
return;
|
||||
}
|
||||
if (canonical == null) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private Color32[][] DecodeSnapshot(byte[] compressed)
|
||||
{
|
||||
byte[] raw = Inflate(compressed);
|
||||
using (var ms = new MemoryStream(raw))
|
||||
using (var r = new BinaryReader(ms))
|
||||
{
|
||||
if (r.ReadUInt32() != SnapshotMagic) return null;
|
||||
int ver = r.ReadInt32();
|
||||
int size = r.ReadInt32();
|
||||
int maps = r.ReadInt32();
|
||||
|
||||
// Resolution / version mismatch: ignore rather than smear; the painter keeps its state.
|
||||
if (ver != SnapshotVersion || size != textureSize) return null;
|
||||
|
||||
int n = textureSize * textureSize;
|
||||
var result = new Color32[MAP_COUNT][];
|
||||
int load = Mathf.Min(maps, MAP_COUNT);
|
||||
for (int mi = 0; mi < load; mi++)
|
||||
{
|
||||
byte[] b = r.ReadBytes(n * 4);
|
||||
if (b.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
|
||||
var px = new Color32[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int o = i * 4;
|
||||
px[i] = new Color32(b[o], b[o + 1], b[o + 2], b[o + 3]);
|
||||
}
|
||||
result[mi] = px;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Deflate(byte[] data)
|
||||
{
|
||||
using (var outp = new MemoryStream())
|
||||
{
|
||||
using (var ds = new DeflateStream(outp, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true))
|
||||
ds.Write(data, 0, data.Length);
|
||||
return outp.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Inflate(byte[] data)
|
||||
{
|
||||
using (var inp = new MemoryStream(data))
|
||||
using (var ds = new DeflateStream(inp, CompressionMode.Decompress))
|
||||
using (var outp = new MemoryStream())
|
||||
{
|
||||
ds.CopyTo(outp);
|
||||
return outp.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
using System.Collections;
|
||||
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
|
||||
/// Replicates 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerPaintSync : NetworkBehaviour
|
||||
@@ -21,9 +25,29 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
"painting stays full fidelity.")]
|
||||
[SerializeField] private int maxStampsPerFrame = 64;
|
||||
|
||||
[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;
|
||||
|
||||
[Tooltip("Snapshot chunks sent per frame. Throttled so the burst stays under the reliable window " +
|
||||
"(~32 in-flight) while live stamps keep flowing.")]
|
||||
[SerializeField] private int snapshotChunksPerFrame = 8;
|
||||
|
||||
private MeshPainter _painter;
|
||||
private readonly List<PaintStampNet> _pending = new List<PaintStampNet>();
|
||||
|
||||
// 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.
|
||||
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
|
||||
private byte[] _snapBuffer; // reassembled compressed snapshot
|
||||
private int _snapCapturedSeq; // batches <= this are already baked into the snapshot
|
||||
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 void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
@@ -37,8 +61,19 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remote replica: never paint from this machine's mouse; paint only from replicated stamps.
|
||||
// Remote replica: never paint from this machine's mouse; paint only from replicated data.
|
||||
_painter.PaintingEnabled = false;
|
||||
// Don't persist a replicated avatar: all clones share one save key, so remotes would
|
||||
// clobber the local player's saved paint on teardown (same fix as the pose system).
|
||||
_painter.persistPaint = false;
|
||||
|
||||
// The server (host) has been present since the start, so its replica already holds the full
|
||||
// paint via the live stream; only a genuine late joiner needs the baseline pulled over.
|
||||
if (!IsServer)
|
||||
{
|
||||
_awaitingSnapshot = true;
|
||||
RequestSnapshotRpc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,14 +83,29 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
_painter.OnStampCommitted -= OnStampCommitted;
|
||||
}
|
||||
|
||||
// ---- Live stamp stream (owner -> server -> remotes) ----
|
||||
|
||||
private void OnStampCommitted(PaintStampNet stamp) => _pending.Add(stamp);
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// Apply a fully-received snapshot once the painter has finished setup, then flush the stamps
|
||||
// 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();
|
||||
_snapBuffer = null;
|
||||
_awaitingSnapshot = false;
|
||||
}
|
||||
|
||||
if (!IsOwner || _pending.Count == 0)
|
||||
return;
|
||||
|
||||
SubmitStampsServerRpc(BuildBatch());
|
||||
int seq = ++_seq;
|
||||
_seqHorizon = seq;
|
||||
SubmitStampsServerRpc(seq, BuildBatch());
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
@@ -73,16 +123,104 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
}
|
||||
|
||||
[ServerRpc]
|
||||
private void SubmitStampsServerRpc(PaintStampNet[] stamps) => ApplyStampsClientRpc(stamps);
|
||||
private void SubmitStampsServerRpc(int seq, PaintStampNet[] stamps) => ApplyStampsClientRpc(seq, stamps);
|
||||
|
||||
[ClientRpc]
|
||||
private void ApplyStampsClientRpc(PaintStampNet[] stamps)
|
||||
private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
|
||||
{
|
||||
// The owner already painted these locally; only remotes replay them.
|
||||
// The owner already painted these locally; only remotes (incl. the host's replica) replay them.
|
||||
if (IsOwner || !_painter)
|
||||
return;
|
||||
|
||||
_seqHorizon = seq; // server replica tracks this as the snapshot cutoff source
|
||||
|
||||
if (_awaitingSnapshot)
|
||||
{
|
||||
// Defer until the baseline is applied; the cutoff sorts out which ones to keep.
|
||||
_bufferedBatches.Add((seq, stamps));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < stamps.Length; i++)
|
||||
_painter.ApplyNetworkStamp(stamps[i]);
|
||||
}
|
||||
|
||||
private void FlushBufferedBatches()
|
||||
{
|
||||
if (_painter)
|
||||
{
|
||||
for (int b = 0; b < _bufferedBatches.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]);
|
||||
}
|
||||
}
|
||||
_bufferedBatches.Clear();
|
||||
}
|
||||
|
||||
// ---- Late-join snapshot transfer (server -> the one joining client) ----
|
||||
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||
private void RequestSnapshotRpc(RpcParams p = default)
|
||||
{
|
||||
if (!_painter || !_painter.TryCaptureSnapshot(out byte[] data))
|
||||
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.
|
||||
int capturedSeq = IsOwner ? _seq : _seqHorizon;
|
||||
StartCoroutine(SendSnapshot(p.Receive.SenderClientId, data, capturedSeq));
|
||||
}
|
||||
|
||||
private IEnumerator SendSnapshot(ulong targetClientId, byte[] data, int capturedSeq)
|
||||
{
|
||||
var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = new[] { targetClientId } } };
|
||||
|
||||
BeginSnapshotClientRpc(data.Length, capturedSeq, send);
|
||||
|
||||
int sentThisFrame = 0;
|
||||
for (int off = 0; off < data.Length; off += snapshotChunkBytes)
|
||||
{
|
||||
int len = Mathf.Min(snapshotChunkBytes, data.Length - off);
|
||||
var chunk = new byte[len];
|
||||
System.Array.Copy(data, off, chunk, 0, len);
|
||||
SnapshotChunkClientRpc(off, chunk, send);
|
||||
|
||||
if (++sentThisFrame >= snapshotChunksPerFrame)
|
||||
{
|
||||
sentThisFrame = 0;
|
||||
yield return null; // spread the burst so the reliable window does not overflow
|
||||
}
|
||||
}
|
||||
|
||||
EndSnapshotClientRpc(send);
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, ClientRpcParams p = default)
|
||||
{
|
||||
_snapBuffer = new byte[totalBytes];
|
||||
_snapCapturedSeq = capturedSeq;
|
||||
_snapReadyToApply = false;
|
||||
_awaitingSnapshot = true;
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
private void SnapshotChunkClientRpc(int offset, byte[] chunk, ClientRpcParams p = default)
|
||||
{
|
||||
if (_snapBuffer == null) return;
|
||||
if (offset >= 0 && offset + chunk.Length <= _snapBuffer.Length)
|
||||
System.Array.Copy(chunk, 0, _snapBuffer, offset, chunk.Length);
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
private void EndSnapshotClientRpc(ClientRpcParams p = default)
|
||||
{
|
||||
// Apply happens in LateUpdate (once the painter is ready) so apply + buffered replay stay ordered.
|
||||
_snapReadyToApply = _snapBuffer != null;
|
||||
if (!_snapReadyToApply)
|
||||
_awaitingSnapshot = false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user