20-粒子系统和输出流

在本章中,我们关心的是对一组粒子(通常很小)进行建模的任务,这些粒子的行为都类似但有点随机; 我们称这样的粒子集合为粒子系统。 粒子系统可用于模拟各种各样的现象,如火灾,雨水,烟雾,爆炸,喷水,魔法效果和射弹。
目标:
1.学习如何使用几何着色器和流出功能有效地存储和渲染粒子。
2.了解我们如何使用基本的物理概念使我们的粒子以物理逼真的方式运动。
3.设计灵活的粒子系统框架,可以轻松创建新的定制粒子系统。

20.1粒子表示

粒子是一个非常小的物体,通常以数学方式将其建模为点。 因此,一个点基元(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST)将成为显示粒子的理想选择。 但是,点基元被光栅化为单个像素。 这不会给我们太大的灵活性,因为我们希望有各种尺寸的粒子,甚至将整个纹理映射到这些粒子上。 因此,我们将采用一种类似于第11章树形广告牌的策略:我们将使用点存储粒子,然后将它们展开为几何着色器中面向相机的四边形。 与树形广告牌相比,它们与世界y轴一致,而广告牌则完全面向相机(见图20.1)。

如果我们知道世界上向量j,广告牌的中心位置C和眼睛位置E的世界坐标,那么我们可以用世界坐标来描述广告牌的本地框架,这就给了我们广告牌的世界矩阵:

w=EC||EC||u=j×w||j×w||v=w×uw=uxvxwxCxuyvywyCyuzvzwzCz0001 w = E − C | | E − C | | u = j × w | | j × w | | v = w × u w = [ u x u y u z 0 v x v y v z 0 w x w y w z 0 C x C y C z 1 ]


图20.1 世界和广告牌框架。广告牌面向眼睛位置E.

除了位置和大小之外,我们的粒子还有其他属性。 我们的粒子顶点结构如下所示:

struct Particle
{
XMFLOAT3 InitialPos;
XMFLOAT3 InitialVel;
XMFLOAT2 Size;
float Age;
unsigned int Type;
};

NOTE:我们不需要将点扩大到四分之一。 例如,使用线条列表渲染雨水效果非常好。我们可以使用不同的几何着色器将点展开为线条。 基本上,在我们的系统中,每个粒子系统都会有自己的效果文件。 效果文件实现特定于与其相关联的特定类型的粒子系统的细节。

20.2粒子运动
我们希望我们的粒子以真实逼真的方式移动。 为了简单起见,在本书中,我们将自己限制在一个恒定的净加速度; 例如,由于重力引起的加速度。 (我们也可以通过使其他力的加速度也是恒定的,比如风来做出松散的近似值)。另外,我们不会使用我们的粒子进行任何碰撞检测。

令p(t)描述沿着曲线移动的粒子(在时间t)的位置。时间t时粒子的瞬时速度为:

v(t)=p(t) v ( t ) = p ′ ( t )

时间t处粒子的瞬时加速度为:
a(t)=v(t)=p′′(t) a ( t ) = v ′ ( t ) = p ″ ( t )

从微积分回忆以下内容:
1.函数f(t)的一个反导函数是任意函数F(t),使得F(t)的导数是f(t); 即F’(t)= f(t)。

2.如果F(t)是f(t)的任何一个反导函数,c是任意常数,那么F(t)+ c也是f(t)的一个反导函数。 此外,f(t)的每个反导因子都有F(t)+ c的形式。

3.为了表示f(t)的任意一个导数,我们使用积分表示法
∫f(t)dt = F(t)+ c。
从速度和加速度的定义可以明显看出,速度函数是加速函数的反导函数,而位置函数是速度函数的反导函数。 因此我们有:

p(t)=v(t)dtv(t)=a(t)dt p ( t ) = ∫ v ( t ) d t v ( t ) = ∫ a ( t ) d t

现在,假定加速度是恒定的(即它不随时间变化)。假设我们知道时间t=0时的初始粒子速度 v(0)=v0 v ( 0 ) = v 0 和初始粒子位置 p(0)=p0 p ( 0 ) = p 0 。然后通过积分恒定加速度得到速度函数:
v(t)=a(t)dt=ta+c v ( t ) = ∫ a ( t ) d t = t a + c

为了找到常数c,我们使用我们的初始速度:
v(0)=0a+c=c=v0 v ( 0 ) = 0 · a + c = c = v 0

所以速度函数为
v(t)=ta+v0 v ( t ) = t a + v 0

为了找到位置函数,我们整合刚发现的速度函数:
p(t)=v(t)dt=(ta+v0)dt=12t2a+tv0+k p ( t ) = ∫ v ( t ) d t = ∫ ( t a + v 0 ) d t = 1 2 t 2 a + t v 0 + k

为了找到常数k,我们使用我们的初始位置:
p(0)=120a+0v0+k=k=P0 p ( 0 ) = 1 2 · 0 · a + 0 · v 0 + k = k = P 0

所以位置函数是
p(t)=12t2a+tv0+p0 p ( t ) = 1 2 t 2 a + t v 0 + p 0

换句话说,粒子的轨迹p(t)(即在任何时刻t≥0时的位置)完全由其初始位置,初始速度和加速度常数决定。 这是合理的,因为如果我们知道我们从哪里开始,我们开始朝哪个方向走多快和多快,并且我们知道我们如何在所有时间加速,那么我们应该能够找出我们遵循的道路。

让我们看一个例子。 假设你有一个小型大炮坐在坐标系的原点,瞄准从x轴测量的30°角(见图20.2)。 所以在这个坐标系中,p0 =(0,0,0)(即炮弹的初始位置在原点),由重力引起的恒定加速度为a =(0,-9.8,0)m / s2(即,由于重力引起的加速度为每秒9.8平方米)。 另外,假设从以前的测试中,我们已经确定在大炮发生火灾时,炮弹球的初始速度为每秒50米。 因此,初始速度是v0 = 50(cos30°,sin30°,0)m / s≈(43.3,25.0,0)m / s [记住速度是速度和方向,所以我们将速度乘以单位方向矢量 (cos 30°,sin30°,0)]。 因此炮弹的轨迹由下式给出:


图20.2。 粒子在xy平面中随时间推移的路径(时间维度未显示),给定初始位置和速度,并且由于重力而经历恒定的加速度。

p(t)=12t2a+tv0+p0=12t2(0,9.8,0)m/s2+t(43.3,25.0,0)m/s p ( t ) = 1 2 t 2 a + t v 0 + p 0 = 1 2 t 2 ( 0 , − 9.8 , 0 ) m / s 2 + t ( 43.3 , 25.0 , 0 ) m / s

如果我们在xy平面中绘制这个图(z坐标总是为零),我们得到图20.2,这是我们期望的重力轨迹。

NOTE:您也可以选择不使用之前派生的函数。 如果你已经知道你想要粒子采用的轨迹函数p(t),那么你可以直接编程它。 例如,如果你想让你的粒子遵循一个椭圆轨道,那么你可以使用p(t)的椭圆参数方程。

20.3 随机性

在粒子系统中,我们希望粒子的行为类似,但不完全相同; 换句话说,我们想为系统添加一些随机性。 例如,如果我们在模拟雨滴,我们不希望雨滴以完全相同的方式落下;我们希望它们从不同的位置,以稍微不同的角度,以稍微不同的速度落下。 为了促进粒子系统所需的随机性功能,我们使用MathHelper.h / .cpp中实现的RandF和RandUnitVec3函数:

// Returns random float in [0, 1).
static float RandF()
{
return (float)(rand()) / (float)RAND_MAX;
}
// Returns random float in [a, b).
static float RandF(float a, float b)
{
return a + RandF()*(b-a);
}
XMVECTOR MathHelper::RandUnitVec3()
{
XMVECTOR One = XMVectorSet(1.0f, 1.0f, 1.0f, 1.0f);
XMVECTOR Zero = XMVectorZero();
// Keep trying until we get a point on/in the hemisphere.
while(true)
{
// Generate random point in the cube [-1,1]^3.
XMVECTOR v = XMVectorSet(
MathHelper::RandF(-1.0f, 1.0f),
MathHelper::RandF(-1.0f, 1.0f),
MathHelper::RandF(-1.0f, 1.0f), 0.0f);
// Ignore points outside the unit sphere in order to
// get an even distribution over the unit sphere. Otherwise
// points will clump more on the sphere near the corners
// of the cube.
if(XMVector3Greater(XMVector3LengthSq(v), One))
continue;
return XMVector3Normalize(v);
}
}

以前的函数适用于C ++代码,但我们还需要着色器代码中的随机数字。在着色器中生成随机数是棘手的,因为我们没有着色器随机数生成器。所以我们所做的是创建一个具有四个浮点组件的DXFI_FORMAT_R32G32B32A32_FLOAT。我们使用坐标在区间[-1,1]中的随机4D向量填充纹理。纹理将使用换行地址模式进行采样,以便我们可以使用区间[0,1]以外的无限纹理坐标。着色器代码然后将采样该纹理以获得一个随机数。有不同的方法来抽样随机纹理。如果每个粒子具有不同的x坐标,我们可以使用x坐标作为纹理坐标来获得一个随机数。然而,如果许多粒子具有相同的x坐标,那么它们将不会很好地工作,因为它们都将在纹理中采样相同的值,这不会非常随机。另一种方法是将当前游戏时间值用作纹理坐标。这样,不同时间产生的粒子就会得到不同的随机值。但是,这意味着同时生成的粒子将具有相同的值。如果粒子系统需要一次发射多个粒子,这可能是一个问题。当同时生成多个粒子时,我们可以在游戏时间中添加不同的纹理坐标偏移值,以便在纹理贴图上采样不同的点,从而得到不同的随机值。例如,如果我们循环20次以创建20个粒子,我们可以使用循环索引(适当缩放)来抵消用于对随机纹理进行采样的纹理坐标。这样,我们会得到20个不同的随机值。

以下代码显示了如何生成随机纹理:

ID3D11ShaderResourceView* d3dHelper::CreateRandomTexture1DSRV(
ID3D11Device* device)
{
//
// Create the random data.
// XMFLOAT4 randomValues[1024];
for(int i = 0; i < 1024; ++i)
{
randomValues[i].x = MathHelper::RandF(-1.0f, 1.0f);
randomValues[i].y = MathHelper::RandF(-1.0f, 1.0f);
randomValues[i].z = MathHelper::RandF(-1.0f, 1.0f);
randomValues[i].w = MathHelper::RandF(-1.0f, 1.0f);
}
D3D11_SUBRESOURCE_DATA initData;
initData.pSysMem = randomValues;
initData.SysMemPitch = 1024*sizeof(XMFLOAT4);
initData.SysMemSlicePitch = 0;
//
// Create the texture.
// D3D11_TEXTURE1D_DESC texDesc;
texDesc.Width = 1024;
texDesc.MipLevels = 1;
texDesc.Format = DXGI_FORMAT_R32G32B32A32_FLOAT;
texDesc.Usage = D3D11_USAGE_IMMUTABLE;
texDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
texDesc.CPUAccessFlags = 0;
texDesc.MiscFlags = 0;
texDesc.ArraySize = 1;
ID3D11Texture1D* randomTex = 0;
HR(device->CreateTexture1D(&texDesc, &initData, &randomTex));
//
// Create the resource view.
// D3D11_SHADER_RESOURCE_VIEW_DESC viewDesc;
viewDesc.Format = texDesc.Format;
viewDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE1D;
viewDesc.Texture1D.MipLevels = texDesc.MipLevels;
viewDesc.Texture1D.MostDetailedMip = 0;
ID3D11ShaderResourceView* randomTexSRV = 0;
HR(device->CreateShaderResourceView(randomTex, &viewDesc,
&randomTexSRV));
ReleaseCOM(randomTex);
return randomTexSRV;
}

请注意,对于随机纹理,我们只需要一个mipmap级别。 为了仅用一个mipmap对纹理进行采样,我们使用SampleLevel内部函数。 该功能允许我们明确指定我们想要采样的mipmap级别。 这个函数的第一个参数是采样器; 第二个参数是纹理坐标(对于1D纹理只有一个); 第三个参数是mipmap级别(在只有一个mipmap级别的纹理的情况下应为0)。

以下着色器函数用于获取单位球体上的随机向量:

float3 RandUnitVec3(float offset)
{
// Use game time plus offset to sample random texture.
float u = (gGameTime + offset);
// coordinates in [-1,1]
float3 v = gRandomTex.SampleLevel(samLinear, u, 0).xyz;
// project onto unit sphere
return normalize(v);
}

20.4混合和粒子系统

粒子系统通常以某种形式的混合来绘制。 对于诸如火焰和魔法等效果,我们希望颜色强度在粒子的位置变亮。 为此,添加剂混合效果很好。 也就是说,我们只需将源和目标颜色相加即可。 但是,颗粒通常也是透明的; 因此,我们必须通过其不透明度来缩放源粒子的颜色; 也就是说,我们使用混合参数:

SrcBlend = SRC_ALPHA;
DestBlend = ONE;
BlendOp = ADD;

这给出了混合等式:

C=asCsrc+Cdst C = a s C s r c + C d s t

换句话说,源粒子对总和的贡献量取决于其不透明度:粒子越不透明,贡献的颜色就越多。 另一种方法是预先将纹理与其不透明度(由alpha通道描述)相乘,以便纹理颜色根据其不透明度进行稀释。 然后我们使用稀释的纹理。 在这种情况下,我们可以使用混合参数:

SrcBlend = ONE;
DestBlend = ONE;
BlendOp = ADD;

这是因为我们基本上预先计算为 Csrc C s r c 并将其直接烘焙到纹理数据中。

添加剂的混合也有很好的效果,使得与那里的颗粒浓度成比例的区域变亮(由于颜色的累积累积); 因此,浓度较高的区域显得更加明亮,这通常是我们想要的(见图20.3)。

对于像烟雾之类的东西,添加混合不起作用,因为添加一串重叠的烟雾粒子的颜色最终会使烟雾变亮,从而使其不再变黑。与减法运算符(D3D11_BLEND_OP_REV_SUBTRACT)混合可以更好地处理烟雾,其中烟雾颗粒会从目标中减去颜色。通过这种方式,较高浓度的烟雾颗粒会导致更黑的颜色,从而产生浓烟的幻觉,而较低浓度的烟雾颗粒会导致轻微的色调,从而产生薄烟雾的幻觉。然而,虽然这对黑烟很好,但对于浅灰烟或蒸汽来说效果不佳。烟的另一种可能性是使用透明度混合,我们只是将烟雾粒子视为半透明物体,并使用透明度混合来渲染它们。透明度混合的主要问题是将系统中的粒子相对于眼睛以前后顺序进行排序。这可能是昂贵且不切实际的。由于粒子系统的随机性,有时可以打破这条规则,而不会出现明显的渲染错误。请注意,如果许多粒子系统处于场景中,则系统仍应按照从前到后的顺序进行排序;我们只是不会将系统的粒子相对于彼此进行排序。请注意,使用混合时,适用§9.5.4和§9.5.5中的讨论。


图20.3 使用添加剂混合时,强度在靠近源点的地方更大,在该点更多的粒子重叠并被添加在一起。 随着颗粒的扩散,强度减弱,因为有更少的颗粒重叠并被加在一起。

20.5流出

我们知道GPU可以写入纹理。例如,GPU处理写入深度/模板缓冲区以及后台缓冲区。 Direct3D 10中引入的功能是流出(SO)阶段。 SO阶段允许GPU实际将几何结构(以顶点列表的形式)写入绑定到流水线的SO阶段的顶点缓冲区V. 具体来说,从几何着色器输出的顶点被写入(或流出)到V.然后可以稍后绘制V中的几何图形。 图20.4说明了这个想法(省略了镶嵌阶段)。 流出将在我们的粒子系统框架中发挥重要作用。


图20.4 原始材料通过管道泵送。 几何着色器输出被流式传输到GPU内存中的顶点缓冲区的基元。

20.5.1为流出创建几何着色器

在使用流出时,必须专门创建几何着色器。 以下代码显示了这是如何在效果文件中完成的:

GeometryShader gsStreamOut = ConstructGSWithSO(
CompileShader(gs_5_0, GS()),
"POSITION.xyz; VELOCITY.xyz; SIZE.xy; AGE.x; TYPE.x");
technique11 SOTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(gsStreamOut);
SetPixelShader(CompileShader(ps_5_0, PS()));
}
}

ConstructGSWithSO的第一个参数就是编译的几何着色器。 第二个参数是一个字符串,它描述了正在流出的顶点的格式(即几何着色器输出的顶点的格式)。 在前面的例子中,顶点格式是:

struct Vertex
{
float3 InitialPosW : POSITION;
float3 InitialVelW : VELOCITY;
float2 SizeW : SIZE;
float Age : AGE;
uint Type : TYPE;
};

20.5.2仅流出

在正常情况下使用流出时,几何着色器输出会流出到顶点缓冲区,并继续向下到渲染管线的下一阶段(光栅化)。 如果您想要一种只渲染数据并且不渲染数据的渲染技术,则必须禁用像素着色器和深度/模板缓冲区。 (禁用像素着色器和深度/模板缓冲区会禁用光栅化。)以下技术显示了如何完成此操作:

DepthStencilState DisableDepth
{
DepthEnable = FALSE;
DepthWriteMask = ZERO;
};
GeometryShader gsStreamOut = ConstructGSWithSO(
CompileShader(gs_5_0, StreamOutGS()),
"POSITION.xyz; VELOCITY.xyz; SIZE.xy; AGE.x; TYPE.x");
technique10 StreamOutTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, StreamOutVS()));
SetGeometryShader(gsStreamOut);
// disable pixel shader for stream-out only
SetPixelShader(NULL);
// we must also disable the depth buffer for stream-out only
SetDepthStencilState(DisableDepth, 0);
}
}

或者,您可以将空渲染目标和空深度/模板缓冲区绑定到渲染管道的输出合并阶段。

在我们的粒子系统中,我们将只使用流出技术。 该技术将仅用于创建和销毁粒子(即更新粒子系统)。 每一帧:
1.当前粒子列表将仅用流出来绘制。 由于光栅化单元被禁用,因此这不会在屏幕上显示任何粒子。
2.该通道的几何着色器将基于各种条件(从粒子系统到粒子系统)创建/销毁粒子。
3.更新的粒子列表将流式传输到顶点缓冲区。
然后应用程序将使用不同的渲染技术为该帧绘制更新的粒子列表。 使用两种技术的主要原因是几何着色器只是做不同的事情。 对于仅流出的技术,几何着色器输入粒子,更新它们并输出粒子。 对于绘图技术,几何着色器的任务是将点扩展为面向相机的四边形。 所以几何着色器甚至不输出相同类型的基元,因此我们需要两个几何着色器。

总而言之,我们需要两种技术来在GPU上呈现我们的粒子系统:
1.仅用于更新粒子系统的流出输出技术。
2.用于绘制粒子系统的渲染技术。

NOTE:粒子物理也可以在只输出流的情况下逐步更新。 然而,在我们的设置中,我们有一个明确的位置函数p(t)。 所以我们不需要在流出输出中递增地更新粒子的位置/速度。 但是,ParticlesGS SDK示例仅使用流出输出来更新物理,因为它们使用不同的物理模型。

20.5.3为流出创建一个顶点缓冲区

为了将顶点缓冲区绑定到SO阶段,以便GPU可以向其写入顶点,必须使用D3D11_BIND_STREAM_OUTPUT绑定标志创建顶点缓冲区。 通常,用作流输出目标的顶点缓冲区也将被用作稍后输入到流水线中的输入(即,它将被绑定到IA阶段,以便可以绘制内容)。 因此,我们还必须指定D3D11_BIND_VERTEX_BUFFER绑定标志。 以下代码片段显示了为Streamout用法创建顶点缓冲区的示例:

D3D11_BUFFER_DESC vbd;
vbd.Usage = D3D11_USAGE_DEFAULT;
vbd.ByteWidth = sizeof(Vertex) * MAX_VERTICES;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT;
vbd.CPUAccessFlags = 0;
vbd.MiscFlags = 0;
HR(md3dDevice->CreateBuffer(&vbd, 0, &mStreamOutVB));

请注意,缓冲存储器保持未初始化状态。 这是因为GPU将向其写入顶点数据。 还要注意缓冲区的大小是有限的,所以应该注意不要流出超过最大值的顶点。

20.5.4绑定到SO阶段

使用D3D11_BIND_STREAM_OUTPUT绑定标志创建的顶点缓冲区可以绑定到流水线的SO阶段,以便使用以下方法进行写入:

void ID3D11DeviceContext::SOSetTargets(
UINT NumBuffers,
ID3D11Buffer *const *ppSOTargets,
const UINT *pOffsets);

1.NumBuffers:作为目标绑定到SO阶段的顶点缓冲区的数量。 最大值是4。
2.ppSOTargets:绑定到SO阶段的顶点缓冲区数组。
3.pOffsets:偏移量数组,每个顶点缓冲区都有一个偏移量,指示SO阶段应该开始写入顶点的起始位置。

NOTE:有四个输出插槽用于输出。 如果少于四个缓冲区绑定到SO阶段,则其他插槽将设置为空。 例如,如果您只绑定到插槽0(第一个插槽),则插槽1,2和3将设置为空。

20.5.5从流出阶段解除绑定

在将顶点流出到顶点缓冲区之后,我们可能想绘制这些顶点定义的基元。 但是,顶点缓冲区不能绑定到SO阶段并同时绑定到IA阶段。 为了从SO阶段中解除一个顶点缓冲区的绑定,我们只需要在它的位置绑定一个不同的缓冲区到SO阶段(可以是null)。 下面的代码通过绑定一个空的缓冲区来从插槽0解除绑定一个顶点缓冲区:

ID3D11Buffer* bufferArray[1] = {0};
md3dDeviceContext->SOSetTargets(1, bufferArray, &offset);

20.5.6自动绘制
流出到顶点缓冲区的几何体可以是可变的。 那么我们绘制了多少个顶点? 幸运的是,Direct3D在内部跟踪计数,我们可以使用ID3D11DeviceContext :: DrawAuto方法绘制写入顶点缓冲区的几何图形,并使用SO:

void ID3D11DeviceContext::DrawAuto();

在调用DrawAuto之前,我们必须先将顶点缓冲区(用作流出目标)绑定到
1.输入IA阶段的插槽0。 DrawAuto方法只能在具有D3D11_BIND_STREAM_OUTPUT绑定标志的顶点缓冲区绑定到IA阶段的输入插槽0时使用。
2.使用DrawAuto进行绘制时,我们仍然必须指定流输出顶点缓冲区中顶点的顶点输入布局
3.DrawAuto不使用索引,因为几何着色器仅输出由顶点列表定义的完整基元。
20.5.7乒乓顶点缓冲区
如前所述,顶点缓冲区不能绑定到OS阶段,并且同时绑定到IA阶段。因此,使用乒乓方案。 使用流出进行绘制时,我们使用两个顶点缓冲区。 一个将用作输入缓冲区,另一个将用作输出缓冲区。 在下一个渲染帧中,两个缓冲区的角色相反。 刚刚流入的缓冲区成为新的输入缓冲区,旧的输入缓冲区成为新的流出目标。下表显示了使用顶点缓冲区 V0,V1 V 0 , V 1 的三次迭代。

\documentclass{article}
\begin{document}
\begin{tabular}{|l|c|r|} %l(left)居左显示 r(right)居右显示 c居中显示
\hline 
&输入顶点缓冲区绑定到IA阶段&输出顶点缓冲区绑定到SO阶段\\
\hline  
第i帧&V_0&V_1\\
\hline 
第i+1帧&V_1&V_0\\
\hline 
第i+2帧&V_0&V_1\\
\hline 
\end{tabular}
\end{document}
\documentclass{article}\begin{document}\begin{tabular}{|l|c|r|} %l(left)居左显示 r(right)居右显示 c居中显示\hline &输入顶点缓冲区绑定到IA阶段&输出顶点缓冲区绑定到SO阶段\\\hline  第i帧&V_0&V_1\\\hline 第i+1帧&V_1&V_0\\\hline 第i+2帧&V_0&V_1\\\hline \end{tabular}\end{document}

20.6基于GPU的粒子系统

粒子系统通常随着时间发射并破坏粒子。 看似自然的做法是使用动态顶点缓冲区并跟踪CPU上的粒子和粒子。 然后,顶点缓冲区将填充当前活动的粒子并绘制。 但是,从前一节我们知道,单独的流出输出通道可以完全在GPU上处理此产卵/终止更新周期。 这样做的动机是效率 - 每当CPU需要将数据上传到GPU时会产生一些开销; 此外,它将工作从CPU转移到GPU,从而将CPU释放出来用于AI或物理等其他任务。 在本节中,我们将解释我们的粒子系统框架的一般细节。

20.6.1粒子效应

关于特定粒子系统如何表现的具体细节在效果文件中实现。也就是说,我们将为每个粒子系统(例如,雨,火,烟等)制作一个不同的(但相似的)效果文件。粒子如何发射,破坏和绘制的细节都在相应的效果文件中编写脚本,因为它们因系统而异。例子:
1.我们可能会在撞击地面时摧毁一个雨滴,而火焰粒子会在几秒钟之后被摧毁。
2.烟雾颗粒可能会随着时间消逝,而雨滴颗粒则不会。同样,烟雾颗粒的大小可能会随着时间而扩大,而雨滴颗粒则不会。
3.线性原语通常适用于雨水建模,而广告牌四元素则用于火/烟粒子。通过对不同的粒子系统使用不同的效果,我们可以让几何着色器将点展开为线条以表示下雨,并指向火/烟的四边形。
4.雨和烟雾颗粒的初始位置和速度显然不同。

重申一下,这些粒子系统的具体细节可以在效果文件中实现,因为在我们的系统中,着色器代码处理粒子的创建,销毁和更新。 这种设计非常方便,因为要添加一个新的粒子系统,我们只需编写一个描述其行为的新效果文件。
20.6.2粒子系统类
下面显示的类处理用于创建,更新和绘制粒子系统的C ++相关代码。 这个代码是一般的,并将应用于我们创建的所有粒子系统。

class ParticleSystem
{
public:
ParticleSystem();
~ParticleSystem();
// Time elapsed since the system was reset.
float GetAge()const;
void SetEyePos(const XMFLOAT3& eyePosW);
void SetEmitPos(const XMFLOAT3& emitPosW);
void SetEmitDir(const XMFLOAT3& emitDirW);
void Init(ID3D11Device* device, ParticleEffect* fx,
ID3D11ShaderResourceView* texArraySRV,
ID3D11ShaderResourceView* randomTexSRV,
UINT maxParticles);
void Reset();
void Update(float dt, float gameTime);
void Draw(ID3D11DeviceContext* dc, const Camera& cam);
private:
void BuildVB(ID3D11Device* device);
ParticleSystem(const ParticleSystem& rhs);
ParticleSystem& operator=(const ParticleSystem& rhs);
private:
UINT mMaxParticles;
bool mFirstRun;
float mGameTime;
float mTimeStep;
float mAge;
XMFLOAT3 mEyePosW;
XMFLOAT3 mEmitPosW;
XMFLOAT3 mEmitDirW;
ParticleEffect* mFX;
ID3D11Buffer* mInitVB;
ID3D11Buffer* mDrawVB;
ID3D11Buffer* mStreamOutVB;
ID3D11ShaderResourceView* mTexArraySRV;
ID3D11ShaderResourceView* mRandomTexSRV;
};

除了绘制方法之外,现在粒子系统类方法的实现是相当常规的(例如,创建顶点缓冲区)。 因此,我们不会在书中显示它们(请参阅相应章节的源代码)。

NOTE:粒子系统使用纹理数组来纹理粒子。 这个想法是我们可能不希望所有的粒子看起来完全一样。 例如,为了实现烟雾粒子系统,我们可能想要使用几种烟雾纹理来添加一些变化; 原始ID可以在像素着色器中用于在纹理数组中的烟雾纹理之间交替。

20.6.3发射粒子

由于几何着色器负责创建/销毁粒子,因此我们需要有特殊的发射器粒子。发射粒子可能会或可能不会被绘制。例如,如果您希望发射器粒子不可见,那么只需要粒子绘制几何着色器不输出它们。发射体粒子在系统中的行为与其他粒子的行为不同,因为它们可以产生其他粒子。例如,发射器粒子可以跟踪已经经过了多少时间,并且当该时间到达某个点时,它发射新的粒子。这样,随着时间的推移会产生新的粒子。我们使用顶点::粒子结构的类型成员来指示发射器粒子。而且,通过限制哪些粒子被允许发射其他粒子,它使我们能够控制粒子如何发射。例如,通过只有一个发射器粒子,很容易控制每帧会创建多少个粒子。只有流的几何着色器应始终输出至少一个发射器粒子,因为如果粒子系统丢失其所有发射器,它就会有效地死亡;然而,对于一些粒子系统来说,这可能是期望的结果。

我们在本书中演示的粒子系统在粒子系统的生命周期中仅使用一个发射器粒子。但是,如果需要,可以扩展粒子系统框架以使用更多。而且,其他颗粒也可以发射颗粒。例如,SDK的ParticlesGS演示包含一个启动粒子(一种产生外壳粒子的不可见粒子),外壳粒子(未爆炸的烟花,在一段时间后会爆炸成新的余烬粒子),其中一些余烬也会爆炸成新的二次余烬粒子,造成二次爆炸。在这个意义上,发射器粒子可以发射其他发射器粒子。练习1要求你探索这个。

20.6.4初始化顶点缓冲区

在我们的粒子系统中,我们有一个特殊的初始化顶点缓冲区。这个顶点缓冲区只包含一个发射器粒子。我们首先绘制这个顶点缓冲区来启动粒子系统。这个发射器粒子将在随后的帧中开始产生其他粒子。请注意,初始化顶点缓冲区只绘制一次(系统复位时除外)。在用单个发射器粒子初始化粒子系统之后,我们以乒乓方式使用两个流输出顶点缓冲区。

如果我们需要从头开始重新启动系统,则初始化顶点缓冲区也很有用。 我们可以使用这段代码重新启动粒子系统:

void ParticleSystem::Reset()
{
mFirstRun = true;
mAge = 0.0f;
}

将mFirstRun设置为true将指示粒子系统在下一次绘制调用时绘制初始化顶点缓冲区,从而使用单个发射器粒子重新启动粒子系统。

20.6.5更新/绘图方法

回想一下,我们需要两种技术来在GPU上呈现我们的粒子系统:
1.仅用于更新粒子系统的流出输出技术。
2.用于绘制粒子系统的渲染技术。
下面的代码是这样做的,除了处理两个顶点缓冲区的乒乓方案:

void ParticleSystem::Draw(ID3D11DeviceContext* dc, const Camera& cam)
{
XMMATRIX VP = cam.ViewProj();
//
// Set constants.
//m FX->SetViewProj(VP);
mFX->SetGameTime(mGameTime);
mFX->SetTimeStep(mTimeStep);
mFX->SetEyePosW(mEyePosW);
mFX->SetEmitPosW(mEmitPosW);
mFX->SetEmitDirW(mEmitDirW);
mFX->SetTexArray(mTexArraySRV);
mFX->SetRandomTex(mRandomTexSRV);
//
// Set IA stage.
//
dc->IASetInputLayout(InputLayouts::Particle);
dc->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);
UINT stride = sizeof(Vertex::Particle);
UINT offset = 0;
// On the first pass, use the initialization VB. Otherwise, use
// the VB that contains the current particle list.
if( mFirstRun )
dc->IASetVertexBuffers(0, 1, &mInitVB, &stride, &offset);
else
dc->IASetVertexBuffers(0, 1, &mDrawVB, &stride, &offset);
//
// Draw the current particle list using stream-out only to update them.
// The updated vertices are streamed-out to the target VB.
//
dc->SOSetTargets(1, &mStreamOutVB, &offset);
D3DX11_TECHNIQUE_DESC techDesc;
mFX->StreamOutTech->GetDesc(&techDesc);
for(UINT p = 0; p < techDesc.Passes; ++p)
{
mFX->StreamOutTech->GetPassByIndex( p )->Apply(0, dc);
if(mFirstRun)
{
dc->Draw(1, 0);
mFirstRun = false;
}
else
{
dc->DrawAuto();
}
}
// done streaming-out--unbind the vertex buffer
ID3D11Buffer* bufferArray[1] = {0};
dc->SOSetTargets(1, bufferArray, &offset);
// ping-pong the vertex buffers
std::swap(mDrawVB, mStreamOutVB);
//
// Draw the updated particle system we just streamed-out.
//
dc->IASetVertexBuffers(0, 1, &mDrawVB, &stride, &offset);
mFX->DrawTech->GetDesc(&techDesc);
for(UINT p = 0; p < techDesc.Passes; ++p)
{
mFX->DrawTech->GetPassByIndex(p)->Apply(0, dc);
dc->DrawAuto();
}
}


图20.5 粒子系统演示屏幕截图显示火灾。

20.7 火

以下是渲染火焰粒子系统的效果。 它由两种技术组成:
1.仅用于更新粒子系统的流出输出技术。
2.用于绘制粒子系统的渲染技术。
编程到这两种技术中的逻辑通常会因粒子系统而不同,因为销毁/产卵/渲染规则将会不同。 火焰粒子在发射位置发射,但被赋予随机的初始速度以传播火焰以产生火球。

//***********************************************
// GLOBALS *
//***********************************************
cbuffer cbPerFrame
{
float3 gEyePosW;
// for when the emit position/direction is varying
float3 gEmitPosW;
float3 gEmitDirW;
float gGameTime;
float gTimeStep;
float4x4 gViewProj;
};
cbuffer cbFixed
{
// Net constant acceleration used to accerlate the particles.
float3 gAccelW = {0.0f, 7.8f, 0.0f};
// Texture coordinates used to stretch texture over quad
// when we expand point particle into a quad.
float2 gQuadTexC[4] =
{
float2(0.0f, 1.0f),
float2(1.0f, 1.0f),
float2(0.0f, 0.0f),
float2(1.0f, 0.0f)
};
};
// Array of textures for texturing the particles.
Texture2DArray gTexArray;
// Random texture used to generate random numbers in shaders.
Texture1D gRandomTex;
SamplerState samLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = WRAP;
AddressV = WRAP;
};
DepthStencilState DisableDepth
{
DepthEnable = FALSE;
DepthWriteMask = ZERO;
};
DepthStencilState NoDepthWrites
{
DepthEnable = TRUE;
DepthWriteMask = ZERO;
};
BlendState AdditiveBlending
{
AlphaToCoverageEnable = FALSE;
BlendEnable[0] = TRUE;
SrcBlend = SRC_ALPHA;
DestBlend = ONE;
BlendOp = ADD;
SrcBlendAlpha = ZERO;
DestBlendAlpha = ZERO;
BlendOpAlpha = ADD;
RenderTargetWriteMask[0] = 0x0F;
};
//***********************************************
// HELPER FUNCTIONS *
//***********************************************
float3 RandUnitVec3(float offset)
{
// Use game time plus offset to sample random texture.
float u = (gGameTime + offset);
// coordinates in [-1,1]
float3 v = gRandomTex.SampleLevel(samLinear, u, 0).xyz;
// project onto unit sphere
return normalize(v);
}
//***********************************************
// STREAM-OUT TECH *
//***********************************************
#define PT_EMITTER 0
#define PT_FLARE 1
struct Particle
{
float3 InitialPosW : POSITION;
float3 InitialVelW : VELOCITY;
float2 SizeW : SIZE;
float Age : AGE;
uint Type : TYPE;
};
Particle StreamOutVS(Particle vin)
{
return vin;
}
// The stream-out GS is just responsible for emitting
// new particles and destroying old particles. The logic
// programed here will generally vary from particle system
// to particle system, as the destroy/spawn rules will be
// different.
[maxvertexcount(2)]
void StreamOutGS(point Particle gin[1],
inout PointStream<Particle> ptStream)
{
gin[0].Age += gTimeStep;
if(gin[0].Type == PT_EMITTER)
{
// time to emit a new particle?
if(gin[0].Age > 0.005f)
{
float3 vRandom = RandUnitVec3(0.0f);
vRandom.x *= 0.5f;
vRandom.z *= 0.5f;
Particle p;
p.InitialPosW = gEmitPosW.xyz;
p.InitialVelW = 4.0f*vRandom;
p.SizeW = float2(3.0f, 3.0f);
p.Age = 0.0f;
p.Type = PT_FLARE;
ptStream.Append(p);
// reset the time to emit
gin[0].Age = 0.0f;
}
// always keep emitters
ptStream.Append(gin[0]);
}
else
{
// Specify conditions to keep particle; this may vary
// from system to system.
if(gin[0].Age <= 1.0f)
ptStream.Append(gin[0]);
}
}
GeometryShader gsStreamOut = ConstructGSWithSO(
CompileShader(gs_5_0, StreamOutGS()),
"POSITION.xyz; VELOCITY.xyz; SIZE.xy; AGE.x; TYPE.x");
technique11 StreamOutTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, StreamOutVS()));
SetGeometryShader(gsStreamOut);
// disable pixel shader for stream-out only
SetPixelShader(NULL);
// we must also disable the depth buffer for stream-out only
SetDepthStencilState(DisableDepth, 0);
}
}
//***********************************************
// DRAW TECH *
//***********************************************
struct VertexOut
{
float3 PosW : POSITION;
float2 SizeW : SIZE;
float4 Color : COLOR;
uint Type : TYPE;
};
VertexOut DrawVS(Particle vin)
{
VertexOut vout;
float t = vin.Age;
// constant acceleration equation
vout.PosW = 0.5f*t*t*gAccelW + t*vin.InitialVelW + vin.InitialPosW;
// fade color with time
float opacity = 1.0f - smoothstep(0.0f, 1.0f, t/1.0f);
vout.Color = float4(1.0f, 1.0f, 1.0f, opacity);
vout.SizeW = vin.SizeW;
vout.Type = vin.Type;
return vout;
}
struct GeoOut
{
float4 PosH : SV_Position;
float4 Color : COLOR;
float2 Tex : TEXCOORD;
};
// The draw GS just expands points into camera facing quads.
[maxvertexcount(4)]
void DrawGS(point VertexOut gin[1],
inout TriangleStream<GeoOut> triStream)
{
// do not draw emitter particles.
if(gin[0].Type != PT_EMITTER)
{
//
// Compute world matrix so that billboard faces the camera.
//
float3 look = normalize(gEyePosW.xyz - gin[0].PosW);
float3 right = normalize(cross(float3(0,1,0), look));
float3 up = cross(look, right);
//
// Compute triangle strip vertices (quad) in world space.
//
float halfWidth = 0.5f*gin[0].SizeW.x;
float halfHeight = 0.5f*gin[0].SizeW.y;
float4 v[4];
v[0] = float4(gin[0].PosW + halfWidth*right - halfHeight*up, 1.0f);
v[1] = float4(gin[0].PosW + halfWidth*right + halfHeight*up, 1.0f);
v[2] = float4(gin[0].PosW - halfWidth*right - halfHeight*up, 1.0f);
v[3] = float4(gin[0].PosW - halfWidth*right + halfHeight*up, 1.0f);
//
// Transform quad vertices to world space and output
// them as a triangle strip.
// GeoOut gout;
[unroll]
for(int i = 0; i < 4; ++i)
{
gout.PosH = mul(v[i], gViewProj);
gout.Tex = gQuadTexC[i];
gout.Color = gin[0].Color;
triStream.Append(gout);
}
}
}
float4 DrawPS(GeoOut pin) : SV_TARGET
{
return gTexArray.Sample(samLinear, float3(pin.Tex, 0))*pin.Color;
}
technique11 DrawTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, DrawVS()));
SetGeometryShader(CompileShader(gs_5_0, DrawGS()));
SetPixelShader(CompileShader(ps_5_0, DrawPS()));
SetBlendState(AdditiveBlending, float4(0.0f, 0.0f, 0.0f, 0.0f), 0xffffffff);
SetDepthStencilState(NoDepthWrites, 0);
}
}

20.8 雨

我们还实施了雨水粒子系统。 降雨粒子系统的行为由降雨效应(rain.fx)指定。 它遵循与fire.fx类似的模式,但销毁/产卵/渲染规则不同。 例如,我们的雨滴以微小的角度向下加速,而火焰粒子向上加速。 此外,降雨粒子扩展成线而不是四边形(见图20.6)。 降雨粒子在相机上方的随机位置发射; 雨总是“跟随”相机,这样我们就不必在世界各地发出雨滴。 也就是说,只要在摄像机附近放射雨粒就足以让人感觉下雨。 请注意,雨水系统不使用任何混合。


图20.6 粒子系统演示显示下雨的截图。

//***********************************************
// GLOBALS *
//***********************************************
cbuffer cbPerFrame
{
float3 gEyePosW;
// for when the emit position/direction is varying
float3 gEmitPosW;
float3 gEmitDirW;
float gGameTime;
float gTimeStep;
float4x4 gViewProj;
};
cbuffer cbFixed
{
// Net constant acceleration used to accerlate the particles.
float3 gAccelW = {-1.0f, -9.8f, 0.0f};
};
// Array of textures for texturing the particles.
Texture2DArray gTexArray;
// Random texture used to generate random numbers in shaders.
Texture1D gRandomTex;
SamplerState samLinear
{
Filter = MIN_MAG_MIP_LINEAR;
AddressU = WRAP;
AddressV = WRAP;
};
DepthStencilState DisableDepth
{
DepthEnable = FALSE;
DepthWriteMask = ZERO;
};
DepthStencilState NoDepthWrites
{
DepthEnable = TRUE;
DepthWriteMask = ZERO;
};
//***********************************************
// HELPER FUNCTIONS *
//***********************************************
float3 RandUnitVec3(float offset)
{
// Use game time plus offset to sample random texture.
float u = (gGameTime + offset);
// coordinates in [-1,1]
float3 v = gRandomTex.SampleLevel(samLinear, u, 0).xyz;
// project onto unit sphere
return normalize(v);
}
float3 RandVec3(float offset)
{
// Use game time plus offset to sample random texture.
float u = (gGameTime + offset);
// coordinates in [-1,1]
float3 v = gRandomTex.SampleLevel(samLinear, u, 0).xyz;
return v;
}
//***********************************************
// STREAM-OUT TECH *
//***********************************************
#define PT_EMITTER 0
#define PT_FLARE 1
struct Particle
{
float3 InitialPosW : POSITION;
float3 InitialVelW : VELOCITY;
float2 SizeW : SIZE;
float Age : AGE;
uint Type : TYPE;
};
Particle StreamOutVS(Particle vin)
{
return vin;
}
// The stream-out GS is just responsible for emitting
// new particles and destroying old particles. The logic
// programed here will generally vary from particle system
// to particle system, as the destroy/spawn rules will be
// different.
[maxvertexcount(6)]
void StreamOutGS(point Particle gin[1],
inout PointStream<Particle> ptStream)
{
gin[0].Age += gTimeStep;
if(gin[0].Type == PT_EMITTER)
{
// time to emit a new particle?
if(gin[0].Age > 0.002f)
{
for(int i = 0; i < 5; ++i)
{
// Spread rain drops out above the camera.
float3 vRandom = 35.0f*RandVec3((float)i/5.0f);
vRandom.y = 20.0f;
Particle p;
p.InitialPosW = gEmitPosW.xyz + vRandom;
p.InitialVelW = float3(0.0f, 0.0f, 0.0f);
p.SizeW = float2(1.0f, 1.0f);
p.Age = 0.0f;
p.Type = PT_FLARE;
ptStream.Append(p);
}
// reset the time to emit
gin[0].Age = 0.0f;
}
// always keep emitters
ptStream.Append(gin[0]);
}
else
{
// Specify conditions to keep particle; this may vary
// from system to system.
if( gin[0].Age <= 3.0f )
ptStream.Append(gin[0]);
}
}
GeometryShader gsStreamOut = ConstructGSWithSO(
CompileShader(gs_5_0, StreamOutGS()),
"POSITION.xyz; VELOCITY.xyz; SIZE.xy; AGE.x; TYPE.x");
technique11 StreamOutTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, StreamOutVS()));
SetGeometryShader(gsStreamOut);
// disable pixel shader for stream-out only
SetPixelShader(NULL);
// we must also disable the depth buffer for stream-out only
SetDepthStencilState(DisableDepth, 0);
}
}
//***********************************************
// DRAW TECH *
//***********************************************
struct VertexOut
{
float3 PosW : POSITION;
uint Type : TYPE;
};
VertexOut DrawVS(Particle vin)
{
VertexOut vout;
float t = vin.Age;
// constant acceleration equation
vout.PosW = 0.5f*t*t*gAccelW + t*vin.InitialVelW + vin.InitialPosW;
vout.Type = vin.Type;
return vout;
}
struct GeoOut
{
float4 PosH : SV_Position;
float2 Tex : TEXCOORD;
};
// The draw GS just expands points into lines.
[maxvertexcount(2)]
void DrawGS(point VertexOut gin[1],
inout LineStream<GeoOut> lineStream)
{
// do not draw emitter particles.
if(gin[0].Type != PT_EMITTER)
{
// Slant line in acceleration direction.
float3 p0 = gin[0].PosW;
float3 p1 = gin[0].PosW + 0.07f*gAccelW;
GeoOut v0;
v0.PosH = mul(float4(p0, 1.0f), gViewProj);
v0.Tex = float2(0.0f, 0.0f);
lineStream.Append(v0);
GeoOut v1;
v1.PosH = mul(float4(p1, 1.0f), gViewProj);
v1.Tex = float2(1.0f, 1.0f);
lineStream.Append(v1);
}
}
float4 DrawPS(GeoOut pin) : SV_TARGET
{
return gTexArray.Sample(samLinear, float3(pin.Tex, 0));
}
technique11 DrawTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, DrawVS()));
SetGeometryShader(CompileShader(gs_5_0, DrawGS()));
SetPixelShader(CompileShader(ps_5_0, DrawPS()));
SetDepthStencilState(NoDepthWrites, 0);
}
}

20.9 总结

1.粒子系统是一组粒子(通常很小),它们的行为都相似,但有点随机。粒子系统可用于模拟各种各样的现象,如火灾,雨水,烟雾,爆炸,洒水和魔法效应。
2.我们用点来模拟我们的粒子,然后在渲染之前将它们展开成几何着色器中面向相机的四边形。这意味着我们可以获得使用点的效率:较小的内存占用量,我们只需应用物理到一个顶点而不是四个四边形顶点,而且,通过稍后将点扩展到四边形,我们还可以获得具有不同大小的粒子并将纹理映射到它们的能力。请注意,没有必要将点扩展到四边形。例如,线条可以用来很好地模拟雨滴;我们可以使用不同的几何图形将点展开为线条。
3.恒定加速度的粒子轨迹由式: p(t)=12t2a+tv0+p0 p ( t ) = 1 2 t 2 a + t v 0 + p 0 给出.其中a是恒定的加速度矢量, v0 v 0 是粒子的初始速度(即时间t = 0时的速度), p0 p 0 是粒子(即时间t = 0时的位置)。通过这个方程,我们可以通过评估t处的函数得到任何时刻t≥0时的粒子位置。
4.当您希望粒子系统的强度与粒子密度成比例时,请使用添加剂混合。对透明粒子使用透明度混合。不按照先后次序对透明粒子系统进行排序可能是也可能不是问题(即问题可能会或可能不明显)。通常对于粒子系统,深度写入是禁用的,因此粒子不会互相混淆。但是,深度测试仍然可用,因此非粒子对象不会遮挡粒子。
5.流出(SO)阶段允许GPU将几何形状(以顶点列表的形式)写入绑定到管线的SO阶段的顶点缓冲区V.具体来说,从几何着色器输出的顶点被写入(或流出)到V.然后可以稍后绘制V中的几何图形。我们使用这种流出功能来实现完全在GPU上运行的粒子系统。要做到这一点,我们使用两种技术:
a)仅用于更新粒子系统的流出输出技术。 在这个渲染过程中,几何着色器根据粒子系统和粒子系统之间的各种条件生成/销毁/更新粒子。 生物粒子然后被流出到顶点缓冲区。
b)用于绘制粒子系统的渲染技术。 在这个渲染过程中,我们绘制了流出的活动粒子。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值