antialias: true applies only to the canvas's own framebuffer. A composer draws into its own targets, so edges come out jagged unless you anti-alias there. The cheapest fix on WebGL 2 is a multisampled target (FBOs and Renderbuffers's renderbuffers), passed in as new EffectComposer(renderer, new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, samples: 4 })); the composer clones it for its second buffer (checked: both report samples 4).
| Technique | Where in the chain | Cost and quality |
|---|---|---|
| MSAA target (samples: 4) | The composer's targets | Geometry edges only; memory x4 |
| FXAAPass | After OutputPass (needs sRGB) | One cheap pass; softens texture detail |
| SMAAPass | Before OutputPass (linear input) | Sharper than FXAA; three passes |
| TAARenderPass, SSAARenderPass | Instead of RenderPass | Many jittered renders; best for still frames |
OutputPass applies renderer.toneMapping and converts linear values to renderer.outputColorSpace, the steps a plain renderer.render() performs at the end of every shader (Color Management and Tone Mapping). Inside a composer the renderer writes linear values to the half-float targets instead, so leaving OutputPass out gives a dark, untone-mapped image, and adding your own sRGB conversion as well converts twice. LUTPass color grading goes after OutputPass, since lookup tables are authored for display values.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 4px; padding: 4px; flex-wrap: wrap; }
figure { margin: 0; text-align: center; }
canvas { display: block; image-rendering: pixelated; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="row" id="row"></div>
<p style="margin:4px 8px">Each canvas is rendered at 1× and shown at 2×, so the edge stairs are easy to see.</p>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { CopyShader } from 'three/addons/shaders/CopyShader.js';
const W = 97, H = 110; // drawn at 1×, displayed at 2×
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, W / H, 0.1, 50);
camera.position.set(0, 0.2, 2.2);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
['#1f5f8b', '#e09a10', '#b5452f'].forEach((color, i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.9, 0.1), new THREE.MeshStandardMaterial({ color }));
book.position.x = (i - 1) * 0.36;
book.rotation.z = (i - 1) * 0.25; // slanted edges show aliasing best
scene.add(book);
});
function view(caption, { samples = 0, output = true }) {
const renderer = new THREE.WebGLRenderer({ antialias: true }); // applies to the canvas only
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
renderer.domElement.style.cssText = `width:${W * 2}px;height:${H * 2}px`;
const figure = document.getElementById('row').appendChild(document.createElement('figure'));
figure.append(renderer.domElement, caption);
// The composer draws into its own targets: pass a multisampled one for MSAA
const target = new THREE.WebGLRenderTarget(W, H, { type: THREE.HalfFloatType, samples });
const composer = new EffectComposer(renderer, target);
composer.addPass(new RenderPass(scene, camera));
// OutputPass applies tone mapping and linear → sRGB; a plain copy leaves the values linear
composer.addPass(output ? new OutputPass() : new ShaderPass(CopyShader));
composer.render();
}
view('composer, no MSAA', {});
view('target samples: 4', { samples: 4 });
view('no OutputPass (linear, dark)', { samples: 4, output: false });
</script>