⚡ Урок · Three.js · Рендеринг
📅 Липень 2026 ⏱ 17 хв 🎓 Середній рівень

Постобробка ефектів у Three.js

Звичайний виклик renderer.render(scene, camera) дає вам одне зображення за кадр без жодного простору для кінематографічного фінішу. EffectComposer у Three.js дозволяє з'єднувати ланцюжок повноекранних шейдерних проходів (passes) поверх цього зображення — bloom, оточуюче затінення, згладжування, кольорокорекцію — щоб перетворити плаский рендер на щось, що виглядає готовим до випуску. У цьому гіді ми побудуємо повний конвеєр композитора, розберемо, що насправді робить кожен вбудований прохід під капотом, і покажемо, як написати власний ShaderPass.

1. Як працює EffectComposer

EffectComposerthree/examples/jsm/postprocessing/EffectComposer.js) замінює одиничний виклик рендерингу чергою проходів. Кожен прохід читає вихідну текстуру, виконує повноекранний фрагментний шейдер над нею і записує результат у текстуру призначення — наступний прохід у ланцюжку читає саме цей вихід. Всередині композитор перемикається («ping-pong») між двома WebGLRenderTarget (readBuffer і writeBuffer), щоб проходи ніколи не читали й не писали в одну й ту саму текстуру одночасно.

Ланцюжок проходів (концептуально):

сцена ──RenderPass──▸ RT_A ──BloomPass──▸ RT_B
RT_B ──SSAOPass──▸ RT_A ──FXAAPass──▸ RT_B
RT_B ──OutputPass──▸ екран (canvas)

Кожен прохід: color_out(uv) = f(color_in(uv), сусідні пікселі, уніформи)

Кожен прохід має три прапорці, що керують цим потоком: enabled (повністю пропустити прохід), needsSwap (чи створив прохід нову текстуру, яку має прочитати наступний прохід), і renderToScreen (писати безпосередньо на canvas замість render target — зазвичай це робить лише останній прохід, хоча сучасний Three.js рекомендує окремий OutputPass у кінці для коректної обробки кольорового простору й тоне-мапінгу).

2. Налаштування RenderPass + OutputPass

Будь-який конвеєр композитора починається з RenderPass (малює 3D-сцену в перший render target) і завершується OutputPass (застосовує тоне-мапінг і конвертує з лінійного робочого простору в sRGB для показу). Все, що додається між ними, працює із зображенням, а не зі сценою.

import * as THREE from 'three'; import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; const composer = new THREE.EffectComposer(renderer); composer.setSize(innerWidth, innerHeight); composer.setPixelRatio(Math.min(devicePixelRatio, 2)); const renderPass = new THREE.RenderPass(scene, camera); composer.addPass(renderPass); // ...тут додаються решта проходів (bloom, SSAO, AA) const outputPass = new THREE.OutputPass(); composer.addPass(outputPass); // Замініть renderer.render() на: function animate() { composer.render(); requestAnimationFrame(animate); } requestAnimationFrame(animate);
Типова помилка: забути викликати composer.setSize()composer.setPixelRatio()) при зміні розміру вікна — виклик лише renderer.setSize() залишає внутрішні render target'и композитора зі старою роздільною здатністю, через що зображення розтягується.

3. UnrealBloomPass: сяйво на яскравих ділянках

Bloom імітує розтікання світла навколо дуже яскравих ділянок — так само, як це робить об'єктив камери. UnrealBloomPass реалізує це у три кроки: виділяє пікселі вище порогу яскравості, розмиває виділене зображення на кількох рівнях mip (даючи м'який, широкий спад замість рівномірного розмиття), а потім адитивно накладає розмитий результат на оригінальне зображення.

яскравість(c) = 0.2126·c.r + 0.7152·c.g + 0.0722·c.b
bright(c) = c, якщо яскравість(c) > поріг, інакше 0
bloom = Σᵢ розмиття(зменшення(bright), mipᵢ) · сила
вихід = tonemap(колір_сцени + bloom)
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; const bloomPass = new THREE.UnrealBloomPass( new THREE.Vector2(innerWidth, innerHeight), 0.9, // сила 0.4, // радіус 0.85 // поріг ); composer.addPass(bloomPass); // Для HDR-значень лише емісивні матеріали або яскраве світло перевищують поріг const sunMat = new THREE.MeshStandardMaterial({ emissive: 0xffaa33, emissiveIntensity: 3.0, // > 1.0 виходить за межі порогу 0.85 });
Сила проти порогу: strength (сила) масштабує, скільки bloom додається назад; threshold (поріг) визначає, які пікселі вважати «яскравими» (нижчий поріг = більша частина сцени сяє); radius (радіус) розширює ядро розмиття на кожному рівні mip. Почніть з сили 0.8–1.2, порогу 0.8–0.9 для тонкого ефекту — усе, що нижче порогу 0.6, зазвичай перетворює всю сцену на серпанок.

4. SSAOPass: екранне оточуюче затінення

Оточуюче затінення (ambient occlusion) затемнює складки й точки контакту — кути, тріщини, місця, де стикаються дві поверхні — там, де оточуюче світло природно блокується. SSAOPass апроксимує це для кожного пікселя, використовуючи буфери глибини й нормалей сцени: для кожного пікселя вона семплює півсферу сусідніх точок у просторі перегляду та перевіряє, скільки з них перекриті геометрією, що ближче до камери.

AO(p) = 1 − (кількість_перекритих_семплів / загальна_кількість_семплів)

для кожного семпла sᵢ у півсфері навколо нормалі n у точці p:
  глибина_семпла = буферГлибини(проекція(p + sᵢ · радіус))
  перекрито, якщо глибина_семпла < view_z(p + sᵢ · радіус) − зсув
import { SSAOPass } from 'three/examples/jsm/postprocessing/SSAOPass.js'; const ssaoPass = new THREE.SSAOPass(scene, camera, innerWidth, innerHeight); ssaoPass.kernelRadius = 8; ssaoPass.minDistance = 0.002; ssaoPass.maxDistance = 0.15; ssaoPass.output = SSAOPass.OUTPUT.Default; // Beauty; використайте .SSAO для налагодження буфера затінення окремо composer.addPass(ssaoPass);
SSAOPass виконується перед bloom та AA у ланцюжку — йому потрібні сирі буфери глибини/нормалей сцени, тому вставляйте його одразу після RenderPass. Це також один із важчих проходів (кілька семплів глибини на піксель); на інтегрованих GPU зменшіть кількість семплів або перейдіть на запечене/lightmap-рішення AO.

5. Згладжування: FXAA проти SMAA проти TAA

renderer.antialias = true використовує апаратний MSAA на основному фреймбуфері, але MSAA відкидається, щойно сцена рендериться в проміжний render target для постобробки — тому конвеєрам композитора потрібен окремий прохід пост-згладжування.

Техніка Вартість Якість Примітки
FXAA Дуже низька (один прохід) Розмиває дрібні деталі Найкраще для слабких пристроїв / мобільних
SMAA Низька–середня Різкіші краї, ніж FXAA Гарний варіант за замовчуванням
TAA Середня (потребує буфер історії) Найкраща, обробляє суб-піксельні деталі Примарність (ghosting) при швидкому русі
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js'; const fxaaPass = new THREE.ShaderPass(FXAAShader); const pixelRatio = renderer.getPixelRatio(); fxaaPass.material.uniforms['resolution'].value.set( 1 / (innerWidth * pixelRatio), 1 / (innerHeight * pixelRatio) ); composer.addPass(fxaaPass);
FXAA потребує оновлення уніформ роздільної здатності при зміні розміру — він працює з растеризованим зображенням, тому йому обов'язково потрібно знати точні розміри в пікселях, щоб коректно семплювати сусідні тексели. Забути про це в обробнику зміни розміру — найпоширеніша помилка FXAA (краї знову виглядають зубчастими після зміни розміру вікна).

6. Написання власного ShaderPass

Кожен вбудований прохід — це насправді ShaderPass із приєднаною конкретною GLSL-програмою. Ви можете написати власний, передавши об'єкт із uniforms, vertexShader і fragmentShader — вхідне зображення завжди надходить як tDiffuse.

const ColorGradeShader = { uniforms: { tDiffuse: { value: null }, uContrast: { value: 1.1 }, uSaturation: { value: 1.2 }, uTint: { value: new THREE.Color(0xfff4e6) }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uContrast; uniform float uSaturation; uniform vec3 uTint; varying vec2 vUv; void main() { vec3 col = texture2D(tDiffuse, vUv).rgb; // Контраст: обертання навколо середньо-сірого col = (col - 0.5) * uContrast + 0.5; // Насиченість: змішування у бік яскравості float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); col = mix(vec3(luma), col, uSaturation); // Мультиплікативний відтінок кольору (теплі відблиски) col *= uTint; gl_FragColor = vec4(col, 1.0); } `, }; const gradePass = new THREE.ShaderPass(ColorGradeShader); composer.addPass(gradePass);
Чому саме tDiffuse? ShaderPass автоматично прив'язує render target виходу попереднього проходу до уніформи з ім'ям tDiffuse перед рендерингом — ваш фрагментний шейдер має лише оголосити uniform sampler2D tDiffuse; і семплувати його з vUv. Жодного ручного з'єднання render target'ів не потрібно.

7. Кіноплівкове зерно та віньєтка

Зерно й віньєтка — дешеві одно-прохідні ефекти, що додають багато сприйнятого лоску практично без вартості для GPU — обидва є чистими функціями UV-координат і насіння шуму, без додаткових семплів сусідніх пікселів.

const GrainVignetteShader = { uniforms: { tDiffuse: { value: null }, uTime: { value: 0 }, uGrainAmount: { value: 0.035 }, uVignetteStrength: { value: 0.35 }, }, vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` uniform sampler2D tDiffuse; uniform float uTime; uniform float uGrainAmount; uniform float uVignetteStrength; varying vec2 vUv; float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233)) + uTime) * 43758.5453); } void main() { vec3 col = texture2D(tDiffuse, vUv).rgb; // Радіальна віньєтка: затемнення до кутів vec2 uv2 = vUv * (1.0 - vUv.yx); float vig = uv2.x * uv2.y * 15.0; vig = clamp(pow(vig, 0.25), 0.0, 1.0); col *= mix(1.0, vig, uVignetteStrength); // Адитивне кіноплівкове зерно, пересіяне щокадру через uTime float grain = (hash(vUv) - 0.5) * uGrainAmount; col += grain; gl_FragColor = vec4(col, 1.0); } `, }; // У циклі рендерингу оновлюйте uTime щокадру для анімованого зерна: grainPass.uniforms.uTime.value = performance.now() * 0.001;

8. Продуктивність та порядок рендерингу

Кожен прохід — це повноекранна відмальовка зі своєю вартістю фрагментного шейдера — на canvas 4K це 8.3 мільйона викликів шейдера за прохід, за кадр. Конвеєр із п'ятьма проходами на 4K легко може перевищити вартість самої геометрії сцени. Порядок і вибір роздільної здатності важать більше, ніж зазвичай очікують.

Приблизна відносна вартість на повноекранний прохід (1080p, GPU середнього рівня):

FXAA ≈ 0.3–0.5 мс
Bloom (5 рівнів mip, половинна роздільна здатність) ≈ 0.8–1.5 мс
SSAO (16 семплів) ≈ 1.5–3.0 мс
Власний одно-семпловий ShaderPass (зерно/віньєтка/корекція) ≈ 0.1–0.3 мс

Правило бюджету: вся постобробка ≤ 4–5 мс, щоб утримати 60 FPS (бюджет кадру 16.6 мс) поряд із рендерингом сцени.
Типова помилка: невідповідність renderer.outputColorSpace припущенням композитора. Якщо кольори виглядають вимитими або перенасиченими після додавання постобробки, перевірте, що renderer.outputColorSpace = THREE.SRGBColorSpace і що OutputPass — останній прохід у ланцюжку: тоне-мапінг має відбуватися лише один раз, в самому кінці.