DEV Community

kentog751
kentog751

Posted on

No build step, no bundler: 11 things that broke shipping Three.js into an iframe

The constraint I gave myself was stupid and I would do it again. Two 3D games, real ones with physics and shadows and a mascot, that have to load inside an iframe on somebody else's page, on whatever laptop a school district bought in 2019, with no build step, no bundler, and no framework.

So the shape is: one index.html, seven plain ES modules the browser loads directly, and a vendored copy of Three.js (r185, two files, about 740KB) sitting in lib/ next to them. I install it with npm install three --no-save and copy the build in, so Three never enters package.json and the host never builds it. <script type="module" src="/originals/petal-peaks/src/main.js"> and that is the entire toolchain.

Both games shipped. Everything below is a thing that broke on the way. Most are not in the docs, and about half only exist because the games run embedded rather than on a page I control.

1. Probe WebGL on a canvas you throw away

I want to pick renderer flags before I construct the renderer. Weak GPU means antialias off, powerPreference: 'default', a lower pixel ratio. To decide that, I have to look at the GL context first.

The obvious move is to grab a context off the real canvas, read WEBGL_debug_renderer_info, and hand the canvas to Three. That silently breaks everything. A canvas can only ever have one context. Once you call getContext('webgl2') with default attributes, that context is bound forever, and WebGLRenderer gets your default-attribute context back and quietly ignores every flag you just chose. No warning, no error. You find out because your Chromebook test frame looks identical before and after your "optimization."

So boot() probes on a canvas that never touches the DOM:

const probe = document.createElement('canvas');
probe.width = probe.height = 1;
const gl = probe.getContext('webgl2') || probe.getContext('webgl');
if (gl) {
  const lose = gl.getExtension('WEBGL_lose_context');
  if (lose) lose.loseContext();
}
if (!gl) { /* show a friendly "your browser has 3D turned off" card */ }
Enter fullscreen mode Exit fullscreen mode

The loseContext() matters more than it looks. Browsers cap live WebGL contexts, and the deeper capability probe in perf.js burns a second one to test failIfMajorPerformanceCaveat, which is the cheapest reliable way to detect a software rasterizer. Hand them back immediately or your actual game canvas is the one that fails to get a context.

Only then does the real renderer get built, with the flags the probe earned:

const s = rendererSettingsFor(this.quality, w, h);
this.renderer = new THREE.WebGLRenderer({ canvas, ...s.context });
applyRendererSettings(this.renderer, this.quality, THREE, w, h);
Enter fullscreen mode Exit fullscreen mode

2. Turn tone mapping off

Every Three.js tutorial from the last few years tells you to set ACESFilmicToneMapping. If your art comes out of Blender with physical materials, sure. Mine does not. My sky is a hand-written ShaderMaterial full of hex colours I picked by eye. ACES took a bright pink-and-blue kids' palette and turned it into grey mush. Not "slightly desaturated." Mush.

this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.NoToneMapping;
Enter fullscreen mode Exit fullscreen mode

The other half of this is the trap that made me try ACES in the first place. A custom ShaderMaterial gets no output encoding from Three at all. Write gl_FragColor and it goes to the framebuffer raw, so every authored hex renders noticeably dark and your instinct is to reach for exposure to fix it. The actual fix is one line at the end of the fragment shader:

gl_FragColor = vec4(vCol, a);
#include <colorspace_fragment>
Enter fullscreen mode Exit fullscreen mode

Include the colorspace chunk, deliberately do not include the tonemapping chunk, and the shader renders exactly the colours you typed. Both games run this way: Petal Peaks has the sky, glow, and light-shaft shaders, and every one of them ends with that include.

3. gl_PointSize is not pixels, it is whatever your shader says

I built a GPU particle system as one THREE.Points with a per-vertex aSize attribute, and filled aSize with numbers between 14 and 26, because in my head point size is pixels and 20px sparkles sounded right.

The first burst covered the whole screen with one white blob. Here is the vertex shader, doing exactly what I told it to:

vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
float pop = smoothstep(0.0, 0.22, aLife);
gl_PointSize = aSize * uScale / max(-mv.z, 0.001) * pop;
Enter fullscreen mode Exit fullscreen mode

with uniforms: { uScale: { value: window.innerHeight * 0.5 } }.

That divide by view depth makes aSize a world-space diameter, so the sprite shrinks with distance like everything else in the scene, which is what you want. It also means the pixel size is aSize * uScale / depth. The default embed is 640px tall, so uScale is 320. A particle 12 units from the camera with aSize = 20 gets 20 * 320 / 12, which is 533 pixels of screen. Roughly the whole viewport, per particle.

The real values are now fractions of a world unit, 0.26 to 0.42, and the file carries a shouty comment so I do not do it again. This is the one bug here where the code was correct and my mental model of the units was the only broken part.

4. A chase camera means your character is always backlit

This one is structural and I think it is the most useful thing here.

Petal Dash is an endless runner with a fixed camera behind the player. The camera sits at p.z - 6.4, the player runs toward +z. So the surface the camera sees, for the entire game, is the character's back. Add one directional light for the sun and, unless that sun sits exactly behind the camera, every polygon facing the lens faces away from the light. MeshLambertMaterial gives away-from-light surfaces ambient-only shading.

I did the obvious thing and added an AmbientLight fill. It did not help, and it cannot help, because ambient is flat: it raises every surface by the same constant, so the back of the character gets brighter but stays perfectly uniform. Uniform brightness with no gradient is what a grey silhouette looks like. I turned the ambient up until he was pale and still shapeless, then turned it back down.

const sun = new THREE.DirectionalLight(0xfff6e0, 1.15);
sun.position.set(6, 12, 6);
this.scene.add(sun);
this.scene.add(new THREE.HemisphereLight(0xbfe6ff, 0x8bd66a, 0.85));
Enter fullscreen mode Exit fullscreen mode

HemisphereLight fixes it structurally rather than by tuning. It lights by how far a normal points up versus down, not by how far it points toward or away from a light direction. A vertical, camera-facing surface always sits in the middle of that gradient and always picks up some sky colour, so it cannot be left flat no matter where the camera is. Cool sky over warm ground bounce also keeps the shadow side coloured instead of grey, which is free art direction.

You can also fix it with a second directional light pointing the same way as the camera. I would not. The direction of a Three.js directional light is target.position - light.position, not "it shines outward from where I put it," and I got that backwards twice before switching approaches.

5. A flat plane renders as one flat colour, no matter how many polygons it has

Related, and it took me embarrassingly long to see. The runner's ground is a flat plane. Under one directional light a flat plane has a constant normal, therefore constant N·L, therefore one uniform fill across the largest surface in the frame. Subdividing does nothing. It is the loudest programmer-art tell there is.

The geometry has to stay flat because collision depends on it, so the shape gets baked into vertex colours at build time:

const patch = 0.5 + 0.5 * Math.sin(x * 0.55 + Math.sin(z * 0.31) * 1.3);
c.copy(base1).lerp(base2, patch);
c.multiplyScalar(0.9 + rand() * 0.2);
Enter fullscreen mode Exit fullscreen mode

Broad low-frequency patches, per-vertex jitter, a worn path tint under each lane, a darker falloff at the outer edge for a contact-shadow read. The platformer does the same to its island tops with concentric rings inside the triangle fan, plus a warm lift on the sun-facing half computed from the sun's actual heading:

const sunF = (x * sunHX + z * sunHZ) * capSpan;
_capC.copy(capMid);
if (sunF > 0) _capC.lerp(capWarm, Math.min(1, sunF) * 0.5);
else _capC.lerp(capShade, Math.min(1, -sunF) * 0.26);
Enter fullscreen mode Exit fullscreen mode

Costs about seven extra triangles per outline point, no extra draw call, and the surface is still perfectly flat for physics.

6. Additive light shafts need two fades, not one

Volumetric-looking light shafts are cheap: a cone with additive blending. Except a raw additive cone reads as a pasted-on white wedge with razor edges down both sides, which players read as a rendering glitch rather than as light.

Two independent fades are required and I only found the second one after the first one kept not being enough.

The far end fades via vertex alpha baked into the geometry, so the beam dissolves instead of stopping at a hard rim. Additive blending plus black vertices equals invisible, which makes this free.

The silhouette fades on view angle. Brightness through a real shaft is proportional to how much volume you are looking through, maximal dead centre and zero at the outline. That is |dot(N, view)|:

vec3 n = normalize(normalMatrix * normal);
vG = pow(abs(dot(n, normalize(-mv.xyz))), 1.35);
Enter fullscreen mode Exit fullscreen mode

The abs is load-bearing because the material is DoubleSide. The same trick with max(0.0, dot(...)) turns the glow sphere around every pickup from a hard blob into something that stops stacking to solid white when a row of collectibles lines up in screen space.

7. body { touch-action: none } turns your iframe into a scroll trap

This is the one that made me feel worst, because it breaks other people's pages, not mine.

Every canvas game tutorial says put touch-action: none on the body so the browser stops trying to scroll and pinch while you play. On your own full-page game, fine. Inside an iframe on someone's blog, you have just eaten every touch that lands on your rectangle. A reader scrolling the article puts a thumb on your game and the page stops moving. They cannot get past it. They leave.

#scene, #touchControls { touch-action: none; }
body.playing { touch-action: none; overscroll-behavior: none; }
Enter fullscreen mode Exit fullscreen mode

Scoped to the surfaces that actually need gestures, plus a .playing class the game only adds once a run has genuinely started. On the menu, the iframe is a well-behaved passenger and scrolls out of the way like an image.

8. Versioning the entry point does not version the graph

This one shipped broken to production and I did not notice for a day.

public/originals/** is served statically with a long edge and browser TTL, while the play page reads index.html through a route handler. After a fix deploy those two disagreed for real: production served the new index.html while the CDN and returning browsers served the old src/main.js. The bug fix in that deploy simply did not run for players. The tell that it was cache and not a bad build was fetching the exact same URL with a junk query string, which returned the new file immediately.

So I put a ?v= on the module URL, bumped it, deployed, and it was still broken for anyone who had visited before. Relative imports resolve against the module URL, but the query string does not propagate. import { Game } from './game.js' inside main.js?v=2 requests game.js with no version at all, hits the cache, and a returning visitor runs new main.js against old game.js. Half a release, which is the worst possible state.

Every relative import has to carry the key too:

import { Game, COLORS, SHAPE_LABEL } from './game.js?v=7';
import { GameAudio } from './audio.js?v=7';
Enter fullscreen mode Exit fullscreen mode

Bump all of them together, or you have invented a distributed systems problem inside one browser tab.

9. The menu rendered an empty void, and reading the code would never have told me

The menus in both games are live dioramas: the character idles in the real world while the camera slowly orbits. In the runner, the world generator's update(dt, playerZ, speed) only runs during play, by design, so idling does not burn distance or spawn obstacles. And reset() clears everything.

Clear everything, then never generate. The menu was a character standing in fogged nothing.

this.track.reset();
this.track.update(0, 0, 0);
this.player.reset();
Enter fullscreen mode Exit fullscreen mode

One forced call with zero speed builds a full lookahead buffer of ground and scenery without starting the run. What matters is how I found it: by opening a browser and checking scene.children.length and groundTiles.length live. Reading the code would never have caught it, because every function in the chain is individually correct. The bug is the omission, and omissions only show up at runtime.

10. Flush edge-triggered input at every state transition

Space is the jump key. Space is also the start key. Two separate keydown listeners see the same event: input.js queues a jump into a 0.12s buffer regardless of game state, and main.js, registered second so it runs second on that same event, reads it as "start the run."

The run then begins with a jump already sitting in the buffer, and the character hops on frame one of every keyboard-started run.

this.input.consumeJump();
this.input.consumeLaneDir();
Enter fullscreen mode Exit fullscreen mode

Two lines at the top of the function that starts a run. The general rule: any input system with edge-triggered "queued until consumed" semantics needs an explicit flush at every state transition that could have been triggered by the same device. A fresh state does not imply fresh input.

11. PCFSoftShadowMap is gone

Small one to finish. In r185 it is deprecated, it warns on every single load, and it silently falls back:

PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead.
Enter fullscreen mode Exit fullscreen mode

My quality tiers name the shadow type as a string and index Three with it, so a tier is data rather than a branch:

if (THREE && THREE[s.shadowMapType] !== undefined) {
  renderer.shadowMap.type = THREE[s.shadowMapType];
}
Enter fullscreen mode Exit fullscreen mode

Low tier gets BasicShadowMap and no shadows at all, mid and high get PCFShadowMap. The upside is that shadow.radius actually applies under PCF, so the soft edge you thought the "soft" map type was giving you is finally real.

What I would do differently

Vendoring Three and skipping the bundler was correct and I would do it again. The games are static files, they cache forever, there is no build to break, and I can open a file in a browser and hit reload. That beats tree-shaking a library I am already using most of.

Three things I got wrong. I should have put the cache key in the imports on day one, on every game, instead of learning it in production on one game and back-porting. The two 3D games still ship bare imports, purely because they have not needed a fix deploy since. That is a loaded gun.

I should have built the live-inspection habit earlier. Bugs 9 and 4 were both invisible to code review and obvious within thirty seconds of opening a console. I now assume anything about lighting, or about what is in the scene at a given moment, is unknowable from the source.

And I should have written down the units. Half the pain in bug 3 and all of it in bug 5 came from assuming a number meant pixels when it meant world units, or that polygon count buys you shading when it buys you nothing under a constant normal. Those notes now sit in capitals at the point of use, which has already saved me twice on the other hand-built games in the same repo.


Kenneth Hartog builds and runs freegamesonline.com, where the in-house games are hand-written and the 3D ones are Three.js with no build step.

Top comments (0)