I wanted my hero banner to feel alive — not just a shader looping in the background, but something that actually reacts to where you are on the page. Here's how I built a cursor-tracking portrait using Gemini image generation, spring physics, and a WebGL canvas — and everything that broke along the way.
The hero banner on my portfolio has gone through a lot of iterations. For a while it was a shader from Unicorn Studio — beautiful, atmospheric, but passive. It didn't know I was there. I wanted something that felt more like a presence: a background that actually responds to where you move your cursor.
The idea was straightforward. Instead of a looping animation, I wanted a portrait of myself that tracked the cursor — so wherever you moved on the page, the character's gaze followed you. Like one of those optical illusion paintings that seems to always be looking at you, except interactive and built on real footage.
What seemed like a clean concept turned into a surprisingly involved engineering problem. This is the full story of how it got built, what broke, and how it ended up working.
Step 1 — Generating the head rotation sequence with Gemini
The whole thing starts with a single reference photo of me in Times Square. I had no motion capture setup, no studio, no rotating rig. Just one image.
What I did was use Gemini's image generation capabilities to synthesize what I would look like at different head rotation angles — every 6 degrees around the full 360-degree circle. The output was a set of 60 individual images, each showing the same scene and the same person, but with the head and gaze shifted to a different direction.
Getting these right took iteration. Gemini is good at maintaining consistency across variations, but lighting, posture, and subtle perspective shifts had to be guided carefully. The goal was a sequence where you could shuffle through the images and see a smooth, convincing rotation — not a set of 60 slightly different people.
Step 2 — Turning images into a video
Once I had all the reference images, I used Gemini again — this time for video generation — to produce a smooth animated video from those reference frames. The images served as the motion blueprint; the video model interpolated between them to produce fluid movement at a consistent frame rate.
The resulting video was a full 360-degree head rotation, smooth enough to extract as a sprite sequence. I pulled it into the project as heroVideo.mp4 so you can see the full original clip below.
The hardest frame to generate
Most of the rotation sequence came together with reasonable effort. But there was one position that took a disproportionate amount of time and failed more times than I can count: getting the character to look to the right.
In the original reference photo, I'm looking to the left. That's just where my gaze happened to land in the shot. For most angles — down, up, slightly left, upper-left — Gemini handled the generation without much friction. But the moment I asked for the opposite direction, the model consistently fell apart.
I tried every framing I could think of. Describing the target gaze in absolute terms: "looking to the right side of the frame." Describing it relative to the starting image: "the character is currently looking left, generate a version where they are looking right." Describing the eye direction, the head turn, the body angle. Specifying which way the nose should point. Nothing worked reliably.
The model seemed to have a hard time decoupling the gaze direction from the rest of the composition. Sometimes it would mirror the entire image. Sometimes it would shift the background lighting, change the framing, or produce a version where the head turned but the eyes kept pointing the original direction. Occasionally it produced something technically correct, but the likeness was noticeably off — different proportions, different face, just a different person in a similar place.
Eventually, after a significant number of attempts, it produced a right-facing version. It wasn't a perfect likeness. The face was close but not quite me. I used it as reference anyway — the animation runs at 58 frames and moves fast enough that any single frame is on screen for only a fraction of a second. Perfection in one frame matters a lot less than consistency across all of them, and the overall motion reads correctly.
It was a good reminder that AI image generation in 2026, for all its capability, still has real blind spots around spatial reasoning. Describing orientation — especially relative to an existing image — is surprisingly hard to communicate to a model that doesn't think about left and right the way a human does.
I tried absolute terms, relative terms, describing the nose direction, the eye angle, which shoulder should face forward. The model understood every individual word and consistently produced the wrong result.
— Roy Villasana
Step 3 — Extracting 58 frames and mapping them to cursor angles
To drive the animation from cursor position, I extracted individual frames from the video at 6 frames per second — one frame every 6 degrees of rotation — giving me a set of JPEG images numbered frame_001.jpg through frame_058.jpg.
The core mapping logic is simple: convert the cursor's angle relative to the center of the canvas into a frame index. If the cursor is directly to the right, show the frame where I'm looking right. If it's below, show the looking-down frame. Full circle.
function angleToFrame(dx, dy) {
const deg = Math.atan2(dy, dx) * (180 / Math.PI);
return ((Math.round(deg / 6) + OFFSET + TOTAL * 100) % TOTAL);
}The OFFSET constant (14) aligns frame 15 with cursor-right at 0°, confirmed by visual inspection of the frames. From there the whole circle maps cleanly — frame 30 is cursor-down, frame 45 is cursor-left.
Step 4 — Spring physics for natural motion
Jumping directly to the correct frame based on cursor angle looked mechanical and jarring. What makes cursor-tracking feel natural is lag — the head follows the cursor with some inertia, accelerating toward it and decelerating as it arrives. That's a spring.
The spring simulation runs every animation frame:
vel = vel * DAMPING + diff * SPRING;
pos = (pos + vel + TOTAL * 10) % TOTAL;Where diff is the shortest circular path from the current frame position to the target frame. The circularDiff function handles the wraparound so the spring never tries to rotate "the long way around" the circle.
This gave the animation a much more natural feel — the gaze eases in and eases out as you move the cursor, which is exactly what you'd see from a real person tracking something.
What broke — and how we fixed it
The first version looked great except for one noticeable glitch: whenever the cursor moved from the top of the image toward the right, the character's head would briefly snap backward before continuing forward. On video it was obvious and jarring.
The root cause was two separate problems layered on top of each other.
Problem 1 — Two frames had the wrong gaze direction
Inspecting the frames around the loop boundary revealed the issue. The rotation should end by gradually looking upward, then wrapping back to the start (looking slightly forward). But the last two frames showed the gaze swinging back to the left — not upward — before the loop reset. The sequence near the top was going: UP → UP → LEFT → LEFT → FORWARD, when it should have been UP → UP → FORWARD.
These two frames were artifacts of how the video generation ended. The fix: reduce the total frame count from 60 to 58, dropping the bad frames from the sequence entirely. The cursor-up position now maps to frame 058 (genuinely looking up), and the boundary wraps directly to frame 001 (looking forward).
Problem 2 — Underdamped spring causing overshoot
Even after removing the bad frames, fast cursor movement across the boundary could still cause a brief backward flicker. The spring had accumulated velocity and was overshooting past the target frame, then snapping back — which played the wrong frames in reverse for a few milliseconds.
The mathematical fix: the spring constants we were using are nowhere near critical damping. For true no-overshoot behavior you'd need a SPRING value below 0.003, which would make the animation feel sluggish.
Instead, we added a velocity clamp:
if (diff * vel > 0 && Math.abs(vel) > Math.abs(diff)) {
vel = diff;
}This says: if the velocity is heading toward the target but would carry past it, cap it at exactly the remaining distance. Overshoot becomes structurally impossible. The spring still accelerates naturally when the cursor is far away; it just can't bounce. The result is a smooth, responsive animation with no oscillation anywhere on the circle.
For true no-overshoot behavior you'd need a SPRING value below 0.003 — which would make the animation feel sluggish. The cleaner fix is a velocity clamp: the spring can accelerate freely, but it can never carry past its target.
— Roy Villasana
The rendering layer — WebGL canvas
The portrait runs on a WebGL canvas that handles two things simultaneously: uploading the current JPEG frame as a texture, and applying a fragment shader on top. In the plain version used as a hero background, the shader adds a soft vignette and animated bokeh glow circles around the edges — so the portrait fades naturally into the dark page background rather than sitting as a hard-edged rectangle.
Frame textures upload with texSubImage2D on frame change (not every tick), so the GPU only does work when the displayed frame actually changes. The spring runs every animation frame regardless, but texture upload is gated behind an index comparison.
The object-fit:cover math runs on the CPU at resize time and gets passed as a vec4 uniform to the shader, so the 16:9 video always fills whatever container shape the portrait sits in without distortion.
The result
The final hero banner alternates randomly between two variants on every page load: the original Unicorn Studio shader, or the cursor-tracking portrait with bokeh. Both live behind the same hero text — same layout, different visual energy.
What started as a single photo in Times Square became a 58-frame sprite sequence that follows your cursor across the full 360-degree circle, driven by spring physics, rendered in WebGL, and backed by AI-generated motion that never existed in any original footage.
The whole pipeline — image generation, video synthesis, frame extraction, animation engine, shader layer — took a few days of iteration. Most of that time was in the debugging, not the building. Which is, I think, always true of anything worth shipping.