Three.jsWeb 3DInteriorFloor PlanInteractiveFrontend

把平面圖變成可以走進去的 3D:Three.js 室內格局的完整做法

·閱讀約 8 分鐘

看房、裝修、租屋,你拿到的都是同一種東西:一張 2D 平面圖。

它是空間最通用的語言,但坦白說,大多數人讀不懂。牆的厚度、房間的比例、從玄關走到主臥的動線——這些資訊都在圖上,可是要在腦中把它立體化,需要的空間想像力並不是人人都有。

所以我做了一個實驗:把一張真實的三房格局,變成瀏覽器裡可以轉視角、切換配置、拖拉家具的 3D 空間。不裝 App、不用帳號,開網頁就能走進去。

做完的心得是:這件事比想像中便宜非常多。不需要室內設計軟體,不需要 Blender,整個場景的來源是一份不到三百行的資料檔。

第一個決定:平面圖的資料長什麼樣

拆到最後,一張平面圖其實只有五種東西:

typescript
export type LayoutPreset = {
  readonly floorOutline: readonly Point2[];   // 外牆輪廓(多邊形頂點)
  readonly walls: readonly WallSegment[];      // 室內牆(線段 + 厚度)
  readonly roomLabels: readonly RoomLabel[];   // 房間名稱與位置
  readonly finishes: readonly RoomFinish[];    // 各房間的地坪材質
  readonly furniture: readonly {               // 家具的初始擺位
    readonly type: string;
    readonly x: number; readonly z: number;
    readonly rotation: number;
  }[];
};

把原始圖紙上的尺寸換算成公尺、原點移到格局中心,一個一個頂點敲進來。這一步最枯燥,但它換到一個重要的性質:資料和渲染徹底分離。同一份型別可以裝任何格局——實驗裡的 A/B 兩種配置切換,就是換一份 preset 而已。

這裡有一個刻意的設計決定值得說:資料檔裡只保留匿名化、正規化之後的幾何。原始圖紙的圖層名稱、標註、座標系統,全部不進版本庫。這是一張真實住宅的格局——幾何本身沒有隱私問題,但「這是哪裡、原圖從哪來」有。資料模型在設計時就把這條線畫清楚,比事後清理容易得多。

地板:一個多邊形,擠出來

有了輪廓頂點,地板是整個場景最簡單的部分。Three.js 的 Shape 吃一串 2D 頂點,ExtrudeGeometry 把它往下擠出厚度:

typescript
const shape = new THREE.Shape();
// 沿著 floorOutline 頂點 moveTo / lineTo 畫出輪廓...
const geometry = new THREE.ExtrudeGeometry(shape, {
  depth: 0.22,          // 樓板厚度
  bevelEnabled: false,
});

不是一塊方形地板上面再蓋牆——地板本身就是格局的形狀。凹進去的陽台、突出來的工作陽台,輪廓走到哪,樓板就到哪。這讓整個模型從第一眼就「像那間房子」,而不是像積木。

牆:線段變成箱子,外牆自動生成

牆的做法樸素到有點好笑:每一段牆就是一個 BoxGeometry——長度是線段長、寬度是牆厚、高度是牆高,再依線段角度旋轉到位。

比較有意思的是外牆不用另外畫。外牆就是地板輪廓本身:沿著輪廓的每一條邊自動生一段牆,室內牆才需要在資料裡逐段宣告:

typescript
// 外牆:沿輪廓自動生成
floorOutline.forEach((point, index) => {
  const next = floorOutline[(index + 1) % floorOutline.length];
  this.createWall(point, next, 0.2, outerMaterial, wallHeight);
});
// 室內牆:資料裡逐段宣告(含各自厚度)
walls.forEach((wall) => {
  this.createWall(wall.from, wall.to, wall.thickness ?? 0.14, innerMaterial, interiorHeight);
});

娃娃屋剖面:面向鏡頭的牆,自動變矮

第一版做完有個致命問題:牆全部等高,鏡頭從外面看,只看得到屋頂視角的一圈牆——完全看不進室內。

室內設計軟體的通用解法是「剖面」:把靠近觀察者的牆切掉。我的版本更簡單——判斷每段外牆的中點是否落在「面向鏡頭」的那兩側,是的話牆高直接降到約三分之一:

typescript
const midpointX = (point[0] + next[0]) / 2;
const midpointZ = (point[1] + next[1]) / 2;
const isCameraFacing = midpointX > maxX - 0.82 || midpointZ < minZ + 0.82;
this.createWall(point, next, 0.2, outerMaterial,
  isCameraFacing ? 1.18 : WALL_HEIGHT);   // 面向鏡頭的牆:矮牆

矮牆而不是無牆,是刻意的:完全切掉會失去「這裡有一道牆」的邊界感,留一截矮牆,空間的圍合關係還在,視線又進得去。娃娃屋就是這樣做的,它有效了一百年。

房間名稱:用 Canvas 畫字,不用 3D 文字

「主臥」「客廳」這些標籤需要出現在地板上。Three.js 的 3D 文字(TextGeometry)需要載字型檔、產生幾何,對中文尤其重——一個字型檔就好幾 MB。

便宜的做法:開一個 2D canvas、用瀏覽器原生的 fillText 把中文畫上去、整張 canvas 變成貼圖(CanvasTexture)貼在一個平面上。瀏覽器本來就會畫中文,何必讓 3D 引擎重新學一次。

家具拖拉:一個 Raycaster 和一個看不見的平面

拖家具聽起來像需要物理引擎,實際上一行物理都沒有。核心是 Three.js 的 Raycaster 加一個與地板同高的無形平面:

typescript
private readonly raycaster = new THREE.Raycaster();
private readonly dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -FLOOR_Y);
private readonly dragPoint = new THREE.Vector3();

// pointermove 時:把螢幕座標的射線打到水平面上,交點就是家具的新位置
if (!this.raycaster.ray.intersectPlane(this.dragPlane, this.dragPoint)) return;
item.position.set(this.dragPoint.x, item.position.y, this.dragPoint.z);

按下時用射線選中家具、拖動時射線與水平面的交點就是新位置。家具不會飛起來也不會穿到地下,因為它的移動天生被鎖在那個平面上——約束不是算出來的,是幾何本身給的。

手機上還有一件事要處理:拖家具和轉視角都是「單指拖動」,兩個手勢會打架。解法是狀態互斥——按到家具就暫停 OrbitControls,放開再還回去。另外工具面板在小螢幕改成可收合、貼齊 safe-area,這部分沒什麼演算法,純粹是「在手機上自己用過一次」之後改的。

光:一盞太陽,一扇假窗

室內場景的光很容易做過頭。這個場景只有三件事:一盞暖色的 DirectionalLight 當太陽(開陰影)、一片冷色的 RectAreaLight 貼在窗的位置當天光、加一點環境光補底。

RectAreaLight 是被低估的工具:它是一個「發光的矩形」,天生就是窗戶的形狀。不用 HDRI、不用烘焙,一盞就讓室內有「白天」的感覺。

如果你也想做一個

按這個順序,每一步都有東西可以看:

一、先把平面圖翻成資料:輪廓多邊形、牆段、標籤。這步最無聊,但做完後面全是組裝。

二、Shape + ExtrudeGeometry 做地板,沿輪廓自動生外牆。到這裡就已經「像那間房子」了。

三、加娃娃屋剖面(面向鏡頭的牆降矮),沒有這步就看不進去。

四、標籤用 CanvasTexture,別碰 3D 文字。

五、家具拖拉用 Raycaster + 水平面,別碰物理引擎。

六、如果資料來自真實住宅,匿名化在資料模型層做,不要靠事後記得。

成品在這裡,可以直接走進去:3D 格局實驗室相關文章:把真實城市變成 3D 場景的管線

參考資料