Render passes

A render pass declaration describes the structure of a pass: its targets and the role of each target. Shaders opt into a declared pass and name its targets in their output struct. The compiler derives every platform number (color slot, Vulkan input attachment index, Metal color index, emulated T register, color export format) from the declaration, so shader authors write only names.

Most passes need only the syntax in Simple render passes. Each section after it adds one capability: shaders that serve several passes, passes that run with several format sets, MSAA, subpasses with pixel-local reads for tile-based GPUs, and shading-rate attachments. General rules holds the rules that apply to all declarations.

Simple render passes

A pass is declared with render_pass. Its color() and depth() lists declare the targets in order of first appearance, and each entry carries its format:

render_pass forward
{
  color(scene = argb16f, velocity = rg16f);
  depth(scene_depth = d32);
}
  • color(A, B, ...) lists the color targets, at most 8. The list order is the color slot order, except on Metal, where the slot is the target index; write the color() list first so the two agree. A color target carries a color format.

  • depth(T) binds the depth target, which carries a depth format.

  • sampled_depth(T) binds the depth target read-only while shaders sample it as a texture, for example soft particles that read the scene depth. No part of the pass writes such a target.

Formats: argb8, rgba8, abgr8, argb8_srgb, rgba8_srgb, abgr8_srgb, a2rgb10, a2bgr10, r11g11b10f, argb16, argb16s, argb16f, argb16ui, argb32f, argb32ui, a1rgb5, a4rgb4, rgb565, rg8, rg8s, rg16, rg16f, rg32f, rg32ui, a8, l16, r8, r8ui, r16f, r16ui, r32f, r32ui, r32si, d16, d24, d32, d32s8.

Shader opt-in

A shader opts into a pass with a pass statement, at most one per variant:

shader forward_opaque
{
  pass forward;
  ...
}

The name resolves against the declarations above the shader, in the same unit or in an earlier include, so declare the pass before the shaders that opt into it. The one subpass of a render_pass is named main: pass forward.main; is the same opt-in, and the compiler reports the subpass as forward.main. The statement belongs to a shader; a block cannot declare it. The opt-in adds no variant axis (see TBDR devices for the one exception). The dump stores the opt-in, and in dev builds the runtime checks it against the active pass at draw.

Output struct

A shader that writes color declares its pixel shader output struct in the (ps) block, with target names as semantics. The compiler resolves each field to its platform slot and puts the plain HLSL struct in front of the shader source:

(ps) {
  FORWARD_OUTPUT@ps_output
  {
    float4 color : scene;
    float2 motion : velocity;
  }
}
hlsl(ps) {
  FORWARD_OUTPUT main_ps(VsOutput input)
  {
    FORWARD_OUTPUT ret;
    ret.color = ...;
    ret.motion = ...;
    return ret;
  }
}

The struct is resolved per variant against that variant’s opt-in. A variant that does not opt in writes a hand-written struct in its hlsl(ps) block instead, and a block cannot declare one.

  • Explicit SV_TargetN semantics are rejected. SV_Depth and other system values pass through. An SV_Coverage field does not combine with alpha_to_coverage: D3D drops the state while Vulkan and Metal combine both masks.

  • The compiled output signature must equal the declared struct. A field’s HLSL class (float, int or uint) and component count are checked against the target format.

  • On embedded platforms that ship GPU ISA in the shader bindump, the export format of each color output (the data format of the pixel shader export instructions, see the AMD RDNA 3.5 ISA reference) is fixed when the shader is compiled. The compiler derives it from the target formats, so an opted-in variant sets no export format by hand.

For dual-source blending, the src1 modifier makes a field the second blend source of a target: float4 blend : src1 scene; beside the plain field for scene. The pass must bind exactly one color target, and src1 does not combine with optional. The second source writes color slot 0 only; on Metal the slot is the target index, so declare that target before every other target of the pass.

Depth and render state

The compiler checks the static render state of an opted-in variant against the pass:

  • A shader that writes SV_Depth needs a writable depth() target.

  • A read-only depth binding (sampled_depth, or the read-only depth of TBDR devices) needs z_write = false.

  • z_write on a pass with no depth target is a warning.

  • Dual-source blending (a blend factor sc1, isc1, sa1 or isa1) needs a src1 field.

  • A src1 field that no blend factor reads is a warning.

Pass-polymorphic shaders

One compiled variant can serve several passes. A variant opts into a set of passes with any_of:

render_pass forward_lowres
{
  color(scene = argb16f);
  depth(scene_depth = d32);
}

shader forward_opaque
{
  pass any_of(forward, forward_lowres);
  ...
}

A set that several shaders share is declared once as a subpass group:

subpass forward_any = any_of(forward, forward_lowres);

shader forward_opaque
{
  pass forward_any;
  ...
}

A member is a pass with one subpass (forward, or forward.main), a PASS.SUBPASS of a pass with several (see TBDR devices), or another group, each declared before the group.

A target that only some members bind needs optional on its output struct field. The field is dropped in variants whose pass does not bind the target, and RP_OUTPUT_HAS(FIELD) from hardware_defines.dshl is 1 where the field exists:

(ps) {
  FORWARD_OUTPUT@ps_output
  {
    float4 color : scene;
    float2 motion : optional velocity;
  }
}
hlsl(ps) {
  FORWARD_OUTPUT main_ps(VsOutput input)
  {
    FORWARD_OUTPUT ret;
    ret.color = ...;
    #if RP_OUTPUT_HAS(motion)
    ret.motion = ...;
    #endif
    return ret;
  }
}

The name after optional must be a target that some pass declared above the shader declares, in any branch of its body. The per-field leaves are emitted with the pixel shader source alone, so in a variant that declares no (ps) output struct, and in every other stage, RP_OUTPUT_HAS is a preprocessor error. In a group variant the field stays where the group contract has a slot for the target, so RP_OUTPUT_HAS(FIELD) is 1 there too; the members that do not bind the target discard the write.

The group is validated where it is declared:

  • One slot cannot carry two target names across the members, and the members must agree on the depth binding kind (writable, read-only, sampled_depth).

  • Every color target of the group contract has one HLSL class (float, int or uint) across the members and across the supported layouts of each member, because one compiled field has one class.

  • A target that members bind at different slots stays legal, but leaves the group contract. An output struct field that names it is an error; a shader that writes it opts into a concrete pass instead.

A group variant is accepted in any member and with no pass active. So is a variant that opts into a pass with one subpass and no reads: it writes the same slots on plain render targets.

Format-polymorphic passes

A pass whose targets take a format that the game selects at run time, for example an HDR scene format that depends on the device, names framebuffer layouts instead of inline formats. A framebuffer_layout gives each target name a format, and any_of names a set of layouts:

framebuffer_layout forward_fb_hdr16
{
  scene = argb16f;
  velocity = rg16f;
  scene_depth = d32;
}

framebuffer_layout forward_fb_hdr11
{
  scene = r11g11b10f;
  velocity = rg16f;
  scene_depth = d32;
}

framebuffer_layout forward_fb = any_of(forward_fb_hdr16, forward_fb_hdr11);

render_pass forward
{
  framebuffer(forward_fb);
  color(scene, velocity);
  depth(scene_depth);
}
  • framebuffer(LAYOUT, ...) names the layouts the pass supports. A pass declares its formats in exactly one of two forms: a framebuffer() statement, or a format on every target. A pass that has both is an error.

  • A layout is known once its declaration precedes the pass, so declare layouts first.

  • Every pass target must be an entry of every supported layout. A layout entry that the pass does not bind is legal.

  • A layout body accepts if (hardware.X) { ... } else { ... } like a pass body.

One shape then serves every format set, and one shader variant serves every layout: the compiler checks each output field against the target format in every supported layout. The runtime creates a pass instance for one of the supported layouts.

MSAA

A sample count is written xN after a format, inline or in a framebuffer layout. The sample count is a layout fact and never reaches a shader, so MSAA adds no shader variant.

In a simple pass, resolve(DST) on an entry declares a multisampled target and its resolve destination together:

render_pass forward_msaa
{
  color(scene_ms = argb8 x4 resolve(scene = argb8));
  depth(depth_ms = d24 x4 resolve(depth = d24));
}
  • resolve(DST) resolves the target into DST at the end of the pass. DST is a single-sampled target with no other role, and it carries the format of the source in every supported layout.

  • The color targets and the depth target of one pass (one subpass, in TBDR devices) have the same sample count in every supported layout.

  • In a pass with target statements, multisampled target NAME; declares a target that is bound with more than one sample. A layout entry with more than one sample requires the qualifier, and a qualified target needs more than one sample in every supported layout. There resolve(DST) is written on the target statement and DST is declared by its own target statement.

  • A multisampled target is read per sample with pixelLocalLoadSample (see TBDR devices).

TBDR devices

On tile-based GPUs a later subpass can read what an earlier subpass of the same pass wrote, from tile memory and without a trip through main memory. A pass with several subpasses is declared with multi_render_pass:

multi_render_pass deferred
{
  allow_split;
  framebuffer(deferred_fb);

  target gbuf0;
  target gbuf1;
  target result;
  target zbuf;
  if (hardware.metal) { target zbuf_value; }

  subpass fill
  {
    color(gbuf0, gbuf1);
    if (hardware.metal) { color(zbuf_value); }
    depth(zbuf);
  }
  subpass resolve
  {
    color(result);
    depth(zbuf);
    if (hardware.metal) { read(gbuf0, gbuf1, zbuf_value); }
    else { read(gbuf0, gbuf1, zbuf); }
  }
}

Metal fetches color attachments only, so the shape above writes the depth value to a color target there and reads that target instead of the depth.

Statements of a multi_render_pass body:

  • target NAME; declares a target. target NAME = FORMAT [xN] [resolve(DST)]; also gives the format, the sample count and a resolve destination, in place of a framebuffer() statement.

  • framebuffer(LAYOUT, ...); and allow_split; apply to the whole pass.

  • subpass NAME { ... } declares a subpass. Its statements are color(...), depth(T), sampled_depth(T), read(...) and vrs(T), and they name targets only.

  • if (hardware.X) { ... } else { ... } selects statements per target platform.

A subpass block under render_pass, or a multi_render_pass without one, is an error that names the other keyword.

Subpass roles, beyond those of Simple render passes:

  • read(A, B, ...) lists the input attachments, at most 8 per subpass. The list order is the attachment ordinal order. A subpass cannot read a target it writes as color.

  • A depth() target that is also in the subpass read() list binds read-only, and the shaders drawn in that subpass must set z_write = false. Metal cannot read a depth target; write the depth value to a color target there.

On Metal the color slot is the target declaration index, not the color() list position. Declare the targets that every shape shares first, so a target added in a hardware branch does not move the others. A read target is declared among the first 8 targets, because Metal fetches the input attachment by target index.

A shader opts into one subpass with pass PASS.SUBPASS;. A subpass group can name subpasses of several passes: subpass gbuffer_writers = any_of(deferred.fill, deferred_msaa.fill);.

Subpass reads

A @subpass_read binding in the (ps) block reads an attachment of the opted-in subpass. pixelLocalLoad and pixelLocalLoadSample from hardware_defines.dshl read it:

(ps) {
  g0@subpass_read = gbuf0;             // a target of the opted-in pass
  g1@subpass_read = deferred.gbuf1;    // the same, with the pass named
  zd@subpass_read = zbuf_ms;           // declared `multisampled target zbuf_ms;`
}
hlsl(ps) {
  float4 a = pixelLocalLoad(g0, input.tc);
  float4 b = pixelLocalLoad(g1, input.tc);
  float d = pixelLocalLoadSample(zd, input.tc, sampleIndex).r;
}

A block cannot declare the binding, and it cannot be an array. It compiles to an input attachment where subpasses are native (Vulkan, iOS Metal) and to an ordinary pixel shader texture elsewhere, at a register the render pass binds. The read is a float read on every backend, so the target carries a float-class format (float, unorm, snorm or a depth format) in every supported layout; a read of a r32ui or r32si target is an error that names the layout.

In a subpass group the read lists share one common prefix, compared by target name, by target index and by the multisampled qualifier. A group variant reads only that prefix, and the members of a reading group must agree on allow_split.

Split execution

allow_split; permits execution without native subpasses: one set_render_target per subpass, with the reads bound as ordinary textures. The runtime picks this mode on a device whose native subpass reads cannot be trusted. On a backend with native subpasses, a variant that opts into a subpass of an allow_split pass whose read list is not empty gains the render_pass_split_mode interval as a variant dimension, whether or not the shader itself declares a read, because both lowerings of the reads must exist. This is the one opt-in that adds a variant axis.

Variable rate shading

vrs(T) names the shading rate attachment of a pass (or of a subpass), an r8ui target:

render_pass forward_vrs
{
  color(scene = argb16f);
  depth(scene_depth = d32);
  vrs(shading_rate = r8ui);
}

The target has no other role and no shader sees it. On a device without shading rate attachments the runtime ignores it and the pass renders at full rate. On Metal every subpass names the same vrs target or none, and the vrs target is declared after every color target.

General rules

  • render_pass, multi_render_pass, framebuffer_layout, subpass and pass are reserved words. A source that uses one of them as an identifier does not parse.

  • Passes and subpass groups share one namespace, so a pass and a group cannot have the same name. Framebuffer layouts have a namespace of their own. The same declaration in two compilation units must match field for field, so declare each of them once in a shared include.

  • A target name that begins with sv_ is reserved for system values, and optional and src1 are reserved as output struct field modifiers.

  • In if (hardware.X) conditions only hardware.*, true and false are allowed.

Runtime

A declared shape becomes a live pass through ShaderRenderPass (prog/dagorInclude/shaders/dag_shaderRenderPass.h). find_id resolves a pass name from the loaded dump, a Pass bakes one instance from the shape, one of its framebuffer layouts and the caller’s boundary plan (Actions: how each target’s contents cross the pass boundary), and the instance is driven with the targets bound by name (Targets) through begin/next_subpass/end, the RAII Scope, or the dev-only Marker for code that keeps plain set_render_target. Where the device cannot run the reads natively, an allow_split shape runs as one plain render target set per subpass with the reads as textures. In dev builds every opted-in variant is checked against the active pass at draw.

A pass makes no state transitions for its targets after it ends. The caller issues the barriers that the next readers of a target need.

namespace ShaderRenderPass

Runtime of the DSHL render_pass objects.

A render_pass declared in DSHL is a shape: targets, subpasses, reads, resolves and the framebuffer layouts it supports, stored in the shader dump. This API turns a shape into a live d3d render pass and drives it. Shapes and layouts are looked up in the main dump only.

Low-level bindump queries

Should not be used by game code directly. A dump load or reload makes every returned span and name invalid, so query again after it.

enum class DepthUse : uint8_t

Values:

enumerator Write
enumerator Read

read-only, and listed in reads

enumerator Sampled

read-only, sampled through a register the caller binds

int get_target_count(Id id)
const char *get_target_name(Id id, int i)
int get_target_slot(Id id, const char *name)
bool is_target_multisampled(Id id, int i)
dag::ConstSpan<ResolvePair> get_resolves(Id id)
int get_subpass_count(Id id)
SubpassInfo get_subpass_info(Id id, int subpass)
dag::ConstSpan<FramebufferId> get_pass_framebuffers(Id id)
FramebufferId get_only_framebuffer(Id id)
const char *get_framebuffer_name(FramebufferId fb)
TargetFormatInfo get_framebuffer_target_format(Id id, FramebufferId fb, int target)

Enums

enum class Id : uint32_t

A render_pass shape. The value is the hash of its name, so it survives a dump reload.

Values:

enumerator Invalid
enum class FramebufferId : uint32_t

A framebuffer layout. The value is the hash of its name, so it survives a dump reload.

Values:

enumerator Invalid
enum class ExecutionConstraint : uint8_t

Values:

enumerator NativeOnly

only real pixel-local reads permitted

enumerator SplitOnly

only T-register reads permitted

enumerator Any

any of the above

enum class RpLoad : uint8_t

How the contents of a target enter the pass.

Values:

enumerator Load

Target contents are loaded from the texture.

enumerator Clear

Target is fast-cleared to the clear value.

enumerator DontCare

Target contents are undefined/discarded.

enum class RpStore : uint8_t

How the contents of a target leave the pass.

Values:

enumerator Store

Results of the render pass are saved to the texture.

enumerator DontCare

Results of the render pass are discarded and texture contents are undefined.

Functions

Id find_id(const char *name)
Returns:

Id::Invalid for an unknown name. A subpass group name is not a pass.

FramebufferId find_framebuffer_id(const char *name)
Returns:

FramebufferId::Invalid for an unknown name. A layout group and an anonymous layout have no id.

ExecutionConstraint get_execution_constraint(Id id)

How this device is allowed to execute the render pass id.

bool device_breaks_subpass_reads()

True if pixel-local reads are broken on this device and T-register reads must be used instead.

void begin(const Pass &p, const Targets &t, const RenderPassArea *area = nullptr)

Begins the specified render pass p, binding the targets t, performing the necessary clears and setting the render area to area.

Note

if area is nullptr, the render area is set to the full resolution of the render targets

void next_subpass()

Advances the current render-pass to the next subpass.

void end()

Ends the current render pass.

ActivePass get_active_pass()

Variables

constexpr int MAX_TARGETS = RENDER_PASS_MAX_TARGETS

Target capacity of a shape, of Targets and of Actions.

class Actions
#include <dag_shaderRenderPass.h>

Specifies what happens with the contents of targets at the start and end of a render pass. On non-mobile platforms, the only choice that must be made is whether to clear or load the contents at the start of the pass.

A target without an entry loads and stores its contents. A resolve source loads its contents and discards them at the end. A resolve destination, a vrs attachment and a target that no subpass writes take no entry, because their actions are fixed. An entry for one of them is fatal.

On mobile devices, careful use of DontCare can avoid unnecessary memory traffic and improve both performance and thermals. Use with caution, as undefined contents are truly undefined and can lead to visual artifacts.

Public Functions

Actions &load(const char *target, RpLoad a)
inline Actions &clear(const char *target)
Actions &store(const char *target, RpStore a)

Private Functions

Entry *entryFor(const char *target)

Private Members

Entry entries[MAX_TARGETS]
int count = 0

Friends

friend class Pass
struct Entry

Public Members

const char *name = nullptr
RpLoad load = RpLoad::Load
RpStore store = RpStore::Store
bool hasLoad = false
bool hasStore = false
struct ActivePass
#include <dag_shaderRenderPass.h>

Interface for the shader system to validate the shader render pass opt-in.

Public Members

Id passNameHash = Id::Invalid
uint32_t shapeHash = 0
int subpass = -1
const char *passName = nullptr
class Marker
#include <dag_shaderRenderPass.h>

Helper for migration. Publishes a (pass, subpass) for code that draws a shape with plain set_render_target, so dev builds check the drawn shaders opt-ins against it. A no-op in release.

Warning

Binds no reads and sets no interval, so using with a render pass w/ pixel-local reads is a fatal.

Public Functions

inline explicit Marker(Id, int = 0)
Marker(const Marker&) = delete
Marker &operator=(const Marker&) = delete
class Pass
#include <dag_shaderRenderPass.h>

A runtime render pass object that can begin a render pass.

fixes a render pass shape and a framebuffer layout as specified in DSHL, as well as start/end actions, execution mode (true subpass reads or emulated T-register reads) and a debug name.

Note

Survives device resets and best-efforts bindump reloads. If a pass or framebuffer changes or disappears after a reload, begin() is fatal until the instance is recreated.

Public Types

enum class Execution : uint8_t

Values:

enumerator Auto

Native preferred.

enumerator ForceSplit

Force T-register reads. Requires allow_split on the render pass in DSHL.

Public Functions

Pass() = default
Pass(Id id, FramebufferId fb, const char *debug_name = nullptr, const Actions *plan = nullptr, Execution execution = Execution::Auto)
Pass(Pass &&o) noexcept
Pass &operator=(Pass &&o) noexcept
~Pass()
Pass(const Pass&) = delete
Pass &operator=(const Pass&) = delete
inline bool valid() const
int targetSlot(const char *name) const
bool runsSplit() const
bool vrsEnabled() const

Private Members

Impl *impl = nullptr

Private Static Functions

static bool resolveTargets(const Impl &p, const Targets &t, RenderPassTarget *out, bool *clear_set, const char **bad_name, int *bad_slot)

Friends

friend void begin(const Pass &p, const Targets &t, const RenderPassArea *area)

Begins the specified render pass p, binding the targets t, performing the necessary clears and setting the render area to area.

Note

if area is nullptr, the render area is set to the full resolution of the render targets

struct ResolvePair

Public Members

int src
int dst
class Scope
#include <dag_shaderRenderPass.h>

RAII begin()/end(). The destructor asserts on a skipped subpass.

Public Functions

Scope(const Pass &p, const Targets &t, const RenderPassArea *area = nullptr)
~Scope()
Scope(const Scope&) = delete
Scope &operator=(const Scope&) = delete
void nextSubpass()

Private Members

int subpassCount = 0
struct SubpassInfo

Public Members

dag::ConstSpan<int> colors
int depth = -1
DepthUse depthUse = DepthUse::Write
dag::ConstSpan<int> reads
int vrs = -1
struct TargetFormatInfo

Public Members

uint32_t format = 0
uint8_t samples = 1
class Targets
#include <dag_shaderRenderPass.h>

The textures bound to the targets for one execution.

Every declared target is bound exactly once, except a vrs target Pass::vrsEnabled() rejects, and a clear value is given exactly for the targets the plan clears. Names are kept by pointer until begin(). A sampled_depth target gets only its read-only depth bind here; the caller binds the same texture to the register the shader samples.

Public Functions

Targets &tex(const char *target, BaseTexture *t, uint8_t mip = 0, uint16_t layer = 0)
Targets &clear(const char *target, ResourceClearValue v)
Targets &tex(int slot, BaseTexture *t, uint8_t mip = 0, uint16_t layer = 0)
Parameters:

slot – The target index, from Pass::targetSlot() or get_target_slot().

Targets &clear(int slot, ResourceClearValue v)

Private Functions

Entry *entryFor(const char *target)
Entry *entryFor(int slot)

Private Members

Entry entries[MAX_TARGETS]
int count = 0

Friends

friend class Pass
struct Entry

Public Members

const char *name = nullptr
RenderPassTarget target = {}
int slot = -1
bool hasClear = false