Una nueva Flashparty, una nueva Compo y una Intro 4k que supo llegar al primer puesto en la competición casi anual latinoamericana de la demoscene. La razón por la cual publicamos este artículo es para cebar a los demosceners y potenciales demosceners latinoamericanos a crear producciones como esta y al explicar el workflow, demistificar la complejidad, demostrando que poniendo todas las piezas juntas del rompecabezas, es posible. No es fácil claro, pero ni a palos imposible.

Nuestro grupo, BEHELiT somos:

Acá podes descargar la Intro 4k en este mismo link –> Intro 4k. Para que se vea más o menos rápido, necesitás una RTX.

Una Breve Introducción

Generalizando una bocha, una Demo es un programa - que cuando es extremadamente pequeño - que se renombra a intro como la que vamos a discutir en este artículo - que produce presentaciones audiovisuales en tiempo real. El propósito de estas producciones es demostrar habilidades en programación, arte visual y música. Estas producciones u otras del mundo de la demoscene (gráficos, música, videos, videojuegos) son compartidas, votadas y publicadas online en festivales o conferencias llamados "demoparties" como por ejemplo, la Flashparty.

En las categorías que hay en estas competiciones, esta producción participó en la categoría "Intro 4K" donde la limitación está en el tamaño del ejecutable, que en este caso es 4096 Bytes.

Aquí, logramos meter en un programa de este tamaño (algo muchísimo más pequeño que el tamaño total de este texto que estás leyendo) gráficos en tiempo real, música y lo necesario para poder coordinar todo en un ejecutable .EXE de Windows.

Para ponerlo en perspectiva, este ejecutable de 4096 Bytes que comentamos en este artículo, produjo ~1 Gigabyte de data de audio y video en 2 minutos de ejecución. Esta captura luego fue subida a Youtube.

Aquí abajo, el video:

El Framework

Nuestro framework está compuesto de las siguientes herramientas:

La Toolchain

El Visual Studio nos habilita a tener todos los headers necesarios para poder compilar un ejecutable y hablar con la API de Windows. Adicionalmente, necesitamos instalar el Windows Universal CRT SDK como dependencia para el proyecto. Este SDK nos permite tener un runtime universal para poder ejecutar programas hechos con C y C++ y nos habilita tener acceso a las funciones estandard a lo largo de diferentes versiones de Windows sin necesidad de recompilar programas dependiendo de cada una de las versiones del sistema operativo.

Con cmake no solo unimos todo el proyecto si que también no los stages de compilación, linking y compresión del ejecutable final. Esto nos permite tener diferentes targets donde en el proceso de desarrollo de la intro, podemos seleccionar tener una versión final con todas las técnicas de compresión y minificado o bien una versión de desarrollo que compila rápido. cmake se integra con Visual Studio relativamente fácil y además estoy más acostumbrado a laburar con cmake antes que con el sistema de Makefiles que probee Visual Studio que es mucho más visual (heh) que con código.

Necesitamos además nasm porque es una de las dependencias de sointu, que es el sintetizador modular sustractivo de audio, forkeado de 4klang que usamos para esta producción. Más adelante en este artículo entro en detalles.

Luego viene crinkler que es un compresor de ejecutables con una vuelta de tuerca: no solamente comprime el ejecutable sino también que es un linker. Esto significa que en vez de usar el linker que viene por defecto en Visual Studio, nos metemos en el medio del proceso de compilación, descartamos el linker de microsoft y usamos este, que antes de insertar los .obj, reorganiza su data, las comprime, crea hashtables y organiza las secciones, comprimiéndolas entre otras cosas.

Una de las partes del código, contiene una shader (en verdad, dos shaders, el principal y el de postprocessing) en un directorio donde lo vamos codeando. Pero no optimizamos (mucho) en el desarrollo el nombre de las variables o sus #defines, ya que reducir a mano el nombre de las variables (como se suele ver seguido en shadertoy) hace el código bastante ilegible. Entonces usamos una herramienta que hace esto por nosotros en el proceso de compilación que se llama Shader Minifier. No hace algo muy distinto a cualquier minificador de código de Javascript o CSS, pero especializado en código HLSL y GLSL. Yo uso GLSL como lenguaje para escribir nuestros shaders.

Aqui debajo una sección del fragment shader de la intro sin minificar:

Hit march(Ray ray) {
    Hit hit = noHit();

    for(int i = 0; i < DMS; i++) {
    	vec3 pivot = ray.origin + ray.direction * hit.distance; 
        float distance = sdfScene(pivot);
        hit.distance += distance; 
        if(hit.distance > MAXDISTANCE 
            || distance < .1 * TENTH_EPSILON) 
        break;
    }

    hit.point += ray.origin + ray.direction * hit.distance;
    hit.normal = computeNormal(hit.point);
    hit.material = sdfTagMaterial(ray, hit.distance, hit.point);

    return hit;
}

Y la misma función minificada:

 "Hit d(Ray v)"
 "{"
   "Hit m=s();"
   "for(int f=0;f<DMS;f++)"
     "{"
       "vec3 y=v.origin+v.direction*m.distance;"
       "float n=D(y);"
       "m.distance+=n;"
       "if(m.distance>MAXDISTANCE||n<.1*TENTH_EPSILON)"
         "break;"
     "}"
   "m.point+=v.origin+v.direction*m.distance;"
   "m.normal=a(m.point);"
   "m.material=D(v,m.distance,m.point);"
   "return m;"
 "}"

Se puede ver también que en el struct del HIT todavía hay espacio para manualmente reducir los nombres de sus propiedades. También sus #defines que por algún motivo no son reemplazados. Pero en esta iteración de desarrollo, preferimos legibilidad ante todo (!) :)

Como los ciclos de compilación y prueba del tamaño final de la producción dependiendo en que máquina compilemos lleva de 5 a 10 minutos, para probar nuestros shaders usamos bonzomatic. Cuando uno esta satisfecho con los cambios del shader, luego se integra a la producción y se compila con varios flags que tengo armados en cmake donde activo y desactivo por targets que van desde "no me comprimas nada, dejá todo como está asi a lo bestia" hasta "sacále todo el jugo que puedas a la compresión". Este último tarda muchísimo, entonces para tener una compilación del shader GLSL rápida, uso bonzomatic que me da feedback inmediato pulsando F5 y después integro con pequeños cambios el shader escrito ahí a la intro.

La Música

Acá es donde entra Madbit o como le decimos en BEHELiT, El Bitio. Sointu es un sintetizador modular sustractivo, especializado para producciones como esta. Sointu viene un con un Tracker y con el sintetizador y un compilador del archivo trackeado en un bytecode que el sintetizador puede interpretar y hacerlo sonar en un espacio ínfimo de código.

Sointu Track de MadbitSointu Track de Madbit

Cada instrumento es definido por sus propiedades para poder ser sintetizado, como un filtro ADSR (attack, decay, sustain, release), el gain, filtros, delay, envelopes, osciladores, etc… Después, se configuran las notas de cada uno de los instrumentos con sus notas en un patrón. Y esos patrones se componen entre ellos para conformar la canción. Una de las cosas que Madbit tuvo que tener en cuenta al armar instrumentos, patrones y la canción en su totalidad, es la limitación de tamaño del tema. Entonces al componer (si mal no recuerdo solo tenía 500 bytes disponibles para poder hacer TODO UN TEMA DE 2 MINUTOS) tuvo que diseñar una canción que fuera interesante y a la vez con una mínima cantidad de instrumentos. ¡Y lo optimizó de manera tal que le metio banda de instrumentos y patrones!

Una anécdota, durante toda la producción, estuve trabajando con un loop de un patrón con 4 instrumentos que no tenía variaciones que me mandó él como boilerplate. El mismo día que estabamos cerrando la intro, le pregunto al bitio si termina el tema (estuvo trabajando fuertemente en la música y diseño de Man in the Vox, también ganador del primer puesto de la Flashparty en la categoria Demo PC). Solo tuvo 3 horas antes de la deadline para hacer el tema y mandarme el yaml que escupe sointu antes de enviar el ejecutable final a la Flashparty. Y lo hizo. Hizo un temazo en 3 horitas nada más. Kudos Madbit, un capo y un músico de la concha de la lora.

El Fragment Shader

Raymarching y Pathtracing

Habiendo experimentado una banda con raymarching y viendo lo que viene pasando en la Demoscene últimamente en las competiciones me encuentro con una forma de procesar los rayos que hace que salga casi-gratis global illumination y la propiedad emissive de un material en un campo geométrico. Después de intentarlo sin éxito claro, le pedí ayuda para la implementación a Row. Me explica que esto que quería lograr, era Pathtracing y que ya estaba implementado en su engine que valió el 1er puesto también con Man in the Vox, una excelente producción de BEHELiT.

Geometría SDF, Pathtracing y SDF Tag MaterialGeometría SDF, Pathtracing y SDF Tag Material

Después de varias charlas por discord a altísimas horas de la noche, el planteo mio fue el siguiente:

"¿Es posible juntar estas dos cosas? ¿Raymarching (para tener las bondades de SDF) y Pathtracing para poder asignarle propiedades a los objetos?"

Y me dijo, claro que si, hold my fukken beer. Le dije que yo se la sostengo, y me la tomé. Después de varias explicaciones seguía sin entender como funcionaba ese segmento del shader donde un objeto SDF era "taggeado" por cada una de sus expresiones matemáticas, y que los rayos vayan rebotando dependiendo de la propiedad de la superficie (roughness, reflective, emissive) y ademas de la capacidad que tenga cada objeto de emitir luz y que esos rayos rebotaran usándolos.

Como no entendí un carajo, aunque Row me lo explicó varias veces, me tomé un vuelo a Buenos Aires y de ahí a Uruguay (donde Row y Madbit viven) a exigir explicaciones y compartir unos guisos DELICIOSOS mientras la nerdeabamos y haciamos un cringe-gauntlet en el estudio del bitio; cosas que merecen otro post.

Calculamos dos veces la geometría:

//////////
// Scene

float sdfScene(vec3 point) {
    //Tunnel
    float tunnel = fBox(point + vec3(0., -1., 0.), vec3(1.2, 1.2, 99999.));
    float carTunnelLeft = fBox(point + vec3(3., -2., 0.), vec3(1.2, 1.2, 99999.));
    float carTunnelRight = fBox(point + vec3(-3., -2., 0.), vec3(1.2, 1.2, 99999.));


    //Walls
    vec3 wallPoint = kifs(point, sin(fGlobalTime/200) + 1.3);  
    //wallPoint = point;

    // red carpet 
    float redCarpet = fBox(wallPoint + vec3(0, 5, 0), vec3(100, 0.01, 100));


    float wall1 = fBox(wallPoint + vec3(1.2, 0, 0), vec3(.50, 5., .25));
    float wall2 = fBox(wallPoint + vec3(-.5, 0, 0), vec3(.25, 3., .35));
    float wall3 = fBox(wallPoint + vec3( .5, 0, 0), vec3(.05, 2.5, .15));
    float wall4 = fBox(wallPoint + vec3( 0.3, 0, 0), vec3(5.05, 1.5, .15));
    float walls = min(wall1, min(wall2, min(wall3, wall4)));

    //Floor
    float floorCube1 = fBox(wallPoint + vec3(-2., 5., -.33), vec3(1., 1., .33));
    float floorCube2 = fBox(wallPoint + vec3(0., 5., 0), vec3(1., 1., .33));
    float floorCube3 = fBox(wallPoint + vec3(2., 5., .33), vec3(1., 1., .33));
  
    float floorCube = min(floorCube3, min(floorCube1, floorCube2));

    return max(-carTunnelRight, max(-carTunnelLeft, max(-tunnel, min(redCarpet, min(walls, floorCube)))));
}

Material sdfTagMaterial(Ray ray, float distance, vec3 point) {

    //Walls
    vec3 wallPoint = kifs(point, sin(fGlobalTime/200) + 1.3);
    //wallPoint = point;

    // red carpet 
    float redCarpet = fBox(wallPoint + vec3(0, 5, 0), vec3(100, 0.01, 100));
    float wall1 = fBox(wallPoint + vec3(1.2, 0, 0), vec3(.50, 5., .25));
    float wall2 = fBox(wallPoint + vec3(-.5, 0, 0), vec3(.25, 3., .35));
    float wall3 = fBox(wallPoint + vec3( .5, 0, 0), vec3(.05, 2.5, .15));
    float wall4 = fBox(wallPoint + vec3( .9, 2, 0), vec3(.05, 5.5, .15));

    float floorCube1 = fBox(wallPoint + vec3(-2., 5., -.33), vec3(1., 1., .33));
    float floorCube2 = fBox(wallPoint + vec3(0., 5., 0), vec3(1., 1., .33));
    float floorCube3 = fBox(wallPoint + vec3(2., 5., .33), vec3(1., 1., .33));
  
    float floorCube = min(floorCube3, min(floorCube1, floorCube2));
  
    Material result;

    if (wall1 < EPSILON)
        result = gold();
    else if (wall2 < EPSILON)
        result = concrete();
    else if (wall3 < EPSILON)
        result = light();
    else if (wall4 < EPSILON)
        result = light3();
    else if (floorCube1 < EPSILON)
        result = light2();
    else if (floorCube2 < EPSILON)
        result = light3();
    else if (redCarpet < EPSILON)
        result = redCarpetMaterial();
    else if (floorCube3 < EPSILON)
        result = carLight();
    else
        result = skybox(fGlobalTime, ray);

    return result;
}

En la función sdfTagMaterial(), la idea es que cuando el rayo se este enviando, poder "taggear" el objeto que está codeado en expresiones SDF y devolver en el HIT sus propiedades, debajo un ejemplo de algunos "materiales":

[...]
/////////////
// Material
Material material(vec3 color, vec3 emissive, float roughness) {
    Material material;
    material.c = color;
    material.e = emissive;
    material.roughness = roughness;
    return material;
}
[...]
Material light2() { // esta la luz de la pija 
    return material(
        vec3(.5, .5, .5),
        syncs[4] > 16*8 ? vec3(0, 1 - mod(syncs[4] / 4, 1), 0) : vec3(0),
        0.55// + sin(fGlobalTime*20.)
    );
}

Material carLight() {
    return material(
        vec3(1, 1, 1),
        syncs[4] > 32*8 
            ? vec3(0, 0, 0) * (.5 + .5 * sin(fGlobalTime*2.)) + vec3(0.2,0.2, 1 - mod(syncs[4] / 4, 1))
             : vec3(0),
        0.55
    );
}

Material light3() {
    return material(
        vec3(.5, .5, .5),
        syncs[7] > 16*1 ? vec3(1, 1, 1) * (.5 + .5 * sin(fGlobalTime*2.)) : vec3(0),
        0.95// + sin(fGlobalTime*20.)
    );
}
[...]

KIFS

Un KIFS es un Kaleidoscopic Iterated function system.

[...]

vec2 repeat(vec2 p, vec2 s) {
  return (fract(p/s-0.5)-0.5)*s;  
}

[...]

vec3 kifs(vec3 p, float t) {
  
  p.xz = repeat(p.xz, vec2(28));
  p.xz = abs(p.xz);
  
  vec2 s=vec2(10,7) * 0.6;
  for(int i=0; i<5; ++i) {
    p.xz *= rot(t);
    p.xz = abs(p.xz) - s;
    p.y += 0.1*abs(p.z);
    s*=vec2(0.7,0.5);
  }
  
  return p;
}

[...]

float sdfScene(vec3 point) {
    [...]
    //Walls
    vec3 wallPoint = kifs(point, sin(fGlobalTime/200) + 1.3);  
    //wallPoint = point;

    // red carpet 
    float redCarpet = fBox(wallPoint + vec3(0, 5, 0), vec3(100, 0.01, 100));
    [...]
}


Como en toda escena SDF, en esta función llamada sdfScene(vec3 point) y que a veces en muchos shaders de shadertoy por ejemplo la llaman map(vec3 point) le pasamos un punto en el espacio y como se puede ver en la variable wallPoint foldeamos toda la escena sobre un espacio 3d en los ejes X y Z para agregar complejidad "caleidoscópica" a la geometría hecha con SDF.

Un ejemplo de esto se puede ver en el siguiente video que está aquí debajo.

En el video, lo que hago es comentar la función kifs y pasarle directamente el punto que recibo de la escena SDF, donde se puede ver la geometría original. Después descomento esa línea para aplicarle la función kifs de nuevo. Se puede ver como toda la geometría rota en el infinito. Los números y vectores usados dentro de la función kifs son a base de prueba y error y algunos valores son choreados de otras implementaciones que encontre en shadertoy.

Strip Preventer vs Error 120

Cada tanto, cuando compilaba la intro y la ejecutaba saltaba un errorlevel 120. Siendo que no había (o no supe encontrar en ese momento) información sobre este código de error, Row se quedó mirando un el código del shader por largo rato hasta que se le ocurrió que quizás en algún momento del proceso de compilación algunas variables se estaban strippeando y que cuando el shader quería ser ejecutado por la GPU, la intro volaba en mil pedazos. ¿La solución? Declarar las variables al principio del main() de la siguiente manera:

void main()
{
    [...]
    float globalTime = fGlobalTime;
    float stripPreventer = beat + pattern + part + partBeat + partIndex + globalTime;
    [...]
}

Como fGlobalTime y la declaración globalTime son variables globales que cambian todo el tiempo en la ejecución del shader, lo que hacemos es forzar el cálculo de todas las variables de que se borraban en tiempo de compilación, aunque no usemos la variable stripPreventer para ningún otro propósito. Me encontré con este problema en GCC varias veces, donde el compilador decide que sirve y que no y me sacaba pedazos de código enteros para optimizarlos, cuando en mi caso de uso realmente necesitaba que no me optimice nada. Que lo hayamos tenido en un shader, con ese errorlevel feo, fue cuando menos sorprendente y desconcertante. :D

Uniforms y Sointu

Finalmente, al usar sointu para hacer la música, tenemos que aprovechar una interfaz que nos da para transformar cada uno de los tracks en información util que nos sirve para sincronizar los efectos de la intro. Por ejemplo, el kick o el hihat, al pasarlos a través de una uniform nos da cierta informacion para modificar las propiedades de del emissive de los materiales; en nuestro main.c:

// main.c 
[...]
#pragma data_seg(".sointu_buffer")
SUsample sointu_buffer[SU_BUFFER_LENGTH];
float syncBuf[(SYNC_DELAY >> 8) * SU_NUMSYNCS + SU_SYNCBUFFER_LENGTH];
HWAVEOUT hWaveOut;
[...]

void entrypoint(void)
{
[...]

    waveOutGetPosition(hWaveOut, &MMTime, sizeof(MMTIME));

    ((PFNGLUNIFORM1FVPROC)wglGetProcAddress("glUniform1fv"))
        (0, 8, &syncBuf[((MMTime.u.sample + SYNC_DELAY) >> 8) * SU_NUMSYNCS]);

[...]
}

Y desde nuestro shader, recibimos las uniforms de la siguiente manera:

uniform float syncs[8];

[ ... ]

Material light3() {
    return material(
        vec3(.5, .5, .5),
        syncs[7] > 16*1 ? vec3(1, 1, 1) * (.5 + .5 * sin(fGlobalTime*2.)) : vec3(0),
        0.95// + sin(fGlobalTime*20.)
    );
}

[ ... ]

void main()
{
    // sointu sync variables
    float beat = syncs[0]/4;
    float pattern = beat/4;
    float part = pattern/2;
    float partBeat = mod(beat,8);
    float partIndex = int(part);
    float globalTime = fGlobalTime;
[...]

Conclusión

Hacía basante tiempo que no me divertía programando. En el proceso, logramos constuir un framework para futuras producciones y aún queda margen para mejoras y reducir el tamaño de las próximas intros todavía más, y quisá meter más contenido, efectos o música.

Referencias