Flutterであまり3Dオブジェクトを表示するニーズはないかもしれませんが、いくつかモデルを表示する方法があります。
3D表示用ライブラリを比較した上で、よくあるデモを作ります。
3Dモデル表示用ライブラリ
pub.devにある3Dモデルが表示できる代表的なライブラリを表にしてみました。
| パッケージ | バックエンド | 備考 |
|---|---|---|
| model_viewer_plus | model-viewer | 一番DL数が多い |
| flutter_3d_controller | model-viewer | 使いやすそう |
| o3d | model-viewer | |
| three_js | angle | |
| flame_3d | Canvas(たぶん) | flameのプラグイン。まだ実用レベルではなさそう |
| flutter_unity_widget | Unity | 今回は除外して考える |
three_jsを使ってみる
以下の理由で、今回はthree_jsを使ってみることにしました。
1. WebViewを使わない
three_jsでは、Web向けのビルドを除くと、レンダリングのバックエンドはangleというライブラリによるネイティブコードでのレンダリングになっています。
WebViewがダメという訳ではありませんが、Androidでmodel-viewer.jsを読み込むために追加する以下のような修正も不要になります。
+ <uses-permission android:name="android.permission.INTERNET"/>
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="example"
+ android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
2. 自由度が高い
複数のモデルを読み込んで同じシーン内に配置することができます。少し調べただけですが、他のライブラリでは出来なさそうでした。
逆に、拡大縮小などの操作を自分で実装する必要があります。
3. three.jsと似ている
three.jsをDartにコンバートするというプロジェクトなので当然ですが、使い方がほとんど一緒です。
数多のthree.jsのドキュメントを参考にできるというのは良い点かなと思います。
デメリットもある
バックエンドのangleが特定バージョンのCMakeを要求するため、Androidのビルドで一工夫が必要です。
- CMake 3.31系をインストールする
- ~/Library/Android/sdk/cmake/3.31.4ディレクトリを作成してシンボリックリンクを張る (macOSの場合)
また、2026/1/6時点でver0.2.6であるため、不具合があるかもしれません。
何か作ってみる
three_jsを使って以下のような実装してみようと思います。
- 3Dモデルを表示する
- ドラッグしてカメラを3Dモデルの周りで周回させる
- アニメーションを再生する
3Dモデルの回転ではなくカメラを回転させるのは、複数のモデルを読み込んで表示した上で全体を眺めたいと思ったためです。
ソースコード
ソースコードの他にアニメーションを含んだ3Dモデルが必要です。glbファイルをassetsから読み込めるようにしてください。
$ flutter pub add three_js
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:three_js/three_js.dart' as three;
/// カメラの注視点
final _lookAt = three.Vector3(0, 1, 0);
/// カメラと注視点の距離
const _cameraDistance = 2.0;
void main() {
runApp(const _App());
}
/// ルートウィジェット
class _App extends StatelessWidget {
const _App();
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(primarySwatch: Colors.blue),
home: const _Content(),
);
}
}
/// コンテンツ
class _Content extends StatefulWidget {
const _Content();
@override
State createState() => _ContentState();
}
class _ContentState extends State<_Content> {
/// 初期化フラグ
bool _initialized = false;
/// three_jsインスタンス
late three.ThreeJS threeJs;
/// カメラコントロール
late final _CameraControl _control;
/// アニメーション対象オブジェクト
late final _TargetObject _target;
@override
void initState() {
super.initState();
threeJs = three.ThreeJS(onSetupComplete: () {}, setup: setup);
}
@override
void dispose() {
threeJs.dispose();
three.loading.clear();
_control.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
AspectRatio(aspectRatio: 1, child: threeJs.build()),
if(_initialized)
Expanded(
child: _AnimationList(
animations: _target.animations,
onSelect: (anim) {
_target.playAnimation(anim);
},
),
),
],
),
);
}
Future<void> setup() async {
await _createScene(threeJs);
_createCamera(threeJs);
_control = _CameraControl(key: threeJs.globalKey, camera: threeJs.camera);
threeJs.addAnimationEvent((dt) {
_target.update(dt);
});
final renderer = threeJs.renderer;
if(renderer != null) {
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
}
setState(() {
_initialized = true;
});
}
/// シーンを作成する
Future<void> _createScene(three.ThreeJS threeJs) async {
final scene = three.Scene();
scene.background = three.Color(0, 0.8, 1, 1);
// ライトをセットアップ
final ambientLight = three.AmbientLight(0x00c0ff, 0.5);
scene.add(ambientLight);
final light = three.DirectionalLight(0xffffff, 1);
light.position.setValues(0, _radian(90), _radian(15));
light.castShadow = true;
scene.add(light);
// 床を追加
final floor = three.Mesh(
three.BoxGeometry(1.5, 0.5, 1.5),
three.MeshStandardMaterial(),
);
floor.position.setValues(0, -0.25, 0);
floor.receiveShadow = true;
scene.add(floor);
// アニメーション対象オブジェクトを読み込み
_target = await _TargetObject.load();
scene.add(_target.object);
threeJs.scene = scene;
}
/// カメラをセットアップする
void _createCamera(three.ThreeJS threeJs) {
threeJs.camera = three.PerspectiveCamera(
// fov
75,
// aspect
1.0,
// near clip
0.1,
// far clip
1000,
);
threeJs.camera.position.setValues(0, 1, 2);
threeJs.scene.add(threeJs.camera);
}
}
class _AnimationList extends StatelessWidget {
const _AnimationList({required this.animations, required this.onSelect});
final List<String> animations;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: animations.length,
itemBuilder: (context, index) {
final animName = animations[index];
return ListTile(
title: Text(animName),
onTap: () {
onSelect(animName);
},
);
},
);
}
}
/// アニメーション対象オブジェクト
class _TargetObject {
/// コンストラクタ
_TargetObject._(three.GLTFData gltfObject) {
object = gltfObject.scene;
// gltfObject内のメッシュに対してキャストシャドウを有効化
gltfObject.scene.traverse((obj) {
if (obj is three.Mesh) {
obj.castShadow = true;
}
});
/// アニメーションミキサーを作成
_mixer = three.AnimationMixer(gltfObject.scene);
_actions = {
for (var a in gltfObject.animations ?? [])
a.name: _mixer.clipAction(a)!
};
}
/// GLTFファイルからオブジェクトを読み込む
static Future<_TargetObject> load() async {
three.GLTFLoader loader = three.GLTFLoader(flipY: true).setPath('assets/');
final object = await loader.fromAsset(
// 適当なファイルに置き換える
'AnimationLibrary_Godot_Standard.glb',
);
return _TargetObject._(object!);
}
/// 現在のアニメーション名
String? _currentAnimation;
/// 表示オブジェクト
late three.Object3D object;
/// アニメーションミキサー
late three.AnimationMixer _mixer;
/// 利用可能なアニメーション一覧を取得する
late final Map<String, three.AnimationAction> _actions;
List<String> get animations => _actions.keys.toList();
/// アニメーションを更新する
void update(double deltaTime) {
_mixer.update(deltaTime);
}
/// 指定したアニメーションを再生する
void playAnimation(String animation) {
final action = _actions[animation];
if (action == null) {
return;
}
if(_currentAnimation != null) {
final current = _actions[_currentAnimation!]!;
current.setEffectiveWeight(0.0);
}
_currentAnimation = animation;
_mixer.activateAction(action);
}
}
/// カメラコントロール
class _CameraControl {
_CameraControl({
required GlobalKey<three.PeripheralsState> key,
required this.camera,
}) {
_state = key.currentState!;
_state.addEventListener(three.PeripheralType.pointerdown, onPointerDown);
_state.addEventListener(three.PeripheralType.pointermove, onPointerMove);
}
late final three.PeripheralsState _state;
final three.Camera camera;
/// 球面座標系の角度
double _theta = _radian(90); // 水平角度(azimuth)
double _phi = _radian(90);
/// 前回のポインタ位置(ドラッグ量の計算に使う)
double _previousX = 0.0;
double _previousY = 0.0;
/// Dispose the event listeners
void dispose() {
_state.removeEventListener(three.PeripheralType.pointerdown, onPointerDown);
_state.removeEventListener(three.PeripheralType.pointermove, onPointerMove);
}
/// ポインターダウンイベントハンドラ
void onPointerDown(dynamic event) {
final ev = event as three.WebPointerEvent;
_previousX = ev.clientX;
_previousY = ev.clientY;
}
/// ポインタームーブイベントハンドラ
void onPointerMove(dynamic event) {
final ev = event as three.WebPointerEvent;
final x = ev.clientX;
final y = ev.clientY;
final dx = _previousX - x;
final dy = y - _previousY;
_previousX = x;
_previousY = y;
// マウスの感度調整
const rotationSpeed = 0.01;
// 水平回転(Y軸周り)
_theta -= dx * rotationSpeed;
// 垂直回転(制限付き)
_phi -= dy * rotationSpeed;
// phiを0.1〜π-0.1の範囲に制限(真上・真下を避ける)
_phi = _phi.clamp(0.1, math.pi - 0.1);
// 球面座標からデカルト座標に変換
final newX = _cameraDistance * math.sin(_phi) * math.cos(_theta);
final newY = _cameraDistance * math.cos(_phi);
final newZ = _cameraDistance * math.sin(_phi) * math.sin(_theta);
// カメラ位置を更新(lookAtを中心に配置)
camera.position.setValues(
_lookAt.x + newX,
_lookAt.y + newY,
_lookAt.z + newZ,
);
// カメラを注視点に向ける
camera.lookAt(_lookAt);
}
}
/// 度をラジアンに変換する
double _radian(double degree) {
return degree * (math.pi / 180);
}
利用した3Dモデルについて
itch.ioで購入した以下のアセットを利用しています。
最後に
株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。
