Webで表現する HD-2D風オンライン空間

本投稿は TECOTEC Advent Calendar 2025 の最終日の記事です。

レジャーソリューション事業部の横山です。
普段はフロントエンド専任のエンジニアとして、主にVue/Nuxtを使ったWebアプリケーションの開発を行なっています。

早速ですが、皆さんは「HD-2D」という表現を聞いたことはありますか?

これはスクウェア・エニックス社が商標登録している、
ドット絵のゲームを現代風にアレンジした表現技法のことです。

【参考: オクトパストラベラー】

ピクセルアートが醸成した古めかしさと、美麗で奥行きのある空間が、
なんとも言えないノスタルジックで不思議な空気感、没入感を演出してくれるのですよね。

一見とても高度な技術に見えますが、3Dの空間に2Dのドット絵を配置し、
エフェクトを重ねていくだけ
でもある程度は表現可能です。

実際にUnityのようなゲームエンジンを用いて再現している記事や動画は幾つか見つかるのですが、
Web(Threejs)でも同じことが可能なのか、今回はデモサイトを作りながら検証していきたいと思います。

1.はじめに

この記事では表現方法の解説がメインとなるため、具体的なThreejsライブラリの使い方やソースコードの説明はほとんど行いません。
先に動作するものを見たい方は以下からサイトに飛べます。

デモサイト

想定する読者

  • Webの3D領域に興味のある方
  • シェーダーやポストプロセスに関心のある方
  • WebSocketを使った双方向通信の流れを知りたい方

使用する主な技術

  • Nuxt4
  • TresJS(Vue用のThreejsラッパー)
  • Cloudflare Worker&Durable Objects

なぜWebでやるのか

話の本筋ではありませんがモチベーションを高める上では大事なことだと思うので、個人的な見解を述べておきます。
私が思うWeb3D領域の利点は以下3点です。

  • Web技術が使える
  • クロスプラットフォーム対応が容易
  • 依然ブルーオーシャン

まず何と言ってもWeb技術なので、慣れ親しんだJSフレームワークを使いながら殆どのことが制御可能です。 特にVue、React、Svelteなどは、専用のThreejsラッパーライブラリが存在します。

加えてキャンバス座標、ワールド座標のどちらにおいても、宣言的UIでHTML/CSSが扱えます。 これはゲームエンジンでUIを制御する苦労と比べると、非常に大きなメリットと言えます。

またCapacitorなどを入れれば、同一ソースコードのままネイティブアプリとしても出荷出来るため、
「まずは手軽にWebで触ってもらい、気に入ったらストアに誘導する」という動きだって可能です。

問題はゲームエンジンのような専用エディタが現状ないため、センスを伴う多くの作業をエンジニアがコードで表現しないといけないという難易度の高さと、 これといったサービスとしての活用例が業界として見出せていないことにあるかと考えています。
しかしだからこそのブルーオーシャンであり、挑む価値のある分野だとも言えます。

2. 事前準備

ここから作業環境を整えていきます。
冒頭で書いた通りコードの説明はしないので、ささっと飛ばし気味でやっていきます。

開発環境

  1. Nuxt4で開発環境を用意します。
    nuxt.com

  2. TresJSモジュールを入れます。
    nuxt.com

  3. VueUseもWebSocket対応で必要になるので入れておきます。
    nuxt.com

ステージ作成

HD-2D風の見た目を作るには、前提として3Dの空間に2Dの板ポリを配置していくことになるのですが、
この時に使うテクスチャはドット絵か、用意できない場合は解像度をあえて落とした粗めのものが望ましいです。

また読み込んだ際はThreejs側でアンチエイリアスをあえてオフ(=境界をジャギー化させる)と、よりドット絵としては馴染むのでオススメです。

中央にキャラクターを置こうと思うので、ここでは気持ち囲うようにテクスチャを貼り付けた板ポリを配置していきます。
また影が反映されるようにcastShadowreceiveShadowの有効化も忘れずに。

オブジェクトはこちらの素材を使わせていただきました。

cainos.itch.io

キャラクター配置

キャラクターも当然ドット絵を使います。
TresJsを使っていればAnimatedSprite コンポーネントでスプライトシートがそのまま扱えます。
ここでは4方向のアニメーションが適用されるようにしました。

キャラクターはこちらの素材を使わせていただきました。

ci-en.dlsite.com

舞台はこれで整ったので、次からいよいよHD-2D風の表現方法についての解説です。

3. ポストプロセス

まずはこちらの画像をご覧ください。

これは私がUnityで撮影したものなのですが、実は全く同じシーンを写しています。見栄えがかなり違いますよね。

では何がこれほど大きなクオリティの差を生んでいるのかと言うと、
「Ambient Occlusion(環境遮蔽)」や「Vignette(四隅を暗く)」、「DepthOfField(被写界深度)」といった、
レンダリング結果に対する後処理効果(=ポストプロセス)です。

多くの3Dレンダリングエンジンや関連パッケージでは、こういった効果は事前に用意されており、
開発者は用意されたパラメータを弄るだけで手軽にクオリティの底上げをすることが出来ます。

ThreeJSでも同様にポストプロセスを公開している有名なパッケージがあるので、ここから先は今回使うものに絞って紹介していきます。

github.com

Bloom

Bloom(ブルーム)は、明るい部分の光をにじませて発光しているように見せる効果です。
光源やハイライトが強調され、シーン全体が柔らかく幻想的な印象になります。

Vignette

Vignette(ビネット)は、画面の周辺を暗くする効果です。
被写体の存在感を強め、奥行きや雰囲気を演出できます。

Tone Mapping

Tone Mapping(トーンマッピング)は、明るすぎる光や暗すぎる影を調整する効果です。
光が白く飛んだり、暗部が真っ黒になるのを防ぎ、全体のコントラストや雰囲気を引き立てます。

Color Average

Color Average(色平均化)は、画面全体の色の平均をもとに色味をなじませる効果です。
色同士のバラつきを抑え、全体的な統一感が増します。

DepthOfField

DepthOfField(被写界深度)は、ピントが合う範囲以外をぼかして奥行きを表現する効果です。
手前や奥をぼかすことで、注目させたい対象が際立ちます。

番外編:Precipitation

Precipitation(降水表現)は、雨や雪などの粒子を画面に表示する演出効果です。 ポストプロセスではなく、パーティクルの一種になります。

天候の変化を視覚的に伝え、シーンの臨場感や季節感を高める他、
これまで適用してきたBloomやDepthOfFieldと組み合わせて、世界観づくりや没入感の強化にも使われます。

最終出力結果

これらを組み合わせることで、同じシーンでも最終的なレンダリング結果に大きな差が生まれます。

【before】

【after】

このようにポストプロセスは、
エフェクトの組み合わせとパラメータの匙加減次第で与える印象が大きく変わります。

良しと思える感覚もまた人それぞれなため、楽しくもあり、センスも問われる難しいところだなと感じます。

もしご興味があればデモサイトの右上のコントロールパネルから色々弄れるので試してみてください。

4. シェーダー

続いてシェーダーです。

ポストプロセスが画面全体を加工する処理であるなら、
シェーダーは各オブジェクトごとに「この物体をどう描くか」を決める処理です。

GPU による描画処理でエンジニアが意識すべきは、
頂点シェーダーフラグメントシェーダーの2つの段階で、いずれもThreejsではGLSL言語で制御可能です。

それぞれ説明していると長くなってしまうため割愛しますが、
頂点シェーダーで位置や形、法線を弄れて、フラグメントシェーダーで色を塗る、それくらいの理解で進めます。

今回は頂点シェーダーを使った、ドット絵利用時に役立つテクニックを一つご紹介します。

頂点シェーダーでアニメーションを追加する

3Dモデルと比べた時の、ドット絵の最大のデメリットはリソースに制約があることだと思います。
特に動きのバリエーションが少ないと、それだけ出来る表現の幅が狭まってしまいますよね。

この素材も可愛いのですが、4方向のアニメーションだけだとやや物足りないものを感じます。

そこで今回は各方向にidleアニメーションを追加したいと思います。

普通に考えるとidle素材を×4方向分追加しないといけないのですが、単純な動きに限り頂点シェーダーだけでも対処出来ます。

const material = new MeshStandardMaterial({
  map: texture,
  alphaTest: 0.5,
  metalness: 1,
  roughness: 1,
  transparent: true,
  depthWrite: true,
  side: DoubleSide,
})

// シェーダーを一部差し替え
material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = { value: 0 }

  shader.vertexShader = shader.vertexShader
    .replace(
      '#include <common>',
      `
        #include <common>
        uniform float uTime;
      `,
    )
    .replace(
      '#include <begin_vertex>',
      `
          vec3 transformed = vec3(position);
          float offset = sin(uTime * 3.0) * 0.01;
          float minY = 0.0;
          float maxY = 0.25;
          float weight = clamp((position.y - minY) / (maxY - minY), 0.0, 1.0);
          transformed.y += offset * weight;
        `,
    )

  material.userData.shader = shader
}

これだけでもキャラクターに命が吹き込まれた感がありますね!
ここでは各頂点を上下に移動する処理(ただし上の方ほど揺れ幅が大きく、下に行くほど揺れにくい)を入れています。

もしシェーダについて気になった方は、The Book of Shaders がおすすめです。 読み物としても非常に面白いので是非見てみてください。

5. オンライン対応

最後に作った空間をオンラインに対応していきます。
座標の同期など、小さな通信を沢山する必要があるのでWebSocketを軸に組んでいきます。

以下は参加/離脱時のフロント↔︎サーバ間のやり取りです。
※参考程度に見てください。

ソースコード

【フロント側】

const { open, send, ws, status } = useWebSocket('/ws/game', {
  immediate: false,
  onMessage(ws, { data }) {
    const event = JSON.parse(data) as WsEvent

    // 初回接続時
    if (event.eventType === WS_EVENT_TYPE.CONNECTED) {
      // プレイヤーを表示
      return
    }

    // 参加時
    if (event.eventType === WS_EVENT_TYPE.JOIN) {
      // 別プレイヤーを表示
      return
    }

    // 離脱時
    if (event.eventType === WS_EVENT_TYPE.LEAVE) {
      // プレイヤーを削除
      return
    }
  },
})
open()

【サーバ側 】server/routes/ws/game.ts

import { WS_ROOM, WS_EVENT_TYPE } from '#shared/constants/ws'
import type { WsEvent } from '#shared/constants/ws'

export default defineWebSocketHandler({
  open(peer) {
    // ルームに接続
    peer.subscribe(WS_ROOM)

    // 自身に接続イベント送信
    peer.send(
      JSON.stringify({
        peerId: peer.id,
        eventType: WS_EVENT_TYPE.CONNECTED,
        data: {
          peerIds: [...peer.peers].map(peer => peer.id),
        },
      }),
    )

    // 全体に参加イベント配信
    peer.publish(
      WS_ROOM,
      JSON.stringify({
        peerId: peer.id,
        eventType: WS_EVENT_TYPE.JOIN,
        data: {
          peerId: peer.id,
        },
      }),
    )
  },
  close(peer) {
    // ルームから離脱
    peer.unsubscribe(WS_ROOM)

    // 全体に離脱イベント配信
    peer.publish(
      WS_ROOM,
      JSON.stringify({
        peerId: peer.id,
        eventType: WS_EVENT_TYPE.LEAVE,
        data: {
          peerId: peer.id,
        },
      }),
    )
  },
  async message(peer, message) {
    // 移動処理など
  },
})

これはNuxt(正確にはサーバーエンジンのNitro)がWebSocket APIの内部実装で利用している、crossws に沿った書き方です。これにより、同一ソースコードのままローカルやクラウドなど様々な環境で動くようになります。

これは当たり前のことではなく、最終的には各実行環境(Node/Workersなど)の違いをNitro側が吸収してくれている、ということです。

Nuxtの生い立ちとして凄まじいのは、
UnJS などの外部パッケージ化された細かな機能群の上に成り立っている点で、
これは各モジュールのメンテナンス性を高めるだけじゃなく、Nuxt 以外にもその恩恵が受けられることを意味しています。
(実際にAngularフレームワークのAnalogは内部でNitroサーバが使われています)

少し話を戻して、
接続処理と比べると移動処理は通信数が多くなってしまうため、許容できる遅延時間と相談しながら送信イベントを適宜間引くと良いと思います。

// 例:沢山発火しても30msに一回の通信に止める
const onMoved = useThrottleFn(() => {
  const event: WsEvent = {
    peerId: playerId.value,
    eventType: WS_EVENT_TYPE.MOVE,
    data: {
      position: player.value.position,
      animation: player.value.animation,
    },
  }
  send(JSON.stringify(event))
}, 30, true)

別タブを開いて操作してみると、同期が出来ていることを確認できます。

なおuseWebSocketuseThrottleFnはvueuseで公開されている関数です。

https://vueuse.org/shared/useThrottleFn/#usethrottlefnvueuse.org

デプロイ

いよいよこれをどこかにデプロイしていきます。
調べてみるとCloudflare worker & Durable Objectsであれば、WebSocketが1日10万リクエストまで無料で使えるようです。
(超えても自動課金される訳ではなく、リクエストが失敗するだけなので安心です)

まずは設定ファイルを追加します。

nuxt.config.ts

nitro: {
  experimental: {
    websocket: true,
  },
  preset: 'cloudflare-durable',
},

Wrangler.toml

name = "hd2d"
compatibility_date = "2025-03-16"
main = "./.output/server/index.mjs"
assets = { directory = "./.output/public/", binding = "ASSETS" }
compatibility_flags = ["nodejs_compat", "nodejs_compat_populate_process_env"]

# These definitions from the experimental cloudflare durable PR: https://github.com/nitrojs/nitro/pull/2801
[[durable_objects.bindings]]
name = "$DurableObject"
class_name = "$DurableObject"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["$DurableObject"] 

以下からCloudflareの無料アカウントを作成し、
www.cloudflare.com

ターミナルで叩いてログイン、初期化します。

npm i -g wrangler
npx wrangler login
npx wrangler init

そしてビルド&デプロイ!

"deploy": "nuxt build && npx wrangler deploy .output/server/index.mjs --assets .output/public"

これでCloudflare Workerにサイト本体(静的配信+Nitroの処理)、Durable ObjectにWebSocketサーバー(接続維持+メッセージ処理)が配置されました。

動作はデモサイトからご確認頂けます。

今回は接続情報や座標情報などを中継して横流ししているだけですが、
例えばワールドの状態など共通の値を永続化することもDurable Objectなら出来るようです。そこまで出来ればオンラインゲームと言えそうですね。

最後に

いかがでしたでしょうか?

かなり盛り沢山な内容になってしまいましたが、
HD-2D風の表現方法からオンライン化まで、一通りの流れを見せられたのではないかなと思います。

個人的には「ゲーム(のような雰囲気)とツール(の持つ有用性)の融合」という観点において、
Web3D領域はまだまだ開拓の余地があるんじゃないかなと考えています。

また今回はWebGLでの解説になりましたが、
今後はWebGPU対応によってWebで3Dをレンダリングする描画コストが飛躍的に改善されていきます。

言語もGLSLの代わりにTSL(Threejs shading language)を使えるようになり、
Blenderで言うところのshader Nodeのような考え方で記述できるようになるようです。

つまり、今からWeb3Dを学び始めるにはとても良いタイミングです。
もしこの記事を読んで気になった方がいましたら、ぜひWeb3Dの世界へ入門してみてください!

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp