Outro conquistador de sombras em Phaser, ou o uso de bicicletas

Dois anos atrás, eu já estava experimentando substâncias sombreadas no Phaser 2D. No último Ludum Dare, de repente decidimos fazer um horror, e que horror sem sombras e luzes! Eu quebrei meus dedos ...

... e nada a tempo para LD. No jogo, é claro, há um pouco de luz e sombra, mas essa é uma aparência miserável do que realmente deveria ser.

Depois de voltar para casa depois de enviar o jogo para o concurso, decidi "fechar a gestalt" e terminar essas sombras infelizes. O que aconteceu - você pode sentir o jogo , jogar a demo , olhar a foto e ler o artigo.

Como sempre, nesses casos, não faz sentido tentar escrever uma solução geral, você precisa se concentrar em uma situação específica. O mundo do jogo pode ser representado na forma de segmentos - pelo menos aquelas entidades que projetam sombras. Paredes são retângulos, pessoas são retângulos, apenas giradas, o spoiler infernal é um círculo, mas no modelo de corte pode ser simplificado para um comprimento de diâmetro sempre perpendicular a um raio de luz.

Existem várias fontes de luz (20 a 30) e todas são circulares (refletores) e estão localizadas condicionalmente abaixo dos objetos iluminados (para que as sombras possam ser infinitas).

Vi na minha cabeça as seguintes maneiras de resolver o problema:

  1. Para cada fonte de luz, criamos uma textura do tamanho de uma tela (bem, ou 2-4 vezes menor). Nesta textura, simplesmente desenhamos o trapézio BCC'D ', onde A é a fonte de luz, BC é o segmento de linha, B'C' é a projeção da linha na borda da textura. Depois disso, essas texturas são enviadas para o sombreador, onde são misturadas em uma única imagem.

    O autor do jogo de plataformas Celeste fez algo parecido com isto, que está bem escrito em seu artigo no meio: medium.com/@NoelFB/remaking-celestes-lighting-3478d6f10bf

    Problemas: 20 a 30 texturas do tamanho da tela que precisam ser redesenhadas quase todos os quadros e carregadas na GPU. Lembro que esse foi um processo muito, muito pouco rápido.

  2. O método descrito em uma postagem em habr - habr.com/post/272233 . Para cada fonte de luz, construímos um "mapa de profundidade", ou seja, essa textura, onde x = o ângulo do “feixe” da fonte de luz, y = o número da fonte de luz e cor == distância da fonte ao obstáculo mais próximo. Se dermos um passo de 0,7 graus (360/512) e 32 fontes de luz, obteremos uma textura de 512 x 32, que não foi atualizada por tanto tempo.
    (exemplo de textura para um passo de 45 graus)
  3. A maneira secreta que descreverei no final

No final, decidi pelo método 2. No entanto, o descrito no artigo não me serviu até o fim. Lá, a textura também foi construída no shader usando um rakecast - o shader do ciclo passou da fonte de luz na direção do feixe e procurou um obstáculo. Nas minhas experiências anteriores, também fiz rakecast no shader, e era muito caro, embora universal.

“Temos apenas segmentos no modelo”, pensei, “e 10 a 20 segmentos caem no raio de qualquer fonte de luz. Não posso calcular rapidamente um mapa de distância com base nisso? ”

Então eu decidi fazê-lo.

Para começar, simplesmente mostrei na tela as paredes, o “personagem principal” condicional e as fontes de luz. Ao redor das fontes de luz, um círculo de pura luz clara cortou na escuridão. Para obter isso:

( demo )

Eu imediatamente comecei a fazer o shader para não relaxar. Era necessário passar para cada fonte de luz suas coordenadas e raio de ação (além do qual a luz não alcança), isso é feito simplesmente através de uma matriz uniforme. E então no sombreador (que é fragmentário, que é realizado para cada pixel na tela), resta entender se o pixel atual está no círculo iluminado ou não.
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++; } } } 

Agora precisamos entender para cada fonte de luz quais segmentos projetarão uma sombra. Em vez disso, quais partes dos segmentos - na figura abaixo, não estamos interessados ​​nas partes "vermelhas" do segmento, porque a luz ainda não os alcança.

Nota: a definição de interseção é um tipo de otimização preliminar. É necessário para reduzir o tempo de processamento adicional, eliminando grandes pedaços de segmentos além do raio da fonte de luz. Isso faz sentido quando temos muitos segmentos cujo comprimento é muito maior que o raio do "brilho". Se não for esse o caso, e tivermos muitos segmentos curtos, pode ser certo não perder tempo determinando a interseção e processando os segmentos inteiros, porque economizar tempo ainda não funciona.

Para fazer isso, usei a fórmula conhecida para encontrar a interseção de uma linha reta e um círculo, que todos se lembram de cor de um curso escolar de geometria ... no mundo imaginário de alguém. Eu simplesmente não me lembrava dela, então tive que pesquisar no Google .

Codificamos, olha o que aconteceu.
( demo )
Parece ser a norma. Agora sabemos quais segmentos podem projetar uma sombra e executar o rakecast.

Aqui também temos opções:

  1. Nós apenas fazemos um círculo, lançamos raios e procuramos cruzamentos. A distância até o cruzamento mais próximo é o valor que precisamos
  2. Você só pode ir para os cantos que caem em segmentos. Afinal, já sabemos os pontos, não é difícil calcular os ângulos.
  3. Além disso, se formos ao longo de um segmento, não precisamos lançar raios e calcular interseções - podemos nos mover ao longo do segmento com a etapa desejada. Veja como funciona:


Aqui AB- segmento (parede), CÉ o centro da fonte de luz, Cd- perpendicular ao segmento.

Vamos x- o ângulo do normal, para o qual você precisa descobrir a distância da fonte ao segmento, X1- ponto no segmento ABonde o raio cai. Triângulo CDX1- retangular Cd- uma perna, e o seu comprimento é conhecido e constante para este segmento, CX1- comprimento desejado. CX1= fracCDcos(x). Se você conhece o passo com antecedência (e nós o conhecemos), pode pré-calcular a tabela de cossenos inversos e procurar distâncias muito rapidamente.

Vou dar um exemplo de código para essa tabela. Quase todo o trabalho com cantos é substituído pelo trabalho com índices, ou seja, números inteiros de 0 a N, em que N = o número de etapas no círculo (ou seja, ângulo da etapa =  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); } } } 

Obviamente, esse método introduz um erro nos casos em que o ângulo inicial ACD não é um múltiplo de uma etapa. Mas, para 512 etapas, visualmente não vejo diferença.

Então, o que já sabemos como fazer:
  1. Encontre segmentos dentro do alcance da fonte de luz que possa projetar uma sombra
  2. Para a etapa t, crie uma tabela de distância (ângulo), passando por cada segmento e calculando as distâncias.


Aqui está a aparência dessa tabela se você a desenhar em raios.

( demo )

E aqui está como ele procura 10 fontes de luz, se escritas em uma textura.

Aqui, cada pixel horizontal corresponde a um ângulo e a cor à distância em pixels.
Está escrito em 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; } } 


Agora passamos a textura para o nosso shader, que já possui as coordenadas e os raios das fontes de luz. E processe-o assim:

 //      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 )
Agora você pode trazer um pouco de beleza. Deixe a luz desaparecer com a distância e as sombras ficarão tremidas.

Para desfoque, olho para os cantos adjacentes, + - passo, assim:

 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; 


Se você juntar tudo e medir o FPS, será assim:

  • Nas placas de vídeo embutidas - tudo está ruim (<30-40), mesmo para exemplos simples
  • Tudo o resto está bem, desde que as fontes de luz não sejam muito fortes. Ou seja, o número de fontes de luz por pixel é importante, não o número total.


Este resultado me serviu bastante. Você ainda pode brincar com a cor da iluminação, mas eu não. Depois de torcer um pouco e adicionar alguns mapas normais, enviei uma versão atualizada do NOPE. Ela parecia assim agora:


Então ele começou a preparar um artigo. Eu olhei para um gif e pensei.

"Portanto, é quase uma aparência pseudo-3D, como em Wolfenstein", exclamei (sim, tenho uma boa imaginação). E, de fato - se assumirmos que todas as paredes têm a mesma altura, os mapas de distância serão suficientes para construirmos a cena. Por que não tentar?

A cena deve ficar assim.


Então nossa tarefa:

  1. Em um ponto da tela, obtenha coordenadas mundiais para o caso quando não houver paredes.

    Vamos considerar o seguinte:
    • Primeiro, normalizamos as coordenadas de um ponto na tela para que haja um ponto (0,0) no centro da tela e nos cantos (-1, -1) e (1,1), respectivamente
    • A coordenada x se torna o ângulo da direção da visão, basta multiplicá-la por A / 2, onde A é o ângulo de visão
    • A coordenada y determina a distância do observador ao ponto, no caso geral d ~ 1 / y. Para um ponto na borda inferior da tela, distância = 1, para um ponto no centro da tela, distância = infinito.
    • Portanto, se você não levar em consideração as paredes, para cada ponto visível do mundo haverá 2 pontos na tela - um acima do meio (no "teto") e o outro abaixo (no "piso")
  2. Agora podemos olhar para a tabela de distâncias. Se houver uma parede mais próxima do que pretendemos, você precisará desenhar uma parede. Caso contrário, significa piso ou teto

Recebemos como ordenado:
( demo )
Adicione iluminação - da mesma maneira, itere sobre as fontes de luz e verifique as coordenadas do mundo. E - o toque final - adicione texturas. Para fazer isso, em uma textura com distâncias, você também precisa escrever o deslocamento u para a textura da parede neste momento. É aqui que o canal b foi útil.
( demo )
Perfeito.

Brincadeira.

Imperfeito, é claro. Mas, diabos, eu ainda li sobre como fazer meu Wolfenstein passar por rakecast há cerca de 15 anos, e eu queria fazer tudo, e aqui está uma oportunidade!

Em vez de uma conclusão


No começo do artigo, mencionei outro método secreto. Aqui está:

Basta pegar o motor que já sabe como.

De fato, se você precisar fazer um jogo, essa será a maneira mais correta e rápida. Por que você precisa cercar suas bicicletas e resolver problemas de longa data?

Mas porque

Na série 10, mudei para outra escola e tive problemas em matemática. Não me lembro do exemplo exato, mas era uma equação com graus, que em todos os aspectos precisava ser simplificada, mas simplesmente não teve sucesso. Desesperada, consultei minha irmã e ela disse: “então adicione x 2 em ambos os lados e tudo se decomporá”. E essa foi a solução: adicione o que não estava lá.

Quando, mais tarde, ajudei meu amigo a construir minha casa, precisei colocar um bloco no limiar - para preencher um nicho. E aqui estou eu, separando a guarnição das barras. Parece que alguém se encaixa, mas não exatamente. Outros são muito menores. Estou pensando em como coletar a palavra felicidade aqui, e um amigo diz: "então eles beberam as ranhuras em um local circular onde ela interfere". E agora o grande bar já está parado.

Essas histórias são unidas por esse efeito, que chamarei de "efeito de inventário". Quando você tenta tomar uma decisão a partir de peças existentes, sem ver o material que pode ser processado e refinado nessas peças. Os números são madeira, dinheiro ou código.

Muitas vezes eu observei o mesmo efeito com os colegas de programação. Não se sentindo confiantes no material, às vezes cedem quando é necessário fazer, digamos, controles não padronizados. Ou adicione testes de unidade onde eles não estavam. Ou eles tentam fornecer tudo, tudo ao criar uma classe, e então obtemos um diálogo como:
- Isso não é necessário agora
- E se for necessário?
- Então vamos adicionar. Deixe os pontos de expansão, só isso. Código não é granito, é plasticina.

E para aprender a ver e sentir o material com o qual trabalhamos, também precisamos de bicicletas.

Este não é apenas um treino para a mente ou para o treinamento. Essa é uma maneira de atingir um nível qualitativamente diferente de trabalho com código.

Obrigado a todos pela leitura.

Links, caso você tenha esquecido de clicar em algum lugar:

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


All Articles