Система частиц

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

Создадим 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 static Particle Generate()
    {
        // я не заполняю координаты X, Y потому что хочу, чтобы все частицы возникали из одного места
        return new Particle
        {
            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 = Particle.Generate();
            // переношу частицы в центр изображения
            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 static Particle Generate()
    {
        return new 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 = Particle.Generate();
            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 = Particle.Generate();
                particle.X = MousePositionX;
                particle.Y = MousePositionY;
                particles.Add(particle);
            }
            else
            {
                break; // а если частиц уже 500 штук, то ничего не генерирую
            }
        }
    }

    // ...
}

Вжжжжжжжух…

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

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

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

  • – прозрачность
  • – красный оттенок в цвете
  • – зеленый оттенок в цвете
  • – синий оттенок в цвете

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

  • – прозрачность
  • – красный оттенок в цвете
  • – зеленый оттенок в цвете
  • – синий оттенок в цвете

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

  •  
  •  
  •  
  •  

В соответствии с такой формулой при k = 0 получим что , а при k = 1 получим , ну и соответственно при выборе значения 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))
            );
        }

        // подменяем метод генерации на новый, который будет возвращать ParticleColorful
        public new static ParticleColorful Generate()
        {
            return new ParticleColorful
            {
                Direction = rand.Next(360),
                Speed = 1 + rand.Next(10),
                Radius = 2 + rand.Next(10),
                Life = 20 + rand.Next(100),
            };
        }

        // ну и отрисовку перепишем
        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 = ParticleColorful.Generate();
            // ну и цвета меняем
            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();
}

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

Используем изображения для частицы

Кстати вместо рисования кружков можно и картинку юзать, скачиваем картинку:

скачать картинку

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

выбираем слева Ресурсы а затем кликаем на Добавить ресурс

находим скачанный файлик:

и наблюдаем как он появился в списке ресурсов

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

namespace WindowsFormsApp15
{

    public class Particle
    {
        /* ... */
    }

    public class ParticleColorful : Particle
    {
        /* ... */
    }

    // новый класс
    public class ParticleImage : Particle
    {
        public Image image;

        public new static ParticleImage Generate()
        {
            return new ParticleImage
            {
                Direction = rand.Next(360),
                Speed = 1 + rand.Next(10),
                Radius = 2 + rand.Next(10),
                Life = 20 + rand.Next(100),
            };
        }

        public override void Draw(Graphics g)
        {
            g.DrawImage(image, X - Radius, Y - Radius, Radius * 2, Radius * 2);
        }
    }
}

и теперь откорректируем UpdateState

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

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < 500)
        {
            // используем наш новый класс
            var particle = ParticleImage.Generate();
            // привязываем изображение из ресурсов
            particle.image = Properties.Resources.particle;

            particle.X = MousePositionX;
            particle.Y = MousePositionY;
            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

если запустить получится вот так:

единственное получается, что затухание у нас не плавное, это тоже можно поправить. Корректируем метод Draw класса ParticleImage

using System.Drawing.Imaging; // добавляем вверху
// ...


public class ParticleImage : Particle
{
    /* ... */

    public override void Draw(Graphics g)
    {
        float k = Math.Min(1f, Life / 100);

        // матрица преобразования цвета
        // типа аналога матрицы трансформации, но для цвета
        ColorMatrix matrix = new ColorMatrix(new float[][]{
            new float[] {1F, 0, 0, 0, 0}, // мультипликатор красного канала
            new float[] {0, 1F, 0, 0, 0}, // мультипликатор зеленого канала
            new float[] {0, 0, 1F, 0, 0}, // мультипликатор синего канала
            new float[] {0, 0, 0, k, 0}, // мультипликатор альфа канала, сюда прозрачность пихаем
            new float[] {0, 0, 0, 0, 1F}}); // а сюда пихаются то сколько мы хотим прибавить к каждому каналу

        // эту матрицу пихают в атрибуты
        ImageAttributes imageAttributes = new ImageAttributes();
        imageAttributes.SetColorMatrix(matrix);

        // ну и тут хитрый метод рисования
        g.DrawImage(image,
            // куда рисовать
            new Rectangle((int)(X - Radius), (int)(Y - Radius), Radius * 2, Radius * 2),
            // и какую часть исходного изображения брать, в нашем случае все изображения
            0, 0, image.Width, image.Height,
            GraphicsUnit.Pixel, // надо передать
            imageAttributes // наши атрибуты с матрицей преобразования
           );
    }
}

вот так

Красим частицы из изображений

а можно еще и цвета добавить

public class ParticleImage : Particle
{
    public Image image;
    public Color FromColor;
    public Color ToColor;

     /* ... */

    public override void Draw(Graphics g)
    {
        float k = Math.Min(1f, Life / 100);

        var color = ParticleColorful.MixColor(ToColor, FromColor, k);

        // матрица преобразования цвета
        // типа аналога матрицы трансформации, но для цвета
        ColorMatrix matrix = new ColorMatrix(new float[][]{
            new float[] {0, 0, 0, 0, 0}, // умножаем текущий красный цвет на 0
            new float[] {0, 0, 0, 0, 0}, // умножаем текущий зеленый цвет на 0
            new float[] {0, 0, 0, 0, 0}, // умножаем текущий синий цвет на 0
            new float[] {0, 0, 0, k, 0}, // тут подставляем k который прозрачность задает
            new float[] {(float)color.R / 255, (float)color.G / 255, (float)color.B/255, 0, 1F}}); // а сюда пихаем рассчитанный цвет переведенный из шкалы от 0 до 255 в шкалу от 0 до 1

       /* ОСТАЛЬНОЕ НЕ ТРОГАЕМ */
    }
}

правим UpdateState

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

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < 1000)
        {
            var particle = ParticleImage.Generate();
            particle.image = Properties.Resources.particle;
            // добавляем цвета
            particle.FromColor = Color.Yellow;
            particle.ToColor = Color.Magenta;

            // остальное не трогаем
            particle.X = MousePositionX;
            particle.Y = MousePositionY;
            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

получается так, я тут еще размеры увеличил:

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

Еще мысли

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

private void UpdateState()
{
    foreach (var particle in particles)
    {
        particle.Life -= 1;
        if (particle.Life < 0)
        {
            particle.Life = 20 + Particle.rand.Next(100);
            particle.Speed = 1 + Particle.rand.Next(10);
            // добавил направление движения -90 градусов +-15
            particle.Direction = -90 + 15 - Particle.rand.Next(30);
            particle.Radius = 2 + Particle.rand.Next(10);
            // генерировать вдоль верхней границы изображения
            particle.X = Particle.rand.Next(picDisplay.Image.Width);
            particle.Y = 0;
        }
        else
        {
            var directionInRadians = particle.Direction / 180 * Math.PI;
            particle.X += (float)(particle.Speed * Math.Cos(directionInRadians));
            particle.Y -= (float)(particle.Speed * Math.Sin(directionInRadians));
        }
    }

    for (var i = 0; i < 10; ++i)
    {
        if (particles.Count < 500)
        {
            // снег белый, поэтому придется использовать ParticleColorful
            var particle = ParticleColorful.Generate();

            // цвет от белого
            particle.FromColor = Color.White;
            // до белого прозрачного
            particle.ToColor = Color.FromArgb(0, Color.White);

            // координата X вдоль всей верхней границы может оказаться
            particle.X = Particle.rand.Next(picDisplay.Image.Width);
            particle.Y = 0;
            // направление движения чтобы вниз
            particle.Direction = -90 + 15 - Particle.rand.Next(30);
            particles.Add(particle);
        }
        else
        {
            break;
        }
    }
}

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

Эмитеры

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

Давайте вынесем всю логику по генерации частиц в отдельный класс. Откроем файл Particle.cs и добавим класс Emiter

// это базовый класс для создания эмитеров
// такая обобщённая версия
public abstract class EmiterBase
{
    List<Particle> particles = new List<Particle>();

    // количество частиц эмитера храним в переменной
    int particleCount = 0;
    // и отдельной свойство которое возвращает количество частиц
    public int ParticlesCount {
        get {
            return particleCount;
        }
        set
        {
            // при изменении этого значения
            particleCount = value;
            // удаляем лишние частицы если вдруг
            if (value < particles.Count)
            {
                particles.RemoveRange(value, particles.Count - value);
            }
        }
    }

    // три абстрактных метода мы их переопределим позже
    public abstract void ResetParticle(Particle particle);
    public abstract void UpdateParticle(Particle particle);
    public abstract Particle CreateParticle();

    // тут общая логика обновления состояния эмитера
    // по сути копипаста
    public void UpdateState()
    {
        foreach (var particle in particles)
        {
            particle.Life -= 1;
            if (particle.Life < 0)
            {
                ResetParticle(particle);
            }
            else
            {
                UpdateParticle(particle);
            }
        }

        for (var i = 0; i < 10; ++i)
        {
            if (particles.Count < 500)
            {
                particles.Add(CreateParticle());
            }
            else
            {
                break;
            }
        }
    }

    public void Render(Graphics g)
    {
        foreach (var particle in particles)
        {
            particle.Draw(g);
        }
    }
}

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

public class PointEmiter : EmiterBase
{
    public Point Position;

    public override Particle CreateParticle()
    {
        var particle = ParticleColorful.Generate();
        particle.FromColor = Color.Yellow;
        particle.ToColor = Color.FromArgb(0, Color.Magenta);
        particle.X = Position.X;
        particle.Y = Position.Y;
        return particle;
    }

    public override void ResetParticle(Particle particle)
    {
        particle.Life = 20 + Particle.rand.Next(100);
        particle.Speed = 1 + Particle.rand.Next(10);
        particle.Direction = Particle.rand.Next(360);
        particle.Radius = 2 + Particle.rand.Next(10);
        particle.X = Position.X;
        particle.Y = Position.Y;
    }

    public override void UpdateParticle(Particle particle)
    {
        var directionInRadians = particle.Direction / 180 * Math.PI;
        particle.X += (float)(particle.Speed * Math.Cos(directionInRadians));
        particle.Y -= (float)(particle.Speed * Math.Sin(directionInRadians));
    }
}

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

public partial class Form1 : Form
{
    // List<Particle> particles = new List<Particle>(); УБРАЛ

    PointEmiter emiter; // Добавил

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

        emiter = new PointEmiter {
            ParticlesCount = 500
        };
    }

    private void UpdateState()
    {
        // тут заменили на вызов UpdateState эмитера
        emiter.UpdateState();
    }

    private void Render(Graphics g)
    {
        // тут заменили на вызов Render эмитера
        emiter.Render(g);
    }

    private void timer1_Tick(object sender, EventArgs e)
    {
        // ТУТ НИЧЕГО НЕ ТРОГАЕМ
        // ...
    }

    // private int MousePositionX = 0; УБРАЛ
    // private int MousePositionY = 0; УБРАЛ
    private void picDisplay_MouseMove(object sender, MouseEventArgs e)
    {
        emiter.Position.X = e.X;
        emiter.Position.Y = e.Y;
    }
}

по крайне мере этот код стал намного короче.

Управляем эмитером

На самом деле теперь метод generate частицы нам не очень то и нужен мы просто делаем эмитер у которого можно регулировать настройки.

Давайте попробуем сделать эмитер который позволит управлять направлением частиц. И менять их цвет почти на лету. Идем обратно в Particle.cs и добавляем очередной класс

public class DirectionColorfulEmiter : PointEmiter
{
    public int Direction = 0; // направление частиц
    public int Spread = 10; // разброс частиц
    public Color FromColor = Color.Yellow; // исходный цвет
    public Color ToColor = Color.Magenta; // конечный цвет

    public override Particle CreateParticle()
    {
        var particle = ParticleColorful.Generate();
        particle.FromColor = this.FromColor;
        particle.ToColor = Color.FromArgb(0, this.ToColor);
        particle.Direction = this.Direction + Particle.rand.Next(-Spread/2, Spread / 2);

        particle.X = Position.X;
        particle.Y = Position.Y;
        return particle;
    }

    public override void ResetParticle(Particle particle)
    {
        var particleColorful = particle as ParticleColorful;
        if (particleColorful != null)
        {
            particleColorful.Life = 20 + Particle.rand.Next(100);
            particleColorful.Speed = 1 + Particle.rand.Next(10);

            particleColorful.FromColor = this.FromColor;
            particleColorful.ToColor = Color.FromArgb(0, this.ToColor);
            particleColorful.Direction = this.Direction + Particle.rand.Next(-Spread / 2, Spread / 2);

            particleColorful.X = Position.X;
            particleColorful.Y = Position.Y;
        }
    }
}

правим код формы:

public partial class Form1 : Form
{
    DirectionColorfulEmiter emiter; // тут поменял на DirectionColorfulEmiter

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

        // и тут поменял
        emiter = new DirectionColorfulEmiter
        {
            ParticlesCount = 500,
            // позиция из центра
            Position = new Point(picDisplay.Width / 2, picDisplay.Height / 2)
        };
    }

    private void UpdateState()
    {
        // ...
    }

    private void Render(Graphics g)
    {
        // ...
    }

    private void timer1_Tick(object sender, EventArgs e)
    {
        // ...
    }

    private void picDisplay_MouseMove(object sender, MouseEventArgs e)
    {
        /* ТУТ УБРАЛ
        emiter.Position.X = e.X;
        emiter.Position.Y = e.Y;*/
    }
}

получается

теперь добавим пару контролеров чтобы управлять поведением:

кликаем дважды сначала на tbDirection и добавляем реакцию на сдвиг:

private void tbDirection_Scroll(object sender, EventArgs e)
{
    emiter.Direction = tbDirection.Value;
}

и дважды кликаем на tbSpread и тоже добавляем:

private void tbSpread_Scroll(object sender, EventArgs e)
{
    emiter.Spread = tbSpread.Value;
}

Добавляем управление цветом

Для выбора цвета есть ColorDialog но его надо вызывать как реакцию на какое-то действие например на нажатие кнопки. Добавим кнопки

кликаем дважды на “From Color” и правим обработчик:

private void btnFromColor_Click(object sender, EventArgs e)
{
    var dialog = new ColorDialog();
    if (dialog.ShowDialog() == DialogResult.OK)
    {
        emiter.FromColor = dialog.Color;
        btnFromColor.BackColor = dialog.Color;
    }
}

аналогично кликаем дважды на “To Color”:

private void btnToColor_Click(object sender, EventArgs e)
 {
    var dialog = new ColorDialog();
    if (dialog.ShowDialog() == DialogResult.OK)
    {
        emiter.ToColor = dialog.Color;
        btnToColor.BackColor = dialog.Color;
    }
}

проверяем

Несколько эмитеров

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

public partial class Form1 : Form
{
    // вместо одного эмитера, делаем список
    List<DirectionColorfulEmiter> emiters = new List<DirectionColorfulEmiter>();

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

        // размещаем произвольным образом 10 эмитеров
        var rnd = new Random();
        for (var i =0; i < 10; ++i)
        {
            emiters.Add(new DirectionColorfulEmiter
            {
                ParticlesCount = 50,
                Position = new Point(rnd.Next(picDisplay.Width), rnd.Next(picDisplay.Height))
            });
        }

    }

    private void UpdateState()
    {
        // тут в цикле обновляем состояние всех эмитеров
        foreach (var emiter in emiters)
        {
            emiter.UpdateState();
        }
    }

    private void Render(Graphics g)
    {
        // тут в цикле рендерим эмитеры
        foreach (var emiter in emiters)
        {
            emiter.Render(g);
        }
    }

    private void timer1_Tick(object sender, EventArgs e)
    {
        // ... тут не трогаем
    }

    private void picDisplay_MouseMove(object sender, MouseEventArgs e)
    {
    }

    private void tbDirection_Scroll(object sender, EventArgs e)
    {
        // опять в цикле
        foreach (var emiter in emiters)
        {
            emiter.Direction = tbDirection.Value;
        }
    }

    private void tbSpread_Scroll(object sender, EventArgs e)
    {
        // и здесь
        foreach (var emiter in emiters)
        {
            emiter.Spread = tbSpread.Value;
        }
    }

    private void btnFromColor_Click(object sender, EventArgs e)
    {
        var dialog = new ColorDialog();
        if (dialog.ShowDialog() == DialogResult.OK)
        {
            // и тут
            foreach (var emiter in emiters)
            {
                emiter.FromColor = dialog.Color;
            }
            btnFromColor.BackColor = dialog.Color;
        }
    }

    private void btnToColor_Click(object sender, EventArgs e)
    {
        var dialog = new ColorDialog();
        if (dialog.ShowDialog() == DialogResult.OK)
        {
            // и даже тут
            foreach (var emiter in emiters)
            {
                emiter.ToColor = dialog.Color;
            }
            btnToColor.BackColor = dialog.Color;
        }
    }
}

получится: