Skip to content

植栽包裝器 wrapper

完成品請見植栽包裝器元件

所以這篇就來打造一個「植栽包裝器」,被它包住的內容會從邊框長出水彩風的花草,會發芽、展葉、開花,有微風輕搖、陣風掃過,滑鼠掃過去還會像撥草叢一樣晃動。

全程只用 Canvas 2D,不需要 WebGL,但會用上不少有趣的小技巧。整體架構分成四大塊:

txt
骨架生成(種子隨機 + 海龜繪圖)
  → 水彩印章(離屏預渲染)
    → 生長敘事(三層進度時間窗)
      → 物理演出(噪聲微風 + 陣風彈簧 + 滑鼠衝量)

讓我們從最基本的「隨機」開始。◝( •ω• )◟

Step 1:種子隨機 — 長一樣才是專業

做植物生成第一個遇到的問題不是怎麼畫,而是怎麼「隨機」。

植物的形狀充滿隨機參數,莖長、傾角、葉片位置、開不開花,全部都要骰。但直接用 Math.random() 會有個大麻煩,每次重畫植物都會變一個樣。

容器尺寸變化時要重新生成植株、視窗縮放要重畫,如果每次重畫植物都長得不一樣,使用者拉一下視窗,整個花園瞬間砍掉重練,比魔術還神奇。╮(╯_╰)╭

左邊每次都長出不同的草,右邊永遠是同一叢

按下「重新生成」就能看到差異,左邊每按一次換一批草,右邊怎麼按都是同一叢。

查看範例原始碼
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const CANVAS_HEIGHT = 200
const STEM_COUNT = 7

const randomCanvasRef = ref<HTMLCanvasElement | null>(null)
const seededCanvasRef = ref<HTMLCanvasElement | null>(null)

/** 線性同餘偽隨機數產生器:同種子永遠產生同一串數字 */
function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

/** 用指定的隨機來源畫一叢草 */
function drawClump(canvas: HTMLCanvasElement, random: () => number, label: string) {
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const baseY = CANVAS_HEIGHT - 16

  for (let i = 0; i < STEM_COUNT; i++) {
    // 扇形展開:依序分配傾角,再加一點隨機抖動
    const lean = -0.9 + (i / (STEM_COUNT - 1)) * 1.8 + (random() - 0.5) * 0.2
    const length = 75 + random() * 60 - Math.abs(lean) * 30
    const droop = Math.sign(lean || 1) * (0.5 + random())

    let x = width / 2 + (random() - 0.5) * 18
    let y = baseY
    let angle = -Math.PI / 2 + lean

    context.beginPath()
    context.moveTo(x, y)
    for (let step = 1; step <= 16; step++) {
      const t = step / 16
      angle += droop * t * (2 / 16)
      x += Math.cos(angle) * (length / 16)
      y += Math.sin(angle) * (length / 16)
      context.lineTo(x, y)
    }
    context.strokeStyle = `hsla(${95 + random() * 25}, 32%, ${50 + random() * 14}%, 0.85)`
    context.lineWidth = 2.5
    context.lineCap = 'round'
    context.stroke()
  }

  context.fillStyle = '#9ca3af'
  context.font = '13px sans-serif'
  context.textAlign = 'left'
  context.fillText(label, 12, 24)
}

function regenerate() {
  const randomCanvas = randomCanvasRef.value
  const seededCanvas = seededCanvasRef.value

  if (randomCanvas)
    drawClump(randomCanvas, Math.random, 'Math.random()')

  // 種子固定為 42,每次重畫都長出一模一樣的草叢
  if (seededCanvas)
    drawClump(seededCanvas, createSeededRandom(42), 'createSeededRandom(42)')
}

onMounted(() => {
  regenerate()
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <div class="grid grid-cols-2 gap-3">
      <canvas
        ref="randomCanvasRef"
        class="w-full rounded-lg"
      />
      <canvas
        ref="seededCanvasRef"
        class="w-full rounded-lg"
      />
    </div>
    <div class="flex items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="regenerate"
      >
        重新生成
      </button>
      <span class="text-xs text-gray-400">
        左邊每次都長出不同的草,右邊永遠是同一叢
      </span>
    </div>
  </div>
</template>

線性同餘產生器(LCG)

解法是自己寫一個「可以指定種子」的偽隨機數產生器。這裡用最經典的線性同餘(Linear Congruential Generator),只要三行:

ts
/** 偽隨機數產生器(線性同餘),確保同種子產生相同植株 */
export function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

原理是把目前狀態乘一個大數、加一個大數,再砍到 32 bit,產生看起來毫無規律的序列。16645251013904223 是教科書等級的經典參數(出自 Numerical Recipes)。

>>> 0 是把可能為負的 32 bit 整數轉成無號數,除以 0xFFFFFFFF 後就得到 0~1 的浮點數。

與 Math.random 的比較

特性Math.random()createSeededRandom(seed)
可重現不行同種子同序列
品質高(引擎內建)普通(但夠用)
速度更快(乘加位移而已)
適合場景一次性隨機程序化生成(plant、地形)

植物生成不需要密碼學等級的隨機品質,「夠亂、可重現」就是完美解。

之後每株植物都會分到一顆自己的種子,整個版面重算一百次,每株還是長在原地、保持原樣。(≖ᴗ≖✿)

Step 2:莖的骨架 — 海龜繪圖

有了隨機,接著來長莖。

莖的本質是一條「彎得很自然」的曲線。直接用貝茲曲線拉控制點也行,但控制點和「植物彎曲的語感」對不太起來,調整參數像在猜謎。

所以這裡改用**海龜繪圖(Turtle Graphics)**的思路,想像一隻海龜從基部出發,每走一小段就稍微轉個彎,走 16 段後留下的足跡就是莖。

0.70
0.50
3.0
頻率拉到最高試試,會出現垂藤的螺旋感

拉動滑桿感受每個參數的「植物學意義」,這就是海龜繪圖的好處,每個參數都直接對應一種彎曲特徵。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 260
const STEM_STEP_COUNT = 16
const STEM_LENGTH = 190

const canvasRef = ref<HTMLCanvasElement | null>(null)
const droop = ref(0.7)
const wobble = ref(0.5)
const wobbleFrequency = ref(3)

let wobblePhase = 1.8
let animationFrameId = 0

interface SkeletonPoint {
  x: number;
  y: number;
}

/** 海龜繪圖:從基部出發,逐段累積角度走出骨架 */
function buildSkeleton(baseX: number, baseY: number): SkeletonPoint[] {
  const segmentLength = STEM_LENGTH / STEM_STEP_COUNT
  const pointList: SkeletonPoint[] = [{ x: baseX, y: baseY }]

  let x = baseX
  let y = baseY
  let angle = -Math.PI / 2 // 起始朝正上方

  for (let i = 1; i <= STEM_STEP_COUNT; i++) {
    const t = i / STEM_STEP_COUNT
    // 下垂:越靠尖端,每段偏轉越多
    angle += droop.value * t * (2 / STEM_STEP_COUNT)
    // 擾動:沿莖身的正弦起伏,形成自然的 S 形
    angle += Math.sin(t * wobbleFrequency.value * 2 + wobblePhase)
      * wobble.value * (2.2 / STEM_STEP_COUNT)

    x += Math.cos(angle) * segmentLength
    y += Math.sin(angle) * segmentLength
    pointList.push({ x, y })
  }

  return pointList
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const baseX = width / 2
  const baseY = CANVAS_HEIGHT - 20

  // 參考線:完全不彎的直挺挺版本
  context.beginPath()
  context.moveTo(baseX, baseY)
  context.lineTo(baseX, baseY - STEM_LENGTH)
  context.strokeStyle = 'rgba(156, 163, 175, 0.3)'
  context.lineWidth = 1
  context.setLineDash([4, 4])
  context.stroke()
  context.setLineDash([])

  const pointList = buildSkeleton(baseX, baseY)

  // 骨架折線
  context.beginPath()
  context.moveTo(pointList[0]!.x, pointList[0]!.y)
  for (let i = 1; i < pointList.length; i++) {
    context.lineTo(pointList[i]!.x, pointList[i]!.y)
  }
  context.strokeStyle = 'hsla(100, 35%, 58%, 0.9)'
  context.lineWidth = 3
  context.lineCap = 'round'
  context.lineJoin = 'round'
  context.stroke()

  // 節點
  for (const point of pointList) {
    context.beginPath()
    context.arc(point.x, point.y, 3, 0, Math.PI * 2)
    context.fillStyle = '#fbbf24'
    context.fill()
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(`droop=${droop.value.toFixed(2)} wobble=${wobble.value.toFixed(2)} freq=${wobbleFrequency.value.toFixed(1)}`, 12, 20)
}

function rerollPhase() {
  wobblePhase = Math.random() * Math.PI * 2
  draw()
}

watch([droop, wobble, wobbleFrequency], draw)

onMounted(() => {
  // 首幀可能還抓不到寬度,下一幀補畫
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">droop 下垂</label>
      <input
        v-model.number="droop"
        type="range"
        min="-1.5"
        max="1.5"
        step="0.05"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ droop.toFixed(2) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">wobble 擾動</label>
      <input
        v-model.number="wobble"
        type="range"
        min="0"
        max="1.5"
        step="0.05"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ wobble.toFixed(2) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">擾動頻率</label>
      <input
        v-model.number="wobbleFrequency"
        type="range"
        min="0.5"
        max="11"
        step="0.5"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ wobbleFrequency.toFixed(1) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="rerollPhase"
      >
        重骰擾動相位
      </button>
      <span class="text-xs text-gray-400">
        頻率拉到最高試試,會出現垂藤的螺旋感
      </span>
    </div>
  </div>
</template>

角度累積

核心邏輯只有一個迴圈,每段在前一段的角度上累加兩種偏轉:

ts
let angle = -Math.PI / 2 + options.lean // 起始朝上,加上初始傾角

for (let i = 1; i <= STEM_STEP_COUNT; i++) {
  const t = i / STEM_STEP_COUNT
  // 下垂:越接近尖端,曲率越大
  angle += options.signedDroop * t * (2 / STEM_STEP_COUNT)
  // 擾動:沿莖身的正弦起伏
  angle += Math.sin(t * wobbleFrequency * 2 + wobblePhase) * preset.wobble * (2.2 / STEM_STEP_COUNT)

  tipLocalX += Math.cos(angle) * segmentLength
  tipLocalY += Math.sin(angle) * segmentLength
}

兩種偏轉各有任務:

  • droop(下垂):偏轉量乘上 t,所以越靠尖端彎得越多,呈現重力把莖梢往下拉的感覺。草葉的彎垂、蕨葉的拱形都靠它。
  • wobble(擾動):正弦波沿莖起伏,做出自然的 S 形。頻率低是緩慢長彎,頻率拉高就變成密集捲曲,垂藤的螺旋感就是 wobbleFrequency: 7~11 騙出來的。

路人:「騙?(˙灬˙ )」

鱈魚:「對,那其實不是真的 3D 螺旋,只是高頻正弦的投影看起來很像而已。視覺效果嘛,看起來像就是像 ヾ(◍'౪`◍)ノ゙」

特化骨架

不同植物在這個基礎迴圈上各自加料,例如:

ts
// 垂藤尖端帶永久小捲鬚收尾
if (preset.kind === 'vine') {
  angle += tipCurlDirection * Math.max(0, t - 0.78) * 2.6
}

// 蕨類末端永久捲成螺旋蕨芽(約 1.4 圈)
if (preset.kind === 'fern') {
  const spiralT = Math.max(0, (t - 0.72) / 0.28)
  angle += Math.sign(options.signedDroop || 1) * spiralT * spiralT * 3.8
}

// 攀藤緊貼邊框攀爬:角度不越過邊線,避免整段懸空
if (preset.kind === 'ivy') {
  angle = Math.max(-Math.PI + 0.06, Math.min(-0.06, angle))
}

黃金葛(pothos)最搞工,它不是控制角度,而是反過來「先決定垂弧的目標高度,再反推角度」,讓莖貼著邊線垂出一段段花綵(swag)的形狀,細節可以直接看 create-plant.ts

為什麼是 16 段?

段數越多曲線越滑,但之後每幀都要重算彎曲(風吹會即時變形),段數直接影響效能。

16 段配合稍後的平滑曲線繪製已經看不出折角,是效果與效能的甜蜜點。

Step 3:破土生長 — 蕨芽舒展的秘密

骨架是完整形狀,但植物總不能「啪」一聲直接出現,要從土裡慢慢長出來才有生命力。

生長的核心概念是 drawnLength,整根莖的骨架早就生成好了,動畫只控制「目前畫到哪裡」。

1.00
2.4
tipCurl 拉到 3.4 就是蕨芽破土的螺旋舒展

把 tipCurl 拉到最大再按重播,就是蕨芽破土的經典畫面。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 260
const STEM_STEP_COUNT = 16
const STEM_LENGTH = 180
const STEM_GROWTH_END = 0.62
const BASE_WIDTH = 5

const canvasRef = ref<HTMLCanvasElement | null>(null)
const progress = ref(1)
const tipCurl = ref(2.4)
const playing = ref(false)

let animationFrameId = 0
let playStartTime = 0

function clamp01(value: number): number {
  return Math.max(0, Math.min(1, value))
}

function easeOutCubic(t: number): number {
  return 1 - (1 - t) ** 3
}

interface BentPoint {
  x: number;
  y: number;
  width: number;
}

/** 依目前生長進度重建莖的座標,尖端帶捲曲 */
function buildGrowingStem(baseX: number, baseY: number): BentPoint[] {
  // 莖在前 62% 進度伸展完成,之後留給葉與花
  const lengthEase = easeOutCubic(clamp01(progress.value / STEM_GROWTH_END))
  const drawnLength = STEM_LENGTH * lengthEase
  // 捲曲隨生長舒展:越長越直
  const curlRemaining = tipCurl.value * (1 - lengthEase)
  const currentTipT = Math.max(0.001, drawnLength / STEM_LENGTH)

  const segmentLength = STEM_LENGTH / STEM_STEP_COUNT
  const pointList: BentPoint[] = [{ x: baseX, y: baseY, width: BASE_WIDTH }]

  let x = baseX
  let y = baseY
  let angle = -Math.PI / 2
  let accumulated = 0

  for (let i = 1; i <= STEM_STEP_COUNT; i++) {
    const t = i / STEM_STEP_COUNT
    if (accumulated >= drawnLength)
      break

    angle += 0.5 * t * (2 / STEM_STEP_COUNT)
    angle += Math.sin(t * 5 + 1.2) * 0.3 * (2.2 / STEM_STEP_COUNT)

    // 捲曲只作用在目前生長尖端附近(relativeT 後半段)
    const relativeT = Math.min(1, t / currentTipT)
    const curlT = Math.max(0, (relativeT - 0.5) / 0.5)
    const bentAngle = angle + curlRemaining * curlT * curlT * 3

    // 最後一段可能只長到一半
    const remaining = drawnLength - accumulated
    const segmentRatio = Math.min(1, remaining / segmentLength)
    const stepLength = segmentLength * segmentRatio

    x += Math.cos(bentAngle) * stepLength
    y += Math.sin(bentAngle) * stepLength
    accumulated += stepLength

    // 錐形漸細,生長中的尖端再細一點
    let width = BASE_WIDTH * (1 - t * 0.85) ** 1.3 + 0.25
    if (segmentRatio < 1)
      width *= 0.4
    pointList.push({ x, y, width })
  }

  return pointList
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const pointList = buildGrowingStem(width / 2, CANVAS_HEIGHT - 20)

  // 逐段繪製,才能表現粗細變化
  for (let i = 1; i < pointList.length; i++) {
    const previous = pointList[i - 1]!
    const point = pointList[i]!
    context.beginPath()
    context.moveTo(previous.x, previous.y)
    context.lineTo(point.x, point.y)
    context.strokeStyle = 'hsla(100, 35%, 58%, 0.92)'
    context.lineWidth = point.width
    context.lineCap = 'round'
    context.stroke()
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(`progress=${progress.value.toFixed(2)}(莖於 ${STEM_GROWTH_END} 完成伸展)`, 12, 20)
}

function play() {
  playing.value = true
  playStartTime = performance.now()

  function tick(now: number) {
    const t = Math.min(1, (now - playStartTime) / 2600)
    progress.value = t
    draw()
    if (t < 1) {
      animationFrameId = requestAnimationFrame(tick)
    }
    else {
      playing.value = false
    }
  }
  animationFrameId = requestAnimationFrame(tick)
}

watch([progress, tipCurl], draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">生長進度</label>
      <input
        v-model.number="progress"
        type="range"
        min="0"
        max="1"
        step="0.01"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ progress.toFixed(2) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">tipCurl 捲曲</label>
      <input
        v-model.number="tipCurl"
        type="range"
        min="0"
        max="3.4"
        step="0.1"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ tipCurl.toFixed(1) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
        :disabled="playing"
        @click="play"
      >
        重播生長
      </button>
      <span class="text-xs text-gray-400">
        tipCurl 拉到 3.4 就是蕨芽破土的螺旋舒展
      </span>
    </div>
  </div>
</template>

長度進度

ts
/** 莖在前 62% 進度伸展完成 */
const STEM_GROWTH_END = 0.62

const lengthPhase = clamp01(stemProgress / STEM_GROWTH_END)
const lengthEase = easeOutCubic(lengthPhase)
const drawnLength = stem.totalLength * lengthEase

莖只用前 62% 的進度伸展,剩下 38% 留給葉片展開與花朵綻放,這是「生長敘事」的第一個時間窗,後面會看到更多。

easeOutCubic 讓莖一開始竄得快、接近完成時放慢,模擬植物衝出土壤再緩緩定型的節奏。

最後一段通常不會剛好整段畫完,所以用 segmentRatio 處理「畫到一半的段」,順便讓生長中的尖端變細:

ts
const remaining = drawnLength - accumulated
const segmentRatio = Math.min(1, remaining / point.segmentLength)
const stepLength = point.segmentLength * segmentRatio

// 生長中的尖端較細
const width = segmentRatio < 1 ? point.width * 0.4 : point.width

尖端捲曲(tipCurl)

真實植物的新芽是捲著的,長大才舒展開。這個效果用一個隨生長遞減的捲曲量就能做到:

ts
// 捲曲隨生長舒展:越長越直
const curlRemaining = stem.tipCurl * (1 - lengthEase)

// 捲曲只作用在目前生長尖端附近
const relativeT = Math.min(1, point.t / currentTipT)
const curlT = Math.max(0, (relativeT - 0.5) / 0.5)
const angle = point.segmentAngle
  + bend * point.t ** 1.4
  + curlRemaining * curlT * curlT * 3

拆解一下這段在做什麼:

  • curlRemaining 隨生長進度遞減,長完就完全舒展
  • relativeT 是「此點在目前已生長部分的相對位置」,所以捲曲永遠跟著生長尖端跑
  • curlT * curlT 把捲曲集中在尖端後半,前半段維持原狀

蕨類的 tipCurl 高達 3.4,配上 Step 2 的末端螺旋,破土時就是一顆慢慢展開的蕨芽。(*´∀`)~♥

Step 4:錐形莖身 — 從折線到水彩絲帶

骨架只是一條線,真正的莖有粗細,基部寬、尖端細,而且要有水彩的質感。

做法是沿著骨架的每個點計算法線,往左右各推出半個莖寬,得到左緣與右緣兩排點,圍起來就是一條錐形「絲帶」。

莖寬已放大數倍方便觀察

切到「骨架 + 法線」模式,黃色短線就是每個節點的法線,長度即當地的莖寬。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 280
const STEM_STEP_COUNT = 16
const STEM_LENGTH = 200
const BASE_WIDTH = 14

type DisplayMode = 'skeleton' | 'normal' | 'ribbon'

const canvasRef = ref<HTMLCanvasElement | null>(null)
const mode = ref<DisplayMode>('ribbon')

const modeList: Array<{ value: DisplayMode; label: string }> = [
  { value: 'skeleton', label: '骨架' },
  { value: 'normal', label: '骨架 + 法線' },
  { value: 'ribbon', label: '水彩絲帶' },
]

let animationFrameId = 0

interface StemPoint {
  x: number;
  y: number;
  width: number;
}

interface EdgePoint {
  x: number;
  y: number;
}

function buildSkeleton(baseX: number, baseY: number): StemPoint[] {
  const segmentLength = STEM_LENGTH / STEM_STEP_COUNT
  const pointList: StemPoint[] = [{ x: baseX, y: baseY, width: BASE_WIDTH }]

  let x = baseX
  let y = baseY
  let angle = -Math.PI / 2

  for (let i = 1; i <= STEM_STEP_COUNT; i++) {
    const t = i / STEM_STEP_COUNT
    angle += 0.65 * t * (2 / STEM_STEP_COUNT)
    angle += Math.sin(t * 5.5 + 0.8) * 0.35 * (2.2 / STEM_STEP_COUNT)

    x += Math.cos(angle) * segmentLength
    y += Math.sin(angle) * segmentLength
    // 錐形漸細:基部寬、尖端細
    const width = BASE_WIDTH * (1 - t * 0.85) ** 1.3 + 0.5
    pointList.push({ x, y, width })
  }

  return pointList
}

/** 用前後點的差向量轉 90 度,求每個節點的單位法線 */
function computeNormalList(pointList: StemPoint[]): EdgePoint[] {
  const normalList: EdgePoint[] = []

  for (let i = 0; i < pointList.length; i++) {
    const previous = pointList[Math.max(0, i - 1)]!
    const next = pointList[Math.min(pointList.length - 1, i + 1)]!
    const dx = next.x - previous.x
    const dy = next.y - previous.y
    const length = Math.hypot(dx, dy) || 1
    normalList.push({ x: -dy / length, y: dx / length })
  }

  return normalList
}

/** 沿法線推出左右緣,再用平滑曲線圍成錐形莖身 */
function traceRibbonPath(
  context: CanvasRenderingContext2D,
  pointList: StemPoint[],
  normalList: EdgePoint[],
  widthScale: number,
  sideOffset: number,
): void {
  const leftList: EdgePoint[] = []
  const rightList: EdgePoint[] = []

  for (let i = 0; i < pointList.length; i++) {
    const point = pointList[i]!
    const normal = normalList[i]!
    const half = (point.width * widthScale) / 2
    const offsetX = normal.x * point.width * sideOffset
    const offsetY = normal.y * point.width * sideOffset

    leftList.push({ x: point.x + normal.x * half + offsetX, y: point.y + normal.y * half + offsetY })
    rightList.push({ x: point.x - normal.x * half + offsetX, y: point.y - normal.y * half + offsetY })
  }

  context.beginPath()
  context.moveTo(leftList[0]!.x, leftList[0]!.y)
  for (let i = 0; i < leftList.length - 1; i++) {
    const current = leftList[i]!
    const next = leftList[i + 1]!
    context.quadraticCurveTo(current.x, current.y, (current.x + next.x) / 2, (current.y + next.y) / 2)
  }
  context.lineTo(leftList[leftList.length - 1]!.x, leftList[leftList.length - 1]!.y)

  for (let i = rightList.length - 1; i > 0; i--) {
    const current = rightList[i]!
    const previous = rightList[i - 1]!
    context.quadraticCurveTo(current.x, current.y, (current.x + previous.x) / 2, (current.y + previous.y) / 2)
  }
  context.lineTo(rightList[0]!.x, rightList[0]!.y)
  context.closePath()
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const pointList = buildSkeleton(width / 2, CANVAS_HEIGHT - 24)
  const normalList = computeNormalList(pointList)

  if (mode.value === 'skeleton' || mode.value === 'normal') {
    context.beginPath()
    context.moveTo(pointList[0]!.x, pointList[0]!.y)
    for (let i = 1; i < pointList.length; i++) {
      context.lineTo(pointList[i]!.x, pointList[i]!.y)
    }
    context.strokeStyle = 'hsla(100, 35%, 58%, 0.9)'
    context.lineWidth = 2
    context.stroke()

    for (const point of pointList) {
      context.beginPath()
      context.arc(point.x, point.y, 2.5, 0, Math.PI * 2)
      context.fillStyle = '#fbbf24'
      context.fill()
    }
  }

  if (mode.value === 'normal') {
    // 法線:往左右各推半個莖寬,黃色短線就是絲帶的左右緣
    for (let i = 0; i < pointList.length; i++) {
      const point = pointList[i]!
      const normal = normalList[i]!
      const half = point.width / 2
      context.beginPath()
      context.moveTo(point.x - normal.x * half, point.y - normal.y * half)
      context.lineTo(point.x + normal.x * half, point.y + normal.y * half)
      context.strokeStyle = 'rgba(251, 191, 36, 0.7)'
      context.lineWidth = 1.2
      context.stroke()
    }
  }

  if (mode.value === 'ribbon') {
    const base = pointList[0]!
    const tip = pointList[pointList.length - 1]!
    const gradient = context.createLinearGradient(base.x, base.y, tip.x, tip.y)
    gradient.addColorStop(0, 'hsla(100, 30%, 52%, 0.52)')
    gradient.addColorStop(1, 'hsla(100, 35%, 65%, 0.6)')

    // 底層:完整寬度的淡彩
    traceRibbonPath(context, pointList, normalList, 1, 0)
    context.fillStyle = gradient
    context.fill()

    // 第二層:偏移的窄色帶,模擬顏料積聚
    traceRibbonPath(context, pointList, normalList, 0.5, 0.14)
    context.fillStyle = 'hsla(100, 30%, 45%, 0.3)'
    context.fill()

    // 邊緣積色
    traceRibbonPath(context, pointList, normalList, 1, 0)
    context.strokeStyle = 'hsla(100, 30%, 36%, 0.35)'
    context.lineWidth = 0.8
    context.stroke()
  }
}

watch(mode, draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex flex-wrap items-center gap-2">
      <button
        v-for="item in modeList"
        :key="item.value"
        class="rounded-lg px-3 py-1 text-xs text-white transition-colors"
        :class="mode === item.value ? 'bg-blue-600' : 'bg-gray-600 hover:bg-gray-500'"
        @click="mode = item.value"
      >
        {{ item.label }}
      </button>
      <span class="text-xs text-gray-400">
        莖寬已放大數倍方便觀察
      </span>
    </div>
  </div>
</template>

法線計算

每個點的切線方向用「前後鄰居的差向量」近似,旋轉 90 度就是法線:

ts
/** 計算每個路徑點的單位法線 */
function computeNormalList(pointList: BentPoint[]): EdgePoint[] {
  const normalList: EdgePoint[] = []

  for (let i = 0; i < pointList.length; i++) {
    const previous = pointList[Math.max(0, i - 1)]!
    const next = pointList[Math.min(pointList.length - 1, i + 1)]!
    const dx = next.x - previous.x
    const dy = next.y - previous.y
    const length = Math.hypot(dx, dy) || 1
    normalList.push({ x: -dy / length, y: dx / length })
  }

  return normalList
}

(dx, dy) 轉成 (-dy, dx) 就是逆時針轉 90 度,這招在 Starry Sea 的魚群散佈也用過,平面幾何的萬用螺絲起子。

平滑圍邊

左右緣如果直接 lineTo 連起來,16 段的折角會原形畢露。這裡用個經典技巧,把「目前點」當控制點、「目前點與下一點的中點」當終點畫二次貝茲曲線:

ts
context.moveTo(leftList[0]!.x, leftList[0]!.y)
for (let i = 0; i < leftList.length - 1; i++) {
  const current = leftList[i]!
  const next = leftList[i + 1]!
  context.quadraticCurveTo(current.x, current.y, (current.x + next.x) / 2, (current.y + next.y) / 2)
}

曲線永遠通過中點、被控制點拉彎,整條邊就圓潤了。

莖寬的錐形公式

ts
const width = baseWidth * (1 - t * 0.85) ** 1.3 + 0.25
部分作用
1 - t * 0.85尖端收到基部的 15%,不會完全變成 0
** 1.3讓收細的速度前慢後快,更像真實植物
+ 0.25保底寬度,尖端細歸細但不能斷

水彩疊色

絲帶身體用三層疊出水彩感,做法與實際元件相同:

ts
// 底層:完整寬度的淡彩,基部到尖端帶漸層
traceRibbonPath(context, pointList, normalList, 1, 0)
context.fillStyle = gradient
context.fill()

// 第二層:偏移的窄色帶,形成顏料積聚的立體感
traceRibbonPath(context, pointList, normalList, 0.5, 0.14)
context.fillStyle = formatHsl({ ...stem.stemColor, lightness: stem.stemColor.lightness - 7 }, 0.3)
context.fill()

// 邊緣積色
traceRibbonPath(context, pointList, normalList, 1, 0)
context.strokeStyle = formatHsl({ ...stem.stemColor, lightness: stem.stemColor.lightness - 16 }, 0.14)
context.lineWidth = 0.8
context.stroke()

第二層故意縮窄又往側邊偏移(sideOffset: 0.14),模擬水彩顏料往一側積聚的不均勻感,是整個水彩風的靈魂小細節。

Step 5:水彩印章 — 離屏預渲染

莖搞定了,輪到葉片與花瓣。這兩位才是水彩感的主角,但也是效能的頭號殺手。

一片有水彩感的葉子要疊好幾層,大面積淡彩、暈開的軟邊、深色積聚層、邊緣積色、葉脈。其中「暈開的軟邊」靠 shadowBlur 模擬,而 shadowBlur 是 Canvas 2D 出了名的效能毒藥。

一個畫面隨便就有上百片葉子,每幀重畫一次?60fps 直接變幻燈片。( ´•̥̥̥ ω •̥̥̥` )

解法是印章(Stamp),把每種葉片、花瓣預先畫在離屏畫布上,動畫期間只要 drawImage 貼上去,平移旋轉縮放都交給變換矩陣。

勾選各圖層觀察水彩質感如何疊出來,葉片已放大方便觀察

勾選各圖層觀察一片水彩葉的組成,再按「換一片葉子」看隨機形狀的變化。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 240
const LEAF_LENGTH = 170

const canvasRef = ref<HTMLCanvasElement | null>(null)
const baseLayerVisible = ref(true)
const darkLayerVisible = ref(true)
const edgeLayerVisible = ref(true)
const veinLayerVisible = ref(true)

let seed = 7
let animationFrameId = 0

function createSeededRandom(seedValue: number): () => number {
  let state = seedValue
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface LeafShape {
  length: number;
  topWidth: number;
  bottomWidth: number;
  arch: number;
}

/** 葉片輪廓:上下兩條貝茲曲線圍成(基部在原點,葉尖朝 +x) */
function traceLeafPath(context: CanvasRenderingContext2D, shape: LeafShape): void {
  const { length, topWidth, bottomWidth, arch } = shape

  context.beginPath()
  context.moveTo(0, 0)
  context.bezierCurveTo(
    length * 0.22,
    -topWidth + arch * 0.4,
    length * 0.72,
    -topWidth * 0.62 + arch,
    length,
    arch,
  )
  context.bezierCurveTo(
    length * 0.72,
    bottomWidth * 0.62 + arch,
    length * 0.22,
    bottomWidth + arch * 0.4,
    0,
    0,
  )
  context.closePath()
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)

  // 紙張底色:水彩要畫在淺色紙上才看得出暈染
  context.fillStyle = '#f6f3ea'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const random = createSeededRandom(seed)
  const shape: LeafShape = {
    length: LEAF_LENGTH,
    topWidth: LEAF_LENGTH * (0.3 + random() * 0.14),
    bottomWidth: LEAF_LENGTH * (0.26 + random() * 0.14),
    arch: (random() - 0.5) * LEAF_LENGTH * 0.22,
  }
  const hue = 100 + random() * 20

  // 印章繪製在獨立的離屏畫布上,multiply 疊色彼此作用、不影響背景
  const stampCanvas = document.createElement('canvas')
  stampCanvas.width = Math.ceil((LEAF_LENGTH + 60) * dpr)
  stampCanvas.height = Math.ceil(CANVAS_HEIGHT * dpr)
  const stamp = stampCanvas.getContext('2d')
  if (!stamp)
    return
  stamp.setTransform(dpr, 0, 0, dpr, 0, 0)
  stamp.translate(20, CANVAS_HEIGHT / 2)
  stamp.globalCompositeOperation = 'multiply'

  if (baseLayerVisible.value) {
    // 底層:大面積淡彩,shadowBlur 模擬顏料暈開的軟邊
    stamp.shadowColor = `hsla(${hue}, 30%, 55%, 0.9)`
    stamp.shadowBlur = 10
    traceLeafPath(stamp, shape)
    stamp.fillStyle = `hsla(${hue}, 30%, 58%, 0.4)`
    stamp.fill()
    stamp.shadowBlur = 0
  }

  if (darkLayerVisible.value) {
    // 第二層:縮小偏移的深色層,形成顏料積聚的深淺
    stamp.save()
    stamp.translate(6, (random() - 0.5) * 6)
    stamp.scale(0.84, 0.8)
    traceLeafPath(stamp, shape)
    stamp.fillStyle = `hsla(${hue}, 30%, 48%, 0.32)`
    stamp.fill()
    stamp.restore()
  }

  if (edgeLayerVisible.value) {
    // 邊緣積色:水彩乾燥後邊緣較深的特徵
    traceLeafPath(stamp, shape)
    stamp.strokeStyle = `hsla(${hue}, 30%, 40%, 0.26)`
    stamp.lineWidth = 3
    stamp.stroke()
  }

  if (veinLayerVisible.value) {
    // 葉脈:中肋 + 三對側脈
    stamp.strokeStyle = `hsla(${hue}, 30%, 36%, 0.35)`
    stamp.lineWidth = 2.4
    stamp.lineCap = 'round'
    stamp.beginPath()
    stamp.moveTo(shape.length * 0.04, 0)
    stamp.quadraticCurveTo(shape.length * 0.5, shape.arch * 0.55, shape.length * 0.94, shape.arch * 0.96)
    stamp.stroke()

    stamp.lineWidth = 1.6
    stamp.globalAlpha = 0.55
    for (let i = 0; i < 3; i++) {
      const t = 0.22 + (i / 3) * 0.5 + random() * 0.06
      const baseX = shape.length * t
      const baseY = shape.arch * t * 0.55
      const veinLength = shape.length * 0.2 * (1 - t * 0.5)

      for (const side of [-1, 1]) {
        stamp.beginPath()
        stamp.moveTo(baseX, baseY)
        stamp.quadraticCurveTo(
          baseX + veinLength * 0.6,
          baseY + side * veinLength * 0.5,
          baseX + veinLength,
          baseY + side * veinLength * 0.8,
        )
        stamp.stroke()
      }
    }
    stamp.globalAlpha = 1
  }

  // 完成的印章貼回主畫布
  context.drawImage(
    stampCanvas,
    (width - LEAF_LENGTH - 60) / 2,
    0,
    stampCanvas.width / dpr,
    stampCanvas.height / dpr,
  )
}

function rerollShape() {
  seed = Math.floor(Math.random() * 100000)
  draw()
}

watch([baseLayerVisible, darkLayerVisible, edgeLayerVisible, veinLayerVisible], draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex flex-wrap items-center gap-4">
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="baseLayerVisible"
          type="checkbox"
        >
        底層淡彩
      </label>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="darkLayerVisible"
          type="checkbox"
        >
        偏移深色層
      </label>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="edgeLayerVisible"
          type="checkbox"
        >
        邊緣積色
      </label>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="veinLayerVisible"
          type="checkbox"
        >
        葉脈
      </label>
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="rerollShape"
      >
        換一片葉子
      </button>
    </div>
    <span class="text-xs text-gray-400">
      勾選各圖層觀察水彩質感如何疊出來,葉片已放大方便觀察
    </span>
  </div>
</template>

多層暈染的配方

實際元件的水彩本體長這樣:

ts
/** 以多層半透明疊色繪製水彩質感的形狀 */
function paintWatercolorBody(context, traceShape, color, random) {
  context.globalCompositeOperation = 'multiply'

  // 底層:大面積淡彩,邊緣以 shadowBlur 模擬暈開
  context.shadowColor = formatHsl(color, 0.9)
  context.shadowBlur = 4
  traceShape(context)
  context.fillStyle = formatHsl(jitterHsl(color, random, 6, 5), 0.4)
  context.fill()
  context.shadowBlur = 0

  // 第二層:縮小偏移的深色層,形成顏料積聚的深淺
  context.translate(1.2 + random() * 1.5, (random() - 0.5) * 1.6)
  context.scale(0.84, 0.8)
  traceShape(context)
  context.fillStyle = formatHsl(jitterHsl({ ...color, lightness: color.lightness - 6 }, random, 8, 4), 0.32)
  context.fill()

  // 邊緣積色:水彩乾燥後邊緣較深的特徵
  traceShape(context)
  context.strokeStyle = formatHsl({ ...color, lightness: color.lightness - 14 }, 0.26)
  context.lineWidth = 1.4
  context.stroke()
}

multiply 合成模式讓疊加處越疊越深,正是水彩顏料層層上色的物理特性。搭配 jitterHsl 對色相和明度做隨機抖動,每片葉子的顏色都有微妙差異。

路人:「為什麼不直接用一張葉子圖片就好?(´・ω・`)」

鱈魚:「圖片的顏色是死的。用程式畫,同一套程式換個色盤就是另一種植物的葉子,而且每片形狀都不一樣,這是貼圖做不到的 ( •̀ ω •́ )✧」

印章的三個細節

超取樣。印章以 2 倍尺寸繪製,縮小貼上時依然銳利:

ts
/** 印章超取樣倍率,縮小繪製時保持銳利 */
const SUPER_SAMPLE = 2

錨點。葉片的基部要對準莖上的著生點,所以印章記錄了錨點位置,貼上時以錨點為原點變換:

ts
context.translate(x, y)
context.rotate(angle)
context.scale(scale, scale)
context.drawImage(stamp.canvas, -stamp.anchorX, -stamp.anchorY, ...)

快取與變體。每種 preset 只生成一次印章組(4 種葉片變體、6 種花瓣變體),存進 Map 共用。一百片葉子實際上只是 4 張圖在輪播,但因為旋轉、縮放、附著位置都不同,完全看不出來。乁( ◔ ௰◔)「

為什麼主畫布不用 multiply?

印章內部用 multiply 疊水彩,但貼到主畫布時改用一般 alpha 合成。

因為植株會互相重疊,如果主畫布也用 multiply,重疊處會連乘疊黑,整個角落變成一坨墨漬。水彩感留在印章裡就好,這是「預渲染」附帶的另一個好處。

Step 6:葉片著生 — 沿莖取樣與切線

印章做好了,要把葉片「種」到莖上。

每片葉子記錄自己在莖上的位置 stemT(0 是基部、1 是尖端)、生長側 side(左或右)、展開角 openAngle

1.00
慢慢拉動進度,觀察葉片在莖通過著生點後才展開

慢慢拉動進度條,葉片會在莖通過著生點後,帶著回彈感逐片展開。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 280
const STEM_STEP_COUNT = 16
const STEM_LENGTH = 200
const STEM_GROWTH_END = 0.62
const LEAF_SPAN = 0.2

const canvasRef = ref<HTMLCanvasElement | null>(null)
const progress = ref(1)
const playing = ref(false)
const attachPointVisible = ref(true)

let animationFrameId = 0
let playStartTime = 0

function clamp01(value: number): number {
  return Math.max(0, Math.min(1, value))
}

function easeOutCubic(t: number): number {
  return 1 - (1 - t) ** 3
}

/** 帶過衝的回彈緩動:超過 1 再彈回來 */
function easeOutBack(t: number): number {
  const c1 = 1.70158
  const c3 = c1 + 1
  return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
}

interface PathPoint {
  x: number;
  y: number;
  t: number;
}

interface Leaf {
  stemT: number;
  side: 1 | -1;
  size: number;
  openAngle: number;
}

// 葉片沿莖互生:左右交錯、越上方越小
const leafList: Leaf[] = [
  { stemT: 0.2, side: 1, size: 52, openAngle: Math.PI * 0.38 },
  { stemT: 0.34, side: -1, size: 48, openAngle: Math.PI * 0.42 },
  { stemT: 0.5, side: 1, size: 42, openAngle: Math.PI * 0.36 },
  { stemT: 0.64, side: -1, size: 36, openAngle: Math.PI * 0.4 },
  { stemT: 0.78, side: 1, size: 28, openAngle: Math.PI * 0.34 },
]

function buildSkeleton(baseX: number, baseY: number, drawnLength: number): PathPoint[] {
  const segmentLength = STEM_LENGTH / STEM_STEP_COUNT
  const pointList: PathPoint[] = [{ x: baseX, y: baseY, t: 0 }]

  let x = baseX
  let y = baseY
  let angle = -Math.PI / 2
  let accumulated = 0

  for (let i = 1; i <= STEM_STEP_COUNT; i++) {
    const t = i / STEM_STEP_COUNT
    if (accumulated >= drawnLength)
      break

    angle += 0.55 * t * (2 / STEM_STEP_COUNT)
    angle += Math.sin(t * 4.5 + 0.6) * 0.3 * (2.2 / STEM_STEP_COUNT)

    const remaining = drawnLength - accumulated
    const stepLength = segmentLength * Math.min(1, remaining / segmentLength)

    x += Math.cos(angle) * stepLength
    y += Math.sin(angle) * stepLength
    accumulated += stepLength
    pointList.push({ x, y, t })
  }

  return pointList
}

/** 取得路徑上指定位置的點與切線角 */
function samplePathAt(pointList: PathPoint[], stemT: number) {
  const lastIndex = pointList.length - 1
  const index = Math.min(lastIndex, Math.round(stemT * STEM_STEP_COUNT))
  const point = pointList[index]!
  const previous = pointList[Math.max(0, index - 1)]!
  const next = pointList[Math.min(lastIndex, index + 1)]!

  return {
    x: point.x,
    y: point.y,
    tangentAngle: Math.atan2(next.y - previous.y, next.x - previous.x),
  }
}

function traceLeafPath(context: CanvasRenderingContext2D, length: number): void {
  const topWidth = length * 0.36
  const bottomWidth = length * 0.3

  context.beginPath()
  context.moveTo(0, 0)
  context.bezierCurveTo(length * 0.22, -topWidth, length * 0.72, -topWidth * 0.62, length, 0)
  context.bezierCurveTo(length * 0.72, bottomWidth * 0.62, length * 0.22, bottomWidth, 0, 0)
  context.closePath()
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const stemProgress = progress.value
  const lengthEase = easeOutCubic(clamp01(stemProgress / STEM_GROWTH_END))
  const drawnLength = STEM_LENGTH * lengthEase
  const pointList = buildSkeleton(width / 2, CANVAS_HEIGHT - 20, drawnLength)

  // 莖身
  if (pointList.length > 1) {
    context.beginPath()
    context.moveTo(pointList[0]!.x, pointList[0]!.y)
    for (let i = 1; i < pointList.length; i++) {
      context.lineTo(pointList[i]!.x, pointList[i]!.y)
    }
    context.strokeStyle = 'hsla(100, 32%, 55%, 0.9)'
    context.lineWidth = 3.5
    context.lineCap = 'round'
    context.stroke()
  }

  // 葉片:莖長到該處之後才開始展開
  for (const leaf of leafList) {
    const leafStart = leaf.stemT * STEM_GROWTH_END + 0.08
    const leafProgress = clamp01((stemProgress - leafStart) / LEAF_SPAN)

    const attachPoint = samplePathAt(pointList, leaf.stemT)

    if (attachPointVisible.value && leaf.stemT <= drawnLength / STEM_LENGTH) {
      context.beginPath()
      context.arc(attachPoint.x, attachPoint.y, 3.5, 0, Math.PI * 2)
      context.fillStyle = leafProgress > 0 ? '#fbbf24' : 'rgba(251, 191, 36, 0.3)'
      context.fill()
    }

    if (leafProgress <= 0)
      continue

    // easeOutBack 讓葉片展開時微微過衝再彈回
    const eased = easeOutBack(leafProgress)
    const leafAngle = attachPoint.tangentAngle + leaf.side * leaf.openAngle * Math.min(eased, 1.1)

    context.save()
    context.translate(attachPoint.x, attachPoint.y)
    context.rotate(leafAngle)
    context.scale(eased, eased)
    traceLeafPath(context, leaf.size)
    context.fillStyle = 'hsla(108, 30%, 58%, 0.75)'
    context.fill()
    context.strokeStyle = 'hsla(108, 30%, 42%, 0.4)'
    context.lineWidth = 1
    context.stroke()
    context.restore()
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(`progress=${stemProgress.toFixed(2)}`, 12, 20)
}

function play() {
  playing.value = true
  playStartTime = performance.now()

  function tick(now: number) {
    const t = Math.min(1, (now - playStartTime) / 3000)
    progress.value = t
    draw()
    if (t < 1) {
      animationFrameId = requestAnimationFrame(tick)
    }
    else {
      playing.value = false
    }
  }
  animationFrameId = requestAnimationFrame(tick)
}

watch([progress, attachPointVisible], draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-24 text-sm text-gray-400">生長進度</label>
      <input
        v-model.number="progress"
        type="range"
        min="0"
        max="1"
        step="0.01"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ progress.toFixed(2) }}</span>
    </div>
    <div class="flex flex-wrap items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
        :disabled="playing"
        @click="play"
      >
        重播生長
      </button>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="attachPointVisible"
          type="checkbox"
        >
        顯示著生點
      </label>
      <span class="text-xs text-gray-400">
        慢慢拉動進度,觀察葉片在莖通過著生點後才展開
      </span>
    </div>
  </div>
</template>

取樣著生點

莖每幀都會被風吹彎,所以葉片位置不能寫死,要從「彎曲後的點列」即時取樣:

ts
/** 取得彎曲路徑上指定位置的點與切線角 */
function samplePathAt(bentPointList: BentPoint[], stemT: number) {
  const lastIndex = bentPointList.length - 1
  const index = Math.min(lastIndex, Math.round(stemT * STEM_STEP_COUNT))
  const point = bentPointList[index]!
  const previous = bentPointList[Math.max(0, index - 1)]!
  const next = bentPointList[Math.min(lastIndex, index + 1)]!

  return {
    x: point.x,
    y: point.y,
    tangentAngle: Math.atan2(next.y - previous.y, next.x - previous.x),
  }
}

切線角同樣用前後鄰居的差向量算,葉片的角度以切線為基準再加上展開角:

ts
const leafAngle = attachPoint.tangentAngle
  + leaf.side * leaf.openAngle * Math.min(eased, 1.1)
  + flutter

這樣風吹彎莖的時候,葉片會跟著莖一起轉,不會出現「莖彎了葉子還浮在原地」的靈異現象。

展開時序與回彈

葉片的展開時機跟著莖的生長走,莖長到哪、葉開到哪:

ts
const leafStart = leaf.stemT * STEM_GROWTH_END + 0.08
const leafProgress = clamp01((stemProgress - leafStart) / LEAF_SPAN)
const eased = easeOutBack(leafProgress)

stemT * STEM_GROWTH_END 是莖長到該著生點的時刻,+ 0.08 讓葉片晚一拍才冒出來,比較有「先抽枝再展葉」的感覺。

easeOutBack 是帶過衝的緩動,展開到 110% 再彈回 100%,小小的彈跳讓展葉瞬間活了起來:

ts
function easeOutBack(t: number): number {
  const c1 = 1.70158
  const c3 = c1 + 1
  return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
}

互生與葉序

葉片沿莖的排列也講究,普通植物左右交錯(互生)、越上方越小:

ts
leafList.push({
  stemT: cursor,
  side: i % 2 === 0 ? 1 : -1, // 左右互生
  size: randomBetween(random, preset.leafSizeRange) * (1 - cursor * 0.35), // 越上方越小
  ...
})
cursor += (0.45 + random() * 1.1) * (spanLength / leafCount) // 間距不規則

特化型態各有自己的葉序,新芽是一對子葉對生於莖頂、蕨類是羽片密集對生成披針形輪廓、黃金葛則是大葉同向垂入容器內側形成葉簾。同一套「stemT + side + openAngle」資料結構,全部都裝得下。

Step 7:開花敘事 — 時間窗的藝術

接下來是整個生長動畫最精彩的部分,開花。

直接讓花瓣從 0 放大到 1 也行,但效果就像帆布傘「啪」一聲打開,完全沒有花的優雅。真實的花有自己的劇本,花苞先鼓起,花瓣一片片錯落綻放,最後花心的雄蕊才探出頭。

1.00
下方色帶是各階段的時間窗,注意花苞在花瓣綻放後淡出

下方色帶就是各階段的時間窗,拉動進度條看每個窗口的演出。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 280

// 生長敘事時間窗(佔單根莖進度的比例)
const BUD_START = 0.56
const BUD_END = 0.8
const PETAL_START = 0.74
const PETAL_STAGGER = 0.12
const PETAL_SPAN = 0.14
const CENTER_START = 0.88

const PETAL_COUNT = 6
const PETAL_SIZE = 58
const CENTER_DOT_COUNT = 6

const canvasRef = ref<HTMLCanvasElement | null>(null)
const progress = ref(1)
const playing = ref(false)

let animationFrameId = 0
let playStartTime = 0

function clamp01(value: number): number {
  return Math.max(0, Math.min(1, value))
}

function easeOutCubic(t: number): number {
  return 1 - (1 - t) ** 3
}

function easeOutBack(t: number): number {
  const c1 = 1.70158
  const c3 = c1 + 1
  return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
}

function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface Petal {
  angleOffset: number;
  scale: number;
  /** 綻放順序(0~1),決定錯落開花時序 */
  growthOrder: number;
}

const random = createSeededRandom(17)
const petalList: Petal[] = []
for (let i = 0; i < PETAL_COUNT; i++) {
  petalList.push({
    angleOffset: (i / PETAL_COUNT) * Math.PI * 2 + (random() - 0.5) * 0.25,
    scale: 0.85 + random() * 0.3,
    growthOrder: random(),
  })
}

function tracePetalPath(context: CanvasRenderingContext2D, length: number): void {
  const width = length * 0.34

  context.beginPath()
  context.moveTo(0, 0)
  context.bezierCurveTo(length * 0.3, -width, length * 1.04, -width * 0.62, length, 0)
  context.bezierCurveTo(length * 1.04, width * 0.62, length * 0.3, width, 0, 0)
  context.closePath()
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const stemProgress = progress.value
  const tipX = width / 2
  const tipY = CANVAS_HEIGHT / 2 - 14

  // 花苞:鼓起後隨花瓣綻放淡出
  const budProgress = clamp01((stemProgress - BUD_START) / (BUD_END - BUD_START))
  const bloomProgress = clamp01((stemProgress - PETAL_START) / (1 - PETAL_START))
  const budAlpha = 1 - clamp01(bloomProgress * 1.8)

  if (budProgress > 0 && budAlpha > 0) {
    const budSize = PETAL_SIZE * 0.8 * easeOutBack(budProgress)
    context.save()
    context.translate(tipX, tipY)
    context.rotate(-Math.PI / 2)
    context.globalAlpha = budAlpha
    tracePetalPath(context, budSize)
    context.fillStyle = 'hsla(350, 50%, 80%, 0.85)'
    context.fill()
    context.restore()
  }

  // 花瓣:依 growthOrder 錯落綻放,帶 easeOutBack 回彈
  for (const petal of petalList) {
    const petalStart = PETAL_START + petal.growthOrder * PETAL_STAGGER
    const petalProgress = clamp01((stemProgress - petalStart) / PETAL_SPAN)
    if (petalProgress <= 0)
      continue

    const eased = easeOutBack(petalProgress)
    // 綻放途中帶一點旋轉,落定時轉正
    const settleSpin = (1 - petalProgress) * 0.5

    context.save()
    context.translate(tipX, tipY)
    context.rotate(petal.angleOffset + settleSpin)
    context.globalAlpha = 0.92
    tracePetalPath(context, PETAL_SIZE * petal.scale * eased)
    context.fillStyle = 'hsla(345, 52%, 84%, 0.9)'
    context.fill()
    context.strokeStyle = 'hsla(345, 48%, 68%, 0.4)'
    context.lineWidth = 1
    context.stroke()
    context.restore()
  }

  // 花心:雄蕊小點以環狀浮現
  const centerProgress = clamp01((stemProgress - CENTER_START) / (1 - CENTER_START))
  if (centerProgress > 0) {
    const centerAlpha = easeOutCubic(centerProgress)
    const ringRadius = PETAL_SIZE * 0.2
    const dotRadius = PETAL_SIZE * 0.09

    context.fillStyle = `hsla(46, 64%, 70%, ${0.9 * centerAlpha})`
    context.beginPath()
    context.arc(tipX, tipY, dotRadius * 1.2, 0, Math.PI * 2)
    context.fill()

    for (let i = 0; i < CENTER_DOT_COUNT; i++) {
      const angle = (i / CENTER_DOT_COUNT) * Math.PI * 2
      context.beginPath()
      context.arc(
        tipX + Math.cos(angle) * ringRadius,
        tipY + Math.sin(angle) * ringRadius,
        dotRadius,
        0,
        Math.PI * 2,
      )
      context.fill()
    }
  }

  // 時間窗視覺化:底部畫出各階段的窗口
  const barLeft = 40
  const barWidth = width - 80
  const barY = CANVAS_HEIGHT - 44

  interface TimeWindow {
    start: number;
    end: number;
    color: string;
    label: string;
  }
  const windowList: TimeWindow[] = [
    { start: 0, end: 0.62, color: '#4ade80', label: '莖伸展' },
    { start: BUD_START, end: BUD_END, color: '#f9a8d4', label: '花苞' },
    { start: PETAL_START, end: 1, color: '#fb7185', label: '花瓣' },
    { start: CENTER_START, end: 1, color: '#fbbf24', label: '花心' },
  ]

  context.font = '11px sans-serif'
  context.textAlign = 'left'
  for (let i = 0; i < windowList.length; i++) {
    const item = windowList[i]!
    const y = barY + i * 9 - 18
    context.fillStyle = `${item.color}88`
    context.fillRect(barLeft + item.start * barWidth, y, (item.end - item.start) * barWidth, 6)
    context.fillStyle = '#9ca3af'
    context.fillText(item.label, barLeft + item.start * barWidth, y - 2)
  }

  // 目前進度指示線
  const markerX = barLeft + stemProgress * barWidth
  context.beginPath()
  context.moveTo(markerX, barY - 38)
  context.lineTo(markerX, barY + 22)
  context.strokeStyle = '#e5e7eb'
  context.lineWidth = 1.5
  context.stroke()
}

function play() {
  playing.value = true
  playStartTime = performance.now()

  function tick(now: number) {
    const t = Math.min(1, (now - playStartTime) / 3200)
    progress.value = t
    draw()
    if (t < 1) {
      animationFrameId = requestAnimationFrame(tick)
    }
    else {
      playing.value = false
    }
  }
  animationFrameId = requestAnimationFrame(tick)
}

watch(progress, draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-24 text-sm text-gray-400">莖的進度</label>
      <input
        v-model.number="progress"
        type="range"
        min="0"
        max="1"
        step="0.01"
        class="flex-1"
      >
      <span class="w-12 text-right text-sm font-mono text-gray-400">{{ progress.toFixed(2) }}</span>
    </div>
    <div class="flex items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
        :disabled="playing"
        @click="play"
      >
        重播綻放
      </button>
      <span class="text-xs text-gray-400">
        下方色帶是各階段的時間窗,注意花苞在花瓣綻放後淡出
      </span>
    </div>
  </div>
</template>

生長敘事時間窗

整個劇本用一組常數定義,全部以「單根莖的進度」為座標:

ts
/** 莖在前 62% 進度伸展完成 */
const STEM_GROWTH_END = 0.62
/** 花苞鼓起窗口 */
const BUD_START = 0.56
const BUD_END = 0.8
/** 花瓣自此錯落綻放 */
const PETAL_START = 0.74
const PETAL_STAGGER = 0.12
const PETAL_SPAN = 0.14
/** 花心雄蕊浮現 */
const CENTER_START = 0.88

注意這些窗口是互相重疊的。花苞在莖還沒長完(0.56 < 0.62)就開始鼓起,花瓣在花苞還沒完全成形(0.74 < 0.8)就開始綻放。

重疊才是自然的關鍵,真實世界的生長階段從來不會排隊等前一棒結束。

錯落綻放

每片花瓣有自己的 growthOrder(0~1 的隨機值),決定它在綻放大隊中的順位:

ts
for (const petal of flower.petalList) {
  const petalStart = PETAL_START + petal.growthOrder * PETAL_STAGGER
  const petalProgress = clamp01((stemProgress - petalStart) / PETAL_SPAN)
  if (petalProgress <= 0)
    continue

  const eased = easeOutBack(petalProgress)
  // 綻放途中帶一點旋轉,落定時轉正
  const settleSpin = (1 - petalProgress) * 0.5

  drawStamp(context, stamp, tipX, tipY, petal.angleOffset + sway + settleSpin + breathe, flower.petalSize * petal.scale * eased, 0.95,)
}

settleSpin 是個值得偷學的小技巧,花瓣展開途中帶著 0.5 弧度的旋轉偏移,隨進度歸零。視覺上花瓣像是「旋著開出來」,比單純放大高級非常多。

花苞的退場

花苞不能直接消失,要隨花瓣綻放淡出:

ts
const bloomProgress = clamp01((stemProgress - PETAL_START) / (1 - PETAL_START))

// 花苞:花瓣綻放後淡出
const budAlpha = 1 - clamp01(bloomProgress * 1.8)

乘 1.8 讓淡出速度比綻放快,花瓣開到一半多花苞就完全隱形,不會穿幫。

Step 8:錯落時序 — 整片植栽的生長交響

單株的劇本寫好了,現在把鏡頭拉遠。一個容器裡有幾十株植物,如果全部同時破土、同時開花,那畫面比軍隊踢正步還整齊,完全不自然。

關閉錯落再重播一次,所有植株像機器人一樣同時立正

關掉錯落再重播一次,差異一目了然。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 240
const PLANT_COUNT = 8
const STEM_STEP_COUNT = 12
const STEM_GROWTH_END = 0.62

const canvasRef = ref<HTMLCanvasElement | null>(null)
const staggered = ref(true)
const playing = ref(false)

let progress = 1
let animationFrameId = 0
let playStartTime = 0

function clamp01(value: number): number {
  return Math.max(0, Math.min(1, value))
}

function easeOutCubic(t: number): number {
  return 1 - (1 - t) ** 3
}

function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface Stem {
  lean: number;
  droop: number;
  length: number;
  hue: number;
  lightness: number;
  /** 生長延遲(佔整株進度的比例) */
  growthDelay: number;
  /** 生長時長(佔整株進度的比例) */
  growthSpan: number;
}

interface Plant {
  /** 基點位置(0~1,相對容器寬) */
  offsetX: number;
  stemList: Stem[];
  /** 整株生長延遲(佔全域進度的比例) */
  growthDelay: number;
  /** 整株生長步調 */
  growthSpan: number;
}

const plantList: Plant[] = []
const random = createSeededRandom(99)

for (let i = 0; i < PLANT_COUNT; i++) {
  const stemCount = 4 + Math.floor(random() * 3)
  const stemList: Stem[] = []

  for (let j = 0; j < stemCount; j++) {
    const spread = stemCount > 1 ? -0.9 + (j / (stemCount - 1)) * 1.8 : 0
    const lean = spread + (random() - 0.5) * 0.2
    stemList.push({
      lean,
      droop: Math.sign(lean || 1) * (0.5 + random() * 0.6),
      length: 50 + random() * 40 - Math.abs(lean) * 18,
      hue: 95 + random() * 25,
      lightness: 50 + random() * 14,
      // 同株內的莖也錯開:依序延遲 + 隨機抖動
      growthDelay: (j / stemCount) * 0.4 + random() * 0.1,
      growthSpan: 0.66 + random() * 0.1,
    })
  }

  plantList.push({
    offsetX: (i + 0.5) / PLANT_COUNT + (random() - 0.5) * 0.06,
    stemList,
    growthDelay: random() * 0.5,
    growthSpan: 0.5 + random() * 0.2,
  })
}

function drawStem(
  context: CanvasRenderingContext2D,
  baseX: number,
  baseY: number,
  stem: Stem,
  stemProgress: number,
) {
  const lengthEase = easeOutCubic(clamp01(stemProgress / STEM_GROWTH_END))
  const drawnLength = stem.length * lengthEase
  if (drawnLength < 0.5)
    return

  const segmentLength = stem.length / STEM_STEP_COUNT
  let x = baseX
  let y = baseY
  let angle = -Math.PI / 2 + stem.lean
  let accumulated = 0

  context.beginPath()
  context.moveTo(x, y)
  for (let i = 1; i <= STEM_STEP_COUNT; i++) {
    const t = i / STEM_STEP_COUNT
    if (accumulated >= drawnLength)
      break

    angle += stem.droop * t * (2 / STEM_STEP_COUNT)
    const remaining = drawnLength - accumulated
    const stepLength = segmentLength * Math.min(1, remaining / segmentLength)

    x += Math.cos(angle) * stepLength
    y += Math.sin(angle) * stepLength
    accumulated += stepLength
    context.lineTo(x, y)
  }
  context.strokeStyle = `hsla(${stem.hue}, 32%, ${stem.lightness}%, 0.85)`
  context.lineWidth = 2.2
  context.lineCap = 'round'
  context.stroke()
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const baseY = CANVAS_HEIGHT - 20

  // 地平線
  context.beginPath()
  context.moveTo(0, baseY)
  context.lineTo(width, baseY)
  context.strokeStyle = 'rgba(156, 163, 175, 0.25)'
  context.lineWidth = 1
  context.stroke()

  for (const plant of plantList) {
    // 錯落關閉時,整株與莖都同時起跑
    const plantProgress = staggered.value
      ? clamp01((progress - plant.growthDelay) / plant.growthSpan)
      : progress

    for (const stem of plant.stemList) {
      const stemProgress = staggered.value
        ? clamp01((plantProgress - stem.growthDelay) / stem.growthSpan)
        : plantProgress

      drawStem(context, plant.offsetX * width, baseY, stem, stemProgress)
    }
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(`progress=${progress.toFixed(2)}`, 12, 20)
}

function play() {
  playing.value = true
  playStartTime = performance.now()

  function tick(now: number) {
    progress = Math.min(1, (now - playStartTime) / 3600)
    draw()
    if (progress < 1) {
      animationFrameId = requestAnimationFrame(tick)
    }
    else {
      playing.value = false
    }
  }
  animationFrameId = requestAnimationFrame(tick)
}

watch(staggered, draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex flex-wrap items-center gap-3">
      <button
        class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors"
        :class="staggered ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'"
        @click="staggered = !staggered"
      >
        錯落時序:{{ staggered ? 'ON' : 'OFF' }}
      </button>
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
        :disabled="playing"
        @click="play"
      >
        重播生長
      </button>
      <span class="text-xs text-gray-400">
        關閉錯落再重播一次,所有植株像機器人一樣同時立正
      </span>
    </div>
  </div>
</template>

三層進度的傳遞

全域進度(0~1)會經過三層轉換,才變成單根莖的進度:

ts
// 第一層:全域 → 整株
const plantProgress = clamp01(
  (growthProgress - plant.growthDelay) / plant.growthSpan,
)

// 第二層:整株 → 單根莖
const stemProgress = clamp01(
  (plantProgress - stem.growthDelay) / stem.growthSpan,
)

// 第三層:單根莖 → 各器官(Step 6、7 的時間窗)

每層都是同一個公式,減掉延遲、除以時長、夾在 0~1。三層疊起來,每株、每根莖、每片葉、每朵花都有自己的人生進度條。

延遲怎麼分配

株與株之間的延遲依 preset 的「生長節奏」指派,新芽搶先冒頭、矮花叢壓軸綻放:

ts
// 依 preset 的生長節奏決定何時破土
// 延遲拉伸 1.5 倍,拉大整片植栽的交錯感
const growthDelay = Math.min(
  0.78,
  randomBetween(random, preset.growthDelayRange) * 1.5,
)

同株內的莖則是依序延遲加上隨機抖動:

ts
stem.growthDelay = (i / stemList.length) * 0.4 + random() * 0.1
stem.growthSpan = Math.min(0.66 + random() * 0.1, 1 - stem.growthDelay)

growthSpan 都會被 1 - growthDelay 限制,保證全域進度跑到 1 時所有東西都長完,不會有植物遲到。

Step 9:微風搖曳 — 噪聲與曲率

植物長好了,但靜止的植物是塑膠花。要活,就要有風。( ´ ▽ ` )ノ

風分兩種,這一步先做溫柔的持續微風,下一步再做暴力的陣風。

切換成硬轉看看,植物瞬間變成雨刷

重點在「怎麼彎」。切換成「整株硬轉」模式,同樣的風,植物瞬間從活物變成雨刷。

查看範例原始碼
vue
<script setup lang="ts">
import { createNoise2D } from 'simplex-noise'
import { onBeforeUnmount, onMounted, ref } from 'vue'

const CANVAS_HEIGHT = 240
const STEM_COUNT = 6
const STEM_STEP_COUNT = 16

const canvasRef = ref<HTMLCanvasElement | null>(null)
const curvatureMode = ref(true)

const noise2d = createNoise2D()
let animationFrameId = 0

function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface Stem {
  offsetX: number;
  length: number;
  lean: number;
  droop: number;
  swayPhase: number;
  hue: number;
}

const stemList: Stem[] = []
const random = createSeededRandom(55)
for (let i = 0; i < STEM_COUNT; i++) {
  stemList.push({
    offsetX: (i + 0.5) / STEM_COUNT,
    length: 120 + random() * 60,
    lean: (random() - 0.5) * 0.3,
    droop: (random() - 0.5) * 0.8,
    swayPhase: random() * Math.PI * 2,
    hue: 95 + random() * 25,
  })
}

/** 持續微風:低頻噪聲 + 慢速正弦(已放大數倍方便觀察) */
function sampleAmbientSway(worldX: number, time: number, phase: number): number {
  return noise2d(time * 0.00022 + phase * 0.13, worldX * 0.003) * 0.05 * 4
    + Math.sin(time * 0.0011 + phase) * 0.016 * 4
}

function draw(time: number) {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const baseY = CANVAS_HEIGHT - 20

  context.beginPath()
  context.moveTo(0, baseY)
  context.lineTo(width, baseY)
  context.strokeStyle = 'rgba(156, 163, 175, 0.25)'
  context.lineWidth = 1
  context.stroke()

  for (const stem of stemList) {
    const baseX = stem.offsetX * width
    const bend = sampleAmbientSway(baseX, time, stem.swayPhase)
    const segmentLength = stem.length / STEM_STEP_COUNT

    let x = baseX
    let y = baseY
    let angle = -Math.PI / 2 + stem.lean

    context.beginPath()
    context.moveTo(x, y)
    for (let i = 1; i <= STEM_STEP_COUNT; i++) {
      const t = i / STEM_STEP_COUNT
      angle += stem.droop * t * (2 / STEM_STEP_COUNT)

      // 曲率模式:彎曲沿莖累積,越靠尖端偏轉越大(t^1.4)
      // 硬轉模式:整根莖繞基部旋轉同一個角度
      const bentAngle = curvatureMode.value
        ? angle + bend * t ** 1.4
        : angle + bend

      x += Math.cos(bentAngle) * segmentLength
      y += Math.sin(bentAngle) * segmentLength
      context.lineTo(x, y)
    }
    context.strokeStyle = `hsla(${stem.hue}, 32%, 56%, 0.85)`
    context.lineWidth = 2.6
    context.lineCap = 'round'
    context.stroke()
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(curvatureMode.value ? 'bend * t^1.4(曲率累積)' : 'bend(整株硬轉)', 12, 20)
}

function animate(time: number) {
  draw(time)
  animationFrameId = requestAnimationFrame(animate)
}

onMounted(() => {
  animationFrameId = requestAnimationFrame(animate)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <button
        class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors"
        :class="curvatureMode ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'"
        @click="curvatureMode = !curvatureMode"
      >
        {{ curvatureMode ? '曲率彎曲' : '整株硬轉' }}
      </button>
      <span class="text-xs text-gray-400">
        切換成硬轉看看,植物瞬間變成雨刷
      </span>
    </div>
  </div>
</template>

噪聲驅動的搖曳

微風用 Simplex Noise 加慢速正弦混合,與 Starry Sea 魚群用的是同一招:

ts
function sampleAmbientSway(worldX: number, time: number, phase: number): number {
  return noise2D(time * 0.00022 + phase * 0.13, worldX * 0.003) * 0.05
    + Math.sin(time * 0.0011 + phase) * 0.016
}
  • 噪聲項輸入帶有 worldX,所以風是「一片一片」吹過去,相鄰植株的搖曳相似但不同步
  • 正弦項補上規律的底噪,讓搖曳永遠不會完全停止

曲率彎曲

拿到搖曳角度後,重點是怎麼施加到莖上:

ts
const angle = point.segmentAngle + bend * point.t ** 1.4

彎曲量乘上 t ** 1.4,基部幾乎不動、越靠尖端偏轉越大。物理上這對應植物的莖基部粗硬、梢部柔軟,彎曲沿著莖身「累積」。

如果少了 t ** 1.4,整根莖繞基部剛性旋轉,就是範例裡的雨刷模式。差一個指數,質感天差地別。

整株同步的細節

一株植物有很多根莖,如果每根莖各自取樣噪聲,整株會散開亂晃,像一群沒有對齊的伴舞。

所以微風以「株」為單位取樣,全株共用,再讓每根莖加上一點自己的細顫:

ts
// 微風以整株為單位取樣,莖葉同步搖曳才不會散開
const plantSway = windField.sampleAmbientSway(plant.originX, time, plant.swayPhase) * swayIntensity

// 每根莖再加上小幅細顫
const stemDetailSway = Math.sin(time * 0.0014 + stem.swayPhase) * 0.012 * swayIntensity
const stemBend = (plant.springAngle + plantSway + stemDetailSway) * stem.flexibility

flexibility 是各 preset 的柔軟度,草最軟(1.0)、野花硬一點(0.8)。

黃金葛比較特別,它的莖攀在邊框上,flexibility: 0 完全不動,改用放大 2.2 倍的葉片顫動來表現風。莖不動葉子動,攀附植物的風感就出來了。

另外搖曳強度 swayIntensity 會隨生長尾段漸入:

ts
// 微風隨生長尾段漸強
const swayIntensity = Math.max(0, Math.min(1, (progress.value - 0.55) / 0.45))

剛破土的嫩芽不會跟著風搖頭晃腦,長到一半才慢慢開始有風的存在感。

Step 10:陣風與彈簧 — 風是有形狀的

微風是背景音,陣風才是高潮。不定時一陣風從畫面一側掃到另一側,所有植物依序彎腰、再震盪回彈。

這需要兩個系統合作,風場負責描述風的形狀與行進,彈簧負責植物的受力反應。

26
3.4
把阻尼調到 1 以下,風過之後會晃個不停

按「吹一陣風」觀察藍色風前緣掃過時植物的反應,再把阻尼調低感受「晃個不停」的效果。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const CANVAS_HEIGHT = 240
const STEM_COUNT = 12
const STEM_STEP_COUNT = 12
const MAX_SPRING_ANGLE = 0.45

const canvasRef = ref<HTMLCanvasElement | null>(null)
const stiffness = ref(26)
const damping = ref(3.4)
const frontVisible = ref(true)

let animationFrameId = 0
let lastTime = 0

function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface Stem {
  offsetX: number;
  length: number;
  lean: number;
  hue: number;
  /** 彈簧角度(弧度) */
  springAngle: number;
  /** 彈簧角速度(弧度/秒) */
  springVelocity: number;
}

const stemList: Stem[] = []
const random = createSeededRandom(31)
for (let i = 0; i < STEM_COUNT; i++) {
  stemList.push({
    offsetX: (i + 0.5) / STEM_COUNT + (random() - 0.5) * 0.04,
    length: 100 + random() * 60,
    lean: (random() - 0.5) * 0.3,
    hue: 95 + random() * 25,
    springAngle: 0,
    springVelocity: 0,
  })
}

interface Gust {
  startTime: number;
  duration: number;
  strength: number;
  direction: 1 | -1;
  frontWidth: number;
}

let activeGust: Gust | null = null
let nextGustTime = 0

function spawnGust(time: number) {
  activeGust = {
    startTime: time,
    duration: 1800 + Math.random() * 1200,
    strength: 0.25 + Math.random() * 0.2,
    direction: Math.random() < 0.72 ? 1 : -1,
    frontWidth: 120 + Math.random() * 160,
  }
}

/** 取樣指定位置目前的陣風彎曲目標(弧度) */
function sampleGustBend(worldX: number, time: number, containerWidth: number): number {
  if (!activeGust)
    return 0

  const { startTime, duration, strength, direction, frontWidth } = activeGust
  const phase = (time - startTime) / duration
  const travelDistance = containerWidth + frontWidth * 2

  // 風前緣由容器一側掃向另一側
  const front = direction === 1
    ? -frontWidth + travelDistance * phase
    : containerWidth + frontWidth - travelDistance * phase

  // 高斯包絡:離風前緣越遠影響越小
  const normalizedDistance = (worldX - front) / frontWidth
  const envelope = Math.exp(-normalizedDistance * normalizedDistance)
  // 時間包絡:整陣風淡入淡出
  const temporalEase = Math.sin(Math.PI * Math.min(1, phase))

  return strength * direction * envelope * temporalEase
}

function getGustFrontX(time: number, containerWidth: number): number | null {
  if (!activeGust)
    return null
  const { startTime, duration, direction, frontWidth } = activeGust
  const phase = (time - startTime) / duration
  const travelDistance = containerWidth + frontWidth * 2
  return direction === 1
    ? -frontWidth + travelDistance * phase
    : containerWidth + frontWidth - travelDistance * phase
}

function draw(time: number, deltaTime: number) {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  // 陣風排程:結束後隔一段時間再來一陣
  if (activeGust && time > activeGust.startTime + activeGust.duration) {
    activeGust = null
    nextGustTime = time + 3000 + Math.random() * 4000
  }
  if (!activeGust && time >= nextGustTime) {
    spawnGust(time)
  }

  const baseY = CANVAS_HEIGHT - 20

  // 風前緣視覺化
  if (frontVisible.value) {
    const frontX = getGustFrontX(time, width)
    if (frontX !== null && activeGust) {
      const gradient = context.createLinearGradient(
        frontX - activeGust.frontWidth,
        0,
        frontX + activeGust.frontWidth,
        0,
      )
      gradient.addColorStop(0, 'rgba(147, 197, 253, 0)')
      gradient.addColorStop(0.5, 'rgba(147, 197, 253, 0.12)')
      gradient.addColorStop(1, 'rgba(147, 197, 253, 0)')
      context.fillStyle = gradient
      context.fillRect(frontX - activeGust.frontWidth, 0, activeGust.frontWidth * 2, CANVAS_HEIGHT)
    }
  }

  context.beginPath()
  context.moveTo(0, baseY)
  context.lineTo(width, baseY)
  context.strokeStyle = 'rgba(156, 163, 175, 0.25)'
  context.lineWidth = 1
  context.stroke()

  for (const stem of stemList) {
    const baseX = stem.offsetX * width

    // 彈簧積分:陣風是移動目標,風走了彈簧自己震盪回彈
    const targetBend = sampleGustBend(baseX, time, width)
    const acceleration = (targetBend - stem.springAngle) * stiffness.value
      - stem.springVelocity * damping.value
    stem.springVelocity += acceleration * deltaTime
    stem.springAngle += stem.springVelocity * deltaTime

    // 限制最大彎曲,避免被吹倒
    if (stem.springAngle > MAX_SPRING_ANGLE) {
      stem.springAngle = MAX_SPRING_ANGLE
      stem.springVelocity = Math.min(0, stem.springVelocity)
    }
    else if (stem.springAngle < -MAX_SPRING_ANGLE) {
      stem.springAngle = -MAX_SPRING_ANGLE
      stem.springVelocity = Math.max(0, stem.springVelocity)
    }

    const segmentLength = stem.length / STEM_STEP_COUNT
    let x = baseX
    let y = baseY
    let angle = -Math.PI / 2 + stem.lean

    context.beginPath()
    context.moveTo(x, y)
    for (let i = 1; i <= STEM_STEP_COUNT; i++) {
      const t = i / STEM_STEP_COUNT
      const bentAngle = angle + stem.springAngle * t ** 1.4
      x += Math.cos(bentAngle) * segmentLength
      y += Math.sin(bentAngle) * segmentLength
      context.lineTo(x, y)
    }
    context.strokeStyle = `hsla(${stem.hue}, 32%, 56%, 0.85)`
    context.lineWidth = 2.6
    context.lineCap = 'round'
    context.stroke()
  }
}

function triggerGust() {
  spawnGust(performance.now())
}

function animate(time: number) {
  const deltaTime = lastTime > 0 ? Math.min(0.05, (time - lastTime) / 1000) : 0.016
  lastTime = time
  draw(time, deltaTime)
  animationFrameId = requestAnimationFrame(animate)
}

onMounted(() => {
  nextGustTime = performance.now() + 800
  animationFrameId = requestAnimationFrame(animate)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">勁度 stiffness</label>
      <input
        v-model.number="stiffness"
        type="range"
        min="5"
        max="80"
        step="1"
        class="flex-1"
      >
      <span class="w-10 text-right text-sm font-mono text-gray-400">{{ stiffness }}</span>
    </div>
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">阻尼 damping</label>
      <input
        v-model.number="damping"
        type="range"
        min="0.5"
        max="12"
        step="0.1"
        class="flex-1"
      >
      <span class="w-10 text-right text-sm font-mono text-gray-400">{{ damping.toFixed(1) }}</span>
    </div>
    <div class="flex flex-wrap items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="triggerGust"
      >
        吹一陣風
      </button>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="frontVisible"
          type="checkbox"
        >
        顯示風前緣
      </label>
      <span class="text-xs text-gray-400">
        把阻尼調到 1 以下,風過之後會晃個不停
      </span>
    </div>
  </div>
</template>

風前緣

一陣風被建模成一個移動的「風前緣」,從容器一側掃向另一側:

ts
function sampleGustBend(worldX: number): number {
  const phase = (currentTime - startTime) / duration
  const travelDistance = currentContainerWidth + frontWidth * 2

  // 風前緣由容器一側掃向另一側
  const front = direction === 1
    ? -frontWidth + travelDistance * phase
    : currentContainerWidth + frontWidth - travelDistance * phase

  const normalizedDistance = (worldX - front) / frontWidth
  const envelope = Math.exp(-normalizedDistance * normalizedDistance)
  const temporalEase = Math.sin(Math.PI * Math.min(1, phase))

  return strength * direction * envelope * temporalEase
}

兩個包絡函數各司其職:

  • 空間包絡 exp(-d²):高斯鐘形曲線,離風前緣越遠影響越小。植物不是同時被吹,而是風到了才彎,這就是「掃過」的感覺。
  • 時間包絡 sin(π · phase):整陣風淡入淡出,開頭結尾都不突兀。

彈簧阻尼系統

風給的是「目標彎曲角」,植物不會瞬間彎到位,而是用彈簧追過去:

ts
/** 彈簧勁度(1/s²),決定回彈速度 */
const SPRING_STIFFNESS = 26
/** 彈簧阻尼(1/s),低於臨界阻尼讓回彈帶有震盪 */
const SPRING_DAMPING = 3.4

const acceleration = (targetBend - plant.springAngle) * SPRING_STIFFNESS
  - plant.springVelocity * SPRING_DAMPING

plant.springVelocity += acceleration * deltaTime
plant.springAngle += plant.springVelocity * deltaTime

這就是經典的彈簧阻尼微分方程,用半隱式歐拉法積分。妙處在風走了之後,彈簧朝向歸零的目標自然震盪衰減,「風過草回彈、晃兩下停住」的演出完全免費。

阻尼 3.4 刻意低於臨界阻尼,臨界阻尼會平滑歸位毫無彈性,欠阻尼才有植物的 Q 彈。範例中把阻尼拉低到 1 以下,就能看到晃很久才停的效果。

安全帽

彈簧加衝量有機會疊出誇張的角度,所以彎曲角有上限:

ts
/** 彈簧角度上限(弧度),避免被吹倒 */
const MAX_SPRING_ANGLE = 0.45

if (plant.springAngle > MAX_SPRING_ANGLE) {
  plant.springAngle = MAX_SPRING_ANGLE
  plant.springVelocity = Math.min(0, plant.springVelocity)
}

撞到上限時順便把「往外衝」的速度歸零,不然彈簧會貼著牆抖動。

還有一個細節,植物可能長在頂部(倒著長)或側邊(橫著長),同一陣水平風對它們的效果不同。每株預先算好 gustFactor = Math.cos(rotation),把世界座標的風投影到植株的局部彎曲方向,倒掛的垂藤被吹時就會正確地往「它的側面」彎。

Step 11:滑鼠互動 — 撥動草叢

風會吹,再來要讓使用者也能「摸」。滑鼠掃過植株,植株順著掃動方向彎折再回彈,像用手撥草叢。

因為彈簧系統已經就位,這一步出乎意料地簡單,滑鼠只要負責「給彈簧一個衝量」就好。

滑鼠左右掃過草叢,掃得越快彎得越大力

滑鼠左右掃過草叢試試,掃得越快彎得越大力。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const CANVAS_HEIGHT = 240
const STEM_COUNT = 14
const STEM_STEP_COUNT = 12
const SPRING_STIFFNESS = 26
const SPRING_DAMPING = 3.4
const MAX_SPRING_ANGLE = 0.45

const canvasRef = ref<HTMLCanvasElement | null>(null)
const radiusVisible = ref(true)

let animationFrameId = 0
let lastTime = 0

// 滑鼠狀態:用前後兩次事件的位移除以時間差,得到速度(px/ms)
let mouseX = -1000
let mouseY = -1000
let mouseVelocityX = 0
let lastMouseTime = 0

function createSeededRandom(seed: number): () => number {
  let state = seed
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

interface Stem {
  offsetX: number;
  length: number;
  lean: number;
  hue: number;
  springAngle: number;
  springVelocity: number;
}

const stemList: Stem[] = []
const random = createSeededRandom(77)
for (let i = 0; i < STEM_COUNT; i++) {
  stemList.push({
    offsetX: (i + 0.5) / STEM_COUNT + (random() - 0.5) * 0.04,
    length: 90 + random() * 70,
    lean: (random() - 0.5) * 0.3,
    hue: 95 + random() * 25,
    springAngle: 0,
    springVelocity: 0,
  })
}

/** 計算點到線段的最短距離 */
function calculatePointToSegmentDistance(
  pointX: number,
  pointY: number,
  startX: number,
  startY: number,
  endX: number,
  endY: number,
): number {
  const segmentX = endX - startX
  const segmentY = endY - startY
  const lengthSquared = segmentX * segmentX + segmentY * segmentY

  if (lengthSquared === 0)
    return Math.hypot(pointX - startX, pointY - startY)

  const t = Math.max(0, Math.min(1, (
    (pointX - startX) * segmentX + (pointY - startY) * segmentY
  ) / lengthSquared))

  return Math.hypot(
    pointX - (startX + segmentX * t),
    pointY - (startY + segmentY * t),
  )
}

function onPointerMove(event: PointerEvent) {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const rect = canvas.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top
  const now = performance.now()

  if (lastMouseTime > 0) {
    const deltaTime = Math.max(1, now - lastMouseTime)
    mouseVelocityX = (x - mouseX) / deltaTime
  }
  mouseX = x
  mouseY = y
  lastMouseTime = now

  const baseY = CANVAS_HEIGHT - 20
  const width = canvas.clientWidth

  // 將滑鼠掃過的衝量施加到鄰近植株的彈簧上
  for (const stem of stemList) {
    const baseX = stem.offsetX * width
    const reach = stem.length * 0.85

    const distance = calculatePointToSegmentDistance(
      mouseX,
      mouseY,
      baseX,
      baseY,
      baseX,
      baseY - reach,
    )

    const radius = 26 + stem.length * 0.2
    if (distance > radius)
      continue

    // 越靠近植株軸線,推力越大
    const falloff = 1 - distance / radius
    const impulse = Math.max(-3, Math.min(3, mouseVelocityX * 2.2)) * falloff

    stem.springVelocity = Math.max(-4, Math.min(4, stem.springVelocity + impulse))
  }
}

function onPointerLeave() {
  mouseX = -1000
  mouseY = -1000
  lastMouseTime = 0
}

function draw(deltaTime: number) {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  const baseY = CANVAS_HEIGHT - 20

  context.beginPath()
  context.moveTo(0, baseY)
  context.lineTo(width, baseY)
  context.strokeStyle = 'rgba(156, 163, 175, 0.25)'
  context.lineWidth = 1
  context.stroke()

  for (const stem of stemList) {
    // 沒有風,目標角度為 0:被撥動後彈簧自己盪回原位
    const acceleration = (0 - stem.springAngle) * SPRING_STIFFNESS
      - stem.springVelocity * SPRING_DAMPING
    stem.springVelocity += acceleration * deltaTime
    stem.springAngle += stem.springVelocity * deltaTime

    if (stem.springAngle > MAX_SPRING_ANGLE) {
      stem.springAngle = MAX_SPRING_ANGLE
      stem.springVelocity = Math.min(0, stem.springVelocity)
    }
    else if (stem.springAngle < -MAX_SPRING_ANGLE) {
      stem.springAngle = -MAX_SPRING_ANGLE
      stem.springVelocity = Math.max(0, stem.springVelocity)
    }

    const baseX = stem.offsetX * width
    const segmentLength = stem.length / STEM_STEP_COUNT
    let x = baseX
    let y = baseY
    let angle = -Math.PI / 2 + stem.lean

    context.beginPath()
    context.moveTo(x, y)
    for (let i = 1; i <= STEM_STEP_COUNT; i++) {
      const t = i / STEM_STEP_COUNT
      const bentAngle = angle + stem.springAngle * t ** 1.4
      x += Math.cos(bentAngle) * segmentLength
      y += Math.sin(bentAngle) * segmentLength
      context.lineTo(x, y)
    }
    context.strokeStyle = `hsla(${stem.hue}, 32%, 56%, 0.85)`
    context.lineWidth = 2.6
    context.lineCap = 'round'
    context.stroke()
  }

  // 滑鼠影響範圍示意
  if (radiusVisible.value && mouseX > -100) {
    context.beginPath()
    context.arc(mouseX, mouseY, 40, 0, Math.PI * 2)
    context.strokeStyle = 'rgba(251, 191, 36, 0.4)'
    context.lineWidth = 1
    context.setLineDash([4, 4])
    context.stroke()
    context.setLineDash([])
  }
}

function animate(time: number) {
  const deltaTime = lastTime > 0 ? Math.min(0.05, (time - lastTime) / 1000) : 0.016
  lastTime = time
  draw(deltaTime)
  animationFrameId = requestAnimationFrame(animate)
}

onMounted(() => {
  animationFrameId = requestAnimationFrame(animate)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full cursor-crosshair rounded-lg"
      @pointermove="onPointerMove"
      @pointerleave="onPointerLeave"
    />
    <div class="flex items-center gap-3">
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="radiusVisible"
          type="checkbox"
        >
        顯示影響範圍
      </label>
      <span class="text-xs text-gray-400">
        滑鼠左右掃過草叢,掃得越快彎得越大力
      </span>
    </div>
  </div>
</template>

命中判定

植株不是一個點,是一條從基部伸向尖端的「軸線」。所以用點到線段的最短距離判定:

ts
// 植株軸線:基部到尖端的近似線段
const tipX = plant.originX + plant.reach * 0.85 * sin
const tipY = plant.originY - plant.reach * 0.85 * cos

const distance = calculatePointToSegmentDistance(
  mouseX,
  mouseY,
  plant.originX,
  plant.originY,
  tipX,
  tipY,
)

const radius = 26 + plant.reach * 0.2
if (distance > radius)
  continue

判定半徑隨植株大小調整,大株蕨類的「身體」比小草寬,摸起來才合理。

衝量計算

衝量大小由滑鼠速度決定,方向要投影到植株的局部座標:

ts
const falloff = 1 - distance / radius
// 世界座標速度投影到植株局部 x 軸(彎曲方向)
const localVelocityX = velocityX * cos + velocityY * sin
const impulse = Math.max(-3, Math.min(3, localVelocityX * 2.2)) * falloff

plant.springVelocity = Math.max(-4, Math.min(4, plant.springVelocity + impulse))

注意衝量是加到 springVelocity 而不是直接改角度。改角度是瞬移,改速度是「推了一把」,後續的彎折、回彈、震盪全部交給彈簧自然演化。

兩層 clamp 限制單次衝量與累積速度,不然滑鼠瘋狂亂甩,植物會被甩到外太空。ヽ(́◕◞౪◟◕‵)ノ

Step 12:植株分布 — 自然的亂

目前為止植物本身已經很完整了,但「種在哪裡」同樣是門學問。

等距排列像受檢閱的儀隊,完全隨機又會擠成一團。自然界的植物會叢聚,幾株擠在一起成一叢,叢與叢之間留空隙,偶爾有落單的散兵。

90
0.00
綠色為叢內植株、藍綠色為落單散兵,edgeBias 拉滿會聚到兩端

按「重新散佈」多骰幾次,再把 edgeBias 拉滿,觀察分布如何聚到兩端。

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const CANVAS_HEIGHT = 260

const canvasRef = ref<HTMLCanvasElement | null>(null)
const spacing = ref(90)
const edgeBias = ref(0)
const clusterCenterVisible = ref(true)

let seed = 3
let animationFrameId = 0

function createSeededRandom(seedValue: number): () => number {
  let state = seedValue
  return () => {
    state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
    return (state >>> 0) / 0xFFFFFFFF
  }
}

/** 將 0~1 的均勻值依邊緣偏好推向兩端 */
function applyEdgeBias(value: number, bias: number): number {
  if (bias <= 0)
    return value

  const centered = (value - 0.5) * 2
  const side = centered === 0 ? 1 : Math.sign(centered)
  // 冪次推向兩端,再保留與中央的最小距離
  const pushed = Math.abs(centered) ** (1 / (1 + bias * 2.5))
  const minimumOffset = bias * 0.3
  const biased = side * (minimumOffset + (1 - minimumOffset) * pushed)
  return biased * 0.5 + 0.5
}

interface LayoutResult {
  clusterCenterList: number[];
  anchorList: number[];
  soloList: number[];
}

/** 沿一條邊產生叢聚式錨點 */
function createEdgeAnchorList(edgeLength: number): LayoutResult {
  const random = createSeededRandom(seed)

  const targetCount = Math.max(1, Math.round(edgeLength / spacing.value))
  const plantCount = Math.max(1, Math.round(targetCount * (0.75 + random() * 0.5)))

  // 叢心隨機散佈,平均 2~3 株一叢
  const clusterCount = Math.max(1, Math.round(plantCount / (1.8 + random() * 1.4)))
  const clusterCenterList: number[] = []
  for (let i = 0; i < clusterCount; i++) {
    clusterCenterList.push(edgeLength * (0.06 + applyEdgeBias(random(), edgeBias.value) * 0.88))
  }

  const edgeMargin = edgeLength * 0.03
  const anchorList: number[] = []
  const soloList: number[] = []

  for (let i = 0; i < plantCount; i++) {
    let offset: number
    let solo = false

    if (random() < 0.18) {
      // 散兵:隨機落單
      offset = edgeLength * (0.04 + applyEdgeBias(random(), edgeBias.value) * 0.92)
      solo = true
    }
    else {
      // 兩次隨機相加近似常態分佈:叢內密、外圍疏
      const center = clusterCenterList[Math.floor(random() * clusterCenterList.length)]!
      offset = center + (random() + random() - 1) * spacing.value * 0.9
    }

    const clamped = Math.min(edgeLength - edgeMargin, Math.max(edgeMargin, offset))
    if (solo)
      soloList.push(clamped)
    else
      anchorList.push(clamped)
  }

  return { clusterCenterList, anchorList, soloList }
}

/** 在錨點畫一小撮草作為示意 */
function drawSprig(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  color: string,
) {
  for (const lean of [-0.5, 0, 0.5]) {
    context.beginPath()
    context.moveTo(x, y)
    context.quadraticCurveTo(
      x + lean * 6,
      y - 10,
      x + lean * 11,
      y - 17,
    )
    context.strokeStyle = color
    context.lineWidth = 1.8
    context.lineCap = 'round'
    context.stroke()
  }
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const context = canvas.getContext('2d')
  if (!context)
    return

  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth
  if (canvas.width !== Math.round(width * dpr)) {
    canvas.style.height = `${CANVAS_HEIGHT}px`
    canvas.width = Math.round(width * dpr)
    canvas.height = Math.round(CANVAS_HEIGHT * dpr)
  }
  context.setTransform(dpr, 0, 0, dpr, 0, 0)
  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  // 容器卡片示意
  const cardLeft = 30
  const cardTop = 40
  const cardWidth = width - 60
  const cardBottom = CANVAS_HEIGHT - 56

  context.beginPath()
  context.roundRect(cardLeft, cardTop, cardWidth, cardBottom - cardTop, 12)
  context.fillStyle = 'rgba(55, 65, 81, 0.5)'
  context.fill()
  context.strokeStyle = 'rgba(156, 163, 175, 0.4)'
  context.lineWidth = 1
  context.stroke()

  const { clusterCenterList, anchorList, soloList } = createEdgeAnchorList(cardWidth)

  // 叢心
  if (clusterCenterVisible.value) {
    for (const center of clusterCenterList) {
      context.beginPath()
      context.arc(cardLeft + center, cardBottom, 7, 0, Math.PI * 2)
      context.strokeStyle = 'rgba(251, 191, 36, 0.7)'
      context.lineWidth = 1.5
      context.setLineDash([3, 3])
      context.stroke()
      context.setLineDash([])
    }
  }

  // 叢內植株與散兵
  for (const anchor of anchorList) {
    drawSprig(context, cardLeft + anchor, cardBottom, 'hsla(100, 35%, 58%, 0.9)')
  }
  for (const solo of soloList) {
    drawSprig(context, cardLeft + solo, cardBottom, 'hsla(170, 40%, 60%, 0.9)')
  }

  context.fillStyle = '#9ca3af'
  context.font = '12px sans-serif'
  context.textAlign = 'left'
  context.fillText(
    `共 ${anchorList.length + soloList.length} 株(叢聚 ${anchorList.length}、散兵 ${soloList.length})`,
    12,
    20,
  )
}

function reroll() {
  seed = Math.floor(Math.random() * 100000)
  draw()
}

watch([spacing, edgeBias, clusterCenterVisible], draw)

onMounted(() => {
  animationFrameId = requestAnimationFrame(draw)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
    />
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">spacing 間距</label>
      <input
        v-model.number="spacing"
        type="range"
        min="50"
        max="160"
        step="5"
        class="flex-1"
      >
      <span class="w-10 text-right text-sm font-mono text-gray-400">{{ spacing }}</span>
    </div>
    <div class="flex items-center gap-3">
      <label class="w-32 text-sm text-gray-400">edgeBias 邊緣偏好</label>
      <input
        v-model.number="edgeBias"
        type="range"
        min="0"
        max="1"
        step="0.05"
        class="flex-1"
      >
      <span class="w-10 text-right text-sm font-mono text-gray-400">{{ edgeBias.toFixed(2) }}</span>
    </div>
    <div class="flex flex-wrap items-center gap-3">
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="reroll"
      >
        重新散佈
      </button>
      <label class="flex items-center gap-1.5 text-sm text-gray-400">
        <input
          v-model="clusterCenterVisible"
          type="checkbox"
        >
        顯示叢心
      </label>
      <span class="text-xs text-gray-400">
        綠色為叢內植株、藍綠色為落單散兵,edgeBias 拉滿會聚到兩端
      </span>
    </div>
  </div>
</template>

叢聚演算法

三個步驟生出自然的分布:

ts
// 1. 依邊長與間距決定總株數(帶隨機浮動)
const targetCount = Math.max(1, Math.round(edgeLength / spacing))
const plantCount = Math.max(1, Math.round(targetCount * (0.75 + random() * 0.5)))

// 2. 叢心隨機散佈,平均 2~3 株一叢
const clusterCount = Math.max(1, Math.round(plantCount / (1.8 + random() * 1.4)))

// 3. 每株掛到隨機叢心,或 18% 機率當散兵
if (random() < 0.18) {
  offset = edgeLength * (0.04 + applyEdgeBias(random(), edgeBias) * 0.92)
}
else {
  const center = clusterCenterList[Math.floor(random() * clusterCenterList.length)]!
  offset = center + (random() + random() - 1) * spacing * 0.9
}

random() + random() - 1 是個小巧思,兩個均勻隨機相加會近似常態分佈(中間機率高、兩端低),叢內自然呈現「中間密、外圍疏」。

邊緣偏好

青草類植物喜歡聚在容器的邊角,這個偏好用冪函數實現:

ts
/** 將 0~1 的均勻值依邊緣偏好推向兩端 */
function applyEdgeBias(value: number, edgeBias: number): number {
  const centered = (value - 0.5) * 2
  const side = centered === 0 ? 1 : Math.sign(centered)
  // 冪次推向兩端,再保留與中央的最小距離
  const pushed = Math.abs(centered) ** (1 / (1 + edgeBias * 2.5))
  const minimumOffset = edgeBias * 0.3
  const biased = side * (minimumOffset + (1 - minimumOffset) * pushed)
  return biased * 0.5 + 0.5
}

對 0~1 的值取「小於 1 的冪次」會把數值推向 1,先把座標居中成 -1~1 再做,效果就是往兩端推。和 Starry Sea 的 spreadCurve 是同一招的反向應用。

角落與圓角

角落植株(蕨類、垂藤)用固定錨點,但容器有圓角時不能直接釘在直角頂點,會懸空在圓弧外:

ts
// 45 度弧線中點與直角頂點的距離
const arcInset = cornerRadius * (1 - Math.SQRT1_2)
// 圓角越大,植株越貼近對角線方向(上限 45 度)
const tilt = Math.min(45, 15 + cornerRadius)

錨點沿圓弧內移到 45 度弧線中點,植株再朝對角線傾斜,圓角卡片的角落就能長出服貼的蕨類。元件還會自動偵測內容的 border-radius,使用者什麼都不用設。

畫布要比容器大

植物從邊框長出來,自然會超出容器範圍。如果 canvas 跟容器一樣大,超出的部分就被裁掉了。

所以每次生成植株後,會把所有植株的局部包圍盒(含葉片外伸與彎曲餘裕)旋轉到世界座標,算出四個方向的溢出量,讓 canvas 向外擴張:

ts
return {
  top: Math.ceil(Math.max(0, -minY)),
  right: Math.ceil(Math.max(0, maxX - containerWidth)),
  bottom: Math.ceil(Math.max(0, maxY - containerHeight)),
  left: Math.ceil(Math.max(0, -minX)),
}

canvas 用絕對定位往外撐、pointer-events: none 不擋互動,內容物完全不知道自己被植物包圍了。

Step 13:全部整合 — 完整元件

所有零件都做完了,最後組裝成 Vue 元件。直接玩玩完成品:

被植物包圍的卡片 滑鼠掃過植株試試,偶爾還有陣風與飄落的花瓣
查看範例原始碼
vue
<script setup lang="ts">
import type { PlantPresetName } from '../../../../src/components/wrapper-plant/plant-presets'
import { computed, reactive, useTemplateRef } from 'vue'
import WrapperPlant from '../../../../src/components/wrapper-plant/wrapper-plant.vue'

interface PresetOption {
  value: PlantPresetName;
  label: string;
  enabled: boolean;
}

const presetOptionList = reactive<PresetOption[]>([
  { value: 'grass', label: '草叢', enabled: true },
  { value: 'flower', label: '野花', enabled: true },
  { value: 'lavender', label: '薰衣草', enabled: false },
  { value: 'fern', label: '蕨類', enabled: true },
  { value: 'vine', label: '垂藤', enabled: true },
  { value: 'pothos', label: '黃金葛', enabled: false },
  { value: 'ivy', label: '攀藤', enabled: false },
])

const selectedPresetList = computed(() =>
  presetOptionList
    .filter((option) => option.enabled)
    .map((option) => option.value),
)

const plantRef = useTemplateRef<InstanceType<typeof WrapperPlant>>('plantRef')

function replay() {
  plantRef.value?.reset()
  plantRef.value?.grow()
}
</script>

<template>
  <div class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6 dark:border-gray-700">
    <div class="flex flex-wrap items-center gap-2">
      <button
        v-for="option in presetOptionList"
        :key="option.value"
        type="button"
        class="border rounded-full px-3.5 py-1 text-sm transition"
        :class="option.enabled
          ? 'border-transparent bg-[#5a8a3c] text-white'
          : 'border-gray-300 text-gray-500 hover:border-[#5a8a3c] hover:text-[#5a8a3c]'"
        @click="option.enabled = !option.enabled"
      >
        {{ option.label }}
      </button>
      <button
        class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
        @click="replay"
      >
        重新生長
      </button>
    </div>

    <wrapper-plant
      ref="plantRef"
      class="w-full"
      :preset-list="selectedPresetList"
    >
      <div class="min-h-[240px] w-full flex flex-col items-center justify-center gap-2 border border-gray-200 rounded-2xl bg-gray-50 p-8 dark:border-gray-700 dark:bg-gray-800">
        <span class="text-lg font-bold">被植物包圍的卡片</span>
        <span class="text-sm text-gray-500 dark:text-gray-400">
          滑鼠掃過植株試試,偶爾還有陣風與飄落的花瓣
        </span>
      </div>
    </wrapper-plant>
  </div>
</template>

每一幀的流程

txt
風場推進(排程與結束陣風)
  → 每株取樣陣風 → 彈簧積分(Step 10)
    → 粒子推進(花瓣、孢子)
      → 渲染:每株 → 每莖
        → 彎曲重建骨架(Step 3、9)
          → 絲帶莖身(Step 4)
            → 印章貼上葉片與花(Step 5~7)

效能的最後一塊拼圖

畫面美是一回事,「裝飾元件」更要緊的是不能搶資源。元件做了好幾層的節流:

進場觸發。用 IntersectionObserver 等元件過半進入畫面才開始生長:

ts
// 過半進入畫面即觸發;要求完整可見(ratio 1)會因次像素誤差永遠觸發不了
{ threshold: 0.55 }

註解裡那句是踩過的坑,threshold 設 1 在某些縮放比例下會因為次像素誤差永遠不觸發,留個 0.55 最穩。

捲出畫面就睡覺useElementVisibility 偵測元件是否在視口內,搭配一個 computed 決定動畫迴圈開或關:

ts
const shouldAnimate = computed(() => {
  // 捲出畫面時暫停所有風場、彈簧與粒子計算
  if (!containerVisible.value)
    return false

  if (isGrowing.value)
    return true

  return isGrown.value
    && !motionReduced.value
    && (props.swaying || props.gusty || props.interactive || props.particleVisible)
})

頁面上放十個植栽包裝器,實際在燒 CPU 的只有看得到的那幾個。

尊重減速偏好usePreferredReducedMotion 偵測使用者的系統設定,開啟減速時關閉所有搖曳、陣風、粒子,植物直接長好站好。

這些 VueUse 的組合拳(useRafFnuseElementBoundinguseDevicePixelRatiouseIntersectionObserveruseElementVisibilityusePreferredReducedMotion)省下大量樣板程式碼,誠心推薦。

環境粒子

最後的點綴是兩種粒子,開完花的植株偶爾飄落花瓣(直接複用花瓣印章),加上緩緩上升的微光孢子:

ts
/** 同時存在的飄落花瓣上限 */
const MAX_PETAL_COUNT = 14
/** 微光孢子數量上限 */
const MAX_MOTE_COUNT = 14

花瓣從實際開花位置取樣生成,孢子數量隨容器面積調整。兩者都有嚴格上限與生成冷卻,點綴就該有點綴的自覺,不能變成暴風雪。乁( ◔ ௰◔)「

總結

回顧一下這趟從一顆種子到整片花園的旅程:

Step概念核心技術
1種子隨機LCG 偽隨機,同種子同植株
2莖的骨架海龜繪圖,droop + wobble 角度累積
3破土生長drawnLength + easeOutCubic + tipCurl 舒展
4錐形莖身法線外推 + 貝茲平滑 + 水彩疊色
5水彩印章離屏預渲染 + multiply 暈染 + 快取變體
6葉片著生沿莖取樣切線 + easeOutBack 展開
7開花敘事重疊時間窗 + 錯落綻放 + settleSpin
8錯落時序三層進度傳遞(全域 → 株 → 莖)
9微風搖曳Simplex Noise + 曲率彎曲 t^1.4
10陣風彈簧高斯風前緣 + 彈簧阻尼震盪
11滑鼠互動點到線段距離 + 速度衝量
12植株分布叢聚 + 邊緣偏好 + 畫布外擴
13全部整合IntersectionObserver + 可見性節流

拆開來看,每一步都只是國中數學等級的小把戲,正弦、冪次、距離公式。但是把它們疊在一起,就長出了一片會呼吸的花園。

鱈魚:「而且這片花園絕對不會被我養死 (๑•̀ㅂ•́)و✧」

路人:「你只是把澆水的責任丟給 requestAnimationFrame 而已吧 (˙灬˙ )」


感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 🐟

v0.67.0