Система частиц, часть 1

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

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

В общем, создадим Windows Forms приложение

добавим на форму PictureBox и назовем его picDisplay

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

namespace WindowsFormsApp15
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            // привязал изображение
            picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
        }
    }
}

рисовать мы будем на этом привязанном изображении

Создаем класс частица

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

Короче, создаем класс Particle

Получим:

namespace WindowsFormsApp15
{
    class Particle
    {
    }
}

И так, что есть частица? Ну если сильно не мудрствовать это просто точка, перемещающаяся в пространстве.

Ну точки рисовать не очень интересно. Поэтому вместо точки у нас будет кружок некоторого радиуса. Т.е. если все это формализовать получим что-то в этом роде

public class Particle
{
    public int Radius; // радуис частицы
    public float X; // X координата положения частицы в пространстве
    public float Y; // Y координата положения частицы в пространстве
}

так как частица у нас перемещается, значит у нее есть какое-то направление. Направление удобно задать в градусах. Ну то есть грубо говоря 0 градусов двигается вправо, 90 градусов двигается вверх и т.д.

public class Particle
{
    public int Radius; // радиус частицы
    public float X; // X координата положения частицы в пространстве
    public float Y; // Y координата положения частицы в пространстве

    public float Direction; // направление движения
}

ну и последний параметр — это скорость перемещения

public class Particle
{
    public int Radius; // радиус частицы
    public float X; // X координата положения частицы в пространстве
    public float Y; // Y координата положения частицы в пространстве

    public float Direction; // направление движения
    public float Speed; // скорость перемещения
}

давайте добавим метод, который будет генерировать частицу со случайным набором характеристик.

public class Particle
{
    public int Radius; // радиус частицы
    public float X; // X координата положения частицы в пространстве
    public float Y; // Y координата положения частицы в пространстве

    public float Direction; // направление движения
    public float Speed; // скорость перемещения

    // добавили генератор случайных чисел
    public static Random rand = new Random();

    // конструктор по умолчанию будет создавать кастомную частицу
    public Particle()
    {
        // я не трогаю координаты X, Y потому что хочу, чтобы все частицы возникали из одного места
        Direction = rand.Next(360);
        Speed = 1 + rand.Next(10);
        Radius = 2 + rand.Next(10);
    }
}

Создаем список рандомных частиц

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

public partial class Form1 : Form
{
    // собственно список, пока пустой
    List<Particle> particles = new List<Particle>();

    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

        // генерирую 500 частиц
        for (var i=0; i< 500; ++i)
        {
            var particle = new Particle();
            // переношу частицы в центр изображения
            particle.X = picDisplay.Image.Width / 2;
            particle.Y = picDisplay.Image.Height / 2;
            // добавляю список
            particles.Add(particle);
        }
    }
}

теперь у нас есть список из 500 частиц.

Добавляем таймер

Теперь мне надо их отрисовать. Но не просто отрисовать, а еще и добавить иллюзию движения. А как достигается иллюзия движения? Путем быстрой смены изображений.

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

это невидимый элемент, они добавляются на панельку под формой:

выбираем его и устанавливаем свойства

  • Enabled на True (это значит, что таймер начнет работать сразу при запуске программы)
  • И ставим Interval на 40 миллисекунд, это то как часто будет вызываться функция, привязанная к таймеру (сейчас мы ее привяжем).

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

Если подсчитать, то 40 мс это получается 25 кадров в секунду. Это чуток больше 24 кадров которые когда-то считались за стандарт частоты киносъёмки. Но в цифровом видео — это давно уже не предел. Вон уже даже ютюб позволяет просматривать видео с частотой 60 к/с, а в компьютерных играх это значение и вовсе не лимитируется.

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

public partial class Form1 : Form
{
    // ...

    int counter = 0; // добавлю счетчик чтобы считать вызовы функции
    private void timer1_Tick(object sender, EventArgs e)
    {
        counter++; // увеличиваю значение счетчика каждый вызов
        using (var g = Graphics.FromImage(picDisplay.Image))
        {
            // рисую на изображении сколько насчитал
            g.DrawString(
                counter.ToString(), // значения счетчика в виде строки
                new Font("Arial", 12), // шрифт
                new SolidBrush(Color.Black), // цвет
                new PointF{ // по центру экрана
                    X = picDisplay.Image.Width / 2,
                    Y = picDisplay.Image.Height / 2
                }
            );
        }

    }
}

запускаю

и чет нифига не видно…

Дело в том, что Windows Forms пытается экономить ресурсы и не будет лишний раз перерисовывать форму (да-да, любая форма это на самом деле набор картинок) если его об этом не попросить.

Поэтому давайте добавим вызов инвалидации picDisplay, который обновит picDisplay в соответствии с изображением привязанном к нему. Вот так:

private void timer1_Tick(object sender, EventArgs e)
{
    counter++;
    using (var g = Graphics.FromImage(picDisplay.Image))
    {
        /* ... */
    }

    // обновить picDisplay
    picDisplay.Invalidate();
}

уже чуть лучше

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

private void timer1_Tick(object sender, EventArgs e)
{
    counter++;
    using (var g = Graphics.FromImage(picDisplay.Image))
    {
        g.Clear(Color.White); // добавил очистку
        /* ... */
    }

    picDisplay.Invalidate();
}

вот теперь уже интереснее

Выводим частицы

Уберем код, который рисует нам циферки

// int counter = 0; эту строчку убрал
private void timer1_Tick(object sender, EventArgs e)
{
    // counter++; и эту тоже убрал
    using (var g = Graphics.FromImage(picDisplay.Image))
    {
        g.Clear(Color.White); // это оставил
        // убрал вывод циферок
    }

    picDisplay.Invalidate();
}

Переключимся на Particle.cs и добавим код, который будет рисовать частицу:

using System;
// ...
using System.Drawing; // чтобы исплоьзовать Graphics

public class Particle
{
    // ...

    public void Draw(Graphics g)
    {
        // создали кисть для рисования
        var b = new SolidBrush(Color.Black);

        // нарисовали залитый кружок радиусом Radius с центром в X, Y
        g.FillEllipse(b, X - Radius, Y - Radius, Radius * 2, Radius * 2);

        // удалили кисть из памяти, вообще сборщик мусора рано или поздно это сам сделает
        // но документация рекомендует делать это самому
        b.Dispose();
    }
}

добавим теперь вызов этой функции в обработчик Tick, вернемся на форму и пишем:

private void timer1_Tick(object sender, EventArgs e)
{
    using (var g = Graphics.FromImage(picDisplay.Image))
    {
        g.Clear(Color.White);
        foreach(var particle in particles)
        {
            particle.Draw(g);
        }
    }

    picDisplay.Invalidate();
}

если запустить получится что-то в этом роде:

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

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

Чтоб наш коды соответствовал этой системе добавим две функции

  • UpdateState, в которой будет обновляться логика
  • Render, в которой будет производится отрисовка

и добавим их вызовы в timer1_Tick, получится что-то такое:

public partial class Form1 : Form
{
    // ...

    // добавил функцию обновления состояния системы
    private void UpdateState()
    {
        /* тут пока пусто */
    }

    // функция рендеринга
    private void Render(Graphics g)
    {
        // утащили сюда отрисовку частиц
        foreach (var particle in particles)
        {
            particle.Draw(g);
        }
    }

    // ну и обработка тика таймера, тут просто декомпозицию выполнили
    private void timer1_Tick(object sender, EventArgs e)
    {
        UpdateState(); // каждый тик обновляем систему

        using (var g = Graphics.FromImage(picDisplay.Image))
        {
            g.Clear(Color.White);
            Render(g); // рендерим систему
        }

        picDisplay.Invalidate();
    }
}

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

private void UpdateState()
{
    foreach (var particle in particles)
    {
        var directionInRadians = particle.Direction / 180 * Math.PI;
        particle.X += (float)(particle.Speed * Math.Cos(directionInRadians));
        particle.Y -= (float)(particle.Speed * Math.Sin(directionInRadians));
    }
}

Запускаем:

А что, прикольно выглядит =) Вообще Windows Forms не предназначен для таких махинаций, поэтому, если у вас тормозит компьютер, попробуйте уменьшить количество частиц.

Все это конечно здорово, но частицы разлетаются и летят куда-то в бесконечность. И в конце концов оставляют нас лицезреть белое изображение. Что конечно так себе. Хочется создать эффект бесконечного потока частиц.

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

И при обновлении состояния системы уменьшают это значение на некоторое значение пока оно не достигнет нуля. Как только значение становится равным нулю, частицу переносят в начальную точку и восстанавливают здоровье.

Пойдем в класс Particle и добавим ей такой параметр

public class Particle
{
    // ...

    public float Life; // запас здоровья частицы

    public static Random rand = new Random();

    public Particle()
    {
        Direction = rand.Next(360);
        Speed = 1 + rand.Next(10);
        Radius = 2 + rand.Next(10);
        Life = 20 + rand.Next(100); // Добавили исходный запас здоровья от 20 до 120
    }

    // ...
}

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

private void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1; // уменьшаю здоровье
        // если здоровье кончилось
        if (particle.Life < 0)
        {
            // восстанавливаю здоровье
            particle.Life = 20 + Particle.rand.Next(100);
            // перемещаю частицу в центр
            particle.X = picDisplay.Image.Width / 2;
            particle.Y = picDisplay.Image.Height / 2;
        }
        else
        {
            // а это наш старый код
            var directionInRadians = particle.Direction / 180 * Math.PI;
            particle.X += (float)(particle.Speed * Math.Cos(directionInRadians));
            particle.Y -= (float)(particle.Speed * Math.Sin(directionInRadians));
        }
    }
}

уже интереснее

можно и остальные параметры рандомно генерить

private void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1;
        if (particle.Life < 0)
        {
            /* ... */

            // делаю рандомное направление, скорость и размер
            particle.Direction = Particle.rand.Next(360);
            particle.Speed = 1 + Particle.rand.Next(10);
            particle.Radius = 2 + Particle.rand.Next(10);
        }
        else
        {
            /* ... */
        }
    }
}

Делаем плавное затухание частиц

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

public class Particle
{
    // ...

    public void Draw(Graphics g)
    {
        // рассчитываем коэффициент прозрачности по шкале от 0 до 1.0
        float k = Math.Min(1f, Life / 100);
        // рассчитываем значение альфа канала в шкале от 0 до 255
        // по аналогии с RGB, он используется для задания прозрачности
        int alpha = (int)(k * 255);

        // создаем цвет из уже существующего, но привязываем к нему еще и значение альфа канала
        var color = Color.FromArgb(alpha, Color.Black);
        var b = new SolidBrush(color);

        // остальное все так же
        g.FillEllipse(b, X - Radius, Y - Radius, Radius * 2, Radius * 2);

        b.Dispose();
    }
}

получим намного более естественное затухание

Добавляем следование за мышкой

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

Для этого нам надо научится фиксировать положение мыши. Для этого у PictureBox есть событие MouseMove.

Переключаемся на форму выделяем picDisplay и добавляем обработчик

public partial class Form1 : Form
{
    // ...

    // добавляем переменные для хранения положения мыши
    private int MousePositionX = 0;
    private int MousePositionY = 0;

    private void picDisplay_MouseMove(object sender, MouseEventArgs e)
    {
        // в обработчике заносим положение мыши в переменные для хранения положения мыши
        MousePositionX = e.X;
        MousePositionY = e.Y;
    }
}

поправим UpdateState, чтобы новые частицы создавались не в центре изображения, а в месте положения мыши:

private void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1;
        if (particle.Life < 0)
        {
            particle.Life = 20 + Particle.rand.Next(100); // это не трогаем
            // новое начальное расположение частицы — это то, куда указывает курсор
            particle.X = MousePositionX;
            particle.Y = MousePositionY

            /* ... */ 
        }
        else
        {
            /* ... */
        }
    }
}

проверяем:

здорово да?)

Исправляем взрыв частиц в начале

Ну почти, кроме начального момента, когда все частицы генерируются мгновенно. Чтобы поправить его просто будем заполнять список частиц в функции UpdateState.

Уберем генерацию частиц в конструкторе

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

        /* УБИРАЮ ЭТОТ КОД
        for (var i=0; i< 500; ++i)
        {
            var particle = new Particle();
            particle.X = picDisplay.Image.Width / 2;
            particle.Y = picDisplay.Image.Height / 2;
            particles.Add(particle);
        }
        */
    }

    // ...
}

и добавим ее UpdateState

public partial class Form1 : Form
{
    // ...

    private void UpdateState()
    {
        foreach (var particle in particles)
        {
            /* ... */
        }

        // добавил генерацию частиц
        // генерирую не более 10 штук за тик
        for (var i = 0; i < 10; ++i)
        {
            if (particles.Count < 500) // пока частиц меньше 500 генерируем новые
            {
                var particle = new Particle();
                particle.X = MousePositionX;
                particle.Y = MousePositionY;
                particles.Add(particle);
            }
            else
            {
                break; // а если частиц уже 500 штук, то ничего не генерирую
            }
        }
    }

    // ...
}

Вжжжжжжжух…

Добавляем смену цвета

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

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

  • \(A_1\) – прозрачность
  • \(R_1\) – красный оттенок в цвете
  • \(G_1\) – зеленый оттенок в цвете
  • \(B_1\) – синий оттенок в цвете

и если второй цвет

  • \(A_2\) – прозрачность
  • \(R_2\) – красный оттенок в цвете
  • \(G_2\) – зеленый оттенок в цвете
  • \(B_2\) – синий оттенок в цвете

то чтобы рассчитать промежуточный цвет по шкале от 0 до 1 получим следующие формулы

  • \(A_{new} = A_2 \cdot k + A_1 \cdot (1 - k)\)  
  • \(R_{new} = R_2 \cdot k + R_1 \cdot (1 - k)\)  
  • \(G_{new} = G_2 \cdot k + G_1 \cdot (1 - k)\)  
  • \(B_{new} = B_2 \cdot k + B_1 \cdot (1 - k)\)  

В соответствии с такой формулой при k = 0 получим что \(A_{new} = A_1\), а при k = 1 получим \(A_{new} = A_2\), ну и соответственно при выборе значения k между 0 и 1 выдаст нам промежуточный цвет.

Идем в Particle.cs и добавляем новый класс для цветных частиц

namespace WindowsFormsApp15
{

    public class Particle
    {
        // ...

        // тут добавил слово virtual чтобы переопределить функцию
        public virtual void Draw(Graphics g)
        {
            /* ... */
        }
    }

    // новый класс для цветных частиц
    public class ParticleColorful : Particle
    {
        // два новых поля под цвет начальный и конечный
        public Color FromColor;
        public Color ToColor;

        // для смеси цветов
        public static Color MixColor(Color color1, Color color2, float k)
        {
            return Color.FromArgb(
                (int)(color2.A * k + color1.A * (1 - k)),
                (int)(color2.R * k + color1.R * (1 - k)),
                (int)(color2.G * k + color1.G * (1 - k)),
                (int)(color2.B * k + color1.B * (1 - k))
            );
        }

        // ну и отрисовку перепишем
        public override void Draw(Graphics g)
        {
            float k = Math.Min(1f, Life / 100);

            // так как k уменьшается от 1 до 0, то порядок цветов обратный
            var color = MixColor(ToColor, FromColor, k);
            var b = new SolidBrush(color);

            g.FillEllipse(b, X - Radius, Y - Radius, Radius * 2, Radius * 2);

            b.Dispose();
        }
    }
}

Теперь сходим в класс форму и заменим Particle на ParticleColorful:

private void UpdateState()
{
    foreach (var particle in particles)
    {
        /* ... ТУТ НЕ ТРОГАЕМ */
    }

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < 500)
        {
            // а у тут уже наш новый класс используем
            var particle = new ParticleColorful();
            // ну и цвета меняем
            particle.FromColor = Color.Yellow;
            particle.ToColor = Color.FromArgb(0, Color.Magenta);
            particle.X = MousePositionX;
            particle.Y = MousePositionY;
            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

private void timer1_Tick(object sender, EventArgs e)
{
    UpdateState();

    using (var g = Graphics.FromImage(picDisplay.Image))
    {
        g.Clear(Color.Black); // А ЕЩЕ ЧЕРНЫЙ ФОН СДЕЛАЮ
        Render(g);
    }

    picDisplay.Invalidate();
}

получится такая красота:

Переходим на векторную скорость

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

Это создает своего рода вау-эффект но для имитации реальных частиц не всегда подходит.

Чтобы реализовать гравитацию, нам понадобится управлять частицей не как точкой с направлением и скоростью. А как точкой у которой есть вектор скорости. Идем в нашу частицу и заменяем

public class Particle
{
    /* ... */

    public float Direction; // направление движения
    public float Speed; // скорость перемещения
    
    /* ... */
}

на вот это

public class Particle
{
    /* ... */

    public float SpeedX; // скорость перемещения по оси X
    public float SpeedY; // скорость перемещения по оси Y
    
    /* ... */
}

теперь подкрутим конструктор

public Particle()
{
    // генерируем произвольное направление и скорость
    var direction = (double)rand.Next(360);
    var speed = 1 + rand.Next(10);
    
    // рассчитываем вектор скорости
    SpeedX = (float)(Math.Cos(direction / 180 * Math.PI) * speed);
    SpeedY = -(float)(Math.Sin(direction / 180 * Math.PI) * speed);
    
    // а это не трогаем
    Radius = 2 + rand.Next(10);
    Life = 20 + rand.Next(100);
}

идем на форму в метод UpdateState и подкручиваем первый цикл:

foreach (var particle in particles)
{
    particle.Life -= 1;  // не трогаем
    if (particle.Life < 0)
    {
        // тоже не трогаем
        particle.Life = 20 + Particle.rand.Next(100);
        particle.X = MousePositionX;
        particle.Y = MousePositionY;

        /* это убираем
        particle.Direction = Particle.rand.Next(360);
        particle.Speed = 1 + Particle.rand.Next(10);
        */

        /* ЭТО ДОБАВЛЯЮ, тут сброс состояния частицы */
        var direction = (double)Particle.rand.Next(360);
        var speed = 1 + Particle.rand.Next(10);

        particle.SpeedX = (float)(Math.Cos(direction / 180 * Math.PI) * speed);
        particle.SpeedY = -(float)(Math.Sin(direction / 180 * Math.PI) * speed);
        /* конец ЭТО ДОБАВЛЯЮ  */

        // это не трогаем
        particle.Radius = 2 + Particle.rand.Next(10);
    }
    else
    {
        /* это все убираем, тут у нас старый пересчет положения частицы в пространстве 
        var directionInRadians = particle.Direction / 180 * Math.PI;
        particle.X += (float)(particle.Speed * Math.Cos(directionInRadians));
        particle.Y -= (float)(particle.Speed * Math.Sin(directionInRadians));
        */

        // и добавляем новый, собственно он даже проще становится, 
        // так как теперь мы храним вектор скорости в явном виде и его не надо пересчитывать
        particle.X += particle.SpeedX;
        particle.Y += particle.SpeedY;
    }
}

если запустить, то визуально должно все остаться как было (простите, мне лень новую гифку делать, так что я скопировал ту что была выше =)

Создаем эмиттер

Так как система частиц — это некоторая замкнутая система то и управлять ей лучше, как одним большим классом.

Зачем?

А затем, чтобы можно было создавать несколько система частиц каждая из которых будет вести себя по-разному. А рисовать можно будет все разом.

Создаем класс Emitter

вот такой он у нас

public class Emitter
{
}

и запихиваем в него все связанно с генерацией и обработкой частиц

class Emitter
    {
        List<Particle> particles = new List<Particle>();
        public int MousePositionX;
        public int MousePositionY;

        public void UpdateState()
        {
            /* АНТИ-КОПИПАСТА, устал я код копипасть,
               так что давайте сами копируйте из своего кода,
               то что было на форме в методе UpdateState :Р */
        }

        public void Render(Graphics g)
        {
            // ну тут так и быть уж сам впишу...
            // это то же самое что на форме в методе Render
            foreach (var particle in particles)
            {
                particle.Draw(g);
            }
        }
    }

переключаемся на форму и там подчищаем

public partial class Form1 : Form
{
    Emitter emitter = new Emitter(); // добавили эмиттер

    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
    }

    /* убираем отсюда этот метод
    private void UpdateState() { /* ... */ }
    */

    /* и этот
    private void Render(Graphics g) { /* ... */ }
    */
    
    private void timer1_Tick(object sender, EventArgs e)
    {
        emitter.UpdateState(); // тут теперь обновляем эмиттер

        using (var g = Graphics.FromImage(picDisplay.Image))
        {
            g.Clear(Color.Black);
            emitter.Render(g); // а тут теперь рендерим через эмиттер
        }

        picDisplay.Invalidate();
    }
    private void picDisplay_MouseMove(object sender, MouseEventArgs e)
    {
        // а тут в эмиттер передаем положение мыфки
        emitter.MousePositionX = e.X;
        emitter.MousePositionY = e.Y;
    }
}

и снова ничего не поменяется

(ɐʞниɯdɐʞ ʁɐɹʎdɓ оɯє оɯҺ иvɐwʎɓ ıqʚ ıqƍоɯҺ ‘ʎʞфиɹ vиɯǝʚǹɔǝƍо ʁ)

Ура, теперь у нас замкнутая система, и можно ей спокойно управлять не беспокоясь о том что происходит на форме! =)

Добавляем глобальную гравитацию

И так, нам нужна гравитация. Гравитация — это вектор ускорения который направлен вниз, и там у него всякие 9 с бантиком метров в секунду.

У нас метров нет, так что будем обходится пикселями.

Добавим к нашему эмиттеру поля, под вектор гравитации:

class Emitter
{

    public float GravitationX = 0;
    public float GravitationY = 1; // пусть гравитация будет силой один пиксель за такт, нам хватит

}

теперь добавим учет вектора при расчете положения частицы, правим метод UpdateState

public void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1; // не трогаем
        if (particle.Life < 0)
        {
            /* ... */
        }
        else
        {
            // гравитация воздействует на вектор скорости, поэтому пересчитываем его
            particle.SpeedX += GravitationX;
            particle.SpeedY += GravitationY;

            // это не трогаем
            particle.X += particle.SpeedX;
            particle.Y += particle.SpeedY;
        }
    }

    // ...
}

уиииииииииии!!!!1111

Добавляем точечную гравитацию

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

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

Что там еще крутится? Солнышко наше родное. Большие объекты как известны притягивают малые.

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

Вот…. так, о чем это я?

А! В общем, чтобы реализовать точечную гравитацию. Нам понадобится точка. А лучше несколько, создам список:

class Emitter
{
    // ...
    public List<Point> gravityPoints = new List<Point>(); // тут буду хранится точки притяжения

    public void UpdateState() { /* ... */ }
    public void Render(Graphics g) { /* ... */ } 
}

обновлю рендер, чтобы он мне эти точки рисовал:

public void Render(Graphics g)
{
    // это не трогаем
    foreach (var particle in particles)
    {
        particle.Draw(g);
    }

    // рисую точки притяжения красными кружочками
    foreach (var point in gravityPoints)
    {
        g.FillEllipse(
            new SolidBrush(Color.Red),
            point.X - 5, 
            point.Y - 5,
            10,
            10
        );
    }
}

и в форме добавлю одну точку прямо в центр экрана:

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
    
        // добавил точечку
        emitter.gravityPoints.Add(new Point(
            picDisplay.Width / 2, picDisplay.Height / 2
        ));
    }
}

отак:

теперь нам понадобится Классическая теория тяготения Ньютона

там есть такая формула:

\[F=G\cdot {m_{1}\cdot m_{2} \over r^{2}}\]

мы ее упростим, до такого вида:

\[a={M \over r^{2}}\]
  • a – это будет гравитационное притяжение к точке
  • M – это то с какой силой будет притягивать точка
  • r – расстояние от частицы до точки притяжения

Плюс нам понадобится вектор направления от частицы до точки притяжения. Мы его рассчитаем зная положение частицы и точки притяжения.

Идем в метод updateState

public void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1; // не трогаем
        if (particle.Life < 0)
        {
            /* ... /
        }
        else
        {   
            // сделаем сначала для одной точки
            // и так считаем вектор притяжения к точке
            float gX = gravityPoints[0].X - particle.X;
            float gY = gravityPoints[0].Y - particle.Y;

            // считаем квадрат расстояния между частицей и точкой r^2
            float r2 = gX * gX + gY * gY;
            float M = 100; // сила притяжения к точке, пусть 100 будет
            
            // пересчитываем вектор скорости с учетом притяжения к точке
            particle.SpeedX += (gX) * M / r2;
            particle.SpeedY += (gY) * M / r2;
            
            // а это старый код, его не трогаем
            particle.SpeedX += GravitationX;
            particle.SpeedY += GravitationY;

            particle.X += particle.SpeedX;
            particle.Y += particle.SpeedY;
        }
    }

    // ...
}

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

Давайте отключим гравитацию и добавим поддержку нескольких точек:

public float GravitationX = 0;
public float GravitationY = 0; // отключил

public void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1;
        if (particle.Life < 0)
        {
           /* ... */
        }
        else
        {
            // каждая точка по-своему воздействует на вектор скорости
            foreach (var point in gravityPoints)
            {
                float gX = point.X - particle.X;
                float gY = point.Y - particle.Y;
                float r2 = gX * gX + gY * gY;
                float M = 100;

                particle.SpeedX += (gX) * M / r2;
                particle.SpeedY += (gY) * M / r2;
            }

            // это не трогаем
            particle.SpeedX += GravitationX;
            particle.SpeedY += GravitationY;

            particle.X += particle.SpeedX;
            particle.Y += particle.SpeedY;
        }
    }

    // ...
}

добавим пару точечек:

public Form1()
{
    InitializeComponent();
    picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);

    emitter.gravityPoints.Add(new Point(
        picDisplay.Width / 2, picDisplay.Height / 2
    ));
    
    // добавил еще две        
    emitter.gravityPoints.Add(new Point(
      (int)(picDisplay.Width * 0.75), picDisplay.Height / 2
   ));

    emitter.gravityPoints.Add(new Point(
       (int)(picDisplay.Width * 0.25), picDisplay.Height / 2
   ));
}

проверяем:

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

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

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

foreach (var point in gravityPoints)
{
    float gX = point.X - particle.X;
    float gY = point.Y - particle.Y;
    float r2 = (float)Math.Max(100, gX * gX + gY * gY); // ограничил
    float M = 100;

    particle.SpeedX += (gX) * M / r2;
    particle.SpeedY += (gY) * M / r2;
}

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

Чет, я на гифках экономлю уже, страница и так весит почти 10МБ. Запустите сами : ))

Обобщаем точку притяжения

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

Добавим абстрактный класс IImpactPoint, это что-то среднее между интерфейсом и обыкновенном классом, то есть как и в интерфейсе не надо прописывать тело функций, но можно как в обыкновенном классе объявлять поля.

Получится такая фиговина:

public abstract class IImpactPoint
{
    public float X; // ну точка же, вот и две координаты
    public float Y;
    
    // абстрактный метод с помощью которого будем изменять состояние частиц
    // например притягивать
    public abstract void ImpactParticle(Particle particle);
    
    // базовый класс для отрисовки точечки
    public void Render(Graphics g)
    {
        g.FillEllipse(
                new SolidBrush(Color.Red),
                X - 5,
                Y - 5,
                10,
                10
            );
    }
}

теперь сделаем точку гравитации:

public class GravityPoint : IImpactPoint
{
    public int Power = 100; // сила притяжения
    
    // а сюда по сути скопировали с минимальными правками то что было в UpdateState
    public override void ImpactParticle(Particle particle)
    {
        float gX = X - particle.X;
        float gY = Y - particle.Y;
        float r2 = (float)Math.Max(100, gX * gX + gY * gY);

        particle.SpeedX += gX * Power / r2;
        particle.SpeedY += gY * Power / r2;
    }
}

О! Сделаем еще точку антигравитации, она вместо притяжения будет отталкивать частицы

public class AntiGravityPoint : IImpactPoint
{
    public int Power = 100; // сила отторжения
    
    // а сюда по сути скопировали с минимальными правками то что было в UpdateState
    public override void ImpactParticle(Particle particle)
    {
        float gX = X - particle.X;
        float gY = Y - particle.Y;
        float r2 = (float)Math.Max(100, gX * gX + gY * gY);

        particle.SpeedX -= gX * Power / r2; // тут минусики вместо плюсов
        particle.SpeedY -= gY * Power / r2; // и тут
    }
}

теперь улучшим наш класс Emitter, чтобы ему можно было передавать эти точки, а оттуда уже вызывались методы ImpactParticle.

Перво-наперво изменим наш список

class Emitter
{
    /* ... */
    
    public List<Point> gravityPoints = new List<Point>(); // <<< ВОТ ЭТОТ
    
    /* ... */
}

на такой

class Emitter
{
    /* ... */
    
    public List<IImpactPoint> impactPoints = new List<IImpactPoint>(); // <<< ТАК ВОТ
    
    /* ... */
}

теперь обновляем цикл в UpdateState, вот такой

foreach (var point in gravityPoints)
{
    float gX = point.X - particle.X;
    float gY = point.Y - particle.Y;
    float r2 = (float) Math.Max(100, gX * gX + gY * gY);
    float M = 100;

    particle.SpeedX -= (gX) * M / r2;
    particle.SpeedY -= (gY) * M / r2;
}

на такой

foreach (var point in impactPoints)
{
    point.ImpactParticle(particle);
}

так как вся логика теперь в точках, то тут осталось только метод вызывать.

И еще рендеринг подкрутим:

public void Render(Graphics g)
{
    // не трогаем
    foreach (var particle in particles)
    {
        particle.Draw(g);
    }

    foreach (var point in impactPoints) // тут теперь  impactPoints
    {
        /* это больше не надо
        g.FillEllipse(
            new SolidBrush(Color.Red),
            point.X - 5,
            point.Y - 5,
            10,
            10
        );
        */
        point.Render(g); // это добавили
    }
}

Ну и подкрутим теперь форму, чтобы она правильные точки передавала.

Идем в конструктор формы, там такое:

public Form1()
{
    InitializeComponent();
    picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
    
    emitter.gravityPoints.Add(new Point(
        picDisplay.Width / 2, picDisplay.Height / 2
    ));
    
    emitter.gravityPoints.Add(new Point(
      (int)(picDisplay.Width * 0.75), picDisplay.Height / 2
   ));
    
    emitter.gravityPoints.Add(new Point(
       (int)(picDisplay.Width * 0.25), picDisplay.Height / 2
   ));
}

заменяем на сякое:

public Form1()
{
    InitializeComponent();
    picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
    
    // гравитон
    emitter.impactPoints.Add(new GravityPoint
    {
        X = (float)(picDisplay.Width * 0.25),
        Y = picDisplay.Height / 2
    });
    
    // в центре антигравитон
   emitter.impactPoints.Add(new AntiGravityPoint
    {
        X = picDisplay.Width / 2,
        Y = picDisplay.Height / 2
    });
    
    // снова гравитон
    emitter.impactPoints.Add(new GravityPoint
    {
        X = (float)(picDisplay.Width * 0.75),
        Y = picDisplay.Height / 2
    });
}

Во какая вундерфавля теперь:

Сыпем снег с неба

Чтобы добиться эффекта, что что-то сыпется с неба надо генерить частицы не из точки а с линии.

Для этого надо научится управлять процессом генерации частицы.

Чтобы это можно было проще делать, декомпозируем метод UpdateState.

Добавим метод ResetParticle, в который вынесем момент генерации частицы и сброса ее состояния, когда жизнь кончается

class Emitter
{
    /* ... */
    

    // добавил новый метод, виртуальным, чтобы переопределять можно было
    public virtual void ResetParticle(Particle particle)
    {
        particle.Life = 20 + Particle.rand.Next(100);
        particle.X = MousePositionX;
        particle.Y = MousePositionY;

        var direction = (double)Particle.rand.Next(360);
        var speed = 1 + Particle.rand.Next(10);

        particle.SpeedX = (float)(Math.Cos(direction / 180 * Math.PI) * speed);
        particle.SpeedY = -(float)(Math.Sin(direction / 180 * Math.PI) * speed);

        particle.Radius = 2 + Particle.rand.Next(10);
    }

    /* ... */
}

и обновим UpdateState

public void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1;
        if (particle.Life < 0)
        {
            ResetParticle(particle); // заменили этот блок на вызов сброса частицы 
        }
        else
        {
            /* ... */ 
        }
    }

    for (var i = 0; i < 10; ++i)
    {   
        if (particles.Count < 500)
        {
            /* ну и тут чуток подкрутили */
            var particle = new ParticleColorful();
            particle.FromColor = Color.White;
            particle.ToColor = Color.FromArgb(0, Color.Black);

            ResetParticle(particle); // добавили вызов ResetParticle

            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

теперь создадим новый эмиттер, который будет генерировать частицы падающими сверху:

public class TopEmitter : Emitter
{
    public int Width; // длина экрана

    public override void ResetParticle(Particle particle)
    {
        base.ResetParticle(particle); // вызываем базовый сброс частицы, там жизнь переопределяется и все такое
        
        // а теперь тут уже подкручиваем параметры движения
        particle.X = Particle.rand.Next(Width); // позиция X -- произвольная точка от 0 до Width
        particle.Y = 0;  // ноль -- это верх экрана 

        particle.SpeedY = 1; // падаем вниз по умолчанию
        particle.SpeedX = Particle.rand.Next(-2, 2); // разброс влево и вправа у частиц 
    }
}

попробуем заюзать этот эмиттер на форме

public partial class Form1 : Form
{
    Emitter emitter; // тут убрали явное создание

    public Form1()
    {
        InitializeComponent();
        picDisplay.Image = new Bitmap(picDisplay.Width, picDisplay.Height);
        
        // а тут теперь вручную создаем
        emitter = new TopEmitter
        {
            Width = picDisplay.Width,
            GravitationY = 0.25f
        };
        
        /* пока отключим точки
        emitter.impactPoints.Add(new GravityPoint
        {
            X = (float)(picDisplay.Width * 0.25),
            Y = picDisplay.Height / 2
        });

        emitter.impactPoints.Add(new AntiGravityPoint
        {
            X = picDisplay.Width / 2,
            Y = picDisplay.Height / 2
        });

        emitter.impactPoints.Add(new GravityPoint
        {
            X = (float)(picDisplay.Width * 0.75),
            Y = picDisplay.Height / 2
        });
        */
    }

    /* ... */
}

ура, снежок! =)

а если включить точки, то так выглядит

Управляем количеством частиц

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

public class Emitter
{
    /* ... */

    public int ParticlesCount = 500;

    public virtual void ResetParticle(Particle particle) { /* ... */ }
    
    /* ... */
}    

теперь добавим учет количества частиц в UpdateState

public void UpdateState()
{
    foreach (var particle in particles)
    {
        /* ... */ 
    }

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < ParticlesCount) // тут 500 меняем на ParticlesCount
        {
            /* ... */
        }
        else
        {
            /* ... */
        }
    }
}

Ну и на этом пока хватит, надеюсь вы выдержали! Q.Q

В следующей части будем уже управлять состоянием системы с помощью компонент на форме.