﻿---
lang: zh-TW
title: "Minecraft 為什麼來得這麼晚：從一個可以隨便改的世界說起"
description: "Minecraft 的畫面全是方塊，看起來誰都做得出來，卻直到 2009 年才出現。順著這個現象往下追：玩法從哪來，早期 3D
  遊戲為什麼做不了可以隨便改的世界，這麼大的世界怎麼裝得下，為什麼今天瀏覽器裡還很少有這樣的遊戲，最後動手寫一個能在瀏覽器裡玩的最小版本，並看看它為什麼成了
  AI 智慧體的考場。"
pubDate: 2026-10-14
tags: [ "游戏", "图形学", "浏览器", "智能体" ]
clc: "TP317.6"
hero: "./hero.zh-TW.svg"
refs:
  - id: "1"
    title: "Infiniminer"
    url: "https://en.wikipedia.org/wiki/Infiniminer"
    collect: "wikipedia-infiniminer"
    site: "Wikipedia"
  - id: "2"
    title: "The local origins of Minecraft: A conversation with indie gamer Zachary
      Barth"
    url: "https://crosscut.com/2015/08/the-godfather-of-minecraft-a-conversation-wi\
      th-indie-gamer-zachary-barth"
    site: "Crosscut"
    date: "2015"
  - id: "3"
    title: "The history of Minecraft"
    url: "https://www.techradar.com/news/the-history-of-minecraft"
    collect: "techradar-the-history-of-minecraft"
    site: "TechRadar"
  - id: "4"
    title: "Early access"
    url: "https://en.wikipedia.org/wiki/Early_access"
    collect: "wikipedia-early-access"
    site: "Wikipedia"
  - id: "5"
    title: "Terrain generation, Part 1"
    url: "https://www.moddb.com/news/terrain-generation-part-1"
    author: "Markus Persson"
    site: "ModDB"
    note: "ModDB 轉載"
  - id: "6"
    title: "Voxel Space"
    url: "https://en.wikipedia.org/wiki/Voxel_Space"
    collect: "wikipedia-voxel-space"
    site: "Wikipedia"
  - id: "7"
    title: "Red Faction (video game)"
    url: "https://en.wikipedia.org/wiki/Red_Faction_(video_game)"
    collect: "wikipedia-red-faction-video-game"
    site: "Wikipedia"
  - id: "8"
    title: "Meshing in a Minecraft Game"
    url: "https://0fps.net/2012/06/30/meshing-in-a-minecraft-game/"
    collect: "0fps-2012-06-30-meshing-in-a-minecraft-game"
    author: "Mikola Lysenko"
    site: "0fps"
    date: "2012"
  - id: "9"
    title: "Minecraft (lost early builds of Java game; 2009-2013)"
    url: "https://www.lostmediawiki.com/Minecraft_(lost_early_builds_of_Java_game;_\
      2009-2013)"
    site: "Lost Media Wiki"
  - id: "10"
    title: "Minecraft Classic browser"
    url: "https://engadget.com/2019/05/09/minecraft-classic-browser"
    collect: "engadget-2019-05-09-minecraft-classic-browser"
    site: "Engadget"
    date: "2019"
  - id: "11"
    title: "Web publishing following Chrome NPAPI deprecation"
    url: "https://blog.unity.com/cn/technology/web-publishing-following-chrome-npap\
      i-deprecation"
    site: "Unity Blog"
  - id: "12"
    title: "WebGPU is now supported in major browsers"
    url: "https://web.dev/blog/webgpu-supported-major-browsers"
    collect: "web-webgpu-supported-major-browsers"
    site: "web.dev"
    date: "2025-11-25"
  - id: "13"
    title: "Is Memory64 actually worth using?"
    url: "https://spidermonkey.dev/blog/2025/01/15/is-memory64-actually-worth-using\
      .html"
    collect: "spidermonkey-2025-01-15-is-memory64-actually-wor"
    site: "SpiderMonkey Blog"
    date: "2025-01-15"
  - id: "14"
    title: "Ken Silverman"
    url: "https://en.wikipedia.org/wiki/Ken_Silverman"
    collect: "wikipedia-ken-silverman"
    site: "Wikipedia"
  - id: "15"
    title: "A New Tool for Unreal Engine Developers to Export to the Web"
    url: "https://thenewstack.io/a-new-tool-for-unreal-engine-developers-to-export-\
      to-the-web/"
    collect: "thenewstack-a-new-tool-for-unreal-engine-develop"
    site: "The New Stack"
  - id: "16"
    title: "Introduction"
    url: "https://mintlify.wiki/zardoy/minecraft-web-client/introduction"
    collect: "mintlify-zardoy-minecraft-web-client-introductio"
    site: "Minecraft Web Client 文件"
  - id: "16b"
    title: "zardoy/minecraft-web-client"
    url: "https://github.com/zardoy/minecraft-web-client"
    collect: "github-zardoy-minecraft-web-client"
    site: "Minecraft Web Client"
    note: "原始碼"
  - id: "17"
    title: "Making your website \"cross-origin isolated\" using COOP and COEP"
    url: "https://web.dev/articles/coop-coep"
    collect: "web-coop-coep"
    site: "web.dev"
  - id: "18"
    title: "Learning to play Minecraft with Video PreTraining"
    url: "https://openai.com/index/vpt"
    site: "OpenAI"
    date: "2022"
  - id: "19"
    title: "Voyager: An Open-Ended Embodied Agent with Large Language Models"
    url: "https://arxiv.org/abs/2305.16291"
    collect: "arxiv-2305-16291"
    author: "Wang G, et al."
    site: "arXiv"
    date: "2023"
    note: "arXiv:2305.16291"
  - id: "20"
    title: "Project Sid: Many-agent simulations toward AI civilization"
    url: "https://arxiv.org/abs/2411.00114"
    collect: "arxiv-2411-00114"
    author: "Altera.AL"
    site: "arXiv"
    date: "2024"
    note: "arXiv:2411.00114"
  - id: "21"
    title: "yuniko-software/minecraft-mcp-server"
    url: "https://github.com/yuniko-software/minecraft-mcp-server"
    collect: "github-yuniko-software-minecraft-mcp-server"
  - id: "22"
    title: "Decart's AI simulates a real-time, playable version of Minecraft"
    url: "https://techcrunch.com/2024/10/31/decarts-ai-simulates-a-real-time-playab\
      le-version-of-minecraft"
    collect: "techcrunch-2024-10-31-decarts-ai-simulates-a-rea"
    site: "TechCrunch"
    date: "2024-10-31"
---

<!-- TODO: 这里可以放一张作者自己玩 Minecraft 或本文演示的截图，并写一两句亲身感受 -->

Minecraft 的畫面幾乎沒有什麼技術含量的樣子：一格一格的方塊，16×16 畫素的貼圖，沒有複雜的光影，人物也是幾個長方體拼起來的。第一次看到它的人，多半會覺得“這個我上我也行”。

可它偏偏是 2009 年才出現的，而且最早是 Markus Persson（Notch）一個人業餘時間寫的。在它之前，3D 遊戲已經發展了十幾年，《雷神之錘》《半條命》畫面複雜得多，卻沒有一款遊戲讓玩家把整個世界一塊一塊地拆掉、再蓋起來。

另一邊，今天的電腦和手機算力早已過剩，瀏覽器裡也有了 WebGL、WebGPU，但我們開啟網頁玩的 3D 遊戲仍然不多，Minecraft 的完整版也不能直接在瀏覽器裡跑。

於是有了幾個問題：

1. Minecraft 的玩法是從哪來的？
2. 如果這個玩法這麼好，早期的 3D 遊戲為什麼沒有這麼做？
3. 一個幾乎無限大、還能隨便改的世界，記憶體怎麼裝得下？
4. 為什麼偏偏是 2009 年、一個人做出來的？
5. 算力這麼充沛的今天，瀏覽器為什麼還是很難“原生”地跑這種遊戲？自己寫一個最小版本，難在哪裡？
6. 這樣一個方塊世界，為什麼又成了 AI 智慧體研究的考場？

本文就順著這幾個問題往下講。寫到第五個問題時，我們會真的寫一個能在瀏覽器裡玩的最小版 Minecraft，大約 500 行程式碼，就嵌在第五節裡，也可以[單獨開啟](/demos/voxel/)。

---

## 一、玩法的來源

### 現象：先火的是另一款遊戲

2009 年 4 月 29 日，Zach Barth（後來做了《太空化學》等程式設計解謎遊戲的 Zachtronics）釋出了一款免費遊戲 Infiniminer。它是一個多人方塊遊戲：兩隊礦工在地下挖礦，比誰挖到的礦石更值錢[^1]。

有意思的是，Barth 設計的是競技，玩家卻很快發現了另一種玩法：不挖礦了，拿方塊蓋房子[^1][^2]。

不到一個月，Barth 就放棄了這個遊戲。原因很樸素：它不賺錢；他沒有混淆原始碼，程式碼被反編譯後流傳出去，外掛和魔改版氾濫，他也沒有精力去修[^1][^2]。最後他把原始碼開源，停止了開發。

### 原因：Minecraft 補上了什麼

同一年 5 月，Notch 開始寫一個叫 Cave Game 的原型，它借鑑了 Infiniminer 的方塊風格、第一人稱視角和建造機制[^1]。他之前做過一個叫 RubyDung 的專案，混合了《地下城守護者》《矮人要塞》和《過山車大亨》的想法[^3]。2009 年 5 月 17 日，第一個公開版本放了出來[^9]。

和 Infiniminer 比，Minecraft 多了兩樣東西。第一樣是玩法上的：後來陸續加入的單人生存、合成，以及一個幾乎無限大的世界，玩家有了自己找事做的理由（這是我的歸納）。第二樣是生意上的：它從一開始就收錢。2009 年 6 月 13 日，還在 Alpha 階段的 Minecraft 就開始以約 10 歐元的價格出售，不到一個月賣出了 1000 多份[^3][^4]。Notch 後來說：“我想，如果我不收錢，我就永遠拿不到錢。”[^3] 到正式版之前，近 200 萬玩家買了 Alpha 和 Beta，收入超過 3300 萬美元[^4]。

所以玩法本身並不是 Notch 憑空發明的，它是 Infiniminer 的玩家先“玩”出來的。**可如果這個玩法這麼吸引人，為什麼十幾年的 3D 遊戲史裡，沒有人先做出來？**

---

## 二、可編輯世界的渲染代價

### 現象：早期 3D 遊戲的世界是“死”的

回想一下 2000 年前後的 3D 遊戲：門只能在劇情需要時開啟，牆炸不開，地面挖不動。火箭筒打在牆上，最多留一個彈孔貼圖。

### 原因：顯示卡擅長畫的是“提前做好”的三角形

1990 年代末，3D 加速卡普及，它們加速的是一件事：把大量三角形畫到螢幕上。

顯示卡是怎麼畫一個三角形的？它拿到的是三個頂點的座標，然後分三步走：先把每個頂點的三維座標換算成螢幕上的位置（頂點著色）；再算出這三個點圍成的區域蓋住了螢幕上哪些畫素（光柵化）；最後給每個被蓋住的畫素算出顏色（片元著色）。為什麼偏偏是三角形？因為三個點一定在同一個平面上，判斷一個畫素在不在它裡面也最簡單。人物、地形、汽車，任何形狀在交給顯示卡之前，都要先拆成一堆三角形。

方塊也不例外。一個方塊有 6 個正方形的面，每個正方形沿對角線切開就是 2 個三角形，所以一個方塊要畫 12 個三角形。具體寫法是：每個面給出 4 個角的座標，再用一串索引告訴顯示卡哪三個點組成一個三角形，比如 `0,1,2` 和 `0,2,3` 就是把 v0、v1、v2、v3 這個正方形切成兩半。本文後面寫的演示就是這麼做的。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mc-tri-title mc-tri-desc">
<title id="mc-tri-title">一個方塊怎麼變成顯示卡能畫的三角形</title>
<desc id="mc-tri-desc">座標 12,30,-5 的草地方塊有 6 個面，每個面沿對角線切成 2 個三角形；頂面的 4 個頂點 v0 到 v3 用索引 0,1,2 和 0,2,3 組成兩個三角形。顯示卡對每個三角形做頂點著色、光柵化、片元著色三步，光柵化算出三角形蓋住了哪些畫素。</desc>
<defs>
<marker id="mc-tri-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="368" rx="8"/>
<text class="t-zone" x="32" y="44">一個方塊</text>
<rect class="dd-zone" x="264" y="16" width="264" height="368" rx="8"/>
<text class="t-zone" x="280" y="44">一個面 = 兩個三角形</text>
<rect class="dd-zone" x="544" y="16" width="240" height="368" rx="8"/>
<text class="t-zone" x="560" y="44">顯示卡畫三角形的三步</text>
<path class="dd-edge" d="M664 116 V130" marker-end="url(#mc-tri-arrow)"/>
<path class="dd-edge" d="M664 188 V202" marker-end="url(#mc-tri-arrow)"/>
<polygon class="dd-card" points="84,100 132,128 132,192 84,164"/>
<polygon class="dd-card-head" points="132,128 180,100 180,164 132,192"/>
<path class="dd-edge" d="M132 128 L180 100 L180 164 L132 192 Z M84 100 L132 192 M132 128 L180 164"/>
<polygon class="dd-card" points="132,72 180,100 132,128 84,100"/>
<path class="dd-edge-acc" d="M132 72 L180 100 L132 128 L84 100 Z M84 100 L180 100"/>
<rect class="dd-card" x="32" y="216" width="200" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 248 V222 Q32.6 216.6 38 216.6 H226 Q231.4 216.6 231.4 222 V248 Z"/>
<line class="dd-edge" x1="32" y1="248" x2="232" y2="248"/>
<text class="t-name" x="48" y="237">方塊</text>
<text class="t-mono" x="216" y="237" text-anchor="end">(12, 30, -5)</text>
<text class="t-mono" x="48" y="265">型別</text><text class="t-val" x="216" y="265" text-anchor="end">1 · 草地</text>
<line class="dd-hair" x1="48" y1="274" x2="216" y2="274"/>
<text class="t-mono" x="48" y="291">儲存</text><text class="t-val" x="216" y="291" text-anchor="end">1 位元組</text>
<line class="dd-hair" x1="48" y1="300" x2="216" y2="300"/>
<text class="t-mono" x="48" y="317">三角形</text><text class="t-val t-acc" x="216" y="317" text-anchor="end">6 面 × 2 = 12 個</text>
<text class="t-say" x="32" y="352">顯示卡只認三角形，</text>
<text class="t-say" x="32" y="370">“方塊”只存在於遊戲的資料裡。</text>
<rect class="dd-card" x="340" y="64" width="112" height="112"/>
<path class="dd-edge-acc" d="M340 64 H452 V176 H340 Z M340 176 L452 64"/>
<text class="t-note" x="420" y="148" text-anchor="middle">三角形 1</text>
<text class="t-note" x="372" y="100" text-anchor="middle">三角形 2</text>
<text class="t-mono" x="334" y="70" text-anchor="end">v3</text>
<text class="t-mono" x="458" y="70">v2</text>
<text class="t-mono" x="334" y="182" text-anchor="end">v0</text>
<text class="t-mono" x="458" y="182">v1</text>
<rect class="dd-card" x="280" y="200" width="232" height="162" rx="6"/>
<path class="dd-card-head" d="M280.6 232 V206 Q280.6 200.6 286 200.6 H506 Q511.4 200.6 511.4 206 V232 Z"/>
<line class="dd-edge" x1="280" y1="232" x2="512" y2="232"/>
<text class="t-name" x="296" y="221">頂面</text>
<text class="t-note" x="496" y="221" text-anchor="end">4 個頂點</text>
<text class="t-mono" x="296" y="249">v0</text><text class="t-val" x="496" y="249" text-anchor="end">(12, 31, -4)</text>
<line class="dd-hair" x1="296" y1="258" x2="496" y2="258"/>
<text class="t-mono" x="296" y="275">v1</text><text class="t-val" x="496" y="275" text-anchor="end">(13, 31, -4)</text>
<line class="dd-hair" x1="296" y1="284" x2="496" y2="284"/>
<text class="t-mono" x="296" y="301">v2</text><text class="t-val" x="496" y="301" text-anchor="end">(13, 31, -5)</text>
<line class="dd-hair" x1="296" y1="310" x2="496" y2="310"/>
<text class="t-mono" x="296" y="327">v3</text><text class="t-val" x="496" y="327" text-anchor="end">(12, 31, -5)</text>
<line class="dd-hair" x1="296" y1="336" x2="496" y2="336"/>
<text class="t-mono" x="296" y="353">索引</text><text class="t-val t-acc" x="496" y="353" text-anchor="end">0,1,2  0,2,3</text>
<rect class="dd-card" x="560" y="64" width="208" height="52" rx="6"/>
<text class="t-name" x="576" y="86">1. 頂點著色</text>
<text class="t-note" x="576" y="104">三維座標 → 螢幕上的位置</text>
<rect class="dd-card" x="560" y="136" width="208" height="52" rx="6"/>
<text class="t-name" x="576" y="158">2. 光柵化</text>
<text class="t-note" x="576" y="176">三角形蓋住了哪些畫素</text>
<rect class="dd-card" x="560" y="208" width="208" height="52" rx="6"/>
<text class="t-name" x="576" y="230">3. 片元著色</text>
<text class="t-note" x="576" y="248">給每個畫素算顏色</text>
<rect class="dd-key" x="712" y="280" width="12" height="12"/><rect class="dd-key" x="700" y="292" width="12" height="12"/><rect class="dd-key" x="712" y="292" width="12" height="12"/><rect class="dd-key" x="676" y="304" width="12" height="12"/><rect class="dd-key" x="688" y="304" width="12" height="12"/><rect class="dd-key" x="700" y="304" width="12" height="12"/><rect class="dd-key" x="712" y="304" width="12" height="12"/><rect class="dd-key" x="652" y="316" width="12" height="12"/><rect class="dd-key" x="664" y="316" width="12" height="12"/><rect class="dd-key" x="676" y="316" width="12" height="12"/><rect class="dd-key" x="688" y="316" width="12" height="12"/><rect class="dd-key" x="700" y="316" width="12" height="12"/><rect class="dd-key" x="712" y="316" width="12" height="12"/><rect class="dd-key" x="628" y="328" width="12" height="12"/><rect class="dd-key" x="640" y="328" width="12" height="12"/><rect class="dd-key" x="652" y="328" width="12" height="12"/><rect class="dd-key" x="664" y="328" width="12" height="12"/><rect class="dd-key" x="676" y="328" width="12" height="12"/><rect class="dd-key" x="688" y="328" width="12" height="12"/><rect class="dd-key" x="700" y="328" width="12" height="12"/><rect class="dd-key" x="712" y="328" width="12" height="12"/><rect class="dd-key" x="616" y="340" width="12" height="12"/><rect class="dd-key" x="628" y="340" width="12" height="12"/><rect class="dd-key" x="640" y="340" width="12" height="12"/><rect class="dd-key" x="652" y="340" width="12" height="12"/><rect class="dd-key" x="664" y="340" width="12" height="12"/><rect class="dd-key" x="676" y="340" width="12" height="12"/><rect class="dd-key" x="688" y="340" width="12" height="12"/><rect class="dd-key" x="700" y="340" width="12" height="12"/><rect class="dd-key" x="712" y="340" width="12" height="12"/>
<path class="dd-edge-acc" d="M606 350 L722 350 L722 282 Z"/>
<text class="t-note" x="664" y="374" text-anchor="middle">光柵化：小方格是被蓋住的畫素</text>
</svg>
</div>
<figcaption>圖 1 · 顯示卡只會畫三角形。一個方塊要先拆成 6 個面、12 個三角形，交給顯示卡；頂點座標和索引的寫法和本文演示的程式碼一致。</figcaption></figure>

注意圖裡左下角那句話：顯示卡並不知道“方塊”是什麼，它只看到三角形。“方塊”只存在於遊戲自己的資料裡。遊戲要先把方塊翻譯成三角形交給顯示卡，方塊一變，三角形也得跟著重新生成。

當時的遊戲為了在有限的硬體上跑得動，會把關卡儘量**提前算好**：哪些區域能互相看見、牆上的光照，都在製作關卡時預先計算並烘焙進資料裡，執行時直接用（例如《雷神之錘》的 BSP 和光照貼圖）。

這套辦法的前提是：世界不會變。一旦玩家能隨手挖掉一塊牆，這些預計算的結果就全部失效，三角形本身也要重新生成，這些都得在執行時完成。對當時的硬體來說，這代價太大了。

### 第一次嘗試：體素和區域性破壞

其實早就有人想過用“方塊”來表示世界。1992 年，NovaLogic 的 Voxel Space 引擎在直升機遊戲《科曼奇》裡用體素（volume pixel，體積畫素）渲染地形，靠的是 CPU 軟體渲染[^6]。Build 引擎的作者 Ken Silverman 從 2000 年起也寫過體素引擎 Voxlap[^14]。但顯示卡加速的是三角形，不是體素，這條路在主流遊戲裡就邊緣化了。

另一條路是在三角形世界裡做有限的破壞。2001 年的《紅色派系》用 Geo-Mod 引擎讓玩家炸開岩石和建築，被認為是第一款允許玩家非指令碼化地改變關卡的遊戲。但它原本計劃讓一切都能破壞，最後因為技術難度，以及要引導玩家走完線性關卡，範圍被縮小了[^7]。

這兩次嘗試分別說明了兩件事：體素這種資料結構適合表示可以改的世界，但顯示卡不會直接畫它；在三角形世界裡做破壞可以，但做不到“全部”。

### 解決：把方塊轉成三角形，只畫露在外面的面

Minecraft 的辦法是把兩者接起來：**世界用方塊存，畫的時候把方塊轉成三角形**。

最直接的轉法是每個方塊畫 6 個面。但仔細想想，兩個相鄰方塊貼在一起的那兩個面，永遠不會被看見。只畫“旁邊是空氣”的面，數量會少得驚人。0fps 的一篇經典文章給過一個例子：一個 8×8×8 的實心方塊堆，每塊畫 6 面是 3072 個四邊形；去掉被擋住的面後只剩 384 個；如果再把同一平面上相鄰的面合併成大矩形（貪心網格，greedy meshing），只需要 6 個[^8]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mc-mesh-title mc-mesh-desc">
<title id="mc-mesh-title">同一堆方塊的三種畫法</title>
<desc id="mc-mesh-desc">取一個 4×4 的截面：每塊畫四條邊要 64 條，只畫外圈要 16 條，合併後只要 4 條。對應到 8×8×8 的實心方塊，三種畫法分別是 3072、384 和 6 個面。</desc>
<rect class="dd-frame" width="800" height="360" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="244" height="328" rx="8"/>
<text class="t-zone" x="32" y="44">樸素：每塊畫 6 面</text>
<rect class="dd-zone" x="276" y="16" width="244" height="328" rx="8"/>
<text class="t-zone" x="292" y="44">剔除：只畫露在外面的</text>
<rect class="dd-zone" x="536" y="16" width="244" height="328" rx="8"/>
<text class="t-zone" x="552" y="44">合併：同一平面連成一片</text>
<rect class="dd-card" x="82" y="64" width="112" height="112" rx="2"/>
<rect class="dd-edge-acc" x="85" y="67" width="22" height="22"/><rect class="dd-edge-acc" x="113" y="67" width="22" height="22"/><rect class="dd-edge-acc" x="141" y="67" width="22" height="22"/><rect class="dd-edge-acc" x="169" y="67" width="22" height="22"/>
<rect class="dd-edge-acc" x="85" y="95" width="22" height="22"/><rect class="dd-edge-acc" x="113" y="95" width="22" height="22"/><rect class="dd-edge-acc" x="141" y="95" width="22" height="22"/><rect class="dd-edge-acc" x="169" y="95" width="22" height="22"/>
<rect class="dd-edge-acc" x="85" y="123" width="22" height="22"/><rect class="dd-edge-acc" x="113" y="123" width="22" height="22"/><rect class="dd-edge-acc" x="141" y="123" width="22" height="22"/><rect class="dd-edge-acc" x="169" y="123" width="22" height="22"/>
<rect class="dd-edge-acc" x="85" y="151" width="22" height="22"/><rect class="dd-edge-acc" x="113" y="151" width="22" height="22"/><rect class="dd-edge-acc" x="141" y="151" width="22" height="22"/><rect class="dd-edge-acc" x="169" y="151" width="22" height="22"/>
<rect class="dd-card" x="342" y="64" width="112" height="112" rx="2"/>
<line class="dd-hair" x1="370" y1="64" x2="370" y2="176"/><line class="dd-hair" x1="398" y1="64" x2="398" y2="176"/><line class="dd-hair" x1="426" y1="64" x2="426" y2="176"/>
<line class="dd-hair" x1="342" y1="92" x2="454" y2="92"/><line class="dd-hair" x1="342" y1="120" x2="454" y2="120"/><line class="dd-hair" x1="342" y1="148" x2="454" y2="148"/>
<path class="dd-edge-acc" d="M345 64 H367 M373 64 H395 M401 64 H423 M429 64 H451 M345 176 H367 M373 176 H395 M401 176 H423 M429 176 H451 M342 67 V89 M342 95 V117 M342 123 V145 M342 151 V173 M454 67 V89 M454 95 V117 M454 123 V145 M454 151 V173"/>
<rect class="dd-card" x="602" y="64" width="112" height="112" rx="2"/>
<line class="dd-hair" x1="630" y1="64" x2="630" y2="176"/><line class="dd-hair" x1="658" y1="64" x2="658" y2="176"/><line class="dd-hair" x1="686" y1="64" x2="686" y2="176"/>
<line class="dd-hair" x1="602" y1="92" x2="714" y2="92"/><line class="dd-hair" x1="602" y1="120" x2="714" y2="120"/><line class="dd-hair" x1="602" y1="148" x2="714" y2="148"/>
<rect class="dd-edge-acc" x="602" y="64" width="112" height="112"/>
<rect class="dd-card" x="32" y="196" width="212" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 228 V202 Q32.6 196.6 38 196.6 H238 Q243.4 196.6 243.4 202 V228 Z"/>
<line class="dd-edge" x1="32" y1="228" x2="244" y2="228"/>
<text class="t-name" x="48" y="217">要畫的</text>
<text class="t-mono" x="48" y="245">4×4 截面</text><text class="t-val" x="228" y="245" text-anchor="end">64 條邊</text>
<line class="dd-hair" x1="48" y1="254" x2="228" y2="254"/>
<text class="t-mono" x="48" y="271">8×8×8 實心塊</text><text class="t-val t-acc" x="228" y="271" text-anchor="end">3,072 面</text>
<rect class="dd-card" x="292" y="196" width="212" height="84" rx="6"/>
<path class="dd-card-head" d="M292.6 228 V202 Q292.6 196.6 298 196.6 H498 Q503.4 196.6 503.4 202 V228 Z"/>
<line class="dd-edge" x1="292" y1="228" x2="504" y2="228"/>
<text class="t-name" x="308" y="217">要畫的</text>
<text class="t-mono" x="308" y="245">4×4 截面</text><text class="t-val" x="488" y="245" text-anchor="end">16 條邊</text>
<line class="dd-hair" x1="308" y1="254" x2="488" y2="254"/>
<text class="t-mono" x="308" y="271">8×8×8 實心塊</text><text class="t-val t-acc" x="488" y="271" text-anchor="end">384 面</text>
<rect class="dd-card" x="552" y="196" width="212" height="84" rx="6"/>
<path class="dd-card-head" d="M552.6 228 V202 Q552.6 196.6 558 196.6 H758 Q763.4 196.6 763.4 202 V228 Z"/>
<line class="dd-edge" x1="552" y1="228" x2="764" y2="228"/>
<text class="t-name" x="568" y="217">要畫的</text>
<text class="t-mono" x="568" y="245">4×4 截面</text><text class="t-val" x="748" y="245" text-anchor="end">4 條邊</text>
<line class="dd-hair" x1="568" y1="254" x2="748" y2="254"/>
<text class="t-mono" x="568" y="271">8×8×8 實心塊</text><text class="t-val t-acc" x="748" y="271" text-anchor="end">6 面</text>
<text class="t-say" x="32" y="312">格子之間的邊都畫了兩遍，</text>
<text class="t-say" x="32" y="330">貼在一起的面永遠看不見。</text>
<text class="t-say" x="292" y="312">本文的演示裡 628,628 個方塊，</text>
<text class="t-say" x="292" y="330">只畫了其中 1.5% 的面。</text>
</svg>
</div>
<figcaption>圖 2 · 方塊世界畫起來不貴，前提是隻畫看得見的面。上排是一個 4×4 截面的示意，下排是 8×8×8 實心方塊的面數（資料來自 0fps）。</figcaption>
</figure>

更關鍵的是第二點：世界被分成一個個區塊（chunk），每個區塊單獨生成一塊網格。玩家挖掉一個方塊，只需要重建這一個區塊的網格，而不是整個世界。“提前算好”的思路沒有被拋棄，只是從“整個關卡算一次”變成了“每個區塊在它變化時算一次”。

能畫了，**但一個幾乎無限大的世界，光是存方塊的資料，記憶體裝得下嗎？**

---

## 三、無限世界的資料與生成

### 現象：世界大到不可能存下來

Minecraft 的世界在水平方向上可以走出幾千萬格。如果每個方塊佔 1 個位元組，哪怕只存地面以下幾十層，也是一個天文數字。

### 第一次嘗試：二維高度圖

最直覺的辦法是不存方塊，只存“每個位置的地面有多高”，用一張二維的噪聲圖（柏林噪聲）算出來。Notch 早期的地形就是這樣生成的。但他很快發現了問題：“這種方法不可能生成任何懸垂的地形”[^5]。一張高度圖上，每個位置只有一個高度，懸崖下面的凹洞、拱橋、洞穴都表示不出來。

### 解決：區塊、按需生成和確定性

Notch 後來的做法有三個要點[^5]：

1. **按區塊生成、儲存、載入和渲染**。當時的區塊大小是 16×16×128。
2. **用三維噪聲當作“密度”**：噪聲值為負的地方是空氣，非負的地方是地面。這樣就能生成懸崖和洞穴。全解析度的三維取樣太慢，還會產生懸空的碎塊，最後是降低取樣解析度再插值解決的。
3. **確定性**：地形“無論玩家從哪個方向走過來，都必須是一樣的”。同一個種子、同一個座標，永遠算出同樣的方塊。

第三點帶來一個很漂亮的推論：**沒去過的地方不用存，去過但沒改過的地方也不用存**，因為它們隨時可以從種子重新算出來。真正需要儲存的只有玩家改過的那些方塊。世界的大小和記憶體的大小就這樣脫了鉤，玩家周圍一圈區塊在記憶體裡，其餘的要麼還沒生成，要麼存在硬碟上。它的世界也因此“不是無限的，但也沒有硬性邊界”：走得太遠，浮點精度不夠，物理就開始出錯，這就是玩家口中的“遠境”（Far Lands）[^5]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mc-chunk-title mc-chunk-desc">
<title id="mc-chunk-title">記憶體裡只有玩家周圍的區塊，存檔裡只有改動</title>
<desc id="mc-chunk-desc">玩家所在區塊 0,0 周圍半徑 3 個區塊已經生成，外面的區塊還沒生成。區塊 0,0 是一個 16384 位元組的陣列，由種子 2009 算出，玩家挖掉了下標 4359 的方塊；存檔裡只記錄這一類改動。</desc>
<defs>
<marker id="mc-chunk-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="336" height="368" rx="8"/>
<text class="t-zone" x="32" y="44">記憶體裡：玩家周圍的區塊</text>
<rect class="dd-zone" x="368" y="16" width="416" height="368" rx="8"/>
<text class="t-zone" x="384" y="44">一個區塊，和它的存檔</text>
<path class="dd-edge-acc" d="M744 200 V242" marker-end="url(#mc-chunk-arrow-acc)"/>
<g id="mc-chunk-grid">
<rect class="dd-derived" x="60" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="116" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="144" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="172" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="200" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="228" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="66" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="66" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="116" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="144" y="94" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="200" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="228" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="94" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="94" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="122" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="122" width="24" height="24" rx="3"/><rect class="dd-card" x="116" y="122" width="24" height="24" rx="3"/><rect class="dd-card" x="144" y="122" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="122" width="24" height="24" rx="3"/><rect class="dd-card" x="200" y="122" width="24" height="24" rx="3"/><rect class="dd-card" x="228" y="122" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="122" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="122" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="150" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="150" width="24" height="24" rx="3"/><rect class="dd-card" x="116" y="150" width="24" height="24" rx="3"/><rect class="dd-card" x="144" y="150" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="150" width="24" height="24" rx="3"/><rect class="dd-card" x="200" y="150" width="24" height="24" rx="3"/><rect class="dd-card" x="228" y="150" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="150" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="150" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="88" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="116" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="144" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="200" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="228" y="178" width="24" height="24" rx="3"/><rect class="dd-card" x="256" y="178" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="178" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="206" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="206" width="24" height="24" rx="3"/><rect class="dd-card" x="116" y="206" width="24" height="24" rx="3"/><rect class="dd-card" x="144" y="206" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="206" width="24" height="24" rx="3"/><rect class="dd-card" x="200" y="206" width="24" height="24" rx="3"/><rect class="dd-card" x="228" y="206" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="206" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="206" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="234" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="234" width="24" height="24" rx="3"/><rect class="dd-card" x="116" y="234" width="24" height="24" rx="3"/><rect class="dd-card" x="144" y="234" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="234" width="24" height="24" rx="3"/><rect class="dd-card" x="200" y="234" width="24" height="24" rx="3"/><rect class="dd-card" x="228" y="234" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="234" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="234" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="116" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="144" y="262" width="24" height="24" rx="3"/><rect class="dd-card" x="172" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="200" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="228" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="262" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="262" width="24" height="24" rx="3"/>
<rect class="dd-derived" x="60" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="88" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="116" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="144" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="172" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="200" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="228" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="256" y="290" width="24" height="24" rx="3"/><rect class="dd-derived" x="284" y="290" width="24" height="24" rx="3"/>
</g>
<rect class="dd-edge-acc" x="172" y="178" width="24" height="24" rx="3"/>
<text class="t-small t-acc" x="184" y="194" text-anchor="middle">0,0</text>
<rect class="dd-card" x="32" y="330" width="16" height="16" rx="3"/>
<text class="t-note" x="56" y="343">已生成</text>
<rect class="dd-derived" x="112" y="330" width="16" height="16" rx="3"/>
<text class="t-note" x="136" y="343">還沒生成，不佔記憶體</text>
<text class="t-say" x="32" y="372">虛線區塊走近時才從種子算出來。</text>
<rect class="dd-card" x="384" y="64" width="384" height="136" rx="6"/>
<path class="dd-card-head" d="M384.6 96 V70 Q384.6 64.6 390 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="384" y1="96" x2="768" y2="96"/>
<text class="t-name" x="400" y="85">區塊</text>
<text class="t-mono t-acc" x="752" y="85" text-anchor="end">(0, 0)</text>
<text class="t-mono" x="400" y="113">尺寸</text><text class="t-val" x="752" y="113" text-anchor="end">16 × 16 × 64</text>
<line class="dd-hair" x1="400" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="400" y="139">儲存</text><text class="t-val" x="752" y="139" text-anchor="end">Uint8Array(16384)，每塊 1 位元組</text>
<line class="dd-hair" x1="400" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="400" y="165">來源</text><text class="t-val" x="752" y="165" text-anchor="end">heightAt(x, z)，種子 2009</text>
<line class="dd-hair" x1="400" y1="174" x2="752" y2="174"/>
<text class="t-mono" x="400" y="191">改動</text><text class="t-val t-acc" x="752" y="191" text-anchor="end">下標 4359：石頭 → 空氣</text>
<text class="t-say" x="400" y="226">存檔只記改動，其餘方塊都能重新算出來。</text>
<rect class="dd-card" x="384" y="248" width="384" height="110" rx="6"/>
<path class="dd-card-head" d="M384.6 280 V254 Q384.6 248.6 390 248.6 H762 Q767.4 248.6 767.4 254 V280 Z"/>
<line class="dd-edge" x1="384" y1="280" x2="768" y2="280"/>
<text class="t-name" x="400" y="269">localStorage</text>
<text class="t-mono" x="752" y="269" text-anchor="end">voxel-demo-edits-2009</text>
<text class="t-mono t-acc" x="400" y="297">"0,0"</text><text class="t-val" x="752" y="297" text-anchor="end">[[<tspan class="t-acc">4359, 0</tspan>], [4375, 7]]</text>
<line class="dd-hair" x1="400" y1="306" x2="752" y2="306"/>
<text class="t-mono" x="400" y="323">"1,-2"</text><text class="t-val" x="752" y="323" text-anchor="end">[[9001, 0]]</text>
<line class="dd-hair" x1="400" y1="332" x2="752" y2="332"/>
<text class="t-note" x="400" y="349">…</text>
</svg>
</div>
<figcaption>圖 3 · 世界的大小和記憶體脫鉤：記憶體裡只有玩家周圍一圈區塊，存檔裡只有玩家改過的方塊。資料來自本文的演示，存檔內容為示意。</figcaption>
</figure>

技術上的幾塊拼圖到這裡都齊了。**那為什麼是 2009 年，而且是一個人做出來的？**

---

## 四、為什麼是 2009 年

這一節沒有一個確定的答案，下面是我把史料拼起來之後的推斷。

**硬體**。一個 16×16×128 的區塊有 32768 個方塊，每塊按 1 位元組算就是 32 KB，加上光照等附加資料還要更多。玩家周圍載入幾百個區塊，光方塊資料就是幾十 MB，還沒算網格。1990 年代末的家用電腦記憶體普遍只有幾十到一百多 MB，到 2009 年已經是幾 GB（這裡的記憶體數字是我的估計，沒有找到統一的統計）。同樣的設計放在十年前，大機率跑不起來。

**分發和收錢**。Minecraft 用 Java 寫，同一份程式碼能在 Windows、Mac、Linux 上跑[^3]，早期版本甚至直接在網頁裡以 Java applet 的形式執行[^9]。玩家用 PayPal 付 10 歐元買一個沒做完的遊戲，這在今天叫“搶先體驗”，Minecraft 正是把這種模式帶火的遊戲之一，Steam 的搶先體驗欄目要到 2013 年才上線[^4]。一個人不需要發行商，也能靠沒做完的遊戲養活自己。

**行業**。2000 年代的大作賣的是內容：劇情、關卡、畫面。一個可以隨便改的世界和精心設計的線性關卡是衝突的，《紅色派系》就是因為要引導玩家走完關卡才縮小了破壞範圍[^7]。大公司沒有理由去做一個“沒有關卡”的遊戲，這件事只能由一個不需要對發行商負責的獨立開發者來做。

有一個細節很有意思：Minecraft 的 Classic、Indev、Infdev 三個早期階段都是在瀏覽器裡玩的，直到 2010 年 7 月的 Alpha 才有了獨立的客戶端[^9]。Infdev 的名字就來自“infinite”，正是無限世界加入的那個階段。

**Minecraft 最早就跑在瀏覽器裡，為什麼今天瀏覽器裡反而很難看到這樣的遊戲？**

---

## 五、瀏覽器裡的方塊世界

### 現象：官方只放出了 2009 年的版本

2019 年 Minecraft 十週年時，Mojang 在網頁上重新放出了 Classic 版：只有創造模式，32 種方塊，沒有怪物[^10]。完整的現代版至今不能直接在瀏覽器裡執行。

### 原因：瀏覽器為安全付出的代價

**外掛時代結束了**。當年網頁裡的 Minecraft 靠的是 Java 外掛，Flash 遊戲和 Unity 網頁遊戲也都依賴瀏覽器外掛（NPAPI）。出於安全和穩定性的考慮，Chrome 在 2015 年 9 月徹底移除了 NPAPI 支援，Unity 也隨之建議開發者改用 WebGL[^11]。Java 寫的 Minecraft 從此不能在網頁裡直接跑，要跑就得用 JavaScript 或 WebAssembly 重寫。

**圖形介面來得晚**。WebGL 對應的是手機上的 OpenGL ES，能力比桌面的圖形介面落後一代。更現代的 WebGPU 在 Chrome 113 首發，直到 2025 年 11 月，Chrome、Edge、Firefox、Safari 才都支援了它，而且 Linux、安卓上的 Firefox 等平臺還在路上[^12]。

**沙箱的限制**。網頁裡的程式碼不能隨意訪問系統，這是瀏覽器的底線，也是遊戲的麻煩：

- 網頁不能直接開 TCP 連線。開源的網頁版客戶端 [mcraft.fun](https://mcraft.fun) 能連上真正的 Minecraft 伺服器，靠的是先用 WebSocket 連到一個代理，再由代理去連伺服器[^16][^16b]。
- WebAssembly 的多執行緒依賴 `SharedArrayBuffer`，而在 Spectre 漏洞之後，瀏覽器要求頁面設定“跨域隔離”的響應頭才能用它[^17]，這會影響頁面嵌入第三方內容。
- 32 位 WebAssembly 最多用 4 GB 記憶體。64 位記憶體（Memory64）已在 Chrome 133 和 Firefox 134 上線，但因為每次訪存都要額外做邊界檢查，可能慢 10% 到一倍以上[^13]。
- 網頁的儲存有配額，空間緊張時可能被瀏覽器清掉，存檔放在這裡並不踏實。

**沒人投錢**。Epic 在 2019 年 12 月的虛幻引擎 4.24 裡把 HTML5 平臺移出了引擎，交給社群維護。一家做網頁匯出工具的公司的聯合創始人評價說，Epic 只支援 WebGL 1，“並不想投很多錢讓它變得可用”，而且遊戲動輒幾個 GB，本來就是“頻寬大戶”[^15]。

所以瀏覽器跑不好大型 3D 遊戲，主要不是算力問題，而是安全模型、標準進度和商業動機一起決定的。

### 第一次嘗試：用現成的方案

想在瀏覽器裡玩 Minecraft，今天有幾條路：

- 官方的 [Classic 版](https://classic.minecraft.net)，免費，但只是 2009 年的樣子。
- 雲遊戲：遊戲跑在伺服器上，瀏覽器只收畫面。能玩完整版，但它不是在瀏覽器裡“跑”的。
- 開源的 mcraft.fun：用 TypeScript 重寫的相容客戶端，Three.js 渲染，能連 1.8 到 1.21.5 的伺服器[^16][^16b]。它最接近“瀏覽器原生”，但不支援模組，大場景下也會吃力。

這些方案說明瀏覽器能跑方塊世界，但都沒回答我們的問題：**如果只要最基礎的玩法，自己從頭寫一個，到底難在哪？**

### 解決：寫一個最小版本

我們寫了一個最小方塊世界，只依賴 Three.js，用 WebGL2 渲染，[原始碼](/demos/voxel/main.js)約 500 行。它就嵌在下面，點一下畫面就能玩：

<figure style="margin:1.5rem 0;">
<iframe src="/demos/voxel/" title="最小方塊世界" loading="lazy" allow="fullscreen" style="display:block;width:100%;aspect-ratio:16/10;min-height:320px;border:0;border-radius:10px;background:#9cc7ef;"></iframe>
<figcaption style="text-align:center;font-size:0.9em;opacity:0.75;margin-top:0.5rem;">點選畫面開始：WASD 移動，空格跳，Shift 跑，左鍵挖，右鍵放，1–6 選方塊，Esc 暫停。需要鍵盤和滑鼠，也可以<a href="/demos/voxel/">單獨開啟</a>。</figcaption>
</figure>

接下來看它是怎麼寫的。

#### 整體結構：一個主迴圈，兩個讀寫入口

整個程式圍繞一個 `frame()` 函式轉。瀏覽器每次準備重新整理螢幕時（通常每秒 60 次）呼叫它一次，它按固定順序做四件事：

```js
function frame(now) {
  const dt = Math.min((now - last) / 1000, 0.05);   // 这一帧过了多少秒，最多按 0.05 算
  last = now;

  updateChunks();                    // 1. 补齐视距内还没建网格的区块
  stepPlayer(dt);                    // 2. 重力、走动、碰撞
  camera.position.set(player.pos.x, player.pos.y + EYE, player.pos.z);
  updateTarget();                    // 3. 准星指着哪个方块
  renderer.render(scene, camera);    // 4. 交给 Three.js 画出来

  requestAnimationFrame(frame);      // 预约下一帧
}
```

（為了好讀，這裡省掉了出生保護、空閒時停畫和 HUD 統計。）

`dt` 封頂 0.05 秒是個小細節：如果某一幀卡了半秒，玩家不會在這一幀裡一下子掉落好幾格、直接穿過地面。

鍵盤滑鼠這些事件不在主迴圈裡處理，它們只改狀態：按鍵記進 `keys` 集合，滑鼠移動改視角，點選時呼叫 `setBlock` 挖或放。主迴圈下一幀讀到新狀態，自然就動起來了。

所有對世界的讀寫都只經過兩個函式：讀用 `getBlock`，寫用 `setBlock`。生成網格、碰撞檢測、找準星，讀的都是 `getBlock`；挖和放，寫的都是 `setBlock`。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mc-arch-title mc-arch-desc">
<title id="mc-arch-title">最小方塊世界的程式碼結構</title>
<desc id="mc-arch-desc">左邊是每幀依次執行的 updateChunks、stepPlayer、updateTarget、renderer.render，前三個都透過 getBlock 讀方塊，渲染只讀 meshes。點選事件呼叫 setBlock，setBlock 改 chunks 陣列、把改動記進 edits 並寫入 localStorage，再重建區塊網格放進 meshes。</desc>
<defs><marker id="mc-arch-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker><marker id="mc-arch-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker></defs>
<rect class="dd-frame" width="800" height="460" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="360" height="428" rx="8"/><text class="t-zone" x="32" y="44">main.js 的邏輯</text>
<rect class="dd-zone" x="392" y="16" width="392" height="428" rx="8"/><text class="t-zone" x="408" y="44">世界資料</text>
<path class="dd-edge-acc" d="M360 90 H406" marker-end="url(#mc-arch-arrow-acc)"/>
<path class="dd-edge-acc" d="M576 116 V150" marker-end="url(#mc-arch-arrow-acc)"/>
<path class="dd-edge-acc" d="M744 90 H756 Q764 90 764 98 V362 Q764 370 756 370 H746" marker-end="url(#mc-arch-arrow-acc)"/>
<path class="dd-edge" d="M360 178 H406" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M360 242 H406" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M360 306 H406" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M408 370 H362" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M196 204 V213" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M196 268 V277" marker-end="url(#mc-arch-arrow)"/>
<path class="dd-edge" d="M196 332 V341" marker-end="url(#mc-arch-arrow)"/>
<rect class="dd-card" x="32" y="64" width="328" height="52" rx="6"/><text class="t-mono t-acc" x="48" y="86">mousedown</text><text class="t-note" x="48" y="104">左鍵挖、右鍵放，目標來自 updateTarget()</text>
<text class="t-note" x="32" y="140">每幀依次執行：</text>
<rect class="dd-card" x="32" y="152" width="328" height="52" rx="6"/><text class="t-mono" x="48" y="174">updateChunks()</text><text class="t-note" x="48" y="192">補齊視距內的區塊網格，每幀最多 2 個</text>
<rect class="dd-card" x="32" y="216" width="328" height="52" rx="6"/><text class="t-mono" x="48" y="238">stepPlayer(dt)</text><text class="t-note" x="48" y="256">重力、走動，三個軸分開做碰撞</text>
<rect class="dd-card" x="32" y="280" width="328" height="52" rx="6"/><text class="t-mono" x="48" y="302">updateTarget()</text><text class="t-note" x="48" y="320">DDA 射線：準星指著哪個方塊</text>
<rect class="dd-card" x="32" y="344" width="328" height="52" rx="6"/><text class="t-mono" x="48" y="366">renderer.render()</text><text class="t-note" x="48" y="384">Three.js 把網格交給 WebGL2</text>
<text class="t-note" x="384" y="172" text-anchor="middle">讀</text>
<rect class="dd-card" x="408" y="64" width="336" height="52" rx="6"/><text class="t-mono t-acc" x="424" y="86">setBlock(x, y, z, id)</text><text class="t-note" x="424" y="104">寫陣列、記改動、重建本區塊和隔壁區塊的網格</text>
<rect class="dd-card" x="408" y="152" width="336" height="162" rx="6"/>
<path class="dd-card-head" d="M408.6 184 V158 Q408.6 152.6 414 152.6 H738 Q743.4 152.6 743.4 158 V184 Z"/>
<line class="dd-edge" x1="408" y1="184" x2="744" y2="184"/>
<text class="t-mono" x="424" y="173">getBlock(x, y, z)</text><text class="t-note" x="728" y="173" text-anchor="end">所有讀取的入口</text>
<text class="t-mono" x="424" y="201">chunks</text><text class="t-val" x="728" y="201" text-anchor="end">Map("cx,cz" → Uint8Array)</text>
<line class="dd-hair" x1="424" y1="210" x2="728" y2="210"/>
<text class="t-mono" x="424" y="227">下標</text><text class="t-val" x="728" y="227" text-anchor="end">x + z·16 + y·256</text>
<line class="dd-hair" x1="424" y1="236" x2="728" y2="236"/>
<text class="t-mono" x="424" y="253">缺區塊</text><text class="t-val" x="728" y="253" text-anchor="end">generateChunk()：從種子算</text>
<line class="dd-hair" x1="424" y1="262" x2="728" y2="262"/>
<text class="t-mono" x="424" y="279">再疊加</text><text class="t-val t-acc" x="728" y="279" text-anchor="end">edits 裡的改動</text>
<line class="dd-hair" x1="424" y1="288" x2="728" y2="288"/>
<text class="t-mono" x="424" y="305">存檔</text><text class="t-val t-acc" x="728" y="305" text-anchor="end">edits → localStorage</text>
<rect class="dd-card" x="408" y="344" width="336" height="52" rx="6"/><text class="t-mono" x="424" y="366">meshes</text><text class="t-note" x="424" y="384">Map("cx,cz" → THREE.Mesh)，每個區塊一個網格</text>
<text class="t-say" x="408" y="424">讀寫方塊只走這兩個函式，渲染只看 meshes。</text>
</svg>
</div>
<figcaption>圖 4 · 演示的程式碼結構。左邊是每幀依次做的四件事，右邊是世界資料；強調色是一次挖方塊的路徑：點選 → setBlock → 改資料、記存檔、重建網格。</figcaption>
</figure>

主迴圈之外，`main.js` 從上到下分成五段，正好對應圖 4 右邊的資料和左邊的四個步驟。把函式體都省掉，整個檔案的骨架是這樣的：

```js
// 1. 世界数据
const chunks = new Map();                 // "cx,cz" → Uint8Array(16×16×64)
function getChunk(cx, cz) {}              // 查不到就调用 generateChunk 现算
function getBlock(wx, wy, wz) {}          // 世界坐标 → 区块 → 数组下标

// 2. 地形生成
function heightAt(wx, wz) {}              // 四层噪声叠出地面高度
function generateChunk(cx, cz) {}         // 填草、土、石、沙、树，再叠上 edits

// 3. 网格生成
function buildChunkGeometry(cx, cz) {}    // 只收集露在外面的面
function rebuildChunk(cx, cz) {}          // 生成 Mesh，放进 meshes
function updateChunks() {}                // 每帧补建最近的 2 个区块，卸掉远的

// 4. 操作与碰撞
function moveAxis(axis, delta) {}         // 一次只动一个轴
function stepPlayer(dt) {}                // 重力、走动、跳跃
function raycast(origin, dir, maxDist) {} // 准星找第一个实心方块
function setBlock(wx, wy, wz, id) {}      // 改数组、记改动、重建网格

// 5. 存档
const edits = new Map();                  // "cx,cz" → Map(下标 → 方块)
function recordEdit(cx, cz, i, id) {}     // 停手 500 毫秒后写 localStorage

// 主循环
function frame(now) {}                    // 上面那四步
```

下面按這五段說說每一段要解決的問題，具體實現可以直接看[原始碼](/demos/voxel/main.js)，每段開頭都有註釋。

**世界資料。** 每個區塊 16×16×64，用一個 `Uint8Array` 存，一個方塊一個位元組，三維座標按 `x + z*16 + y*256` 攤平成陣列下標。關鍵在 `getChunk`：呼叫方不需要關心區塊有沒有生成過，查到哪裡，哪裡就被生成出來，這正是第三節說的“按需生成”。唯一的坑是負座標，算區塊要用 `Math.floor` 而不是直接取整，否則 -3 會被算進區塊 0。

**地形生成。** `heightAt` 把四層 Perlin 噪聲疊起來：第一層頻率最低，決定大的山和谷；後面每一層幅度減半、頻率加倍，只加小起伏。種子固定，同一個座標永遠算出同樣的高度。為了簡單，這裡用的是第三節說的二維高度圖，所以沒有懸崖下的凹洞。

**網格生成。** 這一段就是圖 1 和圖 2 落到程式碼上：遍歷每個實心方塊的六個面，鄰居是實心的就跳過，是空氣就把這個面的 4 個角寫進頂點陣列，切成兩個三角形。視距 6 個區塊時一共約 63 萬個方塊，全畫要 377 萬個面，實際只畫了約 5.7 萬個，佔 1.5%，左上角的 HUD 會實時顯示這幾個數字。建網格是整個程式裡最耗時的一步，一口氣建完 113 個區塊會讓頁面卡住好幾秒，所以 `updateChunks` 每幀只建離玩家最近的兩個，世界是從腳下一圈圈長出去的。

**操作與碰撞。** 玩家被看成一個 0.6×1.8×0.6 的盒子。如果三個方向一起移動、撞上了就整體退回，斜著走向牆時人會被“粘”在牆上；`moveAxis` 改成一次只動一個軸，撞牆的方向停下，另一個方向照樣走，人就貼著牆滑過去了。準星用的是 DDA 演算法：沿視線每次只跨進下一個格子，不漏格也不重複，順帶記下是從哪個面進來的，右鍵放方塊就放在那個面的外側。

**存檔。** 挖和放都走 `setBlock`。它最容易漏的是邊界：挖掉區塊邊上的一塊，隔壁區塊裡原本被擋住的面露出來了，不連隔壁一起重建，地上就會出現一個能看穿的洞。存檔本身照搬圖 3 的思路，`edits` 只記玩家改過的方塊，玩家停手 500 毫秒後寫一次 `localStorage`；`generateChunk` 先從種子算出地形，再把改動覆蓋上去。所以無論區塊是第一次生成，還是被卸掉後重新生成，改過的地方都還在。

<!-- TODO: 请作者在自己的电脑上打开演示，截一张图并记下 HUD 上的帧率，替换下面这句 -->
我在沒有顯示卡的雲伺服器上，用無頭瀏覽器的軟體渲染跑這個演示，約 20 幀；有顯示卡的電腦應該會高得多（這一點我沒有實測）。

寫完之後回頭看，真正難的部分不在算力，而在三個地方：遠處區塊載入時怎麼不卡（我們用了分幀，更好的做法是把網格生成放進 Web Worker）；改動多了以後存檔放哪（`localStorage` 只有幾 MB，下一步該換成 IndexedDB 或 OPFS）；以及多人聯機時網頁只能走 WebSocket。這三處，恰好都是上一小節說的瀏覽器限制。

**一個能隨便改、規則又很清楚的世界，還有什麼別的用處？**

---

## 六、方塊世界與智慧體

### 現象：Minecraft 成了 AI 的考場

過去幾年，Minecraft 幾乎成了 AI 智慧體研究的標準環境。原因和前幾節說的特點直接相關：世界是開放的，沒有固定目標，適合考察“自己找事做”；規則是離散、明確的，方塊就是方塊，合成表就是合成表；世界可以被改，智慧體做了什麼會留下看得見的痕跡；網上還有海量的遊戲影片可以學。

### 第一種接法：看螢幕，按鍵盤

2022 年，OpenAI 的 VPT 讓模型像人一樣只看畫面、用鍵盤滑鼠操作。他們先用少量帶按鍵記錄的錄影訓練一個模型，給大約 7 萬小時網路影片補上“這一幀按了什麼鍵”的標籤，再拿這些影片預訓練。最後在 2.5% 的 10 分鐘回合裡做出了鑽石鎬，而熟練的人類平均要 20 多分鐘、約 24000 個操作[^18]。

這種接法最通用，但也最難：模型要從畫素裡理解世界。

### 第二種接法：直接呼叫介面

2023 年的 Voyager 換了一個思路：不讓 GPT-4 去按鍵，而是讓它寫程式碼。遊戲提供一套 JavaScript 介面，GPT-4 自己給自己出任務，寫程式碼完成任務，根據報錯修改，驗證透過的程式碼存進“技能庫”，下次遇到類似任務直接取出來用。和之前的方法比，它收集到的物品種類多了 3.3 倍，解鎖科技樹的關鍵節點快了最多 15.3 倍[^19]。

2024 年，Altera 的 Project Sid 把 10 個到 1000 多個智慧體放進同一個 Minecraft 世界，觀察到了分工、集體規則的制定和修改，以及文化和宗教在智慧體之間的傳播[^20]。

這種接法後來也走到了普通人手裡。有了 MCP（模型上下文協議）之後，社群做了 Minecraft 的 MCP 伺服器，把移動、挖、放、熔鍊、聊天這些動作暴露成工具，在 Claude 這樣的助手裡用一句話就能指揮遊戲裡的角色蓋房子[^21]。

我們的演示裡也有一行類似的東西：

```js
window.voxel = { player, getBlock, setBlock, stats, meshes };
```

它本來是給除錯用的，但你在瀏覽器控制檯裡呼叫 `voxel.setBlock(...)`，和一個智慧體呼叫 MCP 工具，本質上是同一件事：透過介面讀寫世界。網頁遊戲如果把這類介面規範地暴露出來，就能被智慧體直接操作。

### 第三種接法：讓模型本身成為遊戲

2024 年 10 月，Decart 釋出了 Oasis：沒有遊戲引擎，一個在 Minecraft 影片上訓練出來的模型根據你的鍵盤滑鼠輸入，實時生成下一幀畫面[^22]。它看起來像 Minecraft，但有一個致命問題：模型很快會“忘掉”地形，你轉一圈回來，眼前的景色已經變了[^22]。

這正好回到了本文的主線。Minecraft 的核心不是方塊的樣子，而是**一個能被記住、能被持久修改的世界**。第三節裡區塊、種子和改動記錄解決的就是這件事，而這恰恰是今天的生成式“世界模型”最欠缺的。

---

## 七、回到起點

現在可以回答開頭那個“這個我上我也行”了。Minecraft 看起來簡單，是因為它把複雜度藏在了看不見的地方：畫面上是方塊，底下是一套讓世界可以被隨便修改、又不會撐爆記憶體的資料結構和演算法。方塊的畫風也不只是省事，它讓每個玩家蓋出來的東西和官方的內容一樣“好看”（這是我的理解）。

它來得晚，是因為這件事需要好幾個條件同時成立：顯示卡和記憶體夠用，Java 讓一個人寫的程式能跑在所有電腦上，網路讓一個人能直接把沒做完的遊戲賣給玩家。而當年它能直接在瀏覽器裡執行，今天卻要重寫才能回去，這中間隔著的是瀏覽器十幾年的安全收緊。

## 小結

1. **Minecraft 的玩法從哪來？** 來自 Infiniminer 的玩家。Infiniminer 設計的是競技，玩家卻拿它蓋房子；作者因為不賺錢、程式碼失控而放棄，Minecraft 補上了生存、無限世界和收費。
2. **早期 3D 遊戲為什麼不這麼做？** 當時的效能來自“提前算好”的靜態關卡，可修改的世界會讓預計算全部失效。體素渲染輸給了顯示卡的三角形路線，區域性破壞也做不到“全部”。Minecraft 把方塊轉成三角形、只畫露在外面的面、按區塊重建網格，才讓兩者接上。
3. **這麼大的世界怎麼裝得下？** 按區塊、按需、確定性地生成，沒去過和沒改過的地方都不用存，存檔只記改動。
4. **為什麼是 2009 年？** 記憶體夠了，Java 能跨平臺甚至跑在網頁裡，預售讓一個人能養活自己，而大公司沒有動機去做沒有關卡的遊戲（推斷）。
5. **為什麼今天瀏覽器很難跑這種遊戲？** 不是算力，而是外掛被移除、WebGPU 剛剛普及、沙箱對網路執行緒記憶體儲存的限制，以及引擎廠商不投入。但只要最基礎的玩法，500 行程式碼就夠了，[可以直接試試](/demos/voxel/)。
6. **為什麼它成了 AI 的考場？** 開放、規則清楚、可修改。智慧體可以看螢幕操作，也可以呼叫介面操作；用模型直接生成遊戲畫面的嘗試，卡在了“記住世界”這件 Minecraft 早就解決了的事情上。

## 參考資料
