Unity高性能无GC图表
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 areavar 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 AccessToModifiedClosureif (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 axesDrawLine(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 ticksfor (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 tickDrawLine(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 ticksfor (int i = 0; i <= _xAxisTicks; i++){float normalized = i / (float)_xAxisTicks;float xPos = Mathf.Lerp(_graphRect.xMin, _graphRect.xMax, normalized);// Draw tickDrawLine(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 verticesfor (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);}elsenormalizedX = 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);}// 添加折线信息到tooltipsb.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。