摺紙轉場 transition
將元素過度變成折紙藝術
大家有玩過 Switch 的遊戲「紙片瑪利歐:摺紙國王」嗎?
整體風格、美術設計、音樂與遊戲性都極具巧思
真的很讚,強力推薦大家玩玩看!ദ്ദി ˉ͈̀꒳ˉ͈́ )
我一直很喜歡裡面的摺紙兵登場的方式,所以將效果實作成過度元件了!( ´ ▽ ` )ノ
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| 質點彈簧系統 | 把物體離散成質點並以彈簧相連,透過反覆鬆弛約束模擬布料、繩索等柔性材質 |
| Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
| Dynamic Texture | 可動態修改的材質,常用於即時產生圖案或文字 |
| Vue Transition | 內建元件,可以處理單個項目的進出動畫 |
| CSS Anchor Positioning | CSS 新功能,允許元素相對於其他元素(錨點)進行定位 |
使用範例
基本用法
用法與 Vue Transition 相同,包住一段被 v-if 控制顯示的內容即可。

擅長球類是地瓜球,習慣一打十
查看範例原始碼
<template>
<div class="example-wrap w-full flex flex-col gap-4">
<div
class="example-ctrl grid grid-cols-4 items-center gap-2"
:class="{ 'pointer-events-none': isTransitioning }"
>
<select-stepper
v-model="shape"
:label="t('shapeLabel')"
:options="shapeOptionList"
:option-label-map="shapeLabelMap"
class="col-span-4"
/>
<div
class="col-span-4 border rounded-lg duration-300"
:class="{
'cursor-not-allowed opacity-30': isTransitioning,
'cursor-pointer': !isTransitioning,
}"
>
<base-checkbox
v-model="visible"
:label="t('show')"
class="w-full cursor-pointer p-4"
/>
</div>
</div>
<div
class="card-slot flex flex-1 items-center justify-center"
:class="{ 'pointer-events-none': isTransitioning }"
>
<transition-origami
:shape="shape"
@enter="isTransitioning = true"
@leave="isTransitioning = true"
@after-enter="isTransitioning = false"
@after-leave="isTransitioning = false"
>
<div
v-if="visible"
class="card max-w-[90vw] w-80 flex flex-col items-center gap-2 p-6"
>
<img
src="/low/profile.webp"
alt=""
class="mb-4 h-[180px] w-[180px] overflow-hidden border-4 border-white rounded-full shadow-xl"
>
<div class="text-xl">
{{ t('codfishName') }}
</div>
<p class="text-center text-sm">
{{ t('body') }}
</p>
</div>
</transition-origami>
</div>
</div>
</template>
<script setup lang="ts">
import type { PaperShape } from '../use-paper-solver'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import SelectStepper from '../../select-stepper.vue'
import TransitionOrigami from '../transition-origami.vue'
const { t } = useI18n()
const isTransitioning = ref(false)
const visible = ref(true)
const shape = ref<PaperShape>('plane')
const shapeOptionList: PaperShape[] = ['plane', 'rocket', 'flapping-bird']
const shapeLabelMap = computed<Record<PaperShape, string>>(() => ({
'plane': t('plane'),
'rocket': t('rocket'),
'flapping-bird': t('bird'),
}))
</script>
<style lang="sass" scoped>
.card-slot
// 內容消失時會整個從 DOM 移除,沒有這個固定高度版面會塌陷、上下內容跟著跳動
min-height: 26rem
.card
// snapdom 快照不含元素邊界外效果,box-shadow 會在摺紙瞬間消失,故不使用
background: light-dark(#FFFDF5, #3A3830)
border: 1px solid light-dark(#E4DFCB, #55524A)
</style>原理
摺紙效果分兩層,外層攔截 Vue Transition 事件、把畫面交給替身,內層是一套獨立的摺紙運算引擎,兩者透過 init/enter/leave 溝通,概念與 VFX 轉場 同源。
替身定位
Actor 是一顆 Canvas,用 CSS Anchor Positioning 疊在真實內容正上方,尺寸現場量測後放大 6 倍,留出環繞運鏡與飛出畫面的餘裕,快照另外超取樣 2~3 倍。
錨點名稱與定位樣式全寫在行內,不放進全域樣式,免得與其他元件的同名類名在正式版互相覆蓋;瀏覽器不支援錨點定位時,改用現場量測的座標。
摺痕圖固定 3:4,貼圖先鋪滿紙色再把內容以原生比例置中疊上,鏡頭以「內容那一格」對齊;擺向取直放 3:4 與橫放 4:3 之中較接近內容比例者。留白攤平時裁掉,可見範圍與 DOM 完全一致,起摺後才淡入成紙色(paperColor)。
進出場流程
before-enter/before-leave 掛上 CSS Anchor Name,init(el) 用 snapdom 擷取快照、重建摺痕網格,此刻紙張攤平、與真實內容完全重合。
Leave 沿摺痕收攏成型,鏡頭拉開至環繞視角,摺畢滑出畫面。Enter 從那一格接手,滑入、攤平、鏡頭歸位,最後真實 DOM 淡入。
摺紙運算引擎
質點彈簧運動學求解器,作法與 Origami Simulator 同派。
- 摺痕圖編譯:讀入山摺、谷摺線段,吸附鄰近頂點、切割交點成平面圖,耳切三角化成可渲染網格
- 質點彈簧求解:軸向彈簧維持紙張不可拉伸,摺痕依二面角約束朝目標角收斂,並依手摺順序排入時間窗
- 插畫式紙張渲染:Babylon.js 自訂 shader,Half Lambert 明暗映射成柔和漸層。整套只做減法,最亮處保持快照原色,文字才讀得清楚
- 疊層深度偏移:頂點 shader 依 uv 算固定的 NDC 深度偏移,會疊在一起的紙層必定來自紙上不同位置,前後關係就此釘死
動畫節奏
時長分配、憑空自轉與飛行都由摺痕排程推導,換一份摺法會自動跟著改。
- 步驟時長走「慢 → 快 → 慢」,以「步驟」為單位取
easeInOutCirc斜率的倒數當權重,正規化鋪滿整段時間 - 正轉、倒轉各有自己的時間結構
- 整體姿態走憑空摺紙,紙一邊摺一邊緩緩轉動,兩個自轉軸各佔一段錯落的時間窗
- 飛行採指數緩動,飛離用
easeInExpo先黏在原地再驟然抽離,位移以鏡頭座標給出 - 每種造型各有飛行性格。紙飛機斜滑出右上方、從畫面另一側進場;火箭垂直升空、邊震邊竄(約 10 Hz),落下照自由落體與回彈建構;紙鶴由左往右橫渡,深度位移與翻滾一律為零
- 淡出、淡入的時間窗兩個方向分開
原始碼
API
Props
interface Props {
/** 初次渲染是否播放進場動畫
* @default false
*/
appear?: boolean;
/** 單趟轉場的總時長(摺疊/攤開加上飛行),單位 ms
* @default 2600
*/
duration?: number;
/** 摺紙造型
* @default 'plane'
*/
shape?: PaperShape;
/** 內容填不滿紙面時,補上的紙色(十六進位)
* @default '#FFFFFF'
*/
paperColor?: string;
}Emits
const emit = defineEmits<{
(e: 'enter'): void;
(e: 'afterEnter'): void;
(e: 'leave'): void;
(e: 'afterLeave'): void;
}>()Slots
defineSlots<{
default?: () => unknown;
}>()