CSS bị DOOMed
Frontend·Hacker News·0 lượt xem

CSS bị DOOMed

CSS is DOOMed

AI Summary

Một developer đã tái hiện thành công tựa game kinh điển DOOM ngay trên trình duyệt web, chỉ sử dụng CSS cho phần hiển thị và JavaScript cho logic game. Thành tựu ấn tượng này cho thấy khả năng mạnh mẽ đáng ngạc nhiên của CSS hiện đại, đặc biệt là các tính năng 3D transform và custom properties, trong việc tạo ra các layout phức tạp. Điều này mang lại bài học cho các developer rằng CSS có thể làm được nhiều hơn những gì chúng ta thường nghĩ, và việc khám phá giới hạn của nó có thể dẫn đến những minh chứng sáng tạo cho công nghệ trình duyệt. Trong khi CSS đảm nhiệm việc định vị 3D và các tính toán hình học, JavaScript vẫn đóng vai trò thiết yếu trong việc quản lý state và các logic phức tạp của game.

Mỗi bức tường, sàn nhà, thùng và imp đều là một div - DOOM được hiển thị hoàn toàn bằng CSS. Sử dụng các phép biến đổi 3D, hàm toán học CSS, @property, đường dẫn clip, định vị neo và bộ lọc SVG để xây dựng game bắn súng góc nhìn thứ nhất 3D hoàn toàn có thể chơi được trong trình duyệt mà không cần Canvas hoặc WebGL.

Không, CSS thật tuyệt vời. CSS tốt hơn bao giờ hết và nó ngày càng tốt hơn. Và đó là lý do tại sao tôi xây dựng DOOM bằng CSS. Mỗi bức tường, sàn nhà, thùng và sân khấu đều là một

, được định vị trong không gian 3D bằng cách sử dụng các phép biến đổi CSS. Logic trò chơi chạy bằng JavaScript nhưng kết xuất hoàn toàn bằng CSS. Bạn có thể chơi ngay bây giờ.

Tại sao? Bởi vì tôi muốn tìm ra giới hạn của những gì trình duyệt có thể làm được. Xem CSS hiện đại mạnh mẽ như thế nào. Và bởi vì đó là DOOM. Trong CSS. Bạn có thực sự cần thêm lý do không?

DOOM chạy trong trình duyệt được hiển thị hoàn toàn bằng CSS

Vì vậy, CSS hiện đại thật tuyệt vời. Việc bạn thậm chí có thể xây dựng một cái gì đó như thế này là bằng chứng cho thấy CSS đã phát triển đến mức nào trong 30 năm qua. Nhưng điều đó không có nghĩa là tôi không gặp vấn đề.

Ý tưởng về dự án này bắt đầu khi tôi xây dựng một phiên bản DOOM chạy trên máy hiện sóng cũ những năm 1980 của tôi. Vì vậy, rất nhiều vấn đề ban đầu đã được giải quyết. Tôi đã có mã để trích xuất bản đồ từ trò chơi gốc và một ý tưởng hay về phép toán liên quan.

Bằng chứng khái niệm đầu tiên mà tôi tạo ra hoàn toàn được tạo thủ công và xoay quanh ý tưởng thực hiện CSS nhiều nhất có thể, thậm chí cả trạng thái trò chơi, logic trò chơi và tính toán. Bây giờ điều đó hóa ra không khả thi. Kết xuất? Đúng. Tuyệt đối. Trạng thái trò chơi… vâng, bạn có thể nếu muốn. Logic? Không. Quá phức tạp. Vì vậy, tôi chia dự án thành hai. Sau khi đã chứng minh cho bản thân rằng việc kết xuất là khả thi, tôi đã sử dụng Claude để tạo một phiên bản gần đúng của vòng lặp trò chơi trong JavaScript dựa trên nguồn DOOM ban đầu, đối với tôi đây là phần kém thú vị nhất của dự án. Mã C được công khai và đã tồn tại trong nhiều năm nên không có gì mới và thách thức về điều đó. Vậy tại sao lại lãng phí thời gian chuyển nó bằng tay. Điều này cho phép tôi tập trung vào những phần tốt nhất: CSS.

Tôi đã xuất bản mã trên Github nhưng tôi muốn giải thích một chút về cách hoạt động của mã và những vấn đề tôi gặp phải trong quá trình thực hiện.

Vậy nó hoạt động như thế nào?

Trở lại thời trung học

DOOM không chỉ đưa tôi quay trở lại thời điểm tôi còn học trung học, mà khi tôi bắt đầu lên kế hoạch cho việc này, nó còn mang lại rất nhiều bài toán trung học. Hãy bắt đầu từ những điều cơ bản.

Chúng tôi sử dụng cùng dữ liệu mà công cụ DOOM ban đầu sử dụng: đỉnh, linedef, sidedef và cung, tất cả đều được trích xuất từ tệp WAD gốc đi kèm với phiên bản phần mềm chia sẻ của trò chơi vào năm 1993. Và với dữ liệu này, chúng tôi tạo ra một cảnh tĩnh từ vài nghìn phần tử

và để trình duyệt thực hiện tất cả công việc khó khăn đó.

Và chúng tôi không chỉ tính toán mọi thứ bằng JavaScript. Mỗi bức tường lấy tọa độ DOOM thô làm thuộc tính tùy chỉnh, chẳng hạn như hai cặp tọa độ x/y và chiều cao sàn và trần. Chúng tôi không trực tiếp đặt các phép biến đổi 3D hoặc chiều rộng và chiều cao của phần tử. CSS tính toán mọi thứ khác dựa trên dữ liệu chúng tôi nhận được từ tệp WAD.

θ chiều rộng ∆x Δy

Chiều rộng của bức tường? Đó là Pythagoras cũ tốt ở vùng đồng bằng giữa tọa độ bắt đầu và kết thúc. Vòng quay? Đó là tiếp tuyến nghịch đảo trên delta giữa hai bộ tọa độ. Tôi nghĩ rằng tôi phải gửi lời cảm ơn sâu sắc đến giáo viên toán trung học của tôi vì tôi vẫn nhớ cách làm điều này sau hơn 30 năm.

∆x = xend – xstart Δy = đồng yên – bắt đầu

chiều rộng = ∆ x2 + ∆ y2

θ = rám nắng –1 ( Δy ∆x )

Tất cả phép toán hình học đều diễn ra trong công cụ CSS của trình duyệt. Và thật may mắn, chúng tôi có các hàm CSS cho cả hai công thức này. Chúng ta có thể sử dụng hypot()atan2() để lấy chiều rộng và góc. Thực ra đó không phải là may mắn. Những công thức đó được cố tình thêm vào để giúp thực hiện các loại phép tính này dễ dàng hơn.

.wall {
    --delta-x: calc(var(--end-x) - var(--start-x));
    --delta-y: calc(var(--end-y) - var(--start-y));
    chiều rộng: calc(hypot(var(--delta-x), var(--delta-y)) * 1px);
    chiều cao: calc((var(--ceiling-z) - var(--floor-z)) * 1px);
    biến đổi:
        dịch3d(
            calc(var(--start-x) * 1px), calc(var(--ceiling-z) * -1px),
            calc(var(--start-y) * -1px)
        )
        xoayY(atan2(var(--delta-y), var(--delta-x)));

JavaScript truyền dữ liệu DOOM thô vào. CSS thực hiện phép đo lượng giác. Đối với tôi, sự tách biệt đó là sự cân bằng hợp lý giữa JavaScript và CSS. JavaScript chạy vòng lặp trò chơi. CSS thực hiện việc hiển thị.

Trong mã chúng ta cũng có sự phân tách nghiêm ngặt này. Vòng lặp trò chơi hoàn toàn tách biệt với trạng thái trò chơi riêng biệt. Sau đó, vòng lặp trò chơi gọi các hàm JavaScript trong trình kết xuất, hoạt động như một lớp rất mỏng xung quanh CSS. Về cơ bản, nó đặt các thuộc tính, lớp tùy chỉnh và tạo ra các phần tử HTML mới.

Bài toán tọa độ

Hệ tọa độ của DOOM không ánh xạ trực tiếp tới CSS 3D. DOOM sử dụng hệ thống 2D từ trên xuống trong đó Y tăng dần về phía bắc. CSS 3D có Y đi lên và Z đi về phía người xem. Nhưng ngoài ra chúng ta không phải thực hiện bất kỳ chuyển đổi nào giữa hai hệ tọa độ.

Đây là lý do tại sao bạn thấy tôi sử dụng translate3d(x,-z,-y) thay vì translate3d(x,y,z), vì thuộc tính tùy chỉnh của chúng tôi nằm trong tọa độ DOOM, trong khi phép biến đổi cần tọa độ CSS.

Có một kết quả đặc biệt đáng hài lòng: xoayY(atan2(var(--delta-y), var(--delta-x))) trên tường. Vì DOOM Y ánh xạ tới Z âm và CSS rotateY() xoay quanh trục tung nên các vùng delta DOOM thô sẽ cấp trực tiếp vào atan2() mà không cần bất kỳ chuyển đổi bổ sung nào. Toán học chỉ hoạt động. Đừng lo lắng nếu bạn không nhận được nó. Tôi thậm chí không chắc liệu tôi có hiểu được nó hay không. Nó hoạt động. Hãy tin tôi.

Di chuyển thế giới chứ không phải máy ảnh

Tôi không có bất kỳ kinh nghiệm nào về hiển thị ở chế độ 3D. Và điều tôi nhớ sau vài lần sử dụng phần mềm tạo mô hình 3D là bạn có một chiếc máy ảnh, bạn có thể di chuyển và tạo hoạt ảnh. Nhưng CSS không có máy ảnh. Vì vậy, chúng tôi thực hiện một thủ thuật: chúng tôi di chuyển toàn bộ thế giới theo hướng ngược lại với người chơi. Chúng tôi di chuyển thế giới xung quanh người chơi. Hóa ra đó là một trong những thủ thuật kinh điển về cách thực hiện việc này.

JavaScript chỉ đặt bốn thuộc tính tùy chỉnh trên khung nhìn: --player-x, --player-y, --player-z--player-angle. CSS thực hiện phần còn lại:

#cảnh {
    dịch: 0 0 var(--phối cảnh);    
    biến đổi:
        xoayY(calc(var(--player-angle) * -1rad))
        dịch3d(
            calc(var(--player-x) * -1px),
            calc(var(--player-z) * 1px),
            calc(var(--player-y) * 1px)
    );

Nếu bạn so sánh translate3d() với cái dành cho các bức tường, bạn sẽ nhận thấy rằng bây giờ nó là nghịch đảo. Thay vì translate3d(x,-z,-y) hiện tại chúng tôi sử dụng translate3d(-x,z,y). Điều này là do chúng ta đang chuyển thế giới theo hướng hoàn toàn ngược lại. Nếu chúng ta tiến một bước, chúng ta đang đẩy thế giới lùi lại. Nếu chúng ta đi lên cầu thang, chúng ta sẽ di chuyển cầu thang xuống dưới. Mọi thứ đều ngược lại.

translate: 0 0 var(--perspective) đầu tiên đó là một chi tiết tinh tế nhưng quan trọng. CSS phối cảnh định vị khung nhìn cách xa cảnh một khoảng nhất định. Nếu không bù đắp cho điều đó, toàn bộ thế giới dường như ở quá xa. Vì vậy, chúng tôi chuyển cảnh về phía trước theo đúng mức đó. Điều đó phải mất một chút để tìm ra. Một chi tiết khác là chúng tôi đã tách biệt nó khỏi biến đổi chính bằng cách sử dụng thuộc tính translate độc lập thay vì sử dụng hàm translate() trên transform, cho phép chuyển đổi mượt mà hơn giữa các quan điểm máy ảnh khác nhau, nhưng chúng ta sẽ quay lại vấn đề đó.

Di chuyển và quan sát xung quanh chỉ là cập nhật bốn thuộc tính tùy chỉnh. Thế thôi.

Sàn có dạng hình lõm, nghiêng sang một bên

Các phần tử DOM theo mặc định là theo chiều dọc — chúng tồn tại trong mặt phẳng x/y. Sàn nhà cần phải nằm ngang. Vì vậy, mỗi tầng đều có một rotateX(90deg) để nghiêng nó từ phương thẳng đứng sang mặt phẳng nằm ngang.

.floor {
    biến đổi:
        dịch3d(/* vị trí */)
        xoayX(90deg);

Nó phải dương 90 độ chứ không phải âm vì chúng ta cần div kéo dài về phía trước theo hướng z. Lần đầu tiên tôi đã hiểu sai điều đó. Sàn ở đó, chỉ hướng sai hướng nên người chơi không nhìn thấy được.

Sàn của DOOM không phải hình chữ nhật. Các khu vực có thể là bất kỳ đa giác nào - hình chữ L, các phòng không đều, đường cong hình tròn. Đối với những người chúng tôi sử dụng clip-path với polygon() để cắt div hình chữ nhật thành hình dạng phù hợp. Một số khu vực thậm chí còn có lỗ hổng — trụ, nền hoặc cửa sổ — và đối với những khu vực đó, chúng tôi sử dụng clip-path với path() và quy tắc lấp đầy evenodd. Điều đó cho phép chúng ta vẽ ranh giới bên ngoài và các phần cắt bên trong trong một đường dẫn SVG duy nhất, với trình duyệt chỉ vẽ các khu vực được bao quanh một số lần lẻ.

DOOM được hiển thị bằng CSS với sàn có phần cắt ra bằng cách sử dụng đường cắt cho bệ nâng

Bệ 8 mặt là khu vực có sàn nâng với đường cắt đa giác. Bản thân sàn của căn phòng được cắt bớt bởi một đường dẫn SVG để tạo ra đường cắt cho nền tảng.

Nhưng có một chi tiết mà tôi không hài lòng, đó là trong khi polygon() sử dụng tỷ lệ phần trăm để xác định đa giác, hàm path() cần phải có tất cả các phân đoạn trong không gian tọa độ CSS. Chúng tôi đã cố gắng hết sức để loại bỏ các chi tiết hiển thị CSS khỏi JavaScript… Thở dài.

Nhưng shape() đã giải cứu. Sự bổ sung mới này cho nền tảng cho phép chúng tôi viết đường dẫn bằng ngôn ngữ tự nhiên hơn, sử dụng tỷ lệ phần trăm VÀ điền vào thậm chí. Đây chính xác là những gì tôi đang tìm kiếm. Điều này có nghĩa là chúng ta chỉ có thể làm điều này với CSS hiện đại? Không. Điều này đã có thể thực hiện được một thời gian rồi. Tôi vẫn còn ấn tượng với Bản trình diễn CSS FPS của Keith Clark. CSS hiện đại giúp việc này trở nên dễ dàng và hiệu quả hơn rất nhiều.

Sắp xếp tất cả các kết cấu đó

Lát họa tiết trên các khu vực liền kề là một chi tiết khác mà phải mất một thời gian mới thực hiện được. Hai khu vực liền kề có cùng kết cấu sàn sẽ được xếp liền mạch trên đường ranh giới. Vì background-image lặp lại vô tận nên chúng ta chỉ cần đảm bảo mọi khu vực đều bắt đầu mẫu của nó từ cùng một điểm tham chiếu. Bằng cách sử dụng tọa độ thế giới làm phần bù background-position, tất cả các khu vực đều có chung một lưới kết cấu — bất kể div của mỗi khu vực bắt đầu ở đâu.

Ở bên trái, bạn có thể thấy hai phần tử có kết cấu được căn chỉnh theo chính phần tử đó, tạo ra sự ngắt quãng rõ ràng trong kết cấu. Ở bên phải, bạn có thể thấy hai phần tử sử dụng tọa độ thế giới để định vị kết cấu. Có sự chuyển tiếp liền mạch từ yếu tố này sang yếu tố khác.

Nhưng “tọa độ thế giới” là gì và chúng ta khai thác nó bằng cách nào? Nghe có vẻ hoàn thiện hơn thực tế. Giả sử chúng ta có một

được đặt ở trên cùng tại 400px200px ở bên trái. Chúng tôi sẽ đặt vị trí nền ngược lại với giá trị đó: -200px -400px. Trong mã của chúng tôi, mã này trông giống như:

.floor {
    lặp lại nền: lặp lại;
    kích thước nền: 64px 64px;
    vị trí nền: 
        calc(var(--min-x) * -1px) 
        calc(var(--max-y) * 1px);

Hoạt hình cửa, thang máy và @property

Mở một cánh cửa trong DOOM có nghĩa là nâng mức trần của một khu vực. Theo thuật ngữ CSS, điều đó đang di chuyển một loạt phần tử lên trên. Thay vì tạo hoạt ảnh riêng cho từng bức tường và trần nhà, tôi nhóm chúng vào một thùng chứa

và tạo hiệu ứng chuyển đổi của thùng chứa:

.door > .panel {
    biến đổi: dịchY(0px);
    chuyển đổi: chuyển đổi 1s dễ dàng ra vào;
}
.door[data-state="open"] > .panel {
    biến đổi: dịchY(var(--offset));

--offset được xác định bởi các định nghĩa trong tệp WAD và xác định cửa cần nâng lên bao nhiêu. Việc chuyển đổi [data-state] sẽ kích hoạt quá trình chuyển đổi CSS. Không cần vòng lặp hoạt ảnh JavaScript. Mở một cánh cửa chỉ là đặt thuộc tính trạng thái trên phần tử bên phải trong vòng lặp trò chơi. Trình kết xuất CSS đảm nhiệm hoạt ảnh.

Nhưng có một nhược điểm. Đối với thang máy, người chơi di chuyển dọc theo nền tảng, vì vậy chúng tôi cần cập nhật vị trí --player-z trong hoạt ảnh của nền tảng. Nhưng --player-z được quản lý bằng JavaScript và dựa trên thông tin chúng tôi nhận được từ trạng thái người chơi trong vòng lặp trò chơi. Vì vậy, chúng tôi thực sự không thể sử dụng hoạt ảnh CSS cho việc đó. Vì vậy, hiện tại, chúng tôi sử dụng chức năng tăng cường khối (t² × (3 – 2t)) trong JavaScript để đồng bộ hóa với quá trình chuyển đổi CSS. Đó là loại công việc. Không thực sự. Nhưng hiện tại thì thế là đủ rồi.

@property --player-z {
    cú pháp: "";
    kế thừa: đúng;
    giá trị ban đầu: 0;

Khai báo @property này giúp tất cả những điều này trở nên khả thi. Nếu không có thuộc tính tùy chỉnh đã đăng ký, bạn không thể tạo hoạt ảnh hoặc chuyển đổi chúng — trình duyệt coi chúng là chuỗi. Bằng cách đăng ký --player-z làm số, chúng tôi sẽ có được quá trình chuyển đổi suôn sẻ khi người chơi bước ra khỏi gờ đá.

Tạo hoạt ảnh và thủ thuật phản chiếu

Các hình ảnh của DOOM là hình ảnh 2D luôn hướng về phía máy ảnh — biển quảng cáo. Mỗi kẻ thù có các hình ảnh cho các góc nhìn khác nhau: phía trước, bên trái, phía sau, v.v. Trò chơi gốc sử dụng 8 góc quay nhưng chỉ lưu trữ 5 bộ khung hình độc đáo. Các phép quay từ 6 đến 8 chỉ là gương phản chiếu của các phép quay từ 2 đến 4.

Theo chiều ngang, chúng ta có các hình vẽ để đi bộ. Theo chiều dọc, chúng tôi có các bộ cho các góc khác nhau. Các hàng từ 2 đến 4 được phản chiếu khi người chơi bước sang phía bên kia.

Người chơi DOOM tạo hình ở nhiều tư thế khác nhau và quay theo nhiều hướng khác nhau

Chúng tôi cũng làm điều tương tự. JavaScript tính toán góc giữa người chơi và kẻ thù, ánh xạ nó tới một trong 8 vòng quay, sau đó chọn một hàng sprite (0–4) và cờ phản chiếu (1 hoặc -1):

.sprite {
    vị trí nền-y: calc(var(--heading) * var(--h) * -1px);
    biến đổi:
        dịchX(-50%)
        xoayY(calc(var(--player-angle) * 1rad))
        scaleX(var(--mirror, 1));

rotateY làm cho sprite quay mặt về phía camera. scaleX xử lý việc phản chiếu. Và hoạt hình đi bộ? Mỗi kẻ thù có một tấm hình với các khung cạnh nhau. Theo mặc định, hoạt ảnh sprite-cycle chạy, dịch chuyển background-position-x qua các khung bằng cách sử dụng steps():

@keyframes sprite-cycle {
  từ { nền-vị trí-x: 0; }
  đến { nền-position-x: calc(var(--w) * var(--frames) * -1px); }

Khi kẻ thù bắt đầu tấn công hoặc chết, JavaScript sẽ đặt thuộc tính data-state trên sprite. CSS chọn điều đó và sử dụng một phần khác của bảng sprite với các kích thước và số khung hình khác nhau - hoạt ảnh tấn công hoặc chết. Xóa thuộc tính sẽ trở về vị trí ban đầu. Tất cả các định nghĩa sprite đều có trong CSS, vì vậy, việc thêm loại kẻ thù mới chỉ là một vài dòng thuộc tính tùy chỉnh và phần ghi đè data-state.

Tôi gặp phải một vấn đề: tất cả kẻ thù đều hành quân một cách hoàn hảo, điều này trông thật đáng lo ngại. Chân trái của mọi thây ma đều chạm đất vào cùng một thời điểm. Cách khắc phục là độ trễ hoạt ảnh ngẫu nhiên được đặt trong JavaScript. Khi CSS random() được đưa vào trình duyệt, điều này cũng có thể chuyển sang CSS.

Đạn, vụ nổ và luồng đạn

Các loại đạn như tên lửa và quả cầu lửa imp là các div được đặt trên bảng quảng cáo, giống như các họa tiết. Nhưng thay vì cập nhật vị trí của từng khung hình từ JavaScript, chúng tôi để CSS xử lý chuyển động. Khi một đường đạn được tạo ra, vòng lặp trò chơi JavaScript sẽ tính toán nơi nó sẽ kết thúc nếu không có gì ngăn cản nó và sẽ mất bao lâu để đến đó. Sau đó, lớp trình kết xuất JavaScript mỏng sẽ tạo một phần tử DOM mới, đặt --start-x/y/z, --end-x/y/z--duration trên phần tử đó, sau đó trình duyệt sẽ hoàn toàn tự động chuyển nó từ A đến B bằng cách sử dụng hoạt ảnh CSS:

.projectile { xoay: y calc(var(--player-angle) * 1rad); hoạt hình: di chuyển đạn var(--duration) tuyến tính cả hai; } @keyframes di chuyển đạn { từ { dịch: calc(var(--start-x) * 1px) calc(var(--start-z) * -1px) calc(var(--start-y) * -1px); } đến { dịch: calc(var(--end-x) * 1px) calc(var(--end-z) * -1px) calc(var(--end-y) * -1px); }

Bằng cách sử dụng dịchxoay dưới dạng các thuộc tính CSS riêng biệt, hoạt ảnh chỉ điều khiển vị trí trong khi xoay vẫn phản ứng với --player-angle — vì vậy, quả cầu lửa luôn hướng về phía camera khi người chơi di chuyển. Trong khi đó, vòng lặp trò chơi vẫn tính toán vị trí một cách toán học để phát hiện va chạm — cùng một phép toán tuyến tính mà hoạt ảnh sử dụng. Khi một quả cầu lửa hoặc tên lửa chạm vào tường, sàn nhà, người chơi hoặc kẻ thù, vòng lặp trò chơi JavaScript chỉ loại bỏ phần tử đang bay và tạo ra một vụ nổ.

Vụ nổ đó là một bảng spritesheet ba khung hình hoạt hình với steps() và phần tử sẽ tự xóa khi hoạt ảnh kết thúc — chúng tôi chỉ lắng nghe animationend và gọi remove(). Không có bộ đếm thời gian dọn dẹp, không có sổ sách kế toán thủ công.

Những luồng đạn từ súng lục và súng ngắn hoạt động tương tự nhau — một tấm sprite nhỏ phát một lần và tự hủy.

Ánh sáng bằng bộ lọc: độ sáng()

DOOM lưu trữ mức độ nhẹ cho mỗi khu vực. Chúng tôi đặt thuộc tính đó làm thuộc tính tùy chỉnh --light trên vùng chứa cung và mọi thứ bên trong đều kế thừa thuộc tính đó:

.wall, .floor, .sprite {
    bộ lọc: độ sáng (var (--light, 1));

Dòng CSS hoàn hảo cho việc này — tất cả các bức tường, sàn nhà và các họa tiết trong khu vực tối đều tối mà không cần cài đặt độ sáng cho từng phần tử riêng lẻ. Đèn nhấp nháy trở thành hoạt ảnh khung hình chính trên --light, điều này có thể thực hiện được nhờ vào @property:

@keyframes nhấp nháy nhẹ {
  0%, 4% { --light: 1; }
  5%, 8% { --ánh sáng: 0,5; }
  9%, 50% { --light: 1; }

Bộ lọc Invisible Spectre và SVG

Có một con quái vật trong DOOM có hiệu ứng tàng hình — một hình bóng trong suốt, lung linh. Chúng tôi sao chép điều này bằng bộ lọc SVG được áp dụng qua CSS:

.sprite[data-type="spectre"] {
    bộ lọc: url(#fuzz);
    độ mờ: 0,35;

Bộ lọc SVG sử dụng feColorMatrix để tạo hình bóng đen, feTurbulence để tạo nhiễu theo quy trình và feDisplacementMap để làm biến dạng các pixel. Kết quả không hoàn toàn giống nhưng cũng đủ gần với hiệu ứng ban đầu.

DOOM đáp ứng và định vị neo

Trò chơi hoàn toàn đáp ứng. Nó hoạt động trên điện thoại - thậm chí có thể vài phút trước khi gặp sự cố. Thay đổi kích thước cửa sổ trình duyệt và mọi thứ sẽ thích ứng. Chế độ xem 3D chỉ lấp đầy mọi không gian có sẵn — phần đó thật dễ dàng. Phần khó khăn nhất là HUD.

Thanh trạng thái DOOM ban đầu là hình ảnh có chiều rộng cố định. Chúng tôi chia nó thành các phần riêng biệt — đạn, máu, mặt, áo giáp, chìa khóa — mỗi phần là thành phần riêng. Trên màn hình rộng, chúng xếp thành một hàng, giống như bản gốc. Trên màn hình hẹp, thanh trạng thái bao bọc nhiều hàng bằng cách sử dụng flex-wrap. Điều đó có nghĩa là chiều cao của thanh trạng thái thay đổi.

Và đó là một vấn đề đối với hình ảnh vũ khí, vì nó cần phải nằm ngay trên thanh trạng thái. Nếu thanh trạng thái cao một hàng thì vũ khí ở một vị trí. Nếu nó quấn thành hai hàng, vũ khí cần phải di chuyển lên trên. Đây chính xác là mục đích của việc định vị mỏ neo:

#trạng thái {
    tên neo: --status;
}
#vũ khí {
    vị trí neo: --status;
    dưới cùng: neo (trên cùng);
    trái: neo(giữa);

Vũ khí tự neo vào cạnh trên của thanh trạng thái. Dù nó có cao bao nhiêu, vũ khí cũng theo sau. Chúng tôi sử dụng kỹ thuật tương tự cho điều khiển cảm ứng trên thiết bị di động — tất cả các phím điều khiển, nút bắn và nút sử dụng đều neo vào thanh trạng thái.

Chế độ xem của khán giả

Có chế độ khán giả cho phép bạn thu nhỏ và xem toàn bộ bản đồ từ trên cao. Bạn có thể xoay xung quanh, xoay, phóng to và thu nhỏ. Và có chế độ theo dõi đặt camera phía sau người chơi, giống như chế độ xem của người thứ ba.

Điều tôi thích ở chế độ theo dõi là vị trí camera được tính toán hoàn toàn bằng CSS. Camera cần được đặt phía sau người chơi, ở một khoảng cách và độ cao nhất định. “Phía sau” phụ thuộc vào hướng người chơi đang nhìn, vì vậy chúng ta cần sin()cos() để tính toán offset:

body.spectator.follow-mode #scene { --follow-dist: calc(var(--follow-height) * 0,7); dịch: 0 10vh var(--phối cảnh); xoay: x -55deg; biến đổi: xoayY(calc(var(--player-angle) * -1rad)) dịch3d( tính toán( (var(--player-x) + sin(calc(var(--player-angle) * 1rad)) * var(--follow-dist)) * -1px ), tính toán( (var(--follow-height) + var(--player-floor)) * 1px ), tính toán( (var(--player-y) - cos(calc(var(--player-angle) * 1rad)) * var(--follow-dist)) * 1px ) );

Biến đổi xoay độc lập sẽ xoay camera để nó nhìn xuống người chơi. Nếu không có nó, máy ảnh sẽ trông ngang tầm với đường chân trời và thậm chí không thể nhìn thấy người chơi. Và bản dịch 10vh sẽ dịch chuyển trình phát xuống một chút để chúng ta có thể thấy nhiều cảnh phía trước trình phát hơn.

Và vì chúng đều là thuộc tính độc lập nên giờ đây chúng cũng chuyển đổi riêng biệt. Tôi đã sử dụng các phép biến đổi kết hợp trước đây và máy ảnh sẽ tạo ra một vòng cung thực sự kỳ lạ khi chuyển từ chế độ FPV sang chế độ theo dõi. Nhưng sau khi thay đổi nó thành các thuộc tính độc lập, nó thực sự rất mượt mà. Máy ảnh chuyển từ tầm mắt sang nhìn xuống theo một chuyển động liền mạch trong khi chúng tôi mờ dần trong hình người chơi và mờ dần trên trần nhà.

JavaScript không có tác dụng gì đặc biệt ở đây. Nó chỉ đặt --player-x, --player-y--player-angle từ vòng lặp trò chơi như mọi khi và CSS tìm ra vị trí đặt máy ảnh. Điều duy nhất mà JavaScript xử lý trong chế độ theo dõi là mức thu phóng bằng cách đặt thuộc tính tùy chỉnh --follow-height.

Khi kết xuất DOOM trong CSS, bạn có thể dễ dàng thay đổi cách xem cảnh bằng cách đặt một số thuộc tính CSS, chẳng hạn như chế độ theo dõi nơi bạn có thể nhìn thấy người chơi từ phía sau

Sự khác biệt giữa góc nhìn thứ nhất thông thường và góc nhìn khán giả của chúng ta? Chỉ là một lớp ghi đè phép biến đổi của chúng ta bằng một số phép toán khác và ẩn các giới hạn. Kết xuất là một chi tiết được xử lý bởi CSS. Cùng một cảnh 3D, các phần tử giống nhau, chỉ là một biến đổi khác trên vùng chứa.

Vấn đề loại bỏ

Hiệu suất là con voi trong phòng. Chúng tôi đang yêu cầu bộ tổng hợp của trình duyệt xử lý hàng nghìn phần tử được chuyển đổi 3D, điều này đòi hỏi rất nhiều. Bản đồ lớn có thể làm choáng ngợp trình duyệt. Và không chỉ là trình duyệt chạy chậm hơn và chuyển động trở nên giật cục. Safari trên iOS chỉ gặp sự cố nếu nó trở nên quá nhiều. Vì vậy, chúng tôi loại bỏ - chúng tôi ẩn các yếu tố nằm ngoài sự thất vọng của phối cảnh.

Bạn có thể nghĩ rằng trình duyệt sẽ tự động thực hiện việc này. Nó biết phối cảnh, nó biết vị trí của từng phần tử trong không gian 3D - nó chỉ có thể bỏ qua các phần tử hiển thị phía sau người xem hoặc bên ngoài trường nhìn. Nhưng nó không. Đơn giản là trình tổng hợp của trình duyệt không được tối ưu hóa cho những cảnh 3D như thế này. Chúng được xây dựng cho giao diện người dùng nhiều lớp chứ không phải cho hàng nghìn bề mặt trong thế giới 3D. Vì thế chúng ta phải tự mình làm điều đó. Công bằng mà nói, việc trình duyệt tối ưu hóa cho những tình huống này có thể sẽ gây lãng phí tài nguyên.

Phương pháp chọn lọc mặc định dựa trên JavaScript: cứ sau vài khung hình, chúng tôi lại kiểm tra khoảng cách của từng phần tử và liệu phần tử đó có hướng về phía máy ảnh hay không. Nếu nó ở phía sau người chơi hoặc quá xa, chúng tôi sẽ đặt ẩn trên đó.

Nhưng phiên bản thú vị hơn là việc loại bỏ CSS thuần túy thử nghiệm. Đối với mỗi phần tử, chúng tôi tính toán xem nó có ở phía trước người chơi và trong tầm nhìn hay không. Nếu không, nó sẽ bị ẩn.

Vấn đề: CSS có thể tính một số – 0 cho hiển thị và 1 cho ẩn – nhưng bạn không thể trực tiếp sử dụng số đó để đặt khả năng hiển thị. Có một tính năng mới sắp có trong CSS để giải quyết vấn đề này: if(), nhưng hiện tại tính năng này chỉ có trong Chrome.

Vì vậy, tôi đã sử dụng một thủ thuật có tên là nghiền kiểu. Bạn tạo một hoạt ảnh bị tạm dừng để chuyển đổi khả năng hiển thị giữa hiển thịẩn. Sau đó, bạn đặt độ trễ hoạt ảnh dựa trên giá trị được tính toán để xác định khung hình chính nào được sử dụng:

hoạt ảnh: tạm dừng cuối bước 1 giây;
độ trễ hoạt hình: calc(var(--cull-outside) * -0.5s);
@keyframes cull-toggle {
  0%, 49,9% { khả năng hiển thị: hiển thị; }
  50%, 100% { khả năng hiển thị: ẩn; }

Độ trễ hoạt ảnh âm trên hoạt ảnh bị tạm dừng sẽ nhảy tới điểm đó trong dòng thời gian. Vì vậy, độ trễ 0 giây nằm trong phạm vi hiển thị và -0,5 giây nằm trong phạm vi ẩn. Đó là một bản hack nhưng có chức năng. Khi CSS if() nhận được sự hỗ trợ rộng rãi hơn, chúng tôi có thể thay thế điều này bằng một điều kiện rõ ràng.

Sắp xếp theo chiều sâu

Trình duyệt xử lý việc sắp xếp theo chiều sâu một cách đáng ngạc nhiên. Hình học 2.5D của DOOM - bản đồ 2D với các bức tường thẳng đứng và sàn ngang ở các độ cao khác nhau, đảm bảo rằng chúng ta không có bất kỳ mặt phẳng giao nhau nào. Nhưng ngay cả khi chúng ta có các họa tiết di chuyển qua tường hoặc sàn nhà, trình duyệt sẽ phân chia chúng tại đường giao nhau. Điều này chỉ có tác dụng.

Vấn đề duy nhất là các bề mặt đồng phẳng. Khi một viên đạn hoặc quả cầu lửa chạm vào tường, hình ảnh tác động sẽ xuất hiện ở cùng vị trí với bề mặt tường. Hai bề mặt ở cùng độ sâu tranh nhau tầm nhìn, đôi khi gây ra hiện tượng nhấp nháy. Những lúc khác, viên đạn không nhìn thấy được hoặc bị cắt một phần. Cách khắc phục rất đơn giản: chúng tôi đặt hiệu ứng ở phía trước bức tường một chút. Đó là một khoảng chênh lệch nhỏ, đủ nhỏ để không gây chú ý và đủ lớn để đảm bảo có thể nhìn thấy được tác động của viên đạn hoặc vụ nổ quả cầu lửa.

Nơi DOOM gian lận

Một trong những khác biệt lớn giữa DOOM và trình kết xuất CSS của chúng tôi là cách chúng tôi chiếu kết cấu bầu trời. Bên trong dữ liệu bản đồ, chúng ta có các khu vực có tường được đánh dấu là có bầu trời phía trên. Hãy gọi bầu trời đó là “những bức tường”. Trong hình ảnh bên dưới, bạn có thể thấy những bức tường này có màu hồng. Sau đó, trình kết xuất chỉ vẽ kết cấu bầu trời 2D lên bức tường 3D đó. Nó không tạo cho bức tường 3D một kết cấu trong không gian 3D. Không, nó vẽ nó ở dạng 2D, điều này có thể làm được vì nó hiển thị 3D dưới dạng 2D. Chỉ là một cách hack nhanh thôi.

Nhưng chúng ta không thể làm được điều đó. Chúng tôi đang chiếu một cảnh 3D thực sự. Và điều đó có nghĩa là chúng ta không thể tùy ý hiển thị kết cấu bầu trời ở dạng 2D trong cảnh 3D của mình. Thay vào đó, những gì chúng tôi làm là đặt bầu trời 2D đằng sau khung cảnh 3D. Và điều đó có tác dụng… trừ khi nó không hoạt động.

Không phải vì DOOM gian lận. Đôi khi nó hiển thị những “bức tường” bầu trời trong khi thực sự có hình học bản đồ thực sự đằng sau nó. Vì vậy, bầu trời nằm ở phía trước một phần khác của bản đồ. Và chúng tôi hiển thị bầu trời đằng sau bản đồ. Vì vậy, chúng tôi có thể thấy những phần bản đồ mà bạn không được phép xem.

Ban đầu tôi muốn tính toán đường cắt theo hình các bức tường màu hồng, nhưng bạn cũng phải tính đến việc người chơi đang nhìn ra ngoài cửa sổ và việc tính toán trở nên thực sự phức tạp rất nhanh chóng.

Giải pháp là thêm một bước nữa vào thuật toán loại bỏ. Nó kiểm tra xem một vật phẩm có nằm sau “bức tường” bầu trời hay không theo quan điểm của người chơi và chỉ cần đặt thuộc tính hidden trên đó nếu có. Vì vậy, các “bức tường” bầu trời không chặn vật phẩm trong không gian 3D nhưng chúng chặn các vật phẩm hiển thị.

Có đáng không?

Điều tôi đặt ra là tạo ra thứ gì đó vượt qua giới hạn những gì có thể làm được với CSS. Lý tưởng nhất là tôi đã thực hiện toàn bộ dự án này bằng CSS, nhưng điều đó hiện tại không khả thi. Có, Lyra Rebane xây dựng CPU x86 hoàn toàn bằng CSS, nhưng kỹ thuật đó đơn giản là không đủ nhanh để xử lý vòng lặp trò chơi. Vì vậy, kết quả là một thứ sử dụng nhiều JavaScript. Và vòng lặp trò chơi đó thực sự là phần kém thú vị nhất trong toàn bộ dự án. Nó “chỉ” gần đúng với mã DOOM gốc, được điều chỉnh cho phù hợp với trình duyệt. Thực sự không có gì mới hay sáng tạo về nó.

Phần thú vị là trình kết xuất. Cảnh chỉ là một số thành phần HTML và việc hiển thị hoàn toàn được thực hiện bằng CSS, từ phép chiếu 3D và hoạt ảnh sprite đến các cơ chế chuyển động như thang máy, cửa và đường đạn – vòng lặp trò chơi JavaScript chỉ cung cấp cho CSS một số tọa độ mới và sau đó cho phép CSS xử lý việc cập nhật những gì người dùng nhìn thấy. Sự tách biệt nghiêm ngặt giữa các mối quan tâm.

Và nó hoạt động tốt. Nó hoạt động tốt hơn tôi từng mong đợi. Nhưng tất nhiên, nó sẽ không thay thế trình kết xuất WebGL hoặc WebGPU thích hợp. Hiệu suất bị hạn chế. Nhưng đó không phải là vấn đề. Đây là việc mở rộng ranh giới của những gì CSS có thể làm. Hàm lượng giác, hoạt ảnh @property, clip-path, bộ lọc SVG, định vị neo — đây đều là những tính năng CSS sẵn sàng sản xuất đang được sử dụng theo cách mà các tác giả thông số kỹ thuật của chúng có thể không bao giờ tưởng tượng được.

Trong quá trình thực hiện, chúng tôi cũng gặp phải một số lỗi trình duyệt. Xem Chuyển tiếp trong Safari làm phẳng hoàn toàn preserve-3d — trong quá trình chuyển đổi, trình duyệt ghi lại cảnh dưới dạng ảnh chụp nhanh 2D, do đó toàn bộ thế giới 3D trở nên phẳng. Và việc đặt background-image thông qua thuộc tính tùy chỉnh CSS (ví dụ: background-image: var(--texture-image)) gây ra sự cố nghiêm trọng trong cả Safari và Chrome. Khi mỗi khung hình, trình duyệt sẽ phân giải lại tất cả các tham chiếu var() trên mọi phần tử, kích hoạt quá trình tái tạo điểm ảnh hàng loạt cho hàng nghìn họa tiết. Cách giải quyết là đặt trực tiếp background-image làm kiểu nội tuyến. Bộ tổng hợp của Chrome cũng có tính không ổn định chung với nhiều bề mặt được chuyển đổi 3D này — các kết cấu đôi khi biến mất trong quá trình chơi trò chơi theo những cách không xảy ra trong Safari hoặc Firefox. Và cả quá trình chuyển đổi @starting-style của opacity kết hợp với display: none trên thành phần vị trí 3D dường như kích hoạt quá trình chuyển đổi liên tục trong Safari. Nhiều lỗi lạ quá. Tôi cần gửi một số báo cáo lỗi.

Và nếu không có gì khác, nó sẽ trả lời một câu hỏi chưa ai hỏi: CSS có thể chạy DOOM không?

Vâng. Có, có thể.

Tác giả: msephton

#discussion