Создание трёхмерного тумана войны
опубликовано: ⏰ 13
Чтобы немного отвлечься, решил вспомнить старое. Возможно это всё уже устарело давно, но всё же. Небольшая, но интересная задача с реализацией “тумана войны” в 3D. Внутри нюансы реализации и демонстрационный проект на THREE.js.
Info
Исходники доступны в репозитории GitHub – Проект.
Введение
Когда-то давно нужно было сделать глобальную карту для перемещения по миру между точками интереса, где неисследованная часть закрывалась туманом. Сложность была в том, что карта была 3D и нужно было реализовать туман войны с учётом высот и освещения. При этом нормальных примеров реализации не было. Всё было какое-то полурабочее или не так, как нужно. Тут хотел попробовать собрать более или менее полноценную реализацию. Тут я точно не профессионал и даже не любитель, но всё же попробовал :)
Требования к реализации
Если с 2D всё более-менее понятно – есть текстура + анимированная маска тумана, всё в одной плоскости. То в 3D появляется объём, который нужно учитывать при рисовании тумана. Что было нужно:
- Объём – это трёхмерный туман с возможностью задания высоты. С объёмными волнами, которые медленно перекатываются с учётом освещения и отбрасывая тени.
- Необходимо, чтобы границы тумана не были резкими, то есть плавно спускались к земле. Дополнительно нужен эффект многослойности, когда нижняя часть тумана более прозрачная и может быть другого цвета.
- Исследованная область должна быть не просто квадратной, а иметь произвольную форму и находиться в любом месте карты.
- Необходимо скрывать высокие объекты, даже если они выше уровня тумана (вроде бы логично).
- По возможности, реализация должна быть не очень требовательной к производительности GPU.
Способы реализации
Из тех которые мне известны:
- Использование Screen Space. Эффект накладывается на экран во время пост-процессинга. Это удобно, так как не нужно ничего скрывать дополнительно и заботится о перекрытии объектов на карте.
- Использование Volumetric Fog (объёмный туман) с использованием Volumetric Textures.
- Использование поддельного объёма через 3D-мешы и с прозрачными текстурами и деформации вершин для имитации движения тумана.
Первый вариант наверное самый простой и распространённый способ реализации. Он не требует больших вычислительных ресурсов и можно скрыть всё что угодно. У него есть свои минусы, с ограниченными возможностями по взаимодействию с объектами сцены, так как это всё-таки не “физический объект”. И существуют определённые артефакты при отображении.
Второй вариант довольно относительно тяжёлый для GPU способ, но позволяет просто и быстро сделать объёмный туман. При этом выглядит довольно хорошо и реалистично. У него есть некоторые проблемы. Например, с прорисовкой теней – есть мерцающие артефакты и при повороте камеры, видно как туман появляется заново в разных местах. Есть способы борьбы с этим, но не все это делают. Примеры таких решений можно легко найти в маркетплейсах движков и интернете. Пример – Expand Your World With Volumetric Effects

Последний вариант сложнее в реализации, так как требует больше работы для имитации тумана, но зато позволяет избавиться от артефактов и снизить нагрузку на GPU (если правильно сделан). Реализация на спрайтах не подойдёт, так как это по сути 2D текстуры с различными подходами позиционирования в 3D пространстве. Поэтому будет второй подход с мешами. Как реализуется:
- Создаётся плоскость по размеру с картой. Для неё необходимо задать достаточное количество сегментов (вершин), так как они будут смещаться по высоте. При недостаточном количестве результат будет выглядеть слишком угловатыми.
- Создаётся маска, обычная текстура (offscreen), которая будет использоваться в качестве “памяти” для открытых зон на карте. Используется в фрагментных шейдерах при определении видимости вершины меша.
- В маске исследованная область добавляется с градиентом. То есть границы должны плавно меняться от одного цвета – закрыто (чёрный) к другому – открыто (белый).
- Для создания плавного перехода к поверхности земли рассчитывается высота в вертексном шейдере по маске (градиент – высота).
- Прозрачность границы в фрагментном шейдере рассчитывается на основе данных из вертексного шейдера о текущей высоте.
- Дополнительно на меш накладывается дополнительная текстура или две с шумом, для большей реалистичности. Тут можно проявить креативность с несколькими слоями, движением, прозрачностью и так далее.
- Видимость объектов определяется по маске при её изменении.
Пример – UE4 VFX For Games: Brute Force Volumetric Gases - Ground Fog & Vapor
Реализация второго варианта
Пример реализации будет на WebGL через Three.js, так быстрее и проще показать в виде демки в браузере. Описание биндинга параметров для генерируемой геометрии и их UI пропущу, так как это не очень важно для понимания сути работы.
Создание меша и текстур
В качестве основы используется обычная плоскость размером со сцену и необходимым количеством сегментов
const geometry = new THREE.PlaneGeometry(this.worldWidth, this.worldHeight, this.segments, this.segments);Расчёт сегментов можно сделать по-простому, в зависимости от размера сцены. Это необходимо, чтобы сохранить требуемую детализацию при деформации меша.
// Calculate mesh segments based on world size - more segments for larger worlds
// Using 256 as base scale factor for finer geometry and smoother edges
this.segments = Math.max(256, (Math.max(this.worldWidth, this.worldHeight) / 192) * 256);Далее необходимо создать материал, который будет использоваться для меша. Параметр uniforms используется для передачи данных в шейдер. В данном случае это будут данные о физике тумана, текстуры, масках и т.д. Важно не забыть указать transparent: true и depthWrite: true, так как мы будем использовать прозрачность и глубину для корректного отображения тумана. Параметр side: THREE.DoubleSide необходим, чтобы шейдер корректно отображал туман со всех сторон.
// Create shader material using the fog vertex and fragment shaders
this.material = new THREE.ShaderMaterial({
vertexShader: FOG_VERTEX_SHADER,
fragmentShader: FOG_FRAGMENT_SHADER,
uniforms: this.uniforms,
side: THREE.DoubleSide,
transparent: true,
depthWrite: true,
});export interface ThreeUniforms {
uTime: THREE.IUniform<number>;
uSpeed: THREE.IUniform<number>;
uScale: THREE.IUniform<number>;
uAmplitude: THREE.IUniform<number>;
uColor: THREE.IUniform<THREE.Color>;
uEmission: THREE.IUniform<number>;
uRadius: THREE.IUniform<number>;
uWallWidth: THREE.IUniform<number>;
uFogHeight: THREE.IUniform<number>;
uHistoryTexture: THREE.IUniform<THREE.CanvasTexture | null>;
uFogTexture: THREE.IUniform<THREE.CanvasTexture | null>;
uTextureNeedsUpdate: THREE.IUniform<boolean>;
}Объединяем всё в меш.
this.mesh = new THREE.Mesh(geometry, this.material);Дополнительно создаём текстуру uFogTexture (noise-texture.ts), чтобы создать эффект шума на поверхности тумана.
// Generate a noise texture for the initial fog appearance
const noiseCanvas = new NoiseTexture().generate(512, 512);
// Link the noise texture for visual variation in the fog appearance
this.uniforms.uFogTexture.value = new THREE.CanvasTexture(noiseCanvas);Маска для исследованной области
Маска будет рисоваться на обычном канвасе, который далее используется в шейдере как текстура.
this._texture = new THREE.CanvasTexture(this.canvas);
this._texture.minFilter = THREE.LinearFilter;
this._texture.magFilter = THREE.LinearFilter;В примере отрисовка выполняется при наведении мыши, аналог кисти с размером круга и градиентом (fog-mask-texture.ts, метод reveal), но можно использовать и другие способы. Например, только когда игрок двигается, открывать карту сегментами по мере движения игрока и так далее. Зависит от игрового дизайна.

Привязываем к параметрам шейдера:
// Link the mask texture to the shader's history texture for state persistence
this.uniforms.uHistoryTexture.value = this.mask.texture;Шейдеры
Целиком шейдеры приводить не буду, так как часть генерации шума не очень интересна и можно посмотреть в исходниках (fog-shader.ts).
Вертексный шейдер (FOG_VERTEX_SHADER). Здесь выполняется основная логика деформации геометрии для симуляции волн тумана и плавного спуска к поверхности земли по маске.
// Uniform inputs from JavaScript side
uniform float uTime; // Animation time
uniform float uSpeed; // Speed of fog movement
uniform float uScale; // Scale of the noise pattern
uniform float uAmplitude; // Height amplitude of fog waves
uniform float uRadius; // Radius of fog blob
uniform float uFogHeight; // Base height of fog bottom
uniform sampler2D uHistoryTexture; // History texture for persistence/transition
// Varying outputs passed to fragment shader
varying vec2 vUv; // Texture coordinates
varying float vElevation; // Calculated elevation from noise
varying vec3 vWorldPosition; // World-space position
varying vec3 vNormal; // Surface normal for lighting
varying float vDropFactor; // Current blend factor (history value)
...
void main() {
// Pass texture coordinates to fragment shader
vUv = uv;
// Read history texture for persistence effect
float history = texture2D(uHistoryTexture, vUv).r;
vDropFactor = history;
// Calculate noise at current position with time-based movement
vec3 noisePos = vec3(position.x * uScale, position.y * uScale, uTime * uSpeed);
float noiseVal = fbm(noisePos);
// Add high-frequency detail noise for texture
float detail = snoise(vec3(position.x * uScale * 3.0, position.y * uScale * 3.0, uTime * uSpeed * 1.5));
vElevation = noiseVal + (detail * 0.2);
// Calculate new position with elevation based on noise
vec3 newPos = position;
newPos.z += vElevation * uAmplitude;
// Define fog shape boundaries
float topZ = vElevation * uAmplitude;
float bottomZ = -uFogHeight + vElevation + 0.05;
// Interpolate between top and bottom based on history/drop factor
newPos.z = mix(topZ, bottomZ, vDropFactor);
// Calculate surface normals from noise gradient with larger offset for smoother edges
float offset = 0.5;
float hR = fbm(vec3((position.x + offset) * uScale, position.y * uScale, uTime * uSpeed));
float hL = fbm(vec3((position.x - offset) * uScale, position.y * uScale, uTime * uSpeed));
float hU = fbm(vec3(position.x * uScale, (position.y + offset) * uScale, uTime * uSpeed));
float hD = fbm(vec3(position.x * uScale, (position.y - offset) * uScale, uTime * uSpeed));
vec3 tangent = normalize(vec3(offset * 2.0, 0.0, (hR - hL) * uAmplitude));
vec3 bitangent = normalize(vec3(0.0, offset * 2.0, (hU - hD) * uAmplitude));
vec3 noiseNormal = normalize(cross(tangent, bitangent));
// Calculate wall-style normals from history texture gradient with smoother steps
vec2 offsetUV = vec2(1.0/256.0, 0.0);
float hX = texture2D(uHistoryTexture, vUv + offsetUV.xy).r - texture2D(uHistoryTexture, vUv - offsetUV.xy).r;
float hY = texture2D(uHistoryTexture, vUv + offsetUV.yx).r - texture2D(uHistoryTexture, vUv - offsetUV.yx).r;
vec3 wallNormal = normalize(vec3(-hX, -hY, 0.3));
// Blend between procedural noise normals and wall normals based on drop factor
vNormal = normalize(mix(noiseNormal, wallNormal, vDropFactor));
// Transform to world space
vec4 modelPosition = modelMatrix * vec4(newPos, 1.0);
vWorldPosition = modelPosition.xyz;
// Final clip space transformation
gl_Position = projectionMatrix * viewMatrix * modelPosition;
}Самое важное здесь – это параметр uHistoryTexture, в котором текстура с исследованными и неисследованными областями карты (о ней немного пониже). Здесь используется красный канал текстуры, который содержит значения от 0 до 1.0 – это значение высоты текущей вершины геометрии до плоскости земли.
Далее фрагментный шейдер (FOG_FRAGMENT_SHADER). Здесь мы рассчитываем цвет, свечение и прозрачность в зависимости от vDropFactor, который мы получили из вершинного шейдера. Чем выше vDropFactor, тем ближе вершина к земле, и тем сильнее она должна быть прозрачна, до полной невидимости, если значение больше 0.995.
// Uniform inputs
uniform vec3 uColor; // Base fog color
uniform float uEmission; // Emissive intensity
uniform sampler2D uFogTexture; // Background texture/gradient
// Varying inputs from vertex shader
varying vec2 vUv;
varying float vElevation;
varying vec3 vWorldPosition;
varying vec3 vNormal;
varying float vDropFactor;
void main() {
// Discard fully dropped/merged pixels (no rendering)
if(vDropFactor > 0.995) {
discard;
}
// Calculate lighting
vec3 normal = normalize(vNormal);
vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
float diff = max(dot(normal, lightDir), 0.0);
float ambient = 0.3; // Base ambient light
// Get fog background color from texture
vec3 fogColor = texture2D(uFogTexture, vUv).rgb;
// Blend between fog background and base color based on drop factor
vec3 finalColor = mix(fogColor, uColor, vDropFactor);
// Apply lighting to the blended color
vec3 litColor = finalColor * (diff + ambient);
// Add emission (glow effect)
litColor += finalColor * uEmission;
// Calculate height-based transparency: closer to ground more transparent
float transparency = smoothstep(0.0, 0.6, 1.0 - vDropFactor) ;
// Output final color with transparency based on height
gl_FragColor = vec4(litColor, transparency);
}Скрытие невидимых объектов
На карте могут присутствовать высокие объекты, которые не должны быть видны. Если высоко поднимать уровень тумана, то может выглядеть не очень красиво (отвесная стена). Либо использовать существующую маску исследованных областей для проверки, не видно ли объекта.
В моём случае используется проверка по маске при движении мыши (fog-scene.ts – updateObjectsVisibility). Просто смотрим значение пикселя в маске, и если его значение больше 0.6, то есть исследовано и уже на границе видимости, то объект отображается.
Оптимизация производительности и улучшения
В этом примере некоторые части не совсем оптимально сделаны для упрощения кода. Хотя, даже в таком виде выдаёт 60 FPS на встройке, которой уже немало лет. Из того, что первым приходит в голову:
- Самое очевидное – это заменить генерацию шума во вертексном шейдере на использование предварительно сгенерированной текстуры. Что обычно и делают, так как цель – максимально снизить количество выполняемых инструкций.
- Далее можно использовать старый добрый LOD. При отдалении камеры заменять плоскость тумана на более простую с меньшим количеством сегментов (вершин). Соответственно уменьшится и количество вызовов шейдера.
- Если и этого мало, то можно разбить плоскость на независимые части мешей. Тогда можно будет использовать отсечение (culling) невидимых частей. Будут рендериться только те, которые находятся в поле зрения камеры. В шейдере нужно будет использовать координаты мира для смешения текстур (или генерации шума), а не локальные, чтобы не было швов на границах плоскостей.
- Применять многопоточную обработку для асинхронного обновления масок тумана. Это позволит уменьшить нагрузку на основной поток рендеринга.
- Использовать разные шейдеры (с урощением) для разных уровней детализации. Также можно использовать разные текстуры.
Наверняка ещё что-то есть, но я больше не помню.
Из возможных улучшений можно упомянуть:
- В примере поверхность плоская, но можно сделать туман с привязкой поверхности, чтобы он плавно поднимался на возвышенностях или опускался в низинах. Предварительно вычисленную карту высот можно добавить, например, в синий канал маски.
- Сделать дополнительные эффекты клубов дыма или тумана над поверхностью, используя что-то вроде Niagara или других эффектов.
- Анимацию плавного исчезновения тумана при исследовании. Опять же через отдельный канал маски.
Демо
Заключение
Понятно, что для финального варианта ещё требуются доработки и оптимизации, но тут больше про идеи реализации. Сам GLSL переносим (ну, почти), поэтому относительно не сложно повторить пример на нужном движке и доделать. Хотя, с UE придётся портировать в материалы.