Devlog · 40 commits · 22 hours

Building an Underwater
Portfolio in Two Days

No templates. No starter kits. Just a Three.js canvas, a folder of coral models, and an idea that wouldn't let go.

Saturday, March 29 — 10:14 PM

The Spark

It started with a question: what if a portfolio had no pages?

No scrolling, no routing, no page transitions. Just a single Three.js canvas where a camera moves through a world. Look up — you see the sky, the portal, the ocean stretching to the horizon. Click “Dive In” and the camera plunges through the water surface into an underwater reef where projects float between corals. Pull back and you're reading an about page on a cliffside.

The first commit dropped at 10:14 PM: a React Three Fiber canvas with a Blender portal archway, Three.js Water for ocean reflections, and a procedural sky that tracked the actual sun position using suncalc. GSAP animated the camera between three states. By midnight, you could stand on the ocean and watch the real sunset. It looked rough. But the bones were there.

7e3b1a0 — feat: immersive 3D portfolio with dynamic sky

Saturday — 11:10 PM

Going Under

An hour later, the underwater world existed. A PBR sand floor with four overlapping sine waves for displacement. Animated caustic light projections painting dappled sunbeams across the sand. Rising bubbles. A fog controller that smoothly blended between sky haze and deep blue atmosphere based on camera Y position.

Then came the fish. Animated GLB models — emperor angelfish, guppies, goldfish, bluegill — following CatmullRom spline pathswith per-fish wobble, obstacle avoidance against every rock and coral, and O(N²) separation logic so they don't swim through each other.

“The first time a school of angelfish swam past the camera while caustics danced on the sand below — that was the moment the concept clicked.”

fa23be4 — feat: PBR sand floor, caustic lighting, bubbles

e2b8aa7 — feat: animated fish schools in underwater scene

Sunday, March 30 — Morning

Everything Broke

I bought a coral model pack. Sixteen species, game-ready, FBX format. Converted them to GLB with Blender. Dropped nine instances into the scene.

The tab crashed.

The models were 6–14MB each. I tried gltf-transform — simplify, resize textures, Draco compression. Draco silently broke because Three.js's useGLTFdoesn't ship a Draco decoder. Meshopt compression also failed. Ended up re-exporting everything through Blender at 512px textures with aggressive mesh simplification. Got each model under 1MB.

Then the corals were invisible. Their original materials rendered as pitch black underwater because the fog and dim lighting swallowed everything. I had to clone each material, inject the diffuse map as an emissive map, and boost intensity.

Then they floated above the ground. The sand floor uses procedural displacement in a vertex shader, and the JavaScript getFloorY()function matches it — but floating-point drift between GPU and CPU meant every coral was either hovering or clipping through sand. Took three attempts to get the math to agree.

Sunday — Afternoon

The Reef Comes Alive

Hand-placing corals was a dead end. They clipped into each other, clustered awkwardly, and left the background barren. So I wrote a procedural placement algorithm:

Seeded PRNGCluster biasingCollision avoidanceZone-based densityWeighted model selection

A mulberry32PRNG generates deterministic positions from seed 42. Seven zones control density: dense side reefs, center scatter, mid-distance patches, far silhouettes. Each candidate is checked against every rock and already-placed coral. 70% of placements cluster around randomly chosen centers — mimicking how real coral colonies grow. The algorithm runs once at module load. Zero runtime cost.

Result: 134 corals from 7 model types, spread from foreground to deep background, no clipping, organic clusters. Add 95 procedurally-deformed rocks, and the seafloor finally looked like a reef.

1a4fa84 — feat: procedural coral reef with bioluminescent night glow

Sunday — Evening

Night Falls

The day/night cycle was always the plan. But making it drive everything meant connecting dozens of systems to a single daylight value between 0 and 1. Sky gradient. Fog color and density. Water hue. Portal glow intensity. Particle brightness. Navbar theme. Text shadows. Audio mix. Coral emissive.

At sunset, corals begin to glow with bioluminescence— six colors (cyan, blue, purple, pink, green, orange) mapped per coral via the emissive channel. A smoothstepramp starts the glow at dusk, not full dark. Bloom post-processing catches the hot pixels and bleeds soft light into the water. No point lights needed — just materials and math.

Underwater god rays are seven crossed-plane quads with a GLSL shader that fades vertically and horizontally, with a slow shimmer animation. They're barely noticeable. Remove them and the scene feels flat. That's good design — you feel it before you see it.

Sunday — Late Afternoon

The Dive

The dive transition was the hardest visual to get right. Camera descends from y=9 to y=-8. A CSS overlay rises from the bottom — animated water surface with canvas-drawn wave paths, erupting bubbles, a light flash at the surface crossing, and Subnautica-style lens droplets that drip down and fade after emerging.

The problem: the sky vanished instantly when cameraState changed. The underwater background sphere mounted at full opacity, snapping a beautiful sunset to dark blue before the water overlay even covered the screen. Fixed by keeping DynamicSky always mounted and fading its lighting influence via a surfaceInfluencefactor tied to camera Y. The sunset now bleeds through the transition — exactly like breaking the surface in a video game.

Audio follows the dive too. A Web Audio API low-pass filter ramps from 22kHz to 400Hz as you submerge, muffling the ambient track. The emerge transition plays a water-breaking sound effect, clipped and faded from a longer audio file using FFmpeg.

3ca0846 — feat: improved dive transition & archway rock formation

f94ce13 — feat: emerge transition — lens droplets, timing, water SFX

Sunday — Evening

Making It Run

134 corals. 95 rocks. 5 fish schools with 27 animated fish. Jellyfish. God rays. Caustics. Bloom. Water reflections rendering the scene twice. All on a single canvas. On mobile.

The naive implementation had 134 separate useFrame hooks— one per coral — each running lerpColors on emissive materials every frame. Batching them into a single animation loop was the biggest win.

LOD rocks (detail 4→2 at distance)Batch coral animationFish throttling (>80 units)Water 1024→512 on mobileAlways-mounted shaders

Rocks use distance-based LOD — near rocks get subdivision level 4, mid get 3, far get 2. Fish schools skip position and separation calculations when more than 80 units from the camera (but keep skeletal animation running so they don't freeze). Water reflections drop to 512×512 on mobile. Vignette post-processing is disabled on touch devices.

The hardest performance bug: shader recompilation. When OceanWater and DynamicSky unmounted during the dive and remounted on rise, their WebGL shaders had to recompile on the GPU — causing a visible stutter. Fixed by keeping them always mounted and toggling visibility instead.

82d20af — perf: LOD rocks, batch coral animation, fish distance throttling

Sunday — Night

The iOS Safari Arc

iOS Safari was the final boss. The Dive button didn't fire. Audio was silent. The scene stuttered during transitions. Every fix revealed two more bugs.

Touch delay: iOS waits 300ms on every tap to distinguish from double-tap zoom. Fixed with touch-action: manipulation globally and switching the Dive button from onClick to onPointerDown— fires instantly on touch.

Silent audio:iOS requires AudioContext to be created during a user gesture. The UI sounds system lazily created it on hover events — which iOS doesn't consider a gesture. Fixed by initializing the AudioContext on the loading screen's first tap and never creating a new one.

Backdrop-filter eating touches: Heavy backdrop-filter: blur(32px) on the Dive button created a stacking context that intercepted touch events on iOS. Reduced to 14px.

The Stack

React Three Fiber
3D scene graph
Three.js Water
Ocean reflections
Custom GLSL
God rays, caustics, distortion
GSAP
Camera transitions
suncalc
Real sun/moon tracking
Web Audio API
Underwater muffling, UI sounds
Next.js 16
App Router, Turbopack
PostHog
Analytics & event tracking

Hidden Secrets

There are 11 secrets hidden throughout this site. Some are visual, some are interactive, and one requires real skill. Try to find them all.

Gave up? Hover for hints

Secret #1hover to reveal
Secret #2hover to reveal
Secret #3hover to reveal
Secret #4hover to reveal
Secret #5hover to reveal
That's all I'm spoiling.
The rest you'll have to find yourself. Good luck.