Table of Contents

Post Effects

Every post effect Stride ships, one key each: bloom, ambient occlusion, screen-space reflections, depth of field, light streaks, lens flare, fog, outline, FXAA, and the vignette, film-grain and dither colour transforms. The default compositor has all of them switched off, so the example is the answer to "how do I turn bloom on" - a first set is enabled through ConfigurePostEffects, the rest toggle at runtime - and to the less obvious rule that colour transforms must be added, not enabled. The scene is built for the effects: over-bright lamps, a glossy floor, a receding corridor and a cluster of cubes.

The Program.cs file shows how to:

  • Enabling post effects with ConfigurePostEffects, and toggling them at runtime by flipping Enabled on the same objects
  • Which effects exist on the compositor and start disabled
  • Adding Vignetting, FilmGrain and Dither to the colour-transform group, where they cost nothing extra
  • Building an emissive material above intensity 1 so bloom has something to bloom
  • Showing live effect state as a TextOverlay section
  • Using helpers: SceneSetup3D, Add3DGround, CreatePrimitive, CreateMaterial

Post Effects

View on GitHub.

using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Rendering.Overlay;
using Stride.CommunityToolkit.Rendering.Text;
using Stride.CommunityToolkit.Skyboxes;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Input;
using Stride.Rendering;
using Stride.Rendering.Images;
using Stride.Rendering.Images.Dither;

// Every post effect Stride ships, one key each, on a scene built to show them.
//
// The default compositor has every effect switched off and only tone mapping in its colour
// transforms. Bloom, ambient occlusion, depth of field and the rest already exist on the compositor
// and only need Enabled = true: ConfigurePostEffects below does that for a first set, each key for
// one effect. Vignette, film grain and dither are different: they must be added to the colour-transform
// group, which compiles them into the tone-mapping shader, so they cost no extra pass.
//
// The scene is built for the effects: over-bright lamps for bloom, light streaks and lens flare; a
// glossy floor for screen-space reflections; a receding corridor of pillars for depth of field and
// fog; a tight cluster of cubes for ambient occlusion.

const float LampIntensity = 6f;   // emissive above 1 is what bloom and streaks feed on

Dictionary<Keys, (string Name, Func<bool> IsOn, Action Toggle)> effects = [];

using var game = new Game();

var sceneSetup = new SceneSetup3D
{
    Ground = null,
    // On the lit side of the default directional light, looking along +Z; from the other side every
    // face turned to the camera is in shadow and the whole scene reads black
    Camera = CameraSetup.Perspective(position: new Vector3(0, 4.5f, -14), rotation: new Vector3(180, -14, 0)).WithController(),
    Profiler = true
}.UseBepu().UseSkybox();

game.Run(sceneSetup, start: Start, update: Update);

void Start(Scene scene)
{
    game.ConfigurePostEffects(fx =>
    {
        // A first set switched on; each key below flips one flag.
        fx.Bloom.Enabled = true;
        fx.Bloom.Amount = 0.6f;

        fx.AmbientOcclusion.Enabled = true;

        fx.Fog.Enabled = true;
        fx.Fog.Density = 0.06f;
        fx.Fog.Color = new Color3(0.55f, 0.6f, 0.7f);

        // Tuning for effects that start off.
        fx.DepthOfField.AutoFocus = true;
        fx.LightStreak.Amount = 0.35f;
        fx.LensFlare.Amount = 0.4f;

        // The colour transforms have to be added, not enabled; only the vignette starts on.
        var vignette = new Vignetting { Amount = 0.35f, Radius = 0.8f };
        var filmGrain = new FilmGrain { Enabled = false, Amount = 0.6f, GrainSize = 2f, Animate = true };
        var dither = new Dither { Enabled = false };

        fx.ColorTransforms.Transforms.Add(vignette);
        fx.ColorTransforms.Transforms.Add(filmGrain);
        fx.ColorTransforms.Transforms.Add(dither);

        effects[Keys.D1] = ("Bloom", () => fx.Bloom.Enabled, () => fx.Bloom.Enabled ^= true);
        effects[Keys.D2] = ("Ambient occlusion", () => fx.AmbientOcclusion.Enabled, () => fx.AmbientOcclusion.Enabled ^= true);
        effects[Keys.D3] = ("Screen-space reflections", () => fx.LocalReflections.Enabled, () => fx.LocalReflections.Enabled ^= true);
        effects[Keys.D4] = ("Depth of field", () => fx.DepthOfField.Enabled, () => fx.DepthOfField.Enabled ^= true);
        effects[Keys.D5] = ("Light streaks", () => fx.LightStreak.Enabled, () => fx.LightStreak.Enabled ^= true);
        effects[Keys.D6] = ("Lens flare", () => fx.LensFlare.Enabled, () => fx.LensFlare.Enabled ^= true);
        effects[Keys.D7] = ("Fog", () => fx.Fog.Enabled, () => fx.Fog.Enabled ^= true);
        effects[Keys.D8] = ("Outline", () => fx.Outline.Enabled, () => fx.Outline.Enabled ^= true);
        effects[Keys.D9] = ("FXAA", () => fx.Antialiasing.Enabled, () => fx.Antialiasing.Enabled ^= true);
        effects[Keys.N] = ("Vignette", () => vignette.Enabled, () => vignette.Enabled ^= true);
        effects[Keys.G] = ("Film grain", () => filmGrain.Enabled, () => filmGrain.Enabled ^= true);
        effects[Keys.T] = ("Dither", () => dither.Enabled, () => dither.Enabled ^= true);
    });

    AddInstructions();
    BuildScene(scene);
}

void Update(Scene scene, GameTime time)
{
    foreach (var (key, effect) in effects)
    {
        if (game.Input.IsKeyPressed(key)) effect.Toggle();
    }

    if (game.Input.IsKeyPressed(Keys.R))
    {
        foreach (var effect in effects.Values)
        {
            if (effect.IsOn()) effect.Toggle();
        }
    }
}

void AddInstructions()
{
    // The lines are rebuilt whenever the overlay draws, so each shows the live state.
    TextOverlay.GetOrCreate(game).AddSection("Post effects", () =>
    {
        List<TextElement> lines = [];

        foreach (var (key, effect) in effects)
        {
            var on = effect.IsOn();
            lines.Add(new(KeyLabel(key), $"{effect.Name} {(on ? "on" : "off")}", on ? Color.Yellow : null));
        }

        lines.Add(new("R", "All off"));
        lines.Add(new(""));
        lines.Add(new("Each key toggles one effect", Color.LightGray));

        return lines;
    });
}

static string KeyLabel(Keys key) => key switch
{
    >= Keys.D0 and <= Keys.D9 => ((char)('0' + (key - Keys.D0))).ToString(),
    _ => key.ToString()
};

void BuildScene(Scene scene)
{
    // A glossy floor: what screen-space reflections have to work with. Glossy, not mirror-like -
    // at metalness 1 the floor turns metallic and simply shows the dark underside of the skybox.
    game.Add3DGround(new Vector2(40), new()
    {
        Material = game.CreateMaterial(new Color(95, 100, 110), metalness: 0.5f, glossiness: 0.85f),
    });

    // A corridor of pillars receding into the distance: depth of field blurs the far ones, fog swallows them.
    for (var i = 0; i < 12; i++)
    {
        var z = -4 + i * 3f;
        var shade = (byte)(170 - i * 8);

        Place(Primitive3D.Cylinder(0.6f, 2), new Vector3(-4, 1, z), game.CreateMaterial(new Color(shade, shade, (byte)(shade + 20))));
        Place(Primitive3D.Cylinder(0.6f, 2), new Vector3(4, 1, z), game.CreateMaterial(new Color(shade, shade, (byte)(shade + 20))));
    }

    // Three lamps brighter than white: bloom haloes them, light streaks and lens flare stretch them.
    Place(Primitive3D.Sphere(0.6f), new Vector3(2.5f, 1.2f, -5), Emissive(new Color(255, 140, 60)));
    Place(Primitive3D.Sphere(0.7f), new Vector3(0.8f, 0.7f, -2), Emissive(new Color(120, 200, 255)));
    Place(Primitive3D.Sphere(0.5f), new Vector3(-1.5f, 1.4f, 2), Emissive(new Color(255, 255, 200)));

    // A tight cluster of cubes: creases and contact corners that ambient occlusion darkens.
    // Negative X is screen-right for a camera looking along +Z.
    var cluster = new Vector3(-2.2f, 0, -3);
    ReadOnlySpan<Vector3> offsets = [new(-0.55f, 0.5f, 0), new(0.55f, 0.5f, 0), new(0, 0.5f, 0.9f), new(0, 1.5f, 0.35f), new(1.4f, 0.5f, 0.6f)];

    foreach (var offset in offsets)
    {
        Place(Primitive3D.Cube(), cluster + offset, game.CreateMaterial(new Color(200, 200, 205)));
    }

    void Place(Primitive3D shape, Vector3 position, Material material)
    {
        // Static, so the scene holds still: this example is about the picture, not the physics.
        var entity = game.CreatePrimitive(shape, new BepuPhysicsOptions
        {
            Material = material,
            Body = BodyMode.Static,
            Position = position,
        });

        entity.Scene = scene;
    }
}

// Diffuse plus an emissive term far above 1 (MaterialDescriptors.Emissive): the HDR overshoot is
// what bloom, streaks and flare pick out of the frame.
Material Emissive(Color color) => game.CreateEmissiveMaterial(color, LampIntensity);