3D への移行
いよいよ3Dモデルの描画に入ります。
この章では、3D モデルの基礎を設定し、最初の 3D シェイパーである回転立方体をレンダリングするためのモデル変換の概念について説明します。
これから作成するクラスを含め全体的なクラス関係を図にしました。追加されているのはModel、Entityクラスの二つです。
<サンプルコードを動かしてみました>
モデルとエンティティ1
まず、3D モデルの概念を定義しましょう。これまでは、メッシュ (頂点の集まり) を扱ってきました。モデルは、頂点、色、テクスチャ、およびマテリアルを接着する構造です。モデルは複数のメッシュで構成されている場合があり、複数のゲーム エンティティで使用できます。ゲーム エンティティは、プレイヤーと敵、障害物など、3D シーンの一部を表します。この本では、エンティティは常にモデルに関連付けられていると仮定します (ただし、レンダリングされていないエンティティを持つことができるため、モデルを持つことはできません)。エンティティには、レンダリング時に使用する必要がある位置などの特定のデータがあります。後ほど、モデルを取得してレンダリング プロセスを開始し、そのモデルに関連付けられたエンティティを描画することを確認します。これは、効率性が高いためです。
Mesh(メッシュ)とは
これまでは、メッシュ (頂点の集まり)
とあるように、クラスのプロパティ(フィールド変数、属性のこと)を見てみるとわかりやすい。
<Meshクラス>
- numVertices:頂点の数
- vaoId: VAOのID
- vboIdList: VBOのIDリスト
つまり、面を表していると思います。
根拠としては、頂点数、VAO(VBOの集まり)、VBOのリストをフィールド変数として持っているため、
モデル、エンティティがほかに存在するのであれば役割として面となると判断したため「思います」と記述しました。
public class Mesh {
private int numVertices;
private int vaoId;
private List<Integer> vboIdList;
}
Entityとは
エンティティは常にモデルに関連付けられていると仮定します (ただし、レンダリングされていないエンティティを持つことができるため、モデルを持つことはできません)。エンティティには、レンダリング時に使用する必要がある位置などの特定のデータがあります。後ほど、モデルを取得してレンダリング プロセスを開始し、そのモデルに関連付けられたエンティティを描画することを確認します。これは、効率性が高いためです。
とあるように、やはり、フィールド変数(プロパティ)を確認します。
<Entityクラス>
- id: EntityのID
- modelId: ModelのID
- modelMatrix: Modelの位置、回転を制御する行列
- position: 位置を示す、3次元座標
- rotation: 回転を示す、クォータニオン
- scale: 大きさ、スケール
public class Entity {
private final String id;
private final String modelId;
private Matrix4f modelMatrix;
private Vector3f position;
private Quaternionf rotation;
private float scale;
}
Meshを組み合わせて、作成された1つのオブジェクトだと思います。このクラスが持っているプロパティ(フィールド変数)から推測しているレベルなのでこのような書き方になります。
クォータニオン(Quaternion)
回転と均一なスケーリングを表すことができる 4 つの単精度浮動小数点のクォータニオン。
モデルとは(追加補足)
モデルは、頂点、色、テクスチャ、およびマテリアルを接着する構造です。モデルは複数のメッシュで構成されている場合があり、複数のゲーム エンティティで使用できます。
そして、下のクラスを見ればわかるように、プロパティ(フィールド変数)には、「Entity(エンティティ)」と「Mesh(メッシュ)」があります。
<Model>
- **id***:モデルのID
- entitiesList: エンティティのリスト
- meshList: メッシュのリスト
モデルとエンティティ(コードに関して)
モデルを表すクラスは、明らかに呼び出されModel、次のように定義されます。
つまるところ、Entityを組み合わせた、一つのモデル、例えば、武器を持った戦士であれば、「人型のEntity + 武器Entity + 服Entity」のようになっていると思います。
package org.lwjglb.engine.graph;
import org.lwjglb.engine.scene.Entity;
import java.util.*;
public class Model {
private final String id;
private List<Entity> entitiesList;
private List<Mesh> meshList;
public Model(String id, List<Mesh> meshList) {
this.id = id;
this.meshList = meshList;
entitiesList = new ArrayList<>();
}
public void cleanup() {
meshList.stream().forEach(Mesh::cleanup);
}
public List<Entity> getEntitiesList() {
return entitiesList;
}
public String getId() {
return id;
}
public List<Mesh> getMeshList() {
return meshList;
}
}
ご覧のとおり、モデルは今のところ、Mesh一意の識別子を持つインスタンスのリストを格納しています。それに加えて、Entityそのモデルに関連付けられている (クラスによってモデル化された) ゲーム エンティティのリストを保存します。完全なエンジンを作成する場合は、それらの関係を (モデルではなく) 別の場所に保存することをお勧めしますが、簡単にするために、これらのリンクをModelクラスに保存します。これにより、レンダリング プロセスがより簡単になります。
がどのように見えるかを見る前に、Entityモデルの変換について少し説明しましょう。3D シーンで任意のモデルを表現するには、任意のモデルに作用するいくつかの基本的な操作をサポートする必要があります。
・平行移動: 3 つの軸のいずれかでオブジェクトをある程度移動します。
・回転: 3 つの軸のいずれかを中心にオブジェクトをある程度回転させます。
・スケール: オブジェクトのサイズを調整します。
上記の操作は、変換として知られています。そして、おそらく、これを達成する方法は、座標に一連の行列 (1 つは平行移動、1 つは回転、もう 1 つはスケーリング) を乗算することであると推測しているでしょう。これらの 3 つのマトリックスは、ワールド マトリックスと呼ばれる 1 つのマトリックスに結合され、頂点シェーダーに均一に渡されます。
ワールド マトリックスと呼ばれる理由は、モデル座標からワールド座標に変換するためです。3D モデルの読み込みについて学習すると、それらのモデルが独自の座標系で定義されていることがわかります。彼らはあなたの 3D 空間のサイズを知らず、そこに配置する必要があります。したがって、座標を行列で乗算すると、ある座標系 (モデルの座標系) から別の座標系 (3D 世界の座標系) に変換されます。
これらの操作を行うのに、元の行列(各座標を示すベクトル)に次の行列を乗算する方法がある
- 平行移動用の行列
- 回転用の行列
- スケーリング用の行列
そして、これらの行列(マトリックス)がある座標系 (モデルの座標系) から別の座標系 (3D 世界の座標系) に変換される
そのワールド行列は次のように計算されます (行列を使用した乗算は可換ではないため、順序は重要です)。
※「行列」=「マトリックス」
ワールドマトリックス=平行移動マトリックス 回転マトリックス スケーリングマトリックス
射影行列を変換行列に含めると、次のようになります。
*変換マトリックス=射影マトリックス ワールドマトリックス**
変換行列は次のように定義されます。
平行移動行列パラメータ:
・ dx: x 軸に沿った変位。
・ dy: y 軸に沿った変位。
・ dz: z 軸に沿った変位。
スケール マトリックスは次のように定義されます。
スケール マトリックス パラメータ:
・sx: x 軸に沿ったスケーリング。
・sy: y 軸に沿ったスケーリング。
・sz: z 軸に沿ったスケーリング。
回転行列はもっと複雑です。ただし、1 つの軸に対して 3 つの回転行列を乗算するか、クォータニオンを適用することで構築できることに注意してください (これについては後で詳しく説明します)。Entityそれでは、クラスを定義しましょう。
Entityクラス
package org.lwjglb.engine.scene;
import org.joml.*;
public class Entity {
private final String id;
private final String modelId;
private Matrix4f modelMatrix;
private Vector3f position;
private Quaternionf rotation;
private float scale;
public Entity(String id, String modelId) {
this.id = id;
this.modelId = modelId;
modelMatrix = new Matrix4f();
position = new Vector3f();
rotation = new Quaternionf();
scale = 1;
}
public String getId() {
return id;
}
public String getModelId() {
return modelId;
}
public Matrix4f getModelMatrix() {
return modelMatrix;
}
public Vector3f getPosition() {
return position;
}
public Quaternionf getRotation() {
return rotation;
}
public float getScale() {
return scale;
}
public final void setPosition(float x, float y, float z) {
position.x = x;
position.y = y;
position.z = z;
}
public void setRotation(float x, float y, float z, float angle) {
this.rotation.fromAxisAngleRad(x, y, z, angle);
}
public void setScale(float scale) {
this.scale = scale;
}
public void updateModelMatrix() {
modelMatrix.translationRotateScale(position, rotation, scale);
}
}
ご覧のとおりModelインスタンスには一意の識別子もあり、その位置 (3 つのコンポーネントのベクトルとして)、そのスケール (単なる float、3 つの軸すべてで均等にスケールすると仮定します)、および回転 (クォータニオンとして) の属性を定義します。ピッチ、ヨー、ロールの回転角度を格納する回転情報を格納できます。しかし、代わりに、聞いたことのないクォータニオンという名前の奇妙な数学的アーティファクトを使用しています。回転角度を使用する際の問題は、いわゆるジンバル ロックです。これらの角度 (オイラー角と呼ばれる) を使用して回転を適用すると、最終的に 2 つの回転軸が整列し、自由度が失われ、オブジェクトを適切に回転できなくなる場合があります。四元数にはこれらの問題はありません。四元数とは何かを下手に説明しようとする代わりに、le mw は優れたブログ エントリにリンクするだけです。それらの背後にあるすべての概念を説明しています。それらを深く掘り下げたくない場合は、オイラー角の問題なしに回転を表現できることを覚えておいてください.
モデルに適用されるすべての変換は 4x4 マトリックスによって定義されるため、インスタンスには、位置、スケール、および回転を使用して JOML メソッドによって自動的に構築されるインスタンスがModel格納されます。インスタンスの属性を変更してそのマトリックスを更新するたびに、メソッドを呼び出す必要があります。Matrix4ftranslationRotateScaleupdateModelMatrixModel
その他のコード変更
Scene直接Meshインスタンスではなく、モデルを格納するようにクラスを変更する必要があります。それに加えて、Entity後でレンダリングできるように、インスタンスをモデルにリンクするためのサポートを追加する必要があります。
つまり、Meshの段階でレンダリングを行わず、組み合わせて一つの形になった状態、先ほどの戦士の例であれば、武器、人(戦士)、それぞれの3Dオブジェクトの形になってからレンダリングを行うという認識です。※まだ推測のレベルを出ないです。。。
Sceneクラス
package org.lwjglb.engine.scene;
import org.lwjglb.engine.graph.Model;
import java.util.*;
public class Scene {
private Map<String, Model> modelMap;
private Projection projection;
public Scene(int width, int height) {
modelMap = new HashMap<>();
projection = new Projection(width, height);
}
public void addEntity(Entity entity) {
String modelId = entity.getModelId();
Model model = modelMap.get(modelId);
if (model == null) {
throw new RuntimeException("Could not find model [" + modelId + "]");
}
model.getEntitiesList().add(entity);
}
public void addModel(Model model) {
modelMap.put(model.getId(), model);
}
public void cleanup() {
modelMap.values().stream().forEach(Model::cleanup);
}
public Map<String, Model> getModelMap() {
return modelMap;
}
public Projection getProjection() {
return projection;
}
public void resize(int width, int height) {
projection.updateProjMatrix(width, height);
}
}
SceneRenderクラス
ここで、SceneRenderクラスを少し変更する必要があります。最初に行う必要があるのは、ユニフォームを介してモデル マトリックス情報をシェーダーに渡すことです。したがって、頂点シェーダーで名前が付けられた新しいユニフォームを作成しmodelMatrix、その結果、createUniformsメソッドでその場所を取得します。
public class SceneRender {
...
private void createUniforms() {
...
uniformsMap.createUniform("modelMatrix");
}
...
}
次のステップは、renderメソッドを変更して、モデルへのアクセス方法を変更し、モデル マトリックス ユニフォームを適切に設定することです。
public class SceneRender {
...
public void render(Scene scene) {
shaderProgram.bind();
uniformsMap.setUniform("projectionMatrix", scene.getProjection().getProjMatrix());
Collection<Model> models = scene.getModelMap().values();
for (Model model : models) {
model.getMeshList().stream().forEach(mesh -> {
glBindVertexArray(mesh.getVaoId());
List<Entity> entities = model.getEntitiesList();
for (Entity entity : entities) {
uniformsMap.setUniform("modelMatrix", entity.getModelMatrix());
glDrawElements(GL_TRIANGLES, mesh.getNumVertices(), GL_UNSIGNED_INT, 0);
}
});
}
glBindVertexArray(0);
shaderProgram.unbind();
}
...
}
ご覧のとおり、モデルを反復処理してからメッシュを反復処理し、VAO をバインドしてから、関連付けられたエンティティを取得します。エンティティごとに、描画呼び出しを呼び出す前に、modelMatrixユニフォームに適切なデータを入力します。
modelMatrix次のステップは、ユニフォームを使用するように頂点シェーダーを変更することです。
<scene.vert>
#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec3 color;
out vec3 outColor;
uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;
void main()
{
gl_Position = projectionMatrix * modelMatrix * vec4(position, 1.0);
outColor = color;
}
ご覧のとおり、コードはまったく同じです。ユニフォームを使用して、錐台、位置、スケール、および回転情報を考慮して、座標を正しく投影しています。考慮すべきもう 1 つの重要な点は、ワールド マトリックスに結合する代わりに、平行移動、回転、スケール マトリックスを渡してはどうかということです。その理由は、シェーダーで使用するマトリックスを制限する必要があるためです。また、シェーダーで実行する行列の乗算は、頂点ごとに 1 回実行されることに注意してください。プロジェクション マトリックスはレンダー コール間で変化せず、ワールド マトリックスはEntityインスタンスごとに変化しません。平行移動、回転、スケールの行列を個別に渡すと、さらに多くの行列の乗算を行うことになります。多数の頂点を持つモデルについて考えてみてください。それは多くの余分な操作です。
しかし、モデルの行列がEntityインスタンスごとに変わらないのなら、なぜ Java クラスで行列の乗算を行わなかったのでしょうか? 射影行列とモデル行列を 1 回だけ乗算し、Entityそれを 1 つのユニフォームとして送信できます。この場合、さらに多くの操作を節約できますよね? 答えは、これは今のところ有効な点ですが、ゲーム エンジンにさらに機能を追加すると、とにかくシェーダーでワールド座標を操作する必要があるため、これら 2 つのマトリックスを独立した方法で処理することをお勧めします。
ここでの「2つのマトリックス」は以下のものになります。
- ワールド マトリックス:
- スケール マトリックス:
最後に、注目すべきもう 1 つの非常に重要な側面は、行列の乗算の順序です。まず位置情報をモデル マトリックスで乗算する必要があります。つまり、ワールド空間でモデル座標を変換します。その後、投影を適用します。行列の乗算は可換ではないため、順序が非常に重要であることに注意してください。
Main次に、モデルとエンティティをロードする新しい方法と、3D キューブのインデックスの座標に適応するようにクラスを変更する必要があります。
public class Main implements IAppLogic {
private Entity cubeEntity;
private Vector4f displInc = new Vector4f();
private float rotation;
public static void main(String[] args) {
...
Engine gameEng = new Engine("chapter-06", new Window.WindowOptions(), main);
...
}
...
@Override
public void init(Window window, Scene scene, Render render) {
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,
};
float[] colors = 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,
};
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,
};
List<Mesh> meshList = new ArrayList<>();
Mesh mesh = new Mesh(positions, colors, indices);
meshList.add(mesh);
String cubeModelId = "cube-model";
Model model = new Model(cubeModelId, meshList);
scene.addModel(model);
cubeEntity = new Entity("cube-entity", cubeModelId);
cubeEntity.setPosition(0, 0, -2);
scene.addEntity(cubeEntity);
}
...
}
立方体を描画するには、8 つの頂点を定義する必要があります。さらに 4 つの頂点があるため、色の配列を更新する必要があります。
立方体は 6 つの面で構成されているため、12 個の三角形 (面ごとに 2 つ) を描画する必要があるため、インデックス配列を更新する必要があります。三角形は反時計回りの順序で定義する必要があることに注意してください。これを手作業で行うと、間違いを犯しやすくなります。インデックスを定義したい面は常に目の前に置いてください。次に、頂点を特定し、三角形を反時計回りに描画します。最後に、メッシュが 1 つだけのモデルと、そのモデルに関連付けられたエンティティを作成します。
最初にinputメソッドを使用して、カーソル矢印を使用して立方体の位置を変更し、 キーを使用してそのスケールを変更しZますX。押されたキーを検出し、キューブ エンティティの位置やスケールを更新し、最後にそのモデル マトリックスを更新するだけです。
public class Main implements IAppLogic {
...
public void input(Window window, Scene scene, long diffTimeMillis) {
displInc.zero();
if (window.isKeyPressed(GLFW_KEY_UP)) {
displInc.y = 1;
} else if (window.isKeyPressed(GLFW_KEY_DOWN)) {
displInc.y = -1;
}
if (window.isKeyPressed(GLFW_KEY_LEFT)) {
displInc.x = -1;
} else if (window.isKeyPressed(GLFW_KEY_RIGHT)) {
displInc.x = 1;
}
if (window.isKeyPressed(GLFW_KEY_A)) {
displInc.z = -1;
} else if (window.isKeyPressed(GLFW_KEY_Q)) {
displInc.z = 1;
}
if (window.isKeyPressed(GLFW_KEY_Z)) {
displInc.w = -1;
} else if (window.isKeyPressed(GLFW_KEY_X)) {
displInc.w = 1;
}
displInc.mul(diffTimeMillis / 1000.0f);
Vector3f entityPos = cubeEntity.getPosition();
cubeEntity.setPosition(displInc.x + entityPos.x, displInc.y + entityPos.y, displInc.z + entityPos.z);
cubeEntity.setScale(cubeEntity.getScale() + displInc.w);
cubeEntity.updateModelMatrix();
}
...
}
立方体を見やすくするために、Mainクラス内のモデルを回転させるコードを 3 つの軸に沿って回転するように変更します。メソッドでこれを行いupdateます。
public class Main implements IAppLogic {
...
public void update(Window window, Scene scene, long diffTimeMillis) {
rotation += 1.5;
if (rotation > 360) {
rotation = 0;
}
cubeEntity.setRotation(1, 1, 1, (float) Math.toRadians(rotation));
cubeEntity.updateModelMatrix();
}
...
}
それだけです。回転する 3D 立方体を表示できるようになりました。サンプルをコンパイルして実行すると、このような結果が得られます。
この立方体には何か変なところがあります。一部の面が正しくペイントされていません。何が起こっている?立方体にこのような側面があるのは、立方体を構成する三角形がランダムな順序で描かれているためです。遠くにあるピクセルは、近くにあるピクセルの前に描画する必要があります。これは現在発生していません。そのためには、深度テストを有効にする必要があります。
Renderこれは、クラスのコンストラクターで実行できます。
public class Render {
...
public Render() {
GL.createCapabilities();
glEnable(GL_DEPTH_TEST);
...
}
...
}
これで、キューブが正しくレンダリングされました!
<動かしてみました>