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 thecolor()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_TargetNsemantics are rejected.SV_Depthand other system values pass through. AnSV_Coveragefield does not combine withalpha_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_Depthneeds a writabledepth()target.A read-only depth binding (
sampled_depth, or the read-only depth of TBDR devices) needsz_write = false.z_writeon a pass with no depth target is a warning.Dual-source blending (a blend factor
sc1,isc1,sa1orisa1) needs asrc1field.A
src1field 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: aframebuffer()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 intoDSTat the end of the pass.DSTis 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
targetstatements,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. Thereresolve(DST)is written on thetargetstatement andDSTis declared by its owntargetstatement.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 aframebuffer()statement.framebuffer(LAYOUT, ...);andallow_split;apply to the whole pass.subpass NAME { ... }declares a subpass. Its statements arecolor(...),depth(T),sampled_depth(T),read(...)andvrs(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 subpassread()list binds read-only, and the shaders drawn in that subpass must setz_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,subpassandpassare 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, andoptionalandsrc1are reserved as output struct field modifiers.In
if (hardware.X)conditions onlyhardware.*,trueandfalseare 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
-
enumerator Write
-
dag::ConstSpan<ResolvePair> get_resolves(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
-
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
-
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
-
enumerator NativeOnly
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 targetst, performing the necessary clears and setting the render area toarea.Note
if
areais 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
-
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
Friends
- friend class Pass
-
struct Entry
-
struct ActivePass
- #include <dag_shaderRenderPass.h>
Interface for the shader system to validate the shader render pass opt-in.
-
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.
-
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
Public Functions
-
Pass() = default
-
Pass(Id id, FramebufferId fb, const char *debug_name = nullptr, const Actions *plan = nullptr, Execution execution = Execution::Auto)
-
~Pass()
-
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 targetst, performing the necessary clears and setting the render area toarea.Note
if
areais nullptr, the render area is set to the full resolution of the render targets
-
Pass() = default
-
struct ResolvePair
-
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()
-
void nextSubpass()
Private Members
-
int subpassCount = 0
-
Scope(const Pass &p, const Targets &t, const RenderPassArea *area = nullptr)
-
struct SubpassInfo
-
struct TargetFormatInfo
-
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)
Friends
- friend class Pass
-
Targets &tex(const char *target, BaseTexture *t, uint8_t mip = 0, uint16_t layer = 0)
-
enum class DepthUse : uint8_t