在瀏覽器裡逛教堂:用 A-Frame 打造三座 360° 聖殿

作者: Calpa Liu
字數:3274
出版:2025 年 12 月 2 日
分類: 前端開發 WebXR JavaScript A-FrameSide Project
這個小專案把三座教堂的 360° 全景照片變成一個可以在瀏覽器裡自由切換的 WebXR 體驗。從內容到技術棧,再到工程化部署,本文記錄我如何用 A-Frame、Vite 和 Cloudflare Pages,做出一個既簡單又有臨場感的 360 教堂圖庫。

前言:從一個想在瀏覽器裡逛教堂的念頭開始

如果有一天,你可以坐在自己房間,不用搭飛機、不用排隊,只是打開瀏覽器輸入一個網址,就站在法國奧爾良的大教堂中央,抬頭是哥德式穹頂,轉頭又被聖托里尼純白教堂的光影包圍,最後一個切換站到聖若望大教堂主殿的正中央,那會是怎樣的感覺?

這個念頭一開始只是一個很單純的好奇。如果把 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 教堂圖庫 DEMO
360 教堂圖庫 DEMO

打開這個 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-srcdata-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 教堂圖庫出發,慢慢把它長成展覽平台、教學工具,或是某個大型產品裡的一小塊實驗空間。而在這段過程裡,最重要的也許不是技術本身,而是那個一直在背後的問題:如果我們可以讓別人在瀏覽器裡真正站進某個空間,那個空間,會是什麼?對現在的我來說,這三座教堂只是那串答案裡很小的一段逗點,真正的句子還在往後延伸。

前端開發 WebXR JavaScript A-Frame Side Project
關於 Calpa

Calpa 擅長使用 TypeScriptReact.jsVue.js 建立 Responsive Website。

他積極參與開源社區,曾在 2019 年的香港開源大會上擔任講者,提供工作經驗和見解。此外,他也在 GitHub 上公開分享個人博客程式碼,已獲得超過 300 顆星星和 60 個分支的支持。

他熱愛學習新技術,並樂意分享經驗。他相信,唯有不斷學習才能跟上快速演變的技術環境。

熱門文章

最新文章

Vibe Coding Idea Lab:AI 共創 × 靈感實作 × MVP 引爆場
精選來自超過 700 位學員、Discord 社群與線上共創活動的 idea。我們用 AI 快速落實創意,用 prompt 引爆行動,用共創測試價值。現有 ${ideas.length} 個 idea,歡迎隨緣分享。
圖片管理中心
管理圖片資源
IP 查詢
快速查詢和定位 IP 地址的地理位置和相關信息
Python 運行器
無需後端、無需登入,只需打開瀏覽器即可運行 Python 代碼(由 Pyodide 提供支持)
封面圖生成器
自動創建適合各種平台的文章封面圖
原作(青山剛昌)產生器
一鍵創建原作(青山剛昌)的封面圖
日本色彩
探索和使用傳統日本色彩
部落格內容洞察儀表板
以視覺化儀表板方式追蹤文章成效、分享熱度與分類分布,協助創作者掌握內容表現。
蒙特卡羅估算 π
使用蒙特卡羅方法演示 π 值的估算過程
LLM
使用 LLM 模型進行聊天
活動圖生成器
一鍵創建活動的封面圖
Wagmi Card
一鍵創建 Wagmi 的封面圖
Facebook Quote
Facebook Quote
Music Macro Language (MML) Studio
用程式語法編寫旋律,用音符構築想像
Blurhash
一鍵創建 Blurhash
文字分類器
使用 MediaPipe TextClassifier 分類文字
前端工程師免費工具資源
前端工程師免費工具資源
後端工程師免費工具資源
後端工程師免費工具資源
全端工程師免費工具資源
全端工程師免費工具資源
Web3 工程師免費工具資源
Web3 工程師免費工具資源
紫微斗數排盤系統|結合 AI 的命盤性格與事業財務分析生成器
紫微斗數排盤工具,輸入生日與時辰,自動生成完整命盤分析提示(Prompt)。結合最專業紫微理論與 AI 助力,助你深入解析性格、事業、財務與人際課題。免費使用,適合命理師及紫微愛好者。
PixAI Prompt 組合器|快速打造可用於 AI 繪圖的語言拼圖
使用 PixAI 卻不會寫 prompt?這個工具幫你一鍵組裝角色、表情、風格語彙,輸出高品質繪圖提示語句(Prompt),可直接貼入 PixAI 使用。適合插畫師、創作者、AI 新手與 VTuber 角色開發者。
你擁有的 .eth 是誰?一鍵查出 ENS 背後地址
只要輸入一個 ENS 名稱,我們就能幫你查出它指向哪個以太坊地址,還能看到頭像。如果你常常看到 .eth 名稱卻不知道誰在背後,這個小工具幫得上忙。
水果切割圖生成器