使用 Managed DirectX 渲染地形






4.67/5 (25投票s)
2004年9月6日
7分钟阅读

179777

3024
通过使用高级着色器语言 (High Level Shader Language),本文将帮助您创建几乎可以乱真的地形。
使用 Managed DirectX 渲染地形
很少有 3D 游戏没有地形。创建和渲染地形,以及例如在上面驾驶汽车时涉及的物理效果可能非常困难。本文将展示一种创建地形的技术:一种易于实现但效果良好的技术。
要编译本文中的代码,您需要以下内容
- C# 编译器,最好是 Visual Studio .NET。
- DirectX 9.0c 软件开发工具包。
- 支持 Pixelshader 2.0 的显卡会很有用,否则您将不得不使用 Reference 设备(非常非常慢:每帧大约 10 秒)。
我希望您作为读者能够理解 C# 语言,并且对托管 DirectX 有一定的经验。
开始
渲染地形时,您首先需要的是一种表示地形的方式。通常使用灰度高度图。因为这是一种简单的方法,所以本文将使用灰度高度图。这是我使用的高度图,但您可以轻松地使用任何喜欢的程序进行修改。
我们将使用两种纹理。一种是草,另一种是某种岩石或石头。想法是,地形像素越高,草的成分就越少。因此,我们根据像素的高度对这两种纹理进行某种程度的混合。
高级着色器语言 (High Level Shader Language)
为了做到这一点,我们将使用高级着色器语言。使用这种语言,可以编写自己的像素着色器和顶点着色器,从而更好地控制顶点和像素的渲染方式。HLSL 是一种与 C 非常相似的语言。因此,学习该语言不会花费太多时间。这是我们将用于渲染地形的顶点着色器
float4x4 WorldViewProj;float4 light;
void Transform(
in float4 inPos : POSITION0,
in float2 inCoord : TEXCOORD0,
in float4 blend : TEXCOORD1,
in float3 normal : NORMAL,
out float4 outPos : POSITION0,
out float2 outCoord : TEXCOORD0,
out float4 Blend : TEXCOORD1,
out float3 Normal : TEXCOORD2,
out float3 lightDir : TEXCOORD3 )
{
outPos = mul(inPos, WorldViewProj);
outCoord = inCoord;
Blend = blend;
Normal = normalize(mul(normal,WorldViewProj));
lightDir = inPos.xyz - light;
}
它看起来像一个普通的 C 方法,只有一个不同之处:除了名称之外,输入和输出变量还用语义标记。这会将顶点着色器的输入与应用程序中的顶点数据以及顶点着色器的输出与像素着色器的输入关联起来。您可能会注意到语义 `TEXCOORD` 使用了很多;这是因为 `TEXCOORD` 语义可以用来传递应用程序特定的数据,一个不代表位置、法线等的变量。HLSL 包含许多数学内置函数,例如 `mul()`、`normalize()`。它们的完整列表可以在 MSDN 上找到。有关高级着色器语言的更多信息,我建议您查阅一些网站,因为关于它的内容很多,如果在这里深入讨论,本文会太长。
我将简要描述顶点着色器的作用:首先,将输入位置与世界视图投影矩阵相乘。这样,顶点就从对象空间转换到相机空间。输入纹理坐标和混合值将被传递到像素着色器。法线也被转换然后归一化。最后,通过从世界空间中的向量位置(在这种情况下,世界空间与对象空间相同,因为没有进行平移、旋转或缩放)减去光的位置来计算光的传播方向,以将其传递给像素着色器。这是像素着色器
Texture Texture1;
Texture Texture2;
sampler samp1 = sampler_state { texture = <Texture1>;
minfilter = LINEAR; mipfilter = LINEAR; magfilter = LINEAR;};
sampler samp2 = sampler_state { texture = <Texture2>;
minfilter = LINEAR; mipfilter = LINEAR; magfilter = LINEAR;};
float4 TextureColor(
in float2 texCoord : TEXCOORD0,
in float4 blend : TEXCOORD1,
in float3 normal : TEXCOORD2,
in float3 lightDir : TEXCOORD3) : COLOR0
{
float4 texCol1 = tex2D(samp1, texCoord*4) * blend[0];
float4 texCol2 = tex2D(samp2, texCoord) * blend[1];
return (texCol1 + texCol2) * (saturate(dot(normalize(normal),
normalize(light)))* (1-ambient) + ambient);
}
正如您所看到的,像素着色器接收了几乎所有来自顶点着色器的变量,除了 `POSITION0` 变量,因为这是每个顶点着色器都必须输出的变量,而我们的像素着色器不会使用它。首先,使用 `tex2D()` 内置函数计算两个纹理颜色,注意这个 `tex2D` 方法不接受纹理而是采样器。将这些颜色与混合值相乘,然后将两者的乘积与该像素处的光强度相加,返回该值。该像素着色器返回一个用 `COLOR0` 语义标记的 float4,而不是 `void`,每个像素着色器都必须返回一个标记为 `COLOR0` 的变量,否则将无法编译。
回到 C#
为了让您的应用程序与着色器进行通信,您还必须有一个 `VertexDeclaration`。这将告诉 DirectX `VertexBuffer` 中的数据代表什么以及它与顶点着色器的输入变量如何相关。这是用于地形的 `VertexDeclaration`
VertexElement[] v = new VertexElement[]
{
new VertexElement(0,0,DeclarationType.Float3,DeclarationMethod.Default,
DeclarationUsage.Position,0),
new VertexElement(0,12,DeclarationType.Float3,DeclarationMethod.Default,
DeclarationUsage.Normal,0),
new VertexElement(0,24,DeclarationType.Float2,DeclarationMethod.Default,
DeclarationUsage.TextureCoordinate,0),
new VertexElement(0,32,DeclarationType.Float4,DeclarationMethod.Default,
DeclarationUsage.TextureCoordinate,1),
VertexElement.VertexDeclarationEnd
};
decl = new VertexDeclaration(device,v);
正如您所看到的,这个 `VertexDeclaration` 包含一个 `VertexElements` 数组,用于描述我使用的 `struct`。说到这个 `struct`,我们不能使用 `CustomVertex` 的某个成员,因为我们希望能够为每个顶点添加纹理比例关系。所以,我们将使用这个 `struct`
public struct Vertex
{
Vector3 pos;
Vector3 nor;
float tu,tv;
float b1,b2,b3,b4;
public Vertex(Vector3 p,Vector3 n,
float u,float v,float B1,float B2,
float B3, float B4, bool normalize)
{
pos = p;nor = n;tu = u;tv = v;
b1=B1; b2=B2; b3=B3;b4 = B4;
float total = b1 + b2 + b3 + b4;
if ( normalize)
{
b1 /= total;
b2 /= total;
b3 /= total;
b4 /= total;
}
}
public static VertexFormats Format =
VertexFormats.Position | VertexFormats.Normal |
VertexFormats.Texture0 | VertexFormats.Texture1;
}
它包含一个用于位置的 `Vector3`,一个用于 `normal` 的 `Vector3`,以及用于纹理坐标和混合值的 `float`。为了给这个 `struct` 的成员赋值,它还包含一个构造函数。它还包含一个 `Format` 变量,用于传递给 `VertexBuffer`。
为了让 DirectX 与效果 (effect) 通信,我们只需要一件事:`Effect` 类。像这样创建一个效果
Effect 类
String s = null; effect = Effect.FromFile(device, @"..\..\simple.fx", null, ShaderFlags.None, null, out s); if ( s != null) { MessageBox.Show(s); return; }
默认情况下,您无法调试着色器,因此当您输入错误时,可能会花费数小时寻找问题所在,却不知道从何处着手。为了避免这种情况,我们使用了带有一个 `out` 参数的 `Effect` 构造函数重载,通过该参数,效果会给出 `CompilationErrors`。因此,即使编译着色器失败,此重载也会成功,只是输出字符串不再是 `null`,但效果仍然是 `null`。所以,如果出现错误,将显示一个 `MessageBox` 来展示这些错误。
好吧,除了包含入口点的类之外,此应用程序还包含一个 `Terrain` 类。该类读取位图中的所有数据并创建 `VertexBuffer` 和 `IndexBuffer`。我们通过将 min 和 max 值传递给构造函数来指定地形的高度。为了确保达到 min 和 max 值,我们首先获取最暗和最亮的像素。位图中的每个像素将成为一个顶点,连接这些顶点时产生的四边形将被分割成三角形,形成一个 `TriangleList`。`Draw` 方法假定在调用 `Draw` 方法时 `effect.BeginScene()` 已经被调用。`effect.BeginScene()` 告诉效果它现在将接收要渲染的内容,而 `effect.EndScene()` 则告诉它应该停止处理 `VertexData`。此外,还会设置 `VertexDeclaration`、`VertexBuffer` 和 `IndexBuffer`,最后调用 `DrawPrimitives`。
为了修改着色器中全局变量的值,`Effect` 类包含 `SetValue()` 方法。您可以传递一个字符串来标识要更改的变量
effect.SetValue("Texture1", t1);
另一种方法是创建一个 `EffectHandle`,如下所示
EffectHandle handle = effect.GetParameter(null,"ambient"); effect.SetValue(handle,0.5f);
这样,您就不必将字符串传递给方法,因此会更快。因此,只需要赋值一次的变量可以使用第一种方法赋值,但如果一个变量被更改多次,最好使用第二种方法。请注意,这不仅适用于变量,也适用于技术 (techniques)。使用技术,您可以选择要使用的着色器;由于一个 Effect 文件可能包含多个顶点着色器和像素着色器,因此每个 HLSL 文件至少必须有一个技术。技术声明如下
technique TransformTexture
{
pass P0
{
VertexShader = compile vs_2_0 Transform();
PixelShader = compile ps_2_0 TextureColor();
}
}
一个技术包含一个或多个通道 (pass),在本例中只有一个,名为 `P0`;对于每个通道,您需要分配一个顶点着色器和像素着色器。设置编译目标(HLSL 编译器有很多版本:1_1、2_0、3_0。版本号越高,提供的可能性越多,但显卡的支持就会越少)。`Transform()` 和 `TextureColor()` 是顶点着色器和像素着色器的名称。
在通道中,您还可以设置 `RenderState` 值。如果您想将 `Device.RenderState.Cullmode` 设置为 `Cull.None`,您需要在通道的第一行插入此行
CullMode = None;
按键说明
- Escape:退出
- 向上箭头:增加环境光。
- 向下箭头:减少环境光。
- 空格键:更改相机位置。
- 回车键:渲染法线。
结论
好了,我想就这些了。当然,您可以通过电子邮件或帖子提出所有问题、建议或技巧。