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

半条命游戏关卡查看器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.61/5 (22投票s)

2009年1月22日

CPOL

26分钟阅读

viewsIcon

83176

downloadIcon

2378

基于 DirectX 的应用程序,用于打开和查看半条命 1 的游戏文件

 HLPic2.JPG

引言

几年前,我对第一人称射击游戏产生了兴趣,特别是世界关卡是如何创建并实时渲染的。与此同时,我正处于待业状态,于是我着手学习3D渲染,目标是创建自己的3D渲染引擎。由于我是一名开发者,而非艺术家,我没有创建自己的模型、关卡和纹理的技能。所以我决定尝试编写一个可以渲染现有游戏关卡的渲染引擎。我主要使用了我在网上找到的关于Quake 2、Half Life、WAD和BSP文件的信息和文章。特别是,我发现迈克尔·阿布拉什(Michael Abrash)在Id工作期间为Dr. Dobbs杂志撰写的文章非常具有启发性。

编写这个应用程序让我乐在其中,我想到其他对3D游戏开发感兴趣的人可能会发现这个源代码对学习3D渲染很有用。这个应用程序目前只加载并渲染半条命(Half Life)的关卡。然而,我相信较新的半条命2(Half Life 2)环境仍然使用BSP文件,这些文件针对新的渲染功能进行了扩展(但我没有深入研究)。如果半条命2的关卡确实使用了旧版BSP文件的扩展版本,那么任何人都可以使用此源代码并相应地扩展它来渲染这些较新的关卡文件。这个应用程序不进行任何动画,所以门不会打开,电梯也不会移动。但是实体和模型动画的所有信息都存储在BSP文件中,任何有兴趣的人都应该能够以此源代码为起点添加这些动画。

请注意,我与Valve或Id没有任何关联,我是完全独立创建这个渲染应用程序的。另外请注意,此项目不包含任何Half Life的BSP或WAD文件。这些文件是Valve的知识产权,因此若要在本查看器应用程序中使用它们,您需要从Valve购买该游戏。您可以从Valve获取Half Life 1游戏(仅需9.99美元),网址为http://store.steampowered.com/app/70/。我就是这样做的,然后简单地在我的Half Life游戏安装目录中搜索BSP和WAD文件扩展名。关于如何安排二进制文件、BSP、WAD文件以便运行关卡查看器应用程序的说明,请参见下面的“使用代码”部分。

背景

这是一个Windows应用程序,使用DirectX 9,它读取Half Life 1的BSP和WAD文件,并渲染带有纹理和光照贴图的静态关卡。它还渲染场景实体,这些实体是额外的对象,如箱子、门、窗户、栅格和电梯。此应用程序不支持动画,因此这些场景实体是静态的。为了允许在关卡中导航,大多数在游戏中具有交互性或动画的实体在此处渲染时没有碰撞检测,因此您可以直接穿过它们。它们还以少量透明度渲染,以表明它们是物理透明的。

关卡以第一人称视角渲染,因此玩家就是观察关卡环境的摄像机。您可以使用键盘和鼠标输入,以典型的FPS方式在关卡中移动摄像机,进行观察、奔跑/行走/横向移动、下蹲、跳跃和滑墙。摄像机对象包含一个大致与游戏中Half Life角色大小相同的包围盒,并实现了碰撞检测,以便您可以在关卡中上下楼梯和坡道,执行滑墙等操作。由于电梯和其他动画未实现,因此提供了一个“悬浮”功能,您可以垂直移动到只能通过电梯和梯子到达的区域。还有一个简单的手电筒功能(通过手电筒光照贴图和一个简单的顶点着色器实现),让您可以照亮通风口等较暗区域。
该应用程序可在窗口模式和全屏模式下运行。在窗口模式下,由于鼠标用于访问窗口菜单,因此只能使用键盘命令进行查看(观察)。在全屏模式下,您可以使用鼠标进行观察。键盘命令通过一个映射对象定义,该对象可以在代码中更改,但目前无法通过应用程序UI进行修改。

使用代码

要求
·    运行 Windows XP 操作系统的电脑以及 DirectX 9(应该可以在 Vista 上运行,但我尚未尝试)
·    兼容 DirectX 9 的 3D 显卡
·    D3Dx9_27.dll (D3DX 辅助库,由 Microsoft 免费分发)

说明
确保所有必需的二进制文件都存在于一个目录中
·    HLViewer.exe          (查看器主应用程序可执行文件)
·    GraphicsEngine.dll    (包含所有渲染代码的DLL)
·    D3Dx9_27.dll          (来自Microsoft的D3DX辅助库,用于着色器编译)
·    VertexShader1.fx    (用于手电筒效果的简单顶点着色器)
运行“HLViewer.exe”可执行文件,使用“文件-打开”菜单打开一个BSP文件。包含BSP文件的目录也必须包含BSP文件引用的所有WAD(纹理)文件。例如,“maps”目录可能如下所示:
·    c1a0.bsp (第一个HL关卡文件)
·    halflife.wad
·    cached.wad
·    decals.wad
·    gfx.wad
·    liquids.wad
·    spraypaint.wad
·    xeno.wad

控件
鼠标            - 移动玩家视角(仅限全屏模式)
'w' 键            - 向前移动
's' 键            - 向后移动
'a' 键            - 向左移动(平移)
'd' 键            - 向右移动(平移)
'f' 键             - 切换手电筒
'x' 键             - 悬浮(仅限窗口模式)
'方向键'        - 左右、上下观察
'空格' 键      - 跳跃
'Shift' 键       - 奔跑
'Control' 键    - 下蹲
'Tab' 键         - 切换窗口/全屏模式
鼠标右键     - 悬浮  (仅限全屏模式)

项目概述
这个半条命关卡查看器渲染应用程序是用C++编写的,并组织在一个Microsoft Visual Studio解决方案(“ZGraphics”)中。我使用的是Visual Studio 2003版本,但该解决方案和项目应该能在任何后续版本上完美打开和构建。

要构建这些项目,您需要Microsoft的DirectX 9 SDK,并确保项目包含正确路径的头文件和库文件。您可以从以下网址下载最新的SDK:http://msdn.microsoft.com/en-us/directx/aa937788.aspx。

ZGraphics 解决方案包含两个项目:Application 项目和 GraphicsEngine 项目。

Application 项目很小,包含 CApplication 类,该类处理所有 Windows 函数,例如消息泵、渲染循环、菜单处理、鼠标和键盘输入处理、图形初始化和加载,以及持久化设置。该项目构建为 HLViewer.exe 可执行文件。

GraphicsEngine 项目包含大部分真正有趣的部分,包括用于加载和解析BSP文件、从WAD文件加载纹理、创建和操纵FPS相机、渲染静态几何体和实体以及进行碰撞检测的代码。该项目构建为GraphicsEngine.dll二进制文件,该文件由HLViewer.exe可执行文件引用。

我最初的目的是使这段代码能够移植到其他窗口UI系统和3D渲染API。然而,我只创建了使用DirectX的Windows版本,并且为了节省时间,我没有像最初计划的那样保持清晰的边界。但是程序是围绕着旨在抽象出特定平台功能的四个接口构建的
·    IWApplication    - 应用程序窗口接口。
·    ISceneGraph     - 加载和渲染场景。操纵摄像机,碰撞检测。
·    ICamera           - 创建和操纵FPS摄像机。
·    IRenderer         - 3D渲染API(基本上只是Direct3D的封装)。

辅助模板
我想了解更多关于C++泛型的信息,所以我决定创建自己的基于模板的集合、数学和排序类。这些类位于GraphicsEngine源目录下的子目录中。这些子目录是
·    Collections    - 包含数组、列表、映射、集合、字符串类。
·    Math            - 包含3维和4维矩阵和向量类。
·    Sorting         - 包含排序算法QuickSort、HeapSort、MergeSort。
·    MemoryMgr    - 包含一个简单的内存池对象分配类。

应用程序项目
这里有两个有趣的类:CInput 类和 CApplication 类。CInput 类封装了 DirectX8 输入鼠标/键盘输入功能。CApplication 类实现了 IWApplication 接口,处理所有 Windows 函数,并包含渲染循环。这个项目构建了 HLViewer.exe 可执行文件,并引用了 GraphicsEngine.dll 二进制文件。

GraphicsEngine 项目
这个项目完成了所有有趣的工作。它被构建成GraphicsEngine.dll二进制文件,并由主HLViewer.exe应用程序引用。ICamera、IRenderer和ISceneGraph接口都在这个项目中实现。

FPS 摄像机
在这个半条命查看器应用程序中,摄像机不仅仅是定义场景投影所需查看参数的传统3D摄像机。在FPS游戏中,摄像机还代表玩家,由于玩家可以在关卡中奔跑和跳跃,我将此功能包含在摄像机对象中。因此,ICamera接口定义了对包围盒、移动、跳跃和下蹲/起身以及查看信息的支持。实现ICamera的主要类是FPSCamera、CFrustum和CJump。

CFrustum 类包含查看信息,例如纵横比和焦距,以及定义视锥的几何平面。此外,它还包含一个公共方法,该方法将测试给定包围盒与视锥的关系,如果包围盒的任何部分位于视锥内,则返回true。此方法用于剔除由于存在于当前视锥之外而不可能可见的几何体。

FPSCamera 类封装了 FPS 摄像机所需的一切,因此它包含视锥对象以及在关卡中移动摄像机所需的所有信息。它支持行走/奔跑、横向移动、跳跃(带简单重力)、下蹲和悬浮。它还包括定义玩家角色在“站立”和“下蹲”时范围的包围盒。包围盒用于检测与环境的碰撞,例如墙壁、地板、天花板和实体对象。由于摄像机移动涉及时间,因此有速度参数和一个方法来根据每次渲染实例期间的时间变化更新所有参数。所以,实际上,摄像机对象是这个关卡查看器中唯一被动画化的东西。键盘和鼠标输入用于修改摄像机运动参数。例如,标准的“a”、“s”、“d”、“w”键用于向四个方向之一移动。方向键或鼠标用于改变视角方向(上、下、左右)。还支持让玩家下蹲然后从下蹲状态起身。碰撞检测与键盘输入结合使用,以防止玩家在物体下方时从下蹲状态起身,并避免头部穿过关卡几何体。这在爬行穿过通风口时特别有用。

DX 渲染 API
当我开始这个项目时,我几乎没有3D API的经验。最初,我打算让IRenderer接口非常抽象,以便可以为任何现有的3D API(如DirectX或OpenGL)创建实现。但时间不允许我研究两种不同的3D API,所以我只选择了DirectX。我不知道尝试创建一个通用渲染接口有多么现实,但它可能值得研究,特别是如果有人想将其移植到Linux和/或OpenGL。

IRenderer 接口在 DXRenderer 类中实现,并且在大多数情况下,这些方法直接传递给 D3D API。该类还处理 Direct3D 的初始化和创建所有必要的设备。请注意,我在这里广泛使用了 Microsoft DirectX 9 SDK 提供的 D3D 设置、设备和能力枚举代码。

BSP 数据
游戏关卡BSP文件(例如c1a3.bsp)中包含的BSP数据完整定义了该关卡以及其中的所有实体和模型,除了纹理。几年前我研究这个问题时,有很多网站提供了关于该文件结构的信息,我利用这些信息创建了辅助类,用于将关卡信息加载到内存中并提供对这些信息的访问。我没有深入研究,但我相信较新的Half Life 2关卡文件是基于旧Quake 2的BSP文件的扩展,因此应该可以扩展这些辅助类来加载较新的关卡信息。

BSP 文件被组织成不同的部分(由文件偏移定义),称为“数据块”。每个数据块部分包含一个数据数组,其结构由C语言结构体数据类型定义。BSP文件数据结构在 BSPFileDefs.h 中定义。有一个辅助类 BSPFile,它将打开一个 BSP 文件并读取每个数据块到一个 Array 对象中。另一个辅助类 BSPData,它包含由 BSPFile 对象读取的所有数据块。它还包含一个由 BSP 块数据创建的 BSP 树对象,以及用于点、射线、平面和包围盒交集的辅助方法,用于碰撞检测。该对象还包含 BSP 叶节点和实体在渲染中用于剔除不可见几何体的可见性信息。要理解这个类,您需要搜索 Quake2 和 Half Life 的 BSP 文件。我还强烈推荐 Michael Abrash 关于 BSP 树和可见表面确定的文章。

实体对象也是BSP文件的一部分。实体对象是位于关卡静态几何体内部的对象,它们可以被动画化或在游戏中触发某些事件。有些实体需要被渲染(例如门、箱子等),而另一些实体只用于触发动作,不打算被渲染。在这个查看器应用程序中渲染的实体不是活动的,所以我跳过了对它们的碰撞检测,让用户可以直接穿过它们。此外,我用一定的透明度渲染它们,以表明它们不是实体。门、窗户和可破坏的箱子实体都以这种方式处理。我找不到太多关于实体的信息,所以我不得不通过类名和实验来找出不同的类型。结果是,有些实体是“实体”且无法穿过。另一些明显不应该渲染的实体有时会被渲染(例如触发点),看起来很奇怪。找到每种情况并添加代码来处理它们并不困难,但我决定转而处理其他事情。另一个值得注意的项目是,这些实体对象不属于BSP树或可见性集(据我所知),所以我做了一个预处理步骤(在BSP文件加载和解析时),将关卡几何体的BSP叶节点映射到其中所有存在的实体。有两个映射。一个对象将潜在可见实体映射到每个BSP叶节点,这样只有可能可见的实体才会继续通过渲染代码。另一个对象将完整或部分存在于BSP叶节点内的实体映射到该叶节点。任何与BSP叶节点包围盒相交的实体都包含在映射中。在与实体对象进行碰撞检测时,只有存在于与角色相同BSP叶节点中的实体才会与摄像机包围盒进行交集测试。

void BSPData::LoadData(const char * pszFilename)
{
  _cleanUp();

  BSPFile bspFile;
  try
  {
    bspFile.Open(pszFilename);

    // 从BSP文件加载所有数据块
    m_pVertices = bspFile.LoadVertices();
    assert(m_pVertices);

    m_pFaces = bspFile.LoadFaces();
    assert(m_pFaces);

    DataLump<bspf_plane> * pPlanes = bspFile.LoadPlanes();
    m_pSPlanes = _convertToSPlane(pPlanes);
    assert(m_pSPlanes);
    DELETE_PTR(pPlanes);

    m_pEdges = bspFile.LoadEdges();
    assert(m_pEdges);

    m_pFaceEdges = bspFile.LoadFaceEdgeTable();
    assert(m_pFaceEdges);

    m_pTextInfo = bspFile.LoadTextureInfo();
    assert(m_pTextInfo);

    m_pTextLump = bspFile.LoadTextureLump();
    assert(m_pTextLump);

    m_pLeafs = bspFile.LoadLeaves();
    assert(m_pLeafs);

    m_pLeafFaces = bspFile.LoadLeafFaceTable();
    assert(m_pLeafFaces);

    m_pVisData = bspFile.LoadVisibility();
    assert(m_pVisData);

    m_pNodes = bspFile.LoadBSPNodes();
    assert(m_pNodes);

    m_pLightMaps = bspFile.LoadLightMaps();
    assert(m_pLightMaps);

    m_pEntities = bspFile.LoadEntities();
    assert(m_pEntities);

    m_pModels = bspFile.LoadModels();
    assert(m_pModels);

    bspFile.Close();

    // 创建bsp可见性集数据
    _decompressVisSets();

    // 创建BSP树
    _buildBSPTree();

    // 创建实体和实体可见性集数据
    _createEntityData();
  }
  catch (char * pszMessage)
  {
    bspFile.Close();
    throw(pszMessage);
  }
}


 
纹理数据
BSP关卡的所有纹理都存在于WAD文件中。每个BSP关卡文件引用一个或多个WAD文件。辅助类(Textures)通过获取所选关卡的BSPData类对象并查询“worldspawn”实体来查找该关卡引用的所有WAD文件,从而方便将BSP文件的纹理加载到内存中。然后,打开这些WAD文件,并将BSP数据面数据引用的所有纹理加载到纹理缓存中。WAD文件中的纹理都是调色板化的,因此有一个辅助函数(_createRGBTexture)将其转换为ARGB纹理。

请注意,这些纹理不能直接与DX渲染器一起使用,因此在HFBSPGraph对象中还有另一个转换步骤和缓存,将这些纹理转换为可用的DX版本。光照贴图信息存储在BSP对象数据块中。Textures类包含一个公共方法,用于获取此光照贴图数据并创建可用于渲染对象(HFBSPGraph类)的RGB纹理。

// 加载纹理贴图
for (int n=0; n<BSPData.TextInfo()->m_cSize; n++)
{
  int nMipTex = BSPData.TextInfo()->m_pArray[n].nMipTex;

  if (m_Textures.IsInMap(nMipTex) == 0)
  {
    // 从WAD文件加载纹理并添加到映射
    wtexture WTexture;
    const bspf_miptex * pMipTex = BSPData.MipInfoPtr(nMipTex);
    for (int i=0; i<(int)wadFiles.GetArrayCount(); i++)
    {
    WTexture.pTexture = (wadFiles[i])->LoadTexture(pMipTex->szname);
      if (WTexture.pTexture)
        break;
    }

    assert(WTexture.pTexture);

    if (WTexture.pTexture != 0)
    {
      // 将调色板纹理转换为DWORD XRGB纹理
      _createRGBTexture(WTexture.pTexture, &WTexture.RGBTexture);
      assert(WTexture.RGBTexture.pRGBTex);

      // 将纹理添加到映射
      m_Textures.Add(nMipTex, WTexture);
    }
  }
}


碰撞检测
碰撞检测可能是这个项目中最困难的部分,因为它很难做到正确。大部分碰撞检测代码,即查找关卡几何体/实体与摄像机包围盒之间交集的代码,都在BSPData类中,以及辅助类PolyFace和PolyObject中。我在这里的想法是BSPData类应该提供传入包围盒与类中包含的数据之间的交集检测服务。场景对象(HFBSPGraph类)包含摄像机对象,并负责(除其他外)防止摄像机对象(包围盒)穿透到关卡几何体或实体中。它通过将摄像机包围盒对象传递到BSPData类中的交集检测方法来实现这一点。返回的是是否发生任何交集的信息,如果发生,则交集信息(交平面、点和穿透深度)被传回。请注意,关卡中可能有与各种对象和几何体的多个交集。场景对象然后必须找出如何调整摄像机位置,使其不会穿透到某些几何体中。摄像机位置垂直于交平面进行调整,但允许沿着平面移动以实现“滑墙”效果。

碰撞检测在很大程度上运作良好,但并不完美。可能有更好的方法来检测和收集几何交集,我对此非常感兴趣(我听说过一种叫做“将包围盒推过BSP树,将盒子切割成多个多边形直到检测到面交集……”但我不清楚这是否比我目前的方法更好)。实际上,我到目前为止发现的所有问题都不是由于碰撞检测造成的,而是由于场景代码未能正确响应碰撞而调整摄像机位置。

无论如何,我尝试以高效的方式进行碰撞检测,首先快速排除不可能与摄像机碰撞的大片几何体,然后对可能发生交集的地方执行更精确的测试。对于实体对象,我只测试预先计算好存在于与摄像机相同BSP叶节点中的实体,这个测试很快,因为每个实体都有自己的包围盒。对于关卡几何体,我遍历BSP树,在摄像机和BSP节点边界之间执行包围盒交集测试。每个BSP节点都关联一个包围盒,其中包含它划分的空间,并包括其下方的所有子节点。如果摄像机包围盒与节点包围盒之间未检测到交集,则整个节点都被排除。如果存在潜在交集,则接下来测试摄像机与该节点及其所有子节点关联的分割平面。如果检测到分割平面交集,则执行最终测试以确定是否存在与实际渲染面之间的交集。

请注意,碰撞例程会检测水和熔岩等液体内容,并特意忽略它们,以便玩家可以穿过并沉浸在液体内容中。然而,液体纹理目前不像游戏中那样进行动画处理。

void BSPData::BBIntersectGeometry(const Vector3f vBB[2], Array<SectInfo>& aIntersections) const
{
  _bbIntersectGeom_r( m_pBSPTree->Head(), vBB, aIntersections );
}


矩阵
我花了一些时间才正确处理Half Life关卡数据的世界矩阵。事实证明,Direct3D使用左手坐标系,而Half Life关卡数据使用右手坐标系。因此,在创建世界变换矩阵时,我不得不考虑到这一点。投影矩阵是根据查看信息(包含在FPSCamera对象中)计算的,并取决于纵横比、焦距等。纵横比是根据屏幕纵横比计算的,适用于窗口模式和全屏模式。视图矩阵在每次渲染循环中都会重新计算,因为视图方向可能会通过用户输入而改变。所有这三个矩阵都传递给渲染对象,Direct3D使用它们来渲染场景。

放置摄像机
当关卡首次加载时,摄像机(玩家)需要放置在关卡几何体内的有效位置。在游戏中,这可能是通过在关卡转换之间传递位置信息来完成的。对于这个关卡查看器应用程序,我使用一个名为“info_player_start”的实体。这个实体有一个原点坐标,我将其用作摄像机的起始位置。

一旦摄像机安全地进入关卡,渲染循环就开始了,用户可以在每个渲染时间片中使用键盘命令移动摄像机。但是,如果用户将摄像机移动到墙壁或实体对象中,则必须阻止这种情况,并相应地调整摄像机位置。HFBSPGraph 类中有一个方法(_adjustCamPosition),它使用BSPData对象中的碰撞检测方法,如果发现任何碰撞,则适当地调整摄像机位置。这段代码最终相当复杂,因为我希望玩家能够沿着墙壁和桌子行走,上下楼梯,走上最大仰角指定的坡道,从一个平台跳到另一个平台,悬浮,下蹲和站立。这意味着需要测试摄像机包围盒的所有方向,并为横向和垂直移动提供特殊行为。这一切都运作良好,但并不完美,仍有改进空间。

void HFBSPGraph::_placeCamera()
{
  const EntVars * pEnt = m_BSPData.FindEntity(cString("info_player_start"));
  if (pEnt)
  {
    m_pCamera->Position() = pEnt->vOrigin;
    m_pCamera->SetYawAngle(pEnt->fYawAngle);
  }
  else
  {
    m_pCamera->SetYawAngle(0.0f);
  }

  m_pCamera->SetCrouched(false);

  // 确保摄像机没有嵌入地板中
  _adjustCamPosition(Vector3f::cZero, true);
}

收集可见几何体
一旦摄像机放置在特定渲染实例的位置,它就可以在BSP树中定位,并为该位置收集可见面。可见面收集在两个数组对象中,一个用于静态关卡几何体(由BSP树定义),另一个用于任何潜在可见的实体对象。

对于关卡几何体,BSP树从摄像机当前所在的叶节点开始,以从前到后的顺序遍历。由于BSP节点包含包围盒,因此每个节点都与视锥进行检查,如果不存在交集(即BSP节点不在视锥内部),则该节点及其所有子节点都会被快速剔除。此外,如果任何候选叶节点不在摄像机所在节点的潜在可见集(PVS)中,则该叶节点也会被剔除(请注意,BSP叶节点包含所有面信息,非叶节点仅包含分割平面信息)。最终留下的是所有面片的一个子集,它们很有可能可见,因此必须进行渲染。我以从前到后的顺序遍历BSP树,因为我读到过一些文章说,许多支持z缓冲的硬件如果从前到后渲染,可以更有效地剔除隐藏像素。我不知道这有多真实,甚至它是否会带来实际的性能差异,但既然我有BSP树,我想我还是应该使用它。可能也有很好的理由以从后到前的顺序遍历BSP树(画家算法),并且进行这种更改并不困难。

对于实体对象,我使用加载实体时创建的可见实体对象的预计算映射。该映射列出了摄像机当前所在叶节点中的所有潜在可见实体对象。

最后还有一个检查,进一步剔除面列表,以移除任何背离摄像机或位于视锥之外的面。考虑到当今硬件的能力,这可能有点多余,但由于这对我来说是一次学习经历,我希望在剔除所有不可见面方面做得更多。这种愿望可能源于我阅读的迈克尔·阿布拉什的文章,这些文章是在代码性能的最后一点点都非常重要的时代写的。

const Array<int> * HFBSPGraph::_visibleLeafsFtoB()
{
  assert(m_pCamLeaf);

  m_VisLeafs.Clear();

  // 获取此叶子的可见性集
  const unsigned char * pVisSet = m_BSPData.VisSet(m_BSPData.Leafs()->m_pArray[m_pCamLeaf->nLeaf].ofsCluster);
  assert(pVisSet);

  // 遍历BSP树,从前到后,剔除摄像机视锥外的节点,收集要绘制的面
  // 只在可见叶子中(使用PVS)。
  const BSPNode * pCurrNode = m_pCamLeaf;
  const BSPNode * pParent = m_pCamLeaf->pParent;
  _collectLeafsFtoB_r(pCurrNode, &m_VisLeafs, pVisSet);
  while (pParent)
  {
    if (pParent->pFront == pCurrNode)
    {
      _collectLeafsFtoB_r(pParent->pBack, &m_VisLeafs, pVisSet);
    }
    else
    {
      _collectLeafsFtoB_r(pParent->pFront, &m_VisLeafs, pVisSet);
    }

    pCurrNode = pParent;
    pParent = pCurrNode->pParent;
  }

  return &m_VisLeafs;
}

手电筒
手电筒功能的最初动机是为了在关卡中一些较暗的区域能看得更清楚。我通过手工创建一个“光照贴图”纹理制作了一个廉价的手电筒,它呈圆形,强度在纹理贴图的外围半径处逐渐减弱。起初,当用户选择手电筒选项时,我通过将这个新的光照贴图投射到整个场景来实现。这种实现的问题是,这个光照贴图在作为纹理操作渲染后平铺应用于整个场景,因此手电筒的形状始终是圆形的。我尝试通过研究着色器来纠正这个问题,然后创建了一个顶点着色器,根据顶点的深度坐标来缩放手电筒光照贴图的纹理坐标。这使得手电筒效果看起来更加逼真,但由于只缩放了几何顶点处的纹理坐标,当光照贴图应用于大面积面片时,会出现可见的三角形伪影。可以使用像素着色器而非顶点着色器创建更好的手电筒效果,但在我进一步研究之前就没时间了。

顶点着色器非常简单。它存在于VertexShader1.fx文件中,该文件在应用程序初始化期间使用D3DX辅助库(D3Dx9_27.dll)进行编译。

VS_OUTPUT VS_Flashlight(
    float4 inPos : POSITION,    // HL空间中的顶点位置
    float2 inTex0 : TEXCOORD0,    // 纹理0坐标,预计算的光照贴图坐标
    float2 inTex1 : TEXCOORD1)    // 纹理1坐标,预计算的面纹理坐标
{
  VS_OUTPUT Out = (VS_OUTPUT)0;

  // 将位置投影到单位立方体
  Out.Pos = mul(inPos, WorldViewProj);
   
  // 根据摄像机空间位置计算手电筒纹理坐标
  float3 vcoords = mul(inPos, WorldView);
  float flength = max(length(vcoords), 150.0f);
  const float fscale = 1.7f;
  const float fadjust = 0.5f;
  Out.tex1.x = ((fscale * vcoords.x) / flength) + fadjust;
  Out.tex1.y = ((fscale * vcoords.y) / flength) + fadjust;
   
  // 传递预计算的纹理坐标
  Out.tex0 = inTex0;
  Out.tex2 = inTex1;

  return Out;
}

渲染场景
场景的实际渲染过程相当直接。首先渲染收集到的静态几何面,然后渲染实体对象面。面片使用IRenderer对象的DrawPrimitive和SetTexture方法进行渲染。所有这些都在BeginScene和EndScene IRenderer对象方法中完成,我相信对于DirectX来说,这让驱动程序首先收集数据,然后将其发送到硬件,从而使用户/内核转换次数最少。

void HFBSPGraph::Render()
{
  assert(m_pRenderer);
  assert(m_pCamLeaf);

  // 根据当前摄像机世界位置设置视图(摄像机)矩阵
  Matrix4f mtxView;
  _createViewMatrix(mtxView, m_mtxWorld);
  m_pRenderer->SetTransform(D3DTS_VIEW, mtxView);

  HRESULT hr = m_pRenderer->Clear(0, 0, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, 0xff303030, 1.0f, 0);
  hr = m_pRenderer->BeginScene();
  if (SUCCEEDED(hr))
  {
    // 渲染地图和实体笔刷几何体。
    _setSGRenderStates();
    _renderStaticGeometry(mtxView);
    _renderEntities(mtxView);

    // 渲染工作室模型

    m_pRenderer->EndScene();
  }

  m_pRenderer->Present(0, 0, 0, 0);
}

关注点

我试图触及这个半条命关卡查看器应用程序的主要方面。当然,还有许多细节被省略了,您需要参考源代码才能确切了解事情是如何完成的。我建议您使用调试器逐步检查代码的各个部分,以了解其工作原理。此外,对BSP/WAD文件、Direct3D渲染和一些线性代数的基本理解将有助于您了解和理解这段代码。在编写这个应用程序时,我学到了很多,并非常享受这个过程。我希望其他人会发现这段代码对学习3D编程有所帮助,并能从中获得和我一样的乐趣。

历史

初稿创建于2009年1月21日

2009年2月8日更新。添加了新的代码下载(HLViewer_2.zip)。此代码版本包含一些代码清理以及大量代码注释的增加。新的代码注释应该有助于读者更好地理解应用程序中使用的数据结构和类函数。

© . All rights reserved.