From 43791abc7936fe93ab30bda2a51b3d93f625a895 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 3 Jul 2026 20:47:15 +0200 Subject: [PATCH] Add fur-map support, skinned driver, and world-down curvature to fur shell FurShell now reads an RGBA fur map (RGB = per-texel strand colour, A = fur length) so the standalone look can be driven per-texel. Alpha 0 clips the whole shell stack (root included) so a painted body shows through; _SolidRoot keeps the opaque base shell only for the full-coverage prototype. White map default keeps the standalone prototype unchanged. FurShellRenderer drops the MeshFilter requirement and resolves its mesh per frame: on a SkinnedMeshRenderer it borrows the SkinnedColliderBaker's per-frame baked pose so the fur follows the animation with no extra bake. Curvature/gravity now bends strands toward WORLD down (converted into object space via unity_WorldToObject) instead of object-local down, so a rotated player mesh droops downward correctly. Identity rotation is unchanged, so the test sphere is unaffected. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/Components/FurShellRenderer.cs | 57 +++++++++++++++---- Assets/Shaders/FurShell.shader | 44 ++++++++++++-- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index 8cc7ec3..ab2728e 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -5,8 +5,10 @@ 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. + // index from SV_InstanceID in the shader to pick its extrusion height. Put this next to a MeshFilter + // (static mesh) OR on an animated character's SkinnedMeshRenderer that already carries a + // SkinnedColliderBaker - then the fur borrows that component's per-frame baked pose and follows the + // animation. Assign the FurShell material either way. // // 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 @@ -18,7 +20,6 @@ namespace Chameleon.Components // 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")] @@ -26,9 +27,9 @@ namespace Chameleon.Components [SerializeField, Range(1, 64)] int _shellCount = 32; [Header("Shape")] - [SerializeField, Range(0f, 2f)] float _shellLength = 0.15f; + [SerializeField, Range(0f, 10f)] float _shellLength = 0.15f; [SerializeField, Range(1f, 1000f)] float _density = 250f; - [SerializeField, Range(0f, 1f)] float _thickness = 0.5f; + [SerializeField, Range(0f, 4f)] 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. @@ -50,6 +51,17 @@ namespace Chameleon.Components [SerializeField, Range(0.5f, 8f)] float _rimPower = 3f; [SerializeField, Range(0f, 2f)] float _rimStrength = 0.4f; + [Header("Fur Map")] + // RGBA fur map: RGB = per-texel strand colour, A = fur length (0 = bald, 1 = full length). Leave + // null on the standalone prototype - the shader's white default is uniform full-length fur. + [SerializeField] Texture _furMap; + // Fur is fully cut where the map's alpha is at/below this - keeps stray dilation fuzz from growing + // hair. 0 keeps the exact "alpha 0 = no fur" contract. + [SerializeField, Range(0f, 1f)] float _furThreshold = 0f; + // Opaque base shell. On for the standalone prototype (full coverage); turn OFF on the painted + // avatar so the camo body shows between strands instead of being sealed under a solid root. + [SerializeField] bool _solidRoot = true; + [Header("Wind")] [SerializeField, Range(0f, 1f)] float _windStrength = 0.1f; [SerializeField, Range(0f, 10f)] float _windSpeed = 2f; @@ -79,19 +91,25 @@ namespace Chameleon.Components static readonly int RootAOId = Shader.PropertyToID("_RootAO"); static readonly int RimPowerId = Shader.PropertyToID("_RimPower"); static readonly int RimStrengthId = Shader.PropertyToID("_RimStrength"); + static readonly int FurMapId = Shader.PropertyToID("_FurMap"); + static readonly int FurThresholdId = Shader.PropertyToID("_FurThreshold"); + static readonly int SolidRootId = Shader.PropertyToID("_SolidRoot"); 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; + MeshFilter _meshFilter; + SkinnedColliderBaker _baker; Matrix4x4[] _instances; MaterialPropertyBlock _mpb; void OnEnable() { - var mf = GetComponent(); - _mesh = mf ? mf.sharedMesh : null; + _meshFilter = GetComponent(); + // On a skinned avatar the fur borrows this component's per-frame baked pose instead of a + // static MeshFilter mesh (see ResolveMesh). + _baker = GetComponent(); _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. @@ -128,9 +146,21 @@ namespace Chameleon.Components if (mr) mr.enabled = enabled; } + // Skinned avatar: borrow the per-frame baked pose (local space, unit scale) that + // SkinnedColliderBaker already maintains for the collider - RenderMeshInstanced re-reads the mesh + // each draw, so the fur follows the animation with no extra bake. Static mesh: the MeshFilter's + // shared mesh. Resolved per frame because the skinned mesh is re-baked in place (same instance, + // fresh vertices), and the baker may not have run its first bake when OnEnable fires. + Mesh ResolveMesh() + { + if (_baker) return _baker.BakedMesh; + return _meshFilter ? _meshFilter.sharedMesh : null; + } + void OnBeginCamera(ScriptableRenderContext ctx, Camera cam) { - if (!_furMaterial || !_mesh) return; + Mesh mesh = ResolveMesh(); + 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. @@ -155,8 +185,8 @@ namespace Chameleon.Components receiveShadows = _receiveShadows, }; - for (int sub = 0; sub < _mesh.subMeshCount; sub++) - Graphics.RenderMeshInstanced(rp, _mesh, sub, _instances, count); + for (int sub = 0; sub < mesh.subMeshCount; sub++) + Graphics.RenderMeshInstanced(rp, mesh, sub, _instances, count); } void PushProperties(int shellCount) @@ -176,11 +206,14 @@ namespace Chameleon.Components _mpb.SetFloat(SilhouetteStretchId, _silhouetteStretch); if (_baseMap) _mpb.SetTexture(BaseMapId, _baseMap); + if (_furMap) _mpb.SetTexture(FurMapId, _furMap); _mpb.SetColor(BaseColorId, ToRenderColor(_rootColor)); _mpb.SetColor(TipColorId, ToRenderColor(_tipColor)); _mpb.SetFloat(RootAOId, _rootAO); _mpb.SetFloat(RimPowerId, _rimPower); _mpb.SetFloat(RimStrengthId, _rimStrength); + _mpb.SetFloat(FurThresholdId, _furThreshold); + _mpb.SetFloat(SolidRootId, _solidRoot ? 1f : 0f); _mpb.SetFloat(WindStrengthId, _windStrength); _mpb.SetFloat(WindSpeedId, _windSpeed); @@ -217,6 +250,8 @@ namespace Chameleon.Components 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(FurThresholdId)) _furThreshold = mat.GetFloat(FurThresholdId); + if (mat.HasProperty(SolidRootId)) _solidRoot = mat.GetFloat(SolidRootId) > 0.5f; if (mat.HasProperty(WindStrengthId)) _windStrength = mat.GetFloat(WindStrengthId); if (mat.HasProperty(WindSpeedId)) _windSpeed = mat.GetFloat(WindSpeedId); if (mat.HasProperty(WindFrequencyId)) _windFrequency = mat.GetFloat(WindFrequencyId); diff --git a/Assets/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader index bcfa8f6..11f2a54 100644 --- a/Assets/Shaders/FurShell.shader +++ b/Assets/Shaders/FurShell.shader @@ -35,6 +35,15 @@ Shader "Custom/FurShell" _RimPower("Rim / Backlight Power", Range(0.5, 8.0)) = 3.0 _RimStrength("Rim / Backlight Strength", Range(0.0, 2.0)) = 0.4 + [Header(Fur Map)] + // RGB = per-texel strand colour, A = fur length (0 = bald here, 1 = full length). White default + // means "full-length fur everywhere" so the standalone prototype behaves as before with no map. + _FurMap("Fur Map (RGB color, A length)", 2D) = "white" {} + _FurThreshold("Fur Cutoff (alpha)", Range(0.0, 1.0)) = 0.0 + // Opaque base shell. On = solid root (full-coverage prototype); Off = the root also clips to the + // map so the painted body shows between strands (avatar integration). + [ToggleUI] _SolidRoot("Solid Root", Float) = 1 + [Header(Wind)] _WindStrength("Wind Strength", Range(0.0, 1.0)) = 0.1 _WindSpeed("Wind Speed", Range(0.0, 10.0)) = 2.0 @@ -91,6 +100,8 @@ Shader "Custom/FurShell" half _RootAO; half _RimPower; half _RimStrength; + float _FurThreshold; + float _SolidRoot; float _WindStrength; float _WindSpeed; float _WindFrequency; @@ -99,6 +110,8 @@ Shader "Custom/FurShell" TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); + TEXTURE2D(_FurMap); + SAMPLER(sampler_FurMap); struct Attributes { @@ -141,7 +154,12 @@ Shader "Custom/FurShell" // 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); + // gravity pulls toward WORLD down, so strands hang downward no matter how the mesh is + // oriented - a rotated player mesh would otherwise droop along its own local down. Convert + // world down into object space (the extrusion space) so the droop magnitude stays consistent + // with _ShellLength under the object-to-world transform. + float3 downOS = normalize(mul((float3x3)unity_WorldToObject, float3(0.0, -1.0, 0.0))); + posOS += downOS * (_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, @@ -171,6 +189,11 @@ Shader "Custom/FurShell" float3 N = normalize(input.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS); + // fur map: RGB = strand colour, A = fur length. White default (no map) => full-length fur + // everywhere, so the standalone prototype is unchanged. + half4 furSample = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv); + float furLen = furSample.a; + // --- strand mask: one strand per cell, random length, radially tapered toward the tip --- float2 g = input.uv * _Density; float2 cell = floor(g); @@ -230,14 +253,23 @@ Shader "Custom/FurShell" 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; + // The fur map's alpha caps this strand's height: 0 => bald, so the whole shell stack + // (root included) clips and the body shows through; 1 => the strand reaches its full + // random length. _SolidRoot keeps the base shell opaque for the full-coverage prototype; + // on the painted avatar it is turned OFF so the camo body stays visible between strands + // instead of being sealed under a solid root (which would also Z-fight the body). + float effectiveLen = strandLen * furLen; + half alpha; + if (furLen <= _FurThreshold) + alpha = 0.0h; + else if (h <= 0.0 && _SolidRoot > 0.5) + alpha = 1.0h; + else + alpha = (dist < radius && h <= effectiveLen) ? 1.0h : 0.0h; clip(alpha - 0.5h); // --- shading (lightweight URP) --- - half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; + half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * furSample.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;