Постобработка

Вид 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 — первое задаёт уровень, второе меню графики, а этот объект — арт-направление поверх обоих.

Обзор

TypeScript
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()                                     // назад к собственному виду движка, все эффекты выключены

Запись в него

TypeScript
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 отправляет полосу грейдинга немедленно (одно запекание), а не в конце обработчика.

Тонмаппинг

TypeScript
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: уход в белое, вспышка, грейд день-в-сумерки.

Цветокоррекция

TypeScript
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] каждое

Регулировки грейдинг-пакета, применяемые в этом порядке перед оператором:

  1. whiteBalance — temperature по оси синий / жёлтый (-1 это 50 000 K, холодно; 1 это 2 000 K, тепло), tint по оси зелёный / пурпурный.
  2. shadows / midtones / highlights — линейный RGB-множитель на тональную зону: [1, 1, 1.05] на тенях и [1.05, 1, 0.95] на светах — классическое разделение холодные тени / тёплые света. ranges говорит, где зоны переходят друг в друга, в 0..1 яркости.
  3. slope / offset / power — ASC CDL, то есть lift / gamma / gain грейдинг-инструмента: slope умножает (gain), offset прибавляет (lift), power — показатель степени (gamma). В логарифмическом пространстве.
  4. contrast — в логарифмическом пространстве, вокруг среднего серого.
  5. vibrance, затем saturation — vibrance щадит то, что уже насыщено, saturation — нет.
  6. curves — поканальная кривая в трёх числах: гамма на тенях, точка, где тени заканчиваются, масштаб на светах.

Каждое значение по умолчанию — тождество, так что сцена, не задавшая ни одного из них, — это собственный вид движка и не стоит никакой таблицы вообще.

LUT из грейдинг-инструмента

TypeScript
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

TypeScript
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(...).

Виньетка

TypeScript
vignette?: boolean | {
  midPoint?: number        // как далеко от центра начинается затемнение, 0..1; по умолчанию 0.5
  roundness?: number       // 0 следует соотношению сторон, 1 — круг; по умолчанию 0.5
  feather?: number         // мягкость края, 0..1; по умолчанию 0.5
  color?: ColorInput       // цвет на краю; по умолчанию чёрный
}

Юниформ грейдинг-прохода, вне таблицы: анимировать свободно (сердцебиение, пульс урона).

Ambient occlusion

TypeScript
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-отражения

TypeScript
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, и этот материал отражает, пока опция включена, и откатывается к отражению на основе изображения, пока она выключена. Луч на пиксель — реальная цена на телефоне, пока не измерена.

Глубина резкости

TypeScript
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, записываемый каждый кадр, из расстояния до того, на что смотрит камера.

TypeScript
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, а не поле здесь.

Смотрите также

  • Scene — SceneOptions: свет, небо, туман, качество рендера
  • Camera — exposureCompensation: экспозиция света, в отличие от exposure здесь
  • Material — roughness / metallic: с чем работают отражения и bloom
  • Texture — текстура dirt для bloom