模組四|從 2D 到 3D(Day 20–25)
昨天寫完契約——chenger-sculpt-spec.json 裡的 qualityContract,一份規定「哪些地方不准變、哪些地方准簡化」的驗收條款——今天實作。
要用 Three.js 內建的基本幾何體,把一個角色堆出來。問題是:看著一張三視圖,你要從哪裡開始?
如果你問怎麼用基本幾何體做角色,答案通常是「頭用球體、身體用圓柱、裙子用圓錐」。
這在只有三個部位的時候有用。碰到實際的角色設計就不夠了。這個角色身上有:雙丸子頭、呆毛、廣袖和服、黑金層疊袖口、繩結腰帶、掛飾流蘇、百褶裙、厚底靴、以及一支連著鏈條和繩索的鐵錨。
問題不在「哪個部位用哪個幾何體」,在於你根本不知道要切成幾塊。
切太粗,做不出特徵;切太細,617 行程式碼會變成一團無法維護的東西。(這 617 行量的是 chenger.html 裡 <script type="module"> 區塊的內容行,整檔連同 HTML 與 CSS 是 865 行。)

我在「看圖」和「寫程式」之間插了一步:把每個部位標上拓撲類型。
chenger-sculpt-spec.json 的 components 陣列,每個元件三個欄位:
{
"id": "skirt",
"topologyClass": "pleated-cone",
"primitiveStrategy": "CylinderGeometry open cone, radial cos ripple on vertices, flatShading",
"localFeatures": ["hanging charm discs + tassels (instanced)", "brown underlayer"]
}
topologyClass:這東西本質上是什麼形狀家族primitiveStrategy:用哪些 primitive、怎麼組localFeatures:這個部位的細節清單關鍵是第一欄。先分類,實作就幾乎是機械的。
| id | topologyClass | primitiveStrategy(節錄) |
|---|---|---|
| head | sphere |
SphereGeometry scaled |
| hair | clustered-volumes |
sphere cap + 瀏海 lobes + 2 個丸子(torus+spheres)+ 呆毛 TubeGeometry + 側髮 cones |
| face | applique |
壓扁的 sphere 當眼(白+琥珀虹膜+瞳+高光)、薄 box 當眉、torus 弧當嘴 |
| torso-kimono | lathe |
LatheGeometry 白上衣、黑 V 領 box 條、繩項鍊 torus 弧 + cone 流蘇 |
| sleeves | flared-cones |
LatheGeometry 廣袖、黑袖口 cylinder + 金環 tori、sphere 手 |
| skirt | pleated-cone |
CylinderGeometry 開口錐、頂點徑向 cos 漣漪、flatShading |
| rope-belt | torus+knot |
TorusGeometry 腰帶、結用 spheres、cone 流蘇 |
| legs-boots | stacked-cylinders |
皮膚 cylinder、白襪 cylinder、靴 cylinder + 黑厚底 box、橘鞋帶條 |
| anchor | compound |
環 torus + 桿 cylinder + 橫木 box + 弧臂 torus + 擠出的爪 + InstancedMesh 鏈環 + CatmullRomCurve3 繩管 |
九種拓撲類型,九種不同的處理方式。
因為分類把「這看起來像什麼」轉成「這用哪套方法做」。
看著頭髮想「該怎麼做頭髮」是沒有出口的問題。但如果先判定它是 clustered-volumes(一團一團的體積),那方法就出來了:不要試圖做出髮絲,做出幾團體積,然後在上面放配件。
同樣的,applique(貼花)這個分類告訴我:臉不是一個立體結構,是貼在球面上的平面元素。 眼睛是壓扁的球,眉毛是薄板,嘴是一段圓環弧。不用做眼窩、不用做鼻梁。
分類是一個決定,而決定一旦做出來,實作就沒有選擇焦慮了。

CylinderGeometry open cone, radial cos ripple on vertices, flatShading
拆開來:
CylinderGeometry 做一個上窄下寬、沒有頂底面的開口錐r + amplitude * cos(n * θ)
flatShading
第三步是關鍵。flatShading 讓每個面用單一法線著色,於是平滑的餘弦波在視覺上變成一道一道的折面——那正是百褶的樣子。
沒有做任何一個褶子。 褶子是「一個數學函數 + 一個渲染設定」共同產生的視覺結果。
這是這整套方法的縮影:不要模仿外觀,要找出產生那個外觀的機制。
chain of instanced torus links + CatmullRomCurve3 rope tube to belt
鏈條是一堆一模一樣的環——這是 InstancedMesh 的教科書案例。一份幾何、一份材質、N 個變換矩陣。
繩子則是 CatmullRomCurve3:給幾個控制點,讓曲線自己穿過它們,然後沿曲線掃出管狀幾何。
繩子從腰帶連到錨。如果錨的位置要調整,我只要改控制點,繩子會自己重新彎。繩子不是一個模型,是一個關係。
617 行 JavaScript 裡的實際呼叫次數:
SphereGeometry 16 ConeGeometry 6
TorusGeometry 12 CatmullRomCurve3 4
CylinderGeometry 12 TubeGeometry 3
BoxGeometry 7 LatheGeometry 2
CircleGeometry 1
ExtrudeGeometry 1
InstancedMesh 1
Group 14
(數的是原始碼裡 new THREE.XXX( 的出現次數。網格本身沒有列進來:程式裡的 THREE.Mesh 幾乎都經過一個 mesh(geo, mat) 小工廠產生,直接數建構子會嚴重低估,數工廠呼叫又會漏掉迴圈裡展開的那些,所以這個數字我量不準,就不寫。)
沒有任何一個是外部載入的模型。 全部是 Three.js 內建的參數化幾何。
Group 有 14 個——那是 9 個元件加上一些子群組。分組不只是整理,它決定了哪些東西會一起移動。
規格檔最後還有一段,是我覺得最容易被忽略但最有價值的:
"runtime": {
"pivots": ["root", "anchorGroup"],
"sockets": ["belt-knot", "anchor-ring"],
"nodes": ["head", "hair", "torso", "skirt", "anchor", "ropeTether"],
"variantTargets": ["hair", "clothMain", "clothDark", "gold"]
}
這一段跟外觀無關,講的是這個模型可以被程式怎麼操作:
themed)這一段是「它是一個可控物件」跟「它是一個好看的擺件」的差別。
image-to-3D 給的是一整塊網格,它沒有 socket、沒有可單獨取用的 node、沒有 variantTarget。要有,你得自己去切。
而這條路線的模型是用程式碼堆出來的,所以它天生就是結構化的。每個部位本來就是一個變數。
代價一:拆解是人工的,而且要花時間。
九個元件、九種拓撲判定、每個的 primitiveStrategy,這些都是我看著三視圖一個一個想出來的。
一個角色大概要半天。 20 個角色就是 10 天。
這正是 Day 22 提到的「可量產性」在這條路線上的版本,它也不好量產,只是瓶頸從 GPU 換成了人。
代價二:忠實度就是不高。
契約寫 0.72——那是 targetFidelity 這個欄位,意思是「跟三視圖有七成像就算過關」。實際看起來就是 0.72 的樣子:認得出是這個角色,但它不是那張圖。
這條路線贏的不是像,是可控。 如果你的需求是「要像」,這條路線從一開始就不該考慮。
代價三:617 行的可讀性靠規格檔撐著。
程式碼本身是一長串幾何體的建構與擺位。單獨讀那 617 行,你很難看出結構。
規格檔是它的目錄。 兩個檔案要一起讀才有意義,而這代表它們有 Day 12 講過的那個風險:沒有機制保證規格檔跟程式碼同步。
我改程式碼的時候,不一定會回去改 JSON。這是這個做法目前最脆弱的地方。
一、在「觀察」和「實作」之間插一個「分類」。
看著一個複雜的東西直接想「怎麼做」,會卡住。先問「這在結構上屬於哪一類」,答案出來之後,做法通常就跟著出來了。
分類的價值不在於分得多準,在於它把一個開放問題變成一個有限選項的問題。
這件事在寫程式上也一樣:看到一段需求直接想怎麼實作會卡;先判定「這是一個狀態機/一個管線/一個查表」,實作方式就收斂了。
二、不要模仿外觀,找出產生外觀的機制。
百褶裙不是做出很多褶子,是 cos 加 flatShading。
問句:這個視覺效果,是由什麼規則產生的? 找到規則之後,你用十行程式碼得到的東西,會比手工堆一百個褶子更好調、更好改、也更小。
三、規格裡要有一段「這東西之後怎麼被操作」。
大部分規格只描述成品長什麼樣。加一段描述它可以被程式怎麼動:哪裡是軸、哪裡可以掛東西、哪些部分可以被單獨換掉。
這一段決定了它是一個資產還是一個零件。資產只能整個換掉,零件可以被組合。
四、拆解粒度就是你的維護粒度。
九個元件,代表我之後能單獨改的最小單位是「元件」。我可以只換頭髮的顏色、只調鐵錨的位置。
如果我當初拆成三塊,那我就只能整塊改。如果拆成三十塊,程式碼會變得沒人看得懂。
拆解的時候要想的不是「怎麼做出來」,是「之後我會想單獨改哪些東西」。
明天 Day 25,模組四結算:兩條路線的完整對照,以及為什麼那顆花了一次 GPU 生成的模型,最後一行都沒被用到。 我會附上實際的 grep 結果。