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

Затем мы используем эту текстуру в качестве однородной формы в шейдере постобработки, чтобы контролировать, как сцена постепенно переходит от темно-серой версии к исходным цветам.
Красный канал текстуры действует как порог для каждого пикселя: каждый фрагмент переходит, когда прогресс равномерен (uProgress) превышает соответствующее пороговое значение в текстуре.
Вот упрощенная версия шейдера, который я использовал:
uniform float uProgress;
uniform sampler2D uTransitionTexture;
vec3 greyscale(vec3 color, float str) {
float g = dot(color, vec3(0.1));
return mix(color, vec3(g), str);
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec3 greyScaledColor = greyscale(inputColor.rgb, 1.);
greyScaledColor = mix(greyScaledColor, vec3(0.1, 0.1, .9), pow(uProgress, 5.));
vec4 textureColor = texture2D(uTransitionTexture, vUv);
float mixer = step(textureColor.r, uProgress);
outputColor = vec4(mix(greyScaledColor, inputColor.rgb, mixer), 1.);
}
Как uProgress увеличивается от 0 до 1, разные части экрана отображаются в разное время в зависимости от значений текстуры. Сначала появляются темные области, а позже появляются более светлые области, создавая впечатление, что изображение раскрывается за счет закрученного узора. Эта простая техника позволяет создавать насыщенные и органичные переходы, сохраняя при этом простую логику шейдера! 👌
Туман
Этот туман явно не объемный, это просто своего рода цветовой трюк, примененный к материалам сцены.
Шаг 1: Линейный вертикальный туман
Сначала нам нужно настроить модификацию шейдера (используя onBeforeCompile), и позже мы применим его ко всем материалам сцены.
В этом onBeforeCompileмы просто будем использовать мировую позицию каждого фрагмента, чтобы определить его цвет. Выше определенной позиции по оси Y мы оставляем цвет нетронутым. Под ним придаем фрагменту цвет тумана.
const fogOnBeforeCompile = (shader) => {
// 1. Retrieve the worldPosition and pass it as a varying
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
varying vec3 vWorldPosition;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
`)
// 2. Define the uniforms and the varying in the fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`
uniform float fogPositionY;
uniform float fogSmoothness;
varying vec3 vWorldPosition;
void main() {
`)
3. Use them to create a basic fog
shader.fragmentShader = shader.fragmentShader.replace(
'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
`
vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
// vertical gradient
float verticalMixer = smoothstep(vWorldPosition.y - fogSmoothness, vWorldPosition.y + fogSmoothness, fogPositionY);
float mixer = clamp(verticalMixer, 0., 1.);
vec3 fogColor = vec3(1.);
outgoingLight = mix(outgoingLight, fogColor, mixer);
`);
shader.uniforms.fogPositionY = { value: fogSettings.height };
shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
}
Затем мы можем применить это переопределение шейдера к нашим материалам (обратите внимание, что он предназначен для работы с экземплярами MeshStandardMaterial, вам может потребоваться настроить его, если вы работаете с разными материалами).
gltf.scene.traverse(child => {
if (child.isMesh) {
child.material.onBeforeCompile = (shader) => {
fogOnBeforeCompile(shader);
};
}
});
При такой логике результат уже должен выглядеть так:
В этом примере я также обернул всю сцену сферой и заштриховал ее, используя ту же технику: цвет тумана под заданной мировой позицией, прозрачный над ней. Это создает туман на горизонте.
Шаг 2. Шумовая анимация тумана
Чтобы туман казался живым, мы, очевидно, добавим немного шума. Но расчет шума может оказаться дорогостоящим, особенно если его применять к многочисленным материалам. Поэтому давайте вместо этого воспользуемся бесшовной текстурой шума, такой как эта:

Есть много способов добавить шум и настроить внешний вид тумана. Я лично использовал деформацию домена, чтобы получить интересную поверхность тумана, и использовал расстояние от worldPosition к камере, чтобы контролировать визуальную глубину.
Для простоты вот базовая версия финального шейдера:
const fogOnBeforeCompile = (shader) => {
// 1. Retrieve the worldPosition and pass it as a varying
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
vUv = uv;
`)
// 2. Define the uniforms and the varying in the fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`
uniform float fogPositionY;
uniform float fogSmoothness;
uniform sampler2D noiseTexture;
uniform float uTime;
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
`)
3. Use them to create a basic fog
shader.fragmentShader = shader.fragmentShader.replace(
'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
`
vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
vec4 noiseColor = texture2D(noiseTexture, vec2(vWorldPosition.x * noiseFreq + uTime, vWorldPosition.z * noiseFreq + uTime));
float noise = noiseColor.r;
// vertical gradient
float verticalMixer = smoothstep(
vWorldPosition.y - fogSmoothness,
vWorldPosition.y + fogSmoothness,
fogPositionY + noise);
float mixer = clamp(verticalMixer, 0., 1.);
vec3 fogColor = vec3(1.);
outgoingLight = mix(outgoingLight, fogColor, mixer);
`);
shader.uniforms.fogPositionY = { value: fogSettings.height };
shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
shader.uniforms.uTime = 0;
shader.uniforms.noiseTexture = fogSettings.noiseTexture;
}
А вот демо-версия, которую вы можете проверить и поиграть:
Контуры сетки

У этой техники много названий: контур корпуса, контур задней поверхности, перевернутый контур корпуса и т. д. На самом деле это техника Blender:
Шаг 1: Создайте материал, посвященный контуру.
Поскольку нам нужен черный контур, мы создадим черный материал. Не так давно можно было использовать узел RGB, который затем интерпретировался Three.js как MeshBasicMaterial. Но узел RGB был удален из Blender начиная с версии 5.0.0, и на сегодняшний день нет способа (насколько мне известно) преобразовать материал Blender в MeshBasicMaterial.
Итак, давайте просто сохраним узел Принципиальный BSDF. Затем Three.js создаст MeshStandardMaterial, но это нормально, поскольку будет достаточно легко вручную переназначить его на MeshBasicMaterial в JavaScript.
Обязательно поместите контурный материал в последний слот. На следующем этапе это облегчит нашу жизнь.
Нам также необходимо включить отсечение задней грани (эта опция означает, что обратная сторона материала не будет визуализироваться).

2. Модификатор затвердевания
Теперь добавьте модификатор Solidify со следующими настройками:
– Отрицательная толщина
— Перевернутые нормали
– И укажите положение слота вашего контурного материала в «Смещении материала». В моем случае это будет «2», но вы также можете просто ввести большее число, и Blender просто выберет последний слот материала сетки. Это значительно облегчит вашу жизнь, если вы собираетесь скопировать этот модификатор на множество объектов, которые не обязательно имеют одинаковое количество слотов.

Вот и все! При экспорте в формате glTF не забудьте выбрать «применять модификаторы» и «экспортные материалы».
Бесконечный прокручивающийся город
Неудивительно, что я использовал один повторяемый фрагмент, управляемый системой динамической сетки, которая поддерживает расположение 3×3 вокруг камеры. Плитки клонируются один раз и плавно перемещаются по мере движения камеры, создавая иллюзию бесконечного города, сохраняя при этом низкое потребление памяти.

Освещение
Думаю, пришло время немного саморекламы. Почти в 100% своих проектов мне нравится использовать Three.js LightKit справиться с освещением.
Это простое дополнение, которое позволяет вам мгновенно просматривать и тестировать сотни различных HDR (всю библиотеку Polyhaven). Это также позволяет вам попробовать разные toneMapping и настройки экспозиции.
Когда вы будете довольны своей предустановкой, просто нажмите «Экспорт», чтобы сохранить ее как файл JSON и загрузить в качестве настройки освещения по умолчанию.
Кроме того, он работает с проектами WebGL и WebGPU, а также с ванильным JS и React.
Если вы хотите проверить это вживую, вы можете попробовать демо здесь: Three.js LightKit
Вот и все, ребята!
Надеюсь, вам понравился этот обзор, и вы нашли несколько идей, которые можно применить в своих проектах! 🫡