3. 長さ・距離・正規化
第2章では、ベクトルは数字のリストであり、同時に矢印でもある事を学びました。
矢印には一目で分かる性質が二つあります。
どれ位長いか、そしてどちらを向いているかです。
此の章では一つ目の測り方と、其の二つを切り離す方法を学びます。
小さな事に聞こえますが、グラフィックスプログラマーは此れを常にやっています。
敵はプレイヤーからどれ位離れているか?
光はどの方向から来ているか?
何故斜めに歩くとキャラクターが速くなるのか?
此れらの疑問は全て、length、distance、normalizeという三つの小さな関数で答えられます。
3.1 考え方:矢印の長さは?
東に3メートル歩いて、其れから北に4メートル歩いたとします。
歩いた距離は合計7メートルですが、出発点から一直線で測るとどれ位離れているでしょうか?
其の一直線が矢印で、知りたいのは其の長さです。
図を見て下さい。
矢印と横の移動と縦の移動で、直角三角形が出来ています。
矢印は一番長い辺(斜辺)なので、第1章のピタゴラスの定理が使えます。
3.2 公式
ベクトルの長さは二重の縦線で書きます:。
「vの長さ」と読みます(他に「大きさ」や「ノルム」とも呼ばれます)。
二重線を使うのは、此の章で使う一つの数の絶対値と区別する為です。
2次元ベクトルの場合:
3次元ベクトルの場合:
そして一般に、個の成分を持つベクトルの場合、第1章のシグマ記号を使うと:
言葉で言うと、全ての成分を二乗して、足し合わせて、平方根を取ります。
其れだけです。
二乗は負にならないので長さも負にならず、長さが0になるのはゼロベクトルだけです。
二つの点との間の距離は、片方からもう片方への矢印の長さです:
単位ベクトルとは、長さが丁度1のベクトルです。
単位ベクトルには小さな帽子を付けてと書きます(「vハット」と読みます)。
0でないベクトルを、同じ向きの単位ベクトルに変える事を正規化と言います:
此の章の公式は此れで全部です。
では、何故此れで正しいのかを見ていきましょう。
3.3 仕組み
3.3.1 2次元の長さ
の場合、図の直角三角形の二辺は3と4です。
ピタゴラスの定理によると、斜辺の二乗は他の二辺の二乗の和に等しいです:
従って、出発点から丁度5メートル離れています。
負の成分でも同じです。二乗すると符号が消えるからです:の長さもです。
東ではなく西に3メートル歩いても、最終的な距離は変わらないので、納得出来ますね。
3.3.2 3次元の長さ:ピタゴラスを二回
3次元では矢印は平らな三角形の上に乗っていませんが、問題を二つに分ける事が出来ます。
を考え、幅2(x)、高さ3(y)、奥行き6(z)の箱を想像して下さい。
矢印は底面の角から、反対側の上の角まで伸びています。
ステップ1:底面をからまで横切ります。
底面は2×6の平らな長方形なので、ピタゴラスで底面の対角線が求まります:
ステップ2:から真上に3進むとに着きます。
底面の対角線と縦の辺は直角なので、二辺がと3のもう一つの直角三角形が出来ます:
其の物(という汚い数)は一度も必要無かった事に注目して下さい。
必要だったのはだけで、其処にを足しました。
つまり合計は単にで、此れが3次元の公式です。
3.3.3 もっと多くの次元
同じ方法がずっと使えます。
成分が一つ増える度に、直角が一つ増え、根号の中の二乗が一つ増えます。
4次元は描けませんが、計算は出来ます。の場合:
グラフィックスで4次元ベクトルの長さが必要になる事は殆どありません(4次元ベクトルは第9章で本格的に登場します)が、データサイエンスでは数百個の成分を持つベクトルは普通で、公式は変わりません。
3.3.4 二点間の距離
第2章で、は点から始まり点で終わる矢印だと学びました。
二点間の距離は、単に其の矢印の長さです。
、とします。
どちらからどちらを引いても構いません。
は逆を向いていますが、二乗は同じなので距離は13のままです。
3.3.5 長さの二乗と距離の二乗
平方根の計算は、足し算や掛け算よりも明らかに遅いです。
一回なら問題ありませんが、ゲームでは1フレームに何百万回も距離を比較する事があります。
此処で使える技があります。
二つの長さがどちらも負でなければ、大きい方は二乗も大きいです。
従って、距離を比較したいだけなら、二乗同士を比較して平方根を完全に省略出来ます。
例:原点にいるプレイヤーから10以内に敵がいるか?
を調べる代わりに、を調べます。
| 敵 | 100と比較 | 結果 | |
|---|---|---|---|
| 内側 | |||
| 外側 |
平方根は何処にもありません。
但し、半径も二乗するのを忘れないで下さい。を100ではなく10と比較するのは典型的なバグです。
3.3.6 ベクトルを拡大すると長さも拡大される
ベクトルに数を掛ける(第2章のスカラー倍)と、長さはどうなるでしょうか?
長さ7のに3を掛けてみます:
次に長さ5のにを掛けます:
長さは倍ではなく、2倍になりました。
マイナス記号は矢印を反転させましたが、反転した矢印の長さは同じです。
此処で絶対値の出番です。は「sからマイナス記号を取った物」という意味で、、です。
規則は次の通りです:
何故なら、全ての成分が倍されるので、全ての二乗が倍されます。
和からを括り出せて、だからです。
3.3.7 正規化
上の規則が正規化の鍵です。
長さのベクトルにを掛けると、新しい長さはになります。
そしては正なので、向きは変わりません。
長さ5のの例:
確認:。
長さ7のの例:
確認:。
3.3.8 長さ×向き
正規化を使うと、0でない全てのベクトルを二つの独立した情報に分けられます:
長さは「どれ位」を、単位ベクトルは「どちらへ」を表します。
速度なら、速さ×向きです。
例:速度メートル毎秒で動くボール。
速さは m/s、向きはで、確かにです。
ゲームでは二つの部分を別々に制御したい事が多いので、此の分解はとても便利です。
AIが何処へ行くか(向き)を決め、キャラクターのステータスがどれ位速いか(速さ)を決め、最後に掛け合わせます。
3.3.9 ゼロベクトルの問題
正規化出来ないベクトルが一つだけあります。ゼロベクトルです。
長さが0なので、公式は0で割る事を求めます。
浮動小数点数の計算でははクラッシュせず、NaN(「Not a Number」、数ではない)という特別な値になります。
NaNは毒の様な物で、NaNに触れた計算は全てNaNになります。
位置に一つでもNaNが入ると、モデルが画面から消えたり、物理オブジェクトが何処かへワープしたりします。
そして此れは珍しいケースではありません。
プレイヤーが目標の真上に立っている、スティックがニュートラル、壊れたメッシュの二つの頂点が同じ場所にある、等です。
ゼロベクトルには単に向きが無いので、正しい答えが存在しません。
此のシリーズのlinalg.hhは単純な方法で対処します。ゼロベクトルをnormalizeするとゼロベクトルを返します。
安全(NaNにならない)ですが、其の場合「正規化済み」のベクトルでもゼロになり得る事を覚えておいて下さい。
シェーダーのnormalizeは守ってくれません。GLSLでもSlangでもHLSLでも、ゼロベクトルを正規化した結果は未定義で、大抵NaNになります。
3.4 グラフィックスプログラミングでの使い方
3.4.1 斜め移動のバグ
此れは此の章で一番有名なバグで、殆どの初心者のゲームにあります。
プレイヤーはDで右へ、Wで前へ移動します。
此のシリーズの右手座標系では右が+X、前が−Zなので、コードは次の様な入力ベクトルを作ります:
W only: ( 0, 0, -1) length 1
D only: ( 1, 0, 0) length 1
W + D : ( 1, 0, -1) length 1.4142 <- too long!
そしてposition = position + speed * input * dtを実行します。
速さが5の場合、真っ直ぐ進むと毎秒5ですが、斜めだと:
斜めに移動するとプレイヤーは約41%速くなります。
スピードランナーは此のバグが大好きですが、ゲームデザイナーは嫌いです。
修正方法は、速さを掛ける前に入力の向きを正規化する事です:
此れでがどの方向でも成り立ちます。
此処ではゼロベクトルの規則が重要です。キーが何も押されていなければ入力はですが、此のシリーズのnormalizeはNaNではなくゼロを返すので、プレイヤーは単に止まったままです。
アナログスティックの場合、正規化すると少し傾けただけでも全開と同じ速さになってしまいます。其の為、長さが1より大きい時だけ正規化するのが普通で、そうすれば少しの傾きはゆっくり歩く意味のままです。
3.4.2 AからBへの向き
「何かを別の何かの方へ動かす」はゲームコードの基本中の基本です。プレイヤーを追いかける敵、ホーミングミサイル、目標に向けて撃つ弾、キャラクターを追うカメラ、等。
手順はいつも同じです:
- 引き算:がAからBへの矢印です。
- 正規化:其の長さで割ると、長さ1の向きが得られます。
- 速さと時間を掛けて、位置に足します。
計算例:にいる敵が、にいるプレイヤーを毎秒2の速さで追いかけます。此のフレームは秒です。
プレイヤーがどれだけ遠くにいても、敵は丁度1だけプレイヤーの方へ移動しました。
正規化しないと、プレイヤーが遠い時は速く、近い時はのろのろ動きます。滑らかに「減速しながら」追従するカメラなら其れが欲しい事もありますが、大抵は違います。
3.4.3 光の向きと法線
ライティングには、表面の各点で二つの向きが必要です:
- 光の向き:表面の点から光源への向き、
normalize(lightPos - worldPos)。 - 法線:表面が向いている方向(表面に垂直)。
次の章では、此の二つのベクトルから内積を使って明るさを計算します。
其の計算は、両方のベクトルの長さが1の時だけ正しく動きます。
長過ぎれば理由も無く明るくなり、短過ぎれば暗くなります。
此処に罠があります。
法線は頂点毎に保存されていて、GPUは三角形の上で其れらを第2章の線型補間と全く同じ方法で混ぜます(補間します)。
二つの単位法線との丁度中間では、混ぜた法線は:
此れはもう単位ベクトルではありません。
其の為、殆ど全てのライティング用フラグメントシェーダーは、最初に法線を正規化し直します。
3.4.4 距離に基づくエフェクト
多くのエフェクトは距離と共に弱まります。
シェーダーでは、length、distance、normalizeはSlangにもGLSLにもHLSLにも組み込まれています:
// 1 near the light, 0 at radius and beyond
float attenuation(float3 lightPos, float3 worldPos, float radius) {
float d = distance(lightPos, worldPos);
float a = saturate(1.0 - d / radius);
return a * a;
}
// 1 = no fog, 0 = completely fogged
float fogFactor(float3 cameraPos, float3 worldPos, float fogStart, float fogEnd) {
float d = distance(cameraPos, worldPos);
return saturate((fogEnd - d) / (fogEnd - fogStart));
}// 1 near the light, 0 at radius and beyond
float attenuation(vec3 lightPos, vec3 worldPos, float radius) {
float d = distance(lightPos, worldPos);
float a = clamp(1.0 - d / radius, 0.0, 1.0);
return a * a;
}
// 1 = no fog, 0 = completely fogged
float fogFactor(vec3 cameraPos, vec3 worldPos, float fogStart, float fogEnd) {
float d = distance(cameraPos, worldPos);
return clamp((fogEnd - d) / (fogEnd - fogStart), 0.0, 1.0);
}// 1 near the light, 0 at radius and beyond
float attenuation(float3 lightPos, float3 worldPos, float radius) {
float d = distance(lightPos, worldPos);
float a = saturate(1.0 - d / radius);
return a * a;
}
// 1 = no fog, 0 = completely fogged
float fogFactor(float3 cameraPos, float3 worldPos, float fogStart, float fogEnd) {
float d = distance(cameraPos, worldPos);
return saturate((fogEnd - d) / (fogEnd - fogStart));
}
SlangとHLSLのsaturate(x)とGLSLのclamp(x, 0.0, 1.0)は、どちらも数を0から1の範囲に収めます。
数字で確認しましょう。
にある半径10の光が、点を照らします:
従って、此の点は光の最大の明るさの4分の1を受け取ります。
二乗すると端での減衰が緩やかになり、直線より本物の光に近く見えます。
(本物の光はで弱まります(逆二乗の法則)が、此れは決して0にならないので、ゲームでは此の様に一定の半径で終わる版がよく使われます。)
開始10、終了50のフォグの場合、カメラから30離れた点はなので、第2章のlerp/mixでフォグの色と物体の色を半分ずつ混ぜます。
LOD(Level of Detail、詳細度)はCPU側で距離を使います。近い物体には詳細なメッシュを、遠い物体には軽いメッシュを使います。
閾値と比較するだけなので、距離の二乗を使うのに打って付けです。を、等と比較します。
3.4.5 フラグメントシェーダーで円とビネット
テクスチャ座標(UV)は画面全体で0から1なので、画面の中心はです。
中心からの距離はlength(uv - 0.5)で、中心では0、各辺の真ん中では0.5、角ではです。
距離が同じピクセルは全て一つの円の上にあるので、此の一つの数で円、リング、スポットライト、ビネット(端に向かって暗くなる効果)が描けます。
struct Params {
float2 resolution; // window size in pixels
};
ConstantBuffer<Params> params;
[shader("fragment")]
float4 fragmentMain(float2 uv : TEXCOORD0) : SV_Target {
float2 p = uv - 0.5;
p.x *= params.resolution.x / params.resolution.y; // keep the circle round
float d = length(p);
float vignette = 1.0 - smoothstep(0.3, 0.7, d);
float3 color = float3(1.0, 0.8, 0.6) * vignette;
return float4(color, 1.0);
}#version 450
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
layout(set = 0, binding = 0) uniform Params {
vec2 resolution; // window size in pixels
};
void main() {
vec2 p = uv - 0.5;
p.x *= resolution.x / resolution.y; // keep the circle round
float d = length(p);
float vignette = 1.0 - smoothstep(0.3, 0.7, d);
vec3 color = vec3(1.0, 0.8, 0.6) * vignette;
outColor = vec4(color, 1.0);
}struct Params {
float2 resolution; // window size in pixels
};
ConstantBuffer<Params> params;
float4 fragmentMain(float2 uv : TEXCOORD0) : SV_Target {
float2 p = uv - 0.5;
p.x *= params.resolution.x / params.resolution.y; // keep the circle round
float d = length(p);
float vignette = 1.0 - smoothstep(0.3, 0.7, d);
float3 color = float3(1.0, 0.8, 0.6) * vignette;
return float4(color, 1.0);
}
smoothstep(a, b, x)は、xがaより小さければ0、bより大きければ1、其の間では滑らかなS字カーブを返します。
従って、中心から0.3より近いピクセルは元の色のまま、0.7より遠いピクセルは黒になり、では丁度半分のになります。
解像度の行は正方形でないウィンドウで重要です。UVは縦も横も0から1なので、此れが無いと16:9の画面では「円」が楕円に引き伸ばされます。
3.5 其の他の使い道
長さと距離はグラフィックス以外でも何処にでもあります。
- 地図とGPS:狭い範囲の平らな地図上での二地点間の直線距離は、此の距離の公式其の物です。長距離では地球の丸みが効いてくるので、球面の公式が必要になります。
- k近傍法:新しいデータ点を、最も近いk個の既知の点を見て分類する単純な機械学習の手法です。「近い」とは、多次元での此の距離の公式の事です。
- 特徴ベクトルの正規化:データサイエンスでは、ベクトル(例えば文書の単語数)をよく正規化して、同じ話題の長い文書と短い文書が似て見える様にします。第4章のコサイン類似度で此れを使います。
- 信号の強さ(RMS):オーディオでは、サンプルのブロックの音量をRMS(二乗平均平方根)で測る事がよくあります。サンプルベクトルの長さをで割った物です。サンプルの場合、長さはで、RMSはです。
- 物理:速さは速度ベクトルの長さで、力の強さは力ベクトルの長さです。
- 誤差の測定:「予測が本当の答えからどれ位離れているか」は、二つのベクトル間の距離である事がよくあります。
3.6 コード
では、関数をlinalg.hhに追加しましょう。
第2章のベクトル演算子の下に置いて下さい:
// 長さ・距離・正規化 / length, distance, normalization (chapter 3)
inline float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }
inline float length(Vec3 v) { return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); }
inline float distance(Vec3 a, Vec3 b) { return length(b - a); }
inline Vec3 normalize(Vec3 v) {
float len = length(v);
if (len == 0.0f) return {0.0f, 0.0f, 0.0f}; // ゼロベクトルは正規化出来ない / cannot normalize zero
return (1.0f / len) * v;
}
幾つか補足します:
std::sqrtは<cmath>の関数で、ヘッダーは第1章から既に其れをインクルードしています。distanceは定義其の物で、第2章の引き算演算子を使ったb - aの長さです。normalizeは割る前にゼロベクトルかどうかを確認します。各成分をlenで割る代わりに、1.0f / lenを一回だけ計算して、第2章のスカラー倍で掛けています。len == 0.0fという厳密な比較は、本当のゼロベクトルしか捕まえません。(1e-30, 0, 0)の様なベクトルは正規化されますが、此のチュートリアルでは其れで十分です。
小さなテストプログラムmain.ccです。
比較の技の為にdistanceSquaredという補助関数も定義しています(ヘッダーには含まれません):
#include "linalg.hh"
#include <cstdio>
// Squared distance: no square root needed for comparisons.
float distanceSquared(Vec3 a, Vec3 b) {
Vec3 d = b - a;
return d.x * d.x + d.y * d.y + d.z * d.z;
}
int main() {
Vec2 a{3.0f, 4.0f};
Vec3 v{2.0f, 3.0f, 6.0f};
printf("length(a) = %g\n", length(a));
printf("length(v) = %g\n", length(v));
Vec3 p{1.0f, 2.0f, 3.0f};
Vec3 q{4.0f, 6.0f, 15.0f};
printf("distance(p, q) = %g\n", distance(p, q));
Vec3 n = normalize(v);
printf("normalize(v) = (%.4f, %.4f, %.4f)\n", n.x, n.y, n.z);
printf("length of that = %.4f\n", length(n));
// W + D pressed: right (+X) and forward (-Z) at the same time
Vec3 input{1.0f, 0.0f, -1.0f};
float speed = 5.0f;
printf("raw speed = %.4f\n", length(speed * input));
printf("fixed speed = %.4f\n", length(speed * normalize(input)));
// An enemy chases the player
Vec3 enemy{2.0f, 0.0f, 1.0f};
Vec3 player{5.0f, 0.0f, 5.0f};
Vec3 dir = normalize(player - enemy);
enemy = enemy + (2.0f * 0.5f) * dir; // speed 2, dt 0.5
printf("enemy moved to = (%g, %g, %g)\n", enemy.x, enemy.y, enemy.z);
// Is it within 10 units? Compare squares: 10 * 10 = 100
Vec3 origin{};
Vec3 e1{6.0f, 0.0f, 7.0f};
Vec3 e2{9.0f, 0.0f, 5.0f};
float d1 = distanceSquared(origin, e1);
float d2 = distanceSquared(origin, e2);
printf("e1: %g -> %s\n", d1, d1 < 100.0f ? "inside" : "outside");
printf("e2: %g -> %s\n", d2, d2 < 100.0f ? "inside" : "outside");
Vec3 zero = normalize(Vec3{});
printf("normalize(0) = (%g, %g, %g)\n", zero.x, zero.y, zero.z);
return 0;
}
期待される出力:
length(a) = 5
length(v) = 7
distance(p, q) = 13
normalize(v) = (0.2857, 0.4286, 0.8571)
length of that = 1.0000
raw speed = 7.0711
fixed speed = 5.0000
enemy moved to = (2.6, 0, 1.8)
e1: 85 -> inside
e2: 106 -> outside
normalize(0) = (0, 0, 0)
全ての行が、此の章の計算例の何れかと一致しています。
NaNを自分の目で見たければ、printf("%f\n", 0.0f / 0.0f);を試して下さい(nanと表示するシステムもあれば、-nanと表示するシステムもあります)。
3.7 まとめ
- 長さは、成分の二乗の和の平方根です。ピタゴラスの定理を次元が増える度に一回ずつ使うと出てきます。
- 二点間の距離はです。
- 距離を比較するなら、距離の二乗を比較して平方根を省きます(閾値も二乗する事)。
- ベクトルを倍すると、長さは倍になります。
- 単位ベクトルの長さは1です。正規化とは長さで割る事です:。
- 0でない全てのベクトルは長さ×向きです:。
- ゼロベクトルは正規化出来ません。此のシリーズのヘッダーはゼロを返しますが、シェーダーではゴミ(大抵NaN)になります。
- 移動の入力(斜め移動のバグ)、目標への向き、光の向き、補間された法線は正規化します。
3.8 課題
- ★ 、、の長さを計算して下さい。
- ★ 点との間の距離を計算して下さい。
- ★ を正規化し、結果の長さが1である事を確認して下さい。
- ★ 長さ4のベクトルがあります。其のベクトルを倍した物の長さは?倍した物の長さは?
- ★★
float length(Vec4 v)と、Vec2版のdistance及びnormalizeをヘッダーに追加して下さい(ヘッダーには既にlength(Vec2)とfloat * Vec2があります)。length(Vec4{1, 2, 2, 4})が5を表示し、Vec2{3, 4}の正規化が(0.6, 0.8)を表示する事を確認して下さい。 - ★★
toに向かって最大maxStepだけ移動し、決して行き過ぎない関数Vec3 moveTowards(Vec3 from, Vec3 to, float maxStep)を書いて下さい(目標がmaxStepより近ければtoを返します)。{0, 0, 0}から目標{3, 0, 4}へmaxStep = 2で繰り返し呼ぶと、(1.2, 0, 1.6)、(2.4, 0, 3.2)、(3, 0, 4)、(3, 0, 4)と表示される筈です。 - ★★★ アナログスティックは各軸で−1から1の値を返すので、角まで倒すとを返す事があります。少しの傾きはゆっくりのまま、斜めが真っ直ぐより速くならず、決してNaNを出さない入力処理を設計して下さい。スティックの入力がの時はどうなるべきでしょうか?の時は?
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。