Skip to content

摺紙轉場 transition

將元素過度變成折紙藝術


大家有玩過 Switch 的遊戲「紙片瑪利歐:摺紙國王」嗎?

整體風格、美術設計、音樂與遊戲性都極具巧思

真的很讚,強力推薦大家玩玩看!ദ്ദി ˉ͈̀꒳ˉ͈́ )

我一直很喜歡裡面的摺紙兵登場的方式,所以將效果實作成過度元件了!( ´ ▽ ` )ノ

技術關鍵字

名稱描述
Babylon.js3D 引擎
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
質點彈簧系統把物體離散成質點並以彈簧相連,透過反覆鬆弛約束模擬布料、繩索等柔性材質
Canvas Shader使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難
Dynamic Texture可動態修改的材質,常用於即時產生圖案或文字
Vue Transition內建元件,可以處理單個項目的進出動畫
CSS Anchor PositioningCSS 新功能,允許元素相對於其他元素(錨點)進行定位

使用範例

基本用法

用法與 Vue Transition 相同,包住一段被 v-if 控制顯示的內容即可。

摺成
查看範例原始碼
vue
<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 事件、把畫面交給替身,內層是一套獨立的摺紙運算引擎,兩者透過 initenterleave 溝通,概念與 VFX 轉場 同源。外層用 Vue 內建的 <transition> 搭配 :css="false",交由 JS 接管 Hook,enterleave 各自驅動一個獨立的 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——中間曾改成逐畫素把透明處染色,但那趟 getImageDataputImageData 會反預乘再預乘 alpha,文字抗鋸齒用的半透明畫素首當其衝,內容邊緣反而更毛躁。

進出場流程

before-enterbefore-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 個世界單位)。幅度由回彈係數決定,不與其他曲線混合——先前試過把 easeOutBounceeaseOutCubic 混合來縮小幅度,那會讓曲線在該落地的那一刻碰不到 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;
}>()

v0.77.0