[JavaScript] Three.jsでキャラクターの頭上にセリフを表示する(Canvas+Sprite編)

はじめに

VRでの衝突判定を作成後、以前からやりたかったNPCとぶつかった際に、ポップアップメッセージを表示させる実装をしてみました。

市販ゲームは、画面の下にメッセージ用のダイアログを表示させる形式が多いですが、 その場合、HTML・CSSで比較的簡単にできるので、今回はキャラクターの頭上付近に吹き出しメッセージを実装を試してます。

今回もVRモードで動画を撮ってみました。

1. なぜ吹き出しにしたか

Three.js でキャラクターを配置すると、次にやりたくなるのは

「このキャラが何か反応している感じを出したい」

よくある方法としては、

  • 画面下にメッセージウィンドウを出す
  • UIとしてテキストを固定表示する

といったやり方があります。

ただ今回は、あえてそれを選びませんでした。

理由はシンプルで、

  • キャラクター自身の存在感を強めたかった
  • 「空間にいるキャラ」が喋っているように見せたかった
  • VRでは画面固定UIが視界の邪魔になりやすい

という点があったからです。

特に VR では、 画面下や画面中央にUIを出すと没入感を壊しやすくなります。

一方で、 キャラクターの頭上に短いメッセージが出るだけなら、

  • 視線移動が自然
  • 今何が起きているか直感的
  • キャラとの距離感が分かりやすい

というメリットがあります。

「吹き出し」というよりは、 キャラの上にセリフが浮かぶ、そのくらいの軽い表現を目指しました。


2. DOMではなくSpriteを使った理由

最初に考えたのは、 HTML(DOM)で吹き出しを作り、CSSで位置を制御する方法です。

ただ、この方法には問題がありました。

  • 3D座標と2D座標の変換が必要
  • カメラ移動やVR時のズレ対策が面倒
  • VRとPCで分岐が増える

そこで選んだのが THREE.Sprite です。

Sprite は、

  • 常にカメラ正面を向く
  • 3D空間の座標にそのまま置ける
  • VR / non-VR 共通で使える

という特徴があります。

「キャラの頭上にテキストを浮かせたい」という用途には、 Sprite がかなり相性が良いです。


3. CanvasTextureでセリフを描画する

Sprite に直接テキストを書くことはできないので、 Canvas に文字を描いて、それをテクスチャとして使う構成にしました。

基本的な流れは以下です。

  1. Canvas を生成
  2. 背景を描画
  3. テキストを描画
  4. CanvasTexture に変換
  5. Sprite に貼る

実際のコードはこのようになります。

createTextSprite(text) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  canvas.width = 700;
  canvas.height = 140;

  // 背景
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 角丸背景
  this.roundRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 20);
  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fill();

  // テキスト設定
  ctx.fillStyle = '#fff';
  ctx.font = '27px sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 改行対応
  const lines = text.split('\n');
  const lineHeight = 32;
  const totalHeight = lines.length * lineHeight;
  const startY = canvas.height / 2 - totalHeight / 2 + lineHeight / 2;

  lines.forEach((line, i) => {
    ctx.fillText(line, canvas.width / 2, startY + i * lineHeight);
  });

  const texture = new THREE.CanvasTexture(canvas);
  const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
  const sprite = new THREE.Sprite(material);

  sprite.scale.set(1.5, 0.4, 1);

  return sprite;
}

4. 角丸背景を描くための関数

Canvas には標準で角丸矩形を描くAPIがないため、 簡単なヘルパー関数を用意しています。

roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

全面を fillRect で塗ってしまうと角丸が分からなくなるため、 背景はこの roundRect のみで描画しています。


5. キャラクターの頭上に表示する

作成した Sprite は、 キャラクターのモデルにそのまま追加します。

say(text) {
  if (this.talkSprite) {
    this.model.remove(this.talkSprite);
  }

  const sprite = this.createTextSprite(text);
  sprite.position.set(0, 2.2, 0); // 頭の上

  this.model.add(sprite);
  this.talkSprite = sprite;

  setTimeout(() => {
    this.model.remove(sprite);
    this.talkSprite = null;
  }, 3000);
}

Sprite は常にカメラを向くため、 回転や向きの調整は不要です。


6. セリフはデータとして持たせる

セリフをコードに直書きすると管理が面倒になるため、 キャラクターデータ側に持たせています。

"dialogue": {
  "random": [
    "うぇるかむかもーん!",
    "チノちゃん、見なかった?",
    "Rabbit Houseに遊びに来てね!",
    "みんな!パン作りをなめちゃいけないよ。\nすこしのミスが完成度を左右する戦いなんだからね!"
  ]
}

改行は \n のまま使い、 Canvas 側で分割して描画しています。


7. セリフ表示に効果音を足す

仕上げとして、 セリフ表示と同時に軽い効果音を鳴らしています。

const se = new Audio('./assets/sound/ペタッ.mp3');
se.volume = 0.4;
se.play();

BGMのような重い実装は行わず、 反応音として最低限の演出に留めています。

8. NPCをプレイヤーの方へ向かせる

動画を見ると分かりますが、普通に接触してメッセージを出すだけだとNPCが違う方向を向いて違和感があるので、プレイヤーの方へ方向転換する処理も追加しています。

基本コード

rotateTowardsPlayer(deltaTime) {
  const basePos = this.isVR
    ? config.playerRig.position
    : config.player.box.position;

  const targetPos = basePos.clone();
  targetPos.y = this.model.position.y;

  const m = new THREE.Matrix4();
  m.lookAt(
    targetPos,               // ※モデル都合で逆を使用
    this.model.position,
    this.model.up
  );

  const targetQuat = new THREE.Quaternion()
    .setFromRotationMatrix(m);

  const rotateSpeed = 2.0; // 回転速度
  this.model.quaternion.slerp(
    targetQuat,
    Math.min(1, rotateSpeed * deltaTime)
  );
}

※実装上の事情として モデルの forward 軸の都合で lookAt を逆にしています

呼び出しタイミング

「常に向く」はやらない。

if (playerBox.intersectsBox(this.box)) {
  this.rotateTowardsPlayer(deltaTime);
}
  • 近づいた時だけ向く
  • 離れたらそのまま

なぜ「完全に向かせない」のが良いか

  • 急にクルッと回らない
  • 視線が合った感じになる
  • 威圧感が出ない

特に VR では 真正面を向かせすぎると怖いため。

NPCがこちらを向くだけで、 セリフが「空間の装飾」ではなく 「プレイヤーへの反応」に変わります。