using UnityEngine; using UnityEngine.Rendering; namespace Chameleon.Components { // Shell-texturing fur for FurShell.shader: draws the source mesh _shellCount times via GPU instancing // (Graphics.RenderMeshInstanced); each instance is one shell layer and picks its extrusion height from // SV_InstanceID in the shader. Put this next to a MeshFilter (static mesh) OR on a skinned avatar whose // SkinnedColliderBaker provides the per-frame baked pose the fur follows. // // SINGLE source of truth for the fur look: every tunable shader parameter is a serialized field here, // pushed via a MaterialPropertyBlock, so the material's own inspector sliders are only template // defaults. (Use the "Copy Values From Material" context menu to pull in a hand-tuned material.) // // 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("Distance LOD")] // Shell cost is linear in the shell count and nobody can count 32 shells from a few meters away: // full _shellCount up to Start, then a linear fade down to Min Shells at End (per camera, computed // at draw time). Min Shells 0 stops drawing the fur entirely beyond End. [SerializeField, Min(0f)] float _lodStartDistance = 8f; [SerializeField, Min(0f)] float _lodEndDistance = 30f; [SerializeField, Range(0, 64)] int _lodMinShells = 8; [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 albedo (strand-colour) override for the standalone prototype; a sibling MeshPainter's // live albedo RT overrides this when present, so the fur wears the painted camo per texel. [SerializeField] Texture _baseMap; // Overall tint multiplied onto the PAINTED fur colour; white leaves the painted colour untouched. [SerializeField] Color _furTint = Color.white; // Root/tip are two shades of the same painted colour: roots a touch darker, tips a touch lighter. [SerializeField, Range(0f, 1f)] float _rootAO = 0.3f; [SerializeField, Range(0f, 1f)] float _tipShade = 0.2f; [SerializeField, Range(0.5f, 8f)] float _rimPower = 3f; [SerializeField, Range(0f, 2f)] float _rimStrength = 0.4f; [Header("Fur Map")] // Fur map: R = fur length (0 = bald, 1 = full length); G/B/A unused. The strand colour comes from // the albedo map, not here. Leave null on the standalone prototype - the shader's white default is // uniform full-length fur. A sibling MeshPainter's live fur RT overrides this when present, so a // painted avatar grows fur in real time (see PushProperties). [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; // Low painted alpha => low fur opacity, so a lightly-painted region fades in as faint short fuzz // instead of hard-cutting to full-opacity fur. Larger = softer/longer fade. [SerializeField, Range(0.01f, 1f)] float _furSoftness = 0.5f; // Per-strand edge anti-aliasing (soft strand silhouette + tip instead of hard clipped discs). [SerializeField, Range(0f, 1f)] float _strandSoftness = 0.3f; // Alpha-cutout threshold: fragments at/below this coverage are discarded, everything above draws // fully opaque (the fur renders on the AlphaTest queue - opaque + ZWrite gives per-pixel z-order // between fur objects AND early-Z overdraw rejection). Higher = thinner/harder strands. [SerializeField, Range(0f, 1f)] float _depthSortClip = 0.3f; // 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("X-Ray Reveal")] // Fur-vs-body glow intensity match. Since the fur went opaque cutout only ONE fur fragment // survives per pixel (no shell stacking anymore), so this typically wants ~1 to match the body // instead of the old stack-taming ~0.2. [SerializeField, Range(0f, 1f)] float _xrayFurStrength = 1f; [Header("Rendering")] [SerializeField] ShadowCastingMode _shadowMode = ShadowCastingMode.Off; [SerializeField] bool _receiveShadows = true; // Same dropdown/semantics as MeshRenderer's Rendering Layer Mask (decal/light layer filtering). [SerializeField] RenderingLayerMask _renderingLayerMask = RenderingLayerMask.defaultRenderingLayerMask; // 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 RootAOId = Shader.PropertyToID("_RootAO"); static readonly int TipShadeId = Shader.PropertyToID("_TipShade"); 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 FurSoftnessId = Shader.PropertyToID("_FurSoftness"); static readonly int StrandSoftnessId = Shader.PropertyToID("_StrandSoftness"); static readonly int DepthSortClipId = Shader.PropertyToID("_DepthSortClip"); 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"); static readonly int FurObjScaleId = Shader.PropertyToID("_FurObjScale"); static readonly int XRayColorId = Shader.PropertyToID("_XRayColor"); static readonly int XRayPulseSpeedId = Shader.PropertyToID("_XRayPulseSpeed"); static readonly int XRayPulseStrengthId = Shader.PropertyToID("_XRayPulseStrength"); static readonly int XRayPulseShapeId = Shader.PropertyToID("_XRayPulseShape"); static readonly int XRayFurStrengthId = Shader.PropertyToID("_XRayFurStrength"); MeshFilter _meshFilter; SkinnedColliderBaker _baker; // Optional sibling paint source: when present, its live fur RT (RGB = colour, A = length) overrides // the serialized _furMap so painting the fur map drives the shells in real time. MeshPainter _painter; Matrix4x4[] _instances; MaterialPropertyBlock _mpb; void OnEnable() { _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(); _painter = 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. 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; } // 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; // Distance LOD: scale the shell count down with camera distance (full count inside Start, // Min Shells at/after End). Per camera on purpose - the draw is per camera anyway, so the // Scene View and a distant player camera each get their own appropriate count. int maxCount = Mathf.Max(1, _shellCount); float lodEnd = Mathf.Max(_lodEndDistance, _lodStartDistance + 0.001f); float lodT = Mathf.InverseLerp(_lodStartDistance, lodEnd, Vector3.Distance(cam.transform.position, transform.position)); int count = Mathf.RoundToInt(Mathf.Lerp(maxCount, Mathf.Min(_lodMinShells, maxCount), lodT)); if (count <= 0) return; // Min Shells 0 => beyond End the fur is culled entirely // The array stays at the max size; RenderMeshInstanced only reads the first `count` entries, // so LOD changes don't reallocate per frame. if (_instances == null || _instances.Length != maxCount) _instances = new Matrix4x4[maxCount]; // 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, renderingLayerMask = _renderingLayerMask, }; 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); // Strand colour = the painted albedo: prefer the sibling painter's live albedo RT over the // serialized override, so the fur wears the camo per texel. Re-fetched each frame - the RT // reference ping-pongs on the dilation swap. Texture albedoTex = _painter && _painter.AlbedoMapTexture ? _painter.AlbedoMapTexture : _baseMap; if (albedoTex) _mpb.SetTexture(BaseMapId, albedoTex); // Prefer the sibling painter's live fur RT (R = painted length) over the serialized map. Texture furTex = _painter && _painter.FurMapTexture ? _painter.FurMapTexture : _furMap; if (furTex) _mpb.SetTexture(FurMapId, furTex); _mpb.SetColor(BaseColorId, ToRenderColor(_furTint)); _mpb.SetFloat(RootAOId, _rootAO); _mpb.SetFloat(TipShadeId, _tipShade); _mpb.SetFloat(RimPowerId, _rimPower); _mpb.SetFloat(RimStrengthId, _rimStrength); _mpb.SetFloat(FurThresholdId, _furThreshold); _mpb.SetFloat(FurSoftnessId, _furSoftness); _mpb.SetFloat(StrandSoftnessId, _strandSoftness); _mpb.SetFloat(DepthSortClipId, _depthSortClip); _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)); // Avatar world scale (uniform via PlayerScaler). The shader keeps the fur a CONSTANT world length // but scales the gravity droop by this, so the bend tracks the player (hider 0.5 / hunter 1.5). // Read from lossyScale because the raw object matrix does NOT carry the per-instance scale in the // RenderMeshInstanced path. _mpb.SetFloat(FurObjScaleId, transform.lossyScale.y); // Mirror the sibling painter's live body X-ray reveal onto the fur so the strands glow WITH the // reveal instead of hiding it under opaque fur. Same colour/pulse the body uses => in phase. // ToRenderColor matches the raw MPB upload to Material.SetColor's gamma handling (same // convention as the fur tint above). Off (alpha 0) when no painter or not revealed => the // shader's additive term is zero, so the fur is unchanged. if (_painter) { _mpb.SetColor(XRayColorId, ToRenderColor(_painter.CurrentXRayColor)); _mpb.SetFloat(XRayPulseSpeedId, _painter.XRayPulseSpeed); _mpb.SetFloat(XRayPulseStrengthId, _painter.XRayPulseStrength); _mpb.SetFloat(XRayPulseShapeId, _painter.XRayPulseShape); _mpb.SetFloat(XRayFurStrengthId, _xrayFurStrength); } } // 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)) _furTint = FromRenderColor(mat.GetColor(BaseColorId)); if (mat.HasProperty(RootAOId)) _rootAO = mat.GetFloat(RootAOId); if (mat.HasProperty(TipShadeId)) _tipShade = mat.GetFloat(TipShadeId); 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(FurSoftnessId)) _furSoftness = mat.GetFloat(FurSoftnessId); if (mat.HasProperty(StrandSoftnessId)) _strandSoftness = mat.GetFloat(StrandSoftnessId); if (mat.HasProperty(DepthSortClipId)) _depthSortClip = mat.GetFloat(DepthSortClipId); 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); } } } }