Scene Transitions¶
Scene transitions provide visual feedback during scene changes, making your game feel polished and professional. Brine2D supports fade transitions and custom loading screens.
Overview¶
What are scene transitions?
| Feature | Purpose | Use For |
|---|---|---|
| ISceneTransition | Visual effect during transition | Fade in/out, wipes, crossfades |
| LoadingScene | Progress indication | Long loads, asset loading |
| Combined | Transition + loading screen | Best user experience |
Why use transitions? - ✅ Professional feel - smooth scene changes - ✅ Hide loading - mask asset loading time - ✅ User feedback - show progress during long loads - ✅ Customizable - create unique transition effects
Basic Scene Transitions¶
Without Transition (Instant)¶
// Instant scene change (no transition)
await sceneManager.LoadSceneAsync<GameScene>();
Use when: - Scene loads instantly (< 100ms) - Testing/debugging - Menu navigation without assets
With Fade Transition¶
using Brine2D.Engine.Transitions;
// Fade to black and back (1 second)
await sceneManager.LoadSceneAsync<GameScene>(
transition: new FadeTransition(duration: 1f)
);
// Fade to white (2 seconds)
await sceneManager.LoadSceneAsync<GameScene>(
transition: new FadeTransition(duration: 2f, color: Color.White)
);
Pattern: Scene fades out → loads → fades in (duration split 50/50).
The ISceneTransition Interface¶
public interface ISceneTransition
{
/// <summary>
/// Gets the duration of the transition in seconds.
/// </summary>
float Duration { get; }
/// <summary>
/// Gets whether the transition is complete.
/// </summary>
bool IsComplete { get; }
/// <summary>
/// Gets the current progress (0.0 to 1.0).
/// </summary>
float Progress { get; }
/// <summary>
/// Called when the transition starts.
/// </summary>
void Begin();
/// <summary>
/// Updates the transition.
/// </summary>
void Update(float deltaTime);
/// <summary>
/// Renders the transition effect.
/// </summary>
void Render(IRenderer? renderer);
}
Built-in Transitions¶
FadeTransition¶
The built-in fade transition:
public class FadeTransition : ISceneTransition
{
public FadeTransition(float duration = 1f, Color? color = null)
{
Duration = duration;
_color = color ?? Color.Black;
}
// Fade out: 0.0 -> 0.5 (alpha 0 -> 255)
// Fade in: 0.5 -> 1.0 (alpha 255 -> 0)
}
Usage examples:
// Fast fade to black (0.5 seconds)
new FadeTransition(duration: 0.5f)
// Slow fade to white (2 seconds)
new FadeTransition(duration: 2f, color: Color.White)
// Fade to red (horror game effect)
new FadeTransition(duration: 1.5f, color: Color.Red)
// Fade to blue (underwater scene)
new FadeTransition(duration: 1f, color: Color.FromArgb(20, 100, 200))
Loading Screens¶
The LoadingScene Class¶
public abstract class LoadingScene : Scene
{
protected float LoadingProgress { get; private set; }
protected string LoadingMessage { get; private set; } = "Loading...";
/// <summary>
/// Updates the loading progress (0.0 to 1.0).
/// </summary>
public void UpdateProgress(float progress, string? message = null)
{
LoadingProgress = Math.Clamp(progress, 0f, 1f);
if (message != null)
{
LoadingMessage = message;
}
}
/// <summary>
/// Override this to customize the loading screen appearance.
/// </summary>
protected virtual void OnRenderLoading(GameTime gameTime)
{
// Default: progress bar + percentage
}
}
Key features: - ✅ Progress tracking - 0.0 to 1.0 - ✅ Status messages - "Loading assets...", "Initializing...", etc. - ✅ Automatic rendering - called by SceneManager - ✅ No EntityWorld - loading screens are visual-only (between scene scopes)
Using Loading Screens¶
Generic Loading Screen API (NEW!)¶
The generic API makes loading screens type-safe and automatic:
// Load with custom loading screen
await sceneManager.LoadSceneAsync<GameScene, MyLoadingScreen>();
// With transition AND loading screen
await sceneManager.LoadSceneAsync<GameScene, MyLoadingScreen>(
transition: new FadeTransition(1f)
);
Pattern: LoadSceneAsync<TScene, TLoadingScene>() automatically creates and displays the loading screen.
Basic Loading Screen¶
using Brine2D.Core;
using Brine2D.Engine;
using System.Drawing;
public class SimpleLoadingScreen : LoadingScene
{
// Default implementation shows:
// - "Loading..." text
// - Progress bar
// - Percentage
// No need to override anything!
}
// Usage
await sceneManager.LoadSceneAsync<GameScene, SimpleLoadingScreen>();
Custom Loading Screen¶
using Brine2D.Core;
using Brine2D.Engine;
using Brine2D.Rendering;
using System.Drawing;
public class CustomLoadingScreen : LoadingScene
{
private float _spinnerRotation;
protected override void OnUpdate(GameTime gameTime)
{
base.OnUpdate(gameTime);
// Animate spinner
_spinnerRotation += (float)gameTime.DeltaTime * 360f; // 1 rotation/sec
if (_spinnerRotation >= 360f)
{
_spinnerRotation -= 360f;
}
}
protected override void OnRenderLoading(GameTime gameTime)
{
var centerX = Renderer.Width / 2f;
var centerY = Renderer.Height / 2f;
// Title
Renderer.DrawText(
"Loading Game...",
centerX - 80,
centerY - 100,
Color.Cyan);
// Animated spinner
DrawSpinner(centerX, centerY - 50, 30f, _spinnerRotation);
// Progress bar
var barWidth = 400f;
var barHeight = 30f;
var barX = centerX - barWidth / 2f;
var barY = centerY + 20;
// Background (dark)
Renderer.DrawRectangleFilled(
barX, barY,
barWidth, barHeight,
Color.FromArgb(40, 40, 40));
// Progress fill (bright blue)
Renderer.DrawRectangleFilled(
barX, barY,
barWidth * LoadingProgress,
barHeight,
Color.FromArgb(50, 150, 255));
// Border
Renderer.DrawRectangleOutline(
barX, barY,
barWidth, barHeight,
Color.White,
2f);
// Status message
Renderer.DrawText(
LoadingMessage,
centerX - 60,
centerY + 70,
Color.FromArgb(200, 200, 200));
// Percentage
var percentText = $"{(int)(LoadingProgress * 100)}%";
Renderer.DrawText(
percentText,
centerX - 20,
centerY - 10,
Color.White);
}
private void DrawSpinner(float centerX, float centerY, float radius, float rotation)
{
// Draw 8 circles in a spinner pattern
for (int i = 0; i < 8; i++)
{
var angle = (rotation + i * 45f) * MathF.PI / 180f;
var x = centerX + MathF.Cos(angle) * radius;
var y = centerY + MathF.Sin(angle) * radius;
// Fade based on position
var alpha = (byte)(255 - i * 30);
var color = Color.FromArgb(alpha, 100, 200, 255);
Renderer.DrawCircleFilled(x, y, 5f, color);
}
}
}
// Usage
await sceneManager.LoadSceneAsync<GameScene, CustomLoadingScreen>(
transition: new FadeTransition(1f)
);
Transition Timeline¶
With Transition Only¶
sequenceDiagram
participant SM as SceneManager
participant T as FadeTransition
participant Old as Old Scene
participant New as New Scene
SM->>T: Begin()
Note over T: Progress 0.0
loop Fade Out (0.0 -> 0.5)
SM->>T: Update(deltaTime)
SM->>T: Render()
Note over T: Increasing alpha
end
Note over SM: Progress reaches 0.5
SM->>Old: UnloadAsync()
SM->>New: CreateEntity()
SM->>New: InitializeAsync()
SM->>New: LoadAsync()
loop Fade In (0.5 -> 1.0)
SM->>T: Update(deltaTime)
SM->>T: Render()
Note over T: Decreasing alpha
end
Note over T: Progress reaches 1.0
Note over SM: Transition complete
With Loading Screen¶
sequenceDiagram
participant SM as SceneManager
participant T as FadeTransition
participant LS as LoadingScreen
participant Old as Old Scene
participant New as New Scene
SM->>T: Begin()
loop Fade Out
SM->>T: Update()
SM->>T: Render()
end
SM->>LS: InitializeAsync()
SM->>LS: LoadAsync()
Note over LS: Loading screen visible
SM->>Old: UnloadAsync()
SM->>LS: UpdateProgress(0.3, "Unloading...")
SM->>New: CreateEntity()
SM->>LS: UpdateProgress(0.5, "Creating scene...")
SM->>New: InitializeAsync()
SM->>LS: UpdateProgress(0.7, "Initializing...")
SM->>New: LoadAsync()
SM->>LS: UpdateProgress(1.0, "Ready!")
SM->>LS: UnloadAsync()
loop Fade In
SM->>T: Update()
SM->>T: Render()
end
Note over SM: Scene fully loaded
Complete Examples¶
Example 1: Menu to Game¶
using Brine2D.Engine;
using Brine2D.Engine.Transitions;
public class MenuScene : Scene
{
private readonly ISceneManager _sceneManager;
public MenuScene(ISceneManager sceneManager)
{
_sceneManager = sceneManager;
}
protected override void OnUpdate(GameTime gameTime)
{
// When player clicks "Start Game" button
if (PlayerClickedStartButton())
{
// Simple fade transition (no loading screen needed)
await _sceneManager.LoadSceneAsync<GameScene>(
transition: new FadeTransition(0.5f)
);
}
}
}
Example 2: Level with Loading Screen¶
public class LevelSelectScene : Scene
{
private readonly ISceneManager _sceneManager;
public LevelSelectScene(ISceneManager sceneManager)
{
_sceneManager = sceneManager;
}
protected override void OnUpdate(GameTime gameTime)
{
if (PlayerSelectedLevel())
{
// Load heavy level with loading screen
await _sceneManager.LoadSceneAsync<Level1Scene, GameLoadingScreen>(
transition: new FadeTransition(1f)
);
}
}
}
public class GameLoadingScreen : LoadingScene
{
protected override void OnRenderLoading(GameTime gameTime)
{
// Custom loading screen with game-specific branding
var centerX = Renderer.Width / 2f;
var centerY = Renderer.Height / 2f;
// Game logo (if you have a texture loaded)
// Renderer.DrawTexture(_logo, centerX - 100, centerY - 150);
// Loading text
Renderer.DrawText(
"Loading Level...",
centerX - 70,
centerY,
Color.White);
// Progress bar
var barWidth = 300f;
var barHeight = 20f;
var barX = centerX - barWidth / 2f;
var barY = centerY + 40;
Renderer.DrawRectangleFilled(
barX, barY,
barWidth, barHeight,
Color.FromArgb(50, 50, 50));
Renderer.DrawRectangleFilled(
barX, barY,
barWidth * LoadingProgress,
barHeight,
Color.FromArgb(100, 200, 100));
// Tips while loading
var tips = new[]
{
"Tip: Collect power-ups for extra points!",
"Tip: Watch out for red enemies!",
"Tip: Press SPACE to jump!"
};
var tipIndex = (int)(LoadingProgress * tips.Length);
if (tipIndex < tips.Length)
{
Renderer.DrawText(
tips[tipIndex],
centerX - 150,
centerY + 80,
Color.FromArgb(150, 150, 150));
}
}
}
Example 3: Asset-Heavy Scene¶
public class Level1Scene : Scene
{
private ITexture? _background;
private ITexture? _tileset;
private ITexture? _playerSprite;
private ISoundEffect? _backgroundMusic;
protected override async Task OnLoadAsync(CancellationToken ct)
{
// SceneManager automatically updates loading progress!
// Progress: 0.3 - "Creating scene..."
// Progress: 0.5 - "Initializing..."
// Progress: 0.7 - "Loading assets..."
// Load assets
_background = await Renderer.LoadTextureAsync("backgrounds/level1.png", ct);
_tileset = await Renderer.LoadTextureAsync("tilesets/grass.png", ct);
_playerSprite = await Renderer.LoadTextureAsync("characters/player.png", ct);
_backgroundMusic = await LoadSoundAsync("music/level1.ogg", ct);
// Create entities
CreatePlayer();
CreateEnemies();
CreatePickups();
// Progress: 1.0 - "Ready!"
}
}
Pattern: SceneManager automatically updates loading progress at key points.
Creating Custom Transitions¶
Custom Wipe Transition¶
using Brine2D.Rendering;
using System.Drawing;
public class WipeTransition : ISceneTransition
{
private float _elapsed;
public float Duration { get; }
public bool IsComplete => _elapsed >= Duration;
public float Progress => Math.Clamp(_elapsed / Duration, 0f, 1f);
public WipeTransition(float duration = 1f)
{
Duration = duration;
}
public void Begin()
{
_elapsed = 0f;
}
public void Update(float deltaTime)
{
_elapsed += deltaTime;
}
public void Render(IRenderer? renderer)
{
if (renderer == null || IsComplete) return;
var viewportWidth = renderer.Camera?.ViewportWidth ?? 1280;
var viewportHeight = renderer.Camera?.ViewportHeight ?? 720;
float wipeProgress = Progress;
float wipeX;
if (wipeProgress < 0.5f)
{
// Wipe left to right (cover screen)
wipeX = viewportWidth * (wipeProgress * 2f);
renderer.DrawRectangleFilled(0, 0, wipeX, viewportHeight, Color.Black);
}
else
{
// Wipe left to right (uncover screen)
wipeX = viewportWidth * ((wipeProgress - 0.5f) * 2f);
renderer.DrawRectangleFilled(wipeX, 0, viewportWidth - wipeX, viewportHeight, Color.Black);
}
}
}
// Usage
await sceneManager.LoadSceneAsync<GameScene>(
transition: new WipeTransition(1.5f)
);
Custom Circle Transition¶
public class CircleTransition : ISceneTransition
{
private float _elapsed;
public float Duration { get; }
public bool IsComplete => _elapsed >= Duration;
public float Progress => Math.Clamp(_elapsed / Duration, 0f, 1f);
public CircleTransition(float duration = 1f)
{
Duration = duration;
}
public void Begin()
{
_elapsed = 0f;
}
public void Update(float deltaTime)
{
_elapsed += deltaTime;
}
public void Render(IRenderer? renderer)
{
if (renderer == null || IsComplete) return;
var centerX = renderer.Width / 2f;
var centerY = renderer.Height / 2f;
var maxRadius = MathF.Sqrt(centerX * centerX + centerY * centerY);
float circleProgress = Progress;
float radius;
if (circleProgress < 0.5f)
{
// Circle closes (radius decreases)
radius = maxRadius * (1f - circleProgress * 2f);
}
else
{
// Circle opens (radius increases)
radius = maxRadius * ((circleProgress - 0.5f) * 2f);
}
// Draw black overlay with circular cutout
// (This requires stencil buffer or clever rendering)
// Simplified: just draw black circle that grows/shrinks
if (circleProgress < 0.5f)
{
// Cover everything except shrinking circle
// In practice, you'd use a shader or stencil buffer
}
}
}
Note: Complex transitions may require shader support or render targets.
Best Practices¶
✅ DO¶
1. Use appropriate transition duration
// ✅ Good - quick transitions
new FadeTransition(0.5f) // Fast menu navigation
new FadeTransition(1f) // Standard scene change
new FadeTransition(2f) // Dramatic effect
// ❌ Bad - too slow
new FadeTransition(5f) // Player will get impatient!
2. Show loading screens for heavy scenes
// ✅ Good - loading screen for asset-heavy scene
await sceneManager.LoadSceneAsync<Level1Scene, GameLoadingScreen>();
// ❌ Bad - no feedback during 5-second load
await sceneManager.LoadSceneAsync<Level1Scene>(); // Looks frozen!
3. Update loading progress
// ✅ Good - progress automatically updated by SceneManager
// SceneManager calls UpdateProgress at key points:
// - 0.3: After old scene unload
// - 0.5: After scene creation
// - 0.7: During initialization
// - 1.0: After loading complete
4. Use transitions consistently
// ✅ Good - consistent style
// All scenes use FadeTransition(0.5f)
await sceneManager.LoadSceneAsync<MenuScene>(new FadeTransition(0.5f));
await sceneManager.LoadSceneAsync<GameScene>(new FadeTransition(0.5f));
await sceneManager.LoadSceneAsync<GameOverScene>(new FadeTransition(0.5f));
❌ DON'T¶
1. Don't make transitions too long
// ❌ Bad - 10 seconds is way too long
await sceneManager.LoadSceneAsync<GameScene>(
transition: new FadeTransition(10f)
);
// ✅ Good - under 2 seconds
await sceneManager.LoadSceneAsync<GameScene>(
transition: new FadeTransition(1f)
);
2. Don't use EntityWorld in LoadingScene
// ❌ Bad - LoadingScene doesn't have World
public class MyLoadingScreen : LoadingScene
{
protected override Task OnLoadAsync(CancellationToken ct)
{
World.CreateEntity("Spinner"); // ❌ World is null!
return Task.CompletedTask;
}
}
// ✅ Good - use fields/properties for state
public class MyLoadingScreen : LoadingScene
{
private float _spinnerRotation;
protected override void OnUpdate(GameTime gameTime)
{
_spinnerRotation += (float)gameTime.DeltaTime * 360f; // ✅
}
}
3. Don't block in transitions
// ❌ Bad - blocking transition
public void Render(IRenderer? renderer)
{
Thread.Sleep(1000); // Freezes game!
DownloadTexture().Wait(); // Blocks!
}
// ✅ Good - async loading happens in OnLoadAsync
protected override async Task OnLoadAsync(CancellationToken ct)
{
await DownloadTextureAsync(ct); // Proper async
}
4. Don't skip transitions on instant loads
// ❌ Bad - jarring for user
await sceneManager.LoadSceneAsync<MenuScene>(); // Instant
// ✅ Good - always use fade for polish
await sceneManager.LoadSceneAsync<MenuScene>(
transition: new FadeTransition(0.3f)
);
Troubleshooting¶
Problem: Loading screen not showing¶
Symptom: Scene loads without loading screen.
Solutions:
-
Check generic API:
// ✅ Correct - generic API await sceneManager.LoadSceneAsync<GameScene, MyLoadingScreen>(); // ❌ Wrong - missing loading screen type await sceneManager.LoadSceneAsync<GameScene>(); -
Verify LoadingScene registration (optional):
// Optional: register in DI builder.Services.AddTransient<MyLoadingScreen>();
Problem: Transition stuck at halfway¶
Symptom: Screen stays black/faded.
Solution:
Check transition duration - SceneManager waits for IsComplete:
public bool IsComplete => _elapsed >= Duration; // ✅ Must return true eventually
Problem: Loading progress not updating¶
Symptom: Progress bar stays at 0%.
Solution:
SceneManager updates progress automatically at these points: - 0.3: After old scene unload - 0.5: After scene creation - 0.7: During initialization - 1.0: After loading
Make sure your scene completes these phases.
Summary¶
Scene transitions:
| Feature | Purpose | When to Use |
|---|---|---|
| FadeTransition | Smooth visual effect | All scene changes |
| LoadingScene | Progress feedback | Asset-heavy scenes |
| Custom transitions | Unique effects | Special transitions |
| Combined | Best UX | Production games |
Key patterns:
| Pattern | Implementation |
|---|---|
| Generic API | LoadSceneAsync<TScene, TLoadingScene>() |
| Fade out → load → fade in | FadeTransition duration split 50/50 |
| Progress tracking | SceneManager updates at key points |
| No EntityWorld | LoadingScene is visual-only |
Recommended setup:
// Fast transitions: 0.5s
await sceneManager.LoadSceneAsync<MenuScene>(
transition: new FadeTransition(0.5f)
);
// Heavy scenes: transition + loading screen
await sceneManager.LoadSceneAsync<Level1Scene, GameLoadingScreen>(
transition: new FadeTransition(1f)
);
Next Steps¶
- Scene Lifecycle - Understanding scene phases
- Lifecycle Hooks - Inject behavior during transitions
- Asset Loading - Optimize texture loading
- Custom Renderers - Advanced rendering effects
Remember: Transitions make your game feel professional - always use at least a quick fade (0.3-0.5s) for polish! 🎮