Back to Devoured - June 15, 2026
DEVOURED
Sketching the Impossible: a 3D Portfolio Built Without a Single 3D Model

Sketching the Impossible: a 3D Portfolio Built Without a Single 3D Model

Design Codrops
Tomasz Szmajda built an award-winning 3D portfolio by wrapping flat geometry in hand-drawn textures rather than using traditional 3D models.
What: Tomasz Szmajda used React Three Fiber, Three.js, and GSAP to create a navigable 3D environment without Blender. The site uses custom GLSL shaders for brush-stroke effects and a chunking system to manage performance, opting for WebP textures over KTX2 for compatibility.
Why it matters: This shows how developers can bypass complex 3D modeling workflows by treating web graphics as a series of flat, textured planes managed via code, effectively using the GPU for visual polish rather than geometry.
Deep dive
  • Use React Three Fiber and Three.js to render flat geometry as 3D space to avoid complex asset pipelines.
  • Implement a 'chunking' system to mount and unmount scene segments dynamically based on camera position.
  • Use onBeforeCompile to inject custom logic into standard Three.js materials for effects like paint-reveal.
  • Force GPU shader compilation during a preloader phase using gl.compileAsync to prevent interaction lag.
  • Avoid unnecessary lighting calculations; use baked-in shadows in textures to significantly boost frame rates.
  • Use GSAP Observer for unified scroll handling across touch, mouse, and keyboard input.
  • Implement automatic quality tiering based on hardware, RAM, and browser capability.
Decoder
  • GSAP: The GreenSock Animation Platform, a standard JavaScript library for complex web animations.
  • GLSL: OpenGL Shading Language, a C-like language used to write custom rendering logic directly on the GPU.
  • KTX2: A container format for textures designed to optimize memory usage and GPU decompression in WebGL applications.
  • Three.js: A cross-browser JavaScript library used to create and display 3D computer graphics on a web page.
  • React Three Fiber: A React renderer for Three.js that allows developers to manage 3D objects as standard React components.
Original article

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.

Why This Exists

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.

Four Months, From Sketch to Sky

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.

The Tech Stack

  • React 19 + React Three Fiber 9 + Three.js 0.182 for the 3D environment
  • GSAP 3.14 for all animations: camera flights, door mechanics, reveal transitions
  • Vite 7 for builds and dev server
  • Custom GLSL shaders extending MeshBasicMaterial for the paint-reveal effect
  • WebP textures generated via AI (Google’s image generation), compressed and trimmed to fit flat 3D geometry
  • PostHog for analytics, Lenis heritage in scroll philosophy

The Aesthetic: Why Everything Looks Hand-Drawn

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 PaintRevealMaterial Shader

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 Infinite Corridor

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 Chunking System

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 Camera System: 500 Lines of Pain

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:

  • Scroll movement via GSAP Observer (unifies mouse wheel, touch, and trackpad)
  • Mouse parallax on desktop – the camera sways gently as you move the mouse
  • Gyroscope parallax on mobile – tilt your phone, the corridor shifts
  • Auto-glance – as you approach a door, the camera subtly turns toward it
  • Keyboard navigation – arrow keys, spacebar, Page Up/Down for accessibility
  • Camera override mode – when GSAP takes over for door entry/exit animations

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.

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 Rooms: Abstraction Over Predictability

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:

  • The Gallery: Project cards hanging on an infinite clothesline, like laundry drying in the wind. Scroll sideways to browse. Infinite loop.
  • The Studio: Monitors floating in space, scrolling vertically through my content – videos, blog posts, social media. Infinite in both directions.
  • The About: You fly a paper airplane through an infinite sky filled with clouds and story milestones. Your biography as a flight path.
  • The Contact: A beach by the sea. Social media links are floating barrels you click to connect.

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 Performance Crisis (or: Two Suns, One Moon, and Zero Visible Shadows)

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.

The Shader Pre-Compilation Trick

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.

The KTX2 Experiment That Failed

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. I reverted to WebP.

Adaptive Device Tiering

The site detects device capabilities at load time and adjusts accordingly. 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.

Sound Design: The Detail That Changes Everything

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.

The Achievement System: Gamification as UX

Inspired by Bruno Simon’s portfolio, I added an achievement system that doubles as a tutorial. When you enter a new room, a tooltip appears. 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.

Recognition

The portfolio picked up some awards along the way:

  • GSAP Site of the Day + added to the official GSAP Showcase
  • FWA of the Day
  • CSSDA Special Kudos + 3 Public Choice Awards
  • Orpetron SOTD
  • CSS Winner SOTD
  • Awwwards Honorable Mention

What I Would Do Differently

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 This Project Taught Me

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. That’s how I know I’m growing.

What Comes Next

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.

One Piece of Advice

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.

Explore all 71 articles in today's digest

Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi