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);
        ...
    }
    ...
}

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

<動かしてみました>

<<< 前回 次回 >>>

Java 3D LWJGL GitBook 〜パースペクティブ(遠近法)Chapter05〜

パースペクティブ(遠近法)

参照するドキュメントには次のように書いてあります(Google翻訳)

この章では、透視投影 (遠くにあるオブジェクトを近くのものよりも小さくレンダリングする) とユニフォーム (シェーダーに追加データを渡すためのバッファーのような構造) という 2 つの重要な概念を学習します。

透視投影

まずは、「透視投影」の意味から理解します。
Wikiによると次の通りです。イメージは以下

透視投影(とうしとうえい、英: perspective projection)は3次元物体を2次元平面に描画する図法(投影法)の一種である[1]。中心投影ともいう[2]。視点を設定して投影図を得るため、対象物を目で見た像と近い表現が得られるという特徴をもつ[3]。透視投影で得られた図は「透視図」あるいは英語の perspectiveから「パース」などと呼ばれる。

前の章で作成した素敵な色のクワッドに戻りましょう。注意深く見ると、四角形が歪んでいて長方形に見えることがわかります。ウィンドウの幅を 600 ピクセルから 900 ピクセルに変更することもでき、歪みがより明確になります。ここで何が起こっているのですか?

頂点シェーダー コードを再確認すると、座標を直接渡しているだけです。つまり、頂点の座標 x の値が 0.5 であると言うとき、OpenGL に対して、画面上の x 位置 0.5 に描画するように指示していることになります。次の図は、OpenGL 座標を示しています (x 軸と y 軸のみ)。

これらの座標は、ウィンドウ サイズを考慮して、ウィンドウ座標 (前の図の左上隅に原点がある) にマップされます。したがって、ウィンドウのサイズが 900x580 の場合、OpenGL 座標 (1,0) は座標 (900, 0) にマップされ、四角形ではなく四角形が作成されます。

つまりは、正四角形と長方形がウィンドウサイズに依存して描画されるということが起こっています。

しかし、問題はそれよりも深刻です。クワッドの z 座標を 0.0 から 1.0 および -1.0 に変更します。何が見えますか?四角形は、z 軸に沿って変位しても、正確に同じ場所に描画されます。なぜこうなった?遠くにあるオブジェクトは、近くにあるオブジェクトよりも小さく描画する必要があります。ただし、同じ x 座標と y 座標で描画しています。

ちょっと待って。これは z 座標で処理すべきではありませんか? 答えはイエスとノーです。z 座標は OpenGL にオブジェクトが近づいているか離れているかを伝えますが、OpenGL はオブジェクトのサイズについては何も知りません。サイズの異なる 2 つのオブジェクト (1 つは近くて小さく、もう 1 つは大きくて遠い) を同じサイズで画面に正しく投影できます (これらの x 座標と y 座標は同じですが、z は異なります)。OpenGL は渡された座標を使用するだけなので、これに注意する必要があります。座標を正しく投影する必要があります。

問題を診断したので、どのように修正しますか? 答えは、透視投影マトリックスを使用することです。透視投影マトリックスは、オブジェクトが歪まないように、描画領域の縦横比 (サイズと高さの関係) を処理します。また、距離も処理するため、遠くにあるオブジェクトは小さく描画されます。射影行列は、視野と表示される最大距離も考慮します。

行列に慣れていない方のために説明すると、行列は、列と行に配置された数値の 2 次元配列です。行列内の各数値は要素と呼ばれます。行列の順序は、行と列の数です。たとえば、ここでは 2x2 マトリックス (2 行 2 列) を確認できます。

上記に「答えは、透視投影マトリックスを使用することです。透視投影マトリックスは、オブジェクトが歪まないように、描画領域の縦横比 (サイズと高さの関係) を処理します。」とあるので、ここを考えてみました。つまりは縦横比が歪まないようにしてやればよいという理解をしました。

行列には、数学の本で参照できる、行列に適用できる基本演算 (加算、乗算など) がいくつかあります。3D グラフィックスに関連する行列の主な特徴は、空間内の点を変換するのに非常に役立つことです。

射影行列は、視野と最小距離と最大距離を持つカメラと考えることができます。そのカメラの視野領域は、角錐台から取得されます。次の図は、その領域を上から見た図です。

射影行列は 3D 座標を正しくマッピングするため、2D 画面で正しく表現できます。その行列の数学的表現は次のとおりです (怖がらないでください)。

アスペクト比は、画面の幅と画面の高さの関係です ($$a=幅/高さ$$)。特定の点の投影座標を取得するには、投影行列に元の座標を掛けるだけです。結果は、投影されたバージョンを含む別のベクトルになります。

そのため、ベクトルや行列などの一連の数学的エンティティを処理し、それらに対して実行できる操作を含める必要があります。すべてのコードをゼロから独自に作成するか、既存のライブラリを使用するかを選択できます。簡単なパスを選択し、JOML (Java OpenGL Math Library) と呼ばれる LWJGL で数学演算を処理するための特定のライブラリを使用します。そのライブラリを使用するには、別の依存関係をpom.xmlファイルに追加するだけです。

JOMOLの追加

        <dependency>
            <groupId>org.joml</groupId>
            <artifactId>joml</artifactId>
            <version>${joml.version}</version>
        </dependency>

すべてが設定されたので、射影行列を定義しましょう。Projection次のように定義された名前の新しいクラスを作成します。

つまり、ライブラリ「JOMOL」を使用して細かい計算用のクラス群を使いましょうという理解です。
そして、その実装は「Projection」クラスに集約(担当)させる形で実装します。

package org.lwjglb.engine.scene;

import org.joml.Matrix4f;

public class Projection {

    private static final float FOV = (float) Math.toRadians(60.0f);
    private static final float Z_FAR = 1000.f;
    private static final float Z_NEAR = 0.01f;

    private Matrix4f projMatrix;

    public Projection(int width, int height) {
        projMatrix = new Matrix4f();
        updateProjMatrix(width, height);
    }

    public Matrix4f getProjMatrix() {
        return projMatrix;
    }

    public void updateProjMatrix(int width, int height) {
        projMatrix.setPerspective(FOV, (float) width / height, Z_NEAR, Z_FAR);
    }
}

ご覧のとおり、これMatrix4fは、という名前の透視投影行列を設定するメソッドを提供するクラス (JOML ライブラリによって提供される)に依存していますsetPerspective。このメソッドには、次のパラメーターが必要です。

視野: ラジアン単位の視野角。FOVそのために定数を使用するだけです
アスペクト比: つまり、レンダリングの幅と高さの関係です。
近平面までの距離 (z-near)
遠方平面までの距離 (z-far)。
Projectionクラスインスタンスをクラスに格納Sceneし、コンストラクターで初期化します。それに加えて、ウィンドウのサイズが変更された場合に注意する必要があるため、ウィンドウのサイズが変更されたときに透視投影行列を再計算するSceneという名前の新しいメソッドをそのクラスに提供します。resize

ここは、resize()というメソッドをSceneクラスに追加します。という意味でした。細かいところはそのまま修正します。

public class Scene {
    ...
    private Projection projection;

    public Scene(int width, int height) {
        ...
        projection = new Projection(width, height);
    }
    ...
    public Projection getProjection() {
        return projection;
    }

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

Engineクラスを更新して、コンストラクターのパラメーターに適応させ、メソッドSceneを呼び出す必要もあります。

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

Projectionクラスのまとめ

ここまでの内容をまとめると、自分の解釈ですが、次のようになりました。
射影行列を扱うために作成したProjectionクラスは、Sceneクラスに依存する(フィールド変数にセットしている)ので

  1. リサイズ処理を行うときにJOMOLの部品である「Matrix4fクラス」の値を更新します。
  2. 次に作成するユニフォームクラスに「Matrix4fクラス」をセット、ユニフォームが射影行列を計算できるようにします。

ユニフォーム

透視投影行列を計算するためのインフラストラクチャができたので、それをどのように使用するのでしょうか? これをシェーダーで使用する必要があり、すべての頂点に適用する必要があります。最初は、頂点入力 (座標や色など) にバンドルすることを考えることができます。この場合、射影行列はすべての頂点に共通であるため、多くのスペースが無駄になります。Java コードで頂点に行列を掛けることも考えられます。しかし、そうなると、VBO は役に立たなくなり、グラフィックス カードで利用可能な処理能力を使用できなくなります。

答えは「ユニフォーム」を使うことです。Uniforms は、シェーダーが使用できるグローバル GLSL 変数であり、すべての要素またはモデルに共通のデータを渡すために使用します。それでは、ユニフォームが din シェーダー プログラムを使用する方法から始めましょう。頂点シェーダー コードを変更し、呼び出された新しいユニフォームを宣言し、projectionMatrixそれを使用して投影位置を計算する必要があります。

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec3 color;

out vec3 outColor;

uniform mat4 projectionMatrix;

void main()
{
    gl_Position = projectionMatrix * vec4(position, 1.0);
    outColor = color;
}

ご覧のとおりprojectionMatrix、4x4 行列として定義し、元の座標を乗算して位置を取得します。次に、射影行列の値をシェーダーに渡す必要があります。という名前の新しいクラスUniformMapを作成します。これにより、ユニフォームへの参照を作成し、その値を設定できます。次のように始まります。

package org.lwjglb.engine.graph;

import org.joml.Matrix4f;
import org.lwjgl.system.MemoryStack;

import java.util.*;

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

public class UniformsMap {

    private int programId;
    private Map<String, Integer> uniforms;

    public UniformsMap(int programId) {
        this.programId = programId;
        uniforms = new HashMap<>();
    }

    public void createUniform(String uniformName) {
        int uniformLocation = glGetUniformLocation(programId, uniformName);
        if (uniformLocation < 0) {
            throw new RuntimeException("Could not find uniform [" + uniformName + "] in shader program [" +
                    programId + "]");
        }
        uniforms.put(uniformName, uniformLocation);
    }
    ...
}

ご覧のとおり、コンストラクターはシェーダー プログラムの識別子を受け取り、メソッドで作成されるユニフォームへMapの参照 (インスタンス) を格納する を定義します。Uniforms 参照は、次の2 つのパラメーターを受け取る関数を呼び出すことによって取得されます。IntegercreateUniformglGetUniformLocation

シェーダー プログラムの識別子。
ユニフォームの名前 (シェーダー コードで定義されているものと一致する必要があります)。
ご覧のとおり、ユニフォームの作成はそれに関連付けられているデータ型に依存しません。そのユニフォームのデータを設定したい場合、さまざまなタイプに個別のメソッドが必要になります。ここまでで、必要なのは 4x4 マトリックスをロードするメソッドだけです。

public class UniformsMap {
    ...
    public void setUniform(String uniformName, Matrix4f value) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            Integer location = uniforms.get(uniformName);
            if (location == null) {
                throw new RuntimeException("Could not find uniform [" + uniformName + "]");
            }
            glUniformMatrix4fv(location.intValue(), false, value.get(stack.mallocFloat(16)));
        }
    }
}

SceneRenderこれで、クラスで上記のコードを使用できます。

public class SceneRender {
    ...
    private UniformsMap uniformsMap;

    public SceneRender() {
        ...
        createUniforms();
    }
    ...
    private void createUniforms() {
        uniformsMap = new UniformsMap(shaderProgram.getProgramId());
        uniformsMap.createUniform("projectionMatrix");
    }
    ...
    public void render(Scene scene) {
        ...
        uniformsMap.setUniform("projectionMatrix", scene.getProjection().getProjMatrix());
        ...
    }
}

ユニフォームのまとめ

射影行列、4 x 4マトリックスを計算するためシェーダープログラムと連携させるためのクラスがUniformsMapクラスでそれを
SceneRenderクラスで使用している。全体的には

  1. Projectionクラスが射影行列を保持する役目
  2. UniformsMapクラスがシェーダープログラムと連携する役目

メインメソッド

ほぼ完了です。正しくレンダリングされたクワッドを表示できるようになったので、プログラムを起動すると、色付きのクワッドのない黒い背景が得られます。何が起こっていますか?私たちは何かを壊しましたか?まあ、実際にはありません。シーンを見ているカメラの効果をシミュレートしていることを思い出してください。1 つは最も遠い平面 (1000f に等しい) で、もう 1 つは最も近い平面 (0.01f に等しい) です。座標は次のとおりです。

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。次のような巨大な正方形が表示されます。

現在起こっていることは、クワッドをカメラに近づけすぎていることです。実際にズームインしています。ここで z 座標にの値を割り当てると-1.0f、色付きのクワッドが表示されます。

public class Main implements IAppLogic {
    ...
    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-05", new Window.WindowOptions(), main);
        ...
    }
    ...
    public void init(Window window, Scene scene, Render render) {
        float[] positions = new float[]{
                -0.5f, 0.5f, -1.0f,
                -0.5f, -0.5f, -1.0f,
                0.5f, -0.5f, -1.0f,
                0.5f, 0.5f, -1.0f,
        };
        ...
    }
    ...
}

まとめ

  1. 追加したクラスは「Projectionクラス」「UniformsMapクラス」の二つ
  2. 透視投影行列を計算するためのProjectingクラス、JOMOLのMatrix4fクラス(Projectionクラスで使用している)
    ※この処理で、画面サイズに左右されずにちゃんと描画できる
  3. 「透視投影行列を計算するためのインフラ」=> Projectionクラスを使用するのはシェーダーで、4x4 行列を定義、UniformMapでシェーダーに追加したユニフォームの値を処理する

クラス図

<<<前回 次回 >>>

Java 3D LWJGL GitBook 〜レンダリング詳細 Chapter04〜

レンダリングの詳細

前回は単純な三角形の描画を行いました。そこで、レンダリングの大まかな流れを理解し、プログラムで使用しているクラスの関連も見ました。

この章では、OpenGL がどのように物事をレンダリングするかについて話し続けます。
三角形の代わりに四角形を描画し、各頂点の色などの追加データをメッシュに設定します。

プログラムの構成

Chapter-04の処理をクラス図にして、解析したいと思います。ここでクラスの関係を理解し、処理の流れを理解しておくとよいと判断しました。
クラス図に説明を説明を加えてみました。

ポイントとしては、SceneクラスはMeshクラスを保持していて、SceneRenderクラスで描画するときにSceneクラスからMeshを取得しているところです。
<SceneRender#render()>

    public void render(Scene scene) {
        shaderProgram.bind();
        // ここがポイントの処理
        scene.getMeshMap().values().forEach(mesh -> {
                    glBindVertexArray(mesh.getVaoId());
                    glDrawElements(GL_TRIANGLES, mesh.getNumVertices(), GL_UNSIGNED_INT, 0);
                }
        );

        glBindVertexArray(0);

        shaderProgram.unbind();
    }

レンダリングの確認

前回説明があった描画するのに、OpenGLでは三角形を複数描く事で、いろんな描画を実現しています。
四角形であれば、三角形を2回描けばよいです。

単純にこれを考えると、複雑な、例えばイルカのようなモデルを描画するとなると、重複する三角形が出てきます。
その例が下のコードです。

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,
}

四角を描くのであれば、二つの三角形の頂点データが必要になりますが、各頂点を描く時には、重複する部分が出てきてしまいます。
そこで、頂点と順序(Index)を示したデータがあれば、重複するのを防げます。

ドキュメントの記述では下のようになっています。

最終的には、重複した情報のためにさらに多くのメモリが必要になります。
しかし、大きな問題はこれではありません。最大の問題は、恥の頂点のためにシェーダーでプロセスを繰り返すことです。
ここで、インデックス バッファーが役に立ちます。四角形を描画するには、この方法で各頂点を一度だけ指定する必要があります: V1、V2、V3、V4)。
各頂点には、配列内の位置があります。V1 の位置は 0、V2 の位置は 1 などです。

プログラムの修正

chapter03のコードとchapter04のコードを比較します。変更点に注意しながら行います。
もともとの処理としては次の通りです。
Chapter03

  1. VAOのIDを生成して、作成するMeshクラス(インスタンス)で保持 ※なのでコンストラクタで処理を行っています。
  2. 位置VBOのIDを生成、VBOリストに追加 ※細かい処理は割愛します。
  3. SceneクラスでMeshクラスをマップで管理、この時にVAOをOpenGLにバインドして描画 ※呼び出し元での処理

Chapter04

  1. VAOのIDを生成して、作成するMeshクラス(インスタンス)で保持 ※なのでコンストラクタで処理を行っています。
  2. 位置VBOのIDを生成、VBOリストに追加 ※細かい処理は割愛します。
  3. 色VBOのIDを生成、VBOリストに追加、色バッファーをバッファーデータに追加 ※細かい処理は割愛します。
  4. インデックスVBOのIDを生成、VBOリストに追加、インデックスバッファーをバッファーデータに追加 ※細かい処理は割愛します。
  5. SceneクラスでMeshクラスをマップで管理、この時にVAOをOpenGLにバインドして描画 ※呼び出し元での処理

Meshクラス

コンストラクタ部分を抜き出します。見た目にコードの量が増えています。
単純にコードを追加した形となっています。具体的にはコメントの「// Color VBO」「// Index VBO」の部分です。

つまりは、位置VBOのみの登録しか行っていなかったが、他に色とインデックスVBOを追加したということです。
※コンストラクタの引数も変わっています。

Chapter03のコード

public class Mesh {
    private int numVertices;
    private int vaoId;
    private List<Integer> vboIdList;

    public Mesh(float[] positions, int numVertices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            this.numVertices = numVertices;
            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);

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

Chapter04のコード

public class Mesh {
    private int numVertices;
    private int vaoId;
    private List<Integer> vboIdList;

    public Mesh(float[] positions, float[] colors, 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);

            // Color VBO
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer colorsBuffer = stack.callocFloat(colors.length);
            colorsBuffer.put(0, colors);
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, colorsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(1);
            glVertexAttribPointer(1, 3, 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);
        }
    }
}

変更点の抜粋

以下のコードをchapter03に加えて、追加しただけです。
Chapter04

// Color VBO
vboId = glGenBuffers();
vboIdList.add(vboId);
FloatBuffer colorsBuffer = stack.callocFloat(colors.length);
colorsBuffer.put(0, colors);
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, colorsBuffer, GL_STATIC_DRAW);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, 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);

IAppLogicクラス(Mainクラス)

IAppLogicインターフェースを実装したクラス、つまりはMainクラスですが、このクラスの変更点を見てみましょう。

<Chapter03>

@Override
public void init(Window window, Scene scene, Render render) {
    float[] positions = new float[]{
            0.0f, 0.5f, 0.0f,
            -0.5f, -0.5f, 0.0f,
            0.5f, -0.5f, 0.0f
    };
    Mesh mesh = new Mesh(positions, 3);
    scene.addMesh("triangle", mesh);
}

<Chapter04>

public void init(Window window, Scene scene, Render render) {
    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,
    };
    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,
    };
    int[] indices = new int[]{
            0, 1, 3, 3, 1, 2,
    };
    Mesh mesh = new Mesh(positions, colors, indices);
    scene.addMesh("quad", mesh);
}

具体的には、コードが追加されている形での変更(更新)なのでその部分を抜粋すると次のようになります。
ズバリ、色VBOのデータ(配列)とインデックスVBOのデータ(配列)が追加されています。

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,
};
int[] indices = new int[]{
        0, 1, 3, 3, 1, 2,
};

頂点シェーダー (scene.vert)

シェーダーも同様に比較します。
<Chapter03>

#version 330

layout (location=0) in vec3 inPosition;

void main()
{
    gl_Position = vec4(inPosition, 3.0);
}

<Chapter04>

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec3 color;

out vec3 outColor;

void main()
{
gl_Position = vec4(position, 1.0);
outColor = color;
}

フラグメントシェーダー(scene.flag)

<Chapter03>

#version 330

out vec4 fragColor;

void main()
{
fragColor = vec4(0.0, 0.0, 1.0, 1.0);
}

<Chapter04>

#version 330

in  vec3 outColor;
out vec4 fragColor;

void main()
{
fragColor = vec4(outColor, 1.0);
}

中身を理解する

概要は大体理解できたので、実際にプロおグラムを実行して理解を深めます。

疑問点

  • colors, indeciesの配列値の意味がわからない
  • シェーダーと実行結果の関連が理解できてない

colors, indeciesの配列値の意味がわからない

問題のコードは次の部分です。positionsに関しては、描画する際の頂点を指定していて、順番にX,Y,Z座標になっています。

理解していること

理解していることを確認します。

positionの指定に関して

これは、次のような形で頂点を指定しています。
資格を描画するので点は4つ指定します。 ※V = 頂点

float[] positions = new float[]{
        -0.5f, 0.5f, 0.0f, // V1
        -0.5f, -0.5f, 0.0f,// V2
        0.5f, -0.5f, 0.0f, // V3
        0.5f, 0.5f, 0.0f,  // V4
};
inndecieの指定に関して

四角形なので、頂点が4つ、つまり順番は0から3までになり、左回りに番号が振られているので
V1 = 0, V2 = 1, V3 = 2, V4 = 3となります。そして、三角形を2つ指定するので次のような形になります

  • 三角形1 = V1, V2, V4
  • 三角形2 = V4, V2, V3

三角形2に関しては、左回転で順番を読み込むので、v4がはじめに来ます。

インデックス番号で表現すると下のようになります。

  • 0, 1, 3, 3, 1, 2
    int[] indices = new int[]{
        0, 1, 3, 3, 1, 2,
    };

問題の部分

ズバリ、色の部分です。これは値を変更して実行してみたらわかりました。
どこかでグラデーションの設定がしてあると思われます。
各頂点の色を指定しています。なので下のような出力結果が得られます。

ポイントとしては、シェーダー(scene.flag)の中身を固定値ではなく入力値を返すように修正したため、colorsの配列もデータとして渡すようにコードを変更しているところです。
<scene.flag>

#version 330

in  vec3 outColor; # 入力値
out vec4 fragColor;# 出力値

void main()
{
    fragColor = vec4(outColor, 1.0);
}

色のデータ

float[] colors = new float[]{
        1.5f, 0.0f, 0.0f, // 赤
        0.0f, 1.0f, 0.0f, // 緑
        0.0f, 0.0f, 1.0f, // 蒼
        1.0f, 1.0f, 1.0f, // 白
}

これを色VBOとしてColorBufferに登録する

FloatBuffer colorsBuffer = stack.callocFloat(colors.length);
colorsBuffer.put(0, colors);

最終的なコード

public class Main implements IAppLogic {
    // 省略
    @Override
    public void init(Window window, Scene scene, Render render) {
        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,
        };
        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,
        };
        int[] indices = new int[]{
                0, 1, 3, 3, 1, 2,
        };
        Mesh mesh = new Mesh(positions, colors, indices);
        scene.addMesh("quad", mesh);
    }
    // 省略
}

まとめ

Meshを作成するときに、頂点と、色、インデックスの指定を行い描画を行う処理順序が理解できた。
前回と比べて次の部分がキーポイントだった。

  1. 頂点VBO以外に、色VBOを追加している部分
  2. 頂点シェーダ(scene.vert)、フラグメントシェーダ(scene.frag)の出力を追加する
  3. MeshクラスがGPUに渡されて描画される(3章より)

<<< 前回 次回 >>>

Java 3D LWJGL GitBook 〜三角形の描画 Chapter03〜

あなたの最初の三角形

この章では、最初の三角形を画面にレンダリングし、プログラム可能なグラフィックス パイプラインの基礎を紹介します。しかし、その前に、まず座標系の基礎について説明します。後続の章で扱うテクニックとトピックをサポートするために、いくつかの基本的な数学的概念を簡単な方法で紹介しようとしています。読みやすさのために正確さを犠牲にする可能性のある単純化を想定しています。

アプリケーションを起動したときに、作成したプロジェクトとビルドパスの関係で、下のようなエラーが出るかもしれません。

Exception in thread "main" java.lang.RuntimeException: Error reading file [resources/shaders/scene.vert]

そんな時は、参照するパス指定が間違っているので、それを修正します。

自分の場合は、プロジェクトの構成が、lwjglbook/chapter-03/resources/...となっていたので、
「resources/shaders/scene.vert」の部分を「chapter-03/resources/shaders/scene.vert」に修正しました。

実行結果は、赤い三角形が表示されました。

では、これを実現するためにどのような実装を行っているのかを見ていきましょう。

座標についての簡単な説明

座標を指定することで、空間内のオブジェクトを見つけます。地図を考えてみてください。緯度または経度を指定して、地図上のポイントを指定します。数字のペアだけで、ポイントが正確に識別されます。その数値のペアはポイント座標です (マップは完全ではない楕円体である地球の投影であるため、実際にはもう少し複雑です。そのため、より多くのデータが必要ですが、それは良い例えです)。

2次元座標、(X,Y)

座標系は、1 つまたは複数の数値、つまり 1 つまたは複数のコンポーネントを使用して点の位置を一意に指定するシステムです。さまざまな座標系 (デカルト、極座標など) があり、座標をある座標系から別の座標系に変換できます。デカルト座標系を使用します。

デカルト座標系では、2 次元の座標は、2 つの直交軸 x と y までの符号付き距離を測定する 2 つの数値によって定義されます。

まずは、座標系の理解が必要らしいです。ポイントとしては、次の一言に尽きます。

数字のペアだけで、ポイントが正確に識別されます

地図の類推を続けると、座標系は原点を定義します。地理座標の場合、原点は赤道とゼロ子午線が交差するポイントに設定されます。原点をどこに設定するかによって、特定の点の座標が異なります。座標系は、軸の向きを定義することもできます。前の図では、右に移動すると x 座標が増加し、上に移動すると y 座標が増加します。しかし、別の座標を取得する別の軸方向を持つ別のデカルト座標系を定義することもできます。

なので、座標系の理解をします。単純に点(Vertex)を指定するのに2二次元であれば、下図のように

ご覧のとおり、座標を構成する数値のペアに適切な意味を与えるために、原点や軸方向などの任意のパラメーターを定義する必要があります。座標空間として、任意のパラメーターのセットを持つその座標系を参照します。一連の座標を操作するには、同じ座標空間を使用する必要があります。良いニュースは、平行移動と回転を実行するだけで、座標をある空間から別の空間に変換できることです。

3次元座標、(X,Y,Z)

3D 座標を扱う場合は、追加の軸である z 軸が必要です。3D 座標は、3 つの数値 (x、y、z) のセットによって形成されます。

3次元であれば、下図のようになります。

  • 2次元の場合:x, yの2軸を使って座標を表現 (0, 1)
  • 3次元の場合:x, y, zの3軸を使って座標を表現 (0, 1, 2)

そして、3次元の座標の取り方は2種類あります。

3D 座標は、左手と右手の 2 種類に分類できます。それがどのタイプかどうやってわかりますか?手を取り、親指と人差し指の間で「L」の字を作り、中指は他の 2 本と垂直な方向を向くようにします。親指は x 軸が増加する方向を指し、人差し指は y 軸が増加する方向を指し、中指は z 軸が増加する方向を指す必要があります。左手でそれができる場合は左利き、右手を使う必要がある場合は右利きです。

右手 左手

回転を適用する場合

2D 座標空間は、回転を適用することである空間から別の空間に変換できるため、すべて同等です。反対に、3D 座標空間はすべて等しいわけではありません。両方が同じ利き手である場合、つまり、両方が左利きまたは右利きである場合にのみ、回転を適用して一方から他方に変換できます。

いくつかの基本的なトピックを定義したので、3D グラフィックスを扱う際に一般的に使用される用語について説明しましょう。後の章で 3D モデルをレンダリングする方法を説明すると、異なる 3D 座標空間を使用することがわかります。これは、これらの座標空間のそれぞれにコンテキスト、目的があるためです。座標のセットは、何かを参照しない限り意味がありません。この座標 (40.438031, -3.676626) を調べると、彼らはあなたに何かを言うかもしれません。しかし、それらが幾何学的座標 (緯度と経度) であると言うと、マドリッドのある場所の座標であることがわかります。

3Dシーンについて

3D オブジェクトをロードすると、3D 座標のセットが取得されます。これらの座標は、オブジェクト座標空間と呼ばれる 3 次元座標空間で表されます。グラフィック デザイナーがこれらの 3D モデルを作成するとき、このモデルが表示される 3D シーンについて何も知らないため、モデルにのみ関連する座標空間を使用して座標を定義することしかできません。

ワールド座標について

3D シーンを描画する場合、すべての 3D オブジェクトは、いわゆるワールド空間座標空間に対して相対的になります。3D オブジェクト空間からワールド空間座標に変換する必要があります。一部のオブジェクトは、3D シーンで適切に表示するために、回転、引き伸ばし、または拡大および移動する必要があります。

また、表示される 3D 空間の範囲を制限する必要があります。これは、3D 空間でカメラを動かすようなものです。次に、ワールド空間座標をカメラまたはビュー空間座標に変換する必要があります。最後に、これらの座標を 2D の画面座標に変換する必要があるため、3D ビュー座標を 2D 画面座標空間に投影する必要があります。

次の図は、OpenGL 座標 (z 軸は画面に対して垂直) を示しており、座標は -1 から +1 の間です。

あなたの最初の三角形

これで、OpenGL を使用してシーンをレンダリングする際に行われるプロセスの学習を開始できます。古いバージョンの OpenGL、つまり固定機能のパイプラインに慣れている場合は、なぜそれほど複雑にする必要があるのか​​疑問に思ってこの章を終了するかもしれません。画面に単純な図形を描画するのに、それほど多くの概念やコード行は必要ないと考えるかもしれません。そう思っているあなたにアドバイスをさせてください。実際には、よりシンプルではるかに柔軟です。チャンスを与えるだけです。最新の OpenGL を使用すると、一度に 1 つの問題について考えることができ、コードとプロセスをより論理的な方法で整理できます。

グラフィックス パイプライン

最終的に 3D 表現を 2D 画面に描画する一連の手順は、グラフィックス パイプラインと呼ばれます。OpenGL の最初のバージョンでは、固定機能パイプラインと呼ばれるモデルが採用されていました。このモデルは、固定された一連の操作を定義するレンダリング プロセスの一連のステップを採用しました。プログラマーは、各ステップで使用できる関数のセットに制約され、いくつかのパラメーターを設定して微調整することができました。したがって、適用できる効果と操作は API 自体によって制限されていました (たとえば、「フォグの設定」または「ライトの追加」ですが、これらの機能の実装は固定されており、変更できませんでした)。

グラフィックス パイプラインは、次の手順で構成されています。

image text
頂点と点のリスト
変換とライティング
プリミティブの組み立て
ラスタライズ

ラスタライズ :ベクトル画像をビットマップ画像へと変換すること
テクスチャリング:テクスチャを壁紙のように貼り付けること

OpenGL 2.0 では、プログラム可能なパイプラインの概念が導入されました。このモデルでは、シェーダーと呼ばれる一連の特定のプログラムを使用して、グラフィックス パイプラインを構成するさまざまなステップを制御またはプログラムできます。次の図は、OpenGL プログラマブル パイプラインの簡略化されたバージョンを示しています。

レンダリング

レンダリングは、頂点バッファーの形式で頂点のリストを入力として取り始めます。しかし、頂点とは何ですか?頂点は、シーンをレンダリングするための入力として使用できる任意のデータ構造です。

頂点(Vertex)

具体的には、(X,Y) (X,Y,Z)の座標のこと。英語では「Vertex」

ここまでで、2D または 3D 空間の点を表す構造として考えることができます。また、3D 空間内の点をどのように記述しますか? x、y、z 座標を指定する。そして、頂点バッファとは何ですか? 頂点バッファーは、頂点配列を使用してレンダリングする必要があるすべての頂点をパックし、その情報をグラフィックス パイプラインのシェーダーで利用できるようにするもう 1 つのデータ構造です。

これらの頂点は、スクリーン スペースへの各頂点の投影位置を計算することを主な目的とする頂点シェーダーによって処理されます。このシェーダは、色やテクスチャに関連する他の出力も生成できますが、その主な目的は頂点をスクリーン スペースに投影すること、つまりドットを生成することです。

ジオメトリ処理

ジオメトリ:描画対象の形状や、形状を定義づける頂点の座標や線分

ジオメトリ処理段階では、頂点シェーダーによって変換された頂点を接続して三角形を形成します。これは、頂点が格納された順序を考慮し、異なるモデルを使用してそれらをグループ化することによって行われます。なぜ三角形?三角形は、グラフィック カードの基本的な作業単位のようなものです。これは、複雑な 3D シーンを構築するために組み合わせて変換できる単純な幾何学的形状です。このステージでは、特定のシェーダーを使用して頂点をグループ化することもできます。

<シェーダープログラム(scene.flag)>

#version 330

out vec4 fragColor;

void main()
{
    fragColor = vec4(0.0, 0.0, 1.0, 1.0);
}

ラスター化(ラスタライズ)

ラスター化段階では、前の段階で生成された三角形を取得し、それらをクリップして、ピクセル サイズのフラグメントに変換します。これらのフラグメントは、フラグメント シェーダーによるフラグメント処理段階で使用され、フレームバッファーに書き込まれる最終的な色を割り当てるピクセルを生成します。フレームバッファは、グラフィックス パイプラインの最終結果です。画面に描画する必要がある各ピクセルの値を保持します。

3D カードは、上記のすべての操作を並列化するように設計されていることに注意してください。入力データは、最終的なシーンを生成するために並行して処理されます。

それでは、最初のシェーダー プログラムを書き始めましょう。シェーダーは、ANSI C に基づいた GLSL 言語 (OpenGL Shading Language) を使用して記述されます。まず、次の内容で、ディレクトリのscene.vert下に「 」(拡張子は Vertex Shader の場合)という名前のファイルを作成します。resources\shaders

頂点シェーダー

<シェーダ・プログラム(scene.vert)>

#version 330

layout (location=0) in vec3 inPosition;

void main()
{
    gl_Position = vec4(inPosition, 3.0);
}
1行目

最初の行は、使用している GLSL 言語のバージョンを示すディレクティブです。次の表は、GLSL バージョン、そのバージョンに一致する OpenGL、および使用するディレクティブを関連付けています (Wikipedia: https://en.wikipedia.org/wiki/OpenGL_Shading_Language#Versions )。

GLS バージョン OpenGL バージョン シェーダープリプロセッサー
1.10.59 2.0 #バージョン 110
1.20.8 2.1 #バージョン 120
1.30.10 3.0 #バージョン 130
1.40.08 3.1 #バージョン 140
1.50.11 3.2 #バージョン150
3.30.6 3.3 #バージョン 330
4.00.9 4.0 #バージョン 400
4.10.6 4.1 #バージョン 410
4.20.11 4.2 #バージョン 420
4.30.8 4.3 #バージョン 430
4.40 4.4 #バージョン 440
4.50 4.5 #バージョン 450
2行目

2 行目は、このシェーダーの入力形式を指定します。OpenGL バッファー内のデータは、私たちが望むものであれば何でもかまいません。つまり、言語は、事前定義されたセマンティックを持つ特定のデータ構造を渡すことを強制しません。シェーダーの観点からは、データを含むバッファーを受け取ることを期待しています。それは、位置、追加情報を含む位置、またはその他必要なものです。この例では、頂点シェーダーの観点からは、float の配列を受け取っているだけです。バッファーを埋めるとき、シェーダーによって処理されるバッファー チャンクを定義します。

mainの中

vec3: 3 つの属性で構成されるベクトル
vec4: 4 つの属性のベクトル

そのため、まずそのチャンクを意味のあるものに変換する必要があります。この場合、位置 0 から開始して、3 つの属性 (x、y、z) で構成されるベクトルを受け取ることを期待していると言っています。

シェーダーには、他の C プログラムと同様に、この場合は非常に単純なメイン ブロックがあります。gl_Position変換を適用せずに、受け取った位置を出力変数に返すだけです。なぜ 3 つの属性のベクトルが 4 つの属性のベクトル (vec4) に変換されたのか疑問に思われるかもしれません。それの訳はgl_Position同次座標を使用しているため、結果は vec4 形式であると予想されます。つまり、(x, y, z, w) の形式で何かを期待しています。ここで、w は余分な次元を表します。別の次元を追加する理由 後の章では、実行する必要がある操作のほとんどがベクトルと行列に基づいていることがわかります。その余分な次元がない場合、これらの操作の一部を組み合わせることができません。たとえば、回転操作と平行移動操作を組み合わせることができませんでした。(これについて詳しく知りたい場合は、この余分な次元により、アフィン変換と線形変換を組み合わせることができます。これについては、Fletcher Dunn と Ian Parberry による優れた本「グラフィックスとゲーム開発のための 3D 数学入門」を読むことで学習できます。 )。

フラグメントシェーダー

最初のフラグメント シェーダーを見てみましょう。scene.fragresources ディレクトリの下に、次の内容の「 」(拡張子は Fragment Shader の拡張子) という名前のファイルを作成します。

#version 330

out vec4 fragColor;

void main()
{
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

構造は頂点シェーダーとよく似ています。この場合、各フラグメントに固定色を設定します。出力変数は 2 行目に定義され、vec4 fragColor として設定されます。

シェーダーを使うクラスの作成

シェーダーを作成したので、それらをどのように使用しますか? という名前の新しいクラスを作成する必要があります。このクラスはShaderProgram基本的に、さまざまなシェーダー モジュール (頂点、フラグメント) のソース コードを受け取り、それらをコンパイルしてリンクし、シェーダー プログラムを生成します。これは、従う必要がある一連の手順です。

一連の手順

  1. OpenGL プログラムを作成します。
  2. シェーダー プログラム モジュール (頂点シェーダーまたはフラグメント シェーダー) を読み込みます。
  3. シェーダーごとに、新しいシェーダー モジュールを作成し、そのタイプ (頂点、フラグメント) を指定します。
  4. シェーダーをコンパイルします。
  5. シェーダーをプログラムにアタッチします。
  6. プログラムをリンクします。
    最後に、シェーダー プログラムが GPU に読み込まれ、プログラム識別子という識別子を参照して使用できます。

ShaderProgramクラス

package org.lwjglb.engine.graph;

import org.lwjgl.opengl.GL30;
import org.lwjglb.engine.Utils;

import java.util.*;

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

public class ShaderProgram {

    private final int programId;

    public ShaderProgram(List<ShaderModuleData> shaderModuleDataList) {
        programId = glCreateProgram();
        if (programId == 0) {
            throw new RuntimeException("Could not create Shader");
        }

        List<Integer> shaderModules = new ArrayList<>();
        shaderModuleDataList.forEach(s -> shaderModules.add(createShader(Utils.readFile(s.shaderFile), s.shaderType)));

        link(shaderModules);
    }

    public void bind() {
        glUseProgram(programId);
    }

    public void cleanup() {
        unbind();
        if (programId != 0) {
            glDeleteProgram(programId);
        }
    }

    protected int createShader(String shaderCode, int shaderType) {
        int shaderId = glCreateShader(shaderType);
        if (shaderId == 0) {
            throw new RuntimeException("Error creating shader. Type: " + shaderType);
        }

        glShaderSource(shaderId, shaderCode);
        glCompileShader(shaderId);

        if (glGetShaderi(shaderId, GL_COMPILE_STATUS) == 0) {
            throw new RuntimeException("Error compiling Shader code: " + glGetShaderInfoLog(shaderId, 1024));
        }

        glAttachShader(programId, shaderId);

        return shaderId;
    }
    public int getProgramId() {
        return programId;
    }

    private void link(List<Integer> shaderModules) {
        glLinkProgram(programId);
        if (glGetProgrami(programId, GL_LINK_STATUS) == 0) {
            throw new RuntimeException("Error linking Shader code: " + glGetProgramInfoLog(programId, 1024));
        }

        shaderModules.forEach(s -> glDetachShader(programId, s));
        shaderModules.forEach(GL30::glDeleteShader);
    }

    public void unbind() {
        glUseProgram(0);
    }

    public void validate() {
        glValidateProgram(programId);
        if (glGetProgrami(programId, GL_VALIDATE_STATUS) == 0) {
            throw new RuntimeException("Error validating Shader code: " + glGetProgramInfoLog(programId, 1024));
        }
    }

    public record ShaderModuleData(String shaderFile, int shaderType) {
    }
}

のコンストラクターは、シェーダー モジュール typ (頂点、フラグメントなど) を定義するインスタンスShaderProgramのリストと、シェーダー モジュール コードを含むソース ファイルへのパスを受け取ります。ShaderModuleDataコンストラクターは、(メソッドを呼び出して) 各シェーダー モジュールを最初にコンパイルしcreateShader、最後に (メソッドを呼び出して) すべてをリンクすることにより、新しい OpenGL シェーダー プログラムを作成することから始めますlink。シェーダ プログラムがリンクされると、コンパイルされた頂点シェーダとフラグメント シェーダを解放できます( を呼び出すことによりglDetachShader)。

メソッドはvalidate、基本的にglValidateProgram関数を呼び出します。この関数は主にデバッグ目的で使用され、ゲームが本番段階に達したときに使用しないでください。このメソッドは、現在の OpenGL の状態でシェーダーが正しいかどうかを検証しようとします。これは、シェーダーが正しくても、現在の状態がシェーダーを実行するのに十分ではないという事実 (一部のデータがまだアップロードされていない可能性がある) が原因で、場合によっては検証が失敗する可能性があることを意味します。必要なすべての入力データと出力データが適切にバインドされたときに呼び出す必要があります (描画呼び出しを実行する直前がよいでしょう)。

ShaderProgramまた、このプログラムをレンダリングに使用するメソッド、つまりバインド、バインドを解除する別のメソッド (使用しない場合)、および最後に、リソースが不要になったときにすべてのリソースを解放するクリーンアップ メソッドも提供します。
?
という名前のユーティリティ クラスを作成しますUtils。この場合、ファイルを にロードする public メソッドを定義しますString。

Utilsクラス(ファイル読み込み)

package org.lwjglb.engine;

import java.io.IOException;
import java.nio.file.*;

public class Utils {

    private Utils() {
        // Utility class
    }

    public static String readFile(String filePath) {
        String str;
        try {
            str = new String(Files.readAllBytes(Paths.get(filePath)));
        } catch (IOException excp) {
            throw new RuntimeException("Error reading file [" + filePath + "]", excp);
        }
        return str;
    }
}

Sceneクラス

Sceneモデル、ライトなどの 3D シーンの値を保持するという名前の新しいクラスも必要になります。今では、描画したいモデルのメッシュ (頂点のセット) を引き裂くだけです。これは、そのクラスのソース コードです。

package org.lwjglb.engine.scene;

import org.lwjglb.engine.graph.Mesh;

import java.util.*;

public class Scene {

    private Map<String, Mesh> meshMap;

    public Scene() {
        meshMap = new HashMap<>();
    }

    public void addMesh(String meshId, Mesh mesh) {
        meshMap.put(meshId, mesh);
    }

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

    public Map<String, Mesh> getMeshMap() {
        return meshMap;
    }
}

Meshクラスについて

ご覧のとおりMesh、後で描画に使用されるマップにインスタンスを格納するだけです。しかし、とは何Meshですか?これは基本的に、頂点データを GPU にロードしてレンダリングに使用できるようにする方法です。クラスの詳細を説明する前に、Meshクラスでどのように使用できるかを見てみましょうMain。

Meshクラスの使い方(呼び出し方)
public class Main implements IAppLogic {

    public static void main(String[] args) {
        Main main = new Main();
        Engine gameEng = new Engine("chapter-03", new Window.WindowOptions(), main);
        gameEng.start();
    }
    ...
    @Override
    public void init(Window window, Scene scene, Render render) {
        float[] positions = new float[]{
                0.0f, 0.5f, 0.0f,
                -0.5f, -0.5f, 0.0f,
                0.5f, -0.5f, 0.0f
        };
        Mesh mesh = new Mesh(positions, 3);
        scene.addMesh("triangle", mesh);
    }
    ...
}

このinitメソッドでは、三角形の頂点の座標を含む float の配列を定義します。ご覧のとおり、その配列には構造がなく、そこにすべての座標をダンプするだけです。現状では、OpenGL は構造を認識できません。そのデータの。それは単なるフロートのシーケンスです。次の図は、座標系の三角形を示しています。

Meshクラス

そのデータの構造を定義して GPU にロードするMeshクラスは、次のように定義されるクラスです。

package org.lwjglb.engine.graph;

import org.lwjgl.opengl.GL30;
import org.lwjgl.system.MemoryStack;

import java.nio.FloatBuffer;
import java.util.*;

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

public class Mesh {

    private int numVertices;
    private int vaoId;
    private List<Integer> vboIdList;

    public Mesh(float[] positions, int numVertices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            this.numVertices = numVertices;
            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);

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

    public void cleanup() {
        vboIdList.stream().forEach(GL30::glDeleteBuffers);
        glDeleteVertexArrays(vaoId);
    }

    public int getNumVertices() {
        return numVertices;
    }

    public final int getVaoId() {
        return vaoId;
    }
}
VAOとVBO

ここでは、頂点配列オブジェクト (VAO) と頂点バッファー オブジェクト (VBO) という 2 つの重要な概念を紹介します。上記のコードで迷子になった場合は、最後に、描画したいオブジェクトをモデル化するデータをグラフィックス カード メモリに送信していることを思い出してください。保存すると、後で描画中に参照するための識別子が取得されます。

VBO(頂点バッファ オブジェクト)とは

まず頂点バッファ オブジェクト (VBO) から始めましょう。VBO は、頂点を格納するグラフィックス カード メモリに格納される単なるメモリ バッファです。これは、三角形をモデル化するフロートの配列を転送する場所です。前に述べたように、OpenGL はデータ構造について何も知りません。実際、座標だけでなく、テクスチャや色などの他の情報も保持できます。頂点配列オブジェクト (VAO) は、通常属性リストと呼ばれる 1 つ以上の VBO を含むオブジェクトです。各アトリビュート リストには、位置、色、テクスチャなどの 1 種類のデータを保持できます。各スロットには、必要なデータを自由に格納できます。

VAO(頂点配列オブジェクト)とは

VAO は、グラフィックス カードに格納されるデータの一連の定義をグループ化するラッパーのようなものです。VAO を作成すると、識別子が取得されます。その識別子を使用して、作成時に指定した定義を使用して、それとそれに含まれる要素をレンダリングします。

Meshクラスのコンストラクタ処理

それでは、上記のコードを確認してみましょう。最初に、VAO を作成し (glGenVertexArrays関数を呼び出して)、それをバインドします (glBindVertexArray関数を呼び出して)。その後、( を呼び出してglGenBuffers) VBO を作成し、データをそこに入れる必要があります。そのために、float の配列を に格納しますFloatBuffer。これは主に、C ベースの OpenGL ライブラリとインターフェイスする必要があるためです。そのため、float の配列をライブラリで管理できるものに変換する必要があります。

まとめると次のようになります。

  1. glGenVertexArrays関数を呼び出してVAOの作成
  2. glBindVertexArray関数でVAOのバインド
  3. VBOの生成
  4. VBOのIDをリストに追加
...
private int vaoId;
...
public Mesh(float[] positions, int numVertices) {
    ...
    // VAOの生成
    vaoId = glGenVertexArrays();
    // VAOのバインド
    glBindVertexArray(vaoId);
    // VBOの生成
    int vboId = glGenBuffers();
    // VBOをリストに格納
    vboIdList.add(vboId);
    ...
}
...

このクラスを使用してMemoryUtilオフヒープ メモリにバッファを作成し、OpenGL ライブラリからアクセスできるようにします。(put メソッドを使用して) データを保存した後、flip メソッドを使用してバッファーの位置を 0 の位置にリセットする必要があります (つまり、書き込みが終了したと言います)。Java オブジェクトは、ヒープと呼ばれる領域に割り当てられることに注意してください。ヒープは、JVM のプロセス メモリに確保された大量のメモリです。ヒープに格納されたメモリには、ネイティブ コードからアクセスできません (JNI、Java からネイティブ コードを呼び出すことを可能にするメカニズムでは、アクセスできません)。Java とネイティブ コードの間でメモリ データを共有する唯一の方法は、Java でメモリを直接割り当てることです。

以前のバージョンの LWJGL を使用している場合は、いくつかのトピックを強調することが重要です。BufferUtilsバッファーの作成にユーティリティ クラスを使用していないことに気付いたかもしれません。代わりにMemoryUtilクラスを使用します。これは、BufferUtilsがあまり効率的ではなく、下位互換性のためにのみ維持されているためです。代わりに、LWJGL 3 はバッファ管理の 2 つの方法を提案しています。
・自動管理バッファー、つまり、ガベージ コレクターによって自動的に収集されるバッファー。これらのバッファーは、主に短時間の操作、または GPU に転送され、プロセス メモリに存在する必要のないデータに使用されます。これは、org.lwjgl.system.MemoryStackクラスを使用して実現されます。
・手動で管理されるバッファー。この場合、終了したら慎重に解放する必要があります。これらのバッファは、長時間の操作または大量のデータを対象としています。これは、MemoryUtilクラスを使用して実現されます。
詳細については、 を参照して ください。
その後、( を呼び出して) VBO をバインドし、(関数glBindBufferを呼び出して) データ int ut をロードします。glBufferData次に、最も重要な部分です。データの構造を定義し、VAO の属性リストの 1 つに格納する必要があります。これは、次の行で行われます。

glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);

パラメータは次のとおりです。

・index: シェーダーがこのデータを予期する場所を指定します。
・size: 頂点属性ごとのコンポーネント数を指定します (1 から 4)。この場合、3D 座標を渡しているので、3 である必要があります。
・type: 配列内の各コンポーネントのタイプ (この場合は float) を指定します。
・normalized: 値を正規化するかどうかを指定します。
・stride: 連続する汎用頂点属性間のバイト オフセットを指定します。(後で説明します)。
・offset: バッファ内の最初のコンポーネントへのオフセットを指定します。

VBO と VAO が終了したら、バインドを解除できるようにします (0 にバインドします)。
glBindVertexArray(0);
自動管理バッファを使用しているため、try/catchブロックが終了すると、バッファは自動的にクリーンアップされます。

Meshクラスの概要

このMeshクラスは、cleanup基本的に VAO と VBO を解放するメソッドと、メッシュの頂点数と VAO の ID を取得するいくつかのゲッター メソッドによって完成されます。この要素をレンダリングするとき、描画操作を使用するときに VAO id を使用します。

それでは、これらすべてを機能させてみましょう。シーン内のすべてのモデルのレンダリングを実行するという名前の新しいクラスを作成し、SceneRender次のように定義します。

package org.lwjglb.engine.graph;

import org.lwjglb.engine.Window;
import org.lwjglb.engine.scene.Scene;

import java.util.*;

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

public class SceneRender {

    private ShaderProgram shaderProgram;

    public SceneRender() {
        List<ShaderProgram.ShaderModuleData> shaderModuleDataList = new ArrayList<>();
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/scene.vert", GL_VERTEX_SHADER));
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/scene.frag", GL_FRAGMENT_SHADER));
        shaderProgram = new ShaderProgram(shaderModuleDataList);
    }

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

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

        scene.getMeshMap().values().forEach(mesh -> {
                    glBindVertexArray(mesh.getVaoId());
                    glDrawArrays(GL_TRIANGLES, 0, mesh.getNumVertices());
                }
        );

        glBindVertexArray(0);

        shaderProgram.unbind();
    }
}

ご覧のとおり、コンストラクターで 2 つのShaderModuleDataインスタンス (1 つは頂点シェーダー用、もう 1 つはフラグメント用) を作成し、シェーダー プログラムを作成します。cleanupリソース (この場合はシェーダー プログラム) を解放するrenderメソッドと、描画を実行するメソッドを定義します。このメソッドは、メソッドを呼び出してシェーダー プログラムを使用することから始まりbindます。Scene次に、インスタンスに保存されているメッシュを繰り返し処理し、 (関数を呼び出して) それらをバインドし、(glBindVertexArray関数を呼び出して) VAO の頂点を描画しglDrawArraysます。最後に、VAO とシェーダー プログラムのバインドを解除して、状態を復元します。

Renderクラスの修正

Render最後に、クラスを使用するようにクラスを更新するだけですSceneRender。

package org.lwjglb.engine.graph;

import org.lwjgl.opengl.GL;
import org.lwjglb.engine.Window;
import org.lwjglb.engine.scene.Scene;

public class Render {

    private SceneRender sceneRender;

    public Render() {
        GL.createCapabilities();
        sceneRender = new SceneRender();
    }

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

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

このrenderメソッドは、フレームバッファをクリアし、(glViewportメソッドを呼び出して) ビューポートをウィンドウのサイズに設定することから始めます。つまり、レンダリング領域をそれらの寸法に設定します (これはフレームごとに行う必要はありませんが、ウィンドウのサイズ変更をサポートしたい場合は、この方法で行い、各フレームの潜在的な変更に適応させることができます)。その後、インスタンスrenderに対してメソッドを呼び出すだけです。SceneRenderそして、それだけです!手順を注意深く実行すると、次のように表示されます。

理解できなかった部分

次の文章が重要そうなのに、理解できませんでした。よく読んで今後理解したいと思います。
しかし、2週目なので補足を追加します。

3D オブジェクトをロードすると、3D 座標のセットが取得されます。

Objファイルを読み込むとき「3D 座標のセットが取得されます。」Objファイルは、以下のようなものです。

これらの座標は、オブジェクト座標空間と呼ばれる 3 次元座標空間で表されます。グラフィック デザイナーがこれらの
3D モデルを作成するとき、このモデルが表示される 3D シーンについて何も知らないため、モデルにのみ関連する座標空間を使用して座標を定義することしかできません。

多分グラフィックデザイナーはシーンについて知る必要がないので、コミュニケーションを取る際には気を付けましょうということだと思います。
そして、各3Dモデルを描画するためには「シーン」というプログラム的な要素、状態があり、それをプログラムでいうところのSceneクラス。

大まかな流れで理解する

  1. Mainクラスのインスタンス化
  2. Engineクラスのインスタンス化に伴い以下の設定
      1. ロジッククラス(Mainクラス)のセット
      1. Rennderクラスのセット
      1. シーンクラスのセット
      1. Meshクラスをインスタンス化、シーンクラスにセット
  3. Engine#start()を起動、Engine#run()を起動する

Engine#run()はゲームループになっている。

三角形の描画(理論編)

ここまでで、描画処理の学習準備が整いました。3Dモデル(シーン)の描画学習を始めます。
その前に、下のような文言があります。

画面に単純な図形を描画するのに、それほど多くの概念やコード行は必要ないと考えるかもしれません。
そう思っているあなたにアドバイスをさせてください。実際には、よりシンプルではるかに柔軟です。チャンスを与えるだけです。
最新の OpenGL を使用すると、一度に 1 つの問題について考えることができ、コードとプロセスをより論理的な方法で整理できます。

これに関して、「今回のプログラム(三角形を描画するプログラム)は単純なのでWindowクラス、Engineクラスなど作成する必要がないのでは?」と疑問に思うかもしれないが
実際にはよりシンプルではるかに柔軟です。チャンスを与えるだけです。」ということです。

つまり、各処理の担当クラスを作成し今後そのクラスに処理(例えるならば、担当業務)を追加していくということです。

理論編 グラフィックスパイプライン

次のような処理フローをグラフィックスパイプラインと呼びます。
この処理フローは次のような特徴があります。

固定された一連の操作を定義するレンダリング プロセスの一連のステップを採用しました。
プログラマーは、各ステップで使用できる関数のセットに制約され、いくつかのパラメーターを設定して微調整することができました。

しかし、これは旧バージョンのOpenGLで、下のようなデメリットがありました。

適用できる効果と操作は API 自体によって制限されていました
(たとえば、「フォグの設定」または「ライトの追加」ですが、これらの機能の実装は固定されており、変更できませんでした)。

<旧バージョンの処理フロー>

  1. 点(Vertex)と点のインデックスリストの処理
  2. 変形とライトの処理
  3. プリミティブの組み立て
  4. 抽象度の高い形式で記述された画像データを、コンピュータが最終的に出力することのできる画素の集まり(ビットマップ形式/ラスター形式)に変換
  5. 3Dモデル表面の質感を表現するための手法で、3Dオブジェクトの表面にテクスチャを壁紙のように貼り付けること
  6. Frame Bufferに出力

<新バージョン処理フロー>

  1. 点(Vertex)と点のインデックスリストの処理:VertexShader処理を追加
  2. ジオメトリの処理:GeometoryShader処理を追加
  3. プリミティブの組み立て
  4. 抽象度の高い形式で記述された画像データを、コンピュータが最終的に出力することのできる画素の集まり(ビットマップ形式/ラスター形式)に変換
  5. 3Dモデル表面の質感を表現するための手法で、3Dオブジェクトの表面にテクスチャを壁紙のように貼り付けること
  6. Frame Bufferに出力

ジオメトリとは

ジオメトリとは、幾何学、形状、などの意味を持つ英単語。 コンピュータグラフィックスにおける描画対象の形状や、形状を定義づける頂点の座標や線分、
面などの図形を表す式の係数といったデータの組み合わせを意味することが多い。
Vertexシェーダとは
3DCG では様々なモデルはポリゴン(面)で表示されています。ポリゴンは頂点と線で表現されます。
シェーダはこの頂点情報から面を作り、描画していくわけです。
レンダラが頂点情報を参照するときに呼び出すシェーダを、名前の通り Vertex シェーダ と呼びます。
Frame Bufferとは
フレームバッファとは、コンピュータ内部で一画面分の表示内容を丸ごと記憶しておくことができるメモリ領域やメモリ装置のこと。
画面に何かを描画する際にはまずソフトウェアがフレームバッファの内容を書き換え、
その内容を一定のタイミングでディスプレイなどの表示装置に転送することで画面上に更新が反映される。
これにより、描画処理の過程やその途中の状態が利用者の目に触れることを防ぐことができる。
メインメモリ(RAM)の一部に専用の領域を確保してフレームバッファとして使用する場合と、専用のメモリ装置を使用する場合がある。
ビデオカードなどに内蔵されているビデオメモリ(VRAM:Video RAM)のことをフレームバッファと呼ぶ場合もある。

レンダリング処理

レンダリング処理の順序

  1. 頂点バッファーの形式で頂点のリストを入力として取り始めます。
  2. 頂点シェーダ処理
  3. ジオメトリ処理
  4. フレームバッファへ出力

3D カードは、上記のすべての操作を並列化するように設計されていることに注意してください。入力データは、最終的なシーンを生成するために並行して処理されます。

単語 意味
頂点バッファー 頂点バッファーは、頂点配列を使用してレンダリングする必要があるすべての頂点をパックし、その情報をグラフィックス パイプラインのシェーダーで利用できるようにするデータ構造です
頂点シェーダー スクリーン スペースへの各頂点の投影位置を計算することを主な目的とす

色やテクスチャに関連する他の出力も生成できますが、その主な目的は頂点をスクリーン スペースに投影すること、つまりドットを生成することです。

ジオメトリ処理 頂点シェーダーによって変換された頂点を接続して三角形を形成します。これは、頂点が格納された順序を考慮し、異なるモデルを使用してそれらをグループ化することによって行われます。
グラフィック カードの基本的な作業単位のようなものです。これは、複雑な 3D シーンを構築するために組み合わせて変換できる単純な幾何学的形状です。このステージでは、特定のシェーダーを使用して頂点をグループ化することもできます。
ラスター化(処理) ジオメトリ処理段階で生成された三角形を取得し、それらをクリップして、ピクセル サイズのフラグメントに変換します。
これらのフラグメントは、フラグメント シェーダーによるフラグメント処理段階で使用され、フレームバッファーに書き込まれる最終的な色を割り当てるピクセルを生成します。
フレームバッファ フレームバッファは、グラフィックス パイプラインの最終結果です。画面に描画する必要がある各ピクセルの値を保持します。

頂点シェーダーを書く

ドキュメントに下のような記述があります。

それでは、最初のシェーダー プログラムを書き始めましょう。シェーダーは、ANSI C に基づいた GLSL 言語 (OpenGL Shading Language) を使用して記述されます。
まず、次の内容で、ディレクトリのscene.vert下に「 」(拡張子は Vertex Shader の場合)という名前のファイルを作成します。

各行に対して、説明をコメントで追加しました。つまり、このコードは3次元ベクトルを4次元ベクトルに変換しています。

// 使用している GLSL 言語のバージョンを示すディレクティブです。
#version 330

// このシェーダーの入力形式を指定します。
layout (location=0) in vec3 inPosition;

void main()
{
    // 4次元ベクトルをgl_Positionにセット
    gl_Position = vec4(inPosition, 1.0);
}

フラグメントシェーダーを書く

ドキュメントの説明配下の通り

構造は頂点シェーダーとよく似ています。この場合、各フラグメントに固定色を設定します。出力変数は 2 行目に定義され、vec4 fragColor として設定されます。

// 使用している GLSL 言語のバージョンを示すディレクティブです。
#version 330

// このシェーダーの出力変数
out vec4 fragColor;

void main()
{
    // フラグメントに固定色を設定
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

上記コードの場合は、フラグメントの色が固定で赤になっています。

  var colors = [
    1.0,  1.0,  1.0,  1.0,    // 白
    1.0,  0.0,  0.0,  1.0,    // 赤
    0.0,  1.0,  0.0,  1.0,    // 緑
    0.0,  0.0,  1.0,  1.0     // 青
  ];

シェーダーのまとめ

頂点シェーダーは入力の型を、フラグメントシェーダーは出力の色を指定している。

作成するクラスについて

今までのChapter-01, Chapter-02では記述していませんでしたが。ここで整理したいと思います。

使用しているクラス一覧

No パッケージ名 クラス名 役割
1 org.lwhglb.game Main メインメソッドを起動する
2 org.lwhglb.engine Engine タイトル, WinoowOption, IAplogicを引数にして、画面タイトル、ウィンドウ設定、APロジックを起動する
3 org.lwhglb.engine AppLogic 画面クリア、初期化、入力、更新)処理を実装するインターフェース
4 org.lwhglb.engine Utils ユーティリティクラス、現状ではファイル読みこみ処理の未実装
5 org.lwhglb.engine Window 内部クラスにWindowOptionを持つ、画面の設定、表示など画面周りの処理を行う
6 org.lwhglb.engine.graph Mesh まだ説明がない、三角形を描いている
7 org.lwhglb.engine.graph Render Meshクラスを描画する
8 org.lwhglb.engine.graph SceneRender 各頂点のバインド、描画処理を行っている
9 org.lwhglb.engine.graph ShaderProgram さまざまなシェーダー モジュール (頂点、フラグメント) のソース コードを受け取り、それらをコンパイルしてリンクし、シェーダー プログラムを生成
10 org.lwhglb.engine.scene Scene シーン描画のためのデータを管理するクラス

一般的なシェーダープログラムの動き

  1. OpenGL プログラムを作成します。
  2. シェーダー プログラム モジュール (頂点シェーダーまたはフラグメント シェーダー) を読み込みます。
  3. シェーダーごとに、新しいシェーダー モジュールを作成し、そのタイプ (頂点、フラグメント) を指定します。
  4. シェーダーをコンパイルします。
  5. シェーダーをプログラムにアタッチします。
  6. プログラムをリンクします。

最後に、シェーダー プログラムが GPU に読み込まれ、プログラム識別子という識別子を参照して使用できます。

ShaderProgramクラスの動き

Javaで作成されているソースコードのShaderProgramクラスの動きです。

コンストラクタ

コンストラクタの引数に、ShaderProgramのインナークラスShaderModuleDataを持っていて、ANSCのシェーダーのファイル(scene.flag, scene.vert)へのパスと
タイプ指定用のint型を指定します。つまりは下のようなコードがかけるわけです。

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

public class ShaderProgram {
    public SceneRender() {
        List<ShaderProgram.ShaderModuleData> shaderModuleDataList = new ArrayList<>();
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("chapter-03/resources/shaders/scene.vert", GL_VERTEX_SHADER));
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("chapter-03/resources/shaders/scene.frag", GL_FRAGMENT_SHADER));
        shaderProgram = new ShaderProgram(shaderModuleDataList);
    }
}

GL_VERTEX_SHADERとGL_FRAGMENT_SHADERは、org.lwjgl.opengl.GL30クラスで保持している定数です。
余談ですが、次のようなimport文は、クラスを宣言しなくても直接メソッドやフィールドにアクセスできいます。

下のコードは同じ結果を2回出力します。

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

public class Sample {
    public void test() {
        System.out.println("定数1: " + GL_VERTEX_SHADER + "定数2: " +  GL_FRAGMENT_SHADER);
        System.out.println("定数1: " + GL30.GL_VERTEX_SHADER + "定数2: " +  GL30.GL_FRAGMENT_SHADER);
    }
}

Meshクラスについて

実際に、サンプルプログラムで表示する三角形を表すクラスです。このクラスは次のプロパティ(属性、フィールド変数の事)を持っている。

  • 頂点数
  • VAOのID
  • VBOリスト(VBOのIDリスト)

VAOとVBO

ドキュメントに以下の説明があります。

頂点配列オブジェクト (VAO) と頂点バッファー オブジェクト (VBO) という 2 つの重要な概念を紹介します。上記のコードで迷子になった場合は、最後に、描画したいオブジェクトをモデル化するデータをグラフィックス カード メモリに送信していることを思い出してください。保存すると、後で描画中に参照するための識別子が取得されます。

頂点配列オブジェクト (VAO)
VAOは、通常属性リストと呼ばれる 1 つ以上の VBO を含むオブジェクトです。各アトリビュート リストには、位置、色、テクスチャなどの 1 種類のデータを保持できます。各スロットには、必要なデータを自由に格納できます。
頂点バッファー オブジェクト (VBO)
VBO は、頂点を格納するグラフィックス カード メモリに格納される単なるメモリ バッファです。これは、三角形をモデル化するフロートの配列を転送する場所です。前に述べたように、OpenGL はデータ構造について何も知りません。実際、座標だけでなく、テクスチャや色などの他の情報も保持できます。

Meshクラスの処理内容を確認

package org.lwjglb.engine.graph;

import org.lwjgl.opengl.GL30;
import org.lwjgl.system.MemoryStack;

import java.nio.FloatBuffer;
import java.util.*;

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

public class Mesh {

    private int numVertices;
    private int vaoId;
    private List<Integer> vboIdList;

    public Mesh(float[] positions, int numVertices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            this.numVertices = numVertices;
            vboIdList = new ArrayList<>();
            // VAOの生成とバインド
            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);

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

    public void cleanup() {
        vboIdList.stream().forEach(GL30::glDeleteBuffers);
        glDeleteVertexArrays(vaoId);
    }

    public int getNumVertices() {
        return numVertices;
    }

    public final int getVaoId() {
        return vaoId;
    }
}
  1. 最初に、glGenVertexArrays関数を呼び出してVAO を作成し 、glBindVertexArray関数を呼び出しそれをバインドします。
  2. glGenBuffers関数を呼び出して VBO を作成し、データをそこに入れる必要があります。そのために、float の配列を に格納しますFloatBuffer。
    これは主に、C ベースの OpenGL ライブラリとインターフェイスする必要があるためです。そのため、float の配列をライブラリで管理できるものに変換する必要があります。

ここら辺の処理は、SceneRenderクラスで行っています。

三角形の描画(実践編)

今まで、長々と理論部分を記述してきましたがここからは、プログラムの実行をメインに記述します。
具体的に、プログラムを実行、コードを読んで処理の内容を理解しようということです。

とりあえず描画しているコード

赤い三角形を描画している部分は次のところでした。
Render#render()

glViewport(0, 0, window.getWidth(), window.getHeight());

上記のメソッドでは次のものを指定しています。

  1. 第一引数、第二引数で表示する位置
  2. 第三引数、第四引数で三角形の底辺、高さ

これの値を変更してプログラムを実行してみましょう。自分は以下のようにコードを変更して実行しました。

  1. ファイルの参照先を変更する、SceneRenderクラスのコンストラクタの一部を修正しました。
    public SceneRender() {
    List<ShaderProgram.ShaderModuleData> shaderModuleDataList = new ArrayList<>();
    shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("chapter-03/resources/shaders/scene.vert", GL_VERTEX_SHADER));
    shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("chapter-03/resources/shaders/scene.frag", GL_FRAGMENT_SHADER));
    shaderProgram = new ShaderProgram(shaderModuleDataList);
    }
  2. 三角形の描画処理を変更薄る
    public void render(Window window, Scene scene) {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    //glViewport(0, 0, window.getWidth(), window.getHeight());
    glViewport(1, 2, window.getWidth() / 3, window.getHeight() / 2);
    sceneRender.render(scene);
    }

    そして実行結果外貨の通り

そして、さらにscene.flagの値を変更して下のようにします。

#version 330

out vec4 fragColor;

void main()
{
    fragColor = vec4(0.0, 0.0, 1.0, 1.0);
}

三角形の色が青くなりました。この部分で色を指定しているようです。

つぎはsceme.vert

#version 330

layout (location=0) in vec3 inPosition;

void main()
{
    gl_Position = vec4(inPosition, 3.0);
}

三角形の大きさが小さく、位置もずれました。画面の最大サイズが(おそらく)3倍になったようです。

クラス図

次は、Chapter04を学習します。(これから作成します)
<<<前回 次回 >>>