最近作ったプロダクトで、Flutter を使ってアイソメトリック表現の街を実装したので、その実装方法や考え方をまとめていきます。
今回制作したプロダクトでは、「街」を画面上で表現する必要がありました。
ただ、単純なトップダウン表示だと平面的になってしまい、見た目のクオリティや没入感が弱いという課題がありました。
そこで「2D ベースのまま、できるだけ立体感を持たせたい」と考え、アイソメトリック表現を採用しました。
選定理由
最初は、以下のような選択肢も検討していました。
- Unity などのゲームエンジンを使う
- Flutter 上で本格的な 3D 表現を行う
ただ、今回の開発は ハッカソン形式だったため、
- 実装コスト
- 開発速度
- 技術の成熟度
- UI との統合しやすさ
などを総合的に考えた結果、Flutter + 2D ベースで実装できるアイソメトリックが最も現実的でした。
特に Flutter は通常のアプリ UI との統合がしやすく、既存の画面との相性もかなり良かったです。
トップダウンとアイソメトリックの違い
ここで、通常のトップダウン表示とアイソメトリック表示を比較してみます。
| トップダウン | アイソメトリック | |
|---|---|---|
| 視点 | 真上から見下ろす | 斜め上から見下ろす |
| 実装 | シンプル | やや複雑 |
| 見た目 | 平面的に見えやすい | 奥行き・立体感が出る |
| 次元 | 2D | 2D(なのに立体的に見える) |
実際に実装したもの
実際に実装した画面はこんな感じです。
目次
アイソメトリックとは
アイソメトリックは、見た目は 3D っぽく見えますが、実際には通常の 2D 描画です。
一般的なトップダウンゲームでは、カメラを真上から見下ろす形になります。一方アイソメトリックでは、
- 横方向に 45 度
- 縦方向にも斜め
から見下ろしているように見せることで、タイルがひし形に変換され、立体感を演出できます。
特徴としては、
- 実際には 3D 計算をしていない
- 2D 描画だけで表現できる
- 比較的軽量
- UI との統合がしやすい
といったメリットがあります。
アイソメトリック座標変換
ここでは、トップダウン座標をアイソメトリック表示へ変換する処理を、座標(行列) の観点から数学的に整理していきます。
このプロダクトで扱う座標系は、グリッド座標系 と 画面座標系 の 2 つです。
2つの座標系
グリッド座標系
| 変数 | 意味 |
|---|---|
gx |
列番号(左 → 右で増加) |
gy |
行番号(上 → 下で増加) |
(0, 0) が左上タイルになります。
画面座標系
| 変数 | 意味 |
|---|---|
isoX / screenX
|
右が正 |
isoY / screenY
|
下が正(Flutter のキャンバス標準 = 左上原点) |
なお、タイルのサイズは tileW = 96、tileH = 48(= tileW / 2)で固定しています。この 2 : 1 の比率の意味は最後のコラムで触れます。
変換式の導出
考え方はシンプルで、グリッドの各軸が画面上でどの向きに伸びるかを捉えるだけです。
-
gx軸は画面の右下へ伸びる
gxが 1 増える = 右にtileW/2・下にtileH/2 -
gy軸は画面の左下へ伸びる
gyが 1 増える = 左にtileW/2・下にtileH/2
この 2 つの軸ベクトルを足し合わせると、そのまま変換式になります。
\begin{aligned}
isoX &= gx \cdot \left(+\frac{tileW}{2}\right) + gy \cdot \left(-\frac{tileW}{2}\right) = (gx - gy) \cdot \frac{tileW}{2} \\[6pt]
isoY &= gx \cdot \left(+\frac{tileH}{2}\right) + gy \cdot \left(+\frac{tileH}{2}\right) = (gx + gy) \cdot \frac{tileH}{2}
\end{aligned}
これを行列でまとめると、以下のように書けます。
\begin{bmatrix} isoX \\ isoY \end{bmatrix}
=
\begin{bmatrix}
+\dfrac{tileW}{2} & -\dfrac{tileW}{2} \\[8pt]
+\dfrac{tileH}{2} & +\dfrac{tileH}{2}
\end{bmatrix}
\begin{bmatrix} gx \\ gy \end{bmatrix}
つまりアイソメトリック変換は、グリッド座標に 2×2 の変換行列を掛けるだけの線形変換です。実装も式そのままです。
final isoX = (gx - gy) * tileW / 2;
final isoY = (gx + gy) * tileH / 2;
この式が持つ意味
変換式の構造から、2 つの大事な性質が読み取れます。
-
isoX = (gx - gy) · tileW/2…gxとgyの 差 で決まる → 左右位置 -
isoY = (gx + gy) · tileH/2…gxとgyの 和 で決まる → 奥行き位置(上下位置)
特に gx + gy(和)は、後の 描画順(Painter's Algorithm) で「奥から手前へ描く」順序にそのまま使えます。手前のタイルほど gx + gy が大きくなるからです。
コラム:tileW : tileH = 2 : 1 の意味
タイルの幅と高さの比率は、見た目の自然さに直結します。比率を変えると、こう変わります。
- 1 : 1 → トップダウン形式と同じ
- 2 : 1 → ちょうど良い(標準的なアイソメトリック)
- 4 : 1 → 平らすぎる
2 : 1(横幅が縦幅の 2 倍)にすると、斜め上から見下ろした立体感がいちばん自然に見えます。そのため実装では tileH = tileW / 2 で固定しています。
カメラの初期位置
起動時に、マップの中心タイルを画面中央へ持ってきたい。やることは単純で、中心タイルの (gx, gy) を変換式に突っ込むだけです。
\begin{aligned}
centerGx &= \frac{mapW - 1}{2}, \quad centerGy = \frac{mapH - 1}{2} \\[6pt]
cx &= (centerGx - centerGy) \cdot \frac{tileW}{2} \\[4pt]
cy &= (centerGx + centerGy) \cdot \frac{tileH}{2}
\end{aligned}
正方形マップ(mapW = mapH)なら、差がゼロになるので cx = 0。横長マップ(mapW > mapH)にすると中心が右にずれて cx > 0 になります。
Dart / Flame 実装と照合
ここまでの数学が、実際の map_board.dart にそのまま落ちています。式と 1 対 1 で対応しているのが分かるはずです。
const double tileW = 96.0;
const double tileH = 48.0; // 96 / 2 = 48 → 常に 2:1
for (final (gx, gy) in tiles) {
final isoX = (gx - gy) * tileW / 2; // ← 導出した変換式そのまま
final isoY = (gx + gy) * tileH / 2; // ←
world.add(TerrainTileComponent(
gx: gx, gy: gy,
position: Vector2(isoX, isoY), // 変換後の座標に配置
renderOrder: gx + gy, // 描画順 = 奥行き(gx+gy)
));
}
// カメラを中心タイルへ
final cx = ((mapW - 1) / 2.0 - (mapH - 1) / 2.0) * tileW / 2;
final cy = ((mapW - 1) / 2.0 + (mapH - 1) / 2.0) * tileH / 2;
camera.viewfinder.position = Vector2(cx, cy);
renderOrder に gx + gy(奥行き)をそのまま使っているのがポイントです。先ほど触れた「和は奥行きを表す」性質が、ここで効いてきます。
よくある誤解
positionはタイルの「左上隅」ではなく、ひし形の中心です。Flame のPositionComponentはこの位置を基準にrender()を描くので、描画コードもこの中心基準で組みます。
Flame の update/render
アイソメトリックの座標計算ができたら、次はそれを実際に画面へ描き続ける仕組みが必要です。このプロダクトでは描画エンジンに Flame を使いました。
なぜ Flame(ゲームエンジン)が必要なのか
Flutter のウィジェットは「状態が変わったときだけ再描画」される作りになっています。これは UI には最適ですが、街が常にアニメーションし続けるような用途には向いていません。
- 毎フレーム(60fps)描くには 16ms ごとに
setState()を呼び続ける必要がある -
CustomPainterでも「前フレームからの経過時間」が自動では渡ってこない - 多数のオブジェクトの更新を自分で管理しないといけない
Flame はこの面倒を肩代わりしてくれます。起動時に一度だけ onLoad() を呼び、そのあとは毎フレーム update → render を自動で回してくれます。
起動
↓
onLoad() ← 初期化(1回だけ)
↓ 毎フレーム(約60回/秒)
update(dt) ← 状態を更新
↓
render(canvas) ← 画面に描画
役割はシンプルに分かれていて、update で「状態を変える」、render で「今の状態を描く」。この 2 つを分けて考えるのがポイントです。
dt(デルタタイム)でフレームレート非依存にする
update(dt) の dt は 前フレームからの経過時間(秒) です。これが地味に重要で、アニメーションを「秒速」で定義できるようになります。
たとえば毎フレーム位置を固定量ずらすと、こうなります。
_t += 1; // フレームレート依存:60fpsと30fpsで速度が変わってしまう
_t += dt; // フレームレート非依存:常に「1秒で1.0増える」
dt で積算すれば、60fps の端末でも 30fps の端末でも「1 秒で同じだけ進む」ので、どの環境でも同じ速さで動きます。このプロダクトでは、水タイルの揺らぎや建物の浮遊アニメをこの _t で動かしています。
// 建物の浮遊アニメ
double _t = 0;
@override
void update(double dt) => _t += dt;
world と camera
Flame の FlameGame は、大きく world と camera の 2 つを持っています。
-
world… タイルや建物が存在する空間(ゲームの「世界」そのもの) -
camera… その world のどこを、どのくらいの範囲で画面に映すかを決める「窓」
FlameGame
├── camera ← world のどこを見るか(位置・ズーム)を制御
└── world ← タイルや建物が存在する空間
├── TerrainTileComponent (0,0)
├── TerrainTileComponent (1,0)
├── BuildingComponent (2,1)
└── ...
タイルや建物はすべて world に追加します。前のセクションで計算したカメラ初期位置は、この camera.viewfinder.position に入れることで「マップ中心を画面中央に映す」を実現していました。
camera.viewfinder.position = Vector2(cx, cy);
コンポーネントを world に並べる
タイルや建物は PositionComponent を継承したコンポーネントとして作り、座標変換した位置を渡して world に追加します。onLoad() の中で全タイルを生成しているのがこの部分です。
final isoX = (gx - gy) * tileW / 2; // 座標変換(前セクション)
final isoY = (gx + gy) * tileH / 2;
world.add(TerrainTileComponent(
position: Vector2(isoX, isoY),
priority: gx + gy, // 描画順 = 奥行き
));
ここでも gx + gy(和=奥行き)が priority(描画順)として使われています。Flame は priority の小さい順に描くので、奥のタイルから手前のタイルへと自然に重なります。この描画順の話は次のセクションで詳しく扱います。
update と render の役割分担
最後に、コンポーネント側での update / render の使い分けを整理しておきます。
@override
void update(double dt) => _t += dt; // 時間を進めるだけ(状態の更新)
@override
void render(Canvas canvas) {
final float = math.sin(_t * 1.8) * 3.0; // _t から「今の浮き具合」を計算
canvas.translate(0, -float); // その分だけ上にずらして
_drawBody(canvas); // 建物を描く
}
update は時間を進めるだけ、render はその時間から「今どう見えるか」を計算して描くだけ。状態の更新と描画をきっちり分けておくと、アニメーションのコードがぐっと見通しよくなります。
Path API でひし形タイルを描画
座標変換でタイルを「どこに置くか」が決まったので、次は1枚のタイルを「どう描くか」です。アイソメトリックタイルの正体はただのひし形なので、Flutter の Canvas と Path API で描けます。
render() 内の座標系を押さえる
前提として、Flame の render(Canvas canvas) が呼ばれる時点で、canvas の原点はすでにそのタイルの位置(isoX, isoY)へ移動済みです。つまり render() の中の (0, 0) は、そのまま「ひし形タイルの中心」を指します。
なので各タイルは「自分の中心からの相対座標」だけ考えて描けばよく、絶対座標を気にする必要はありません。y は下が正(Flutter キャンバス標準)なので、-y が上方向です。
ひし形を描く _diamond()
ひし形は「上・右・下・左」の4頂点を順に結ぶだけです。Path の moveTo → lineTo → close() で定義します。
Path _diamond() => Path()
..moveTo(0, -tileH / 2) // 上頂点 (0, -24)
..lineTo(tileW / 2, 0) // 右頂点 (+48, 0)
..lineTo(0, tileH / 2) // 下頂点 (0, +24)
..lineTo(-tileW / 2, 0) // 左頂点 (-48, 0)
..close(); // 左頂点 → 上頂点に戻って閉じる
tileW=96, tileH=48 なので、各頂点は中心から tileW/2(48px)または tileH/2(24px)だけ離れた位置になります。
横幅 96px・縦幅 48px なので、ちょうど 2 : 1 のひし形です。座標変換のときに決めた比率が、ここでそのまま形になっています。
全タイルに共通する「3段構成」
このプロダクトには草・森・道路・水の4種類のタイルがありますが、どれも描き方の骨格は同じです。
| タイル | ① 土台 | ② 中身 | ③ 仕上げ |
|---|---|---|---|
| 草 | ひし形を緑で塗る | 小円を5個散らす(草ツブ) | 輪郭線 |
| 森 | ひし形を緑で塗る | 三角形+矩形の木を2本 | 輪郭線 |
| 道路 | ひし形をグレーで塗る | 水平の破線 | 輪郭線 |
| 水 | 青を2層で塗る | sin波3本+光の煌めき | 輪郭線 |
つまり 「ひし形を塗る → 中身を描く → 輪郭線」 の 3 段。一番シンプルな草タイルを見ると分かりやすいです。
void _drawGrass(Canvas canvas) {
final d = _diamond();
canvas.drawPath(d, Paint()..color = const Color(0xFF9CC43B)); // ① 黄緑で塗る
// ② 草のツブツブ(小円を散らす)
final dot = Paint()..color = const Color(0xFF6B9E1F).withValues(alpha: 0.6);
for (final (rx, ry) in [
(0.0, -8.0), (-14.0, 4.0), (14.0, 2.0), (-6.0, 14.0), (10.0, 14.0),
]) {
canvas.drawCircle(Offset(rx, ry), 3.5, dot);
}
_borderPath(canvas, d); // ③ 輪郭線
}
輪郭線は全タイル共通で、半透明の黒を細く引いています。これでタイル同士の境界に統一感が出ます。
void _borderPath(Canvas canvas, Path path) {
canvas.drawPath(path, Paint()
..color = Colors.black.withValues(alpha: 0.45)
..style = PaintingStyle.stroke
..strokeWidth = 1.2);
}
水タイルだけは「動く」
草・森・道路は静止画ですが、水タイルだけは前セクションの _t(経過時間)を使ってアニメーションさせています。波を sin で揺らすのがポイントです。
for (int i = 0; i < 3; i++) {
final yBase = -8.0 + i * 9.0; // 波を縦に3本ずらして配置
final xOff = math.sin(_t * 2.2 + i * 1.2) * 4.0; // 横方向に±4px揺らす
final path = Path()
..moveTo(-16 + xOff, yBase)
..quadraticBezierTo(-8 + xOff, yBase - 4, xOff, yBase)
..quadraticBezierTo(8 + xOff, yBase + 4, 16 + xOff, yBase);
canvas.drawPath(path, wave);
}
ここで効いているのが + i * 1.2 の部分です。これは波ごとに 位相をずらす ための項で、これがないと3本の波が完全に同期して一斉に同じ方向へ動いてしまい、「板が横にスライドしているような」不自然な見た目になります。波ごとにタイミングをずらすことで、自然な水の揺らぎになります。
このあたりの「複数オブジェクトを i * 定数 で少しずつずらして一斉に動かさない」というのは、建物の浮遊アニメでも同じテクニックを使っています。
描画順
タイルを1枚描けるようになっても、複数枚を正しい順番で描かないと立体感は出ません。ここで効いてくるのが、前セクションでも触れた gx + gy(和=奥行き)です。
なぜ描画順が問題になるのか
アイソメトリックでは、建物のように背の高いものは奥のタイルに「かぶさる」ように見せたい。ところが描く順番を間違えると、手前にあるはずの建物が奥の地面の下に潜り込んで、地面に埋まったような見た目になってしまいます。
3D なら Z バッファ(深度バッファ)が自動で前後関係を解決してくれますが、2D の Canvas にはそれがありません。そこで採用するのが Painter's Algorithm(画家のアルゴリズム) です。
考え方は油絵そのもので、奥のものから先に塗り、手前のものを後から重ね塗りするだけ。後から描いたものが前のものを隠すので、自然な奥行きになります。
「奥から手前」を gx + gy で決める
では「どのタイルが奥か」をどう判定するか。ここで座標変換の式がそのまま使えます。
isoY = (gx + gy) \cdot \frac{tileH}{2}
tileH/2 は正の定数なので、isoY(画面上の縦位置)は gx + gy に完全に比例します。つまり、
-
gx + gyが小さい →isoYが小さい → 画面の上(=奥) -
gx + gyが大きい →isoYが大きい → 画面の下(=手前)
なので gx + gy が小さい順に描けば、そのまま「奥から手前」の順になります。難しい深度計算は不要で、ただの足し算で奥行きが決まるわけです。
ソートで描画順を作る
実装では、全タイルを gx + gy の昇順でソートしてから world に追加しています。
final tiles = [
for (int y = 0; y < mapH; y++)
for (int x = 0; x < mapW; x++) (x, y),
]..sort((a, b) {
final diff = (a.$1 + a.$2) - (b.$1 + b.$2); // gx+gy の差(主キー)
return diff != 0 ? diff : a.$1 - b.$1; // 同値なら gx 昇順(副キー)
});
主キーが gx + gy、副キーが gx です。副キーが要るのは、gx + gy が同じタイル(たとえば (1,0) と (0,1))の順序を安定させるためです。これらは画面上で同じ高さに横並びになるので地形だけなら順序はどちらでもいいのですが、建物が混ざると順序が崩れて描画が乱れることがあるので、gx 昇順で固定しておきます。
Flame の priority と建物の +1
Flame では、コンポーネントの priority の昇順で render() が呼ばれます(大きいほど後=前面)。なので地形タイルには gx + gy をそのまま渡します。
ここで重要なのが、建物だけ priority を +1 することです。
// 地形タイル
priority: gx + gy
// 建物(同じ位置の地形より必ず1大きい)
priority: gx + gy + 1
建物は地面(ひし形)より縦に大きく、上方向にはみ出します。同じ gx + gy の地形と建物が並んだとき、+1 しておくことで建物が必ず地形の後に描かれ、地面の上にきちんと立って見えます。たった +1 ですが、これがないと建物が自分の足元のタイルにめり込みます。
まとめると
1. 全タイルを gx+gy 昇順でソート(=奥から手前)
2. 地形は priority = gx+gy
3. 建物は priority = gx+gy+1(地形の上に乗せる)
奥行きの判定が「座標の和」というシンプルな量に落ちているのが、アイソメトリックの気持ちいいところです。
疑似3Dライティング
ここまでは平らなタイルの話でしたが、街には建物が立ちます。建物に立体感を出すカギが、面ごとに明るさを変える疑似的なライティングです。実際には光源計算もシェーダーもなく、「面の向きごとに色を変えるだけ」で立体に見せています。
建物は3つの面でできている
アイソメトリックの建物(直方体)は、視点から見える面が 上面・左面(SW)・右面(SE) の3つです。これらをそれぞれ別の Path で描き、塗る色を少しずつ変えます。
ポイントは、3面を同じベース色から作ることです。バラバラの色を手で指定するのではなく、ベース色1つを基準に「暗い版・明るい版」を生成します。これで建物の種類を変えても、色のトーンが自然に揃います。
面の頂点は地面のひし形から作れる
各面の頂点は、座標変換のときに使ったひし形の頂点を、高さ bh(building height)だけ上にずらすだけで求まります。y は下が正なので「上に伸びる」は -bh 方向です。
たとえば左面は、ひし形の左頂点と下頂点(地面側)に、それを bh ぶん持ち上げた2点を加えた4頂点でできています。
Path _leftFace(double bh) => Path()
..moveTo(-tileW / 2, 0) // 地面:左頂点 (-48, 0)
..lineTo(0, tileH / 2) // 地面:下頂点 (0, +24)
..lineTo(0, tileH / 2 - bh) // 上端:下頂点を bh 持ち上げ
..lineTo(-tileW / 2, -bh) // 上端:左頂点を bh 持ち上げ
..close();
右面は左右対称、上面は地面のひし形をまるごと -bh 平行移動したものです。高さ変数 bh を変えるだけで、任意の高さの建物が作れるのが気持ちいいところです。
Color.lerp で明暗を作る
明るさの調整には Color.lerp(2色間の線形補間)を使います。Color.lerp(a, b, t) は t=0 で a、t=1 で b、その間を補間します。ベース色を黒に近づければ暗く、白に近づければ明るくなります。
final left = Color.lerp(_base, Colors.black, 0.30)!; // 30% 黒へ → 影
final right = Color.lerp(_base, Colors.white, 0.20)!; // 20% 白へ → 光
final top = _base; // ベース色そのまま
たとえばベースが青 0xFF60A5FA なら、左面は 0xFF4372AE(くすんだ暗い青)、右面は 0xFF80B7FB(明るい水色)になります。上面はそのまま。この3段の明度差が、平面の塗りつぶしを立体に見せます。
なぜこれで立体に見えるのか
人間の目は「同じ物体でも、光の当たる面は明るく、影になる面は暗い」と無意識に解釈します。なので面ごとに明暗差をつけるだけで、脳が勝手に「これは立体だ」と補完してくれます。
逆に3面を全部同じ色で塗ると、立方体が「のっぺりした六角形のシルエット」にしか見えません。明暗差こそが立体感の源です。
なお 30% や 20% という数値は、強すぎず弱すぎずの塩梅で選んでいます。たとえば左面を Color.lerp(_base, Colors.black, 1.0) にすると真っ黒なシルエットになって不自然です。「影らしく見える」ちょうどいい値が 0.30 あたり、というわけです。
ベース色1つで管理する利点
この設計のいいところは、建物の色を変えたいときベース色を1つ差し替えるだけで済むことです。このプロダクトでは建物タイプごとにベース色を変えていて、市役所は青、病院は赤、公園はティール…といった具合です。どのタイプも Color.lerp が自動で明暗のバランスを保つので、立体感を保ったまま色だけ違う建物が手軽に作れます。
まとめ
Flutter + Flame で、2D 描画だけのアイソメトリックな街を作る方法を一通り見てきました。最後に全体を振り返っておきます。
この記事を貫いていたのは、「3D 計算をせず、座標の計算とちょっとした塗り分けだけで立体に見せる」という考え方でした。やってきたことを並べると、こうなります。
-
座標変換 … グリッド座標
(gx, gy)を、軸ベクトルの合成でアイソメトリック座標に変換する。isoXは差、isoYは和で決まる -
Flame の update/render …
updateで状態を更新し、renderで描く。dtでフレームレート非依存にする - Path でひし形を描く … タイルの正体はただのひし形。「塗る → 中身 → 輪郭」の3段で描く
-
描画順(Painter's Algorithm) …
gx + gy(奥行き)順に奥から描けば、手前のものが自然に重なる - 疑似3Dライティング … 建物の3面をベース色から明暗で塗り分けるだけで立体に見える
-
アニメーション …
sinで往復させ、位相と周期をずらして「一斉に動かさない」ことで自然な賑わいを出す
面白いのは、これらの多くが gx + gy(和)と gx - gy(差)という、座標変換で出てきた2つの量にずっと支えられていたことです。奥行きの判定も、描画順も、カメラ位置も、もとを辿れば最初の変換式に行き着きます。最初の数学をきちんと押さえておくと、後がぜんぶ繋がってくるわけです。
アイソメトリックは「3D っぽい見た目」のわりに、中身は 2D の素直な計算の積み重ねでした。ゲームエンジンを持ち出さなくても、Flutter の Canvas と少しの数学でここまで作れます。ハッキング的に立体感を演出していくのは、やってみるとかなり楽しいので、興味があればぜひ手を動かしてみてください。








