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:
adminandClaude Opus 4.8 committed 2026-06-27 15:44:02 +02:00
1 parent e04868f9c0
commit ae84bb99dd
3 files changed
+287 -1275

No files matched your search

+131
View File
@@ -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();
}
}
}