From 9b5cd36e86879f864650e23f35f71fa1b07e6c52 Mon Sep 17 00:00:00 2001 From: botoddly <250804054+botoddly@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:39:01 +0200 Subject: [PATCH 1/4] Describe a render pass with a value type instead of allocating a builder --- docs/render-pass-flow.md | 25 +++- src/Pixely/Gpu/CommandBuffer.cs | 10 +- src/Pixely/Gpu/RenderPassBuilder.cs | 139 ++++++++++--------- tests/Pixely.Tests/RenderPassBuilderTests.cs | 134 ++++++++++++++++++ 4 files changed, 240 insertions(+), 68 deletions(-) create mode 100644 tests/Pixely.Tests/RenderPassBuilderTests.cs diff --git a/docs/render-pass-flow.md b/docs/render-pass-flow.md index 7903137c..cfd7a053 100644 --- a/docs/render-pass-flow.md +++ b/docs/render-pass-flow.md @@ -108,7 +108,30 @@ new RenderPassBuilder(commandBuffer) - `Load` - Keep existing contents - Others may exist for different load/store operations -Add multiple color targets for deferred rendering (G-buffer). +Add multiple color targets for deferred rendering (G-buffer), up to `RenderPassBuilder.MaxColorTargets` (4, the SDL_GPU limit). + +`RenderPassBuilder` is a value type with inline storage, so describing a pass every frame allocates nothing. +Each fluent call returns a new value rather than mutating the receiver, so a partly configured builder +can serve as the starting point for several passes. It holds the `CommandBuffer` it was created with, +so a builder value is good for one frame: + +```csharp +// Shared configuration, no state shared between the passes built from it +RenderPassBuilder cleared = new RenderPassBuilder(commandBuffer) + .SetSharedColorTargetSettings(ColorTargetSettings.Clear); + +using (IRenderPass albedoPass = cleared.AddColorTarget(_albedo).Build()) +{ + // ... +} + +using (IRenderPass normalPass = cleared.AddColorTarget(_normals).Build()) +{ + // ... +} +``` + +Either give every color target its own settings, or set shared settings for all of them - mixing the two throws. ## Common Patterns diff --git a/src/Pixely/Gpu/CommandBuffer.cs b/src/Pixely/Gpu/CommandBuffer.cs index a435a3ab..916e8149 100644 --- a/src/Pixely/Gpu/CommandBuffer.cs +++ b/src/Pixely/Gpu/CommandBuffer.cs @@ -83,13 +83,13 @@ public void PushVertexUniformData(uint slot, TType variable) where TType } } - public IRenderPass CreateRenderPass(List colorTargets, List colorTargetSettings, Texture? depthBuffer, DepthBufferSettings depthBufferSettings) + public IRenderPass CreateRenderPass(ReadOnlySpan colorTargets, ReadOnlySpan colorTargetSettings, Texture? depthBuffer, DepthBufferSettings depthBufferSettings) { ThrowIfDisposed(); - - Span colorTargetInfos = stackalloc SDL_GPUColorTargetInfo[colorTargets.Count]; - - for (int i = 0; i < colorTargets.Count; i++) + + Span colorTargetInfos = stackalloc SDL_GPUColorTargetInfo[colorTargets.Length]; + + for (int i = 0; i < colorTargets.Length; i++) { Texture colorTarget = colorTargets[i]; ColorTargetSettings colorTargetSetting = colorTargetSettings[i]; diff --git a/src/Pixely/Gpu/RenderPassBuilder.cs b/src/Pixely/Gpu/RenderPassBuilder.cs index c678f362..0aad2905 100644 --- a/src/Pixely/Gpu/RenderPassBuilder.cs +++ b/src/Pixely/Gpu/RenderPassBuilder.cs @@ -1,90 +1,92 @@ +using System.Runtime.CompilerServices; + namespace Pixely.Gpu; -internal struct RenderPassBuilderState +/// +/// Describes a render pass and creates it. A value type with inline storage, so building a pass +/// every frame costs nothing on the heap. Copies are independent: passing a builder around or +/// building from the same value twice does not share state. +/// +public struct RenderPassBuilder { - public RenderPassBuilderState() + // SDL_GPU accepts at most four color targets in a single render pass. + public const int MaxColorTargets = 4; + + [InlineArray(MaxColorTargets)] + private struct ColorTargetArray { - ResetState(); + private Texture _element0; } - public List ColorTargets { get; } = new(); - public List ColorTargetSettings { get; } = new(); - public Texture? DepthBuffer { get; set; } - public DepthBufferSettings DepthBufferSettings { get; set; } = DepthBufferSettings.Default; - public ColorTargetSettings? SharedColorTargetSettings { get; set; } - - public void ResetState() + [InlineArray(MaxColorTargets)] + private struct ColorTargetSettingsArray { - ColorTargets.Clear(); - ColorTargetSettings.Clear(); - DepthBuffer = null; - DepthBufferSettings = DepthBufferSettings.Default; - SharedColorTargetSettings = null; + private ColorTargetSettings _element0; } -} -public interface IRenderPassBuilder -{ - IRenderPassBuilder AddColorTarget(Texture texture); - IRenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings); - IRenderPassBuilder AddColorTargets(ReadOnlySpan textures); - IRenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings); - IRenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings); - - IRenderPass Build(); -} - -public class RenderPassBuilder : IRenderPassBuilder -{ - private RenderPassBuilderState _state = new(); private readonly CommandBuffer _commandBuffer; + private ColorTargetArray _colorTargets; + private ColorTargetSettingsArray _colorTargetSettings; + private int _colorTargetCount; + private int _colorTargetSettingsCount; + private Texture? _depthBuffer; + private DepthBufferSettings _depthBufferSettings; + private ColorTargetSettings? _sharedColorTargetSettings; public RenderPassBuilder(CommandBuffer commandBuffer) { _commandBuffer = commandBuffer; + _depthBufferSettings = DepthBufferSettings.Default; } - - public IRenderPassBuilder AddColorTarget(Texture texture) + + public RenderPassBuilder AddColorTarget(Texture texture) { - _state.ColorTargets.Add(texture); + ThrowIfColorTargetsFull(); + _colorTargets[_colorTargetCount] = texture; + _colorTargetCount++; return this; } - - public IRenderPassBuilder AddColorTargets(ReadOnlySpan textures) + + public RenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) { - foreach (var texture in textures) - { - AddColorTarget(texture); - } + ThrowIfColorTargetsFull(); + _colorTargets[_colorTargetCount] = texture; + _colorTargetCount++; + _colorTargetSettings[_colorTargetSettingsCount] = settings; + _colorTargetSettingsCount++; return this; } - public IRenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) + public RenderPassBuilder AddColorTargets(ReadOnlySpan textures) { - _state.ColorTargets.Add(texture); - _state.ColorTargetSettings.Add(settings); + foreach (Texture texture in textures) + { + ThrowIfColorTargetsFull(); + _colorTargets[_colorTargetCount] = texture; + _colorTargetCount++; + } return this; } - public IRenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) + public RenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) { - _state.SharedColorTargetSettings = settings; + _sharedColorTargetSettings = settings; return this; } - public IRenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) + public RenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) { - _state.DepthBuffer = depthBuffer; - _state.DepthBufferSettings = settings; + _depthBuffer = depthBuffer; + _depthBufferSettings = settings; return this; } - public IRenderPass Build() + public readonly IRenderPass Build() { - bool hasShared = _state.SharedColorTargetSettings != null; - bool hasPerTarget = _state.ColorTargetSettings.Count > 0; - bool hasColorTargets = _state.ColorTargets.Count > 0; - bool hasDepthBuffer = _state.DepthBuffer != null; + bool hasShared = _sharedColorTargetSettings != null; + bool hasPerTarget = _colorTargetSettingsCount > 0; + bool hasColorTargets = _colorTargetCount > 0; + bool hasDepthBuffer = _depthBuffer != null; if (hasShared && hasPerTarget) { @@ -96,25 +98,38 @@ public IRenderPass Build() throw new InvalidOperationException("Must have either shared or per-target settings set when using color targets."); } + if (hasPerTarget && _colorTargetSettingsCount != _colorTargetCount) + { + throw new InvalidOperationException("Every color target needs its own settings when per-target settings are used."); + } + if (!hasColorTargets && !hasDepthBuffer) { throw new InvalidOperationException("At least one color target or a depth buffer is required."); } + ColorTargetArray colorTargets = _colorTargets; + ColorTargetSettingsArray colorTargetSettings = _colorTargetSettings; + Span colorTargetSpan = colorTargets; + Span colorTargetSettingsSpan = colorTargetSettings; + if (hasShared) { - for (int i = 0; i < _state.ColorTargets.Count; i++) - { - _state.ColorTargetSettings.Add(_state.SharedColorTargetSettings!); - } + colorTargetSettingsSpan[.._colorTargetCount].Fill(_sharedColorTargetSettings!); } - IRenderPass renderPass = _commandBuffer.CreateRenderPass(_state.ColorTargets, _state.ColorTargetSettings, _state.DepthBuffer, - _state.DepthBufferSettings); - - _state.ResetState(); + return _commandBuffer.CreateRenderPass( + colorTargetSpan[.._colorTargetCount], + colorTargetSettingsSpan[.._colorTargetCount], + _depthBuffer, + _depthBufferSettings); + } - return renderPass; + private readonly void ThrowIfColorTargetsFull() + { + if (_colorTargetCount == MaxColorTargets) + { + throw new InvalidOperationException($"A render pass cannot have more than {MaxColorTargets} color targets."); + } } } - diff --git a/tests/Pixely.Tests/RenderPassBuilderTests.cs b/tests/Pixely.Tests/RenderPassBuilderTests.cs new file mode 100644 index 00000000..a71e33af --- /dev/null +++ b/tests/Pixely.Tests/RenderPassBuilderTests.cs @@ -0,0 +1,134 @@ +using Pixely.Gpu; + +namespace Pixely.Tests; + +public class RenderPassBuilderTests +{ + // The builder validates before it touches the command buffer, so these cases need no GPU device. + private static RenderPassBuilder CreateBuilder() + { + return new RenderPassBuilder(null!); + } + + private sealed class FakeTexture : Texture + { + public FakeTexture() : base(default, new ShortSize(1, 1), TextureFormat.R8G8B8A8Unorm, 4) + { + } + + public override void Dispose() + { + } + } + + [Test] + public void Build_WithSharedAndPerTargetSettings_Throws() + { + RenderPassBuilder builder = CreateBuilder() + .AddColorTarget(new FakeTexture(), ColorTargetSettings.Clear) + .SetSharedColorTargetSettings(ColorTargetSettings.Clear); + + Assert.That(() => builder.Build(), Throws.InvalidOperationException); + } + + [Test] + public void Build_WithColorTargetAndNoSettings_Throws() + { + RenderPassBuilder builder = CreateBuilder().AddColorTarget(new FakeTexture()); + + Assert.That(() => builder.Build(), Throws.InvalidOperationException); + } + + [Test] + public void Build_WithoutColorTargetsOrDepthBuffer_Throws() + { + RenderPassBuilder builder = CreateBuilder(); + + Assert.That(() => builder.Build(), Throws.InvalidOperationException); + } + + [Test] + public void Build_WithFewerPerTargetSettingsThanColorTargets_Throws() + { + RenderPassBuilder builder = CreateBuilder() + .AddColorTarget(new FakeTexture(), ColorTargetSettings.Clear) + .AddColorTarget(new FakeTexture()); + + Assert.That(() => builder.Build(), Throws.InvalidOperationException); + } + + [Test] + public void AddColorTarget_BeyondMaxColorTargets_Throws() + { + RenderPassBuilder builder = CreateBuilder(); + for (int target = 0; target < RenderPassBuilder.MaxColorTargets; target++) + { + builder = builder.AddColorTarget(new FakeTexture()); + } + + Assert.That(() => builder.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); + } + + [Test] + public void AddColorTargets_BeyondMaxColorTargets_Throws() + { + Texture[] textures = new Texture[RenderPassBuilder.MaxColorTargets + 1]; + for (int target = 0; target < textures.Length; target++) + { + textures[target] = new FakeTexture(); + } + + RenderPassBuilder builder = CreateBuilder(); + + Assert.That(() => builder.AddColorTargets(textures), Throws.InvalidOperationException); + } + + [Test] + public void Copies_DoNotShareState() + { + RenderPassBuilder original = CreateBuilder(); + + RenderPassBuilder copy = original; + for (int target = 0; target < RenderPassBuilder.MaxColorTargets; target++) + { + copy = copy.AddColorTarget(new FakeTexture()); + } + + Assert.Multiple(() => + { + Assert.That(() => copy.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); + Assert.That(() => original.AddColorTarget(new FakeTexture()), Throws.Nothing); + }); + } + + [Test] + public void DescribingAPass_DoesNotAllocate() + { + Texture colorTarget = new FakeTexture(); + Texture depthBuffer = new FakeTexture(); + + for (int warmUp = 0; warmUp < 4; warmUp++) + { + Describe(colorTarget, depthBuffer); + } + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < 16; iteration++) + { + Describe(colorTarget, depthBuffer); + } + + long allocatedAfter = GC.GetAllocatedBytesForCurrentThread(); + + Assert.That(allocatedAfter - allocatedBefore, Is.Zero); + } + + // Everything a renderer does per frame up to Build, which needs a real command buffer. + private static RenderPassBuilder Describe(Texture colorTarget, Texture depthBuffer) + { + return new RenderPassBuilder(null!) + .AddColorTarget(colorTarget) + .SetSharedColorTargetSettings(ColorTargetSettings.Clear) + .SetDepthBuffer(depthBuffer, DepthBufferSettings.Default); + } +} From e4591b416c602d3809807a4a42da6400198c2e97 Mon Sep 17 00:00:00 2001 From: botoddly <250804054+botoddly@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:48:55 +0200 Subject: [PATCH 2/4] Allow eight color targets, matching what SDL enforces --- docs/render-pass-flow.md | 2 +- src/Pixely/Gpu/RenderPassBuilder.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/render-pass-flow.md b/docs/render-pass-flow.md index cfd7a053..a256ba78 100644 --- a/docs/render-pass-flow.md +++ b/docs/render-pass-flow.md @@ -108,7 +108,7 @@ new RenderPassBuilder(commandBuffer) - `Load` - Keep existing contents - Others may exist for different load/store operations -Add multiple color targets for deferred rendering (G-buffer), up to `RenderPassBuilder.MaxColorTargets` (4, the SDL_GPU limit). +Add multiple color targets for deferred rendering (G-buffer), up to `RenderPassBuilder.MaxColorTargets` (8, the point at which SDL itself rejects the pass). `RenderPassBuilder` is a value type with inline storage, so describing a pass every frame allocates nothing. Each fluent call returns a new value rather than mutating the receiver, so a partly configured builder diff --git a/src/Pixely/Gpu/RenderPassBuilder.cs b/src/Pixely/Gpu/RenderPassBuilder.cs index 0aad2905..0eece4fb 100644 --- a/src/Pixely/Gpu/RenderPassBuilder.cs +++ b/src/Pixely/Gpu/RenderPassBuilder.cs @@ -9,8 +9,9 @@ namespace Pixely.Gpu; /// public struct RenderPassBuilder { - // SDL_GPU accepts at most four color targets in a single render pass. - public const int MaxColorTargets = 4; + // What SDL_BeginGPURenderPass rejects beyond: MAX_COLOR_TARGET_BINDINGS in SDL_sysgpu.h. + // SDL exposes no constant for it, and the prose in SDL_gpu.h still claims four. + public const int MaxColorTargets = 8; [InlineArray(MaxColorTargets)] private struct ColorTargetArray From 886cd617944e4920a707e1bb451c0f511a960717 Mon Sep 17 00:00:00 2001 From: botoddly <250804054+botoddly@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:05:31 +0200 Subject: [PATCH 3/4] Configure a copy in the builder instead of the receiver --- docs/render-pass-flow.md | 2 +- src/Pixely/Gpu/CommandBuffer.cs | 14 ++++ src/Pixely/Gpu/RenderPassBuilder.cs | 72 +++++++++++--------- tests/Pixely.Tests/RenderPassBuilderTests.cs | 44 +++++++++++- 4 files changed, 95 insertions(+), 37 deletions(-) diff --git a/docs/render-pass-flow.md b/docs/render-pass-flow.md index a256ba78..845a5e8b 100644 --- a/docs/render-pass-flow.md +++ b/docs/render-pass-flow.md @@ -108,7 +108,7 @@ new RenderPassBuilder(commandBuffer) - `Load` - Keep existing contents - Others may exist for different load/store operations -Add multiple color targets for deferred rendering (G-buffer), up to `RenderPassBuilder.MaxColorTargets` (8, the point at which SDL itself rejects the pass). +Add multiple color targets for deferred rendering (G-buffer), up to `CommandBuffer.MaxColorTargets` (8, the point at which SDL itself rejects the pass). `RenderPassBuilder` is a value type with inline storage, so describing a pass every frame allocates nothing. Each fluent call returns a new value rather than mutating the receiver, so a partly configured builder diff --git a/src/Pixely/Gpu/CommandBuffer.cs b/src/Pixely/Gpu/CommandBuffer.cs index 916e8149..805ff0f2 100644 --- a/src/Pixely/Gpu/CommandBuffer.cs +++ b/src/Pixely/Gpu/CommandBuffer.cs @@ -83,10 +83,24 @@ public void PushVertexUniformData(uint slot, TType variable) where TType } } + // What SDL_BeginGPURenderPass rejects beyond: MAX_COLOR_TARGET_BINDINGS in SDL_sysgpu.h. + // SDL exposes no constant for it, and the prose in SDL_gpu.h still claims four. + public const int MaxColorTargets = 8; + public IRenderPass CreateRenderPass(ReadOnlySpan colorTargets, ReadOnlySpan colorTargetSettings, Texture? depthBuffer, DepthBufferSettings depthBufferSettings) { ThrowIfDisposed(); + if (colorTargets.Length > MaxColorTargets) + { + throw new ArgumentException($"A render pass cannot have more than {MaxColorTargets} color targets.", nameof(colorTargets)); + } + + if (colorTargetSettings.Length != colorTargets.Length) + { + throw new ArgumentException($"Expected settings for {colorTargets.Length} color targets, got {colorTargetSettings.Length}.", nameof(colorTargetSettings)); + } + Span colorTargetInfos = stackalloc SDL_GPUColorTargetInfo[colorTargets.Length]; for (int i = 0; i < colorTargets.Length; i++) diff --git a/src/Pixely/Gpu/RenderPassBuilder.cs b/src/Pixely/Gpu/RenderPassBuilder.cs index 0eece4fb..5b2dbd49 100644 --- a/src/Pixely/Gpu/RenderPassBuilder.cs +++ b/src/Pixely/Gpu/RenderPassBuilder.cs @@ -9,17 +9,13 @@ namespace Pixely.Gpu; /// public struct RenderPassBuilder { - // What SDL_BeginGPURenderPass rejects beyond: MAX_COLOR_TARGET_BINDINGS in SDL_sysgpu.h. - // SDL exposes no constant for it, and the prose in SDL_gpu.h still claims four. - public const int MaxColorTargets = 8; - - [InlineArray(MaxColorTargets)] + [InlineArray(CommandBuffer.MaxColorTargets)] private struct ColorTargetArray { private Texture _element0; } - [InlineArray(MaxColorTargets)] + [InlineArray(CommandBuffer.MaxColorTargets)] private struct ColorTargetSettingsArray { private ColorTargetSettings _element0; @@ -40,46 +36,56 @@ public RenderPassBuilder(CommandBuffer commandBuffer) _depthBufferSettings = DepthBufferSettings.Default; } - public RenderPassBuilder AddColorTarget(Texture texture) + // Every method configures a copy and returns it, so the receiver keeps whatever it already + // described. A method that mutated this directly would also mutate the variable it was called on. + public readonly RenderPassBuilder AddColorTarget(Texture texture) { - ThrowIfColorTargetsFull(); - _colorTargets[_colorTargetCount] = texture; - _colorTargetCount++; - return this; + ThrowIfCapacityExceeded(1); + + RenderPassBuilder builder = this; + builder._colorTargets[builder._colorTargetCount] = texture; + builder._colorTargetCount++; + return builder; } - public RenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) + public readonly RenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) { - ThrowIfColorTargetsFull(); - _colorTargets[_colorTargetCount] = texture; - _colorTargetCount++; - _colorTargetSettings[_colorTargetSettingsCount] = settings; - _colorTargetSettingsCount++; - return this; + ThrowIfCapacityExceeded(1); + + RenderPassBuilder builder = this; + builder._colorTargets[builder._colorTargetCount] = texture; + builder._colorTargetCount++; + builder._colorTargetSettings[builder._colorTargetSettingsCount] = settings; + builder._colorTargetSettingsCount++; + return builder; } - public RenderPassBuilder AddColorTargets(ReadOnlySpan textures) + public readonly RenderPassBuilder AddColorTargets(ReadOnlySpan textures) { + ThrowIfCapacityExceeded(textures.Length); + + RenderPassBuilder builder = this; foreach (Texture texture in textures) { - ThrowIfColorTargetsFull(); - _colorTargets[_colorTargetCount] = texture; - _colorTargetCount++; + builder._colorTargets[builder._colorTargetCount] = texture; + builder._colorTargetCount++; } - return this; + return builder; } - public RenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) + public readonly RenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) { - _sharedColorTargetSettings = settings; - return this; + RenderPassBuilder builder = this; + builder._sharedColorTargetSettings = settings; + return builder; } - public RenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) + public readonly RenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) { - _depthBuffer = depthBuffer; - _depthBufferSettings = settings; - return this; + RenderPassBuilder builder = this; + builder._depthBuffer = depthBuffer; + builder._depthBufferSettings = settings; + return builder; } public readonly IRenderPass Build() @@ -126,11 +132,11 @@ public readonly IRenderPass Build() _depthBufferSettings); } - private readonly void ThrowIfColorTargetsFull() + private readonly void ThrowIfCapacityExceeded(int addedColorTargets) { - if (_colorTargetCount == MaxColorTargets) + if (_colorTargetCount + addedColorTargets > CommandBuffer.MaxColorTargets) { - throw new InvalidOperationException($"A render pass cannot have more than {MaxColorTargets} color targets."); + throw new InvalidOperationException($"A render pass cannot have more than {CommandBuffer.MaxColorTargets} color targets."); } } } diff --git a/tests/Pixely.Tests/RenderPassBuilderTests.cs b/tests/Pixely.Tests/RenderPassBuilderTests.cs index a71e33af..f6f5303e 100644 --- a/tests/Pixely.Tests/RenderPassBuilderTests.cs +++ b/tests/Pixely.Tests/RenderPassBuilderTests.cs @@ -61,7 +61,7 @@ public void Build_WithFewerPerTargetSettingsThanColorTargets_Throws() public void AddColorTarget_BeyondMaxColorTargets_Throws() { RenderPassBuilder builder = CreateBuilder(); - for (int target = 0; target < RenderPassBuilder.MaxColorTargets; target++) + for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) { builder = builder.AddColorTarget(new FakeTexture()); } @@ -72,7 +72,7 @@ public void AddColorTarget_BeyondMaxColorTargets_Throws() [Test] public void AddColorTargets_BeyondMaxColorTargets_Throws() { - Texture[] textures = new Texture[RenderPassBuilder.MaxColorTargets + 1]; + Texture[] textures = new Texture[CommandBuffer.MaxColorTargets + 1]; for (int target = 0; target < textures.Length; target++) { textures[target] = new FakeTexture(); @@ -83,13 +83,51 @@ public void AddColorTargets_BeyondMaxColorTargets_Throws() Assert.That(() => builder.AddColorTargets(textures), Throws.InvalidOperationException); } + [Test] + public void ConfiguringABuilder_LeavesTheReceiverAlone() + { + RenderPassBuilder shared = CreateBuilder().SetSharedColorTargetSettings(ColorTargetSettings.Clear); + + // Two passes branching off the same configuration, as documented in docs/render-pass-flow.md. + shared.AddColorTarget(new FakeTexture()); + shared.AddColorTarget(new FakeTexture()); + + // Neither branch may have added to shared, so it still has room for every color target. + RenderPassBuilder filled = shared; + for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) + { + filled = filled.AddColorTarget(new FakeTexture()); + } + + Assert.That(() => filled.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); + } + + [Test] + public void AddColorTargets_BeyondCapacity_AddsNothing() + { + Texture[] textures = [new FakeTexture(), new FakeTexture()]; + + RenderPassBuilder builder = CreateBuilder(); + for (int target = 0; target < CommandBuffer.MaxColorTargets - 1; target++) + { + builder = builder.AddColorTarget(new FakeTexture()); + } + + Assert.Multiple(() => + { + Assert.That(() => builder.AddColorTargets(textures), Throws.InvalidOperationException); + // The rejected pair must not have consumed the one remaining slot. + Assert.That(() => builder.AddColorTarget(new FakeTexture()), Throws.Nothing); + }); + } + [Test] public void Copies_DoNotShareState() { RenderPassBuilder original = CreateBuilder(); RenderPassBuilder copy = original; - for (int target = 0; target < RenderPassBuilder.MaxColorTargets; target++) + for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) { copy = copy.AddColorTarget(new FakeTexture()); } From 643b16dffdeaace5a142fd6300956b5269ed0aac Mon Sep 17 00:00:00 2001 From: botoddly <250804054+botoddly@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:06:35 +0200 Subject: [PATCH 4/4] Create a render pass directly and keep the builder for composition --- docs/render-pass-flow.md | 56 ++++---- docs/subrenderers.md | 17 ++- src/Pixely.Pencuil/PencuilRenderer.cs | 13 +- src/Pixely/Gpu/CommandBuffer.cs | 20 +++ src/Pixely/Gpu/RenderPassBuilder.cs | 122 +++++++----------- tests/Pixely.Tests/RenderPassBuilderTests.cs | 113 ---------------- .../ClickThroughRenderer.cs | 13 +- .../DepthOnlyRenderer.cs | 14 +- .../ImageLoadingRenderer.cs | 5 +- .../IndexBufferRenderer.cs | 5 +- .../IndexedRenderPassRenderer.cs | 5 +- .../InstancingRenderer.cs | 5 +- .../PrimaryRenderer.cs | 5 +- .../SecondaryWindowRenderer.cs | 5 +- .../StencilBufferRenderer.cs | 14 +- .../StorageBufferRenderer.cs | 5 +- .../TextureArrayRenderer.cs | 5 +- .../TransparentWindowRenderer.cs | 13 +- .../TriangleRenderer.cs | 5 +- .../Program.cs | 13 +- 20 files changed, 149 insertions(+), 304 deletions(-) diff --git a/docs/render-pass-flow.md b/docs/render-pass-flow.md index 845a5e8b..2499d292 100644 --- a/docs/render-pass-flow.md +++ b/docs/render-pass-flow.md @@ -25,10 +25,8 @@ public void Render(BasicRenderContext renderContext) renderContext.CommandBuffer.PushFragmentUniformData(0, color); // 2. CREATE RenderPass - using IRenderPass renderPass = new RenderPassBuilder(renderContext.CommandBuffer) - .AddColorTarget(renderContext.SwapchainTexture) - .SetSharedColorTargetSettings(ColorTargetSettings.Clear) - .Build(); + using IRenderPass renderPass = renderContext.CommandBuffer.CreateRenderPass( + renderContext.SwapchainTexture, ColorTargetSettings.Clear); // 3. INSIDE RenderPass: Bind and draw renderPass.BindGraphicsPipeline(_graphicsPipeline); @@ -94,44 +92,56 @@ Typical order inside a RenderPass: For multiple objects, rebind vertex buffers and push new uniforms between draws. -## RenderPassBuilder +## Creating a RenderPass + +`CommandBuffer.CreateRenderPass` takes the pass description directly. It allocates nothing, so it is +what a renderer should call every frame: ```csharp -new RenderPassBuilder(commandBuffer) - .AddColorTarget(texture) // Output texture - .SetSharedColorTargetSettings(ColorTargetSettings.Clear) // Clear on start - .Build() +// One color target +using IRenderPass pass = commandBuffer.CreateRenderPass(texture, ColorTargetSettings.Clear); + +// One color target and a depth buffer +using IRenderPass pass = commandBuffer.CreateRenderPass( + texture, ColorTargetSettings.Clear, depthBuffer, DepthBufferSettings.Default); + +// Depth only, no color target +using IRenderPass pass = commandBuffer.CreateDepthOnlyRenderPass(depthBuffer, DepthBufferSettings.Default); + +// Several color targets for deferred rendering (G-buffer), from storage the caller owns +using IRenderPass pass = commandBuffer.CreateRenderPass( + _gBufferTextures, _gBufferSettings, _depthBuffer, DepthBufferSettings.Default); ``` +The span overload takes one settings entry per color target, and at most `CommandBuffer.MaxColorTargets` +(8, the point at which SDL itself rejects the pass) targets. + **ColorTargetSettings options:** - `Clear` - Clear the target before rendering - `Load` - Keep existing contents - Others may exist for different load/store operations -Add multiple color targets for deferred rendering (G-buffer), up to `CommandBuffer.MaxColorTargets` (8, the point at which SDL itself rejects the pass). +## RenderPassBuilder -`RenderPassBuilder` is a value type with inline storage, so describing a pass every frame allocates nothing. -Each fluent call returns a new value rather than mutating the receiver, so a partly configured builder -can serve as the starting point for several passes. It holds the `CommandBuffer` it was created with, -so a builder value is good for one frame: +`RenderPassBuilder` collects the same description across several statements, for a pass composed +conditionally or from a varying number of targets: ```csharp -// Shared configuration, no state shared between the passes built from it -RenderPassBuilder cleared = new RenderPassBuilder(commandBuffer) +RenderPassBuilder builder = new RenderPassBuilder(commandBuffer) + .AddColorTarget(_albedo) .SetSharedColorTargetSettings(ColorTargetSettings.Clear); -using (IRenderPass albedoPass = cleared.AddColorTarget(_albedo).Build()) +if (_depthEnabled) { - // ... + builder.SetDepthBuffer(_depthBuffer, DepthBufferSettings.Default); } -using (IRenderPass normalPass = cleared.AddColorTarget(_normals).Build()) -{ - // ... -} +using IRenderPass pass = builder.Build(); ``` -Either give every color target its own settings, or set shared settings for all of them - mixing the two throws. +It is a class and allocates, so prefer `CreateRenderPass` in a per-frame render path. `Build()` resets +the builder, which can then describe the next pass. Either give every color target its own settings, +or set shared settings for all of them - mixing the two throws. ## Common Patterns diff --git a/docs/subrenderers.md b/docs/subrenderers.md index 61c59f72..11cfce5a 100644 --- a/docs/subrenderers.md +++ b/docs/subrenderers.md @@ -102,6 +102,11 @@ public class GeometryPhase : IRenderer private readonly IReadOnlyList _subrenderers; private readonly GameRenderContextBuffers _buffers; + // Owned by the renderer so describing the pass every frame allocates nothing + private readonly Texture[] _gBufferTextures = new Texture[3]; + private readonly ColorTargetSettings[] _gBufferSettings = + [ColorTargetSettings.Clear, ColorTargetSettings.Clear, ColorTargetSettings.Clear]; + public GeometryPhase( IEnumerable subrenderers, GameRenderContextBuffers buffers) @@ -112,12 +117,12 @@ public class GeometryPhase : IRenderer public void Render(GameRenderContext renderContext) { - using IRenderPass renderPass = new RenderPassBuilder(renderContext.CommandBuffer) - .AddColorTarget(_buffers.AlbedoBuffer.Texture) - .AddColorTarget(_buffers.NormalBuffer.Texture) - .AddColorTarget(_buffers.PositionBuffer.Texture) - .SetSharedColorTargetSettings(ColorTargetSettings.Clear) - .Build(); + _gBufferTextures[0] = _buffers.AlbedoBuffer.Texture; + _gBufferTextures[1] = _buffers.NormalBuffer.Texture; + _gBufferTextures[2] = _buffers.PositionBuffer.Texture; + + using IRenderPass renderPass = renderContext.CommandBuffer.CreateRenderPass( + _gBufferTextures, _gBufferSettings, null, DepthBufferSettings.Default); foreach (IGeometrySubrenderer subrenderer in _subrenderers) { diff --git a/src/Pixely.Pencuil/PencuilRenderer.cs b/src/Pixely.Pencuil/PencuilRenderer.cs index 6da8a317..dbb680d2 100644 --- a/src/Pixely.Pencuil/PencuilRenderer.cs +++ b/src/Pixely.Pencuil/PencuilRenderer.cs @@ -163,10 +163,7 @@ private void RenderPencil(CommandBuffer commandBuffer) _maxDepthValue = coloredRectangleInstructions.Count + textureRegionInstructions.Count; - using IRenderPass renderPass = new RenderPassBuilder(commandBuffer) - .AddColorTarget(_retainedTexture, _guiColorTargetSettings) - .SetDepthBuffer(_depthBuffer, DepthBufferSettings.Default) - .Build(); + using IRenderPass renderPass = commandBuffer.CreateRenderPass(_retainedTexture, _guiColorTargetSettings, _depthBuffer, DepthBufferSettings.Default); commandBuffer.PushVertexUniformData(0, _viewProjection); @@ -224,9 +221,7 @@ private void RenderPencil(CommandBuffer commandBuffer) private void Clear(CommandBuffer commandBuffer) { - using IRenderPass clearPass = new RenderPassBuilder(commandBuffer) - .AddColorTarget(_retainedTexture, _guiColorTargetSettings) - .Build(); + using IRenderPass clearPass = commandBuffer.CreateRenderPass(_retainedTexture, _guiColorTargetSettings); } private void Present(CommandBuffer commandBuffer, Texture target) @@ -235,9 +230,7 @@ private void Present(CommandBuffer commandBuffer, Texture target) ? ColorTargetSettings.Clear : new ColorTargetSettings { LoadOperation = LoadOperation.Load }; - using IRenderPass presentPass = new RenderPassBuilder(commandBuffer) - .AddColorTarget(target, settings) - .Build(); + using IRenderPass presentPass = commandBuffer.CreateRenderPass(target, settings); commandBuffer.PushVertexUniformData(0, _presentViewProjection); commandBuffer.PushVertexUniformData(1, Matrix4x4.Identity); diff --git a/src/Pixely/Gpu/CommandBuffer.cs b/src/Pixely/Gpu/CommandBuffer.cs index 805ff0f2..d8aedd4d 100644 --- a/src/Pixely/Gpu/CommandBuffer.cs +++ b/src/Pixely/Gpu/CommandBuffer.cs @@ -87,10 +87,30 @@ public void PushVertexUniformData(uint slot, TType variable) where TType // SDL exposes no constant for it, and the prose in SDL_gpu.h still claims four. public const int MaxColorTargets = 8; + public IRenderPass CreateRenderPass(Texture colorTarget, ColorTargetSettings colorTargetSettings) + { + return CreateRenderPass(new ReadOnlySpan(in colorTarget), new ReadOnlySpan(in colorTargetSettings), null, DepthBufferSettings.Default); + } + + public IRenderPass CreateRenderPass(Texture colorTarget, ColorTargetSettings colorTargetSettings, Texture depthBuffer, DepthBufferSettings depthBufferSettings) + { + return CreateRenderPass(new ReadOnlySpan(in colorTarget), new ReadOnlySpan(in colorTargetSettings), depthBuffer, depthBufferSettings); + } + + public IRenderPass CreateDepthOnlyRenderPass(Texture depthBuffer, DepthBufferSettings depthBufferSettings) + { + return CreateRenderPass(ReadOnlySpan.Empty, ReadOnlySpan.Empty, depthBuffer, depthBufferSettings); + } + public IRenderPass CreateRenderPass(ReadOnlySpan colorTargets, ReadOnlySpan colorTargetSettings, Texture? depthBuffer, DepthBufferSettings depthBufferSettings) { ThrowIfDisposed(); + if (colorTargets.Length == 0 && depthBuffer == null) + { + throw new ArgumentException("At least one color target or a depth buffer is required.", nameof(colorTargets)); + } + if (colorTargets.Length > MaxColorTargets) { throw new ArgumentException($"A render pass cannot have more than {MaxColorTargets} color targets.", nameof(colorTargets)); diff --git a/src/Pixely/Gpu/RenderPassBuilder.cs b/src/Pixely/Gpu/RenderPassBuilder.cs index 5b2dbd49..257d1997 100644 --- a/src/Pixely/Gpu/RenderPassBuilder.cs +++ b/src/Pixely/Gpu/RenderPassBuilder.cs @@ -1,98 +1,67 @@ -using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace Pixely.Gpu; /// -/// Describes a render pass and creates it. A value type with inline storage, so building a pass -/// every frame costs nothing on the heap. Copies are independent: passing a builder around or -/// building from the same value twice does not share state. +/// Collects the description of a render pass across several statements, for callers that compose one +/// conditionally or from a varying number of targets. It allocates, so a renderer that describes the same +/// pass every frame should call +/// or one of its overloads instead. /// -public struct RenderPassBuilder +public class RenderPassBuilder { - [InlineArray(CommandBuffer.MaxColorTargets)] - private struct ColorTargetArray - { - private Texture _element0; - } - - [InlineArray(CommandBuffer.MaxColorTargets)] - private struct ColorTargetSettingsArray - { - private ColorTargetSettings _element0; - } - private readonly CommandBuffer _commandBuffer; - private ColorTargetArray _colorTargets; - private ColorTargetSettingsArray _colorTargetSettings; - private int _colorTargetCount; - private int _colorTargetSettingsCount; + private readonly List _colorTargets = new(); + private readonly List _colorTargetSettings = new(); private Texture? _depthBuffer; - private DepthBufferSettings _depthBufferSettings; + private DepthBufferSettings _depthBufferSettings = DepthBufferSettings.Default; private ColorTargetSettings? _sharedColorTargetSettings; public RenderPassBuilder(CommandBuffer commandBuffer) { _commandBuffer = commandBuffer; - _depthBufferSettings = DepthBufferSettings.Default; } - // Every method configures a copy and returns it, so the receiver keeps whatever it already - // described. A method that mutated this directly would also mutate the variable it was called on. - public readonly RenderPassBuilder AddColorTarget(Texture texture) + public RenderPassBuilder AddColorTarget(Texture texture) { - ThrowIfCapacityExceeded(1); - - RenderPassBuilder builder = this; - builder._colorTargets[builder._colorTargetCount] = texture; - builder._colorTargetCount++; - return builder; + _colorTargets.Add(texture); + return this; } - public readonly RenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) + public RenderPassBuilder AddColorTarget(Texture texture, ColorTargetSettings settings) { - ThrowIfCapacityExceeded(1); - - RenderPassBuilder builder = this; - builder._colorTargets[builder._colorTargetCount] = texture; - builder._colorTargetCount++; - builder._colorTargetSettings[builder._colorTargetSettingsCount] = settings; - builder._colorTargetSettingsCount++; - return builder; + _colorTargets.Add(texture); + _colorTargetSettings.Add(settings); + return this; } - public readonly RenderPassBuilder AddColorTargets(ReadOnlySpan textures) + public RenderPassBuilder AddColorTargets(ReadOnlySpan textures) { - ThrowIfCapacityExceeded(textures.Length); - - RenderPassBuilder builder = this; foreach (Texture texture in textures) { - builder._colorTargets[builder._colorTargetCount] = texture; - builder._colorTargetCount++; + _colorTargets.Add(texture); } - return builder; + return this; } - public readonly RenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) + public RenderPassBuilder SetSharedColorTargetSettings(ColorTargetSettings settings) { - RenderPassBuilder builder = this; - builder._sharedColorTargetSettings = settings; - return builder; + _sharedColorTargetSettings = settings; + return this; } - public readonly RenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) + public RenderPassBuilder SetDepthBuffer(Texture depthBuffer, DepthBufferSettings settings) { - RenderPassBuilder builder = this; - builder._depthBuffer = depthBuffer; - builder._depthBufferSettings = settings; - return builder; + _depthBuffer = depthBuffer; + _depthBufferSettings = settings; + return this; } - public readonly IRenderPass Build() + public IRenderPass Build() { bool hasShared = _sharedColorTargetSettings != null; - bool hasPerTarget = _colorTargetSettingsCount > 0; - bool hasColorTargets = _colorTargetCount > 0; + bool hasPerTarget = _colorTargetSettings.Count > 0; + bool hasColorTargets = _colorTargets.Count > 0; bool hasDepthBuffer = _depthBuffer != null; if (hasShared && hasPerTarget) @@ -105,7 +74,7 @@ public readonly IRenderPass Build() throw new InvalidOperationException("Must have either shared or per-target settings set when using color targets."); } - if (hasPerTarget && _colorTargetSettingsCount != _colorTargetCount) + if (hasPerTarget && _colorTargetSettings.Count != _colorTargets.Count) { throw new InvalidOperationException("Every color target needs its own settings when per-target settings are used."); } @@ -115,28 +84,31 @@ public readonly IRenderPass Build() throw new InvalidOperationException("At least one color target or a depth buffer is required."); } - ColorTargetArray colorTargets = _colorTargets; - ColorTargetSettingsArray colorTargetSettings = _colorTargetSettings; - Span colorTargetSpan = colorTargets; - Span colorTargetSettingsSpan = colorTargetSettings; - if (hasShared) { - colorTargetSettingsSpan[.._colorTargetCount].Fill(_sharedColorTargetSettings!); + for (int i = 0; i < _colorTargets.Count; i++) + { + _colorTargetSettings.Add(_sharedColorTargetSettings!); + } } - return _commandBuffer.CreateRenderPass( - colorTargetSpan[.._colorTargetCount], - colorTargetSettingsSpan[.._colorTargetCount], + IRenderPass renderPass = _commandBuffer.CreateRenderPass( + CollectionsMarshal.AsSpan(_colorTargets), + CollectionsMarshal.AsSpan(_colorTargetSettings), _depthBuffer, _depthBufferSettings); + + ResetState(); + + return renderPass; } - private readonly void ThrowIfCapacityExceeded(int addedColorTargets) + private void ResetState() { - if (_colorTargetCount + addedColorTargets > CommandBuffer.MaxColorTargets) - { - throw new InvalidOperationException($"A render pass cannot have more than {CommandBuffer.MaxColorTargets} color targets."); - } + _colorTargets.Clear(); + _colorTargetSettings.Clear(); + _depthBuffer = null; + _depthBufferSettings = DepthBufferSettings.Default; + _sharedColorTargetSettings = null; } } diff --git a/tests/Pixely.Tests/RenderPassBuilderTests.cs b/tests/Pixely.Tests/RenderPassBuilderTests.cs index f6f5303e..4dd4e3b7 100644 --- a/tests/Pixely.Tests/RenderPassBuilderTests.cs +++ b/tests/Pixely.Tests/RenderPassBuilderTests.cs @@ -56,117 +56,4 @@ public void Build_WithFewerPerTargetSettingsThanColorTargets_Throws() Assert.That(() => builder.Build(), Throws.InvalidOperationException); } - - [Test] - public void AddColorTarget_BeyondMaxColorTargets_Throws() - { - RenderPassBuilder builder = CreateBuilder(); - for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) - { - builder = builder.AddColorTarget(new FakeTexture()); - } - - Assert.That(() => builder.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); - } - - [Test] - public void AddColorTargets_BeyondMaxColorTargets_Throws() - { - Texture[] textures = new Texture[CommandBuffer.MaxColorTargets + 1]; - for (int target = 0; target < textures.Length; target++) - { - textures[target] = new FakeTexture(); - } - - RenderPassBuilder builder = CreateBuilder(); - - Assert.That(() => builder.AddColorTargets(textures), Throws.InvalidOperationException); - } - - [Test] - public void ConfiguringABuilder_LeavesTheReceiverAlone() - { - RenderPassBuilder shared = CreateBuilder().SetSharedColorTargetSettings(ColorTargetSettings.Clear); - - // Two passes branching off the same configuration, as documented in docs/render-pass-flow.md. - shared.AddColorTarget(new FakeTexture()); - shared.AddColorTarget(new FakeTexture()); - - // Neither branch may have added to shared, so it still has room for every color target. - RenderPassBuilder filled = shared; - for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) - { - filled = filled.AddColorTarget(new FakeTexture()); - } - - Assert.That(() => filled.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); - } - - [Test] - public void AddColorTargets_BeyondCapacity_AddsNothing() - { - Texture[] textures = [new FakeTexture(), new FakeTexture()]; - - RenderPassBuilder builder = CreateBuilder(); - for (int target = 0; target < CommandBuffer.MaxColorTargets - 1; target++) - { - builder = builder.AddColorTarget(new FakeTexture()); - } - - Assert.Multiple(() => - { - Assert.That(() => builder.AddColorTargets(textures), Throws.InvalidOperationException); - // The rejected pair must not have consumed the one remaining slot. - Assert.That(() => builder.AddColorTarget(new FakeTexture()), Throws.Nothing); - }); - } - - [Test] - public void Copies_DoNotShareState() - { - RenderPassBuilder original = CreateBuilder(); - - RenderPassBuilder copy = original; - for (int target = 0; target < CommandBuffer.MaxColorTargets; target++) - { - copy = copy.AddColorTarget(new FakeTexture()); - } - - Assert.Multiple(() => - { - Assert.That(() => copy.AddColorTarget(new FakeTexture()), Throws.InvalidOperationException); - Assert.That(() => original.AddColorTarget(new FakeTexture()), Throws.Nothing); - }); - } - - [Test] - public void DescribingAPass_DoesNotAllocate() - { - Texture colorTarget = new FakeTexture(); - Texture depthBuffer = new FakeTexture(); - - for (int warmUp = 0; warmUp < 4; warmUp++) - { - Describe(colorTarget, depthBuffer); - } - - long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); - for (int iteration = 0; iteration < 16; iteration++) - { - Describe(colorTarget, depthBuffer); - } - - long allocatedAfter = GC.GetAllocatedBytesForCurrentThread(); - - Assert.That(allocatedAfter - allocatedBefore, Is.Zero); - } - - // Everything a renderer does per frame up to Build, which needs a real command buffer. - private static RenderPassBuilder Describe(Texture colorTarget, Texture depthBuffer) - { - return new RenderPassBuilder(null!) - .AddColorTarget(colorTarget) - .SetSharedColorTargetSettings(ColorTargetSettings.Clear) - .SetDepthBuffer(depthBuffer, DepthBufferSettings.Default); - } } diff --git a/tutorials/Pixely.Tutorials.ClickThrough/ClickThroughRenderer.cs b/tutorials/Pixely.Tutorials.ClickThrough/ClickThroughRenderer.cs index 39ac741f..be33bc59 100644 --- a/tutorials/Pixely.Tutorials.ClickThrough/ClickThroughRenderer.cs +++ b/tutorials/Pixely.Tutorials.ClickThrough/ClickThroughRenderer.cs @@ -18,14 +18,11 @@ public ClickThroughRenderer(GraphicsPipeline graphicsPipeline, GpuVertexBuffer