|
|
@@ -0,0 +1,805 @@
|
|
|
+#unity/日常积累
|
|
|
+
|
|
|
+可以看到,是材质出问题了,导致运行时,这样的显示效果;
|
|
|
+
|
|
|
+![[Pasted image 20251103160850.png]]
|
|
|
+
|
|
|
+对比修复前后的,调用逻辑:
|
|
|
+
|
|
|
+## 修复前 GuideUIHighlight.cs
|
|
|
+
|
|
|
+``` cs
|
|
|
+using UnityEngine;
|
|
|
+using UnityEngine.UI;
|
|
|
+using SugarFrame.Node;
|
|
|
+
|
|
|
+namespace PortableHighlight
|
|
|
+{
|
|
|
+ public enum HoleShape
|
|
|
+ {
|
|
|
+ Circle = 0,
|
|
|
+ Rect = 1,
|
|
|
+ Ellipse = 2
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 引导高亮
|
|
|
+ /// </summary>
|
|
|
+ [RequireComponent(typeof(Image))]
|
|
|
+ public class GuideUIHighlight : MonoBehaviour, ICanvasRaycastFilter
|
|
|
+ {
|
|
|
+ [Header("Target")]
|
|
|
+ public RectTransform target;
|
|
|
+ public bool followTargetRuntime = true;
|
|
|
+
|
|
|
+ [Header("Appearance")]
|
|
|
+ public Color dimColor = new Color(0f, 0f, 0f, 0.7f);
|
|
|
+ public HoleShape shape = HoleShape.Rect;
|
|
|
+ public float radius = 200f; // For circle
|
|
|
+ public Vector2 halfSize = new Vector2(200f, 120f); // For rect/ellipse
|
|
|
+ [Range(0f, 50f)] public float softness = 8f;
|
|
|
+
|
|
|
+ [Header("Mask Display")]
|
|
|
+ public SugarFrame.Node.MaskDisplayMode maskDisplayMode = SugarFrame.Node.MaskDisplayMode.Normal;
|
|
|
+ [Range(0f, 1f)] public float maskAlpha = 0.7f;
|
|
|
+
|
|
|
+ private Image backgroundImage;
|
|
|
+ private Canvas rootCanvas;
|
|
|
+ private Vector3[] corners = new Vector3[4];
|
|
|
+
|
|
|
+ private readonly int idCenter = Shader.PropertyToID("_Center");
|
|
|
+ private readonly int idRadius = Shader.PropertyToID("_Radius");
|
|
|
+ private readonly int idHalfSize = Shader.PropertyToID("_HalfSize");
|
|
|
+ private readonly int idShape = Shader.PropertyToID("_Shape");
|
|
|
+ private readonly int idSoftness = Shader.PropertyToID("_Softness");
|
|
|
+ private readonly int idColor = Shader.PropertyToID("_Color");
|
|
|
+
|
|
|
+ // Cached values (in canvas local space) for raycast test
|
|
|
+ private Vector2 cachedCenter;
|
|
|
+ private float cachedRadius;
|
|
|
+ private Vector2 cachedHalfSize;
|
|
|
+
|
|
|
+ void Awake()
|
|
|
+ {
|
|
|
+ backgroundImage = GetComponent<Image>();
|
|
|
+ rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ EnsureMaterial();
|
|
|
+ ApplyStaticParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ void OnValidate()
|
|
|
+ {
|
|
|
+// #if UNITY_EDITOR
|
|
|
+// if (backgroundImage == null) backgroundImage = GetComponent<Image>();
|
|
|
+// EnsureMaterial();
|
|
|
+// ApplyStaticParams();
|
|
|
+// UpdateTargetParams();
|
|
|
+// #endif
|
|
|
+ }
|
|
|
+
|
|
|
+ public void RefreshHighlight()
|
|
|
+ {
|
|
|
+ EnsureMaterial();
|
|
|
+ ApplyStaticParams();
|
|
|
+ UpdateTargetParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ void Update()
|
|
|
+ {
|
|
|
+ if (followTargetRuntime)
|
|
|
+ {
|
|
|
+ UpdateTargetParams();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void LateUpdate()
|
|
|
+ {
|
|
|
+ // Keep highlight at the very top of its canvas hierarchy
|
|
|
+ if (transform != null)
|
|
|
+ {
|
|
|
+ transform.SetAsLastSibling();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public void SetTarget(RectTransform newTarget)
|
|
|
+ {
|
|
|
+ target = newTarget;
|
|
|
+ UpdateTargetParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void EnsureMaterial()
|
|
|
+ {
|
|
|
+ if (backgroundImage.material == null || backgroundImage.material.shader == null || backgroundImage.material.shader.name != "UI/SimpleUIHole")
|
|
|
+ {
|
|
|
+ var shader = Shader.Find("UI/SimpleUIHole");
|
|
|
+ if (shader != null)
|
|
|
+ {
|
|
|
+ backgroundImage.material = new Material(shader);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // raycastTarget 的设置现在在 ApplyStaticParams 中根据遮罩模式动态设置
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ApplyStaticParams()
|
|
|
+ {
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+ var mat = backgroundImage.material;
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置颜色
|
|
|
+ Color finalColor = GetFinalMaskColor();
|
|
|
+ mat.SetColor(idColor, finalColor);
|
|
|
+
|
|
|
+ mat.SetFloat(idSoftness, softness);
|
|
|
+ mat.SetFloat(idShape, (float)shape);
|
|
|
+ mat.SetFloat(idRadius, radius);
|
|
|
+ mat.SetVector(idHalfSize, halfSize);
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置射线检测
|
|
|
+ if (maskDisplayMode == SugarFrame.Node.MaskDisplayMode.None)
|
|
|
+ {
|
|
|
+ // 无遮罩模式:禁用射线检测,允许点击穿透
|
|
|
+ backgroundImage.raycastTarget = false;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 正常遮罩和透明遮罩模式:启用射线检测,用于阻挡点击
|
|
|
+ backgroundImage.raycastTarget = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 根据遮罩显示模式获取最终的颜色
|
|
|
+ /// </summary>
|
|
|
+ /// <returns>最终的颜色</returns>
|
|
|
+ private Color GetFinalMaskColor()
|
|
|
+ {
|
|
|
+ switch (maskDisplayMode)
|
|
|
+ {
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.Normal:
|
|
|
+ // 正常遮罩:使用配置的颜色和透明度
|
|
|
+ return new Color(dimColor.r, dimColor.g, dimColor.b, maskAlpha);
|
|
|
+
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.Transparent:
|
|
|
+ // 透明遮罩:无颜色显示,但保持遮罩效果(alpha为0但材质仍存在)
|
|
|
+ return new Color(0f, 0f, 0f, 0f);
|
|
|
+
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.None:
|
|
|
+ // 不显示遮罩:完全透明
|
|
|
+ return new Color(0f, 0f, 0f, 0f);
|
|
|
+
|
|
|
+ default:
|
|
|
+ return dimColor;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpdateTargetParams()
|
|
|
+ {
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+
|
|
|
+ Vector2 center;
|
|
|
+ if (target == null)
|
|
|
+ {
|
|
|
+ center = Vector2.zero;
|
|
|
+ cachedHalfSize = halfSize;
|
|
|
+ cachedRadius = radius;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ if (rootCanvas == null) rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ target.GetWorldCorners(corners);
|
|
|
+
|
|
|
+ // Convert world corners into canvas local space
|
|
|
+ for (int i = 0; i < 4; i++)
|
|
|
+ {
|
|
|
+ Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(rootCanvas != null ? rootCanvas.worldCamera : null, corners[i]);
|
|
|
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
|
|
+ rootCanvas.GetComponent<RectTransform>(),
|
|
|
+ screenPoint,
|
|
|
+ rootCanvas != null ? rootCanvas.worldCamera : null,
|
|
|
+ out var localPoint);
|
|
|
+ corners[i] = localPoint;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Center and half size in canvas local space
|
|
|
+ Vector2 min = (Vector2)corners[0];
|
|
|
+ Vector2 max = (Vector2)corners[2];
|
|
|
+ center = (min + max) * 0.5f;
|
|
|
+ Vector2 half = (max - min) * 0.5f;
|
|
|
+
|
|
|
+ if (shape == HoleShape.Circle)
|
|
|
+ {
|
|
|
+ radius = Mathf.Max(half.x, half.y);
|
|
|
+ backgroundImage.material.SetFloat(idRadius, radius);
|
|
|
+ cachedRadius = radius;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ halfSize = half;
|
|
|
+ backgroundImage.material.SetVector(idHalfSize, halfSize);
|
|
|
+ cachedHalfSize = halfSize;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ backgroundImage.material.SetVector(idCenter, center);
|
|
|
+ cachedCenter = center;
|
|
|
+ ApplyStaticParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Allow raycasts to pass only through the hole
|
|
|
+ public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
|
|
|
+ {
|
|
|
+ // 透明遮罩和正常遮罩都需要进行射线检测,只是显示效果不同
|
|
|
+ if (rootCanvas == null) rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ if (rootCanvas == null) return true; // fallback: block
|
|
|
+
|
|
|
+ RectTransform canvasRect = rootCanvas.GetComponent<RectTransform>();
|
|
|
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
|
|
+ canvasRect,
|
|
|
+ sp,
|
|
|
+ rootCanvas != null ? rootCanvas.worldCamera : null,
|
|
|
+ out var localPoint);
|
|
|
+
|
|
|
+ // Translate to hole-local space
|
|
|
+ Vector2 delta = localPoint - cachedCenter;
|
|
|
+
|
|
|
+ bool insideHole = false;
|
|
|
+ switch (shape)
|
|
|
+ {
|
|
|
+ case HoleShape.Circle:
|
|
|
+ insideHole = (delta.sqrMagnitude <= cachedRadius * cachedRadius);
|
|
|
+ break;
|
|
|
+ case HoleShape.Rect:
|
|
|
+ insideHole = Mathf.Abs(delta.x) <= cachedHalfSize.x && Mathf.Abs(delta.y) <= cachedHalfSize.y;
|
|
|
+ break;
|
|
|
+ case HoleShape.Ellipse:
|
|
|
+ float nx = cachedHalfSize.x <= 0.0001f ? 0f : delta.x / cachedHalfSize.x;
|
|
|
+ float ny = cachedHalfSize.y <= 0.0001f ? 0f : delta.y / cachedHalfSize.y;
|
|
|
+ insideHole = (nx * nx + ny * ny) <= 1f;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return false to let the ray pass through when inside the hole.
|
|
|
+ // Return true to block clicks outside the hole.
|
|
|
+ // 透明遮罩和正常遮罩都遵循相同的射线阻挡逻辑
|
|
|
+ return !insideHole;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 修复后 GuideUIHighlight.cs
|
|
|
+
|
|
|
+``` cs
|
|
|
+using UnityEngine;
|
|
|
+using UnityEngine.UI;
|
|
|
+using SugarFrame.Node;
|
|
|
+
|
|
|
+namespace PortableHighlight
|
|
|
+{
|
|
|
+ public enum HoleShape
|
|
|
+ {
|
|
|
+ Circle = 0,
|
|
|
+ Rect = 1,
|
|
|
+ Ellipse = 2
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 引导高亮
|
|
|
+ /// </summary>
|
|
|
+ [RequireComponent(typeof(Image))]
|
|
|
+ public class GuideUIHighlight : MonoBehaviour, ICanvasRaycastFilter
|
|
|
+ {
|
|
|
+ [Header("Target")]
|
|
|
+ public RectTransform target;
|
|
|
+ public bool followTargetRuntime = true;
|
|
|
+
|
|
|
+ [Header("Appearance")]
|
|
|
+ public Color dimColor = new Color(0f, 0f, 0f, 0.7f);
|
|
|
+ public HoleShape shape = HoleShape.Rect;
|
|
|
+ public float radius = 200f; // For circle
|
|
|
+ public Vector2 halfSize = new Vector2(200f, 120f); // For rect/ellipse
|
|
|
+ [Range(0f, 50f)] public float softness = 8f;
|
|
|
+
|
|
|
+ [Header("Mask Display")]
|
|
|
+ public SugarFrame.Node.MaskDisplayMode maskDisplayMode = SugarFrame.Node.MaskDisplayMode.Normal;
|
|
|
+ [Range(0f, 1f)] public float maskAlpha = 0.7f;
|
|
|
+
|
|
|
+ private Image backgroundImage;
|
|
|
+ private Canvas rootCanvas;
|
|
|
+ private Vector3[] corners = new Vector3[4];
|
|
|
+
|
|
|
+ // 使用独立材质实例避免全局Shader属性污染
|
|
|
+ private Material instanceMaterial;
|
|
|
+
|
|
|
+ private readonly int idCenter = Shader.PropertyToID("_Center");
|
|
|
+ private readonly int idRadius = Shader.PropertyToID("_Radius");
|
|
|
+ private readonly int idHalfSize = Shader.PropertyToID("_HalfSize");
|
|
|
+ private readonly int idShape = Shader.PropertyToID("_Shape");
|
|
|
+ private readonly int idSoftness = Shader.PropertyToID("_Softness");
|
|
|
+ private readonly int idColor = Shader.PropertyToID("_Color");
|
|
|
+
|
|
|
+ // Cached values (in canvas local space) for raycast test
|
|
|
+ private Vector2 cachedCenter;
|
|
|
+ private float cachedRadius;
|
|
|
+ private Vector2 cachedHalfSize;
|
|
|
+
|
|
|
+ void Awake()
|
|
|
+ {
|
|
|
+ backgroundImage = GetComponent<Image>();
|
|
|
+ rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ EnsureMaterial();
|
|
|
+ ApplyStaticParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ void OnDestroy()
|
|
|
+ {
|
|
|
+ // 销毁时清理材质实例,避免内存泄漏
|
|
|
+ if (instanceMaterial != null)
|
|
|
+ {
|
|
|
+ if (Application.isPlaying)
|
|
|
+ {
|
|
|
+ DestroyImmediate(instanceMaterial);
|
|
|
+ }
|
|
|
+ instanceMaterial = null;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 销毁材质实例");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public void RefreshHighlight()
|
|
|
+ {
|
|
|
+ EnsureMaterial();
|
|
|
+ ApplyStaticParams();
|
|
|
+ UpdateTargetParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ // void Update()
|
|
|
+ // {
|
|
|
+ // if (followTargetRuntime)
|
|
|
+ // {
|
|
|
+ // UpdateTargetParams();
|
|
|
+ // }
|
|
|
+ // }
|
|
|
+
|
|
|
+ void LateUpdate()
|
|
|
+ {
|
|
|
+ // Keep highlight at the very top of its canvas hierarchy
|
|
|
+ if (transform != null)
|
|
|
+ {
|
|
|
+ transform.SetAsLastSibling();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public void SetTarget(RectTransform newTarget)
|
|
|
+ {
|
|
|
+ target = newTarget;
|
|
|
+ UpdateTargetParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void EnsureMaterial()
|
|
|
+ {
|
|
|
+ // 检查是否需要创建新材质实例
|
|
|
+ bool needNewMaterial = false;
|
|
|
+
|
|
|
+ if (backgroundImage.material == null)
|
|
|
+ {
|
|
|
+ needNewMaterial = true;
|
|
|
+ }
|
|
|
+ else if (backgroundImage.material.shader == null)
|
|
|
+ {
|
|
|
+ needNewMaterial = true;
|
|
|
+ }
|
|
|
+ else if (backgroundImage.material.shader.name != "UI/SimpleUIHole")
|
|
|
+ {
|
|
|
+ needNewMaterial = true;
|
|
|
+ }
|
|
|
+ else if (backgroundImage.material == instanceMaterial)
|
|
|
+ {
|
|
|
+ // 如果已经是我们的实例材质,不需要重新创建
|
|
|
+ needNewMaterial = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (needNewMaterial)
|
|
|
+ {
|
|
|
+ // 首先尝试从Resources加载预制的材质
|
|
|
+ var prefabMaterial = Resources.Load<Material>("Materials/GuideUIHoleMaterial");
|
|
|
+ if (prefabMaterial != null)
|
|
|
+ {
|
|
|
+ // 关键:创建完全独立的材质实例,避免任何共享
|
|
|
+ instanceMaterial = new Material(prefabMaterial);
|
|
|
+ instanceMaterial.name = $"GuideUIHoleMaterial_Instance_{GetInstanceID()}";
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 创建独立材质实例: {instanceMaterial.name}");
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 如果预制材质不存在,尝试通过Shader.Find创建
|
|
|
+ var shader = Shader.Find("UI/SimpleUIHole");
|
|
|
+ if (shader != null)
|
|
|
+ {
|
|
|
+ instanceMaterial = new Material(shader);
|
|
|
+ instanceMaterial.name = $"GuideUIHoleMaterial_Instance_{GetInstanceID()}";
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 通过Shader.Find创建独立材质实例: {instanceMaterial.name}");
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 最后的fallback:使用默认UI材质但设置为透明
|
|
|
+ Debug.LogError("[GuideUIHighlight] 无法找到UI/SimpleUIHoleFixed shader和预制材质!");
|
|
|
+ backgroundImage.material = null;
|
|
|
+ backgroundImage.color = new Color(0, 0, 0, 0); // 设置为透明
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // raycastTarget 的设置现在在 ApplyStaticParams 中根据遮罩模式动态设置
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ApplyStaticParams()
|
|
|
+ {
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+
|
|
|
+ // 确保使用我们的独立材质实例
|
|
|
+ if (backgroundImage.material != instanceMaterial)
|
|
|
+ {
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置颜色
|
|
|
+ Color finalColor = GetFinalMaskColor();
|
|
|
+
|
|
|
+ // 直接设置材质实例的属性(不会影响全局,因为这是独立实例)
|
|
|
+ instanceMaterial.SetColor(idColor, finalColor);
|
|
|
+ instanceMaterial.SetFloat(idSoftness, softness);
|
|
|
+ instanceMaterial.SetFloat(idShape, (float)shape);
|
|
|
+ instanceMaterial.SetFloat(idRadius, radius);
|
|
|
+ instanceMaterial.SetVector(idHalfSize, halfSize);
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置射线检测
|
|
|
+ if (maskDisplayMode == SugarFrame.Node.MaskDisplayMode.None)
|
|
|
+ {
|
|
|
+ // 无遮罩模式:禁用射线检测,允许点击穿透
|
|
|
+ backgroundImage.raycastTarget = false;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 正常遮罩和透明遮罩模式:启用射线检测,用于阻挡点击
|
|
|
+ backgroundImage.raycastTarget = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ Debug.Log($"[GuideUIHighlight] 使用独立材质实例设置参数,避免全局污染");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 根据遮罩显示模式获取最终的颜色
|
|
|
+ /// </summary>
|
|
|
+ /// <returns>最终的颜色</returns>
|
|
|
+ private Color GetFinalMaskColor()
|
|
|
+ {
|
|
|
+ switch (maskDisplayMode)
|
|
|
+ {
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.Normal:
|
|
|
+ // 正常遮罩:使用配置的颜色和透明度
|
|
|
+ return new Color(dimColor.r, dimColor.g, dimColor.b, maskAlpha);
|
|
|
+
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.Transparent:
|
|
|
+ // 透明遮罩:无颜色显示,但保持遮罩效果(alpha为0但材质仍存在)
|
|
|
+ return new Color(0f, 0f, 0f, 0f);
|
|
|
+
|
|
|
+ case SugarFrame.Node.MaskDisplayMode.None:
|
|
|
+ // 不显示遮罩:完全透明
|
|
|
+ return new Color(0f, 0f, 0f, 0f);
|
|
|
+
|
|
|
+ default:
|
|
|
+ return dimColor;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpdateTargetParams()
|
|
|
+ {
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+
|
|
|
+ Vector2 center;
|
|
|
+ if (target == null)
|
|
|
+ {
|
|
|
+ center = Vector2.zero;
|
|
|
+ cachedHalfSize = halfSize;
|
|
|
+ cachedRadius = radius;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ if (rootCanvas == null) rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ target.GetWorldCorners(corners);
|
|
|
+
|
|
|
+ // Convert world corners into canvas local space
|
|
|
+ for (int i = 0; i < 4; i++)
|
|
|
+ {
|
|
|
+ Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(rootCanvas != null ? rootCanvas.worldCamera : null, corners[i]);
|
|
|
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
|
|
+ rootCanvas.GetComponent<RectTransform>(),
|
|
|
+ screenPoint,
|
|
|
+ rootCanvas != null ? rootCanvas.worldCamera : null,
|
|
|
+ out var localPoint);
|
|
|
+ corners[i] = localPoint;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Center and half size in canvas local space
|
|
|
+ Vector2 min = (Vector2)corners[0];
|
|
|
+ Vector2 max = (Vector2)corners[2];
|
|
|
+ center = (min + max) * 0.5f;
|
|
|
+ Vector2 half = (max - min) * 0.5f;
|
|
|
+
|
|
|
+ if (shape == HoleShape.Circle)
|
|
|
+ {
|
|
|
+ radius = Mathf.Max(half.x, half.y);
|
|
|
+ cachedRadius = radius;
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ halfSize = half;
|
|
|
+ cachedHalfSize = halfSize;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 使用独立材质实例设置中心位置,避免全局污染
|
|
|
+ if (instanceMaterial != null)
|
|
|
+ {
|
|
|
+ instanceMaterial.SetVector(idCenter, center);
|
|
|
+ }
|
|
|
+ cachedCenter = center;
|
|
|
+ ApplyStaticParams();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Allow raycasts to pass only through the hole
|
|
|
+ public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
|
|
|
+ {
|
|
|
+ // 透明遮罩和正常遮罩都需要进行射线检测,只是显示效果不同
|
|
|
+ if (rootCanvas == null) rootCanvas = GetComponentInParent<Canvas>();
|
|
|
+ if (rootCanvas == null) return true; // fallback: block
|
|
|
+
|
|
|
+ RectTransform canvasRect = rootCanvas.GetComponent<RectTransform>();
|
|
|
+ RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
|
|
+ canvasRect,
|
|
|
+ sp,
|
|
|
+ rootCanvas != null ? rootCanvas.worldCamera : null,
|
|
|
+ out var localPoint);
|
|
|
+
|
|
|
+ // Translate to hole-local space
|
|
|
+ Vector2 delta = localPoint - cachedCenter;
|
|
|
+
|
|
|
+ bool insideHole = false;
|
|
|
+ switch (shape)
|
|
|
+ {
|
|
|
+ case HoleShape.Circle:
|
|
|
+ insideHole = (delta.sqrMagnitude <= cachedRadius * cachedRadius);
|
|
|
+ break;
|
|
|
+ case HoleShape.Rect:
|
|
|
+ insideHole = Mathf.Abs(delta.x) <= cachedHalfSize.x && Mathf.Abs(delta.y) <= cachedHalfSize.y;
|
|
|
+ break;
|
|
|
+ case HoleShape.Ellipse:
|
|
|
+ float nx = cachedHalfSize.x <= 0.0001f ? 0f : delta.x / cachedHalfSize.x;
|
|
|
+ float ny = cachedHalfSize.y <= 0.0001f ? 0f : delta.y / cachedHalfSize.y;
|
|
|
+ insideHole = (nx * nx + ny * ny) <= 1f;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return false to let the ray pass through when inside the hole.
|
|
|
+ // Return true to block clicks outside the hole.
|
|
|
+ // 透明遮罩和正常遮罩都遵循相同的射线阻挡逻辑
|
|
|
+ return !insideHole;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 关键差异概览
|
|
|
+
|
|
|
+- 材质实例管理
|
|
|
+
|
|
|
+- GuideSystem2:显式维护私有 instanceMaterial,创建“独立材质实例”,并在销毁时主动清理;每次用前还强制校验并回填到 Image.material。
|
|
|
+
|
|
|
+- GuideSystem3:直接用 backgroundImage.material = new Material(shader) 生成材质后,后续通过 backgroundImage.material.SetXxx 写入;没有私有引用,也没有销毁时的释放。
|
|
|
+
|
|
|
+- 动态更新逻辑
|
|
|
+
|
|
|
+- GuideSystem2:去掉了 Update() 的跟随(注释掉),仅 LateUpdate() 置顶;外部调用 RefreshHighlight() 或设置 target 后再推一次参数。
|
|
|
+
|
|
|
+- GuideSystem3:保留 Update(),followTargetRuntime 为真时每帧更新洞位置与大小。
|
|
|
+
|
|
|
+- 防污染措施的严谨度
|
|
|
+
|
|
|
+- GuideSystem2:始终对“我们自己的材质实例”写属性,且在 EnsureMaterial() 中保证当前 Image 正在用的就是该实例;销毁时 DestroyImmediate(instanceMaterial),防止泄漏与复用。
|
|
|
+
|
|
|
+- GuideSystem3:虽然创建了 new Material(shader),理论上这也会产生实例,但后续不再追踪该实例,也不保证它不被其他地方替换/共享,更不做销毁,存在潜在“材质引用复用/泄漏”的风险点。
|
|
|
+
|
|
|
+## 代码对照(材质创建/绑定/清理)
|
|
|
+
|
|
|
+- GuideSystem2:显式独立实例 + 命名 + 回填 + 销毁
|
|
|
+
|
|
|
+``` cs
|
|
|
+void OnDestroy()
|
|
|
+{
|
|
|
+ // 销毁时清理材质实例,避免内存泄漏
|
|
|
+ if (instanceMaterial != null)
|
|
|
+ {
|
|
|
+ if (Application.isPlaying)
|
|
|
+ {
|
|
|
+ DestroyImmediate(instanceMaterial);
|
|
|
+ }
|
|
|
+ instanceMaterial = null;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 销毁材质实例");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+``` cs
|
|
|
+private void EnsureMaterial()
|
|
|
+{
|
|
|
+ // 检查是否需要创建新材质实例
|
|
|
+ bool needNewMaterial = false;
|
|
|
+ ...
|
|
|
+ else if (backgroundImage.material == instanceMaterial)
|
|
|
+ {
|
|
|
+ // 如果已经是我们的实例材质,不需要重新创建
|
|
|
+ needNewMaterial = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (needNewMaterial)
|
|
|
+ {
|
|
|
+ // 首先尝试从Resources加载预制的材质
|
|
|
+ var prefabMaterial = Resources.Load<Material>("Materials/GuideUIHoleMaterial");
|
|
|
+ if (prefabMaterial != null)
|
|
|
+ {
|
|
|
+ // 关键:创建完全独立的材质实例,避免任何共享
|
|
|
+ instanceMaterial = new Material(prefabMaterial);
|
|
|
+ instanceMaterial.name = $"GuideUIHoleMaterial_Instance_{GetInstanceID()}";
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 创建独立材质实例: {instanceMaterial.name}");
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 如果预制材质不存在,尝试通过Shader.Find创建
|
|
|
+ var shader = Shader.Find("UI/SimpleUIHole");
|
|
|
+ if (shader != null)
|
|
|
+ {
|
|
|
+ instanceMaterial = new Material(shader);
|
|
|
+ instanceMaterial.name = $"GuideUIHoleMaterial_Instance_{GetInstanceID()}";
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ Debug.Log($"[GuideUIHighlight] 通过Shader.Find创建独立材质实例: {instanceMaterial.name}");
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ // 最后的fallback:使用默认UI材质但设置为透明
|
|
|
+ Debug.LogError("[GuideUIHighlight] 无法找到UI/SimpleUIHoleFixed shader和预制材质!");
|
|
|
+ backgroundImage.material = null;
|
|
|
+ backgroundImage.color = new Color(0, 0, 0, 0); // 设置为透明
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // raycastTarget 的设置现在在 ApplyStaticParams 中根据遮罩模式动态设置
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+``` cs
|
|
|
+private void ApplyStaticParams()
|
|
|
+{
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+
|
|
|
+ // 确保使用我们的独立材质实例
|
|
|
+ if (backgroundImage.material != instanceMaterial)
|
|
|
+ {
|
|
|
+ backgroundImage.material = instanceMaterial;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置颜色
|
|
|
+ Color finalColor = GetFinalMaskColor();
|
|
|
+
|
|
|
+ // 直接设置材质实例的属性(不会影响全局,因为这是独立实例)
|
|
|
+ instanceMaterial.SetColor(idColor, finalColor);
|
|
|
+ instanceMaterial.SetFloat(idSoftness, softness);
|
|
|
+ instanceMaterial.SetFloat(idShape, (float)shape);
|
|
|
+ instanceMaterial.SetFloat(idRadius, radius);
|
|
|
+ instanceMaterial.SetVector(idHalfSize, halfSize);
|
|
|
+ ...
|
|
|
+ Debug.Log($"[GuideUIHighlight] 使用独立材质实例设置参数,避免全局污染");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+``` cs
|
|
|
+// 使用独立材质实例设置中心位置,避免全局污染
|
|
|
+if (instanceMaterial != null)
|
|
|
+{
|
|
|
+ instanceMaterial.SetVector(idCenter, center);
|
|
|
+}
|
|
|
+cachedCenter = center;
|
|
|
+ApplyStaticParams();
|
|
|
+```
|
|
|
+
|
|
|
+- GuideSystem3:简单创建 + 直接用 backgroundImage.material 写属性
|
|
|
+
|
|
|
+``` cs
|
|
|
+private void EnsureMaterial()
|
|
|
+{
|
|
|
+ if (backgroundImage.material == null || backgroundImage.material.shader == null || backgroundImage.material.shader.name != "UI/SimpleUIHole")
|
|
|
+ {
|
|
|
+ var shader = Shader.Find("UI/SimpleUIHole");
|
|
|
+ if (shader != null)
|
|
|
+ {
|
|
|
+ backgroundImage.material = new Material(shader);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // raycastTarget 的设置现在在 ApplyStaticParams 中根据遮罩模式动态设置
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+``` cs
|
|
|
+private void ApplyStaticParams()
|
|
|
+{
|
|
|
+ if (backgroundImage == null || backgroundImage.material == null) return;
|
|
|
+ var mat = backgroundImage.material;
|
|
|
+
|
|
|
+ // 根据遮罩显示模式设置颜色
|
|
|
+ Color finalColor = GetFinalMaskColor();
|
|
|
+ mat.SetColor(idColor, finalColor);
|
|
|
+
|
|
|
+ mat.SetFloat(idSoftness, softness);
|
|
|
+ mat.SetFloat(idShape, (float)shape);
|
|
|
+ mat.SetFloat(idRadius, radius);
|
|
|
+ mat.SetVector(idHalfSize, halfSize);
|
|
|
+ ...
|
|
|
+```
|
|
|
+
|
|
|
+``` cs
|
|
|
+if (shape == HoleShape.Circle)
|
|
|
+{
|
|
|
+ radius = Mathf.Max(half.x, half.y);
|
|
|
+ backgroundImage.material.SetFloat(idRadius, radius);
|
|
|
+ cachedRadius = radius;
|
|
|
+}
|
|
|
+else
|
|
|
+{
|
|
|
+ halfSize = half;
|
|
|
+ backgroundImage.material.SetVector(idHalfSize, halfSize);
|
|
|
+ cachedHalfSize = halfSize;
|
|
|
+}
|
|
|
+...
|
|
|
+backgroundImage.material.SetVector(idCenter, center);
|
|
|
+cachedCenter = center;
|
|
|
+ApplyStaticParams();
|
|
|
+```
|
|
|
+
|
|
|
+## “材质污染”是如何被规避的(GuideSystem2 的做法)
|
|
|
+
|
|
|
+- 明确“独占实例”:通过 new Material(prefabMaterial) 或 new Material(shader) 创建专属实例,并保存到 instanceMaterial,确保不是某个共享的 Material 资源。
|
|
|
+
|
|
|
+- 强制绑定与回填:每次 ApplyStaticParams() 都校验 Image.material 是否就是 instanceMaterial,不是就回填,避免在别处被替换成共享材质后继续写入导致“串改”。
|
|
|
+
|
|
|
+- 始终对实例写属性:所有 SetColor/SetFloat/SetVector 都写在 instanceMaterial 上,不触碰 sharedMaterial,不使用全局 Shader 参数。
|
|
|
+
|
|
|
+- 生命周期清理:OnDestroy() 主动销毁 instanceMaterial,防止实例残留被别处意外复用或造成内存泄漏。
|
|
|
+
|
|
|
+- 诊断可见性:命名实例并打印日志,方便排查是否出现跨对象复用或异常替换。
|
|
|
+
|
|
|
+对比之下,GuideSystem3 虽然使用 new Material(shader) 也会创建实例,但因为不持有独立引用、不在销毁时清理,也不校验回填,存在以下潜在风险:
|
|
|
+
|
|
|
+- 该 Image 的 material 引用可能被外部代码替换为某个共享 Material,后续 SetXxx 将污染到被复用的材质。
|
|
|
+
|
|
|
+- 未清理的材质实例会常驻,可能被误引用或引发泄漏,进一步增加“污染”概率。
|
|
|
+
|
|
|
+### 其他细微差异
|
|
|
+
|
|
|
+- 运行期跟随
|
|
|
+
|
|
|
+- GuideSystem2 注释了 Update() 跟随,减少每帧写材质属性的频率,更利于将“写材质”收敛到必要时机。
|
|
|
+
|
|
|
+- GuideSystem3 每帧跟随,会更频繁地对材质写属性(若材质被共享,这会放大污染面)。
|
|
|
+
|
|
|
+—
|
|
|
+
|
|
|
+- GuideSystem2:通过“私有化材质实例 + 强制回填 + 生命周期清理”系统性地规避了材质污染问题。
|
|
|
+
|
|
|
+- GuideSystem3:简单按需创建实例,但缺乏引用约束与清理,存在被替换/复用引起污染的风险点。
|