Recientemente, necesitaba resolver un problema que es bastante común en muchos juegos con una vista superior: representar en la pantalla un montón de barras de salud enemigas. Algo como esto:
Obviamente, quería hacer esto de la manera más eficiente posible, preferiblemente en una llamada de sorteo. Como de costumbre, antes de comenzar a trabajar, hice una pequeña investigación en línea sobre las decisiones de otras personas, y los resultados fueron muy diferentes.
No avergonzaré a nadie por el código, pero basta con decir que algunas de las soluciones no fueron del todo brillantes, por ejemplo, alguien agregó un objeto Canvas a cada enemigo (lo cual es muy ineficiente).
El método al que llegué como resultado es ligeramente diferente de todo lo que he visto con otros, y no utiliza ninguna clase de IU (incluido Canvas), así que decidí documentarlo para el público. Y para aquellos que quieran aprender el código fuente,
lo publiqué en Github .
¿Por qué no usar Canvas?
Un lienzo para cada enemigo es obviamente una mala decisión, pero podría usar un lienzo común para todos los enemigos; un solo Canvas también llevaría a procesar el procesamiento por lotes de llamadas.
Sin embargo, no me gusta la cantidad de trabajo realizado en cada marco relacionado con este enfoque. Si usa Canvas, en cada cuadro debe realizar las siguientes operaciones:
- Determine cuáles de los enemigos están en la pantalla y seleccione cada uno de ellos en la tira de la interfaz de usuario del grupo.
- Proyecte la posición del enemigo en la cámara para colocar la tira.
- Cambie el tamaño de la parte de "relleno" de la tira, probablemente como Imagen.
- Lo más probable es que cambie el tamaño de las tiras de acuerdo con el tipo de enemigos; por ejemplo, los enemigos grandes deben tener tiras grandes para que no se vea tonto.
De todos modos, todo esto contaminaría los búferes de geometría de Canvas y conduciría a una reconstrucción de todos los datos de vértices en el procesador. No quería que todo esto se hiciera por un elemento tan simple.
Brevemente sobre mi decisión
Una breve descripción de mi proceso de trabajo:
- Adjuntamos objetos de tiras de energía a los enemigos en 3D.
- Esto le permite organizar y recortar automáticamente las tiras.
- La posición / tamaño de la tira se puede ajustar según el tipo de enemigo.
- Dirigiremos las rayas a la cámara en el código usando transform, que todavía está allí.
- El sombreador garantiza que siempre se renderice por encima de todo.
- Usamos Instancing para representar todas las tiras en una sola llamada de sorteo.
- Utilizamos simples coordenadas UV de procedimiento para mostrar el nivel de plenitud de la tira.
Ahora veamos la solución con más detalle.
¿Qué es la instancia?
Al trabajar con gráficos, la técnica estándar se ha utilizado durante mucho tiempo: varios objetos se combinan entre sí para que tengan datos y materiales de vértice comunes y se puedan representar en una sola llamada de dibujo. Esto es exactamente lo que necesitamos, porque cada llamada de sorteo es una carga adicional en la CPU y la GPU. En lugar de hacer una sola llamada de dibujo para cada objeto, los procesamos todos al mismo tiempo y usamos un sombreador para agregar variabilidad a cada copia.
Puede hacer esto manualmente duplicando los datos del vértice de malla X veces en un búfer, donde X es el número máximo de copias que se pueden representar, y luego usando la matriz de parámetros de sombreado para convertir / colorear / variar cada copia. Cada copia debe almacenar el conocimiento sobre qué instancia numerada es, para usar este valor como índice de la matriz. Entonces podemos usar una llamada de renderización indexada que ordena "renderizar solo a N", donde N es el número de instancias que
realmente se necesita en el marco actual, menos que el número máximo de X.
La mayoría de las API modernas ya tienen código para esto, por lo que no es necesario que lo haga manualmente. Esta operación se llama "Instancing"; de hecho, automatiza el proceso descrito anteriormente con restricciones predefinidas.
El motor de Unity también admite instancias , tiene su propia API y un conjunto de macros de sombreado que ayudan en su implementación. Utiliza ciertos supuestos, por ejemplo, que cada instancia requiere una transformación 3D completa. Estrictamente hablando, para las tiras 2D no es necesario por completo, podemos hacerlo con simplificaciones, pero como lo son, las usaremos. Esto simplificará nuestro sombreador y también proporcionará la capacidad de usar indicadores 3D, por ejemplo, círculos o arcos.
Clase dañable
Nuestros enemigos tendrán un componente llamado
Damageable
, que les dará salud y les permitirá sufrir daños por colisiones. En nuestro ejemplo, es bastante simple:
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) {
Objeto HealthBar: posición / giro
El objeto de la barra de salud es muy simple: de hecho, es solo un Quad conectado al enemigo.

Usamos la
escala de este objeto para hacer que la tira sea larga y delgada, y colocarla directamente sobre el enemigo. No se preocupe por su rotación, lo arreglaremos utilizando el código adjunto al objeto en
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); } }
Este código siempre dirige el quad hacia la cámara. Podemos realizar cambios de tamaño y rotación en el sombreador, pero los implemento aquí por dos razones.
En primer lugar, la creación de instancias de Unity siempre usa la transformación completa de cada objeto, y dado que transferimos todos los datos de todos modos, puede usarla. En segundo lugar, establecer la escala / rotación aquí asegura que el paralelogramo delimitador para recortar la tira siempre será verdadero. Si hiciéramos la tarea de tamaño y rotación la responsabilidad del sombreador, entonces Unity podría truncar las tiras que deberían ser visibles cuando están cerca de los bordes de la pantalla, porque el tamaño y la rotación de su paralelogramo delimitador no corresponderán a lo que vamos a renderizar. Por supuesto, podríamos implementar nuestro propio método de truncamiento, pero generalmente es mejor usar lo que tenemos si es posible (el código de Unity es nativo y tiene acceso a más datos espaciales que nosotros).
Explicaré cómo se procesa la tira después de mirar el sombreador.
Shader HealthBar
En esta versión, crearemos una simple tira clásica rojo-verde.
Utilizo una textura 2x1 con un píxel verde a la izquierda y uno rojo a la derecha. Naturalmente, desactivé mipmapping, filtrado y compresión, y configuré el parámetro del modo de direccionamiento en Clamp, lo que significa que los píxeles en nuestra tira siempre serán perfectamente verdes o rojos, y no se extenderán por los bordes. Esto nos permitirá cambiar las coordenadas de textura en el sombreador para desplazar la línea que divide los píxeles rojo y verde hacia abajo y hacia arriba de la tira.
(Dado que solo hay dos colores aquí, podría usar la función de paso en el sombreador para volver al punto de uno u otro. Sin embargo, este método es conveniente porque puede usar una textura más compleja si lo desea, y esto funcionará de manera similar mientras la transición esté en textura media)Primero, declararemos las propiedades que necesitamos:
Shader "UI/HealthBar" { Properties { _MainTex ("Texture", 2D) = "white" {} _Fill ("Fill", float) = 0 }
_MainTex
es una textura rojo-verde, y
_Fill
es un valor de 0 a 1, donde 1 es salud completa.
A continuación, debemos ordenar que la tira se renderice en la cola de superposición, lo que significa ignorar toda la profundidad de la escena y renderizar encima de todo:
SubShader { Tags { "Queue"="Overlay" } Pass { ZTest Off
La siguiente parte es el código del sombreador en sí. Estamos escribiendo un sombreador sin iluminación (apagado), por lo que no debemos preocuparnos por la integración con varios sombreadores de superficie Unity, este es un par simple de sombreadores de vértices / fragmentos. Primero, escribe bootstrap:
CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #include "UnityCG.cginc"
En su mayor parte, este es un programa de arranque estándar, con la excepción de
#pragma multi_compile_instancing
, que le dice al compilador de Unity qué debe compilarse para la Instancia.
La estructura del vértice debe incluir datos de instancia, por lo que haremos lo siguiente:
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
También debemos especificar qué será exactamente en los datos de las instancias, además de los procesos de Unity (transformación) para nosotros:
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _Fill) UNITY_INSTANCING_BUFFER_END(Props)
Por lo tanto, informamos que Unity debe crear un búfer llamado "Props" para almacenar los datos de cada instancia, y dentro de él utilizaremos un flotante por instancia para una propiedad llamada
_Fill
.
Puede usar varios tampones; vale la pena hacerlo si tiene varias propiedades actualizadas a diferentes frecuencias; dividiéndolos, puede, por ejemplo, no cambiar un búfer cuando se cambia otro, lo cual es más eficiente. Pero no necesitamos esto.
Nuestro sombreador de vértices hace casi completamente el trabajo estándar, porque el tamaño, la posición y la rotación ya se transfieren para transformar. Esto se implementa utilizando
UnityObjectToClipPos
, que utiliza automáticamente la transformación de cada instancia. Uno podría imaginar que, sin instancias, esto normalmente sería un uso simple de una propiedad de matriz única. pero cuando se usa la creación de instancias dentro del motor, parece una matriz de matrices, y Unity selecciona independientemente una matriz adecuada para esta instancia.
Además, debe cambiar UV para cambiar la ubicación del punto de transición de rojo a verde de acuerdo con la propiedad
_Fill
. Aquí está el fragmento de código relevante:
UNITY_SETUP_INSTANCE_ID(v); float fill = UNITY_ACCESS_INSTANCED_PROP(Props, _Fill);
UNITY_SETUP_INSTANCE_ID
y
UNITY_ACCESS_INSTANCED_PROP
hacen toda la magia accediendo a la versión deseada de la propiedad
_Fill
desde el búfer constante para esta instancia.
Sabemos que en el estado normal, las coordenadas UV del cuadrante cubren todo el intervalo de textura, y que la línea divisoria de la tira está en el medio de la textura horizontalmente. Por lo tanto, pequeños cálculos matemáticos desplazan horizontalmente la tira hacia la izquierda o hacia la derecha, y el valor de sujeción de la textura garantiza el relleno de la parte restante.
El sombreador de fragmentos no podría ser más simple porque todo el trabajo ya está hecho:
return tex2D(_MainTex, i.uv);
El código completo del sombreador de comentarios está disponible en
el repositorio de GitHub .
Material de barra de salud
Entonces todo es simple: solo tenemos que asignar a nuestra tira el material que utiliza este sombreador. Casi no se necesita hacer nada más, solo seleccione el sombreador deseado en la parte superior, asigne una textura rojo-verde y, lo más importante,
marque la casilla "Habilitar la instalación de GPU" .

Actualización de propiedades de relleno de HealthBar
Entonces, tenemos el objeto de la barra de salud, el sombreador y el material que se representará, ahora necesitamos establecer la propiedad
_Fill
para cada instancia. Hacemos esto dentro de
HealthBar.cs
siguiente manera:
private void UpdateParams() { meshRenderer.GetPropertyBlock(matBlock); matBlock.SetFloat("_Fill", damageable.CurrentHealth / (float)damageable.MaxHealth); meshRenderer.SetPropertyBlock(matBlock); }
CurrentHealth
el
CurrentHealth
clase
CurrentHealth
en un valor de 0 a 1, dividiéndolo por
MaxHealth
. Luego lo pasamos a la propiedad
_Fill
usando
MaterialPropertyBlock
.
Si no ha utilizado
MaterialPropertyBlock
para transferir datos a sombreadores, incluso sin crear instancias, debe estudiarlo. No está bien explicado en la documentación de Unity, pero es la forma más eficiente de transferir datos de cada objeto a los sombreadores.
En nuestro caso, cuando se usa la creación de instancias, los valores para todas las barras de salud se empaquetan en un búfer constante para que puedan transferirse todos juntos y dibujarse a la vez.
Aquí no hay casi nada, excepto una placa repetitiva para establecer variables, y el código es bastante aburrido; vea
el repositorio de GitHub para más detalles.
Demo
El
repositorio de GitHub tiene una demostración de prueba en la que un montón de cubos azules malvados son destruidos por heroicas esferas rojas (¡hurra!), Tomando el daño que muestran las rayas descritas en el artículo. Demo escrita en Unity 2018.3.6f1.
El efecto del uso de instancias se puede observar de dos maneras:
Panel de estadísticas
Después de hacer clic en Reproducir, haz clic en el botón de Estadísticas arriba del panel de Juego. Aquí puede ver cuántas llamadas de sorteo se guardan gracias a la instancia:

Después de iniciar el juego, puede hacer clic en el material HealthBar y
desmarcar la casilla de verificación "Habilitar la instalación de GPU", después de lo cual el número de llamadas guardadas se reducirá a cero.
Depurador de trama
Después de iniciar el juego, ve a Ventana> Análisis> Depurador de cuadros, y luego haz clic en "Habilitar" en la ventana que aparece.
En la parte inferior izquierda verá todas las operaciones de renderizado realizadas. Tenga en cuenta que si bien hay muchos desafíos separados para enemigos y proyectiles (si lo desea, también puede implementar instancias para ellos). Si te desplazas hacia la parte inferior, verás el elemento "Barra de salud Dibujar malla (instanciada)".
Esta llamada individual representa todas las tiras. Si hace clic en esta operación y luego en la operación, verá que todas las tiras desaparecen, ya que se dibujan en una sola llamada. Si está en el Depurador de tramas, desmarca la casilla de verificación Habilitar la instalación de GPU del material, verá que una línea se convirtió en varias, y después de configurar la bandera nuevamente en una.
Cómo expandir este sistema
Como dije antes, dado que estas barras de salud son objetos reales, no hay nada que te impida convertir simples barras 2D en algo más complejo. Pueden ser semicírculos bajo enemigos que disminuyen en un arco, o rombos giratorios sobre sus cabezas. Con el mismo enfoque, aún puede procesarlos todos en una sola llamada.