結論
奥行きのある 3D 画面(透視投影)では、画面の端のほうに映る「まっすぐな線」は斜めに傾いて見えます。スワイプの向きを 画面の真上 を基準に測ると、プレイヤーが画面の線に沿って指を動かしただけで「斜めに投げた」と判定されてしまいます。
対処は次の 2 つです。
- 指を置いた点を通る線が、画面上でどちらを向いているか を
RaycasterとVector3.project()で求める - その向きが真上になるように、スワイプの軌跡を始点のまわりで回してから、これまでどおり判定する
ついでに、手ブレを 0 にする処理で -0 が生まれて、Vitest の toEqual が落ちる罠も踏んだので、最後に書いておきます。
環境
- three 0.186.1
- TypeScript 7.0.2
- Vite 8.3.3
- Vitest 5.0.3
- 確認: Windows 11 のブラウザ(スマホサイズに縮めてタッチ操作を再現)、2026 年 10 月時点
症状
スマホ向けのボウリングゲームを Three.js で作っていました。投げる操作は「画面を下から上へスワイプ」で、
- スワイプの描き始めの傾き → 投げ出す向き
- 途中からの曲がり具合 → カーブ
として読んでいます。カメラはプレイヤーの後ろ上方にあり、レーンは奥へ行くほど細く映ります。
ここでテストプレイしてもらったところ、こんな声が来ました。
真上にスライドするとまっすぐ行くのに、表示されているレーンに沿ってスワイプするとめちゃくちゃ曲がる
言われてみれば当然で、人は 画面に映っているレーンの線 に沿って指を動かします。画面の縦方向なんて気にしていません。
原因
透視投影では、平行な線は奥の 1 点(消失点)へ集まるように映ります。レーンの中央に立っていれば線はほぼ真上に向きますが、端に立つと線は斜めに傾きます。今回の画面では、右寄りに立ったときのレーンの線が 画面上で左へ約 8° 傾いていました。
右寄り(右から 5 枚目)に立ったときの画面です。青い点線を見ると、まっすぐなはずのレーン方向が、画面上では奥へ行くほど左に寄って映っているのがわかります。
一方、判定は「画面の真上からの角度」で測っていました。
// 描き始めの向き (hx, hy) が、画面の真上 (0, -1) からどれだけ傾いているか(右が正)
const tilt = Math.atan2(hx, -hy);
そのため、プレイヤーが斜めに映った線に忠実に沿うほど、tilt は 8° ぶんずれて「左へ投げた」と読まれていました。プレイヤーは悪くない、判定の基準のほうが画面の見た目とずれていた、というわけです。
解決方法
1. 画面座標 ⇔ 床の座標を行き来する関数を用意する
画面の点から床(レーン面)の点へは Raycaster で、床の点から画面の点へは Vector3.project() で変換します。
import * as THREE from 'three';
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const floor = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // y = 0 の床
/** 画面座標(clientX/Y)→ 床の上の点。床に当たらなければ null */
function screenToFloor(
clientX: number, clientY: number,
camera: THREE.Camera, canvas: HTMLCanvasElement,
): THREE.Vector3 | null {
const rect = canvas.getBoundingClientRect();
ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, camera);
return raycaster.ray.intersectPlane(floor, new THREE.Vector3());
}
/** 床の上の点 → 画面座標(clientX/Y)。カメラの後ろなら null */
function floorToScreen(
p: THREE.Vector3,
camera: THREE.Camera, canvas: HTMLCanvasElement,
): { x: number; y: number } | null {
const rect = canvas.getBoundingClientRect();
const v = p.clone().project(camera);
if (v.z > 1) return null;
return {
x: rect.left + ((v.x + 1) / 2) * rect.width,
y: rect.top + ((1 - v.y) / 2) * rect.height,
};
}
setFromCamera に渡すのは -1〜1 の正規化デバイス座標(NDC)です。y は画面と上下が逆になるので、符号を反転しています。
2. 指を置いた点を通る線の「画面上の向き」を求める
指を置いた点を床に落とし、そこから ピンの方向へ少し進んだ点 をもう一度画面に戻します。2 点を結んだ向きが、その場所でのレーンの線の向きです。
/** 指を置いた点を通るレーンの線が、画面上でピン方向へ向かう向き(単位ベクトル、y は下が正) */
function laneForwardOnScreen(
clientX: number, clientY: number,
camera: THREE.Camera, canvas: HTMLCanvasElement,
pinDir = new THREE.Vector3(0, 0, -1), // ピンのある向き(この例では -Z)
): { x: number; y: number } | null {
const hit = screenToFloor(clientX, clientY, camera, canvas);
if (!hit) return null;
const ahead = floorToScreen(hit.clone().add(pinDir), camera, canvas); // 1 m 先
if (!ahead) return null;
const dx = ahead.x - clientX;
const dy = ahead.y - clientY;
const len = Math.hypot(dx, dy);
return len > 1e-6 ? { x: dx / len, y: dy / len } : null;
}
直線は透視投影しても直線のままなので、少し先の 1 点を取るだけで、その線全体の画面上の向きがわかります。
この向きは pointerdown のときに 1 回だけ 求めて保存しておきます。指が動くたびに測り直すと、基準そのものが動いてしまうためです。
3. その向きが真上になるよう、軌跡を回してから判定する
既存の判定関数(画面の真上を基準にしたもの)には手を入れず、入力の軌跡のほうを回します。
type Point = { x: number; y: number };
/** これ以上傾いた基準は信用しない(rad、約 40°) */
const MAX_FORWARD_TILT = 0.7;
/** forward(画面上の単位ベクトル)が真上 (0, -1) になるよう、始点のまわりで軌跡を回す */
function alignToForward(points: readonly Point[], forward: Point | null): Point[] {
if (!forward || points.length === 0) return points.slice();
const { x: fx, y: fy } = forward;
// 下向き、または傾きすぎの基準は使わず、画面の真上を基準にする
if (fy >= 0 || Math.abs(Math.atan2(fx, -fy)) > MAX_FORWARD_TILT) return points.slice();
const s = points[0];
return points.map((p) => {
const dx = p.x - s.x;
const dy = p.y - s.y;
return {
x: s.x + dx * -fy + dy * fx, // forward の右向き (-fy, fx) の成分
y: s.y - (dx * fx + dy * fy), // forward 方向の成分(上が負のまま)
};
});
}
// pointerup で
const shape = analyzeSwipe(alignToForward(trail, forwardAtStart), MAX_ANGLE);
ポイントは、forward の右向きの軸 と forward の軸 に軌跡を射影し直していることです。画面の y は下が正なので、forward (fx, fy) の右向きは (-fy, fx) になります。forward = (0, -1)(真上)を入れると、x も y も元のままになるので、中央に立ったときの操作感は変わりません。
「レーンの線より右へ傾ければ右へ投げる」「途中で曲げればカーブ」という判定は、回したあとの軌跡に対してそのまま効きます。判定のロジックを書き直さずに済んだのが、地味にうれしいところでした。
フォールバックを入れておく
投げる前にレーン全体を真上から見下ろすカメラなど、線の向きが下を向いたり、大きく傾いたりする場面があります。そういうときに無理に基準を使うと、かえっておかしな判定になります。そこで、
- 床に当たらない(
null) - 線が画面の下向き(
fy >= 0) - 真上から 40° より傾いている
のどれかなら、これまでどおり画面の真上を基準にしています。
動作確認
テストは「斜めに映る線に沿ったスワイプは、向きもカーブも 0 になる」ことを確かめる形にしました。
// 真上から deg 度傾いた、画面上の単位ベクトル
const dir = (deg: number) => ({
x: Math.sin((deg * Math.PI) / 180),
y: -Math.cos((deg * Math.PI) / 180),
});
it('斜めに映るレーンの線に沿ったスワイプは、まっすぐ', () => {
const pts = swipe(300, -20, 0); // 長さ 300px、左へ 20° 傾いた、曲がりなしのスワイプ
expect(analyzeSwipe(pts, MAX).angle).toBeLessThan(-0.02); // 回す前は「左へ投げた」
expect(analyzeSwipe(alignToForward(pts, dir(-20)), MAX))
.toEqual({ angle: 0, spin: 0 }); // 回した後はまっすぐ
});
swipe() は、テスト用にスワイプの軌跡(点の配列)を作る小さな関数です。MAX は投げ出す角度の上限(rad)です。
実際の画面でも、スマホサイズにしたブラウザで右寄りに立ち、画面上で左へ約 8° 傾いたレーンの線に沿ってスワイプする操作を再現しました。向きもカーブも 0 と読まれ、まっすぐ転がりました ✅
おまけ:手ブレの処理で -0 が生まれて toEqual が落ちる
上のテストは、最初こう言われて落ちました。「え、どっちも 0 じゃん…?」と固まりました 😇
AssertionError: expected { angle: +0, spin: -0 } to deeply equal { angle: +0, spin: +0 }
犯人は、小さな傾きを手ブレとして 0 にするこの書き方でした。
const live = Math.sign(r) * Math.max(0, Math.abs(r) - DEADZONE);
r が小さな 負の値 だと、Math.sign(r) は -1、Math.max(...) は 0 になり、-1 * 0 で -0 ができます。-0 === 0 は true ですが、Vitest の toBe と toEqual は数値を Object.is で比べるので、-0 と 0 を別物として扱います。
expect(-0).toBe(0); // 失敗
expect({ a: -0 }).toEqual({ a: 0 }); // 失敗
expect(-0).toBeCloseTo(0); // 成功
返す直前で 0 にそろえると通ります。
return { angle: angle || 0, spin: spin || 0 }; // -0 を 0 に
|| 0 は NaN も 0 にしてしまうので、NaN を見逃したくない場合は angle + 0(-0 + 0 は 0)のほうが安全です。
まとめ
- 奥行きのある画面では、プレイヤーは 画面に映っている線 に沿ってスワイプする。画面の真上を基準にすると、端に立ったときにずれる
- 指を置いた点を床に落とし、少し先の点を
project()で画面に戻せば、その場所での線の向きがわかる - 判定関数は変えずに、軌跡を「その向きが真上になるよう」回してから渡すと、変更が小さく済む
- 下向き・傾きすぎの基準は使わず、画面の真上にフォールバックする
-
Math.sign(x) * 0は-0になりうる。toEqualで比べるなら 0 にそろえておく
参考
- Raycaster – three.js docs
-
Vector3 – three.js docs(
.project( camera )の項) - Object.is() – MDN
