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 <noreply@anthropic.com>
This commit is contained in:
1 parent
50efc7e8b3
commit
bf4297a3b1
4 files changed
+514
No files matched your search
@@ -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<MeshFilter>();
|
||||
_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<MeshRenderer>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3c62508e4154584680226a26d48f0af
|
||||
Reference in new issue
Block a user