Vol. 07 · Dispatch2026-07-06

A four-pass post stack for a desert tank sim — bloom, FXAA, IBL from RoomEnvironment

Tank Battle's desert look isn't a shader trick, it's four passes and one PMREM step. RenderPass → UnrealBloomPass (0.28 strength, 0.8 radius, 0.85 threshold) → FXAA → OutputPass, plus a scene.environment sourced from PMREM(RoomEnvironment) at 0.04 sigma. Here's why each pass is doing what it does, why the stack falls back to plain renderer.render on touch devices, and the one FXAA-on-resize footgun that's caught me twice.

by MetaWorldOS Engineering
Three.jsPost-processingWebGLPBRIBL

TL;DR — desktop Tank Battle runs EffectComposer with four passes: RenderPass (draw the scene) → UnrealBloomPass(strength=0.28, radius=0.8, threshold=0.85) → ShaderPass(FXAAShader) → OutputPass. Renderer setup is ACESFilmicToneMapping at exposure 1.05, sRGB output. Ambient light comes from scene.environment = PMREMGenerator.fromScene(new RoomEnvironment(), 0.04).texture with scene.environmentIntensity = 0.55 — no HDR file, no external asset. On touch devices the whole composer path is disabled and we render the raw scene, because the mobile GPU can’t afford another full-screen postprocess. FXAA has one footgun: its resolution uniform doesn’t update on resize automatically, and forgetting the manual update is what makes an FXAA-composited scene look progressively worse as you zoom the window.

Code: src/features/gamecenter/games/tank-battle/engine.ts (lines 459–493 for the stack, 2459–2474 for resize).

The Tank Battle scene is desert atmosphere first, physics second. The physics is what I already wrote about in the raycast suspension post — that’s what makes the tank drive. The atmosphere is what makes the tank feel like it’s driving through a specific place, and it’s about 60 lines of post-processing setup that people usually paste from an example without understanding what each pass costs.

The composer stack

if (!this.isTouch) {
  const w = window.innerWidth, h = window.innerHeight
  this.composer = new EffectComposer(this.renderer)
  this.composer.addPass(new RenderPass(this.scene, this.camera))
  // Cool white bloom, subtle strength.
  this.bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), 0.28, 0.8, 0.85)
  this.composer.addPass(this.bloomPass)
  this.fxaaPass = new ShaderPass(FXAAShader)
  this.composer.addPass(this.fxaaPass)
  this.composer.addPass(new OutputPass())
}

Four passes, in order:

RenderPass — this is the one that actually renders the three.js scene into the composer’s framebuffer. Everything downstream operates on that framebuffer. Skip this and every other pass has nothing to work with.

UnrealBloomPass(resolution, strength, radius, threshold) — the four-arg constructor. resolution seeds the internal downsample pyramid (this gets overridden per-frame anyway, but the initial value should match your window). strength = 0.28 is deliberately low; the sun in this scene is bright and the bloom would blow out the whole screen at 1.0. radius = 0.8 is close to the pass default and controls the falloff radius across the mip pyramid. threshold = 0.85 means “only bloom pixels whose luminance is above 0.85” — i.e. the sun disc, muzzle flashes, spark particles, tracer bullets, and nothing else.

Threshold is the underrated knob. Set it to 0 and every bright-ish pixel blooms and the scene gets the “sneezed on the lens” look; set it to 1 and nothing blooms. 0.85 is the value that isolates the pixels that are supposed to be self-luminous — the ones already at or near the tone mapper’s ceiling — and lets everything else pass through untouched.

ShaderPass(FXAAShader) — anti-aliasing. FXAA is fast and imperfect; it’s a screen-space pass that finds edges by luminance discontinuity and blurs across them. It gets some cases wrong (thin lines, high-contrast type) but for a game scene with organic geometry and a lot of texture noise, it’s fine. See below for the resize gotcha.

OutputPass — this is the pass that runs the tone-mapping and applies the sRGB colorspace conversion for output. If you skip this, your final image is linear-space and looks washed out or over-saturated depending on the display path. Three.js’s older versions used to bake tone-mapping into the renderer; the newer post-processing chain wants OutputPass explicitly, and forgetting it is the “why does everything look weird” bug for people migrating from an old three.js example.

Order matters. RenderPass has to be first (nothing to compose without it). OutputPass has to be last (everything before it operates on linear values). Bloom must come before FXAA (bloom blows out edges; if you FXAA first and then bloom, the bloom re-introduces edge artifacts across the anti-aliased seams). Bloom must come before OutputPass (bloom operates on the linear HDR-ish framebuffer, not on the tone-mapped LDR output).

Renderer settings that make bloom look right

this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.05

ACESFilmicToneMapping is the current best-practice tone map for game-ish content: it desaturates highlights toward white cleanly (no green sky, no yellow sun) while keeping midtone contrast. 1.05 is a small overexposure — fully-lit sand at midday reads as brighter-than-white, which is the point.

Bloom on a linear framebuffer + ACES tone map is what makes bright things feel bright rather than just white. Without ACES, cranking exposure produces posterized clipping. With ACES, the same exposure produces natural-looking overbrights that the bloom pass then diffuses.

The IBL: RoomEnvironment through PMREM

Ambient lighting for PBR materials needs an environment map. You have two options: ship an HDR file (.hdr / .exr — proper IBL, biggest file, best result), or generate a synthetic environment from a small three.js helper.

Tank Battle uses the second:

this.pmrem = new THREE.PMREMGenerator(this.renderer)
this.scene.environment = this.pmrem.fromScene(new RoomEnvironment(), 0.04).texture
this.scene.environmentIntensity = 0.55

RoomEnvironment is a tiny procedural scene shipped in three/examples/jsm/environments/ — it’s just a room with area lights on the walls, ceiling, and floor. PMREMGenerator.fromScene renders it to a cubemap, then filters that cubemap into a mip pyramid where each level corresponds to a specific glossiness. scene.environment picks the appropriate mip level per-material based on roughness, so a shiny thing gets the sharp environment reflection and a matte thing gets the blurred low-mip.

The 0.04 argument is the sigma — a Gaussian blur applied to the input scene before PMREM filters it. It smooths out the RoomEnvironment’s discrete light panels so the resulting IBL is more diffuse, less “you can see the ceiling lights in the reflection”. At 0.04 the IBL is soft but still gives directional light on shiny surfaces.

environmentIntensity = 0.55 scales the whole IBL contribution. Full-strength (1.0) RoomEnvironment IBL tends to overpower directional sunlight; halving it keeps the IBL as fill and lets the sun be the primary shape-defining light. The tank’s turret picks up warm reflections from the sky-tinted ambient, but the shadow side stays dark enough to read as shadow.

There is nothing HDR here. No 30MB .hdr file. RoomEnvironment + PMREM is one megabyte of code that produces a decent PBR ambient. When the scene stops needing “decent” and starts needing “specific” — real reflections of a specific sky at a specific time of day — you swap in an HDR. For a stylized desert this is enough.

Why the whole thing is gated on !this.isTouch

this.isTouch =
  typeof window !== 'undefined' &&
  (matchMedia('(hover: none) and (pointer: coarse)').matches || 'ontouchstart' in window)

If isTouch is true, the composer never gets constructed, and the render tick falls through to the raw path:

if (this.composer) this.composer.render()
else this.renderer.render(this.scene, this.camera)

Same fallback for MSAA (antialias: !this.isTouch), for shadow map (shadowMap.enabled = !this.isTouch), and for pixel ratio (touch caps at 1.5, desktop at 2.0).

Post-processing on a mobile GPU is expensive in a way desktop budgets hide. UnrealBloomPass alone is a chain of downsample+upsample fullscreen passes; FXAA is one more; OutputPass is one more. On a mid-range mobile GPU those extra fullscreen fragment invocations are what push a 60 fps target under budget and start throttling. Rather than build a “quality tier” system with a slider, we use the touch heuristic as a proxy: if the user is on a phone or tablet, the extra visual polish is worth less than the frame rate stability, so we ship the plain path.

The visual difference on desktop-vs-mobile is real but not disqualifying. On mobile the sun is a hot pixel, not a bloomed halo; the tracer bullets are lines, not glowing streaks; edges are jaggy in the corners. But the tank still drives, the palm trees still cast shadows (from the directional light in the scene proper — those aren’t shadow-map shadows, those are baked into the ground textures), and the desert still reads as a desert.

The FXAA resize footgun

FXAA is a screen-space shader. Its output depends on the input pixel size. If you don’t tell it the current size, it uses whatever size it was constructed at, and after a few resize events the anti-aliasing gets progressively worse in a way that’s hard to attribute to the resize because nothing else looks obviously wrong.

You have to update the resolution uniform manually every time you resize:

private handleResize() {
  const r = this.opts.container.getBoundingClientRect()
  const w = Math.max(1, r.width)
  const h = Math.max(1, r.height)
  this.renderer.setSize(w, h, false)
  this.camera.aspect = w / h
  this.camera.updateProjectionMatrix()
  if (this.composer) {
    this.composer.setSize(w, h)
    const dpr = this.renderer.getPixelRatio()
    if (this.fxaaPass) {
      const u = this.fxaaPass.material.uniforms['resolution']
      if (u) u.value.set(1 / (w * dpr), 1 / (h * dpr))
    }
  }
}

The specific line:

u.value.set(1 / (w * dpr), 1 / (h * dpr))

FXAA wants “one over the pixel count”, not the pixel count itself, in each axis. Multiplied by the device pixel ratio because the framebuffer is bigger than the CSS box on high-DPI displays. Passing w, h instead of 1/w, 1/h — a mistake that’s easy to make when eyeballing the API — produces an FXAA that thinks every pixel is a giant one, and the whole screen goes soft.

composer.setSize(w, h) handles the framebuffer resize for the composer’s internal buffers. It does NOT update per-pass uniforms. UnrealBloomPass’s own resolution updates automatically via its setSize method, but ShaderPass has no idea what the shader wants — it’s just a container. The FXAA shader specifically wants a resolution uniform in this format, and because the shader is opaque to the composer, you’re the one who has to feed it.

If FXAA is looking soft or the anti-aliasing is inconsistent between initial load and after a window resize, this is the first thing to check.

What we didn’t put in

  • No SSAO. Screen-space ambient occlusion is another full-screen pass with a depth read-back, and on a scene where the ground is nearly flat and the tank is the only complex geometry, the AO contribution is small and localized. The IBL takes care of the rough shadow feeling; SSAO would burn frame time for polish that most players wouldn’t notice.
  • No SMAA. SMAA is higher-quality than FXAA but slower. On a game where the camera moves at speed and the frame integrates motion into perceived quality anyway, FXAA is the right cost/quality point.
  • No motion blur. Considered, rejected for readability. Aiming down a gunsight in a moving vehicle is easier when the world isn’t smearing.
  • No color grading LUT. The desert palette is hard-coded into the scene lighting and fog color, not applied as a post-process. Skipping the LUT saves a fullscreen texture read + shader dependency; the tradeoff is that seasonal tint changes would require touching many places instead of one.

Every one of those is a reasonable pass to add in a different game. The four we shipped are the minimum stack that gives PBR materials the ambient they need, isolates the bright pixels for that game-ish glow, gets rid of the worst edge aliasing, and lands the pixels in the right color space. Anything on top of that is polish; anything below it is broken.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

A four-anchor raycast suspension for a tank in raw Rapier

Tank Battle's chassis rides on four downward raycasts, each of which contributes a spring impulse, a track-drive impulse, and a lateral friction impulse. It's the standard raycast-vehicle pattern from Unity's Wheel Collider or BeamNG, written straight against @dimforge/rapier3d-compat with no vehicle plugin. Here's the whole loop, the constants, the front/rear drive split, and the lateral-friction cliff that makes it feel like a tank.

Read dispatch
Three.js

Sound Race — every song generates a 3-lane hazard course, decoded, analyzed, and driven entirely in the browser

How we ship a rhythm-driven WebGL racer with no backend. A Web Audio decode + a Web Worker that runs FFT off the main thread, a beatmap synthesizer that turns spectral flux into a curved elevated road, an audio-clock game loop that never drifts against the music, and a Jamendo-only music source that keeps CORS out of the failure list. All from the production code of /gamecenter/sound-race.

Read dispatch

Recibe un email cuando publiquemos un nuevo artículo — análisis técnicos, ~una vez al mes.