On this page
The Challenge
PolyFish isn't a static scene. The architecture is designed to support thousands of creatures, with the goal of drawing as many as performance allows. The current demo runs 60 fish with AI brains, 15 dolphins hunting them, 10 manatees grazing kelp, 60 kelp plants with full Verlet physics (13 nodes each), 80 food particles, 1024 pooled VFX particles, caustic shaders, water surface animation, spatial audio, and a cinematic camera system. All of that needs to fit inside a 16ms frame budget.
The core philosophy: never do work the player can't see, and never allocate memory the garbage collector has to clean up. Every optimization in PolyFish falls into one of these two categories.
Object Pooling
Every entity in PolyFish - fish, dolphins, manatees, food, seeds, plants, and VFX particles - comes from an object pool. Nothing is created with new during gameplay. When a fish dies and a new one spawns, we're reusing the same object with reset state.
The pool uses a swap-remove strategy on its active array for O(1) release. When an entity is released, it's swapped with the last active element and the array length is decremented - no splice, no indexOf scan. The pool tracks active items separately from the full capacity, so iterating over living entities never touches dead slots.
Without Pooling
Every spawn calls new Creature(), allocating meshes, materials, typed arrays, and AI state. Every death sends all of that to the garbage collector. GC pauses cause visible frame stutters, especially on mobile.
With Pooling
Creatures are pre-allocated at load time. Spawn resets position, health, and AI state on an existing object. Death marks it inactive. Zero allocation, zero GC pressure, zero stutters.
// O(1) release via swap-remove - no array splice needed
release(entity) {
const idx = entity._poolActiveIndex;
const last = this.active[this.active.length - 1];
this.active[idx] = last;
last._poolActiveIndex = idx;
this.active.length--;
entity._poolActive = false;
}
Pool Sizes and Dynamic Growth
Pool sizes are tuned per platform. Desktop gets generous headroom (up to 2000 slots per type for dynamic growth), while VR uses tighter budgets to stay within the stricter frame requirements of head-mounted displays. During development, the initial pool sizes were regularly exceeded as the simulation grew in complexity - they were a starting point, not a hard limit. When demand exceeded capacity, pools would dynamically expand to accommodate the growing ecosystem.
Spatial Hashing
Without spatial partitioning, checking whether a dolphin is near any fish requires comparing every dolphin against every fish - O(n²) checks per frame. With 85 creatures, that's 7,225 distance calculations. Spatial hashing reduces this to O(nearby) by dividing the world into a grid and only checking entities in adjacent cells.
PolyFish uses a 2D grid on the XZ plane (the ocean floor plane) with a cell size of 5 units. Each cell is indexed by a Cantor pairing hash of the cell coordinates: ((cx * 73856093) ^ (cz * 19349663)) | 0. The hash is rebuilt every 33ms (~30 Hz), matching the fastest AI tick rate.
Two separate hashes run in parallel: one for creature-creature interactions (separation forces, predator detection, flee responses) and one for creature-food queries (foraging, eating). Each query reuses a pre-allocated results array - no arrays are created during lookups.
// Pre-allocated result arrays - zero allocation per query
const _hashResults = []; // creature-creature
const _foodHashResults = []; // creature-food
// Query: find all creatures within radius of position
creatureHash.query(creature.x, creature.z, fleeRadius, _hashResults);
// _hashResults now contains nearby creatures - no allocation occurred
Vertex Swim Animation
Creatures don't use skeletal animation or play recorded loops. Instead, the mesh deformation happens in the vertex shader, driven by a traveling sine wave. This allows smooth, continuous animation that never loops or stutters, and it scales effortlessly to hundreds of creatures without animation state overhead. This procedural approach to creature animation was inspired by techniques shared in the Abzu GDC talk.
The Wave Function
Each vertex on the mesh has a Y-position (or Z-position for bottom-feeders). A sine wave travels along the creature's body from front to back (for fish) or top to bottom (for dolphins/manatees). The wave frequency, amplitude, and phase all vary per-creature instance, created via instanced vertex attributes.
// Vertex shader swim animation (simplified)
attribute float aPhase; // Per-instance phase offset
attribute float aFrequency; // Oscillation rate (Hz)
attribute float aAmplitude; // Wave height
varying float vDistance;
varying float vWave;
void main() {
// Distance along body (0 at head, 1 at tail)
vDistance = position.x; // Assuming fish-like orientation
// Wave phase: depends on time, distance, and per-instance params
float wavePhase = uTime * aFrequency + vDistance + aPhase;
float waveOffset = sin(wavePhase) * aAmplitude;
// Apply side-to-side (X) displacement for fish
vec3 deformedPos = position;
deformedPos.x += waveOffset;
// Eye region gets masked to prevent sliding
float eyeMask = smoothstep(-1.0, 0.2, vDistance);
deformedPos.x = mix(position.x, deformedPos.x, eyeMask);
gl_Position = projectionMatrix * viewMatrix * vec4(deformedPos, 1.0);
}
Species-Specific Patterns
Fish: Side-to-side oscillation (X-axis) at 1.5 Hz. The wave travels from snout to tail, creating the classic "S-curve" swim pattern. The eye region (front 20% of the mesh) is masked so the head barely moves, keeping the eye stable and readable.
Dolphins: Vertical oscillation (Y-axis) at 1.2 Hz. The wave travels from spine to flukes. The entire body flexes up-and-down, which is how real dolphins swim.
Manatees: Vertical oscillation (Y-axis) at 1.0 Hz - slower and more leisurely. The tail creates most of the propulsion while the body stays relatively straight.
GPU Instancing
Without instancing, rendering 60 fish means 60 separate draw calls - each one a full GPU state change (bind vertex buffer, bind material, set uniforms, draw). PolyFish uses THREE.InstancedMesh to render all creatures of a species in a single draw call. The GPU receives one mesh and a list of per-instance transforms, drawing all 60 fish at once.
This drops creature rendering from 85+ draw calls to just 3 (one per species). Plants use the same technique, collapsing 60 individual plant meshes into 1 draw call. Food and seeds add 2 more. The entire scene renders in roughly 5-7 draw calls for all dynamic entities.
Per-Instance Swim Animation
The swim animation can't use a shared uniform because each creature has a different phase and amplitude. Instead, PolyFish stores swim parameters as InstancedBufferAttribute values - per-instance data that the vertex shader reads to compute unique wave displacement for each fish. This means 60 uniquely-animated fish in a single draw call.
Plant Bone Textures
Plants are more complex - each has 13 bones driven by Verlet physics, and every instance has a unique pose. The solution: pack all bone matrices into a DataTexture (52 pixels wide × N instances tall, RGBA32F). The vertex shader reads bone transforms via texelFetch using the instance ID as the texture row. This eliminates per-plant draw calls entirely while preserving unique skeletal animation.
LOD Systems (Computational)
Not everything needs full detail. PolyFish runs a three-tier computational LOD system based on camera distance, updated every 250ms (not every frame) to avoid the cost of continuous distance checks. This is not about reducing polygon count at distance - it's about reducing the frequency and complexity of AI updates, physics calculations, and behavior simulation for distant creatures.
LOD 0 (< 15 units)
Full simulation. Complete AI with separation forces, full Verlet physics with 3 constraint iterations, collision detection active, shadow casting enabled.
LOD 1 (15 - 30 units)
Reduced computation. AI runs but separation is simplified. Verlet physics runs every 2nd frame with 1 constraint iteration. Collision detection disabled. Still visible and behaviorally convincing but with reduced computational cost.
LOD 2 (> 30 units)
Minimal computation. AI skips separation entirely. Plants use procedural sway instead of Verlet. Collision detection skipped. Shadow casting disabled. Visual mesh remains the same - only the simulation complexity is reduced.
The computational savings compound. A distant kelp plant at LOD 2 skips Verlet constraint solving and collision entirely, uses a simple sine-wave sway instead, and doesn't participate in collision tests. That's roughly 80% less simulation work per plant compared to LOD 0, even though the mesh itself is rendered at the same visual quality.
Caustic Shader LOD
The caustic light pattern shader also has a LOD system. Close objects get the full dual-octave Voronoi computation. Distant objects only evaluate the coarse first octave, cutting shader complexity in half. Both LODs fade with distance anyway, so the transition is invisible.
AI Budget & Staggering
Creature AI is the most expensive per-entity cost in the simulation. Each brain tick involves spatial hash queries, food-finding raycasts, separation force calculations, and state machine transitions. Running all 85 creature brains every frame would blow the budget.
The solution is decoupled tick rates combined with stagger groups. AI doesn't run every frame - it runs on a fixed interval per species, and within each species the population is split into 3 stagger groups that take turns.
With 60 fish split across 3 stagger groups, only 20 fish run their AI brain per tick. At 16 Hz tick rate, that's 20 brains processed roughly every 60ms - not 60 brains every 16ms. The motion system (velocity, heading, swim animation) still runs every frame for smooth visuals; only the expensive decision-making is throttled.
Camera Scout Budget
The cinematic camera's Ecosystem Scout - which scans creatures to find interesting subjects - also runs on a frame budget. It evaluates at most 20 creatures per frame in a round-robin sweep, ensuring that even with hundreds of entities, the scout never spikes a frame.
Zero-Allocation Loops
JavaScript's garbage collector is the enemy of smooth frame rates. A single frame that allocates objects, arrays, or strings can trigger a GC pause that drops the frame. PolyFish achieves zero heap allocations per frame during steady-state simulation.
Every typed array, temporary vector, and result buffer is pre-allocated at startup. Verlet chains use Float32Array scratch buffers for impulse propagation. Spatial hash queries write into pre-allocated result arrays. Instancing sync uses shared Matrix4 and Quaternion temps. The VFX particle system uses a custom batched implementation (VFXManager.js) that stores all 1024 particles' positions, velocities, lifetimes, and colors in flat typed arrays, updated in-place every frame. It renders as a single THREE.Points draw call and supports 7 lifecycle event effects with per-particle forces.
// Pre-allocated temps - reused every frame, never re-created
const _instanceMatrix = new THREE.Matrix4();
const _instanceQuat = new THREE.Quaternion();
const _instanceScale = new THREE.Vector3();
const _instanceEuler = new THREE.Euler();
// Verlet impulse scratch buffers - Float32Array, allocated once
this._tempX = new Float32Array(nodeCount);
this._tempY = new Float32Array(nodeCount);
this._tempZ = new Float32Array(nodeCount);
VR Scaling
WebXR adds unique constraints. The browser renders two eye views per frame (effectively doubling fill rate), and dropped frames cause motion sickness, not just visual jank. PolyFish automatically adjusts rendering quality when running in VR.
VR-capable devices are explicitly treated as desktop-class, keeping the full 2048 x 2048 shadow map even on standalone headsets like Quest 3. Mobile phones without VR get the reduced 1024 x 1024 shadow map. The water surface shader conditionally skips wave animation on lower-end GPUs. Rendering tick rates may be adjusted per-species to balance visual smoothness with frame rate stability. All of these decisions are made once at startup based on the active session type, not per-frame.
Desktop Configuration
60 fish, 15 dolphins, 10 manatees, 60 plants. 2048 x 2048 shadow map. Full water shader. ~200 active entities.
VR Configuration
Same creature populations and shadow map as desktop (2048 x 2048). VR headsets are treated as desktop-class to maintain visual fidelity. Rendering quality optimized for headset stability.
The result: a consistent 72-90fps in VR (depending on headset refresh rate) with the same ecosystem simulation running, delivering a visually convincing experience while maintaining frame rate stability.