0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Three.js】奥行きのある画面で「まっすぐスワイプしたのに曲がる」ときの対処:画面の真上ではなく、映っている線を基準にする

0
Posted at

結論

奥行きのある 3D 画面(透視投影)では、画面の端のほうに映る「まっすぐな線」は斜めに傾いて見えます。スワイプの向きを 画面の真上 を基準に測ると、プレイヤーが画面の線に沿って指を動かしただけで「斜めに投げた」と判定されてしまいます。

対処は次の 2 つです。

  1. 指を置いた点を通る線が、画面上でどちらを向いているか を Raycaster と Vector3.project() で求める
  2. その向きが真上になるように、スワイプの軌跡を始点のまわりで回してから、これまでどおり判定する

ついでに、手ブレを 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 にそろえておく

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?