使用Silverlight-5和XNA实现3D基础






4.89/5 (9投票s)
一个最小的 3D 程序,展示了如何在 Silverlight-5 中使用 XNA,并对核心概念进行了全面解释。
引言
本文介绍了最简单的 Silverlight/XNA 程序:一个基本的旋转三角形,但具有完整的 3D 照明效果。它是经典“Hello World”程序的 3D 等效版本。它包含大约 50 行代码,并且解释了每行代码背后的原理。

背景
Silverlight-5 最大的新功能之一是使用 XNA 框架实现完全的 GPU 加速 3D 图形。
Silverlight 和 XNA 的结合将使一整类新的 3D 应用程序能够直接从网络运行,包括科学、工程、产品营销、商业和地理空间领域的应用程序。
XNA 框架为程序员提供了强大的能力,可以相当直接地控制图形硬件,并且可以实现一些令人印象深刻的结果。但是,尽管 XNA 大大简化了 3D 编程,特别是与 DirectX 或 OpenGL 相比,它仍然是一种相对低级的技术,没有 3D 编程经验的 Silverlight 程序员可能会面临陡峭的初始学习曲线。更糟糕的是,由于这是一种新技术,文档目前有些零散,并且可用的少数示例并不总是显示最简单的方法。
入门
您需要首先设置 Silverlight 5 开发环境,网上已经有大量关于此的信息,因此无需在此处重复。
设置好工具后,以下是构建第一个 XNA 应用程序的步骤:
- 启动 Visual Studio 并创建一个新的 Silverlight 5 应用程序。
- 将一个“
DrawingSurface
”控件拖放到主页面上。这就是所有 3D 魔法发生的地方。 - 如果在工具箱中看不到“
DrawingSurface
”控件,您可能需要右键单击并选择“选择项目...”来添加它。 - 为 Drawing Surface 添加一个“
Draw
”事件。 - 在事件处理程序中,添加两行代码。
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice; g.Clear(new Color(0.8f, 0.8f, 0.8f));
- 添加两个“
using
”指令。using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics;
- 删除不必要的“
using
”指令,特别是System.Windows.Media
,否则您将有两个冲突的Color
定义,并且项目将无法编译。 - 将以下内容添加到 HTML 或 ASPX 测试页面的
<object>
元素中:<param name="EnableGPUAcceleration" value="true" />
- 按 F5,您应该会在浏览器中看到一个浅灰色矩形。
当然,这并非普通的 Silverlight 灰色矩形,它是一个 GPU 加速的 XNA 矩形!
到目前为止,这已经足够简单了,现在让我们添加一些 3D。
添加 3D
这是完整的代码
using System.Windows.Controls;
using System.Windows.Graphics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace Silverlight5_3d
{
public partial class MainPage : UserControl
{
private float aspectRatio = 1f;
public MainPage()
{
InitializeComponent();
}
private void drawingSurface1_SizeChanged
(object sender, System.Windows.SizeChangedEventArgs e)
{
aspectRatio = (float)(drawingSurface1.ActualWidth / drawingSurface1.ActualHeight);
}
private void drawingSurface1_Draw(object sender, DrawEventArgs e)
{
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
g.RasterizerState = RasterizerState.CullNone;
g.Clear(new Color(0.8f, 0.8f, 0.8f, 1.0f));
VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[]{
new VertexPositionNormalTexture(new Vector3(-1, -1, 0),
Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(0, 1, 0),
Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(1, -1, 0),
Vector3.Forward,Vector2.Zero)};
VertexBuffer vb = new VertexBuffer(g,VertexPositionNormalTexture.VertexDeclaration,
vertices.Length,BufferUsage.WriteOnly);
vb.SetData(0, vertices, 0, vertices.Length, 0);
g.SetVertexBuffer(vb);
BasicEffect basicEffect = new BasicEffect(g);
basicEffect.EnableDefaultLighting();
basicEffect.LightingEnabled = true;
basicEffect.Texture = new Texture2D(g, 1, 1, false, SurfaceFormat.Color);
basicEffect.Texture.SetData<color>(new Color[1]{new Color(1f, 0, 0)});
basicEffect.TextureEnabled = true;
basicEffect.World = Matrix.CreateRotationY((float)e.TotalTime.TotalSeconds * 2);
basicEffect.View = Matrix.CreateLookAt(new Vector3(0, 0, 5.0f),
Vector3.Zero, Vector3.Up);
basicEffect.Projection = Matrix.CreatePerspectiveFieldOfView
(0.85f, aspectRatio, 0.01f, 1000.0f);
basicEffect.CurrentTechnique.Passes[0].Apply();
g.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
e.InvalidateSurface();
}
}
}
代码行数不多,但涉及的内容很多,所以我们来分解一下。
绘制图元
XNA 是一个相当低级的框架,直接与图形硬件协同工作,因此它唯一知道如何绘制的东西是三角形和线条。在我们的程序中,执行绘制的代码是:
g.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
这指示 GPU 使用预加载的顶点缓冲区绘制一个三角形。
顶点缓冲区
XNA 提供了四种内置的顶点选项,您也可以定义自己的自定义变体。所有顶点都具有使用 Vector3
结构定义的 3D 位置,并且它们可以具有附加属性,例如颜色、纹理坐标和照明“法线”。我们希望我们的程序具有逼真的 3D 照明,因此我们使用 VertexPositionNormalTexture
结构。
我们的程序有一个单独的三角形,因此我们创建了一个包含三个顶点的数组。我们定义 3D 位置坐标,使三角形位于 X-Y 平面上,并且我们将照明“法线”定义为与此垂直,即沿着 Z 轴“向前”。我们将纹理坐标定义为 (0,0),因为我们将使用一个像素的纯色纹理位图。
一旦我们有了顶点数组,我们将其封装在 VertexBuffer
类中并将其发送到 GPU。
VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[]{
new VertexPositionNormalTexture(new Vector3(-1, -1, 0),Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(0, 1, 0),Vector3.Forward,Vector2.Zero),
new VertexPositionNormalTexture(new Vector3(1, -1, 0),Vector3.Forward,Vector2.Zero)};
VertexBuffer vb = new VertexBuffer
(g,VertexPositionNormalTexture.VertexDeclaration,
vertices.Length,BufferUsage.WriteOnly);
vb.SetData(0, vertices, 0, vertices.Length, 0);
g.SetVertexBuffer(vb);
GraphicsDevice 类
GraphicsDevice
类是 XNA 的核心,用于控制 GPU。它只有一个实例,您可以通过 GraphicsDeviceManager
类的 static
属性获取它。
GraphicsDevice
类具有用于绘制图元和设置顶点缓冲区的方法,如上所述。它还有许多其他属性和方法,用于控制 3D 渲染过程的细节。对于我们的简单程序,我们保留所有默认设置,除了将“RasterizerState
”更改为“CullNone
”。默认是不绘制三角形的背面,因为它们通常在 3D 对象的内部看不见,但我们的 3D 模型是一个单独的三角形,我们希望能够看到它的两面。
GraphicsDevice g = GraphicsDeviceManager.Current.GraphicsDevice;
g.RasterizerState = RasterizerState.CullNone;
g.Clear(new Color(0.8f, 0.8f, 0.8f, 1.0f));
效果
XNA 提供了五种不同的“Effect
”类选择,它们封装了与 GraphicsDevice
通信的许多低级细节。我们将使用“BasicEffect
”类,它实际上相当强大——它标准地包含了一个非常好的 3D 照明设置。
BasicEffect basicEffect = new BasicEffect(g);
basicEffect.EnableDefaultLighting();
basicEffect.LightingEnabled = true;
为了实现终极的低级控制,可以通过直接使用 GraphicsDevice
对象,而不使用内置的 Effect 类来编程 XNA。您甚至可以编程自己的自定义像素着色器和顶点着色器。但是,内置的 Effect
类,特别是 BasicEffect
,使事情变得容易得多。
纹理
在 XNA 中,3D 场景由覆盖纹理的三角形网格组成。纹理只是 2D 位图——XNA 类是 Texture2D
。它们使用 Effect
类发送到 GraphicsDevice
,每个三角形顶点指定其在纹理中的 2D 坐标。按照惯例,这些纹理坐标称为 U 和 V。
在许多 XNA 应用程序中,特别是游戏,纹理是离线创建的,然后在运行时加载。对于我们的简单程序,我们以编程方式创建一个一个像素的纯红色纹理。
basicEffect.Texture = new Texture2D(g, 1, 1, false, SurfaceFormat.Color);
basicEffect.Texture.SetData<color>(new Color[1]{new Color(1f, 0, 0)});
basicEffect.TextureEnabled = true;
</color>
矩阵和 3D 变换
这是 3D 编程的核心。我们顶点缓冲区中的顶点定义了我们 3D 虚拟世界中三角形三个角的精确位置,但在显示任何内容之前,这些 3D 坐标需要转换为 2D 屏幕坐标。
此过程分为三个阶段,每个阶段都使用通过 Matrix
类定义的 3D 变换。Matrix
类实际上表示一个 4x4 的浮点数矩阵,但其主要功能是通过平移、旋转和缩放的基本操作来变换由 Vector3
类定义的 3D 坐标。
basicEffect.World = Matrix.CreateRotationY((float)e.TotalTime.TotalSeconds * 2);
basicEffect.View = Matrix.CreateLookAt(new Vector3(0, 0, 5.0f), Vector3.Zero, Vector3.Up);
basicEffect.Projection = Matrix.CreatePerspectiveFieldOfView
(0.85f, aspectRatio, 0.01f, 1000.0f);
第一步是将模型坐标转换为世界坐标。这称为世界变换(World Transform)。在我们的程序中,我们有一个模型,一个单独的三角形,世界变换用于定义它在我们 3D 虚拟世界中的位置和方向。如果我们的 3D 场景有多个模型,或相同模型的多个实例,它们每个都将有自己的世界变换。我们只有一个模型,但它会实时旋转。因此,我们使用世界变换来表示基于经过时间的旋转。
第二步是将世界坐标(以我们虚拟世界的“原点”为中心)转换为视图坐标(以我们的视点或摄像机位置为中心)。这称为视图变换(View Transform)。Matrix
类有一个方便的方法(CreateLookAt
),用于根据摄像机位置和它所看向的中心点创建视图变换。
第三步是将我们虚拟世界中的 3D 坐标转换为 2D 屏幕坐标。为了保持一致性,这一步也使用 Matrix
类完成,因此输出是 3D 坐标,但只有 X 和 Y 值用于在屏幕上创建 2D 渲染图像。这一步称为投影变换(Projection Transform),XNA 提供了两种选择:正交投影和透视投影。使用正交投影选项,离摄像机较远的对象不会显得更小。透视投影选项是更“自然”的视图,其中远处的对象显得更小。Matrix
类还提供了一个方便的方法(CreatePerspectiveFieldOfView
),用于根据摄像机属性创建投影变换,特别是以 Y 轴弧度表示的镜头角度(即广角或远摄)。
一旦我们的三个变换矩阵设置完毕,BasicEffect
类就会负责设置 GraphicsDevice
以将我们的 3D 场景渲染到 2D 屏幕上。
效果通道和技术
所有 XNA 的内置 Effect
类都继承自 Effect
基类,该基类封装了“技术”(Techniques)和“通道”(Passes)的概念。
技术提供替代的渲染效果,可以在绘制时选择,可能取决于正在绘制的对象类型。
通道用于多阶段渲染。
BasicEffect
类提供了一种技术,默认情况下会选中该技术,并且该技术只有一个通道。在发出任何绘制命令之前,有必要“应用”一个通道。我们的程序通过这行代码来实现这一点:
basicEffect.CurrentTechnique.Passes[0].Apply();
但您经常会看到以下形式的 XNA 代码:
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Apply();
// Drawing commands
}
我们使用更简单的形式,因为我们知道 BasicEffect
只有一个通道。如果我们要使用具有多个通道的效果,或者如果我们要通用化我们的代码使其适用于任何 Effect
类,我们将使用 foreach
循环。
Resize 事件和新的 Silverlight 5 线程模型
当我们创建投影变换时,我们需要提供 DrawingSurface
的宽高比 (宽度/高度)。
为了使我们的“Hello World”程序保持简单,我们可能会尝试在 Draw
事件处理程序中执行此操作。但这将不起作用:
Float aspectRatio = (float)(drawingSurface1.ActualWidth / drawingSurface1.ActualHeight);
basicEffect.Projection =
Matrix.CreatePerspectiveFieldOfView(0.85f, aspectRatio, 0.01f, 1000.0f);
它会抛出异常:
System.UnauthorizedAccessException: Invalid cross-thread access
原因在于 Silverlight 5 的新线程模型。以前,Silverlight 在其单一 UI 线程上执行所有操作,尽管您确实可以选择手动创建额外的线程。
Silverlight 5 现在在另一个称为“Composition
”线程的线程上执行其低级图形工作。对于基于 XAML 的 Silverlight,您无需担心此更改,因为您的代码在 XAML 对象树上运行,并且不涉及其低级渲染。
然而,使用 Silverlight-XNA,您正在更低的层次上工作,并且 DrawingSurface
控件的 Draw
事件在合成线程上执行。如果您尝试从合成线程访问 XAML 控件,您将收到一个异常。
在我们的程序中,我们通过局部变量“aspectRatio
”来避免这种情况,我们在 Resize
事件处理程序中更新它,然后在 Draw
事件处理程序中使用它。我们没有对这个变量进行任何锁定,假设一个 32 位浮点数是原子性的。
对于在合成线程和 UI 线程之间共享更复杂数据结构的生产代码,您需要非常小心地正确锁定。
Invalidate
程序中的最后一行代码是对以下内容的调用:
e.InvalidateSurface();
将此调用放在 Draw
事件处理程序的末尾会导致 Draw
事件立即再次触发。对于不断变化的 3D 场景(例如我们旋转的三角形),这提供了最流畅的操作。
如果您的场景是静态的,那么您无需不断重绘它,只需在有变化时调用 InvalidateSurface()
即可。
优化绘制方法
我们的程序旨在尽可能短小精悍,以说明 Silverlight-XNA 的要点。
在实际程序中,您会希望通过分离一次性设置代码来优化关键的 Draw
处理程序。Draw
处理程序每帧执行一次,例如每秒 60 次,因此您需要使其尽可能轻量。特别是,您应该避免在其中实例化任何对象。
结论
至此,我们完成了对一个非常简单的 Silverlight-XNA 程序的介绍。
尽管它只涵盖了 XNA 中可用的一小部分,但其中包含许多关键概念,您实际上可以使用此处展示的类和方法构建一个相当复杂的 3D 程序。
附录
本文撰写以来,微软已经发布了 Silverlight 5。
发布的版本比测试版具有更严格的安全限制,用户现在必须明确授予网站使用加速 3D 图形的权限。
您需要将以下代码添加到主页构造函数中以提示用户执行此操作:
public MainPage()
{
InitializeComponent();
if (GraphicsDeviceManager.Current.RenderMode != RenderMode.Hardware)
{
string message;
switch (GraphicsDeviceManager.Current.RenderModeReason)
{
case RenderModeReason.Not3DCapable:
message = "You graphics hardware is not capable of displaying this page ";
break;
case RenderModeReason.GPUAccelerationDisabled:
message = "Hardware graphics acceleration has not been enabled
on this web page.\n\n" +
"Please notify the web site owner.";
break;
case RenderModeReason.TemporarilyUnavailable:
message = "Your graphics hardware is temporarily unavailable.\n\n"+
"Try reloading the web page or restarting your browser.";
break;
case RenderModeReason.SecurityBlocked:
message =
"You need to configure your system to allow this web site
to display 3D graphics:\n\n" +
" 1. Right-Click the page\n" +
" 2. Select 'Silverlight'\n" +
" (The 'Microsoft Silverlight Configuration' dialog will be displayed)\n" +
" 3. Select the 'Permissions' tab\n" +
" 4. Find this site in the list and change its 3D Graphics
permission from 'Deny' to 'Allow'\n" +
" 5. Click 'OK'\n" +
" 6. Reload the page";
break;
default:
message = "Unknown error";
break;
}
MessageBox.Show(message,"3D Content Blocked", MessageBoxButton.OK);
}
}
不幸的是,微软没有为提供一个简单的用户体验而提供良好的支持。