动画与粒子系统

Number of views 1

在本章中,我们将涵盖以下内容:

  • 使用顶点位移实现表面动画
  • 创建粒子喷泉
  • 利用TransformFeedback变换反馈创建粒子系统
  • 使用实例化粒子创建粒子系统
  • 利用粒子模拟火焰
  • 利用粒子模拟烟雾

介绍

着色器让我们得以充分利用现代显卡的大规模并行架构。由于着色器具备变换顶点坐标的能力,因此可被用于在其内部直接实现部分动画相关功能。如果动画算法能够被合理地并行化,从而在着色器中执行,就能带来一定的效率提升。

在着色器程序中实现动画的一大难点,在于难以写入更新后的顶点坐标。着色器的设计初衷并非支持向任意缓冲区写入数据(帧缓冲区除外)。因此,许多开发者会灵活运用帧缓冲对象(FBO) 与纹理对象来存储着色器的输出数据。

不过近年来,OpenGL 新增了一项特性,支持将顶点着色器输出变量的值写入任意一个或多个缓冲区。这项特性被称为Transform Feedback 变换反馈

在本篇中,我们将探讨若干在着色器中实现动画的示例,核心聚焦于粒子系统。

  • 第一个示例《基于顶点位移实现动画》,演示了如何通过基于时变函数变换对象的顶点坐标来实现动画效果。
  • 在《创建粒子喷泉》这一实例教程中,我们将构建一个恒加速度作用下的简易粒子系统。
  • 《使用变换反馈创建粒子系统》则通过示例说明如何在粒子系统中运用 OpenGL 的变换反馈功能。
  • 《使用实例化粒子创建粒子系统》这一实例教程,会展示如何借助实例化渲染,为大量复杂对象添加动画效果。

最后两个实例教程,重点呈现了用于模拟烟雾、火焰等复杂真实物理系统的粒子系统实现方案。

基于顶点位移实现动画

利用着色器实现动画的一种简单直接的方式,是在顶点着色器中基于某种Time-Dependent Function时变函数对顶点进行变换。OpenGL 应用程序提供静态几何图形,顶点着色器则借助当前时间(以统一变量(uniform variable) 的形式传入)修改这些几何图形。这一做法将顶点坐标的计算任务从 CPU 转移到 GPU,并充分利用图形驱动所提供的所有并行处理能力。

本示例中,我们将基于正弦波对细分后的四边形顶点进行变换,以此创建一个起伏的波浪表面。我们会向渲染管线传入一组三角形,这些三角形共同构成 x-z 平面上的平坦表面;而在顶点着色器中,我们将基于时变正弦函数调整顶点的 y 坐标,并计算变换后顶点的法向量。下图展示了预期达成的效果(可以自行想象:波浪正从左至右沿该表面移动)。

image1766979392271.png

我们可以使用噪声纹理来基于随机函数对表面进行动画。(有关噪声纹理的详细信息,请参见Shader实验室相关文章)。

在深入代码实现之前,我们先梳理一下所需用到的数学原理。

我们将把曲面的 y 坐标变换为当前时间模型空间 x 坐标的函数(即 y 坐标由这两个变量共同决定)。为此,我们会采用基础的平面波方程(如下图示)。

image1766979767451.png

其中,A 为波的振幅(波峰的高度),λ(拉姆达)为波长(相邻波峰之间的距离),v 为波的传播速度。前述图示展示了 t = 0 且波长为 1 时该波的示例效果。我们将通过统一变量(uniform 变量) 来配置这些系数。

为了让曲面呈现出正确的着色效果,我们还需要计算顶点变换后的位置的法向量。该法向量可通过对上述函数求(偏)导数得到,最终推导得出如下方程:

$$
\mathbf{n}(x,t) = \left( -A\frac{2\pi}{\lambda} \cos\left( \frac{2\pi}{\lambda}(x - vt) \right), 1 \right)
$$

当然,在将上述向量代入着色模型进行计算之前,必须先对其做归一化处理

准备工作

配置你的 OpenGL 应用程序,使其渲染出 x-z 平面上的平坦细分曲面。使用的三角形数量越多,最终呈现的效果会越理想。同时,可采用你偏好的任意方式记录动画时间,并通过名为 Time 的统一变量(uniform 变量),将当前时间传递给顶点着色器。

其余关键的统一变量(uniform 变量)为前述波方程的各项系数:

  • K:波数(计算公式为 2π/λ)

  • Velocity(速度):波的传播速度

  • Amp(振幅):波的振幅

配置程序,为选定的着色模型提供这些适配的统一变量。

如何做

使用以下代码创建顶点着色器:

#version 400
// 顶点位置输入(布局位置0)
layout (location = 0) in vec3 VertexPosition;
// 输出:变换后的顶点位置(相机空间)
out vec4 Position;
// 输出:变换后的法向量(相机空间)
out vec3 Normal;

// 动画时间(由CPU传入的uniform变量)
uniform float Time;
// 波方程参数
uniform float K;     // 波数
uniform float Velocity; // 波的传播速度
uniform float Amp;   // 波的振幅

// 模型视图矩阵(将顶点转换至相机空间)
uniform mat4 ModelViewMatrix;
// 法向量矩阵(法向量的模型视图变换,保证正交性)
uniform mat3 NormalMatrix;
// 模型视图投影矩阵(将顶点转换至裁剪空间)
uniform mat4 MVP;

void main()
{
    // 将顶点位置转换为齐次坐标(w分量为1.0)
    vec4 pos = vec4(VertexPosition, 1.0);
  
    // 计算正弦波的相位因子:u = 波数 × (x坐标 - 波速×时间)
    // 核心作用:模拟波浪沿x轴正方向(Velocity为正)移动
    float u = K * (pos.x - Velocity * Time);
  
    // 基于正弦函数修改y坐标,实现波浪起伏
    pos.y = Amp * sin(u);
  
    // 计算法向量:先求波函数对x的偏导,再构造法向量并归一化
    vec3 n = vec3(0.0);
    // 法向量xy分量:(-K×A×cos(u), 1.0) 归一化,z分量保持0(x-z平面波浪)
    n.xy = normalize(vec2(-K * Amp * cos(u), 1.0));
  
    // 将顶点位置和法向量转换至相机空间,传递给片元着色器
    Position = ModelViewMatrix * pos;
    Normal = NormalMatrix * n;
  
    // 最终输出裁剪空间顶点坐标(OpenGL固定要求)
    gl_Position = MVP * pos;
}

创建一个片元着色器,该着色器基于 Position 和 Normal 变量,使用你选择的任意着色模型计算片元颜色(如Blinn-Phong)。

原理

顶点着色器接收顶点的位置信息,并利用此前讨论过的波动方程更新其 y 坐标。在前三条语句执行完毕后,变量 pos 本质上是输入变量 VertexPosition 的副本,仅更新了其中的 y 坐标。

随后,我们通过前文的公式计算法向量,将计算结果归一化后存入变量 n 中。由于该波实际为二维波(其传播不依赖 z 轴),因此法向量的 z 分量值为 0。

最后,我们将新的顶点位置和法向量转换为相机坐标系下的数值后,传递给片元着色器。和常规操作一致,我们也会将裁剪坐标系下的顶点位置传入内置变量 gl_Position 中。

更多

在顶点着色器中修改顶点位置,是将部分计算任务从 CPU 转移到 GPU 的一种简便方法。这种方式还能避免为修改顶点位置,而在 GPU 内存与主内存之间传输顶点缓冲区的操作。

该方法的主要缺点在于:如果更新后的顶点位置需要用于后续额外处理(例如碰撞检测),CPU 端将无法直接获取这些数据。不过,也有多种方法可以将这类数据回传给 CPU。其中一种技巧是灵活运用帧缓冲对象(FBOs),从片元着色器中读取更新后的顶点位置。在下一小节中,我们将介绍另一种技术,它会用到 OpenGL 的一项较新特性 ——变换反馈(transform feedback)

创建一个粒子喷泉

在计算机图形学中,粒子系统是一组用于模拟各类 “模糊” 效果体系的对象,可还原烟雾、液体喷射、火焰、爆炸或其他类似现象。每个粒子都被视为仅具备位置信息、无尺寸属性的点对象;通常情况下,粒子会被渲染为点精灵(采用 GL_POINTS 图元模式)。

每个粒子都有完整的生命周期:从 “诞生” 开始,按照预设的一组规则完成运动动画,随后 “消亡”;消亡后的粒子可被重新激活,再次完整经历整个生命周期。通常而言,粒子之间不会产生交互行为,也不具备反光特性;粒子也常被渲染为单个带纹理、面向相机且具备透明效果的四边形。

在单个粒子的生命周期内,其运动轨迹会遵循一组预设规则来驱动。这些规则通常包含基础运动学方程—— 这类方程用于定义粒子在恒定加速度作用下的运动规律(例如重力场环境)。除此之外,我们还可以纳入风力、摩擦力或其他影响因素的计算逻辑。粒子在生命周期内,其形状或透明度也可能发生动态变化。一旦粒子的 “存活时长” 达到阈值(或运动到指定位置),它就会被判定为 “消亡” 状态,随后可被回收复用,再次参与新一轮的粒子生命周期循环。

在本示例中,我们将实现一个相对简易的粒子系统,模拟出喷泉喷水的视觉效果。本示例中的粒子不涉及 “回收复用” 机制:当粒子到达生命周期终点时,我们会将其渲染为完全透明的状态,使其达到视觉上完全不可见的效果。这种设计会让喷泉呈现出 “有限喷射时长” 的特点,仿佛其喷射的水源是有限的。在后续的示例中,我们会介绍一些优化方案,通过粒子回收复用的方式来改进这个粒子系统。

以下是一组连续的帧序列图像,展示了这个简易粒子系统的输出效果:

image1766989720990.png为了实现粒子的动画效果,我们将采用适用于恒定加速度作用下物体的标准运动学方程。

$$
\mathbf{P}(t) = \mathbf{P}_0 + \mathbf{v}_0 t + \frac{1}{2}\mathbf{a} t^2
$$

上述方程描述了粒子在时刻 t 的位置。P0 代表初始位置,v0 代表初始速度,a 则为加速度。

我们将所有粒子的初始位置统一设定为坐标原点 (0,0,0)。初始速度会在设定的数值区间内随机生成。每个粒子的生成时间存在细微差异,因此我们在上述方程中使用的时间参数,是相对于该粒子生成时刻的相对时间。

由于所有粒子的初始位置完全相同,我们无需将其作为着色器的输入属性传入。取而代之的是,我们只需向着色器传入另外两个顶点属性:初始速度生成时刻(即粒子的 “诞生时间”)。在粒子到达诞生时间之前,我们会将其渲染为完全透明的状态;粒子诞生之后,我们便用上述方程计算它的位置,方程中的时间参数 t 取相对时间(计算方式为:Time – StartTime)。

我们将把每个粒子渲染为带纹理的点精灵(采用 GL_POINTS 图元模式)。为点精灵应用纹理的操作十分简便 —— 因为 OpenGL 会自动生成纹理坐标,并通过内置变量 gl_PointCoord 将这些坐标传递给片元着色器供其使用。

同时,我们会让点精灵的 Alpha 值随粒子的存活时长呈线性衰减,以此实现粒子在运动过程中逐渐淡出的视觉效果。

准备工作

我们将创建两个缓冲区(或单个交错缓冲区),用于存储传递给顶点着色器的输入数据。

第一个缓冲区专门存储每个粒子的初始速度 —— 我们会从预设的有限向量范围内随机选取初始速度的数值。为了实现前文图片中粒子呈现的垂直 “锥形” 分布效果,我们会从锥形区域内的向量集合中随机选取(初始速度向量)。以下代码是实现该逻辑的一种方式:

vec3 v(0.0f);
float velocity, theta, phi;
GLfloat *data = new GLfloat[nParticles * 3];
for( GLuint i = 0; i<nParticles; i++ ) {
 // 选取速度的方向
 theta = glm::mix(0.0f, (float)PI / 6.0f, randFloat());
 phi = glm::mix(0.0f, (float)TWOPI, randFloat());
 v.x = sinf(theta) * cosf(phi);
 v.y = cosf(theta);
 v.z = sinf(theta) * sinf(phi);
 // 缩放以设定速度的大小(速率)
 velocity = glm::mix(1.25f,1.5f,randFloat());
 v = v * velocity;
 data[3*i] = v.x;
 data[3*i+1] = v.y;
 data[3*i+2] = v.z;
}
glBindBuffer(GL_ARRAY_BUFFER,initVel);
glBufferSubData(GL_ARRAY_BUFFER, 0, 
 nParticles * 3 * sizeof(float), data);

上述代码中,randFloat 函数会返回一个介于 0 到 1 之间的随机值。我们通过调用 GLM 库的 mix 函数,在预设的数值范围内选取随机数(GLM 的 mix 函数与 GLSL 中对应的函数功能完全一致:它会在第一个参数和第二个参数的数值之间执行线性插值)。在这里,我们先生成一个 0 到 1 之间的随机浮点数,再用这个值在目标数值区间的两个端点之间做插值,从而得到区间内的随机值。

为了从锥形区域内选取向量,我们采用球面坐标系来实现。其中,θ(theta)的值决定了锥形中心与锥体外边缘之间的夹角;而 φ(phi)的值则定义了在给定 θ 值的情况下,向量围绕 y 轴的所有可能方向。关于球面坐标系的更多细节,可参考该篇文章

选定向量方向后,我们会对该向量进行缩放,使其模长(即速度大小)落在 1.25 到 1.5 之间。速度向量的模长对应粒子的整体运动速率,我们可以调整这个数值范围,来实现粒子运动速率的更多样化,或是让粒子整体运动得更快 / 更慢。

循环的最后三行代码将向量赋值到数组 data的对应位置。循环结束后,我们把数组数据拷贝到名为 initVel的缓冲区中,并将该缓冲区配置为顶点属性为0的输入数据源。

在第二个缓冲区中,我们将存储每个粒子的生成时刻(start time)。该缓冲区为每个顶点(即每个粒子)仅存储一个浮点型数值。在本示例中,我们会以固定速率依次创建每个粒子。以下代码将创建一个缓冲区,其中每个粒子的生成时间比前一个粒子晚 0.00075 秒:

float * data = new GLfloat[nParticles];
float time = 0.0f, rate = 0.00075f;
for( unsigned int i = 0; i<nParticles; i++ ) {
 data[i] = time;
 time += rate;
}
glBindBuffer(GL_ARRAY_BUFFER,startTime);
glBufferSubData(GL_ARRAY_BUFFER, 0, 
 nParticles * sizeof(float), data);

这段代码仅创建了一个浮点型数组:数组起始值为 0,每个元素依次递增 rate(步长)。随后该数组被拷贝到名为 startTime的缓冲区中,并将该缓冲区配置为顶点属性为1的输入数据源。

在 OpenGL 程序中设置以下统一变量(uniform):

  • ParticleTex:粒子所用的纹理
  • Time:动画启动后流逝的总时长
  • Gravity:表示前文运动方程中加速度一半的向量(对应公式中a/2 的部分)
  • ParticleLifetime:粒子生成后的存活总时长

确保深度测试处于关闭状态,并通过以下语句启用 Alpha 混合:

glDisable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

还需要为每个点精灵设置合理的尺寸。例如,以下代码行将点精灵的尺寸设为 10 像素:

glPointSize(10.0f);

如何做

顶点着色器代码

#version 400
// 初始速度与生成时刻
layout (location = 0) in vec3 VertexInitVel; 
layout (location = 1) in float StartTime;

out float Transp; // 粒子的透明度

uniform float Time; // 动画累计时间
uniform vec3 Gravity = vec3(0.0,-0.05,0.0); // 世界坐标系下的重力向量(对应1/2加速度)
uniform float ParticleLifetime; // 粒子最大存活时长
uniform mat4 MVP; // 模型视图投影矩阵

void main()
{
    // 假定粒子初始位置为(0,0,0)
    vec3 pos = vec3(0.0);
    Transp = 0.0; // 初始透明度为0(不可见)

    // 粒子在生成时刻前不显示
    if( Time > StartTime ) {
        float t = Time - StartTime; // 粒子的相对存活时长
        // 若粒子未超过最大存活时长
        if( t < ParticleLifetime ) {
            // 计算粒子当前位置(位移公式:s = v0*t + 1/2*a*t²,Gravity已预存1/2*a)
            pos = VertexInitVel * t + Gravity * t * t;
            // 透明度线性衰减:存活越久,透明度越低
            Transp = 1.0 - t / ParticleLifetime;
        }
    }
    // 将粒子位置转换为裁剪坐标系下的坐标
    gl_Position = MVP * vec4(pos, 1.0);
}

片元着色器代码

#version 400
in float Transp; // 从顶点着色器传入的粒子透明度
uniform sampler2D ParticleTex; // 粒子纹理采样器

layout ( location = 0 ) out vec4 FragColor; // 片元输出颜色

void main(){
    // 采样粒子纹理(gl_PointCoord为点精灵的自动纹理坐标)
    FragColor = texture(ParticleTex, gl_PointCoord);
    // 将纹理的Alpha值与粒子透明度相乘,实现线性淡出
    FragColor.a *= Transp;
}
0 Answers