flutter3d_testing

Pixel regression tests for a game, on a machine with no GPU.

import 'package:flutter3d_testing/flutter3d_testing.dart';

test('the crypt still looks like the crypt', () async {
  final frame = await renderFrame(
    width: 320,
    height: 180,
    build: (device) {
      final scene = Scene();
      // ... put the level in it, uploading meshes to `device`
      return (scene: scene, camera: camera);
    },
  );
  await expectMatchesGolden(frame, 'test/goldens/crypt.png');
});

The first run records the reference and says so. Later runs fail when the picture changes, and the message says by how much and how to re-record.

Replaying a recorded run

A .f3drun your game recorded is a test too:

// `Ascent` is the game's own.
void main() {
  testReplay(
    'test/tapes/ascent.f3drun',
    start: Ascent.open, // builds a ReplaySubject on the device it is given
    goldensAt: <int>[120, 600],
  );
}

The replay runs on the software backend. At every checkpoint the tape holds (or only those you name in digestAt) the simulation's digest has to match the recorded one, and the frames at goldensAt are compared with test/goldens/ascent-120.png and ascent-600.png. If the level changed since the tape was recorded, the test says that before stepping, because a divergence at step 25 would send you looking in the wrong place. apps/flutter3d_demo_platformer/test/replay_test.dart is a complete example.

Why this can exist here and nowhere else

flutter3d_cpu is a full backend: a software rasteriser that passes the same conformance suite as Impeller and WebGL. A frame can therefore be drawn with no driver, no display and no graphics hardware, and a continuous integration runner can answer "does it still look right".

Every other 3D engine on this platform needs a real device for that check. In practice that means either a machine nobody wants to pay for or a check nobody runs.

Why it is its own package

flutter3d_cpu must not depend on flutter3d. A backend that could not compile without the engine would be part of the engine, not an implementation of an interface. And flutter3d must not depend on any backend; a scan in tool/structure.dart enforces that.

So neither of them can hold code that needs both, and this package needs both.

What a match means

tolerance is the share of pixels allowed to differ, and it is zero by default. What counts as a differing pixel is a separate setting, and its default is not byte for byte. A pixel differs when red, green or blue is more than channel steps off (8 unless you say otherwise), and alpha is not compared unless you pass alpha: true. This README used to say the tolerance was zero and stop there, which anyone keeping byte-exact references read as "exact". It was not exact, and a reference that moved by eight steps everywhere still passed.

For an exact comparison:

await expectMatchesGolden(frame, path, channel: 0, alpha: true);

The frame comes from a software rasteriser, so the same scene drawn twice is the same bytes twice. There is no driver, clock or thread to disagree, which means any difference is a change and not noise. Raise tolerance only when something has been measured to move, and say why in the call.

On another device

renderFrame, testReplay and expectReplayMatches take device:, the function that opens a device at a size, in the shape every backend's open has. The frame is drawn there instead of on the software rasteriser:

import 'package:flutter3d_webgpu/flutter3d_webgpu_web.dart';

final frame = await renderFrame(
  width: 320,
  height: 180,
  device: WebGpuDevice.open,
  build: buildCrypt,
);

A frame's device is disposed once its pixels are read; a replay opens one for the whole run. A test that keeps its own device calls drawOnce with a Renderer made on it. This is also how a backend written outside this repository draws the engine's frames in its own suite.

What it does not do

It does not compare a software frame against a GPU one. This repository keeps two separate golden sets on purpose: the same scene differs by a fraction of a percent between a rasteriser and a driver, and one shared set would need a tolerance. See the note at the top of packages/flutter3d_cpu/test/cross_backend_test.dart.

References a game records here show what the software backend draws. That is still the right thing to regress against, because a change that alters the picture alters it on both. Goldens drawn with device: are that backend's: keep each backend's set in a directory of its own.


Part of flutter3d, an independent implementation of a 3D engine for Flutter. It is not a fork or a binding of another engine, and it is not affiliated with Google or the Flutter project. It has four switchable rendering backends: Impeller via Flutter GPU, WebGL2, WebGPU and a software rasteriser. It loads glTF, OBJ and .f3d, and has six lighting models, shadows, bloom, skinning, animation, BVH culling and picking, plus a deterministic fixed-step game layer with collision, navigation, positional audio, and gamepad and touch input. Four example games (shooter, platformer, racing, strategy) are each built on a genre package: flutter3d_game_shooter, flutter3d_game_platformer, flutter3d_game_racing, flutter3d_game_strategy. A new game starts from the editor's scaffold, which writes one from a template: flutter3d.pleion.dev/first-project/. Documentation: flutter3d.pleion.dev.

Libraries

flutter3d_testing
Pixel regression tests for a game, on a machine with no GPU.