- City In A Bottle ist eine JavaScript-Demo, die eine Raycasting-Engine und einen Stadtgenerator in 256 Byte HTML unterbringt
- Die Ausführung startet nur mit
<canvas> und onclick=setInterval(...); die Szene wird in jedem Frame auf einem 99 Pixel breiten Canvas neu gezeichnet
- Das Rendering erfolgt ohne trigonometrische Funktionen, nur mit einfacher Algebra und Bitoperationen; pro Pixel werden ein Kamerastrahl und ein Strahl in Richtung der Lichtquelle verfolgt, um Schatten zu erzeugen
- Die Stadtform trennt anhand von
X-, Y-, Z-Koordinaten und Bedingungen wie X/9^Z/8, X%99 und Z>32 Gebäudehöhen, Straßen und Freiräume
- Die endgültige Helligkeit mischt Distanznebel, Schatten und die Textur
(X&Y&Z)%3/Z in die Breite von fillRect ein und realisiert mit extrem wenig Code Stadt, Perspektive und Materialwirkung zugleich
Gesamtstruktur in 256 Byte HTML
- Die Demo funktioniert nicht als JavaScript-Schnipsel, sondern als ein einzelnes gültiges HTML-Programm
<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)>
- Das Ganze gliedert sich in HTML-Code, Frame-Update-Loop, Rendering-System, Raycasting-Engine und Stadtgenerierungslogik
- Statt trigonometrischer Funktionen oder höherer Mathematik werden nur einfache Algebra und Bitoperationen verwendet
- Nach der ursprünglichen Veröffentlichung war es etwa zwei Jahre lang einer der beliebtesten Beiträge in der Twitter-Timeline des Autors
HTML und Ausführungsschleife
- Das HTML besteht nur aus einem Canvas und einem Click-Event
<canvas style=width:99% id=c onclick=setInterval('',t=9)>
- Die
id des canvas wird auf c gesetzt, um in JavaScript kurz darauf zugreifen zu können
style=width:99% ist nicht zwingend erforderlich; es funktioniert auch ohne
- In
onclick wird setInterval aufgerufen, um die Update-Schleife zu starten
- Das Intervall ist auf
9 Millisekunden gesetzt
- Die Zeitvariable
t wird hier ebenfalls mit 9 initialisiert, um Platz zu sparen
- Wird das Canvas mehrfach angeklickt, laufen mehrere Intervalle parallel und machen die Demo langsamer – ein kleiner Bug
Lesbar ausgeschriebenes JavaScript
- Die nach dem Klick ausgeführte JavaScript-Payload ist 199 Byte groß
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
- Mit Leerzeichen und Zeilenumbrüchen lässt sich der Ablauf so lesen
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 löscht das Canvas, setzt die Breite auf 99 Pixel und speichert denselben Wert zur Wiederverwendung in w
- Die Standardhöhe des Canvas beträgt 150; der Bereich darunter bleibt leer
++t erhöht in jedem Frame den Zeitwert und animiert so die Szene
for (i = 6e3; i--;) ist die äußere Schleife, die die Helligkeit jedes Pixels bestimmt
Berechnung der Kamerastrahlen
- Jedes Pixel wird als ein einzelner Strahl behandelt, der von der Kamera ausgeht
- Die horizontale Komponente wird in
a gespeichert
a = i % w / 50 - 1
i % w liefert die horizontale Position des aktuellen Pixels; geteilt durch 50 und minus 1 wird sie grob auf einen Wert zwischen -1 und 1 normalisiert
- Die vertikale Komponente wird in
b gespeichert; derselbe Wert landet auch in s für das Hintergrund-Fading
b = s = 1 - i / 4e3
- Statt eines exakten vertikalen Seitenverhältnisses wird mit
i / 4e3 vereinfacht, um Codegröße zu sparen
- Diese Vereinfachung erzeugt eine kaum wahrnehmbare Schrägstellung, spart aber Bytes
4e3 wurde gewählt, um den Horizont unter die Bildmitte zu verschieben
- Die Startposition der Kamera nutzt den Zeitwert und erzeugt eine Szene, die sich nach rechts bewegt
X = t
Y = Z = d = 1
Y, Z und das für Distanznebel verwendete d werden alle mit 1 initialisiert
Stadtgenerierung und Kollisionsprüfung
- Die innere Schleife ist der Kern des Raycasting-Systems und bewegt
Z vorwärts, bis etwas getroffen wird
for(; ++Z<w &
Z wird erhöht, solange es kleiner als w, also 99, ist
- Gebäude, Gassen und Freiräume zur Meerseite hin entstehen durch die folgende Bedingung
Y < 6 - (32<Z & 27<X%w && X/9^Z/8)*8%46
- Es wird geprüft, ob der Strahl unterhalb der Höhe an dieser Position liegt, um die Kollision zu bestimmen
6 - verschiebt das Höhenergebnis nach unten in Richtung Mitte und kehrt es um, sodass der Boden unten liegt
- Die Bedingung in Klammern bestimmt die Form der Stadt
32<Z lässt Platz zwischen Kamera und der ersten Gebäudereihe
27<X%w erzeugt periodische Freiräume und teilt die Stadtblöcke wie Straßen auf
- Bei negativen Werten ist es immer false, wodurch leere Bereiche wie Meer entstehen
X/9^Z/8 nutzt bitweises XOR, um eine zufällig wirkende Verteilung der Gebäudehöhen zu erzeugen
- Die Divisoren
9 und 8 steuern Gebäudebreite und -tiefe
X/9 ist zusammen mit den Zahlen für die Straßenbreite durch 9 teilbar und verhindert so, dass an den Rändern extrem dünne Gebäude entstehen
- Das Klammerergebnis wird mit
8 multipliziert und per Modulo 46 begrenzt, um den maximalen Höhenbereich zu erzeugen
8 und 46 wurden experimentell gewählt, um unterschiedliche Gebäudehöhen zu erzeugen
Schatten, Textur und Distanznebel
- Bei einer Kollision übernimmt dieselbe innere Schleife eine zweite Aufgabe und führt eine Prüfung in Richtung der Lichtquelle aus
d | (s = (X&Y&Z)%3/Z, a = b = 1, d = Z/w)
d | unterscheidet, ob der aktuelle Strahl von der Kamera ausgeht oder als Strahl in Lichtrichtung auf Schatten prüft
- Anfangs ist
d = 1, daher arbeitet er als Kamerastrahl
- Nach einer Kollision wird
d = Z/w, also kleiner als 1; dadurch ändert sich das Ergebnis der bitweisen OR-Auswertung, die Schleife läuft erneut, und die Schattenprüfung beginnt
- Kommt es während der Schattenprüfung erneut zu einer Kollision, wird die Schleife verlassen und das betreffende Pixel als Schatten gezeichnet
- Der Texturwert
s wird mit folgendem Ausdruck erzeugt
s = (X&Y&Z)%3/Z
- Durch bitweises AND auf
X, Y und Z sowie den Rest bei Division durch 3 entsteht eine grau wirkende Textur, die an Fenster erinnert
- Die anschließende Division durch
Z lässt weit entfernte Texturen verblassen
- Um den Strahl in Richtung einer sonnenähnlichen gerichteten Lichtquelle zu schicken, werden
a und b beide auf 1 gesetzt
d = Z/w ist der Distanznebelwert und wird verwendet, um weit entfernte Gebäude heller zu machen
Pixel zeichnen und Helligkeit darstellen
- Jedes Pixel wird mit
fillRect gezeichnet
c.getContext`2d`.fillRect(i%w, i/w|0, 1 - d*Z/w + s, 1)
i%w ergibt die x-Koordinate, i/w|0 die y-Koordinate
- Die Helligkeit wird durch Verkleinern der Pixelbreite dargestellt; das ist die zentrale Technik, um mit wenig Code ein Graustufenbild zu erzeugen
1 entspricht einem schwarzen Pixel, daher erzeugt der endgültige Ausdruck die Bildhelligkeit, indem er einen Wert von 1 abzieht
d * Z/w kombiniert Schatten- und Distanzeffekte
- Ohne Schatten bewegt sich der Strahl bis zur maximalen Distanz
w, sodass Z/w den Wert 1 annimmt
- Im Schatten ist
Z kleiner als w, wodurch das Ergebnis dunkler erscheint
- Je näher das lichtblockierende Objekt ist, desto dunkler wird der Schatten, was einen Effekt ähnlich Ambient Occlusion hinterlässt
- Am Ende wird
s addiert, um die Gebäudetextur in die endgültige Helligkeit einzumischen
Nachfolgende Demos und Experimentierwerkzeuge
- Die Demo wurde bei der Revision 2022 demo party eingereicht und ist auf Pouet zu sehen
- Damals wurde sie in der falschen Kategorie eingereicht und erreichte deshalb keine hohe Platzierung
- Später erstellten Xor und andere Coder auf Shadertoy 256-Byte-Shader, die die JavaScript-Version nachbildeten
- Daniel Darabos erstellte ein Observable-Werkzeug, mit dem sich verschiedene Elemente des Programms in Echtzeit manipulieren lassen
- Der Code kann auf Dwitter remixt werden; auch in CapJS lässt sich damit experimentieren
1 Kommentare
Hacker-News-Meinungen
Prozedurale Generierung im frühen 8-Bit-Spiel Elite: https://procedural-generation.tumblr.com/post/112509130817/e...
Allgemeiner Artikel zu prozeduraler Generierung: https://en.wikipedia.org/wiki/Procedural_generation
Ein etwas verwandtes Konzept ist auch die Lazy Evaluation in funktionalen Programmiersprachen: https://en.wikipedia.org/wiki/Lazy_evaluation
Wenn ein Raytracing-Algorithmus beim Erzeugen eines 2D-Bildes aus 3D-Daten nur die sichtbaren Punkte auswertet, wirkt das ähnlich wie das Konzept der Lazy Evaluation, also „die Auswertung eines Ausdrucks aufzuschieben, bis sein Wert benötigt wird“: https://en.wikipedia.org/wiki/Ray_tracing_(graphics)
Darüber hinaus fühlt es sich oberflächlich auch grob mit „Beobachtung lässt die Wellenfunktion kollabieren“ verbunden an: https://en.wikipedia.org/wiki/Wave_function_collapse
Allerdings ist dieses Werk noch beeindruckender, weil es in JavaScript gebaut wurde. JavaScript hat nämlich oft eine schlechtere Codedichte als 16-Bit-x86-Maschinencode
Der Quellcode ist ebenfalls enthalten
http://www.youtube.com/watch?v=36BPql6Nl_U
Der Name stammt vom zugrunde liegenden Menger-Schwamm-Fraktal: https://en.wikipedia.org/wiki/Menger_sponge
Der große Vorteil von JavaScript hier ist, dass die Standard-dweet-Bibliothek Funktionen wie sin/cos/fill/line enthält. Das x86-BIOS hingegen erledigt im Grunde nur so etwas wie den „Moduswechsel“; danach muss man einfach direkt ein Pixel-Array bearbeiten
Deshalb kommt mir der Gedanke, dass große Sprachmodelle vielleicht nicht die beste Methode sind, die Welt zu modellieren
Ich wäre schon zufrieden, wenn ich so etwas in etwa 256 Zeilen hinbekäme, und in Wirklichkeit würde ich wahrscheinlich deutlich mehr brauchen
Visuelle Dinge, ob künstlich oder natürlich, folgen in vielen Fällen Mustern. Sonst wären sie von vornherein schwer zu erkennen. Deshalb lässt sich mathematisch viel damit anfangen. Für Höhentexturen kann man verschiedene Arten von Rauschen oder Mustern verwenden, Fraktale lassen sich ebenfalls interessant einsetzen, für Vegetation und Bäume Fibonacci/Goldener Schnitt, zwischen Skelett-Elementen und Knochen Größenverhältnisse und Ähnliches. Dafür braucht man keine magische Vermutungsmaschine
Das sind tweetgroße Programme für die virtuelle Konsole Pico-8