メインコンテンツまでスキップ

目視で見つからない表示の崩れを座標のテストで防ぐ

タグ:

画面に並べた要素が少し重なっていたり、枠から数 px はみ出していたりする崩れは、画面を眺めるだけでは見つけにくいです。崩れていること自体は分かっても、どの数値がどれだけずれているかは、絵からは読み取れないです。

ブラウザで動かしている放置ゲームで、この種類の崩れを 2 つ出しました。どちらもスクリーンショットでの確認をすり抜け、遊んだ人からの指摘で分かったものです。対処は 2 つとも同じ形で、重ならない、はみ出さないという見た目の条件を、座標の判定として単体テストに書くことでした。

ゲームに限った話ではないです。図表の要素を並べる時、カレンダーに予定の帯を置く時、グラフの軸ラベルを配置する時にも、同じ形の条件が出てきます。

症状: 静止画では気づけなかった 2 つの崩れ​

題材は、庭が舞台のゲームです。犬が奥から手前へ走ってきて、道沿いには灯り(ガーデンライト)が最大 12 本並びます。奥行きを出すため、奥にあるものほど小さく描いています。

街灯の形が欠けた​

灯りを増やしていくと、灯りの箱が四角く描かれず、一部が欠けて見える、という指摘がありました。実際には欠けていたのではなく、奥の灯りが、手前にある別の灯りの支柱や箱に隠れていました。

灯りは 1 本が支柱と箱の 2 つの四角でできていて、どちらも数 px しかないです。これが別の灯りに 1 px 重なるだけで、形が崩れて見えます。

奥 3 段の灯りを 1 px 単位で拡大し、箱を 3 × 2 のままにすると隣の段の支柱に重なり、1 × 1 に縮めると重ならないことを並べた図

縮小された画面では、この欠けは灯りの模様としか見えないです。全体の雰囲気を見ているかぎり、崩れていると気づけないです。

犬が道からはみ出した​

もう 1 つは、犬が左右に大きく動いて街灯に当たっている、という指摘でした。犬は奥から手前へ、左右にふらつきながら走ってきます。この動きは、灯りを足す前の何も置いていない庭では問題にならなかったです。道の脇に灯りが立ってはじめて、犬が道の外まで出ていたことが見えるようになりました。

こちらもスクリーンショットでは分からなかったです。犬の左右の位置は時間とともに揺れるので、はみ出しているのは周期の一部だけです。

原因: 見た目の条件が、どこにも書かれていなかった​

2 件は別々の場所で起きていますが、構図は同じです。守りたかった条件は決まっていたのに、その条件はコードのどこにも書かれておらず、条件を満たすはずの数値だけが置かれていました。

守りたかった条件コードにあったもの
灯りどの灯りも、他の灯りと重ならない灯りの大きさと、灯りを置く間隔。それぞれ別の式
犬犬は道の幅から出ない左右の振れ幅。固定値 10 px で、道の幅とは連動しない

灯りのほうは、大きさを決める式と、位置を決める式が別の関数に分かれていました。どちらの式も、単独で見れば妥当です。重ならないかどうかは、2 つを組み合わせた結果なので、どちらの式を読んでも出てこないです。

犬のほうは、道の幅が奥行きによって変わるのに、振れ幅だけが変わらなかったことが原因でした。

画面は横 96 px、縦 54 px の小さな座標で作っています。y は値が大きいほど手前で、y = 18 が地平線です。道は地平線から手前に向かって広がる台形で、その半分の幅を次の式で決めています。

/** 道の半幅(y が手前ほど広い) */
export function pathHalfWidth(y: number): number {
return 2 + Math.max(0, y - HORIZON) * 0.55;
}

犬の足元は、一番奥で y = 23、手前で y = 44 です。この式に入れると、道の半幅は奥で 4.75 px、手前で 16.3 px になります。一方の犬は、幅 20 px の絵を奥行きに応じて 0.3 倍から 1.0 倍に縮めて描くので、半分の幅は奥で 3 px、手前で 10 px です。

振れ幅が 10 px のままだと、一番奥では犬の右端が中央から 13 px の位置まで動きます。道の縁は中央から 4.75 px です。灯りは道の縁より 3 px 外に立つので、犬は道を越えて灯りの上まで届いていました。

手前で問題が出ていなかったのは、手前に着く直前だけ振れ幅を 0 に向かって絞る処理が入っていたためです。到着した時に犬が中央で止まって見えるようにするためのもので、道の幅とは関係がないです。結果として、絞りが効かない奥から中間にかけてだけ、はみ出しが残っていました。

奥ほど道が狭くなる台形の上で、振れ幅を固定値にすると奥と中間で犬が道の外へはみ出し、奥行きから計算するとどの奥行きでも道の中に収まることを並べた図

図の左側が、はみ出していた時の状態です。手前では収まっているのに、奥ほど道が狭くなるぶんだけ外に出ます。手前の画面を見て問題なしと判断すると、奥の崩れがそのまま残ります。

2 件に共通していたのは、次の 3 点です。

  • 条件は頭の中にあり、コードには条件を満たすつもりで置いた数値だけがあった
  • その数値は複数の場所に分かれていて、片方を直した時にもう片方が追従しなかった
  • 条件が満たされているかは、絵を見て判断するしかなかった

対処: 条件を座標の判定として書く​

どちらも、条件をそのまま座標の判定として書きました。重なりは四角どうしの重なり判定、はみ出しは端の位置の比較です。

重ならないことを確かめる​

灯り 1 本は、支柱と箱の 2 つの四角です。12 本ぶんの四角を並べて、全部の組み合わせで重なりを調べます。

it('どの灯りも、支柱と灯りが他の灯りと重ならない(隠れて欠けて見えない)', () => {
type R = [number, number, number, number];
const rects: R[][] = Array.from({ length: 12 }, (_, i) => {
const p = lightPos(i);
const { lw, lh, lx } = lampSize(p);
return [
[p.x, p.y - p.h, p.w, p.h],
[lx, p.y - p.h - lh, lw, lh],
];
});
const overlap = (a: R, b: R): boolean => a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3];
for (let i = 0; i < rects.length; i++)
for (let j = i + 1; j < rects.length; j++)
for (const a of rects[i]) for (const b of rects[j]) expect(overlap(a, b), `灯り ${i} と ${j}`).toBe(false);
});

判定しているのは、2 つの四角が x 方向でも y 方向でも範囲を共有しているかどうかだけです。12 本から 2 本を選ぶすべての組について、支柱と箱の 4 通りの組み合わせを見ます。失敗した時にどの 2 本かが分かるよう、番号をメッセージに入れています。

このテストは、数値を決める作業そのものにも使いました。灯りの大きさと間隔は、遠近を出すために奥ほど小さく・狭くしています。間隔は手前から奥へ 0.7 倍ずつ詰まるので、奥の段どうしは y が 2 px しか離れないです。その狭い隙間に入る大きさを、テストが通るまで試して決めました。

段足元の y支柱灯りの箱
手前462 × 64 × 3
2 段目382 × 54 × 3
3 段目321 × 43 × 2
4 段目以降28 / 25 / 231 × 31 × 1

手で数値を探す時に、絵を見て判断すると、1 回ごとに画面を確認することになります。合否をテストに任せると、試した値がそのまま通るか落ちるかで返ってきます。決まった数値は、そのときの条件を満たす組み合わせとして固定されます。

範囲から出ないことを確かめる​

振れ幅は、固定値をやめて、その奥行きで動ける幅から出すようにしました。道の半幅から、その奥行きでの犬の半分の幅を引いた値です。

/** 道の外側(灯りなど)に当たらないよう、犬の端は道の縁から出さない(この値までは縁の外にはみ出してよい) */
const EDGE_MARGIN = 1.5;

/** その奥行きで、犬の中心が中央から左右へ動ける最大幅(犬の幅は奥ほど小さい)。 */
export function maxSway(z: number): number {
const halfWidth = (DOG_W / 2) * depthScale(z);
return Math.max(0, pathHalfWidth(feetY(z)) + EDGE_MARGIN - halfWidth);
}

縁から 1.5 px までは外に出てよいことにしています。犬の足が道の縁を少し踏んだほうが走っている感じが出るからで、灯りは縁より 3 px 外なので、この範囲なら灯りには届かないです。一番奥での振れ幅は 3.25 px になり、固定値だった 10 px から大きく縮みました。

テストは、位相と奥行きを総当たりして、犬の端が許される範囲に収まっているかを見ます。

it('走ってくる間、犬は道の幅の中に収まる(道の外の灯りに当たらない)', () => {
for (let sway = 0; sway < 6.3; sway += 0.1) {
for (let z = 0; z <= 1; z += 0.02) {
const half = 10 * depthScale(z);
const edge = pathHalfWidth(feetY(z)) + 1.5; // 灯りは道の縁より 3px 外
expect(Math.abs(dogX(z, sway) - 48) + half).toBeLessThanOrEqual(Math.max(edge, half) + 1e-9);
}
}
expect(maxSway(0.5)).toBeGreaterThan(0);
});

位相を 0.1 刻みで 1 周ぶん、奥行きを 0.02 刻みで奥から手前まで動かして、すべての組み合わせを調べています。左辺は犬の中心が中央から離れた距離に、その奥行きでの犬の半分の幅を足したもの、つまり犬の外側の端です。

総当たりにしたのは、どの位相で崩れるかが事前に分からないためです。はみ出しが起きるのは周期の一部だけで、代表的な値をいくつか選んで確かめる形だと、選んだ点がたまたま収まっている可能性が残ります。

奥行きと位相の 2 つを動かすので、確かめる組み合わせは数千通りになりますが、計算は座標の式を 1 回呼ぶだけなので一瞬で終わります。

最後の 1 行も必要です。振れ幅を 0 にすれば犬は中央から動かず、はみ出しの判定は常に通ります。中間の奥行きで振れ幅が 0 より大きいことを確かめて、条件を満たすだけの動かない実装になっていないかを見ています。

テストにできる条件と、できない条件​

同じ時期に、灯りまわりでもう 1 つ指摘を受けました。灯りのまわりにほのかな光を足したところ、奥の支柱が手前の光でぼやけて見える、という症状です。

これは座標の重なりとしては拾えないです。光は半透明の四角なので、他のものと重なること自体は正しい状態です。問題は重なりの有無ではなく、どちらが先に描かれたかでした。灯りごとに光・支柱・箱の順で描いていたため、奥から手前へ進むうちに、手前の灯りの光が、すでに描き終えた奥の支柱の上に乗っていました。

対処は、判定を足すことではなく、描く順番を層に分けることでした。光を全部描いてから、その上に支柱と箱を描きます。

// 光を先に全部描き、その上に、奥の灯りから順に支柱と灯りを描く
const look = lightLook(levels.lights);
if (look.glow) for (let i = 0; i < look.count; i++) drawGlow(ctx, lightPos(i));
for (let i = look.count - 1; i >= 0; i--) drawLight(ctx, lightPos(i), look);

2 つの種類を並べると、違いがはっきりします。

条件の種類例テストにできるか手当て
位置の条件重ならない、範囲から出ないできる。座標の比較で判定できる総当たりの単体テスト
順序や見え方の条件後ろのものが手前の効果で濁らないできない。位置の大小では決まらない描く順番を層として決めておく

座標の条件かどうかが、分かれ目です。答えが位置の大小で決まるなら、判定として書けます。半透明を重ねた結果の色のように、描いた後のピクセルを見ないと分からないものは、別の手当てが要ります。

再発を防ぐ​

今回の 2 件から、進め方を次のように変えました。

まず、条件を言葉にできた時点で、その場で判定に落とします。「重ならないように大きさを決めた」と書けるなら、それは重なり判定として書ける条件です。言葉にできたのにコードに現れていない条件は、後から読む人にも、数値を変える人にも見えないです。

次に、要素を新しく足す時は、既存の動きの前提を見直します。犬の振れ幅は、灯りを置くまで誰も困らなかったです。はみ出していても、はみ出した先に何も無かったためです。後から置いたものが、前に決めた動きの前提を壊す形になっていないかを、追加のたびに見ます。

そして、範囲や位置を決める値は、固定値ではなく、置かれた場所の寸法から導きます。振れ幅を道の幅と犬の大きさから計算するようにしたことで、道の形を変えても、犬の大きさを変えても、振れ幅が自動で追従します。

この 3 点は、画面に要素を並べるものなら同じように当てはまります。

場面座標の判定として書ける条件
図やグラフの軸ラベル隣り合うラベルの文字領域が重ならない
カレンダーの予定の帯同じ時間帯の帯が、列の幅の中に収まる
画面に浮かせる通知やツールチップ画面の外に出ない。基準にした要素を覆わない

どれも、要素の矩形と、置いてよい範囲さえ計算できれば判定できます。見た目を確かめるために画面を描く必要はないです。

まとめ​

見た目の崩れのうち、重なりとはみ出しは、絵を見なくても座標の比較で確かめられます。2 件の崩れを出したあとに整理した要点は、次の 3 つです。

  • 重ならない、範囲から出ないといった条件は、頭の中に置かず、座標の比較として書き出しておく
  • 崩れる場所が事前に分からない条件は、位置や時間の刻みを総当たりして調べる
  • 位置では決まらない条件は、重なりの判定ではなく、描く順番の決め方として形に残す