capturePhoto function
- required Renderer renderer,
- required Scene scene,
- required CameraNode camera,
- required int width,
- required int height,
- required FutureOr<
void> onRows(- PhotoRows rows
- RenderSettings settings = const RenderSettings(),
- PhotoFilter filter = PhotoFilter.none,
- LinearColor? clearColor,
- int layerMask = RenderLayers.all,
- int tileWidth = 1024,
- int tileHeight = 1024,
- int margin = 32,
- void onProgress(
- 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,
);
}