Post

Implementação de um Ray Tracing

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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).

  5. 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 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 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

Cilindro Esfera achatada gerada na resolução 800x600 com 64 amostras

Cilindro 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:

  1. (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$

  2. (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

Coração Superfície Coração gerada na resolução 800x600 com 64 amostras

Mitchel 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.

Espelhos Infinitos 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_radiusEfeito
0.0Câmera pinhole clássica — tudo em foco
0.1Desfoque suave e realista
0.2Separação clara entre o plano focal e o restante
0.5Desfoque 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.

Depth of Field - lens 0.0 lens_radius = 0.0: imagem gerada com 100 amostras

Depth of Field - lens 0.2 lens_radius = 0.2: imagem gerada com 100 amostras

Depth of Field - lens 0.5 lens_radius = 0.5: imagem gerada com 100 amostras

This post is licensed under CC BY 4.0 by the author.