傍晚窗邊的那道光
下午四點多,光斜斜地從窗外穿進來。人站在窗前,臉整個沉進暗處,只剩髮絲的邊緣亮著一圈金色。平常看不見的灰塵這時候全現形了,一道一道的光就這樣浮在空氣裡。
光從整個頁面的後方照過來,你與畫面之間的空氣被切成一道道光束。
| 名稱 | 描述 |
|---|---|
| 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 指令標記會擋住光的元素。
指令會把元素拍成一張圖,所以擋光的是它真正的輪廓。文字因此一個字一個字擋光,光從筆畫之間的縫隙穿出來。( •̀ ω •́ )✧
下午四點多,光斜斜地從窗外穿進來。人站在窗前,臉整個沉進暗處,只剩髮絲的邊緣亮著一圈金色。平常看不見的灰塵這時候全現形了,一道一道的光就這樣浮在空氣裡。
<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,讓光源照亮整個畫面。兩者都會讓擋光物擋下來,影子也順便有了著落。
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;
}