Постобработка
Вид 3D-сцены как один объект: scene.postProcessing. В нём то, что держат volume в Unity и
post-process volume в Unreal — тонмаппинг, цветокоррекция, LUT, bloom, виньетка, ambient
occlusion, screen-space-отражения, глубина резкости — так что любое из них ищи здесь.
Задаёшь его при создании (SceneOptions.postProcessing) и меняешь вживую потом.
Чего здесь нет, намеренно: собственный свет сцены (ibl, environmentIntensity,
exposureCompensation, fog) и качество её рендера (antialias, renderScale,
dynamicResolution) остаются на Scene — первое задаёт уровень, второе
меню графики, а этот объект — арт-направление поверх обоих.
Обзор
const scene = new Scene({
skybox: '#3a78d8',
postProcessing: {
toneMapping: 'neutral',
whiteBalance: { temperature: 0.15 },
contrast: 1.15,
saturation: 1.1,
lut: asset('./look.cube'),
bloom: { intensity: 0.3 },
vignette: { midPoint: 0.4, feather: 0.6 },
ambientOcclusion: { radius: 0.6 },
depthOfField: { focusDistance: 4, aperture: 2, strength: 3 },
},
})
scene.add(Light.sun(), Mesh.box({ material: Material.lit({ color: '#ff5555' }) }))
scene.open()
scene.postProcessing.saturation = 0.6 // одно поле, вживую
scene.postProcessing.apply({ toneMapping: 'filmic', bloom: { intensity: 0.1 } }) // несколько: только названные
scene.postProcessing.depthOfField = { focusDistance: 2 } // перевод фокуса: пиши каждый кадр
scene.postProcessing.reset() // назад к собственному виду движка, все эффекты выключеныЗапись в него
scene.postProcessing // объект (свойство только для чтения: присваивание бросает исключение)
scene.postProcessing.<field> = value // одно поле
scene.postProcessing.apply(options): this // поля, названные в `options`, и ничего больше
scene.postProcessing.reset(): this // каждое поле к значению по умолчанию
scene.postProcessing.<field> // текущее значение (выключенная возможность читается как `false`)apply— слияние на каждом уровне.apply({ bloom: { intensity: 0.4 } })меняет одну ручку bloom и сохраняет остальные;apply({ whiteBalance: { tint: 0.1 } })сохраняет температуру. Пропущенное поле не трогается.SceneOptions.postProcessing— тот жеapplyповерх значений по умолчанию.- Возможность — это
true,falseили объект.trueвключает её со значениями по умолчанию (или с ручками, которые у неё уже были);falseвыключает; объект включает её с этими ручками поверх текущих. Чтение возможности даёт полный набор её ручек либоfalse. - Присваивать нечего.
scene.postProcessing = { ... }бросает исключение — запись целого объекта должна была бы решить, что делать с полями, которые она не называет, а оба прочтения («сбросить», «сохранить») разумны.applyсохраняет,resetсбрасывает, и ты говоришь, что именно.
Два вида полей, две цены
Движок обращается с полями двумя способами, и это важно для того, что можно анимировать:
- Полоса грейдинга —
toneMapping,exposure,whiteBalance,contrast,saturation,vibrance,shadows/midtones/highlights/ranges,slope/offset/power,curves,lut— запекается в ОДНУ 3D-таблицу поиска, которую кадр сэмплирует в любом случае. За кадр это не стоит ничего; изменение перезапекает таблицу 32³ на CPU, несколько миллисекунд. Поэтому записи полей склеиваются: запись трёх полей в одном обработчике запекает один раз, в конце обработчика, а значения квантуются до 1/1000, так что твин, который едва движется, не запекает ничего. Переход между двумя видами за секунду (несколько десятков запеканий) — нормально; поле грейдинга, гоняемое каждый кадр минуту, — не для этого. - Живая полоса —
bloom,vignette,ambientOcclusion,screenSpaceReflections,depthOfField— набор опций вида: пишется в движок сразу, анимировать свободно.
apply отправляет полосу грейдинга немедленно (одно запекание), а не в конце обработчика.
Тонмаппинг
toneMapping?: 'aces' | 'neutral' | 'linear' | 'filmic' // по умолчанию 'aces'
exposure?: number // пост-экспозиция, в стопах; по умолчанию 0Сцена освещена в физических единицах (солнце в 100 000 люкс, лампа в 800 люмен). Оператор тонмаппинга — то, что сворачивает этот диапазон в диапазон дисплея: всё до него работает со светом, всё после — с пикселями, которые ты видишь.
'aces'— ACES legacy от Filament, по умолчанию. Киношный вид: яркие цвета обесцвечиваются к белому, так что HDR-огонь выглядит бледным.'neutral'— Khronos PBR Neutral. Сохраняет оттенок и насыщенность до очень ярких значений; выбор, когда цвета должны оставаться такими, как говорит материал (продуктовые кадры, плоскоцветная графика).'linear'— обрезает каждый канал. Вид движка без тонмаппера: насыщенно, и всё выше белого потеряно.'filmic'— кривая Uncharted 2.
Есть две экспозиции, и они делают разное. Камерная
exposureCompensation масштабирует свет — bloom, туман и блик
солнца её видят — и ею экспонируют тёмный интерьер. postProcessing.exposure масштабирует
готовую картинку, после bloom: уход в белое, вспышка, грейд день-в-сумерки.
Цветокоррекция
whiteBalance?: { temperature?: number; tint?: number } // -1..1 каждое; по умолчанию 0
contrast?: number // 0..2; по умолчанию 1
saturation?: number // 0..2; по умолчанию 1
vibrance?: number // 0..2; по умолчанию 1
shadows?: [r, g, b]; midtones?: [r, g, b]; highlights?: [r, g, b] // по умолчанию [1, 1, 1]
ranges?: { shadows?: [start, end]; highlights?: [start, end] } // по умолчанию [0, 0.333], [0.55, 1]
slope?: [r, g, b]; offset?: [r, g, b]; power?: [r, g, b] // по умолчанию [1,1,1], [0,0,0], [1,1,1]
curves?: { shadowGamma?: [r, g, b]; midPoint?: [r, g, b]; highlightScale?: [r, g, b] } // по умолчанию [1, 1, 1] каждоеРегулировки грейдинг-пакета, применяемые в этом порядке перед оператором:
whiteBalance—temperatureпо оси синий / жёлтый (-1 это 50 000 K, холодно; 1 это 2 000 K, тепло),tintпо оси зелёный / пурпурный.shadows/midtones/highlights— линейный RGB-множитель на тональную зону:[1, 1, 1.05]на тенях и[1.05, 1, 0.95]на светах — классическое разделение холодные тени / тёплые света.rangesговорит, где зоны переходят друг в друга, в 0..1 яркости.slope/offset/power— ASC CDL, то есть lift / gamma / gain грейдинг-инструмента:slopeумножает (gain),offsetприбавляет (lift),power— показатель степени (gamma). В логарифмическом пространстве.contrast— в логарифмическом пространстве, вокруг среднего серого.vibrance, затемsaturation— vibrance щадит то, что уже насыщено, saturation — нет.curves— поканальная кривая в трёх числах: гамма на тенях, точка, где тени заканчиваются, масштаб на светах.
Каждое значение по умолчанию — тождество, так что сцена, не задавшая ни одного из них, — это собственный вид движка и не стоит никакой таблицы вообще.
LUT из грейдинг-инструмента
lut?: string | null // asset('./look.cube') или голое имя staged-файла; null убирает3D LUT, экспортированный DaVinci Resolve, Photoshop или любым инструментом, который пишет формат
.cube (LUT_3D_SIZE n, затем n³ строк r g b). Применяется последним, после оператора, в
display-referred sRGB — та же конвенция, что у Color Lookup в Unity, так что LUT, собранный по
скриншоту твоей сцены, применяется как задуман. DOMAIN_MIN / DOMAIN_MAX учитываются; 1D LUT
(LUT_1D_SIZE) отклоняется, а файл, который движок не может прочитать, пишет в лог [grading] …
и оставляет вид как был. Файл парсится один раз; его замена — такое же запекание, как любое
изменение грейдинга.
Bloom
bloom?: boolean | {
intensity?: number // сколько размытых светов добавляется обратно, 0..1; по умолчанию 0.2
threshold?: boolean // светится только то, что ярче 1.0 после экспозиции; по умолчанию true
highlight?: number // ограничение вклада одного пикселя; по умолчанию 1000
levels?: number // глубина цепочки размытия, 1..12: как далеко расходится свечение; по умолчанию 6
quality?: 'low' | 'medium' | 'high' | 'ultra' // число сэмплов размытия; по умолчанию 'medium'
lensFlare?: boolean // блики + гало вокруг самых ярких точек; по умолчанию false
dirt?: Texture | null // «грязная линза»: эта текстура умножается на свечение; по умолчанию нет
dirtStrength?: number // 0..1; по умолчанию 0.2
}Bloom читает HDR-картинку до тонмаппинга, поэтому знает, что солнце в тысячу раз ярче лампы.
threshold: false позволяет всему немного светиться — мягкий, сонный вид; highlight — лекарство,
когда несколько эмиссивных пикселей мерцают; dirt — вид запачканной линзы из Texture.load(...).
Виньетка
vignette?: boolean | {
midPoint?: number // как далеко от центра начинается затемнение, 0..1; по умолчанию 0.5
roundness?: number // 0 следует соотношению сторон, 1 — круг; по умолчанию 0.5
feather?: number // мягкость края, 0..1; по умолчанию 0.5
color?: ColorInput // цвет на краю; по умолчанию чёрный
}Юниформ грейдинг-прохода, вне таблицы: анимировать свободно (сердцебиение, пульс урона).
Ambient occlusion
ambientOcclusion?: boolean | {
intensity?: number // сила затемнения; по умолчанию 1
radius?: number // как далеко достаёт окклюзия, в метрах; по умолчанию 0.3
power?: number // контраст спада; > 1 стягивает его в складку; по умолчанию 1
quality?: 'low' | 'medium' | 'high' | 'ultra' // число сэмплов; по умолчанию 'medium'
}Контактное затемнение в складках и там, где предметы касаются земли. Без него свет на основе
изображения освещает складку ровно так же ярко, как открытую грань, и всё выглядит наклеенным на
пол, а не стоящим на нём. radius в мировых метрах — единственная ручка, которая обязана
следовать масштабу сцены: ~0.3 для предметов на столе, ~0.6–1 для двора из ящиков и контейнеров.
Он отрабатывает своё в интерьерах и плотных скоплениях предметов; на открытом солнечном дворе
почти бесполезен (он ослабляет только ambient-слагаемое, а солнечный свет — не ambient).
Screen-space-отражения
screenSpaceReflections?: boolean | {
maxDistance?: number // метры, которые луч проходит, прежде чем сдаться; по умолчанию 3
thickness?: number // метры, на которые поверхность считается продолжающейся за свою глубину; по умолчанию 0.1
stride?: number // пикселей на шаг луча: 1 точно и медленно, 2..4 обычно; по умолчанию 2
}Глянцевые поверхности отражают то, что на экране, через ray march по буферу глубины — мокрый пол
отражает лампы над ним. Ограничения классические: ничего за пределами экрана не отражается,
отражение заканчивается на краю кадра, а поверхность с низкой шероховатостью показывает зерно луча
(больший stride дешевле и зернистее). Там, где луч промахивается, остаётся отражение на основе
изображения.
Какие материалы отражают. Движок трассирует отражения только для материалов, чьё определение говорит
reflections : screenspace(свойство материала Filament; по умолчаниюdefault= только отражения на основе изображения). Встроенные материалы (Material.lit()и родственные) пока этого не говорят, так что с ними опция ложится на вид и ничего не отражается. Собственный шейдер приложения (Material.load) может: добавь строку в его.mat, и этот материал отражает, пока опция включена, и откатывается к отражению на основе изображения, пока она выключена. Луч на пиксель — реальная цена на телефоне, пока не измерена.
Глубина резкости
depthOfField?: boolean | {
focusDistance?: number // метры от камеры до плоскости в фокусе; по умолчанию 10
aperture?: number // f-число, на которое должно походить размытие; по умолчанию 2.8, меньше = мельче
strength?: number // множитель на размытие; по умолчанию 1 = физика объектива камеры
}Камера фокусируется на focusDistance; всё перед этой плоскостью и за ней размывается, как у
объектива, боке сохраняет свой свет (проход работает на HDR-картинке). aperture — f-число, на
которое размытие должно походить: движок масштабирует размытие вместо того, чтобы открывать
реальную диафрагму камеры, так что экспозиция не сдвигается.
Физика честная, а потому мягкая. Игровая камера — широкий объектив: поле зрения 60° — это объектив
21 мм, а широкий объектив размывает мало: при f/1.4 и фокусе 2.5 м предмет в 9 м размывается
примерно на 2 px кадра в 600 px. strength — художественный множитель для вида, который дал бы
объектив 50 мм; 3–4 при fov 60° читается как кино. Все три живые: перевод фокуса — это
focusDistance, записываемый каждый кадр, из расстояния до того, на что смотрит камера.
class FocusOnTarget extends System<'focus', Scene> {
target!: Node
update() {
const d = this.target.position.sub(this.scene.camera.position).length()
this.scene.postProcessing.depthOfField = { focusDistance: d }
}
}
scene.system(FocusOnTarget, { target: hero })Оверлеи, веб, HDR-дисплеи
- Оверлейная сцена (
scene.createOverlay()) берёт грейдинг родителя — один вид на картинку; её bloom, виньетка и остальная живая полоса — её собственные. - Веб-плеер и Web Lite рисуют на облегчённом 3D-движке, у которого нет стека постобработки: каждая опция здесь там принимается и игнорируется.
- На HDR-дисплее оператор растягивается до запаса яркости экрана; это сторона хоста,
настраиваемая через
SceneOptions.hdr, а не поле здесь.