- City In A Bottle é um demo em JavaScript que coloca um motor de raycasting e um gerador de cidade dentro de 256 bytes de HTML
- A execução começa apenas com
<canvas> e onclick=setInterval(...), redesenhando a cena a cada frame em um canvas de 99 pixels de largura
- A renderização é feita sem trigonometria, usando apenas álgebra básica e operações de bits, com rastreamento por pixel de um raio da câmera e de um raio na direção da luz para criar sombras
- A forma da cidade é dividida em altura dos prédios, ruas e espaços vazios usando coordenadas
X, Y, Z e condições como X/9^Z/8, X%99, Z>32
- O brilho final mistura névoa de distância, sombra e a textura
(X&Y&Z)%3/Z na largura do fillRect, implementando cidade, perspectiva e textura com código extremamente pequeno
Estrutura completa dentro de 256 bytes de HTML
- O demo não funciona como um trecho de JavaScript, mas como um programa HTML válido completo
<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)>
- O conjunto se divide em código HTML, loop de atualização por frame, sistema de renderização, motor de raycasting e lógica de geração da cidade
- Em vez de trigonometria ou matemática avançada, ele usa apenas álgebra básica e operações de bits
- Depois da publicação original, foi por cerca de 2 anos um dos posts mais populares da timeline do autor no Twitter
HTML e loop de execução
- O HTML é composto apenas por um canvas e um evento de clique
<canvas style=width:99% id=c onclick=setInterval('',t=9)>
- O
id do canvas é definido como c para permitir acesso curto no JavaScript
style=width:99% não é obrigatório e o código funciona mesmo sem isso
- Em
onclick, setInterval é chamado para iniciar o loop de atualização
- O intervalo é definido em
9 milissegundos
- A variável de tempo
t também é inicializada com 9 aqui para economizar espaço
- Há um pequeno bug: clicar várias vezes no canvas cria vários intervals e deixa tudo mais lento
JavaScript reescrito para ficar legível
- O payload JavaScript executado após o clique tem 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
- Com espaços e quebras de linha, o fluxo pode ser lido assim
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 limpa o canvas e define sua largura para 99 pixels, além de guardar o mesmo valor em w para reutilização
- A altura padrão do canvas é 150, e a área abaixo disso fica vazia
++t incrementa o valor de tempo a cada frame, animando a cena
for (i = 6e3; i--;) é o loop externo que determina o brilho de cada pixel
Cálculo dos raios da câmera
- Cada pixel é tratado como um único raio saindo da câmera
- O componente horizontal é armazenado em
a
a = i % w / 50 - 1
i % w obtém a posição horizontal do pixel atual, divide por 50 e subtrai 1, normalizando aproximadamente para um intervalo entre -1 e 1
- O componente vertical é armazenado em
b, e o mesmo valor também vai para s, usado no fade do fundo
b = s = 1 - i / 4e3
- Em vez de calcular a proporção vertical exata, usa-se
i / 4e3 como simplificação para reduzir o tamanho do código
- Essa simplificação cria uma inclinação quase imperceptível, mas economiza bytes
4e3 foi escolhido para deslocar o horizonte para baixo do centro
- A posição inicial da câmera usa o valor de tempo, criando uma cena que se move para a direita
X = t
Y = Z = d = 1
Y, Z e d, usado para névoa de distância, são todos inicializados com 1
Geração da cidade e detecção de colisão
- O loop interno é o núcleo do sistema de raycasting e faz
Z avançar até tocar em alguma coisa
for(; ++Z<w &
Z continua aumentando até ser menor que w, isto é, 99
- Prédios, becos e o espaço vazio do lado do mar da cidade são criados pela condição a seguir
Y < 6 - (32<Z & 27<X%w && X/9^Z/8)*8%46
- Ela verifica se o raio está abaixo da altura daquela posição para determinar a colisão
6 - desloca o resultado da altura para baixo do centro e o inverte, deixando o chão na parte inferior
- As condições dentro dos parênteses definem a forma da cidade
32<Z deixa um espaço entre a câmera e a primeira fileira de prédios
27<X%w cria espaços vazios periódicos que dividem os quarteirões como se fossem ruas
- Em valores negativos, isso sempre vira false, criando uma região vazia parecida com o mar
X/9^Z/8 usa XOR em nível de bits para produzir uma distribuição de alturas que parece aleatória
- As divisões por
9 e 8 ajustam a largura e a profundidade dos prédios
X/9 também fica alinhado com os números ligados à largura das ruas, evitando prédios extremamente finos nas bordas
- O resultado entre parênteses é multiplicado por
8 e então passa por módulo 46, definindo a faixa máxima de alturas
8 e 46 foram escolhidos empiricamente para gerar variedade na altura dos prédios
Sombra, textura e névoa de distância
- Quando acontece uma colisão, o mesmo loop interno assume uma segunda função: fazer a verificação na direção da luz
d | (s = (X&Y&Z)%3/Z, a = b = 1, d = Z/w)
d | diferencia se o raio atual é o raio saindo da câmera ou um raio de sombra na direção da luz
- No início, como
d = 1, ele funciona como raio da câmera
- Após a colisão,
d = Z/w se torna um valor menor que 1, o resultado da avaliação com OR em bits muda, o loop roda novamente e começa a checagem da sombra
- Se ocorrer outra colisão durante a checagem da sombra, o loop termina e aquele pixel é desenhado como sombra
- O valor de textura
s é gerado pela expressão abaixo
s = (X&Y&Z)%3/Z
- Ela aplica AND em nível de bits a
X, Y e Z, e usa o resto da divisão por 3 para formar uma textura cinza parecida com janelas
- Depois divide novamente por
Z, fazendo com que a textura desapareça aos poucos à distância
- Para enviar o raio na direção de uma luz direcional, como um sol,
a e b são ambos definidos como 1
d = Z/w é o valor da névoa de distância e serve para clarear prédios mais distantes
Desenho dos pixels e expressão de brilho
- Cada pixel é desenhado com
fillRect
c.getContext`2d`.fillRect(i%w, i/w|0, 1-d*Z/w+s, 1)
i%w gera a coordenada x, e i/w|0 gera a coordenada y
- O brilho é representado reduzindo a largura do pixel, uma técnica essencial para produzir uma imagem em escala de cinza com pouquíssimo código
1 corresponde a um pixel preto, então a expressão final subtrai valores de 1 para formar o brilho da imagem
d * Z/w combina sombra e efeito de distância
- Fora da sombra, o raio percorre até a distância máxima
w, então Z/w vira 1
- Dentro da sombra,
Z fica menor que w, deixando a imagem mais escura
- Quanto mais próximo estiver o objeto que bloqueia a luz, mais escura fica a sombra, criando um efeito parecido com ambient occlusion
- No fim,
s é somado para misturar a textura dos prédios ao brilho final
Demo posterior e ferramentas de experimentação
- Foi apresentado na Revision 2022 demo party e pode ser visto no Pouet
- Na época, foi enviado para a categoria errada e por isso não alcançou uma colocação alta
- Depois, no Shadertoy, Xor e outros coders criaram um shader de 256 bytes que reproduz a versão em JavaScript
- Daniel Darabos criou uma ferramenta no Observable que permite manipular vários elementos do programa em tempo real
- O código pode ser remixado no Dwitter e também pode ser experimentado no CapJS
1 comentários
Comentários do Hacker News
A geração procedural do jogo Elite, dos primórdios dos 8 bits: https://procedural-generation.tumblr.com/post/112509130817/e...
Documento geral sobre geração procedural: https://en.wikipedia.org/wiki/Procedural_generation
Um conceito um pouco relacionado é a avaliação preguiçosa em linguagens de programação funcionais: https://en.wikipedia.org/wiki/Lazy_evaluation
Se um algoritmo de ray tracing avalia apenas os pontos visíveis ao criar uma imagem 2D a partir de dados 3D, ele parece semelhante ao conceito de avaliação preguiçosa, de “adiar a avaliação de uma expressão até que seu valor seja necessário”: https://en.wikipedia.org/wiki/Ray_tracing_(graphics)
Indo além, superficialmente também dá a sensação de se conectar, de forma aproximada, à ideia de que “a observação colapsa a função de onda”: https://en.wikipedia.org/wiki/Wave_function_collapse
Mas esta obra é mais impressionante por ter sido feita em JavaScript, já que JavaScript costuma ter densidade de código pior que código de máquina x86 de 16 bits
Também inclui o código-fonte
http://www.youtube.com/watch?v=36BPql6Nl_U
O nome vem do fractal Menger sponge em que se baseia: https://en.wikipedia.org/wiki/Menger_sponge
A grande vantagem do JavaScript aqui é que a biblioteca dweet padrão tem funções como sin/cos/fill/line. Já a BIOS do x86 praticamente só faz algo como “troca de modo” e, depois disso, é preciso lidar diretamente com o array de pixels
Isso me faz pensar que talvez grandes modelos de linguagem não sejam a melhor forma de modelar o mundo
Eu já ficaria satisfeito se conseguisse fazer algo assim em umas 256 linhas, e na prática provavelmente levaria muito mais do que isso
Coisas visuais, sejam artificiais ou naturais, em muitos casos seguem padrões. Caso contrário, nem seriam fáceis de reconhecer em primeiro lugar. Por isso há muita coisa que pode ser explorada matematicamente. Em texturas de altura, dá para usar vários tipos de ruído ou padrões; fractais também podem ser usados de maneiras interessantes; em vegetação e árvores, Fibonacci/proporção áurea; entre elementos do esqueleto e ossos, coisas como proporções de tamanho. Para isso não é necessária uma máquina mágica de palpites
São programas do tamanho de um tweet para o console virtual Pico-8