Skip to main content

Wei Ji

知識邊界

Matt Might 的「圖解博士學位 (The illustrated guide to a Ph.D.)」主要是以視覺化的方式解釋「全人類的知識邊界」與博士學位之間關係。不過我認為這個概念在幫助人理解教育體系本身的設計以及自身的知識邊界非常有幫助。沒有看過得人可以點擊上面的連結或是這個中文翻譯

以下我會參考這個概念解釋,加上一點我的個人見解。

“As our circle of knowledge expands, so does the circumference of darkness surrounding it.”

-- 愛因斯坦

用圓圈來形容知識,Matt Might 並不是第一個人,不過視覺化的表達可能遠比名言金句更有力量。現在想像一個圓圈代表全人類知識體系已知的知識,而圓圈外則是未知:

當一個人接受了國民義務教育,它從整個知識體系中均衡的學習到了一點東西:

在 Matt Might 的圖解中,高中是另外一個均勻的知識圈圈,開始出現知識領域專業化是大學系所才發生的事情。不過在我國教育實務上有分成普通高中跟高職;普通高中又有分組(108 課綱之後為班群),而高職則是已經開始進行類似大學系所的專業化學習(理論與實務的差異先不談):

普通高中是綠色的比較均勻圈圈,高職則是以較偏頗的虛線,看過銀之匙(荒川弘)這部作品的讀者可能會對這種差異更有感覺,作品中普通升學體系的主角進入技職體系所受到的衝擊就是來自於兩種體系對於知識邊界預期不太一樣。

如果你繼續留在學術界,完成學士、碩士的訓練;並且持續閱讀論文、學習往特定的知識領域前進;最後在人類的知識邊界挖出一角,那個成果將讓你成為博士。

Matt Might 花比較多篇幅在醞釀這個小角,因為擴展人類知識邊界的博士並不容易,不過這並不是我今天要談的重點,所以容我快速帶過,但是到這裡為止,你應該對「知識邊界」的概念有比較清晰的想像。

另外在我看來,知識邊界拓展的方式從微觀尺度來講,更像是「劈裂」過去的,當每你經歷一個得以觸動的知識點時,知識邊界會以閃電一般的路徑劈裂過去。

觸動的與否取決於該知識點與知識邊界的距離,以及原本「裂痕的方向」有關,這是為什麼同樣的事件、同樣的體驗可以成為某些人的知識點並拓展它的知識邊界,對某些人而言卻只是成為被忘卻的記憶。

專題式自學法

專題導向學習 (Project-Based Learning) 似乎是一種教育科學的方法論,並且在我國被提倡也只是最近(108 課綱)的事情而已。而我有幸在成長過程自然的捕捉到這個方法論的脈絡。


「想做遊戲」,於是有了:

飛貓工作室

但是實際上沒有完成任何遊戲。


「看著 LEGO® NXT 2.0,心想自己造一個」,於是有了:

電機開發平台 (MMFEDP, Modular Multi-Faceted Electrical Develop Platform) 專案

但是實際上沒有完成任何跟可程式化有關的東西或是足夠實用的減速機。


「看著機器人比賽,對著那種與他人合作打造機器人的憧憬,心想自己造一個」,於是有了:

獨立性無人地面載具 (ITUGV, Independent Task Unmanned Ground Vehicle)

但是實際上沒有實現遠端遙控的機器人。


「YouTube 的『homemad』影片往往包含了車銑床之類的加工方式,但是我想自己製作機器人零件,那就用鑄造的好了」,於是有了:

土砲熔爐

但是實際上沒有用它製作過機器人零件。


「想和同儕分享寫程式的樂趣」,於是有了:

程式蠱

但是實際上沒有讓其他人參與過。


「想做 2.5D RPG」,於是有了:

VB.NET 土砲 2.D 遊戲

但是實際上沒有實現遊戲該有的抽象化與職責分離。


「看著 KSP 遊戲但是無法合法的擁有,心想自己造一個」,於是有了:

VB.NET 太空軌道模擬遊戲

但是實際上停留在 2D 模擬,而且沒有更進一步的多組件編輯機制。


「心想開發一個多人連線 FPS」,於是有了:

香巴朵 Online

但是實際上 3D 射擊的部份並不是使用 GPU 繪圖,而連線功能也僅停留在 2D 的實驗性開發 (prototype)。


「想用論壇機制來解決抽象的『自造者銀行』」,於是有了:

C 幣論壇

但是實際上交易功能並沒有投入使用。


「既然我已經做過熔爐了,這次順便解決廢氣問題好了」,於是有了:

畢業專題-熔爐

但是實際上廢氣處理系統並沒有得到足夠多的關注與測試。


當我說:

我有「閃亮事物症候群」

不是在開玩笑的。

當我說:

「智者從歷史中學習,愚者從錯誤中學習」,而我是愚者。

不只是說說而已。

每一個 Side Project 其實都源自微不足道的願望,並且乍看之下所有專案都以失敗告終,不過我尚未提起我在當中獲得了什麼:

  • 「飛貓工作室」:我當過傲慢毫無能力(不論是領導能力或是技術能力)的籌備者;這讓我每次在團體或團隊中手握權力時,不斷提醒著自身與他人之間的關係。
  • 「MMFEDP」、「ITUGV」:我已經成長到不必透過 NXT 2.0 這樣的東西,而是使用 Arduino、樹莓派來開發機電玩具的程度了。
  • 「土砲熔爐」、「畢業專題-熔爐」:它帶給我「無法製造」的無能感是引導我前往材料系的因素之一,如今我已經能夠使用車床、銑床、鉗工、手工電弧焊、3D 列印...等等方式打造我想要的東西。
  • 「程式蠱」:Zero-player game 的概念至今仍然在影響著我學習的方向,實作 2.0 的時候更是直接點開 dlopen 的使用經驗。
  • 「VB.NET *」:雖然現在我已經不使用 VB 了,但是第一次使用參考(指標)以及從靜態記憶體到動態記憶體的過程依然是不可多得的體驗。
  • 「香巴朵 Online」:它對我提供了一個很強烈的 TCP Socket 的記憶點,這讓我在使用諸如 HTTP 其他網路連線機制的時候很有幫助。
  • 「C 幣論壇」:使用 Laravel 的經驗可以說是幫我在後端軟體的開發經驗上打下非常堅硬的基礎。

專題與知識邊界

是的,大部分的專題目標都在我當下的知識邊界之外,這也是為什麼它們大多數都會失敗的原因。

我們選擇在這個十年登上月球,並完成其他的事,不是因為它們很簡單,而是因為它們很困難。

-- 約翰.甘迺迪(John F. Kennedy)

每一個 Side Project 都是對於「博士挑戰人類知識邊界」的微小仿作,對我而言知識不是一個被好像很偉大的人站在教室裡授予的東西,而是在知識邊界之外,透過一個又一個知識點劈開未知獲得的東西。

如此不斷的前進,過個幾年回過頭來看,可能會驚訝於那些曾經遙不可及的目標不知不覺已經處於自己的身後;又或是它已經處於自己當下的知識邊界觸手可及的地方。

Wei Ji

Assets

這個部份算是上一次的研究筆記漏掉了,這個模組沒什麼特別的,就是一堆放在 Git LFS 的檔案,只是我抽出來單獨放在一個 repository 內。

做這件事的時候,順便參考了幾個開源遊戲,看看它們處理遊戲素材的:

Cayley.js

Cayley.js 是一個 Cayley WASM 的再封裝,除了實做了一些東西以外,還提供一些單元測試跟 Benchmark 測試。

處理它的時候算是踩了 Javascript 生態系一個很經典的小坑,原本我打算用 Vitest 取代原本的 AVA 單元測試框架,但是我忘記了 Vite 工具鏈是 ESM 本位主義,因此單元測試會 import Web 版本的 WASM 載入器。

然而 Web 與 Node.js 是屬於兩個不同的 Javascript Runtime,對於「如何載入 WASM」的方式也不同;Web 是使用 fetch 從網路下載 WASM;Node.js 則是使用 fs 從檔案系統讀取 WASM。

使用 Vitest 呼叫 fetch 會觸發 "Not implemented yet" 之類的錯誤訊息。最後是使用 Jest 取代 AVA,慶幸的是已經有人寫了遷移指南1,照著做就完成了。

Benchmark 那邊則是用 tsx 取代 ts-node

Zod

Zod 是一個方便開發者在 Runtime 對 JSON 資料進行 Schema 驗證的函式庫,我其實不確定 Biomes 為什麼要刻意 fork 出一個版本,最明顯的差異是 Biomes 的版本多了這個方法:

z.object().annotate(Symbol.for("some"), true)

並且 Symbol 並不在 Zod 的支援路線上:

Symbols aren't considered literal values, nor can they be simply compared with ===. This was an oversight in Zod 3.2

考慮到這並不屬於 JSON 解析的範疇,想想也合理,不過 Biomes 專案本身已經用下去了,我也不能在不熟悉的專案的情況下貿然移除這個客製化 Zod 轉而使用原本的。Biomes 原本是直接從 GitHub 安裝,我 fork 之後做了兩件事:

  • 將原本應該被 gitignore 的發布路徑 (/lib) 從 Git 紀錄中清除。
  • 將打包結果發布到 NPM。

Shared

ecsshared 之間有很嚴重的循環仰賴,無奈之下只好用 PNPM 的 workspace 處理。ecsserver 有輕微的仰賴,我直接複製那部份的程式碼進入 ecs,在我看來這裡解偶的效益遠比 DRY 還重要。

shared 則是測試的部份對 server 仰賴,仰賴了 Voxeloo WASM loader 的部份,這一點我打算暫時忽略,之後再來處理,畢竟這個部份可以參考 Cayley 的處理方式。

這是我在 shared 上面臨另外一個比較麻煩的問題:

shared 使用 Zod 大量定義 Schema,而一些複雜的 Schema 在編譯 Typescript 型別檔 (.d.ts) 時,無法正常推論,因此必須透過手動聲明的方式來解決:

export const zChallengeCompleteMessage: z.ZodObject<{
kind: z.ZodLiteral<"challenge_complete">;
challengeId: typeof zBiomesId;
}> = z.object({
kind: z.literal("challenge_complete"),
challengeId: zBiomesId,
});

而且看起來跟前面提到的 Symbol 有關...F**k...

Footnotes

  1. Switching from Ava to Jest for TypeScript | by Gant Laborde | Red Shift. Retrieved 2025-12-24, from https://shift.infinite.red/a6dac7d1712f

  2. Migration guide | Zod. Retrieved 2025-12-24, from https://zod.dev/v4/changelog#drops-symbol-support

Wei Ji

前情提要

最近我在研究一個開源專案 (ill-inc/biomes-game),並且在 2025-12-14 算是完成了一個里程碑,我成功重建專案內的素材瀏覽器,將遊戲素材讀出並且播放動畫:

「這個是怎麼幫 Voxel 上動畫的?」一個工程師這個問我。

「我不知道」我回道。

雖然我有瞄過遊戲素材檔案一眼,知道裡面有 .vox 和 JSON 檔,但是我不知道它們在這個專案具體是怎麼組裝起來的。

.vox 這個檔案格式我其實不陌生,早在 2021 年 11 月的時候我就有一篇筆記紀錄關於各種 Voxel 儲存的檔案格式,不過並沒有整理成能夠拿給別人看的程度。於是我想說趁這個機會把東西整理出來發一篇廢文。

.gox (Goxel)

Goxel 是 一個 Voxel 編輯軟體,而 .gox 則是它的專有(專案)格式,檔案格式的 Spec 直接寫在程式碼的註解內:

/*
* File format, version 2:
*
* This is inspired by the png format, where the file consists of a list of
* chunks with different types.
*
* 4 bytes magic string : "GOX "
* 4 bytes version : 2
* List of chunks:
* 4 bytes: type
* 4 bytes: data length
* n bytes: data
* 4 bytes: CRC
*
* The layer can end with a DICT:
* for each entry:
* 4 byte : key size (0 = end of dict)
* n bytes: key
* 4 bytes: value size
* n bytes: value
*
* chunks types:
*
* IMG : a dict of info:
* - box: the image gox.
*
* PREV: a png image for preview.
*
* BL16: a 16^3 block saved as a 64x64 png image.
*
* LAYR: a layer:
* 4 bytes: number of blocks.
* for each block:
* 4 bytes: block index
* 4 bytes: x
* 4 bytes: y
* 4 bytes: z
* 4 bytes: 0
* [DICT]
*
* CAMR: a camera:
* [DICT] containing the following entries:
* name: string
* dist: float
* rot: quaternion
* ofs: offset
* ortho: bool
*
* LIGH: the light:
* [DICT] containing the following entries:
* pitch: radian
* yaw: radian
* intensity: float
*/

順便提一下,我有回報過 Issue,雖然不是我修的;其實不值得炫耀,不過回報 bug 也是 FOSS 的參與方式之一,對吧?

KVX, KV6 (Ken Silverman's Voxel file)

在介紹這些檔案格式以前,可能需要談談 Ken Silverman 這個人,他是 Build 遊戲引擎 的作者,並且他設計 KVX 檔案格式被用於 Shadow WarriorBlood 兩款遊戲。12

不過對我而言這些資訊並不是我認識他的原因,我是從這個影片得知這號人物存在的:

根據影片的說明,我們可以找到 Voxlap 引擎的說明頁面:

https://advsys.net/ken/voxlap.htm

接著我們可以在網頁中看到它指向一個名為 SLAB6 的工具:

https://advsys.net/ken/download.htm#slab6

這裡有一個 SLAB6 原始碼的備份:

https://github.com/vuolen/slab6-mirror

並且這裡有一個筆記是從 SLAB6 中抽出 slab6.txt 檔案格式的介紹整理而成的:

https://gist.github.com/falkreon/8b873ec6797ffad247375fc73614fd08

該文件提供了 VOX,KVX 和 KV6 三種檔案格式的詳細說明,因此我們可以知道:

  • VOX: 無壓縮的三維 RGB 資料。
  • KVX: Ken Silverman 比較早期 (1995 年) 設計的 Voxel 格式。
  • KV6: 在 Ken Silverman 比較後期 (2020 年) 設計的格式,原本屬於 SLAB6 這個軟體的一部分。
info

注意,這裡的 VOX 不要跟目前真正流行的 .vox (MagicaVoxel) 檔案格式搞混,我稍後會介紹。

為什麼要介紹這個看起來有點古老的格式?因為你依然可以在一些 Voxel 遊戲中看到它的蹤跡,例如: OpenSpades (Ace of Spades 的 Clone)

VXL

.vxl 是紅色警戒 2 用於繪製單位模型的格式,具體的檔案結構如以下文件所示:

This document describes the VXL format for storing voxel (volume pixels) models for the game Tiberian Sun by Westwood Studios. 3

Luanti (Minetest)

Minetest 是一個「很像 Minecraft」的開源遊戲引擎,並且地圖的資料是儲存在 SQLite 中,

CREATE TABLE `blocks` (
`x` INTEGER, `y` INTEGER, `z` INTEGER,
`data` BLOB NOT NULL,
PRIMARY KEY (`x`, `z`, `y`)
);

至於當中的 BLOB 是如何編碼的,則可以在 world_format.md 中找到4

Vox (MagicaVoxel)

MagicaVoxel 是一個閉源的免費 Voxel 編輯軟體。

.vox 檔案是 RIFF (Resource Interchange File Format) 風格的格式5,完整的文件和檔案 sample 可以在這裡找到:

https://github.com/ephtracy/voxel-model

BINVOX6

它是單色的 voxel 格式,檔案結構如下:

#binvox 1
dim 128 128 128
translate -0.120158 -0.481158 -0.863158
scale 7.24632
<data>

由文本的資訊與二進制的資料構成。

資料則是由數個 word 組成,一個 word 用來描述一段連續的 voxel:

  1. 0 or 1 用來表示實體或是空氣
  2. 1~255 表示前一個 byte 的資料要重複幾次

.qb (Qubicle Binary)

Qubicle 是一個付費的 Voxel 編輯器。檔案格式的內容可以在它舊的網站找到7

Minecraft 地圖格式

Named Binary Tag (NBT)

NBT 是一種 Minecraft 用於儲存資料的數據結構。而序列化的 NBT 則是 SNBT (stringified NBT)。

SNBT 有著類似於 JSON 的結構(與 JSON 並不兼容),比如:

{name1:123,name2:"sometext1",name3:{subname1:456,subname2:"sometext2"}}

從這段程式碼可以更好的理解 NBT 和 SNBT 的關係:8

    var tag1;
try {
tag1 = nbtlint.parse(input.value);
} catch (e) {
output.value = e.message;
}

// 這是 NBT
var tag2 = new nbtlint.TagCompound({
Score: new nbtlint.TagInteger(1500),
Pos: new nbtlint.TagList(nbtlint.TagDouble, [
new nbtlint.TagDouble(15.5),
new nbtlint.TagDouble(123),
new nbtlint.TagDouble(-491.77),
]),
SelectedItem: new nbtlint.TagCompound({
id: new nbtlint.TagString("minecraft:diamond_sword"),
}),
});

// 這是 SNBT
// {
// Score: 1500,
// Pos: [15.5d, 123d, -491.77d],
// SelectedItem: {
// id: "minecraft:diamond_sword"
// }
// }
console.log(nbtlint.stringify(tag2, "\t"));

「能夠儲存樹狀結構以及各種資料型別的定義」這個抽象概念本身就是 NBT,不論實作的語言是什麼;不論資料儲存在記憶體還是硬碟上。而 SNBT 就是序列化的 BNT。

不同版本的地圖資料

info

以下是四年前 (2021) 寫的筆記,而且我也退坑 Minecraft 一陣子了,不確定最新版本的 Minecraft 是否有所調整。

不同時期(版本)的 Minecraft 使用不盡相同的資料結構來儲存遊戲世界。

Server_level.dat9

  • 單一檔案
  • gzip 壓縮
  • 使用於 Classic

Java_Edition_Alpha_level_format10

  • 複數個檔案
  • 使用 NBT
  • 使用資料夾結構區分 chunk
  • GZip 壓縮
  • 使用於 Infdev 、 Alpha 和部份版本的 Beta

一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit:

  • ID: 8 bits
  • Data: 4 bits
  • Light: 4 bits
  • SkyLight: 4 bits

在 Infdev 版本中,一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit。10

Region file format 11

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (r.x.z.mcr)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Beta 1.3

Anvil File Format 12

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (a.x,z.mca)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Java Edition 1.2.1
  • 格式大致跟 Region file format 相同,只有描述 chunk 的 NBT 與部份 label 有進行調整。

Footnotes

  1. Ken Silverman's Projects Page. Retrieved 2025-12-24, from https://advsys.net/ken/download.htm#slab6

  2. RTCM - Files - General Tools - Voxel. Retrieved 2025-12-24, from https://web.archive.org/web/20200706184402/http://www.r-t-c-m.com/knowledge-base/downloads-rtcm/general-tools-voxel/

  3. VXL_Format.txt. Retrieved 2025-12-24, from http://xhp.xwis.net/documents/VXL_Format.txt

  4. luanti/doc/world_format.md. GitHub. Retrieved 2025-12-24, from https://github.com/luanti-org/luanti/blob/46436248de4d64887f5ee3f1005224495ede895c/doc/world_format.md

  5. MagicaVoxel-file-format-vox.txt. GitHub. Retrieved 2025-12-24, from https://github.com/ephtracy/voxel-model/blob/8044f9eb086216f3485cdaa525a52120d72274e9/MagicaVoxel-file-format-vox.txt

  6. BINVOX voxel file format. Retrieved 2025-12-24, from https://www.patrickmin.com/binvox/binvox.html

  7. Qubicle Binary (QB) | Qubicle 3.0 Documentation. Retrieved 2025-12-24, from https://web.archive.org/web/20250417030951/https://getqubicle.com/qubicle/documentation/docs/file/qb/

  8. AjaxGb/NBTLint: Quickly and easily validate the stringified NBT format (SNBT) used in Minecraft commands. Retrieved 2025-12-24, from https://github.com/AjaxGb/NBTLint

  9. server_level.dat – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Server_level.dat

  10. Java Edition Alpha level format – Official Minecraft Wiki. Retrieved 2021-02-28, from https://minecraft.gamepedia.com/Java_Edition_Alpha_level_format 2

  11. Region file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Region_file_format

  12. Anvil file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.gamepedia.com/Anvil_file_format

Wei Ji

Galois

上一篇已經說明過 Viewer/Editor 是以 Electron 實做,並且和一個副程式透過 stdio 通訊。

經過研究發現該副程式的工作依然是採取 request-response 模式,並沒有太複雜的非同步通訊行為,因此我把處理副程式的實做改到 Nest.js 建立的 API 伺服器內,並且把原本用 Electron 呈現的 Viewer/Editor 重構成單純的 Web 應用程式。

呈現結果如下:

viewer

editor

Editor 給我的感覺有點失望,因為它實際上並沒有編輯的能力,只是畫面更完整一點的 Viewer,並且 Block 類的素材是沒辦法讀取的,這方面的問題預計暫時先跳過,不過已知的資訊有:

  • Trace 到 voxeloo 實做,看起來是單純的斷言失敗,而失敗的原因看起來是缺少 texture 參數。
  • Viewer 那邊是可以渲染方塊的,只是兩邊的 query 不太一樣。

循環仰賴

Galois 和 shared 之間原本有循環仰賴的問題,我試著移除一些看起來不是核心功能的東西,並且把真的消除不掉的仰賴直接從 shared 內抽出來直接放進 Galois 內。

Wei Ji
info

重構 Biomes 過程遇到的問題,涉及比較技術細節的故障排除所以額外發一篇文分開講。

原始錯誤

Exception occurred during build:
Traceback (most recent call last):
File "impl/materializers.py", line 1783, in materialize
File "impl/materializers.py", line 716, in materialize_ToIcon_Flora
File "impl/vox.py", line 739, in iconify_voxel_array
File "impl/render_voxels.py", line 111, in render_map
ModuleNotFoundError: No module named 'numpy.core.multiarray'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File "build.py", line 65, in exec_program
File "impl/lru_cache_by_hash.py", line 30, in set_default
File "impl/lru_cache_by_hash.py", line 24, in make_value_cached
File "build.py", line 66, in <lambda>
File "impl/materializers.py", line 1785, in materialize
impl.types.MaterializationError: Error materializing node: DerivedNode(kind='ToIcon_Flora', deps=[Flora:0x1fc2cb2e4002fa67], dep_hashes=['57036fed7713db64e4fb917e35d1c6cc5436883b'])

執行上下文

整個呼叫鏈是從 HTTP request 觸發,在 Nest.js 內部 spawn 一個副程式, 而該副程式是透過 pyinstaller 打包的 Python 程式, 並且該程式又 import 了另外一個透過 pybind11 打包的 C++ (實作的)動態函式庫。

出現問題的 render_voxels.py 則是「透過 pyinstaller 打包的 Python 程式」當中的一部分。

解決方法

build.py 所在的專案改變 numpy 的版本:

// pyproject.toml

- "numpy>=1.26.0",
+ "numpy==1.26.4",

直後重新用 pyinstaller 打包一份新的執行檔。

推論過程

查資料的過程會發現常見的錯誤訊息跟我的有一點點不同,不是 numpy.core.multiarray 而是 numpy._core.multiarray1

E DeprecationWarning: numpy.core.multiarray is deprecated and has been renamed to numpy._core.multiarray. The numpy._core namespace contains private NumPy internals and its use is discouraged, as NumPy internals can change without warning in any release. In practice, most real-world usage of numpy.core is to access functionality in the public NumPy API. If that is the case, use the public NumPy API. If not, you are using NumPy internals. If you would still like to access an internal attribute, use numpy._core.multiarray._ARRAY_API.

於是這個問題可能是來自 numpy 的破壞性更新。

確認一下 Biomes 專案下 requirements.txt 使用的版本:numpy==1.24.2

檢查一下自己的專案配置使用 "numpy>=1.26.0,並且 lock 檔顯示實際安裝 2.3.5,到這邊為止幾乎可以確定是 numpy 版本的問題,不過以防萬一還是做更嚴謹的驗證。

如果我們根據錯誤訊息 File "impl/render_voxels.py", line 111, in render_map 可以找到:

    proj = rays.render_orthographic_color(
cm=cm,
normals=dense_normals,
size=size,
src=src_after_padding,
dir=dir,
lighting_dir=lighting_dir,
up=up,
far=1000.0,
zoom=zoom,
distance_capacity=0.5,
)

rays.render_orthographic_color 的實做實際是在 C++ 內完成的,現在我們回去看一下 C++ 實作:

#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
// ...
auto render_orthographic_color(
const ColorMap& cm,
const py::array_t<float>& normals,
Vec2i size,
Vec3f src,
Vec3f dir,
Vec3f lighting_dir,
Vec3f up = {0.0f, -1.0f, 0.0f},
float far = 100.0f,
float zoom = 1.0f,
float distance_capacity = 0.5f) {
auto [w, h] = size;
Vec3f z_dir = normalized(dir);
Vec3f x_dir = normalized(cross(z_dir, up));
Vec3f y_dir = normalized(cross(x_dir, z_dir));

auto ret = py::array_t<float>({h, w, 4});
auto acc = ret.mutable_unchecked<3>();
auto inv_zoom = 1.0f / zoom;

for (int i = 0; i < h; i += 1) {
auto x_shift = 0.5f * (w - 1) * x_dir;
auto y_shift = (0.5f * (h - 1) - i) * y_dir;
auto pos = src - inv_zoom * (x_shift - y_shift);
for (int j = 0; j < w; j += 1) {
auto val = integrate_color(
cm, normals, pos, z_dir, lighting_dir, far, distance_capacity);
acc(i, j, 0) = val.x;
acc(i, j, 1) = val.y;
acc(i, j, 2) = val.z;
acc(i, j, 3) = val.w;
pos += x_dir * inv_zoom;
}
}

return ret;
}

因此我們知道這個試圖 import numpy.core.multiarray 的 numpy 實際上應該是 pybind11 提供的,確認一下我們正在使用哪個版本:pybind/pybind11@2.9.2

README.md 說得很清楚了2

Integrated NumPy support (NumPy 2 requires pybind11 2.12+)

Footnotes

  1. MAINT: NumPy _ARRAY_API import deprecation warning · Issue #4886 · pybind/pybind11. Retrieved 2025-12-13, from https://github.com/pybind/pybind11/issues/4886

  2. pybind/pybind11: Seamless operability between C++11 and Python. Retrieved 2025-12-13, from https://github.com/pybind/pybind11

Wei Ji

Galois Viewer

viewer 是一個 Electron 實做。server 則是一個薄封裝,實作了簡單的佇列,並且會從 Python 那邊的實作 (build.py) spawn 一個 process。

從資料流的角度則如下圖所示:

spawn 出來的 process 是透過 FD (File descriptor) 3 和 4 跟 Electron 的後端※溝通。而兩者傳輸的資料格式則是使用 base64 編碼過得 JSON,而 JSON 的 sechma 可以在 biomes-aql-utils 裡面找到。

info

※更精確的說法是 main process,與之相對的瀏覽器端則是 renderer process,這裡選擇使用一個對 Electron 不熟悉的人應該也比較好理解的用詞。

Error: spawn /bin/sh ENOENT at Process.ChildProcess

目前遇到的主要障礙是以下錯誤訊息:

Error: A JavaScript error occurred in the main process

Uncaught Exception: Error: spawn /bin/sh ENOENT at Process.ChildProcess._handle.onexit (node:internal/child_process:282:19) at onErrorNT (node:internal/child_process:477:16) at processTicksAndRejections (node:internal/process/task_queues:83:21)
Uncaught Exception: Error: write EPIPE at afterWriteDispatched (node:internal/stream_base_commons:164:15) at writeGeneric (node:internal/stream_base_commons:155:3) at Socket._writeGeneric (node:net:780:11) at Socket._write (node:net:792:8) at writeOrBuffer (node:internal/streams/writable:389:12) at _write (node:internal/streams/writable:330:10) at Socket.Writable._write (node:internal/streams/writable:334:10) at BatchAssetServer.send (/home/flyskypie/Desktop/2025-10-22_biomes-game_research/2025-11-16_galois-extract/galois-fe-investigation/js/server/dist/server.cjs:93:30) at /home/flyskypie/Desktop/2025-10-22_biomes-game_research/2025-11-16_galois-extract/galois-fe-investigation/js/server/dist/server.cjs:110:9 at processTicksAndRejections (node:internal/process/task_queues:96:5)

目前盲猜是因為 spawn 發生在 Electron 內,在 Linux 上應屬於某種沙盒環境,因此「有 shell 的 spawn」沒辦法正常運作。

我不是很想處理 Electron 打包機制與虛擬目錄引入的複雜性,之後應該會把架構改寫成單純的 Web 前端跟 HTTP API 伺服器。

Wei Ji
  • 我最喜歡的資料結構:Graph
  • 我最喜歡的演算法:Perlin noise
  • 我最喜歡的語言:ECMAScript
  • 我最喜歡的物理現象:穿隧效應、絕緣破壞
  • 我最喜歡的社會哲學概念:巨靈論(Leviathan, Thomas Hobbe, 1651)
  • 我最喜歡的設計模式:Pipeline
  • 我最有興趣的軟體架構:ECS (Entity–component–system)
  • 最喜歡的 RFC:RFC 2119
  • 最喜歡的 ISO:ISO 8601
  • 最喜歡的 Desktop:Xfce
  • 最喜歡的 CSS 開發模式:CSS Module
  • 最喜歡的金屬:鋁
  • 最喜歡的筆記軟體:CodiMD
  • 最喜歡的歌:To Be Human (MARINA)
  • 最喜歡的影集:太空無垠 (The Expanse)
  • 最喜歡的輕小說:小書痴的下剋上 (本好きの下剋上)

Wei Ji

最近在研究 Biomes 的程式碼看到這個:

    this.process = spawn(
buildCommand,
["batch", `--workspace="${dataDir}"`, "--ignore_sigint"].concat(
additionalArgs
),
{
cwd: execDir,
stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"],
shell: true,
windowsHide: true,
}
);

恩?就算不看文件也猜得到 stdio 前三個是 stdinstdoutstderr,但是後面兩個是怎麼回事?

點進去看型別會看到1

        readonly stdio: [
Writable | null,
// stdin
Readable | null,
// stdout
Readable | null,
// stderr
Readable | Writable | null | undefined,
// extra
Readable | Writable | null | undefined, // extra
];

在 Node.js 的官方文件則只有稍稍帶過2

// Open an extra fd=4, to interact with programs presenting a
// startd-style interface.
spawn('prg', [], { stdio: ['pipe', null, null, null, 'pipe'] });

這個用法我不太熟悉,於是稍微往下追究。

File Descriptor (Python 被呼叫端)

如果直接執行該指令會得到錯誤:

$ ./build \
batch \
--workspace="${PWD}/data" \
--ignore_sigint

Traceback (most recent call last):
File "build.py", line 161, in <module>
File "build.py", line 115, in build_batch
File "<frozen os>", line 1037, in fdopen
OSError: [Errno 9] Bad file descriptor
[PYI-453501:ERROR] Failed to execute script 'build' due to unhandled exception!

回去看實做的地方則是會看到:

    infile = os.fdopen(3, "r")
outfile = os.fdopen(4, "w")

再找一下範例程式3

# Import os Library
import os

# Open file
fd = os.open("test.txt", os.O_RDWR|os.O_CREAT)

# Get a file object for the file
fo = os.fdopen(fd, "w+")

# Write something on open file
fo.write( "This is a test content for w3schools")

# Close file
fo.close()

好吧,我還是不太清楚,這個 fd 到底是什麼,看起來是某種檔案有關的 id,但是不知道為什麼會跟 stdio 扯上關係。

File Descriptor (System call)

嗯...這個 os.openos.fdopen 是用整數在操作,而不是實例或物件之類的,看起來很底層,

讓我翻一下 System call 的資料456

int open(const char* path, int oflag, /* mode_t mode */...);

ssize_t read(int fd, void* buf, size_t count);

ssize_t write(int fildes, const void* buf, size_t nbyte);

File Descriptor (Unix/Linux User space)

上面簡單介紹了User space和Kernel space,這對於理解fd有很大的幫助。fd會存在,就是因為使用者程序無法直接訪問硬體,因此當程序向核心發起system call打開一個檔案時,在使用者處理程序中必須有一個東西標識著打開的檔案,這個東西就是fd。7

file descriptor 和 file 之間的關係並不是一對一的。8

Process

寫一個簡單的 Node.js 程式 spawn 這個程式把它掛起來之後,用 pstree 找到 PID 之後調查一下:

$ pwd
/proc/435759

$ cat cmdline | xargs -n1 --null
./build
batch
--workspace=/some/where/data
--ignore_sigint

$ ll fd
total 0
dr-x------ 2 flyskypie flyskypie 5 Dec 6 20:34 ./
dr-xr-xr-x 9 flyskypie flyskypie 0 Dec 6 20:26 ../
lr-x------ 1 flyskypie flyskypie 64 Dec 6 20:34 0 -> /dev/null
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 1 -> /dev/pts/15
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 2 -> /dev/pts/15
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 3 -> 'socket:[1744182]'
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 4 -> 'socket:[1744184]'

在檔案系統中,每個磁碟有多個分區,每個分區有多個柱面組,每個柱面組有一個inode陣列。上面的數字2248868代表inode的一個編號,稱為inode編號。可以用來唯一標識改該檔案,也可以定位inode在磁碟中的位置。在socketfs虛擬檔案系統中,socket對應的inode並不像普通的檔案系統,位於磁碟上,而是位於核心的socket結構中(記憶體)。9

結論

"一切皆是文件" 是 Unix/Linux 的核心哲學之一,即便是 stdin, stdout, stderr 這些標準的 IO (stdio, standard input/output),實際上跟「打開檔案進行讀寫」的行為處於一套相同的體系:File Descriptor。

File Descriptor 是作業系統發給 Process 的整數 ID;stdin, stdout, stderr 固定為 0, 1, 2。透過 open 之類的操作界面 (System call),系統會另外分配 ID 並且再連結到對應要進行讀寫的檔案。

Node.js 在 Spawn 的時候可以在 stdin, stdout, stderr 以外再額外指定兩條管道,它們在 process 內的 File Descriptor 為 3 和 4。

Footnotes

  1. DefinitelyTyped/types/node/child_process.d.ts. GitHub. Retrieved 2025-12-06, from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/e70e0eca2af7ad212c893ee94bbcb1ba33b4dd3d/types/node/child_process.d.ts#L181-L191

  2. Child process | Node.js v25.2.1 Documentation. Retrieved 2025-12-06, from https://nodejs.org/api/child_process.html#optionsstdio

  3. Python os.fdopen(). W3Schools Online Web Tutorials. Retrieved 2025-12-06, from https://www.w3schools.com/python/ref_os_fdopen.asp

  4. open (system call) - Wikipedia. https://en.wikipedia.org/wiki/Open_(system_call)

  5. read (system call) - Wikipedia. https://en.wikipedia.org/wiki/Read_(system_call)

  6. write (system call) - Wikipedia. https://en.wikipedia.org/wiki/Write_(system_call)

  7. 理解linux中的file descriptor(檔案描述符) | Bigbyto. https://wiyi.org/linux-file-descriptor.html

  8. Linux 的 file descriptor 筆記 - Kakashi's Blog. https://kkc.github.io/2020/08/22/file-descriptor/

  9. linux /proc/[pid]/fd 中socket描述符後面的數字是什麼意思?inode(information node)_proc fd socket-CSDN部落格. https://blog.csdn.net/Dontla/article/details/124854177

Wei Ji

Galois Shader

這個組件在原本的專案有幾個神奇的事情:

  1. 使用 Bazel 封裝 Rust 程式碼,而不是標準 Rust 專案的 Cargo.toml
  2. 使用 Rust 當生成器,生成 Typescript。

"使用 Bazel 封裝"是什麼意思? BUILD.bazel 內容如下:

load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library")

package(default_visibility = ["//visibility:public"])

rust_library(
name = "shader_preprocessor",
srcs = ["preprocess.rs"],
deps = [
"@crate_index//:regex",
],
)

rust_library(
name = "materials",
srcs = ["materials.rs"],
deps = [
"@crate_index//:serde",
"@crate_index//:serde_json",
],
)

rust_library(
name = "shader_link_info",
srcs = ["link_info.rs"],
deps = [
"@crate_index//:glsl",
],
)

rust_binary(
name = "shader_gen_ts",
srcs = ["gen_ts.rs"],
deps = [
":materials",
":shader_link_info",
":shader_preprocessor",
"@crate_index//:clap",
"@crate_index//:convert_case",
"@crate_index//:glsl",
],
)

編譯結果是一個 CLI 的執行檔。

回過來說,這個組件主要的目的是:

  • 把 Shader 檔案轉成 Typescript 字串。
  • 提供可以 hot load 的 Three.js RawShaderMaterial
  • 懶得寫 Typescript,直接用生的(?)

Galois Viewer

Galois 的部份還在重構中,所以還沒上傳,不過當前進度大概如下:

目前是使用 PNPM 的 workspace/monorepo 來同時包容所有子組件。沒有使用單純的專案架構是因為不同子組件的性質不盡相同,例如:viewer 是 Electron app;server 是 process 封裝;editor 是另外一個 Electron app。

有些組件會作為 app 發布、有些則是作為套件被 import、有些涉及 React 、有些又沒有涉及 React,透過 workspace 分別設定每個組件的組態我覺得比較恰當。

剛剛提到 server 實際上會呼叫 CLI spawn process,而呼叫的對象在原始實做是 python py/assets/build.py,目前我是直接使用 pyinstaller 來打包 Python CLI 成獨立的執行檔。

並且目前的進度, viewer 在跟 server 的執行檔整合還有一點問題需要排除:

Wei Ji

Galois

Galois is a collection of tools for asset generation. Galois introduces a custom DSL for defining assets (e.g. meshes, textures, game objects, configuration files, maps, worlds), a build system for generating binary asset data, and tools for viewing and editing assets through a UI.

以上是 README 的原始描述。

遊戲素材

src/galois/data 目錄下是該專案大部分透過 Git LFS (Large File Storage) 儲存的資料所在的路徑,內容如下:

tree -L 1 src/galois/data
src/galois/data
├── animations
├── audio
├── blocks
├── color_palettes
├── crops
├── editor
├── flora
├── gaia
├── glass
├── icons
├── item_meshes
├── mapping
├── maps
├── npcs
├── placeables
├── textures
└── wearables

並且當中的結構與 biomes_data_snapshot.tar.gz 的內容大致吻合:

biomes_data_snapshot.tar.gz 是什麼呢?它是 Biomes 透過腳本在本地運行時,理論上應該被下載的檔案,但是官方的伺服器已經不再提供該檔案,GitHub 上有一些 issue 有反應這件事:

換言之,這個 biomes_data_snapshot.tar.gz 是檔案想接手這個開源專案的人第一個會碰到的問題(在不理解程式直接使用官方提供的腳本試圖運行該專案的話),而這個 biomes_data_snapshot.tar.gz 檔案的祕密就藏在 Galois 組件內。

info

打包用的腳本其實存在於 scripts/b/data_snapshot.py,不過我認為理解資料比輸出資料還重要。

素材瀏覽器與編輯器

src/galois/js 中可以看到 editorviewer

tree -L 1 src/galois/js
src/galois/js
├── assets
├── components
├── editor
├── index.d.ts
├── interface
├── lang
├── publish
├── python
├── README.md
├── scripts
├── server
├── tsconfig.json
└── viewer

根據程式碼判斷,它們應該是 Electron + React 實現的 GUI 工具。

這個組件的定位使其不涉及遊戲本身的實做,並且提供一些必要的封裝給遊戲開發使用,因此作為我的下一個解析目標。

AQL (Asset Query Language) utilities

這個模組在 Biomes 的原始路徑是:src/galois/js/lang, 並且當中缺少一些的 Typescript 程式碼是由 src/galois/py/assets 下的 Python 腳本生成的, 因為涉及跨語言的操作,而且沒有其他仰賴,因此我將其抽出作為獨立的套件。

"Asset Query Language" 只是根據內部實現的邏輯跟被呼叫的方式先隨便取的暫時性名稱,它被呼叫的時候大致上長這樣:

import * as l from "@/galois/lang";

const skeleton = l.toSkeleton(animationInfo.skeleton);
const tPose = l.ExtractInitialPose(animationsGltf, skeleton);
const posedVoxJointMap = l.ToPosedVoxJointMapFromVoxLayers(vox, skeleton);

let gltf = l.ToGLTF(
meshJointMap,
tPose,
l.ExtractAllAnimations(animationsGltf, skeleton)
);

不然 lang 並不是一個方便理解的名稱。

當前挑戰

Galois 的外部仰賴

首先, Galois 對 shared 有仰賴,這個部份沒有什麼問題,畢竟 shared 職責上就是處理那些同時會被前端與後端使用的通用邏輯。

但是 Galois 對 server 仰賴就有問題了,因為 server 是應用程式的後端實做,在素材瀏覽或編輯階段不應該對後端邏輯有所仰賴。

仔細看發現跟發布有關,或許可以將該部份暫時從 Galois 中移除,剩下的部份一小部份如果實做不是很多可以視情況直接從 server 移過來。

shared 的循環仰賴

因為 ecs 組件大部分的程式碼用 Python 生成的,可以的話我想拉出來變成單獨的套件,但是因為它跟 shared 之間有密切的耦合(循環仰賴),無奈之下只能用 monorepo 處理。

shared 的測試仰賴

shared 也有對 server 的仰賴,另外一個則是 voxeloo,但是它使用的是不明的實做:

import wasmLoader from "@/gen/shared/cpp_ext/voxeloo-simd/wasm";

return wasmLoader({
wasmBinary: await readFile(wasmFile),
wasmMemory: makeWasmMemory(1024),
});

之前我用 Emscripten 編譯出來的 Voxeloo WASM 本身並沒有符合上述 wasmLoader 界面的東西,Bazel 裡面我也看不出來這個東西是在哪裡實做的。

這個部份是寫在單元測試內的東西,不影響遊戲本體運作,因此我合理懷疑這個是專案經過重構後遺留的痕跡,某個時間點之後就不再維護單元測試而只注重遊戲運行正常與否,造成單元測試內的實作已經過期。