虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Babylon.js x Hono x Fresh x Deno で作る 3D モデリングツール

皆様、夏をいかがお過ごしでしょうか? 奥谷です。

本記事は虎の穴ラボ2024年夏の連載ブログ 13日目の記事です。
前回は、godanさんによる「刺繍ミシンで日本語を刺繍しよう」でした。
次回は、m.mさんによる「画像ファイルをまとめてWebPに変換しよう!」が投稿予定です。

今回は、Babylon.jsHonoFreshDeno を使って、3D モデリングツールを作ってみました。

https://pile-up.deno.dev/ にアクセスして、遊べます。

pile-up.deno.dev

とりあえずソースコードを全部見たい!という方は、こちらからどうぞ。

github.com

3D モデリングツール 『Pile-Up』

今回の3Dモデリングツールは、Babylon.jsが提供するCSG(Constructive Solid Geometry)を3Dモデルを構築します。

この機能は、3Dモデルを重ね合わせて新しくオブジェクトを構築します。

例えば、立方体と球体を積み重ねて(pile up)、立方体の一部が球体の形に抉られている形状を作成すると次のようになります。

この3Dモデルを構築する場合、ソースコードでは次のように記述します。

// 立方体と球体で重ね合わせた形状を作成
const box = BABYLON.MeshBuilder.CreateBox("box", {}, scene);
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { segments: 3 }, scene);
sphere.position = new BABYLON.Vector3(0, 0.5, 0.25);

const csgObject = BABYLON.CSG.FromMesh(box).subtract(BABYLON.CSG.FromMesh(sphere));

csgObject.toMesh("csg", undefined, scene, true);
box.dispose();
sphere.dispose();

見ての通り、座標など数値で指定しますが、「ちょっと斜め」、「もうちょい右に移動」などの操作は数値で指定するのは難しいです。
なので、GUIで操作できるようにしました。記事冒頭の椅子を作る動画(5倍速)がこのアプリです。

技術スタック

  • アプリケーションホスト先:Deno Deploy
  • ストレージ/データベース:Deno KV
  • 3D表現:Babylon.js
  • Web アプリケーションフレームワーク:Fresh、Hono
  • 開発時ランタイム:Deno 1.44.1

ここからは使用した要素ごとピックアップして、導入方法や実装上の得られたポイントを紹介します。

Fresh

今回、アプリケーション全体のフレームワークには、Freshを使用しました。 Freshは、Preactが使われているDeno向けのWebアプリケーションフレームワークです。

基本的にはSSRで動作し、Islands アーキテクチャーの導入による動的なUIの構築が可能です。

このアプリケーションでは、Islands アーキテクチャーの上で操作するコンポーネントに、Babylon.jsでコントロールされるcanvas要素を載せ、動かす部分がキモになります。 例えば、次のような実装です。

// islands/Modeler/BabylonApp.tsx 抜粋
export default function BabylonAppLoader() {
  const canvasRef = useRef(null);
  const canvasCSGRef = useRef(null);
 const objects = useRef<CSGModelingObject[]>([]);

  useEffect(() => {
    const canvasElement = canvasRef.current;
    const canvasCSGElement = canvasCSGRef.current;

    if (canvasElement === null || canvasCSGElement === null) {
      return;
    }

    startBabylonEditApp(canvasElement, update, getModelingData);
    startBabylonResultApp(canvasCSGElement, getModelingData, setScreenShot);
  }, []);

  const getModelingData = () => {
    return objects.current;
  };

  return (
    <>
      <div class="grid grid-cols-2 gap-1 mb-2">
        <div class="mx-2">
          <canvas ref={canvasRef} width="500px" height="500px" /> {/* 編集用 */}
          <canvas ref={canvasCSGRef} width="500px" height="500px" /> {/* CSG 処理結果表示用 */}
        </div>

        <div class="mx-2 bg-neutral p-4 w-full">
        {/* 省略 */}
        </div>
      </div>
    </>
  )
}

canvas要素が、2つ並んでいます。 上は、編集用。下は、CSG結果のプレビュー用です。

2つのcanvas間の表示内容の同期を取るにあたり、useStateでは速度が出せなかったため、useRefを使用しています。 他にも、実装の中で挙動を見ながら調整しています。

また閲覧ページでは、3Dモデルを表示するまで、キャプチャ画像を表示してのロード画面を構築しています。

SSRされるHTMLにデータURLで埋め込まれた画像を表示することで、Babylon.jsの読み込みよりも必ず先に表示されている状態を構築しています。

// routes/models/[id].tsx
import { defineRoute } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
import Viewer from "../../islands/Viewer.tsx";
import { getModel, getModelInfo } from "../../utils/kvstorage.ts";
import { createSourceCode } from "../../utils/create_source_code.ts";
import SourceCode from "../../islands/SourceCode.tsx";

export default defineRoute(async (_req, ctx) => {
  const resultInfo = await getModelInfo(ctx.params.id); // 3Dモデルのタイトルや、画像データ。

  const resultData = await getModel(ctx.params.id);
  const sourceCode = createSourceCode(resultData);

  return (
    <>
      <Head>
        {/* 省略 */}
      </Head>
      <div class="px-4 mb-4">
        <div class="flex justify-center">
          <div class="flex flex-col">
            <div class="text-2xl font-bold text-center mb-4">
              <p>Work Name: 【{resultInfo.title}】</p>
            </div>
            <div class="mb-4">
              <Viewer modelId={ctx.params.id} image={resultInfo.image} /> {/* image Data URL 形式の画像データを渡す。*/}
            </div>
            <div class="flex flex-row-reverse">
              <TwitterShareLink linkUrl={linkUrl} />
            </div>
          </div>
        </div>
      </div>
      <div class="px-4">
        <details>
          <summary class="justify-center">Source Code</summary>
          <SourceCode code={sourceCode} />
        </details>
      </div>
    </>
  );
});
// islands/Viewer.tsx

import BabylonAppLoader from "./Viewer/BabylonApp.tsx";

export default function Viewer(props: { modelId: string; image: string }) {
  return <BabylonAppLoader modelId={props.modelId} image={props.image} />;
}
import { useEffect, useRef, useState } from "preact/hooks";
import { startBabylonViewerApp } from "./BabylonViewerApp.ts";
import { CSGModelingObject } from "../types.ts";

import { hc } from "hono/client";
import { AppRoutesType } from "../../api/app.ts";

const client = hc<AppRoutesType>("/");

export default function BabylonAppLoader(
  props: { modelId: string; image: string },
) {
  const canvasViewerRef = useRef(null);
  const objects = useRef<CSGModelingObject[]>([]);
  const [isRender, setIsRender] = useState(false);

  // 省略
  function callRender() {
    setIsRender(true);
  }

  useEffect(() => {
    const canvasViewerElement = canvasViewerRef.current;

    if (canvasViewerElement === null) {
      return;
    }

    startBabylonViewerApp(canvasViewerElement, getModelingData, callRender);
  }, []);

  return (
    <>
      <div class={"absolute " + (isRender ? "hidden" : "")}> { /* ロード画面 babylon.jsでのレンダリングができると非表示に切り替え*/}
        <span
          class={"absolute loading loading-spinner loading-lg text-warning top-[280px] left-[280px]"}
        >
        </span>
        <img src={props.image} width="600px" height="600px">
        </img>
      </div>
      <canvas ref={canvasViewerRef} width="600px" height="600px" />
    </>
  );
}

Babylon.js のパフォーマンス調整

先の紹介の通り、3D表現使うにあたり、Babylon.jsを使用しました。

今回行う『CSG』、実はちょっと処理負荷が高いです。 1回CSGで3Dモデルを構築して使うなら大きな問題にはなりにくいです。 が、今回のように元となる3Dモデルを動かすたびに形を変える場合、まともに動作させることが難しくなることがあります。 対策として、高速化と処理負荷軽減のため、曲面主体メッシュはポリゴン数が減るように調整を行っています。

let mesh: BABYLON.Mesh | null = null;
switch (obj.meshType) {
  case "box":
    mesh = BABYLON.MeshBuilder.CreateBox(obj.id, {}, scene);
    break;
  case "sphere":
    mesh = BABYLON.MeshBuilder.CreateSphere(
      obj.id,
      { segments: 3 },
      scene,
    );
    break;
  case "cylinder":
    mesh = BABYLON.MeshBuilder.CreateCylinder(obj.id, {
      tessellation: 6,
    }, scene);
    break;
  case "Torus":
    mesh = BABYLON.MeshBuilder.CreateTorus(obj.id, {
      tessellation: 6,
    }, scene);
    break;
}

特に設定していない cylinder と、この処理をかけた cylinder は次のような見た目になります。

設定なし

ポリゴン数を減らしたもの

はい、円柱というより6角柱ですね。

こういった処理を行ってもカプセル形状を使うとエラーが頻発したため、アプリでは使用できる形状の選択肢から外しています。

Hono でFreshのAPI部分を実装

Hono は、高速、軽量、Web標準に基づいて構築されている Webアプリケーションフレームワークです。

https://hono.dev/

今回は、Freshで作成したフロントエンドと連携するAPI部分をHonoで実装しました。

以下のように構築されています。

// api/app.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import {
  getModel,
  getModelInfo,
  saveModel,
  searchModels,
} from "../utils/kvstorage.ts";
import {
  getModelsDataRoute,
  getModelsInfoRoute,
  getModelsSearchRoute,
  postModelsRoute,
} from "../utils/api_definition.ts";
import { callCreateOgp } from "../utils/queues.ts";

const app = new OpenAPIHono();

export const appRoutes = app
  // POST /api/models
  .openapi(postModelsRoute, async (c) => {
    const json = await c.req.json();
    const result = await saveModel(json.title, json.models, json.image);

    await callCreateOgp(result.id);

    return c.json({ message: "OK", url: `/models/${result.id}` });
  })
  // GET /api/models/:id/data
  .openapi(getModelsDataRoute, async (c) => {
    const id = c.req.param("id");
    const arr = await getModel(id);

    return c.json(arr);
  })
  // GET /api/models/:id/info
  .openapi(getModelsInfoRoute, async (c) => {
    const id = c.req.param("id");
    const result = await getModelInfo(id);

    return c.json(result);
  })
  // GET /api/models/search
  .openapi(getModelsSearchRoute, async (c) => {
    const modelInfos = await searchModels(c.req.query("q") || "");

    return c.json({ modelInfos });
  });

export type AppRoutesType = typeof appRoutes;

パスや、引数、返り値の型を定義は、zod-openapi を使用しています。 この定義はすべて掲載するには長くなるため、省略します。

作った Hono のAPI部分をFreshに連携するには、以下のように記述します。

// routes/api/[...path].ts
import { Handler } from "$fresh/server.ts";
import { appRoutes } from "../../api/app.ts";

export const handler: Handler = appRoutes.fetch;

また、Hono を使うことで、フロント側は、Hono RPC を使い、APIを呼び出すことができます。

例えば、3Dモデルの表示画面では、次のような呼び出しで、3Dモデルのデータを取得しています。

// islands/Viewer/BabylonApp.tsx
import { hc } from "hono/client";
import { AppRoutesType } from "../../api/app.ts";

const client = hc<AppRoutesType>("/");

const result = await client.api.models[":id"].data.$get({ //api/models/:id/data にGETリクエストを送信に相当
  param: {
    id: props.modelId,
  },
});

「3Dモデルを〜」とここまで繰り返していますが、実は保存しているのは3Dモデル自体ではなく、その作成に当たっての手順を保存しています。 これによって、作成した3Dモデルの表示画面で、貼り付けて使えるソースコードを作ることができます。

Deno Queues でOGP画像を生成と圧縮

3Dモデルを登録すると、3Dモデルのタイトルを埋め込んだOGP画像が生成されます。
この処理には、Deno Queues を使用しています。

基本的な構造は、以前toranoana.deno #16 で紹介したものになっています。

speakerdeck.com

以前は色とテキストなど違うものの、全体的に同じ色が全体に広がる画像を作成していました。 今回のケースでは、利用者が3Dモデルを保存するとき、CSG結果のcanvasの内容をリサイズしたものをベースに使用しています。 3Dなだけあり、色も画一的にならず、保存するキャプチャの内容次第で画像の容量が大きくなり、Deno KV の1レコード当たりの容量制限を超えることがありました。 対策として、vercel/satoriで作成されるPNG画像 を @jsquash/oxipng を使い圧縮しています。

// utils/create_ogp.tsx

import satori from "npm:satori";
import * as svg2png from "npm:svg2png-wasm";
import { optimise } from "@jsquash/oxipng";
import { getModelInfo, setModelOgpImage } from "./kvstorage.ts";

export async function createOgp(id: string) {
  const info = await getModelInfo(id);
  const singleByteChars = info.title.match(/[ -~]/g) || [];
  const titleLength = info.title.length * 2 - singleByteChars.length;
  const title = titleLength > 10 ? info.title.slice(0, 8) + "..." : info.title;

  // TSX => SVG 変換
  const svg = await satori(
    // 省略
  );

  // SVG -> PNG 変換
  const png = await svg2png.svg2png(svg, convert_options);
  // PNG 圧縮
  const oxipng = await optimise(png, { level: 3 }); 

  setModelOgpImage(id, new Uint8Array(oxipng));
}

圧縮を行うことで、Deno KV の1レコード当たりの容量制限を超えることなく保存できるようになりました。 この処理で作られるOGP画像は、Xでシェアする際に表示される画像として使用されます。

次のような画像が生成されます。

3Dモデルを作ったら、ぜひシェアしてみてください。


まとめ

改めて、Babylon.jsHonoFreshDeno を使って、3D モデリングツールを作ってみました。

これまで使ったことのある、3Dモデリングできるツールとしては、MetasequoiaBlenderがありました。 比較すると簡単なツールではありますが、そこそこ面白く作れたのがよかったと思います。

以前別のツールを作った際の積み上げと流用もあり、比較的短い実装期間で用意できました。

この記事の作成後、少し改修して、重なったオブジェクトも操作しやすいように、UI側でcanbasにある3Dモデルにフォーカスを当てる機能を追加しました。

ぜひ遊んでみてください。

宣伝 - toranoana.deno #17 を開催します

2024年7月31日 19:30 から、 toranoana.deno #17 を開催します

yumenosora.connpass.com

「Denoで作ったアプリケーション/モジュール/サービスを見て欲しい!」 「Deno こういうふうに使ってみた」など Deno関連ならテーマを問いません! 登壇、視聴参加ともに募集中です。