<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sheikh Abdullah Bin Alam</title>
    <description>The latest articles on DEV Community by Sheikh Abdullah Bin Alam (@sheikh_abdullahbinalam_).</description>
    <link>https://dev.to/sheikh_abdullahbinalam_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4085049%2F21bb7169-b350-462d-b910-3936c0b703ad.jpg</url>
      <title>DEV Community: Sheikh Abdullah Bin Alam</title>
      <link>https://dev.to/sheikh_abdullahbinalam_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sheikh_abdullahbinalam_"/>
    <language>en</language>
    <item>
      <title>I built my portfolio as a night train ride in vanilla Three.js. Here's what actually broke.</title>
      <dc:creator>Sheikh Abdullah Bin Alam</dc:creator>
      <pubDate>Thu, 20 Aug 2026 07:56:19 +0000</pubDate>
      <link>https://dev.to/sheikh_abdullahbinalam_/i-built-my-portfolio-as-a-night-train-ride-in-vanilla-threejs-heres-what-actually-broke-3ol4</link>
      <guid>https://dev.to/sheikh_abdullahbinalam_/i-built-my-portfolio-as-a-night-train-ride-in-vanilla-threejs-heres-what-actually-broke-3ol4</guid>
      <description>&lt;p&gt;My site, &lt;a href="https://iampoonno.com" rel="noopener noreferrer"&gt;iampoonno.com&lt;/a&gt;, doesn't have sections. It has a train.&lt;/p&gt;

&lt;p&gt;You board a night train at a forest platform, ride through a driver's cab, gain speed, and stop at two stations: one for the creative work, one for the eleven years I spent doing commercial and analytics work at Unilever. The two tracks then merge into one heading toward a tree, because that's the actual point of the site: those aren't two different people, they're one person who happened to take two windows into the same thing. I also represented Bangladesh on national basketball teams, so there's a shelf with a jersey on it in the driver's cab, because apparently that needed to be in there too.&lt;/p&gt;

&lt;p&gt;None of that is the interesting part for this audience. The interesting part is what it took to make a single continuous 3D scene feel like a real place instead of a tech demo, on vanilla Three.js 0.160, no framework, esbuild-bundled, served off Cloudflare Pages. Here's the stuff that actually broke, in the order it humbled me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole ride is one scroll value
&lt;/h2&gt;

&lt;p&gt;There's no routing, no page transitions, no scenes that swap in and out. Scroll position maps to a single number &lt;code&gt;t&lt;/code&gt; from 0 to 1, and everything (camera position, camera look-target, fog density, which station is active, whether the train is accelerating) is a function of &lt;code&gt;t&lt;/code&gt;. The camera doesn't snap between keyframes, it's damped toward its target every frame (&lt;code&gt;lerp&lt;/code&gt; factor around &lt;code&gt;dt * 3.5&lt;/code&gt;), so even a jumpy scroll input reads as smooth motion.&lt;/p&gt;

&lt;p&gt;The trick that made this feel intentional rather than like a slideshow: certain bands of &lt;code&gt;t&lt;/code&gt; are HOLD zones, where consecutive keyframes sit at nearly identical camera positions. Scrolling through a HOLD band moves the camera almost nowhere, which is what gives you room to actually look around a station and click things, without needing separate scroll-lock logic. The whole ride runs on one continuous curve; the "stops" are just places where the curve goes flat for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The station board only wakes up when it's actually parked
&lt;/h2&gt;

&lt;p&gt;The stations use a real split-flap departure board, the kind you see in old train stations, built as a 3D object with individually flipping tiles. Early on it looked broken: scroll to a station, and the board sits there dormant with blank flaps, like the power's out.&lt;/p&gt;

&lt;p&gt;The bug wasn't rendering, it was state. The board only wakes up while the camera is genuinely &lt;strong&gt;parked&lt;/strong&gt; at a station: meaning a specific "parked" flag set by the docking logic, not just "the scroll position happens to be near the station's &lt;code&gt;t&lt;/code&gt; value." If you &lt;code&gt;scrollTo&lt;/code&gt; a &lt;code&gt;t&lt;/code&gt; that's technically inside the station's window but the parking function never ran, the board reads that as scrolling past the window, not stopping at it, and stays dormant. Every board QA script I wrote afterward had to explicitly call the park function rather than just jumping to a timestamp, or I'd spend twenty minutes debugging a board that was never actually asleep from the user's perspective, only from the test's.&lt;/p&gt;

&lt;p&gt;There's a second layer on top of that: once the board is parked and flipping, a flick of the scroll wheel shouldn't interrupt it mid-flip, because half-flipped tiles read as glitchy, not railway-authentic. So a park gets read-held: the board ignores the first bit of scroll input after it starts a flip cycle, protected by a renewing timeout rather than a flat one, so a burst of fast scrolling doesn't leave it permanently stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fog is not a slider, it's a layered system
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FogExp2&lt;/code&gt; alone reads as haze, not atmosphere. What actually sells "misty forest at night" is fog that visibly drifts in from the edges of the screen, glows near the lamps, and breathes instead of sitting static. That ended up being three separate systems stacked on top of the base fog: soft sprite planes that slide in from the sides at the platform and stations, glow slots that scale with how many light sources are active in the current story branch (so a station with three practical lamps isn't drawing the same fog-glow budget as one with one), and a camera-proximity ramp that brightens fog near the camera and dims it far away.&lt;/p&gt;

&lt;p&gt;That last one bit me directly: turn the proximity ramp up too far, or let too many lamps run hot at once, and the fog stops reading as mist and starts reading as snow: individual glow points instead of one continuous haze. The fix was capping both the lamp count contributing to glow at any moment and the ramp's max brightness, not chasing it with post-processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "poor Core Web Vitals" score was the loader, not the ride
&lt;/h2&gt;

&lt;p&gt;This is the one I'd actually tell another dev to remember. Real User Monitoring was flagging bad Interaction to Next Paint and bad Largest Contentful Paint. My first assumption was the WebGL ride itself: too many draw calls, a physics loop blocking the main thread, the usual suspects for a 3D site. Both were wrong, and both were the &lt;em&gt;loader&lt;/em&gt;, which runs and finishes before the ride even starts.&lt;/p&gt;

&lt;p&gt;INP doesn't measure scrolling at all: it only scores discrete interactions like taps and clicks. So no amount of the ride feeling smooth or janky under scroll shows up in that number, which meant I was optimizing the wrong loop entirely. The actual INP hit was a warm-up routine that pre-compiled shaders and materials during the loader, so the very first tap after boot got queued behind that work. &lt;code&gt;scheduler.yield()&lt;/code&gt; resumes the yielded task at high priority once it's called, so a naive yield-and-continue loop would still let the warm-up cut back in front of a waiting tap. The fix was checking &lt;code&gt;isInputPending()&lt;/code&gt; before every resume and bailing the warm-up loop early if a real interaction was waiting.&lt;/p&gt;

&lt;p&gt;The LCP hit was stranger. There's an article/newspaper overlay that appears later in the ride, and to make its entrance transition invisible during the boot sequence, an early version set its opacity to something like 0.004 rather than a hard 0. Browsers only exclude an element from LCP candidacy if it's &lt;em&gt;fully&lt;/em&gt; transparent: 0.004 is still nonzero, so that overlay was quietly eligible, and because it's a large block of text sitting in the DOM from load, it was getting stamped as the page's Largest Contentful Paint at whatever moment its layout resolved, sometimes 9 to 20+ seconds into the boot sequence, nowhere near what a visitor actually experiences. The fix wasn't touching opacity at all: it was moving the element fully off-screen with a small negative &lt;code&gt;translateY&lt;/code&gt; during boot, deliberately small enough to stay within the browser's raster margin so it doesn't cost a repaint, but far enough that it's genuinely outside the layout the browser scores.&lt;/p&gt;

&lt;p&gt;Neither of these would show up if you profiled the ride once it's running. They only show up if you profile the boot sequence as its own thing, separately, with the same rigor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The journal isn't a styled div, it's one baked image
&lt;/h2&gt;

&lt;p&gt;There's a memory-fragment collectible in the ride styled like a page from a real handwritten diary, complete with a curled page edge and ink that looks like it's actually soaked into paper rather than sitting on top of it as CSS text. Every early version that tried to build this out of DOM layers (a background texture, an SVG curl, a webfont on top) looked exactly like what it was: three separate flat things stacked in a browser. It never read as one object.&lt;/p&gt;

&lt;p&gt;What actually worked was giving up on compositing it live and instead pre-generating the whole page as a single RGBA image: the handwriting rendered with genuine multiply-blend ink darkening rather than flat opacity, the page curve photo-traced rather than approximated with a border-radius trick, baked once into one asset the ride just displays. It's a small concession (you lose the ability to tweak the copy without regenerating the image), but a diary page is exactly the kind of object where "looks handmade" only survives if it actually is one physical thing, not a UI assembled to resemble one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the small stuff adds up to
&lt;/h2&gt;

&lt;p&gt;None of the individually loud effects (bloom, fog, the 3D board) is what makes the site feel expensive. It's the layer under those: a canvas-based firefly cursor that uses actual spring physics rather than a CSS trail, so it feels alive instead of decorative; a whistle sound effect pulled from a shuffled bag of three takes so pulling the cord twice in a row never plays the identical sample; an underwater dive sequence that's a pure frequency sweep and nothing else, because every version I tried with a synth stinger or a reverb tail read as "sound designer trying too hard" instead of "you went underwater." The craft bar for this project has been Awwwards Site of the Month, and at that level the things that get you rejected are never the big system, they're the one detail that breaks the illusion that everything else took care of.&lt;/p&gt;

&lt;p&gt;If you're building something similarly scroll-driven and immersive in Three.js, the actual lesson across all of this is the same one every time: profile the thing you assume is fine, not just the thing you assume is broken. The board wasn't rendering-broken, it was state-broken. The site wasn't compute-slow, it was loader-slow. The diary page wasn't a styling problem, it was a compositing-approach problem. The bug is rarely where the symptom is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;iampoonno.com is a scroll-driven 3D portfolio built with vanilla Three.js, no framework, deployed on Cloudflare Pages. If you want to see the whole ride, scroll from the top. The train doesn't leave until you do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
