第 39 課 - 3D Mesh 渲染
在 第 30 課 - 後處理與渲染圖 之前,畫布上的一切都走 2D 管線:正交相機、SDF、路徑三角化填色,等等。這一課加上一條 3D Mesh 分支,而不取代那個 2D 渲染器:頂點帶有法線、透視/正交投影、深度測試,並在同一張畫布上與 2D 圖層合成。
Framer、Spline、Rive 與 Bevy
Framer 與 Spline 裡 2D/3D 融合的核心想法,可以收成兩條技術路徑。每個產品都依自己的定位選擇不同的架構。
Framer 的 3D 能力本質上是 CSS 3D transform 的加強包裝,不是真正的 3D 渲染管線。因此不能匯入外部 3D 模型(GLB/OBJ),也沒有真正的光照、陰影或材質系統。3D 效果限於卡片翻轉、視差圖層這類簡單情況。見:How to Turn 2D Elements into Interactive 3D Objects in Framer

Spline 是真正的 3D 編輯器,架構更接近遊戲引擎。Spline 讓你建立獨立的 UI Scenes(2D 畫布),再把它們當作 3D 場景裡 UI Frame 物件上的紋理。Working with 2D and 3D objects

Rive 今天基本上仍是 2D 工具。它的「3D 感覺」來自 2.5D 變換、Mesh 變形與類似技巧,而不是完整的 3D 管線。
我們的做法更接近 2D Rendering in Bevy:2D 圖形落在 z = 0 平面上,3D 模型共用同一個世界;當你平移/縮放 2D 相機時,3D 相機可以選擇跟著走。實作見 PR #271。
架構概觀
Same WebGL/WebGPU context
└─ MeshPipeline render graph
├─ Pass: 3D (MeshPipeline3D.drawMeshes)
├─ Pass: 3D Gizmo (RenderGizmo3D.drawGizmos, on top of meshes)
├─ Pass: grid / 2D vector (existing BatchManager)
└─ Pass: post-processing → presentRenderer3DPlugin:註冊 3D 元件,並排程MeshPipeline3D(GPU 快取)、CameraSync(與 2D 相機連動)、Pick3D(選取/拖曳),以及RenderGizmo3D(繪製變換 Gizmo)。MeshPipeline3D:不呈現自己的交換鏈;在MeshPipeline的主通道裡 先畫 3D,再畫 2D,共用顏色與深度附件。- 既有的
Mesh.ts仍然是 2D 向量填色。不要把它和 3D 的Mesh3D混淆。
外掛設定:
import {
App,
DefaultPlugins,
DefaultRenderer3DPlugin,
} from '@infinite-canvas-tutorial/ecs';
const app = new App().addPlugins(...DefaultPlugins, DefaultRenderer3DPlugin);
app.run();核心元件
| 元件 | 角色 |
|---|---|
Camera3D | projection: 'perspective' | 'orthographic';當 linked 時,CameraSync 跟隨 2D 的平移/縮放 |
Mesh3D | positions、normals,以及選用的 indices(三角形 Mesh) |
Material3D | 金屬-粗糙度 PBR:baseColor、metallic、roughness,以及環境光強度;舊欄位仍保留以維持相容 |
Light3D | ambient/directional/point/spot;見 場景照明範例 |
Transform3D | 平移、歐拉旋轉、縮放 |
Selected3D | 3D 選取狀態:Gizmo 模式、作用中的軸/平面、拖曳參考點等(見 3D 變換 Gizmo) |
Mat4 | 4×4 矩陣輔助(perspective/ortho/lookAt) |
透過 ECS 的 commands.spawn 產生實體,例如:
import {
Camera3D,
Mesh3D,
Material3D,
Transform3D,
} from '@infinite-canvas-tutorial/ecs';
commands.spawn(
new Camera3D({
eye: [3, 3, 5],
center: [0, 0, 0],
clearColor: true,
}),
);
commands.spawn(
new Mesh3D({ positions, normals, indices }),
new Material3D({
baseColor: [0.25, 0.55, 0.95, 1],
ambient: 0.45,
diffuse: 0.75,
specular: 0.4,
shininess: 48,
}),
new Transform3D({
translation: [0, 0, 0],
rotation: [0.3, 0.6, 0],
scale: [1, 1, 1],
}),
);統一的 3D 空間(連動相機)
要 平移/縮放畫布,並讓 2D 與 3D 一起移動,請在 Camera3D 上設定 linked: true。
每一幀,CameraSync 讀取 2D ComputedCamera 的 (x, y, zoom),並寫入 3D 相機的 eye/center/baseDistance:
- 縮放:
baseDistance = canvas.height / 2,eye.z = baseDistance / zoom,對應 z = 0 時 2Dmat3.projection的可見高度 - 連動正交(
projection: 'orthographic'):eye = [x, -y, distance],center = [x, -y, 0](2D 畫布的 Y 向下,3D 世界的 Y 向上,透過canvasWorldToWorld3D翻轉) - 連動透視(
projection: 'perspective'):eye = [x, y, distance],center = [x, y, 0](與 2D 節點相同,畫布座標,Y 向下)
連動 + 正交
MeshPipeline3D 直接使用 2D 的 viewProjectionMatrix(包含 Y 翻轉、平移與縮放),因此 extrude3d 與 2D 矩形嚴格對齊。
立方體(正交) 範例使用 連動 + 正交(translation: [200, 100, 40])。
連動 + 透視
在透視模式中,螢幕位置來自 完整的 2D VP(平移、縮放與 Y 翻轉都與 2D 一致);深度則對 Transform3D.translation.z 使用透視矩陣,以產生近大遠小。錨點是 translation 的 (x, y, z),因此物件會落在與 2D 相同的 (x, y)。
立方體(透視) 使用上面的 連動 + 透視 設定;拖曳畫布時,立方體應與 2D 圖層一起平移與縮放。
射線投射拾取
3D 選取與 Gizmo 互動由 Pick3D 系統處理(選取工具作用中時,Select 也會使用它)。指標按下時,游標下的視埠像素會拿來測試 Gizmo 把手與 Mesh3D 三角形。沒有 GPU 拾取通道,一切都在 CPU 上的 ray-casting.ts 與 pick3d-probe.ts 執行。
管線
viewport (x, y)
└─ buildPickSceneForViewport(camera, …) → Mesh3DPickScene (same matrices as rendering)
└─ probePick3DAtViewport(…)
├─ 1. gizmo parts on Selected3D entities (screen-space / ray, per part)
└─ 2. all Mesh3D on this canvas → keep closest hit (smallest t)優先順序:已經選取的實體會先探測 Gizmo 把手;都沒有命中時,才探測場景 Mesh。Mesh 之間,沿光線 距離最小 的命中(或在連動透視中深度最大、最靠近前面的命中)勝出。
標準射線投射(正交/自由相機)
在非連動透視的模式裡,拾取與經典 3D 編輯器相同:
screenToRay:視埠像素 → NDC → 用inv(view × projection)反投影近平面與遠平面,得到世界空間光線。rayMeshIntersection:用實體的模型矩陣變換 Mesh 頂點,然後:- 粗略階段:光線對變換後的 AABB(slab 法)。
- 精細階段:對每個三角形做 Möller–Trumbore 光線–三角形測試;保留最近的
t。
// packages/ecs/src/utils/ray-casting.ts (simplified)
const invVP = computeInvViewProjection(projMatrix, viewMatrix);
const ray = screenToRay(vx, vy, width, height, invVP);
const hit = rayMeshIntersection(ray, positions, indices, modelMatrix);
// hit: { t, point, triangleIndex } | null連動正交(linked + orthographic)重用同一條路徑:MeshPipeline3D 把 2D 的 viewProjectionMatrix 送進拾取場景,因此點擊仍與 2D 矩形以及 extrude3d 對齊。
連動透視:螢幕空間三角形
連動透視對 Mesh 命中 不 使用世界光線。螢幕位置由完整的 2D VP 決定;深度來自作用在 translation.z 上的另一個透視矩陣。為了與頂點著色器一致,pickMeshLinkedPerspective 用相同的 uniform 把每個三角形投影到視埠空間,再測試游標是否落在 2D 投影三角形內(pointInTriangle2D),並挑選該像素上 最前面的深度。這個模式的 Gizmo 零件重用同一個輔助函式(把手可以選擇加上 Z 方向的螢幕偏移)。
這樣在帶有透視立方體的無限畫布上平移/縮放時,仍能做到「看到的就是能選到的」。
拖曳約束
按下 Gizmo 會建立一個 GizmoSession,帶有初始姿態與固定的投影。對一般相機,指標移動會讓光線與作用中的約束平面相交。連動相機會反解畫面上顯示的把手投影,包含它的 Z 螢幕偏移。平移沿著選取的軸或平面;旋轉沿著局部環軸。無效或退化的取樣不會改變預覽。
移動期間,這個 session 會更新執行期元件。放開時,宣告式來源會發布一次文件編輯與一筆復原紀錄。Escape、指標取消,或離開畫布,會恢復起始姿態,而且不記錄編輯。
改變作用中的相機、它的投影/視圖,或畫布尺寸,會取消按住的手勢。從外部編輯來源的姿態、幾何,或某個祖先的變換,也會取消它:執行期預覽回到 目前的文件,因此舊的指標放開不能覆寫 API 編輯或復原。無關的編輯,例如名稱與顏色,可以和正常繼續的拖曳合併。另一張畫布上的變更不會中斷它。再按一次把手,就能在新視圖中開始。
計算後的版面也是手勢脈絡的一部分。Yoga 可以改變來源的位置或尺寸,而不改那些文件欄位;這些變化,包括祖先變換,會取消預覽。取消會保留新的版面,只還原仍由預覽擁有的座標。在除此之外都沒有改變的版面上按 Escape,會回到計算出的起始位置,即使它與文件不同。session 自己寫入的預覽不會讓它失效。
GizmoSourceFrame 會對局部的來源/祖先幾何做快照,避免世界矩陣延遲更新造成的誤中斷。當指標 session 擁有來源預覽時,伴生同步不會再次寫入該來源。直接操作伴生物件的整合,仍保留它們的反向同步。
沒有變化的幀不會重寫伴生變換或材質,姿態或材質編輯會重用已產生的 CPU 幾何。幾何設定的變更會單獨讓該快取失效。
MeshPipeline3D 在姿態/材質編輯期間,也會重用 GPU 的頂點、法線、UV、索引,以及每個 Mesh 的 uniform 緩衝區。只有對 Mesh3D 的寫入會讓幾何失效;直接編輯陣列時,先用 entity.write(Mesh3D) 取得元件。快取在伴生同步之後消費那些寫入,跨過被跳過的繪製仍保留它們,並在下一次繪製時上傳一次替換結果。幾何陣列相同的 Mesh 在同一張畫布內共用已上傳的幾何,而每個 Mesh 保留自己的模型 uniform 與繪製繫結。原地的幾何編輯會上傳新的一代;其他實例保留先前的 GPU 快照,直到它們被更新或移除。 每張畫布擁有自己的快取與場景 uniform。移除 Mesh 或把它移到另一張畫布,會釋放舊的緩衝區;銷毀畫布時,會在裝置之前釋放它的資源。失敗的配置會回滾,並且可以重試,而不替換先前完整的快取項目。
模型矩陣與法線矩陣只在姿態改變時重新計算。模型 uniform 的上傳也會跳過沒有變化的幀,以及只有相機改變的情況;材質編輯與已完成的紋理載入會更新 uniform,而不重建那些矩陣。對變換/顏色陣列的原地編輯以值比較。
場景 uniform 按畫布快取。相機與連動的 2D 視圖改變時會更新場景矩陣;光源改變時會重新打包照明,而不重建那些矩陣。沒有變化的繪製會重用場景資料與每個 Mesh 的繪製繫結。紋理就緒或被替換、幾何重建,以及 GPU 資源替換,會讓受影響的繫結失效。繫結會在它們參考的緩衝區與紋理之前釋放。失敗的場景上傳與繫結配置仍可重試。
3D 圖層清單跟隨 ECS 的幾何、中繼資料、範圍與成員變更,而不是在每個閒置幀掃描每一個 Mesh。選取成員另外追蹤,因此懸停 Gizmo 把手不會重建清單。圖層項目保留穩定的實體參考,供之後選取使用,包含復原/重做之後。
把手顏色、軸,以及完整的指標流程,見 3D 變換 Gizmo。
3D 變換 Gizmo
使用 選取工具(penbarSelected === Pen.SELECT)時,點擊 3D Mesh 會為實體加上 Selected3D,並在其中心繪製 平移 把手:紅/綠/藍箭頭,加上半透明的平面控制項。拖曳把手會寫回 Transform3D.translation。這與 2D 的 Selected + RenderTransformer 類似,但走另一條 3D 拾取與繪製路徑。
互動流程
pointer down (Select tool)
└─ Pick3D: probe gizmo first, then Mesh3D
├─ hit gizmo → capture pose/projection, create GizmoSession
└─ hit mesh → select; miss → clear selection
pointer move (held)
└─ GizmoSession.update → constrained runtime preview
pointer up
└─ finish(true) → publish source pose once, record history
Escape / pointer cancel / leave
└─ finish(false) → restore pose, clear gesture state座標與把手的意義
與 統一的 3D 空間 一致,Gizmo 使用 畫布座標(Y 向下),而不是 Blender 那種 Y 向上:
| 顏色 | 軸 | 方向(典型的連動畫布視圖) | 拖曳會更新 |
|---|---|---|---|
| 紅 | +X | 向右 | translation.x |
| 綠 | +Y | 向下 | translation.y |
| 藍 | +Z | 深度(z+ 朝螢幕內) | translation.z |
箭頭:沿單一軸平移。
平面(
xy/xz/yz半透明方塊):一次移動兩個分量(例如xy只動 X 與 Y,不動 Z)。組合模式
transform(預設,類似 Spline):同時顯示平移箭頭/平面 以及 旋轉環;抓到哪個把手就執行那個操作(activePartKind:translate|rotate),沒有W/E模式切換。拖曳 箭頭/平面 → 更新
Transform3D.translation;拖曳 環 → 更新Transform3D.rotation(局部歐拉角,與 Mesh 相同)。環跟隨物件目前的朝向;箭頭留在畫布世界軸上(X 向右、Y 向下、Z 為深度)。
拾取使用螢幕空間的 最近 命中;重疊時箭頭在上面,因此平移勝出。
scale保留給以後。
編輯擠出的矩形
帶有 extrude3d 的 rect 使用與宣告式 Mesh 相同的 Gizmo。它可序列化的選項現在包含抬升,以及局部 3D 旋轉:
const node: RectSerializedNode = {
id: 'extruded-card',
type: 'rect',
zIndex: 0,
x: 100,
y: 80,
width: 160,
height: 100,
extrude3d: { depth: 80, z: 20, rotation: [0.2, 0.3, 0] },
fills: [{ type: 'solid', value: '#3377dd' }],
};- XY 平移會寫入來源矩形在其父座標中的
x/y,包含旋轉、縮放或翻轉過的父節點。奇異的父變換會拒絕預覽,因為它的反矩陣不存在。 - Z 平移改變
extrude3d.z,並保留depth。在 3D 旋轉為零時,z是正面的抬升;伴生中心位於z - depth / 2。 - 環的旋轉寫入
extrude3d.rotation:相對於來源既有的世界空間 2D 旋轉、以弧度表示的 XYZ 歐拉角。它不會改動來源的 2Drotation。對一般的旋轉圖形,伴生會組合Rz(sourceAngle) × Rx(x) × Ry(y) × Rz(z)。
放開時提交一次;取消會恢復預覽。復原/重做與重新載入文件會恢復同一個姿態。停用擠出或替換它的伴生,會中斷手勢,並恢復目前的文件,而不是重建一個過時的伴生。
extrude3d: true 仍然表示深度 100,數字仍然指定深度。物件值的預設是深度 100、抬升 0,以及旋轉 [0, 0, 0]。一次更新會替換整個選項值:省略的欄位回到那些預設。目前還沒有縮放把手;用矩形尺寸與 depth 改變大小。矩形擠出會讓局部角落跟著旋轉與縮放走。來自變換後父節點的圓角、鏡射與剪切會烘進 Mesh,因此它們的足跡仍與可編輯的 2D 來源對齊。奇異的父變換無法產生新的 Mesh。
擠出封閉的 Path
帶有一層實心填色的封閉 Path 可以使用同一個 extrude3d 選項。它的正面與背面跟隨填色輪廓,包含孔洞;編輯 d 或 fillRule 會重建 Mesh。平移會重用它。來自變換後父節點的剪切會烘進 Mesh,因此該線性變換的變更會重建它。
const vector: PathSerializedNode = {
id: 'extruded-vector',
type: 'path',
zIndex: 0,
x: 100,
y: 80,
d: 'M100 80 L200 80 L200 180 L100 180 Z M125 105 L125 155 L175 155 L175 105 Z',
fillRule: 'evenodd',
fills: [{ type: 'solid', value: '#3377dd' }],
extrude3d: { depth: 40, z: 0, sideColor: '#facc15' },
};所有子路徑都必須封閉。開放路徑、描邊圖層、多層或非實心填色、非流形輪廓,以及奇異變換,仍以 2D 顯示。Path 保留原來的文件節點,並支援 Gizmo 編輯、復原與重新載入。
把圖示變成 3D
在 App 裡選取一個 Icon,開啟 3D → Convert icon to 3D。選擇深度與材質預設(霧面、亮面或金屬)。轉換會把 SVG 描邊,包含線帽與接合,展開成封閉輪廓。填色部分與孔洞會變成同一個群組裡可編輯的 Path。選取群組可一次改變所有零件的深度或材質;選取一個 Path 可編輯它的輪廓或倒角。復原會還原 IconFont。這次轉換是圖示幾何的快照。
用 registerIconifyIconSet 註冊圖示集之後,程式碼可以使用相同的轉換:
const result = iconFontToExtrudedPaths(iconNode, {
depth: 40,
bevelSize: 2,
metallic: 1,
roughness: 0.25,
});
if (result) api.updateNodes([result.group, ...result.paths]);metallic 與 roughness 是 0 到 1 之間的 PBR 參數。這些預設使用既有的照明管線。透明玻璃與折射尚未支援。SVG 濾鏡、漸層與不支援的塗料,在擠出之前可能需要手動整理輪廓。
sideColor 是側壁的選用 CSS 顏色;正面與背面保留來源填色。省略它時,所有面都使用來源填色。側壁顏色是塗料變更,因此編輯它會重用既有的 Mesh 幾何。
SVG 的 <path>、<polygon>、<rect>、<circle> 與 <ellipse> 元素,以及封閉的 <polyline> 元素,可以用 svgElementsToExtrudedNodes 匯入成填色擠出的來源。這個輔助函式會把其他匯入的圖形留在 2D;無效的深度會被拒絕,而不是悄悄改成預設值。
import { svgElementsToExtrudedNodes } from '@infinite-canvas-tutorial/ecs';
const svg = new DOMParser().parseFromString(source, 'image/svg+xml');
const nodes = svgElementsToExtrudedNodes(
svg.documentElement as unknown as SVGSVGElement,
40,
);
api.updateNodes(nodes);在線上 App 中,選取矩形或 Path 即可在上方工具列開啟 3D 控制。啟用擠出,然後編輯它的深度與倒角。專案選單也提供 Import SVG as 3D,它會把可見圖稿置中到畫布上,即使圖稿只佔 SVG 視埠的一部分,並把不支援的圖層留在 2D。使用 Export as → GLB (3D) 下載專案中所有已就緒的擠出 Mesh。App 也讓你設定倒角剖面(1–8 段),以及選用的十六進位側壁顏色;側壁顏色留白就使用圖形的填色。 若某個 Path 無法產生 Mesh,App 會停用擠出動作,並顯示封閉輪廓、填色與描邊的要求。
**匯入支援範圍:**封閉的純色輪廓與支援的純色描邊可轉換為可編輯的 3D 圖形。漸層、圖案與其他不支援擠出的圖層會保留為 2D;支援的孔洞、群組變換、本地參考與裁剪會保留。外部參考、遮罩與濾鏡不會轉換為 3D。
下方示範可切換描邊、裁剪與重複參考。詳細支援條件請見 SVG 擠出與匯入相容性參考。
為 Path 與 Rect 擠出的兩端設定 extrude3d.bevelSize(畫布單位)即可加上倒角。示範把它顯示為 Bevel。當向內偏移仍保留填色拓撲時,支援孔洞、多個島嶼與凹角。如果輪廓碰撞,或狹窄區域塌陷,圖形會保留銳利邊緣。改變深度或尺寸會重建伴生 Mesh,同時讓來源保持可編輯。 解析後的填色邊界合計超過 4096 個頂點的圖形也會保持銳利,以限制 Mesh 的工作量。
把 extrude3d.bevelSegments 設成 1 得到直線倒角(預設),或設成 2–8 得到圓角的四分之一圓剖面。段數越多,輪廓越平滑,Mesh 三角形也越多。示範的 Bevel profile 控制讓你在匯入的 SVG 圖形上比較直線與圓角剖面。
範例的 Download GLB 按鈕把產生的 3D Mesh 匯出成自足的 glTF 二進位檔。使用 api.exportGlb 下載所有已就緒的擠出圖形,或傳入節點只匯出選取範圍(包含選取群組的子節點):
await api.exportGlb();
const glb = await api.exportGlb({
nodes: [api.getNodeById('extruded-vector')!],
download: false,
}); // Uint8Array匯出把畫布 Y 向下的座標轉成 glTF Y 向上的座標,並保留目前的 3D 姿態、實心填色與選用的側壁顏色。它只匯出已就緒的 Mesh 伴生;不支援的 2D SVG 圖形不會包含在內。基礎色紋理以 PNG 或 JPEG 嵌入(其他可解碼格式會轉成 PNG)。紋理 URL 必須允許 CORS 擷取。同步的 api.export({ format: ExportFormat.GLB }) 仍可用於沒有紋理的匯出。 較低階的 Mesh 匯出,呼叫 exportMeshesToGlb(sources, await loadGlbImages(sources))。
使用 ViewBox + shear sample 檢視變換後的輪廓與它的 evenodd 孔洞,然後關掉擠出,比較 2D 足跡。 使用 Slice clipping sample 查看一個孔洞、兩塊分開的片段,以及被 SVG 視埠切過的曲線邊緣。
Loading canvas…
照明
在下方場景中比較環境光、方向光、點光源與聚光燈。調整球體的金屬度與粗糙度,切換環境貼圖,或試用陰影與移動的聚光燈。
場景最多使用 8 盞直接光,其中最多 2 盞符合條件的光源可投射陰影。自發光使用 material3d.emissiveFactor / emissiveStrength,無光照材質使用 material3d.unlit;鏤空與透明使用 alphaMode。
The cube is a rough dielectric with adjustable emission, the sphere is a metal, and the cylinder is a smooth dielectric. Toggle the sphere caster and floor receiver independently. Compare HDR and LDR panoramas on the sphere.
glTF 模型
透過宣告式的 mesh3d 幾何(geometry: { type: 'gltf', url: '…' })載入外部 glTF/GLB。Khronos 的 Duck 樣本在 /data/ 下使用一對 .gltf + .bin;Mesh 會置中並正規化到單位範圍,再用 scale3d 縮放。
glTF 的基礎色係數使用線性 RGB,而 ECS 把材質基礎色存成 sRGB。匯入時會在兩者之間轉換;GLB 匯出再轉回線性 RGB,因此材質顏色在來回之後保持穩定。
匯入器也保留 glTF 的 doubleSided、normalTexture、occlusionTexture,以及檔案中寫入的 TANGENT 屬性。法線貼圖改變照明法線;遮蔽只影響間接光。互動式 PBR 模型 說明法線貼圖何時使用檔案中的切線,或從 UV 推導。
glTF 的 COLOR_0 頂點顏色(浮點數,或正規化的無號 byte/short,RGB 或 RGBA)在線性空間裡乘上材質基礎色。互動式頂點顏色模型 顯示有無場景照明的結果。匯出把顏色保留成浮點 RGBA。
匯入器接受三角形清單、三角形帶(mode: 5)與三角形扇(mode: 6),有無索引皆可。它把它們轉成三角形清單,供渲染與 GLB 匯出。鏡射的節點變換也會修正三角形繞序,讓單面表面仍然可見。
稀疏的 glTF accessor 支援位置、法線、UV、顏色、切線與三角形索引。缺少的基礎資料從零開始;稀疏值只替換列出的元素。匯入的 Mesh 會匯出成一般的緊密 GLB 緩衝區。