Every little fish swimming around is a visitor on this page right now, and one of them is you ( ´ ▽ ` )ノ If they get in the way of reading, feel free to turn them off.
ChronoRain
utilThe glass holds on to the rain's footsteps, every drop a stretch of time hanging on the window.
Until memory overflows, and they say goodbye in a winding trail.
Tech Keywords
Section titled “Tech Keywords”| Name | Description |
|---|---|
| Canvas Shader | Written in GLSL and executed directly on the GPU, faster than the Canvas 2D API but harder to master |
| WebGL2 Instanced Rendering | Draws large numbers of identical geometries at once via WebGL2 instanced drawing, greatly reducing draw calls |
| Metaball | Each object emits a smooth potential; wherever the summed potential exceeds a threshold becomes the surface, so nearby objects blend together naturally |
| Height Map | A texture that stores how high each point is; neighbouring differences give the slope, and the slope gives the normals that drive lighting and refraction |
| Caustics | Light refracted by a curved transparent surface gathers into bright patterns behind it, like the light web on a pool floor or the bright spot inside a water drop's shadow |
| Fresnel | Reflection grows stronger at grazing angles, which is why ice and glass glow at the edges and stay clear in the middle |
| DOM to Image | Converts DOM elements into images, based on SVG foreignObject |
| Particle System | Spawns large numbers of small objects, commonly used to simulate smoke, fire, rain, and snow |
| Simplex Noise | Improved Perlin Noise that computes faster without directional bias, commonly used in procedural generation |
| Intl.DateTimeFormat | Built-in date and time formatting that supports time zones and 12/24-hour cycles |
Usage Examples
Section titled “Usage Examples”Basic Usage
Section titled “Basic Usage”Move the cursor across the glass (or drag a finger on touch screens) to wipe the mist away, and the drops get pushed along.
The background can be any image URL, as long as the image allows cross-origin access. ( ´ ▽ ` )ノ
<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>Video Background
Section titled “Video Background”Videos work too! (≖‿ゝ≖)✧
<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>How it Works
Section titled “How it Works”The digits are trails left by a drop dragged along each stroke, with no font involved.
The film can't hold together, so it breaks into beads of different sizes, and metaballs join nearby beads into a stream; drops that grow heavy enough slide down the glass, swallowing any water in their way.
Drops just past the threshold get caught on specks of dirt and move in fits and starts, while bigger ones glide more smoothly; since the dirt stays put on the glass, later drops tend to follow earlier paths.
All the light and shade is computed physically. Sight refracts through each drop by Snell's law, magnifying and distorting the content behind; light passing through the drops lands on the page as shadows and caustics. White bent in any direction is still white, so on a pure white background the water shows up through this light and shade alone. (≖‿ゝ≖)✧
When the glass is misted, the blurred background comes from the mipmaps and is converted to linear color with pow(x, 2.2) for lighting. In pure black areas, mipmap filtering can leave tiny negative values, and pow of a negative number is undefined: an NVIDIA GTX 1060 actually returns NaN, punching blocky patches into the mist. Night footage is full of pure black, so video backgrounds hit this most often. Clamping colors to 0 before the conversion fixes it. (´・ω・`)
Source Code
Section titled “Source Code”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>;}If you have any suggestions or questions, feel free to email me( ´ ▽ ` )ノ
If you like cool components, feel free to " Buy me a cup of coffee " to show your support!