Декали — DecalSet, DecalTrail
Метки, проецируемые на поверхности: пулевые отверстия, следы ног, кровь, подпалины, постеры, следы
торможения. DecalSet — один узел, владеющий до max декалей, которые рисуются как ОДИН renderable;
каждая декаль — бокс, проецирующий своё изображение на непрозрачную сцену внутри него, так что она
оборачивает углы пол–стена, лестницы и кривые поверхности без доступа к их геометрии. Ложится только
на непрозрачные поверхности — никогда на частицы, стекло или другие декали. DecalTrail кладёт
непрерывную полосу декалей вдоль пути (след торможения шины, протащенное тело). Для эффектов, которые
летят, см. Particles.
Обзор
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()Набор
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 … 7type 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, когда хост не рисует декали.
Координаты — в пространстве набора: набор, добавленный в корень сцены без трансформа, берёт мировые координаты (обычный случай); набор, подвешенный к двери, едет с ней и берёт локальные координаты двери.
декаль читает глубину сцены, которую движок держит привязанной, только пока на экране есть набор с
живыми декалями. scene.setDepthEffects(false) выключает эффекты, читающие глубину, по всему
движку — наборы декалей тогда ничего не рисуют (см. Scene).
хост без декалей предупреждает один раз ([decals] this host has no createDecalSet — decals are not drawn); spawn тогда возвращает −1, а count равен 0.
Одна декаль — размер, жизнь, вид
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 не даёт попаданию в пол залезть на стену, с которой он встречается: поверхности,
повёрнутые слишком далеко от оси проекции, гаснут.
Размещение
// на поверхности: точка на ней и нормаль из неё (попадание 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.
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
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 — след, который сходит на нет, а не обрывается.
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)) и используй его локальные координаты.