render method
Renders the current state of this Scene onto the given ui.Canvas using the specified Camera.
The Camera provides the perspective from which the scene is viewed, and the ui.Canvas is the drawing surface onto which this Scene will be rendered.
Optionally, a ui.Rect can be provided to define a viewport, limiting the rendering area on the canvas. If no ui.Rect is specified, the entire canvas will be rendered.
pixelRatio is the multiplier from logical to physical pixels used
when allocating the offscreen render target. Defaults to the
implicit view's devicePixelRatio (or 1.0 if no view is attached),
so the scene is rasterized at the same density Flutter is
compositing the surrounding UI at. Pass a smaller value to trade
fidelity for performance, or a larger one for supersampling.
Implementation
void render(
Camera camera,
ui.Canvas canvas, {
ui.Rect? viewport,
double? pixelRatio,
}) {
if (!_readyToRender) {
debugPrint('Flutter Scene is not ready to render. Skipping frame.');
debugPrint(
'You may wait on the Future returned by Scene.initializeStaticResources() before rendering.',
);
return;
}
// Measure the backend's render-to-texture Y orientation once, on the
// first frame (the OpenGL ES context is only up after the first frame).
probeBackendYFlip();
final drawArea = viewport ?? canvas.getLocalClipBounds();
if (drawArea.isEmpty) {
return;
}
// Allocate the offscreen render target at physical-pixel resolution so
// the rasterized 3D content matches Flutter's framebuffer density.
// Without this, the texture is sized in logical pixels and the
// framebuffer compositor upscales it (visible as pixelation on
// high-DPI devices). See: https://github.com/bdero/flutter_scene/issues/60
final dpr =
pixelRatio ??
ui.PlatformDispatcher.instance.implicitView?.devicePixelRatio ??
1.0;
final pixelSize = ui.Size(
(drawArea.width * dpr).ceilToDouble(),
(drawArea.height * dpr).ceilToDouble(),
);
final enableMsaa = _antiAliasingMode == AntiAliasingMode.msaa;
final gpu.Texture swapchainColor = surface.getNextSwapchainColorTexture(
pixelSize,
);
// Resolve the IBL environment up front (before building the render
// graph): the default is built lazily here on first use, which submits
// a one-time prefilter pass that must not be nested inside the frame's
// render passes. Doing this in the constructor instead would break the
// OpenGL ES backend, which sets up its context lazily on the raster
// thread only after the first frame.
final environmentMap = environment ?? Material.getDefaultEnvironmentMap();
// Reuse one host buffer across frames; reset() cycles it to the next
// frame's backing storage.
final transientsBuffer =
_transientsBuffer ??= gpu.gpuContext.createHostBuffer();
transientsBuffer.reset();
final light = directionalLight;
// Cascaded shadows fit the camera frustum, so they require a
// perspective camera; other camera types render without shadows.
final cascades =
light != null && light.castsShadow && camera is PerspectiveCamera
? light.computeCascades(camera, pixelSize.width / pixelSize.height)
: const <ShadowCascade>[];
// Walk the graph once to tick components and animations and refresh
// the flat render list before the passes iterate it. Skipped when
// update() already ran the tick for this frame.
if (!_tickedThisFrame) {
final nowMillis = DateTime.now().millisecondsSinceEpoch;
final lastMillis = _lastTickMillis ?? nowMillis;
_tick((nowMillis - lastMillis) / 1000.0);
}
_tickedThisFrame = false;
// Rebuild the spatial culling structure if the pre-pass changed the
// scene, before the render passes query it.
renderScene.rebuildIfDirty();
final graph = RenderGraph();
if (cascades.isNotEmpty) {
graph.addPass(
ShadowPass(
renderScene: renderScene,
cascades: cascades,
tileResolution: light!.shadowMapResolution,
),
);
}
graph.addPass(
ScenePass(
camera: camera,
renderScene: renderScene,
dimensions: pixelSize,
environmentMap: environmentMap,
environmentIntensity: environmentIntensity,
environmentTransform: environmentTransform,
enableMsaa: enableMsaa,
directionalLight: light,
cascades: cascades,
),
);
// Split custom effects by where they run in the chain.
final beforeTonemap = <PostEffect>[];
final afterTonemap = <PostEffect>[];
for (final effect in postProcess.customEffects) {
if (!effect.enabled) {
continue;
}
if (effect.insertion == PostInsertion.beforeTonemap) {
beforeTonemap.add(effect);
} else {
afterTonemap.add(effect);
}
}
final pool = surface.transientTexturePool;
final width = pixelSize.width.toInt();
final height = pixelSize.height.toInt();
final postTime =
DateTime.now().millisecondsSinceEpoch.remainder(100000) / 1000.0;
// Custom effects on the linear HDR scene color, ping-ponging through
// HDR buffers and republishing the scene-color handle that bloom and
// the resolve read.
for (var i = 0; i < beforeTonemap.length; i++) {
final output = pool.acquire(
TransientTextureDescriptor.color(
width: width,
height: height,
format: gpu.PixelFormat.r16g16b16a16Float,
debugName: i.isEven ? 'post_hdr_a' : 'post_hdr_b',
),
);
graph.addPass(
PostEffectPass(
effect: beforeTonemap[i],
inputKey: kSceneColorBlackboardKey,
outputKey: kSceneColorBlackboardKey,
output: output,
dimensions: pixelSize,
time: postTime,
),
);
}
// Bloom runs in HDR before the resolve, which composites it back in.
if (postProcess.bloom.enabled) {
graph.addPass(
BloomPass(dimensions: pixelSize, settings: postProcess.bloom),
);
}
// The resolve writes the swapchain directly unless after-tone-mapping
// effects need an intermediate buffer to chain on.
final gpu.Texture resolveOutput =
afterTonemap.isEmpty
? swapchainColor
: pool.acquire(
TransientTextureDescriptor.color(
width: width,
height: height,
format: swapchainColor.format,
debugName: 'post_ldr_resolve',
),
);
graph.addPass(
ResolvePass(
outputColor: resolveOutput,
exposure: exposure,
toneMappingMode: toneMapping,
postProcess: postProcess,
),
);
// Custom effects on the display-referred image. The last one writes
// the swapchain that gets composited onto the canvas.
for (var i = 0; i < afterTonemap.length; i++) {
final isLast = i == afterTonemap.length - 1;
final output =
isLast
? swapchainColor
: pool.acquire(
TransientTextureDescriptor.color(
width: width,
height: height,
format: swapchainColor.format,
debugName: i.isEven ? 'post_ldr_a' : 'post_ldr_b',
),
);
graph.addPass(
PostEffectPass(
effect: afterTonemap[i],
inputKey: kDisplayColorBlackboardKey,
outputKey: kDisplayColorBlackboardKey,
output: output,
dimensions: pixelSize,
time: postTime,
),
);
}
graph.execute(
transientsBuffer: transientsBuffer,
texturePool: surface.transientTexturePool,
);
final image = swapchainColor.asImage();
final srcRect = ui.Rect.fromLTWH(0, 0, pixelSize.width, pixelSize.height);
final paint = ui.Paint()..filterQuality = ui.FilterQuality.medium;
if (backendFlipsRenderTargetY) {
// The Y-flip workaround stores the scene top-down via the vertex stage
// (see y_flip.dart). The web shim pairs that with a present-time blit
// flip; on native there is no such flip (asImage presents as-is), so
// flip the blit vertically here to land the image right-side up.
canvas.save();
canvas.translate(0, drawArea.top + drawArea.bottom);
canvas.scale(1, -1);
canvas.drawImageRect(image, srcRect, drawArea, paint);
canvas.restore();
} else {
canvas.drawImageRect(image, srcRect, drawArea, paint);
}
}