3D Gallery · 2026
Portfolio Gallery
The better half of this site. A gallery you actually walk through, one room per project.

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
Most portfolios are a page you scroll. This one is a place you walk into. It drops you at the mouth of a concrete corridor: warm amber light spilling along the floor, a colored spotlight over each piece, framed work on the walls, a frosted mirror at the far end, a vintage radio on a plinth you can actually switch on, a light switch that actually works, and footsteps that echo as you move. Every project is a physical thing. You walk up to it, look at it, click into it.
It all runs in your browser with nothing behind it. No backend, no dependencies past Three.js and Vite, and not a single audio file. The footsteps, the reverb, the clicks, the radio: all of it is generated on the fly while you play.
The Problem
Every portfolio site looks the same from ten feet away. Hero, grid of cards, footer. They talk about the work through screenshots and bullet points, but the site itself proves nothing. If the pitch is spatial thinking, audio design, real-time rendering, and systems work, a flat page of thumbnails demonstrates none of it.
So the brief I set myself was simple and slightly unreasonable: build a portfolio that is one of the pieces. Make the craft of the site inseparable from the work it’s showing off.
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 work, zero-config ES module bundling |
| Language | Vanilla JS (ES Modules) | No framework overhead, direct DOM access, full control of the 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 | Panels, prompts, and menus are DOM elements layered over the WebGL canvas |
Architecture
The code is a set of small, self-contained ES modules. Each one owns its own state, its own DOM, and its own Three.js objects, and nothing reaches across to poke at anything else:
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 shape: a create* function builds its Three.js objects or DOM, adds them to the scene, and hands back an update function for the render loop to call. No globals, no shared mutable state past the scene graph itself. When something breaks, you know exactly which file to open.
Key Features
The Gallery Space
The room is a corridor: 6m wide, 20m deep, 4m ceilings. Walls, floor, and ceiling are flat planes with a standard PBR material, and the concrete look comes from texture maps (color, normal, roughness) generated in code at load time. Not one image file is loaded for the room surfaces.
Five pieces hang inside it. Four are framed screenshots on the side walls, and the fifth is the frosted mirror at the far end that stands in for this site. Each piece is a little assembly: a boxy frame, a canvas showing the work (or a Reflector, for the mirror), a ceiling spotlight tinted to that piece’s own palette, a real light fixture modeled above it with a color-matched glowing disc, and an invisible volume that notices when you get close.
Warm amber LED strips run along the base of every wall. In the dark they’re quiet wayfinding; in the light they read as trim. Switch on the radio and they start to breathe with the bass. A pair of fast and slow followers pulls the rhythmic hits out of the frequency data so the strips track the beat instead of just hovering at a flat glow. Each screenshot also gives off a faint backlight, using its own image as an emissive map, so the work has the soft self-lit feel of a real screen.
The Mirror
The far wall holds a 5m frosted mirror, built with Three.js’s Reflector addon at 512x512. That resolution is deliberately low, so the reflection comes back as a soft blur rather than a hard mirror image. A dark figure (cylinder body, sphere head) follows your camera around, but it’s only switched on during the mirror’s own render pass, through an onBeforeRender hook. So it shows up in the reflection and never in your direct view. That hook grew into something bigger than “draw the player.” It can hold things that only live in the reflected world, and some of them are better found than explained.
First-Person Controls
On desktop it’s Pointer Lock for mouse-look and WASD to move. Collision raycasts the X and Z axes separately against every wall, so instead of slamming to a stop when you graze a wall, you slide along it. The head bob is a sine wave scaled by how fast you’re walking, and that same wave drives the footstep timing. When you first enter, a 1.5-second ease-out dolly glides you into the room rather than snapping you upright in place.
On mobile it’s a split-touch setup. The left 35% of the screen is a virtual joystick, the right 65% is drag-to-look with tap-to-interact, and a hamburger button stands in for the ESC key. Both paths pour into the same FirstPerson.js loop through shared setters, so the rest of the code never has to care which one is driving. First-time mobile players get a full-screen overlay that labels the two touch zones with inline SVG icons, and one tap clears it.
Procedural Audio
Every sound in here is synthesized. No audio files ship with the build.
Footsteps are bandpass-filtered bursts of noise at 650Hz and 850Hz for the left and right foot, each with an exponential decay. A synthetic stereo impulse response runs through a ConvolverNode for the room’s reverb. Steps fire on the zero-crossings of the head-bob sine wave, so what you hear lines up exactly with what you see, and the playback rate is nudged per step so no two footfalls sound identical.
The radio on the plinth plays positional 3D audio through THREE.PositionalAudio, so it gets louder as you approach and fades as you leave. The radio itself is modeled from boxes, cylinders, and spheres, with emissive materials that glow when it’s on and an antenna that wobbles on a slow sine. While it plays, an AnalyserNode taps its gain node and feeds the bass into those floor LED strips.
The UI has its own sound design too. Open or close the detail panel and you get a procedural noise swoosh with a bandpass sweep (high to low on open, low to high on close), built in real time and routed through the listener so the global mute catches it automatically.
Post-Processing
The render pipeline runs three passes through EffectComposer:
- RenderPass: the base scene
- Custom GLSL film grain: animated noise at 0.3% intensity, using a time-seeded
fract(sin(dot(...)))for the randomness - OutputPass: tone mapping and color space conversion
There are bloom and vignette passes in the code too, but they’re switched off. In testing they kept pulling the eye up to the ceiling fixtures and washing out the wall lighting I’d spent ages tuning. The grain on its own gives the scene the bit of analog grit it wanted, and nothing else.
Lighting
Every piece gets a spotlight tinted to its own screenshot: sage-mint for PokeSynth, warm amber for Huuman Beats, soft violet for PokeSound, rose pink for PokeTunes, cool silver for the mirror. The glowing disc in each fixture matches its spotlight, so every piece drops its own distinct pool of color on the wall beneath it. Shadows are PCFSoftShadowMap at 512x512 per spotlight.
The switch on the left wall flips the whole room between warm-lit and near-dark. Toggling it smoothly lerps the hemisphere light, ambient light, ceiling fixtures, emissive strengths, and light colors over about 0.3 seconds. The floor LED strips stay lit either way, so you’re never fully in the dark and always have a line to follow.
Interactive Objects
The light switch has a plate, a toggle nub, and a generous invisible hitbox so the raycast forgives sloppy aim. The radio’s click target rides the same raycast system. Prompts like “View Details,” “Toggle Lights,” and “Play Music” fade down to low opacity after 1.5 seconds on screen so they don’t clutter the view, with a shared PromptIdle helper tracking how long each one has been visible through a WeakMap.
UI Layer
All the interface is HTML and CSS sitting on top of the WebGL canvas. There’s no 3D text anywhere. Each module builds and owns its own elements:
- Info Panel: appears within 2.5m of a piece and hides past 3.0m, with the gap between those two numbers stopping it from flickering right at the edge
- Detail Panel: a slide-in panel on the right with the hero image, description, tech pills, external links, and a “Read Case Study” button. It opens when your raycast clicks an artwork.
- Case Study Overlay: a full-screen view that fetches a project’s markdown file and renders it through a small custom parser handling headings, bold and italic, links, code blocks, tables, and lists
- ESC Menu: pulls double duty as an identity and contact hub, with name, subtitle, email, LinkedIn, GitHub, a sound toggle, and control hints drawn as inline SVGs
The ESC stack is sequenced with some care: Case Study closes to Detail Panel, Detail Panel closes to a Resume prompt, a click there drops you back into the game, and ESC from there opens the Pause menu. That dance exists to work around a browser rule: you can’t call requestPointerLock() straight from an ESC keydown.
Performance Approach
- Nothing gets allocated in the render loop. Every
Vector3,Raycaster,Euler, andQuaternionis created once at module scope and reused every frame, so the garbage collector has nothing to do mid-flight. - Pixel ratio is capped at 2 with
Math.min(devicePixelRatio, 2), so a high-DPI screen doesn’t quietly ask the GPU to render four times the pixels. - 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
PCFSoftShadowMap, soft enough to look right without eating fill rate. - Textures are generated once at load, so the room surfaces never wait on a fetch.
Challenges and Solutions
Pointer Lock vs. the ESC Key
Problem: Browsers read ESC as “the user wants out of pointer lock,” and Chrome quietly refuses any requestPointerLock() that comes from an ESC keydown. That killed the obvious flow of pressing ESC to close a panel and dropping straight back into the game.
Solution: After you close the detail panel with ESC, a “Click to resume” prompt appears. Clicking the canvas re-enters pointer lock and gives the browser the fresh gesture it insists on. Fast clickers never even see the prompt, since it only fades in after a 300ms delay.
Walking Through Corners
Problem: Raycasting the X and Z axes separately can miss a wall corner when you approach it at an angle, and a missed corner is a player clipping through the wall.
Solution: Invisible box colliders sit at the wall junctions that need them, giving the raycaster something solid to hit no matter which way you come at it.
Reflections and Render Order
Problem: The mirror needs to show a silhouette that doesn’t exist in the direct view. Three.js’s Reflector renders the scene again from a mirrored camera, so anything in the main scene would show up in both.
Solution: The silhouette starts hidden. An onBeforeRender hook on the Reflector flips it visible just before the reflection pass and hides it again right after. That same hook chain is extensible, which is how the reflection can hold things that only exist in the mirrored world, visible only when you’re standing in front of it.
Web Audio Autoplay
Problem: Browsers won’t let an AudioContext make noise until the user does something first. Set up the footsteps and the radio too early and they’d just fail silently.
Solution: The audio graph gets built at load, but nothing plays until you hit “Click to Enter,” which is itself the gesture the autoplay policy is waiting for. The radio also calls context.resume() on first click, covering the case where the context exists but is still suspended.
Mobile Touch Input
Problem: Phones have no pointer lock, no mouse, and no keyboard. The desktop control scheme simply doesn’t translate.
Solution: Mobile.js is a sibling to FirstPerson.js, not a wrapper around it. It handles its own touch events (split zones, virtual joystick, tap detection) and feeds movement and look deltas back through shared setters like setMoveVector and applyLookDelta. The render loop has no idea which input is live. It just reads the same velocity and quaternion either way.
Outcome
The site ended up being both the portfolio and a piece in it: a self-contained demo of 3D rendering, spatial audio, interaction design, and systems work, shown through the format instead of described next to it. Walk slowly. Keep the sound on. And when you reach the far end and you’re standing in front of the mirror, pay attention to what it shows you.
Gallery
Gallery coming soon
More visuals from this project are on the way.