diff --git a/Documentation~/zh/changelog.md b/Documentation~/zh/changelog.md index d7717f40..03c42c88 100644 --- a/Documentation~/zh/changelog.md +++ b/Documentation~/zh/changelog.md @@ -80,6 +80,7 @@ slug: /changelog ## master +* (2026.02.26) 修复`SaveAsImage`被其他组件遮挡时无法正常保存的问题 (#337) * (2026.02.26) 增加`Axis`的`mainAxis`参数设置主轴可控制柱图的朝向 (#331) * (2026.02.03) 修复`UITable`的`viewport`在不同的锚点下可能会绘制异常的问题 * (2026.01.15) 修复`Pie`的点击有时候不响应的问题 (#357) diff --git a/Editor/Charts/BaseChartEditor.cs b/Editor/Charts/BaseChartEditor.cs index 739903b1..371c4e34 100644 --- a/Editor/Charts/BaseChartEditor.cs +++ b/Editor/Charts/BaseChartEditor.cs @@ -285,7 +285,7 @@ namespace XCharts.Editor } if (GUILayout.Button(Styles.btnSaveAsImage)) { - m_Chart.SaveAsImage(); + m_Chart.SaveAsImage("png", "", 4f); } if (m_CheckWarning) { diff --git a/Editor/MainComponents/UIComponentEditor.cs b/Editor/MainComponents/UIComponentEditor.cs index b942a2bd..66609fe6 100644 --- a/Editor/MainComponents/UIComponentEditor.cs +++ b/Editor/MainComponents/UIComponentEditor.cs @@ -54,7 +54,7 @@ namespace XCharts.Editor } if (GUILayout.Button(Styles.btnSaveAsImage)) { - m_UIComponent.SaveAsImage(); + m_UIComponent.SaveAsImage("png", "", 4f); } OnDebugEndInspectorGUI(); } diff --git a/Runtime/Internal/BaseChart.cs b/Runtime/Internal/BaseChart.cs index b3536b48..098c5b07 100644 --- a/Runtime/Internal/BaseChart.cs +++ b/Runtime/Internal/BaseChart.cs @@ -627,6 +627,8 @@ namespace XCharts.Runtime vh.Clear(); var maxPainter = settings.maxPainter; var maxSeries = m_Series.Count; + if (painter == null || painter.index < 0 || painter.index >= maxPainter) + return; var rate = Mathf.CeilToInt(maxSeries * 1.0f / maxPainter); m_PainterUpper.Refresh(); m_PainterTop.Refresh(); diff --git a/Runtime/Internal/BaseGraph.API.cs b/Runtime/Internal/BaseGraph.API.cs index 65aaeb13..f760da11 100644 --- a/Runtime/Internal/BaseGraph.API.cs +++ b/Runtime/Internal/BaseGraph.API.cs @@ -209,15 +209,20 @@ namespace XCharts.Runtime /// /// type of image: png, jpg, exr /// save path - public void SaveAsImage(string imageType = "png", string savePath = "") + /// export resolution scale. 1 means original size + /// whether to recursively use lower-level UI background color + public void SaveAsImage(string imageType = "png", string savePath = "", float exportScale = 1f, + bool useRecursiveBackgroundColor = false) { - StartCoroutine(SaveAsImageSync(imageType, savePath)); + StartCoroutine(SaveAsImageSync(imageType, savePath, exportScale, useRecursiveBackgroundColor)); } - private IEnumerator SaveAsImageSync(string imageType, string path) + private IEnumerator SaveAsImageSync(string imageType, string path, float exportScale, + bool useRecursiveBackgroundColor) { yield return new WaitForEndOfFrame(); - ChartHelper.SaveAsImage(rectTransform, canvas, imageType, path); + ChartHelper.SaveAsImage(rectTransform, canvas, imageType, path, exportScale, + useRecursiveBackgroundColor); } public Vector3 GetTitlePosition(Title title) diff --git a/Runtime/Internal/Painter.cs b/Runtime/Internal/Painter.cs index e0c33fea..95672462 100644 --- a/Runtime/Internal/Painter.cs +++ b/Runtime/Internal/Painter.cs @@ -18,7 +18,11 @@ namespace XCharts.Runtime protected bool m_Refresh; protected Action m_OnPopulateMesh; - public Action onPopulateMesh { set { m_OnPopulateMesh = value; } } + public Action onPopulateMesh + { + get { return m_OnPopulateMesh; } + set { m_OnPopulateMesh = value; } + } public int index { get { return m_Index; } set { m_Index = value; } } public Type type { get { return m_Type; } set { m_Type = value; } } public void Refresh() diff --git a/Runtime/Internal/Utilities/ChartHelper.cs b/Runtime/Internal/Utilities/ChartHelper.cs index 9b53d2c3..abfebcc6 100644 --- a/Runtime/Internal/Utilities/ChartHelper.cs +++ b/Runtime/Internal/Utilities/ChartHelper.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; +using XUGL; #if dUI_TextMeshPro using TMPro; #endif @@ -1060,34 +1061,359 @@ namespace XCharts.Runtime private static extern void Download(string base64str, string fileName); #endif - public static Texture2D SaveAsImage(RectTransform rectTransform, Canvas canvas, string imageType = "png", string path = "") + private static void SetLayerRecursively(GameObject obj, int layer) + { + if (obj == null) return; + obj.layer = layer; + var trans = obj.transform; + for (int i = 0; i < trans.childCount; i++) + { + SetLayerRecursively(trans.GetChild(i).gameObject, layer); + } + } + + private static void CloneChildrenRecursively(Transform source, Transform targetParent, int layer) + { + if (source == null || targetParent == null) return; + for (int i = 0; i < source.childCount; i++) + { + var child = source.GetChild(i); + var childClone = GameObject.Instantiate(child.gameObject, targetParent, false); + SetLayerRecursively(childClone, layer); + SyncPainterCallbacks(child, childClone.transform); + } + } + + private static void SyncPainterCallbacks(Transform source, Transform clone) + { + if (source == null || clone == null) return; + var sourcePainter = source.GetComponent(); + var clonePainter = clone.GetComponent(); + if (sourcePainter != null && clonePainter != null) + { + clonePainter.onPopulateMesh = sourcePainter.onPopulateMesh; + clonePainter.index = sourcePainter.index; + clonePainter.type = sourcePainter.type; + clonePainter.material = sourcePainter.material; + clonePainter.Refresh(); + } + var count = Mathf.Min(source.childCount, clone.childCount); + for (int i = 0; i < count; i++) + { + SyncPainterCallbacks(source.GetChild(i), clone.GetChild(i)); + } + } + + private static void DestroyObject(GameObject obj) + { + if (obj == null) return; +#if UNITY_EDITOR + if (!Application.isPlaying) + GameObject.DestroyImmediate(obj, true); + else + GameObject.Destroy(obj); +#else + GameObject.Destroy(obj); +#endif + } + + private static byte[] EncodeImage(Texture2D tex, string imageType) { - var cam = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; - var pos = RectTransformUtility.WorldToScreenPoint(cam, rectTransform.position); - var width = (int)(rectTransform.rect.width * canvas.scaleFactor); - var height = (int)(rectTransform.rect.height * canvas.scaleFactor); - var posX = pos.x + rectTransform.rect.xMin * canvas.scaleFactor; - var posY = pos.y + rectTransform.rect.yMin * canvas.scaleFactor; - var rect = new Rect(posX, posY, width, height); - var tex = new Texture2D(width, height, TextureFormat.ARGB32, false); - tex.ReadPixels(rect, 0, 0); - tex.Apply(); - byte[] bytes; switch (imageType) { case "png": - bytes = tex.EncodeToPNG(); - break; + return tex.EncodeToPNG(); case "jpg": - bytes = tex.EncodeToJPG(); - break; + return tex.EncodeToJPG(); case "exr": - bytes = tex.EncodeToEXR(); - break; + return tex.EncodeToEXR(); default: Debug.LogError("SaveAsImage ERROR: not support image type:" + imageType); return null; } + } + + private static float[] GetChartCornerRadius(BaseChart chart, float chartWidth, float chartHeight, float scaleFactor) + { + if (chart == null || chartWidth <= 0 || chartHeight <= 0) + return null; + + var background = chart.GetChartComponent(); + if (background == null || background.borderStyle == null || !background.borderStyle.roundedCorner) + return null; + + var cornerRadius = background.borderStyle.cornerRadius; + if (cornerRadius == null || cornerRadius.Length == 0) + return null; + + float brLt = 0, brRt = 0, brRb = 0, brLb = 0; + bool needRound = false; + UGL.InitCornerRadius(cornerRadius, chartWidth, chartHeight, false, false, + ref brLt, ref brRt, ref brRb, ref brLb, ref needRound); + + if (!needRound) + return null; + + return new float[] + { + brLt * scaleFactor, + brRt * scaleFactor, + brRb * scaleFactor, + brLb * scaleFactor + }; + } + + private static float GetRoundedRectCoverage(float x, float y, float width, float height, + float radiusLt, float radiusRt, float radiusRb, float radiusLb, float aaWidth = 1f) + { + if (radiusLb > 0 && x < radiusLb && y < radiusLb) + { + var dx = x - radiusLb; + var dy = y - radiusLb; + var dist = Mathf.Sqrt(dx * dx + dy * dy); + var delta = radiusLb - dist; + if (delta >= aaWidth) return 1f; + if (delta <= -aaWidth) return 0f; + return Mathf.Clamp01((delta + aaWidth) / (2f * aaWidth)); + } + if (radiusLt > 0 && x < radiusLt && y > height - radiusLt) + { + var dx = x - radiusLt; + var dy = y - (height - radiusLt); + var dist = Mathf.Sqrt(dx * dx + dy * dy); + var delta = radiusLt - dist; + if (delta >= aaWidth) return 1f; + if (delta <= -aaWidth) return 0f; + return Mathf.Clamp01((delta + aaWidth) / (2f * aaWidth)); + } + if (radiusRt > 0 && x > width - radiusRt && y > height - radiusRt) + { + var dx = x - (width - radiusRt); + var dy = y - (height - radiusRt); + var dist = Mathf.Sqrt(dx * dx + dy * dy); + var delta = radiusRt - dist; + if (delta >= aaWidth) return 1f; + if (delta <= -aaWidth) return 0f; + return Mathf.Clamp01((delta + aaWidth) / (2f * aaWidth)); + } + if (radiusRb > 0 && x > width - radiusRb && y < radiusRb) + { + var dx = x - (width - radiusRb); + var dy = y - radiusRb; + var dist = Mathf.Sqrt(dx * dx + dy * dy); + var delta = radiusRb - dist; + if (delta >= aaWidth) return 1f; + if (delta <= -aaWidth) return 0f; + return Mathf.Clamp01((delta + aaWidth) / (2f * aaWidth)); + } + return 1f; + } + + private static void ApplyRoundedCornerClip(Texture2D tex, float[] cornerRadii) + { + if (tex == null || cornerRadii == null || cornerRadii.Length < 4) + return; + + var width = tex.width; + var height = tex.height; + if (width <= 0 || height <= 0) + return; + + var radiusLt = Mathf.Max(0, cornerRadii[0]); + var radiusRt = Mathf.Max(0, cornerRadii[1]); + var radiusRb = Mathf.Max(0, cornerRadii[2]); + var radiusLb = Mathf.Max(0, cornerRadii[3]); + if (radiusLt <= 0 && radiusRt <= 0 && radiusRb <= 0 && radiusLb <= 0) + return; + + var colors = tex.GetPixels32(); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var px = x + 0.5f; + var py = y + 0.5f; + var coverage = GetRoundedRectCoverage(px, py, width, height, + radiusLt, radiusRt, radiusRb, radiusLb, 1f); + if (coverage <= 0f) + { + var index = y * width + x; + var color = colors[index]; + color.a = 0; + colors[index] = color; + } + else if (coverage < 1f) + { + var index = y * width + x; + var color = colors[index]; + color.a = (byte)Mathf.Clamp(Mathf.RoundToInt(color.a * coverage), 0, 255); + colors[index] = color; + } + } + } + tex.SetPixels32(colors); + tex.Apply(); + } + + private static Color32 GetBackgroundColorRecursive(Transform parent) + { + if (parent == null) return new Color32(255, 255, 255, 255); + + // Try to find Image components with colors in child nodes + for (int i = 0; i < parent.childCount; i++) + { + var child = parent.GetChild(i); + var image = child.GetComponent(); + if (image != null && image.enabled && child.gameObject.activeInHierarchy) + { + var color = image.color; + if (color.a > 0) + { + // Found a visible background image + color.a = 1f; // Make it fully opaque for proper blending + return color; + } + } + // Recursively search child nodes + var foundColor = GetBackgroundColorRecursive(child); + if (foundColor.a > 0) + return foundColor; + } + + return Color.white; + } + + public static Texture2D SaveAsImage(RectTransform rectTransform, Canvas canvas, string imageType = "png", + string path = "", float exportScale = 1f, bool useRecursiveBackgroundColor = false) + { + if (rectTransform == null || canvas == null) + return null; + + var clampedExportScale = Mathf.Max(1f, exportScale); + var scaleFactor = canvas.scaleFactor <= 0 ? 1f : canvas.scaleFactor; + var outputScaleFactor = scaleFactor * clampedExportScale; + var width = Mathf.Max(1, Mathf.CeilToInt(rectTransform.rect.width * outputScaleFactor)); + var height = Mathf.Max(1, Mathf.CeilToInt(rectTransform.rect.height * outputScaleFactor)); + var chart = rectTransform.GetComponent(); + var cornerRadii = GetChartCornerRadius(chart, rectTransform.rect.width, rectTransform.rect.height, outputScaleFactor); + + Texture2D tex = null; + var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); + var antiAliasing = QualitySettings.antiAliasing > 0 ? QualitySettings.antiAliasing : 4; + rt.antiAliasing = Mathf.Clamp(antiAliasing, 1, 8); + + var oldActive = RenderTexture.active; + var captureLayer = 31; + var rootObj = new GameObject("xcharts_save_image_root"); + var camObj = new GameObject("xcharts_save_image_camera"); + var canvasObj = new GameObject("xcharts_save_image_canvas"); + var contentObj = new GameObject("xcharts_save_image_content", typeof(RectTransform)); + + try + { + SetLayerRecursively(rootObj, captureLayer); + SetLayerRecursively(camObj, captureLayer); + SetLayerRecursively(canvasObj, captureLayer); + SetLayerRecursively(contentObj, captureLayer); + + camObj.transform.SetParent(rootObj.transform, false); + var camera = camObj.AddComponent(); + camera.clearFlags = CameraClearFlags.SolidColor; + + // Get background color - try multiple sources for better results + Color32 bgColor = new Color32(255, 255, 255, 255); + var chartParent = rectTransform.parent; + + // First, try to get from chart's Background component + if (chart != null) + { + bgColor = chart.GetChartBackgroundColor(); + //bgColor.a = 255; + } + + // If enabled, find background color recursively from sibling nodes + if (useRecursiveBackgroundColor && (bgColor.a < 255 || + (bgColor.r == 255 && bgColor.g == 255 && bgColor.b == 255))) + { + var siblingBgColor = GetBackgroundColorRecursive(chartParent); + if (siblingBgColor.a > 0) + bgColor = siblingBgColor; + } + + camera.backgroundColor = bgColor; + + camera.cullingMask = 1 << captureLayer; + camera.orthographic = true; + camera.orthographicSize = height / 2f; + camera.nearClipPlane = -100; + camera.farClipPlane = 100; + camera.allowHDR = false; + camera.allowMSAA = rt.antiAliasing > 1; + camera.targetTexture = rt; + + canvasObj.transform.SetParent(rootObj.transform, false); + var captureCanvas = canvasObj.AddComponent(); + captureCanvas.renderMode = RenderMode.ScreenSpaceCamera; + captureCanvas.worldCamera = camera; + captureCanvas.planeDistance = 1; + captureCanvas.pixelPerfect = canvas.pixelPerfect; + captureCanvas.sortingOrder = 0; + canvasObj.AddComponent(); + + var canvasRect = canvasObj.GetComponent(); + canvasRect.anchorMin = Vector2.zero; + canvasRect.anchorMax = Vector2.one; + canvasRect.pivot = new Vector2(0.5f, 0.5f); + canvasRect.anchoredPosition = Vector2.zero; + canvasRect.sizeDelta = new Vector2(width, height); + + contentObj.transform.SetParent(canvasObj.transform, false); + var contentRect = contentObj.GetComponent(); + contentRect.anchorMin = new Vector2(0.5f, 0.5f); + contentRect.anchorMax = new Vector2(0.5f, 0.5f); + contentRect.pivot = rectTransform.pivot; + contentRect.anchoredPosition = Vector2.zero; + contentRect.sizeDelta = rectTransform.rect.size; + contentRect.localScale = new Vector3(clampedExportScale, clampedExportScale, 1f); + + // Clone sibling nodes (including background layers below chart) + var chartSiblingIndex = rectTransform.GetSiblingIndex(); + if (chartParent != null) + { + for (int i = 0; i < chartParent.childCount; i++) + { + var sibling = chartParent.GetChild(i); + // Only clone siblings below the chart (smaller sibling index) + if (i < chartSiblingIndex) + { + var siblingClone = GameObject.Instantiate(sibling.gameObject, contentObj.transform, false); + SetLayerRecursively(siblingClone, captureLayer); + } + } + } + + CloneChildrenRecursively(rectTransform, contentObj.transform, captureLayer); + + Canvas.ForceUpdateCanvases(); + camera.Render(); + + RenderTexture.active = rt; + tex = new Texture2D(width, height, TextureFormat.ARGB32, false); + tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); + tex.Apply(); + ApplyRoundedCornerClip(tex, cornerRadii); + } + finally + { + RenderTexture.active = oldActive; + RenderTexture.ReleaseTemporary(rt); + DestroyObject(rootObj); + } + + var bytes = EncodeImage(tex, imageType); + if (bytes == null) + return null; + var fileName = rectTransform.name + "." + imageType; #if UNITY_WEBGL string base64str = Convert.ToBase64String(bytes);