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: PropertyVolumeComponentBase
where X : PropertyVolumeComponent{
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);
然后更新后的RWStructedBuffer,直接写回自身,是放在GPU端一直更新的:
AutoFocusParams params = _AutoFocusParams[0];
params.currentFocusDistance = SmoothDamp(params.currentFocusDistance, focusDistance, params.currentVelocity);
_AutoFocusParams[0] = params;
最后输出:
Output(params.currentFocusDistance);
接着,到了后处理阶段,shader DepthOfField.hlsl,直接拿到刚刚处理过的RWStructedBuffer获取数据:
//custom-begin: autofocus
#if AUTO_FOCUS
struct AutoFocusOutput
{
float focusDistance;
float lensCoeff;
};
StructuredBuffer_AutoFocusOutput : register(t3); float2 GetFocusDistanceAndLensCoeff()
{
return float2(_AutoFocusOutput[0].focusDistance, _AutoFocusOutput[0].lensCoeff);
}
#else
到这里,完成了焦距信息的传入。
之前第一次打开Book of the Dead,看见这种做法不理解,为什么一个线程的信息也要用Compute Shader去做,后来
接触到RWStructedBuffer处理完直接丢Shader这种做法(不支持StructedBuffer,必须是RW才能丢),发现这么用确实省了带宽,
另外由于自动对焦涉及到屏幕信息读取,还是属于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
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
之前的GrassOcclusion用了OcclusionProbes烘焙单个植被的AO,这里OcclusionProbes覆盖整个场景,
将场景的遮蔽信息储存进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
获取的,并且做了修正操作:
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
注1:demo中这么乘做法比较粗暴。
注2:Unity存的SH有一部分计算放在了CPU端进行了化简,主要是sh[6](l2,r0)部分常数加在了sh[0](l0,r0)上,
通过SphericalHarmonicsL2走Unity内部传入shader的球谐,会自动做转换,而_AmbientProbeSH
是外部传入,所以要做
这样一个转换。(这部分资料比较少,不保证正确)
对于OcclusionProbes的做法,个人觉得更像是经验方案。或许可以弥补当前GI方案的不足,但只靠叠AO去改善画面的话
,很难说是物理正确的。
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度;而对象空间则依然不变。