Desenhamos uma explosão de desenho animado para 180 linhas de C ++ nuas

Há uma semana, publiquei outro capítulo do meu curso de computação gráfica ; hoje retornaremos ao traçado de raios novamente, mas desta vez iremos um pouco além de renderizar esferas triviais. Não preciso de fotorrealismo: para fins de desenho animado, uma explosão , ao que me parece, acontecerá.

Como sempre, temos apenas um compilador à nossa disposição, nenhuma biblioteca de terceiros pode ser usada. Não quero me preocupar com gerenciadores de janelas, processamento de mouse / teclado e similares. O resultado do nosso programa será uma imagem simples salva em disco. Não busco velocidade / otimização, meu objetivo é mostrar os princípios básicos.

No total, como desenhar essa imagem em 180 linhas de código nessas condições?



Deixe-me inserir um gif animado (seis metros):



E agora vamos dividir toda a tarefa em várias etapas:

Etapa um: leia o artigo anterior


Sim exatamente. A primeira coisa a fazer é ler o capítulo anterior , que fala sobre o básico do traçado de raios. É muito curto, em princípio, todas as reflexões-refrações não podem ser lidas, mas pelo menos até a iluminação difusa eu recomendo a leitura. O código é bastante simples, as pessoas até o rodam em microcontroladores:



Estágio Dois: Desenhe Uma Esfera


Vamos desenhar uma esfera sem nos preocuparmos com materiais ou iluminação. Por simplicidade, esta esfera viverá no centro das coordenadas. Sobre esta foto, quero obter:



Veja o código aqui , mas deixe-me fornecer o principal diretamente no texto do artigo:

#define _USE_MATH_DEFINES #include <cmath> #include <algorithm> #include <limits> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" const float sphere_radius = 1.5; float signed_distance(const Vec3f &p) { return p.norm() - sphere_radius; } bool sphere_trace(const Vec3f &orig, const Vec3f &dir, Vec3f &pos) { pos = orig; for (size_t i=0; i<128; i++) { float d = signed_distance(pos); if (d < 0) return true; pos = pos + dir*std::max(d*0.1f, .01f); } return false; } int main() { const int width = 640; const int height = 480; const float fov = M_PI/3.; std::vector<Vec3f> framebuffer(width*height); #pragma omp parallel for for (size_t j = 0; j<height; j++) { // actual rendering loop for (size_t i = 0; i<width; i++) { float dir_x = (i + 0.5) - width/2.; float dir_y = -(j + 0.5) + height/2.; // this flips the image at the same time float dir_z = -height/(2.*tan(fov/2.)); Vec3f hit; if (sphere_trace(Vec3f(0, 0, 3), Vec3f(dir_x, dir_y, dir_z).normalize(), hit)) { // the camera is placed to (0,0,3) and it looks along the -z axis framebuffer[i+j*width] = Vec3f(1, 1, 1); } else { framebuffer[i+j*width] = Vec3f(0.2, 0.7, 0.8); // background color } } } std::ofstream ofs("./out.ppm", std::ios::binary); // save the framebuffer to file ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(std::max(0, std::min(255, static_cast<int>(255*framebuffer[i][j])))); } } ofs.close(); return 0; } 

A classe de vetor vive no arquivo geometry.h, não o descreverei aqui: primeiro, tudo é trivial, manipulação simples de vetores bidimensionais e tridimensionais (adição, subtração, atribuição, multiplicação por um produto escalar e escalar) e, em segundo lugar, O gbg já o descreveu em detalhes como parte de um curso sobre computação gráfica.

Eu salvo a foto no formato ppm ; Essa é a maneira mais fácil de salvar imagens, embora nem sempre seja a mais conveniente para futuras visualizações.

Então, na função main (), eu tenho dois ciclos: o segundo ciclo simplesmente salva a imagem em disco e o primeiro ciclo passa por todos os pixels da imagem, emite um raio da câmera através desse pixel e procura ver se esse raio se cruza com a nossa esfera.

Atenção, a principal idéia do artigo: se no último artigo analisamos analiticamente a interseção de um raio e uma esfera, agora eu o conto numericamente. A idéia é simples: a esfera tem uma equação da forma x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 = 0; mas, em geral, a função f (x, y, z) = x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 é definida em todo o espaço. Dentro da esfera, a função f (x, y, z) terá valores negativos e, fora da esfera, será positiva. Ou seja, a função f (x, y, z) define a distância (com um sinal!) Para a nossa esfera para o ponto (x, y, z). Portanto, simplesmente deslizamos ao longo do feixe até ficarmos entediados ou a função f (x, y, z) se tornar negativa. A função sphere_trace () faz exatamente isso.

Estágio Três: Iluminação Primitiva


Vamos codificar a iluminação difusa mais simples, quero obter uma imagem assim na saída:



Como no artigo anterior, para facilitar a leitura, dei um passo = um commit. As alterações podem ser vistas aqui .

Para iluminação difusa, não basta calcular o ponto de interseção do feixe com a superfície, precisamos conhecer o vetor normal para a superfície nesse ponto. Recebi esse vetor normal por simples diferenças finitas em nossa função da distância até a superfície:

 Vec3f distance_field_normal(const Vec3f &pos) { const float eps = 0.1; float d = signed_distance(pos); float nx = signed_distance(pos + Vec3f(eps, 0, 0)) - d; float ny = signed_distance(pos + Vec3f(0, eps, 0)) - d; float nz = signed_distance(pos + Vec3f(0, 0, eps)) - d; return Vec3f(nx, ny, nz).normalize(); } 

Em princípio, é claro, já que estamos desenhando uma esfera, o normal pode ser obtido com muito mais facilidade, mas fiz isso com uma reserva para o futuro.

Estágio Quatro: vamos desenhar um padrão em nossa esfera


E vamos desenhar algum tipo de padrão em nossa área, por exemplo, assim:



Para fazer isso, no código anterior, mudei apenas duas linhas!

Como eu fiz isso? Claro, não tenho texturas. Acabei de assumir a função g (x, y, z) = sin (x) * sin (y) * sin (z); é novamente definido em todo o espaço. Quando meu raio cruza a esfera em algum momento, o valor da função g (x, y, z) nesse ponto define a cor do pixel para mim.

A propósito, preste atenção aos círculos concêntricos ao redor da esfera - esses são artefatos do meu cálculo numérico da interseção.

Etapa 5: mapeamento de deslocamento


Por que eu quis desenhar esse padrão? E ele vai me ajudar a desenhar um porco-espinho:



Onde meu padrão era preto, quero abrir um buraco em nossa esfera e onde era branco, pelo contrário, esticar a corcunda.

Para fazer isso, basta alterar as três linhas em nosso código:

 float signed_distance(const Vec3f &p) { Vec3f s = Vec3f(p).normalize(sphere_radius); float displacement = sin(16*sx)*sin(16*sy)*sin(16*sz)*noise_amplitude; return p.norm() - (sphere_radius + displacement); } 

Ou seja, alterei o cálculo da distância para a nossa superfície, definindo-a como x ^ 2 + y ^ 2 + z ^ 2 - r ^ 2 - sin (x) * sin (y) * sin (z). De fato, definimos uma função implícita .

Etapa 6: Outra função implícita


E por que estou avaliando o produto dos senos apenas para pontos na superfície de nossa esfera? Vamos redefinir nossa função implícita assim:

 float signed_distance(const Vec3f &p) { float displacement = sin(16*px)*sin(16*py)*sin(16*pz)*noise_amplitude; return p.norm() - (sphere_radius + displacement); } 

A diferença com o código anterior é muito pequena, é melhor ver o diff . Aqui está o resultado:



Assim, podemos definir componentes desconectados em nosso objeto!

Passo Sete: Ruído Pseudo-Aleatório


A imagem anterior já está começando a parecer remotamente com uma explosão, mas o produto dos senos tem um padrão muito regular. Nós precisaríamos de uma função mais "rasgada", mais "aleatória" ... O ruído de Perlin virá em nosso auxílio. Aqui está algo assim que nos conviria muito melhor do que o produto dos senos:



Como gerar esse ruído é um pouco offtopic, mas aqui está a idéia principal: você precisa gerar imagens aleatórias com diferentes resoluções, suavize-as para obter algo assim:



E então apenas resuma-os:



Leia mais aqui e aqui .

Vamos adicionar um código que gere esse ruído e obter esta imagem:



Observe que no código de renderização eu não mudei nada, apenas a função que "enruga" nossa esfera mudou.

Estágio Oito, Final: Adicione Cor


A única coisa que mudei neste commit é que, em vez de uma cor branca uniforme, apliquei uma cor que depende linearmente da quantidade de ruído aplicada:

 Vec3f palette_fire(const float d) { const Vec3f yellow(1.7, 1.3, 1.0); // note that the color is "hot", ie has components >1 const Vec3f orange(1.0, 0.6, 0.0); const Vec3f red(1.0, 0.0, 0.0); const Vec3f darkgray(0.2, 0.2, 0.2); const Vec3f gray(0.4, 0.4, 0.4); float x = std::max(0.f, std::min(1.f, d)); if (x<.25f) return lerp(gray, darkgray, x*4.f); else if (x<.5f) return lerp(darkgray, red, x*4.f-1.f); else if (x<.75f) return lerp(red, orange, x*4.f-2.f); return lerp(orange, yellow, x*4.f-3.f); } 

Este é um gradiente linear simples entre as cinco cores principais. Bem, aqui está a foto!



Conclusão


Essa técnica de rastreamento de raios é chamada de marcação de raios. A lição de casa é simples: cruze o traçador de raios anterior com o blackjack e os reflexos com a nossa explosão, para que a explosão também ilumine tudo ao seu redor! A propósito, essa explosão carece de translucidez.

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


All Articles