Replace the symmetric sine with a raised cosine raised to _XRayPulseShape: a long near-zero plateau, a slow rise and a short full peak. Both shader halves use the identical formula to stay in phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
17 KiB
HLSL
320 lines
17 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: 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
|
|
|
|
// 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.
|
|
// 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
|