Descifrado de trazado de rayos del tamaño de una postal


"¡Lo hizo de nuevo!": Esto es lo que se me ocurrió cuando miré la parte posterior del folleto de Pixar [1] , completamente lleno de código. Un grupo de construcciones y expresiones fue firmado en la esquina inferior derecha por nada menos que Andrew Kensler. Para aquellos que no lo conocen, diré: Andrew es un programador que inventó un rastreador de rayos del tamaño de una tarjeta de negocios de 1337 bytes en 2009.

Esta vez, a Andrew se le ocurrió algo más voluminoso, pero con un resultado visual mucho más interesante. Desde que terminé de escribir los Libros negros de Game Engine sobre Wolf3D y DOOM , tuve tiempo de aprender el interior de su código críptico. Y casi de inmediato, me fascinaron literalmente las técnicas descubiertas en él. Eran muy diferentes del trabajo anterior de Andrew, basado en un trazador de rayos "estándar". Estaba interesado en aprender sobre la marcha de rayos, las características de la geometría volumétrica constructiva, el trazado de trazado / trazado de Monte Carlo, así como muchos otros trucos que utilizó para comprimir el código en un trozo de papel tan pequeño.



Código fuente




El frente del volante es un anuncio para el departamento de reclutamiento de Pixar. En el reverso, se imprimen 2,037 bytes de código C ++, ofuscados para ocupar la superficie más pequeña posible.

#include <stdlib.h> // card > pixar.ppm #include <stdio.h> #include <math.h> #define R return #define O operator typedef float F;typedef int I;struct V{F x,y,z;V(F v=0){x=y=z=v;}V(F a,F b,F c=0){x=a;y=b;z=c;}V O+(V r){RV(x+rx,y+ry,z+rz);}VO*(V r){RV(x*rx,y*r. y,z*rz);}FO%(V r){R x*r.x+y*r.y+z*rz;}VO!(){R*this*(1/sqrtf(*this%*this) );}};FL(F l,F r){R l<r?l:r;}FU(){R(F)rand()/RAND_MAX;}FB(V p,V l,V h){l=p +l*-1;h=h+p*-1;RL(L(L(lx,hx),L(ly,hy)),L(lz,hz));}FS(V p,I&m){F d=1\ e9;V f=p;fz=0;char l[]="5O5_5W9W5_9_COC_AOEOA_E_IOQ_I_QOUOY_Y_]OWW[WaOa_aW\ eWa_e_cWiO";for(I i=0;i<60;i+=4){V b=V(l[i]-79,l[i+1]-79)*.5,e=V(l[i+2]-79,l [i+3]-79)*.5+b*-1,o=f+(b+e*L(-L((b+f*-1)%e/(e%e),0),1))*-1;d=L(d,o%o);}d=sq\ rtf(d);V a[]={V(-11,6),V(11,6)};for(I i=2;i--;){V o=f+a[i]*-1;d=L(d,ox>0?f\ absf(sqrtf(o%o)-2):(o.y+=oy>0?-2:2,sqrtf(o%o)));}d=powf(powf(d,8)+powf(pz, 8),.125)-.5;m=1;F r=L(-L(B(p,V(-30,-.5,-30),V(30,18,30)),B(p,V(-25,17,-25),V (25,20,25))),B(V(fmodf(fabsf(px),8),py,pz),V(1.5,18.5,-25),V(6.5,20,25))) ;if(r<d)d=r,m=2;F s=19.9-py;if(s<d)d=s,m=3;R d;}IM(V o,V d,V&h,V&n){I m,s= 0;F t=0,c;for(;t<100;t+=c)if((c=S(h=o+d*t,m))<.01||++s>99)R n=!V(S(h+V(.01,0 ),s)-c,S(h+V(0,.01),s)-c,S(h+V(0,0,.01),s)-c),m;R 0;}VT(V o,V d){V h,n,r,t= 1,l(!V(.6,.6,1));for(I b=3;b--;){I m=M(o,d,h,n);if(!m)break;if(m==1){d=d+n*( n%d*-2);o=h+d*.1;t=t*.2;}if(m==2){F i=n%l,p=6.283185*U(),c=U(),s=sqrtf(1-c), g=nz<0?-1:1,u=-1/(g+nz),v=nx*ny*u;d=V(v,g+ny*ny*u,-ny)*(cosf(p)*s)+V( 1+g*nx*nx*u,g*v,-g*nx)*(sinf(p)*s)+n*sqrtf(c);o=h+d*.1;t=t*.2;if(i>0&&M(h +n*.1,l,h,n)==3)r=r+t*V(500,400,100)*i;}if(m==3){r=r+t*V(50,80,100);break;}} R r;}I main(){I w=960,h=540,s=16;V e(-22,5,25),g=!(V(-3,4,0)+e*-1),l=!V(gz, 0,-gx)*(1./w),u(gy*lz-gz*ly,gz*lx-gx*lz,gx*ly-gy*lx);printf("P\ 6 %d %d 255 ",w,h);for(I y=h;y--;)for(I x=w;x--;){V c;for(I p=s;p--;)c=c+T(e ,!(g+l*(xw/2+U())+u*(yh/2+U())));c=c*(1./s)+14./241;V o=c+1;c=V(cx/ox,c. y/oy,cz/oz)*255;printf("%c%c%c",(I)cx,(I)cy,(I)cz);}}// Andrew Kensler 

¿Él incluso trabaja?




Con el código hay una instrucción para su lanzamiento. La idea es redirigir la salida estándar a un archivo. Por extensión, podemos suponer que el formato de salida es un formato de imagen de texto llamado NetPBM [2] .

  $ clang -o card2 -O3 raytracer.cpp
 $ time ./card> pixar.ppm

 2m58.524s reales
 usuario 2m57.567s
 sys 0m0.415s 

Después de dos minutos y cincuenta y ocho segundos [3] , se genera la siguiente imagen. Es sorprendente la poca cantidad de código que se requiere para ello.


Puedes extraer mucho de la imagen de arriba. El grano es un signo obvio de un "trazador de ruta". Este tipo de renderizador difiere del trazado de rayos en que los rayos no se remontan a las fuentes de luz. En este método, se emiten miles de rayos por píxel desde las fuentes y el programa los monitorea, esperando que encuentren la fuente de luz. Esta es una técnica interesante que, mucho mejor que el trazado de rayos, puede manejar la representación de oclusión ambiental, sombras suaves, cáusticos y radiosidad.

Romperemos el código en partes




Pasar la entrada a CLion formatea el código (vea la salida aquí ) y lo divide en partes / tareas más pequeñas.

  #include <stdlib.h> // card> pixar.ppm 
  #include <stdio.h> 
  #include <math.h> 

  #define R return 
  #define el operador O 
  typedef float F; typedef int I; 
  struct V {F x, y, z; V (F v = 0) {x = y = z = v;} V (F a, F b, F 
  c = 0) {x = a; y = b; z = c;} V O + (V r) {RV (x + rx, y + ry, z + rz);} VO * (V r) {RV ( x * rx, y * r. 
  y, z * rz);} FO% (V r) {R x * r.x + y * r.y + z * rz;} VO! () {R * this * (1 / sqrtf (* this% * esto) 
  );}}; 
  FL (F l, F r) {R l <r? L: r;} FU () {R (F) rand () / RAND_MAX;} FB (V p, V l, V h) {l = p 
  + l * -1; h = h + p * -1; RL (L (L (L (lx, hx), L (ly, hy)), L (lz, hz));} 
  FS (V p, I & m) {F d = 1 \ 
  e9; V f = p; fz = 0; char l [] = "5O5_5W9W5_9_COC_AOEOA_E_IOQ_I_QOUOY_Y_] OWW [WaOa_aW \ 
  eWa_e_cWiO "; para (I i = 0; i <60; i + = 4) {V b = V (l [i] -79, l [i + 1] -79) *. 5, e = V (l [ i + 2] -79, l 
  [i + 3] -79) *. 5 + b * -1, o = f + (b + e * L (-L ((b + f * -1)% e / (e% e), 0), 1)) * - 1; d = L (d, o% o);} d = sq \ 
  rtf (d); V a [] = {V (-11.6), V (11.6)}; para (I i = 2; i -;) {V o = f + a [i] * -1; d = L (d, ox> 0? F \ 
  absf (sqrtf (o% o) -2) :( o.y + = oy> 0? -2: 2, sqrtf (o% o)));} d = powf (powf (d, 8) + powf (pz , 
  8) ,. 125) -. 5; m = 1; F r = L (-L (B (p (V, -30, -. 5, -30), V (30,18,30)), B (p, V (-25.17, -25), V 
  (25,20,25))), B (V (fmodf (fabsf (px), 8), py, pz), V (1.5,18.5, -25), V (6.5,20,25))) 
  ; if (r <d) d = r, m = 2; F s = 19.9-py; if (s <d) d = s, m = 3; R d;} 
  IM (V o, V d, V & h, V & n) {I m, s = 
  0; F t = 0, c; para (; t <100; t + = c) if ((c = S (h = o + d * t, m)) <. 01 || ++ s> 99) R n =! V (S (h + V (.01,0 
  ), s) -c, S (h + V (0, .01), s) -c, S (h + V (0,0, .01), s) -c), m; R 0;} 
  VT (V o, V d) {V h, n, r, t = 
  1, l (! V (.6, .6,1)); para (I b = 3; b -;) {I m = M (o, d, h, n); si (! M) se rompe ; if (m == 1) {d = d + n * ( 
  n% d * -2); o = h + d * .1; t = t * .2;} if (m == 2) {F i = n% l, p = 6.283185 * U (), c = U (), s = sqrtf (1-c), 
  g = nz <0? -1: 1, u = -1 / (g + nz), v = nx * ny * u; d = V (v, g + ny * ny * u, -ny) * (cosf (p) * s) + V ( 
  1 + g * nx * nx * u, g * v, -g * nx) * (sinf (p) * s) + n * sqrtf (c); o = h + d * .1; t = t *. 2; if (i> 0 && M (h 
  + n * .1, l, h, n) == 3) r = r + t * V (500,400,100) * i;} if (m == 3) {r = r + t * V (50,80,100) ; descanso;}} 
  R r;} 
  I main () {I w = 960, h = 540, s = 16; V e (-22,5,25), g =! (V (-3,4,0) + e * -1), l =! V (gz, 
  0, -gx) * (1./w), u (gy * lz-gz * ly, gz * lx-gx * lz, gx * ly-gy * lx); printf ("P \ 
  6% d% d 255 ", w, h); para (I y = h; y -;) para (I x = w; x -;) {V c; para (I p = s; p- -;) c = c + T (e 
  ,! (g + l * (xw / 2 + U ()) + u * (yh / 2 + U ()))); c = c * (1./s) + 14. / 241; V o = c + 1; c = V (cx / ox, c. 
  y / oy, cz / oz) * 255; printf ("% c% c% c", (I) cx, (I) cy, (I) cz);}} 
  // Andrew Kensler 

Cada una de las secciones se describe en detalle en el resto del artículo:
- trucos ordinarios, - clase de vector, - código auxiliar, - base de datos, - marcha de rayos, - muestreo, - código principal.

Trucos comunes con #define y typedef




Los trucos comunes están usando #define y typedef para reducir significativamente la cantidad de código. Aquí denotamos F = float, I = int, R = return y O = operator. La ingeniería inversa es trivial.

Clase v




Luego viene la clase V, que renombré a Vec (aunque, como veremos a continuación, también se usa para almacenar canales RGB en formato flotante).

 struct Vec { float x, y, z; Vec(float v = 0) { x = y = z = v; } Vec(float a, float b, float c = 0) { x = a; y = b; z = c;} Vec operator+(Vec r) { return Vec(x + rx, y + ry, z + rz); } Vec operator*(Vec r) { return Vec(x * rx, y * ry, z * rz); } // dot product float operator%(Vec r) { return x * rx + y * ry + z * rz; } // inverse square root Vec operator!() {return *this * (1 / sqrtf(*this % *this) );} }; 

Tenga en cuenta que no hay operador de resta (-), por lo que en lugar de escribir "X = A - B", se utiliza "X = A + B * -1". La raíz cuadrada inversa es útil más adelante para normalizar los vectores.

Función principal




main () es el único carácter que no puede ofuscarse porque es invocado por la función _start de la biblioteca libc. Por lo general, vale la pena comenzar con él, porque será más fácil trabajar de esta manera. Me tomó un tiempo entender el significado de las primeras letras, pero aun así logré crear algo legible.

 int main() { int w = 960, h = 540, samplesCount = 16; Vec position(-22, 5, 25); Vec goal = !(Vec(-3, 4, 0) + position * -1); Vec left = !Vec(goal.z, 0, -goal.x) * (1. / w); // Cross-product to get the up vector Vec up(goal.y * left.z - goal.z * left.y, goal.z * left.x - goal.x * left.z, goal.x * left.y - goal.y * left.x); printf("P6 %d %d 255 ", w, h); for (int y = h; y--;) for (int x = w; x--;) { Vec color; for (int p = samplesCount; p--;) color = color + Trace(position, !(goal + left * (x - w / 2 + randomVal())+ up * (y - h / 2 + randomVal()))); // Reinhard tone mapping color = color * (1. / samplesCount) + 14. / 241; Vec o = color + 1; color = Vec(color.x / ox, color.y / oy, color.z / oz) * 255; printf("%c%c%c", (int) color.x, (int) color.y, (int) color.z); } } 

Tenga en cuenta que los literales flotantes no contienen la letra "f", y la parte fraccionaria se descarta para ahorrar espacio. El mismo truco se usa a continuación, donde se cae la parte entera (flotante x = .5). También es inusual la construcción "for" con una expresión de iteración insertada dentro de la condición de interrupción.

Esta es una función principal bastante estándar para un rayo / trazado de ruta. Aquí se establecen vectores de cámara y se emiten rayos para cada píxel. La diferencia entre el trazador de rayos y el trazador de ruta es que se emiten varios rayos por píxel en el TP, que se desplazan ligeramente al azar. Luego, el color obtenido para cada rayo en un píxel se acumula en tres canales flotantes R, B, G. Al final, se realiza la corrección tonal del resultado del método Reinhardt.

La parte más importante es sampleCount, que teóricamente se puede establecer en 1 para acelerar el renderizado y la iteración. Aquí hay representaciones de muestra con valores de muestra de 1 a 2048.

Encabezado de spoiler


1



2



4 4



8



16



32



64



128



256



512



1024



2048

Código de ayuda




Otra pieza simple de código son las funciones auxiliares. En este caso, tenemos una función trivial min (), un generador de valores aleatorios en el intervalo [0,1] y una prueba mucho más interesante boxTest (), que forma parte del sistema Constructive Solid Geometry (CSG) utilizado para cortar el mundo. CSG se discute en la siguiente sección.

 float min(float l, float r) { return l < r ? l : r; } float randomVal() { return (float) rand() / RAND_MAX; } // Rectangle CSG equation. Returns minimum signed distance from // space carved by lowerLeft vertex and opposite rectangle // vertex upperRight. float BoxTest(Vec position, Vec lowerLeft, Vec upperRight) { lowerLeft = position + lowerLeft * -1; upperRight = upperRight + position * -1; return -min( min( min(lowerLeft.x, upperRight.x), min(lowerLeft.y, upperRight.y) ), min(lowerLeft.z, upperRight.z)); } 

Funciones de geometría volumétrica constructiva.




No hay vértices en el código. Todo se hace usando las funciones CSG. Si no está familiarizado con ellos, simplemente diga que se trata de funciones que describen si la coordenada está dentro o fuera del objeto. Si la función devuelve una distancia positiva, entonces el punto está dentro del objeto. Una distancia negativa indica que el punto está fuera del objeto. Hay muchas funciones para describir diferentes objetos, pero en aras de la simplificación, tomemos por ejemplo una esfera y dos puntos, A y B.

imagen

 // Signed distance point(p) to sphere(c,r) float testSphere(Vec p, Vec c, float r) { Vec delta = c - p; float distance = sqrtf(delta%delta); return radius - distance; } Vec A {4, 6}; Vec B {3, 2}; Vec C {4, 2}; float r = 2.; testSphere(A, C, r); // == -1 (outside) testSphere(B, C, r); // == 1 (inside) 

La función testSphere () devuelve -1 para el punto A (es decir, está afuera) y 1 para B (es decir, está adentro). Las señales a distancia son solo un truco, que le permite obtener dos piezas de información en lugar de una en el caso de un solo valor. También se puede escribir un tipo de función similar para describir un paralelogramo (esto es exactamente lo que se realiza en la función BoxTest).


  // Signed distance point(p) to Box(c1,c2) float testRectangle(Vec p, Vec c1, Vec c2) { c1 = p + c1 * -1; c2 = c2 + position * -1; return min( min( min(c1.x, c2.x), min(c1.y, c2.y)), min(c1.z, c2.z)); } Vec A {3, 3}; Vec B {4, 6}; Vec C1 {2, 2}; Vec C2 {5, 4}; testRectangle(A, C1, C2); // 1.41 (inside) testRectangle(B, C1, C2); // -2.23 (outside) 

Ahora veamos qué sucede si voltea el signo del valor de retorno.


  // Signed distance point(p) to carved box(c1,c2) float testCarveBox(Vec p, Vec c1, Vec c2) { c1 = p + c1 * -1; c2 = c2 + position * -1; return -min( min( min(c1.x, c2.x), min(c1.y, c2.y)), min(c1.z, c2.z)); } Vec A {3, 3}; Vec B {4, 6}; Vec C1 {2, 2}; Vec C2 {5, 4}; testCarveBox(A, C1, C2); // == -1.41 (outside) testCarveBox(B, C1, C2); // == 2.23 (inside) 

Ahora no describimos un objeto sólido, sino que declaramos que todo el mundo es sólido y cortamos el espacio vacío en él. Las funciones se pueden usar como ladrillos de construcción, que cuando se combinan pueden describir formas más complejas. Usando el operador de suma lógica (función min) podemos cortar un par de rectángulos uno encima del otro y el resultado se verá así.


  // Signed distance point to room float testRoom(Vec p) { Vec C1 {2, 4}; Vec C2 {5, 2}; // Lower room Vec C3 {3, 5}; Vec C4 {4, 4}; // Upper room // min() is the union of the two carved volumes. return min(testCarvedBox(p, C1, C2), testCarvedBox(p, C3, C4)); } Vec A {3, 3}; Vec B {4, 6}; testRoom(A, C1, C2); // == -1.41 (outside) testRoom(B, C1, C2); // == 1.00 (inside) 

Si lo piensas bien, se parece a la habitación que estamos estudiando, porque la habitación inferior se expresa exactamente de esta manera, con la ayuda de dos paralelogramos cortados.

Ahora, habiendo dominado el poderoso conocimiento de CSG, podemos volver al código y considerar la función de la base de datos, que es la más difícil de manejar.

 #define HIT_NONE 0 #define HIT_LETTER 1 #define HIT_WALL 2 #define HIT_SUN 3 // Sample the world using Signed Distance Fields. float QueryDatabase(Vec position, int &hitType) { float distance = 1e9; Vec f = position; // Flattened position (z=0) fz = 0; char letters[15*4+1] = // 15 two points lines "5O5_" "5W9W" "5_9_" // P (without curve) "AOEO" "COC_" "A_E_" // I "IOQ_" "I_QO" // X "UOY_" "Y_]O" "WW[W" // A "aOa_" "aWeW" "a_e_" "cWiO"; // R (without curve) for (int i = 0; i < sizeof(letters); i += 4) { Vec begin = Vec(letters[i] - 79, letters[i + 1] - 79) * .5; Vec e = Vec(letters[i + 2] - 79, letters[i + 3] - 79) * .5 + begin * -1; Vec o = f + (begin + e * min(-min((begin + f * -1) % e / (e % e), 0), 1) ) * -1; distance = min(distance, o % o); // compare squared distance. } distance = sqrtf(distance); // Get real distance, not square distance. // Two curves (for P and R in PixaR) with hard-coded locations. Vec curves[] = {Vec(-11, 6), Vec(11, 6)}; for (int i = 2; i--;) { Vec o = f + curves[i] * -1; distance = min(distance, ox > 0 ? fabsf(sqrtf(o % o) - 2) : (oy += oy > 0 ? -2 : 2, sqrtf(o % o)) ); } distance = powf(powf(distance, 8) + powf(position.z, 8), .125) - .5; hitType = HIT_LETTER; float roomDist ; roomDist = min(// min(A,B) = Union with Constructive solid geometry //-min carves an empty space -min(// Lower room BoxTest(position, Vec(-30, -.5, -30), Vec(30, 18, 30)), // Upper room BoxTest(position, Vec(-25, 17, -25), Vec(25, 20, 25)) ), BoxTest( // Ceiling "planks" spaced 8 units apart. Vec(fmodf(fabsf(position.x), 8), position.y, position.z), Vec(1.5, 18.5, -25), Vec(6.5, 20, 25) ) ); if (roomDist < distance) distance = roomDist, hitType = HIT_WALL; float sun = 19.9 - position.y ; // Everything above 19.9 is light source. if (sun < distance)distance = sun, hitType = HIT_SUN; return distance; } 

Puede ver aquí la función de "cortar" el paralelogramo, en el que solo se utilizan dos rectángulos para construir toda la habitación (nuestro cerebro hace el resto, representa las paredes). La escalera horizontal es una función CSG un poco más compleja que utiliza la división del resto. Y finalmente, las letras de la palabra PIXAR están formadas por 15 líneas con un par “origen / delta” y dos casos especiales para curvas en las letras P y R.

Rayo marchando




Con una base de datos de funciones CSG que describe el mundo, es suficiente para omitir todos los rayos emitidos en la función main (). La marcha de rayos usa la función de distancia. Esto significa que la posición de muestreo se desplaza una distancia hacia el obstáculo más cercano.

 // Perform signed sphere marching // Returns hitType 0, 1, 2, or 3 and update hit position/normal int RayMarching(Vec origin, Vec direction, Vec &hitPos, Vec &hitNorm) { int hitType = HIT_NONE; int noHitCount = 0; float d; // distance from closest object in world. // Signed distance marching for (float total_d=0; total_d < 100; total_d += d) if ((d = QueryDatabase(hitPos = origin + direction * total_d, hitType)) < .01 || ++noHitCount > 99) return hitNorm = !Vec(QueryDatabase(hitPos + Vec(.01, 0), noHitCount) - d, QueryDatabase(hitPos + Vec(0, .01), noHitCount) - d, QueryDatabase(hitPos + Vec(0, 0, .01), noHitCount) - d) , hitType; // Weird return statement where a variable is also updated. return 0; } 

La idea de la marcha de rayos basada en la distancia es avanzar una distancia hasta el objeto más cercano. Al final, el haz se acercará tanto a la superficie que puede considerarse un punto de incidencia.


Tenga en cuenta que la marcha de rayos no devuelve una verdadera intersección con la superficie, sino una aproximación. Es por eso que la marcha se detiene en el código cuando d <0.01f.

Poniendo todo junto: muestreo




La investigación del trazado de ruta está casi completa. Nos falta un puente que conecte la función main () con el rayo marcher. Esta última parte, a la que renombré "Trace", es el "cerebro" en el que los rayos rebotan o se detienen, dependiendo de lo que encuentren.

 Vec Trace(Vec origin, Vec direction) { Vec sampledPosition, normal, color, attenuation = 1; Vec lightDirection(!Vec(.6, .6, 1)); // Directional light for (int bounceCount = 3; bounceCount--;) { int hitType = RayMarching(origin, direction, sampledPosition, normal); if (hitType == HIT_NONE) break; // No hit. This is over, return color. if (hitType == HIT_LETTER) { // Specular bounce on a letter. No color acc. direction = direction + normal * ( normal % direction * -2); origin = sampledPosition + direction * 0.1; attenuation = attenuation * 0.2; // Attenuation via distance traveled. } if (hitType == HIT_WALL) { // Wall hit uses color yellow? float incidence = normal % lightDirection; float p = 6.283185 * randomVal(); float c = randomVal(); float s = sqrtf(1 - c); float g = normal.z < 0 ? -1 : 1; float u = -1 / (g + normal.z); float v = normal.x * normal.y * u; direction = Vec(v, g + normal.y * normal.y * u, -normal.y) * (cosf(p) * s) + Vec(1 + g * normal.x * normal.x * u, g * v, -g * normal.x) * (sinf(p) * s) + normal * sqrtf(c); origin = sampledPosition + direction * .1; attenuation = attenuation * 0.2; if (incidence > 0 && RayMarching(sampledPosition + normal * .1, lightDirection, sampledPosition, normal) == HIT_SUN) color = color + attenuation * Vec(500, 400, 100) * incidence; } if (hitType == HIT_SUN) { // color = color + attenuation * Vec(50, 80, 100); break; // Sun Color } } return color; } 

Experimenté un poco con esta función para cambiar el número máximo de reflejos de haz permitidos. El valor "2" le da a las letras un color Vantablack lacado sorprendentemente hermoso [4] .


1


2


3


4 4

Código fuente completamente limpio




Para poner todo junto, creé un código fuente completamente limpio.

Referencias




[1] Fuente: publicación de Twitter de lexfrench el 8 de octubre de 2018.

[2] Fuente: Wikipedia: formato de imagen NetPBM

[3] Fuente: visualización realizada en el MacBook Pro más potente, 2017

[4] Fuente: Wikipedia: Vantablack

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


All Articles