Декали — DecalSet, DecalTrail

Метки, проецируемые на поверхности: пулевые отверстия, следы ног, кровь, подпалины, постеры, следы торможения. DecalSet — один узел, владеющий до max декалей, которые рисуются как ОДИН renderable; каждая декаль — бокс, проецирующий своё изображение на непрозрачную сцену внутри него, так что она оборачивает углы пол–стена, лестницы и кривые поверхности без доступа к их геометрии. Ложится только на непрозрачные поверхности — никогда на частицы, стекло или другие декали. DecalTrail кладёт непрерывную полосу декалей вдоль пути (след торможения шины, протащенное тело). Для эффектов, которые летят, см. Particles.

Обзор

TypeScript
const scene = new Scene()
scene.add(Light.sun())
const ground = Mesh.plane({ material: Material.lit({ color: '#a09c90' }), normal: [0, 1, 0], scale: 20 })
ground.aspect(Shape, { box: [20, 0.01, 20] }).aspect(Physics, { motion: 'static' })
scene.add(ground)
scene.open()

const main = async () => {
  const atlas = await Texture.load(asset('./decals.png'))     // две ячейки рядом
  const holes = new DecalSet({ map: atlas, sheet: [2, 1], size: 0.2, max: 128 })
  scene.add(holes)

  const hit = Physics.raycast([0, 5, 0], [0, -1, 0])          // выстрел прямо вниз
  if (hit) holes.spawn(hit.point, hit.normal, { frame: 'random' })
  holes.spawn([1, 0, 0], [0, 1, 0], { frame: 1, size: [0.14, 0.3], up: [0, 0, -1], life: 5, fadeOut: 1 })
  console.log(`decals alive: ${holes.count} of ${holes.max}`)
}
main()

Набор

TypeScript
new DecalSet(options?: DecalSetOptions)

set.spawn(point: Vec3Like, normal: Vec3Like, options?: DecalSpawnOptions): number   // слот
set.place(placement: DecalPlacement): number       // декаль по полному фрейму
set.update(slot, placement: Partial<DecalPlacement>): this   // переместить / перестилизовать декаль из place()
set.remove(slot): this
set.clear(): this
set.count: number                                  // декалей живо сейчас
set.max: number                                    // бюджет слотов
set.material: Material
set.renderPriority = 3                             // только запись, 0 … 7
TypeScript
type DecalSetOptions = DecalOptions & {   // каждое поле DecalOptions — умолчание набора
  map?: Texture                 // атлас (изображение материала по умолчанию)
  sheet?: number | [cols, rows] // сетка атласа: n = n столбцов в ОДНОЙ строке; по умолчанию 1 (вся текстура)
  normalMap?: Texture           // атлас нормалей в касательном пространстве на той же сетке → РЕЛЬЕФНАЯ декаль (ниже)
  bump?: number                 // сила рельефа; по умолчанию 1
  edge?: number                 // 0..1 мягкая доля половины глубины бокса с обоих концов; по умолчанию 0.3
  angleFade?: number            // косинус угла поверхности, после которого она гаснет; по умолчанию 0.3 (≈72°), 0 = нет
  emissive?: number             // HDR-усиление изображения; по умолчанию 0
  material?: Material           // вместо Material.decal({ map, normalMap, … }) — соблюдай контракт decal.mat
  max?: number                  // слоты — максимум живых декалей одновременно; по умолчанию 256
  renderPriority?: number       // по умолчанию 3
  name?: string
}

map, normalMap, bump, edge, angleFade и emissive настраивают материал по умолчанию; с собственным material они игнорируются. В отличие от Particles, где sheet: 8 — сетка 8×8, атлас декалей, заданный одним числом, — одна строка: sheet: 4 — четыре ячейки рядом.

Слоты — кольцо: полный набор переиспользует самую старую декаль, так что max — бюджет на экране. spawn / place возвращают слот или −1, когда хост не рисует декали.

Координаты — в пространстве набора: набор, добавленный в корень сцены без трансформа, берёт мировые координаты (обычный случай); набор, подвешенный к двери, едет с ней и берёт локальные координаты двери.

Note

декаль читает глубину сцены, которую движок держит привязанной, только пока на экране есть набор с живыми декалями. scene.setDepthEffects(false) выключает эффекты, читающие глубину, по всему движку — наборы декалей тогда ничего не рисуют (см. Scene).

Host-gated

хост без декалей предупреждает один раз ([decals] this host has no createDecalSet — decals are not drawn); spawn тогда возвращает −1, а count равен 0.

Одна декаль — размер, жизнь, вид

TypeScript
type DecalOptions = {
  size?: number | [w, h]        // размер изображения на поверхности, мировые единицы; по умолчанию 0.2
  depth?: number                // как далеко перед точкой и за ней она ещё ложится; по умолчанию min(w, h)
  frame?: number | 'random'     // ячейка атласа, по строкам от верхнего левого угла; по умолчанию 0
  tint?: ColorInput             // умножается на изображение; по умолчанию белый
  opacity?: number              // 0..1 поверх альфы tint; по умолчанию 1
  life?: number                 // секунд до исчезновения; 0 = пока не переиспользуется (по умолчанию)
  fadeIn?: number               // секунды; по умолчанию 0
  fadeOut?: number              // секунды в конце `life` (игнорируется при life 0); по умолчанию 0
  gradient?: [bottom, top]      // множители непрозрачности на −Y / +Y крае изображения; по умолчанию [1, 1]
  caps?: [bottom, top]          // наклон этих краёв (уклон в единичном пространстве); по умолчанию [0, 0]
}

angleFade не даёт попаданию в пол залезть на стену, с которой он встречается: поверхности, повёрнутые слишком далеко от оси проекции, гаснут.

Размещение

TypeScript
// на поверхности: точка на ней и нормаль из неё (попадание raycast, постановка ноги с Vec3.up)
type DecalSpawnOptions = DecalOptions & {
  up?: Vec3Like                 // мировое направление верха изображения, спроецированное на поверхность; по умолчанию случайный поворот
  rotation?: number             // дополнительный поворот вокруг нормали, радианы; по умолчанию случайный без `up`, иначе 0
}
// по полному фрейму: как узел, смотрящий ВНУТРЬ поверхности (его −Z проецирует, +Y — верх изображения)
type DecalPlacement = DecalOptions & {
  position: Vec3Like
  rotation?: QuatLike | Vec3Like   // кватернион или углы Эйлера в градусах (YXZ), как Node.eulerAngles; по умолчанию identity
}

spawn — геймплейный путь: след ноги передаёт направление движения как up. place — для декали, которую ты будешь двигать или перестилизовывать (пятно, поставленное в редакторе, маркер): update(slot, { … }) меняет поля, которые ему даны, остальные сохраняет, как и время рождения декали (её часы жизни). update знает только слоты, созданные place.

TypeScript
const stain = holes.place({ position: [2, 0.01, 2], rotation: [-90, 0, 0], size: [0.6, 0.6], tint: '#5a3a20' })
holes.update(stain, { position: [2.5, 0.01, 2] })
holes.remove(stain)

Рельефные декали — normalMap

С normalMap декаль искривляет освещение поверхности вместо того, чтобы рисовать цвет, так что след ноги или вмятина видны на любой поверхности без собственного цвета. Атлас нормалей — в касательном пространстве (+X = право изображения, +Y — его верх) и должен быть линейным — Texture.load(src, { srgb: false }) или Texture.fromPixels(…, { srgb: false }). Вместе с map цвет домножается (кратер темнеет по альфе карты). Солнечный член применяется и в тени: декаль не читает тень.

Непрерывные полосы — DecalTrail

TypeScript
new DecalTrail(set: DecalSet, options: DecalTrailOptions)
trail.add(point: Vec3Like, normal: Vec3Like, opacity = 1): number   // слот нового сегмента или −1
trail.end(point?: Vec3Like, normal = Vec3.up): this
trail.set / trail.options

type DecalTrailOptions = Omit<DecalOptions, 'size' | 'caps' | 'gradient'> & {
  width: number                 // ширина полосы на поверхности, мировые единицы
}

Скармливай ей точки по мере движения: каждая новая точка становится одним сегментом-боксом от предыдущей, с верхом изображения вдоль движения. Стыки срезаны под угол — каждый бокс срезан вдоль биссектрисы, которую он делит с соседом, — так что на изгибе нет перекрытия, затемняющего внешнюю сторону, и нет щели на внутренней; поворот круче примерно 120° (срез круче 60°) разрывает полосу и начинает новую. opacity, переданная с каждой точкой, интерполируется вдоль её сегмента, так что след может темнеть по мере углубления заноса и гаснуть к его концу. Первый add после старта или end() только закрепляет полосу (−1), как и точка, которая не сдвинулась. end(point) рисует один последний сегмент до point с непрозрачностью 0 — след, который сходит на нет, а не обрывается.

TypeScript
const marks = new DecalSet({ map: tread, max: 900 })
scene.add(marks)
const skid = new DecalTrail(marks, { width: 0.25, tint: '#202020' })
// на колесо, каждые 0.25 м, пока оно скользит:
skid.add(contactPoint, contactNormal, 0.12 + 0.88 * slip)
// когда занос заканчивается:
skid.end(contactPoint)

Сегменты — декали набора: то же кольцо, тот же бюджет max: набор на 900 держит 900 сегментов по всем полосам, нарисованным из него.

Подводные камни

  • Карта нормалей, загруженная как цвет (sRGB по умолчанию), искривляет свет неправильно ➜ загружай её с { srgb: false }.
  • update() на слоте из spawn() ничего не делает ➜ place() декали, которые будешь двигать.
  • Метки на движущемся объекте: набор в корне сцены берёт мировые координаты и остаётся позади ➜ подвесь набор к объекту (door.add(set)) и используй его локальные координаты.

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

  • Material — Material.decal({ … }), материал декали по умолчанию
  • Physics — Physics.raycast для точки попадания и нормали
  • Texture — атласы, Texture.fromPixels, srgb: false
  • Particles — эффекты, которые летят, а не прилипают
  • Scene — setDepthEffects