Unity3D C# 信号FFT分析之自定义波形UI控件

Unity3D C# 信号FFT分析之自定义波形UI控件

2020年8月31日 0 作者 老王

1 引言

前段时间帮公司写了个工具,用于分析超声探头的性能,基本功能是读取示波器采集到的数据,然后进行FFT分析,计算出带宽、灵敏度,然后生成测试报告,最终的报告中的一部分类似下图。
测试结果
当然这个程序是用winform写的,因为这个程序只在windows上跑,而且winform有控件可以直接用来绘制波形图。
一直不知道FFT分析有什么用,经过这次开发,对FFT有了新的认识,特此总结一下。
在讨论FFT前,我们今天先把波形图用Unity的UGUI画出来,毕竟自己还是喜欢使用Unity进行开发。
周末花时间写了个,最终的结果如下图,还不太完善,但有这方面需要的同学也可以参考下实现的逻辑。
Unity3D自定义波形图UI控件
这个控件分开来有三部分:
①是原始波形图的绘制,上图中的蓝线部分,整个波形图能够拖拽移动(上图没展示出来)
②是左侧和底部的刻度尺,刻度尺要对应上波形图的实际值(上图中未标出数字,但可以通过测量光标看出来)
③是十字测量光标,鼠标移动到十字光标部分,鼠标样式会改变,然后能够拖动并实时展示光标所在的值
接下来,我们来一一分析下这几部分是如何实现的。

2 逻辑梳理

Demo程序的逻辑入口是挂载到Canvas物体上的Demo脚本。

2.1 波形图的绘制

2.1.1 波形的模拟

由于博主手上没有波形数据,所以写了个工具类来模拟正弦信号的叠加。

/// <summary>
/// 正弦波.
/// </summary>
/// <param name="amplitude">幅值</param>
/// <param name="initialPhase">初相位</param>
/// <param name="signalTime">总时间</param>
/// <param name="time">横坐标时间集合</param>
/// <param name="frequencys">要叠加的频率集合</param>
public static float[] SinWave(float amplitude, float initialPhase, float signalTime, out float[] time, params float[] frequencys)
{
    const int cnt = 2500;
    float deltaTime = signalTime / cnt;

    float[] pos = new float[cnt];
    time = new float[cnt];
    for (int i = 0; i < cnt; i++)
    {
        float x = i * deltaTime;

        float y = 0;

        foreach (float frequency in frequencys)
        {
            y += SinWave(initialPhase, amplitude, frequency, x);
        }
        y /= frequencys.Length;
        pos[i] = y;
        time[i] = x;
    }

    return pos;
}

private static float SinWave(float initialPhase, float amplitude, float frequency, float time)
{
    return amplitude * Mathf.Sin(Mathf.Deg2Rad * initialPhase + Mathf.PI * 2 * frequency * time);
}
// 这里是叠加频率分别为15Hz、50Hz,幅值均为50,初相位均为0.25弧度的正弦信号叠加后的信号,采样时间是0.25秒
float[] initWave = WaveUtil.SinWave(50, 0.25f, 0.25f, out float[] waveTimes, 15, 50);

2.1.2 创建物体工具类

Demo中所有组件都是动态生成的,没有制作成预制体,所以我这儿写了个创建物体的工具类UguiUtil。
CreateEmpty方法用于创建一个空物体,其锚点在左上角(注意,这一点很重要,因为后面设置物体的位置时都是按照这个锚点来计算的)。
CreateImage其实就是在空物体上添加了图片控件,并能设置颜色和宽高。
AttachMeshRenderer是给物体添加MeshRenderer组件和MeshFilter组件,由于这里是直接用在ui上,所以把投射阴影、接收阴影、烘焙光照这些都给关闭了,同时直接指定了不考虑光照的Shader "Unlit/Color"。

namespace Utils
{
    public class UguiUtil
    {
        public static RectTransform CreateEmpty(Transform parent, string name, float width, float height)
        {
            GameObject obj = new GameObject(name);
            obj.layer = LayerMask.NameToLayer("UI");
            RectTransform rectTransform = obj.AddComponent<RectTransform>();
            rectTransform.SetParent(parent);
            rectTransform.localScale = Vector3.one;
            rectTransform.pivot = Vector2.up;
            rectTransform.anchorMin = Vector2.up;
            rectTransform.anchorMax = Vector2.up;
            rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
            rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
            rectTransform.anchoredPosition = Vector2.zero;
            rectTransform.localPosition = Vector3.zero;
            return rectTransform;
        }

        public static Image CreateImage(Transform parent, string name, float width, float height, Color color)
        {
            Transform transform = CreateEmpty(parent, name, width, height);
            Image image = transform.gameObject.AddComponent<Image>();
            image.color = color;
            return image;
        }

        public static Mesh AttachMeshRenderer(GameObject obj, Color color)
        {
            MeshRenderer mr = obj.AddComponent<MeshRenderer>();
            mr.receiveShadows = false;
            mr.shadowCastingMode = ShadowCastingMode.Off;
            mr.lightProbeUsage = LightProbeUsage.Off;
            mr.reflectionProbeUsage = ReflectionProbeUsage.Off;
            Material mat = new Material(Shader.Find("Unlit/Color"));
            mat.color = color;
            mr.material = mat;
            MeshFilter mf = obj.AddComponent<MeshFilter>();
            Mesh mesh = new Mesh();
            mf.mesh = mesh;
            return mesh;
        }
    }
}

2.1.3 绘制波形

接下来就是绘制波形。
github上有个自定义控件的库UChart,他是去继承Graphic那个类来写UI控件的,我这儿没有,不论是波形图还是刻度尺子还是十字光标都是使用Mesh的MeshTopology.Lines或MeshTopology.Line来绘制的。

mesh.SetIndices(indices, MeshTopology.LineStrip, 0);

先来看看整个波形图的层次结构。
波形图层次结构
项目中波形图对应的类是WaveItem,要生成一个波形控件直接实例化一个WaveItem,同时指定长宽和横纵坐标集合即可。
Demo中,先是创建了一个空物体(如上图中的Sin物体),设置长宽分别为800和600,然后在空物体下创建了一个背景图Bg,并设置为黑色,然后创建了一个Wave物体,给他添加了MeshRenderer组件,并根据横纵坐标生成了网格中的顶点。
如何根据横纵坐标生成顶点的呢?
其实就是存在横纵坐标值到UI的长宽的一个换算问题。先来看纵坐标,波形的值我们是已知的,由此可以算出最小最大值。而最大值对应UI坐标y轴上的0,最小值对应-600(因为Wave物体的锚点在左上角,我们用Mesh来绘制波形时都是以锚点为参考点的,而且参考坐标是y轴向上,x轴向右,x轴向前),所以任意一个波形的值对应的UI坐标如下:

float deltaY = waveHeight / Mathf.Abs(m_YMax - m_YMin);
float y = (wavePos[i] - m_YMin) * deltaY - waveHeight;

同理可得出每个点的x坐标。由于我们的波形任意相邻两个点的时间差是相同的,所以可以先直接求出两个点之前的UI间隔,再根据点的编号来求得横坐标值。

float deltaX = waveWidth / (wavePos.Length-1);
for (int i = 0; i < wavePos.Length; i++)
{
    float x = deltaX * i;
}

网格中的顶点求出之后,再设置索引值,即可绘制出波形图。这里索引值直接是0到顶点数-1,然后MeshTopology设置为LineStrip。

mesh.SetIndices(indices, MeshTopology.LineStrip, 0);

绘制波形的完整代码如下:

private RectTransform CreateWave(Transform canvas, float width, float height, float[] wavePos, float[] timePos, Color bgColor, Color lineColor, string name = "Wave")
{
    RectTransform waveParent = UguiUtil.CreateEmpty(canvas, name, width, height);
    float waveWidth = width - ScaleWidth;
    float waveHeight = height - ScaleWidth;

    // 背景
    Image bgImage = UguiUtil.CreateImage(waveParent, "Bg", waveWidth, waveHeight, bgColor);
    bgImage.transform.localPosition = new Vector3(ScaleWidth, 0, 0);
    // 波形图
    RectTransform wave = UguiUtil.CreateEmpty(waveParent, "Wave", waveWidth, waveHeight);
    wave.localPosition = new Vector3(ScaleWidth, 0, -1);
    var mesh = UguiUtil.AttachMeshRenderer(wave.gameObject, lineColor);

    float deltaX = waveWidth / (wavePos.Length-1);
    MathUtil.GetMinMax(wavePos, out m_YMin, out m_YMax);
    if (m_YMin.Equals(m_YMax))
    {
        return waveParent;
    }
    float deltaY = waveHeight / Mathf.Abs(m_YMax - m_YMin);

    Vector3[] vertices = new Vector3[wavePos.Length];
    for (int i = 0; i < wavePos.Length; i++)
    {
        // 锚点在左上角,即左上角的坐标为(0, 0), 右下角坐标为(waveWidth, -waveHeight)
        vertices[i] = new Vector3(deltaX * i, (wavePos[i] - m_YMin) * deltaY - waveHeight, 0);
    }

    MeshUtil.CreateLine(vertices, mesh);

    return waveParent;
}

MeshUtil

namespace Utils
{
    public class MeshUtil
    {
        public static void CreateLine(Vector3[] pos, Mesh mesh)
        {
            mesh.vertices = pos;
            int[] indices = new int[pos.Length];
            for (int i = 0; i < pos.Length; i++)
            {
                indices[i] = i;
            }
            mesh.SetIndices(indices, MeshTopology.LineStrip, 0);
        }
    }
}

2.1.4 波形图可拖动

其实很简单,项目中写了一个MonoBehaviour脚本DragItem,其实现了IBeginDragHandler, IDragHandler, IEndDragHandler三个接口,然后挂载到了波形图父物体上(如上图中的Sin物体)。

2.2 刻度尺

相信搞懂波形图的绘制,刻度尺的绘制也是件简单的事啦,我这儿就不多说废话啦。
只是要注意,Demo中固定刻度尺的宽度是20,中件的刻度线长度是10,最边上的刻度线是15,刻度线和波形图边界空了5,如图,知道了这个,看代码才好理解。
刻度尺距离标注

2.3 十字光标

十字光标对应的脚本是CursorItem,其继承自MonoBehaviour,并实现了IPointerEnterHandler, IPointerExitHandler接口。

2.3.1 鼠标进入切换鼠标光标类型

很简单,CursorItem实现了IPointerEnterHandler, IPointerExitHandler接口。在鼠标进入时,设置光标为十字光标。

public void OnPointerEnter(PointerEventData eventData)
{
    Cursor.SetCursor(m_CursorTexture, Vector2.zero, CursorMode.Auto);
}

离开时设为默认样式,这里加了m_DragItem.IsDrag判断是为了防止拖动时鼠标样式的闪烁。

public void OnPointerExit(PointerEventData eventData)
 {
     if (!m_DragItem.IsDrag)
     {
         Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto);
     }
 }

2.3.2 测量光标的值

十字光标的左侧和底部有数字标记出当前的值。
原理也挺简单的,十字光标那个物体挂载了DragItem脚本,拖动时会触发回调,然后交给WaveItem去计算光标的值,并显示在标签上。
设置标签时,不同波形表示的值可能不一样,有长有短,所以得根据值去动态更改标签背景的长度(当然,我这儿偷了懒,没有去设置,但是代码给出来了)。
有两种方法可以预先知道Text的长度
法一:

string maxStr = value.ToString("F1");
text.text = maxStr;
Font font = Font.CreateDynamicFontFromOSFont("Arial", text.fontSize);
text.font = font;
// 预先计算Text的长度
// 法①
font.RequestCharactersInTexture(maxStr);
CharacterInfo characterInfo = new CharacterInfo();
char[] arr = maxStr.ToCharArray();
float totalLength = 0f;
foreach (char c in arr)
{
   font.GetCharacterInfo(c, out characterInfo, text.fontSize);
   totalLength += characterInfo.advance;
}

法二

// 法②
TextGenerator textGenerator = new TextGenerator(maxStr.Length);
TextGenerationSettings settings = new TextGenerationSettings();
settings.fontSize = textMax.fontSize;
textGenerator.GetPreferredWidth(maxStr, settings);
Rect textRect = textMax.GetPixelAdjustedRect();
float totalLength = textRect.width;

3 源码

Demo放在这儿了。
链接:https://pan.baidu.com/s/1qrkavvpUnw6Nkfh-2NK-FQ
提取码:k8ex