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>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-08 13:24:52 +02:00
1 parent 78ab6e2dd6
commit 6db78397da
6 files changed
+186 -66

No files matched your search

+11 -61
View File
@@ -34,31 +34,14 @@
// 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.
// 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 _HalfLambertBrightness; // legacy name: now a GENERAL brightness/exposure (CamoForwardFragment), not a half-Lambert-only shift
half _Saturation;
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;
@@ -66,37 +49,6 @@ half _XRayPulseSpeed; // 0 = steady; otherwise breathing pulse via _Time (see
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.
@@ -298,14 +250,12 @@ void CamoForwardFragment(
half4 color = UniversalFragmentCamo(inputData, surfaceData);
// Camo body-tone grade on the WHOLE shaded body (direct + ambient/GI + specular): a GENERAL
// brightness/exposure and a saturation control - camo tools to sink/lift the body tone and to
// mute/punch its colour to match the scene. 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 (x1 multiply, no-op lerp) => byte-identical to stock when the camo knobs are off.
color.rgb *= 1.0h + _HalfLambertBrightness;
half camoLuma = dot(color.rgb, half3(0.2126h, 0.7152h, 0.0722h)); // Rec.709 luma
color.rgb = lerp(camoLuma.xxx, color.rgb, 1.0h + _Saturation);
// 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);
+71
View File
@@ -0,0 +1,71 @@
#ifndef PLAYER_CAMO_GBUFFER_INCLUDED
#define PLAYER_CAMO_GBUFFER_INCLUDED
// Camo variant of URP's LitGBufferPassFragment. Include AFTER LitGBufferPass.hlsl (reuses its
// Varyings / InitializeInputData / InitializeBakedGIData).
//
// The project renders FORWARD, so this GBuffer pass has exactly ONE consumer: HTrace WSGI renders
// its thin GBuffer through the "UniversalGBuffer" tag and its GI composite multiplies the traced
// irradiance by GBuffer0.rgb (diffuse albedo) and GBuffer1.a (occlusion), ADDING the result after
// post-processing - i.e. after the forward pass's camo grade already ran. Two camo hooks keep that
// injected indirect light consistent with the graded forward look:
// 1. ApplyPaintedHeightSurface: forward AO semantics for the painted height (neutral 0.5 -> AO 1)
// instead of the raw 0.5-centred height value in GBuffer1.a, + the relief normal.
// 2. ApplyCamoBodyGrade on the albedo: brightness/saturation follow into the injected GI, so
// desaturated paint stays desaturated in shadows / under indirect light.
// Everything else is a faithful copy of URP 17.3 LitGBufferPass.hlsl's fragment.
#include "PlayerCamoShared.hlsl"
GBufferFragOutput CamoGBufferFragment(Varyings input)
{
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);
// Camo hooks (see header). The albedo grade is clamped: GBuffer0 is UNORM, and HTrace multiplies
// its GI by the stored value, so out-of-range brightness would clip there anyway.
ApplyPaintedHeightSurface(input.uv, surfaceData);
ApplyCamoBodyGrade(surfaceData.albedo);
surfaceData.albedo = saturate(surfaceData.albedo);
#ifdef LOD_FADE_CROSSFADE
LODFadeCrossFade(input.positionCS);
#endif
InputData inputData;
InitializeInputData(input, surfaceData.normalTS, inputData);
SETUP_DEBUG_TEXTURE_DATA(inputData, UNDO_TRANSFORM_TEX(input.uv, _BaseMap));
#if defined(_DBUFFER)
ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData);
#endif
InitializeBakedGIData(input, inputData);
BRDFData brdfData;
InitializeBRDFData(surfaceData.albedo, surfaceData.metallic, surfaceData.specular, surfaceData.smoothness, surfaceData.alpha, brdfData);
Light mainLight = GetMainLight(inputData.shadowCoord, inputData.positionWS, inputData.shadowMask);
MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI, inputData.shadowMask);
half3 color = GlobalIllumination(brdfData, (BRDFData)0, 0,
inputData.bakedGI, surfaceData.occlusion, inputData.positionWS,
inputData.normalWS, inputData.viewDirectionWS, inputData.normalizedScreenSpaceUV);
return PackGBuffersBRDFData(brdfData, inputData, surfaceData.smoothness, surfaceData.emission + color, surfaceData.occlusion);
}
#endif
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: aa6746f5d4263894aa14dfc135bc9410
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+10 -5
View File
@@ -4,10 +4,14 @@
//
// Faithful copy of URP's Lit.shader - same properties, keywords and includes, so material values
// transfer 1:1, the Lit inspector keeps working, and the runtime MeshPainter (which only sets
// _BaseColor / PBR keywords on the material instance) is unaffected. The ONLY change is the
// ForwardLit pass: its fragment is CamoForwardFragment, which reimplements URP's forward shading
// while delegating GI / shadows / clustered lights to URP and applying the camo knobs. All
// other passes (ShadowCaster, GBuffer, DepthOnly, DepthNormals, Meta, ...) are stock URP/Lit.
// _BaseColor / PBR keywords on the material instance) is unaffected. TWO passes deviate from stock:
// - ForwardLit: fragment is CamoForwardFragment, which reimplements URP's forward shading while
// delegating GI / shadows / clustered lights to URP and applying the camo knobs.
// - GBuffer: fragment is CamoGBufferFragment. The project renders forward, so this pass only feeds
// HTrace WSGI's thin GBuffer - it mirrors the camo body grade (brightness/saturation) onto the
// albedo and the painted-height AO remap onto the occlusion, so HTrace's injected indirect light
// matches the graded forward look (see PlayerCamoGBuffer.hlsl).
// All other passes (ShadowCaster, DepthOnly, DepthNormals, Meta, ...) are stock URP/Lit.
Shader "Custom/PlayerCamoLit"
{
Properties
@@ -379,7 +383,7 @@ Shader "Custom/PlayerCamoLit"
// -------------------------------------
// Shader Stages
#pragma vertex LitGBufferPassVertex
#pragma fragment LitGBufferPassFragment
#pragma fragment CamoGBufferFragment
// -------------------------------------
// Material Keywords
@@ -438,6 +442,7 @@ Shader "Custom/PlayerCamoLit"
// Includes
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitGBufferPass.hlsl"
#include "PlayerCamoGBuffer.hlsl"
ENDHLSL
}
+80
View File
@@ -0,0 +1,80 @@
#ifndef PLAYER_CAMO_SHARED_INCLUDED
#define PLAYER_CAMO_SHARED_INCLUDED
// Camo knobs + painted-height surface remap SHARED by the ForwardLit pass (PlayerCamo.hlsl) and the
// UniversalGBuffer pass (PlayerCamoGBuffer.hlsl).
//
// Why the GBuffer pass needs this at all: the project renders FORWARD, so the only consumer of the
// UniversalGBuffer pass is HTrace WSGI. Its screen-space GI composite (ColorComposeURP) multiplies
// the traced irradiance by THIS pass's diffuse albedo (GBuffer0.rgb) and occlusion (GBuffer1.a) and
// adds the result AFTER post-processing - i.e. after the forward pass's camo grade already ran.
// Anything that grades the body's diffuse look (brightness/saturation, painted-height AO) must
// therefore be mirrored into the GBuffer, or HTrace re-injects the UNGRADED look wherever indirect
// light dominates (paint keeps its full saturation in shadows, painted height swings the GI).
//
// Declared OUTSIDE UnityPerMaterial (LitInput.hlsl owns that CBUFFER). The player is a handful of
// instances, so the resulting SRP-batcher break is irrelevant.
half _HalfLambertBrightness; // legacy name: a GENERAL brightness/exposure (see PlayerCamo.hlsl docs)
half _Saturation; // 0 = neutral, -1 = greyscale, >0 = punchier (see PlayerCamo.hlsl docs)
// --- 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).
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;
// 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
}
// Camo body-tone grade: a GENERAL brightness/exposure and a saturation control - camo tools to
// sink/lift the body tone and to mute/punch its colour to match the scene. Neutral at brightness 0 /
// saturation 0 (x1 multiply, no-op lerp). Linear in the colour, so applying it to the ALBEDO (GBuffer
// path) matches applying it to the lit result (forward path) for the diffuse contribution.
void ApplyCamoBodyGrade(inout half3 color)
{
color *= 1.0h + _HalfLambertBrightness;
half camoLuma = dot(color, half3(0.2126h, 0.7152h, 0.0722h)); // Rec.709 luma
color = lerp(camoLuma.xxx, color, 1.0h + _Saturation);
}
#endif
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 014a631929fff3b44b0edf8e54e2c373
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: