3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterで3Dモデルを表示する(three_js)

Posted at

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モデルの回転ではなくカメラを回転させるのは、複数のモデルを読み込んで表示した上で全体を眺めたいと思ったためです。

こんな感じのものになります。
screen-20260106-190132.gif

ソースコード

ソースコードの他にアニメーションを含んだ3Dモデルが必要です。glbファイルをassetsから読み込めるようにしてください。

$ flutter pub add three_js
main.dart
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を使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?