From bf4297a3b12bf9964319f0b9cd308877c025416a Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 3 Jul 2026 17:34:58 +0200 Subject: [PATCH] Add generic shell-texturing fur shader and renderer FurShell.shader renders N normal-extruded shells with per-strand alpha-cutout for hair/fur/grass on any mesh (needs only normals/UVs). Parameters cover length, density, thickness, taper, tip fill, curvature, per-strand direction randomness, wind sway and silhouette strand stretch. FurShellRenderer drives every parameter via MaterialPropertyBlock and draws the mesh shellCount times with RenderMeshInstanced from beginCameraRendering (works in edit, game and screenshot cameras). Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/Components/FurShellRenderer.cs | 230 +++++++++++++++ .../Components/FurShellRenderer.cs.meta | 2 + Assets/Shaders/FurShell.shader | 273 ++++++++++++++++++ Assets/Shaders/FurShell.shader.meta | 9 + 4 files changed, 514 insertions(+) create mode 100644 Assets/Scripts/Components/FurShellRenderer.cs create mode 100644 Assets/Scripts/Components/FurShellRenderer.cs.meta create mode 100644 Assets/Shaders/FurShell.shader create mode 100644 Assets/Shaders/FurShell.shader.meta diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs new file mode 100644 index 0000000..8cc7ec3 --- /dev/null +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -0,0 +1,230 @@ +using UnityEngine; +using UnityEngine.Rendering; + +namespace Chameleon.Components +{ + // Drives the shell-texturing fur look for FurShell.shader. Draws the source mesh _shellCount times + // via GPU instancing (Graphics.RenderMeshInstanced); every instance is one shell layer and reads its + // index from SV_InstanceID in the shader to pick its extrusion height. STANDALONE prototype - not yet + // connected to the paint pipeline. Put this next to a MeshFilter and assign the FurShell material. + // + // This component is the SINGLE source of truth for the fur look: every tunable shader parameter is a + // serialized field here and gets pushed via a MaterialPropertyBlock. The material's own inspector + // sliders are therefore only template defaults - they are overridden live. (Use the "Copy Values From + // Material" context menu to pull a material you already tuned into these fields.) + // + // The draw is issued from RenderPipelineManager.beginCameraRendering (not Update/LateUpdate) so it + // renders reliably for EVERY camera pass - including the Scene View and edit-mode repaints, where + // LateUpdate does not tick per frame. RenderParams.camera pins each submission to the camera being + // rendered, so multiple cameras never multiply the geometry. + [ExecuteAlways] + [RequireComponent(typeof(MeshFilter))] + public sealed class FurShellRenderer : MonoBehaviour + { + [Header("Shells")] + [SerializeField] Material _furMaterial; + [SerializeField, Range(1, 64)] int _shellCount = 32; + + [Header("Shape")] + [SerializeField, Range(0f, 2f)] float _shellLength = 0.15f; + [SerializeField, Range(1f, 1000f)] float _density = 250f; + [SerializeField, Range(0f, 1f)] float _thickness = 0.5f; + [SerializeField, Range(0.1f, 8f)] float _taper = 2f; + // Floor on the strand radius (fraction of thickness): stops the taper thinning strands to a point, + // so neighbouring shell slices keep overlapping and the tips don't break into floating dots. + [SerializeField, Range(0f, 1f)] float _tipFill = 0.35f; + [SerializeField, Range(0f, 1f)] float _curvature = 0.25f; + // Per-strand lean: each strand tilts in its own random direction (fragment-space, see shader). + [SerializeField, Range(0f, 1f)] float _directionRandomness = 0.3f; + // View-dependent fix for the silhouette: elongates the strand footprint along the grazing view + // 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; + + [Header("Shading")] + // Optional per-instance albedo override; leave null to keep the material's own texture. + [SerializeField] Texture _baseMap; + [SerializeField] Color _rootColor = new Color(0.42f, 0.32f, 0.20f, 1f); + [SerializeField] Color _tipColor = new Color(0.62f, 0.52f, 0.36f, 1f); + [SerializeField, Range(0f, 1f)] float _rootAO = 0.45f; + [SerializeField, Range(0.5f, 8f)] float _rimPower = 3f; + [SerializeField, Range(0f, 2f)] float _rimStrength = 0.4f; + + [Header("Wind")] + [SerializeField, Range(0f, 1f)] float _windStrength = 0.1f; + [SerializeField, Range(0f, 10f)] float _windSpeed = 2f; + [SerializeField, Range(0f, 10f)] float _windFrequency = 1f; + // Sway direction in the object's XZ plane; normalized in the shader. + [SerializeField] Vector2 _windDirection = new Vector2(1f, 0f); + + [Header("Rendering")] + [SerializeField] ShadowCastingMode _shadowMode = ShadowCastingMode.Off; + [SerializeField] bool _receiveShadows = true; + // Hide the plain MeshRenderer on this object so it doesn't double-draw under the fur (the root + // shell already covers the surface). Leave off if you want a separately-shaded base mesh. + [SerializeField] bool _hideSourceRenderer = true; + + static readonly int ShellCountId = Shader.PropertyToID("_ShellCount"); + static readonly int ShellLengthId = Shader.PropertyToID("_ShellLength"); + static readonly int DensityId = Shader.PropertyToID("_Density"); + static readonly int ThicknessId = Shader.PropertyToID("_Thickness"); + static readonly int TaperId = Shader.PropertyToID("_Taper"); + static readonly int TipFillId = Shader.PropertyToID("_TipFill"); + static readonly int CurvatureId = Shader.PropertyToID("_Curvature"); + static readonly int DirRandomnessId = Shader.PropertyToID("_DirRandomness"); + static readonly int SilhouetteStretchId = Shader.PropertyToID("_SilhouetteStretch"); + static readonly int BaseMapId = Shader.PropertyToID("_BaseMap"); + static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); + static readonly int TipColorId = Shader.PropertyToID("_TipColor"); + static readonly int RootAOId = Shader.PropertyToID("_RootAO"); + static readonly int RimPowerId = Shader.PropertyToID("_RimPower"); + static readonly int RimStrengthId = Shader.PropertyToID("_RimStrength"); + static readonly int WindStrengthId = Shader.PropertyToID("_WindStrength"); + static readonly int WindSpeedId = Shader.PropertyToID("_WindSpeed"); + static readonly int WindFrequencyId = Shader.PropertyToID("_WindFrequency"); + static readonly int WindDirectionId = Shader.PropertyToID("_WindDirection"); + + Mesh _mesh; + Matrix4x4[] _instances; + MaterialPropertyBlock _mpb; + + void OnEnable() + { + var mf = GetComponent(); + _mesh = mf ? mf.sharedMesh : null; + _mpb = new MaterialPropertyBlock(); + // RenderMeshInstanced requires the material's GPU-instancing flag; guarantee it here so any + // assigned FurShell material just works without the manual inspector checkbox. + if (_furMaterial) _furMaterial.enableInstancing = true; + if (_hideSourceRenderer) SetSourceRenderer(false); + RenderPipelineManager.beginCameraRendering += OnBeginCamera; +#if UNITY_EDITOR + UnityEditor.EditorApplication.update += EditorWindTick; +#endif + } + + void OnDisable() + { + RenderPipelineManager.beginCameraRendering -= OnBeginCamera; + if (_hideSourceRenderer) SetSourceRenderer(true); +#if UNITY_EDITOR + UnityEditor.EditorApplication.update -= EditorWindTick; +#endif + } + +#if UNITY_EDITOR + // Wind is driven by _Time.y, but the Scene/Game view doesn't repaint every frame in edit mode, so + // without a nudge the preview looks frozen. Only requests repaints while wind is actually active. + void EditorWindTick() + { + if (!Application.isPlaying && _windStrength > 0f && _windSpeed > 0f) + UnityEditor.SceneView.RepaintAll(); + } +#endif + + void SetSourceRenderer(bool enabled) + { + var mr = GetComponent(); + if (mr) mr.enabled = enabled; + } + + void OnBeginCamera(ScriptableRenderContext ctx, Camera cam) + { + if (!_furMaterial || !_mesh) return; + + // Guarantee the instancing flag at the draw site: a shader/material reimport can reset it back + // to the on-disk state after OnEnable ran, and RenderMeshInstanced throws without it. + if (!_furMaterial.enableInstancing) _furMaterial.enableInstancing = true; + + int count = Mathf.Max(1, _shellCount); + if (_instances == null || _instances.Length != count) + _instances = new Matrix4x4[count]; + + // all shells share the object's transform; they differ only by SV_InstanceID in the shader + Matrix4x4 m = transform.localToWorldMatrix; + for (int i = 0; i < count; i++) _instances[i] = m; + + PushProperties(count); + + var rp = new RenderParams(_furMaterial) + { + camera = cam, // pin to this camera so multiple cameras don't multiply the draw + worldBounds = new Bounds(transform.position, Vector3.one * 1000f), + matProps = _mpb, + shadowCastingMode = _shadowMode, + receiveShadows = _receiveShadows, + }; + + for (int sub = 0; sub < _mesh.subMeshCount; sub++) + Graphics.RenderMeshInstanced(rp, _mesh, sub, _instances, count); + } + + void PushProperties(int shellCount) + { + // Clear first so a base-map override that was assigned and then cleared doesn't linger in the + // block; every other value is (re)written below regardless. + _mpb.Clear(); + + _mpb.SetFloat(ShellCountId, shellCount); + _mpb.SetFloat(ShellLengthId, _shellLength); + _mpb.SetFloat(DensityId, _density); + _mpb.SetFloat(ThicknessId, _thickness); + _mpb.SetFloat(TaperId, _taper); + _mpb.SetFloat(TipFillId, _tipFill); + _mpb.SetFloat(CurvatureId, _curvature); + _mpb.SetFloat(DirRandomnessId, _directionRandomness); + _mpb.SetFloat(SilhouetteStretchId, _silhouetteStretch); + + if (_baseMap) _mpb.SetTexture(BaseMapId, _baseMap); + _mpb.SetColor(BaseColorId, ToRenderColor(_rootColor)); + _mpb.SetColor(TipColorId, ToRenderColor(_tipColor)); + _mpb.SetFloat(RootAOId, _rootAO); + _mpb.SetFloat(RimPowerId, _rimPower); + _mpb.SetFloat(RimStrengthId, _rimStrength); + + _mpb.SetFloat(WindStrengthId, _windStrength); + _mpb.SetFloat(WindSpeedId, _windSpeed); + _mpb.SetFloat(WindFrequencyId, _windFrequency); + _mpb.SetVector(WindDirectionId, new Vector4(_windDirection.x, _windDirection.y, 0f, 0f)); + } + + // MaterialPropertyBlock.SetColor uploads raw values (unlike Material.SetColor it does no gamma + // correction), so convert to linear ourselves in linear color space - otherwise the tints read too + // bright versus the material color picker. + static Color ToRenderColor(Color c) + => QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c; + + static Color FromRenderColor(Color c) + => QualitySettings.activeColorSpace == ColorSpace.Linear ? c.gamma : c; + + // Convenience for migrating a material you already tuned by hand: copies its authored values into + // these fields (which then drive the look). Symmetric with ToRenderColor so colors round-trip. + [ContextMenu("Copy Values From Material")] + void CopyFromMaterial() + { + if (!_furMaterial) return; + var mat = _furMaterial; + if (mat.HasProperty(ShellLengthId)) _shellLength = mat.GetFloat(ShellLengthId); + if (mat.HasProperty(DensityId)) _density = mat.GetFloat(DensityId); + if (mat.HasProperty(ThicknessId)) _thickness = mat.GetFloat(ThicknessId); + if (mat.HasProperty(TaperId)) _taper = mat.GetFloat(TaperId); + if (mat.HasProperty(TipFillId)) _tipFill = mat.GetFloat(TipFillId); + if (mat.HasProperty(CurvatureId)) _curvature = mat.GetFloat(CurvatureId); + if (mat.HasProperty(DirRandomnessId)) _directionRandomness = mat.GetFloat(DirRandomnessId); + if (mat.HasProperty(SilhouetteStretchId)) _silhouetteStretch = mat.GetFloat(SilhouetteStretchId); + if (mat.HasProperty(BaseColorId)) _rootColor = FromRenderColor(mat.GetColor(BaseColorId)); + if (mat.HasProperty(TipColorId)) _tipColor = FromRenderColor(mat.GetColor(TipColorId)); + if (mat.HasProperty(RootAOId)) _rootAO = mat.GetFloat(RootAOId); + if (mat.HasProperty(RimPowerId)) _rimPower = mat.GetFloat(RimPowerId); + if (mat.HasProperty(RimStrengthId)) _rimStrength = mat.GetFloat(RimStrengthId); + if (mat.HasProperty(WindStrengthId)) _windStrength = mat.GetFloat(WindStrengthId); + if (mat.HasProperty(WindSpeedId)) _windSpeed = mat.GetFloat(WindSpeedId); + if (mat.HasProperty(WindFrequencyId)) _windFrequency = mat.GetFloat(WindFrequencyId); + if (mat.HasProperty(WindDirectionId)) + { + Vector4 w = mat.GetVector(WindDirectionId); + _windDirection = new Vector2(w.x, w.y); + } + } + } +} diff --git a/Assets/Scripts/Components/FurShellRenderer.cs.meta b/Assets/Scripts/Components/FurShellRenderer.cs.meta new file mode 100644 index 0000000..1f5c4ea --- /dev/null +++ b/Assets/Scripts/Components/FurShellRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a3c62508e4154584680226a26d48f0af \ No newline at end of file diff --git a/Assets/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader new file mode 100644 index 0000000..bcfa8f6 --- /dev/null +++ b/Assets/Shaders/FurShell.shader @@ -0,0 +1,273 @@ +// Generic shell-texturing fur/hair/grass shader (URP). STANDALONE prototype - NOT wired into the +// MeshPainter paint pipeline yet. The illusion of strands comes from rendering the mesh many times +// ("shells"), each extruded a little further along its normals, and alpha-clipping a per-cell strand +// mask that thins toward the tip. It is generic because it needs nothing but positions + normals + a +// UV set - it works on any mesh. +// +// DISPATCH: the shells are drawn by FurShellRenderer.cs via Graphics.RenderMeshInstanced. Each +// instance is one shell layer; the layer index arrives as SV_InstanceID and drives the extrusion +// height. Put this material on FurShellRenderer, NOT on a plain MeshRenderer (a MeshRenderer draws a +// single instance => only the root shell would show). The renderer is the single source of truth: it +// pushes EVERY tunable parameter below via a MaterialPropertyBlock, so the values authored on the +// material are only template defaults (they get overridden live at render time). +// +// hair vs fur vs grass = the same technique, only parameters differ (length, density, taper, bend). +Shader "Custom/FurShell" +{ + Properties + { + [Header(Fur Shape)] + _ShellCount("Shell Count (driven by FurShellRenderer)", Range(1, 64)) = 32 + _ShellLength("Fur Length", Range(0.0, 2.0)) = 0.15 + _Density("Strand Density", Range(1.0, 1000.0)) = 250 + _Thickness("Strand Thickness", Range(0.0, 1.0)) = 0.5 + _Taper("Tip Taper", Range(0.1, 8.0)) = 2.0 + _TipFill("Tip Fill (bridge shell gaps)", Range(0.0, 1.0)) = 0.35 + _Curvature("Gravity / Curl Bend", Range(0.0, 1.0)) = 0.25 + _DirRandomness("Direction Randomness (per strand)", Range(0.0, 1.0)) = 0.3 + _SilhouetteStretch("Silhouette Strand Stretch", Range(0.0, 5.0)) = 2.0 + + [Header(Fur Shading)] + [MainTexture] _BaseMap("Albedo", 2D) = "white" {} + [MainColor] _BaseColor("Root Tint", Color) = (0.42, 0.32, 0.20, 1) + _TipColor("Tip Tint", Color) = (0.62, 0.52, 0.36, 1) + _RootAO("Root Darkening (fake AO)", Range(0.0, 1.0)) = 0.45 + _RimPower("Rim / Backlight Power", Range(0.5, 8.0)) = 3.0 + _RimStrength("Rim / Backlight Strength", Range(0.0, 2.0)) = 0.4 + + [Header(Wind)] + _WindStrength("Wind Strength", Range(0.0, 1.0)) = 0.1 + _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) + } + + SubShader + { + Tags + { + "RenderType" = "Opaque" + "RenderPipeline" = "UniversalPipeline" + "Queue" = "Geometry" + } + LOD 200 + + Pass + { + Name "FurForward" + Tags { "LightMode" = "UniversalForward" } + + Cull Back + ZWrite On + ZTest LEqual + + HLSLPROGRAM + #pragma target 3.5 + #pragma vertex vert + #pragma fragment frag + + #pragma multi_compile_instancing + #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN + #pragma multi_compile_fragment _ _SHADOWS_SOFT + #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS + #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" + + CBUFFER_START(UnityPerMaterial) + float4 _BaseMap_ST; + half4 _BaseColor; + half4 _TipColor; + float _ShellCount; + float _ShellLength; + float _Density; + float _Thickness; + float _Taper; + float _TipFill; + float _Curvature; + float _DirRandomness; + float _SilhouetteStretch; + half _RootAO; + half _RimPower; + half _RimStrength; + float _WindStrength; + float _WindSpeed; + float _WindFrequency; + float4 _WindDirection; + CBUFFER_END + + TEXTURE2D(_BaseMap); + SAMPLER(sampler_BaseMap); + + struct Attributes + { + float4 positionOS : POSITION; + float3 normalOS : NORMAL; + float2 uv : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + float2 uv : TEXCOORD0; + float3 normalWS : TEXCOORD1; + float3 positionWS : TEXCOORD2; + float shellH : TEXCOORD3; // 0 = root, 1 = tip + }; + + // cheap deterministic 2D hash -> [0,1) + float Hash21(float2 p) + { + p = frac(p * float2(123.34, 345.45)); + p += dot(p, p + 34.345); + return frac(p.x * p.y); + } + + Varyings vert(Attributes input) + { + Varyings output = (Varyings)0; + UNITY_SETUP_INSTANCE_ID(input); + + // shell layer index -> normalized height (guarded so the non-instanced variant compiles) + #if UNITY_ANY_INSTANCING_ENABLED + uint shellIndex = input.instanceID; + #else + uint shellIndex = 0u; + #endif + float count = max(_ShellCount, 1.0); + float h = (count <= 1.0) ? 0.0 : (float)shellIndex / (count - 1.0); + + // extrude along the normal; bend the tips down with h^2 for a gravity/curl droop + float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * h); + posOS += float3(0.0, -1.0, 0.0) * (_Curvature * _ShellLength * h * h); + + // 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, + // and the h^2 weight keeps roots planted while tips travel most => the stacked shells read + // as strands bending in the wind. + float2 wdir = _WindDirection.xy; + 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)); + + VertexPositionInputs vp = GetVertexPositionInputs(posOS); + VertexNormalInputs vn = GetVertexNormalInputs(input.normalOS); + + output.positionCS = vp.positionCS; + output.positionWS = vp.positionWS; + output.normalWS = vn.normalWS; + output.uv = TRANSFORM_TEX(input.uv, _BaseMap); + output.shellH = h; + return output; + } + + half4 frag(Varyings input) : SV_Target + { + float h = input.shellH; + + float3 N = normalize(input.normalWS); + float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS); + + // --- strand mask: one strand per cell, random length, radially tapered toward the tip --- + float2 g = input.uv * _Density; + float2 cell = floor(g); + float strandLen = Hash21(cell); // this strand's random max height (0..1) + + // per-strand lean: shift the cell-local strand centre by a random direction that grows + // with height, so each strand tilts its own way. This is a fragment-space lean - geometry + // is shared per vertex while strands are per cell, so the tilt has to live here, not in the + // extrusion. + float rndAngle = Hash21(cell + 3.7) * 6.2831853; + float2 leanDir = float2(cos(rndAngle), sin(rndAngle)); + float2 local = (frac(g) * 2.0 - 1.0) - leanDir * (_DirRandomness * h); + + // silhouette stretch: at grazing angles the flat shell slices are viewed edge-on, so a + // strand breaks into squished, separated dots. Elongate the strand footprint along the + // tangent-space view direction - the axis the extruded shells drift apart on screen - so + // consecutive slices overlap into a continuous strand near the rim. The tangent frame is + // rebuilt from screen-space derivatives (Schueler's cotangent trick), so this stays generic: + // no per-mesh tangents required. graze^2 confines the effect to the silhouette, leaving the + // head-on look untouched. The branch is on a uniform, so the derivatives stay quad-uniform + // (safe) and the cost is skipped when the feature is off; at stretch==1 the basis rotation is + // a pure rotation => identity, so ramping it in is continuous. + if (_SilhouetteStretch > 0.0) + { + float graze = 1.0 - saturate(dot(N, V)); + graze *= graze; + + float3 dp1 = ddx(input.positionWS); + float3 dp2 = ddy(input.positionWS); + float2 duv1 = ddx(input.uv); + float2 duv2 = ddy(input.uv); + float3 dp2perp = cross(dp2, N); + float3 dp1perp = cross(N, dp1); + float3 T = dp2perp * duv1.x + dp1perp * duv2.x; + float3 B = dp2perp * duv1.y + dp1perp * duv2.y; + float invmax = rsqrt(max(dot(T, T), dot(B, B))); + T *= invmax; B *= invmax; + + float2 vt = float2(dot(V, T), dot(V, B)); + float vlen = length(vt); + float2 sdir = (vlen > 1e-4) ? (vt / vlen) : float2(1.0, 0.0); + + float stretch = 1.0 + _SilhouetteStretch * graze; + float along = dot(local, sdir) / stretch; // compress coord => elongate footprint along sdir + float perp = dot(local, float2(-sdir.y, sdir.x)); + local = float2(along, perp); + } + + float dist = length(local); + + // allowed radius shrinks with height => the strand narrows toward its tip. _TipFill is a + // floor on that radius (as a fraction of the base thickness): the taper only thins a strand + // down to _TipFill, never to a point. With few shells the pointy tip breaks into floating + // discs ("dots") because consecutive shell slices stop overlapping; holding a minimum radius + // keeps neighbouring slices wide enough to bridge the inter-shell gap. 0 = sharp needles + // (dots return), higher = fuller, slightly rounded tips. + float taperedR = pow(saturate(1.0 - h), _Taper * 0.5); + float radius = _Thickness * max(taperedR, _TipFill); + + // the root shell (h==0) stays fully solid so nothing shows through to the surface + half alpha = 1.0h; + if (h > 0.0) + alpha = (dist < radius && h <= strandLen) ? 1.0h : 0.0h; + clip(alpha - 0.5h); + + // --- shading (lightweight URP) --- + half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; + albedo = lerp(albedo, _TipColor.rgb, h); + half ao = lerp(1.0h - _RootAO, 1.0h, h); // roots darker (fake self-occlusion) + albedo *= ao; + + float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS); + Light mainLight = GetMainLight(shadowCoord); + half ndotl = saturate(dot(N, mainLight.direction)) * 0.5h + 0.5h; // soft half-lambert + half3 lighting = mainLight.color * (ndotl * mainLight.shadowAttenuation); + + half3 ambient = SampleSH(N); + half3 color = albedo * (lighting + ambient); + + #ifdef _ADDITIONAL_LIGHTS + uint addCount = GetAdditionalLightsCount(); + for (uint i = 0u; i < addCount; i++) + { + Light lt = GetAdditionalLight(i, input.positionWS); + half nl = saturate(dot(N, lt.direction)) * 0.5h + 0.5h; + color += albedo * lt.color * (nl * lt.distanceAttenuation * lt.shadowAttenuation); + } + #endif + + // rim / backlight scatter: brighter fuzz at grazing angles, strongest at the tips + half rim = pow(saturate(1.0h - saturate(dot(N, V))), _RimPower) * _RimStrength * h; + color += rim * mainLight.color * albedo; + + return half4(color, 1.0h); + } + ENDHLSL + } + } + FallBack Off +} diff --git a/Assets/Shaders/FurShell.shader.meta b/Assets/Shaders/FurShell.shader.meta new file mode 100644 index 0000000..e2f4aee --- /dev/null +++ b/Assets/Shaders/FurShell.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: bb87d19898c0c6c4f900688f146021bd +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: