Skip to content

OpenGL/GLUT实践:粒子系统,并添加纹理、动态模糊、边界碰撞

3790字约13分钟

OpenGLCG

2024-09-03

源码见GitHub:A-UESTCer-s-Code

1 运行效果

最终的火焰 Demo 效果:

recording

基础粒子系统:

recording

加入纹理的粒子系统:

recording

实现动态模糊:

recording

实现边界碰撞:

recording

2 实现过程

2.1 基本粒子系统

2.1.1 定义粒子结构

粒子结构定义了粒子在粒子系统中的属性,每个属性都对粒子的行为和外观有着重要的影响。以下是粒子结构的详细描述:

  • active:一个布尔值,指示该粒子是否处于活跃状态。当粒子被激活时,其值为 true,表示粒子仍在系统中活动;当粒子被标记为非活跃状态时,其值为 false,系统可能会将其从渲染或模拟中移除。

  • life:浮点数,表示粒子的生命周期。生命周期定义了粒子在系统中存在的时间长度。当生命周期耗尽时,粒子可能会被标记为非活跃状态,并被系统移除。

  • fade:浮点数,表示粒子的衰老速度。衰老速度决定了粒子的生命周期如何逐渐减少。通常,衰老速度越高,粒子的生命周期减少得越快。

  • r, g, b:浮点数,分别表示粒子的颜色。使用红、绿、蓝(RGB)三个分量来定义粒子的颜色,这决定了粒子在渲染时的外观。

  • x, y, z:浮点数,表示粒子的三维空间位置。粒子在三维空间中的位置决定了其在场景中的位置,从而影响了其在屏幕上的呈现位置。

  • v_x, v_y, v_z:浮点数,表示粒子在三维空间中的速度。速度定义了粒子在每个时间步长内在空间中移动的距离和方向。

  • a_x, a_y, a_z:浮点数,表示粒子在三维空间中的加速度。加速度影响了粒子速度的变化,从而影响了粒子在空间中的运动。

通过这些属性,粒子系统可以精确控制和模拟粒子的行为,从而实现各种动态效果和视觉效果。

2.1.2 创建粒子并初始化

2.1.2.1 创建粒子

我们使用先前定义的结构类型 particle 来定义一个数组,用于存储粒子的信息。

2.1.2.2 初始化

在函数 InitPaticleSystem 中初始化粒子信息:循环初始化粒子:通过一个 for 循环遍历所有的粒子,对每个粒子进行初始化。

  • 粒子生命周期和衰老速度:使用随机数为每个粒子设置一个初始生命周期 init_life,并将其存储在 life 中。同时,将衰老速度 speed_aging 设置为 TIME

  • 粒子颜色和位置:设置每个粒子的颜色为红色 (r = 1.0f, g = 0.0f, b = 0.0f),并将其初始位置设为 (0, 50, 0)。

  • 粒子速度和方向:通过球坐标系的转换公式,为每个粒子设置初始速度和方向。随机生成角度 thetarho,然后计算球坐标系中的速度分量,并存储在 v_x, v_y, v_z 中。

  • 粒子加速度:设置粒子在 Y 轴方向上的加速度为 -30.0f,这代表了重力的作用。

通过这个函数,我们可以初始化粒子系统,并为每个粒子设置初始的生命周期、颜色、位置、速度、加速度等属性。

2.1.3 粒子状态更新与绘制

2.1.3.1 绘制

RenderScene 函数用于绘制粒子场景。首先,通过 glClear 函数清除屏幕和深度缓冲区,确保每次绘制前都有一个干净的画布。

  • 绘制粒子: 在一个循环中遍历所有粒子,获取每个粒子的位置信息(x、y、z 坐标)。然后,利用 glColor4f 函数设置绘制点的颜色,其中的透明度值由粒子的生命值决定,即 particles[i].life。通过设置颜色的 alpha 值,我们可以实现粒子随着生命周期的减少逐渐消失的效果
  • 设置点的大小: 使用 glPointSize 函数设置绘制点的大小为 4.0f
  • 绘制点: 使用 glBegin(GL_POINTS) 开始绘制点,然后通过 glVertex3f 函数将每个粒子的位置信息传递给 OpenGL,绘制出粒子。
  • 切换缓冲区并显示: 最后,使用 glutSwapBuffers 函数切换前后缓冲区,将绘制好的图像显示在屏幕上。

通过以上步骤,实现了将粒子系统中的粒子渲染到 OpenGL 窗口中。

2.1.3.2 更新
  • Update 函数: 用于更新粒子的状态。通过一个循环遍历所有的粒子,更新它们的位置、速度和生命周期。具体:
    • 更新粒子的位置,根据当前速度和时间步长(TIME)计算粒子在每个时间步长内移动的距离,并更新粒子的位置。
    • 更新粒子的速度,根据粒子的加速度和时间步长计算粒子在每个时间步长内速度的变化,并更新粒子的速度。
    • 减少粒子的生命周期,根据粒子的衰老速度,减少粒子的生命周期。
    • 如果粒子的生命周期小于 0,说明粒子已经到达了生命周期的末尾,需要重新生成粒子:给予粒子新的生命周期、位置和速度。
  • TimerFunction 函数: 一个计时器函数,用于定时更新粒子系统并触发重新绘制。在每次调用时,先调用 Update() 函数更新粒子状态,然后通过 glutPostRedisplay() 请求重新绘制。

2.1.4 实现效果

通过补充一些常规的函数如窗口响应函数、初始化函数、主函数,我们实现了如下的基本粒子系统:

recording

2.2 添加纹理

2.2.1 纹理添加

  1. 载入纹理:

    首先,我们需要定义一个全局变量 texture,用来存储纹理的 ID。然后,添加一个函数 LoadGLTextures 来加载纹理。在这个函数中,我们使用 SOIL 库的函数 SOIL_load_OGL_texture 来加载位图,并将其转换为纹理。加载成功后,将纹理绑定到 OpenGL 中,并设置纹理过滤参数。最后,函数返回成功或失败的标志。

  2. 修改初始化函数:

    在初始化函数 InitGL 中,首先调用 LoadGLTextures 函数来载入纹理。如果纹理加载失败,则返回 false。接着,进行 OpenGL 的初始化设置,包括启用光滑着色模式、设置背景颜色、深度缓冲、混合等。然后,启用纹理映射并将载入的纹理绑定到当前的纹理单元。

2.2.2 渲染粒子

渲染粒子时,给粒子贴上纹理可以提升渲染效果。为了实现这个目标,我们需要绘制一个正方形,并将纹理贴在正方形上。但是由于绘制大量正方形的速度相对较慢,为了提高效率,我们可以利用 OpenGL 绘制三角形的高效性,采用绘制两个三角形的方式来绘制正方形。

具体实现步骤如下:

  1. 绘制正方形: 我们使用 glBegin(GL_TRIANGLE_STRIP) 开始绘制三角形带,然后按顺时针或逆时针顺序指定正方形的四个顶点,以构成两个三角形,从而绘制出正方形。
  2. 设置顶点坐标和纹理坐标: 在指定顶点的同时,我们需要建立起顶点坐标和纹理坐标的对应关系。通过使用 glTexCoord2d 函数,为每个顶点指定对应的纹理坐标,以确定纹理贴图在正方形上的位置和方向。
  3. 绘制两个三角形: 通过指定顶点的顺序,构成两个三角形,分别由顶点组合 (v0, v1, v2)(v1, v2, v3) 组成。

通过以上步骤,我们可以利用绘制两个三角形的方式来绘制正方形,并在其上贴上纹理,实现粒子的渲染效果。这样的做法在提高渲染效率的同时,也可以保持良好的渲染质量。

2.2.3 实现效果

我们选择如下图片作为纹理:

image-20240420102623878

经过上面的纹理设置,我们最终得到效果如下:

recording

2.3 运动模糊效果

为模拟动态模糊效果,我们可以按照以下步骤进行操作:

  1. 保留每帧绘图结果: 在每一帧绘制结束后,保存当前的绘图结果,即将帧缓冲区的内容复制到另一个缓冲区中,以便后续使用。
  2. 绘制半透明的黑色长方形: 在每一帧绘制过程中,绘制一个半透明的黑色长方形,覆盖在当前帧绘制结果上。这个长方形的透明度可以根据需要进行调整,通常选择一个适中的透明度。
  3. 混合绘图结果: 将保存的前几帧绘图结果与当前帧绘图结果进行混合,从而模拟运动模糊的效果。可以通过修改混合函数的参数来调整混合的效果,例如使用加权平均值混合。

实现步骤:

  1. initGL 函数中启用混合功能,以便实现半透明效果。

    首先要在 initGL 函数中添加如下语句,这样透明度才会有效。

    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
  2. 在render函数中,将清除缓冲区的操作修改为仅清除深度缓冲区,以保留之前帧的绘图结果。

    render 函数中,将一开始的

    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);

    改为

    glClear(GL_DEPTH_BUFFER_BIT );
  3. render 函数的末尾添加绘制半透明的黑色长方形的操作,以模拟运动模糊效果。

    glColor4f(0.0f, 0.0f, 0.0f, 0.1f);
    glRectf(-windowWidth, -windowHeight, windowWidth, windowHeight);

最终 render 函数如下:

void RenderScene(void)
{
	glClear(GL_DEPTH_BUFFER_BIT);

	for (int i = 0; i < MAX_PARTICLES; i++)					       
	{
		float x = particles[i].x;						       
		float y = particles[i].y;
		float z = 0;

		// Draw particle using RGB values, alpha value based on it's life
		glColor4f(particles[i].r, particles[i].g, particles[i].b, particles[i].life);

		glBegin(GL_TRIANGLE_STRIP); 
		glTexCoord2d(1, 1); glVertex3f(x + 2.0f, y + 2.0f, z); // Top Right
		glTexCoord2d(0, 1); glVertex3f(x - 2.0f, y + 2.0f, z); // Top Left
		glTexCoord2d(1, 0); glVertex3f(x + 2.0f, y - 2.0f, z); // Bottom Right
		glTexCoord2d(0, 0); glVertex3f(x - 2.0f, y - 2.0f, z); // Bottom Left
		glEnd();

		glPopMatrix();
	}

	glColor4f(0.0f, 0.0f, 0.0f, 0.1f);
	glRectf(-windowWidth, -windowHeight, windowWidth, windowHeight);

	glutSwapBuffers();
}

得到效果:

recording

2.4 边界碰撞效果

为了实现边界碰撞效果,我们需要添加一个名为 checkBump 的函数,并将其在 TimerFunction 中的 Update(); 函数后调用。这个函数的作用是检查所有粒子的位置是否越过了边界,如果是,则立即将其速度反向,以模拟碰撞效果。

void checkBump()
{
    for (int i = 0; i < MAX_PARTICLES; i++)
    {
        float x = particles[i].x;
        float y = particles[i].y;

        // 检测是否碰撞到边界
        if (x > windowWidth || x < -windowWidth)
        {
            particles[i].v_x = -particles[i].v_x;
            x += particles[i].v_x; // 稍微调整位置,防止卡在边界上
        }
        if (y > windowHeight || y < -windowHeight)
        {
            particles[i].v_y = -particles[i].v_y;
            y += particles[i].v_y; // 稍微调整位置,防止卡在边界上
        }
    }
}

该函数会遍历所有粒子,检查其位置是否超出了窗口边界。如果发现粒子碰到了边界,就会立即反转相应的速度分量,从而实现了边界碰撞效果。同时,为了防止粒子卡在边界上,稍微调整了粒子的位置。

通过这种方式,我们可以在粒子系统中实现边界碰撞效果,增加了系统的真实感和趣味性。

实现效果如下:

recording

2.5 火焰效果

要实现火焰效果,我们需要调整粒子的初始化 InitPaticleSystem 和更新函数 Update ,以便更好地模拟火焰的外观和行为。下面是需要进行的改动:

1)粒子的生成位置

我们可以使用正态分布随机数生成器来随机生成粒子的初始位置,使生成的火焰粒子中间多,两边少,看起来更加自然。这里使用了均值为0,标准差为10的正态分布。

std::default_random_engine generator;
std::normal_distribution<float> distribution(0.0, 10.0);
//...
particles[i].x = distribution(generator);
particles[i].y = -55.0f;
2)粒子的颜色设置

在火焰效果中,粒子的颜色通常会随着其与火源之间的距离变化而变化。这是因为在火焰的中心部位,燃烧更为激烈,火焰的温度和亮度更高,因此颜色更接近黄色或橙色。而在火焰的边缘部位,燃烧相对较弱,火焰的温度和亮度较低,颜色则更接近红色。

为了模拟这种效果,我们可以通过计算粒子与火源之间的距离,并根据距离来设置粒子的颜色。在上述代码中,我们首先计算了粒子与火源之间的距离,然后将这个距离与最大距离进行归一化,得到一个取值范围在0到1之间的插值因子 lerp_factor

接着,我们使用线性插值的方式来设置粒子的颜色。线性插值是一种简单的插值方法,它通过两个已知点之间的直线来估计介于这两个点之间的其他点的值。在这里,我们将 lerp_factor 应用于颜色的 g 绿色分量,使得随着粒子与火源的距离增加,绿色分量的值逐渐减小,从而实现了颜色的渐变效果。

float distance = abs(particles[i].x);
float maxDistance = 15.0f;

std::uniform_real_distribution<float> random_factor(0.0f, 0.1f);

// Set color for particle
float lerp_factor = distance / maxDistance;
particles[i].r = 1.0f;
particles[i].g = (1.0f - lerp_factor); 
particles[i].b = 0.0f;
3)粒子的速度设置

火焰通常具有上升的趋势,因此我们可以将粒子的初始速度设置为向上的方向,并添加一定的水平方向的随机速度成分。

float speedRange = 10.0f;
particles[i].v_x = ((rand() % 100) / 100.0f) * speedRange - speedRange / 2.0f;
particles[i].v_y = 2.0f;
4)粒子的加速度设置

火焰的上升通常受到重力的影响,但同时也受到火焰本身的推动。因此,我们可以将粒子的竖直方向的加速度设置为一个正值,以模拟火焰的上升效果。

particles[i].a_x = 0.0f;						
particles[i].a_y = 50.0f;