65.9K
CodeProject 正在变化。 阅读更多。
Home

SimpleScene:C# 和 OpenTK 中的 3D 场景管理器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (40投票s)

2014年7月17日

Apache

13分钟阅读

viewsIcon

145579

一个简单的开源场景管理器和 WavefrontOBJ 文件加载器,使用 C# 和 OpenGL (OpenTK) 编写。可在 MacOS、Windows 和 Linux 上运行。

引言

本文介绍 SimpleScene,一个使用 C# 和 OpenTK 托管的 OpenGL 封装器编写的简单 3D 场景管理器。该代码(包括二进制文件!)可在 Mac、Windows 和 Linux 上无需修改即可运行。代码还包括一个 C# 编写的 WavefrontOBJ 文件读取器。 

 

背景

在编写游戏时,通常最好从 3D 引擎开始,例如 Unity、Torque、Unreal Engine,或者像 Ogre 或 Axiom 这样的开源选项。然而,如果您的目标是深入了解并学习 OpenGL、GLSL 和 3D 引擎的概念,那么这些项目的庞大和复杂性可能会令人望而生畏。 

SimpleScene 是一个小型 3D 场景管理器和 3D 示例,旨在满足这一需求。我试图在代码中优先考虑直接性而不是灵活的抽象,因为这样更容易理解、学习和修改。代码已进入公共领域,没有任何许可限制。您可以从 SimpleScene github 查看或下载它。

WavefrontOBJLoader 示例程序基于过渡性 GLSL 120,因为它具有非常广泛的设备兼容性。这是第一个支持几何着色器的 GLSL,它曾被用于实现单通道线框渲染——一种基于着色器的添加对象线框的方法,该方法没有重复绘制 GL 线条导致的性能和视觉伪影。

什么是场景管理器?

简而言之,场景管理器负责管理和绘制您的 3D 对象场景,这样您就可以专注于移动 3D 对象这一抽象,而不是如何将它们转换并绘制到摄像机和视口中。 

OpenGL、Direct3d 和 WebGL 等 3D API 被称为立即模式渲染 API。它们允许您获取一个清晰的像素缓冲区,并开始在该缓冲区中绘制 3D 形状,或进行栅格化。绘制到缓冲区中的形状集合通常被称为场景。因此,场景管理器负责将一组对象(它们的位置、方向、动画状态、渲染状态以及任何其他相关信息)转换为一组立即模式调用。 

场景管理器是游戏引擎吗?

不完全是,它们之间的界限确实模糊。

游戏引擎包含一个场景管理器,但它通常还包含大量其他代码,假设程序将以特定方式运行。最常见的游戏引擎假设包括(a)假设只有一个操作系统窗口,(b)假设连续重绘更新-绘制,其中整个场景每帧更新一次,以及(c)对象网格不会被任意编辑。做出这样的假设可以更轻松地集成物理、碰撞检测,甚至某些类型的对象网络功能。然而,这些假设也可能根深蒂固地存在于代码库中,使得游戏引擎不适合用于非游戏应用程序。 

另一方面,场景管理器更通用。SimpleScene为构建特定类型的应用程序设定策略。遵循上面的示例,SimpleScene (a) 不创建窗口,因此您可以根据需要创建和控制它们,(b) 没有渲染循环,允许您控制何时以及如何重绘窗口的各个部分,(c) 允许您插入任何类型的网格处理程序,包括可编辑的网格表示。另一方面,它也不附带物理或碰撞检测。 

例如,SimpleScene 目前包括以下功能:

  • 通过 opengl 渲染 3D 网格,包括加载 WavefrontOBJ 文件的支持
    • 多纹理渲染(漫反射、镜面反射和环境光/发光贴图)
    • 对象线框,通过 GLSL 单通道或强制 GL 线条重叠绘制
    • CPU 视锥剔除
    • 动态包围体层次结构,用于空间划分
  • 通过 CPU 鼠标射线相交测试(包围球体和精确网格)进行 3D 对象“拾取”
  • 跨平台的 2D 矢量和字体绘制,通过 agg-sharp 的 GDI 式包装器

然而,它*不*包含现代游戏引擎中通常发现的功能

  • 碰撞检测或物理
  • 动态光照
  • 多线程模拟、网络、多线程渲染(目前还没有!)

如何使用场景管理器?

在最高级别上,您必须创建一个视口窗口,决定要在该视口中渲染多少个场景,并设置将每个场景投影到视口所需的矩阵变换。

SimpleScene 包含一个示例 WavefrontOBJLoader 程序,该程序加载一个示例天空盒、OBJ 3D 模型“飞船”,并绘制一个基于 GL-Point 的星场环境。我们不会在这里回顾使用 OpenGL 或 OpenTK 的每一个元素,而是提供一些主要元素的概览。您可以使用 Visual Studio 或 Xamarin / MonoDevelop 加载、编译和运行此项目。 

WavefrontOBJLoader 的渲染被分成三个不同的场景。场景是一组一起渲染到同一摄像机和屏幕视口投影中的对象。这些场景在 Main.cs 中声明,并在 Main_setupScene.cs 中设置。

        SSScene scene;
        SSScene hudScene;
        SSScene environmentScene;

我们的 environmentScene 包括我们的天空盒和星场,并以“无限投影”进行渲染。它使用摄像机旋转而非位置投影到视口中。它还忽略 z 测试,并首先绘制。这使其看起来像是无限远。 

我们的 hudScene 包括 FPS 显示和线框切换说明。它是一个特殊场景,以非 3D 透视投影进行渲染,可以轻松地将 2D UI 元素放在场景渲染的顶部。这种类型的层在飞行员面罩上的抬头显示器(Heads-Up-Display)之后被称为 HUD。

我们的 scene 包括正在绘制的 3D 元素,在本例中只是我们加载的主要 3D 模型复制了两份。 

将对象添加到场景

为了有有趣的内容可以渲染,我们需要向场景添加对象。您可以在 **Main_setupScene.cs** 中看到这一点。下面是如何添加从 OBJ/MTL 文件和纹理加载的主要“飞船”模型。

    // add drone
    SSObject droneObj = new SSObjectMesh (
            new SSMesh_wfOBJ (
                  SSAssetManager.mgr.getContext ("./drone2/"),   // directory context
                  "drone2.obj",                                  // model filename
                  true, shaderPgm));                             // shader program to use
    scene.addObject (this.activeModel = droneObj);               // add it to the scene

    // setup some rendering and lighting paramaters
    droneObj.renderState.lighted = true;
    droneObj.ambientMatColor = new Color4(0.2f,0.2f,0.2f,0.2f);
    droneObj.diffuseMatColor = new Color4(0.3f,0.3f,0.3f,0.3f);
    droneObj.specularMatColor = new Color4(0.3f,0.3f,0.3f,0.3f);
    droneObj.shininessMatColor = 10.0f;

    droneObj.MouseDeltaOrient(-40.0f,0.0f);                      // turn it slightly
    droneObj.Pos = new OpenTK.Vector3(-5,0,0);                   // move it off the origin

2D HUD 对象以类似的方式添加,只是我们将 z 坐标保留为零。

    // HUD text....
    fpsDisplay = new SSObjectGDISurface_Text ();
    fpsDisplay.Label = "FPS: ...";
    hudScene.addObject (fpsDisplay);
    fpsDisplay.Pos = new Vector3 (10f, 10f, 0f);
    fpsDisplay.Scale = new Vector3 (1.0f);

渲染场景

您可以在 Main_renderScene.cs 和 OpenTK 游戏窗口回调 OnRenderFrame 中看到这三个场景的设置和渲染。首先,我们清除渲染缓冲区

     // clear the render buffer....
     GL.Enable(EnableCap.DepthTest);
     GL.DepthMask (true);
     GL.ClearColor(0.0f, 0.0f, 0.0f, 0.0f); // black
     // GL.ClearColor (System.Drawing.Color.White);
     GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

然后,我们设置一个“无限投影”矩阵来渲染包含天空盒的环境场景。由于我们首先渲染天空盒,我们完全禁用了深度测试。一种更现代的技术是在渲染完所有不透明物体后渲染天空盒,并强制使用深度测试将其设置为“无限深度”。这可以避免写入被实心几何体完全遮挡的天空盒像素。然而,我坚持采用先渲染它的简单方法。

     // render the "environment" scene
     //
     // todo: should move this after the scene render, with a proper depth
     //  test, because it's more efficient when it doesn't have to write every pixel
     {
       GL.Disable(EnableCap.DepthTest);
       GL.Enable(EnableCap.CullFace);
       GL.CullFace (CullFaceMode.Front);
       GL.Disable(EnableCap.DepthClamp);

       // setup infinite projection for cubemap
       Matrix4 projMatrix = Matrix4.CreatePerspectiveFieldOfView (fovy, aspect, 0.1f, 2.0f);
       environmentScene.setProjectionMatrix (projMatrix);    
       // environmentScene.setProjectionMatrix(projection);

       // create a matrix of just the camera rotation only (it needs to stay at the origin)
       environmentScene.setInvCameraViewMatrix (
            Matrix4.CreateFromQuaternion (
                 scene.activeCamera.worldMat.ExtractRotation ()
            ).Inverted ());

       environmentScene.Render ();
     }

然后,我们设置透视投影并渲染主场景的 3D 对象。 

     // rendering the "main" 3d scene....
     {
        GL.Enable (EnableCap.CullFace);
        GL.CullFace (CullFaceMode.Back);
        GL.Enable(EnableCap.DepthTest);
        GL.Enable(EnableCap.DepthClamp);

        GL.DepthMask (true);

        // setup the inverse matrix of the active camera...
        scene.setInvCameraViewMatrix (scene.activeCamera.worldMat.Inverted ());

        // setup the view projection. technically only need to do this on window resize..
        Matrix4 projection = Matrix4.CreatePerspectiveFieldOfView (fovy, aspect, 1.0f, 500.0f);
        scene.setProjectionMatrix (projection);

        // render 3d content...
        scene.SetupLights ();
        scene.Render ();
      }

最后,我们设置一个正交矩阵,并渲染 HUD 场景元素。

      //  render HUD scene

      GL.Disable (EnableCap.DepthTest);
      GL.Disable (EnableCap.CullFace);
      GL.DepthMask (false);

      hudScene.setProjectionMatrix(Matrix4.Identity);
      hudScene.setInvCameraViewMatrix(
           Matrix4.CreateOrthographicOffCenter(0,ClientRectangle.Width,ClientRectangle.Height,0,-1,1));

      hudScene.Render ();

场景渲染后,您可能需要一些代码来使用户能够与场景元素进行交互。在我们简单的示例中,唯一的交互是与摄像机。鼠标滚轮可以放大和缩小它,拖动可以围绕我们的模型对象进行环绕。我们还设置了按键“w”来在三种不同的线框显示模式之间切换。您可以在 Main_setupInput.cs 中看到这些处理程序。 

场景管理器如何绘制场景?

在底层,场景管理器会跟踪场景中的所有对象、它们的位置及其变换矩阵。每当要求渲染场景时,它就会遍历场景,在将对象渲染到立即模式 API(在本例中是 OpenTK / OpenGL)之前更新投影矩阵。

绘制网格的一个非常简单的例子可以在 **SSObjectCube.cs** 中找到。此类使用旧版 OpenGL API 渲染一个单位大小的立方体。它不需要管理矩阵,因为场景管理器已经在 **SSScene** 和 **SSObject** 中处理了这些。这使得 Render 代码易于理解,因为它只是使用 OpenGL 渲染自身,就像它以原点为中心一样。

    public override void Render(ref SSRenderConfig renderConfig) {
         base.Render (ref renderConfig);

         // define the corners of a cube
         var p0 = new Vector3 (-1, -1,  1);  
         var p1 = new Vector3 ( 1, -1,  1);
         var p2 = new Vector3 ( 1,  1,  1);  
         var p3 = new Vector3 (-1,  1,  1);
         var p4 = new Vector3 (-1, -1, -1);
         var p5 = new Vector3 ( 1, -1, -1);
         var p6 = new Vector3 ( 1,  1, -1);
         var p7 = new Vector3 (-1,  1, -1);

         GL.Begin(BeginMode.Triangles);
         GL.Color3(0.5f, 0.5f, 0.5f);
            
         // draw the faces of the cube
         drawQuadFace(p0, p1, p2, p3);            
         drawQuadFace(p7, p6, p5, p4);
         drawQuadFace(p1, p0, p4, p5);
         drawQuadFace(p2, p1, p5, p6);
         drawQuadFace(p3, p2, p6, p7);
         drawQuadFace(p0, p3, p7, p4);

         GL.End();
     }

为了清晰起见,我们对 **drawQuadFace** 的实现使用了旧版 OpenGL 绘图调用,这涉及到为每个参数调用一个函数。虽然这在尝试理解 3D 时易于阅读,但为每个顶点调用一个 GL 函数已经过时了。现代 OpenGL 2+、OpenGLES 和 Direct3D 已经取消了这种调用接口,转而使用更快的顶点缓冲区,我们稍后会讨论。这是因为每个顶点调用一个函数非常慢,并且从 C# 这样的托管语言来看,它简直是迟缓的。每一个 GL.Vertex3() 调用都是通过托管到本机桥接器。

在这里,您可以看到它用于绘制立方体每个四边形的各个面的代码

     private void drawQuadFace(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3) {
            
         GL.Normal3(Vector3.Cross(p1-p0,p2-p0).Normalized());
         GL.Vertex3(p0);
         GL.Vertex3(p1);
         GL.Vertex3(p2);

         GL.Normal3(Vector3.Cross(p2-p0,p3-p0).Normalized());
         GL.Vertex3(p0);
         GL.Vertex3(p2);
         GL.Vertex3(p3);
     }

要了解更现代的顶点缓冲区和索引缓冲区,以及纹理,我们需要查看 **SSMesh_wfOBJ.cs**。此类了解如何采用 WavefrontOBJLoader 类生成的数据模型,并将其格式化以通过 OpenGL 进行渲染。因为 Wavefront OBJ 不支持动画,所以格式相当简单。它是一个几何体子集列表,每个子集映射到一个材质定义。该材质定义可能包括静态着色颜色和最多四种纹理(漫反射、镜面反射、环境光和凹凸贴图)。 

在处理静态(非动画)3D 网格时,相当多的工作只是移动缓冲区中的位,将一种格式转换为另一种格式。Wavefront 文件加载器将 3D 数据从 ASCII 文件格式移动到内存中的中间表示。然后 SSMesh_wfOBJ.cs 加载并将该数据传递给 OpenGL,方法是设置信息的结构,将它们作为顶点和索引缓冲区传递,并将纹理发送到显卡。渲染时,SSMesh_wfOBJ 仅将 GPU 指向这些数据集,然后按下“完成”按钮,瞧,3D 形状就出来了。

好的,它并不是那么简单,但也很接近了。我们还没有谈到的一个额外魔法是着色器,用一种称为 GLSL(OpenGL 着色语言)的语言编写。Direct3d 有其自己的等效语言 HLSL。曾有一段时间,Nvidia 有自己的特殊着色语言 Cg,它试图提供一种可以转换为 GLSL 和 HLSL 的语言,但它已被弃用。现在我们都为跨平台 OpenGL 编写 GLSL,为 Windows Direct3D 编写 HLSL。

什么是着色器?

很久很久以前,3D 栅格化硬件包含一组固定的函数功能。起初硬件只能渲染着色的三角形,没有图像纹理。然后最终可以为一个三角形添加一个纹理。然后有人想出了将多个纹理分层并组合到同一三角形上的主意。硬件的每一代都是固定的函数,因为它只能做它被设计来做的事情,不多也不少。

随着 3D 硬件越来越受欢迎,软件开发人员开始通过创建奇怪的纹理输入和以奇怪的方式使用固定的混合模式来欺骗硬件执行固定函数未 intended 的操作。最终,每个人都意识到如果图形芯片拥有自己的软件,它们会更有用和更灵活——因此开始了通往现代通用 GPU 的道路。 

您的 GPU 就像您的 CPU,只是有一个特殊之处。而您的 CPU 被设计为运行一个非常非常大的软件程序的单个副本。您的 GPU 被设计为同时运行许多非常非常小的软件程序的副本。这些非常小的程序称为着色器。当我们将它们用于 3D 渲染时,它们会处理 3D 渲染中必须每帧重复数百万次的许多小型任务。

顶点操作每顶点发生一次。几何体操作每几何图元(即三角形)发生一次,像素操作每像素发生一次(我们称之为片段或纹素,而不是像素)。这就产生了三种类型的着色器:顶点着色器、几何体着色器和片段着色器。 

就像我们的 SSObjectCube 类被编写成好像它是存在的唯一立方体一样,着色器也是为仅处理单个操作而编写的。它针对该类型操作的每次出现调用一次,通常同时跨 GPU 硬件的高度并行的着色器单元调用。这意味着着色器编程与常规编程略有不同。我们无法与正在运行的其他任何着色器实例进行通信,因为我们不知道它们是否会运行以及何时运行。着色器仅处理其当前任务的输入,并输出所需的内容。

有书籍和网站专门解释着色器和着色器编程的细节。此时,我们将直接深入 SimpleScene 着色器,以及我们示例项目的着色器如何工作。

我们的着色器和单通道线框

我们的着色器在 **Main_setupShaders.cs** 中设置。资源管理器在 **Assets/shaders** 中找到它们。每种类型的着色器都有自己的文件。 

我承认,我将 SimpleScene 保持得尽可能简单直接的意图在着色器方面稍微失败了。目前,我有三种不同的着色技术混合在一个着色器堆中。(a)一个基本的每像素发光+漫反射+镜面着色器,(b)单通道线框计算,以及(c)一个尚未工作的凹凸贴图着色器。将来我计划清理这个混乱,修复凹凸贴图,并将它们分成单独的着色器,以便更容易理解。然而,我不想让完美成为好的敌人,所以您可以看到我正在进行中的着色器代码。

关于我们的着色器,首先要注意的是它们使用的是 GLSL 120。这是一个“过渡性”的 GLSL 形式,因为它一只脚在旧版固定功能管道的世界里,另一只脚在现代 GPU 编程的世界里。我出于两个特定原因使用了它。首先,它具有最广泛的硬件兼容性——特别是它与单通道线框所需的几何体着色器兼容性最好。其次,因为它很容易与旧版 OpenGL 参数函数调用和顶点缓冲区协同工作,所以它允许 SimpleScene 的其余部分轻松地混合这两种模式,同时仍使用相同的着色器代码。一旦我们转向现代 GLSL,所有固定功能管道都将消失,函数接口不再可用,并且所有数据都通过通用缓冲区提供给显卡。这更好、更通用、更高效,但从教育角度来看不一定更好——这是 SimpleScene 的主要目标之一。

在传统的线框渲染中,对象会被正常绘制一次,然后再次作为一系列线框线条绘制。这有几个问题:首先,绘制对象两次很慢。其次,线条与原始模型“z 冲突”,有时不可见。解决方法可以尝试在更多情况下使其可见,但没有一种解决方法是完美的。一个更好的解决方案是 GLSL 中的单通道线框。我们不是绘制单独的线框线条,而是简单地计算构成三角形边缘的像素,并在渲染三角形时将这些像素涂成不同的颜色。这不仅使我们能够避免上述两个问题,而且还允许我们根据线框正在绘制的像素调整其颜色(深色覆盖浅色,浅色覆盖深色)。这是结果的截图

 

 

您可以阅读代码、下面的参考资料,并通过自己摸索和实验来了解 OpenGL 着色器的工作原理以及这些着色器的作用。如果您对代码的特定部分有疑问,或者如何自己实现某个效果,请在评论区发帖,我会尽力回答。

后续问题?

有困难理解代码的某一部分吗?想就如何添加特定功能获得建议吗?在下面发帖,我会尽力回答。

参考文献

历史

  • 2014-07:添加了 GDIviaAGG 用于跨平台 2D 矢量图形
  • 2014-07:添加了基于射线相交的 3D 拾取(包围球体和精确网格)
  • 2014-07:代码和文章首次发布

 

© . All rights reserved.