1 Introdução
Esse artigo foi inspirado por Image-based Lighting approaches and parallax-corrected cubemap
Esse artigo assume que você já sabe como carregar e gerar cubemaps.
2 Reflexo Clássico de Cubemap
Então, qual é o jeito padrão de fazer reflexos de cubemap? bom, é simples: calcule a direção de view da posição da câmera para a posição do fragmento, reflita usando a função "reflect" com a normal do fragmento e pronto!
vec3 viewDirection = normalize(worldPosition - viewPosition);
vec3 reflectedDirection = reflect(viewDirection, normal);
vec3 reflection = texture(reflectionCubemap, reflectedDirection).rgb;
Como pode ver, o reflexo parece estar se movendo conosco, como se estivesse bem longe como o céu, que seria bom se isso fosse um cubemap de céu mas isso é um cubemap local gerado a partir de um local específico.
Dica: Quando estiver gerando cubemaps desative todos os reflexos de specular de luzes e outros cubemaps pois estes dependem da direção da view e poderia ficar muito esquisito olhando de outros ângulos, cubemaps geralmente só devem conter luz difusa.
Se desenharmos isto no paint.net, é isso que temos:
Como estamos fazendo sampling do cubemap usando apenas a direção refletida "R" sem levar a posição do cubemap em conta (denotada pela bola), o cubemap acaba fazendo sampling da posição incorreta denotada por "P?" ao invés da posição correta denotada por "P" (que não temos) e é por isso que vemos grande parte do teto ao invés das paredes no vídeo.
3 Parallax Corrected Cubemap
Um Parallax Corrected Cubemap resolve esse problema encontrado o P correto utilizando raytracing e então fazendo sampling do cubemap usando a direção da posição do cubemap para P (a direção C).
4 Raytracing
Não deixe a palavra "raytracing" te assustar, mesmo se pudéssemos gerar um BVH de nossa cena e usar ele para encontrar P, seria muito lento e complicado para se usar, ao invés nós vamos simplificar nossa cena para um cubo definido pelo usuário e então fazer a intersecção de um raio por dentro dele.
4.1 Em Código
Só precisamos de uma pequena função para fazer a intersecção de um raio com uma caixa por dentro.
Usando o código no artigo mencionado na introdução, eu fiz uma versão mais genérica dele e também adicionei uma checagem de aabb para evitar que o raio faça intersecção por fora da caixa pois percebi que causa vários problemas gráficos em alguns cenários.
//P is rayOrigin + (rayDirection * t)
//where t is the return value
//returns -1.0 if the ray is outside the box
float intersectRayInsideBox(vec3 rayOrigin, vec3 rayDirection, mat4 worldToLocal) {
vec3 localOrigin = (worldToLocal * vec4(rayOrigin, 1.0)).xyz;
vec3 aabbCheck = abs(localOrigin);
if (max(aabbCheck.x, max(aabbCheck.y, aabbCheck.z)) > 1.0) {
return -1.0;
}
vec3 localDirection = mat3(worldToLocal) * rayDirection;
vec3 firstPlane = (vec3(-1.0) - localOrigin) / localDirection;
vec3 secondPlane = (vec3(1.0) - localOrigin) / localDirection;
vec3 furthestPlane = max(firstPlane, secondPlane);
return min(furthestPlane.x, min(furthestPlane.y, furthestPlane.z));
}
Mesmo se a função foi feita para um aabb, ela na verdade suporta qualquer caixa rotacionada ou escalada, já que aplicamos o inverso da matriz da caixa ao raio (a matriz worldToLocal) ao invés da caixa que é sempre um AABB 2x2x2 na perspectiva da função.
No lado da CPU, só precisamos calcular a matriz worldToLocal e enviá-la para o shader junto com a posição do cubemap.
Vector3f position = new Vector3f(0f, 2f, 0f);
Quaternionf rotation = new Quaternionf(0f, 0f, 0f, 1f);
Vector3f halfExtents = new Vector3f(5f, 2f, 5f);
Matrix4f worldToLocal = new Matrix4f()
.translate(position)
.rotate(rotation)
.scale(halfExtents)
.invert()
;
Vector3f cubemapPosition = new Vector3f(0f, 1.65f, 0f);
"position" é a posição da caixa, "rotation" é a rotação da caixa definida por um quaternion, "halfExtents" é a metade das extensões da caixa (uma caixa de comprimento 10,0 tem uma meia extensão de 5.0 no eixo X) e "cubemapPosition" é a posição do cubemap de onde ele foi gerado.
uniform mat4 cubemapWorldToLocal;
uniform vec3 cubemapPosition;
Como a intersecção só funciona se o fragmento estiver dentro da caixa, você deve fazer a caixa um pouco maior do que a área que ela representa.
E então, só precisamos fazer a intersecção do raio da posição do fragmento usando a direção refletida para encontrar P e então fazer sample do cubemap usando a direção corrigida.
vec3 viewDirection = normalize(worldPosition - viewPosition);
vec3 reflectedDirection = reflect(viewDirection, normal);
vec3 reflection = vec3(0.0);
float t = intersectRayInsideBox(worldPosition, reflectedDirection, cubemapWorldToLocal);
if (t >= 0.0) {
vec3 P = worldPosition + (reflectedDirection * t);
vec3 correctedDirection = normalize(P - cubemapPosition);
reflection = texture(reflectionCubemap, correctedDirection).rgb;
}
5 Resultado Final
E esse é o resultado final: