Fractals and Non-Euclidean Geometry in the Browser
Browser-Scale Geometry: High-Performance Fractal Rendering and Non-Euclidean Spaces with WebGL
Current Situation Analysis
Modern web applications increasingly demand real-time visualization of mathematically dense, infinite spaces. Developers attempting to render fractal sets or topologically impossible geometries directly in the browser quickly encounter two hard limits: sequential CPU execution and IEEE 754 32-bit floating-point precision.
The industry typically treats WebGL as a 3D scene graph for meshes and lighting, overlooking its true nature as a massively parallel compute engine. When developers attempt to evaluate the Mandelbrot recurrence relation z_{n+1} = z_n^2 + c on the CPU, they face a combinatorial explosion. A standard 1920Γ1080 viewport contains 2,073,600 pixels. Evaluating 200β500 iterations per pixel on a single JavaScript thread yields approximately 3β5 frames per second. This is fundamentally unusable for interactive exploration.
Furthermore, the precision wall is routinely misunderstood. GPU fragment shaders operate on 32-bit floats, which provide roughly 7 decimal digits of precision. When zooming into a fractal beyond a scale factor of 10^4, coordinate values exceed the mantissa's resolution. The result is pixelation, geometric aliasing, and complete loss of mathematical fidelity. Most browser-based implementations cap out here because developers attempt to upgrade the entire rendering pipeline to 64-bit math, which WebGL does not natively support in fragment shaders.
The solution requires abandoning CPU-bound iteration loops, embracing parallel fragment computation, and decoupling absolute coordinate precision from per-pixel evaluation. By shifting heavy arithmetic to the GPU and using perturbation theory to bypass float limitations, developers can achieve real-time, infinite-depth mathematical exploration entirely within a browser tab.
WOW Moment: Key Findings
The breakthrough lies in recognizing that per-pixel absolute precision is unnecessary. Instead of computing the full complex coordinate for every pixel, the GPU only needs to compute the delta relative to a high-precision reference point. This architectural shift transforms an impossible CPU task into a trivial GPU operation.
| Approach | Frame Rate (1080p) | Max Zoom Depth | Memory Overhead | Implementation Complexity |
|---|---|---|---|---|
| CPU Sequential Loop | 3β5 FPS | 10^2 | Low | Low |
| Standard GPU Fragment Shader | 60 FPS | 10^4 | Low | Medium |
| GPU + Perturbation Theory | 60 FPS | 10^14+ | Medium (Data Textures) | High |
| GPU + 64-bit Emulation | 15β20 FPS | 10^10 | High | Very High |
This finding matters because it decouples visual fidelity from hardware constraints. Developers no longer need to choose between performance and mathematical depth. The perturbation approach allows the CPU to compute a single high-precision reference orbit using arbitrary-precision libraries, while the GPU handles millions of lightweight delta calculations per frame. The result is buttery-smooth interaction at zoom levels that previously required desktop applications or offline rendering.
Core Solution
Building a browser-native infinite geometry engine requires three coordinated systems: parallel fragment computation, precision decoupling, and viewport-relative topology management.
1. GPU-Parallel Fractal Evaluation with Smooth Coloring
The Mandelbrot set is evaluated per-pixel in a fragment shader. Instead of JavaScript loops, the iteration logic runs in GLSL. To eliminate the hard color banding caused by integer iteration counts, we compute a fractional iteration value using the escape radius.
precision highp float;
uniform vec2 uResolution;
uniform vec2 uCenter;
uniform float uZoom;
uniform int uMaxIterations;
vec2 complexSquare(vec2 z) {
return vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
}
vec2 complexAdd(vec2 a, vec2 b) {
return a + b;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
vec2 c = uCenter + uv / uZoom;
vec2 z = vec2(0.0);
int iter = 0;
for (int i = 0; i < 1000; i++) {
if (i >= uMaxIterations) break;
if (dot(z, z) > 4.0) break;
z = complexAdd(complexSquare(z), c);
iter++;
}
if (iter == uMaxIterations) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Smooth iteration calculation
float log_zn = log(dot(z, z)) * 0.5;
float nu = log(log_zn / log(2.0)) / log(2.0);
float smoothIter = float(iter) + 1.0 - nu;
// Cosine palette mapping
vec3 color = 0.5 + 0.5 * cos(3.0 + smoothIter * 0.15 + vec3(0.0, 0.6, 1.0));
gl_FragColor = vec4(color, 1.0);
}
Architecture Rationale: The shader runs once per fragment. The log2(log2(dot(z,z))) equivalent formula converts the escape radius into a continuous value, eliminating staircase banding. The cosine palette provides perceptually uniform gradients without expensive texture lookups.
2. Breaking the Precision Wall with Perturbation Theory
When zoom exceeds 10^4, 32-bit floats lose coordinate accuracy. Perturbation theory solves this by computing one high-precision reference orbit on the CPU, then calculating per-pixel deltas on the GPU.
CPU Side (Arbitrary Precision):
import Decimal from 'decimal.js';
export function computeReferenceOrbit(
centerX: number,
centerY: number,
iterations: number
): Float32Array {
const cReal = new Decimal(centerX);
const cImag = new Decimal(centerY);
let zReal = new Decimal(0);
let zImag = new Decimal(0);
const orbitData = new Float32Array(iterations * 2);
for (let i = 0; i < iterations; i++) {
const zRealSq = zReal.times(zReal);
const zImagSq = zImag.times(zImag);
const zRealZImag = zReal.times(zImag);
const nextReal = zRealSq.minus(zImagSq).plus(cReal);
const nextImag = zRealZImag.times(2).plus(cImag);
zReal = nextReal;
zImag = nextImag;
orbitData[i * 2] = zReal.toNumber();
orbitData[i * 2 + 1] = zImag.toNumber();
}
return orbitData;
}
GPU Side (Delta Computation):
uniform sampler2D uReferenceOrbit;
uniform vec2 uPixelOffset;
uniform int uCurrentIteration;
vec2 complexMultiply(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
void computePerturbation() {
// Fetch reference orbit state
vec2 refZ = texelFetch(uReferenceOrbit, ivec2(0, uCurrentIteration), 0).rg;
// Initialize delta
vec2 delta = uPixelOffset;
// Perturbation recurrence: D_{n+1} = 2*Z_n*D_n + D_n^2 + C_delta
vec2 deltaSquared = complexSquare(delta);
vec2 crossTerm = 2.0 * complexMultiply(refZ, delta);
delta = crossTerm + deltaSquared + uPixelOffset;
// Check escape condition relative to reference
if (dot(delta, delta) > 4.0) {
// Escape detected
}
}
Architecture Rationale: The CPU computes the reference orbit once per camera movement using decimal.js. The orbit is uploaded as a 1D data texture. The GPU shader only tracks the tiny deviation (delta) from that reference. Since deltas remain small, 32-bit precision is sufficient. This hybrid approach keeps the main thread responsive while enabling zoom depths beyond 10^14.
3. Constructing Non-Euclidean Corridors
Infinite looping hallways require viewport-relative geometry management and mathematical culling.
Treadmill Chunk Recycling: Instead of generating infinite geometry, maintain a fixed pool of corridor segments. As the camera advances, segments that pass behind the near clipping plane are teleported to the far end of the visible range. The camera position remains static in world space; only the texture offsets and segment indices update. This guarantees constant memory usage regardless of travel distance.
GPU Clipping Planes: Traditional geometry swapping causes visible seams at corridor turns. Instead, define a THREE.Plane at the corner threshold. Three.js translates this into a WebGL clip space equation. Fragments on the invalid side of the plane are discarded before rasterization. The wall appears to vanish exactly at the turn without mesh manipulation, preserving vertex continuity and avoiding Z-fighting.
Pitfall Guide
1. React State in the Render Loop
Explanation: Updating React state on every frame triggers reconciliation, virtual DOM diffing, and component re-renders. This adds 8β15ms of overhead per frame, destroying 60fps targets.
Fix: Store all animation, camera, and shader uniforms in useRef. Only use React state for UI overlays, controls, and non-render-loop data.
2. Integer Iteration Banding
Explanation: Mapping color directly to the integer iteration count creates hard visual bands where the escape condition flips. Fix: Always compute the smooth iteration value using the escape radius logarithm. The fractional component fills the gaps between integer steps.
3. Main Thread Blocking with Arbitrary Precision
Explanation: decimal.js operations are computationally expensive. Running them synchronously during camera movement freezes the UI.
Fix: Offload orbit computation to a Web Worker. Use postMessage with Transferable objects to move Float32Array data without copying. Implement requestAnimationFrame throttling to compute only when the camera stops moving.
4. Data Texture Upload Bottlenecks
Explanation: Uploading a full reference orbit texture every frame stalls the GPU pipeline. WebGL texture uploads are synchronous and block the command buffer. Fix: Double-buffer the orbit texture. Upload the new orbit in the background while the shader reads from the previous frame's texture. Swap bindings only after the upload completes.
5. Geometry Swapping for Corridor Turns
Explanation: Dynamically adding/removing meshes at corridor corners causes visible popping, shadow map artifacts, and physics engine desynchronization. Fix: Use GPU clipping planes or stencil buffers to hide geometry mathematically. Keep the mesh topology static; let the fragment shader handle visibility.
6. Ignoring Device Pixel Ratio (DPR)
Explanation: Rendering at CSS pixel dimensions on high-DPI displays results in blurry fractals and misaligned clipping planes.
Fix: Multiply canvas width/height by window.devicePixelRatio. Update uResolution uniform accordingly. Handle DPR changes via ResizeObserver.
7. Unbounded Memory in Chunk Recycling
Explanation: Failing to properly reset transform matrices or texture offsets during treadmill recycling causes floating-point drift and memory leaks. Fix: Implement strict object pooling. Reset chunk positions to exact integer multiples of chunk size. Use modulo arithmetic for index wrapping instead of incremental addition.
Production Bundle
Action Checklist
- Replace all render-loop state with
useRefandrequestAnimationFrame - Implement smooth iteration coloring using escape radius logarithms
- Offload arbitrary-precision orbit calculation to a Web Worker
- Double-buffer data textures to prevent GPU pipeline stalls
- Use
THREE.Planeclipping instead of geometry swapping for corridor turns - Handle
devicePixelRatioscaling and canvas resize events - Implement object pooling for corridor chunk recycling
- Profile with Chrome DevTools Performance tab to verify <16ms frame budget
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Shallow zoom (<10^3) | Standard GPU fragment shader | Simpler implementation, lower memory overhead | Low |
| Deep zoom (10^4 to 10^14) | Perturbation theory + data textures | Bypasses 32-bit float precision wall | Medium (CPU worker + texture memory) |
| Infinite corridor with sharp turns | GPU clipping planes | Eliminates geometry seams and Z-fighting | Low |
| Infinite corridor with smooth curves | Treadmill chunk recycling + vertex displacement | Maintains constant memory, avoids mesh complexity | Medium |
| Mobile deployment | Reduced iteration count + DPR scaling | Preserves battery life and thermal limits | Low |
Configuration Template
// src/webgl/GeometryEngine.ts
import * as THREE from 'three';
import { OrbitControls } from 'three-orchestra';
export interface GeometryEngineConfig {
canvas: HTMLCanvasElement;
maxIterations: number;
workerPath: string;
enablePerturbation: boolean;
}
export class GeometryEngine {
private renderer: THREE.WebGLRenderer;
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private controls: OrbitControls;
private worker: Worker;
private orbitTexture: THREE.DataTexture;
private isComputingOrbit: boolean = false;
constructor(config: GeometryEngineConfig) {
this.renderer = new THREE.WebGLRenderer({
canvas: config.canvas,
antialias: false,
powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
this.controls = new OrbitControls(this.camera, config.canvas);
this.worker = new Worker(config.workerPath);
this.worker.onmessage = this.handleOrbitResult.bind(this);
this.orbitTexture = new THREE.DataTexture(
new Float32Array(1024 * 2),
1024, 1, THREE.RGBAFormat, THREE.FloatType
);
}
private handleOrbitResult(event: MessageEvent) {
this.orbitTexture.image.data = event.data;
this.orbitTexture.needsUpdate = true;
this.isComputingOrbit = false;
}
public requestOrbitUpdate(centerX: number, centerY: number) {
if (this.isComputingOrbit) return;
this.isComputingOrbit = true;
this.worker.postMessage({ centerX, centerY, iterations: 1024 });
}
public render() {
this.renderer.render(this.scene, this.camera);
}
}
Quick Start Guide
- Initialize the WebGL Context: Create a canvas element and attach a
THREE.WebGLRendererwithpowerPreference: 'high-performance'. Disable antialiasing to reduce fragment shader overhead. - Set Up the Render Loop: Use
requestAnimationFramewithuseRefto track uniforms. Update camera position and zoom factors outside React's render cycle. - Deploy the Precision Worker: Create a Web Worker that imports
decimal.js. Send camera coordinates on movement end, receiveFloat32Arrayorbit data, and upload to aTHREE.DataTexture. - Bind Shader Uniforms: Pass
uResolution,uCenter,uZoom,uReferenceOrbit, anduMaxIterationsto your fragment shader. EnableTHREE.Planeclipping for corridor geometry. - Validate Performance: Open Chrome DevTools Performance tab. Record a 10-second interaction session. Verify frame times stay below 16.6ms and GPU memory remains stable. Adjust iteration counts or texture dimensions if bottlenecks appear.
