Skip to content

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

水時鐘 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 小時制

使用範例 ​

基本用法 ​

窗外是哪裡的雨
玻璃後方晨光下的稻田
查看範例原始碼
vue
<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"
      />
    </div>

    <util-water-clock
      class="aspect-video rounded-xl"
      :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"
    >
      <img
        src="/photography-morning-light-of-rice.jpg"
        :alt="t('sceneAlt')"
        class="h-full w-full object-cover"
      >
    </util-water-clock>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
import BaseSelect from '../../base-select.vue'
import UtilWaterClock from '../util-water-clock.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 { t } = useI18n()

const timeZone = ref<TimeZone>('Asia/Taipei')

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.2,
  refraction: 3,
  shadowStrength: 0.2,
  lightAngle: 315,
  strokeWidth: 1,
  rainAmount: 0.7,
})
</script>

原理 ​

數字是一顆水珠照筆順拖出的水痕,沒有用到字型。水膜撐不住,很快斷成一串大小不一的水珠,靠得近的再用 Metaball 黏成一道水流;夠大的水珠會沿玻璃滑落,沿路併吞碰到的水。

光影都依物理計算。視線穿過水珠時依 Snell 定律折射,後方內容因此放大、扭曲;光線穿過水珠落到頁面上,形成影子與焦散。白色怎麼折射都還是白色,純白底上的水就靠這些光影現形。(≖‿ゝ≖)✧

原始碼 ​

API ​

Props ​

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;
  /**
   * 折射強度,實際上是水面到背景的距離倍率。
   * 越大,水珠外圈扭曲後方內容的幅度越大,再大就上下顛倒;影子一直貼著水珠,不受影響。@default 3
   */
  refraction?: number;
  /**
   * 直射光強度:水珠投在背景上的影子與焦散都來自它。
   * 白色怎麼折射都還是白色,純白底上的水全靠這些光影現形,0 就只剩折射與反射。@default 1
   */
  shadowStrength?: number;
  /**
   * 玻璃起霧的程度,0 為清澈、1 為霧最濃。
   * 起霧時乾的地方會模糊,有水的地方看出去仍然清楚,水寫的字會特別顯眼。@default 0.2
   */
  fog?: number;
  /** 光從哪個方向照過來(度),0 為正上方、順時針增加。@default 315 */
  lightAngle?: number;
  /** canvas 的 z-index。@default 1 */
  zIndex?: number;
  /**
   * 擷取玻璃後方內容的倍率(相對 CSS 像素,已含裝置像素比),越低越快、紋理越糊。
   * 未指定時用 `min(devicePixelRatio, 2)`
   */
  captureScale?: number;
}

Slots ​

defineSlots<{
  /** 玻璃後方的內容,水會折射它 */
  default?: () => unknown;
}>()

Methods ​

defineExpose({
  /** 重新擷取玻璃後方的內容。內容是 canvas、影片這類 DOM 觀察不到的變化時,改完要手動呼叫 */
  recapture,
})

v0.91.0