數位魚缸:用 React Three Fiber 打造可以自己佈置的 3D 海底世界
2026年4月27日
實踐專案:數位魚缸(3D Aquarium)

為什麼做這個專案
想做一個「打開就能靜靜看一會兒」的網頁:深藍色的海底、慢慢游動的魚、從石頭旁邊冒出的氣泡。而且魚缸裡要放什麼,由使用者自己決定。
這是一個用 React Three Fiber 打造的 3D 數位魚缸,左上角的控制面板可以即時調整魚和造景的數量。
技術架構
| 項目 | 使用技術 |
|---|---|
| 框架 | Next.js + React + TypeScript |
| 3D | three.js + React Three Fiber + drei |
| 模型 | 金魚、斑馬魚、河豚三種 GLB 模型 |
| 部署 | Vercel |
主要功能
- 生態配比:三種魚各自可以調整 0~10 隻
- 造景:用滑桿增減石頭(0~5 顆)和水草(0~10 叢),新增的造景放在預先設計好的位置,數量變多也不會破壞構圖
- 氣泡:從石頭附近持續冒出上升的氣泡
- 深海氛圍:深藍色背景加上霧效,遠處逐漸消失在海裡,看不到邊界
- 自由視角:可以拖曳旋轉、縮放,從不同角度看魚缸
實作細節
1. 同一個模型,複製出很多條魚
同一種魚要出現好幾隻時,不能直接重複使用同一個模型物件:three.js 的物件同時只能存在場景裡的一個位置,帶骨架的模型共用時還會互相影響。這裡用 SkeletonUtils.clone 為每一條魚做一份獨立的深度複製:
const { scene: goldfishScene } = useGLTF("/models/Goldfish.glb");
// ...
const clonedScene = useMemo(() => SkeletonUtils.clone(modelScene), [modelScene]);模型只下載一次,每條魚各自擁有自己的副本,互不干擾。
2. 每條魚都有自己的游法
每條魚有一個游動方向,每一幀繞著垂直軸做一點點正弦擺動,看起來就像在左右搖擺著前進。碰到魚缸邊界時,把該方向的分量反轉,魚就會轉身游回來:
const sway = Math.sin(time * 3 + movement.timeOffset) * movement.turnRate;
direction.current.applyAxisAngle(new THREE.Vector3(0, 1, 0), sway);
ref.current.position.add(direction.current.clone().multiplyScalar(movement.speed));
if (Math.abs(ref.current.position.x) > limit) direction.current.x *= -1;
if (Math.abs(ref.current.position.z) > limit) direction.current.z *= -1;三種魚的速度和轉向幅度各不相同:斑馬魚游得快、轉得急;河豚慢慢地飄;金魚介於兩者之間。每條魚再加上隨機的時間偏移,就不會整群同步擺動。
3. 氣泡用 InstancedMesh
氣泡數量多、又每一幀都在動。如果每顆都是獨立的網格,效能會很差,所以用 instancedMesh 一次畫出所有氣泡,在 useFrame 裡更新每一顆的位置。
穩定性:避免 WebGL 白屏
開發時遇到最麻煩的問題,是在 Mac 的高解析度螢幕上,3D 畫面偶爾會突然整個變白:瀏覽器因為 GPU 負擔太重,丟失了 WebGL context。
最後用了幾個方法降低 GPU 的負擔:
- 固定解析度倍率(
dpr={1}):不跟著 Retina 螢幕放大到 2~3 倍 - 關閉反鋸齒(
antialias: false):省下大量顯示記憶體 - 改用最基本的陰影(
BasicShadowMap):保留陰影,但計算成本最低 - 低解析度環境貼圖:保留魚鱗和石頭的反光,但只用 32 像素的環境貼圖
畫面細緻度犧牲了一點,換來的是在各種裝置上都能穩定執行。
小結
3D 網頁很容易越做越華麗,但這個專案讓我學到:能穩定跑起來,比畫面多細緻更重要。使用者不會注意到少了反鋸齒,但一定會注意到畫面突然變白。