🌑 Inspiration

There's a moment in Japanese aesthetics called ma (間) — the negative space between things. The pause. The silence. The emptiness that makes the structure around it meaningful.

I wanted to build a portfolio that felt like that.

Most developer portfolios follow a template: hero section, skills list, projects grid, contact form. Clean, safe, forgettable. I wanted mine to feel like entering a world — not reading a résumé. I was inspired by the visual language of Japanese ink-wash painting, atmospheric dark cinema, and the raw technical power hidden inside the browser.

The question I kept asking myself was:

"What happens when you treat the browser's canvas like a game engine — not a document?"

That question became LEGEND.

🔨 How I Built It No Frameworks. Just the Platform.

Every line of LEGEND runs on vanilla HTML, CSS, and JavaScript — zero build tools, zero React, zero bundlers. The entire 3D environment is rendered directly using the WebGL API with raw GLSL shader programs.

The 3D Scene

The hero section is a real-time 3D renderer. The environment — the Japanese sanmon gate, the red moon, the floating maple leaves — is rendered every frame using WebGL. I wrote the vertex shaders and fragment shaders by hand. The maple leaf particles are driven by a physics simulation running on the GPU:

Each leaf follows a modified drag-gravity equation:

𝐹 ⃗ 𝑛 𝑒

𝑡

𝑚 𝑔 ⃗ − 𝑘 𝑑 ⋅ 𝑣 ⃗ + 𝐹 ⃗ 𝑑 𝑟 𝑖 𝑓 𝑡 F net ​

=m g ​

−k d ​

⋅ v + F drift ​

Where ( k_d ) is the aerodynamic drag constant and ( \vec{F}_{drift} ) is a sinusoidal cross-wind term that gives each leaf a natural spiraling fall. The GPU processes thousands of particles in parallel — something that would be impossible on the CPU thread without dropping frames.

The Kunai Cursor

The custom cursor is a Japanese kunai that physically follows mouse movement using spring-damper kinematics. The position ( x(t) ) of the kunai tip is solved each frame as:

𝑥 ¨ + 2 𝜁 𝜔 𝑛 𝑥 ˙ + 𝜔 𝑛 2

𝑥

𝜔 𝑛 2 𝑥 𝑡 𝑎 𝑟 𝑔 𝑒 𝑡 x ¨ +2ζω n ​

x ˙ +ω n 2 ​

x=ω n 2 ​

x target ​

Where ( \zeta ) is the damping ratio (set to ( 0.65 ) for a slightly elastic, snappy feel) and ( \omega_n ) is the natural frequency that controls responsiveness. The kunai rotates to face the direction of motion, calculated from the velocity vector:

𝜃

arctan ⁡ ( 𝑦 ˙ 𝑥 ˙ ) θ=arctan( x ˙ y ˙ ​

​

)

This gives the cursor a physical, weapon-like quality — it lags slightly, snaps back, and always points where it's headed.

The ASCII Dragon (Dragon Realm)

The second page — Dragon Realm — hosts the Pretext Engine, a real-time ASCII physics playground I built from scratch. A dragon made entirely of ASCII characters follows your cursor using a segmented inverse-kinematics chain. Each body segment ( i ) solves its angle relative to the segment ahead of it:

𝜃

𝑖

atan2 ( 𝑝 𝑖 − 1 . 𝑦 − 𝑝 𝑖 . 𝑦 , 𝑝 𝑖 − 1 . 𝑥 − 𝑝 𝑖 . 𝑥 ) θ i ​

=atan2(p i−1 ​

.y−p i ​

.y,p i−1 ​

.x−p i ​

.x)

The fire-breathing effect is a particle emitter that spawns ASCII glyphs (* 彡 ぎ `) at randomized velocities with exponential decay:

𝑠 ( 𝑡

)

𝑠 0 ⋅ 𝑒 − 𝜆 𝑡 s(t)=s 0 ​

⋅e −λt

Where ( \lambda ) controls how fast each fire particle fades, creating a natural flicker.

Persistent Music Player

The 12-track anime music player uses the Web Audio API and localStorage to persist playback state — track index, volume, current timestamp — across page navigations. The player rehydrates audio within ~50ms of page load, creating seamless cross-page continuity with no perceptible interruption.

The Scroll System

Scroll is driven by Lenis, a smooth-scroll library, but critically integrated inside my existing WebGL requestAnimationFrame loop rather than running its own RAF. This means the scroll physics, the 3D scene, the cursor, and all UI animations are synchronized to a single frame clock — eliminating frame-doubling artifacts and jank:

javascript function frame(now) { const dt = Math.min((now - lastTime) / 1000, 0.05); lastTime = now; lenis.raf(now); // scroll kunai.update(dt); // cursor physics scene.render(dt); // WebGL 3D scene ui.animate(dt); // UI transitions requestAnimationFrame(frame); } requestAnimationFrame(frame); 🧠 What I Learned WebGL from first principles — vertex buffers, attribute pointers, uniform locations, texture atlases, draw calls. No Three.js safety net. GLSL shader programming — SDF-based rendering, noise functions, UV distortions, blending modes. Spring physics in UI — damped harmonic oscillators feel dramatically better than CSS easing functions for interactive elements. The browser is a real-time engine — the gap between "web page" and "game" is mainly a mindset gap, not a technical one. Deployment complexity is underrated — Unicode filenames, CDN routing, Vercel static-vs-server detection, og:image caching — the hardest bugs weren't in the code. They were in the infrastructure. ⚔️ Challenges

  1. The Cursor-Scene Synchronization Problem

Running Lenis scroll outside the main RAF caused double-rendering. Every other frame the camera was updated twice — creating a subtle shimmer on the 3D geometry. The fix was counterintuitive: delete the scroll library's own RAF and call it manually inside mine. One clock. One truth.

  1. Unicode Filenames on Vercel's CDN

The music track files had Japanese names like ブルーバード.mp3. Locally they played perfectly. On Vercel's CDN, the URLs were percent-encoded differently by different browsers, causing a roughly 60% failure rate on track loading. I wrote a mass-rename script to convert all filenames to pure ASCII (blue_bird.mp3, idol.mp3, etc.) and updated all references automatically.

  1. The server.js Trap

I had a server.js for local development. When pushed to GitHub, Vercel auto-detected it and spun up a Node.js server deployment — which had no routes and returned 404 on every URL. The fix: delete server.js from Git tracking and write an explicit vercel.json to force static hosting.

  1. The og:image LinkedIn Cache

After adding a custom Open Graph thumbnail, LinkedIn still showed the old generic preview. LinkedIn caches og:image for days. The solution: use the LinkedIn Post Inspector tool (linkedin.com/post-inspector/) to force-bust the cache and re-scrape the page.

🏁 Built With

WebGL · GLSL · Vanilla JavaScript · Web Audio API · CSS Custom Properties · Lenis · GitHub · Vercel

LEGEND was built alone, late at night, across hundreds of hours of iteration — because I believe My portfolio should be the best thing I have ever shipped.

Built With

Share this project:

Updates

Submission history