This commit is contained in:
Mouse
2019-02-27 20:58:04 +08:00
parent 0dba4c30a3
commit 5850ffcd8b
167 changed files with 6 additions and 3 deletions

3
docs/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"restructuredtext.confPath": "d:\\Workspace\\lwjglbook-CN-Translation\\source"
}

87
docs/01-first-steps.md Normal file
View File

@@ -0,0 +1,87 @@
# 事前准备First Steps
在本书中我们将学习开发3D游戏所涉及的主要技术。本书将使用Java语言和Java轻量级游戏库([LWJGL](http://www.lwjgl.org/))来编写示例代码。LWJGL库允许我们访问底层的API应用程序接口例如OpenGL。
LWJGL是一个底层的API它像一个OpenGL包装库。如果你是想在短时间内开始制作一个3D游戏那么也许你该考虑别的选择比如使用引擎[JmonkeyEngine]。使用LWJGL这个底层API在你看到效果之前你需要了解许多概念并且编写大量的代码。这样做的好处是你可以更好的理解3D图形渲染并且可以更好的控制它。
在前文我们说过在本书中我们将使用Java。更确切来说我们将使用Java 10所以你需要从Oracle的页面下载对应版本的JDK。 请选择适合你的操作系统的安装程序。本书假定你对Java语言有一定的了解。
如果你需要一个可以运行示例代码的Java IDE集成开发环境你可以下载为Java 10提供良好支持的IntelliJ IDEA。 由于Java 10仅支持64位的平台记得下载64位版本的IntelliJ。IntelliJ提供有一个免费且开源的社区版你可以在这里下载它 [https://www.jetbrains.com/idea/download/](https://www.jetbrains.com/idea/download/ "Intellij").
![](_static/01/intellij.png)
为了构建我们的示例代码,我们将使用[Maven](https://maven.apache.org/)。Maven已经集成在大多数IDE中你可以在IDE中直接打开不同章节的示例代码。只要打开了示例代码的文件夹IntelliJ就会检测到它是一个Maven项目。
![](_static/01/maven_project.png)
Maven基于一个名为`pom.xml`Project Object Model项目对象模型的XML文件来构建项目它管理了项目的依赖需要使用的库和在构建过程中需要执行的步骤。Maven遵循约定高于配置的原则即如果你遵守标准的项目结构和命名约定就不需要在配置文件中明确地声明源文件在哪里或者应该在哪里编译类。
本书不是一个Maven教程如果有需要请在网上搜索Maven的相关资料。源代码文件夹定义了一个父项目它声明需要使用的插件并且声明需要使用的库的版本。
LWJGL 3.1 有了一些在项目构建上的改变。现在它变得更加模块化我们可以有选择的使用类库而不是导入一个巨大的Jar文件。
但这是有代价的:你需要仔细地逐个指定依赖关系。不过[LWJGL下载](https://www.lwjgl.org/download)页面提供了一个为您生成POM文件的脚本。在我们的示例中我们将只使用GLFW和OpenGL。你可以在源代码中查看我们的POM文件。
LWJGL平台依赖库已经可以为你的操作系统自动解压本地库因此不需要使用其他插件例如`mavennatives`。我们只需要配置三个Profile来设置LWJGL所处的操作系统。Profile将会为Windows、Linux和Mac OS系列设置正确的属性值。
```xml
<profiles>
<profile>
<id>windows-profile</id>
<activation>
<os>
<family>Windows</family>
</os>
</activation>
<properties>
<native.target>natives-windows</native.target>
</properties>
</profile>
<profile>
<id>linux-profile</id>
<activation>
<os>
<family>Linux</family>
</os>
</activation>
<properties>
<native.target>natives-linux</native.target>
</properties>
</profile>
<profile>
<id>OSX-profile</id>
<activation>
<os>
<family>mac</family>
</os>
</activation>
<properties>
<native.target>natives-osx</native.target>
</properties>
</profile>
</profiles>
```
在每一个项目中LWJGL平台依赖项将使用当前操作系统指定的配置中的属性值。
```xml
<dependency>
<groupId>org.lwjgl</groupId>
<artifactId>lwjgl-platform</artifactId>
<version>${lwjgl.version}</version>
<classifier>${native.target}</classifier>
</dependency>
```
此外每个项目生成一个可运行的Jar一种可以通过输入`java -jar name_of_the_jar.jar`就可运行的Jar。这是通过使用Maven的`maven-jar-plugin`插件实现的,该插件创建了一个含有`MANIFEST.MF`文件的Jar并且文件内有指定的值。该文件最重要的属性就是`Main-Class`,它指明了程序的入口。此外,所有的依赖库都被设置在该文件的`Class-Path`属性中。要在另一台计算机上运行它你只需要复制位于目标目录下的主Jar文件和Lib目录包括其中所有的Jar文件
Jar文件包含着LWJGL类和本地库。LWJGL还将负责提取它们并将它们添加到JVM的库路径中。
本章的源代码是LWJGL网站([http://www.lwjgl.org/guide](http://www.lwjgl.org/guide))的入门示例你可以看到我们没有使用Swing或JavaFX作为我们的GUI库。我们使用的是[GLFW](www.glfw.org)它是一个用来处理GUI组件窗口等和事件按键按下、鼠标移动等并且与OpenGL上下文进行简单连接的库。此前版本的LWJGL提供了一个自定义GUI API但在LWJGL 3中GLFW是首选的窗口API。
示例源码是简单的并且有着良好的文档,所以我们不会在书中再次说明。
如果你正确地配置了环境,你应该能够运行它并且看到一个有着红色背景的窗口。
![Hello World](_static/01/hello_world.png)
**本书中源代码发布于 [**GitHub**](https://github.com/lwjglgamedev/lwjglbook)**。

324
docs/02-the-game-loop.md Normal file
View File

@@ -0,0 +1,324 @@
# 游戏循环The Game Loop
在本章中,我们将通过创建我们的游戏循环来开始开发游戏引擎。游戏循环是每个游戏的核心部分。它基本上是一个无休止的循环,负责周期地处理用户的输入、更新游戏状态和渲染图形到屏幕上。
下面的代码片段展示了游戏循环的结构:
```java
while (keepOnRunning) {
handleInput();
updateGameState();
render();
}
```
那么,就这样了吗?我们已经完成游戏循环了吗?显然,还没有。上面的代码有很多缺陷。首先,游戏循环运行的速度将取决于运行它的计算机。如果计算机足够快,用户甚至看不到游戏中发生了什么。此外,这个游戏循环将消耗所有的计算机资源。
因此我们需要游戏循环独立于运行的计算机尝试以恒定速率运行。让我们假设我们希望游戏以每秒50帧50 Frames Per Second50 FPS的恒定速率运行。那么我们的游戏循环代码可能是这样的
```java
double secsPerFrame = 1.0d / 50.0d;
while (keepOnRunning) {
double now = getTime();
handleInput();
updateGameState();
render();
sleep(now + secsPerFrame getTime());
}
```
这个游戏循环很简单可以用于一些游戏但是它也存在一些缺陷。首先它假定我们的更新和渲染方法适合以50FPS`secsPerFrame`等于20毫秒的速度更新。
此外,我们的计算机可能会优先考虑暂停游戏循环运行一段时间,来运行其他的任务。因此,我们可能会在非常不稳定的时间周期更新游戏状态,这是不符合游戏物理的要求的。
最后线程休眠的时间精度仅仅只有0.1秒,所以即使我们的更新和渲染方法没有消耗时间,我们也不会以恒定的速率更新。所以,正如你看到的,问题没那么简单。
在网上你可以找到大量的游戏循环的变种。在本书中我们将用一个不太复杂的在大多数情况下都能正常工作的方法。我们将用的方法通常被称为固定步长游戏循环Fixed Step Game Loop
首先,我们可能想要单独控制游戏状态被更新的周期和游戏被渲染到屏幕的周期。为什么我们要这么做?因为,以恒定的速率更新游戏状态更为重要,特别是如果我们使用物理引擎。相反,如果我们的渲染没有及时完成,在运行我们的游戏循环时渲染旧帧是没有意义的。我们可以灵活地跳过某些帧。
让我们看看现在我们的游戏循环是什么样子的:
```java
double secsPerUpdate = 1.0d / 30.0d;
double previous = getTime();
double steps = 0.0;
while (true) {
double loopStartTime = getTime();
double elapsed = loopStartTime - previous;
previous = current;
steps += elapsed;
handleInput();
while (steps >= secsPerUpdate) {
updateGameState();
steps -= secsPerUpdate;
}
render();
sync(current);
}
```
通过这个游戏循环,我们可以在固定的周期更新我们的游戏状态。但是我们如何避免耗尽计算机资源,使它不连续渲染呢?这在`sync`方法中实现:
```java
private void sync(double loopStartTime) {
float loopSlot = 1f / 50;
double endTime = loopStartTime + loopSlot;
while(getTime() < endTime) {
try {
Thread.sleep(1);
} catch (InterruptedException ie) {}
}
}
```
那么我们在上述方法中做了什么呢?总而言之,我们计算我们的游戏循环迭代应该持续多长时间(它被储存在`loopSlot`变量中),我们休眠的时间取决于我们在循环中花费的时间。但我们不做一整段时间的休眠,而是进行一些小的休眠。这允许其他任务运行,并避免我们之前提到的休眠准确性问题。然后,我们要做的是:
1. 计算我们应该退出这个方法的时间(这个变量名为`endTime`),并开始我们的游戏循环的另一个迭代。
2. 比较当前时间和结束时间如果我们没有到达结束时间就休眠1毫秒。
现在是时候构造我们的代码,以便开始编写游戏引擎的第一个版本了。但在此之前,我们来讨论一下控制渲染速率的另一种方法。在上面的代码中,我们做微休眠,以控制我们需要等待的时间。但是我们可以选择另一种方法来限制帧率。我们可以使用**垂直同步**Vertical Synchronization。垂直同步的主要目的是避免画面撕裂。什么是画面撕裂这是一种显示现象当我们更新图像储存区时它正被渲染。结果是一部分图像显示先前的图像而另一部分图像显示正在渲染的图像。如果我们启用垂直同步当GPU中的内容正被渲染到屏幕上时我们不会向GPU发送数据。
> Now it is time to structure our code base in order to start writing our first version of our Game Engine. But before doing that we will talk about another way of controlling the rendering rate. In the code presented above, we are doing micro-sleeps in order to control how much time we need to wait. But we can choose another approach in order to limit the frame rate. We can use v-sync \(vertical synchronization\). The main purpose of v-sync is to avoid screen tearing. What is screen tearing? Its a visual effect that is produced when we update the video memory while its being rendered. The result will be that part of the image will represent the previous image and the other part will represent the updated one. If we enable v-sync we wont send an image to the GPU while it is being rendered onto the screen.
当我们开启垂直同步时,我们将与显卡的刷新率同步,显卡将以恒定的帧率渲染。用下面一行代码启用它:
```java
glfwSwapInterval(1);
```
有了上面的代码,就意味着我们,至少在一个屏幕更新被绘制到屏幕之前,必须等待。事实上,我们不是直接绘制到屏幕上。相反,我们将数据储存在缓冲区中,然后用下面的方法交换它:
```java
glfwSwapBuffers(windowHandle);
```
因此如果我们启用垂直同步我们就可以实现稳定的帧率而不需要进行微休眠来检查更新时间。此外帧率将与我们的显卡刷新率相匹配。也就是说如果它设定为60Hz60FPS那么我们就有60FPS。我们可以通过在`glfwSwapInterval`方法中设置高于1的数字来降低这个速率如果我们设置为2我们将得到30FPS
让我们整理一下源代码。首先我们将把所有的GLFW窗口初始化代码封装在一个名为`Window`的类中,提供一些基本的参数(如标题和大小)。`Window`类还提供一个方法以便在游戏循环中检测按下的按键:
```java
public boolean isKeyPressed(int keyCode) {
return glfwGetKey(windowHandle, keyCode) == GLFW_PRESS;
}
```
除了有初始化代码以外,`Window`类还需要知道调整大小。因此,需要设置一个回调方法,在窗口大小被调整时调用它。回调方法将接收帧缓冲区的以像素为单位的宽度和高度(绘制区域,简单来说就是显示区域)。如果希望得到帧缓冲区的宽度、高度,你可以使用`glfwSetWindowSizeCallback`方法。屏幕坐标不一定对应像素例如具有视网膜显示屏Retina Display的Mac设备。因为我们将在进行OpenGL调用时使用这些信息所以我们要注意像素不在屏幕坐标中。您可以通过GLFW的文档了解更多信息。
```java
// Setup resize callback
glfwSetFramebufferSizeCallback(windowHandle, (window, width, height) -> {
Window.this.width = width;
Window.this.height = height;
Window.this.setResized(true);
});
```
我们还将创建一个`Renderer`类,它将处理我们游戏的渲染。现在,它仅会有一个空的`init`方法,和另一个用预设的颜色清空屏幕的方法:
```java
public void init() throws Exception {
}
public void clear() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
```
然后我们将创建一个名为`IGameLogic`的接口,它封装了我们的游戏逻辑。这样,我们就可以让游戏引擎在不同的游戏上重复使用。该接口将具有获取输入、更新游戏状态和渲染游戏内容的方法。
```java
public interface IGameLogic {
void init() throws Exception;
void input(Window window);
void update(float interval);
void render(Window window);
}
```
然后我们将创建一个名为`GameEngine`的类,它将包含我们游戏循环的代码。这个类实现了`Runnable`接口,因为游戏循环将要在单独的线程中运行。
```java
public class GameEngine implements Runnable {
//..[Removed code]..
private final Thread gameLoopThread;
public GameEngine(String windowTitle, int width, int height, boolean vsSync, IGameLogic gameLogic) throws Exception {
gameLoopThread = new Thread(this, "GAME_LOOP_THREAD");
window = new Window(windowTitle, width, height, vsSync);
this.gameLogic = gameLogic;
//..[Removed code]..
}
```
`vSync`参数允许我们选择是否使用垂直同步。你可以看到我们创建了一个新线程,它将执行我们的`GameEngine`类的`run`方法,该类包含着我们的游戏循环:
```java
public void start() {
gameLoopThread.start();
}
@Override
public void run() {
try {
init();
gameLoop();
} catch (Exception excp) {
excp.printStackTrace();
}
}
```
我们的`GameEngine`类提供了一个`start`方法,它仅会启动我们的线程,因此`run`方法将异步执行。`run`方法将执行初始化并运行游戏循环直到我们关闭窗口。在线程中初始化GLFW是非常重要的在之后我们才更新它。因此`init`方法中,我们的窗口和`Renderer`实例被初始化。
在源代码中,你将看到我们创建了其他辅助类,例如`Timer`(它将提供用于计算已经过的时间的实用方法),并在我们的游戏循环逻辑中使用它们。
我们的`GameEngine`类只是将`input``update`方法委托给`IGameLogic`实例。在`render`方法中,它也委托给`IGameLogic`实例并更新窗口。
```java
protected void input() {
gameLogic.input(window);
}
protected void update(float interval) {
gameLogic.update(interval);
}
protected void render() {
gameLogic.render(window);
window.update();
}
```
在程序的入口,包含`main`方法的类只会创建一个`GameEngine`实例并启动它。
```java
public class Main {
public static void main(String[] args) {
try {
boolean vSync = true;
IGameLogic gameLogic = new DummyGame();
GameEngine gameEng = new GameEngine("GAME",
600, 480, vSync, gameLogic);
gameEng.start();
} catch (Exception excp) {
excp.printStackTrace();
System.exit(-1);
}
}
}
```
最后,我们只需要创建游戏逻辑类,本章中我们实现一个简单的游戏逻辑。它只会在按下上或下键时,增加或降低窗口的颜色缓冲区的清空颜色。`render`方法将会用这个颜色清空窗口的颜色缓冲区。
```java
public class DummyGame implements IGameLogic {
private int direction = 0;
private float color = 0.0f;
private final Renderer renderer;
public DummyGame() {
renderer = new Renderer();
}
@Override
public void init() throws Exception {
renderer.init();
}
@Override
public void input(Window window) {
if ( window.isKeyPressed(GLFW_KEY_UP) ) {
direction = 1;
} else if ( window.isKeyPressed(GLFW_KEY_DOWN) ) {
direction = -1;
} else {
direction = 0;
}
}
@Override
public void update(float interval) {
color += direction * 0.01f;
if (color > 1) {
color = 1.0f;
} else if ( color < 0 ) {
color = 0.0f;
}
}
@Override
public void render(Window window) {
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
window.setClearColor(color, color, color, 0.0f);
renderer.clear();
}
}
```
`render`方法中,当窗口大小被调整时,我们接收通知,以便更新视口将坐标中心定位到窗口的中心。
我们创建的类层次结构将帮助我们将游戏引擎代码与具体的游戏代码分开。虽然现在可能看起来没有必要,但是我们将每个游戏的通用代码从具体的游戏的逻辑、美术作品和资源中分离出来,以便重用我们的游戏引擎。在之后的章节中,我们需要重构这个类层次结构,因为我们的游戏引擎变得更加复杂。
## 线程问题
如果你试图在OSX系统运行前面提供的源代码你将得到这样的错误
```
Exception in thread "GAME_LOOP_THREAD" java.lang.ExceptionInInitializerError
```
这是什么意思这是因为GLFW库的某些功能不能在`Thread`中调用,因为这不是主`Thread`。我们在初始化时,包括在`GameEngine`类的`init`方法中的窗口创建。这些方法都是由同一类的`run`方法调用,而`run`方法由一个新的`Thread`调用,而不是用来启动程序的主线程调用。
这是GLFW库的一个限制它基本上意味着我们应该避免为游戏循环创建新线程。我们可以尝试在主线程中创建所有与窗口相关的东西但是我们将无法渲染任何东西。问题是OpenGL的调用需要在创建其上下文Context的同一个`Thread`中运行。
在Windows和Linux平台上即使我们不能使用主线程初始化GLFW示例代码也能运行。问题就是在OS X平台上所以我们需要更改我们的`GameEngine`类的`run`方法的代码使它支持这个平台,就像这样:
```java
public void start() {
String osName = System.getProperty("os.name");
if ( osName.contains("Mac") ) {
gameLoopThread.run();
} else {
gameLoopThread.start();
}
}
```
我们正做的是当我们在OS X平台上时忽略游戏循环线程并直接在主线程运行游戏循环代码。这不是一个完美的解决方案但它将允许你在Mac上运行示例。在论坛上找到的其他解决方案例如启动JVM时添加`-XstartOnFirstThread`参数)似乎不起作用。
在将来如果LWJGL提供其他的GUI库来检查此限制是否适用于它们这可能是值得探讨的。非常感谢Timo Bühlmann指出这个问题。
> In the future it may be interesting to explore if LWJGL provides other GUI libraries to check if this restriction applies to them. (Many thanks to Timo Bühlmann for pointing out this issue).
## 平台差异OS X
你可以运行上面的代码在Windows或Linux上但我们仍需要为OSX平台做一些修改。正如GLFW文档中所描述的
> 目前OS X仅支持的OpenGL 3.x和4.x版本的上下文是向上兼容的。OS X 10.7 Lion支持OpenGL 3.2版本和OS X 10.9 Mavericks支持OpenGL 3.3和4.1版本。在任何情况下你的GPU需要支持指定版本的OpenGL以便上下文创建成功。
因此,为了支持在之后的章节中介绍的特性,我们需要将这些代码添加到`Window`类创建窗口代码之前:
```java
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
```
这将使程序使用OpenGL 3.2到4.1之间的最高版本。如果没有这些代码就会使用旧版本的OpenGL。

View File

@@ -0,0 +1,45 @@
# 坐标简介A brief about coordinates
本章节中我们将讨论坐标和坐标系coordinate system尝试以简单的方式介绍一些基本的数学概念为后面章节将要介绍的技术和内容提供帮助。我们将一些内容简化为了易于学习而牺牲准确性。
我们通过指定坐标来确定物体在空间中的位置。想想地图。通过在地图上指定纬度和经度来确定一个点。只需一对数字,就可以精确的确认一个点。这对数字就是点坐标(实际上有些复杂,因为地图是一个不完美的椭圆球体(地球是不完美的椭圆球体)的投影,所以需要更多的数据,但这是一个很好的类比)
坐标系是一个系统,它使用一个或多个数字,即一个或多个坐标来唯一地指定一个点的位置。存在着多种不同的坐标系(如笛卡尔坐标系,极坐标系等),并且可以将坐标从一个坐标系转换到另一个坐标系。我们将使用笛卡尔坐标系。
在笛卡尔坐标系中对于二维坐标由两个数字定义它们表示到两个相互垂直的X、Y轴的距离。
![Cartesian Coordinate System](_static/03/cartesian_coordinate_system.png)
继续类比地图坐标系定义一个原点。对于地理坐标原点被设置为赤道和零度经线交叉的点。根据我们原点设置的位置特定点的坐标是不同的。坐标系也可以定义轴的方向。在上图中X坐标随着点向右移动而增加Y坐标随着点向上移动而增加。但是我们也可以定义一个与笛卡尔坐标系不同的具有不同的轴取向的坐标系我们将得到不同的坐标。
![Alternative Cartesian Coordinate System](_static/03/alt_cartesian_coordinate_system.png)
正如你所看到的那样,我们需要定义一些参数,例如原点和轴方向,以便给构成坐标的数字对给出适当的含义。为了使用一组坐标,我们必须使用对应的坐标系。好消息是我们可以通过平移和旋转来将坐标从一个坐标系转换到另一个坐标系。
如果我们要处理三维坐标我们需要增加一个轴即Z轴。三维坐标将由三个数字(x, y, z)构成。
![3D Cartesian Coordinate System](_static/03/3d_cartesian_coordinate_system.png)
在二维笛卡尔坐标系中,只要轴相互垂直,我们就可以改变三维坐标系中的轴的方向。下图展示了另一个三维坐标系。
![Alternative 3D Cartesian Coordinate System](_static/03/alt_3d_cartesian_coordinate_system.png)
三维坐标可分为左手系和右手系两种类型。你怎么知道它是什么类型的用你的手在你的拇指和食指之间形成一个“L”中指应指向垂直于其他两个手指的方向。拇指应该指向X轴的正方向食指应该指向Y轴的正方向而中指应该指向Z轴的正方向。如果你能用左手做到那么它就是左手系如果你需要用右手那它就是右手系。
![Right Handed vs Left Handed](_static/03/righthanded_lefthanded.png)
二维坐标系是相同的,因为通过旋转,我们可以从一个坐标系转换到另一个坐标系。但是,三维坐标系并不都是相同的。如果它们可以使用相同的手来表示,也就是说,如果两者都是左手系或者右手系,那么就能通过旋转一个坐标系到另一个坐标系。
现在我们已经确定了一些基本的概念,让我们来讲解一些在处理三维图形时常用的术语。当我们在之后的章节中解释如何渲染三维模型时,我们将看到我们使用不同的三维坐标系,这是因为每个坐标系都有不同的设定,不同的目的。一组坐标是没有意义的,除非明确它是某个坐标系的坐标。当你看到这个坐标(40.438031, -3.676626)时,你可能会有一个大胆的想法。但是如果我说他们是几何坐标(经度和纬度),你就会发现它们是马德里某个地方的坐标。
当我们加载三维物体时我们将得到一组三维坐标。这些坐标在被称为物体坐标系object coordinate space的三维坐标系中表达。当建模师在设计这些三维模型的时候他们对该模型将显示的三维场景毫不知情因此只能使用与模型相关的坐标系来定义坐标。
当我们将绘制一个三维场景时,我们所有的三维物体将与被称为世界的坐标系对应。我们需要将三维物体的坐标系转换到世界坐标系。一些物体需要旋转、拉伸、放大和转换,以便在三维场景中能够正确地显示。
我们还需要限制所显示的三维空间的范围,例如移动摄像机穿梭在我们的三维空间中。然后我们需要将世界坐标转换成摄像机或视口坐标。最后,这些坐标需要转换为二维的屏幕坐标,所以我们需要将三维视图坐标投影到二维屏幕坐标系。
下面的图片展示了OpenGL坐标系Z轴垂直于屏幕坐标在-1和+1之间。
![OpenGL coordinates](_static/03/opengl_coordinates.png)
如果你不能清晰的理解这些概念,别担心。在下一章节中,它们将用实例表现出来。

356
docs/04-rendering.md Normal file
View File

@@ -0,0 +1,356 @@
# 渲染Rendering
在本章中我们将学习用OpenGL渲染场景时所要做的事情。如果你已经习惯了OpenGL的旧版本习惯了使用固定管线你可能会跳过这一章不想知道为什么它需要这么复杂。它其实更简单更灵活你只需要给它一个表现的机会。现代OpenGL让你只需考虑一个问题这可以让你以更合理的方式组织代码和开发。
将三维表示映射到二维屏幕的一系列步骤被称为图形管线(`Graphics Pipeline`。OpenGL最初的版本使用了一个被称为固定管线`Fixed-function Pipeline`的模型。该模型在绘制过程中定义了一组固定的操作步骤。程序员被每一步可用的函数集约束可以使用的效果和进行的操作受到API本身例如“设置雾”或“添加光照”的限制但是这些功能的实现是固定的并且不能改变。
固定管线由以下步骤组成:
![Graphics Pipeline](_static/04/rendering_pipeline.png)
OpenGL 2.0 引入了可编程管线(`Programmable Pipeline`)的概念。在这个模型中,组成图形管线的不同步骤可以通过使用一组叫做着色器(`Shader`的特定程序来控制或编程。下面的图片简单的描述了OpenGL可编程管线
![Programmable Pipeline](_static/04/rendering_pipeline_2.png)
该渲染最初将以顶点缓冲区为形式的一系列顶点作为输入。但是,什么是顶点?顶点(`Vertex`是描述二维或者三维空间中的点的数据结构。如何描述三维空间中的一个点呢通过指定其X、Y和Z坐标。什么是顶点缓冲区顶点缓冲区`Vertex Buffer`)是使用顶点数组来包装所有需要渲染的顶点的另一种数据结构,并使这些数据能在图形管线的着色器中使用。
这些顶点由顶点着色器(`Vertex Shader`)处理,其主要目的是计算每个顶点到屏幕空间中的投影位置。该着色器还可以生成与颜色或纹理相关的其他输出,但其主要目的是将顶点投影到屏幕空间中,即生成点。
几何处理(`Geometry Processing`)阶段将由顶点着色器变化的顶点连接成三角形。它考虑到顶点储存的顺序,并使用不同的模型对它们进行分组。为什么是三角形?三角形(`Triangle`)就是显卡的基本工作单元。它是一个简单的几何形状,可以组合和变换,以构建复杂的三维场景。这个阶段还可以使用特定的着色器来对顶点进行分组。
光栅化(`Rasterization`)阶段接收此前生成的三角形,剪辑它们,并将它们转换成像素大小的片元。
这些片元将在片元处理(`Fragment Processing`)阶段被片元着色器(`Fragment Shader`)使用,以生成像素写入到帧缓冲区的最终颜色。帧缓冲区(`Framebuffer`)是图形管线的最终输出。它储存了每个像素应该被绘制到屏幕上的值。
注意,显卡被设计成并行处理上述所有操作。输入数据可以并行处理以生成最终场景。
让我们开始编写第一个着色器程序。着色器是使用基于ANSI C的GLSLOpenGL着色器语言编写的。首先`resources`目录下创建一个名为“`vertex.vs`”(扩展名为顶点着色器英文简写)的文件,内容如下:
```glsl
#version 330
layout (location=0) in vec3 position;
void main()
{
gl_Position = vec4(position, 1.0);
}
```
第一行是一个表示我们正在使用的GLSL语言版本的标识符。下表是GLSL版本、与该版本匹配的OpenGL版本和使用方法来自维基百科 [https://en.wikipedia.org/wiki/OpenGL\_Shading\_Language\#Versions](https://en.wikipedia.org/wiki/OpenGL_Shading_Language#Versions))。
| GLSL版本 | OpenGL版本 | 着色器标识符 |
| --- | --- | --- |
| 1.10.59 | 2.0 | \#version 110 |
| 1.20.8 | 2.1 | \#version 120 |
| 1.30.10 | 3.0 | \#version 130 |
| 1.40.08 | 3.1 | \#version 140 |
| 1.50.11 | 3.2 | \#version 150 |
| 3.30.6 | 3.3 | \#version 330 |
| 4.00.9 | 4.0 | \#version 400 |
| 4.10.6 | 4.1 | \#version 410 |
| 4.20.11 | 4.2 | \#version 420 |
| 4.30.8 | 4.3 | \#version 430 |
| 4.40 | 4.4 | \#version 440 |
| 4.50 | 4.5 | \#version 450 |
第二行指定此着色器的输入格式。OpenGL缓冲区中的数据可以是我们想要的任何东西也就是说语言不会强迫你传递预定义语义的任何特定数据结构。从着色器的角度来看它希望接收带有数据的缓冲区。它可以是一个坐标一个有一些附加信息的坐标或者我们想要的任何东西。顶点着色器只接收浮点数组。当填充缓冲区时我们定义要由着色器处理的缓冲块。
首先需要把这些块变成对我们有意义的东西。现在规定从位置0开始我们期望接收由三个属性X、Y、Z组成的向量。
着色器有个主代码块就像任何C语言程序一样这是示例是非常简单的。它只是将接收到的坐标不经任何变换返回到`gl_Position`。你现在可能想知道为什么三个属性的向量被转换成四个属性的向量(`vec4`)。这是因为`gl_Position`仅接受`vec4`类型的数据,因为它是齐次坐标(`Homogeneous Coordinates`)。也就是说,它希望接收到形似(X, Y, Z, W)的东西其中W代表一个额外的维度。为什么还要添加另一个维度在之后的章节中你会看到我们需要做的大部分操作都是基于向量和矩阵的。如果没有额外的维度一些操作不能组合。例如不能把旋转和变换操作组合起来。如果你想学习更多有关于这方面的知识这个额外的维度允许我们组合仿射和线性变换。你可以通过阅读《3D Math Primer for Graphics and Game development》作者是Fletcher Dunn 和 Ian Parberry来更多地了解这一点。
现在来看看我们的第一个片元着色器。在`resources`目录下创建一个名为`fragment.fs`(扩展名片元着色器英文简写)的文件,内容如下:
```glsl
#version 330
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 0.5, 0.5, 1.0);
}
```
该结构与我们的顶点着色器非常相似。现在,它将为每个片元设置固定的颜色。输出被定义为第二行的`vec4`类型的`fragColor`变量。
现在我们已经创建了着色器,该如何使用它们呢?以下是我们要做的一系列步骤:
1. 创建OpenGL程序
2. 载入顶点和片元着色器文件
3. 对每个着色器创建一个新的着色器程序并指定它的类型(顶点或片元)
4. 编译着色器
5. 将着色器绑定到OpenGL程序上
6. 连接程序
最后着色器将会被载入到显卡中我们可以通过引用程序ID来使用它。
```java
package org.lwjglb.engine.graph;
import static org.lwjgl.opengl.GL20.*;
public class ShaderProgram {
private final int programId;
private int vertexShaderId;
private int fragmentShaderId;
public ShaderProgram() throws Exception {
programId = glCreateProgram();
if (programId == 0) {
throw new Exception("Could not create Shader");
}
}
public void createVertexShader(String shaderCode) throws Exception {
vertexShaderId = createShader(shaderCode, GL_VERTEX_SHADER);
}
public void createFragmentShader(String shaderCode) throws Exception {
fragmentShaderId = createShader(shaderCode, GL_FRAGMENT_SHADER);
}
protected int createShader(String shaderCode, int shaderType) throws Exception {
int shaderId = glCreateShader(shaderType);
if (shaderId == 0) {
throw new Exception("Error creating shader. Type: " + shaderType);
}
glShaderSource(shaderId, shaderCode);
glCompileShader(shaderId);
if (glGetShaderi(shaderId, GL_COMPILE_STATUS) == 0) {
throw new Exception("Error compiling Shader code: " + glGetShaderInfoLog(shaderId, 1024));
}
glAttachShader(programId, shaderId);
return shaderId;
}
public void link() throws Exception {
glLinkProgram(programId);
if (glGetProgrami(programId, GL_LINK_STATUS) == 0) {
throw new Exception("Error linking Shader code: " + glGetProgramInfoLog(programId, 1024));
}
if (vertexShaderId != 0) {
glDetachShader(programId, vertexShaderId);
}
if (fragmentShaderId != 0) {
glDetachShader(programId, fragmentShaderId);
}
glValidateProgram(programId);
if (glGetProgrami(programId, GL_VALIDATE_STATUS) == 0) {
System.err.println("Warning validating Shader code: " + glGetProgramInfoLog(programId, 1024));
}
}
public void bind() {
glUseProgram(programId);
}
public void unbind() {
glUseProgram(0);
}
public void cleanup() {
unbind();
if (programId != 0) {
glDeleteProgram(programId);
}
}
}
```
`ShaderProgram`类的构造函数在OpenGL中创建一个新的程序并提供添加顶点和片元着色器的方法。这些着色器被编译并绑定到OpenGL程序中。当所有的着色器都被绑定时应该调用`link`方法,来连接所有代码并验证所有操作都已正确地完成。
一旦着色器程序被连接,编译的顶点和片元着色器可以被释放(通过调用`glDetachShader`方法)
关于验证,是通过调用`glValidateProgram`方法完成的。此方法主要用于调试,当游戏到达生产阶段时,应将其删除。此方法将验证在**当前OpenGL状态**下着色器是否正确。这意味着,即使着色器是正确的,在某些情况下也可能验证失败,这是因为当前状态不够完整(一些数据可能尚未加载),无法运行着色器。因此,我们可以将错误信息输出到标准错误输出中。
`ShaderProgram`类还提供了在渲染时激活该程序(绑定)和停止使用它(解绑)的方法。最后,它提供了一个`cleanup`方法,用于当它不再被需要时,释放所有资源。
既然有一个清理方法,让我们更改`IGameLogic`接口来添加一个`cleanup`方法:
```java
void cleanup();
```
这个方法将在游戏循环结束时调用,所以需要修改`GameEngine`类的`run`方法:
```java
@Override
public void run() {
try {
init();
gameLoop();
} catch (Exception excp) {
excp.printStackTrace();
} finally {
cleanup();
}
}
```
现在我们可以使用着色器来显示一个三角形。这将在`Renderer`类的`init`方法中进行。首先,我们要创建着色器程序:
```java
public void init() throws Exception {
shaderProgram = new ShaderProgram();
shaderProgram.createVertexShader(Utils.loadResource("/vertex.vs"));
shaderProgram.createFragmentShader(Utils.loadResource("/fragment.fs"));
shaderProgram.link();
}
```
我们已经创建了一个工具类,它提供了一个从类路径中取得文件内容的方法。此方法用于取得着色器的内容。
现在我们可以把我们的三角形定义为一组浮点数。我们创建一个一维浮点数组它将定义三角形的顶点。正如你所看到的数组中没有结构。就目前而言OpenGL无法知道该数组的结构这只是一组浮点数
```java
float[] vertices = new float[]{
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
};
```
下图体现了在坐标系中的三角形。
![Coordinates Syste](_static/04/triangle_coordinates.png)
现在有了坐标我们需要把它们储存到我们的显卡中并告诉OpenGL它的结构。现在将介绍两个重要的概念顶点数组对象`Vertex Array Object` VAO和顶点缓冲对象`Vertex Buffer Object`, VBO。如果你对下一段代码感到疑惑请记住现在所做的是把将要绘制的模型对象数据传递到显卡的储存器中。当储存它的时候我们将得到一个ID稍后再绘制时会使用它。
先介绍顶点缓冲对象VBO吧。VBO只是显卡储存器中存储顶点的内存缓冲区。这是用来暂存一组三角形模型的浮点数的地方。正如之前所说的OpenGL对我们的数据结构一无所知。事实上它不仅可以保存坐标还可以保存其他信息比如纹理、颜色等。
顶点数组对象VAO是包含一个或多个VBO的对象通常被称为属性列表。每个属性列表可以保存一种类型的数据位置、颜色、纹理等。在每个渲染间隔中你可以自由地储存所需的任何数据。
一个VAO就像是一个包装它按一组定义对储存在显卡中的数据分组。当我们创建一个VAO是我们得到一个ID。我们使用这个ID来渲染它和使用它在创建过程中按特定定义的数据。
让我们继续编写示例代码。必须做的第一件事就是把浮点数储存在一个`FloatBuffer`中。这主要是因为我们必须使用基于C语言的OpenGL库的接口所以必须把浮点数转换成可以由库管理的东西。
```java
FloatBuffer verticesBuffer = MemoryUtil.memAllocFloat(vertices.length);
verticesBuffer.put(vertices).flip();
```
我们使用`MemoryUtil`类来在堆内存中创建一个缓冲区以便于OpenGL库访问。在储存了数据调用`put`方法)之后,我们需要调用`flip`方法将缓冲区的位置重置为0也就是说我们已经完成了对它的写入。记住Java中的对象被分配在一个叫堆`Heap`)的内存空间。堆是JVM内存中保留的一大堆内存。储存在堆中的对象不能通过本地代码访问JNI这种机制使得Java不能直接调用本地代码。Java代码和本地代码直接共享内存数据的唯一方法是直接在Java分配内存。
如果你来自LWJGL的旧版本强调一些要点是很重要的。你可能注意到了我们不使用工具类`BufferUtils`来创建缓冲区。相反,我们使用`MemoryUtil`类。这是由于`BufferUtils`不是非常有效的并且仅被用于向后兼容。相反LWJGL 3提供了两种缓冲区的管理方法
* 自动管理缓冲区即由垃圾回收器自动回收的缓冲区。这些缓冲区主要用于短暂的操作或者用于传递到GPU的数据并且不需要存在于进程内存中。这是通过使用`org.lwjgl.system.MemoryStack`实现的。
* 手动管理缓冲区。这种情况下,一旦完成操作,我们需要小心地释放它们。这些缓冲区用于长时间操作或者大量的数据。这是通过使用`MemoryUtil`类实现的。
你可以在此处查阅细节:
[https://blog.lwjgl.org/memory-management-in-lwjgl-3/](https://blog.lwjgl.org/memory-management-in-lwjgl-3/ "here")
在这种情况下我们的数据被发送到GPU这样就可以使用自动管理的缓冲区。但是稍后我们将使用它们来储存可能需要手动管理的大量数据。这就是为什么我们使用`MemoryUtil`类,因此,这就是为什么我们要在最后一个块中释放缓冲区资源。在下一章中,我们将学习如何使用自动管理缓冲区。
现在需要创建VAO然后绑定它。
```java
vaoId = glGenVertexArrays();
glBindVertexArray(vaoId);
```
然后需要创建VBO绑定它并将数据输入。
```java
vboId = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, verticesBuffer, GL_STATIC_DRAW);
memFree(verticesBuffer);
```
现在是最重要的部分。我们需要定义数据的结构并将其储存在VAO的属性列表中。这是用下面一行代码完成的。
```java
glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);
```
它的参数是:
* index: 指定着色器期望此数据的位置。
* size: 指定每个顶点属性的数据数从1到4。现在我们使用三维坐标所以它应该是3。
* type: 指定数组中每个数据的类型,现在是浮点数。
* normalized: 指定值是否应规范化。
* stride: 指定连续顶点数据之间的字节偏移量(稍后我们再解释)。
* offset: 指定缓冲区中第一个数据的偏移量。
在完成了VBO操作之后我们可以解除它和VAO的绑定绑定到0
```java
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
```
一旦完成操作,我们**必须**释放由`FloatBuffer`占用的堆内存,这是通过手动调用`memFree`方法完成的因为Java垃圾回收不会清理分配的堆内存。
```java
if (verticesBuffer != null) {
MemoryUtil.memFree(verticesBuffer);
}
```
这就是`init`方法应该有的代码。我们的数据已经在显卡中,准备使用了。现在只需要修改我们的`render`方法在游戏循环中进行渲染。
```java
public void render(Window window) {
clear();
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
shaderProgram.bind();
// 绑定VAO
glBindVertexArray(vaoId);
glEnableVertexAttribArray(0);
// 绘制顶点
glDrawArrays(GL_TRIANGLES, 0, 3);
// 还原状态
glDisableVertexAttribArray(0);
glBindVertexArray(0);
shaderProgram.unbind();
}
```
正如你所看到的我们只需要清理窗口绑定着色器程序绑定VAO绘制储存在VAO关联的VBO中的顶点然后还原状态。仅此而已。
我们还在`Renderer`类中添加了一个`cleanup`方法用于释放资源。
```java
public void cleanup() {
if (shaderProgram != null) {
shaderProgram.cleanup();
}
glDisableVertexAttribArray(0);
// 删除VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(vboId);
// 删除VAO
glBindVertexArray(0);
glDeleteVertexArrays(vaoId);
}
```
就这样!如果你小心地按着上述步骤做,你会看到类似的东西。
这是我们的第一个三角形!你也许会想这并不会使它成为前十名的游戏,这是正确的。你也可以认为这是一件无聊的工作来画一个无聊的三角形。但请记住,我们正在介绍关键的概念,并准备基于架构来做更复杂的事情。请耐心等待,继续阅读。

View File

@@ -0,0 +1,309 @@
# 更多关于渲染的事情More on Rendering
本章中我们将继续讨论OpenGL如何渲染物体。为了整理代码我们要创建一个名为`Mesh`的新类把一个坐标数组作为输入创建VBO和VAO对象把VBO和VAO对象加载到显卡中。
```java
package org.lwjglb.engine.graph;
import java.nio.FloatBuffer;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.opengl.GL15.*;
import static org.lwjgl.opengl.GL20.*;
import static org.lwjgl.opengl.GL30.*;
import org.lwjgl.system.MemoryUtil;
public class Mesh {
private final int vaoId;
private final int vboId;
private final int vertexCount;
public Mesh(float[] positions) {
FloatBuffer verticesBuffer = null;
try {
verticesBuffer = MemoryUtil.memAllocFloat(positions.length);
vertexCount = positions.length / 3;
verticesBuffer.put(positions).flip();
vaoId = glGenVertexArrays();
glBindVertexArray(vaoId);
vboId = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, verticesBuffer, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
} finally {
if (verticesBuffer != null) {
MemoryUtil.memFree(verticesBuffer);
}
}
}
public int getVaoId() {
return vaoId;
}
public int getVertexCount() {
return vertexCount;
}
public void cleanUp() {
glDisableVertexAttribArray(0);
// 删除VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(vboId);
// 删除VAO
glBindVertexArray(0);
glDeleteVertexArrays(vaoId);
}
}
```
我们将在`DummyGame`类中实例化`Mesh`,从`Renderer``init`方法中删除VAO和VBO代码。在`Renderer`类的渲染方法中也将接收一个`Mesh`对象来渲染。`cleanup`方法也被简化,因为`Mesh`类已经提供了一个释放VAO和VBO资源的方法。
```java
public void render(Mesh mesh) {
clear();
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
shaderProgram.bind();
// 绘制
glBindVertexArray(mesh.getVaoId());
glEnableVertexAttribArray(0);
glDrawArrays(GL_TRIANGLES, 0, mesh.getVertexCount());
// 还原状态
glDisableVertexAttribArray(0);
glBindVertexArray(0);
shaderProgram.unbind();
}
public void cleanup() {
if (shaderProgram != null) {
shaderProgram.cleanup();
}
}
```
值得注意的一点是:
```java
glDrawArrays(GL_TRIANGLES, 0, mesh.getVertexCount());
```
`Mesh`类通过将坐标数组除以3来计算顶点的数目因为我们使用XY和Z坐标。现在我们可以渲染更复杂的形状。来渲染一个正方形吧。一个正方形可以用两个三角形来组成如图所示。
![Quad coordinates](_static/05/quad_coordinates.png)
正如你所看到的这两个三角形中的每一个都由三个顶点组成。第一个三角形由顶点V1、V2和V4橙色的点组成第二个三角形由顶点V4V2和V3绿色的点组成。顶点以逆时针顺序连接因此要传递的浮点数数组应该是[V1, V2, V4, V4, V2, V3]。因此,`DummyGame``init`方法将是这样的:
```java
@Override
public void init() throws Exception {
renderer.init();
float[] positions = new float[]{
-0.5f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
};
mesh = new Mesh(positions);
}
```
现在你应该可以看到这样一个正方形:
![Quad rendered](_static/05/quad_rendered.png)
我们完成了吗没有。上面的代码仍然存在一些问题。我们使用了重复的坐标来表示正方形。我们传递了两次V2和V4坐标。这是个小形状它可能不是什么大问题但想象一个更复杂的3D模型我们会多次重复传递坐标。记住我们使用三个浮点数表示顶点的位置但稍后我们将需要更多的数据来表示纹理等。考虑到在更复杂的形状中三角形直接共享的顶点数量可以更高如图所示其中顶点可以在六个三角形之间共享
![Dolphin](_static/05/dolphin.png)
最后,我们需要更多的内存来储存重复的数据,这就是索引缓冲区(`Index Buffer`)发挥作用的地方。为了绘制正方形我们只需要以这样的方式指定每个顶点V1, V2, V3, V4。每个顶点在数组中都有一个位置。V1在位置0上V2在位置1上等。
| V1 | V2 | V3 | V4 |
| --- | --- | --- | --- |
| 0 | 1 | 2 | 3 |
然后,我们通过引用它们的位置来指定这些顶点的顺序:
| 0 | 1 | 3 | 3 | 1 | 2 |
| --- | --- | --- | --- | --- | --- |
| V1 | V2 | V4 | V4 | V2 | V3 |
因此,我们需要修改`Mesh`类来接收另一个参数,一个索引数组,现在绘制的顶点数量是该索引数组的长度。
```java
public Mesh(float[] positions, int[] indices) {
vertexCount = indices.length;
```
在创建了储存坐标的VBO之后我们需要创建另一个VBO来保存索引。因此重命名持有坐标的VBO的ID的变量名并为索引VBO`idxVboId`创建一个ID。创建VBO的过程相似但现在的类型是`GL_ELEMENT_ARRAY_BUFFER`
```java
idxVboId = glGenBuffers();
indicesBuffer = MemoryUtil.memAllocInt(indices.length);
indicesBuffer.put(indices).flip();
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, idxVboId);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indicesBuffer, GL_STATIC_DRAW);
memFree(indicesBuffer);
```
因为我们是在处理整数,所有需要创建一个`IntBuffer`而不是一个`FloatBuffer`
就是这样。现在VAO包含两个VBO一个储存坐标另一个储存索引。`Mesh`类的`cleanUp`方法也必须考虑到要释放另一个VBO。
```java
public void cleanUp() {
glDisableVertexAttribArray(0);
// 删除 VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
glDeleteBuffers(posVboId);
glDeleteBuffers(idxVboId);
// 删除 VAO
glBindVertexArray(0);
glDeleteVertexArrays(vaoId);
}
```
最后,我们需要修改`glDrawArrays`调用的方法:
```java
glDrawArrays(GL_TRIANGLES, 0, mesh.getVertexCount());
```
改为调用`glDrawElements`方法:
```java
glDrawElements(GL_TRIANGLES, mesh.getVertexCount(), GL_UNSIGNED_INT, 0);
```
方法的参数如下:
* mode: 指定渲染的图元类型,现在是三角形。没有什么变化。
* count: 指定要渲染的顶点数。
* type: 指定索引数据的类型。现在是无符号整数型。
* indices: 指定要开始使用索引渲染的数据偏移量。
现在可以使用新的和更有效的方法来绘制复杂的模型了,仅需指定索引。
```java
public void init() throws Exception {
renderer.init();
float[] positions = new float[]{
-0.5f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
};
int[] indices = new int[]{
0, 1, 3, 3, 1, 2,
};
mesh = new Mesh(positions, indices);
}
```
现在给示例代码增加颜色吧。我们把另一组浮点数传递给`Mesh`类,它储存了正方形中每个顶点的颜色。
```java
public Mesh(float[] positions, float[] colours, int[] indices) {
```
为了使用该数组我们需要创建另一个VBO它将与我们的VAO相关联。
```java
// 颜色 VBO
colourVboId = glGenBuffers();
FloatBuffer colourBuffer = memAllocFloat(colours.length);
colourBuffer.put(colours).flip();
glBindBuffer(GL_ARRAY_BUFFER, colourVboId);
glBufferData(GL_ARRAY_BUFFER, colourBuffer, GL_STATIC_DRAW);
memFree(colourBuffer);
glVertexAttribPointer(1, 3, GL_FLOAT, false, 0, 0);
```
请注意`glVertexAttribPointer`方法的调用第一个参数现在是“1”。这是着色器希望数据的位置。当然因为增加了一个VBO所以我们需要在`cleanUp`方法中释放它。
下一步是修改着色器。顶点着色器现在需要两个参数坐标位置0和颜色位置1。顶点着色器将只输出接收到的颜色因此这可以由片元着色器处理。
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec3 inColour;
out vec3 exColour;
void main()
{
gl_Position = vec4(position, 1.0);
exColour = inColour;
}
```
现在,片元着色器输入由顶点着色器处理的颜色,并使用它来生成颜色。
```glsl
#version 330
in vec3 exColour;
out vec4 fragColor;
void main()
{
fragColor = vec4(exColour, 1.0);
}
```
最后要做的是修改渲染代码,使其使用第二个数据数组:
```java
public void render(Window window, Mesh mesh) {
clear();
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
shaderProgram.bind();
// Draw the mesh
glBindVertexArray(mesh.getVaoId());
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glDrawElements(GL_TRIANGLES, mesh.getVertexCount(), GL_UNSIGNED_INT, 0);
// ...
```
你可以看到在渲染过程中我们需要启用位于位置1的VAO属性。现在可以把颜色传递给`Mesh`类,以便给正方形添加颜色。
```java
float[] colours = new float[]{
0.5f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 0.5f,
0.0f, 0.5f, 0.5f,
};
```
然后会得到这样一个色彩鲜艳的正方形。
![Coloured quad](_static/05/coloured_quad.png)

460
docs/06-transformations.md Normal file
View File

@@ -0,0 +1,460 @@
# 变换Transformations
## 投影
让我们看到在前一章中创建的漂亮的彩色的正方形。如果仔细看它更像一个矩形你甚至可以将窗口的宽度从600像素改为900像素失真就会更加明显。这发生了什么事呢
如果你观察顶点着色器的代码我们只是直接地传递坐标。换句话说当一个顶点的X坐标为0.5时我们对OpenGL说在屏幕的X坐标为0.5的位置绘制它。下图是OpenGL坐标仅含X和Y轴
![Coordinates](_static/06/coordinates.png)
将这些坐标投影到窗口坐标需要考虑到窗口的大小。因此如果我们的窗口大小为900x480OpenGL坐标(1, 0)将被投影到窗口坐标(900, 0),最终创建一个矩形而不是一个正方形。
![Rectangle](_static/06/rectangle.png)
但是问题远比这更严重。在0.0到1.0和-1.0之间修改正方形的坐标你看到了什么正方形是完全绘制在同一个地方不管它是否沿着Z轴位移。为什么会发生这种情况远处的物体应该比近处的物体小但是我们使用相同的X和Y坐标绘制它们。
但是等等。这不应该用Z坐标来处理吗这是正确的但是又不正确。Z坐标告诉OpenGL对象的远近但是OpenGL对你的对象的大小一无所知。你可以有两个不同大小的物体一个更近更小一个更远更大而且可以以相同的大小正确地投影到屏幕上有相同的屏幕X和Y坐标但Z坐标不同。OpenGL只使用我们正在传递的坐标所以必须处理这个问题。我们需要正确地投影坐标。
既然已经确诊了这个问题,我们该怎么做?答案是使用投影矩阵(`Projection Matrix`)或截锥体(`Frustum`)。投影矩阵将处理绘制区域的宽高比(宽度和高度的关系),因此物体不会被扭曲。它也将处理距离,所以较远的物体将会被绘制得更小。投影矩阵还将考虑我们的视野和应该显示的最大距离有多远。
对于不熟悉矩阵的人,矩阵(`Matrix`是以行和列排列的二维数组。矩阵中的每个数字被称为元素。矩阵阶次是行和列的数量。例如这里是一个2x2矩阵有2行2列
![2x2 Matrix](_static/06/2_2_matrix.png)
矩阵有许多可以使用的基本运算法则(如加法、乘法等),你可以在任何数学书中查阅它们。与三维图形相关的矩阵对空间中点的变换非常有用。
你可以把投影矩阵想象成一个摄像机,它有一个视野和最小和最大距离。该摄像机的可视区域是一个截断的金字塔。下图展示了该区域的俯视图。
![Projection Matrix concepts](_static/06/projection_matrix.png)
投影矩阵将正确地投影三维坐标,以便它们能够在二维屏幕上正确地显示。该矩阵的数学表示如下(不要害怕):
![Projection Matrix](_static/06/projection_matrix_eq.png)
其中屏幕宽高比(`Aspect Ratio`)指的是屏幕宽度与屏幕高度的关系($$a=width/height$$)。为了获得给定点的投影坐标,我们只需要将投影矩阵乘以原始坐标,结果将是投影后的另一个向量。
因此我们需要处理一组数学对象如向量、矩阵并要可以对它们进行操作。我们可以选择从头开始编写所有的代码或者使用已经实现的库。当然我们会选择容易的方法使用JOML`Java OpenGL Math Library`处理LWJGL内的数学运算。为了使用该库我们只需要为`pom.xml`文件添加另一个依赖项。
```xml
<dependency>
<groupId>org.joml</groupId>
<artifactId>joml</artifactId>
<version>${joml.version}</version>
</dependency>
```
然后设定使用的库版本。
```xml
<properties>
[...]
<joml.version>1.9.6</joml.version>
[...]
</properties>
```
现在一切都完事了,让我们创建我们的投影矩阵吧。在`Renderer`类中创建`Matrix4f`由JOML库提供的实例。`Matrix4f`类提供了一个`perspective`方法来创建投影矩阵。该方法需要以下参数:
* 视野:可视区域的弧度角大小。我们将定义一个储存该值的常数。
* 屏幕宽高比。
* 最近视距z-near
* 最远视距z-far
我们将在`init`方法中实例化该矩阵,因此需要将引用传递给`Window`实例以获得窗口大小(你可以在源代码看到它)。代码如下:
```java
/**
* Field of View in Radians
*/
private static final float FOV = (float) Math.toRadians(60.0f);
private static final float Z_NEAR = 0.01f;
private static final float Z_FAR = 1000.f;
private Matrix4f projectionMatrix;
```
投影矩阵的创建如下所示:
```java
float aspectRatio = (float) window.getWidth() / window.getHeight();
projectionMatrix = new Matrix4f().perspective(FOV, aspectRatio,
Z_NEAR, Z_FAR);
```
现在我们忽略宽高比可以改变(通过调整窗口大小)。这可以在`render`方法中检查并相应地改变投影矩阵。
现在有了矩阵我们如何使用它呢我们需要在着色器中使用它并且它应该被应用到所有顶点上。首先你可能会想到把它捆绑在顶点输入中就像坐标和颜色那样。但这样我们会浪费很多空间因为投影矩阵在几次渲染期间都不会发生改变。你可能还想在Java代码中用矩阵处理所有顶点。但是这样我们输入的VBO就是没用的了这样就不能使用显卡中的处理器资源了。
答案是使用“`uniform`”。Uniform是着色器可以使用的全局的GLSL变量我们将使用它与着色器交流。
所以我们需要修改顶点着色器的代码,并声明一个新的称为`projectionMatrix`的全局变量,并用它来计算投影位置。
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec3 inColour;
out vec3 exColour;
uniform mat4 projectionMatrix;
void main()
{
gl_Position = projectionMatrix * vec4(position, 1.0);
exColour = inColour;
}
```
如上所述,我们把`projectionMatrix`定义为一个4x4的矩阵新的坐标是通过把它与原始坐标相乘得到的。现在我们需要把投影矩阵的值传递给着色器。首先我们需要确定全局变量的位置。
这是通过调用方法`glGetUniformLocation`完成的,它有两个参数:
* 着色器程序的ID
* 全局变量名(它应该与着色器里定义的名称相同)
此方法返回储存全局变量的ID。由于我们可能有一个以上的全局变量我们将把这些ID储存在由变量名作为索引的Map中稍后我们需要那个ID。因此`ShaderProgram`需要创建一个新的字段来保存这些ID
```java
private final Map<String, Integer> uniforms;
```
然后由构造方法初始化它:
```java
uniforms = new HashMap<>();
```
最后,我们创建一个方法来获得全局变量储存的位置。
```java
public void createUniform(String uniformName) throws Exception {
int uniformLocation = glGetUniformLocation(programId,
uniformName);
if (uniformLocation < 0) {
throw new Exception("Could not find uniform:" +
uniformName);
}
uniforms.put(uniformName, uniformLocation);
}
```
现在在着色器程序编译后,我们就可以在`Renderer`类中调用`createUniform`方法(现在我们投影矩阵实例化后就调用它)。
```java
shaderProgram.createUniform("projectionMatrix");
```
现在,我们已经准备好一个可以储存投影矩阵数据的储存器。由于投影矩阵在渲染期间不会改变,所以可以在创建后直接设置值。但是我们将在`render`方法中做这件事。稍后你可以看到,我们可以重用该全局变量来做额外的事情,这些事情需要在每次渲染调用中完成。
我们将在`ShaderProgram`类中创建另一个方法来设置数据,称为`setUniform`。我们通过使用JOML库提供的实用方法将矩阵转换成4x4的`FloatBuffer`对象,并将它们发送到全局变量中。
```java
public void setUniform(String uniformName, Matrix4f value) {
// Dump the matrix into a float buffer
try (MemoryStack stack = MemoryStack.stackPush()) {
FloatBuffer fb = stack.mallocFloat(16);
value.get(fb);
glUniformMatrix4fv(uniforms.get(uniformName), false, fb);
}
}
```
正如你看到的,我们以不同的方式创建缓冲区。我们使用的是自动管理缓冲区,并将它们分配到堆栈上。这是因为这个缓冲区是大小很小,而且它在本方法外不被使用。因此,我们使用`MemoryStack`类。
现在,在着色器绑定之后,`Renderer`类中的`render`方法可以使用该方法。
```java
shaderProgram.setUniform("projectionMatrix", projectionMatrix);
```
我们快要完成了。现在我们可以正确地渲染正方形。所以现在可以启动你的程序,然后得到一个...黑色背景上没有任何彩色正方形。发生了什么我们弄糟了什么吗实际上没有任何问题。记住我们正在模拟摄像机观察场景的效果。我们提供了两个距离一个是最远视距1000f和一个最近视距0.01f)。而我们的坐标是:
```java
float[] positions = new float[]{
-0.5f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
};
```
也就是说我们坐标中的Z坐标在可视区域之外。给它们赋值为-0.05f。现在你会看到像这样的一个巨大的绿色矩形:
![Square 1](_static/06/square_1.png)
这是因为,我们正绘制出离摄像机机太近的正方形。我们实际上是在放大它。如果现在把一个`-1.05f`的值赋值给Z坐标就可以看到彩色正方形了。
![Square coloured](_static/06/square_coloured.png)
如果继续向后移动正方形,我们会看到它变小了。还要注意到正方形不再像矩形了。
## 使用变换
让我们回想一下到目前为止我们都做了什么。我们已经学会了如何将数据以有效的格式传递给显卡,以及如何使用顶点和片元着色器来投影这些顶点并设置它们的颜色。现在应该开始在三维空间中绘制更复杂的模型了。但为了做到这件事,我们必须能够加载模型,并在指定的位置以适当的大小和所需的旋转将它渲染在三维空间中。
现在为了实现这种渲染,我们需要提供一些基本操作来操作模型:
* 移动(`translation`): 在三个轴中的任意一个轴上移动一个物体。
* 旋转(`Rotation`): 按任意一个轴旋转物体任意角度。
* 缩放(`Scale`): 调整物体的大小。
![Transformations](_static/06/transformations.png)
上面的操作统称为变换(`Transformation`)。你可能猜到要实现这一点的方法是把坐标乘以一组矩阵(一个用于移动,一个用于旋转,一个用于缩放)。这三个矩阵将被组合成一个称为“世界矩阵”的矩阵,并作为一个全局变量传递给顶点着色器。
之所以被称为世界矩阵是因为我们正在从模型坐标转换为世界坐标。当学习加载3D模型时你会发现这些模型是在它们自己的坐标系中定义的。它们不知道你的三维空间的大小但它们需要在里面渲染。因此当我们用矩阵乘以坐标时实际上做的是从一个坐标系模型坐标系转换到另一个坐标系三维世界坐标系
世界矩阵应该这样计算(顺序很重要,因为乘法交换律不适用于矩阵):
$$
World Matrix\left[Translation Matrix\right]\left[Rotation Matrix\right]\left[Scale Matrix\right]
$$
如果把投影矩阵包含在变换矩阵中,它会是这样的:
$$
Transf=\left[Proj Matrix\right]\left[Translation Matrix\right]\left[Rotation Matrix\right]\left[Scale Matrix\right]=\left[Proj Matrix\right]\left[World Matrix\right]
$$
变换矩阵是这样的:
$$
\begin{bmatrix}
1 & 0 & 0 & dx \\
0 & 1 & 0 & dy \\
0 & 0 & 1 & dz \\
0 & 0 & 0 & 1
\end{bmatrix}
$$
位移矩阵参数如下:
* dx: 沿X轴位移。
* dy: 沿Y轴位移。
* dz: 沿Z轴位移。
缩放矩阵是这样定义的;
$$
\begin{bmatrix}
sx & 0 & 0 & 0 \\
0 & sy & 0 & 0 \\
0 & 0 & sz & 0 \\
0 & 0 & 0 & 1
\end{bmatrix}
$$
缩放矩阵参数如下:
* sx: 沿着X轴缩放。
* sy: 沿着Y轴缩放。
* sz: 沿着Z轴缩放。
旋转矩阵要复杂得多,但请记住,它可以由每个绕单独的轴旋转的旋转矩阵相乘得到。
现在为了实践这些理论我们需要重构代码一点点。在游戏中我们将加载一组模型用来根据游戏逻辑在不同的位置渲染许多对象想象一个FPS游戏它载入了三个不同敌人的模型。确实只有三个模型但使用这些模型我们可以渲染想要的任意数量的敌人。我们需要为每个对象创建一个VAO和一组VBO吗答案是否定的只需要每个模型加载一次就行。我们需要做的是根据它的位置大小和旋转来独立绘制它。当渲染这些模型时我们需要对它们进行变换。
因此,我们将创建一个名为`GameItem`的新类,该类将模型加载到`Mesh`实例中。一个`GameItem`实例将由变量储存它的位置、旋转状态和大小。这个就是这个类的定义。
```java
package org.lwjglb.engine;
import org.joml.Vector3f;
import org.lwjglb.engine.graph.Mesh;
public class GameItem {
private final Mesh mesh;
private final Vector3f position;
private float scale;
private final Vector3f rotation;
public GameItem(Mesh mesh) {
this.mesh = mesh;
position = new Vector3f(0, 0, 0);
scale = 1;
rotation = new Vector3f(0, 0, 0);
}
public Vector3f getPosition() {
return position;
}
public void setPosition(float x, float y, float z) {
this.position.x = x;
this.position.y = y;
this.position.z = z;
}
public float getScale() {
return scale;
}
public void setScale(float scale) {
this.scale = scale;
}
public Vector3f getRotation() {
return rotation;
}
public void setRotation(float x, float y, float z) {
this.rotation.x = x;
this.rotation.y = y;
this.rotation.z = z;
}
public Mesh getMesh() {
return mesh;
}
}
```
我们将创建一个名为`Transformation`的类,让它来处理变换。
```java
package org.lwjglb.engine.graph;
import org.joml.Matrix4f;
import org.joml.Vector3f;
public class Transformation {
private final Matrix4f projectionMatrix;
private final Matrix4f worldMatrix;
public Transformation() {
worldMatrix = new Matrix4f();
projectionMatrix = new Matrix4f();
}
public final Matrix4f getProjectionMatrix(float fov, float width, float height, float zNear, float zFar) {
float aspectRatio = width / height;
projectionMatrix.identity();
projectionMatrix.perspective(fov, aspectRatio, zNear, zFar);
return projectionMatrix;
}
public Matrix4f getWorldMatrix(Vector3f offset, Vector3f rotation, float scale) {
worldMatrix.identity().translate(offset).
rotateX((float)Math.toRadians(rotation.x)).
rotateY((float)Math.toRadians(rotation.y)).
rotateZ((float)Math.toRadians(rotation.z)).
scale(scale);
return worldMatrix;
}
}
```
正如你所看到的,这个类把投影矩阵和世界矩阵结合起来。给定一组参数来进行位移、旋转和缩放,然后返回世界矩阵。`getWorldMatrix`返回的结果将为每个`GameItem`实例转换坐标。该类还提供了获得投影矩阵的方法。
需要注意的一件事是,`Matrix4f`类的`mul`方法修改了该实例的内容。因此,如果直接将投影矩阵与变换矩阵相乘,我们会修改投影矩阵本身。这就是为什么总是在每次调用时将每个矩阵初始化为单位矩阵。
`Renderer`类的构造方法中,我们仅实例化了没有任何参数的`Transformation`类,而在`init`方法中我们只创建了Uniform。
```java
public Renderer() {
transformation = new Transformation();
}
public void init(Window window) throws Exception {
// .... Some code before ...
// Create uniforms for world and projection matrices
shaderProgram.createUniform("projectionMatrix");
shaderProgram.createUniform("worldMatrix");
window.setClearColor(0.0f, 0.0f, 0.0f, 0.0f);
}
```
`Renderer`类的渲染方法中,现在可以接收到一个`GameItem`的数组:
```java
public void render(Window window, GameItem[] gameItems) {
clear();
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
shaderProgram.bind();
// Update projection Matrix
Matrix4f projectionMatrix = transformation.getProjectionMatrix(FOV, window.getWidth(), window.getHeight(), Z_NEAR, Z_FAR);
shaderProgram.setUniform("projectionMatrix", projectionMatrix);
// Render each gameItem
for(GameItem gameItem : gameItems) {
// Set world matrix for this item
Matrix4f worldMatrix =
transformation.getWorldMatrix(
gameItem.getPosition(),
gameItem.getRotation(),
gameItem.getScale());
shaderProgram.setUniform("worldMatrix", worldMatrix);
// Render the mes for this game item
gameItem.getMesh().render();
}
shaderProgram.unbind();
}
```
每次调用`render`时就更新投影矩阵一次。这样,我们可以处理窗口大小的调整。然后,我们遍历`GameItem`数组,并根据它们各自的位置、旋转和大小创建变换矩阵。这个矩阵将被传递到着色器并绘制`Mesh`。投影矩阵对于所有要渲染的项目都是相同的。这就是为什么它在`Transformation`类中是单独一个变量的原因。
我们将渲染代码移动到`Mesh`类中。
We moved the rendering code to draw a Mesh to this class:
```java
public void render() {
// 绘制Mesh
glBindVertexArray(getVaoId());
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glDrawElements(GL_TRIANGLES, getVertexCount(), GL_UNSIGNED_INT, 0);
// 重置状态
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glBindVertexArray(0);
}
```
顶点着色器只需简单地添加一个新的`worldMatrix`变量,然后用它与`projectionMatrix`一同计算坐标:
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec3 inColour;
out vec3 exColour;
uniform mat4 worldMatrix;
uniform mat4 projectionMatrix;
void main()
{
gl_Position = projectionMatrix * worldMatrix * vec4(position, 1.0);
exColour = inColour;
}
```
正如你所看到的代码完全一样。我们使用Uniform来正确地计算坐标并且考虑截锥、位置、大小和旋转等。
另外一个重要的问题是,为什么不使用位移、旋转和缩放矩阵,而是把它们组合成一个世界矩阵?原因是我们应该尽量减少在着色器中使用的矩阵。还要记住,在着色器中所做的矩阵乘法是每个顶点一次。投影矩阵在渲染调用期间不会改变,而每一个`GameItem`实例的世界矩阵也不会改变。如果我们独立位移、旋转和缩放矩阵,我们要做更多的矩阵乘法运算。想象一个有超多顶点的模型,这是很多余的操作。
但你现在可能会想,如果每个`GameItem`中的世界矩阵都不会发生变化为什么不在Java类中做矩阵乘法我们将投影矩阵和世界矩阵与每个`GameItem`相乘把它们作为一个Uniform这种情况下我们确实能节省更多的操作。但是当我们向游戏引擎中添加更多的特性时我们需要在着色器中使用世界坐标所以最好以独立的方式处理这两个矩阵。
最后只需要修改一下`DummyGame`类,创建一个`GameItem`实例,让其与`Mesh`关联,并添加一些操作来位移,旋转和缩放正方形。因为这只是个测试示例,所有没有添加太多,你可以在本书的源代码中找到相关内容。

333
docs/07-textures.md Normal file
View File

@@ -0,0 +1,333 @@
# 纹理Textures
## 创建一个三维立方体
在本章中,我们将学习如何在渲染中加载纹理并使用它们。为了讲解与纹理相关的所有概念,我们将把之前章节中使用的正方形更改为三维立方体。为了绘制一个立方体,我们只需要正确地设置一个立方体的坐标,就能使用现有代码正确地绘制它。
为了绘制立方体,我们只需要定义八个顶点。
![立方体坐标](_static/07/cube_coords.png)
因此,它的坐标数组将是这样的:
```java
float[] positions = new float[] {
// VO
-0.5f, 0.5f, 0.5f,
// V1
-0.5f, -0.5f, 0.5f,
// V2
0.5f, -0.5f, 0.5f,
// V3
0.5f, 0.5f, 0.5f,
// V4
-0.5f, 0.5f, -0.5f,
// V5
0.5f, 0.5f, -0.5f,
// V6
-0.5f, -0.5f, -0.5f,
// V7
0.5f, -0.5f, -0.5f,
};
```
当然由于我们多了4个顶点我们需要更改颜色数组。当前仅重复前四项的值。
```java
float[] colours = new float[]{
0.5f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 0.5f,
0.0f, 0.5f, 0.5f,
0.5f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 0.5f,
0.0f, 0.5f, 0.5f,
};
```
最后,由于立方体是六个面构成的,所以需要绘制十二个三角形(每个面两个),所以我们需要更改索引数组。记住三角形必须按逆时针顺序定义。如果你徒手做这件事,很容易犯错。一定要将你想定义的面摆在你的面前,确认顶点并以逆时针顺序绘制三角形。
```java
int[] indices = new int[] {
// Front face
0, 1, 3, 3, 1, 2,
// Top Face
4, 0, 3, 5, 4, 3,
// Right face
3, 2, 7, 5, 3, 7,
// Left face
6, 1, 0, 6, 0, 4,
// Bottom face
2, 1, 6, 2, 6, 7,
// Back face
7, 6, 4, 7, 4, 5,
};
```
为了更好的观察立方体,我们将更改'DummyGame'类中旋转模型的代码,使它沿着三个轴旋转。
```java
// Update rotation angle
float rotation = gameItem.getRotation().x + 1.5f;
if ( rotation > 360 ) {
rotation = 0;
}
gameItem.setRotation(rotation, rotation, rotation);
```
就这样,现在能够显示一个旋转的三维立方体了。现在你可以编译和运行示例代码,会得到如下所示的东西。
![没有开启深度测试的立方体](_static/07/cube_no_depth_test.png)
这个立方体有些奇怪。有些面没有被正确的绘制。发生了什么?立方体出现这个现象的原因,是组成立方体的三角形是以一种随即顺序绘制的。事实上距离较远的像素应该在距离较近的像素之前绘制,而不是现在这样。为了做到这一点,我们必须启用深度测试(`Depth Test`)。
这可以在`Window`类的`init`方法中做到:
```java
glEnable(GL_DEPTH_TEST);
```
现在立方体被正确地渲染了!
![开启深度测试的立方体](_static/07/cube_depth_test.png)
如果你看了本章这一小节的代码,你可能会看到`Mesh`类做了一次小规模的调整。VBO的ID现在被储存在一个List中以方便地迭代它们。
## 为立方体添加纹理
现在我们将把纹理应用到立方体上。纹理是用来绘制某个模型的像素的颜色的图像可以认为纹理是包裹在3D模型周围的皮肤。你要做的就是将纹理图像中的点分配给模型中的顶点。这样OpenGL能够根据纹理图像计算其他像素的颜色。
![纹理映射](_static/07/texture_mapping.png)
纹理图像不需要与模型同样大小它可以变大或变小。如果要处理的像素不能映射到纹理中的特定点OpenGL将推断颜色。当创建纹理时可以控制此过程。
因此为了将纹理应用到模型我们必须做的是将纹理坐标分配给每个顶点。纹理坐标系有些不同于模型坐标系。首先我们的纹理是二维纹理所以坐标只有X和Y两个量。此外原点是图像的左上角X或Y的最大值都是1。
![纹理坐标系](_static/07/texture_coordinates.png)
我们如何将纹理坐标与位置坐标联系起来呢简单就像传递颜色信息一样。我们创建了一个VBO将为每个顶点储存纹理坐标。
让我们为在三维立方体中应用纹理来修改代码吧。第一步是加载将被用作纹理的图像。对于这件事在之前版本的LWJGL中通常使用Slick2D库。在撰写本文的时候这个库似乎与LWJGL 3不兼容所以需要使用更冗长的方法。我们将使用一个名为`pngdecoder`的库,因此,需要在`pom.xml`文件中声明依赖。
```xml
<dependency>
<groupId>org.l33tlabs.twl</groupId>
<artifactId>pngdecoder</artifactId>
<version>${pngdecoder.version}</version>
</dependency>
```
然后设置库使用的版本。
```xml
<properties>
[...]
<pngdecoder.version>1.0</pngdecoder.version>
[...]
</properties>
```
在一些教程中你可能看到,必须做的第一件事是调用`glEnable(GL_TEXTURE_2D)`来启用OpenGL的纹理功能。如果使用固定函数关系这是正确的。但我们使用的是GLSL着色器因此不再需要了。
现在我们将创建一个新的`Texture`类,它将进行所有必要的步骤以加载纹理。纹理图像将位于`resources`文件夹中可以作为CLASSPATH资源访问并使用输入流传递到`PNGDecoder`类。
```java
PNGDecoder decoder = new PNGDecoder(
Texture.class.getResourceAsStream(fileName));
```
然后我们需要解码PNG图像并通过`PNGDecoder``decode`方法将其内容储存到缓冲区中。PNG图像以RGBA(RGB意为红绿蓝A意为Alpha或透明度)格式解码,每个像素使用四个字节。
`decode`方法需要三个参数:
* `buffer`: 储存解码后图像的`ByteBuffer`(因为每个像素是四个字节其大小将是4\*宽度\*高度)。
* `stride`: 指定从一行开始到下一行的起始字节的距离。现在它是每行的字节数。
* `format`: 图像解码的目标格式(RGBA)。
```java
ByteBuffer buf = ByteBuffer.allocateDirect(
4 * decoder.getWidth() * decoder.getHeight());
decoder.decode(buf, decoder.getWidth() * 4, Format.RGBA);
buf.flip();
```
一件关于OpenGL的重要事情由于历史原因要求纹理图像的大小(每个轴的像素数)必须是二的指数(2, 4, 8, 16, ....)。一些驱动消除了这种限制,但最好还是坚持以免出现问题。
下一步是将纹理传递到显存中。首先我们需要创建一个新的纹理ID。与该纹理相关的操作都要使用该ID因此我们需要绑定它。
```java
// Create a new OpenGL texture
int textureId = glGenTextures();
// Bind the texture
glBindTexture(GL_TEXTURE_2D, textureId);
```
然后需要告诉OpenGL如何解包我们的RGBA字节。由于每个数据大小都是一个字节所以我们需要添加以下代码
```java
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
```
最后我们可以传递材质数据:
```java
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, decoder.getWidth(),
decoder.getHeight(), 0, GL_RGBA, GL_UNSIGNED_BYTE, buf);
```
`glTexImage2D`的参数如下:
* `target`: 指定目标纹理(纹理类型)。现在是`GL_TEXTURE_2D`
* `level`: 指定纹理细节的等级。0级是基本图像等级。第n级是第n个多级渐远纹理(`Mipmap`)的图像。之后再谈这个问题。
* `internal format`: 指定纹理中颜色分量的数量。
* `width`: 指定纹理图像的宽度。
* `height`: 指定纹理图像的高度。
* `border`: 此值必须为0。
* `format`: 指定像素数据的格式现在为RGBA。
* `type`: 指定像素数据的类型。现在,我们使用的是无符号字节。
* `data`: 储存数据的缓冲区。
在一些代码片段中,你可能会发现在调用`glTexImage2D`方法钱设置了一些过滤参数。过滤是指在缩放时如何绘制图像,以及如何修改像素。
如果没有设置这些参数,纹理将不会显示。因此,在`glTexImage2D`方法调用之前,你会看到如下代码:
```java
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
```
这些参数基本上在表达,当一个像素与纹理坐标没有直接的一对一关联时,它将选择最近的纹理坐标点。
到目前为止,我们不会设置这些参数。相反,我们将生成一个多级渐远纹理(`Mipmap`)。多级渐远纹理是由高细节纹理生成的逐级降低分辨率的纹理集合。当我们的物体缩放时,就将自动使用低分辨率的图像。
为了生成多级渐远纹理,我们只需要编写以下代码(现在,我们把它放在`glTextImage2D`方法调用之后)
```java
glGenerateMipmap(GL_TEXTURE_2D);
```
就这样我们已经成功地加载了纹理现在需要使用它。正如之前所说的我们需要把纹理坐标作为另一个VBO。因此我们将修改`Mesh`类来接收浮点数组,其中储存纹理坐标,而不是颜色(我们可以同时有颜色和纹理,但为了简化它,我们将删除颜色)。构造函数现在是这样的:
```java
public Mesh(float[] positions, float[] textCoords, int[] indices,
Texture texture)
```
纹理坐标VBO与颜色VBO创建的方式相同。唯一的区别是它有两个元素而不是三个
```java
vboId = glGenBuffers();
vboIdList.add(vboId);
textCoordsBuffer = MemoryUtil.memAllocFloat(textCoords.length);
textCoordsBuffer.put(textCoords).flip();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, textCoordsBuffer, GL_STATIC_DRAW);
glVertexAttribPointer(1, 2, GL_FLOAT, false, 0, 0);
```
现在我们需要在着色器中使用这些纹理。在顶点着色器中第二个Uniform被修改了因为现在它是一个`vec2`(也顺便更改了名称,所以记得在`Renderer`类中更改它)。顶点着色器就像之前一样,仅仅传递纹理坐标给片元着色器。
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
out vec2 outTexCoord;
uniform mat4 worldMatrix;
uniform mat4 projectionMatrix;
void main()
{
gl_Position = projectionMatrix * worldMatrix * vec4(position, 1.0);
outTexCoord = texCoord;
}
```
在片元着色器中,我们使用那些纹理坐标来设置像素颜色:
```glsl
#version 330
in vec2 outTexCoord;
out vec4 fragColor;
uniform sampler2D texture_sampler;
void main()
{
fragColor = texture(texture_sampler, outTexCoord);
}
```
在分析代码之前,让我们理清一些概念。显卡有几个空间或槽来储存纹理。每一个空间被称为纹理单元(`Texture Unit`)。当使用纹理时,我们必须设置想用的纹理。正如你所看到的,我们有一个新的名为`texture_sampler`的Uniform。该Uniform是`sampler2D`,并储存有我们希望使用的纹理单元的值。
`main`函数中,我们使用`texture`函数来使用纹理。这个函数有两个参数:取样器(`Sampler`)和纹理坐标并返回正确的颜色。取样器Uniform允许使用多重纹理(`multi-texture`),不过现在不是讨论这个话题的时候,但是我们会在稍后再尝试添加。
因此,在`ShaderProgram`类中我们将创建一个新的方法允许为整数型Uniform设置数值
```java
public void setUniform(String uniformName, int value) {
glUniform1i(uniforms.get(uniformName), value);
}
```
`Renderer`类的`init`方法中我们将创建一个新的Uniform
```java
shaderProgram.createUniform("texture_sampler");
```
此外,在`Renderer`类的`render`方法中我们将Uniform的值设置为0(我们现在不使用多个纹理所以只使用单元0)。
```java
shaderProgram.setUniform("texture_sampler", 0);
```
最好,我们只需改变`Mesh`类的渲染方法就可以使用纹理。在方法开始的地方,我们添加以下几行代码:
```java
// Activate first texture unit
glActiveTexture(GL_TEXTURE0);
// Bind the texture
glBindTexture(GL_TEXTURE_2D, texture.getId());
```
我们基本上将`texture.getId()`所获得的纹理绑定到纹理单元0上。
现在,我们刚刚修改了代码来支持纹理。现在我们需要为三维立方体设置纹理坐标。我们的纹理图像文件将是这样的:
![立方体纹理](_static/07/cube_texture.png)
在我们的3D模型中一共有八个顶点。我们首先定义每个顶点的正面纹理坐标。
![立方体纹理的正面](_static/07/cube_texture_front_face.png)
| Vertex | Texture Coordinate |
| --- | --- |
| V0 | \(0.0, 0.0\) |
| V1 | \(0.0, 0.5\) |
| V2 | \(0.5, 0.5\) |
| V3 | \(0.5, 0.0\) |
然后,定义顶面纹理的映射。
![正方体纹理的顶面](_static/07/cube_texture_top_face.png)
| Vertex | Texture Coordinate |
| --- | --- |
| V4 | \(0.0, 0.5\) |
| V5 | \(0.5, 0.5\) |
| V0 | \(0.0, 1.0\) |
| V3 | \(0.5, 1.0\) |
正如你所看到的,有一个问题,我们需要为同一个顶点(V0和V3)设置不同的纹理坐标。怎么样才能解决这个问题呢?解决这一问题的唯一方法是重复一些顶点并关联不同的纹理坐标。对于顶面,我们需要重复四个顶点并为它们分配正确的纹理坐标。
因为前面、后面和侧面都使用相同的纹理所以我们不需要重复这些顶点。在源代码中有完整的定义但是我们需要从第8章跳到第20章了。最终的结果就像这样。
![有纹理的立方体](_static/07/cube_with_texture.png)
在接下来的章节中我们将学习如何加载由3D建模工具生成的模型这样我们就不需要手动定义顶点和纹理坐标了(顺便一提,对于更复杂的模型,手动定义是不存在的)。

344
docs/08-camera.md Normal file
View File

@@ -0,0 +1,344 @@
# 摄像机Camera
在本章中,我们将学习如何在三维场景中移动,就像一个可以在三维世界穿梭的摄像机,然而实际上这就是描述它的专用术语。
但是如果你尝试在OpenGL寻找中的摄像机功能你会发现这根本不是摄像机换句话说摄像机一直是固定在以屏幕(0, 0, 0)为中心点的位置。
因此,我们需要模拟出一个可以在三维空间中移动的摄像机。但是怎么做呢?摄像机是不能移动的,因此我们必须要移动世界中的全部的实体。换句话说,如果移动不了摄像机就移动整个世界。
因此,假设摄像机从(Cx, Cy, Cz)沿着Z轴移动到(Cx, Cy, Cz+dz),从而靠近在(Ox, Oy, Oz)放置的物体。
![摄像机的运动](_static/08/camera_movement.png)
我们要做的是如何向相反的方向精确地移动物体(在三维空间中的所有物体)。换句话说,其实物体就像在跑步机上跑步一样。
![实际的运动](_static/08/actual_movement.png)
摄像机可以沿着三个轴(x, y和z)移动,也可以绕着它们旋转(翻滚(`roll`)、俯仰(`pitch`)和偏航(`yaw`))。
![俯仰和偏航](_static/08/roll_pitch_yaw.png)
所以从基本上我们要做的就是让移动和旋转应用于所设置的三维世界全部物体。应该怎么做呢?答案是用另外一种变换,让它变换所有在摄像机运动方向上相反的顶点,再根据摄像机的旋转来旋转它们。当然,这将要用到另外一个矩阵,即所谓的观察矩阵(`View Matrix`)来完成。这个矩阵首先进行平移,然后沿着轴线进行旋转。
来看看如何构造这个矩阵。如果你记得变换章节第6章的变换矩阵:
$$Transf = \lbrack ProjMatrix \rbrack \cdot \lbrack TranslationMatrix \rbrack \cdot \lbrack RotationMatrix \rbrack \cdot \lbrack ScaleMatrix \rbrack = \lbrack ProjMatrix \rbrack \cdot \lbrack WorldMatrix \rbrack$$
在使用观察矩阵之前,应将它与投影矩阵相乘,之后矩阵应该是这样的:
$$Transf = \lbrack ProjMatrix \rbrack \cdot \lbrack ViewMatrix \rbrack \cdot \lbrack TranslationMatrix \rbrack \cdot \lbrack RotationMatrix \rbrack \cdot \lbrack ScaleMatrix \rbrack = \lbrack ProjMatrix \rbrack \cdot \lbrack ViewMatrix \rbrack \cdot \lbrack WorldMatrix \rbrack $$
现在已经有三个矩阵了,我们应该思考一下这些矩阵的生命周期。在游戏运行的时候,投影矩阵不应该更改太多,最坏的情况,每次渲染可能改变一次。如果摄像机移动,则观察矩阵可以在每次渲染改变一次。世界矩阵每渲染一个`GameItem`改变一次,所以每次渲染调用都会改变许多次。
因此如何把每一个矩阵传递到顶点着色器呢你可能会看到一些代码为三个矩阵分别定义一个Uniform但理论上最有效的方法是组合投影和观察矩阵将其称为`PV`矩阵,并传递`world``PV`矩阵到着色器。这样,将可以与世界坐标一起运算,并且可以避免一些额外的运算。
实际上,最方便的方法是将观察矩阵与世界矩阵相组合。为什么会这样?因为要记住整个摄像机的概念就是戏法,要做的是移动整个世界来模拟摄像机的位移和只显示一小部分的三维世界。因此,如果直接与世界坐标一起处理,可能会让远离中心点的世界坐标遇到一些精度问题。如果在所谓的摄像机空间中工作利用点的性质,虽然远离世界的中心点,但也靠近摄像机。可以将观察和世界矩阵相结合的矩阵称为模型观察矩阵(`Model View Matrix`)。
来开始修改代码实现摄像机吧。首先,先创建一个名为`Camera`的类,它将储存摄像机的位置与旋转的方向。该类将提供设置位置或旋转方向\(`setPosition``setRotation`\)的方法,或在当前状态\(`movePosition``moveRotation`\)下用偏移量更新这些值。
```java
package org.lwjglb.engine.graph;
import org.joml.Vector3f;
public class Camera {
private final Vector3f position;
private final Vector3f rotation;
public Camera() {
position = new Vector3f(0, 0, 0);
rotation = new Vector3f(0, 0, 0);
}
public Camera(Vector3f position, Vector3f rotation) {
this.position = position;
this.rotation = rotation;
}
public Vector3f getPosition() {
return position;
}
public void setPosition(float x, float y, float z) {
position.x = x;
position.y = y;
position.z = z;
}
public void movePosition(float offsetX, float offsetY, float offsetZ) {
if ( offsetZ != 0 ) {
position.x += (float)Math.sin(Math.toRadians(rotation.y)) * -1.0f * offsetZ;
position.z += (float)Math.cos(Math.toRadians(rotation.y)) * offsetZ;
}
if ( offsetX != 0) {
position.x += (float)Math.sin(Math.toRadians(rotation.y - 90)) * -1.0f * offsetX;
position.z += (float)Math.cos(Math.toRadians(rotation.y - 90)) * offsetX;
}
position.y += offsetY;
}
public Vector3f getRotation() {
return rotation;
}
public void setRotation(float x, float y, float z) {
rotation.x = x;
rotation.y = y;
rotation.z = z;
}
public void moveRotation(float offsetX, float offsetY, float offsetZ) {
rotation.x += offsetX;
rotation.y += offsetY;
rotation.z += offsetZ;
}
}
```
接下来在`Transformation`中,将添加一个新矩阵来储存观察矩阵。
```java
private final Matrix4f viewMatrix;
```
我们要提供一个更新这个值的方法。与投影矩阵一样,这个矩阵对于渲染周期中所面对的对象都是相同的。
```java
public Matrix4f getViewMatrix(Camera camera) {
Vector3f cameraPos = camera.getPosition();
Vector3f rotation = camera.getRotation();
viewMatrix.identity();
// First do the rotation so camera rotates over its position
viewMatrix.rotate((float)Math.toRadians(rotation.x), new Vector3f(1, 0, 0))
.rotate((float)Math.toRadians(rotation.y), new Vector3f(0, 1, 0));
// Then do the translation
viewMatrix.translate(-cameraPos.x, -cameraPos.y, -cameraPos.z);
return viewMatrix;
}
```
正如你所见的,我们首先需要做旋转,然后变换。如果操作顺序相反,我们不会沿着摄像机位置旋转,而是沿着坐标原点旋转。请注意,在`Camera`类的`movePosition`方法中我们不只是简单地增加摄像机位置的偏移量。我们还考虑了沿Y轴的旋转偏航以便计算最终位置。如果我们只是通过偏移来增加摄像机的位置摄像机就不会朝着它的方向移动。
除了上面提到的,我们现在还没有得到一个可以完全自由移动的摄像机\(例如如果我们沿着X轴旋转当我们向前移动时摄像机不会在空中向上或向下移动\)。这将在之后的章节中完成,因为这有点复杂。
最后,我们将移除之前的`getWorldMatrix`方法,并添加一个名为`getModelViewMatrix`的新方法。
```java
public Matrix4f getModelViewMatrix(GameItem gameItem, Matrix4f viewMatrix) {
Vector3f rotation = gameItem.getRotation();
modelViewMatrix.identity().translate(gameItem.getPosition()).
rotateX((float)Math.toRadians(-rotation.x)).
rotateY((float)Math.toRadians(-rotation.y)).
rotateZ((float)Math.toRadians(-rotation.z)).
scale(gameItem.getScale());
Matrix4f viewCurr = new Matrix4f(viewMatrix);
return viewCurr.mul(modelViewMatrix);
}
```
`getModelViewMatrix`方法将在每个`GameItem`实例中调用,因此我们必须对观察矩阵的副本进行处理,因此在每次调用中都不会积累转换\(记住`Matrix4f`类不是不可变的\)。
`Renderer`类的`render`方法中,在投影矩阵更新之后只需要根据摄像机的值更新观察矩阵。
```java
// Update projection Matrix
Matrix4f projectionMatrix = transformation.getProjectionMatrix(FOV, window.getWidth(), window.getHeight(), Z_NEAR, Z_FAR);
shaderProgram.setUniform("projectionMatrix", projectionMatrix);
// Update view Matrix
Matrix4f viewMatrix = transformation.getViewMatrix(camera);
shaderProgram.setUniform("texture_sampler", 0);
// Render each gameItem
for(GameItem gameItem : gameItems) {
// Set model view matrix for this item
Matrix4f modelViewMatrix = transformation.getModelViewMatrix(gameItem, viewMatrix);
shaderProgram.setUniform("modelViewMatrix", modelViewMatrix);
// Render the mes for this game item
gameItem.getMesh().render();
}
```
这就是我们实现摄像机的基本代码现在我们需要用它。我们Key更改输入处理和更新摄像机的方式。我们将设置以下按键
* “A”和“D”键使摄像机左右\(x轴\)移动。
* “W”和“S”键使摄像机前后\(z轴\)移动。
* “Z”和“X”键使摄像机上下\(y轴\)移动。
当鼠标按下右键时我们可以使用鼠标位置沿X和Y轴旋转摄像机。
正如你所看到的,我们将首次使用鼠标。创建一个名为`MouseInput`的新类,该类将封装鼠标处理的代码。
```java
package org.lwjglb.engine;
import org.joml.Vector2d;
import org.joml.Vector2f;
import static org.lwjgl.glfw.GLFW.*;
public class MouseInput {
private final Vector2d previousPos;
private final Vector2d currentPos;
private final Vector2f displVec;
private boolean inWindow = false;
private boolean leftButtonPressed = false;
private boolean rightButtonPressed = false;
public MouseInput() {
previousPos = new Vector2d(-1, -1);
currentPos = new Vector2d(0, 0);
displVec = new Vector2f();
}
public void init(Window window) {
glfwSetCursorPosCallback(window.getWindowHandle(), (windowHandle, xpos, ypos) -> {
currentPos.x = xpos;
currentPos.y = ypos;
});
glfwSetCursorEnterCallback(window.getWindowHandle(), (windowHandle, entered) -> {
inWindow = entered;
});
glfwSetMouseButtonCallback(window.getWindowHandle(), (windowHandle, button, action, mode) -> {
leftButtonPressed = button == GLFW_MOUSE_BUTTON_1 && action == GLFW_PRESS;
rightButtonPressed = button == GLFW_MOUSE_BUTTON_2 && action == GLFW_PRESS;
});
}
public Vector2f getDisplVec() {
return displVec;
}
public void input(Window window) {
displVec.x = 0;
displVec.y = 0;
if (previousPos.x > 0 && previousPos.y > 0 && inWindow) {
double deltax = currentPos.x - previousPos.x;
double deltay = currentPos.y - previousPos.y;
boolean rotateX = deltax != 0;
boolean rotateY = deltay != 0;
if (rotateX) {
displVec.y = (float) deltax;
}
if (rotateY) {
displVec.x = (float) deltay;
}
}
previousPos.x = currentPos.x;
previousPos.y = currentPos.y;
}
public boolean isLeftButtonPressed() {
return leftButtonPressed;
}
public boolean isRightButtonPressed() {
return rightButtonPressed;
}
}
```
`MouseInput`类提供了一个在初始化过程中应该调用的`init`方法,并注册一组回调来处理鼠标事件:
* `glfwSetCursorPosCallback`:注册一个回调,该回调将在鼠标移动时被调用。
* `glfwSetCursorEnterCallback`:注册一个回调,该回调将在鼠标进入窗口时被调用。即使鼠标不在窗口内,我们也会收到鼠标事件。当鼠标在窗口内时,使用这个回调来确认鼠标进入窗口。
* `glfwSetMouseButtonCallback`:注册一个回调,该回调在按下鼠标按钮时被调用。
`MouseInput`类提供了一个`input`方法,在处理游戏输入时应调用该方法。该方法计算鼠标从先前位置的位移,并将其存储到`Vector2f`类型的`displVec`变量中,以便它被游戏使用。
`MouseInput`类将在`GameEngine`类中被实例化,并且将作为游戏实现的`init``update`方法中的参数传递(因此需要相应地更改接口)。
```java
void input(Window window, MouseInput mouseInput);
void update(float interval, MouseInput mouseInput);
```
鼠标输入将在`GameEngine`类的`input`方法中被处理,而最终的控制将交由游戏实现。
```java
protected void input() {
mouseInput.input(window);
gameLogic.input(window, mouseInput);
}
```
现在已经准备好更改`DummyGame`类来处理键盘和鼠标输入了。该类的输入方法将如下所示:
```java
@Override
public void input(Window window, MouseInput mouseInput) {
cameraInc.set(0, 0, 0);
if (window.isKeyPressed(GLFW_KEY_W)) {
cameraInc.z = -1;
} else if (window.isKeyPressed(GLFW_KEY_S)) {
cameraInc.z = 1;
}
if (window.isKeyPressed(GLFW_KEY_A)) {
cameraInc.x = -1;
} else if (window.isKeyPressed(GLFW_KEY_D)) {
cameraInc.x = 1;
}
if (window.isKeyPressed(GLFW_KEY_Z)) {
cameraInc.y = -1;
} else if (window.isKeyPressed(GLFW_KEY_X)) {
cameraInc.y = 1;
}
}
```
这将更新一个名为`cameraInc``Vector3f`变量,它储存了摄像机应有的位移。
`DummyGame`类的`update`方法将根据处理键盘和鼠标事件,来修改摄像机的位置和旋转。
```java
@Override
public void update(float interval, MouseInput mouseInput) {
// Update camera position
camera.movePosition(cameraInc.x * CAMERA_POS_STEP,
cameraInc.y * CAMERA_POS_STEP,
cameraInc.z * CAMERA_POS_STEP);
// Update camera based on mouse
if (mouseInput.isRightButtonPressed()) {
Vector2f rotVec = mouseInput.getDisplVec();
camera.moveRotation(rotVec.x * MOUSE_SENSITIVITY, rotVec.y * MOUSE_SENSITIVITY, 0);
}
}
```
现在可以向世界添加更多的立方体,将它们放在特定位置并使用新摄像机进行观察。正如你所见的,所有的立方体共享相同的`Mesh`实例。
```java
GameItem gameItem1 = new GameItem(mesh);
gameItem1.setScale(0.5f);
gameItem1.setPosition(0, 0, -2);
GameItem gameItem2 = new GameItem(mesh);
gameItem2.setScale(0.5f);
gameItem2.setPosition(0.5f, 0.5f, -2);
GameItem gameItem3 = new GameItem(mesh);
gameItem3.setScale(0.5f);
gameItem3.setPosition(0, 0, -2.5f);
GameItem gameItem4 = new GameItem(mesh);
gameItem4.setScale(0.5f);
gameItem4.setPosition(0.5f, 0, -2.5f);
gameItems = new GameItem[]{gameItem1, gameItem2, gameItem3, gameItem4};
```
你会得到如下所示的东西:
![方块](_static/08/cubes.png)

View File

@@ -0,0 +1,416 @@
# 加载更复杂的模型Loading more complex models
在本章中我们将学习加载在外部文件中定义的更复杂的模型。这些模型将由3D建模工具(例如[Blender](https://www.blender.org/))创建。目前我们是徒手创建模型直接写代码定义它们的几何数组。在本章中我们将学习如何以OBJ格式定义的模型。
OBJ(或者.obj)是Wavefront Technologies开发的一种几何定义开放文件格式现已被广泛使用。OBJ文件储存构成3D模型的顶点、纹理坐标和多边形。这是一种相对简单的格式因为它是基于文本格式的每一行定义了一个元素(顶点、纹理坐标等)。
在.obj文件中每行从一个标识符开始标识元素的类型
* 以"\#"开始的行是注释。
* 以"v"开始的行用坐标(x, y, z, w)定义一个几何顶点。例如:`v 0.155 0.211 0.32 1.0`
* 以"vn"开始的行是用坐标(x, y, z)定义顶点法线(`Vertex normals`)。例如:`vn 0.71 0.21 0.82`。之后再讨论这个东西。
* 以"vt"开始的行定义纹理坐标。例如:`vt 0.500 1`
* 以"f"开始的行定义了一个面。利用该行中的信息可以创建索引数组。我们将只处理面是三角形的情况。它可以有几种定义方式:
* 它可以定义顶点位置(f v1 v2 v3)。例如:`f 6 3 1`。在这种情况下这个三角形是由位置为6、3和1的几何顶点定义的(顶点索引总是从1开始)。
* 它可以定义顶点位置、纹理坐标和法线(f v1/t1/n1 v2/t2/n2 v3/t3/n3)。例如:`f 6/4/1 3/5/3 7/6/5`。第一部分的`v1/t1/n1`分别定义了坐标、纹理坐标和顶点法线。看到这个部分可以想到选择几何顶点6、纹理坐标4和顶点法线1。
OBJ格式有更多的元素类型(如一对多多边形、定义材质等)。现在我们仅实现如上所述我们的OBJ加载器将忽略其他元素类型。
但是什么是法线呢让我们先定义它。当你有一个平面时它的法线是垂直于该平面的向量其长度等于1。
![法线](_static/09/normals.png)
正如你在上图中看到的,一个平面可以有两个法线,我们应该用哪一个呢?三维图形中的法线被用于光照,所以我们应该选择面向光源的法线。换句话说,我们应该选择指向模型外部的法线。
我们有一个有多边形、三角形组成的3D模型。每个三角形由三个顶点组成三角形的法线向量是垂直于三角形表面的向量其长度等于1。
顶点法线与特定顶点相关联,并且是周围三角形的法线的组合(当然它的长度等于1)。在这里你可以看到一个3D网格的顶点模型(取自[维基百科](https://en.wikipedia.org/wiki/Vertex_normal#/media/File:Vertex_normals.png))
![法线向量](_static/09/vertex_normals.png)
法线将会被用于光照。
让我们开始创建OBJ加载器。首先我们将修改`Mesh`因为现在必须使用纹理。我们可能加载一些没有定义纹理坐标的OBJ文件我们必须能够使用颜色而不是纹理渲染它们。这种情况下面的定义就是这样的`f v/n`
`Mesh`类现在有一个名为`colour`的新属性。
```java
private Vector3f colour;
```
并且构造函数不再需要`Texture`。取而代之的是,我们将为纹理和颜色属性提供`get``set`方法。
```java
public Mesh(float[] positions, float[] textCoords, float[] normals, int[] indices) {
```
当然,在`render``clear`方法中,在使用纹理之前,必须检查纹理是否为`null`。正如你在构造函数中看到的,现在需要一个名为`normals`的浮点数组。如何使用法线渲染答案很简单它将只是VAO里的另一个VBO所以我们需要添加如下代码。
```java
// Vertex normals VBO
vboId = glGenBuffers();
vboIdList.add(vboId);
vecNormalsBuffer = MemoryUtil.memAllocFloat(normals.length);
vecNormalsBuffer.put(normals).flip();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, vecNormalsBuffer, GL_STATIC_DRAW);
glVertexAttribPointer(2, 3, GL_FLOAT, false, 0, 0);
```
`render`方法中我们必须启用这个VBO并在之后禁用它。
```java
// Draw the mesh
glBindVertexArray(getVaoId());
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glDrawElements(GL_TRIANGLES, getVertexCount(), GL_UNSIGNED_INT, 0);
// Restore state
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
```
现在已经完成了`Mesh`类的修改,我们可以修改代码来使用纹理坐标或固定的颜色。因此,我们需要修改片元着色器,像这样:
```glsl
#version 330
in vec2 outTexCoord;
out vec4 fragColor;
uniform sampler2D texture_sampler;
uniform vec3 colour;
uniform int useColour;
void main()
{
if ( useColour == 1 )
{
fragColor = vec4(colour, 1);
}
else
{
fragColor = texture(texture_sampler, outTexCoord);
}
}
```
正如你所看到的我们已经创建了两个新Uniform
* `colour`: 将储存基础颜色。
* `useColour`: 这是个标记当你不想使用纹理时它将被设置为1。
`Renderer`类中我们需要创建这两个Uniform。
```java
// Create uniform for default colour and the flag that controls it
shaderProgram.createUniform("colour");
shaderProgram.createUniform("useColour");
```
和其他Uniform一样`Renderer`类的`render`方法中,我们也需要为每个`GameItem`设置这些Uniform的值。
```java
for(GameItem gameItem : gameItems) {
Mesh mesh = gameItem.getMesh();
// Set model view matrix for this item
Matrix4f modelViewMatrix = transformation.getModelViewMatrix(gameItem, viewMatrix);
shaderProgram.setUniform("modelViewMatrix", modelViewMatrix);
// Render the mes for this game item
shaderProgram.setUniform("colour", mesh.getColour());
shaderProgram.setUniform("useColour", mesh.isTextured() ? 0 : 1);
mesh.render();
}
```
我们可以创建一个名为`OBJLoader`的新类它将解析OBJ文件并用其中的数据创建一个`Mesh`实例。你可能会在网上发现一些其他实现可能比这更有效,但我认为这个方案更容易理解。这是一个工具类,他将有一个静态方法:
```java
public static Mesh loadMesh(String fileName) throws Exception {
```
参数`fileName`是指定文件的名称该文件必须包含在OBJ模型的类路径中。
我们在该方法中做的第一件事是读取文件内容并存储数组中的所有行。然后创建几个列表来保存顶点、纹理坐标、法线和面。
```java
List<String> lines = Utils.readAllLines(fileName);
List<Vector3f> vertices = new ArrayList<>();
List<Vector2f> textures = new ArrayList<>();
List<Vector3f> normals = new ArrayList<>();
List<Face> faces = new ArrayList<>();
```
然后解析每一行,并根据起始标识符得到顶点位置、纹理坐标、顶点法线或面定义。最后重新整理这些数据。
```java
for (String line : lines) {
String[] tokens = line.split("\\s+");
switch (tokens[0]) {
case "v":
// Geometric vertex
Vector3f vec3f = new Vector3f(
Float.parseFloat(tokens[1]),
Float.parseFloat(tokens[2]),
Float.parseFloat(tokens[3]));
vertices.add(vec3f);
break;
case "vt":
// Texture coordinate
Vector2f vec2f = new Vector2f(
Float.parseFloat(tokens[1]),
Float.parseFloat(tokens[2]));
textures.add(vec2f);
break;
case "vn":
// Vertex normal
Vector3f vec3fNorm = new Vector3f(
Float.parseFloat(tokens[1]),
Float.parseFloat(tokens[2]),
Float.parseFloat(tokens[3]));
normals.add(vec3fNorm);
break;
case "f":
Face face = new Face(tokens[1], tokens[2], tokens[3]);
faces.add(face);
break;
default:
// Ignore other lines
break;
}
}
return reorderLists(vertices, textures, normals, faces);
```
在重新排序之前,让我们看看如何解析面的定义。我们创建了一个名为`Face`的类,它负责解析一个面的定义。一个`Face`是由索引组列表组成的,现在,因为我们正在处理三角形,所以我们将有三个索引组。
![面定义](_static/09/face_definition.png)
我们将创建另一个名为`IndexGroup`的内部类,它将为索引组保存数据。
```java
protected static class IdxGroup {
public static final int NO_VALUE = -1;
public int idxPos;
public int idxTextCoord;
public int idxVecNormal;
public IdxGroup() {
idxPos = NO_VALUE;
idxTextCoord = NO_VALUE;
idxVecNormal = NO_VALUE;
}
}
```
`Face`类看起来像这样。
```java
protected static class Face {
/**
* List of idxGroup groups for a face triangle (3 vertices per face).
*/
private IdxGroup[] idxGroups = new IdxGroup[3];
public Face(String v1, String v2, String v3) {
idxGroups = new IdxGroup[3];
// Parse the lines
idxGroups[0] = parseLine(v1);
idxGroups[1] = parseLine(v2);
idxGroups[2] = parseLine(v3);
}
private IdxGroup parseLine(String line) {
IdxGroup idxGroup = new IdxGroup();
String[] lineTokens = line.split("/");
int length = lineTokens.length;
idxGroup.idxPos = Integer.parseInt(lineTokens[0]) - 1;
if (length > 1) {
// It can be empty if the obj does not define text coords
String textCoord = lineTokens[1];
idxGroup.idxTextCoord = textCoord.length() > 0 ? Integer.parseInt(textCoord) - 1 : IdxGroup.NO_VALUE;
if (length > 2) {
idxGroup.idxVecNormal = Integer.parseInt(lineTokens[2]) - 1;
}
}
return idxGroup;
}
public IdxGroup[] getFaceVertexIndices() {
return idxGroups;
}
}
```
当解析面时,我们可以看到没有纹理但具有矢量法线的对象,在这种情况下,面定义可能像`f 11//1 17//1 13//1`这样,所以我们需要检测这些情况。
最后,我们需要重新整理这些数据。`Mesh`类需要四个数组,分别用于位置坐标、纹理坐标、法线矢量,一个用于索引。前三个数组应该具有相同数量的元素,因为索引数组是唯一的(注意,相同数量的元素并不意味着相同的长度。顶点坐标是三维的,由三个浮点数组成。纹理坐标是二维的,由两个浮点数组成)。OpenGL不允许我们对每个元素类型定义不同的索引数组(如果可以的话,我们就不需要在应用纹理时重复顶点)。
当你打开一个OBJ文件时你首先可能会看到保存顶点坐标的列表比保存纹理坐标的列表和顶点的数量要多。这是我们需要解决的问题。举一个简单的例子定义了一个具有纹理高度的正方形(只是为了说明例子)其OBJ文件可能是这样的(不要太关注法线坐标,因为它只是为了说明例子)。
```java
v 0 0 0
v 1 0 0
v 1 1 0
v 0 1 0
vt 0 1
vt 1 1
vn 0 0 1
f 1/2/1 2/1/1 3/2/1
f 1/2/1 3/2/1 4/1/1
```
当我们完成文件解析时,可以看到以下列表(每个元素的数字是它在文件中的位置顺序)
![序列1](_static/09/ordering_i.png)
现在我们将使用面定义来创建包括索引在内的最终数组。要考虑的是,纹理坐标与法线向量的定义顺序与顶点的定义顺序不一样。如果列表的大小是相同的,并且它们是有序的,那么面定义就只需要一个顶点用一个数字。
因此,我们需要排列数据,并根据我们的需要进行设置。必须要做的第一件事是创建三个数组和一个列表,分别用于顶点、纹理坐标、法线向量和索引列表。正如我们之前所说的,三个数组将拥有相同数量的元素(等于顶点的数量)。顶点数组将有一个顶点列表的副本。
![序列2](_static/09/ordering_ii.png)
现在我们开始处理面。第一个面的第一个索引组是1/2/1。看到索引组中的第一个索引即定义几何顶点来构造索引列表的所以我们把它命名为`posIndex`
面指定我们应该把占据第一个位置的元素的索引添加到索引列表中。因此,我们将`posIndex`减去1后放到`indicesList`中(我们必须减去1因为数组的起始是0而OBJ文件格式中是1)。
![序列3](_static/09/ordering_iii.png)
然后,我们使用索引组的其他索引来设置`texturesArray``normalsArray`。索引组的第二个索引是2所谓我们必须将第二个纹理坐标放在与顶点指定的`posIndex`位置(V1)相同的位置上。
![序列4](_static/09/ordering_iv.png)
然后我们选择第三个索引它是1所以我们要做的是将第一个法线向量坐标放在与顶点指定的`posIndex`位置(V1)相同的位置上。
![序列5](_static/09/ordering_v.png)
在我们处理了第一个面之后,数组和列表看起来就会像这样。
![序列6](_static/09/ordering_vi.png)
在我们处理了第二个面之后,数组和列表看起来就会像这样。
![序列7](_static/09/ordering_vii.png)
第二个面也定义了已经被赋值的顶点,但是它们有相同的值,所以处理这个问题上很简单。我觉得这个过程已经讲解得很清晰了,不过在你实现它之前可能会有些麻烦。下面是重新排列数据的方法。请记住,我们要得到的是浮点数组,所以必须把顶点、纹理和法线的数组转换成浮点数组。因此,顶点和法线数组的大小将是顶点列表的大小乘以三,而纹理坐标数组的大小应该是顶点列表的大小乘以二。
```java
private static Mesh reorderLists(List<Vector3f> posList, List<Vector2f> textCoordList,
List<Vector3f> normList, List<Face> facesList) {
List<Integer> indices = new ArrayList();
// Create position array in the order it has been declared
float[] posArr = new float[posList.size() * 3];
int i = 0;
for (Vector3f pos : posList) {
posArr[i * 3] = pos.x;
posArr[i * 3 + 1] = pos.y;
posArr[i * 3 + 2] = pos.z;
i++;
}
float[] textCoordArr = new float[posList.size() * 2];
float[] normArr = new float[posList.size() * 3];
for (Face face : facesList) {
IdxGroup[] faceVertexIndices = face.getFaceVertexIndices();
for (IdxGroup indValue : faceVertexIndices) {
processFaceVertex(indValue, textCoordList, normList,
indices, textCoordArr, normArr);
}
}
int[] indicesArr = new int[indices.size()];
indicesArr = indices.stream().mapToInt((Integer v) -> v).toArray();
Mesh mesh = new Mesh(posArr, textCoordArr, normArr, indicesArr);
return mesh;
}
private static void processFaceVertex(IdxGroup indices, List<Vector2f> textCoordList,
List<Vector3f> normList, List<Integer> indicesList,
float[] texCoordArr, float[] normArr) {
// Set index for vertex coordinates
int posIndex = indices.idxPos;
indicesList.add(posIndex);
// Reorder texture coordinates
if (indices.idxTextCoord >= 0) {
Vector2f textCoord = textCoordList.get(indices.idxTextCoord);
texCoordArr[posIndex * 2] = textCoord.x;
texCoordArr[posIndex * 2 + 1] = 1 - textCoord.y;
}
if (indices.idxVecNormal >= 0) {
// Reorder vectornormals
Vector3f vecNorm = normList.get(indices.idxVecNormal);
normArr[posIndex * 3] = vecNorm.x;
normArr[posIndex * 3 + 1] = vecNorm.y;
normArr[posIndex * 3 + 2] = vecNorm.z;
}
}
```
另一件值得注意的是纹理坐标是UV格式所以Y坐标需要将文件中取到的值减去一。
最后我们可以渲染OBJ模型。我已经准备了一个OBJ文件其中是前面章节中使用过的具有纹理的立方体。为了在`DummyGame`类的`init`方法中使用它,我们需要创建一个`GameItem`实例。
```java
Texture texture = new Texture("/textures/grassblock.png");
mesh.setTexture(texture);
GameItem gameItem = new GameItem(mesh);
gameItem.setScale(0.5f);
gameItem.setPosition(0, 0, -2);
gameItems = new GameItem[]{gameItem};
```
然后将会得到一个熟悉的有纹理的立方体。
![有纹理的立方体](_static/09/textured_cube.png)
我们可以尝试渲染其他模型。例如可以使用著名的Standford Bunny模型(它可以自由地下载)将它放在resources文件夹。这个模型没有纹理所以我们可以这样做
```java
Mesh mesh = OBJLoader.loadMesh("/models/bunny.obj");
GameItem gameItem = new GameItem(mesh);
gameItem.setScale(1.5f);
gameItem.setPosition(0, 0, -2);
gameItems = new GameItem[]{gameItem};
```
![Standford Bunny](_static/09/standford_bunny.png)
这个模型看起来有点奇怪,因为没有纹理也没有光,所以我们不能看到它的体积,但是你可以检查模型是否正确地加载。在`Window`类中设置OpenGL参数时添加这一行代码。
```java
glPolygonMode( GL_FRONT_AND_BACK, GL_LINE );
```
当你放大的时候,你会看到类似的东西。
![Standford Bunny的三角形](_static/09/standford_bunny_triangles.png)
现在你可以看到组成模型的所有三角形了。
使用OBJ载入类就可以使用Blender创建模型了。Blender是一个强大的工具但刚开始使用它有点困难它有很多选项很多组合在第一次使用它的时候你需要做很多基本的事情。当你使用Blender导出模型时请务必将法线和面输出为三角形。
![OBJ导出选项](_static/09/obj_export_options.png)
还要记得在导出时切割边,因为我们不能给同一个顶点配方几个纹理坐标。此外,我们需要为每个三角形定义法线,而不是顶点。如果你遇到了光照问题(下一章)在使用模型时你应该验证一下法线。你可以在Blender中看到法线。
![边切割](_static/09/edge_split.png)

View File

@@ -0,0 +1,384 @@
# 要有光Let there be light
在本章中我们将学习如何为我们的3D游戏引擎添加光照。我们不会去实现一个完美的物理光照模型因为抛开复杂性不说它还需要巨量的计算机资源相反我们只需要一个近似的、像样的光照效果。我们将使用一种名为 __Phong__ 的着色算法由Bui Tuong Phong开发。另一个需要注意的是我们将只模拟灯光但我们不会模拟这些灯光所产生的阴影这将在其他章节中完成
在开始之前,首先定义几个光源种类:
* **点光源(Point Light)**:这种光源模拟的是一个由点向空间各个方向均匀散射的光源
* **聚光源(Spot Light)**:这种光源模拟从空间中的点发射的光源,但不是在所有方向上发射,而是限定在了一个锥形方向上
* **平行光源(Directional Light)**这种光源模拟了太阳光3D场景中的所有物体都会受到来自特定方向的平行光线的照射。无论物体是近抑或是远光线总是以一定角度照射在物体上的。
* **环境光(Ambient Light)**:这种类型的光源来自空间的任何地方,并以相同的强度照亮所有物体。
![光照类型](_static/10/light_types.png)
因此,为了模拟光,我们需要考虑光源类型,以及光的位置和其他一些参数,如颜色。当然,我们还必须考虑物体如何受光照影响以及吸收和反射光。
Phong着色算法将模拟光线对我们模型中每个点的影响即对每个顶点的影响。这就是为什么它被称为局部光照模型的原因这也是该算法不能计算阴影的原因它只会计算应用到每个顶点的光而不考虑顶点是否在挡光物体的后面。我们将在后面的章节中解决这个问题。但是正因为如此它是一种非常简单快速的算法并且可以提供非常好的效果。我们将在这里实现一个没有深入考虑材质影响的简化版本。
Phong算法提供了三种光照分量
* **环境光(Ambient Light)**:模拟来自任何地方的光,这将为我们提供(需要对应强度值)未被任何光线照射的区域,就像背景光。
* **漫反射(Diffuse Reflectance)**:考虑到面对光源的表面更亮。
* **镜面反射(Specular Reflectance)**:模拟光线如何在抛光表面或金属表面上反射。
最后,我们还要知道的规律是,乘以分配给片段的颜色,将根据接收的光线将该颜色变得更亮或更暗。我们令$A$为环境光、$D$为漫反射、$S$为镜面高光。根据以上规律对于分量的加法表示如下:
$$L = A + D + S$$
这些分量其实就是颜色,也就是每个光分量所贡献的颜色分量。这是因为光分量不仅会提供一定程度的强度,还会改变模型的颜色。在我们的片段着色器中,我们只需将该光的颜色与原始片段颜色(从纹理或基色获得)相乘即可。
我们也可以为相同的材质分配不同的颜色,这些颜色将用于环境光,漫反射和镜面反射。因此,这些分量将由材质相关的颜色而受到调整。如果材质具有纹理,我们将简单地为每个分量使用单个纹理。
所以对于非纹理材质的最终颜色将是:$L = A * 环境光色 + D * 漫反射的颜色 + S * 镜面反射的颜色$
对于有纹理材质的最终颜色将是:
$$L = A * 材质颜色 + D * 材质颜色 + S * 材质颜色$$
## 环境光分量
让我们来看看第一个分量,即环境光分量,它只是一个常量值,会使我们的所有对象变得更亮或更暗。我们可以使用它来模拟特定时间段内的光线(黎明,黄昏等),也可以用它来添加一些光线,这些光线不直接被光线照射,但可以以简单的方式被间接光线照射(比如反射)。
环境光是运算最简单的分量,我们只需要传递一种颜色,并乘以基本颜色,以调整该基本颜色。假如我们已经确定片段的颜色是$1.00.00.0$,即红色。如果没有环境光时,它将显示为完全红色的片段。如果我们将环境光设置为$0.5,0.5,0.5$,则最终颜色将为$0.5,0,0$其实就是变暗的红色。这种光会以同样的方式使所有片段变暗说光照暗了物体似乎有点奇怪实际上这就是我们得到的效果。除此之外如果光色的RGB分量不相同它还可以为片段添加一些颜色所以我们只需要一个向量来调节环境光强度和颜色。
## 漫反射
现在我们来谈谈漫反射。它模拟了这样的规律,即与光源垂直的面看起来比以更接近光的角度接收光的面更亮。一个物体接收的光线越多,其光密度(让我这样称呼)就越高。
![漫反射光](_static/10/diffuse_light.png)
但是,我们该如何计算它? 你还记得上一章我们介绍过的法线概念吗法线是垂直于平面并且长度为1的向量。因此让我们在上图中绘制三个点的法线如你所见每个点的法线将是垂直于每个点的切平面的向量。我们不去绘制来自光源的光线而是绘制从每个点到光源即相反的方向的向量。
![法线与光的方向](_static/10/diffuse_light_normals.png)
正如你所看到的,$P1$点的法线$N1$,与指向光源的向量平行,该法线的方向与光线的方向相反($N1$已经被平移标记,以便你可以看到它,但它在数学上是等价的)。$P1$相对于指向光源的向量,其角度等于$0$。因为它的平面垂直于光源,所以$P1$将是最亮的点。
$P2$点的法线$N2$与指向光源的向量的夹角约为30度所以它应该比$P1$更暗。最后,$P3$的法线$N3$也与指向光源的向量平行,但两个向量的方向相反。$P3$与指向光源的向量的角度为180度所以根本不应该获得任何光线。
所以,看起来我们得到了一个计算某个点的光照强度的好方法,光强与该点的法线和该点指向光源的向量之间的夹角大小有关。但我们具体要怎么计算它呢?
有一个我们可以使用的数学运算————数量积又称为点积。该操作需要两个向量并产生一个数字标量如果它们之间的角度较小则生成一个正数如果它们之间的角度很大则生成一个负数。如果两个向量都被归一化即两者的长度都等于1那么数量积的结果将介于$-1$和$1$之间。如果两个向量的方向相同(即夹角为$0$则数量积为1如果两个向量夹角为直角则它的值为$0$;如果两个向量的方向相反,则为$-1$。
我们定义两个向量,$v1$和$v2$,并以$$α$$作为它们之间的夹角。数量积的定义如下:
![数量积](_static/10/dot_product.png)
如果两个向量都归一化即它们的长度它们的模块将等于1它们的数量积即为夹角的余弦值。我们同样使用该运算来计算漫反射分量。
所以我们需要计算指向光源的向量。我们如何做到这一点? 假如我们有每个点的位置(即顶点位置),我们有光源的位置。首先,这两个坐标必须位于相同的坐标系中。为了简化,让我们假设它们都处于世界坐标系中,那么这些位置是指向顶点位置($VP$)和光源($VS$)的矢量的坐标,如下图所示:
![漫反射光照运算](_static/10/diffuse_calc_i.png)
如果我们从$VP$中减去$VS$,我们就得到了$L$向量。
现在我们可以在指向光源的矢量和法线之间做数量积因为Johann Lambert是第一个提出这种关系来模拟平面亮度的所以该乘积被称为兰伯特项。
让我们总结一下,我们定义以下变量:
* $vPos$ :我们的顶点在模型视图空间坐标中的位置。
* $lPos$:视图空间坐标中的光线位置。
* $intensity$光的强度从0到1
* $lCourour$:光的颜色。
* $normal$:顶点法线。
首先,我们需要计算从当前位置指向光源的向量:$toLightDirection = lPos - vPos$。该操作的结果需要进行归一化。
然后我们需要计算漫反射因子(标量):$diffuseFactor = normal \cdot toLightDirection$。计算两个向量之间的数量积,我们希望它在$-1$和$1$之间,所以两个向量都需要进行归一化。颜色需要在$0$到$1$之间,所以如果值低于$0$,我们将它设置为$0$。
最后,我们只需要通过漫反射因子和光强来调制光色:
$$ color = diffuseColour * lColour * diffuseFactor * intensity$$
## 镜面反射
现在我们来看看镜面反射,但首先我们需要知道光线是如何反射的。当光照射到一个平面时,它的一部分被吸收,另一部分被反射,如果你还记得你的物理课内容,反射就是光子从物体反弹回来。
![反射光](_static/10/light_reflection.png)
当然,平面不是完全抛光的,如果你近距离仔细观察,你会看到很多不平整的地方。除此之外,有许多射线光(实际上是光子),会撞击这个平面,并且会以各种各样的角度进行反射。因此,我们看到的就像是一束光照射一平面并散射出去。也就是说,光线在撞击平面时会发散,这就是我们之前讨论过的漫反射分量。
![平面](_static/10/surface.png)
但是,当光线照射抛光平面时,例如金属,光线会受到较低扩散的影响,并且大部分光线会反射到相反的方向。
![抛光平面](_static/10/polished_surface.png)
这就是镜面反射模型,它取决于材质特性。关于镜面反射,要注意的一点是,只有当摄像机处于适当的位置时,即反射光的发射区域内,反射光才可见。
![镜面高光](_static/10/specular_lightining.png)
解释了反射的机制,我们接下来准备计算这个分量。首先,我们需要一个从光源指向顶点的向量。当我们计算漫反射分量时,我们使用的是方向与之相反的向量,它指向的是光源。$toLightDirection$,所以让我们将其计算为$fromLightDirection = -(toLightDirection)$。
然后我们需要计算正常情况下由$fromLightDirection$到平面所产生的反射光。有一个名为reflect的GLSL函数。所以$reflectLight = reflect(fromLightSource, normal)$。
我们还需要一个指向相机的向量,并将其命名为$cameraDirection$,然后计算出相机位置和顶点位置之间的差值:$cameraDirection = cameraPos - vPos$。相机位置向量和顶点位置需要处于相同的坐标系中,并且生成的向量需要进行归一化。下图概述了我们目前计算的主要分量:
![镜面高光运算](_static/10/specular_lightining_calc.png)
现在我们需要计算光强,即$specularFactor$。如果$cameraDirection$和$reflectLight$向量指向相同的方向,该值就越高,如果它们方向相反其值则越低。为了计算这个值我们将再次使用数量积。$specularFactor = cameraDirection \cdot reflectLight$。我们只希望这个值在$0$和$1$之间,所以如果它低于$0$就设置它为0。
我们还需要考虑到,如果相机指向反射光锥,则该光更强烈。这可以通过计算$specularFactor$的$specularPower$幂来实现,其中$specularPower$为给定的参数:
$$ specularFactor = specularFactor ^ {specularPower} $$。
最后我们需要对材质的反射率进行建模反射率将影响反射光的强度这将使用一个名为reflectance的参数。所以镜面反射分量的颜色分量为$$ specularColour * lColour * reflectance * specularFactor * intensity $$。
## 衰减
我们现在知道如何计算这三个分量了,这些分量可以帮助我们用环境光模拟点光源。但是我们的光照模型还不完整,物体反射的光与光的距离无关,我们需要模拟光线衰减。
衰减是一个有关距离和光的函数。光的强度与距离的平方成反比。这很容易理解,随着光线的传播,其能量沿着球体表面分布,其半径等于光线行进的距离,而球的表面与其半径的平方成正比。我们可以用下式来计算衰减因子:$1.0 /(atConstant + atLineardist + atExponentdist ^ {2})$。
为了模拟衰减,我们只需要将衰减因子乘以最终的颜色即可。
## 实现
现在我们可以开始编程实现上面描述的所有概念,我们将从着色器开始。大部分工作将在片段着色器中完成,但我们还需要将顶点着色器中的一些数据传递给它。在前一章中,片段着色器只是接收纹理坐标,现在我们还将传递两个参数:
* 已转换为模型视图空间坐标系并已归一化的顶点法线。
* 已转换为模型视图空间坐标系的顶点位置。
这是顶点着色器的代码:
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
out vec2 outTexCoord;
out vec3 mvVertexNormal;
out vec3 mvVertexPos;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main()
{
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPos;
outTexCoord = texCoord;
mvVertexNormal = normalize(modelViewMatrix * vec4(vertexNormal, 0.0)).xyz;
mvVertexPos = mvPos.xyz;
}
```
在我们继续讲解片段着色器之前,必须强调一个非常重要的概念。从上面的代码可以看到,`mvVertexNormal`,该变量包含已转换为模型视图空间坐标的顶点法线。这是通过将`vertexNormal`乘上`modelViewMatrix`来实现的就像顶点位置一样。但有一个细微的差别该顶点法线的w分量在乘以矩阵之前被设置为0`vec4vertexNormal0.0`。我们为什么要这样做呢 因为我们希望法线可以旋转和缩放但我们不希望它被平移所以我们只对它的方向感兴趣而不是它的位置。而这是通过将w分量设置为0来实现的这也是是使用齐次坐标的优点之一通过设置w分量我们可以控制应用了哪些变换。你可以用手做矩阵乘法看看为什么是这样。
现在我们可以开始在片段着色器中干点事情了,除了将来自顶点着色器的值声明为输入参数之外,我们将定义一些有用的结构体来模拟光照和材质特性。首先,我们将定义用于模拟光的结构。
```glsl
struct Attenuation
{
float constant;
float linear;
float exponent;
};
struct PointLight
{
vec3 colour;
// 光源位置是在视图坐标系中的
vec3 position;
float intensity;
Attenuation att;
};
```
点光源由一个颜色,一个位置,以及一个介于$0$和$1$之间的数字来定义,这个数字模拟光的强度以及一组衰减方程的参数。
模拟材质特性的结构体是:
```glsl
struct Material
{
vec4 ambient;
vec4 diffuse;
vec4 specular;
int hasTexture;
float reflectance;
};
```
材质由一组颜色定义(假如我们不使用纹理为片段着色):
* 用于环境分量的颜色。
* 用于漫反射分量的颜色。
* 用于镜面反射的颜色。
材质也由一个标志来定义该标志控制它是否具有相关的纹理以及反射率指数。我们将在片段着色器中使用以下uniform。
```glsl
uniform sampler2D texture_sampler;
uniform vec3 ambientLight;
uniform float specularPower;
uniform Material material;
uniform PointLight pointLight;
uniform vec3 camera_pos;
```
我们用新建的uniform设置下面的几个变量
* 环境光:包含会以同样方式影响每个片段的颜色。
* 镜面反射强度(在讨论镜面反射光时提供的公式中使用的指数)。
* 一个点光源。
* 材质特性。
* 相机在视图空间坐标系中的位置。
我们还将定义一些全局变量,它们将保存要在环境、漫反射和镜面反射中使用的材质颜色分量。我们使用这些变量是因为如果分量具有纹理,我们将对所有分量使用相同的颜色,并且我们不希望进行冗余的纹理查找。这些变量是这样定义的:
```glsl
vec4 ambientC;
vec4 diffuseC;
vec4 speculrC;
```
我们现在可以定义一个函数,来根据材质特性设置这些变量:
```glsl
void setupColours(Material material, vec2 textCoord)
{
if (material.hasTexture == 1)
{
ambientC = texture(texture_sampler, textCoord);
diffuseC = ambientC;
speculrC = ambientC;
}
else
{
ambientC = material.ambient;
diffuseC = material.diffuse;
speculrC = material.specular;
}
}
```
现在我们要定义一个函数,以点光源、顶点位置及其法线为输入并返回前面描述的漫反射和镜面反射计算的颜色。
```glsl
vec4 calcPointLight(PointLight light, vec3 position, vec3 normal)
{
vec4 diffuseColour = vec4(0, 0, 0, 0);
vec4 specColour = vec4(0, 0, 0, 0);
// 漫反射
vec3 light_direction = light.position - position;
vec3 to_light_source = normalize(light_direction);
float diffuseFactor = max(dot(normal, to_light_source ), 0.0);
diffuseColour = diffuseC * vec4(light.colour, 1.0) * light.intensity * diffuseFactor;
// 镜面反射
vec3 camera_direction = normalize(-position);
vec3 from_light_source = -to_light_source;
vec3 reflected_light = normalize(reflect(from_light_source, normal));
float specularFactor = max( dot(camera_direction, reflected_light), 0.0);
specularFactor = pow(specularFactor, specularPower);
specColour = speculrC * specularFactor * material.reflectance * vec4(light.colour, 1.0);
// 衰减
float distance = length(light_direction);
float attenuationInv = light.att.constant + light.att.linear * distance +
light.att.exponent * distance * distance;
return (diffuseColour + specColour) / attenuationInv;
}
```
前面的代码相对比较直白简单,它只是计算了漫反射分量的颜色,另一个是计算镜面反射的颜色,并通过光线在行进到我们正在处理的顶点时受到的衰减来调制它们。
请注意,顶点坐标是位于视图空间中的。在计算镜面反射时,我们必须指出视角,即相机。这可以这样做:
```glsl
vec3 camera_direction = normalize(camera_pos - position);
```
但是,由于`位置`在视图空间中,相机位置始终位于原点,即$0,0,0$,所以我们如下计算它:
```glsl
vec3 camera_direction = normalize(vec3(0, 0, 0) - position);
```
可以如此简化:
```glsl
vec3 camera_direction = normalize(-position);
```
有了前面的函数,定点着色器的主函数就变得非常简单了。
```glsl
void main()
{
setupColours(material, outTexCoord);
vec4 diffuseSpecularComp = calcPointLight(pointLight, mvVertexPos, mvVertexNormal);
fragColor = ambientC * vec4(ambientLight, 1) + diffuseSpecularComp;
}
```
调用setupColours函数将使用适当的颜色来设置变量ambientC、diffuseC和speculrC。然后我们计算漫反射和镜面反射并考虑衰减。为了方便我们使用单个函数调用来完成此操作如上所述。最终的颜色是通过添加环境光分量来计算的将ambientC乘以环境光。正如你所看到的环境光不受衰减的影响。
在着色器中我们引入了一些需要进一步解释的新概念定义结构体并将它们用作uniform。但我们要怎么传递这些结构体首先我们将定义两个新类它们模拟点点光源和材质的特性名为`PointLight``Material`。它们只是普通的POJO(普通的Java对象)所以你可以在本书附带的源代码中查看它们。然后我们需要在ShaderProgram类中创建新方法首先要能够为点光源和材质结构创建uniform。
```java
public void createPointLightUniform(String uniformName) throws Exception {
createUniform(uniformName + ".colour");
createUniform(uniformName + ".position");
createUniform(uniformName + ".intensity");
createUniform(uniformName + ".att.constant");
createUniform(uniformName + ".att.linear");
createUniform(uniformName + ".att.exponent");
}
public void createMaterialUniform(String uniformName) throws Exception {
createUniform(uniformName + ".ambient");
createUniform(uniformName + ".diffuse");
createUniform(uniformName + ".specular");
createUniform(uniformName + ".hasTexture");
createUniform(uniformName + ".reflectance");
}
```
正如你所看到的它非常简单我们只为构成结构体的所有属性创建一个单独的uniform。现在我们需要创建另外两个方法来设置这些uniform的值并且将使用参数`PointLight`和材质的实例。
```java
public void setUniform(String uniformName, PointLight pointLight) {
setUniform(uniformName + ".colour", pointLight.getColor() );
setUniform(uniformName + ".position", pointLight.getPosition());
setUniform(uniformName + ".intensity", pointLight.getIntensity());
PointLight.Attenuation att = pointLight.getAttenuation();
setUniform(uniformName + ".att.constant", att.getConstant());
setUniform(uniformName + ".att.linear", att.getLinear());
setUniform(uniformName + ".att.exponent", att.getExponent());
}
public void setUniform(String uniformName, Material material) {
setUniform(uniformName + ".ambient", material.getAmbientColour());
setUniform(uniformName + ".diffuse", material.getDiffuseColour());
setUniform(uniformName + ".specular", material.getSpecularColour());
setUniform(uniformName + ".hasTexture", material.isTextured() ? 1 : 0);
setUniform(uniformName + ".reflectance", material.getReflectance());
}
```
在本章源代码中,你还将看到我们还修改了`Mesh`类来存放材质实例并且我们创建了一个简单的示例并在其中创建了一个可用“N”和“M”键控制移动的点光源以显示点光源聚焦在反射率值高于0的网格上时是怎样的。
让我们回到片段着色器正如我们所说的我们需要另一种包含相机位置camera_pos的uniform。这些坐标必须位于视图空间中。通常我们将在世界空间坐标中设置光坐标因此我们需要将它们乘以视图矩阵以便能够在着色器中使用它们所以我们需要在`Transformation`类中创建一个新方法,该方法返回视图矩阵让我们转换光照坐标。
```java
// 获得光源对象的副本并将它的坐标转换为视图坐标
PointLight currPointLight = new PointLight(pointLight);
Vector3f lightPos = currPointLight.getPosition();
Vector4f aux = new Vector4f(lightPos, 1);
aux.mul(viewMatrix);
lightPos.x = aux.x;
lightPos.y = aux.y;
lightPos.z = aux.z;
shaderProgram.setUniform("pointLight", currPointLight);
```
我们不会在这里引用整个源代码,因为如果这样这一章就太长了,对于解释概念并没有太多的作用。您可以在本书附带的源代码中查看它。
![Lightning results](_static/10/lightning_result.png)

View File

@@ -0,0 +1,323 @@
# 要有更多的光Let there be even more light
在本章中,我们将实现在前面章节中介绍的其他类型的光。我们先从平行光源开始。
## 平行光源
平行光照击中所有物体的平行射线都来自同一方向,因此它用来模拟遥远但具有高光强的光源,比如太阳。
![平行光源](_static/11/directional_light.png)
平行光源的另一个特点是它不受衰减的影响。再想一想太阳光,所有被光线照射的地球上的物体都以相同强度被照射,地球上的物体离太阳的距离之大,以至于它们之间的相对位置是影响的。事实上,平行光源被定义为放在无限远处的光源,如果它受到衰减的影响,它将对任何物体都没有光照(它对颜色的影响将等于0)。
此外,平行光源也由漫反射和镜面光分量组成,与点光源的唯一区别在于它没有位置,而有方向,并且它不受衰减的影响。让我们回到平行光属性的讨论,想象我们正在模拟太阳在三维世界中运动,下图显示了黎明、正午和黄昏时的光照方向。
![太阳是一种平行光源](_static/11/sun_directional_light.png)
上述位置的照明方向为:
* 黎明: \(-1, 0, 0\)
* 正午: \(0, 1, 0\)
* 黄昏: \(1, 0, 0\)
注意:你可能认为上面的坐标是位置坐标,但是它们是向量,方向,而不是位置。从数学的角度来看,向量和位置是不可区分的,它们有着完全不同的含义。
但是我们如何定义这个光位于无穷远处呢答案是使用W坐标也就是说使用其次坐标然后将W坐标设置为0。
* 黎明: \(-1, 0, 0, 0\)
* 正午: \(0, 1, 0, 0\)
* 黄昏: \(1, 0, 0, 0\)
这就像我们在使用法线时一样。对于法线我们将W分量设置为0表示我们对位移不感兴趣只对方向感兴趣。此外当我们处理平行光照时也需要这样做摄像机的平移不应该影响平行光照的方向。
让我们开始编写和模拟我们的方向光。我们要做的第一件事是创建一个模型来储存它的属性。它只是另一个具有复制构造函数的普通的Java对象并储存方向、颜色和强度。
```java
package org.lwjglb.engine.graph;
import org.joml.Vector3f;
public class DirectionalLight {
private Vector3f color;
private Vector3f direction;
private float intensity;
public DirectionalLight(Vector3f color, Vector3f direction, float intensity) {
this.color = color;
this.direction = direction;
this.intensity = intensity;
}
public DirectionalLight(DirectionalLight light) {
this(new Vector3f(light.getColor()), new Vector3f(light.getDirection()), light.getIntensity());
}
// Getters and settes beyond this point...
```
正如你所看到的,我们使用`Vector3f`来储存方向。保持冷静当将平行光照传递到着色器时我们将处理W分量。顺便说一个我们接下来要做的就是更新`ShaderProgram`来创建和更新储存平行光照的Uniform。
在片元着色器中,我们将定义一个结构体来模拟平行光源。
```glsl
struct DirectionalLight
{
vec3 colour;
vec3 direction;
float intensity;
};
```
在这个定义中,`ShaderProgram`类中的新方法也是直接了当的。
```java
// ...
public void createDirectionalLightUniform(String uniformName) throws Exception {
createUniform(uniformName + ".colour");
createUniform(uniformName + ".direction");
createUniform(uniformName + ".intensity");
}
// ...
public void setUniform(String uniformName, DirectionalLight dirLight) {
setUniform(uniformName + ".colour", dirLight.getColor() );
setUniform(uniformName + ".direction", dirLight.getDirection());
setUniform(uniformName + ".intensity", dirLight.getIntensity());
}
```
我们现在需要使用Uniform。我们将通过`DummyGame`类控制太阳的角度来模拟它跨越天空。
![太阳的移动](_static/11/sun_movement.png)
我们需要更新光的方向,所以当黎明时(-90°)太阳在(-1, 0, 0)方向上它的X坐标从-1逐渐增加到0Y坐标逐渐从0增加到1。然后X坐标增加到1Y坐标又下降到0。这可以通过将X坐标设置为正弦角和Y坐标设置为余弦角来完成。
![正弦和余弦](_static/11/sine_cosine.png)
我们也会调整光照强度当它离黎明越远强度将越强并且随着黄昏的临近而减弱。我们将通过设置强度为0来模拟夜晚。此外我们还将调节颜色使光线在黎明和黄昏时变得更红。这将在`DummyGame`类的`update`方法中实现。
```java
// Update directional light direction, intensity and colour
lightAngle += 1.1f;
if (lightAngle > 90) {
directionalLight.setIntensity(0);
if (lightAngle >= 360) {
lightAngle = -90;
}
} else if (lightAngle <= -80 || lightAngle >= 80) {
float factor = 1 - (float)(Math.abs(lightAngle) - 80)/ 10.0f;
directionalLight.setIntensity(factor);
directionalLight.getColor().y = Math.max(factor, 0.9f);
directionalLight.getColor().z = Math.max(factor, 0.5f);
} else {
directionalLight.setIntensity(1);
directionalLight.getColor().x = 1;
directionalLight.getColor().y = 1;
directionalLight.getColor().z = 1;
}
double angRad = Math.toRadians(lightAngle);
directionalLight.getDirection().x = (float) Math.sin(angRad);
directionalLight.getDirection().y = (float) Math.cos(angRad);
```
然后,我们需要将平行光源传递给`Renderer`类中的`render`方法中的着色器。
```java
// Get a copy of the directional light object and transform its position to view coordinates
DirectionalLight currDirLight = new DirectionalLight(directionalLight);
Vector4f dir = new Vector4f(currDirLight.getDirection(), 0);
dir.mul(viewMatrix);
currDirLight.setDirection(new Vector3f(dir.x, dir.y, dir.z));
shaderProgram.setUniform("directionalLight", currDirLight);
```
正如你所看到的我们需要变换光照向量到观察空间但是我们将W分量设置为0因为我们对变换不感兴趣。
现在,我们已经准备好在片元着色器上完成剩下的工作,因为顶点着色器不需要修改。之前已经说过,我们需要定义一个名为`DirectionalLight`的新结构体来储存平行光源数据所以需要一个新的Uniform类型。
```glsl
uniform DirectionalLight directionalLight;
```
我们需要重构代码,在前一章中,我们有一个叫做`calcPointLight`的函数,它负责计算漫反射和镜面反射分类,也使用衰减。但正如我们所说的,平行光照使用漫反射和镜面反射分量,但不受衰减影响,所以我们将创建一个名为`calcLightColour`新的函数,只计算那些内容。
```glsl
vec4 calcLightColour(vec3 light_colour, float light_intensity, vec3 position, vec3 to_light_dir, vec3 normal)
{
vec4 diffuseColour = vec4(0, 0, 0, 0);
vec4 specColour = vec4(0, 0, 0, 0);
// Diffuse Light
float diffuseFactor = max(dot(normal, to_light_dir), 0.0);
diffuseColour = diffuseC * vec4(light_colour, 1.0) * light_intensity * diffuseFactor;
// Specular Light
vec3 camera_direction = normalize(camera_pos - position);
vec3 from_light_dir = -to_light_dir;
vec3 reflected_light = normalize(reflect(from_light_dir , normal));
float specularFactor = max( dot(camera_direction, reflected_light), 0.0);
specularFactor = pow(specularFactor, specularPower);
specColour = speculrC * light_intensity * specularFactor * material.reflectance * vec4(light_colour, 1.0);
return (diffuseColour + specColour);
}
```
然后,`calcPointLight`方法将衰减应用到先前函数中计算的结果。
```glsl
vec4 calcPointLight(PointLight light, vec3 position, vec3 normal)
{
vec3 light_direction = light.position - position;
vec3 to_light_dir = normalize(light_direction);
vec4 light_colour = calcLightColour(light.colour, light.intensity, position, to_light_dir, normal);
// Apply Attenuation
float distance = length(light_direction);
float attenuationInv = light.att.constant + light.att.linear * distance +
light.att.exponent * distance * distance;
return light_colour / attenuationInv;
}
```
我们还将创建一个新的函数来计算平行光源的效果,它只调用`calcLightColour`方法。
```glsl
vec4 calcDirectionalLight(DirectionalLight light, vec3 position, vec3 normal)
{
return calcLightColour(light.colour, light.intensity, position, normalize(light.direction), normal);
}
```
最后,`main`方法通过环境光和平行光的颜色分量来计算片元颜色。
```glsl
void main()
{
setupColours(material, outTexCoord);
vec4 diffuseSpecularComp = calcDirectionalLight(directionalLight, mvVertexPos, mvVertexNormal);
diffuseSpecularComp += calcPointLight(pointLight, mvVertexPos, mvVertexNormal);
fragColor = ambientC * vec4(ambientLight, 1) + diffuseSpecularComp;
}
```
就是这样,现在我们可以模拟太阳在天空中的移动,就像这样(运动速度加快,不用等待太久就可以看到)。
![平行光照结果](_static/11/directional_light_result.png)
## 聚光源
现在我们将实现与点光源非常相似的光源,但是它发出的光被限制在三维锥体中。它模拟从焦点或者其他不向四面八方发射光的光源。聚光源具有和点光源一样的参数,但它还增加了两个新的参数,锥角和锥方向。
![聚光](_static/11/spot_light.png)
聚光源与点光源的计算方法相同,但有一些不同。从顶点位置到光源的矢量不在光锥内的点不受光照的影响。
![聚光2](_static/11/spot_light_ii.png)
该如何计算它是否在光锥里呢?我们需要在点光源和圆锥方向矢量(它们都归一化)之间再次做数量积。
![聚光计算](_static/11/spot_light_calc.png)
L和C向量之间的数量积等于$$\vec{L}\cdot\vec{C}=|\vec{L}|\cdot|\vec{C}|\cdot Cos(\alpha)$$。在聚光源的定义中,我们储存锥角的余弦值,如果数量积高于该值,我们就知道它在光锥内部(想想余弦图α角为0°时余弦将为1。在0°~180°时角越小余弦值越大)。
第二个区别是远离光源的点将接收更少的光,也就是说,衰减将更强。有几种计算方法,我们将选择一种简单的方法,通过将衰减与以下公式相乘:
$$1 - (1-Cos(\alpha))/(1-Cos(cutOffAngle)$$
(在片元着色器中我们没有传递角度而是传递角的余弦值。你可以从0~1检查上面的公式当角度为0时余弦值为1。)
实现非常类似于其他的光源,我们需要创建一个名为`SpotLight`的类设置适当的Uniform将其传递给着色器并修改片元着色器。你可以查看本章的源代码。
当传递Uniform时另一个重要的事情是变换不应该应用到光锥方向上因为我们只对方向感兴趣。因此和平行光源的情况一样当转换到观察矩阵空间时必须把w值设置为0。
![聚光源示例](_static/11/spot_light_sample.png)
## 多光源
我们最终实现了所有类型的光源,但是目前每个类型的光源只能有一个。这对于环境光和平行光来说没问题,但是我们确实希望使用多个点光源和聚光源。我们需要修改片元着色器来接收光源列表,所以使用数组来储存这些数据。
在开始之前要注意的事情是在GLSL中数组的长度必须在编译时设置因此它必须足够大以便在运行时能够容纳所有对象。第一件事情是定义一些常量来设置我们要使用的最大点光源数和聚光源数。
```glsl
const int MAX_POINT_LIGHTS = 5;
const int MAX_SPOT_LIGHTS = 5;
```
然后我们需要修改之前只储存一个点光源和一个聚光源的Uniform为数组。
```glsl
uniform PointLight pointLights[MAX_POINT_LIGHTS];
uniform SpotLight spotLights[MAX_SPOT_LIGHTS];
```
在main函数中我们只需要对这些数组进行迭代以使用现有函数计算每个对象对颜色的贡献。我们可能不会像Uniform数组长度那样传递很多光所以需要控制它。有很多可行的方法但这可能不适用于旧的显卡最终我们将检查光强度(在数组中的空位的对象光强为0)。
```glsl
for (int i=0; i<MAX_POINT_LIGHTS; i++)
{
if ( pointLights[i].intensity > 0 )
{
diffuseSpecularComp += calcPointLight(pointLights[i], mvVertexPos, mvVertexNormal);
}
}
for (int i=0; i<MAX_SPOT_LIGHTS; i++)
{
if ( spotLights[i].pl.intensity > 0 )
{
diffuseSpecularComp += calcSpotLight(spotLights[i], mvVertexPos, mvVertexNormal);
}
}
```
现在我们需要在`Render`类中创建这些Uniform。当我们使用数组时我们需要为列表中的每个元素创建一个Uniform。例如对于`pointLights`数组,我们需要创建名为`pointLights[0]``pointLights[1]`之类的Uniform。当然这也适用于结构体属性所以我们将创建`pointLights[0].colour``pointLights[1].colour`等等。创建这些Uniform的方法如下。
```java
public void createPointLightListUniform(String uniformName, int size) throws Exception {
for (int i = 0; i < size; i++) {
createPointLightUniform(uniformName + "[" + i + "]");
}
}
public void createSpotLightListUniform(String uniformName, int size) throws Exception {
for (int i = 0; i < size; i++) {
createSpotLightUniform(uniformName + "[" + i + "]");
}
}
```
我们也需要方法来设置这些Uniform的值。
```java
public void setUniform(String uniformName, PointLight[] pointLights) {
int numLights = pointLights != null ? pointLights.length : 0;
for (int i = 0; i < numLights; i++) {
setUniform(uniformName, pointLights[i], i);
}
}
public void setUniform(String uniformName, PointLight pointLight, int pos) {
setUniform(uniformName + "[" + pos + "]", pointLight);
}
public void setUniform(String uniformName, SpotLight[] spotLights) {
int numLights = spotLights != null ? spotLights.length : 0;
for (int i = 0; i < numLights; i++) {
setUniform(uniformName, spotLights[i], i);
}
}
public void setUniform(String uniformName, SpotLight spotLight, int pos) {
setUniform(uniformName + "[" + pos + "]", spotLight);
}
```
最后我们只需要更新`Render`类来接收点光源和聚光源列表,并相应地修改`DummyGame`类来创建这些列表。最后看起来就像这样。
![多光源](_static/11/multiple_lights.png)

618
docs/12-game-hud.md Normal file
View File

@@ -0,0 +1,618 @@
# 游戏HUDGame HUD
本章中我们将为游戏实现一个HUD(平视显示器)。换句话说就是在三维场景上用一组二维图形和文本显示相关信息。我们将创建一个简单的HUD接下来将说明一些如何显示这些信息的基本方法。
当你查看本章的源代码时,还将看到我们重构了一些代码,特别是`Renderer`以便为HUD渲染做好准备。
## 文本渲染
创建HUD所要做的第一件事是渲染文本。为了实现它我们将把包含字母字符的纹理的纹理映射到一个矩形中该矩形将被分割为一组表示各个字符的片段。之后我们将使用该纹理绘制文本。所以第一步是创建含有所有字符的纹理你可以使用很多程序来做例如[CBFG](http://www.codehead.co.uk/cbfg/)、[F2IBuilder](http://sourceforge.net/projects/f2ibuilder/)等等。现在我们使用Codeheads Bitmap Font Generator(CBFG)。
CBFG有很多设置例如纹理大小、字体类型、要使用的反走样等等。下图是我们将用来生成纹理文件的配置。在本章中我们将假设文本编码为ISO-8859-1格式如果需要处理不同的编码格式则需要稍微修改代码。
![CBFG配置](_static/12/CBG.png)
当你设置CBFG的所有配置后可以导出为多种图片格式。现在我们将它导出为BMP文件然后再转换为PNG文件以便将它作为纹理加载。当转换为PNG格式时我们也可以将黑色背景设置为透明也就是说我们将黑色的Alpha值设置为0(可以使用GIMP这样的工具)。最后,你会得到与下图类似的东西。
![字体纹理](_static/12/font_texture.png)
如上所试所有的字符都排列在图像中。现在图像有15列和17行。通过
As you can see, the image has all the characters displayed in rows and columns. In this case the image is composed by 15 columns and 17 rows. By using the character code of a specific letter we can calculate the row and the column that is enclosed in the image. The column can be calculated as follows: $$column = code \space mod \space numberOfColumns$$. Where $$mod$$ is the module operator. The row can be calculated as follows: $$row = code / numberOfCols$$, in this case we will do a integer by integer operation so we can ignore the decimal part.
我们将创建一个名为`TextItem`的类它将储存渲染文本所需的内容。这是一个简化的实现不考虑多行文本等特性但它能在HUD中显示文本信息。下面是这个类的实现。
```java
package org.lwjglb.engine;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import org.lwjglb.engine.graph.Material;
import org.lwjglb.engine.graph.Mesh;
import org.lwjglb.engine.graph.Texture;
public class TextItem extends GameItem {
private static final float ZPOS = 0.0f;
private static final int VERTICES_PER_QUAD = 4;
private String text;
private final int numCols;
private final int numRows;
public TextItem(String text, String fontFileName, int numCols, int numRows) throws Exception {
super();
this.text = text;
this.numCols = numCols;
this.numRows = numRows;
Texture texture = new Texture(fontFileName);
this.setMesh(buildMesh(texture, numCols, numRows));
}
```
这个类继承了`GameItem`,这是因为我们希望改变文本在屏幕上的位置,也可能需要缩放和旋转它。构造函数接收腰显示的文本和用于渲染的纹理数据(包括图像数据和行列数目)。
在构造函数中,我们加载纹理图像文件,并调用一个方法来创建一个`Mesh`实例用于模拟文本。让我们看看`buildMesh`方法。
```java
private Mesh buildMesh(Texture texture, int numCols, int numRows) {
byte[] chars = text.getBytes(Charset.forName("ISO-8859-1"));
int numChars = chars.length;
List<Float> positions = new ArrayList();
List<Float> textCoords = new ArrayList();
float[] normals = new float[0];
List<Integer> indices = new ArrayList();
float tileWidth = (float)texture.getWidth() / (float)numCols;
float tileHeight = (float)texture.getHeight() / (float)numRows;
```
代码创建了用于储存Mesh的位置、纹理坐标、法线和索引的数据结构。现在我们不使用照明因此法线数列是空的。我们要做的是构造一组字符片段每个字符片段代表一个字符。我们还需要根据每个字符片段对应的字符来分配适当的纹理坐标。下图表示了文本矩形和字符片段的关系。
![文本矩形](_static/12/text_quad.png)
因此,对于每个字符,我们需要创建由两个三角形构成的字符片段,这两个三角形可以用四个顶点(V1、V2、V3和V4)定义。第一个三角形(左下角的那个)的索引为(0, 1, 2),而第二个三角形(右上角的那个)的索引为(3, 0, 2)。纹理坐标是基于与纹理图像中每个字符相关连的行和列计算的,纹理坐标的范围为[0,1]所以我们只需要将当前行或当前列除以总行数和总列数就可以获得V1的坐标。对于其他顶点我们只需要适当加上行宽或列宽就可以。
下面的循环语句块就创建了与显示文本的矩形相关的所有顶点、纹理坐标和索引。
```java
for(int i=0; i<numChars; i++) {
byte currChar = chars[i];
int col = currChar % numCols;
int row = currChar / numCols;
// 构造由两个三角形组成的字符片段
// 左上角的顶点
positions.add((float)i*tileWidth); // x
positions.add(0.0f); //y
positions.add(ZPOS); //z
textCoords.add((float)col / (float)numCols );
textCoords.add((float)row / (float)numRows );
indices.add(i*VERTICES_PER_QUAD);
// 左下角的顶点
positions.add((float)i*tileWidth); // x
positions.add(tileHeight); //y
positions.add(ZPOS); //z
textCoords.add((float)col / (float)numCols );
textCoords.add((float)(row + 1) / (float)numRows );
indices.add(i*VERTICES_PER_QUAD + 1);
// 右下角的顶点
positions.add((float)i*tileWidth + tileWidth); // x
positions.add(tileHeight); //y
positions.add(ZPOS); //z
textCoords.add((float)(col + 1)/ (float)numCols );
textCoords.add((float)(row + 1) / (float)numRows );
indices.add(i*VERTICES_PER_QUAD + 2);
// 右上角的顶点
positions.add((float)i*tileWidth + tileWidth); // x
positions.add(0.0f); //y
positions.add(ZPOS); //z
textCoords.add((float)(col + 1)/ (float)numCols );
textCoords.add((float)row / (float)numRows );
indices.add(i*VERTICES_PER_QUAD + 3);
// 添加左上角和右下角顶点的索引
indices.add(i*VERTICES_PER_QUAD);
indices.add(i*VERTICES_PER_QUAD + 2);
}
```
在上面的代码片段中需要注意的一些事情是:
* 我们将使用屏幕坐标来表示顶点(记住屏幕坐标系的原点位于屏幕左上角)。三角形上顶点的Y坐标小于三角形底部顶点的Y坐标。
* 我们不缩放图形因此每个字符片段的X宽度等于字符宽度。三角形的高度将是每个字符的高度。这是因为我们希望尽可能地使文本渲染得像原始纹理(不管怎样,我们可以稍后对它进行缩放,因为`TextItem`类继承了`GameItem`类。
* Z坐标为固定值因为它与绘制这个图像无关。
下图显示了一些顶点的坐标。
![文本矩形坐标](_static/12/text_quad_coords.png)
为什么我们使用屏幕坐标首先我们将在HUD中渲染2D对象这样通常更容易使用它们。其次我们将使用正投影(`Orthographic Projection`)绘制它们,稍后再解释什么是正投影。
`TextItem`类最后还需添加一些方法以获取文本并在运行时更改文本。每当文本被更改时我们需要清理之前的VAO(储存在`Mesh`实例中)并创建一个新的VAO。我们不需要清理纹理所以在`Mesh`类中添加了一个新方法来删除这些数据。
```java
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
Texture texture = this.getMesh().getMaterial().getTexture();
this.getMesh().deleteBuffers();
this.setMesh(buildMesh(texture, numCols, numRows));
}
```
既然我们已经建立了渲染文本所需要的基础结构接下来该怎么做呢首先是渲染三维场景在之前的章节已经说明了然后再在上面渲染二维HUD。为了渲染HUD我们将使用正投影(也称为正交投影(`Orthogonal Projection`))。正投影是三维物体的二维表示,你可能已经在三维模型的蓝图中看到了一些例子,它们用来表示某些物体的顶部或某些侧面的样子。下图展示了圆柱体从顶部和前面的正投影。
![正投影](_static/12/orthographic_projections.png)
为了绘制二维物体这个投影是非常方便的因为它“忽略”了Z坐标的值也就是说忽略了到屏幕的距离。有了这种矩阵物体的体积不会随着距离的增大而减小(如投影矩阵)。为了使用正投影投影物体,我们需要使用另一个矩阵。正投影矩阵的公式如下所示。
![正投影矩阵](_static/12/orthographic_matrix.png)
这个矩阵还矫正了失真,因为我们的窗口并不总是完美的正方形,而是一个矩形。`right``bottom`是屏幕大小,而`left``top`是原点坐标。正投影矩阵用于将屏幕坐标转换为三维空间坐标。下图展示了这个投影是如何完成的。
![正投影示例](_static/12/orthographic_projection_sample.png)
这个矩阵允许我们使用屏幕坐标。
我们现在可以继续实现HUD了。接下来我们要做的是创建另一组着色器一个顶点着色器和一个片元着色器来绘制HUD。顶点着色器很简单
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
out vec2 outTexCoord;
uniform mat4 projModelMatrix;
void main()
{
gl_Position = projModelMatrix * vec4(position, 1.0);
outTexCoord = texCoord;
}
```
它仅接收顶点坐标、纹理坐标、索引和法线,并将使用矩阵将它们转换为三维空间坐标。该矩阵即是正投影矩阵与模型矩阵相乘,即$$projModelMatrix = ortographicMatrix \cdot modelMatrix$$。由于我们没有在模型坐标系中使用任何坐标所以在Java代码中将两个矩阵相乘比在着色器中相乘更高效。这样我们只需为每个顶点做一次乘法运算。还要记住顶点应该用屏幕坐标表示。
片元着色器也很简单:
```glsl
#version 330
in vec2 outTexCoord;
in vec3 mvPos;
out vec4 fragColor;
uniform sampler2D texture_sampler;
uniform vec4 colour;
void main()
{
fragColor = colour * texture(texture_sampler, outTexCoord);
}
```
它只是将基本颜色与纹理颜色相乘,这样可以改变渲染文本的颜色,而不需要创建多个纹理文件。现在既然我们已经创建了一组新的着色器,就可以在`Renderer`类中使用它们。但在此之前,我们要创建一个名为`IHud`的接口该接口储存要在HUD上显示的所有元素还提供一个默认的`cleanup`方法。
```java
package org.lwjglb.engine;
public interface IHud {
GameItem[] getGameItems();
default void cleanup() {
GameItem[] gameItems = getGameItems();
for (GameItem gameItem : gameItems) {
gameItem.getMesh().cleanUp();
}
}
}
```
通过使用该接口不同的游戏可以定义自定义的HUD而不需要改变渲染机制。现在回到`Renderer`类,顺便说一下,它已经被移动到`engine.graph`包下,因为现在它的通用性足以不依赖任何游戏的具体实现了。在`Renderer`类中,我们添加了一个新的方法来创建、连接和初始化一个新的`ShaderProgram`,以便使用之前所述的着色器。
```java
private void setupHudShader() throws Exception {
hudShaderProgram = new ShaderProgram();
hudShaderProgram.createVertexShader(Utils.loadResource("/shaders/hud_vertex.vs"));
hudShaderProgram.createFragmentShader(Utils.loadResource("/shaders/hud_fragment.fs"));
hudShaderProgram.link();
// 为正投影模型矩阵和颜色创建Uniform
hudShaderProgram.createUniform("projModelMatrix");
hudShaderProgram.createUniform("colour");
}
```
`render`方法首先会调用`renderScene`方法,里面包含了之前章节所述的渲染三维场景的代码,然后调用一个名为`renderHud`的新方法用于渲染HUD。
```java
public void render(Window window, Camera camera, GameItem[] gameItems,
SceneLight sceneLight, IHud hud) {
clear();
if ( window.isResized() ) {
glViewport(0, 0, window.getWidth(), window.getHeight());
window.setResized(false);
}
renderScene(window, camera, gameItems, sceneLight);
renderHud(window, hud);
}
```
`renderHud`方法实现如下:
```java
private void renderHud(Window window, IHud hud) {
hudShaderProgram.bind();
Matrix4f ortho = transformation.getOrthoProjectionMatrix(0, window.getWidth(), window.getHeight(), 0);
for (GameItem gameItem : hud.getGameItems()) {
Mesh mesh = gameItem.getMesh();
// HUD元素的正投影矩阵与模型矩阵相乘
Matrix4f projModelMatrix = transformation.getOrtoProjModelMatrix(gameItem, ortho);
hudShaderProgram.setUniform("projModelMatrix", projModelMatrix);
hudShaderProgram.setUniform("colour", gameItem.getMesh().getMaterial().getAmbientColour());
// 渲染HUD元素
mesh.render();
}
hudShaderProgram.unbind();
}
```
上述代码中我们遍历了HUD的所有元素并将与每个元素关联的模型矩阵和正投影矩阵相乘。正投影矩阵在每次`render`调用时被刷新(因为屏幕大小可以改变),并且通过如下方式计算:
```java
public final Matrix4f getOrthoProjectionMatrix(float left, float right, float bottom, float top) {
orthoMatrix.identity();
orthoMatrix.setOrtho2D(left, right, bottom, top);
return orthoMatrix;
}
```
`game`包中,我们将创建一个`Hud`类,它实现了`IHud`接口,并在构造函数接收一个文本用于在内部创建`TextItem`实例。
```java
package org.lwjglb.game;
import org.joml.Vector4f;
import org.lwjglb.engine.GameItem;
import org.lwjglb.engine.IHud;
import org.lwjglb.engine.TextItem;
public class Hud implements IHud {
private static final int FONT_COLS = 15;
private static final int FONT_ROWS = 17;
private static final String FONT_TEXTURE = "/textures/font_texture.png";
private final GameItem[] gameItems;
private final TextItem statusTextItem;
public Hud(String statusText) throws Exception {
this.statusTextItem = new TextItem(statusText, FONT_TEXTURE, FONT_COLS, FONT_ROWS);
this.statusTextItem.getMesh().getMaterial().setColour(new Vector4f(1, 1, 1, 1));
gameItems = new GameItem[]{statusTextItem};
}
public void setStatusText(String statusText) {
this.statusTextItem.setText(statusText);
}
@Override
public GameItem[] getGameItems() {
return gameItems;
}
public void updateSize(Window window) {
this.statusTextItem.setPosition(10f, window.getHeight() - 50f, 0);
}
}
```
`DummyGame`类中我们创建该类的实例,用默认文本初始化它,最后得到如下所示的东西。
![文本渲染结果](_static/12/text_result.png)
`Texture`类中,我们可以通过修改纹理的过滤来提高文本的可读性(如果你想要缩放文本的话,你需要注意)。
```java
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
```
但是例子还没有完成。如果你要缩放,使文本与立方体重叠时,就会看到这样的效果。
![背景不透明的文本](_static/12/text_opaque.png)
绘制的文本背景不透明。为了实现背景透明,我们必须明确启用混合(`Blend`)这样就可以使用Alpha量。我们将在`Window`类中用下面的代码设置其他初始化参数。
```java
// 支持透明背景
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
```
现在你可以看到文本以透明背景绘制了。
![透明背景的文本](_static/12/text_transparent.png)
## 完成HUD
现在我们已经渲染了文本但还可以向HUD添加更多的元素。我们将添加一个根据摄像机朝向旋转的指针。现在我们将向`Hud`类添加一个新的`GameItem`,它将有一个指针的模型。
![指针](_static/12/compass.png)
指针的模型是.obj文件但它不会关联任何纹理相反它只有背景颜色。所以我们需要修改HUD的片段着色器来检测是否使用纹理。我们将通过设置一个名为`hasTexture`的新Uniform来实现它。
```glsl
#version 330
in vec2 outTexCoord;
in vec3 mvPos;
out vec4 fragColor;
uniform sampler2D texture_sampler;
uniform vec4 colour;
uniform int hasTexture;
void main()
{
if ( hasTexture == 1 )
{
fragColor = colour * texture(texture_sampler, outTexCoord);
}
else
{
fragColor = colour;
}
}
```
要添加指针到HUD上我们只需要在`Hud`类中创建一个新的`GameItem`实例。它加载指针模型,并将其添加到数组中。现在,我们需要放大指针。因为它在屏幕坐标中渲染,所以通常你需要放大它。
```java
// 创建指针
Mesh mesh = OBJLoader.loadMesh("/models/compass.obj");
Material material = new Material();
material.setAmbientColour(new Vector4f(1, 0, 0, 1));
mesh.setMaterial(material);
compassItem = new GameItem(mesh);
compassItem.setScale(40.0f);
// 进行旋转变换,使它转换到屏幕坐标系
compassItem.setRotation(0f, 0f, 180f);
// 创建一个数组用于储存HUD组件
gameItems = new GameItem[]{statusTextItem, compassItem};
```
还要注意的是为了使罗盘向上指我们需要旋转180°因为模型通常倾向于使用OpenGL空间坐标系。如果我们要求使用屏幕坐标它会指向下方。`Hud`类还提供一个方法来更新指针的指向,这也必须要考虑。
```java
public void rotateCompass(float angle) {
this.compassItem.setRotation(0, 0, 180 + angle);
}
```
`DummyGame`类中每当摄像机移动时我们需要更新角度。我们需要使用Y角旋转。
```java
// 根据鼠标更新摄像机
if (mouseInput.isRightButtonPressed()) {
Vector2f rotVec = mouseInput.getDisplVec();
camera.moveRotation(rotVec.x * MOUSE_SENSITIVITY, rotVec.y * MOUSE_SENSITIVITY, 0);
// 更新HUD指针
hud.rotateCompass(camera.getRotation().y);
}
```
我们会看到这样的东西(记住它只是个例子,在实际的游戏中,你可能想使用一些纹理来更改指针的外观)。
![有指针的HUD](_static/12/hud_compass.png)
## 再谈文本渲染
在回顾其他主题之前让我们再谈谈之前介绍的文本渲染方法。该方案非常简单和方便地介绍了渲染HUD所涉及的概念但它有一些问题
* 它不支持非拉丁字符。
* 如果你想使用多种字体,则需要为每种字体创建单独的纹理文件。此外,改变文本大小的唯一方法是缩放,这会导致渲染文本的质量较差,或者需要创建额外的纹理文件。
* 最重要的是,大多数字体中的字符之间的大小并不同,而我们将字体纹理分割成同样大小的元素。我们使用了[Monospaced](https://en.wikipedia.org/wiki/Monospaced_font)风格(即所有字符具有相同的宽度)的“Consolas”字体但如果使用非Monospaced的字体就会看到字符之间恼人的空白。
我们需要更改方法,提供一种更灵活的渲染文本方式。如果你思考一下,整个想法是可行的,也就是通过单独渲染每个字符的矩形来渲染文本。这里的问题就是该如何生成纹理。我们需要通过系统中可用的字体动态地生成这些纹理。
这就需要`java.awt.Font`出马了,我们将通过指定字体系列和大小动态地绘制每一个字符来生成纹理。该纹理的使用方式与之前描述的相同,但它将完美地解决上述所有问题。我们将创建一个名为`FontTexture`的新类,该类将接受`Font`实例和字符集名称,并将动态地创建包含所有可用字符的纹理。下面是构造函数。
```java
public FontTexture(Font font, String charSetName) throws Exception {
this.font = font;
this.charSetName = charSetName;
charMap = new HashMap<>();
buildTexture();
}
```
首先要处理非拉丁字符问题,指定字符集和字体,我们将创建一个包含所有可渲染字符的`String`
```java
private String getAllAvailableChars(String charsetName) {
CharsetEncoder ce = Charset.forName(charsetName).newEncoder();
StringBuilder result = new StringBuilder();
for (char c = 0; c < Character.MAX_VALUE; c++) {
if (ce.canEncode(c)) {
result.append(c);
}
}
return result.toString();
}
```
让我们来看看实际创建纹理的`buildTexture`方法。
```java
private void buildTexture() throws Exception {
// 使用FontMetrics获取每个字符信息
BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2D = img.createGraphics();
g2D.setFont(font);
FontMetrics fontMetrics = g2D.getFontMetrics();
String allChars = getAllAvailableChars(charSetName);
this.width = 0;
this.height = 0;
for (char c : allChars.toCharArray()) {
// 获取每个字符的大小,并更新图像大小
CharInfo charInfo = new CharInfo(width, fontMetrics.charWidth(c));
charMap.put(c, charInfo);
width += charInfo.getWidth();
height = Math.max(height, fontMetrics.getHeight());
}
g2D.dispose();
```
我们首先通过创建创建临时图像来获得`FontMetrics`,然后遍历包含所有可用字符的`String`,并在`FontMetrics`的帮助下获取字体的宽度。我们把这些信息储存在一个`charMap`上,以字符作为`Map`的键。这样,我们就确定了纹理图像的大小(图像的高度等于所有字符的最大高度,而宽度等于所有字符的宽度总和)。
`ChatSet`是一个内部类,它储存关于字符的信息(它的宽度和它在纹理图像中的起点)。
```java
public static class CharInfo {
private final int startX;
private final int width;
public CharInfo(int startX, int width) {
this.startX = startX;
this.width = width;
}
public int getStartX() {
return startX;
}
public int getWidth() {
return width;
}
}
```
然后,我们将创建一个包含所有可用字符的图像,只需要在`BufferedImage`上绘制字符串即可。
```java
// 创建与字符集相关的图像
img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
g2D = img.createGraphics();
g2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2D.setFont(font);
fontMetrics = g2D.getFontMetrics();
g2D.setColor(Color.WHITE);
g2D.drawString(allChars, 0, fontMetrics.getAscent());
g2D.dispose();
```
我们正在生成一个包含所有字符的单行图像(可能不满足纹理大小应该为二的幂的前提,但是它仍适用于大多数现代显卡。在任何情况下,你都可以通过增加额外的空白来解决这个问题)。你甚至可以看到生成的图像,在代码之后,添加这样的一行代码:
```java
ImageIO.write(img, IMAGE_FORMAT, new java.io.File("Temp.png"));
```
图像将被写入一个临时文件。该文件将包含一长条可用的所有字符,在白色背景下启用反走样绘制。
![字体纹理](_static/12/texture_font.png)
最后只需要从那个图像创建一个`Texture`实例我们只需使用PNG格式转储图像字节(这就是`Texture`类所需要的)。
```java
// 将图像转储到字节缓冲区
InputStream is;
try (
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
ImageIO.write(img, IMAGE_FORMAT, out);
out.flush();
is = new ByteArrayInputStream(out.toByteArray());
}
texture = new Texture(is);
}
```
你可能注意到,我们已经稍微修改了`Texture`类,以便可以使用一个接收`InputStream`的构造函数。现在我们只需要修改`TextItem`类,就可以在构造函数中接收`FontTexture`实例。
```java
public TextItem(String text, FontTexture fontTexture) throws Exception {
super();
this.text = text;
this.fontTexture = fontTexture;
setMesh(buildMesh());
}
```
`buildMesh`方法只需要稍稍改变矩形坐标和纹理坐标的设置,这是其中一个顶点的示例。
```java
float startx = 0;
for(int i=0; i<numChars; i++) {
FontTexture.CharInfo charInfo = fontTexture.getCharInfo(characters[i]);
// 构造由两个三角形组成的字符片段
// 左上角顶点
positions.add(startx); // x
positions.add(0.0f); // y
positions.add(ZPOS); // z
textCoords.add( (float)charInfo.getStartX() / (float)fontTexture.getWidth());
textCoords.add(0.0f);
indices.add(i*VERTICES_PER_QUAD);
// 更多代码...
startx += charInfo.getWidth();
}
```
你可以在源代码中查看其他修改。下面的图片是一个大小为20的Arial字体的渲染结果
![改进后的文本](_static/12/text_rendered_improved.png)
你可以看到文本渲染的质量已经有了很大的提升,你可以用不同的字体和大小来渲染。这仍然有很大的改进空间(比如支持多行文本、特效等),但这留给各位读者作为练习。
你可能还注意到,我们仍然能够缩放文本(通过着色器中的模型观察矩阵)。文本可能不需要但对其他的HUD元素可能有用。
我们已经建立了所有的基础结构来为游戏创建一个HUD。现在只剩一个问题那就是创建所有的元素传递相关信息给用户并给他们一个专业的外观。
## OSX
如果你试图运行本章中的示例以及下一个渲染文本的示例则可能会发现应用程序和屏幕上没有显示任何内容。这是由于AWT和GLFW在OSX下相处得“很好”。但这和AWT有什么关系呢我们使用的是`Font`它属于AWT如果要实例化它AWT也需要初始化。在OSX中AWT试图在主线程运行但GLFW也需要这样。这就是造成这种混乱的原因。
为了能够使用`Font`GLFW必须在AWT之前初始化并且示例需要以Headless模式运行。你需要在任何东西被初始化之前设置这个属性
```java
System.setProperty("java.awt.headless", "true");
```
你也许会得到一个警告,但示例成功运行了。
一个更简洁的方法是使用[stb](https://github.com/nothings/stb/)库来渲染文本。

View File

@@ -0,0 +1,338 @@
# 天空盒与一些优化 (Sky Box and some optimizations)
## 天空盒
天空盒(Sky Box)允许我们设置背景,让玩家产生三维世界看起来更大的错觉。这个背景将摄像机所在的位置包裹起来,并覆盖整个空间。我们要做的是构造一个大立方体,它将显示在三维场景周围,也就是说,摄像机的位置将是立方体的中心。这个立方体的侧面将包裹着一个纹理,纹理上有一座小山,蓝天和云彩,这些图像将被显示为一幅连续的风景。
下图展示了天空盒的概念。
![天空盒](_static/13/skybox.png)
创建天空盒的过程可概括为以下几步:
* 创建一个大立方体。
* 给它加上纹理,让我们看到一个没有边缘的巨大景观的错觉。
* 渲染立方体,它的边缘在很远的地方,它的原点在摄像机的位置。
然后,我们从纹理开始。你会发现互联网上有很多预先生成的纹理,本章示例中使用的一个纹理从这里下载:[http://www.custommapmakers.org/skyboxes.php](http://www.custommapmakers.org/skyboxes.php)。我们使用的纹理是:[http://www.custommapmakers.org/skyboxes/zips/ely\_hills.zip](http://www.custommapmakers.org/skyboxes/zips/ely_hills.zip)作者是Colin Lowndes。
该网站的纹理都是由TGA文件组成每个文件都是立方体的一面。我们的纹理加载器希望文件格式为PNG所以需要将所有TGA图像组合成一个PNG图像。我们可以使用其他方法比如立方体映射(Cube Mapping),让其自动使用纹理。但为了使本章简洁易懂,你必须手动把它们排列成一个图片。最终图像是这样的:
![天空盒纹理](_static/13/skybox_texture.png)
之后我们需要创建一个OBJ文件其中有一个立方体立方体的每一面的纹理坐标都正确地设置了。下面的图片展示了材质与面关联的区域(你可以在本书源代码中找到本章使用的OBJ文件)。
![天空盒立方体的面](_static/13/skybox_cube_faces.png)
一旦资源准备完毕,我们就可以开始编写代码了。首先创建一个名为`SkyBox`的新类它的构造函数接收OBJ模型路径和天空盒纹理文件路径。就像之前一章的HUD一样这个类也继承`GameItem`类。为什么它要继承`GameItem`类?首先,为了方便我们重用大部分处理网格和纹理的代码。第二,因为天空盒不会移动,我们只想使用旋转和缩放。这样想想`SkyBox`确实是一个`GameItem``SkyBox`类的实现如下。
```java
package org.lwjglb.engine;
import org.lwjglb.engine.graph.Material;
import org.lwjglb.engine.graph.Mesh;
import org.lwjglb.engine.graph.OBJLoader;
import org.lwjglb.engine.graph.Texture;
public class SkyBox extends GameItem {
public SkyBox(String objModel, String textureFile) throws Exception {
super();
Mesh skyBoxMesh = OBJLoader.loadMesh(objModel);
Texture skyBoxtexture = new Texture(textureFile);
skyBoxMesh.setMaterial(new Material(skyBoxtexture, 0.0f));
setMesh(skyBoxMesh);
setPosition(0, 0, 0);
}
}
```
如果你查看了本章的源代码,你将看到我们重构了一些代码。我们创建了一个名为`Scene`的类,它与三维世界相关的内容有关。`Scene`类的实现和属性如下,其中有`SkyBox`类的实例。
```java
package org.lwjglb.engine;
public class Scene {
private GameItem[] gameItems;
private SkyBox skyBox;
private SceneLight sceneLight;
public GameItem[] getGameItems() {
return gameItems;
}
// 更多代码...
```
接下来是为天空盒创建一组顶点和片元着色器。但为什么不使用我们已有的场景着色器呢?实际上,我们需要的着色器是原有着色器的简化版,不让光照影响天空盒(更准确的说,我们不需要点光源,聚光源和平行光源)。下面是天空盒的顶点着色器。
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
out vec2 outTexCoord;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main()
{
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
outTexCoord = texCoord;
}
```
可以看到我们仍使用模型观察矩阵。此前已说明过,我们将缩放天空盒,所以需要变换矩阵。你可能会看到一些其他实现,在初始化时就放大了立方体模型的大小,并且不需要将模型和观察矩阵相乘。但我们选择了之前的方法,因为它更灵活,它允许我们在运行时改变天空盒的大小,但如果你愿意,可以轻松切换到另一种方法。
片元着色器也非常简单。
```glsl
#version 330
in vec2 outTexCoord;
in vec3 mvPos;
out vec4 fragColor;
uniform sampler2D texture_sampler;
uniform vec3 ambientLight;
void main()
{
fragColor = vec4(ambientLight, 1) * texture(texture_sampler, outTexCoord);
}
```
如你所见我们为着色器添加了一个环境光这个Uniform的目的是修改天空盒的颜色来模拟白天和黑夜(如果不这样,在世界的其他地方都是黑暗的时候,天空盒看起来就像是在中午)。
`Renderer`类中我们刚刚添加了新的方法来使用这些着色器并设置Uniform(这没有什么新的概念)。
```java
private void setupSkyBoxShader() throws Exception {
skyBoxShaderProgram = new ShaderProgram();
skyBoxShaderProgram.createVertexShader(Utils.loadResource("/shaders/sb_vertex.vs"));
skyBoxShaderProgram.createFragmentShader(Utils.loadResource("/shaders/sb_fragment.fs"));
skyBoxShaderProgram.link();
skyBoxShaderProgram.createUniform("projectionMatrix");
skyBoxShaderProgram.createUniform("modelViewMatrix");
skyBoxShaderProgram.createUniform("texture_sampler");
skyBoxShaderProgram.createUniform("ambientLight");
}
```
当然,我们需要为渲染天空盒在全局渲染中创建一个新的渲染方法。
```java
private void renderSkyBox(Window window, Camera camera, Scene scene) {
skyBoxShaderProgram.bind();
skyBoxShaderProgram.setUniform("texture_sampler", 0);
// 更新投影矩阵
Matrix4f projectionMatrix = transformation.getProjectionMatrix(FOV, window.getWidth(), window.getHeight(), Z_NEAR, Z_FAR);
skyBoxShaderProgram.setUniform("projectionMatrix", projectionMatrix);
SkyBox skyBox = scene.getSkyBox();
Matrix4f viewMatrix = transformation.getViewMatrix(camera);
viewMatrix.m30(0);
viewMatrix.m31(0);
viewMatrix.m32(0);
Matrix4f modelViewMatrix = transformation.getModelViewMatrix(skyBox, viewMatrix);
skyBoxShaderProgram.setUniform("modelViewMatrix", modelViewMatrix);
skyBoxShaderProgram.setUniform("ambientLight", scene.getSceneLight().getAmbientLight());
scene.getSkyBox().getMesh().render();
skyBoxShaderProgram.unbind();
}
```
上述方法与其他渲染方法非常相似但有一个需要详解的不同。如你所见我们像平常一样传递投影矩阵和模型观察矩阵。但当我们得到观察矩阵是我们将一些数值设置为0。为什么要这么做这背后的原因是我们不希望天空盒被移动。
请记住,当我们在移动摄像机时,实际上是在移动整个世界。因此,如果我们直接将观察矩阵与模型矩阵相乘,当摄像机移动时,天空盒也将移动。但是我们不想这样,我们想把它固定在坐标原点(0, 0, 0)。这是通过设置观察矩阵的移动量部分实现的(`m30`, `m31``m32`)。
你可能会认为可以避免使用观察矩阵,因为天空盒必须固定在原点。但在这种情况下,你会看到天空盒不会随着相机旋转,这不是我们想要的。因此我们需要旋转它但不要移动它。
这就是全部,你可以查看本章源代码,在`DummyGame`类中我们创建了更多的方块实例来模拟一个地面和一个天空盒。你也可以改变环境光来模拟光照和昼夜交替。你会看到下面的情况。
![天空盒结果](_static/13/skybox_result.png)
天空盒是一个很小的立方体,所以你很容易地看到移动的效果(在实际的游戏中,它应该大得多)。你也可以看到,组成地面的方块比天空盒子大,所以当你移动时,你会看到在山中出现的方块。这是比较明显的,因为我们设置的天空盒相对大小较小。但无论如何,我们需要通过隐藏和模糊远处的物体(例如使用雾效果)来减轻这种影响。
不创建更大的天空盒的另一个原因是我们需要进行几个优化来提高效率(稍后对此进行解释)。
可以注释渲染方法中防止天空盒移动的代码,然后你就可以在天空盒外看到类似景象。
![天空盒移动](_static/13/skybox_displaced.png)
虽然这不是一个天空盒该做的,但它可以帮助你理解天空盒的概念。请记住,这是一个简单的例子,你可以通过增加其他效果来改善它,比如太阳在天空中移动或云层移动。此外,为了创建更大的世界,你需要将世界分割成区域,只加载那些与玩家所处的区域相连的区域。
另外要提的是我们应该在什么时候渲染天空盒在场景渲染之前还是之后场景渲染后渲染天空盒更好因为大部分片元将由于深度测试而被丢弃。也就是说那些被场景元素遮挡的不可见的天空盒片元将被丢弃。当OpenGL尝试渲染它们并启用深度测试时它将丢弃那些渲染在先前已渲染的片元之后的片元这是因为这些片元的深度值较低。所以答案很明显对吧在渲染场景后只需渲染天空盒即可。但这个方法的问题是如何处理透明纹理。如果我们在场景中有透明纹理的物体它们将使用“背景”色绘制但现在的颜色是黑色。如果我们先渲染天空盒透明效果将正确地渲染。那么我们应该在场景之前渲染天空盒吗正确但又不正确。如果你在场景渲染前渲染天空盒可以解决透明纹理问题但是会影响性能。不过你现在可能没有面临天空盒的透明纹理问题。但假设你有一个透明的物品它与远处的物体重叠如果首先渲染透明对象那么也会出现其他问题。因此另一种方法是在所有其他元素被渲染后透明地绘制透明的物体。这是一些商业游戏使用的方法。不过现在我们在渲染场景之后渲染天空盒以期获得更好的性能。
## 一些优化
从之前的例子来看,天空盒相对较小使得它看起来有点奇怪(你可以看到物体从山后神奇地出现)。所以让我们增加天空盒的大小和世界的大小将天空盒的大小放大50倍这样世界由40,000个游戏元素实例(方块)组成
如果你改变缩放并重新运行这个例子,你会发现开始出现性能问题,并且在三维世界中移动不流畅。现在是时候关注一下性能了(你可能听过一句老话“过早的优化是万恶之源”但是从第13章开始我希望没有人会说这是过早的)。
让我们开始介绍一个概念,它能减少正在渲染的数据数量,它叫做面剔除(Face Culling)。在示例中,我们渲染了成千上万个立方体,立方体是由六个面组成的。我们正在渲染每个立方体的六个面,即使它们有些是看不到的。通过放大一个立方体,你会看到它的内部就像这样。
![立方体内部](_static/13/cube_interior.png)
不能被看到的面应该立即被丢弃,这就是面剔除的方式。事实上,对于一个立方体,你最多同时看到三个面,所以我们只能通过使用面剔除来舍弃一半的面(40,000×3×2个三角形)(如果你的游戏不要求你进入模型的内侧,这是高效的,稍后你就可以看到)。
面剔除检查是为了让每个三角形都面向我们丢弃那些不面向我们的三角形。但是如何知道三角形是否面向我们呢线索就是OpenGL是将顶点以卷绕顺序构成三角形的。
记得从第一张开始我们可以定义一个三角形的顶点以顺时针或逆时针顺序排列。在OpenGL中默认情况下以逆时针顺序排列顶点的三角形面向观察者而以顺时针顺序排列顶点的三角形面向相反的方向。关键是在考虑观察点的情况下检查顶点排列顺序。因此按照逆时针顺序定义的三角形可以渲染。
让我们来实现它,在`Window`类的`init`方法中添加下列代码:
```java
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
```
第一行代码将启用面剔除,第二行代码设置背向面为需要剔除的面。如果向上看,你会看到这样的东西。
![使用面剔除的天空盒](_static/13/skybox_face_culling.png)
发生了什么?如果你查看顶面的顶点顺序,将看到它是按逆时针定义的。但请记住,它包裹的是摄像机。事实上,如果你让位移影响天空盒,这样就能从上面看到它,你会发现当在上面时,它会再次出现。
![从外部看使用面剔除的天空盒](_static/13/skybox_face_culling_exterior.png)
让我们描述一下发生了什么。下图展示了天空盒立方体的顶面三角形中的一个三角形,它由逆时针顺序排列的三个顶点定义。
![以逆时针顺序定义的顶点](_static/13/cube_counter_clockwise.png)
但要记住,我们是在天空盒里,如果从内部观察立方体,会看到顶点是按顺时针顺序排列的。
![从内部看立方体](_static/13/cube_clockwise.png)
这是因为天空盒被定义为从外部观察。因此,我们需要改变一些面的朝向,以便在启用面剔除时能被正确地渲染。
但仍有更多的优化空间。让我们回顾一下渲染过程。在`Renderer``render`方法中,我们做的是遍历`GameItem`数组和渲染相关的`Mesh`。对每个`GameItem`,我们做了以下事情:
1. 设置模型观察矩阵(每个`GameItem`的唯一值)。
2. 获取`GameItem`储存的`Mesh`并绑定纹理绑定VAO并启用其属性。
3. 执行调用,渲染三角形。
4. 禁用纹理和VAO。
但在现在的游戏中40,000个`GameItem`都使用相同的`Mesh`而我们重复第二项到第四项的操作。这不是很高效的请记住对OpenGL函数的每次调用都是有性能开销的本地调用。除此之外我们还应该尽量限制OpenGL中的状态变化(绑定和解绑纹理、VAO都是状态变化)。
我们需要改变开发的方式,围绕网格组织代码结构,因为经常有许多游戏项目使用相同的网格。现在我们有一个游戏项目,每个都指向同一个网格。就像下图那样。
![游戏项数组](_static/13/game_item_list.png)
我们将创建一个网格映射图取代之前的方法,其中包括储存共享该网格的所有游戏项目。
![网格图](_static/13/mesh_map.png)
对于每一个`Mesh`,渲染步骤将是:
1. 设置模型观察矩阵(每个`GameItem`唯一的)。
2. 获取与`GameItem`相关联的`Mesh`并绑定`Mesh`纹理绑定VAO并启用其属性。
3. 对于每个相关的`GameItem`
a. 设置模型观察矩阵(每个`GameItem`唯一的)。
b. 调用绘制三角形。
4. 解绑纹理和VAO。
`Scene`类中,我们将储存下面的`Map`
```java
private Map<Mesh, List<GameItem>> meshMap;
```
我们仍有`setGameItems`方法,但我们不只是储存数组,而是构造网格映射图。
```java
public void setGameItems(GameItem[] gameItems) {
int numGameItems = gameItems != null ? gameItems.length : 0;
for (int i=0; i<numGameItems; i++) {
GameItem gameItem = gameItems[i];
Mesh mesh = gameItem.getMesh();
List<GameItem> list = meshMap.get(mesh);
if ( list == null ) {
list = new ArrayList<>();
meshMap.put(mesh, list);
}
list.add(gameItem);
}
}
```
`Mesh`类现在有一个方法来渲染与其相关的`GameItem`列表,然后将绑定和解绑代码分成了不同的方法。
```java
private void initRender() {
Texture texture = material.getTexture();
if (texture != null) {
// 激活第一个纹理库
glActiveTexture(GL_TEXTURE0);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture.getId());
}
// 绘制网格
glBindVertexArray(getVaoId());
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
}
private void endRender() {
// 恢复状态
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
}
public void render() {
initRender();
glDrawElements(GL_TRIANGLES, getVertexCount(), GL_UNSIGNED_INT, 0);
endRender();
}
public void renderList(List<GameItem> gameItems, Consumer<GameItem> consumer) {
initRender();
for (GameItem gameItem : gameItems) {
// 设置游戏项目所需的渲染数据
consumer.accept(gameItem);
// 渲染游戏项目
glDrawElements(GL_TRIANGLES, getVertexCount(), GL_UNSIGNED_INT, 0);
}
endRender();
}
```
正如你所看到的,我们仍然保留了旧方法,它返回一个`Mesh`,这是考虑到如果只有一个`GameItem`的情况(这可能在其他情况下使用,这就是为什么不移除它)。新的方法渲染一个`List<GameItem>`,并接受一个`Counsumer`类型的参数(一个函数使用了Java8引入的函数式编程),它将用于在绘制三角形之前为每个`GameItem`设置特定的内容。我们将使用它来设置模型观察矩阵,因为不希望`Mesh`类与Uniform的名称和设置这些参数时所涉及的参数相耦合。
`Renderer`类中的`renderScene`方法你可以看到我们只需遍历网格映射图并通过Lambda设置模型观察矩阵Uniform。
```java
for (Mesh mesh : mapMeshes.keySet()) {
sceneShaderProgram.setUniform("material", mesh.getMaterial());
mesh.renderList(mapMeshes.get(mesh), (GameItem gameItem) -> {
Matrix4f modelViewMatrix = transformation.buildModelViewMatrix(gameItem, viewMatrix);
sceneShaderProgram.setUniform("modelViewMatrix", modelViewMatrix);
}
);
}
```
我们可以做的另一组优化是,我们在渲染周期中创建了大量对象。特别是,我们创建了太多的`Matrix4f`实例,它们为每个`GameItem`实例都保存了一个模型视图矩阵。我们应在`Transformation`类中创建特定的矩阵,并重用相同的实例。如果你查看源码,会看到我们已经更改了方法的名称,`getXX`方法只返回储存的矩阵实例,并且改变矩阵值得任何方法都被称为`buildXX`,以阐明其作用。
我们也避免了每次为矩阵设置Uniform时构造新的`FloatBuffer`实例,并移除了其他一些无用的实例化操作。有了这些,你就可以看到更流畅更灵活的渲染了。
你可以在源代码中查看所有细节。

315
docs/14-height-maps.md Normal file
View File

@@ -0,0 +1,315 @@
# 高度图Height Maps
本章中我们将学习如何使用高度图创建复杂的地形。在开始前,你会注意到我们做了一些重构。我们创建了一些新的包和移动了一些类以更好地组织它们。你可以在源代码中了解这些改变。
所以什么是高度图?高度图是用于生成三维地形的图像,它使用像素颜色来获取表面高度。高度图图像通常是灰度图,它可以由[Terragen](http://planetside.co.uk/)等软件生成。一张高度图图像看起来就像这样。
![高度图](_static/14/heightmap.png)
上图就像你俯视一片陆地一样。利用上图,我们将构建由顶点组成的三角形所组成的网格。每个顶点的高度将根据图像的每个像素的颜色来计算。黑色是最低,白色是最高。
我们将为图像的每个像素创建一组顶点,这些顶点将组成三角形,这些三角形将组成下图所示的网格。
![高度图网格](_static/14/heightmap_grid.png)
网格将组成一个巨大的四边形它将会在X和Z轴上渲染并根据像素颜色来改变它的Y轴高度。
![高度图坐标系](_static/14/heightmap_coordinates.png)
由高度图创建三维地形的过程可概括为以下步骤:
* 加载储存高度图的图像(我们将使用一个`BufferedImage`实例以获取每个像素)。
* 为每个图像像素创建一个顶点,其高度基于像素颜色。
* 将正确的纹理坐标分配给顶点。
* 设置索引以绘制与顶点相关的三角形。
我们将创建一个名为`HeightMapMesh`的类,该类将基于高度图按以上步骤创建一个`Mesh`。让我们先看看该类定义的常量:
```java
private static final int MAX_COLOUR = 255 * 255 * 255;
```
如上所述我们将基于高度图图像的每个像素的颜色来计算每个顶点的高度。图像通常是灰度图对于PNG图像来说这意味着每个像素的每个RGB值可以在0到255之间变化因此我们有256个值来表示不同的高度。这可能足够了但如果精度不够我们可以使用三个RGB值以有更多的值在此情况下高度计算范围为0到255^3。我们将使用第二种方法因此我们不局限于灰度图。
接下来的常量是:
```java
private static final float STARTX = -0.5f;
private static final float STARTZ = -0.5f;
```
网格将由一组顶点一个顶点对应一个像素构成其X和Z坐标的范围如下
* X轴的范围为[-0.5, 0.5],即[`STARTX`, `-STARTX`]。
* Z轴的范围为[-0.5, 0.5],即[`STARTZ`, `-STARTZ`]。
不用太过担心这些值稍后得到的网格可以被缩放以适应世界的大小。关于Y轴我们将设置`minY``maxY`两个参数用于设置Y坐标的最低和最高值。这些参数并不是常数因为我们可能希望在运行时更改它们而不使用缩放。最后地形将包含在范围为`[STARTX, -STARTX]``[minY, maxY]``[STARTZ, -STARTZ]`的立方体内。
网格将会在`HeightMapMesh`类的构造函数中创建,该类的定义如下。
```java
public HeightMapMesh(float minY, float maxY, String heightMapFile, String textureFile, int textInc) throws Exception {
```
它接收Y轴的最小值和最大值被用作高度图的图像文件名和要使用的纹理文件名。它还接受一个名为`textInc`的整数,这稍后再说明。
我们在构造函数中做的第一件事就是将高度图图像加载到`BufferedImage`实例中。
```java
this.minY = minY;
this.maxY = maxY;
PNGDecoder decoder = new PNGDecoder(getClass().getResourceAsStream(heightMapFile));
int height = decoder.getHeight();
int width = decoder.getWidth();
ByteBuffer buf = ByteBuffer.allocateDirect(
4 * decoder.getWidth() * decoder.getHeight());
decoder.decode(buf, decoder.getWidth() * 4, PNGDecoder.Format.RGBA);
buf.flip();
```
然后,我们将纹理文件载入到一个`ByteBuffer`中,并设置构造`Mesh`所需的变量。`incx``incz`变量将储存每个顶点的X或Z坐标之间的最小间隔因此`Mesh`包含在上述区域中。
```java
Texture texture = new Texture(textureFile);
float incx = getWidth() / (width - 1);
float incz = Math.abs(STARTZ * 2) / (height - 1);
List<Float> positions = new ArrayList();
List<Float> textCoords = new ArrayList();
List<Integer> indices = new ArrayList();
```
之后,我们将遍历图像,为每个像素创建一个顶点,设置其纹理坐标与索引,以正确地定义组成`Mesh`的三角形。
```java
for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
// 为当前位置创建顶点
positions.add(STARTX + col * incx); // x
positions.add(getHeight(col, row, width, buf)); // y
positions.add(STARTZ + row * incz); // z
// 设置纹理坐标
textCoords.add((float) textInc * (float) col / (float) width);
textCoords.add((float) textInc * (float) row / (float) height);
// 创建索引
if (col < width - 1 && row < height - 1) {
int leftTop = row * width + col;
int leftBottom = (row + 1) * width + col;
int rightBottom = (row + 1) * width + col + 1;
int rightTop = row * width + col + 1;
indices.add(rightTop);
indices.add(leftBottom);
indices.add(leftTop);
indices.add(rightBottom);
indices.add(leftBottom);
indices.add(rightTop);
}
}
}
```
创建顶点坐标的过程是不需要解释的。现在先别管为什么我们用一个数字乘以纹理坐标以及如何计算高度。你可以看到,对于每个顶点,我们定义两个三角形的索引(除非现在是最后一行或最后一列)。让我们用一个**3×3**的图像来想象它们是如何构造的。一个**3×3**的图像包含9个顶点每因此有**2×4**个三角形组成4个正方形。下图展示了网格每个顶点被命名为`Vrc`(rc列)。
![高度图顶点](_static/14/heightmap_vertices.png)
当处理第一个顶点(V00)时,我们在红色阴影处定义了两个三角形的索引。
![高度图索引I](_static/14/heightmap_indices_i.png)
当处理第二个顶点(V01)时,我们在红色阴影处又定义了两个三角形的索引。但当处理第三个顶点(V02)时,我们不需要定义更多的索引,该行的所有三角形都已被定义。
![高度图索引II](_static/14/heightmap_indices_ii.png)
你可以很容易地想到其他顶点的处理过程是如何进行的。现在,一旦创建了所有的顶点位置、纹理坐标和索引,我们就只需要用所有这些数据创建`Mesh`和相关的`Material`
```java
float[] posArr = Utils.listToArray(positions);
int[] indicesArr = indices.stream().mapToInt(i -> i).toArray();
float[] textCoordsArr = Utils.listToArray(textCoords);
float[] normalsArr = calcNormals(posArr, width, height);
this.mesh = new Mesh(posArr, textCoordsArr, normalsArr, indicesArr);
Material material = new Material(texture, 0.0f);
mesh.setMaterial(material);
```
你可以看到,我们根据顶点位置计算法线。在看如何计算法线之前,来看看如何获取高度吧。我们已经创建了一个名为`getHeight`的方法,它负责计算顶点的高度。
```java
private float getHeight(int x, int z, int width, ByteBuffer buffer) {
byte r = buffer.get(x * 4 + 0 + z * 4 * width);
byte g = buffer.get(x * 4 + 1 + z * 4 * width);
byte b = buffer.get(x * 4 + 2 + z * 4 * width);
byte a = buffer.get(x * 4 + 3 + z * 4 * width);
int argb = ((0xFF & a) << 24) | ((0xFF & r) << 16)
| ((0xFF & g) << 8) | (0xFF & b);
return this.minY + Math.abs(this.maxY - this.minY) * ((float) argb / (float) MAX_COLOUR);
}
```
该方法接受像素的X和Y坐标图像的宽度以及与之相关的`ByteBuffer`返回RGB颜色(R、G、B分量之和)并计算包含在`minY``maxY`之间的值(`minY`为黑色,`maxY`为白色)。
你可以使用`BufferedImage`来编写一个更简单的方法它有更方便的方法来获得RGB值但这将使用AWT。记住AWT不能很好的兼容OSX所以尽量避免使用它的类。
现在来看看如何计算纹理坐标。第一个方法是将纹理覆盖整个网格,左上角的顶点纹理坐标为(0, 0),右下角的顶点纹理坐标为(1, 1)。这种方法的问题是,纹理必须是巨大的,以便获得良好的渲染效果,否则纹理将会被过度拉伸。
但我们仍然可以使用非常小的纹理,通过使用高效的技术来获得很好的效果。如果我们设置超出[1, 1]范围的纹理坐标,我们将回到原点并重新开始计算。下图表示在几个正方形中平铺相同的纹理,并超出了[1, 1]范围。
![纹理坐标I](_static/14/texture_coordinates_i.png)
这是我们在设置纹理坐标时所要做的。我们将一个参数乘以纹理坐标(计算好像整个网格被纹理包裹的情况),即`textInc`参数,以增加在相邻顶点之间使用的纹理像素数。
![纹理坐标II](_static/14/texture_coordinates_ii.png)
目前唯一没有解决的是法线计算。记住我们需要法线,光照才能正确地应用于地形。没有法线,无论光照如何,地形将以相同的颜色渲染。我们在这里使用的方法不一定是最高效的,但它将帮助你理解如何自动计算法线。如果你搜索其他解决方案,可能会发现更有效的方法,只使用相邻点的高度而不需要做交叉相乘操作。尽管如此,这仅需要在启动时完成,这里的方法不会对性能造成太大的损害。
让我们用图解的方式解释如何计算一个法线值。假设我们有一个名为**P0**的顶点。我们首先计算其周围每个顶点(**P1**, **P2**, **P3**, **P4**)和与连接这些点的面相切的向量。这些向量(**V1**, **V2**, **V3**, **V4**)是通过将每个相邻点与**P0**相减(例如**V1 = P1 - P0**)得到的。
![法线计算I](_static/14/normals_calc_i.png)
然后,我们计算连接每一个相邻点的平面的法线。这是与之前计算得到的向量交叉相乘计算的。例如,向量**V1**与**V2**所在的平面(蓝色阴影部分)的法线是由**V1**和**V2**交叉相乘得到的,即**V12 = V1 × V2**。
![法线计算II](_static/14/normals_calc_ii.png)
如果我们计算完毕其他平面的法线(**V23 = V2 × V3****V34 = V3 × V4****V41 = V4 × V1**),则法线**P0**就是周围所有平面法线(归一化后)之和:**N0 = V12 + V23 + V34 + V41**。
![法线计算III](_static/14/normals_calc_iii.png)
法线计算的方法实现如下所示。
```java
private float[] calcNormals(float[] posArr, int width, int height) {
Vector3f v0 = new Vector3f();
Vector3f v1 = new Vector3f();
Vector3f v2 = new Vector3f();
Vector3f v3 = new Vector3f();
Vector3f v4 = new Vector3f();
Vector3f v12 = new Vector3f();
Vector3f v23 = new Vector3f();
Vector3f v34 = new Vector3f();
Vector3f v41 = new Vector3f();
List<Float> normals = new ArrayList<>();
Vector3f normal = new Vector3f();
for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
if (row > 0 && row < height -1 && col > 0 && col < width -1) {
int i0 = row*width*3 + col*3;
v0.x = posArr[i0];
v0.y = posArr[i0 + 1];
v0.z = posArr[i0 + 2];
int i1 = row*width*3 + (col-1)*3;
v1.x = posArr[i1];
v1.y = posArr[i1 + 1];
v1.z = posArr[i1 + 2];
v1 = v1.sub(v0);
int i2 = (row+1)*width*3 + col*3;
v2.x = posArr[i2];
v2.y = posArr[i2 + 1];
v2.z = posArr[i2 + 2];
v2 = v2.sub(v0);
int i3 = (row)*width*3 + (col+1)*3;
v3.x = posArr[i3];
v3.y = posArr[i3 + 1];
v3.z = posArr[i3 + 2];
v3 = v3.sub(v0);
int i4 = (row-1)*width*3 + col*3;
v4.x = posArr[i4];
v4.y = posArr[i4 + 1];
v4.z = posArr[i4 + 2];
v4 = v4.sub(v0);
v1.cross(v2, v12);
v12.normalize();
v2.cross(v3, v23);
v23.normalize();
v3.cross(v4, v34);
v34.normalize();
v4.cross(v1, v41);
v41.normalize();
normal = v12.add(v23).add(v34).add(v41);
normal.normalize();
} else {
normal.x = 0;
normal.y = 1;
normal.z = 0;
}
normal.normalize();
normals.add(normal.x);
normals.add(normal.y);
normals.add(normal.z);
}
}
return Utils.listToArray(normals);
}
```
最后,为了创建更大的地形,我们有两个选择:
* 创建更大的高度图
* 重用高度图并将其平铺在三维空间中。高度图将像一个地形块,在世界上像瓷砖一样平移。为了做到这一点,高度图边缘的像素必须是相同的(左侧边缘必须与右侧相同,上侧边缘必须与下侧相同),以避免块之间的间隙。
我们将使用第二种方法(并选择适当的高度图)。为了做到它,我们将创建一个名为`Terrain`的类,该类将创建一个正方形的高度图块,定义如下。
```java
package org.lwjglb.engine.items;
import org.lwjglb.engine.graph.HeightMapMesh;
public class Terrain {
private final GameItem[] gameItems;
public Terrain(int blocksPerRow, float scale, float minY, float maxY, String heightMap, String textureFile, int textInc) throws Exception {
gameItems = new GameItem[blocksPerRow * blocksPerRow];
HeightMapMesh heightMapMesh = new HeightMapMesh(minY, maxY, heightMap, textureFile, textInc);
for (int row = 0; row < blocksPerRow; row++) {
for (int col = 0; col < blocksPerRow; col++) {
float xDisplacement = (col - ((float) blocksPerRow - 1) / (float) 2) * scale * HeightMapMesh.getXLength();
float zDisplacement = (row - ((float) blocksPerRow - 1) / (float) 2) * scale * HeightMapMesh.getZLength();
GameItem terrainBlock = new GameItem(heightMapMesh.getMesh());
terrainBlock.setScale(scale);
terrainBlock.setPosition(xDisplacement, 0, zDisplacement);
gameItems[row * blocksPerRow + col] = terrainBlock;
}
}
}
public GameItem[] getGameItems() {
return gameItems;
}
}
```
让我们详解整个过程,我们拥有由以下坐标定义的块(X和Z使用之前定义的常量)。
![地形构建I](_static/14/terrain_construction_1.png)
假设我们创建了一个由3×3块网格构成的地形。我们假设我们不会缩放地形块(也就是说,变量`blocksPerRow`是**3**而变量`scale`将会是**1**)。我们希望网格的中央在坐标系的(0, 0)。
我们需要移动块,这样顶点就变成如下坐标。
![地形构建II](_static/14/terrain_construction_2.png)
移动是通过调用`setPosition`方法实现的但记住我们所设置的是一个位移而不是一个位置。如果你看到上图你会发现中央块不需要任何移动它已经定位在适当的坐标上。绘制绿色顶点需要在X轴上位移**-1**而绘制蓝色顶点需要在X轴上位移**+1**。计算X位移的公式要考虑到缩放和块的宽度公式如下
$$xDisplacement=(col - (blocksPerRow -1 ) / 2) \times scale \times width$$
Z位移的公式为
$$zDisplacement=(row - (blocksPerRow -1 ) / 2) \times scale \times height$$
如果在`DummyGame`类中创建一个`Terrain`实例,我们可以得到如图所示的效果。
![地形结果](_static/14/terrain_result.png)
你可以在地形周围移动相机,看看它是如何渲染的。由于还没有实现碰撞检测,你可以穿过它并从上面看它。由于我们已经启用了面剔除,当从下面观察时,地形的某些部分不会渲染。

View File

@@ -0,0 +1,271 @@
# 地形碰撞 (Terrain Collisions)
此前我们创建了一个地形,接下来就是检测碰撞以避免穿过它。回忆一下之前的内容,一个地形是由地形块组成的,每个地形块都是由高度图生成的,高度图用于设置构成地形的三角形的顶点高度。
为了检测碰撞,我们必须将当前所在位置的**Y**值与当前地形点的**Y**值进行比较。如果有碰撞,我们需要回到地形上方。很简单的想法,是吗?确实是这样,但在比较之前,我们需要进行几次计算。
我们首先要定义的是我们对“当前位置”这个词的理解。由于我们还没有一个球员的概念,答案很简单,当前的位置将是相机的位置。因此,我们已经有了比较的组成部分之一,因此,接下来要计算的是当前位置的地形高度。
首先要定义的是“当前位置”这个词的概念。由于我们还没有一个“玩家”的概念,因此当前位置将是摄像机的位置。这样我们就有了比较的一方,因此接下来要计算当前位置的地形高度。
如上所是,地形由地形块组成,如下图所示。
![地形网格](_static/15/terrain_grid.png)
每个地形块都是由相同的高度图网格构成,但被精确地缩放和位移,以形成看起来像是连续的景观的地形网格。
所以首先要做的是确定当前位置(摄像机位置)在哪个地形块。为了得到它,我们将基于位移和缩放来计算每个地形块的包围盒(**BoundingBox**)。因为地形在运行时不会移动或缩放,所以我们可以在`Terrain`类的构造方法中计算。这样就可以在任何时候访问它们,而不需要在每个游戏循环周期中重复这些计算。
我们将创建一个新的方法来计算一个地形块的包围盒,名为`getBoundingBox`
```java
private Box2D getBoundingBox(GameItem terrainBlock) {
float scale = terrainBlock.getScale();
Vector3f position = terrainBlock.getPosition();
float topLeftX = HeightMapMesh.STARTX * scale + position.x;
float topLeftZ = HeightMapMesh.STARTZ * scale + position.z;
float width = Math.abs(HeightMapMesh.STARTX * 2) * scale;
float height = Math.abs(HeightMapMesh.STARTZ * 2) * scale;
Box2D boundingBox = new Box2D(topLeftX, topLeftZ, width, height);
return boundingBox;
}
```
`Box2D``java.awt.Rectangle2D.Float`类的简化版本为了避免使用AWT而创建。
限制我们需要计算地形块的世界坐标。在上一章中,你看到所有的地形网格都是在一个正方形中创建的,它的原点设置为`[STARTX, STARTZ]`。因此,我们需要把这些坐标转换为世界坐标,这要考虑下图所示的位移与缩放。
![模型坐标系到世界坐标系](_static/15/model_to_world_coordinates.png)
如上所述,这可以在`Terrain`类构造方法中计算,因为它不会在运行时发生变化,所以我们要添加一个新的属性来保存包围盒:
```java
private final Box2D[][] boundingBoxes;
```
`Terrain`类的构造方法中,当我们创建地形块时,只需调用计算包围盒的方法。
```java
public Terrain(int terrainSize, float scale, float minY, float maxY, String heightMapFile, String textureFile, int textInc) throws Exception {
this.terrainSize = terrainSize;
gameItems = new GameItem[terrainSize * terrainSize];
PNGDecoder decoder = new PNGDecoder(getClass().getResourceAsStream(heightMapFile));
int height = decoder.getHeight();
int width = decoder.getWidth();
ByteBuffer buf = ByteBuffer.allocateDirect(
4 * decoder.getWidth() * decoder.getHeight());
decoder.decode(buf, decoder.getWidth() * 4, PNGDecoder.Format.RGBA);
buf.flip();
// 每行与每列的顶点数
verticesPerCol = heightMapImage.getWidth();
verticesPerRow = heightMapImage.getHeight();
heightMapMesh = new HeightMapMesh(minY, maxY, buf, width, textureFile, textInc);
boundingBoxes = new Box2D[terrainSize][terrainSize];
for (int row = 0; row < terrainSize; row++) {
for (int col = 0; col < terrainSize; col++) {
float xDisplacement = (col - ((float) terrainSize - 1) / (float) 2) * scale * HeightMapMesh.getXLength();
float zDisplacement = (row - ((float) terrainSize - 1) / (float) 2) * scale * HeightMapMesh.getZLength();
GameItem terrainBlock = new GameItem(heightMapMesh.getMesh());
terrainBlock.setScale(scale);
terrainBlock.setPosition(xDisplacement, 0, zDisplacement);
gameItems[row * terrainSize + col] = terrainBlock;
boundingBoxes[row][col] = getBoundingBox(terrainBlock);
}
}
}
```
因此,有了所有预先计算的包围盒,我们将创建一个新的方法,这个方法将以当前位置为参数,返回对应地形高度。该方法名为`getHeight`,其定义如下。
```java
public float getHeight(Vector3f position) {
float result = Float.MIN_VALUE;
// 对于每个地形块,我们获取包围盒,将其转换到观察坐标系
// 检查坐标是否包含在包围盒中
Box2D boundingBox = null;
boolean found = false;
GameItem terrainBlock = null;
for (int row = 0; row < terrainSize && !found; row++) {
for (int col = 0; col < terrainSize && !found; col++) {
terrainBlock = gameItems[row * terrainSize + col];
boundingBox = boundingBoxes[row][col];
found = boundingBox.contains(position.x, position.z);
}
}
// 如果我们找到了一个包含我们位置的地形块
// 计算该位置的地形高度
if (found) {
Vector3f[] triangle = getTriangle(position, boundingBox, terrainBlock);
result = interpolateHeight(triangle[0], triangle[1], triangle[2], position.x, position.z);
}
return result;
}
```
在此方法中第一件事是确定我们所在的地形块。由于我们已经有了每个地形块的包围盒,所以算法很简单。我们只需要迭代包围盒数组,并检查当前位置是否位于其中(`Box2D`提供了该方法)。
一旦找到了地形块,我们需要计算所处的三角形,这是由之后的`getTriangle`方法计算的。之后,我们得到了所在三角形的坐标,包括它的高度。但是,我们需要的是一个点的高度,这个点不位于这些顶点中的任何一点,而位于它们之间的位置。这将在`interpolateHeight`方法中计算,我们也将解释这是如何计算的。
让我们先从确定所处的三角形开始。构成地形块的正方形可以看作一个网格,其中每个单元由两个三角形组成。首先我们定义一些变量:
* **boundingBox.x**是与包围盒相关联的地形块的原**x**坐标。
* **boundingBox.y**是与包围盒相关联的地形块的原**z**坐标(即使你看到一个**y**,但它是在**z**轴的)。
* **boundingBox.width**是地形块正方形的宽度。
* **boundingBox.height**是地形块正方形的高度。
* **cellWidth**是一个单元的宽度。
* **cellHeight**是一个单元的高度。
上面定义的所有变量都用世界坐标来表示。为了计算单元的宽度,我们只需要将包围盒宽度除以每列的顶点数:
$$cellWidth = \frac{boundingBox.width}{verticesPerCol}$$
`cellHeight`的计算也相似:
$$cellHeight = \frac{boundingBox.height}{verticesPerRow}$$
一旦有了这些变量,我们就可以计算所在的单元格的行和列了:
$$col = \frac{position.x - boundingBox.x}{boundingBox.width}$$
$$row = \frac{position.z - boundingBox.y}{boundingBox.height}$$
下图在示例地形块展示了此前描述的所有变量。
![地形块变量](_static/15/terrain_block_variables_n.png)
有了这些信息,就可以计算单元格中包含的三角形顶点的位置。我们怎么才能做到呢?让我们来看看组成一个单元格的三角形。
![单元格](_static/15/cell.png)
你可以看到,单元格是被一个对角线分开为两个三角形的。确定与当前位置相关的三角形的方法,是检查**z**坐标在对角线的上方还是下方。在本例中,将对角线的**x**值设置为当前位置的**x**值,求出对应的对角线**z**值,如果当前位置的**z**值小于对角线的**z**值,那么我们在**T1**中。反之如果当前位置的**z**值大于对角线的**z**值,我们就在**T2**中。
我们可以通过计算与对角线相匹配的直线方程来确定。
如果你还记得学校的数学课,从两点通过的直线(在二维中)的方程为:
$$y-y1=m\cdot(x-x1)$$
其中m是直线的斜率也就是说当沿**x**轴移动时,其高度会发生变化。请注意,在本例中,**y**坐标其实是一个**z**。还要注意的是,我们使用的是二维坐标,因为在这里不计算高度,只要**x**坐标和**z**坐标就足够了。因此,在本例中,直线方程应该是这样。
$$z-z1=m\cdot(x-x1)$$
斜率可以按如下方式计算:
$$m=\frac{z1-z2}{x1-x2}$$
所以给定一个**x**坐标得到一个**z**值的对角线方程就像这样:
$$z=m\cdot(xpos-x1)+z1=\frac{z1-z2}{x1-x2}\cdot(zpos-x1)+z1$$
其中**x1**、**x2**、**z1**和**z2**分别是顶点**V1**和**V2**的**x**和**z**坐标。
因此,通过上述方式来获得当前位置所在的三角形的方法,名为`getTriangle`,其实现如下:
```java
protected Vector3f[] getTriangle(Vector3f position, Box2D boundingBox, GameItem terrainBlock) {
// 获得与当前位置相关的高度图的行列
float cellWidth = boundingBox.width / (float) verticesPerCol;
float cellHeight = boundingBox.height / (float) verticesPerRow;
int col = (int) ((position.x - boundingBox.x) / cellWidth);
int row = (int) ((position.z - boundingBox.y) / cellHeight);
Vector3f[] triangle = new Vector3f[3];
triangle[1] = new Vector3f(
boundingBox.x + col * cellWidth,
getWorldHeight(row + 1, col, terrainBlock),
boundingBox.y + (row + 1) * cellHeight);
triangle[2] = new Vector3f(
boundingBox.x + (col + 1) * cellWidth,
getWorldHeight(row, col + 1, terrainBlock),
boundingBox.y + row * cellHeight);
if (position.z < getDiagonalZCoord(triangle[1].x, triangle[1].z, triangle[2].x, triangle[2].z, position.x)) {
triangle[0] = new Vector3f(
boundingBox.x + col * cellWidth,
getWorldHeight(row, col, terrainBlock),
boundingBox.y + row * cellHeight);
} else {
triangle[0] = new Vector3f(
boundingBox.x + (col + 1) * cellWidth,
getWorldHeight(row + 2, col + 1, terrainBlock),
boundingBox.y + (row + 1) * cellHeight);
}
return triangle;
}
protected float getDiagonalZCoord(float x1, float z1, float x2, float z2, float x) {
float z = ((z1 - z2) / (x1 - x2)) * (x - x1) + z1;
return z;
}
protected float getWorldHeight(int row, int col, GameItem gameItem) {
float y = heightMapMesh.getHeight(row, col);
return y * gameItem.getScale() + gameItem.getPosition().y;
}
```
你可以看到我们有另外两个反复。第一个名为`getDiagonalZCoord`,给定**x**位置和两个顶点计算对角线的**z**坐标。另一个名为`getWorldHeight`,用来获得三角形顶点的高度(即**y**坐标)。当地形网格被创建时,每个顶点的高度都被预先计算和储存,我们只需将其转换为世界坐标。
好,我们有当前位置的三角形坐标。最后,我们准备在当前位置计算地形高度。怎么做呢?我们的三角形在一个平面上,一个平面可以由三个点定义,在本例中,三个顶点定义了一个三角形。
平面方程如下:
$$a\cdot x+b\cdot y+c\cdot z+d=0$$
上述方程的常数值是:
$$a=(B_{y}-A_{y}) \cdot (C_{z} - A_{z}) - (C_{y} - A_{y}) \cdot (B_{z}-A_{z})$$
$$b=(B_{z}-A_{z}) \cdot (C_{x} - A_{x}) - (C_{z} - A_{z}) \cdot (B_{z}-A_{z})$$
$$c=(B_{x}-A_{x}) \cdot (C_{y} - A_{y}) - (C_{x} - A_{x}) \cdot (B_{y}-A_{y})$$
其中**A**、**B**和**C**是定义平面所需的三个顶点。
然后,利用之前的方程以及当前位置的**x**和**z**坐标值,我们能够计算**y**值,即当前位置的地形高度:
$$y = (-d - a \cdot x - c \cdot z) / b$$
实现了如上运算的方法如下:
```java
protected float interpolateHeight(Vector3f pA, Vector3f pB, Vector3f pC, float x, float z) {
// 平面方程 ax+by+cz+d=0
float a = (pB.y - pA.y) * (pC.z - pA.z) - (pC.y - pA.y) * (pB.z - pA.z);
float b = (pB.z - pA.z) * (pC.x - pA.x) - (pC.z - pA.z) * (pB.x - pA.x);
float c = (pB.x - pA.x) * (pC.y - pA.y) - (pC.x - pA.x) * (pB.y - pA.y);
float d = -(a * pA.x + b * pA.y + c * pA.z);
// y = (-d -ax -cz) / b
float y = (-d - a * x - c * z) / b;
return y;
}
```
这就完了!现在我们能够检测碰撞,所以在`DummyGame`类中,在更新摄像机位置时,修改如下代码:
```java
// 更新摄像机位置
Vector3f prevPos = new Vector3f(camera.getPosition());
camera.movePosition(cameraInc.x * CAMERA_POS_STEP, cameraInc.y * CAMERA_POS_STEP, cameraInc.z * CAMERA_POS_STEP);
// 检查是否发生碰撞。如果为true将y坐标设置为
// 最大高度
float height = terrain.getHeight(camera.getPosition());
if ( camera.getPosition().y <= height ) {
camera.setPosition(prevPos.x, prevPos.y, prevPos.z);
}
```
如你所见,检测地形碰撞的概念很容易理解,但是我们需要仔细地进行计算并了解正处理的不同坐标系。
此外,虽然这里给出的算法在大多数情况下都是可用的,但仍存在需要仔细处理的情况。你可以发现的一个问题是隧道效应(`Tunnelling`)。设想一个情况,我们正以高速穿过地形,正因如此,位置增量值较高。这个值变得如此之高,以至于因为我们检测的是最终位置的碰撞,所以可能已经穿过了位于两点之间的障碍。
![隧道效应](_static/15/tunnelling.png)
有许多可行的解决方案可以避免这个效应,最简单的解决方法是将要进行的计算分成增量较小的多份。

215
docs/16-fog.md Normal file
View File

@@ -0,0 +1,215 @@
# 雾 (Fog)
在处理更复杂的问题之前,我们将学习如何在游戏引擎中创建雾特效。有了这个特效,就可以模拟遥远的物体变暗,似乎消失在浓雾中。
让我们来确定定义雾的属性是什么。第一个是雾的颜色。在现实世界中,雾是灰色的,但我们可以利用这个特效来模拟不同颜色的雾覆盖的区域。还有一个属性是雾的浓度。
因此为了使用雾特效我们需要找到一种方法当3D场景的物体远离摄像机时使它们褪色到雾的颜色。靠近摄像机的物体不会受到雾的影响但远处的物体将无法分辨。因此我们需要计算一个参数可以用来混合雾的颜色与每个片元的颜色以模拟雾特效。这个参数取决于与摄像机相距的距离。
让我们把这个参数命名为`fogFactor`并设定它的范围为0到1。当`fogFactor`为1时意味着物体完全不会受到雾的影响也就是说它是较近的物体。当`fogFactor`为0时意味着物体完全隐藏在雾中。
然后,计算雾色的方程如下:
$$finalColour = (1 - fogFactor) \cdot fogColour + fogFactor \cdot framentColour$$
* **finalColour** 是使用雾特效的最终颜色。
* **fogFactor** 是控制雾的颜色与片元的颜色如何混合的参数,它基本上控制物体的可见性。
* **fogColour** 是雾的颜色。
* **fragmentColour** 没有使用雾特效的片元颜色。
现在我们需要找到一种方法来基于距离计算**fogFactor**。我们可以选择不同的模型,首先使用线性模型。这是一个给定距离以线性方式改变**fogFactor**的模型。
线性模型由以下参数定义:
* **fogStart**: 开始使用雾特效的距离。
* **fogFinish**: 雾特效达到最大值的距离。
* **distance**: 到摄像机的距离。
有了这些参数,方程就会是这样的:
$$\displaystyle fogFactor = \frac{(fogFinish - distance)}{(fogFinish - fogStart)}$$
对于距离低于**fogStart**的物体我们简单地设置**fogFactor**为**1**。下图表明了**fogFactor**是如何随着距离变化而变化的。
![线性模型](_static/16/linear_model.png)
线性模型易于计算,但不太真实,因为它不考虑雾气浓度。实际上雾往往以更平滑的方式增加。所以下一个合适的模型是指数模型。该模型的方程如下:
$$\displaystyle focFactor = e^{-(distance \cdot fogDensity)^{exponent}} = \frac{1}{e^{(distance \cdot fogDensity)^{exponent}}}$$
其中的新变量如下所述:
* **fogDensity** 是雾的厚度或浓度。
* **exponent** 用来控制雾随着距离的增加增长的速度。
下图显示了两个图形,分别设置了不同的**exponent**值(蓝线为**2**,红线为**4**)。
![指数模型](_static/16/exponential_model.png)
在代码中,我们将使用一个公式,让它可以为**exponent**设置不同的值(你可以很容易地修改示例以使用不同的值)。
既然已经解释过这个原理了,我们就可以实现它。我们将在场景的片元着色器中实现雾特效,因为这有我们需要的所有变量。我们将首先定义一个储存雾属性的结构体。
```glsl
struct Fog
{
int active;
vec3 colour;
float density;
};
```
`active`属性用于激活或关闭雾特效。雾属性将通过另一个被称作`fog`的Uniform传递给着色器。
```glsl
uniform Fog fog;
```
我们还将创建一个包含着雾属性的名为`Fog`的新类它是另一个POJOPlain Ordinary Java Object简单的Java对象
```java
package org.lwjglb.engine.graph.weather;
import org.joml.Vector3f;
public class Fog {
private boolean active;
private Vector3f colour;
private float density;
public static Fog NOFOG = new Fog();
public Fog() {
active = false;
this.colour = new Vector3f(0, 0, 0);
this.density = 0;
}
public Fog(boolean active, Vector3f colour, float density) {
this.colour = colour;
this.density = density;
this.active = active;
}
// Getters and setters here….
```
我们将在`Scene`类中添加一个`Fog`示例。默认情况下,`Scene`类将初始化一个`Fog`示例到常量`NOFOG`,用于模拟关闭雾特效的情况。
因为添加了一个新的Uniform类型所以我们需要修改`ShaderProgram`类来创建和初始化雾的Uniform。
```java
public void createFogUniform(String uniformName) throws Exception {
createUniform(uniformName + ".active");
createUniform(uniformName + ".colour");
createUniform(uniformName + ".density");
}
public void setUniform(String uniformName, Fog fog) {
setUniform(uniformName + ".activeFog", fog.isActive() ? 1 : 0);
setUniform(uniformName + ".colour", fog.getColour() );
setUniform(uniformName + ".density", fog.getDensity());
}
```
`Renderer`类中,我们只需要在`setupSceneShader`方法中创建Uniform
```java
sceneShaderProgram.createFogUniform("fog");
```
然后在`renderScene`方法中使用它:
```java
sceneShaderProgram.setUniform("fog", scene.getFog());
```
我们现在可以在游戏中定义雾特效,但是需要回到片元着色器中应用雾特效。我们将创建一个名为`calcFog`的函数,函数定义如下。
```glsl
vec4 calcFog(vec3 pos, vec4 colour, Fog fog)
{
float distance = length(pos);
float fogFactor = 1.0 / exp( (distance * fog.density)* (distance * fog.density));
fogFactor = clamp( fogFactor, 0.0, 1.0 );
vec3 resultColour = mix(fog.colour, colour.xyz, fogFactor);
return vec4(resultColour.xyz, colour.w);
}
```
如你所见,我们首先计算到顶点的距离。顶点坐标定义在`pos`变量中,我们只需要计算长度。然后利用**exponent**为2的指数模型相当于乘以两次计算雾参数。我们得到的`fogFactor`的范围在**0**到**1**之间,并使用`mix`函数。在GLSL中`min`函数被用于混合雾色和图元颜色(由颜色变量定义)。相当于使用如下方程:
$$resultColour = (1 - fogFactor) \cdot fog.colour + fogFactor \cdot colour$$
我们还为颜色保留了**w**元素,即透明度。我们不希望这个元素受到影响,片元应该保持它的透明程度不变。
在应用了所有的光效之后,在片元着色器的最后,如果雾特效启用的话,我们将简单地把返回值设置为片元颜色。
```glsl
if ( fog.activeFog == 1 )
{
fragColor = calcFog(mvVertexPos, fragColor, fog);
}
```
所有这些代码完成后,我们可以用下面的数据设置一个雾特效:
```java
scene.setFog(new Fog(true, new Vector3f(0.5f, 0.5f, 0.5f), 0.15f));
```
然后我们将获得像这样的效果:
![雾特效](_static/16/fog_effect.png)
你会看到远处的物体褪色,当你靠近它们时,雾开始消失。但有一个问题,天空盒看起来有点奇怪,地平线不受雾的影响。有几种方法可以解决这个问题:
* 使用只能看到天空的另一个不同的天空盒。
* 删除天空盒,因为有浓雾,你不应该能够看到一个背景。
也可能这两个解决方案没有适合你的,你可以试着将雾色与天空盒的背景相匹配,但这样你会做复杂的计算,结果也许不会更好。
如果你运行这个示例,你会感到平行光变得暗淡,场景变暗,但雾看起来有问题,因为它不受光的影响,会看到如下图所示的结果。
![发光的雾](_static/16/glowing_fog.png)
远处的物体变为雾色,这是一个不受光影响的常数。这造成了一个在黑暗中发光的效果(这可能并不好)。我们需要修改计算雾的函数,让其考虑光照。该函数将接收环境光和平行光来调整雾色。
```glsl
vec4 calcFog(vec3 pos, vec4 colour, Fog fog, vec3 ambientLight, DirectionalLight dirLight)
{
vec3 fogColor = fog.colour * (ambientLight + dirLight.colour * dirLight.intensity);
float distance = length(pos);
float fogFactor = 1.0 / exp( (distance * fog.density)* (distance * fog.density));
fogFactor = clamp( fogFactor, 0.0, 1.0 );
vec3 resultColour = mix(fogColor, colour.xyz, fogFactor);
return vec4(resultColour.xyz, 1);
}
```
如你所见,平行光我们仅使用了颜色和强度,我们不需要关注它的方向。这样,我们只需要稍微修改函数的调用:
```glsl
if ( fog.active == 1 )
{
fragColor = calcFog(mvVertexPos, fragColor, fog, ambientLight, directionalLight);
}
```
在夜晚时,我们会看到这样的效果。
![夜晚的雾](_static/16/fog_at_night.png)
一个要强调的重要的事情是,我们必须聪明地选择雾色。这是很重要的,当我们没有天空盒,但有固定的颜色背景,应该把雾色设置为背景色。如果你删除了天空盒的代码并重新运行示例代码,你会得到这样的结果。
![黑色背景](_static/16/fog_clear_colour_black.png)
但如果我们把背景色修改为0.5, 0.5, 0.5),最终结果看起来就是如下所示。
![雾灰色背景](_static/16/fog_clear_colour_grey.png)

188
docs/17-normal-mapping.md Normal file
View File

@@ -0,0 +1,188 @@
# 法线贴图Normal Mapping
本章中将讲解一项技术它将极大地改善我们的3D模型的外观。到目前为止我们已经能够将纹理使用到复杂的3D模型上但这还离真实物体的样子很远。现实世界中的物体表面不是完全光滑的它们有我们的3D模型目前所不具有的瑕疵。
为了渲染更真实的场景,我们将渲染**法线贴图(Normal Mapping)**。如果你在现实世界中看到一个平面你会发现那些瑕疵甚至可以在很远的距离看到。在3D场景中平面没有瑕疵我们可以将纹理应用在它之上但这不会改变光反射的方式。这就是为什么与现实有区别的原因。
我们可以考虑通过增加三角形数量来增加模型的细节并反映出这些瑕疵,但性能会下降。我们需要的是改变表面光反射的方式来增加真实感。这就是用法线贴图技术实现的。
让我们看看光滑平面的例子,一个平面由两个三角形组成为一个四边形。回忆之前的光照章节,模型的光反射的要素是平面法线。在此情况下,我们整个平面仅有单一的法线,当计算光如何影响片元时,每个片元都使用相同的法线。看起来就像下图那样。
![平面法线](_static/17/surface_normals.png)
如果可以改变平面的每个片元的法线,我们就可以模拟平面的瑕疵,使它们更逼真。看起来就像下图那样。
![片元法线](_static/17/fragment_normals.png)
要做到这一点我们要加载另一个纹理它储存面的法线。法线纹理的每个像素将以RGB值储存法线的**x**、**y**和**z**坐标值。
让我们用下面的纹理绘制一个四边形。
![纹理](_static/17/rock.png)
上图的法线纹理如下所示。
![法线纹理](_static/17/rock_normals.png)
如你所见,如果我们把颜色变换应用到原始纹理,每个像素使用颜色分量储存法线信息。在看到法线贴图时,你常常会看到主色调倾向于蓝色,这是由于大多数法线指向转换正**z**轴所致。在一个平面表面的矢量中,**z**分量通常比**x**和**y**分量的值高得多。由于**x**、**y**、**z**坐标被映射到RGB导致蓝色分量也有着更高的值。
因此,使用法线贴图渲染对象只需要一个额外的纹理,并同时使用它渲染片元以获得适当的法线值。
让我们开始修改代码,以支持法线贴图。我们将添加一个新的`Texture`实例到`Material`类,这样就可以把一个法线贴图纹理添加到游戏项目上。此实例将有自己的`get``set`方法,并有方法可以检查`Material`是否有法线贴图。
```java
public class Material {
private static final Vector4f DEFAULT_COLOUR = new Vector3f(1.0f, 1.0f, 1.0f, 10.f);
private Vector3f ambientColour;
private Vector3f diffuseColour;
private Vector3f specularColour;
private float reflectance;
private Texture texture;
private Texture normalMap;
// … Previous code here
public boolean hasNormalMap() {
return this.normalMap != null;
}
public Texture getNormalMap() {
return normalMap;
}
public void setNormalMap(Texture normalMap) {
this.normalMap = normalMap;
}
}
```
我们将在场景的片元着色器中使用法线贴图纹理。但是,由于我们在观察坐标空间操作,所以需要通过模型观察矩阵来进行适当的变换。因此,我们需要修改场景的顶点着色器。
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
out vec2 outTexCoord;
out vec3 mvVertexNormal;
out vec3 mvVertexPos;
out mat4 outModelViewMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main()
{
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPos;
outTexCoord = texCoord;
mvVertexNormal = normalize(modelViewMatrix * vec4(vertexNormal, 0.0)).xyz;
mvVertexPos = mvPos.xyz;
outModelViewMatrix = modelViewMatrix;
}
```
在场景的片元着色器中,我们需要添加另一个输入参数。
```glsl
in mat4 outModelViewMatrix;
```
在片元着色器中我们需要为法线贴图纹理采样器传递一个新的Uniform
```glsl
uniform sampler2D texture_sampler;
```
此外,在片元着色器中,我们将创建一个新函数,该函数计算当前片元的法线。
```glsl
vec3 calcNormal(Material material, vec3 normal, vec2 text_coord, mat4 modelViewMatrix)
{
vec3 newNormal = normal;
if ( material.hasNormalMap == 1 )
{
newNormal = texture(normalMap, text_coord).rgb;
newNormal = normalize(newNormal * 2 - 1);
newNormal = normalize(modelViewMatrix * vec4(newNormal, 0.0)).xyz;
}
return newNormal;
}
```
该函数有以下参数:
* `Material`实例。
* 顶点法线。
* 纹理坐标。
* 模型视图矩阵。
我们在此函数中做的第一件事是检查这个`Material`实例是否有法线贴图。如果没有,我们就像平常一样简单地使用顶点法线。如果它有法线贴图,我们使用储存着法线数据的法线纹理关联到当前纹理坐标。
记住我们取得的颜色是法线坐标但因为它们被储存为RGB值所以它们的值在范围\[0, 1\]中。我们需要将其范围转换为\[-1, 1\]所以我们将其乘以2然后减去1。然后我们将它归一化并将其转换到模型观察坐标系就像顶点法线那样
就这样,我们可以使用返回值作为片元所有光照计算中的法线。
`Renderer`类中,我们需要创建法线贴图,在`renderScene`方法中,我们做如下设置:
```java
//...
sceneShaderProgram.setUniform("fog", scene.getFog());
sceneShaderProgram.setUniform("texture_sampler", 0);
sceneShaderProgram.setUniform("normalMap", 1);
//...
```
上述代码中你可能注意到一些有趣的事情。我们将材质纹理Uniform`texture_sampler`)设置为**0**,然后将法线贴图纹理(`normalMap`)设置为**1**。如果你回想纹理章节,我们不止使用一个纹理,所以我们必须为每个单独的纹理设置纹理单元。
当我们渲染`Mesh`时,也需要考虑这一点。
```java
private void initRender() {
Texture texture = material.getTexture();
if (texture != null) {
// 激活第一纹理库
glActiveTexture(GL_TEXTURE0);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture.getId());
}
Texture normalMap = material.getNormalMap();
if ( normalMap != null ) {
// 激活第二纹理库
glActiveTexture(GL_TEXTURE1);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, normalMap.getId());
}
// 绘制网格
glBindVertexArray(getVaoId());
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
}
```
如你所见,我们需要绑定每个可用的纹理,并激活相关的纹理单元,以便多个纹理能够同时工作。在`Renderer`类的`renderScene`方法中我们不需要显式设置纹理的Uniform因为它已经包含在`Material`中。
为了展示法线贴图带来的提升我已经创建了两个并排显示的四边形示例。右边的有一张法线贴图而左边没有。我们还删除了地形、天空盒和HUD并设置了平行光照可以用鼠标左右键改变方向这样你就可以看到效果了。我已经修改了基本源代码以支持关闭天空盒和地形。我们还在片元着色器中设置了光效果的范围为\[0, 1\],以避免图像过度曝光。
结果如下图所示。
![法线贴图结果](_static/17/normal_mapping_result.png)
如你所见,具有法线贴图的四边形感觉更大。虽然它本质上是一个像其他四边形一样的平面,但你可以看到光线是如何反射的。
尽管我们已经编写的代码完全符合这个示例,但你需要知道它的局限性。该代码仅适用与使用模型坐标空间创建的法线贴图纹理。如果是这种情况,我们可以使用模型视图矩阵来将法线坐标转换到观察空间。
但通常法线贴图不是这样定义的。它们通常被定义在所谓的**切线空间(`Tangent Space`**中。切线空间是一个坐标系,它定位在模型的每个三角形,其坐标系的**z**轴总是垂直于表面。这就是为什么当你看一个法线贴图时,它通常是蓝色的,甚至对于面相对复杂的模型也是如此。
我们现在仍使用这个简单的实现,但请记住,你必须总是使用在模型空间中定义的法线贴图。如果使用切线空间中定义的贴图,就会变得很奇怪。为了能够让它们一同工作,我们需要使用特定的矩阵来将坐标变换到切线空间。

405
docs/18-shadows.md Normal file
View File

@@ -0,0 +1,405 @@
# 阴影Shadows
## 阴影映射
目前我们能够表现光线如何影响三维场景中的对象。接收到更多光的物体比没有接收光的物体更亮。然而我们仍无法投射阴影。阴影能增加3D场景的真实度因此我们将在本章中添加对它的支持。
我们将使用一种被称为阴影映射Shadow Mapping的技术这种技术被广泛使用于游戏中且不会严重影响引擎性能。阴影映射看起来很容易理解但是很难正确地实现它。或者更准确地说很难用一种通用的涵盖了一切可能并产生一致的效果的方法去实现它。
我们将在此说明一种方法它可以为你在大多数情况下添加阴影但更重要的是它将帮助你了解其局限性。这里介绍的代码远不是完美的但我认为它很容易理解。它还被设计用于支持有向光这我认为是更复杂的情况但你将了解如何将其扩展以支持其他类型的光照例如点光源。如果你想获得更高级的效果你应该使用更高级的技术例如层叠阴影图Cascaded Shadow Maps。在任何情况下这里解释的概念都仅仅作为基础。
所以,让我们从思考如何检查特定区域(实际上是片元)是否在阴影中开始。在绘制这个区域的时候,我们可以发出射线投射到光源上,如果我们可以在不发生任何碰撞的情况下到达光源,那么像素就在光照中,反之,像素处于阴影中。
下图展示了点光源的情况点PA可以到达光源但点PB和PC不行因此它们位于阴影中。
![阴影概述I](_static/18/shadow_concepts_I.png)
那么我们如何才能检查是否能以一种有效的方式发射出不发生碰撞的射线呢理论上光源可以投射出无限的光线Name我们如何检查光线是否被遮挡
我们能做的不是投射光线而是从光线透视图中观察3D场景并从该位置渲染场景。我们可以将相机设置在光源位置并渲染场景以便储存每个片元的深度。这相当于计算每个片元到光源的距离。最后我们要做的是将光照所及的最小距离储存为阴影图。
下图展示了一个悬浮在平面上并垂直于光线的立方体。
![阴影概述II](_static/18/shadow_concepts_II.png)
从光源的角度看,情况是这样的(颜色越深,越接近光源)。
![从光源的角度渲染](_static/18/render_light_perspective.png)
利用这些信息。我们可以像往常一样渲染3D场景并以最小储存距离检查每个每个片元到光源的距离。如果距离小于阴影贴图中储存的值则对象位于光照中否则位于阴影中。我们可以让几个物体被同一光照照射但我们储存最小距离。
因此,阴影映射分为两步:
* 首先,我们将场景从光照空间渲染为阴影图,以获得最小距离。
* 其次,我们从摄像机的视角渲染场景。并使用深度图计算对象是否位于阴影中。
为了渲染深度图我们需要说说深度缓冲区Depth-buffer。当我们渲染一个场景时所有深度信息都储存在一个名为“深度缓冲区”又称“Z缓冲区Z-buffer的缓冲区中。深度信息是渲染的每个片元的$$z$$值。如果你从第一章回忆我们在渲染场景时,将正在渲染的场景从世界坐标转换为屏幕坐标。我们所绘制的坐标空间,对于$$x$$和$$y$$轴来说,坐标的范围为$$0$$到$$1$$。如果一个物体比其他对象原,我们必须通过透视投影矩阵计算它如何影响其$$x$$和$$y$$坐标。这不是根据$$z$$值自动计算的,它必须由我们来做。实际储存在$$z$$坐标中的是它在片元的深度,仅此而已。
此外,在源代码中,我们启用了深度测试。在`Window`类中,我们添加如下行:
```glsl
glEnable(GL_DEPTH_TEST);
```
通过添加这行,我们可以防止无法看到的片元被绘制出来,因为他们位于其他对象之后。在绘制片元之前,它的$$z$$值将与Z缓冲区中的$$z$$值进行比较。如果它的$$z$$值(它的距离)大于缓冲区的$$z$$值,则会被丢弃。请记住,这是在屏幕空间中完成的,因此,给定一对屏幕空间中范围为$$[0, 1]$$的$$x$$和$$y$$坐标,我们比较其片元的$$z$$值。同样,$$z$$值也在此范围内。
深度缓冲区的存在是我们在执行任何渲染操作之前需要清除屏幕的原因。我们不仅需要清除颜色,还要清除深度信息:
```java
public void clear() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
```
为了开始构建深度图,我们希望从光源的角度获得深度信息。我们需要在光源位置设置一个摄像头,渲染场景并将深度信息储存到纹理中,以便稍后访问它。
因此,我们首先需要做的是添加对创建这些纹理的支持。我们将修改`Texture`类,通过添加新的构造函数来支持创建空纹理。此构造函数需要纹理的尺寸以及它储存的像素的格式。
```java
public Texture(int width, int height, int pixelFormat) throws Exception {
this.id = glGenTextures();
this.width = width;
this.height = height;
glBindTexture(GL_TEXTURE_2D, this.id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, this.width, this.height, 0, pixelFormat, GL_FLOAT, (ByteBuffer) null);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
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_CLAMP_TO_EDGE`,因为我们不希望在超出$$[0, 1]$$范围时重复纹理。
所以现在我们可以创建空的纹理我们需要能够在其中渲染一个场景。为了做到它我们需要使用帧缓冲区对象Frame Buffers Objects或称FBOs。帧缓冲区是可以作为渲染终点的缓冲区集合。当我们渲染到屏幕上时我们使用的是OpenGL的默认缓冲区。OpenGL允许我们使用FBO渲染到用户定义的缓冲区。我们将通过创建一个名为`ShadowMap`的新类来隔离为阴影映射创建FBO过程的其余代码。如下就是那个类的定义。
```java
package org.lwjglb.engine.graph;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.opengl.GL30.*;
public class ShadowMap {
public static final int SHADOW_MAP_WIDTH = 1024;
public static final int SHADOW_MAP_HEIGHT = 1024;
private final int depthMapFBO;
private final Texture depthMap;
public ShadowMap() throws Exception {
// 创建FBO以渲染深度图
depthMapFBO = glGenFramebuffers();
// 创建深度图纹理
depthMap = new Texture(SHADOW_MAP_WIDTH, SHADOW_MAP_HEIGHT, GL_DEPTH_COMPONENT);
// 绑定深度图纹理到FBO
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap.getId(), 0);
// 仅设置深度
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
throw new Exception("Could not create FrameBuffer");
}
// 解绑
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
public Texture getDepthMapTexture() {
return depthMap;
}
public int getDepthMapFBO() {
return depthMapFBO;
}
public void cleanup() {
glDeleteFramebuffers(depthMapFBO);
depthMap.cleanup();
}
}
```
`ShadowMap`类定义了两个常量用于确定储存深度图的纹理的大小。它还定义了两个属性一个用于FBO一个用于纹理。在构造函数中创建一个新的FBO和一个新的`Texture`。对于FBO将使用常量`GL_DEPTH_COMPONENT`作为像素格式因为我们只对储存深度值感兴趣然后将FBO绑定到纹理实例。
以下几行代码显式地将FBO设置为不渲染任何颜色。FBO需要颜色缓冲区但我们不需要这就是为什么我们将颜色缓冲区设置为`GL_NONE`
```java
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
```
现在,我们准备在`Renderer`类中将场景从灯光透视渲染为FBO。为了做到它我们将创建一组特殊的顶点和片元着色器。
名为`depth_vertex.vs`的顶点着色器的定义如下:
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
uniform mat4 modelLightViewMatrix;
uniform mat4 orthoProjectionMatrix;
void main()
{
gl_Position = orthoProjectionMatrix * modelLightViewMatrix * vec4(position, 1.0f);
}
```
我们希望接收与场景着色器相同的输入数据。但实际上,我们只需要坐标,但是为了尽可能多地重用代码,我们还是要传送其他数据。我们还需要一对矩阵。记住,我们必须以光源的角度渲染场景,所以我们需要将模型转换到光源的坐标空间。这是通过`modelLightViewMatrix`矩阵完成的,该矩阵类似于用于摄像机的模型观察矩阵。现在光源是我们的摄像机。
然后我们需要将这些坐标转换到屏幕空间也就是说需要投影它们。这是计算平行光与点光源的阴影图时的区别之一。对于地昂扬我们将使用透视投影Perspective Projection矩阵就像我们正常渲染场景一样。相反平行光以相同方式影响所有对象而与距离无关。平行光源位于无穷远的点上没有位置只有方向。正交投影Orthographic Projection不会使远处的物体变小因此正交投影最适合平行光。
片元着色器更简单。它只输出$$z$$坐标作为深度值。
```glsl
#version 330
void main()
{
gl_FragDepth = gl_FragCoord.z;
}
```
实际上,你可以删掉该行,因为我们只生成深度值,深度值将自动返回。
一旦我们为深度渲染定义了新的着色器,就可以在`Renderer`类中使用它们。我们为初始化这些着色器定义了一个新方法,名为`setupDepthShader`,它将在其他着色器被初始化时调用。
```java
private void setupDepthShader() throws Exception {
depthShaderProgram = new ShaderProgram();
depthShaderProgram.createVertexShader(Utils.loadResource("/shaders/depth_vertex.vs"));
depthShaderProgram.createFragmentShader(Utils.loadResource("/shaders/depth_fragment.fs"));
depthShaderProgram.link();
depthShaderProgram.createUniform("orthoProjectionMatrix");
depthShaderProgram.createUniform("modelLightViewMatrix");
}
```
现在,我们需要创建一个新的方法,使用那些名为`renderDepthMap`的着色器。该方法将在主渲染方法中调用。
```java
public void render(Window window, Camera camera, Scene scene, IHud hud) {
clear();
// 在设置视口之前渲染深度图
renderDepthMap(window, camera, scene);
glViewport(0, 0, window.getWidth(), window.getHeight());
// 其余的代码在这...
```
如果你浏览上述代码,将看到在设置视口之前,新方法就已经被调用。这是因为这个新方法将更高视口以匹配保存深度图的纹理的尺寸。因此,在完成`renderDepthMap`之后,我们将始终需要设置屏幕尺寸的视口(不检查窗口是否已调整大小)。
现在让我们定义一下`renderDepthMap`方法。第一件事是绑定在`ShadowMap`类中创建的FBO并设置视口以匹配纹理尺寸。
```java
glBindFramebuffer(GL_FRAMEBUFFER, shadowMap.getDepthMapFBO());
glViewport(0, 0, ShadowMap.SHADOW_MAP_WIDTH, ShadowMap.SHADOW_MAP_HEIGHT);
```
然后,清除深度缓冲区内容并绑定深度着色器。因为我们只处理深度值,所以不需要清除颜色信息。
```java
glClear(GL_DEPTH_BUFFER_BIT);
depthShaderProgram.bind();
```
现在我们需要设置矩阵,接下来是棘手的部分。我们使用光源作为摄像机,所以需要创建一个需要一个坐标和三个角的观察矩阵。正如本章开头所说,我们只实现平行光,这种类型的光不定义位置,而是定义方向。如果我们使用点光源,这很容易,光源的位置就是观察矩阵的位置,但我们没有位置。
我们将采用一种简单的方法来计算光的位置。平行光是由一个向量定义的,通常是归一化的,它指向光源所在的方向。我们把这个方向向量乘以一个可配置的因子,在这样它就为要绘制的场景定义了一个合理距离的点。我们将使用该方向来计算该观察矩阵的旋转角度。
![光源位置](_static/18/light_position.png)
这是计算灯光位置与旋转角度的代码片段:
```java
float lightAngleX = (float)Math.toDegrees(Math.acos(lightDirection.z));
float lightAngleY = (float)Math.toDegrees(Math.asin(lightDirection.x));
float lightAngleZ = 0;
Matrix4f lightViewMatrix = transformation.updateLightViewMatrix(new Vector3f(lightDirection).mul(light.getShadowPosMult()), new Vector3f(lightAngleX, lightAngleY, lightAngleZ));
```
接下我们需要计算正交投影矩阵:
```java
Matrix4f orthoProjMatrix = transformation.updateOrthoProjectionMatrix(orthCoords.left, orthCoords.right, orthCoords.bottom, orthCoords.top, orthCoords.near, orthCoords.far);
```
我们已经修改了`Transformation`类,以囊括光照观察矩阵和正交投影矩阵。此们有一个正交的二维投影矩阵,所以我们重命名了此前的方法和属性,你可以直接查看源代码中的定义。
然后,我们按照`renderScene`方法渲染场景对象,但在光照空间坐标系中使用上述矩阵工作。
```java
depthShaderProgram.setUniform("orthoProjectionMatrix", orthoProjMatrix);
Map<Mesh, List<GameItem>> mapMeshes = scene.getGameMeshes();
for (Mesh mesh : mapMeshes.keySet()) {
mesh.renderList(mapMeshes.get(mesh), (GameItem gameItem) -> {
Matrix4f modelLightViewMatrix = transformation.buildModelViewMatrix(gameItem, lightViewMatrix);
depthShaderProgram.setUniform("modelLightViewMatrix", modelLightViewMatrix);
}
);
}
// 解绑
depthShaderProgram.unbind();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
```
正交投影矩阵的参数是在平行光中定义的。将正交投影矩阵想象为一个边界框其中包含我们要渲染的所有对象。当只投影适合该边界框的对象时对象将可视。该边界框由6个参数定义左、右、上、下、近、远。由于光源位置现在是原点因此这些参数定义为原点到左或右x轴或上或下y轴或远或近z轴的距离。
要使阴影图正常工作,最棘手的一点是确定灯光位置和正交投影矩阵的参数。这就是所有这些参数现在在`DirectionalLight`类中定义的方式,因此可以根据每个场景正确设置这些参数。
你可以实现一个更自动的方法通过计算摄像机截锥Frustum的中心回到光的方向建立一个包含场景中所有对象的正交投影。下图展示了如上所述的三维场景、相机位置、截锥蓝色、最佳光源位置以及红色的边界框。
![通用光源位置计算](_static/18/generic_light_pos_calculation.png)
上述方法的问题是很难计算,如果你有很小的物体,并且边界框很大,你可以会得到奇怪的结果。这里介绍的方法对于小场景更简单,你可以调整它以匹配你的模型(即使你可以选择显式设置灯光的位置,以避免相机远离原点移动时产生奇怪的效果)。如果你想要一个更通用的模板,可以应用到任何场景,你应该扩展它,以支持层叠阴影图。
让我们继续。在使用深度图实际计算阴影之前,可以使用生成的纹理渲染一个正方形,以观察深度图的实际外观。在有一个旋转立方体漂浮在一个有垂直平行光的平面上的场景中,你可以得到如下结果。
![深度图](_static/18/depth_map.png)
如上所述,颜色越深,离光源的位置越近。深度图中光源位置的影响是什么?你可以修改平行光照的倍增因子,将看到在纹理中渲染的对象的大小不会变小。记住,我们使用的是正交投影矩阵,物体不会随着距离增大而变小。你将看到的是,所有的颜色都会变得更亮,如下所示:
![更远的距离的深度图](_static/18/depth_map_higher_distance.png)
这是否意味着我们可以为光源选择一个较远的位置而不造成任何后果呢?答案是不行。如果光源离我们要渲染的对象太远,这些对象会超出正交投影矩阵定义的边界框。在此情况下,你会得到一个不错的白色纹理,但这是没用的阴影图。好的,那么我们只需增加边界框的大小,一切都会好的,对吗?答案也是不行。如果你为正交投影矩阵选用了巨大的尺寸,你的物体在纹理中会被绘制得很小,深度值甚至会重叠,造成奇怪的结果。好吧,所以你可以考虑增加纹理大小,但在此情况下,你是有限制的,纹理不能因使用巨大的编辑框而无限增大。
因此,可以看到,选择光源的位置和正交投影的参数是一个复杂的平衡,这使得使用阴影图很难得到正确的效果。
让我们回到渲染过程,一旦计算了深度图,我们就可以在渲染场景时使用它。首先,我们需要修改场景的顶点着色器。到目前为止,顶点着色器使用透视矩阵将顶点坐标从模型观察空间投影到屏幕空间。现在还需要使用投影矩阵从光照空间坐标投影顶点坐标,以用于片元着色器中计算阴影。
顶点着色器是这样修改的:
```glsl
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
out vec2 outTexCoord;
out vec3 mvVertexNormal;
out vec3 mvVertexPos;
out vec4 mlightviewVertexPos;
out mat4 outModelViewMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat4 modelLightViewMatrix;
uniform mat4 orthoProjectionMatrix;
void main()
{
vec4 mvPos = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPos;
outTexCoord = texCoord;
mvVertexNormal = normalize(modelViewMatrix * vec4(vertexNormal, 0.0)).xyz;
mvVertexPos = mvPos.xyz;
mlightviewVertexPos = orthoProjectionMatrix * modelLightViewMatrix * vec4(position, 1.0);
outModelViewMatrix = modelViewMatrix;
}
```
我们为光照观察矩阵和正交投影矩阵使用了新的Uniform。
在片元着色器中,我们将创建一个新的函数来计算阴影,代码如下:
```glsl
float calcShadow(vec4 position)
{
float shadowFactor = 1.0;
vec3 projCoords = position.xyz;
// 从屏幕坐标变换到纹理坐标
projCoords = projCoords * 0.5 + 0.5;
if ( projCoords.z < texture(shadowMap, projCoords.xy).r )
{
// 当前片元不在阴影中
shadowFactor = 0;
}
return 1 - shadowFactor;
}
```
该函数接收使用正交投影矩阵投影的光照观察空间的坐标。如果坐标在阴影中,则返回$$0$$,如果不在阴影中,则返回$$1$$。首先,将坐标转换为纹理坐标。屏幕坐标在$$[-1, 1]$$范围内,但纹理坐标在$$[0, 1]$$范围内。我们通过坐标从纹理中获取深度值,并将其与片元坐标的$$z$$值比较。如果$$z$$值低于储存在纹理中的值,则表示片元不再阴影中。
在片元着色器中,`calcShadow`函数的返回值,用于调节点光源、聚光源和平行光对光照颜色的共享。环境光不受阴影的影响。
```glsl
float shadow = calcShadow(mlightviewVertexPos);
fragColor = clamp(ambientC * vec4(ambientLight, 1) + diffuseSpecularComp * shadow, 0, 1);
```
`Renderer`类的`renderScene`方法中我们只需要传递正交投影和光照观察矩阵到Uniform我们还需要修改着色器的初始化方法以创建新的Uniform。你可以在本书的源代码中了解。
如果运行`DummyGame`类,该类已被修改为在带有平行光的平面上设置有悬浮的立方体,并可使用上下键修改角度,则应该看到如下情况。
![阴影图结果](_static/18/shadow_map_result.png)
虽然阴影已经工作了你可以通过移动光照方向来检查但是实际会出现一些问题。首先被照亮的物体中有奇怪的线条。这种情况被称为阴影失真Shadow Acne它是由储存深度图的纹理的分辨率有限造成的。第二个问题是阴影的边界不平滑看起来很粗糙。原因同样纹理分辨率。我们将解决这些问题以提高阴影质量。
## 改进阴影映射
既然我们已经有了阴影映射机制,那么让我们来解决现有的问题。我们先从失真问题开始。深度图纹理大小有限,因此,可以将多个片元映射到该纹理深度中的同一像素。纹理深度储存最小深度,因此到最后,我们有几个片元共享相同的深度,尽管它们的距离不同。
我们可以通过增加片元着色器中的深度比较来解决这个问题,我们添加了一个偏移。
```glsl
float bias = 0.05;
if ( projCoords.z - bias < texture(shadowMap, projCoords.xy).r )
{
// 当前片元不在阴影中
shadowFactor = 0;
}
```
现在,阴影失真消失了。
![无阴影失真](_static/18/shadow_no_acne.png)
> 译者注使用偏移来消除阴影失真又会造成悬浮Peter Panning问题另请参阅[LearnOpenGL阴影映射](https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/01%20Shadow%20Mapping/)一文。
现在我们要解决的是去阴影边缘问题,这也是由纹理分辨率引起的。对于每个片元,我们将使用片元的坐标值和周围的值对深度图进行采样。然后我们将计算平均值并将该值指定为阴影值。在此情况下,它的值不会是$$0$$和$$1$$但可以在两者间取值,以获得更平滑的边缘。
![深度平均值](_static/18/depth_average_value.png)
在纹理坐标中,周围值必须与当前片元位置保持一个像素距离。所以我们需要计算纹理坐标中一个像素的增量,它等于$$1 / 纹理大小$$。
在片元着色器中,我们只需要修改阴影银子的计算来得到一个平均值。
```glsl
float shadowFactor = 0.0;
vec2 inc = 1.0 / textureSize(shadowMap, 0);
for(int row = -1; row <= 1; ++row)
{
for(int col = -1; col <= 1; ++col)
{
float textDepth = texture(shadowMap, projCoords.xy + vec2(row, col) * inc).r;
shadowFactor += projCoords.z - bias > textDepth ? 1.0 : 0.0;
}
}
shadowFactor /= 9.0;
```
现在结果看起来更平滑了。
![最终结果](_static/18/final_result.png)
现在我们的示例看起来好多了。尽管如此这里介绍的阴影映射技术仍有很大的改进空间。你可以查看如何解决悬浮Peter Panning效果因偏移引起和其他改进阴影边缘的计算。无论如何有了这里所讲解的概念你就有了开始修改示例的良好基础。
为了渲染多个光源,你只需要为每个光源渲染一个深度图。在渲染场景时,你需要采样所有的深度图来计算合适的阴影系数。

146
docs/24-hud-revisited.md Normal file
View File

@@ -0,0 +1,146 @@
#HUD 重温 - NanoVG
在之前的章节中,解释了如何使用正交投影在场景的顶部渲染形状和纹理。 在本章中,我们将学习如何使用 [NanoVG](https://github.com/memononen/nanovg) 库来渲染反锯齿矢量图形以简单的方式构建更复杂的HUD。
还有很多其他库可用于支持此任务,例如[Nifty GUI](https://github.com/nifty-gui/nifty-gui)[Nuklear](https://github.com/vurtun/nuklear)等。在本章中将重点介绍Nanovg因为它非常易于使用但如果您想制作菜单和窗口开发出复杂的GUI按钮交互您应该查看[Nifty GUI](https://github.com/nifty-gui/nifty-gui)。
开始使用[NanoVG](https://github.com/memononen/nanovg) 的第一步是在`pom.xml`文件中添加依赖关系\(一个用于编译时需要的依赖项,另一个用于编译时需要的依赖项 为运行时所需的本机\)。
```xml
...
<dependency>
<groupId>org.lwjgl</groupId>
<artifactId>lwjgl-nanovg</artifactId>
<version>${lwjgl.version}</version>
</dependency>
...
<dependency>
<groupId>org.lwjgl</groupId>
<artifactId>lwjgl-nanovg</artifactId>
<version>${lwjgl.version}</version>
<classifier>${native.target}</classifier>
<scope>runtime</scope>
</dependency>
```
在我们开始使用[NanoVG](https://github.com/memononen/nanovg)之前我们必须在OpenGL里面设置一些东西这样代码测试样本才能正常工作。 我们需要启用对模板缓冲区测试的支持。 到现在为止,已经讨论了颜色和深度缓冲区,但是我们没有提到模板缓冲区。 该缓冲区为每个像素存储一个值\(an integer\)\(整数值\),该像素用于控制应该绘制哪些像素。 该缓冲区用于根据其存储的值屏蔽或丢弃绘图区域。 例如,它可以用于以简单的方式剪切场景的某些部分。 我们通过将这一行添加到`Window`类\(after we enable depth testing\)\(在启用深度测试后\)来启用模板缓冲区测试:
```java
glEnable(GL_STENCIL_TEST);
```
由于程序正在使用另一个缓冲区,因此我们在每次渲染调用之前还必须注意删除其值。 因此,需要修改`Renderer`类的clear方法
```java
public void clear() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
}
```
我们还将添加一个用于激活抗锯齿的新窗口选项。 因此在Window类中将通过以下方式启用它
```java
if (opts.antialiasing) {
glfwWindowHint(GLFW_SAMPLES, 4);
}
```
现在我们准备使用[NanoVG](https://github.com/memononen/nanovg)库。 我们要做的第一件事就是丢弃已经创建的HUD作品那就是`IHud`接口的着色器,`Renderer`类中的HUD渲染方法等。你可以在源代码中找到这一点。
在这种情况下,新的`Hud`类会照顾它的渲染,所以我们不需要将它委托给`Renderer`类。 让我们通过定义该类来开始讨论,它将有一个`init`方法来设置构建HUD所需的库和资源。 该方法是这样定义的:
```java
public void init(Window window) throws Exception {
this.vg = window.getOptions().antialiasing ? nvgCreate(NVG_ANTIALIAS | NVG_STENCIL_STROKES) : nvgCreate(NVG_STENCIL_STROKES);
if (this.vg == NULL) {
throw new Exception("Could not init nanovg");
}
fontBuffer = Utils.ioResourceToByteBuffer("/fonts/OpenSans-Bold.ttf", 150 * 1024);
int font = nvgCreateFontMem(vg, FONT_NAME, fontBuffer, 0);
if (font == -1) {
throw new Exception("Could not add font");
}
colour = NVGColor.create();
posx = MemoryUtil.memAllocDouble(1);
posy = MemoryUtil.memAllocDouble(1);
counter = 0;
}
```
我们要做的第一件事就是创建一个NanoVG环境。 在这种情况下我们使用的是OpenGL3.0 后端,因为引用了`org.lwjgl.nanovg.NanoVGGL3`来命名空间。 如果要激活抗锯齿功能先得激活`NVG_ANTIALIAS`标志。
接下来,我们使用先前加载到 `ByteBuffer` 中的True Type(全真字体)来创建。 给它分配一个名称,以便稍后在呈现文本时使用它。 其中一个重要的事情是,在使用字体时,用于加载字体的`ByteBuffer`必须保存在内存中。 也就是说,它不能被垃圾收集,除非你有一个很好的核心转储它。 这就是为什么它作为类属性存储的原因。
然后,我们创建一个颜色实例和一些有用的变量,这些变量将在渲染时使用。 在渲染初始化之前,该方法在游戏`init`方法中被调用:
```java
@Override
public void init(Window window) throws Exception {
hud.init(window);
renderer.init(window);
...
```
`Hud`类还定义了一个渲染方法在场景渲染完成之后应该调用这个渲染方法这样HUD也会同时被绘制。
```java
@Override
public void render(Window window) {
renderer.render(window, camera, scene);
hud.render(window);
}
```
Hud类的`render`方法是从这里开始的:
```java
public void render(Window window) {
nvgBeginFrame(vg, window.getWidth(), window.getHeight(), 1);
```
我们必须做的第一件事就是调用`nvgBeginFrame`方法。 ```nvgBeginFrame`` method. 所有的NanoVG渲染操作都必须包含在两个之中```nvgBeginFrame``nvgEndFrame`.\`\`\`根据以下参数:
* NanoVG的文本
* 要渲染的窗口的大小 \(宽度和高度\).\(width an height\).
* 像素比例。 如果您需要Hi-DPI的支持则可以更改这个值。 对于这个示例我们将它设置为1。
然后我们创建几个条子,它会占据整个屏幕。 第一个是这样画的:
```java
// Upper ribbon
nvgBeginPath(vg);
nvgRect(vg, 0, window.getHeight() - 100, window.getWidth(), 50);
nvgFillColor(vg, rgba(0x23, 0xa1, 0xf1, 200, colour));
nvgFill(vg);
```
渲染一个形状时,第一个应该调用的方法是`nvgBeginPath`它指示NanoVG开始绘制一个新的形状。 然后我们定义要绘制的内容,矩形,填充颜色以及调用我们绘制的`nvgFill`
您可以查看源代码的其余部分,以查看其余形状的绘制方式。 在渲染字体之前,不需要调用 nvgBeginPath。
在完成绘制所有形状之后,我们只需调用`nvgEndFrame`来结束渲染,但在离开该方法之前还有一件重要的事情要做。 我们必须恢复OpenGL状态。 NanoVG修改OpenGL状态以执行其操作如果状态未正确恢复您可能会看到场景未正确呈现或者甚至已被消除。 因此我们需要恢复我们渲染所需的相关OpenGL状态。 这是在`Window`类中委托的:
```java
// Restore state
window.restoreState();
```
该方法是这样定义的:
```java
public void restoreState() {
glEnable(GL_DEPTH_TEST);
glEnable(GL_STENCIL_TEST);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
if (opts.cullFace) {
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
}
}
```
这就是所有的\(除了一些其他的方法来清除\)\(besides some additional methods to clear things up\),代码完成。 当你执行这个样本时,你会得到如下的结果:
![HUD](_static/24/hud.png)

BIN
docs/_static/01/hello_world.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/_static/01/intellij.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/_static/01/maven_project.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
docs/_static/03/opengl_coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/_static/04/rendering_pipeline.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
docs/_static/04/rendering_pipeline_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
docs/_static/04/triangle_coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
docs/_static/05/coloured_quad.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/_static/05/coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
docs/_static/05/dolphin.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/_static/05/quad_coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
docs/_static/05/quad_rendered.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
docs/_static/06/2_2_matrix.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

BIN
docs/_static/06/coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
docs/_static/06/projection_matrix.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
docs/_static/06/projection_matrix_eq.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
docs/_static/06/rectangle.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
docs/_static/06/square_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/_static/06/square_coloured.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/_static/06/transformations.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/_static/07/cube_coords.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
docs/_static/07/cube_depth_test.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/_static/07/cube_no_depth_test.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/_static/07/cube_texture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/_static/07/cube_with_texture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/_static/07/texture_coordinates.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
docs/_static/07/texture_mapping.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
docs/_static/08/actual_movement.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
docs/_static/08/camera_movement.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
docs/_static/08/cubes.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/_static/08/new_transf_eq.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
docs/_static/08/roll_pitch_yaw.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/_static/09/edge_split.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
docs/_static/09/face_definition.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
docs/_static/09/normals.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
docs/_static/09/obj_export_options.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/_static/09/ordering_i.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
docs/_static/09/ordering_ii.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
docs/_static/09/ordering_iii.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
docs/_static/09/ordering_iv.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
docs/_static/09/ordering_v.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
docs/_static/09/ordering_vi.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
docs/_static/09/ordering_vii.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
docs/_static/09/standford_bunny.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/_static/09/textured_cube.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/_static/09/vertex_normals.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/_static/10/diffuse_calc_i.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/_static/10/diffuse_light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/_static/10/dot_product.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
docs/_static/10/light_reflection.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
docs/_static/10/light_types.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/_static/10/lightning_result.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
docs/_static/10/polished_surface.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
docs/_static/10/specular_lightining.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/_static/10/surface.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/_static/11/directional_light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
docs/_static/11/multiple_lights.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
docs/_static/11/sine_cosine.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/_static/11/spot_light.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/_static/11/spot_light_calc.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/_static/11/spot_light_ii.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/_static/11/spot_light_sample.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/_static/11/sun_movement.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/_static/12/CBG.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

BIN
docs/_static/12/compass.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
docs/_static/12/font_texture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
docs/_static/12/hud_compass.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Some files were not shown because too many files have changed in this diff Show More