Cree un río de lava brillante y fluido utilizando curvas y sombreadores Bézier

La mayoría de las veces, el uso de técnicas gráficas convencionales es el camino correcto. A veces, sin embargo, la experimentación y la creatividad en los niveles fundamentales de un efecto pueden ser beneficiosas para el estilo del juego, lo que hace que destaque más. En este tutorial, te mostraré cómo crear un río de lava 2D animado usando curvas de Bézier, geometría texturizada personalizada y sombreadores de vértices.

Nota: Aunque este tutorial está escrito con AS3 y Flash, debería poder utilizar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos..


Vista previa del resultado final

Haga clic en el signo Más para abrir más opciones: puede ajustar el grosor y la velocidad del río, y arrastrar los puntos de control y los puntos de posición alrededor.

¿No flash? Mira el video de YouTube en su lugar:


Preparar

La implementación de demostración anterior utiliza AS3 y Flash con Starling Framework para el procesamiento acelerado por GPU y la biblioteca Feathers para los elementos de la interfaz de usuario. En nuestra escena inicial, vamos a colocar una imagen de fondo y una imagen de roca de primer plano. Más adelante vamos a agregar un río, insertándolo entre esas dos capas..


Geometría

Los ríos están formados por complejos procesos naturales de interacción entre una masa fluida y el suelo debajo de ella. No sería práctico hacer una simulación físicamente correcta para un juego. Solo queremos obtener la representación visual correcta, y para ello vamos a utilizar un modelo simplificado de río..

Modelar el río como una curva es una de las soluciones que podemos utilizar, lo que nos permite tener un buen control y lograr una apariencia sinuosa. Elegí usar curvas cuadráticas de Bézier para mantener las cosas simples.

Las curvas de Bézier son curvas paramétricas usadas a menudo en gráficos de computadora; En las curvas de Bézier cuadráticas, la curva pasa por dos puntos específicos y su forma está determinada por el tercer punto, que generalmente se denomina punto de control..

Como se muestra arriba, la curva pasa a través de los puntos de posición mientras que el punto de control administra el curso que toma. Por ejemplo, colocar el punto de control directamente entre los puntos de posición define una línea recta, mientras que otros valores para el punto de control "atraen" la curva para que se acerque a ese punto.

Este tipo de curva se define mediante la siguiente fórmula matemática:

[latex] \ Large B (t) = (1 - t) ^ 2 P_0 + (2t - 2t ^ 2) C + t ^ 2 P_1 [/ latex]

En t = 0 estamos al inicio de nuestra curva; en t = 1 estamos al final.

Técnicamente vamos a utilizar múltiples curvas de Bézier donde el final de una es el comienzo de la otra, formando una cadena..

Ahora tenemos que resolver el problema de mostrar realmente nuestro río. Las curvas no tienen grosor, por lo que vamos a construir un primitivo geométrico a su alrededor..

Primero necesitamos una forma de tomar curva y convertirla en segmentos de línea. Para hacer esto tomamos nuestros puntos y los insertamos en la definición matemática de la curva. Lo bueno de esto es que podemos agregar fácilmente un parámetro para controlar la calidad de esta operación.

Aquí está el código para generar los puntos de la definición de la curva:

 // Calcular el punto de la función privada de la expresión de Bezier cuadrática quadraticBezier (P0: Point, P1: Point, C: Point, t: Number): Point var x = (1 - t) * (1 - t) * P0.x + (2 - 2 * t) * t * Cx + t * t * P1.x; var y = (1 - t) * (1 - t) * P0.y + (2 - 2 * t) * t * C.y + t * t * P1.y; devolver nuevo punto (x, y); 

Y aquí está cómo convertir la curva en segmentos de línea:

 // Este es un método que utiliza una lista de nodos // Cada nodo se define como: position, control función pública convertToPoints (calidad: Número = 10): Vector. var points: vector. = nuevo vector. (); precisión var: Número = 1 / calidad; // Pase a través de todos los nodos para generar segmentos de línea para (var i: int = 0; i < _nodes.length - 1; i++)  var current:CurveNode = _nodes[i]; var next:CurveNode = _nodes[i + 1]; // Sample Bezier curve between two nodes // Number of steps is determined by quality parameter for (var step:Number = 0; step < 1; step += precision)  var newPoint:Point = quadraticBezier(current.position, next.position, current.control, step); points.push(newPoint);   return points; 

Ahora podemos tomar una curva arbitraria y convertirla en un número personalizado de segmentos de línea: cuantos más segmentos, mayor será la calidad:

Para llegar a la geometría vamos a generar dos nuevas curvas basadas en la original. Su posición y puntos de control serán movidos por un valor de compensación de vector normal, que podemos considerar como el grosor. La primera curva se moverá en la dirección negativa, mientras que la segunda se moverá en la dirección positiva.

Ahora usaremos la función definida anteriormente para crear segmentos de línea a partir de las curvas. Esto formará un límite alrededor de la curva original..

¿Cómo hacemos esto en código? Tendremos que calcular los valores normales para los puntos de posición y control, multiplicarlos por el desplazamiento y agregarlos a los valores originales. Para los puntos de posición tendremos que interpolar Normales formadas por líneas a puntos de control adyacentes..

 // Iterar a través de todos los puntos para (var i: int = 0; i < _nodes.length; i++)  var normal:Point; var surface:Point; // Normal formed by position points if (i == 0)  // First point - take normal from first line segment normal = lineNormal(_nodes[i].position, _nodes[i].control); surface = lineNormal(_nodes[i].position, _nodes[i + 1].position);  else if (i + 1 == _nodes.length)  // Last point - take normal from last line segment normal = lineNormal(_nodes[i - 1].control, _nodes[i].position); surface = lineNormal(_nodes[i - 1].position, _nodes[i].position);  else  // Middle point - take 2 normals from segments // adjecent to the point, and interpolate them normal = lineNormal(_nodes[i].position, _nodes[i].control); normal = normal.add( lineSegmentNormal(_nodes[i - 1].control, _nodes[i].position)); normal.normalize(1); // This causes a slight visual issue for thicker rivers // It can be avoided by adding more nodes surface = lineNormal(_nodes[i].position, _nodes[i + 1].position);  // Add offsets to the original node, forming a new one. nodesWithOffset.add( _nodes[i].position.x + normal.x * offset, _nodes[i].position.y + normal.y * offset, _nodes[i].control.x + surfaceNormal.x * offset, _nodes[i].control.y + surfaceNormal.y * offset ); 

Ya puede ver que podemos usar esos puntos para definir pequeños polígonos de cuatro lados: "quads". Nuestra implementación utiliza un objeto de visualización de Starling personalizado, que proporciona nuestros datos geométricos directamente a la GPU..

Un problema, dependiendo de la implementación, es que no podemos enviar los quads directamente; En cambio, tenemos que enviar triángulos. Pero es bastante fácil elegir dos triángulos usando cuatro puntos:

Resultado:


Texturizado

El estilo geométrico limpio es divertido, e incluso podría ser un buen estilo para algunos juegos experimentales. Pero, para hacer que nuestro río se vea realmente bien, podríamos ver algunos detalles más. Usar una textura es una buena idea. Lo que nos lleva al problema de mostrarlo en la geometría personalizada creada anteriormente.

Tendremos que agregar información adicional a nuestros vértices; Las posiciones por sí solas ya no servirán. Cada vértice puede almacenar parámetros adicionales a nuestro gusto, y para admitir el mapeo de textura necesitaremos definir las coordenadas de la textura.

Las coordenadas de la textura están en el espacio de textura, y los valores de píxeles de la imagen se asignan a las posiciones mundiales de los vértices. Para cada píxel que aparece en la pantalla, calculamos las coordenadas de textura interpoladas y las usamos para buscar valores de píxel para las posiciones en la textura. Los valores 0 y 1 en el espacio de textura corresponden a bordes de textura; Si los valores dejan ese rango tenemos un par de opciones:

  • Repetir - Repetir indefinidamente la textura..
  • Abrazadera - cortar la textura fuera de los límites del intervalo [0, 1].

Aquellos que saben un poco sobre el mapeo de texturas son conscientes de las posibles complejidades de la técnica. ¡Tengo buenas noticias para ti! Esta forma de representar los ríos se mapea fácilmente a una textura..

Desde los lados, la altura de la textura se asigna en su totalidad, mientras que la longitud del río se segmenta en trozos más pequeños del espacio de la textura, dimensionados de manera adecuada al ancho de la textura.

Ahora implementarlo en el código:

 // _texture es una variante de textura de Starling: Número = 0; // Iterar a través de todos los puntos para (var i: int = 0; i < _points.length; i++)  if (i > 0) // Distancia en el espacio de textura para la distancia del segmento de línea actual + = Point.distance (lastPoint, _points [i]) / _texture.width;  // Asignar coordenadas de textura a la geometría _vertexData.setTexCoords (vertexId ++, distance, 0); _vertexData.setTexCoords (vertexId ++, distance, 1); 

Ahora se parece mucho más a un río:


Animación

Nuestro río ahora se parece mucho más a uno real, con una gran excepción: está parado!

Bien, entonces necesitamos animarlo. Lo primero que puedes pensar es usar la animación de la hoja de sprites. Y eso puede funcionar, pero para mantener más flexibilidad y ahorrar un poco en la memoria de texturas, haremos algo más interesante.

En lugar de cambiar la textura, podemos cambiar la forma en que la textura se asigna a la geometría. Hacemos esto cambiando las coordenadas de textura para nuestros vértices. Esto solo funcionará para texturas enlosables con mapeo configurado en repetir.

Una forma fácil de implementar esto es cambiar las coordenadas de textura en la CPU y enviar los resultados a la GPU cada fotograma. Esta suele ser una buena forma de iniciar una implementación de este tipo de técnica, ya que la depuración es mucho más fácil. Sin embargo, vamos a sumergirnos directamente en la mejor manera en que podemos lograr esto: animando las coordenadas de la textura usando sombreadores de vértices.

Por experiencia, puedo decir que las personas a veces se sienten intimidadas por los shaders, probablemente debido a su conexión con los efectos gráficos avanzados de los juegos de gran éxito. La verdad es que el concepto detrás de ellos es extremadamente simple, y si puede escribir un programa, puede escribir un sombreado, eso es todo, pequeños programas que se ejecutan en la GPU. Vamos a utilizar un sombreador de vértice para animar nuestro río, hay varios otros tipos de sombreadores, pero podemos hacerlo sin ellos.

Como su nombre lo indica, los sombreadores de vértices procesan los vértices. Se ejecutan para cada vértice y toman como atributos de vértice de entrada: posición, coordenadas de textura y color.

Nuestro objetivo es compensar el valor X de la coordenada de textura del río para simular el flujo. Mantenemos un contador de flujo y lo aumentamos cada cuadro por tiempo delta. Podemos especificar un parámetro adicional para la velocidad de la animación. El valor de compensación se debe pasar al sombreador como un valor uniforme (constante), una forma de proporcionar al programa del sombreador más información que solo vértices. Este valor suele ser un vector de cuatro componentes; solo vamos a utilizar el componente X para almacenar el valor, mientras configuramos Y, Z y W en 0.

 // Desplazamiento de la textura en el índice 5, que luego hacemos referencia en el shader context.setProgramConstantsFromVector (Context3DProgramType.VERTEX, 5, new [-_textureOffset, 0, 0, 0], 1);

Esta implementación utiliza el lenguaje de sombreado AGAL. Puede ser un poco difícil de entender, ya que es una asamblea como el lenguaje. Puedes aprender más acerca de esto aquí.

Sombreador de vértices:

 m44 op, va0, vc0 // Calcular la posición mundial del vértice mul v0, va1, vc4 // Calcular el color del vértice // Agregar la coordenada de la textura del vértice (va2) y nuestra constante de desplazamiento de la textura (vc5): agregar v1, va2, vc5

Animación en acción:


¿Por qué parar aquí??

Ya casi terminamos, excepto que nuestro río aún parece poco natural. El corte liso entre el fondo y el río es una verdadera monstruosidad. Para resolver esto, puedes usar una capa adicional del río, un poco más gruesa, y una textura especial, que cubriría las orillas del río y cubriría la fea transición..

Y como la demostración representa un río de lava fundida, ¡no podemos ir sin un poco de brillo! Cree otra instancia de la geometría del río, ahora con una textura luminosa y establezca su modo de fusión en "agregar". Para aún más diversión, agregue una animación suave del valor alfa resplandor.

Demostración final:

Por supuesto, puedes hacer mucho más que solo ríos con este tipo de efecto. Lo he visto usado para efectos de partículas fantasma, cascadas o incluso para animar cadenas. Hay mucho espacio para mejoras adicionales, la versión final desde el punto de vista del rendimiento desde arriba se puede hacer usando una llamada de sorteo si las texturas se fusionan en un atlas. Los ríos largos deben dividirse en múltiples partes y ser eliminados. Una extensión importante sería implementar el forking de nodos de curvas para habilitar múltiples vías fluviales y, a su vez, simular la bifurcación.

Estoy usando esta técnica en nuestro último juego, y estoy muy satisfecho con lo que podemos hacer con ella. Lo estamos utilizando para ríos y carreteras (sin animación, obviamente). Estoy pensando en usar un efecto similar para los lagos..


Conclusión

Espero haberte dado algunas ideas sobre cómo pensar fuera de las técnicas gráficas regulares, como el uso de hojas de sprites o conjuntos de mosaicos para lograr efectos como este. Requiere un poco más de trabajo, un poco de matemática y algunos conocimientos de programación de GPU, pero a cambio obtienes mucha más flexibilidad.