不,CSS 很棒。CSS 比以往任何時候都更好,而且只會越來越好。這就是為什麼我用 CSS 打造了 DOOM。每一面牆、地板、桶子和惡魔都是一個 `<div>`,利用 CSS transform 在 3D 空間中定位。遊戲邏輯運行在 JavaScript 中,但渲染完全由 CSS 完成。你現在就可以玩了。
為什麼?因為我想找出瀏覽器極限。看看現代 CSS 有多強大。而且因為這是 DOOM。用 CSS 寫的。你真的需要更多理由嗎?
所以,現代 CSS 很棒。你甚至能打造出這樣的東西,證明了 CSS 在過去 30 年裡取得了多大的進步。但這並不意味著我沒有遇到問題。這個專案的想法始於我打造了一個運行在我老舊的 1980 年代示波器上的 DOOM 版本。所以很多最初的問題都已經解決了。我有提取遊戲地圖的程式碼,以及對涉及數學的良好理解。
我創建的第一個概念驗證是完全手工製作的,並且圍繞著盡可能多地在 CSS 中完成的想法,甚至包括遊戲狀態、遊戲邏輯和計算。現在看來這並不可行。渲染?是的。絕對可以。遊戲狀態……嗯,如果你想,也可以。邏輯?不行。太複雜了。所以我把專案分成了兩部分。一旦我證明了渲染是可行的,我就使用 Claude 根據原始 DOOM 原始碼,在 JavaScript 中創建了一個近似的遊戲循環版本,對我來說這是專案中最不有趣的部分。C 程式碼是公開的,而且已經公開多年了,所以這方面沒有什麼新鮮或具挑戰性的。所以為什麼要浪費時間手動移植它。這讓我能夠專注於最好的部分:CSS。
我在 Github 上發布了程式碼,但我想稍微解釋一下它是如何工作的,以及我在過程中遇到的問題。
DOOM 不僅把我帶回了高中時代,而且當我開始規劃這件事時,也讓我想起了很多高中數學。讓我們從基礎開始。
我們使用原始 DOOM 引擎使用的相同數據:頂點、線段、側面和扇區,所有這些都從 1993 年遊戲的共享軟體版本附帶的原始 WAD 文件中提取。利用這些數據,我們創建了一個由數千個 `<div>` 元素組成的靜態場景,讓瀏覽器完成所有繁重的工作。
而且我們不只是在 JavaScript 中計算一切。每面牆都獲得其原始 DOOM 座標作為自定義屬性,例如兩對 x/y 座標以及地板和天花板的高度。我們不直接設置元素的 3D transform 或寬度和高度。CSS 會根據我們從 WAD 文件中獲取的數據計算所有其他內容。
牆的寬度?這就是起點和終點座標之間差異的畢氏定理。旋轉?這是兩個座標集之間差異的反正切。我想我應該非常感謝我的高中數學老師,因為 30 多年後我仍然記得如何做到這一點。
Δ x = x 結束 – x 開始 Δ y = y 結束 – y 開始
θ = tan – 1 ( Δ y Δ x )
所有的幾何數學都發生在瀏覽器的 CSS 引擎中。幸運的是,我們有這兩個公式的 CSS 函數。我們可以分別使用 `hypot()` 和 `atan2()` 來獲得寬度和角度。實際上這不是運氣。這些公式是故意添加的,以便更容易進行此類計算。
JavaScript 輸入原始 DOOM 數據。CSS 進行三角學計算。對我來說,這種分離是 JavaScript 和 CSS 之間的正確平衡。JavaScript 運行遊戲循環。CSS 進行渲染。
在程式碼中,我們也有這種嚴格的分離。遊戲循環完全獨立,具有獨立的遊戲狀態。然後遊戲循環調用渲染器中的 JavaScript 函數,它充當 CSS 的一個非常薄的層。它基本上設置自定義屬性、類別並創建新的 HTML 元素。
DOOM 的座標系統不直接映射到 CSS 3D。DOOM 使用自頂向下的 2D 系統,其中 Y 軸向北增加。CSS 3D 的 Y 軸向上,Z 軸朝向查看者。但除此之外,我們不必在兩個座標系統之間進行任何轉換。
這就是為什麼你會看到我使用 `translate3d(x,-z,-y)` 而不是 `translate3d(x,y,z)`,因為我們的自定義屬性是以 DOOM 座標表示的,而 transform 需要 CSS 座標。
有一個特別令人滿意的結果:牆上的 `rotateY(atan2(var(--delta-y), var(--delta-x)))`。因為 DOOM 的 Y 映射到負 Z,而 CSS 的 `rotateY()` 圍繞垂直軸旋轉,原始 DOOM 的差值直接饋送到 `atan2()` 而無需任何額外轉換。數學就是這麼算的。如果你不明白,別擔心。我甚至不確定我是否明白。它就是能用。相信我。
我沒有 3D 渲染經驗。我記得幾次使用 3D 建模軟體的經驗是,你會有一個相機,你可以移動和動畫它。但 CSS 沒有相機。所以我們用一個技巧:我們將整個世界朝與玩家相反的方向移動。我們圍繞玩家移動世界。這恰好是這種做法的經典技巧之一。
JavaScript 只在視口上設置四個自定義屬性:`--player-x`、`--player-y`、`--player-z` 和 `--player-angle`。CSS 處理其餘部分:
如果你比較 `translate3d()` 和牆壁的 `translate3d()`,你會注意到它現在是反向的。我們使用 `translate3d(-x,z,y)` 而不是 `translate3d(x,-z,-y)`。這是因為我們正在以完全相反的方向移動世界。如果我們向前走一步,我們就是在向後移動世界。如果我們爬樓梯,我們就是在讓樓梯向下移動。一切都是反向的。
第一個 `translate: 0 0 var(--perspective)` 是一個微妙但重要的細節。CSS 的 `perspective` 將視點設置在離場景一定距離處。如果不進行補償,整個世界看起來會太遠。所以我們將場景向前移動精確的量。這花了一點時間才弄清楚。另一個細節是,我們將它與主 transform 分開,使用獨立的 `translate` 屬性而不是在 `transform` 上使用 `translate()` 函數,這允許在不同的攝影機視角之間進行更平滑的過渡,但我們稍後會回到這個問題。
移動和環顧四周只是更新四個自定義屬性。就是這樣。
DOM 元素預設是垂直的——它們存在於 x/y 平面上。地板需要是水平的。所以每個地板都會得到一個 `rotateX(90deg)` 來將其從垂直翻轉到水平平面。
必須是正 90 度,而不是負度,因為我們需要 div 在 z 方向上向前延伸。我第一次弄錯了。地板在那裡,只是方向錯了,所以從玩家的視角看是看不見的。
DOOM 的地板不是矩形的。扇區可以是任何多邊形——L 形、不規則房間、圓形曲線。對於這些,我們使用 `clip-path` 和 `polygon()` 將矩形 div 裁剪成正確的形狀。有些扇區甚至有孔——柱子、平台或窗戶——對於這些,我們使用 `clip-path` 和 `path()` 以及 `evenodd` 填充規則。這使我們能夠在單個 SVG 路徑中繪製外部邊界和內部切口,瀏覽器只繪製被奇數次包圍的區域。
八邊形平台是一個具有抬高地板和多邊形裁剪路徑的扇區。房間本身的地板被 SVG 路徑裁剪,以獲得平台的切口。
但這裡有一個細節我不太滿意,那就是 `polygon()` 使用百分比定義多邊形,而 `path()` 函數需要所有段都在 CSS 座標空間中。我們非常努力地想將 CSS 渲染細節排除在 JavaScript 之外……唉。但 `shape()` 來拯救了。這個添加到平台的新功能使我們能夠以更自然的語言編寫路徑,使用百分比和 `evenodd` 填充。這正是我想要的。現在這是否意味著我們只能用現代 CSS 來做?不。這已經可能有一段時間了。我仍然對 Keith Clark 的 CSS FPS 演示感到敬畏。現代 CSS 只是讓它變得更容易、更高效。
紋理在相鄰扇區之間的平鋪是另一個花了一段時間才弄對的細節。共享相同地板紋理的兩個相鄰扇區應該無縫平鋪。由於 `background-image` 無限重複,我們只需要確保每個扇區都從相同的參考點開始其圖案。通過使用世界座標作為背景位置偏移,所有扇區共享相同的紋理網格——無論每個扇區的 div 碰巧從哪裡開始。
左邊你可以看到兩個元素,它們的紋理與元素本身對齊,產生了明顯的紋理斷裂。右邊你可以看到兩個元素,它們使用世界座標來定位紋理。它們之間有無縫的過渡。
但什麼是「世界座標」?我們如何接入它?它聽起來比實際複雜。假設我們有一個 `<div>`,它位於頂部 400px 處,左側 200px 處。我們將背景位置設置為其反向:-200px -400px。在我們的程式碼中,它看起來像這樣:
在 DOOM 中打開門意味著抬高扇區的天花板。用 CSS 的話來說,就是向上移動一堆元素。而不是單獨動畫每一面牆和天花板,我們將它們分組到一個容器 `<div>` 中並動畫容器的 `transform`:
`--offset` 由 WAD 文件中的定義決定,並定義門需要抬高多少。切換 `[data-state]` 會觸發 CSS 過渡。無需 JavaScript 動畫循環。打開門只是從遊戲循環中為正確的元素設置一個狀態屬性。CSS 渲染器負責動畫。
但有一個問題。對於電梯,玩家會隨著平台一起移動,所以我們需要在平台動畫期間更新 `--player-z` 位置。但是 `--player-z` 由 JavaScript 管理,並且基於我們從遊戲循環中的玩家狀態獲取的信息。所以我們不能真正使用 CSS 動畫來實現這一點。所以目前我們在 JavaScript 中使用三次緩入緩出(t² × (3 – 2t))函數來與 CSS 過渡保持同步。這有點用。不太好用。但目前已經足夠了。
這個 `@property` 聲明是讓這一切成為可能的原因。沒有註冊的自定義屬性,你無法動畫或過渡它們——瀏覽器將它們視為字符串。通過將 `--player-z` 註冊為數字,我們在玩家走下懸崖時獲得了平滑的下降過渡。
DOOM 的精靈是 2D 圖像,始終面向攝影機——視牌。每個敵人都有不同視角的精靈:正面、左側、背面等。原始遊戲使用 8 個旋轉角度,但只存儲 5 組獨特的幀。角度 6 到 8 是角度 2 到 4 的鏡像。
水平方向有行走時的精靈。垂直方向有不同角度的組。當玩家向另一側行走時,第 2 到第 4 行會被鏡像。
我們也做同樣的事情。JavaScript 計算玩家與敵人之間的角度,將其映射到 8 個角度之一,然後選擇一個精靈行(0-4)和一個鏡像標誌(1 或 -1):
`rotateY` 使精靈面向攝影機。`scaleX` 處理鏡像。那麼行走動畫呢?每個敵人都有一個精靈圖,幀並排排列。預設情況下,`sprite-cycle` 動畫運行,它使用 `steps()` 在幀之間移動 `background-position-x`:
當敵人開始攻擊或死亡時,JavaScript 會在精靈上設置一個 `data-state` 屬性。CSS 會識別它並使用精靈圖的不同部分,具有不同的尺寸和幀數——攻擊或死亡動畫。清除該屬性會返回到原始位置。所有精靈定義都位於 CSS 中,因此添加新的敵人類型只需幾行自定義屬性和 `data-state` 覆蓋。
我遇到的問題之一是:所有敵人都完美地同步行進,這看起來非常令人不安。每個殭屍人的左腳在同一時刻著地。解決方法是在 JavaScript 中設置隨機的 `animation-delay`。當 CSS `random()` 在瀏覽器中可用時,這也可以移到 CSS 中。
彈藥,如火箭和惡魔火球,就像精靈一樣是視牌 `<div>`。但我們不是從 JavaScript 中每幀更新它們的位置,而是讓 CSS 處理移動。當彈藥生成時,JavaScript 遊戲循環會計算它在沒有任何東西阻止它的情況下會到達哪裡,以及它需要多長時間才能到達。然後,薄的 JavaScript 渲染器層會創建一個新的 DOM 元素,設置元素的 `--start-x/y/z`、`--end-x/y/z` 和 `--duration`,然後瀏覽器會通過 CSS 動畫完全自動地將其從 A 飛到 B:
通過使用 `translate` 和 `rotate` 作為獨立的 CSS 屬性,動畫只驅動位置,而 `rotate` 保持對 `--player-angle` 的響應——因此火球在玩家移動時仍然面向攝影機。同時,遊戲循環仍然在數學上計算碰撞檢測的位置——與動畫使用的線性數學相同。當火球或火箭擊中牆壁、地板、玩家或敵人時,JavaScript 遊戲循環只需在飛行中移除該元素並生成爆炸。
手槍和霰彈槍的子彈煙塵工作方式類似——一個播放一次並自毀的小型精靈圖。
DOOM 為每個扇區存儲一個亮度級別。我們將其設置為扇區容器上的 `--light` 自定義屬性,並且其中的所有內容都會繼承它:
CSS 級聯非常適合此——黑暗扇區中的所有牆壁、地板和精靈都變暗,而無需單獨設置每個元素的亮度。閃爍的燈光變成 `--light` 上的關鍵幀動畫,這要歸功於 `@property`:
DOOM 中有一個怪物具有隱形效果——一個閃爍、透明的剪影。我們使用通過 CSS 應用的 SVG 濾鏡來複製它:
SVG 濾鏡使用 `feColorMatrix` 創建黑色剪影,`feTurbulence` 創建程序化噪點,`feDisplacementMap` 扭曲像素。結果不完全相同,但足以接近原始效果。
遊戲是完全響應式的。它可以在手機上運行——也許幾分鐘後就會崩潰。調整瀏覽器窗口大小,一切都會適應。3D 視口只是填滿可用空間——這部分很容易。棘手的部分是 HUD。
原始 DOOM 狀態欄是一個固定寬度的圖像。我們將其分成獨立的部分——彈藥、生命值、臉部、護甲、鑰匙——每個部分都是一個獨立的元素。在寬屏幕上,它們像原始遊戲一樣位於單一行。在窄屏幕上,狀態欄使用 `flex-wrap` 包裝成多行。這意味著狀態欄的高度會變化。
這對武器精靈來說是個問題,因為它需要正好位於狀態欄的頂部。如果狀態欄只有一行高,武器就在一個位置。如果它包裝成兩行,武器就需要向上移動。這正是錨點定位的作用:
武器將自己錨定在狀態欄的頂邊。無論它有多高,武器都會跟隨。我們對移動設備上的觸摸控件使用相同的技術——操縱桿、開火按鈕和使用按鈕都錨定在狀態欄上。
有一個旁觀者模式,可以讓你縮小並從上方看到整個地圖。你可以平移、旋轉、縮放。還有一個跟隨模式,將攝影機放在玩家後面,類似於第三人稱視角。
我喜歡跟隨模式的一點是,攝影機位置完全在 CSS 中計算。攝影機需要放置在玩家後面,保持一定的距離和高度。「後面」取決於玩家面向的方向,所以我們需要 `sin()` 和 `cos()` 來計算偏移量:
獨立的 `rotate` transform 將攝影機旋轉,使其向下看玩家。沒有它,攝影機將與地平線齊平,甚至看不到玩家。而 `10vh` 的 `translate` 將玩家向下移動一點,這樣我們可以看到玩家前方更多的場景。由於它們都是獨立的屬性,它們現在也獨立過渡。我以前使用過組合 transform,攝影機在從 FPV 到跟隨模式的過渡中會產生一個非常奇怪的弧線。但在將其更改為獨立屬性後,它變得非常、非常流暢。攝影機以眼睛的高度變成向下看,同時我們淡入玩家精靈並淡出天花板。
JavaScript 在這裡沒有做任何特別的事情。它只是像往常一樣從遊戲循環中設置 `--player-x`、`--player-y` 和 `--player-angle`,然後 CSS 會弄清楚如何放置攝影機。在跟隨模式下,JavaScript 處理的唯一事情是通過設置 `--follow-height` 自定義屬性來控制縮放級別。
常規第一人稱視角和我們的旁觀者視角之間的區別是什麼?只是一個覆蓋我們的 transform 並使用一些不同的數學並隱藏天花板的類。渲染是 CSS 處理的細節。相同的 3D 場景,相同的元素,只是容器上的 transform 不同。
效能是房間裡的大象。我們要求瀏覽器的合成器處理數千個 3D 轉換的元素,這要求很高。大型地圖會壓垮瀏覽器。不僅僅是瀏覽器運行速度變慢,移動變得斷斷續續。Safari 在 iOS 上如果負載過重就會崩潰。所以我們進行剔除——我們隱藏不在透視視錐體內的元素。
你會認為瀏覽器會自動執行此操作。它知道透視,它知道每個元素在 3D 空間中的位置——它可以跳過渲染在視圖後面或視野外的元素。但它沒有。瀏覽器合成器根本沒有針對此類 3D 場景進行優化。它們是為分層 UI 而構建的,而不是為 3D 世界中的數千個表面而構建的。所以我們必須自己做。好吧,這對瀏覽器來說可能是在浪費資源來優化這些場景。
預設的剔除方法是基於 JavaScript 的:每隔幾幀,我們都會檢查每個元素的距離以及它是否面向攝影機。如果它在玩家後面或太遠,我們就將 `hidden` 屬性設置給它。
但更有趣的版本是實驗性的純 CSS 剔除。對於每個元素,我們計算它是否在玩家前面並且在視野範圍內。如果不在,它應該被隱藏。
問題是:CSS 可以計算一個數字——0 表示可見,1 表示隱藏——但你不能直接使用這個數字來設置 `visibility`。CSS 有一個即將推出的新功能可以解決這個問題:`if()`,但目前它才剛剛在 Chrome 中推出。
所以我用了一個叫做「類型研磨」的技巧。你創建一個暫停的動畫,它在可見和隱藏之間切換 `visibility`。然後你根據計算出的值設置 `animation-delay` 來確定使用哪個關鍵幀:
暫停動畫的負 `animation-delay` 會跳到時間軸上的該點。所以 0 秒的延遲落在可見範圍內,而 -0.5 秒落在隱藏範圍內。這是一個技巧,但它是有效的。當 CSS `if()` 獲得更廣泛的支持時,我們可以將其替換為乾淨的條件。
瀏覽器對深度排序的處理出奇地好。DOOM 的 2.5D 幾何結構——一個具有不同高度的垂直牆壁和水平地板的 2D 地圖,確保我們沒有任何相交的平面。但即使有精靈穿過牆壁或地板,瀏覽器也會在交叉線上將它們分割。這就是有效的方法。
唯一的問題是共面表面。當子彈或火球擊中牆壁時,撞擊精靈會以與牆壁表面完全相同的位置生成。同一深度上的兩個表面爭奪可見性,有時會導致閃爍。其他時候,子彈擊中不可見,或部分被切斷。解決方法很簡單:我們將效果定位在牆壁稍微靠前的位置。這是一個小的偏移量,小到不易察覺,大到足以確保子彈撞擊或火球爆炸可見。
DOOM 和我們的 CSS 渲染器之間的一個主要區別是我們投影天空紋理的方式。在地圖數據中,我們有標記有天空在其上方的扇區。我們稱之為「天空牆」。下圖中可以看到這些牆壁是粉紅色的。然後渲染器只是將 2D 天空紋理繪製到該 3D 牆壁上。它不是在 3D 空間中為 3D 牆壁設置紋理。不,它以 2D 方式繪製,因為它以 2D 方式渲染 3D。這只是一個快速的技巧。
但我們不能這樣做。我們正在投影一個真正的 3D 場景。這意味著我們不能任意地在我們的 3D 場景中以 2D 方式顯示天空紋理。我們所做的是將我們的 2D 天空放在 3D 場景後面。這樣就可以了……除非不行。不行是因為 DOOM 作弊。有時它會在實際上是地圖幾何結構的後面顯示「天空牆」。所以天空位於地圖另一部分的正面。而我們顯示天空在地圖後面。所以我們可以看到你本來不應該看到的地图部分。
起初我想計算一個形狀為粉紅色牆壁的裁剪路徑,但你也必須考慮到玩家正在從窗戶向外看,計算會很快變得非常複雜。
解決方案是為剔除算法添加另一個步驟。它檢查一個物體是否在地圖玩家的視角後面,如果是,就簡單地將 `hidden` 屬性設置給它。所以「天空牆」不會在 3D 空間中阻擋物體,但它們會阻止物體被渲染。
我著手做的是創造一些能挑戰 CSS 可能性的東西。理想情況下,我會用 CSS 完成整個專案,但目前這不可行。是的,Lyra Rebane 用 CSS 完全構建了一個 x86 CPU,但那種技術不足以處理遊戲循環。所以結果是使用了大量 JavaScript。而那個遊戲循環老實說是整個專案中最不有趣的部分。它「僅僅」是原始 DOOM 代碼的近似值,為瀏覽器做了適配。真的沒有什麼新鮮或創意的。
有趣的部分是渲染器。場景只是一些 HTML 元素,渲染完全由 CSS 完成,從 3D 投影和精靈動畫到移動機制,如電梯、門和彈藥——JavaScript 遊戲循環只給 CSS 一些新的座標,然後讓 CSS 處理更新用戶看到的東西。嚴格的分離關注點。
而且效果很好。效果比我預期的要好得多。但當然,它無法取代真正的 WebGL 或 WebGPU 渲染器。效能有限。但這不是重點。這是關於挑戰 CSS 的極限。三角函數、`@property` 動畫、`clip-path`、SVG 濾鏡、錨點定位——這些都是生產級的 CSS 功能,以其規範作者可能從未想像過的方式使用。
我們在過程中還遇到了一些瀏覽器錯誤。Safari 中的視圖轉換會完全壓平 `preserve-3d`——在轉換期間,瀏覽器將場景捕捉為 2D 快照,因此整個 3D 世界變平了。通過 CSS 自定義屬性設置 `background-image`(例如 `background-image: var(--texture-image)`)會在 Safari 和 Chrome 中都導致嚴重問題。當瀏覽器在每一幀重新解析每個元素上的所有 `var()` 引用時,會觸發數千個紋理的大規模重新光柵化。解決方法是直接將 `background-image` 設置為內聯樣式。Chrome 的合成器在處理如此多的 3D 轉換表面時也存在一般不穩定性——紋理偶爾會在遊戲過程中消失,這在 Safari 或 Firefox 中不會發生。此外,`@starting-style` 的 `opacity` 過渡與 3D 定位元素上的 `display: none` 結合似乎會在 Safari 中觸發連續過渡。所以有很多奇怪的錯誤。我有一些錯誤報告要提交。
如果沒有別的,它回答了一個沒有人問過的問題:CSS 能運行 DOOM 嗎?