From ed09d6a1b02c9202e741635404b4b47650e929a6 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 4 Jul 2026 12:08:46 +0200 Subject: [PATCH] Netcode: receiver-side paint reveal gate + fur pipette gating Hide remote avatars until this client has actually applied a snapshot reaching the owner-published baseline cutoff, preventing the default grey/white material from flashing before a player's painting has loaded. - PlayerPaintSync: owner-authoritative _publishedCutoff NetworkVariable, receiver tracks _appliedCutoff; explicit blank publish for never-painted avatars so they still reveal. - NetworkPlayerSetup: cache _paintSync, hide body renderers on spawn, gate UpdateVisibility on PaintRevealReady. - MeshPainter.Pick: fur toggle now follows pickedHeight (own paint-mesh only), matching the Occlusion/Height pipette behaviour. Co-Authored-By: Claude Opus 4.8 --- .../MeshPainter/MeshPainter.Pick.cs | 12 ++++---- Assets/Scripts/Network/NetworkPlayerSetup.cs | 12 +++++++- Assets/Scripts/Network/PlayerPaintSync.cs | 29 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs index 9ea6e28..2f3728f 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs @@ -8,8 +8,9 @@ public partial class MeshPainter [Header("Brush - pipette (eyedropper)")] [Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " + "(albedo, metallic, smoothness, emission) and adopts them into the brush (opacities/alphas " + - "left untouched) instead of painting. Height (occlusion) is adopted and its toggle enabled " + - "only when our own paint mesh is picked; any other surface disables the height toggle. On a " + + "left untouched) instead of painting. Height (occlusion) and fur length are adopted and their " + + "toggles enabled only when our own paint mesh is picked; any other surface disables those " + + "toggles and keeps their values. On a " + "foreign object the data channels are read texel-exact from the material; albedo follows " + "'Pipette Albedo From Screen'. Held button picks once.")] public bool pipette = false; @@ -84,9 +85,10 @@ public partial class MeshPainter paintMetallicSmoothness = true; paintOcclusion = pickedHeight; paintEmission = true; - // Fur toggle always turns on with a pick; the length is only adopted off our own paint mesh - // (pickedHeight), so a foreign-surface pick enables fur but leaves furLength at its current value. - fur = true; + // Fur mirrors height/occlusion: adopted and its toggle enabled only when our OWN paint mesh was + // picked (pickedHeight, where furLength was sampled); any other surface disables the fur toggle + // and keeps furLength. + fur = pickedHeight; pipette = false; diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs index 4550fa3..3047105 100644 --- a/Assets/Scripts/Network/NetworkPlayerSetup.cs +++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs @@ -108,6 +108,7 @@ public class NetworkPlayerSetup : NetworkBehaviour private float _renderGateUntil; // Time.time deadline that lifts _renderGate; frame-driven (see UpdateVisibility) so a mid-settle disable/despawn can't strand the body hidden forever private MeshPainter _painter; // owns the per-avatar material instance carrying _XRayColor + private PlayerPaintSync _paintSync; // receiver-side reveal gate: body hidden until this client applied the painting private ConcealmentMeter _concealment; // owner-only embedded-percent source (disabled on remotes) private PlayerCharacterController _controller; // sibling, cached (the freeze re-asserts per frame) private bool _xrayApplied; @@ -120,6 +121,7 @@ public class NetworkPlayerSetup : NetworkBehaviour _active.Add(this); // scoreboard registry (paired with OnNetworkDespawn) _bodyRenderers = GetComponentsInChildren(true); _painter = GetComponentInChildren(true); + _paintSync = GetComponentInChildren(true); _concealment = GetComponentInChildren(true); _isFound.OnValueChanged += OnFoundChanged; if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found @@ -150,6 +152,11 @@ public class NetworkPlayerSetup : NetworkBehaviour // simulation (input/movement) and the CharacterController's own depenetration. DisableLocalSimulation(); + // Receiver-side reveal gate: hide the body from the very first frame until this client has applied + // the owner's painting baseline (see PlayerPaintSync.PaintRevealReady). Without this a freshly + // spawned remote flashes the default grey/white material before its painting arrives. + SetBodyRenderers(false); + // The concealment meter (per-frame bake + voxelization + overlap) only feeds the LOCAL player; // a remote's concealment is computed on its own owner's machine, so here it is wasted CPU. if (_concealment) _concealment.enabled = false; @@ -274,7 +281,10 @@ public class NetworkPlayerSetup : NetworkBehaviour if (IsOwner && !found) return; // While the conversion render gate is up, the body stays hidden no matter what ComputeVisible says. - bool visible = !_renderGate && ComputeVisible(found); + // The paint gate is an additional hard hide: a remote body never shows before this client has + // applied its painting (owner is always ready; a null sync - offline/single-player - never gates). + bool paintReady = !_paintSync || _paintSync.PaintRevealReady; + bool visible = paintReady && !_renderGate && ComputeVisible(found); if (_visibilityApplied && visible == _lastVisible) return; SetBodyRenderers(visible); } diff --git a/Assets/Scripts/Network/PlayerPaintSync.cs b/Assets/Scripts/Network/PlayerPaintSync.cs index 1e648c3..274180b 100644 --- a/Assets/Scripts/Network/PlayerPaintSync.cs +++ b/Assets/Scripts/Network/PlayerPaintSync.cs @@ -83,6 +83,26 @@ public class PlayerPaintSync : NetworkBehaviour new(new Vector3(0.2f, 1.4f, 1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); private bool _heightApplyPending; // remote: apply _height.Value to the material once IsReady / on change + // Receiver-side reveal gate (owner-authoritative): the cutoff sequence of the owner's FIRST published + // baseline - carry-loaded, manually loaded, or (for a never-painted avatar) an explicit blank publish. + // -1 until the owner has established and published its painting. A remote body stays HIDDEN until this + // client has actually APPLIED a snapshot whose cutoff reaches this value (see PaintRevealReady), so a + // peer never flashes the default grey/white material, and a match-start carry never briefly shows the + // pre-load blank maps (the flag can't outrun the chunked snapshot - the applied cutoff must catch up). + private readonly NetworkVariable _publishedCutoff = + new(-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); + + // Highest snapshot cutoff this replica has actually APPLIED (not merely begun receiving). Compared + // against _publishedCutoff to decide the reveal; only ever raised. + private int _appliedCutoff = int.MinValue; + + /// + /// True once THIS client may render the avatar's body: the owner is always ready (it holds its own + /// painting locally); a remote replica is ready only after it has applied a snapshot reaching the + /// owner's published baseline. Read by NetworkPlayerSetup's per-client visibility pass. + /// + public bool PaintRevealReady => IsOwner || (_publishedCutoff.Value >= 0 && _appliedCutoff >= _publishedCutoff.Value); + // 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. @@ -225,6 +245,7 @@ public class PlayerPaintSync : NetworkBehaviour { _snapReadyToApply = false; _painter.ApplyPaintSnapshot(_snapBuffer, _snapBaselineId); + if (_snapCapturedSeq > _appliedCutoff) _appliedCutoff = _snapCapturedSeq; // receiver-side reveal gate if (logSync) LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events"); FlushBuffered(); _snapBuffer = null; @@ -262,6 +283,13 @@ public class PlayerPaintSync : NetworkBehaviour } } + // Establish + publish the reveal baseline once even when nothing was carried/loaded: a never-painted + // avatar must still hand every peer an explicit (blank) snapshot, otherwise its body stays hidden + // forever behind the receiver-side reveal gate. Carry/manual-load already publish (and set the + // cutoff), so this only fires for the no-carry case. + if (_publishedCutoff.Value < 0 && _pendingCarry == null && _painter && _painter.IsPaintReady) + BroadcastLoadedPaint(); + // Delta-only publish: the NetworkVariable dirties only on an actual change. if (_painter && _emissionIntensity.Value != _painter.emissionIntensity) _emissionIntensity.Value = _painter.emissionIntensity; @@ -605,6 +633,7 @@ public class PlayerPaintSync : NetworkBehaviour int cutoff = ++_seq; // every event up to here is now superseded by the loaded baseline _seqHorizon = cutoff; int baselineId = _painter.CurrentCheckpointId; // the load's fresh baseline id + if (_publishedCutoff.Value < 0) _publishedCutoff.Value = cutoff; // first publish = the receiver-side reveal baseline if (logSync) LogSync($"broadcasting loaded painting to all peers (cutoff {cutoff}, baseline {baselineId}, {data.Length} bytes)"); StartCoroutine(UploadSnapshot(data, cutoff, baselineId, broadcast: true, target: 0)); }