每次要把橫幅風景照發 Instagram,都得打開 Lightroom 手動裁切——調整裁切框、對齊、最後再匯出。於是我就寫了 tilesplit,一個用 Rust 打造的小工具,專門在分割圖片的同時保留 Ultra HDR 資訊。這篇文章來聊聊 Ultra HDR 是什麼,以及這個工具背後的技術細節。
為什麼需要 TileSplit?
我最主要的使用情境是把 3:2 的橫幅風景照發到 Instagram 上。
雖然 Instagram 現在允許以原始比例上傳照片,但橫幅照片在動態牆上顯示得比直幅照片小很多,視覺衝擊力大打折扣。所以現在 IG 很流行「無縫滑動」的發法:把一張橫幅照片切成兩張(或更多)直幅圖,使用者左右滑的時候會有連續的視覺體驗,同時每張圖在動態牆上都能佔滿版面。但如果這張照片是 Ultra HDR 格式,用一般工具切完,HDR 就沒了——原本在 HDR 螢幕上那種亮眼的層次感,瞬間變得平淡。
原因很簡單:大多數工具在處理圖片時,根本不知道 Gain Map 的存在。它們只保留了 SDR 的部分,Gain Map 直接被丟掉了。
什麼是 Ultra HDR?
Ultra HDR 是 Google 在 Android 14 引入的圖片格式,本質上還是 JPEG,但多藏了一些東西。它最厲害的地方在於向後兼容。
傳統的 HDR 圖片(像 10-bit HEIF 或 AVIF)在舊手機或不支援 HDR 的螢幕上,常常會顏色怪怪的或是過暗。Ultra HDR 用了一個很聰明的方法解決這個問題:
- SDR 基礎圖片:本質上就是一張普通的 JPEG,任何軟體都能打開、正常顯示。
- Gain Map(增益圖):在 JPEG 裡面偷偷塞了一張「變亮地圖」——告訴系統每個區域可以變多亮。
- HDR 重建:在支援 HDR 的螢幕上,系統把 SDR 圖片和 Gain Map 合在一起算,就能還原出更亮的高光和更豐富的細節。
同一張圖片,舊手機上正常顯示,新手機上閃閃發光。
TileSplit 是如何工作的?
核心目標很單純:切圖的同時,把 Ultra HDR 的資料完整保留下來。整個流程大概分四步:
1. 偵測與解析
程式先用 jpegli 去讀輸入檔案,檢查 JPEG 裡有沒有 XMP Metadata 和 Gain Map。如果 jpegli 讀不出來(不同手機產生的 Ultra HDR 格式會有差異),就退回去用 Google 原生的 ultrahdr 解碼器做更深入的解析。
2. 雙層解碼
確認是 Ultra HDR 之後,程式會把圖片拆成兩層:SDR 主圖(標準 RGB 像素)和 Gain Map(亮度增益資料)。同時提取 Metadata(像 hdrgm:GainMapMin、hdrgm:Gamma 等),這些參數決定了 SDR 和 Gain Map 怎麼合成 HDR。
來看看程式碼長什麼樣子(省略了 debug logging):
fn try_extract_ultrahdr_with_jpegli(
source_bytes: &[u8],
debug: bool,
) -> Option<(ultrahdr::GainMapMetadata, RawImage, GainMap, Option<Vec<u8>>)> {
// 用 catch_unwind 保護 jpegli 解碼——某些損壞的 JPEG 會造成 panic
let decoded = match catch_unwind_quiet(AssertUnwindSafe(|| {
JpegDecoder::new()
.preserve(PreserveConfig::default())
.output_format(JpegPixelFormat::Rgb)
.decode(source_bytes)
})) {
Ok(Ok(image)) => image,
_ => return None,
};
// 從 JPEG extras 裡挖出 XMP metadata 和 Gain Map
let extras = decoded.extras()?;
let xmp = extras.xmp()?;
let (metadata, _) = ultrahdr::metadata::xmp::parse_xmp(xmp).ok()?;
let gainmap = match extras.gainmap() {
Some(gainmap) => gainmap.to_vec(),
// extras 裡找不到的話,試試 MPF(Multi-Picture Format)
None => extract_gainmap_jpeg_from_mpf(source_bytes, debug)?,
};
// 有些手機的 XMP metadata 不完整,嘗試從 Gain Map 本身的 XMP 補齊
let metadata = if metadata_looks_default_or_incomplete(&metadata) {
extract_metadata_from_gainmap_xmp(&gainmap, debug).unwrap_or(metadata)
} else {
metadata
};
let icc_profile = extras.icc_profile().map(|icc| icc.to_vec());
// 把像素資料包成 RawImage 結構
let sdr = RawImage::from_data(
decoded.width, decoded.height,
PixelFormat::Rgb8, ColorGamut::Bt709, ColorTransfer::Srgb,
decoded.data,
).ok()?;
// Gain Map 的 JPEG 也需要解碼成像素資料
let gainmap = decode_gainmap_jpeg(&gainmap, ColorGamut::Bt709).ok()?;
Some((metadata, sdr, gainmap, icc_profile))
}
幾個值得注意的設計:
catch_unwind_quiet:jpegli 是 C 庫的封裝,遇到格式損壞可能會 panic,用 catch_unwind 接住避免整個程式崩潰
- MPF fallback:有些手機(特別是早期支援 Ultra HDR 的機型)把 Gain Map 存在 JPEG 的 Multi-Picture Format 區段而非 Extras 裡,需要額外處理
- Metadata 補齊:某些裝置產生的 XMP metadata 不完整,程式會嘗試從 Gain Map 自身的 XMP 中提取更完整的參數
3. 幾何映射與分割
根據使用者要的比例(像 4:5 for Instagram),程式算出 SDR 主圖的裁剪區域。
這裡有個坑:Gain Map 的解析度通常比主圖小(常見是 1/4 大小),所以不能直接拿主圖的座標去切 Gain Map。需要根據兩者的比例做精確映射,不然切出來的 HDR 重建會位置偏移,高光對不準。
fn map_rect_to_gainmap(
rect: Rect,
source_width: u32,
source_height: u32,
gainmap_width: u32,
gainmap_height: u32,
) -> Rect {
// 用整數運算避免浮點誤差
let x0 = (rect.x as u64 * gainmap_width as u64 / source_width as u64) as u32;
let y0 = (rect.y as u64 * gainmap_height as u64 / source_height as u64) as u32;
let right_edge = rect.x.saturating_add(rect.width);
let bottom_edge = rect.y.saturating_add(rect.height);
// 右下角用 ceiling division,確保不會少切到像素
let mut x1 = div_ceil_u64(
right_edge as u64 * gainmap_width as u64,
source_width as u64,
) as u32;
let mut y1 = div_ceil_u64(
bottom_edge as u64 * gainmap_height as u64,
source_height as u64,
) as u32;
// 邊界保護
x1 = x1.clamp(0, gainmap_width);
y1 = y1.clamp(0, gainmap_height);
if x1 <= x0 {
x1 = (x0 + 1).min(gainmap_width);
}
if y1 <= y0 {
y1 = (y0 + 1).min(gainmap_height);
}
Rect {
x: x0,
y: y0,
width: x1.saturating_sub(x0),
height: y1.saturating_sub(y0),
}
}
4. 編碼與重組
最後一步是把切好的東西重新包裝成合法的 Ultra HDR JPEG。流程是:
- 把裁剪後的 Gain Map 編碼成獨立的 JPEG,並嵌入 XMP metadata
- 用
jpegli 編碼 SDR 主圖,保留 ICC Color Profile
- 手動組裝 Ultra HDR 容器:插入 XMP APP1 和 MPF APP2,再接上 Gain Map JPEG
為什麼不用 jpegli 的 .add_gainmap() 一行搞定?因為它生成的 MPF 偏移量是錯的(用了絕對偏移而非 MPF 規格要求的相對偏移),導致 viewer 找不到 Gain Map,HDR 就失效了。所以得自己手動組裝容器。
fn encode_ultrahdr_tile(
sdr_tile: RawImage,
gainmap_tile: GainMap,
metadata: &ultrahdr::GainMapMetadata,
source_icc_profile: Option<&[u8]>,
) -> Result<Vec<u8>, i32> {
// 1. Gain Map 編碼成 JPEG,同時嵌入 XMP metadata
let gainmap_jpeg = encode_gainmap_jpeg(&gainmap_tile, metadata)?;
// 2. 編碼 SDR 主圖(不含 Gain Map,容器組裝交給 assemble_ultrahdr_jpeg)
let mut config = EncoderConfig::ycbcr(SDR_TILE_JPEG_QUALITY, ChromaSubsampling::None);
if let Some(icc_profile) = source_icc_profile
&& !icc_profile.is_empty()
{
config = config.icc_profile(icc_profile.to_vec());
}
let (pixel_layout, pixel_data) = match sdr_tile.format {
PixelFormat::Rgb8 => (PixelLayout::Rgb8Srgb, Cow::Borrowed(&sdr_tile.data)),
PixelFormat::Rgba8 => { /* RGBA → RGB 轉換 */ }
_ => return Err(EXIT_IO_ERROR),
};
let sdr_jpeg = /* jpegli encode */;
// 3. 手動組裝 Ultra HDR 容器
assemble_ultrahdr_jpeg(&sdr_jpeg, &gainmap_jpeg, metadata)
}
assemble_ultrahdr_jpeg 做的事:把 XMP APP1 和 MPF APP2 插到主圖裡,再把 Gain Map JPEG 接在主圖後面。MPF header 裡的偏移量必須相對於 TIFF header(mpf_marker_offset + 8),這個細節後面的「踩坑」章節會詳細說明。
這樣輸出的每一張切片都是完整的 Ultra HDR 檔案,可以在支援的設備上獨立顯示 HDR 效果。
關於重新編碼與畫質
你可能會問:「重新編碼會不會讓照片變糟?」
會。但可以把損失降到很低。
JPEG 每次重新編碼都會因為 DCT 量化而失去一些細節。為了盡量保住畫質,tilesplit 做了幾件事:
- 用 Jpegli 編碼器:Google 開源的
jpegli 在同樣檔案大小下,畫質比傳統 libjpeg 好不少
- 高畫質設定:預設 Quality 100,在 Instagram 上基本看不出和原圖的差異
- 避免不必要的轉換:直接操作解碼後的原始像素,不做多餘的格式轉換
技術上不是「絕對無損」——要做到無損得用 jpegtran 那種直接操作 DCT 係數的方式,但那要同時處理 Ultra HDR 的 Metadata 封裝會極其困難。實際用起來,tilesplit 產出的畫質對攝影愛好者來說完全夠用。
關鍵相依庫
tilesplit 主要靠這三個 Rust crate:
image:Rust 生態系最常用的圖像處理庫,負責基礎的裁剪、格式轉換和非 HDR 圖片的處理。
jpegli-rs:Google jpegli 的 Rust 封裝,壓縮率比 libjpeg 好,而且支援 JPEG Extras——可以讀寫嵌在 JPEG 裡的 XMP Metadata 和 Gain Map。
ultrahdr-rs:Google libultrahdr 的封裝,包含 Ultra HDR 的核心邏輯(Metadata 解析、SDR/HDR 轉換公式等)。遇到格式比較特殊的圖片時,靠它來兜底。它的純 Rust 子 crate ultrahdr-core 也用來生成 XMP APP1 marker。
為什麼選擇 Rust?
Rust 剛好適合這種場景:記憶體安全(不用擔心 segfault)、執行效率接近 C++、而且 FFI 很方便——libultrahdr 和 jpegli 都是 C/C++ 的庫,Rust 可以直接呼叫再包一層安全介面。加上 Result 型別強制處理所有錯誤情況,遇到檔案損壞或 metadata 缺失,不會直接 crash,而是給出有意義的錯誤訊息。
安裝與使用
如果你也有類似需求,可以直接從 GitHub 安裝:
cargo install --git https://github.com/p47t/rust-52-projects --branch tilesplit
用法很簡單:
# 基本用法,自動生成 photo-left.jpg 和 photo-right.jpg
tilesplit --input photo.jpg
# 指定輸出路徑
tilesplit --input photo.jpg --left-output left.jpg --right-output right.jpg
原始碼在 rust-52-projects/tilesplit 上。
踩過的坑:亮度為什麼不對?
做完初版後,切出來的圖在 HDR 螢幕上看起來比原圖暗很多。花了不少時間 debug 才搞清楚,「把 Gain Map 塞進去」跟「讓 viewer 正確解讀」是兩回事。以下是踩過的幾個主要坑:
MPF 偏移量的基準點
Ultra HDR JPEG 是兩張圖接在一起——主圖和 Gain Map。MPF (Multi-Picture Format) APP2 marker 裡有個 TIFF IFD 結構,記錄 Gain Map 的位置。關鍵是:偏移量是相對於 MPF 裡的 TIFF header,不是檔案開頭。
MPF APP2 的二進制結構:
FF E2 ← marker (2 bytes)
xx xx ← length (2 bytes)
4D 50 46 00 ← "MPF\0" (4 bytes)
4D 4D 00 2A ← TIFF header ← 偏移量從這裡算起
TIFF header 在 MPF marker 起始位置 +8 bytes 的地方。一開始用了 jpegli 的 .add_gainmap() API,發現它寫入的是絕對偏移而非相對偏移,viewer 根本找不到 Gain Map。改成手動組裝容器才修好。
主圖和 Gain Map 都要有 XMP
原本以為只要主圖的 XMP 有完整參數就行。結果拿 Lightroom 匯出的 Ultra HDR 對比——主圖的 XMP 只有 hdrgm:Version="1.0",完整的 GainMapMin、GainMapMax、Gamma 等參數全在 Gain Map JPEG 自己的 XMP 裡。
這表示很多 viewer(包括 Android 的 Photos 和 Chrome)是優先從 Gain Map 的 XMP 讀取參數的。Gain Map JPEG 如果沒嵌入 XMP,viewer 可能拿不到正確的 boost 參數,畫面就是暗的。
rdf:Seq 才是標準格式
Gain Map 參數是 per-channel 的(RGB 各通道可以不同),在 XMP 裡有兩種表達方式:
<!-- 逗號分隔(有些 viewer 不認) -->
<rdf:Description hdrgm:GainMapMax="-0.699, -0.615, -0.603" />
<!-- rdf:Seq(標準,所有 viewer 都認) -->
<hdrgm:GainMapMax>
<rdf:Seq>
<rdf:li>-0.699220</rdf:li>
<rdf:li>-0.614892</rdf:li>
<rdf:li>-0.603440</rdf:li>
</rdf:Seq>
</hdrgm:GainMapMax>
ultrahdr-rs crate 的 generate_xmp() 用的是逗號分隔格式,實測某些 viewer 會 fallback 到預設值。改成 rdf:Seq 格式後就正常了。
Gain Map 品質不能省
Gain Map 每個像素代表 HDR boost 強度,套用公式大致是 boost = max_boost^(pixel/255)。因為是指數關係,JPEG 壓縮的量化誤差會被指數放大——pixel 值差個 5,亮度可能差 5% 以上。所以 Gain Map 一律用 quality 100 編碼,不管使用者設的 SDR 品質是多少。
教訓
Ultra HDR 的正確性散落在 MPF 規格、XMP schema、和各家 viewer 的實作細節裡。光看 ISO 21496-1 不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出做二進制比對,才能確認哪些欄位是必要的、viewer 怎麼解析。
結語
HDR 螢幕越來越普及,Ultra HDR 這種格式以後只會更常見。現有的圖像處理工具大多還沒跟上,tilesplit 算是填補了這個小空白。如果你也有保留 HDR 切圖的需求,希望這個工具能幫到你。
最近在看 builder-derive 這個小專案時,會再次被 Rust 的 procedural macro 驚艷到:
你在程式碼裡只寫一行 #[derive(Builder)],編譯器就幫你生出一整套 Builder API。
但它到底怎麼做到的?
這篇就用 builder-derive 的實作,從 TokenStream 一路追到實際生成的程式碼,拆解 Rust proc macro 的完整流程。
先看使用端:我們到底得到了什麼
先看最終使用方式:
use builder_derive::Builder;
#[derive(Builder, Debug)]
struct User {
username: String,
email: String,
age: Option<u32>,
tags: Vec<String>,
}
let user = User::builder()
.username("alice".to_string())
.email("[email protected]".to_string())
.age(30)
.build()?;
這裡我們沒有手寫 UserBuilder,也沒有手寫 setter、build()。
#[derive(Builder)] 在編譯期把這些東西全部產生出來。
Proc Macro 的核心心法:編譯期程式碼產生器
很多人第一次接觸 macro 會覺得它像「文字替換」。
Rust 的 procedural macro 比這個更嚴謹:
- 編譯器把你標註
derive 的語法轉成 TokenStream
- macro crate 用
syn 把 token parse 成 AST(語法樹)
- 你在 AST 上做檢查與分析
- 用
quote 產生新的 Rust tokens 丟回編譯器
- 編譯器把這段新程式碼當成真的程式去編譯
builder-derive 的模組切分很清楚:
src/lib.rs:入口
src/parse.rs:輸入合法性檢查
src/field.rs:欄位型別分析
src/generate.rs:程式碼生成
第 1 步:入口函式(lib.rs)
builder-derive 的入口非常典型:
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match generate::impl_builder(&input) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
這段有三個重點:
#[proc_macro_derive(Builder)] 把這個函式註冊成 derive macro。
parse_macro_input! 把原始 token 解析成 syn::DeriveInput。
- 發生錯誤時用
to_compile_error() 轉成編譯錯誤,而不是 panic。
也就是說,macro 的錯誤訊息其實是你主動設計出來的 UX。
第 2 步:先擋掉不支援的型別(parse.rs)
在 parse.rs 裡,專案先做輸入驗證:
- 只接受「具名欄位 struct」
- 拒絕 enum / union / tuple struct / unit struct
這個設計超務實。因為 Builder 的生成邏輯依賴欄位名稱,沒有欄位名就沒辦法安全產生 setter。
像這段 compile-fail 測試會得到清楚錯誤:
#[derive(Builder)]
enum MyEnum {
Variant1,
Variant2,
}
錯誤訊息是:
Builder can only be derived for structs, not enums
builder-derive 這裡做得很對:把「不可能支援」的 case 儘早在編譯期擋下來,使用者不用等到 runtime 才踩雷。
第 3 步:欄位分析(field.rs)
接著是整個 macro 的關鍵資料結構 FieldInfo,每個欄位會被分析出:
- 欄位名稱
name
- 原始型別
ty
- 是否
Option<T>
Option<T> 的內層型別 T
- 是否
Vec<T>
怎麼判斷 Option<T>?
它用 syn::Type pattern matching:
- 先確認是
Type::Path
- 抓路徑最後一段(例如
Option)
- 看識別字是不是
Option
- 若是,從 angle-bracket 參數拿出
T
這是 proc macro 最常見也最實用的技巧:
不要自己 parse 字串;直接操作 AST。
syn 到底在做什麼?
可以把 syn 想成「Rust 語法的解析器 + AST 資料模型」。
在 builder-derive 裡,syn 主要做三件事:
- 把
TokenStream 轉成 DeriveInput(看到 struct 名稱、可見性、欄位)
- 用 enum pattern matching 檢查資料型別(
Data::Struct / Fields::Named)
- 深入欄位型別結構(
Type::Path -> PathSegment -> PathArguments)判斷 Option<T>、Vec<T>
為什麼這很重要?因為 procedural macro 要的是「語法結構」,不是字串比對。
舉例來說,Option<String>、std::option::Option<String> 在字串上看起來不同,但在 AST 層都能被一致處理。這就是 syn 在 macro 裡的核心價值。
另外像 syn::Error::new_spanned(...) 也很關鍵:它可以把錯誤綁在原始程式碼 span 上,讓編譯錯誤直接指到真正寫錯的那一行。
這個專案一個值得注意的設計
對 Option<T> 欄位,setter 參數型別是 T,不是 Option<T>。
也就是你寫:
而不是:
這讓 API 更順手。不過 tradeoff 是:目前 API 不能顯式設定 None,只能「不呼叫 setter」來得到 None。
第 4 步:用 quote 生成程式碼(generate.rs)
generate.rs 裡把生成工作拆成四塊,這個切法很值得學:
generate_builder_struct
generate_builder_constructor
generate_setter_methods
generate_build_method
quote! 怎麼把 AST 變回 Rust 程式?
quote 的角色是「把分析結果重新組裝成 token」。
在這個專案裡,最常用的語法有兩個:
#var:把變數插進模板
#(...)*:把 iterator 產生的多段程式碼展開
例如:
let builder_fields = field_infos.iter().map(|field| {
let name = &field.name;
let builder_ty = field.builder_field_type();
quote! { #name: #builder_ty }
});
quote! {
struct #builder_name {
#(#builder_fields,)*
}
}
這段可以讀成:「每個欄位先變成一小段 token,最後用 repetition 一次展開成完整 struct 欄位列表。」
也因為這種寫法,macro 可以維持很強的可組合性:
- 每個生成函式只負責一種片段
- 最後在
impl_builder 把片段拼回完整輸出
對大型 macro 專案來說,這比單一巨大 quote! 區塊更容易維護。
4.1 生成 Builder struct
每個 builder 欄位都用 Option<...> 包起來,目的是追蹤「這個欄位有沒有被設定」。
4.2 生成 builder()
在原始 struct 上加:
impl User {
pub fn builder() -> UserBuilder { ... }
}
所有欄位初始化成 None。
4.3 生成 setter(可 chain)
每個 setter 都是這個形狀:
pub fn field(mut self, value: T) -> Self {
self.field = Some(value);
self
}
self by value + 回傳 Self,就是 fluent API 的來源。
4.4 生成 build(),而且不同欄位策略不同
builder-derive 在這裡把欄位分三類處理:
Option<T>:直接 passthrough(沒設就 None)
Vec<T>:沒設就 unwrap_or_default(),變空陣列
- 其他欄位:必填,沒設就回
Err("field is required")
這個策略很實用,因為 Vec<T> 在很多 config 型別裡確實適合預設空集合。
展開後大概長怎樣?
以這個輸入:
#[derive(Builder)]
struct Config {
host: String,
port: u16,
timeout: Option<u64>,
features: Vec<String>,
}
macro 會產生近似這樣的程式碼(簡化版):
struct ConfigBuilder {
host: Option<String>,
port: Option<u16>,
timeout: Option<u64>,
features: Option<Vec<String>>,
}
impl Config {
fn builder() -> ConfigBuilder {
ConfigBuilder {
host: None,
port: None,
timeout: None,
features: None,
}
}
}
impl ConfigBuilder {
fn host(mut self, value: String) -> Self {
self.host = Some(value);
self
}
fn port(mut self, value: u16) -> Self {
self.port = Some(value);
self
}
fn timeout(mut self, value: u64) -> Self {
self.timeout = Some(value);
self
}
fn features(mut self, value: Vec<String>) -> Self {
self.features = Some(value);
self
}
fn build(self) -> Result<Config, String> {
Ok(Config {
host: self.host.ok_or_else(|| "host is required".to_string())?,
port: self.port.ok_or_else(|| "port is required".to_string())?,
timeout: self.timeout,
features: self.features.unwrap_or_default(),
})
}
}
重點是:這些都發生在編譯期,你 runtime 不需要額外 macro 成本。
錯誤處理:compile-time 跟 runtime 分工
這個專案的錯誤策略很清楚:
Compile-time(macro 階段)
- 型別不支援就直接編譯失敗
- 錯誤訊息會標到對應程式碼位置(透過
syn::Error::new_spanned)
Runtime(build 階段)
- 缺必要欄位時,
build() 回 Result::Err(String)
這種分工很合理:
- 能在語法層判斷的,就盡量提早失敗
- 必須等使用者呼叫流程才能判斷的,就用
Result 回報
為什麼這個範例很適合學 proc macro
builder-derive 的好處是它「剛好夠真實,但不會太重」:
- 有完整 pipeline(parse -> analyze -> generate)
- 有 compile-fail test(
trybuild)
- 有 integration tests 驗證行為
- 邏輯分層乾淨,不會全部擠在一個檔案
如果正要開始學 proc macro,建議順序是:
- 先看
src/lib.rs 入口
- 再看
parse.rs 怎麼做輸入保護
- 再看
field.rs 怎麼做型別判斷
- 最後看
generate.rs 的 quote! 拼裝
這樣你比較不會一開始就被 quote! 的 token interpolation 淹沒。
目前限制與下一步
以目前實作來看,還有幾個可以進化的方向:
- 支援 generics(例如
struct Foo<T>)
- 支援
#[builder(...)] 欄位屬性(default、setter(into)、skip)
- 更結構化的錯誤型別(取代
String)
- 允許 optional setter 傳
Option<T>(可顯式設 None)
但以「教學用、理解 proc macro 原理」來說,現在這個版本其實已經很漂亮了。
結語
Rust 的 procedural macro 真正厲害的地方,不是語法炫技,而是把重複樣板在「編譯期」變成可驗證、可維護的程式碼生成流程。
builder-derive 這個專案剛好把這件事示範得很清楚:
- 用
syn 看懂你的 Rust 程式
- 用
quote 生成你本來不想手寫的 boilerplate
- 用型別檢查和測試把 macro 行為收斂到可預期
下次看到 #[derive(...)],你可以把它想成:
「這不是魔法,是一個在編譯器裡跑的小型 code generator。」
專案連結:rust-52-projects/builder-derive
看了 wasm-pack build --target web 產生的 pkg/ 目錄,搞懂了 JS 膠水程式碼的角色。
wasm-pack 輸出四個檔案:
| 檔案 |
用途 |
.wasm |
編譯後的 WASM 二進位 |
.js |
JS 膠水程式碼(runtime 必須) |
.d.ts |
TypeScript 型別宣告(純靜態,runtime 不用) |
_bg.wasm.d.ts |
原始 WASM exports 的型別宣告 |
.d.ts 不參與執行——它們只給 TypeScript 編譯器做型別檢查用。整個流程不涉及 TypeScript 編譯。
膠水程式碼做兩件事:
1. 載入 WASM:init() 用 WebAssembly.instantiateStreaming 串流編譯 .wasm 檔,邊下載邊編譯。
2. 雙向橋接 WASM 和瀏覽器 API:
-
JS → WASM:處理型別轉換。WASM 只懂數字,所以字串要先編碼成 UTF-8 寫進 WASM 線性記憶體,再傳指標和長度:
export function start(canvas_id, grid_width, grid_height) {
const ptr0 = passStringToWasm0(canvas_id, wasm.__wbindgen_malloc, ...);
const len0 = WASM_VECTOR_LEN;
return wasm.start(ptr0, len0, grid_width, grid_height);
}
-
WASM → JS:web_sys 的每個呼叫都對應一個 JS import 函式。以 WebGPU 的 device.createBuffer() 為例,Rust 端呼叫 web_sys → WASM 呼叫 import → JS 膠水程式碼呼叫真正的瀏覽器 API:
__wbg_createBuffer_fb1752eab5cb2a7f: function(arg0, arg1) {
const ret = arg0.createBuffer(arg1);
return ret;
}
在 wgpu Game of Life 專案裡,膠水程式碼包含大約 200 個這樣的橋接函式,全部由 wasm-bindgen 在建置時根據 #[wasm_bindgen] 和 web_sys 的使用自動生成。
在做 wgpu Game of Life 時發現一件事:WASM 環境下的 Rust async 不需要 Tokio 或 async-std——瀏覽器的 event loop 就是 runtime。
在 native Rust,你需要一個 executor 來 poll futures。但在 WASM,wasm-bindgen-futures 把 Rust 的 Future 轉換成 JavaScript 的 Promise,交給瀏覽器的 event loop 來驅動:
#[wasm_bindgen]
pub async fn start(canvas_id: &str) {
// 每個 .await 都是把控制權交還給瀏覽器的 event loop
let adapter = instance.request_adapter(&options).await;
let (device, queue) = adapter.request_device(&desc, None).await;
}
JavaScript 端看到的就是一個回傳 Promise 的函式:
await wasm.start("canvas");
每次 .await 時,Rust future 暫停執行,控制權回到瀏覽器。當底層的 JS 操作(例如 WebGPU 的 requestAdapter)完成時,瀏覽器透過 microtask 觸發 Rust 的 waker,從暫停處繼續執行。
|
Native |
WASM |
| Runtime |
Tokio / async-std |
瀏覽器 event loop |
| Executor |
Rust 端的 thread pool |
JS microtask queue |
| Spawn |
tokio::spawn(多執行緒) |
spawn_local(單執行緒) |
所以 Cargo.toml 只需要 wasm-bindgen-futures,不需要任何 Rust async runtime。
這個專案把 Conway’s Game of Life 搬到 GPU 上面跑——用 Rust 的 wgpu 寫 WebGPU compute shader,編譯成 WASM 在瀏覽器裡執行。128x128 的網格、上萬個細胞的模擬,全部在 GPU 上平行計算。
專案原始碼:wgpu-game-of-life
先玩再說
操作方式:Play/Pause 開始模擬,點擊畫布可以畫細胞,Speed 調整速度。需要 WebGPU 支援的瀏覽器(Chrome 113+、Edge 113+、Firefox 141+)。
顏色代表細胞年齡:綠色是新生的,黃色是年輕的,橘色是成熟的,白色是古老的 still life 結構。
為什麼做這個?
在完成 WASM Markdown 編輯器之後,我想更深入探索 WASM 的可能性。Markdown 編輯器純粹是 CPU 計算,但現代瀏覽器已經支援 WebGPU——可以直接存取 GPU 的算力。
Game of Life 是個完美的入門專案:
- 天然適合平行計算:每個細胞的下一代只取決於鄰居,可以完全並行
- 需要 compute shader + render pipeline:同時學兩種 GPU 程式設計模式
- 視覺回饋即時:寫完馬上看到結果
- 規模剛好:不會太大,但足以理解 GPU 程式設計的核心概念
技術架構
整體流程
核心是 ping-pong 雙緩衝:兩個 storage buffer 交替讀寫。每一步模擬時,compute shader 從一個 buffer 讀取當前狀態,計算下一代寫入另一個 buffer,然後交換。Render pipeline 負責把結果畫到畫面上。
專案結構
wgpu-game-of-life/
├── Cargo.toml
├── src/
│ ├── lib.rs # WASM 進入點,匯出 API
│ ├── gpu.rs # wgpu 初始化、pipeline 建立、模擬邏輯
│ ├── compute.wgsl # Compute shader(Game of Life 規則)
│ └── render.wgsl # Vertex + Fragment shader(網格視覺化)
├── index.html
└── www/
├── index.js # 控制邏輯、動畫迴圈、滑鼠互動
└── styles.css
Rust 依賴項
[dependencies]
wgpu = "24" # WebGPU API
wasm-bindgen = "0.2" # JS 互操作
wasm-bindgen-futures = "0.4" # async 支援(wgpu 初始化是 async 的)
web-sys = { version = "0.3", features = ["Document", "Window", "Element", "HtmlCanvasElement", "console"] }
console_error_panic_hook = "0.1"
js-sys = "0.3"
bytemuck = { version = "1", features = ["derive"] }
跟 Markdown 編輯器比,多了 wgpu(核心)、wasm-bindgen-futures(因為 GPU 初始化是非同步的)和 bytemuck(安全地把 Rust 資料轉成 GPU buffer 需要的位元組)。
Compute Shader:Game of Life 規則
這是整個專案最核心的部分——用 WGSL 寫的 compute shader:
@group(0) @binding(0) var<uniform> grid: vec2u;
@group(0) @binding(1) var<storage, read> cells_in: array<u32>;
@group(0) @binding(2) var<storage, read_write> cells_out: array<u32>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= grid.x || id.y >= grid.y) { return; }
// 數 8 個鄰居(環形邊界)
var neighbors: u32 = 0u;
for (var dy: i32 = -1; dy <= 1; dy++) {
for (var dx: i32 = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) { continue; }
let nx = u32((i32(id.x) + dx + i32(grid.x)) % i32(grid.x));
let ny = u32((i32(id.y) + dy + i32(grid.y)) % i32(grid.y));
neighbors += select(0u, 1u, cells_in[cell_index(nx, ny)] > 0u);
}
}
let idx = cell_index(id.x, id.y);
let age = cells_in[idx];
// Conway's rules + 年齡追蹤
if (neighbors == 3u && age == 0u) {
cells_out[idx] = 1u; // 誕生
} else if (age > 0u && (neighbors == 2u || neighbors == 3u)) {
cells_out[idx] = min(age + 1u, 255u); // 存活,年齡 +1
} else {
cells_out[idx] = 0u; // 死亡
}
}
幾個重點:
@workgroup_size(8, 8):每個工作群組處理 8x8 = 64 個細胞,GPU 會自動分配到各個核心
cells_in 是唯讀,cells_out 是可寫:避免讀寫衝突,這就是為什麼需要兩個 buffer
- 環形邊界(toroidal wrapping):左邊超出會接到右邊,上面超出接到下面
- 年齡追蹤:不只是 0/1,而是記錄細胞存活了幾代(上限 255)
128x128 的網格需要 dispatch ceil(128/8) × ceil(128/8) = 16 × 16 = 256 個工作群組,每個群組 64 個執行緒,總共 16,384 個 GPU 執行緒平行計算。
Render Shader:年齡上色
Fragment shader 根據年齡把細胞染成不同顏色:
fn age_color(age: u32) -> vec4f {
if (age == 0u) {
return vec4f(0.06, 0.06, 0.12, 1.0); // 死亡:深色背景
}
let t = clamp(f32(age - 1u) / 50.0, 0.0, 1.0);
// 顏色漸層:亮綠 → 黃綠 → 橘 → 暖白
let c0 = vec3f(0.15, 0.90, 0.30); // 新生
let c1 = vec3f(0.80, 0.90, 0.15); // 年輕
let c2 = vec3f(0.95, 0.60, 0.10); // 成熟
let c3 = vec3f(1.00, 0.85, 0.70); // 古老
// 三段線性插值
if (t < 0.33) { return mix(c0, c1, t / 0.33); }
else if (t < 0.66) { return mix(c1, c2, (t - 0.33) / 0.33); }
else { return mix(c2, c3, (t - 0.66) / 0.34); }
}
渲染方式是畫一個全螢幕四邊形(6 個頂點、2 個三角形),fragment shader 根據 UV 座標查詢對應的細胞年齡。這比為每個細胞生成幾何體(instanced rendering)更簡單,而且效能足夠。
Rust 端:wgpu 初始化
wgpu 在 WASM 環境下的初始化跟 native 基本一樣,只是 surface 從 canvas 建立:
// 從 HTML canvas 建立 surface
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
..Default::default()
});
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas))
.expect("failed to create surface");
// 請求 adapter 和 device(非同步)
let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
}).await.expect("no adapter");
let (device, queue) = adapter.request_device(
&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits::downlevel_webgl2_defaults()
.using_resolution(adapter.limits()),
..Default::default()
},
None,
).await.expect("no device");
Backends::BROWSER_WEBGPU | Backends::GL 讓它在支援 WebGPU 的瀏覽器用 WebGPU,不支援的用 WebGL2 作為 fallback。
Ping-Pong 雙緩衝
最有趣的設計是 bind group 的建立——為了實現 ping-pong,我們建兩組 bind group:
let compute_bind_groups = [
// Step 0: 讀 A,寫 B
create_bind_group(&cell_buffers[0], &cell_buffers[1]),
// Step 1: 讀 B,寫 A
create_bind_group(&cell_buffers[1], &cell_buffers[0]),
];
每一步模擬只要切換 step_index,就自動交換讀寫方向。
WASM API 設計
Rust 端透過 thread_local! 儲存全域狀態,匯出簡單的函式給 JavaScript:
為什麼需要 thread_local!?因為 #[wasm_bindgen] 匯出的必須是自由函式,JavaScript 呼叫 step()、render() 時不會帶著物件——所以 Simulation 必須存在模組層級的 static 裡。但一般的 static 要求內容必須實作 Sync,而 RefCell 不是 Sync。Simulation 裡面持有的 wgpu 資源(Device、Queue、Surface 等)也不是 Send/Sync 的。thread_local! 讓每個執行緒擁有自己的副本,繞過了 Sync 的限制——在 WASM 環境下本來就只有一個執行緒,所以它實際上就是一個不需要 Sync 的全域可變變數。
thread_local! {
static SIMULATION: RefCell<Option<Simulation>> = RefCell::new(None);
}
#[wasm_bindgen]
pub async fn start(canvas_id: &str, grid_width: u32, grid_height: u32) {
console_error_panic_hook::set_once();
let sim = Simulation::new(canvas_id, grid_width, grid_height).await;
SIMULATION.with(|s| *s.borrow_mut() = Some(sim));
}
#[wasm_bindgen]
pub fn step() {
with_sim(|sim| sim.step());
}
#[wasm_bindgen]
pub fn toggle_cell(x: u32, y: u32) {
with_sim(|sim| sim.toggle_cell(x, y));
}
start() 是 async 的,因為 wgpu 初始化(request_adapter、request_device)都是非同步操作。其他函式都是同步的。
CPU 端的細胞鏡像
一個實作上的巧妙之處:我們在 CPU 端維護一份細胞狀態的副本。
為什麼?因為當使用者點擊畫布要切換某個細胞時,從 GPU 讀回資料(readback)是很昂貴的操作。所以我們在 CPU 端維護一份鏡像,toggle 時修改 CPU 資料再上傳到 GPU:
pub fn toggle_cell(&mut self, x: u32, y: u32) {
let idx = (y * self.grid_width + x) as usize;
self.cells[idx] = if self.cells[idx] > 0 { 0 } else { 1 };
self.queue.write_buffer(&self.cell_buffers[buf_idx], 0,
bytemuck::cast_slice(&self.cells));
self.render();
}
每次 step() 時也同步執行 CPU 端的模擬,確保鏡像保持一致。
建置與執行
cd wgpu-game-of-life
wasm-pack build --target web
python -m http.server 8080
# 開啟 http://localhost:8080
建置輸出:
- WASM 檔案:117 KB
- JS 膠水程式碼:57 KB
- 總計:~174 KB
比 Markdown 編輯器的 235 KB 還小,主要因為 wgpu 的 WASM backend 大部分邏輯在瀏覽器原生的 WebGPU API 裡。
學到的東西
WebGPU / wgpu 特定
- Compute shader 比想像中簡單:WGSL 語法接近 Rust,workgroup/dispatch 的概念很直覺
- Bind group 是關鍵抽象:它定義了 shader 能存取哪些資源,切換 bind group 就能改變資料流向
- wgpu 的跨平台設計很優秀:同一份 Rust 程式碼,換個 backend 就能在 native 和 WASM 上跑
- GPU readback 很貴:不要隨便從 GPU 讀資料回來,用 CPU 鏡像是常見的解法
跟 Markdown 編輯器的比較
|
Markdown 編輯器 |
Game of Life |
| GPU 使用 |
無 |
Compute + Render |
| 非同步初始化 |
否 |
是(wgpu 需要 async) |
| 主要瓶頸 |
CPU 解析 |
GPU shader 編譯 |
| 互動模式 |
文字輸入 |
滑鼠繪圖 + 動畫迴圈 |
| 套件大小 |
235 KB |
174 KB |
結語
這是我第一次寫 GPU shader 程式——用 Rust 配 wgpu 的體驗非常好。wgpu 把 WebGPU 的複雜性封裝得很乾淨,而 WGSL shader 語言的設計也很現代。
如果你也想學 WebGPU,Game of Life 真的是個很好的起點:概念簡單、視覺效果漂亮、而且剛好涵蓋 compute pipeline 和 render pipeline 兩個核心概念。
參考資源