using UnityEngine; using UnityEngine.UI; #if UNITY_2018_1_OR_NEWER using TMPro; #endif namespace NLD.UI { public enum ShadowBlendMode { Normal, Multiply, Additive } [ExecuteAlways] [DisallowMultipleComponent] [AddComponentMenu("UI/Common UI Shadow")] public class CommonUIShadow : MonoBehaviour, IMaterialModifier { private static readonly int ShadowColorID = Shader.PropertyToID("_ShadowColor"); private static readonly int ShadowOffsetID = Shader.PropertyToID("_ShadowOffset"); private static readonly int ShadowSkewID = Shader.PropertyToID("_ShadowSkew"); private static readonly int ShadowSoftnessID = Shader.PropertyToID("_ShadowSoftness"); private static readonly int ShadowBlurID = Shader.PropertyToID("_ShadowBlur"); private static readonly int ShadowBlurIntensityID = Shader.PropertyToID("_ShadowBlurIntensity"); [Header("Shadow Settings")] [Tooltip("Size/softness of the shadow (for Image only, TMP shadow is always sharp)")] [Range(0f, 20f)] [SerializeField] private float _size = 0f; [Tooltip("Direction to offset shadow (0-360 degrees)")] [Range(0f, 360f)] [SerializeField] private float _offsetAngle = 135f; [Tooltip("Offset distance (no limit for TMP)")] [SerializeField] private float _offsetDistance = 30f; [Header("Skew")] [Tooltip("Horizontal skew")] [Range(-1f, 1f)] [SerializeField] private float _skewX = 0f; [Tooltip("Vertical skew")] [Range(-1f, 1f)] [SerializeField] private float _skewY = 0f; [Header("Appearance")] [SerializeField] private Color _color = new Color(0f, 0f, 0f, 0.5f); [Tooltip("Blur intensity: 0 = sharp edge, 1 = fully blurred (controls gradient width in remaining SDF space)")] [Range(0f, 1f)] [SerializeField] private float _blurIntensity = 0f; [Tooltip("Blur range: total size of the shadow effect (0-10 range)")] [Min(0f)] [SerializeField] private float _blurRange = 10f; [Tooltip("Solid core: fraction of the blur range that is fully opaque (0 = all gradient, 1 = solid fills full range then gradient beyond)")] [Range(0f, 1f)] [SerializeField] private float _solidCore = 0f; [SerializeField] private ShadowBlendMode _blendMode = ShadowBlendMode.Normal; private enum ShadowMode { None, Shader, ChildObject } private ShadowMode _mode = ShadowMode.None; private static Shader _shadowShader; private Material _modifiedMaterial; private Graphic _graphic; private GameObject _contentObject; private RectTransform _contentRect; private GameObject _shadowObject; private RectTransform _shadowRect; private RectTransform _selfRect; #if UNITY_2018_1_OR_NEWER private TextMeshProUGUI _contentTMP; private TextMeshProUGUI _shadowTMP; private TextMeshProUGUI _sourceTMP; [SerializeField] private Color _originalColor = Color.white; [SerializeField] private bool _colorInitialized = false; private bool _isDirty = true; private string _cachedText = null; private TMP_FontAsset _cachedFont = null; private float _cachedFontSize = 0f; private Vector2 _cachedOffset = Vector2.zero; private int _skipFrames = 0; private const int SYNC_INTERVAL = 3; #endif #region Properties public float Size { get => _size; set { _size = Mathf.Max(0f, value); MarkDirty(); UpdateShadow(); } } public float OffsetAngle { get => _offsetAngle; set { _offsetAngle = (value + 360f) % 360f; MarkDirty(); UpdateShadow(); } } public float OffsetDistance { get => _offsetDistance; set { _offsetDistance = value; MarkDirty(); UpdateShadow(); } } public float SkewX { get => _skewX; set { _skewX = Mathf.Clamp(value, -1f, 1f); MarkDirty(); UpdateShadow(); } } public float SkewY { get => _skewY; set { _skewY = Mathf.Clamp(value, -1f, 1f); MarkDirty(); UpdateShadow(); } } public Color Color { get => _color; set { _color = value; MarkDirty(); UpdateShadow(); } } public ShadowBlendMode BlendMode { get => _blendMode; set { _blendMode = value; MarkDirty(); UpdateShadow(); } } public float BlurIntensity { get => _blurIntensity; set { _blurIntensity = Mathf.Clamp01(value); MarkDirty(); UpdateShadow(); } } public float BlurRange { get => _blurRange; set { _blurRange = Mathf.Max(0f, value); MarkDirty(); UpdateShadow(); } } public float SolidCore { get => _solidCore; set { _solidCore = Mathf.Clamp01(value); MarkDirty(); UpdateShadow(); } } #endregion private void MarkDirty() { #if UNITY_2018_1_OR_NEWER _isDirty = true; #endif } private void OnEnable() { _selfRect = GetComponent(); DetectMode(); if (_mode == ShadowMode.Shader) { _graphic = GetComponent(); EnsureShaderLoaded(); if (_graphic != null) _graphic.SetMaterialDirty(); } else if (_mode == ShadowMode.ChildObject) { CreateShadowObject(); } UpdateShadow(); } private void OnDisable() { CleanupShaderMode(); CleanupChildMode(); } private void OnDestroy() { CleanupShaderMode(); CleanupChildMode(); } private void LateUpdate() { if (_mode == ShadowMode.ChildObject) { #if UNITY_2018_1_OR_NEWER if (_sourceTMP != null) { bool contentChanged = _isDirty || _cachedText != _sourceTMP.text || _cachedFont != _sourceTMP.font || !Mathf.Approximately(_cachedFontSize, _sourceTMP.fontSize); if (contentChanged) { SyncTMPContent(); _isDirty = false; _skipFrames = 0; } else { _skipFrames++; if (_skipFrames >= SYNC_INTERVAL) { SyncTMPContent(); _skipFrames = 0; } } } #endif } } #if UNITY_EDITOR private void OnValidate() { if (!isActiveAndEnabled) return; UnityEditor.EditorApplication.delayCall += () => { if (this == null) return; DetectMode(); UpdateShadow(); }; } #endif private void DetectMode() { #if UNITY_2018_1_OR_NEWER if (GetComponent() != null) { _mode = ShadowMode.ChildObject; return; } #endif if (GetComponent() != null || GetComponent() != null) { _mode = ShadowMode.Shader; return; } _mode = ShadowMode.ChildObject; } private void UpdateShadow() { if (_mode == ShadowMode.Shader) { if (_graphic != null) _graphic.SetMaterialDirty(); } else if (_mode == ShadowMode.ChildObject) { UpdateChildTransforms(); UpdateChildColors(); } } #region Shader Mode private static void EnsureShaderLoaded() { if (_shadowShader == null) { _shadowShader = Shader.Find("NLD/UI/CommonUIShadow"); } } public Material GetModifiedMaterial(Material baseMaterial) { if (!isActiveAndEnabled || _mode != ShadowMode.Shader || _shadowShader == null) return baseMaterial; if (_modifiedMaterial == null) { _modifiedMaterial = new Material(_shadowShader); _modifiedMaterial.hideFlags = HideFlags.HideAndDontSave; } _modifiedMaterial.mainTexture = baseMaterial.mainTexture; if (baseMaterial.HasProperty("_Color")) _modifiedMaterial.SetColor("_Color", baseMaterial.GetColor("_Color")); UpdateShaderProperties(_modifiedMaterial); return _modifiedMaterial; } private void UpdateShaderProperties(Material mat) { float angleRad = _offsetAngle * Mathf.Deg2Rad; Vector2 offset = new Vector2( Mathf.Cos(angleRad) * _offsetDistance, Mathf.Sin(angleRad) * _offsetDistance ); Color shadowColor = GetProcessedColor(); mat.SetColor(ShadowColorID, shadowColor); mat.SetVector(ShadowOffsetID, new Vector4(offset.x, offset.y, 0, 0)); mat.SetVector(ShadowSkewID, new Vector4(_skewX, _skewY, 0, 0)); mat.SetFloat(ShadowSoftnessID, _size * 0.1f); mat.SetFloat(ShadowBlurID, _blurRange); mat.SetFloat(ShadowBlurIntensityID, _blurIntensity); } private void CleanupShaderMode() { if (_modifiedMaterial != null) { #if UNITY_EDITOR if (!Application.isPlaying) DestroyImmediate(_modifiedMaterial); else #endif Destroy(_modifiedMaterial); _modifiedMaterial = null; } if (_graphic != null) _graphic.SetMaterialDirty(); } #endregion #region Child Object Mode private void CreateShadowObject() { if (_shadowObject != null || _contentObject != null) return; #if UNITY_2018_1_OR_NEWER _sourceTMP = GetComponent(); if (_sourceTMP == null) return; if (!_colorInitialized) { _originalColor = _sourceTMP.color; _colorInitialized = true; } _sourceTMP.color = new Color(0, 0, 0, 0); _isDirty = true; _skipFrames = 0; _shadowObject = new GameObject("_Shadow"); _shadowObject.hideFlags = HideFlags.DontSave | HideFlags.NotEditable; _shadowRect = _shadowObject.AddComponent(); _shadowObject.transform.SetParent(transform, false); _shadowObject.transform.SetAsFirstSibling(); _shadowRect.anchorMin = Vector2.zero; _shadowRect.anchorMax = Vector2.one; _shadowRect.offsetMin = Vector2.zero; _shadowRect.offsetMax = Vector2.zero; _shadowRect.pivot = _selfRect.pivot; _shadowTMP = _shadowObject.AddComponent(); _shadowTMP.raycastTarget = false; _contentObject = new GameObject("_Content"); _contentObject.hideFlags = HideFlags.DontSave | HideFlags.NotEditable; _contentRect = _contentObject.AddComponent(); _contentObject.transform.SetParent(transform, false); _contentObject.transform.SetAsLastSibling(); _contentRect.anchorMin = Vector2.zero; _contentRect.anchorMax = Vector2.one; _contentRect.offsetMin = Vector2.zero; _contentRect.offsetMax = Vector2.zero; _contentRect.pivot = _selfRect.pivot; _contentTMP = _contentObject.AddComponent(); _contentTMP.raycastTarget = false; SyncTMPContent(); UpdateChildColors(); UpdateChildTransforms(); #endif } private void CleanupChildMode() { #if UNITY_2018_1_OR_NEWER if (_sourceTMP != null && _colorInitialized) { _sourceTMP.color = _originalColor; } #endif if (_contentObject != null) { #if UNITY_EDITOR if (!Application.isPlaying) DestroyImmediate(_contentObject); else #endif Destroy(_contentObject); _contentObject = null; _contentRect = null; #if UNITY_2018_1_OR_NEWER _contentTMP = null; #endif } if (_shadowObject != null) { #if UNITY_EDITOR if (!Application.isPlaying) DestroyImmediate(_shadowObject); else #endif Destroy(_shadowObject); _shadowObject = null; _shadowRect = null; #if UNITY_2018_1_OR_NEWER _shadowTMP = null; _colorInitialized = false; #endif } } #if UNITY_2018_1_OR_NEWER private void SyncTMPContent() { if (_sourceTMP == null || _shadowTMP == null || _contentTMP == null) return; if (_sourceTMP.font == null) return; if (!_colorInitialized) { _originalColor = _sourceTMP.color; _colorInitialized = true; _sourceTMP.color = new Color(0, 0, 0, 0); } _cachedText = _sourceTMP.text; _cachedFont = _sourceTMP.font; _cachedFontSize = _sourceTMP.fontSize; SyncTMPProperties(_contentTMP); SyncTMPProperties(_shadowTMP); _contentTMP.color = _originalColor; _shadowTMP.color = GetProcessedColor(); ApplyTMPShadowBlur(); ExpandShadowVerticesPerCharacter(); Vector2 currentOffset = CalculateOffset(); if (_cachedOffset != currentOffset) { UpdateChildTransforms(); _cachedOffset = currentOffset; } } private Vector2 CalculateOffset() { float angleRad = _offsetAngle * Mathf.Deg2Rad; return new Vector2( Mathf.Cos(angleRad) * _offsetDistance, Mathf.Sin(angleRad) * _offsetDistance ); } private void SyncTMPProperties(TextMeshProUGUI target) { if (target == null || _sourceTMP == null) return; target.text = _sourceTMP.text; target.font = _sourceTMP.font; target.fontSize = _sourceTMP.fontSize; target.fontStyle = _sourceTMP.fontStyle; target.fontWeight = _sourceTMP.fontWeight; target.alignment = _sourceTMP.alignment; if (target.enableWordWrapping != _sourceTMP.enableWordWrapping) target.enableWordWrapping = _sourceTMP.enableWordWrapping; if (target.overflowMode != _sourceTMP.overflowMode) target.overflowMode = _sourceTMP.overflowMode; } private void ApplyTMPShadowBlur() { if (_shadowTMP == null) return; // totalExtent: total shadow size in SDF space (range 0-10 maps to 0-1) float totalExtent = Mathf.Clamp01(_blurRange * 0.1f); // solidCore controls what fraction of totalExtent is fully opaque: // 0 = no solid core (all gradient) → 1 = solid fills full range then gradient beyond float faceDilate = totalExtent * _solidCore; // intensity controls gradient width in remaining SDF space (prevents inward blur at high faceDilate) float outlineSoftness = _blurIntensity * (1f - faceDilate); // Use fontMaterials (plural) to cover all sub-meshes including CJK fallback fonts var materials = _shadowTMP.fontMaterials; foreach (var mat in materials) { if (mat == null) continue; mat.SetFloat(ShaderUtilities.ID_FaceDilate, faceDilate); mat.SetFloat(ShaderUtilities.ID_OutlineSoftness, outlineSoftness); } } private void ExpandShadowVerticesPerCharacter() { if (_shadowTMP == null || _blurRange <= 0f) return; _shadowTMP.ForceMeshUpdate(); var textInfo = _shadowTMP.textInfo; if (textInfo == null) return; float scale = 1f + _blurRange * 0.02f; for (int i = 0; i < textInfo.characterCount; i++) { var charInfo = textInfo.characterInfo[i]; if (!charInfo.isVisible) continue; int meshIndex = charInfo.materialReferenceIndex; int vi = charInfo.vertexIndex; var meshInfo = textInfo.meshInfo[meshIndex]; // Character center (average of its 4 quad vertices) Vector3 vCenter = (meshInfo.vertices[vi] + meshInfo.vertices[vi + 1] + meshInfo.vertices[vi + 2] + meshInfo.vertices[vi + 3]) * 0.25f; // UV center (average of its 4 quad UVs, used for SDF scaling) Vector2 uvCenter = (meshInfo.uvs0[vi] + meshInfo.uvs0[vi + 1] + meshInfo.uvs0[vi + 2] + meshInfo.uvs0[vi + 3]) * 0.25f; for (int j = 0; j < 4; j++) { // Expand vertex position outward from this character's own center meshInfo.vertices[vi + j] = vCenter + (meshInfo.vertices[vi + j] - vCenter) * scale; // Contract UV inward so SDF boundary maps to the expanded position meshInfo.uvs0[vi + j] = uvCenter + (meshInfo.uvs0[vi + j] - uvCenter) / scale; } } // Push modified geometry back to TMP's mesh for (int i = 0; i < textInfo.materialCount; i++) { var meshInfo = textInfo.meshInfo[i]; if (meshInfo.mesh == null) continue; meshInfo.mesh.vertices = meshInfo.vertices; meshInfo.mesh.uv = meshInfo.uvs0; _shadowTMP.UpdateGeometry(meshInfo.mesh, i); } } #endif private void UpdateChildColors() { #if UNITY_2018_1_OR_NEWER if (_contentTMP != null) _contentTMP.color = _originalColor; if (_shadowTMP != null) { _shadowTMP.color = GetProcessedColor(); ApplyTMPShadowBlur(); } #endif } private void UpdateChildTransforms() { if (_shadowRect == null || _contentRect == null || _selfRect == null) return; Vector2 offset = (_cachedOffset != Vector2.zero) ? _cachedOffset : CalculateOffset(); _contentRect.anchoredPosition = Vector2.zero; _shadowRect.anchoredPosition = offset; bool hasSkew = !Mathf.Approximately(_skewX, 0f) || !Mathf.Approximately(_skewY, 0f); if (hasSkew) { float skewAngle = Mathf.Atan2(_skewX + _skewY, 2f) * Mathf.Rad2Deg; _shadowRect.localRotation = Quaternion.Euler(0, 0, skewAngle * 0.5f); float scaleX = 1f + Mathf.Abs(_skewY) * 0.15f; float scaleY = 1f + Mathf.Abs(_skewX) * 0.15f; _shadowRect.localScale = new Vector3(scaleX, scaleY, 1f); } else { _shadowRect.localRotation = Quaternion.identity; _shadowRect.localScale = Vector3.one; } _contentRect.localRotation = Quaternion.identity; _contentRect.localScale = Vector3.one; } private void CopyRectTransform(RectTransform source, RectTransform target) { target.anchorMin = source.anchorMin; target.anchorMax = source.anchorMax; target.pivot = source.pivot; target.sizeDelta = source.sizeDelta; } #endregion private Color GetProcessedColor() { Color shadowColor = _color; switch (_blendMode) { case ShadowBlendMode.Multiply: shadowColor = new Color(_color.r * 0.7f, _color.g * 0.7f, _color.b * 0.7f, _color.a); break; case ShadowBlendMode.Additive: shadowColor = new Color(_color.r, _color.g, _color.b, _color.a * 0.6f); break; } return shadowColor; } public void SetOffset(float x, float y) { _offsetDistance = Mathf.Sqrt(x * x + y * y); _offsetAngle = Mathf.Atan2(y, x) * Mathf.Rad2Deg; if (_offsetAngle < 0) _offsetAngle += 360f; MarkDirty(); UpdateShadow(); } } }