// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE /* Original implementation provided by JWoe */ namespace WallstopStudios.UnityHelpers.Visuals.UGUI { using UnityEngine; using UnityEngine.Serialization; using UnityEngine.UI; /// /// Extends Unity's with per-instance material instancing, HDR color support, and optional shape mask textures. /// /// /// Assign a material that exposes a `_Color` property (for tint) and, optionally, a `_ShapeMask` texture slot. EnhancedImage duplicates that material at runtime so per-instance HDR adjustments stay local to the image. /// Upsides: /// /// /// Automatically instantiates materials so HDR tints and mask assignments do not leak to other UI elements. /// /// /// Supports shape masks without relying on additional UI mask components, enabling custom shader workflows. /// /// /// Falls back to the underlying whenever HDR values are not required. /// /// /// Downsides: /// /// /// Creates and manages a material copy per instance, which adds allocation and cleanup overhead. /// /// /// Requires shaders that expose the `_ShapeMask` slot to benefit from mask-driven outlines or wipes. /// /// /// Reach for when you need per-control HDR highlights, stylised wipes, or shader-driven reveal effects. Prefer the stock when shared materials and low overhead matter more than these features. /// /// /// /// using UnityEngine; /// using WallstopStudios.UnityHelpers.Visuals.UGUI; /// /// public sealed class AbilityIconPresenter : MonoBehaviour /// { /// [SerializeField] private EnhancedImage icon; /// [SerializeField] private Material abilityMaterial; /// /// void Awake() /// { /// icon.material = Instantiate(abilityMaterial); /// icon.HdrColor = new Color(1.6f, 1.2f, 0.6f, 1f); /// } /// /// public void SetCharge(float normalizedCharge) /// { /// float intensity = Mathf.Lerp(1f, 2.5f, normalizedCharge); /// icon.HdrColor = new Color(intensity, 1f, 0.4f, 1f); /// } /// } /// /// /// public sealed class EnhancedImage : Image { private static readonly int ShapeMaskPropertyID = Shader.PropertyToID("_ShapeMask"); private static readonly int ColorPropertyID = Shader.PropertyToID("_Color"); private static readonly int MainTex = Shader.PropertyToID("_MainTex"); /// /// Stores the dedicated material instance produced by . /// This is a runtime-only object that doesn't survive domain reloads. /// private Material _cachedMaterialInstance; internal Material CachedMaterialInstanceForTests => _cachedMaterialInstance; /// /// Stores the original user-assigned material before we replace it with our instance. /// Serialized so we can recreate the material instance after domain reload. /// [SerializeField] [HideInInspector] private Material _baseMaterial; /// /// HDR-capable tint applied to the instantiated material. Values above 1 keep their intensity instead of being clamped. /// /// /// Changing this value refreshes the cached material instance, using whenever the supplied color remains within the standard dynamic range. /// public Color HdrColor { get => _hdrColor; set { if (_hdrColor == value) { return; } _hdrColor = value; UpdateMaterialInstance(); } } /// /// Optional shape mask texture assigned to the material's `_ShapeMask` slot to drive custom shader based masking. /// /// /// Mimics UI mask behaviour without additional components. Provide a shader that samples `_ShapeMask` alpha and multiplies it with the sprite alpha. /// [FormerlySerializedAs("shapeMask")] [SerializeField] internal Texture2D _shapeMask; /// /// Backing field for , persisted for inspector integration and HDR authoring. /// // HDR Color field that will override the base Image color if values set > 1 [FormerlySerializedAs("hdrColor")] [SerializeField] [ColorUsage(showAlpha: true, hdr: true)] internal Color _hdrColor = Color.white; /// protected override void Start() { base.Start(); UpdateMaterialInstance(); } /// protected override void OnDestroy() { // Ensure our instance is released before base classes tear down internals CleanupMaterialInstance(); base.OnDestroy(); } #if UNITY_EDITOR /// protected override void OnValidate() { base.OnValidate(); UpdateMaterialInstance(); } /// /// Forces a refresh of the material instance. Used by the Editor to ensure /// changes are immediately reflected when properties are modified. /// internal void ForceRefreshMaterialInstance() { UpdateMaterialInstance(); } #endif /// /// Ensures this component owns a dedicated material instance and reapplies mask and color data. /// private void UpdateMaterialInstance() { Material currentMaterial = material; // Handle case where our cached instance was destroyed (e.g., domain reload) // but _baseMaterial is still valid. Restore from base material. // This can happen when: // 1. currentMaterial is null (instance was destroyed) // 2. currentMaterial is the default material (Unity reverted to default after instance was destroyed) // In both cases, if we have a valid _baseMaterial, we should restore from it. bool currentIsNullOrDefault = currentMaterial == null || ReferenceEquals(currentMaterial, defaultGraphicMaterial); bool hasValidBaseMaterial = _baseMaterial != null && !ReferenceEquals(_baseMaterial, defaultGraphicMaterial); bool cachedInstanceIsInvalid = _cachedMaterialInstance == null; if (currentIsNullOrDefault && hasValidBaseMaterial && cachedInstanceIsInvalid) { currentMaterial = _baseMaterial; } // Treat the built-in default UI material the same as "no material assigned" // so tests that explicitly set material = null do not cause an instance to be created. // BUT only if we don't have a valid base material to restore from. if (currentMaterial == null || ReferenceEquals(currentMaterial, defaultGraphicMaterial)) { return; } // Determine the base material - either a new assignment or our stored reference // If the current material is our cached instance, use the stored base // If it's something else, that's the new base material Material baseMaterial; if (_cachedMaterialInstance != null && currentMaterial == _cachedMaterialInstance) { // User hasn't changed the material, use stored base baseMaterial = _baseMaterial; } else { // User assigned a new material (or first time setup) baseMaterial = currentMaterial; _baseMaterial = baseMaterial; // Cleanup old instance since we have a new base if (_cachedMaterialInstance != null) { DestroyImmediate(_cachedMaterialInstance); _cachedMaterialInstance = null; } } // Safety check - if base material is null or default, bail if (baseMaterial == null || ReferenceEquals(baseMaterial, defaultGraphicMaterial)) { return; } // Create new instance only if we don't have one if (_cachedMaterialInstance == null) { _cachedMaterialInstance = new Material(baseMaterial); // Use HideFlags to prevent the instance from being saved to scene/prefab // but allow it to survive within the current editor session. _cachedMaterialInstance.hideFlags = HideFlags.HideAndDontSave; } // Copy the sprite's texture to the material's _MainTex. // Unity's Image component provides mainTexture from its sprite. Texture spriteTexture = mainTexture; if (spriteTexture != null && _cachedMaterialInstance.HasProperty(MainTex)) { _cachedMaterialInstance.SetTexture(MainTex, spriteTexture); } if (_shapeMask != null) { // If the shader does not expose _ShapeMask, try to swap to a helper shader // that defines the property so tests and editor UX remain predictable. if (!_cachedMaterialInstance.HasProperty(ShapeMaskPropertyID)) { Shader fallback = Shader.Find("Hidden/Wallstop/EnhancedImageSupport"); if (fallback != null) { // Preserve commonly used properties when swapping shaders Texture mainTex = _cachedMaterialInstance.HasProperty(MainTex) ? _cachedMaterialInstance.GetTexture(MainTex) : null; Color currentColor = _cachedMaterialInstance.HasProperty(ColorPropertyID) ? _cachedMaterialInstance.GetColor(ColorPropertyID) : Color.white; _cachedMaterialInstance.shader = fallback; if (mainTex != null) { _cachedMaterialInstance.SetTexture(MainTex, mainTex); } _cachedMaterialInstance.SetColor(ColorPropertyID, currentColor); } } if (_cachedMaterialInstance.HasProperty(ShapeMaskPropertyID)) { _cachedMaterialInstance.SetTexture(ShapeMaskPropertyID, _shapeMask); } } // Always use _hdrColor for the material's color. The "HDR" in the name means // it supports values > 1, not that it should be ignored for standard range colors. _cachedMaterialInstance.SetColor(ColorPropertyID, _hdrColor); // Assign the material if it changed. When the material reference is already // our cached instance, the base setter exits early without calling SetMaterialDirty. // We need to handle this case explicitly below. bool materialChanged = material != _cachedMaterialInstance; if (materialChanged) { material = _cachedMaterialInstance; } // Notify the canvas system that both the material and geometry need updating. // SetAllDirty() triggers layout, geometry, and material rebuilds. This is more // aggressive than SetMaterialDirty() + SetVerticesDirty() but ensures the Canvas // system fully re-reads material properties and rebuilds the mesh. // This is necessary because modifying material properties (via SetColor) doesn't // automatically notify the Canvas system - only assigning a different material // reference would trigger that. Since we're modifying an existing instance, // we must explicitly request the full rebuild. SetAllDirty(); } /// /// Releases the cached material instance created for this image. /// private void CleanupMaterialInstance() { if (_cachedMaterialInstance != null) { // Use immediate destruction so references become fake-null right away DestroyImmediate(_cachedMaterialInstance); _cachedMaterialInstance = null; } _baseMaterial = null; } // Test helpers to avoid reflection internal void InvokeStartForTests() => Start(); internal void InvokeOnDestroyForTests() => OnDestroy(); internal Material BaseMaterialForTests => _baseMaterial; } }