using System.IO; using System.IO.Compression; using Unity.Netcode; using UnityEngine; /// /// One committed brush stamp in replicable form. Painting is synced as commands, not pixels: the owner /// already resolves every stamp into local object space (hit point, radius, tangent frame), so a remote /// peer replays the SAME stamp through its own backend onto the identical mesh/UVs. /// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless. /// Channel data quantizes to bytes with no real loss (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; public const byte FlagFur = 1 << 5; // fur has its own toggle -> replay must carry it explicitly // 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; also the fur length deposit weight) public Color32 EmissionColor; // emission color (alpha = opacity) public byte FurLength; // target fur length the stroke eases toward (0..255 = 0..1) 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 FurLength); 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; if (fur) ch |= PaintStampNet.FlagFur; handler(new PaintStampNet { P = p, R = r, U = u, V = v, N = n, Channels = ch, BrushIndex = activeBrushIndex, SpraySeed = _lastSpraySeed, BrushColor = brushColor, EmissionColor = emissionColor, FurLength = ToByte01(furLength), 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; fur = (s.Channels & PaintStampNet.FlagFur) != 0; brushColor = s.BrushColor; emissionColor = s.EmissionColor; furLength = s.FurLength / 255f; 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(); } BeginBackendStroke(); DrawBackendStamp(s.P, s.R, s.U, s.V, s.N); EndBackendStroke(); // 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 --- // // 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; /// True once Start() has built the backend, so capture/apply are safe. public bool IsPaintReady => _setupValid; /// /// 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. /// 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); WriteCanonicalMaps(w); } compressed = Deflate(raw.ToArray()); } return true; } /// /// 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 (!TryDecodeSnapshot(compressed, "Paint", out Color32[][] canonical)) return; ReseedCanonical(canonical); SetHistoryBaseline(canonical, baselineId); } /// /// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a /// fresh local baseline (like , just from bytes) - used to carry a /// painting across a scene-switch respawn, then rebroadcast via BroadcastLoadedPaint. Returns false /// if the painter is not ready or the blob is incompatible (the current painting stays untouched). /// public bool LoadPaintFromSnapshot(byte[] compressed) { if (!TryDecodeSnapshot(compressed, "Carry", out Color32[][] canonical)) return false; ReseedCanonical(canonical); ResetHistoryToCurrent(canonical); return true; } /// Guard + decode for an incoming snapshot blob; false = not ready / empty / undecodable. private bool TryDecodeSnapshot(byte[] compressed, string context, out Color32[][] canonical) { canonical = null; if (!_setupValid || compressed == null || compressed.Length == 0) return false; try { canonical = DecodeSnapshot(compressed); } catch (System.Exception e) { Debug.LogWarning($"[MeshPainter] {context} snapshot decode failed: {e.Message}", this); return false; } return canonical != null; } 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; return ReadCanonicalMaps(r, maps); } } 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(); } } }