Outbound的剪辑着色器方法:实时环境的精确植被丢弃

如何防止草在你的开放世界房车生活游戏中穿透地板?在这篇客座文章中,Square Glade Games 的程序员 Tony Fial 和 Michiel Procé 详细介绍了他们如何处理并最终解决这个问题,在 Outbound 中使用自定义着色器剪辑解决方案。
我们是 Tony Fial 和 Michiel Procé,Square Glade Games 团队的一部分,我们目前正在开发工作室最新的标题 Outbound,这是一款设定在理想近未来的开放世界探索游戏。玩家从一辆空的房车开始,可以将其改造成梦想中的移动家园,按照自己的想法进行建造。
车辆是游戏的一个重要焦点,驾驶它穿越自然也是如此。在 Outbound 的世界中,手工制作了大量的植物和草,生机勃勃、高大且丰富。尽管我们可以用这些素材创造一个美丽的世界,但将它们与在这种环境中行驶的车辆结合在一起会导致一些视觉问题。
问题
玩家能够在基本上任何开放区域驾驶他们的房车。灌木和草对这没有阻碍。由于房车离地面很近,这常常导致地形上的草穿透车辆的底部或侧面。
还有一些地方,房车可以接触到更高的植物,如花和灌木。为了展示手头的问题,下面的截图显示了草和灌木严重穿透车辆的情况。这不仅在视觉上不吸引人,还会导致各种游戏玩法问题,如视觉上阻挡互动或重要信息。

总结我们的核心问题,有不同类型的植物和草穿透房车,从视觉和游戏玩法的角度来看,这是不希望发生的。
现在,来解决这个问题吧,如何?
头脑风暴可能的解决方案
在 Square Glade Games,在我们积极开始工作之前,我们个人觉得整理出一个最佳需求列表是很方便的。
在这种特定情况下,我们需要我们的解决方案:
• 具有高性能。在Outbound中有很多草,因此在草和植物更多的区域,未优化的解决方案可能会非常昂贵。
• 保持原始风格不变。目前我们处于一个开发阶段,无法改变Outbound中主要元素的外观,因此理想的解决方案尽可能多地利用原始植被。
• 具有跨平台兼容性。由于该标题计划在多个平台上发布,因此解决方案需要在Windows、Nintendo Switch™、Xbox和PlayStation®上运行。
• 易于使用。理想情况下,解决方案应该对团队中的设计师和程序员都直观易用。
• 适用于多种形状。理想情况下,我们会剪掉与车辆形状完全相符的植被,可能使用多种形状。
现在考虑能够满足这一系列要求的解决方案。我们最初的想法是所有草叶共享的一个元素……着色器。
在Outbound中几乎所有的植物都是使用地形工具放置在Unity地形上的。其中相当大一部分是草,使用默认的草着色器。该着色器使用GPU以非常高效的方式放置和广告草平面。其他元素,如上图所示的较大灌木,作为细节网格放置,使用其自己分配的材料和着色器。
这提出了另一个重要细节,即提议的解决方案应该能够以相同的方式同时在多个完全不同的着色器上工作。
提议的解决方案
以下所有提议的解决方案也有一个主要的共同“输入”:露营车的位置,或者更准确地说,应该剪掉树叶的区域。
根据所述要求,我们希望我们的解决方案对其他Square Glade团队成员来说是直观的。根据我们的经验,只有当编辑工具直观且易于上手时,团队成员才会使用它们。考虑到这一点,我们决定构建一个可缩放、可旋转和可操作的视觉3D立方体,以便恰到好处地剪掉车辆的车身并进行调整。立方体内的任何树叶都会被剪掉,而立方体外的所有东西看起来都一样。
模板着色器
我们尝试的第一件事是使用一个叫做'模板缓冲区'的着色器元素。
着色器编程的这一部分非常迷人,但也有点难以理解。对我们来说,这归结为我们告诉'剪切元素',在这种情况下是我们的立方体,向渲染帧的模板缓冲区写入一些信息。这意味着在屏幕上立方体所在的任何地方,它将写入值1。'剪切'对象(在我们的例子中是草)可以从该缓冲区读取,并丢弃任何值恰好为1的像素。
在着色器代码中,这看起来像这样:
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}剪切对象向缓冲区写入值1,由行Ref 1所述,并将始终这样做。如果后续渲染的模板值匹配或通过模板比较,它将替换为该着色器的信息。草的实现类似:它还会查找Ref 1的值,只有在比较等于该参考值时才会通过检查。
该实现确实有效地剪掉了草,并且非常高效,因为它作用于渲染帧的像素,不受给定场景中草的数量影响。然而,这个解决方案存在一个致命的缺陷。因为这个实现没有深度感,它也会剪掉立方体后面的任何东西。实际上,这意味着当玩家坐在车辆内,从第一人称视角看时,整个屏幕会被标记为“剪切”,因此玩家不会看到任何草。因此,我们不得不尝试一些其他方法,这些方法在玩家相机位于“剪切器”对象内部时也能工作。
手动剪切
我们简要讨论的一个解决方案是手动移除我们车辆位置的草,将其从地形本身中去除。我们已经为游戏中的其他部分这样做过,使用Unity在地形上提供的'TerrainData.SetDetailLayer'函数。这将把细节层的灰度颜色设置为0,位于面包车正下方的像素,指示地形在该位置移除任何细节网格或草。
因为外部的地图相当大,这意味着细节层的分辨率较低,使其有点“锯齿状”。这对于草和其他网格的正常细节放置是完全可以的,但在手动剪切部分时,较低的分辨率会导致形状与面包车的大小不够接近,要么太小,要么太大。
这个解决方案还会导致当车辆刚好在两个地形细节像素的边界上时,细节闪烁。出于这些原因,我们没有继续实施这个解决方案。我们的旅程继续!
剪切着色器
使用模板缓冲区着色器时,我们认为我们快要成功了,因为我们在需要的地方以面包车外部车身的精度渲染了不可见的像素。如果有另一种方法可以做到这一点,同时实际使用立方体的深度,知道解决方案基本上只需剪切其边界框内的像素。
事实证明,确实有一种方法可以做到这一点!HLSL着色器提供了谦逊的clip()函数,如果指定的值小于0,则简单地丢弃该像素。你可能在某些随机着色器中见过这个,它通常用于alpha剪切。
举个例子,外部的草看起来像实际的草丛,而不是带有草图像的方形四边形,因为我们在草纹理的alpha通道为黑色的地方“剪切”掉。
当我们对这个解决方案进行了快速的初步原型/检查时,我们对这个实现能否工作抱有很高的期望,因为我们能够在某个世界位置上渲染不可见的像素。在伪代码中,函数看起来像下面这样:
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );解决方案:剪辑着色器
到目前为止,我们有一个简单的示例,展示了一个有前景的解决方案,即使用剪辑着色器。下一步是创建一个函数,为着色器提供所需的信息,以便在我们想要的地方进行剪辑。这涉及两个部分:
• 计算本质上是“形状”的部分,包括其尺寸和变换,并将这些数据提供给着色器。
• 着色器使用这些数据,检查给定点是否在形状内,并在需要的地方丢弃其像素。
在我们解决方案的第一步中,我们创建了一个 'GrassClipperShape' 脚本,这是一个 MonoBehaviour,我们可以将其附加到场景中的对象上,以指示剪辑区域的位置。下面显示了一个示例,其中使用 OnDrawGizmos 在编辑器视图中显示形状的区域。

由于我们理想情况下希望使用多个这样的剪辑器,我们需要一个总的脚本(即“管理器”)来处理所有可用的剪辑器。每个剪辑器将向这个名为 'GrassClipperManager' 的总脚本提供以下属性:
• 形状:形状的类型,我们希望这个版本能够与立方体和球体一起使用,因此这是一个简单的枚举,设置为 'cube' 或 'sphere'
• Vector3:场景中对象的大小
• 矩阵4x4:在世界空间中计算的旋转对象
GrassClipperManager 在场景中只有一个,将每帧从剪辑器获取这些信息,并像这样发送给着色器:
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);上面的行将设置全局着色器值。简而言之,这意味着您可以使用这些确切名称和类型的着色器值,并在任何着色器中使用它们。
因为我们希望我们的裁剪在多个不同的着色器上发生,所以我们创建了一个单独的 HLSL 脚本,以便包含在任何需要受我们裁剪器影响的着色器中。该脚本公开了一个名为 'ApplyClipVolumeSDF' 的自定义函数。它使用现在填充的全局着色器值的信息,并计算一个像素是否在任何边界内。
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}如上所示,如果像素应该被丢弃,它将调用 'clip(-1)' 函数,返回一个被丢弃的像素。否则,它将正常通过着色器的其余部分。
裁剪着色器实现
随着裁剪函数的创建并提供必要的数据,现在是将其实现到我们的着色器中的时候了。
让我们首先讨论如何为细节网格执行此操作,我们可以创建原始网格的副本并进行编辑。在着色器的最顶部,我们必须像这样引用自定义脚本:
#include "Assets/Shaders/ClipVolume.hlsl"然后当我们想要实际使用该函数时,我们只需在着色器的片段部分内部调用它,如下所示:
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);在我们的案例中,只有两个着色器需要包含这个,即 Unity 草使用的默认着色器和用于所有其他作为细节网格渲染的植物的自定义着色器。现在我们有了这个,如果需要,可以很容易地在任何其他着色器中实现它。
但我们的旅程并没有结束——最后一个障碍出现了。我们现在如何编辑并实际保留对默认草地着色器所做的更改?Unity使用一些特定的内置着色器来渲染草地,在我们的案例中是'WavingGrassBillboard.shader'。这个着色器会自动应用于所有草地,没有选项可以提供自定义变体。这对使我们的解决方案有效至关重要,因为它需要挂钩到那个着色器,以便能够调用自定义的'ApplyClip'函数并丢弃不需要的像素。
在尝试了一些解决方案后,团队成员Michiel Procé找到了一个可靠的方法来编辑并实际保留对默认草地着色器的更改。通过在构建和编辑器中运行以下代码,我们的自定义着色器替换了默认的URP着色器:
string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";
if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
{
Shader replacementShader = Shader.Find(replacementShaderName);
shadersResources.terrainDetailGrassBillboardShader = replacementShader;
}
}请注意,这仅替换了WavingGrassBillboard着色器,但为其他着色器实现这一点会类似。
最后的想法
我们使用剪辑着色器的最终解决方案非常适合我们的目的,我们对它提供的结果非常满意。请参见下面的屏幕截图,以可视化解决方案,其中一个矩形立方体剪掉了内部的草。请注意,盒子是从上方看到的,并且穿过地形以获得最佳的剪裁视图。

回顾我们对草地剪裁解决方案的要求清单,我们很高兴看到它符合所有要求!
• 这个解决方案是高效的,因为用于计算剪裁的函数非常简单。而且因为它直接丢弃像素,我们的实现不会进行进一步不必要的处理。
• 它保持Outbound'的原始风格不变,因为它是建立在我们已经使用的着色器之上的。
• 实现是平台无关的,因为clip()函数本身就是。
• 这个解决方案对团队的其他成员来说是直观易用的。设计师可以创建和使用多种形状,甚至可以让它们相互交叉。
我们相信,像上面提到的功能是极其重要的,不仅出于创造力的考虑,还可以防止奇怪的错误在后期出现。
示例项目
为了与社区分享这个解决方案,我们创建了一个使用上述技术的示例项目,以便您可以自己尝试 – 在GitHub上查看。
感谢您阅读我们的客座文章。希望这能帮助许多面临与我们相同问题的其他开发者!
Outbound目前正在进行封闭测试;关注Steam上的游戏以获取更新。在我们的Steam策展页面上探索更多使用Unity制作的游戏,并在我们的资源中心上查看更多Unity开发者的故事。
Nintendo Switch™是Nintendo的商标。
