Architecture
Four rules, each enforced by a test instead of by intention, and one consequence of the platform that shapes everything downstream of it.
The rules #
The HAL: the engine names no graphics API #
flutter3d depends on flutter3d_hardware and nothing below it. That package is the HAL: the hardware abstraction layer, and the vocabulary the engine is written against. It contains no implementation at all, a backend implements it, the engine talks to it, and neither has to know about the other.
flowchart TB engine["flutter3d<br>the renderer"] vocab["flutter3d_hardware<br><b>THE HAL</b><br>GraphicsDevice · CommandEncoder<br>TextureHandle · GeometryBuffer<br>no implementation in it"] impeller["flutter3d_impeller<br><b>flutter_gpu</b><br>Metal · Vulkan"] webgl["flutter3d_webgl<br><b>WebGL2</b><br>the browser"] cpu["flutter3d_cpu<br><b>software</b><br>rasterises in Dart"] app["your application"] engine --> vocab impeller --> vocab webgl --> vocab cpu --> vocab app -->|picks one| impeller app --> engine
An application names a backend in its pubspec and hands the device to Renderer.create. That is the one line that changes to move a game onto another API, and the reason the choice stays visible in the pubspec instead of being resolved by magic.
import 'package:flutter3d/flutter3d.dart';
import 'package:flutter3d_impeller/flutter3d_impeller.dart';
final device = await GpuRenderBackend.open(width: 1280, height: 720);
final renderer = Renderer.create(device: device);
The four backends #
| Backend | Runs on | Entry point | Status |
|---|---|---|---|
flutter3d_impeller |
flutter_gpu: Metal on Apple platforms, Vulkan elsewhere |
GpuRenderBackend.open(width:, height:) |
The production one. Everything in these docs runs on it |
flutter3d_webgl |
WebGL2, in the browser | WebGlDevice.open(width:, height:) |
Runs all three games. Slower, at a fixed resolution. What an ordinary web build opens |
flutter3d_cpu |
Nothing. It rasterises in Dart | CpuDevice(width:, height:, shaders:) |
Complete for the golden set. A dev dependency of every game |
flutter3d_webgpu |
WebGPU, in a browser that hands out an adapter | WebGpuDevice.open(width:, height:) |
Draws, and passes the whole conformance suite against a live device. Declines four capabilities by name. Asked for rather than defaulted to |
Each exists for a different reason, and none of them is a fallback for another.
flutter3d_impeller is the real one. Nothing else in the stack names flutter_gpu, which is the property that package exists to hold.
flutter3d_webgl is how you find out whether the HAL is a seam or a description of Impeller. A fake backend can only confirm that an interface is callable, never that it is implementable. All three games run on it, at a fixed internal resolution and a lower frame rate, and all three are embedded in these docs. The racing game was the last of them: it drew the circuit at well under a frame a second for months, and what was wrong was a cube shadow atlas sized from the sun's setting rather than anything about the seam.
Its shaders are GLSL ES 3.00 generated from flutter3d_shaders. Nothing checked that the generated file was current for a long time, and it cost two failures that reported nothing: a uniform member the browser's copy had never heard of, and a sky shader from before the sky was rewritten. tool/ci.sh regenerates it and fails on the diff now, and the compiled Impeller bundle, which cannot be diffed, is held by a freshness rule instead.
flutter3d_cpu is the one that makes the agreement mean something. Two hardware backends agreeing proves less than it looks like: both are driven by a C API and both rasterise on a GPU, so an assumption shared by graphics hardware would be invisible to the pair of them. This one shares nothing with either, no driver, no shading language, no command buffer. It is also what makes 97 golden scenes checkable in a headless run, and what caught three bugs that every simulation test passed.
flutter3d_webgpu asks a question none of the other three could: whether the shader half of the contract is a seam. The first three all read one text (impellerc compiles the GLSL, the WebGL generator translates it, the software rasteriser transcribes it into Dart by hand), and WebGPU cannot, because WGSL is a different language and a browser will not take SPIR-V. So its table is generated down a second toolchain, the same manifest through glslangValidator and then naga, and all thirty-nine stages come out. What it declines it declines by name: no blend constant (WebGPU has no colour/alpha pair for one) and no wireframe (no polygon fill mode in the API at all). Two more used to be on that list. Rendering into a mip is on now and cost no code: a reflection probe convolves its own chain, and a face and a level are baseArrayLayer and baseMipLevel on an ordinary view here. Block compression is on too, and what it cost was the asking: a WebGPU device gets exactly the features it requested, and requesting one the adapter does not carry rejects the device instead of handing back a lesser one, so the adapter is asked, the intersection is requested, and the capability answers from what was granted.
A browser build tries WebGPU first and opens WebGL2 where the browser hands out no adapter. --dart-define=FLUTTER3D_WEBGPU=false keeps WebGL2 only, and that is a decision about bytes: a probe that can call either opener keeps both backends reachable and dart2js ships what it can reach, which the strategy demo measures at 376,649 bytes of main.dart.js, 14.9%.
Any new backend has to pass flutter3d_conformance before it counts as one.
Writing a fifth one is a documented job rather than an archaeology exercise: Writing a HAL backend covers the whole contract, the ten semantics that appear in no signature, the conformance suite you can run before compiling a single shader, and the 118 shader entry points your bundle has to answer to.
What the HAL actually names #
abstract interface class GraphicsDevice implements TextureAllocator {
PipelineHandle createPipeline(...);
GeometryBuffer uploadGeometry(ByteData bytes, GeometryUsage usage);
TextureHandle? createTextureFromPixels({...});
void beginFrame();
CommandEncoder beginRenderPass(RenderPassDescriptor descriptor);
Future<ByteData?> readPixels(TextureHandle texture);
/// The frame before, without waiting for it: what an exposure meter and
/// a picking pass read.
Future<ByteData> readback(TextureHandle texture, {ScreenRect? region});
}
Plus PassEncoder (state, bindings, draws), PassState as one value, the enums a caller has to name (formats.dart, vertex_layout_spec.dart), opaque handles for the things a backend owns (GeometryBuffer, ShaderHandle, TextureHandle), sampling (SamplerDescriptor, MipChain), and a render target pool with the description that makes two targets interchangeable.
Two rules in tool/structure.dart hold the boundary: the hardware layer names no graphics API refuses a flutter_gpu import anywhere in the HAL, and the engine names no backend refuses one in flutter3d, and refuses the dependency too, which the import scan alone would miss. The HAL also refuses dart:ui and the rest of Flutter outright now: GraphicsDevice.present, its one Flutter member, is gone, and what a finished frame becomes is decided by a device registry in flutter3d_hardware that a backend registers its own presenter with, looked up by presentFrame in flutter3d_app.
The compiled shader bundle is an asset of flutter3d_impeller, which is where it belongs: a bundle is one backend's output, and the backend that reads it is the one that ships it. The GLSL it is built from is shared, in flutter3d_shaders, because the other two backends compile from the same sources. It used to sit in flutter3d, which made the engine carry one backend's build output.
When a second backend arrives, flutter3d_hardware does not change. A translation file appears in the new backend, and that is all. That is the test of whether a layer is an abstraction or a description of its first implementation, and it is why the CPU backend was written before anyone needed software rendering.
The game layer does not depend on the renderer #
flutter3d_game imports Flutter in exactly one file, src/input/desktop_input.dart, because a key event is a Flutter type. Everything else is plain Dart over vector_math. flutter3d_physics imports nothing at all.
Simulation, input and collision have nothing to say about how a frame is drawn, and the bugs they have are invisible in a screenshot. All of them are reachable from a plain unit test, and all of them have been caught by one.
A genre is a package #
flutter3d_game_shooter holds what only a shooter wants: monsters, weapons, an inventory, pickups, and the order a shooter's step runs in. A platformer inherits none of it.
This was not free. Monster and MonsterSystem lived in the game layer for a while, which meant the engine knew what an alert pause was, that attacking involves a weapon, and that being hurt involves a chance of flinching. A platformer has none of those, and the first thing that would have happened when one was written is that half the file would have been unusable and the other half copied.
What stayed behind is machinery: Actor (a body, a brain and some health, every part optional), ActorSystem, Mechanism, the level format, the ECS. What left is vocabulary.
The file used to be lib/shooter.dart in the game package, unexported by the barrel, a rule instead of a boundary. It still resolved from inside the package, and the four things it leaned on carried no marking at all. The genre rule is what turned the rename into a fact.
Where the two sides meet #
Level geometry has to become mesh nodes. An actor has to get a visual. A glowing fixture has to drive a light. Neither of the two rules above lets that mapping live in the simulation or in the renderer, so it lives above both: flutter3d_app loads a level into a scene for any application, and flutter3d_game gives a game's actors and fixtures their visuals.
Everything in it is mechanism. What a torch looks like and what colour a runner is are decided by the game and handed in through FixtureAppearance and ActorAppearance.
final visuals = ActorVisuals(
scene,
appearance: const DungeonMonsters(), // your game's opinion
device: device,
);
loaded.level.spawnInto(
SpawnContext(
world: loaded.collision,
actors: actors,
mechanisms: mechanisms,
onActorSpawned: visuals.add, // the bridge, hooked in
onFixture: fixtures.add,
),
registry: kinds,
);
The consequence: shaders are built ahead of time #
There is no runtime shader compilation on this platform. tool/build_shaders.sh calls impellerc and produces a bundle; the application loads it as an asset. Everything below follows from that one fact.
- A material graph is impossible while the game runs. Every lighting model is a separate pre-built shader.
- Every shader is a separate
RenderPipeline, so a pipeline switch is the most expensive state change in a pass. That is why it is the high-order key when the render list is sorted. - Lighting is a uniform array, not a permutation per light count.
vec4 lights[8]with the count as a uniform, so switching a light on or off never rebuilds a pipeline. A capture with three lights and one with a single light both report one pipeline. - The bundle grows with every permutation: 12.5 KB with one shader, 97 KB with seven.
- A shared GLSL header (
shaders/lib/surface.glsl) guarantees an identical uniform block across permutations, and the engine tolerates missing members: a shader that never readslight_directionloses it from reflection entirely.
Declaring a uniform block and never reading it is not harmless. Reflection still reports the block at a non-zero size while the compiled Metal function binds no buffer for it, and binding that phantom block is a SIGSEGV with no Dart stack trace. That is why LightingModel carries explicit metadata (usesFragInfo, usesMaterialMaps, usesMetallicRoughnessMap) rather than the engine inferring anything from a shader's size, and why the header is split into lib/color.glsl and lib/surface.glsl.
A material is a pipeline #
Because of the above, RenderMaterial.lighting is not a style setting. It selects the fragment shader, and therefore the pipeline, and therefore where the draw lands in the sort.
RenderMaterial(
name: 'brass',
lighting: LightingModel.pbr, // <- picks the shader
baseColor: const LinearColor(0.7, 0.5, 0.2),
metallic: 1.0,
roughness: 0.35,
);
| Model | Reads the material block | Samples albedo | Samples the full map set |
|---|---|---|---|
LightingModel.unlit |
no | yes | no |
LightingModel.lambert |
yes | yes | no |
LightingModel.blinnPhong |
yes | yes | yes |
LightingModel.pbr |
yes | yes | yes |
LightingModel.toon |
yes | yes | no |
LightingModel.normals |
no | no | no |
Where the frame graph fits #
The renderer does not hardcode a list of passes. FrameGraph takes nodes that declare what they read and write, and compiles an order, culling anything nothing consumes and reporting when a resource is last used so a pool can hand its target to somebody else.
flowchart LR shadow["shadow"] -->|shadow map| opaque["opaque"] opaque -->|scene HDR| trans["transparent"] trans --> bright["bright pass"] bright --> blur["blur chain"] blur -->|bloom| comp["composite"] trans -->|scene HDR| comp comp -->|swapchain| overlay["overlay"]
Reading it as data rather than as a call sequence is what makes two things possible: a pass that nobody reads from is not encoded at all, and a render target whose last reader has run can be recycled inside the same frame.
Extending without forking #
Two seams exist for adding to a frame without editing the renderer.
PassContributor draws inside an existing pass. The particle system is one: renderer.renderSteps.addContributor(ParticleContributor(particles)), and as a result nothing in flutter3d names a particle.
final particles = ParticleSystem(capacity: 2000);
renderer.renderSteps.addContributor(ParticleContributor(particles));
EntityKind adds a noun to the level format. Both games' entire vocabularies are kinds, and the same registry validates a document and spawns it, so the two cannot disagree about what a level may contain.
Next #
- The frame: what each of those passes actually does
- Geometry & materials: what a pipeline is drawing
- Pitfalls: the platform conditions behind several of these decisions