O que estou pensando agora: Interfaces adaptativas em sistemas web complexos e mudanças em tempo real com LLMs

← Todos os escritos

Escritos

Computação gráfica em Java: um rasterizador 3D

Neste artigo

Considere três pontos no espaço. Cada ponto tem coordenadas x, y e z. Para desenhar o triângulo formado por eles, precisamos transformar essas coordenadas em posições na imagem e decidir quais pixels fazem parte dele.

Quando dois triângulos ocupam o mesmo pixel, também precisamos saber qual está na frente. A ordem em que chamamos o método de desenho não deveria decidir isso.

O exemplo usa Java 21 e gera um PNG. O Java fica responsável por armazenar e gravar a imagem. Projeção, recorte e preenchimento dos triângulos são calculados no código, sem uma biblioteca 3D.

Código completo: Rasterizador.java. Salve o arquivo em uma pasta e execute:

java Rasterizador.java --test
java Rasterizador.java

A segunda execução grava triangulos.png na pasta atual. O resultado é este:

Dois triângulos, um roxo e um amarelo, com superfícies que se cruzam e alternam a visibilidade.

Coordenadas e câmera

A câmera está na origem e olha na direção de +Z. X cresce para a direita e Y cresce para cima. Então, um ponto com z = 3 está na frente da câmera; um ponto com z = -3 está atrás dela.

Essa é a convenção deste exemplo. Outras implementações podem usar -Z como direção da câmera.

record V3(double x, double y, double z) {}
record Tela(double x, double y, double invZ) {}
record Triangulo(V3 a, V3 b, V3 c, int cor) {}

O triângulo tem três vértices e uma cor. Tela guarda a posição projetada e o inverso da distância no eixo Z. Esse último valor vai ser usado no cálculo da profundidade.

Rotação e translação

Os vértices começam em coordenadas locais. Antes de projetar, o exemplo rotaciona cada um em torno de Y e move o triângulo para a frente da câmera.

static V3 rotacionarY(V3 v, double angulo) {
    double cos = Math.cos(angulo), sin = Math.sin(angulo);
    return new V3(
        v.x * cos + v.z * sin,
        v.y,
        -v.x * sin + v.z * cos
    );
}

static V3 mover(V3 v, double x, double y, double z) {
    return new V3(v.x + x, v.y + y, v.z + z);
}

angulo é informado em radianos. No código completo, a rotação é 0.2 e a translação em Z é 3.

A ordem dessas operações altera o resultado. Rotacionar primeiro mantém o giro em torno da origem local. Se movermos primeiro, a rotação também altera a posição que foi adicionada.

Para desenhar vários quadros, podemos variar o ângulo e limpar os buffers antes de cada quadro. Aqui a saída é uma imagem estática, para observar os cálculos sem incluir janela, eventos ou temporização.

Projeção em perspectiva

Um objeto distante ocupa menos espaço na imagem. Podemos obter esse comportamento dividindo x e y por z.

Tela projetar(V3 v) {
    return new Tela(
        largura / 2.0 + focal * v.x / v.z,
        altura / 2.0 - focal * v.y / v.z,
        1.0 / v.z
    );
}

As metades da largura e da altura colocam a origem no centro da imagem. O sinal negativo em Y existe porque, na imagem, as linhas crescem para baixo.

focal converte a proporção em pixels. Para um campo de visão vertical de 60 graus:

focal = altura / (2.0 * Math.tan(Math.toRadians(60) / 2.0));

Considere a metade superior do campo de visão. A tangente do meio ângulo é a razão entre a metade da altura projetada e a distância focal. Isolando essa distância, chegamos à expressão acima.

O mesmo focal é usado nos dois eixos. Assim, mudar a largura aumenta a área visível na horizontal, sem esticar o objeto. Os pixels são tratados como quadrados.

Se x = 1, dobrar z reduz pela metade o deslocamento horizontal em relação ao centro. Mas, se z for zero, a divisão deixa de ser válida. Precisamos resolver isso antes da projeção.

Recorte perto da câmera

O plano próximo, chamado de near plane, foi definido em z = 0.1. Só projetamos a parte do triângulo que está desse plano para a frente.

Descartar o triângulo inteiro quando apenas um vértice está atrás do plano faz a superfície desaparecer de uma vez. O recorte preserva a parte visível.

Para uma aresta que cruza o plano, calculamos a interseção:

V3 intersecao(V3 a, V3 b) {
    double t = (near - a.z) / (b.z - a.z);
    return new V3(
        a.x + t * (b.x - a.x),
        a.y + t * (b.y - a.y),
        near
    );
}

t indica a posição na aresta, entre a e b. O método só é chamado quando os dois pontos estão em lados diferentes do plano.

O recorte percorre as arestas, mantém os vértices que estão dentro e adiciona as interseções. Um triângulo pode virar um quadrilátero. Nesse caso, desenhamos dois triângulos usando o primeiro vértice como ponto comum.

O código completo faz esse recorte antes de chamar projetar(). Limitar o retângulo de desenho à imagem, mais adiante, evita escrever fora do buffer, mas não substitui o recorte em Z.

Quais pixels pertencem ao triângulo?

Depois da projeção, o problema passa a ser 2D. Para cada pixel dentro do retângulo que contém o triângulo, verificamos se seu centro está dentro da superfície.

A função abaixo calcula um produto vetorial em duas dimensões:

static double borda(Tela a, Tela b, double x, double y) {
    return (b.x - a.x) * (y - a.y)
         - (b.y - a.y) * (x - a.x);
}

O sinal informa de qual lado da aresta o ponto está. Dividindo o resultado pela área orientada do triângulo, obtemos os pesos baricêntricos:

double area = borda(a, b, c.x, c.y);
if (Math.abs(area) < 1e-10) return;

double px = x + 0.5, py = y + 0.5;
double wa = borda(b, c, px, py) / area;
double wb = borda(c, a, px, py) / area;
double wc = 1.0 - wa - wb;
if (wa < 0 || wb < 0 || wc < 0) continue;

wa, wb e wc representam a contribuição de cada vértice para aquele ponto. Dentro do triângulo, os pesos são não negativos e somam 1.

O teste de área descarta triângulos degenerados. Três pontos alinhados não formam uma superfície para preencher e causariam uma divisão por zero.

A amostra fica em x + 0.5 e y + 0.5, no centro do pixel. O retângulo percorrido é limitado por 0, largura - 1 e altura - 1.

Profundidade por pixel

Um depth buffer guarda a profundidade do fragmento visível em cada posição da imagem. Começamos com infinito, já que nenhum triângulo foi desenhado.

Arrays.fill(profundidade, Double.POSITIVE_INFINITY);

Neste exemplo, a profundidade é a coordenada z no espaço da câmera. Menor z significa mais perto.

Depois da projeção em perspectiva, interpolar z diretamente com os pesos da tela produz um resultado incorreto. Interpolamos 1/z e invertemos o resultado:

double invZ = wa * a.invZ + wb * b.invZ + wc * c.invZ;
double z = 1.0 / invZ;
int indice = y * largura + x;

if (z < profundidade[indice]) {
    profundidade[indice] = z;
    imagem.setRGB(x, y, cor);
}

Considere dois vértices em profundidades 2 e 8. No meio da aresta projetada, os pesos são iguais. O cálculo fica 1 / (0.5 / 2 + 0.5 / 8) = 3.2. A média direta seria 5, que corresponde a outro ponto da aresta no espaço.

Essa diferença aparece quando os triângulos se cruzam. Um pode estar na frente em alguns pixels e atrás em outros. Ordenar cada triângulo pela profundidade média não resolve essa situação.

O que verificar

O argumento --test verifica oclusão, ordem de desenho, recorte, projeção no centro, triângulo degenerado e interpolação da profundidade. Para triângulos sobrepostos em profundidades diferentes, inverter a ordem de desenho deve gerar os mesmos pixels.

Se duas superfícies tiverem exatamente a mesma profundidade, o teste com < mantém a primeira. Superfícies quase coincidentes também podem sofrer com precisão numérica.

O exemplo usa cor constante e uma amostra por pixel. Não calcula iluminação, textura, transparência ou antialiasing. Também não implementa a regra top-left para decidir qual triângulo possui uma borda compartilhada. Uma malha maior precisa desse tratamento para evitar falhas e ambiguidades nas bordas.

Um próximo experimento é atribuir uma cor diferente a cada vértice. Para interpolar atributos definidos no espaço 3D sob perspectiva, o cálculo precisa considerar atributo/z e 1/z, em vez de apenas somar os atributos com os pesos da tela.

Referências