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: