From 59939b34960bd4a1f306a2308c93b0d323bc2811 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 1 Jul 2026 07:07:39 +0200 Subject: [PATCH] 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 --- Assets/Resources/PaintVariantKeepAlive.mat | 3 +- Assets/Scripts/MeshPainter.Persistence.cs | 31 +++++++++-- Assets/Scripts/MeshPainter.Pick.cs | 57 ++++++++++---------- Assets/Scripts/MeshPainter.Setup.cs | 6 +++ Assets/Scripts/MeshPainter.cs | 33 ++++++++++++ Assets/Scripts/PaintHud.Library.cs | 5 +- Assets/Scripts/PaintHud.Persistence.cs | 60 ++++++++++++++++++++-- Assets/Scripts/PaintHud.cs | 13 +++++ Assets/Scripts/PlayerPaintSync.cs | 29 +++++++++++ Assets/Shaders/PlayerCamo.hlsl | 23 ++++++--- Assets/Shaders/PlayerCamoLit.shader | 5 ++ 11 files changed, 216 insertions(+), 49 deletions(-) diff --git a/Assets/Resources/PaintVariantKeepAlive.mat b/Assets/Resources/PaintVariantKeepAlive.mat index 9c07268..92d6ec2 100644 --- a/Assets/Resources/PaintVariantKeepAlive.mat +++ b/Assets/Resources/PaintVariantKeepAlive.mat @@ -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: diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs index a771516..dba769c 100644 --- a/Assets/Scripts/MeshPainter.Persistence.cs +++ b/Assets/Scripts/MeshPainter.Persistence.cs @@ -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"; /// @@ -26,9 +26,11 @@ public partial class MeshPainter /// 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) diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs index f3a0630..88d0550 100644 --- a/Assets/Scripts/MeshPainter.Pick.cs +++ b/Assets/Scripts/MeshPainter.Pick.cs @@ -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; /// @@ -44,9 +45,10 @@ public partial class MeshPainter private const float TerrainFoliageHeightEpsilon = 0.3f; /// - /// 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 } /// - /// 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. /// 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; } /// - /// 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 (raw, unlit). Albedo /// follows : 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 /// /// 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. /// 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; } diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs index 189aee0..70c7c3b 100644 --- a/Assets/Scripts/MeshPainter.Setup.cs +++ b/Assets/Scripts/MeshPainter.Setup.cs @@ -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); } diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index a567b8c..8be5467 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -91,6 +91,35 @@ public partial class MeshPainter : MonoBehaviour set { if (_materialInstance && _materialInstance.HasProperty(SpecularDampenId)) _materialInstance.SetFloat(SpecularDampenId, value); } } + /// + /// Painted-height look knobs on the same Custom/PlayerCamoLit 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 . Defaults match the shader so the unpainted look is unchanged. + /// + /// 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. + public float OcclusionThreshold + { + get => _materialInstance && _materialInstance.HasProperty(OcclusionThresholdId) ? _materialInstance.GetFloat(OcclusionThresholdId) : 0.2f; + set { if (_materialInstance && _materialInstance.HasProperty(OcclusionThresholdId)) _materialInstance.SetFloat(OcclusionThresholdId, value); } + } + + /// Multiplier on the painted-height relief NORMAL strength (1 = base, 0 = flat). + public float HeightNormalIntensity + { + get => _materialInstance && _materialInstance.HasProperty(HeightNormalIntensityId) ? _materialInstance.GetFloat(HeightNormalIntensityId) : 1.4f; + set { if (_materialInstance && _materialInstance.HasProperty(HeightNormalIntensityId)) _materialInstance.SetFloat(HeightNormalIntensityId, value); } + } + + /// Multiplier on the painted-height UV PARALLAX amplitude (1 = base , + /// 0 = no displacement). Backed by the material's _Parallax, so it round-trips like the others. + 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"); diff --git a/Assets/Scripts/PaintHud.Library.cs b/Assets/Scripts/PaintHud.Library.cs index dd40f06..28bf51a 100644 --- a/Assets/Scripts/PaintHud.Library.cs +++ b/Assets/Scripts/PaintHud.Library.cs @@ -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(); diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs index e06fa99..300adc9 100644 --- a/Assets/Scripts/PaintHud.Persistence.cs +++ b/Assets/Scripts/PaintHud.Persistence.cs @@ -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 panels = new List(); 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) } /// A draggable panel was dropped -> persist the layout immediately. @@ -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; } /// Snapshots the bound painter's brush values into the in-memory mirror. @@ -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 diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index 853f398..5e056d2 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -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) diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 62ed1b6..48a56ff 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -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 _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); diff --git a/Assets/Shaders/PlayerCamo.hlsl b/Assets/Shaders/PlayerCamo.hlsl index 25bd9d0..7b5bcc7 100644 --- a/Assets/Shaders/PlayerCamo.hlsl +++ b/Assets/Shaders/PlayerCamo.hlsl @@ -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 } diff --git a/Assets/Shaders/PlayerCamoLit.shader b/Assets/Shaders/PlayerCamoLit.shader index 5b7f008..acea0de 100644 --- a/Assets/Shaders/PlayerCamoLit.shader +++ b/Assets/Shaders/PlayerCamoLit.shader @@ -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" {}