3 điểm bởi GN⁺ 2024-05-21 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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>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 idc để 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, Zd 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 98 đ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
  • 846 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, ab đề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

 
GN⁺ 2024-05-21
Ý kiến trên Hacker News
  • Tài liệu liên quan có cách cấu thành thế giới của Pitfall cho Atari 2600: https://evoniuk.github.io/posts/pitfall.html
    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
  • Cùng tác giả còn có 1K Pinball Game in JavaScript: https://frankforce.com/lu1ky-pinball-code-deep-dive/
  • Remnants của Alcatraz cũng là một demo MS-DOS 256 byte tương tự, có kèm liên kết YouTube: https://www.pouet.net/prod.php
    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
    • Liên kết đúng có vẻ là bên này: https://www.pouet.net/prod.php?which=96536
      Cũng có kèm mã nguồn
    • Spongy làm được việc tương tự trong 128 byte, tức chỉ bằng một nửa kích thước: https://www.pouet.net/prod.php?which=53871
      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
    • Không hẳn mật độ mã của JavaScript lúc nào cũng kém hơn. Biến có thể dùng tên dài 1 byte, trong khi lệnh load của x86 thường từ 2 byte trở lên và hay là 3 byte
      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
  • Thật sự đáng kinh ngạc. Mật độ thông tin mà ta cảm nhận được từ lượng mã ít ỏi như vậy cao đến vô lý
    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
    • Mã bị làm rối đầy các shortcut có lẽ cũng không phải là cách tốt nhất để mô hình hóa thế giới. Dù vậy, đây chắc chắn là một thành tựu ấn tượng
      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
    • Tại sao lại nghĩ mô hình ngôn ngữ lớn là tốt nhất?
  • Rất ngầu, nhưng hơi tiếc là trong lúc đọc bài, vòng lặp cứ chạy liên tục làm laptop bị nóng lên
    • Thì ra đó là lý do mấy phút vừa rồi điện thoại của tôi nóng bất thường!
  • Nếu thích những thứ như thế này thì bạn cũng sẽ thích #tweetcart trên Twitter
    Đó là các chương trình cỡ một tweet dành cho máy console ảo Pico-8