看板が鏡文字になった日
球体の惑星で、建物に寄ると看板が左右反転していた。原因はカメラの「上」の向き。最初の診断が間違っていたことと、テストがバグを守っていたことの記録。
#Three.js#カメラ#テスト
8月の初め、ポートフォリオのコードをまとめてレビューしてもらったところ、一番上に「致命的」として書かれていた指摘がこれでした。
建物にズームしたときのスクリーンショットで、「PRODUCTS」と「GAME」の看板が左右反転して描かれている。
このサイトのトップは、宇宙に浮かぶ小さな惑星です。表面に5つの建物が立っていて、目印をクリックすると、カメラがその建物へ飛んでいって正面から映します。その正面の看板が、鏡に映したように裏返っていたのです。
しかも厄介なことに、5つの建物のうち、症状がひどいのは3つだけでした。残りの2つは、ほぼ普通に見えていた。この「建物によって症状の重さが違う」ことが、発見を遅らせていました。
最初の診断は、間違っていた
レビューには、原因と直し方も書かれていました。カメラの位置を決める式で、「地面に沿った方向」と「地面から垂直な方向」に掛ける cos と sin が入れ替わっている。入れ替えれば直る、という診断です。
根拠もありました。式の中の角度の定数には「地面の法線から19.5度傾ける」と説明が書いてあるのに、実際の式は「地平線から19.5度」になっている。説明と式が食い違っているのだから、式が間違っているはずだ、と。
ところが、実際に入れ替えてみると、カメラは建物を真上から見下ろす構図になりました。目指していた参考写真は、地平線が画面の下にあって、看板を正面から少し見上げるような構図です。入れ替えると、参考写真から遠ざかる。
間違っていたのは式ではなく、定数の説明のほうでした。「地平線から19.5度」という式の値は、参考写真の構図とほぼ一致していて、正しかったのです。説明を書き間違えていたせいで、レビューする側が説明を信じ、正しい式を疑う。誤った説明1行が、誤診を1回まるごと生んでいました。
本当の原因は、カメラの「上」
では、何が看板を裏返していたのか。答えは、カメラの「上」がどちらを向いているか、でした。
3D のカメラは、「どこにいるか」と「どこを見ているか」だけでは姿勢が決まりません。同じ場所から同じものを見ていても、カメラを傾ければ、画面の中で被写体は回転します。その傾きを決めるのが、カメラにとっての「上」の向きです。
平らな地面の上なら、カメラの上は常に空の方向(世界の上)で問題ありません。このサイトも、平らな島の時代からずっとそうしていました。
でも、地面を球にしたことで、前提が崩れていました。赤道のあたりに立つ建物にとっての「上」は、世界から見るとほぼ真横です。それなのに、カメラの上は世界の上に固定されたまま、構図だけをその建物から見た座標で組んでいました。その結果、2つのことが同時に起きていました。
- 建物が横倒しに映る。 実測すると、建物の上方向が、画面の縦から84度ずれていました
- 看板が鏡文字になる。 視線の向きが、カメラの上下の軸(上と、その真逆の真下)とわずか10〜18度しか離れていなかった。視線が上下の軸とほとんど重なると、カメラの傾きが計算の上で決まらなくなり(縮退)、どちらに転ぶか分からない状態になります。その結果が鏡文字でした
症状の重さが建物ごとに違ったのも、これで説明がつきました。視線が上下の軸からどれくらい離れているかは、建物が立っている緯度で変わります。離れている角度が37度や63度の建物は安全側で、10〜18度の3つだけが縮退の真上にいたのです。
直し方は、建物に寄っているあいだだけ、カメラの「上」をその建物が立っている地面の法線に合わせることでした。建物から離れて惑星全体を眺めるときは、元どおり世界の上に戻します。その間の飛行中は、2つの向きのあいだを滑らかに補間しています。直したあと、看板は全部正面から読めるようになりました。
テストが、バグを「守って」いた
この件で一番考えさせられたのは、直す前も、テストは全部通っていたことです。
カメラの位置を計算する部分には、ちゃんとテストがありました。でもそのテストは、「カメラの角度は、90度から傾きの定数を引いた値になる」という、実装の式をそのまま言い換えた形で書かれていました。実装とテストが同じ式を見ているのだから、両方が一緒に間違っていても、テストは緑のままです。
もっと皮肉なテストもありました。「カメラは被写体を見下ろしている(見上げていない)」というテストです。これは、赤道の上の建物では、地面に沿った方向がちょうど世界の上と一致するせいで通っていました。縮退しているからこそ通るテストが、バグを守っていたのです。
このプロジェクトでは、テストを書いたら、わざとコードに間違いを仕込んで、テストがちゃんと落ちるかを確かめる「変異テスト」をしています。でも、変異テストが見つけられるのは「テストと実装が食い違っている」ことだけです。テストと実装が揃って仕様から外れていることは、原理的に見つけられません。
そこで、姿勢のテストは書き方を変えました。
- 角度は、式を言い換えずに、仕様の値そのもの(19.5度)をそのまま書く
- 「視線とカメラの上が、どの建物でも30度以上離れている」という、壊れたときに何が起きるかを直接テストにする
- 「世界の上を使うと、3つの建物で縮退する」という、バグそのものもテストで固定しておく
縮退していたからこそ通っていたテストは、消しました。なぜ消したのかは、コメントに残してあります。
値は正しいのに、絵だけが壊れる
この件のあと、3D の不具合について1つの見方を持つようになりました。値が全部正しいのに、絵だけが壊れることがある。 位置も、見ている先も、計算どおり。それでも「上」が決まっていなければ、看板は鏡文字になるし、建物は横倒しになる。
そういう壊れ方は、数字をどれだけ眺めても見つかりません。だからこのサイトでは、見た目に関わる変更をしたら、必ず実際に描画して、スクリーンショットで確かめることにしています。そして、見つけた壊れ方は、「壊れたときに何が起きるか」の形でテストに残す。そうしておけば、同じ壊れ方は二度と黙って通り抜けられません。
