案例研究 - 跨具有不同功能的设备缩放应用
此案例研究介绍了 Windows 混合现实应用程序如何面向具有不同硬件功能的各种平台。 Datascape 是一个 Windows Mixed Reality 应用程序,它基于地形数据显示天气数据。 该应用程序将用户括在全息数据可视化效果中。 用户可以通过发现混合现实中的数据来探索他们获得的独特见解。
Datascape 应用面向 Microsoft HoloLens、Windows Mixed Reality 沉浸式头戴显示设备、低功率电脑和高性能电脑。 主要挑战是渲染一个具有视觉吸引力的场景,同时在具有截然不同性能的设备上以高帧速率执行。
此案例研究逐步讲解我们用于创建一些性能密集型系统的过程和技术,尤其是渲染像云这样的天气。 我们描述了我们遇到的问题,以及我们如何克服这些问题。
有关混合现实和 Unity 应用的性能注意事项的详细信息,请参阅:
下面是有关 Datascape 应用程序的一些背景和挑战。
我们的主要渲染难题是透明度,因为解决这一问题十分昂贵。
在写入深度缓冲区时,可以从前到后渲染实心几何图形,停止渲染位于该像素后面的任何未来像素。 此操作可防止隐藏像素执行像素着色器,并显著加快渲染速度。 如果以最佳方式对几何图形进行排序,屏幕上的每个像素仅绘制一次。
透明几何形状必须从后到前进行排序,并依赖于将像素着色器的输出与屏幕上的当前像素混合。 此过程可能导致每个帧在屏幕上绘制多个像素,称为过度绘制。
对于 HoloLens 和主流电脑,屏幕只能填充几次,从而使透明渲染出现问题。
Datascape 场景有三个主要组件:UI、地图和天气。 我们知道天气效果需要它们可以获得的所有性能,因此我们设计了 UI 和地图来减少过度绘制。
我们多次重新设计 UI,以最大程度地减少过度绘制量。 对于发光按钮和地图概述等组件,我们选择使用更复杂的几何图形,而不是覆盖透明组件。
对于地图,我们使用自定义着色器,该着色器去除了标准 Unity 功能,如阴影和复杂的照明。 自定义着色器用简单的单太阳照明模型和自定义雾计算替换了这些功能。 这个简单的像素着色器提高了性能。
我们按预算完成了 UI 和地图的渲染,因此它们不需要任何依赖于硬件的更改。 天气可视化效果,尤其是云渲染,更具挑战性。
从 NOAA 服务器下载的云数据采用三个不同的 2D 层。 对于网格的每个单元,每个层都有云的顶部和底部高度以及云的密度。 我们已将数据处理成云信息纹理,该纹理将每个组件存储在纹理的红色、绿色和蓝色组件中。
为确保低功率的计算机可以渲染云,我们的备份方法使用实心几何图形来最大程度地减少过度绘制。
我们通过为每个层生成实心高度地图网格来生成云。 我们使用每个顶点的云信息纹理的半径来生成形状。 我们使用几何着色器在云的顶部和底部生成顶点,生成坚实的云形状。 我们使用纹理中的密度值,用较深的颜色为云着色,以获得乌云。
以下着色器代码创建顶点:
v2g vert (appdata v)
{
v2g o;
o.height = tex2Dlod(_MainTex, float4(v.uv, 0, 0)).x;
o.vertex = v.vertex;
return o;
}
g2f GetOutput(v2g input, float heightDirection)
{
g2f ret;
float4 newBaseVert = input.vertex;
newBaseVert.y += input.height * heightDirection * _HeigthScale;
ret.vertex = UnityObjectToClipPos(newBaseVert);
ret.height = input.height;
return ret;
}
[maxvertexcount(6)]
void geo(triangle v2g p[3], inout TriangleStream<g2f> triStream)
{
float heightTotal = p[0].height + p[1].height + p[2].height;
if (heightTotal > 0)
{
triStream.Append(GetOutput(p[0], 1));
triStream.Append(GetOutput(p[1], 1));
triStream.Append(GetOutput(p[2], 1));
triStream.RestartStrip();
triStream.Append(GetOutput(p[2], -1));
triStream.Append(GetOutput(p[1], -1));
triStream.Append(GetOutput(p[0], -1));
}
}
fixed4 frag (g2f i) : SV_Target
{
clip(i.height - 0.1f);
float3 finalColor = lerp(_LowColor, _HighColor, i.height);
return float4(finalColor, 1);
}
我们引入了一种小型噪声模式,可在真实数据的基础上获得更多详细信息。 为了生成圆形云边缘,我们在内插半径值达到阈值时剪裁像素着色器中的像素来丢弃近零值。
由于云是实心几何图形,因此可以在地形渲染之前渲染它们。 将昂贵的地图像素隐藏在云层之下,可以进一步提高帧速率。 由于采用实心几何图形渲染方法,此解决方案可在各种高低端显卡以及 HoloLens 上正常运行。
我们的解决方案对云数据进行了不错的展示,但略显平淡无奇。 云的渲染并未传达出我们想要在高端机器上实现的立体感。 下一步是通过用约 100,000 个粒子来代表和创建云,从而生成更自然和立体的外观。
如果粒子保持固态并按前后顺序排序,你仍然可受益:从先前渲染的粒子后面的深度缓冲区剔除,从而减少过度绘制。 此外,基于粒子的解决方案可以更改粒子量以面向不同的硬件。 但是,所有像素仍需经过深度测试,这会导致更多的开销。
首先,我们在启动时围绕体验中心点创建了粒子位置。 我们更密集地把粒子分布在中心周围,在远处分布得更少。 我们预先对从中心到后部的所有粒子进行排序,因此首先渲染最接近的粒子。
计算着色器对云信息纹理进行采样,将每个粒子定位在正确高度,并根据密度对其进行着色。 每个粒子都包含高度和半径。 高度基于从云信息纹理采样的云数据。 半径基于初始分布,该分布计算并存储到其最近的邻居的水平距离。
我们使用 DrawProcedural 来渲染每个粒子的四边形。 四边形利用这些数据来定位自己,根据高度来调整角度。 当用户水平查看粒子时,它会显示高度。 当用户从上到下查看粒子时,将覆盖它与其邻居之间的区域。
以下着色器代码显示分布:
ComputeBuffer cloudPointBuffer = new ComputeBuffer(6, quadPointsStride);
cloudPointBuffer.SetData(new[]
{
new Vector2(-.5f, .5f),
new Vector2(.5f, .5f),
new Vector2(.5f, -.5f),
new Vector2(.5f, -.5f),
new Vector2(-.5f, -.5f),
new Vector2(-.5f, .5f)
});
StructuredBuffer<float2> quadPoints;
StructuredBuffer<float3> particlePositions;
v2f vert(uint id : SV_VertexID, uint inst : SV_InstanceID)
{
// Find the center of the quad, from local to world space
float4 centerPoint = mul(unity_ObjectToWorld, float4(particlePositions[inst], 1));
// Calculate y offset for each quad point
float3 cameraForward = normalize(centerPoint - _WorldSpaceCameraPos);
float y = dot(quadPoints[id].xy, cameraForward.xz);
// Read out the particle data
float radius = ...;
float height = ...;
// Set the position of the vert
float4 finalPos = centerPoint + float4(quadPoints[id].x, y * height, quadPoints[id].y, 0) * radius;
o.pos = mul(UNITY_MATRIX_VP, float4(finalPos.xyz, 1));
o.uv = quadPoints[id].xy + 0.5;
return o;
}
我们从前到后对粒子进行排序,并且仍然使用纯色样式着色器来剪辑透明像素,而不是混合它们。 这项技术即使在低性能机器上也能处理大量粒子,从而避免昂贵的过度绘制。
纯色粒子为云提供了自然的感觉,但仍需要一些东西来获取云的蓬松状。 我们决定尝试用于引入透明度的高端显卡的自定义解决方案。 我们只需切换粒子的初始排序顺序,并将着色器更改为使用纹理 alpha。
这个解决方案看起来很棒,但事实证明,即便是最强大的机器也承受不了。 每个像素都必须在屏幕上渲染数百次。
为了减少用于渲染云的像素数,我们在屏幕分辨率四分之一的缓冲区中渲染它们。 绘制所有粒子后,我们将最终结果拉伸回屏幕。
以下代码显示屏幕外渲染:
cloudBlendingCommand = new CommandBuffer();
Camera.main.AddCommandBuffer(whenToComposite, cloudBlendingCommand);
cloudCamera.CopyFrom(Camera.main);
cloudCamera.rect = new Rect(0, 0, 1, 1); //Adaptive rendering can set the main camera to a smaller rect
cloudCamera.clearFlags = CameraClearFlags.Color;
cloudCamera.backgroundColor = new Color(0, 0, 0, 1);
currentCloudTexture = RenderTexture.GetTemporary(Camera.main.pixelWidth / 2, Camera.main.pixelHeight / 2, 0);
cloudCamera.targetTexture = currentCloudTexture;
// Render clouds to the offscreen buffer
cloudCamera.Render();
cloudCamera.targetTexture = null;
// Blend low-res clouds to the main target
cloudBlendingCommand.Blit(currentCloudTexture, new RenderTargetIdentifier(BuiltinRenderTextureType.CurrentActive), blitMaterial);
此解决方案将处理速度提高了四倍,但也存在几个局限性。 首先,在渲染到屏幕外缓冲区时,我们失去了主场景的所有深度信息。 山后面的粒子在山顶上渲染。
其次,拉伸缓冲区会在云的边缘引入分辨率变化明显的伪影。 接下来两节介绍如何解决这些问题。
我们需要让粒子与世界几何体共存,即当一座山或物体挡住它后面的粒子时,这些粒子需要被遮挡住。 因此,我们使用包含主场景几何图形的深度缓冲区填充屏幕外缓冲区。 为了生成深度缓冲区,我们创建了第二个相机,只渲染纯几何图形和深度场景。
我们在云像素着色器中使用新纹理遮挡像素。 我们使用相同的纹理来计算与云像素到几何图形之间的距离。 通过使用这个距离并将其应用于像素的 alpha,我们实现了云在接近地形时逐渐淡出的效果。 这个效果消除了粒子和地形相交处的任何生硬剪切。
被拉伸的云看起来几乎与粒子中心或重叠位置上的正常大小的云相同,但云边缘有一些伪影。 锐利的边缘变得模糊,相机的移动则引入了锯齿效应。
为解决此问题,我们:
- 在屏幕外缓冲区上运行简单的着色器来确定对比度发生重大变化的位置。
- 将具有重大更改的像素放入新模具缓冲区。
- 使用模具缓冲区对高对比度区域进行遮罩,以便在将屏幕外缓冲区应用到屏幕时,在云层内部和周围产生孔洞。
- 在全屏模式下再次渲染所有粒子,使用模具缓冲区将除边缘之外的所有内容进行遮罩,从而产生了一组最小像素。 由于我们已为粒子创建了命令缓冲区,因此,只需将其再次渲染到新相机即可。
最终结果是,云中心部分的开销低廉且具有清晰的边缘。 虽然此解决方案比全屏渲染所有粒子要快得多,但测试像素与模板缓冲区匹配仍然需要一定的成本。 大量过度绘制仍然昂贵。
关于风的效果,我们在计算着色器中生成了长三角形条带,从而在世界各地创建了许多缕风。 由于使用了窄条带,风效对填充率的影响并不严重。 但是,数十万个顶点导致顶点着色器负载沉重。
为减小负载,我们在计算着色器中引入了追加缓冲区,用于馈送要绘制的一小部分风的条带。 在计算着色器中,我们使用简单的视锥剔除逻辑确定条带是否在相机视图外,并防止将这些条带添加到推送缓冲区。 此过程显著减少了条带的数量,提高了性能。
以下代码演示了追加缓冲区。
计算着色器:
AppendStructuredBuffer<int> culledParticleIdx;
if (show)
culledParticleIdx.Append(id.x);
C# 代码:
protected void Awake()
{
// Create an append buffer, setting the maximum size and the contents stride length
culledParticlesIdxBuffer = new ComputeBuffer(ParticleCount, sizeof(int), ComputeBufferType.Append);
// Set up Args Buffer for Draw Procedural Indirect
argsBuffer = new ComputeBuffer(4, sizeof(int), ComputeBufferType.IndirectArguments);
argsBuffer.SetData(new int[] { DataVertCount, 0, 0, 0 });
}
protected void Update()
{
// Reset the append buffer, and dispatch the compute shader normally
culledParticlesIdxBuffer.SetCounterValue(0);
computer.Dispatch(...)
// Copy the append buffer count into the args buffer used by the Draw Procedural Indirect call
ComputeBuffer.CopyCount(culledParticlesIdxBuffer, argsBuffer, dstOffset: 1);
ribbonRenderCommand.DrawProceduralIndirect(Matrix4x4.identity, renderMaterial, 0, MeshTopology.Triangles, dataBuffer);
}
我们在云粒子上尝试使用此技术,在计算着色器上剔除它们,并只推送要渲染的可见粒子。 但我们没有节省太多处理,因为最大的瓶颈是在屏幕上渲染的云像素数,而不是计算顶点的成本。
另一个问题是,由于粒子的并行计算,追加缓冲区按随机顺序填充。 排序的粒子变得无序,导致云粒子闪烁。 有一些方法可以对推送缓冲区进行排序,但剔除粒子带来的性能增益有限,可能会被另一种排序所抵消。 我们决定不对云粒子进行这种优化。
为确保在具有不同渲染条件(如多云视图和清晰视图)的应用上以稳定的帧速率,我们引入了自适应渲染功能。
自适应渲染的第一步是测量性能。 我们将自定义代码插入到渲染帧开头和结尾的命令缓冲区中,同时捕获左右眼睛屏幕时间。
将渲染时间与所需的刷新频率进行比较,以显示删除帧的接近程度。 接近删除帧时,可以更快地调整渲染速度。
一种简单的调整渲染的方法是改变屏幕视口的大小,以减少渲染所需的像素数量。 系统使用 UnityEngine.XR.XRSettings.renderViewportScale 来缩小目标视区,并自动拉伸结果以适应屏幕。 缩放比例微小的更改在世界几何图形上几乎不明显,缩放系数为 0.7 时,需要渲染的像素量只有原来的一半。
当检测到即将丢帧时,我们将按某个固定比率降低缩放比例;当运行得足够快时恢复它。
在这个案例研究中,我们根据启动时硬件的图形能力来决定使用哪种云技术。 你也可以根据性能测量的数据来做出这个决定,以帮助防止系统长时间保持在低分辨率。
针对不同硬件能力进行优化具有挑战性,需要进行规划。 以下是一些建议:
- 先从低性能计算机开始,以熟悉问题领域。
- 开发在所有计算机上运行的备份解决方案。 然后,你可以为高端机器添加更多复杂性,或者提高备用解决方案的分辨率。
- 在设计解决方案时考虑到填充率,因为像素是非常宝贵的资源。
- 基于透明度建立纯色几何图形的目标。
- 针对最糟糕的情况进行设计,在适当的情况下考虑为繁重负载使用自适应渲染。
![]() |
Robert Ferrese 软件工程师 @Microsoft |
![]() |
Dan Andersson 软件工程师 @Microsoft |