Skip to main content

Prism Dispersion

Open the prism experiment. Drag the canvas or use arrow keys to rotate the equilateral prism. Use the settings drawer to adjust rotation angle and Cauchy dispersion strength, or reset to default parameters.

Geometric Optics Model

The simulation traces light through an upright equilateral prism using 2D geometric optics. Each incident ray is evaluated against polygon edges for intersection, refraction, and potential total internal reflection.

Snell's Law in Vector Formulation

Given an incident unit direction v\mathbf{v}, outward surface normal n\mathbf{n}, and refractive index ratio η=n1/n2\eta = n_1 / n_2:

  1. Orient the normal n\mathbf{n} opposite to the incoming ray such that cosθi=(vn)>0\cos \theta_i = -(\mathbf{v} \cdot \mathbf{n}) > 0.
  2. Compute the transmission discriminant: Δ=1η2(1cos2θi).\Delta = 1 - \eta^2 (1 - \cos^2 \theta_i).
  3. Total Internal Reflection (TIR): If Δ<0\Delta < 0, light cannot transmit into the second medium and reflects completely: v=v2(vn)n.\mathbf{v}' = \mathbf{v} - 2(\mathbf{v} \cdot \mathbf{n})\mathbf{n}.
  4. Refraction: If Δ0\Delta \ge 0, the refracted transmission vector is: t=ηv+(ηcosθiΔ)n.\mathbf{t} = \eta \mathbf{v} + \left(\eta \cos \theta_i - \sqrt{\Delta}\right)\mathbf{n}.
export function refract(direction, outwardNormal, fromIndex, toIndex) {
const normal = dot(direction, outwardNormal) > 0 ? outwardNormal.map(x => -x) : outwardNormal;
const cosine = -dot(direction, normal);
const ratio = fromIndex / toIndex;
const discriminant = 1 - ratio * ratio * Math.max(0, 1 - cosine * cosine);
if (discriminant < 0) return null; // Total internal reflection
return unit(direction.map((x, i) => ratio * x + (ratio * cosine - Math.sqrt(discriminant)) * normal[i]));
}

Cauchy Dispersion Model

To separate polychromatic white light into a rainbow spectrum, the refractive index varies as a function of wavelength λ[400,700]nm\lambda \in [400, 700]\,\text{nm} via an empirical Cauchy relation:

n(λ)=1.5+B0.008(1(λ/1000)210.552),n(\lambda) = 1.5 + B \cdot 0.008 \left(\frac{1}{(\lambda / 1000)^2} - \frac{1}{0.55^2}\right),

where BB controls dispersion strength (calibrated against 550 nm yellow light passing horizontally through an upright prism). The simulation samples 101 wavelengths with 21 parallel beam offsets each, producing 101×21=2,121101 \times 21 = 2{,}121 individual rays traced up to 12 bounces.

export function traceRay(origin, direction, polygon, index, width, height) {
const points = [origin];
let p = origin, v = direction;
for (let bounce = 0; bounce < 12; bounce++) {
const hit = intersectRay(p, v, polygon);
if (!hit) {
// Ray leaves the prism; extend to viewport boundary
points.push(extendToBoundary(p, v, width, height));
break;
}
points.push(hit.point);
const exiting = dot(v, hit.normal) > 0;
const transmitted = refract(v, hit.normal, exiting ? index : 1, exiting ? 1 : index);
v = transmitted ?? v.map((x, i) => x - 2 * dot(v, hit.normal) * hit.normal[i]);
p = hit.point.map((x, i) => x + v[i] * 1e-4);
}
return points;
}

GPU Batching in p5.js WebGL Mode

Standard 2D canvas rasterization requires 2,121 independent stroke() operations per redraw. Under additive color blending, this creates substantial rasterization overhead during continuous interaction.

The implementation switches p5.js into WebGL mode and uploads ray segments into a single contiguous vertex buffer (BeamMesh):

CharacteristicCanvas 2D PipelineWebGL Batching Pipeline
Geometry2,121 individual Path2D strokesPre-allocated Float32Array vertex buffer
Draw calls2,121 drawing calls per redraw1 call for all beams, 1 for prism fill, 1 for outline
Edge renderingCPU stroke rasterizationGPU fragment shader with signed distance and antialiasing
Color blendingCanvas compositingAdditive blending (gl.blendFunc(gl.ONE, gl.ONE))
// Each line segment expands into a quad (two triangles, 6 vertices)
// with position, premultiplied color, and signed normal offset
mesh.segment(p1, p2, [r, g, b, alpha], lineWidth);

// Upload all vertices to GPU in a single bufferSubData call
gl.bufferSubData(gl.ARRAY_BUFFER, 0, mesh.data.subarray(0, mesh.count * VERTEX_SIZE));
gl.drawArrays(gl.TRIANGLES, 0, beamVertexCount);

The fragment shader evaluates normal distance from the segment axis, generating smooth Gaussian falloff along beam edges without texture lookups.

Interaction Decoupling and Context Recovery

  • Decoupled input: Dragging mutates an internal reference and schedules a redraw via requestAnimationFrame. React component state is only synchronized when the pointer is released, keeping interaction smooth.
  • WebGL context loss: Listens for webglcontextlost and webglcontextrestored, reallocating GPU programs and buffers automatically without leaking orphaned resources.
  • Cleanup: All GPU buffers, shaders, event listeners, and observers are released on unmount.
Explore connectionsOpen network