はじめに
リーダブルコードという書籍がある。エンジニア向けの書籍として著名でエンジニアのバイブルなどと持て囃す向きもある。エンジニアをされている方なら一度は耳にしたことのある方も多いだろう。私はこれまでこの書籍を読んだことはなかったが概要は把握していた。その上でこの書籍の影響と思われる事例にしばしば直面してきたためこの記事を書くに至った。なお、あくまでゲーム開発における事例について述べるため、それ以外のシステム開発での有用性については考慮外であることをあらかじめお断りしておく。
一読してみて
とはいえ本書をまったく読まないまま論じるわけにもいかないため、今回この書籍を改めて目を通した。そこで感じたのは、全体としては確かに首肯できる部分はあるものの、経験を積むうちに自然と身に付くようなものが多いという印象である。それでは改めて問題と思う箇所をピックアップして論じていく。
章ごとの論考
「7章 : 制御フローを読みやすくする」について
「7.3 : 三項演算子」
どうも本書の著者は三項演算子が嫌いなようだ。無理に三項演算子にまとめるくらいならif文で数行にわたるコードを書いた方がよいと解説している。散々各所で巨大なコードを小さくしろと解説しているのに矛盾してはいないだろうか。それどころか「11.2 : その他の手法」では論理演算子を並べてif文を一行にまとめる手法を紹介している。三項演算子は駄目で論理演算子は良い理由がよく分からない。
同じようにdo/while文も嫌いなようであり使用しないことを推奨している。しかし私は三項演算子もdo/while文も、もちろん論理演算子も大好きで便利なものだと思っているしそれが読み辛いと思ったことはない。do/while文を使いたくないばかりにwhileの条件をひねくり回しフラグ変数なんか導入するくらいなら素直にdo/while文で書いた方がマシだろう。
「10章 : 無関係の下位問題を抽出する」について
これは目的と直接関係しないと思われる部分の処理を関数化(メソッド化)して外出しし、見通しをよくしようというテクニックである。この教えの影響だろう、とにかく長い処理はメソッド化するのが望ましいとばかりに何でもメソッド化してしまい大量のメソッドで溢れる実装をいくつも見てきた。しかしそれでコードが読みやすくなったとは到底思えない。
とにかくメソッド化されている部分に来ると処理内容を確認するために別の行やクラスに飛ばなければいけない。そこで行き来させられるのはかなり苦痛である。加えて飛んだ先の処理がさらにメソッド化されていてたらい回しにされたり、中にはメソッド化された中身が一行しかなかったりと、うんざりさせられたことがある。
私はメソッドの行数が肥大化しても、処理がぶつ切りにされるよりは一つにまとまっている方が処理の流れが追いやすく読みやすい。
本書では例文が挙げられていたのでそれを引用してみよう。
var findClosestLocation = function(lat, lng, array){
var closest;
var closest_dist = Number.MAX_VALUE;
for (var i = 0; i < array.length; i += 1){
var lat_rad = radians(lat);
var lng_rad = radians(lng);
var lat2_rad = radians(array[i].latitude);
var lng2_rad = radians(array[i].longitude);
var dist = Math.acos(Math.sin(lat_rad) * Math.sin(lat2_rad) +
Math.cos(lat_rad) * Math.cos(lat2_rad) *
Math.cos(lng2_rad - lng_rad));
if (dist < closest_dist){
closest = array[i];
closest_dist = dist;
}
}
return closest;
};
このコードが長文なので問題だとしている。そこでコードの一部を関数化して抽出することを勧めておりその結果が以下である。
var spherical_distance = function(lat1, lng1, lat2, lng2){
var lat_rad = radians(lat);
var lng_rad = radians(lng);
var lat2_rad = radians(array[i].latitude);
var lng2_rad = radians(array[i].longitude);
return Math.acos(Math.sin(lat_rad) * Math.sin(lat2_rad) +
Math.cos(lat_rad) * Math.cos(lat2_rad) *
Math.cos(lng2_rad - lng_rad));
};
var findClosestLocation = function(lat, lng, array){
var closest;
var closest_dist = Number.MAX_VALUE;
for (var i = 0; i < array.length; i += 1){
var dist = spherical_distance(lat, lng, array[i].latitude, array[i].longitude);
if (dist < closest_dist){
closest = array[i];
closest_dist = dist;
}
}
return closest;
};
いや、これは駄目だろう。なぜその部分だけ関数化して切り離してしまうのか。本書ではこれで幾何学計算の部分に心を奪われることがなくなったと自負している。しかしこの処理は幾何学計算も含めて一つの処理だ。今後幾何学計算の部分に改修を入れる必要が出てくるかもしれない。その時にこうした分離はかえってコードを読みにくくし改修内容も煩わしいものになりかねない。さらに本書では関数化された部分は再利用が可能だと自負しているが、これも今すぐ再利用すると決まったわけではない。不必要な機能の実装については本書自身が13.1で戒めている。一方で、救いがあるのは切り出した関数が戻り値を返す形式になっていることだ。このおかげで少なくともこの関数が何らかの値を返す処理であることが関数本体に目を通さなくても分かるようになっている。しかしそれについての説明は本書にない。これを読み解けるエンジニアがどれほどいるのだろうか。
「13章 : 短いコードを書く」について
ここでは開発が進めばコードやファイルの量が増大し、管理が難しくなることを説いている。そのためにコードをできるだけ小さく保てと述べている。これ自体はもっともな内容である。そのために定期的なリファクタリングを行うことの重要性は私も重々理解している。しかしそれでも、必要不可欠な処理だけで数千行、数万行に及ぶコードが出来上がることはプロジェクトの規模に比例して発生する。
広辞苑について、「項目の量が多すぎる!もっと減らすべきだ!」などとクレームを付ける者はいないだろう。25万に及ぶ膨大な項目量こそ広辞苑のアイデンティティともいえる。そして各項目自体は非常に簡素であり必要最低限の解説が書かれているのみである。
コードもまた然りで、肥大化したコードはそれだけ複雑で多様な処理を行う高度なシステムである証左である。それを無理矢理何とかしようとしても、却って問題は大きくなってしまう。
一つのクラスのコード量が肥大化して目的のメソッドが探しにくくなったからといって別のクラスに分割するようなことをすると、今度は分割したコードファイルが大量に生まれてクラスを探す作業に変換されただけでなくオーバーヘッドの原因ともなりかねない。
全体を通して
ここからは本書全体に対する反論と持論を述べていく。
リーダビリティに基準はない
そもそもリーダビリティ(可読性)に絶対的な基準はない。あくまで主観によるところが大きい。なぜならそれは文脈や読み手の状況によって変化するものだからだ。そこで例を挙げて解説しよう。
例題
3次元空間にある点が三角錐の内部に存在するかどうかを判定するメソッドがある。このメソッドにどうも不具合があり、正しい判定がされていない。そこであなたはこの不具合の修正を行うことになった。該当のメソッドを調査しよう。対象のソースコードは以下である。なお、言語はUnity C#で書かれている。
using UnityEngine;
using System;
using Random = UnityEngine.Random;
public class GeometryCalculation : MonoBehaviour
{
// --- 判定結果を表す列挙型を定義します。結果は詳細に! ---
public enum ContactResult
{
Separated_Above, // 完全に分離している (平面の法線側)
JustTouching, // ぴったり接触している (キスしている状態)
Intersecting_Front, // 交差・めり込んでいる (平面の法線側から侵入)
Intersecting_Back, // 交差・めり込んでいる (平面の裏側から侵入)
PassedThrough_Back // 完全に通過して分離している (平面の裏側)
}
/// <summary>
/// 球の中心から平面までの符号付き最短距離 d_signed を計算し、半径 R と比較することで
/// 詳細な関係性(接触、交差、通過など)を分析します。
/// </summary>
public static ContactResult GetSpherePlaneRelationship(Vector3 C, float R, Vector3 N, Vector3 P_pt)
{
// 浮動小数点誤差は敵。これを使い、境界線を曖昧にします。
const float Epsilon = 0.0001f;
// 1. 平面上の点 P_pt から球の中心 C へのベクトル V_CP を準備。
Vector3 V_CP = C - P_pt;
// 2. 符号付き最短距離 d_signed の計算!
// d_signed = V_CPを法線Nに投影した長さ = V_CP ・ N
// この値の符号が、Cが平面の「どちら側」にいるかを教えてくれます!
float d_signed = Vector3.Dot(V_CP, N);
// --- d_signed と R の比較による詳細判定 ---
// Case 1: 完全に分離 (表側)
// d_signed が R より大きく、しかも正の値である場合。
if (d_signed > R + Epsilon)
{
// 球は平面の表側(法線N側)で、半径分以上の距離が離れている。平和!
return ContactResult.Separated_Above;
}
// Case 2: ぴったり接触 (境界線)
// d_signed が R にほぼ等しい場合。
// Epsilonを使って「ほぼR」を判定します。
if (Mathf.Abs(d_signed - R) < Epsilon)
{
// 距離がピッタリ半径と一致。完璧なキス(接触)状態!
return ContactResult.JustTouching;
}
// Case 3: 交差 (d < R) または通過 (d < -R) の領域へ!
// 3a. 裏側での分離 (Passed Through)
// d_signed が -R よりも小さい場合(裏側で、半径分以上離れている)
if (d_signed < -R - Epsilon)
{
// 球は平面を完全に通り抜け、裏側で離脱しています。幽霊かな?
return ContactResult.PassedThrough_Back;
}
// 3b. 交差 (Intersecting) - ここまで来たら必ず交差している!
// 符号付き距離 d_signed が正(0 < d_signed < R)。
// 球の中心Cはまだ平面の表側だが、側面が平面にめり込んでいる。
if (d_signed > Epsilon)
{
// 表側からめり込み中!
return ContactResult.Intersecting_Front;
}
// 符号付き距離 d_signed が負(-R < d_signed <= 0)。
// 球の中心Cは既に平面の裏側にいるが、まだ完全に通過しきっていない。
// (つまり、球の一部がまだ平面の表側に残っている状態)
else if (d_signed < -Epsilon)
{
// 裏側に入り込み中!
return ContactResult.Intersecting_Back;
}
// 最終手段:d_signed がほぼゼロ(|d_signed| < Epsilon)。
// 球の中心が平面上にいる場合、これは「中心が交差している」状態。
// ここでは、交差(裏側からの判定)として扱っておきましょう。
else
{
return ContactResult.Intersecting_Back;
}
}
/// <summary>
/// モンテカルロ法:球面上にランダムな点を打ち込み、そのうち立方体の内部に入った割合で面積を推定します。
/// これは、積分が解析的に困難な場合の「最後の切り札」です!
/// </summary>
/// <param name="C_s">球の中心</param>
/// <param name="R">球の半径</param>
/// <param name="C_c">立方体の中心</param>
/// <param name="L">立方体の一辺の長さ</param>
/// <param name="N">試行回数(サイコロの数)</param>
/// <returns>交差部分の表面積の近似値</returns>
public static float CalculateIntersectionSurfaceArea(
Vector3 C_s, float R,
Vector3 C_c, float L,
int N)
{
float totalSphereArea = 4.0f * Mathf.PI * R * R; // 全表面積を先に計算。
int hits = 0; // 立方体の内部に命中したラッキーな点のカウンターです。
// 立方体の半分の長さ。AABB判定(軸並行バウンディングボックス)に必要です。
float halfL = L / 2.0f;
// ---------------------------------------------------
// メインループ:N回、狂ったように点を打ち込み続ける!
// ---------------------------------------------------
for (int i = 0; i < N; i++)
{
// 2. 球の表面上のランダムな点 P を生成します。
// ランダムな方向ベクトルを生成する、Unityの便利な機能!
// このnormalizedされたベクトルをR倍すれば、球面上にランダムに点Pが生まれます。
// ちなみに、Random.onUnitSphereは完全な均等分布になるので、モンテカルロ法に最適です!
Vector3 randomDirection = Random.onUnitSphere;
// P_sphere: 球の中心を原点とした、球面上の点の座標。
Vector3 P_sphere = randomDirection * R;
// P_w: 球の中心 C_s を考慮した、ワールド座標 P_w。これが我々の判定点です!
Vector3 P_w = P_sphere + C_s;
// 3. 点 P_w が立方体の内部にあるかを判定(非常に重要なステップ!)
// 立方体が回転していないと信じて(AABBチェックと仮定)、軸ごとに範囲をチェックします。
// X軸の範囲チェック:P_w.xが [C_c.x - halfL, C_c.x + halfL] の間にあるか?
bool inX = (P_w.x >= C_c.x - halfL) && (P_w.x <= C_c.x + halfL);
// Y軸の範囲チェック:同様にY軸でもチェック。
bool inY = (P_w.y >= C_c.y - halfL) && (P_w.y <= C_c.y + halfL);
// Z軸の範囲チェック:同様にZ軸でもチェック。
bool inZ = (P_w.z >= C_c.z - halfL) && (P_w.z <= C_c.z + halfL);
if (inX && inY && inZ)
{
// 全ての軸の範囲内!点Pは立方体の内側に命中しました!
hits++;
}
// ちなみに、もし立方体が回転していたら、このシンプルなinX/inY/inZチェックは使えません。
// その場合は、点Pを立方体のローカル座標系に変換する、もっと面倒な作業が必要になります。(←余談)
}
// 4. 表面積の近似値を計算。これがモンテカルロ法の核心です!
// 割合 (Ratio) を計算: (命中数 / 全試行数)
float ratio = (float)hits / N;
// 交差表面積 = 全表面積 × 割合
float intersectionArea = totalSphereArea * ratio;
// 厳正なる計算の結果を返却!
return intersectionArea;
}
/// <summary>
/// レイキャスティング法(AABBではなく、直接4面をチェック)で、直線が三角錐を貫通するか判定します。
/// この手法は、レイが面を通過するtの値を追跡し、t_enterとt_exitを求めるのが核心です。
/// </summary>
/// <returns>IntersectionResult の詳細な判定結果</returns>
public static IntersectionResult GetLineTetrahedronRelationship(
Vector3 R0, Vector3 D,
Vector3 V0, Vector3 V1, Vector3 V2, Vector3 V3)
{
// 浮動小数点誤差を許容するための、最も重要な小さな定数。
const float Epsilon = 0.0001f;
// 直線が四面体に入ってくるtの値(t_enter)と、出ていくtの値(t_exit)を追跡します。
// 初期値は、t_enterは可能な限り小さく、t_exitは可能な限り大きく設定します。
float t_enter = float.NegativeInfinity; // レイの遥か後方から始まるイメージ
float t_exit = float.PositiveInfinity; // レイの遥か前方で終わるイメージ
// 判定する4つの面(平面)を定義します。法線の向きを統一することが重要!
// V0を基準に内側を向くように平面を定義します(逆の方がレイキャスティングでは一般的ですが、ここでは処理をシンプルにします)。
// 面の配列を定義する代わりに、一つずつチェックするぞ!
// --- 4つの面を順番にチェックするぞ! ---
// 面 F0: V1, V2, V3
// UnityのPlaneは法線方向を定義時に決定しますが、ここでは法線を手動で計算し、V0を基準に内側を向かせます。
Vector3 N0 = Vector3.Cross(V2 - V1, V3 - V1).normalized;
// 法線がV0側(内側)を向くように調整
if (Vector3.Dot(N0, V0 - V1) > 0) N0 = -N0;
// N0 が内側を向いているので、レイが内側に向かう(交差する)時 t(D.N) < 0 となり、
// レイが面を通過する時の t の値が更新されます。
// 判定は、4回繰り返すので、関数に切り出すのが賢明でしたね。(←セルフツッコミ)
if (!UpdateTExtremes(R0, D, V1, N0, ref t_enter, ref t_exit, Epsilon)) return IntersectionResult.Separated;
// 面 F1: V0, V3, V2
Vector3 N1 = Vector3.Cross(V3 - V0, V2 - V0).normalized;
if (Vector3.Dot(N1, V1 - V0) > 0) N1 = -N1;
if (!UpdateTExtremes(R0, D, V0, N1, ref t_enter, ref t_exit, Epsilon)) return IntersectionResult.Separated;
// 面 F2: V0, V1, V3
Vector3 N2 = Vector3.Cross(V1 - V0, V3 - V0).normalized;
if (Vector3.Dot(N2, V2 - V0) > 0) N2 = -N2;
if (!UpdateTExtremes(R0, D, V0, N2, ref t_enter, ref t_exit, Epsilon)) return IntersectionResult.Separated;
// 面 F3: V0, V2, V1
Vector3 N3 = Vector3.Cross(V2 - V0, V1 - V0).normalized;
if (Vector3.Dot(N3, V3 - V0) > 0) N3 = -N3;
if (!UpdateTExtremes(R0, D, V0, N3, ref t_enter, ref t_exit, Epsilon)) return IntersectionResult.Separated;
// --- 最終判定フェーズ ---
// 1. t_enter が t_exit よりも大きい場合、直線は四面体を通過できていません。
if (t_enter > t_exit + Epsilon)
{
// 軸並行バウンディングボックス(AABB)判定でよくある「スライスの矛盾」と同じ現象です。
return IntersectionResult.Separated;
}
// 2. t_enter または t_exit が極端な値のままの場合(レイが四面体の後ろにあるなど)。
// ただし、上記の処理でt_enter/t_exitは既に有限値になっているはずですが、レイの始点 R0 が四面体の後ろにある場合を考慮します。
// 3. 接触 or 交差の判定
// t_enter の値がゼロに近い場合、レイの始点 R0 が四面体の面に接していることを意味します。
if (t_enter < Epsilon && t_enter > -Epsilon)
{
// レイの始点R0が面に完全に乗っている場合、これは「接している」と判定します。
return IntersectionResult.Touching;
}
// t_enter が正の値、かつ t_enter < t_exit の場合、貫通しています。
if (t_enter > Epsilon)
{
// t_enter > 0 なので、レイの始点R0は四面体の外側にあり、そこから内部へ突入しています。
return IntersectionResult.Intersecting;
}
// t_enter が負の値の場合(レイの始点 R0 が既に四面体の内部にある場合)
if (t_enter < -Epsilon)
{
// レイの始点R0が内部にあり、t_exitが正の値であれば、交差(内部から外部へ抜ける)です。
if (t_exit > Epsilon)
{
return IntersectionResult.Intersecting; // 内部にいる点から外部へ抜ける
}
// t_exit も負の場合、直線は完全に四面体の内部で完結しているか、または無限に伸びている場合です。
// 多くのレイキャスティングではt>0のみを考慮するため、ここは「交差」としておきます。
return IntersectionResult.Intersecting;
}
// ここまで来たら、何らかの接触はしているはず(デバッグ用)
return IntersectionResult.Touching;
}
/// <summary>
/// 直線 L と平面 Pi の交点 t を計算し、t_enter および t_exit の値を更新する、複雑なサブルーチン。
/// レイキャスティングの最も面倒で、かつ重要な部分です。
/// </summary>
/// <param name="P">平面上の点 (Plane Point)</param>
/// <param name="N">平面の法線 (Normal)</param>
/// <returns>分離していると分かった場合 false、継続する場合は true</returns>
private static bool UpdateTExtremes(Vector3 R0, Vector3 D, Vector3 P, Vector3 N,
ref float t_enter, ref float t_exit, float Epsilon)
{
// 直線と平面の交点計算の分母 (D ・ N)
float denominator = Vector3.Dot(D, N);
// 直線と平面の交点計算の分子 (-(R0 - P) ・ N)
float numerator = -Vector3.Dot(R0 - P, N);
// 1. D ・ N がほぼゼロの場合(レイが平面に平行である場合)
if (Mathf.Abs(denominator) < Epsilon)
{
// Pが四面体の面を定義する点なので、R0-PはPからR0へのベクトル。
// もし分子もゼロなら、レイは平面上に存在します(四面体の面をかすめている)。
if (Mathf.Abs(numerator) < Epsilon)
{
// レイは平面上に乗っている。これは「接触」の可能性ありとして、tは更新せずに継続!
return true;
}
else
{
// レイは平面に平行で、平面上にはない。
// Nが内側を向いているので、レイが四面体の内側にいなければ、分離確定です。
// (R0 - P) ・ N > 0 の場合、R0は平面の外側にいる。
if (numerator > 0)
{
// 四面体の外側で平行。完全に分離!
return false;
}
// 内側で平行なら、レイは四面体を通過せず内部にとどまっている(交差)として継続
return true;
}
}
// 2. 交点 t の値を計算
float t = numerator / denominator;
// 3. t_enter / t_exit の更新ロジック
// D ・ N < 0 の場合、レイは四面体の「内部」へ向かう(進入面)
if (denominator < 0)
{
// t_enterを最大値で更新(最も遠い進入面が t_enter を決定する)
t_enter = Mathf.Max(t_enter, t);
}
// D ・ N > 0 の場合、レイは四面体の「外部」へ向かう(脱出面)
else // (denominator > 0)
{
// t_exitを最小値で更新(最も近い脱出面が t_exit を決定する)
t_exit = Mathf.Min(t_exit, t);
}
// 4. 途中で t_enter > t_exit となったら、レイは四面体を通過不可能。
if (t_enter > t_exit + Epsilon)
{
// 幾何学的な矛盾!レイは四面体のスライスを通過できない!
return false;
}
return true; // 処理継続
}
/// <summary>
/// 点Pが三角錐(V0, V1, V2, V3)の内部にあるかを判定する。
/// </summary>
public static bool IsPointInTetrahedron(Vector3 P, Vector3 V0, Vector3 V1, Vector3 V2, Vector3 V3)
{
// 浮動小数点誤差を許容するための微小な値
const float Epsilon = 0.0001f;
// 1. 4つの三角形の面(平面)を定義する
// 法線の向きがすべて外側を向くように、頂点の順序を統一する。
Plane F0 = new Plane(V1, V2, V3);
if (F0.GetSide(V0)) F0.Flip();
Plane F1 = new Plane(V0, V3, V2);
if (F1.GetSide(V1)) F1.Flip();
Plane F2 = new Plane(V0, V1, V3);
if (F2.GetSide(V2)) F2.Flip();
Plane F3 = new Plane(V0, V2, V1);
if (F3.GetSide(V2)) F3.Flip();
// 2. 点Pがすべての面に対して「裏側」(符号付き距離 <= 0)にあるかを判定
float d0 = F0.GetDistanceToPoint(P);
if (d0 > Epsilon) return false;
float d1 = F1.GetDistanceToPoint(P);
if (d1 > Epsilon) return false;
float d2 = F2.GetDistanceToPoint(P);
if (d2 > Epsilon) return false;
float d3 = F3.GetDistanceToPoint(P);
if (d3 > Epsilon) return false;
// すべての面に対して内部側(または境界上)にある
return true;
}
/// <summary>
/// 点Pが有限の円錐(V, A, α, H)の内部にあるかを、几帳面すぎるほど厳密に判定します。
/// この関数は静的(Static)なので、クラスのインスタンスがなくても呼び出せます。便利!
/// </summary>
/// <returns>条件をすべてクリアすれば true、一つでも失敗すれば false を返します。</returns>
public static bool IsPointInCone(Vector3 P, Vector3 V, Vector3 A, float alphaDeg, float H)
{
// 浮動小数点数(float)は信頼性に欠けます。厳密なゼロ判定はできません!
// そのため、この微小な許容誤差 Epsilon が、私たちの計算の「守護天使」となります。
const float Epsilon = 0.0001f;
// 1. 頂点Vから判定点Pへ向かうベクトル (VP) を作成。
// これは「頂点を原点としたときの点Pの位置」を示します。
Vector3 VP = P - V;
// 2. 軸上での高さ h を計算。
// VPを軸Aにどれだけ投影できるか?それは内積(Dot Product)で一発ですね!
// h の値がマイナスだと、Pは円錐の「後ろ側」にいることを示します。
float h = Vector3.Dot(VP, A);
// --- フェーズA: 軸上の位置判定 (最も簡単なチェック) ---
// Pが円錐の軸の反対側(h < 0)にいる場合。
// 円錐は一方向しか見ていませんから、これは即刻不合格です!
if (h < -Epsilon) // Epsilonで境界(Vの真上)も許容する
{
// 「お前は円錐の背後にいる!」判定。
return false;
}
// Pが円錐の定義された高さ H を超えている場合。
// 円錐の「天井」を突き抜けているなら、当然、内部ではありませんね。
if (h > H + Epsilon) // Epsilonで底面上の点も許容する
{
// 「お前は高すぎる!」判定。
return false;
}
// ここまで来たら、Pは軸上での位置(高さ)はクリアしています。よし!
// --- フェーズB: 半径判定 (幾何学の核心) ---
// 3. 点Pから円錐の軸 A までの最短距離 d_perp を計算します。
// Pが軸からどれだけ「横に逸れているか」を知るための、極めて重要なステップです。
// d_perp^2 = |VP|^2 - h^2 (ピタゴラスの定理の逆ですね。直角三角形を想像してください!)
float VPsquared = VP.sqrMagnitude; // ベクトルの長さの二乗はこれで楽ちん。
float h_squared = h * h;
// VPsquared - h_squared が数値誤差でわずかに負になるのは嫌なので、Mathf.Maxでゼロ未満を防ぎます。
float d_perp_squared = Mathf.Max(0.0f, VPsquared - h_squared);
float d_perp = Mathf.Sqrt(d_perp_squared); // これが最短距離です!
// 4. その高さ h における円錐の「許容される最大の半径」 R(h) を計算します。
// なぜなら、円錐の許容半径は高さによって変わるからです。当たり前ですね。
// まずは度数法をラジアンに。これでMathf.Tan()が使えます。
float alphaRad = alphaDeg * Mathf.Deg2Rad;
// R(h) = h × tan(α)
// tan(α)は円錐の「広がり率」を示す定数になります。
float maxRadius_at_h = h * Mathf.Tan(alphaRad);
// 5. 最終比較: d_perp <= R(h)
// Pの実際の軸からのズレが、許容される最大のズレ(半径)より小さいか?
if (d_perp <= maxRadius_at_h + Epsilon)
{
// 軸上の位置も、半径のズレも、すべて条件を満たしました!
// Pは円錐の「内側」にいると結論付けられます!
return true;
}
// 半径の条件でアウト!円錐の側面を突き破って外に出ています。
return false;
}
}
さてソースコードに目を通してみてどう感じただろうか。
先に正解を述べると、該当のメソッドはIsPointInTetrahedronで以下の箇所を次のように修正すればよい。
if (F3.GetSide(V2)) F3.Flip();
↓
if (F3.GetSide(V3)) F3.Flip();
しかし、それよりも無関係なメソッドの過剰なコメントが目に付かなかっただろうか。そのためにかなりの行数を費やしている。肝心のIsPointInTetrahedronはコメントが控え目のため埋没気味になってしまっている。
これは点の三角錐内部判定メソッドに注目しているため、それ以外の記述内容がノイズになるのである。逆に例えば球と平面の交差判定メソッド(GetSpherePlaneRelationship)に注目した場合、過剰なコメントは処理内容を分かりやすく解説してくれるありがたいものとなるだろう。もっともそれは最初だけで、内容を把握した後ではやはり煩わしいものとなるだろう。
このように処理の何に注目するかや読み手の状態でリーダビリティというのは変わってくる。安易に第三者が読みやすいか読みにくいかを判断するのは危険である。
処理の複雑さとコードの複雑さは比例する
複雑な処理を行うにはコードも複雑な記述が必要になる。求められる処理の複雑さに比例してコードの複雑さや量も増えていく。これは仕方のないことである。悪いコードとは、簡単な処理を複雑に書いているコードである。
しかし本書はとかくコード量が少ないことを良しとするきらいがある。あまりに本書を過剰に持ち上げすぎると、複雑なコードを書くことを躊躇してしまうのではないか。その結果出来上がるのは、依頼されても「出来ません」を連発する駄目エンジニアである。
まとめ
本書で紹介されている内容は首肯できる部分が多いのは冒頭で述べた通りである。とりわけコード記法には有効なテクニックが多く収録されていると感じる。一方で、とりわけゲーム開発のような複雑で処理が肥大化しやすいプロジェクトにおいては安易に受け売りすべきではない内容が含まれているのも確かである。いかに本書がおすすめとして紹介されているからといって、エンジニアのバイブルなどと称して妄信したりせず批判的な視点をもって読むべきであろう。そもそも本書の副題に「より良いコードを書くためのシンプルで実践的なテクニック 」と記載されている。それ以上でもそれ以下でもないだろう。