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-volumetric-light,再用 v-util-volumetric-light 指令標記會擋住光的元素。

指令會把元素拍成一張圖,所以擋光的是它真正的輪廓。文字因此一個字一個字擋光,光從筆畫之間的縫隙穿出來。( •̀ ω •́ )✧

傍晚窗邊的那道光

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

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 p-4"
      :label="t('enable')"
    />

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

      <p
        v-util-volumetric-light
        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-volumetric-light
          class="flex flex-col flex-1 items-center gap-0.5 rounded-lg py-2"
          :class="stat.colorClass"
        >
          <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"
          v-util-volumetric-light
          class="rounded-full bg-slate-100 px-3 py-1 text-xs dark:bg-slate-800"
        >
          {{ tag }}
        </span>

        <button
          v-util-volumetric-light
          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-volumetric-light
      :disabled="!enabled"
      :intensity="themePreset.intensity"
      :haze="themePreset.haze"
      :shadow-strength="themePreset.shadowStrength"
      :glow="themePreset.glow"
    />
  </div>
</template>

<script setup lang="ts">
import { useData } from 'vitepress'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseCheckbox from '../../base-checkbox.vue'
import UtilVolumetricLight from '../util-volumetric-light.vue'
import { vUtilVolumetricLight } from '../v-util-volumetric-light'

const { t } = useI18n()

const enabled = ref(false)

const tagList = computed(() => [t('tagBacklight'), t('tagPhoto'), t('tagLight')])

/** 各給一個色相,才看得出暖光打上不同顏色時的差別 */
const statList = computed(() => [
  { value: '128', label: t('statLike'), colorClass: 'bg-rose-100 dark:bg-rose-800' },
  { value: '32', label: t('statComment'), colorClass: 'bg-teal-100 dark:bg-teal-800' },
  { value: '3.4k', label: t('statView'), colorClass: 'bg-amber-100 dark:bg-amber-800' },
])

/**
 * 兩種主題要不同的配方
 *
 * 差別在有沒有「亮著的表面」。淺色底頁面本身就是那面白牆,
 * 光束打上去理所當然;深色底什麼都沒有,只畫光束的話會變成
 * 背景全黑卻憑空浮著幾道光,所以要把看得見的光源本體打開,讓光有個來源。
 *
 * 影子則是關掉。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
})
</script>

原理

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

場景是立體的

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

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

順帶一提,光源別擺在畫面正中央。視點就在那裡,兩者連成一線時光束正對著你射過來,投影出來剛好是一把對稱的扇子。稍微偏開就好。

拆成兩趟

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

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

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

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

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

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

光與影分兩層

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

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

深色底要交代光去了哪裡

淺色底的頁面本身就是那面亮著的白牆,光束打上去理所當然。深色底什麼都沒有,只畫光束的話會變成背景全黑卻憑空浮著幾道光。光打到哪去了?(´・ω・`)

所以深色底要開 glow,讓光源本體出現在畫面上;或是開 ambient,讓光源照亮整個畫面。兩者都會讓擋光物擋下來,影子也順便有了著落。

原始碼

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.75.0