diff --git a/Assets/Resources/PaintVariantKeepAlive.mat b/Assets/Resources/PaintVariantKeepAlive.mat index 75ecebb..9c07268 100644 --- a/Assets/Resources/PaintVariantKeepAlive.mat +++ b/Assets/Resources/PaintVariantKeepAlive.mat @@ -28,6 +28,7 @@ Material: - _EMISSION - _METALLICSPECGLOSSMAP - _OCCLUSIONMAP + - _PARALLAXMAP m_InvalidKeywords: [] m_LightmapFlags: 1 m_EnableInstancingVariants: 0 @@ -78,7 +79,7 @@ Material: m_Scale: {x: 1, y: 1} m_Offset: {x: 0, y: 0} - _ParallaxMap: - m_Texture: {fileID: 0} + m_Texture: {fileID: 2800000, guid: c24e49ee8237bac42930ef868a325c48, type: 2} m_Scale: {x: 1, y: 1} m_Offset: {x: 0, y: 0} - _SpecGlossMap: diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs index eb12a5e..0915bca 100644 --- a/Assets/Scripts/MeshPainter.Backend.cs +++ b/Assets/Scripts/MeshPainter.Backend.cs @@ -69,8 +69,9 @@ public partial class MeshPainter switch (mi) { case MAP_ALBEDO: return new[] { "_BaseMap", "_MainTex" }; - // One mask map drives both URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G). - case MAP_MSO: return new[] { "_MetallicGlossMap", "_OcclusionMap" }; + // One mask map drives three URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G) + + // _ParallaxMap (G = occlusion reused as a 0.5-centred height for parallax/relief). + case MAP_MSO: return new[] { "_MetallicGlossMap", "_OcclusionMap", "_ParallaxMap" }; default: return new[] { "_EmissionMap" }; } } diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs index 9469a52..189aee0 100644 --- a/Assets/Scripts/MeshPainter.Setup.cs +++ b/Assets/Scripts/MeshPainter.Setup.cs @@ -148,12 +148,14 @@ public partial class MeshPainter _seedCanonical = new Color32[MAP_COUNT][]; _seedCanonical[MAP_ALBEDO] = BuildBaseColors(); - // Mask map seed from the material scalars: R = metallic, G = occlusion (255 = - // unoccluded, so the look is unchanged until painted), B = 0, A = smoothness. + // Mask map seed from the material scalars: R = metallic, G = occlusion/height, B = 0, + // A = smoothness. G = 128 (= 0.5) is the NEUTRAL height: no parallax/relief and AO = + // saturate(2*0.5) = 1 (no darkening), so the unpainted look is unchanged. Painting G + // below 0.5 carves a hole (and darkens), above 0.5 raises a hill (see PlayerCamoLit). float metallic0 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f; float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(SmoothnessId) : 0.5f; _seedCanonical[MAP_MSO] = FillCanonical(new Color32( - (byte)(Mathf.Clamp01(metallic0) * 255f), 255, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f))); + (byte)(Mathf.Clamp01(metallic0) * 255f), 128, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f))); // Emission: keep the existing look (black if the material's emission is off). Color emission0 = Color.black; @@ -232,6 +234,18 @@ public partial class MeshPainter _materialInstance.EnableKeyword("_OCCLUSIONMAP"); if (_materialInstance.HasProperty(OcclusionStrengthId)) _materialInstance.SetFloat(OcclusionStrengthId, 1f); } + + // Height: the same mask map is also bound to _ParallaxMap (see MapProps), so the painted + // occlusion value (G) doubles as a 0.5-centred height. _PARALLAXMAP turns on URP's single- + // 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. + if (_materialInstance.HasProperty(ParallaxMapId)) + { + _materialInstance.EnableKeyword("_PARALLAXMAP"); + if (_materialInstance.HasProperty(ParallaxId) && _materialInstance.GetFloat(ParallaxId) < 0.02f) + _materialInstance.SetFloat(ParallaxId, 0.02f); + } } /// Pushes the global emission intensity to the material's _EmissionColor (live-tunable). diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index b81d2b1..a567b8c 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -130,9 +130,10 @@ public partial class MeshPainter : MonoBehaviour [Range(0f, 1f)] public float metallicSmoothnessOpacity = 1f; - [Tooltip("Ambient occlusion painted into the mask map (1 = fully lit / no darkening, " + - "0 = fully occluded / dark). NOTE: URP only darkens INDIRECT light (ambient + " + - "reflections), so the effect is subtle under strong direct lighting. 1 = no-op.")] + [Tooltip("Occlusion + HEIGHT painted into the mask map (one value, 0..1, 0.5 = neutral/no-op). " + + "Drives parallax + a relief normal: 0.5 = flat, >0.5 = raised hill, <0.5 = carved hole. " + + "Also drives ambient occlusion, but only the LOWER half darkens: 0.5..1 = fully lit, " + + "0.5..0 = AO fades 1..0 (so a hole is also darker). See Custom/PlayerCamoLit.")] [Range(0f, 1f)] public float occlusion = 0.5f; @@ -284,7 +285,7 @@ public partial class MeshPainter : MonoBehaviour // Seed colors per map in canonical (material) layout, built once at Start: // albedo -> RGB color - // MSO -> R = metallic, G = occlusion (1 = unoccluded), A = smoothness (mask map) + // MSO -> R = metallic, G = occlusion/height (0.5 = neutral: no relief, AO = 1), A = smoothness // emission -> RGB emission private Color32[][] _seedCanonical; @@ -322,6 +323,8 @@ 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 ParallaxMapId = Shader.PropertyToID("_ParallaxMap"); + private static readonly int ParallaxId = Shader.PropertyToID("_Parallax"); 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/Shaders/PlayerCamo.hlsl b/Assets/Shaders/PlayerCamo.hlsl index d076de5..25bd9d0 100644 --- a/Assets/Shaders/PlayerCamo.hlsl +++ b/Assets/Shaders/PlayerCamo.hlsl @@ -32,6 +32,45 @@ half _HalfLambert; half _HalfLambertBrightness; half _SpecularDampen; +// --- Painted height (from the occlusion channel) --- +// The painted occlusion value (G of the MSO mask map, bound to _OcclusionMap AND _ParallaxMap) is +// reinterpreted as a 0.5-centred HEIGHT field: +// 0.5 -> neutral: no displacement, flat relief normal, AO = 1 (identical to the unpainted look) +// <0.5 -> recessed (hole) >0.5 -> raised (hill) +// 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 +// handful of instances, so the SRP-batcher break is irrelevant). +half _HeightNormalStrength; +float4 _OcclusionMap_TexelSize; + +// Remaps the painted occlusion/height value into surface AO + a relief normal. Runs after URP's +// InitializeStandardLitSurfaceData (uv is already parallax-displaced) and before InitializeInputData, +// so the relief normal flows into inputData.normalWS through the tangent frame. +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. + half occ = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv).g; + surfaceData.occlusion = LerpWhiteTo(saturate(occ * 2.0h), _OcclusionStrength); +#endif + +#if defined(_PARALLAXMAP) + // Per-pixel relief normal from the height (occlusion) gradient via central differences over one + // texel. A flat field (occ=0.5 everywhere) yields zero deltas -> (0,0,1), so neutral stays unbent. + float2 texel = _OcclusionMap_TexelSize.xy; + half hL = SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, uv - float2(texel.x, 0.0)).g; + 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)); + surfaceData.normalTS = BlendNormalRNM(surfaceData.normalTS, reliefTS); +#endif +} + // Per-light shading: copy of URP's LightingPhysicallyBased with the diffuse term wrapped // (half-Lambert) and the specular term dampened. Diffuse and specular use SEPARATE radiance so the // wrap lifts only the diffuse shadow side and never smears specular onto back faces. @@ -208,6 +247,11 @@ void CamoForwardFragment( SurfaceData surfaceData; InitializeStandardLitSurfaceData(input.uv, surfaceData); + // Painted height: reinterpret the occlusion channel as a 0.5-centred height -> remap the AO + // (only the lower half darkens) and add a relief normal from the height gradient. The uv is + // already parallax-displaced above (_PARALLAXMAP), so this stays consistent with the offset. + ApplyPaintedHeightSurface(input.uv, surfaceData); + #ifdef LOD_FADE_CROSSFADE LODFadeCrossFade(input.positionCS); #endif @@ -215,9 +259,10 @@ void CamoForwardFragment( InputData inputData; InitializeInputData(input, surfaceData.normalTS, inputData); - // Camo: the form is de-emphasised purely inside the light loop (direct diffuse driven toward the - // dark/shadow value + specular faded), so the true normal is kept here and GI/reflections stay - // physically correct. No normal bending -> view-independent, no bright/dark flip when orbiting. + // Camo: the MACRO form is de-emphasised purely inside the light loop (direct diffuse driven toward + // the dark/shadow value + specular faded), so the body's true shape is kept here and GI/reflections + // stay physically correct - no macro normal bending, so no bright/dark flip when orbiting. The + // painted height above intentionally adds LOCAL relief normals (holes/hills) on top of that. SETUP_DEBUG_TEXTURE_DATA(inputData, UNDO_TRANSFORM_TEX(input.uv, _BaseMap)); diff --git a/Assets/Shaders/PlayerCamoLit.shader b/Assets/Shaders/PlayerCamoLit.shader index caf4dc1..5b7f008 100644 --- a/Assets/Shaders/PlayerCamoLit.shader +++ b/Assets/Shaders/PlayerCamoLit.shader @@ -43,6 +43,7 @@ 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 _OcclusionStrength("Strength", Range(0.0, 1.0)) = 1.0 _OcclusionMap("Occlusion", 2D) = "white" {}