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