Book of the Dead 死者之書Demo工程回顧與學習

HONT發表於2022-01-19

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數值與具體元件物件,貫穿整個專案,

因此提前講解。

指令碼中的PropertyVolumeComponentBasePropertyVolumeComponent繼承自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.csBake函式。

先進行一些變數準備工作,通過地形拿到所有植被:

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度;而物件空間則依然不變。

相關文章