diff --git a/.github/.cspell/flame_dictionary.txt b/.github/.cspell/flame_dictionary.txt index ada21b99707..361a530893e 100644 --- a/.github/.cspell/flame_dictionary.txt +++ b/.github/.cspell/flame_dictionary.txt @@ -5,6 +5,7 @@ Audioplayers # A Flutter plugin to play multiple simultaneously audio files http BGUG # Break Guns Using Gems, a game by BlueFire https://github.com/bluefireteam/bgug Bodymovin # An After Effects extension to export Lottie animations https://aescripts.com/bodymovin/ emberquest # Ember Quest, our platformer tutorial game +fscene # The website of Flutter Scene https://fscene.dev Hermione # A character from the book Harry Potter Kenobi # Eminent Jedi Master, General of the Republic Army, Obi-Wan Kenobi Nakama # An open-source server designed to power modern games and apps https://github.com/Allan-Nava/nakama-flutter diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index f17b6d1723c..bdf170b2099 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -13,6 +13,7 @@ Klingsbo # github.com/spydon Kornél # github.com/kornellapu lapu # Artist of reference art (basic shader tutorial) Lapu # github.com/kornellapu +Lousberg # kaylousberg.itch.io luan # github.com/luanpotter luanpotter # github.com/luanpotter Lukas # github.com/spydon @@ -26,3 +27,4 @@ Tellinghuisen # Author of Statistical Error Propagation (2001) videon # github.com/markvideon wolfenrain # github.com/wolfenrain xaha # github.com/xvrh +Zuno # pixelgameart.org diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 61205af8515..c85561c4425 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -30,7 +30,9 @@ jobs: flutter-version: ${{env.FLUTTER_MIN_VERSION}} - uses: bluefireteam/melos-action@v3 # flame_3d always requires the latest stable, since flutter_gpu is still - # unstable, so it is only analyzed in the analyze-latest job. + # unstable, and flame_3d_component requires the Flutter version that + # flutter_scene needs, so they are only analyzed in the analyze-latest + # job (the glob covers both). - name: "Analyze with lowest supported version" run: melos exec --ignore="flame_3d*" -- dart analyze --fatal-infos . diff --git a/README.md b/README.md index 0b88d768a14..e0a98ce659b 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ helpers, in order to make integrations seamless. Flame officially provides bridge libraries to the following packages: +- [flame_3d_component][flame_3d_component] for [flutter_scene][flutter_scene]: Add 3D + components to the component tree. - [flame_audio][flame_audio] for [AudioPlayers][audioplayers]: Play multiple audio files simultaneously. - [flame_behavior_tree][flame_behavior_tree] for [behavior_tree][behavior_tree]: Drive game logic @@ -220,6 +222,8 @@ via an issue, GitHub discussion, or reach out to the team either using the [flame_network_assets]: https://github.com/flame-engine/flame/tree/main/packages/flame_network_assets [flame_rive]: https://github.com/flame-engine/flame/tree/main/packages/flame_rive [rive]: https://rive.app/ +[flame_3d_component]: https://github.com/flame-engine/flame/tree/main/packages/flame_3d_component +[flutter_scene]: https://github.com/bdero/flutter_scene [flame_svg]: https://github.com/flame-engine/flame/tree/main/packages/flame_svg [flutter_svg]: https://github.com/dnfield/flutter_svg [flame_texturepacker]: https://github.com/flame-engine/flame/tree/main/packages/flame_texturepacker diff --git a/doc/bridge_packages/bridge_packages.md b/doc/bridge_packages/bridge_packages.md index e45dad0d8d8..07e75e86fc7 100644 --- a/doc/bridge_packages/bridge_packages.md +++ b/doc/bridge_packages/bridge_packages.md @@ -5,6 +5,11 @@ Uses Flutter GPU / Impeller low-level level access to provide an ergonomic and **very experimental** 3D rendering engine on top of Flame. +:::{package} flame_3d_component + +Add 3D components to the Flame component tree (bridge package for [flutter_scene]). +::: + :::{package} flame_audio Play multiple audio files simultaneously (bridge package for [AudioPlayers]). @@ -110,11 +115,13 @@ Load Typled sprite atlases with edge-repeated padding (bridge package for [Typle [Tiled]: https://www.mapeditor.org/ [Typled]: https://pub.dev/packages/typled [flutter_svg]: https://github.com/dnfield/flutter_svg +[flutter_scene]: https://github.com/bdero/flutter_scene ```{toctree} :hidden: +flame_3d_component flame_audio flame_behaviors flame_bloc diff --git a/doc/bridge_packages/flame_3d_component/component_3d.md b/doc/bridge_packages/flame_3d_component/component_3d.md new file mode 100644 index 00000000000..1a89d22b8e5 --- /dev/null +++ b/doc/bridge_packages/flame_3d_component/component_3d.md @@ -0,0 +1,121 @@ +# Flame 3D Component + +flame_3d_component lets you put a 3D object, rendered by the +[flutter_scene](https://pub.dev/packages/flutter_scene) engine, into the regular 2D component tree of +a Flame game. The `Component3D` is a `PositionComponent`, so it can be positioned, sized, scaled, +rotated, anchored, and layered together with your sprites and other 2D components. + +All the 3D rendering is done by [Flutter Scene](https://fscene.dev), the realtime 3D engine for +Flutter created and maintained by [Brandon DeRosier (bdero)](https://github.com/bdero). Big thanks +to him for building it. The engine's own documentation, guides, and API reference live at +[fscene.dev](https://fscene.dev), and everything there applies to the scene inside a `Component3D`. + + +## Scope + +This package is for when you want a 3D object in an otherwise 2D Flame game, for example a spinning +model in the menu, a 3D character on top of a 2D background, or a dice rolling across a board. + +It is not a 3D game engine and it is not meant to replace one. The scene graph, cameras, materials, +lighting, animation, model loading, and physics are all handled by flutter_scene. This package only +draws a flutter_scene scene into a Flame component. If you are building a full 3D game, use +flutter_scene directly, or the experimental [flame_3d](https://pub.dev/packages/flame_3d) package, +which is a separate effort with a different purpose. + + +## Installation + +3D scene support is provided by the `flame_3d_component` bridge package, be sure to put it in your pubspec +file to use it. + +If you want to know more about the installation visit +[flame_3d_component on pub.dev](https://pub.dev/packages/flame_3d_component/install). + +flutter_scene renders through Flutter GPU, which has to be enabled on every native platform. While +developing you can pass a flag: + +```sh +flutter run --enable-flutter-gpu +``` + +See the [flutter_scene README](https://pub.dev/packages/flutter_scene#enable-flutter-gpu) for how to +enable it permanently per platform. On the web nothing needs to be enabled. + + +## How to use flame_3d_component + +Import `package:flame_3d_component/flame_3d_component.dart`, which also re-exports the +flutter_scene API, and add a `Component3D` to your game. Everything added to its `root` node is +rendered through the component's `camera`: + +```dart +class SpinningCube extends Component3D { + SpinningCube() + : super( + size: Vector2(400, 300), + camera: PerspectiveCamera(position: Vector3(2, 2, -4)), + ); + + late final Node cube; + double _rotation = 0; + + @override + Future onLoad() async { + await super.onLoad(); + cube = Node( + mesh: Mesh(CuboidGeometry(Vector3.all(1)), PhysicallyBasedMaterial()), + ); + root.add(cube); + } + + @override + void update(double dt) { + super.update(dt); + _rotation += dt; + cube.rotation = Quaternion.axisAngle(Vector3(0, 1, 0), _rotation); + } +} +``` + +Geometry and materials need the engine's shader libraries, so create them after `super.onLoad()` +has completed, like in the example above. The component's `onLoad` waits for +`Scene.initializeStaticResources`. + +Models can be loaded with the regular flutter_scene loaders, for example `loadScene` for assets +converted by the flutter_scene asset pipeline or `Node.fromGlbAsset` for a glTF binary that is +parsed at runtime: + +```dart +final model = await Node.fromGlbAsset('assets/models/dash.glb'); +root.add(model); +``` + +The scene's clock follows Flame's clock: the `dt` passed to the component's `update` is handed to +the scene right before it renders, so pausing the game also pauses animations and node components +inside the scene. + +An existing `Scene` can also be shared with the component, which is useful when a scene is built +outside of the component tree: + +```dart +final sceneComponent = Component3D( + scene: myScene, + camera: myCamera, + size: Vector2(400, 300), +); +``` + + +## Name clashes + +Both Flame and flutter_scene define classes named `Component` and `Sprite`, so +`flame_3d_component` does not re-export those two. Import `package:flutter_scene/scene.dart` +directly if you need the flutter_scene versions, for example to attach a flutter_scene component +to a node. + + +## Resolution + +By default the scene is rasterized at the device pixel ratio multiplied by the current zoom of the +canvas, so it stays sharp when the Flame camera zooms in. Pass `pixelRatio` to the component to +override this, for example to render at a lower resolution for performance. diff --git a/doc/bridge_packages/flame_3d_component/flame_3d_component.md b/doc/bridge_packages/flame_3d_component/flame_3d_component.md new file mode 100644 index 00000000000..ab47a2597a0 --- /dev/null +++ b/doc/bridge_packages/flame_3d_component/flame_3d_component.md @@ -0,0 +1,5 @@ +# flame_3d_component + +```{toctree} +Overview +``` diff --git a/packages/flame_3d_component/CHANGELOG.md b/packages/flame_3d_component/CHANGELOG.md new file mode 100644 index 00000000000..30fd8bf53d5 --- /dev/null +++ b/packages/flame_3d_component/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + + - **FEAT**: Add the `flame_3d_component` bridge package with a `Component3D` that renders a `flutter_scene` scene inside the Flame component tree. diff --git a/packages/flame_3d_component/LICENSE b/packages/flame_3d_component/LICENSE new file mode 100644 index 00000000000..755cc7d230b --- /dev/null +++ b/packages/flame_3d_component/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/flame_3d_component/README.md b/packages/flame_3d_component/README.md new file mode 100644 index 00000000000..7c603bf9229 --- /dev/null +++ b/packages/flame_3d_component/README.md @@ -0,0 +1,55 @@ + +

+ + flame + +

+ +

+Adds 3D components (rendered by the flutter_scene engine) to the component tree of your Flame games. +

+ +

+ + + + +

+ +--- + + + + +# flame_3d_component + +Package to add 3D objects, rendered by +[flutter_scene](https://pub.dev/packages/flutter_scene), to the regular 2D +component tree of a Flame game. + +All the 3D rendering is done by [Flutter Scene](https://fscene.dev), the +realtime 3D engine for Flutter created and maintained by +[Brandon DeRosier (bdero)](https://github.com/bdero). Big thanks to him for +building it, and for the Flutter GPU work that makes it possible. Head over to +[fscene.dev](https://fscene.dev) for the engine's own documentation, guides, +and API reference. + + +## What this package is, and what it is not + +This package is for when you want a 3D object in an otherwise 2D Flame game: a +spinning model in the menu, a 3D character on top of a 2D background, a dice +rolling across a board, a product preview inside your game UI. The `Component3D` +is a normal `PositionComponent`, so it is positioned, sized, scaled, rotated, +anchored, and layered exactly like a sprite, and it participates in Flame's +update and render loop like any other component. + +It is **not** a 3D game engine and it is not meant to replace one. All of the +3D work, such as the scene graph, cameras, materials, lighting, animation, +model loading, and physics, is done by flutter_scene. This package only draws a +flutter_scene scene into a Flame component, nothing more. If you are building a +full 3D game, use flutter_scene directly, or the experimental +[flame_3d](https://pub.dev/packages/flame_3d) package, which is a separate +effort with a different purpose. + +More [here](https://docs.flame-engine.org/main/bridge_packages/flame_3d_component/flame_3d_component.html). diff --git a/packages/flame_3d_component/analysis_options.yaml b/packages/flame_3d_component/analysis_options.yaml new file mode 100644 index 00000000000..bf0c5679af0 --- /dev/null +++ b/packages/flame_3d_component/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml + +linter: + rules: + - public_member_api_docs + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flame_3d_component/example/.gitignore b/packages/flame_3d_component/example/.gitignore new file mode 100644 index 00000000000..0a8e98e91fa --- /dev/null +++ b/packages/flame_3d_component/example/.gitignore @@ -0,0 +1,49 @@ +linux/ +macos/ +web/ +windows/ + +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/flame_3d_component/example/README.md b/packages/flame_3d_component/example/README.md new file mode 100644 index 00000000000..df048fa58e5 --- /dev/null +++ b/packages/flame_3d_component/example/README.md @@ -0,0 +1,20 @@ +# Example of a 3D object between 2D components + +An animated 3D skeleton, loaded from a glTF file and rendered by +`flutter_scene` through a `Component3D`, placed between two regular Flame +layers: a `ParallaxComponent` scrolling behind it and a +`SpriteAnimationComponent` walking back and forth in front of it. + +Rendering goes through Flutter GPU, which has to be enabled on native +platforms: + +```sh +flutter run --enable-flutter-gpu +``` + +On the web no flag is needed. + +The skeleton model is from the +[KayKit Skeletons](https://kaylousberg.itch.io/kaykit-skeletons) pack by Kay +Lousberg (CC0), and the parallax art is by Luis Zuno (CC0), see +`assets/images/parallax/license.txt`. diff --git a/packages/flame_3d_component/example/analysis_options.yaml b/packages/flame_3d_component/example/analysis_options.yaml new file mode 100644 index 00000000000..52c18920db4 --- /dev/null +++ b/packages/flame_3d_component/example/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml + +linter: + rules: + public_member_api_docs: false + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flame_3d_component/example/assets/images/ember.png b/packages/flame_3d_component/example/assets/images/ember.png new file mode 100644 index 00000000000..a5858eb5c13 Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/ember.png differ diff --git a/packages/flame_3d_component/example/assets/images/parallax/bg.png b/packages/flame_3d_component/example/assets/images/parallax/bg.png new file mode 100644 index 00000000000..eecdea1ccf9 Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/parallax/bg.png differ diff --git a/packages/flame_3d_component/example/assets/images/parallax/foreground-trees.png b/packages/flame_3d_component/example/assets/images/parallax/foreground-trees.png new file mode 100644 index 00000000000..38280bc815f Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/parallax/foreground-trees.png differ diff --git a/packages/flame_3d_component/example/assets/images/parallax/license.txt b/packages/flame_3d_component/example/assets/images/parallax/license.txt new file mode 100755 index 00000000000..2661e42868d --- /dev/null +++ b/packages/flame_3d_component/example/assets/images/parallax/license.txt @@ -0,0 +1,6 @@ +Artwork created by Luis Zuno (@ansimuz) + +License (CC0) You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission: http://creativecommons.org/publicdomain/zero/1.0/ + +Get more resources at pixelgameart.org, Spread the word! + diff --git a/packages/flame_3d_component/example/assets/images/parallax/mountain-far.png b/packages/flame_3d_component/example/assets/images/parallax/mountain-far.png new file mode 100644 index 00000000000..21ea9ee26b1 Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/parallax/mountain-far.png differ diff --git a/packages/flame_3d_component/example/assets/images/parallax/mountains.png b/packages/flame_3d_component/example/assets/images/parallax/mountains.png new file mode 100644 index 00000000000..fd340365e7c Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/parallax/mountains.png differ diff --git a/packages/flame_3d_component/example/assets/images/parallax/trees.png b/packages/flame_3d_component/example/assets/images/parallax/trees.png new file mode 100644 index 00000000000..4c0a9aba862 Binary files /dev/null and b/packages/flame_3d_component/example/assets/images/parallax/trees.png differ diff --git a/packages/flame_3d_component/example/assets/models/skeleton.glb b/packages/flame_3d_component/example/assets/models/skeleton.glb new file mode 100644 index 00000000000..769e85c9e4c Binary files /dev/null and b/packages/flame_3d_component/example/assets/models/skeleton.glb differ diff --git a/packages/flame_3d_component/example/lib/main.dart b/packages/flame_3d_component/example/lib/main.dart new file mode 100644 index 00000000000..e327f6d8336 --- /dev/null +++ b/packages/flame_3d_component/example/lib/main.dart @@ -0,0 +1,134 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/parallax.dart'; +import 'package:flame_3d_component/flame_3d_component.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + runApp(GameWidget(game: ExampleGame())); +} + +/// A 3D skeleton walking between two regular 2D Flame layers: a parallax +/// background behind it and an animated sprite in front of it. +class ExampleGame extends FlameGame { + @override + Future onLoad() async { + final parallax = await loadParallaxComponent( + [ + ParallaxImageData('assets/images/parallax/bg.png'), + ParallaxImageData('assets/images/parallax/mountain-far.png'), + ParallaxImageData('assets/images/parallax/mountains.png'), + ParallaxImageData('assets/images/parallax/trees.png'), + ParallaxImageData('assets/images/parallax/foreground-trees.png'), + ], + baseVelocity: Vector2(20, 0), + velocityMultiplierDelta: Vector2(1.8, 1.0), + filterQuality: FilterQuality.none, + ); + camera.backdrop.add(parallax); + + world.add(Skeleton()); + + camera.viewport.add(Ember()); + camera.viewport.add( + TextComponent( + text: + 'A flutter_scene model between two Flame layers, ' + 'drag to rotate it', + position: Vector2.all(16), + ), + ); + } +} + +/// The 3D object. It fills the whole game area and renders with a transparent +/// background, so the parallax behind it stays visible. Dragging rotates the +/// model. +class Skeleton extends Component3D with DragCallbacks { + Skeleton() + : super( + anchor: Anchor.center, + // The model is about 2.2 units tall with its feet at the origin. + camera: PerspectiveCamera( + position: Vector3(0, 1.8, 5.2), + target: Vector3(0, 1.0, 0), + ), + ); + + /// The imported model is animated, and the animation owns the transform + /// of the node it is bound to, so the rotation is applied to this parent. + final Node pivot = Node(); + double _yaw = 0; + double _pitch = 0; + + @override + Future onLoad() async { + await super.onLoad(); + // Model by Kay Lousberg, https://kaylousberg.itch.io/kaykit-skeletons + final model = await Node.fromGlbAsset('assets/models/skeleton.glb'); + pivot.add(model); + root.add(pivot); + + final walk = model.findAnimationByName('Walking_A'); + if (walk != null) { + model.createAnimationClip(walk) + ..loop = true + ..play(); + } + } + + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + this.size = size; + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + _yaw += event.localDelta.x * 0.01; + _pitch = (_pitch + event.localDelta.y * 0.01).clamp(-0.5, 0.5); + pivot.rotation = + Quaternion.axisAngle(Vector3(0, 1, 0), _yaw) * + Quaternion.axisAngle(Vector3(1, 0, 0), _pitch); + } +} + +/// A regular sprite animation walking back and forth across the model, in +/// front of it. +class Ember extends SpriteAnimationComponent with HasGameRef { + Ember() + : super( + size: Vector2.all(96), + anchor: Anchor.bottomCenter, + ); + + static const _secondsPerCrossing = 4.0; + double _time = 0; + + @override + Future onLoad() async { + animation = await gameRef.loadSpriteAnimation( + 'assets/images/ember.png', + SpriteAnimationData.sequenced( + amount: 3, + textureSize: Vector2.all(16), + stepTime: 0.15, + ), + ); + } + + @override + void update(double dt) { + super.update(dt); + _time += dt; + // Walk back and forth between the screen edges, always measured + // against the current game size so a resize never pushes it off screen. + final phase = (_time / _secondsPerCrossing) % 2; + final progress = phase < 1 ? phase : 2 - phase; + final gameSize = gameRef.size; + final travel = (gameSize.x - size.x).clamp(0.0, double.infinity); + position = Vector2(size.x / 2 + progress * travel, gameSize.y * 0.7); + } +} diff --git a/packages/flame_3d_component/example/pubspec.yaml b/packages/flame_3d_component/example/pubspec.yaml new file mode 100644 index 00000000000..a7fd5cf8151 --- /dev/null +++ b/packages/flame_3d_component/example/pubspec.yaml @@ -0,0 +1,29 @@ +name: flame_3d_component_example +resolution: workspace +description: Example app of how to use the flame_3d_component package + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.47.0" + +dependencies: + flame: ^2.0.0-dev.0 + flame_3d_component: ^0.1.0 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.4-dev.0 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - assets/images/ + - assets/images/parallax/ + - assets/models/ diff --git a/packages/flame_3d_component/lib/flame_3d_component.dart b/packages/flame_3d_component/lib/flame_3d_component.dart new file mode 100644 index 00000000000..a2ef8f583c7 --- /dev/null +++ b/packages/flame_3d_component/lib/flame_3d_component.dart @@ -0,0 +1,16 @@ +/// Renders [flutter_scene](https://pub.dev/packages/flutter_scene) 3D scenes +/// as regular components in the Flame component tree. +/// +/// The main entry point is `Component3D`. Build the scene graph with the +/// flutter_scene API, which this library re-exports, and add the component +/// anywhere in your game like any other `PositionComponent`. +/// +/// The names `Component` and `Sprite` exist in both Flame and flutter_scene, +/// so this library re-exports flutter_scene without them. Import +/// `package:flutter_scene/scene.dart` directly when you need the flutter_scene +/// versions. +library; + +export 'package:flutter_scene/scene.dart' hide Component, Sprite; + +export 'src/component_3d.dart'; diff --git a/packages/flame_3d_component/lib/src/component_3d.dart b/packages/flame_3d_component/lib/src/component_3d.dart new file mode 100644 index 00000000000..36c780bc7d7 --- /dev/null +++ b/packages/flame_3d_component/lib/src/component_3d.dart @@ -0,0 +1,136 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_scene/scene.dart' + show Camera, Node, PerspectiveCamera, Scene; + +/// Renders a flutter_scene [Scene] as a regular Flame [PositionComponent]. +/// +/// The component draws [camera]'s view of [scene] into its own [size], so it +/// can be positioned, scaled, rotated, anchored, and layered with the other +/// components in the tree. Build the scene graph by adding [Node]s to [root] +/// or by working with [scene] directly. +/// +/// The scene's clock is driven by Flame: the time passed to [update] is handed +/// to [Scene.update] right before the scene is rendered, so pausing the game +/// also pauses animations and node components inside the scene. +/// +/// [onLoad] waits for [Scene.initializeStaticResources], which loads the +/// engine's shader libraries. Geometry and materials rely on those, so create +/// them after `super.onLoad()` in a subclass, or after awaiting +/// [Scene.initializeStaticResources] yourself. +/// +/// Rendering goes through Flutter GPU, which has to be enabled on every +/// native platform (for example with `flutter run --enable-flutter-gpu`). +/// Frames are skipped while the engine is not ready to render. +class Component3D extends PositionComponent { + /// Creates a [Component3D]. + /// + /// A new empty [Scene] is created on first use when [scene] is omitted, and + /// a default [PerspectiveCamera] is used when [camera] is omitted. + Component3D({ + this._scene, + Camera? camera, + this.pixelRatio, + super.position, + super.size, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + super.key, + }) : camera = camera ?? PerspectiveCamera(); + + Scene? _scene; + + /// The scene that is rendered by this component. + /// + /// Created on first access when no scene was passed to the constructor. + Scene get scene => _scene ??= Scene(); + + /// The camera whose view of [scene] is rendered. + Camera camera; + + /// The logical to physical pixel multiplier for the offscreen render + /// target. + /// + /// When null, the device pixel ratio multiplied by the current zoom of the + /// canvas is used, so the scene stays sharp when the Flame camera zooms in. + /// Set a smaller value to trade fidelity for performance, or a larger one + /// to render at a higher resolution than the screen. + double? pixelRatio; + + /// The root [Node] of [scene]. + Node get root => scene.root; + + double _pendingDelta = 0; + + /// Whether the flutter_scene engine is ready to render. + /// + /// Rendering is skipped while this is false. + @protected + bool get isReadyToRender => Scene.isReadyToRender; + + @override + Future onLoad() async { + await Scene.initializeStaticResources(); + } + + @override + void update(double dt) { + _pendingDelta += dt; + } + + @override + void render(Canvas canvas) { + final viewport = size.toRect(); + if (viewport.isEmpty || !isReadyToRender) { + return; + } + final dt = _pendingDelta; + _pendingDelta = 0; + updateScene(dt); + renderScene(canvas, viewport, pixelRatio ?? _effectivePixelRatio(canvas)); + } + + /// Advances [scene] by [dt] seconds, the time that has passed in Flame + /// since the scene was last rendered. + @protected + void updateScene(double dt) { + scene.update(dt); + } + + /// Renders [camera]'s view of [scene] into [viewport] on [canvas], with the + /// offscreen render target sized by [pixelRatio]. + /// + /// Override this to render the scene differently, for example with + /// [Scene.renderViews] to draw several cameras into the viewport. + @protected + void renderScene(Canvas canvas, Rect viewport, double pixelRatio) { + scene.render(camera, canvas, viewport: viewport, pixelRatio: pixelRatio); + } + + /// Computes the pixel ratio from the device pixel ratio and how much the + /// canvas is currently zoomed, so the offscreen target matches the + /// resolution the scene is composited at. + double _effectivePixelRatio(Canvas canvas) { + final devicePixelRatio = + PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 1.0; + final destination = canvas.getDestinationClipBounds(); + final local = canvas.getLocalClipBounds(); + if (destination.isEmpty || local.isEmpty) { + return devicePixelRatio; + } + final zoom = max( + destination.width / local.width, + destination.height / local.height, + ); + if (!zoom.isFinite || zoom <= 0) { + return devicePixelRatio; + } + return devicePixelRatio * zoom; + } +} diff --git a/packages/flame_3d_component/pubspec.yaml b/packages/flame_3d_component/pubspec.yaml new file mode 100644 index 00000000000..719bf022971 --- /dev/null +++ b/packages/flame_3d_component/pubspec.yaml @@ -0,0 +1,33 @@ +name: flame_3d_component +resolution: workspace +description: Add 3D components backed by flutter_scene to the Flame component tree +version: 0.1.0 +homepage: https://github.com/flame-engine/flame/tree/main/packages/flame_3d_component +funding: + - https://opencollective.com/blue-fire + - https://github.com/sponsors/bluefireteam + - https://patreon.com/bluefireoss +topics: + - flame + - 3d + - flutter-scene + - gltf + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.47.0" + +dependencies: + flame: ^2.0.0-dev.0 + flutter: + sdk: flutter + flutter_scene: ^0.23.0 + vector_math: ^2.1.4 + +dev_dependencies: + dartdoc: ^9.0.0 + flame_lint: ^1.4.4-dev.0 + flame_test: ^3.0.0-dev.0 + flutter_test: + sdk: flutter + test: any diff --git a/packages/flame_3d_component/test/component_3d_test.dart b/packages/flame_3d_component/test/component_3d_test.dart new file mode 100644 index 00000000000..7dc0c298e54 --- /dev/null +++ b/packages/flame_3d_component/test/component_3d_test.dart @@ -0,0 +1,144 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame_3d_component/flame_3d_component.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A [Component3D] that records the scene calls instead of touching the GPU, +/// which is not available under `flutter test`. +class _RecordingComponent3D extends Component3D { + _RecordingComponent3D({ + this.ready = true, + super.camera, + super.size, + super.pixelRatio, + }); + + final bool ready; + final List updates = []; + final List viewports = []; + final List pixelRatios = []; + + @override + bool get isReadyToRender => ready; + + @override + Future onLoad() async {} + + @override + void updateScene(double dt) { + updates.add(dt); + } + + @override + void renderScene(Canvas canvas, Rect viewport, double pixelRatio) { + viewports.add(viewport); + pixelRatios.add(pixelRatio); + } +} + +void main() { + group('Component3D', () { + test('uses a perspective camera by default', () { + final component = _RecordingComponent3D(); + expect(component.camera, isA()); + }); + + test('keeps a provided camera', () { + final camera = PerspectiveCamera(); + final component = _RecordingComponent3D(camera: camera); + expect(component.camera, same(camera)); + }); + + testWithFlameGame( + 'hands the accumulated update time to the scene when rendering', + (game) async { + final component = _RecordingComponent3D( + size: Vector2(200, 100), + pixelRatio: 1, + ); + await game.ensureAdd(component); + + component.update(0.25); + component.update(0.5); + expect(component.updates, isEmpty); + + component.render(_canvas()); + expect(component.updates, [0.75]); + + component.render(_canvas()); + expect(component.updates, [0.75, 0]); + }, + ); + + testWithFlameGame('renders into a viewport matching its size', ( + game, + ) async { + final component = _RecordingComponent3D( + size: Vector2(200, 100), + pixelRatio: 1.5, + ); + await game.ensureAdd(component); + + component.render(_canvas()); + + expect(component.viewports, [const Rect.fromLTWH(0, 0, 200, 100)]); + expect(component.pixelRatios, [1.5]); + }); + + testWithFlameGame('skips rendering when the size is empty', (game) async { + final component = _RecordingComponent3D(); + await game.ensureAdd(component); + + component.update(1); + component.render(_canvas()); + + expect(component.updates, isEmpty); + expect(component.viewports, isEmpty); + }); + + testWithFlameGame('skips rendering while the engine is not ready', ( + game, + ) async { + final component = _RecordingComponent3D( + ready: false, + size: Vector2.all(100), + ); + await game.ensureAdd(component); + + component.update(1); + component.render(_canvas()); + + expect(component.updates, isEmpty); + expect(component.viewports, isEmpty); + }); + + test('derives the pixel ratio from the canvas zoom', () { + final component = _RecordingComponent3D(size: Vector2.all(100)); + final recorder = PictureRecorder(); + final canvas = Canvas(recorder) + ..clipRect(const Rect.fromLTWH(0, 0, 400, 400)) + ..scale(2); + + component.render(canvas); + recorder.endRecording().dispose(); + + final devicePixelRatio = + PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 1.0; + expect(component.pixelRatios, [devicePixelRatio * 2]); + }); + + test('falls back to the device pixel ratio without a clip', () { + final component = _RecordingComponent3D(size: Vector2.all(100)); + + component.render(_canvas()); + + final devicePixelRatio = + PlatformDispatcher.instance.implicitView?.devicePixelRatio ?? 1.0; + expect(component.pixelRatios, [devicePixelRatio]); + }); + }); +} + +Canvas _canvas() => Canvas(PictureRecorder());