Другое

Как убрать серые контуры в симуляции жидкости Jos Stam

Узнайте, как избавиться от серых контуров в симуляциях жидкости Jos Stam при использовании непрозрачных или белых фонов. Откройте эффективные методы альфа‑смешивания.

Как убрать серые контуры в симуляции жидкости, основанной на статье Джоа Стама, при работе с не‑черным фоном?

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

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

  1. Добавил отдельный массив плотности для альфа‑канала вместе с цветными массивами, но это не помогло.
  2. Использовал белый как нулевое значение, вычитая остальные цветовые каналы из текущего канала:
javascript
const imageData = this.CTX.createImageData(this.WIDTH, this.HEIGHT)

for (let x = 1; x <= this.WIDTH; x++) {
    for (let y = 1; y <= this.HEIGHT; y++) {
        const i = ((y - 1) * this.WIDTH + (x - 1)) * 4
        imageData.data[i] = 255 - (this.g[this.index(x, y)] + this.b[this.index(x, y)])
        imageData.data[i + 1] = 255 - (this.r[this.index(x, y)] + this.b[this.index(x, y)])
        imageData.data[i + 2] = 255 - (this.r[this.index(x, y)] + this.g[this.index(x, y)])
        imageData.data[i + 3] = 255
    }
}

this.CTX.putImageData(imageData, 0, 0)

Однако этот подход имеет ограничения:

  • Трудно адаптировать под другие цвета фона.
  • При некоторых комбинациях цветов выдаёт неверные результаты (например, отрицательные значения).

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

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

Это происходит, потому что симуляция не учитывает цвет фона по умолчанию; она просто накапливает значения плотности. Когда эти значения низкие (но не нулевые), они создают промежуточные серые тона, а не желаемую прозрачность или соответствие цвету фона.


Содержание


Понимание причины

Границы серого цвета появляются, потому что значения плотности интерпретируются во время рендеринга. В текущей реализации низкие (но ненулевые) значения плотности создают промежуточные серые тона вместо правильной прозрачности или цвета фона.

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

Математическая причина в том, что текущий рендеринг рассматривает RGB‑значения как аддитивные цвета без правильного альфа‑смешивания, из‑за чего низкоплотные области выглядят серыми, а не прозрачными или цветом фона.


Решение с правильным альфа‑смешиванием

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

Ключевая концепция: пре‑мультипликированный альфа

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

javascript
// Правильное смешивание с пре‑мультипликированным альфа
function blendColors(fgColor, bgColor, alpha) {
    // fgColor — цвет жидкости (RGB)
    // bgColor — цвет фона (RGB)
    // alpha — прозрачность (0 = прозрачный, 1 = непрозрачный)

    // Предмультиплицируем цвет переднего плана
    const pmR = fgColor.r * alpha;
    const pmG = fgColor.g * alpha;
    const pmB = fgColor.b * alpha;

    // Смешиваем с фоном
    const resultR = pmR + bgColor.r * (1 - alpha);
    const resultG = pmG + bgColor.g * (1 - alpha);
    const resultB = pmB + bgColor.b * (1 - alpha);

    return {
        r: Math.round(resultR),
        g: Math.round(resultG),
        b: Math.round(resultB),
        a: Math.round(alpha * 255)
    };
}

Рендеринг с правильным альфа

Вместо текущего подхода используйте этот метод:

javascript
const imageData = this.CTX.createImageData(this.WIDTH, this.HEIGHT);
const bgColor = { r: 255, g: 255, b: 255 }; // Белый фон
// Для прозрачности: bgColor = { r: 0, g: 0, b: 0 }

for (let x = 0; x < this.WIDTH; x++) {
    for (let y = 0; y < this.HEIGHT; y++) {
        const i = (y * this.WIDTH + x) * 4;
        const density = this.r[this.index(x, y)]; // Используйте максимум RGB или объединённый

        // Вычисляем альфа на основе плотности (нормализуем в диапазон 0‑1)
        const alpha = Math.min(density / maxDensity, 1);

        // Получаем цвет жидкости (можно объединить каналы RGB)
        const fluidColor = {
            r: this.r[this.index(x, y)],
            g: this.g[this.index(x, y)],
            b: this.b[this.index(x, y)]
        };

        // Смешиваем с фоном
        const blended = blendColors(fluidColor, bgColor, alpha);

        imageData.data[i] = blended.r;
        imageData.data[i + 1] = blended.g;
        imageData.data[i + 2] = blended.b;
        imageData.data[i + 3] = blended.a;
    }
}

this.CTX.putImageData(imageData, 0, 0);

Сопоставление цвета фона

Для разных цветов фона необходимо корректировать смешивание.

Поддержка динамического фона

javascript
class FluidRenderer {
    constructor(backgroundColor = { r: 0, g: 0, b: 0 }) {
        this.backgroundColor = backgroundColor;
    }

    setBackgroundColor(color) {
        this.backgroundColor = color;
    }

    render() {
        // ... код рендеринга с использованием this.backgroundColor
    }
}

// Использование:
const renderer = new FluidRenderer({ r: 255, g: 255, b: 255 }); // Белый фон
// renderer.setBackgroundColor({ r: 128, g: 128, b: 255 }); // Пользовательский цвет

Специальный случай прозрачного фона

Для истинной прозрачности (чтобы видеть содержимое за канвой) установите фон чёрным и используйте глобальное альфа‑композитирование:

javascript
// Для прозрачной канвы
this.CTX.globalCompositeOperation = 'source-over';
this.CTX.globalAlpha = 1.0;

// Рендеринг с чёрным фоном (станет прозрачным)
const bgColor = { r: 0, g: 0, b: 0 };

Настройка канвы для прозрачности

Правильная настройка канвы критична для поддержки прозрачности.

Конфигурация WebGL

javascript
// Для прозрачности в WebGL
const canvas = document.getElementById('fluid-canvas');
const gl = canvas.getContext('webgl', {
    alpha: true,
    premultipliedAlpha: true
});

// Очистка прозрачным цветом
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// Включаем смешивание
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

Конфигурация 2D‑канвы

javascript
const canvas = document.getElementById('fluid-canvas');
const ctx = canvas.getContext('2d', {
    alpha: true
});

// Для прозрачного фона
ctx.clearRect(0, 0, canvas.width, canvas.height);

Продвинутые техники рендеринга

Многослойный рендеринг

Для более сложных сценариев рассмотрите рендеринг жидкости в несколько проходов:

javascript
function renderMultiLayer() {
    // Первый проход: рендеринг жидкости с альфа
    this.CTX.globalCompositeOperation = 'source-over';

    // Второй проход: добавляем фон при необходимости
    if (this.backgroundColor) {
        this.CTX.fillStyle = `rgb(${this.backgroundColor.r}, ${this.backgroundColor.g}, ${this.backgroundColor.b})`;
        this.CTX.fillRect(0, 0, this.WIDTH, this.HEIGHT);
    }
}

Нормализация каналов цвета

Вместо текущего вычитания нормализуйте каналы цвета корректно:

javascript
function normalizeDensity(density, maxDensity) {
    return Math.max(0, Math.min(1, density / maxDensity));
}

function getCombinedDensity(x, y) {
    // Объединяем каналы RGB в физически осмысленный способ
    const r = this.r[this.index(x, y)];
    const g = this.g[this.index(x, y)];
    const b = this.b[this.index(x, y)];

    // Используем максимум канала или среднее
    return Math.max(r, g, b);
    // Или: return (r + g + b) / 3;
}

Примеры реализации

Полное решение

javascript
class FluidRenderer {
    constructor(width, height, backgroundColor = { r: 0, g: 0, b: 0 }) {
        this.WIDTH = width;
        this.HEIGHT = height;
        this.backgroundColor = backgroundColor;
        this.CTX = document.getElementById('fluid-canvas').getContext('2d');

        // Находим максимальную плотность для нормализации
        this.maxDensity = this.findMaxDensity();
    }

    findMaxDensity() {
        let max = 0;
        for (let x = 0; x < this.WIDTH; x++) {
            for (let y = 0; y < this.HEIGHT; y++) {
                const density = Math.max(
                    this.r[this.index(x, y)],
                    this.g[this.index(x, y)],
                    this.b[this.index(x, y)]
                );
                max = Math.max(max, density);
            }
        }
        return max || 1; // Избегаем деления на ноль
    }

    setBackgroundColor(color) {
        this.backgroundColor = color;
        this.maxDensity = this.findMaxDensity(); // Пересчитаем при необходимости
    }

    render() {
        const imageData = this.CTX.createImageData(this.WIDTH, this.HEIGHT);

        for (let x = 0; x < this.WIDTH; x++) {
            for (let y = 0; y < this.HEIGHT; y++) {
                const i = (y * this.WIDTH + x) * 4;

                // Получаем объединённую плотность
                const density = this.getCombinedDensity(x, y);
                const alpha = this.normalizeDensity(density, this.maxDensity);

                // Получаем цвет жидкости
                const fluidColor = {
                    r: this.r[this.index(x, y)],
                    g: this.g[this.index(x, y)],
                    b: this.b[this.index(x, y)]
                };

                // Смешиваем с фоном
                const blended = this.blendColors(fluidColor, this.backgroundColor, alpha);

                imageData.data[i] = blended.r;
                imageData.data[i + 1] = blended.g;
                imageData.data[i + 2] = blended.b;
                imageData.data[i + 3] = blended.a;
            }
        }

        this.CTX.putImageData(imageData, 0, 0);
    }

    getCombinedDensity(x, y) {
        return Math.max(
            this.r[this.index(x, y)],
            this.g[this.index(x, y)],
            this.b[this.index(x, y)]
        );
    }

    blendColors(fgColor, bgColor, alpha) {
        const pmR = fgColor.r * alpha;
        const pmG = fgColor.g * alpha;
        const pmB = fgColor.b * alpha;

        const resultR = pmR + bgColor.r * (1 - alpha);
        const resultG = pmG + bgColor.g * (1 - alpha);
        const resultB = pmB + bgColor.b * (1 - alpha);

        return {
            r: Math.round(resultR),
            g: Math.round(resultG),
            b: Math.round(resultB),
            a: Math.round(alpha * 255)
        };
    }

    normalizeDensity(density, maxDensity) {
        return Math.max(0, Math.min(1, density / maxDensity));
    }
}

Пример прозрачной канвы

javascript
// Создаём рендерер с поддержкой прозрачности
const transparentRenderer = new FluidRenderer(width, height, { r: 0, g: 0, b: 0 });

// Настраиваем канву для прозрачности
const canvas = document.getElementById('fluid-canvas');
canvas.style.background = 'transparent'; // CSS‑прозрачность

// Рендерим с правильным альфа‑смешиванием
transparentRenderer.render();

Проблемы производительности

Техники оптимизации

javascript
// Предварительно вычисляем цвет фона как массив
const bgRGB = [
    this.backgroundColor.r,
    this.backgroundColor.g,
    this.backgroundColor.b
];

// Используем типизированные массивы для лучшей производительности
const imageData = new ImageData(
    new Uint8ClampedArray(this.WIDTH * this.HEIGHT * 4),
    this.WIDTH,
    this.HEIGHT
);

// Пакетные операции
for (let i = 0; i < imageData.data.length; i += 4) {
    // Обрабатываем 4 значения сразу (RGBA)
}

Альтернатива WebGL

Для больших симуляций лучше реализовать жидкость в WebGL, а не в 2D‑канве. Согласно WebGPU Transparency and Blending, WebGL может обрабатывать прозрачность более эффективно и с лучшим качеством, чем 2D‑канва для сложных симуляций.


Заключение

Чтобы избавиться от серых контуров в симуляции жидкости Джо Стама на не‑чёрном фоне:

  1. Используйте правильное альфа‑смешивание вместо простого вычитания цветов, реализуя пре‑мультипликированный альфа для корректного рендеринга прозрачности.
  2. Настройте канву правильно для поддержки прозрачности с подходящими настройками контекста.
  3. Нормализуйте значения плотности относительно максимальной плотности симуляции для единообразных результатов.
  4. Смешивайте цвета с фоном с помощью правильной математики альфа‑композиции.
  5. Рассмотрите многопроходный рендеринг для сложных сценариев фона.

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

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


Источники

  1. Stack Overflow: How to remove gray outlines appearing around my fluid?
  2. WebGPU Transparency and Blending
  3. Three.js Forum: WebGL Fluid animation
  4. GitHub: WebGL Fluid Simulation Issues
  5. Jos Stam’s Stable Fluids Paper
  6. Real‑Time Fluid Dynamics for Games
Авторы
Проверено модерацией
Модерация