Sweep over 48 files (Editor/ excluded): remove comments that restate the code, condense class summaries, fix three stale doc statements (HsvColorPicker alpha bar, PaintHud layoutPrefab, MasterVolume FallbackDefault). Inline two single-use wrappers (IsPointerOverUi, BuildModes), drop an unused using, replace a magic literal with the existing ParallaxBase constant, and convert ~10 UnityEngine.Object null checks to the implicit-bool convention. No serialized fields, signatures, RPC/NetworkVariable names, defaults, or #if blocks touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
19 KiB
C#
321 lines
19 KiB
C#
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("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;
|
|
// 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")]
|
|
// The fur draws up to _shellCount stacked TRANSPARENT shells, so the mirrored X-ray glow composites
|
|
// once per overlapping layer and blows out; this scales the per-shell glow down so the total reads
|
|
// at roughly the body's intensity. Lower = subtler.
|
|
[SerializeField, Range(0f, 1f)] float _xrayFurStrength = 0.2f;
|
|
|
|
[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 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 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<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>();
|
|
_painter = GetComponent<MeshPainter>();
|
|
_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);
|
|
|
|
// 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(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(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);
|
|
}
|
|
}
|
|
}
|
|
}
|