Java 3D LWJGL GitBook 〜Chapter10:ImGui を使用した GUI 描画〜

Imgui を使用した GUI 描画

今まで、3Dモデルの描画を中心に学習してきましたが、GUI作成の処理を学習します。

Dear ImGui は、OpenGL や Vulkan などの複数のバックエンドを使用できるユーザー インターフェイス ライブラリです。これを使用して、GUI コントロールを表示したり、HUD を開発したりします。複数のウィジェットを提供し、外観は簡単にカスタマイズできます。

参照するドキュメントとプログラムソースへのリンクは以下になります。

やはり、ライブラリを追加しますのでPOMファイルに追記します。

最初に、Java Imgui ラッパーの maven 依存関係をプロジェクト pom.xml に追加します。コンパイル時と実行時の依存関係を追加する必要があります。

<dependency>
   <groupId>io.github.spair</groupId>
   <artifactId>imgui-java-binding</artifactId>
   <version>${imgui-java.version}</version>
</dependency>
<dependency>
    <groupId>io.github.spair</groupId>
    <artifactId>imgui-java-${native.target}</artifactId>
    <version>${imgui-java.version}</version>
    <scope>runtime</scope>
</dependency>

Imgui を使用すると、2D 形状のみを使用して、他の 3D モデルをレンダリングするように、ウィンドウ、パネルなどをレンダリングできます。使用するコントロールを設定すると、Imgui がそれを、シェーダーを使用してレンダリングできる一連の頂点バッファーに変換します。これが、あらゆるバックエンドで使用できる理由です。

各頂点に対して、Imgui はその座標 (2D 座標)、テクスチャ座標、および関連する色を定義します。したがって、Gui メッシュをモデル化し、関連する VAO と VBO を作成する新しいクラスを作成する必要があります。という名前のクラスは、GuiMesh次のように定義されます。

こんな感じの表示が行われます。

GuiMeshの作成

package org.lwjglb.engine.graph;

import imgui.ImDrawData;

import static org.lwjgl.opengl.GL15.*;
import static org.lwjgl.opengl.GL20.*;
import static org.lwjgl.opengl.GL30.*;

public class GuiMesh {

    private int indicesVBO;
    private int vaoId;
    private int verticesVBO;

    public GuiMesh() {
        vaoId = glGenVertexArrays();
        glBindVertexArray(vaoId);

        // Single VBO
        verticesVBO = glGenBuffers();
        glBindBuffer(GL_ARRAY_BUFFER, verticesVBO);
        glEnableVertexAttribArray(0);
        glVertexAttribPointer(0, 2, GL_FLOAT, false, ImDrawData.SIZEOF_IM_DRAW_VERT, 0);
        glEnableVertexAttribArray(1);
        glVertexAttribPointer(1, 2, GL_FLOAT, false, ImDrawData.SIZEOF_IM_DRAW_VERT, 8);
        glEnableVertexAttribArray(2);
        glVertexAttribPointer(2, 4, GL_UNSIGNED_BYTE, true, ImDrawData.SIZEOF_IM_DRAW_VERT, 16);

        indicesVBO = glGenBuffers();

        glBindBuffer(GL_ARRAY_BUFFER, 0);
        glBindVertexArray(0);
    }

    public void cleanup() {
        glDeleteBuffers(indicesVBO);
        glDeleteBuffers(verticesVBO);
        glDeleteVertexArrays(vaoId);
    }

    public int getIndicesVBO() {
        return indicesVBO;
    }

    public int getVaoId() {
        return vaoId;
    }

    public int getVerticesVBO() {
        return verticesVBO;
    }
}

ご覧のとおり、単一の VBO を使用していますが、位置、テクスチャ座標、および色に対していくつかの属性を定義しています。この場合、バッファにデータを入力しません。使用方法については後で説明します。

IGuiInstanceの作成

また、アプリケーションが GUI コントロールを作成し、ユーザー入力に反応できるようにする必要もあります。これをサポートするために、IGuiInstance次のように定義される名前の新しいインターフェースを定義します。

package org.lwjglb.engine;

import org.lwjglb.engine.scene.Scene;

public interface IGuiInstance {
    void drawGui();

    boolean handleGuiInput(Scene scene, Window window);
}

Sceneの更新

このメソッドは、drawGuiGUI の構築に使用されます。ここで、GUI メッシュの構築に使用されるウィンドウとウィジェットを定義します。このメソッドを使用して、handleGuiInputGUI で入力イベントを処理します。入力が GUI によって処理されたかどうかを示すブール値を返します。たとえば、オーバーラップ ウィンドウを表示する場合、ゲーム ロジックでキーストロークを処理し続けることに関心がない場合があります。戻り値を使用してそれを制御できます。IGuiInstanceインターフェイスの特定の実装をクラスに格納しますScene。

public class Scene {
    ...
    private IGuiInstance guiInstance;
    ...
    public IGuiInstance getGuiInstance() {
        return guiInstance;
    }
    ...
    public void setGuiInstance(IGuiInstance guiInstance) {
        this.guiInstance = guiInstance;
    }
}

GuiRenderの作成

次のステップは、GUI をレンダリングするための新しいクラスを作成することですGuiRender。

package org.lwjglb.engine.graph;

import imgui.*;
import imgui.type.ImInt;
import org.joml.Vector2f;
import org.lwjglb.engine.*;
import org.lwjglb.engine.scene.Scene;

import java.nio.ByteBuffer;
import java.util.*;

import static org.lwjgl.opengl.GL32.*;

public class GuiRender {

    private GuiMesh guiMesh;
    private Vector2f scale;
    private ShaderProgram shaderProgram;
    private Texture texture;
    private UniformsMap uniformsMap;

    public GuiRender(Window window) {
        List<ShaderProgram.ShaderModuleData> shaderModuleDataList = new ArrayList<>();
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/gui.vert", GL_VERTEX_SHADER));
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/gui.frag", GL_FRAGMENT_SHADER));
        shaderProgram = new ShaderProgram(shaderModuleDataList);
        createUniforms();
        createUIResources(window);
    }

    public void cleanup() {
        shaderProgram.cleanup();
        texture.cleanup();
    }
    ...
}

ご覧のとおり、ここにあるもののほとんどは非常になじみ深いもので、シェーダーとユニフォームをセットアップしただけです。createUIResourcesただし、次のように定義された新しいメソッドが呼び出されます。

public class GuiRender {
    ...
    private void createUIResources(Window window) {
        ImGui.createContext();

        ImGuiIO imGuiIO = ImGui.getIO();
        imGuiIO.setIniFilename(null);
        imGuiIO.setDisplaySize(window.getWidth(), window.getHeight());

        ImFontAtlas fontAtlas = ImGui.getIO().getFonts();
        ImInt width = new ImInt();
        ImInt height = new ImInt();
        ByteBuffer buf = fontAtlas.getTexDataAsRGBA32(width, height);
        texture = new Texture(width.get(), height.get(), buf);

        guiMesh = new GuiMesh();
    }
    ...
}

上記の方法では、Imgui をセットアップします。最初にコンテキスト (操作を実行するために必要) を作成し、表示サイズをウィンドウ サイズに設定します。Imgui はステータスを ini ファイルに保存します。実行間でステータスを保持したくないため、null に設定する必要があります。次のステップは、フォント アトラスを初期化し、シェーダーで使用されるテクスチャを設定して、テキストなどを適切にレンダリングできるようにすることです。最後のステップは、インスタンスを作成することですGuiMesh。

は、createUniformsスケール用に単一の 2 つのフロートを作成するだけです (使用方法については後で説明します)。

public class GuiRender {
    ...
    private void createUniforms() {
        uniformsMap = new UniformsMap(shaderProgram.getProgramId());
        uniformsMap.createUniform("scale");
        scale = new Vector2f();
    }
    ...
}

メソッドを見てみましょうrender。

public class GuiRender {
    ...
    public void render(Scene scene) {
        IGuiInstance guiInstance = scene.getGuiInstance();
        if (guiInstance == null) {
            return;
        }
        guiInstance.drawGui();

        shaderProgram.bind();

        glEnable(GL_BLEND);
        glBlendEquation(GL_FUNC_ADD);
        glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
        glDisable(GL_DEPTH_TEST);
        glDisable(GL_CULL_FACE);

        glBindVertexArray(guiMesh.getVaoId());

        glBindBuffer(GL_ARRAY_BUFFER, guiMesh.getVerticesVBO());
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, guiMesh.getIndicesVBO());

        ImGuiIO io = ImGui.getIO();
        scale.x = 2.0f / io.getDisplaySizeX();
        scale.y = -2.0f / io.getDisplaySizeY();
        uniformsMap.setUniform("scale", scale);

        ImDrawData drawData = ImGui.getDrawData();
        int numLists = drawData.getCmdListsCount();
        for (int i = 0; i < numLists; i++) {
            glBufferData(GL_ARRAY_BUFFER, drawData.getCmdListVtxBufferData(i), GL_STREAM_DRAW);
            glBufferData(GL_ELEMENT_ARRAY_BUFFER, drawData.getCmdListIdxBufferData(i), GL_STREAM_DRAW);

            int numCmds = drawData.getCmdListCmdBufferSize(i);
            for (int j = 0; j < numCmds; j++) {
                final int elemCount = drawData.getCmdListCmdBufferElemCount(i, j);
                final int idxBufferOffset = drawData.getCmdListCmdBufferIdxOffset(i, j);
                final int indices = idxBufferOffset * ImDrawData.SIZEOF_IM_DRAW_IDX;

                texture.bind();
                glDrawElements(GL_TRIANGLES, elemCount, GL_UNSIGNED_SHORT, indices);
            }
        }

        glEnable(GL_DEPTH_TEST);
        glEnable(GL_CULL_FACE);
        glDisable(GL_BLEND);
    }
    ...
}

最初に行うことは、インターフェースの実装をセットアップしたかどうかを確認することですIGuiInstance。インスタンスがない場合は、何もレンダリングする必要はなく、単に戻ります。その後、drawGuiメソッドを呼び出します。つまり、各レンダー呼び出しでそのメソッドを呼び出して、Imgui がそのステータスを更新し、適切な頂点データを生成できるようにします。シェーダーをバインドした後、まず透明度を使用できるブレンドを有効にします。ブレンディングを有効にするだけでは、透明度は表示されません。ブレンディングがどのように適用されるかについて、OpenGL に指示する必要もあります。これは、関数を介して行われますglBlendFunc。ここで適用できるさまざまな機能の詳細についての優れた説明を確認できます。

その後、Imgui が適切に機能するように深度テストと顔カリングを無効にする必要があります。次に、データの構造を定義する gui メッシュをバインドし、データとインデックス バッファーをバインドします。Imgui は画面座標を使用して頂点データを生成します。つまり、x値は[0, screen width]範囲をカバーし、y値は をカバーします[0, screen height]。ユニフォームを使用してscale、その座標系から[-1, 1]OpenGL のクリップ スペースの範囲にマップします。

その後、Imgui によって生成されたデータを取得して、GUI をレンダリングします。IMgui はまず、コマンド リストと呼ばれるものにデータを整理します。各コマンド リストには、頂点とインデックスのデータを格納するバッファーがあるため、最初に を呼び出してデータを GPU にダンプしますglBufferData。各コマンド リストは、ドロー コールを生成するために使用するコマンドのセットとしても定義します。各コマンドは、描画される要素の数とバッファに適用されるオフセットをコマンド リストに保存します。すべての要素を描画したら、深度テストを再度有効にできます。

resize最後に、 Imgui の表示サイズを調整するためにウィンドウのサイズが変更されるたびに呼び出されるメソッドを追加する必要があります。

public class GuiRender {
    ...
    public void resize(int width, int height) {
        ImGuiIO imGuiIO = ImGui.getIO();
        imGuiIO.setDisplaySize(width, height);
    }
}

UniformsMapの更新

UniformsMap2D ベクトルのサポートを追加するには、クラスを更新する必要があります。

public class UniformsMap {
    ...
    public void setUniform(String uniformName, Vector2f value) {
        glUniform2f(getUniformLocation(uniformName), value.x, value.y);
    }
}

scene.flagの更新

GUI のレンダリングに使用される頂点シェーダーは非常に単純です ( gui.vert)。座標が範囲内に収まるように座標を変換し[-1, 1]、フラグメント シェーダーで使用できるようにテクスチャ座標と色を出力するだけです。

#version 330

layout (location=0) in vec2 inPos;
layout (location=1) in vec2 inTextCoords;
layout (location=2) in vec4 inColor;

out vec2 frgTextCoords;
out vec4 frgColor;

uniform vec2 scale;

void main()
{
    frgTextCoords = inTextCoords;
    frgColor = inColor;
    gl_Position = vec4(inPos * scale + vec2(-1.0, 1.0), 0.0, 1.0);
}

フラグメント シェーダー ( gui.frag) では、テクスチャ座標に関連付けられた頂点カラーとテクスチャ カラーの組み合わせを出力するだけです。

#version 330

in vec2 frgTextCoords;
in vec4 frgColor;

uniform sampler2D txtSampler;

out vec4 outColor;

void main()
{
    outColor = frgColor  * texture(txtSampler, frgTextCoords);
}

すべてをまとめる

全体的には下のような感じです。

ここで、GUI をレンダリングするために、以前のすべての写真を接着する必要があります。最初に、新しいGuiRenderクラスをそのクラスに使用することから始めますRender。

public class Render {
    ...
    private GuiRender guiRender;
    ...
    public Render(Window window) {
        ...
        guiRender = new GuiRender(window);
    }

    public void cleanup() {
        ...
        guiRender.cleanup();
    }

    public void render(Window window, Scene scene) {
        ...
        guiRender.render(scene);
    }

    public void resize(int width, int height) {
        guiRender.resize(width, height);
    }
}

また、更新ループにEngine含めるようにクラスを変更し、その戻り値を使用して入力が消費されたかどうかを示す必要があります。IGuiInstance

public class Engine {
    ...
    public Engine(String windowTitle, Window.WindowOptions opts, IAppLogic appLogic) {
        ...
        render = new Render(window);
        ...
    }
    ...
    private void resize() {
        int width = window.getWidth();
        int height = window.getHeight();
        scene.resize(width, height);
        render.resize(width, height);
    }

    private void run() {
        ...
        IGuiInstance iGuiInstance = scene.getGuiInstance();
        while (running && !window.windowShouldClose()) {
            ...
            if (targetFps <= 0 || deltaFps >= 1) {
                boolean inputConsumed = iGuiInstance != null ? iGuiInstance.handleGuiInput(scene, window) : false;
                appLogic.input(window, scene, now - initialTime, inputConsumed);
            }
            ...
        }
        ...
    }
    ...
}

入力消費された戻り値を使用するようにインターフェイスを更新する必要もありますIAppLogic。

public interface IAppLogic {
    ...
    void update(Window window, Scene scene, long diffTimeMillis);
    ...
}

ImGuiの描画部分

最後に、IGuiInstance in the Main` クラスを実装します。

ImGuiはでも画面の作成メソッドが実装されています。それが ImGui.showDemoWindow();です。
画面の作成方法は、「ImGui」クラスからstaticメソッドを呼び出して作成します。
詳細はこちらの記事に記載しています。

ImGuiのハローワールド(デモ表示)

ImGUiをいじってみる

ハローワールドの次

public class Main implements IAppLogic, IGuiInstance {
    ...
    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-10", new Window.WindowOptions(), main);
        ...
    }

    ...
    @Override
    public void drawGui() {
        ImGui.newFrame();
        ImGui.setNextWindowPos(0, 0, ImGuiCond.Always);
        ImGui.showDemoWindow();
        ImGui.endFrame();
        ImGui.render();
    }

    @Override
    public boolean handleGuiInput(Scene scene, Window window) {
        ImGuiIO imGuiIO = ImGui.getIO();
        MouseInput mouseInput = window.getMouseInput();
        Vector2f mousePos = mouseInput.getCurrentPos();
        imGuiIO.setMousePos(mousePos.x, mousePos.y);
        imGuiIO.setMouseDown(0, mouseInput.isLeftButtonPressed());
        imGuiIO.setMouseDown(1, mouseInput.isRightButtonPressed());

        return imGuiIO.getWantCaptureMouse() || imGuiIO.getWantCaptureKeyboard();
    }
    ...
    public void input(Window window, Scene scene, long diffTimeMillis, boolean inputConsumed) {
        ...
    }
}

このdrawGuiメソッドでは、新しいフレームとウィンドウの位置を設定し、 を呼び出してshowDemoWindowImgui のデモ ウィンドウを生成するだけです。フレームを終了した後、これを呼び出すことが非常に重要ですrender。これは、以前に定義された GUI 構造で一連のコマンドを生成するものです。1handleGuiInputつ目は、マウスの位置を取得し、その情報とマウス ボタンの状態で IMgui の IO クラスを更新します。入力が Imgui によってキャプチャされたことを示すブール値も返します。最後に、そのフラグを受け取るようにメソッドを更新する必要がありますinput(まだ何もしていませんが、実装しているインターフェイスにあります)。

これらすべての変更により、Imgui デモ ウィンドウが回転する立方体に重なっているのを確認できます。パネルのさまざまな方法を操作して、Imgui の機能を垣間見ることができます。

動かしてみた(IntelliJ IDEA)

Java 3D LWJGL GitBook 〜Chapter09:より複雑なモデルのロード〜

より複雑なモデルのロード

無料で配布している3Dモデルなど6章(Chapter06)で学習した3Dモデルのロード方法とは別の方法でロードする必要があります。

参照するドキュメントとプログラムソースへのリンクは以下になります。

ゲームを作成するには、複雑な 3D モデルをさまざまな形式で読み込む機能が不可欠です。それらの一部のパーサーを作成するには、多くの作業が必要になります。1 つのフォーマットをサポートするだけでも、時間がかかる場合があります。幸いなことに、Assimpライブラリは、多くの一般的な 3D 形式を解析するために既に使用できます。これは、静的およびアニメーション モデルをさまざまな形式でロードできる C/C++ ライブラリです。LWJGL は、Java コードからそれらを使用するためのバインディングを提供します。この章では、その使用方法について説明します。

プログラムを実行したときに出たエラーメッセージ

[LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash.

これは、最新のJDKを使用しないようにすることで解消しました。※詳細はこちら

クラス図

概要

Assimpライブラリを使用して、今までよりも複雑な、具体的には、3dモデルファイルを読み込む形で3Dモデルを表示できるようにプログラムを改造するというところです。
この章では、以下のような手順で説明しています。

  1. モデルローダークラスの作成
  2. Materialクラス、SceneRenderクラスの修正
  3. Renderクラスを周世押して、顔カリングを有効にする

LWJGLのJavaDocを見て、モデルローダーの処理内容を理解する

モデルローダー

3Dモデルを読み込むためのクラスを作成します。

最初に、プロジェクト pom.xml に Assimp maven の依存関係を追加します。コンパイル時と実行時の依存関係を追加する必要があります。

<dependency>
    <groupId>org.lwjgl</groupId>
    <artifactId>lwjgl-assimp</artifactId>
    <version>${lwjgl.version}</version>
</dependency>
<dependency>
    <groupId>org.lwjgl</groupId>
    <artifactId>lwjgl-assimp</artifactId>
    <version>${lwjgl.version}</version>
    <classifier>${native.target}</classifier>
    <scope>runtime</scope>
</dependency>

ModelLoader#loadModel()

ModelLoader依存関係が設定されたら、 Assimp でモデルをロードするために使用されるという名前の新しいクラスを作成します。このクラスは、次の 2 つの静的パブリック メソッドを定義します。

package org.lwjglb.engine.scene;

import org.joml.Vector4f;
import org.lwjgl.PointerBuffer;
import org.lwjgl.assimp.*;
import org.lwjgl.system.MemoryStack;
import org.lwjglb.engine.graph.*;

import java.io.File;
import java.nio.IntBuffer;
import java.util.*;

import static org.lwjgl.assimp.Assimp.*;

public class ModelLoader {

    private ModelLoader() {
        // Utility class
    }

    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache) {
        return loadModel(modelId, modelPath, textureCache, aiProcess_GenSmoothNormals | aiProcess_JoinIdenticalVertices |
                aiProcess_Triangulate | aiProcess_FixInfacingNormals | aiProcess_CalcTangentSpace | aiProcess_LimitBoneWeights |
                aiProcess_PreTransformVertices);

    }

    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {
        ...

    }
    ...
}

どちらのメソッドにも次の引数があります。

変数名 説明
modelId ロードするモデルの一意の識別子。
modelPath モデル ファイルが配置されているファイルへのパス。
textureCache 同じテクスチャを複数回ロードすることを避けるためのテクスチャ キャッシュへの参照。

【modelPathの補足】

これは通常のファイル パスであり、CLASSPATH の相対パスではありません。Assimp は追加のファイルをロードする必要があり、同じベース パスを使用する可能性があるためですmodelPath(たとえば、wavefront のマテリアル ファイル、OBJ、ファイル)。リソースを JAR ファイル内に埋め込むと、Assimp はそれをインポートできないため、ファイル システム パスである必要があります。テクスチャをロードするとき、テクスチャをロードするためにモデルが配置されているベース ディレクトリを取得するために使用modelPathします (モデルで定義されているパスをオーバーライドします)。一部のモデルには、明らかにアクセスできない、モデルが開発された場所のローカル フォルダーへの絶対パスが含まれているため、これを行います。

【loadModel()オーバーロードしている方】
public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {

2 番目のメソッドには、 という名前の追加の引数がありflagsます。このパラメータを使用すると、ロード プロセスを調整できます。最初のメソッドは 2 番目のメソッドを呼び出し、ほとんどの状況で役立ついくつかの値を渡します。

変数名 説明
aiProcess_JoinIdenticalVertices このフラグは、使用される頂点の数を減らし、面間で再利用できる頂点を識別します。
aiProcess_Triangulate モデルは、クワッドまたはその他のジオメトリを使用して要素を定義する場合があります。三角形のみを扱っているため、このフラグを使用して、彼の面をすべて三角形に分割する必要があります (必要な場合)。
aiProcess_FixInfacingNormals このフラグは、内側を向いている法線を反転させようとします。
aiProcess_CalcTangentSpace ライトを実装するときにこのパラメーターを使用しますが、基本的には法線情報を使用してタンジェントとバイタンジェントを計算します。
aiProcess_LimitBoneWeights アニメーションを実装するときにこのパラメーターを使用しますが、基本的には 1 つの頂点に影響を与えるウェイトの数を制限します。
aiProcess_PreTransformVertices このフラグは、ロードされたデータに対して何らかの変換を実行するため、モデルは原点に配置され、座標は数学 OpenGL 座標系に修正されます。回転したモデルに問題がある場合は、必ずこのフラグを使用してください。重要: モデルがアニメーションを使用している場合は、このフラグを使用しないでください。このフラグはその情報を削除します。

使用できるフラグは他にもたくさんあります。LWJGL または Assimp のドキュメントで確認できます。

ModelLoader#コンストラクタ

2 番目のコンストラクターに戻りましょう。最初に行うことは、メソッドを呼び出してaiImportFile、選択したフラグでモデルをロードすることです。

public class ModelLoader {
    ...
    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {
        File file = new File(modelPath);
        if (!file.exists()) {
            throw new RuntimeException("Model path does not exist [" + modelPath + "]");
        }
        String modelDir = file.getParent();

        AIScene aiScene = aiImportFile(modelPath, flags);
        if (aiScene == null) {
            throw new RuntimeException("Error loading model [modelPath: " + modelPath + "]");
        }
        ...
    }
    ...
}

コンストラクターの残りのコードは次のとおりです。

public class ModelLoader {
    ...
    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {
        ...
        int numMaterials = aiScene.mNumMaterials();
        List<Material> materialList = new ArrayList<>();
        for (int i = 0; i < numMaterials; i++) {
            AIMaterial aiMaterial = AIMaterial.create(aiScene.mMaterials().get(i));
            materialList.add(processMaterial(aiMaterial, modelDir, textureCache));
        }

        int numMeshes = aiScene.mNumMeshes();
        PointerBuffer aiMeshes = aiScene.mMeshes();
        Material defaultMaterial = new Material();
        for (int i = 0; i < numMeshes; i++) {
            AIMesh aiMesh = AIMesh.create(aiMeshes.get(i));
            Mesh mesh = processMesh(aiMesh);
            int materialIdx = aiMesh.mMaterialIndex();
            Material material;
            if (materialIdx >= 0 && materialIdx < materialList.size()) {
                material = materialList.get(materialIdx);
            } else {
                material = defaultMaterial;
            }
            material.getMeshList().add(mesh);
        }

        if (!defaultMaterial.getMeshList().isEmpty()) {
            materialList.add(defaultMaterial);
        }

        return new Model(modelId, materialList);
    }
    ...
}

<補足>
オブジェクト:早い話が、インスタンスのこと
モデル:メッシュとマテリアルを統合したオブジェクト
マテリアル:メッシュで使用される色とテクスチャを定義(表す)したオブジェクト
メッシュ:形を作る頂点、面を表すオブジェクト

マテリアルを処理

モデルに含まれるマテリアルを処理します。マテリアルは、モデルを構成するメッシュで使用される色とテクスチャを定義します。次に、さまざまなメッシュを処理します。モデルは複数のメッシュを定義でき、各メッシュはモデルに定義されたマテリアルの 1 つを使用できます。これが、レンダリング時にバインディング呼び出しを繰り返さないように、マテリアルとリンクの後にメッシュを処理する理由です。

上記のコードを調べると、Assimp ライブラリへの呼び出しの多くがPointerBufferインスタンスを返すことがわかります。それらは C ポインターのように考えることができます。データを含むメモリー領域を指すだけです。それらを処理するには、それらが保持するデータのタイプを事前に知る必要があります。マテリアルの場合、そのバッファを繰り返し処理して、AIMaterialクラスのインスタンスを作成します。AIMesh2 番目のケースでは、クラスのインスタンスを作成するメッシュ データを保持するバッファーを反復処理します。

processMaterialその方法を調べてみましょう。

public class ModelLoader {
    ...
    private static Material processMaterial(AIMaterial aiMaterial, String modelDir, TextureCache textureCache) {
        Material material = new Material();
        try (MemoryStack stack = MemoryStack.stackPush()) {
            AIColor4D color = AIColor4D.create();

            int result = aiGetMaterialColor(aiMaterial, AI_MATKEY_COLOR_DIFFUSE, aiTextureType_NONE, 0,
                    color);
            if (result == aiReturn_SUCCESS) {
                material.setDiffuseColor(new Vector4f(color.r(), color.g(), color.b(), color.a()));
            }

            AIString aiTexturePath = AIString.calloc(stack);
            aiGetMaterialTexture(aiMaterial, aiTextureType_DIFFUSE, 0, aiTexturePath, (IntBuffer) null,
                    null, null, null, null, null);
            String texturePath = aiTexturePath.dataString();
            if (texturePath != null && texturePath.length() > 0) {
                material.setTexturePath(modelDir + File.separator + new File(texturePath).getName());
                textureCache.createTexture(material.getTexturePath());
                material.setDiffuseColor(Material.DEFAULT_COLOR);
            }

            return material;
        }
    }
    ...
}

最初にマテリアル カラーを取得します。この場合は、(AI_MATKEY_COLOR_DIFFUSEフラグを設定して) ディフューズ カラーを取得します。ライトを適用するときに使用する色にはさまざまな種類があります。たとえば、ディフューズ、アンビエント (アンビエント ライト用)、スペキュラー (ライトのスペキュラー ファクター用) などがあります。その後、マテリアルがテクスチャまたはテクスチャを定義しているかどうかを確認します。そうでない場合、つまりテクスチャ パスが存在する場合、テクスチャ パスを保存し、テクスチャの作成をTexturCache前の例のようにクラス。この場合、マテリアルがテクスチャを定義する場合、拡散色をデフォルト値の黒に設定します。これにより、テクスチャの有無をチェックせずに、ディフューズ カラーとテクスチャの両方の値を使用できるようになります。モデルでテクスチャが定義されていない場合は、マテリアル カラーと組み合わせることができるデフォルトの黒のテクスチャを使用します。

processMeshメソッドはこのように定義されています。

public class ModelLoader {
    ...
    private static Mesh processMesh(AIMesh aiMesh) {
        float[] vertices = processVertices(aiMesh);
        float[] textCoords = processTextCoords(aiMesh);
        int[] indices = processIndices(aiMesh);

        // Texture coordinates may not have been populated. We need at least the empty slots
        if (textCoords.length == 0) {
            int numElements = (vertices.length / 3) * 2;
            textCoords = new float[numElements];
        }

        return new Mesh(vertices, textCoords, indices);
    }
    ...
}

Meshの処理

AMeshは、頂点の位置、テクスチャ座標、およびインデックスのセットによって定義されます。これらの各要素はprocessVertices、 processTextCoordsおよびprocessIndicesメソッドで処理されます。すべてのデータを処理した後、テクスチャ座標が定義されているかどうかを確認します。そうでない場合は、テクスチャ座標のセットを 0.0f に割り当てて、VAO の一貫性を確保します。

processXXXメソッドは非常に単純です。目的のデータを返すインスタンスに対して対応するメソッドを呼び出し、それAIMeshを配列に格納するだけです。

public class ModelLoader {
    ...
    private static int[] processIndices(AIMesh aiMesh) {
        List<Integer> indices = new ArrayList<>();
        int numFaces = aiMesh.mNumFaces();
        AIFace.Buffer aiFaces = aiMesh.mFaces();
        for (int i = 0; i < numFaces; i++) {
            AIFace aiFace = aiFaces.get(i);
            IntBuffer buffer = aiFace.mIndices();
            while (buffer.remaining() > 0) {
                indices.add(buffer.get());
            }
        }
        return indices.stream().mapToInt(Integer::intValue).toArray();
    }
    ...
    private static float[] processTextCoords(AIMesh aiMesh) {
        AIVector3D.Buffer buffer = aiMesh.mTextureCoords(0);
        if (buffer == null) {
            return new float[]{};
        }
        float[] data = new float[buffer.remaining() * 2];
        int pos = 0;
        while (buffer.remaining() > 0) {
            AIVector3D textCoord = buffer.get();
            data[pos++] = textCoord.x();
            data[pos++] = 1 - textCoord.y();
        }
        return data;
    }

    private static float[] processVertices(AIMesh aiMesh) {
        AIVector3D.Buffer buffer = aiMesh.mVertices();
        float[] data = new float[buffer.remaining() * 3];
        int pos = 0;
        while (buffer.remaining() > 0) {
            AIVector3D textCoord = buffer.get();
            data[pos++] = textCoord.x();
            data[pos++] = textCoord.y();
            data[pos++] = textCoord.z();
        }
        return data;
    }
}

メソッドを呼び出すと、頂点へのバッファーが取得されることがわかりますmVertices。それらを単純に処理してList、頂点の位置を含む浮動小数点数を作成します。このメソッドはバッファを返すだけなので、その情報を頂点を作成する OpenGL メソッドに直接渡すことができます。2 つの理由から、そのようにはしません。1 つ目は、コード ベースの変更を可能な限り減らすことです。2 つ目は、中間構造にロードすることで、プロ処理タスクを実行したり、ロード プロセスをデバッグしたりできる可能性があることです。

はるかに効率的な方法、つまりバッファーを OpenGL に直接渡す方法のサンプルが必要な場合は、このサンプルを確認してください。

モデルの使用

Materialクラスの修正

クラスを変更してMaterial、拡散色のサポートを追加する必要があります。

public class Material {

    public static final Vector4f DEFAULT_COLOR = new Vector4f(0.0f, 0.0f, 0.0f, 1.0f);

    private Vector4f diffuseColor;
    ...
    public Material() {
        diffuseColor = DEFAULT_COLOR;
        ...
    }
    ...
    public Vector4f getDiffuseColor() {
        return diffuseColor;
    }
    ...
    public void setDiffuseColor(Vector4f diffuseColor) {
        this.diffuseColor = diffuseColor;
    }
    ...
}

SceneRenderクラスの修正

このSceneRenderクラスでは、マテリアルの拡散色を作成し、レンダリング中に適切に設定する必要があります。

public class SceneRender {
    ...
    private void createUniforms() {
        ...
        uniformsMap.createUniform("material.diffuse");
    }

    public void render(Scene scene) {
        ...
        for (Model model : models) {
            List<Entity> entities = model.getEntitiesList();

            for (Material material : model.getMaterialList()) {
                uniformsMap.setUniform("material.diffuse", material.getDiffuseColor());
                ...
            }
        }
        ...
    }
    ...
}

scene.flagの修正

ご覧のとおり、ユニフォームの名前に が含まれる変な名前を使用してい.ます。これは、シェーダーで構造を使用するためです。構造を使用すると、複数の型を 1 つの結合型にグループ化できます。これはフラグメント シェーダーで確認できます。

#version 330

in vec2 outTextCoord;

out vec4 fragColor;

struct Material
{
    vec4 diffuse;
};

uniform sampler2D txtSampler;
uniform Material material;

void main()
{
    fragColor = texture(txtSampler, outTextCoord) + material.diffuse;
}

UniformsMapの修正

また、値UniformsMapを渡すためのサポートを追加するために、クラスに新しいメソッドを追加する必要がありますVector4f

public class UniformsMap {
    ...
    public void setUniform(String uniformName, Vector4f value) {
        glUniform4f(getUniformLocation(uniformName), value.x, value.y, value.z, value.w);
    }
}

Mainクラスの修正

最後に、Mainクラスを使用してModelLoaderモデルをロードするようにクラスを変更する必要があります。

public class Main implements IAppLogic {
    ...
    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-09", new Window.WindowOptions(), main);
        ...
    }
    ...
    public void init(Window window, Scene scene, Render render) {
        Model cubeModel = ModelLoader.loadModel("cube-model", "resources/models/cube/cube.obj",
                scene.getTextureCache());
        scene.addModel(cubeModel);

        cubeEntity = new Entity("cube-entity", cubeModel.getId());
        cubeEntity.setPosition(0, 0, -2);
        scene.addEntity(cubeEntity);
    }
    ...
}

ご覧のとおり、initメソッドは大幅に簡素化され、コードにモデル データが埋め込まれなくなりました。現在、波面フォーマットを使用する立方体モデルを使用しています。フォルダー内にモデル ファイルがありresources\models\cubeます。そこには、次のファイルがあります。

? cube.obj: メイン モデル ファイル。実際にはテキストベースのフォーマットであるため、それを開いて、頂点、インデックス、テクスチャ座標がどのように定義され、面を定義することで結合されているかを確認できます。また、マテリアル ファイルへの参照も含まれています。

cube.mtl: マテリアル ファイル。色とテクスチャを定義します。

cube.png: モデルのテクスチャ ファイル。

最後に、レンダリングを最適化する別の機能を追加します。顔カリングを適用することで、レンダリングされるデータの量を減らします。よく知られているように、立方体は 6 つの面で構成されており、6 つの面が表示されていません。立方体の内部をズームすると、これを確認できます。内部が表示されます。

見えない顔はすぐに破棄する必要があります。これが顔カリングの機能です。実際、立方体の場合、同時に 3 つの面しか見ることができないため、面のカリングを適用するだけで面の半分を破棄できます (これは、ゲームで内側に飛び込む必要がない場合にのみ有効です)。モデルの)。

すべての三角形について、フェース カリングは、それが私たちの方を向いているかどうかをチェックし、その方向を向いていないものを破棄します。しかし、三角形が自分の方を向いているかどうかはどうすればわかりますか? OpenGL がこれを行う方法は、三角形を構成する頂点の巻き順によるものです。

最初の章で、三角形の頂点を時計回りまたは反時計回りの順序で定義できることを思い出してください。OpenGL では、デフォルトで、反時計回りの三角形はビューアーの方を向き、時計回りの三角形は後ろ向きです。ここで重要なことは、視点を考慮してレンダリング中にこの順序がチェックされることです。したがって、反時計回りの順序で定義された三角形は、レンダリング時に、視点のために時計回りに定義されていると解釈できます。

Renderクラスの修正

Renderクラスで顔カリングを有効にします。

public class Render {
    ...
    public Render() {
        ...
        glEnable(GL_CULL_FACE);
        glCullFace(GL_BACK);
        ...
    }
    ...
}

1 行目は面のカリングを有効にし、2 行目は後ろ向きの面をカリング (削除) する必要があることを示しています。

サンプルを実行すると、前の章と同じ結果が表示されますが、立方体を拡大すると、内側の面はレンダリングされません。このサンプルを変更して、より複雑なモデルを読み込むことができます。

Java 3D LWJGL GitBook 〜カメラChapter08:カメラを動かすとは〜

カメラ

この章では、レンダリングされた 3D シーン内を移動する方法を学習します。この機能は、3D ワールド内を移動できるカメラを持つようなものであり、実際、3D ワールドを参照するために使用される用語です。

参照するドキュメントとソースは以下になります。

カメラ紹介

OpenGL で特定のカメラ機能を検索しようとすると、カメラの概念がないことがわかります。つまり、カメラは常に固定されており、画面の中心にある (0, 0, 0) の位置に中心があります。そこで、3D シーン内を移動できるカメラがあるかのような印象を与えるシミュレーションを行います。どうすればこれを達成できますか? カメラを移動できない場合は、3D 空間に含まれるすべてのオブジェクトを一度に移動する必要があります。つまり、カメラを動かすことができなければ、世界全体を動かすことになります。

したがって、カメラの位置を z 軸に沿って開始位置 (Cx、Cy、Cz) から位置 (Cx、Cy、Cz+dz) に移動して、座標 (Ox、Oy、Oz)。

<カメラの概念がある場合> => カメラを動かす

実際に行うことは、オブジェクト (実際には 3D 空間内のすべてのオブジェクト) を、カメラが移動する方向とは反対の方向に移動することです。トレッドミルに置かれているオブジェクトのように考えてください。

<カメラの概念がない場合> => 世界を動かす

カメラは 3 つの軸 (x、y、z) に沿って変位することができ、それらに沿って回転することもできます (ロール、ピッチ、ヨー)。

ロール(X軸)、ピッチ(Y軸)、ヨー(Z軸)

※参考サイト:3次元ベクトルのロール、ピッチ、ヨー=(roll, pitch and yaw).
 上記リンクのサイトに以下のような図がありました。
ロール(X軸)、ピッチ(Y軸)、ヨー(Z軸)

そして、右手と、左手の2種類あるようです。親指(X軸), 人差し指(Y軸), 中指(Z軸)の形です。
なので、上記の画像は「右手」タイプのものになりマス。

右手 左手

したがって、基本的に私たちがしなければならないことは、3D ワールドのすべてのオブジェクトを移動および回転できるようにすることです。これをどのように行うのですか?答えは、すべてのオブジェクトのすべての頂点をカメラの動きの反対方向に移動し、カメラの回転に従ってそれらを回転させる別の変換を適用することです。もちろん、これは別のマトリックス、いわゆるビューマトリックスで行われます。このマトリックスは、最初に平行移動を実行し、次に軸に沿って回転を実行します。

その行列を構築する方法を見てみましょう。変換の章を覚えているなら、私たちの変換方程式は次のようでした:

射影行列を乗算する前にビュー行列を適用する必要があるため、式は次のようになります。

成すべきこと

3D ワールドのすべてのオブジェクトを移動および回転できるようにすること
つまり、「すべてのオブジェクトのすべての頂点をカメラの動きの反対方向に移動し、カメラの回転に従ってそれらを回転させる別の変換を適用することです。」
具体的な方法としては、頂点座標を持っているオブジェクトにマトリックス(行列)を掛け算などして、移動・回転するということです。
その計算方法として、上記の計算式を使用できます。

実装部分

今回は、カメラとマウスの入力を追加します。

カメラの実装

それでは、カメラをサポートするようにコードを変更してみましょう。Camera最初に、カメラの位置と回転状態、およびそのビュー マトリックスを保持する、という名前の新しいクラスを作成します。クラスは次のように定義されます。

この章で、Cameraクラスが追加されます。カメラを動かすときの処理(マトリックスの計算処理)が実装されています。
※ ビュー行列は「ワールドに置いたあらゆる物をカメラの世界に移動させる行列」(参考サイト)

package org.lwjglb.engine.scene;

import org.joml.*;

public class Camera {

    private Vector3f direction;
    private Vector3f position;
    private Vector3f right;
    private Vector2f rotation;
    private Vector3f up;
    private Matrix4f viewMatrix;

    public Camera() {
        direction = new Vector3f();
        right = new Vector3f();
        up = new Vector3f();
        position = new Vector3f();
        viewMatrix = new Matrix4f();
        rotation = new Vector2f();
    }

    public void addRotation(float x, float y) {
        rotation.add(x, y);
        recalculate();
    }

    public Vector3f getPosition() {
        return position;
    }

    public Matrix4f getViewMatrix() {
        return viewMatrix;
    }

    public void moveBackwards(float inc) {
        viewMatrix.positiveZ(direction).negate().mul(inc);
        position.sub(direction);
        recalculate();
    }

    public void moveDown(float inc) {
        viewMatrix.positiveY(up).mul(inc);
        position.sub(up);
        recalculate();
    }

    public void moveForward(float inc) {
        viewMatrix.positiveZ(direction).negate().mul(inc);
        position.add(direction);
        recalculate();
    }

    public void moveLeft(float inc) {
        viewMatrix.positiveX(right).mul(inc);
        position.sub(right);
        recalculate();
    }

    public void moveRight(float inc) {
        viewMatrix.positiveX(right).mul(inc);
        position.add(right);
        recalculate();
    }

    public void moveUp(float inc) {
        viewMatrix.positiveY(up).mul(inc);
        position.add(up);
        recalculate();
    }

    private void recalculate() {
        viewMatrix.identity()
                .rotateX(rotation.x)
                .rotateY(rotation.y)
                .translate(-position.x, -position.y, -position.z);
    }

    public void setPosition(float x, float y, float z) {
        position.set(x, y, z);
        recalculate();
    }

    public void setRotation(float x, float y) {
        rotation.set(x, y);
        recalculate();
    }
}

ご覧のとおり、回転と位置に加えて、前方上方向と右方向を定義するいくつかのベクトルを定義します。これは、自由空間移動カメラを実装しているためです。前方に移動したい場合にカメラを回転させると、定義済みの軸ではなく、カメラが指している場所に移動したいだけです。次の位置がどこに配置されるかを計算するには、これらのベクトルを取得する必要があります。そして最後に、カメラの状態が 4x4 マトリックス (ビュー マトリックス) に格納されるため、位置や回転を変更するたびに更新する必要があります。ご覧のとおり、ビュー マトリックスを更新するときは、最初に回転を行い、次に平行移動を行う必要があります。逆にすると、カメラの位置に沿って回転するのではなく、座標の原点に沿って回転します。

このCameraクラスは、前方、上、または右に移動するときに位置を更新するメソッドも提供します。これらのメソッドでは、ビュー マトリックスを使用して、現在の状態に応じて前方、上、または右のメソッドがどこにあるべきかを計算し、それに応じて位置を増やします。コードを非常にシンプルに保ちながら、これらの計算に素晴らしい JOML ライブラリを使用します。

JOMOLライブラリは、以下のクラスのこと

  • org.joml.Vector2f;

他にもありますが、つまりは「org.joml.*;」のクラスということです。これらのJOMOLライブラリクラス群を使用することが、ライブラリを使用するということです。まぁ、そのままですね。。。

カメラの使用(呼び出し元)

Cameraクラスにインスタンスを保存するSceneので、変更に進みましょう。

public class Scene {
    ...
    private Camera camera;
    ...
    public Scene(int width, int height) {
        ...
        camera = new Camera();
    }
    ...
    public Camera getCamera() {
        return camera;
    }
    ...
}

MouseInput

マウスでカメラを操作できるといいですね。そのために、マウス イベントを処理する新しいクラスを作成し、それらを使用してカメラの回転を更新できるようにします。これがそのクラスのコードです。

package org.lwjglb.engine;

import org.joml.Vector2f;

import static org.lwjgl.glfw.GLFW.*;

public class MouseInput {

    private Vector2f currentPos;
    private Vector2f displVec;
    private boolean inWindow;
    private boolean leftButtonPressed;
    private Vector2f previousPos;
    private boolean rightButtonPressed;

    public MouseInput(long windowHandle) {
        previousPos = new Vector2f(-1, -1);
        currentPos = new Vector2f();
        displVec = new Vector2f();
        leftButtonPressed = false;
        rightButtonPressed = false;
        inWindow = false;

        glfwSetCursorPosCallback(windowHandle, (handle, xpos, ypos) -> {
            currentPos.x = (float) xpos;
            currentPos.y = (float) ypos;
        });
        glfwSetCursorEnterCallback(windowHandle, (handle, entered) -> inWindow = entered);
        glfwSetMouseButtonCallback(windowHandle, (handle, button, action, mode) -> {
            leftButtonPressed = button == GLFW_MOUSE_BUTTON_1 && action == GLFW_PRESS;
            rightButtonPressed = button == GLFW_MOUSE_BUTTON_2 && action == GLFW_PRESS;
        });
    }

    public Vector2f getCurrentPos() {
        return currentPos;
    }

    public Vector2f getDisplVec() {
        return displVec;
    }

    public void input() {
        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クラスは、そのコンストラクターで、マウス イベントを処理するための一連のコールバックを登録します。

・glfwSetCursorPosCallback: マウスが移動したときに呼び出されるコールバックを登録します。
・glfwSetCursorEnterCallback: マウスがウィンドウに入ったときに呼び出されるコールバックを登録します。マウスがウィンドウ内にない場合でも、マウス イベントを受け取ります。このコールバックを使用して、マウスがウィンドウ内にあるときを追跡します。
・glfwSetMouseButtonCallback: マウス ボタンが押されたときに呼び出されるコールバックを登録します。
このMouseInputクラスは、ゲーム入力が処理されるときに呼び出される入力メソッドを提供します。このメソッドは、前の位置からのマウスの変位を計算し、それをdisplVec変数に格納して、ゲームで使用できるようにします。

クラスはMouseInputクラスでインスタンス化され、Windowそのインスタンスを返すゲッターも提供されます。また、イベントがポーリングされるたびに入力メソッドを呼び出します。

Windowクラスの追加

public class Window {
    ...
    private MouseInput mouseInput;
    ...
    public Window(String title, WindowOptions opts, Callable<Void> resizeFunc) {
        ...
        mouseInput = new MouseInput(windowHandle);
    }
    ...
    public MouseInput getMouseInput() {
        return mouseInput;
    }
    ...    
    public void pollEvents() {
        ...
        mouseInput.input();
    }
    ...
}

これで、 のビュー マトリックスを使用するように頂点シェーダーを変更できます。これは、ご想像のとおりCamera、uniform として渡されます。

<scene.vert>

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;

out vec2 outTextCoord;

uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

void main()
{
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
    outTextCoord = texCoord;
}

SceneRender

したがって、次のステップは、クラスでユニフォームを適切に作成し、各呼び出しSceneRenderでその値を更新することです。render

public class SceneRender {
    ...
    private void createUniforms() {
        ...
        uniformsMap.createUniform("viewMatrix");
        ...
    }    
    ...
    public void render(Scene scene) {
        ...
        uniformsMap.setUniform("projectionMatrix", scene.getProjection().getProjMatrix());
        uniformsMap.setUniform("viewMatrix", scene.getCamera().getViewMatrix());
        ...
    }
}

Main

以上で、基本コードはカメラの概念をサポートします。今、それを使用する必要があります。入力の処理方法を変更して、カメラを更新できます。次のコントロールを設定します。

・キー「A」と「D」は、カメラをそれぞれ左と右 (x 軸) に移動します。
・「W」キーと「S」キーは、それぞれカメラを前後 (z 軸) に移動します。
・「Z」キーと「X」キーは、それぞれカメラを上下 (y 軸) に移動します。
マウスの右ボタンが押されたときに、マウスの位置を使用して x 軸と y 軸に沿ってカメラを回転させます。

Mainこれで、クラスを更新してキーボードとマウスの入力を処理する準備が整いました。

public class Main implements IAppLogic {

    private static final float MOUSE_SENSITIVITY = 0.1f;
    private static final float MOVEMENT_SPEED = 0.005f;
    ...

    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-08", new Window.WindowOptions(), main);
        ...
    }
    ...
    public void input(Window window, Scene scene, long diffTimeMillis) {
        float move = diffTimeMillis * MOVEMENT_SPEED;
        Camera camera = scene.getCamera();
        if (window.isKeyPressed(GLFW_KEY_W)) {
            camera.moveForward(move);
        } else if (window.isKeyPressed(GLFW_KEY_S)) {
            camera.moveBackwards(move);
        }
        if (window.isKeyPressed(GLFW_KEY_A)) {
            camera.moveLeft(move);
        } else if (window.isKeyPressed(GLFW_KEY_D)) {
            camera.moveRight(move);
        }
        if (window.isKeyPressed(GLFW_KEY_UP)) {
            camera.moveUp(move);
        } else if (window.isKeyPressed(GLFW_KEY_DOWN)) {
            camera.moveDown(move);
        }

        MouseInput mouseInput = window.getMouseInput();
        if (mouseInput.isRightButtonPressed()) {
            Vector2f displVec = mouseInput.getDisplVec();
            camera.addRotation((float) Math.toRadians(-displVec.x * MOUSE_SENSITIVITY),
                    (float) Math.toRadians(-displVec.y * MOUSE_SENSITIVITY));
        }
    }

UML(クラス図)

プログラムの実行

プログラムを実行してみました。

<<<前回 次回 >>>

Java 3D LWJGL GitBook 〜テクスチャChapter07:処理内容の確認もする〜

テクスチャ

皆さんご存じ(と思われる)テクスチャに関して学習します。テクスチャは作成した3Dモデルに対して貼り付ける表皮みたいなものです。
これを張り付けるのには、どうしたらよいか?座標的にはどのように考えるのか?を理解していきます。

この章では、テクスチャをロードする方法、テクスチャをモデルに関連付ける方法、およびレンダリング プロセスでテクスチャを使用する方法を学習します。

参照するドキュメントとソースは以下になります。

テクスチャの読み込み1

テクスチャは、モデルのピクセルの色を設定するためにモデルにマップされるイメージです。テクスチャは、3D モデルを包むスキンと考えることができます。行うことは、画像テクスチャのポイントをモデルの頂点に割り当てることです。その情報を使用して、OpenGL はテクスチャ イメージに基づいて他のピクセルに適用する色を計算できます。

キーワード:"テクスチャは、3D モデルを包むスキンと考えることができます。行うことは、画像テクスチャのポイントをモデルの頂点に割り当てることです。"

テクスチャ イメージは、モデルと同じサイズである必要はありません。大きくても小さくてもかまいません。処理するピクセルがテクスチャ内の特定のポイントにマッピングできない場合、OpenGL は色を外挿します。特定のテクスチャが作成されるときに、このプロセスがどのように行われるかを制御できます。

基本的に、テクスチャをモデルに適用するために必要なことは、各頂点にテクスチャ座標を割り当てることです。テクスチャ座標系は、モデルの座標系とは少し異なります。まず、2D テクスチャがあるので、座標には x と y の 2 つのコンポーネントしかありません。その上、原点は画像の左上隅に設定され、x または y 値の最大値は 1 です。

テクスチャ座標と位置座標をどのように関連付けるのですか? 簡単です。色情報を渡したのと同じ方法です。各頂点位置のテクスチャ座標を持つ VBO を設定します。

理論のまとめ

【テクスチャ】
座標には x と y の 2 つのコンポーネントしかありません。
その上、原点は画像の左上隅に設定され、x または y 値の最大値は 1 です。
「テクスチャ座標と位置座標をどのように関連付けるのですか?」
-> 各頂点位置のテクスチャ座標を持つ VBO を設定します。

VBOを設定するのに、プログラムではどこでやっているのか?確認したいと思います。

プログラムの挙動確認

Javaはメインメソッドが動くので、ここから確認していきます。
クラス名#メソッド名()クラス名#フィールド変数のように参照するクラスとメソッド、フィールドを記述します。

大まかな流れ

  1. Main#main(): Mainクラス(IAppLogicインターフェース)をインスタンス化、Engineクラスのコンストラクタに渡しEngineを実行(start()の実行)します。
  2. Engine#start()でENgine#run()メソッドを起動し、Main#コンストラクタを起動、「runningフラグ」と「Window#windowShouldCloseフラグ」でループを抜ける(ゲーム終了)判定を行います。
  3. ループ内でtargetFps <= 0 || deltaFps >= 1の判定がTRUEの時に入力処理を行う
  4. 画面データの更新(Main#rendar())
  5. レンダリング処理(Rendar#rendaer())

Engineクラス

ここで、テクスチャイメージとVBOの関連付を行っている部分はEngineクラスのコンストラクタから呼び出されるMain#init()になります。
そして、テクスチャイメージを読み込みVBOの関連づけなどを行っているのは、Textureクラスのコンストラクタです。

    public Engine(String windowTitle, Window.WindowOptions opts, IAppLogic appLogic) {
        window = new Window(windowTitle, opts, () -> {
            resize();
            return null;
        });
        targetFps = opts.fps;
        targetUps = opts.ups;
        this.appLogic = appLogic;
        render = new Render();
        scene = new Scene(window.getWidth(), window.getHeight());
        appLogic.init(window, scene, render);
        running = true;
    }

Mainクラス(IAppLogic)

Engineクラスで呼び出したMain#init()の処理内容を実装します。位置情報(頂点座標)(positions)、テクスチャ座標(textCoords)、頂点インデックス(indices)の各VBOを作成。
テクスチャクラス、マテリアル、メッシュ、モデル、エンティティを作成します。

    @Override
    public void init(Window window, Scene scene, Render render) {
        float[] positions = new float[]{
            ・・・
        };
        float[] textCoords = new float[]{
            ・・・
        };
        int[] indices = new int[]{
            ・・・
        };
        Texture texture = scene.getTextureCache().createTexture("chapter-07/resources/models/cube/cube.png");
        Material material = new Material();
        material.setTexturePath(texture.getTexturePath());
        List<Material> materialList = new ArrayList<>();
        materialList.add(material);

        Mesh mesh = new Mesh(positions, textCoords, indices);
        material.getMeshList().add(mesh);
        Model cubeModel = new Model("cube-model", materialList);
        scene.addModel(cubeModel);

        cubeEntity = new Entity("cube-entity", cubeModel.getId());
        cubeEntity.setPosition(0, 0, -2);
        scene.addEntity(cubeEntity);
    }

テクスチャの読み込み2

それでは、3D キューブでテクスチャを使用するようにコード ベースを変更してみましょう。最初のステップは、テクスチャとして使用されるイメージをロードすることです。このタスクでは、stbライブラリの LWJGL ラッパーを使用します。そのためには、まずpom.xmlファイル内のネイティブを含め、その依存関係を宣言する必要があります。

STBライブラリ追加(POM)

<dependency>
    <groupId>org.lwjgl</groupId>
    <artifactId>lwjgl-stb</artifactId>
    <version>${lwjgl.version}</version>
</dependency>
[...]
<dependency>
    <groupId>org.lwjgl</groupId>
    <artifactId>lwjgl-stb</artifactId>
    <version>${lwjgl.version}</version>
    <classifier>${native.target}</classifier>
    <scope>runtime</scope>
</dependency>

Textureクラス

Texture最初のステップは、テクスチャをロードするために必要なすべてのステップを実行し、次のように定義される新しいクラスを作成することです

package org.lwjglb.engine.graph;

import org.lwjgl.system.MemoryStack;

import java.nio.*;

import static org.lwjgl.opengl.GL30.*;
import static org.lwjgl.stb.STBImage.*;

public class Texture {

    private int textureId;
    private String texturePath;

    public Texture(int width, int height, ByteBuffer buf) {
        this.texturePath = "";
        generateTexture(width, height, buf);
    }

    public Texture(String texturePath) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            this.texturePath = texturePath;
            IntBuffer w = stack.mallocInt(1);
            IntBuffer h = stack.mallocInt(1);
            IntBuffer channels = stack.mallocInt(1);

            ByteBuffer buf = stbi_load(texturePath, w, h, channels, 4);
            if (buf == null) {
                throw new RuntimeException("Image file [" + texturePath + "] not loaded: " + stbi_failure_reason());
            }

            int width = w.get();
            int height = h.get();

            generateTexture(width, height, buf);

            stbi_image_free(buf);
        }
    }

    public void bind() {
        glBindTexture(GL_TEXTURE_2D, textureId);
    }

    public void cleanup() {
        glDeleteTextures(textureId);
    }

    private void generateTexture(int width, int height, ByteBuffer buf) {
        textureId = glGenTextures();

        glBindTexture(GL_TEXTURE_2D, textureId);
        glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0,
                GL_RGBA, GL_UNSIGNED_BYTE, buf);
        glGenerateMipmap(GL_TEXTURE_2D);
    }

    public String getTexturePath() {
        return texturePath;
    }
}

stbi_load()org.lwjgl.stb.STBImageのメソッド(static importしています。)

単語 意味
コンストラクター newするときに動く処理のこと
チャンネル数 色の数、Lの場合は1、RGBの場合は3など

コンストラクターで最初に行うことは、ライブラリーに s を割り当てIntBufferて、画像サイズとチャンネル数を返すことです。次に、stbi_loadメソッドを呼び出して、実際に画像を にロードしますByteBuffer。このメソッドには、次のパラメーターが必要です。

<コンストラクタで実行すること>

  1. ライブラリーにIntBufferで、画像サイズとチャンネル数を割り当てる
  2. stbi_loadメソッドを呼び出して、実際に画像を にロードします
  3. これはgenerateTextureメソッドでテクスチャを GPU にアップロードする
  4. RGBA バイトをアンパックする方法を OpenGL に指示する

<stbi_loadメソッドの引数>

・filePath: ファイルへの絶対パス。stb ライブラリはネイティブであり、 について何も理解していませんCLASSPATH。したがって、通常のファイル システム パスを使用します。
・width: 画像の幅。これには、画像の幅が入力されます。
・height: 画像の高さ。これには、画像の高さが入力されます。
・channels: 画像チャンネル。
・desired_channels: 目的の画像チャネル。4 (RGBA) を渡します。
覚えておくべき重要な点の 1 つは、歴史的な理由から、テクスチャ イメージのサイズ (各次元のテクセル数) が 2 のべき乗 (2、4、8、16、...) であることが OpenGL で要求されることです。これは OpenGL ドライバーではもう必要ないと思いますが、問題がある場合は寸法を変更してみてください。

channlesはイメージファイルのチャネル数、desired_channelsは目的の(出力する)イメージファイルのチャネル数

TextureCache

次のステップは、テクスチャを GPU にアップロードすることです。これはgenerateTextureメソッドで行われます。まず、(glGenTextures関数を呼び出して) 新しいテクスチャ識別子を作成する必要があります。その後、( を呼び出してglBindTexture) そのテクスチャにバインドする必要があります。次に、RGBA バイトをアンパックする方法を OpenGL に指示する必要があります。各コンポーネントのサイズは 1 バイトなのでGL_UNPACK_ALIGNMENT、glPixelStorei関数に使用するだけです。最後に、 を呼び出してテクスチャ データをロードしますglTexImage2D。

<glTexImage2Dメソッドのパラメータ>

このglTexImage2Dメソッドには次のパラメーターがあります。
・target: ターゲット テクスチャ (そのタイプ) を指定します。この場合: GL_TEXTURE_2D.
・level: 詳細レベル番号を指定します。レベル 0 はベース イメージ レベルです。レベル n は、n 番目のミップマップ削減イメージです。これについては後で詳しく説明します。
・internal format: テクスチャのカラー コンポーネントの数を指定します。
・width: テクスチャ イメージの幅を指定します。
・height: テクスチャ イメージの高さを指定します。
・border: この値はゼロでなければなりません。
・format: ピクセル データの形式を指定します。この場合は RGBA です。
・type: ピクセル データのデータ型を指定します。これには符号なしバイトを使用しています。
・data: データを格納するバッファ。

<Texture#generateTexture()の処理>

その後、glTexParameteri関数を呼び出すことで、基本的には、ピクセルがテクスチャ座標に直接 1 対 1 で関連付けられていない場合に、最も近いテクスチャ座標ポイントを選択すると言います。その後、ミップマップを生成します。ミップマップは、非常に詳細なテクスチャから生成された、解像度が低下する画像のセットです。これらの低解像度の画像は、オブジェクトがスケーリングされるときに自動的に使用されます。関数を呼び出すときにこれを行いglGenerateMipmapます。以上で、テクスチャのロードに成功しました。今、それを使用する必要があります。

次に、テクスチャ キャッシュを作成します。モデルが同じテクスチャを再利用することは非常に頻繁にあるため、同じテクスチャを複数回ロードする代わりに、すでにロードされているテクスチャをキャッシュして、各テクスチャを 1 回だけロードします。これはTextureCacheクラスによって制御されます。

package org.lwjglb.engine.graph;

import java.util.*;

public class TextureCache {

    public static final String DEFAULT_TEXTURE = "resources/models/default/default_texture.png";

    private Map<String, Texture> textureMap;

    public TextureCache() {
        textureMap = new HashMap<>();
        textureMap.put(DEFAULT_TEXTURE, new Texture(DEFAULT_TEXTURE));
    }

    public void cleanup() {
        textureMap.values().stream().forEach(Texture::cleanup);
    }

    public Texture createTexture(String texturePath) {
        return textureMap.computeIfAbsent(texturePath, Texture::new);
    }

    public Texture getTexture(String texturePath) {
        Texture texture = null;
        if (texturePath != null) {
            texture = textureMap.get(texturePath);
        }
        if (texture == null) {
            texture = textureMap.get(DEFAULT_TEXTURE);
        }
        return texture;
    }
}

ご覧のとおり、ロードされたテクスチャを に保存し、Mapテクスチャ パスが null (テクスチャのないモデル) の場合にデフォルトのテクスチャを返します。デフォルトのテクスチャは、テクスチャではなく色を定義するモデルと組み合わせることができる単なるバック イメージであるため、フラグメント シェーダーで両方を組み合わせることができます。クラス インスタンスはクラスTextureCacheに格納されます。Scene

public class Scene {
    ...
    private TextureCache textureCache;
    ...
    public Scene(int width, int height) {
        ...
        textureCache = new TextureCache();
    }
    ...
    public TextureCache getTextureCache() {
        return textureCache;
    }
    ...
}

ここで、テクスチャのサポートを追加するためにモデルを定義する方法を変更する必要があります。そのために、そして次の章でロードするより複雑なモデルに備えるために、 という名前の新しいクラスを導入しMaterialます。このクラスは、テクスチャ パスとMeshインスタンスのリストを保持します。したがって、Modelインスタンスを「es」ではなく「Listof 」に関連付けます。次の章では、マテリアルに拡散色や鏡面反射色などの他のプロパティを含めることができます。MaterialMesh

クラスは次のMaterialように定義されます。

package org.lwjglb.engine.graph;

import java.util.*;

public class Material {

    private List<Mesh> meshList;
    private String texturePath;

    public Material() {
        meshList = new ArrayList<>();
    }

    public void cleanup() {
        meshList.stream().forEach(Mesh::cleanup);
    }

    public List<Mesh> getMeshList() {
        return meshList;
    }

    public String getTexturePath() {
        return texturePath;
    }

    public void setTexturePath(String texturePath) {
        this.texturePath = texturePath;
    }

ご覧のとおり、MeshインスタンスはMaterialクラスの下にあります。Modelしたがって、次のようにクラスを変更する必要があります。

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<Material> materialList;

    public Model(String id, List<Material> materialList) {
        this.id = id;
        entitiesList = new ArrayList<>();
        this.materialList = materialList;
    }

    public void cleanup() {
        materialList.stream().forEach(Material::cleanup);
    }

    public List<Entity> getEntitiesList() {
        return entitiesList;
    }

    public String getId() {
        return id;
    }

    public List<Material> getMaterialList() {
        return materialList;
    }
}

前に述べたように、テクスチャ座標を別の VBO として渡す必要があります。そこでMesh、色の代わりにテクスチャ座標を含む float の配列を受け入れるようにクラスを変更します。Meshクラスは次のように変更されます。

public class Mesh {
    ...
    public Mesh(float[] positions, float[] textCoords, int[] indices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            numVertices = indices.length;
            vboIdList = new ArrayList<>();

            vaoId = glGenVertexArrays();
            glBindVertexArray(vaoId);

            // Positions VBO
            int vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer positionsBuffer = stack.callocFloat(positions.length);
            positionsBuffer.put(0, positions);
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, positionsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(0);
            glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);

            // Texture coordinates VBO
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer textCoordsBuffer = MemoryUtil.memAllocFloat(textCoords.length);
            textCoordsBuffer.put(0, textCoords);
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, textCoordsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(1);
            glVertexAttribPointer(1, 2, GL_FLOAT, false, 0, 0);

            // Index VBO
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            IntBuffer indicesBuffer = stack.callocInt(indices.length);
            indicesBuffer.put(0, indices);
            glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vboId);
            glBufferData(GL_ELEMENT_ARRAY_BUFFER, indicesBuffer, GL_STATIC_DRAW);

            glBindBuffer(GL_ARRAY_BUFFER, 0);
            glBindVertexArray(0);
        }
    }
    ...
}

クラス図にすると下のようになります。

テクスチャの使用

次に、シェーダーでテクスチャを使用する必要があります。頂点シェーダーでは、2 番目の入力パラメーターvec2を変更しました (パラメーター名も変更しました)。頂点シェーダーは、カラーの場合と同様に、フラグメント シェーダーが使用するテクスチャ座標を渡すだけです。

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;

out vec2 outTextCoord;

uniform mat4 projectionMatrix;
uniform mat4 modelMatrix;

void main()
{
    gl_Position = projectionMatrix * modelMatrix * vec4(position, 1.0);
    outTextCoord = texCoord;
}

sampler2Dフラグメント シェーダーでは、テクスチャをサンプリングして (ユニフォームを介して) ピクセルの色を設定するために、テクスチャ座標を使用する必要があります。

#version 330

in vec2 outTextCoord;

out vec4 fragColor;

uniform sampler2D txtSampler;

void main()
{
    fragColor = texture(txtSampler, outTextCoord);
}

これらすべてがクラスでどのように使用されるかを見ていきますSceneRender。まず、テクスチャ サンプラー用の新しいユニフォームを作成する必要があります。

public class SceneRender {
    ...
    private void createUniforms() {
        ...
        uniformsMap.createUniform("txtSampler");
    }
    ...
}

これで、レンダリング プロセスでテクスチャを使用できます。

public class SceneRender {
    ...
    public void render(Scene scene) {
        shaderProgram.bind();

        uniformsMap.setUniform("projectionMatrix", scene.getProjection().getProjMatrix());

        uniformsMap.setUniform("txtSampler", 0);

        Collection<Model> models = scene.getModelMap().values();
        TextureCache textureCache = scene.getTextureCache();
        for (Model model : models) {
            List<Entity> entities = model.getEntitiesList();

            for (Material material : model.getMaterialList()) {
                Texture texture = textureCache.getTexture(material.getTexturePath());
                glActiveTexture(GL_TEXTURE0);
                texture.bind();

                for (Mesh mesh : material.getMeshList()) {
                    glBindVertexArray(mesh.getVaoId());
                    for (Entity entity : entities) {
                        uniformsMap.setUniform("modelMatrix", entity.getModelMatrix());
                        glDrawElements(GL_TRIANGLES, mesh.getNumVertices(), GL_UNSIGNED_INT, 0);
                    }
                }
            }
        }

        glBindVertexArray(0);

        shaderProgram.unbind();
    }
}

ご覧のとおり、最初にテクスチャ サンプラーのユニフォームを0値に設定します。なぜこれを行うのかを説明しましょう。グラフィックス カードには、テクスチャを格納するためのスペースまたはスロットがいくつかあります。これらの各スペースは、テクスチャ ユニットと呼ばれます。テクスチャを操作するときは、操作するテクスチャ ユニットを設定する必要があります。この場合、使用するテクスチャは 1 つだけなので、 texture unit を使用し0ます。ユニフォームにはsampler2Dタイプがあり、使用するテクスチャ ユニットの値を保持します。モデルとマテリアルを反復処理するとき、各マテリアルに関連付けられたテクスチャをキャッシュから取得し、次のglActiveTexture関数を呼び出してテクスチャ ユニットをアクティブにします。パラメータGL_TEXTURE0をバインドします。これが、テクスチャ ユニットとテクスチャ識別子を関連付ける方法です。

現在、テクスチャをサポートするためにコード ベースを変更したところです。次に、3D 立方体のテクスチャ座標を設定する必要があります。テクスチャ イメージ ファイルは次のようになります。

3D モデルには 8 つの頂点があります。これを行う方法を見てみましょう。まず、各頂点の前面テクスチャ座標を定義しましょう。

バーテックス座標 テクスチャ座標
V0 (0.0, 0.0)
V1 (0.0, 0.5)
V2 (0.5、0.5)
V3 (0.5, 0.0)

次に、上面のテクスチャ マッピングを定義しましょう。

バーテックス テクスチャ座標
V4 (0.0, 0.5)
V5 (0.5、0.5)
V0 (0.0, 1.0)
V3 (0.5、1.0)

ご覧のとおり、問題があるため、同じ頂点 (V0 と V3) に対して異なるテクスチャ座標を設定する必要があります。どうすればこれを解決できますか? これを解決する唯一の方法は、いくつかの頂点を繰り返し、異なるテクスチャ座標を関連付けることです。上面では、4 つの頂点を繰り返し、正しいテクスチャ座標を割り当てる必要があります。

前面、背面、および側面は同じテクスチャを使用するため、これらの頂点をすべて繰り返す必要はありません。ソース コードに完全な定義がありますが、8 ポイントから 20 ポイントに移動する必要がありました。

次の章では、3D モデリング ツールによって生成されたモデルをロードする方法を学習します。これにより、手動で位置とテクスチャ座標を定義する必要がなくなります (ちなみに、これはより複雑なモデルでは非現実的です)。

initクラスのメソッドを変更してMain、テクスチャ座標を定義し、テクスチャ データをロードするだけです。

public class Main implements IAppLogic {
    ...
    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-07", new Window.WindowOptions(), main);
        ...
    }
    ...
    public void init(Window window, Scene scene, Render render) {
        float[] positions = new float[]{
                // V0
                -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,

                // For text coords in top face
                // V8: V4 repeated
                -0.5f, 0.5f, -0.5f,
                // V9: V5 repeated
                0.5f, 0.5f, -0.5f,
                // V10: V0 repeated
                -0.5f, 0.5f, 0.5f,
                // V11: V3 repeated
                0.5f, 0.5f, 0.5f,

                // For text coords in right face
                // V12: V3 repeated
                0.5f, 0.5f, 0.5f,
                // V13: V2 repeated
                0.5f, -0.5f, 0.5f,

                // For text coords in left face
                // V14: V0 repeated
                -0.5f, 0.5f, 0.5f,
                // V15: V1 repeated
                -0.5f, -0.5f, 0.5f,

                // For text coords in bottom face
                // V16: V6 repeated
                -0.5f, -0.5f, -0.5f,
                // V17: V7 repeated
                0.5f, -0.5f, -0.5f,
                // V18: V1 repeated
                -0.5f, -0.5f, 0.5f,
                // V19: V2 repeated
                0.5f, -0.5f, 0.5f,
        };
        float[] textCoords = new float[]{
                0.0f, 0.0f,
                0.0f, 0.5f,
                0.5f, 0.5f,
                0.5f, 0.0f,

                0.0f, 0.0f,
                0.5f, 0.0f,
                0.0f, 0.5f,
                0.5f, 0.5f,

                // For text coords in top face
                0.0f, 0.5f,
                0.5f, 0.5f,
                0.0f, 1.0f,
                0.5f, 1.0f,

                // For text coords in right face
                0.0f, 0.0f,
                0.0f, 0.5f,

                // For text coords in left face
                0.5f, 0.0f,
                0.5f, 0.5f,

                // For text coords in bottom face
                0.5f, 0.0f,
                1.0f, 0.0f,
                0.5f, 0.5f,
                1.0f, 0.5f,
        };
        int[] indices = new int[]{
                // Front face
                0, 1, 3, 3, 1, 2,
                // Top Face
                8, 10, 11, 9, 8, 11,
                // Right face
                12, 13, 7, 5, 12, 7,
                // Left face
                14, 15, 6, 4, 14, 6,
                // Bottom face
                16, 18, 19, 17, 16, 19,
                // Back face
                4, 6, 7, 5, 4, 7,};
        Texture texture = scene.getTextureCache().createTexture("resources/models/cube/cube.png");
        Material material = new Material();
        material.setTexturePath(texture.getTexturePath());
        List<Material> materialList = new ArrayList<>();
        materialList.add(material);

        Mesh mesh = new Mesh(positions, textCoords, indices);
        material.getMeshList().add(mesh);
        Model cubeModel = new Model("cube-model", materialList);
        scene.addModel(cubeModel);

        cubeEntity = new Entity("cube-entity", cubeModel.getId());
        cubeEntity.setPosition(0, 0, -2);
        scene.addEntity(cubeEntity);
    }

最終結果はこんな感じ。

クラス図

<動画>

<<< 前回 次回 >>>

Java 3D LWJGL GitBook 〜3D への移行Chapter06〜

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>

  1. **id***:モデルのID
  2. entitiesList: エンティティのリスト
  3. 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 世界の座標系) に変換されます。

これらの操作を行うのに、元の行列(各座標を示すベクトル)に次の行列を乗算する方法がある

  1. 平行移動用の行列
  2. 回転用の行列
  3. スケーリング用の行列

そして、これらの行列(マトリックス)がある座標系 (モデルの座標系) から別の座標系 (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);
        ...
    }
    ...
}

これで、キューブが正しくレンダリングされました!

<動かしてみました>

<<< 前回 次回 >>>