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:
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:
- glTF,
.fsceneb,.fmatasset build system - Rapier, Box3D physics backends
- Editor Core, editor UI, and MCP Agent interface
And it's not just
flutter_scene; the project also includesflutter_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:
- Drives the per-frame tick
- Constructs or retrieves the Camera
- Calls
Scene.render - Manages loading placeholders and Pipeline warm-up
- Handles Widget input within the scene
- Projects 3D nodes into the Flutter Semantics tree
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:
- Localized 3D areas within Flutter layouts
- Picture-in-picture
- Split-screen with multiple Cameras
- Different Views using different LayerMasks
- RenderTexture
- Mixing 3D and 2D UI
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:
- Local transform and cached world transform
- Parent-child hierarchy
- Mesh
- Component
- Visibility
- Render Layer
- Raycast switch
- Shadow static marker
- Additional data like LOD, animation, Skin
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,
updatewill not execute beforeonLoadfinishes. The physics system can also usefixedUpdateto run at a fixed time step.
Currently, the engine's built-in components include:
- Mesh, Instanced Mesh
- Camera
- Directional, Point, Spot Light
- LOD
- Semantics
- WidgetComponent
- SplatComponent
- Environment Volume
- Various physics components
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:
- Geometry and Material
- Current world transform
- Visibility
- Layer
- World AABB
- LOD
- Instance Transform
- List of local lights corresponding to the object
- Shadow and highlight states
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:
- A Depth Prepass is only generated when SSAO, SSR, depth of field, or a custom Pass requests depth.
- The main Scene Pass is only split into opaque and transparent stages when a material declares
scene_color. - No Shadow Pass is created if there are no shadow-casting lights.
- There is no extra cost if post-processing is not enabled.
Additionally, the RenderGraph here is not a complex graph compiler. I feel this name is very misleading. More accurately, it should be described as:
- An ordered list of Passes
- A Blackboard for sharing data between Passes
- A TransientTexturePool for reusing textures across frames
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:
- Opaque objects: First grouped by Render Pipeline to reduce state switches like Shader and Vertex Layout, then within the same Pipeline, drawn from near to far based on distance, utilizing depth testing to kill later Fragments as early as possible.
- Transparent objects: Drawn from far to near based on Camera distance, using Premultiplied Alpha blending. Transparent Instanced Meshes are split into one sorting record per Instance.
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:
Runtime import
final model = await Node.fromGlbAsset('assets/model.glb'); scene.add(model);
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
.fscenebgenerated 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:
PhysicallyBasedMaterialUnlitMaterialSpriteMaterialShaderMaterialPreprocessedMaterial
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:
- No custom per-light BRDF Hook
mat3is temporarily unsupported due to GLES std140 issues- The lit Shader is close to the Metal minimum 16 Sampler limit
- Second UV, glTF Tangent, Texture Transform are not yet complete
- glTF extensions like Clearcoat, Sheen, Transmission are not yet complete
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
AnimationPlayerblends them by weight, suitable for Idle, Walk, Run Cross Fade.
Then, for lighting, shadows, and post-processing, the following are currently available:
- HDR/EXR/equirectangular Environment Map
- Diffuse SH and Prefiltered Specular IBL
- Procedural Gradient/Physical Sky
- Custom
.fmatSky - Directional, Point, Spot Light
- Cascaded Directional Shadow
- Spot Shadow Atlas
- Static shadow caching
- Environment Volume blending
- Exposure and Tone Mapping
- SSAO, SSR, Fog, God Rays
- Bloom, Color Grading, Vignette, Film Grain
- Chromatic Aberration, DoF, FXAA, MSAA
- Custom Render Pass and Post Effect
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:
- MouseRegion Hover cannot be forwarded
- Keyboard and Focus are not supported
- TextField is not yet usable
- WidgetComponent does not enter
.fsceneserialization
Then in the physics architecture, the core package only defines abstract physics interfaces:
PhysicsWorldRigidBodyColliderJoint- Raycast, ShapeCast, Overlap
- Collision Event
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:
- 3D display of products and equipment
- Interactive 3D digital avatars in Apps
- Data visualization
- 3D editing tools
- ...
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.
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!
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.
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.
This is insane. I wake up and Flutter has become a game engine.
666
No follow-up on Toyota's game engine?