Crear un río de lava que fluye y fluye usando curvas y sombreadores Bézier
() translation by (you can also view the original English article)
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 puede ser beneficioso para el estilo del juego, por lo que se destacan más. En este tutorial voy a mostrarte cómo crear un río de lava animado en 2D usando curvas de Bézier, geometría texturada personalizada y sombreadores de vértices.
Nota: Aunque este tutorial está escrito con AS3 y Flash, debería ser capaz de usar 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? Consulta el video de YouTube en su lugar:
Preparacion
La implementación de demostración anterior utiliza AS3 y Flash con Starling Framework para procesamiento acelerado de GPU y la biblioteca Feathers para elementos de interfaz de usuario. En nuestra escena inicial vamos a colocar una imagen de tierra y una imagen de primer plano de la roca. Más tarde vamos a añadir 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. Sería poco práctico hacer una simulación físicamente correcta para un juego. Sólo queremos ir a obtener la representación visual adecuada, y para ello vamos a utilizar un modelo simplificado de un río.
Modelar el río como una curva es una de las soluciones que podemos usar, permitiéndonos tener un buen control y lograr una mirada serpenteante. Elegí utilizar curvas cuadráticas de Bézier para mantener las cosas simples.
Las curvas de Bézier son curvas paramétricas que a menudo se utilizan en gráficos por ordenador; en las curvas cuadráticas de Bézier, la curva pasa a través de dos puntos especificados, y su forma está determinada por el tercer punto, que usualmente se llama 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 rumbo que toma. Por ejemplo, poner 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" a la curva para acercarse a ese punto.
Este tipo de curva se define mediante la siguiente fórmula matemática:
En t = 0 estamos en el inicio de nuestra curva; en t = 1 estamos al final.
Técnicamente vamos a utilizar múltiples curvas Bézier donde el fin de una es el inicio del otro, 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 una primitiva geométrica a su alrededor.
Primero necesitamos una manera de tomar la curva y convertirla en segmentos de línea. Para ello, tomamos nuestros puntos y los conectamos 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:
1 |
|
2 |
// Calculate point from quadratic Bezier expression
|
3 |
private function quadraticBezier(P0:Point, P1:Point, C:Point, t:Number):Point |
4 |
{
|
5 |
var x = (1 - t) * (1 - t) * P0.x + (2 - 2 * t) * t * C.x + t * t * P1.x; |
6 |
var y = (1 - t) * (1 - t) * P0.y + (2 - 2 * t) * t * C.y + t * t * P1.y; |
7 |
|
8 |
return new Point(x, y); |
9 |
}
|
Y aquí es cómo convertir la curva en segmentos de línea:
1 |
|
2 |
// This is a method which uses a list of nodes
|
3 |
// Each node is defined as: {position, control}
|
4 |
public function convertToPoints(quality:Number = 10):Vector. |
5 |
{
|
6 |
var points:Vector. = new Vector.(); |
7 |
|
8 |
var precision:Number = 1 / quality; |
9 |
|
10 |
// Pass through all nodes to generate line segments
|
11 |
for (var i:int = 0; i < _nodes.length - 1; i++) |
12 |
{
|
13 |
var current:CurveNode = _nodes[i]; |
14 |
var next:CurveNode = _nodes[i + 1]; |
15 |
|
16 |
// Sample Bezier curve between two nodes
|
17 |
// Number of steps is determined by quality parameter
|
18 |
for (var step:Number = 0; step < 1; step += precision) |
19 |
{
|
20 |
var newPoint:Point = quadraticBezier(current.position, |
21 |
next.position, current.control, step); |
22 |
points.push(newPoint); |
23 |
}
|
24 |
}
|
25 |
return points; |
26 |
}
|
Ahora podemos tomar una curva arbitraria y convertirla en un número personalizado de segmentos de línea: cuanto más segmentos, mayor es la calidad:



Para llegar a la geometría vamos a generar dos nuevas curvas basadas en la original. Sus puntos de posición y control se moverán por un valor de desplazamiento de vector normal, que podemos considerar como el espesor. 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 formando las curvas. Esto formará un límite alrededor de la curva original.



¿Cómo hacemos esto en código? Necesitaremos calcular las normales para los puntos de posición y control, multiplicarlos por el offset y agregarlos a los valores originales. Para los puntos de posición tendremos que interpolar las normales formadas por líneas a puntos de control adyacentes.
1 |
|
2 |
// Iterate through all points
|
3 |
for (var i:int = 0; i < _nodes.length; i++) { |
4 |
|
5 |
var normal:Point; |
6 |
var surface:Point; // Normal formed by position points |
7 |
|
8 |
if (i == 0) { |
9 |
// First point - take normal from first line segment
|
10 |
normal = lineNormal(_nodes[i].position, _nodes[i].control); |
11 |
surface = lineNormal(_nodes[i].position, _nodes[i + 1].position); |
12 |
}
|
13 |
else if (i + 1 == _nodes.length) { |
14 |
// Last point - take normal from last line segment
|
15 |
normal = lineNormal(_nodes[i - 1].control, _nodes[i].position); |
16 |
surface = lineNormal(_nodes[i - 1].position, _nodes[i].position); |
17 |
}
|
18 |
else { |
19 |
// Middle point - take 2 normals from segments
|
20 |
// adjecent to the point, and interpolate them
|
21 |
normal = lineNormal(_nodes[i].position, _nodes[i].control); |
22 |
normal = normal.add( |
23 |
lineSegmentNormal(_nodes[i - 1].control, _nodes[i].position)); |
24 |
normal.normalize(1); |
25 |
|
26 |
// This causes a slight visual issue for thicker rivers
|
27 |
// It can be avoided by adding more nodes
|
28 |
surface = lineNormal(_nodes[i].position, _nodes[i + 1].position); |
29 |
}
|
30 |
|
31 |
// Add offsets to the original node, forming a new one.
|
32 |
nodesWithOffset.add( |
33 |
_nodes[i].position.x + normal.x * offset, |
34 |
_nodes[i].position.y + normal.y * offset, |
35 |
_nodes[i].control.x + surfaceNormal.x * offset, |
36 |
_nodes[i].control.y + surfaceNormal.y * offset |
37 |
); |
38 |
}
|
Ya puedes ver que podemos usar esos puntos para definir pequeños polígonos de cuatro lados - "quads". Nuestra implementación utiliza Starling DisplayObject personalizado, que da nuestros datos geométricos directamente a la GPU.
Un problema, dependiendo de la implementación, es que no podemos enviar quads directamente; en cambio, tenemos que enviar triángulos. Pero es bastante fácil seleccionar dos triángulos usando cuatro puntos:



Resultado:
Texturizado
El estilo geométrico limpio es divertido, y podría incluso ser un buen estilo para algunos juegos experimentales. Pero, para hacer que nuestro río parezca realmente bueno podríamos hacer con algunos más detalles. Usar una textura es una buena idea. Lo que nos lleva al problema de mostrarlo en la geometría personalizada creada anteriormente.
Tendremos que añadir información adicional a nuestros vértices; las posiciones por sí solas ya no lo harán. Cada vértice puede almacenar parámetros adicionales a nuestro gusto, y para soportar el mapeo de textura, necesitaremos definir coordenadas de textura.



Las coordenadas de la textura están en el espacio de la textura, y los valores del pixel del mapa de la imagen 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íxeles para posiciones en la textura. Los valores 0 y 1 en el espacio de textura corresponden a los bordes de la textura; si los valores dejan ese rango tenemos un par de opciones:
- Repetir - repetir indefinidamente la textura.
- Abrazadera - corta la textura fuera de los límites del intervalo [0, 1].
Aquellos que saben un poco sobre el mapeo de textura son conscientes de posibles complejidades de la técnica. ¡Tengo buenas noticias para ti! Esta manera de representar los ríos se asigna fácilmente a una textura.



Desde los lados la altura de la textura se mapea en su totalidad, mientras que la longitud del río se segmenta en trozos más pequeños del espacio de la textura, tamaño apropiado al ancho de la textura.
Ahora para implementar en el código:
1 |
|
2 |
// _texture is a Starling texture
|
3 |
var distance:Number = 0; |
4 |
|
5 |
// Iterate through all points
|
6 |
for (var i:int = 0; i < _points.length; i++) { if (i > 0) { |
7 |
// Distance in texture space for current line segment
|
8 |
distance += Point.distance(lastPoint, _points[i]) / _texture.width; |
9 |
}
|
10 |
|
11 |
// Assign texture coordinates to geometry
|
12 |
_vertexData.setTexCoords(vertexId++, distance, 0); |
13 |
_vertexData.setTexCoords(vertexId++, distance, 1); |
14 |
}
|
Ahora se parece mucho más a un río:
Animación
Nuestro río ahora parece mucho más real, con una gran excepción: ¡está parado!
Bien, así que tenemos que animarlo. Lo primero que puede pensar es utilizar la animación de hoja de sprite. Y eso bien puede funcionar, pero para mantener más flexibilidad y ahorrar un poco en la memoria de textura, haremos algo más interesante.
En lugar
de cambiar la textura, podemos cambiar la forma en que la textura se
correlaciona con la geometría. Hacemos esto cambiando las coordenadas de
textura de nuestros vértices. Esto sólo funcionará para texturas de
mosaico con mapeado establecido para repeat
.



Una manera fácil de implementar esto es cambiar las coordenadas de textura en la CPU y enviar los resultados a la GPU cada fotograma. Ésa es generalmente una buena manera de comenzar una puesta en práctica este tipo de técnica, puesto que la depuración es mucho más fácil. Sin embargo, vamos a bucear directamente de la mejor manera que podemos lograr esto: animar las coordenadas de textura utilizando sombreadores de vértice.
Por experiencia, puedo decir que a veces las personas se sienten intimidadas por los shaders, probablemente debido a su conexión con los efectos gráficos avanzados de los juegos de blockbuster. La verdad sea dicho que el concepto detrás de ellos es extremadamente simple, y si usted puede escribir un programa, usted puede escribir un shader - ése es todo que son, pequeños programas que funcionan en el GPU. Vamos a usar un vertex shader para animar nuestro río, hay varios otros tipos de shaders, pero podemos hacerlo sin ellos.
Como su nombre lo indica, los sombreadores de vértices procesan vértices. Funcionan para cada vértice, y toman como entrada los atributos del vértice: posición, coordenadas de la 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 incrementamos cada frame by time 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 el programa de sombreado con más información que sólo vértices. Este valor suele ser un vector de cuatro componentes; sólo vamos a utilizar el componente X para almacenar el valor, mientras que la configuración de Y, Z y W a 0.
1 |
|
2 |
// Texture offset at index 5, which we later reference in the shader
|
3 |
context.setProgramConstantsFromVector(Context3DProgramType.VERTEX, 5, |
4 |
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í.
Vertex shader:
1 |
|
2 |
m44 op, va0, vc0 // Calculate vertex world position |
3 |
mul v0, va1, vc4 // Calculate vertex color |
4 |
// Add vertex texture coordinate (va2) and our texture offset constant (vc5):
|
5 |
add v1, va2, vc5 |
Animación en acción:
¿Por qué parar aquí?
Estamos bastante bien hecho, excepto que nuestro río todavía parece antinatural. El plano cortado entre el fondo y el río es una verdadera monstruosidad. Para resolver esto se puede utilizar una capa adicional del río, ligeramente más gruesa, y una textura especial, que se superponen las orillas del río y cubrir la transición fea.



Y puesto que la demo representa el río de lava fundida, no podemos ir sin un poco de resplandor! Hacer otra instancia de la geometría del río, ahora usando una textura de resplandor y establecer su modo de fusión para "añadir". Para aún más diversión, agregue una animación suave del valor del alfa del resplandor.
Demostración final:
Por supuesto, usted puede hacer mucho más que sólo los ríos que utilizan este tipo de efecto. He visto que se utiliza para efectos de partículas de fantasmas, cascadas o incluso para animar cadenas. Hay mucho espacio para la mejora adicional, la versión final del funcionamiento sabio de arriba se puede hacer usando una llamada del drenaje si las texturas se combinan a un atlas. Los ríos largos deben dividirse en múltiples partes y ser sacrificados. Una ampliación importante sería implementar bifurcación de nodos de curva para habilitar múltiples rutas de ríos ya su vez simular la bifurcación.
Estoy usando esta técnica en nuestro último juego, y estoy muy contento con lo que podemos hacer con él. 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 que te di algunas ideas sobre cómo pensar fuera de las técnicas gráficas regulares, como el uso de hojas de sprite o juegos de azulejos para lograr efectos como éste. Requiere un poco más de trabajo, un poco de matemáticas, y algunos conocimientos de programación de GPU, pero a cambio de obtener una mayor flexibilidad.