From 7c84f6ba9274c219d986b4be8f5cc95565500593 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 29 Jun 2026 20:12:39 +0200 Subject: [PATCH] Expose the camo knobs in the HUD, persist and replicate them Surface the PlayerCamoLit material knobs (HalfLambert, HalfLambert brightness, specular dampen) as material-backed properties on MeshPainter, guarded by an IsReady gate since the HUD binds before the material instance exists. Wire the three HUD sliders to them and seed the sliders from the live material once the painter is ready. Persist the values into the same HUD JSON as the brush settings (with a hasCamoValues flag so pre-camo save files never clobber the authored material) and carry them across a scene-switch respawn. Replicate them owner-authoritatively via a NetworkVariable in PlayerPaintSync, exactly like emission intensity, so remotes and late joiners show the same camo. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MeshPainter.cs | 38 ++++++++++++ Assets/Scripts/PaintHud.Persistence.cs | 80 +++++++++++++++++++++++++- Assets/Scripts/PaintHud.cs | 29 ++++++++++ Assets/Scripts/PlayerPaintSync.cs | 41 +++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index 8b674a5..376c662 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -56,6 +56,41 @@ public partial class MeshPainter : MonoBehaviour /// the pre-spawn window, a remote replica, or any binding gap - never paints from local input. public bool PaintingEnabled { get; set; } = false; + /// True once Start has built the material instance and mesh caches (the camo knobs below + /// become readable from the material). The HUD binds in the avatar's OnNetworkSpawn, which runs + /// BEFORE this Start, so it must wait for this before reading the camo knobs into its sliders. + public bool IsReady => _setupValid; + + /// + /// Live camouflage knobs on the player's Custom/PlayerCamoLit material instance, surfaced so + /// the paint HUD can tune them at runtime. Backed directly by the material instance, which is the + /// authored source of truth: the getter reads it, the setter writes it. Both are a no-op when the + /// material lacks the property (non-camo shader) or before Start builds the instance, so they never + /// clobber the authored value. Runtime tuning is persisted by the HUD (PaintHud.Persistence saves them + /// to the same JSON as the brush values and carries them across a scene-switch respawn) and replicated + /// to other peers owner-authoritatively by , so remotes show the same camo. + /// + public float CamoHalfLambert + { + get => _materialInstance && _materialInstance.HasProperty(HalfLambertId) ? _materialInstance.GetFloat(HalfLambertId) : 0f; + set { if (_materialInstance && _materialInstance.HasProperty(HalfLambertId)) _materialInstance.SetFloat(HalfLambertId, value); } + } + + /// Companion to : additive brightness shift (-1..1) of the + /// flattened diffuse term, so the body tone can be tuned to match the scene. + public float CamoHalfLambertBrightness + { + get => _materialInstance && _materialInstance.HasProperty(HalfLambertBrightnessId) ? _materialInstance.GetFloat(HalfLambertBrightnessId) : 0f; + set { if (_materialInstance && _materialInstance.HasProperty(HalfLambertBrightnessId)) _materialInstance.SetFloat(HalfLambertBrightnessId, value); } + } + + /// Companion to : scales down the direct specular highlight. + public float CamoSpecularDampen + { + get => _materialInstance && _materialInstance.HasProperty(SpecularDampenId) ? _materialInstance.GetFloat(SpecularDampenId) : 0f; + set { if (_materialInstance && _materialInstance.HasProperty(SpecularDampenId)) _materialInstance.SetFloat(SpecularDampenId, value); } + } + [Header("Backend")] [Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")] public bool useGpu = true; @@ -268,6 +303,9 @@ public partial class MeshPainter : MonoBehaviour private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor"); private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap"); private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength"); + private static readonly int HalfLambertId = Shader.PropertyToID("_HalfLambert"); + private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness"); + private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen"); private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor"); private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask"); private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter"); diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs index e66d565..c3b582b 100644 --- a/Assets/Scripts/PaintHud.Persistence.cs +++ b/Assets/Scripts/PaintHud.Persistence.cs @@ -51,6 +51,15 @@ public partial class PaintHud private readonly HudValues _carry = new HudValues(); private bool _hasCarry; + // Camo knobs (HalfLambert / brightness / specular dampen) live on the player's PlayerCamoLit material + // instance, which only exists after the painter's Start. They are saved/restored with the brush values + // above, but can't be WRITTEN until the material is ready - so a restored (disk) or carried (respawn) + // value waits here and is applied in the LateUpdate ready-gate. _camoEverReady gates the carry/save so + // the not-yet-ready 0 the getter returns never overwrites the material's authored value. + private Vector3 _pendingCamo; + private bool _hasPendingCamo; + private bool _camoEverReady; + private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); private string HudSavePath() @@ -75,6 +84,7 @@ public partial class PaintHud { public int version = HudFileVersion; public bool hasValues; // false = no painter was bound when saved -> restore positions only + public bool hasCamoValues; // false = pre-camo save file -> keep the material's authored camo (no clobber) public List panels = new List(); public HudValues values = new HudValues(); } @@ -99,6 +109,7 @@ public partial class PaintHud public int activeBrushIndex; public int sprayDots; public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax; + public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs } /// A draggable panel was dropped -> persist the layout immediately. @@ -124,12 +135,14 @@ public partial class PaintHud { CopyValues(painter, state.values); state.hasValues = true; + state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable } else if (_hasCarry) { // No painter bound at teardown (e.g. quit with no avatar) -> persist the last mirrored values. state.values = _carry; state.hasValues = true; + state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter } Directory.CreateDirectory(HudSaveDir); @@ -164,6 +177,17 @@ public partial class PaintHud SyncFromPainter(); } + // Camo is material-backed and can't be written until the painter is ready -> stash the saved values + // for the LateUpdate ready-gate. hasCamoValues guards pre-camo save files: without it we would apply + // a 0 and clobber the material's authored camo. + if (state.hasCamoValues && state.values != null) + { + _pendingCamo = new Vector3(state.values.camoHalfLambert, + state.values.camoHalfLambertBrightness, + state.values.camoSpecularDampen); + _hasPendingCamo = true; + } + // Point-anchored panels: anchoredPosition is resolution-independent under the canvas' // ScaleWithScreenSize scaler, so the raw saved value places them identically next session. var root = _panel.transform; @@ -179,6 +203,31 @@ public partial class PaintHud { if (painter) { + // Camo knobs are material-backed and only readable/writable after the painter's Start built the + // instance (binding ran earlier, in the avatar's OnNetworkSpawn). Apply the restored (disk) or + // carried (respawn) values to the material once it is ready, then seed the sliders from it. Done + // BEFORE the carry mirror + change detection below so the just-applied value is already the + // baseline and never re-triggers a redundant auto-save. With nothing pending (first session, no + // save file) the material keeps its authored camo and the sliders just reflect it. + if (!_camoSynced && painter.IsReady) + { + if (_hasPendingCamo) + { + painter.CamoHalfLambert = _pendingCamo.x; + painter.CamoHalfLambertBrightness = _pendingCamo.y; + painter.CamoSpecularDampen = _pendingCamo.z; + _hasPendingCamo = false; + } + SyncCamoSliders(); + + _savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert; + _savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; + _savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen; + + _camoEverReady = true; + _camoSynced = true; + } + // Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh // painter from this snapshot instead of its prefab defaults. Captured while the painter lives, // so it is immune to the destroy-pending timing of the despawn. @@ -239,7 +288,10 @@ public partial class PaintHud && p.sprayDotSizeMin == v.sprayDotSizeMin && p.sprayDotSizeMax == v.sprayDotSizeMax && p.sprayDotAlphaMin == v.sprayDotAlphaMin - && p.sprayDotAlphaMax == v.sprayDotAlphaMax; + && p.sprayDotAlphaMax == v.sprayDotAlphaMax + && p.CamoHalfLambert == v.camoHalfLambert + && p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness + && p.CamoSpecularDampen == v.camoSpecularDampen; } /// Snapshots the bound painter's brush values into the in-memory mirror. @@ -256,6 +308,16 @@ public partial class PaintHud if (!painter || !_hasCarry) return; ApplyValues(painter, _carry); SyncFromPainter(); + + // Camo is material-backed -> defer to the ready-gate using the carried (last live) values. Guarded + // by _camoEverReady so a carry taken before any material was ready can't apply a stale 0. + if (_camoEverReady) + { + _pendingCamo = new Vector3(_carry.camoHalfLambert, + _carry.camoHalfLambertBrightness, + _carry.camoSpecularDampen); + _hasPendingCamo = true; + } } private static void CopyValues(MeshPainter from, HudValues v) @@ -280,6 +342,16 @@ public partial class PaintHud v.sprayDotSizeMax = from.sprayDotSizeMax; v.sprayDotAlphaMin = from.sprayDotAlphaMin; v.sprayDotAlphaMax = from.sprayDotAlphaMax; + + // Camo is material-backed: the getters read 0 until the painter's material instance exists, so only + // mirror them once ready - otherwise preserve the desired values already in the target (the value a + // restore/carry put there, which the ready-gate still has to apply). + if (from.IsReady) + { + v.camoHalfLambert = from.CamoHalfLambert; + v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness; + v.camoSpecularDampen = from.CamoSpecularDampen; + } } private static void ApplyValues(MeshPainter to, HudValues v) @@ -304,6 +376,12 @@ public partial class PaintHud to.sprayDotSizeMax = v.sprayDotSizeMax; to.sprayDotAlphaMin = v.sprayDotAlphaMin; to.sprayDotAlphaMax = v.sprayDotAlphaMax; + + // Camo setters are a no-op until the material exists; the guaranteed deferred apply runs from the + // LateUpdate ready-gate via _pendingCamo. Writing here too keeps Copy/Apply/Equal symmetric. + to.CamoHalfLambert = v.camoHalfLambert; + to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness; + to.CamoSpecularDampen = v.camoSpecularDampen; } private HudState LoadHudState() diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index 6eb1fa6..8d2061f 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -63,6 +63,9 @@ public partial class PaintHud : MonoBehaviour private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax"; private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin"; private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax"; + private const string SliderHalfLambert = "Slider_HalfLambert"; + private const string SliderHalfLambertBrightness = "Slider_HalfLambertBrightness"; + private const string SliderSpecularDamp = "Slider_SpecularDamp"; private const string MaskSelector = "MaskSelector"; private const string MaskSwatchPrefix = "MaskSwatch_"; @@ -79,6 +82,7 @@ public partial class PaintHud : MonoBehaviour private GameObject _panel; private Font _font; + private bool _camoSynced; // camo sliders are material-backed -> seeded once the painter's material instance exists private void Awake() { @@ -154,6 +158,7 @@ public partial class PaintHud : MonoBehaviour painter.BrushSizeChanged -= SyncBrushSize; } painter = newPainter; + _camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready WirePanel(_panel.transform); @@ -277,6 +282,14 @@ public partial class PaintHud : MonoBehaviour WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .25f, v => { if (painter) painter.sprayDotAlphaMin = v; }); WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : 1f, v => { if (painter) painter.sprayDotAlphaMax = v; }); + // Camouflage knobs on the player's PlayerCamoLit material instance (material-backed, not painter + // fields). The values are not readable here yet - the HUD binds in the avatar's OnNetworkSpawn, + // which runs before the painter's Start builds the instance - so SyncCamoSliders re-seeds them + // once the painter reports IsReady (see LateUpdate). Setting a slider before then is a safe no-op. + WireSlider(panel, SliderHalfLambert, painter ? painter.CamoHalfLambert : 0f, v => { if (painter) painter.CamoHalfLambert = v; }); + WireSlider(panel, SliderHalfLambertBrightness, painter ? painter.CamoHalfLambertBrightness : 0f, v => { if (painter) painter.CamoHalfLambertBrightness = v; }); + WireSlider(panel, SliderSpecularDamp, painter ? painter.CamoSpecularDampen : 0f, v => { if (painter) painter.CamoSpecularDampen = v; }); + foreach (var name in DraggablePanels) MakeDraggable(panel, name); } @@ -330,6 +343,22 @@ public partial class PaintHud : MonoBehaviour SetSlider(_panel.transform, SliderBrushSize, painter.brushSize); } + /// + /// Seeds the camouflage sliders from the player's live PlayerCamoLit material (painter -> HUD). The + /// values live on the material instance the painter builds in its Start, but the HUD binds earlier + /// (the avatar's OnNetworkSpawn fires before Start), so they are not readable at WirePanel time. This + /// runs once the painter reports (see LateUpdate) so the sliders + /// show the material's authored values instead of the placeholder 0 from the bind-time read. + /// + private void SyncCamoSliders() + { + if (!_panel || !painter) return; + var panel = _panel.transform; + SetSlider(panel, SliderHalfLambert, painter.CamoHalfLambert); + SetSlider(panel, SliderHalfLambertBrightness, painter.CamoHalfLambertBrightness); + SetSlider(panel, SliderSpecularDamp, painter.CamoSpecularDampen); + } + private static void SetToggle(Transform panel, string name, bool value) { var toggle = Find(panel, name); diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 17c6f97..62ed1b6 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -84,6 +84,16 @@ public class PlayerPaintSync : NetworkBehaviour private readonly NetworkVariable _emissionIntensity = new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); + // Per-player camouflage knobs on the PlayerCamoLit material (x = HalfLambert, y = HalfLambert + // brightness, z = specular dampen). Exactly like emission intensity these are per-player material + // values, not painted into the maps, so they ride their own owner-authoritative NetworkVariable. The + // remote applies them to its material once its painter is ready and again on every change; a late + // joiner gets the current value on spawn for free. The default mirrors Player.mat's authored camo so + // the brief pre-publish spawn frame shows no flash (the owner re-publishes the real value once ready). + private readonly NetworkVariable _camo = + new(new Vector3(0.5f, 0f, 0.1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); + private bool _camoApplyPending; // remote: apply _camo.Value to the material once IsReady / on change + // 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. @@ -149,6 +159,11 @@ public class PlayerPaintSync : NetworkBehaviour _emissionIntensity.OnValueChanged += OnEmissionChanged; _painter.emissionIntensity = _emissionIntensity.Value; + // Track the owner's camo knobs; apply the current (late-join) value once the material exists + // (deferred to LateUpdate - the setter is a no-op before the painter's Start builds the instance). + _camo.OnValueChanged += OnCamoChanged; + _camoApplyPending = true; + // The server (host) has been present since the start, so its replica already holds the full // paint via the live stream; only a genuine late joiner needs the baseline pulled over. if (!IsServer) @@ -175,6 +190,7 @@ public class PlayerPaintSync : NetworkBehaviour _painter.OnHistoryRestore -= OnLocalRestore; } _emissionIntensity.OnValueChanged -= OnEmissionChanged; + _camo.OnValueChanged -= OnCamoChanged; } // Owner history events -> appended to the ordered outgoing FIFO, flushed in LateUpdate. @@ -202,6 +218,10 @@ public class PlayerPaintSync : NetworkBehaviour if (_painter) _painter.emissionIntensity = current; } + // Apply on the next LateUpdate once the material is ready (the camo setters are a no-op before that, + // and on a fresh spawn the OnValueChanged may arrive before the painter's Start has built the instance). + private void OnCamoChanged(Vector3 previous, Vector3 current) => _camoApplyPending = true; + // ---- Live stamp stream (owner -> server -> remotes) ---- private void OnStampCommitted(PaintStampNet stamp) => @@ -221,6 +241,17 @@ public class PlayerPaintSync : NetworkBehaviour _awaitingSnapshot = false; } + // Remote replica: apply the owner's camo onto this avatar's material once it exists / whenever it + // changed. The owner drives its own material via the HUD + persistence, so it never applies here. + if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady) + { + Vector3 c = _camo.Value; + _painter.CamoHalfLambert = c.x; + _painter.CamoHalfLambertBrightness = c.y; + _painter.CamoSpecularDampen = c.z; + _camoApplyPending = false; + } + if (!IsOwner) return; @@ -243,6 +274,16 @@ public class PlayerPaintSync : NetworkBehaviour if (_painter && _emissionIntensity.Value != _painter.emissionIntensity) _emissionIntensity.Value = _painter.emissionIntensity; + // Publish the owner's camo knobs when the HUD/persistence changes them (delta-only; the + // NetworkVariable dirties only on an actual change). Gated on IsReady so the pre-Start 0-read is + // never published - that would briefly flatten remotes' lighting until the real value lands. + if (_painter && _painter.IsReady) + { + var camo = new Vector3(_painter.CamoHalfLambert, _painter.CamoHalfLambertBrightness, _painter.CamoSpecularDampen); + if (_camo.Value != camo) + _camo.Value = camo; + } + FlushOutgoing(unlimited: false); }