Skip to main content
Dev Log20 June 2026ยทby Starforge Team

Dev Log #5: How We Embedded 3D Into a Static Website Without Breaking Either

The engineering story behind Starforge's Three.js rendering layer โ€” SharedRenderer, WebGL context limits, SEO-safe lazy loading, mobile optimisation, and the performance numbers we're actually hitting.

#devlog#three-js#webgl#rendering#performance#seo#mobile#engineering

From the beginning, Starforge's marketing site had a specific brief: it should feel alive. Not in the vague hand-wavy sense that a designer puts in a brief and a developer quietly ignores, but genuinely, technically, visibly alive โ€” ships rotating, nebulae drifting, the galactic map twinkling with implied depth. The kind of thing that makes someone who's just landed on the page scroll down slowly because they want to see what moves next.

The problem is that "alive" and "fast, SEO-indexed, accessible, and stable on a $200 Android phone" are not naturally compatible. This is the engineering story of how we built the 3D layer in Starforge's site, what broke along the way, and what the performance numbers actually look like now.

The Constraint: Static-First, 3D-Second

The site is statically generated โ€” HTML, CSS, and JavaScript built at deploy time, served from a CDN, no server-side rendering per request. This is the correct architecture for a game marketing site: it makes the site fast, cheap to host, trivially cacheable, and straightforward to reason about.

The challenge with adding Three.js to a static site is that Three.js is large, WebGL context initialisation is expensive, and both of those facts interact badly with how search crawlers and low-powered devices experience your pages.

We had three non-negotiable constraints before writing any rendering code:

Constraint 1: SEO must not degrade. The site needs to rank for search terms. If our ship rotation renders in Three.js and the page has no text describing the ship without JavaScript running, we've made a page that a search crawler sees as empty. Every piece of textual content โ€” ship names, faction descriptions, stat blocks โ€” must exist in the HTML before JavaScript runs. The 3D layer is decoration on top of content, not a container for it.

Constraint 2: No JavaScript, no broken experience. Users with JavaScript disabled, crawlers, and legacy browsers must see a functional, presentable page. Not a blank white canvas with an error. Graceful fallback to static images with no console errors.

Constraint 3: One WebGL context per page, managed globally. This is the constraint that caused the most architecture work. Browsers impose a limit on concurrent WebGL contexts โ€” typically 8-16 on desktop, as few as 4 on mobile. A page with five 3D ship viewers, each managing its own renderer, will silently drop contexts on mobile. The dropped contexts do not error; they simply stop rendering, with no user feedback. Finding this problem three days before a launch is not a good time.

The Static HTML Layer

Before any JavaScript, the page is HTML and CSS. Ship viewers โ€” the components that show a rotating ship model โ€” are built as semantic HTML blocks:

`html

Iron Fortress Battleship โ€” Terran Federation heavy capital ship

Iron Fortress

Terran Federation Battleship โ€” 1.8M HP, 6 weapon hardpoints

`

The noscript block provides the static image fallback. The figcaption provides SEO-relevant text that exists before JavaScript runs. The data-model attribute tells our JavaScript which 3D asset to load. From a search crawler's perspective, this is a fully functional semantic HTML figure element with descriptive text and an image alt attribute. Three.js doesn't exist as far as the crawler is concerned.

When JavaScript runs, it finds all .ship-viewer elements, checks if WebGL is available, and either upgrades the elements to 3D viewers or leaves the noscript fallback in place. The upgrade is progressive: HTML-first, JavaScript-enhanced.

The SharedRenderer Problem

The WebGL context limit problem required a proper architectural solution, not a hack.

Our initial implementation gave each ship viewer its own THREE.WebGLRenderer. On a page with four ship viewers, that's four WebGL contexts. On desktop Chrome this worked fine. On mobile Safari โ€” which enforces a stricter 4-context limit and degrades silently โ€” the fourth context simply never rendered. We shipped this to closed beta. A tester on an iPhone SE filed a bug report with a screenshot of a blank ship viewer. We had not caught it because our mobile testing fleet consisted entirely of flagship-tier devices.

The fix is SharedRenderer โ€” a singleton pattern for WebGL contexts across the page.

The page creates one THREE.WebGLRenderer instance, stored in a module-level singleton. Individual ship viewers do not own a renderer; they request render time from the SharedRenderer via a render queue. The SharedRenderer maintains a list of registered scenes, iterates through them on each requestAnimationFrame, calls renderer.render(scene, camera) for each active viewer, and manages context ownership globally.

The implementation has a few subtleties worth noting:

Off-screen viewers are suspended. Ship viewers that have scrolled out of the viewport are removed from the active render queue via IntersectionObserver. A viewer that's below the fold isn't consuming GPU time. When it scrolls back into view, it re-registers. On a long page with eight ship viewers, typically two or three are in the active render queue at any moment.

Canvas ownership rotates. A single WebGL renderer renders into a single canvas. To display multiple viewers simultaneously, we use the preserveDrawingBuffer approach: render each scene in sequence into the canvas, then copy the result to an OffscreenCanvas per viewer using transferToImageBitmap. This sounds expensive; in practice on modern hardware it's 0.3-0.8ms per viewer per frame, which is acceptable at 60fps.

Graceful degradation is explicit. The SharedRenderer's initialisation checks WebGL availability and the device GPU tier (via a lightweight feature detection pass). On devices where WebGL initialisation fails or the GPU tier is too low, SharedRenderer returns null and every ship viewer falls back to its static image. This is not an error state; it's a designed path.

Lazy Loading: The Three.js Bundle

Three.js itself is approximately 600KB minified. Loading it on every page visit, regardless of whether the visitor's device can use it, is wasteful and slow.

We lazy-load Three.js using dynamic import() behind an IntersectionObserver trigger: Three.js only downloads when the first ship viewer enters the viewport (or is about to, with a 200px margin). On pages with no ship viewers, Three.js never loads at all.

The lazy load sequence:

1. Page loads. HTML renders. Static ship viewer placeholders display their noscript images.

2. User scrolls toward the first ship viewer.

3. IntersectionObserver fires. Dynamic import('three') begins.

4. Three.js downloads and initialises. SharedRenderer creates the WebGL context.

5. The ship viewer in the viewport upgrades from static image to 3D model.

6. Subsequent ship viewers that enter the viewport upgrade immediately (SharedRenderer already exists).

The practical effect: on a fast connection, the 3D upgrade happens 300-500ms after the user reaches the first ship viewer โ€” fast enough to feel instantaneous. On a slow connection, they see the static image for longer before the 3D upgrade occurs, which is the correct experience: they're never blocked on a download before seeing content.

GLTF model assets (the actual ship models) load separately from Three.js and also use lazy loading. The Iron Fortress model is 1.4MB. It only downloads when a page section featuring the Iron Fortress is actually scrolled to.

Performance Numbers

These are real numbers from our monitoring setup (Lighthouse CI on each deploy, plus field data from closed beta).

Desktop (Chrome, mid-tier hardware):

- Three.js lazy load delay: 280-420ms after first ship viewer intersects

- WebGL context init: ~90ms on first initialisation

- Per-frame render time (4 active viewers, SharedRenderer): 2.1-3.8ms per frame

- Sustained FPS with 4 active viewers: stable 60fps

- Three.js memory footprint: 38MB initial, peaks at 85MB with 4 loaded ship models

Mobile (tested on Galaxy A54, iPhone 12 โ€” mid-range targets):

- Three.js lazy load delay: 680-1200ms (slower connection + CPU)

- WebGL context init: ~220ms

- Per-frame render time (2 active viewers): 4.2-7.1ms per frame

- Sustained FPS with 2 active viewers: 45-58fps, occasionally dips to 35fps during scroll

- Memory: 52MB peak with 2 loaded models

Core Web Vitals (field data from closed beta):

- LCP (Largest Contentful Paint): 1.4s desktop, 2.8s mobile โ€” 3D assets do not affect LCP because the noscript static images load first

- CLS (Cumulative Layout Shift): 0.02 โ€” ship viewer containers are fixed-size before Three.js loads

- FID/INP: 12ms desktop, 34ms mobile โ€” acceptable

The CLS number is worth explaining because it required explicit effort. When Three.js loads and the canvas element is injected, a naive implementation causes a layout shift โ€” the container reflows. We prevent this by setting fixed dimensions on the .ship-viewer container in CSS before JavaScript runs. The Three.js canvas is positioned absolutely within a fixed-size parent. Layout never shifts.

Mobile Optimisation: What Actually Helped

Several optimisations we tried had minimal measurable impact. Two made a significant difference.

Dropping to 30fps on mobile using requestAnimationFrame throttling. We detect mobile devices at initialisation and halve the render rate. On mobile, 60fps 3D is genuinely difficult to maintain and users don't reliably perceive the difference between 60fps and 30fps for ambient ship rotation. Halving the render rate cut GPU time in half and reduced the battery drain complaints we were getting from beta testers on long sessions from 12 to 3.

Model LOD (Level of Detail) based on screen size. Ship models have three mesh resolutions: full (used on desktop at large display sizes), medium (used on desktop at small container sizes and high-end mobile), and low (used on mid-range mobile and lower). The LOD selection happens at model load time based on the container pixel dimensions and device pixel ratio. The low-resolution Iron Fortress model is 280KB; the full-resolution version is 1.4MB. On a Galaxy A54, serving the low-resolution model reduced initial render quality imperceptibly and cut download time by 80%.

What We'd Do Differently

Async model preloading in the background. Currently, model downloads only begin when the viewer enters the viewport. On fast connections this is fine; on slow connections there's a noticeable delay between scroll-trigger and model appearance. If we had instrumented this earlier, we'd have built a background preload queue that starts fetching models 2-3 scroll-pages ahead of the user's current position.

SharedRenderer should have been designed from day one. We started with per-component renderers and refactored to SharedRenderer when the mobile context limit problem emerged. The refactor took two days. Designing SharedRenderer upfront would have taken two hours. The lesson is not new โ€” global resource management should be architectural, not retrofitted โ€” but it's one we apparently needed to learn again.

The Result

The 3D layer works. Ships rotate. Nebulae drift. On the faction page, the Void Syndicate's Kraken Battleship slowly turns in the purple ambient light of the Void Nebula and it looks exactly like what it's supposed to look like: a ship that's genuinely threatening in a game you want to play.

The static site is still fast. The SEO content is still intact. The mobile experience degrades gracefully for devices that need it. The WebGL context limit is managed globally and has not caused a single silent rendering failure since SharedRenderer launched.

Occasionally the engineering is in service of the game. This was one of those times.

โ€” Starforge Dev Team