DirectX12之代码中的流水线

今天看到一篇文章,讨论技术总监需不需要了解细节,我个人觉得技术总监了不了解细节不重要,重要的是遇到别人解决不了的问题,你行不行。技术无管理,至少管理对于这个岗位要求的并不高,毕竟我们程序员都是很简单的人。

//***************************************************************************************
// color.hlsl by Frank Luna (C) 2015 All Rights Reserved.
//
// Transforms and colors geometry.
//***************************************************************************************
 
cbuffer cbPerObject : register(b0)
{
	float4x4 gWorld; 
};

cbuffer cbPass : register(b1)
{
    float4x4 gView;
    float4x4 gInvView;
    float4x4 gProj;
    float4x4 gInvProj;
    float4x4 gViewProj;
    float4x4 gInvViewProj;
    float3 gEyePosW;
    float cbPerObjectPad1;
    float2 gRenderTargetSize;
    float2 gInvRenderTargetSize;
    float gNearZ;
    float gFarZ;
    float gTotalTime;
    float gDeltaTime;
};

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

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

VertexOut VS(VertexIn vin)
{
	VertexOut vout;
	
	// Transform to homogeneous clip space.
    float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);
    vout.PosH = mul(posW, gViewProj);
	
	// Just pass vertex color into the pixel shader.
    vout.Color = vin.Color;
    
    return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
    return pin.Color;
}


我们希望通过这个简单的shader绘制一个立方体,在untiy中我们可以很方便的创建一个cube,然后用这个shader设置一个material,将material赋给cube的meshrender,大功告成。但是在DX中我们就需要做一大堆事情,才能完成这个工作,不过比起使用汇编写代码,DX还是容易的很多。

顶点与索引缓冲区

通常我们的模型文件都是存放到.fbx文件中的,将它渲染到屏幕上需要走很长的一段路。比如游戏进程需要先把这个文件从磁盘读入到内存然后再传输到显存,最后通过gpu将其渲染到屏幕上。哪些工作需要cpu做,哪些工作需要gpu做,这个我们心里要有个数。

对于一个立方体而言它的顶点数据就是8个顶点,索引数据是什么?就是为每一个顶点设定了一个index,这样就不需要把所有共面的顶点都加载到内存和显存中了。

我们需要先建立一个顶点缓冲区,一个索引缓冲区,cpu是通过这两个缓存区,将顶点数据和索引数据传输给gpu的。

创建一个顶点缓存区,我们需要设置D3D12_RESOURCE_DESC描述符,然后调用ID3D12Device::CreateCommittedResource方法创建一个ID3D12Resource对象。

这里说的挺容易,其实里面需要一大堆的设置代码,这里就不罗列了。说一下重点,我们会把一些静态几何体置于默认堆(D3D12_HEAP_TYPE_DEFAULT)来优化性能。在这种情况下,顶点缓存区一旦初始化结束后,只有GPU会去读取里面的数据。然而,如果CPU不能向默认堆中的顶点缓冲区写入数据,那么我们该如何初始化顶点缓存区呢?

这时候我们就需要一个上传缓冲区,先将数据上传到上传缓冲区,然后再Copy到默认缓存区。

创建好顶点缓冲区还需要创建一个顶点缓冲区的视图,然后将其绑定到渲染流水线上。

同理我们还需要建立一个索引缓冲区,索引缓冲区视图,并将其绑定到渲染流水线上。

至此,我们就可以在VS中处理顶点数据了。

顶点的描述

我们需要将顶点的数据结构与VS中的Input相关联:

 

看一下这个描述符:

typedef struct D3D12_INPUT_ELEMENT_DESC
    {
    LPCSTR SemanticName;
    UINT SemanticIndex;
    DXGI_FORMAT Format;
    UINT InputSlot;
    UINT AlignedByteOffset;
    D3D12_INPUT_CLASSIFICATION InputSlotClass;
    UINT InstanceDataStepRate;
    } 	D3D12_INPUT_ELEMENT_DESC;

第一个参数是语义,通过语义将结构体中的成员和VS中的参数一一对应。

第二个参数是语义的索引,比如TEXCOORD这个语义对应了多个参数TEXCOORD0和TEXCOORD1。

第三个参数是数据个格式。

第四个参数是输入槽,如果使用一个输入槽,需要使用AlignedByteOffset参数标记偏移量。可以使用多个输入卡槽进行优化。比如我们渲染shadowmap的时候需要两个Pass,第一个Pass只需要位置信息和纹理坐标,第二个Pass需要的参数比较多。我们可以定义两个结构体,并且将这两个结构体对应两个输入槽,出于性能考虑输入槽最多不要超过3个。

后面两个参数与instance技术相关暂时先不介绍。

语义的关联都是一个接一个传递的,比如VS的输出就是PS的输入,这个我们写Shader的时候都很熟悉了,如下图:

常量缓冲区

我们在编写shader的时候需要很多参数比如坐标变换矩阵,视点的位置信息,光源的方向等等。这些参数都是cpu传递给gpu的,因此我们需要在GPU中开辟一个缓存区用来存储这些常量参数,这个缓冲区就是常量缓冲区。

常量缓存区的创建就像是cpu端new了一块内存空间,但是与cpu不同的是,我们不仅要告诉gpu我们需要使用一块显存,我们还要告诉gpu这是一块用来做什么的显存。为什么我们在cpu端申请一块内存从来不关心它的属性,直接读写就可以了,因为这块内存的管理是我们自己控制的,但是显卡中显存的控制是靠图形API和显卡驱动程序控制的,我们只能按照它们制定的规则去使用。之前的图形API希望自己是个黑盒,尽量让上层调用者不需要关系底层实现的细节,就像是java的gc。但是随着功能复杂度的提升,它们觉得gc实现起来太复杂了,而且性能又不高,又不符合现代cpu多核多线程的架构,于是它们就希望把图形api底层的接口暴漏出来,让我们自己去处理实现细节,这就是DX12和Vulcan做的事情。它们让我们从java程序员转成c++程序员,从而提高游戏性能。这么做没有什么不对,虽然实现起来复杂了一些,但和游戏相比这毕竟只是很少的一段代码,而且也不需要经常变更。让我们继续享受学习新技术的乐趣吧。

首先常量缓存区每帧都需要更新,因此它不能和顶点缓冲区一样放到DEFAULT堆中,我们需要把它放到UPLOAD堆中。接下来我调用ID3D12Device::CreateCommittedResource方法创建一个ID3D12Resource对象,和创建顶点缓存区的代码很类似,我们又创建了一个常量缓存区。其实DX12看似复杂,掌握规律后还是容易理解的。

我们申请一个资源比如贴图,常量缓存,前后缓存,深度/模板缓存等等,这些东西其实都是显卡中的一块显存,我们叫它ID3D12Resource。为了告诉gpu这块显存是用来做什么的,我们需要为每一个资源填写一个描述符,描述符里面描述了这块显存的大小,格式等等属性信息,通过描述符图形API会帮我们创建一个合适的资源。资源创建好后,我们需要为这个资源创建一个View并且把这个View关联到一个handle中,这个handle被保存到描述符堆中。我们可以从描述符堆中找到这个handle,需要的时候把它绑定到渲染流水线中。这个handle就相当于插头,它是cpu端的,我们还需要一个插座,这个是gpu端的,插座就是根签名,我们稍后介绍。(注意并不是所有的资源都需要根签名比如深度/模板缓冲区,因为这些资源是显而易见的,cpu知道应该把它绑定到哪里)

struct ObjectConstants
{
    DirectX::XMFLOAT4X4 World = MathHelper::Identity4x4();
};

struct PassConstants
{
    DirectX::XMFLOAT4X4 View = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 InvView = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 Proj = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 InvProj = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 ViewProj = MathHelper::Identity4x4();
    DirectX::XMFLOAT4X4 InvViewProj = MathHelper::Identity4x4();
    DirectX::XMFLOAT3 EyePosW = { 0.0f, 0.0f, 0.0f };
    float cbPerObjectPad1 = 0.0f;
    DirectX::XMFLOAT2 RenderTargetSize = { 0.0f, 0.0f };
    DirectX::XMFLOAT2 InvRenderTargetSize = { 0.0f, 0.0f };
    float NearZ = 0.0f;
    float FarZ = 0.0f;
    float TotalTime = 0.0f;
    float DeltaTime = 0.0f;
};

比如上面两个结构体,分别描述了Object和Pass需要的参数,于是我们创建两个常量缓冲区。

PassCB = std::make_unique<UploadBuffer<PassConstants>>(device, passCount, true);
ObjectCB = std::make_unique<UploadBuffer<ObjectConstants>>(device, objectCount, true);

这里面使用了工具函数,所以创建一个常量缓冲区还是很简单的。接下来我们根据资源的数量创建一个描述符堆。

void ShapesApp::BuildDescriptorHeaps()
{
    UINT objCount = (UINT)mOpaqueRitems.size();

    // Need a CBV descriptor for each object for each frame resource,
    // +1 for the perPass CBV for each frame resource.
    UINT numDescriptors = (objCount+1) * gNumFrameResources;

    // Save an offset to the start of the pass CBVs.  These are the last 3 descriptors.
    mPassCbvOffset = objCount * gNumFrameResources;

    D3D12_DESCRIPTOR_HEAP_DESC cbvHeapDesc;
    cbvHeapDesc.NumDescriptors = numDescriptors;
    cbvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
    cbvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
    cbvHeapDesc.NodeMask = 0;
    ThrowIfFailed(md3dDevice->CreateDescriptorHeap(&cbvHeapDesc,
        IID_PPV_ARGS(&mCbvHeap)));
}

接下来我们创建常量缓冲区的view,注意不是为每一个常量缓冲区创建一个view,而是为每一个常量缓冲区中的对象创建一个view。因为我们为每一个物体都需要传递一个转换矩阵,所以就要为每一个转换矩阵创建一个view。对于前后台缓冲区,深度缓冲区,它们创建好view就绑定到流水线中了,而常量缓冲区的view还需要插入到根签名中,才算绑定成功。

void ShapesApp::BuildConstantBufferViews()
{
    UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));

    UINT objCount = (UINT)mOpaqueRitems.size();

    // Need a CBV descriptor for each object for each frame resource.
    for(int frameIndex = 0; frameIndex < gNumFrameResources; ++frameIndex)
    {
        auto objectCB = mFrameResources[frameIndex]->ObjectCB->Resource();
        for(UINT i = 0; i < objCount; ++i)
        {
            D3D12_GPU_VIRTUAL_ADDRESS cbAddress = objectCB->GetGPUVirtualAddress();

            // Offset to the ith object constant buffer in the buffer.
            cbAddress += i*objCBByteSize;

            // Offset to the object cbv in the descriptor heap.
            int heapIndex = frameIndex*objCount + i;
            auto handle = CD3DX12_CPU_DESCRIPTOR_HANDLE(mCbvHeap->GetCPUDescriptorHandleForHeapStart());
            handle.Offset(heapIndex, mCbvSrvUavDescriptorSize);

            D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
            cbvDesc.BufferLocation = cbAddress;
            cbvDesc.SizeInBytes = objCBByteSize;

            md3dDevice->CreateConstantBufferView(&cbvDesc, handle);
        }
    }

    UINT passCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(PassConstants));

    // Last three descriptors are the pass CBVs for each frame resource.
    for(int frameIndex = 0; frameIndex < gNumFrameResources; ++frameIndex)
    {
        auto passCB = mFrameResources[frameIndex]->PassCB->Resource();
        D3D12_GPU_VIRTUAL_ADDRESS cbAddress = passCB->GetGPUVirtualAddress();

        // Offset to the pass cbv in the descriptor heap.
        int heapIndex = mPassCbvOffset + frameIndex;
        auto handle = CD3DX12_CPU_DESCRIPTOR_HANDLE(mCbvHeap->GetCPUDescriptorHandleForHeapStart());
        handle.Offset(heapIndex, mCbvSrvUavDescriptorSize);

        D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
        cbvDesc.BufferLocation = cbAddress;
        cbvDesc.SizeInBytes = passCBByteSize;
        
        md3dDevice->CreateConstantBufferView(&cbvDesc, handle);
    }
}

至此一个常量缓冲区在cpu端已经准备完毕了,我们可以每帧更新常量缓冲区的数据将其传递给gpu供shader使用。

根签名

什么是根签名,简单理解就是一个函数的参数声明,我们把shader当成是一个函数,那么里面需要的资源比如贴图,常量这些就是参数。我们需要使用根签名告诉gpu这个shader有哪些参数,这些参数放到哪个寄存器中(可以理解成函数的堆栈)。我们会在cpu端组织这些资源,然后在每帧中将这些资源传递给gpu,cpu中的资源相当与函数的实参。理解了根签名的意义看代码就很简单了。在上一节中我们已经创建好了插头(常量缓存区),这一章我们来介绍插座。

cbuffer cbPerObject : register(b0)
{
	float4x4 gWorld; 
};

cbuffer cbPass : register(b1)
{
    float4x4 gView;
    float4x4 gInvView;
    float4x4 gProj;
    float4x4 gInvProj;
    float4x4 gViewProj;
    float4x4 gInvViewProj;
    float3 gEyePosW;
    float cbPerObjectPad1;
    float2 gRenderTargetSize;
    float2 gInvRenderTargetSize;
    float gNearZ;
    float gFarZ;
    float gTotalTime;
    float gDeltaTime;
};

我们在shader中定义了两个常量缓存区,一个绑定到寄存器b0中,一个绑定到b1中。我们需要告诉gpu这个shader有两个常量缓存区。

void ShapesApp::BuildRootSignature()
{
    CD3DX12_DESCRIPTOR_RANGE cbvTable0;
    cbvTable0.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 0);

    CD3DX12_DESCRIPTOR_RANGE cbvTable1;
    cbvTable1.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 1);

	// Root parameter can be a table, root descriptor or root constants.
	CD3DX12_ROOT_PARAMETER slotRootParameter[2];

	// Create root CBVs.
    slotRootParameter[0].InitAsDescriptorTable(1, &cbvTable0);
    slotRootParameter[1].InitAsDescriptorTable(1, &cbvTable1);

	// A root signature is an array of root parameters.
	CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(2, slotRootParameter, 0, nullptr, 
        D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

	// create a root signature with a single slot which points to a descriptor range consisting of a single constant buffer
	ComPtr<ID3DBlob> serializedRootSig = nullptr;
	ComPtr<ID3DBlob> errorBlob = nullptr;
	HRESULT hr = D3D12SerializeRootSignature(&rootSigDesc, D3D_ROOT_SIGNATURE_VERSION_1,
		serializedRootSig.GetAddressOf(), errorBlob.GetAddressOf());

	if(errorBlob != nullptr)
	{
		::OutputDebugStringA((char*)errorBlob->GetBufferPointer());
	}
	ThrowIfFailed(hr);

	ThrowIfFailed(md3dDevice->CreateRootSignature(
		0,
		serializedRootSig->GetBufferPointer(),
		serializedRootSig->GetBufferSize(),
		IID_PPV_ARGS(mRootSignature.GetAddressOf())));
}

上面创建了根签名,也就参数声明,接下来我们需要把实参传递进去。

void ShapesApp::DrawRenderItems(ID3D12GraphicsCommandList* cmdList, const std::vector<RenderItem*>& ritems)
{
    UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
 
	auto objectCB = mCurrFrameResource->ObjectCB->Resource();

    // For each render item...
    for(size_t i = 0; i < ritems.size(); ++i)
    {
        auto ri = ritems[i];

        cmdList->IASetVertexBuffers(0, 1, &ri->Geo->VertexBufferView());
        cmdList->IASetIndexBuffer(&ri->Geo->IndexBufferView());
        cmdList->IASetPrimitiveTopology(ri->PrimitiveType);

        // Offset to the CBV in the descriptor heap for this object and for this frame resource.
        UINT cbvIndex = mCurrFrameResourceIndex*(UINT)mOpaqueRitems.size() + ri->ObjCBIndex;
        auto cbvHandle = CD3DX12_GPU_DESCRIPTOR_HANDLE(mCbvHeap->GetGPUDescriptorHandleForHeapStart());
        cbvHandle.Offset(cbvIndex, mCbvSrvUavDescriptorSize);

        cmdList->SetGraphicsRootDescriptorTable(0, cbvHandle);

        cmdList->DrawIndexedInstanced(ri->IndexCount, 1, ri->StartIndexLocation, ri->BaseVertexLocation, 0);
    }
}

上面的代码很清晰,为每一个Object设置顶点缓存,索引缓存,然后从描述符堆中取出view的handle,将其设置到根签名中,然后调用渲染。

至此我们为一个简单shader准备好了全部的资源,顶点与索引数据,常量,可以开始运行shader了。这里根签名介绍的比较简单,后续会写一个文章专门介绍根签名。

shader的编译

shader和c/c++一样需要将其编译成汇编代码,然后再转成二进制文件供gpu使用。使用D3D提供的接口之际编译shader,将其存储在ID3DBlob中。

mShaders["standardVS"] = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "VS", "vs_5_1");
mShaders["opaquePS"] = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "PS", "ps_5_1");
	

你可以在线编译也可以离线编译,为了不影响游戏性能,我们一般都是离线编译,测试Demo就无所谓了。

固定管线

我们只提供了VS和PS两个shader,但是还有一些固定管线我们没有设置比如光栅化。

struct CD3DX12_RASTERIZER_DESC : public D3D12_RASTERIZER_DESC
{
    CD3DX12_RASTERIZER_DESC()
    {}
    explicit CD3DX12_RASTERIZER_DESC( const D3D12_RASTERIZER_DESC& o ) :
        D3D12_RASTERIZER_DESC( o )
    {}
    explicit CD3DX12_RASTERIZER_DESC( CD3DX12_DEFAULT )
    {
        FillMode = D3D12_FILL_MODE_SOLID;
        CullMode = D3D12_CULL_MODE_BACK;
        FrontCounterClockwise = FALSE;
        DepthBias = D3D12_DEFAULT_DEPTH_BIAS;
        DepthBiasClamp = D3D12_DEFAULT_DEPTH_BIAS_CLAMP;
        SlopeScaledDepthBias = D3D12_DEFAULT_SLOPE_SCALED_DEPTH_BIAS;
        DepthClipEnable = TRUE;
        MultisampleEnable = FALSE;
        AntialiasedLineEnable = FALSE;
        ForcedSampleCount = 0;
        ConservativeRaster = D3D12_CONSERVATIVE_RASTERIZATION_MODE_OFF;
    }
    explicit CD3DX12_RASTERIZER_DESC(
        D3D12_FILL_MODE fillMode,
        D3D12_CULL_MODE cullMode,
        BOOL frontCounterClockwise,
        INT depthBias,
        FLOAT depthBiasClamp,
        FLOAT slopeScaledDepthBias,
        BOOL depthClipEnable,
        BOOL multisampleEnable,
        BOOL antialiasedLineEnable, 
        UINT forcedSampleCount, 
        D3D12_CONSERVATIVE_RASTERIZATION_MODE conservativeRaster)
    {
        FillMode = fillMode;
        CullMode = cullMode;
        FrontCounterClockwise = frontCounterClockwise;
        DepthBias = depthBias;
        DepthBiasClamp = depthBiasClamp;
        SlopeScaledDepthBias = slopeScaledDepthBias;
        DepthClipEnable = depthClipEnable;
        MultisampleEnable = multisampleEnable;
        AntialiasedLineEnable = antialiasedLineEnable;
        ForcedSampleCount = forcedSampleCount;
        ConservativeRaster = conservativeRaster;
    }
    ~CD3DX12_RASTERIZER_DESC() {}
    operator const D3D12_RASTERIZER_DESC&() const { return *this; }
};

渲染流水线状态

DX12之前渲染状态都是分开设置的,DX12将大部分渲染状态都放到了流水线渲染状态对象中(PSO),好处不言而喻,渲染状态的切换一直是影响游戏性能的关键因素,DX12将这块整理出来一定是有原因的,具体有什么优势我就不瞎说了。毕竟不是写图形API和显卡驱动的人没有发言权。

void ShapesApp::BuildPSOs()
{
    D3D12_GRAPHICS_PIPELINE_STATE_DESC opaquePsoDesc;

	//
	// PSO for opaque objects.
	//
    ZeroMemory(&opaquePsoDesc, sizeof(D3D12_GRAPHICS_PIPELINE_STATE_DESC));
	opaquePsoDesc.InputLayout = { mInputLayout.data(), (UINT)mInputLayout.size() };
	opaquePsoDesc.pRootSignature = mRootSignature.Get();
	opaquePsoDesc.VS = 
	{ 
		reinterpret_cast<BYTE*>(mShaders["standardVS"]->GetBufferPointer()), 
		mShaders["standardVS"]->GetBufferSize()
	};
	opaquePsoDesc.PS = 
	{ 
		reinterpret_cast<BYTE*>(mShaders["opaquePS"]->GetBufferPointer()),
		mShaders["opaquePS"]->GetBufferSize()
	};
	opaquePsoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
    opaquePsoDesc.RasterizerState.FillMode = D3D12_FILL_MODE_WIREFRAME;
	opaquePsoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
	opaquePsoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
	opaquePsoDesc.SampleMask = UINT_MAX;
	opaquePsoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
	opaquePsoDesc.NumRenderTargets = 1;
	opaquePsoDesc.RTVFormats[0] = mBackBufferFormat;
	opaquePsoDesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
	opaquePsoDesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
	opaquePsoDesc.DSVFormat = mDepthStencilFormat;
    ThrowIfFailed(md3dDevice->CreateGraphicsPipelineState(&opaquePsoDesc, IID_PPV_ARGS(&mPSOs["opaque"])));


    //
    // PSO for opaque wireframe objects.
    //

    D3D12_GRAPHICS_PIPELINE_STATE_DESC opaqueWireframePsoDesc = opaquePsoDesc;
    opaqueWireframePsoDesc.RasterizerState.FillMode = D3D12_FILL_MODE_WIREFRAME;
    ThrowIfFailed(md3dDevice->CreateGraphicsPipelineState(&opaqueWireframePsoDesc, IID_PPV_ARGS(&mPSOs["opaque_wireframe"])));
}

总结一下,基本上搞清楚DX12这东西是怎么用的了,有很多细节需要实践摸索,路漫漫其修远兮,吾将上下而求索。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值