DevLog #2: Ventajas de los DSL. Caso gráficos.

Un DSL (lenguaje específico de dominio, por sus siglas en inglés), es básicamente un lenguaje custom orientado a cumplir objetivos arquitectónicos y de experiencia de desarrollo. Probablemente podemos listar más razones, pero considero que esas son las dos principales.

En gráficos son especialmente comunes, casi cualquier motor gráfico tiene su propio DSL, aunque Three.js era una de las pocas excepciones. Durante años la forma tradicional de construir en Three un shader (un programa que corre en la GPU), era mediante escritura de código GLSL/WGSL puro.

El pasado de Three.js

Three.js en su forma más tradicional mantenía trozos de código (cadenas de texto sin más) de GLSL que importábamos y concatenábamos para construir shaders de forma modular. Pequeñas piezas que denominaron “chunks”.

Algunas de estas piezas se limitaban a inyectar uniforms y otras variables de entrada y salida que Three alimentaba de forma automática, como la matriz de transformación del mundo o de la cámara, siendo necesarias a su vez para ser consumidas por otras piezas.

Esto era una solución modular que nos evitaba tratar con un “uber shader”, un solo programa petado de variables para controlar su comportamiento, con limitaciones técnicas importantes. Pero suponía un desafío evidente, había que saber cómo estaban formadas esas piezas, dónde estaban, cómo se llamaban e incluso en qué orden importarlas.

Un cachito de una string replicando el fragment shader de Phong (tiene otras cientos de líneas). La alternativa menos horrible y más segura es no copiar todo, solo usar el original reemplazando (con replace) lo que quieras. Los #include hacen referencia a los chunks, son reemplazados por otros masacotes de código.

Incluso aunque separes el texto en ficheros como miShader.vert y miShader.frag con coloreado de sintaxis y todo el rollo, seguirás teniendo mero texto. Mucha gente se hacía sus propios replacers o extensores, funciones donde indicabas el chunk a reemplazar y código que querías inyectar y cosas así, pero en definitiva todo era construir una pedazo de cadena de texto que pasarle al compilador de WGSL.

Además, otro problema de los chunks es que no propiciaba demasiado la abstracción, muchas de esas piezas conllevan conocimiento muy técnico sobre cómo funcionan los algoritmos principales utilizados. Por ejemplo, no es lo mismo calcular iluminación en un shader de vértices (algoritmo de Gouraud) que en un shader de fragmentos (algoritmo de Phong), y conocer estos detalles es vital para saber cómo y dónde se importan las luces, es decir, es necesario entender bien la arquitectura.

Dado que puede haber muchos chunks con una responsabilidad clara pero pequeñas variaciones, se decidió partir todo lo que sea posiblemente común en más chunks, habiendo decenas de ellos, algunos de tan solo 2-3 líneas.

Por suerte contábamos con una buena cantidad de materiales base con sus shaders ya formados. Si queremos que un objeto siempre presente su color real sin ser afectado por la iluminación podíamos usar algún material compatible con unlit o el MeshBasicMaterial; mientras que si queríamos un efecto cartoon usamos MeshToonMaterial, etc. Todo eso podemos lograrlo sin necesidad de tocar ningún shader.

Incluso para customs, la buena noticia es que no era necesario construir todo el shader a piezas desde cero, ya que Three.js nos permitía extender los shaders de sus materiales base (reemplazando cadenas de texto como se dijo). Pero lo siento mucho por ti si lo que buscas es un efecto dissolve como el del siguiente ejemplo:

Ops!, dispositivo no compatible con este ejemplo

Three.js Shading Language (TSL)

TSL es una solución basada en nodos que nos permite construir shaders de forma sencilla y abstracta. El material del cubo del ejemplo anterior fue escrito de la siguiente manera:

TypeScript

const dissolveProgress = uniform(0.3)
const noise = mx_noise_float(uv().mul(6.0))
const mask = noise.greaterThan(dissolveProgress)

const material = new MeshStandardNodeMaterial({ color: 0xffffff, side: 2 })
material.maskNode = mask
material.castShadowNode = vec4(0, 0, 0, mask)
material.transparent = true

Básicamente las tres primeras líneas sirven para crear una máscara de opacidad a partir de ruido, osea, el código del shader en sí que se ejecutará en la GPU.

El resto es simplemente el procedimiento estándar para crear un material y asignarle el código del shader. Three propone nodos para controlar opacidad de forma parcial o total (en este caso maskNode para total).

De hecho, la auténtica maravilla de este código es que afecta a la sombra. Hemos utilizado exactamente la misma máscara en un nodo del pase de sombras, que controla el color (y opacidad) de cada píxel de la misma.

Pero hay más ventajas. Enumerando algunas:

  • Simplicidad sintáctica. Toda operación queda perfectamente definida con funciones/objetos que creamos sobre TypeScript. El hecho de que todo pase por eso es lo que lo define como lenguaje.
  • Disminución de código imperativo específico. Se diluye el sentimiento de programa para la GPU dividido en dos partes (vertex y fragment) clásico. Creamos piezas que hacen cosas y modifican la salida de posición de vértices o de color según nuestras intenciones. También aplicable a otros pases de postprocesado, niebla, o sombras como acabamos de ver.
  • Abstracción de lenguajes. Por si no resulta evidente, TSL es un lenguaje transpilable a GLSL y WGSL (de WebGL y de WebGPU respectivamente), no tenemos que aprender ningún lenguaje de shaders base (aunque en realidad ayuda muchísimo conocer alguno, entender cómo funcionan y saber qué podemos conseguir)
  • Todos los nodos son reutilizables. Three los combina todos inteligentemente y evita una cantidad enorme de duplicidad o replicación en memoria.
  • Tipado. Al estar sobre TypeScript el IDE puede ayudarnos a detectar errores de sintaxis, a entender las APIs y ver opciones disponibles muy rápidamente, no tenemos que sumergirnos en otros lenguajes sin soportes.
  • Depuración. Si alguna vez has intentado depurar código GLSL sabrás a qué me refiero. Sacar información de la GPU a la CPU es un dolor de cabeza. Con TSL no solo ves errores antes de que ocurran, también brinda métodos para depurar el estado de una forma más sencilla. También para ver el código final al que compila.
  • Clara separación de lo que es código que se ejecutará en la GPU. Aunque al principio parezca lo contrario, la realidad es que podemos ver claramente qué es de GPU: literalmente todas las funciones nodo que surgen del módulo three/tsl.

¿Por qué no más DSL?

Cada día es más raro encontrar DSLs fuera del mundo gráfico. Antiguamente muchas tecnologías y plataformas incorporaban su propio set de instrucciones que luego compilaban o transpilaban a uno o más lenguajes. Algunos DSL son ya tratados como si fueran lenguajes en sí mismos (como el famoso SQL y sus sabores).

Hoy día la tendencia es la contraria. Es más ventajoso tener un único lenguaje que podamos extender mediante una biblioteca muy bien definida, aprovechando todos los mecanismos básicos del mismo.

En el mundo gráfico se justifica porque no nos queda otra que tratar con APIs gráficas (WebGL, OpenGL, Vulkan, DirectX, Metal…), que además de ser toscas y carecer de una arquitectura en sí (te da las piezas, tú decides qué montar), son muy diferentes entre sí. En cuanto quieras dar al menos soporte a 2, no te queda otra que usar un lenguaje común (y transpilar/compilar con estrategias diferentes).

Bonus: ¿cómo se lleva la IA con TSL?

De más general a más particular:

  1. La IA no se lleva muy bien con las artes gráficas generativas por código, necesitaría ser retroalimentada con imágenes, y tampoco es buena interpretando (además de ser caro).

  2. Tampoco con el mundo de la computación gráfica. Es un mundo muy oscuro donde las técnicas más increíbles no suelen publicarse y explicarse bien, no de forma relacionable a código de Three.js, y empeora con bibliotecas menos conocidas.

  3. Aún mucho peor con TSL. Pese a que Three es la biblioteca de renderizado más famosa del mundo, TSL ha tenido un periodo de madurez, experimentación y cambio extremadamente turbulento. La IA no para de mezclar, sugerir cosas eliminadas o renombradas. Suma esto a una poderosa falta de ejemplos. Si quieres que una IA te ayude, asegúrate de que se empape de la última documentación y del código de las últimas versiones, si no, te va a sugerir demasiadas cosas que no son posibles.

Quitando esto, TSL es un lenguaje más simple. Una vez adaptada, trabajar con una IA en TSL debería ser órdenes de magnitud más barato que sobre código directo.