Skip to content

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

雲隙光 util ​

光線從頁底透出,化作層層錯落的微光。

技術關鍵字 ​

名稱描述
Canvas Shader使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難
Ray Marching沿著射線一步步前進取樣,常用於繪製雲隙光、霧、雲等等沒有明確表面的東西
SDF (Signed Distance Field)有號距離場,用數學函式描述形狀輪廓,常用於 Shader 中繪製平滑圖形
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
CSS mix-blend-mode指定元素與底下畫面的混色方式,例如相加發光的 plus-lighter、壓暗的 multiply
Noise比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等
Vue Directive自定義 Vue 指令,用於封裝 DOM 操作邏輯和重複行為

使用範例 ​

基本用法 ​

蓋一層全畫面的 util-god-rays,再用 v-util-god-rays 指令標記會擋住光的元素。桌面版光源跟著游標跑,手機版沒有游標,改成傾斜手機驅動光源,沒有陀螺儀權限或感測器時退回自動飄移。

傍晚窗邊的那道光

下午四點多,光斜斜地從窗外穿進來。人站在窗前,臉整個沉進暗處,只剩髮絲的邊緣亮著一圈金色。平常看不見的灰塵這時候全現形了,一道一道的光就這樣浮在空氣裡。

128喜歡
32留言
3.4k瀏覽
#逆光#攝影#光影
查看範例原始碼
vue
<template>
  <div class="w-full flex flex-col gap-4 py-2">
    <base-checkbox
      v-model="enabled"
      class="example-ctrl justify-center"
      :label="t('enable')"
    />

    <article class="mx-auto max-w-md w-full flex flex-col gap-6 py-8">
      <!-- 文字也擋光,光從筆畫之間的縫隙穿出來 -->
      <h1
        v-util-god-rays
        class="font-noto-sans-tc text-2xl font-black leading-snug"
      >
        {{ t('heading') }}
      </h1>

      <p
        v-util-god-rays
        class="text-sm leading-loose opacity-80"
      >
        {{ t('paragraph') }}
      </p>

      <!-- 數據列 -->
      <div class="flex gap-2">
        <div
          v-for="stat in statList"
          :key="stat.label"
          v-util-god-rays
          class="flex flex-col flex-1 items-center gap-0.5 rounded-lg bg-slate-100 py-2 dark:bg-slate-800"
        >
          <span class="text-sm font-bold">{{ stat.value }}</span>
          <span class="text-[10px] opacity-60">{{ stat.label }}</span>
        </div>
      </div>

      <div class="flex flex-wrap items-center gap-2">
        <span
          v-for="tag in tagList"
          :key="tag.label"
          v-util-god-rays
          class="rounded-full px-3 py-1 text-xs"
          :class="tag.colorClass"
        >
          {{ tag.label }}
        </span>

        <button
          v-util-god-rays
          type="button"
          class="ml-auto rounded-lg bg-stone-100 px-4 py-1.5 text-xs dark:bg-stone-800"
        >
          {{ t('readMore') }}
        </button>
      </div>
    </article>

    <util-god-rays
      :disabled="!enabled"
      :follow-cursor="followCursor"
      :light-x="mobileLightX"
      :light-y="mobileLightY"
      :ease="lightEase"
      :intensity="themePreset.intensity"
      :haze="themePreset.haze"
      :shadow-strength="themePreset.shadowStrength"
      :glow="themePreset.glow"
    />
  </div>
</template>

<script setup lang="ts">
import { useDeviceOrientation, useMediaQuery, useRafFn } from '@vueuse/core'
import { createNoise2D } from 'simplex-noise'
import { useData } from 'vitepress'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import UtilGodRays from '../util-god-rays.vue'
import { vUtilGodRays } from '../v-util-god-rays'

const { t } = useI18n()

const enabled = ref(false)

/**
 * 手機沒有游標可以「跟著跑」,改成傾斜手機驅動光源;
 * 沒有陀螺儀訊號時(沒授權、沒感測器)退回自動飄移,兩者共用同一組座標範圍,切換起來不會跳格
 */
const TILT_RANGE_DEG = 20
/** 手機螢幕窄,光源得留在畫面內才看得到,範圍故意不沿用桌面版貼著畫面外緣的預設值 */
const LIGHT_X_CENTER = 0.5
const LIGHT_X_AMPLITUDE = 0.35
const LIGHT_Y_CENTER = 0.25
const LIGHT_Y_AMPLITUDE = 0.15
const DRIFT_SPEED = 0.05
/** 開燈後這麼久還收不到傾斜訊號,判定這台裝置沒有陀螺儀,改用飄移 */
const SIGNAL_TIMEOUT = 1500

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max)
}

const isCoarsePointer = useMediaQuery('(pointer: coarse)')
const orientation = reactive(useDeviceOrientation())

/** 手機的光源座標,桌面 followCursor 開著時完全不會用到這兩個值 */
const mobileLightX = ref(LIGHT_X_CENTER)
const mobileLightY = ref(LIGHT_Y_CENTER)
/** 這次開燈是靠傾斜還是飄移撐場:傾斜要把 ease 收低吃掉陀螺儀原始資料的抖動 */
const mobileMode = ref<'tilt' | 'drift' | null>(null)

const followCursor = computed(() => !(enabled.value && isCoarsePointer.value))
const lightEase = computed(() => mobileMode.value === 'tilt' ? 0.08 : 1)

/** 傾斜的校準基準:第一筆訊號收到時記下使用者拿手機的自然姿勢,之後只看偏移量 */
let calibrationBeta: number | null = null
let calibrationGamma: number | null = null
let signalTimeoutId: ReturnType<typeof setTimeout> | undefined

const noiseX = createNoise2D()
const noiseY = createNoise2D()
let driftElapsed = 0
const drift = useRafFn(({ delta }) => {
  driftElapsed += delta / 1000
  mobileLightX.value = LIGHT_X_CENTER + noiseX(driftElapsed * DRIFT_SPEED, 0) * LIGHT_X_AMPLITUDE
  mobileLightY.value = LIGHT_Y_CENTER + noiseY(0, driftElapsed * DRIFT_SPEED) * LIGHT_Y_AMPLITUDE
}, { immediate: false })

function startDrift() {
  mobileMode.value = 'drift'
  drift.resume()
}

function teardownMobileLight() {
  clearTimeout(signalTimeoutId)
  drift.pause()
  mobileMode.value = null
  calibrationBeta = null
  calibrationGamma = null
}

function waitForTiltSignal() {
  clearTimeout(signalTimeoutId)
  signalTimeoutId = setTimeout(() => {
    if (mobileMode.value !== 'tilt') {
      startDrift()
    }
  }, SIGNAL_TIMEOUT)
}

/** iOS 13+ 要求在使用者手勢裡才能要權限,這個建構子不在標準 DOM 型別裡 */
interface DeviceOrientationEventCtor {
  requestPermission?: () => Promise<'granted' | 'denied'>;
}

async function setupMobileLight() {
  if (!orientation.isSupported) {
    startDrift()
    return
  }

  const requestPermission
    = (DeviceOrientationEvent as unknown as DeviceOrientationEventCtor).requestPermission
  if (typeof requestPermission === 'function') {
    try {
      const result = await requestPermission()
      if (result !== 'granted') {
        startDrift()
        return
      }
    }
    catch {
      startDrift()
      return
    }
  }

  waitForTiltSignal()
}

watch(() => [orientation.beta, orientation.gamma] as const, ([beta, gamma]) => {
  if (!enabled.value || !isCoarsePointer.value || mobileMode.value === 'drift') {
    return
  }
  if (beta === null || gamma === null) {
    return
  }

  if (calibrationBeta === null || calibrationGamma === null) {
    calibrationBeta = beta
    calibrationGamma = gamma
    clearTimeout(signalTimeoutId)
    mobileMode.value = 'tilt'
  }

  const deltaBeta = clamp(beta - calibrationBeta!, -TILT_RANGE_DEG, TILT_RANGE_DEG)
  const deltaGamma = clamp(gamma - calibrationGamma!, -TILT_RANGE_DEG, TILT_RANGE_DEG)
  mobileLightX.value = LIGHT_X_CENTER + (deltaGamma / TILT_RANGE_DEG) * LIGHT_X_AMPLITUDE
  mobileLightY.value = LIGHT_Y_CENTER + (deltaBeta / TILT_RANGE_DEG) * LIGHT_Y_AMPLITUDE
})

watch(enabled, (value) => {
  if (!value) {
    teardownMobileLight()
    return
  }
  if (isCoarsePointer.value) {
    void setupMobileLight()
  }
})

/** 各給一個色相,才看得出暖光打上不同顏色時的差別 */
const tagList = computed(() => [
  { label: t('tagBacklight'), colorClass: 'bg-rose-100 dark:bg-rose-800' },
  { label: t('tagPhoto'), colorClass: 'bg-teal-100 dark:bg-teal-800' },
  { label: t('tagLight'), colorClass: 'bg-amber-100 dark:bg-amber-800' },
])

const statList = computed(() => [
  { value: '128', label: t('statLike') },
  { value: '32', label: t('statComment') },
  { value: '3.4k', label: t('statView') },
])

/**
 * 兩種主題要不同的配方
 *
 * 差別在有沒有「亮著的表面」。淺色底頁面本身就是那面白牆,
 * 光束打上去理所當然;深色底什麼都沒有,只畫光束的話會變成
 * 背景全黑卻憑空浮著幾道光,所以要把看得見的光源本體打開,讓光有個來源。
 *
 * 影子則是關掉。multiply 在深色底上幾乎沒有作用,
 * 零點一乘零點七還是零點零七,白畫一趟不如省下來
 */
const themePresetMap = {
  light: { intensity: 0.3, haze: 0.4, shadowStrength: 0.3, glow: 0 },
  dark: { intensity: 0.6, haze: 0.5, shadowStrength: 0, glow: 3 },
}

const { isDark } = useData()
const themePreset = computed(() => isDark.value ? themePresetMap.dark : themePresetMap.light)

onBeforeUnmount(() => {
  enabled.value = false
  teardownMobileLight()
})
</script>

原理 ​

我們看得見光束,靠的是空氣散射。光穿過空氣,一部分散進眼睛,光束才顯形。所以每個像素都要沿著自己那條視線走一趟,一步步問,這一段空氣照得到光嗎?這種演算法叫做 Ray Marching。

場景是立體的 ​

座標這樣擺,頁面是 z 等於零的那個平面,視點在前方,光源退到後方,標記過的元素則是躺在平面上的擋光板。

每個像素於是有一條真正的視線,從視點斜插進場景。視線既然是斜的,光又只有一點,影子便隨著距離張開成錐狀,光束也就有了體積。

頁面(z = 0)每個像素一條視線擋光板影子隨距離張開成錐狀視點光源

拆成兩趟 ​

每個像素走一百多步,每一步還要跟每個擋光物比對,成本是「像素 × 步數 × 擋光物」,畫面一大馬上就跑不動。

所以先把擋光物烘成一張圖,行進時每一步只要在那張圖上取樣一次,成本從相乘變成相加。⎝(・ω´・⎝)

那張圖也順便解決了影子。擋光物全部躺在同一個平面上,「光源到取樣點」這條線最多只穿過那個平面一次,算出穿過的位置去問一次就有答案,完全不需要陰影圖。

光只長在標記過的東西旁邊 ​

行進時還要記錄這條視線走在擋光物附近的比例。這個比例拿來當閘門,遠離擋光物的地方它精確為零,頁面原封不動。

光束之間的明暗則來自空氣裡的擋光花紋。花紋要繞著光源長,同一條光線上的空氣才會共用同一個花紋點,整條線一起亮或一起暗,一道一道分得開。

光與影分兩層 ​

加亮要用 plus-lighter、壓暗要用 multiply,而元素身上只能掛一種混合模式,所以光與影各自畫在一張畫布上,影子在下、光疊在上。

兩者其實是同一份計算的一體兩面。光束是「光穿得過的比例」,影子是「光穿不過的比例」,連花紋的濃淡都共用,所以亮帶與暗帶可以完美匹配。( •̀ ω •́ )✧

原始碼 ​

API ​

Props ​

interface Props {
  /** 關掉光,畫面淡回原本的樣子。@default false */
  disabled?: boolean;
  /** 罩在第幾層。這一層固定佔滿整個視窗,需要壓過誰就把它調高。@default 100 */
  zIndex?: number;
  /** 光源看起來在畫面上的哪裡,零為左緣、一為右緣,可超出範圍擺到畫面外。@default -0.5 */
  lightX?: number;
  /**
   * 光源看起來在畫面上的哪裡,零為上緣、一為下緣。@default 0.1
   *
   * 這個值別擺到畫面正中央。視點就在那裡,光源與它連成一線時,
   * 光束會從正面直直射過來,看起來就是一把平面的扇子。
   * 偏開一點,才看得出光束斜插進空間裡
   */
  lightY?: number;
  /** 光源退到頁面後方多遠,單位為畫面對角線比例。越遠影子越接近平行光。@default 0.05 */
  lightDepth?: number;
  /**
   * 光束往擋光物外側延伸多遠,單位為畫面對角線比例。@default 0
   *
   * 這個值決定光影會蔓延到多大範圍,出了範圍頁面完全不動。
   * 零表示只留影子錐本身,光完全貼著擋光物;調大則往外渲開,
   * 太大就接近「整個畫面都在發光」
   */
  beamRange?: number;
  /** 光束往你的方向拖多長,單位為畫面對角線比例。@default 0.8 */
  beamLength?: number;
  /** 光線顏色。@default '#fffaf0' */
  lightColor?: string;
  /**
   * 光疊回頁面的混合模式,決定它看起來是「打光」還是「塗色」。@default 'plus-lighter'
   *
   * 相加才有真的發亮的感覺,代價是淺色底上很快就會過曝到全白;
   * 那種情況把 haze 或 intensity 收一點即可
   */
  blendMode?: BlendMode;
  /** 光線強度。@default 0.3 */
  intensity?: number;
  /**
   * 亮著的空氣糊在物體前面的濃度,也就是光穿過空氣的存在感。@default 0.4
   *
   * 這一層只加光,照不到光的地方原封不動,所以調大只會讓光束更實,
   * 整個頁面不會跟著變暗
   */
  haze?: number;
  /** 擋光物輪廓鑲邊的強度,背光最招牌的那圈亮邊。@default 0 */
  edgeGlow?: number;
  /**
   * 擋光物投下的影子有多深,零為不畫影子。@default 0.3
   *
   * 影子與光束是同一份計算的一體兩面,所以它一樣只長在擋光物旁邊,
   * 也一樣讓塵埃切成一條條,亮帶與暗帶可以完美匹配
   */
  shadowStrength?: number;
  /** 影子的顏色。越接近黑色影子越實,帶點藍會有冷暖對比。@default '#1b2436' */
  shadowColor?: string;
  /**
   * 光源把整個畫面照亮多少,擋光物與它的影子會擋下這道光。@default 0
   *
   * 這是唯一不受光束範圍限制、會鋪滿整個畫面的一項,深色底頁面必開。
   * 少了它會變成「背景全黑,卻憑空浮著幾道光束」,光打到哪去了?
   * 沒有亮著的表面,光束就沒有來由。
   *
   * 淺色底頁面則相反,維持零即可。頁面本身就是亮的,
   * 眼睛自動把它讀成一面亮著的白牆,光束打上去就成立了
   */
  ambient?: number;
  /**
   * 看得見的光源本體有多亮,零為不畫。@default 0
   *
   * 一團糊開的圓形光暈,長在光源投影到畫面上的位置,擋光物擋在前面時會缺一角。
   * 同樣是深色底的救命項。光束有了,發光的東西卻不在畫面上,
   * 看起來會像光從空無一物的地方射出來
   */
  glow?: number;
  /** 光源本體的光暈有多大,單位為畫面長邊比例。@default 0.06 */
  glowSize?: number;
  /** 擋光花紋有多濃,也就是光束之間的明暗差多大。開到一時光束之間是真的斷開。@default 1 */
  dustiness?: number;
  /** 畫面上大致看得到幾道光束,越大越細碎。@default 14 */
  beamCount?: number;
  /** 光束的邊緣有多硬,零是糊成一片、一是刀切一般。@default 0 */
  beamContrast?: number;
  /**
   * 視點退到頁面前方多遠,單位為畫面對角線比例。@default 0.55
   *
   * 這就是透視的強度。小了光束會往畫面邊緣爆開,大了整個場景趨近正投影,
   * 光束變成平的扇形,也就沒有立體感了
   */
  perspective?: number;
  /** 光源跟著游標跑。@default true */
  followCursor?: boolean;
  /** 光源追上游標的速度,一為立刻跟上、越小越慢。@default 1 */
  ease?: number;
  /** 渲染解析度倍率,越小越省效能。@default 1 */
  renderScale?: number;
}

v0.90.7