update 0.6.5

This commit is contained in:
2026-09-30 08:56:49 +08:00
parent d9f9b2a90f
commit 016aee57ee
30 changed files with 2471 additions and 7 deletions
@@ -0,0 +1,504 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XericLibrary.Runtime.UIGraph
{
/// <summary>
/// 一条面片切出来的一个贝塞尔小块:几何、逐点宽度、端点颜色、uv 进度区间。
/// 位置都在 Graphic 的局部平面内,与渲染器同一坐标约定。
/// </summary>
internal struct RibbonPiece
{
public Vector2 c0;
public Vector2 c1;
public Vector2 c2;
public Vector2 c3;
public float w0;
public float w1;
public float w2;
public float w3;
public Color32 startColor;
public Color32 endColor;
public float uvYStart;
public float uvYEnd;
public int ribbonIndex;
/// <summary>本小块占它所在样条段的比例,用来折算细分数</summary>
public float segmentFraction;
}
/// <summary>面片上 uv0.y 代表什么进度</summary>
public enum SplineUvProgressSpace
{
/// <summary>每条面片各自 0→1:尾部 0、头部 1,效果跟着已填充部分伸缩</summary>
RibbonLocal,
/// <summary>钉在整条样条上:贴图位置不随进度变化</summary>
SplineGlobal,
}
/// <summary>
/// 样条面片切分器:把若干条 (头部,尾部) 面片切成逐段贝塞尔小块。
/// <code>
/// 纯函数:不碰 MonoBehaviour,也不碰 Debug.Log —— 后者在 Unity 之外会抛
/// "ECall could not be defined",那样就没法在离线门里直接跑出数来验证。
/// 几何切分用贝塞尔在 [t0,t1] 上的精确限制(Restrict);
/// t0=0,t1=1 时逐点还原原始四个控制点,所以整段不产生任何漂移。
/// </code>
/// </summary>
internal static class SplineRibbonBuilder
{
internal struct Request
{
/// <summary>各控制点锚点(局部坐标),长度 = pointCount</summary>
public Vector2[] anchors;
/// <summary>各点的出手柄(局部坐标世界式,即已含锚点)</summary>
public Vector2[] outHandles;
/// <summary>各点的入手柄(局部坐标世界式,即已含锚点)</summary>
public Vector2[] inHandles;
/// <summary>各点颜色</summary>
public Color[] pointColors;
/// <summary>各点宽度</summary>
public float[] pointWidths;
public int pointCount;
public int segmentCount;
public bool curveLoop;
/// <summary>偶数下标=头部、奇数下标=尾部,成对;取值 [0,1]</summary>
public float[] headTailPairs;
/// <summary>可空;与面片同序时逐条叠乘到样条颜色上</summary>
public Color32[] ribbonTints;
/// <summary>进度 → 参数 t(距离校正就落在这个回调里)</summary>
public Func<float, float> progressToParametric;
public SplineUvProgressSpace uvSpace;
/// <summary>每条面片至少切成几块;决定 uv0.y 与视觉距离对齐的精度</summary>
public int chunksPerRibbon;
}
/// <summary>
/// 切分所有面片。无效输入返回空表而不是抛异常。
/// </summary>
internal static List<RibbonPiece> Build(Request req)
{
var pieces = new List<RibbonPiece>();
if (req.anchors == null || req.outHandles == null || req.inHandles == null)
return pieces;
if (req.headTailPairs == null || req.progressToParametric == null)
return pieces;
if (req.pointCount < 2 || req.segmentCount < 1 || req.headTailPairs.Length < 2)
return pieces;
int chunks = Mathf.Max(1, req.chunksPerRibbon);
int ribbonCount = req.headTailPairs.Length / 2;
for (int r = 0; r < ribbonCount; r++)
{
float head = Mathf.Clamp01(req.headTailPairs[r * 2]);
float tail = Mathf.Clamp01(req.headTailPairs[r * 2 + 1]);
if (Mathf.Abs(head - tail) <= 1e-6f)
continue;
BuildRibbon(req, r, tail, head, chunks, pieces);
}
return pieces;
}
/// <summary>
/// 面片沿路径的一段连续进度区间(起点进度、终点进度、起终点的参数 t)
/// </summary>
private struct Span
{
public float fromProgress;
public float toProgress;
public float fromParam;
public float toParam;
public float ArcLength;
}
private static void BuildRibbon(Request req, int ribbonIndex, float tail, float head,
int chunks, List<RibbonPiece> output)
{
var spans = ResolveSpans(req, tail, head);
if (spans.Count == 0)
return;
float segments = req.segmentCount;
Color32 tint = (req.ribbonTints != null && ribbonIndex < req.ribbonTints.Length)
? req.ribbonTints[ribbonIndex]
: new Color32(255, 255, 255, 255);
bool global = req.uvSpace == SplineUvProgressSpace.SplineGlobal;
int firstOfRibbon = output.Count;
// 与本次新增的 piece 一一对应的实测弧长:RibbonLocal 的 uv 分母就用这一份加总
var arcs = new List<float>();
foreach (var span in spans)
{
if (span.ArcLength <= 0f)
continue;
var cuts = CollectCuts(req, span);
int intervalCount = cuts.Count - 1;
float spanWalked = 0f;
for (int i = 0; i < intervalCount; i++)
{
float a = cuts[i];
float b = cuts[i + 1];
float arc = MeasureArc(req, a, b);
int seg = Mathf.Clamp(Mathf.FloorToInt(a * segments), 0, req.segmentCount - 1);
float localA = Mathf.Clamp01(a * segments - seg);
float localB = Mathf.Clamp01(b * segments - seg);
if (localB - localA <= 1e-6f)
continue;
Vector2 p0, p1, p2, p3;
GetSegment(req, seg, out p0, out p1, out p2, out p3);
float q0, q1, q2, q3;
GetWidthSegment(req, seg, out q0, out q1, out q2, out q3);
var piece = new RibbonPiece();
piece.ribbonIndex = ribbonIndex;
piece.segmentFraction = localB - localA;
Restrict(p0, p1, p2, p3, localA, localB,
out piece.c0, out piece.c1, out piece.c2, out piece.c3);
Restrict(q0, q1, q2, q3, localA, localB,
out piece.w0, out piece.w1, out piece.w2, out piece.w3);
piece.startColor = Tint(ColorAt(req, a), tint);
piece.endColor = Tint(ColorAt(req, b), tint);
// SplineGlobal:落在路径上的绝对进度,环绕时在 1→0 处天然断开。
// spanWalked 只在这里用 —— 它要的是"在本区间内走了多长"。
// RibbonLocal 的 uv 留给下面那一趟,用和分子同一份来源的弧长来分配。
if (global)
{
piece.uvYStart = ProgressAt(span, spanWalked / span.ArcLength);
piece.uvYEnd = ProgressAt(span, (spanWalked + arc) / span.ArcLength);
spanWalked += arc;
output.Add(piece);
continue;
}
spanWalked += arc;
output.Add(piece);
arcs.Add(arc);
}
}
if (global)
return;
// RibbonLocal:整条面片(含环绕的两段)合成一个连续的 0→1。
// <code>
// 分子分母必须同源。以前分母用 MeasureSpanArc(整个区间一次量完的折线长),
// 分子用逐小块的 MeasureArc(切得更细、量得更长),两者能差 0.2%,
// 于是中间的小块算出 uv > 1,末片又被钉成 1 ⇒ 出现"某片 uv 非增"。
// 更糟的是原先靠"emit 计数 == 预计块数"来认最后一块:被跳过的退化小块
// 也已经计过一次数 ⇒ 1 被压到中间某块上,下一块又从 0.6 起重来 ——
// 真机门量到的"跨尾面片 uv 回跳 4 次"就是这么来的。
// 现在出完片再按这份弧长统一分配,两端自然落在 0 与 1,中间跳过几块都不影响单调。
// </code>
float totalArc = 0f;
for (int i = 0; i < arcs.Count; i++)
totalArc += arcs[i];
if (arcs.Count == 0 || totalArc <= 0f)
{
output.RemoveRange(firstOfRibbon, output.Count - firstOfRibbon);
return;
}
float cursor = 0f;
for (int i = 0; i < arcs.Count; i++)
{
int pieceIndex = firstOfRibbon + i;
var piece = output[pieceIndex];
float from = cursor;
cursor += arcs[i];
piece.uvYStart = from / totalArc;
piece.uvYEnd = i == arcs.Count - 1 ? 1f : cursor / totalArc;
output[pieceIndex] = piece;
}
}
/// <summary>
/// 把一对 (头部,尾部) 展开成沿路径的区间。
/// head &gt; tail 是一个区间;循环样条上 head &lt; tail 表示"跨过终点绕回去",
/// 拆成 [tail,1] 与 [0,head] 两个区间,面片仍然连续。非循环样条不做环绕,直接不出片。
/// </summary>
private static List<Span> ResolveSpans(Request req, float tail, float head)
{
var spans = new List<Span>(req.curveLoop ? 2 : 1);
if (head > tail)
{
AddSpan(spans, req, tail, head);
return spans;
}
if (!req.curveLoop)
return spans;
AddSpan(spans, req, tail, 1f);
AddSpan(spans, req, 0f, head);
return spans;
}
private static void AddSpan(List<Span> spans, Request req, float fromProgress, float toProgress)
{
if (toProgress - fromProgress <= 1e-6f)
return;
// 退化:校正后端点仍然重合(长度为零的样条会出现),这个区间不出
float fromParam = Mathf.Clamp01(req.progressToParametric(fromProgress));
float toParam = Mathf.Clamp01(req.progressToParametric(toProgress));
if (toParam - fromParam <= 1e-6f)
return;
var span = new Span
{
fromProgress = fromProgress,
toProgress = toProgress,
fromParam = fromParam,
toParam = toParam,
};
span.ArcLength = MeasureSpanArc(req, fromParam, toParam);
spans.Add(span);
}
/// <summary>
/// 跨段的区间弧长:先按样条段边界切开,再逐段量。
/// MeasureArc 只能量"同一段内"的两点,直接拿来量整段会静默少算。
/// </summary>
private static float MeasureSpanArc(Request req, float a, float b)
{
float segments = req.segmentCount;
float sum = 0f;
float cursor = a;
while (cursor < b - 1e-6f)
{
int seg = Mathf.Clamp(Mathf.FloorToInt(cursor * segments), 0, req.segmentCount - 1);
float nextBoundary = (seg + 1f) / segments;
float stop = Mathf.Min(b, nextBoundary);
sum += MeasureArc(req, cursor, stop);
cursor = stop;
}
return sum;
}
/// <summary>区间内按弧长占比折算出该处的进度值</summary>
private static float ProgressAt(Span span, float fraction)
{
return Mathf.Clamp01(span.fromProgress + (span.toProgress - span.fromProgress) * fraction);
}
/// <summary>
/// 切分点:等进度网格 + 所有落在区间内的样条段边界,去重后升序。
/// 有了段边界,每个小块必然完整落在同一段贝塞尔里。
/// </summary>
private static List<float> CollectCuts(Request req, Span span)
{
int chunks = Mathf.Max(1, req.chunksPerRibbon);
var cuts = new List<float>(chunks + req.segmentCount + 2) { span.fromParam, span.toParam };
for (int s = 1; s < req.segmentCount; s++)
{
float boundary = (float)s / req.segmentCount;
if (boundary > span.fromParam && boundary < span.toParam)
cuts.Add(boundary);
}
for (int k = 1; k < chunks; k++)
{
float progress = span.fromProgress
+ (span.toProgress - span.fromProgress) * k / chunks;
cuts.Add(Mathf.Clamp01(req.progressToParametric(progress)));
}
cuts.Sort();
var unique = new List<float>(cuts.Count);
for (int i = 0; i < cuts.Count; i++)
{
if (unique.Count > 0 && cuts[i] - unique[unique.Count - 1] <= 1e-6f)
continue;
unique.Add(cuts[i]);
}
return unique;
}
/// <summary>段 seg 的四个控制点:本点锚、本点出手柄、下一点入手柄、下一点锚</summary>
private static void GetSegment(Request req, int seg,
out Vector2 p0, out Vector2 p1, out Vector2 p2, out Vector2 p3)
{
int next = NextPoint(req, seg);
p0 = req.anchors[seg];
p1 = req.outHandles[seg];
p2 = req.inHandles[next];
p3 = req.anchors[next];
}
/// <summary>
/// 宽度也走同一条贝塞尔:本段两端取本点宽、下段两端取下点宽。
/// 与几何用同一个 [t0,t1] 限制,带宽过渡才不会和形状错位。
/// </summary>
private static void GetWidthSegment(Request req, int seg,
out float w0, out float w1, out float w2, out float w3)
{
int next = NextPoint(req, seg);
w0 = WidthAt(req, seg);
w1 = WidthAt(req, seg);
w2 = WidthAt(req, next);
w3 = WidthAt(req, next);
}
private static int NextPoint(Request req, int seg)
{
return req.curveLoop
? PositiveMod(seg + 1, req.pointCount)
: Mathf.Min(seg + 1, req.pointCount - 1);
}
private static float WidthAt(Request req, int index)
{
if (req.pointWidths == null || req.pointWidths.Length == 0)
return 0f;
return req.pointWidths[Mathf.Clamp(index, 0, req.pointWidths.Length - 1)];
}
/// <summary>按渐层取色;索引越界一律夹紧,绝不裸索引(渐层列表可能比点少)</summary>
private static Color ColorAt(Request req, float parametric)
{
if (req.pointColors == null || req.pointColors.Length == 0)
return Color.white;
int count = req.pointColors.Length;
// 与 XericRectSpline.GetDeltaT 同一套映射(那个是 internal,Release 侧够不到),
// 所以取色和几何形状保持同相
float usable = req.curveLoop ? count : Mathf.Max(1, count - 1);
float scaled = Mathf.Clamp01(parametric) * usable;
int index = Mathf.FloorToInt(scaled);
float dt = Mathf.Clamp01(scaled - index);
int a = req.curveLoop ? PositiveMod(index, count) : Mathf.Clamp(index, 0, count - 1);
int b = req.curveLoop ? PositiveMod(index + 1, count) : Mathf.Clamp(index + 1, 0, count - 1);
return Color.Lerp(req.pointColors[a], req.pointColors[b], dt);
}
private static Color32 Tint(Color baseColor, Color32 tint)
{
var t = (Color)tint;
var r = new Color(baseColor.r * t.r, baseColor.g * t.g,
baseColor.b * t.b, baseColor.a * t.a);
return r;
}
private static int PositiveMod(int value, int modulus)
{
if (modulus <= 0)
return 0;
return ((value % modulus) + modulus) % modulus;
}
// ---------------------------------------------------------------- 贝塞尔裁剪
/// <summary>
/// 三次贝塞尔在 [t0,t1] 上的精确限制:
/// Q0=B(t0),Q1=B(t0)+(t1-t0)/3·B'(t0),Q2=B(t1)-(t1-t0)/3·B'(t1),Q3=B(t1)。
/// </summary>
internal static void Restrict(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3,
float t0, float t1, out Vector2 q0, out Vector2 q1, out Vector2 q2, out Vector2 q3)
{
float span = t1 - t0;
q0 = Eval(p0, p1, p2, p3, t0);
q1 = q0 + Derivative(p0, p1, p2, p3, t0) * (span / 3f);
q3 = Eval(p0, p1, p2, p3, t1);
q2 = q3 - Derivative(p0, p1, p2, p3, t1) * (span / 3f);
}
internal static void Restrict(float p0, float p1, float p2, float p3,
float t0, float t1, out float q0, out float q1, out float q2, out float q3)
{
float span = t1 - t0;
q0 = Eval(p0, p1, p2, p3, t0);
q1 = q0 + Derivative(p0, p1, p2, p3, t0) * (span / 3f);
q3 = Eval(p0, p1, p2, p3, t1);
q2 = q3 - Derivative(p0, p1, p2, p3, t1) * (span / 3f);
}
internal static Vector2 Eval(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t)
{
var a = Vector2.Lerp(p0, p1, t);
var b = Vector2.Lerp(p1, p2, t);
var c = Vector2.Lerp(p2, p3, t);
var d = Vector2.Lerp(a, b, t);
var e = Vector2.Lerp(b, c, t);
return Vector2.Lerp(d, e, t);
}
internal static float Eval(float p0, float p1, float p2, float p3, float t)
{
var a = Mathf.Lerp(p0, p1, t);
var b = Mathf.Lerp(p1, p2, t);
var c = Mathf.Lerp(p2, p3, t);
var d = Mathf.Lerp(a, b, t);
var e = Mathf.Lerp(b, c, t);
return Mathf.Lerp(d, e, t);
}
/// <summary>导数 = 3 × 一阶差分控制点的二次贝塞尔</summary>
internal static Vector2 Derivative(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t)
{
var d0 = p1 - p0;
var d1 = p2 - p1;
var d2 = p3 - p2;
var a = Vector2.Lerp(d0, d1, t);
var b = Vector2.Lerp(d1, d2, t);
return Vector2.Lerp(a, b, t) * 3f;
}
internal static float Derivative(float p0, float p1, float p2, float p3, float t)
{
var d0 = p1 - p0;
var d1 = p2 - p1;
var d2 = p3 - p2;
var a = Mathf.Lerp(d0, d1, t);
var b = Mathf.Lerp(d1, d2, t);
return Mathf.Lerp(a, b, t) * 3f;
}
/// <summary>小块弧长(折线近似,够 uv 分配用)</summary>
private static float MeasureArc(Request req, float a, float b)
{
const int samples = 5;
int seg = Mathf.Clamp(Mathf.FloorToInt(a * req.segmentCount), 0, req.segmentCount - 1);
Vector2 p0, p1, p2, p3;
GetSegment(req, seg, out p0, out p1, out p2, out p3);
float la = Mathf.Clamp01(a * req.segmentCount - seg);
float lb = Mathf.Clamp01(b * req.segmentCount - seg);
var last = Eval(p0, p1, p2, p3, la);
float sum = 0f;
for (int i = 1; i <= samples; i++)
{
var next = Eval(p0, p1, p2, p3, Mathf.Lerp(la, lb, (float)i / samples));
sum += Vector2.Distance(last, next);
last = next;
}
return sum;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2849f073cdc1b8028779b7b6c5fedfe3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using XericLibrary.Runtime.XericComponent;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace XericLibrary.Runtime.UIGraph
{
/// <summary>
/// 由界面样条(XericRectSplinePath)驱动的曲线绘制组件。
/// <code>
/// 一条样条上可以同时铺多条"面片",每条由一对 (头部, 尾部) 进度值决定覆盖范围,
/// 于是 {1,0} 是铺满整条的底轨,{0.6f,0} 就是填到 60% 的进度条本体。
/// 进度默认经样条烘焙后的弧长重映射校正,画面上的推进长度才和进度值成正比;
/// 面片上的 uv0.y 被重映射成整条面片的 0→1,材质按 UV 就能做流动/遮罩类效果。
/// </code>
/// </summary>
[AddComponentMenu("Xeric Library/UI/UISplineCurveRenderer", 17)]
public class UISplineCurveRenderer : CurveCacheRendererBase
{
#region 字段属性
/// <summary>驱动本曲线的界面样条</summary>
public XericRectSplinePath splinePath;
/// <summary>
/// 面片对:偶数下标=头部、奇数下标=尾部,取值 [0,1]。
/// 例:{1,0} 一条铺满;{1,0, 0.6f,0} 两条=底轨 + 60% 填充。
/// </summary>
public float[] headTailPairs = { 1f, 0f };
/// <summary>可空;与面片同序时逐条叠乘到样条颜色上(底轨压暗、填充提亮)</summary>
public Color32[] ribbonTints;
/// <summary>进度值的含义:直接按参数,还是先经烘焙做弧长校正</summary>
public SplineProgressMapping progressMapping = SplineProgressMapping.BakedDistance;
/// <summary>烘焙采样数,越大校正越细;同时压住库侧 1/(N+1) 的系统偏差</summary>
[Range(8, 4096)]
public int bakeSubdivide = 256;
/// <summary>
/// 什么时候做弧长烘焙。默认 OnEditEnd:几何停止变化后补烘一次。
/// 烘焙是 O(subdivide) 的采样,放在每帧会把编辑器拖到卡。
/// </summary>
public SplineBakeTrigger bakeTrigger = SplineBakeTrigger.OnEditEnd;
/// <summary>已经烘过几次(排查用:编辑器里拖动一整轮应该只加 1)</summary>
public int BakeRunCount => m_BakeRunCount;
/// <summary>面片上 uv0.y 代表条内进度还是全样条进度</summary>
public SplineUvProgressSpace uvProgressSpace = SplineUvProgressSpace.RibbonLocal;
/// <summary>每条面片至少切成几块,决定 uv 与视觉距离对齐的精度</summary>
[Range(1, 64)]
public int chunksPerRibbon = 8;
/// <summary>基准带宽(样条所在矩形的局部像素)</summary>
[Min(0.01f)]
public float curveWidth = 10f;
/// <summary>带宽来源:常量,还是乘上样条的缩放渐层 Y</summary>
public SplineWidthSource widthSource = SplineWidthSource.ScaleGradientY;
/// <summary>单段贝塞尔的细分段数</summary>
[Range(2, 128)]
public int tessellationSegments = 32;
/// <summary>样条颜色渐层全透明时的兜底颜色</summary>
public Color fallbackColor = Color.white;
/// <summary>
/// 推给材质 _MainTex 的贴图。
/// Canvas 是用 MaterialPropertyBlock 把 Graphic.mainTexture 顶到 _MainTex 上的,
/// 所以必须走这个口子交出去;只改 material 的贴图会被默认白图覆盖掉。
/// </summary>
public Texture progressTexture;
/// <summary>SetProgress 作用在哪条面片的头部;-1 表示不指定</summary>
public int fillRibbonIndex = 1;
/// <summary>每帧跟随样条变化重建(关掉后只能靠 RebuildCurve 手动触发)</summary>
public bool followSplineChanges = true;
/// <summary>与曲线缓存同序的 uv 区间,x=起点 y=终点</summary>
private readonly List<Vector2> m_PieceUv = new List<Vector2>();
private bool m_NeedUvPatch;
private int m_LastBakeSignature = int.MinValue;
private bool m_BakePending;
private int m_BakeRunCount;
#endregion
#region 生命周期
public override Texture mainTexture
=> progressTexture != null ? progressTexture : base.mainTexture;
protected override void OnEnable()
{
base.OnEnable();
RebuildCurve();
}
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
if (curveWidth < 0.01f) curveWidth = 0.01f;
if (tessellationSegments < 2) tessellationSegments = 2;
if (followSplineChanges && isActiveAndEnabled)
{
EditorApplication.delayCall += () =>
{
if (this != null && isActiveAndEnabled)
RebuildCurve();
};
}
}
#endif
protected override void LateUpdate()
{
if (followSplineChanges)
RebuildCurve();
base.LateUpdate();
// 只能在"刚刚重建过缓存"之后改写一次:
// 改写后 uv0.y 就不再是生成器写的段内 t,重复改写会把映射套两遍
if (m_NeedUvPatch)
{
m_NeedUvPatch = false;
ApplyRibbonUv();
}
}
#endregion
#region API
/// <summary>
/// 把指定面片的头部推到 value(进度条前进就调这个)
/// </summary>
public void SetProgress(float value)
{
int index = fillRibbonIndex >= 0 ? fillRibbonIndex : 0;
if (headTailPairs == null || headTailPairs.Length < (index + 1) * 2)
return;
headTailPairs[index * 2] = Mathf.Clamp01(value);
RebuildCurve();
}
/// <summary>
/// 读取指定面片的头部进度
/// </summary>
public float GetProgress()
{
int index = fillRibbonIndex >= 0 ? fillRibbonIndex : 0;
if (headTailPairs == null || headTailPairs.Length < (index + 1) * 2)
return 0f;
return headTailPairs[index * 2];
}
/// <summary>
/// 按当前样条与面片配置重建曲线
/// </summary>
public void RebuildCurve()
{
ClearAll();
m_PieceUv.Clear();
m_NeedUvPatch = false;
if (splinePath == null || rectTransform == null)
return;
var spline = splinePath.xericSpline;
if (spline == null || spline.Count < 2)
return;
// 锚点→世界的换算依赖矩形角点
splinePath.UpdateRectDepend();
var rect = rectTransform.rect;
if (Mathf.Abs(rect.width) < 1e-6f || Mathf.Abs(rect.height) < 1e-6f)
return;
int pointCount = spline.Count;
var anchors = new Vector2[pointCount];
var outHandles = new Vector2[pointCount];
var inHandles = new Vector2[pointCount];
var colors = new Color[pointCount];
var widths = new float[pointCount];
int signature = pointCount;
for (int i = 0; i < pointCount; i++)
{
anchors[i] = (Vector2)rectTransform.InverseTransformPoint(splinePath.GetPointPosition(i));
outHandles[i] = (Vector2)rectTransform.InverseTransformPoint(splinePath.GetEndHandlePosition(i));
inHandles[i] = (Vector2)rectTransform.InverseTransformPoint(splinePath.GetStartHandlePosition(i));
colors[i] = ColorAt(spline, i);
widths[i] = WidthAt(spline, i);
unchecked
{
signature = signature * 31 + anchors[i].GetHashCode()
+ outHandles[i].GetHashCode() * 7 + inHandles[i].GetHashCode() * 13;
}
}
// 非均匀缩放矩形会改变世界空间弧长的比例,签名要把矩形尺寸也算进去
unchecked
{
signature = signature * 31 + rect.width.GetHashCode() + rect.height.GetHashCode() * 3;
}
var request = new SplineRibbonBuilder.Request
{
anchors = anchors,
outHandles = outHandles,
inHandles = inHandles,
pointColors = colors,
pointWidths = widths,
pointCount = pointCount,
segmentCount = spline.useablePointCount,
curveLoop = spline.CurveLoop,
headTailPairs = headTailPairs,
ribbonTints = ribbonTints,
uvSpace = uvProgressSpace,
chunksPerRibbon = chunksPerRibbon,
progressToParametric = BuildMapping(spline, signature),
};
var pieces = SplineRibbonBuilder.Build(request);
if (pieces.Count == 0)
return;
var ctrl = new Vector2[4];
var pieceWidths = new float[4];
foreach (var piece in pieces)
{
ctrl[0] = piece.c0; ctrl[1] = piece.c1; ctrl[2] = piece.c2; ctrl[3] = piece.c3;
pieceWidths[0] = piece.w0; pieceWidths[1] = piece.w1;
pieceWidths[2] = piece.w2; pieceWidths[3] = piece.w3;
var entry = new CurveCacheEntry
{
startColor = piece.startColor,
endColor = piece.endColor,
tessellationSegments = PieceTessellation(piece),
};
AddCurve(entry, (Vector2[])ctrl.Clone(), (float[])pieceWidths.Clone());
m_PieceUv.Add(new Vector2(piece.uvYStart, piece.uvYEnd));
}
m_NeedUvPatch = m_PieceUv.Count > 0;
}
#endregion
#region 取数
/// <summary>
/// 造出"进度 → 参数 t"的映射。校正走样条烘焙的弧长重映射,
/// 库侧要求先烘焙且长度不足时会抛,所以这里自己把关并回落。
/// </summary>
private Func<float, float> BuildMapping(XericRectSpline spline, int signature)
{
if (progressMapping != SplineProgressMapping.BakedDistance)
return ParametricIdentity;
if (!EnsureBaked(spline, signature))
return ParametricIdentity;
return ParametricFromBakedDistance;
}
private float ParametricIdentity(float progress) => progress;
/// <summary>
/// 进度→参数 t。两端是边界真值:进度 0 就是曲线起点、1 就是终点,
/// 所以先把两端钉死再走重映射 —— 库里的弧长重映射原本按"采样点数"而不是"段数"
/// 做分母,进度 1 会落在 (N-1)/N,{1,0} 的面片就永远短一小截贴不到末尾。
/// 库侧已经修了分母,这里钉端点既是兜底也让旧 DLL 下也能立刻闭合。
/// </summary>
private float ParametricFromBakedDistance(float progress)
{
if (progress <= 0f) return 0f;
if (progress >= 1f) return 1f;
return Mathf.Clamp01(splinePath.RemappingTimeValue(progress));
}
/// <summary>
/// 按 <see cref="bakeTrigger"/> 决定这一趟烘不烘,返回 false 表示当前用不了弧长校正。
/// <code>
/// 烘焙是 O(subdivide) 的采样:以前几何签名一变就烘,编辑器里拖动控制点等于每帧烘一次。
/// OnEditEnd 的做法是"几何正在线上变"时先挂起、继续用上一次那份烘焙数据,
/// 等下一趟签名不再变化(编辑停了)才补烘那一次。
/// </code>
/// </summary>
private bool EnsureBaked(XericRectSpline spline, int signature)
{
int target = Mathf.Max(bakeSubdivide, spline.Count);
bool geometryChanged = signature != m_LastBakeSignature;
m_LastBakeSignature = signature;
switch (bakeTrigger)
{
case SplineBakeTrigger.Manual:
// 一次都不自动烘,等 BakeNow
return HasUsableBake(spline, target);
case SplineBakeTrigger.EveryRebuild:
if (!geometryChanged && HasUsableBake(spline, target))
return true;
return RunBake(spline, target);
default:
if (geometryChanged)
{
// 几何正在线上变:这一趟不烘,也**不用**上一份烘焙数据。
// 结构大改(清点重铺、切循环)后那份表对应的已经不是这条曲线了,
// 拿它做校正会把面片排到错处 —— 宁可这一趟退回参数映射,
// 等编辑停下来的下一趟烘完再恢复校正。
m_BakePending = true;
return false;
}
if (!m_BakePending)
return HasUsableBake(spline, target);
m_BakePending = false;
return RunBake(spline, target);
}
}
/// <summary>烘焙数据存在且长度够用(旧数据只要长度够就继续用,别再烘一次)</summary>
private static bool HasUsableBake(XericRectSpline spline, int target)
=> spline.BakeCount >= target;
private bool RunBake(XericRectSpline spline, int target)
{
splinePath.BakePoint(target);
m_BakeRunCount++;
return spline.BakeCount >= target;
}
/// <summary>
/// 手动烘一次(bakeTrigger 为 Manual 时用这个,或改完曲线想立刻精确时按)。
/// 烘完直接重建,按下就能看见贴合结果。
/// </summary>
public void BakeNow()
{
if (splinePath == null || splinePath.xericSpline == null)
return;
var spline = splinePath.xericSpline;
RunBake(spline, Mathf.Max(bakeSubdivide, spline.Count));
m_BakePending = false;
RebuildCurve();
}
private Color ColorAt(XericRectSpline spline, int index)
{
var gradient = spline.ColorGradient;
if (gradient == null || index < 0 || index >= gradient.Count)
return fallbackColor;
var color = gradient[index];
return color.a <= 0f ? fallbackColor : color;
}
private float WidthAt(XericRectSpline spline, int index)
{
switch (widthSource)
{
case SplineWidthSource.ScaleGradientY:
return curveWidth * Mathf.Abs(GradientAt(spline.ScaleGradient, index, Vector3.one).y);
case SplineWidthSource.FloatGradient:
// 额外参数本来就是"每点多大"的语义时用它最直观;
// 注意它默认全 0,不填就什么都画不出来
return curveWidth * Mathf.Max(0f, GradientAt(spline.FloatGradient, index, 1f));
default:
return curveWidth;
}
}
/// <summary>渐层可能比点数短(在 Inspector 里直接改 ControlPoint 长度就会这样),一律夹紧</summary>
private static T GradientAt<T>(SplineGradientBase<T> gradient, int index, T fallback)
{
if (gradient == null || index < 0 || index >= gradient.Count)
return fallback;
return gradient[index];
}
private int PieceTessellation(RibbonPiece piece)
{
int tess = Mathf.RoundToInt(tessellationSegments * Mathf.Clamp01(piece.segmentFraction));
return Mathf.Clamp(tess, 2, 512);
}
#endregion
#region uv 重写
/// <summary>
/// 把生成器写在每个小块里的"段内 t"重映射成整条面片的 0→1。
/// 默认材质只用到 uv0.x(边缘羽化),uv0.y 留给换上去的材质消费。
/// </summary>
private void ApplyRibbonUv()
{
if (m_PieceUv.Count == 0)
return;
for (int i = 0; i < m_Curves.Count && i < m_PieceUv.Count; i++)
{
var curve = m_Curves[i];
var range = m_PieceUv[i];
int end = curve.startVertexIndex + curve.vertexCount;
if (end > m_MeshVertexCache.Count)
end = m_MeshVertexCache.Count;
for (int v = curve.startVertexIndex; v < end; v++)
{
var vert = m_MeshVertexCache[v];
vert.uv0.y = Mathf.LerpUnclamped(range.x, range.y, vert.uv0.y);
m_MeshVertexCache[v] = vert;
}
}
SetVerticesDirty();
}
#endregion
}
/// <summary>进度值如何换算成曲线参数</summary>
public enum SplineProgressMapping
{
/// <summary>进度直接当参数 t:段与段的视觉长度不等</summary>
Parametric,
/// <summary>进度按烘焙后的弧长重映射:画面上推进的长度和进度成正比</summary>
BakedDistance,
}
/// <summary>弧长烘焙的触发时机(校正的数据源就是这份烘焙数据)</summary>
public enum SplineBakeTrigger
{
/// <summary>从不自动烘,只认 BakeNow / Inspector 上的烘焙按钮</summary>
Manual,
/// <summary>几何停止变化后补烘一次:编辑器里拖动控制点不会逐帧烘</summary>
OnEditEnd,
/// <summary>几何一变就烘(每帧一变烘一次,贵;只在需要立刻精确时用)</summary>
EveryRebuild,
}
/// <summary>带宽来源</summary>
public enum SplineWidthSource
{
/// <summary>恒用 curveWidth</summary>
Constant,
/// <summary>curveWidth × 该点缩放渐层的 Y(渐层默认是 1,不填也等价于常量)</summary>
ScaleGradientY,
/// <summary>curveWidth × 该点的额外参数。默认全 0,不填什么都画不出来</summary>
FloatGradient,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 91cbc8bc694b85a8f15fefce8d3a72b1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using UnityEngine;
namespace XericLibrary.Runtime.UIGraph
{
/// <summary>
/// 曲线进度条的播放驱动器:把时间推成 UISplineCurveRenderer 的进度。
/// 只负责"进度从哪来",几何与着色全在渲染器那一侧。
/// </summary>
[AddComponentMenu("Xeric Library/UI/UISplineProgressDriver", 18)]
[RequireComponent(typeof(UISplineCurveRenderer))]
public class UISplineProgressDriver : MonoBehaviour
{
/// <summary>自动推进(关掉就只认手动 SetProgress)</summary>
public bool autoDrive = true;
/// <summary>从 0 走到 1 所需秒数</summary>
[Min(0.01f)]
public float duration = 2.5f;
/// <summary>用 scaledTime,随引擎暂停/缩放</summary>
public bool useUnscaledTime = false;
/// <summary>走到尽头后往返还是从头再来</summary>
public bool pingPong = false;
/// <summary>起始进度</summary>
[Range(0f, 1f)]
public float startProgress = 0f;
private UISplineCurveRenderer m_Renderer;
private float m_Elapsed;
private void Awake()
{
m_Renderer = GetComponent<UISplineCurveRenderer>();
m_Elapsed = startProgress * duration;
if (m_Renderer != null)
m_Renderer.SetProgress(startProgress);
}
private void Update()
{
if (!autoDrive || m_Renderer == null)
return;
m_Elapsed += useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
float cycle = m_Elapsed / duration;
float progress = pingPong
? Mathf.PingPong(cycle, 1f)
: cycle - Mathf.Floor(cycle);
m_Renderer.SetProgress(progress);
}
/// <summary>手动设进度,同时让自动推进从该处继续</summary>
public void SetProgress(float value)
{
value = Mathf.Clamp01(value);
m_Elapsed = value * duration;
if (m_Renderer != null)
m_Renderer.SetProgress(value);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4f851c7a6653a78879ce665e643f5a28
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: