首页 > 技术文章 > 简易3D热力图

tiancaiwrk 2020-09-27 10:08 原文

  三维场景里面想要表现出热力图, 最简单的就是投影(Projection)或者叫贴花(Decals)了, 不过最近也有不少通过生成3D热力图的例子, 比如高德接口已经提供了3D热力图 : 

  

  生成3D热力图的方式按照生产流程来看, 大概有那么几种 : 

  1. 获得的数据是散点数据, 需要我们自己生成一张高度图, 用高度图来生成 Mesh 网格, 然后自己绘制热力图的颜色

  2. 获得的数据直接就是一张热力图, 需要根据热力图来生成高度图, 生成网格后直接用热力图作为贴图即可

  其实都是以生成 Mesh 网格的方式来制作的, 还有纯渲染的比如类似天气, 雾气这样的方式也能做出3D热力图, 不过考虑到可能需要能够点击交互, 使用生成 Mesh 才是最通用的, 并且根据实际需求可以设定分辨率来减少性能损耗, 创建过程中的计算很多可以通过多线程来完成, 使用方便代码简单, 依赖了系统的矩阵转换, 比使用 Shader 来写纯渲染的好多了.

 

  按照散点数据的模式来制作高度图, 首先需要把散点位置转换为相对位置, 比如热力图的中心位于点 A 处, 热力图是一个矩形范围, 那么就可以放一个 GameObject 到该点, 利用 worldToLocalMatrix 把离散点坐标转换为本地坐标, 这样根据矩形空间就能映射到热力图相对位置了, 那么我们把离散点直接像素写入到高度图上去吗? 肯定不是, 因为热力图有一个扩展范围, 至少需要按照某个半径画圆画到高度图上, 这样就得到最初的高度图了, 可是在做成 Mesh 的时候希望它有边缘平滑的效果, 而不是直接 0-1 的剧烈变化, 上面高德地图的边缘平滑基于贝塞尔曲线来的, 我就简单一些直接做几次高斯模糊就行了, 这样就能得到一张边缘平滑的高度图了, 生成 Mesh 之后怎样绘制热力图颜色呢? 最简单的写个 shader 根据世界坐标 y 值进行插值几个颜色就行了.

  离散点绘制到高度图上核心是使用贴图的 _ST 属性, 通过计算修改贴图的这个属性就能实现调整图片位置以及大小的逻辑了, Shader 逻辑为 : 

Shader "Custom/HeightMapCreator"
{
    Properties
    {
        _MainTex("Texture", 2D) = "black"{}
        _Shape ("Shape", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float2 st : TEXCOORD1;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            sampler2D _Shape;
            float4 _Shape_ST;
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.st = TRANSFORM_TEX(v.uv, _Shape);
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                col = (col + tex2D(_Shape, i.st));
                return col;
            }
            ENDCG
        }
    }
}

  MainTexture 就是我们需要进行迭代的贴图, Shape 就是我们绘制离散点的形状的图片, 通过 _Shape_ST 来调整 Shape 的大小和位置, 这样我们就可以把 Shape 按照坐标计算的位置和自己设定的大小绘制到高度图上了, 当然如果是有很多个离散点的话, 并且都使用同样的 Shape 的话, 这个可以改进为传入 Tiling/Offset 数组的方式实现采样, 要不然每个点都需要进行一次 Blit 操作非常浪费性能...

  基于上面的 Shader 看看坐标转换以及图片合成的过程 : 

    public class PointInfo
    {
        public Vector3 worldPos;
        public float value;

        public static implicit operator Vector3(PointInfo info)
        {
            return info.worldPos;
        }
    }
    public class HeightMapInfo
    {
        public Matrix4x4 worldToLocalMatrix;
        public int worldSize_x;      // total
        public int worldSize_z;      // total

        public int heightMapSize_x;
        public int heightMapSize_z;
        public Vector2 worldToMapScale { get { return new Vector2((float)heightMapSize_x / (float)worldSize_x, (float)heightMapSize_z / (float)worldSize_z); } }

        public Texture2D renderShape;
        public int renderSize_x;
        public int renderSize_y;
        public Vector2 renderShapeScale { get { return new Vector2((float)heightMapSize_x / (float)renderSize_x, (float)heightMapSize_z / (float)renderSize_y); } }
    }

    private static RenderTexture Blit(RenderTexture mainTexture, Material material, HeightMapInfo info, Vector3 worldPos)
    {
        var region = new Rect(-info.worldSize_x * 0.5f, -info.worldSize_z * 0.5f, info.worldSize_x, info.worldSize_z);
        var localPos = info.worldToLocalMatrix.MultiplyPoint3x4(worldPos);  // based on middle-center
        var localPos_xz = new Vector2(localPos.x, localPos.z);
        var renderTarget = RenderTexture.GetTemporary(info.heightMapSize_x, info.heightMapSize_z, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default);

        var offset = localPos_xz - region.min;
        var offset_u = (offset.x / region.width) * -1.0f;
        var offset_v = (offset.y / region.height) * -1.0f;

        var shapeRenderOffset_u = info.renderSize_x * 0.5f / info.heightMapSize_x;
        var shapeRenderOffset_v = info.renderSize_y * 0.5f / info.heightMapSize_z;

        var final_u = offset_u + shapeRenderOffset_u;
        var final_v = offset_v + shapeRenderOffset_v;
        var final_uv = new Vector2(final_u, final_v);

        // offset is scaled
        var final_uv_scaled = Vector2.Scale(final_uv, info.renderShapeScale);
        material.SetTextureOffset("_Shape", final_uv_scaled);

        Graphics.Blit(mainTexture, renderTarget, material);
        RenderTexture.ReleaseTemporary(mainTexture);

        return renderTarget;
    }

  这里按照归一化的过程, 把3D热力图所占的世界空间的大小归一化, 散点位置转换为热力图本地坐标系位置之后, 通过归一化计算就能得到 Shape 的 Offset 了.

  PS : 注意 shader 中的 Offset 是会被 Tiling 属性影响的, 所以 final_uv 需要再乘以一个 info.renderShapeScale 才能得到最后的偏移 final_sv_scaled.

 

  有了上面的过程之后就可以通过离散点来创建高度图了 : 

    public static Texture2D CreateRawHeatMapMap(HeightMapInfo info, List<PointInfo> worldPoints)
    {
        var mat = new Material(Shader.Find("Custom/HeightMapCreator"));
        mat.SetTexture("_Shape", info.renderShape);
        mat.SetTextureScale("_Shape", info.renderShapeScale);

        Texture2D heightMap = new Texture2D(info.heightMapSize_x, info.heightMapSize_z, TextureFormat.ARGB32, false, true);
        heightMap.wrapMode = TextureWrapMode.Clamp;
        RenderTexture renderTarget = null;

        foreach(var worldPoint in worldPoints)
        {
            renderTarget = Blit(renderTarget, mat, info, worldPoint, worldPoint.value);
        }

        if(renderTarget)
        {
            Graphics.CopyTexture(renderTarget, heightMap);
            RenderTexture.ReleaseTemporary(renderTarget);
        }

        return heightMap;
    }

  然后看看三个离散点得到的高度图 : 

   然后通过高斯模糊之后得到的高度图 : 

  这里需要注意在前面的过程中都是通过 GPU 申请的内存, 当我们进行完所有操作之后返回的高度图是 Texture2D 并且需要能够读取像素的, 那么就需要 decode 到系统内存中, 需要通过 :

    RenderTexture.active = renderTarget;
    Texture2D.ReadPixels(...);
    Texture2D.Apply();

  这样的方式把图片从 GPU 上获取过来.

  之后通过高度图创建 Mesh 的过程就比较简单了, 大家都会, 创建顶点, 创建UV, 创建 triangles 就行了, 而使用多线程的话, 可以在主线程中直接获取图片的全部像素, 然后在线程中计算即可 : 

    var pixels = heightMap.GetPixels();  // 主线程中获取
    .......

    // 自己实现像素查找
    public static Color GetPixel(Color[] pixels, int x, int y, int width, int height)
    {
        x = Mathf.Clamp(x, 0, width - 1);
        y = Mathf.Clamp(y, 0, height - 1);
        var index = y * width + x;
        if(pixels.Length <= index)
        {
            throw new System.Exception();
        }
        return pixels[index];
    }

 

  运行结果, 添加了一个按照顶点 y 值插值颜色的 Surface 材质(alpha:fade) : 

 

推荐阅读