海田1 发表于 2021-3-20 12:56

Unreal:如何高效的将数据从GPU拷贝到CPU

如 上一篇文章所述所述:我们在从BackBuffer中读取像素数据时,使用了 ReadSurfaceData 这个函数,这个函数是非常耗时的, 因为涉及到了将像素数据从GPU拷贝回CPU,看一下unreal这个函数的源码,我们会发现在真正拷贝数据之前会调用下Flush函数,这块导致了耗时的增加,我测试了下大概在30ms左右。
FORCEINLINE void ReadSurfaceData(FRHITexture* Texture,FIntRect Rect,TArray<FColor>& OutData,FReadSurfaceDataFlags InFlags)
        {
                QUICK_SCOPE_CYCLE_COUNTER(STAT_RHIMETHOD_ReadSurfaceData_Flush);
                ImmediateFlush(EImmediateFlushType::FlushRHIThread);
                GDynamicRHI->RHIReadSurfaceData(Texture,Rect,OutData,InFlags);
        }其实不光在unreal中,在别的引擎中,我们经常遇到要拿到一个render target中的像素数据,进行一定处理后,又写入到RT中,通常呢是利用readpixels相关的函数来读取:
OpenGL中的glReadPixelsUnity中的ReadPixelsUnreal中的ReadSurfaceData
那怎么才能最快的将GPU上的像素数据拷贝到CPU呢,这里我们参考OpenGL中的像素缓冲区对象 (PBO),在unreal中实现了ping-pong PBO的高效像素数据拷贝方案,利用空间换时间,成功的将数据拷贝的时间从30ms降低到了10ms左右,下面将分两部分介绍,首先介绍下OpenGL中的PBO技术,然后结合代码详细介绍下ping-pong PBO的方案实现。
OpenGL 像素缓冲区对象 PBO

关于PBO的介绍,这篇博客介绍的非常棒,这里我简单的总结下相关的点:
PBO主要有两大优点:
它可以通过DMA(Direct Memory Access)快速的在显卡上传递像素数据,而不影响CPU的时钟周期它提供了一种内存映射机制,可以映射OpenGL控制的缓冲区对象到客户端的内存地址空间中,客户端可以使用glMapBufferARB(), glUnmapBufferARB()函数修改全部或部分缓冲区对象,注意如果GPU仍使用此缓冲区对象,glMapBufferARB()不会返回,直到GPU完成了对相应缓冲区对象的操作
我们主要利用这两个优势来实现快速的拷贝数据。
ping-pong PBO

简单画一张图,阐述下大概的方案:


可以看到我们一共有三种RT:
FTexture2DRHIRef BackBufferFTexture2DRHIRef mReadBackTextureFTexture2DRHIRef mStagingBuffer
BackBuffer就是我们每一帧渲染完成后,从OnBackBufferReady_RenderThread的回调中拿到的数据,mReadBackTexture和mStagingBuffer是在程序初始化时我们提前创建好的RT,并且两者是一一对应的,这里我们封装了一个StageFrame的数据结构:
struct StageFrame
{
        int mFrameId;
        /** Static: Readback textures for asynchronously reading the viewport frame buffer back to the CPU.We ping-pong between the buffers to avoid stalls. */
        FTexture2DRHIRef mReadBackTexture;
        /* Staging Buffer
        */
        FTexture2DRHIRef mStagingBuffer;
        /** Static: Pointers to mapped system memory readback textures that game frames will be asynchronously copied to */
        void * mReadbackBuffers;
        int mWidth;
        int mHeight;

        int mActualUsedWidth;
        int mActualUsedHeight;
        StageFrame(int width, int height)
        {
                mFrameId = 0;
                mActualUsedWidth = mWidth = width;
                mActualUsedHeight = mHeight = height;
                mReadbackBuffers = nullptr;
                InitializeData();
        }

        void InitializeData()
        {
        FRHIResourceCreateInfo CreateInfo;
        mReadBackTexture = RHICreateTexture2D(
                mWidth,
                mHeight,
                PF_B8G8R8A8,
                1,
                1,
                TexCreate_CPUReadback,
                CreateInfo
        );


        mStagingBuffer = RHICreateTexture2D(mWidth, mHeight, PF_B8G8R8A8, 1, 1, TexCreate_RenderTargetable, CreateInfo);
        }
};这里注意在创建staging buffer和readbackTexture时,使用了不同的tag,stagin buffer由于要做为一个RT来绘制,所以被声明为了TexCreate_RenderTargetable,而我们最后要从readback texture 整块的拿数据,因此被声明为了TexCreate_CPUReadback,除此之外两者的大小是一致的。
所谓的ping-pong PBO就是我们会提前申请两个StageFrame,每次从back buffer拷贝数据时,我们都会交替选用两个StageFrame中的一个,就像ping-pong一样,因此被叫做了ping-pong pbo。
在拿到每一帧的backbuffer后,首先将正在运行中的readback buffer 利用RHICmdList.UnmapStagingSurface解绑,
// Unmap the buffer now that we've pushed out the frame
        {
                struct FReadbackFromStagingBufferContext
                {
                        UStreamingComponent* This;
                        TSharedPtr<StageFrame, ESPMode::ThreadSafe> framePushed;
                };
                FReadbackFromStagingBufferContext ReadbackFromStagingBufferContext =
                {
                        this,
                        frames[(ReadbackTextureIndex + 1) % BUFFER_QUEUE]
                };
                ENQUEUE_UNIQUE_RENDER_COMMAND_ONEPARAMETER(
                        ReadbackFromStagingBuffer,
                        FReadbackFromStagingBufferContext, Context, ReadbackFromStagingBufferContext,
                        {
                        RHICmdList.UnmapStagingSurface(Context.framePushed->mReadBackTexture);
                        });
        }然后判断目前空闲的readback buffer的大小和格式是否相同,如果相同的话,可以利用RHICmdList.CopyToResolveTarget快速的将backbuffer拷贝到readbackbuffer中,
if (BackBuffer->GetFormat() == CurrentFrame->mReadBackTexture->GetFormat() &&
                BackBuffer->GetSizeXY() == CurrentFrame->mReadBackTexture->GetSizeXY())
        {
                UE_LOG(LogTemp, Log, TEXT(""));
                RHICmdList.CopyToResolveTarget(BackBuffer, CurrentFrame->mReadBackTexture, FResolveParams{});
        }否则的话,需要利用一个shader来将backbuffer先绘制到已经标记为TexCreate_RenderTargetable的staging buffer中,然后再将staging buffer 拷贝到readback buffer中
FRHIRenderPassInfo RPInfo(CurrentFrame->mStagingBuffer, ERenderTargetActions::Load_Store);
                RHICmdList.BeginRenderPass(RPInfo, TEXT("CopyBackbuffer"));
                {
                        RHICmdList.SetViewport(0, 0, 0.0f, width, height, 1.0f);

                        FGraphicsPipelineStateInitializer GraphicsPSOInit;
                        HICmdList.ApplyCachedRenderTargets(GraphicsPSOInit);
                        GraphicsPSOInit.BlendState = TStaticBlendState<>::GetRHI();
                        GraphicsPSOInit.RasterizerState = TStaticRasterizerState<>::GetRHI();
                        GraphicsPSOInit.DepthStencilState = TStaticDepthStencilState<false, CF_Always>::GetRHI();

                        TShaderMap<FGlobalShaderType>* ShaderMap = GetGlobalShaderMap(GMaxRHIFeatureLevel);
                        TShaderMapRef<FScreenVS> VertexShader(ShaderMap);
                        TShaderMapRef<FScreenPS> PixelShader(ShaderMap);

                        GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI = GFilterVertexDeclaration.VertexDeclarationRHI;
                        GraphicsPSOInit.BoundShaderState.VertexShaderRHI = GETSAFERHISHADER_VERTEX(*VertexShader);
                        GraphicsPSOInit.BoundShaderState.PixelShaderRHI = GETSAFERHISHADER_PIXEL(*PixelShader);
                        GraphicsPSOInit.PrimitiveType = PT_TriangleList;

                        SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit);

                        if (width != BackBuffer->GetSizeX() || height != BackBuffer->GetSizeY())
                        {
                                PixelShader->SetParameters(RHICmdList, TStaticSamplerState<SF_Bilinear>::GetRHI(), BackBuffer);
                        }
                        else
                        {
                                PixelShader->SetParameters(RHICmdList, TStaticSamplerState<SF_Point>::GetRHI(), BackBuffer);
                        }

                        RendererModule->DrawRectangle(
                                RHICmdList,
                                0, 0,                                                                        // Dest X, Y
                                width,                                                                        // Dest Width
                                height,                                                                        // Dest Height
                                0, 0,                                                                        // Source U, V
                                1, 1,                                                                        // Source USize, VSize
                                CurrentFrame->mStagingBuffer->GetSizeXY(),                // Target buffer size
                                FIntPoint(1, 1),                                                // Source texture size
                                *VertexShader,
                                EDRF_Default);

                }
                RHICmdList.EndRenderPass();
                RHICmdList.CopyToResolveTarget(CurrentFrame->mStagingBuffer, CurrentFrame->mReadBackTexture, FResolveParams{});至此,我们已经把backbuffer的数据拷贝了出来,但是数据依旧在GPU上,最后一步就是将当前readback buffer中的数据拿到CPU上
int32 UnusedWidth = 0;
        int32 UnusedHeight = 0;
        check(CurrentFrame->mReadBackTexture->IsValid());
        {
                RHICmdList.MapStagingSurface(CurrentFrame->mReadBackTexture, CurrentFrame->mReadbackBuffers, UnusedWidth, UnusedHeight);

                CurrentFrame->mActualUsedWidth = UnusedWidth;
                CurrentFrame->mActualUsedHeight = UnusedHeight;
        }拿到数据后,整个render thread上我们需要的操作就完成了,可以单独再起一个task来操纵拿到的像素数据,来做进一步的处理。
谢谢

我放心你带套猛 发表于 2021-3-20 13:03

沙发沙发,感谢分享!

宇宙无限 发表于 2021-3-20 13:13

这里的概念有些混淆,所谓的staging buffer是不能作为rendertarget的,而readback buffer也是一种staging buffer。另外readback buffer在map时,按照你的做法是需要做强制wait的,即需要等待cpu把之前的command都flush给gpu,且gpu要执行完这些command。由于command的执行是异步的,所以很大概率cpu端在map时会引发stall的问题。

万胜 发表于 2021-3-20 13:23

这里好像没提到gpu端的async compute和async copy怎么做啊?在DX VK上从贴图复制到readback buffer这个过程是可以走compute engine copy engine的吧。

简单350 发表于 2021-3-20 13:29

根本不存在高效率的意义 手机平台上没有显存 直接用共享模式让GPU和CPU共享rt即可,然后不断的让GPU把数据同步到共享的rt,PC平台有显存,必然存在数据同步问题,具体可以参考苹果的metal内存模式文档。
页: [1]
查看完整版本: Unreal:如何高效的将数据从GPU拷贝到CPU