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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-04 12:08:46 +02:00
1 parent 49704448e9
commit ed09d6a1b0
3 files changed
+47 -6

No files matched your search

+29
View File
@@ -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<int> _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;
/// <summary>
/// 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.
/// </summary>
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));
}