Table of Contents

Render passes

Each frame draws through a render graph: a set of passes, each drawing into a target, with the order worked out from what each pass reads and writes. A game with a single screen never needs to think about this, because the default pass calls Draw and draws the components. A game that clears to a colour, draws to a texture first, or runs post-processing adds passes in ConfigureRenderGraph.

protected override void ConfigureRenderGraph(FrameGraphBuilder builder, IGameTime gameTime)
{
  AddDefaultRenderGraphPass(builder, gameTime, clearColor: Color.CornflowerBlue);
}

AddDefaultRenderGraphPass adds the pass that calls Draw, clears the screen to the colour if one is given, and draws the visible components after it. Without the colour, the screen keeps what was on it.

Adding a pass

builder.AddPass(name) starts a pass. Configure it with a chain of calls, and finish with Execute, which takes the code the pass runs:

Call Meaning
Renders(RenderPassDescriptor) The target and how it is cleared. Without it, the pass draws to the screen and clears nothing.
Renders(ctx => ...) The same, computed when the pass runs, for a target made by the graph.
Reads(resource) The pass reads a resource another pass writes, so it runs after that pass.
Writes(resource) The pass draws into a resource.
DependsOn(pass) The pass runs after another, with no resource between them.
Releases(resource) The resource can be freed after this pass, earlier than the graph would free it.
Execute(ctx => ...) The pass's code. Required. It returns the pass's handle.

RenderPassDescriptor has these fields:

  • RenderTarget: the texture to draw into. Null is the screen.
  • Viewport: the part of the target to draw into.
  • ClearColorBuffer and ClearColor: clear the target to a colour first.
  • ClearDepthBuffer: clear the depth buffer first.
  • ProjectionOverride: a projection matrix in place of the logical one.
  • Tag: what the pass is for, RenderPassTag.Scene, PostProcess or UI. A material must allow the tag of the pass it draws in. The default, None, is treated as Scene.

The graph runs a pass after every pass that writes a resource it reads, and after any pass named in DependsOn. Passes with no link between them have no fixed order, so link any two that must run in sequence.

Resources

A resource is a texture a pass writes and another pass reads:

  • builder.CreateTransient(name, descriptor) makes a resource that lasts for one frame. The graph frees it after its last use.
  • builder.ImportResource(name, descriptor) declares a resource the game owns. The graph never frees it.

A FrameGraphResourceDescriptor describes the size and format. ForScaledColorTarget(1f, 1f) is a colour target the size of the screen. ForColorTarget(width, height) is a fixed size, and ForDepthTarget(width, height) a depth target.

Inside Execute and Renders, ctx.GetRenderTarget(resource) gives the IRenderTarget the graph made for it.

A scene drawn to a texture, then shown

protected override void ConfigureRenderGraph(FrameGraphBuilder builder, IGameTime gameTime)
{
  var scene = builder.CreateTransient("Scene.Color", FrameGraphResourceDescriptor.ForScaledColorTarget(1f, 1f));

  builder.AddPass("Scene")
    .Writes(scene)
    .Renders(ctx => new RenderPassDescriptor
    {
      RenderTarget = ctx.GetRenderTarget(scene),
      Tag = RenderPassTag.Scene,
      ClearColorBuffer = true,
      ClearColor = Color.Black,
    })
    .Execute(_ => DrawWorld());

  builder.AddPass("Present")
    .Reads(scene)
    .Renders(new RenderPassDescriptor { Tag = RenderPassTag.UI, ClearColorBuffer = true, ClearColor = Color.Black })
    .Execute(ctx =>
    {
      using var encoder = Graphics2D.Begin();
      encoder.SubmitSprite(new SpriteDrawOptions(
        ctx.GetRenderTarget(scene),
        new Rectangle(0, 0, RenderSurface.LogicalWidth, RenderSurface.LogicalHeight)));
    });
}

private void DrawWorld()
{
  using var encoder = Graphics2D.Begin();
  encoder.SubmitShape(ShapeDrawOptions.CircleFilled(new Vector2(320, 180), 80, Color.Gold));
}

The Present pass reads the scene, so it runs after the Scene pass. Nothing else draws the components or Draw in this graph, so the game draws everything from these two passes.

Templates

FrameGraphTemplates has ready-made shapes, which are extension methods on FrameGraphBuilder. Import BSGameLib.Game.Rendering.Templates to use them:

  • AddClearPass(name, color) adds a pass that only clears the screen.
  • AddScenePostProcessUiPipeline(...) adds three passes: a scene drawn to a texture, a post-processing pass that reads that texture, and a UI pass that runs last. You give a callback for each. The scene callback draws into the texture, the post-processing callback draws the texture to the screen, and the UI callback draws the interface.
  • AddOffscreenPass(name, descriptor, record) adds a pass that draws into a texture of its own, cleared first, and returns the pass and the texture, for a later pass to depend on and read: a mask, a lightmap or a minimap drawn before the scene that uses it.

Render targets over many frames

IOffscreenRenderer.RenderToTarget makes a new target on each call, as shown in Drawing. To draw into one target over many frames, make it once with IRenderTargetAllocator, and draw into it with IRenderContextFactory when its contents change:

private IRenderTarget _minimap;
private bool _minimapIsCurrent;

protected override void Initialize()
{
  _minimap = GetService<IRenderTargetAllocator>().Create(128, 128);
  base.Initialize();
}

protected override void BeginFrame(IGameTime gameTime)
{
  if (!_minimapIsCurrent)
  {
    var factory = GetService<IRenderContextFactory>();
    using var pass = factory.BeginRenderPass(new RenderPassDescriptor
    {
      RenderTarget = _minimap,
      Tag = RenderPassTag.UI,
      ClearColorBuffer = true,
      ClearColor = Color.Transparent,
    });
    using var encoder = factory.Begin();
    encoder.SubmitShape(ShapeDrawOptions.CircleOutline(new Vector2(64, 64), 60, Color.White));
    _minimapIsCurrent = true;
  }

  base.BeginFrame(gameTime);
}

protected override void Dispose(bool isDisposing)
{
  _minimap?.Dispose();
  base.Dispose(isDisposing);
}

The target belongs to the game, so the game disposes it. _minimapIsCurrent is a field the game sets to false when the minimap has to be drawn again.

Checks in Debug builds

The graph is checked when it is built. A debug build checks it fully, and a release build does not check it. A resource that no pass reads or writes throws. With full checking, so does any pass after the first that has no reads, writes or dependencies. The protected GraphValidationMode property sets the level: Disabled, Basic or Full.