






















I can’t model 3D. That pretty much explains this entire project.
For months, I had been browsing Awwwards and The FWA and found sites like Igloo Inc, 3D combined with infinite scrolling, and I just thought: I need something like this. Then I saw Bruno Simon’s portfolio with the little car. I knew I wanted a 3D portfolio. I also knew I had zero Blender skills and honestly no interest in faking it with someone else’s models.
So I figured, why not just code simple rectangles, planes, cubes, flat geometry, and wrap them in hand-drawn textures? I couldn’t sculpt a world in Blender, so I sketched one on flat rectangles instead. That workaround accidentally became the whole visual identity of the project.
You can find the source code on GitHub.
Go browse any Facebook group where developers share portfolios. I did. About 90% of them are the same thing: dark background, neon accent colors, text on the left, image on the right. Many of them look like they were generated by AI… they probably were. AI has this very specific aesthetic tendency: black page, neon glow, done.
I don’t have a problem with those sites technically. Their UX is probably better than mine, honestly – it’s hard to get lost on a standard layout. But I wanted something different. I wanted visitors to actually walk through a space, not just scroll down a page about me. If someone sees my portfolio and wants to work with me, they’ll figure out how to reach the Contact room. I’m not worried about that.
So I set out to build a portfolio you can walk through, not scroll through.

The project started in December 2025. Initially, I thought it would be a 2D illustrated website – hand-drawn textures on flat HTML sections. But somewhere in the first few weeks, I realized flat HTML wasn’t going to cut it. This thing needed actual 3D depth. So I moved the whole thing into Three.js and React Three Fiber, and suddenly I was building rooms.
Four months later, it was live. Four months of fighting with camera systems and scroll mechanics I had no idea how to build. Also generating a ton of textures with AI, because there was no way I was drawing all of them by hand.






This was never Plan B. From day one, I wanted it to feel like a sketch – like you opened someone’s notebook and the drawings jumped into 3D. The paper texture backgrounds, the ink-line doors, the doodles floating in the corridor. All intentional.
What evolved later was the color. I had all these sketch-style textures, and one day I thought: what if they painted themselves when you hover? What if hovering over something literally paints it with color?
That became the main interaction of the whole portfolio. Every clickable element starts as a black-and-white sketch and fills with color on hover – a brush-stroke reveal driven by a custom shader. It’s basically a visual hint – if something fills with color when you hover, it means you can click it and something will happen.
The effect works by extending Three.js’s MeshBasicMaterial through onBeforeCompile, injecting custom fragment shader logic that blends between a sketch texture and a painted texture using procedural noise:
// Brush-stroke blend: progressively swap sketch -> painted
if (uProgress > 0.001) {
vec4 paintedColor = texture2D(uMapPainted, vMapUv);
float rn = paintNoise(vMapUv * 15.0) * 0.15;
// Reveal from bottom-left to top-right for organic feel
float maskValue = (1.0 - vMapUv.y) + rn;
float threshold = uProgress * 1.5;
if (maskValue < threshold) {
diffuseColor = vec4(paintedColor.rgb, 1.0);
}
}
The noise function gives it messy, organic edges – so instead of a clean wipe, you get something that actually looks like paint bleeding on paper. uProgress is animated from 0 to 1 by GSAP on hover.
I went with extending MeshBasicMaterial rather than writing a shader from scratch because I needed the standard Three.js texture pipeline (UV mapping, color spaces, transparency) to keep working. The custom logic only decides which pixels show the painted version – everything else stays stock.
Keeping all textures visually consistent was honestly one of the hardest parts. Every texture was AI-generated, and getting AI to generate hundreds of assets in the same hand-drawn style is… painful. Sometimes I just generated 20 versions and picked the one that didn’t look completely different from the rest.

The idea is simple: you enter a building through sketched double doors, and behind them is a corridor that stretches infinitely in both directions. On the walls, at alternating sides, are four doors – each leading to a room with its own world inside.
The corridor is built from repeating segments, each 80 units long, managed by InfiniteCorridorManager. Only three segments are ever mounted: the one the camera is in, plus one ahead and one behind. As you scroll, segments spawn and despawn dynamically.
On top of that, each segment is wrapped in a SegmentVisibilityWrapper that uses useFrame to check whether the segment is actually in view. If you’ve scrolled 5 units past a segment, it hides entirely – zero draw calls for geometry the camera isn’t even looking at:
useFrame(() => {
const isBehindCamera = camera.position.z < endZ - 5;
const isFarAhead = camera.position.z > startZ + 30;
const isVisible = !(isBehindCamera || isFarAhead);
if (groupRef.current.visible !== isVisible) {
groupRef.current.visible = isVisible;
}
});
The entrance posed a tricky challenge. When the user first arrives, they see the entrance doors – but behind those doors is the infinite corridor. I needed segment -1 (the one behind the entrance) to exist for shader pre-compilation, but I couldn’t let it be visible or its doors would clip through the entrance doors. The solution: a hideDoorsForSegments array that suppresses specific segment doors during the entrance phase, then reveals them once the user has entered.
useInfiniteCamera.js is 500 lines long, and honestly almost every one of those lines exists because of a bug I had to fix.
The camera does several things at the same time:
The auto-glance system is worth talking about. For each door, the hook calculates proximity using a start/peak/end distance model with eased strength:
for (const door of DOOR_POSITIONS) {
const doorGlobalZ = zOffset + door.z;
const dist = z - doorGlobalZ;
let strength = 0;
if (dist > PEAK_DIST && dist < START_DIST) {
strength = (START_DIST - dist) / (START_DIST - PEAK_DIST);
} else if (dist <= PEAK_DIST && dist > END_DIST) {
strength = (dist - END_DIST) / (PEAK_DIST - END_DIST);
}
if (strength > 0) {
const easedStrength = strength * (2 - strength); // easeOutQuad
const dir = door.side === 'left' ? -1 : 1;
// ...
}
}
The result is a small head-turn that makes it feel like the camera notices the doors on its own. It’s subtle, but it makes the corridor feel way less static.
The hardest part of the entire project, no contest, was the camera system for entering and exiting rooms. When you click a door, the camera needs to:
Every single one of those steps had bugs. The camera would snap instead of moving smoothly. It would clip through wall geometry. It would end up facing the wrong direction after exiting. The rotation math had to account for the door’s angle (sawtooth walls are angled, not straight), the camera’s parallax state, and GSAP’s control handoff.
The DoorSection.jsx component – the one managing all of this – grew to 1,287 lines. I’m not proud of the size, but every line handles a real edge case.
One trick I am proud of: after releasing camera control back to the scroll system, the hook calculates the camera’s current physical rotation and derives an equivalent glance offset, instead of snapping to the “ideal” glance for that position. This prevents a jarring snap when exiting a room near a door:
const currentRotationY = camera.rotation.y;
const parallaxContribution = parallax.current.x * 0.3;
const derivedGlance = (currentRotationY - parallaxContribution) / 3;
const diff = Math.abs(derivedGlance - initialGlance);
if (diff > 0.02) {
glanceOffset.current = derivedGlance; // Start from where we ARE
targetGlance.current = initialGlance; // Smooth toward ideal
}
The original plan was normal rooms. A room with a desk. A room with shelves. You know – rooms. But that felt boring and predictable. If someone walks down a corridor in a building and opens a door, they expect a room. They don’t expect to suddenly be flying a paper airplane through clouds.
So every room became its own little world:




Almost every room has infinity built into it. In the Gallery, the cards loop. In the Studio, the monitors loop. In About, the sky never ends, just like the corridor itself. Only Contact breaks the pattern – and I think that’s actually better. Contact is the destination. It should feel like you’ve arrived somewhere.
The About room had a particularly nasty technical challenge. When you fly through clouds and then exit back to the corridor, the clouds would leak into the corridor view. The problem: the invisible clipping wall behind the camera also clipped things inside the corridor, because the camera enters the room at an angle.
The fix was two things: curving the clipping boundary so it matched the angled entry path, and extending the fly-in distance for the About room specifically – you travel further into About than into any other room, giving the clipping wall more clearance.
When I first shared a preview link in Facebook developer groups, the feedback was clear: your site is beautiful, but it runs like a slideshow.
I spent days trying to figure out what was killing performance. I was looking at texture sizes, draw calls, shader complexity – everything. Then I found it.
Two directional lights and an ambient light were casting real-time shadows across the entire scene. These were standard Three.js lights I had added early in development for “proper” lighting. The problem? My scene is made entirely of flat textured planes – the shadow maps were computing complex depth passes for geometry that visually showed no shadow difference at all.
I removed every light. Then I added a slight tint directly to the textures in code – a tiny bit of simulated shadow baked into the color values. Visually? Almost no change. Performance-wise? Night and day.

Another piece of community feedback: “Why do I wait for the preloader, and then wait AGAIN when I click a door?”
The answer was shader compilation. Three.js compiles shaders lazily – the first time a material renders, the GPU compiles its shader program, causing a visible stutter. Every room had dozens of materials, and they all compiled on first entry.
The solution was RoomWarmup: a component that mounts all four rooms 500 units below the scene during the preloader phase, forces the GPU to compile every shader via gl.compileAsync(), then unmounts everything:
const RoomWarmup = ({ onWarmupComplete, isLowTier }) => {
// Mount all rooms 500 units below the scene
// Wait 3 frames for everything to render
// Then force-compile all shaders
if (gl.compileAsync) {
gl.compileAsync(scene, camera, scene)
.then(finishWarmup)
.catch(() => {
gl.compile(scene, camera); // sync fallback
finishWarmup();
});
}
};
// In the JSX:
<group position={[0, -500, 0]}>
<GalleryRoom showRoom={true} isWarmup={true} />
<StudioRoom showRoom={true} isWarmup={true} />
<AboutRoom showRoom={true} isWarmup={true} />
<ContactRoom showRoom={true} isWarmup={true} />
</group>
On high-end devices, this eliminated all entry stutter – every room opens instantly because the GPU already knows every shader. On low-end devices, the warmup is skipped entirely (to prevent WebGL context loss from memory pressure), and rooms load on-demand with a slight delay.
I came up with this approach myself after reading the community complaints. No tutorial taught me this – it came from frustration and a lot of console.log debugging.
I read everywhere that KTX2 / Basis Universal textures are the gold standard for WebGL performance – GPU-native decompression, smaller payloads, the works. So I converted everything.
It was a disaster. Textures misaligned. Colors shifted. The preloader got two seconds slower. And the visual quality on the hand-drawn textures – which depend on crisp lines and subtle gradients – degraded noticeably.
I reverted to WebP. The site already ran at 60 FPS on phones and 144 FPS on desktop with WebP textures. Sometimes the “correct” optimization just isn’t worth it if the current solution already works. I know 3D developers might judge me for this, but I’m speaking from the actual user experience perspective – and it’s smooth.
The site detects device capabilities at load time and adjusts accordingly:
const isMobileDevice = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const isWeakCPU = navigator.hardwareConcurrency <= 4;
const isLowRAM = navigator.deviceMemory <= 4;
const isSmallScreen = window.innerWidth < 450;
const isLowEnd = isMobileDevice || isWeakCPU || isLowRAM || isSmallScreen;
Low-end devices get fewer preloaded textures, no room warmup, and simpler rendering. On top of that, a PerformanceMonitor from drei watches FPS in real-time and automatically downgrades the quality tier if frames start dropping.
On my main PC (3 monitors, 144Hz), the site holds a steady 144 FPS. On phones, it maintains around 60 FPS. On my 80 euro Thinkpad – honestly, it’s a miracle it runs at all – it manages 15-30 FPS. But it runs.
I noticed that the sites I admired most – Bruno Simon, Igloo Inc – all had sound. Sound is what makes a 3D site feel like a place instead of just a page with graphics.
Every room has its own ambient soundscape:
Door interactions have three distinct sounds: a creak when you hover (the door tilts slightly), a full open sound when you click, and a close sound when it shuts behind you.
Finding the right sounds was genuinely frustrating. Everything had to be free for commercial use, match the aesthetic tonally, and feel cohesive across all rooms. I’m still not 100% happy with every sound, but having some sound, even imperfect, is so much better than total silence.
Inspired by Bruno Simon’s portfolio (where you can knock over bowling pins and earn achievements), I added an achievement system that doubles as a tutorial.
When you enter a new room, a tooltip appears: “Scroll to fly through my story” in About, “Drag to rotate and browse” in Studio. These aren’t just instructions though – they’re achievements waiting to be unlocked. Complete the action, and the tooltip transforms into a completed badge with a chime.
const ACHIEVEMENTS = {
corridor_enter: { label: 'Click a door to enter', title: 'Explorer' },
corridor_explore: { label: 'Scroll to explore the corridor', title: 'Wanderer' },
about_fly: { label: 'Scroll to fly through my story', title: 'Sky Walker' },
studio_interact: { label: 'Drag to rotate and browse', title: 'Director' },
gallery_inspect: { label: 'Click project to inspect', title: 'Art Critic' },
contact_choose: { label: 'Find a contact method', title: 'Sociable' }
};
This solves a real problem: in a non-standard interface, people don’t know what to do. The achievements teach interaction patterns while rewarding exploration. Progress persists in localStorage, and every unlock fires a PostHog analytics event so I can track which rooms get the most engagement.
The portfolio picked up some awards along the way:
Honestly? Almost nothing in terms of approach. The “mechanics first” philosophy – build the scroll, the camera, the corridor logic before touching a single texture – saved me from the trap of polishing visuals on top of broken foundations.
What I would skip: those two directional lights and the moon shader that cost me days of debugging for zero visual benefit. And the KTX2 experiment. Sometimes knowing what NOT to do is the real optimization.
That I’m still a beginner.
This is maybe my first project that achieved real recognition – the awards, the community response, the chance to write this article. My previous project, a site for the Polish rapper Young Multi, got attention when I first built it. But when I look at it now, I see everything I would do differently.
That’s how I know I’m growing.
This portfolio isn’t perfect either. The UX isn’t as clean as a standard layout – I know that. But people seem to enjoy exploring it, which means something went right.
My biggest goal right now: building a website for the Polish hip-hop collective 2115 and submitting it to the Webby Awards and Lovie Awards. It’s a high bar. But when you’re getting awards like FWA of the Day at this age, and getting invited to write for Codrops – I mean, there’s really no such thing as impossible.
First: write the screenplay. Before you touch a single line of code, imagine the film. What does the user see? Where does the camera go? What’s the story?
Then build the mechanics with simple shapes. Rectangles. Cubes. No textures, no shaders – just the raw movement and flow. Make it feel right when it looks like nothing.
Only then do you dress the world.
Tech is never the blocker – it’s always your imagination. If you can think of a solution, you can code it. It just takes time and stubbornness.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。