跳到內容

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

時之雨

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;
}>()
interface Expose {
/** 重新擷取玻璃後方的內容。內容是 canvas 這類 DOM 觀察不到的變化時,改完要手動呼叫(影片會自動逐格更新) */
recapture: () => Promise<void>;
}