跪拜 Guibai
← Back to the summary

A Former Impeller Engineer Built a Full 3D Engine Inside Flutter

Recently, a third-party implementation called bdero/flutter_scene blew me away. For example, the Minecraft-like game effect shown below was made by the author using Flutter Scene. It's entirely based on Flutter GPU/Impeller content, without introducing any other external engine, and without any PlatformView:

ezgif-4a906bd00436315f

In the entire Flutter Scene implementation, it manages its own scene graph, materials, animations, lighting, shadows, RenderGraph, GPU resources, draw sorting, spatial culling, and glTF importing. The underlying layer depends on the native flutter_gpu, and it supports all Flutter platforms, except for the Web platform which uses an API-compatible WebGL2 backend. It also supports Custom embedders, meaning it can even support HarmonyOS.

In terms of implementation, it has already far surpassed any official GPU Demo effect. For example, the project has PBR, IBL, shadows, SSAO, SSR, depth of field, post-processing, animation blending, LOD, instancing, RenderTexture, 3D Gaussian Splatting, physics, Flutter Widgets in 3D scenes, and even supports a scene editor and MCP Agent operation interface.

Looking at it simply, the entire project can be divided into six layers:

Layer Main Modules Role
Flutter Presentation Layer SceneView, Semantics, Pointer, WidgetTexture Frame loop, Flutter compositing, input, accessibility
Scene Logic Layer Scene, Node, Component, Camera Scene graph, behavior, animation, physics lifecycle
Render Data Layer RenderScene, RenderItem, BVH Flat draw list and spatial index
Render Scheduling Layer RenderGraph, various RenderPasses Shadows, depth, SSAO, SSR, PBR, post-processing
Draw Encoding Layer SceneEncoder, Material, Geometry Pipeline, sorting, binding, Draw Call
GPU Backend Layer Flutter GPU/Impeller, WebGL2 shim Actually submitting GPU commands

In addition, there are three peripheral system supports:

And it's not just flutter_scene; the project also includes flutter_scene_rapier, flutter_scene_box3d, an editor, MCP, and a standalone editor App. The level of completeness is truly surprising.

And the usage is also very convenient, accessible through the SceneView entry point. SceneView internally has a Flutter Ticker, but it does not rebuild the entire Widget subtree every frame. Instead, it triggers a repaint only on the drawing layer through a ChangeNotifier:

That is, SceneView is not a PlatformView, nor is it a native Surface layered on top of Flutter. The result of SceneView actually participates in Flutter's Canvas compositing and can be overlaid, clipped, and laid out with ordinary Flutter UI.

final scene = Scene();

class SpinComponent extends Component {
  SpinComponent(this.speed);

  final double speed;

  @override
  void update(double deltaSeconds) {
    node.localTransform *=
        vm.Matrix4.rotationY(speed * deltaSeconds);
  }
}

void buildScene() {
  final mesh = Mesh(
    CuboidGeometry(vm.Vector3.all(1)),
    PhysicallyBasedMaterial(),
  );

  scene.add(
    Node(mesh: mesh)..addComponent(SpinComponent(1.2)),
  );
}

Widget buildView() {
  return SceneView(
    scene,
    camera: PerspectiveCamera(
      position: vm.Vector3(0, 2, -5),
      target: vm.Vector3.zero(),
    ),
    warmUp: true,
  );
}

Then, each RenderView gets its own off-screen texture. After the texture size is scaled according to the actual situation, it is composited onto the Flutter Canvas. Once the 3D scene completes GPU rendering, the texture is converted into a Flutter drawable image via asImage(), and then composited to the target area by Canvas.drawImageRect.

So it has a very obvious super advantage; it can naturally support:

Of course, the relative cost is that each screen View has an independent off-screen target and RenderGraph. Multiple Cameras cannot be automatically reused in a single draw, and different Views do not share Render Targets within a frame.

Then, the scene graph in the project mainly relies on Node and Component. Where Node is only responsible for hierarchy and transformation. Node is a typical retained-mode scene graph node, containing:

Transform uses a dirty flag mechanism. When a parent node is modified, the world transform of the child tree is marked invalid, and it is only recalculated when actually accessed or rendered. The world AABB also has a separate cache and propagates invalidation up to ancestors.

Simply put, if you directly modify Matrix4 in place, the engine doesn't know the matrix changed, and you must manually call markTransformDirty():

node.localTransform.translate(...);
node.markTransformDirty();

Then Component is a behavior and capability plugin. Component is similar to a component in a lightweight ECS, but needs to be attached to a traditional Node, with a complete lifecycle:

onAttach
  ↓
onMount
  ↓
onLoad (async)
  ↓
fixedUpdate / update
  ↓
onUnmount
  ↓
onDetach

For example, update will not execute before onLoad finishes. The physics system can also use fixedUpdate to run at a fixed time step.

Currently, the engine's built-in components include:

The advantage of this design is that Flutter developers do not need to accept the full ECS mental model. Custom behavior only requires inheriting Component.

Of course, the most important thing about the entire project is that the scene tree is not directly used for drawing. Node Graph and RenderScene are two separate structures.

Node is used to express business hierarchy, but it's not suitable for the GPU to recursively traverse the entire tree every frame during drawing. So when a MeshComponent is mounted to the scene, each drawable Primitive is registered as a RenderItem.

This also fits well with Flutter's multi-Tree design.

And RenderItem mainly stores:

The render Pass traverses the flat RenderScene.items.

Then BVH is used for culling and light assignment. RenderItems with bounds are placed into the BVH, and objects without bounds or with culling disabled go into an always-visible list.

Static structural changes will rebuild the BVH. When only objects move, it can go through a refit instead of a full rebuild.

BVH is not only used for frustum culling but also for local light assignment. Point and Spot Lights find potentially affected objects through the spatial structure each frame, then generate a light index slice for each RenderItem. The Fragment Shader does not need to loop through all lights in the scene.

So essentially, it's a Forward Renderer with a Cluster-like approach but at the object granularity level, not a traditional Deferred Renderer.

The complete rendering flow is roughly as follows:

A more detailed rendering flow would be as shown below, generally following this path each frame:

These Passes are not all run every frame; they are dynamically enabled based on actual needs and capabilities, for example:

Additionally, the RenderGraph here is not a complex graph compiler. I feel this name is very misleading. More accurately, it should be described as:

Passes execute in insertion order, without automatic dependency analysis, automatic reordering, or dead Pass culling. Each Pass also creates its own CommandBuffer.

The advantage of this implementation is simplicity, debuggability, and suitability for the still-evolving Flutter GPU API. However, the disadvantage is also obvious: if the number of Passes increases, resource reuse and scheduling efficiency will lag behind the RenderGraph of mature engines.

Then in encoding, the project's SceneEncoder divides visible objects into two categories:

Then the entire Pipeline uses a process-level cache, with the Key being:

Vertex Shader + Fragment Shader + Vertex Layout

The project has also done extensive optimization for Pipeline creation, temporary Uniform memory, and redundant binding, for example:

A streaming scene with 100 Tiles on Metal has an average frame build time of 5.7ms.

Then Flutter Scene supports two import modes:

  1. Runtime import

    final model = await Node.fromGlbAsset('assets/model.glb'); scene.add(model);

  2. Externally obtained bytes:

    final model = await Node.fromGlbBytes(bytes);

Then preprocessing at build time, converting glTF to .fsceneb through a Build Hook:

buildScenes(
  buildInput: input,
  buildOutput: output,
  inputFilePaths: ['assets_src/model.glb'],
);

That is, what is actually read is the .fsceneb generated at build time, avoiding re-parsing glTF on the end device. It also supports Scene Subtree streaming loading and Prefab reuse.

Then for the material system, the built-in materials mainly include:

PBR uses the Metallic-Roughness workflow, supporting Base Color, Normal, Metallic/Roughness, AO, Emissive, and environment reflections.

The default Scene lazily loads a procedural Studio Environment, so you can see results after creating a PBR material without additional light configuration.

Additionally, you can define custom material languages through .fmat. The positioning of .fmat is similar to Filament .mat, Godot Shader, and a simplified Shader DSL:

material {
  name: "Toon",
  shading_model: lit,
  blending: opaque,

  parameters: [
    { type: vec4, name: base_color,
      hint: source_color, default: [1, 1, 1, 1] },
    { type: float, name: bands,
      hint: range(1, 8, 1), default: 4 },
  ],
}

fragment {
  void Surface(inout MaterialInputs material) {
    float lighting = max(dot(
      GetWorldNormal(),
      normalize(vec3(0.4, 0.8, 0.2))
    ), 0.0);

    material.base_color =
        material_params.base_color *
        floor(lighting * material_params.bands) /
        material_params.bands;

    PrepareMaterial(material);
  }
}

Afterwards, the Build Hook compiles the Shader and generates parameter Metadata. Then the Dart side sets type-safe parameters by name:

final material = await loadFmatMaterial('assets/toon.fmat');

material.parameters
  ..setColor('base_color', const Color(0xFFE0A030))
  ..setFloat('bands', 4);

Compared to the raw ShaderMaterial, developers do not need to manually handle std140 Packing, Samplers, and Uniform Slots. It even supports materials actively declaring:

scene_color
scene_depth

The engine only creates corresponding resources when a visible material requests them.

However, it's not without limitations. The project still has a significant gap from a complete glTF/PBR implementation:

Therefore, ordinary Metallic-Roughness models are basically usable, but car paint, glass, lenses, cloth, complex UV assets, and high-fidelity industrial models may not render correctly.

Afterwards, imported glTF Animation is saved on the model's root node and executed with the following code:

final walk = model.createAnimationClip(
  model.findAnimationByName('Walk')!,
)
  ..loop = true
  ..play();

Each Clip has independent playback state, speed, time, loop, and Weight. Multiple Clips can run simultaneously, and the internal AnimationPlayer blends them by weight, suitable for Idle, Walk, Run Cross Fade.

Then, for lighting, shadows, and post-processing, the following are currently available:

Among these, static shadow caching is most worth mentioning separately. You just need to set for static objects:

node.shadowStatic = true;

Afterwards, their Cascade Shadow Tiles can be reused across frames, only overlaying dynamic objects into them. In a large-scale static world, this is much cheaper than redrawing all buildings and ground for each Cascade every frame.

Then the most heavyweight feature is that Flutter Widgets can be placed into 3D scenes. This is the project's most Flutter-characteristic feature:

scene.add(
  Node()
    ..addComponent(
      WidgetComponent(
        size: const Size(480, 300),
        pixelRatio: 2,
        worldHeight: 1.5,
        child: const MyControlPanel(),
      ),
    ),
);

You can even bind a Widget to an existing display screen material on a glTF model without creating an additional Quad.

However, because its current working method is:

Flutter Raster
→ GPU/CPU Readback
→ CPU
→ Re-upload to 3D Texture

That is, there is a CPU round-trip overhead. For example, a typical Panel capture might cost several milliseconds each time, and dynamic Widgets are captured every frame by default, so a large number of high-resolution Widget Surfaces will significantly increase overhead.

Additionally, other limitations include:

Then in the physics architecture, the core package only defines abstract physics interfaces:

And Rapier 3D is accessed through flutter_scene_rapier, using Dart FFI to call Rust Rapier. It supports rigid bodies, colliders, joints, motors, queries, and Character Controller, and provides precompiled binaries for most platforms. Physics advances at a fixed time step, and the rendering Transform uses interpolation.

And Box3D is accessed through flutter_scene_box3d, using the Dart box3d package. It supports common Shapes, queries, and collision events, but joints and explicit mass/inertia overrides seem not yet connected.

So it can be seen that this is a complete set of Flutter 3D engine, and it's a usable, complete support package, also supporting Flutter Widget integration. For example, support beyond games, such as:

This project directly fills the gap of a 3D scene that Flutter officially never had time to support. The author is also a former Impeller engineer, who seemingly built this project after leaving Google. It's interesting to think about—something that couldn't be done at Google was accomplished after leaving Google.

Links

https://fscene.dev/

Comments

Top 4 of 6 from juejin.cn, machine-translated. The original thread is authoritative.

笑对人生的英仔

I've been following this project too — seems like it's from FlutterCon USA 2026!

恋猫de小郭

You could say it was promoted at FlutterCon USA 2026. He's been working on it for a while, and this time it's pretty polished.

笑对人生的英仔  → 恋猫de小郭

FlutterCon USA 2026 had a lot of content, but there are no related videos on YouTube. The one I'm following is about multi-window support.

安妮你的熊呢 2 likes

This is insane. I wake up and Flutter has become a game engine.

楚云升 1 likes

666

笑对人生的英仔

No follow-up on Toyota's game engine?