最近,我需要用顶视图解决很多游戏中常见的问题:在屏幕上渲染一大堆敌人的健康条。 像这样:
显然,我想尽可能有效地做到这一点,最好是在一次平局中。 像往常一样,在开始工作之前,我对其他人的决定做了一些在线研究,结果却大不相同。
我不会为代码感到羞耻,但是只要说一些解决方案并不十分出色就可以了,例如,有人向每个敌人添加了Canvas对象(这是非常低效的)。
结果,我使用的方法与我在其他人身上看到的所有方法都略有不同,并且根本不使用任何UI类(包括Canvas),因此我决定将其公开。 对于那些想学习源代码的人,我将
其发布
在Github上 。
为什么不使用Canvas?
为每个敌人使用一个Canvas显然是一个错误的决定,但是我可以为所有敌人使用一个Canvas。 单个Canvas也会导致渲染调用批处理。
但是,我不喜欢与此方法相关的每个框架中完成的工作量。 如果使用Canvas,则必须在每个帧中执行以下操作:
- 确定屏幕上显示了哪些敌人,然后从池UI栏中选择每个敌人。
- 在相机中投射敌人的位置,以定位地带。
- 调整条的“填充”部分的大小,可能像Image。
- 最有可能根据敌人的类型改变条带的大小; 例如,大敌人应该有大条,这样看起来就不会很傻。
无论如何,所有这些都会污染Canvas几何缓冲区,并导致处理器中所有顶点数据的重建。 我不希望所有这些工作都如此简单。
简要介绍我的决定
我的工作过程的简要说明:
- 我们将能量条的对象附加到3D中的敌人上。
- 这使您可以自动排列和修剪条带。
- 地带的位置/大小可以根据敌人的类型进行调整。
- 我们将使用仍然在其中的transform在代码中将条纹定向到相机。
- 着色器可确保它们始终在所有内容之上进行渲染。
- 我们使用Instancing在一个绘制调用中渲染所有带。
- 我们使用简单的程序UV坐标来显示条带的填充程度。
现在让我们更详细地看一下解决方案。
什么是实例化?
在处理图形时,长期以来一直使用标准技术:将多个对象组合在一起,以便它们具有通用的顶点数据和材质,并且可以在一次绘制调用中对其进行渲染。 这正是我们需要的,因为每个绘制调用都会给CPU和GPU带来额外的负担。 无需对每个对象进行一次绘制调用,我们可以同时渲染所有对象,并使用着色器为每个副本添加可变性。
您可以通过在一个缓冲区中将网格顶点数据复制X倍来手动完成此操作,其中X是可以渲染的最大副本数,然后使用着色器参数数组对每个副本进行转换/着色/更改。 每个副本都必须存储有关其编号实例的知识,以便将该值用作数组的索引。 然后,我们可以使用索引的渲染调用,该调用将“仅渲染到N”排序,其中N是当前帧中
实际需要的实例数,小于X的最大数。
大多数现代的API已有此代码,因此您无需手动执行此操作。 此操作称为“实例化”; 实际上,它以预定义的限制自动执行上述过程。
Unity引擎还支持实例化 ,它具有自己的API和一组有助于其实现的着色器宏。 它使用某些假设,例如,每个实例都需要完整的3D转换。 严格来说,对于2D带钢并不需要完全-我们可以简化,但既然如此,我们将使用它们。 这将简化我们的着色器,并提供使用3D指示器(例如圆形或圆弧)的功能。
类易损
我们的敌人将拥有一个名为
Damageable
的组件,这可以使他们健康,并让他们从碰撞中受到伤害。 在我们的示例中,这非常简单:
public class Damageable : MonoBehaviour { public int MaxHealth; public float DamageForceThreshold = 1f; public float DamageForceScale = 5f; public int CurrentHealth { get; private set; } private void Start() { CurrentHealth = MaxHealth; } private void OnCollisionEnter(Collision other) {
HealthBar对象:位置/转弯
生命线对象非常简单:实际上,它只是连接到敌人的四边形。

我们使用该对象的
比例使条带变长和变细,并将其直接置于敌人上方。 不用担心它的旋转,我们将使用
HealthBar.cs
附加到对象的代码来修复它:
private void AlignCamera() { if (mainCamera != null) { var camXform = mainCamera.transform; var forward = transform.position - camXform.position; forward.Normalize(); var up = Vector3.Cross(forward, camXform.right); transform.rotation = Quaternion.LookRotation(forward, up); } }
此代码始终将四边形指向相机。 我们
可以在着色器中执行调整大小和旋转的操作,但是出于两个原因在这里实现它们。
首先,Unity实例化始终使用每个对象的完整转换,并且由于我们无论如何都传输所有数据,因此您可以使用它。 其次,在此处设置比例/旋转可确保修剪带材的边界平行四边形始终为真。 如果我们将大小和旋转的任务指定为着色器的职责,则Unity可以截断当它们靠近屏幕边缘时应可见的条带,因为其边界平行四边形的大小和旋转将与我们要渲染的内容不符。 当然,我们可以实现自己的截断方法,但是通常,如果可能的话,最好使用我们拥有的方法(Unity代码是本机代码,并且比我们拥有更多的空间数据)。
在看完着色器后,我将解释如何渲染条。
着色器健康栏
在此版本中,我们将创建一个简单的经典红绿色条。
我使用2x1纹理,左侧有一个绿色像素,右侧有一个红色像素。 自然,我关闭了mipmapping,过滤和压缩功能,并将寻址模式参数设置为Clamp,这意味着我们地带的像素将始终是完美的绿色或红色,并且不会散布在边缘周围。 这将使我们能够更改着色器中的纹理坐标,以将分割红色和绿色像素的线上下移动。
(由于只有两种颜色,因此我可以使用着色器中的step函数返回到另一种颜色。但是,这种方法很方便,可以根据需要使用更复杂的纹理,并且在过渡过程中也可以类似地工作中等质地。)首先,我们将声明所需的属性:
Shader "UI/HealthBar" { Properties { _MainTex ("Texture", 2D) = "white" {} _Fill ("Fill", float) = 0 }
_MainTex
是红绿色纹理,而
_Fill
是从0到1的值,其中1是完全健康。
接下来,我们需要命令该条带在叠加队列中进行渲染,这意味着忽略场景中的所有深度并在所有内容之上进行渲染:
SubShader { Tags { "Queue"="Overlay" } Pass { ZTest Off
下一部分是着色器代码本身。 我们正在编写一个没有光照(未照明)的着色器,因此我们不必担心与各种Unity表面着色器的集成,这只是一对顶点/片段着色器。 首先,编写引导程序:
CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #include "UnityCG.cginc"
在大多数情况下,这是标准的引导程序,但
#pragma multi_compile_instancing
,它告诉Unity编译器为Instancing需要编译什么。
顶点结构必须包含实例数据,因此我们将执行以下操作:
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
除了对我们进行Unity(转换)处理的内容外,我们还需要指定实例数据中的确切内容:
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _Fill) UNITY_INSTANCING_BUFFER_END(Props)
因此,我们报告说Unity必须创建一个名为“ Props”的缓冲区来存储每个实例的数据,并且在其中我们将为每个实例使用一个float来表示
_Fill
属性。
您可以使用多个缓冲区。 如果您有几个以不同频率更新的属性,那是值得做的; 划分它们,例如,可以在更改另一个缓冲区时不更改一个缓冲区,这样效率更高。 但是我们不需要这个。
我们的顶点着色器几乎完全完成了标准工作,因为大小,位置和旋转已转移到变换中。 这是使用
UnityObjectToClipPos
实现的,该功能自动使用每个实例的转换。 可以想象,如果不实例化,通常只是简单地使用单个矩阵属性。 但是在引擎内部使用实例化时,它看起来像一个矩阵数组,而Unity会独立选择一个适合该实例的矩阵。
另外,您需要根据
_Fill
属性更改UV以将过渡点的位置从红色更改为绿色。 这是相关的代码片段:
UNITY_SETUP_INSTANCE_ID(v); float fill = UNITY_ACCESS_INSTANCED_PROP(Props, _Fill);
UNITY_SETUP_INSTANCE_ID
和
UNITY_ACCESS_INSTANCED_PROP
通过从此实例的常量缓冲区访问
_Fill
属性的正确版本来完成所有工作。
我们知道,在正常状态下,象限的UV坐标覆盖整个纹理间隔,并且条带的分界线在水平方向上位于纹理的中间。 因此,小的数学计算可将条带水平向左或向右移动,并且纹理的Clamp值可确保填充其余部分。
片段着色器再简单不过了,因为所有工作都已经完成:
return tex2D(_MainTex, i.uv);
完整的注释着色器代码可在
GitHub存储库中找到 。
健康栏材料
然后,一切都变得简单了-我们只需要将材质球使用的材质分配给条带即可。 几乎不需要做任何事情,只需在上部选择所需的着色器,分配红绿色纹理,然后最重要的是
选中“启用GPU实例化”框 。

HealthBar填充属性更新
因此,我们有了运行状况栏对象,着色器和要渲染的材质,现在我们需要为每个实例设置
_Fill
属性。 我们在
HealthBar.cs
中执行以下操作:
private void UpdateParams() { meshRenderer.GetPropertyBlock(matBlock); matBlock.SetFloat("_Fill", damageable.CurrentHealth / (float)damageable.MaxHealth); meshRenderer.SetPropertyBlock(matBlock); }
我们将
CurrentHealth
类的
CurrentHealth
转换为0到1的值,将其除以
MaxHealth
。 然后,使用
MaterialPropertyBlock
将其传递给
_Fill
属性。
如果您没有使用
MaterialPropertyBlock
甚至没有实例化就将数据传输到着色器,那么您需要进行研究。 Unity文档中对此解释不充分,但这是将数据从每个对象传输到着色器的最有效方法。
在我们的示例中,使用实例化时,所有运行状况栏的值都打包在一个常量缓冲区中,以便可以将它们一起传输并一次绘制。
除了用于设置变量的样板之外,这里几乎没有其他内容,并且代码相当无聊。
有关详细信息,请参见
GitHub存储库 。
演示版
GitHub存储库有一个测试演示,其中,一堆邪恶的蓝色立方体被英勇的红色球体(hooray!)摧毁,并承受了本文所述的条纹所显示的损害。 用Unity 2018.3.6f1编写的演示。
可以通过两种方式观察使用实例化的效果:
统计面板
单击“播放”后,单击“游戏”面板上方的“统计”按钮。 在这里,您可以看到由于实例化而节省了多少绘制调用:

启动游戏后,您可以单击HealthBar材料,然后
取消选中 “启用GPU实例化”
复选框 ,此后保存的呼叫数将减少为零。
帧调试器
启动游戏后,转到“窗口”>“分析”>“帧调试器”,然后在出现的窗口中单击“启用”。
在左下角,您将看到执行的所有渲染操作。 请注意,尽管敌人和炮弹有许多单独的挑战(如果您愿意,也可以为其实施实例化)。 如果滚动到底部,您将看到“绘制网格(显示)Healthbar”项。
此单个调用将渲染所有条。 如果单击此操作,然后单击其上的操作,则会看到所有条带消失,因为它们是在一次调用中绘制的。 如果位于帧调试器中,则从材料中取消选中“启用GPU实例化”复选框,您会看到一行变成了几行,并将标志再次设置为一行。
如何扩展这个系统
如前所述,由于这些健康条是真实的对象,因此没有什么可以阻止您将简单的2D条变成更复杂的东西。 它们可以是弧形下降的敌人下方的半圆形,也可以是头顶上方旋转的菱形。 使用相同的方法,您仍然可以在一个调用中全部呈现它们。