Interactive 3D web experience · 2026
Portfolio Gallery
This site. A first-person walkable gallery built with Three.js, procedural audio, and zero frameworks.

Portfolio Gallery
A first-person 3D portfolio site built entirely in the browser with Three.js. No frameworks, no templates — just vanilla JavaScript, WebGL, and Web Audio.
Built with: Three.js | Vite | Vanilla JS (ES Modules) | GLSL Shaders | Web Audio API | HTML/CSS Overlays
Overview
Instead of a flat page with project cards, this portfolio drops visitors into a walkable gallery space. A concrete corridor with warm amber floor lighting, per-artwork colored spotlights, framed work on the walls, a frosted mirror at the far end, a clickable vintage radio on a plinth, a working light switch, and procedural footstep audio that echoes off the floor. Every project is a physical object you walk up to, examine, and click into.
The entire experience runs client-side with zero backend, zero dependencies beyond Three.js and Vite, and zero external audio files. Footsteps, reverb, UI sounds, and ambient audio are all synthesized in the browser.
The Problem
Traditional portfolio sites are functionally identical: hero section, project grid, footer. They communicate work through screenshots and bullet points, but nothing about the site itself demonstrates spatial thinking, audio design, real-time rendering, or systems architecture — the exact skills the portfolio is supposed to showcase.
The challenge: build a portfolio that is the portfolio piece. A site where the craft of building it is inseparable from the work it presents.
Tech Stack
| Layer | Technology | Why |
|---|---|---|
| Rendering | Three.js (WebGL) | Full control over scene graph, lighting, materials, and post-processing |
| Build | Vite | Fast HMR for iterative 3D development, zero-config ES module bundling |
| Language | Vanilla JS (ES Modules) | No framework overhead, direct DOM manipulation, full control of render loop |
| Shaders | GLSL (via ShaderPass) | Custom film grain post-processing effect |
| Audio | Web Audio API | Procedural footstep synthesis, convolver reverb, positional 3D radio audio, UI sound design |
| UI | HTML/CSS Overlays | All panels, prompts, and menus are DOM elements layered over the WebGL canvas |
Architecture
The codebase is organized as a set of self-contained ES modules, each owning its own state, DOM, and Three.js objects:
src/
main.js Entry — scene, camera, renderer, render loop
scene/
Gallery.js Room geometry + procedural textures
Artwork.js Frame + canvas + spotlight + fixture + trigger volume (per piece)
Lighting.js Hemisphere + ambient + ceiling fixture point lights
Furniture.js Bench, plinth, collision boxes
Mirror.js Far-wall Reflector with frosted reflection + player silhouette
Radio.js Vintage tube radio with positional 3D audio
controls/
FirstPerson.js Pointer lock + WASD + raycaster collision + head bob + jump
Mobile.js Split-touch: left joystick, right look, tap interact
audio/
Footsteps.js Fully procedural step synthesis + convolver reverb
PanelSwoosh.js Procedural filtered-noise swoosh for UI transitions
effects/
PostProcessing.js EffectComposer: GLSL film grain, output
AudioReactiveLEDs.js Bass-driven floor LED pulse (AnalyserNode + fast/slow followers)
ui/
LoadingScreen.js Progress bar + enter prompt + fade transition
InfoPanel.js Proximity-triggered artwork metadata (hysteresis)
DetailPanel.js Raycast aim-to-click + slide-in panel with hero/tech/links
CaseStudy.js Full-screen overlay, fetches + renders project markdown
LightSwitch.js Interactive wall switch with smooth light lerp
Tutorial.js Onboarding — desktop text hints / mobile zone overlay
PromptIdle.js Shared idle-fade for interaction prompts
config/
artworks.js All project data: positions, rotations, images, tech, links
utils/
analytics.js Thin GA4 event tracking wrapper
helpers.js Procedural texture generators, resize, math utilities
markdown.js Minimal Markdown-to-HTML renderer (headings, tables, lists, code)
Every module follows the same factory pattern: export a create* function that builds its Three.js objects or DOM, adds them to the scene, and returns an update function consumed by the render loop. No globals, no shared mutable state beyond the scene graph itself.
Key Features
Gallery Space
The room is a 6m wide by 20m deep rectangular corridor with 4m ceilings. Walls, floor, and ceiling are flat PlaneGeometry with MeshStandardMaterial and procedurally generated texture maps (normal, roughness, color) that simulate poured concrete. No image files are loaded for room surfaces.
Five artworks are displayed — four framed screenshots on the side walls and a frosted mirror on the far wall representing this site. Each piece is a Group containing a box-geometry frame, a plane-geometry canvas with loaded texture (or Reflector for the mirror), a ceiling-mounted SpotLight tinted to the piece’s dominant color palette, a physical light fixture mesh (mount plate, stem, ball joint, housing, color-matched emissive disc), and an invisible trigger volume for proximity detection.
Warm amber LED accent strips run along the base of every wall — subtle wayfinding in the dark, architectural detail in the light. When the radio plays, the strips pulse gently with the bass — a fast/slow follower pair isolates rhythmic transients from the frequency data so the effect tracks the beat rather than settling to a flat level. Each artwork canvas carries a faint emissive backlight using its own texture as an emissive map, giving screenshots the soft self-illumination of a digital screen.
The Mirror
The far wall holds a 5m-wide frosted mirror built with Three.js’s Reflector addon at 512x512 resolution — low enough to produce a natural soft blur rather than a sharp reflection. A dark silhouette figure (cylinder body, sphere head) tracks the camera position and is toggled visible only during the Reflector’s internal render pass via an onBeforeRender hook, so it appears in the reflection but never in the player’s direct view. The reflection layer grew more complex than initially planned — the onBeforeRender chain accommodates more than just the player’s own silhouette, though some things are best discovered rather than documented.
First-Person Controls
Desktop uses the Pointer Lock API for mouse-look with WASD movement. Collision detection raycasts independently on X and Z axes against all wall meshes, enabling wall-sliding instead of dead stops. Head bob is sinusoidal, amplitude-modulated by walking speed, with the bob cycle driving footstep timing. Entry begins with a 1.5-second ease-out dolly that eases the visitor into the space rather than snapping them to a standing position.
Mobile uses a split-touch system: the left 35% of the screen spawns a virtual joystick for movement, the right 65% handles drag-to-look with tap-to-interact detection. A hamburger menu button replaces the ESC key. Both input paths feed into the same FirstPerson.js update loop through shared setters. Mobile onboarding uses a full-screen zone overlay — the left and right touch regions are labeled with inline SVG icons showing their function, dismissed with a single tap.
Procedural Audio
Every sound in the gallery is synthesized — no audio files ship with the build.
Footsteps use bandpass-filtered noise bursts at 650Hz and 850Hz (left/right foot), with exponential decay envelopes. A synthetic stereo impulse response feeds a ConvolverNode for gallery reverb. Steps trigger on zero-crossings of the head-bob sine wave, so audio and visual rhythm are perfectly synced. Playback rate is randomized per step for natural variation.
A vintage tube radio on a plinth plays positional 3D audio through THREE.PositionalAudio with distance-based rolloff. The radio mesh is built from box, cylinder, and sphere geometries with emissive materials that glow when playing. Its antenna wobbles gently via a sine-driven rotation. When music plays, an AnalyserNode taps the radio’s gain node and feeds bass frequency data into the floor LED accent strips, which pulse with the rhythm.
UI transitions have their own sound design — the detail panel plays a procedural filtered-noise swoosh on open and close, with a bandpass frequency sweep (high-to-low on open, low-to-high on close) generated in real time via the Web Audio API and routed through the listener for automatic mute support.
Post-Processing
The render pipeline chains three passes through EffectComposer:
- RenderPass — base scene
- Custom GLSL FilmGrainShader — animated noise overlay at 0.3% intensity, using a time-seeded
fract(sin(dot(...)))pseudo-random function - OutputPass — tone mapping and color space conversion
Bloom and vignette passes are implemented but disabled — during development they drew too much attention to the ceiling light fixtures and washed out the carefully tuned wall lighting. The subtle film grain alone gives the scene the analog texture it needs.
Lighting
Each artwork has a SpotLight tinted to its dominant screenshot palette — sage-mint for PokéSynth, warm amber for Huuman Beats, soft violet for PokéSound, rose pink for PokéTunes, cool silver for the mirror. The fixture’s emissive disc matches its spotlight color, so each piece casts a distinct pool of light on the wall below it. Shadows use PCFSoftShadowMap at 512x512 per spotlight.
The light switch on the left wall toggles the gallery between warm-lit and near-dark states. Toggling smoothly lerps hemisphere light, ambient light, ceiling fixture point lights, emissive intensities, and light colors over ~0.3 seconds. The floor LED strips stay on regardless — they serve as always-visible wayfinding in the dark.
Interactive Objects
The light switch has a physical plate, toggle nub, and an invisible forgiving hitbox for raycasting. The radio click-target detects interaction through the same raycast system. Interaction prompts (“View Details”, “Toggle Lights”, “Play Music”) fade to low opacity after 1.5 seconds of continuous visibility to reduce visual noise — a shared PromptIdle utility tracks per-element visibility duration via a WeakMap.
UI Layer System
All UI is HTML/CSS overlaid on the WebGL canvas — no 3D text rendering. Each module creates and owns its DOM elements:
- Info Panel — appears when within 2.5m of an artwork, hides beyond 3.0m (hysteresis prevents flicker at the boundary)
- Detail Panel — slide-in right panel with hero image, description, tech stack pills, external links, and a “Read Case Study” button. Opens on raycast click against artwork meshes.
- Case Study Overlay — full-screen overlay that fetches project markdown files and renders them through a custom Markdown-to-HTML parser supporting headings, bold/italic, links, code blocks, tables, and lists
- ESC Menu — doubles as identity/contact hub with name, subtitle, email/LinkedIn/GitHub links, sound toggle, and control hints rendered as inline SVGs
The ESC navigation stack is carefully sequenced: Case Study (ESC) closes to Detail Panel (ESC) closes to Resume Prompt (click) returns to gameplay (ESC) opens Pause Menu. This works around the browser’s constraint that requestPointerLock() cannot be called from an ESC keydown event.
Performance Approach
- Zero render-loop allocations. All
Vector3,Raycaster,Euler, andQuaternionobjects are pre-allocated at module scope and reused every frame. - Pixel ratio capped at 2 (
Math.min(devicePixelRatio, 2)) to prevent 4x oversampling on high-DPI screens. - Flat geometry only. The room is axis-aligned planes and boxes — no subdivisions, no curves, no imported meshes.
- Shadow maps are 512x512 per spotlight, with
PCFSoftShadowMapfor quality without excessive fill cost. - Procedural textures are generated once at module load, avoiding async texture fetches for room surfaces.
Challenges and Solutions
Pointer Lock and ESC Key Conflict
Problem: Browsers treat the ESC key as a “user wants to exit pointer lock” signal. Chrome silently blocks requestPointerLock() calls originating from ESC keydown handlers. This broke the expected flow of pressing ESC to close a panel and immediately re-entering gameplay.
Solution: Introduced a “Click to resume” prompt that appears after closing the detail panel via ESC. The user clicks the canvas to re-enter pointer lock, satisfying the browser’s user-gesture requirement. Fast clickers never see the prompt — it fades in after a 300ms delay.
Wall Collision Edge Cases
Problem: Per-axis raycaster collision can miss wall corners when approaching diagonally, potentially letting the player clip through geometry.
Solution: Invisible BoxGeometry colliders are placed at wall junctions where needed, giving the raycaster solid objects to hit regardless of approach angle.
Reflections and Render Order
Problem: The far-wall mirror needs to show a player silhouette that doesn’t exist in the direct view. Three.js’s Reflector renders the scene from a mirrored camera — any object visible in the main scene would appear in both views.
Solution: The silhouette mesh starts hidden. An onBeforeRender hook on the Reflector toggles the silhouette visible just before the reflection render pass and immediately hides it afterward. This hook chain is extensible — the mirror’s reflection can contain elements that only exist in the reflected world, visible only when you’re standing in front of it.
Web Audio Autoplay Policy
Problem: Browsers block AudioContext creation until a user gesture. The footstep system and positional radio audio would silently fail if initialized before the user interacts.
Solution: Audio infrastructure is created at module load but no sound plays until after the “Click to Enter” button — which is itself the gesture that satisfies the autoplay policy. The radio additionally calls context.resume() on first click, handling the edge case where the context was created but suspended.
Mobile Touch Input
Problem: Mobile devices have no pointer lock, no mouse, and no keyboard. The desktop control system is fundamentally incompatible.
Solution: Mobile.js is a peer module to FirstPerson.js, not a wrapper. It provides its own touch event handling (split-touch zones, virtual joystick, tap detection) and feeds movement/look deltas into FirstPerson.js through shared setters (setMoveVector, applyLookDelta). The render loop doesn’t know or care which input source is active — it just reads the same velocity and quaternion.
Outcome
The site serves as both portfolio and portfolio piece — a self-contained demonstration of 3D rendering, spatial audio, interaction design, and systems architecture, presented through the format itself rather than described alongside it. Walk slowly. Keep the sound on. And if you find yourself at the far end of the gallery, facing the mirror — pay attention to what it shows you.
Gallery
Gallery coming soon
More visuals from this project are on the way.