十、让事情变得丰富
声音是任何游戏的重要组成部分,因为它能从虚拟世界提供即时反馈。如果你在玩游戏时把声音关小,你可能会发现这是一种非常被动的体验,因为我们希望事件伴随着声音。
像创作游戏的其他方面一样,好的音效需要大量的创造力。为游戏中的动作选择一套合适的音效可以决定视觉效果是有效还是完全无效。
本章探索了 Pygame 模块,你可以用它来给你的游戏添加音效和音乐。我们还将介绍如何使用自由软件来创建和编辑你的声音。
什么是声音?
声音本质上是振动,通常通过空气传播,但也可能通过水或其他物质传播。几乎所有的东西都会振动,并将振动以声音的形式传到空气中。例如,当我打字时,我可以听到塑料键与下面的表面碰撞时产生的“噼啪”声。塑料振动非常快,推动周围的空气分子,反过来推动其他分子,并发出连锁反应,最终到达我的耳朵,被解释为声音。
振动中的能量越多,声音就越大。按键相对安静,因为按下一个键不需要太大的力,但如果我用很大的力敲击键盘,比如用大锤,声音会更大,因为振动中会有更多的能量。
声音也可以在音高、上变化,这是空气中振动的速度。一些材料,如金属,往往振动非常快,当受到撞击时会产生高音调的噪音。其他材料以不同的速率振动,产生不同的音调。
大多数声音是音高和音量变化的复杂混合体。如果我把一个玻璃杯掉到石头地板上,最初的撞击会产生很大的噪音,接着是碎片振动并落回地面时发出的各种声音。所有这些声音的结合产生了一种我们认为是玻璃破碎的声音。
声音在到达听者的耳朵之前也会被改变。我们都很熟悉在你和说话的人之间的一堵墙是如何使声音变得模糊 并让人难以理解的。这是因为声音可以穿过墙壁和空气,但它会降低音量并改变途中的振动。声音也可能会从某些表面上反弹出 ??,产生回声等效果。在游戏中复制这样的物理效果是增强视觉效果的一个好方法。如果游戏角色进入一个大洞穴,当他走路时,如果他的脚步声有回音,那就更有说服力了。但是就像游戏设计的大多数方面一样,一点艺术上的许可是允许的。太空中没有声音,因为没有空气供它传播,但我仍然希望我的激光炮能产生令人满意的 zap 噪音!
存储声音
早期的电脑游戏使用芯片创造简单的音调来产生电子哔哔声和口哨声,但不能产生复杂的声音。如今,游戏硬件可以存储和再现现实生活中的声音,为游戏创造丰富的额外维度。计算机上的声卡可以录制和播放高质量的音频。
声音可以用一种波来表示。图 10-1 显示了代表声音的一小部分(几分之一秒)的声波——完整的声音会更长更复杂。波形显示了声音的能量或振幅、如何随时间变化。
声波形成许多波峰和波谷;这些波峰和波谷的幅度差越大,声音的音量就越大。声音的音调由波的频率(波峰之间的时间距离)决定;峰值在时间上越接近,声音就越高。
图 10-1 。一个声波
要在计算机上存储声音,您必须首先将其转换为数字形式,这可以通过将麦克风插入声卡的 mic 插孔,或者插入专为计算机使用而设计的新型麦克风的 USB 端口来实现。当麦克风拾取声音时,声波被转换成电信号,该电信号由声卡以固定的间隔进行采样,产生一系列可以保存到文件中的数字。样本是表示特定时刻波形振幅的值,用于在回放时重建波形。样本越多,声卡播放声音就越准确。图 10-2 显示了从低采样率重建的波形,覆盖在原始波形上。您可以看到,采样波形通常会跟随真实波形,但许多细节会丢失,从而产生低质量的声音。较高的采样速率会产生更接近原始波形的波形,回放时听起来会更好。
图 10-2 。样本声波
采样率以赫兹(Hz)或千赫兹(KHz)为单位,赫兹表示每秒采样数,千赫兹表示每秒采样数千个。电话质量约为 6KHz,CD 质量为 44KHz。采样速率可以比 CD 质量更高,但只有狗和蝙蝠能够分辨出这种差异!
声音格式
像图像一样,数字音频也有许多不同的文件格式,它们会影响质量和文件大小。Pygame 支持两种音效音频格式:WAV(仅未压缩)和 Ogg。大多数处理声音的软件都可以读写 WAV 文件。对 Ogg 的支持并不普遍,但仍然非常普遍。如果一个应用不直接支持 Ogg,也许可以通过升级或插件来添加它。
声音有许多属性会影响质量和文件大小:
- 单个样本的大小,通常是 8 位或 16 位整数,尽管有些格式支持浮点样本。通常您应该使用 16 位存储声音文件,因为它可以再现 CD 质量的声音,并且最受声卡支持。
Sample rate—
每秒存储的样本数。采样速率最常见的值是 11025Hz、22050Hz 或 44100Hz,但也有许多其他可能的值。采样速率越高,产生的声音质量越好,但文件也会越大。Channels
—
声音文件可以是单声道(单声道声音),也可以是立体声(左右扬声器的独立声道)。立体声听起来更好,但使用的内存是未压缩音频文件的两倍。Compression
—
声音可以生成大文件。例如,一分钟长、44100Hz、16 位的立体声音频将产生大约 10MB 的数据。幸运的是,音频可以被压缩,这样就可以放入更小的空间。Pygame 不支持压缩的 WAV 文件,但是支持 Ogg 格式,压缩性非常好。
决定你需要这些属性的什么组合通常取决于你将如何分发你的游戏。如果你将在 CD 或 DVD 上发行你的游戏,你可能会有足够的空间来存储高质量的声音。然而,如果你想通过电子邮件或下载来发布你的游戏,你可能需要牺牲一点质量来获得更小的文件。
创造声音效果
创建音效的一种方法是简单地录制自己的音效。例如,如果你需要一个引擎噪音,最好的方法就是录下真实引擎运转的声音。其他声音是不切实际的,甚至是不可能捕捉到的,可能需要一点创造力来创造一个近似的声音——一个好的枪声可以通过记录一个气球爆炸,然后使用声音编辑软件来扩展和加深声音来创造。通过记录铅笔敲击金属台扇格栅的声音,然后提高音调并添加一点回声,甚至可以创建相位器火。
提示如果你想在外面录音,又不想随身带着笔记本电脑,那就买个便宜的录音机吧。质量可能会受到一点影响,因为你不是直接录制到高质量的数字,但你总是可以用 Audacity (
http://audacity.sourceforge.net/
)
或类似的软件来清理声音。
要开始录制音效,你需要一个麦克风。您可以使用带有 2.5 毫米插孔的标准麦克风,插入声卡的麦克风插孔,也可以使用专门为计算机设计的 USB 麦克风。这两种话筒都能提供良好的效果,但最好不要使用头戴式话筒,因为它们针对录制语音而非一般音效进行了优化。
除了麦克风,你还需要软件来采集声音并保存到你的硬盘上。大多数操作系统都带有一个可以录制声音的基本程序,但你可能会从其他声音软件中获得更好的结果,如 Audacity ( 图 10-3 ),这是一个用于 Windows、Mac OS X 和 Linux 的开源应用。你可以从http://audacity.sourceforge.net
.
下载 Audacity(免费)
图 10-3 。大胆
要 Audacity 录音,请单击录音按钮(红色圆圈)开始录音,然后单击停止按钮(黄色方块)结束录音。然后声音的波形会显示在主窗口中,主窗口中有许多控制,您可以使用它们来检查波形并选择部分波形。
Audacity 有许多你可以用来编辑声音的特性。多个声音可以混合在一起,您可以在它们之间进行剪切和粘贴。您还可以应用各种效果来提高声音质量,或者完全改变它们!
要使用 Audacity 应用声音,请选择您想要更改的音频部分,然后从“效果”菜单中选择一种效果。以下是您可以使用的一些效果:
- 放大— 使声音变大。一般来说,你应该尽可能大声地储存你的声音,不要让削波。当波的振幅大于可储存的范围时,会发生削波,并降低声音的质量。如果波的顶部或底部是一条水平线,就意味着声音被削波了。
- 改变音高— 提高或降低声音。如果你提高一个声音的音调,它听起来就像在氦气中一样,如果你降低音调,它听起来就会更低沉和像上帝一样。改变音高是把一种声音变成另一种声音的好方法。如果你要录下两个金属勺子碰撞的声音,并降低音调,听起来就像是剑碰到了盔甲。
- 回声— 给声音添加回声。添加回声可以让你的效果听起来像是在任何地方,从一个空房间到一个巨大的洞穴。
- 去噪— 如果你不够幸运能够接触到录音棚和专业设备,你的录音可能会有轻微的嘶嘶声,这是由背景噪音以及麦克风和声卡的缺陷造成的。噪音消除效果很好地清理了你的声音。
编辑完音效后,您可以将它们导出为各种其他格式,包括 WAV 和 Ogg。最好保留原始文件,以便在需要时可以将它们导出为不同的格式。
警告从电影中录制声音似乎是为你的游戏获得有趣效果的好方法,但你可能会违反版权法——所以最好避免。
股票音效
你也可以购买 CD 上的音效,或者从网上下载。这些音效是高质量的,因为它们是在录音室制作的,并且经过专业编辑。我个人用过的一个热门网站是 Sounddogs ( http://www.sounddogs.com/
)。他们的 CD 很贵,但是你也可以单独购买,按秒付费。如果你的游戏只需要一打左右的短音效,价格还是比较合理的。
网上也有许多免费音效的来源。Pygame wiki 包含一个页面,列出了一些好的网站(www.pygame.org/wiki/resources
)。你也可以通过搜索网络找到更多好的网站。
用 Pygame 播放声音
可以通过pygame.mixer
界面用 Pygame 播放音效。在你可以使用混音器之前,它必须首先用一些参数初始化,这些参数定义了你将要播放的声音类型。这可以用pygame.mixer.init
函数来完成,但是在某些平台上,混音器是由pygame.init
自动初始化的。Pygame 提供了一个pyame.mixer.pre_init
函数,您可以使用它来设置这个自动初始化的参数。两个初始化函数都采用以下四个参数:
frequency
—这是音频播放的采样率,与声音文件的采样率含义相同。高采样率可能会降低性能,但即使是旧的声卡也可以轻松处理44100
的频率设置,这是 CD 质量。另一个常见的频率设置是22050
,听起来不太好。size
—这是用于回放的音频样本的大小,单位为位。样本量可以是8
或16
。在相同的性能下,16
的值是最佳的,因为音质比8
高得多。这个值也可以是负数,表示混音器应该使用带符号的样本(某些平台要求)。签名样本和未签名样本的音质没有区别。stereo
—该参数应设置为单声道的1
或立体声的2
。建议使用立体声,因为它可以用来制造声音来自屏幕上某个特定点的错觉。buffer
—这是为回放而缓冲的样本的数量。较低的值导致较低的延迟,即要求 Pygame 播放声音和您实际听到声音之间的时间。较高的值会增加延迟,但对于避免声音丢失可能是必要的,因为声音丢失会导致令人讨厌的爆音和咔哒声。我发现一个值4096
最适合 44100,16 位立体声。该值必须始终是 2 的幂。
以下是如何初始化 16 位、44100Hz 立体声的混音器:
pygame.mixer.pre_init(44100, 16, 2, 4096)
pygame.init()
对pygame.mixer.pre_init
的调用设置混音器的参数,该参数在对pygame.init
的调用中初始化。如果你需要在 Pygame 初始化后更改任何参数,你必须在调用pygame.mixer.init
重新初始化之前,调用pygame.mixer.quit
退出混合器。
声音对象
Sounds 对象用于存储和播放从 WAV 或 Ogg 文件中读取的音频数据。您可以用pygame.mixer.Sound
构造一个Sound
对象,它接受声音文件的文件名,或者一个包含数据的 Python file
对象。下面是如何从硬盘上加载一个名为phaser.ogg
的声音文件:
phaser_sound = Pygame.mixer.Sound("phaser.ogg")
您可以用play
方法播放一个Sound
对象,该方法有两个可选参数:loop
和maxtime
。为loop
设置一个值会使声音在首次播放后重复播放。例如,如果loop
设置为5
,声音将播放完毕,然后重复 5 次(共 6 次)。您也可以将loop
设置为–1
的特殊值,这将导致声音连续播放,直到您调用stop
方法。
maxtime
参数用于在给定的毫秒数后停止回放,这对于设计为循环播放(连续播放)的声音很有用,因为您可以精确指定它们将播放多长时间。
如果对play
的调用成功,它将返回一个Channel
对象(见下一节);否则,它将返回None
。以下是一次性播放相位器声音的方法:
channel = phaser_sound.play()
这一行将播放一个五秒钟长的相位炮射击:
channel = phaser_sound.play(–1, 5000)
关于Sound
对象方法的完整列表,参见表 10-1 。
表 10-1 。声音对象的方法
|
方法
|
目的
|
| — | — |
| fadeout
| 逐渐降低所有频道的音量。fadeout
采用单个参数,即以毫秒为单位的渐变长度。 |
| get_length
| 返回声音的长度,以秒为单位。 |
| get_num_channels
| 计算声音播放的次数。 |
| get_volume
| 以介于 0.0 和 1.0 之间的浮点数形式返回声音的音量,其中 0.0 表示静音,1.0 表示最大音量。 |
| play
| 播放声音。请参阅“声音对象”一节了解参数的描述。返回值是一个Channel
对象,如果 Pygame 无法播放声音,则为 None。 |
| set_volume
| 设置播放声音时的音量。该参数是一个介于 0.0 和 1.0 之间的浮点数,其中 0.0 表示静音,1.0 表示全音量。 |
| stop
| 立即停止声音播放。 |
声音频道
声道是由声卡混合在一起的几个声源之一,在 Pygame 中由Channel
对象表示。Sound
对象的play
方法为将播放声音的通道返回一个Channel
对象,如果所有通道都忙于播放,则返回None
。您可以通过调用pygame.mixer.get_num_channels
功能来检索可用频道的数量。如果您发现您没有足够的通道来播放您需要的所有声音,您可以通过调用pygame.mixer.set_num_channels
函数来创建更多的通道。
如果您只想以最大音量播放声音,可以放心地忽略Sound.play
的返回值。否则,您可以使用Channel
对象的方法创建一些有用的效果。其中最有用的一个功能是独立设置左右扬声器的音量,这可以用来创造一种声音来自屏幕上特定点的幻觉——这种效果被称为立体声平移 。Channel
对象的set_volume
方法可以接受两个参数:左扬声器的音量和右扬声器的音量,都是 0 到 1 之间的值。清单 10-1 显示了一个函数,它在给定发声事件的 x 坐标和屏幕宽度的情况下计算扬声器的音量。x 坐标离扬声器越远,音量就越低,所以当一个点从左到右在屏幕上移动时,左扬声器将降低音量,而右扬声器将提高音量。
清单 10-1 。计算立体声平移的函数
def stereo_pan(x_coord, screen_width):
right_volume = float(x_coord) / screen_width
left_volume = 1.0 - right_volume
return (left_volume, right_volume)
清单 10-2 展示了如何使用stereo_pan
函数来播放一个爆炸坦克的声音效果。图 10-4 显示了爆炸的位置如何与左右声道的值相关联。
清单 10-2 。使用立体声 _ 声相功能
tank.explode() # Do explosion visual
explosion_channel = explosion_sound.play()
if explosion_channel is not None:
left, right = stereo_pan(tank.position.x, SCREEN_SIZE[0])
explosion_channel.set_volume(left, right)
图 10-4 。设定爆炸的立体声声相
提示如果你为一个移动的精灵更新每一帧的立体声平移,它将增强立体声效果。
一般来说,最好将选择频道的任务留给 Pygame,但是可以通过调用Channel
对象的play
方法来强制Sound
对象通过特定的频道播放,该方法获取您想要播放的声音,后跟您想要它重复的次数以及您想要它播放的最长时间。这样做的一个原因是为高优先级声音保留一个或多个通道。例如,您可能不希望背景环境噪声阻挡玩家的枪声。要保留多个通道,调用pygame.mixer.set_reserved
函数,这可防止多个通道被Sound.play
方法考虑。例如,如果您调用pygame.mixer.set_reserved(2)
,Pygame 在从Sound
对象调用play
时将不会选择通道0
或1
。清单 10-3 显示了如何保留前两个频道。
清单 10-3 。保留频道
pygame.mixer.set_reserved(2)
reserved_channel_0 = pygame.mixer.Channel(0)
reserved_channel_1 = pygame.mixer.Channel(1)
下面是如何通过一个保留的频道强制播放声音:
reserved_channel_1.play(gunfire_sound)
关于Channel
对象方法的完整列表,参见表 10-2 。
表 10-2 。通道对象的方法
|
方法
|
目的
|
| — | — |
| fadeout
| 在一段时间内渐隐(降低音量)声音,以毫秒为单位。 |
| get_busy
| 如果频道上正在播放声音,则返回 True。 |
| get_endevent
| 返回声音结束播放时将发送的事件,如果没有设置结束事件,则返回 NOEVENT。 |
| get_queue
| 返回任何排队等待播放的声音,如果没有排队声音,则返回 None。 |
| get_volume
| 以介于 0.0 和 1.0 之间的单个值检索通道的当前音量(不考虑 set_volume 设置的立体声音量)。 |
| pause
| 暂时暂停播放此频道上的任何声音。 |
| play
| 在特定频道播放声音。接受 Sound 对象和可选的循环和最大时间值,它们与 Sound.play 的含义相同 |
| queue
| 当前声音结束时播放给定的声音。获取要排队的声音对象。 |
| set_endevent
| 当当前声音播放完毕时请求事件。获取要发送的事件的 id,它应该在 USEREVENT 之上(pygame.locals
中的常量),以避免与现有事件冲突。如果没有给定参数,Pygame 将停止发送结束事件。 |
| set_volume
| 设置该频道的音量。如果给定一个值,它将用于两个扬声器。如果给定两个值,则独立设置左右扬声器音量。两种方法都将音量作为 0.0 到 1.0 之间的值,其中 0.0 是无声的,1.0 是最大音量。 |
| stop
| 立即停止播放频道上的任何声音。 |
| unpause
| 继续播放暂停的频道。 |
混合器功能
我们已经在pygame.mixer
模块中介绍了许多功能。表 10-3 给出了它们的完整列表。
表 10-3 。pygame.mixer 中的函数
|
功能
|
目的
|
| — | — |
| pygame.mixer.Channel
| 为给定的通道索引创建通道对象。 |
| pygame.mixer.fadeout
| 逐渐将所有通道的音量降低到 0。采用渐变时间(以毫秒为单位)。 |
| pygame.mixer.find_channel
| 查找当前未使用的频道并返回其索引。 |
| pygame.mixer.get_busy
| 如果正在播放声音(在任何频道上),则返回 True。 |
| pygame.mixer.get_init
| 如果混合器已经初始化,则返回 True。 |
| pygame.mixer.get_num_channels
| 检索可用频道的数量。 |
| pygame.mixer.init
| 初始化混音器模块。有关参数的说明,请参见本节的开头部分。 |
| pygame.mixer.pause
| 暂时停止所有频道的声音播放。 |
| pygame.mixer.pre_init
| 当调音台通过调用 pygame.init 自动初始化时,设置调音台的参数。 |
| pygame.mixer.quit
| 退出混音器。这是在 Python 脚本结束时自动完成的,但是如果您想用不同的参数重新初始化混合器,您可能需要调用它。 |
| pygame.mixer.set_num_channels
| 设置可用频道的数量。 |
| pygame.mixer.Sound
| 创建声音对象。获取包含声音数据的文件名或 Python 文件对象。 |
| pygame.mixer.stop
| 停止所有频道的声音播放。 |
| pygame.mixer.unpause
| 继续播放暂停的声音(请参见 pygame.mixer.pause)。 |
聆听调音台的运转
让我们写一个脚本,在 Pygame 中试验音效。如果你运行清单 10-4 中的,你会看到一个带有鼠标光标的白屏。点击屏幕上的任意位置,抛出一个银球,银球在重力作用下下落,当银球从屏幕边缘或底部反弹时,会播放声音效果(参见图 10-5 )。
清单 10-4 。调音台运行中(bouncesound.py)
import pygame
from pygame.locals import *
from random import randint
from gameobjects.vector2 import Vector2
SCREEN_SIZE = (640, 480)
# In pixels per second, per second
GRAVITY = 250.0
# Increase for more bounciness, but don't go over 1!
BOUNCINESS = 0.7
def stero_pan(x_coord, screen_width):
right_volume = float(x_coord) / screen_width
left_volume = 1.0 - right_volume
return (left_volume, right_volume)
class Ball(object):
def __init__(self, position, speed, image, bounce:sound):
self.position = Vector2(position)
self.speed = Vector2(speed)
self.image = image
self.bounce:sound = bounce:sound
self.age = 0.0
def update(self, time_passed):
w, h = self.image.get_size()
screen_width, screen_height = SCREEN_SIZE
x, y = self.position
x -= w/2
y -= h/2
# Has the ball bounce
bounce = False
# Has the ball hit the bottom of the screen?
if y + h >= screen_height:
self.speed.y = -self.speed.y * BOUNCINESS
self.position.y = screen_height - h / 2.0 - 1.0
bounce = True
# Has the ball hit the left of the screen?
if x <= 0:
self.speed.x = -self.speed.x * BOUNCINESS
self.position.x = w / 2.0 + 1
bounce = True
# Has the ball hit the right of the screen
elif x + w >= screen_width:
self.speed.x = -self.speed.x * BOUNCINESS
self.position.x = screen_width - w / 2.0 - 1
bounce = True
# Do time based movement
self.position += self.speed * time_passed
# Add gravity
self.speed.y += time_passed * GRAVITY
if bounce:
self.play_bounce:sound()
self.age += time_passed
def play_bounce:sound(self):
channel = self.bounce:sound.play()
if channel is not None:
# Get the left and right volumes
left, right = stero_pan(self.position.x, SCREEN_SIZE[0])
channel.set_volume(left, right)
def render(self, surface):
# Draw the sprite center at self.position
w, h = self.image.get_size()
x, y = self.position
x -= w/2
y -= h/2
surface.blit(self.image, (x, y))
def run():
# Initialise 44KHz 16-bit stero sound
pygame.mixer.pre_init(44100, 16, 2, 1024*4)
pygame.init()
pygame.mixer.set_num_channels(8)
screen = pygame.display.set_mode(SCREEN_SIZE, 0)
print(pygame.display.get_wm_info())
hwnd = pygame.display.get_wm_info()["window"]
x, y = (200, 200)
pygame.mouse.set_visible(False)
clock = pygame.time.Clock()
ball_image = pygame.image.load("ball.png").convert_alpha()
mouse_image = pygame.image.load("mousecursor.png").convert_alpha()
# Load the sound file
bounce:sound = pygame.mixer.Sound("bounce.wav")
balls = []
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
if event.type == MOUSEBUTTONDOWN:
# Create a new ball at the mouse position
random_speed = ( randint(-400, 400), randint(-300, 0) )
new_ball = Ball( event.pos,
random_speed,
ball_image,
bounce:sound )
balls.append(new_ball)
time_passed_seconds = clock.tick() / 1000.
screen.fill((255, 255, 255))
dead_balls = []
for ball in balls:
ball.update(time_passed_seconds)
ball.render(screen)
# Make not of any balls that are older than 10 seconds
if ball.age > 10.0:
dead_balls.append(ball)
# remove any 'dead' balls from the main list
for ball in dead_balls:
balls.remove(ball)
# Draw the mouse cursor
mouse_pos = pygame.mouse.get_pos()
screen.blit(mouse_image, mouse_pos)
pygame.display.update()
if __name__ == "__main__":
run()
图 10-5 。布朗森. py
当Ball
类的update
方法检测到精灵碰到了屏幕的边缘或底部时,它反转精灵的方向并调用Sound
对象的play
方法。精灵的 x 坐标用于计算左右扬声器的音量,以便声音看起来是从精灵碰到屏幕边界的点发出的。效果相当有说服力——如果你闭上眼睛,你应该还是能分辨出球弹向哪里!
如果你通过快速点击鼠标创建了很多精灵,你可能会发现一些反弹停止产生声音效果。发生这种情况是因为所有可用的频道都被用于播放相同的声音效果,而新的声音只能在频道空闲时播放。
用 Pygame 玩音乐
虽然pygame.mixer
模块可以播放任何类型的音频,但通常不建议将其用于音乐,因为音乐文件往往很大,会对计算机资源造成压力。Pygame 提供了一个名为pygame.mixer.music
的pygame.mixer
子模块,可以一次读取(并播放)一段音乐文件,而不是一次读取全部,这就是所谓的音频流。
注音乐可能是
pygame.mixer.music
最常见的用途,但你可以用它来流式传输任何大型音频文件,比如画外音音轨。
获取音乐
如果你不够幸运,没有音乐天赋,你可能不得不用别人创作的音乐。有很多股票音乐网站,你可以购买游戏中使用的音乐文件,或者免费下载。你可以在 Pygame wiki ( http://www.pygame.org/wiki/resources
)上找到一些商业和免费音乐网站。
警告从你的音乐收藏中选择几首曲目,让你最喜欢的乐队作为游戏的配乐,这可能很诱人,但这违反了版权法,应该避免!
播放音乐
pygame.mixer.music
模块可以播放 MP3 和 Ogg 音乐文件。对 MP3 的支持因平台而异,所以最好使用 Ogg 格式,它在各种平台上都受到很好的支持。您可以使用 Audacity 或其他声音编辑应用将 MP3 转换为 Ogg。
要播放一个音乐文件,首先用你想要播放的音乐曲目的文件名调用pygame.mixer.music.load
函数,然后调用pygame.mixer.music.play
开始播放(参见清单 10-5 )。你想用这个模块做的任何事情都可以用pygame.mixer.music
模块来完成(没有Music
对象,因为一次只能传输一个音乐文件)。有停止、倒带、暂停、设置音量等功能。完整列表见表 10-4 。
假设您有一个文件名 techno.ogg(参见使用 Audacity 将音乐转换成。ogg),您可以播放这样的音乐:
清单 10-5 。播放音乐文件
pygame.mixer.music.load("techno.ogg")
pygame.mixer.music.play()
表 10-4 。pygame.mixer.music 函数
|
功能
|
目的
|
| — | — |
| pygame.mixer.get_busy
| 如果音乐正在播放,则返回 True。 |
| pygame.mixer.music.fadeout
| 在一段时间内减少音量。以毫秒为单位计算渐变时间。 |
| pygame.mixer.music.get_endevent
| 返回要发送的结束事件,如果没有事件,则返回 0。 |
| pygame.mixer.music.get_volume
| 返回音乐的音量;参见 set_volume。 |
| pygame.mixer.music.load
| 加载音乐文件进行播放。获取音频文件的文件名。 |
| pygame.mixer.music.play
| 开始播放载入的音乐文件。开始播放音乐后,您想要音乐重复播放的次数,后跟您想要开始回放的点(以秒为单位)。如果将第一个值设置为–1,它将一直重复,直到您调用 pygame.mixer.stop。 |
| pygame.mixer.music.rewind
| 从头开始播放音乐文件。 |
| pygame.mixer.music.set_endevent
| 请求在音乐播放完毕时发送一个事件。获取要发送的事件的 id,该 id 应该在 user event(py game . locals 中的常量)之上,以避免与现有事件冲突。如果没有给定参数,Pygame 将停止发送结束事件。 |
| pygame.mixer.music.set_volume
| 设置音乐的音量。将音量作为 0.0 到 1.0 之间的值,其中 0.0 表示静音,1.0 表示最大音量。当加载新音乐时,音量将被重置为 1.0。 |
| pygame.mixer.music.stop
| 停止播放音乐。 |
| pygame.mixer.music.unpause
| 继续播放暂停的音乐。 |
| pygame.muxer.music.get_pos
| 返回音乐播放的时间,以毫秒为单位。 |
| pygame.muxer.music.pause
| 暂时暂停播放音乐。 |
| pygame.muxer.music.queue
| 设置当前音乐结束时播放的曲目。接受一个参数,即您要播放的文件的文件名。 |
在行动中聆听音乐
让我们使用pygame.mixer.music
模块创建一个简单的点唱机。清单 10-6 从硬盘上的一个路径读入一个 Ogg 文件列表,并显示一些熟悉的类似高保真的按钮,你可以用它们来播放、暂停或停止音乐,并在曲目列表中移动(见图 10-6 )。如果您更改列表顶部的MUSIC_PATH
值,您可以让它从您自己的收藏中播放。
清单 10-6 。Pygame 点唱机(jukebox.py)
import pygame
from pygame.locals import *
from math import sqrt
import os
import os.path
# Location of music on your computer
MUSIC_PATH = "./MUSIC"
SCREEN_SIZE = (800, 600)
def get_music(path):
# Get the filenames in a folder
raw_filenames = os.listdir(path)
music_files = []
for filename in raw_filenames:
# We only want ogg files
if filename.endswith('.ogg'):
music_files.append(os.path.join(MUSIC_PATH, filename))
return sorted(music_files)
class Button(object):
def __init__(self, image_filename, position):
self.position = position
self.image = pygame.image.load(image_filename)
def render(self, surface):
# Render at the center
x, y = self.position
w, h = self.image.get_size()
x -= w /2
y -= h / 2
surface.blit(self.image, (x, y))
def is_over(self, point):
# Return True if a point is over the button
point_x, point_y = point
x, y = self.position
w, h = self.image.get_size()
x -= w /2
y -= h / 2
in_x = point_x >= x and point_x < x + w
in_y = point_y >= y and point_y < y + h
return in_x and in_y
def run():
pygame.mixer.pre_init(44100, 16, 2, 1024*4)
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, 0)
default_font = pygame.font.get_default_font()
font = pygame.font.SysFont("default_font", 50, False)
# Create our buttons
x = 100
y = 240
button_width = 150
# Store the buttons in a dictionary, so we can assign them names
buttons = {}
buttons["prev"] = Button("prev.png", (x, y))
buttons["pause"] = Button("pause.png", (x+button_width*1, y))
buttons["stop"] = Button("stop.png", (x+button_width*2, y))
buttons["play"] = Button("play.png", (x+button_width*3, y))
buttons["next"] = Button("next.png", (x+button_width*4, y))
music_filenames = get_music(MUSIC_PATH)
if len(music_filenames) == 0:
print("No OGG files found in ", MUSIC_PATH)
return
white = (255, 255, 255)
label_surfaces = []
# Render the track names
for filename in music_filenames:
txt = os.path.split(filename)[-1]
print("Track:", txt)
txt = txt.split('.')[0]
surface = font.render(txt, True, (100, 0, 100))
label_surfaces.append(surface)
current_track = 0
max_tracks = len(music_filenames)
pygame.mixer.music.load( music_filenames[current_track] )
clock = pygame.time.Clock()
playing = False
paused = False
# This event is sent when a music track ends
TRACK_END = USEREVENT + 1
pygame.mixer.music.set_endevent(TRACK_END)
while True:
button_pressed = None
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
if event.type == MOUSEBUTTONDOWN:
# Find the pressed button
for button_name, button in buttons.items():
if button.is_over(event.pos):
print(button_name, "pressed")
button_pressed = button_name
break
if event.type == TRACK_END:
# If the track has ended, simulate pressing the next button
button_pressed = "next"
if button_pressed is not None:
if button_pressed == "next":
current_track = (current_track + 1) % max_tracks
pygame.mixer.music.load( music_filenames[current_track] )
if playing:
pygame.mixer.music.play()
elif button_pressed == "prev":
# If the track has been playing for more that 3 seconds,
# rewind i, otherwise select the previous track
if pygame.mixer.music.get_pos() > 3000:
pygame.mixer.music.stop()
pygame.mixer.music.play()
else:
current_track = (current_track - 1) % max_tracks
pygame.mixer.music.load( music_filenames[current_track] )
if playing:
pygame.mixer.music.play()
elif button_pressed == "pause":
if paused:
pygame.mixer.music.unpause()
paused = False
else:
pygame.mixer.music.pause()
paused = True
elif button_pressed == "stop":
pygame.mixer.music.stop()
playing = False
elif button_pressed == "play":
if paused:
pygame.mixer.music.unpause()
paused = False
else:
if not playing:
pygame.mixer.music.play()
playing = True
screen.fill(white)
# Render the name of the currently track
label = label_surfaces[current_track]
w, h = label.get_size()
screen_w = SCREEN_SIZE[0]
screen.blit(label, ((screen_w - w)/2, 450))
# Render all the buttons
for button in list(buttons.values()):
button.render(screen)
# No animation, 5 frames per second is fine!
clock.tick(5)
pygame.display.update()
if __name__ == "__main__":
run()
图 10-6 。点唱机脚本
自动点唱机使用pygame.mixer.set_endevent
功能请求在曲目播放完毕时发送一个事件。Pygame 没有为此提供事件,但是您可以通过使用大于USEREVENT
(pygame.locals
中的常数)的id
值来轻松创建自己的事件。清单 10-6 使用了id TRACK_END
,其值为USEREVENT + 1
。当在主事件循环中检测到TRACK_END
事件时,它开始流式播放下一个音乐文件,以便按顺序播放曲目。
摘要
声音是一种创造性的媒介,它可能需要大量的实验来完善游戏中的音频。选择好的音效至关重要,因为玩家可能会多次听到它们,而糟糕或恼人的音频会很快阻碍进一步的播放。对于配乐来说也是如此,配乐既有增强的潜力,也有让人烦恼的潜力。
pygame.mixer
模块提供了 Pygame 的音效功能,允许你在多个频道中的一个上加载和播放声音文件。当声音通过Sound
对象播放时,Pygame 会自动分配一个空闲通道。这是最简单的方法,但是您可以通过播放特定频道的声音来选择自己管理频道。我建议,只有当你要播放许多声音,并希望对它们进行优先排序时,才这样做。
虽然你可以用Sound
对象播放任何音频,但最好用pygame.mixer.music
模块播放音乐,因为它可以流式传输音频,而不是将整个文件加载到内存中。音乐模块提供了许多简单的功能,您可以使用它们来管理游戏中的音乐。
下一章将介绍更多你可以用来在游戏中创造令人信服的 3D 视觉效果的技术。
十一、灯光,摄像机,开拍!
在第八章和第九章中,你学习了如何操作 3D 信息和使用 OpenGL 显示简单的模型。在这一章中,我们将介绍如何使用图像来创建更具视觉吸引力的场景。我们还将讨论如何从文件中读取 3D 模型,这是创建完美游戏的重要一步。
使用纹理
在第九章中,你学习了如何从阴影多边形创建一个 3D 模型,但是为了让一个物体看起来更有说服力,你需要使用纹理,这是被拉伸成多边形的图像。顶点和多边形创建模型的形状,但是纹理定义任何 3D 对象的最终外观。你可以把一个未来士兵的模型变成一个僵尸,甚至是一个雕像,只要简单地改变它的纹理所使用的图像。
OpenGL 对纹理有极好的支持,可以用来在游戏中创建高度细节化的模型和场景。
用 OpenGL 上传纹理
在你可以使用图像作为 OpenGL 的纹理之前,你首先必须上传它。这将获取原始图像信息,并将其发送到图形卡的高速视频内存。用作纹理的图像大小必须至少为 64 x 64 像素,宽度和高度必须是 2 的幂(64、128、256 等。).如果你的图像不是 2 的幂,你应该调整它以适应下一个 2 的幂。清单 11-1 是一个给定一个维度,可以计算 2 的下一次幂的函数。
清单 11-1 。计算 2 的下一次幂
from math import log, ceil
def next_power_of_2(size):
return 2 ** ceil(log(size, 2))
注意一些显卡确实能够使用不是 2 的幂的纹理,但是为了最大的兼容性,最好坚持 2 的幂。
纹理大小的上限因显卡而异。可以通过调用glGetIntegerv(GL_MAX_TEXTURE_SIZE)
向 OpenGL 询问支持的最大纹理大小。在我的电脑上,它返回了16384
,这的确是一个非常大的纹理!
以下列表概述了在 OpenGL 中上传图像数据和创建纹理所需的步骤:
- 用
pygame.image.load
加载图像。 - 使用
pygame.image.tostring
功能检索包含原始图像数据的字符串。 - 用
glGenTextures
生成一个id
,可以用来识别纹理。 - 设置渲染多边形时影响纹理使用方式的任何参数。
- 使用
glTexImage2D
功能上传原始图像数据。
加载图像的方式与在 2D 游戏中加载图像的方式相同——只需用图像文件名调用pygame.image.load
。例如,以下代码行加载一个名为sushitex.png
的图像并返回一个表面:
texture_surface = pygame.image.load("sushitex.png")
一旦您有了包含您的图像的 Pygame 表面,调用pygame.image.tostring
来检索包含原始图像数据的字符串。pygame.image.tostring
的第一个参数是表面;第二个应该是RGB
对于不透明的图像,或者RGBA
如果图像有一个 alpha 通道。最后一个参数应该设置为True
,它告诉 py game翻转返回数据中的行(否则,纹理会颠倒)。以下代码行检索我们之前加载的表面的原始图像数据:
texture_data = pygame.image.tostring(texture_surface, 'RGB', True)
OpenGL 中的每个纹理都必须用glGenTextures
函数分配一个id
值(一个数字)。这些id
用于选择绘制 OpenGL 图元时使用的纹理。glGenTextures
函数获取您想要生成的id
的数量,并返回一个 ID 值列表,或者如果您只需要一个值,则返回一个值。纹理id
s 一般是顺序分配的(所以前三个纹理很可能会被命名为 1,2,3),但情况可能并不总是这样。
注意纹理
id
s 是整数值,而不是物体。删除纹理id
,或者让它超出范围,都不会清理纹理。你应该手动删除你不想再使用的纹理(稍后见“删除纹理”一节)。
一旦你指定了一个纹理id
,调用glBindTexture
来告诉 OpenGL 所有后续的纹理相关函数都应该使用这个纹理。glBindTexture
函数有两个参数;第一个参数应该是标准 2D 纹理的GL_TEXTURE_2D
,第二个参数是你想要绑定的id
。下面两行为sushitex.png
纹理创建了一个单独的id
,并且绑定以供使用:
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
在上传图像数据之前,我们需要设置几个纹理参数,它们是影响 OpenGL 在渲染时如何使用纹理的值。要设置纹理参数,请调用glTexParameteri
获取整数参数,或glTexParameterf
获取浮点参数。两个函数都采用相同的三个参数;纹理类型(GL_TEXTURE_2D
用于标准 2D 纹理),后跟要设置的参数名称和要设置的值。大多数参数是可选的,如果您不更改它们,它们将使用默认值。
我们需要为所有纹理设置的两个参数是GL_TEXTURE_MIN_FILTER
和 GL_TEXTURE_MAX_FILTER
,,它们告诉 OpenGL 应该使用什么方法来放大或缩小纹理。当纹理像素(通常称为纹理像素)小于屏幕像素时,使用GL_TEXTURE_MIN_FILTER
参数。当纹理元素变得比像素大时,使用参数。下面两行将两个参数都设置为GL_LINEAR
,这使得缩放后的纹理看起来平滑,不那么像素化。稍后我们将讨论其他可能的值。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
接下来我们需要调用glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
,它告诉 OpenGL 原始图像数据中的行是如何被打包在一起的,然后调用glTexImage2D
上传图像数据。下面几行上传了我们之前检索到的texture_data
。一旦这一步完成,纹理就可以用来绘制三角形、四边形和任何其他 OpenGL 图元。
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
width, height = texture_surface.get_rect().size
glTexImage2D( GL_TEXTURE_2D,
0, # First mip-level
3, # Bytes per pixel
width,
height,
0, # Texture border
GL_RGB,
GL_UNSIGNED_BYTE,
texture_data)
glTexImage2D
有很多参数,因为 OpenGL 被设计成在它支持的图像格式中非常灵活。幸运的是,当从 Pygame 表面上传纹理数据时,我们通常可以坚持使用我们之前描述的参数,尽管对于带有 alpha 通道的表面,你应该将3
改为4
,将GL_RGB
改为GL_RGBA
。关于glTexImage2D
参数的更详细说明,参见表 11-1 。
表 11-1 。glTexImage2D 的参数(按函数调用顺序)
|
参数
|
说明
|
| — | — |
| target
| 通常将be GL_TEXTURE_2D
用于图像纹理。OpenGL 支持一维和三维纹理,但这些在游戏中不太常用。 |
| level
| mip 映射级别,其中 0 是第一个(最大)级别,1 是下一个级别。我一会儿将解释 mip 地图。 |
| internalFormat
| 指示数据如何存储在视频存储器中。一般来说,这是不透明图像的3
和带有 alpha 通道的图像的4
,但它也接受其他格式的常量。 |
| width
| 图像的宽度,以像素为单位。 |
| height
| 图像的高度,以像素为单位。 |
| border
| 设置纹理的边框。可以是用于边框的0
,也可以是用于单像素边框的1
。 |
| format
| 要上传的图像数据的格式。这通常是不透明图像的GL_RGB
,或者带有 alpha 通道的图像的GL_RGBA
。 |
| type
| 指定图像组件的存储方式;通常这是GL_UNSIGNED_BYTE
,用于从 Pygame 表面检索的图像数据。 |
| data
| 包含原始图像数据的字符串。 |
一旦上传了纹理,就不再需要原始的 Pygame 表面对象,因为 OpenGL 有它的副本。您可以让它超出范围,让 Python 为您清理,或者调用del
删除引用。显式总是比隐式好,所以这里应该用 del,但是两种方式都可以。
纹理坐标
当在 OpenGL 中启用纹理时,可以为每个顶点分配一个纹理坐标,该坐标定义了纹理中的一个位置。当一个有纹理的多边形被渲染到屏幕上时,纹理将在这些坐标之间被拉伸。
OpenGL 使用归一化的纹理坐标,这意味着无论纹理图像中有多少个像素,纹理的宽度和高度始终为 1.0(见图 11-1 )。所以纹理坐标(0.5,0.5)总是在纹理的中心。纹理的左下方是坐标(0,0),右上方是坐标(1,1)。使用标准化坐标的优点是,如果改变图像的尺寸,它们将保持不变。
图 11-1 。纹理坐标
纹理坐标的存储方式与二维向量的存储方式相同:要么是元组,要么是Vector2
对象。在 OpenGL 中,纹理坐标的组成部分被称为 s 和 t,但为了方便起见,您仍然可以使用一个Vector2
对象,并将这些组成部分称为 x 和 y。不过,一般来说,除了存储它们并根据需要将它们发送到 OpenGL 之外,您很少需要对纹理坐标做任何事情,因此元组通常是最佳选择。
注意一些 3D APIs、工具、书籍将纹理坐标称为 u 和 v,而不是 s 和 t
渲染纹理
要在渲染多边形或任何其他 OpenGL 图元时使用纹理,您必须使用glTexCoord2f
函数为每个顶点提供纹理坐标,该函数将 s 和 t 组件作为参数。图元将使用用glBindTexture
函数绑定的最后一个纹理来绘制。
下面的代码绘制了一个与 x 和 y 轴对齐的四边形。它对四个角使用纹理坐标,这样整个图像将在四边形中绘制。
# Draw a quad (4 vertices, 4 texture coords)
glBegin(GL_QUADS)
# Top left corner
glTexCoord2f(0, 1)
glVertex3f(-100, 100, 0)
# Top right corner
glTexCoord2f(1, 1)
glVertex3f(100, 100, 0)
# Bottom right corner
glTexCoord2f(1, 0)
glVertex3f(100, -100, 0)
# Bottom left corner
glTexCoord2f(0, 0)
glVertex3f(-100, -100, 0)
glEnd()
删除纹理
每个纹理都会耗尽视频内存,这是一种有限的资源。当你完成一个纹理(切换等级,退出游戏,等等。),您应该通过调用glDeleteTextures
来删除它,以释放它的视频内存。这个函数取你想要删除的id
,或者一个id
的列表,一旦纹理被删除了,再去绑定它就是一个错误,所以你要丢弃id
的值。
下面一行删除了一个名为texture_id
的纹理 ID:
glDeleteTextures(texture_id)
查看纹理运行情况
让我们写一个脚本来演示上传一个纹理并在渲染一个 OpenGL 图元时使用它。我们将绘制一个纹理四边形,为了表明它不仅仅是一个精灵,我们将围绕 x 轴旋转它。
在清单 11-2 的init
函数中是对glEnable(GL_TEXTURE_2D)
的调用,它启用 OpenGL 纹理。如果你在让纹理在 OpenGL 中工作时遇到问题,检查一下你是否已经调用了这个函数。
清单 11-2 。运行中的纹理(opengltex.py)
from math import radians
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *
SCREEN_SIZE = (800, 600)
def resize(width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(60.0, float(width)/height, .1, 1000.)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
def init():
glEnable(GL_TEXTURE_2D)
glClearColor(1.0, 1.0, 1.0, 0.0)
def run():
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)
resize(*SCREEN_SIZE)
init()
# Load the textures
texture_surface = pygame.image.load("sushitex.png")
# Retrieve the texture data
texture_data = pygame.image.tostring(texture_surface, 'RGB', True)
# Generate a texture id
texture_id = glGenTextures(1)
# Tell OpenGL we will be using this texture id for texture operations
glBindTexture(GL_TEXTURE_2D, texture_id)
# Tell OpenGL how to scale images
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR )
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR )
# Tell OpenGL that data is aligned to byte boundries
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
# Get the dimensions of the image
width, height = texture_surface.get_rect().size
# Upload the image to OpenGL
glTexImage2D( GL_TEXTURE_2D,
0,
3,
width,
height,
0,
GL_RGB,
GL_UNSIGNED_BYTE,
texture_data)
clock = pygame.time.Clock()
tex_rotation = 0.0
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
time_passed = clock.tick()
time_passed_seconds = time_passed / 1000.
tex_rotation += time_passed_seconds * 360.0 / 8.0
# Clear the screen (similar to fill)
glClear(GL_COLOR_BUFFER_BIT)
# Clear the model-view matrix
glLoadIdentity()
# Set the modelview matrix
glTranslatef(0.0, 0.0, -600.0)
glRotate(tex_rotation, 1, 0, 0)
# Draw a quad (4 vertices, 4 texture coords)
glBegin(GL_QUADS)
glTexCoord2f(0, 1)
glVertex3f(-300, 300, 0)
glTexCoord2f(1, 1)
glVertex3f(300, 300, 0)
glTexCoord2f(1, 0)
glVertex3f(300, -300, 0)
glTexCoord2f(0, 0)
glVertex3f(-300, -300, 0)
glEnd()
pygame.display.flip()
glDeleteTextures(texture_id)
if __name__ == "__main__":
run()
run
函数执行读入sushitex.png
图像并将图像数据上传到 OpenGL 所需的五个步骤。然后,它通过向 OpenGL 发送四个纹理坐标和四个顶点来绘制一个纹理四边形。纹理坐标通过glTexcoord2f
函数发送,该函数将s
和t
组件作为参数。glTexcoord2f
的替代方法是glTexCoord2fv
函数,它采用一个序列(tuple、Vector2
等)。)而不是个人价值观。
旋转是通过glRotate
函数完成的,该函数创建一个绕轴的旋转矩阵,并将其乘以当前的模型-视图矩阵。它有四个参数:第一个参数是要旋转的角度(以度为单位),后面是轴的 x、y 和 z 分量。例如,调用glRotate(45, 1, 0, 0)
将围绕 x 轴旋转 45 度,调用glRotate(-30, 0, 1, 0)
将围绕 y 轴旋转负 30 度(即,30 度顺时针)。您还可以使用此功能绕任何轴旋转,而不仅仅是三个基本轴。
当你运行清单 11-2 时,你会看到类似图 11-2 的东西,有一个大的纹理四边形绕着 x 轴旋转。尝试使用纹理坐标值来查看它们对四边形的影响。
图 11-2 。opengltex.py
Mip 映射
当一个多边形中的像素比绘制它的纹理中的像素少时,OpenGL 必须跳过纹理中的一些像素,以使它适合。这会导致渲染的多边形产生扭曲效果,这种效果随着纹理的缩小而恶化。这种失真会分散 3D 动画场景的注意力,降低视觉效果的整体质量。
Mip 映射 就是一种将这种影响降到最低的技术。它的工作原理是预先计算逐渐变小的纹理版本,每个版本的尺寸都是前一个纹理的一半(见图 11-3 )。例如,一个 256 x 256 像素的纹理也将有一个 128 x128 像素的版本,然后是一个 64 x64 像素的版本,以及更小的纹理版本,其中每个版本的大小是其前面纹理的一半,一直到最终纹理(它是原始纹理中所有像素的平均单个像素)。当 OpenGL 使用 mip 映射纹理渲染多边形时,它将使用最接近屏幕上多边形大小的 mip 级别,这减少了跳过的像素数量并提高了视觉质量。
图 11-3 。Mip 地图
注 Mip 是 parvo 中 multum 的首字母缩略词,拉丁语意为“小空间中的多”
您可以通过将第二个参数设置为glTexImage2D
来上传每个 mip 级别的图像数据。原始的——也是最大的——纹理是 mip 级别的0
。第一个 mip 等级是1
,应该是原来的一半尺寸,第二个 mip 等级是2
,应该是原来的四分之一尺寸。
因为计算 mip 级别是一项常见任务,所以我们可以使用 OpenGL 工具库(OpenGL.GLU
)中的一个函数在一次调用中创建并上传所有 mip 级别。除了level
和border
(不需要)之外,gluBuild2DMipmaps
函数可以代替对glTexImage2D
的调用,并采用相同的参数。例如,下面的调用可以替换对清单 11-2 中glTexImage2D
的调用(完整示例参见源代码文档中的 11-2.1.py):
gluBuild2DMipmaps( GL_TEXTURE_2D,
3,
width,
height,
GL_RGB,
GL_UNSIGNED_BYTE,
texture_data )
使用 mip 贴图纹理的唯一缺点是它们比非 mip 贴图纹理多使用三分之一的视频内存,但是视觉质量的提高是值得的。我建议对 3D 场景中使用的所有纹理使用 mip 贴图。对于未缩放的纹理,例如字体或平视显示器,您不需要使用 mip 贴图。
纹理参数
OpenGL 在渲染 3D 场景的方式上非常灵活。可以设置许多纹理参数来创建视觉效果,并调整使用纹理渲染多边形的方式。本节涵盖了一些常用的纹理参数——完整的列表超出了本书的范围,但是你可以在线阅读 OpenGL 文档(https://www.opengl.org/sdk/docs/HYPERLINK``http://www.opengl.org/sdk/docs/man/)”``)
了解所有细节)。
最小和最大过滤器
在清单 11-2 中,我们设置了两个纹理参数GL_TEXTURE_MIN_FILTER
和GL_TEXTURE_MAX_FILTER
,它们定义了用于纹理缩放的最小化和最大化过滤器。这两个值都被设置为GL_LINEAR
,这在大多数情况下都可以得到很好的结果,但是也可以使用其他值来微调缩放比例。
当 OpenGL 将纹理多边形渲染到屏幕上时,它会定期对纹理进行采样,以计算多边形中像素的颜色。如果纹理是 mip 映射的(见我们前面的讨论),OpenGL 也必须决定它应该采样的 mip 级别。它用来采样纹理和选择 mip 级别的方法由最小化或最大化过滤器参数定义。
您可以为最大化过滤器(GL_TEXTURE_MAX_FILTER
)设置的唯一值是GL_NEAREST
和GL_LINEAR
。通常最好坚持使用GL_LINEAR
,这使得 OpenGL 在缩放时使用双线性过滤 来平滑纹理,但纹理在高比例下会显得模糊。另一种选择是GL_NEAREST
,它看起来更锐利,但却是块状的。这些值也受最小化过滤器(GL_TEXTURE_MIN_FILTER
)支持,此外还有其他四个常量告诉 OpenGL 如何在颜色计算中包括 mip 级别(见表 11-2 )。最小化过滤器的最高质量设置是GL_LINEAR_MIPMAP_LINEAR
,它像GL_LINEAR
一样柔化纹理,但也在两个最接近的 mip 级别之间混合(称为三线性过滤 )。下面几行将最小化和最大化过滤器方法设置为最高质量的设置(本示例的完整代码在源代码文档的 11-2.2.py 中):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
表 11-2 。最小和最大过滤器参数的潜在值
|
参数常数
|
样本效应
|
| — | — |
| GL_NEAREST
| 选择离采样点最近的纹理元素。这使纹理看起来清晰,但可能会出现低质量。 |
| GL_LINEAR
| 选择最接近采样点的四个纹理元素,并在它们之间混合。这会平滑渲染的纹理,并使其在缩放时看起来不那么锯齿状。 |
| GL_NEAREST_MIPMAP_NEAREST
| 选择最接近采样点的纹理元素,以及最接近正在渲染的像素大小的 mip 级别。Minimizing filter only
。 |
| GL_LINEAR_MIPMAP_NEAREST
| 混合最接近采样点的四个纹理元素,并选择最接近正在渲染的像素大小的 mip 级别。Minimizing filter only
。 |
| GL_NEAREST_MIPMAP_LINEAR
| 选择最接近采样点的纹理元素,并在最接近渲染像素大小的两个 mip 级别之间混合。Minimizing filter only
。 |
| GL_LINEAR_MIPMAP_LINEAR
| 在最接近采样点的四个纹理元素之间混合,并在最接近渲染像素大小的两个 mip 级别之间混合。Minimizing filter only
。 |
纹理包装
组件在 0 到 1 范围内的纹理坐标将引用纹理内部的点,但是组件在该范围之外的纹理坐标并不是错误,事实上,它非常有用。OpenGL 如何处理不在 0 到 1 范围内的坐标由GL_TEXTURE_WRAP_S
和GL_TEXTURE_WRAP_T
纹理参数定义。这些参数的默认值是GL_REPEAT
,它使离开纹理一边的样本出现在另一边。这产生了纹理平铺的效果,因此它的多个副本被并排放置。您可以通过编辑清单 11-2 中对的调用来看到这一点。尝试用以下代码替换对glBegin
和glEnd
的调用之间的代码行(参见源代码文档中的 11-2.3.py,了解整个脚本中包含的这段代码):
glTexCoord2f(0, 3)
glVertex3f(-300, 300, 0)
glTexCoord2f(3, 3)
glVertex3f(300, 300, 0)
glTexCoord2f(3, 0)
glVertex3f(300, -300, 0)
glTexCoord2f(0, 0)
glVertex3f(-300, -300, 0)
如果您运行编辑过的清单 11-2 ,您应该会看到类似图 11-4 的内容。只画了一个四边形,但是纹理重复了 9 次,因为纹理成分的范围是从 0 到 3。像这样平铺纹理在游戏中很有用,因为你可以纹理化非常大的多边形,而不必把它们分成更小的块。例如,可以用一个拉长的四边形和一个平铺纹理创建一个长栅栏,这比每个栅栏都用小四边形更有效。
图 11-4 。重复纹理坐标
重复纹理与使用下面的 Python 函数变换每个纹理坐标的组件具有相同的效果(尽管包装是由您的显卡而不是 Python 完成的)。%
符号是模数运算符,它返回除法中的余数,因此% 1.0
将返回一个数的小数部分。
def wrap_repeat(component):
return component % 1.0
提示拼贴最适用于被设计为无缝的纹理——也就是说,如果你将它们并排放置,你就看不到其中一个结束,另一个开始。
包裹参数的另一个设置是GL_MIRRORED_REPEAT
,它与GL_REPEAT
相似,但是在样本点越过纹理边缘时镜像。要查看这一点,请在清单 11-2 的函数中的while True:
行之前插入以下两行,完整示例参见源代码文档中的 11-2.4:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT)
现在当你运行清单 11-2 时,你应该会看到重复的图像在纹理的 s 和 t 轴周围被镜像。镜像重复设置是一种使纹理看起来无缝的好方法,即使它们不是设计出来的。对应于GL_MIRRORED_REPEAT
的 Python 代码如下:
def wrap_mirrored_repeat(component):
if int(component) % 2:
return 1.0 - (component % 1.0)
else:
return component % 1.0
我们要看的最后一个纹理包裹设置是GL_CLAMP_TO_EDGE
,它将纹理成分饱和到 0 到 1 的范围。如果纹理组件超出该范围,它将被设置为 0 或 1,这取决于哪个最接近原始值。使纹理组件饱和的视觉效果是,当 OpenGL 在纹理边界之外采样时,纹理的外部边缘会无限重复。当您的纹理坐标非常接近纹理边缘,但您希望确保 OpenGL 不会对纹理边缘上的点进行采样时,此设置非常有用(如果您启用了GL_LINEAR
过滤器设置,这种情况会发生)。要查看此设置的效果,将对glTexParameteri
的调用更改如下(源代码文档中的完整代码示例见 11-2.5.py):
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
以下函数是GL_CAMP_TO_EDGE
的 Python 等价物:
def wrap_clamp_to_edge(component):
if component > 1.0:
return 1.0
elif component < 0.0:
return 0.0
return component
我们已经将GL_TEXTURE_WRAP_S
和GL_TEXTURE_WRAP_T
的值设置为相同的值,但是如果您需要,它们可以独立设置。例如,要创建一个栅栏四边形,我们可能希望纹理水平重复(在 s 组件上),而不是垂直重复(在 t 组件上)。
尝试使用清单 11-2 中的来看看组合纹理包裹参数的效果。
使用模型
三维模型很少直接在代码中定义。一般来说,它们是从 3D Studio 或 Blender 等 3D 应用生成的文件中读入的。本节介绍 3D 模型如何存储在文件中,以及如何在游戏中读取和显示它们。
存储模型
与图像一样,有多种文件格式可用于存储 3D 模型,每种格式都由不同的制造商生产,具有不同的功能。与图像不同,在为游戏存储模型时,没有明确的赢家,有许多可供选择。
模型可以存储为二进制或文本。二进制格式非常有效地打包信息,并且倾向于生成较小的文件,而文本格式将信息存储为文本行,这样生成的文件较大但更容易处理。
用于存储 3D 信息的文件将至少包含一个顶点列表,还可能包含一个纹理坐标和法线列表。许多还包含关于纹理和照明的附加信息。您不必使用 3D 模型中的所有信息,但是您实现的格式功能越多,它就越接近艺术家创建的模型。
提示很多游戏开发者都会发布他们游戏中使用的 3D 模型格式。你可以用商业游戏中的角色来测试你的游戏——但是要小心违反版权法!
三维模型的 OBJ 格式
为了处理 3D 模型,我们将使用波前 OBJ 格式(扩展.obj
),这是一种已经存在多年的简单文本格式。大多数处理 3D 模型的软件至少能够编写 OBJ 文件,也可能能够读取它们。它们是基于文本的,因此您可以在文本编辑器中打开它们,如 Windows 记事本,或其他平台上的等效工具。
OBJ 文件通常带有一个素材库文件(扩展名为.mtl
),其中包含各种定义模型中多边形应该如何渲染的设置,包括纹理的文件名。
解析 OBJ 文件
OBJ 文件中的每一行都定义了一条信息(顶点,纹理坐标等)。)并由一个或多个由空格分隔的单词组成。第一个词表示该行包含的内容,其余的词是信息本身。例如,下面一行在(–100,50,–20)处定义了一个顶点:
v –100 50 –20
Python 非常擅长读取文本文件,并且具有一次读取一行的内置支持。如果迭代一个打开的文件对象,它将返回一个包含每一行的字符串。下面是我们如何开始编写 Python 代码来解析存储在 OBJ 文件中的 3D 模型:
obj_file = file("tank.obj")
for line in obj_file:
words = line.split() # Split line on spaces
command = words[0]
data = words[1:]
每次通过for
循环都返回文件中的下一行。在循环中,我们将这一行拆分成单词。第一个单词被命名为command
,其余的单词存储在一个名为data
的列表中。一个完整的解析器将基于command
的值选择一个动作,并使用包含在data
列表中的任何信息。
表 11-3 包含了 OBJ 文件中一些常用的命令。还有更多可能的命令,但这些可以用来存储游戏中使用的 3D 模型。我们可以使用表 11-3 中的信息来读取和显示坦克的 3D 模型。
表 11-3 。波前 OBJ 文件中的线
|
线条
|
说明
|
| — | — |
| #
| 表示该行是注释,应被软件忽略。 |
| f <vertex1> <vertex2> etc.
| 定义模型中的面。每个单词由三个用正斜杠分隔的值组成:顶点索引、纹理坐标索引和法线索引。面上的每个点都有三个一组的值(三角形为 3,四边形为 4,等等。). |
| mtllib <filename>
| 指定 OBJ 文件的材质库。 |
| usemtl <material name>
| 从材料库中选择材料。 |
| v <x> <y> <z>
| 在 x,y,z 上定义一个顶点。 |
| vt <s> <t>
| 定义纹理坐标。 |
材料库文件
波前 OBJ 文件通常与包含纹理和照明信息的材质库文件配合使用。当你在 OBJ 文件中遇到一个mtlib
命令时,它会引入一个包含这些额外信息的素材库文件。在命令数据的第一个字中给出了素材库的文件名,并且应该附加扩展名.mtl
。
素材库也是文本文件,可以用与父 OBJ 文件相同的方式进行解析。在素材库文件中,newmtl
命令开始一个新的素材,它包含一些参数。其中一个参数是纹理文件名,它是用map_Kd
命令引入的。例如,材质文件中的以下几行将定义一个名为tankmaterial
的纹理,该纹理的文件名为tanktexture.png
:
newmtl tankmaterial
map_Kd tanktexture.png
观看模型运行
让我们写一个类来加载波前 OBJ 文件,并用 OpenGL 渲染它。我创建了一个未来主义坦克的模型(见图 11-5 ),用 AC3D ( www.inivis.com/
)建造,导出为mytank.obj
和mytank.mtl
。我的艺术技巧有限;请随意用你自己的 3D 物体替换我的模型。您可以使用任何能够导出 OBJ 文件的 3D modeler 软件。
图 11-5 。AC3D 中的未来坦克物体
我们正在构建的类叫做Model3D
,负责读取模型并存储其几何(顶点,纹理坐标,模型等。).它还可以将几何图形发送到 OpenGL 来渲染模型。Model3D
需要存储的内容详见表 11-4 。
表 11-4 。存储在 Model3D 类中的信息
|
名字
|
目的
|
| — | — |
| self.vertices
| 顶点(3D 点)列表,存储为三个值的元组(x、y 和 z)。 |
| self.tex_coords
| 纹理坐标列表,存储为两个值的元组(对于 s 和 t)。 |
| self.normals
| 法线列表,存储为三个值的元组(x、y 和 z)。 |
| self.materials
| 一个Material
对象的字典,这样我们可以在给定材质名称的情况下查找纹理文件名。 |
| self.face:groups
| 将存储每种材质的面的FaceGroup
对象列表。 |
| self.display_list_id
| 一个显示列表id
,我们将使用它来加速 OpenGL 渲染。 |
除了Model3D
类,我们需要定义一个类来存储材质和面组,它们是共享相同材质的多边形。清单 11-3 是Model3D
类的开始。
清单 11-3 。model3d.py 中的类定义
# A few imports we will need later
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
import os.path
class Material(object):
def init (self):
self.name = ""
self.texture_fname = None
self.texture_id = None
class FaceGroup(object):
def init (self):
self.tri_indices = []
self.material_name = ""
class Model3D(object):
def init (self):
self.vertices = []
self.tex_coords = []
self.normals = []
self.materials = {}
self.face:groups = []
# Display list id for quick rendering
self.display_list_id = None
现在我们已经有了基本的类定义,我们可以给Model3D
添加一个方法来打开一个 OBJ 文件并读取内容。在read_obj
方法中(参见清单 11-4 ,我们遍历文件的每一行,并将其解析成一个命令字符串和一个数据列表。许多if
语句决定如何处理存储在data
中的信息。
清单 11-4 。解析 OBJ 文件的方法
def read_obj(self, fname):
current_face:group = None
file_in = open(fname)
for line in file_in:
# Parse command and data from each line
words = line.split()
command = words[0]
data = words[1:]
if command == 'mtllib': # Material library
model_path = os.path.split(fname)[0]
mtllib_path = os.path.join( model_path, data[0] )
self.read_mtllib(mtllib_path)
elif command == 'v': # Vertex
x, y, z = data
vertex = (float(x), float(y), float(z))
self.vertices.append(vertex)
elif command == 'vt': # Texture coordinate
s, t = data
tex_coord = (float(s), float(t))
self.tex_coords.append(tex_coord)
elif command == 'vn': # Normal
x, y, z = data
normal = (float(x), float(y), float(z))
self.normals.append(normal)
elif command == 'usemtl' : # Use material
current_face:group = FaceGroup()
current_face:group.material_name = data[0]
self.face:groups.append( current_face:group )
elif command == 'f':
assert len(data) == 3, "Sorry, only triangles are supported"
# Parse indices from triples
for word in data:
vi, ti, ni = word.split('/')
indices = (int(vi) - 1, int(ti) - 1, int(ni) - 1)
current_face:group.tri_indices.append(indices)
for material in self.materials.values():
model_path = os.path.split(fname)[0]
texture_path = os.path.join(model_path, material.texture_fname)
texture_surface = pygame.image.load(texture_path)
texture_data = pygame.image.tostring(texture_surface, 'RGB', True)
material.texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, material.texture_id)
glTexParameteri( GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER,
GL_LINEAR)
glTexParameteri( GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER,
GL_LINEAR_MIPMAP_LINEAR)
glPixelStorei(GL_UNPACK_ALIGNMENT,1)
width, height = texture_surface.get_rect().size
gluBuild2DMipmaps( GL_TEXTURE_2D,
3,
width,
height,
GL_RGB,
GL_UNSIGNED_BYTE,
texture_data)
OBJ 文件中的第一个命令通常是mtllib
,它告诉我们素材库文件的名称。当遇到这个命令时,我们将素材库的文件名传递给read_mtllib
方法(我们将在后面编写)。
如果命令由几何体(顶点、纹理坐标或法线)组成,它将被转换为浮点值元组,并存储在适当的列表中。例如,行v 10 20 30
将被转换成元组(10, 20, 30)
并附加到self.vertices
。
每组面之前是一个usemtl
命令,它告诉我们后续的面将使用哪种材质。当read_obj
遇到这个命令时,它会创建一个新的FaceGroup
对象来存储随后的材质名称和面信息。
面由f
命令、定义,由面上每个顶点的一个词组成(3 代表三角形,4 代表四边形,等等)。).每个单词包含顶点、纹理坐标和法线列表的索引,由正斜杠字符(/
)分隔。例如,下面的线定义了一个三角形,其中第一个点使用顶点 3、纹理坐标 8 和法线 10。这些索引的三元组存储在当前的面组中,当我们渲染它时,将用于重建模型形状。
按照代码解析 OBJ 文件中的每一行,我们进入一个循环,读取材质字典中的纹理并将它们上传到 OpenGL。我们将使用 mip 映射和高质量纹理缩放。
注意为了简单起见,Model3D 类只处理包含三角形的 OBJ 文件。如果您的模型包含四边形或其他多边形,您将需要使用 3D 建模器将其转换为三角形。
读取材质的方法类似于read_obj
方法,但是更简单,因为我们只对纹理名称感兴趣。材料中还存储了其他信息,但为了简单起见,我们现在忽略它。
在一个素材库文件中,newmtl
命令开始一个新的材质定义,map_Kd
命令设置纹理文件名。read_mtllib
方法(清单 11-5 )提取这些信息并存储在self.materials
字典中。
清单 11-5 。解析素材库
def read_mtllib(self, mtl_fname):
file_mtllib = open(mtl_fname)
for line in file_mtllib:
words = line.split()
command = words[0]
data = words[1:]
if command == 'newmtl':
material = Material()
material.name = data[0]
self.materials[data[0]] = material
elif command == 'map_Kd':
material.texture_fname = data[0]
这两个函数(read_obj
和read_mtllib
)足以从 OBJ 文件中读取我们需要的所有信息,现在我们可以编写代码将几何图形发送到 OpenGL。draw
方法(见清单 11-6 )遍历每个面组,绑定一个纹理,并将几何列表中的数据发送给 OpenGL。
清单 11-6 。将几何图形发送到 OpenGL
def draw(self):
vertices = self.vertices
tex_coords = self.tex_coords
normals = self.normals
for face:group in self.face:groups:
material = self.materials[face:group.material_name]
glBindTexture(GL_TEXTURE_2D, material.texture_id)
glBegin(GL_TRIANGLES)
for vi, ti, ni in face:group.tri_indices:
glTexCoord2fv( tex_coords[ti] )
glNormal3fv( normals[ni] )
glVertex3fv( vertices[vi] )
glEnd()
def draw_quick(self):
if self.display_list_id is None:
self.display_list_id = glGenLists(1)
glNewList(self.display_list_id, GL_COMPILE)
self.draw()
glEndList()
glCallList(self.display_list_id)
一次发送一个顶点的几何图形可能会很慢,因此还有一个draw_quick
方法来编译包含模型几何图形的显示列表,然后只需调用一次glCallList
就可以呈现该列表。
Model3D
类现在包含了我们加载和渲染 3D 模型所需的一切,但是在我们使用它之前,我们应该编写代码来清理我们使用过的 OpenGL 资源。清单 11-7 给Model3D
添加了一个free_resources
方法,删除显示列表和任何创建的纹理。当您不再需要模型时,可以调用这个方法,或者您可以让它被__del__
方法自动调用,当不再有对对象的引用时,Python 就会调用这个方法。
清单 11-7 。清理 OpenGL 资源
def __del__(self):
#Called when the model is cleaned up by Python
self.free_resources()
def free_resources(self):
# Delete the display list and textures
if self.display_list_id is not None:
glDeleteLists(self.display_list_id, 1)
self.display_list_id = None
# Delete any textures we used
for material in self.materials.values():
if material.texture_id is not None:
glDeleteTextures(material.texture_id)
# Clear all the materials
self.materials.clear()
# Clear the geometry lists
del self.vertices[:]
del self.tex_coords[:]
del self.normals[:]
del self.face:groups[:]
注意手动调用
free_resources
函数是个好主意,因为如果 Python 在 PyOpenGL 退出后清理 Model3D 对象,可能会导致错误。
使用 Model3D 类
让我们编写一个脚本,使用我们的Model3D
类来加载和呈现模型。清单 11-8 创建一个Model3D
对象并用它来读取mytank.obj
(和材料)。
清单 11-8 。渲染坦克模型(tankdemo.py)
from math import radians
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *
# Import the Model3D class
import model3d
SCREEN_SIZE = (800, 600)
def resize(width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(60.0, float(width)/height, .1, 1000.)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
def init():
# Enable the GL features we will be using
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
glEnable(GL_COLOR_MATERIAL)
glEnable(GL_TEXTURE_2D)
glEnable(GL_CULL_FACE)
glShadeModel(GL_SMOOTH)
glClearColor(1.0, 1.0, 1.0, 0.0) # white
# Set the material
glMaterial(GL_FRONT, GL_AMBIENT, (0.0, 0.0, 0.0, 1.0))
glMaterial(GL_FRONT, GL_DIFFUSE, (0.2, 0.2, 0.2, 1.0))
glMaterial(GL_FRONT, GL_SPECULAR, (1.0, 1.0, 1.0, 1.0))
glMaterial(GL_FRONT, GL_SHININESS, 10.0)
# Set light parameters
glLight(GL_LIGHT0, GL_AMBIENT, (0.0, 0.0, 0.0, 1.0))
glLight(GL_LIGHT0, GL_DIFFUSE, (0.4, 0.4, 0.4, 1.0))
glLight(GL_LIGHT0, GL_SPECULAR, (1.0, 1.0, 1.0, 1.0))
# Enable light 1 and set position
glEnable(GL_LIGHT0)
glLight(GL_LIGHT0, GL_POSITION, (0, .5, 1, 0))
def run():
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)
resize(*SCREEN_SIZE)
init()
clock = pygame.time.Clock()
# Read the model
tank_model = model3d.Model3D()
tank_model.read_obj('mytank.obj')
rotation = 0.0
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
time_passed = clock.tick()
time_passed_seconds = time_passed / 1000.0
glLoadIdentity()
glRotatef(15, 1, 0, 0)
glTranslatef(0.0, -1.5, -3.5)
rotation += time_passed_seconds * 45.0
glRotatef(rotation, 0, 1, 0)
tank_model.draw_quick()
pygame.display.flip()
if __name__ == "__main__":
run()
清单 11-8 中的函数启用了我们将使用的 OpenGL 特性。它还为材质和灯光设置参数,这将使坦克具有金属外观。我们将在下一章讨论材质和照明参数。
在绘制坦克之前,设置模型矩阵,使相机略高于坦克并俯视它——这比沿 z 轴观察它提供了一个稍微有趣的视点。
对glRotatef(15, 1, 0, 0)
的调用创建了一个绕 x 轴 15 度的旋转矩阵,相当于向下看了一点。这个旋转矩阵乘以一个平移矩阵,调用glTranslatef(0.0, -1.5, -3.5)
,有效地将坦克下移 1.5 个单位,再移回 3.5 个单位。第二次调用glRotatef
使坦克绕 y 轴旋转,这样我们可以看到它在运动。
当你运行清单 11-8 中的时,你应该会看到一辆看起来令人生畏的坦克绕着 y 轴旋转(见图 11-6 )。如果你有一个更好的模型来代替坦克,那么用你的模型的文件名替换mytank.obj
。
图 11-6 。加油示范 py
注意并非所有的 3D modeler 应用都使用相同的比例。如果您发现您的模型非常大或非常小,您可能需要在导出之前对其进行缩放,或者更改代码以在不同的距离查看对象。
摘要
纹理是使 3D 场景看起来令人信服的主要方式,因为你可以将真实世界的图像应用到游戏中的对象。照片是创建纹理的好方法——数码相机是游戏开发的绝佳工具!或者您可以使用其他来源的图像;Pygame wiki ( www.pygame.org/wiki/resources
)有一个很棒的免费纹理网站链接集。
因为 OpenGL 是独立于 Pygame 创建的,所以没有一个函数可以用来读取和上传一个图像作为纹理;将文件中的图像数据保存到高速视频存储器需要几个步骤。如果你使用本章概述的步骤,你应该没有问题处理纹理。请务必设置最小化和最大化纹理参数,以及包装功能。
您学习了如何从文件中读取 3D 模型并使用 OpenGL 渲染它。波前 OBJ 格式得到了很好的支持,你可能会发现Model3D
类对于你写的任何游戏都足够了。或者您可能想要扩展read_obj
方法来覆盖更多的特性(例如,四边形)。如果你想在游戏中支持其他格式,你可以在网上找到你需要的文档。编写解析器可能具有挑战性,但这是掌握 3D 模型创建细节的一个很好的方法。
在下一章中,你将学习如何使用 OpenGL 光照,并创建令人印象深刻的特效,为你的游戏增添光彩。
十二、使用 OpenGL 设置场景
你已经对 OpenGL 有了很大的进步,已经学会了如何在 3D 场景中渲染和操作对象。在这一章中,我们将更详细地介绍照明,并向你介绍 OpenGL 的其他特性,这些特性将帮助你给你的游戏增添光彩。
了解照明
我在第七章中向你介绍了 OpenGL 的光照功能,但是忽略了一些细节。在这一部分,我们将探索如何使用灯光,并用它们来增加 3D 场景的真实感。
OpenGL 使用您分配给模型顶点或面的法线来计算玩家将看到的光线颜色。渲染场景中的多边形时会使用这些颜色,方法是为每个面指定一种颜色(平面着色),或者在顶点之间混合颜色(如果启用了平滑着色)。模型越详细,光照就越有说服力,因为颜色只计算整个面或顶点,而不是多边形中的每个像素。
OpenGL 支持三种类型的灯光:
- 平行光: 平行光是光线相互平行的光源。太阳通常被视为平行光——尽管从技术上来说它是点光源——因为它太远了,当光线到达地球时,光线实际上是平行的。
- 点光源(也叫位置光): 点光源是从单个点发出的光源,用于表示场景中大多数邻近的光线。点光源的常见用途是灯泡、火焰和特殊效果。
- 聚光灯: 聚光灯类似于点光源,从一个点发出,但光线聚焦到一个圆形区域,以该点为尖端,形成一个圆锥形。这里不涉及 OpenGL 聚光灯,因为在游戏中不常用到,不过如果有兴趣可以在
www.opengl.org/sdk/
docs/man/
在线查看 OpenGL 文档。聚光灯一样的效果可以通过其他方式来实现,通常是混合,我将在本章后面介绍。
启用照明
要在 OpenGL 中启用光照,只需调用glEnable(GL_LIGHTING)
。如果你需要暂时禁用照明——比如渲染字体——调用glDisable(GL_LIGHTING)
。你还需要启用你想在场景中使用的单个灯光,这也可以用glEnable
通过传递一个灯光常量(GL_LIGHT0
到GL_LIGHT7
)来实现。
设置灯光参数
使用glLight
功能可以设置许多灯光参数。这个函数为你想要设置的灯光取一个常量(GL_LIGHT0
到GL_LIGHT7
),后面是你想要设置的参数的常量和你想要设置的值。灯光参数列表见表 12-1 。(此表不包括聚光灯使用的参数。完整列表见 OpenGL 文档。)
表 12-1 。灯光参数
|
参数名称
|
类型
|
说明
|
| — | — | — |
| GL_POSITION
| 要点 | 设置灯光 GL_AMBIENT 的位置 |
| GL_AMBIENT
| 颜色 | 设置灯光的环境强度 |
| GL_DIFFUSE
| 颜色 | 设置灯光的漫射颜色 |
| GL_SPECULAR
| 颜色 | 设置灯光将创建的镜面高光的颜色 |
| GL_CONSTANT_ATTENUATION
| 数字 | 指定了常数衰减系数 |
| GL_LINEAR_ATTENUATION
| 数字 | 指定线性衰减系数 |
| GL_QUADRATIC_ATTENUATION
| 数字 | 指定二次衰减系数 |
GL_POSITION
参数用于设置将要创建的光的位置和类型。它获取位置的 x、y 和 z 坐标以及一个名为w
的附加参数。如果w
设置为 0.0,灯光将为方向,如果w
设置为 1.0,灯光将为位置(点光源)。以下两条线在(1.0,1.0,1.0)处创建平行光,在坐标(50.0,100.0,0.0)处创建位置光:
glLight(GL_LIGHT0, GL_POSITION, (1.0, 1.0, 1.0, 0.0))
glLight(GL_LIGHT1, GL_POSITION, (50.0, 100.0, 0.0, 1.0))
平行光沿原点方向发出平行光线,因此(1.0,1.0,1.0)处的位置光将模拟位于当前视点右上方后方的无限远的光源。位置光从坐标(50.0,100.0,0.0)向所有方向发送光线。
还有三种颜色值可以为灯光设置:环境光、漫反射和镜面反射。环境颜色用于模拟从场景中其他对象反射的间接光的效果。在阳光明媚的日子里,周围的颜色会非常明亮,因为即使在阴凉处也有足够的光线可以看清。相反,在一个只有一盏油灯照明的洞穴里,周围的颜色会很暗,甚至可能是黑色,从而在没有照明的地方形成一片漆黑。
灯光的漫反射颜色是照亮周围场景的灯光的主要颜色,取决于您试图模拟的光源;阳光很可能是明亮的白色,但其他光源,如蜡烛、火球和魔法咒语,可能会有不同的颜色。
灯光的最终颜色值是高光颜色,它是由反射或抛光表面产生的高光颜色。如果你看一个闪亮的物体,你会看到亮点,光线直接反射到你的眼睛里。在 OpenGL 中,这些亮点的颜色是由高光颜色参数决定的。
以下三个对glLight
的调用将创建一个很适合作为火球或火箭的光:
glLight(GL_LIGHT1, GL_AMBIENT, (0.05, 0.05, 0.05))
glLight(GL_LIGHT1, GL_DIFFUSE, (1.0, 0.2, 0.2))
glLight(GL_LIGHT1, GL_SPECULAR, (1.0, 1.0, 1.0))
环境是深灰色的,因为我们希望效果是局部的,而不是在短距离之外贡献太多的光。漫反射是明亮的火焰红色,镜面反射是强烈的白色,以在附近的表面产生明亮的高光。
灯光也有三个衰减、??,定义点光源的亮度如何随距离变化。OpenGL 使用三个因子常数、*线性、和二次、*来计算一个数字,该数字乘以由照明计算产生的颜色。OpenGL 使用的公式相当于清单 12-1 中的get_attenuation
函数,它取一个点到光源的距离,后跟三个衰减因子。get_attenuation
的返回值是一个数字,表示距离光源一定距离处的灯光亮度。
清单 12-1 。计算衰减系数的函数
def get_attenuation(distance, constant, linear, quadratic):
return 1.0 / (constant + linear * distance + quadratic * (distance ** 2))
注意OpenGL 可以使用的最大亮度级别是 1.0——大于 1.0 的值不会有任何额外的效果。
对于一个火球效果,我们可能希望在视觉效果周围的区域发出强烈的光,但是很快就消失了。以下对glLight
的调用设置了三个衰减因子,使得光线在一个单位的半径内达到最大强度,但很快减弱,直到几个单位之外几乎没有任何光线:
glLight(GL_LIGHT1, GL_CONSTANT_ATTENUATION, 0.5)
glLight(GL_LIGHT1, GL_LINEAR_ATTENUATION, 0.3)
glLight(GL_LIGHT1, GL_QUADRATIC_ATTENUATION, 0.2)
注意衰减需要 OpenGL 为渲染的每个多边形做更多的工作,并且会对你的游戏产生性能影响——尽管除非你有一个旧的显卡,否则这可能不会被注意到。相对于现代游戏,我们的游戏只有很少的多边形,所以这绝对不是问题。
使用材料
OpenGL 中的材质是定义多边形应该如何与光源交互的参数集合。 这些参数是用glMaterial
函数设置的,并与灯光参数结合生成渲染时使用的最终颜色。glMaterial
的第一个参数通常是GL_FRONT
,它设置面向相机的多边形的参数,但也可以是面向远离相机的多边形的GL_BACK
——或者是面向两者的GL_FRONT_AND_BACK
。对于完全封闭的模型——也就是说,你看不到它们的内部——你可以坚持使用GL_FRONT
,因为只有正面是可见的(除非你在模型的内部*!).glMaterial
的第二个参数是你要设置的材料参数的常数,最后一个参数是你要设置的值。表 12-2 列出了您可以使用的材料参数。*
表 12-2 。材料参数
|
参数名称
|
类型
|
说明
|
| — | — | — |
| GL_AMBIENT
| 颜色 | 设置材质颜色的环境贡献。 |
| GL_DIFFUSE
| 颜色 | 设置材质的漫射颜色。 |
| GL_SPECULAR
| 颜色 | 设置材质上高光的颜色。 |
| GL_SHININESS
| 数字 | 指定一个介于 0 和 128 之间的值,用于定义材质的光泽度。 |
| GL_EMISSION
| 颜色 | 指定材料的发射颜色。 |
“环境光”、“漫反射”和“镜面反射颜色”参数的含义与它们对于灯光的含义相同,并且与相应的灯光参数组合在一起。GL_SHININESS
参数定义了材质上镜面高光的大小。较高的值会产生小的高光,使材质看起来坚硬或光滑。值越低,创建的材质越粗糙。GL_EMISSION
参数定义材料的发射颜色,即材料本身发出的光的颜色。材质在 OpenGL 中不能用作光源,但是设置发射色会使模型看起来有自己的内部照明。
调整参数
为材质和灯光选择参数更像是一门艺术而不是科学;实验通常是获得你想要的外观的最好方法。选择上一章中旋转槽的材料和灯光设置(清单 11-8 )来使槽看起来像金属。尝试使用坦克的材质值来创建不同的外观。通过改变一些参数,你应该能够使它看起来像一个塑料玩具或木制模型。
提示灯光参数可以随时间变化,产生一些有趣的效果。
管理灯光
在渲染场景时,至少有八个 OpenGL 灯光供您使用,但是很容易想象需要更多的灯光。考虑一个以教堂为背景的场景;窗外有月光,几盏闪烁的灯,祭坛上有许多蜡烛。我们不能在一帧中激活所有这些光源,那么我们如何管理它们呢? 第一步是把相近的光源组合成单一的光。祭坛上的蜡烛是一个完美的候选;虽然有许多光源,但如果将它们替换为从一组蜡烛中心的某个地方发出的单点光源,玩家可能不会注意到。减少所用灯光数量的下一步是优先选择明亮和/或关闭的灯光,而不是较远或不太明亮的灯光。如果我们对每一帧都执行这些步骤,我们可以在不影响场景视觉质量的情况下减少灯光数量。
场景中灯光的数量也会影响游戏的帧速率,因为 OpenGL 必须为每个额外的灯光做一系列的计算。为了提高性能,你可能希望将游戏中使用的最大灯光数量设置为比 OpenGL 所能处理的最大数量低*。*
理解混合
到目前为止,当我们渲染多边形时,纹理的颜色已经完全取代了它们下面的屏幕像素——这是我们在渲染实心物体时需要的,但不会帮助我们渲染半透明物体。为此,我们需要利用 OpenGL 的混合特性,将纹理的颜色和其下表面的颜色结合起来。
混合对于创造许多玩家在今天的游戏中认为理所当然的效果也是必不可少的,因为它可以用来创造任何东西,从汽车挡风玻璃上的污点到壮观的爆炸。在这一部分,我们将探索如何在你的游戏中使用混合,并检查一些可以用它创建的效果。
使用混合
当 OpenGL 执行混合时,它从源和目标(即纹理和屏幕)采样一种颜色,然后用一个简单的等式将这两种颜色组合起来,以产生写入屏幕的最终颜色。OpenGL 对颜色进行数学运算,但是如果它是用 Python 编写的,它可能看起来像这样:
src = texture.sample_color()
dst = screen.sample_color()
final_color = blend_equation(src * src_factor, dst * dst_factor)
screen.put_color(final_color)
src
和dst
值 是从纹理和屏幕上采样的两种颜色。这些颜色各自乘以一个混合因子 ( src_factor
和dst_factor
),并与一个blend_equation
函数相结合,产生写入屏幕的最终颜色。组合不同的混合因子和混合方程式可以产生大量的视觉效果。
在 OpenGL 术语中,混合因子是用glBlendFunc
函数设置的,该函数为源因子和目标因子取两个常数。例如,调用glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
会将源颜色乘以源的 alpha 分量,将目标颜色乘以 1 减去源的 alpha 分量。见表 12-3 中可能混合因子的完整列表。
表 12-3 。混合因子常数
|
混合因子常数
|
说明
|
| — | — |
| GL_ZERO
| 乘以零 |
| GL_ONE
| 乘以一 |
| GL_SRC_COLOR
| 乘以源 |
| GL_ONE_MINUS_SRC_COLOR
| 乘以 1 减去源颜色 |
| GL_DST_COLOR
| 乘以目的地 |
| GL_ONE_MINUS_DST_COLOR
| 乘以 1 减去目标颜色 |
| GL_SRC_ALPHA
| 乘以源 alpha 分量 |
| GL_ONE_MINUS_SRC_ALPHA
| 乘以源 alpha 分量的倒数 |
| GL_DST_ALPHA
| 乘以目标 alpha |
| GL_ONE_MINUS_DST_ALPHA
| 乘以目标 alpha 的倒数 |
| GL_CONSTANT_COLOR
| 乘以恒定颜色(用glBlendColor
设置) |
| GL_ONE_MINUS_CONSTANT_COLOR
| 乘以常数颜色的倒数 |
| GL_CONSTANT_ALPHA
| 乘以常数α |
| GL_ONE_MINUS_CONSTANT_ALPHA
| 乘以常数α的倒数 |
| GL_SRC_ALPHA_SATURATE
| 指定源 alpha 和一减去源 alpha 的最小值 |
使用glBlendEquation
函数设置混合方程,该函数采用多个潜在混合方程常数中的一个。默认值是GL_ADD
,它只是将源颜色和目标颜色相加(乘以混合因子后)。混合方程常数的完整列表见表 12-4 。
表 12-4 。混合方程常数
|
混合方程常数
|
说明
|
| — | — |
| GL_FUNC_ADD
| 添加源颜色和目标颜色 |
| GL_FUNC_SUBTRACT
| 从源颜色中减去目的颜色 |
| GL_FUNC_REVERSE_SUBTRACT
| 从目标颜色中减去源 |
| GL_MIN
| 计算源颜色和目标颜色的最小值(最暗值) |
| GL_MAX
| 计算源颜色和目标颜色的最大值(最亮值) |
| GL_LOGIC_OP
| 用一个逻辑运算组合源和目标颜色(有关逻辑运算的信息,请参见 OpenGL 文档) |
混合中涉及的另一个 OpenGL 函数是glBlendColor
,它设置一个在一些混合因子中使用的恒定颜色。该函数将红色、绿色、蓝色和 alpha 分量用作常量颜色。例如,glBlendColor(1.0, 0.0, 0.0, 0.5)
将设置 50%半透明红色的恒定颜色。
在游戏中启用混合的步骤如下:
- 通过调用
glEnable(GL_BLEND)
启用混合。 - 调用
glBlendFunc
功能设置混合因子。混合系数的完整列表见表 12-3 。 - 调用
glBlendEquation
函数设置混合方程式。混合方程的完整列表见表 12-4 。 - 如果您已经使用了一个引用恒定颜色的混合因子,您还需要调用
glBlendColor
函数,该函数将红色、绿色、蓝色和 alpha 颜色组件作为参数。
OpenGL 通过各种混合选项支持许多潜在的混合效果。以下部分涵盖了游戏中常用的一些效果以及创建这些效果所需的混合选项。
阿尔法混合
最常用的混合效果之一是 alpha 混合,它使用图像中的 alpha 通道来控制像素的不透明度。Alpha 混合可用于创建带有孔洞或不规则边缘的纹理,如撕破的旗帜。
我们可以使用以下函数调用来设置 alpha 混合:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glBlendEquation(GL_FUNC_ADD)
这告诉 OpenGL 将源颜色乘以源 alpha,将目标颜色乘以 1.0 减去源 alpha。然后,它用GL_FUNC_ADD
混合公式将这两种颜色结合起来——简单地将它们加在一起,产生写入屏幕的最终颜色。效果是根据源颜色的 alpha 分量对颜色进行插值,相当于清单 12-2 中的alpha_blend
函数。
清单 12-2 。Python Alpha 混合函数
def alpha_blend(src, dst):
return src * src.a + dst * (1.0—src.a)
这种 alpha 混合对于在需要许多小多边形的地方创建细节非常有用。例如,越野赛车游戏中的一簇杂草可以创建为具有半透明区域的单一纹理,而不是详细的模型。
混合也可以使用常量 alpha 组件来完成,而不是在纹理中使用 alpha 通道。这对于从整体上改变纹理的不透明度非常有用,而不是针对单个纹理元素。常量 alpha 混合可以用来渲染从窗口到力场的任何东西。下面的调用将使纹理 50%透明,或者 50%不透明,如果你喜欢的话。
glBlendColor(1.0, 1.0, 1.0, 0.5)
glBlendFactor(GL_CONSTANT_ALPHA, GL_ONE_MINUS_CONSTANT_ALPHA)
glBlendEquation(GL_ADD)
添加剂混合
另一种有用的混合技术是加色混合,它类似于基本的 alpha 混合,只是源颜色被直接添加到目标颜色,从而使底层图像变亮。这就产生了一种发光的效果,这就是为什么加法混合经常被用来渲染火焰、电流或类似的取悦大众的特殊效果。下面的函数调用选择添加剂混合:
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
glBlendEquation(GL_FUNC_ADD)
这与常规 alpha 混合的唯一区别是目标混合因子被设置为GL_ONE
,它将颜色乘以 1.0——实际上,根本不改变它。添加剂混合相当于清单 12-3 中的additive_blend
函数。
清单 12-3 。Python 加法混合函数
def additive_blend(src, dst):
return src * src.a + dst
减法混合
加法混合的反向操作是减法混合,即从底层图像中减去源颜色,使其变得更暗。以下函数调用为减法混合设置混合选项:
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
glBlendEquation(GL_FUNC_REVERSE_SUBTRACT)
混合方程式被设置为GL_FUNC_REVERSE_SUBTRACT
而不是GL_FUNC_SUBTRACT
,因为我们想要从目标减去源,而不是相反。
GL_ADD
没有倒版是因为 A+B 永远和 B+A 一样(减法也不一样;A–B 并不总是与 B–A 相同。
减色混合非常适合渲染烟雾,因为几层会产生纯黑色。清单 12-4 中的函数是 Python 中减法混合的等价物。
清单 12-4 。Python 减法混合函数
def subtractive_blend(src, dst):
return dst—src * src.a
看到融合在行动中
让我们写一些代码来帮助我们可视化一些常用的混合效果。当您执行清单 12-5 时,您会看到一个背景图像和另一个较小的图像——我们的朋友河豚——可以用鼠标移动。fugu 图像包含 alpha 信息,但是由于没有设置混合选项,您将看到图像中通常不可见的部分。如果你按下键盘上的 1 键,它将启用阿尔法混合,河豚图像的背景像素将变得不可见(参见图 12-1 )。按 2 选择加性混合,使河豚发出鬼魅般的光芒,按 3 选择减性混合,使河豚产生暗影。
清单 12-5 。演示混合效果(blenddemo.py)
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *
SCREEN_SIZE = (800, 600)
def resize(width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(45.0, float(width)/height, .1, 1000.)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
def init():
glEnable(GL_TEXTURE_2D)
glEnable(GL_BLEND)
glClearColor(1.0, 1.0, 1.0, 0.0)
def upload_texture(filename, use_alpha=False):
# Read an image file and upload a texture
if use_alpha:
format, gl_format, bits_per_pixel = 'RGBA', GL_RGBA, 4
else:
format, gl_format, bits_per_pixel = 'RGB', GL_RGB, 3
img_surface = pygame.image.load(filename)
#img_surface = premul_surface(img_surface)
data = pygame.image.tostring(img_surface, format, True)
texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR )
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR )
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
width, height = img_surface.get_rect().size
glTexImage2D( GL_TEXTURE_2D,
0,
bits_per_pixel,
width,
height,
0,
gl_format,
GL_UNSIGNED_BYTE,
data )
# Return the texture id, so we can use glBindTexture
return texture_id
def draw_quad(x, y, z, w, h):
# Send four vertices to draw a quad
glBegin(GL_QUADS)
glTexCoord2f(0, 0)
glVertex3f(x-w/2, y-h/2, z)
glTexCoord2f(1, 0)
glVertex3f(x+w/2, y-h/2, z)
glTexCoord2f(1, 1)
glVertex3f(x+w/2, y+h/2, z)
glTexCoord2f(0, 1)
glVertex3f(x-w/2, y+h/2, z)
glEnd()
def run():
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)
resize(*SCREEN_SIZE)
init()
# Upload the background and fugu texture
background_tex = upload_texture('background.png')
fugu_tex = upload_texture('fugu.png', True)
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
if event.type == KEYDOWN:
if event.key == K_1:
# Simple alpha blending
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glBlendEquation(GL_FUNC_ADD)
elif event.key == K_2:
# Additive alpha blending
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
glBlendEquation(GL_FUNC_ADD)
elif event.key == K_3:
# Subtractive blending
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
glBlendEquation(GL_FUNC_REVERSE_SUBTRACT)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# Draw the background
glBindTexture(GL_TEXTURE_2D, background_tex)
glDisable(GL_BLEND)
draw_quad(0, 0, -SCREEN_SIZE[1], 600, 600)
glEnable(GL_BLEND)
# Draw a texture at the mouse position
glBindTexture(GL_TEXTURE_2D, fugu_tex)
x, y = pygame.mouse.get_pos()
x -= SCREEN_SIZE[0]/2
y -= SCREEN_SIZE[1]/2
draw_quad(x, -y, -SCREEN_SIZE[1], 256, 256)
pygame.display.flip()
# Free the textures we used
glDeleteTextures(background_tex)
glDeleteTextures(fugu_tex)
if __name__ == "__main__":
run()
图 12-1 。混合效果
试着改变glBlendFunc
和glBlendEquation
中的常量来产生一些更有趣的效果。如果你想实现这里没有提到的特殊效果,你可以在网上找到参数。
混合问题
如果在场景中大量使用混合,会有一些潜在的问题。渲染混合多边形时,它仍会将信息写入深度缓冲区,OpenGL 使用深度缓冲区来防止背景对象与前景对象重叠。这样做的问题是,一旦一个半透明的多边形被画出,它后面就不能再画多边形了。一个好的解决方法是画出所有不透明的多边形,然后从后向前画半透明的多边形,这样最远的多边形会先画出来。
混合多边形的渲染时间也往往比不透明多边形长,因此,如果您发现场景渲染缓慢,请尝试减少半透明多边形的数量。
理解雾
雾 是 OpenGL 的一个特性,可以用来模拟大气对渲染对象的影响。启用雾化功能时,OpenGL 会随着距离的增加将多边形混合为一种纯色,这样当对象从前景移动到背景时,它会逐渐呈现雾化颜色,直到变成一个纯色轮廓。在早期的游戏中,雾经常被用来隐藏物体不能被拉得很远的事实。它不是突然出现的风景,而是在几帧内与背景色融合在一起。现代游戏较少受到这个问题的困扰,因为它们可以将场景渲染到更远的距离,但当远处的物体进入相机的范围时,雾仍然经常被用来巧妙地融入其中。
雾本身也有助于创造视觉效果;最明显的用途是模拟真实的雾,但您也可以使用该功能通过雾化成黑色或模拟火星上的红色薄雾来增强昏暗的室内场景。
雾参数
调用glEnable(GL_
FOG)
到 让 OpenGL 应用雾到所有渲染的多边形上。您可以使用glFog
系列函数设置许多雾参数,这些函数采用一个常量作为您想要设置的值,后跟该值本身。
雾的颜色可以通过用常量GL_FOG_COLOR
调用glFogfv
来设置,后面跟着你想要使用的颜色。例如,下面一行将雾的颜色设置为纯白色(也许是为了模拟暴风雪):
glFogfv(GL_FOG_COLOR, (1.0, 1.0, 1.0))
OpenGL 有三种不同的雾模式,它们定义了雾如何随距离变化。这些雾化模式可以用glFogi
功能设置。例如,下面一行将设置 fog 使用GL_LINEAR
模式:
glFogi(GL_FOG_MODE, GL_LINEAR)
GL_LINEAR
雾模式最常用于掩盖远处景物进入视野的效果,因为起点和终点可以独立设置,雾会在它们之间淡入。例如,如果您有一个赛车游戏,可以将 1,000 个单位渲染到远处,并且您希望距离轨迹和树在最终单位上淡入,您可以设置以下雾参数:
glFogi(GL_FOG_MODE, GL_LINEAR)
glFogf(GL_FOG_START, 999.0)
glFogf(GL_FOG_END, 1000.0)
GL_FOG_START
和GL_FOG_END
参数标记雾应该开始和结束的摄像机的距离。
GL_FOG_MODE
参数的其他潜在值是GL_EXP
和GL_EXP2
。两者都能产生看起来更自然的雾,并且更适合模拟真实的雾或霾。GL_EXP
模式为摄像机附近的物体快速混合雾,但为较远的物体混合较慢。GL_EXP2
与此相似,但是开始时前景中的雾色较少。
GL_EXP
和GL_EXP2
都使用单一的密度值,而不是起点和终点的值。密度可以用GL_FOG_DENSITY
参数设置,该值介于 0.0 和 1.0 之间,值越高,雾越浓。以下调用将创建一个令人信服的火星薄雾,红色GL_EXP2
雾的密度为 0.2:
glFogfv(GL_FOG_COLOR, (1.0, 0.7, 0.7))
glFogi(GL_FOG_MODE, GL_EXP2)
glFogf(GL_FOG_DENSITY, 0.2)
看到雾在行动
让我们修改前一章的旋转坦克,而不是写一个完整的脚本来测试雾(见清单 11-8)。我们将从添加几行到init
函数开始,以启用雾化并设置雾化参数。下面的线条创建了一个简单的线性雾,逐渐变成白色:
glEnable(GL_FOG)
glFogfv(GL_FOG_COLOR, (1.0, 1.0, 1.0))
glFogi(GL_FOG_MODE, GL_LINEAR)
glFogf(GL_FOG_START, 1.5)
glFogf(GL_FOG_END, 3.5)
如果您现在运行修改后的清单 11-8,您将会看到类似于图 12-2 的内容。雾开始于距离摄像机 1.5 个单位,结束于距离摄像机 3.5 个单位,这使得坦克的一部分被雾完全遮挡。这不是一个非常有用的雾,因为它会模糊任何不靠近相机的东西,但它确实很好地展示了效果。
图 12-2 。浓雾弥漫的坦克
旋转坦克演示的另一个有用的修改是能够相对于摄像机移动坦克,这样我们就可以看到雾是如何随着距离变化的。用以下代码行替换对run
函数中glTranslate
的调用,用鼠标移动坦克:
tank_distance = pygame.mouse.get_pos()[1] / 50.0
glTranslatef(0.0, -1.5, -tank_distance)
现在当你运行坦克演示时,你可以通过上下移动鼠标来控制坦克与摄像机的距离。试着用火星烟雾的设置替换我们添加到init
函数中的对glFogi
的调用,或者用你自己的值进行实验以产生新的效果。您可能还想将透明色更改为类似于雾色,以便一个浓雾弥漫的坦克完全消失在背景中。
渲染背景
一个坦克游戏可能会设定在一个户外环境,可能是一个荒凉的,后世界末日的荒地,远处有山。自然,我们希望尽可能详细地渲染背景场景,为游戏动作提供良好的背景,但即使我们可以模拟每座山,也仍然太慢,无法将 3D 视觉效果一直渲染到地平线。这是一个玩家可以潜在地看到地平线的任何游戏的常见问题(即使是室内游戏也会受到影响,如果他们有一个窗口)。
天空体
渲染远处风景的一个常见解决方案是天空盒,它只是一个每边都有风景的纹理立方体。 立方体的前、后、左、右四个面都呈现出朝向地平线的景象。立方体的顶部是天空的图像,立方体的底部是地面。当天空盒被画在摄像机周围时,玩家四周都是远处风景的图像,这就产生了一种身临其境的错觉。
创建天空盒
天空盒是一个立方体的模型,可以在你选择的建模软件中创建——我用过的所有 3D 应用都提供了创建立方体图元的选项。天空盒应该围绕原点创建,这样立方体的中心就在(0,0,0)处。立方体的每一面都应该从天空盒的六个场景纹理中选择一个。
注意你可能需要翻转立方体表面的法线,使它们指向内而不是外。这样做的原因是,立方体将从内部而不是外部被查看,并且您希望多边形面向相机。
生成天空盒纹理可能需要更多的努力,因为每个纹理必须与它共享一条边的其他四个纹理无缝对齐。如果你有艺术倾向,你可以画出这些纹理,但是使用 3D 建模软件为天空盒的每个面渲染场景的六个视图可能更容易。渲染天空盒的一个很好的选择是 Terragen ( www.planetside.co.uk/terragen/
),它可以创建非常逼真的虚拟景观图像。我使用 Terragen 创建了图 12-3 中的天空立方体纹理,我们将在天空盒样本代码中使用。
图 12-3 。天空盒的纹理
渲染天空盒
渲染天空盒应该是在一个新的帧中首先要做的事情,并且不需要清除颜色缓冲区(尽管你仍然需要清除深度缓冲区)。
因为天空盒只是立方体的一个模型,所以它可以存储为任何其他模型,但是在渲染之前需要一些额外的步骤:
- 将天空盒中所有纹理的包裹模式设置为
GL_CLAMP_TO_EDGE
。这对于避免立方体面相交的天空盒中的接缝是必要的。有关包装模式的更多信息,请参见上一章。这一步只需要做一次。 - 将天空盒的位置设置为与摄像机(即播放器)相同。这是因为天空盒代表了玩家永远无法到达的非常遥远的风景。
- 用
glDisable(GL_LIGHTING)
禁用照明。我们不需要使用 OpenGL 的光照特性,因为天空盒的纹理已经被有效地预光照了。禁用照明后,OpenGL 将使用原始亮度级别渲染纹理。 - 用
glDepthMask(False)
禁用深度缓冲器。通常,如果玩家在一个立方体里面,他将看不到立方体外面的任何东西,这显然不是我们想要的。用glDepthMask(False)
将深度蒙版设置为False
告诉 OpenGL 忽略天空盒中的深度信息,这样其他模型将在它上面渲染。
渲染天空盒后,请确保重新启用照明和深度遮罩,否则场景中的其他模型可能无法正确渲染。下面两行应该跟在调用之后以呈现天空盒:
glEnable(GL_LIGHTING)
glDepthMask(True)
观看天空盒的运行
让我们编写代码来呈现天空盒。清单 12-6 使用前一章的Model3D
类来加载天空盒模型及其相关纹理。当你运行它时,你会看到一个山脉的风景,如果你用鼠标调整视点,你将能够从任何方向看到风景。
清单 12-6 。渲染天空盒(skybox.py)
from math import radians
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *
# Import the Model3D class
import model3d
SCREEN_SIZE = (800, 600)
def resize(width, height):
glViewport(0, 0, width, height)
glMatrixMode(GL_PROJECTION)
glLoadIdentity()
gluPerspective(60.0, float(width)/height, .1, 1000.)
glMatrixMode(GL_MODELVIEW)
glLoadIdentity()
def init():
# Enable the GL features we will be using
glEnable(GL_DEPTH_TEST)
glEnable(GL_LIGHTING)
glEnable(GL_TEXTURE_2D)
glShadeModel(GL_SMOOTH)
# Enable light 1 and set position
glEnable(GL_LIGHTING)
glEnable(GL_LIGHT0)
glLight(GL_LIGHT0, GL_POSITION, (0, .5, 1))
def run():
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE, FULLSCREEN|HWSURFACE|OPENGL|DOUBLEBUF)
resize(*SCREEN_SIZE)
init()
# Read the skybox model
sky_box = model3d.Model3D()
sky_box.read_obj('tanksky/skybox.obj')
# Set the wraping mode of all textures in the sky-box to GL_CLAMP_TO_EDGE
for material in sky_box.materials.values():
glBindTexture(GL_TEXTURE_2D, material.texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
# Used to rotate the world
mouse_x = 0.0
mouse_y = 0.0
#Don't display the mouse cursor
pygame.mouse.set_visible(False)
while True:
for event in pygame.event.get():
if event.type == QUIT:
pygame.quit()
quit()
if event.type == KEYDOWN:
pygame.quit()
quit()
# We don't need to clear the color buffer (GL_COLOR_BUFFER_BIT)
# because the skybox covers the entire screen
glClear(GL_DEPTH_BUFFER_BIT)
glLoadIdentity()
mouse_rel_x, mouse_rel_y = pygame.mouse.get_rel()
mouse_x += float(mouse_rel_x) / 5.0
mouse_y += float(mouse_rel_y) / 5.0
# Rotate around the x and y axes to create a mouse-look camera
glRotatef(mouse_y, 1, 0, 0)
glRotatef(mouse_x, 0, 1, 0)
# Disable lighting and depth test
glDisable(GL_LIGHTING)
glDepthMask(False)
# Draw the skybox
sky_box.draw_quick()
# Re-enable lighting and depth test before we redraw the world
glEnable(GL_LIGHTING)
glDepthMask(True)
# Here is where we would draw the rest of the world in a game
pygame.display.flip()
if __name__ == "__main__":
run()
Skybox 增强功能
虽然天空盒创造了一个令人信服的远景幻觉,但也有一些增强功能可以用来给背景添加更多的视觉光晕。天空盒技术的一个缺点是它不会随着时间的推移而改变,因为图像已经预先渲染过了。 为天空盒的部分制作动画,或者在其上添加大气效果,可以增加一点额外的真实感。例如,让太阳发出一点微光或渲染远处的闪电可以增强天空盒。也可以将半透明的天空盒分层并制作一个或多个动画。例如,可能有一个天空盒用于远山,另一个用于云。独立旋转云天空盒会产生逼真的天气效果。
天空盒并不是游戏中渲染背景的唯一方法;天空穹顶 是一种类似的技术,使用球体或半球来显示远处的风景。圆顶可能是比立方体更明显的选择,因为真实的天空本质上是球形的,但是球体不像立方体那样容易纹理化。如果玩家永远不会直接向上看,圆柱是另一种选择。
去哪里寻求帮助
游戏编程的乐趣之一是它提供了许多有趣的问题,当你最终解决它们时,会给你一种真正的成就感。每一次你想出如何让事情运转起来,或者改正你犯的错误,你的游戏就离你想象的更近了一点。所以面对问题和修复错误是开发游戏过程的一部分,而不是什么可怕的事情。通常情况下,只需要从编程中休息一会儿,再想一想,答案就会出现在你面前。不过,偶尔,即使是经验丰富的游戏开发者也会面临他们找不到解决方案的问题。这是你应该寻求帮助的地方。
很可能另一个程序员面临着同样的问题,并在 Web 上记录了它,这就是为什么互联网是程序员最好的调试工具。尝试使用谷歌或其他搜索引擎,搜索与你面临的问题相关的关键词。如果你没有找到答案,你可以在邮件列表或新闻组中发布一条关于你的问题的消息。对于 Python 相关的问题,请前往http://www.reddit.com/r/learnpython/
并发表您的问题。编程社区非常乐于助人,并且愿意付出很多努力来帮助你,只要你也付出一些努力。也可以在http://stackoverflow.com/
上发帖提问。
摘要
在这一章中,我们详细介绍了 OpenGL 的光照特性。OpenGL 光照非常强大,可以极大地增强游戏的真实感。灯光和材质参数的组合将帮助您创建您在游戏中寻找的那种情绪,无论是明亮欢快的卡通世界,还是每个角落都潜伏着无法形容的怪物的噩梦般的景观。
混合是创建大量特殊效果的关键,但也可以用来简单地渲染半透明的对象。我们只讨论了一些可以用 OpenGL 的混合特性创建的效果;结合混合因子和方程式可以创建更多。
雾是 OpenGL 的另一个功能,它可以通过模拟大气效果来增强你的游戏,或者当它进入相机的范围时,隐藏远处场景的效果。雾很容易添加到场景中,因为只需要几行代码来启用和设置参数,因此您可以在不更改渲染代码的情况下试验雾。
我们还讨论了天空盒,它是给游戏添加背景的好方法。天空盒可能是旧技术,但它们仍然在现代游戏中使用,包括尖端的主机游戏。
游戏开发是一个不断扩展的领域,这使得它成为一个令人着迷的爱好或职业。大多数开发人员——无论是新手还是行业老手——都对他们所做的事情充满热情,并乐于与他人分享知识和代码。一旦你做好了一个游戏,你应该考虑提交到 Pygame 网站(
http://www.pygame.org
)上,分享给 Pygame 社区。www.pyweek.org
我们希望你喜欢读这本书。如果您对我们讨论的主题有任何疑问,我们很乐意回答。您可以在我们的网站上找到我们的联系方式:
原作者—
威尔·麦古根:http://www.willmcgugan.com
更新作者—
哈里森·金斯利:http://pythonprogramming.net