Skip to content

Volumetric Light util

Light pours in from behind the whole page, carving the air between you and the screen into solid beams.

Tech Keywords

NameDescription
Canvas ShaderWritten in GLSL and executed directly on the GPU, faster than the Canvas 2D API but harder to master
Ray MarchingSamples step by step along a ray, commonly used to render volumetric light, fog, clouds, and other things without a definite surface
SDF (Signed Distance Field)Describes shape outlines with mathematical functions, commonly used in shaders to draw smooth graphics
DOM to ImageConverts DOM elements into images, based on SVG foreignObject
CSS mix-blend-modeSets how an element blends with what is painted beneath it, such as additive plus-lighter or darkening multiply
NoiseProduces more natural randomness than Math.random, commonly used for terrain, clouds, and textures
Vue DirectiveCustom Vue directives that encapsulate DOM manipulation logic and repeated behaviors

Examples

Basic Usage

Lay a full-screen util-volumetric-light over the page, then mark the elements that block the light with the v-util-volumetric-light directive.

The directive captures each element as an image, so what blocks the light is its real outline. Text therefore blocks letter by letter, and light pours through the gaps between the strokes. ( •̀ ω •́ )✧

That One Beam by the Window

It's just past four, and the light comes slanting in through the window. Stand in front of it and your face sinks into shadow, leaving a ring of gold along the edge of your hair. Dust that stays invisible all day shows up as well, and the light hangs there in the air, one strand at a time.

128Likes
32Comments
3.4kViews
#Backlight#Photography#Light
View the example source code
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>

How it works

We see a beam because the air it passes through scatters light into our eyes. So every pixel walks its own view ray, asking at each step whether this bit of air can see the light. The technique is called Ray Marching.

The scene is three-dimensional

The coordinates are laid out like this: the page is the plane at z = 0, the viewpoint sits in front, the light retreats behind, and marked elements are plates lying on the plane.

Every pixel therefore has a real view ray slicing into the scene at an angle. The ray is slanted, the light is a point, so shadows open into cones with distance and the beams gain volume.

One note: don't park the light in the dead centre of the screen. The viewpoint is right there, so the beams come straight at you and project into a symmetric fan. Nudge it off-axis.

Two passes

Every pixel walks a hundred-odd steps, and each step would have to check every occluder. That gives "pixels × steps × occluders", which stops running the moment the canvas gets large.

So the occluders get baked into one image first, and each step only samples that image once. Multiplication turns into addition. ⎝(・ω´・⎝)

That image also settles the shadows. All occluders lie on one plane, so the line from the light to a sample point crosses it at most once. Work out where it crosses, ask once, and no shadow map is needed at all.

Light only grows beside the marked elements

The march records one more thing along the way: how much of the ray travels near an occluder. That ratio becomes the gate. Far from any occluder it is exactly zero, and the page stays untouched.

The light and dark between beams comes from a blocking pattern in the air. The pattern orbits the light, so air along one light ray shares one point of it and the whole line brightens or dims together, which is what separates one beam from the next.

Light and shadow take separate layers

Brightening wants plus-lighter, darkening wants multiply, and one element can only carry one blend mode. So light and shadow each get their own canvas, shadow underneath, light on top.

The two are the same calculation seen from opposite sides. A beam is "the fraction of light that gets through", a shadow is "the fraction that doesn't", and they share the pattern's density, so the bright gaps and dark bands line up on their own. ( •̀ ω •́ )✧

A dark page has to say where the light went

A light-coloured page is already the lit white wall, so beams landing on it make sense. A dark page has nothing, and beams alone become a pitch-black background with a few strands of light floating over it. Where did the light go? (´・ω・`)

So a dark page wants glow, which puts the source itself on screen, or ambient, which lets the source illuminate everything. Occluders hold both of them back, which gives the shadows somewhere to live too.

Source Code

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