3.12 创建一个3D爆炸效果,简单的粒子系统

问题

你想在3D世界中创建一个漂亮的3D爆炸效果。

解决方案

你可以通过混合大量的小火焰图像(叫做粒子)创建一个爆炸效果,,如图3-22所示。注意一个单独的粒子很暗,但是,如果你添加大量的粒子,就会获得一个漂亮的爆炸效果。

图3-22

图3-22 单个爆炸粒子

在爆炸的开始阶段,所有粒子都位于爆炸的初始位置,因为所有图像的颜色都叠加在了一起,所以会形成一个明亮的火球,绘制粒子时你需要使用additive blending才能获得这个效果。

随着时间流逝,粒子会从初始位置离开。而且,在离开中心的过程中还要使每个粒子图像变得更小更暗(淡出)。

一个漂亮的3D爆炸需要用到50至100个粒子。因为这些图像只是显示在3D世界中的2D图像,需要用到billboard,所以这个教程是建立在教程3-11基础上的。

你将创建一个基于shader的实时粒子系统。每一帧中,计算每个粒子的存活时间(age),它的位置、颜色和大小是基于存活时间计算的。当然,应该在GPU中进行这些计算,让CPU可以完成更重要的工作。

工作原理

如前所述,本章会重用教程3-11的代码,所以需要理解球形billboarding的原理。对每个粒子,你仍需定义六个顶点,而且只需定义一次,将它们传递到显卡。对每个顶点,需要包含下列数据用于vertex shader:

从这些数据,GPU可以基于当前时间计算所需的所有东西。例如,它可以计算粒子已经存活了多少时间。当你将这个存活时间乘以粒子的方向时,就可以获取粒子离开爆炸初始位置的距离。

你需要一个顶点格式存储这个数据,使用一个Vector3存储位置;一个Vector4存储第二,第三,第四个数据;另一个Vector4存储最后两个数据。创建自定义顶点格式可参见教程5-14:

public struct VertexExplosion 
{
    public Vector3 Position; 
    public Vector4 TexCoord; 
    public Vector4 AdditionalInfo; 
    
    public VertexExplosion(Vector3 Position, Vector4 TexCoord, Vector4 AdditionalInfo) 
    {
        this.Position = Position; 
        this.TexCoord = TexCoord; 
        this.AdditionalInfo = AdditionalInfo; 
    }
    
    public static readonly VertexElement[] VertexElements = new VertexElement[] 
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), 
        new VertexElement(0, 12, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), 
        new VertexElement(0, 28, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1), 
    }; 
    
    public static readonly int SizeInBytes = sizeof(float) * (3 + 4 + 4); 
} 

这看起来与教程3-11中的很像,除了用一个Vector4代替Vector2作为第二个参数,让额外的两个浮点数可以传递到vertex shader。要创建随机方向,需要使用一个randomizer,所以在XNA类中添加以下变量:

Random rand;

在Game类的Initialize方法中进行初始化:

rand = new Random(); 

现在可以创建顶点了。下面的方法基于教程3-11生成顶点:

private void CreateExplosionVertices(float time) 
{
    int particles = 80; 
    explosionVertices = new VertexExplosion[particles * 6]; 
    
    int i = 0; 
    for (int partnr = 0; partnr < particles; partnr++) 
    {
        Vector3 startingPos = new Vector3(5,0,0); 
        float r1 = (float)rand.NextDouble() - 0.5f; 
        float r2 = (float)rand.NextDouble() - 0.5f; 
        float r3 = (float)rand.NextDouble() - 0.5f;
        Vector3 moveDirection = new Vector3(r1, r2, r3); 
        moveDirection.Normalize(); 
        
        float r4 = (float)rand.NextDouble(); 
        r4 = r4 / 4.0f * 3.0f + 0.25f; 
        
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection,r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); 
    }
} 

这个方法需要在初始化一个新爆炸时调用,它接受当前时间为参数。首先定义了爆炸中使用的粒子数量,创建了一个数组保存每个粒子的六个顶点。然后,创建这些顶点。对每个顶点,首先储存startingPos,即爆炸的中心点。

然后,你想让每个粒子都有不同的方向。这可以通过生成两个[0,1]区间的随机数实现,但需要减去0.5使它们落在[–0.5f, +0.5f]区间。基于这个随机值创建一个Vector3,归一化这个Vector3让随机方向具有相同的长度。

技巧:推荐使用归一化,因为向量(0.5f, 0.5f, 0.5f)比向量(0.5f, 0, 0)长。所以,当你增加第一个向量的位置时,如果使用第二个向量作为运动方向,粒子会运动得更快。结果是,爆炸会变得像个立方体而不是球形。

然后,获取另一个随机值,用来给每个粒子施加各自的效果,这是因为粒子的速度和大小都要由这个值进行调整。要求有一个介于0和1之间的随机数,然后缩放到[0.25f, 1.0f]区间(谁想要一个速度为的粒子?)。

最后,将每个粒子的六个顶点添加到顶点数组中。注意每六个顶点都包含相同的信息,除了纹理坐标,纹理坐标用来在vertex shader定义每个顶点偏离中心位置的偏移量。

HLSL

现在准备好了顶点,可以编写vertex和pixel shader了。

首先定义XNA-HLSL变量,纹理采样器,vertex shader和pixel shader的输出结构:

//XNA interface 
float4x4 xView; 
float4x4 xProjection; 
float4x4 xWorld; 
float3 xCamPos; 
float3 xCamUp; 
float xTime; 

//Texture Samplers 
Texture xExplosionTexture; 
sampler textureSampler = sampler_state 
{
    texture = <xExplosionTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR; 
    mipfilter=LINEAR; 
    AddressU = CLAMP; 
    AddressV = CLAMP; 
}; 

struct ExpVertexToPixel 
{
    float4 Position : POSITION; 
    float2 TexCoord : TEXCOORD0; 
    float4 Color : COLOR0; 
}; 

struct ExpPixelToFrame 
{
    float4 Color : COLOR0; 
};

因为爆炸中的每个粒子都要施加billboard,你需要相同的变量。这次,你还需要在XNA程序的xTime变量中存储当前时间,让vertex shader可以计算每个粒子的生存时间。

如教程3-11所述,vertex shader将2D屏幕位置和纹理坐标传递到pixel shader中。因为你想实现粒子的淡出效果,所以还要从vertex shader中传递颜色信息。和往常一样,pixel shader只计算每个像素的颜色。

Vertex Shader

vertex shader对顶点施加billboard,所以使用以下代码,这个代码来自于教程3-11中的球形billboarding:

//Technique: Explosion
float3 BillboardVertex(float3 billboardCenter, float2 cornerID, float size) 
{
    float3 eyeVector = billboardCenter - xCamPos; 
    float3 sideVector = cross(eyeVector,xCamUp); 
    sideVector = normalize(sideVector); 
    float3 upVector = cross(sideVector,eyeVector); 
    upVector = normalize(upVector); 
    float3 finalPosition = billboardCenter; 
    finalPosition += (cornerID.x-0.5f)*sideVector*size; 
    finalPosition += (0.5f-cornerID.y)*upVector*size; 
    
    return finalPosition; 
} 

简而言之,你将billboard的中心位置,纹理坐标(用来区分当前顶点)和billboard大小传递到这个方法,这个方法返回顶点的3D位置。更多的信息可参见教程3-11。

下面是vertex shader,它将使用刚才定义的方法:

ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)
{
    ExpVertexToPixel Output = (ExpVertexToPixel)0; 
    
    float3 startingPosition = mul(inPos, xWorld); 
    float2 texCoords = inTexCoord.xy; 
    float birthTime = inTexCoord.z; 
    float maxAge = inTexCoord.w; 
    float3 moveDirection = inExtra.xyz; 
    float random = inExtra.w; 
}

vertex shader接受存储在每个顶点中的一个Vector3和两个Vector4。首先,将数据中的内容存储在一些变量中。Billboard中心位置存储在Vector3中。你已经定义了第一个Vector4中的x和y分量包含纹理坐标,第三第四个分量包含粒子创建的时间和存活时间。第二个Vector4包含粒子移动的方向和一个额外的随机浮点数。

现在,你可以将当前时间减去birthTime获取粒子已经存在的时间,存储在xTime变量中。但是,当使用time span时,你想用0和1之间的相对值表示这个时间,0表示开始时刻,1表示结束时刻。所以要将age除以maxAge, maxAge表示此时粒子应该“死亡”。

float age = xTime - birthTime; float relAge = age/maxAge; 

当粒子是新的时,xTime与birthTime相同,relAge 为0。当接近于结束,age几乎等于maxAge,relAge接近于1。

首先根据粒子的age 调整它的大小。你想让每个粒子开始时最大然后慢慢变小。但是,你不想让它完全消失,所以需要使用以下代码:

float size = 1-relAge*relAge/2.0f; 

看起来有点难以理解,如果用图表示可以帮助你理解这个代码,如图3-22左图所示。对横轴上的每个粒子的Age,你可以在在纵轴上找到对应的大小。例如,relAge = 0时大小为1,relAge = 1时大小为0.5f。

图3-23

图3-23 Size-relAge函数; displacement-relAge函数

但是,当relAge为1时会继续减小。这意味着size会变为负值(如下面的代码所见,这会导致图像会在反方向扩展)。所以,你需要将这个值 处于0和1之间。

因为大小为1的粒子太小,只需简单地将它们乘以一个数字进行缩放,这里是5。要让每个粒子各不相同,你还要将大小乘以一个随机数:

float sizer = saturate(1-relAge*relAge/2.0f); 
float size = 5.0f*random*sizer; 

现在粒子的大小随着时间的流逝会变小,下一步是计算粒子中心的3D位置。你已经知道初始3D位置(爆炸的中心位置)和粒子的移动方向。你需要知道粒子已经沿着这个方向移动了多远,当然,这个距离对应粒子的生存时间。一个简单的方法是让粒子以一个不变的速度移动,但是实际情况中这个速度会减小。

看一下图3-23中的右图,水平轴表示粒子的生存时间,竖直轴表示粒子离开中心位置的距离。你看到距离一开始是线性增加的,但过了一会儿,这个距离增加速度减小,最后不变。这意味着开始时速度保持不变最后为0。

幸运的是,这个曲线只是简单的正弦函数的四分之一。对于一个任意给定的位于0和1之间的relAge,你可以使用这个函数找到对应的曲线上的距离:

float totalDisplacement = sin(relAge*6.28f/4.0f); 

一个完整的正弦曲线周期是从0到2*pi=6.28。因为你只想要四分之一周期,所以需要除以4。现在对于0和1之间的relAge,totalDisplacement拥有了一个对应图3-23右图所示的曲线的值。

你还要将这个值乘以一个因子使粒子离开中心一点儿,本例中这个因子为3比较合适。更大的值导致更大的爆炸。这个值还要乘以一个随机值,使每个粒子都有自己的速度:

float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random;

一旦知道了粒子沿着运动方向运动的距离,就可以很简单地获取粒子的当前位置:

float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; 
billboardCenter += age*float3(0,-1,0)/1000.0f;

最后一行代码给粒子施加一个重力。因为xTime变量包含当前的以毫秒为单位的时间,这行代码会每秒让粒子下降一个单位。

技巧:如果你想对一个移动的物体施加爆炸效果,例如一架飞机,你只需简单地添加一条代码让所有粒子移向物体移动的方向。你可以在顶点的一个额外的TEXCOORD2向量中传递这个方向。

现在有了billboard 中心的3D位置和大小,你就做好了将这些值传递到billboarding方法中的准备:

float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); 
float4 finalPosition4 = float4(finalPosition, 1); 
float4x4 preViewProjection = mul (xView, xProjection); 
Output.Position = mul(finalPosition4, preViewProjection);

这个代码来自于教程3-11。首先计算billboarded位置。和往常一样,这个3D位置需要乘以一个 4 × 4矩阵转换为2D屏幕位置,但在这之前,float3需要被转换到float4。你将最终的2D位置存储在Output结构的Position变量中。

以上代码已经可以给出一个漂亮的结果,但是当粒子接近结束时再施加淡出效果会更棒。当粒子刚刚生成时,你想让它完全可见,当达到relAge = 1时,你想让它完全透明。

要实现这个效果,你可以使用线性减少,但是如果使用图3-23左图中的曲线效果会更好。这次,你想从1到0,所以无需除以2:

float alpha = 1-relAge*relAge; 

现在就可以定义粒子的调制颜色了:

Output.Color = float4(0.5f,0.5f,0.5f,alpha); 

在pixel shader中,你将每个像素的颜色乘以这个颜色。这会将像素的alpha值调整为这里计算的值,这样粒子就会慢慢变得透明。颜色的RGB分量也通过因子2柔化,不然你会很容易看到独立的粒子。

别忘了将纹理坐标传递到pixel shader,这样才能知道billboard的顶点对应纹理的哪个顶点:

Output.TexCoord = texCoords; 
return Output; 

Pixel Shader

幸运的是,pixel shader相对于vertex shader来说很简单:

ExpPixelToFrame ExplosionPS(ExpVertexToPixel PSIn) : COLOR0 
{
    ExpPixelToFrame Output = (ExpPixelToFrame)0; 
    
    Output.Color = tex2D(textureSampler, PSIn.TexCoord)*PSIn.Color; 
    
    return Output; 
} 

对每个像素,你从纹理中获取对应的颜色,将这个颜色乘以在vertex shader计算的调制颜色。这个调制颜色会减少亮度,对应粒子的生存时间设置alpha值。

下面是technique定义:

technique Explosion 
{
    pass Pass0 
    {
        VertexShader = compile vs_1_1 ExplosionVS(); 
        PixelShader = compile ps_1_1 ExplosionPS(); 
    }
} 

在XNA中设置Technique参数

别忘了在XNA代码中设置所有XNA-to-HLSL变量:

expEffect.CurrentTechnique = expEffect.Techniques["Explosion"];
expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); 
expEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix); 
expEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix); 
expEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position); 
expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); 
expEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector); 
expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds); 

Additive Blending(附加混合)

因为这个technique依赖于混合,你需要在绘制前设置绘制状态,alpha混合的更多信息可参加教程2-12和3-3。

在本例中,你想使用additive blending让所有的颜色都叠加在一起。这可以通过设置渲染状态实现:

device.RenderState.AlphaBlendEnable = true; 
device.RenderState.SourceBlend = Blend.SourceAlpha; 
device.RenderState.DestinationBlend = Blend.One; 

第一行代码开启alpha混合。对每个像素,它的颜色决定于以下规则:

finalColor=sourceBlend*sourceColor+destBlend*destColor 

根据前面设置的渲染状态,这个规则会变成以下公式:

finalColor=sourceAlpha*sourceColor+1*destColor 

显卡会绘制每个像素多次,因为你将绘制大量的爆炸图像(需要关闭写入depth buffer)。所以当显卡已经绘制了80个粒子中的79个,并开始绘制最后一个时,会对这个粒子的每个像素进行以下操作:

1.找到存储在frame buffer中的像素的当前颜色,将三个颜色通道乘以1(对应前面规则中的1*destColor)。

2.获取在pixel shader中计算的这个粒子的新颜色,将这个颜色的三个通道乘以alpha通道,alpha通道取决于粒子的当前生存时间(对应上述规则中的sourceAlpha*sourceColor)。

3. 将两个颜色相加,将结果保存到frame buffer中。

在粒子的开始时刻(relAge=0),sourceAlpha等于1,所以additive blending会产生一个大爆炸。在粒子的结束时刻(relAge=1),newAlpha为0,粒子不产生效果。

注意:这个混合规则的第二个例子可参加教程2-12。

关闭写入到Depth Buffer

你还要考虑到最后一个方面。当离开相机最经的粒子首先被绘制时,所有在它之后的粒子将不会被绘制(这会导致不发生混合)!所以绘制粒子时,你需要关闭z-buffer测试,并在将粒子作为场景中的最后一个元素被绘制。但这并不是一个好方法,因为当爆炸离开相机很远时且相机和爆炸之间有一个建筑物时,爆炸仍会被绘制,就好像之间没有这个建筑物似的!

更好的方法是先绘制场景。然后关闭z-buffer写入将粒子作为最后一个元素进行绘制。通过这个方法,你可以解决这个问题:

下面是代码:

device.RenderState.DepthBufferWriteEnable = false; 

然后,绘制爆炸使用的三角形:

expEffect.Begin(); 

foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) 
{
    pass.Begin(); 
    device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); 
    device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); 
    pass.End(); 
}
expEffect.End(); 

在绘制完爆炸后,你需要再次开启z-buffer写入:

device.RenderState.DepthBufferWriteEnable = true; 

当你想开始一个新的爆炸时,调用CreateExplosionVertices方法!本例中,当用户按下空格键时会调用这个方法:

if ((keyState.IsKeyDown(Keys.Space))) 
            CreateExplosionVertices((float) gameTime.TotalGameTime.TotalMilliseconds);

代码

下面是生成爆炸所需顶点的方法:

private void CreateExplosionVertices(float time) 
{
    int particles = 80; 
    explosionVertices = new VertexExplosion[particles * 6]; 
    
    int i = 0; 
    for (int partnr = 0; partnr < particles; partnr++) 
    {
        Vector3 startingPos = new Vector3(5,0,0); 
        float r1 = (float)rand.NextDouble() - 0.5f; 
        float r2 = (float)rand.NextDouble() - 0.5f; 
        float r3 = (float)rand.NextDouble() - 0.5f; 
        Vector3 moveDirection = new Vector3(r1, r2, r3); 
        moveDirection.Normalize(); 
        
        float r4 = (float)rand.NextDouble(); 
        r4 = r4 / 4.0f * 3.0f + 0.25f; 
        
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); 
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); 
    }
} 

下面是完整的Draw方法:

protected override void Draw(GameTime gameTime) 
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); 
    cCross.Draw(quatCam.ViewMatrix, quatCam.ProjectionMatrix); 
    
    if (explosionVertices != null) 
    {
        //draw billboards 
        expEffect.CurrentTechnique = expEffect.Techniques["Explosion"]; 
        expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); 
        expEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix); 
        expEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix); 
        expEffect.Parameters["xCamPos"].SetValue(quatCam.Position); 
        expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); 
        expEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector); 
        expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds); 
        
        device.RenderState.AlphaBlendEnable = true; 
        device.RenderState.SourceBlend = Blend.SourceAlpha; 
        device.RenderState.DestinationBlend = Blend.One; 
        device.RenderState.DepthBufferWriteEnable = false; 
        
        expEffect.Begin(); 
        foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) 
        {
            pass.Begin(); 
            device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); 
            device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); 
            pass.End(); 
        }
        expEffect.End(); 
        
        device.RenderState.DepthBufferWriteEnable = true; 
    }
    base.Draw(gameTime); 
} 

下面是vertex shader代码:

ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) 
{
    ExpVertexToPixel Output = (ExpVertexToPixel)0; 
    
    float3 startingPosition = mul(inPos, xWorld); 
    float2 texCoords = inTexCoord.xy; 
    float birthTime = inTexCoord.z; 
    float maxAge = inTexCoord.w; 
    float3 moveDirection = inExtra.xyz; 
    float random = inExtra.w; 
    
    float age = xTime - birthTime; 
    float relAge = age/maxAge; 
    float sizer = saturate(1-relAge*relAge/2.0f); 
    float size = 5.0f*random*sizer; 
    
    float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random; 
    float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; 
    billboardCenter += age*float3(0,-1,0)/1000.0f; 
    float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); 
    float4 finalPosition4 = float4(finalPosition, 1); 
    
    float4x4 preViewProjection = mul (xView, xProjection); 
    Output.Position = mul(finalPosition4, preViewProjection); 
    
    float alpha = 1-relAge*relAge; 
    Output.Color = float4(0.5f,0.5f,0.5f,alpha); 
    
    Output.TexCoord = texCoords; 
    
    return Output; 
} 

vertex shader使用前面定义的BillboardVertex方法,在教程的最后你还可以看到简单的pixel shader和technique定义。

程序截图

译者注:在XNA官方网站上http://creators.xna.com/en-US/sample/particle3d也有一个粒子系统的例子,不过它的实现方法是使用点精灵(point sprite),这个方法没有使用billboard那样具有弹性,但是它的优点是只使用一个顶点就可以绘制一个粒子,而在billboard需要六个,这可以减少发送到显卡的数据量。源代码Particle3DSample.rar下载。


发布时间:2009/10/12 上午9:41:58  阅读次数:13472

2006 - 2024,推荐分辨率 1024*768 以上,推荐浏览器 Chrome、Edge 等现代浏览器,截止 2021 年 12 月 5 日的访问次数:1872 万 9823 站长邮箱

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号