Постобробка ефектів у Three.js
Звичайний виклик renderer.render(scene, camera) дає
вам одне зображення за кадр без жодного простору для
кінематографічного фінішу. EffectComposer у Three.js
дозволяє з'єднувати ланцюжок повноекранних шейдерних
проходів (passes) поверх цього зображення —
bloom, оточуюче затінення, згладжування, кольорокорекцію — щоб
перетворити плаский рендер на щось, що виглядає готовим до
випуску. У цьому гіді ми побудуємо повний конвеєр композитора,
розберемо, що насправді робить кожен вбудований прохід під
капотом, і покажемо, як написати власний
ShaderPass.
1. Як працює EffectComposer
EffectComposer (з
three/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 (даючи
м'який, широкий спад замість рівномірного розмиття), а потім
адитивно накладає розмитий результат на оригінальне зображення.
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 апроксимує це для кожного пікселя,
використовуючи буфери глибини й нормалей сцени: для кожного
пікселя вона семплює півсферу сусідніх точок у просторі
перегляду та перевіряє, скільки з них перекриті геометрією, що
ближче до камери.
для кожного семпла 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);
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);
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 легко може перевищити вартість самої геометрії сцени. Порядок і вибір роздільної здатності важать більше, ніж зазвичай очікують.
-
Рекомендований порядок:
RenderPass→SSAOPass(потребує сирих глибини/нормалей) →UnrealBloomPass(потребує освітленого HDR-кольору) → кольорокорекція / зерно / віньєтка (дешеві, косметичні) → згладжування (FXAAабоSMAA, обов'язково останнім, щоб згладжувати фінальне композитне зображення, а не проміжне) →OutputPass(тоне-мапінг + кольоровий простір). -
Рендерте bloom на половинній чи чвертній роздільній
здатності.
UnrealBloomPassуже зменшує роздільність всередині, але обмеженняcomposer.setPixelRatio()до 1.5–2 на дисплеях високої щільності зберігає реальну пропускну здатність у кожному проході. -
Вимикайте проходи, які не потрібні кадру,
не видаляйте й не додавайте їх заново — перемикайте
pass.enabled = false, що пропускає прохід практично без накладних витрат порівняно з перебудовою композитора. - Об'єднуйте дешеві косметичні проходи в один ShaderPass там, де можливо (наприклад, зерно + віньєтка + кольорокорекція в одному фрагментному шейдері, як у розділі 7) замість ланцюжка з трьох окремих повноекранних відмальовок.
-
Профілюйте через
renderer.info.renderта накладку часу кадру GPU (наприклад, Spector.js або вбудований WebGL-інспектор браузера), перш ніж додавати проходи навмання — SSAO та bloom зазвичай є двома найдорожчими пунктами.
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, не потрібна.