
Nuestra experiencia navideña Ho-Ho Hunt en realidad comenzó como una idea muy diferente: una aplicación de fitness personal para los 24 días previos a Navidad. El plan era que palearas pequeñas áreas de nieve al principio y luego abordaras espacios más grandes a medida que pasaban los días. Concepto divertido – pero no una gran opción para eventos únicos y campañas B2B.
Así que pivotamos, mantuvimos el movimiento satisfactorio, y lo movimos a una escena de patio trasero compacta donde un solo campo de juego despejable funciona mucho mejor para sesiones cortas - un desafío perfecto y compacto de paleo de nieve VR para equipos, eventos y activaciones de marca.
Ho-Ho Hunt en pocas palabras
En Ho-Ho Hunt, te pones en las botas de Papá Noel en una noche donde las cosas salen mal: aterriza de emergencia en un techo en un vecindario tranquilo, pierde el control del trineo y ve regalos caer en la nieve profunda de un pequeño patio trasero.
Armado con una pala que afortunadamente yace cerca, tienes una misión: despejar el patio trasero y desenterrar cada regalo antes de que el temporizador llegue a cero.
Cada golpe visiblemente talla la nieve y revela más regalos; el tempo aumenta, estás compitiendo contra el reloj y la tabla de clasificación, y compañeros o visitantes a tu alrededor te animan. Simple de entender, sorprendentemente intenso y muy elevador del ritmo cardíaco.
Detrás de Escena:
construyendo una mecánica de paleo de nieve
El proyecto demo para este artículo fue creado con Unity 6 (6000.0.62f1 específicamente). No estamos al tanto de cambios significativos en Shader Graph que puedan afectar la funcionalidad, sin embargo, así que deberías estar bien con una versión más antigua o más nueva también.
La malla del campo de nieve
Para construir un campo de nieve "paleable", necesitamos comprometernos entre complejidad, rendimiento y gameplay. No queremos construir iglús o muñecos de nieve, sino revelar lo que está cubierto bajo la nieve. Por lo tanto, decidimos ir por una malla de plano granular con disposición de vértices dirigida por un height map, todo renderizado por un shader de nieve dedicado. Cada vez que el height map cambia por la excavación de la hoja de la pala, llamamos a un compute shader para modificar el height map en consecuencia. Esto no solo nos permite eliminar nieve donde se palea, también nos permite depositar nieve en otro lugar o crear huellas y otras abolladuras fácilmente.
Para nuestra experiencia, hicimos uso de las capacidades de generación de malla en tiempo de ejecución de Unity para ser más flexibles y experimentar con tamaño y granularidad. Para seguir, puedes usar Blender para crear la malla. Agrega un objeto plano, cambia al modo de edición, haz clic derecho en el plano y selecciona Subdivide. Luego ingresa el número de subdivisiones deseadas por borde en el panel inferior izquierdo. En este ejemplo usaremos 63, dándonos un plano subdividido en 64x64 caras más pequeñas.

Luego, exporta esta malla como un archivo FBX (Archivo -> Exportar -> FBX), limita la exportación solo a la malla seleccionada (no necesitamos la cámara y luz por defecto), y marca la casilla "Apply Transform" para normalizar la escala. Unity sí permite importar archivos .blend directamente, pero pasar por FBX hace las cosas un poco más fáciles debido a los diferentes sistemas de coordenadas en Blender y Unity.
Color simple de nieve
Usamos Shader Graph para construir un shader para la nieve. Haz clic derecho en tu proyecto, selecciona Crear -> Shader Graph -> URP -> Lit Shader Graph. Esto crea un shader vacío con entradas para la etapa de vértice y fragmento.

Antes de comenzar a soportar alturas cambiantes, cuidemos primero el color básico de la nieve. Una capa plana de nieve fresca suele ser blanca, con un poco de ruido sutil. Agregaremos dos parámetros al gráfico: un Color BaseColor (blanco por defecto), una Texture2D NoiseTex para el ruido, y un Float NoiseScale para escalar el ruido según sea necesario (valor por defecto 1). Luego, tomaremos la posición del mundo de un fragmento, escalaremos los valores x y z según la escala del ruido, muestrearemos nuestra textura de ruido, re-mapearemos el valor, y luego modificaremos el color base usando este valor ligeramente. Para el re-mapeo, un rango de 0.8 a 1.1 ha demostrado funcionar bien. Alimentamos esta salida en el Color Base del fragmento.

Ahora guarda este shader, haz clic derecho en él en la vista del proyecto, y selecciona Crear -> Material. Esto creará automáticamente un material usando este shader. Finalmente, arrastra el shader a la malla de nieve en la escena para asignar el material.

Aún no hemos establecido una textura de ruido, por lo que el muestreo siempre produce 0 y el re-mapeo lleva a una nieve de color constante (0.8, 0.8, 0.8).
Puedes usar un generador en línea que soporta generar texturas de ruido perlin con tiling, almacena la imagen resultante en el proyecto, y asígnala al parámetro NoiseTex del material, lo que da un resultado como el siguiente:

(La diferencia podría ser difícil de ver debido a la compresión y configuración del monitor.)
Heightmap para desplazamiento de vértices
Ahora construyamos el desplazamiento de vértices. Para alimentar los valores de altura, agregaremos otro parámetro Texture2D HeightTex al shader. También podríamos introducir un parámetro de escala para jugar con el efecto, pero en este caso, lo omitiremos para evitar confusión más adelante - es más fácil asumir que el valor que viene del height map es la diferencia de altura renderizada real.
Para aplicar el valor de altura, muestrearemos el height map y agregaremos el valor al valor y de la posición del vértice antes de alimentar el resultado en el parámetro de posición del vértice.

Aún no tenemos el heightmap real - vamos a crear esto como una render texture para actualizarlo en tiempo de ejecución - pero para propósitos de prueba, podemos alimentar temporalmente la textura de ruido perlin. La iluminación no es bonita ya que aún no estamos calculando normales, pero con wireframe habilitado, el resultado se ve como se esperaba.

Generando abolladuras en tiempo de ejecución
Crearemos una render texture para almacenar y cambiar el heightmap en tiempo de ejecución. En tu vista del proyecto, haz clic en Crear -> Rendering -> Render Texture, llámala Heightmap y marca la casilla "Random Write". Luego, arrastra esta render texture al parámetro HeightTex del material de nieve.
Para modificar la render texture, crearemos un compute shader con un kernel que realiza la modificación. En tu vista del proyecto, haz clic en Crear -> Shader -> Compute Shader y llámalo ShovelBrush, luego implementa el kernel así:
#pragma kernel Shovel
// El heightmap a modificar
RWTexture2D<float> Heightmap;
// Parámetros de la pala, establecidos antes de llamar al kernel
uint Resolution;
float2 CenterUV;
float Radius;
float Strength;
[numthreads(8, 8, 1)]
void Shovel(uint3 id : SV_DispatchThreadID) {
// Salir para llamadas fuera de la resolución conocida
if (id.x >= Resolution || id.y >= Resolution) {
return;
}
// Ignorar texels fuera del radio de la pala
float2 uv = (float2(id.xy) + 0.5) / Resolution;
float2 d = uv - CenterUV;
float dist = length(d);
if (dist > Radius) {
return;
}
// Calcular falloff dentro del radio
float t = saturate(dist / Radius);
float falloff = smoothstep(1.0, 0.0, t);
// Actualizar heightmap
int2 coord = int2(id.xy);
float h = Heightmap[coord];
h += Strength * falloff;
Heightmap[coord] = h;
}
Contrario al graphics shader anterior, un compute shader es un programa GPU que ejecutas bajo demanda para hacer trabajo de propósito general (física, procesamiento de imágenes, pintar en texturas, etc.). No dibuja nada por sí mismo. Los graphics shaders no escriben en texturas, así que necesitamos el compute shader para hacer eso por nosotros.
Estamos lanzando 8x8 = 64 hilos en paralelo en una textura, y cada llamada necesita verificar si realmente hay algo que hacer para sus parámetros específicos. El tamaño se mapea bien a texturas 2D, funciona bien en GPUs móviles y mantiene la presión de registros modesta. Ha demostrado ser un valor por defecto seguro en nuestro dispositivo objetivo, Meta Quest.
Lo que falta ahora es una pieza que une todo esto. Construiremos un script simple que genera aleatoriamente abolladuras o pilas en nuestra malla, solo para mostrar el efecto. Crea un nuevo script en tu proyecto (vista del proyecto: Crear -> script MonoBehaviour), llámalo RandomShovel (por falta de un mejor nombre), e implementa así:
using System.Collections;
using UnityEngine;
public class RandomShovel : MonoBehaviour {
[SerializeField] private ComputeShader shovelShader;
[SerializeField] private RenderTexture heightmap;
[SerializeField] private Material showMaterial;
private int _shovelKernel;
private Coroutine _shovelCoroutine;
void Start() {
_shovelKernel = shovelShader.FindKernel("Shovel");
shovelShader.SetTexture(_shovelKernel, "Heightmap", heightmap);
shovelShader.SetInt("Resolution", heightmap.width);
// Limpiar heightmap con "altura media"
var prev = RenderTexture.active;
RenderTexture.active = heightmap;
GL.Clear(false, true, new Color(0.5f, 0.5f, 0.5f, 1f));
RenderTexture.active = prev;
}
void OnEnable() {
_shovelCoroutine = StartCoroutine(RandomShovelCoroutine());
}
void OnDisable() {
StopCoroutine(_shovelCoroutine);
}
private IEnumerator RandomShovelCoroutine() {
while (true) {
yield return new WaitForSeconds(0.2f);
// Preparar parámetros para esta ejecución
Vector4 center = new Vector4(Random.Range(0f, 1f), Random.Range(0f, 1f), 0, 0);
float radius = Random.Range(0.05f, 0.3f);
float strength = Random.Range(-0.5f, 0.5f);
shovelShader.SetVector("CenterUV", center);
shovelShader.SetFloat("Radius", radius);
shovelShader.SetFloat("Strength", strength);
// Ejecutar kernel
shovelShader.Dispatch(_shovelKernel, heightmap.width / 8, heightmap.height / 8, 1);
}
}
}
Luego, crea un objeto de juego vacío en la escena con el mismo nombre y arrastra el script sobre él. Luego arrastra el compute shader, la render texture del heightmap y el material de nieve desde tu vista del proyecto a las propiedades correspondientes para unirlo todo.
El script limpia el heightmap usando un valor de color medio, para que inmediatamente podamos ver el efecto de abolladuras y pilas. También empuja los parámetros necesarios al compute shader, activa el kernel cada 200ms, y crea aleatoriamente abolladuras o pilas en el heightmap. Cuando inicias el proyecto ahora, también puedes ver la render texture del heightmap cambiando.

Al hacer clic en el botón de reproducción, se incrustará el reproductor de YouTube y se pueden establecer cookies de terceros y recopilar datos de YouTube.
Iluminación adecuada
Notarás que la nieve aún parece bastante plana y sin acento incluso cuando las alturas cambian. Esto es porque aún no estamos alimentando ninguna información sobre normales al shader.
Nuestro primer enfoque fue usar el nodo "Normal from Height" y alimentar el heightmap:

¡El resultado no fue muy satisfactorio sin embargo! Incluso con un nodo adicional "Normal Strength" con una fuerza de por ejemplo 2, la nieve aparecía bastante plana.

Así que en su lugar, calcularemos las normales manualmente sondeando los texels adyacentes en el heightmap. Antes de sumergirnos en los detalles, aquí está el resultado para comparación:

Para hacer estos sondeos en texels cercanos, el shader necesita saber qué tan "grande" es un texel en realidad en espacio del mundo y en coordenadas normalizadas, es decir, tamaño de la malla / resolución de la malla y 1 / resolución de la malla.
Agregaremos ambos como nuevos parámetros del shader, HeightTexelUV y MetersPerTexel, con los valores por defecto coincidiendo con tu malla. Si usaste los mismos parámetros en Blender que hice arriba, eso sería HeightTexelUV = 1 / 64 = 0.015625 y MetersPerTexel = 10 / 64 = 0.15625. Podemos agregar HeightTexelUV al valor u o v actual y luego consultar el otro texel correspondiente. Miramos cómo cambia la altura hacia la derecha y adelante, luego dividimos por los metros-por-texel para obtener pendientes físicas verdaderas. De esas dos pendientes construimos un vector normal (−slopeX, 1, −slopeY) y lo normalizamos - esto es solo las matemáticas de los planos tangentes de la superficie. Los signos menos vienen de la dirección del producto cruzado, así que la normal se inclina opuesta a la dirección en que la superficie se eleva.

Podríamos mejorar los resultados aún más sondeando en las direcciones opuestas también, pero esto ya parece estar funcionando bien.
Ruido extra
Como estamos trabajando en VR, el jugador va a ver la nieve desde muy cerca. Por eso una capa adicional de ruido sería útil. Sin embargo, esto solo se necesita muy cerca de la cámara y puede desvanecerse más lejos para evitar demasiado ruido en la distancia. Aquí hay un primer plano del efecto que grabamos mientras probábamos una compilación de Ho-Ho Hunt.

Este ruido extra es eventualmente solo otra textura de ruido, pero esta vez como un normal map. El valor muestreado se alimenta en el cálculo de normales que acabamos de construir, pero con una resolución mucho más fina que el ruido de nieve más grande que usamos al principio de este artículo. Si no tienes un buen normal map de ruido disponible, puedes duplicar la textura de ruido actual, cambiarla al tipo Normal map y marcar "Create from grayscale", solo para ver el efecto.
Así que agregaremos otro parámetro Texture2D DetailNormalTex y parámetro de escala DetailScale, muestrearemos el valor (¡recuerda usar tipo "Normal" aquí, es fácil pasar por alto!), y luego controlaremos la fuerza basándonos en la distancia desde la cámara para asegurar que el efecto se desvanezca correctamente:

La salida de este ruido de detalle, y la salida del cálculo de normales del heightmap anterior, ahora se mezclan usando el nodo Normal Blend. La salida se alimenta en la Normal de la etapa del fragmento.
Usé valor DetailScale 200 y usé rugosidad 0.2 con filtrado suave en la configuración de la textura del normal map, dándome los siguientes resultados:

Como puedes ver, el resultado en este ejemplo - que fue construido desde cero para esta publicación - se ve un poco diferente que el ejemplo de arriba del juego real. Esto muestra cómo las texturas de ruido o parámetros de escala afectan la salida, y vale la pena experimentar y ajustar parámetros para el mejor resultado.
Brillo
Intentamos agregar otra capa de brillo para imitar los cristales individuales brillantes que reflejan perfectamente la luz en un día de invierno. Sin embargo, esto habría requerido emisión con iluminación horneada o post-procesamiento de bloom. Lo primero no era posible debido a la naturaleza dinámica del campo de nieve, lo segundo no era deseable debido al rendimiento extra requerido, así que decidimos no agregar brillo.
Notas adicionales
La belleza de este enfoque es que todo sucede en la GPU - las texturas están ahí, los shaders están ahí, y no hay ida y vuelta entre GPU y CPU. Incluso el sondeo del heightmap para actualizaciones del mapa de colisión sucede de forma asíncrona (usando AsyncGPUReadback.Request) y realmente no bloquea nada. ¡Perfecto para dispositivos móviles como el Quest!
Esto es obviamente solo un punto de partida para mecánicas simples de nieve. Mientras trabajábamos en esto, experimentamos con muchas otras cosas y desarrollamos más el paleo y renderizado principales.
Qué más hacer con compute shaders
Nuestro compute shader completo en Ho-Ho Hunt trae un par de funciones extra. Una es un método que se asemeja más a la hoja de una pala, otra es un método de desenfoque que se ejecuta después de ciertas acciones para suavizar bordes duros en la malla generada y crear la ilusión de deriva de nieve. Un tercer método es consultar el heightmap de forma asíncrona para construir una malla de colisión a partir de él, pero solo cuando la nieve realmente cambia.
Depósito de nieve basado en velocidad
También agregamos depósito de nieve y sistema de partículas basado en rociado de nieve dependiendo de la dirección de la pala.

Al hacer clic en el botón de reproducción, se incrustará el reproductor de YouTube y se pueden establecer cookies de terceros y recopilar datos de YouTube.
Mecánica de bola de nieve
Esto fue una pequeña diversión divertida. Usando el pincel del compute shader, tallamos el camino de una esfera en movimiento en la nieve. Como puedes imaginar, sería fácil dejar que la esfera crezca continuamente mientras se mueve a través de la nieve, convirtiéndose lentamente en parte de un muñeco de nieve.

Al hacer clic en el botón de reproducción, se incrustará el reproductor de YouTube y se pueden establecer cookies de terceros y recopilar datos de YouTube.
Optimización
Hemos creado el render target usando parámetros estándar, aunque solo estamos usando un canal - solo estamos consultando el valor R. Puedes ahorrar algo de ancho de banda cambiando el tipo en consecuencia, o agregar información adicional si es necesario. En nuestra implementación, también integramos un mapa de compactación para hacer que la nieve se sienta más dura si está comprimida, pero lo deshabilitamos para facilitar el gameplay.
Al trabajar con ruido, es tentador usar los nodos de función en Shader Graph en lugar de búsquedas de textura. Si bien estos pueden ser suficientes para prototipado inicial, típicamente son más costosos y deberían reemplazarse. Además, cambiar una textura da más control creativo a los diseñadores y generalmente es más fácil que re-cablear un gráfico.
La precisión es otra cosa a considerar. Por defecto, un Shader Graph se crea con precisión "Single". Para ahorrar algo de ancho de banda, lo cambiamos a precisión "Half" y forzamos precisión "Single" solo en un subgráfico específico.
Conclusión
Esto termina nuestra pequeña mirada detrás de escena sobre cómo construimos Ho-Ho Hunt. Si te gustaría licenciar Ho-Ho Hunt o una de nuestras otras experiencias para tu negocio, o si te gustaría discutir tus propias ideas, ¡no dudes en contactarnos!
