Implementação de um Ray Tracing
Contexto
Este post documenta a solução do exercício de Ray Tracing da disciplina Temas de Computação Visual (IMPA, verão 2026).
Problema 1
A primeira tarefa era implementar a descrição de um cubo e um cilindro para o nosso sistema de ray tracing.
Cubo
Diferente de uma esfera, onde temos uma equação matemática elegante para calcular a interseção, um cubo é formado por seis faces planas. A abordagem ingênua seria testar a interseção com cada uma das seis faces separadamente, mas existe uma solução muito mais eficiente.
O Método Slab: Pensando em 3 Pares de Planos
A ideia central é pensar no cubo como a interseção de três pares de planos paralelos — um par para cada eixo (X, Y e Z). Se o raio atravessa todos os três pares de planos, então ele intercepta o cubo.
Para cada eixo:
- Calculamos em que ponto o raio entra no “slab” (a região entre os dois planos)
- Calculamos em que ponto o raio sai do “slab”
- Encontramos a interseção de todos os intervalos
Implementação Passo a Passo:
Definindo os Limites do Cubo
1 2 3
double half_size = side_length / 2.0; Vector3D min_bound = center - Vector3D(half_size, half_size, half_size); Vector3D max_bound = center + Vector3D(half_size, half_size, half_size);
Como armazenamos apenas o centro e o tamanho do lado, primeiro calculamos os limites mínimos e máximos do cubo.
Testando Cada Eixo
Para o eixo X:
1 2 3
double t_min = (min_bound.x - ray.origin.x) / ray.direction.x; double t_max = (max_bound.x - ray.origin.x) / ray.direction.x; if (t_min > t_max) std::swap(t_min, t_max);
Aqui calculamos os valores de t onde o raio cruza os planos perpendiculares ao eixo X. O swap é necessário porque se o raio aponta na direção negativa, \(t_{min}\) e \(t_{max}\) ficam invertidos.
Verificando a Interseção dos Intervalos
1 2 3
if ((t_min > ty_max) || (ty_min > t_max)) return rec; if (ty_min > t_min) t_min = ty_min; if (ty_max < t_max) t_max = ty_max;
Esta é a parte crucial: verificamos se os intervalos de entrada/saída se sobrepõem. Se não houver sobreposição em qualquer eixo, o raio não atinge o cubo.
Repetimos o processo para os três eixos, e ao final t_min contém a distância até o primeiro ponto de impacto.
Tratando Casos Especiais
1 2 3 4 5
double t = t_min; if (t < CAST_EPSILON) { t = t_max; if (t < CAST_EPSILON) return rec; }
Se \(t_{min}\) é negativo, significa que o ponto de entrada está atrás da câmera. Isso acontece quando a câmera está dentro do cubo — neste caso, usamos \(t_{max}\) (o ponto de saída).
Calculando a Normal da Superfície
O último desafio é determinar qual face foi atingida para calcular a normal correta.
1 2 3 4 5 6 7 8 9 10
Vector3D p_local = rec.point - center; double max_c = std::max({std::abs(p_local.x), std::abs(p_local.y), std::abs(p_local.z)}); if (max_c == std::abs(p_local.x)) { rec.normal = Vector3D(std::copysign(1.0, p_local.x), 0, 0); } else if (max_c == std::abs(p_local.y)) { rec.normal = Vector3D(0, std::copysign(1.0, p_local.y), 0); } else { rec.normal = Vector3D(0, 0, std::copysign(1.0, p_local.z)); }
A ideia é simples: transformamos o ponto de impacto para o espaço local do cubo (com origem no centro). A componente com maior valor absoluto indica qual face foi atingida.
Cubo gerado na resolução 800x600 com 64 amostras
Cilindro
Diferente do cubo, o cilindro combina duas geometrias distintas: um corpo lateral curvo (um tubo) e duas tampas circulares planas. Por isso, a interseção precisa ser testada em separado para cada uma dessas partes, e o resultado final é o impacto mais próximo.
O cilindro é definido pelo seu centro (centro de massa), raio e altura, com o eixo principal alinhado ao eixo Z.
Testando o Corpo Lateral
A ideia é reduzir o problema a 2D: como o tubo é infinito ao longo do eixo Z, basta verificar se o raio intersecta um círculo no plano XY. Ignorando a componente Z, chegamos à equação quadrática clássica:
\[at^2 + bt + c = 0\]onde:
- $a = d_x^2 + d_y^2$
- $b = 2(o_x d_x + o_y d_y)$
- $c = o_x^2 + o_y^2 - r^2$
e $\vec{o} = \text{ray.origin} - \text{center}$ é o vetor do centro do cilindro até a origem do raio.
1
2
3
double a = ray.direction.x * ray.direction.x + ray.direction.y * ray.direction.y;
double b = 2.0 * (oc.x * ray.direction.x + oc.y * ray.direction.y);
double c = oc.x * oc.x + oc.y * oc.y - radius * radius;
Se $a \approx 0$, o raio é perfeitamente paralelo ao eixo Z e nunca bate na lateral — pulamos esse teste. Caso contrário, calculamos o discriminante. Para cada raiz válida ($t > \varepsilon$), verificamos se o ponto de impacto está dentro da altura do cilindro:
1
2
double z = oc.z + t * ray.direction.z;
if (z >= -half_h && z <= half_h) { /* hit válido */ }
A normal lateral aponta radialmente para fora, ignorando o eixo Z:
1
normal_closest = Vector3D(p_local.x, p_local.y, 0).normalize();
Testando as Tampas
Cada tampa é um plano horizontal ($z = \pm\frac{h}{2}$). Calculamos o $t$ de interseção com o plano e verificamos se o ponto está dentro do círculo de raio $r$:
1
2
3
4
5
// Tampa superior
double t_top = (half_h - oc.z) / ray.direction.z;
double px = oc.x + t_top * ray.direction.x;
double py = oc.y + t_top * ray.direction.y;
if (px * px + py * py <= radius * radius) { /* hit válido */ }
As normais das tampas são simplesmente $(0, 0, +1)$ para a superior e $(0, 0, -1)$ para a inferior. Se o raio for perfeitamente perpendicular ao eixo Z ($d_z \approx 0$), pulamos o teste das tampas para evitar divisão por zero.
Consolidando o Resultado
Ao final, simplesmente ficamos com o menor $t$ válido encontrado entre o corpo lateral e as duas tampas — garantindo que reportamos o ponto de impacto mais próximo da câmera.
Cilindro gerado na resolução 800x600 com 64 amostras
Problema 2
Em vez de criarmos equações matemáticas complexas e classes separadas para cada variação de um objeto (por exemplo, um elipsoide, uma caixa rotacionada ou um cilindro distorcido), nós criamos um objeto base centrado na origem e aplicamos transformações ao raio.
A classe ObjectTransform atua como um “wrapper” (envoltório). A ideia central é simples: se quisermos mover um objeto 5 unidades para a direita, deixamos o objeto onde está e movemos o raio 5 unidades para a esquerda.
A nossa classe recebe o objeto base e uma matriz de transformação 3×3, responsável por lidar com rotação, escala e shear (distorção).
A. Levando o Raio para o Espaço do Objeto
O primeiro passo no método hit() é aplicar a transformação no raio que vem da cena (coordenadas do mundo) para que ele passe a enxergar o espaço local do objeto perfeitamente alinhado na origem.
1
2
3
4
5
6
7
// A. Levar o raio do espaço do mundo para o espaço do objeto
Vector3D local_origin = M * ray.origin;
Vector3D local_dir = M * ray.direction;
Ray local_ray(local_origin, local_dir, ray.depth);
// Dispara o raio contra o objeto no seu "mundo" local
HitRecord local_rec = object->hit(local_ray);
Aqui assumimos que M é a matriz de transformação do Mundo para o Objeto.
B. Trazendo o Ponto de Intersecção para o Mundo
Se o local_ray acertar a geometria, o ponto de impacto retornado estará no espaço local. Para que o motor de renderização faça o cálculo correto de luzes e sombras, precisamos devolver esse ponto para as coordenadas da cena. Fazemos isso aplicando a operação inversa à que fizemos no raio:
1
2
// B. Trazer o ponto de intersecção do espaço do objeto para o mundo
rec.point = M_inv * local_rec.point;
A matriz M_inv (inversa de M) atua como a transformação do Objeto para o Mundo.
C.Normais: Por que usar a Inversa Transposta?
No exercício é levantado uma questão: por que a normal não pode ser simplesmente multiplicada pela matriz que leva os pontos do objeto para o mundo (M_inv)?
A resposta reside no fato de que normais não são vetores comuns; elas representam uma direção ortogonal (perpendicular) a uma superfície. Quando aplicamos escalas não-uniformes ou distorções (shear) a um objeto, os ângulos da superfície mudam. Se transformarmos a normal como um vetor qualquer, ela deixará de fazer um ângulo de 90 graus com a superfície esticada.
1
2
// C. Trazer a normal do espaço do objeto para o mundo usando Inversa Transposta
rec.normal = (M_inv_T * local_rec.normal).normalize();
Corrigindo a Distância (t)
Um detalhe que passou despercebido em um primeiro momento ao transformar o raio é que uma matriz com escala irá distorcer o comprimento do vetor direção do raio. Consequentemente, o parâmetro t retornado pelo objeto local estará “esticado” ou “espremido”.
Como o motor precisa do t nas dimensões reais do mundo, é necessário recalcular medindo a distância física entre a origem do raio original e o novo ponto de intersecção convertido:
1
2
// Como a escala deformativa distorce a distância, recalculamos 't'
rec.t = (rec.point - ray.origin).length();
Classe Completa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class ObjectTransform : public Shape
{
public:
std::shared_ptr<Shape> object;
Matrix3x3 M; // matriz: Mundo -> Objeto
Matrix3x3 M_inv; // matriz: Objeto -> Mundo
Matrix3x3 M_inv_T; // matriz: Normal Objeto -> Mundo
ObjectTransform(std::shared_ptr<Shape> obj, const Matrix3x3& world_to_local_matrix)
: Shape("transform"), object(obj), M(world_to_local_matrix){
M_inv = M.inverse();
M_inv_T = M_inv.transpose();
}
HitRecord hit(const Ray& ray) const override {
HitRecord rec;
Vector3D local_origin = M * ray.origin;
Vector3D local_dir = M * ray.direction;
Ray local_ray(local_origin, local_dir, ray.depth);
HitRecord local_rec = object->hit(local_ray);
if (local_rec.hit) {
rec.hit = true;
rec.material = local_rec.material;
rec.uv = local_rec.uv;
rec.point = M_inv * local_rec.point;
rec.normal = (M_inv_T * local_rec.normal).normalize();
rec.t = (rec.point - ray.origin).length();
}
return rec;
}
};
Resultados
Esfera achatada gerada na resolução 800x600 com 64 amostras
Esfera esticada gerada na resolução 800x600 com 64 amostras
Problema 3
Neste problema o objetivo é renderizar superfícies implícitas — objetos definidos não por uma parametrização geométrica explícita (como as faces de um cubo ou a equação de uma esfera), mas pela equação $f(x, y, z) = 0$. Dois objetos foram implementados: o Coração e a superfície de Mitchel.
As equações das superfícies são:
(Mitchel) $f(x, y, z) = 4(x^4 + (y^2 + z^2)^2 + 17x^2(y^2 + z^2)) - 20(x^2 + y^2 + z^2) + 17 = 0$
(Coração) $f(x, y, z) = \left(x^2 + \frac{9}{4}y^2 + z^2 - 1\right)^3 - x^2 z^3 - \frac{9}{80}y^2 z^3 = 0$
Como não existe uma fórmula fechada para encontrar onde o raio cruza essas superfícies, precisamos de uma estratégia numérica.
A Classe Base: ImplicitSurface
A solução foi criar uma classe base abstrata ImplicitSurface, que herda de Shape, e que implementa toda a lógica de interseção. As subclasses (Heart e Mitchel) só precisam sobrescrever o método evaluate(), que retorna o valor de $f(p)$ para um dado ponto $p$.
1
virtual double evaluate(const Vector3D& p) const = 0;
Essa separação de responsabilidades mantém o código limpo e extensível: para adicionar qualquer nova superfície implícita, basta criar uma nova subclasse e definir sua equação.
Estratégia de Interseção: Bounding Box + Ray Marching + Bisseção
A interseção foi implementada em três etapas encadeadas.
1. Bounding Box (Caixa Delimitadora)
O primeiro passo é o mesmo método de slabs já utilizado para o cubo. Cada superfície define uma caixa delimitadora (min_bound, max_bound) que a envolve completamente. O raio só precisa ser avaliado dentro desse intervalo $[t_{in}, t_{out}]$. Raios que nem chegam a tocar a caixa são descartados imediatamente, o que é uma otimização fundamental de performance.
1
2
3
4
double t_in = tmin;
double t_out = tmax;
if (t_out < CAST_EPSILON) return rec; // Caixa atrás da câmera
if (t_in < CAST_EPSILON) t_in = CAST_EPSILON; // Câmera dentro da caixa
2. Ray Marching (Amostragem Uniforme)
Uma vez dentro da caixa, avançamos ao longo do raio em passos uniformes de tamanho step_size, avaliando $f$ em cada ponto. O objetivo não é encontrar a interseção diretamente, mas detectar uma mudança de sinal em $f$:
1
2
3
if (f_prev * f_curr <= 0.0) {
// A superfície está entre t_prev e t_curr!
}
Quando $f$ muda de sinal, sabemos que a superfície zero-level cruzou esse intervalo. O step_size controla a precisão e o custo: passos menores encontram a interseção mais facilmente, mas exigem mais avaliações. Para o Coração e Mitchel, step_size = 0.02 é suficiente.
3. Bisseção (Refinamento da Raiz)
Uma mudança de sinal nos diz onde a superfície está, mas não nos dá a posição exata. Para refinar, aplicamos o método da bisseção (busca binária no intervalo $[t_{prev}, t_{curr}]$):
1
2
3
4
5
6
7
8
9
for (int i = 0; i < 15; ++i) {
double mid = (a + b) / 2.0;
double f_mid = evaluate(ray.point_at_parameter(mid));
if (f_a * f_mid < 0.0) {
b = mid; // Raiz na primeira metade
} else {
a = mid; f_a = f_mid; // Raiz na segunda metade
}
}
Com apenas 15 iterações, a precisão é da ordem de $2^{-15}$ do step_size, o que é mais que suficiente para renderização. Como percorremos o raio da frente para trás, a primeira raiz encontrada é sempre a mais próxima da câmera — então podemos retornar imediatamente ao achar uma interseção válida.
Calculando a Normal: Gradiente Numérico
Em superfícies implícitas, a normal em um ponto $p$ é dada pelo gradiente de $f$:
\[\vec{n} = \nabla f(p) = \left(\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}, \frac{\partial f}{\partial z}\right)\]Como as equações do Coração e de Mitchel são complexas, calculamos o gradiente numericamente usando diferenças centrais, com um $\varepsilon$ minúsculo:
1
2
3
4
5
6
7
Vector3D gradient(const Vector3D& p) const {
double eps = 1e-4;
double dx = evaluate(Vector3D(p.x + eps, p.y, p.z)) - evaluate(Vector3D(p.x - eps, p.y, p.z));
double dy = evaluate(Vector3D(p.x, p.y + eps, p.z)) - evaluate(Vector3D(p.x, p.y - eps, p.z));
double dz = evaluate(Vector3D(p.x, p.y, p.z + eps)) - evaluate(Vector3D(p.x, p.y, p.z - eps));
return Vector3D(dx, dy, dz).normalize();
}
Cada componente da derivada parcial exige duas avaliações de $f$, totalizando 6 chamadas extras a evaluate() por interseção. É caro, mas produz normais suaves e precisas para qualquer superfície implícita — sem nenhuma lógica geométrica específica.
As Subclasses: Heart e Mitchel
Com toda a infraestrutura pronta, as implementações concretas são triviais:
1
2
3
4
5
6
7
8
9
10
11
class Heart : public ImplicitSurface {
public:
Heart() : ImplicitSurface("heart",
Vector3D(-1.5, -1.5, -1.5), Vector3D(1.5, 1.5, 1.5), 0.02) {}
double evaluate(const Vector3D& p) const override {
double x2 = p.x*p.x, y2 = p.y*p.y, z2 = p.z*p.z, z3 = z2*p.z;
double term1 = x2 + (9.0/4.0)*y2 + z2 - 1.0;
return (term1*term1*term1) - x2*z3 - (9.0/80.0)*y2*z3;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
class Mitchel : public ImplicitSurface {
public:
Mitchel() : ImplicitSurface("mitchel",
Vector3D(-2.0, -2.0, -2.0), Vector3D(2.0, 2.0, 2.0), 0.02) {}
double evaluate(const Vector3D& p) const override {
double x2 = p.x*p.x, y2 = p.y*p.y, z2 = p.z*p.z;
double y2z2 = y2 + z2;
double term1 = p.x*p.x*p.x*p.x + y2z2*y2z2 + 17.0*x2*y2z2;
return 4.0*term1 - 20.0*(x2 + y2 + z2) + 17.0;
}
};
Como ObjectTransform já foi implementado no Problema 2, ambas as superfícies podem ser movidas, rotacionadas e escaladas livremente na cena — sem nenhuma modificação adicional.
Resultados
Superfície Coração gerada na resolução 800x600 com 64 amostras
Superfície Mitchel gerada na resolução 800x600 com 64 amostras
Problema 4
Este problema explora um dos fenômenos mais fascinantes do ray tracing: reflexos recursivos. A tarefa foi implementar um material puramente especular (espelho) e criar uma cena clássica de “espelhos infinitos” — dois espelhos paralelos, um de frente para o outro.
4.1 O Material Espelho: MirrorMaterial
Diferente dos materiais difusos (que espalham a luz em todas as direções), um espelho perfeito reflete o raio de entrada em uma única direção bem definida. A implementação herda de Material e sobrescreve o método shade().
A Fórmula do Vetor de Reflexão
Dado um raio incidente com direção $\vec{d}$ e uma normal de superfície $\hat{n}$, o vetor refletido é:
\[\vec{r} = \vec{d} - 2(\vec{d} \cdot \hat{n})\hat{n}\]A intuição geométrica é simples: decompomos $\vec{d}$ nas componentes paralela e perpendicular à normal. A componente paralela é mantida, e a perpendicular (que “mergulha” na superfície) é invertida.
1
Vector3D reflect_dir = (ray_dir - n * 2.0 * ray_dir.dot(n)).normalize();
Recursão Controlada: o Parâmetro depth
Um espelho reflete o que está à sua frente — inclusive outro espelho. Isso cria uma cadeia de raios recursivos que, sem um critério de parada, se tornaria infinita. A solução é o campo depth do raio, que conta quantos “pulos” já foram dados:
1
2
3
4
5
6
7
8
9
if (hit_record.ray.depth < scene.max_depth) {
// ...
Ray reflection_ray(
hit_record.point + reflect_dir * CAST_EPSILON,
reflect_dir,
hit_record.ray.depth + 1 // Incrementa o contador a cada salto
);
// ...
}
Quando depth atinge max_depth, a recursão para e o material retorna preto (Color(0,0,0)). O pequeno deslocamento CAST_EPSILON na origem do raio refletido é essencial para evitar que o raio colida imediatamente com a própria superfície que o originou (auto-interseção).
O reflection_coefficient (entre 0 e 1) controla o quanto de luz é refletida em cada salto. Um espelho perfeito usa 1.0; espelhos reais perdem um pouco de energia a cada reflexão, o que pode ser simulado com valores como 0.95.
Implementação Completa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Color MirrorMaterial::shade(const HitRecord& hit_record, const Scene& scene) const {
Color reflected_color(0, 0, 0);
if (hit_record.ray.depth < scene.max_depth) {
Vector3D ray_dir = hit_record.ray.direction;
Vector3D n = hit_record.normal;
// Calcula o vetor de reflexão
Vector3D reflect_dir = (ray_dir - n * 2.0 * ray_dir.dot(n)).normalize();
// Dispara o raio refletido com depth + 1
Ray reflection_ray(hit_record.point + reflect_dir * CAST_EPSILON,
reflect_dir, hit_record.ray.depth + 1);
HitRecord reflection_hit = scene.hit(reflection_ray);
if (reflection_hit.hit) {
reflected_color = reflection_hit.material->shade(reflection_hit, scene)
* reflection_coefficient;
} else {
// Raio foi para o infinito: retorna a cor do céu
reflected_color = scene.background * reflection_coefficient;
}
}
return reflected_color;
}
4.2 A Cena dos Espelhos Infinitos
Para demonstrar a recursão, a cena foi montada com dois planos espelhados paralelos (em $y = +5$ e $y = -5$), uma câmera posicionada entre eles e uma bola vermelha no centro para termos um objeto concreto se repetindo nos reflexos.
1
2
3
4
5
// Espelho 1: em Y = +5.0, normal aponta para o centro
scene.add(std::make_shared<PlaneUV>(Vector3D(0, 5.0, 0), Vector3D(0,-1.0,0), ...), mat_mirror);
// Espelho 2: em Y = -5.0, normal aponta para o centro
scene.add(std::make_shared<PlaneUV>(Vector3D(0,-5.0, 0), Vector3D(0, 1.0,0), ...), mat_mirror);
Quantos Espelhos Aparecem?
O número de reflexos visíveis é determinado diretamente por scene.max_depth. Cada vez que um raio quica de um espelho para o outro, o contador avança em +1. Quando o limite é atingido, o reflexo mais distante aparece preto.
Na prática, isso significa:
max_depth = 3: apenas 3 reflexos visíveis — o resultado parece “cortado” abruptamente.max_depth = 10: 10 reflexos, criando a ilusão de profundidade infinita.max_depth = 50: quase indistinguível de infinito visualmente, mas com custo computacional muito maior.
O custo cresce porque cada pixel que “enxerga” um espelho dispara uma cadeia de raios. Com dois espelhos paralelos, um único raio pode gerar até max_depth raios secundários — transformando o que seria uma renderização linear em exponencial para cenas mais complexas.
Salão de espelhos com bola vermelha, max_depth = 10, renderizado em 800×600 com 64 amostras
Problema 5
Até agora, todas as cenas foram renderizadas com uma câmera pinhole — um modelo idealizado onde todos os objetos, independentemente da distância, aparecem igualmente nítidos. Na realidade, câmeras físicas possuem lentes com diâmetro finito, o que cria o efeito de Profundidade de Campo (Depth of Field): apenas objetos a uma certa distância ficam em foco, enquanto o restante fica desfocado (o famoso bokeh).
O Modelo Físico: Da Pinhole à Lente Fina
Na câmera pinhole, cada pixel recebe exatamente um raio, disparado da origem da câmera até o ponto correspondente no plano de imagem. Com uma lente real, a ideia é diferente:
- A lente tem um disco de abertura com raio $r$ (o
lens_radius). - Existe um plano focal a uma distância $d_f$ (o
focal_distance) da câmera, onde os raios convergem. - Todos os objetos exatamente no plano focal aparecem nítidos; o desfoque cresce conforme os objetos se afastam desse plano.
Para simular isso em ray tracing, invertemos o raciocínio: em vez de modelar a lente fisicamente, amostramos múltiplos raios por pixel, cada um partindo de um ponto diferente no disco da lente mas convergindo para o mesmo ponto focal. A média dessas amostras produz o desfoque naturalmente.
Implementação Passo a Passo
A câmera foi modificada para receber dois novos parâmetros: lens_radius e focal_distance. Quando lens_radius = 0, o comportamento é idêntico à câmera pinhole original — garantindo retrocompatibilidade com todas as cenas anteriores.
1
2
3
Camera(const Vector3D& eye, const Vector3D& look_at, const Vector3D& up_vec,
double fov, int img_width, int img_height,
double lens_radius = 0.0, double focal_distance = 10.0)
A lógica de profundidade de campo é executada dentro do método ray(), em quatro passos:
Passo 1 — Encontrar o Ponto Focal
Disparamos um raio pinhole como de costume para descobrir onde ele intersecta o plano focal. Esse é o ponto que deve aparecer nítido para qualquer raio amostrado neste pixel:
1
Vector3D focal_point = eye + direction * focal_distance;
Passo 2 — Sortear um Ponto no Disco da Lente
Amostramos um ponto aleatório e uniformemente distribuído dentro de um disco de raio lens_radius. A fórmula garante distribuição uniforme (sem concentração no centro):
1
2
3
4
double r = lens_radius * std::sqrt(dist(generator));
double theta = 2.0 * M_PI * dist(generator);
double dx = r * std::cos(theta);
double dy = r * std::sin(theta);
O truque do $\sqrt{r}$ é necessário porque a área de um anel cresce proporcionalmente ao raio — sem a raiz quadrada, os pontos se concentrariam no centro do disco.
Passo 3 — Mover a Origem para o Disco
Usamos os vetores $\vec{u}$ e $\vec{v}$ da câmera (que formam o plano da lente) para posicionar a nova origem do raio:
1
Vector3D lens_point = eye + u * dx + v * dy;
Passo 4 — Calcular a Nova Direção
O raio parte do ponto amostrado na lente e aponta diretamente para o ponto focal encontrado no Passo 1. Isso garante que todos os raios de um mesmo pixel convergem no mesmo ponto do espaço — criando nitidez naquele ponto e desfoque em todo o resto:
1
2
Vector3D new_direction = (focal_point - lens_point).normalize();
return Ray(lens_point, new_direction);
A Cena de Demonstração
Para evidenciar o efeito, a cena posiciona cinco esferas em fila ao longo do eixo X, a diferentes distâncias da câmera. A câmera foi configurada para focar exatamente na esfera central (verde):
1
2
3
4
5
6
7
8
9
Vector3D eye_pos(8.0, 0.0, 1.5);
Vector3D look_target(0.0, 0.0, 1.0);
double focus_dist = (eye_pos - look_target).length(); // Distância exata até o alvo
scene.add(std::make_shared<Ball>(Vector3D( 4.0, 0.5, 1.0), 1.0), mat_red); // Muito perto — muito desfocada
scene.add(std::make_shared<Ball>(Vector3D( 2.0, 0.0, 1.0), 1.0), mat_blue); // Perto — leve desfoque
scene.add(std::make_shared<Ball>(Vector3D( 0.0, 0.5, 1.0), 1.0), mat_green); // Centro — FOCO PERFEITO
scene.add(std::make_shared<Ball>(Vector3D(-2.0, 0.0, 1.0), 1.0), mat_blue); // Longe — leve desfoque
scene.add(std::make_shared<Ball>(Vector3D(-4.0, 0.5, 1.0), 1.0), mat_red); // Muito longe — muito desfocada
Variando o Tamanho da Lente
O lens_radius é o principal parâmetro criativo. Ele controla diretamente o tamanho do círculo de confusão — o disco que um ponto desfocado projeta no sensor.
lens_radius | Efeito |
|---|---|
0.0 | Câmera pinhole clássica — tudo em foco |
0.1 | Desfoque suave e realista |
0.2 | Separação clara entre o plano focal e o restante |
0.5 | Desfoque extremo |
É importante notar que profundidade de campo exige muitas amostras por pixel para produzir um resultado limpo. Com poucas amostras, o desfoque aparece como ruído granulado em vez de um borrão suave — porque cada amostra usa um ponto diferente no disco da lente, e a média converge apenas com volume suficiente de raios.
lens_radius = 0.0: imagem gerada com 100 amostras

