Skip to main content

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 裡面我也看不出來這個東西是在哪裡實做的。

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

Wei Ji

voxeloo WASM 綁定

Biomes 對 voxeloo 實做了兩種綁定/交叉編譯,將其從 C++ 專案轉換成其他語言的函式庫,其中一個是 Python,並且透過 pybind11 實現;另外一個則是 WASM 並且透過 Emscripten 實現。要注意的是編譯結果有分成一般跟 SIMD (Single Instruction Multiple Data) 兩種。

考量 Biomes 有自己分別在 client 和 server 端實做自己的 WASM 載入函式:

src/server/shared/voxeloo.ts:

export async function loadVoxeloo(): Promise<VoxelooModule> {
//...
}

src/client/game/webasm.ts:

export async function loadVoxeloo(
clientConfig: WasmConfig
): Promise<VoxelooModule> {
//...
}

我就不在 WASM binding 這一層做其他處理了,直接將 Emscripten 編譯的結果發布到 NPN 上。

順道一提,我有試著讓編譯過程執行在 Docker 內,但是並沒有成功,換句話說當前編譯過程可能仰賴我的 host 安裝的某些東西,但是現階段我不打算處理這個問題,等有機會再回來看要怎麼解決,確保「能成功編譯的環境」不會失傳。

cayley

cayley 看起來是另外一個運算吃重的模組,以 Rust 撰寫。

WASM 綁定

這裡遇到一個小插曲:

  • cayley 專案使用 Rust 1.70.0
    • WORKSPACE.bazel 內有寫: RUST_VERSION = "1.70.0"
  • 專案的 wasm-bindgen0.2.83
    • Cargo.lock 內有寫。
  • wasm-bindgen 要求 wasm-bindgen-cli 0.2.83
  • wasm-bindgen-cli 0.2.83 要求 Rust 1.82+
    • wasm-bindgen-cli 0.2.83 有一個仰賴要求 1.82+

我不知道這個相依性地獄 (dependency hell) 是怎麼產生的,也不打算深究,直接包進 Docker 裡面分階段在不同的 Rust 版本編譯。

Biomes 原本只有發布 bundlernodejs 兩種版本,後者是給 CommonJS 用的,前者則是直接 import WASM 檔案,需要專案有使用 Webpack 或是 Loader 之類的自動翻譯模組,因此我額外加上了 web 當作 ESM 的預設包,顧名思義是給瀏覽器用的。

wasm-bindgen-cli 工具蠻完整的,除了 WASM 載入器的 Javascript 有準備好,用來給 Typescript 的 .d.ts 也有生成,該說不愧是網頁前端友善(?)的 Rust 嗎?

Python 綁定

Python 這邊是使用 pyo3/maturin 的工具完成,並且這次我試著發布到 PyPI 去,有點意外 Rust 的部份也一併被上傳,從 Python 安裝的時候似乎會在本地跑編譯。

Wei Ji

前一陣子在 GitHub 上找到有興趣的專案,放到口袋清單之後,終於在上週 (2025-10-22) 忍不住把它拿出來玩了,這裡紀錄一下到目前為止 (2025-11-02) 的進度。

來自 Global Illumination, Inc. 的 Biomes

Biomes 是一個(理論上)能夠在瀏覽器執行的開源方塊沙盒多人連線角色扮演遊戲 (MMORPG)。

為什麼我說「理論上」呢?因為這個專案已經葛屁兩年了,在親眼看到以前說不準它的實際情況。

並且專案停滯的時間點剛好是 OpenAI 收購 Global Illumination (Biomes 背後的公司團隊) 的時間點1

動機

不熟悉我的讀者可能不理解我沒事幹麻去撿一個死兩年專案的屍體,這些是我曾經寫過相關的文章:

作為一個開源 Minecraft 愛好者(?),我一直都有在關注開源軟體圈中與 Minecraft 的發展,特別是物色一個適合的目標 fork 它改成我想要的樣子。

Biomes 乍看之下完成度很高:

而且是使用貼近 Web 的技術(Typescript, WASM...)實做,對我而言理解的門檻會低得多(比起 C++ 實做的 Minetest)。

專案的第一印象

該專案使用異構單體庫結構,也就是:

  • 單庫(Monorepo):使用一個 Git 庫存放所有模組與程式碼。
  • 異構(Polyglot):多個模組涉及不同的程式語言與技術棧(Techstack)。
  • 單體(Monolithic):所有程式碼會組裝成單一的 Web 服務。
info

「異構單體庫」並不是一個正式用語,但是我試著用中文組裝成一個最簡要能表達多個複雜的概念的詞彙。

並且透過 Bazel 這個工具來處理複雜的仰賴鏈,接著在外面套一層 Python 腳本,所有操作都需要透過 b.py 完成。

雖然試著用 Bazel 把專案跑起來,但是似乎是工具版本不符的原因,整個過程並不順利。加上個人不是很喜歡這種「全家桶」結構,因此下一步就是開始拆專案。

拆拆拆

一些比較大型模組會有自己獨立的 README.md

find . -name README.md
./README.md
./voxeloo/README.md
./docs/README.md
./src/client/README.md
./src/shared/README.md
./src/server/README.md
./src/server/bob/README.md
./src/server/shared/README.md
./src/benchmarks/README.md
./src/galois/README.md
./src/galois/js/README.md
./.githooks/README.md
./scripts/tsconfig-replace-paths/README.md
./.devcontainer/README.md

接著是找到外部仰賴最少,即仰賴鏈最上游的模組。

docs

第一個抽出的模組是網頁文件:

https://github.com/FlySkyPie/biomes-docs

它不仰賴別人的模組也沒有被仰賴,並且有自己獨立的 package.json,很容易抽出。

yarn 轉換成 pnpm、修復幽靈仰賴;並且處理的圖片都儲存在 git LFS 的問題之後就能正常運作了。

geometry.hpplight_kernel.hpp

接下來的兩個模組則是用 Python 生成 .hpp 函式庫:

其中一個還使用了 Jinja2 — 基於 Python 的樣板引擎/函式庫。

voxeloo

接著是名為 voxeloo 的組件,使用了前述程序化生成的 .hpp

在原本使用 Bazel 處理的仰賴鏈中,還包含了一些指向 GitHub 的外部函式庫,例如:catch2eigenrobin-hood-hashingzstd...。

在這方面我使用 CPM.cmake 來處理仰賴關係,只要目標有撰寫良好的 CMakeLists.txt,就可以把 C++ 專案當成套件拉進專案使用。

第三方函式庫

OpenSimplexNoise 原本的 Git Repo 沒有 CMakeLists.txt,所以我 fork 之後加上 CMakeLists.txt

opttritri.htribox3.h 這兩個檔案原本是直接放在專案內的,我就稍微朔源了一下然後一樣加上 CMakeLists.txt

voxeloo Python 綁定

Biomes 對 voxeloo 實做了兩種綁定/交叉編譯,將其從 C++ 專案轉換成其他語言的函式庫,其中一個是 Python,並且透過 pybind11 實現。

我使用 scikit-build 作為 Python 的建置後端 (Build Backend) 將奇打包成一個 Python 庫 (Python Wheel),並且補上型別資訊 (Stub)。

Footnotes

  1. OpenAI收購AI設計公司Global Illumination | iThome. Retrieved 2025-10-06 from https://www.ithome.com.tw/news/158307

Wei Ji

我曾經有一個同事,是動畫師之類的,雖然不同部門、不算熟、也沒什麼深入聊過,但是我認為我從他身上學到一個很重要的概念—「演出」。

他經常會用「演出」來形容一段角色動畫,也經常在做動畫前先自己用肢體表現一次,呈現多種角色動作的形式。當下我其實沒有太多想法,直到後來我才隱約知道他說的「演出」是什麼意思。

這是當下我知道的「演出」:

後來我看到了:

這是透過動作捕捉器,直接將人類演員的肢體動作錄製成能在遊戲內使用的動畫影格,動作更自然也不用讓動畫師手工調整。

接著我看到了:

很多短影音往往包含了有趣的上下文與故事,但是仔細觀察會發現這個影片中的角色的動作跟前一個動作捕捉演員有著接近的神韻:一些明顯且誇大的神情或肢體「演出」。


岔開一下話題,這是一個我很享受的 Factorio 影片:

然後這是那個 Factorio 影片的幕後花絮:

可以發現影片的整體節奏並不像正片一樣緊湊,作者的情緒起伏也沒這麼浮誇。

作者也表示,劇本是模仿 3Blue1Brown 的緒事風格「事情可能怎麼被發現」,而刻意營造出「他自己發現」的氛圍。


簡單來說,「演出」一種精細設計的虛假呈現;是一種謊言,如果任何戲劇作品出現不現實的情節,那十之八九是一種「演出」。