
Unity3D C# 信号FFT分析之自定义波形UI控件
更新日志:
2020.09.13 重构代码,添加fft分析
1 引言
前段时间帮公司写了个工具,用于分析超声探头的性能,基本功能是读取示波器采集到的数据,然后进行FFT分析,计算出带宽、灵敏度,然后生成测试报告,最终的报告中的一部分类似下图。
当然这个程序是用winform写的,因为这个程序只在windows上跑,而且winform有控件可以直接用来绘制波形图。
一直不知道FFT分析有什么用,经过这次开发,对FFT有了新的认识,特此总结一下。
在讨论FFT前,我们今天先把波形图用Unity的UGUI画出来,毕竟自己还是喜欢使用Unity进行开发。
周末花时间写了个,最终的结果如下图,还不太完善,但有这方面需要的同学也可以参考下实现的逻辑。
这个控件分开来有三部分:
①是原始波形图的绘制,上图中的蓝线部分,整个波形图能够拖拽移动(上图没展示出来)
②是左侧和底部的刻度尺,刻度尺要对应上波形图的实际值(上图中未标出数字,但可以通过测量光标看出来)
③是十字测量光标,鼠标移动到十字光标部分,鼠标样式会改变,然后能够拖动并实时展示光标所在的值
接下来,我们来一一分析下这几部分是如何实现的。
2 逻辑梳理
Demo程序的逻辑入口是挂载到Canvas物体上的Demo脚本。
2.1 波形图的绘制
2.1.1 波形的模拟
ps:demo使用的数据已经更新,波形数据使用的是实际采集到的5MHz的超声波探头信号。
由于博主手上没有波形数据,所以写了个工具类来模拟正弦信号的叠加。
/// <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/1JUFBxlBTdeSSBt3_qk3rIA
提取码:uzxs
博主本文博客链接。
非常不错,学习一下