前言:從一個想在瀏覽器裡逛教堂的念頭開始
如果有一天,你可以坐在自己房間,不用搭飛機、不用排隊,只是打開瀏覽器輸入一個網址,就站在法國奧爾良的大教堂中央,抬頭是哥德式穹頂,轉頭又被聖托里尼純白教堂的光影包圍,最後一個切換站到聖若望大教堂主殿的正中央,那會是怎樣的感覺?
這個念頭一開始只是一個很單純的好奇。如果把 Flickr 上那些精心拍攝的 360° 教堂全景,變成一個「真的可以逛」的網頁,而不是躺在硬碟裡的一堆 JPG,會不會是一件很有趣的事?於是,這個用 A-Frame 和 Vite 做出來的 360 教堂圖庫,就這樣誕生了。它沒有複雜的導航,也沒有花俏的 UI,只有三座教堂:法國的 Cathédrale Sainte-Croix d’Orléans、聖托里尼的 Holy Metropolitan Church of Hypapanti of Thira,以及 Cathedral of Saint John the Baptist 的室內空間,安安靜靜地等你走進來。對我來說,這比較像是在幫自己準備一個「隨時可以回去逛教堂」的入口,而不是做一個炫技的 WebGL 展示。
專案原始碼放在 GitHub 上,網址是:https://github.com/calpa/aframe-360-demo。線上 Demo 則部署在 Cloudflare Pages,上線版本可以在這裡直接打開體驗:https://aframe-360-demo.pages.dev/。這篇文章想做的事情,是把這個小專案從「初次打開的體驗」一路拆解到「底層是怎麼運作的」,再順便談談我為什麼會選這樣的技術棧,以及它還可以往哪幾個方向延伸。
初次體驗:被丟進一張 360° 照片裡

打開這個 360 教堂圖庫的頁面時,我的第一個感覺其實不是「技術好炫」,而是那種很直白的臨場感。你真的就被丟進一張照片裡,抬頭是穹頂,低頭是地板,左右轉頭都是細節。這種感覺有點像第一次用 Google Street View 逛街,只是這次不是在路上找門牌號碼,而是在教堂裡抬頭看穹頂、看牆上的畫和光線。
整個體驗從使用者角度來看,非常不需要說明書。畫面中央只有一個游標,前方懸浮著三條黑色橫條,上面寫著三座教堂的名字。你不需要知道什麼是 WebXR,也不需要理解 A-Frame 是什麼框架,只要把視線慢慢移過去,把游標停在其中一條橫條上,場景就會淡出,再淡入到另一個地方。和一般網站那種「點擊某個按鈕、跳到另一個頁面」相比,這種操作更接近「你就是在場景裡,只是選擇下一個要被瞬移到哪裡」的思路。
技術棧與場景結構:用 A-Frame 把全景圖變成一個世界
如果從工程師視角把這個 Demo 拆開來看,會發現它的技術棧其實刻意保持精簡,但每一層都踩在現在前端開發和 WebXR 的交匯點上。最底層還是 WebGL 負責把 3D 畫出來,不過我並沒有直接去寫 shader 或是操作矩陣,而是交給 A-Frame 幫忙封裝。它提供了 <a-scene>、<a-sky> 這類語意化的標籤,讓你可以用比較接近 HTML 的方式描述場景,而不是在一堆低階 API 裡打轉。
所謂「站在照片裡」,實際上就是把一張等距柱狀投影的 2:1 全景圖,貼到一個巨大的球體內側,然後把相機放在球心,讓使用者可以轉頭改變視角。A-Frame 幫你把這件事抽象成一個 <a-sky> 元素,只要指定對應的圖片路徑,它就會把貼圖展開在球體內側,處理好投影與接縫問題。只要你拿到的是正確格式的 equirectangular 圖片,解像度夠高、構圖完整,丟給 <a-sky> 之後,整個世界就成立了。
對第一次碰 WebXR 的前端工程師來說,這是一個非常好理解的切入點。你不需要一開始就設計完整的 3D 關卡,也不需要搞懂各種座標系統或物理引擎,只要先學會怎樣把一張全景圖「貼」到一個 sky sphere 上,就已經走了一大半的路。剩下的事情,更多是設計層面:你想讓使用者站在哪裡?你希望他第一眼看到的是什麼?你希望他轉頭時會有什麼發現?當這些問題都開始變得有趣時,才是這個小專案真正開始長出靈魂的時候。
在實際程式碼裡,這一層骨架長得大概是這樣:先在 <a-assets> 裡定義三張全景圖,然後用一個 <a-sky> 元素把它們貼在球體內側,預設載入其中一張作為起點。
<a-assets>
<!-- Local cathedral panoramas (placed in /public) -->
<img id="orleans" src="/orleans.jpg" />
<img id="hypapanti" src="/hypapanti-thira.jpg" />
<img id="saint-john" src="/saint-john-baptist.jpg" />
<audio
id="click-sound"
crossorigin="anonymous"
src="https://cdn.aframe.io/360-image-gallery-boilerplate/audio/click.ogg"
></audio>
</a-assets>
<a-sky
id="image-360"
radius="10"
src="#orleans"
animation__fade="property: components.material.material.color; type: color; from: #FFF; to: #000; dur: 300; startEvents: fade"
animation__fadeback="property: components.material.material.color; type: color; from: #000; to: #FFF; dur: 300; startEvents: animationcomplete__fade"
></a-sky>互動設計:三條橫條背後的事件流水線
這個專案在互動上的選擇也刻意保持節制。它沒有把介面做得像一個 3D 展覽館,而是把所有互動壓縮成三條橫條。每一條只是單純的文字,寫著教堂的名稱,沒有縮圖、沒有卡片陰影,也沒有多餘的裝飾。當游標移過去時,顏色變化和 hover 狀態會提醒你「現在可以點」。當你真的點下去,就會觸發一整條事件流水線。
這條事件線從按鈕開始。按鈕接住使用者的 click 之後,不是在同一個地方寫完所有邏輯,而是透過 A-Frame 的 component 和 proxy-event 把事件轉發到天空元素。<a-sky> 收到事件後,先啟動一段淡出動畫,把整個畫面慢慢推向黑色,給使用者一個過場。等淡出動畫完成,再切換 <a-sky> 使用的圖片資源,把舊場景換成另一座教堂,最後啟動另一段淡入動畫,讓新的場景從黑色裡慢慢浮現出來。
在這個過程裡,開發者幾乎沒有手寫大量的 JavaScript 程式碼,而是透過 component 系統,把事件流拆成幾個小責任,分散在 HTML 標籤和屬性上。整個互動更像是在描述「某個事件發生時,這個元素要做什麼動畫,什麼時候切換貼圖」,而不是在一個大函式裡手動管理狀態與 DOM 操作。這樣的寫法很符合 A-Frame 一貫的宣告式風格,也讓整個場景在視覺和邏輯上比較容易維護。
在 index.html 裡,我用一個小小的 link-active-state component 搭配 template,把這三條橫條的行為組合起來。上半部先在 <head> 裡註冊 component,負責處理「目前選中的按鈕要變亮」這件事:
<script>
AFRAME.registerComponent("link-active-state", {
init: function () {
var el = this.el;
var sceneEl = el.sceneEl;
if (!sceneEl.components || !sceneEl.hasLoaded) {
var self = this;
sceneEl.addEventListener(
"loaded",
function () {
self.init();
},
{ once: true }
);
return;
}
if (!sceneEl.selectedLink) {
sceneEl.selectedLink = el;
el.setAttribute("material", "color", "#333");
}
el.addEventListener("click", function () {
var previous = sceneEl.selectedLink;
if (previous && previous !== el) {
previous.setAttribute("material", "color", "#111");
}
el.setAttribute("material", "color", "#333");
sceneEl.selectedLink = el;
});
},
});
</script>下半部則透過 aframe-template-component 產生三條文字橫條。每一條其實都是同一個 template,只是換了 data-src 與 data-label:
<script id="link" type="text/html">
<a-entity
class="link"
geometry="primitive: plane; height: 0.5; width: 5.2"
material="shader: flat; color: #111"
link-active-state
sound="on: click; src: #click-sound"
event-set__mouseenter="scale: 1.1 1.1 1"
event-set__mouseleave="scale: 1 1 1"
event-set__click="_target: #image-360; _delay: 300; material.src: ${src}"
proxy-event="event: click; to: #image-360; as: fade"
>
<a-text
value="${label}"
align="center"
color="#FFF"
width="5"
position="0 0 0.01"
></a-text>
</a-entity>
</script>
<a-entity id="links" position="0 -0.8 -4">
<a-entity
template="src: #link"
data-src="#orleans"
data-label="Cathédrale Sainte-Croix d'Orléans 360° Panorama"
position="0 0 0"
></a-entity>
<a-entity
template="src: #link"
data-src="#hypapanti"
data-label="Holy Metropolitan Church of Hypapanti of Thira (interior)"
position="0 -0.7 0"
></a-entity>
<a-entity
template="src: #link"
data-src="#saint-john"
data-label="Cathedral of Saint John the Baptist (interior)"
position="0 -1.4 0"
></a-entity>
</a-entity>內容本身其實非常單純。三張圖都來自 Flickr 的 equirectangular pool,各自代表一座教堂的室內空間。這些照片的共同特徵,是解像度高、構圖完整,而且已經處理成正確的等距柱狀 360 全景,直接丟給 <a-sky> 使用,就能穩定貼合球體,不會出現明顯的接縫或變形。從體驗角度來看,你進去的不是一個「遊戲關卡」,而比較像是「被瞬移到某個真實空間」,短暫停留幾分鐘,感受一下那個空間的光線和比例,然後再切換到下一個地方。
工程化與部署:讓 Demo 走進真實世界
在工程化這一塊,專案選擇了 Vite 加上 TypeScript 這組在這幾年幾乎變成預設選項的組合。雖然 src/main.ts 目前只做了一件看起來有點無聊的事:匯入一個 CSS 檔案,但這個結構已經幫未來要擴充的路打通了。如果之後想加更多邏輯、抽出 utility 函式、甚至引入狀態管理工具,都可以自然長在這個 TypeScript 專案上,而不需要推倒重來。Vite 的開發體驗也讓這種「邊調整場景、邊在瀏覽器看效果」的迭代變得很順手。改一個 animation 屬性、調整一個按鈕的位置,存檔之後幾乎即時反映在畫面中,整個開發節奏可以維持在一種非常流暢的狀態,久而久之也就變成我日常開發 Web 小實驗的預設節拍。
部署端我選擇使用 Cloudflare Pages 做 CI/CD。這個專案本質上就是一組靜態資源,所以直接接上 GitHub repo,設定好 build 指令和輸出目錄,每次 push 之後就會自動 build 和發佈。Cloudflare 幫你處理 SSL、快取和全球 CDN,對這種偏展示性質的 WebXR 小專案來說,很難找到更輕量又穩定的方案。對開發者來說,也少了很多基礎設施層的負擔,可以把注意力留在體驗本身。
如果把這個 360 教堂圖庫看成一個最小可行產品,其實已經具備了很多可以延伸的軸線。一條是內容軸。現在的版本只放了三座教堂,但只要你手上有合適的全景圖,這個架構完全可以拿來展示自己的作品、展覽、咖啡店,或者任何你想讓別人在網頁裡「實際站進去」的空間。對攝影師來說,它可以是一個作品集;對實體空間設計者來說,它可以是一種線上導覽;對做活動的人來說,它也可以變成一個「活動結束之後,還能回去看場地」的紀錄方式。
另一條是互動軸。現在的 Demo 只有場景切換和淡入淡出,如果在場景裡加上熱點和文字說明,讓使用者可以在教堂裡點開某幅畫的解說,或者在某個角落聽到音訊導覽,就已經很接近真正的虛擬展覽。如果再往前一步,為每一個場景設計一條導覽路線,限定幾個停留點,甚至可以做成線上實境導覽,讓使用者跟著一條故事線走完整個空間。
在技術軸上,則可以繼續把現有的 HTML 元素和 component 封裝成更通用的模組,變成「一行 import 就能用的 360 圖庫元件」。對沒有時間碰底層 WebGL 的開發者來說,只要提供一組全景圖和一些設定,就能快速搭建自己的 WebXR 體驗。從這個角度看,現在的專案比較像是一個手工搭好的範例,而未來有機會長成一個可以被重複使用的工具箱。
對前端工程師來說,這樣的小專案很適合作為技術實驗場。它的規模足夠小,不會壓垮一天的開發時間,但每一層又都踩在當代前端和 WebXR 的交會點上。你在其中做的每一個微調,最後都能回饋到未來更大型的產品裡。你可以在這裡嘗試新的動畫風格、新的互動模式、新的部署流程,失敗的成本很低,但學到的東西卻可以帶很久。
結語:從一個小 Demo 到下一個起點
回頭看這個 360 教堂圖庫,它的功能其實不複雜,整個程式碼量也不算多,卻完整涵蓋了我想練習的一切:用 A-Frame 把一張張全景圖變成可以實際「走進去」的場景,用 component 系統把互動拆成幾條清楚的事件線,用 Vite 和 TypeScript 把一個 WebXR Demo 放進現代前端開發流程,再用 Cloudflare Pages 把這些東西穩定地放上世界。對我來說,這不是一個畫下句點的專案,而是一個很清楚的逗點。
之後每一次當我想到新的 WebXR 想法,或者想把某個「也許可以這樣逛」變成現實時,我都可以從這個 360 教堂圖庫出發,慢慢把它長成展覽平台、教學工具,或是某個大型產品裡的一小塊實驗空間。而在這段過程裡,最重要的也許不是技術本身,而是那個一直在背後的問題:如果我們可以讓別人在瀏覽器裡真正站進某個空間,那個空間,會是什麼?對現在的我來說,這三座教堂只是那串答案裡很小的一段逗點,真正的句子還在往後延伸。
如果你有 AI 專案、網站開發或技術整合需求,或正在為團隊尋找工程師,歡迎來信交流: partner@calpa.me
歡迎訂閱 Calpa 的頻道,一同將想像力化為可能:
