用数组来实现热度图

着色器难以掌握的一个典型的原因就是缺少合适的文档。许多开发者在学习着色器的时候被代码搞得一团糟,原因就是他们没有很深的知识来解释眼前到底发生了什么。事实上 Cg/HLSL 中有着大量的臆断,这些臆断又没有被正确的证明过,这就让问题变得更加的突出。Unity3d允许C#脚本使用诸如 SetFloatSetIntSetVector 等之类的方法来跟着色器通信。遗憾的是,Unity3D没有类似 SetArray 的方法,也正因如此导致很多开发者们以为 Cg/HLSL 不支持 数组(arrays)。但事实并非如此,这篇文章将会给你展示向着色器传递数组的可能性。需要注意的是GPU为并行计算进行了高度的优化,所以如果在着色器中使用 循环结构(loops) 将会极大的降低它的性能。

在这个知识点中,我们将会实现一个热度图,看起来大概跟下图一样:
diagram


  • 始前准备
    这个知识点中介绍的效果是从一些设置好的点中创建一个热度图。这个热度图将会覆盖在另外一张图片的上面,就像一张前置图片。下面是必要的步骤:

    • 1.在Unity中使用一张纹理创建一个 quad,这张纹理就是用来创建热度图的那张纹理。在我们的例子中,使用了一张伦敦地图的纹理。
    • 2.创建另外一个 quad,并且把它放在前面创建的那个quad的上面。我们的热度图将会在这个quad上产生。
    • 3.创建一个对应的材质,并且把材质和着色器都应用到第二个quad上。

  • 操作步骤
    这个着色器跟我们之前创建的着色器还是有很大不同,当然它也相对较短。因此,下面的步骤中直接提供了着色器完整代码:
    • 1.将下面的的着色器代码整个复制到新创建的着色器中:
    // Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
    // Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
    shader "Custom/Heatmap" 
    {
        Properties 
        {
            _HeatTex ("Texture", 2D) = "white" {}
        }
        Subshader 
        {
            Tags {"Queue"="Transparent"}
            Blend SrcAlpha OneMinusSrcAlpha // Alpha blend
            Pass 
            {
                CGPROGRAM
                #pragma vertex vert
                #pragma fragment frag
                struct vertInput 
                {
                    float4 pos : POSITION;
                };
                struct vertOutput {
                    float4 pos : POSITION;
                    fixed3 worldPos : TEXCOORD1;
                };
    
                vertOutput vert(vertInput input) 
                {
                    vertOutput o;
                    o.pos = UnityObjectToClipPos(input.pos);
                    o.worldPos = mul(unity_ObjectToWorld, input.pos).xyz;
                    return o;
                }
                uniform int _Points_Length = 0;
                uniform float3 _Points [20]; // (x, y, z) = position
                uniform float2 _Properties [20]; // x = radius, y = intensity
                sampler2D _HeatTex;
                half4 frag(vertOutput output) : COLOR 
                {
                    // Loops over all the points
                    half h = 0;
                    for (int i = 0; i < _Points_Length; i ++)
                    {
                        // Calculates the contribution of each point
                        half di = distance(output.worldPos, _Points[i].xyz);
                        half ri = _Properties[i].x;
                        half hi = 1 - saturate(di / ri);
                        h += hi * _Properties[i].y;
                    }
                    // Converts (0-1) according to the heat texture
                    h = saturate(h);
                    half4 color = tex2D(_HeatTex, fixed2(h, 0.5));
                    return color;
                }
                ENDCG
            }
        }
        Fallback "Diffuse"
    }
    
    • 2.当你把这个着色器挂载到对应的材质球上的时候,你还需要为热度图提供一张渐变纹理。重要的是记得把这张纹理的 Wrap Mode 设置为 Clamp。下图中的纹理就是我们这个例子中使用的渐变纹理:
      diagram

      注意
      如果你的热度图要作为一个覆盖层,那么还要确保这张渐变纹理有一个alpha通道并且导入图片设置那里记得勾选 Alpha is Transparency
    • 3.创建一个名为 Heatmaps 的C#脚本,并输入下面的代码:
    using System.Collections;
    using UnityEngine;
    public class Heatmaps : MonoBehaviour
    {
        public Vector3[] positions;
        public float[] radiuses;
        public float[] intensities;
        public Material material;
        void Start()
        {
            material.SetInt("_Points_Length", positions.Length);
            for (int i = 0; i < positions.Length; i++)
            {
                material.SetVector("_Points"+ i, positions[i]);
                Vector2 properties = new Vector2(radiuses[i], intensities[i]);
                material.SetVector("_Properties" + i, properties);
            }
        }
        void Update()
        {
    
        }
    }
    
    • 4.将这个脚本挂载到场景中的一个游戏对象上,最好是你之前创建的quad。然后将为这个效果创建的材质球拖拽到脚本中的 材质变量(material slot) 上。做完这些后,C#脚本就能访问材质球了并且会对它进行初始化。
    • 5.最后,在C#脚本上,展开 positionsradiusesintensities 这些数组变量,给它们填入热度图需要的参数值。positions 指的是热度图的位置点信息(世界坐标),radiuses 指的是热度图的半径大小,intensities 指的是热度图周围的强烈程度:
      diagram

  • 原理介绍
    这个着色器的实现依赖了我们本书之前从来没有介绍的一些东西;首先第一个就是数组。在Cg语言中可以通过下面的语法创建数组:
    uniform float3 _Points [20];
    
    Cg不支持未知大小的数组:你必须要预先知道你要分配的空间大小。前面那行代码创建了一个20个元素的数组。

    Unity并没有直接暴露出任何能初始化那些数组的方法。然而,里面的单个元素却可以用数组的名称 (_Points) 加元素位置的方式去访问,比如 _Points0 或者 _Points10 这样去访问。但是这种方式只对确切类型的数组有效,比如 float3 类型和 float2 类型。我们把这个C#脚本挂载在了一个quad上,然后对着色器中的数组元素逐个进行了初始化。

    在着色器的片元函数中,每个循环结构中相似的事情是,对材质上的每个像素,求所有的点对热度图的影响程度:
    // Loops over all the points
    half h = 0;
    for (int i = 0; i < _Points_Length; i ++)
    {
        // Calculates the contribution of each point
        half di = distance(output.worldPos, _Points[i].xyz);
        half ri = _Properties[i].x;
        half hi = 1 - saturate(di / ri);
        h += hi * _Properties[i].y;
    }
    
    变量 h 保存了来自所有点的热度,它是根据半径和强度计算出来的。之后它就被用于查看到底该用渐变纹理上的哪些颜色。

    着色器和数组其实是一个很好的组合,但是却很少有游戏能在使用时发挥出它们的全部潜力。然而在这里,它们却引来了一个很大的瓶颈,因为对于每一个像素来说,着色器都必须要去遍历完所有的点。