256 linhas de C ++ simples: escrevendo um traçador de raios do zero em poucas horas

Estou publicando o próximo capítulo do meu curso de aula sobre computação gráfica ( aqui você pode ler o original em russo, embora a versão em inglês seja mais recente). Desta vez, o tópico da conversa é desenhar cenas usando o traçado de raios . Como sempre, tento evitar bibliotecas de terceiros, pois isso faz com que os alunos olhem por baixo do capô.

Já existem muitos projetos semelhantes na Internet, mas quase todos mostram programas concluídos que são extremamente difíceis de entender. Aqui, por exemplo, um programa de renderização muito famoso que cabe em um cartão de visita . Um resultado muito impressionante, mas entender esse código é muito difícil. Meu objetivo não é mostrar como posso, mas explicar em detalhes como reproduzir isso. Além disso, parece-me que esta palestra especificamente nem é útil tanto quanto material de treinamento em computação gráfica, mas como uma ferramenta de programação. Mostrarei consistentemente como chegar ao resultado final, começando do zero: como decompor um problema complexo em estágios solucionáveis ​​elementares.

Atenção: apenas olhar para o meu código e ler este artigo com uma xícara de chá na mão não faz sentido. Este artigo foi projetado para você pegar um teclado e escrever seu próprio mecanismo. Ele certamente será melhor que o meu. Bem, ou apenas mude a linguagem de programação!

Então, hoje vou mostrar como desenhar essas figuras:



Etapa 1: salvar a imagem em disco


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. Portanto, a primeira coisa que precisamos fazer é salvar a imagem em disco. Aqui está o código que permite que você faça isso. Deixe-me dar seu arquivo principal:

#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); 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)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; } 

Na função principal, apenas a função render () é chamada, nada mais. O que há dentro da função render ()? Antes de tudo, defino uma imagem como uma matriz unidimensional de valores de buffer de quadros do tipo Vec3f, esses são vetores tridimensionais simples que nos dão a cor (r, g, b) de cada pixel.

A classe de vetor vive no arquivo geometry.h, não vou descrevê-lo aqui: primeiro, tudo é trivial lá, 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. Se você deseja salvar em outros formatos, ainda recomendo conectar uma biblioteca de terceiros, por exemplo, stb . Esta é uma biblioteca maravilhosa: basta incluir um arquivo de cabeçalho stb_image_write.h no projeto, e isso permitirá salvar até em png, mesmo em jpg.

No total, o objetivo deste estágio é garantir que possamos: a) criar uma imagem na memória e escrever diferentes valores de cores; b) salvar o resultado no disco para que possa ser visualizado em um programa de terceiros. Aqui está o resultado:



Etapa dois, a mais difícil: traçar diretamente os raios


Este é o estágio mais importante e difícil de toda a cadeia. Quero definir uma esfera no meu código e mostrá-la na tela sem me preocupar com materiais ou iluminação. É assim que nosso resultado deve ser:



Por conveniência, no meu repositório, há um commit para cada estágio; O Github facilita a visualização de suas alterações. Aqui, por exemplo , o que mudou no segundo commit comparado ao primeiro.

Para começar: o que precisamos para representar uma esfera na memória do computador? Quatro números são suficientes para nós: um vetor tridimensional com o centro da esfera e um escalar que descreve o raio:

 struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } }; 

A única coisa não trivial nesse código é uma função que permite verificar se um determinado raio (originário de orig na direção de dir) cruza com a nossa esfera. Uma descrição detalhada do algoritmo para verificar a interseção do feixe e da esfera pode ser lida aqui ; eu recomendo fazer isso e verificar meu código.

Como funciona o traçado de raios? Muito simples No primeiro estágio, simplesmente cobrimos a imagem com um gradiente:

  for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } 

Agora, para cada pixel, formaremos um raio saindo do centro de coordenadas e passando através do pixel e verificando se esse raio cruza nossa esfera.



Se não houver interseção com a esfera, colocaremos color1, caso contrário color2:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8); // background color } return Vec3f(0.4, 0.4, 0.3); } void render(const Sphere &sphere) {  [...] for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); Vec3f dir = Vec3f(x, y, -1).normalize(); framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere); } }  [...] } 

Nesse ponto, recomendo pegar um lápis e verificar no papel todos os cálculos, tanto a interseção de um raio com uma esfera quanto a varredura de uma imagem com raios. Por precaução, nossa câmera é determinada pelas seguintes coisas:

  • largura da imagem
  • altura da imagem
  • ângulo de visão
  • localização da câmera, Vec3f (0,0,0)
  • direção do olhar, ao longo do eixo z, na direção do infinito negativo

Etapa três: adicionar mais esferas


Tudo o mais difícil está atrás de nós, agora nosso caminho é sem nuvens. Se podemos desenhar uma esfera. então, obviamente, adicione mais um pouco de trabalho não é difícil. Aqui você pode ver as alterações no código, e aqui está o resultado:



Estágio Quatro: Iluminação


Todo mundo é bom em nossa imagem, mas isso não é iluminação suficiente. No restante do artigo, falaremos apenas sobre isso. Adicione algumas fontes de luz pontuais:

 struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; }; 

Considerar a iluminação real é uma tarefa muito, muito difícil; portanto, como todos os outros, enganaremos os olhos desenhando resultados completamente não físicos, mas provavelmente prováveis, plausíveis. Primeira observação: por que está frio no inverno e quente no verão? Porque o aquecimento da superfície da terra depende do ângulo de incidência da luz solar. Quanto mais alto o sol acima do horizonte, mais brilhante a superfície é iluminada. E vice-versa, quanto menor o horizonte, mais fraco. Bem, depois que o sol se põe no horizonte, os fótons não chegam até nós. No que diz respeito às nossas esferas: aqui está o nosso raio emitido pela câmera (sem relação com fótons, preste atenção!) Intersectado com a esfera. Como entendemos como o ponto de interseção é iluminado? Você pode simplesmente olhar para o ângulo entre o vetor normal neste momento e o vetor que descreve a direção da luz. Quanto menor o ângulo, melhor a superfície é iluminada. Para torná-lo ainda mais conveniente, você pode simplesmente levar o produto escalar entre o vetor normal e o vetor de iluminação. Lembro que o produto escalar entre dois vetores aeb é igual ao produto das normas dos vetores pelo cosseno do ângulo entre os vetores: a * b = | a | | b | cos (alfa (a, b)). Se pegarmos vetores de comprimento unitário, o produto escalar mais simples nos dará a intensidade da iluminação da superfície.

Assim, na função cast_ray, em vez de uma cor constante, retornaremos a cor levando em consideração as fontes de luz:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; } 

Veja as alterações aqui , mas o resultado do programa:



Estágio Cinco: Superfícies Brilhantes


Um truque com um produto escalar entre um vetor normal e um vetor de luz aproxima muito bem a iluminação de superfícies foscas, na literatura é chamado de iluminação difusa. O que fazer se quisermos liso e brilhante? Quero obter esta imagem:



Veja como pouca mudança precisa ser feita. Em resumo, as reflexões em superfícies brilhantes são mais brilhantes, menor o ângulo entre a direção da visão e a direção da luz refletida . Bem, é claro que nos cantos contaremos através de produtos escalares, exatamente como antes.

Essa ginástica com superfícies brilhantes e foscas é conhecida como modelo Phong . O wiki tem uma descrição bastante detalhada desse modelo de iluminação; ele lê bem quando comparado em paralelo ao meu código. Aqui está uma figura-chave para entender:


Estágio Seis: Sombras


Por que temos luz, mas sem sombras? Bagunça! Quero esta foto:



Apenas seis linhas de código nos permitem alcançar isso: quando traçamos cada ponto, apenas garantimos que a fonte de luz não cruze os objetos em nossa cena e, se o fizer, a fonte de luz atual passa. Há apenas uma pequena sutileza: mudo o ponto um pouco na direção do normal:

 Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; 

Porque Sim, é apenas que nosso ponto está na superfície do objeto e (excluindo a questão de erros numéricos) qualquer raio desse ponto cruzará nossa cena.

Passo Sete: Reflexões


Isso é inacreditável, mas para adicionar reflexões à nossa cena, precisamos adicionar apenas três linhas de código:

  Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1); 

Veja você mesmo: na interseção com o objeto, contamos simplesmente o raio refletido (a função do cálculo dos solavancos foi útil!) E chamamos recursivamente a função cast_ray na direção do raio refletido. Certifique-se de jogar com a profundidade da recursão , defino-a em quatro, comece do zero, o que mudará na imagem? Aqui está o meu resultado com uma reflexão de trabalho e uma profundidade de quatro:



Estágio Oito: Refração


Aprendendo a contar reflexões, as refrações são contadas exatamente da mesma forma . Uma função que permite calcular a direção do raio refratado (de acordo com a lei de Snell ) e três linhas de código em nossa função recursiva cast_ray. Aqui está o resultado, no qual a bola mais próxima se tornou "vidro", ela refrata e reflete levemente:



Estágio nove: adicione mais objetos


Por que estamos todos sem leite, mas sem leite. Até esse momento, renderizamos apenas esferas, pois esse é um dos objetos matemáticos não triviais mais simples. E vamos adicionar um pedaço do avião. Um clássico do gênero é um tabuleiro de xadrez. Para isso, basta uma dúzia de linhas em uma função que considera a interseção do feixe com a cena.

Bem, aqui está o resultado:



Como prometi, exatamente 256 linhas de código, conte para você !

Estágio dez: lição de casa


Percorremos um longo caminho: aprendemos a adicionar objetos à cena, a considerar uma iluminação bastante complicada. Deixe-me deixar duas tarefas como lição de casa. Absolutamente todo o trabalho preparatório já foi realizado no ramo homework_assignment . Cada trabalho exigirá um máximo de dez linhas de código.

Tarefa 1: Mapa do Ambiente


No momento, se o feixe não cruzar a cena, basta configurá-lo para uma cor constante. E por que, de fato, permanente? Vamos tirar uma foto esférica (arquivo envmap.jpg ) e usá-la como pano de fundo! Para facilitar a vida, vinculei nosso projeto à biblioteca stb para a conveniência de trabalhar com jpegs. Isso deve ser uma renderização como esta:



A segunda tarefa: charlatão!


Podemos renderizar esferas e planos (consulte o tabuleiro de xadrez). Então, vamos adicionar um desenho de modelos triangulados! Escrevi código para ler a grade de triângulos e adicionei uma função de interseção raio-triângulo lá. Agora, adicionar um pato à nossa cena deve ser completamente trivial!



Conclusão


Minha principal tarefa é mostrar projetos que são interessantes (e fáceis!) Para programar, espero realmente poder fazê-lo. Isso é muito importante, pois estou convencido de que um programador deve escrever muito e com bom gosto. Não conheço você, mas a contabilidade pessoal e o sapper, com uma complexidade de código bastante comparável, não me atraem.

Duzentas e cinquenta linhas de raytracing podem ser escritas em poucas horas. Quinhentas linhas de rasterizador de software podem ser dominadas em poucos dias. Da próxima vez, veremos o rakecasting e, ao mesmo tempo, mostrarei os jogos mais simples que meus alunos do primeiro ano escrevem como parte do ensino de programação em C ++. Fique atento!

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


All Articles