LAB_04 // LIGHTNING

LIGHT
NING

WebGL // fbm Fog // Every Strike Unique

One fragment shader: fbm noise bends a vertical line into a bolt and paints the fog around it, ported from React Bits' Lightning to Vue. Strike mode is the twist: the bolt only exists for a few hundred milliseconds, and every strike jumps the shader time, so you never see the same one twice.

01

THE_STORM

LIVE // WEBGL

STRIKE

YOUR CONFIG [+]
<LabLightning
  :hue="230" :x-offset="0" :speed="1"
  :intensity="1" :size="1"
  :strike-mode="true" :interval="3"
/>
THE WHOLE COMPONENT [+]
<template>
  <div ref="containerRef" class="lightning-container" aria-hidden="true">
    <canvas ref="canvasRef" class="lightning-canvas"></canvas>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue"

// Vue port of React Bits' Lightning — fbm-distorted bolt with volumetric fog,
// one fragment shader on raw WebGL. https://reactbits.dev/backgrounds/lightning
// Extra on top of the original: strikeMode — the bolt only appears in short
// random bursts, and every burst jumps the shader time so no two look alike.
const props = withDefaults(defineProps<{
  hue?: number
  xOffset?: number
  speed?: number
  intensity?: number
  size?: number
  strikeMode?: boolean
  interval?: number    // average seconds between strikes (strikeMode only)
}>(), {
  hue: 230,
  xOffset: 0,
  speed: 1,
  intensity: 1,
  size: 1,
  strikeMode: false,
  interval: 3,
})

const containerRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)

const vertexShaderSource = `
  attribute vec2 aPosition;
  void main() {
    gl_Position = vec4(aPosition, 0.0, 1.0);
  }
`

const fragmentShaderSource = `
  precision mediump float;
  uniform vec2 iResolution;
  uniform float iTime;
  uniform float uHue;
  uniform float uXOffset;
  uniform float uSpeed;
  uniform float uIntensity;
  uniform float uSize;

  #define OCTAVE_COUNT 10

  vec3 hsv2rgb(vec3 c) {
      vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0,4.0,2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
      return c.z * mix(vec3(1.0), rgb, c.y);
  }

  float hash11(float p) {
      p = fract(p * .1031);
      p *= p + 33.33;
      p *= p + p;
      return fract(p);
  }

  float hash12(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * .1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
  }

  mat2 rotate2d(float theta) {
      float c = cos(theta);
      float s = sin(theta);
      return mat2(c, -s, s, c);
  }

  float noise(vec2 p) {
      vec2 ip = floor(p);
      vec2 fp = fract(p);
      float a = hash12(ip);
      float b = hash12(ip + vec2(1.0, 0.0));
      float c = hash12(ip + vec2(0.0, 1.0));
      float d = hash12(ip + vec2(1.0, 1.0));

      vec2 t = smoothstep(0.0, 1.0, fp);
      return mix(mix(a, b, t.x), mix(c, d, t.x), t.y);
  }

  float fbm(vec2 p) {
      float value = 0.0;
      float amplitude = 0.5;
      for (int i = 0; i < OCTAVE_COUNT; ++i) {
          value += amplitude * noise(p);
          p *= rotate2d(0.45);
          p *= 2.0;
          amplitude *= 0.5;
      }
      return value;
  }

  void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
      vec2 uv = fragCoord / iResolution.xy;
      uv = 2.0 * uv - 1.0;
      uv.x *= iResolution.x / iResolution.y;
      uv.x += uXOffset;

      uv += 2.0 * fbm(uv * uSize + 0.8 * iTime * uSpeed) - 1.0;

      float dist = abs(uv.x);
      vec3 baseColor = hsv2rgb(vec3(uHue / 360.0, 0.7, 0.8));
      vec3 col = baseColor * pow(mix(0.0, 0.07, hash11(iTime * uSpeed)) / dist, 1.0) * uIntensity;
      col = pow(col, vec3(1.0));
      float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0);
      fragColor = vec4(col, a);
  }

  void main() {
      mainImage(gl_FragColor, gl_FragCoord.xy);
  }
`

let gl: WebGLRenderingContext | null = null
let raf = 0
let running = false
let io: IntersectionObserver | undefined
let loc: Record<string, WebGLUniformLocation | null> = {}
let startTime = 0
let timeJump = 0
let strikeStart = -1
let strikeDur = 0
let nextStrikeAt = 0
let flicker: number[] = []

function resizeCanvas() {
  const canvas = canvasRef.value
  if (!canvas) return
  canvas.width = canvas.clientWidth
  canvas.height = canvas.clientHeight
}

function initGL(): boolean {
  const canvas = canvasRef.value
  if (!canvas) return false
  gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false })
  if (!gl) return false

  const compile = (source: string, type: number) => {
    const shader = gl!.createShader(type)!
    gl!.shaderSource(shader, source)
    gl!.compileShader(shader)
    if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
      console.error("Shader compile error:", gl!.getShaderInfoLog(shader))
      return null
    }
    return shader
  }
  const vs = compile(vertexShaderSource, gl.VERTEX_SHADER)
  const fs = compile(fragmentShaderSource, gl.FRAGMENT_SHADER)
  if (!vs || !fs) return false

  const program = gl.createProgram()!
  gl.attachShader(program, vs)
  gl.attachShader(program, fs)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error("Program linking error:", gl.getProgramInfoLog(program))
    return false
  }
  gl.useProgram(program)

  const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1])
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
  const aPosition = gl.getAttribLocation(program, "aPosition")
  gl.enableVertexAttribArray(aPosition)
  gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)

  for (const name of ["iResolution", "iTime", "uHue", "uXOffset", "uSpeed", "uIntensity", "uSize"]) {
    loc[name] = gl.getUniformLocation(program, name)
  }
  return true
}

/* strikeMode envelope: 0 between strikes, a fresh random flicker curve during one */
function strikeEnvelope(now: number): number {
  if (now >= nextStrikeAt && strikeStart < 0) beginStrike(now)
  if (strikeStart < 0) return 0
  const t = (now - strikeStart) / strikeDur
  if (t >= 1) {
    strikeStart = -1
    return 0
  }
  const pos = t * (flicker.length - 1)
  const i = Math.floor(pos)
  return flicker[i] + (flicker[i + 1] - flicker[i]) * (pos - i)
}

function beginStrike(now: number) {
  strikeStart = now
  strikeDur = 280 + Math.random() * 350
  timeJump = Math.random() * 1000            // new shader time = new bolt shape
  flicker = [0, 1, 0.3 + Math.random() * 0.4, 0.8 + Math.random() * 0.2, 0.35, 0]
  nextStrikeAt = now + props.interval * 1000 * (0.5 + Math.random())
}

function drawFrame(now: number) {
  const canvas = canvasRef.value
  if (!gl || !canvas) return
  resizeCanvas()
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.uniform2f(loc.iResolution, canvas.width, canvas.height)
  gl.uniform1f(loc.iTime, (now - startTime) / 1000.0 + timeJump)
  gl.uniform1f(loc.uHue, props.hue)
  gl.uniform1f(loc.uXOffset, props.xOffset)
  gl.uniform1f(loc.uSpeed, props.speed)
  gl.uniform1f(loc.uSize, props.size)
  const env = props.strikeMode ? strikeEnvelope(now) : 1
  gl.uniform1f(loc.uIntensity, props.intensity * env)
  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT)
  if (env > 0) gl.drawArrays(gl.TRIANGLES, 0, 6)
}

function loop(now: number) {
  drawFrame(now)
  raf = requestAnimationFrame(loop)
}

function start() {
  if (running) return
  if (!gl && !initGL()) return
  running = true
  startTime = performance.now()
  nextStrikeAt = startTime + 500
  strikeStart = -1
  raf = requestAnimationFrame(loop)
}

function stop() {
  running = false
  cancelAnimationFrame(raf)
}

onMounted(() => {
  resizeCanvas()
  window.addEventListener("resize", resizeCanvas)
  // reduced motion: one static frame, no animation loop
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    if (initGL()) {
      startTime = performance.now()
      const env = props.strikeMode
      if (!env) drawFrame(startTime)
    }
    return
  }
  io = new IntersectionObserver(
    ([entry]) => (entry.isIntersecting ? start() : stop()),
    { threshold: 0.1 },
  )
  if (containerRef.value) io.observe(containerRef.value)
})

onBeforeUnmount(() => {
  io?.disconnect()
  window.removeEventListener("resize", resizeCanvas)
  stop()
  gl?.getExtension("WEBGL_lose_context")?.loseContext()
  gl = null
})

/* manual trigger: in strikeMode fires a strike now, otherwise jumps to a new bolt */
function strikeNow() {
  if (props.strikeMode) {
    beginStrike(performance.now())
  } else {
    timeJump = Math.random() * 1000
  }
  if (!running && gl) drawFrame(performance.now())
}
defineExpose({ strike: strikeNow })
</script>

<style scoped>
.lightning-container {
  width: 100%;
  height: 100%;
  position: relative;
  pointer-events: none;
  overflow: hidden;
}
.lightning-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
</style>

SOURCE: ported from reactbits.dev/backgrounds/lightning (React) to Vue 3, raw WebGL, no wrapper library. Strike mode and the per-strike time jump are additions from this lab.