Я люблю box-тени.

Четыре года назад я выяснил, что мой процессор M1 может рендерить безумное количество таких теней, поэтому решил извлечь из них максимум, и мне это удалось. Если вам интересно, как пользоваться box-тенями, чтобы создать современный стиль UX, то вы не по адресу. Но если вам нравятся творчество и эксперименты, то продолжайте чтение.

Я хочу поделиться худшими примерами того, что можно сделать при помощи box-теней в одном div. Примерами, которые не должны работать, однако почему-то работают. Но прежде чем приступить, нужно ответить на вопрос: что же такое box-тень?

Основы графического дизайна

Box-тень — это что-то вроде отбрасываемой тени (drop shadow). А отбрасываемая тень — это фильтр изображения, особенно популярный в графическом дизайне из-за своей универсальности в добавлении композиции иллюзии глубины.

Фильтр берёт растр изображения и сдвигает пиксели вдоль осей x и y. Он отрисовывает эти пиксели единым цветом за исходным изображением, создавая иллюзию глубины благодаря отбрасыванию контура изображения как «тени» на композиции.

Чтобы увидеть это в действии, мы можем использовать свойство CSS filter.

div {
  filter: drop-shadow(xOffset yOffset rgba(0, 0, 0, 0.5));
}

В инструментах наподобие Photoshop, Gimp, Figma и CSS есть множество разных реализаций фильтра отбрасываемой тени, каждая из которых обладает своим набором возможностей. Например, CSS поддерживает опциональное значение размытия, применяемого к отбрасываемой тени.

div {
  filter: drop-shadow(xOffset yOffset blurSize rgba(0, 0, 0, 0.5));
}

Создав несколько слоёв фильтров отбрасываемой тени, можно с лёгкостью добавлять в композицию интересную глубину.

Вот, например, двуслойные фильтры drop-shadow.

Красота.

А что такое box-тени?

Box-тень

Box-тень (box shadow) — это разновидность фильтра отбрасываемой тени со множеством компромиссов. Во-первых, название Box подразумевает, что фильтр поддерживает только прямоугольные формы. Например, попробуем применить его к предыдущему примеру.

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

Как известно, большинство пользовательских интерфейсов состоит из прямоугольников. И ещё известно, что умные люди придумали математические хаки для отрисовки очень скруглённых прямоугольников крайне малой ценой, что разработчики UI очень ценят, ведь эти хакерские прямоугольники могут быть такими округлыми, что кажутся кругами. И css реализация box-теней в CSS поддерживает этот математический хак.

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

Этот небольшой миксер демонстрирует разнообразие доступных форм (в оригинале статьи он интерактивен).

Это даёт огромную свободу в создании дизайна UI. Наложив их слоями друг на друга, можно получить потрясающие результаты. Можете поэкспериментировать с редактором границ самостоятельно.

Слои — это важное слово. Мы можем наложить друг на друга или объединить в цепочку множество box-теней в одном div. В примере выше это используется для задания цветов.

function randomizeAndColor(e) {
  randomize(e);
  const spread = Math.random() > 0.8 ? 2 : 0;
  const x1 = Math.floor(3 - Math.random() * 6) / (1 + spread);
  const y1 = Math.floor(3 - Math.random() * 6) / (1 + spread);
  const y2 = 2 + Math.floor(Math.random() * 4);
  const blur2 = 8 + Math.floor(Math.random() * 12);
  e.style.boxShadow = `${x1}px ${y1}px 0px ${spread}px ${getRandomPastelColor()}, 0 ${y2}px ${blur2}px #0006`;
}

Как не стоит использовать box-тени

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

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

Я уже исследовал это на минимальной графике в предыдущей статье.

Результаты мне понравились.

Всё изменилось, когда народ огня развязал войну
Всё изменилось, когда народ огня развязал войну
«Мы недостойны!»
«Мы недостойны!»
Make it so
Make it so
Beam me up
Beam me up
Мкееей...
Мкееей...
Утки, у-у!
Утки, у-у!

Управляющая этим конфигурация довольно проста:

const blocks = [
  [8, "#114d33"],
  [24, "#50bbab"],
  [28, "#fcba94"],
  [10, "#eced26"],
  // ...other characters
];
<MinimalSets blockLists={blocks} />;

Естественно, теперь возникает следующий логичный вопрос: «можно ли сделать больше box-теней?» И что насчёт размытия и прозрачности? Как они влияют на производительность?

Я написал небольшой визуальный инструмент, в котором создаётся и привязывается к div огромная box-тень.

const computedBoxShadow = points.map(
  ([x, y], i) =>
    `${x}px ${y}px ${getBlur(i, frame)}px ${animatedColor()}`
).join(",")

//...другой код

<style>`
  myDiv {
    box-shadow: ${computedBoxShadow};
  }
`</style>

Анимация реализуется заданием строки box shadow каждые 300 мс с выполнением анимации при помощи свойства transition: all. Это вызывает небольшое торможение и оказалось медленнее, чем задание box-тени в каждом кадре.

Результатом работы стало приложение, в котором можно касаться экрана, чтобы перемешивать цветовую палитру, и с историей последних десяти палитр слева. Вот пример с сотней box-теней (в оригинале статьи интерактивный).

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

Однако я обнаружил, что если не задавать никакой прозрачности или размытия, то мой ноутбук с M1 способен отрисовывать буквально тысячи box-теней.

Как ни в коем случае нельзя использовать box-тени

Отлично, теперь мы можем отрисовывать кучу box-теней. Что дальше?

Мы не можем вращать box-тени, но они могут быть кругами, а круг похож на мяч. А что, если создать кучу мячей, которые могут отскакивать друг от друга? А может, я смогу имитировать эффект 3D, масштабируя размер в зависимости от значения z... Перспектива будет неточной, но это добавит 3D-глубины.

Сделать это довольно просто: реализовать обычное «игровое состояние», обновляемое в requestAnimationFrame с последующим присваиванием огромной box-тени элементу div. Можно касаться экрана, чтобы притягивать к себе мячи. Мячи находятся в ящике и будут отталкиваться от его стенок, чтобы оставаться в кадре.

Добавим функцию такта в requestAnimationFrame

const tick = (timestamp: number) => {
  gameState.frame++;
  gameState.deltaTime = Math.min(
    (timestamp - gameState.prevFrameStartTime) / 1000,
    0.1
  );
  gameState.prevFrameStartTime = timestamp;
  update(gameState);
  render(gameState);
  winContext._gameFrame = window.requestAnimationFrame(tick);
};

Обновление симуляции — довольно лёгкий процесс, но ради краткости я приведу только псевдокод.

const update: GameFunction = (state) => {
  for (const ball in state) {
    updateBall();
    containBall();
    addFriction();
    if (touched) pullToPoint(touchX, touchY);
  }
};

А теперь перейдём к интересной части — к рендерингу. 60 раз в секунду будет выполняться следующий код:

const render: GameFunction = (state) => {
  const boxShadowString = state.balls
    .sort((a, b) => b.z - a.z)
    .map((ball) => {
      const zIndexScale = 1 + ball.z / 30;
      const size = ball.size * zIndexScale;
      const halfSize = (size - state.renderContainerSize) / 2;
      const hcs = state.renderContainerSize / 2;
      return [
        ball.x + hcs,
        "px ",
        ball.y + hcs,
        "px 0 ",
        halfSize,
        "px ",
        ball.color,
      ].join("");
    })
    .join(",");
  const renderEl = document.getElementById("render");
  if (renderEl) {
    renderEl.style.boxShadow = boxShadowString;
  }
};

Мы сортируем мячи по z-индексу и заполняем массив box-теней. Размер вычисляется на основании того, что x,y,z представляют собой центр мяча с радиусом size. Масштаб по z — это хак, создающий «глубину» по z, при котором размер масштабируется на основании фиксированного коэффициента.

Вот демо с 50 мячами. В оригинале статьи их можно перетаскивать и они будут отталкиваться от стенок.

3D-масштабирование работает достаточно неплохо, создавая ощущение глубины, хотя это и полная имитация. Можно заметить, что когда мяч приближается к «камере», то перестаёт быть кругом. Это вызвано тем, что div box-теней слишком мал для этого способа масштабирования. Увеличение размера контейнера исправляет это, но чем больше контейнер, тем ниже производительность.

Посмотрим, что произойдёт, если сделать мячи отскакивающими друг от друга при помощи старой доброй проверки коллизий n^2. Теперь я буду отражать скорость мячей при распознавании коллизий, что будет неточно, но просто. Я не стремлюсь симулировать реальную физику. Также я зафиксирую позицию по z, чтобы система превратилась в 2D и было проще наблюдать за происходящим.

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

Я воссоздал ещё одну систему, в которой мячи пытаются найти путь домой к случайной начальной позиции. Однако силы касания достаточно, чтобы отталкивать их. Это создаёт эффект отрывания кусков от губки. Можно использовать эту систему для какой-нибудь пенной струи в симуляторе имитации жидкости для игры. Было бы забавно.

Я заметил, что эффект 3D в примере выше сильно проявляется, когда мячи медленно возвращаются на место. Как усилить этот аспект 3D? Возможно, попробовать рисовать облака точек, где точками будут box-тени? Можно проецировать точки на разные поверхности, а затем отрисовывать точки как в каком-нибудь ужасном 3D-рендерере.

Я подумал, что неплохо было бы начать с того, чтобы отобразить пиксели с изображения как точки на 2D-плоскости. Кроме того, это станет хорошим нагрузочным тестом верхнего предела количества симулируемых в реальном времени box-теней. Вот функция отображения.

const pixels = await getImagePixels(
  "/images/starry_night_full.jpg" as any,
  width
);
const dx = window.innerWidth / pixels[0].length;
const dy = window.innerHeight / pixels.length;
for (let y = 0; y < pixels.length; y++) {
  for (let x = 0; x < pixels[0].length; x++) {
    const px = x * dx + dx / 2,
      py = y * dy + dy / 2,
      pz = 60 + Math.random() * 3;
    state.particles.push({
      size: pSize,
      x: px,
      y: py,
      z: pz,
      ox: px,
      oy: py,
      oz: pz,
      dx: Math.random() * 3,
      dy: Math.random() * 3,
      dz: Math.random() * 3,
      color: pixels[y][x],
    });
  }
}

Изображение отмасштабировано, чтобы уместить максимальную ширину, которую можно настроить в параметре запроса, но во всём остальном осталось таким же. Если вам нужны исходники, то они есть в codesandbox.

Я начал со своей любимой картины. Попробуйте понажимать на неё в оригинале статьи.

На некоторых устройствах демо может ломаться, потому что оно рендерит много тысяч box-теней в симулированном 3D-пространстве. Можно выполнять перетаскивание, чтобы «взрывать» изображение.

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

Многообещающе. Лично мне нравится, что это похоже на картину из-за как будто разбрызганных круглых пикселей. Вот ещё один пример с увеличенным количеством элементов и взаимодействием.

Заметно, что при таком масштабе всё начинает притормаживать. Здесь примерно 12 тысяч box-теней. Любопытно, всё работает так быстро, потому что у M1 общая память GPU и CPU? Мой десктоп не может справиться с таким количеством box-теней, как и мой iPhone со старым Android. Сумасшедшие результаты.

А как насчёт равномерного проецирования точек на поверхность меша?

Оказалось, что если добавить немного математики, всё заработает. Вот куб, использующий формулу равномерного распределения точек.

Чем-то напоминает желе. Я добавил небольшой источник света, перемещающийся за курсором мыши. Это добавляет чуть больше глубины. Освещение неточное, в нём много магических констант, но чем бы было программирование без доли магии?

.map(([p, coord]) => {
  const zIndexScale = 1 + coord.z / 40;
  const size = p.size * zIndexScale;
  const halfSize = (size - state.renderContainerSize) / 2;
  const hcs = state.renderContainerSize / 2;

  const lightDist = Math.sqrt(dist(coord, lightPos));
  const intensity = (1 - lightDist / 900) * 1; // Понятия не имею, что я здесь делаю.
  const lumen = Math.min(2, (1 / lightDist ** 2) * 60000);
  return [
    coord.x + hcs,
    "px ",
    coord.y + hcs,
    "px 0 ",
    halfSize,
    "px ",
    darkenHexColor(p.color, lumen),
  ].join("");
})

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

Первым делом GPT выдал мне функцию случайного распределения, которая мне не подходила. Мне нужно было равномерное распределение точек на поверхности куба. Со второй попытки у меня получилось.

export function getCubeSurfacePoints(
  cubeSideLength: number,
  numberOfPoints: number
) {
  const points = new Map<string, Vec3>();
  const halfSideLength = Math.floor(cubeSideLength / 2);
  const facePointSpacing = Math.floor(
    cubeSideLength / Math.sqrt(numberOfPoints)
  );
  const addPoint = (x, y, z) => {
    const key = `${x}, ${y}, ${z}`;
    points.set(key, { x, y, z });
  };

  // Генерируем точки на каждой из граней куба
  for (let i = -halfSideLength; i <= halfSideLength; i += facePointSpacing) {
    for (let j = -halfSideLength; j <= halfSideLength; j += facePointSpacing) {
      // Передняя грань
      addPoint(i, j, halfSideLength);
      // Задняя грань
      addPoint(i, j, -halfSideLength);
      // Верхняя грань
      addPoint(i, halfSideLength, j);
      // Нижняя грань
      addPoint(i, -halfSideLength, j);
      // Левая грань
      addPoint(halfSideLength, i, j);
      // Правая грань
      addPoint(-halfSideLength, i, j);
    }
  }

  // Отфильтровываем точки, находящиеся снаружи куба
  return Array.from(points.values());
}

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

Это означает, что мы с лёгкостью сможем увеличивать «разрешение» куба, увеличивая количество точек на грань. Давайте немного его увеличим.

Куб — это, конечно, здорово, но как насчёт других форм, например, сферы?

Оказалось, что для равномерного размещения точек на сфере требуются странные формулы. Я немного изучил разные методики и остановился на «спиральной дискретизации». Понятия не имею, правильная ли она, наверно, да, но я не полностью понял математику. Смысл заключается в том, чтобы представить, как точки равномерно распределяются на линии, обёрнутой вокруг на заданное количество оборотов снизу вверх. Как верёвка, обёрнутая вокруг мяча.

export function spiralDiscretization(
  numPoints: number,
  numTurns: number,
  radius = 1
) {
  const points: Vec3[] = [];
  for (let i = 0; i < numPoints; i++) {
    const t = 1;
    const phi = Math.acos(1 - (2 * i) / (numPoints - 1));
    const theta = (2 * phi * numTurns * t) % (2 * Math.PI);
    const rad = 1 * radius;
    const x = rad * Math.sin(phi) * Math.cos(theta);
    const y = rad * Math.sin(phi) * Math.sin(theta);
    const z = rad * Math.cos(phi);

    points.push({ x, y, z });
  }
  return points;
}

Аналогия между линией и верёвкой дала мне идею об анимировании точек под какую-нибудь музыку, потому что линию можно достаточно просто отобразить на массив значений частот. Сделать это было довольно просто, а результат мне понравился.

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

Вот ещё один пример с меньшим количеством оборотов.

Здорово наблюдать за тем, как спирали создают аппроксимацию равномерного распределения, но ломаются, если оборотов недостаточно. Моя девушка сказала, что это похоже на ленточного червя. Я склонен согласиться, особенно когда цвета белые. Анимацию звука можно было бы улучшить. Наверно, я делаю что-то не так с отображением, из-за чего визуализация выглядит гораздо менее интересной. Тем не менее, она показывает, что при правильной формуле можно реализовать и сферы.

Наверно, следующим вопросом будет вопрос о треугольниках? Как мы знаем, треугольники — это первичный бульон, на основе которого создаются почти все виды компьютерной графики. То есть если мы сможем рендерить группу треугольников, то сможем рендерить почти что угодно. Возможно, даже получится реализовать поддержку текстур и UV-развёртки. Однако было бы сложно идеально использовать минимально необходимое для сцены количество точек. Обычно это реализуют при помощи программного растеризатора, но у меня есть идея получше.

Когда я впервые создал Гомера Симпсона из трёх слоёв box-теней, на меня снизошло видение. Видение снизошло спустя четыре часа после публикации видео Себастьяна Лаге. Всего два слова: трассировка лучей. Можно ли трассировать лучи при помощи box-теней? Ведь если это получится, то можно будет рисовать в одном div при помощи box-теней практически что угодно. Сработает ли это? По крайней мере, на M1 должно сработать. В то время эта идея меня слишком напугала, поэтому я решил заняться чем-то попроще. Проведя за несколько лет множество экспериментов, я думаю, что время настало. Будем делать трассировщик лучей на box-тенях.

Пожалуйста, НЕ делайте этого с box-тенями...

если, конечно, у вас нет Apple Silicon

Предупреждение: все последующие примеры стоит запускать с осторожностью. Я вас предупредил. Определённо не нужно делать этого с box-тенями. Это ужасная, ужасная идея, не имеющая никакой практической ценности. Я слишком долго в последнее время работал с CSS и теперь вижу одни лишь строки box-теней. И думаю, это заразно.

Примеры будут иметь низкое разрешение с изображениями рендерингов высокого разрешения.

Трассировка/марширование лучей — это точный, но медленный способ генерации изображений. Его довольно легко писать, но достаточно сложно оптимизировать. Сегодня основная часть трассировки лучей выполняется на GPU и может быть довольно сложной. А в нашем распоряжении одни лишь box-тени, и использование GPU нарушает наши принципы, поэтому я создам трассировщик на CPU.

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

const gameState = {
  frame: 0,
  prevFrameStartTime: 0,
  deltaTime: 0,
  renderContainerSize: 32,
  cam: new PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    0.1,
    100
  ),
  spheres: [
    {
      position: new Vector3(0, 1.3, 0),
      radius: 1.3,
      material: CreateMat({ color: new Color(1, 0.2, 0.3) }),
    },
    {
      position: new Vector3(-3, 1.3, 0),
      radius: 1.3,
      material: CreateMat({
        color: new Color(0.9, 0.9, 0.9),
        smoothness: 0.9,
      }),
    },
    {
      position: new Vector3(0, 10.8, 0),
      radius: 3.6,
      material: CreateMat({
        color: new Color(0, 0, 0),
        emissive: new Color(1, 1, 1),
        emissiveStrength: 8,
      }),
    },
  ],
};

const DEFAULT_MATERIAL = {
  color: new Color(1, 1, 1),
  emissive: new Color(0, 0, 0),
  emissiveStrength: 0,
  smoothness: 0,
};

Я написал в запросе к GPT много технического жаргона, и проверил результат.

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

fixed gtp render result

Я немного отрефакторил код и сцену, добавив прогрессивный рендеринг. Смысл в том, что в процессе рендеринга кучи лучей мы постепенно придём к «эталону». Прогрессивная система распределяет вычисление лучей по кадрам, чтобы мы видели прогресс в направлении эталона. Я хотел добавить возможность использования интерактивной камеры, с которой хорошо сочетается система прогрессивного рендеринга. Камеру и управление орбитальным движением я взял из библиотеки ThreeJS. Мне не хотелось этого делать, но и не хотелось писать целые страницы матричных вычислений для орбитального перемещения; к тому же библиотека поддерживает мобильные платформы, что мне нравится.

Эта версия может рендерить только сферы. Всё в сцене является сферой, отмасштабированной на некий коэффициент. Можете попробовать перемещать камеру в оригинале статьи.

Этот пример работает на малой доле полного разрешения, по умолчанию чуть больше 6%. Однако отодвинувшись от экрана, вы увидите, что сцена постепенно становится лучше и сфокусированней. Если вы просматриваете её с телефона, то смотрите на него на расстоянии вытянутой руки и постепенно приближайте. Это очень круто. Чем дальше находится экран, тем больше элементов заметно и наш мозг заполняет пробелы, но с приближением экрана низкое разрешение становится всё очевиднее.

const targetW = w * 0.061 * scale;
const unitW = w / targetW;
const targetH = h * 0.061 * scale;
const unitH = h / targetH;
// прочий код
const scale = Number.parseFloat(params.get("scale") || "1");
const pixelSize = Number.parseFloat(params.get("pixelSize") || "12");
const bounce = Number.parseFloat(params.get("bounce") || "3");
const maxSamples = Number.parseFloat(params.get("samples") || "6000");

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

progressive render example scene

Выглядит многообещающе, но всё очень медленное. Крайне медленное. Давайте изучим код и посмотрим, сможет ли он работать быстрее.

Рендеринг стандартен: испускаем луч для каждого пикселя камеры, а затем рисуем цвет пикселя и усредняем его между кадрами.

let i = 0;
for (let x = 0; x < targetW; x++) {
  for (let y = 0; y < targetH; y++) {
    const u = (x / targetW) * 2 - 1;
    const v = -(y / targetH) * 2 + 1;
    const color = render(spheres, bounce, cam, u, v);

    let p = state.particles[i++] as any;
    if (!p) {
      p = {
        color,
      };
      state.particles.push(p);
    }
    p.size = pixelSize;
    p.x = unitW / 2 + x * unitW;
    p.y = unitH / 2 + y * unitH;
    p.color = color.lerpColors(p.color, color, 1 / gameState.frame);
  }
}

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

const tColor = new Color();
function trace(ray: Ray, spheres: Array<Sphere>, bounces = 3): THREE.Color {
  const acc = new Color(AMB_COLOR);
  const col = new Color(1, 1, 1);

  for (let i = 0; i <= bounces; i++) {
    const hit = intersectRaySpheres(ray, spheres);
    if (!hit) {
      acc.add(AMB_COLOR);
      break;
    }

    ray.origin = hit.position;
    const diffuse = randomHemisphereDirection(hit.normal)
      .add(hit.normal)
      .normalize();
    const specular = ray.direction.reflect(hit.normal);

    ray.direction = diffuse.lerp(specular, hit.object.material.smoothness);
    tColor
      .set(hit.object.material.emissive)
      .multiplyScalar(hit.object.material.emissiveStrength)
      .multiply(col);
    acc.add(tColor);

    const continueProbability = Math.max(col.r, col.g, col.b);
    if (Math.random() > continueProbability) {
      break;
    }

    col
      .multiply(hit.object.material.color)
      .multiplyScalar(1 / continueProbability);

    if (hit.object.material.emissiveStrength > 0) {
      break;
    }
  }

  return acc;
}

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

Общий принцип заключается в отражении лучей по всей сцене, пока они не попадут в источник света, после чего возвращается вычисленный цвет на основании свойств объекта и источника света. Иногда луч не попадает в источник света, а иногда попадает, поэтому нужно испускать МНОГО лучей. В моём трассировщике используется довольно простая модель освещения, никаких физически точных BRDF, текстур и подповерхностного рассеяния. Простое рассеянное освещение с зеркальными отражениями.

У меня получилось заставить работать плоскости, но я немного ошибся с числами с плавающей запятой. Ничего интересного не получилось.

render of bad planes

Разобравшись с этим. я смог рендерить прямоугольники света, что упростило создание хорошо освещённых сцен. Вот пример плоскости света без солнца.

render of square light with a few spheres

Видно, как редко луч попадает в источник света, когда ему не от чего отражаться. Существуют методики оптимизации этого, например, отклонением лучей в сторону источников света или даже отбрасыванием лучей в обратном направлении, от источников света до точки пересечения. Я немного поэкспериментировал, но решил, что быстрее всего увеличить производительность можно при помощи многопоточности. Задача сама намекает мне на это: теоретически можно добиться четырёхкратного роста производительности, в то время как исправление сборщика мусора TreeJS даст, возможно, 10%.

Веб-воркеры

Веб-воркеры — это способ реализации многопоточности в JS. Основная часть распределённых вычислений в этом и заключается — мы разбиваем вычисления на части и распределяем их по ресурсам. Когда все ресурсы закончат вычисления, мы собираем результаты. Трассировка лучей — потрясающая вещь, потому что вычисления возвращают единственный результат без побочных эффектов. Я написал код менеджера воркеров, создающий пул воркеров. У него есть два метода: render и updateScene, чтобы мы могли заменять сцены во время исполнения.

Код воркера просто скопирован из трассировщика и дополнен управлением данными для смены сцен. API post-сообщений немного неудобен, но вполне применим. Каждый воркер рендерит кадр целиком, а не отдельный пиксель, чтобы снизить лишнюю трату ресурсов на post-сообщения.

Результат меня и восхитил, и разочаровал. Вы можете сами запустить полноэкранную версию.

Восхитительно то, что рендер теперь существенно быстрее. Вот его немного увеличенная версия.

multi threaded cornel box

Недостаток заключается в том, что при взаимодействии экран становится чёрным. Почему?

Каждый раз, когда меняется камера или сцена, все ранее вычисленные лучи становятся недействительными, поэтому отбрасываются. Это происходит в основном потоке в обработчике событий. То есть пока воркер не сможет вычислить лучи для новых данных сцены, экран остаётся чёрным.

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

// выполняется при событии ввода
const reset = () => {
  gameState.isDirty = true;
  gameState.isDirtyInput = true;
  gameState.lastDirty = Date.now();
};

// в рендере воркера
if (lastFrameTime < state.lastDirty) {
  lastFrameTime = Date.now();
} else {
  if (state.dirty) {
    state.dirty = false;
    state.frame = 1;
  }
  // отправляем сообщения воркеров
}

Существует случай, в котором основной поток может получить кадр от воркера непосредственно после обновления данных сцены. Отправленный воркером кадр относится к предыдущим данным сцены. Его можно отбрасывать, добавив в post-сообщение метку времени или id сцены, но я оставил его, потому что один кадр плохих данных лучей быстро исключается усреднением. Наша цель довольно тупая — выполнять трассировку лучей при помощи box-теней, поэтому я решил, что код трассировки тоже вполне может быть немного тупым.

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

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

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

garage scene render

Данные сцен хранятся в JSON, так что их можно довольно просто менять.

garage scene render

Систему определённо можно усовершенствовать. Загрузка треугольников из модели объекта и добавление продвинутой структуры ускорения совершат чудо, как и более корректная модель освещения. Но я доволен результатом, теперь мы можем выполнять трассировку лучей при помощи box-теней.

Отлично, но зачем?

При работе я довольно много пользовался GPT. Меня до сих пор беспокоит то, как он ответил на этот запрос.

gyptiy being dumb

Он сказал, что это невозможно. Но это возможно! Он дал мне ещё несколько советов. В конечном итоге он выдал мне код, который как будто должен был работать, но, конечно, не заработал.

Впрочем, нечестно спрашивать это у GPT, потому что он может отвечать только то, что видел в Интернете, а я не знаю, писал ли кто-нибудь об этом в Интернете. Ну, теперь в Интернете есть пример, и я требую у OpenAI, чтобы она обучила GPT-5.ohoh на этой статье, чтобы он мог правильнее отвечать на вопросы о трассировщиках лучей на box-тенях.

Комментарии (2)


  1. Panzerschrek
    01.08.2024 08:09

    Автор всё же несколько лукавит. Box shadow используются только для отрисовки, а трассировка лучей - просто код на JS. Хотя и такое весьма забавно.

    Кстати, это ещё и хороший пример того, насколько JS отстаёт в производительности от нативного кода. Типичные примеры трассировки лучей в сцене с небольшим количеством примитивов работают заметно шустрее, чем в этом демо. А уж если заиспользовать GPU (как на shadertoy), то скорость будет ещё на порядки выше.


    1. Alexufo
      01.08.2024 08:09

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