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