⚡ Урок · 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 — останній прохід у ланцюжку: тоне-мапінг має відбуватися лише один раз, в самому кінці.

Часті запитання

Чого я навчуся в цьому уроці?

Повний гід із постобробки у Three.js через EffectComposer — RenderPass, UnrealBloomPass, SSAOPass, FXAA/SMAA, кіноплівкове зерно та власний GLSL ShaderPass, з порадами щодо продуктивності.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Як працює EffectComposer, Налаштування RenderPass + OutputPass, UnrealBloomPass: сяйво на яскравих ділянках, SSAOPass: екранне оточуюче затінення, Згладжування: FXAA проти SMAA проти TAA, Написання власного ShaderPass, Кіноплівкове зерно та віньєтка, Продуктивність та порядок рендерингу.

Скільки часу займає цей урок?

Цей урок займає приблизно 17 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.