三維場景里面想要表現出熱力圖, 最簡單的就是投影(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) :