"No hay nada peor que una imagen clara de un concepto borroso". - fotógrafo Ansel Adams
En la
primera parte del artículo, creamos un trazador de rayos Whited, capaz de trazar reflejos perfectos y sombras nítidas. Pero nos faltan los efectos de la falta de claridad: reflejo difuso, reflejos brillantes y sombras suaves.
Con base en el
código que
ya tenemos , resolveremos iterativamente
la ecuación de renderizado formulada por James Cajia en 1986 y transformaremos nuestro renderizador en un
trazado de ruta capaz de transmitir los efectos anteriores. Nuevamente usaremos C # para scripts y HLSL para shaders. El código se carga en
Bitbucket .
Este artículo es mucho más matemático que el anterior, pero no se alarme. Trataré de explicar cada fórmula lo más claramente posible. Aquí se necesitan las fórmulas para ver qué está sucediendo y
por qué funciona nuestro procesador, por lo que recomiendo tratar de comprenderlas y, si algo no está claro, haga preguntas en los comentarios al artículo original.
La imagen a continuación se representa utilizando el mapa
Graffiti Shelter del sitio web HDRI Haven. Otras imágenes en este artículo se han renderizado usando la tarjeta
Kiara 9 Dusk .
Ecuación de representación
Desde un punto de vista formal, la tarea del renderizador fotorrealista es resolver la ecuación de representación, que se escribe de la siguiente manera:
L ( x , v e c o m e g a o ) = L e ( x , v e c o m e g a o ) + i n t O m e g a f r ( x , v e c o m e g a i , v e c o m e g a o ) ( vec omegai cdot vecn)L(x, vec omegai)d vec omegai
Analicémoslo. Nuestro objetivo final es determinar el brillo del píxel de la pantalla. La ecuación de renderizado nos da la cantidad de iluminación.
L(x, vec omegao) viniendo de un punto
x (punto de incidencia del haz) en la dirección
vec omegao (la dirección en la que cae el rayo). La superficie misma puede ser una fuente de luz que emite luz.
Le(x, vec omegao) en nuestra dirección La mayoría de las superficies no lo hacen, por lo que solo reflejan la luz del exterior. Por eso se usa la integral. Acumula iluminación proveniente de todas las direcciones posibles del hemisferio.
Omega alrededor de lo normal (por lo tanto, si bien tenemos en cuenta la iluminación que cae sobre la superficie
desde arriba , y no
desde el interior , lo que puede ser necesario para materiales translúcidos).
La primera parte es
fr se llama función de distribución de reflectancia bidireccional (BRDF). Esta función describe visualmente el tipo de material con el que estamos tratando: metal o dieléctrico, oscuro o brillante, brillante o mate. BRDF determina la proporción de iluminación proveniente de
vec omegai que se refleja en la dirección
vec omegao . En la práctica, esto se implementa usando un vector de tres componentes con valores de rojo, verde y azul en el intervalo
[0,1] .
Segunda parte -
( vec omegai cdot vecn) Es el equivalente de
1 cos theta donde
theta - ángulo entre luz incidente y superficie normal
vecn . Imagine una columna de rayos de luz paralelos que caen sobre la superficie perpendicularmente. Ahora imagine el mismo rayo cayendo a la superficie en un ángulo plano. La luz se distribuirá en un área más grande, pero también significa que cada punto de esta área se verá más oscuro. Se necesita coseno para tener esto en cuenta.
Finalmente, la iluminación en sí misma se obtuvo de
vec omegai se determina de forma recursiva utilizando la misma ecuación. Es decir, la iluminación en el punto
x Depende de la luz incidente desde todas las direcciones posibles en el hemisferio superior. En cada una de estas direcciones desde un punto
x hay otro punto
x prime , cuyo brillo nuevamente depende de la luz que cae desde todas las direcciones posibles del hemisferio superior de este punto. Todos los cálculos se repiten.
Esto es lo que sucede aquí, esta es una ecuación integral infinitamente recursiva con un número infinito de regiones hemisféricas de integración. No podemos resolver esta ecuación directamente, pero hay una solución bastante simple.
1 ¡ No te olvides de eso! A menudo hablaremos sobre el coseno, y siempre tendremos en cuenta el producto escalar. Desde
veca cdot vecb= | veca | | vecb | cos( theta) , y estamos tratando con
direcciones (vectores unitarios), entonces el producto escalar
es el coseno en la mayoría de las tareas de gráficos por computadora.
Monte Carlo viene al rescate
Monte Carlo Integration es una técnica de integración numérica que nos permite calcular aproximadamente cualquier integral utilizando un número finito de muestras aleatorias. Además, Monte Carlo garantiza la convergencia a la decisión correcta: cuantas más muestras tomemos, mejor. Aquí está su forma generalizada:
FN approx frac1N sumNn=0 fracf(xn)p(xn)
Por lo tanto, la integral de la función
f(xn) se puede calcular aproximadamente promediando muestras aleatorias en el dominio de integración. Cada muestra se divide por la probabilidad de su selección.
p(xn) . Debido a esto, la muestra elegida con más frecuencia tendrá más peso que la muestra elegida con menos frecuencia.
En el caso de muestras uniformes en el hemisferio (cada dirección tiene la misma probabilidad de ser seleccionado), la probabilidad de muestras es constante:
p( omega)= frac12 pi (porque
2 pi Es el área de superficie de un solo hemisferio). Si juntamos todo esto, obtenemos lo siguiente:
L(x, vec omegao) aproxLe(x, vec omegao)+ frac1N sumNn=0 colorGreen2 pifr(x, vec omegai, vec omegao)( vec omegai cdot vecn)L(x, vec omegai)
La radiación
Le(x, vec omegao) Es solo el valor devuelto por nuestra función
Shade
.
frac1N Ya se está ejecutando en nuestra función
AddShader
. Multiplicación por
L(x, vec omegai) sucede cuando reflejamos el rayo y lo rastreamos aún más. Nuestra tarea es dar vida a la parte verde de la ecuación.
Prerrequisitos
Antes de embarcarse en un viaje, cuidemos algunos aspectos: la acumulación de muestras, las escenas deterministas y la aleatoriedad del sombreador.
Acumulación
Por alguna razón, Unity no me pasa la textura HDR como
destination
en
OnRenderImage
. El formato
R8G8B8A8_Typeless
funcionó para mí, por lo que la precisión rápidamente se vuelve demasiado baja para acumular una gran cantidad de muestras. Para manejar esto, agreguemos
private RenderTexture _converged
al
private RenderTexture _converged
C #. Este será nuestro búfer, acumulando con gran precisión los resultados antes de mostrarlos en la pantalla. Inicializamos /
_target
la textura de la misma manera que
_target
en la función
InitRenderTexture
. En la función
Render
, doble el bliting:
Graphics.Blit(_target, _converged, _addMaterial); Graphics.Blit(_converged, destination);
Escenas deterministas
Al realizar cambios en el renderizado para evaluar el efecto, es útil compararlo con resultados anteriores. Hasta ahora, con cada reinicio del modo Play o la compilación del guión, obtendremos una nueva escena aleatoria. Para evitar esto, agregue
public int SphereSeed
al
public int SphereSeed
C # y la siguiente línea al comienzo de
SetUpScene
:
Random.InitState(SphereSeed);
Ahora podemos configurar manualmente las escenas de semillas. Ingrese cualquier número y active /
RayTracingMaster
nuevamente hasta obtener la escena correcta.
Se usaron los siguientes parámetros para las imágenes de muestra: Semilla de Esfera 1223832719, Radio de Esfera [5, 30], Esferas Máx. 10000, Radio de Colocación de Esfera 100.
Aleatoriedad del sombreador
Antes de comenzar el muestreo estocástico, debemos agregar aleatoriedad al sombreador.
Usaré la cadena canónica que encontré en la red, modificada por conveniencia:
float2 _Pixel; float _Seed; float rand() { float result = frac(sin(_Seed / 100.0f * dot(_Pixel, float2(12.9898f, 78.233f))) * 43758.5453f); _Seed += 1.0f; return result; }
Inicialice
_Pixel
directamente en
CSMain
como
_Pixel = id.xy
para que cada píxel pueda usar diferentes valores aleatorios.
_Seed
inicializa desde C # en la función
SetShaderParameters
.
RayTracingShader.SetFloat("_Seed", Random.value);
La calidad de los números aleatorios generados aquí es inestable. En el futuro, valdría la pena explorar y probar esta función analizando la influencia de los parámetros y comparándola con otros enfoques. Pero por ahora, lo usaremos y esperamos lo mejor.
Muestreo del hemisferio
Comencemos de nuevo: necesitamos direcciones aleatorias distribuidas uniformemente en el hemisferio. Esta tarea no trivial para el alcance completo se describe en detalle en
este artículo de Corey Simon. Es fácil adaptarse al hemisferio. Así es como se ve el código del sombreador:
float3 SampleHemisphere(float3 normal) { // float cosTheta = rand(); float sinTheta = sqrt(max(0.0f, 1.0f - cosTheta * cosTheta)); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); // return mul(tangentSpaceDir, GetTangentSpace(normal)); }
Las direcciones se generan para un hemisferio centrado en el eje Z positivo, por lo que debemos transformarlas para que se centren en la normal deseada. Generamos una tangente y binormal (dos vectores ortogonales a los normales y ortogonales entre sí). Primero, seleccionamos un vector auxiliar para generar la tangente. Para hacer esto, tomamos el eje X positivo y volvemos al Z positivo solo si está normalmente (aproximadamente) alineado con el eje X. Luego, podemos usar el producto vectorial para generar la tangente y luego el binormal.
float3x3 GetTangentSpace(float3 normal) {
Dispersión de Lambert
Ahora que tenemos direcciones aleatorias uniformes, podemos proceder con la implementación del primer BRDF. Para la reflexión difusa, el más utilizado es el Lambert BRDF, que es sorprendentemente simple:
fr(x, vec omegai, vec omegao)= frackd pi donde
kd - Esta es la superficie del albedo. Vamos a insertarlo en nuestra ecuación de representación de Monte Carlo (todavía no tendré en cuenta la emisividad) y veamos qué sucede:
L(x, vec omegao) aprox frac1N sumNn=0 colorBlueViolet2kd( vec omegai cdot vecn)L(x, vec omegai)
Inserte esta ecuación en el sombreador de inmediato. En la función
Shade
, reemplace el código dentro de la
if (hit.distance < 1.#INF)
con las siguientes líneas:
// ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= 2 * hit.albedo * sdot(hit.normal, ray.direction); return 0.0f;
La nueva dirección del haz reflejado se determina utilizando nuestra función de muestras homogéneas del hemisferio. La energía del haz se multiplica por la parte correspondiente de la ecuación que se muestra arriba. Dado que la superficie no emite ninguna iluminación (solo refleja la luz recibida directa o indirectamente del cielo), devolvemos 0. Aquí, no olvide que
AddShader
promedia las muestras, por lo que no debemos preocuparnos
frac1N sum .
CSMain
ya contiene multiplicación por
L(x, vec omegai) (el siguiente haz reflejado), por lo que no nos queda mucho trabajo.
sdot
es una función auxiliar que
sdot
por mí mismo. Simplemente devuelve el resultado del producto escalar con un coeficiente adicional, y luego lo limita al intervalo
[0,1] :
float sdot(float3 x, float3 y, float f = 1.0f) { return saturate(dot(x, y) * f); }
Resumamos lo que nuestro código está haciendo hasta ahora.
CSMain
genera los rayos primarios de la cámara y llama a
Shade
. Al cruzar la superficie, esta función a su vez genera un nuevo haz (uniformemente aleatorio en el hemisferio alrededor de lo normal) y tiene en cuenta el BRDF del material y el coseno en la energía del haz. En la intersección del rayo con el cielo, tomamos muestras de HDRI (nuestra única fuente de iluminación) y devolvemos la iluminación, que se multiplica por la energía del rayo (es decir, el resultado de todas las intersecciones anteriores, comenzando desde la cámara). Esta es una muestra simple que se mezcla con un resultado convergente. Como resultado, el impacto se tiene en cuenta en cada muestra.
frac1N .
Es hora de revisar todo en el trabajo. Dado que los metales no tienen reflejo difuso,
SetUpScene
por ahora en la función
SetUpScene
de un script C # (pero aún así llamamos
Random.value
aquí para preservar el determinismo de la escena):
bool metal = Random.value < 0.0f;
Inicie el modo de reproducción y vea cómo la imagen inicialmente ruidosa se borra y converge en una hermosa representación:
Imagen de espejo de Phong
No está mal para solo unas pocas líneas de código (y una pequeña fracción de las matemáticas). Vamos a refinar la imagen agregando reflejos de espejo usando BRDF de Phong. La formulación original de Fong tenía sus problemas (falta de relaciones y conservación de energía), pero afortunadamente
otras personas los eliminaron . El BRDF mejorado se muestra a continuación.
vec omegar Es la dirección de la luz perfectamente reflejada, y
alpha Es un indicador de Phong que controla la aspereza:
fr(x, vec omegai, vec omegao)=ks frac alpha+22 pi( vec omegar cdot vec omegao) alpha
Un
gráfico bidimensional interactivo muestra cómo se ve el BRDF para Phong cuando
alpha=15 para un rayo incidente en un ángulo de 45 °. Intenta cambiar el valor.
alpha .
Pegue esto en nuestra ecuación de representación de Monte Carlo:
L(x, vec omegao) aprox frac1N sumNn=0 colorbrownks( alpha+2)( v e c o m e g a r c d o t v e c o m e g a o ) a l p h a ( v e c o m e g a i c d o t v e c n ) L ( x , v e c o m e g a i )
Y finalmente, agreguemos esto al Lambert BRDF existente:
L(x, vec omegao) aprox frac1N sumNn=0[ colorBlueViolet2kd+ colormarrónks( alpha+2)( vec omegar cdot vec omegao) alpha]( vec omegai cdot vecn)L(x, vec omegai)
Y así es como se ven en el código junto con la dispersión de Lambert:
Tenga en cuenta que reemplazamos el producto escalar con un poco diferente, pero equivalente (reflejado
omegao en lugar de
omegai ) Ahora vuelva a convertir los materiales metálicos en las funciones
SetUpScene
y compruebe cómo funciona.
Experimentando con diferentes valores
alpha , puede notar un problema: incluso el bajo rendimiento requiere mucho tiempo para la convergencia, y el ruido de alto rendimiento es especialmente sorprendente. Incluso después de unos minutos de espera, el resultado está lejos de ser ideal, lo cual es inaceptable para una escena tan simple.
alpha=15 y
alpha=300 con 8192 muestras se ven así:
¿Por qué sucedió esto? Después de todo, antes teníamos reflexiones ideales tan hermosas (
alpha= infty )! .. El problema es que generamos muestras
homogéneas y les asignamos pesos de acuerdo con BRDF. Con valores altos de Phong, el BRDF es pequeño para todos, pero estas direcciones son muy cercanas a la reflexión perfecta, y es muy poco probable que las seleccionemos al azar utilizando nuestras muestras
homogéneas . Por otro lado,
si realmente cruzamos una de estas direcciones, entonces el BRDF será enorme para compensar todas las otras muestras pequeñas. El resultado es una dispersión muy grande. Los caminos con múltiples reflejos especulares son aún peores y producen ruido visible en las imágenes.
Muestreo mejorado
Para que nuestro trazado de ruta sea práctico, necesitamos cambiar el paradigma. En lugar de desperdiciar muestras preciosas en áreas en las que terminan sin ser importantes (ya que obtienen un BRDF y / o un valor de coseno muy bajos),
generemos muestras importantes .
Como primer paso, devolveremos nuestras reflexiones ideales y luego veremos cómo se puede generalizar esta idea. Para hacer esto, dividimos la lógica de sombreado en reflexión difusa y especular. Para cada muestra, elegiremos aleatoriamente una u otra (dependiendo de la proporción
kd y
ks ) En el caso de la reflexión difusa, nos adheriremos a muestras homogéneas, pero para el especular, reflejaremos explícitamente el haz en la única dirección importante. Dado que ahora se gastarán menos muestras en cada tipo de reflexión, debemos aumentar la influencia en consecuencia, para obtener el mismo valor total:
energy
es una pequeña función auxiliar que promedia los canales de color:
float energy(float3 color) { return dot(color, 1.0f / 3.0f); }
Así que creamos un trazador de rayos Whited más hermoso de la parte anterior, pero ahora con sombreado difuso real (que implica sombras suaves, oclusión ambiental, iluminación global difusa):
Muestra de importancia
Echemos otro vistazo a la fórmula básica de Monte Carlo:
FN approx frac1N sumNn=0 fracf(xn)p(xn)
Como puede ver, dividimos la influencia de cada muestra (muestra) en la probabilidad de elegir esta muestra en particular. Hasta ahora, hemos utilizado muestras homogéneas del hemisferio, por lo que tuvimos una constante
p( omega)= frac12 pi . Como vimos anteriormente, esto está lejos de ser óptimo, por ejemplo, en el caso del Phong BRDF, que es grande en un número muy pequeño de direcciones.
Imagine que podríamos encontrar una distribución de probabilidad que coincida exactamente con la función integrable:
p(x)=f(x) . Entonces sucederá lo siguiente:
FN aprox frac1N sumNn=01
Ahora no tenemos muestras que hagan muy poca contribución. Es menos probable que se seleccionen estas muestras. Esto reducirá significativamente la varianza del resultado y acelerará la convergencia del renderizado.
En la práctica, es imposible encontrar una distribución tan ideal, porque algunas partes de la función integrable (en nuestro caso BRDF × coseno × luz incidente) son desconocidas (esto es más obvio para la luz incidente), pero la distribución de muestras según BRDF × coseno o incluso solo según BRDF ayudará nosotros Este principio se llama muestreo por importancia.
Muestra de coseno
En los siguientes pasos, debemos reemplazar nuestra distribución homogénea de muestras con la distribución de acuerdo con la regla del coseno. No olvide que, en lugar de multiplicar muestras homogéneas por coseno,
reduciendo su influencia, queremos generar un número proporcionalmente
menor de muestras.
Este artículo de Thomas Poole describe cómo hacer esto.
SampleHemisphere
parámetro
alpha
a nuestra función
SampleHemisphere
. La función determina el índice de la selección de cosenos: 0 para una muestra uniforme, 1 para la selección de cosenos o mayor para valores más altos de Phong. En código, se ve así:
float3 SampleHemisphere(float3 normal, float alpha) {
Ahora la probabilidad de cada muestra es igual
p( omega)= frac alpha+12 pi( vec omega cdot vecn) alpha . La belleza de esta ecuación puede no parecer obvia de inmediato, pero un poco más tarde la comprenderá.
Muestra de Lambert por importancia
Para empezar, refinaremos el renderizado de reflexión difusa. En nuestra distribución homogénea, la constante BRDF de Lambert ya se usa, pero podemos mejorarla agregando el coseno. La distribución de probabilidad de la muestra por coseno (donde
alpha=1 ) es igual
frac( vec omegai cdot vecn) pi , que simplifica nuestra fórmula de Monte Carlo para la reflexión difusa:
L(x, vec omegao) aprox frac1N sumNn=0 colorBlueVioletkdL(x, vec omegai)
Esto acelerará un poco nuestro sombreado difuso. Ahora entremos en el problema real.
Muestra de Fongov por importancia
Para Phong BRDF, el procedimiento es similar. Esta vez tenemos el producto de dos cosenos: el coseno estándar de la ecuación de representación (como en el caso de la reflexión difusa), multiplicado por el coseno propio de BRDF. Solo nos ocuparemos de lo último.
Insertemos la distribución de probabilidad de los ejemplos anteriores en la ecuación de Phong. Se puede encontrar una conclusión detallada en
Lafortune y Willems: Uso del modelo de reflectancia de Phong modificado para renderizado basado en la física (1994) :
L(x, vec omegao) aprox frac1N sumNn=0 colorbrownks frac alpha+2 alpha+1( vec omegai cdot vecn)L(x, vec omegai)
Estos cambios son suficientes para eliminar cualquier problema con un alto rendimiento en Phong y hacer que nuestro renderizado converja en un tiempo mucho más razonable.
Materiales
Finalmente, ¡expandamos nuestra generación de escenas para crear valores cambiantes para la suavidad y la emisividad de las esferas! En
struct Sphere
de un script de C #, agregue
public float smoothness
public Vector3 emission
y
public Vector3 emission
. Dado que cambiamos el tamaño de la estructura, debemos cambiar el paso al crear el búfer de cálculo (4 × el número de números flotantes, ¿recuerdas?). Haga que la función
SetUpScene
inserte valores de suavidad y
SetUpScene
.
En el sombreador, agregue ambas variables para
struct Sphere
y
struct RayHit
, y luego
CreateRayHit
en
CreateRayHit
. Y finalmente, establezca ambos valores en
IntersectGroundPlane
(codificado, pegue cualquier valor) e
IntersectSphere
(obteniendo valores de
Sphere
).
Quiero usar valores de suavidad de la misma manera que en el sombreador de Unity estándar, que difiere de un exponente de Fong bastante arbitrario. Aquí hay una buena conversión que se puede usar en la función
Shade
:
float SmoothnessToPhongAlpha(float s) { return pow(1000.0f, s * s); }
float alpha = SmoothnessToPhongAlpha(hit.smoothness);
El uso de la emisividad se realiza devolviendo un valor en
Shade
:
return hit.emission;
Resultados
Respira hondo. relájate y espera hasta que la imagen se convierta en una imagen tan hermosa:
Felicidades Te las arreglaste para atravesar el matorral de expresiones matemáticas. Implementamos un trazado de ruta que realiza sombreado difuso y espejo, aprendimos sobre el muestreo por importancia, aplicando inmediatamente este concepto para que la representación converja en minutos, no en horas o días.
En comparación con el anterior, este artículo fue un gran paso en términos de complejidad, pero también mejoró significativamente la calidad del resultado. Trabajar con cálculos matemáticos lleva tiempo, pero se justifica porque puede profundizar significativamente su comprensión de lo que está sucediendo y le permitirá expandir el algoritmo sin destruir la confiabilidad física.
Gracias por leer! En la tercera parte, nosotros (por un tiempo) dejaremos la jungla de muestreo y sombreado, y regresaremos a la civilización para encontrarnos con los señores Moller y Trumbor. Tendremos que hablar con ellos sobre triángulos.
Sobre el autor: David Curie es el desarrollador de Three Eyed Games, programador del Laboratorio de Ingeniería Virtual de Volkswagen, investigador de gráficos por computadora y músico de heavy metal.