DirectX11 With Windows SDK--15 几何着色器初探

前言

从这一部分开始,感觉就像是踏入了无人深空一样,在之前初学DX11的时候,这部分内容都是基本上跳过的,现在打算重新认真地把它给拾回来。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

几何着色器

首先用一张图来回顾一下渲染管线的各个阶段,目前为止我们接触的着色器有顶点着色器和像素着色器,而接触到的渲染管线阶段有:输入装配阶段、顶点着色阶段、光栅化阶段、像素着色阶段、输出合并阶段。

1172605-20180805203259072-78808112.png

可以看到,几何着色器是我们在将顶点送入光栅化阶段之前,可以操作顶点的最后一个阶段。它同样也允许我们编写自己的着色器代码。几何着色器可以做如下事情:

  1. 让程序自动决定如何在渲染管线中插入/移除几何体;
  2. 通过流输出阶段将顶点信息再次传递到顶点缓冲区;
  3. 改变图元类型(如输入点图元,输出三角形图元);

但它也有缺点,几何着色器输出的顶点数据很可能是有较多重复的,从流输出拿回到顶点缓冲区的话会占用较多的内存空间。它本身无法输出索引数组。

几何着色阶段会收到一系列代表输入几何体类型的顶点,然后我们可以自由选择其中的这些顶点信息,然后交给流输出对象重新解释成新的图元类型(或者不变),传递给流输出阶段或者是光栅化阶段。而几何着色器仅能够接受来自输入装配阶段提供的顶点信息,对每个顶点进行处理,无法自行决定增减顶点。

注意:离开几何着色器的顶点如果要传递给光栅化阶段,需要包含有转换到齐次裁剪坐标系的坐标信息(语义为SV_POSITIONfloat4向量)

可编程的几何着色器

从一个看似没什么用的几何着色器代码入手

若我们直接从VS项目新建一个几何着色器文件,则可以看到下面的代码:

struct GSOutput
{
    float4 pos : SV_POSITION;
};

[maxvertexcount(3)]
void main(
    triangle float4 input[3] : SV_POSITION, 
    inout TriangleStream< GSOutput > output
)
{
    for (uint i = 0; i < 3; i++)
    {
        GSOutput element;
        element.pos = input[i];
        output.Append(element);
    }
}

ps. 可能有些人会对void main的写法表示不爽,比如说我。不过这不是C语言的主函数......

若在输入装配阶段指定使用TriangleList图元的话,初步观察该代码,实际上你可以发现其实该着色器只是把输入的顶点按原样输出给流输出对象,即跟什么都没做(咸鱼)有什么区别。。不过从这份代码里面就已经包含了几何着色器所特有的绝大部分语法了。

首先,几何着色器是根据图元类型来进行调用的,若使用的是TriangleList,则每一个三角形的三个顶点都会作为输入,触发几何着色器的调用。这样一个TriangleList解释的30个顶点会触发10次调用。

对于几何着色器,我们必须要指定它每次调用所允许输出的最大顶点数目。我们可以使用属性语法来强行修改着色器行为:

[maxvertexcount(N)]

这里N就是每次调用允许产出的最大顶点数目,然后最终输出的顶点数目不会超过N的值。maxvertexcount的值应当尽可能的小。

关于性能上的表现,我根据龙书提供的引用找到了对应的说明文档:

NVIDIA08

虽然是10年前的文档,这里说到:在GeForce 8800 GTX,一个几何着色器的调用在输出1到20个标量的时候可以达到最大运行性能表现,但是当我们指定最大允许输出标量的数目在27到40个时,性能仅达到峰值的50%。比如说,如果顶点的声明如下:

struct V0
{
    float3 pos : POSITION;
    float2 tex : TEXCOORD;
};

这里每个顶点就已经包含了5个标量了,如果以它作为输出类型,则maxvertexcount为4的时候就可以达到理论上的峰值性能(20个标量)。

但如果顶点类型中还包含有float3类型的法向量,每个顶点就额外包含了3个标量,这样在maxvertexcount为4的时候就输出了32个标量,只有50%的峰值性能表现。

这份文档已经将近10年了,对于那时候的显卡来说使用几何着色器可能不是一个很好的选择,不过当初的显卡也早已不能和现在的显卡相提并论了。

注意:

  1. maxvertexcount的值应当设置到尽可能小的值,因为它将直接决定几何着色器的运行效率。
  2. 几何着色器的每次调用最多只能处理1024个标量,对于只包含4D位置向量的顶点来说也只能处理256个顶点。
  3. 几何着色器输入的结构体类型不允许超过128个标量,对于只包含4D位置向量的顶点来说也只能包含32个顶点。

在HLSL编译器里,如果设置的maxvertexcount过大,会直接收到编译错误:
1172605-20180806192145098-1956370002.png

然后代码中的triangle是用于指定输入的图元类型,具体支持的关键字如下:

图元类型描述
pointPoint list
lineLine list or line strip
triangleTriangle list or triangle strip
lineadjLine list with adjacency or line strip with adjacency
triangleadjTriangle list with adjacency or triangle strip with adjacency

具体的图元类型可以到第2章回顾:点击此处

而参数类型可以是用户自定义的结构体类型,或者是向量(float4)类型。从顶点着色器传过来的顶点至少会包含一个表示齐次裁剪坐标的向量。

参数名inupt实际上用户是可以任意指定的。

对于该输入参数的元素数目,取决于前面声明的图元类型:

图元类型元素数目
point[1] 每次只能处理1个顶点
line[2] 一个线段必须包含2个顶点
triangle[3] 一个三角形需要3个顶点
lineadj[4] 一个邻接线段需要4个顶点
triangleadj[6] 一个邻接三角形需要6个顶点

而第二个参数必须是一个流输出对象,而且需要被指定为inout可读写类型。可以看到,它是一个类模板,模板的形参指定要输出的类型。流输出对象有如下三种:

流输出对象类型描述
PointStream一系列点的图元
LineStream一系列线段的图元
TriangleStream一系列三角形的图元

流输出对象都具有下面两种方法:

方法描述
Append向指定的流输出对象添加一个输出的数据
RestartStrip在以线段或者三角形作为图元的时候,默认是以strip的形式输出的,如果我们不希望下一个输出的顶点与之前的顶点构成新图元,则需要调用此方法来重新开始新的strip。若希望输出的图元类型也保持和原来一样的TriangleList,则需要每调用3次Append方法后就调用一次RestartStrip。

注意:

  1. 所谓的删除顶点,实际上就是不将该顶点传递给流输出对象
  2. 若传入的顶点中多余的部分无法构成对应的图元,则抛弃掉这些多余的顶点

在开始前,先放出Basic.hlsli文件的内容:

#include "LightHelper.hlsli"

cbuffer CBChangesEveryFrame : register(b0)
{
    matrix g_World;
    matrix g_WorldInvTranspose;
}

cbuffer CBChangesOnResize : register(b1)
{
    matrix g_Proj;
}

cbuffer CBChangesRarely : register(b2)
{
    DirectionalLight g_DirLight[5];
    PointLight g_PointLight[5];
    SpotLight g_SpotLight[5];
    Material g_Material;
    matrix g_View;
    float3 g_EyePosW;
    float g_CylinderHeight;
}


struct VertexPosColor
{
    float3 PosL : POSITION;
    float4 Color : COLOR;
};

struct VertexPosHColor
{
    float4 PosH : SV_POSITION;
    float4 Color : COLOR;
};

实战1: 将一个三角形分割成三个三角形

现在我们的目标是把一个三角形分裂成三个三角形:
1172605-20180808204508906-886184113.jpg

这也为以后实现分形做为基础。建议读者可以先自行尝试编写着色器代码再来对比。在编写好着色器代码后,
要给渲染管线绑定好一切所需的资源才能够看到效果。

HLSL代码

Triangle_VS.hlsl, Triangle_GS.hlslTriangle_PS.hlsl的实现如下:

// Triangle_VS.hlsl
#include "Basic.hlsli"

VertexPosHColor VS(VertexPosColor vIn)
{
    matrix worldViewProj = mul(mul(g_World, g_View), g_Proj);
    VertexPosHColor vOut;
    vOut.Color = vIn.Color;
    vOut.PosH = mul(float4(vIn.PosL, 1.0f), worldViewProj);
    return vOut;
}
// Triangle_GS.hlsl
#include "Basic.hlsli"

[maxvertexcount(9)]
void GS(triangle VertexPosHColor input[3], inout TriangleStream<VertexPosHColor> output)
{
    //
    // 将一个三角形分裂成三个三角形,即没有v3v4v5的三角形
    //       v1
    //       /\
    //      /  \
    //   v3/____\v4
    //    /\xxxx/\
    //   /  \xx/  \
    //  /____\/____\
    // v0    v5    v2


    VertexPosHColor vertexes[6];
    int i;
    [unroll]
    for (i = 0; i < 3; ++i)
    {
        vertexes[i] = input[i];
        vertexes[i + 3].Color = (input[i].Color + input[(i + 1) % 3].Color) / 2.0f;
        vertexes[i + 3].PosH = (input[i].PosH + input[(i + 1) % 3].PosH) / 2.0f;
    }

    [unroll]
    for (i = 0; i < 3; ++i)
    {
        output.Append(vertexes[i]);
        output.Append(vertexes[3 + i]);
        output.Append(vertexes[(i + 2) % 3 + 3]);
        output.RestartStrip();

    }
}
// Triangle_PS.hlsl
#include "Basic.hlsli"

float4 PS(VertexPosHColor pIn) : SV_Target
{
    return pIn.Color;
}

这里输入和输出的图元类型都是一致的,但无论什么情况都一定要注意设置好maxvertexcount的值,这里固定一个三角形的三个顶点输出9个顶点(构成三个三角形),并且每3次Append就需要调用1次RestartStrip

实战2: 通过圆线构造圆柱体侧面

已知图元类型为LineStrip,现在有一系列连续的顶点构成圆线(近似圆弧的连续折线),构造出圆柱体的侧面。即输入图元类型为线段,输出一个矩形(两个三角形)。

1172605-20180808205822537-1877161667.png

思路: 光有顶点位置还不足以构造出圆柱体侧面,因为无法确定圆柱往哪个方向延伸。所以我们还需要对每个顶点引入所在圆柱侧面的法向量,通过叉乘就可以确定上方向/下方向并进行延伸了。

HLSL代码

Cylinder_VS.hlsl, Cylinder_GS.hlslCylinder_PS.hlsl的实现如下:

// Cylinder_VS.hlsl
#include "Basic.hlsli"

VertexPosHWNormalColor VS(VertexPosNormalColor vIn)
{
    VertexPosHWNormalColor vOut;
    matrix viewProj = mul(g_View, g_Proj);
    float4 posW = mul(float4(vIn.PosL, 1.0f), g_World);

    vOut.PosH = mul(posW, viewProj);
    vOut.PosW = posW.xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
    vOut.Color = vIn.Color;
    return vOut;
}
// Cylinder_GS.hlsl
#include "Basic.hlsli"

// 一个v0v1线段输出6个三角形顶点
[maxvertexcount(6)]
void GS(line VertexPosHWNormalColor input[2], inout TriangleStream<VertexPosHWNormalColor> output)
{
    // *****************************
    // 要求圆线是顺时针的,然后自底向上构造圆柱侧面           
    //   -->      v2____v3
    //  ______     |\   |
    // /      \    | \  |
    // \______/    |  \ |
    //   <--       |___\|
    //           v1(i1) v0(i0)

    float3 upDir = normalize(cross(input[0].NormalW, (input[1].PosW - input[0].PosW)));
    VertexPosHWNormalColor v2, v3;
    
    matrix viewProj = mul(g_View, g_Proj);


    v2.PosW = input[1].PosW + upDir * g_CylinderHeight;
    v2.PosH = mul(float4(v2.PosW, 1.0f), viewProj);
    v2.NormalW = input[1].NormalW;
    v2.Color = input[1].Color;

    v3.PosW = input[0].PosW + upDir * g_CylinderHeight;
    v3.PosH = mul(float4(v3.PosW, 1.0f), viewProj);
    v3.NormalW = input[0].NormalW;
    v3.Color = input[0].Color;

    output.Append(input[0]);
    output.Append(input[1]);
    output.Append(v2);
    output.RestartStrip();

    output.Append(v2);
    output.Append(v3);
    output.Append(input[0]);
}
// Cylinder_PS.hlsl
#include "Basic.hlsli"

float4 PS(VertexPosHWNormalColor pIn) : SV_Target
{
    // 标准化法向量
    pIn.NormalW = normalize(pIn.NormalW);

    // 顶点指向眼睛的向量
    float3 toEyeW = normalize(g_EyePosW - pIn.PosW);

    // 初始化为0 
    float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
    float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f);

    // 只计算方向光
    ComputeDirectionalLight(g_Material, g_DirLight[0], pIn.NormalW, toEyeW, ambient, diffuse, spec);

    return pIn.Color * (ambient + diffuse) + spec;
}

实战3: 画出顶点的法向量

画出顶点的法向量可以帮助你进行调试,排查法向量是否出现了问题。这时候图元的类型为PointList,需要通过几何着色器输出一个线段(两个顶点)。由于顶点中包含法向量,剩下的就是要自行决定法向量的长度。

下图的法向量长度为0.5

1172605-20180808210947538-382005993.png

HLSL代码

Normal_VS.hlsl, Normal_GS.hlslNormal_PS.hlsl的实现如下:

// Normal_VS.hlsl
#include "Basic.hlsli"

VertexPosHWNormalColor VS(VertexPosNormalColor vIn)
{
    VertexPosHWNormalColor vOut;
    matrix viewProj = mul(g_View, g_Proj);
    float4 posW = mul(float4(vIn.PosL, 1.0f), g_World);

    vOut.PosH = mul(posW, viewProj);
    vOut.PosW = posW.xyz;
    vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);
    vOut.Color = vIn.Color;
    return vOut;
}

// Normal_GS.hlsl
#include "Basic.hlsli"

[maxvertexcount(2)]
void GS(point VertexPosHWNormalColor input[1], inout LineStream<VertexPosHWNormalColor> output)
{
    matrix viewProj = mul(g_View, g_Proj);
    

    VertexPosHWNormalColor v;

    // 防止资源争夺
    v.PosW = input[0].PosW + input[0].NormalW * 0.01f;
    v.NormalW = input[0].NormalW;
    v.PosH = mul(float4(v.PosW, 1.0f), viewProj);
    v.Color = input[0].Color;
    output.Append(v);

    v.PosW = v.PosW + input[0].NormalW * 0.5f;
    v.PosH = mul(float4(v.PosW, 1.0f), viewProj);

    output.Append(v);
}
// Normal_PS.hlsl
#include "Basic.hlsli"

float4 PS(VertexPosHWNormalColor pIn) : SV_TARGET
{
    return pIn.Color;
}

C++代码的部分变化

BasicEffect的变化

变化如下:

class BasicEffect : public IEffect
{
public:

    BasicEffect();
    virtual ~BasicEffect() override;

    BasicEffect(BasicEffect&& moveFrom) noexcept;
    BasicEffect& operator=(BasicEffect&& moveFrom) noexcept;

    // 获取单例
    static BasicEffect& Get();

    

    // 初始化Basic.hlsli所需资源并初始化渲染状态
    bool InitAll(ID3D11Device * device);


    //
    // 渲染模式的变更
    //

    // 绘制三角形分裂
    void SetRenderSplitedTriangle(ID3D11DeviceContext * deviceContext);
    // 绘制无上下盖的圆柱体
    void SetRenderCylinderNoCap(ID3D11DeviceContext * deviceContext);
    // 绘制所有顶点的法向量
    void SetRenderNormal(ID3D11DeviceContext * deviceContext);
    

    //
    // 矩阵设置
    //

    void XM_CALLCONV SetWorldMatrix(DirectX::FXMMATRIX W);
    void XM_CALLCONV SetViewMatrix(DirectX::FXMMATRIX V);
    void XM_CALLCONV SetProjMatrix(DirectX::FXMMATRIX P);

    
    //
    // 光照、材质和纹理相关设置
    //

    // 各种类型灯光允许的最大数目
    static const int maxLights = 5;

    void SetDirLight(size_t pos, const DirectionalLight& dirLight);
    void SetPointLight(size_t pos, const PointLight& pointLight);
    void SetSpotLight(size_t pos, const SpotLight& spotLight);

    void SetMaterial(const Material& material);



    void XM_CALLCONV SetEyePos(DirectX::FXMVECTOR eyePos);

    // 设置圆柱体侧面高度
    void SetCylinderHeight(float height);

    // 应用常量缓冲区和纹理资源的变更
    void Apply(ID3D11DeviceContext * deviceContext);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

BasicEffect::SetRenderSplitedTriangle方法--渲染分裂的三角形

该方法处理的是图元TriangleList。因为后续的方法处理的图元不同,在调用开始就得设置回正确的图元。也请确保输入装配阶段提供好需要分裂的三角形顶点。

void BasicEffect::SetRenderSplitedTriangle(ID3D11DeviceContext * deviceContext)
{
    deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
    deviceContext->IASetInputLayout(pImpl->m_pVertexPosColorLayout.Get());
    deviceContext->VSSetShader(pImpl->m_pTriangleVS.Get(), nullptr, 0);
    deviceContext->GSSetShader(pImpl->m_pTriangleGS.Get(), nullptr, 0);
    deviceContext->RSSetState(nullptr);
    deviceContext->PSSetShader(pImpl->m_pTrianglePS.Get(), nullptr, 0);

}

BasicEffect::SetRenderCylinderNoCap方法--渲染圆柱侧面

该方法处理的是图元LineStrip,确保输入的一系列顶点和法向量能够在同一平面上。若提供的顶点集合按顺时针排布,则会自底向上构建出圆柱体,反之则是自顶向下构建。

这里需要关闭背面裁剪,因为我们也可以看到圆柱侧面的内部(没有盖子)。

void BasicEffect::SetRenderCylinderNoCap(ID3D11DeviceContext * deviceContext)
{
    deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINESTRIP);
    deviceContext->IASetInputLayout(pImpl->m_pVertexPosNormalColorLayout.Get());
    deviceContext->VSSetShader(pImpl->m_pCylinderVS.Get(), nullptr, 0);
    deviceContext->GSSetShader(pImpl->m_pCylinderGS.Get(), nullptr, 0);
    deviceContext->RSSetState(RenderStates::RSNoCull.Get());
    deviceContext->PSSetShader(pImpl->m_pCylinderPS.Get(), nullptr, 0);

}

BasicEffect::SetRenderNormal方法--渲染法向量

该方法处理的图元是PointList,确保输入的顶点要包含法向量。

void BasicEffect::SetRenderNormal(ID3D11DeviceContext * deviceContext)
{
    deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);
    deviceContext->IASetInputLayout(pImpl->m_pVertexPosNormalColorLayout.Get());
    deviceContext->VSSetShader(pImpl->m_pNormalVS.Get(), nullptr, 0);
    deviceContext->GSSetShader(pImpl->m_pNormalGS.Get(), nullptr, 0);
    deviceContext->RSSetState(nullptr);
    deviceContext->PSSetShader(pImpl->m_pNormalPS.Get(), nullptr, 0);

}

GameApp类的变化

该项目包含上面三种实战内容,需要用户去指定当前播放的模式。

首先声明部分变化如下:

class GameApp : public D3DApp
{
public:
    enum class Mode { SplitedTriangle, CylinderNoCap, CylinderNoCapWithNormal };
    
public:
    GameApp(HINSTANCE hInstance);
    ~GameApp();

    bool Init();
    void OnResize();
    void UpdateScene(float dt);
    void DrawScene();

private:
    bool InitResource();

    void ResetTriangle();
    void ResetRoundWire();



private:
    
    ComPtr<ID2D1SolidColorBrush> m_pColorBrush;                 // 单色笔刷
    ComPtr<IDWriteFont> m_pFont;                                // 字体
    ComPtr<IDWriteTextFormat> m_pTextFormat;                    // 文本格式

    ComPtr<ID3D11Buffer> m_pVertexBuffer;                       // 顶点集合
    int m_VertexCount;                                          // 顶点数目
    Mode m_ShowMode;                                            // 当前显示模式

    BasicEffect m_BasicEffect;                                  // 对象渲染特效管理

};

GameApp::ResetTriangle方法--重设为三角形顶点

void GameApp::ResetTriangle()
{
    // ******************
    // 初始化三角形
    //

    // 设置三角形顶点
    VertexPosColor vertices[] =
    {
        { XMFLOAT3(-1.0f * 3, -0.866f * 3, 0.0f), XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f) },
        { XMFLOAT3(0.0f * 3, 0.866f * 3, 0.0f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) },
        { XMFLOAT3(1.0f * 3, -0.866f * 3, 0.0f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) }
    };
    // 设置顶点缓冲区描述
    D3D11_BUFFER_DESC vbd;
    ZeroMemory(&vbd, sizeof(vbd));
    vbd.Usage = D3D11_USAGE_IMMUTABLE;
    vbd.ByteWidth = sizeof vertices;
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    vbd.CPUAccessFlags = 0;
    // 新建顶点缓冲区
    D3D11_SUBRESOURCE_DATA InitData;
    ZeroMemory(&InitData, sizeof(InitData));
    InitData.pSysMem = vertices;
    HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffer.ReleaseAndGetAddressOf()));
    // 三角形顶点数
    m_VertexCount = 3;
}

GameApp::ResetRoundWire方法--重设为圆线顶点

void GameApp::ResetRoundWire()
{
    // ****************** 
    // 初始化圆线
    // 设置圆边上各顶点
    // 必须要按顺时针设置
    // 由于要形成闭环,起始点需要使用2次
    //  ______
    // /      \
    // \______/
    //

    VertexPosNormalColor vertices[41];
    for (int i = 0; i < 40; ++i)
    {
        vertices[i].pos = XMFLOAT3(cosf(XM_PI / 20 * i), -1.0f, -sinf(XM_PI / 20 * i));
        vertices[i].normal = XMFLOAT3(cosf(XM_PI / 20 * i), 0.0f, -sinf(XM_PI / 20 * i));
        vertices[i].color = XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f);
    }
    vertices[40] = vertices[0];

    // 设置顶点缓冲区描述
    D3D11_BUFFER_DESC vbd;
    ZeroMemory(&vbd, sizeof(vbd));
    vbd.Usage = D3D11_USAGE_IMMUTABLE;
    vbd.ByteWidth = sizeof vertices;
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    vbd.CPUAccessFlags = 0;
    // 新建顶点缓冲区
    D3D11_SUBRESOURCE_DATA InitData;
    ZeroMemory(&InitData, sizeof(InitData));
    InitData.pSysMem = vertices;
    HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffer.ReleaseAndGetAddressOf()));
    // 线框顶点数
    m_VertexCount = 41;
}

GameApp类剩余部分可以在项目源码中查看。

最终效果如下:
1172605-20180808214140597-971901454.gif

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

转载于:https://www.cnblogs.com/X-Jun/p/9429881.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值