Add painted-height HUD sliders for AO shift and relief intensity

Wire three material-backed look sliders (occlusion AO shift, relief normal
intensity, parallax intensity) through the paint HUD, its layout/state
persistence, the .paint slot format (bumped to v4), owner-authoritative
network sync and the runtime reset, mirroring the existing camo-knob path so
they save, restore, replicate and reset alongside the brush values.

Change the occlusion AO mapping from a division threshold to a linear shift:
AO fades from 1 down to (1 - shift) at the deepest painted hole, so a 0 shift
no longer leaves black spots. Enable _NORMALMAP alongside _PARALLAXMAP so URP
transforms the painted relief normal into world space (otherwise it discards
normalTS and the relief is invisible), and pin the added keyword in the build
keep-alive material.

Stop the pipette from picking the occlusion/height value and turn its toggle
off after a pick. Default the knobs to 0.2 shift / 1.4 normal / 1.0 parallax
across the shader, painter, network and HUD fallbacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-01 07:07:39 +02:00
1 parent fdcb4f8d8a
commit 59939b3496
11 files changed
+216 -49

No files matched your search

+2 -1
View File
@@ -27,6 +27,7 @@ Material:
m_ValidKeywords:
- _EMISSION
- _METALLICSPECGLOSSMAP
- _NORMALMAP
- _OCCLUSIONMAP
- _PARALLAXMAP
m_InvalidKeywords: []
@@ -47,7 +48,7 @@ Material:
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Texture: {fileID: 2800000, guid: c24e49ee8237bac42930ef868a325c48, type: 2}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
+27 -4
View File
@@ -16,7 +16,7 @@ public partial class MeshPainter
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
// round-trips the exact canonical bytes ReadCanonical produces.
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
private const int PaintFileVersion = 3; // v3: added the per-player material scalars (emission + camo)
private const int PaintFileVersion = 4; // v3: emission + camo scalars; v4: + painted-height knobs
private const string PaintFileExt = ".paint";
/// <summary>
@@ -26,9 +26,11 @@ public partial class MeshPainter
/// </summary>
private struct PaintScalars
{
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
public bool hasHeight; // v4+ painted-height knobs present (occlusion threshold + normal/parallax intensity)
public float emissionIntensity;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity;
}
private static string PaintLibraryDir =>
@@ -104,6 +106,11 @@ public partial class MeshPainter
w.Write(CamoHalfLambertBrightness);
w.Write(CamoSpecularDampen);
// v4: the painted-height knobs (same material-instance source of truth as the camo knobs).
w.Write(OcclusionThreshold);
w.Write(HeightNormalIntensity);
w.Write(ParallaxIntensity);
var bytes = new byte[n * 4];
for (int mi = 0; mi < MAP_COUNT; mi++)
{
@@ -160,6 +167,13 @@ public partial class MeshPainter
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
CamoSpecularDampen = scalars.camoSpecularDampen;
}
// v4 slots also carry the painted-height knobs; older slots (v2/v3) leave them at the current value.
if (scalars.hasHeight)
{
OcclusionThreshold = scalars.occlusionThreshold;
HeightNormalIntensity = scalars.heightNormalIntensity;
ParallaxIntensity = scalars.parallaxIntensity;
}
return true;
}
@@ -190,7 +204,7 @@ public partial class MeshPainter
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != 2 && ver != PaintFileVersion) return null; // list legacy v2 slots + current
if (ver < 2 || ver > PaintFileVersion) return null; // list legacy v2..v3 slots + current
return r.ReadString();
}
}
@@ -216,7 +230,7 @@ public partial class MeshPainter
{
if (r.ReadUInt32() != PaintFileMagic) return null;
int ver = r.ReadInt32();
if (ver != 2 && ver != PaintFileVersion) return null; // read legacy v2 + current
if (ver < 2 || ver > PaintFileVersion) return null; // read legacy v2..v3 + current
r.ReadString(); // display name (header only; not needed for the pixel load)
int w = r.ReadInt32();
int h = r.ReadInt32();
@@ -232,6 +246,15 @@ public partial class MeshPainter
scalars.camoSpecularDampen = r.ReadSingle();
}
// v4+: the painted-height knobs follow the camo scalars.
if (ver >= 4)
{
scalars.hasHeight = true;
scalars.occlusionThreshold = r.ReadSingle();
scalars.heightNormalIntensity = r.ReadSingle();
scalars.parallaxIntensity = r.ReadSingle();
}
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
// from the material instead.
if (w != textureSize || h != textureSize)
+26 -31
View File
@@ -6,11 +6,12 @@ public partial class MeshPainter
// --- Pipette (eyedropper) ---
//
// A sampling MODE rather than a paint channel: while active (Shift or Ctrl held, or the HUD toggle), a
// left-click raycasts onto ANY surface (not just the paint mesh) and reads every channel value at
// the hit point (albedo, metallic, smoothness, occlusion, emission) into the brush properties -
// with every opacity/alpha forced to 1 so re-painting reproduces the picked look at full strength.
// left-click raycasts onto ANY surface (not just the paint mesh) and reads the channel values at
// the hit point (albedo, metallic, smoothness, emission) into the brush properties - with every
// opacity/alpha forced to 1 so re-painting reproduces the picked look at full strength. Occlusion is
// excluded: it drives painted height now, so the pipette neither samples it nor enables its toggle.
//
// On a FOREIGN object the data channels (metallic/smoothness/occlusion/emission) come texel-exact
// On a FOREIGN object the data channels (metallic/smoothness/emission) come texel-exact
// from the material (raw, UNLIT). Albedo follows the 'pipetteAlbedoFromScreen' toggle: OFF (default)
// reads it texel-exact from the material too; ON takes it from the rendered, fully-lit on-screen
// pixel ("what you see" - honours baked lightmaps/ambient/SSAO), combining material PBR fidelity
@@ -19,16 +20,16 @@ public partial class MeshPainter
// terrain are read texel-exact.
[Header("Brush - pipette (eyedropper)")]
[Tooltip("Pipette mode: a click samples ALL channel values at the hit point on ANY surface " +
"(albedo, metallic, smoothness, occlusion, emission) and adopts them into the brush " +
"(all alphas set to 1) instead of painting. On a foreign object the data channels are read " +
"texel-exact from the material; albedo follows 'Pipette Albedo From Screen'. Held button picks once.")]
[Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " +
"(albedo, metallic, smoothness, emission - NOT occlusion/height) and adopts them into the " +
"brush (all alphas set to 1) instead of painting. On a foreign object the data channels are " +
"read texel-exact from the material; albedo follows 'Pipette Albedo From Screen'. Held button picks once.")]
public bool pipette = false;
[Tooltip("On a foreign-object pick, where albedo comes from. OFF (default): albedo is read " +
"texel-exact from the material like the other channels (raw, unlit). ON: albedo is taken " +
"from the lit on-screen pixel ('what you see' - honours baked lightmaps/ambient/SSAO). " +
"The data channels (metallic/smoothness/occlusion/emission) always come from the material.")]
"The data channels (metallic/smoothness/emission) always come from the material.")]
[SerializeField] private bool pipetteAlbedoFromScreen = false;
/// <summary>
@@ -44,9 +45,10 @@ public partial class MeshPainter
private const float TerrainFoliageHeightEpsilon = 0.3f;
/// <summary>
/// Raycasts at a screen position against ANY collider, samples every channel value at the hit
/// (texel-exact where possible) and copies it into the brush, forcing all opacities/alphas to
/// 1 and enabling every paint channel. Reads regardless of which channels are currently toggled.
/// Raycasts at a screen position against ANY collider, samples the channel values at the hit
/// (texel-exact where possible) and copies them into the brush, forcing the sampled opacities/alphas
/// to 1 and enabling those paint channels. Occlusion/height is excluded (see FinalizePick). Reads
/// regardless of which channels are currently toggled.
/// Pick paths, by what was hit:
/// - our paint mesh -> texel-exact from the painted maps (full PBR);
/// - terrain ground -> blend of the terrain layers by their splat weights (full PBR);
@@ -83,18 +85,19 @@ public partial class MeshPainter
}
/// <summary>
/// Common tail of every successful pick: forces all opacities/alphas to 1, enables every paint
/// channel (a whole-material pick implies repainting all of it), leaves pipette mode (one-shot)
/// and notifies listeners (the in-game HUD) to re-sync their widgets.
/// Common tail of every successful pick: forces the sampled opacities/alphas to 1, enables the
/// sampled paint channels, leaves pipette mode (one-shot) and notifies listeners (the in-game HUD) to
/// re-sync their widgets. Occlusion is deliberately EXCLUDED: that channel now drives painted height,
/// not a surface property to sample, so the pipette neither reads it (see the pick paths) nor enables
/// its toggle - the deliberate height brush value is left untouched.
/// </summary>
private void FinalizePick()
{
metallicSmoothnessOpacity = 1f;
occlusionOpacity = 1f;
paintAlbedo = true;
paintMetallicSmoothness = true;
paintOcclusion = true;
paintOcclusion = false;
paintEmission = true;
pipette = false;
@@ -136,16 +139,16 @@ public partial class MeshPainter
Color32 e = em[idx];
// sRGB bytes map straight back into the gamma-space brush colours (round-trips exactly).
// Occlusion (m.g) is NOT picked: it now drives painted height, kept as the deliberate brush value.
brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, 1f);
metallic = m.r / 255f;
occlusion = m.g / 255f;
smoothness = m.a / 255f;
emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, 1f);
return true;
}
/// <summary>
/// Foreign-object pick: the data channels (metallic/smoothness/occlusion/emission) always come
/// Foreign-object pick: the data channels (metallic/smoothness/emission) always come
/// texel-exact from the material via <see cref="PickFromForeignMaterial"/> (raw, unlit). Albedo
/// follows <see cref="pipetteAlbedoFromScreen"/>: when set, it is overwritten with the rendered,
/// fully-lit on-screen pixel ("what you see" - honours baked lightmaps/ambient/SSAO); otherwise
@@ -237,13 +240,7 @@ public partial class MeshPainter
metallic = Mathf.Clamp01(metallicVal);
smoothness = Mathf.Clamp01(smoothVal);
// --- Occlusion: OcclusionMap.g (linear), lerp(1, g, strength) ---
float occStrength = GetFloatProp(mat, "_OcclusionStrength", null, 1f);
float occ = 1f;
Texture occTex = GetTexProp(mat, "_OcclusionMap", null);
if (hasUv && occTex)
occ = Mathf.Lerp(1f, SampleTexel(occTex, ApplyST(mat, "_OcclusionMap", null, uv), false).g, occStrength);
occlusion = Mathf.Clamp01(occ);
// Occlusion is deliberately NOT sampled (that channel drives painted height now, see FinalizePick).
// --- Emission: EmissionMap (sRGB) * EmissionColor ---
Color emColor = GetColorProp(mat, "_EmissionColor", null, Color.black);
@@ -292,7 +289,7 @@ public partial class MeshPainter
/// <summary>
/// Blends the terrain's layers at a ground point by their splat (alphamap) weights: the weighted
/// diffuse colour becomes albedo and the weighted per-layer metallic/smoothness scalars feed
/// those channels; occlusion is 1 and emission black. Terrain has no Renderer/material, so this
/// those channels; emission is black (occlusion is left as the brush's height value). Terrain has no Renderer/material, so this
/// is the only way to read its surface.
/// </summary>
private bool PickFromTerrainLayers(TerrainData td, Vector3 local, float nx, float nz)
@@ -333,8 +330,7 @@ public partial class MeshPainter
brushColor = new Color(Mathf.Clamp01(albedo.r * inv), Mathf.Clamp01(albedo.g * inv), Mathf.Clamp01(albedo.b * inv), 1f);
metallic = Mathf.Clamp01(metallicVal * inv);
smoothness = Mathf.Clamp01(smoothVal * inv);
occlusion = 1f;
emissionColor = new Color(0f, 0f, 0f, 1f);
emissionColor = new Color(0f, 0f, 0f, 1f); // occlusion left untouched (now painted height)
return true;
}
@@ -371,8 +367,7 @@ public partial class MeshPainter
brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), 1f);
metallic = 0f;
smoothness = 0f;
occlusion = 1f;
emissionColor = new Color(0f, 0f, 0f, 1f);
emissionColor = new Color(0f, 0f, 0f, 1f); // occlusion left untouched (now painted height)
return true;
}
+6
View File
@@ -240,9 +240,15 @@ public partial class MeshPainter
// step parallax (ApplyPerPixelDisplacement) AND our relief-normal/AO remap in PlayerCamoLit.
// Lift _Parallax off its floor so the displacement is actually visible (the relief normal is
// the dominant cue; this just adds subtle motion parallax). Live-tunable on the instance.
// _NORMALMAP is REQUIRED for the relief normal to show: URP's InitializeInputData only
// transforms surfaceData.normalTS into world space when _NORMALMAP (or _DETAIL) is set -
// otherwise it discards normalTS and uses the geometric normal, so the relief is invisible.
// _BumpMap stays unbound (= flat default normal), so it only enables the tangent->world path
// and our relief blends on top of a flat base.
if (_materialInstance.HasProperty(ParallaxMapId))
{
_materialInstance.EnableKeyword("_PARALLAXMAP");
_materialInstance.EnableKeyword("_NORMALMAP");
if (_materialInstance.HasProperty(ParallaxId) && _materialInstance.GetFloat(ParallaxId) < 0.02f)
_materialInstance.SetFloat(ParallaxId, 0.02f);
}
+33
View File
@@ -91,6 +91,35 @@ public partial class MeshPainter : MonoBehaviour
set { if (_materialInstance && _materialInstance.HasProperty(SpecularDampenId)) _materialInstance.SetFloat(SpecularDampenId, value); }
}
/// <summary>
/// Painted-height look knobs on the same <c>Custom/PlayerCamoLit</c> material instance, surfaced for
/// the HUD like the camo knobs above (material-backed source of truth, no-op before the instance
/// exists). All three persist with the painting (PaintHud.Persistence + the .paint slot) and replicate
/// to peers via <see cref="PlayerPaintSync"/>. Defaults match the shader so the unpainted look is unchanged.
/// </summary>
/// <summary>Painted-height AO darkening depth (shift, 0..1): AO fades from 1 down to (1 - this) at
/// the deepest painted hole. 0 = no darkening (no black spots), 1 = full black at occ=0.</summary>
public float OcclusionThreshold
{
get => _materialInstance && _materialInstance.HasProperty(OcclusionThresholdId) ? _materialInstance.GetFloat(OcclusionThresholdId) : 0.2f;
set { if (_materialInstance && _materialInstance.HasProperty(OcclusionThresholdId)) _materialInstance.SetFloat(OcclusionThresholdId, value); }
}
/// <summary>Multiplier on the painted-height relief NORMAL strength (1 = base, 0 = flat).</summary>
public float HeightNormalIntensity
{
get => _materialInstance && _materialInstance.HasProperty(HeightNormalIntensityId) ? _materialInstance.GetFloat(HeightNormalIntensityId) : 1.4f;
set { if (_materialInstance && _materialInstance.HasProperty(HeightNormalIntensityId)) _materialInstance.SetFloat(HeightNormalIntensityId, value); }
}
/// <summary>Multiplier on the painted-height UV PARALLAX amplitude (1 = base <see cref="ParallaxBase"/>,
/// 0 = no displacement). Backed by the material's _Parallax, so it round-trips like the others.</summary>
public float ParallaxIntensity
{
get => _materialInstance && _materialInstance.HasProperty(ParallaxId) ? _materialInstance.GetFloat(ParallaxId) / ParallaxBase : 1f;
set { if (_materialInstance && _materialInstance.HasProperty(ParallaxId)) _materialInstance.SetFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase); }
}
[Header("Backend")]
[Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")]
public bool useGpu = true;
@@ -325,6 +354,10 @@ public partial class MeshPainter : MonoBehaviour
private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength");
private static readonly int ParallaxMapId = Shader.PropertyToID("_ParallaxMap");
private static readonly int ParallaxId = Shader.PropertyToID("_Parallax");
private static readonly int HeightNormalIntensityId = Shader.PropertyToID("_HeightNormalIntensity");
private static readonly int OcclusionThresholdId = Shader.PropertyToID("_OcclusionThreshold");
// _Parallax amplitude that ParallaxIntensity = 1 maps to (the floor ConfigureMaterialPbr lifts to).
private const float ParallaxBase = 0.02f;
private static readonly int HalfLambertId = Shader.PropertyToID("_HalfLambert");
private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness");
private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen");
+3 -2
View File
@@ -251,8 +251,9 @@ public partial class PaintHud
{
if (!painter || !painter.LoadPaintByName(name)) return;
// The load also restored the slot's emission intensity + camo knobs onto the painter (v3 slots) ->
// push them into the sliders so the HUD reflects the loaded look, exactly like a pipette pick does.
// The load also restored the slot's material scalars onto the painter (emission + camo from v3,
// the painted-height knobs from v4) -> push them into the sliders so the HUD reflects the loaded
// look, exactly like a pipette pick does. SyncCamoSliders covers the camo + height sliders.
SyncFromPainter();
SyncCamoSliders();
+55 -5
View File
@@ -60,6 +60,12 @@ public partial class PaintHud
private bool _hasPendingCamo;
private bool _camoEverReady;
// Painted-height knobs (occlusion threshold, normal intensity, parallax intensity) ride the exact same
// material-ready deferral as the camo knobs above: they become writable only after the painter's Start,
// so a restored/carried value waits here and is applied in the LateUpdate ready-gate (shared timing).
private Vector3 _pendingHeight;
private bool _hasPendingHeight;
// Authored defaults for the runtime reset (= "as if the saved layout file had been deleted"): the
// prefab's panel positions, captured before any saved layout is restored over them, and the painter's
// prefab/material default brush values, captured before carry/restore mutate the live painter. Camo is
@@ -95,6 +101,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 bool hasHeightValues; // false = pre-height save file -> keep the material's authored height knobs (no clobber)
public List<PanelPos> panels = new List<PanelPos>();
public HudValues values = new HudValues();
}
@@ -120,6 +127,7 @@ public partial class PaintHud
public int sprayDots;
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity; // painted-height knobs (material-backed)
}
/// <summary>A draggable panel was dropped -> persist the layout immediately.</summary>
@@ -146,6 +154,7 @@ public partial class PaintHud
CopyValues(painter, state.values);
state.hasValues = true;
state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable
state.hasHeightValues = painter.IsReady; // height knobs share the camo readiness
}
else if (_hasCarry)
{
@@ -153,6 +162,7 @@ public partial class PaintHud
state.values = _carry;
state.hasValues = true;
state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter
state.hasHeightValues = _camoEverReady;
}
Directory.CreateDirectory(HudSaveDir);
@@ -198,6 +208,16 @@ public partial class PaintHud
_hasPendingCamo = true;
}
// Same deferral for the painted-height knobs; hasHeightValues guards pre-height save files so they
// keep the material's authored defaults instead of being slammed to a 0 threshold/flat relief.
if (state.hasHeightValues && state.values != null)
{
_pendingHeight = new Vector3(state.values.occlusionThreshold,
state.values.heightNormalIntensity,
state.values.parallaxIntensity);
_hasPendingHeight = 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;
@@ -229,6 +249,9 @@ public partial class PaintHud
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
_defaults.occlusionThreshold = painter.OcclusionThreshold;
_defaults.heightNormalIntensity = painter.HeightNormalIntensity;
_defaults.parallaxIntensity = painter.ParallaxIntensity;
_hasDefaultCamo = true;
}
@@ -239,11 +262,21 @@ public partial class PaintHud
painter.CamoSpecularDampen = _pendingCamo.z;
_hasPendingCamo = false;
}
if (_hasPendingHeight)
{
painter.OcclusionThreshold = _pendingHeight.x;
painter.HeightNormalIntensity = _pendingHeight.y;
painter.ParallaxIntensity = _pendingHeight.z;
_hasPendingHeight = false;
}
SyncCamoSliders();
_savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert;
_savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen;
_savedValues.occlusionThreshold = _lastSeenValues.occlusionThreshold = painter.OcclusionThreshold;
_savedValues.heightNormalIntensity = _lastSeenValues.heightNormalIntensity = painter.HeightNormalIntensity;
_savedValues.parallaxIntensity = _lastSeenValues.parallaxIntensity = painter.ParallaxIntensity;
_camoEverReady = true;
_camoSynced = true;
@@ -312,7 +345,10 @@ public partial class PaintHud
&& p.sprayDotAlphaMax == v.sprayDotAlphaMax
&& p.CamoHalfLambert == v.camoHalfLambert
&& p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness
&& p.CamoSpecularDampen == v.camoSpecularDampen;
&& p.CamoSpecularDampen == v.camoSpecularDampen
&& p.OcclusionThreshold == v.occlusionThreshold
&& p.HeightNormalIntensity == v.heightNormalIntensity
&& p.ParallaxIntensity == v.parallaxIntensity;
}
/// <summary>Snapshots the bound painter's brush values into the in-memory mirror.</summary>
@@ -338,6 +374,10 @@ public partial class PaintHud
_carry.camoHalfLambertBrightness,
_carry.camoSpecularDampen);
_hasPendingCamo = true;
_pendingHeight = new Vector3(_carry.occlusionThreshold,
_carry.heightNormalIntensity,
_carry.parallaxIntensity);
_hasPendingHeight = true;
}
}
@@ -372,6 +412,9 @@ public partial class PaintHud
v.camoHalfLambert = from.CamoHalfLambert;
v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness;
v.camoSpecularDampen = from.CamoSpecularDampen;
v.occlusionThreshold = from.OcclusionThreshold;
v.heightNormalIntensity = from.HeightNormalIntensity;
v.parallaxIntensity = from.ParallaxIntensity;
}
}
@@ -398,11 +441,14 @@ public partial class PaintHud
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.
// Camo + height setters are a no-op until the material exists; the guaranteed deferred apply runs
// from the LateUpdate ready-gate via _pendingCamo/_pendingHeight. Writing here too keeps Copy/Apply/Equal symmetric.
to.CamoHalfLambert = v.camoHalfLambert;
to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness;
to.CamoSpecularDampen = v.camoSpecularDampen;
to.OcclusionThreshold = v.occlusionThreshold;
to.HeightNormalIntensity = v.heightNormalIntensity;
to.ParallaxIntensity = v.parallaxIntensity;
}
private HudState LoadHudState()
@@ -482,15 +528,19 @@ public partial class PaintHud
_defaults.camoHalfLambert = painter.CamoHalfLambert;
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
_defaults.occlusionThreshold = painter.OcclusionThreshold;
_defaults.heightNormalIntensity = painter.HeightNormalIntensity;
_defaults.parallaxIntensity = painter.ParallaxIntensity;
}
ApplyValues(painter, _defaults);
SyncFromPainter();
SyncCamoSliders();
// ApplyValues wrote the camo straight to the material (painter is ready at a runtime reset), so
// drop any still-pending restored camo the ready-gate would otherwise re-apply over the default.
// ApplyValues wrote the camo + height knobs straight to the material (painter is ready at a
// runtime reset), so drop any still-pending restored values the ready-gate would re-apply over the default.
_hasPendingCamo = false;
_hasPendingHeight = false;
CaptureCarry(); // a later respawn now carries the defaults, not the pre-reset values
SnapshotSavedBaseline(); // values match the (now deleted) save -> the reset itself triggers no auto-save
+13
View File
@@ -70,6 +70,9 @@ public partial class PaintHud : MonoBehaviour
private const string SliderHalfLambert = "Slider_HalfLambert";
private const string SliderHalfLambertBrightness = "Slider_HalfLambertBrightness";
private const string SliderSpecularDamp = "Slider_SpecularDamp";
private const string SliderOcclusionThreshold = "Slider_OcclusionThreshold";
private const string SliderNormalIntensity = "Slider_NormalIntensity";
private const string SliderParallaxIntensity = "Slider_ParallaxIntensity";
private const string MaskSelector = "MaskSelector";
private const string MaskSwatchPrefix = "MaskSwatch_";
@@ -310,6 +313,12 @@ public partial class PaintHud : MonoBehaviour
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; });
// Painted-height knobs: material-backed like the camo sliders above, so they are seeded once the
// painter is ready (SyncCamoSliders) and a set before then is a safe no-op.
WireSlider(panel, SliderOcclusionThreshold, painter ? painter.OcclusionThreshold : 0.2f, v => { if (painter) painter.OcclusionThreshold = v; });
WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; });
WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; });
foreach (var name in DraggablePanels)
MakeDraggable(panel, name);
}
@@ -379,6 +388,10 @@ public partial class PaintHud : MonoBehaviour
SetSlider(panel, SliderHalfLambert, painter.CamoHalfLambert);
SetSlider(panel, SliderHalfLambertBrightness, painter.CamoHalfLambertBrightness);
SetSlider(panel, SliderSpecularDamp, painter.CamoSpecularDampen);
// Painted-height knobs share the same material-ready timing as the camo sliders.
SetSlider(panel, SliderOcclusionThreshold, painter.OcclusionThreshold);
SetSlider(panel, SliderNormalIntensity, painter.HeightNormalIntensity);
SetSlider(panel, SliderParallaxIntensity, painter.ParallaxIntensity);
}
private static void SetToggle(Transform panel, string name, bool value)
+29
View File
@@ -94,6 +94,14 @@ public class PlayerPaintSync : NetworkBehaviour
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
// Painted-height knobs (x = occlusion AO threshold, y = relief normal intensity, z = parallax
// intensity). Same per-player material values as the camo knobs - not baked into the maps - so they
// ride their own owner-authoritative NetworkVariable and apply once the remote painter is ready. The
// default mirrors the PlayerCamoLit shader defaults (0.2 / 1.4 / 1).
private readonly NetworkVariable<Vector3> _height =
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
// 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.
@@ -164,6 +172,10 @@ public class PlayerPaintSync : NetworkBehaviour
_camo.OnValueChanged += OnCamoChanged;
_camoApplyPending = true;
// Same deferred apply for the painted-height knobs.
_height.OnValueChanged += OnHeightChanged;
_heightApplyPending = 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)
@@ -191,6 +203,7 @@ public class PlayerPaintSync : NetworkBehaviour
}
_emissionIntensity.OnValueChanged -= OnEmissionChanged;
_camo.OnValueChanged -= OnCamoChanged;
_height.OnValueChanged -= OnHeightChanged;
}
// Owner history events -> appended to the ordered outgoing FIFO, flushed in LateUpdate.
@@ -221,6 +234,7 @@ public class PlayerPaintSync : NetworkBehaviour
// 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;
private void OnHeightChanged(Vector3 previous, Vector3 current) => _heightApplyPending = true;
// ---- Live stamp stream (owner -> server -> remotes) ----
@@ -252,6 +266,16 @@ public class PlayerPaintSync : NetworkBehaviour
_camoApplyPending = false;
}
// Remote replica: apply the owner's painted-height knobs once the material exists / on change.
if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady)
{
Vector3 h = _height.Value;
_painter.OcclusionThreshold = h.x;
_painter.HeightNormalIntensity = h.y;
_painter.ParallaxIntensity = h.z;
_heightApplyPending = false;
}
if (!IsOwner)
return;
@@ -282,6 +306,11 @@ public class PlayerPaintSync : NetworkBehaviour
var camo = new Vector3(_painter.CamoHalfLambert, _painter.CamoHalfLambertBrightness, _painter.CamoSpecularDampen);
if (_camo.Value != camo)
_camo.Value = camo;
// Publish the painted-height knobs the same way (delta-only, ready-gated).
var height = new Vector3(_painter.OcclusionThreshold, _painter.HeightNormalIntensity, _painter.ParallaxIntensity);
if (_height.Value != height)
_height.Value = height;
}
FlushOutgoing(unlimited: false);
+17 -6
View File
@@ -40,10 +40,15 @@ half _SpecularDampen;
// The UV displacement is URP's stock single-step parallax (ParallaxOffset1Step already does
// height*amp - amp/2, i.e. the 0.5 centring) under the _PARALLAXMAP keyword. Here we add the
// matching per-pixel relief NORMAL from the height gradient (the dominant cue) and remap the ambient
// occlusion so only the LOWER half darkens: AO = saturate(occ*2) (occ>=0.5 -> no darkening, occ in
// [0,0.5] -> AO 1..0). Declared outside UnityPerMaterial like the camo knobs above (the player is a
// occlusion so only the LOWER half darkens, with the darkening DEPTH driven by _OcclusionThreshold
// (a shift, see below). Declared outside UnityPerMaterial like the camo knobs above (the player is a
// handful of instances, so the SRP-batcher break is irrelevant).
half _HeightNormalStrength;
// Runtime intensities tuned from the paint HUD. _HeightNormalIntensity scales the relief normal
// (default 1). _OcclusionThreshold is a SHIFT (0..1, default 0.5): the AO darkening depth at the
// deepest painted hole - 0 = no darkening at all, 1 = AO reaches full black at occ=0.
half _HeightNormalIntensity;
half _OcclusionThreshold;
float4 _OcclusionMap_TexelSize;
// Remaps the painted occlusion/height value into surface AO + a relief normal. Runs after URP's
@@ -52,9 +57,14 @@ float4 _OcclusionMap_TexelSize;
void ApplyPaintedHeightSurface(float2 uv, inout SurfaceData surfaceData)
{
#if defined(_OCCLUSIONMAP)
// Lower-half AO remap: occ>=0.5 stays fully lit, occ in [0,0.5] darkens toward 0.
// Only the lower (recessed) half of the painted height darkens. _OcclusionThreshold is a SHIFT =
// how DEEP the darkening reaches at the deepest hole: AO fades from 1 (occ>=0.5, neutral/hill)
// down to (1 - shift) at occ=0. shift=0 -> AO stays 1 everywhere (no darkening, no black spots),
// shift=0.5 -> AO in [0.5,1], shift=1 -> AO in [0,1] (full darkening). Linear over occ in [0,0.5].
half occ = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv).g;
surfaceData.occlusion = LerpWhiteTo(saturate(occ * 2.0h), _OcclusionStrength);
half depth = saturate(1.0h - occ * 2.0h); // 0 at occ>=0.5 (neutral/hill), 1 at occ=0 (deepest)
half ao = 1.0h - depth * _OcclusionThreshold; // darken toward (1 - shift) as the hole deepens
surfaceData.occlusion = LerpWhiteTo(ao, _OcclusionStrength);
#endif
#if defined(_PARALLAXMAP)
@@ -65,8 +75,9 @@ void ApplyPaintedHeightSurface(float2 uv, inout SurfaceData surfaceData)
half hR = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv + float2(texel.x, 0.0)).g;
half hD = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv - float2(0.0, texel.y)).g;
half hU = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv + float2(0.0, texel.y)).g;
half3 reliefTS = normalize(half3((hL - hR) * _HeightNormalStrength,
(hD - hU) * _HeightNormalStrength, 1.0h));
half strength = _HeightNormalStrength * _HeightNormalIntensity;
half3 reliefTS = normalize(half3((hL - hR) * strength,
(hD - hU) * strength, 1.0h));
surfaceData.normalTS = BlendNormalRNM(surfaceData.normalTS, reliefTS);
#endif
}
+5
View File
@@ -44,6 +44,11 @@ Shader "Custom/PlayerCamoLit"
_Parallax("Scale", Range(0.005, 0.08)) = 0.005
_ParallaxMap("Height Map", 2D) = "black" {}
_HeightNormalStrength("Painted Height Normal Strength", Range(0.0, 50.0)) = 8.0
// Runtime knobs for the painted-height look (1 = base, tuned live in the paint HUD):
_HeightNormalIntensity("Painted Height Normal Intensity", Range(0.0, 2.0)) = 1.4
// AO darkening DEPTH (shift): AO fades from 1 down to (1 - this) at the deepest painted hole.
// 0 = no darkening (no black spots), 1 = full black at occ=0.
_OcclusionThreshold("Painted Occlusion AO Shift", Range(0.0, 1.0)) = 0.2
_OcclusionStrength("Strength", Range(0.0, 1.0)) = 1.0
_OcclusionMap("Occlusion", 2D) = "white" {}