The One-File Bet: Why Inlining GLB and Shaders Is a Performance Trade, Not a Feature
I've spent enough time profiling Three.js sites to know that "it's just one file" sounds like a feature until you actually watch the network panel. The base64 overhead is real and mechanical: every GLB, texture, and audio asset inlined as a data URI costs roughly 33% more bytes than the equivalent binary file. That's not a rounding error—a scene with a few textured meshes and a compressed model can push an initial HTML payload toward tens of megabytes, and the browser's main parser has to chew through all of it before it can paint anything.
The cache invalidation problem is subtler but more corrosive in production. Because code and assets live in the same document, changing a single CSS color forces clients to re-download the entire payload. In a normal split setup, the JS bundle might cache for months while a texture swaps independently. Here, everything is coupled to everything.
The tradeoff I keep coming back to is what you give up versus what you get. What you get is genuinely valuable: no node_modules bitrot, no bundler config to break when a dependency goes abandoned, no build step at all. The archival argument is real—this file will run as long as CDNs exist. But the honest production cost is that you're trading streaming, incremental rendering, and long-lived HTTP caching for what is essentially a portable demo artifact.
flowchart TD
A[Single index.html with inline base64 GLB] --> B[Browser begins parsing HTML]
B --> C[Main thread blocked: decode base64 → WebGL buffer]
C --> D[FCP delayed until entire payload parsed]
D --> E[No progressive render: first paint waits for all assets]
F[Separate .glb files fetched async] --> G[HTML parses quickly: minimal inline code]
G --> H[Async GLTFLoader fetches assets in parallel]
H --> I[Progressive render: scene appears as assets arrive]
I --> J[onload fires after fetch completes]
When I think about whether to adopt this pattern for something that will live long-term, I reach for it when the artifact itself is the point—a portfolio piece, a concept demo, an archival experiment. I'd hesitate on anything with a traffic budget or a team editing it monthly.
How the Prompt Becomes the Architecture: The PROMPT.md Feedback Loop
The thing that makes this architecture interesting isn't the Three.js code itself—it's what PROMPT.md enables. Because everything lives in one context window, generative tools can read the entire experience and rewrite it wholesale. There's no cross-file module resolution to break, no bundler config to invalidate, no dependency tree to misunderstand. When you're iterating with an LLM, that's not a minor convenience; it's the entire difference between "refactor this shader block" and "refactor this shader block and then manually fix the seven imports that broke in the process."
Where this shines is whole-viewport changes. Want to swap the VHS CRT post-processing pass for a chromatic aberration filter? The prompt can see the full shader, the full render loop, and the full scene graph in one context. It can rewrite the pass, update the uniform bindings, and adjust the camera parameters without losing track of what's connected to what. That's a genuinely powerful editing primitive that modular architectures actively resist.
Where it breaks is predictable once you've seen it happen: state machine logic interleaved with render loops. When interaction states, camera transitions, and raycast handlers are woven into one flat script, LLMs struggle with self-inconsistent renaming. You ask for a new ARCADE_VIEW state, and the rewrite updates the state enum but misses the onComplete callback in the tween chain that still references the old state name. The model doesn't know it's inconsistent because the error won't surface in the text—it surfaces at runtime, in a camera that lerps to a destination that no longer exists.
I've found the failure mode I'd watch for is not "the LLM can't do it" but rather "the LLM can't tell it's wrong." A modular architecture gives you compiler errors and type checks that catch these crosses; a flat file gives you nothing until the user clicks the slide door and teleports to the void. The prompt-driven workflow is unbeatable for rapid prototyping and deep aesthetic iteration, but it demands that you treat the output as a draft to be audited, not a commit to be trusted.
The Camera State Machine Problem: Why Rapid Clicks Deadlock Your Kyoto Lobby
The multi-state camera architecture in Sublevel Studio is elegant on paper: you click the arcade cabinet, and the camera lerps from the main lobby view to the arcade; you click the CRT, and it transitions to that monitor; you slide open the shoji door and it moves into the adjacent room. Each interaction zone has a target camera position and a tween that interpolates toward it.
The deadlock is a classic race condition. Imagine the user clicks the arcade trigger. The camera starts tweening from MAIN_LOBBY toward ARCADE. While that tween is mid-flight, the user clicks the CRT trigger. The second click fires, and a new tween starts from the camera's current—interpolated—position toward CRT_VIEW. So far, so good. But the first tween's onComplete callback hasn't fired yet. When it does—after the second tween has already begun or even finished—it executes its completion logic, which might be something like "set camera to final arcade position" or "enable arcade interactions." That overwrites whatever state the second transition established, leaving the camera in a broken position: the scene thinks you're at the CRT, but the camera is at the arcade, and all subsequent trigger clicks are dead because the state enum doesn't match reality.
stateDiagram-v2
[*] --> MAIN_LOBBY
MAIN_LOBBY --> ARCADE: click arcade
MAIN_LOBBY --> CRT_VIEW: click CRT
MAIN_LOBBY --> SLIDING_ROOM: open shoji door
ARCADE --> CRT_VIEW: click CRT
CRT_VIEW --> SLIDING_ROOM: open shoji door
SLIDING_ROOM --> MAIN_LOBBY: return
state TRANSITIONING {
[*] --> TWEEN_ACTIVE
TWEEN_ACTIVE --> [*]: tween completes
}
MAIN_LOBBY --> TRANSITIONING: tween starts
ARCADE --> TRANSITIONING: tween starts
CRT_VIEW --> TRANSITIONING: tween starts
SLIDING_ROOM --> TRANSITIONING: tween starts
TRANSITIONING --> ARCADE: tween.isRunning() === false
TRANSITIONING --> CRT_VIEW: tween.isRunning() === false
TRANSITIONING --> SLIDING_ROOM: tween.isRunning() === false
TRANSITIONING --> MAIN_LOBBY: tween.isRunning() === false
TRANSITIONING --> BROKEN_POSITION: user clicks second trigger mid-tween
BROKEN_POSITION --> [*]: camera stuck, all inputs dead
What's the fix? A proper finite state machine with transition guards and cancellable tweens. Every state transition should check if (this.tween.isRunning()) return; before starting a new one. Every tween should be cancellable—meaning the tween library exposes a stop() method that kills the tween and fires no completion callbacks. When the user clicks a trigger during an active transition, you either queue the request or ignore it entirely, never allowing a second tween to start while one is in flight. The guard condition is simple, but it has to be enforced at the state level, not the individual tween level, because the deadlock lives in the interaction between multiple tweens, not within any single one.
Touch vs. Raycast: Preventing Inadvertent Scene Interactions During Scroll
The collision between a 3D canvas and a scrollable DOM page is one of those problems that only reveals itself on a real device. On desktop, a mouse wheel scroll is a discrete event—it doesn't generate pointer movement across the canvas surface. On mobile, a scroll gesture is a finger dragging across the screen, which means it passes directly over the canvas and fires pointermove events continuously. If your Three.js raycaster is attached to pointermove, every scroll gesture is also a raycast operation. The raycaster doesn't care whether the finger intends to scroll or interact—it just sees a pointer position and reports which meshes are underneath it.
The failure mode is subtle because it's probabilistic. A fast flick generates a few raycasts, and if the pointer happens to cross a trigger mesh during that flick, you open a sliding door you never intended to touch. The user is mid-scroll, the door slides open, the camera starts transitioning unexpectedly, and now they're trapped in an interaction they didn't want.
The core fix is decoupling the scroll gesture from the raycast gesture by introducing a motion threshold. Track the pointer's initial position on pointerdown. On pointermove, calculate the distance from that origin. If the distance exceeds ~10 pixels, set an isDragging flag to true and suppress raycast interactions. The raycaster should only fire on pointerup if isDragging is false—and ideally, only after a short delay or on touchend rather than pointerdown. A pointerdown fires before the browser knows whether the gesture is a tap or a scroll; waiting for touchend gives you that disambiguation.
| Input Handling Strategy | Mobile Reliability | Desktop Precision | Edit Cost |
|---|---|---|---|
pointer-events: none during scroll |
High: eliminates interception entirely | Low: canvas becomes unclickable during scroll momentum | Low: CSS toggle based on scroll direction |
Raycast on touchend only |
High: clear tap vs. scroll separation | Medium: fine for taps, but no hover/predictive interaction | Low: single event listener change |
| Separate interaction zones with DOM overlays | Medium: requires careful positioning of invisible anchors | High: precise hover and click targets with native accessibility | High: needs synchronization between 3D scene and DOM layer |
| Full 3D interaction with scroll-lock | Low: mobile users get stuck when scroll is intercepted | High: completely immersive, no scroll ambiguity | Medium: must manage scroll state and provide manual unlock |
The other mitigation worth considering is pointer-events: none on the canvas during active scroll momentum. When the user is scrolling, the canvas shouldn't capture any pointer events at all. This is a CSS-level fix that requires detecting scroll direction and toggling the property, but it's cheap and effective. The interaction state machine described earlier becomes the guard: if the camera is transitioning, or if the user is mid-scroll, the canvas simply ignores the pointer.
I've found that the threshold-based isDragging approach combined with touchend-only raycasting handles 90% of the real-world cases. The remaining edge cases are usually users who tap precisely on a trigger mesh while scrolling slowly—which is rare enough that a small interaction delay on tap doesn't hurt the experience meaningfully.
Where VHS Post-Processing Breaks on Mobile: Fill-Rate and DPR Handling
The retro CRT aesthetic is a per-pixel tax levied on every frame. The VHS pass, chromatic aberration, and scanline overlays all operate across the full viewport—every fragment shader runs regardless of whether the scene behind it is complex or simple. On a desktop with a dedicated GPU, that's fine. On a mobile device with an integrated GPU, you're spending your entire frame budget on a post-processing effect that delivers aesthetic value but zero functional benefit.
The standard advice is to clamp devicePixelRatio with Math.min(window.devicePixelRatio, 2). That works for a 1080p phone—a DPR of 3 becomes 2, and you're rendering at roughly 4x the cost of a 1x buffer. But on a 4K device with DPR 2, you're still rendering 8 million pixels through a per-pixel chromatic aberration and VHS distortion shader. The fill-rate cost doesn't care about the spec sheet; it cares about how many fragments you're pushing per frame. A 4K display at DPR 2 is 8.3 million fragments per frame. At 60fps, that's 500 million fragment shader invocations per second, before you even calculate the scene geometry.
The fix is adaptive DPR scaling tied to sustained FPS monitoring. Track a rolling average of frame time over the last 60 frames. If the average FPS drops below a threshold (say, 40), reduce the render target resolution by a step—from DPR 2 to DPR 1.5, then to DPR 1.0. If FPS recovers, step back up. This creates a feedback loop where the post-processing shader cost self-regulates based on the device's actual capability, not its spec sheet.
The crucial detail is prefers-reduced-motion. This is a hard cutoff, not a suggestion. If the user has reduced motion enabled at the OS level, disable the VHS distortion entirely—the CRT pass contributes nothing to the core interaction, and it's the most frame-expensive element in the pipeline. The scanlines can stay on at a lower intensity because they're cheap, but the distortion and chromatic aberration should be conditionally compiled out.
I've also found it useful to treat the post-processing stack as optional rather than baked in. If the device reports width * height * dpr above a rough budget—say 6 million pixels—skip the VHS pass entirely and fall back to a static CRT texture overlay on the canvas container. The visual difference is noticeable but acceptable, and it keeps the interaction loop running smoothly on devices that can't afford the retro aesthetic.
CDN Pinning vs. Bundled Ownership: The Three.js 0.160.0 Constraint
Pinning Three.js 0.160.0 via an ES module CDN import is a bet that the URL remains stable, the version stays available, and your network allows that specific request. The stability risk is real: CDN URLs change, versions get deprecated or unlisted, and unpkg-style links can update their resolution logic in ways that break pinned paths. When that URL breaks, the entire scene fails to load—no graceful fallback, because the module import itself is the failure point. The degradation isn't a broken visual effect; it's a blank canvas.
The CSP issue is more immediate for production deployments. Enterprise environments often set a strict Content Security Policy that blocks unhashed inline scripts and external CDN fetches by default. Your single-file architecture, with its inline <script> blocks and CDN imports, gets rejected outright. The file runs perfectly on a local server or GitHub Pages, but it won't render a single frame in a corporate environment that enforces script-src 'self'. That's an operational constraint that only surfaces when someone tries to deploy beyond a hobby context.
Then there's the tree-shaking cost. Importing the full three.module.js from a CDN means the browser downloads and parses the entire library—including the parts you don't use—regardless of whether your scene references them. Three.js 0.160.0's module build is substantial. You don't need the whole library for a room with sliding doors and a post-processing pass, but the browser fetches and evaluates it all anyway. A Vite-based build with npm can tree-shake to only the code paths your scene actually uses, which is often a meaningful percentage reduction in bytes parsed.
But the tradeoff cuts back the other way. The Vite + npm path requires a Node.js environment, a package manager, a bundler config, and a build step. Every time you change a line of code, you re-run the build. That build step is exactly what makes whole-context LLM prompting difficult: the model needs to reason about the dependency tree, the bundler config, and the module resolution across multiple files. It can't see the entire experience in one context window, so every edit becomes a multi-file operation with potential inconsistencies.
The honest position is that the CDN pinning works fine for personal portfolio pieces and archival demos. The moment you need production reliability, CSP compliance, or a monthly maintenance cycle, Vite + npm is the right call. The cost is abandoning the frictionless "rewrite the whole file" workflow that makes the Fable prompt-driven approach so powerful.
I've found myself oscillating on this: the zero-build portability and whole-context promptability are genuinely unique advantages. But they come with a ceiling. If I knew the archive would be viewed by 100 people, I'd stay single-file. If it were a marketing site with traffic, I'd pay the setup cost for a real build pipeline.
The Accessibility Gap: Spatial WebGL Nodes vs. the DOM Below
The tatami room, arcade cabinet, and 3D plastic menu—objects that look interactable—are invisible to a screen reader. A keyboard-only user can't Tab to the shoji door, can't press Enter to open it, and can't hear that the CRT monitor is clickable. The raycaster fires on pointer events, but pointer events don't exist for assistive technology. This isn't a minor oversight; it's a fundamental architectural limitation of spatial WebGL interaction.
The hybrid structure already provides an elegant solution. Because the editorial section below the canvas is real semantic HTML, you can mirror the 3D interaction states into a hidden DOM overlay—invisible anchors, aria-labels, and Enter-key handlers that trigger the same state changes as pointer clicks. The pattern is straightforward: traverse the scene graph, identify raycastable meshes, and generate focusable DOM elements positioned absolutely over the canvas. Each anchor calls the same camera transition function the raycaster would have called.
The sync problem is the hard part. If a user presses Enter on the arcade button while the camera is already transitioning, the same deadlock from the state machine section reappears. Your DOM overlay needs its own transition guard—check isTransitioning before dispatching the interaction, just like the pointer handler does.
The WebGL-disabled fallback isn't optional. If context creation fails or the WebGL library doesn't load from the CDN, the entire scene disappears. A static image with a descriptive paragraph about the Kyoto lobby, the arcade mini-game, and what the visitor would have experienced preserves the content value without the 3D. That fallback should be the default markup, not a runtime afterthought.
When I'm building an accessible interaction layer for a prompt-generated scene, I start with the traversal, then the anchors, then the guard logic. The traversal is mechanical. The guard logic is the part that takes real thinking.
Resources
- GitHub - MengTo/sublevel-studio: A single-file Three.js studio concept with an interactive Kyoto lobby, arcade, portfolio CRTs, and editorial case studies. · GitHub
- raw.githubusercontent.com
- GitHub - MengTo/threeui: Open-source ThreeUI Community catalog with live interactive components and complete Community source. · GitHub
Updated 2026-09-02 by Mehran Mozaffari.
Related posts
15 September 2026
From Static Mesh to Walking Character: A Technical Operator's Manual for the 3D Vibe Coding Pipeline
15 September 2026
Designing Physical Objects with Gemini Canvas: From Prompt to Printable STL
10 September 2026
Unbundling the Hype: How Prompt-to-3D, MCP, and Collaborative Generative Workflows Actually Fit Together
8 September 2026
B3D: Distilling Biomechanics from Foundation Models — A Deep Dive into Architecture, Tradeoffs, and Production Realities
7 September 2026
Bringing 3D Cards into Rive: What GPU Canvas Actually Changes
6 September 2026
Turning Flat Art into Holo Cards: A Deep Dive into holo-card-studio's 2.5D Pipeline
