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:

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.