1.前言
一轉眼離Book of the Dead Environment Demo開放下載已過去多年,當時因為技術力有限,以及對HDRP理解尚淺,
所以這篇文章一直擱淺到了現在。如今工作重心已轉向UE。Unity方面也對新版本的HDRP有了一些認知,故感觸頗多。
Book of the Dead——死者之書,是Unity2018年展示的Demo作品。
主要展現HDRP的運用、原始碼修改展示,音訊處理方案等。
該Demo已傳百度網盤:
連結:https://pan.baidu.com/s/1UBY0EcAGLwRJEW1VaDyUgQ
提取碼:f47c
開啟使用版本:unity2018.2.21f1
2.Feature
下面直接對Demo中Feature資料夾中的技術內容進行展開。
2.1 PropertyMaster
該模組連結了Volume數值與具體元件物件,貫穿整個專案,
因此提前講解。
指令碼中的PropertyVolumeComponentBase
、PropertyVolumeComponent
繼承自VolumeComponent
繼承了VolumeComponent
也就是說可以在Volume中被載入,因此專案裡繼承了PropertyVolumeComponent
的那些元件也就可以掛載於Volume中:
而Volume繫結的是通用元件,無法和場景中的具體物件繫結或是數值同步。這時候
擴充套件的PropertyVolumeComponent
就出現作用了:
public abstract class PropertyVolumeComponent<X> : PropertyVolumeComponentBase where X : PropertyVolumeComponent<X> { static PropertyVolumeComponent() { PropertyMaster.componentTypes.Add(typeof(X)); } }
PropertyMaster.componentTypes會記錄需要和場景中具體物件繫結的所有型別,然後做這一步操作:
public void UpdateProperties() {//在PropertyMaster類裡 var manager = VolumeManager.instance; var stack = manager.stack;//拿到當前Volume if (updateVolumes && volumeTrigger && volumeLayerMask != 0) manager.Update(volumeTrigger, volumeLayerMask); foreach (var type in componentTypes) {//剛剛快取的型別 var component = (PropertyVolumeComponentBase) stack.GetComponent(type); if (component.active) component.OverrideProperties(this); } }
PropertyMaster實現了IExposedPropertyTable
介面,在上述程式碼的OverrideProperties處,
將自己傳進去,再通過ExposedReference
和名稱的Guid匹配,拿到對應場景物件。
關於ExposedRenference
具體可以看這篇測試:https://www.cnblogs.com/hont/p/15815344.html
PropertyInspector
則提供Volume資訊的Debug,在編輯器下獲取到屬於當前Layer的Volume,以方便檢視:
最後會在每次HDRenderPipeline.OnBeforeCameraCull
處更新一次繫結資訊,保證每幀的數值都是最新的。
總結來說,PropertyMaster的做法適合如URP RenderFeature、HDRP Custom Volume之類元件的場景物件解耦。
2.2 AxelF
AxelF是專案裡對聲音部分進行處理的一個模組,檢視專案中的音訊環境配置;需開啟場景檔案AudioZones:
該模組分為如下部分:
-
- Patch 不同音訊物件的最小單位,ScriptableObject物件。可內嵌多個音源,設定是否隨機播放,序列播放等
- Zone 不同音訊區域的空間標記,內部存放所有Zone的靜態List,在Heartbeat類的
Update
中統一更新。並且存放了AudioEmitter
的引用,當角色進入Zone
後觸發AudioEmitter - AudioEmitter 音訊播放元件,
OnEnable
時呼叫Sequencer
播放Patch
- Heartbeat 音訊統一更新元件,負責其他幾個部分的
Update
統一更新,繫結玩家位置等
通過場景中擺放不同Zone
,來控制角色到達不同位置時聲音的播放邏輯。
該模組的SceneGUI處理較為有趣:
其使用物件位置資訊在SceneGUI部分繪製HelpBox風格GUI,具體可檢視DrawZoneLabelStatic
方法。
部分邏輯:
m.y = l.y + 1f; EditorGUI.HelpBox(m, y.text, MessageType.None); EditorGUI.DropShadowLabel(l, x); GUI.color = c; Handles.EndGUI();
2.3 DepthOfFieldAutoFocus自動對焦
(自動對焦——鏡頭轉向樹後,焦距切換,背景被自動虛化)
該模組有如下特點:
-
- Compute Shader寫入RWStructedBuffer,再傳入螢幕Shader的無縫連結
- Compute Shader傳入RWStructedBuffer後,資料不取回放在GPU端自動更新
- 增加
IDepthOfFieldAutoFocus
介面,對原先景深功能的修改
2.3.1 Compute Shader部分
在C#端對自動對焦需要的引數做ComputeShader部分傳入(ComputeShader的執行緒數是1,一會會講):
void Init(float initialFocusDistance) { if (m_AutoFocusParamsCB == null) { m_AutoFocusParamsCB = new ComputeBuffer(1, 12); m_ResetHistory = true; } if (m_AutoFocusOutputCB == null) m_AutoFocusOutputCB = new ComputeBuffer(1, 8); ...
CS端通過比對四個斜方向深度,得到最新焦距並插值更新(Depth方法也在這個CS裡):
float3 duv = float3(1.0, 1.0, -1.0) * 0.01; float focusDistance = Depth(0); focusDistance = min(focusDistance, Depth( duv.xy));//1,1 focusDistance = min(focusDistance, Depth( duv.zy));//-1,1 focusDistance = min(focusDistance, Depth(-duv.zy));//1,-1 focusDistance = min(focusDistance, Depth(-duv.xy));//-1,-1 focusDistance = max(focusDistance, _FocalLength);
然後更新params的RWStructedBuffer,該結構是放在GPU端一直更新的:
AutoFocusParams params = _AutoFocusParams[0]; params.currentFocusDistance = SmoothDamp(params.currentFocusDistance, focusDistance, params.currentVelocity); _AutoFocusParams[0] = params;
最後輸出:
Output(params.currentFocusDistance);
接著,到了後處理階段,shader DepthOfField.hlsl,直接拿到剛剛處理過的_AutoFocusOutput獲取資料:
//custom-begin: autofocus #if AUTO_FOCUS struct AutoFocusOutput { float focusDistance; float lensCoeff; }; StructuredBuffer<AutoFocusOutput> _AutoFocusOutput : register(t3); float2 GetFocusDistanceAndLensCoeff() { return float2(_AutoFocusOutput[0].focusDistance, _AutoFocusOutput[0].lensCoeff); } #else
到這裡,完成了焦距資訊的傳入。
之前第一次開啟Book of the Dead,看見這種做法不理解,為什麼一個執行緒的資訊也要用Compute Shader去做,後來
接觸到RWStructedBuffer處理完直接丟Shader這種做法,發現還可以這麼用,
另外由於自動對焦涉及到螢幕資訊讀取,還是屬於GPU部分擅長的操作,因此Demo中才用Compute Shader來做這個。
2.3.2 對後處理景深元件的修改
雖然自己擴充套件也可以,但不如直接改後處理中的Depth of View,與渲染管線的修改關鍵字不同,
檢視修改處,需要搜尋該關鍵字:
//custom-begin: autofocus
其修改部分位於_LocalPackages中:
首先,在PostProcessLayer.cs中定義了欄位:
//custom-begin: autofocus public Object depthOfFieldAutoFocus; //custom-end
方便直接把自動對焦元件連結到PostProcessLayer中:
然後定義了一個介面:
//custom-begin: autofocus public interface IDepthOfFieldAutoFocus { void SetUpAutoFocusParams(CommandBuffer cmd, float focalLength /*in meters*/, float filmHeight, Camera cam, bool resetHistory); } //custom-end
在上下文中也存放了自動對焦元件的引用,在每幀後處理渲染時,呼叫介面方法,更新自動對焦邏輯:
public override void Render(PostProcessRenderContext context) { ... //custom-begin: autofocus if (context.depthOfFieldAutoFocus != null) context.depthOfFieldAutoFocus.SetUpAutoFocusParams(cmd, f, k_FilmHeight, context.camera, m_ResetHistory); //custom-end ...
在自動對焦邏輯中,每幀會呼叫Dispatch
更新ComputeShader:
cmd.DispatchCompute(m_Compute, 0, 1, 1, 1);
2.4 GrassOcclusion植被AO遮蔽
GrassOcclusion通過烘焙植被AO,增強植被部分在畫面中的表現。
檔案目錄結構如下:
該模組分為如下部分:
-
- 單個植被通過OcclusionProbes烘焙出單個植被頂檢視AO Texture,一般64x64
- 整個場景植被通過地形拿到資料,拼接這些單個植被AO圖,生成一張2048x2048的大AO圖,然後再在shader裡整合
關於單個植被的AO烘焙,可以開啟BakeGrassOcclusion場景檢視,它通過OcclusionProbes烘焙,通過指令碼SaveOcclusionToTexture儲存。
接下來講解整個場景的大AO圖烘焙。
2.4.1 整個場景的大AO圖烘焙
引數配置可以看prefab GrassOcclusion:
Grass Prototypes 存放所有烘焙好的單個植被引用。Terrain連結的是場景地形檔案。
當點選Bake烘焙時,會進入GrassOcclusion.Editor.cs
的Bake
函式。
先進行一些變數準備工作,通過地形拿到所有植被:
TreeInstance[] instances = m_Terrain.terrainData.treeInstances;
TreePrototype[] prototypes = m_Terrain.terrainData.treePrototypes;
此處有一個地形縮放的魔數:
float magicalScaleConstant = 41.5f; //yea, I know float terrainScale = magicalScaleConstant / m_Terrain.terrainData.size.x;
然後建立一張RT:
RenderTexture rt = RenderTexture.GetTemporary(m_Resolution, m_Resolution, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear); Graphics.SetRenderTarget(rt);
遍歷所有單個植被,具體會根據植被的旋轉等資訊進行匹配操作,這裡不深入:
foreach(GrassPrototype p in m_GrassPrototypes) SplatOcclusion(p, instances, prototypes, m_Material, terrainScale, m_NonTerrainInstances, worldToLocal);
不過SplatOcclusion函式中DrawProcedural稍微說下:
Graphics.DrawProcedural(MeshTopology.Triangles, vertCount, instanceCount);
這裡就是有多少個例項就是多少個繪製pass(烘焙階段的),以每次畫一個四邊面進行繪製。
最後會順便存下高度圖,然後統一存入GrassOcclusionData(ScriptableObject)中。
具體應用到場景中的資料可以在Scenes/Forest_EnvironmentSample下檢視。
2.4.2 渲染管線中的運用
GrassOcclusion這一步的操作是通過改渲染管線實現的,可在修改後的HDRP中查詢關鍵字:
//forest-begin
當獲取Grass部分AO時,藉助GrassOcclusion傳入的全域性資訊會進行計算,在HDRP Shader中邏輯程式碼如下:
float SampleGrassOcclusion(float3 positionWS) { float3 pos = mul(_GrassOcclusionWorldToLocal, float4(positionWS, 1)).xyz; float terrainHeight = tex2D(_GrassOcclusionHeightmap, pos.xz).a; float height = pos.y - terrainHeight * _GrassOcclusionHeightRange; UNITY_BRANCH if(height < _GrassOcclusionCullHeight) { float xz = lerp(1.0, tex2D(_GrassOcclusion, pos.xz).a, _GrassOcclusionAmountGrass); return saturate(xz + smoothstep(_GrassOcclusionHeightFadeBottom, _GrassOcclusionHeightFadeTop, height)); // alternatively: // float amount = saturate(smoothstep(_GrassOcclusionHeightFade, 0, pos.y) * _GrassOcclusionAmount); // return lerp(1.0, tex2D(_GrassOcclusion, pos.xz).a, amount); } else return 1; }
最後會根據相對高度進行一次漸變混合,部分OcclusionProbes的邏輯將在下面講解。
2.5 LayerCulling
LayerCulling主要是對Unity不同層顯示距離控制介面的封裝。
Unity在很早的版本就有提供針對不同Layer的剔除介面:
Layer視距剔除:
var distances = Enumerable .Repeat(Camera.main.farClipPlane, 32) .ToArray(); distances[12] = 3f;//Layer12的剔除距離為3 testCamera.layerCullDistances = distances;//!一定要以陣列賦值,否則無效 testCamera.layerCullSpherical = true;//是否以球形為基準剔除
Layer平行光陰影剔除:
testLight.layerShadowCullDistances = distances;
在專案場景Forest_EnvironmentSample中,搜尋LayerCulling,即可找到對應的剔除配置:
2.6 OcclusionProbes環境AO遮蔽探針
之前的GrassOcclusion用了OcclusionProbes烘焙單個植被的AO,這裡OcclusionProbes覆蓋整個場景,
將較低頻的場景體積AO資訊儲存進ScriptableObject。
這部分主要講述
-
- 呼叫內部介面烘焙遮蔽探針,存入Texture3D
- 解包Unity環境SH(球諧探針),一起丟入Shader
- Shader部分整合計算得到AO值
首先,我們可以從場景中掛載的OcclusionProbes處開始,它會繫結Lightmapping烘焙介面:
void AddLightmapperCallbacks() { Lightmapping.started += Started; Lightmapping.completed += Completed; }
當烘焙開始時將呼叫到Started
函式,函式中會去設定探針位置等初始化操作。
烘焙結束後,呼叫Completed
函式。
在函式裡可以直接拿到烘焙好的遮蔽資訊:
Vector4[] results = new Vector4[count]; if (!UnityEditor.Experimental.Lightmapping.GetCustomBakeResults(results))
然後Data和DataDetail會分別轉換成3DTexture進行儲存(專案裡沒有用Detail資料):
Color32[] colorData = new Color32[length]; for (int i = 0; i < length; ++i) { byte occ = (byte)Mathf.Clamp((int)(data[i].x * 255), 0, 255); colorData[i] = new Color32(occ, occ, occ, occ); } tex.SetPixels32(colorData);
除了遮蔽的3DTexture資訊,OcclusionProbes還會存一份環境SH(球諧探針),用於後期參與計算:
public AmbientProbeData m_AmbientProbeData;
這個SH是從RenderSettings.ambientProbe
獲取的,並且做了修正操作,
修正操作指:
Unity存的SH(指SphericalHarmonicsL2部分)有一部分計算放在了CPU端進行了化簡,主要是把sh[6](l2,r0)的部分合併到了sh[0](l0,r0)上,
通過SphericalHarmonicsL2走Unity內部傳入shader的球諧,會自動做轉換,而_AmbientProbeSH
是外部傳入,所以要做
這樣一個轉換(這部分資料比較少,不保證正確)。
var ambientProbe = RenderSettings.ambientProbe; m_AmbientProbeData.sh = new Vector4[7]; // LightProbes.GetShaderConstantsFromNormalizedSH(ref ambientProbe, m_AmbientProbeData.sh); GetShaderConstantsFromNormalizedSH(ref ambientProbe, m_AmbientProbeData.sh); EditorUtility.SetDirty(m_AmbientProbeData);
這樣,有了SH和3DTexture遮蔽資訊,下一步可以看下Shader裡如何進行整合的。
這部分或許不重要,因為呼叫了Unity的介面,無法看到具體的演算法或者程式碼邏輯是什麼。
下面是HDRP shader整合部分。
在MaterialUtilities.hlsl,SampleOcclusionProbes中,有獲取環境3DTexture AO值的操作:
float SampleOcclusionProbes(float3 positionWS) { // TODO: no full matrix mul needed, just scale and offset the pos (don't really need to support rotation) float occlusionProbes = 1; float3 pos = mul(_OcclusionProbesWorldToLocalDetail, float4(positionWS, 1)).xyz; UNITY_BRANCH if(all(pos > 0) && all(pos < 1)) { occlusionProbes = tex3D(_OcclusionProbesDetail, pos).a; } else { pos = mul(_OcclusionProbesWorldToLocal, float4(positionWS, 1)).xyz; occlusionProbes = tex3D(_OcclusionProbes, pos).a; } return occlusionProbes; }
這裡用_OcclusionProbesWorldToLocalDetail,將位置轉換為本地位置,因為外面場景OcclusionProbes物件設定了縮放
通過這個縮放轉回本地座標之後,就是0-1範圍內的值了。這算是一個小技巧。
拿到存在3DTexture中的環境AO後,再乘上之前計算的GrassOcclusion,得到skyOcclusion:
float SampleSkyOcclusion(float3 positionRWS, float2 terrainUV, out float grassOcclusion) { float3 positionWS = GetAbsolutePositionWS(positionRWS); grassOcclusion = SampleGrassOcclusion(terrainUV); return grassOcclusion * SampleOcclusionProbes(positionWS); }
並且skyOcclusion存放在surfaceData裡:
surfaceData.skyOcclusion = SampleSkyOcclusion(input.positionRWS, grassOcclusion);
剛剛說還存了環境SH,在SampleBakedGI裡,剛好拿計算好的skyOcclusion乘上_AmbientProbeSH
,
再加在環境GI的SH上,也就是將天光資訊加在當前場景位置取樣到的光照探針上:
//forest-begin: sky occlusion #if SKY_OCCLUSION SHCoefficients[0] += _AmbientProbeSH[0] * skyOcclusion; SHCoefficients[1] += _AmbientProbeSH[1] * skyOcclusion; SHCoefficients[2] += _AmbientProbeSH[2] * skyOcclusion; SHCoefficients[3] += _AmbientProbeSH[3] * skyOcclusion; SHCoefficients[4] += _AmbientProbeSH[4] * skyOcclusion; SHCoefficients[5] += _AmbientProbeSH[5] * skyOcclusion; SHCoefficients[6] += _AmbientProbeSH[6] * skyOcclusion; #endif //forest-end
注:demo中這麼乘做法比較粗暴。
對於OcclusionProbes的做法,個人覺得更像是經驗方案。簡單理解可以理解為"天光加強+植被AO加強+環境低頻AO加強",
或許對於Unity烘焙+探針的GI方案起到了一定補充。
2.7 StaggeredCascade
交錯陰影主要指CSM(級聯陰影)的後面幾級級聯拆分到不同幀,分開更新。
這部分不做展開,感興趣可以搜尋一些資料,也是比較多的。
2.8 TerrainFoley
Foley指通過傳統方法手工製作的音效(https://zhuanlan.zhihu.com/p/42927286),這裡的Foley主要指角色經過草叢,
或角色周圍所聽到的音效,和控制這些音效的邏輯。
TerrainFoley部分主要通過地形API,拿到地形不同部分對應的音效資訊。通過PlayerFoley類,去進行實時監聽和更新。
例如獲得當前所踩位置,腳步音效的部分:
var terrainFoley = TerrainFoleyManager.current; footstepIndex = _foleyMap.GetFoleyIndexAtPosition(position, terrainFoley.splatMap); footstep = foley.footsteps[footstepIndex];
這部分具體可參考TerrainFoleyManager.cs
3.其他關注點
3.1 HDRP修改
當時的版本還不算完善,整體流程也不是像新版本走RenderGraph驅動的。
關於專案中的修改處,具體可搜尋關鍵字:
//forest-begin
例如當時增加了VelocityBuffer到GBuffer,去實現運動模糊,
而現在HDRP已經支援了運動模糊:
//forest-begin: G-Buffer motion vectors if(hdCamera.frameSettings.enableGBufferMotionVectors) cmd.EnableShaderKeyword("GBUFFER_MOTION_VECTORS"); else cmd.DisableShaderKeyword("GBUFFER_MOTION_VECTORS"); var gBuffers = m_GbufferManager.GetBuffersRTI(enableShadowMask); if(hdCamera.frameSettings.enableGBufferMotionVectors) { m_GBuffersWithVelocity[0] = gBuffers[0]; m_GBuffersWithVelocity[1] = gBuffers[1]; m_GBuffersWithVelocity[2] = gBuffers[2]; m_GBuffersWithVelocity[3] = gBuffers[3]; m_GBuffersWithVelocity[4] = m_VelocityBuffer.nameID; gBuffers = m_GBuffersWithVelocity; } HDUtils.SetRenderTarget(cmd, hdCamera, gBuffers, m_CameraDepthStencilBuffer); //forest-end:
更多改動更像是為了彌補當時HDRP未完成的功能而臨時增加的。
3.2 效能統計
在MiniProfiler.cs中,運用到一個Unity當時新提供的API,可以直接在IMGUI中輸出Profile項:
RecorderEntry[] recordersList = { new RecorderEntry() { name="RenderLoop.Draw" }, new RecorderEntry() { name="Shadows.Draw" }, new RecorderEntry() { name="RenderLoopNewBatcher.Draw" }, new RecorderEntry() { name="ShadowLoopNewBatcher.Draw" }, new RecorderEntry() { name="RenderLoopDevice.Idle" }, };
void Awake() { for(int i = 0; i < recordersList.Length; i++) { var sampler = Sampler.Get(recordersList[i].name); if(sampler != null) { recordersList[i].recorder = sampler.GetRecorder(); } } }
具體可搜尋sampler.GetRecorder()進行了解學習。
3.3 Object Space法線的運用
專案中的植被為了防止LOD跳變,使用了Object Space Normal Map(OSNM),而現在最新的HDRP
版本直接提供了法線空間模式切換的選項:
可以想象模型有一個面,法線貼圖讓其法線向上偏移45度。
此時增加一個lod級別,該面片與另外一個面合併,變成一個向上傾斜的新面,
若用切線空間則法線在原偏移上又向上偏移了45度;而物件空間則依然不變。