Files
chameleon/Assets/Scripts/Components/FurShellRenderer.cs
T
adminandClaude Opus 4.8 43791abc79 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>
2026-07-03 20:47:15 +02:00

266 lines
15 KiB
C#

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. 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
// 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]
public sealed class FurShellRenderer : MonoBehaviour
{
[Header("Shells")]
[SerializeField] Material _furMaterial;
[SerializeField, Range(1, 64)] int _shellCount = 32;
[Header("Shape")]
[SerializeField, Range(0f, 10f)] float _shellLength = 0.15f;
[SerializeField, Range(1f, 1000f)] float _density = 250f;
[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.
[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("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;
[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 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");
MeshFilter _meshFilter;
SkinnedColliderBaker _baker;
Matrix4x4[] _instances;
MaterialPropertyBlock _mpb;
void OnEnable()
{
_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.
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<MeshRenderer>();
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)
{
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.
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);
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);
_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(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);
if (mat.HasProperty(WindDirectionId))
{
Vector4 w = mat.GetVector(WindDirectionId);
_windDirection = new Vector2(w.x, w.y);
}
}
}
}