diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index 8c8d344..a5ff932 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -41,6 +41,11 @@ namespace Chameleon.Components // direction so the flat shell slices merge into continuous strands at the rim instead of squished // dots. 0 = off (footprint stays circular everywhere). [SerializeField, Range(0f, 5f)] float _silhouetteStretch = 2f; + // How much the avatar's world scale drives the fur SIZE. 1 = fully proportional (small avatar => small + // fur, like the body). 0 = constant world size (same absolute fur length at any avatar scale). Fully + // proportional fur reads as too short on the small hider and too long on the big hunter, so dial this + // down to keep the fur closer to a fixed world length; the curl keeps its shape at any setting. + [SerializeField, Range(0f, 1f)] float _furScaleInfluence = 0.5f; [Header("Shading")] // Optional albedo (strand-colour) override for the standalone prototype; a sibling MeshPainter's @@ -79,6 +84,14 @@ namespace Chameleon.Components // Sway direction in the object's XZ plane; normalized in the shader. [SerializeField] Vector2 _windDirection = new Vector2(1f, 0f); + [Header("X-Ray Reveal")] + // The sibling MeshPainter's body see-through reveal is mirrored onto the fur so the strands glow WITH + // the reveal instead of hiding it under opaque fur (colour/pulse are pulled from the painter, in phase + // with the body). The fur draws up to _shellCount stacked TRANSPARENT shells, so that additive glow + // composites once per overlapping layer and blows out; this scales the per-shell glow down to tame the + // stacking so the total reads at roughly the body's intensity. Lower = subtler. + [SerializeField, Range(0f, 1f)] float _xrayFurStrength = 0.2f; + [Header("Rendering")] [SerializeField] ShadowCastingMode _shadowMode = ShadowCastingMode.Off; [SerializeField] bool _receiveShadows = true; @@ -110,6 +123,13 @@ namespace Chameleon.Components static readonly int WindSpeedId = Shader.PropertyToID("_WindSpeed"); static readonly int WindFrequencyId = Shader.PropertyToID("_WindFrequency"); static readonly int WindDirectionId = Shader.PropertyToID("_WindDirection"); + static readonly int FurObjScaleId = Shader.PropertyToID("_FurObjScale"); + static readonly int FurScaleInfluenceId = Shader.PropertyToID("_FurScaleInfluence"); + static readonly int XRayColorId = Shader.PropertyToID("_XRayColor"); + static readonly int XRayPulseSpeedId = Shader.PropertyToID("_XRayPulseSpeed"); + static readonly int XRayPulseStrengthId = Shader.PropertyToID("_XRayPulseStrength"); + static readonly int XRayPulseShapeId = Shader.PropertyToID("_XRayPulseShape"); + static readonly int XRayFurStrengthId = Shader.PropertyToID("_XRayFurStrength"); MeshFilter _meshFilter; SkinnedColliderBaker _baker; @@ -243,6 +263,31 @@ namespace Chameleon.Components _mpb.SetFloat(WindSpeedId, _windSpeed); _mpb.SetFloat(WindFrequencyId, _windFrequency); _mpb.SetVector(WindDirectionId, new Vector4(_windDirection.x, _windDirection.y, 0f, 0f)); + + // Avatar world scale (uniform via PlayerScaler) = the scale baked into the instance matrix used + // for the shell extrusion, so the shader can scale the curvature droop in lockstep and keep the + // curl proportional at any player size (hider 0.5 / hunter 1.5). Read from lossyScale because + // the raw object matrix does NOT carry the per-instance scale in the RenderMeshInstanced path. + _mpb.SetFloat(FurObjScaleId, transform.lossyScale.y); + // Blend the fur size between fully proportional to the avatar (1) and a constant world size (0): + // the shader pre-divides the object-space displacement so the net world length follows this. + _mpb.SetFloat(FurScaleInfluenceId, _furScaleInfluence); + + // Mirror the sibling painter's live body X-ray reveal onto the fur so the strands glow WITH the + // reveal (see-through camo) instead of hiding it under opaque fur. Same colour/pulse/flash the + // body uses => in phase. ToRenderColor matches the raw MPB upload to Material.SetColor's gamma + // handling (same convention as the fur tint above). Off (alpha 0) when no painter or not revealed + // => the shader's additive term is zero, so the fur is unchanged. + if (_painter) + { + _mpb.SetColor(XRayColorId, ToRenderColor(_painter.CurrentXRayColor)); + _mpb.SetFloat(XRayPulseSpeedId, _painter.XRayPulseSpeed); + _mpb.SetFloat(XRayPulseStrengthId, _painter.XRayPulseStrength); + _mpb.SetFloat(XRayPulseShapeId, _painter.XRayPulseShape); + // Per-shell glow scale: the body writes the reveal once (opaque) but the fur stacks many + // transparent shells that each composite it, so scale it down to keep the total near the body. + _mpb.SetFloat(XRayFurStrengthId, _xrayFurStrength); + } } // MaterialPropertyBlock.SetColor uploads raw values (unlike Material.SetColor it does no gamma diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs index b0e05c2..ebe727c 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs @@ -139,6 +139,31 @@ public partial class MeshPainter : MonoBehaviour if (flash != _xrayFlash) { _xrayFlash = flash; ApplyXRayFlashState(); } } + /// The live see-through X-ray glow currently on the body material (rgb = glow colour incl. any + /// hit-flash boost, a = eased fade master), for a sibling + /// to mirror onto the fur so the strands glow WITH the reveal instead of hiding it. Fully off + /// (alpha 0) until a reveal fades in, or before the material instance is set up. + public Color CurrentXRayColor => + _materialInstance && _materialInstance.HasProperty(XRayColorId) + ? _materialInstance.GetColor(XRayColorId) + : new Color(0f, 0f, 0f, 0f); + + /// Authored X-ray breathing pulse speed (0 = steady), mirrored onto the fur so its glow breathes + /// in phase with the body's forward/overlay halves. + public float XRayPulseSpeed => + _materialInstance && _materialInstance.HasProperty(XRayPulseSpeedId) + ? _materialInstance.GetFloat(XRayPulseSpeedId) : 0f; + + /// Live X-ray pulse strength (the hit flash drops this to 0 for a steady pop), mirrored onto the fur. + public float XRayPulseStrength => + _materialInstance && _materialInstance.HasProperty(XRayPulseStrengthId) + ? _materialInstance.GetFloat(XRayPulseStrengthId) : 0f; + + /// Authored X-ray pulse shape, mirrored onto the fur so its breathing matches the body. + public float XRayPulseShape => + _materialInstance && _materialInstance.HasProperty(XRayPulseShapeId) + ? _materialInstance.GetFloat(XRayPulseShapeId) : 1f; + [Tooltip("Seconds for the see-through X-ray reveal glow to fade fully in or out when toggled (0 = instant snap).")] [Min(0f)] [SerializeField] private float xrayFadeDuration = 0.3f; @@ -431,7 +456,9 @@ public partial class MeshPainter : MonoBehaviour private static readonly int HalfLambertBrightnessId = Shader.PropertyToID("_HalfLambertBrightness"); private static readonly int SpecularDampenId = Shader.PropertyToID("_SpecularDampen"); private static readonly int XRayColorId = Shader.PropertyToID("_XRayColor"); + private static readonly int XRayPulseSpeedId = Shader.PropertyToID("_XRayPulseSpeed"); private static readonly int XRayPulseStrengthId = Shader.PropertyToID("_XRayPulseStrength"); + private static readonly int XRayPulseShapeId = Shader.PropertyToID("_XRayPulseShape"); private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor"); private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask"); private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter"); diff --git a/Assets/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader index e76ab6c..80bc9f9 100644 --- a/Assets/Shaders/FurShell.shader +++ b/Assets/Shaders/FurShell.shader @@ -59,6 +59,28 @@ Shader "Custom/FurShell" _WindSpeed("Wind Speed", Range(0.0, 10.0)) = 2.0 _WindFrequency("Wind Spatial Frequency", Range(0.0, 10.0)) = 1.0 _WindDirection("Wind Direction (object XZ)", Vector) = (1, 0, 0, 0) + + // Avatar world scale, pushed by FurShellRenderer (transform.lossyScale). Drives the curvature droop + // so the curl stays proportional at any player size; 1 = authored size (see the vertex shader). + [HideInInspector] _FurObjScale("Object Scale (driven by FurShellRenderer)", Float) = 1 + // How strongly the avatar scale drives the fur SIZE: 1 = fully proportional (fur shrinks/grows with the + // avatar), 0 = constant world size (same absolute fur length at any avatar scale). Proportional fur + // reads as too short on the small hider and too long on the big hunter, so this blends between them. + [HideInInspector] _FurScaleInfluence("Scale Influence (driven by FurShellRenderer)", Range(0, 1)) = 0.5 + + [Header(XRay Reveal)] + // See-through reveal glow mirrored from the sibling MeshPainter's body X-ray so the fur strands glow + // WITH the reveal instead of hiding it under opaque fur. rgb = HDR glow colour, a = fade master. + // Default (0,0,0,0) => additive term is zero, so the fur looks unchanged until a reveal fades in. + [HDR] _XRayColor("X-Ray Overlay Color", Color) = (0, 0, 0, 0) + // Breathing pulse kept identical to the body halves (PlayerCamo.hlsl / XRayOverlay) so they stay in phase. + _XRayPulseSpeed("X-Ray Pulse Speed", Float) = 3 + _XRayPulseStrength("X-Ray Pulse Strength", Range(0, 1)) = 0.5 + _XRayPulseShape("X-Ray Pulse Shape", Range(1, 8)) = 3 + // The fur draws _ShellCount stacked TRANSPARENT shells; the additive glow below would composite once + // PER overlapping layer and blow out (the body writes it once, opaque). This scales the per-shell glow + // down so the stacked total reads at roughly the body's intensity. Tune to match the body glow. + _XRayFurStrength("X-Ray Fur Strength (tames shell stacking)", Range(0, 1)) = 0.2 } SubShader @@ -127,6 +149,13 @@ Shader "Custom/FurShell" float _WindSpeed; float _WindFrequency; float4 _WindDirection; + float _FurObjScale; + float _FurScaleInfluence; + half4 _XRayColor; + half _XRayPulseSpeed; + half _XRayPulseStrength; + half _XRayPulseShape; + half _XRayFurStrength; CBUFFER_END TEXTURE2D(_BaseMap); @@ -173,13 +202,24 @@ Shader "Custom/FurShell" float count = max(_ShellCount, 1.0); float h = (count <= 1.0) ? 0.0 : (float)shellIndex / (count - 1.0); + // Effective fur-size scale. The instance matrix already multiplies the object-space fur + // displacement by the avatar's world scale (_FurObjScale), so at full influence the fur is + // fully proportional (small avatar => small fur, like the body). _FurScaleInfluence blends + // that toward a CONSTANT world size (influence 0 => same absolute fur length at any avatar + // size), because fully proportional fur reads as too short on the small hider and too long on + // the big hunter. dispComp pre-divides the object-space offset so that after the matrix's + // *_FurObjScale the NET world displacement equals effScale, not _FurObjScale. + float objScale = max(_FurObjScale, 1e-4); + float effScale = lerp(1.0, objScale, _FurScaleInfluence); + float dispComp = effScale / objScale; + // extrude along the normal; bend the tips down with h^2 for a gravity/curl droop. // The (h + 0.02) lifts the whole stack a hair off the surface: the root (h=0) would else // sit exactly on the opaque body and z-fight it - the fur draws a CPU-baked mesh while the // body uses GPU skinning, so their depths are not bit-identical, and now that fur is // transparent (ZWrite Off) it can't rely on a depth write to win the tie. 0.02 of the shell // length is invisible but separates the root cleanly. - float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * (h + 0.02)); + float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * (h + 0.02) * dispComp); // wind sway: nudge each shell along the wind direction (object XZ) by a time-varying // amount. The phase is offset by object-space position so a gust rolls across the surface, @@ -189,17 +229,20 @@ Shader "Custom/FurShell" float wlen = length(wdir); wdir = (wlen > 1e-4) ? (wdir / wlen) : float2(1.0, 0.0); float phase = _Time.y * _WindSpeed + (input.positionOS.x + input.positionOS.z) * _WindFrequency; - posOS.xz += wdir * (_WindStrength * _ShellLength * h * h * sin(phase)); + posOS.xz += wdir * (_WindStrength * _ShellLength * h * h * sin(phase) * dispComp); // Gravity/curl bends toward WORLD down. Applied AFTER moving to world space and using ONLY // unity_ObjectToWorld (positions/normals already prove that matrix correct). The previous // object-space approach relied on unity_WorldToObject, which is NOT reliably populated in the // RenderMeshInstanced path - when it falls back to identity the droop follows the mesh's own // local down, so on the rotated player the fur hung sideways instead of toward true down. - // objScale keeps the droop magnitude consistent with the normal extrusion (object units). float3 positionWS = TransformObjectToWorld(posOS); - float objScale = length(mul((float3x3)unity_ObjectToWorld, float3(0.0, 1.0, 0.0))); - positionWS.y -= _Curvature * _ShellLength * h * h * objScale; + // Curl droop in world down. It uses the SAME effScale as the extrusion above (not the raw + // _FurObjScale), so the curl keeps its SHAPE relative to the fur length at any avatar size and + // at any influence setting - dialing the length toward constant carries the droop with it + // instead of leaving it too strong on the shrunk hider / too weak on the grown hunter. Applied + // after moving to world space, so the droop follows true world down regardless of player rotation. + positionWS.y -= _Curvature * _ShellLength * h * h * effScale; VertexNormalInputs vn = GetVertexNormalInputs(input.normalOS); @@ -341,6 +384,18 @@ Shader "Custom/FurShell" half rim = pow(saturate(1.0h - saturate(dot(N, V))), _RimPower) * _RimStrength * h; color += rim * mainLight.color * albedo; + // X-Ray reveal glow on the VISIBLE fur, mirroring the body's forward half (PlayerCamo.hlsl) + // EXACTLY so the strands glow IN PHASE with the body instead of hiding its reveal under + // opaque fur. Additive HDR term; _XRayColor.a is the eased fade master (0 when off => fur + // unchanged). FurShellRenderer pushes the same colour/pulse/flash state the sibling + // MeshPainter drives on the body, so on/occluded stays consistent between fur and body. + half xrayCurve = pow(0.5h - 0.5h * cos(_Time.y * _XRayPulseSpeed), _XRayPulseShape); + half xrayPulse = 1.0h - _XRayPulseStrength * (1.0h - xrayCurve); + // _XRayFurStrength tames the per-shell accumulation: the body adds this term once (opaque), + // but the fur stacks _ShellCount transparent shells that each composite it, so the raw term + // over-brightens. Scaling it per shell brings the stacked total back near the body's glow. + color += _XRayColor.rgb * (_XRayColor.a * xrayPulse * _XRayFurStrength); + return half4(color, alpha); } ENDHLSL