LAB_03 // SIDE_RAYS

SIDE
RAYS

WebGL // One Fragment Shader // Every Uniform Exposed

Volumetric light rays from a single full-screen triangle, ported from React Bits' Side Rays to Vue and OGL. Every shader uniform is wired to a control below. Tune it, then copy the whole component.

01

THE_STAGE

LIVE // WEBGL

RAYS

ORIGIN top-right
YOUR CONFIG [+]
<LabSideRays
  :speed="2.5" :intensity="2" :spread="2" :tilt="0"
  :saturation="1.5" :blend="0.75" :falloff="1.6" :opacity="1"
  ray-color1="#EAB308" ray-color2="#96c8ff" origin="top-right"
/>
THE WHOLE COMPONENT [+]
<template>
  <div ref="containerRef" class="side-rays" aria-hidden="true"></div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
// @ts-ignore — ogl has no bundled types
import { Renderer, Program, Mesh, Triangle } from "ogl"

// Vue/OGL port of React Bits' Side Rays
// https://reactbits.dev/backgrounds/side-rays
const props = withDefaults(defineProps<{
  speed?: number
  rayColor1?: string
  rayColor2?: string
  intensity?: number
  spread?: number
  origin?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
  tilt?: number
  saturation?: number
  blend?: number
  falloff?: number
  opacity?: number
}>(), {
  speed: 2.5,
  rayColor1: "#EAB308",
  rayColor2: "#96c8ff",
  intensity: 2,
  spread: 2,
  origin: "top-right",
  tilt: 0,
  saturation: 1.5,
  blend: 0.75,
  falloff: 1.6,
  opacity: 1.0,
})

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

const hexToRgb = (hex: string): [number, number, number] => {
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  return m
    ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255]
    : [1, 1, 1]
}

const originToFlip = (origin: string): [number, number] => {
  switch (origin) {
    case "top-left": return [1, 0]
    case "bottom-right": return [0, 1]
    case "bottom-left": return [1, 1]
    default: return [0, 0] // top-right
  }
}

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

const fragment = `precision highp float;

uniform float iTime;
uniform vec2 iResolution;
uniform float iSpeed;
uniform vec3 iRayColor1;
uniform vec3 iRayColor2;
uniform float iIntensity;
uniform float iSpread;
uniform float iFlipX;
uniform float iFlipY;
uniform float iTilt;
uniform float iSaturation;
uniform float iBlend;
uniform float iFalloff;
uniform float iOpacity;

float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord, float seedA, float seedB, float speed) {
  vec2 sourceToCoord = coord - raySource;
  float cosAngle = dot(normalize(sourceToCoord), rayRefDirection);
  return clamp(
    (0.45 + 0.15 * sin(cosAngle * seedA + iTime * speed)) +
    (0.3 + 0.2 * cos(-cosAngle * seedB + iTime * speed)),
    0.0, 1.0) *
    clamp((iResolution.x - length(sourceToCoord)) / iResolution.x, 0.5, 1.0);
}

void main() {
  vec2 fragCoord = gl_FragCoord.xy;
  if (iFlipX > 0.5) fragCoord.x = iResolution.x - fragCoord.x;
  if (iFlipY > 0.5) fragCoord.y = iResolution.y - fragCoord.y;

  vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
  vec2 rayPos = vec2(iResolution.x * 1.1, -0.5 * iResolution.y);

  float tiltRad = iTilt * 3.14159265 / 180.0;
  float cs = cos(tiltRad);
  float sn = sin(tiltRad);
  vec2 rel = coord - rayPos;
  vec2 tiltedCoord = vec2(rel.x * cs - rel.y * sn, rel.x * sn + rel.y * cs) + rayPos;

  float halfSpread = iSpread * 0.275;
  vec2 rayRefDir1 = normalize(vec2(cos(0.785398 + halfSpread), sin(0.785398 + halfSpread)));
  vec2 rayRefDir2 = normalize(vec2(cos(0.785398 - halfSpread), sin(0.785398 - halfSpread)));

  vec4 rays1 = vec4(iRayColor1, 1.0) * rayStrength(rayPos, rayRefDir1, tiltedCoord, 36.2214, 21.11349, iSpeed);
  vec4 rays2 = vec4(iRayColor2, 1.0) * rayStrength(rayPos, rayRefDir2, tiltedCoord, 22.3991, 18.0234, iSpeed * 0.2);

  vec4 color = rays1 * (1.0 - iBlend) * 0.9 + rays2 * iBlend * 0.9;

  float distanceToLight = length(fragCoord.xy - vec2(rayPos.x, iResolution.y - rayPos.y)) / iResolution.y;
  float brightness = iIntensity * 0.4 / pow(max(distanceToLight, 0.001), iFalloff);
  color.rgb *= brightness;

  float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
  color.rgb = mix(vec3(gray), color.rgb, iSaturation);

  color.a = max(color.r, max(color.g, color.b)) * iOpacity;
  gl_FragColor = color;
}`

let renderer: any
let uniforms: any
let raf = 0
let cleanup: (() => void) | undefined
let io: IntersectionObserver | undefined

function start() {
  const el = containerRef.value
  if (!el || renderer) return

  renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio, 2), alpha: true })
  const gl = renderer.gl
  gl.canvas.style.width = "100%"
  gl.canvas.style.height = "100%"
  el.appendChild(gl.canvas)

  const [flipX, flipY] = originToFlip(props.origin)
  uniforms = {
    iTime:       { value: 0 },
    iResolution: { value: [1, 1] },
    iSpeed:      { value: props.speed },
    iRayColor1:  { value: hexToRgb(props.rayColor1) },
    iRayColor2:  { value: hexToRgb(props.rayColor2) },
    iIntensity:  { value: props.intensity },
    iSpread:     { value: props.spread },
    iFlipX:      { value: flipX },
    iFlipY:      { value: flipY },
    iTilt:       { value: props.tilt },
    iSaturation: { value: props.saturation },
    iBlend:      { value: props.blend },
    iFalloff:    { value: props.falloff },
    iOpacity:    { value: props.opacity },
  }

  const program = new Program(gl, { vertex, fragment, uniforms })
  const mesh = new Mesh(gl, { geometry: new Triangle(gl), program })

  const updateSize = () => {
    renderer.dpr = Math.min(window.devicePixelRatio, 2)
    renderer.setSize(el.clientWidth, el.clientHeight)
    uniforms.iResolution.value = [el.clientWidth * renderer.dpr, el.clientHeight * renderer.dpr]
  }
  window.addEventListener("resize", updateSize)
  updateSize()

  const loop = (t: number) => {
    uniforms.iTime.value = t * 0.001
    renderer.render({ scene: mesh })
    raf = requestAnimationFrame(loop)
  }
  raf = requestAnimationFrame(loop)

  cleanup = () => {
    cancelAnimationFrame(raf)
    window.removeEventListener("resize", updateSize)
    gl.getExtension("WEBGL_lose_context")?.loseContext()
    gl.canvas.remove()
    renderer = undefined
    uniforms = undefined
  }
}

function stop() {
  cleanup?.()
  cleanup = undefined
}

// props flow straight into the uniforms — no rebuild needed
watch(props, () => {
  if (!uniforms) return
  const [flipX, flipY] = originToFlip(props.origin)
  uniforms.iSpeed.value = props.speed
  uniforms.iRayColor1.value = hexToRgb(props.rayColor1)
  uniforms.iRayColor2.value = hexToRgb(props.rayColor2)
  uniforms.iIntensity.value = props.intensity
  uniforms.iSpread.value = props.spread
  uniforms.iFlipX.value = flipX
  uniforms.iFlipY.value = flipY
  uniforms.iTilt.value = props.tilt
  uniforms.iSaturation.value = props.saturation
  uniforms.iBlend.value = props.blend
  uniforms.iFalloff.value = props.falloff
  uniforms.iOpacity.value = props.opacity
})

onMounted(() => {
  // only burn GPU while the rays are actually on screen
  io = new IntersectionObserver(
    ([entry]) => (entry.isIntersecting ? start() : stop()),
    { threshold: 0.1 },
  )
  if (containerRef.value) io.observe(containerRef.value)
})

onBeforeUnmount(() => {
  io?.disconnect()
  stop()
})
</script>

<style scoped>
.side-rays {
  width: 100%;
  height: 100%;
  position: relative;
  pointer-events: none;
  overflow: hidden;
}
</style>

SOURCE: ported from reactbits.dev/backgrounds/side-rays (React) to Vue 3 + OGL. Same shader, Vue lifecycle.

02

THE_DIVE

The same shader as a scene: sunlight through water. Blue rays from the surface, a depth gradient behind them, a few CSS bubbles in front. Nothing else changed, it is all in the uniforms.

PRESET // UNDERWATER

THE DEEP