- City In A Bottle là một demo JavaScript chứa cả engine raycasting và bộ tạo thành phố trong chỉ 256 byte HTML
- Việc chạy bắt đầu chỉ với
<canvas> và onclick=setInterval(...), rồi vẽ lại cảnh mỗi khung hình trên canvas rộng 99 pixel
- Việc render được xử lý bằng đại số cơ bản và phép toán bit thay vì lượng giác, đồng thời theo dõi tia camera và tia theo hướng nguồn sáng cho từng pixel để tạo bóng
- Hình dạng thành phố được chia thành chiều cao tòa nhà, đường sá và khoảng trống dựa trên tọa độ
X, Y, Z cùng các điều kiện như X/9^Z/8, X%99, Z>32
- Độ sáng cuối cùng được biểu diễn bằng cách trộn sương mù theo khoảng cách, bóng đổ và texture
(X&Y&Z)%3/Z vào độ rộng fillRect, nhờ đó hiện thực hóa cả thành phố, phối cảnh và chất liệu bằng đoạn mã cực nhỏ
Cấu trúc tổng thể bên trong HTML 256 byte
- Demo hoạt động không phải như một mẩu JavaScript, mà là một chương trình HTML hợp lệ duy nhất
<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)>
- Toàn bộ được chia thành mã HTML, vòng lặp cập nhật khung hình, hệ thống render, engine raycasting và logic tạo thành phố
- Nó chỉ dùng đại số cơ bản và phép toán bit thay vì lượng giác hay toán học nâng cao
- Sau khi được công bố, đây từng là một trong những bài đăng được yêu thích nhất trên dòng thời gian Twitter của tác giả trong khoảng 2 năm
HTML và vòng lặp thực thi
- HTML chỉ gồm một canvas và sự kiện click
<canvas style=width:99% id=c onclick=setInterval('',t=9)>
canvas được đặt id là c để có thể truy cập ngắn gọn trong JavaScript
style=width:99% không bắt buộc, bỏ đi vẫn chạy được
- Trong
onclick, setInterval được gọi để bắt đầu vòng lặp cập nhật
- Khoảng thời gian được đặt là
9 mili giây
- Biến thời gian
t cũng được khởi tạo là 9 ngay tại đây để tiết kiệm dung lượng
- Nếu bấm canvas nhiều lần, nhiều interval sẽ cùng chạy và làm chậm đi, đây là một lỗi nhỏ
JavaScript viết lại cho dễ đọc
- Phần payload JavaScript chạy sau khi click có kích thước 199 byte
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
- Khi thêm khoảng trắng và xuống dòng, có thể đọc theo luồng sau
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 xóa canvas, đặt chiều rộng thành 99 pixel, đồng thời lưu cùng giá trị vào w để tái sử dụng
- Chiều cao canvas mặc định là 150, nên phần bên dưới vẫn để trống
++t tăng giá trị thời gian ở mỗi khung hình để tạo animation cho cảnh
for (i = 6e3; i--;) là vòng lặp ngoài quyết định độ sáng cho từng pixel
Tính toán tia camera
- Mỗi pixel được xử lý như một tia phát ra từ camera
- Thành phần ngang được lưu trong
a
a = i % w / 50 - 1
i % w lấy vị trí ngang của pixel hiện tại, chia cho 50 rồi trừ 1 để chuẩn hóa gần đúng về khoảng từ -1 đến 1
- Thành phần dọc được lưu trong
b, và cùng giá trị đó cũng được đưa vào s để làm hiệu ứng mờ nền
b = s = 1 - i / 4e3
- Thay vì tính chính xác tỷ lệ dọc, nó được đơn giản hóa thành
i / 4e3 để giảm kích thước mã
- Sự đơn giản hóa này tạo ra một độ nghiêng gần như không đáng chú ý, nhưng tiết kiệm byte
4e3 được chọn để đẩy đường chân trời xuống thấp hơn trung tâm
- Vị trí khởi đầu của camera dùng giá trị thời gian, tạo nên cảnh chuyển động sang bên phải
X = t
Y = Z = d = 1
Y, Z và d dùng cho sương mù theo khoảng cách đều được khởi tạo bằng 1
Tạo thành phố và kiểm tra va chạm
- Vòng lặp bên trong là cốt lõi của hệ thống raycasting, đẩy
Z tiến về phía trước cho tới khi chạm vào thứ gì đó
for(; ++Z<w &
Z tăng cho đến khi nhỏ hơn w, tức 99
- Các tòa nhà, ngõ hẻm và vùng trống phía biển của thành phố được tạo ra bởi điều kiện sau
Y < 6 - (32<Z & 27<X%w && X/9^Z/8)*8%46
- Nó kiểm tra xem tia có thấp hơn chiều cao tại vị trí đó hay không để xác định va chạm
6 - có vai trò kéo kết quả chiều cao xuống dưới trung tâm và đảo nó để mặt đất nằm phía dưới
- Điều kiện trong ngoặc quyết định hình dáng thành phố
32<Z để lại một khoảng trống giữa camera và hàng tòa nhà đầu tiên
27<X%w tạo khoảng trống tuần hoàn, chia các khối đô thị như đường sá
- Với giá trị âm, biểu thức này luôn là false, nên tạo ra vùng trống giống mặt biển
X/9^Z/8 dùng XOR theo bit để tạo phân bố chiều cao tòa nhà trông như ngẫu nhiên
- Các giá trị chia
9 và 8 điều chỉnh bề rộng và chiều sâu tòa nhà
X/9 cùng các con số liên quan đến độ rộng đường đều chia hết cho 9, giúp tránh tạo ra các tòa nhà quá mỏng ở mép
- Kết quả trong ngoặc được nhân với
8 rồi lấy modulo 46 để tạo giới hạn chiều cao tối đa
8 và 46 là các giá trị được chọn bằng thử nghiệm để tạo đa dạng chiều cao công trình
Bóng đổ, texture và sương mù theo khoảng cách
- Khi có va chạm, cùng vòng lặp bên trong đó đảm nhận vai trò thứ hai là kiểm tra theo hướng nguồn sáng
d | (s = (X&Y&Z)%3/Z, a = b = 1, d = Z/w)
d | dùng để phân biệt tia hiện tại là tia đi ra từ camera hay tia kiểm tra bóng theo hướng ánh sáng
- Ban đầu
d = 1, nên nó hoạt động như tia camera
- Sau va chạm,
d = Z/w trở thành giá trị nhỏ hơn 1, kết quả đánh giá bit OR thay đổi và vòng lặp chạy lại để bắt đầu kiểm tra bóng
- Nếu trong lúc kiểm tra bóng lại va chạm lần nữa, vòng lặp sẽ thoát ra và pixel đó được vẽ như một vùng bóng
- Giá trị texture
s được tạo bởi công thức sau
s = (X&Y&Z)%3/Z
- Nó áp dụng phép AND bit trên
X, Y, Z, rồi lấy phần dư chia cho 3 để tạo texture xám trông như cửa sổ
- Sau đó lại chia cho
Z để texture ở xa mờ dần đi
- Để bắn tia theo hướng một nguồn sáng định hướng như mặt trời,
a và b đều được đặt thành 1
d = Z/w là giá trị sương mù theo khoảng cách, dùng để làm các tòa nhà xa sáng hơn
Vẽ pixel và biểu diễn độ sáng
- Mỗi pixel được vẽ bằng
fillRect
c.getContext`2d`.fillRect(i%w, i/w|0, 1-d*Z/w+s, 1)
i%w tạo tọa độ x, i/w|0 tạo tọa độ y
- Độ sáng được biểu diễn bằng cách giảm độ rộng của pixel, đây là kỹ thuật cốt lõi để tạo ảnh grayscale bằng mã cực ngắn
1 tương ứng với pixel màu đen, nên biểu thức cuối cùng tạo độ sáng của ảnh bằng cách trừ giá trị khỏi 1
d * Z/w kết hợp hiệu ứng bóng và khoảng cách
- Nếu không ở trong bóng, tia sẽ đi đến khoảng cách tối đa
w, nên Z/w bằng 1
- Trong vùng bóng,
Z nhỏ hơn w, nên nhìn tối hơn
- Vật thể chặn sáng càng gần thì bóng càng tối, tạo hiệu ứng giống ambient occlusion
- Cuối cùng cộng thêm
s để trộn texture của tòa nhà vào độ sáng cuối cùng
Demo tiếp theo và công cụ thử nghiệm
- Nó đã được gửi tham dự Revision 2022 demo party và có thể xem trên Pouet
- Khi đó tác phẩm bị gửi nhầm vào sai hạng mục nên không đạt thứ hạng cao
- Sau đó trên Shadertoy, Xor và các coder khác đã tạo ra một shader 256 byte tái hiện phiên bản JavaScript này
- Daniel Darabos đã tạo một công cụ Observable cho phép thao tác thời gian thực với nhiều thành phần của chương trình
- Mã có thể được remix trên Dwitter, và cũng có thể thử nghiệm trong CapJS
1 bình luận
Ý kiến trên Hacker News
Sinh thủ tục trong game 8-bit đời đầu Elite: https://procedural-generation.tumblr.com/post/112509130817/e...
Tài liệu chung về sinh thủ tục: https://en.wikipedia.org/wiki/Procedural_generation
Một khái niệm hơi liên quan là đánh giá lười trong ngôn ngữ lập trình hàm: https://en.wikipedia.org/wiki/Lazy_evaluation
Nếu một thuật toán ray tracing nào đó, khi tạo ảnh 2D từ dữ liệu 3D, chỉ đánh giá những điểm nhìn thấy được, thì nó trông khá giống với khái niệm đánh giá lười: “trì hoãn việc đánh giá biểu thức cho đến khi giá trị của nó thật sự cần thiết”: https://en.wikipedia.org/wiki/Ray_tracing_(graphics)
Xa hơn nữa, nhìn bề ngoài còn có cảm giác gần như liên hệ với chuyện “việc quan sát làm sụp đổ hàm sóng”: https://en.wikipedia.org/wiki/Wave_function_collapse
Tuy nhiên tác phẩm này ấn tượng hơn ở chỗ được làm bằng JavaScript. Vì JavaScript thường có mật độ mã kém hơn mã máy x86 16-bit
Cũng có kèm mã nguồn
http://www.youtube.com/watch?v=36BPql6Nl_U
Tên gọi xuất phát từ fractal Menger sponge mà nó dựa trên: https://en.wikipedia.org/wiki/Menger_sponge
Lợi thế lớn của JavaScript ở đây là thư viện dweet chuẩn có các chức năng như sin/cos/fill/line. Trong khi đó BIOS x86 về cơ bản chỉ làm được cỡ “chuyển chế độ”, rồi sau đó phải tự thao tác trực tiếp với mảng pixel
Vì thế tôi nghĩ có lẽ mô hình ngôn ngữ lớn không nhất thiết là cách tốt nhất để mô hình hóa thế giới
Nếu là tôi, làm được khoảng 256 dòng thôi chắc cũng đã hài lòng rồi, và thực tế nhiều khả năng còn mất nhiều hơn thế rất nhiều
Các thứ thị giác, dù nhân tạo hay tự nhiên, trong nhiều trường hợp đều tuân theo mẫu. Nếu không thì ngay từ đầu đã khó mà nhận ra. Vì vậy có rất nhiều thứ có thể khai thác bằng toán học. Với texture độ cao có thể dùng nhiều loại nhiễu hoặc mẫu khác nhau, fractal cũng có thể được dùng rất thú vị, với thảm thực vật và cây cối có thể dùng Fibonacci/tỷ lệ vàng, giữa các yếu tố khung xương và xương có thể dùng tỷ lệ kích thước, v.v. Những việc này không cần một cỗ máy phỏng đoán kiểu ma thuật
Đó là các chương trình cỡ một tweet dành cho máy console ảo Pico-8