Skip to content
歡迎來票選你最喜歡的元件! 也可以告訴我任何你想說的話喔!(*´∀`)~♥

軌道單選器 radio

拋出握把球,球會受到各個選項黑洞的引力影響,直到被捕獲。

也可以直接點選選項,球會自動飛過去。

技術關鍵字

名稱 描述
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊
物理模擬模擬真實世界物理現象,如重力、碰撞、速度等物理效果
JS 動畫基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等

使用範例

基本用法

不知道喝甚麼?放手交給物理定律吧 (ノ>∀<)ノ ⌒*

拋出握把球,球會受到各個選項黑洞的引力影響,直到被捕獲。

珍珠奶茶
紅茶
可可
柳橙汁
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6 py-10">
    <div class="flex justify-center">
      <radio-orbital
        v-model="selected"
        :options="options"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import RadioOrbital from '../radio-orbital.vue'

const { t } = useI18n()

const options = computed(() => [
  { label: t('bubbleTea'), value: 'bubble-tea' },
  { label: t('blackTea'), value: 'black-tea' },
  { label: t('cocoa'), value: 'cocoa' },
  { label: t('orangeJuice'), value: 'orange-juice' },
])

const selected = ref('bubble-tea')
</script>

原理

每個選項就像宇宙中的黑洞,對球施加引力。

球被拋出後,根據牛頓萬有引力公式計算加速度,同時透過阻尼係數模擬空氣阻力讓球逐漸減速。


路人:「太空甚麼時候有空氣阻力了?」

鱈魚:「...我說有就是有 ( ・ิω・ิ)」


當球靠近某個黑洞且速度低於閾值時,就會被捕獲,觸發選取事件。

具體流程如下:

  1. 使用 Pointer Events 追蹤拖曳軌跡,透過滑動時間窗口計算釋放瞬間的速度。
  2. 釋放後進入物理模擬,每幀計算所有黑洞對球的引力加速度。
  3. 加入阻尼讓球逐漸減速,碰到視窗邊界則反彈。
  4. 球進入捕捉範圍且速度夠低時,吸入黑洞完成選取。

重力軟化(Softening)

在真實的 N 體模擬中,當兩個物體距離趨近於零時,引力公式中的分母也趨近於零,導致加速度趨近無限大。

重力軟化參數就是在分母加上一個小值,避免數值爆炸。∠( ᐛ 」∠)_

更精準的模擬

剛分享時,有大大提到可以使用 Runge-Kutta 取代 Euler 法求解。

研究了一下,真是讓我長知識了。ヾ(◍'౪`◍)ノ゙

使用 Euler 法,球在黑洞之間移動時會出現不自然的彈射,改用 RK4 後軌跡明顯更平滑,不需要刻意降低重力或提高阻尼來掩蓋數值誤差。

後來又更進一步,將 RK4 換成了記憶體更節省的 Low-Storage Runge-Kutta 4.5(LSRK 4.5)

精度與 RK4 相同(同為 4 階,「4.5」的 4 是階數、5 是階段數),但穩定域更寬、每個積分步只需一個暫存向量。

沒想到做小廢物元件還能長知識,真是感謝大大們的回饋!(*´∀`)~♥

使用相同的初始條件、相同的黑洞配置,仔細觀察球的軌跡差異。

Euler
Euler Sub-step
RK4
LSRK 4.5

不難得出以下結論:

  • Euler 的球會因為數值誤差出現異常加速,也會在接近零點處高速震盪,軌跡極不穩定。
  • Euler Sub-step 把每幀拆成 4 個子步長,局部誤差被壓低,軌跡明顯比 Euler 穩定,但仍劣於 RK4。
  • RK4 則能維持平滑穩定的軌道。
  • LSRK 4.5 與 RK4 精度相同,所以軌跡接近,但更穩定且記憶體用量更低。

以下讓我們來分享一下 Runge-Kutta 與 Euler 法的差異。

從 Euler 到 Runge-Kutta

物理模擬的本質是一個常微分方程(ODE)初值問題:已知球在某一刻的位置和速度,根據力學定律推算下一刻的狀態。

電腦沒辦法處理連續的時間,所以我們把時間切成一小段一小段的 dt(每一幀的間隔),用數值積分來逼近真實的物理軌跡。

核心方程可以寫成:

drdt=v,dvdt=a(r)

其中 r 是位置、 v 是速度、 a(r) 是位置相關的加速度(引力)。

Euler 法(一階)

最直覺的做法,用當前這一刻的斜率,直接往前跨一步:

vn+1=vn+a(rn)Δtrn+1=rn+vnΔt

翻譯成程式碼就是:

ts
velocity += acceleration * dt
position += velocity * dt

Euler 法的局部截斷誤差O(Δt2) ,全局誤差為 O(Δt) ,也就是說只要 dt 不夠小,誤差就會快速累積。

為什麼 Euler 在這裡會出問題

引力公式為:

a=Gr2+ϵ2

當球靠近黑洞, r 變小,加速度會急遽增大。Euler 只在起點取一次樣,導致得出一個巨大的加速度,接著直接灌進速度

看起來就是球多憑空多了個加速度,看起來像是被射到黑洞的另一邊。

最終就在拉回來,射出去,再拉回來,再射過去之間,反覆彈射。

這就是所謂的彈弓效應(Slingshot Effect),也是數值不穩定的典型症狀。

想像你要畫一條急彎的曲線,但只准你在起點看一眼方向,然後閉著眼走一大步,你一定會偏離曲線暴衝出去。=͟͟͞͞( •̀д•́)

Euler 次步長(Sub-stepping)

Euler 法的誤差來源很直觀:dt 越大,每一步走得越偏。

最直接的改進方式是把每一幀的 dt 拆成 N 個子步長(sub-step),每個子步長執行一次標準 Euler 積分:

Δtsub=ΔtNvn+1=vn+a(rn)Δtsubrn+1=rn+vnΔtsub

翻譯成程式碼大概長這樣:

ts
const subStepCount = 4
const subDt = dt / subStepCount

for (let i = 0; i < subStepCount; i++) {
  const { ax, ay } = computeAcceleration(position.x, position.y)
  velocity.x += ax * subDt
  velocity.y += ay * subDt
  position.x += velocity.x * subDt
  position.y += velocity.y * subDt
}

每個子步長的力場都會重新取樣,因此在彎曲劇烈的引力場附近,誤差會顯著下降。

全局誤差仍然是 O(Δt) ,但常數項縮小了 N 倍,當 N=4 時,誤差大約只有原來的 14

次步長不等於高階方法

即使取 N 個子步長,Euler 的誤差階數仍然是一階 O(Δt) ,只是等效步長變小了。

RK4 在同樣的計算量(取 4 次樣)下,誤差階數可以達到 O(Δt4) ,精度差距是量級上的,而不只是倍數。

Runge-Kutta 4 階(RK4)

RK4 的策略不只看起點,而是在一個 dt取 4 次樣本,綜合考慮整段區間的力場變化。

具體步驟:

k1:在起點 (rn,vn) 算加速度

k1=a(rn)

k2:用 k1 走半步到中點,在中點算加速度

k2=a(rn+vnΔt2)

k3:用 k2 走半步到中點(另一個估計),再算一次

k3=a(rn+(vn+k2Δt2)Δt2)

k4:用 k3 走整步到終點,算加速度

k4=a(rn+(vn+k3Δt)Δt)

最後加權平均:

vn+1=vn+k1+2k2+2k3+k46Δtrn+1=rn+v(1)+2v(2)+2v(3)+v(4)6Δt

中間的權重 1:2:2:1 來自 Simpson 積分法則,對起點和終點各取一次,對中點取兩次,能精確積分到三次多項式。

RK4 的局部截斷誤差O(Δt5) ,全局誤差為 O(Δt4) 。相比 Euler 的 O(Δt) ,精度提升了好幾個量級。

直覺理解

EulerEuler Sub-stepRK4
比喻站在起點看一眼方向,閉眼走一大步把一大步拆成四小步,每小步各看一眼方向走一小段看看、再走一段看看,走了四次後取平均
每幀取樣次數1N (與子步長數相同)4
全局誤差O(Δt)O(Δt) (常數縮小 N 倍)O(Δt4)
急彎表現容易飛出曲線比 Euler 穩定,但仍是一階能緊貼曲線

代價是每幀要計算 4 次加速度(遍歷所有黑洞 4 次),但這個元件的黑洞數量就是選項數量,通常不會超過 10 個,對現代瀏覽器來說完全不是問題。( •̀ ω •́ )✧

Low-Storage Runge-Kutta 4.5(LSRK 4.5)

LSRK 的命名慣例是 「階數.階段數」,所以 4.5 代表「4 階精度、5 個積分階段」。

為什麼要 5 個階段?

Butcher 障礙(Butcher barriers)規定了不同精度所需的最少階段數:

目標階數最少需要的階段數
33
44(恰好夠)
56(跨過一道障礙)

RK4 用 4 個階段恰好達到 4 階精度,但它的穩定域相對有限。Carpenter-Kennedy 多用一個階段換取更寬的穩定域,讓同樣大小的 dt 下更不容易發散。

RK4 雖然精準,但它需要同時保留 k1k2k3k4 四個中間向量,記憶體用量是狀態大小的 4 倍以上。

對大規模計算(例如 CFD 流體模擬、氣象預報)來說,這個成本相當可觀。

LSRK 2N 儲存方案由 Williamson(1980)提出,核心概念是將四個 k 向量的角色壓縮成一個持續更新的暫存向量 du

每個階段的更新規則如下:

duAidu+Δtf(U)UU+Bidu

其中 AiBi 是預先計算好的常數, f(U) 是當前狀態的導數(速度 + 加速度)。

本元件使用 Carpenter-Kennedy 5 階段 4 階精度係數:

階段 iAiBi
1014329971744779575080441755
25673018057731357537059087516183667771713612068292357
32404267990393201674669523817201463215492090206949498
43550918686646209150117938531345643535374481467310338
51275806237668842570457699227782119143714882151754819

翻譯成程式碼大概長這樣:

ts
let duPx = 0
let duPy = 0
let duVx = 0
let duVy = 0

for (let i = 0; i < 5; i++) {
  const { ax, ay } = computeAcceleration(position.x, position.y)

  duPx = A[i] * duPx + dt * velocity.x
  duPy = A[i] * duPy + dt * velocity.y
  duVx = A[i] * duVx + dt * ax
  duVy = A[i] * duVy + dt * ay

  position.x += B[i] * duPx
  position.y += B[i] * duPy
  velocity.x += B[i] * duVx
  velocity.y += B[i] * duVy
}

一眼就能看出與 RK4 最大的差異在於沒有 k1k2k3k4

只有一個 du 向量在每個階段被覆寫,記憶體用量維持在 2N(只需狀態本身 + 一個暫存向量)。

最終四種方法的比較:

EulerEuler Sub-stepRK4LSRK(Carpenter-Kennedy)
比喻站在起點看一眼方向,閉眼走一大步把一大步拆成四小步,各看一眼方向走一小段看看、再走一段看看,走了四次後取平均同 RK4,但走的時候只記最後一步,不回頭翻筆記
每幀取樣次數1N45
全局誤差O(Δt)O(Δt) (常數縮小 N 倍)O(Δt4)O(Δt4)
額外記憶體4× 狀態大小(k1 ~ k4)1× 狀態大小(du)
急彎表現容易飛出曲線比 Euler 穩定,但仍是一階能緊貼曲線能緊貼曲線

如果只是做來玩玩的簡單案例,其實感受不到 LSRK 的記憶體優勢,但哪天若有大規模模擬中派得上用場了。(・∀・)9

原始碼

API

Props

interface RadioOption {
  label: string;
  value: string;
}

interface Props {
  /** 目前選取的值 */
  modelValue?: string;
  /** 選項列表 */
  options?: RadioOption[];
  /**
   * 球的直徑(px)
   * @default 12
   */
  ballSize?: number;
  /**
   * 重力強度
   * @default 5_000_000
   */
  gravity?: number;
  /**
   * 阻尼係數(0-1)。數值越小球減速越快,會很快停下來;數值越大球滑行距離越長,減速越慢
   * @default 0.9
   */
  damping?: number;
  /**
   * 捕捉距離(px)。球進入此範圍且速度夠低時會被吸入。數值越大越容易被捕捉;數值越小需要更精確地靠近黑洞
   * @default 14
   */
  captureDistance?: number;
  /**
   * 捕捉速度閾值(px/s)。球速度低於此值才會被捕捉。數值越大球在較快速度時也能被吸入;數值越小球必須幾乎靜止才會被捕捉
   * @default 100
   */
  captureSpeed?: number;
  /**
   * 重力軟化參數,防止球靠近黑洞時加速度爆炸。數值越大重力變化越平滑,球不會突然加速;數值越小球靠近黑洞時會急劇加速
   * @default 12
   */
  softening?: number;
  /**
   * 點選黑洞時球移動過去的動畫時長(ms)
   * @default 350
   */
  moveDuration?: number;
  /**
   * 主色調,用於球顏色與選中時的邊框色。接受任何 CSS 合法顏色值
   * @default '#34c6eb'
   */
  color?: string;
}

Emits

interface Emits {
  'update:modelValue': [value: string];
}

v0.60.0