3 puntos por GN⁺ 2024-05-21 | 1 comentarios | Compartir por WhatsApp
  • City In A Bottle es una demo en JavaScript que mete un motor de raycasting y un generador de ciudad dentro de un HTML de 256 bytes
  • La ejecución empieza solo con <canvas> y onclick=setInterval(...), y vuelve a dibujar la escena en cada frame sobre un canvas de 99 píxeles de ancho
  • El renderizado se resuelve sin trigonometría, usando álgebra básica y operaciones de bits, y para cada píxel sigue un rayo de cámara y otro en dirección de la luz para crear sombras
  • La forma de la ciudad se divide en altura de edificios, calles y espacios vacíos con coordenadas X, Y, Z y condiciones como X/9^Z/8, X%99, Z>32
  • El brillo final mezcla niebla por distancia, sombras y la textura (X&Y&Z)%3/Z en el ancho de fillRect, logrando ciudad, perspectiva y textura con un código extremadamente pequeño

Estructura completa dentro de un HTML de 256 bytes

  • La demo no funciona como un fragmento de JavaScript, sino como un solo programa HTML válido
<canvas style=width:99% id=c onclick=setInterval('for(c.width=w=99,++t,i=6e3;i--;c.getContext`2d`.fillRect(i%w,i/w|0,1-d*Z/w+s,1))for(a=i%w/50-1,s=b=1-i/4e3,X=t,Y=Z=d=1;++Z<w&(Y<6-(32<Z&27<X%w&&X/9^Z/8)* 8%46||d|(s=(X&Y&Z)%3/Z,a=b=1,d=Z/w));Y-=b)X+=a',t=9)>
  • En conjunto se divide en código HTML, bucle de actualización por frame, sistema de renderizado, motor de raycasting y lógica de generación de la ciudad
  • En lugar de trigonometría o matemáticas avanzadas, usa solo álgebra básica y operaciones de bits
  • Después de publicarse originalmente, fue una de las publicaciones más populares en la línea de tiempo de Twitter del autor durante unos 2 años

HTML y bucle de ejecución

  • El HTML está compuesto solo por un canvas y un evento de clic
<canvas style=width:99% id=c onclick=setInterval('',t=9)>
  • El id del canvas se define como c para acceder a él de forma corta desde JavaScript
  • style=width:99% no es obligatorio y funciona incluso sin eso
  • En onclick se llama a setInterval para iniciar el bucle de actualización
    • El intervalo se define en 9 milisegundos
    • La variable de tiempo t también se inicializa aquí en 9 para ahorrar espacio
  • Si se hace clic varias veces en el canvas, se ejecutan varios intervalos y aparece un pequeño bug que lo vuelve más lento

JavaScript reescrito para que se entienda mejor

  • La carga de JavaScript que se ejecuta tras el clic ocupa 199 bytes
for(c.width=w=99,++t,i=6e3;i--;c.getContext`2d`.fillRect(i%w,i/w|0,1-d*Z/w+s,1))for(a=i%w/50-1,s=b=1-i/4e3,X=t,Y=Z=d=1;++Z<w&(Y<6-(32<Z&27<X%w&&X/9^Z/8)* 8%46||d|(s=(X&Y&Z)%3/Z,a=b=1,d=Z/w));Y-=b)X+=a
  • Si se le agregan espacios y saltos de línea, puede leerse con este flujo
c.width = w = 99
++t
for (i = 6e3; i--;)
{
  a = i%w/50 - 1
  s = b = 1 - i/4e3
  X = t
  Y = Z = d = 1
  for(; ++Z<w &
    (Y < 6 - (32<Z & 27<X%w && X/9^Z/8)*8%46 ||
    d | (s = (X&Y&Z)%3/Z, a = b = 1, d = Z/w));)
  {
    X += a
    Y -= b
  }
  c.getContext`2d`.fillRect(i%w, i/w|0, 1 - d*Z/w + s, 1)
}
  • c.width = w = 99 limpia el canvas y fija su ancho en 99 píxeles, además de guardar ese mismo valor en w para reutilizarlo
  • La altura por defecto del canvas es 150, y la zona inferior queda vacía
  • ++t incrementa el valor de tiempo en cada frame para animar la escena
  • for (i = 6e3; i--;) es el bucle externo que decide el brillo de cada píxel

Cálculo de los rayos de cámara

  • Cada píxel se procesa como un rayo que sale de la cámara
  • El componente horizontal se guarda en a
a = i % w / 50 - 1
  • i % w obtiene la posición horizontal del píxel actual, se divide entre 50 y luego se le resta 1 para normalizarlo aproximadamente entre -1 y 1
  • El componente vertical se guarda en b, y ese mismo valor también entra en s para el desvanecimiento del fondo
b = s = 1 - i / 4e3
  • En lugar de calcular una proporción vertical exacta, se simplifica con i / 4e3 para reducir el tamaño del código
  • Esta simplificación crea una inclinación casi imperceptible, pero ahorra bytes
  • 4e3 fue elegido para mover el horizonte por debajo del centro
  • La posición inicial de la cámara usa el valor de tiempo para generar una escena que se desplaza hacia la derecha
X = t
Y = Z = d = 1
  • Y, Z y d, que se usa para la niebla por distancia, se inicializan todos en 1

Generación de la ciudad y detección de colisiones

  • El bucle interno es el núcleo del sistema de raycasting y hace avanzar Z hasta tocar algo
for(; ++Z<w &
  • Z se incrementa mientras siga siendo menor que w, es decir, 99
  • Los edificios, callejones y espacios vacíos hacia la costa se crean con la siguiente condición
Y < 6 - (32<Z & 27<X%w && X/9^Z/8)*8%46
  • Comprueba si el rayo está por debajo de la altura en esa posición para decidir si hay colisión
  • 6 - baja el resultado de altura por debajo del centro e invierte la escena para que el suelo quede abajo
  • La condición dentro de los paréntesis determina la forma de la ciudad
    • 32<Z deja espacio entre la cámara y la primera fila de edificios
    • 27<X%w crea huecos periódicos que separan los bloques urbanos como si fueran calles
    • En valores negativos siempre da false, lo que genera una zona vacía parecida al mar
    • X/9^Z/8 usa XOR a nivel de bits para dar una distribución de alturas de edificios con apariencia aleatoria
    • Los divisores 9 y 8 ajustan el ancho y la profundidad de los edificios
    • X/9 se alinea con los números relacionados con el ancho de las calles, evitando que aparezcan edificios extremadamente delgados en los bordes
  • El resultado entre paréntesis se multiplica por 8 y luego se le aplica módulo 46 para producir un rango de altura máximo
  • 8 y 46 fueron elegidos de forma experimental para obtener distintas alturas de edificios

Sombras, textura y niebla por distancia

  • Cuando ocurre una colisión, el mismo bucle interno asume un segundo rol y hace una prueba en dirección de la luz
d | (s = (X&Y&Z)%3/Z, a = b = 1, d = Z/w)
  • d | distingue si el rayo actual es el rayo que sale de la cámara o uno que inspecciona sombras en dirección de la luz
  • Al principio d = 1, así que funciona como rayo de cámara
  • Tras la colisión, d = Z/w pasa a ser un valor menor que 1, cambia el resultado de la evaluación con OR a nivel de bits y el bucle vuelve a ejecutarse para iniciar la prueba de sombra
  • Si durante la prueba de sombra vuelve a haber una colisión, sale del bucle y ese píxel se dibuja en sombra
  • El valor de textura s se genera con la siguiente expresión
s = (X&Y&Z)%3/Z
  • Aplica AND a nivel de bits sobre X, Y y Z, y usa el residuo al dividir entre 3 para crear una textura gris parecida a ventanas
  • Luego vuelve a dividir entre Z para que la textura se desvanezca a la distancia
  • Para enviar el rayo en la dirección de una luz direccional tipo sol, a y b se fijan ambos en 1
  • d = Z/w es el valor de niebla por distancia y se usa para aclarar los edificios lejanos

Dibujo de píxeles y expresión del brillo

  • Cada píxel se dibuja con fillRect
c.getContext`2d`.fillRect(i%w, i/w|0, 1-d*Z/w+s, 1)
  • i%w produce la coordenada x, e i/w|0 produce la coordenada y
  • El brillo se expresa reduciendo el ancho del píxel, una técnica clave para generar una imagen en escala de grises con muy poco código
  • 1 corresponde a un píxel negro, así que la expresión final crea el brillo de la imagen restando valores a 1
  • d * Z/w combina el efecto de sombra con la distancia
    • Si no está en sombra, el rayo avanza hasta la distancia máxima w, por lo que Z/w se vuelve 1
    • Dentro de la sombra, Z queda por debajo de w, así que se ve más oscuro
    • Cuanto más cerca esté el objeto que bloquea la luz, más oscura será la sombra, dejando un efecto parecido al ambient occlusion
  • Al final se suma s para mezclar la textura del edificio en el brillo final

Demo posterior y herramientas de experimentación

  • Fue presentado en la Revision 2022 demo party y puede verse en Pouet
  • En ese momento se envió en la categoría equivocada y no logró quedar en una posición alta
  • Después, en Shadertoy, Xor y otros coders hicieron un shader de 256 bytes que recrea la versión en JavaScript
  • Daniel Darabos creó una herramienta en Observable que permite manipular varios elementos del programa en tiempo real
  • El código puede remezclarse en Dwitter y también puede probarse en CapJS

1 comentarios

 
GN⁺ 2024-05-21
Comentarios de Hacker News
  • Como material relacionado, está la forma en que se construye el mundo de Pitfall para Atari 2600: https://evoniuk.github.io/posts/pitfall.html
    Generación procedural en el juego temprano de 8 bits Elite: https://procedural-generation.tumblr.com/post/112509130817/e...
    Documento general sobre generación procedural: https://en.wikipedia.org/wiki/Procedural_generation
    Como concepto algo relacionado, también está la evaluación diferida en los lenguajes de programación funcional: https://en.wikipedia.org/wiki/Lazy_evaluation
    Si algún algoritmo de trazado de rayos evalúa solo los puntos visibles al crear una imagen 2D a partir de datos 3D, parece similar al concepto de evaluación diferida de “posponer la evaluación de una expresión hasta que se necesite su valor”: https://en.wikipedia.org/wiki/Ray_tracing_(graphics)
    Y, yendo más allá, en apariencia también da la sensación de estar conectado, de forma aproximada, con “la observación colapsa la función de onda”: https://en.wikipedia.org/wiki/Wave_function_collapse
  • Del mismo autor también está 1K Pinball Game in JavaScript: https://frankforce.com/lu1ky-pinball-code-deep-dive/
  • Remnants, de Alcatraz, también es una demo de MS-DOS de 256 bytes similar, e incluye un enlace a YouTube: https://www.pouet.net/prod.php
    Sin embargo, esta obra resulta más impresionante por estar hecha en JavaScript. Porque JavaScript suele tener peor densidad de código que el código máquina x86 de 16 bits
    • El enlace correcto parece ser este: https://www.pouet.net/prod.php?which=96536
      También incluye el código fuente
    • Spongy consigue algo parecido en 128 bytes, es decir, la mitad del tamaño: https://www.pouet.net/prod.php?which=53871
      http://www.youtube.com/watch?v=36BPql6Nl_U
      El nombre viene del fractal Menger sponge en el que se basa: https://en.wikipedia.org/wiki/Menger_sponge
    • No necesariamente la densidad de código de JavaScript es peor. Las variables pueden usar nombres de 1 byte, mientras que las instrucciones de carga de x86 normalmente tienen 2 bytes o más, y con frecuencia 3 bytes
      La gran ventaja de JavaScript aquí es que la biblioteca dweet estándar tiene funciones como sin/cos/fill/line. En cambio, el BIOS de x86 solo hace algo como el “cambio de modo”, y después hay que manipular directamente el arreglo de píxeles
  • Es realmente impresionante. La densidad de información que percibimos en tan poco código es absurdamente alta
    Por eso me hace pensar que los modelos de lenguaje grandes quizá no sean la mejor forma de modelar el mundo
    • Es muy probable que el código ofuscado lleno de atajos tampoco sea la mejor forma de modelar el mundo. Aun así, esto sin duda es un logro impresionante
      Yo estaría satisfecho si pudiera hacerlo en unas 256 líneas, y en la práctica probablemente me llevaría mucho más que eso
      Muchas cosas visuales, sean artificiales o naturales, siguen patrones. Si no, para empezar, serían difíciles de reconocer. Por eso hay mucho que se puede aprovechar matemáticamente. Para las texturas de altura se pueden usar varios tipos de ruido o patrones; los fractales también pueden aprovecharse de forma interesante; para la vegetación y los árboles se puede usar Fibonacci/proporción áurea; y entre elementos del esqueleto y huesos, cosas como proporciones de tamaño. Para esto no hace falta una máquina mágica de conjeturas
    • ¿Por qué alguien pensaría que los modelos de lenguaje grandes son lo mejor?
  • Está muy genial, pero fue una lástima que, mientras leía el artículo, el bucle siguiera ejecutándose y mi laptop se sobrecalentara
    • ¡Así que por eso mi teléfono estuvo inusualmente caliente durante los últimos minutos!
  • Si te gusta este tipo de cosas, también te va a gustar #tweetcart en Twitter
    Son programas del tamaño de un tuit para la consola virtual Pico-8