Dirck Mulder
Backgrounds||4 min read

Build a Light Pillar Effect in React with WebGL

Render a dramatic volumetric light beam rising from the ground using raymarching in a WebGL fragment shader.

Volumetric light is one of those effects that makes people stop scrolling. It looks physical, like the light is actually occupying space. LightPillar achieves this in the browser with a raymarching fragment shader built on Three.js. No 3D models, no textures, just math.

The final result

What we are building

A glowing vertical beam of light that rises from the base of the canvas and softens at the top. The beam has animated density variation, a color gradient from bottom to top, and an optional mouse-interactive rotation.

Setting up

bash
npm install three
tsx
import * as THREE from 'three';
import React, { useRef, useEffect, useState } from 'react';

Building the component

The component generates the fragment shader dynamically, injecting quality settings as GLSL constants:

tsx
const settings = qualitySettings[quality];
const fragmentShader = `
  // ...
  const int ITERATIONS = ${settings.iterations};
  const int WAVE_ITERATIONS = ${settings.waveIterations};
  const float STEP_MULT = ${settings.stepMultiplier.toFixed(1)};
  // ...
`;

This sidesteps the GLSL limitation of not allowing loop bounds to be runtime values. By injecting the constant at compile time, the GPU can unroll the loop efficiently.

The raymarching loop is the heart of the effect. For each pixel, a ray is marched from a virtual camera position:

glsl
for(int i = 0; i < ITERATIONS; i++) {
  vec3 pos = origin + direction * depth;

  // Rotate position around Y axis
  float newX = pos.x * rotCos - pos.z * rotSin;
  float newZ = pos.x * rotSin + pos.z * rotCos;
  pos.x = newX;
  pos.z = newZ;

  vec3 deformed = pos;
  deformed.y *= uPillarHeight;
  deformed = deformed + vec3(0.0, uTime, 0.0);

  // Apply wave octaves
  for(int j = 0; j < WAVE_ITERATIONS; j++) {
    vec3 oscillation = cos(deformed.zxy * frequency - phase);
    deformed += oscillation * amplitude;
    frequency *= 2.0;
    amplitude *= 0.5;
  }

  float fieldDistance = length(cosinePair) - 0.2;
  float radialBound = length(pos.xz) - uPillarWidth;

  // Smooth union of field and radial bounds
  float k = 4.0;
  float h = max(k - abs(-radialBound - (-fieldDistance)), 0.0);
  fieldDistance = -(min(-radialBound, -fieldDistance) - h * h * 0.25 / k);

The smooth union h * h * 0.25 / k blends the pillar's cylindrical boundary with the wave field, creating the organic edges. Each step accumulates color based on the current gradient and field proximity:

glsl
vec3 gradient = mix(uBottomColor, uTopColor, smoothstep(15.0, -15.0, pos.y));
color += gradient / fieldDistance;

Dividing by fieldDistance is the key: small distances (near the surface) contribute large color values, and large distances contribute almost nothing. This naturally creates the glow falloff.

After raymarching, the accumulated color is compressed with tanh to keep it in a displayable range:

glsl
color = tanh(color * uGlowAmount / widthNormalization);

The Three.js setup uses separate useEffect hooks for initialization and prop updates, so changing colors or intensity does not tear down and rebuild the WebGL context:

tsx
useEffect(() => {
  // One-time setup: renderer, geometry, material
}, [webGLSupported, quality]);

useEffect(() => {
  if (!materialRef.current) return;
  materialRef.current.uniforms.uTopColor.value = parseColor(topColor);
}, [topColor]);

How to use it

tsx
<div className="relative h-screen bg-black">
  <LightPillar
    topColor="#5227FF"
    bottomColor="#FF9FFC"
    intensity={1.0}
    rotationSpeed={0.3}
    pillarWidth={3.0}
    glowAmount={0.005}
    mixBlendMode="screen"
  />
  <div className="relative z-10">Your content</div>
</div>

The mixBlendMode="screen" style keeps dark areas transparent when compositing over a dark background.

Key takeaways

  • Injecting loop counts as GLSL compile-time constants via template literals gives you quality presets without runtime branching overhead.
  • The smooth union operation blends two signed distance fields so the pillar boundary feels organic rather than hard-edged.
  • Separating init and update into multiple useEffect hooks lets you change props without destroying the WebGL context.