为 .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
本译文仅用于学习和交流目的。非商业转载请注明译者、出处,并保留文章在译言的完整链接。

posted @ 2017-02-23 17:52  Savorboard  阅读(5336)  评论(1编辑  收藏  举报