Files
chameleon/Assets/Shaders/PlayerCamo.hlsl
T
adminandClaude Opus 4.8 c54e0cf502 Add see-through X-ray reveal overlay for players
Add a second, additive HDR emission color (_XRayColor) that reveals a
player through walls as a pulsing energy glow, driven during matches:

- Shader: XRayOverlay pass (ZTest Greater, additive) for occluded parts
  plus a matching additive term in the forward fragment for visible
  parts, so the glow shows everywhere and never double-blends. Exposes
  _XRayColor, _XRayPulseSpeed and _XRayPulseStrength for a breathing pulse.
- Render it after opaques via a RenderObjects feature (custom XRayReveal
  light-mode tag) on both PC and Mobile renderers, so the through-wall
  test is depth-sort independent and no longer drops out at some camera
  angles/distances.
- MeshPainter drives _XRayColor alpha through a framerate-independent
  fade (xrayFadeDuration) rather than a hard toggle.
- Reveal is driven per avatar on every client: all remaining un-found
  hiders glow during the Resolution phase, and a hider whose
  ConcealmentMeter EmbeddedPercent exceeds MatchManager.embeddedRevealPercent
  glows via an owner-authoritative NetworkVariable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:22:17 +02:00

316 lines
16 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, 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;
// X-Ray reveal glow (rgb = HDR color, a = fade/opacity master). Consumed here for the VISIBLE parts;
// the separate XRayOverlay pass (SRPDefaultUnlit) covers the OCCLUDED parts. See CamoForwardFragment.
half4 _XRayColor;
half _XRayPulseSpeed; // 0 = steady; otherwise breathing pulse via _Time (see CamoForwardFragment)
half _XRayPulseStrength; // modulation depth of the pulse around the authored intensity
// 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)
// 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;
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)
// 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;
half strength = _HeightNormalStrength * _HeightNormalIntensity;
half3 reliefTS = normalize(half3((hL - hR) * strength,
(hD - hU) * strength, 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);
// X-Ray reveal glow on the VISIBLE parts. URP draws the SRPDefaultUnlit XRayOverlay pass BEFORE
// this UniversalForward pass (see DrawObjectsPass shader-tag order), so that pass alone can only
// survive on OCCLUDED pixels - this opaque forward write overwrites the visible ones. Adding the
// same additive term here reveals the glow on the visible parts too. Visible (here) and occluded
// (overlay, ZTest Greater) are mutually exclusive by depth => no double contribution. Added
// post-fog to match the overlay, which outputs the raw color.
// Breathing pulse (sin over _Time) on the glow master, matching the XRayOverlay pass exactly so the
// visible and occluded halves stay in phase. Speed 0 => term is 1 => identical to no pulse.
half xrayPulse = 1.0h + sin(_Time.y * _XRayPulseSpeed) * _XRayPulseStrength;
color.rgb += _XRayColor.rgb * (_XRayColor.a * xrayPulse);
color.a = OutputAlpha(color.a, IsSurfaceTypeTransparent(_Surface));
outColor = color;
#ifdef _WRITE_RENDERING_LAYERS
outRenderingLayers = EncodeMeshRenderingLayer();
#endif
}
#endif