Unity高性能无GC图表
引言
最近有一个数字孪生项目,有一个绘制折线图图表的需求,数据量挺大,而且上位机发送数据很频繁,需要记录数据,并且自动覆写最老的数据,考察了网上的一些现成的插件,发现并不太满足我的需求:
- 插件不是线程安全的,但我网络接收数据用的UDP线程池+完成端口,如果非线程安全则需要额外处理。
- 性能低。数据结构用的List,有GC,当数据达到最大缓存数量时,他会Remove掉最老的一个,然后添加一个新的,这就会引起GC。
- 太臃肿,配置复杂,还缺乏文档,对于一个简单的折线图,配置很多额外没用的东西,很麻烦。
从数据结构开始
写一个无GC、线程安全的数据容器,是必要的,思路是,使用定长数组,做成循环队列,当队列满时,自动覆写最旧的数据(其实只需要移动首位索引),因此数据容器本身没有内存分配和释放,完全的0GC,而且性能很好。
思路:
- 分别设置一个头索引和一个尾索引,头索引和尾索引相等时,队列为空,尾索引的下一个索引值等于头索引时,队列满,这里没有满的情况,如果满了,则把头索引也移动到下一位,保持元素总数不变。
- 计算索引时,使用位运算代替取余运算,提高性能,如下:
使用( index + i ) & mask
代替( index + i ) % count
运算,但是这样的问题是,总体容量必须是2的幂,但这完全可以接受,而且某种意义上更好。
使用读写锁,分别完成读和写的线程同步。
代码如下:
using System;
using System.Threading;
using UnityEngine;
namespace HXUtilities.HXQueue
{
/// <summary>
/// 线程安全的循环队列实现,使用数组存储并提供高效入队操作
/// 容量固定为2的幂次方,支持通过逻辑索引访问元素和范围枚举
/// 采用ReaderWriterLockSlim实现并发控制
/// </summary>
/// <typeparam name="T">队列元素类型</typeparam>
public class HXOverwritableCircularQueue<T>
{
public delegate void OnDataEnqueuedHandler(T v, int count);
public event OnDataEnqueuedHandler OnDataEnqueued;
private int _head;
private int _tail;
private int _count;
private readonly int _capacity;
private readonly int _mask;
private readonly ReaderWriterLockSlim _lock = new ();
private readonly T[] _buffer;
/// <summary>
/// 获取队列当前元素数量(线程安全)
/// </summary>
public int Count
{
get
{
_lock.EnterReadLock();
try
{
return _count;
}
finally
{
_lock.ExitReadLock();
}
}
}
/// <summary>
/// 获取队列的最大容量
/// </summary>
public int Capacity => _capacity;
/// <summary>
/// 将元素添加到队列尾部
/// </summary>
/// <param name="item">要添加的元素</param>
/// <remarks>
/// 当队列已满时,新元素会覆盖最旧的元素(先进先出覆盖策略)
/// </remarks>
public void Enqueue(T item)
{
_lock.EnterWriteLock();
try
{
_buffer[_tail] = item;
_tail = (_tail + 1) & _mask;
// 队列未满时增加计数,已满时移动头指针实现覆盖
if (_count < _capacity)
_count++;
else
_head = (_head + 1) & _mask;
}
finally
{
OnDataEnqueued?.Invoke(item, _count);
_lock.ExitWriteLock();
}
}
/// <summary>
/// 通过逻辑索引访问队列元素
/// </summary>
/// <param name="index">逻辑索引(从队列头部开始计算)</param>
/// <returns>对应位置的元素</returns>
/// <exception cref="IndexOutOfRangeException">当索引超出有效范围时抛出</exception>
public T this[int index]
{
get
{
_lock.EnterReadLock();
try
{
if (index < 0 || index >= _count)
throw new IndexOutOfRangeException($"Index must be between 0 and {_count - 1}.");
// 将逻辑索引转换为物理存储位置
int actualIndex = (_head + index) & _mask;
return _buffer[actualIndex];
}
finally
{
_lock.ExitReadLock();
}
}
}
public T Last => this[_count - 1];
public T First => this[0];
/// <summary>
/// 批量处理队列中的最后N个元素
/// </summary>
/// <param name="count"></param>
/// <param name="callback"></param>
public void EnumerateLast(int count, Action<T> callback)
{
EnumerateRange(_count - count, _count - 1, callback);
}
/// <summary>
/// 高性能范围枚举回调
/// </summary>
/// <param name="startIndex">起始逻辑索引(包含)</param>
/// <param name="endIndex">结束逻辑索引(包含)</param>
/// <param name="callback">元素处理回调</param>
public void EnumerateRange(int startIndex, int endIndex, Action<T> callback)
{
if (callback == null)
throw new ArgumentNullException(nameof(callback));
_lock.EnterReadLock();
try
{
int maxIndex = _count - 1;
startIndex = Mathf.Clamp(startIndex, 0, maxIndex);
endIndex = Mathf.Clamp(endIndex, 0, maxIndex);
if (endIndex < startIndex)
return;
// 计算物理起始位置和可能的数组回绕情况
int physicalStart = (_head + startIndex) & _mask;
int elementsToProcess = endIndex - startIndex + 1;
int elementsUntilWrap = _capacity - physicalStart;
// 处理连续存储段或分段处理回绕情况
if (elementsToProcess <= elementsUntilWrap)
ProcessSegment(physicalStart, elementsToProcess, callback);
else
{
ProcessSegment(physicalStart, elementsUntilWrap, callback);
ProcessSegment(0, elementsToProcess - elementsUntilWrap, callback);
}
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// 处理连续存储段的元素回调
/// </summary>
/// <param name="start">物理存储起始位置</param>
/// <param name="count">需要处理的元素数量</param>
/// <param name="callback">元素处理回调</param>
private void ProcessSegment(int start, int count, Action<T> callback)
{
for (int i = 0; i < count; i++)
{
callback(_buffer[start + i]);
}
}
/// <summary>
/// 构造循环队列实例
/// </summary>
/// <param name="capacity">初始容量(会自动调整为最近的2的幂次方,最小为8)</param>
public HXOverwritableCircularQueue(int capacity = 8)
{
_capacity = PowerOfTwo(capacity);
_buffer = new T[_capacity];
_mask = _capacity - 1;
}
/// <summary>
/// 克隆方法
/// </summary>
/// <param name="source">源队列实例</param>
public void Clone(HXOverwritableCircularQueue<T> source)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (_capacity != source._capacity)
throw new InvalidOperationException(
$"Capacity mismatch: current={_capacity}, source={source._capacity}");
source._lock.EnterReadLock();
_lock.EnterWriteLock();
try
{
_count = source._count;
_head = source._head;
_tail = source._tail;
Array.Copy(
sourceArray: source._buffer,
sourceIndex: 0,
destinationArray: _buffer,
destinationIndex: 0,
length: source._capacity
);
}
finally
{
_lock.ExitWriteLock();
source._lock.ExitReadLock();
}
}
/// <summary>
/// 将输入值调整为2的幂次方
/// </summary>
/// <param name="n">原始容量值</param>
/// <returns>不小于输入值的最小2的幂次方数值</returns>
private static int PowerOfTwo(int n)
{
if (n <= 8)
return 8;
n--;
n |= n >> 1;
n |= n >> 2;
n |= n >> 4;
n |= n >> 8;
n |= n >> 16;
n++;
return n;
}
}
}
多线程折线图表
绘制思路,本来想使用Shader来绘制折线,但是有点复杂,尤其是当一个图表中有多个折线系列时,不好整,所以写一个UGUI组件,继承自Graphic
,通过OnPopulateMesh
来实现绘制。主要的思路和实现的功能:
- 由处理网络接收的线程池,直接将收到的数据压入循环队列。
- 当循环队列有数据被压入时,折线类通过注册循环队列的
OnDataEnqueued
事件,得到数据更新通知,然后重绘折线。- 循环队列的容量可能非常大,比如可以缓存10000多个数据,但是折线类可以只截取其中的一部分数据进行折线绘制。
- 图表数据有三种更新模式:①当注册循环队列的数据入队事件时,图表可自动更新(取最后n个数据)。②折线图可以将数据队列的某一个时刻进行快照保存,这样,数据队列数据仍然在更新,但是图表可以暂停显示在某一个时刻。③不注册队列入队事件,而是手动手动设置数据偏移,从队列中截取指定位置和数量的数据用于展示(查询数据的回放)。
首先定义一个折线的系列类:
public class LineSeries
{
public string name; // 系列名称
public Color color = Color.white; // 折线颜色
public float lineWidth = 2f; // 线宽
public bool bVisible = true; // 是否可见
public HXConcurrentLoopQueue<float> data; // 对应的数据队列
}
折线类,关键的成员变量:
public class HXLineChart : Graphic
{
private int bRedrawGraph; // 是否有数据更新(1,需要更新,0不需要更新)
public int maxDataPoints = 100; // 折线图中最大显示多少个数据
private enum DataMode
{
AutoLast,
DataSnapshort,
Dataoffset
}
private DataMode dataMode = DataMode.AutoLast;
// .................
}
添加系列的方法:
public void AddSeries(string seriesName, HXConcurrentLoopQueue<float> data, Color seriesColor,
float lineWidth = 1.2f, bool bVisible = true)
{
if (_series.ContainsKey(seriesName) || data == null)
return;
_series.TryAdd(seriesName, new LineSeries
{
name = seriesName,
color = seriesColor,
bVisible = bVisible,
lineWidth = lineWidth,
data = data
});
data.OnDataEnqueued += _ => Interlocked.Exchange(ref bRedrawGraph, 1);
}
绘制:
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
_vertexCache.Clear();
_indexCache.Clear();
if (_series.Count == 0) return;
// Calculate graph area
var rect = GetPixelAdjustedRect();
_graphRect = new Rect(
rect.x + graphLeftTopOffset.x,
rect.y + graphLeftTopOffset.y,
rect.width - (graphLeftTopOffset.x + graphRightBottomOffset.x),
rect.height - (graphLeftTopOffset.y + graphRightBottomOffset.y)
);
// 自动更新最小值和最大值
if (bAutoMinMaxValue)
CalculateMinMaxValues();
// 绘制坐标轴
DrawAxes();
// 绘制折线
foreach (var t in _series.Values)
{
if (t.bVisible && t.data.Count > 1)
DrawLineSeries(t);
}
if (_showVerticalLine)
{
DrawVerticalLine();
}
vh.AddUIVertexStream(_vertexCache, _indexCache);
}
/// 计算最大最小值
private void CalculateMinMaxValues()
{
float localmin = float.MaxValue;
float localmax = float.MinValue;
bool hasData = false;
// 统一处理数据的委托
Action<float> updateMinMax = value =>
{
// ReSharper disable AccessToModifiedClosure
if (value < localmin) localmin = value;
if (value > localmax) localmax = value;
hasData = true;
};
foreach (var series in _series.Values)
{
if (!series.bVisible)
continue;
switch (dataMode)
{
case DataMode.AutoLast: // 自动更新模式,显示
series.data.EnumerateLast(maxDataPoints, updateMinMax);
break;
case DataMode.DataSnapshort: // 数据快照模式(数据队列仍然在更新,折线暂停显示某一时刻)
{
foreach (var point in series.dataSnapshort)
updateMinMax(point);
break;
}
default: // 数据
series.data.EnumerateRange(dataOffset, dataOffset + maxDataPoints, updateMinMax);
break;
}
}
// 无数据时回退到默认范围
if (!hasData)
{
localmin = 0;
localmax = 1;
}
// 处理相等情况(避免除零)
if (Mathf.Approximately(localmin, localmax))
{
// 防御极端值溢出
if (localmin > float.MinValue + 1) localmin -= 1;
else localmin = float.MinValue;
if (localmax < float.MaxValue - 1) localmax += 1;
else localmax = float.MaxValue;
}
// 计算动态边距
float range = localmax - localmin;
localmin -= range * _yAxisMargin;
localmax += range * _yAxisMargin;
// 仅当值变化时触发事件
if (!Mathf.Approximately(localmin, _minValue) || !Mathf.Approximately(localmax, _maxValue))
{
_minValue = localmin;
_maxValue = localmax;
OnMinMaxValueChanged?.Invoke(_minValue, _maxValue);
}
}
private void DrawAxes()
{
// X and Y axes
DrawLine(
new Vector2(_graphRect.xMin, _graphRect.yMin),
new Vector2(_graphRect.xMax, _graphRect.yMin),
_axisColor, _axisWidth);
DrawLine(
new Vector2(_graphRect.xMin, _graphRect.yMin),
new Vector2(_graphRect.xMin, _graphRect.yMax),
_axisColor, _axisWidth);
// Y axis ticks
for (int i = 0; i <= _yAxisTicks; i++)
{
float normalized = i / (float)_yAxisTicks;
//float value = Mathf.Lerp(_minValue, _maxValue, normalized);
float yPos = Mathf.Lerp(_graphRect.yMin, _graphRect.yMax, normalized);
// Draw tick
DrawLine(
new Vector2(_graphRect.xMin, yPos),
new Vector2(_graphRect.xMin - _tickLength, yPos),
_tickColor, _tickWidth);
// Draw value label (using shader for text is complex, so using UI Text is acceptable)
// In a production environment, consider using TextMeshPro and object pooling
}
// X axis ticks
for (int i = 0; i <= _xAxisTicks; i++)
{
float normalized = i / (float)_xAxisTicks;
float xPos = Mathf.Lerp(_graphRect.xMin, _graphRect.xMax, normalized);
// Draw tick
DrawLine(
new Vector2(xPos, _graphRect.yMin),
new Vector2(xPos, _graphRect.yMin - _tickLength),
_tickColor, _tickWidth);
}
}
private void DrawLineSeries(LineSeries series)
{
int dataCount = dataMode != DataMode.DataSnapshort ? series.data.Count : series.dataSnapshort.Length;
int i = 0;
Vector2 prevPoint = Vector2.zero;
bool isFirstPoint = true;
switch (dataMode)
{
case DataMode.AutoLast:
series.data.EnumerateLast(maxDataPoints, Call);
break;
case DataMode.DataSnapshort:
{
foreach( var value in series.dataSnapshort )
Call(value);
break;
}
default:
series.data.EnumerateRange(dataOffset, dataOffset + maxDataPoints, Call);
break;
}
return;
void Call(float value)
{
float normalizedX = dataCount < maxDataPoints
? (maxDataPoints - dataCount + i) / (float)(maxDataPoints - 1)
: i / (float)(maxDataPoints - 1);
float normalizedY = Mathf.InverseLerp(_minValue, _maxValue, value);
Vector2 point = new Vector2(Mathf.Lerp(_graphRect.xMin, _graphRect.xMax, normalizedX), Mathf.Lerp(_graphRect.yMin, _graphRect.yMax, normalizedY));
if (!isFirstPoint)
{
DrawLine(prevPoint, point, series.color, series.lineWidth);
}
prevPoint = point;
isFirstPoint = false;
++i;
}
}
private void DrawLine(Vector2 from, Vector2 to, Color col, float width)
{
Vector2 dir = (to - from).normalized;
Vector2 perpendicular = new Vector2(-dir.y, dir.x) * width * 0.5f;
int startIndex = _vertexCache.Count;
// Create quad vertices
for (int i = 0; i < 4; i++)
{
UIVertex vert = UIVertex.simpleVert;
vert.color = col;
_quadVertices[i] = vert;
}
// Set positions
_quadVertices[0].position = from - perpendicular;
_quadVertices[1].position = from + perpendicular;
_quadVertices[2].position = to + perpendicular;
_quadVertices[3].position = to - perpendicular;
// Add vertices
_vertexCache.AddRange(_quadVertices);
// Add indices
_indexCache.Add(startIndex);
_indexCache.Add(startIndex + 1);
_indexCache.Add(startIndex + 2);
_indexCache.Add(startIndex);
_indexCache.Add(startIndex + 2);
_indexCache.Add(startIndex + 3);
}
// 处理Tooltip和数据更新
private void Update()
{
if (_series.Count <= 0) return;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform, Input.mousePosition, null, out var localPos);
// 检查鼠标是否在图表区域内
if (_tooltips && _graphRect.Contains(localPos))
{
// 记录鼠标X位置用于绘制竖线
_verticalLineX = localPos.x;
_showVerticalLine = true;
// 计算鼠标在X轴上的归一化位置 (0-1)
float mouseNormalizedX = Mathf.InverseLerp(
_graphRect.xMin, _graphRect.xMax, localPos.x);
bool hasData = false;
sb.Clear();
// 为每条折线计算当前X位置的值
foreach (var series in _series.Values)
{
if (!series.bVisible)
continue;
int readDataCount = dataMode != DataMode.DataSnapshort ? series.data.Count : series.dataSnapshort.Length;
int dataCount = Mathf.Min(readDataCount, maxDataPoints);
if (dataCount <= 1) continue;
// 计算数据索引(浮点数,用于插值)
float normalizedX;
if (dataCount < maxDataPoints)
{
float datastart = (maxDataPoints - dataCount) / (float)(maxDataPoints - 1);
float dataX = mouseNormalizedX - datastart;
if (dataX < 0)
continue;
normalizedX = dataX / (1 - datastart);
}
else
normalizedX = mouseNormalizedX;
float dataIndex = normalizedX * dataCount + dataMode switch
{
DataMode.DataSnapshort => 0,
DataMode.AutoLast => readDataCount - dataCount,
_ => dataOffset
};
int index0 = Mathf.FloorToInt(dataIndex);
int index1 = Mathf.Min(index0 + 1, dataCount - 1);
// 线性插值计算Y值
float value;
if (index0 == index1)
{
value = series.data[index0];
}
else
{
float t = dataIndex - index0;
value = Mathf.Lerp(series.data[index0], series.data[index1], t);
}
// 添加折线信息到tooltip
sb.Append(series.name).Append(':').Append(value.ToString("F2")).Append('\n');
hasData = true;
}
if (hasData)
_tooltips.Display(sb.ToString().TrimEnd());
else
_showVerticalLine = false;
}
else
_showVerticalLine = false;
if (Interlocked.CompareExchange(ref bRedrawGraph, 0, 1) == 1 || _showVerticalLine ||
_showVerticalLine != _lastShowVerticalLineState)
{
SetVerticesDirty();
}
_lastShowVerticalLineState = _showVerticalLine;
}
后记
可以优化的地方还有很多…不过现在,十几个折线图同时绘制的情况下,帧率可达200+,几乎无GC。