计算机图形学与GPU渲染 -- 图形API--DX

简介

DirectX是一个应用程序编程接口(api)的集合,用于在微软平台上处理与多媒体相关的任务,特别是游戏编程和视频。最初,这些api的名字都是以“Direct”开头的,比如Direct3D, DirectDraw, DirectMusic, DirectPlay, DirectSound等等。DirectX这个名字被创造出来作为所有这些API的缩写(X代表特定的API名称),并很快成为集合的名称。当微软后来开始开发游戏主机时,X被用作Xbox名称的基础,以表明该主机基于DirectX技术。在为Xbox设计的api(如XInput和跨平台音频创建工具(XACT))的命名中保留了X的首字母,而DirectX模式则继续用于Windows api(如Direct2D和DirectWrite)。
Direct3D(简称:D3D)是微软公司在Microsoft Windows操作系统上所开发的一套3D绘图编程接口,是一种低级别 API,可用于绘制每帧三角形、线条或点,或在 GPU 上启动高度并行操作。
Direct3D:
在一致的抽象后面隐藏不同的 GPU 实现。 但仍需要了解如何绘制 3D 图形。

Direct3D

Direct3D在一致的抽象后面隐藏不同的 GPU 实现。 但仍需要了解如何绘制 3D 图形。
抽象概念包括:devices, swap chains和resources。
D3D定义了5种设备驱动类型:

  • HAL(hardware abstraction layer):使硬件加速。
  • reference:应用程序请求一个reference设备。
  • null :不支持渲染,调试无渲染动作的程序的时候可使用。
  • Sw:系统实现的一种低效的软件光栅化方案。
  • WARP:通过高版本dx支持低版本的dx的高效的软件方案。

Swap chain:

  • 每一个设备至少要有一个swap chain.一个swap chain可用来产生一个或多个back buffer surfaces。渲染目标(render target)也是back buffer surface。back buffer是属于渲染(render)的部份。所有的back buffer都是合理的render target,但是并非所有render target都是back buffer。surface是一种资源,包含一个矩形集合的像素数据,如color, alpha, depth/stencil。

resources有4个属性:

  • Type:资源的类型,如顶点缓冲区(vexert buffer)或一个渲染目标(render target)。
  • Usage:资源的用途,如纹理(texture)或渲染目标,由系统的标志所组成,每个标志位占1 bits。
  • Format:数据的格式,如一个二维表面的像素格式。例如,D3DFMT_R8G8B8的值是一个24 bits的颜色深度(colour depth,8 bits是红色,8 bits绿色以及8 bits是蓝色)。
  • Pool:资源所分配的内部存储器空间类型。

Direct3D Pipeline

  1. 设备初始化
  2. 创建DXGI交换链
  3. 配置交换链与Direct3D设备的交互
  4. 创建着色器
  5. 创建顶点缓冲区
  6. 配置pipeline状态
  7. 绘制
    image

设备初始化

Direct3D初始化阶段首先需要创建D3D设备和D3D设备上下文
D3D设备(ID3D11Device)通常代表一个显示适配器(即显卡),它最主要的功能是用于创建各种所需资源,最常用的资源有:资源类(ID3D11Resource, 包含纹理和缓冲区),视图类以及着色器。此外,D3D设备还能够用于检测系统环境对功能的支持情况。
D3D设备上下文(ID3D11DeviceContext)可以看做是一个渲染管线。通常我们在创建D3D设备的同时也会附赠一个立即设备上下文(Immediate Context)。一个D3D设备仅对应一个D3D立即设备上下文,并且只要我们拥有其中一方,就能通过各自的方法获取另一方(即ID3D11Device::GetImmediateContext和ID3D11DeviceContext::GetDevice)。渲染管线主要负责渲染和计算工作,它需要绑定来自与它关联的D3D设备所创建的各种资源、视图和着色器才能正常运转,除此之外,它还能够负责对资源的直接读写操作。
如果你的系统支持Direct3D 11.1的话,则对应的接口类为:ID3D11Device1、ID3D11DeviceContext1,它们分别继承自上面的两个接口类,区别在于额外提供了少数新的接口,并且接口方法的实现可能会有所区别。

    HRESULT hr = S_OK;

    // 创建D3D设备 和 D3D设备上下文
    UINT createDeviceFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)  
    createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
    // 驱动类型数组
    D3D_DRIVER_TYPE driverTypes[] =
    {
        D3D_DRIVER_TYPE_HARDWARE,
        D3D_DRIVER_TYPE_WARP,
        D3D_DRIVER_TYPE_REFERENCE,
    };
    UINT numDriverTypes = ARRAYSIZE(driverTypes);

    // 特性等级数组
    D3D_FEATURE_LEVEL featureLevels[] =
    {
        D3D_FEATURE_LEVEL_11_1,
        D3D_FEATURE_LEVEL_11_0,
    };
    UINT numFeatureLevels = ARRAYSIZE(featureLevels);

    D3D_FEATURE_LEVEL featureLevel;
    D3D_DRIVER_TYPE d3dDriverType;
    for (UINT driverTypeIndex = 0; driverTypeIndex < numDriverTypes; driverTypeIndex++)
    {
        d3dDriverType = driverTypes[driverTypeIndex];
        hr = D3D11CreateDevice(nullptr, d3dDriverType, nullptr, createDeviceFlags, featureLevels, numFeatureLevels,
            D3D11_SDK_VERSION, m_pd3dDevice.GetAddressOf(), &featureLevel, m_pd3dImmediateContext.GetAddressOf());

        if (hr == E_INVALIDARG)
        {
            // Direct3D 11.0 的API不承认D3D_FEATURE_LEVEL_11_1,所以我们需要尝试特性等级11.0以及以下的版本
            hr = D3D11CreateDevice(nullptr, d3dDriverType, nullptr, createDeviceFlags, &featureLevels[1], numFeatureLevels - 1,
                D3D11_SDK_VERSION, m_pd3dDevice.GetAddressOf(), &featureLevel, m_pd3dImmediateContext.GetAddressOf());
        }

        if (SUCCEEDED(hr))
            break;
    }

    if (FAILED(hr))
    {
        MessageBox(0, L"D3D11CreateDevice Failed.", 0, 0);
        return false;
    }

    // 检测是否支持特性等级11.0或11.1
    if (featureLevel != D3D_FEATURE_LEVEL_11_0 && featureLevel != D3D_FEATURE_LEVEL_11_1)
    {
        MessageBox(0, L"Direct3D Feature Level 11 unsupported.", 0, 0);
        return false;
    }

    // 检测 MSAA支持的质量等级
    m_pd3dDevice->CheckMultisampleQualityLevels(
        DXGI_FORMAT_R8G8B8A8_UNORM, 4, &m_4xMsaaQuality);
    assert(m_4xMsaaQuality > 0);

DXGI交换链

DXGI交换链(IDXGISwapChain)缓存了一个或多个表面(2D纹理),它们都可以称作后备缓冲区(backbuffer)。后备缓冲区则是我们主要进行渲染的场所,我们可以将这些缓冲区通过合适的手段成为渲染管线的输出对象。在进行呈现(Present)的时候有两种方法:

BitBlt Model(位块传输模型):将后备缓冲区的数据进行BitBlt(位块传输,即内容上的拷贝),传入到DWM与DX共享的后备缓冲区,然后进行翻转以显示其内容。使用这种模型至少需要一个后备缓冲区。事实上,这也是Win32应用程序最常使用的方式,在进行呈现后,渲染管线仍然是对同一个后备缓冲区进行输出。(支持Windows 7及更高版本)
Flip Model(翻转模型):该模型可以避免上一种方式多余的复制,后备缓冲区表面可以直接与DWM内的前台缓冲区进行翻转。但是需要创建至少两个后备缓冲区,并且在每次完成呈现后通过代码切换到另一个后备缓冲区进行渲染。该模型可以用于Win32应用程序以及UWP应用程序(需要DXGI1.2,支持Windows 8及更高版本)
image

注意:考虑到要兼容Win7系统,而且由于我们编写的是Win32应用程序,因此这里使用的是第一种模型。同时这也是绝大多数教程所使用的。

交换链创建

交换链创建代码如下:

    ComPtr<IDXGIDevice> dxgiDevice = nullptr;
    ComPtr<IDXGIAdapter> dxgiAdapter = nullptr;
    ComPtr<IDXGIFactory1> dxgiFactory1 = nullptr;   // D3D11.0(包含DXGI1.1)的接口类
    ComPtr<IDXGIFactory2> dxgiFactory2 = nullptr;   // D3D11.1(包含DXGI1.2)特有的接口类

    // 为了正确创建 DXGI交换链,首先我们需要获取创建 D3D设备 的 DXGI工厂,否则会引发报错:
    // "IDXGIFactory::CreateSwapChain: This function is being called with a device from a different IDXGIFactory."
    HR(m_pd3dDevice.As(&dxgiDevice));
    HR(dxgiDevice->GetAdapter(dxgiAdapter.GetAddressOf()));
    HR(dxgiAdapter->GetParent(__uuidof(IDXGIFactory1), reinterpret_cast<void**>(dxgiFactory1.GetAddressOf())));

    // 查看该对象是否包含IDXGIFactory2接口
    hr = dxgiFactory1.As(&dxgiFactory2);
    // 如果包含,则说明支持D3D11.1
    if (dxgiFactory2 != nullptr)
    {
        HR(m_pd3dDevice.As(&m_pd3dDevice1));
        HR(m_pd3dImmediateContext.As(&m_pd3dImmediateContext1));
        // 填充各种结构体用以描述交换链
        DXGI_SWAP_CHAIN_DESC1 sd;
        ZeroMemory(&sd, sizeof(sd));
        sd.Width = m_ClientWidth;
        sd.Height = m_ClientHeight;
        sd.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
        // 是否开启4倍多重采样?
        if (m_Enable4xMsaa)
        {
            sd.SampleDesc.Count = 4;
            sd.SampleDesc.Quality = m_4xMsaaQuality - 1;
        }
        else
        {
            sd.SampleDesc.Count = 1;
            sd.SampleDesc.Quality = 0;
        }
        sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
        sd.BufferCount = 1;
        sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
        sd.Flags = 0;

        DXGI_SWAP_CHAIN_FULLSCREEN_DESC fd;
        fd.RefreshRate.Numerator = 60;
        fd.RefreshRate.Denominator = 1;
        fd.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
        fd.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
        fd.Windowed = TRUE;
        // 为当前窗口创建交换链
        HR(dxgiFactory2->CreateSwapChainForHwnd(m_pd3dDevice.Get(), m_hMainWnd, &sd, &fd, nullptr, m_pSwapChain1.GetAddressOf()));
        HR(m_pSwapChain1.As(&m_pSwapChain));
    }
    else
    {
        // 填充DXGI_SWAP_CHAIN_DESC用以描述交换链
        DXGI_SWAP_CHAIN_DESC sd;
        ZeroMemory(&sd, sizeof(sd));
        sd.BufferDesc.Width = m_ClientWidth;
        sd.BufferDesc.Height = m_ClientHeight;
        sd.BufferDesc.RefreshRate.Numerator = 60;
        sd.BufferDesc.RefreshRate.Denominator = 1;
        sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
        sd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
        sd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
        // 是否开启4倍多重采样?
        if (m_Enable4xMsaa)
        {
            sd.SampleDesc.Count = 4;
            sd.SampleDesc.Quality = m_4xMsaaQuality - 1;
        }
        else
        {
            sd.SampleDesc.Count = 1;
            sd.SampleDesc.Quality = 0;
        }
        sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
        sd.BufferCount = 1;
        sd.OutputWindow = m_hMainWnd;
        sd.Windowed = TRUE;
        sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
        sd.Flags = 0;
        HR(dxgiFactory1->CreateSwapChain(m_pd3dDevice.Get(), &sd, m_pSwapChain.GetAddressOf()));
    }

交换链与Direct3D设备的交互

在创建好上述对象后,如果窗口的大小是固定的,则需要经历下面的步骤:

  1. 获取交换链后备缓冲区的ID3D11Texture2D接口对象
  2. 为后备缓冲区创建渲染目标视图ID3D11RenderTargetView
  3. 通过D3D设备创建一个ID3D11Texture2D用作深度/模板缓冲区,要求与后备缓冲区等宽高
  4. 创建深度/模板视图ID3D11DepthStrenilView,绑定刚才创建的2D纹理
  5. 通过D3D设备上下文,在渲染管线的输出合并阶段设置渲染目标
  6. 在渲染管线的光栅化阶段设置好渲染的视口区域
    image
    对应代码实现如下:

    // 重设交换链并且重新创建渲染目标视图
    ComPtr<ID3D11Texture2D> backBuffer;
    HR(m_pSwapChain->ResizeBuffers(1, m_ClientWidth, m_ClientHeight, DXGI_FORMAT_R8G8B8A8_UNORM, 0));
    HR(m_pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(backBuffer.GetAddressOf())));
    HR(m_pd3dDevice->CreateRenderTargetView(backBuffer.Get(), nullptr, m_pRenderTargetView.GetAddressOf()));
    
    // 设置调试对象名
    D3D11SetDebugObjectName(backBuffer.Get(), "BackBuffer[0]");

    backBuffer.Reset();


    D3D11_TEXTURE2D_DESC depthStencilDesc;

    depthStencilDesc.Width = m_ClientWidth;
    depthStencilDesc.Height = m_ClientHeight;
    depthStencilDesc.MipLevels = 1;
    depthStencilDesc.ArraySize = 1;
    depthStencilDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;

    // 要使用 4X MSAA? --需要给交换链设置MASS参数
    if (m_Enable4xMsaa)
    {
        depthStencilDesc.SampleDesc.Count = 4;
        depthStencilDesc.SampleDesc.Quality = m_4xMsaaQuality - 1;
    }
    else
    {
        depthStencilDesc.SampleDesc.Count = 1;
        depthStencilDesc.SampleDesc.Quality = 0;
    }
    


    depthStencilDesc.Usage = D3D11_USAGE_DEFAULT;
    depthStencilDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
    depthStencilDesc.CPUAccessFlags = 0;
    depthStencilDesc.MiscFlags = 0;

    // 创建深度缓冲区以及深度模板视图
    HR(m_pd3dDevice->CreateTexture2D(&depthStencilDesc, nullptr, m_pDepthStencilBuffer.GetAddressOf()));
    HR(m_pd3dDevice->CreateDepthStencilView(m_pDepthStencilBuffer.Get(), nullptr, m_pDepthStencilView.GetAddressOf()));


    // 将渲染目标视图和深度/模板缓冲区结合到管线
    m_pd3dImmediateContext->OMSetRenderTargets(1, m_pRenderTargetView.GetAddressOf(), m_pDepthStencilView.Get());

    // 设置视口变换
    m_ScreenViewport.TopLeftX = 0;
    m_ScreenViewport.TopLeftY = 0;
    m_ScreenViewport.Width = static_cast<float>(m_ClientWidth);
    m_ScreenViewport.Height = static_cast<float>(m_ClientHeight);
    m_ScreenViewport.MinDepth = 0.0f;
    m_ScreenViewport.MaxDepth = 1.0f;

    m_pd3dImmediateContext->RSSetViewports(1, &m_ScreenViewport);

着色器

着色器代码是一份编译后可以在GPU内部运行的程序,不同API有不同的着色器编程语言,D3D使用的为HLSL(OpenGL/ES的为GLSL)。它负责GPU pipeline中可编程阶段,传统的可编程着色器有顶点着色器(vertex shader)和片段着色器(fragment shader/pixel shader)。
着色器代码为类C语言风格,可以有头文件包含、结构体定义、输入输出参数等等类似概念,如果我们要绘制一个简单的三角形可以做如下编程;
定义输入/输出参数结构体:

struct VertexIn
{
    float3 pos : POSITION;
    float4 color : COLOR;
};

struct VertexOut
{
    float4 posH : SV_POSITION;
    float4 color : COLOR;
};

image
顶点着色器代码,这是第一个可编程着色器,输入为3D模型的顶点位置,输出为坐标转换后模型在二维屏幕对应的坐标:
Triangle_VS.hlsl

// 顶点着色器
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    vOut.posH = float4(vIn.pos, 1.0f);
    vOut.color = vIn.color; // 这里alpha通道的值默认为1.0
    return vOut;
}

片段着色器代码,输入为顶点着色器的输出,输出为着色后像素信息:
Triangle_PS.hlsl

// 像素着色器
float4 PS(VertexOut pIn) : SV_Target
{
    return pIn.color;
}

为使着色器代码可以和我们构建pipeline的代码关联起来,我们需要做如下准备:

  1. 代码中定义输入结构体
    struct VertexPosColor
    {
        DirectX::XMFLOAT3 pos;
        DirectX::XMFLOAT4 color;
        static const D3D11_INPUT_ELEMENT_DESC inputLayout[2];
    };
  1. 使用D3D11_INPUT_ELEMENT_DESC结构体来描述待传入结构体中每个成员的具体信息:
typedef struct D3D11_INPUT_ELEMENT_DESC
    {
    LPCSTR SemanticName;	//语义名
    UINT SemanticIndex;		//语义索引
    DXGI_FORMAT Format;		//输入格式
    UINT InputSlot;			//输入槽索引(0-15)
    UINT AlignedByteOffset;	//初始位置(字节偏移量)
    D3D11_INPUT_CLASSIFICATION InputSlotClass;	//输入类型
    UINT InstanceDataStepRate;					//忽略
    } 	D3D11_INPUT_ELEMENT_DESC;
  1. 初始化inputLayout信息
const D3D11_INPUT_ELEMENT_DESC GameApp::VertexPosColor::inputLayout[2] = {
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
    { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 }
};
  1. 创建着色器和输入布局
    ComPtr<ID3DBlob> blob;

    // 创建顶点着色器
    HR(CreateShaderFromFile(L"HLSL\\Triangle_VS.cso", L"HLSL\\Triangle_VS.hlsl", "VS", "vs_5_0", blob.ReleaseAndGetAddressOf()));
    HR(m_pd3dDevice->CreateVertexShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pVertexShader.GetAddressOf()));
    // 创建并绑定顶点布局
    HR(m_pd3dDevice->CreateInputLayout(VertexPosColor::inputLayout, ARRAYSIZE(VertexPosColor::inputLayout),
        blob->GetBufferPointer(), blob->GetBufferSize(), m_pVertexLayout.GetAddressOf()));

    // 创建像素着色器
    HR(CreateShaderFromFile(L"HLSL\\Triangle_PS.cso", L"HLSL\\Triangle_PS.hlsl", "PS", "ps_5_0", blob.ReleaseAndGetAddressOf()));
    HR(m_pd3dDevice->CreatePixelShader(blob->GetBufferPointer(), blob->GetBufferSize(), nullptr, m_pPixelShader.GetAddressOf()));

格式设置完后,我们需要把数据和着色器关联起来

  1. 创建顶点缓冲区
    顶点缓冲区需要通过缓冲区描述D3D11_BUFFER_DESC来创建
typedef struct D3D11_BUFFER_DESC
    {
    UINT ByteWidth;				//数据字节长度
    D3D11_USAGE Usage;			//CPU和GPU读写权限
    UINT BindFlags;				//缓冲区类型标志
    UINT CPUAccessFlags;		//CPU读写权限限定
    UINT MiscFlags;
    UINT StructureByteStride;
    } 	D3D11_BUFFER_DESC;

USAGE表示了缓冲区对于CPU和GPU的可读写状态,参考下表
image
2. 按要求配置好顶点缓冲区描述,并创建顶点缓冲区:

    // 设置三角形顶点
    VertexPosColor vertices[] =
    {
        { XMFLOAT3(0.0f, 0.5f, 0.5f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) },
        { XMFLOAT3(0.5f, -0.5f, 0.5f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) },
        { XMFLOAT3(-0.5f, -0.5f, 0.5f), XMFLOAT4(1.0f, 0.0f, 0.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.GetAddressOf()));

渲染管线

资源准备完成后需要把对应的资源绑定到pipeline上,以便GPU可以正确使用对应资源;如果渲染不同的场景则需要重新创建对应的资源然后重新绑定到pipeline上。

    // ******************
    // 给渲染管线各个阶段绑定好所需资源
    //

    // 输入装配阶段的顶点缓冲区设置
    UINT stride = sizeof(VertexPosColor);	// 跨越字节数
    UINT offset = 0;						// 起始偏移量

    m_pd3dImmediateContext->IASetVertexBuffers(0, 1, m_pVertexBuffer.GetAddressOf(), &stride, &offset);
    // 设置图元类型,设定输入布局
    m_pd3dImmediateContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
    m_pd3dImmediateContext->IASetInputLayout(m_pVertexLayout.Get());
    // 将着色器绑定到渲染管线
    m_pd3dImmediateContext->VSSetShader(m_pVertexShader.Get(), nullptr, 0);
    m_pd3dImmediateContext->PSSetShader(m_pPixelShader.Get(), nullptr, 0);

    // ******************
    // 设置调试对象名
    //
    D3D11SetDebugObjectName(m_pVertexLayout.Get(), "VertexPosColorLayout");
    D3D11SetDebugObjectName(m_pVertexBuffer.Get(), "VertexBuffer");
    D3D11SetDebugObjectName(m_pVertexShader.Get(), "Trangle_VS");
    D3D11SetDebugObjectName(m_pPixelShader.Get(), "Trangle_PS");

绘制

D3D提供了Draw方法,调用该方法后,从输入装配阶段开始,该绘制的进行将会经历一次完整的渲染管线阶段,直到输出合并阶段为止。

        virtual void STDMETHODCALLTYPE Draw( 
            /* [annotation] */ 
            _In_  UINT VertexCount,
            /* [annotation] */ 
            _In_  UINT StartVertexLocation) = 0;

通过指定VertexCount和StartVertexLocation的值我们可以按顺序绘制从索引StartVertexLocation到StartVertexLocation + VertexCount - 1的顶点

    // 绘制三角形
    m_pd3dImmediateContext->Draw(3, 0);
    HR(m_pSwapChain->Present(0, 0));





补充: UMD和KMD交互流程分析

D3D一次提交命令集的流程
image

参考:
https://keenjin.github.io/2020/04/DXRender/
https://github.com/MKXJun/DirectX11-With-Windows-SDK

posted @ 2023-08-14 14:45  Edver  阅读(211)  评论(0编辑  收藏  举报