Files
chameleon/Assets/Shaders/PlayerCamo.hlsl
T
adminandClaude Opus 4.8 fdcb4f8d8a Drive parallax height and relief from the painted occlusion channel
Reuse the existing occlusion paint channel (no new map, MAP_COUNT stays 3):
0.5 is neutral, above 0.5 raises a hill, below 0.5 sinks a hole and also
fades ambient occlusion (saturate(2*occ)). Bind the MSO map to _ParallaxMap,
enable _PARALLAXMAP, derive a relief normal from the height gradient, and
reseed unpainted occlusion to 0.5 so untouched surfaces stay visually neutral.
Pin the _PARALLAXMAP runtime variant in the build keep-alive material.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:40:23 +02:00

287 lines
14 KiB
HLSL

#ifndef PLAYER_CAMO_INCLUDED
#define PLAYER_CAMO_INCLUDED
// Camouflage lighting for the player avatar. Goal: keep the player painted to match its
// surroundings while de-emphasising the 3D FORM, so a correctly painted avatar is harder to spot.
// Knobs - _HalfLambert at 0 and _HalfLambertBrightness at 0 => byte-identical URP/Lit:
//
// _HalfLambert (0..1): form-flattening diffuse over two segments -
// 0 -> saturate(N.L) plain Lambert (no-op)
// 0.5 -> N.L * 0.5 + 0.5 classic half-Lambert (soft terminator,
// no hard, form-revealing shadow line)
// 1 -> 1 fully FLAT: the direct diffuse no longer
// reveals the 3D form. View-INDEPENDENT
// (operates on the shading term, not a view-/light-bent normal, so
// there is no bright/dark flip when orbiting). The DIRECT specular
// highlight (a strong shape cue) is faded out with it, gone at 1.
// _HalfLambertBrightness (-1..1): additive shift of the (flattened) diffuse term, default 0. Tunes
// the body brighter/darker to match the scene tone (e.g. flatten to
// 1, then dial this down to sink into a dark scene). Clamped at 0.
// _SpecularDampen (0..1): scales down the DIRECT specular highlight (a strong shape cue) on
// top of the _HalfLambert fade.
//
// Implementation note: URP's LightingPhysicallyBased() hard-codes saturate(N.L) inline with no
// override hook, so the dark-converging half-Lambert needs the forward light loop reimplemented.
// This file is a 1:1 copy of URP 17.3 UniversalFragmentPBR that delegates ALL the fragile parts
// (GI / APV / shadow mask / AO / clustered light loop / final color) to URP's own helpers and only
// swaps the per-light shading call for the camo variant below.
//
// Declared OUTSIDE UnityPerMaterial (LitInput.hlsl owns that CBUFFER). The player is a handful of
// instances, so the resulting SRP-batcher break is irrelevant.
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.
half3 LightingPhysicallyBasedCamo(BRDFData brdfData, BRDFData brdfDataClearCoat,
half3 lightColor, half3 lightDirectionWS, float lightAttenuation,
half3 normalWS, half3 viewDirectionWS,
half clearCoatMask, bool specularHighlightsOff)
{
half NdotL = dot(normalWS, lightDirectionWS);
half ndlSat = saturate(NdotL);
// Form-flattening diffuse driven by _HalfLambert over two segments:
// 0 -> saturate(N.L) plain Lambert (no-op, byte-identical URP/Lit)
// 0.5 -> N.L * 0.5 + 0.5 classic half-Lambert (soft terminator, no hard shadow line)
// 1 -> 1 fully flat: the direct diffuse no longer reveals the 3D form.
// _HalfLambertBrightness then shifts the whole (flattened) term up/down to tune the body tone;
// clamped at 0 so it never produces negative light.
half classicHL = NdotL * 0.5h + 0.5h; // already in [0,1] for N.L in [-1,1]
half t1 = saturate(_HalfLambert * 2.0h); // 0..1 across _HalfLambert in [0, 0.5]
half t2 = saturate(_HalfLambert * 2.0h - 1.0h); // 0..1 across _HalfLambert in [0.5, 1]
half diffuseTerm = lerp(lerp(ndlSat, classicHL, t1), 1.0h, t2);
diffuseTerm = max(0.0h, diffuseTerm + _HalfLambertBrightness);
half3 atten = lightColor * lightAttenuation;
half3 diffuseRadiance = atten * diffuseTerm; // flattened + brightness-shifted
half3 specularRadiance = atten * ndlSat; // clamped (no back-face specular)
half3 color = brdfData.diffuse * diffuseRadiance;
#ifndef _SPECULARHIGHLIGHTS_OFF
[branch] if (!specularHighlightsOff)
{
half3 spec = brdfData.specular * DirectBRDFSpecular(brdfData, normalWS, lightDirectionWS, viewDirectionWS);
#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
// Inert for URP/Lit (no clear-coat keywords on the forward pass); kept for fidelity.
half brdfCoat = kDielectricSpec.r * DirectBRDFSpecular(brdfDataClearCoat, normalWS, lightDirectionWS, viewDirectionWS);
half NoV = saturate(dot(normalWS, viewDirectionWS));
half coatFresnel = kDielectricSpec.x + kDielectricSpec.a * Pow4(1.0 - NoV);
spec = spec * (1.0 - clearCoatMask * coatFresnel) + (brdfCoat * clearCoatMask).xxx;
#endif
// Fade the bright, form-revealing highlight out with _HalfLambert (gone at 1, so no highlight
// survives at full camo), on top of the explicit _SpecularDampen.
spec *= (1.0h - _SpecularDampen) * (1.0h - _HalfLambert);
color += spec * specularRadiance;
}
#endif
return color;
}
half3 LightingPhysicallyBasedCamo(BRDFData brdfData, BRDFData brdfDataClearCoat, Light light,
half3 normalWS, half3 viewDirectionWS, half clearCoatMask, bool specularHighlightsOff)
{
return LightingPhysicallyBasedCamo(brdfData, brdfDataClearCoat, light.color, light.direction,
light.distanceAttenuation * light.shadowAttenuation, normalWS, viewDirectionWS,
clearCoatMask, specularHighlightsOff);
}
// Faithful copy of URP 17.3 UniversalFragmentPBR. ONLY change vs. stock: the three per-light calls
// go to LightingPhysicallyBasedCamo (above). Everything else delegates to URP unchanged.
half4 UniversalFragmentCamo(InputData inputData, SurfaceData surfaceData)
{
#if defined(_SPECULARHIGHLIGHTS_OFF)
bool specularHighlightsOff = true;
#else
bool specularHighlightsOff = false;
#endif
BRDFData brdfData;
// NOTE: can modify "surfaceData"...
InitializeBRDFData(surfaceData, brdfData);
#if defined(DEBUG_DISPLAY)
half4 debugColor;
if (CanDebugOverrideOutputColor(inputData, surfaceData, brdfData, debugColor))
{
return debugColor;
}
#endif
BRDFData brdfDataClearCoat = CreateClearCoatBRDFData(surfaceData, brdfData);
half4 shadowMask = CalculateShadowMask(inputData);
AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
uint meshRenderingLayers = GetMeshRenderingLayer();
Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);
MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI);
LightingData lightingData = CreateLightingData(inputData, surfaceData);
lightingData.giColor = GlobalIllumination(brdfData, brdfDataClearCoat, surfaceData.clearCoatMask,
inputData.bakedGI, aoFactor.indirectAmbientOcclusion, inputData.positionWS,
inputData.normalWS, inputData.viewDirectionWS, inputData.normalizedScreenSpaceUV);
#ifdef _LIGHT_LAYERS
if (IsMatchingLightLayer(mainLight.layerMask, meshRenderingLayers))
#endif
{
lightingData.mainLightColor = LightingPhysicallyBasedCamo(brdfData, brdfDataClearCoat,
mainLight,
inputData.normalWS, inputData.viewDirectionWS,
surfaceData.clearCoatMask, specularHighlightsOff);
}
#if defined(_ADDITIONAL_LIGHTS)
uint pixelLightCount = GetAdditionalLightsCount();
#if USE_CLUSTER_LIGHT_LOOP
[loop] for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
{
CLUSTER_LIGHT_LOOP_SUBTRACTIVE_LIGHT_CHECK
Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
#ifdef _LIGHT_LAYERS
if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers))
#endif
{
lightingData.additionalLightsColor += LightingPhysicallyBasedCamo(brdfData, brdfDataClearCoat, light,
inputData.normalWS, inputData.viewDirectionWS,
surfaceData.clearCoatMask, specularHighlightsOff);
}
}
#endif
LIGHT_LOOP_BEGIN(pixelLightCount)
Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, aoFactor);
#ifdef _LIGHT_LAYERS
if (IsMatchingLightLayer(light.layerMask, meshRenderingLayers))
#endif
{
lightingData.additionalLightsColor += LightingPhysicallyBasedCamo(brdfData, brdfDataClearCoat, light,
inputData.normalWS, inputData.viewDirectionWS,
surfaceData.clearCoatMask, specularHighlightsOff);
}
LIGHT_LOOP_END
#endif
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
lightingData.vertexLightingColor += inputData.vertexLighting * brdfData.diffuse;
#endif
#if REAL_IS_HALF
return min(CalculateFinalColor(lightingData, surfaceData.alpha), HALF_MAX);
#else
return CalculateFinalColor(lightingData, surfaceData.alpha);
#endif
}
// Replicates URP's LitPassFragment body. The camo flattening lives entirely in the light loop
// (UniversalFragmentCamo), so the true normal is kept and GI/reflections stay physically correct.
void CamoForwardFragment(
Varyings input
, out half4 outColor : SV_Target0
#ifdef _WRITE_RENDERING_LAYERS
, out uint outRenderingLayers : SV_Target1
#endif
)
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
#if defined(_PARALLAXMAP)
#if defined(REQUIRES_TANGENT_SPACE_VIEW_DIR_INTERPOLATOR)
half3 viewDirTS = input.viewDirTS;
#else
half3 viewDirWS = GetWorldSpaceNormalizeViewDir(input.positionWS);
half3 viewDirTS = GetViewDirectionTangentSpace(input.tangentWS, input.normalWS, viewDirWS);
#endif
ApplyPerPixelDisplacement(viewDirTS, input.uv);
#endif
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
InputData inputData;
InitializeInputData(input, surfaceData.normalTS, inputData);
// 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));
#if defined(_DBUFFER)
ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData);
#endif
InitializeBakedGIData(input, inputData);
half4 color = UniversalFragmentCamo(inputData, surfaceData);
color.rgb = MixFog(color.rgb, inputData.fogCoord);
color.a = OutputAlpha(color.a, IsSurfaceTypeTransparent(_Surface));
outColor = color;
#ifdef _WRITE_RENDERING_LAYERS
outRenderingLayers = EncodeMeshRenderingLayer();
#endif
}
#endif