Изучение процедурной геометрии с помощью Three.js и WebGPU | Кодропс

Изучение процедурной геометрии с помощью Three.js и WebGPU | Кодропс



Примечание редактора: В сентябре этого года в Париже состоится первая конференция Three.js, где мы рассмотрим некоторые творческие работы сообщества Three.js. Сегодня мы рады представить Чиро Визуалсчьи поразительные эксперименты привлекли внимание X. В этом уроке он рассказывает нам о своих Художник-геометристизучая, как Three.js, WebGPU и TSL могут превратить простой штрих во что угодно: от светящихся кристаллов и расплавленных трещин до шелка полярного сияния и живого биолюминесцентного рифа. Здесь много красивых экспериментов, а также несколько умных идей по построению системы, лежащей в их основе. Мы надеемся, что вам понравится исследовать его вместе с нами.

🎟️Все еще нет билета? В рамках нашего партнерства с первой конференцией Three.js читатели Codrops могут использовать код CODROPS получить скидка 15%. Получите свой билет

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

Первый вариант нарисованных растений назывался РастительностьГенераторТриJSи сделал ровно одно: деревья вдоль штриха. После недели жизни с ним я понял, что самое интересное — это вовсе не деревья. Это был шов между «вот тропинка по поверхности» и «вот что на ней растет». Все, что находится слева от этого шва, остается неизменным навсегда: рейкастинг, передискретизация, отмена действий, орбита. Все, что справа, — это каждый раз новый арт-проект.

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

Вот что я хочу осветить:

  • Превращение перетаскивания в геометрию: выбор с помощью BVH, почему события необработанного указателя бесполезны в качестве пути, а также ошибка координатного пространства, которая съедает весь день, если вы не предвидите ее появления.
  • Контракт режима: двадцать строк, которые делают режим рисования подключаемым.
  • Оживление каждого слайдера: как построить систему, в которой перетаскивание ползунка плотности никогда ничего не восстанавливает, и почему это ограничение сделало режимы лучше, а не просто быстрее.
  • Четыре шейдера: пропускающий кварц, лента трещины черного тела, сложенный шелк полярного сияния и волна биолюминесценции, живущая в мировом пространстве.
  • Внешний вид: студия, построенная из шести светящихся прямоугольников и цепочки столбов наверху.

Все, что приведено ниже, имеет демонстрационную страницу, которую вы можете открыть, потыкать и посмотреть самостоятельно. Они живут под /demos в репо. Бегать npm run dev и пойти в /demos/; они используют производственный код везде, где это возможно, и я буду ссылаться на соответствующий код по ходу дела. Честно говоря, я создал их для этой статьи, а затем продолжал использовать для отладки, что, вероятно, говорит вам кое-что о том, как мне следовало работать все это время. Изучите все демоверсии.

Концепция

Эталонным изображением в моей голове была жеода: тусклый серый камень, который кто-то разрубил, а внутри — яростный фиолетовый интерьер, которому здесь не место. В этом контрасте и заключается вся идея. Сфера холста нарочито скучна: атласный базальт, шепот прозрачного лака, потому что кристаллы должны быть единственной интересной вещью в раме.

Это решение повлияло на кодовую базу больше, чем я ожидал. Если холст матовый и темный, то все, что вы на нем рисуете, должно давать свой свет: пропускающий, излучающий, аддитивные ленты, точечные светильники, идущие по мазку. В каждом режиме появлялась какая-то версия «эта штука светится изнутри» не потому, что я это планировал, а потому, что все, что не светилось, просто исчезало в сфере.

Реализация

Одно событие указателя, один образец поверхности

Рисование на сетке — это raycast, и это самая простая часть. На самом деле каждому режиму нужно нечто большее, чем просто хитпойнт:

export interface SurfaceSample {
  /** World-space hit — used only for the live stroke preview beads. */
  position: THREE.Vector3;
  normal: THREE.Vector3;
  /** Anchor-space hit, captured at pick time. */
  local: THREE.Vector3;
  localNormal: THREE.Vector3;
}

Из нормали мы строим касательную рамку, и в этой рамке все размещается. Кристалл отклоняется n вращаясь в сторону t1/t2; трещина расширяется вбок вдоль t1 × n; усик анемоны распускается веером n. Это три строчки, и на них стоит посмотреть сразу:

const t1 = new THREE.Vector3(1, 0, 0);
if (Math.abs(n.x) > 0.9) t1.set(0, 1, 0); // pick an axis that isn't parallel to n
t1.cross(n).normalize();
const t2 = new THREE.Vector3().crossVectors(n, t1);

Math.abs(n.x) > 0.9 охранник — единственный интересный момент. Пересеките два параллельных вектора, и вы получите нулевой вектор, а нормированный нулевой вектор равен NaNи NaN в матрице означает, что экземпляр молча исчезает. Эту ошибку вы найдете на полюсах сферы в 2 часа ночи.

Для ковыряния себя прошиваю three-mesh-bvh глобально и постройте дерево границ на холсте:

(THREE.Mesh.prototype as any).raycast = acceleratedRaycast;
// ...
(raycaster as any).firstHitOnly = true;

firstHitOnly В каком-то смысле флаг имеет большее значение, чем BVH. Без него рейкастер собирает каждый пересечение и сортирует их, а рисование на замкнутой сфере означает, что вы также попадаете на заднюю грань. При этом обход прекращается в тот момент, когда у него есть ближайший треугольник. Демонстрационная страница ниже выбирает сферу из 33 600 треугольников и печатает стоимость одного выбора с флажком, позволяющим удалить дерево границ и вернуться к тестированию каждого треугольника.

Посмотреть демонстрацию «Выбор поверхности и касательная рамка»

События указателя не являются путем

Вот вещь, о которой вам никто не говорит. События указателя приходят в фиксированное ставкане фиксированный расстояние. Медленно двигайте рукой, и вы получите сорок образцов на сантиметр. Проведите им, и вы получите четыре по всей сфере. Если вы рассыпете по одному кластеру кристаллов на образец (что, очевидно, является первой реализацией, которую я поставлял около часа), вы получите плотный сгусток там, где пользователь колебался, и вообще ничего там, где он двигался.

Таким образом, каждый режим выполняет повторную выборку, прежде чем что-либо построить:

let travelled = 0;
let next = 0;
for (let i = 0; i  0) travelled += samples[i].local.distanceTo(samples[i - 1].local);
  if (travelled 

Вот и все. Пройдитесь по необработанным образцам, отслеживайте накопленную длину дуги и выдавайте точку каждый раз. PATH_STEP мировые единицы. Все остальное выбрасывается. Кристаллы сбрасывают кластер каждые 0,0625 единицы, а лента трещины перемещается по своей центральной линии каждые 0,025. travelled Значение, которое оно возвращает, позже также выполняет двойную функцию, поскольку это также координата расстояния, которая управляет всей анимацией роста.

Демонстрация ниже показывает довольно типичный штрих: 46 необработанных образцов с промежутками от 0,002 до 0,089 мировых единиц. Это сорокачетырехкратная разница между самой тайтовой и самой лузовой парой, и все это за один удар. И это даже не особенно беспорядочный случай.

Посмотреть демонстрацию «От событий указателя до центральной линии»

Холст движется, пока вы рисуете

Сфера плавно плавает и покачивается по медленной синусоидальной волне во время вращения, потому что полностью статичный объект выглядит просто как снимок экрана. В первой сборке покраска работала нормально, вплоть до тех пор, пока я не отпустил мышь, не увидел, как кристаллы растут, а затем увидел, как они соскальзывают с поверхности, как наклейки на мокрое лобовое стекло.

Образцы находились в мировом пространстве. Конечно, были, это то, что дает вам рейкастер. Но мировое пространство действительно имеет смысл только в момент удара. К моменту завершения анимации роста сфера повернулась на 15°. Таким образом, каждый образец немедленно преобразуется в локальное пространство привязки, во время выбора:

this.anchor.updateWorldMatrix(true, false);
this.invAnchor.copy(this.anchor.matrixWorld).invert();
return {
  position: h.point.clone(),
  normal,
  local: h.point.clone().applyMatrix4(this.invAnchor),
  localNormal: normal.clone().transformDirection(this.invAnchor),
};

Важная часть заключается в том, что инверсия пересчитывается при каждом выборе, а не кэшируется для всего хода. Так и должно быть, потому что привязка перемещается между событиями указателя. Штрих, на рисование которого уходит две секунды, можно было бы протестировать на сотне немного разных матриц. Если вы кэшируете инверсию один раз при указателе вниз, вы получаете более тонкую версию того же мазка. Он проявляется только при медленных и осторожных движениях, и именно здесь люди его и заметят.

Посмотреть демонстрацию «Указание на движущемся холсте»

Контракт режима

Теперь шов. Вот и вся история расширяемости, и она умещается на одном экране:

export interface StrokeInstance {
  group: THREE.Group;
  update(dt: number, time: number): void;
  finishGrowth(): void;
  applySettings?(settings: unknown): void;
  dispose(): void;
}

export interface PaintMode {
  readonly id: string;
  createStroke(samples: SurfaceSample[], seed: number, settings: S): StrokeInstance;
}

Режим — это всего лишь фабрика, превращающая сэмплы в живой объект. Родители приложения group под
плавучий якорь, звонки update каждый кадр, звонки dispose при отмене, а в противном случае это не требуется
знать, что внутри. Регистрация одного из них — это строка в записи:

private modes: Record> = {
  'Crystals': crystalMode as PaintMode,
  'Molten fissures': fissureMode as PaintMode,
  'Aurora silk': auroraMode as PaintMode,
  'Bioluminescent reef': reefMode as PaintMode,
};

Необязательный applySettings? Это самая интересная часть, и я вернусь к ней через секунду.
Короткая версия заключается в том, что режим, который может изменить свой внешний вид на основе новых настроек, получает живые ползунки для
бесплатно. Режим, который по-прежнему не может иметь возможность вернуться к перестройке без принудительного запуска приложения.
чтобы узнать что-нибудь особенное об этом режиме.

Отдельно стоит упомянуть обработку семян. Каждый штрих сохраняет стабильный индекс, а эффективное начальное значение смешивает его с глобальным начальным значением:

private effectiveSeed(index: number): number {
  return ((this.settings.seed * 2654435761) ^ (index * 40503 + 1)) >>> 0;
}

Мультипликативная константа Кнута, нечетный множитель индекса и XOR. Ничего особо умного. В результате каждый штрих выглядит иначе, чем его соседи, в то время как вся сцена по-прежнему последовательно перетасовывается при изменении глобального начального числа. У вас не получится каждый удар прыгать в одну и ту же новую аранжировку.

Генерировать на максимуме, отбирать ползунком

Вот ограничение, которое я установил для себя, и в итоге оно оказалось одним из самых продуктивных решений в
проект: перетаскивание ползунка никогда не должно выделять что-либо.

Наивный ползунок плотности перемещает обводку и перестраивает ее. Это прекрасно работает с двадцатью
экземпляры. В две тысячи, с lil-gui стрельба onChange шестьдесят раз в секунду, это становится
слайдшоу. Хуже того, при каждой перестройке случайные числа перебрасываются, поэтому геометрия мерцает пока ты
тащить.

Вы не можете судить взгляд, который не стоит на месте.

Итак, правило простое: сгенерируйте все на максимуме слайдера один раз, а затем позвольте слайдерам решать.
что видно.

export const MAX_DENSITY = 16;
export const MAX_SHARDS = 16;

Каждый кристалл хранится как его генеративные параметрыа не как запеченная матрица. Мы сохраняем свой кластер
положение, касательная рамка и несколько стабильных случайных значений от 0 до 1:

interface CrystalInstance {
  anchor: THREE.Vector3;  // cluster's anchor-local surface point
  n: THREE.Vector3; t1: THREE.Vector3; t2: THREE.Vector3;
  clusterRnd: number;     // density culling rank
  shardIndex: number;     // shard-count culling rank
  offAz: number; offFrac: number; heightBase: number; jitterRnd: number;
  leanRnd: number; leanAz: number; spin: number;
  hueRnd: number; satRnd: number; clearRnd: number;
  // ...derived cache, rewritten by applySettings()
}

applySettings затем перекомпоновывает каждую матрицу и цвет на месте. Отбраковка — это просто сравнение
против ранга, который экземпляр имел с момента его создания:

const densityFrac = s.clusterDensity / MAX_DENSITY;
inst.visible =
inst.clusterRnd 

Отбракованный экземпляр получает матрицу нулевого масштаба. Он остается в буфере, остается в вызове отрисовки, использует ту же память, что и кадр назад, и требует вызова вершинного шейдера, который создает вырожденный треугольник. На любом графическом процессоре, выпущенном в этом десятилетии, это практически бесплатно.

Стабильный рейтинг важен. Поднимите ползунок плотности, и те же кристаллы появятся в том же порядке. Существующие кластеры не перемещаются. Оно читается как «больше этого», а не «совершенно другое». Та же идея применима и к смеси прозрачного кварца:

inst.clearRnd converts the same crystals every time.

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



Источник

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх