Anti-Aliasing

Anti-Aliasing and Output Color Correction

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).

Anti-aliasing options with EffectComposer
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.

Composer output with no MSAA, with a 4-sample render target, and without OutputPassHTMLLive
<!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>