using System.Collections.Generic;
using Sandbox.Rendering;
using Sandbox.Volumes;
namespace SceneTests.Components;
[TestClass]
public class PostProcessingComponentTest
{
///
/// Serializes a GameObject to json, destroys the original, then deserializes the json
/// back into the scene and enables it - the standard save/load round trip idiom used
/// by the integration tests.
///
static GameObject SerializeRoundTrip( Scene scene, GameObject go )
{
var json = go.Serialize().ToJsonString();
go.Destroy();
scene.ProcessDeletes();
var jsonObject = Json.ParseToJsonObject( json );
SceneUtility.MakeIdGuidsUnique( jsonObject );
var clone = new GameObject( false );
clone.Deserialize( jsonObject );
clone.Enabled = true;
return clone;
}
///
/// Finds the post process layer the given camera holds for a stage by debug name,
/// using the internal PostProcessLayers storage that BasePostProcess.InsertCommandList
/// writes into. Returns null when no such layer exists.
///
static PostProcessLayer FindLayer( CameraComponent cam, Stage stage, string name )
{
if ( !cam.PostProcess.Layers.TryGetValue( stage, out var list ) )
return null;
return list.FirstOrDefault( x => x.Name == name );
}
///
/// Counts every post process layer registered on the camera across all stages.
/// Only BasePostProcess.InsertCommandList creates these layers, so the count is an
/// exact measure of how many effects built themselves this tick.
///
static int TotalLayerCount( CameraComponent cam )
{
return cam.PostProcess.Layers.Sum( x => x.Value.Count );
}
///
/// Creates the component on a fresh GameObject, cycles it disabled and enabled again,
/// then destroys the GameObject - the construction / lifecycle smoke test applied to
/// every post processing component.
///
static void CreateAndCycle( Scene scene ) where T : Component, new()
{
var go = scene.CreateObject();
var effect = go.Components.Create();
Assert.IsTrue( effect.IsValid(), $"{typeof( T ).Name} should construct" );
Assert.IsTrue( effect.Enabled, $"{typeof( T ).Name} should be enabled after create" );
effect.Enabled = false;
Assert.IsFalse( effect.Enabled, $"{typeof( T ).Name} should disable safely" );
effect.Enabled = true;
Assert.IsTrue( effect.Enabled, $"{typeof( T ).Name} should re-enable safely" );
go.Destroy();
scene.ProcessDeletes();
Assert.IsFalse( effect.IsValid(), $"{typeof( T ).Name} should be destroyed with its GameObject" );
}
///
/// Every post processing component - the effects, the volume and the outline marker -
/// can be constructed, disabled, re-enabled and destroyed in the integration host where
/// the shader and texture loads in their initializers succeed. None of them create
/// scene objects or tag the GameObject; their only render-facing state is the command
/// lists they build later through the PostProcessSystem.
///
[TestMethod]
public void EveryEffectConstructsAndSurvivesEnableCycles()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var baseline = scene.SceneWorld.SceneObjects.Count();
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
CreateAndCycle( scene );
Assert.AreEqual( baseline, scene.SceneWorld.SceneObjects.Count(), "Post process components should not create scene objects" );
}
///
/// Pins the default property values of every post process effect exactly as the
/// implementations declare them, so accidental default changes are caught.
///
[TestMethod]
public void EffectDefaultValuesArePinned()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var ao = go.Components.Create( false );
Assert.AreEqual( 1.0f, ao.Intensity );
Assert.AreEqual( 128, ao.Radius );
Assert.AreEqual( 1.0f, ao.FalloffRange );
Assert.AreEqual( 5.0f, ao.ThinCompensation );
var bloom = go.Components.Create( false );
Assert.AreEqual( SceneCamera.BloomAccessor.BloomMode.Additive, bloom.Mode );
Assert.AreEqual( 1.0f, bloom.Strength );
Assert.AreEqual( 1.0f, bloom.Threshold );
Assert.AreEqual( Color.White, bloom.Tint );
var overlay = go.Components.Create( false );
Assert.AreEqual( 0.1f, overlay.Blend );
Assert.AreEqual( BlendMode.Normal, overlay.BlendMode );
Assert.IsNull( overlay.Material );
Assert.AreEqual( 0, overlay.Order );
var blur = go.Components.Create( false );
Assert.AreEqual( 1.0f, blur.Size );
var ca = go.Components.Create( false );
Assert.AreEqual( 0.33f, ca.Scale );
Assert.AreEqual( new Vector3( 6, 2, 4 ), ca.Offset );
var adjust = go.Components.Create( false );
Assert.AreEqual( 1.0f, adjust.Blend );
Assert.AreEqual( 1.0f, adjust.Saturation );
Assert.AreEqual( 0.0f, adjust.HueRotate );
Assert.AreEqual( 1.0f, adjust.Brightness );
Assert.AreEqual( 1.0f, adjust.Contrast );
var grading = go.Components.Create( false );
Assert.AreEqual( ColorGrading.GradingType.None, grading.GradingMethod );
Assert.AreEqual( 6500.0f, grading.ColorTempK );
Assert.AreEqual( 1.0f, grading.BlendFactor );
Assert.AreEqual( Texture.White, grading.LookupTexture );
Assert.AreEqual( ColorGrading.ColorSpaceEnum.None, grading.ColorSpace );
Assert.AreEqual( 2, grading.RedCurve.Frames.Length, "Default per-channel curves have two frames" );
Assert.AreEqual( 0.5f, grading.RedCurve.Frames[0].Value, 0.001f );
Assert.AreEqual( 1.0f, grading.RedCurve.Frames[1].Value, 0.001f );
var dof = go.Components.Create( false );
Assert.AreEqual( 30.0f, dof.BlurSize );
Assert.AreEqual( 200.0f, dof.FocalDistance );
Assert.AreEqual( 500.0f, dof.FocusRange );
Assert.IsFalse( dof.FrontBlur );
Assert.IsTrue( dof.BackBlur );
var grain = go.Components.Create( false );
Assert.AreEqual( 0.1f, grain.Intensity );
Assert.AreEqual( 0.5f, grain.Response );
var outline = go.Components.Create( false );
Assert.AreEqual( Color.White, outline.Color );
Assert.AreEqual( Color.Black * 0.4f, outline.ObscuredColor );
Assert.AreEqual( Color.Transparent, outline.InsideColor );
Assert.AreEqual( Color.Transparent, outline.InsideObscuredColor );
Assert.AreEqual( 0.25f, outline.Width );
Assert.IsFalse( outline.OverrideTargets );
Assert.IsNull( outline.Targets );
Assert.IsNull( outline.Material );
var motion = go.Components.Create( false );
Assert.AreEqual( 0.05f, motion.Scale );
var pixelate = go.Components.Create( false );
Assert.AreEqual( 0.25f, pixelate.Scale );
var ssr = go.Components.Create( false );
Assert.AreEqual( 0.5f, ssr.RoughnessCutoff, 0.001f, "SSR roughness cutoff is a hardcoded constant" );
var sharpen = go.Components.Create( false );
Assert.AreEqual( 2.0f, sharpen.Scale );
Assert.AreEqual( 1.0f, sharpen.TexelSize );
var tonemap = go.Components.Create( false );
Assert.AreEqual( Tonemapping.TonemappingMode.HableFilmic, tonemap.Mode );
Assert.AreEqual( Tonemapping.ExposureColorSpaceEnum.RGB, tonemap.ExposureMethod );
Assert.IsTrue( tonemap.AutoExposureEnabled );
Assert.AreEqual( 1.0f, tonemap.MinimumExposure );
Assert.AreEqual( 3.0f, tonemap.MaximumExposure );
Assert.AreEqual( 0.0f, tonemap.ExposureCompensation );
Assert.AreEqual( 1.0f, tonemap.Rate );
var vignette = go.Components.Create( false );
Assert.AreEqual( Color.Black, vignette.Color );
Assert.AreEqual( 1.0f, vignette.Intensity );
Assert.AreEqual( 1.0f, vignette.Smoothness );
Assert.AreEqual( 1.0f, vignette.Roundness );
Assert.AreEqual( new Vector2( 0.5f, 0.5f ), vignette.Center );
var volume = go.Components.Create( false );
Assert.AreEqual( 0, volume.Priority );
Assert.AreEqual( 1.0f, volume.BlendWeight );
Assert.AreEqual( 50.0f, volume.BlendDistance );
Assert.IsTrue( volume.EditorPreview );
Assert.IsFalse( volume.IsInfinite, "Volumes default to a box volume" );
go.Destroy();
scene.ProcessDeletes();
}
///
/// All the simple screen-blit effects round trip their [Property] state through json
/// serialization on a single GameObject: blur, chromatic aberration, color adjustments,
/// film grain, pixelate, sharpen, vignette, motion blur and blit overlay.
///
[TestMethod]
public void ScreenEffectSerializationRoundTrip()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var blur = go.Components.Create();
blur.Size = 0.5f;
var ca = go.Components.Create();
ca.Scale = 0.75f;
ca.Offset = new Vector3( 1, 2, 3 );
var adjust = go.Components.Create();
adjust.Blend = 0.5f;
adjust.Saturation = 1.5f;
adjust.HueRotate = 90.0f;
adjust.Brightness = 1.25f;
adjust.Contrast = 0.75f;
var grain = go.Components.Create();
grain.Intensity = 0.4f;
grain.Response = 0.8f;
var pixelate = go.Components.Create();
pixelate.Scale = 0.6f;
var sharpen = go.Components.Create();
sharpen.Scale = 3.5f;
sharpen.TexelSize = 2.5f;
var vignette = go.Components.Create();
vignette.Color = new Color( 1.0f, 0.0f, 0.0f, 0.5f );
vignette.Intensity = 0.7f;
vignette.Smoothness = 0.3f;
vignette.Roundness = 0.4f;
vignette.Center = new Vector2( 0.25f, 0.75f );
var motion = go.Components.Create();
motion.Scale = 0.5f;
var overlay = go.Components.Create();
overlay.Blend = 0.9f;
overlay.BlendMode = BlendMode.Multiply;
overlay.Order = 7;
var clone = SerializeRoundTrip( scene, go );
var loadedBlur = clone.Components.Get();
Assert.IsNotNull( loadedBlur );
Assert.AreEqual( 0.5f, loadedBlur.Size );
var loadedCa = clone.Components.Get();
Assert.IsNotNull( loadedCa );
Assert.AreEqual( 0.75f, loadedCa.Scale );
Assert.AreEqual( new Vector3( 1, 2, 3 ), loadedCa.Offset );
var loadedAdjust = clone.Components.Get();
Assert.IsNotNull( loadedAdjust );
Assert.AreEqual( 0.5f, loadedAdjust.Blend );
Assert.AreEqual( 1.5f, loadedAdjust.Saturation );
Assert.AreEqual( 90.0f, loadedAdjust.HueRotate );
Assert.AreEqual( 1.25f, loadedAdjust.Brightness );
Assert.AreEqual( 0.75f, loadedAdjust.Contrast );
var loadedGrain = clone.Components.Get();
Assert.IsNotNull( loadedGrain );
Assert.AreEqual( 0.4f, loadedGrain.Intensity );
Assert.AreEqual( 0.8f, loadedGrain.Response );
var loadedPixelate = clone.Components.Get();
Assert.IsNotNull( loadedPixelate );
Assert.AreEqual( 0.6f, loadedPixelate.Scale );
var loadedSharpen = clone.Components.Get();
Assert.IsNotNull( loadedSharpen );
Assert.AreEqual( 3.5f, loadedSharpen.Scale );
Assert.AreEqual( 2.5f, loadedSharpen.TexelSize );
var loadedVignette = clone.Components.Get();
Assert.IsNotNull( loadedVignette );
Assert.AreEqual( new Color( 1.0f, 0.0f, 0.0f, 0.5f ), loadedVignette.Color );
Assert.AreEqual( 0.7f, loadedVignette.Intensity );
Assert.AreEqual( 0.3f, loadedVignette.Smoothness );
Assert.AreEqual( 0.4f, loadedVignette.Roundness );
Assert.AreEqual( new Vector2( 0.25f, 0.75f ), loadedVignette.Center );
var loadedMotion = clone.Components.Get();
Assert.IsNotNull( loadedMotion );
Assert.AreEqual( 0.5f, loadedMotion.Scale );
var loadedOverlay = clone.Components.Get();
Assert.IsNotNull( loadedOverlay );
Assert.AreEqual( 0.9f, loadedOverlay.Blend );
Assert.AreEqual( BlendMode.Multiply, loadedOverlay.BlendMode );
Assert.AreEqual( 7, loadedOverlay.Order );
Assert.IsNull( loadedOverlay.Material, "A null overlay material round trips as null" );
clone.Destroy();
scene.ProcessDeletes();
}
///
/// The heavier rendering effects round trip their configuration: ambient occlusion,
/// bloom, depth of field, color grading curves, tonemapping and highlight outline.
/// ScreenSpaceReflections and Highlight have no serializable settings but survive as
/// component presence.
///
[TestMethod]
public void RenderingEffectSerializationRoundTrip()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var ao = go.Components.Create();
ao.Intensity = 0.5f;
ao.Radius = 256;
ao.FalloffRange = 0.5f;
ao.ThinCompensation = 2.0f;
var bloom = go.Components.Create();
bloom.Mode = SceneCamera.BloomAccessor.BloomMode.Screen;
bloom.Strength = 5.0f;
bloom.Threshold = 1.5f;
bloom.Tint = new Color( 0.1f, 0.2f, 0.3f );
var dof = go.Components.Create();
dof.BlurSize = 50.0f;
dof.FocalDistance = 300.0f;
dof.FocusRange = 100.0f;
dof.FrontBlur = true;
dof.BackBlur = false;
var grading = go.Components.Create();
grading.GradingMethod = ColorGrading.GradingType.TemperatureControl;
grading.ColorTempK = 9000.0f;
grading.BlendFactor = 0.5f;
grading.ColorSpace = ColorGrading.ColorSpaceEnum.RGB;
grading.RedCurve = new Curve( new Curve.Frame( 0.0f, 0.25f ), new Curve.Frame( 1.0f, 0.75f ) );
var tonemap = go.Components.Create();
tonemap.Mode = Tonemapping.TonemappingMode.AgX;
tonemap.ExposureMethod = Tonemapping.ExposureColorSpaceEnum.Luminance;
tonemap.AutoExposureEnabled = false;
tonemap.MinimumExposure = 0.5f;
tonemap.MaximumExposure = 4.0f;
tonemap.ExposureCompensation = 2.0f;
tonemap.Rate = 3.0f;
var outline = go.Components.Create();
outline.Color = Color.Red;
outline.InsideColor = Color.Green;
outline.Width = 2.0f;
outline.OverrideTargets = true;
go.Components.Create();
go.Components.Create();
var clone = SerializeRoundTrip( scene, go );
var loadedAo = clone.Components.Get();
Assert.IsNotNull( loadedAo );
Assert.AreEqual( 0.5f, loadedAo.Intensity );
Assert.AreEqual( 256, loadedAo.Radius );
Assert.AreEqual( 0.5f, loadedAo.FalloffRange );
Assert.AreEqual( 2.0f, loadedAo.ThinCompensation );
var loadedBloom = clone.Components.Get();
Assert.IsNotNull( loadedBloom );
Assert.AreEqual( SceneCamera.BloomAccessor.BloomMode.Screen, loadedBloom.Mode );
Assert.AreEqual( 5.0f, loadedBloom.Strength );
Assert.AreEqual( 1.5f, loadedBloom.Threshold, 0.001f, "Threshold round trips untouched because the serialized version is current" );
Assert.AreEqual( new Color( 0.1f, 0.2f, 0.3f ), loadedBloom.Tint );
var loadedDof = clone.Components.Get();
Assert.IsNotNull( loadedDof );
Assert.AreEqual( 50.0f, loadedDof.BlurSize );
Assert.AreEqual( 300.0f, loadedDof.FocalDistance );
Assert.AreEqual( 100.0f, loadedDof.FocusRange );
Assert.IsTrue( loadedDof.FrontBlur );
Assert.IsFalse( loadedDof.BackBlur );
var loadedGrading = clone.Components.Get();
Assert.IsNotNull( loadedGrading );
Assert.AreEqual( ColorGrading.GradingType.TemperatureControl, loadedGrading.GradingMethod );
Assert.AreEqual( 9000.0f, loadedGrading.ColorTempK );
Assert.AreEqual( 0.5f, loadedGrading.BlendFactor );
Assert.AreEqual( ColorGrading.ColorSpaceEnum.RGB, loadedGrading.ColorSpace );
Assert.AreEqual( 2, loadedGrading.RedCurve.Frames.Length, "Curve key frames should round trip" );
Assert.AreEqual( 0.25f, loadedGrading.RedCurve.Frames[0].Value, 0.001f );
Assert.AreEqual( 0.75f, loadedGrading.RedCurve.Frames[1].Value, 0.001f );
var loadedTonemap = clone.Components.Get();
Assert.IsNotNull( loadedTonemap );
Assert.AreEqual( Tonemapping.TonemappingMode.AgX, loadedTonemap.Mode );
Assert.AreEqual( Tonemapping.ExposureColorSpaceEnum.Luminance, loadedTonemap.ExposureMethod );
Assert.IsFalse( loadedTonemap.AutoExposureEnabled );
Assert.AreEqual( 0.5f, loadedTonemap.MinimumExposure );
Assert.AreEqual( 4.0f, loadedTonemap.MaximumExposure );
Assert.AreEqual( 2.0f, loadedTonemap.ExposureCompensation );
Assert.AreEqual( 3.0f, loadedTonemap.Rate );
var loadedOutline = clone.Components.Get();
Assert.IsNotNull( loadedOutline );
Assert.AreEqual( Color.Red, loadedOutline.Color );
Assert.AreEqual( Color.Green, loadedOutline.InsideColor );
Assert.AreEqual( 2.0f, loadedOutline.Width );
Assert.IsTrue( loadedOutline.OverrideTargets );
Assert.IsNotNull( clone.Components.Get(), "SSR should survive the round trip" );
Assert.IsNotNull( clone.Components.Get(), "Highlight should survive the round trip" );
clone.Destroy();
scene.ProcessDeletes();
}
///
/// A PostProcessVolume round trips its blend configuration and the SceneVolume shape
/// it inherits from VolumeComponent - here a sphere volume with a custom radius.
///
[TestMethod]
public void PostProcessVolumeSerializationRoundTrip()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var volume = go.Components.Create();
volume.Priority = 3;
volume.BlendWeight = 0.5f;
volume.BlendDistance = 100.0f;
volume.EditorPreview = false;
var sv = volume.SceneVolume;
sv.Type = SceneVolume.VolumeTypes.Sphere;
sv.Sphere = new Sphere( 0, 64 );
volume.SceneVolume = sv;
var clone = SerializeRoundTrip( scene, go );
var loaded = clone.Components.Get();
Assert.IsNotNull( loaded, "Deserialized GameObject should have a PostProcessVolume" );
Assert.AreEqual( 3, loaded.Priority );
Assert.AreEqual( 0.5f, loaded.BlendWeight );
Assert.AreEqual( 100.0f, loaded.BlendDistance );
Assert.IsFalse( loaded.EditorPreview );
Assert.AreEqual( SceneVolume.VolumeTypes.Sphere, loaded.SceneVolume.Type );
Assert.AreEqual( 64.0f, loaded.SceneVolume.Sphere.Radius, 0.001f );
clone.Destroy();
scene.ProcessDeletes();
}
///
/// The PostProcessSystem runs at scene-stage end of every game tick: it clears the
/// camera's internal PostProcessLayers and rebuilds them from the enabled BasePostProcess
/// components found on the camera and its descendants. Each effect blits into its pinned
/// stage and order, disabled effects vanish on the next tick, effects on unrelated
/// GameObjects never register, turning the camera's EnablePostProcessing off empties the
/// layers, and turning the global r_postprocess convar off leaves the previous layers
/// stale because the system early-outs before clearing.
///
[TestMethod]
public void EffectsRegisterCommandListLayersOnCameraTick()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
PostProcessSystem.EnablePostProcess = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
camGo.Components.Create();
camGo.Components.Create();
var sharpen = camGo.Components.Create();
camGo.Components.Create();
camGo.Components.Create();
var childGo = scene.CreateObject();
childGo.SetParent( camGo );
childGo.Components.Create();
var unrelatedGo = scene.CreateObject();
unrelatedGo.Components.Create();
scene.GameTick();
Assert.AreEqual( 6, TotalLayerCount( cam ), "Five camera effects plus one child effect should have registered" );
var vignetteLayer = FindLayer( cam, Stage.BeforePostProcess, "Vignette" );
Assert.IsNotNull( vignetteLayer, "Vignette blits in BeforePostProcess" );
Assert.AreEqual( 5000, vignetteLayer.Order );
Assert.IsNotNull( vignetteLayer.CommandList );
var blurLayer = FindLayer( cam, Stage.BeforePostProcess, "Blur" );
Assert.IsNotNull( blurLayer );
Assert.AreEqual( 4000, blurLayer.Order );
var sharpenLayer = FindLayer( cam, Stage.AfterPostProcess, "Sharpen" );
Assert.IsNotNull( sharpenLayer );
Assert.AreEqual( 1, sharpenLayer.Order );
var caLayer = FindLayer( cam, Stage.AfterPostProcess, "ChromaticAberration" );
Assert.IsNotNull( caLayer );
Assert.AreEqual( 1000, caLayer.Order );
var pixelateLayer = FindLayer( cam, Stage.AfterPostProcess, "Pixelate" );
Assert.IsNotNull( pixelateLayer );
Assert.AreEqual( 10000, pixelateLayer.Order );
var grainLayer = FindLayer( cam, Stage.AfterPostProcess, "FilmGrain" );
Assert.IsNotNull( grainLayer, "Effects on descendant GameObjects register too" );
Assert.AreEqual( 200, grainLayer.Order );
Assert.IsNull( FindLayer( cam, Stage.AfterPostProcess, "ColorAdjustments" ), "Effects on unrelated GameObjects do not register" );
sharpen.Enabled = false;
scene.GameTick();
Assert.AreEqual( 5, TotalLayerCount( cam ), "Disabled effects should drop out on the next tick" );
Assert.IsNull( FindLayer( cam, Stage.AfterPostProcess, "Sharpen" ) );
cam.EnablePostProcessing = false;
scene.GameTick();
Assert.AreEqual( 0, TotalLayerCount( cam ), "Disabling camera post processing should clear all layers" );
cam.EnablePostProcessing = true;
scene.GameTick();
Assert.AreEqual( 5, TotalLayerCount( cam ), "Re-enabling should rebuild the layers" );
PostProcessSystem.EnablePostProcess = false;
scene.GameTick();
Assert.AreEqual( 5, TotalLayerCount( cam ), "The global convar early-outs before the clear, so the previous layers persist untouched" );
Assert.IsNotNull( FindLayer( cam, Stage.BeforePostProcess, "Vignette" ) );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
}
}
///
/// The compute-based effects also register through the same pipeline at their pinned
/// stages: AmbientOcclusion right after the depth prepass, DepthOfField after
/// transparents, Bloom before post process and ColorGrading after. Tonemapping both
/// blits in the dedicated Tonemapping stage and writes the camera's AutoExposure setup
/// during the build, the only externally visible side effect of a post process build.
/// Quality convars gate AO and DoF entirely.
///
[TestMethod]
public void ComputeEffectsRegisterLayersAndDriveAutoExposure()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
var oldAoQuality = AmbientOcclusion.UserQuality;
var oldDofQuality = DepthOfField.Quality;
var oldBloom = Bloom.UserEnabled;
PostProcessSystem.EnablePostProcess = true;
AmbientOcclusion.UserQuality = 3;
DepthOfField.Quality = 3;
Bloom.UserEnabled = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
camGo.Components.Create();
camGo.Components.Create();
camGo.Components.Create();
camGo.Components.Create();
var tonemap = camGo.Components.Create();
tonemap.AutoExposureEnabled = true;
tonemap.ExposureCompensation = 1.5f;
tonemap.MinimumExposure = 0.5f;
tonemap.MaximumExposure = 2.5f;
tonemap.Rate = 4.0f;
scene.GameTick();
var aoLayer = FindLayer( cam, Stage.AfterDepthPrepass, "Ambient Occlusion" );
Assert.IsNotNull( aoLayer, "AO registers after the depth prepass" );
Assert.AreEqual( 0, aoLayer.Order );
var dofLayer = FindLayer( cam, Stage.AfterTransparent, "Dof" );
Assert.IsNotNull( dofLayer, "DoF registers after transparents" );
Assert.AreEqual( 100, dofLayer.Order );
var bloomLayer = FindLayer( cam, Stage.BeforePostProcess, "Bloom" );
Assert.IsNotNull( bloomLayer );
Assert.AreEqual( 100, bloomLayer.Order );
var gradingLayer = FindLayer( cam, Stage.AfterPostProcess, "ColorGrading" );
Assert.IsNotNull( gradingLayer );
Assert.AreEqual( 4000, gradingLayer.Order );
var tonemapLayer = FindLayer( cam, Stage.Tonemapping, "Tonemapping" );
Assert.IsNotNull( tonemapLayer, "Tonemapping blits in its dedicated stage" );
Assert.AreEqual( 0, tonemapLayer.Order );
Assert.IsTrue( cam.AutoExposure.Enabled, "Tonemapping should enable the camera's auto exposure" );
Assert.AreEqual( 1.5f, cam.AutoExposure.Compensation, 0.001f );
Assert.AreEqual( 0.5f, cam.AutoExposure.MinimumExposure, 0.001f );
Assert.AreEqual( 2.5f, cam.AutoExposure.MaximumExposure, 0.001f );
Assert.AreEqual( 4.0f, cam.AutoExposure.Rate, 0.001f );
tonemap.AutoExposureEnabled = false;
scene.GameTick();
Assert.IsFalse( cam.AutoExposure.Enabled, "Tonemapping should write the disabled flag through too" );
AmbientOcclusion.UserQuality = 0;
scene.GameTick();
Assert.IsNull( FindLayer( cam, Stage.AfterDepthPrepass, "Ambient Occlusion" ), "AO quality 0 skips the effect entirely" );
DepthOfField.Quality = 0;
scene.GameTick();
Assert.IsNull( FindLayer( cam, Stage.AfterTransparent, "Dof" ), "DoF quality 0 skips the effect entirely" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
AmbientOcclusion.UserQuality = oldAoQuality;
DepthOfField.Quality = oldDofQuality;
Bloom.UserEnabled = oldBloom;
}
}
///
/// Every screen effect early-outs from Render when its strength is zero, leaving no
/// layer at all on the camera. Bumping a single intensity registers exactly that one
/// layer, and Vignette additionally gates on the blended color's alpha - a fully
/// transparent vignette color skips the blit even with a non-zero intensity.
///
[TestMethod]
public void ZeroStrengthEffectsSkipLayerRegistration()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
var oldBloom = Bloom.UserEnabled;
PostProcessSystem.EnablePostProcess = true;
Bloom.UserEnabled = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
var blur = camGo.Components.Create();
blur.Size = 0.0f;
var vignette = camGo.Components.Create();
vignette.Intensity = 0.0f;
var sharpen = camGo.Components.Create();
sharpen.Scale = 0.0f;
var pixelate = camGo.Components.Create();
pixelate.Scale = 0.0f;
var grain = camGo.Components.Create();
grain.Intensity = 0.0f;
var ca = camGo.Components.Create();
ca.Scale = 0.0f;
var bloom = camGo.Components.Create();
bloom.Strength = 0.0f;
var grading = camGo.Components.Create();
grading.BlendFactor = 0.0f;
var motion = camGo.Components.Create();
motion.Scale = 0.0f;
scene.GameTick();
Assert.AreEqual( 0, TotalLayerCount( cam ), "All zero-strength effects should early out without registering layers" );
vignette.Intensity = 0.5f;
scene.GameTick();
Assert.AreEqual( 1, TotalLayerCount( cam ), "Only the vignette should have registered" );
Assert.IsNotNull( FindLayer( cam, Stage.BeforePostProcess, "Vignette" ) );
vignette.Color = Color.Transparent;
scene.GameTick();
Assert.AreEqual( 0, TotalLayerCount( cam ), "A fully transparent vignette color skips the blit despite the intensity" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
Bloom.UserEnabled = oldBloom;
}
}
///
/// The Highlight camera effect only registers a command list while at least one
/// HighlightOutline exists in the scene. HighlightOutline.GetOutlineTargets returns the
/// enabled renderers on itself and its descendants by default; with OverrideTargets on
/// it returns the manual Targets list, treating null as empty.
///
[TestMethod]
public void HighlightRegistersOnlyWithOutlines()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
PostProcessSystem.EnablePostProcess = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
camGo.Components.Create();
scene.GameTick();
Assert.IsNull( FindLayer( cam, Stage.AfterTransparent, "Highlight" ), "No outlines in the scene means no highlight pass" );
var outlineGo = scene.CreateObject();
var mr = outlineGo.Components.Create();
var outline = outlineGo.Components.Create();
Assert.IsTrue( outline.GetOutlineTargets().Contains( mr ), "Default targets are the renderers on self and descendants" );
scene.GameTick();
var layer = FindLayer( cam, Stage.AfterTransparent, "Highlight" );
Assert.IsNotNull( layer, "An outline in the scene should register the highlight pass" );
Assert.AreEqual( 1000, layer.Order );
outline.OverrideTargets = true;
Assert.IsFalse( outline.GetOutlineTargets().Any(), "Manual targets with a null list resolve to empty" );
outline.Targets = new List { mr };
Assert.IsTrue( outline.GetOutlineTargets().SequenceEqual( new Renderer[] { mr } ), "Manual targets return exactly the list" );
outline.Enabled = false;
scene.GameTick();
Assert.IsNull( FindLayer( cam, Stage.AfterTransparent, "Highlight" ), "Disabling the only outline removes the highlight pass" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
}
}
///
/// PostProcessVolume.GetWeight is pure math over the volume shape: the weight ramps
/// from zero at the edge to BlendWeight once the point is BlendDistance inside, using
/// the unsigned edge distance - so a point OUTSIDE the box at the same distance gets
/// the same weight (only volume containment via the volume system keeps that from
/// mattering in practice). A zero BlendDistance degenerates the remap to zero weight
/// everywhere rather than a hard edge, infinite volumes always return BlendWeight, and
/// the volume transform offsets the test position. The VolumeSystem lookup only finds
/// volumes that contain the queried position.
///
[TestMethod]
public void VolumeWeightMath()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var volume = go.Components.Create();
// Default volume is a 100 unit box (+-50) with BlendDistance 50 and BlendWeight 1
Assert.IsFalse( volume.IsInfinite );
Assert.AreEqual( 1.0f, volume.GetWeight( Vector3.Zero ), 0.001f, "The box centre is 50 units from the edge, a full blend distance" );
Assert.AreEqual( 0.2f, volume.GetWeight( new Vector3( 40, 0, 0 ) ), 0.001f, "10 units inside the edge is a fifth of the blend distance" );
Assert.AreEqual( 0.2f, volume.GetWeight( new Vector3( 60, 0, 0 ) ), 0.001f, "The edge distance is unsigned - 10 units outside weighs the same as 10 units inside" );
volume.BlendWeight = 0.5f;
Assert.AreEqual( 0.5f, volume.GetWeight( Vector3.Zero ), 0.001f, "BlendWeight caps the fully blended weight" );
volume.BlendDistance = 200.0f;
Assert.AreEqual( 0.125f, volume.GetWeight( Vector3.Zero ), 0.001f, "A blend distance larger than the box means even the centre is partially blended" );
volume.BlendDistance = 0.0f;
Assert.AreEqual( 0.0f, volume.GetWeight( Vector3.Zero ), 0.001f, "Zero blend distance degenerates the remap to zero weight, not a hard edge" );
volume.BlendDistance = 50.0f;
volume.BlendWeight = 0.75f;
var sv = volume.SceneVolume;
sv.Type = SceneVolume.VolumeTypes.Infinite;
volume.SceneVolume = sv;
Assert.IsTrue( volume.IsInfinite );
Assert.AreEqual( 0.75f, volume.GetWeight( new Vector3( 99999, 0, 0 ) ), 0.001f, "Infinite volumes return BlendWeight everywhere" );
sv.Type = SceneVolume.VolumeTypes.Box;
volume.SceneVolume = sv;
go.WorldPosition = new Vector3( 1000, 0, 0 );
var volumeSystem = scene.GetSystem();
Assert.IsNotNull( volumeSystem, "The volume system is a standard scene system" );
Assert.IsFalse( volumeSystem.FindAll( Vector3.Zero ).Contains( volume ), "The moved volume no longer contains the origin" );
Assert.IsTrue( volumeSystem.FindAll( new Vector3( 1000, 0, 0 ) ).Contains( volume ), "The volume contains its new centre" );
Assert.AreEqual( 0.75f, volume.GetWeight( new Vector3( 1000, 0, 0 ) ), 0.001f, "GetWeight follows the volume's world transform" );
go.Destroy();
scene.ProcessDeletes();
}
///
/// During the per-tick camera update, effects on the camera contribute with weight one
/// and effects inside post process volumes contribute with the volume's positional
/// weight, folded together by GetWeighted's sequential lerp. Pinned through Tonemapping's
/// auto exposure write: an infinite volume with BlendWeight 0.25 pulls each exposure
/// value a quarter of the way from the camera's value to the volume's value, while the
/// non-lerped AutoExposureEnabled flag comes from the first (camera) effect only. A box
/// volume that doesn't contain the camera contributes nothing until the camera's
/// PostProcessAnchor is placed inside it.
///
[TestMethod]
public void VolumeEffectsBlendIntoCameraExposure()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
PostProcessSystem.EnablePostProcess = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
var camTonemap = camGo.Components.Create();
camTonemap.AutoExposureEnabled = true;
camTonemap.ExposureCompensation = 1.0f;
// MinimumExposure 1, MaximumExposure 3, Rate 1 stay at their defaults
var volumeGo = scene.CreateObject();
var volume = volumeGo.Components.Create();
volume.BlendWeight = 0.25f;
var sv = volume.SceneVolume;
sv.Type = SceneVolume.VolumeTypes.Infinite;
volume.SceneVolume = sv;
var volTonemap = volumeGo.Components.Create();
volTonemap.AutoExposureEnabled = false;
volTonemap.ExposureCompensation = 5.0f;
volTonemap.MinimumExposure = 3.0f;
volTonemap.MaximumExposure = 7.0f;
volTonemap.Rate = 5.0f;
scene.GameTick();
Assert.AreEqual( 2.0f, cam.AutoExposure.Compensation, 0.001f, "lerp( lerp( 0, 1, 1 ), 5, 0.25 ) = 2" );
Assert.AreEqual( 1.5f, cam.AutoExposure.MinimumExposure, 0.001f, "lerp( 1, 3, 0.25 ) = 1.5" );
Assert.AreEqual( 4.0f, cam.AutoExposure.MaximumExposure, 0.001f, "lerp( 3, 7, 0.25 ) = 4" );
Assert.AreEqual( 2.0f, cam.AutoExposure.Rate, 0.001f, "lerp( 1, 5, 0.25 ) = 2" );
Assert.IsTrue( cam.AutoExposure.Enabled, "The enable flag is taken from the first (camera) effect, not blended" );
// Shrink the volume to the default box at the origin and move the camera away
sv.Type = SceneVolume.VolumeTypes.Box;
volume.SceneVolume = sv;
camGo.WorldPosition = new Vector3( 5000, 0, 0 );
scene.GameTick();
Assert.AreEqual( 1.0f, cam.AutoExposure.Compensation, 0.001f, "A volume that doesn't contain the camera contributes nothing" );
Assert.AreEqual( 1.0f, cam.AutoExposure.MinimumExposure, 0.001f );
Assert.AreEqual( 3.0f, cam.AutoExposure.MaximumExposure, 0.001f );
Assert.AreEqual( 1.0f, cam.AutoExposure.Rate, 0.001f );
// Anchor the volume query at the origin while the camera stays away
var anchorGo = scene.CreateObject();
cam.PostProcessAnchor = anchorGo;
scene.GameTick();
Assert.AreEqual( 2.0f, cam.AutoExposure.Compensation, 0.001f, "The PostProcessAnchor position drives volume lookup instead of the camera position" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
}
}
///
/// Volumes are applied in ascending priority order and GetWeighted lerps sequentially,
/// so with full blend weights the highest priority volume's value wins outright.
/// Swapping the priorities flips the winner on the next tick.
///
[TestMethod]
public void VolumePriorityOrdersBlending()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
PostProcessSystem.EnablePostProcess = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
var goA = scene.CreateObject();
var volA = goA.Components.Create();
volA.Priority = 0;
var svA = volA.SceneVolume;
svA.Type = SceneVolume.VolumeTypes.Infinite;
volA.SceneVolume = svA;
var tmA = goA.Components.Create();
tmA.ExposureCompensation = 10.0f;
var goB = scene.CreateObject();
var volB = goB.Components.Create();
volB.Priority = 5;
var svB = volB.SceneVolume;
svB.Type = SceneVolume.VolumeTypes.Infinite;
volB.SceneVolume = svB;
var tmB = goB.Components.Create();
tmB.ExposureCompensation = 20.0f;
scene.GameTick();
Assert.AreEqual( 20.0f, cam.AutoExposure.Compensation, 0.001f, "The higher priority volume is applied last and overrides at full weight" );
volA.Priority = 10;
scene.GameTick();
Assert.AreEqual( 10.0f, cam.AutoExposure.Compensation, 0.001f, "Raising the other volume's priority flips the winner" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
}
}
///
/// An effect that lives only inside a volume - with nothing on the camera - still
/// registers its command list on the camera when the volume contains it, using the
/// volume weight as its blend. Dropping the volume's BlendWeight to zero zeroes the
/// weighted intensity, which makes the effect early-out and unregister.
///
[TestMethod]
public void VolumeOnlyEffectRegistersOnCamera()
{
var oldEnable = PostProcessSystem.EnablePostProcess;
PostProcessSystem.EnablePostProcess = true;
try
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
var volumeGo = scene.CreateObject();
var volume = volumeGo.Components.Create();
var sv = volume.SceneVolume;
sv.Type = SceneVolume.VolumeTypes.Infinite;
volume.SceneVolume = sv;
volumeGo.Components.Create();
scene.GameTick();
Assert.IsNotNull( FindLayer( cam, Stage.BeforePostProcess, "Vignette" ), "A volume-only effect registers on the camera through the volume" );
volume.BlendWeight = 0.0f;
scene.GameTick();
Assert.IsNull( FindLayer( cam, Stage.BeforePostProcess, "Vignette" ), "Zero volume weight zeroes the blended intensity and the effect early-outs" );
}
finally
{
PostProcessSystem.EnablePostProcess = oldEnable;
}
}
///
/// BasePostProcess.Build can be driven directly with a crafted context to pin
/// GetWeighted's math without ticking: the value starts at the default and is lerped
/// towards each weighted effect in turn - weights 0.6 then 0.5 over compensations 1
/// and 3 give lerp( lerp( 0, 1, 0.6 ), 3, 0.5 ) = 1.8 - and the build inserts the
/// Tonemapping layer on the context camera. Outside of a build the context is cleared,
/// so calling Render directly throws the "Should only be called during build" guard.
///
[TestMethod]
public void WeightedBuildMathAndContextGuard()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var camGo = scene.CreateObject();
var cam = camGo.Components.Create();
var goA = scene.CreateObject();
var tmA = goA.Components.Create();
tmA.ExposureCompensation = 1.0f;
var goB = scene.CreateObject();
var tmB = goB.Components.Create();
tmB.ExposureCompensation = 3.0f;
var ctx = new PostProcessContext
{
Camera = cam,
Components = new WeightedEffect[]
{
new WeightedEffect { Effect = tmA, Weight = 0.6f },
new WeightedEffect { Effect = tmB, Weight = 0.5f },
}
};
tmA.Build( ctx );
Assert.AreEqual( 1.8f, cam.AutoExposure.Compensation, 0.001f, "lerp( lerp( 0, 1, 0.6 ), 3, 0.5 ) = 1.8" );
Assert.AreEqual( 1.0f, cam.AutoExposure.MinimumExposure, 0.001f, "Identical values are unchanged by the weighted lerp" );
Assert.AreEqual( 3.0f, cam.AutoExposure.MaximumExposure, 0.001f );
var tonemapLayer = FindLayer( cam, Stage.Tonemapping, "Tonemapping" );
Assert.IsNotNull( tonemapLayer, "A direct build inserts the layer on the context camera" );
Assert.ThrowsException( () => tmA.Render(), "After the build the context is cleared and Render must throw" );
var vignette = goA.Components.Create();
Assert.ThrowsException( () => vignette.Render(), "Render outside of any build throws the context guard" );
}
///
/// The obsolete PostProcess base class manages a raw camera command list instead of
/// going through the PostProcessSystem: enabling creates a CommandList and adds it to
/// the camera, OnUpdate resets and refills it once per tick, disabling removes and
/// nulls it, and re-enabling creates a fresh list instance.
///
[TestMethod]
public void LegacyPostProcessCommandListLifecycle()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var cam = go.Components.Create();
var probe = go.Components.Create( false );
probe.CameraRef = cam;
probe.Enabled = true;
var first = probe.ActiveCommandList;
Assert.IsNotNull( first, "Enabling should create the command list" );
Assert.AreEqual( 0, probe.BuildCount, "UpdateCommandList only runs from OnUpdate" );
scene.GameTick();
Assert.AreEqual( 1, probe.BuildCount, "One update per tick" );
scene.GameTick();
Assert.AreEqual( 2, probe.BuildCount );
probe.Enabled = false;
Assert.IsNull( probe.ActiveCommandList, "Disabling should null the command list" );
scene.GameTick();
Assert.AreEqual( 2, probe.BuildCount, "A disabled effect no longer updates" );
probe.Enabled = true;
Assert.IsNotNull( probe.ActiveCommandList, "Re-enabling should create a new command list" );
Assert.AreNotSame( first, probe.ActiveCommandList );
go.Destroy();
scene.ProcessDeletes();
}
///
/// Component json with an old (or missing) __version runs the registered JsonUpgraders
/// before the properties are applied: Bloom's v1 upgrader remaps the old Threshold range
/// via t/2+1, and AmbientOcclusion's v1 upgrader throws away the old Radius/Intensity
/// values so they fall back to the current defaults while untouched keys still apply.
///
[TestMethod]
public void JsonUpgradersRewriteOldComponentData()
{
// The upgrader method cache is global state that other tests reinitialize with
// mocked type libraries - reset it to the host's library for determinism.
JsonUpgrader.UpdateUpgraders( Game.TypeLibrary );
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var bloom = go.Components.Create( false );
var bloomJson = Json.ParseToJsonObject( "{\"__type\":\"Bloom\",\"Threshold\":1}" );
bloom.DeserializeImmediately( bloomJson );
Assert.AreEqual( 1.5f, bloom.Threshold, 0.001f, "The v1 upgrader remaps the old threshold as t/2 + 1" );
var ao = go.Components.Create( false );
var aoJson = Json.ParseToJsonObject( "{\"__type\":\"AmbientOcclusion\",\"Radius\":32,\"Intensity\":0.25,\"FalloffRange\":0.5}" );
ao.DeserializeImmediately( aoJson );
Assert.AreEqual( 128, ao.Radius, "The v1 upgrader discards the old radius, keeping the default" );
Assert.AreEqual( 1.0f, ao.Intensity, 0.001f, "The v1 upgrader discards the old intensity, keeping the default" );
Assert.AreEqual( 0.5f, ao.FalloffRange, 0.001f, "Keys the upgraders don't touch still apply" );
go.Destroy();
scene.ProcessDeletes();
}
}
#pragma warning disable CS0618 // PostProcess is deliberately exercised while it still exists
///
/// Minimal concrete implementation of the obsolete PostProcess base class, exposing the
/// protected command list and counting UpdateCommandList calls so the lifecycle test can
/// observe the enable/update/disable behavior.
///
class LegacyPostProcessProbe : PostProcess
{
///
/// How many times the per-update UpdateCommandList hook has run.
///
public int BuildCount { get; private set; }
///
/// The protected command list the base class manages, surfaced for assertions.
///
public CommandList ActiveCommandList => CommandList;
///
/// The required camera, surfaced so the test can assign it without relying on the
/// [RequireComponent] machinery resolving test-assembly types.
///
public CameraComponent CameraRef
{
get => Camera;
set => Camera = value;
}
///
/// Counts each rebuild requested by the base class's OnUpdate.
///
protected override void UpdateCommandList()
{
BuildCount++;
}
}
#pragma warning restore CS0618