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); }