El enfoque de clip shader de Outbound: Descarte preciso del follaje para entornos en tiempo real

¿Cómo evitar que la hierba se deslice por el suelo en tu juego de van life de mundo abierto? En esta publicación invitada, Tony Fial y Michiel Procé, programadores de Square Glade Games, ofrecen una mirada detallada de cómo abordaron y, en última instancia, resolvieron este problema en Outbound con una solución personalizada para clips de shader.
Somos Tony Fial y Michiel Procé, parte del equipo de Square Glade Games. Actualmente, estamos trabajando en el título más reciente del estudio, Outbound, que es un juego de exploración de mundo abierto ambientado en un futuro próximo utópico. El jugador comienza con una camioneta camper vacía y puede convertirla en la casa móvil de sus sueños, construyéndola exactamente como quiera.
El vehículo es un gran punto focal para el juego, al igual que su conducción a través de la naturaleza. El mundo en Outbound está hecho a mano e incluye mucho follaje y pasto, que es delicioso, alto y abundante. Aunque pudimos crear un mundo hermoso con estos assets, combinarlos con un vehículo que transita por este tipo de entornos causó algunos problemas visuales.
El problema
El jugador es capaz de conducir su camioneta camper básicamente a través de cualquier área abierta. Los arbustos y la hierba no bloquean esto. Dado que la camioneta estaba bastante cerca del suelo, a menudo la hierba del terreno se recortaba por la parte inferior o los lados del vehículo.
También hay lugares donde la camioneta puede llegar al follaje más alto, como flores y arbustos. Para mostrar el problema en cuestión, la captura de pantalla a continuación muestra un caso en el que tanto la hierba como los arbustos están fuertemente recortados en el vehículo. Esto no solo es visualmente poco atractivo, sino que también causa varios problemas de jugabilidad, como el bloqueo visual de interacciones o información importante.

Para resumir nuestro problema principal, hay diferentes tipos de follaje y pasto que atraviesan la caravana, lo que no se desea desde una perspectiva visual y de juego.
Ahora, vamos a resolverlo, ¿de acuerdo?
Lluvia de ideas sobre posibles soluciones
En Square Glade Games, antes de comenzar a trabajar activamente en una solución, nos resulta muy útil compilar una lista de los requisitos óptimos.
En este caso particular, necesitábamos nuestra solución para lo siguiente:
• Sé eficaz. Hay mucho césped en Outbound, por lo que una solución no optimizada puede ser muy costosa en las áreas con mucho más césped y plantas.
• Mantener el estilo original intacto. Actualmente, nos encontramos en un estado de desarrollo en el que no podemos alterar el aspecto de los elementos principales en Outbound, por lo que lo ideal es que la solución utilice la mayor cantidad posible del follaje original.
• Ser compatible entre plataformas.Dado que el título está planeado para salir en múltiples plataformas, la solución debe funcionar en Windows, Nintendo SwitchTM, Xbox y PlayStation®.
• Sé intuitivo al usarlo.La solución debería ser idealmente intuitiva tanto para los diseñadores como para los programadores del equipo.
• Aplicar en múltiples formas. Lo ideal sería recortar el follaje en una forma exacta del vehículo, posiblemente con varias formas.
Ahora, piensa en soluciones que podrían cumplir con esta lista de requisitos. Primero pensamos en un elemento que todas las hojas de hierba comparten... el shader.
Casi toda la flora de Outbound se coloca en el terreno de Unity utilizando las herramientas de terreno. Una porción considerable de esta cantidad es la hierba, que utiliza el shader Grass predeterminado. Este shader utiliza la GPU para colocar y proyectar los planos de césped de una manera muy eficiente. Otros elementos, como los arbustos más grandes que se muestran en la captura de pantalla anterior, se colocan como mallas de detalles, utilizando su propio material y shader asignados.
Esto presentaba otro detalle importante, a saber, que la solución propuesta debería poder funcionar en varios shaders completamente diferentes, de la misma manera y al mismo tiempo.
Soluciones propuestas
Todas las soluciones propuestas a continuación comparten una "entrada" importante en común: La posición de la caravana o, para ser más precisos, el área donde se debe cortar el follaje.
Observando los requisitos establecidos, queríamos que nuestra solución fuera intuitiva para el resto del equipo de Square Glade. En nuestra experiencia, las herramientas del Editor solo las utilizarán los miembros del equipo cuando sean intuitivas y fáciles de elegir. Con esto en mente, decidimos construir un cubo 3D visual que pudiera ampliarse, girarse y manipularse para sujetar lo suficiente la carrocería del vehículo y ajustarla para que estuviera en lo correcto. Cualquier follaje dentro del cubo se cortaría, mientras que todo lo que estuviera fuera de él se vería igual.
Stencil shader
Lo primero que probamos fue usar un elemento shader llamado 'stencil buffer'.
Esta parte de la programación de shaders es muy fascinante, pero también es un poco difícil de entender. Todo se reduce a que, para nuestro propósito, le decimos al "elemento de corte", en este caso, nuestro cubo, que escriba información en el búfer de plantilla de un frame renderizado. Eso significa que, en cualquier parte de la pantalla donde esté el cubo, escribirá un valor de 1. El objeto 'recortado' (en nuestro caso, la hierba) puede leer de ese búfer y descartar cualquier píxel que tenga un valor establecido en exactamente 1.
En el código del shader, se vería algo como esto:
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}El objeto clipping escribe un valor de 1 en el buffer, indicado por la línea Ref 1, y lo hace siempre. Si un valor de plantilla renderizado posterior coincide con el de la plantilla, o pasa la comparación, la reemplazará por la información de este shader.El césped tiene una implementación similar: También buscará el valor de Ref 1 y solo pasará la verificación si la comparación es igual a ese valor de referencia.
Esta implementación sirvió para cortar la hierba y fue muy eficiente, ya que funciona en los píxeles del frame renderizado y no se ve afectada por la cantidad de hierba en una escena dada. Sin embargo, esta solución presentaba un defecto fatal. Debido a que esta implementación no tiene ningún sentido de profundidad, también cortará cualquier cosa detrás del cubo. Prácticamente, esto significaba que cuando el jugador estaba sentado dentro del vehículo, mientras miraba en primera persona, toda la pantalla se marcaba como "recortada", por lo que el jugador no veía césped en ningún lado. Debido a esto, tuvimos que probar algunos otros métodos que también funcionaban cuando la cámara del jugador estaba dentro del objeto 'clipper'.
Recorte manual
Una solución que analizamos brevemente fue eliminar manualmente el césped en la posición de nuestro vehículo, alejándolo del terreno en sí. Ya lo habíamos hecho para otras partes del juego, utilizando la función 'TerrainData.SetDetailLayer' que Unity proporciona en el terreno. Esto establecería el color de la escala de grises de la capa de detalles en 0 en los píxeles justo debajo de la camioneta, indicando al terreno que elimine cualquier malla de detalles o césped en ese conjunto de ubicaciones.
Debido a que los mapas de Outbound son bastante grandes, la resolución de la capa de detalles está en el lado inferior, lo que la hace un poco irregular. Esto está perfectamente bien para la colocación normal de detalles de césped y otras mallas, pero al cortar manualmente las partes, la menor resolución dará como resultado una forma que no sería lo suficientemente cercana al tamaño de la camioneta, ya sea demasiado pequeña o demasiado grande.
Esta solución también generaría detalles parpadeantes hacia adentro y hacia afuera cuando el vehículo estaba justo al borde de los dos píxeles de detalle del terreno. Por estas razones, no seguimos implementando esta solución. ¡Nuestro viaje continúa!
Clip shader
Con el stencil buffer shader, pensamos que ya casi habíamos llegado, ya que habíamos vuelto los píxeles invisibles donde era necesario con la precisión de la carrocería exterior de la camioneta. Si tan solo hubiera otra forma de hacerlo, mientras se usa la profundidad del cubo, saber que la solución básicamente solo debería cortar los píxeles dentro de su cuadro delimitador.
¡Resulta que hay un método que hace justamente eso! Los shaders HLSL proporcionan la función humble clip(), que simplemente descarta el píxel si el valor especificado es menor que 0. Es posible que hayas visto esto antes en algún shader aleatorio, donde a menudo se usa para el recorte alfa.
Por ejemplo, la hierba de Outbound se ve como mechones de hierba en sí y no como cuadriláteros cuadrados con una imagen de hierba, porque la quitamos cuando el canal alfa de nuestra textura de hierba es negro.
Cuando hicimos un primer prototipo/comprobación rápida de esta solución, teníamos grandes esperanzas de que esta implementación funcionara, ya que podíamos hacer que los píxeles fueran invisibles por encima de cierta posición del mundo. En el pseudocódigo, la función tenía el siguiente aspecto:
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );La solución: Un shader de clips
En este punto, teníamos un ejemplo simple que mostraba una solución prometedora, a saber, usar un shader de clips. El siguiente paso fue crear una función para proporcionar al shader la información necesaria para clipar exactamente donde queríamos.Esto incluyó dos partes:
• La parte en la que calculamos esencialmente la “forma”, incluidas sus dimensiones y transformaciones, y proporcionamos estos datos al shader.
• La parte en la que el shader utiliza estos datos, verifica si un punto dado está dentro de la forma y descarta sus píxeles donde sea necesario.
Para el primer paso de nuestra solución, creamos un script 'GrassClipperShape', un MonoBehaviour que podíamos adjuntar a un objeto en la escena, el cual dictaría dónde estaría un área de recorte. Un ejemplo de esto se muestra a continuación, donde se muestra el área de la forma con OnDrawGizmos en la vista Editor.

Dado que lo ideal sería utilizar varios de estos cortadores, necesitamos un script general (es decir, un "administrador") para manejar todos los cortadores disponibles. Cada clipper proporcionaría las siguientes propiedades a este script global, llamado "GrassClipperManager":
• Shape:el tipo de forma. Queríamos que esta versión funcionara tanto con cubos como con esferas, por lo que se trata de un enum simple configurado como cubo o esfera.
• Vector3: tamaño del objeto en la escena
• Matrix4x4:el objeto girado calculado en el espacio del mundo
GrassClipperManager, del cual solo hay uno en la escena, obtendrá esta información de los clippers de cada frame y la enviará al shader de la siguiente manera:
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);Las líneas anteriores establecerán valores globales de shader. Para explicarte brevemente, esto significa que puedes usar valores de shader con estos nombres y tipos exactos, y utilizarlos en cualquier shader.
Debido a que queremos que nuestro recorte se realice en varios shaders diferentes, creamos un script HLSL separado para que se incluya en el shader que necesite nuestro clipper. Este script expone una función personalizada llamada "ApplyClipVolumeSDF". Utiliza la información de los valores globales del shader ahora rellenos y calcula si un píxel está dentro de alguno de los límites.
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}Como puedes ver arriba, si se supone que el píxel se debe descartar, llamará a la función 'clip(-1)', devolviendo un píxel descartado. De lo contrario, avanzará de manera normal por el resto del shader.
Implementación de clip shaders
Con la función de recorte ahora creada y provista de los datos necesarios, era hora de implementarla en nuestros shaders.
Primero, hablemos de cómo hacerlo para las mallas de detalles, donde podríamos crear una copia del original y editarlo. En la parte superior del shader, debemos hacer referencia al script personalizado de la siguiente manera:
#include "Assets/Shaders/ClipVolume.hlsl"Y luego, cuando queramos usar realmente la función, simplemente la llamamos así dentro de la parte del fragmento del shader:
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);En nuestro caso, solo dos shaders tenían que incluirlo, a saber, el shader predeterminado que utiliza Unity Grass y un shader personalizado para el resto del follaje renderizado como mallas de detalle. Ahora que tenemos esto, se puede implementar fácilmente en cualquier otro shader si es necesario.
Pero nuestro viaje no había terminado: se presentó un último obstáculo. ¿Cómo podríamos editar y retener los cambios realizados en el shader de césped predeterminado? Unity utiliza algunos shaders integrados específicos para el renderizado de hierba, en nuestro caso, 'WavingGrassBillboard.shader'. Este shader se aplica automáticamente a toda la hierba, sin opción de proporcionar variantes personalizadas. Esto fue crucial para que nuestra solución funcionara, ya que necesitaba conectarse a ese shader para poder llamar a la función personalizada "ApplyClip" y descartar los píxeles no deseados.
Después de probar algunas soluciones, el miembro del equipo Michiel Procé descubrió una manera de editar y mantener los cambios en el shader de césped predeterminado de manera confiable. Al ejecutar el siguiente código durante las compilaciones y en el Editor, nuestro shader personalizado reemplaza al shader URP predeterminado:
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;
}
}Ten en cuenta que esto solo reemplaza al shader WavingGrassBillboard, pero implementarlo para otros shaders sería similar.
Pensamientos finales
Nuestra solución final de usar un clip shader funciona bien para nuestros propósitos y estamos muy contentos con los resultados que nos brinda. Mira la captura de pantalla a continuación para ver una visualización de la solución, en la que un cubo rectangular corta la hierba que está dentro. Ten en cuenta que el cuadro se ve desde arriba y se coloca a través del terreno para obtener una vista óptima de lo que se corta.

¡Mirando atrás nuestra lista de requisitos para nuestra solución de corte de césped, nos alegró ver que se adhiere a todos ellos!
• La solución es eficaz, ya que las funciones utilizadas para calcular el recorte son muy económicas. Y, debido a que descarta completamente el píxel, nuestra implementación no hará ningún procesamiento innecesario.
• Mantiene el estilo original intacto porque está construido sobre los shaders que ya estábamos usando.
• La implementación es independiente de la plataforma, porque la función clip() en sí lo es.
• La solución es intuitiva para el resto del equipo. Los diseñadores pueden crear y usar múltiples formas, e incluso hacer que se crucen entre sí.
Creemos que características como las anteriores son extremadamente importantes, no solo por motivos de creatividad, sino también para evitar que aparezcan errores extraños más adelante.
Proyecto de muestra
Para compartir esta solución con la comunidad, creamos un proyecto de muestra utilizando estas técnicas detalladas anteriormente, para que puedas probarlo tú mismo. Compruébalo aquí en GitHub.
Gracias por leer nuestra publicación de invitado. Espero que esto ayude a muchos otros desarrolladores que enfrentan el mismo problema que nosotros.
Outbound se encuentra actualmente en versión beta cerrada. Sigue el juego en Steam para ver las actualizaciones. Explora más juegos Made with Unity en nuestra página de Steam Curator y consulta más historias de desarrolladores Unity en nuestro centro de recursos.
Nintendo SwitchTM es una marca comercial de Nintendo.
