每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
時之雨
util玻璃留住了雨的腳步,每一滴都是懸在窗上的歲月。
直到回憶超載,在蜿蜒中道別。
| 名稱 | 描述 |
|---|---|
| Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
| WebGL2 Instanced Rendering | 透過 WebGL2 的 Instanced Drawing 一次繪製大量相同幾何體,大幅減少 draw call |
| Metaball | 每個物體在空間中散發一個平滑的位能,疊加後超過門檻的地方就是表面,靠近時會自然黏成一團 |
| 高度圖 | 用一張貼圖記錄每個點有多高,相鄰兩點相減就是坡度,由坡度推出法線,光影與折射都靠它 |
| 焦散 | 光穿過彎曲的透明表面時被折射聚集,在後方投出一塊塊亮紋,泳池底的光網、水珠影子裡的亮點都是它 |
| Fresnel | 視線越貼近表面反射越強的現象,冰塊、玻璃邊緣發亮、正面透明就是它 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| Simplex Noise | 改良版 Perlin Noise,計算更快且無方向性偏差,常用於程序化生成 |
| Intl.DateTimeFormat | 瀏覽器內建的日期時間格式化,可以指定時區與 12/24 小時制 |
游標劃過玻璃(手機按著滑)就能把霧擦掉,水珠會被推著走。
背景圖可以換成任何圖片網址,圖片要允許跨網域讀取才能用。( ´ ▽ ` )ノ
<template> <div class="w-full flex flex-col gap-4 py-2"> <div class="example-ctrl grid grid-cols-2 gap-4 p-4 lg:grid-cols-4"> <div class="flex flex-col gap-1"> <span class="text-sm font-bold"> {{ t('city') }} </span> <base-select v-model="timeZone" :options="TIME_ZONE_LIST" :option-label-map="timeZoneLabelMap" /> </div>
<base-checkbox v-model="options.showSeconds" :label="t('showSeconds')" />
<base-checkbox v-model="options.hour12" :label="t('hour12')" />
<base-input v-model.number="options.fog" type="range" :label="`${t('fog')}: ${options.fog}`" :min="0" :max="1" :step="0.05" />
<base-input v-model.number="options.refraction" type="range" :label="`${t('refraction')}: ${options.refraction}`" :min="0" :max="3" :step="0.05" />
<base-input v-model.number="options.shadowStrength" type="range" :label="`${t('shadowStrength')}: ${options.shadowStrength}`" :min="0" :max="2" :step="0.05" />
<base-input v-model.number="options.lightAngle" type="range" :label="`${t('lightAngle')}: ${options.lightAngle}°`" :min="0" :max="359" :step="1" />
<base-input v-model.number="options.strokeWidth" type="range" :label="`${t('strokeWidth')}: ${options.strokeWidth}`" :min="0.5" :max="2" :step="0.05" />
<base-input v-model.number="options.rainAmount" type="range" :label="`${t('rainAmount')}: ${options.rainAmount}`" :min="0" :max="1" :step="0.05" />
<base-input v-model.number="options.rainSize" type="range" :label="`${t('rainSize')}: ${options.rainSize}`" :min="0.5" :max="3" :step="0.1" />
<form class="col-span-2 flex flex-col gap-1 lg:col-span-4" @submit.prevent="applyBackground()" > <span class="text-sm font-bold"> {{ t('background') }} </span> <div class="flex items-end gap-2"> <base-input v-model="backgroundDraft" class="min-w-0 flex-1" type="url" :placeholder="t('backgroundPlaceholder')" /> <base-btn :label="t('apply')" @click="applyBackground()" /> <base-btn :label="t('reset')" :disabled="isDefaultBackground" ignore-click @click="resetBackground()" /> </div> <span v-if="isBackgroundFailed" class="text-sm text-red-500" > {{ t('backgroundFailed') }} </span> </form> </div>
<!-- 全螢幕與雨聲的控制列在 clock-stage 裡 --> <clock-stage> <util-chrono-rain class="h-full w-full" :time-zone="timeZone" :show-seconds="options.showSeconds" :hour12="options.hour12" :fog="options.fog" :refraction="options.refraction" :shadow-strength="options.shadowStrength" :light-angle="options.lightAngle" :stroke-width="options.strokeWidth" :rain-amount="options.rainAmount" :rain-size="options.rainSize" > <!-- 換圖時整個換掉:時鐘只看結構變動,換了節點才會重拍 --> <img :key="backgroundUrl" :src="backgroundUrl" :alt="isDefaultBackground ? t('sceneAlt') : t('customSceneAlt')" crossorigin="anonymous" class="h-full w-full object-cover" @error="handleBackgroundError" > </util-chrono-rain> </clock-stage> </div></template>
<script setup lang="ts">import { computed, ref } from 'vue'import { useI18n } from 'vue-i18n'import BaseBtn from '../../base-btn.vue'import BaseCheckbox from '../../base-checkbox.vue'import BaseInput from '../../base-input.vue'import BaseSelect from '../../base-select.vue'import UtilChronoRain from '../util-chrono-rain.vue'import ClockStage from './clock-stage.vue'
type TimeZone = 'Asia/Taipei' | 'Asia/Tokyo' | 'Europe/London' | 'America/New_York'
const TIME_ZONE_LIST: TimeZone[] = ['Asia/Taipei', 'Asia/Tokyo', 'Europe/London', 'America/New_York']const DEFAULT_BACKGROUND = '/photography-morning-light-of-rice.jpg'
const { t } = useI18n()
const timeZone = ref<TimeZone>('Asia/Taipei')
const backgroundUrl = ref(DEFAULT_BACKGROUND)const backgroundDraft = ref('')const isBackgroundFailed = ref(false)const isDefaultBackground = computed(() => backgroundUrl.value === DEFAULT_BACKGROUND)
function applyBackground() { const url = backgroundDraft.value.trim() if (!url) { return } isBackgroundFailed.value = false backgroundUrl.value = url}
function resetBackground() { backgroundDraft.value = '' isBackgroundFailed.value = false backgroundUrl.value = DEFAULT_BACKGROUND}
/** * 載不到,或對方不允許跨網域讀取:時鐘要把背景拍下來才能讓水折射,這種圖拍不到。 * 換回預設的圖,並告訴使用者。 * 換了圖之後,舊的那張可能晚一點才回報失敗:不是目前這張就不理,免得把新圖也換掉 */function handleBackgroundError(event: Event) { // 讀觸發事件那張圖自己的 src 屬性:屬性是當初設的原字串,不會變成目前的網址 const url = (event.currentTarget as HTMLImageElement | null)?.getAttribute('src') if (url !== backgroundUrl.value || isDefaultBackground.value) { return } isBackgroundFailed.value = true backgroundUrl.value = DEFAULT_BACKGROUND}
const timeZoneLabelMap = computed<Record<TimeZone, string>>(() => ({ 'Asia/Taipei': t('taipei'), 'Asia/Tokyo': t('tokyo'), 'Europe/London': t('london'), 'America/New_York': t('newYork'),}))
const options = ref({ showSeconds: false, hour12: false, fog: 0.5, refraction: 3, shadowStrength: 0.2, lightAngle: 315, strokeWidth: 1, rainAmount: 0.7, rainSize: 1,})</script>放影片也行喔!(≖‿ゝ≖)✧
<template> <div class="w-full flex flex-col gap-4 py-2"> <div class="example-ctrl grid grid-cols-1 gap-4 p-4 sm:grid-cols-3"> <base-input v-model.number="options.glow" type="range" :label="`${t('glow')}: ${options.glow}`" :min="0" :max="2" :step="0.05" />
<base-input v-model.number="options.fog" type="range" :label="`${t('fog')}: ${options.fog}`" :min="0" :max="1" :step="0.05" />
<base-input v-model.number="options.rainAmount" type="range" :label="`${t('rainAmount')}: ${options.rainAmount}`" :min="0" :max="1" :step="0.05" /> </div>
<!-- 全螢幕與雨聲的控制列在 clock-stage 裡,與基本用法共用 --> <clock-stage> <util-chrono-rain class="h-full w-full" :show-seconds="false" :glow="options.glow" :fog="options.fog" :rain-amount="options.rainAmount" > <!-- 插槽裡的影片會一格一格更新到水裡,不必手動重拍 --> <video ref="videoRef" src="/footage-city-street-bokeh.mp4" poster="/footage-city-street-bokeh.jpg" class="h-full w-full object-cover" :aria-label="t('sceneAlt')" autoplay loop muted playsinline /> </util-chrono-rain> </clock-stage> </div></template>
<script setup lang="ts">import { useElementVisibility } from '@vueuse/core'import { ref, useTemplateRef, watch } from 'vue'import { useI18n } from 'vue-i18n'import BaseInput from '../../base-input.vue'import UtilChronoRain from '../util-chrono-rain.vue'import ClockStage from './clock-stage.vue'
const { t } = useI18n()
const options = ref({ glow: 1, fog: 0.4, rainAmount: 0.6,})
// 捲出畫面就暫停影片,省電也省流量const videoRef = useTemplateRef('videoRef')const isVisible = useElementVisibility(videoRef)watch(isVisible, (visible) => { const video = videoRef.value if (!video) { return } if (visible) { // 瀏覽器擋自動播放時就停在封面,不影響時鐘 video.play().catch(() => {}) return } video.pause()})</script>數字是一顆水珠照筆順拖出的水痕,沒有用到字型。
水膜撐不住,就會斷成一串大小不一的水珠,靠得近的再用 Metaball 黏成一道水流;夠大的水珠會沿玻璃滑落,沿路併吞碰到的水。
剛過門檻的水珠會被玻璃上的髒點卡住,走走停停,越大滑得越順;髒點固定在玻璃上,後來的水珠常沿著前人的路線走。
光影都依物理計算。視線穿過水珠時依 Snell 定律折射,後方內容因此放大、扭曲;光線穿過水珠落到頁面上,形成影子與焦散。白色怎麼折射都還是白色,純白底上的水就靠這些光影現形。(≖‿ゝ≖)✧
起霧時從縮圖取模糊的背景,再用 pow(x, 2.2) 換成線性色彩計算光影。純黑的地方經過縮圖過濾,可能剩下極小的負數,負數的 pow 沒有定義,NVIDIA GTX 1060 實際算出了 NaN,霧裡跟著破出一塊塊色塊。夜景影片到處都是純黑,影片背景最容易遇到。換算前先把顏色夾到 0 就解決了。(´・ω・`)
interface Props { /** 顯示秒數。@default true */ showSeconds?: boolean; /** 12 小時制。@default false */ hour12?: boolean; /** IANA 時區,例如 `'Asia/Tokyo'`。未指定時用裝置時區 */ timeZone?: string; /** 字高(px)。未指定時依容器大小自動縮放 */ digitHeight?: number; /** 寫出數字的水珠大小倍率,1 為預設大小。@default 1 */ strokeWidth?: number; /** 背景雨量:雨珠與玻璃上的霧珠,0 為不下雨、1 為大雨。@default 0.3 */ rainAmount?: number; /** * 雨珠與霧珠的大小倍率,1 為預設大小。 * 預設已跟著字的大小等比縮放,全螢幕時不會變成一顆顆小點;越大,同樣的雨量顆粒越少越大。@default 1 */ rainSize?: number; /** * 折射強度,實際上是水面到背景的距離倍率。 * 越大,水珠外圈扭曲後方內容的幅度越大,再大就上下顛倒;影子一直貼著水珠,不受影響。@default 3 */ refraction?: number; /** * 直射光強度:水珠投在背景上的影子與焦散都來自它。 * 白色怎麼折射都還是白色,純白底上的水全靠這些光影現形,0 就只剩折射與反射。@default 1 */ shadowStrength?: number; /** * 玻璃起霧的程度,0 為清澈、1 為霧最濃。 * 起霧時乾的地方會模糊,有水的地方看出去仍然清楚,水寫的字會特別顯眼。@default 0.5 */ fog?: number; /** 光從哪個方向照過來(度),0 為正上方、順時針增加。@default 315 */ lightAngle?: number; /** * 暈光:背景裡最亮的地方(車燈、路燈)透過水與霧散開的光,0 為關。 * 乾的玻璃只散一點,起霧時散成一大團,水珠會把折射後看到的光源再放大一圈。@default 0.3 */ glow?: number; /** * 擦玻璃:游標經過(觸控要按著滑)就把霧與水擦掉,水堆到擦痕兩側,過一陣子霧再慢慢回來。 * 開啟時手指在時鐘上滑動不會捲動頁面。@default true */ wipeable?: boolean; /** canvas 的 z-index。@default 1 */ zIndex?: number; /** * 擷取玻璃後方內容的倍率(相對 CSS 像素,已含裝置像素比),越低越快、紋理越糊。 * 未指定時用 `min(devicePixelRatio, 2)` */ captureScale?: number;}defineSlots<{ /** 玻璃後方的內容,水會折射它。裡面的 `<video>` 每一格都會更新到水裡 */ default?: () => unknown;}>()Methods
Section titled “Methods”interface Expose { /** 重新擷取玻璃後方的內容。內容是 canvas 這類 DOM 觀察不到的變化時,改完要手動呼叫(影片會自動逐格更新) */ recapture: () => Promise<void>;}