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 actuales 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:
- cmake
- nasm
- visual studio 2022 community edition (CRT SDK)
- sointu
- crinkler
- Shader Minifier
- bonzomatic
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 sino que también 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 provee 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 building, 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, con un sintetizador y un compilador del
archivo trackeado, que lo transforma en bytecode que el sintetizador puede interpretar y
hacerlo sonar en un espacio ínfimo de código.
Sointu 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 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 la geometría y el "Tag Material":
//////////
// 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 building 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 el proceso de build, aunque no usemos la
variable stripPreventer
para ningún otro propósito.
Me encontré con este problema en GCC
varias veces,
donde el compilador en este caso, 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.