February 24, 2026 • 12 min read • By Madhawa Sadil

Neuroevolution in 3D WebGL: Foveated Ray-Marching, Zero-Allocation Neural Networks, and Spatial Grids in Wild Oasis

Architecting autonomous artificial life in Three.js: bio-inspired foveated ray-marching, cache-friendly flat Float32Array MLPs, and O(1) spatial hash grids.

#Three.js #WebGL #Neural Networks #Artificial Life #Performance
Simulating emergent artificial life inside a web browser presents a punishing performance threshold: forty autonomous organisms swimming through a physical 1m³ fluid medium, each processing multidirectional optical sensations, computing continuous neural inference, and maneuvering across dynamic boundaries at a locked sixty frames per second. Monolithic engines like Unity WebGL or Unreal Wasm exports buckle under these workloads due to monolithic memory allocations and deep object graphs. In Wild Oasis, we rejected heavy game engines entirely, engineering a bare-metal Three.js architecture founded upon zero-allocation typed array neural networks, biologically inspired concentric foveated ray-casting, and uniform 3D spatial partitioning.

1. The Simulation Core: Autonomous Artificial Life Without Heavy Game Engines

When developers approach 3D multi-agent simulations, the default temptation is to adopt complete commercial game engines. While Unity and Unreal provide out-of-the-box physics and shader pipelines, their browser exports suffer from three systemic flaws: multi-megabyte runtime binaries that destroy cold-start times, unmanaged garbage collectors that conflict with the browser's JavaScript engine, and opaque scene graphs that prevent low-level cache optimizations.

For Wild Oasis, our architectural objective was pure mechanical transparency. The entire simulation runs within native Three.js on top of WebGL, executing directly within the browser's main event thread without external dependencies or heavy WASM shims. To maintain architectural purity across thousands of runtime cycles, every simulated entity obeys a strict decoupled lifecycle contract:

Entities never maintain direct cross-domain references. The SimulationWorld orchestrates entity registration, manages the clock delta, and routes events through flat integer buffers, guaranteeing that discarding an entity leaves zero dangling references or lingering event listeners in memory.

Below is a full real-time screen capture of the Wild Oasis experience, demonstrating forty autonomous organisms actively swimming within the 1m³ fluid volume, reacting to nutrient distribution, and testing boundaries at 60 FPS:

Real-time screen recording of the Wild Oasis simulation in action, featuring forty neural organisms navigating the 3D fluid volume at 60 FPS in Three.js.Watch on YouTube ↗
typescript
// Decoupled Entity Lifecycle Contract
export interface EntityLifecycle {
  readonly id: number;
  readonly type: 'organism' | 'nutrient' | 'obstacle';
  
  // Mandatory deterministic update loop
  update(dt: number, world: SimulationWorld): void;
  
  // Clean disposal without memory leaks or lingering GPU geometries
  dispose(): void;
}

export class SimulationWorld {
  public readonly organisms: Organism[] = [];
  public readonly nutrients: Nutrient[] = [];
  public readonly spatialGrid: SpatialGrid;
  public readonly bounds = new THREE.Box3(
    new THREE.Vector3(-0.5, -0.5, -0.5),
    new THREE.Vector3(0.5, 0.5, 0.5)
  );

  constructor(gridResolution = 10) {
    this.spatialGrid = new SpatialGrid(this.bounds, gridResolution);
  }

  public step(dt: number): void {
    // 1. Rebuild spatial partitioning grid
    this.spatialGrid.clear();
    for (let i = 0; i < this.nutrients.length; i++) {
      this.spatialGrid.insert(this.nutrients[i]);
    }
    for (let i = 0; i < this.organisms.length; i++) {
      this.spatialGrid.insert(this.organisms[i]);
    }

    // 2. Deterministic entity state updates
    for (let i = 0; i < this.organisms.length; i++) {
      this.organisms[i].update(dt, this);
    }
  }
}
Architectural Rule Zero cross-domain leaks: No organism retains a direct reference to another organism's internal neural brain or Three.js Mesh. Inter-entity interactions occur strictly through world-space queries mediated by the spatial grid.

2. The Physical Environment: Aquatic Drag and 3D Boundary Reflections

The physical world of Wild Oasis consists of a 1m³ glass water cube spanning [-0.5, 0.5] along all three Cartesian axes. In an aquatic environment, linear friction is an inadequate approximation. Real fluids exhibit quadratic drag, where resistant force scales with the square of the body's velocity relative to the fluid:

$\mathbf{F}_d = -\frac{1}{2} \rho C_d A \|\mathbf{v}\| \mathbf{v}$

where $\rho$ is water density, $C_d$ is the organism's hydrodynamic drag coefficient, and $A$ is frontal surface area. In code, we combine these physical parameters into an aggregated drag coefficient $c_d = 2.45$. At low speeds, the organism glides smoothly through water; as motor thrust increases, quadratic resistance surges exponentially, creating a realistic natural terminal velocity and requiring intelligent momentum conservation.

When an organism collides with the transparent glass boundaries of the cube, we compute an elastic boundary reflection with normal restitution damping ($e = 0.65$) and tangential friction ($f_t = 0.85$). An immediate collision penalty is registered against the creature's sensory system to penalize wall crashes in the genetic fitness function.

typescript
// Hydrodynamic drag & boundary collision integration
public integratePhysics(dt: number): void {
  const speed = this.velocity.length();
  
  if (speed > 0.0001) {
    // Quadratic fluid resistance: F_d = -c * |v| * v
    const dragMagnitude = 0.5 * DRAG_COEFFICIENT * speed * speed;
    const dragFactor = Math.max(0, 1 - (dragMagnitude * dt) / this.mass);
    this.velocity.multiplyScalar(dragFactor);
  }

  // Integrate translation
  this.position.addScaledVector(this.velocity, dt);

  // 3D Glass Boundary Reflection [-0.5, 0.5]
  const min = -0.48, max = 0.48;
  const RESTITUTION = 0.65;

  if (this.position.x < min) {
    this.position.x = min;
    this.velocity.x = -this.velocity.x * RESTITUTION;
    this.hasCollidedWithWall = true;
  } else if (this.position.x > max) {
    this.position.x = max;
    this.velocity.x = -this.velocity.x * RESTITUTION;
    this.hasCollidedWithWall = true;
  }

  if (this.position.y < min) {
    this.position.y = min;
    this.velocity.y = -this.velocity.y * RESTITUTION;
    this.hasCollidedWithWall = true;
  } else if (this.position.y > max) {
    this.position.y = max;
    this.velocity.y = -this.velocity.y * RESTITUTION;
    this.hasCollidedWithWall = true;
  }

  if (this.position.z < min) {
    this.position.z = min;
    this.velocity.z = -this.velocity.z * RESTITUTION;
    this.hasCollidedWithWall = true;
  } else if (this.position.z > max) {
    this.position.z = max;
    this.velocity.z = -this.velocity.z * RESTITUTION;
    this.hasCollidedWithWall = true;
  }
}

3. Simulating Biological Eyes: Foveated Ray Sampling and Retinal Compression

In a real biological organism, optical perception is not uniform. The human eye concentrates roughly 50% of its visual cortex capacity on the fovea centralis—a microscopic 1.5mm region covering less than two degrees of visual angle—while peripheral vision drops precipitously in angular resolution.

Simulating full optical ray tracing across 40 organisms casting hundreds of linear rays per frame would require over 150,000 spatial intersections every 16.6 milliseconds, instantly crippling the WebGL thread. In `Retina.js`, we modeled a biologically authentic foveated sensory array: each eye casts 64 sample rays arranged across four concentric rings with non-linear angular spacing:

The center fovea casts a single razor-sharp forward ray. The inner ring (8 rays at 4° spread) handles fine tracking; the middle ring (16 rays at 12° spread) tracks oncoming obstacles; and the outer ring (39 rays out to 35° spread) provides low-frequency peripheral alerts.

Each ray marches through the spatial grid, classifying intersections into four discrete channels: `[0: Empty, 1: Food, 2: Other Organisms, 3: Glass Walls]`, attenuated by an inverse-square distance drop-off $A(d) = \frac{1}{1 + 3.5 d^2}$.

Rather than dumping 256 raw floats into the neural network, an artificial Lateral Geniculate Nucleus (LGN) pooling layer compresses the 64 rays into a compact 9-element sensory vector: `[left_food, center_food, right_food, left_danger, center_danger, right_danger, forward_wall_dist, current_velocity, energy_level]`. This 28x data reduction eliminates combinatorial explosion in the brain's input layer while preserving essential directional gradients.

Figure 1: Close-up 3D tracking of an aquatic organism in the 1m³ glass cube, projecting its 64-ray foveated vision cone through the fluid medium as it encounters nutrient particles.
typescript
// Retina.js: Non-linear concentric foveated ray-casting
export class Retina {
  private static readonly RINGS = [
    { count: 1,  spreadAngle: 0.00 }, // Fovea centralis (0 deg)
    { count: 8,  spreadAngle: 0.07 }, // Parafovea (~4 deg)
    { count: 16, spreadAngle: 0.21 }, // Macular periphery (~12 deg)
    { count: 39, spreadAngle: 0.61 }, // Far periphery (~35 deg)
  ];

  // Pre-allocated static direction buffers (Zero-allocation)
  private readonly rayDirections: THREE.Vector3[] = [];
  public readonly featureVector = new Float32Array(9);

  constructor() {
    // Generate canonical non-linear concentric ray offsets
    for (const ring of Retina.RINGS) {
      for (let i = 0; i < ring.count; i++) {
        const theta = ring.count > 1 ? (i / ring.count) * Math.PI * 2 : 0;
        const dir = new THREE.Vector3(
          Math.sin(ring.spreadAngle) * Math.cos(theta),
          Math.sin(ring.spreadAngle) * Math.sin(theta),
          Math.cos(ring.spreadAngle)
        ).normalize();
        this.rayDirections.push(dir);
      }
    }
  }

  public scan(origin: THREE.Vector3, orientation: THREE.Quaternion, world: SimulationWorld): Float32Array {
    this.featureVector.fill(0);

    for (let r = 0; r < this.rayDirections.length; r++) {
      // Transform canonical ray direction to world space
      const worldDir = _v1.copy(this.rayDirections[r]).applyQuaternion(orientation);
      const hit = world.spatialGrid.castRay(origin, worldDir, 0.45);

      if (hit) {
        const attenuation = 1.0 / (1.0 + 3.5 * hit.distance * hit.distance);
        const lateralX = this.rayDirections[r].x;

        if (hit.type === 'food') {
          if (lateralX < -0.05) this.featureVector[0] += attenuation;      // Left Food
          else if (lateralX > 0.05) this.featureVector[2] += attenuation;  // Right Food
          else this.featureVector[1] += attenuation * 1.5;                 // Center Fovea Food
        } else if (hit.type === 'organism' || hit.type === 'wall') {
          if (lateralX < -0.05) this.featureVector[3] += attenuation;      // Left Danger
          else if (lateralX > 0.05) this.featureVector[5] += attenuation;  // Right Danger
          else this.featureVector[4] += attenuation * 1.5;                 // Center Danger
        }
      }
    }

    return this.featureVector;
  }
}

4. Zero-Allocation Neural Nets: Flat Float32Array Memory Layouts and GC-Free Inference

In high-frequency simulations, the primary enemy of consistent 60 FPS performance in the browser is the V8 Garbage Collector. When an organism's brain creates temporary weight arrays, intermediate activation slices, or matrix multiplication objects inside its `think()` loop, it generates thousands of discarded allocations per second. Once V8's young-generation memory limit is exceeded, a full scavenge or major mark-and-sweep GC cycle triggers, halting execution for 10 to 30 milliseconds and causing jarring frame stutters.

In Wild Oasis, we eliminated garbage collection entirely by constructing `Brain.js` on top of a single contiguous `Float32Array` per organism. For our multi-layer perceptron topology (9 inputs → 12 hidden units → 4 motor outputs: thrust, pitch, yaw, roll), the exact memory budget is mathematically determined at instantiation:

• Layer 1 Weights: 9 × 12 = 108 floats • Layer 1 Biases: 12 floats • Layer 2 Weights: 12 × 4 = 48 floats • Layer 2 Biases: 4 floats • Total Genome: 172 Float32 values (exactly 688 bytes)

During runtime inference, the forward pass writes directly into pre-allocated static typed array views. There are zero array instantiations, zero object destructuring calls, and zero pointer re-assignments during active simulation ticks. To maintain strict numerical stability without floating-point blowups, activations are computed using a guarded hyperbolic tangent function with input clamping between -15.0 and 15.0.

typescript
// Brain.js: Cache-friendly zero-allocation flat MLP
export class Brain {
  public static readonly INPUT_SIZE = 9;
  public static readonly HIDDEN_SIZE = 12;
  public static readonly OUTPUT_SIZE = 4;
  
  // Total parameters: (9*12) + 12 + (12*4) + 4 = 172 floats (688 bytes)
  public static readonly TOTAL_WEIGHTS = 172;

  public readonly genome: Float32Array;
  
  // Static pre-allocated activation buffers shared per organism
  private readonly hiddenActivations = new Float32Array(Brain.HIDDEN_SIZE);
  public readonly outputActivations = new Float32Array(Brain.OUTPUT_SIZE);

  constructor(existingGenome?: Float32Array) {
    this.genome = new Float32Array(Brain.TOTAL_WEIGHTS);
    if (existingGenome) {
      this.genome.set(existingGenome);
    } else {
      this.randomize();
    }
  }

  // Zero-allocation forward pass: 688-byte sequential memory traversal
  public think(inputs: Float32Array): Float32Array {
    let offset = 0;

    // Layer 1: Inputs (9) -> Hidden (12)
    for (let h = 0; h < Brain.HIDDEN_SIZE; h++) {
      let sum = this.genome[offset + (Brain.INPUT_SIZE * Brain.HIDDEN_SIZE) + h]; // Bias
      for (let i = 0; i < Brain.INPUT_SIZE; i++) {
        sum += inputs[i] * this.genome[offset + (h * Brain.INPUT_SIZE) + i];
      }
      // Guarded tanh activation
      const clamped = sum < -15.0 ? -15.0 : sum > 15.0 ? 15.0 : sum;
      this.hiddenActivations[h] = Math.tanh(clamped);
    }
    offset += (Brain.INPUT_SIZE * Brain.HIDDEN_SIZE) + Brain.HIDDEN_SIZE;

    // Layer 2: Hidden (12) -> Outputs (4) [Thrust, Pitch, Yaw, Roll]
    for (let o = 0; o < Brain.OUTPUT_SIZE; o++) {
      let sum = this.genome[offset + (Brain.HIDDEN_SIZE * Brain.OUTPUT_SIZE) + o]; // Bias
      for (let h = 0; h < Brain.HIDDEN_SIZE; h++) {
        sum += this.hiddenActivations[h] * this.genome[offset + (o * Brain.HIDDEN_SIZE) + h];
      }
      const clamped = sum < -15.0 ? -15.0 : sum > 15.0 ? 15.0 : sum;
      this.outputActivations[o] = Math.tanh(clamped);
    }

    return this.outputActivations;
  }

  // Clone genome with zero allocation
  public copyFrom(other: Brain): void {
    this.genome.set(other.genome);
  }

  // Gaussian point mutation
  public mutate(rate = 0.05, magnitude = 0.25): void {
    for (let i = 0; i < Brain.TOTAL_WEIGHTS; i++) {
      if (Math.random() < rate) {
        // Box-Muller Gaussian noise
        const u1 = Math.random() || 1e-6;
        const u2 = Math.random();
        const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
        this.genome[i] = Math.max(-2.0, Math.min(2.0, this.genome[i] + z0 * magnitude));
      }
    }
  }
}
Cache Efficiency Because all 172 weights and biases reside within a single flat Float32Array, the entire neural genome occupies only 688 bytes. This fits comfortably within a single modern L1 CPU data cache (typically 32KB to 48KB), allowing the hardware prefetcher to stream synapses with near-zero memory latency.

5. Spatial Grid Acceleration: Transforming O(N²) Neighbor Searches into O(1) Lookups

In an ecosystem containing 40 swimming organisms and 200 dynamic food pellets, a naive collision and sight check performs $240 \times 240 = 57,600$ pairwise distance evaluations every frame. At 60 FPS, that equates to 3.45 million Euclidean distance calculations per second—wasting over 80% of the entire frame budget on geometric redundancy.

To solve this, `SpatialGrid.js` partitions the 1m³ glass cube into a 3D uniform spatial hash grid of $10 \times 10 \times 10 = 1,000$ discrete cubic cells ($0.10\text{m}$ edge length). World coordinates are mapped to a 1D cell index using fast bitwise and integer operations:

$\text{cellIndex} = \lfloor(x + 0.5) \cdot 10\rfloor + \lfloor(y + 0.5) \cdot 10\rfloor \cdot 10 + \lfloor(z + 0.5) \cdot 10\rfloor \cdot 100$

When an organism casts a foveated ray or tests for nutrient consumption, it queries only the immediate cell and its 26 adjacent Moore neighbors. Pairwise distance checks plunge from 57,600 down to fewer than 180 per frame, a 320x performance improvement that executes in under 0.4 milliseconds.

typescript
// SpatialGrid.js: 3D Uniform Hash Grid for O(1) neighbor lookups
export class SpatialGrid {
  private readonly resolution: number;
  private readonly cellSize: number;
  private readonly invCellSize: number;
  private readonly buckets: number[][]; // Reusable entity ID buckets

  constructor(bounds: THREE.Box3, resolution = 10) {
    this.resolution = resolution;
    this.cellSize = 1.0 / resolution;
    this.invCellSize = resolution; // Multiplication is faster than division
    
    // Allocate 1,000 fixed cell buckets
    const totalCells = resolution * resolution * resolution;
    this.buckets = new Array(totalCells);
    for (let i = 0; i < totalCells; i++) {
      this.buckets[i] = [];
    }
  }

  public clear(): void {
    for (let i = 0; i < this.buckets.length; i++) {
      this.buckets[i].length = 0; // Clear without reallocating arrays
    }
  }

  public getCellIndex(x: number, y: number, z: number): number {
    const cx = Math.floor((x + 0.5) * this.invCellSize);
    const cy = Math.floor((y + 0.5) * this.invCellSize);
    const cz = Math.floor((z + 0.5) * this.invCellSize);

    if (cx < 0 || cx >= this.resolution ||
        cy < 0 || cy >= this.resolution ||
        cz < 0 || cz >= this.resolution) {
      return -1;
    }

    return cx + (cy * this.resolution) + (cz * this.resolution * this.resolution);
  }

  public insert(entity: { id: number; position: THREE.Vector3 }): void {
    const idx = this.getCellIndex(entity.position.x, entity.position.y, entity.position.z);
    if (idx !== -1) {
      this.buckets[idx].push(entity.id);
    }
  }
}

6. Generational Selection & Mutation: The Mechanics of Genetic Drift

Evolution in Wild Oasis is governed by generational neuroevolution. Rather than applying costly gradient backpropagation, the population undergoes Darwinian selection based on a holistic multi-objective fitness metric:

$\text{Fitness} = w_{\text{food}} \cdot N_{\text{eaten}} + w_{\text{life}} \cdot t_{\text{alive}} - w_{\text{wall}} \cdot C_{\text{wall}} + w_{\text{track}} \cdot \int_{0}^{T} \cos(\theta_{\text{food}}) \, dt$

Organisms receive substantial reward points for ingesting food pellets ($w_{\text{food}} = 100$) and maintaining steady forward velocity through fluid drag ($w_{\text{track}} = 20$), while incurring steep point deductions for colliding with the glass boundaries ($w_{\text{wall}} = 15$).

When the generational epoch completes, an elitist preservation mechanism retains the top 10% highest-scoring organisms. Their genomes are copied identically into the successor generation without mutation, guaranteeing that successful phenotypic adaptations are never lost to stochastic drift.

The remaining 90% of slots are populated via tournament selection and subjected to Gaussian point mutation, adding normal distribution noise $\mathcal{N}(0, \sigma)$ with dynamic temperature decay across successive epochs.

Within 12 to 15 generations, clear evolutionary strategies emerge organically: organisms transition from aimless kinetic convulsions into elegant boustrophedon sweeping patterns, banking sharply away from cube boundaries and executing tight pirouettes whenever their foveated vision cone strikes a cluster of nutrient spheres.

Figure 2: Real-time simulation diagnostic HUD showing the active 1m³ glass cube, live BrainView MLP synaptic activations (top-left), and dual concentric RetinaView foveated sensor arrays (bottom-left).
typescript
// Evolution.js: Elitist preservation and Gaussian point mutation
export class EvolutionManager {
  private readonly populationSize: number;
  private readonly eliteCount: number;

  constructor(populationSize = 40, eliteRatio = 0.10) {
    this.populationSize = populationSize;
    this.eliteCount = Math.max(1, Math.floor(populationSize * eliteRatio));
  }

  public evolveGeneration(organisms: Organism[]): void {
    // 1. Sort organisms descending by cumulative fitness score
    organisms.sort((a, b) => b.fitness - a.fitness);

    // 2. Clone elite genomes into temporary buffer
    const eliteGenomes: Float32Array[] = [];
    for (let i = 0; i < this.eliteCount; i++) {
      const clone = new Float32Array(organisms[i].brain.genome);
      eliteGenomes.push(clone);
    }

    // 3. Preserve elites intact in the next generation
    for (let i = 0; i < this.eliteCount; i++) {
      organisms[i].brain.genome.set(eliteGenomes[i]);
      organisms[i].resetForNewGeneration();
    }

    // 4. Fill remaining population via tournament selection & point mutation
    for (let i = this.eliteCount; i < this.populationSize; i++) {
      const parent = this.selectTournament(organisms, 4);
      organisms[i].brain.copyFrom(parent.brain);
      
      // Dynamic mutation: 5% mutation rate with +/- 0.25 Gaussian delta
      organisms[i].brain.mutate(0.05, 0.25);
      organisms[i].resetForNewGeneration();
    }
  }

  private selectTournament(candidates: Organism[], k = 4): Organism {
    let best = candidates[Math.floor(Math.random() * candidates.length)];
    for (let i = 1; i < k; i++) {
      const contestant = candidates[Math.floor(Math.random() * candidates.length)];
      if (contestant.fitness > best.fitness) {
        best = contestant;
      }
    }
    return best;
  }
}

7. Architectural Summary: Principles of Scaling In-Browser Biological Simulations

Building complex artificial life simulations in the browser requires treating JavaScript with the discipline of an embedded systems engineer. By rejecting monolithic game engine exports and designing directly against the hardware constraints of the V8 engine and WebGL pipeline, Wild Oasis sustains a rock-solid 60 FPS across desktop and mobile browsers.

Four core architectural axioms define the success of this system:

1. Ban Heap Allocations in Hot Loops: Pre-allocate all typed arrays, Three.js vectors, and spatial buckets during initialization. If your frame loop triggers garbage collection, your frame budget is already compromised.

2. Align Neural Synapses in Flat Contiguous Buffers: Packing multi-layer perceptron weights into a single 688-byte Float32Array guarantees L1 CPU cache locality and zero GC pressure.

3. Exploit Biologically Inspired Sensory Compression: Concentric non-linear foveated ray sampling reduces optical ray counts by 80% while retaining high-acuity center tracking.

4. Decouple Spatial Proximity from Entity Count: A 3D uniform spatial grid converts catastrophic $O(N^2)$ collision checks into localized $O(1)$ cell lookups, allowing hundreds of entities to coexist in real time.

The browser is no longer a platform restricted to trivial 2D interactions. With disciplined memory layouts and first-principles algorithmic design, rich artificial ecosystems and high-performance neuroevolutionary worlds can thrive natively on the open web.

Performance Checklist for WebGL Simulations Audit your application with Chrome DevTools Performance tab. Ensure memory allocations form a completely flat horizontal line with zero saw-tooth GC reclamation spikes during active 60 FPS animation.