Java 3D LWJGL GitBook: 第16章 – オーディオ

第16章 - オーディオ

これまではグラフィックスを扱ってきましたが、すべてのゲームのもう 1 つの重要な側面はオーディオです。この章では、サウンドのサポートを追加します。

OpenAL

この章では、OpenAL (Open Audio Library) を使用してオーディオ機能について説明します。OpenAL はオーディオの OpenGL 版であり、抽象化レイヤーを介してサウンドを再生できます。そのレイヤーは、オーディオ サブシステムの根底にある複雑さから私たちを切り離します。それに加えて、3D シーンでサウンドを「レンダリング」することができます。サウンドを特定の場所に設定し、距離に応じて減衰させ、速度に応じて変更することができます (ドップラー効果のシミュレーション) 。

コーディングを開始する前に、OpenAL を扱う際に関係する主な要素を提示する必要があります。

バッファ。
ソース。
リスナー。
バッファには、音楽や効果音などのオーディオ データが格納されます。これらは、OpenGL ドメインのテクスチャに似ています。OpenAL は、オーディオ データが PCM (Pulse Coded Modulation) 形式 (モノラルまたはステレオ) であることを想定しているため、最初に PCM に変換せずに MP3 または OGG ファイルをダンプすることはできません。

次の要素はソースで、音を発する 3D 空間内の位置 (ポイント) を表します。ソースはバッファーに関連付けられ (一度に 1 つだけ)、次の属性で定義できます。

・位置、ソースの場所 ($$x$$、YとZ座標)。ちなみに、OpenAL は OpenGL と同様に右手デカルト座標系を使用するため、(単純化するために) ワールド座標はサウンド空間座標系の座標と同等であると想定できます。
・ソースの移動速度を指定する速度。これは、ドップラー効果をシミュレートするために使用されます。
・サウンドの強さを変更するために使用されるゲイン (アンプ係数のようなもの)。

ソースには、後でソース コードを説明するときに表示される追加の属性があります。

最後になりましたが、生成されたサウンドが聞こえるはずのリスナーです。Listener は、マイクが 3D オーディオ シーンでサウンドを受信するように設定されていることを表します。リスナーは 1 人だけです。そのため、オーディオのレンダリングはリスナーの視点から行われるとよく​​言われます。リスナーはいくつかの属性を共有しますが、向きなどの追加の属性があります。方向は、リスナーが向いている方向を表します。

つまり、オーディオ 3D シーンは、音を発する一連の音源と、それらを受信するリスナーによって構成されます。最終的に知覚される音は、リスナーからさまざまなソースまでの距離、それらの相対速度、および選択した伝播モデルによって異なります。ソースはバッファを共有し、同じデータを再生できます。次の図は、さまざまな要素タイプが含まれるサンプル 3D シーンを示しています。

実装

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

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

それでは、コーディングを始めましょう。org.lwjglb.engine.soundオーディオの処理を担当するすべてのクラスをホストする名前で新しいパッケージを作成します。まずSoundBuffer、OpenAL バッファを表すという名前のクラスから始めます。そのクラスの定義の一部を以下に示します。

package org.lwjglb.engine.sound;

import org.lwjgl.stb.STBVorbisInfo;
import org.lwjgl.system.*;

import java.nio.*;

import static org.lwjgl.openal.AL10.*;
import static org.lwjgl.stb.STBVorbis.*;
import static org.lwjgl.system.MemoryUtil.NULL;

public class SoundBuffer {
    private final int bufferId;

    private ShortBuffer pcm;

    public SoundBuffer(String filePath) {
        this.bufferId = alGenBuffers();
        try (STBVorbisInfo info = STBVorbisInfo.malloc()) {
            pcm = readVorbis(filePath, info);

            // Copy to buffer
            alBufferData(bufferId, info.channels() == 1 ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16, pcm, info.sample_rate());
        }
    }

    public void cleanup() {
        alDeleteBuffers(this.bufferId);
        if (pcm != null) {
            MemoryUtil.memFree(pcm);
        }
    }

    public int getBufferId() {
        return this.bufferId;
    }

    private ShortBuffer readVorbis(String filePath, STBVorbisInfo info) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            IntBuffer error = stack.mallocInt(1);
            long decoder = stb_vorbis_open_filename(filePath, error, null);
            if (decoder == NULL) {
                throw new RuntimeException("Failed to open Ogg Vorbis file. Error: " + error.get(0));
            }

            stb_vorbis_get_info(decoder, info);

            int channels = info.channels();

            int lengthSamples = stb_vorbis_stream_length_in_samples(decoder);

            ShortBuffer result = MemoryUtil.memAllocShort(lengthSamples * channels);

            result.limit(stb_vorbis_get_samples_short_interleaved(decoder, channels, result) * channels);
            stb_vorbis_close(decoder);

            return result;
        }
    }
}

クラスのコンストラクターはサウンド ファイル パスを想定し、そこから新しいバッファーを作成します。最初に行うことは、 への呼び出しで OpenAL バッファを作成することalGenBuffersです。最後に、サウンド バッファは、それが保持するデータへのポインタのような整数によって識別されます。バッファーが作成されたら、その中にオーディオ データをダンプします。コンストラクターは OGG 形式のファイルを想定しているため、PCM 形式に変換する必要があります。これはreadVorbis メソッドで行われます。

以前のバージョンの LWJGL にはWaveData、WAV 形式のオーディオ ファイルをロードするために使用される という名前のヘルパー クラスがありました。このクラスは LWJGL 3 にはもう存在しません。それでも、そのクラスからソース コードを取得して、ゲームで使用することができます (おそらく変更は必要ありません)。

このSoundBufferクラスはcleanup、リソースを使い終わったときにリソースを解放するメソッドも提供します。

という名前のクラスによって実装される OpenAL のモデル化を続けましょうSoundSource。クラスは以下に定義されています。

package org.lwjglb.engine.sound;

import org.joml.Vector3f;

import static org.lwjgl.openal.AL10.*;

public class SoundSource {

    private final int sourceId;

    public SoundSource(boolean loop, boolean relative) {
        this.sourceId = alGenSources();
        alSourcei(sourceId, AL_LOOPING, loop ? AL_TRUE : AL_FALSE);
        alSourcei(sourceId, AL_SOURCE_RELATIVE, relative ? AL_TRUE : AL_FALSE);
    }

    public void cleanup() {
        stop();
        alDeleteSources(sourceId);
    }

    public boolean isPlaying() {
        return alGetSourcei(sourceId, AL_SOURCE_STATE) == AL_PLAYING;
    }

    public void pause() {
        alSourcePause(sourceId);
    }

    public void play() {
        alSourcePlay(sourceId);
    }

    public void setBuffer(int bufferId) {
        stop();
        alSourcei(sourceId, AL_BUFFER, bufferId);
    }

    public void setGain(float gain) {
        alSourcef(sourceId, AL_GAIN, gain);
    }

    public void setPosition(Vector3f position) {
        alSource3f(sourceId, AL_POSITION, position.x, position.y, position.z);
    }

    public void stop() {
        alSourceStop(sourceId);
    }
}

音源クラスは、その位置、ゲイン、および再生、停止、一時停止を制御するメソッドを設定するいくつかのメソッドを提供します。複数のソースが同じバッファを共有できることに注意してください。SoundBufferクラスと同様に、aSoundSourceは各操作で使用される識別子によって識別されます。このクラスはcleanup、予約されたリソースを解放するメソッドも提供します。しかし、コンストラクターを調べてみましょう。alGenSources最初に行うことは、呼び出しでソースを作成することです。次に、コンストラクターのパラメーターを使用していくつかの興味深いプロパティを設定します。

最初のパラメータ はloop、再生するサウンドをループ モードにするかどうかを示します。デフォルトでは、再生アクションがソースに対して呼び出されると、オーディオ データが消費されると再生が停止します。一部のサウンドではこれで問題ありませんが、バックグラウンド ミュージックなど、何度も再生する必要があるサウンドもあります。オーディオがいつ停止して再生プロセスを再開するかを手動で制御する代わりに、単純に looping プロパティを true に設定します: “ alSourcei(sourceId, AL_LOOPING, AL_TRUE);”.

もう 1 つのパラメータrelativeは、ソースの位置がリスナーに対して相対的かどうかを制御します。この場合、ソースの位置を設定するときは、基本的に、OpenAL 3D シーンの位置ではなく、世界の位置ではなく、リスナーまでの距離 (ベクトルを使用) を定義しています。これは、「alSourcei(sourceId, AL_SOURCE_RELATIVE, AL_TRUE);”呼び出しによってアクティブ化されます。しかし、これを何に使用できますか?このプロパティは、たとえば、リスナーまでの距離によって影響を受けてはならない (減衰されてはならない) バックグラウンド サウンドの場合に役立ちます。たとえば、プレーヤーのコントロールに関連するバックグラウンド ミュージックやサウンド エフェクトを考えてみましょう。これらのソースを相対として設定し、それらの位置を(0,0,0)それらは減衰されません。

今度はリスナーの番です。これは、驚いたことに、 という名前のクラスによってモデル化されていSoundListenerます。これがそのクラスの定義です。

package org.lwjglb.engine.sound;

import org.joml.Vector3f;

import static org.lwjgl.openal.AL10.*;

public class SoundListener {

    public SoundListener(Vector3f position) {
        alListener3f(AL_POSITION, position.x, position.y, position.z);
        alListener3f(AL_VELOCITY, 0, 0, 0);
    }

    public void setOrientation(Vector3f at, Vector3f up) {
        float[] data = new float[6];
        data[0] = at.x;
        data[1] = at.y;
        data[2] = at.z;
        data[3] = up.x;
        data[4] = up.y;
        data[5] = up.z;
        alListenerfv(AL_ORIENTATION, data);
    }

    public void setPosition(Vector3f position) {
        alListener3f(AL_POSITION, position.x, position.y, position.z);
    }

    public void setSpeed(Vector3f speed) {
        alListener3f(AL_VELOCITY, speed.x, speed.y, speed.z);
    }
}

前のクラスとの違いは、リスナーを作成する必要がないことです。リスナーは常に 1 つ存在するため、作成する必要はありません。既に用意されています。したがって、コンストラクターでは、単に初期位置を設定するだけです。同じ理由で、cleanupメソッドは必要ありません。クラスには、クラスのようにリスナーの位置と速度を設定するためのメソッドもありますSoundSourceが、リスナーの向きを変更するための追加のメソッドがあります。オリエンテーションとは何かを確認しましょう。リスナーの向きは、次の図に示すように、「at」ベクトルと「up」ベクトルの 2 つのベクトルによって定義されます。

「at」ベクトルは基本的にリスナーが向いている方向を指し、デフォルトではその座標は(0,0,-1). 「上」ベクトルは、リスナーにとって上向きの方向を決定し、デフォルトでは(0,1,0)したがって、これら 2 つのベクトルのそれぞれの 3 つのコンポーネントが、alListenerfvメソッド呼び出しで設定されます。このメソッドは、float のセット (可変数の float) をプロパティ (この場合は向き) に転送するために使用されます。

続行する前に、ソースとリスナーの速度に関するいくつかの概念を強調する必要があります。ソースとリスナー間の相対速度により、OpenAL はドップラー効果をシミュレートします。ご存じないかもしれませんが、ドップラー効果は、あなたに近づいている移動物体が、遠ざかるときよりも高い周波数で放射しているように見える原因です. 問題は、単にソースまたはリスナーの速度を設定するだけでは、OpenAL はそれらの位置を更新しないということです。相対速度を使用してドップラー効果を計算しますが、位置は変更されません。そのため、移動するソースまたはリスナーをシミュレートする場合は、ゲーム ループ内でそれらの位置を更新する必要があります。

主要な要素をモデル化したので、それらを機能するように設定できます。OpenAL ライブラリを初期化する必要があるため、これを処理するという名前の新しいクラスを作成し、次のSoundManagerように開始します。

package org.lwjglb.engine.sound;

import org.joml.*;
import org.lwjgl.openal.*;
import org.lwjglb.engine.scene.Camera;

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

import static org.lwjgl.openal.AL10.alDistanceModel;
import static org.lwjgl.openal.ALC10.*;
import static org.lwjgl.system.MemoryUtil.NULL;

public class SoundManager {

    private final List<SoundBuffer> soundBufferList;
    private final Map<String, SoundSource> soundSourceMap;
    private long context;
    private long device;
    private SoundListener listener;

    public SoundManager() {
        soundBufferList = new ArrayList<>();
        soundSourceMap = new HashMap<>();

        device = alcOpenDevice((ByteBuffer) null);
        if (device == NULL) {
            throw new IllegalStateException("Failed to open the default OpenAL device.");
        }
        ALCCapabilities deviceCaps = ALC.createCapabilities(device);
        this.context = alcCreateContext(device, (IntBuffer) null);
        if (context == NULL) {
            throw new IllegalStateException("Failed to create OpenAL context.");
        }
        alcMakeContextCurrent(context);
        AL.createCapabilities(deviceCaps);
    }
    ...
}

このクラスは、SoundBufferおよびSoundSourceインスタンスへの参照を保持して、それらを追跡し、後で適切にクリーンアップします。SoundBuffers は List に格納されますが、SoundSources は に格納されるMapため、名前で取得できます。コンストラクターは OpenAL サブシステムを初期化します。

・デフォルトのデバイスを開きます。
・そのデバイスの機能を作成します。
・OpenGL のようなサウンド コンテキストを作成し、それを現在のコンテキストとして設定します。
このSoundManagerクラスは、音源とバッファを追加するメソッドと、cleanupすべてのリソースを解放するメソッドを定義します。

public class SoundManager {
    ...
    public void addSoundBuffer(SoundBuffer soundBuffer) {
        this.soundBufferList.add(soundBuffer);
    }

    public void addSoundSource(String name, SoundSource soundSource) {
        this.soundSourceMap.put(name, soundSource);
    }

    public void cleanup() {
        soundSourceMap.values().forEach(SoundSource::cleanup);
        soundSourceMap.clear();
        soundBufferList.forEach(SoundBuffer::cleanup);
        soundBufferList.clear();
        if (context != NULL) {
            alcDestroyContext(context);
        }
        if (device != NULL) {
            alcCloseDevice(device);
        }
    }
    ...
}

また、リスナーとソースを管理するメソッドと、playSoundSourceその名前を使用してサウンドを有効にするメソッドも提供します。

public class SoundManager {
    ...
    public SoundListener getListener() {
        return this.listener;
    }

    public SoundSource getSoundSource(String name) {
        return this.soundSourceMap.get(name);
    }

    public void playSoundSource(String name) {
        SoundSource soundSource = this.soundSourceMap.get(name);
        if (soundSource != null && !soundSource.isPlaying()) {
            soundSource.play();
        }
    }

    public void removeSoundSource(String name) {
        this.soundSourceMap.remove(name);
    }

    public void setAttenuationModel(int model) {
        alDistanceModel(model);
    }

    public void setListener(SoundListener listener) {
        this.listener = listener;
    }
    ...
}

このSoundManagerクラスには、カメラ位置を指定してリスナーの向きを更新するメソッドもあります。私たちの場合、カメラがあるときはいつでもリスナーが配置されます。では、カメラの位置と回転の情報が与えられた場合、「at」ベクトルと「up」ベクトルをどのように計算するのでしょうか? 答えは、カメラに関連付けられたビュー マトリックスを使用することです。「で」を変換する必要があります(0,0,-1)
そして「アップ」(0,1,0)
カメラの回転を考慮したベクトル。カメラに関連付けcameraMatrixられたビュー マトリックスを とします。それを達成するためのコードは次のとおりです。

public class SoundManager {
    ...
    public void updateListenerPosition(Camera camera) {
        Matrix4f viewMatrix = camera.getViewMatrix();
        listener.setPosition(camera.getPosition());
        Vector3f at = new Vector3f();
        viewMatrix.positiveZ(at).negate();
        Vector3f up = new Vector3f();
        viewMatrix.positiveY(up);
        listener.setOrientation(at, up);
    }
    ...
}

上記のコードは、以前に説明した説明と同等であり、より効率的なアプローチです。完全な逆行列を計算する必要がないだけで同じ結果が得られる、 JOMLライブラリで利用可能な高速な方法を使用します。このメソッドはLWJGL フォーラムでJOML の作成者によって提供されたものなので、そこで詳細を確認できます。ソース コードを確認すると、SoundManagerクラスがビュー マトリックスの独自のコピーを計算することがわかります。

それだけです。サウンドを再生するために必要なインフラストラクチャはすべて揃っています。Mainバックグラウンド サウンドを設定するクラスで使用するだけでよく、特定のアニメーション フレームで特定のサウンドがリスナーの位置に相対的な強度でアクティブになります。

public class Main implements IAppLogic {
    ...
    private SoundSource playerSoundSource;
    private SoundManager soundMgr;

    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-16", opts, main);
        ...
    }

    @Override
    public void cleanup() {
        soundMgr.cleanup();
    }

    @Override
    public void init(Window window, Scene scene, Render render) {
        ...
        lightAngle = 45;
        initSounds(bobEntity.getPosition(), camera);
    }

    private void initSounds(Vector3f position, Camera camera) {
        soundMgr = new SoundManager();
        soundMgr.setAttenuationModel(AL11.AL_EXPONENT_DISTANCE);
        soundMgr.setListener(new SoundListener(camera.getPosition()));

        SoundBuffer buffer = new SoundBuffer("resources/sounds/creak1.ogg");
        soundMgr.addSoundBuffer(buffer);
        playerSoundSource = new SoundSource(false, false);
        playerSoundSource.setPosition(position);
        playerSoundSource.setBuffer(buffer.getBufferId());
        soundMgr.addSoundSource("CREAK", playerSoundSource);

        buffer = new SoundBuffer("resources/sounds/woo_scary.ogg");
        soundMgr.addSoundBuffer(buffer);
        SoundSource source = new SoundSource(true, true);
        source.setBuffer(buffer.getBufferId());
        soundMgr.addSoundSource("MUSIC", source);
        source.play();
    }

    @Override
    public void input(Window window, Scene scene, long diffTimeMillis, boolean inputConsumed) {
        ...
        soundMgr.updateListenerPosition(camera);
    }

    @Override
    public void update(Window window, Scene scene, long diffTimeMillis) {
        animationData.nextFrame();
        if (animationData.getCurrentFrameIdx() == 45) {
            playerSoundSource.play();
        }
    }
}

最後のメモ。AL11.AL_EXPONENT_DISTANCEOpenAL では、alDistanceModel を使用して必要なモデル ( 、AL_EXPONENT_DISTANCE_CLAMPなど)を渡すことにより、減衰モデルを変更することもできます。それらで遊んで、結果を確認できます。

Java 3D LWJGL GitBook: 第15章 – アニメーション

第15章 - アニメーション

これまでは静的な 3D モデルしかロードしていませんでしたが、この章ではそれらをアニメーション化する方法を学びます。アニメーションについて考えるとき、最初のアプローチは、モデルの位置ごとに異なるメッシュを作成し、それらを GPU にロードし、それらを順番に描画して動きの錯覚を作成することです。このアプローチは一部のゲームには最適ですが、メモリ消費に関してはあまり効率的ではありません。ここで骨格アニメーションが活躍します。assimpを使用してこれらのモデルをロードする方法を学習します。

この章の完全なソース コードは、ここにあります。

ここでは、ボーン・アニメーションの実装を行うようです。マインクラフトみたいにプレーヤーをコントロールするときにも使用できそうです。
というか、マイクラはLWJGLで動いているので、できて当たり前ですがね(笑)※実装するのは難しそうだ。。。

単語の意味

  • ボーン:「骨」という意味だが、ここではモデルに対して、アニメーションするときの動きを関連図けるための格子のようなオブジェクトを指す
  • トラバース:XML文書を処理する場合、通常はDOMツリーを生成し、それに対して要素の追加や変更などの操作を行う。こうした場合、まずDOMツリーの根(ルート)からたどって、ツリーを上ったり下りたりしながらさまざまな処理を行うことになる。この移動をトラバースという。
  • ノード:レンダリング実行時の各処理を構成する機能ブロックのことです。

アンチエイリアシングのサポート

この章では、アンチエイリアスのサポートも追加します。この瞬間まで、モデルにのこぎりのようなエッジが見られたかもしれません。これらの影響を取り除くために、基本的にいくつかのサンプルの値を使用して各ピクセルの最終的な値を構築するアンチエイリアシングを適用します。この場合、4 つのサンプル値を使用します。イメージを作成する前に、これをウィンドウ ヒントとして設定する必要があります (それを制御する新しいウィンドウ オプションを追加します)。

public class Window {
    ...
    public Window(String title, WindowOptions opts, Callable<Void> resizeFunc) {
        ...
        if (opts.antiAliasing) {
            glfwWindowHint(GLFW_SAMPLES, 4);
        }
        glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
        glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
        ...
    }
    ...
    public static class WindowOptions {
        public boolean antiAliasing;
        ...
    }
}

このRenderクラスでは、マルチサンプリングを有効にする必要があります (それに加えて、サンプル モデルを適切にレンダリングするために顔のカリングを削除します)。

public class Render {
    ...
    public Render(Window window) {
        GL.createCapabilities();
        glEnable(GL_MULTISAMPLE);
        glEnable(GL_DEPTH_TEST);

        sceneRender = new SceneRender();
        guiRender = new GuiRender(window);
        skyBoxRender = new SkyBoxRender();
    }
    ...
}

序章

スケルトン アニメーションでは、モデルがアニメーション化される方法は、その下にあるスケルトンによって定義されます。スケルトンは、ボーンと呼ばれる特別な要素の階層によって定義されます。これらのボーンは、位置と回転によって定義されます。これは階層であるとも言いました。つまり、各ボーンの最終的な位置は、親の位置の影響を受けます。たとえば、手首について考えてみましょう。キャラクターが肘を動かしたり、肩を動かしたりすると、手首の位置が変更されます。

骨は、物理的な骨や関節を表す必要はありません。骨は、クリエイティブがアニメーションをモデル化できるようにするアーティファクトです。ボーンに加えて、3D モデルを構成する三角形を定義するポイントである頂点があります。しかし、スケルトン アニメーションでは、関連するボーンの位置に基づいて頂点が描画されます。

この章では、さまざまな情報源を参考にしましたが、アニメーション モデルの作成方法について非常に適切に説明している 2 つの情報源を見つけました。論文のソースは次の場所で参照できます。

現在のコードでアニメーションを含むモデルをロードすると、バインディング ポーズと呼ばれるものが得られます。(前の章のコードで) それを試すことができ、3D モデルを完全に見ることができます。バインディング ポイズは、アニメーションの影響をまったく受けずに、モデルの位置法線、テクスチャ座標を定義します。アニメーション化されたモデルは、基本的に次の追加情報を定義します。
・ 変換を構成できる階層を定義するボーンによって構成されたツリーのような構造。
・ 各メッシュには、頂点の位置、法線などに関する情報が含まれているだけでなく、この頂点がどのボーンに関連しているか (ボーン インデックスを使用) と、それらがどの程度影響を受けているか (つまり、重み係数を使用してエフェクトを調整している) に関する情報が含まれます。 .
・ 各ボーンに適用する必要がある特定の変換を定義する一連のアニメーション キー フレームは、拡張によって関連する頂点を変更します。モデルは複数のアニメーションを定義でき、それぞれが複数のアニメーション キー フレームで構成されている場合があります。アニメーションの場合、これらのキー フレーム (期間を定義する) を反復処理し、それらの間で相互運用することもできます。基本的に、特定の瞬間に、関連するボーンに関連付けられた変換を各頂点に適用します。

まず、アニメーション情報を含む assimp が扱う構造体をおさらいしましょう。まず、ボーンとウェイトの情報から始めます。それぞれについてAIMesh、頂点の位置、テクスチャ座標、およびインデックスにアクセスできます。メッシュにはボーンのリストも保存されます。各ボーンは、次の属性によって定義されます。
・名前。
・オフセット マトリックス: これは後で各ボーンで使用される最終的な変換を計算するために使用されます。
ボーンは重みのリストも指します。各重みは、次の属性によって定義されます。

・重み係数、つまり、各頂点に関連付けられたボーンの変換の影響を調整するために使用される数値です。
・頂点識別子、つまり現在のボーンに関連付けられている頂点。
次の図は、これらすべての要素間の関係を示しています。

したがって、各頂点は、位置、法線、およびテクスチャ座標を含むだけでなく、それらの頂点に影響を与えるボーンの一連のインデックス (通常は 4 つの値) ( jointIndices) と、その効果を調整する一連のウェイトを持ちます。各頂点は、最終的な位置を計算するために、各ジョイントに関連付けられた変換行列に従って変更されます。したがって、次の図に示すように、各メッシュに関連付けられた VAO を拡張してその情報を保持する必要があります。

Assimp シーン オブジェクトは、ノードの階層を定義します。各ノードは、名前と子ノードのリストによって定義されます。アニメーションはこれらのノードを使用して、適用する変換を定義します。この階層は、実際にボーンの階層として定義されます。すべてのボーンはノードであり、ルート ノードを除く親と、場合によっては子のセットを持ちます。ボーンではない特別なノードがあり、変換をグループ化するために使用され、変換を計算するときに処理する必要があります。もう 1 つの問題は、このノード階層がモデル全体から定義されていることです。メッシュごとに個別の階層はありません。

シーンは、一連のアニメーションも定義します。1 つのモデルに複数のアニメーションを設定して、キャラクターの歩き方、走り方などをモデル化できます。これらのアニメーションはそれぞれ、異なる変換を定義します。アニメーションには次の属性があります。

・名前。
・期間。つまり、アニメーションの継続時間です。アニメーションは、異なるフレームごとに各ノードに適用する必要がある変換のリストであるため、名前がわかりにくいかもしれません。
・アニメーション チャンネルのリスト。アニメーション チャネルには、特定の瞬間に、各ノードに適用する必要がある移動、回転、スケーリングの情報が含まれます。アニメーション チャネルに含まれるデータをモデル化するクラスはAINodeAnim. アニメーション チャネルは、キー フレームとして同化できます。
次の図は、上記のすべての要素間の関係を示しています。

フレームの特定の瞬間に、ボーンに適用される変換は、その瞬間のアニメーション チャネルで定義された変換に、ルート ノードまでのすべての親ノードの変換を乗算したものです。したがって、シーンに保存されている情報を抽出する必要があります。プロセスは次のとおりです。

・ノード階層を構築します。
・アニメーションごとに、(アニメーション ノードごとに) 各アニメーション チャネルを反復処理し、考えられるすべてのアニメーション フレームの各ボーンの変換行列を作成します。これらの変換行列は、骨に関連付けられたノードの変換行列と骨変換行列の組み合わせです。
・ルート ノードから開始し、フレームごとに、そのノードの変換マトリックスを作成します。これは、ノードの変換マトリックスに、そのノードの特定のフレームの移動、回転、スケール マトリックスの構成を掛けたものです。
・次に、そのノードに関連付けられたボーンを取得し、ボーンのオフセット マトリックスを乗算してその変換を補完します。結果は、その特定のフレームの関連するボーンに関連付けられた変換マトリックスになり、シェーダーで使用されます。
・その後、子ノードを繰り返し処理し、親ノードの変換行列を渡し、子ノードの変換と組み合わせて使用​​します。

実装

実装の説明は、以下の順序で行われてます。

  1. メインの変更点の説明
  2. 付随する部分の変更点

メインの変更点がModelLoaderなので、修正した後に、呼び出している部分とそれに付随する部分を修正していきます。

ModelLoaderクラスの変更を分析することから始めましょう。

public class ModelLoader {

    public static final int MAX_BONES = 150;
    private static final Matrix4f IDENTITY_MATRIX = new Matrix4f();
    ...
    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, boolean animation) {
        return loadModel(modelId, modelPath, textureCache, aiProcess_GenSmoothNormals | aiProcess_JoinIdenticalVertices |
                aiProcess_Triangulate | aiProcess_FixInfacingNormals | aiProcess_CalcTangentSpace | aiProcess_LimitBoneWeights |
                (animation ? 0 : aiProcess_PreTransformVertices));

    }
    ...
}

アニメーション付きのモデルをロードしているかどうかを示すために、メソッドに追加の引数 ( という名前animation) が必要です。loadModelその場合、aiProcess_PreTransformVerticesフラグは使用できません。このフラグは、ロードされたデータに対して何らかの変換を実行するため、モデルは原点に配置され、座標は数学 OpenGL 座標系に修正されます。このフラグはアニメーション データ情報を削除するため、アニメーション モデルには使用できません。

メッシュを処理している間、メッシュを処理しているときに、各頂点に関連付けられているボーンとウェイトも処理します。それらを処理している間、必要な変換を後で構築できるように、ボーンのリストを保存します。

public class ModelLoader {
    ...
    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {
        ...
        List<Bone> boneList = new ArrayList<>();
        for (int i = 0; i < numMeshes; i++) {
            AIMesh aiMesh = AIMesh.create(aiMeshes.get(i));
            Mesh mesh = processMesh(aiMesh, boneList);
            ...
        }
        ...
    }
    ...
    private static Mesh processMesh(AIMesh aiMesh, List<Bone> boneList) {
        ...
        AnimMeshData animMeshData = processBones(aiMesh, boneList);
        ...
        return new Mesh(vertices, normals, tangents, bitangents, textCoords, indices, animMeshData.boneIds, animMeshData.weights);
    }
    ...
}

新しいメソッドprocessBonesは次のように定義されます。

public class ModelLoader {
    ...
    private static AnimMeshData processBones(AIMesh aiMesh, List<Bone> boneList) {
        List<Integer> boneIds = new ArrayList<>();
        List<Float> weights = new ArrayList<>();

        Map<Integer, List<VertexWeight>> weightSet = new HashMap<>();
        int numBones = aiMesh.mNumBones();
        PointerBuffer aiBones = aiMesh.mBones();
        for (int i = 0; i < numBones; i++) {
            AIBone aiBone = AIBone.create(aiBones.get(i));
            int id = boneList.size();
            Bone bone = new Bone(id, aiBone.mName().dataString(), toMatrix(aiBone.mOffsetMatrix()));
            boneList.add(bone);
            int numWeights = aiBone.mNumWeights();
            AIVertexWeight.Buffer aiWeights = aiBone.mWeights();
            for (int j = 0; j < numWeights; j++) {
                AIVertexWeight aiWeight = aiWeights.get(j);
                VertexWeight vw = new VertexWeight(bone.boneId(), aiWeight.mVertexId(),
                        aiWeight.mWeight());
                List<VertexWeight> vertexWeightList = weightSet.get(vw.vertexId());
                if (vertexWeightList == null) {
                    vertexWeightList = new ArrayList<>();
                    weightSet.put(vw.vertexId(), vertexWeightList);
                }
                vertexWeightList.add(vw);
            }
        }

        int numVertices = aiMesh.mNumVertices();
        for (int i = 0; i < numVertices; i++) {
            List<VertexWeight> vertexWeightList = weightSet.get(i);
            int size = vertexWeightList != null ? vertexWeightList.size() : 0;
            for (int j = 0; j < Mesh.MAX_WEIGHTS; j++) {
                if (j < size) {
                    VertexWeight vw = vertexWeightList.get(j);
                    weights.add(vw.weight());
                    boneIds.add(vw.boneId());
                } else {
                    weights.add(0.0f);
                    boneIds.add(0);
                }
            }
        }

        return new AnimMeshData(Utils.listFloatToArray(weights), Utils.listIntToArray(boneIds));
    }
    ...
}

このメソッドは、特定のメッシュのボーン定義をトラバースし、それらのウェイトを取得して生成し、3 つのリストを埋めます。

・boneList: オフセット マトリックスを含むボーンのリストが含まれます。後で最終的な骨の変換を計算するために使用します。Boneその情報を保持するために、という名前の新しいクラスが作成されました。このリストには、すべてのメッシュのボーンが含まれます。
・boneIds: の各頂点のボーンの識別子のみが含まれますMesh。ボーンは、レンダリング時の位置によって識別されます。このリストには、特定のメッシュのボーンのみが含まれています。
・weightsMesh:関連するボーンに適用されるの各頂点の重みが含まれています。
このメソッドで取得された情報は、AnimMeshDataレコードにカプセル化されます (クラス内で定義されますModelLoader)。newBoneとVertexWeightclass もレコードです。それらは次のように定義されます。

public class ModelLoader {
    ...
    public record AnimMeshData(float[] weights, int[] boneIds) {
    }

    private record Bone(int boneId, String boneName, Matrix4f offsetMatrix) {
    }

    private record VertexWeight(int boneId, int vertexId, float weight) {
    }
}

s とsを配列Utilsに変換するために、クラスに 2 つの新しいメソッドも作成しました。Listfloatint

public class Utils {
    ...
    public static float[] listFloatToArray(List<Float> list) {
        int size = list != null ? list.size() : 0;
        float[] floatArr = new float[size];
        for (int i = 0; i < size; i++) {
            floatArr[i] = list.get(i);
        }
        return floatArr;
    }

    public static int[] listIntToArray(List<Integer> list) {
        return list.stream().mapToInt((Integer v) -> v).toArray();
    }
    ...
}

メソッドに戻るとloadModel、メッシュとマテリアルを処理したら、アニメーション データ (各アニメーションとその変換に関連付けられたさまざまなアニメーション キー フレーム) を処理します。そのすべての情報もModelクラスに保存されます。

public class ModelLoader {
    ...
    public static Model loadModel(String modelId, String modelPath, TextureCache textureCache, int flags) {
        ...
        List<Model.Animation> animations = new ArrayList<>();
        int numAnimations = aiScene.mNumAnimations();
        if (numAnimations > 0) {
            Node rootNode = buildNodesTree(aiScene.mRootNode(), null);
            Matrix4f globalInverseTransformation = toMatrix(aiScene.mRootNode().mTransformation()).invert();
            animations = processAnimations(aiScene, boneList, rootNode, globalInverseTransformation);
        }

        aiReleaseImport(aiScene);

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

メソッドは非常に単純です。buildNodesTreeノードのツリーを構築するルート ノードから開始して、ノード階層をトラバースするだけです。

public class ModelLoader {
    ...
    private static Node buildNodesTree(AINode aiNode, Node parentNode) {
        String nodeName = aiNode.mName().dataString();
        Node node = new Node(nodeName, parentNode, toMatrix(aiNode.mTransformation()));

        int numChildren = aiNode.mNumChildren();
        PointerBuffer aiChildren = aiNode.mChildren();
        for (int i = 0; i < numChildren; i++) {
            AINode aiChildNode = AINode.create(aiChildren.get(i));
            Node childNode = buildNodesTree(aiChildNode, node);
            node.addChild(childNode);
        }
        return node;
    }
    ...
}

このtoMatrixメソッドは assimp 行列を JOML 行列に変換するだけです:

public class ModelLoader {
    ...
    private static Matrix4f toMatrix(AIMatrix4x4 aiMatrix4x4) {
        Matrix4f result = new Matrix4f();
        result.m00(aiMatrix4x4.a1());
        result.m10(aiMatrix4x4.a2());
        result.m20(aiMatrix4x4.a3());
        result.m30(aiMatrix4x4.a4());
        result.m01(aiMatrix4x4.b1());
        result.m11(aiMatrix4x4.b2());
        result.m21(aiMatrix4x4.b3());
        result.m31(aiMatrix4x4.b4());
        result.m02(aiMatrix4x4.c1());
        result.m12(aiMatrix4x4.c2());
        result.m22(aiMatrix4x4.c3());
        result.m32(aiMatrix4x4.c4());
        result.m03(aiMatrix4x4.d1());
        result.m13(aiMatrix4x4.d2());
        result.m23(aiMatrix4x4.d3());
        result.m33(aiMatrix4x4.d4());

        return result;
    }
    ...
}

メソッドは次のprocessAnimationsように定義されます。

public class ModelLoader {
    ...
    private static List<Model.Animation> processAnimations(AIScene aiScene, List<Bone> boneList,
                                                           Node rootNode, Matrix4f globalInverseTransformation) {
        List<Model.Animation> animations = new ArrayList<>();

        // Process all animations
        int numAnimations = aiScene.mNumAnimations();
        PointerBuffer aiAnimations = aiScene.mAnimations();
        for (int i = 0; i < numAnimations; i++) {
            AIAnimation aiAnimation = AIAnimation.create(aiAnimations.get(i));
            int maxFrames = calcAnimationMaxFrames(aiAnimation);

            List<Model.AnimatedFrame> frames = new ArrayList<>();
            Model.Animation animation = new Model.Animation(aiAnimation.mName().dataString(), aiAnimation.mDuration(), frames);
            animations.add(animation);

            for (int j = 0; j < maxFrames; j++) {
                Matrix4f[] boneMatrices = new Matrix4f[MAX_BONES];
                Arrays.fill(boneMatrices, IDENTITY_MATRIX);
                Model.AnimatedFrame animatedFrame = new Model.AnimatedFrame(boneMatrices);
                buildFrameMatrices(aiAnimation, boneList, animatedFrame, j, rootNode,
                        rootNode.getNodeTransformation(), globalInverseTransformation);
                frames.add(animatedFrame);
            }
        }
        return animations;
    }
    ...
}

このメソッドはインスタンスの を返しListますModel.Animation。モデルには複数のアニメーションを含めることができるため、アニメーションはインデックスごとに保存されることに注意してください。これらのアニメーションごとに、アニメーション フレーム (Model.AnimatedFrameインスタンス) のリストを作成します。これは、基本的に、モデルを構成する各ボーンに適用される変換マトリックスのリストです。アニメーションごとに、次のcalcAnimationMaxFramesように定義されているメソッドを呼び出して、最大フレーム数を計算します。

public class ModelLoader {
    ...
    private static int calcAnimationMaxFrames(AIAnimation aiAnimation) {
        int maxFrames = 0;
        int numNodeAnims = aiAnimation.mNumChannels();
        PointerBuffer aiChannels = aiAnimation.mChannels();
        for (int i = 0; i < numNodeAnims; i++) {
            AINodeAnim aiNodeAnim = AINodeAnim.create(aiChannels.get(i));
            int numFrames = Math.max(Math.max(aiNodeAnim.mNumPositionKeys(), aiNodeAnim.mNumScalingKeys()),
                    aiNodeAnim.mNumRotationKeys());
            maxFrames = Math.max(maxFrames, numFrames);
        }

        return maxFrames;
    }
    ...
}

クラスの変更を確認する前に、アニメーション情報を保持するためModelLoaderのクラスの変更を確認しましょう。Model

public class Model {
    ...
    private List<Animation> animationList;
    ...
    public Model(String id, List<Material> materialList, List<Animation> animationList) {
        entitiesList = new ArrayList<>();
        this.id = id;
        this.materialList = materialList;
        this.animationList = animationList;
    }
    ...
    public List<Animation> getAnimationList() {
        return animationList;
    }
    ...
    public record AnimatedFrame(Matrix4f[] boneMatrices) {
    }

    public record Animation(String name, double duration, List<AnimatedFrame> frames) {
    }
}

ご覧のとおり、モデルに関連付けられたアニメーションのリストを保存します。それぞれのアニメーションは、名前、持続時間、およびアニメーション フレームのリストによって定義されます。基本的には、各ボーンに適用されるボーン変換マトリックスを保存するだけです。

ModelLoaderクラスに戻ると、各AINodeAnimインスタンスは、特定のフレームのモデル内のノードに適用されるいくつかの変換を定義します。特定のノードに対するこれらの変換は、AINodeAnim実例。これらの変換は、位置の移動、回転、およびスケーリング値の形式で定義されます。ここでの秘訣は、たとえば、特定のノードの移動値は特定のフレームで停止できますが、回転とスケーリングの値は次のフレームで継続できるということです。この場合、回転やスケーリングよりも移動値が少なくなります。したがって、フレームの最大数を計算するには、最大値を使用することをお勧めします。これはノードごとに定義されるため、問題はさらに複雑になります。ノードは、最初のフレームにいくつかの変換を定義するだけで、残りのフレームにそれ以上の変更を適用することはできません。この場合、常に最後に定義された値を使用する必要があります。したがって、ノードに関連付けられたすべてのアニメーションの最大数を取得します。

メソッドに戻ると、processAnimationsその情報を使用して、さまざまなフレームを反復処理し、メソッドを呼び出してボーンの変換マトリックスを構築する準備が整いましたbuildFrameMatrices。フレームごとに、ルート ノードから開始し、ノード階層の上から下に再帰的に変換を適用します。は次のbuildFrameMatricesように定義されます。

public class ModelLoader {
    ...
    private static void buildFrameMatrices(AIAnimation aiAnimation, List<Bone> boneList, Model.AnimatedFrame animatedFrame,
                                           int frame, Node node, Matrix4f parentTransformation, Matrix4f globalInverseTransform) {
        String nodeName = node.getName();
        AINodeAnim aiNodeAnim = findAIAnimNode(aiAnimation, nodeName);
        Matrix4f nodeTransform = node.getNodeTransformation();
        if (aiNodeAnim != null) {
            nodeTransform = buildNodeTransformationMatrix(aiNodeAnim, frame);
        }
        Matrix4f nodeGlobalTransform = new Matrix4f(parentTransformation).mul(nodeTransform);

        List<Bone> affectedBones = boneList.stream().filter(b -> b.boneName().equals(nodeName)).toList();
        for (Bone bone : affectedBones) {
            Matrix4f boneTransform = new Matrix4f(globalInverseTransform).mul(nodeGlobalTransform).
                    mul(bone.offsetMatrix());
            animatedFrame.boneMatrices()[bone.boneId()] = boneTransform;
        }

        for (Node childNode : node.getChildren()) {
            buildFrameMatrices(aiAnimation, boneList, animatedFrame, frame, childNode, nodeGlobalTransform,
                    globalInverseTransform);
        }
    }
    ...
}

ノードに関連付けられた変換を取得します。次に、このノードにアニメーション ノードが関連付けられているかどうかを確認します。その場合、処理しているフレームに適用される適切な移動、回転、およびスケーリング変換を取得する必要があります。その情報を使用して、そのノードに関連付けられたボーンを取得し、その特定のフレームの各ボーンの変換行列を次のように乗算して更新します。

・モデルの逆グローバル変換行列 (ルート ノード変換行列の逆行列)。
・ノードの変換マトリックス。
・ボーン オフセット マトリックス。
その後、ノード変換マトリックスをそれらの子ノードの親マトリックスとして使用して、子ノードを反復処理します。

public class ModelLoader {
    ...
    private static Matrix4f buildNodeTransformationMatrix(AINodeAnim aiNodeAnim, int frame) {
        AIVectorKey.Buffer positionKeys = aiNodeAnim.mPositionKeys();
        AIVectorKey.Buffer scalingKeys = aiNodeAnim.mScalingKeys();
        AIQuatKey.Buffer rotationKeys = aiNodeAnim.mRotationKeys();

        AIVectorKey aiVecKey;
        AIVector3D vec;

        Matrix4f nodeTransform = new Matrix4f();
        int numPositions = aiNodeAnim.mNumPositionKeys();
        if (numPositions > 0) {
            aiVecKey = positionKeys.get(Math.min(numPositions - 1, frame));
            vec = aiVecKey.mValue();
            nodeTransform.translate(vec.x(), vec.y(), vec.z());
        }
        int numRotations = aiNodeAnim.mNumRotationKeys();
        if (numRotations > 0) {
            AIQuatKey quatKey = rotationKeys.get(Math.min(numRotations - 1, frame));
            AIQuaternion aiQuat = quatKey.mValue();
            Quaternionf quat = new Quaternionf(aiQuat.x(), aiQuat.y(), aiQuat.z(), aiQuat.w());
            nodeTransform.rotate(quat);
        }
        int numScalingKeys = aiNodeAnim.mNumScalingKeys();
        if (numScalingKeys > 0) {
            aiVecKey = scalingKeys.get(Math.min(numScalingKeys - 1, frame));
            vec = aiVecKey.mValue();
            nodeTransform.scale(vec.x(), vec.y(), vec.z());
        }

        return nodeTransform;
    }
    ...
}

インスタンスは、AINodeAnim移動、回転、およびスケーリング情報を含む一連のキーを定義します。これらのキーは、特定の瞬間を参照します。情報は時間順に並べられていると仮定し、各フレームに適用される変換を含む行列のリストを作成します。前に述べたように、これらの変換の一部は特定のフレームで「停止」する可能性があるため、最後のフレームには最後の値を使用する必要があります。

メソッドは次のfindAIAnimNodeように定義されます。

public class ModelLoader {
    ...
    private static AINodeAnim findAIAnimNode(AIAnimation aiAnimation, String nodeName) {
        AINodeAnim result = null;
        int numAnimNodes = aiAnimation.mNumChannels();
        PointerBuffer aiChannels = aiAnimation.mChannels();
        for (int i = 0; i < numAnimNodes; i++) {
            AINodeAnim aiNodeAnim = AINodeAnim.create(aiChannels.get(i));
            if (nodeName.equals(aiNodeAnim.mNodeName().dataString())) {
                result = aiNodeAnim;
                break;
            }
        }
        return result;
    }
    ...
}

Meshボーン インデックスとボーン ウェイトに新しい VBO を割り当てるには、クラスを更新する必要があります。最大 4 つのウェイト (および頂点ごとの関連付けられたボーン インデックス) を使用することがわかります。

public class Mesh {

    public static final int MAX_WEIGHTS = 4;
    ...
    public Mesh(float[] positions, float[] normals, float[] tangents, float[] bitangents, float[] textCoords, int[] indices) {
        this(positions, normals, tangents, bitangents, textCoords, indices,
                new int[Mesh.MAX_WEIGHTS * positions.length / 3], new float[Mesh.MAX_WEIGHTS * positions.length / 3]);
    }

    public Mesh(float[] positions, float[] normals, float[] tangents, float[] bitangents, float[] textCoords, int[] indices,
                int[] boneIndices, float[] weights) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            ...
            // Bone weights
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer weightsBuffer = MemoryUtil.memAllocFloat(weights.length);
            weightsBuffer.put(weights).flip();
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, weightsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(5);
            glVertexAttribPointer(5, 4, GL_FLOAT, false, 0, 0);

            // Bone indices
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            IntBuffer boneIndicesBuffer = MemoryUtil.memAllocInt(boneIndices.length);
            boneIndicesBuffer.put(boneIndices).flip();
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, boneIndicesBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(6);
            glVertexAttribPointer(6, 4, GL_FLOAT, false, 0, 0);
            ...
        }
    }
    ...
}

このNodeクラスは、に関連付けられたデータを格納するだけで、AINodeその子を管理するための特定のメソッドがあります。

package org.lwjglb.engine.scene;

import org.joml.Matrix4f;

import java.util.*;

public class Node {
    private final List<Node> children;

    private final String name;

    private final Node parent;

    private Matrix4f nodeTransformation;

    public Node(String name, Node parent, Matrix4f nodeTransformation) {
        this.name = name;
        this.parent = parent;
        this.nodeTransformation = nodeTransformation;
        this.children = new ArrayList<>();
    }

    public void addChild(Node node) {
        this.children.add(node);
    }

    public List<Node> getChildren() {
        return children;
    }

    public String getName() {
        return name;
    }

    public Matrix4f getNodeTransformation() {
        return nodeTransformation;
    }

    public Node getParent() {
        return parent;
    }
}

これで、アニメーション モデルをレンダリングする方法と、静的モデルと共存させる方法を確認できます。SceneRenderクラスから始めましょう。このクラスでは、(現在のアニメーション フレームに割り当てられた) ボーン マトリックスを渡すために新しいユニフォームをセットアップして、シェーダーで使用できるようにするだけです。それに加えて、静的およびアニメーション化されたエンティティのレンダリングは、このクラスに追加の影響を与えません。

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

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

            for (Material material : model.getMaterialList()) {
                ...
                for (Mesh mesh : material.getMeshList()) {
                    glBindVertexArray(mesh.getVaoId());
                    for (Entity entity : entities) {
                        uniformsMap.setUniform("modelMatrix", entity.getModelMatrix());
                        AnimationData animationData = entity.getAnimationData();
                        if (animationData == null) {
                            uniformsMap.setUniform("bonesMatrices", AnimationData.DEFAULT_BONES_MATRICES);
                        } else {
                            uniformsMap.setUniform("bonesMatrices", animationData.getCurrentFrame().boneMatrices());
                        }
                        glDrawElements(GL_TRIANGLES, mesh.getNumVertices(), GL_UNSIGNED_INT, 0);
                    }
                }
            }
        }
    }
    ...
}

静的モデルの場合、null に設定された行列の配列を渡します。UniformsMap行列の配列の値を設定する新しいメソッドを追加するには、も変更する必要があります。

public class UniformsMap {
    ...
    public void setUniform(String uniformName, Matrix4f[] matrices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            int length = matrices != null ? matrices.length : 0;
            FloatBuffer fb = stack.mallocFloat(16 * length);
            for (int i = 0; i < length; i++) {
                matrices[i].get(16 * i, fb);
            }
            glUniformMatrix4fv(uniforms.get(uniformName), false, fb);
        }
    }
}

AnimationDataに設定された現在のアニメーションを制御する名前の新しいクラスも作成しましたEntity。

package org.lwjglb.engine.scene;

import org.joml.Matrix4f;
import org.lwjglb.engine.graph.Model;

public class AnimationData {

    public static final Matrix4f[] DEFAULT_BONES_MATRICES = new Matrix4f[ModelLoader.MAX_BONES];

    static {
        Matrix4f zeroMatrix = new Matrix4f().zero();
        for (int i = 0; i < DEFAULT_BONES_MATRICES.length; i++) {
            DEFAULT_BONES_MATRICES[i] = zeroMatrix;
        }
    }

    private Model.Animation currentAnimation;
    private int currentFrameIdx;

    public AnimationData(Model.Animation currentAnimation) {
        currentFrameIdx = 0;
        this.currentAnimation = currentAnimation;
    }

    public Model.Animation getCurrentAnimation() {
        return currentAnimation;
    }

    public Model.AnimatedFrame getCurrentFrame() {
        return currentAnimation.frames().get(currentFrameIdx);
    }

    public int getCurrentFrameIdx() {
        return currentFrameIdx;
    }

    public void nextFrame() {
        int nextFrame = currentFrameIdx + 1;
        if (nextFrame > currentAnimation.frames().size() - 1) {
            currentFrameIdx = 0;
        } else {
            currentFrameIdx = nextFrame;
        }
    }

    public void setCurrentAnimation(Model.Animation currentAnimation) {
        currentFrameIdx = 0;
        this.currentAnimation = currentAnimation;
    }
}

もちろん、インスタンスEntityへの参照を保持するようにクラスを変更する必要があります。AnimationData

public class Entity {
    ...
    private AnimationData animationData;
    ...
    public AnimationData getAnimationData() {
        return animationData;
    }
    ...
    public void setAnimationData(AnimationData animationData) {
        this.animationData = animationData;
    }
    ...
}

シーンの頂点シェーダー ( scene.vert) を変更して、アニメーション データを再生する必要があります。いくつかの定数と、ボーンの重みとインデックスの新しい入力属性を定義することから始めます (頂点ごとに 4 つの要素を使用するため、 と を使用vec4しますivec4)。現在のアニメーションに関連付けられているボーン マトリックスもユニフォームとして渡します。

#version 330

const int MAX_WEIGHTS = 4;
const int MAX_BONES = 150;

layout (location=0) in vec3 position;
layout (location=1) in vec3 normal;
layout (location=2) in vec3 tangent;
layout (location=3) in vec3 bitangent;
layout (location=4) in vec2 texCoord;
layout (location=5) in vec4 boneWeights;
layout (location=6) in ivec4 boneIndices;
...
uniform mat4 bonesMatrices[MAX_BONES];
...

このmain関数では、関連付けられたボーン インデックスによって指定され、関連付けられた重みによって変調された行列を使用して、ボーン ウェイトを繰り返し処理し、位置と法線を変更します。各ボーンが位置 (および法線) の修正に寄与するが、重みを使用して変調される場合のように考えることができます。静的モデルを使用している場合、重みはゼロになるため、元の位置と法線の値に固執します。

...
void main()
{
    vec4 initPos = vec4(0, 0, 0, 0);
    vec4 initNormal = vec4(0, 0, 0, 0);
    vec4 initTangent = vec4(0, 0, 0, 0);
    vec4 initBitangent = vec4(0, 0, 0, 0);

    int count = 0;
    for (int i = 0; i < MAX_WEIGHTS; i++) {
        float weight = boneWeights[i];
        if (weight > 0) {
            count++;
            int boneIndex = boneIndices[i];
            vec4 tmpPos = bonesMatrices[boneIndex] * vec4(position, 1.0);
            initPos += weight * tmpPos;

            vec4 tmpNormal = bonesMatrices[boneIndex] * vec4(normal, 0.0);
            initNormal += weight * tmpNormal;

            vec4 tmpTangent = bonesMatrices[boneIndex] * vec4(tangent, 0.0);
            initTangent += weight * tmpTangent;

            vec4 tmpBitangent = bonesMatrices[boneIndex] * vec4(bitangent, 0.0);
            initTangent += weight * tmpBitangent;
        }
    }
    if (count == 0) {
        initPos = vec4(position, 1.0);
        initNormal = vec4(normal, 0.0);
        initTangent = vec4(tangent, 0.0);
        initBitangent = vec4(bitangent, 0.0);
    }

    mat4 modelViewMatrix = viewMatrix * modelMatrix;
    vec4 mvPosition =  modelViewMatrix * initPos;
    gl_Position   = projectionMatrix * mvPosition;
    outPosition   = mvPosition.xyz;
    outNormal     = normalize(modelViewMatrix * initNormal).xyz;
    outTangent    = normalize(modelViewMatrix * initTangent).xyz;
    outBitangent  = normalize(modelViewMatrix * initBitangent).xyz;
    outTextCoord  = texCoord;
}

次の図は、プロセスを示しています

このMainクラスでは、アニメーション モデルをロードし、アンチエイリアシングを有効にする必要があります。また、更新ごとにアニメーション フレームをインクリメントします。

public class Main implements IAppLogic {
    ...
    private AnimationData animationData;
    ...
    public static void main(String[] args) {
        Main main = new Main();
        Window.WindowOptions opts = new Window.WindowOptions();
        opts.antiAliasing = true;
        Engine gameEng = new Engine("chapter-15", opts, main);
        gameEng.start();
    }
    ...
    @Override
    public void init(Window window, Scene scene, Render render) {
        String terrainModelId = "terrain";
        Model terrainModel = ModelLoader.loadModel(terrainModelId, "resources/models/terrain/terrain.obj",
                scene.getTextureCache(), false);
        scene.addModel(terrainModel);
        Entity terrainEntity = new Entity("terrainEntity", terrainModelId);
        terrainEntity.setScale(100.0f);
        terrainEntity.updateModelMatrix();
        scene.addEntity(terrainEntity);

        String bobModelId = "bobModel";
        Model bobModel = ModelLoader.loadModel(bobModelId, "resources/models/bob/boblamp.md5mesh",
                scene.getTextureCache(), true);
        scene.addModel(bobModel);
        Entity bobEntity = new Entity("bobEntity", bobModelId);
        bobEntity.setScale(0.05f);
        bobEntity.updateModelMatrix();
        animationData = new AnimationData(bobModel.getAnimationList().get(0));
        bobEntity.setAnimationData(animationData);
        scene.addEntity(bobEntity);

        SceneLights sceneLights = new SceneLights();
        AmbientLight ambientLight = sceneLights.getAmbientLight();
        ambientLight.setIntensity(0.5f);
        ambientLight.setColor(0.3f, 0.3f, 0.3f);

        DirLight dirLight = sceneLights.getDirLight();
        dirLight.setPosition(0, 1, 0);
        dirLight.setIntensity(1.0f);
        scene.setSceneLights(sceneLights);

        SkyBox skyBox = new SkyBox("resources/models/skybox/skybox.obj", scene.getTextureCache());
        skyBox.getSkyBoxEntity().setScale(100);
        skyBox.getSkyBoxEntity().updateModelMatrix();
        scene.setSkyBox(skyBox);

        scene.setFog(new Fog(true, new Vector3f(0.5f, 0.5f, 0.5f), 0.02f));

        Camera camera = scene.getCamera();
        camera.setPosition(-1.5f, 3.0f, 4.5f);
        camera.addRotation((float) Math.toRadians(15.0f), (float) Math.toRadians(390.f));

        lightAngle = 0;
    }
    ...
    @Override
    public void update(Window window, Scene scene, long diffTimeMillis) {
        animationData.nextFrame();
    }
}

最後に、クラスのメソッドが変更されているSkyBoxため、クラスも変更する必要があります。loadModelModelLoader

public class SkyBox {
    ...
    public SkyBox(String skyBoxModelPath, TextureCache textureCache) {
        skyBoxModel = ModelLoader.loadModel("skybox-model", skyBoxModelPath, textureCache, false);
        ...
    }
}

次のようなものが表示されます。

Java 3D LWJGL Gitbook~第14章 – 法線マッピング~

第14章 - 法線マッピング

この章では、3D モデルの外観を劇的に改善するテクニックについて説明します。ここまでで、複雑な 3D モデルにテクスチャを適用できるようになりましたが、実際のオブジェクトがどのように見えるかにはまだほど遠い状態です。現実世界の表面は完全に平らではなく、現在の 3D モデルにはない不完全さがあります。

よりリアルなシーンをレンダリングするために、法線マップを使用します。現実世界の平らな表面を見ると、光が反射する方法によって、遠くからでもこれらの欠陥が見えることがわかります。3D シーンでは、平らな表面には欠陥がなく、テクスチャを適用できますが、光が反射する方法は変更しません。それが違いを生むものです。

三角形の数を増やしてモデルの詳細を増やし、それらの不完全さを反映することを考えるかもしれませんが、パフォーマンスは低下します。必要なのは、表面での光の反射方法を変更してリアリズムを高める方法です。これは法線マッピング技術で達成されます。

つまるところは、「光がどのように反射するかを見ることができます。」というところの実装を追加した内容の記事になります。

サンプルコードの実行

コンセプト

プレーン サーフェスの例に戻りましょう。平面は、四角形を形成する 2 つの三角形によって定義できます。ライティングの章で覚えていると思いますが、光がどのように反射するかをモデル化する要素はサーフェス法線です。この場合、サーフェス全体に対して単一の法線があり、サーフェスの各フラグメントは、光がそれらにどのように影響するかを計算するときに同じ法線を使用します。これを次の図に示します。

サーフェスの各フラグメントの法線を変更できれば、サーフェスの不完全性をモデル化して、より現実的な方法でレンダリングできます。これを次の図に示します。

これを実現する方法は、サーフェスの法線を保存する別のテクスチャをロードすることです。通常のテクスチャの各ピクセルには、
X, Y, Z。 RGB 値として格納された法線の座標。次のテクスチャを使用してクワッドを描画してみましょう。

上の画像の法線マップ テクスチャの例を次に示します。

ご覧のとおり、元のテクスチャに色変換を適用したかのようです。各ピクセルは、色成分を使用して法線情報を格納します。通常、法線マップを表示するときに目にすることの 1 つは、支配的な色が青色になる傾向があることです。これは、法線が正の方向を指しているという事実によるものです。

Z軸。のZコンポーネントは通常、よりもはるかに高い値を持ちますXとY法線が表面の外を指しているため、平らな表面用のもの。
以来 X, Y, Z座標が RGB にマッピングされると、青のコンポーネントもより高い値になります。

したがって、法線マップを使用してオブジェクトをレンダリングするには、追加のテクスチャが必要であり、フラグメントをレンダリングするときにそれを使用して適切な法線値を取得します。

実装

通常、法線マップはそのように定義されず、いわゆるタンジェント スペースで定義されます。接線空間は、モデルの各三角形にローカルな座標系です。その座標空間では、X軸は常にサーフェスの外を指します。これが、向かい合った面を持つ複雑なモデルであっても、法線マップが通常青みがかっている理由です。接空間を処理するには、ノルム、アル、タンジェント、バイタンジェント ベクトルが必要です。すでに法線ベクトルがあり、接線ベクトルと従接線ベクトルは法線ベクトルに垂直なベクトルです。これらのベクトルTBNは、シェーダーで使用している座標系の接空間にあるデータを使用できるようにする行列を計算するために必要です。

ここで、この側面に関する優れたチュートリアルを確認できます

したがって、最初のステップは、ModelLoaderタンジェントおよびバイタンジェント情報を含む、クラスをロードする法線マッピングのサポートを追加することです。assimp のモデル読み込みフラグを設定するときに、これを含めたことを思い出してください: aiProcess_CalcTangentSpace. このフラグを使用すると、タンジェント データとバイタンジェント データを自動的に計算できます。

このprocessMaterialメソッドでは、まず法線マップ テクスチャの存在を照会します。その場合は、そのテクスチャを読み込み、そのテクスチャ パスをマテリアルに関連付けます。

public class ModelLoader {
    ...
    private static Material processMaterial(AIMaterial aiMaterial, String modelDir, TextureCache textureCache) {
        ...
        try (MemoryStack stack = MemoryStack.stackPush()) {
            ...
            AIString aiNormalMapPath = AIString.calloc(stack);
            Assimp.aiGetMaterialTexture(aiMaterial, aiTextureType_NORMALS, 0, aiNormalMapPath, (IntBuffer) null,
                    null, null, null, null, null);
            String normalMapPath = aiNormalMapPath.dataString();
            if (normalMapPath != null && normalMapPath.length() > 0) {
                material.setNormalMapPath(modelDir + File.separator + new File(normalMapPath).getName());
                textureCache.createTexture(material.getNormalMapPath());
            }
            return material;
        }
    }
    ...
}

このprocessMeshメソッドでは、タンジェントとバイタンジェントのデータもロードする必要があります。

public class ModelLoader {
    ...
    private static Mesh processMesh(AIMesh aiMesh) {
        ...
        float[] tangents = processTangents(aiMesh, normals);
        float[] bitangents = processBitangents(aiMesh, normals);
        ...
        return new Mesh(vertices, normals, tangents, bitangents, textCoords, indices);
    }
    ...
}

processTangentsおよびメソッドは、processBitangents法線をロードするものと非常によく似ています。

public class ModelLoader {
    ...
    private static float[] processBitangents(AIMesh aiMesh, float[] normals) {

        AIVector3D.Buffer buffer = aiMesh.mBitangents();
        float[] data = new float[buffer.remaining() * 3];
        int pos = 0;
        while (buffer.remaining() > 0) {
            AIVector3D aiBitangent = buffer.get();
            data[pos++] = aiBitangent.x();
            data[pos++] = aiBitangent.y();
            data[pos++] = aiBitangent.z();
        }

        // Assimp may not calculate tangents with models that do not have texture coordinates. Just create empty values
        if (data.length == 0) {
            data = new float[normals.length];
        }
        return data;
    }
    ...
    private static float[] processTangents(AIMesh aiMesh, float[] normals) {

        AIVector3D.Buffer buffer = aiMesh.mTangents();
        float[] data = new float[buffer.remaining() * 3];
        int pos = 0;
        while (buffer.remaining() > 0) {
            AIVector3D aiTangent = buffer.get();
            data[pos++] = aiTangent.x();
            data[pos++] = aiTangent.y();
            data[pos++] = aiTangent.z();
        }

        // Assimp may not calculate tangents with models that do not have texture coordinates. Just create empty values
        if (data.length == 0) {
            data = new float[normals.length];
        }
        return data;
    }
    ...
}

ご覧のとおり、新しいデータを保持するためにクラスも変更する必要がありMeshますMaterial。Meshクラスから始めましょう:

public class Mesh {
    ...
    public Mesh(float[] positions, float[] normals, float[] tangents, float[] bitangents, float[] textCoords, int[] indices) {
        try (MemoryStack stack = MemoryStack.stackPush()) {
            ...
            // Tangents VBO
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer tangentsBuffer = stack.callocFloat(tangents.length);
            tangentsBuffer.put(0, tangents);
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, tangentsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(2);
            glVertexAttribPointer(2, 3, GL_FLOAT, false, 0, 0);

            // Bitangents VBO
            vboId = glGenBuffers();
            vboIdList.add(vboId);
            FloatBuffer bitangentsBuffer = stack.callocFloat(bitangents.length);
            bitangentsBuffer.put(0, bitangents);
            glBindBuffer(GL_ARRAY_BUFFER, vboId);
            glBufferData(GL_ARRAY_BUFFER, bitangentsBuffer, GL_STATIC_DRAW);
            glEnableVertexAttribArray(3);
            glVertexAttribPointer(3, 3, GL_FLOAT, false, 0, 0);

            // Texture coordinates VBO
            ...
            glEnableVertexAttribArray(4);
            glVertexAttribPointer(4, 2, GL_FLOAT, false, 0, 0);
            ...
        }
    }
    ...
}

タンジェント データとバイタンジェント データ (法線データと同様の構造に従う) 用に 2 つの新しい VBO を作成し、テクスチャ座標 VBO の位置を更新する必要があります。

クラスにはMaterial、法線マッピング テクスチャ パスへのパスを含める必要があります。

public class Material {
    ...
    private String normalMapPath;
    ...
    public String getNormalMapPath() {
        return normalMapPath;
    }
    ...
    public void setNormalMapPath(String normalMapPath) {
        this.normalMapPath = normalMapPath;
    }
    ...
}

次に、シーンの頂点シェーダー ( scene.vert)から始めて、シェーダーを変更する必要があります。

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec3 normal;
layout (location=2) in vec3 tangent;
layout (location=3) in vec3 bitangent;
layout (location=4) in vec2 texCoord;

out vec3 outPosition;
out vec3 outNormal;
out vec3 outTangent;
out vec3 outBitangent;
out vec2 outTextCoord;

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

void main()
{
    mat4 modelViewMatrix = viewMatrix * modelMatrix;
    vec4 mvPosition =  modelViewMatrix * vec4(position, 1.0);
    gl_Position   = projectionMatrix * mvPosition;
    outPosition   = mvPosition.xyz;
    outNormal     = normalize(modelViewMatrix * vec4(normal, 0.0)).xyz;
    outTangent    = normalize(modelViewMatrix * vec4(tangent, 0)).xyz;
    outBitangent  = normalize(modelViewMatrix * vec4(bitangent, 0)).xyz;
    outTextCoord  = texCoord;
}

ご覧のとおり、bitangent と tangent に関連付けられた新しい入力データを定義する必要があります。法線を処理したのと同じ方法でこれらの要素を変換し、そのデータを入力としてフラグメント シェーダーに渡します ( scene.frag)。

#version 330
...
in vec3 outTangent;
in vec3 outBitangent;
...
struct Material
{
    vec4 ambient;
    vec4 diffuse;
    vec4 specular;
    float reflectance;
    int hasNormalMap;
};
...
uniform sampler2D normalSampler;
...

頂点シェーダーからの新しい入力を定義することから始めます。Materialこれには、使用可能な法線マップがあるかどうかを通知する構造体の追加要素が含まれます ( hasNormalMap)。また、法線マップ テクスチャの新しいユニフォームを追加します ( normalSampler))。次のステップは、法線マップ テクスチャに基づいて法線を更新する関数を定義することです。

...
...
vec3 calcNormal(vec3 normal, vec3 tangent, vec3 bitangent, vec2 textCoords) {
    mat3 TBN = mat3(tangent, bitangent, normal);
    vec3 newNormal = texture(normalSampler, textCoords).rgb;
    newNormal = normalize(newNormal * 2.0 - 1.0);
    newNormal = normalize(TBN * newNormal);
    return newNormal;
}

void main() {
    vec4 text_color = texture(txtSampler, outTextCoord);
    vec4 ambient = calcAmbient(ambientLight, text_color + material.ambient);
    vec4 diffuse = text_color + material.diffuse;
    vec4 specular = text_color + material.specular;

    vec3 normal = outNormal;
    if (material.hasNormalMap > 0) {
        normal = calcNormal(outNormal, outTangent, outBitangent, outTextCoord);
    }

    vec4 diffuseSpecularComp = calcDirLight(diffuse, specular, dirLight, outPosition, normal);

    for (int i=0; i<MAX_POINT_LIGHTS; i++) {
        if (pointLights[i].intensity > 0) {
            diffuseSpecularComp += calcPointLight(diffuse, specular, pointLights[i], outPosition, normal);
        }
    }

    for (int i=0; i<MAX_SPOT_LIGHTS; i++) {
        if (spotLights[i].pl.intensity > 0) {
            diffuseSpecularComp += calcSpotLight(diffuse, specular, spotLights[i], outPosition, normal);
        }
    }
    fragColor = ambient + diffuseSpecularComp;

    if (fog.activeFog == 1) {
        fragColor = calcFog(outPosition, fragColor, fog, ambientLight.color, dirLight);
    }
}

このcalcNormal関数は次のパラメータを取ります。

  • 頂点法線。
  • 頂点接線。
  • 頂点バイタンジェント。
  • テクスチャ座標。
    その関数で最初に行うことは、TBN 行列を計算することです。その後、法線マップ テクスチャから法線値を取得し、TBN マトリックスを使用して接線空間からビュー空間に渡します。取得する色は通常の座標ですが、RGB 値として保存されるため、範囲 [0, 1] に含まれることを思い出してください。[-1, 1] の範囲になるように変換する必要があるため、2 を掛けて 1 を引くだけです。
    最後に、マテリアルが法線マップ テクスチャを定義する場合にのみ、その関数を使用します。

SceneRenderシェーダーで使用する新しい法線を作成して使用するには、クラスも変更する必要があります。

public class SceneRender {
    ...
    private void createUniforms() {
        ...
        uniformsMap.createUniform("normalSampler");
        ...
        uniformsMap.createUniform("material.hasNormalMap");
        ...
    }
    public void render(Scene scene) {
        ...
        uniformsMap.setUniform("normalSampler", 1);
        ...
        for (Model model : models) {
            ...
            for (Material material : model.getMaterialList()) {
                ...
                String normalMapPath = material.getNormalMapPath();
                boolean hasNormalMapPath = normalMapPath != null;
                uniformsMap.setUniform("material.hasNormalMap", hasNormalMapPath ? 1 : 0);
                ...
                if (hasNormalMapPath) {
                    Texture normalMapTexture = textureCache.getTexture(normalMapPath);
                    glActiveTexture(GL_TEXTURE1);
                    normalMapTexture.bind();
                }
                ...
            }
        }
        ...
    }
    ...    
}

Main最後のステップは、この効果を示すためにクラスを更新することです。法線マップが関連付けられている場合と関連付けられていない場合の 2 つのクワッドをロードします。また、左矢印と右矢印を使用して光の角度を制御し、効果を示します。

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

        Entity wallLeftEntity = new Entity("wallLeftEntity", wallNoNormalsModelId);
        wallLeftEntity.setPosition(-3f, 0, 0);
        wallLeftEntity.setScale(2.0f);
        wallLeftEntity.updateModelMatrix();
        scene.addEntity(wallLeftEntity);

        String wallModelId = "quad-model";
        Model quadModel = ModelLoader.loadModel(wallModelId, "resources/models/wall/wall.obj",
                scene.getTextureCache());
        scene.addModel(quadModel);

        Entity wallRightEntity = new Entity("wallRightEntity", wallModelId);
        wallRightEntity.setPosition(3f, 0, 0);
        wallRightEntity.setScale(2.0f);
        wallRightEntity.updateModelMatrix();
        scene.addEntity(wallRightEntity);

        SceneLights sceneLights = new SceneLights();
        sceneLights.getAmbientLight().setIntensity(0.2f);
        DirLight dirLight = sceneLights.getDirLight();
        dirLight.setPosition(1, 1, 0);
        dirLight.setIntensity(1.0f);
        scene.setSceneLights(sceneLights);

        Camera camera = scene.getCamera();
        camera.moveUp(5.0f);
        camera.addRotation((float) Math.toRadians(90), 0);

        lightAngle = -35;
    }
        ...
    public void input(Window window, Scene scene, long diffTimeMillis, boolean inputConsumed) {
        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_LEFT)) {
            lightAngle -= 2.5f;
            if (lightAngle < -90) {
                lightAngle = -90;
            }
        } else if (window.isKeyPressed(GLFW_KEY_RIGHT)) {
            lightAngle += 2.5f;
            if (lightAngle > 90) {
                lightAngle = 90;
            }
        }

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

        SceneLights sceneLights = scene.getSceneLights();
        DirLight dirLight = sceneLights.getDirLight();
        double angRad = Math.toRadians(lightAngle);
        dirLight.getDirection().x = (float) Math.sin(angRad);
        dirLight.getDirection().y = (float) Math.cos(angRad);
    }
    ...
}

結果を次の図に示します。
ご覧のとおり、通常のテクスチャが適用されたクワッドは、よりボリュームのある印象を与えます。本質的には、他のクワッドと同じように平らな面ですが、光がどのように反射するかを見ることができます。

Java 3D LWJGL Gitbook ~第13章 霧~

第13章 霧

この章では、ゲーム エンジンでフォグ エフェクトを作成する方法を確認します。その効果を使用して、遠くのオブジェクトが薄暗くなり、濃霧に消えていくように見える様子をシミュレートします。

コンセプト

まず、フォグを定義する属性を調べてみましょう。1つ目は霧の色です。現実の世界では、霧は灰色ですが、この効果を使用して、さまざまな色の霧が侵入する広い領域をシミュレートできます。アトリビュートはフォグの密度です。

したがって、フォグ エフェクトを適用するには、3D シーン オブジェクトがカメラから遠く離れている限り、フォグ カラーにフェードする方法を見つける必要があります。カメラに近いオブジェクトは霧の影響を受けませんが、遠くにあるオブジェクトは区別できません。そのため、その効果をシミュレートするために、フォグ カラーと各フラグメント カラーをブレンドするために使用できる係数を計算できる必要があります。その要因は、カメラまでの距離に依存する必要があります。

その要因を呼びましょう
フォググファクター
、その範囲を 0 から 1 に設定します。
フォググファクター
が 1 の場合、オブジェクトがフォグの影響を受けない、つまり近くのオブジェクトであることを意味します。とき
フォググファクター
値が 0 の場合、オブジェクトが完全にフォグに隠れることを意味します。

したがって、フォグ カラーの計算に必要な式は次のとおりです。

フォグ カラーの計算に必要な式

finalColor = ( 1 fogfactor ) fogColor + fogFactor fragmentColor

霧効果を適用した結果の色です。
フォグ カラーとフラグメント カラーのブレンド方法を制御するパラメータです。基本的にオブジェクトの可視性を制御します。
霧の色です。
フォグ効果を適用していないフラグメントの色です。
次に、計算方法を見つける必要があります
距離にもよる。さまざまなモデルを選択できますが、最初のモデルは線形モデルを使用することです。これは、距離が与えられると、fogFactor 値を直線的に変化させるモデルです。

線形モデルは、次のパラメーターによって定義できます。

: フォグ エフェクトが適用され始める距離。fogStart
: フォグ エフェクトが最大値に達する距離。fogFinish
: カメラまでの距離。distance

fogFactor = ( fofFinish distace ) ( fogFinish fogStart )

以下の距離にあるオブジェクトの場合
単に設定するだけです

. 次のグラフは、
距離で変わります。

線形モデルは計算が簡単ですが、あまり現実的ではなく、霧の密度が考慮されていません。実際には、霧はより滑らかに成長する傾向があります。したがって、次の適切なモデルは指数モデルです。そのモデルの式は次のとおりです。
作用する新しい変数は次のとおりです。

フォグの厚さまたは密度をモデル化します。
これは、霧が距離とともに増加する速度を制御するために使用されます。
次の図は、指数のさまざまな値に対する上記の式の 2 つのグラフを示しています (青い線は $$2$$、

このコードでは、指数の値を 2 に設定する数式を使用します (別の値を使用するように例を簡単に変更できます)。

実装

理論が説明されたので、それを実践することができます。scene.frag必要なすべての変数がそこにあるので、シーン フラグメント シェーダー ( ) にエフェクトを実装します。フォグ属性をモデル化する構造体を定義することから始めます。

基本的な処理は以下の通りです。

  1. Fogクラスを作成
  2. Fogモデルのロード
  3. シーンへの描画を行う

今回の「フォグ」に関しては、エフェクトという形のモデル?を追加しているので、シェーダーとのやり取りが多くなっています。

...
struct Fog
{
    int activeFog;
    vec3 color;
    float density;
};
...

このactiveアトリビュートは、フォグ エフェクトを有効または無効にするために使用されます。フォグは、 という名前の別のユニフォームを介してシェーダーに渡されfogます。

...
uniform Fog fog;
...

calcFogこのように定義されているという名前の関数を作成します。

...
vec4 calcFog(vec3 pos, vec4 color, Fog fog, vec3 ambientLight, DirLight dirLight) {
    vec3 fogColor = fog.color * (ambientLight + dirLight.color * dirLight.intensity);
    float distance = length(pos);
    float fogFactor = 1.0 / exp((distance * fog.density) * (distance * fog.density));
    fogFactor = clamp(fogFactor, 0.0, 1.0);

    vec3 resultColor = mix(fogColor, color.xyz, fogFactor);
    return vec4(resultColor.xyz, color.w);
}
...

ご覧のとおり、最初に頂点までの距離を計算します。頂点座標はpos変数で定義されており、長さを計算するだけです。次に、指数が 2 の指数モデルを使用してフォグ ファクターを計算します (これは、2 倍するのと同じです)。fogFactor間の範囲にクランプします
0と1
機能を使用しmixます。GLSL では、mix関数はフォグ カラーとフラグメント カラー (変数で定義color) をブレンドするために使用されます。これは、次の方程式を適用することと同じです。

resultColor = ( 1 fogFacor ) fog . color + fogFacor color

また、元の色の透明度である w コンポーネントも保持します。フラグメントはその透過性レベルを維持する必要があるため、このコンポーネントが影響を受けることは望ましくありません。

フラグメント シェーダーの最後で、すべてのライト エフェクトを適用した後、フォグがアクティブな場合は、返された値をフラグメント カラーに割り当てるだけです。

...
    if (fog.activeFog == 1) {
        fragColor = calcFog(outPosition, fragColor, fog, ambientLight.color, dirLight);
    }
...

Fogフォグ属性を含む別の POJO (Plain Old Java Object)という名前の新しいクラスも作成します。

package org.lwjglb.engine.scene;

import org.joml.Vector3f;

public class Fog {

    private boolean active;
    private Vector3f color;
    private float density;

    public Fog() {
        active = false;
        color = new Vector3f();
    }

    public Fog(boolean active, Vector3f color, float density) {
        this.color = color;
        this.density = density;
        this.active = active;
    }

    public Vector3f getColor() {
        return color;
    }

    public float getDensity() {
        return density;
    }

    public boolean isActive() {
        return active;
    }

    public void setActive(boolean active) {
        this.active = active;
    }

    public void setColor(Vector3f color) {
        this.color = color;
    }

    public void setDensity(float density) {
        this.density = density;
    }
}

Fogクラスにインスタンスを追加しますScene。

public class Scene {
    ...
    private Fog fog;
    ...
    public Scene(int width, int height) {
        ...
        fog = new Fog();
    }
    ...
    public Fog getFog() {
        return fog;
    }
    ...
    public void setFog(Fog fog) {
        this.fog = fog;
    }
    ...
}

ここで、これらすべての要素をクラスに設定する必要があります。最初に、構造SceneRenderに均一な値を設定します。Fog

public class SceneRender {
    ...
    private void createUniforms() {
        ...
        uniformsMap.createUniform("fog.activeFog");
        uniformsMap.createUniform("fog.color");
        uniformsMap.createUniform("fog.density");
    }
    ...
}

このrenderメソッドでは、最初にブレンドを有効にしてからFogユニフォームを設定する必要があります。

public class SceneRender {
    ...
     public void render(Scene scene) {
        glEnable(GL_BLEND);
        glBlendEquation(GL_FUNC_ADD);
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
        shaderProgram.bind();
        ...
        Fog fog = scene.getFog();
        uniformsMap.setUniform("fog.activeFog", fog.isActive() ? 1 : 0);
        uniformsMap.setUniform("fog.color", fog.getColor());
        uniformsMap.setUniform("fog.density", fog.getDensity());
        ...
        shaderProgram.unbind();
        glDisable(GL_BLEND);
    }
    ...
}

最後に、Main霧を設定するようにクラスを変更し、霧の効果を示すためにスケーリングされた地形として単一のクワッドを使用します。

public class Main implements IAppLogic {
    ...
    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-13", new Window.WindowOptions(), main);
        ...
    }
    ...
    public void init(Window window, Scene scene, Render render) {
        String terrainModelId = "terrain";
        Model terrainModel = ModelLoader.loadModel(terrainModelId, "resources/models/terrain/terrain.obj",
                scene.getTextureCache());
        scene.addModel(terrainModel);
        Entity terrainEntity = new Entity("terrainEntity", terrainModelId);
        terrainEntity.setScale(100.0f);
        terrainEntity.updateModelMatrix();
        scene.addEntity(terrainEntity);

        SceneLights sceneLights = new SceneLights();
        AmbientLight ambientLight = sceneLights.getAmbientLight();
        ambientLight.setIntensity(0.5f);
        ambientLight.setColor(0.3f, 0.3f, 0.3f);

        DirLight dirLight = sceneLights.getDirLight();
        dirLight.setPosition(0, 1, 0);
        dirLight.setIntensity(1.0f);
        scene.setSceneLights(sceneLights);

        SkyBox skyBox = new SkyBox("resources/models/skybox/skybox.obj", scene.getTextureCache());
        skyBox.getSkyBoxEntity().setScale(50);
        scene.setSkyBox(skyBox);

        scene.setFog(new Fog(true, new Vector3f(0.5f, 0.5f, 0.5f), 0.95f));

        scene.getCamera().moveUp(0.1f);
    }
    ...
    public void update(Window window, Scene scene, long diffTimeMillis) {
        // Nothing to be done here
    }
}

強調すべき重要な点の 1 つは、霧の色を賢く選択する必要があるということです。スカイボックスがなく固定色の背景がある場合、これはさらに重要です。霧の色をクリアの色と同じになるように設定する必要があります。スカイボックスをレンダリングするコードのコメントを外してサンプルを再実行すると、このような結果が得られます。

ようなものが表示されるはずです。

Java 3D LWJGL Gitbook~ 第12章 スカイボックス ~

第12章 スカイボックス

この章では、スカイ ボックスの作成方法について説明します。スカイボックスを使用すると、背景を設定して、3D 世界がより広いという錯覚を与えることができます。その背景はカメラの位置を包み込み、空間全体を覆います。ここで使用するテクニックは、3D シーンの周りに表示される大きな立方体を作成することです。つまり、カメラ位置の中心が立方体の中心になります。その立方体の側面は、画像が連続した風景のように見える方法でマッピングされる丘、青い空、雲のテクスチャでラップされます。

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

サンプルコードの実行結果

スカイボックス

次の図は、スカイボックスの概念を示しています。

スカイ ボックスを作成するプロセスは、次の手順に要約できます。

・大きなキューブを作成します。
エッジのない巨大な風景を見ているような錯覚を与えるテクスチャを適用します。
・立方体をレンダリングして、側面が遠くにあり、原点がカメラの中心にくるようにします。
SkyBoxスカイ ボックス キューブ (テクスチャ付き) とテクスチャ キャッシュへの参照を含む 3D モデルへのパスを受け取るコンストラクタで名前を付けた新しいクラスを作成することから始めます。このクラスはそのモデルをロードし、そのモデルにEntity関連付けられたインスタンスを作成します。SkyBoxクラスの定義は以下の通りです。

package org.lwjglb.engine.scene;

import org.lwjglb.engine.graph.*;

public class SkyBox {

    private Entity skyBoxEntity;
    private Model skyBoxModel;

    public SkyBox(String skyBoxModelPath, TextureCache textureCache) {
        skyBoxModel = ModelLoader.loadModel("skybox-model", skyBoxModelPath, textureCache);
        skyBoxEntity = new Entity("skyBoxEntity-entity", skyBoxModel.getId());
    }

    public Entity getSkyBoxEntity() {
        return skyBoxEntity;
    }

    public Model getSkyBoxModel() {
        return skyBoxModel;
    }
}

SkyBoxクラスへの参照をクラスに保存しますScene。

public class Scene {
    ...
    private SkyBox skyBox;
    ...
    public SkyBox getSkyBox() {
        return skyBox;
    }
    ...
    public void setSkyBox(SkyBox skyBox) {
        this.skyBox = skyBox;
    }
    ...
}

次のステップは、スカイ ボックス用の頂点シェーダーとフラグメント シェーダーの別のセットを作成することです。しかし、既にあるシーン シェーダーを再利用してみませんか? 答えは、実際には、必要なシェーダーはそれらのシェーダーの簡略化されたバージョンであるということです。たとえば、スカイ ボックスにはライトを適用しません。以下に、スカイ ボックスの頂点シェーダーを示します ( skybox.vert)。

#version 330

layout (location=0) in vec3 position;
layout (location=1) in vec3 normal;
layout (location=2) 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;
}

まだモデル マトリックスを使用していることがわかります。スカイボックスをスケーリングするため、モデル マトリックスが必要です。開始時にスカイ ボックスをモデル化する立方体のサイズを大きくし、モデルとビュー マトリックスを乗算する必要がない他の実装がいくつか見られる場合があります。このアプローチを選択したのは、より柔軟で、実行時にスカイボックスのサイズを変更できるためですが、必要に応じて他のアプローチに簡単に切り替えることができます。

フラグメント シェーダー ( skybox.frag) も非常に単純で、テクスチャまたは拡散色から色を取得するだけです。

#version 330

in vec2 outTextCoord;
out vec4 fragColor;

uniform vec4 diffuse;
uniform sampler2D txtSampler;
uniform int hasTexture;

void main()
{
    if (hasTexture == 1) {
        fragColor = texture(txtSampler, outTextCoord);
    } else {
        fragColor = diffuse;
    }
}

SkyBoxRenderこれらのシェーダーを使用してレンダリングを実行する名前の新しいクラスを作成します。クラスは、シェーダー プログラムを作成し、必要なユニフォームをセットアップすることから始まります。

package org.lwjglb.engine.graph;

import org.joml.Matrix4f;
import org.lwjglb.engine.scene.*;

import java.util.*;

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

public class SkyBoxRender {

    private ShaderProgram shaderProgram;

    private UniformsMap uniformsMap;

    private Matrix4f viewMatrix;

    public SkyBoxRender() {
        List<ShaderProgram.ShaderModuleData> shaderModuleDataList = new ArrayList<>();
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/skybox.vert", GL_VERTEX_SHADER));
        shaderModuleDataList.add(new ShaderProgram.ShaderModuleData("resources/shaders/skybox.frag", GL_FRAGMENT_SHADER));
        shaderProgram = new ShaderProgram(shaderModuleDataList);
        viewMatrix = new Matrix4f();
        createUniforms();
    }
    ...
}

次のステップでは、グローバルなレンダリング メソッドで呼び出されるスカイボックスの新しいレンダリング メソッドを作成します。

public class SkyBoxRender {
    ...
    public void render(Scene scene) {
        SkyBox skyBox = scene.getSkyBox();
        if (skyBox == null) {
            return;
        }
        shaderProgram.bind();

        uniformsMap.setUniform("projectionMatrix", scene.getProjection().getProjMatrix());
        viewMatrix.set(scene.getCamera().getViewMatrix());
        viewMatrix.m30(0);
        viewMatrix.m31(0);
        viewMatrix.m32(0);
        uniformsMap.setUniform("viewMatrix", viewMatrix);
        uniformsMap.setUniform("txtSampler", 0);

        Model skyBoxModel = skyBox.getSkyBoxModel();
        Entity skyBoxEntity = skyBox.getSkyBoxEntity();
        TextureCache textureCache = scene.getTextureCache();
        for (Material material : skyBoxModel.getMaterialList()) {
            Texture texture = textureCache.getTexture(material.getTexturePath());
            glActiveTexture(GL_TEXTURE0);
            texture.bind();

            uniformsMap.setUniform("diffuse", material.getDiffuseColor());
            uniformsMap.setUniform("hasTexture", texture.getTexturePath().equals(TextureCache.DEFAULT_TEXTURE) ? 0 : 1);

            for (Mesh mesh : material.getMeshList()) {
                glBindVertexArray(mesh.getVaoId());

                uniformsMap.setUniform("modelMatrix", skyBoxEntity.getModelMatrix());
                glDrawElements(GL_TRIANGLES, mesh.getNumVertices(), GL_UNSIGNED_INT, 0);
            }
        }

        glBindVertexArray(0);

        shaderProgram.unbind();
    }
}

関連するユニフォームにそのデータをロードする前に、ビュー マトリックスを変更していることがわかります。カメラを動かすとき、実際に行っていることは世界全体を動かしていることを忘れないでください。したがって、ビュー マトリックスをそのまま乗算すると、カメラが移動するとスカイボックスが移動します。しかし、これは必要ありません。(0, 0, 0) の原点座標に貼り付けたいのです。これは、平行移動の増分を含むビュー マトリックスの部分 ( m30、m31およびm32コンポーネント)。スカイ ボックスは原点に固定する必要があるため、ビュー マトリックスの使用をまったく避けることができると考えるかもしれません。その場合、スカイボックスがカメラと一緒に回転しないことがわかりますが、これは私たちが望んでいるものではありません。回転する必要がありますが、平行移動は必要ありません。スカイボックスをレンダリングするには、ユニフォームをセットアップし、スカイ ボックスに関連付けられた立方体をレンダリングします。

クラスでは、Renderクラスをインスタンス化しSkyBoxRender、render メソッドを呼び出すだけです。

public class Render {
    ...
    private SkyBoxRender skyBoxRender;
    ...

    public Render(Window window) {
        ...
        skyBoxRender = new SkyBoxRender();
    }

    public void render(Window window, Scene scene) {
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        glViewport(0, 0, window.getWidth(), window.getHeight());

        skyBoxRender.render(scene);
        sceneRender.render(scene);
        guiRender.render(scene);
    }
    ...
}

スカイ ボックスを最初にレンダリングしていることがわかります。これは、シーンに透明度のある 3D モデルがある場合、それらを (黒い背景ではなく) スカイボックスとブレンドしたいという事実によるものです。

最後に、このMainクラスでは、シーンにスカイ ボックスを設定し、タイルのセットを作成して、無限の地形の錯覚を与えます。カメラの位置に合わせて移動するタイルのチャンクが常に表示されるように設定します。

public class Main implements IAppLogic {
    ...
    private static final int NUM_CHUNKS = 4;

    private Entity[][] terrainEntities;

    public static void main(String[] args) {
        ...
        Engine gameEng = new Engine("chapter-12", new Window.WindowOptions(), main);
        ...
    }
    ...

    @Override
    public void init(Window window, Scene scene, Render render) {
        String quadModelId = "quad-model";
        Model quadModel = ModelLoader.loadModel("quad-model", "resources/models/quad/quad.obj",
                scene.getTextureCache());
        scene.addModel(quadModel);

        int numRows = NUM_CHUNKS * 2 + 1;
        int numCols = numRows;
        terrainEntities = new Entity[numRows][numCols];
        for (int j = 0; j < numRows; j++) {
            for (int i = 0; i < numCols; i++) {
                Entity entity = new Entity("TERRAIN_" + j + "_" + i, quadModelId);
                terrainEntities[j][i] = entity;
                scene.addEntity(entity);
            }
        }

        SceneLights sceneLights = new SceneLights();
        sceneLights.getAmbientLight().setIntensity(0.2f);
        scene.setSceneLights(sceneLights);

        SkyBox skyBox = new SkyBox("resources/models/skybox/skybox.obj", scene.getTextureCache());
        skyBox.getSkyBoxEntity().setScale(50);
        scene.setSkyBox(skyBox);

        scene.getCamera().moveUp(0.1f);

        updateTerrain(scene);
    }

    @Override
    public void input(Window window, Scene scene, long diffTimeMillis, boolean inputConsumed) {
        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);
        }

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

    @Override
    public void update(Window window, Scene scene, long diffTimeMillis) {
        updateTerrain(scene);
    }
        public void updateTerrain(Scene scene) {
        int cellSize = 10;
        Camera camera = scene.getCamera();
        Vector3f cameraPos = camera.getPosition();
        int cellCol = (int) (cameraPos.x / cellSize);
        int cellRow = (int) (cameraPos.z / cellSize);

        int numRows = NUM_CHUNKS * 2 + 1;
        int numCols = numRows;
        int zOffset = -NUM_CHUNKS;
        float scale = cellSize / 2.0f;
        for (int j = 0; j < numRows; j++) {
            int xOffset = -NUM_CHUNKS;
            for (int i = 0; i < numCols; i++) {
                Entity entity = terrainEntities[j][i];
                entity.setScale(scale);
                entity.setPosition((cellCol + xOffset) * 2.0f, 0, (cellRow + zOffset) * 2.0f);
                entity.getModelMatrix().identity().scale(scale).translate(entity.getPosition());
                xOffset++;
            }
            zOffset++;
        }
    }
}