Unity GPU Path Tracing - Parte 2

imagen

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

imagen

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) { //       float3 helper = float3(1, 0, 0); if (abs(normal.x) > 0.99f) helper = float3(0, 0, 1); //   float3 tangent = normalize(cross(normal, helper)); float3 binormal = normalize(cross(normal, tangent)); return float3x3(tangent, binormal, 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:

 //    ray.origin = hit.position + hit.normal * 0.001f; float3 reflected = reflect(ray.direction, hit.normal); ray.direction = SampleHemisphere(hit.normal); float3 diffuse = 2 * min(1.0f - hit.specular, hit.albedo); float alpha = 15.0f; float3 specular = hit.specular * (alpha + 2) * pow(sdot(ray.direction, reflected), alpha); ray.energy *= (diffuse + specular) * sdot(hit.normal, ray.direction); return 0.0f; 

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:

 //       hit.albedo = min(1.0f - hit.specular, hit.albedo); float specChance = energy(hit.specular); float diffChance = energy(hit.albedo); float sum = specChance + diffChance; specChance /= sum; diffChance /= sum; //     float roulette = rand(); if (roulette < specChance) { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = reflect(ray.direction, hit.normal); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction); } else { //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal); ray.energy *= (1.0f / diffChance) * 2 * hit.albedo * sdot(hit.normal, ray.direction); } return 0.0f; 

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

imagen

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) { //  ,      float cosTheta = pow(rand(), 1.0f / (alpha + 1.0f)); float sinTheta = sqrt(1.0f - cosTheta * cosTheta); float phi = 2 * PI * rand(); float3 tangentSpaceDir = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); //      return mul(tangentSpaceDir, GetTangentSpace(normal)); } 

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)


 //   ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(hit.normal, 1.0f); ray.energy *= (1.0f / diffChance) * hit.albedo; 

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)


 //   float alpha = 15.0f; ray.origin = hit.position + hit.normal * 0.001f; ray.direction = SampleHemisphere(reflect(ray.direction, hit.normal), alpha); float f = (alpha + 2) / (alpha + 1); ray.energy *= (1.0f / specChance) * hit.specular * sdot(hit.normal, ray.direction, f); 

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:

imagen

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.

imagen

Source: https://habr.com/ru/post/es412685/


All Articles