Home -> Shaders

Shaders

Shaders are small programs which run on the graphics device. They describe the programmable portions of the rendering or compute pipeline, such as transforming vertices, generating fragments, expanding geometry, or running general compute work. In Cobalt, shader code is loaded into an IShaderProgram, compiled, reflected, and then attached to an IProgramNode in the render tree.

Cobalt supports multiple shader code formats, however HLSL is the recommended source language for new shader code. In practice, HLSL has become the de facto high-level shader source language for cross-API renderer work. It is the native language for Direct3D, is treated as a first-class Vulkan shader source language through the DXC compiler and SPIR-V, and is widely used as "the" shader language across the real-time rendering industry today. Major engines reflect this: Unreal Engine material graphs are compiled from HLSL-backed material expressions and allow custom HLSL code, while Unity documents HLSL as the language used for shader programs and compiles that code to the target graphics APIs.

Using HLSL as the authoring language also gives access to a broad set of shader features, including structures, arrays, structured buffers, byte address buffers, append and consume buffers, and interlocked atomic operations.

GLSL, SPIR-V assembly, and binary SPIR-V are also useful formats, particularly when integrating with external shader toolchains. In portable application code however, HLSL is generally the simplest long-term choice because a single shader source style can be used across all renderer back ends.

Program Shape

A graphics shader program must provide a vertex shader and a fragment shader. A geometry shader can be added when the GeometryShaders feature is supported by the device and enabled when the renderer is created. A compute shader program contains only a compute shader, and requires the ComputeShaders feature.

It is usually better to build a smaller number of general-purpose shader programs and control their behaviour with state values, state buffers, textures, and resource arrays, rather than creating a very large number of specialised shader programs. Changing shader programs changes significant pipeline state. On modern APIs this commonly maps to changing pipeline state objects, and on older APIs it can require substantial driver-side validation and state restoration. Where practical, group draw calls by shader program and use uniform state to select material options, lighting modes, and debug views.

uniform float4 overrideColor;
uniform bool useOverrideColor;

struct FragmentInput
{
    float4 color : COLOR0;
};

float4 main(FragmentInput input) : SV_TARGET0
{
    return useOverrideColor ? overrideColor : input.color;
}

This approach is not a rule that every possible shader should be merged into one program. Expensive branches, different resource layouts, and very different rendering techniques may justify separate shader programs. The important guideline is to avoid shader program changes for trivial differences that can be represented clearly as state.

Names Not Registers

Cobalt does not require applications to assign texture slots, sampler slots, constant buffer registers, or descriptor binding numbers in shader source code. The shader program is reflected after compilation, and the application resolves the resource names it cares about into opaque IDs such as TextureId, SamplerId, StateValueId, StateBufferId, and ResourceArrayId.

Names are the public contract between shader code and application code. They are easier to read, easier to review, and easier to validate than numeric binding slots. This is the same reason ordinary programming languages name variables and functions rather than expecting the programmer to manually remember opaque numeric addresses.

uniform Texture2D baseColorTexture;
uniform SamplerState baseColorTexture_CombinedSampler;
uniform float4 materialTint;

struct FragmentInput
{
    float2 texCoord : TEXCOORD0;
};

float4 main(FragmentInput input) : SV_TARGET0
{
    return baseColorTexture.Sample(baseColorTexture_CombinedSampler, input.texCoord) * materialTint;
}
TextureId baseColorTextureId = shaderProgram->GetTextureId("baseColorTexture");
StateValueId materialTintId = shaderProgram->GetStateValueId("materialTint");

stateGroupNode->BindTextureWithCombinedSampler(baseColorTextureId, baseColorTexture.get(), baseColorSampler.get());
stateGroupNode->SetStateValue(materialTintId, V4Float32(1.0f, 0.8f, 0.7f, 1.0f));

Resolve these names once during setup, store the IDs, and reuse those IDs when updating render tree state. It is valid to call GetTextureId and the related lookup methods repeatedly, but there is no reason to resolve text strings every frame when the result can be cached as part of material, pass, or shader setup data.

ImportantImportant

Explicit HLSL register annotations or GLSL binding decorations should not be used as the application-facing binding model. Cobalt may rewrite binding locations while converting and compiling shader code for the target renderer. The stable binding contract is the reflected resource name and the ID returned by the IShaderProgram.

Entry Point Names

The default shader entry point name is main. Several shader source structures also allow a custom entry point name to be supplied when the shader stage is loaded. This is useful when multiple entry points are stored in one source file, or when shader code is generated from a shared library of helper functions.

float4 SolidColorVertex(float3 position : position) : SV_POSITION
{
    return float4(position, 1.0f);
}
shaderProgram->LoadShaderStage(
    IShaderProgram::ShaderStage::Vertex,
    ShaderSourceInfoHLSL(vertexShaderCode, "SolidColorVertex"));

HLSL, SPIR-V assembly, and binary SPIR-V source structures expose entry point name constructors. MSL source structures also carry entry point metadata for renderers which accept MSL. GLSL source shaders use the GLSL main entry point. Already compiled bytecode formats such as DXBC and DXIL have already selected the entry point during offline compilation.

Shader Resources

Cobalt maps common shader resource constructs onto renderer API objects. Simple global uniform values are exposed as state values and are set through SetStateValue. Named constant buffers are exposed as state buffers and are bound through BindStateBuffer.

uniform float4 drawColor;

cbuffer CameraState
{
    row_major float4x4 viewProjection;
    float4 cameraPosition;
};
StateValueId drawColorId = shaderProgram->GetStateValueId("drawColor");
StateBufferId cameraStateId = shaderProgram->GetStateBufferId("CameraState");

stateGroupNode->SetStateValue(drawColorId, V4Float32(1.0f, 0.0f, 0.0f, 1.0f));
stateGroupNode->BindStateBuffer(cameraStateId, cameraStateBuffer.get(), cameraPageNo);

Texture resources map to the relevant ITextureBuffer and ITextureSampler types. Combined image samplers are the most portable approach. In HLSL, define the texture and sampler with the same base name, and suffix the sampler with _CombinedSampler. Separate texture and sampler resources can be used when the SeparateTextureSamplers feature is supported and enabled, but this is not portable to OpenGL renderers.

uniform Texture2D colorTexture;
uniform SamplerState colorTexture_CombinedSampler;

float4 SampleColor(float2 texCoord)
{
    return colorTexture.Sample(colorTexture_CombinedSampler, texCoord);
}

Buffer resources used for large structured data map to resource arrays. HLSL StructuredBuffer, RWStructuredBuffer, AppendStructuredBuffer, ConsumeStructuredBuffer, ByteAddressBuffer, and RWByteAddressBuffer map to IDataArray. HLSL Buffer and RWBuffer map to ITexelArray. Resource arrays require the ResourceArrays feature.

struct Particle
{
    float4 position;
    float4 color;
};

StructuredBuffer<Particle> inputParticles;
RWStructuredBuffer<Particle> outputParticles;

[numthreads(64, 1, 1)]
void main(uint3 threadId : SV_DispatchThreadID)
{
    Particle particle = inputParticles[threadId.x];
    particle.position.xyz += float3(0.0f, 1.0f, 0.0f);
    outputParticles[threadId.x] = particle;
}

HLSL interlocked atomic operations are supported for the resource types where the target shader model and renderer back end support them. A common use is incrementing counters or writing indirect draw arguments from a compute shader through an RWByteAddressBuffer.

RWByteAddressBuffer visibleDrawCount;

[numthreads(64, 1, 1)]
void main(uint3 threadId : SV_DispatchThreadID)
{
    uint oldDrawCount = 0;
    visibleDrawCount.InterlockedAdd(0u, 1u, oldDrawCount);
}

ImportantImportant

Interlocked operations provide atomic updates, but they are not a general replacement for shader memory barriers or careful work ordering. If a shader algorithm depends on memory visibility between threads or dispatches, use the synchronization constructs required by the shader language and target renderer.

Structured State and Arrays

State values and state buffer members can be declared as structures, arrays, arrays of structures, and nested structures. Cobalt reflects each leaf state value by name. Structure member access uses dot notation, and each ordinary array level in the reflected name is represented with [].

struct MaterialLayer
{
    float4 tint;
    float roughness;
};

uniform MaterialLayer materialLayers[4];
StateValueId layerTintId = shaderProgram->GetStateValueId("materialLayers[].tint");
StateValueId layerRoughnessId = shaderProgram->GetStateValueId("materialLayers[].roughness");

stateGroupNode->SetStateValue(layerTintId, V4Float32(1.0f, 0.8f, 0.6f, 1.0f), 2);
stateGroupNode->SetStateValue(layerRoughnessId, V1Float32(0.35f), 2);

The same naming pattern applies when arrays are nested inside structures, and when structures contain arrays of other structures. One array index is supplied for each [] in the reflected name.

struct Light
{
    float4 position;
    float4 color;
};

struct LightCluster
{
    Light lights[8];
    float exposureByView[2][4];
};

uniform LightCluster clusters[3];
StateValueId lightColorId = shaderProgram->GetStateValueId("clusters[].lights[].color");
stateGroupNode->SetStateValue(lightColorId, V4Float32(1.0f, 0.4f, 0.2f, 1.0f), 1, 5);

StateValueId exposureId = shaderProgram->GetStateValueId("clusters[].exposureByView[]");
size_t flattenedExposureIndex = (1 * 4) + 3;
stateGroupNode->SetStateValue(exposureId, V1Float32(0.75f), 1, flattenedExposureIndex);

These examples set values directly through a state container. State buffers use the same reflected member names after their layout is loaded from the shader; the lookup is performed on the state buffer or state buffer layout instead of directly on the shader program.

HLSL declarations such as float exposureByView[2][4] or float weights[2][4][6] are arrays of arrays. Cobalt supports them only when the ShaderArraysOfArrays feature is supported by the device and enabled when the renderer is created. In practice, it is only the legacy OpenGL 3.3 renderer where this feature may not be supported. For reflection however, the array-of-array portion is flattened into a single one-dimensional array entry. The reflected name contains one [] for the flattened multidimensional array, and the application supplies a single flattened index for that portion.

uniform float weights[2][4][6];
StateValueId weightsId = shaderProgram->GetStateValueId("weights[]");

size_t firstIndex = 1;
size_t secondIndex = 2;
size_t thirdIndex = 3;
size_t flattenedIndex = (firstIndex * (4 * 6)) + (secondIndex * 6) + thirdIndex;

stateGroupNode->SetStateValue(weightsId, V1Float32(1.0f), flattenedIndex);

The rightmost HLSL array index moves fastest in the flattened index calculation. For example, weights[1][2][3] in a float weights[2][4][6] declaration becomes (1 * 4 * 6) + (2 * 6) + 3.

ImportantImportant

Prefer single-dimensional arrays for new shader state unless multidimensional HLSL syntax is genuinely clearer. For example, float weights[48] is easier to reflect, easier to index from application code, and does not require the ShaderArraysOfArrays feature. If arrays of arrays are used, keep the flattening formula beside the shader declaration or wrap it in a small helper so the shader and application agree on the same indexing convention.

Supported Stages and Constructs

The following shader stages and constructs are part of the current Cobalt shader model:

Unsupported Constructs

Some shader language features are deliberately outside the current Cobalt renderer abstraction. They may be available in one underlying graphics API, but cannot currently be used as portable Cobalt shader code.

Further Reading

The following external references provide useful background for Cobalt's shader recommendations:

Next page, Best Practices