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