摺紙轉場 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 flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="text-nowrap">
{{ t('shapeLabel') }}
</div>
<select
v-model="shape"
class="example-select w-full flex-1"
>
<option
v-for="option in shapeOptionList"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<base-checkbox
v-model="visible"
:label="checkboxLabel"
/>
</div>
<div class="card-slot flex flex-1 items-center justify-center">
<transition-paper :shape="shape">
<div
v-if="visible"
class="card max-w-[90vw] w-80 flex flex-col gap-1 rounded p-6"
>
<div class="text-base font-bold">
{{ t('title') }}
</div>
<p class="text-sm">
{{ t('body') }}
</p>
<p class="text-sm">
{{ t('body2') }}
</p>
</div>
</transition-paper>
</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 TransitionPaper from '../transition-paper.vue'
const { t } = useI18n()
const visible = ref(true)
const shape = ref<PaperShape>('plane')
const checkboxLabel = computed(() => (visible.value ? t('hide') : t('show')))
const shapeOptionList = computed<{ value: PaperShape; label: string }[]>(() => [
{ value: 'plane', label: t('plane') },
{ value: 'rocket', label: t('rocket') },
])
</script>
<style lang="sass" scoped>
.card-slot
// 內容消失時會整個從 DOM 移除,沒有這個固定高度版面會塌陷、上下內容跟著跳動
min-height: 18rem
.card
// snapdom 快照不含元素邊界外效果,box-shadow 會在摺紙瞬間消失,故不使用
background: light-dark(#FFFDF5, #3A3830)
border: 1px solid light-dark(#E4DFCB, #55524A)
</style>原理
摺紙效果分成兩層,外層攔截 Vue Transition 事件、把畫面交給替身,內層是一套獨立的摺紙運算引擎,兩者透過 init/enter/leave 溝通,概念與 VFX 轉場 同源。外層用 Vue 內建的 <transition> 搭配 :css="false",交由 JS 接管 Hook,enter 與 leave 各自驅動一個獨立的 Actor(摺紙替身)。
替身定位
Actor 是一顆 Canvas,用 CSS Anchor Positioning 疊在真實內容正上方,尺寸現場量測後放大 3 倍,留出環繞運鏡與紙飛機滑出畫面的餘裕。快照另外超取樣 2~3 倍,攤平時的文字才不會糊掉。
摺痕圖固定 3:4,內容比例任意,兩者無法同時不變形又邊界重合。因此貼圖先開一張紙張比例的畫布,內容以原生比例置中放置,鏡頭再以「內容那一格」對齊。
擺向會先挑過:比一比直放 3:4 與橫放 4:3 哪個接近內容比例,較接近的勝出,貼圖再轉九十度貼上去。橫式卡片(320×215)直接沿用直放時上下各留 24.8 % 空白,改為橫放後只剩 5.2 %。代價是橫放時紙張長軸原本水平、摺畢卻要直立,中間必然有一次九十度轉向,已併進既有的傾倒時間窗。
填不滿的留白攤平時仍裁掉,可見範圍與 DOM 完全一致;起摺後才淡入成紙色(paperColor,預設白)。先前留白是全程裁掉的,落在留白區的部位會整片消失——火箭的鼻錐與尾翼剛好都在那兩條帶子裡,摺出來就成了沒頭沒尾的一段箭身。
貼圖是先鋪滿紙色再疊上內容,留白因此不是透明而是實色。Canvas 沒畫到的地方會是 rgba(0, 0, 0, 0),雙線性取樣會把那個「透明的黑」混進內容邊界而浮出黑邊;先鋪底就沒有透明畫素可混。留白的判定改用 uv 範圍,整條路徑都不碰 alpha——中間曾改成逐畫素把透明處染色,但那趟 getImageData/putImageData 會反預乘再預乘 alpha,文字抗鋸齒用的半透明畫素首當其衝,內容邊緣反而更毛躁。
進出場流程
before-enter/before-leave 掛上 CSS Anchor Name,Actor 的 init(el) 用 snapdom 擷取快照、重建摺痕網格,此刻紙張攤平、與真實內容完全重合。換手那一格必須先畫好再藏起真實 DOM,否則會空白閃一下。
Leave 沿摺痕收攏成飛機,鏡頭同步拉開至環繞視角,摺畢隨即滑出畫面。Enter 從那一格接手,紙飛機滑入、鬆開摺痕攤平、鏡頭收回歸位,抵達終點再補跑幾格高精度求解確實鋪平紙面,最後真實 DOM 淡入。
摺紙運算引擎
摺紙本體是一套質點彈簧運動學求解器,作法與 Origami Simulator 同派。
- 摺痕圖編譯:讀入山摺、谷摺線段,吸附鄰近頂點、切割交點成平面圖,耳切三角化成可渲染網格
- 質點彈簧求解:軸向彈簧維持紙張不可拉伸,摺痕依二面角約束朝目標角收斂,並依手摺順序排入時間窗。刻意不過衝,紙摺過頭會穿透相鄰紙層,實測
easeOutBack會衝到目標角的 176 % - 插畫式紙張渲染:Babylon.js 自訂 shader,Half Lambert 明暗以極寬的過渡映射成柔和漸層。整套只做減法,最亮處保持快照原色,只往下疊陰影,文字才讀得清楚。陰影壓得很淡(最暗僅到 0.91),任何一面都不會明顯變黑。內容與留白之間取軟邊而非硬切,圓角之類的邊界才不會是鋸齒
- 疊層深度偏移:摺畢後紙層間距僅 0.005 世界單位,摺疊尾段每幀卻位移 0.087,兩層逐幀互相穿越就會閃爍。頂點 shader 依 uv 算一個固定的 NDC 深度偏移,會疊在一起的紙層必定來自紙上不同位置,前後關係就此釘死
動畫節奏
時長分配、憑空自轉與飛行都由摺痕排程推導,換一份摺法會自動跟著改。
- 步驟時長走「慢 → 快 → 慢」,最後一步再加重收尾。直覺作法是把
easeInOutCirc套在連續時間上,但該曲線頭尾導數為零,只有四步時第一步就吃掉 42.7 % 的時間。因此改成以「步驟」為單位取曲線斜率的倒數當權重,再正規化鋪滿整段時間 - 正轉、倒轉各有自己的時間結構。攤開若直接倒放摺疊的時間軸,會先慢慢攤最大那一摺、再草草收尾
- 整體姿態走憑空摺紙。真人得停下動作才能翻面,憑空摺紙沒這個限制,紙一邊摺一邊緩緩轉動,兩個自轉軸各佔一段錯落的時間窗,曲線用最柔的
easeInOutSine - 飛行採指數緩動,飛離一律用
easeInExpo先黏在原地再驟然抽離。位移以鏡頭座標給出,純粹遠離只會越縮越小卻始終留在視錐內 - 兩種造型各有飛行性格。紙飛機斜斜滑出右上方,飛入用
easeOutExpo,並刻意從畫面另一側進場——機頭在紙面上朝向固定,原路折返等於倒著飛,沿同一航向繼續前進才始終指著行進方向。火箭則垂直升空、邊震邊竄(約 10 Hz),落下照自由落體與回彈建構,觸地彈一下才停穩(回彈約 1.9 個世界單位)。幅度由回彈係數決定,不與其他曲線混合——先前試過把easeOutBounce與easeOutCubic混合來縮小幅度,那會讓曲線在該落地的那一刻碰不到 0(實測停在 0.18 就轉為緩慢下降),彈跳退化成一段平台,看起來像半空中煞停 - 火箭不翻滾、成品要停得住。沿用紙飛機的時間結構時,火箭在摺畢前 166 毫秒就開始位移、中途翻滾達 270 度,成品的可辨識視窗是負的。改成飛行性格各自持有時間結構後,摺畢到起飛之間留了約三成的時間,且只保留繞垂直軸的自轉
- 淡出、淡入的時間窗兩個方向分開。位移曲線不同,同一個時間點對應的高度差很多;共用一組時火箭在高度 10 就已九成不透明,而畫布可見的世界半高只有 6.9,它是以全不透明的姿態從畫布邊緣冒出來的
摺紙造型
shape 決定摺成什麼,摺痕圖與飛行性格會一起換。
- 紙飛機採寬翼滑翔機(basic glider)摺法,鈍鼻加寬翼,攤開時內容辨識度比尖頭紙鏢高
- 火箭採稜線摺法:角摺收尖的鼻錐、兩道縱摺收成的箭身(外側那道反摺回正面,兩側各多一道立起的稜)、尾端外張的尾翼
火箭的鼻錐陡度是關鍵。45 度角摺的頂角恆為 90 度、高度恆等於機身半寬,不管機身怎麼調都是一塊扁楔子。實測掃描鼻端寬度(佔最寬處的比例):45 度為 0.35~0.70、角摺線落點取 560 為 0.26~0.46、取 720 為 0.18~0.22;880 以上兩片角瓣嚴重交疊,剖面反而中段最寬、應變飆到 6e-2 不可用。
縱摺取九十度而非摺滿:摺滿時翻到背面的紙會在正面投影裡忽寬忽窄,箭身剖面上下起伏;收成方筒的剖面平整得多,正面也留一整面給內容。
兩款摺畢的邊長應變皆趨近 0(近乎完全剛性摺疊),有 vitest 測試把關。
原始碼
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;
}>()