Three.js を使って滑らかに文字を曲げる

こんにちは、[ファンタアドベントカレンダー2024](https://qiita.com/advent-calendar/2024/funteractive-inc) の12月10日担当・フロントエンドエンジニアの清水です👷

日々いろんな実装をしていて「難しいなあ、面白いなあ」と思うのですが、その中から今回はある商品の側面に文字を配置する実装をした時の話を書こうと思います。

CSS を使ってもある程度はできるのですが、1文字単位で曲げることは難しく、場合によってはなめらかに曲げることができないシチュエーションもあると思います。

そこで今回は、Three.js を使って1文字単位でも曲げることができる方法をご紹介します。

### 技術スタック

- Astro: `4.11.6`
- TypeScript: `5.6.3`
- Threejs: `0.166.1`

### 手順

以下の手順で準備・実装していきます。

1. 商品のモデルを作る
2. モデルをブラウザ上に表示する
3. モデルに沿ったカーブを作る
4. テキストをテクスチャとして用意する
5. カーブにテキストを沿わせる

### 商品のモデルを作る

今回の案件では商品画像しか渡されていなかったため、その曲面がどれほど曲がっているかわかりませんでした。そこで今回は、[Blender](https://www.blender.org/) を使用して商品のモデルを作り、それに文字を沿わせる形で実装を進めます。

今回は単純なモデルだったので、細分化・縮小のみでモデルの作成ができました。

Blender で商品のモデルができたら、File > Export > glTF2.0 を選び、gltf 形式で出力します。

![advent-calendar-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617055/8b52663c-3644-9c5c-ada6-4ee2dd0765b3.png)

### モデルをブラウザ上に表示する

作成したモデルをブラウザ上で確認できるようにします。

まずは [Astro](https://astro.build/) にて作成したモデルを読み込めるように調整します。

モデルファイルである gltf や glb はデフォルトのアセットに含まれていないため、import の末尾に `?url` をつけてインポートする必要があります。

https://ja.vite.dev/guide/assets#explicit-url-imports

```jsx
import MyModel from "assets/my-model.gltf?url"
```

モデルをインポートできたら、[Threejs 公式のドキュメント](https://threejs.org/docs/#manual/ja/introduction/Loading-3D-models)を参考に実装を進めていきます。

実装は単純で、

- `GLTFLoader` を使用してモデルをロード
- ロードしたモデルをシーンに追加

の２ステップでレンダリング準備は終わりです。シーンをレンダリングするとモデルが表示されるはずです。

```jsx
import { Scene } from "three";
import MyModel from "assets/my-model.gltf?url"

const createModel = (onLoad: (data: GLTF) => void) => {
  const loader = new GLTFLoader();

  loader.load(
    MyModel,
    onLoad,
    function (xhr) {
      // ロード状況をログに出す
      console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
    },
    function (error) {
      console.log("An error happened : " + error);
    },
  );
};

// モデルがロードされたらシーンに追加
const scene = new Scene();
createModel((model) => scene.add(model));
```

### モデルに沿ったカーブを作る

モデルを3D空間上に配置できたので、商品に沿った曲線を作っていきます。

曲線を作れるクラスはいくつか用意されていますが、この実装では [`CubicBezierCurve3`](https://threejs.org/docs/#api/en/extras/curves/CubicBezierCurve3) を使用して作ります。この位置調整は地道に調整していきます。。

工夫として、原点を引数から渡せるようにしてあげるとオブジェクトが移動しても簡単に対応できるのでいい感じです。

```jsx
import { CubicBezierCurve3, Vector3 } from "three"

const createObjCurve = (x: number, y: number, z: number) => {
  return new CubicBezierCurve3(
    new Vector3(-60 + x, 0 + y, -30 + z), // モデルに沿って頂点の位置を調整
    new Vector3(-80 + x, 0 + y, 10 + z),
    new Vector3(80 + x, 0 + y, 10 + z),
    new Vector3(60 + x, 0 + y, -30 + z),
  );
};

```

カーブだけではシーンに追加してレンダリングすることができないので実装を調整します。

```jsx
import { CubicBezierCurve3, Vector3, BufferGeometry } from "three"

const createModelCurve = (x: number, y: number, z: number) => {
  return new CubicBezierCurve3(
    new Vector3(-126 + x, 0 + y, -34 + z),
    new Vector3(-82 + x, 0 + y, 12 + z),
    new Vector3(82 + x, 0 + y, 12 + z),
    new Vector3(126 + x, 0 + y, -34 + z),
  );
};

// カーブをメッシュに変換して可視化
const curve = createModelCurve(0, 0, 0);
// getPoints の引数を大きくするとなめらかなカーブになります
const geometry = new BufferGeometry().setFromPoints(curve.getPoints(5));
const material = new LineBasicMaterial({ color: 0x000000 });
const curveObject = new Line(geometry, material);
scene.add(curveObject);
```

また、位置調整をしやすいようにカーブの接線と法線を表示します。

```jsx
const createNormalAndTangent = (curve: CubicBezierCurve3) => {
  // 接線ベクトルを取得
  const tangent = curve.getTangent(0.5);

  // 法線ベクトルを取得
  const normal = new Vector3()
    .crossVectors(tangent, new Vector3(0, 1, 0))

  // ベクトルを可視化
  const position = curve.getPoint(0.5);
  const tangentArrow = new ArrowHelper(tangent, position, 20, 0xff0000);
  const normalArrow = new ArrowHelper(normal, position, 20, 0x0000ff);

  return [tangentArrow, normalArrow];
};

scene.add(...createNormalAndTangent(curve))
```

すると、以下の画像のようにカーブと接戦・法線のベクトルを示した矢印が表示することができます。カーブを滑らかにする場合はジオメトリ作成時に頂点を多く作成することで調整可能です。

![advent-calendar-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617055/4b9e9b38-43b0-2c41-26bf-cf379ce6da46.png)


### テキストをテクスチャとして用意する

今回はフォントの指定がある上、有料フォントのためローカルに落とすことができず `TextGeometry` を利用したテキストのレンダリングが難しそうでした。

そこで canvas にテキストをレンダリングし、そのレンダリング結果をテクスチャとして使用します。まずはテキストがレンダリングされた canvas を用意します。

```jsx
/**テキストサイズに合わせて、テキストを出力したcanvasを生成する */
export const generateTextCanvas = (text: string) => {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");

  if (!context) {
    return null;
  }

　// フォントをカスタマイズ
  context.font = '48px "my-font-family"';
  context.fillStyle = "000000";
  context.fillText(text, 0, 50);

  const width = context.measureText(text).width;
  const height = 48; // フォントサイズから高さを設定

  // Canvasのサイズをテキストに合わせる
  canvas.width = width;
  canvas.height = height;
  context.font = '48px "my-font-family"';
  context.fillStyle = "000000";
  context.fillText(text, 0, height * 0.8); // テキストが見えるように範囲を調整

  return {
    canvas,
    width,
    height,
  };
};
```

canvas が用意できたら、レンダリング結果をテクスチャとして利用しメッシュを作成していきます。

```jsx
const font = generateTextCanvas("Hello, World!")
const texture = new CanvasTexture(font.canvas);
const fontGeometry = new PlaneGeometry(
  font.width * 0.3, // サイズを調整
  font.height * 0.3,
  50, // カーブに沿わせた時に曲がるようにポイントを少し多めに設定する
  50,
);
const fontMaterial = new MeshBasicMaterial({
  map: texture,　// テクスチャを設定
  transparent: true,
});
const fontMesh = new Mesh(fontGeometry, fontMaterial);
```

### カーブにテキストを沿わせる

メッシュができたので、最後にカーブに文字を沿わせる実装を追加します。

今回は Three.js のアドオンにある [Flow](https://threejs.org/examples/#webgl_modifier_curve) を利用して実装します。

これによって、カーブに沿ってメッシュを滑らかに曲げることができます。

以下サンプルのリンクですが、シャチ（?）がうにょんと曲がっていていい感じです🐟

https://zz85.github.io/threejs-path-flow/flow.html

```jsx
const fontMesh = new Mesh(fontGeometry, fontMaterial);

// テキストを位置・回転を調整
fontMesh.position.set(0, 0, 0);
fontMesh.geometry.rotateX(Math.PI);

// テキストのサイズを取得
fontGeometry.computeBoundingBox();
const boundingBox = fontGeometry.boundingBox;

// 中央に寄せる
if (boundingBox) {
  const fontOffset = boundingBox?.max
    .clone()
    .add(boundingBox.min)
    .multiplyScalar(0.5);
  fontGeometry.translate(-fontOffset?.x, -fontOffset.y, -fontOffset.z);
}

// カーブに沿わせた時に若干ズレるので微調整
const textPosition = -0.105;

// カーブに沿わせる
const flow = new Flow(fontMesh);
flow.updateCurve(0, curve);
flow.moveAlongCurve(textPosition);

scene.add(flow.object3D);
```

ここまで来れば、下の画像のようにカーブに沿って指定したフォントの文字が表示されるはずです。

![advent-calendar-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617055/9e5a8b85-a138-e8ec-9f1e-4acb3058f614.png)


### おわりに

あまりこういう実装が必要なシチュエーションがないので、難しいと同時に面白いなと思いながら実装をしました。

今回のように画像を作成する場合、特殊なフォントの指定がされている場面は少なからずあると思うので、１つのアプローチとして良いなと思いました。機会があれば個人開発などでも実装してみようと思います。

明日は弊社のVPoEである[ひささんの記事]()です！続けてご覧ください！