植栽包裝器 wrapper
完成品請見植栽包裝器元件。
所以這篇就來打造一個「植栽包裝器」,被它包住的內容會從邊框長出水彩風的花草,會發芽、展葉、開花,有微風輕搖、陣風掃過,滑鼠掃過去還會像撥草叢一樣晃動。
全程只用 Canvas 2D,不需要 WebGL,但會用上不少有趣的小技巧。整體架構分成四大塊:
骨架生成(種子隨機 + 海龜繪圖)
→ 水彩印章(離屏預渲染)
→ 生長敘事(三層進度時間窗)
→ 物理演出(噪聲微風 + 陣風彈簧 + 滑鼠衝量)讓我們從最基本的「隨機」開始。◝( •ω• )◟
Step 1:種子隨機 — 長一樣才是專業
做植物生成第一個遇到的問題不是怎麼畫,而是怎麼「隨機」。
植物的形狀充滿隨機參數,莖長、傾角、葉片位置、開不開花,全部都要骰。但直接用 Math.random() 會有個大麻煩,每次重畫植物都會變一個樣。
容器尺寸變化時要重新生成植株、視窗縮放要重畫,如果每次重畫植物都長得不一樣,使用者拉一下視窗,整個花園瞬間砍掉重練,比魔術還神奇。╮(╯_╰)╭
按下「重新生成」就能看到差異,左邊每按一次換一批草,右邊怎麼按都是同一叢。
查看範例原始碼
<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),只要三行:
/** 偽隨機數產生器(線性同餘),確保同種子產生相同植株 */
export function createSeededRandom(seed: number): () => number {
let state = seed
return () => {
state = (state * 1664525 + 1013904223) & 0xFFFFFFFF
return (state >>> 0) / 0xFFFFFFFF
}
}原理是把目前狀態乘一個大數、加一個大數,再砍到 32 bit,產生看起來毫無規律的序列。1664525 和 1013904223 是教科書等級的經典參數(出自 Numerical Recipes)。
>>> 0 是把可能為負的 32 bit 整數轉成無號數,除以 0xFFFFFFFF 後就得到 0~1 的浮點數。
與 Math.random 的比較
| 特性 | Math.random() | createSeededRandom(seed) |
|---|---|---|
| 可重現 | 不行 | 同種子同序列 |
| 品質 | 高(引擎內建) | 普通(但夠用) |
| 速度 | 快 | 更快(乘加位移而已) |
| 適合場景 | 一次性隨機 | 程序化生成(plant、地形) |
植物生成不需要密碼學等級的隨機品質,「夠亂、可重現」就是完美解。
之後每株植物都會分到一顆自己的種子,整個版面重算一百次,每株還是長在原地、保持原樣。(≖ᴗ≖✿)
Step 2:莖的骨架 — 海龜繪圖
有了隨機,接著來長莖。
莖的本質是一條「彎得很自然」的曲線。直接用貝茲曲線拉控制點也行,但控制點和「植物彎曲的語感」對不太起來,調整參數像在猜謎。
所以這裡改用**海龜繪圖(Turtle Graphics)**的思路,想像一隻海龜從基部出發,每走一小段就稍微轉個彎,走 16 段後留下的足跡就是莖。
拉動滑桿感受每個參數的「植物學意義」,這就是海龜繪圖的好處,每個參數都直接對應一種彎曲特徵。
查看範例原始碼
<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>角度累積
核心邏輯只有一個迴圈,每段在前一段的角度上累加兩種偏轉:
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 螺旋,只是高頻正弦的投影看起來很像而已。視覺效果嘛,看起來像就是像 ヾ(◍'౪`◍)ノ゙」
特化骨架
不同植物在這個基礎迴圈上各自加料,例如:
// 垂藤尖端帶永久小捲鬚收尾
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,整根莖的骨架早就生成好了,動畫只控制「目前畫到哪裡」。
把 tipCurl 拉到最大再按重播,就是蕨芽破土的經典畫面。
查看範例原始碼
<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>長度進度
/** 莖在前 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 處理「畫到一半的段」,順便讓生長中的尖端變細:
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)
真實植物的新芽是捲著的,長大才舒展開。這個效果用一個隨生長遞減的捲曲量就能做到:
// 捲曲隨生長舒展:越長越直
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:錐形莖身 — 從折線到水彩絲帶
骨架只是一條線,真正的莖有粗細,基部寬、尖端細,而且要有水彩的質感。
做法是沿著骨架的每個點計算法線,往左右各推出半個莖寬,得到左緣與右緣兩排點,圍起來就是一條錐形「絲帶」。
切到「骨架 + 法線」模式,黃色短線就是每個節點的法線,長度即當地的莖寬。
查看範例原始碼
<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 度就是法線:
/** 計算每個路徑點的單位法線 */
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 段的折角會原形畢露。這裡用個經典技巧,把「目前點」當控制點、「目前點與下一點的中點」當終點畫二次貝茲曲線:
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)
}曲線永遠通過中點、被控制點拉彎,整條邊就圓潤了。
莖寬的錐形公式
const width = baseWidth * (1 - t * 0.85) ** 1.3 + 0.25| 部分 | 作用 |
|---|---|
1 - t * 0.85 | 尖端收到基部的 15%,不會完全變成 0 |
** 1.3 | 讓收細的速度前慢後快,更像真實植物 |
+ 0.25 | 保底寬度,尖端細歸細但不能斷 |
水彩疊色
絲帶身體用三層疊出水彩感,做法與實際元件相同:
// 底層:完整寬度的淡彩,基部到尖端帶漸層
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 貼上去,平移旋轉縮放都交給變換矩陣。
勾選各圖層觀察一片水彩葉的組成,再按「換一片葉子」看隨機形狀的變化。
查看範例原始碼
<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>多層暈染的配方
實際元件的水彩本體長這樣:
/** 以多層半透明疊色繪製水彩質感的形狀 */
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 倍尺寸繪製,縮小貼上時依然銳利:
/** 印章超取樣倍率,縮小繪製時保持銳利 */
const SUPER_SAMPLE = 2錨點。葉片的基部要對準莖上的著生點,所以印章記錄了錨點位置,貼上時以錨點為原點變換:
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。
慢慢拉動進度條,葉片會在莖通過著生點後,帶著回彈感逐片展開。
查看範例原始碼
<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>取樣著生點
莖每幀都會被風吹彎,所以葉片位置不能寫死,要從「彎曲後的點列」即時取樣:
/** 取得彎曲路徑上指定位置的點與切線角 */
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),
}
}切線角同樣用前後鄰居的差向量算,葉片的角度以切線為基準再加上展開角:
const leafAngle = attachPoint.tangentAngle
+ leaf.side * leaf.openAngle * Math.min(eased, 1.1)
+ flutter這樣風吹彎莖的時候,葉片會跟著莖一起轉,不會出現「莖彎了葉子還浮在原地」的靈異現象。
展開時序與回彈
葉片的展開時機跟著莖的生長走,莖長到哪、葉開到哪:
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%,小小的彈跳讓展葉瞬間活了起來:
function easeOutBack(t: number): number {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
}互生與葉序
葉片沿莖的排列也講究,普通植物左右交錯(互生)、越上方越小:
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 也行,但效果就像帆布傘「啪」一聲打開,完全沒有花的優雅。真實的花有自己的劇本,花苞先鼓起,花瓣一片片錯落綻放,最後花心的雄蕊才探出頭。
下方色帶就是各階段的時間窗,拉動進度條看每個窗口的演出。
查看範例原始碼
<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>生長敘事時間窗
整個劇本用一組常數定義,全部以「單根莖的進度」為座標:
/** 莖在前 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 的隨機值),決定它在綻放大隊中的順位:
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 弧度的旋轉偏移,隨進度歸零。視覺上花瓣像是「旋著開出來」,比單純放大高級非常多。
花苞的退場
花苞不能直接消失,要隨花瓣綻放淡出:
const bloomProgress = clamp01((stemProgress - PETAL_START) / (1 - PETAL_START))
// 花苞:花瓣綻放後淡出
const budAlpha = 1 - clamp01(bloomProgress * 1.8)乘 1.8 讓淡出速度比綻放快,花瓣開到一半多花苞就完全隱形,不會穿幫。
Step 8:錯落時序 — 整片植栽的生長交響
單株的劇本寫好了,現在把鏡頭拉遠。一個容器裡有幾十株植物,如果全部同時破土、同時開花,那畫面比軍隊踢正步還整齊,完全不自然。
關掉錯落再重播一次,差異一目了然。
查看範例原始碼
<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)會經過三層轉換,才變成單根莖的進度:
// 第一層:全域 → 整株
const plantProgress = clamp01(
(growthProgress - plant.growthDelay) / plant.growthSpan,
)
// 第二層:整株 → 單根莖
const stemProgress = clamp01(
(plantProgress - stem.growthDelay) / stem.growthSpan,
)
// 第三層:單根莖 → 各器官(Step 6、7 的時間窗)每層都是同一個公式,減掉延遲、除以時長、夾在 0~1。三層疊起來,每株、每根莖、每片葉、每朵花都有自己的人生進度條。
延遲怎麼分配
株與株之間的延遲依 preset 的「生長節奏」指派,新芽搶先冒頭、矮花叢壓軸綻放:
// 依 preset 的生長節奏決定何時破土
// 延遲拉伸 1.5 倍,拉大整片植栽的交錯感
const growthDelay = Math.min(
0.78,
randomBetween(random, preset.growthDelayRange) * 1.5,
)同株內的莖則是依序延遲加上隨機抖動:
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:微風搖曳 — 噪聲與曲率
植物長好了,但靜止的植物是塑膠花。要活,就要有風。( ´ ▽ ` )ノ
風分兩種,這一步先做溫柔的持續微風,下一步再做暴力的陣風。
重點在「怎麼彎」。切換成「整株硬轉」模式,同樣的風,植物瞬間從活物變成雨刷。
查看範例原始碼
<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 魚群用的是同一招:
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,所以風是「一片一片」吹過去,相鄰植株的搖曳相似但不同步 - 正弦項補上規律的底噪,讓搖曳永遠不會完全停止
曲率彎曲
拿到搖曳角度後,重點是怎麼施加到莖上:
const angle = point.segmentAngle + bend * point.t ** 1.4彎曲量乘上 t ** 1.4,基部幾乎不動、越靠尖端偏轉越大。物理上這對應植物的莖基部粗硬、梢部柔軟,彎曲沿著莖身「累積」。
如果少了 t ** 1.4,整根莖繞基部剛性旋轉,就是範例裡的雨刷模式。差一個指數,質感天差地別。
整株同步的細節
一株植物有很多根莖,如果每根莖各自取樣噪聲,整株會散開亂晃,像一群沒有對齊的伴舞。
所以微風以「株」為單位取樣,全株共用,再讓每根莖加上一點自己的細顫:
// 微風以整株為單位取樣,莖葉同步搖曳才不會散開
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.flexibilityflexibility 是各 preset 的柔軟度,草最軟(1.0)、野花硬一點(0.8)。
黃金葛比較特別,它的莖攀在邊框上,flexibility: 0 完全不動,改用放大 2.2 倍的葉片顫動來表現風。莖不動葉子動,攀附植物的風感就出來了。
另外搖曳強度 swayIntensity 會隨生長尾段漸入:
// 微風隨生長尾段漸強
const swayIntensity = Math.max(0, Math.min(1, (progress.value - 0.55) / 0.45))剛破土的嫩芽不會跟著風搖頭晃腦,長到一半才慢慢開始有風的存在感。
Step 10:陣風與彈簧 — 風是有形狀的
微風是背景音,陣風才是高潮。不定時一陣風從畫面一側掃到另一側,所有植物依序彎腰、再震盪回彈。
這需要兩個系統合作,風場負責描述風的形狀與行進,彈簧負責植物的受力反應。
按「吹一陣風」觀察藍色風前緣掃過時植物的反應,再把阻尼調低感受「晃個不停」的效果。
查看範例原始碼
<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>風前緣
一陣風被建模成一個移動的「風前緣」,從容器一側掃向另一側:
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):整陣風淡入淡出,開頭結尾都不突兀。
彈簧阻尼系統
風給的是「目標彎曲角」,植物不會瞬間彎到位,而是用彈簧追過去:
/** 彈簧勁度(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 以下,就能看到晃很久才停的效果。
安全帽
彈簧加衝量有機會疊出誇張的角度,所以彎曲角有上限:
/** 彈簧角度上限(弧度),避免被吹倒 */
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:滑鼠互動 — 撥動草叢
風會吹,再來要讓使用者也能「摸」。滑鼠掃過植株,植株順著掃動方向彎折再回彈,像用手撥草叢。
因為彈簧系統已經就位,這一步出乎意料地簡單,滑鼠只要負責「給彈簧一個衝量」就好。
滑鼠左右掃過草叢試試,掃得越快彎得越大力。
查看範例原始碼
<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>命中判定
植株不是一個點,是一條從基部伸向尖端的「軸線」。所以用點到線段的最短距離判定:
// 植株軸線:基部到尖端的近似線段
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判定半徑隨植株大小調整,大株蕨類的「身體」比小草寬,摸起來才合理。
衝量計算
衝量大小由滑鼠速度決定,方向要投影到植株的局部座標:
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:植株分布 — 自然的亂
目前為止植物本身已經很完整了,但「種在哪裡」同樣是門學問。
等距排列像受檢閱的儀隊,完全隨機又會擠成一團。自然界的植物會叢聚,幾株擠在一起成一叢,叢與叢之間留空隙,偶爾有落單的散兵。
按「重新散佈」多骰幾次,再把 edgeBias 拉滿,觀察分布如何聚到兩端。
查看範例原始碼
<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>叢聚演算法
三個步驟生出自然的分布:
// 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 是個小巧思,兩個均勻隨機相加會近似常態分佈(中間機率高、兩端低),叢內自然呈現「中間密、外圍疏」。
邊緣偏好
青草類植物喜歡聚在容器的邊角,這個偏好用冪函數實現:
/** 將 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 是同一招的反向應用。
角落與圓角
角落植株(蕨類、垂藤)用固定錨點,但容器有圓角時不能直接釘在直角頂點,會懸空在圓弧外:
// 45 度弧線中點與直角頂點的距離
const arcInset = cornerRadius * (1 - Math.SQRT1_2)
// 圓角越大,植株越貼近對角線方向(上限 45 度)
const tilt = Math.min(45, 15 + cornerRadius)錨點沿圓弧內移到 45 度弧線中點,植株再朝對角線傾斜,圓角卡片的角落就能長出服貼的蕨類。元件還會自動偵測內容的 border-radius,使用者什麼都不用設。
畫布要比容器大
植物從邊框長出來,自然會超出容器範圍。如果 canvas 跟容器一樣大,超出的部分就被裁掉了。
所以每次生成植株後,會把所有植株的局部包圍盒(含葉片外伸與彎曲餘裕)旋轉到世界座標,算出四個方向的溢出量,讓 canvas 向外擴張:
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 元件。直接玩玩完成品:
查看範例原始碼
<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>每一幀的流程
風場推進(排程與結束陣風)
→ 每株取樣陣風 → 彈簧積分(Step 10)
→ 粒子推進(花瓣、孢子)
→ 渲染:每株 → 每莖
→ 彎曲重建骨架(Step 3、9)
→ 絲帶莖身(Step 4)
→ 印章貼上葉片與花(Step 5~7)效能的最後一塊拼圖
畫面美是一回事,「裝飾元件」更要緊的是不能搶資源。元件做了好幾層的節流:
進場觸發。用 IntersectionObserver 等元件過半進入畫面才開始生長:
// 過半進入畫面即觸發;要求完整可見(ratio 1)會因次像素誤差永遠觸發不了
{ threshold: 0.55 }註解裡那句是踩過的坑,threshold 設 1 在某些縮放比例下會因為次像素誤差永遠不觸發,留個 0.55 最穩。
捲出畫面就睡覺。useElementVisibility 偵測元件是否在視口內,搭配一個 computed 決定動畫迴圈開或關:
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 的組合拳(useRafFn、useElementBounding、useDevicePixelRatio、useIntersectionObserver、useElementVisibility、usePreferredReducedMotion)省下大量樣板程式碼,誠心推薦。
環境粒子
最後的點綴是兩種粒子,開完花的植株偶爾飄落花瓣(直接複用花瓣印章),加上緩緩上升的微光孢子:
/** 同時存在的飄落花瓣上限 */
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 而已吧 (˙灬˙ )」
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 🐟