Unity高性能无GC图表

Unity高性能无GC图表

引言

最近有一个数字孪生项目,有一个绘制折线图图表的需求,数据量挺大,而且上位机发送数据很频繁,需要记录数据,并且自动覆写最老的数据,考察了网上的一些现成的插件,发现并不太满足我的需求:

  1. 插件不是线程安全的,但我网络接收数据用的UDP线程池+完成端口,如果非线程安全则需要额外处理。
  2. 性能低。数据结构用的List,有GC,当数据达到最大缓存数量时,他会Remove掉最老的一个,然后添加一个新的,这就会引起GC。
  3. 太臃肿,配置复杂,还缺乏文档,对于一个简单的折线图,配置很多额外没用的东西,很麻烦。

从数据结构开始

写一个无GC、线程安全的数据容器,是必要的,思路是,使用定长数组,做成循环队列,当队列满时,自动覆写最旧的数据(其实只需要移动首位索引),因此数据容器本身没有内存分配和释放,完全的0GC,而且性能很好。
思路:

  1. 分别设置一个头索引和一个尾索引,头索引和尾索引相等时,队列为空,尾索引的下一个索引值等于头索引时,队列满,这里没有满的情况,如果满了,则把头索引也移动到下一位,保持元素总数不变。
  2. 计算索引时,使用位运算代替取余运算,提高性能,如下:
    使用 ( 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来实现绘制。主要的思路和实现的功能:

  1. 由处理网络接收的线程池,直接将收到的数据压入循环队列。
  2. 当循环队列有数据被压入时,折线类通过注册循环队列的OnDataEnqueued事件,得到数据更新通知,然后重绘折线。
  3. 循环队列的容量可能非常大,比如可以缓存10000多个数据,但是折线类可以只截取其中的一部分数据进行折线绘制。
  4. 图表数据有三种更新模式:①当注册循环队列的数据入队事件时,图表可自动更新(取最后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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

示申○言舌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值