From 6db78397dac196f99d65771b537e3791cc132499 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 8 Jul 2026 13:24:52 +0200 Subject: [PATCH] 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 --- Assets/Shaders/PlayerCamo.hlsl | 72 +++---------------- Assets/Shaders/PlayerCamoGBuffer.hlsl | 71 +++++++++++++++++++ Assets/Shaders/PlayerCamoGBuffer.hlsl.meta | 7 ++ Assets/Shaders/PlayerCamoLit.shader | 15 ++-- Assets/Shaders/PlayerCamoShared.hlsl | 80 ++++++++++++++++++++++ Assets/Shaders/PlayerCamoShared.hlsl.meta | 7 ++ 6 files changed, 186 insertions(+), 66 deletions(-) create mode 100644 Assets/Shaders/PlayerCamoGBuffer.hlsl create mode 100644 Assets/Shaders/PlayerCamoGBuffer.hlsl.meta create mode 100644 Assets/Shaders/PlayerCamoShared.hlsl create mode 100644 Assets/Shaders/PlayerCamoShared.hlsl.meta diff --git a/Assets/Shaders/PlayerCamo.hlsl b/Assets/Shaders/PlayerCamo.hlsl index 95fd91d..dcd5f1b 100644 --- a/Assets/Shaders/PlayerCamo.hlsl +++ b/Assets/Shaders/PlayerCamo.hlsl @@ -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); diff --git a/Assets/Shaders/PlayerCamoGBuffer.hlsl b/Assets/Shaders/PlayerCamoGBuffer.hlsl new file mode 100644 index 0000000..1ea0c52 --- /dev/null +++ b/Assets/Shaders/PlayerCamoGBuffer.hlsl @@ -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 diff --git a/Assets/Shaders/PlayerCamoGBuffer.hlsl.meta b/Assets/Shaders/PlayerCamoGBuffer.hlsl.meta new file mode 100644 index 0000000..8d56c16 --- /dev/null +++ b/Assets/Shaders/PlayerCamoGBuffer.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: aa6746f5d4263894aa14dfc135bc9410 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/PlayerCamoLit.shader b/Assets/Shaders/PlayerCamoLit.shader index 05634e9..58c1e87 100644 --- a/Assets/Shaders/PlayerCamoLit.shader +++ b/Assets/Shaders/PlayerCamoLit.shader @@ -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 } diff --git a/Assets/Shaders/PlayerCamoShared.hlsl b/Assets/Shaders/PlayerCamoShared.hlsl new file mode 100644 index 0000000..a04c584 --- /dev/null +++ b/Assets/Shaders/PlayerCamoShared.hlsl @@ -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 diff --git a/Assets/Shaders/PlayerCamoShared.hlsl.meta b/Assets/Shaders/PlayerCamoShared.hlsl.meta new file mode 100644 index 0000000..0762d2b --- /dev/null +++ b/Assets/Shaders/PlayerCamoShared.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 014a631929fff3b44b0edf8e54e2c373 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: