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 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf4297a3b1
commit
43791abc79
2 files changed
+84
-17
No files matched your search
@@ -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<MeshFilter>();
|
||||
_mesh = mf ? mf.sharedMesh : null;
|
||||
_meshFilter = GetComponent<MeshFilter>();
|
||||
// On a skinned avatar the fur borrows this component's per-frame baked pose instead of a
|
||||
// static MeshFilter mesh (see ResolveMesh).
|
||||
_baker = GetComponent<SkinnedColliderBaker>();
|
||||
_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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user