capturePhoto function

Future<PhotoCaptureReport> capturePhoto({
  1. required Renderer renderer,
  2. required Scene scene,
  3. required CameraNode camera,
  4. required int width,
  5. required int height,
  6. required FutureOr<void> onRows(
    1. PhotoRows rows
    ),
  7. RenderSettings settings = const RenderSettings(),
  8. PhotoFilter filter = PhotoFilter.none,
  9. LinearColor? clearColor,
  10. int layerMask = RenderLayers.all,
  11. int tileWidth = 1024,
  12. int tileHeight = 1024,
  13. int margin = 32,
  14. void onProgress(
    1. double progress
    )?,
})

Draws scene through camera at width × height and hands it to onRows a row of tiles at a time, top to bottom, finished and opaque.

Each tile is tileWidth × tileHeight of the picture drawn with margin pixels more on every side, as a CameraViewSettings.window of the whole picture; the margin is thrown away. A window, not a crop, so a tile is the picture's own pixels: on a device that lists DeviceFeature.viewportBeyondTarget every fragment is shaded from the position, depth and shadow lookup the picture drawn whole gives it, and a tiled photo with nothing on that reads neighbours equals the scene drawn once, byte for byte. A device without the feature draws a window through a crop of the projection, which agrees to within the rounding of a different matrix — a shadow edge can land a texel the other way. What the margin is for: bloom, ambient occlusion, depth of field and screen-space reflections read neighbours, and at a tile's edge without one they read the clear colour — a seam down the picture wherever a highlight sits near one. With a margin wider than what they read, the neighbours are real. Bloom's widest levels reach further than any margin, so a capture with bloom is close to the screen rather than equal to it; photo_capture_test.dart measures how close.

The camera is left as it was given. The renderer is the game's own: a photo is drawn on the device that is already up, at the size the game asks, so nothing about it needs a second device. Nothing else may draw on it until this returns. A hardware readback can resolve a frame or two after the ask, and a game frame drawn in between takes its targets from the same pool as the tile waiting to be read — so a game stops redrawing its surface while a photo is taken, which it is free to do with the world paused.

onRows gets a PhotoRows of width × count × 4 bytes for frame rows top to top + count, and is awaited before the next row of tiles is drawn, so a sink writing to a disk holds this back rather than letting it run ahead.

Implementation

Future<PhotoCaptureReport> capturePhoto({
  required Renderer renderer,
  required Scene scene,
  required CameraNode camera,
  required int width,
  required int height,
  required FutureOr<void> Function(PhotoRows rows) onRows,
  RenderSettings settings = const RenderSettings(),
  PhotoFilter filter = PhotoFilter.none,
  LinearColor? clearColor,
  int layerMask = RenderLayers.all,
  int tileWidth = 1024,
  int tileHeight = 1024,
  int margin = 32,
  void Function(double progress)? onProgress,
}) async {
  if (width <= 0 || height <= 0) {
    throw ArgumentError('A photo of ${width}x$height has no pixels to draw.');
  }
  if (tileWidth <= 0 || tileHeight <= 0 || margin < 0) {
    throw ArgumentError(
      'Tiles of ${tileWidth}x$tileHeight with a margin of $margin cannot '
      'cover a picture; tiles need a size and a margin cannot be negative.',
    );
  }
  final tilesX = (width + tileWidth - 1) ~/ tileWidth;
  final tilesY = (height + tileHeight - 1) ~/ tileHeight;
  final chosen = photoSettings(settings, filter, renderer.exposure);
  final finish = PhotoFinish.of(chosen.look, width, height);
  final drawWidth = tileWidth + 2 * margin;
  final drawHeight = tileHeight + 2 * margin;

  // One depth mapping for the whole photo, the one the screen draws it with.
  // A tile fitting its own near plane to the little it sees would draw the
  // same surface at another depth than its neighbour, and a tie between two
  // coplanar faces would settle one way in one tile and the other way in the
  // next.
  final near = renderer.depthNearFor(
    scene: scene,
    view: CameraView(camera: camera, layerMask: layerMask),
    width: width,
    height: height,
    settings: chosen.tile,
  );
  for (var tileY = 0; tileY < tilesY; tileY++) {
    final top = tileY * tileHeight;
    final rows = math.min(tileHeight, height - top);
    final strip = Uint8List(width * rows * 4);
    for (var tileX = 0; tileX < tilesX; tileX++) {
      final left = tileX * tileWidth;
      final columns = math.min(tileWidth, width - left);
      final result = renderer.render(
        width: drawWidth,
        height: drawHeight,
        scene: scene,
        views: <CameraView>[
          CameraView(
            camera: camera,
            clearColor: clearColor,
            layerMask: layerMask,
            cut: true,
            settings: CameraViewSettings(
              depthNear: near,
              // The tile, margin and all, as a window of the photo: drawn
              // through the photo's own projection, so each of its pixels is
              // the one the photo drawn whole would have.
              window: FrameWindow(
                width: width,
                height: height,
                left: left - margin,
                top: top - margin,
              ),
            ),
          ),
        ],
        settings: chosen.tile,
      );
      // A tile the device cannot hand back throws its
      // `DeviceResourceException` here, and nothing past row [top] has
      // been written.
      final pixels = await renderer.device.readback(result.frame);
      final tile = pixels.buffer.asUint8List(
        pixels.offsetInBytes,
        pixels.lengthInBytes,
      );
      for (var row = 0; row < rows; row++) {
        final from = ((row + margin) * drawWidth + margin) * 4;
        strip.setRange(
          (row * width + left) * 4,
          (row * width + left + columns) * 4,
          tile,
          from,
        );
      }
      onProgress?.call((tileY * tilesX + tileX + 1) / (tilesX * tilesY));
    }
    // Opaque: a saved photo with the sky see-through is a picture of the
    // viewer's background, and a clear colour's alpha was never a choice
    // about the picture.
    for (var i = 3; i < strip.length; i += 4) {
      strip[i] = 255;
    }
    finish.apply(strip, top, rows);
    await onRows(PhotoRows(bytes: strip, top: top, count: rows, width: width));
  }
  return PhotoCaptureReport(
    width: width,
    height: height,
    tilesX: tilesX,
    tilesY: tilesY,
    margin: margin,
    exposure: renderer.exposure,
    setAside: chosen.setAside,
  );
}