Table of Contents

Shaders in a toolkit package

What to know before writing an .sdsl or .sdfx file for the toolkit: where it goes, what the build does with it, what the engine already gives you, and the three habits that keep a shader honest on every display and in every golden image. The compiler facts - the SPIR-V toolchain, reserved words, where the generated HLSL lands - are in the repository instructions; this page is the recipe.

Where a shader lives

The core package has no shaders, by decision, and stays that way. A shader makes a package carry an Effects pack item, an .sdpkg manifest and a build-time dependency on the asset compiler, and none of that belongs in the package everything else depends on. So a shader goes into the package that owns the feature - Shapes for the shape batch, DebugShapes, the two ImGui packages - or into Stride.CommunityToolkit.Effects, the home for shader-backed pieces that have no package of their own: the post-processing colour transforms and GPU picking today, more later, until the maintainers settle a longer-term layout.

Inside the package the layout is fixed:

Stride.CommunityToolkit.Effects/
  Effects/                     <- every .sdsl and .sdfx, flat; the folder name is the contract
    NightVisionShader.sdsl
    GpuPickingEffect.sdfx
  PostProcessing/NightVision.cs
  Picking/GpuPicker.cs
  Stride.CommunityToolkit.Effects.csproj

The folder must be Effects/ at the project root: the asset compiler's targets stage exactly that folder into the package as stride/Assets/Effects and write the .sdpkg manifest for it. Do not hand-write either. The project file needs one line, and a comment saying why it is there:

<PackageReference Include="Stride.AssetCompiler" Version="$(StrideVersion)" IncludeAssets="build;buildTransitive" />

Key classes (NightVisionShaderKeys, GpuPickingShaderKeys) are generated at build time by a Roslyn source generator for every shader in the project. There is no file on disk and nothing to regenerate. Declare the shader internal shader X : ... and the keys class is internal too, which is what you want unless a caller outside the package sets its parameters; a public shader with an undocumented keys class fails the XML-comment warning on every build.

What the engine already gives you

Stride ships function libraries as shaders you call qualified, without inheriting them, so a mixin stays small and its parameter block stays yours:

Library What is in it Call it as
Math PI, Luminance, Hermine and Quintic smoothsteps, FastRandom, ray-plane and ray-sphere tests, ExpDecay Math.Luminance(rgb)
ColorUtility ToLinear, SRgbToLinear, LinearToSRgb (the cubic fits the engine uses for sprites and UI) and their Precise forms, GammaToLinear, LinearToGamma ColorUtility.ToLinear(c)
HSVUtils GetHue, GetSaturation, GetValue, RGB to HSV and back HSVUtils.GetValue(rgb)
BlendUtils Overlay, the basic colour and alpha blends of a front colour over a back colour, ColorDivide BlendUtils.Overlay(a, b)
NormalPack EncodeNormal and DecodeNormal for G-buffers NormalPack.DecodeNormal(n)
Texturing The full-screen quad's TexCoord stream and the default samplers inherit it
ColorTransformShader The base of a post-processing colour transform: override Compute(float4 color) inherit it

ShapeDistance : Math in the Shapes package and NightVisionShader : ColorTransformShader, Texturing in Effects are the two patterns. Inherit for streams and overrides; qualify for functions.

Three habits

Decode colours for the target you draw on. The back buffer is sRGB, so a colour that arrives as bytes must be decoded to linear before blending and the hardware encodes it on the way out. Skip the decode and the picture is washed out; decode with pow(c, 2.2) instead of ColorUtility.ToLinear and the darks crush. Every toolkit shader that takes vertex colours does this the same way; the ShapeBatch page has the story of getting it wrong twice.

Dither any wide, soft gradient. Eight bits band. Half a code of ordered noise, fixed to the pixel grid so it never crawls, breaks the bands into something the eye does not see. The ShapeBatch colour mixin carries the version below; copy it rather than sharing it, because a shared mixin would drag a package dependency in for twenty lines.

// One 4x4 Bayer cell per four pixels either way, returned in -0.5..0.5
float Bayer(float2 pixel)
{
    int2 cell = int2(pixel) & 3;
    int index = cell.x + cell.y * 4;

    float threshold =
        index == 0 ? 0.0 : index == 1 ? 8.0 : index == 2 ? 2.0 : index == 3 ? 10.0 :
        index == 4 ? 12.0 : index == 5 ? 4.0 : index == 6 ? 14.0 : index == 7 ? 6.0 :
        index == 8 ? 3.0 : index == 9 ? 11.0 : index == 10 ? 1.0 : index == 11 ? 9.0 :
        index == 12 ? 15.0 : index == 13 ? 7.0 : index == 14 ? 13.0 : 5.0;

    return (threshold + 0.5) / 16.0 - 0.5;
}

// Half a code in the units of the target: an sRGB target's code is wider in linear terms the
// brighter the value (the curve's slope, about 2.2 times the value), never narrower than the toe
float3 Dither(float3 rgb, float noise, bool linearTarget)
{
    float3 code = linearTarget ? max(rgb * 2.2, 1.0 / 12.92) / 255.0 : 1.0 / 255.0;

    return rgb + noise * code;
}

// In PSMain: streams.ColorTarget.rgb = Dither(rgb, Bayer(streams.ShadingPosition.xy), true);

Wrap the draw in a timing scope. Two lines put the feature's GPU time under its own name in the profiler overlay's GPU section and as a marker in a frame capture. Without them the cost is invisible, folded into whatever stage the engine attributes it to. Every custom renderer in the toolkit does this; the key is public so a game can find the block by name.

public static readonly ProfilingKey ProfilingKey = new("ShapeBatch");
private static readonly Color4 ProfileColor = new(1f, 0.6f, 0.1f, 1f);

// in Draw or DrawCore
using var _ = context.QueryManager.BeginProfile(ProfileColor, ProfilingKey);

Effects and the generated mixins class

A .sdsl is a shader; a .sdfx is an effect, the recipe that picks and composes shaders from parameters at run time. The Shapes package uses one to swap in a textured fill only when a fill source is set; the Effects package uses one to give the engine's forward shading effect a child that replaces the pixel stage for picking:

effect GpuPickingEffect
{
    mixin StrideForwardShadingEffect;
    mixin child GpuPickingIds;
};

effect GpuPickingIds
{
    mixin GpuPickingShader;
};

The source generator turns each .sdfx into a registered mixin builder inside a static class named ShaderMixins, one per file namespace. Two packages with effect files therefore have two types of that name, which NDepend's same-name rule flags; the suppression in GlobalSuppression.cs names the generated class and says it is the generator's. Add one for your package if it ships an effect file.

Render features and the phase model

A root render feature uploads in Prepare, draws once per view in Draw, and releases in Flush. A feature that empties its data in Draw is drained by the first view and draws nothing in the second - which is why the shape batch's mirror panel and render-texture feeds work: the batch is emptied in Flush, after the last view. DebugShapes follows the same shape. A scene renderer (SceneRendererBase) draws once and the model does not apply; the text renderers and the picking renderer are those.

Proving it

A shader change that should change nothing is proven by the golden images: run dotnet run --file build/gold-images.cs before and after and every scene stays bit-identical. A deliberate visual change is reviewed on the contact sheet and then re-baselined with --update, and the release notes say which goldens moved and why. A new shader gets a gold scene of its own in tests/Stride.CommunityToolkit.GoldScenes, small and named for what it pins.

When a picture is wrong and the reason is not obvious, do not reason from the C#: read a pixel back with GetData, or dump the whole target to an image, and look. The picking pass's instancing bug, where every instance was drawn through its inverse matrix, was found in one dump after an hour of theories; the story is on the GPU picking page.