three.jsで2D画像ジェネレーターを作った話

この記事はTech KAYAC Advent Calendar 2021の18日目です

皆様こんにちは。
入社2年目、CL事業部フロントエンドエンジニアの古園です。

最近はポケットモンスター ブリリアントダイヤモンドをプレイし、
チャンピオンのシロナが持つガブリアスに6縦されました。
地面タイプへの対策を何もしていなかった僕の手落ちです。ギャラドス育てるか。

なぜthree.jsでわざわざ2Dを?

元々WebGLを扱えるエンジニアになりたいという思いがあり、仕事でもpixi.jsを扱う案件に入っていました。
そんな時に個人開発をスタートさせたので、仕事で扱っているpixi.jsを扱うよりもthree.jsを覚えて何か作る方が勉強になるしモチベーションも上がるのでは?と思い立ち、three.jsを使って何かを作ることにしました。

そんな経緯で初めたところまではよかったのですが、

  • いきなり3D作品を作ろうとしても難しそう
  • それよりは自分が仕事も含めて作ったことがあるものをthree.jsで作ってみた方が完成まで至りやすそう

といった懸念が上がります。
マーク・ザッカーバーグも言っていますね。
多分動くと思うからリリースしようぜ完璧を目指すよりまず終わらせろと。
完成の目処が立たないものに挑戦するのはthree.jsを覚えるという趣旨からも外れます。
こういったことを考慮すると、

  • canvasやレンダリングなど一部同じ技術を使う画像ジェネレーターが良さそう
  • そうなるとthree.jsでわざわざ2Dを扱うことになる
  • あれ?楽しそう

といった結論になり、画像ジェネレーターを作ることにしました。

どんな2D画像ジェネレーターを作るか

ちょうど開発を始めた頃はウマ娘プリティーダービーのリリース初期であり、育成システムがパワプロみたいで楽しいと言われていた時期です。
その育成システムの一部が注目され、twitterではこんなものが流行っていました。

絶対にロクなことにはならなさそう

元々、ゲームのプレーヤーはウマ娘のトレーナーとして日々の何気ないどんなことからでもトレーニングのアイデアを得る天才でネタになっていました。この流れからゲーム内だけでなくプレーヤー自身の日常や大喜利のオチとして扱うファンが現れ、徐々に流行っていきました。

・・・これを実際のゲーム画面のように「〜とのトレーニングに活かせるかもしれない!」と書かれた画像を作れれば面白いのでは?

はい、実際に作ってみましょう

環境

環境はNext.js + CSS modules(scss) + three.js (+ 別の場所でcropper.js)
追加で以下のようにしました。

  1. コンポーネントのロジックとUIファイルを分かりやすくするため、Container / Presentation構成を採用

  2. 追加で、ContainerからPresentationへのprops祭りを防ぐためページアクセス時に発火する関数以外はカスタムフックに記述し、Presentationから呼び出すようにする

  3. 共通で使用するcssの変数や関数はanimations.scss, functions.scss, mixins.scss, variables.scssに記述し、それらをimportしたcommons.scssをnext.config.jsで

const withTM = require('next-transpile-modules')(['three'])
const withPlugins = require('next-compose-plugins');

module.exports = withPlugins([withTM], {
  sassOptions: {
    prependData: '@import "~/styles/commons.scss";',
  },
})

のように読み込むことで各ファイルごとにimportをしなくて済むようにする

1, 2はコードの可読性担保のため、3は効率化のために採用しました。

実装

① three.jsで2D表現を実装する方法

OrthographicCameraでできます。
カメラの描画範囲を画像いっぱいに設定し、カメラ位置をZ軸上の画像の半分の高さの場所に設置することで上手く全画面表示になります。

const camera = new OrthographicCamera(
  -IMAGE.width / 2,
  IMAGE.width / 2,
  IMAGE.height / 2,
  -IMAGE.height / 2,
)
camera.position.set(0, 0, CANVAS.height / 2)

またPerspectiveCameraでも可能で、画角を90度にした上で画像のアスペクト比を設定すると同じように表示できます。カメラ位置はOrthographicCameraと同様です。

const camera = new PerspectiveCamera(90, IMAGE.width / IMAGE.height)
camera.position.set(0, 0, CANVAS.height / 2)

② 画像の生成

むしろ(自分で勝手に縛りを設けたせいで)こちらが大変でした。

ウマ娘は添付画像のように半透明で緑縁の白いオブジェクトの上にテキストがある形になっています。

スマートファルコンのトレーニング
育成中のイベントシーン

これを実装するなら「予めテキスト欄の画像を用意して読み込む」一択なのですが、今回のthree.jsの勉強という趣旨からは外れているような気がする(個人差があります)。
実装するならちゃんと全部three.jsで用意しなきゃな!といった気の迷いもあり画像を使うのを禁止しました。

その結果がこちら。

// トレーニング画像
// readImageはthree.jsのTextureLoaderで作成した画像を読み込む自作関数
const texture_training = await readImage(image)
const geometry_training = new PlaneGeometry(IMAGE.width, IMAGE.height)
const material_training = new MeshStandardMaterial({
  map: texture_training,
})
const mesh_training = new Mesh(geometry_training, material_training)
scene.add(mesh_training)

// テキストの白い欄
const textboxShape = new Shape()
textboxShape.arc(
  TEXTBOX.position.left.x,
  TEXTBOX.position.left.y,
  TEXTBOX.radius,
  (Math.PI / 6) * 3,
  -(Math.PI / 6) * 3,
  false,
)
textboxShape.arc(
  TEXTBOX.position.right.x,
  TEXTBOX.radius,
  TEXTBOX.radius,
  -(Math.PI / 6) * 3,
  (Math.PI / 6) * 3,
  false,
)

const extrudeSettings = {
  depth: 1,
  bevelSegments: 2,
  steps: 1,
  bevelSize: 1,
  bevelThickness: 1,
}

const geometry_textbox = new ExtrudeGeometry(textboxShape, extrudeSettings)
const material_textbox = new MeshStandardMaterial({
  color: 0xffffff,
  opacity: 0.88,
  transparent: true,
})
const mesh_textbox = new Mesh(geometry_textbox, material_textbox)
scene.add(mesh_textbox)

// テキスト欄の緑線
const points_textboxframe = []
const startPosition = {
  x: 0,
  y: 0,
}
// 左
for (let angle = 270; angle >= 90; angle -= 0.0005) {
  const circleX =
    TEXTBOX.position.left.x +
    3 +
    (TEXTBOX.radius + 3) * Math.cos(angle * (Math.PI / 180))
  const circleY =
    TEXTBOX.position.left.y +
    (TEXTBOX.radius + 3) * Math.sin(angle * (Math.PI / 180))
  if (angle === 270) {
    startPosition.x = circleX
    startPosition.y = circleY
  }
  points_textboxframe.push(circleX, circleY, 3)
}
// 右
for (let angle = 90; angle >= -90; angle -= 0.0005) {
  const circleX =
    TEXTBOX.position.right.x +
    3 -
    (TEXTBOX.radius + 3) * 2 +
    (TEXTBOX.radius + 3) * Math.cos(angle * (Math.PI / 180))
  const circleY =
    TEXTBOX.position.right.y +
    (TEXTBOX.radius + 3) * Math.sin(angle * (Math.PI / 180))
  points_textboxframe.push(circleX, circleY, 3)
}
// 1周させる
points_textboxframe.push(startPosition.x, startPosition.y, 3)

const geometry_textboxframe = new LineGeometry()
geometry_textboxframe.setPositions(points_textboxframe)
const material_textboxframe = new LineMaterial({
  color: 0x91d57c,
  linewidth: 0.006,
  alphaTest: 1.0,
})
const mesh_textboxframe = new Line2(
  geometry_textboxframe,
  material_textboxframe,
)
scene.add(mesh_textboxframe)

// テキスト
const canvas_text = document.createElement('canvas')
canvas_text.width = 525
canvas_text.height = 200
const ctx_text = canvas_text.getContext('2d')
ctx_text.beginPath()
ctx_text.font = `bold ${TEXT.fontSize}px ${TEXT.font}`
ctx_text.fillStyle = TEXT.color
ctx_text.fillText('その時、ふと閃いた!', 0, TEXT.posY + TEXT.fontSize)
ctx_text.fillText(
  `このアイディアは、${name}との`,
  0,
  TEXT.posY + TEXT.fontSize * 2 + TEXT.lineHeight,
)
ctx_text.fillText(
  'トレーニングに活かせるかもしれない!',
  0,
  TEXT.posY + TEXT.fontSize * 3 + TEXT.lineHeight * 2,
)

const texture_text = new CanvasTexture(canvas_text)
const geometry_text = new PlaneGeometry(canvas_text.width, canvas_text.height)
const material_text = new MeshStandardMaterial({
  color: 0xffffff,
  map: texture_text,
  transparent: true,
  depthTest: false,
})
const mesh_text = new Mesh(geometry_text, material_text)
mesh_text.position.set(0, -400, 3)
scene.add(mesh_text)

renderer.render(scene, camera)

コードが地獄ですね...

白いテキスト欄のような形状のオブジェクトと文字を画像化して取り込む実装はネットにも記事がたくさんあるのでそう時間はかからなかったのですが、緑の淵線はthree.jsのLineが太さを設定できるかはデバイス依存のため、Line2の存在と実装方法が分かるまで苦労しました...
(今改めて調べるとMeshLineでも良さそうです)

完成品

そうして実際に出来上がった画像がこちら

閃いた画像
太り気味待ったなし

URLはこちらですので皆様もぜひ遊んで見てください!
Twitterへのシェア導線も用意してあります。
(フォントの使用期限の関係で来年6月頭あたりまでの公開です)

最後に

カヤックでは一緒に楽しくものづくりをする仲間を募集しています!!