Files
chameleon/Assets/Shaders/PlayerCamo.hlsl
T
adminandClaude Fable 5 6db78397da Apply camo brightness/saturation grade in GBuffer pass for HTrace GI
HTrace WSGI composites indirect lighting after post-processing using the
GBuffer albedo/occlusion of the player's stock URP GBuffer pass. That pass
knew neither the painted-height AO semantics nor the brightness/saturation
grade, so desaturated paint reappeared fully saturated in shadow/indirect
light and painted height modulated GI with the raw 0.5-centered value.

- PlayerCamoShared.hlsl (new): single source for camo knobs,
  ApplyPaintedHeightSurface and ApplyCamoBodyGrade shared by both passes
- PlayerCamoGBuffer.hlsl (new): CamoGBufferFragment = URP 17.3 Lit GBuffer
  fragment + height-AO remap on occlusion + grade applied to albedo
- PlayerCamoLit.shader: GBuffer pass now uses CamoGBufferFragment
- PlayerCamo.hlsl: consumes the shared helpers (forward path unchanged)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:24:52 +02:00

286 lines
15 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 0, _HalfLambertBrightness 0 and _Saturation 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): GENERAL brightness (exposure) on the WHOLE shaded body - diffuse,
// ambient/GI AND specular - as a final multiply (1 + value), default
// 0 = neutral. NO LONGER tied to the half-Lambert diffuse term (the
// "HalfLambert" in the name is legacy): dial down to sink the body
// into a dark scene, up to lift it into a bright one. Applied in
// CamoForwardFragment, before fog and the X-ray glow.
// _Saturation (-1..1): colour saturation of the shaded body, default 0 = neutral. -1 =
// greyscale, <0 = muted (blends into muted scenes), >0 = punchier.
// Luma-preserving lerp(grey, colour, 1 + _Saturation) toward Rec.709
// grey, same final stage as brightness.
// _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. Brightness/saturation and the painted
// height (knobs + ApplyPaintedHeightSurface) live in PlayerCamoShared.hlsl - the UniversalGBuffer
// pass (PlayerCamoGBuffer.hlsl, HTrace WSGI's GI input) mirrors them so injected indirect light
// matches the graded forward look.
#include "PlayerCamoShared.hlsl"
half _HalfLambert;
half _SpecularDampen;
// 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: how far the trough dims below the full-intensity peak
half _XRayPulseShape; // >1 bends the pulse: longer near-off plateau, rising to a brief full peak
// 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.
// (Brightness is NOT applied here anymore - it is a general final-color exposure in CamoForwardFragment.)
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); // in [0,1], no negative light
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);
// Camo body-tone grade on the WHOLE shaded body (direct + ambient/GI + specular). Applied before
// fog (part of the body look) and before the X-ray reveal glow below, so the reveal keeps its
// authored intensity. Neutral at brightness 0 / saturation 0 => byte-identical to stock when the
// camo knobs are off. Mirrored onto the GBuffer albedo (PlayerCamoGBuffer.hlsl) so HTrace WSGI's
// injected indirect light follows the grade too - otherwise paint keeps its raw look in shadows.
ApplyCamoBodyGrade(color.rgb);
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.
// Asymmetric breathing pulse on the glow master, matching the XRayOverlay pass exactly so the visible
// and occluded halves stay in phase. A raised cosine (0..1, peak once per cycle) raised to _XRayPulseShape
// rises from a long near-off plateau to a brief peak; _XRayPulseStrength sets how deep the trough dims
// (peak stays at the authored intensity). Speed 0 => cos term constant => steady glow.
half xrayCurve = pow(0.5h - 0.5h * cos(_Time.y * _XRayPulseSpeed), _XRayPulseShape);
half xrayPulse = 1.0h - _XRayPulseStrength * (1.0h - xrayCurve);
color.rgb += _XRayColor.rgb * (_XRayColor.a * xrayPulse);
color.a = OutputAlpha(color.a, IsSurfaceTypeTransparent(_Surface));
outColor = color;
#ifdef _WRITE_RENDERING_LAYERS
outRenderingLayers = EncodeMeshRenderingLayer();
#endif
}
#endif