可视化数据 unity

本文由Aoi翻译,转载请注明出处。文章来自于catlikecoding,原文作者介绍了Unity制作图表、可视化数据的方法。更多的名词解释内容,请点击末尾的“原文链接”查看。



介绍

这个教程里,我们在Unity 4里用C#脚本来展示日趋复杂的图表。你将学会:

  • 创建图表,从简单到复杂
  • 控制粒子系统
  • 写各种数学函数
  • 在play模式下改变行为
  • 使用Unity事件函数Start 和Update
  • 写循环,包括单循环和嵌套循环
  • 使用数组、枚举和代表

假设你已经对Unity编辑器有了初步的了解,并且知道创建C#脚本的基本知识。如果你完成了简易时钟制作教程,那么你就可以开始这一章了。

注意我会经常省略已经讲解过的代码块。新的代码内容会继续讲解清楚。

准备

打开一个新项目, 我们将在一个单位的立方体内建立表格,放置于(0, 0, 0)和(1, 1, 1)之间。设置一下编辑器以得到更好的视觉效果。4 Split是一个方便预定义的试图布局,所以选择它。从Window / Layout / 4 Spit选择,或者在屏幕右上方的下拉菜单里。把所有的视图模式都设置为Textured,旋转透视图,这样三个轴就都指向你了。

通过GameObject / Create Other / Cube创建一个新的方块,设置位置为(0.5, 0.5, 0.5)。这为我们校准视图提供参考。现在缩放和平移视图使其聚焦于单位方块。

最后,选择Main Camera,通过GameObject / Align With View使其匹配立透视图。如果那不管用,通过单机确认正确的视图是否激活,然后再试试。

场景视图以及相机聚焦于方块

这个方块不再需要了,所以移除它。然后通过GameObject / Create Other / Particle System创建粒子系统并重置其变换。现在它能产生随机例子了,但是不是我们想要的,所以我们停用除了渲染器之外的一切东西。

取消选择Looping,  Play On AwakeEmission,以及Shape.这保留了惰性粒子系统,我们可以用它实现图形数据可视化。

惰性粒子系统

创建第一个图表

创建一个Y值依赖于X值的简单图线图。我们将用粒子的位置可视化这个。

重命名粒子系统对象为Graph 1,创建C#脚本,命名为Grapher1,作为最小的GameObject类,然后将它作为组件添加到对象。

1
2
3
using UnityEngine;
  
public class Grapher1 : MonoBehaviour {}

有着空 Grapher1组件的Graph 1

首先我们的创建一些粒子作为图表的点。使用特殊的Start方法创建,这是一个在更新开始之前被调用一次的Unity事件方法。

我们应该使用多少粒子呢?粒子越多,图表的样本分辨率就越高。我们设置为默认分辨率10。

1
2
3
4
5
6
7
8
using UnityEngine;
public class Grapher1 : MonoBehaviour {
     public int resolution = 10;
     private ParticleSystem.Particle[] points;
     void Start () {
         points = new ParticleSystem.Particle[resolution];
     }
}

Grapher1配置分辨率

现在我们可以按照自己的意愿设置分辨率了。技术上至少是0,分辨率太高的话又会减慢运行。

我们可以确保初始化数组时变量在一定范围内。如果分辨率超出了范围,我们就将其重设为最小值,并且记录警告信息。让我们用一个10-100的合理范围。

1
2
3
4
5
6
7
void Start () {
         if (resolution < 10 || resolution > 100) {
             Debug.LogWarning( "Grapher resolution out of bounds, resetting to minimum." , this );
             resolution = 10;
         }
         points = new ParticleSystem.Particle[resolution];
     }

现在该把点沿着X轴放置。第一个点应该放在0,最后一个放在1。其他的点应该在这两者之间。所以距离,或者说X增量,两点之间是1(分辨率-1)。

除了位置,我们来可以用颜色来提供相同的信息。让点的红色量等于其沿X轴的位置。

我们将使用一个for循环来遍历所有点,并设置位置和颜色,这是类型Vector3 和颜色的结构值。我们还需要设置粒子的大小,否则将不会显示。大小为0.1就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Start () {
         if (resolution < 10 || resolution > 100) {
             Debug.LogWarning( "Grapher resolution out of bounds, resetting to minimum." , this );
             resolution = 10;
         }
         points = new ParticleSystem.Particle[resolution];
         float increment = 1f / (resolution - 1);
         for (int i = 0; i < resolution; i++) {
             float x = i * increment;
             points[i].position = new Vector3(x, 0f, 0f);
             points[i].color = new Color(x, 0f, 0f);
             points[i].size = 0.1f;
         }
     }
到目前为止,还出不了效果。当播放的时候,什么都显示不出来。那是因为我们得把粒子添加到粒子系统。方便起见,每个组件都有一个粒子系统属性,我们可以用它来访问粒子系统(如果有的话)。我们需要做的就是调用SetParticles方法,提供粒子数组以及我们想要的粒子数量。由于我们想要使用所有的粒子,所以子要提供数组的长度就可以。我们需要给每一帧添加一个更新方法。
1
2
3
void Update () {
         particleSystem.SetParticles(points, points.Length);
     }
10粒子组成的线

就是这样,现在我们得到了一个沿着X轴由黑到红的点线。显示多少点取决于分辨率的值。

分辨率为10、100的线

现在,只有在图表初始化的时候考虑分辨率。播放模式下更新值不起任何作用。现在来修改一下。

检测分辨率的一个简单方法是存储两次,然后经常检查这两个值是否仍然一样。如果在某些点不一样,我们就要重建图表。为此需要创建一个私有变量currentResolution 。

由于重建这些点适合初始化的时候是一样的,所以把代码移到名为CreatePoints的新的私有方法中。这样我们就能重新使用代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using UnityEngine;
  
public class Grapher1 : MonoBehaviour {
  
     public int resolution = 10;
  
     private int currentResolution;
     private ParticleSystem.Particle[] points;
  
     void Start () {
         CreatePoints();
     }
  
     private void CreatePoints () {
         if (resolution < 10 || resolution > 100) {
             Debug.LogWarning( "Grapher resolution out of bounds, resetting to minimum." , this );
             resolution = 10;
         }
         currentResolution = resolution;
         points = new ParticleSystem.Particle[resolution];
         float increment = 1f / (resolution - 1);
         for (int i = 0; i < resolution; i++){
             float x = i * increment;
             points[i].position = new Vector3(x, 0f, 0f);
             points[i].color = new Color(x, 0f, 0f);
             points[i].size = 0.1f;
         }
     }
  
     void Update () {
         if (currentResolution != resolution) {
             CreatePoints();
         }
         particleSystem.SetParticles(points, points.Length);
     }
}

现在只要改变分辨率的值就能重建图表了。然而,你会注意到每当分辨率超出范围,甚至是输入的时候,控制台都会弹出警告。我们可以使用Range属性来告诉Unity编辑器使用滑块来代替数字框。

由于我们只关注有效地输入编辑,并且不会通过代码来改变分辨率,所以现在可以移除分辨率检查了,当然,也许你会决定保留它。

1
2
[Range(10, 100)]
     public int resolution = 10;

有着分辨率范围滑块的Grapher1

现在该设置点在Y轴的位置了。简单一点开始吧,把Y等于X。换句话说,我们在可视化数学公式y = x,或者函数f(x) = x。为了做到这一点,我们需要循环所有的点,获取它们的位置,使用X值计算Y值,然后设置新位置。一旦我们使用for循环,就将执行每个更新。

1
2
3
4
5
6
7
8
9
10
11
void Update () {
         if (currentResolution != resolution) {
             CreatePoints();
         }
         for (int i = 0; i < resolution; i++) {
             Vector3 p = points[i].position;
             p.y = p.x;
             points[i].position = p;
         }
         particleSystem.SetParticles(points, points.Length);
     }
函数 f(x) = x.

接下来把点的绿色分量设置的和Y位置一样。由于红加绿会得到黄,这将使得线从黑变黄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Update () {
         if (currentResolution != resolution) {
             CreatePoints();
         }
         for (int i = 0; i < resolution; i++) {
             Vector3 p = points[i].position;
             p.y = p.x;
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
红加绿得到黄

或许你已经注意到了在播放状态下改变代码并回到Unity,你会看到NullReferenceException错误信息。这还是因为重新加载时Unity没有记录私有点变量。

要解决这个问题,除了检查分辨率,我们可以检查点是否为null。这将使我们在编写代码时始终保持在播放模式,非常方便。注意这个检查还可以消除对Start方法的需要,所以可以删除它了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using UnityEngine;
  
public class Grapher1 : MonoBehaviour {
  
     [Range(10, 100)]
     public int resolution = 10;
  
     private int currentResolution;
     private ParticleSystem.Particle[] points;
  
     private void CreatePoints () {
         currentResolution = resolution;
         points = new ParticleSystem.Particle[resolution];
         float increment = 1f / (resolution - 1);
         for (int i = 0; i < resolution; i++){
             float x = i * increment;
             points[i].position = new Vector3(x, 0f, 0f);
             points[i].color = new Color(x, 0f, 0f);
             points[i].size = 0.1f;
         }
     }
  
     void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         for (int i = 0; i < resolution; i++) {
             Vector3 p = points[i].position;
             p.y = p.x;
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
}

显示多个图表

只有一个图表的话是不是有点枯燥呢?如果能展示多个图表那多好啊!我们需要做的是用不同的方式来计算p.y,代码的其他部分可以保持原样。直接抽取计算p.y的代码,放到它自己的函数里,我们就叫它Linear。这个函数所做的是模仿数学函数f(x) = x。我们把这个做成静态的,因为它不需要对象来实现功能,只需输入值就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         for (int i = 0; i < resolution; i++) {
             Vector3 p = points[i].position;
             p.y = Linear(p.x);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
  
     private static float Linear (float x) {
         return x;
     }

通过创建更多函数并调用它们代替Linear来添加其他数学函数也很简单。我们来添加三个新函数。第一个是幂数,计算f(x) = x2。第二个是抛物线,计算f(x) = (2x - 1)2。第三个是正弦,计算f(x) = (sin(2πx) + 1) / 2

1
2
3
4
5
6
7
8
9
10
11
12
private static float Exponential (float x) {
         return x * x;
     }
  
     private static float Parabola (float x){
         x = 2f * x - 1f;
         return x * x;
     }
  
     private static float Sine (float x){
         return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x);
     }

四个函数的图

每次在这三个选择之间切换需要改变代码,即使在播放模式下也并不难。创建一个枚举类型,包含想要展示的每个函数的项。我们把它叫做FunctionOption,但是由于我们是在class里定义的,所以它的正是名称是Grapher1.FunctionOption。 

添加新类型的公共变量命名函数。这个给我们一个很好地字段,可以在检查器中选择功能。

1
2
3
4
5
6
7
8
public enum FunctionOption {
         Linear,
         Exponential,
         Parabola,
         Sine
     }
  
     public FunctionOption function ;


选择使用哪个功能

在检查器中选择功能很好,但是现在还起不到什么作用。每次更新,我们需要根据功能的值来决定调用什么函数。有多种方法可以实现这个,我们使用委托阵列。

首先为函数定义一个委托类型,有一个单精度浮点数作为输入和输出,这相当于函数方法。我们叫它FunctionDelegate。然后添加一个静态阵列,命名functionDelegates,用委托函数填写。在枚举中以相同的顺序将他们命名。

现在我们可以根据功能变量从阵列中选择想要的委托,通过把它计算成一个整数。把这个委托存储在临时变量里,并使用它来计算Y的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private delegate float FunctionDelegate (float x);
     private static FunctionDelegate[] functionDelegates = {
         Linear,
         Exponential,
         Parabola,
         Sine
     };
  
     void Update () {
         if (currentResolution != resolution){
             CreatePoints();
         }
         FunctionDelegate f = functionDelegates[(int) function ];
         for (int i = 0; i < resolution; i++){
             Vector3 p = points[i].position;
             p.y = f(p.x);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }

终于,我们可以在播放模式下改变函数图形了!

虽然每次选择另一个函数的时候都要重新创建图形,其他时候基本不用改变。所以不用在每次更新的时候都计算点。然而,如果给函数加上时间就不一样了。例如,改变一下正弦函数,变为f(x) = (sin(2πx + Δ) + 1) / 2,Δ等于播放时间。这将形成一个缓慢的正弦波动画。这里是完整的脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
using UnityEngine;
  
public class Grapher1 : MonoBehaviour {
      
     public enum FunctionOption {
         Linear,
         Exponential,
         Parabola,
         Sine
     }
      
     private delegate float FunctionDelegate (float x);
     private static FunctionDelegate[] functionDelegates = {
         Linear,
         Exponential,
         Parabola,
         Sine
     };
  
     public FunctionOption function ;
      
     [Range(10, 100)]
     public int resolution = 10;
      
     private int currentResolution;
     private ParticleSystem.Particle[] points;
      
     private void CreatePoints () {
         currentResolution = resolution;
         points = new ParticleSystem.Particle[resolution];
         float increment = 1f / (resolution - 1);
         for (int i = 0; i < resolution; i++) {
             float x = i * increment;
             points[i].position = new Vector3(x, 0f, 0f);
             points[i].color = new Color(x, 0f, 0f);
             points[i].size = 0.1f;
         }
     }
      
     void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         FunctionDelegate f = functionDelegates[(int) function ];
         for (int i = 0; i < resolution; i++) {
             Vector3 p = points[i].position;
             p.y = f(p.x);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
      
     private static float Linear (float x) {
         return x;
     }
      
     private static float Exponential (float x) {
         return x * x;
     }
  
     private static float Parabola (float x){
         x = 2f * x - 1f;
         return x * x;
     }
  
     private static float Sine (float x){
         return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x + Time.timeSinceLevelLoad);
     }
}

添加一个额外的维度

到目前为止,我们只用X轴输入,又一次用了时间。现在我们将创建一个使用Z轴的新图表对象,因此产出的将不再是一条线,而是网格。

确保你不是在播放模式。创建一个新的Unity对象,就像Graph 1,同时要有一个新的绘图脚本,把它们叫做Graph 2和Grapher2 。你可以复制并需要的地方做修改,这样能加快速度。勾选其名称前的复选框来禁用Graph 1,因为我们不会再用到它了。从Grapher1 中复制代码到Grapher2,先只改变Grapher2的class名,一会再修改代码的其他部分。

完成上述内容的最快方法是复制脚本并编辑class名称,然后复制对象并重命名,然后把新脚本拖拽到就的上面。

切换到一个新的图

为了把线变成一个正方形网格,我们需要改变Grapher2的CreatePoints函数。创建更多的点,用一个嵌套的for循环初始化它们。现在设置Z轴以及蓝色的分量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void CreatePoints () {
         currentResolution = resolution;
         points = new ParticleSystem.Particle[resolution * resolution];
         float increment = 1f / (resolution - 1);
         int i = 0;
         for (int x = 0; x < resolution; x++) {
             for (int z = 0; z < resolution; z++) {
                 Vector3 p = new Vector3(x * increment, 0f, z * increment);
                 points[i].position = p;
                 points[i].color = new Color(p.x, 0f, p.z);
                 points[i++].size = 0.1f;
             }
         }
     }

扁平的网格

现在出现了一个漂亮的扁平网格。但是它不应该显示线性函数吗?是的,但是现在只用于显示沿着Z轴的第一行点。如果你选了一个不同的函数,只有这些点会改变,其他的将保持不变。这是因为Update函数目前只遍历分辨率点,然而它应该是遍历所有点的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         FunctionDelegate f = functionDelegates[(int) function ];
         for (int i = 0; i < points.Length; i++) {
             Vector3 p = points[i].position;
             p.y = f(p.x);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }

四个函数的网格

现在我们再次看到了函数,拓展到了Z轴。然而,有些奇怪的事发生了。显示抛物线的时候尝试旋转透视图,从某些角度,图形绘制是错误的。这是因为粒子是按我们创建的顺序显示的。为了修正这一点,设置粒子系统渲染器模块的Sort Mode为“By Distance”,而不是None。这个确保从所有角度看图形都显示正确,但这也会降低性能。所以在显示大量点的时候不要使用它。幸运的是,如果我们只从正确的方向看图,我们就可以忽略排序。

Sort Mode的 None 和By Distance.

现在来更新功能代码,这样我们就能利用新坐标的优势。首先改变FunctionDelegate输入参数,改为一个vector和一个float,来代替单一的float。虽然可以分别指定X和Z的位置,我们简单地将整个位置矢量。还包含当前时间,而不必在函数里面寻找。

1
private delegate float FunctionDelegate (Vector3 p, float t);

现在我们需要相应地更新功能函数,改变委托的调用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         FunctionDelegate f = functionDelegates[(int) function ];
         float t = Time.timeSinceLevelLoad;
         for (int i = 0; i < points.Length; i++) {
             Vector3 p = points[i].position;
             p.y = f(p, t);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
  
     private static float Linear (Vector3 p, float t) {
         return p.x;
     }
  
     private static float Exponential (Vector3 p, float t) {
         return p.x * p.x;
     }
  
     private static float Parabola (Vector3 p, float t){
         p.x = 2f * p.x - 1f;
         return p.x * p.x;
     }
  
     private static float Sine (Vector3 p, float t){
         return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * p.x + t);
     }

我们已经准备好在数学函数加入Z。例如:改变抛物线函数为f(x,z) = 1 - (2x - 1)2 × (2z - 1)2。我们还可以拓展一下正弦函数,分层多个正弦来得到一个复合的振荡效果。

1
2
3
4
5
6
7
8
9
10
11
12
private static float Parabola (Vector3 p, float t){
         p.x += p.x - 1f;
         p.z += p.z - 1f;
         return 1f - p.x * p.x * p.z * p.z;
     }
  
     private static float Sine (Vector3 p, float t){
         return 0.50f +
             0.25f * Mathf.Sin(4f * Mathf.PI * p.x + 4f * t) * Mathf.Sin(2f * Mathf.PI * p.z + t) +
             0.10f * Mathf.Cos(3f * Mathf.PI * p.x + 5f * t) * Mathf.Cos(5f * Mathf.PI * p.z + 3f * t) +
             0.15f * Mathf.Sin(Mathf.PI * p.x + 0.6f * t);
     }

更加有趣的抛物线和正弦函数

让我们以添加Ripple函数来完成Grapher2,这是从网格中心发出的单一正弦波。这里是完整的脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
using UnityEngine;
  
public class Grapher2 : MonoBehaviour {
  
     public enum FunctionOption {
         Linear,
         Exponential,
         Parabola,
         Sine,
         Ripple
     }
  
     private delegate float FunctionDelegate (Vector3 p, float t);
     private static FunctionDelegate[] functionDelegates = {
         Linear,
         Exponential,
         Parabola,
         Sine,
         Ripple
     };
  
     public FunctionOption function ;
  
     [Range(10, 100)]
     public int resolution = 10;
  
     private int currentResolution;
     private ParticleSystem.Particle[] points;
  
     private void CreatePoints () {
         currentResolution = resolution;
         points = new ParticleSystem.Particle[resolution * resolution];
         float increment = 1f / (resolution - 1);
         int i = 0;
         for (int x = 0; x < resolution; x++) {
             for (int z = 0; z < resolution; z++) {
                 Vector3 p = new Vector3(x * increment, 0f, z * increment);
                 points[i].position = p;
                 points[i].color = new Color(p.x, 0f, p.z);
                 points[i++].size = 0.1f;
             }
         }
     }
  
     void Update () {
         if (currentResolution != resolution || points == null ) {
             CreatePoints();
         }
         FunctionDelegate f = functionDelegates[(int) function ];
         float t = Time.timeSinceLevelLoad;
         for (int i = 0; i < points.Length; i++) {
             Vector3 p = points[i].position;
             p.y = f(p, t);
             points[i].position = p;
             Color c = points[i].color;
             c.g = p.y;
             points[i].color = c;
         }
         particleSystem.SetParticles(points, points.Length);
     }
  
     private static float Linear (Vector3 p, float t) {
         return p.x;
     }
  
     private static float Exponential (Vector3 p, float t) {
         return p.x * p.x;
     }
  
     private static float Parabola (Vector3 p, float t){
         p.x = 2f * p.x - 1f;
         p.z = 2f * p.z - 1f;
         return 1f - p.x * p.x * p.z * p.z;
     }
  
     private static float Sine (Vector3 p, float t){
         return 0.50f +
             0.25f * Mathf.Sin(4 * Mathf.PI * p.x + 4 * t) * Mathf.Sin(2 * Mathf.PI * p.z + t) +
             0.10f * Mathf.Cos(3 * Mathf.PI * p.x + 5 * t) * Mathf.Cos(5 * Mathf.PI * p.z + 3 * t) +
             0.15f * Mathf.Sin(Mathf.PI * p.x + 0.6f * t);
     }
  
     private static float Ripple (Vector3 p, float t){
         p.x -= 0.5f;
         p.z -= 0.5f;
         float squareRadius = p.x * p.x + p.z * p.z;
         return 0.5f + Mathf.Sin(15f * Mathf.PI * squareRadius - 2f * t) / (2f + 100f * squareRadius);
     }
}


原文链接        http://catlikecoding.com/unity/tutorials/graphs/

你可能感兴趣的:(unity)