zoukankan      html  css  js  c++  java
  • 为 .NET Core 设计一个 3D 图形渲染库

    原文地址:https://mellinoe.wordpress.com/2017/02/08/designing-a-3d-rendering-library-for-net-core/
    作者:ERIC MELLINO
    翻译:杨晓东(Savorboard)

    第一篇文章请看:http://www.cnblogs.com/savorboard/p/net-core-game-engine.html

    在第二篇文章中,我将探索Veldrid,这个库为Crazy Core的游戏引擎中的所有3D和2D渲染提供支持。我将讨论这个库的作用,我为什么建立它,以及它是如何工作的。

    注意:对于本文中讨论的一些内容,建议对图形API有基本的了解。对于初学者,我建议查看下面的示例代码,以获得所涉及概念的一般概念。

    使用像.NET这样的托管语言最明显的好处之一是,您的程序可以立即移植到支持该运行时的任何系统。一旦您开始使用本地原生库,或者依赖于其他特定于平台的功能,此优点就会消失。那么,你如何设计一个硬件加速的3D应用程序,它能够运行在各种操作系统和各种图形API?好吧,你做一个抽象层,并屏蔽不利的代码!与任何编程抽象一样,必须非常仔细地进行权衡以隐藏复杂性,同时仍然保持强大的和表达性的编程模型。有了Veldrid,我有几个打到的目标和非必须目标:

    VELDRID的目标

    • 允许您编写不绑定到任何特定图形API的抽象代码。 提供Direct3D 11和OpenGL 3+的具体实现。

    • 遵循通常的图形API模式。Veldrid不发明自己的符号或quirkiness(图形API是足够多的)。

    • 更快。 不要增加大部分的不必要的开销。鼓励在正常呈现循环期间不分配内存的模式,否则分配最小内存。

    VELDRID的非必须目标

    • 允许您在不知道3D图形概念的情况下编程3D图形。Veldrid的接口比具体的API稍微更抽象,像OpenGL或D3D,但是暴露了相同的概念。

    • 公开单个API的所有功能。通过Veldrid暴露的概念应该可以用所有后端表达; 没有非常好的理由,不什么应该抛出NotSupportedException。对于相同的概念,不同的性能特征是可以预期的(在允许范围内),只要行为不是不可观察的。

    特性集

    • 可编程的顶点,片段和几何着色器
    • 顶点和索引缓冲区,包括多个输入顶点缓冲区
    • 一个灵活的材料系统,具有顶点布局和着色器变量管理
    • 索引和实例化渲染
    • 可自定义混合,深度模板和光栅化状态
    • 可定制的帧缓冲区和渲染目标
    • 2D和cubemap纹理

    向我展示代码

    现在这一切都很好,但是使用Veldrid的程序实际上是什么样子?更一般的是:它甚至意味着使用抽象渲染库?为了帮助展示,我创建了适当命名的“ Veldrid微小演示 ”。让我们走一遍代码,看看它是如何工作的。整个项目链接到那些谁想要修补它。它使用新的基于MSBuild的工具为.NET核心,所以构建它是容易,快速,万无一失。

    设置窗口

    
    bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
    OpenTKWindow window = new SameThreadWindow();
    RenderContext rc;
    if (isWindows && !args.Contains("opengl"))
    {
        rc = new D3DRenderContext(window)
    }
    else
    {
        rc = new OpenGLRenderContext(window);
    }
    window.Title = "Veldrid TinyDemo";
    
    

    哇,我们做了一个空白的窗口。惊人!关于“RenderContext”的其他东西是什么?所有这些方法是什么,我用它做什么?简单地说,RenderContext是表示计算机图形设备的核心对象。它是允许您创建GPU资源,控制设备状态和执行低级绘图操作的对象。

    创建设备资源

    此演示在屏幕中心绘制旋转的3D立方体。为了做到这一点,我们需要先创建几个GPU资源。在Veldrid中,所有图形资源都使用ResourceFactory创建,可从RenderContext访问。这些资源对于以前写过图形代码的任何人都会很熟悉。我们需要:

    • 包含多维数据集网格顶点的顶点缓冲区
    • 包含立方体网格索引的索引缓冲区
    • “material”,它是一个含有复合对象的
      • 顶点着色器和片段着色器。
      • 顶点数据的输入布局的描述。
      • 所使用的全局着色器参数的描述。
    VertexBuffer vb = rc.ResourceFactory.CreateVertexBuffer(
        Cube.Vertices,
        new VertexDescriptor(VertexPositionColor.SizeInBytes, 2),
        isDynamic:false);
    IndexBuffer ib = rc.ResourceFactory.CreateIndexBuffer(
        Cube.Indices,
        isDynamic: false);
    

    创建一个VertexBuffer,其中包含静态Cube类及包含简单的3D多维数据集数据。创建一个IndexBuffer包含立方体网格的静态索引数据。

    DynamicDataProvider<Matrix4x4> viewProjection = new DynamicDataProvider<Matrix4x4>();
    

    DynamicDataProvider是一个简单的抽象,便于将数据传输到全局着色器参数。在这个简单的例子中,我们只有两个数据,我们需要发送到顶点着色器:相机的视图和投影矩阵。为了简单起见,我将这些组合成一个Matrix4x4。

    
    Material material = rc.ResourceFactory.CreateMaterial(rc,
        "vertex", "fragment",
        new MaterialVertexInput(VertexPositionColor.SizeInBytes,
            new MaterialVertexInputElement(
                "Position", VertexSemanticType.Position, VertexElementFormat.Float3),
            new MaterialVertexInputElement(
                "Color", VertexSemanticType.Color, VertexElementFormat.Float4)),
        new MaterialInputs<MaterialGlobalInputElement>(
            new MaterialGlobalInputElement(
                "ViewProjectionMatrix", MaterialInputType.Matrix4x4, viewProjection)),
        MaterialInputs<MaterialPerOjbectInputElement>.Empty,
        MaterialTextureInputs.Empty);
    

    可以说是示例中最复杂的部分,这创建了上面描述的“material”对象。创建此资源需要这几个信息:

    • 顶点和片段着色器的名称。在这种情况下,它们简单地称为“vertex”和“fragment”。
    • 顶点输入数据的每个元素的描述。我们的立方体每顶点只有两个数据:3D位置和颜色。
    • 全局着色器输入的说明。如上所述,我们只有一个缓冲区保存一个组合的视图 - 投影矩阵。

    Drawing

    现在我们有了所有的GPU资源,我们可以画出一些东西!在这个演示中,渲染发生在一个非常简单的循环中。在循环的每次迭代时改变着色器参数,以便给立方体旋转的外观。

    
    while(window.Exists)
    {
        InputSnapshot  snapshot = window.GetInputSnapshot(); //处理窗口事件。
        rc.ClearBuffer(); //清除屏幕。
    
        rc.SetViewport(0,0,window.Width,window.Height); //确保视口覆盖整个窗口,以防它被调整大小。
        float  timeFactor = Environmental.TickCount / 1000f ; //得到粗略的时间估计。
        viewProjection.Data =
            //根据当前时间创建一个旋转的相机矩阵。
            Matrix4x4.CreateLookAt(
                new  Vector3(2 *(float)Math.Sin(timeFactor),(float)Math.Sin(timeFactor),2 *(float)Math.Cos(timeFactor)
                Vector3.Zero,//总是看世界的起源。
                Vector3UnitY)
            //将它与透视投影矩阵组合。
            * Matrix4x4.CreatePerspectiveFieldOfView(1.05f,(浮动)window.Width / window.Height。5F,10F);
        rc.setVertexBuffer(vb); //附加多维数据集顶点缓冲区。
        rc.SetIndexBuffer(ib); //附加立方体索引缓冲区。
        rc.SetMaterial(material); //附加材料。
        rc.DrawIndexedPrimitives(Cube.Indices.Length); //绘制多维数据集。
    
        rc.SwapBuffers(); //交换回缓冲区并将场景呈现到窗口。
    }}
    

    首先,屏幕被清除,并且视口被设置为覆盖整个屏幕。早些时候,我说我们将渲染一个“旋转3D立方体”。更准确地说,虽然,摄影机本身围绕着坐在世界原点的静态立方体旋转。当“ viewProjection.Data ”被赋值时,矩阵值被传播到顶点着色器的 “viewProjection”变量中。我们将我们先前创建的三个资源绑定到RenderContext,调用DrawIndexedPrimitives,然后交换上下文的后台缓冲区,它将呈现的场景呈现给窗口。

    在上面的代码中一个明显的事情是,没有提到任何具体的图形API(除了上下文创建)。所有示例代码都将在OpenGL和Direct3D上工作和运行相同。完整的项目可以在GitHub上的项目页面上找到 ; 我鼓励你下载它并且尝试运行!

    场景的背后

    在这些调用背后都发生了什么?让我们用两个例子深入一点。

    VertexBuffer vb = rc.ResourceFactory.CreateVertexBuffer(
        Cube.Vertices,
        new VertexDescriptor(VertexPositionColor.SizeInBytes, 2),
        isDynamic:false);
    

    熟悉OpenGL的人将知道顶点缓冲区存储在称为VBO的特殊对象中,熟悉Direct3D的人员使用通用的“缓冲区”来存储大量不同的东西。当OpenGL后端被要求创建一个VertexBuffer时,它会为你创建一个VBO,填充你的顶点数据,并存储该缓冲区的辅助信息。Direct3D后端通过创建填充 ID3D11Buffer对象来做同样的事情。

    “VertexBuffer”本身是一个接口,用于显示对顶点缓冲区有用的操作,例如设置顶点数据,检索它,以及将缓冲区映射到CPU的地址空间。该Direct3D11和OpenGL此后端的每个返回一个VertexBuffer,一个自己版本衍生的D3DVertexBuffer 或OpenGLVertexBuffer,他们的操作是通过特定的调用到每个这些图形API的实现。这种相同的模式用于Veldrid中可用的所有图形资源。

    下一个例子是从主渲染循环:

    rc.DrawIndexedPrimitives(Cube.Indices.Length); //绘制多维数据集。
    

    具体来说,这是什么?让我们来看看 OpenGL 的代码:

    public  override  void  DrawIndexedPrimitives(int  count,int  startingIndex)
    {
        PreDrawCommand();
        DrawElementsType  elementsType =((OpenGLIndexBuffer)IndexBuffer).ElementsType;
        int  indexSize = OpenGLFormats.GetIndexFormatSize(elementsType);
        GL.DrawElements(_primitiveType,count,elementsType,new  IntPtr(startingIndex * indexSize));
    }}
    

    DrawIndexedPrimitives被翻译成单个呼叫glDrawElements,并且参数被从存储在RenderContext(原始类型)以及当前绑定的IndexBuffer(索引数据的格式)的状态中拉出。

    Direct3D的后台做了什么?

    public override void DrawIndexedPrimitives(int count, int startingIndex, int startingVertex)
    {
        _deviceContext.DrawIndexed(count, startingIndex, startingVertex);
    }
    

    该调用简单地转换为ID3D11DeviceContext :: DrawIndexed。当Vertex和IndexBuffers绑定到RenderContext时,所有其他相关状态已经设置。

    如果你看了代码,有一件事你会注意到,虽然大多数图形资源在Veldrid被返回并且作为接口交换,代码在每个后端将它们作为强类型的对象。例如,D3D后端总是假定它将传递D3DVertexBuffer或D3DShader。这意味着,如果由于某种原因尝试将OpenGLVertexBuffer传递到D3DRenderContext,您将遇到灾难性的异常。在帖子结束关于这个设计决定有关于我的想法。

    哪些工作正常,哪些不是

    库是如何呈现我所要达到的目标呢?这是相当不错的事情:

    • API是连贯的,并且暴露了一个好的功能集,同时保持API的封装。
    • 这些概念是相似的,你可以通常遵循OpenGL或D3D教程,并将这些概念很容易地映射到Veldrid。
    • 在后端代码中有足够数量的“API泄漏”可能被黑客攻击。OpenGL和D3D是相似的,我可以在大多数差异,而不失去大量的功能或速度。
      • 示例:如果帧缓冲区未绑定深度纹理,则OpenGL需要(全局)禁用深度测试。D3D似乎不关心这个,或在内部处理它。因此,当无深度帧缓冲器被绑定时,OpenGL后端禁用全局深度测试状态,即使当前绑定的深度状态应该被启用。这种类型的问题不会泄漏到使用库的最终用户,但它确实会使一个干净的实现变得有点丑。
    • 性能好。这不是“zero-cost abstraction”,但是抽象足够薄。
      • 单独的后端能够跟踪GPU状态,延迟或省略没有效果的呼叫。例如,如果使用相同的顶点数据一个接一个渲染的两个对象。那么第二个对象对SetVertexBuffer()和SetIndexBuffer()的调用将基本上是无操作的,避免了昂贵的GPU状态变化。
      • OpenTK和SharpDX都是非常好的,薄的,快速的包装器为相应的图形API。在需要时调用它们的开销很小。
    • 在后端之间切换是微不足道的。该Veldrid RenderDemo 支持在运行OpenGL和Direct3D之间切换(无需重新启动)。

    另一方面,这里是我在使用库后的几个我的项目中的几个最大的问题:

    • 没有统一的着色器代码。您需要单独编写GLSL和HLSL代码,这样做的方式与D3D和OpenGL后端的工作方式相同。这意味着着色器需要暴露相同的输入(统一/常量缓冲区),相同的顶点布局,相同的纹理输入等。其他人如何处理?
      • Unity,Xenko:这些使用自定义着色语言。这是一个干净的解决方案,但是巨大比我做的更复杂。
      • MonoGame,Unreal:自动着色器转换。这里的方法是根据需要将单个着色器语言翻译成许多。这可能相当简单,取决于你愿意接受多少晦涩的语法。
    • 材质规格很详细。上面的Tiny Demo的例子显示了创建一个简单的Material对象的详细程度。有可能所有必要的信息可以通过着色器反射(使用OpenGL和D3D),但我没有这样做。
    • 没有多线程支持。OpenGL是众所周知的(不可用的)多线程,但D3D11后端可以很容易地与重新设计的API线程。
    • 资源创建是不寻常的,因为不使用构造函数。如果没有每个对象中的间接级别,或者使用重新设计的程序集架构,这将很难解决(请参阅“Veldrid v2的想法”中的最后一个要点)。
    • 有一些泄漏到API中的东西应该放到另一个帮助库中。一个更清洁的设计只会在核心库中包含非常低级的概念,而其他的则在顶层。

    “VELDRID V2” 的一些想法

    Veldrid的初始版本对我非常有用,我学到了很多东西。潜在“v2”版本的库我已经建立了一个很长的改进列表。

    对库的最明显的改进是添加额外的后端实现。理想情况下,该库的下一代版本将至少支持OpenGL ES和Vulkan以及现有的D3D11和OpenGL 3+后端。最重要的是,这将给我选择在iOS和Android上运行,这是目前无法使用D3D或“完整”的OpenGL。实际上,这将是实施最昂贵的功能,但也是最有影响力的。

    正如我上面提到的,初始库的一个明显的问题是它不支持多线程渲染。像Vulkan这样的API被明确地设计为用于多线程应用程序, 很明显,线程是解决现代图形库的一个重要问题。在较小的程度上,甚至direct3d11,这已经在Veldrid支持,具有在我的库中未使用的线程功能。我怀疑这个功能自然会落在下一代设计的支持Vulkan和其他现代图形API的库。

    我已经在Veldrid的当前版本提到材料的问题,这是一个显然需要在v2中进行大修的领域。很难说,改进的版本将看起来是什么样子,像没有为库的其余部分设计,但至少它需要减少冗长的代码,和改进当前版本的一些缺陷。

    由于上述特性很可能需要重新构建库的大部分代码,我认为另一个核心部分需重新考虑,即在公共API中使用接口和抽象类将是有趣的。Veldrid是一个单个程序集,它包含单个API不可知界面的多个实现。这意味着您可以在运行时而不是部署时决定是使用Direct3D还是OpenGL,还可以在运行时切换API。另一方面,由于涉及接口和虚分派(virtual dispatch),该方法带有一定级别的运行时开销。大多数其他3D图形层使用编译时专门化,而不是运行时/接口专门化。我想探讨是否可以使用替代方法,涉及“诱饵和转换”技术用于一些PCL项目。自定义AssemblyLoadContext可用于加载使用特定图形API的特定版本的Veldrid.dll。这将允许您保留当前方法的灵活性,而不需要接口或一些虚分派(virtual dispatch)。

    Veldrid是一个在我的GitHub页面可以获得的开源项目。它使用新的基于MSBuild 的.NET Core 工具,可以从任何针对.NET Standard 1.5或更高版本的项目中使用。


    本文地址:http://www.cnblogs.com/savorboard/p/designing-a-3d-rendering-library-for-net-core.html
    本译文仅用于学习和交流目的。非商业转载请注明译者、出处,并保留文章在译言的完整链接。

  • 相关阅读:
    Asp.net2.0 中自定义过滤器对Response内容进行处理 dodo
    自动化测试工具 dodo
    TestDriven.NET 2.0——单元测试的好助手(转) dodo
    JS弹出窗口的运用与技巧 dodo
    ElasticSearch 简介 规格严格
    修改PostgreSQL字段长度导致cached plan must not change result type错误 规格严格
    Linux系统更改时区(转) 规格严格
    mvn编译“Cannot find matching toolchain definitions for the following toolchain types“报错解决方法 规格严格
    ElasticSearch 集群 & 数据备份 & 优化 规格严格
    Elasticsearch黑鸟教程22:索引模板的详细介绍 规格严格
  • 原文地址:https://www.cnblogs.com/savorboard/p/designing-a-3d-rendering-library-for-net-core.html
Copyright © 2011-2022 走看看