赞助广告

 

年份

最新评论

评论 RSS

网络——从Lobby移动到实际游戏

clock 三月 8, 2011 09:38 by author alex
问题 创建了会话后,你想给所有玩家时间用来聚集、聊天、让他们可以示意已经准备好可以进行游戏了。 解决方案 XNA在会话状态和玩家的IsReady属性中带有基本的lobby功能。 会话开始于Lobby状态。只有主机可以调用NetworkSession. StartGame方法,这个方法将会话移动到Playing状态。主机可以基于所有玩家的IsReady状态做出决定。 工作原理 当会话创建后,会话的SessionState属性会拥有Lobby值。 当在这个状态时,你需要让所有在这个会话中的玩家可以示意他们已经做好了准备,这可以通过将他们的IsReady设为true做到。这个值可以被会话中的所有玩家读取: case GameState.InSession: ...{ switch (networkSession.SessionState) ... { case NetworkSessionState.Lobby: ...{ if (keybState != lastKeybState) ... { if (keybState.IsKeyDown(Keys.R)) { LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.IsReady = !localGamer.IsReady; } } } break; case NetworkSessionState.Playing: ... { } break; } networkSession.Update(); } 主机需要检测是否所有玩家都将IsReady设为了true。最简单的方法是检查NetworkSess ion.IsEveryoneReady的值,如果所有人都准备好了,主机会调用NetworkSession.StartGame方法,这个方法将状态从Lobby移动到Playing: case NetworkSessionState.Lobby: ...{ if (keybState != lastKeybState) ... { if (keybState.IsKeyDown(Keys.R)) ... { LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.IsReady = !localGamer.IsReady; } } if (networkSession.IsHost) ...{ if (networkSession.AllGamers.Count > 1) ...{ if (networkSession.IsEveryoneReady) ...{ networkSession.StartGame(); log.Add("All players ready -- start the game!"); } } } } break; 当状态改变至Playing时,会话会引发GameStarted事件,这个事件所有玩家都会进行监听。这样可以让他们绘制游戏屏幕并开始发送和接收游戏数据。 当主机决定结束游戏时,需要调用NetworkSession.EndGame方法,这个方法将所有玩家的IsReady重置为false并返回Lobby状态。 case NetworkSessionState.Playing: ...{ if (networkSession.IsHost) ... { if (keybState != lastKeybState) ... { if (keybState.IsKeyDown(Keys.E)) ...{ networkSession.EndGame(); } } } } break; 这会引发会话的GameEnded事件,所有玩家都可以监听这个事件,这样他们就可以,例如,绘制lobby图像。 代码 InSession状态包含大多数基本代码让多个玩家表示他们的IsReady,让主机从Lobby状态移动到Playing状态或返回: case GameState.InSession: ...{ switch (networkSession.SessionState) ...{ case NetworkSessionState.Lobby: ...{ if (keybState != lastKeybState) ... { if (keybState.IsKeyDown(Keys.R)) ... { LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.IsReady = !localGamer.IsReady; } } if (networkSession.IsHost) ... { if (networkSession.AllGamers.Count > 1) ...{ if (networkSession.IsEveryoneReady) ...{ networkSession.StartGame(); log.Add("All players ready -- start the game!"); } } } } break; case NetworkSessionState.Playing: ... { if (networkSession.IsHost) ... { if (keybState != lastKeybState) ... { if (keybState.IsKeyDown(Keys.E)) ... { networkSession.EndGame(); } } } } break; } networkSession.Update(); } break;

网络——添加富状态信息

clock 三月 8, 2011 09:33 by author alex
问题 你想为玩家设置富状态信息,这些信息可以显示给其他Xbox Live玩家,这些玩家可以通过Xbox Guide或在http://www.xbox.com网站上看到这些信息。 解决方案 大多数游戏可以给玩家添加富状态信息,这样其他玩家可以看到玩家正在玩什么游戏,当前他们正在游戏中干什么。你可以在Gamer.SignedInGamers[0].Presence属性中设置这个信息。 工作原理 使用XNA的网络功能,在你设置玩家的富状态信息前,首先需要确保玩家已经登录到Xbox Live。你可以从60个PresenceModes中选择,对已经登录的玩家来说非常容易设置: Gamer.SignedInGamers[0].Presence.PresenceMode=GamerPresenceMode.AtMenu; 所有的PresenceModes如表8-1所示。 表8-1 60个PresenceModes 对某些模式(Stage,Level,Score,CoOpStage,VersusScore),你还可以设置一个值表示玩家的stage或关卡或当前得分。下面的例子表示玩家正处于第15关: Gamer.SignedInGamers[0].Presence.PresenceMode = GamerPresenceMode.Level; Gamer.SignedInGamers[0].Presence.PresenceValue = 15;

网络——异步搜索网络会话

clock 三月 8, 2011 09:31 by author alex
问题 一些网络操作,诸如下载玩家配置或搜索可用的会话,会花费大量的时间。前面的教程中使用的是最简单的设置,这些操作会使程序暂停直至操作完成,这段时间中你想让玩家知道操作的信息! 解决方案 XNA为几乎所有需要时间完成的网络操作提供了异步选择。例如,NetworkSession. Find 方法的异步操作对应NetworkSession. BeginFind方法。 在异步操作的开始,XNA会创建第二个线程用于这个操作,让这个操作可以与主程序并行处理。这样做的优点是,你的主程序只需初始化异步操作,然后可以继续自己的处理。这让你可以向玩家提供任何运行在第二个线程中的异步操作的信息。 一旦异步操作完成,结果会传递到你指定的方法中,让你可以处理异步操作产生的结果。 工作原理 这个教程会解释如何使用NetworkSession.BeginFind方法,XNA中的其他的异步操作工作原理是相同的。 这个教程是教程8-3的异步版本,在那个教程中,程序开始处于SignIn状态并依次进入SearchSession, CreateSession和InSession状态。在这个教程中,你将添加一个Searching状态,当操作在后台运行时可以向用户提供信息: public enum GameState ...{ SignIn, SearchSession, Searching, CreateSession, InSession } 开始异步操作 SearchSession状态很容易,因为它要做的只是初始化异步操作: case GameState.SearchSession: ...{ NetworkSession.BeginFind(NetworkSessionType.SystemLink, 2, null, EndAsynchSearch, null); log.Add("ASynch search started - proceed to Searching"); log.Add("Searching"); currentGameState = GameState.Searching; } break; NetworkSession.BeginFind方法会导致在后台创建第二个线程,这个线程与主程序并行进行搜索操作,这样带来的好处是主程序在运行到NetworkSession.BeginFind时无需等待(而使用NetworkSession.Find方法时需要),可以立即进入到Searching状态。 NetworkSession.BeginFind方法需要五个参数。前三个和NetworkSession. Find方法是相同的,可见教程8-3的解释。 要理解第四个参数,我需要解释当后台的搜索操作结束后会发生什么。第二个线程会调用你的方法之一并将它传递到结果。这意味着你必须指定应该调用哪个方法,这可以通过使用第四个参数做到。在本例中,当第二个线程结束后你指定它调用EndAsynchSearch方法,这个方法后面就会讲到。 最后一个参数让你识别异步操作,当在同一时间有多个异步操作的情况中这是很有用的。可见教程1—9看一下这个参数的例子。 在此期间. . . 当搜索在后台进行时,你可以自由地在Searching状态中做任何其他操作。在这个简单的例子中,每次调用Update方法时, 会在显示在屏幕的文字后面加上一个”.”。 case GameState.Searching: ...{ log[log.Count - 1] +=”.” } break; NetworkSessions.EndFind 当运行在后台的第二个线程结束异步搜索后,它会调用在NetworkSession. BeginFind方法中第四个参数指定的方法。在前面的代码中,我指定的是EndAsynchSearch方法,具体代码如下,看起来比较复杂,但主要部分来自于前一个教程的SearchSession状态中的代码: private void EndAsynchSearch(IAsyncResult result) ...{ AvailableNetworkSessionCollection activeSessions = NetworkSession.EndFind(result); if (activeSessions.Count == 0) ...{ currentGameState = GameState.CreateSession; log.Add("No active sessions found - proceed to CreateSession"); } else ...{ AvailableNetworkSession sessionToJoin = activeSessions[0]; networkSession = NetworkSession.Join(sessionToJoin); string myString = "Joined session hosted by " + sessionToJoin.HostGamertag; myString += " with " + sessionToJoin.CurrentGamerCount.ToString() + " players"; myString += " and " + sessionToJoin.OpenPublicGamerSlots.ToString() + " open player slots."; log.Add(myString); HookSessionEvents(); currentGameState = GameState.InSession; } } 这个方法将异步操作的结果作为参数。 对每个开始一个异步操作的方法,你都会找到一个方法可以处理它的结果。本例中开始异步操作的是NetworkSession.BeginFind方法,你可以将结果传递到NetworkSession.EndFind方法,这个方法将结果放在一个AvailableNetworkSessionCollection对象中。接下去的操作就和前一个教程一模一样了。 代码 下面是Update方法中的switch代码块,这个代码让程序从SignIn状态移动到SearchSession和Searching状态。EndAsynchSearch方法决定是否移动到CreateSession或 InSession状态。 switch (currentGameState) ...{ case GameState.SignIn: ...{ if (Gamer.SignedInGamers.Count < 1) ...{ Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); currentGameState = GameState.SearchSession; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession"); } } break; case GameState.SearchSession: ...{ NetworkSession.BeginFind(NetworkSessionType.SystemLink, 2, null, EndAsynchSearch, null); log.Add("ASynch search started - proceed to Searching"); log.Add("Searching"); currentGameState = GameState.Searching; } break; case GameState.Searching: ... { log[log.Count - 1] += "."; } break; case GameState.CreateSession: ... { networkSession = NetworkSession.Create(NetworkSessionType.SystemLink,4, 16); networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; log.Add("New session created"); HookSessionEvents(); currentGameState = GameState.InSession; } break; case GameState.InSession: ...{ networkSession.Update(); } break; }

网络——在网络上发送,接收数据

clock 三月 7, 2011 13:56 by author alex
问题 创建并加入一个网络会话是一回事,但如果不能发送或接收任何数据那么网络会话有什么用呢? 解决方案 当玩家连接到会话时,你可以在一个PacketWriter流中存储所有想要发送的数据。完成这个操作后,你可以使用LocalNetworkPlayer.SendData方法将这个PacketWriter发送给会话中的所有玩家。 在玩家接收数据前,你应该检查他们的LocalNetworkGamer. IsDataAvailable是否被设为ture,这表示数据已经被接收并做好了处理的准备。 一旦IsDataAvailable为true,你就可以调用LocalNetworkGamer.ReceiveData方法,返回一个包含另一个玩家发送给本地玩家的所有数据的PacketReader。 工作原理 这个教程建立在前一个教程结果的基础上,前一个教程允许同一网络上的多个机器通过一个会话互联。程序结束于InSession状态,这个状态只是简单地调用会话的Update方法。 现在,你将在InSession状态中做点实际的操作,让你的玩家可以将一些数据发送到会话中的其他玩家那里。本例中,你将发送程序运行的分钟数和秒数。 然后,你监听可用的数据。如果有可用的数据,你会接收两个数字,将它们放在一个字符串中,显示在屏幕上。 要发送和接收数据,你需要一个PacketWriter对象和一个PacketReader对象,所以在代码中添加这两个变量: PacketWriter writer = new PacketWriter(); PacketReader reader = new PacketReader(); 在项目中使用超过一个的PacketWriter对象和PacketReader对象是毫无理由的。 将数据发送到会话中的另一个玩家 你需要在PacketWriter中存储所有要发送给其他玩家的数据,这可以通过将数据作为Write方法的参数做到: writer.Write(gameTime.TotalGameTime.Minutes); writer.Write(gameTime.TotalGameTime.Seconds); 在将所有数据存储到PacketWriter之后,你可以使用本地玩家的SendData方法将它发送到所有其他用户: LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.SendData(writer, SendDataOptions.None); SendDataOptions参数会在教程的最后解释。更重要的是,SendData有一个重载方法可以只将数据发送到指定玩家而不是会话中的所有玩家。 以上就是将数据发送到会话中的其他玩家需要的所用操作。 从会话中的另一个玩家处接收数据 从其他玩家接收数据大致就是反过程:调用本地玩家的ReceiveData方法,这会返回一个包含其他玩家发送数据的PacketReader。调用PacketReader. Read方法中的一个从PacketReader获取数据: NetworkGamer sender; localGamer.ReceiveData(reader, out sender); string playerName = sender.Gamertag; int minutes = reader.ReadInt32(); int seconds = reader.ReadInt32(); ReceiveData方法存储PacketReader流中的数据,发送给你数据的玩家会存储在第二个参数中,这样你就可以知道数据来自于谁。 当从PacketReader读取数据时,你需要确保以与发送相同的顺序进行读取。而且,因为PacketReader只包含字节流,你需要告知你想从字节构建哪个对象。例如,一个整数需要的字节比矩阵少,所以需要在某些时候告知你想恢复为哪种类型的对象。 本例中分钟数和秒数为整数,所以你想从字节流中重新构建两个整数。看一下PacketReader的不同Read方法,注意支持哪个对象。如果你想重构矩阵,则应该使用ReadMatrix方法,使用ReadSingle方法重构float,ReadDouble方法重构double,ReadInt16 重构short。 LocalGamer.IsDataAvailable 如果多个玩家向你发送数据,可能会有多个字节流需要被读取,这种情况也会发生在其他玩家调用SendData的频率大于你调用ReceiveData的频率时。 在这种情况下,你可以查询localGamer.IsDataAvailable属性,因为只要有一个字节流正在等待本地游戏,这个属性就会为true。 只要数据对你的玩家可用,下面的代码就会接收一个新PacketReader并读取发送数据的玩家的GamerTag属性。然后,玩家程序运行的分钟数和秒数就会从PacketReader中读取。 while (localGamer.IsDataAvailable) ...{ NetworkGamer sender; localGamer.ReceiveData(reader, out sender); string gamerTime = ""; gamerTime += sender.Gamertag + ": "; gamerTime += reader.ReadInt32() + "m "; gamerTime += reader.ReadInt32() + "s"; gamerTimes[sender.Gamertag] = gamerTime; } 要让这个例子实际干点事情,数据被转换到一个叫做gamerTime的字符串中,它存储在一个Dictionary。Dictionary是默认的generic .NET查询表,可以使用以下代码创建: Dictionary<string, string> gamerTimes = new Dictionary<string, string>(); 前面的代码会在Dictionary中创建一个数据项,对应发送给你数据的玩家。当从一个玩家接收新数据时,Dictionary中的对应数据项会被更新,你可以在Draw方法中将Dictionary中的字符串显示在屏幕上。 当玩家离开会话时,你需要将它们对应的数据项从Dictionary移除,这可以在GamerLeft 事件中加以处理: void GamerLeftEventHandler(object sender, GamerLeftEventArgs e) ...{ log.Add(e.Gamer.Gamertag + " left the current session"); gamerTimes.Remove(e.Gamer.Gamertag); } SendDataOptions 当你将数据发送到会话中的其他玩家时,你期望到达接受者的信息的顺序与发送的顺序是相同的,但是基于Internet的原理,你的信息可能会以不同于发送的顺序到达,甚至更糟,有些数据可能根本就传不到! 幸运的是,你可以为发送的数据包指定两个重要的系数,在使用前你需要知道它们是什么,好处是什么,更重要的是,它们的缺点是什么: 到达的顺序(Order of arrival):数据包接收的顺序是否与发送的顺序相同? 安全性(Reliability):你发送的这个数据是否是至关紧要的,如果数据包丢失游戏还能进行吗? 以上两个问题不是是就是否,可以提供四种可能性。LocalNetworkGamer.SendData可以将SendDataOptions作为第二个参数,这个参数可以让你指定四种情况中的一个: SendDataOptions.None:发送的数据不是关键的,你接收数据的顺序也无关紧要。 SendDataOptions.InOrder:接收数据的顺序必须和发送它们的顺序相同,但一些数据包丢失无大碍。 SendDataOptions.Reliable:与SendDatOptions.InOrder相反,你的数据是关键的,你发送的所有数据必须到达接收者。但是,接收数据的顺序是否和发送的顺序相同无关紧要。 SendDataOptions.ReliableInOrder:所有的数据必须以和发送顺序相同的顺序到达接收者。 不是很难,我选择最后一个!有些选项有点缺点,解释如下: SendDataOptions.None:没有速度损失,只能指望数据能够成功发送。 SendDataOptions.InOrder:在数据发送前,所有的数据包被分配了一个序号。接收者检查这个序号,如果数据包A在一个更加新的数据包B之后被接收,数据包A会被抛弃。这是个简单的检查方法,几乎不花时间,但即使有些数据成功到达了目的地可能也会被抛弃。 SendDataOptions.Reliable:接收者会检查丢失了哪个数据包。当数据包C从数据包流ABDE中丢失时,接收者会要求发送者重新发送数据包C,同时,数据包D和E在XNA代码中可以被访问。 SendDataOptions.ReliableInOrder:只有在你的数据需要时才使用这个选项。当接收者法线数据包C从流ABDE中丢失时,它会让发送者重新发送数据包C。这次,其后的数据包D和E不会被接收者传递到XNA中,直到数据包C也被成功的传递。这会引起延迟,因为所有后继的数据包会保存在内存中直至数据包C被重新发送并收到才会被传递到XNA程序中。 普遍的原则是,对大多数数据来说SendDataOptions.InOrder是安全的,尽可能不要使用 SendDataOption.ReliableInOrder。 SendDataOption.Chat 在你开始发送数据前,你需要记住一件事情:在Internet上发送的聊天信息(chat message)不可以被加密,法律上是禁止的。 因为默认情况下使用localGamer.SendData方法发送数据都会进行加密,你必须使用SendDataOptions.Chat表示XNA不要加密聊天信息。你也可以使用SendDataOption的组合,如下所示: localGamer.SendData(write,SengDataOptions.Chat|SendDataOptions.Reliable); 注意你可以发送加密和不加密混合的信息。如果你这样干,排序过的聊天信息只根据聊天数据排序而不是根据加密过的数据。例如,让我们看一下发送第一条信息时的情况,依次是数据信息、聊天信息、数据信息,如图8-1左图所示。 图8-1 顺序发送4个数据包(左图)和4种接收数据的方式 图8-1的右图显示了数据如何到达接收端的4中可能方式。在a情况中,数据到达的顺序与发送的顺序一样。在情况b和c中,数据包的顺序发生了改变,但是,这两种情况中第一个聊天数据包A在在第二个聊天数据包C之前被接收,第一个数据包B在第二个数据包D之前。 因为两个数据包和两个聊天数据包可以在一帧中被发送,你需要确保在接收端将它们混合起来,一个方法是在发送数据包前给它们添加一个小说明,表示它们是数据包还是聊天数据包,看一下下面的代码,其中D表示一个数据包,C表示一个聊天数据包: writer.Write("D"); writer.Write(gameTime.TotalGameTime.Minutes); writer.Write(gameTime.TotalGameTime.Seconds); LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.SendData(writer, SendDataOptions.ReliableInOrder); writer.Write("C"); writer.Write("This is a chat message from " + localGamer.Gamertag); localGamer.SendData(writer, SendDataOptions.Chat|SendDataOptions.ReliableInOrder); 在接收端,只是简单地检查数据包是D还是C,并处理对应的数据包: while (localGamer.IsDataAvailable) ...{ NetworkGamer sender; localGamer.ReceiveData(reader, out sender); string messageType = reader.ReadString(); if (messageType == "D") ... { string gamerTime = ""; gamerTime += sender.Gamertag + ": "; gamerTime += reader.ReadInt32() + "m "; gamerTime += reader.ReadInt32() + "s"; gamerTimes[sender.Gamertag] = gamerTime; } else if (messageType == "C") ... { lastChatMessage[sender.Gamertag] = reader.ReadString(); } } 多个本地玩家 如果多个玩家连接在同一个机器上,你需要通过迭代器发送和接受数据。 将数据发送到所有玩家很简单: //send data from all local players to all other players in session foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers) ...{ writer.Write(gameTime.TotalGameTime.Minutes); writer.Write(gameTime.TotalGameTime.Seconds); localGamer.SendData(writer, SendDataOptions.ReliableInOrder); } 记住你可以使用SendData的一个重载方法将数据只发送到一个指定玩家。 接收数据也不难,只需循环代码直到所有本地玩家的IsDataAvailable为false: foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers) ...{ while (localGamer.IsDataAvailable) ...{ NetworkGamer sender; localGamer.ReceiveData(reader, out sender); string gamerTime = localGamer.Gamertag + " received from "; gamerTime += sender.Gamertag + ": "; gamerTime += reader.ReadInt32() + "m "; gamerTime += reader.ReadInt32() + "s"; gamerTimes[sender.Gamertag] = gamerTime; } } 代码 下面是Update方法的代码,包括扩展过的InSession状态,你的机器上的所有玩家将数据发送到会话中的所有玩家。然后,他们会接收发送给他们的数据。 如果你在多个机器上运行这个代码,他们会自动连接到第一个机器创建的会话上。然后,开始发送时间信息并在Draw方法中将接受到的数据显示在屏幕上。 protected override void Update(GameTime gameTime) ...{ if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); if (this.IsActive) ...{ switch (currentGameState) ...{ case GameState.SignIn: ... { if (Gamer.SignedInGamers.Count < 1) ... { Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); } else ...{ currentGameState = GameState.SearchSession; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession"); } } break; case GameState.SearchSession: ... { AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, null); if (activeSessions.Count == 0) ... { currentGameState = GameState.CreateSession; log.Add("No active sessions found - proceed to CreateSession"); } else ... { AvailableNetworkSession networkToJoin = activeSessions[0]; networkSession = NetworkSession.Join(networkToJoin); string myString = "Joined session hosted by " + networkToJoin.HostGamertag; myString += " with " + networkToJoin.CurrentGamerCount.ToString() + " players"; myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots."; log.Add(myString); HookSessionEvents(); currentGameState = GameState.InSession; } } break; case GameState.CreateSession: ... { networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16); networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; log.Add("New session created"); HookSessionEvents(); currentGameState = GameState.InSession; } break; case GameState.InSession: ... { //send data from all local players to all other players in session foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers) ...{ writer.Write(gameTime.TotalGameTime.Minutes); writer.Write(gameTime.TotalGameTime.Seconds); localGamer.SendData(writer, SendDataOptions.ReliableInOrder); } //receive data from all other players in session foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers) ...{ while (localGamer.IsDataAvailable) ... { NetworkGamer sender; localGamer.ReceiveData(reader, out sender); string gamerTime = localGamer.Gamertag + " received from "; gamerTime += sender.Gamertag + ": "; gamerTime += reader.ReadInt32() + "m "; gamerTime += reader.ReadInt32() + "s"; gamerTimes[sender.Gamertag] = gamerTime; } } networkSession.Update(); } break; } base.Update(gameTime); } }

网络——加入一个网络会话

clock 三月 7, 2011 13:25 by author alex
问题 你想搜索你的机器、网络或Live服务找到活动会话,你想从检测到的会话列表中选择一个会话并加入其中。 解决方案 你可以使用NetworkSessions.Find方法搜索可用的会话,这会返回一个包含所有可用会话的AvailableNetworkSessionCollection对象。你可以使用 NetworkSession.Join方法加入其中的一个会话,将指定的会话作为一个参数。 当连接到一个会话时,如前一个教程所述,你应确保监听由这个会话引发的事件。 工作原理 在这个教程中,你将在前一个教程定义的GameState枚举中添加一个SearchSession状态: public enum GameState ...{ SignIn, SearchSession, CreateSession, InSession} 程序从SignIn状态开始,允许用户选择一个账号。然后,不是立即创建一个新的网络会话,而是首先找到并加入一个已存在的会话并进入InSession状态。如果没有找到可用的会话,你将进入CreateSession状态,这个已经在前一个教程中解释过了。 搜索可用会话 你可以使用NetworkSession.Find方法搜索所有可用的会话,这个方法需要三个参数,让你可以过滤结果。这个方法只返回与第一个参数同种类型并且与一台机器上允许玩家数量相同的会话。你可以在本教程最后找到最后一个参数的例子。 AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, null); 注意:和NetworkSession.Create方法一样,如果机器没有连接到网络,NetworkSession. Find方法会报错。所以,你需要将它封装在一个try-catch结构中。 所有检测到的会话存储在一个AvailableNetworkSessionsCollection中,如果没有找到会话,这个集合为空。 加入一个活动的会话 如果检测到至少一个会话,你可以使用NetworkSession. Join方法加入它: AvailableNetworkSession networkToJoin = activeSessions[0]; networkSession = NetworkSession.Join(networkToJoin); 这会将程序连接到第一个参数指定的会话,本例中,你加入的是activeSessions中的第一个会话。在进行这个操作之前,你可能还想检查是否至少有一个玩家slot,这是由networkToJoin.OpenPublicPlayerSlots表示的。 注意:一个AvailableNetworkSession包含许多有用的信息,其中QualityOfService属性可以用来判断你的机器和会话主机间的连接速度。 最后的SearchSession 状态如下所示: case GameState.SearchSession: ...{ AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, null); if (activeSessions.Count == 0) ...{ currentGameState = GameState.CreateSession; log.Add("No active sessions found - proceed to CreateSession"); AvailableNetworkSession networkToJoin = activeSessions[0]; networkSession = NetworkSession.Join(networkToJoin); string myString = "Joined session hosted by " + networkToJoin.HostGamertag; myString += " with " + networkToJoin.CurrentGamerCount.ToString() + " players"; myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots."; log.Add(myString); HookSessionEvents(); currentGameState = GameState.InSession; } } break; 首先找到指定三个过滤参数的所有活动会话,如果没有检测到会话,则进入CreateSession状态创建你自己的会话,可参见前一个教程。如果找到至少一个会话,加入找到的第一个会话。更高级的例子会将所有会话显示在屏幕上让用户可以加以选择。 然后,将连接的会话属性显示在屏幕上,诸如主机名称,加入这个会话的玩家数量,打开的玩家slots的数量。 最后在进入InSession状态前开始监听由会话引发的任何事件(可参见教程8-2) 。 使用NetworkSessionProperties过滤检测到的会话 当使用NetworkSession.Find方法搜索活动会话时,你可以指定三个参数过滤检测到的会话。 前面已经提及,前两个参数让你可以指定会话的种类和会话允许的最多玩家数量。 但是,当游戏变得非常流行时,Find方法会返回很大数量的活动会话。所以,当你创建一个会话时,指定一个NetworkSessionProperties对象是很有用的,这个对象最多可以包含八个整数值。这八个整数代表什么完全取决于你。例如,你可以让第一个整数表示游戏的难度设置,第二个代表bots的数量,第三个代表游戏正在处于的地图,本例中设置为null,因为你还没有设置这个对象。 当另外一个玩家搜索活动会话时,他们可以搜索对应的游戏,例如,在一张指定的地图上进行游戏。 根据NetworkSessionProperties创建一个会话 要指定一个NetworkSessionProperties,你需要使用NetworkSession.Create的第二个重载方法,它接受另外两个参数,让你可以将属性作为最后一个参数。创建了一个新NetworkSessionProperties对象后,你可以设定8个整数值。在下面的例子中,只设置两个值。之后,将属性作为最后一个参数传递到NetworkSession.Create方法中: NetworkSessionProperties createProperties = new NetworkSessionProperties(); createProperties[0] = 3; createProperties[1] = 4096; networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16, 0, createProperties); 使用指定的NetworkSessionProperties搜索会话 你可以使用指定的属性搜索会话。只需简单地创建一个NetworkSessionProperties对象,指定你要查找的属性,将它传递到NetworkSession.Find方法中: NetworkSessionProperties findProperties = new NetworkSessionProperties(); findProperties[1] = 4096; AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, findProperties); 本例中,只使用一个值而不是两个。Find方法会返回所有拥有第二个属性中4096的方法,而不管其他七个属性是什么。 代码 下面的Update方法显示了基本的会话管理。 当程序开始时,用户要求使用一个账号登录,然后,程序搜索活动会话,如果找到至少一个,则加入这个会话。 如果没有找到活动会话,程序会创建自己的会话。无论通过何种方式,你都会连接到一个会话,程序需要监听会话引发的事件。你结束于InSession状态,这个状态中会以一定的时间间隔更新会话。 同样的代码可以运行在多台机器上,如果这些机器连接到同一个网络,第一个机器会创建会话,其他机器会检测并自动加入这个会话。 protected override void Update(GameTime gameTime) ...{ if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); if (this.IsActive) ... { switch (currentGameState) ... { case GameState.SignIn: ... { if (Gamer.SignedInGamers.Count < 1) ... { Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); } else ... { currentGameState = GameState.SearchSession; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession"); } } break; case GameState.SearchSession: ...{ NetworkSessionProperties findProperties = new NetworkSessionProperties(); findProperties[0] = 3; findProperties[1] = 4096; AvailableNetworkSessionCollection activeSessions NetworkSession.Find(NetworkSessionType.SystemLink,4, findProperties); if (activeSessions.Count == 0) ... { currentGameState = GameState.CreateSession; log.Add("No active sessions found - proceed to CreateSession"); } else ... { AvailableNetworkSession networkToJoin = activeSessions[0]; networkSession = NetworkSession.Join(networkToJoin); string myString = "Joined session hosted by " + networkToJoin.HostGamertag; myString += " with " + networkToJoin.CurrentGamerCount.ToString() + " players"; myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots."; log.Add(myString); HookSessionEvents(); currentGameState = GameState.InSession; } } break; case GameState.CreateSession: ...{ NetworkSessionProperties createProperties = new NetworkSessionProperties(); createProperties[0] = 3; createProperties[1] = 4096; networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16, 0, createProperties); networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; log.Add("New session created"); HookSessionEvents(); currentGameState = GameState.InSession; } break; case GameState.InSession: ... { networkSession.Update(); } break; } } base.Update(gameTime); }

网络——创建一个网络会话

clock 三月 7, 2011 13:22 by author alex
问题 你想创建一个网络会话(session),这样其他Xbox 360平台或PC就可以找到并加入到你的会话。 解决方案 一台机器首先需要开始一个网络会话,这可以通过使用NetworkSession. Create方法很容易地做到。创建会话的机器就是会话主机。 在创建会话之后,所有连接到这个会话的机器,包括主机,都会监听这个会话产生的任何事件,诸如玩家加入或离开会话等。 工作原理 你需要包含Microsoft.XNA.Framework.Net命名空间,这可以通过在代码顶部添加using代码做到: using Microsoft.XNA.Framework.Net 本教程中,程序会在三个自定义状态中循环。如前一个教程所述,开始时你处于SignIn状态,要求玩家选择一个账号。前一个教程中的SignedIn状态由CreateSession状态替换, CreateSession状态中你将创建一个新的网络会话并监听它的事件。最后结束于InSession状态。 首先定义这些状态: public enum GameState ...{ SignIn, CreateSession, InSession} SignIn状态前一个教程已经讨论过了。让我们开始编写CreateSession状态的代码。 创建一个网络会话 在这个状态中,你将创建一个新的网络会话。首先将这个变量添加到代码中: NetworkSession networkSession; 你可以创建一个新的网络会话并使用NetworkSession . Create方法将它存储到刚才定义的变量中: networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4,16); 这行代码会让机器创建一个其他玩家可以连接的新会话。如你所见,这个方法需要三个参数。 第一个参数指定会话类型。如果所有的玩家连接到同一台机器上应该创建NetworkSessionType. Local类型的会话,例如,四个玩家都有各自的手柄连接到同一台Xbox 360上。 如果一个或多个玩家在不同的机器上,这些机器连接到相同的网络,你应该创建一个NetworkSessionType. SystemLink类型的会话。NetworkSessionType. PlayerMatch类型的会话与SystemLink类型工作方式类似,但是它允许玩家通过Internet连接到会话,使用的是Live的服务,这种情况下,为了使用这些服务,所有参与的玩家都要需要购买Live Gold成员。 注意:因为Zune只能创建连接到另一台Zune的网络连接,在Zune上只能使用NetworkSessionType. Local和NetworkSessionType. SystemLink类型的会话。 第二个参数指定连接到同一个机器的多个玩家是否可以加入网络。如果不是,你需要指定每台机器只有一个玩家。例如,如果你想开始一个本地会话,你应该允许超过一个玩家可以连接到相同的机器。 注意:一个本地玩家指在代码运行的这台机器上的用户。XNA 3.0在每台Windows机器或Zune上只支持一个本地玩家,在Xbox 360允许最多四个玩家。只有另三个玩家都连接到同样的机器上时四个玩家中的每一个才被认为是本地的。 最后一个参数指定可以连接到这个会话的最大玩家数。因为你想处理多玩家游戏,这个数字至少为2,上限是31。 NetworkSession.Create另一个重载方法可以再接受两个参数。第一个参数指定保留给朋友的玩家插槽数量,通过这个方式,只有你标记为朋友的玩家才可以连接到会话。第二个参数让你可以用一个NetworkSession属性作为会话的标签,这样其他玩家可以很容易地找到它。可参见教程3-8看这样一个例子。 注意:如果机器没有连接到网络上,NetworkSession. Create方法会报错,所以你将它封装在一个try-catch结构中。 现在有了一个新会话,你可以设置一些属性了。一个重要的属性定义了如果主机退出的话应该发生什么事。你可以通过将AllowHostMigration属性设置为true指定XNA自动选择clients/peers中的一个变成新的主机: networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; 如你所见,你也可以指定游戏开始后哪个客户端可被允许加入到会话中。 注意:要特别注意主机的迁移。如果当会话退出时游戏数据只保存在老的主机中,不可能将这个数据自动传送到新主机中。你需将重要的数据复制到至少两台机器上,这样的话,如果主机退出,你可以将这个数据复制到新主机上。 链接到会话事件 现在你的机器是一个网络会话的主机,然后需要知道其他玩家是否连接到这个会话。你可以通过链接一个自定义的方法到网络会话的GamerJoined 事件中做到这点。只要一个玩家加入会话,会话就会自动引发GamerJoined事件。结果是,链接到这个事件的所有方法都会被调用。 下面这行代码将自定义的GamerJoinedEventHandler方法连接到GamerJoined事件: networkSession.GamerJoined += GamerJoinedEventHandler; 在GamerJoinedEventHandler方法中,你可以放置当一个新玩家加入到会话中时需要执行的代码。下面的例子中将一行包含玩家名称的文字显示在屏幕上: void GamerJoinedEventHandler(object sender, GamerJoinedEventArgs e) ...{ log.Add(e.Gamer.Gamertag + " joined the current session"); } 和所有事件处理方法一样,这个方法接受引发事件的对象(本例中是网络会话),还有第二个参数包含事件类型的特定信息。本例中,GamerJoinedEventArgs包含对应加入这个会话的玩家的Gamer对象。 网络会话可以引发你想要监听的其他事件,包括GamerLeft事件、GameStarted事件、GameEnded事件(这个事件表示会话从大厅切换到游戏模式,可参见教程8-7)、SessionEnded事件和HostChanged事件。 在AllowHostMigration设置为false且主机退出会话时或主机在网络会话上调用Dispose方法时都会引发SessionEnded事件。在AllowHostMigration设置为true且主机退出会话时会引发HostChanged事件。 下面的代码通过网络会话监听GamerJoined,GamerLeft和HostChanged事件,并在屏幕上显示对应的信息: void HookSessionEvents() ...{ log.Add("Listening for session events"); networkSession.GamerJoined += GamerJoinedEventHandler; networkSession.GamerLeft += GamerLeftEventHandler; networkSession.HostChanged += HostChangedEventHandler; } void GamerJoinedEventHandler(object sender, GamerJoinedEventArgs e) ...{ log.Add(e.Gamer.Gamertag + " joined the current session"); } void GamerLeftEventHandler(object sender, GamerLeftEventArgs e) ...{ log.Add(e.Gamer.Gamertag + " left the current session"); } void HostChangedEventHandler(object sender, HostChangedEventArgs e) ...{ log.Add("Host migration detected"); NetworkSession eventRaisingSession = (NetworkSession)sender; if (eventRaisingSession.IsHost) log.Add("This machine has become the new Host!"); } 在主机迁移的情况中,sender对象(这里是指一个网络会话)首先转换为一个NetworkSession对象,这样你可以获取它的属性。你可以使用其中的IsHost属性检测是否这个机器变成了会话的新主机。 确保在创建会话后立即调用HookSessionEvents方法,下面是Update方法中的CreateSession状态的代码: case GameState.CreateSession: ...{ networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16); networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; log.Add("New session created"); HookSessionEvents(); currentGameState = GameState.InSession; } 会话被创建,设置AllowHostMigration和AllowJoinInProgress的值,程序会监听任何由会话引发的事件。 更新网络会话 连接到一个会话后,你需要以一定的时间间隔更新它。显然应该在游戏的更新过程中放置这个代码。现在,在InSession 状态中只需进行一个操作: case GameState.InSession: ...{ networkSession.Update(); } break; 代码 这里是最终的Update方法。程序开始时处于SignIn状态,之后在CreateSession状态中创建一个会话。程序结束时处于InSession状态。 protected override void Update(GameTime gameTime) ...{ if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); if (this.IsActive) ... { switch (currentGameState) ... { case GameState.SignIn: ... { if (Gamer.SignedInGamers.Count < 1) ... { Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); } else ... { currentGameState = GameState.CreateSession; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to CreateSession"); } } break; case GameState.CreateSession: ... { networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16); networkSession.AllowHostMigration = true; networkSession.AllowJoinInProgress = false; log.Add("New session created"); HookSessionEvents(); currentGameState = GameState.InSession; } break; case GameState.InSession: ... { networkSession.Update(); } break; } } base.Update(gameTime); }

网络——登录网络服务

clock 三月 6, 2011 08:56 by author alex
问题 在你可以连接另一个玩家前,首先需要使用一个账号登录或创建一个新的账号。如果所有玩家都在相同的网络,这可以是一个离线账号,或者当你想通过Internet连接时也可以是一个在线Live账号。 在你可以访问XNA的网络功能前必须登录,它也允许其他玩家看到你的姓名和你可能提供其他信息。 注意:登录在Zune不需要。在Zune上的XNA游戏由一个SignedInPlayer开始,这个SignedInPlayer拥有你分配给Zune设备的名称。 解决方案 使用你的账号登录非常简单。你要做的就是调用Guide.ShowSignIn方法,这个方法允许用户选择一个已存在的账号或创建一个新的。 工作原理 在你可以使用XNA的网络功能前,你必须在游戏中添加GamerServicesComponent。 添加GamerServicesComponent 这个GameComponent (可参见教程1-7)保证整个网络引擎在背后以不变的时间间隔进行更新。对任何一个GameComponent,都要求在Game构造函数中加载: public Game1() ...{ graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; Components.Add(new GamerServicesComponent(this)); } 显示登录界面 简单的程序会在定义的两个状态中切换:SignIn和SignedIn。只要程序在SignIn状态中,登录界面就会显示给用户直到用户选择或创建一个账号。一旦选择了正确的账号,程序就会移动到SignedIn状态。 首先声明两个状态: public enum GameState ...{ SignIn, SignedIn } 然后声明一个变量保存当前状态: GameState currentGameState = GameState.SignIn; 这会让程序开始时处于SignIn状态。在更新循环中,你将检测程序位于哪个状态。如果处于SignIn状态,你想让用户选择一个账号。但是,因为用户可以选择一个账号自动登录,可能在开始GamerServicesComponent时一个账号已经登录。所以,你想检查当前登录是否还没有账号: if (Gamer.SignedInGamers.Count < 1) ...{ Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); } else ...{ currentGameState = GameState.SignedIn; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SignedIn"); } 如果没有账号被记录,你激活登录界面,让用户可以选择或创建一个账号。一旦选择了一个账号,Gamer.SignedInGamers.Count不为0。在下一个更新过程中,当前状态会被设置为SignedIn,包含玩家名称的信息会被添加到日志中。 注意:日志的内容会在Draw方法中输出到屏幕上,可参见教程3-5学习如何将文字显示在屏幕上。 Guide.ShowSignIn方法需要两个参数。第一个参数显示可以登录多少个玩家。在Xbox 360平台上,同时最多可有4个用户,在Windows平台上被限制为只有1个。第二个参数指定是否只允许在线Live账户登录,这在使用Live服务时很有用。 注意:记住用户可以取消Guide界面,这会导致没有账号被选择。 因为同一时间无法打开Guide界面两次,在调用Guide.ShowSignIn方法前你必须检查当前Guide是否已关闭,这可以通过检查Game的IsActive属性做到,当游戏窗口是active状态,没有被最小化,Guide没有显示时这个属性才为true。 代码 整个Update过程如下所示。先检查IsActive属性,之后如果还没有用账号登录就调用Guide.SignIn方法,一旦登录为正确的账号, 状态变为SignedIn。 protected override void Update(GameTime gameTime) ...{ if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); if (this.IsActive) ... { switch (currentGameState) ... { case GameState.SignIn: ... { if (Gamer.SignedInGamers.Count < 1) ... { Guide.ShowSignIn(1, false); log.Add("Opened User SignIn Interface"); } else ... { currentGameState = GameState.SignedIn; log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SignedIn"); } } break; case GameState.SignedIn: ... { } break; } } base.Update(gameTime); }

在XNA 3.0 项目添加声音——根据相机位置从一个3D位置播放声音:3D声音

clock 三月 3, 2011 10:59 by author alex
问题 要让你的3D游戏更真实,你想让每个声音都位于3D空间的某个位置。通过这种方式,在相机右侧发生的爆炸会主要通过右声道播放,这样用户就会感到爆炸是真的发生在他的右侧。即使爆炸不在相机的视野中,玩家也可以知道在他的右侧发生了什么事。 注意:Zune不支持Xact和下面的Apply3D()方法,只支持Cue对象,3D音效只被PC和Xbox 360支持。 解决方案 XNA可以让这些事变得简单。对每个声音,你必须设置相机的3D位置和使用声音Cue对象的Apply3D方法设置声源的位置。注意当相机或声源移动时,你需要调用这个方法更新Cue。工作原理如果你想设置声源的3D位置,首先必须为这个音源设置一个AudioEmitter对象和为相机设置一个AudioListener对象。当将这两者传递到Cue的Apply3D方法中时,XNA会计算音响(或耳机)左右声道声音的分配。 下面的方法创建了两个对象: private void UpdateSoundPosition(Cue cue, Vector3 sourcePos, Vector3 camPos, Vector3 camForward, Vector3 camUp) ...{ AudioEmitter emitter = new AudioEmitter(); emitter.Position = sourcePos; AudioListener listener = new AudioListener(); listener.Position = camPos; listener.Forward = camForward; listener.Up = camUp; cue.Apply3D(listener, emitter); } 对emitter(音源)来说,你只需设置位置。对listener(相机)来说,你还要设置Forward和Up向量,因为当相机旋转了180度后,左右声道会互换。 当创建了这两个变量后,你可以将它们传递到Cue的Apply3D方法中,当音源或相机发生移动时都要进行这个操作。 你需要在第一次调用Play方法前调用Apply3D方法。所以,确保在Cue中还没有调用 Play方法: protected override void Initialize() ...{ fpsCam = new QuakeCamera(GraphicsDevice.Viewport, new Vector3(0,0,5) , 0, 0); audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs"); waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb"); soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb"); cue1 = soundBank.GetCue("audio1"); base.Initialize(); } 在Update方法中,如果你移动了相机位置或音源位置,就需要调用UpdateSoundPosition方法: protected override void Update(GameTime gameTime) ...{ GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); MouseState mouseState = Mouse.GetState(); KeyboardState keyState = Keyboard.GetState(); fpsCam.Update(mouseState, keyState, gamePadState); float time = (float)gameTime.TotalGameTime.TotalMilliseconds/1000.0f; Vector3 startingPos = new Vector3(0, 0, -10); Matrix rotMatrix = Matrix.CreateRotationY(time); modelPos = Vector3.Transform(startingPos, rotMatrix); UpdateSoundPosition(cue1, modelPos, fpsCam.Position, fpsCam.Forward, fpsCam.UpVector); if (cue1.IsPrepared) cue1.Play(); audioEngine.Update(); base.Update(gameTime); } 这个方法的前半部分检查用户输入并将输入传递到相机,解释请见教程2-3。当中的代码块更新音源位置。当相机和音源更新后,你需要调用UpdateSoundPosition方法告知cue1对象最后的位置。如果cue1还没有播放,则开始播放。 当心:因为3D effect是通过控制左右声道的音量实现的,所以声音中的任何立体声信息会丢失:首先将声音转换为单声道,然后对应物体的3D位置设置左右声道的音量。 代码 所有的Initialize,UpdateSoundPosition和Update方法前面已经写过了。 扩展阅读 Xact音频工具让你可以在cue中添加一些很棒的3D音效,诸如距离衰减和多普勒效应。因为是图形界面,在视频中介绍要比在书中好得多。在http://creators.xna.com 网站上有一些很好的视频介绍如何将3D音效添加到cue中。 译者注:在http://creators.xna.com/en-US/sample/3daudio网页上也有一个3D音效的例子,实现了距离衰减,多普勒效应。

在XNA 3.0 项目添加声音——循环播放声音

clock 三月 3, 2011 10:56 by author alex
问题 你想让声音循环播放,例如,播放背景音乐或播放连续的声音,例如一个汽车引擎的声音。 注意:因为Zune不支持Xact,你需要使用SoundEffect.Play()的重载方法循环播放声音,可见教程7-1中的对应解释。 解决方案 使用XAct audio tool,可以很容易地表示一个声音是否要循环播放。你将在XNA代码中创建一个Cue对象,因为你需要能够在播放过程中进行暂停或停止操作。 你还可以检测一个声音是否已经完成播放,这在你想切换背景音乐时很重要,这个操作可以通过检查Cue的IsStopped属性做到。 工作原理 循环播放一个声音你可以通过在Xact工具中将一个声音的LoopEvent属性设为Infinite让它循环播放。要做到这点,打开你的Xact项目,在sound bank中选择需要循环播放的sound。选择了sound后,在Sound Bank面板的右上方的Play Wave会变得可见,点击Play Wave节点,如图7-2所示。 在点击了Play Wave节点后,它的属性在XAct 窗口的左下方的属性窗口中可见,找到LoopEvent属性,设置为Infinite,别忘了保存Xact项目。 图7-2 当选择了sound后Play Wave节点会变得可见 现在当你重新编译XNA项目并使用前面的代码播放cue时,它就会无限循环播放。因为你想进行控制,可以在适当的时候停止播放这个cue,所以需要创建一个Cue对象储存对这个cue的引用: Cue cue1; 通过调用soundBank变量的GetCue方法对这个变量赋值,你可以通过调用Play方法播放这个cue: cue1 = soundBank.GetCue("audio1"); cue1.Play(); 这会让cue循环播放,这是你在XAct audio tool中设置号好的。但这次,你有了指向cue的引用,所以可以暂停、继续或停止这个cue: cue1.Pause(); cue1.Resume(); cue1.Stop(AudioStopOptions.Immediate); 当心:当你从播放中停止一个cue后,你无法简单地在这个cue再次调用Play。你首先需要调用它的GetCue 方法才能从soundBank中再次调用它。 检查一个Sound是否已经停止播放/改变背景音乐 你可以通过sound的IsStopped属性检查一个sound cue是否已经完成播放: if (currentCue.IsStopped) //do something 如果你想让XNA程序循环播放一些背景音乐,你需要创建一个数组保存cue的名称而不是cue本身,当cue开始播放后cue本身会变得无用。你需要一些额外的变量创建一个背景循环系统: string[] bgCueNames; Cue currentCue; int currentCueNr = 0; 这个数组保存要播放的背景cue的名称,currentCue保存当前正在播放的cue,这样你可以检查它是否已经结束,currentCueNr变量用于激活下一个cue。 下面的方法初始化cun名称数组并开始第一个cue: private void InitSounds() ...{ audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs"); waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb"); soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb"); bgCueNames = new string[5]; bgCueNames[0] = "bgAudio1"; bgCueNames[1] = "bgAudio2"; bgCueNames[2] = "bgAudio3"; bgCueNames[3] = "bgAudio4"; bgCueNames[4] = "bgAudio5"; PlayBGCue(0); } 首先你需要创建一个教程7-1所示的Xact项目,包含5个cue。开始一个cue的PlayBGCue方法是简单的: private void PlayBGCue(int cueNr) ...{ currentCue = soundBank.GetCue(bgCueNames[cueNr]); currentCue.Play(); } 它在currentCue变量中存储了当前正在播放的cue的引用,所以你可以在UpdateSounds方法中检查它的IsPlayed属性,而UpdateSounds方法放在了主Update方法中: private void UpdateSounds() ...{ if (currentCue.IsStopped) ...{ if (++currentCueNr == bgCueNames.Length) currentCueNr = 0; PlayBGCue(currentCueNr); } audioEngine.Update(); } 如果当前cue已经完成播放,则增加currentCueNr,如果它大于播放的cue数量(本例中为5)则设为0。最后调用PlayBGCue播放下一个sound。 注意:在currentCueNr之前的 ++ 表示你想在求值前增加currentCueNr的值。如果写成currentCueNr++,当currentCueNr为4时判断结果为false,之后当值为5时,会抛出 a GetCue方法的OutOfRange错误。通过写成++currentCueNr,如果currentCueNr为4,这个值首先被增加到5,这样判断结果为true,这个值会被设置为0。 通过在这个方法中放置audioEngine的Update方法,在Update调用的所有东西就是这个UpdateSounds方法。 代码 前面的章节中你可以找到循环播放背景音乐列表的所有代码,下面的代码通过按下空格键开始循环播放本教程第一部分中定义的sound,按Enter键停止: protected override void Update(GameTime gameTime) ...{ GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); KeyboardState keyState = Keyboard.GetState(); if (keyState.IsKeyDown(Keys.Space) || (gamePadState.Buttons.B == ButtonState.Pressed)) ... { if ((cue1 == null) || (cue1.IsStopped)) ... { cue1 = soundBank.GetCue("audio1"); cue1.Play(); } } if (keyState.IsKeyDown(Keys.Enter) || (gamePadState.Buttons.A ButtonState.Pressed)) if (cue1 != null) cue1.Stop(AudioStopOptions.Immediate); audioEngine.Update(); base.Update(gameTime); } 当空格键第一次被按下时,cue1变量为null。只要用户按下了Enter键,cue就会停止。当用户再次按下空格键,cue会重新播放。 再次提醒,在cue停止后你需要重新创建cue1变量(通过调用GetCue方法)。

在XNA 3.0 项目添加声音——通过Xact播放简单的.wav文件

clock 三月 3, 2011 10:53 by author alex
问题 你想在游戏中播放声音。 注意:Zune不支持Xact,所以参见教程7-1和7-2学习如何在Zune上播放声音。 解决方案 通过使用Xact,一个XNA Game Studio 3.0自带的免费工具,你可以创建包含游戏中使用的所有声音的Xact项目。你可以将这个Xact项目导入到XNA项目中,这样就可以使用一行简单的代码播放存储在XAct项目中的声音。 工作原理 首先,确保你已经打开了一个XNA项目并存储到了磁盘中的某处。要更好的组织素材,你应该创建一个新文件夹存储所有的声音素材。在XNA项目的Solution Explorer中,找到Content,右击它,选择Add→New Folder,起一个名称。 现在通过点击Start菜单并点击Programs→Microsoft XNA Game Studio 30→Tools→ Microsoft Cross-Platform Audio Creation Tool (XAct)打开Xact。 当开始Xact时,你要做的第一件事就是通过选择File→New Project开始一个新项目,给你的项目起一个名称,并将它保存到XNA项目的Content\Audio目录中。 现在有了一个新的空项目,在左边的树形目录中,找到Wave Banks,它包含了waves,即音频文件。右击Wave Banks选择New Wave Bank。然后,通过右击Sound Banks选择New Sound Bank创建一个新的sound bank,sound bank包含声音,它们是在XNA项目中实际播放的对象。一个sound指向一个wave,可以在wave上添加一些效果。 现在在窗口的右边应该有两个面板。点击Wave Bank面板,你应该可以在Xact窗口的左下角看到它的属性。找到Name属性,将它从Wave Bank改为myWaveBank。然后,点击Sound Bank面板,将Name属性改为mySoundBank。这些名称可以从XNA项目中访问到。 你可以使用wave bank存储所有. wav文件,所以右击面板的某处选择Insert Wave File(s)。浏览到磁盘上你想在游戏中播放的. wav文件,点击Open。你可以看到它被添加到了wave bank中,显示为红色,因为你还没有在sound bank中创建一个sound 使用这个wave。 技巧:文件的位置是相对于Xact项目存储的。如果你改变了声音文件的位置或Xact项目的位置就会遇到麻烦,所以在将声音文件导入到wave bank前先将它们复制到Content\Audio目录中。 一个wave是磁盘上一个声音文件的引用,一个sound使用一个wave让你可以添加一些效果,诸如改变音量和音高。从一个wave你可以生成多个sound。但是,程序员使用一个cue访问sound。所以对每个sound,在可以通过XNA代码访问前需要创建一个cue。一个cue可以包含多个声音,所以一行代码可以播放多个声音。 所以,在可以从wave bank播放一个wave前,你需要创建一个sound和使用这个wave的cue,这可以通过选择wave并拖动到Sound Bank面板的cue区域做到,如图7-1所示。 当心:确保将sound拖动到Sound Bank面板底部的cue列表中,而不是顶部的sound列表。 wave bank和sound bank是分离的,这样你可以从一个wave文件创建多个sound,通过这种方式,你可以在同一个wave上在不同的sound上添加不同的音效。 图7-1 将你的wave拖动到cue区域 这就是Xact工具的用法,别忘了通过选择File→Save Projec保存项目,并将文件导入到XNA项目中,方法和导入一张图像或对象文件是一样的。你可以将. xap 文件拖动到Content\Audio文件夹上,或在Solution Explorer中右击Audio文件夹并选择Add→Existing Item。 myXactProject. Xap文件会立刻显示在XNA Game Studio 3.0程序的Solution Explorer中。 当编译项目时,Xact项目会被读取并转换为二进制文件,这个二进制文件会被写入到项目的输出文件夹中。对添加到XNA项目的每个. xap Xact项目文件,XNA内容管道会创建一个. xgs二进制文件(与Xact项目的名称相同),为每个wave bank创建一个. xwb二进制文件,为每个sound bank 创建一个. xsb二进制文件。 在XNA项目中,你需要将这三个文件连接到对应的变量中,首先在代码顶部进行声明: AudioEngine audioEngine; WaveBank waveBank; SoundBank soundBank; 然后将它们连接到二进制文件(它们是在你编译项目时创建/更新的),例如,在Initialize方法中: audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs"); waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb"); soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb"); 变量soundBank包含所有可以播放的cue。但是,当你实例化一个新的SoundBank时,需要将它传递到一个AudioEngine,所以首先需要初始化AudioEngine。只要你从soundBank播放一个cue,它就会从AudioEngine对应的waveBank中找到wave,所以你需要第二行代码。 注意:myWaveBank和mySoundBank的名称是在Xact工具中命名的。 现在,当你想播放一个简单的声音时,你要做的就是调用soundBank变量的PlayCue方法,将要播放的cue名称传递到这个方法中! soundBank.PlayCue("audio1"); 当播放结束要确保所有cue从内存中移除,所以要在每一帧都调用 audioEngine的Update方法: audioEngine.Update(); 当心:当你改变了Xact项目并存储到文件后,你无法简单地运行XNA项目的. exe指望它将这个改变施加到Xact项目中。首先需要重新编译(按F5)XNA项目使内容管道从Xact项目中创建新的二进制文件。 代码 你需要通过全局变量播放包含在Xact项目中的声音cue: AudioEngine audioEngine; WaveBank waveBank; SoundBank soundBank; 在项目的开始处进行初始化: protected override void Initialize() ...{ audioEngine = new AudioEngine("myXactProject.xgs"); waveBank = new WaveBank(audioEngine, "myWaveBank.xwb"); soundBank = new SoundBank(audioEngine, "mySoundBank.xsb"); base.Initialize(); } 使用soundBank. PlayCue方法播放声音,例如下面代码中在按下一个键时播放声音。确保每一帧调用audioEngine. Update方法: protected override void Update(GameTime gameTime) ...{ if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); KeyboardState keybState = Keyboard.GetState(); if (keybState.IsKeyDown(Keys.Space)) soundBank.PlayCue("audio1"); audioEngine.Update(); base.Update(gameTime); } 现在当运行代码时,当你按下空格键时就会播放声音。

在XNA 3.0 项目添加声音——播放MP3或WMA声音文件

clock 三月 1, 2011 10:15 by author alex
问题 你想在XNA游戏中播放一个MP3或WMA声音文件。 解决方案 XNA可以使用默认内容管道将MP3或WMA声音文件加载到一个Song对象中,加载之后,就可以使用静态的MediaPlayer类播放Song对象了。 工作原理 首先将MP3或WMA文件添加到XNA项目中。这和添加一个图像是一样的,可参见教程3-1。最简单的方法就是将MP3或WMA文件拖动到Content文件夹上。 你需要一个Song对象链接到MP3或WMA文件上,在代码顶部添加这个对象: Song mySong; 在LoadContent方法中使用内容管道加载对象: mySong=Content.Load<Song>(“Sample”); 使用以下简单命令就可以播放文件了: MediaPlayer.Play(mySong); MediaPlayer是一个静态类,一次只能播放一个声音,这对播放背景音乐是很理想的。但是,你需要像教程7-1中类似的方法在游戏中播放另一个声音。 Song对象和MediaPlayer类都有一些有用的属性,诸如歌的长度,当前歌的播放进度等: TimeSpan toGo=mySong.Duration - MediaPlayer.PlayPositon; String myText = “Time remaining for current song:” + toGo.ToString();

在XNA 3.0 项目添加声音——播放并控制简单的.wav声音文件

clock 三月 1, 2011 10:13 by author alex
问题 你想有一个快速播放音效的方法。 解决方案 在XNA3.0中,你可以直接从XNA中加载和播放声音,而无需使用Xact加载它们。虽然这个方法移除了一些Xact的高级功能,但可以让播放音效变得非常容易。 工作原理 首先在项目中添加音效文件,方法与在项目中添加一张图像是一样的,可参见教程3-1。最简单的方法是将一个.wav文件拖动到解决方案的Content文件夹上。 将.wav文件添加到项目中之后,在代码顶部声明一个SoundEffect变量,它将链接到之后的音效文件: SoundEffect myEffect; 在LoadContent方法中,添加以下代码加载一个.wav文件: myEffect=Content.Load<SoundEffect>(“effect”); 当你想播放声音时,可以使用以下代码: myEffect.Play(); SoundEffect.Play()重载方法 SoundEffect.Play()方法有很多重载方法,可以提供更多的控制。最复杂的重载方法有四个参数: 第一个参数volume让你可以控制音量,范围值在0.0f到1.0f之间,1.0f对应设置在SoundEffect.MasterVolumn,这个值也在0.0f和1.0f之间,1.0f对应系统音量(可在Windows系统工具栏的扬声器图标中看到)。 第二个参数pitch是音高(速度),音高的数值范围是-1.0到1.0之间的浮点数,负值表示播放速度变慢,正值变快,0.0表示正常速度。注意这可以用来产生一个声音的不同版本:散弹枪和手枪使用相同的声音效果,但音高不同。 第三个参数pan是声道,输入的数值范围是-1.0到1.0之间的浮点数,-1.0表示只通过左声道播放,1.0表示只通过右声道,正值表示右声道的音量比左声道大。 最后一个参数loop让你可以设定是否循环,输入的是布尔值,1代表true,也就是声音播放完之后会自动再播放一次,而0代表false,即代表着声音播放完之后就不会再播放了,可见下面的章节学习如何防止一个声音循环播放。 控制音效 前面的代码已经足以播放音效了,但XNA还提供了SoundEffectInstance类,你可以使用这个类暂停或停止音效,改变音量等,首先要在代码顶部添加一个变量: SoundEffectInstance myEffectInstance; 当调用SoundEffectInstance的Play方法时,SoundEffect对象会自动转换为一个SoundEffectInstance对象。 现在你可以访问SoundEffectInstance对象,你可以使用这个对象改变音高和音量或者检查它是否已经停止播放: if (myEffectInstance != null) { myEffectInstance.Volume *= 0.9f; if (myEffectInstance.State == SoundState.Stopped) { myEffectInstance.Dispose(); myEffectInstance = null; } } 这个代码会持续地降低音量,当停止播放时会移除对这个对象的引用。 注意:Zune可以同时播放16个SoundEffectInstance,但加载的SoundEffectInstance数量没有限制。Xbox360可以同时播放300个SoundEffectInstance,但一次只能加载300个SoundEffectInstance。在PC上没有限制。但提醒一点,在写本书的时候,SoundEffectInstance.Play方法有一个bug,它会在使用SoundEffectInstance.Resume方法时被忽略。

在场景中添加光线——在Deferred Shading引擎中添加阴影能力

clock 二月 14, 2011 15:02 by author alex
问题 虽然你已经掌握了基本的计算机实时光照,但你应该注意到光源还没有投射出阴影。这是因为pixel shader是基于光线与法线的夹角计算光照的。直到现在,pixel shader还没有考虑到光线与像素间的物体。 阴影映射(shadow mapping)技术在我的网站(http://www. riemers. net)的第三个系列中加以介绍,这个技术可以对一个光源生成正确的阴影,但是你想用deferred渲染的方法实现。 解决方案 在场景中添加阴影的一个极好的方法是阴影映射技术,我已经在我的网站上详细介绍了 (http://www.riemers.net)。 简而言之,阴影映射技术将每个像素与光源之间的真实距离与从光源看起来的距离做比较。如果真实距离大于从光源看来的距离,那么必定有个对象处于相机与像素之间,因此这个像素不被照亮。 要允许这种比较,场景首先应该绘制成从光源看起来的情况,这样像素到光源的距离可以被存储在一张纹理中。 如果你想实现deferred版本的阴影映射技术,必须首先为每个光源生成一张深度贴图。完成这张贴图后,阴影映射的比较就可以施加在deferrded渲染的第二步中。 工作原理 本教程完全建立在前一个教程的基础上。前一个教程的第一步保持不变,因为你仍需要屏幕上所有像素的颜色,法线和深度值。 生成阴影贴图 XNA代码 在第二步中获取一个光源的光照值之前,你需要为这个光源生成阴影贴图。这个阴影贴图包含场景和光源间的距离。你还需要添加一个渲染目标和两个变量:一个存储距离,一个黑色图像用来重置另一个: RenderTarget2D shadowTarget; Texture2D shadowMap; Texture2D blackImage; 在LoadContent方法中初始化渲染目标和黑色图像: shadowTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Single); blackImage = new Texture2D(device, width, height, 1, TextureUsage.None, SurfaceFormat.Color); 然后找到GenerateShadingMap方法。这个方法使用alpha混合将所有光源的光照值混合在一起添加到一张纹理中。这一步不需要,因为你需要在两个混合操作之间生成一张新的阴影贴图,这会导致阴影贴图混合到shading贴图中。 不用alpha混合,添加完每个光源的光照值后你将保存shading贴图。但首先要使用黑色图像删除shading贴图: private Texture2D GenerateShadingMap() { shadingMap = blackImage; for (int i = 0; i < NumberOfLights; i++) { RenderShadowMap(spotLights[i]); AddLight(spotLights[i]); } return shadingTarget.GetTexture(); } 对每个光源首先调用RenderShadowMap方法,这个方法会在shadowMap变量中存储光源的阴影贴图。基于这张阴影贴图,光源会将它的光照值添加到shading贴图中。 阴影贴图需要包含从光源看来的距离值。所以,每个光源需要定义View和Projection矩阵,这需要扩展SpotLight结构: public struct SpotLight { public Vector3 Position; public float Strength; public Vector3 Direction; public float ConeAngle; public float ConeDecay; public Matrix ViewMatrix; public Matrix ProjectionMatrix; } 对一个聚光灯来说,定义这两个矩阵非常容易: spotLights[i].ViewMatrix = Matrix.CreateLookAt(lightPosition, lightPosition + lightDirection,lightUp); float viewAngle = (float)Math.Acos(spotLights[i].ConeAngle); spotLights[i].ProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(coneAngle * 2.0f, 1.0f, 0.5f, 1000.0f); viewAngle基于聚光灯的光锥。通过这种方式,渲染目标的区域可以得到最优化的使用。 RenderShadowMap方法将这些矩阵传递到ShadowMap effect(下面就会定义)中。之后,场景从相机看来的方式被绘制,距离保存到shadowMap纹理中。 private void RenderShadowMap(SpotLight spotLight) { device.SetRenderTarget(0, shadowTarget); effectShadowMap.CurrentTechnique= effectShadowMap.Techniques["ShadowMap"]; ffectShadowMap.Parameters["xView"].SetValue(spotLight.ViewMatrix); effectShadowMap.Parameters["xProjection"].SetValue(spotLight.ProjectionMatrix); RenderScene(effectShadowMap); device.SetRenderTarget(0, null); shadowMap = shadowTarget.GetTexture(); } HLSL代码 HLSL代码很简单。因为3D场景需要变换为2D屏幕坐标,effect需要World,View和 Projection矩阵。 要匹配RenderScene方法还需要设置一个纹理,所以effect也包含xTexture变量,虽然这里没有用到这个变量 float4x4 xWorld; float4x4 xView; float4x4 xProjection; Texture xTexture; struct VertexToPixel { float4 Position : POSITION; float4 ScreenPos : TEXCOORD1; }; struct PixelToFrame { float4 Color : COLOR0; } vertex shader需要计算2D屏幕坐标。这个屏幕坐标还包含深度值让pixel shader输出。因为POSITION语义无法在pixel shader中访问,你要将它复制到ScreenPos变量中。 vertex shader非常简单,因为它做的就是将3D位置转换为2D屏幕位置: VertexToPixel MyVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { VertexToPixel Output = (VertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.ScreenPos = Output.Position; return Output; } pixel shader接受屏幕坐标。因为这是一个齐次(homogeneous)向量,所以在使用前要将前三个分量除以第四个分量。pixel shader生成深度值输出。 PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0 { PixelToFrame Output = (PixelToFrame)0; Output.Color.r = PSIn.ScreenPos.z/PSIn.ScreenPos.w; return Output; } 下面是technique定义: technique ShadowMap { pass Pass0 { VertexShader = compile vs_2_0 MyVertexShader(); PixelShader = compile ps_2_0 MyPixelShader(); } } 根据光照计算添加阴影映射 XNA代码 绘制了阴影贴图后,调用AddLight方法。这个方法首先开启shadingTarget和DeferredSpotLight technique。这个方法和technique主要部分与前一个教程相同,除了一些小变化。在每个光源的最后,shadingTarget的当前内容被保存到shadingMap纹理中。通过xPreviousShadingMapContents变量将这个内容传递到下一个光源中,你还要传递阴影贴图。 private void AddLight(SpotLight spotLight) { device.SetRenderTarget(0, shadingTarget); effect2Lights.CurrentTechnique = effect2Lights.Techniques["DeferredSpotLight"]; effect2Lights.Parameters["xPreviousShadingContents"].SetValue(shadingMap); effect2Lights.Parameters["xNormalMap"].SetValue(normalMap); effect2Lights.Parameters["xDepthMap"].SetValue(depthMap); effect2Lights.Parameters["xShadowMap"].SetValue(shadowMap); effect2Lights.Parameters["xLightPosition"].SetValue(spotLight.Position); effect2Lights.Parameters["xLightStrength"].SetValue(spotLight.Strength); effect2Lights.Parameters["xConeDirection"].SetValue(spotLight.Direction); effect2Lights.Parameters["xConeAngle"].SetValue(spotLight.ConeAngle); effect2Lights.Parameters["xConeDecay"].SetValue(spotLight.ConeDecay); Matrix viewProjInv = Matrix.Invert(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); effect2Lights.Parameters["xViewProjectionInv"].SetValue(viewProjInv); effect2Lights.Parameters["xLightViewProjection"].SetValue(spotLight.ViewMatrix * spotLight.ProjectionMatrix); effect2Lights.Begin(); foreach (EffectPass pass in effect2Lights.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = fsVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleStrip, fsVertices, 0, 2); pass.End(); } effect2Lights.End(); device.SetRenderTarget(0, null); shadingMap = shadingTarget.GetTexture(); } HLSL代码确保effect可以接受新的纹理: Texture xShadowMap; sampler ShadowMapSampler = sampler_state { texture = <xShadowMap>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; Texture xPreviousShadingContents; sampler PreviousSampler = sampler_state { texture = <xPreviousShadingContents>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; 唯一的变化在pixel shader中。你想获取点和光源之间的真实距离和存储在阴影贴图中的距离。如果存储在阴影贴图中的距离小于真实距离,说明有一个物体处于光源和像素之间,这个像素不会被当前光源照到。要找到真实距离,需要通过光照的ViewProjection矩阵转换3D位置。在除以它的齐次分量(译者注:即w分量,第四个分量)后,这个距离的Z分量就可以容易地使用了。 //find screen position as seen by the light float4 lightScreenPos = mul(worldPos, xLightViewProjection); lightScreenPos /= lightScreenPos.w; 然后,你想获取存储在阴影贴图中的距离。首先需要知道到哪采样阴影贴图,将lightScreenPos的分量从[–1,1]的屏幕位置区间映射到[0,1]纹理坐标区间,这和前一个教程一样: //find sample position in shadow map float2 lightSamplePos; lightSamplePos.x = lightScreenPos.x/2.0f+0.5f; lightSamplePos.y = (-lightScreenPos.y/2.0f+0.5f); 现在可以采样存储在阴影贴图中的深度值了。检查这个距离是否小于真实距离,表示像素是否被当前光源照亮: //determine shadowing criteria float realDistanceToLight = lightScreenPos.z; float distanceStoredInDepthMap = tex2D(ShadowMapSampler, lightSamplePos); bool shadowCondition = distanceStoredInDepthMap <= realDistanceToLight - 1.0f/100.0f; 最后,基于shadowCondition和coneCondition判断像素是否被照亮。这个shading值被添加到shadingMap中的原有值上,这是在pixel shader中的最后一行代码中执行的。 下面是完整的pixel shader代码: PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0 { PixelToFrame Output = (PixelToFrame)0; //sample normal from normal map float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb; normal = normal*2.0f-1.0f; normal = normalize(normal); //sample depth from depth map float depth = tex2D(DepthMapSampler, PSIn.TexCoord).r; //create screen position float4 screenPos; screenPos.x = PSIn.TexCoord.x*2.0f-1.0f; screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f); screenPos.z = depth; screenPos.w = 1.0f; //transform to 3D position float4 worldPos = mul(screenPos, xViewProjectionInv); worldPos /= worldPos.w; //find screen position as seen by the light float4 lightScreenPos = mul(worldPos, xLightViewProjection); lightScreenPos /= lightScreenPos.w; //find sample position in shadow map float2 lightSamplePos; lightSamplePos.x = lightScreenPos.x/2.0f+0.5f; lightSamplePos.y = (-lightScreenPos.y/2.0f+0.5f); //determine shadowing criteria float realDistanceToLight = lightScreenPos.z; float distanceStoredInDepthMap = tex2D(ShadowMapSampler, lightSamplePos); bool shadowCondition = distanceStoredInDepthMap <= realDistanceToLight - 1.0f/100.0f; //determine cone criteria float3 lightDirection = normalize(worldPos - xLightPosition); float coneDot = dot(lightDirection, normalize(xConeDirection)); bool coneCondition = coneDot >= xConeAngle; //calculate shading float shading = 0; if (coneCondition && !shadowCondition) { float coneAttenuation = pow(coneDot, xConeDecay); shading = dot(normal, -lightDirection); shading *= xLightStrength; shading *= coneAttenuation; } float4 previous = tex2D(PreviousSampler, PSIn.TexCoord); Output.Color = previous + shading; return Output; } 代码 这个教程使用与教程6-10相同的代码,但GenerateShadingMap,RenderShadowMap和 AddLight方法有所变化,而这几个方法前面已经写过了。

在场景中添加光线——使用Deferred Shading在场景中添加多光源

clock 二月 14, 2011 15:00 by author alex
问题 你想在场景中同时使用多个光源。 一个方法是使用多个光源绘制场景并将每个光源的影响混合在一起。当添加一个新光源时场景需要整个被重新绘制,这个方法无法拓展,因为帧频率会随着光源的增加按比例下降。 解决方案 本教程中将使用一个完全不同的方法。你将3D场景绘制到一张2D纹理中。然后,为这张纹理中的所有像素计算所有光源的光照。这意味着你要在2D纹理上进行逐像素的处理,但只需绘制3D场景一次。 但在进行光线计算时需要每个像素的初始3D位置,对吗?对。继续看下去如何做,整个过程分为3步,如图6-12所示。 在第一步中,你将整个3D场景绘制到一张纹理中(见教程3-8)—不是一张纹理而是一次三张纹理。下面是你想要的三张纹理: 你要将每个像素的基本颜色绘制到第一张纹理中(图6-12中的左上图)。 你需要将每个像素的法线转换为颜色并将这个颜色存储在第二张纹理中(图6-12中的中上图)。 你要将每个像素的深度(像素与相机间的距离)存储在第三张纹理中(图6-12中的右上图)。 图6-12 deferred渲染中的三个步骤 前面已经说过,整个操作过程只需使用一个effect的一个pass进行一次,所以,这个操作在使用没有光照计算的方法绘制场景时开销相同(或者更少,因为effect非常简单)。 现在花点时间理解下面的文字。你需要将场景中的所有像素的深度值存储在一张纹理中。对每个像素,你还需要知道它的2D屏幕坐标,因为这个坐标和它的纹理坐标是相同的。这意味着通过某种方法,从每个像素的2D屏幕坐标中你可以重建它的3D位置。而且,你还存储了每个像素的3D法线。如果你重建了3D位置和法线,就可以计算像素上的光照了。 所以,这就是你接下去要做的事。在生成了三张纹理后,在第二步你要激活一个新的,干净的渲染目标。对这个新目标的每个像素,你将重建它的3D位置和3D法线。这让你可以计算第一光的光照值。最后你会得到一个包含第一个光照的shading贴图。 对每个光源重复上面的步骤,将它们的光照值添加到shading贴图中。最后,你会得到一张包含所有光照的shading贴图。这个过程如图6-12中的step II所示,显示了六个光源。 在第三步中,你将颜色贴图(在第一步中创建)和这个shading贴图(在第二步中创建)组合起来。如图6-12的step III所示。 使用Deferred渲染的优点 如果只是简单地为每个光源绘制场景并将它们组合起来,你必须将3D世界转换到屏幕空间中去。 这样的操作需要使用vertex和pixel shaders。vertex shader必须将每个顶点的3D位置转换到2D屏幕位置。pixel shaders必须计算比屏幕中的像素多得多的像素。例如,如果背景中的物体A首先被绘制,显卡会使用pixel shader 计算像素的颜色。如果接着绘制在物体A之前的物体B,显卡需要再次计算这些像素的颜色。这样,显卡计算的像素会大大增多。 简而言之,vertex shaders需要做大量的工作,pixel shaders需要处理比屏幕上的像素更多的像素。 使用deferred渲染,你只需在第一步中进行这样的操作一次。然后你将为每个光源在纹理上进行一些逐像素的处理,这个处理过程中只需处理像素一次。最后一步将颜色贴图和shading贴图组合在一起包含在了另一个逐像素处理过程中。 简而言之,只需进行一次将3D场景转换为2D空间的操作。对每个光源,你的显卡只需处理屏幕上的像素一次。相对而言,vertex shader做的工作也少很多,当使用多个光源时,使用deferred渲染方法pixel shader也会处理少得多的像素。 工作原理 Deferred渲染需要进行三个步骤,如下所述。 准备工作 每个步骤都要创建一个单独的HLSL文件。创建这些文件(Deferred1Scene. fx, Deferred2L ights . fx, and Deferred3 Final. fx)并在XNA代码中添加变量: Effect effect1Scene; Effect effect2Lights; Effect effect3Final; 别忘了在LoadContent 方法中加载它们: effect1Scene = Content.Load<Effect>("Deferred1Scene"); effect2Lights = ontent.Load<Effect>("Deferred2Lights"); effect3Final = Content.Load<Effect>("Deferred3Final"); 确保在LoadContents方法中加载所需的几何数据。本例中,我将从一个顶点数组绘制一个简单的屋子,在InitSceneVertices方法中进行初始化: InitSceneVertices(); InitFullscreenVertices(); 最后一行代码初始化第二个顶点数组,定义了两个大三角形覆盖了整个屏幕。它们被用在了第二步和第三步中,当你使用自己的pixel shaders绘制全屏纹理时,允许你逐像素地处理全屏纹理。InitFullScreenVertices方法来自于教程2-12。 然后为了保持代码清晰,定义了一个RenderScene方法,这个方法以一个effect为参数,使用这个effect绘制整个屏幕。这个简单例子只从顶点数组中绘制三面带纹理的墙和一面地板。如果场景中包含模型,确保也使用这个effect绘制这些模型: private void RenderScene(Effect effect) { //Render room effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xTexture"].SetValue(wallTexture); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = wallVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleStrip, wallVertices, 0, 14); pass.End(); } effect.End(); } 第一步:将3D场景绘制到三张纹理中 在第一步中,你将场景绘制到三张纹理中。这些纹理需要包含基本颜色,3D法线和屏幕上每个像素的深度值。深度表示相机和物体上对应像素的距离。 这里只使用一个pixel shader。pixel shader将一次渲染到三张纹理而不是一个。 XNA Code 首先定义渲染目标: RenderTarget2D colorTarget; RenderTarget2D normalTarget; RenderTarget2D depthTarget; 在LoadContent方法中进行初始化: PresentationParameters pp = device.PresentationParameters; int width = pp.BackBufferWidth; int height = pp.BackBufferHeight; colorTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color); normalTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color); depthTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Single); 因为法线有三个分量,你将它存储为一个Color。深度值为一个single float值。当pixel shader写入多个渲染目标时,它们的格式必须是相同大小的。Color的每个分量使用8 bits (256 个可能值),所以Color使用32 bits。一个float也使用32 bits,所以不会出错。 创建了渲染目标后就可以进行绘制了。下面的方法处理了整个第一步的过程,所以要在Draw方法的第一行中调用: private void RenderSceneTo3RenderTargets() { //bind render targets to outputs of pixel shaders device.SetRenderTarget(0, colorTarget); device.SetRenderTarget(1, normalTarget); device.SetRenderTarget(2, depthTarget); //clear all render targets device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); //render the scene using custom effect writing to all targets simultaneously effect1Scene.CurrentTechnique = effect1Scene.Techniques["MultipleTargets"]; effect1Scene.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect1Scene.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); RenderScene(effect1Scene); //deactivate render targets to resolve them device.SetRenderTarget(0, null); device.SetRenderTarget(1, null); device.SetRenderTarget(2, null); //copy contents of render targets into texture colorMap = colorTarget.GetTexture(); normalMap = normalTarget.GetTexture(); depthMap = depthTarget.GetTexture(); } 首先,你将三个渲染目标绑定到pixel shaders中的COLOR0, COLOR1和COLOR2。请确保将它们的内容清空为黑色,(更重要)z-buffer设置为1 (见教程2-1)。 初始化结束后,就可以绘制场景了。使用MultipleTargets technique,这个technique将在下面定义。设置World, View和Projection矩阵(World矩阵必须在RenderScene方法中设置,因为场景中每个对象的世界矩阵是不同的)。通过将MultipleTargets technique传递到RenderScene绘制场景。 RenderScene方法完成后,三个渲染目标就会包含屏幕上每个像素的颜色,法线和深度值。在将它们保存到纹理之前,需要关闭它们(见教程3-8)。 HLSL代码 你仍需定义MultipleTargets technique,这个technique一次将场景绘制到三张纹理中。首先定义XNA-to-HLSL变量: float4x4 xWorld; float4x4 xView; float4x4 xProjection; Texture xTexture; sampler TextureSampler = sampler_state { texture = <xTexture> ; agfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; 像以往一样,你需要定义World,View和Projection矩阵。因为房间的墙和地板都带有纹理,还需要一个texture用来采样颜色。这些颜色会被保存到第一个渲染目标中。 下面是vertex 和pixel shaders的output结构: struct VertexToPixel { float4 Position : POSITION; float3 Normal : TEXCOORD0; float4 ScreenPos : TEXCOORD1; float2 TexCoords: TEXCOORD2; }; struct PixelToFrame { float4 Color: COLOR0; float4 Normal: COLOR1; float4 Depth: COLOR2; }; 在必须的Position之后,vertex shader还将法线传递到pixel shader以使它可以存储到第二个渲染目标。另外,因为pixel shader需要将深度值保存到第三个渲染目标中,你还需要将屏幕坐标传递到pixel shader。屏幕坐标的X和Y分量包含了当前像素的屏幕坐标,Z分量包含深度。 最后,pixel shader需要纹理坐标从纹理中对应的位置采样颜色。 非常重要的是pixel shader的output结构。不像本书的其他任何一个部分,本例中的pixel shader会生成多个output。你的pixel shader不仅会写入COLOR0,还会写入COLOR1和COLOR2 .显然这些output对应三个渲染目标。 先讨论简单的vertex shader: VertexToPixel MyVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoords: TEXCOORD0) { VertexToPixel Output = (VertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(inNormal, rotMatrix); Output.Normal = rotNormal; Output.ScreenPos = Output.Position; Output.TexCoords = inTexCoords; return Output; } 3D位置转换为2D屏幕位置很简单。法线通过世界矩阵中的旋转部分进行旋转(见教程6-5)。纹理坐标直接输出到output,2D屏幕坐标复制到ScreenPos变量中。 下面是pixel shader: PixelToFrame MyPixelShader(VertexToPixel PSIn) { PixelToFrame Output = (PixelToFrame)0; Output.Color.rgb = tex2D(TextureSampler, PSIn.TexCoords); Output.Normal.xyz = PSIn.Normal/2.0f+0.5f; Output.Depth = PSIn.ScreenPos.z/PSIn.ScreenPos.w; return Output; } 这很简单。颜色从纹理中采样(本例中是墙上的砖块纹理)并存储在第一个渲染目标中。然后是法线,因为3D法线的每个分量定义在[–1,1]区间,你需要将它们转换到[0,1]区间,这样它才可以存储为一个颜色分量。你可以将这个值除以2然后加0.5实现上述目的。 最后,需要在第三个渲染目标中存储深度值。深度值存储在ScreenPos变量的Z分量中。因为ScreenPos是4 × 4矩阵乘法额结果,所以它是一个4 × 1向量。在可以使用前三个分量前,你需要将它们除以第四个分量,这就是pixel shader中最后一行代码进行的操作。 下面是technique定义: technique MultipleTargets { pass Pass0 { VertexShader = compile vs_2_0 MyVertexShader(); PixelShader = compile ps_2_0 MyPixelShader(); } } 第一步的总结 第一步结束后,你生成并存储了三个纹理:第一个纹理包含基本颜色,第二个包含法线,第三个包含深度。 第二步:生成Shading贴图 知道了每个像素的颜色,法线和深度后,就可以进行光照计算了。这需要让显卡绘制两个覆盖整个屏幕的三角形,让你可以创建一个pixel shader用来被屏幕上的每个像素调用。在这个pixel shader中,你将计算一个光源施加在一个像素上的光照值。 这个过程对场景中的每个光源进行重复,这些重复过程对应图6-12中step II 的六张图像,因为这个例子使用了六个光源。本例中展示的是如何计算聚光灯的光照。 注意:如果你想添加一个不同的光源,需要调整光照计算。这只是pixel shader中的一小部分代码,其他部分保持不变。 HLSL代码 简而言之,这个effect将从深度贴图中采样每个像素的深度以重建像素的3D位置。知道了3D位置,就可以进行光照计算了。 要重新创建3D位置,你需要反转ViewProjection矩阵和深度贴图。而且还需要法线贴图和一些变量设置聚光灯(见教程6-8): float4x4 xViewProjectionInv; float xLightStrength; float3 xLightPosition; float3 xConeDirection; float xConeAngle; float xConeDecay; Texture xNormalMap; sampler NormalMapSampler = sampler_state { texture = <xNormalMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; Texture xDepthMap; sampler DepthMapSampler = sampler_state { texture = <xDepthMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; 然后是vertex和pixel shader的output结构。vertex shader生成2D屏幕坐标。还需要纹理坐标,这样每个像素才能从正确的位置采样法线贴图和深度贴图。 这次,pixel shader只需生成一个output值:当前光源对当前像素的光照值。 struct VertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct PixelToFrame { float4 Color : COLOR0; }; 因为在InitFullscreenVertices方法中定义的六个顶点已经定义在屏幕坐标中(位于[(–1,–1),(1,1)]区间)了,vertex shader只需简单地将位置和纹理坐标传递到output: VertexToPixel MyVertexShader(float4 inPos: POSITION0, float2 texCoord: TEXCOORD0) { VertexToPixel Output = (VertexToPixel)0; Output.Position = inPos; Output.TexCoord = texCoord; return Output; } 颜色处理都在pixel shader中。首先从法线贴图和深度贴图中采样法线和深度值: PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0 { PixelToFrame Output = (PixelToFrame)0; float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb; normal = normal*2.0f-1.0f; normal = normalize(normal); float depth = tex2D(DepthMapSampler, PSIn.TexCoord); } 深度值可以立即从深度贴图中采样。而法线必须首先将[0,1]区间重新映射到[–1,–1]区间,这是第一步中的逆操作。 下一步是重新构建像素的3D位置。要获取这个位置,首先需要当前像素的屏幕位置。 当前像素的纹理坐标适合做这件事,但它需要从[0,1]纹理坐标映射到[–1,1]屏幕坐标。屏幕坐标的Y需要取负值: float4 screenPos; screenPos.x = PSIn.TexCoord.x*2.0f-1.0f; screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f); 但是,屏幕位置还有第三个分量:相机和像素的距离。这就是你为什么生成第二个渲染目标的原因。因为知道了深度,就知道了第三个分量: screenPos.z = depth; screenPos.w = 1.0f; 第四个分量是需要的,因为接下来你要将这个矢量与一个4 × 4矩阵相乘。你可以通过把第四个分量设置为1将一个Vector3变成一个Vector4。 现在有了像素的屏幕坐标,但你想获取3D位置。还记得你可以通过把3D位置乘以ViewProjection矩阵(教程2-1)将一个3D位置转换成2D屏幕位置吗?所以,如何进行相反的操作——将2D屏幕位置转换为3D位置?这很简单,只需乘以ViewProjection的逆矩阵: float4 worldPos = mul(screenPos, xViewProjectionInv); worldPos /= worldPos.w; 矩阵的逆矩阵由XNA代码设置并计算,这很容易做到。 向量与4 × 4矩阵的计算结果返回一个同源(homogenous)向量,在使用前你需要将它前三个分量除以第四个分量。 最后获得了像素的3D位置。你还知道了像素的3D法线。有了这两者,就可以进行任何光照计算了。pixel shader的其余部分计算了一个聚光灯的光照值(来自于教程6-8),下面是完整的pixel shader代码: PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0 { PixelToFrame Output = (PixelToFrame)0; float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb; normal = normal*2.0f-1.0f; normal = normalize(normal); float depth = tex2D(DepthMapSampler, PSIn.TexCoord).r; float4 screenPos; screenPos.x = PSIn.TexCoord.x*2.0f-1.0f; screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f); screenPos.z = depth; screenPos.w = 1.0f; float4 worldPos = mul(screenPos, xViewProjectionInv); worldPos /= worldPos.w; float3 lightDirection = normalize(worldPos - xLightPosition); float coneDot = dot(lightDirection, normalize(xConeDirection)); bool coneCondition = coneDot >= xConeAngle; float shading = 0; if (coneCondition) { float coneAttenuation = pow(coneDot, xConeDecay); shading = dot(normal, -lightDirection); shading *= xLightStrength; shading *= coneAttenuation; } Output.Color.rgb = shading; return Output; } 下面是technique定义: technique DeferredSpotLight { pass Pass0 { VertexShader = compile vs_2_0 MyVertexShader(); PixelShader = compile ps_2_0 MyPixelShader(); } } 你创建了一个effect,这个effect从一张深度贴图,一张法线贴图和一个聚光灯开始,创建了一个包含聚光灯光照可见范围的shading贴图。 XNA代码 在XNA代码中,你将对场景中的每个光源调用这个effect。要管理光源,应该创建一个结构,保存聚光灯的所有细节: public struct SpotLight { public Vector3 Position; public float Strength; public Vector3 Direction; public float ConeAngle; public float ConeDecay; } 在项目中添加这些对象的数组: SpotLight[] spotLights; 对它进行初始化以存储一些光源: spotLights = new SpotLight[NumberOfLights]; 现在你就可以定义每个聚光灯了。你可以在Update方法中改变它们的设置,让你可以让这些光源绕着场景旋转! 然后,你将创建一个可以以SpotLight对象为参数的方法,这个方法将这个聚光灯的光照值绘制到渲染目标中: private void AddLight(SpotLight spotLight) { effect2Lights.CurrentTechnique = effect2Lights.Techniques["DeferredSpotLight"]; effect2Lights.Parameters["xNormalMap"].SetValue(normalMap); effect2Lights.Parameters["xDepthMap"].SetValue(depthMap); effect2Lights.Parameters["xLightPosition"].SetValue(spotLight.Position); effect2Lights.Parameters["xLightStrength"].SetValue(spotLight.Strength); effect2Lights.Parameters["xConeDirection"].SetValue(spotLight.Direction); effect2Lights.Parameters["xConeAngle"].SetValue(spotLight.ConeAngle); effect2Lights.Parameters["xConeDecay"].SetValue(spotLight.ConeDecay); Matrix viewProjInv = Matrix.Invert(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); effect2Lights.Parameters["xViewProjectionInv"].SetValue(viewProjInv); effect2Lights.Begin(); foreach (EffectPass pass in effect2Lights.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = fsVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleStrip, fsVertices, 0, 2); pass.End(); } effect2Lights.End(); } 首先选择你刚才定义的HLSL technique。然后,传递法线贴图和深度贴图,这两个贴图都是在第一步中创建的。接下来的代码传递聚光灯的设置。最后一个变量设置ViewProjection矩阵的逆矩阵,这个逆矩阵可以简单地使用Matrix. Invert方法得到。 定义完所有变量后,显卡绘制两个覆盖整个屏幕的三角形。这样显卡就可以对屏幕上的每个像素计算当前聚光灯的光照值。 AddLight方法绘制一个光源的光照值。你可以为每个聚光灯调用这个方法并将它们的光照值加在一起!这可以通过使用additive alpha混合做到。使用additive alpha混合,每个光照值都会被添加到相同的渲染目标中。 private Texture2D GenerateShadingMap() { device.SetRenderTarget(0, shadingTarget); device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); device.RenderState.AlphaBlendEnable = true; device.RenderState.SourceBlend = Blend.One; device.RenderState.DestinationBlend = Blend.One; for (int i = 0; i < NumberOfLights; i++) AddLight(spotLights[i]); device.RenderState.AlphaBlendEnable = false; device.SetRenderTarget(0, null); return shadingTarget.GetTexture(); } GenerateShadingMap方法首先开启一个新的叫做shadingTarget的渲染目标。首先要清除前面的内容。然后,打开additive alpha混合并将所有的光照值添加到渲染目标。然后关闭alpha混合 blending防止与后面的渲染混在一起。最后,渲染目标的内容被保存到一张纹理,并返回这个纹理。 这个方法应该在Draw方法中的第二行中调用: shadingMap = GenerateShadingMap(); 还需要在项目中添加shadingTarget和shadingMap变量: RenderTarget2D shadingTarget; Texture2D shadingMap; 在LoadContent方法中初始化渲染目标: shadingTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color); 因为这个渲染目标包含屏幕上每个像素的光照值,所以必须拥有与屏幕相同的大小。 第二步的总结 现在,你有了一张包含屏幕的每个像素光照值的shading贴图。 第三步:组合颜色贴图和Shading贴图 最后一步很简单。在第一步中每个像素的基本颜色存储在colorMap中。第二步中每个像素的光照值存储在shadingMap中。在第三步中,你只需简单地将两者相乘获取最终的颜色。 HLSL代码 effect接受colorMap和shadingMap纹理。要照亮场景中没有被聚光灯照到的部分,你需要添加一个小小的环境光: float xAmbient; Texture xColorMap; sampler ColorMapSampler = sampler_state { texture = <xColorMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; Texture xShadingMap; sampler ShadingMapSampler = sampler_state { texture = <ShadingMap>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; vertex shader和pixel shader的output结构,vertex shader本身与前面的教程中完全一样。这是因为vertex shader将接受六个顶点定义两个覆盖整个屏幕的三角形。 struct VertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct PixelToFrame { float4 Color : COLOR0; }; // Technique: CombineColorAndShading VertexToPixel MyVertexShader(float4 inPos: POSITION0, float2 texCoord: TEXCOORD0) { VertexToPixel Output = (VertexToPixel)0; Output.Position = inPos; Output.TexCoord = texCoord; return Output; } pixel shader很简单: PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0 { PixelToFrame Output = (PixelToFrame)0; float4 color = tex2D(ColorMapSampler, PSIn.TexCoord); float shading = tex2D(ShadingMapSampler, PSIn.TexCoord); Output.Color = color*(xAmbient + shading); return Output; } 你采样color和shading值,添加环境光并将它们相乘。最终的颜色传递到渲染目标。 下面是technique定义: technique CombineColorAndShading { pass Pass0 { VertexShader = compile vs_2_0 MyVertexShader(); PixelShader = compile ps_2_0 MyPixelShader(); } } XNA代码 effect需要在Draw方法的最后被调用。CombineColorAndShading方法选择technique,传递color和shading贴图,设置环境光。最后,使用刚才定义的technique绘制两个三角形: private void CombineColorAndShading() { effect3Final.CurrentTechnique= effect3Final.Techniques["CombineColorAndShading"]; effect3Final.Parameters["xColorMap"].SetValue(colorMap); effect3Final.Parameters["xShadingMap"].SetValue(shadingMap); effect3Final.Parameters["xAmbient"].SetValue(0.3f); effect3Final.Begin(); foreach (EffectPass pass in effect3Final.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = fsVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, fsVertices, 0, 2); pass.End(); } effect3Final.End(); } 这两个三角形需要被绘制到屏幕而不是渲目标。 第三步的总结 对屏幕上的每个像素,你组合了基本颜色和光照强度。 代码 所有的effect文件、对应deferred shading的主要方法前面已经写过了。因为你将代码分解成几个方法,所以Draw方法非常清晰: protected override void Draw(GameTime gameTime) { //render color, normal and depth into 3 render targets RenderSceneTo3RenderTargets(); //Add lighting contribution of each light onto shadingMap shadingMap = GenerateShadingMap(); //Combine base color map and shading map CombineColorAndShading(); base.Draw(gameTime); } 性能技巧 对每个光源,你的pixel shader将计算屏幕上所有像素的光照值。要减少第二步中处理像素的数量,你可以只绘制屏幕中被光照影响的部分而不是整个屏幕。这可以通过调整两个三角形的坐标实现。

在场景中添加光线——添加HLSL镜面高光

clock 二月 14, 2011 14:52 by author alex
问题 你想使用自定义的HLSL effect在场景中添加镜面高光。镜面高光是位于反光位置的高亮度区域,如图6-11所示。 解决方案 下面的讨论将帮助你判断哪个像素具有高光分量。 图6-11的左图显示了一条光线L,从光源指向三角形中的一个像素。在左图中还显示了 eye向量,从相机指向像素。如果L的反射向量与E相同,那么这个像素就有一个高光分量。 图6-11 使用靠近eye向量的光线方向检测像素 你可以通过求L关于像素法线的镜像获取L的反射向量。如果镜像方向与eye向量夹角很小则这两个方向几乎是相同的。你可以通过点乘这两个向量检测两者的夹角(可参见教程 6-8)。 如果角度为0,则这两个方向是相同的,你需要添加一个高光分量,这时点乘的结果为1。如果两个方向不同,则点乘结果小于1。 注意:两个向量A和B的点乘结果等于(A的长度)*(B的长度)*(两者夹角的余弦)。如果A和B都已经进行了归一化,点乘结果会变为(两者夹角的余弦)。如果A和B的夹角为0,则余弦值为1。如果两者垂直,夹角为90度,余弦值为0,如图6-11的右图所示。如果两个向量方向相反,夹角为180度,余弦值为-1。 当反射的方向与eye向量的方向夹角小于90度时,点乘结果为正。 你还不能立即使用这个值判断高光,因为这样做会在所有反射向量与eye向量的夹角小于90度的像素上添加高光,而你想在夹角小于10度时才添加高光。 这可以通过对点乘结果进行一个高次幂实现。例如,将点乘结果进行12次方的操作,会使角度小于10度的情况下这个值才会大于0,如图6-11右下图所示。 每个像素的运算结果是一个single值,表示高光强度。 工作原理 和以往一样,你需要首先设置World,View和Projection矩阵将3D位置转换到2D屏幕位置。因为这个教程用的是一个点光源,你还需指定它的位置。要计算eye向量,你需要知道相机的位置。你还需能够设置光照强度控制高光大小。因为光照强度可能大于1,因此需要缩小光照强度避免饱和(saturation)。 注意:在大多数情况中,你需要缩小光源的强度。在多光源的情况中大多数像素的光照会饱和,浪费光照effect。 float4x4 xWorld; float4x4 xView; float4x4 xProjection; float3 xLightPosition; float3 xCameraPos; float xAmbient; float xSpecularPower; float xLightStrength; struct SLVertexToPixel { float4 Position : POSITION; float3 Normal : TEXCOORD0; float3 LightDirection : TEXCOORD1; float3 EyeDirection : TEXCOORD2; }; struct SLPixelToFrame { float4 Color : COLOR0; }; vertex shader还计算了EyeDirection并进行插值。pixel shader仍然只输出每个像素的颜色。 Vertex Shader vertex shader与前面的教程没有太大的不同。唯一一个新的东西就是eye向量在vertex shader中进行计算。从一个点指向另一个点的向量可以通过将终点减去起点实现。 SLVertexToPixel SLVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { SLVertexToPixel Output = (SLVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3 final3DPos = mul(inPos, xWorld); Output.LightDirection = final3DPos - xLightPosition; Output.EyeDirection = final3DPos - xCameraPos; float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(inNormal, rotMatrix); Output.Normal = rotNormal; return Output; } Pixel Shader pixel shader更加有趣。基本颜色是蓝色的,无需关注太多。在pixel shader中归一化每个方向,因为它的长度可能不是1 (见教程6-3)。 与以往一样,你计算了光照,将它乘以xLightStrength缩小一点(xLightStrength小于1)。 SLPixelToFrame SLPixelShader(SLVertexToPixel PSIn) : COLOR0 { SLPixelToFrame Output = (SLPixelToFrame)0; float4 baseColor = float4(0,0,1,1); float3 normal = normalize(PSIn.Normal); float3 lightDirection = normalize(PSIn.LightDirection); float shading = dot(normal, -lightDirection); shading *= xLightStrength; float3 reflection = -reflect(lightDirection, normal); float3 eyeDirection = normalize(PSIn.EyeDirection); float specular = dot(reflection, eyeDirection); specular = pow(specular, xSpecularPower); specular *= xLightStrength; Output.Color = baseColor*(shading+xAmbient)+specular; return Output; } 然后,使用reflect 函数计算光线方向的镜像。因为光线方向是指向像素的,它的反射方向将指向眼睛,反射方向与eye向量相反,所以需要取负值。 Specular的值可以通过点乘eye向量和反射方向获取,将这个值进行高次幂计算,使这两个向量的夹角小于10度的像素高光值才会大于0。这个值需要通过乘以xLightStrength 变得小一点。 最后,ambient,shading和specular分量组合在一起获得像素最后的颜色。 注意:specular分量在最终颜色中添加白色。如果光线有不同的颜色,你需要将specular值乘以光线的颜色。 定义Technique 下面是technique定义: technique SpecularLighting { pass Pass0 { VertexShader = compile vs_2_0 SLVertexShader(); PixelShader = compile ps_2_0 SLPixelShader(); } } 代码 因为所有HLSL代码前面已经写过了,下面只是XNA代码: effect.CurrentTechnique = effect.Techniques["SpecularLighting"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xAmbient"].SetValue(0.0f); effect.Parameters["xLightStrength"].SetValue(0.5f); effect.Parameters["xLightPosition"].SetValue(new Vector3(5.0f, 2.0f, -15.0f)); effect.Parameters["xCameraPos"].SetValue(fpsCam.Position); effect.Parameters["xSpecularPower"].SetValue(128.0f); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleStrip, vertices, 0, 6); pass.End(); } effect.End();

在场景中添加光线——使用HLSL定义聚光灯

clock 二月 14, 2011 14:50 by author alex
问题 前面教程中定义的点光源从一个点发出四面八方的光。你想定义一个聚光灯,它与点光源很很像,但光线只照亮一个圆锥区域,如图6-10。 图6-10 定义一个聚光灯的变量 解决方案 在pixel shader中,判断当前像素是否在光照圆锥中,这可以通过将光线方向和圆锥方向进行点乘做到。 工作原理 开始的代码与前面的教程中的是一样的。因为聚光灯比点光源需要设置的东西更多,你需要将下列XNA-to-HLSL变量添加到. fx文件中: float xLightStrength; float3 xConeDirection; float xConeAngle; float xConeDecay; 第一个变量让你可以增加/减少光照强度。这个变量对其他类型的光也是很有用的,当场景中有多个光源时它也是必须的(见教程6-10)。然后定义光锥的中心线方向、光锥宽度。最后定义光照的衰减。 除此之外,你还要扩展pixel shader。基本上与逐像素点光源中(见教程6-7)做的一样,只是多了一个检查像素是否在光锥中的步骤: SLPixelToFrame SLPixelShader(SLVertexToPixel PSIn) : COLOR0 { SLPixelToFrame Output = (SLPixelToFrame)0; float4 baseColor = float4(0,0,1,1); float3 normal = normalize(PSIn.Normal); float3 lightDirection = normalize(PSIn.LightDirection); float coneDot = dot(lightDirection, normalize(xConeDirection)); float shading = 0; if (coneDot > xConeAngle) { float coneAttenuation = pow(coneDot, xConeDecay); shading = dot(normal, -lightDirection); shading *= xLightStrength; shading *= coneAttenuation; } Output.Color = baseColor*(shading+xAmbient); return Output; } 归一化法线和光线方向之后,你需要检测当前像素是否在光锥之内。这可以检测两个方向间的夹角做到: 当前像素到光源的方向 光锥的中心线的方向 第一个方向就是lightDirection,第二个方向由xConeDirection变量定义。只有这两个方向的夹角小于某个临界值,像素才会被照亮。 检测的一个快速方法是计算这两个方向的点乘。结果接近于1表示两者的夹角很小,结果越小表示夹角越大。 要判断角度是否太大,你要检测点乘结果是否小于某个临界值,这个临界值存储在ConeAngle变量中。如果像素在光锥中,就计算光照因子。要在接近光锥边缘的地方减弱光照,你要计算变量coneDot的xConeDecay次幂。结果是,对那些远离光锥中心线方向的像素来说,当coneDot变量小于等于1时,幂的结果会变得更小(见图6-11的右图)。 光锥之外的像素光照值为0,光线对这些像素没有影响。 代码 完整的pixel shader代码前面已经有了。 在XNA代码的Draw方法中,开启effect,设置参数并绘制场景: effect.CurrentTechnique = effect.Techniques["SpotLight"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xAmbient"].SetValue(0.2f); effect.Parameters["xLightPosition"].SetValue(new Vector3(5.0f, 2.0f, -15.0f+variation)); effect.Parameters["xConeDirection"].SetValue(new Vector3(0,-1,0)); effect.Parameters["xConeAngle"].SetValue(0.5f); effect.Parameters["xConeDecay"].SetValue(2.0f); effect.Parameters["xLightStrength"].SetValue(0.7f); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip,vertices, 0, 6); pass.End(); } effect.End();

在场景中添加光线——添加HLSL逐像素光照

clock 二月 14, 2011 14:48 by author alex
问题 如教程6-3所示,要获得最好的光照效果应该使用逐像素光照,特别是对那些由大三角形构成的曲线的情况中。你想使用自己的effect添加逐像素光照。 解决方案 前两个教程中,你在每个顶点中计算明暗值(shading value,也可以翻译成着色值)。三角形三个顶点的明暗值会进行线性以获取每个像素的明暗值。 在逐像素光照中,你想对三个顶点的法线进行插值以获取每个像素的法线,这样就可以基于每个像素的法线计算光照因子。但是,当从一个顶点到另一个顶点进行法线插值时结果是有缺陷的。如图6-9中的左图所示,图中的水平直线表示一个顶点包含法线的三角形。当你在像素上对这左右两个顶点的法线进行插值时,插值的法线总会沿着虚线。导致在三角形中间的法线法线是正确的,其他位置的法线会小于实际值,如图所示。 图6-9 从顶点到像素的线性插值是错误的 解决方法是在pixel shader中处理这个插过值的法线。因为它的方向是正确的,你可以归一化这个向量。这是因为每个像素的缩放因子是不同的,所以这一步需要在pixel shader中进行。 注意:要理解为什么将法线长度变为1,可参见教程6-1中的“归一化法线”一节。 当你想让光照强度只取决于法线和入射光夹角时,更小的法线导致更小的光照强度。 光照方向也会遇到同样的问题,如图6-9右图所示。插过值的光线方向的长度沿着虚线曲线,这会导致向量比实际的小。你仍需要归一化这个插过值的光线方向。 工作原理 和以往一样,你的项目必须从至少包含3D位置和法线的顶点中与显卡进行交互。你想让XNA代码可以设置World,View,Projection矩阵,光源的3D位置和环境光: float4x4 xWorld; float4x4 xView; float4x4 xProjection; float3 xLightPosition; float xAmbient; struct PPSVertexToPixel { float4 Position: POSITION; float3 Normal: TEXCOORD0; float3 LightDirection: TEXCOORD1; }; struct PPSPixelToFrame { float4 Color: COLOR0; }; 如前所述,vertex shader会输出法线,这个法线已经进行了插值,光线方向也进行了插值。pixel shader只需计算每个像素最后的颜色。 注意:在单向光的简单例子中,光源的方向是XNA-to-HLSL变量,对顶点和像素来说都是不变的。所以vertex shader无需计算这个值。 Vertex Shader vertex shader从顶点中接受法线,根据世界矩阵中的旋转值旋转这个法线(见教程6-5),并将它传递到pixel shader。 在vertex shader中还通过将顶点位置减去点光源的位置计算了光线方向(见教程6-5)。根据当前世界矩阵获取顶点的最终3D位置。 PPSVertexToPixel PPSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { PPSVertexToPixel Output = (PPSVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3 final3DPos = mul(inPos, xWorld); Output.LightDirection = final3DPos - xLightPosition; float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(inNormal, rotMatrix); Output.Normal = rotNormal; return Output; } Pixel Shader 法线和光线方向在三个顶点间进行插值,作用在三角形的所有像素上。如前所述,因为被插值的向量的长度会比实际的小,所以会发生错误。你可以通过归一化操作解决这个问题。将两个方向归一化之后,就可以点乘两者获取光照因子: PPSPixelToFrame PPSPixelShader(PPSVertexToPixel PSIn) : COLOR0 { PPSPixelToFrame Output = (PPSPixelToFrame)0; float4 baseColor = float4(0,0,1,1); float3 normal = normalize(PSIn.Normal); float3 lightDirection = normalize(PSIn.LightDirection); float lightFactor = dot(normal, -lightDirection); Output.Color = baseColor*(lightFactor+xAmbient); return Output; } 定义Technique 这个technique需要Shader 2.0–compatible的显卡: technique PerPixelShading { pass Pass0 { VertexShader = compile vs_2_0 PPSVertexShader(); PixelShader = compile ps_2_0 PPSPixelShader(); } } 代码 前面已经写过. fx文件中的HLSL代码了,所以下面只是绘制三角形的XNA代码: effect.CurrentTechnique = effect.Techniques["PerPixelShading"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xAmbient"].SetValue(0.0f); effect.Parameters["xLightPosition"].SetValue(new Vector3(6.0f, 1.0f, -5.0f)); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 6); pass.End(); } effect.End(); 你可以试着改变光源的位置查看效果。本例中的光源前后移动。

在场景中添加光线——使用HLSL定义点光源

clock 二月 14, 2011 14:46 by author alex
问题 直到现在,你都是用单向光照亮场景,这对在3D世界中添加阳光是很有用的。但很多情况中,你还需要一个从点发出的光线,例如一个探照灯或爆炸。这种光源叫做点光源。 解决方案 将点光源的3D位置从XNA项目传送到XNA effect中。对每个顶点,计算光源指向顶点的方向,并将这个方向作为光线方向。知道了光线方向就可以像以前一样继续了。 工作原理 在. fx文件中,使用下列代码替换xLightDirection参数,让你可以将光源的3D位置从XNA项目传递到HLSL effect中: float3 xLightPosition; 然后,对每个顶点,你要计算从光源指向顶点的方向。从A指向B的方向可以通过B减去A获得。记住你需要顶点的最终3D位置,这意味着初始3D位置需要通过是世界矩阵进行变换: float3 final3DPosition = mul(inPos, xWorld); float3 lightDirection = final3DPosition - xLightPosition; lightDirection = normalize(lightDirection); Output.LightFactor = dot(rotNormal, -lightDirection); 因为光源指向顶点的方向可能比1大得多,所以要确保将这个方向归一化。有了光线方向,你可以像以前的教程那样继续处理了。 当运行代码时,对每个顶点都要计算光源到这个顶点的方向,所以这个方向往往是不同的。 距离衰减 要让效果变得更加真实,你想在光源与顶点的距离变大时让点光源的光照变弱。要做到这点,可以在归一化lightDirection之前对它使用length函数获得distance,然后将LightFactor除以这个distance: float3 final3DPosition = mul(inPos, xWorld); float3 lightDirection = final3DPosition - xLightPosition; float distance = length(lightDirection); lightDirection = normalize(lightDirection); Output.LightFactor = dot(rotNormal, -lightDirection); Output.LightFactor /= distance; 代码 在XNA项目中,你可以在任何你想的位置放置点光源: effect.CurrentTechnique = effect.Techniques["VertexShading"]; effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xLightPosition"].SetValue(new Vector3(1, 5, 0)); effect.Parameters["xAmbient"].SetValue(0.0f); 下面是完整的vertex shader: VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { VSVertexToPixel Output = (VSVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3 normal = normalize(inNormal); float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(normal, rotMatrix); float3 final3DPosition = mul(inPos, xWorld); float3 lightDirection = final3DPosition - xLightPosition; lightDirection = normalize(lightDirection); Output.LightFactor = dot(rotNormal, -lightDirection); return Output; }

在场景中添加光线——添加HLSL Vertex Shading

clock 二月 14, 2011 14:39 by author alex
问题 使用你配置好的光照,BasicEffect可以很好地绘制场景。但是,如果你想定义一些更酷的效果,首先要实现的就是正确的光照。 本教程中,你将学习如何编写一个基本的HLSL effect实现逐顶点光照。 解决方案 传递每个顶点的3D位置和法线到effect中。显卡上的vertex shader需要对每个顶点做两件事。 首先,当绘制3D世界时,总是要使用世界矩阵,视矩阵和投影矩阵将3D位置转换为对应的2D屏幕坐标。 第二,通过叉乘光线方向和法线方向计算顶点的光照强度。 工作原理 首先需要在XNA项目中定义顶点。显然你需要将3D位置存储在每个顶点中。要在vertex shader 中计算正确的光照,你还需要为每个顶点提供法线,可参见教程6-1理解法线的概念。 你可以使用教程6-1中的相同代码,这个代码创建了包含一个3D位置和一个法线(还包含纹理坐标,只是这里你不使用它们)的六个顶点。 在XNA项目中创建一个新的. fx文件,添加以下代码。它包含了可以从XNA应用程序中改变的HLSL变量。 float4x4 xWorld; float4x4 xView; float4x4 xProjection; float xAmbient; float3 xLightDirection; 当将一个3D坐标转换到2D屏幕坐标时,总是需要视矩阵和投影矩阵(见教程2-1)。因为你还想在场景中移动物体,所以还需一个世界矩阵(见教程4-2)。因为这个教材处理的是光照,你需要定义光线的方向。Ambient变量让你可以设置光照的最小级别,这样,即使一个对象没有被光源直接照射,它仍是隐约可见的。 在进入vertex shader和pixel shader前,首先需要定义output结构。首先,vertex shader的output 就是pixel shader的input,必须保存每个顶点的2D屏幕坐标。第二,vertex shader 还计算了每个顶点的光照强度。 在vertex shader和pixel shader之间,这些值进行了插值,让每个像素获取了它们各自的插值。 pixel shader仅计算每个像素的最终颜色。 struct VSVertexToPixel { float4 Position : POSITION; float LightingFactor : TEXCOORD0; }; struct VSPixelToFrame { float4 Color : COLOR0; }; Vertex Shader vertex shader将World,View和Projection矩阵组合成一个矩阵,用来将3D坐标转换为2D屏幕坐标。 给定光线方向和法线方向,vertex shader可以根据图6-7计算光照强度。光线和法线间的夹角越小,光照越强烈,夹角越大,光照越少。 你可以通过点乘这两个方向获得这个值。点乘返回一个0到1之间的值(如果两个向量的长度都是1)。 图6-7 光线方向和法线方向的点乘 但是,在计算两者的点乘时首先要将其中一个方向反向,否则这两个方向是相反的。例如,图6-7右图中你发现法线和光线方向是相反的,这会导致点乘的结果为负。 VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { VSVertexToPixel Output = (VSVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3 normal = normalize(inNormal); Output.LightFactor = dot(rotNormal, -xLightDirection); return Output; } 点乘的结果是一个single值,基于两个法线的夹角和长度。在大多数情况中,你只需要光线基于两者的夹角。这意味着你需要确保3D空间中的所有法线和光线方向长度是一样的;否则,具有更长法线的顶点会获取更多的光照。 这可以通过让所有法线的长度为1做到,即需要归一化法线。 注意:归一化(normalizing)的意思不是对法线不做操作,而是让一个向量的长度变为1,可参见教程6-1。 使用世界矩阵时确保正确的光照 前面的代码在世界矩阵为单位矩阵时工作良好,即物体需要放置在(0,0,0)3D空间的初始位置(见教程5-2)。 但在大多数情况中,你想使用另外的世界矩阵,让你可以移动/旋转/缩放对象。 如图6-1所示,如果你旋转了物体,法线也会跟着一起旋转。这意味着法线需要通过世界矩阵中的旋转量进行变换。 世界矩阵中的缩放操作不会影响光照的计算。你总要在vertex shader中归一化法线,让向量的长度变为1。 但是,如果世界矩阵中包含平移,你就会遇到麻烦。这是因为法线是最大长度为1的向量。例如,当你使用一个包含超过两个单位的矩阵变换法线时,所有的法线都会指向那个方向。 如图6-8所示,一个物体使用一个包含平移一段距离的世界矩阵向右平移时,顶点的位置会移向右方。法线也会根据这个世界矩阵移向右方,但它们的方向应该是不变的。所以,在使用世界矩阵变换法线时,你需要将世界矩阵中的平移部分剥离出来。 图6-8 被世界矩阵中的平移影响的法线 矩阵是一个包含4 × 4个数字的表格。你应该只使用世界矩阵中的旋转部分变换法线,而不要用平移部分。你可以提取出矩阵的旋转部分,它位于左上的3 × 3的数字中。只需简单地将4 × 4世界矩阵变换为一个3 × 3矩阵,就可以只获取旋转信息,这正是你所需要的!使用这个矩阵旋转法线,代码如下所示: float3 normal = normalize(inNormal); float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(normal, rotMatrix); Output.LightFactor = dot(rotNormal, -xLightDirection); Pixel Shader 首先,三角形的三个顶点由vertex shader进行处理,计算光照值。然后,对三角形中的每个像素,这个光照值会在三个顶点间进行插值。这个插值过的光照值传递到pixel shader。 在这个简单地例子中,取蓝色为物体的基本颜色。要在三角形上添加明暗效果,要将这个基本颜色乘以LightFactor (在前面的vertex shader中计算)和环境光照(由XNA程序通过xAmbient变量设置)。环境光(ambient)因子确保所有物体不会是完全黑暗的,而LightFactor 根据光线方向施加对应的光照: VSPixelToFrame VSPixelShader(VSVertexToPixel PSIn) : COLOR0 { VSPixelToFrame Output = (VSPixelToFrame)0; float4 baseColor = float4(0,0,1,1); Output.Color = baseColor*(PSIn.LightFactor+xAmbient); return Output; } 定义technique 最后,定义technique: technique VertexShading { pass Pass0 { VertexShader = compile vs_2_0 VSVertexShader(); PixelShader = compile ps_2_0 VSPixelShader(); } } XNA代码 在XNA项目中,导入HLSL文件并将它存储在一个Effect变量中,这和教程3-1中对纹理的操作是类似的。在本例中,HLSL文件名为vertexshading. fx: effect = content.Load<Effect>("vertexshading"); 当绘制物体时,首先需要设置effect的参数,这需要用到BasicEffect: effect.CurrentTechnique = effect.Techniques["VertexShading"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xLightDirection"].SetValue(new Vector3(1, 0, 0)); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } effect.End(); 代码 XNA代码绘制对象的多个实例。因为使用了不同的世界矩阵,这些对象会绘制在不同位置。 最终结果和教程6-1是一样的,只是这次你使用了自己的HLSL effect: effect.CurrentTechnique = effect.Techniques["VertexShading"]; effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xLightDirection"].SetValue(new Vector3(1, 0, 0)); effect.Parameters["xAmbient"].SetValue(0.0f); for (int i = 0; i < 9; i++) { Matrix world = Matrix.CreateTranslation(4, 0, 0) * Matrix.CreateRotationZ((float)i * MathHelper.PiOver2 / 8.0f); effect.Parameters["xWorld"].SetValue(world); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } effect.End(); } 下面是.fx文件的完整内容: float4x4 xWorld; float4x4 xView; float4x4 xProjection; float xAmbient; float3 xLightDirection; struct VSVertexToPixel { float4 Position : POSITION; float LightFactor : TEXCOORD0; }; struct VSPixelToFrame { float4 Color : COLOR0; } // Technique: VertexShading VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0) { VSVertexToPixel Output = (VSVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float3 normal = normalize(inNormal); float3x3 rotMatrix = (float3x3)xWorld; float3 rotNormal = mul(normal, rotMatrix); Output.LightFactor = dot(rotNormal, -xLightDirection); return Output; } VSPixelToFrame VSPixelShader(VSVertexToPixel PSIn) : COLOR0 { VSPixelToFrame Output = (VSPixelToFrame)0; float4 baseColor = float4(0,0,1,1); Output.Color = baseColor*(PSIn.LightFactor+xAmbient); return Output; } technique VertexShading { pass Pass0 { VertexShader = compile vs_2_0 VSVertexShader(); PixelShader = compile ps_2_0 VSPixelShader(); } }

在场景中添加光线——在反光表面添加镜面高光

clock 二月 14, 2011 14:36 by author alex
问题 就算开启了逐像素明暗,有些金属或闪光表面仍显得有点暗淡。在现实生活中,当观察诸如金属、玻璃或一些塑料时,你会发现某些区域的反光非常强烈。这样的区域如图6-6的虚线圆圈所示。这些高亮的区域叫做镜面高光(specular highlights)。 图6-6 镜面高光 解决方案 和逐像素光照一样,你只需简单地告知BasicEffect创建镜面高光就可以开启它。 注意:镜面高光只应添加到发光材质上。不要将它们添加到诸如衣服或草地的柔软表面,或至少让反光效果非常微弱。 工作原理 使用BasicEffect开启镜面高光非常简单。对每个光源,你指定高光颜色。然后,设置高光强度。这个强度可以指定高光的宽度。强度越大,镜面高光越窄。可见教程6-8学习相关细节。 basicEffect.LightingEnabled = true; basicEffect.DirectionalLight0.Direction = lightDirection; basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; basicEffect.PreferPerPixelLighting = true; basicEffect.DirectionalLight0.SpecularColor = Color.White.ToVector3(); basicEffect.SpecularPower = 32; 注意:当使用镜面高光时,总要使用逐像素光照,这是因为镜面高光通常是一个小的,非线性的点,所以它们不应该被插值,而是应该逐像素地计算。 代码 下面的代码定义顶点并绘制一个矩形: private void InitVertices() { vertices = new VertexPositionNormalTexture[4]; vertices[0] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(0, 1, 0), new Vector2(0, 1)); vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 10), new Vector3(0, 1, 0), new Vector2(0, 0)); vertices[2] = new VertexPositionNormalTexture(new Vector3(10, 0, 0), new Vector3(0, 1, 0), new Vector2(1, 1)); vertices[3] = new VertexPositionNormalTexture(new Vector3(10, 0,- 10), new Vector3(0, 1, 0), new Vector2(1, 0)); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); } 在Draw方法中,添加如下代码创建BasicEffect,在矩形上添加镜面高光: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = blueTexture; basicEffect.TextureEnabled = true; Vector3 lightDirection = new Vector3(0, -3, -10); lightDirection.Normalize(); basicEffect.LightingEnabled = true; basicEffect.DirectionalLight0.Direction = lightDirection; basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; basicEffect.PreferPerPixelLighting = true; basicEffect.DirectionalLight0.SpecularColor = Color.White.ToVector3(); basicEffect.SpecularPower = 32; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 2); pass.End(); } basicEffect.End();

友情链接赞助