Otro conquistador de la sombra en Phaser, o el uso de bicicletas.

Hace dos años, ya estaba experimentando con sustancias de sombra en Phaser 2D. En el último Ludum Dare, de repente decidimos hacer un horror, ¡pero qué horror sin sombras y luces! Me partí los nudillos ...

... y nada a tiempo para LD. En el juego, por supuesto, hay un poco de luz y sombra, pero esta es una apariencia miserable de lo que realmente debería haber sido.

Después de regresar a casa después de enviar el juego a la competencia, decidí "cerrar la gestalt" y terminar estas sombras desafortunadas. Lo que sucedió: puedes sentirte en el juego , jugar en la demostración , mirar la imagen y leer el artículo.

Como siempre en tales casos, no tiene sentido tratar de escribir una solución general, debe concentrarse en una situación específica. El mundo del juego se puede representar en forma de segmentos, al menos aquellas entidades que proyectan sombras. Las paredes son rectángulos, las personas son rectángulos, solo giran, el spoiler infernal es un círculo, pero en el modelo de corte se puede simplificar a una longitud de un diámetro que siempre es perpendicular a un rayo de luz.

Hay varias fuentes de luz (20-30), y todas ellas son circulares (focos) y se encuentran condicionalmente más bajas que los objetos iluminados (de modo que las sombras pueden ser infinitas).

Vi en mi cabeza las siguientes formas de resolver el problema:

  1. Para cada fuente de luz, construimos una textura del tamaño de una pantalla (bueno, o 2-4 veces más pequeña). En esta textura, simplemente dibujamos el trapecio BCC'D ', donde A es la fuente de luz, BC es el segmento, B'C' es la proyección del segmento hacia el borde de la textura. Después de eso, estas texturas se envían al sombreador, donde se mezclan en una sola imagen.

    El autor del juego de plataformas Celeste hizo algo como esto, que está bien escrito en su artículo en medium: medium.com/@NoelFB/remaking-celestes-lighting-3478d6f10bf

    Problemas: 20-30 texturas del tamaño de una pantalla que necesitan ser redibujadas casi cada cuadro y cargadas en la GPU. Recuerdo que este fue un proceso muy, muy rápido.

  2. El método descrito en una publicación en un habr - habr.com/post/272233 . Para cada fuente de luz construimos un "mapa de profundidad", es decir tal textura, donde x = el ángulo del "haz" de la fuente de luz, y = el número de la fuente de luz, y el color == distancia de la fuente al obstáculo más cercano. Si damos un paso de 0.7 grados (360/512) y 32 fuentes de luz, obtenemos una textura de 512x32, que no se ha actualizado durante tanto tiempo.
    (ejemplo de textura para un paso de 45 grados)
  3. La forma secreta que describiré al final

Al final, me decidí por el método 2. Sin embargo, lo descrito en el artículo no me convenía hasta el final. Allí, la textura también se construyó en el sombreador usando un rakecast: el sombreador en el ciclo pasó de la fuente de luz en la dirección del haz y buscó un obstáculo. En mis experimentos anteriores, también hice rakecast en el sombreador, y era muy costoso, aunque universal.

“Solo tenemos segmentos en el modelo”, pensé, “y 10-20 segmentos caen en el radio de cualquier fuente de luz. ¿No puedo calcular rápidamente un mapa de distancia basado en esto?

Entonces decidí hacerlo.

Para empezar, simplemente mostré en la pantalla las paredes, el "personaje principal" condicional y las fuentes de luz. Alrededor de las fuentes de luz, un círculo de pura luz clara cortada en la oscuridad. Para obtener esto:

( demo )

Inmediatamente comencé a hacer con el sombreador para no relajarme. Era necesario pasar a cada fuente de luz sus coordenadas y radio de acción (más allá del cual la luz no alcanza), esto se hace simplemente a través de una matriz uniforme. Y luego, en el sombreador (que es fragmentario, que se realiza para cada píxel en la pantalla), quedaba por entender si el píxel actual estaba en el círculo iluminado o no.
class SimpleLightShader extends Phaser.Filter { constructor(game) { super(game); let lightsArray = new Array(MAX_LIGHTS*4); lightsArray.fill(0, 0, lightsArray.length); this.uniforms.lightsCount = {type: '1i', value: 0}; this.uniforms.lights = {type: '4fv', value: lightsArray}; this.fragmentSrc = ` precision highp float; uniform int lightsCount; uniform vec4 lights[${MAX_LIGHTS}]; void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; lightness += step(length(light.xy - gl_FragCoord.xy), light.z); } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,0.5), vec4(0,0,0,0), lightness); } `; } updateLights(lightSources) { this.uniforms.lightsCount.value = lightSources.length; let i = 0; let array = this.uniforms.lights.value; for (let light of lightSources) { array[i++] = light.x; array[i++] = game.world.height - light.y; array[i++] = light.radius; i++; } } } 

Ahora necesitamos comprender para cada fuente de luz qué segmentos proyectarán una sombra. Más bien, qué partes de los segmentos: en la figura a continuación no estamos interesados ​​en las partes "rojas" del segmento, porque la luz aún no les llega.

Nota: la definición de intersección es un tipo de optimización preliminar. Es necesario para reducir el tiempo de procesamiento adicional, eliminando grandes piezas de segmentos más allá del radio de la fuente de luz. Esto tiene sentido cuando tenemos muchos segmentos cuya longitud es mucho mayor que el radio del "resplandor". Si este no es el caso, y tenemos muchos segmentos cortos, puede ser correcto no perder el tiempo para determinar la intersección y procesar los segmentos completos, porque Ahorrar tiempo todavía no funciona.

Para hacer esto, utilicé la fórmula bien conocida para encontrar la intersección de una línea recta y un círculo, que todos recuerdan de memoria de un curso escolar de geometría ... en el mundo imaginario de alguien. Simplemente no la recordaba, así que tuve que buscarlo en Google .

Codificamos, mira lo que pasó.
( demo )
Parece ser la norma. Ahora sabemos qué segmentos pueden proyectar una sombra y pueden realizar rakecast.

Aquí también tenemos opciones:

  1. Simplemente vamos en círculo en círculo, lanzamos rayos y buscamos intersecciones. La distancia a la intersección más cercana es el valor que necesitamos.
  2. Solo puedes ir a esas esquinas que se dividen en segmentos. Después de todo, ya conocemos los puntos, no es difícil calcular los ángulos.
  3. Además, si vamos a lo largo de un segmento, entonces no necesitamos emitir rayos y calcular intersecciones; podemos movernos a lo largo del segmento con el paso deseado. Así es como funciona:


Aqui AB- segmento (pared), CEs el centro de la fuente de luz, Cd- perpendicular al segmento.

Dejar x- el ángulo con respecto a lo normal, para lo cual necesita averiguar la distancia desde la fuente hasta el segmento, X1- punto en el segmento ABdonde cae el rayo. Triángulo CDX1- rectangular Cd- una pierna, y su longitud es conocida y constante para este segmento, CX1- longitud deseada. CX1= fracCDcos(x). Si conoce el paso de antemano (y nosotros lo sabemos), puede calcular previamente la tabla de cosenos inversos y buscar distancias muy rápidamente.

Daré un ejemplo de código para dicha tabla. Casi todo el trabajo con esquinas se reemplaza por trabajo con índices, es decir enteros de 0 a N, donde N = el número de pasos en el círculo (es decir, ángulo de paso =  frac2 piN)

 class HypTable { constructor(steps = 512, stepAngle = 2*Math.PI/steps) { this.perAngleStep = [1]; for (let i = 1; i < steps/4; i++) { //   pi/2 let ang = i*stepAngle; this.perAngleStep[i] = 1/Math.cos(ang); } this.stepAngle = stepAngle; } /** * @param distancesMap -  ,    * @param angle1 -           * @param angle2 -           * @param normalFromLight - ,      */ fillDistancesForArc(distancesMap, angle1, angle2, normalFromLight) { const D = Math.hypot(normalFromLight.x, normalFromLight.y); const normalAngle = Phaser.Math.normalizeAngle(Math.atan2(normalFromLight.y, normalFromLight.x)); const normalAngleIndex = (normalAngle / this.stepAngle)|0; const index1 = (angle1 / this.stepAngle)|0; const index2 = (angle2 / this.stepAngle)|0; for (let angleIndex = index1; angleIndex <= index2; angleIndex++) { let distanceForAngle = D * this.perAngleStep[normalize(angleIndex - normalAngleIndex)]; distancesMap.set(angleIndex, distanceForAngle); } } } 

Por supuesto, este método introduce un error para los casos en que el ángulo ACD inicial no es un múltiplo de un paso. Pero para 512 pasos, visualmente no veo diferencia.

Entonces, lo que ya sabemos hacer:
  1. Encuentra segmentos dentro del rango de la fuente de luz que pueden proyectar una sombra
  2. Para el paso t, cree una tabla dist (ángulo), pasando por cada segmento y calculando las distancias.


Así es como se ve esta tabla si la dibuja en rayos.

( demo )

Y así es como se ven las 10 fuentes de luz, si están escritas en una textura.

Aquí, cada píxel horizontal corresponde a un ángulo, y el color a la distancia en píxeles.
Está escrito en js como este usando imageData
  fillBitmap(data, index) { let total = index + this.steps*4; let d1, d2; let i = 0; //data[index] = Red //data[index+1] = Green //data[index+2] = Blue //data[index+3] = Alpha for (; index < total; index+=4, i++) { //  512,    R     2. d1 = (this.distances[i]/2)|0; data[index] = d1; d1 = this.distances[i] - d1*2; d2 = (d1*128)|0; //   G -     2. data[index+1] = d2; //  B  A  255,     . data[index+2] = 255; data[index+3] = 255; } } 


Ahora pasamos la textura a nuestro sombreador, que ya tiene las coordenadas y los radios de las fuentes de luz. Y procesarlo así:

 //      uniform sampler2D iChannel0; #define STRENGTH 0.3 #define MAX_DARK 0.7 #define M_PI 3.141592653589793 #define M_PI2 6.283185307179586 //       float decodeDist(vec4 color) { return color.r*255.*2. + color.g*2.; } float getShadow(int i, float angle, float distance) { //   x   ==  float u = angle/M_PI2; //   y   ==     float v = float(i)/${MAX_LIGHTS}.; float shadowAfterDistance = decodeDist(texture2D(iChannel0, vec2(u, v))); //  1   ,  0  . return step(shadowAfterDistance, distance); } void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; //       vec2 light2point = gl_FragCoord.xy - light.xy; float radius = light.z; float distance = length(light2point); float inLight = step(distance, radius); //      ,       //  . //      , //    ,          //           //     ,    if (inLight == 0.) continue; float angle = mod(-atan(light2point.y, light2point.x), M_PI2); // 1     0   float thisLightness = (1. - getShadow(i, angle, distance)); //,   “”  ,   ,  //    lightness += thisLightness*STRENGTH; } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,MAX_DARK), vec4(0,0,0,0), lightness); } 


Resultado:
( demo )
Ahora puedes traer un poco de belleza. Deje que la luz se desvanezca con la distancia, y las sombras serán borrosas.

Para desenfoque, miro las esquinas adyacentes, + - paso, así:

 thisLightness = (1. - getShadow(i, angle, distance)) * 0.4 + (1. - getShadow(i, angle-SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle+SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle-SMOOTH_STEP*2., distance)) * 0.1 + (1. - getShadow(i, angle+SMOOTH_STEP*2., distance)) * 0.1; 


Si pones todo junto y mides el FPS, resulta así:

  • En tarjetas de video integradas: todo está mal (<30-40), incluso para ejemplos simples
  • Todo lo demás está bien, siempre que las fuentes de luz no sean muy fuertes. Es decir, el número de fuentes de luz por píxel es importante, no el número total.


Este resultado me convino bastante. Todavía podías jugar con el color de la iluminación, pero yo no. Después de girar un poco y agregar algunos mapas normales, cargué una versión actualizada de NOPE. Ella se veía así ahora:


Luego comenzó a preparar un artículo. Miré ese gif y pensé.

"Así que es casi un aspecto pseudo-3D, como en Wolfenstein", exclamé (sí, tengo buena imaginación). Y, de hecho, si suponemos que todas las paredes tienen la misma altura, los mapas de distancia serán suficientes para que podamos construir la escena. ¿Por qué no probarlo?

La escena debería verse así.


Entonces nuestra tarea:

  1. En un punto de la pantalla, obtenga coordenadas mundiales para el caso cuando no haya paredes.

    Consideraremos esto:
    • Primero, normalizamos las coordenadas de un punto en la pantalla para que haya un punto (0,0) en el centro de la pantalla y en las esquinas (-1, -1) y (1,1), respectivamente
    • La coordenada x se convierte en el ángulo desde la dirección de visión, solo necesita multiplicarla por A / 2, donde A es el ángulo de visión
    • La coordenada y determina la distancia desde el observador hasta el punto, en el caso general d ~ 1 / y. Para un punto en el borde inferior de la pantalla, distancia = 1, para un punto en el centro de la pantalla, distancia = infinito.
    • Por lo tanto, si no tiene en cuenta las paredes, entonces para cada punto visible en el mundo habrá 2 puntos en la pantalla, uno arriba del medio (en el "techo") y el otro debajo (en el "piso")
  2. Ahora podemos mirar la tabla de distancias. Si hay un muro más cerca que nuestro punto, entonces debes dibujar un muro. Si no, significa piso o techo

Obtenemos lo ordenado:
( demo )
Agregue iluminación: de la misma manera, repita las fuentes de luz y verifique las coordenadas mundiales. Y, el toque final, agrega texturas. Para hacer esto, en una textura con distancias, también necesita escribir el desplazamiento u para la textura de la pared en este punto. Aquí es donde el canal b fue útil.
( demo )
Perfecto

Es broma.

Imperfecto, por supuesto. Pero, demonios, todavía leo sobre cómo hacer que mi Wolfenstein se transmita por rake hace unos 15 años, y quería hacerlo todo, ¡y esta es una gran oportunidad!

En lugar de una conclusión


Al comienzo del artículo, mencioné otro método secreto. Aquí esta:

Simplemente tome el motor que ya sabe cómo.

De hecho, si necesitas hacer un juego, esta será la forma más correcta y rápida. ¿Por qué necesitas cercar tus bicicletas y resolver problemas de larga data?

Pero por qué.

En el grado 10, me mudé a otra escuela y tuve problemas en matemáticas. No recuerdo el ejemplo exacto, pero era una ecuación con grados, que en todos los aspectos necesitaba ser simplificada, pero simplemente no tuvo éxito. Desesperada, consulté con mi hermana, y ella dijo: "así que agregue x 2 en ambos lados, y todo se descompondrá". Y esa fue la solución: agregar lo que no estaba allí.

Cuando, mucho más tarde, ayudé a mi amigo a construir mi casa, tuve que poner un bloque en el umbral, para llenar un nicho. Y aquí estoy, clasificando el borde de las barras. Uno parece encajar, pero no del todo. Otros son mucho más pequeños. Estoy pensando en cómo recopilar la palabra felicidad aquí, y un amigo dice: "así que bebieron los surcos en un lugar circular donde interfiere". Y ahora el gran bar ya está parado.

Estas historias están unidas por tal efecto, que llamaré el "efecto de inventario". Cuando intenta tomar una decisión a partir de piezas existentes, sin ver material que se pueda procesar y refinar en estas partes. Los números son madera, dinero o código.

Muchas veces he observado el mismo efecto con colegas en la programación. Al no sentirse seguros del material, a veces ceden cuando es necesario hacer, por ejemplo, controles no estándar. O agregue pruebas unitarias a donde no estaban. O intentan proporcionar todo, todo al diseñar una clase, y luego obtenemos un diálogo como:
- Esto no es necesario ahora
- ¿Qué pasa si se hace necesario?
- Entonces lo agregaremos. Deje los puntos de expansión, eso es todo. El código no es granito, es plastilina.

Y para aprender a ver y sentir el material con el que trabajamos, también necesitamos bicicletas.

Esto no es solo un entrenamiento para la mente o el entrenamiento. Esta es una forma de alcanzar un nivel de trabajo cualitativamente diferente con el código.

Gracias a todos por leer.

Enlaces, en caso de que haya olvidado hacer clic en alguna parte:

Source: https://habr.com/ru/post/es434370/


All Articles