Files
chameleon/Assets/Scripts/MeshPainter.Net.cs
T
adminandClaude Opus 4.8 69e185d969 Sync paint strokes across the network via command replication
Replicate brush strokes as self-contained commands instead of texture
pixels: the owner emits each committed stamp (local placement plus brush
state, byte-quantized) and remotes replay it through the same draw path,
reproducing identical maps without streaming megabytes.

- MeshPainter.Net.cs: PaintStampNet serializable stamp, OnStampCommitted
  owner-capture event (no-op without listener), ApplyNetworkStamp remote
  replay, seedable spray for deterministic speckle.
- MeshPainter.cs: emit stamp after drawing, seed spray only when synced.
- PlayerPaintSync.cs: NetworkBehaviour batching owner stamps reliably
  (ServerRpc -> ClientRpc) with echo guard; remote disables local painting.
- Player/Hider/Hunter prefabs: PlayerPaintSync plus NGO GlobalObjectIdHash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:02:33 +02:00

158 lines
6.5 KiB
C#

using Unity.Netcode;
using UnityEngine;
/// <summary>
/// One committed brush stamp in a form that can be replicated across the network. Painting is
/// synced as commands (these stamps), not as pixels: the owner already resolves every stamp into
/// local object space (hit point, radius, tangent frame) inside <see cref="MeshPainter.PaintAtScreen"/>,
/// so a remote peer just replays the SAME stamp through its own backend onto the identical mesh/UVs
/// and gets a near-identical result - for a fraction of the bandwidth of streaming the 12 MB of maps.
///
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless and
/// robust to ordering/loss (sent reliably). Channel data (metallic/smoothness/occlusion + opacities)
/// quantizes to bytes with no real loss because the paint maps are 8-bit anyway.
/// </summary>
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;
// 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)
public Color32 EmissionColor; // emission color (alpha = opacity)
public byte Metallic, Smoothness, Occlusion; // 0..255 = 0..1
public byte MSOpacity, OccOpacity; // 0..255 = 0..1
public void NetworkSerialize<T>(BufferSerializer<T> 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 Metallic);
s.SerializeValue(ref Smoothness);
s.SerializeValue(ref Occlusion);
s.SerializeValue(ref MSOpacity);
s.SerializeValue(ref OccOpacity);
}
}
public partial class MeshPainter
{
/// <summary>
/// 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.
/// </summary>
public event System.Action<PaintStampNet> 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);
/// <summary>True if anyone is listening for replicated stamps (the owner is networked + painting).</summary>
private bool WantsStampCapture => OnStampCommitted != null;
/// <summary>Builds the replicated stamp from the current brush state + resolved placement and raises the event.</summary>
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;
handler(new PaintStampNet
{
P = p, R = r, U = u, V = v, N = n,
Channels = ch,
BrushIndex = activeBrushIndex,
SpraySeed = _lastSpraySeed,
BrushColor = brushColor,
EmissionColor = emissionColor,
Metallic = ToByte01(metallic),
Smoothness = ToByte01(smoothness),
Occlusion = ToByte01(occlusion),
MSOpacity = ToByte01(metallicSmoothnessOpacity),
OccOpacity = ToByte01(occlusionOpacity),
});
}
/// <summary>
/// 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.
/// </summary>
private void BuildSprayStampMaskSeeded(int seed)
{
var prev = Random.state;
Random.InitState(seed);
BuildSprayStampMask();
Random.state = prev;
}
/// <summary>
/// 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).
/// </summary>
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;
brushColor = s.BrushColor;
emissionColor = s.EmissionColor;
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(); }
if (_gpuActive)
{
BeginGpuStroke();
DrawBrushGpu(s.P, s.R, s.U, s.V, s.N);
EndGpuStroke();
}
else
{
ResetCpuDirty();
PaintStamp3D(s.P, s.R, s.U, s.V, s.N);
EndCpuStroke();
}
}
}