Files
sbox-public/engine/Sandbox.Engine/Systems/SceneSystem/SpriteBatchSceneObject.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

536 lines
15 KiB
C#

namespace Sandbox.Rendering;
using System.Buffers;
using System.Runtime.InteropServices;
/// <summary>
/// This object renders every sprite registered to it in a single draw call. It takes care of sorting, sampling, and the whole pipeline regarding sprites.
/// The SceneSpriteSystem is responsible for pushing sprites into this object depending on its properties.
/// </summary>
internal sealed class SpriteBatchSceneObject : SceneCustomObject
{
internal readonly record struct SpriteGroup( SpriteData[] SharedSprites, int Offset, int Count );
public bool Sorted { get; set; } = false;
public bool Filtered { get; set; } = false;
public bool Additive { get; set; } = false;
internal Dictionary<Guid, SpriteRenderer> Components = new();
private readonly ComputeShader SpriteComputeShader = new( "sprite/sprite_cs" );
private readonly ComputeShader SortComputeShader = new( "sort_cs" );
private readonly GpuBuffer<uint> SpriteAtomicCounter;
private readonly Material SpriteMaterial;
internal Dictionary<Guid, SpriteGroup> SpriteGroups = [];
internal readonly SamplerState sampler = new()
{
AddressModeU = TextureAddressMode.Clamp,
AddressModeV = TextureAddressMode.Clamp,
};
// GPU Resident representation of a sprite
[StructLayout( LayoutKind.Sequential, Pack = 16 )]
public struct SpriteData
{
public Vector3 Position;
public Vector3 Rotation;
public Vector2 Scale;
public uint TintColor; // Packed RGBA8
public uint OverlayColor; // Packed RGBA8
public int TextureHandle;
public int RenderFlags;
public uint BillboardMode;
public float FogStrength;
public uint Lighting;
public float DepthFeather;
public int SamplerIndex;
public int Splots = 0;
public int Sequence = 0;
public float SequenceTime = 0;
public float RotationOffset = -1.0f;
public Vector4 MotionBlur;
public Vector3 Velocity = Vector3.Zero;
public Vector4 BlendSheetUV;
public Vector2 Offset;
public SpriteData()
{
}
// Helper method to pack Color to RGBA8
internal static uint PackColor( Color color )
{
byte r = (byte)(color.r * 255f);
byte g = (byte)(color.g * 255f);
byte b = (byte)(color.b * 255f);
byte a = (byte)(color.a * 255f);
return (uint)(r | (g << 8) | (b << 16) | (a << 24));
}
}
struct SpriteVertex
{
public Vector4 position;
public Vector4 normal;
public Vector2 uv;
public SpriteVertex( Vector3 pos, Vector3 norm, Vector2 inUv )
{
position = new Vector4( pos, 1.0f );
normal = new Vector4( norm, 0.0f );
uv = inUv;
}
}
const int DefaultBufferSize = 16;
int CurrentBufferSize = DefaultBufferSize;
int _splotCount = 0;
int SplotCount
{
get
{
if ( _splotCount != 0 )
{
return _splotCount;
}
// Use precomputed splot counts to avoid iteration
int splotCount = 0;
foreach ( var group in SpriteGroups )
{
if ( _precomputedSplotCounts.TryGetValue( group.Key, out int precomputedCount ) )
{
splotCount += precomputedCount;
}
else
{
// Fallback
var spriteGroup = group.Value;
for ( int i = 0; i < spriteGroup.Count; i++ )
{
int index = spriteGroup.Offset + i;
int leading = spriteGroup.SharedSprites[index].MotionBlur.x > 1 ? 2 : 1;
splotCount += spriteGroup.SharedSprites[index].Splots * leading;
}
}
}
_splotCount = splotCount;
return _splotCount;
}
}
int SpriteCount
{
get
{
int sum = Components.Count;
foreach ( var group in SpriteGroups )
{
sum += group.Value.Count;
}
return sum;
}
}
bool GPUUploadQueued = false;
GpuBuffer<SpriteData> SpriteBuffer;
GpuBuffer<SpriteData> SpriteBufferOut;
GpuBuffer<SpriteVertex> VertexBuffer;
GpuBuffer<int> IndexBuffer;
GpuBuffer<uint> GPUSortingBuffer;
GpuBuffer<float> GPUDistanceBuffer;
SpriteData[] SpriteDataBuffer = null!;
bool SpriteDataBufferRented = false;
public SpriteBatchSceneObject( Scene scene ) : base( scene.SceneWorld )
{
SpriteMaterial = Material.FromShader( "sprite/sprite_ps.shader" );
InitializeSpriteMesh();
// GPU buffers
SpriteBuffer = new( CurrentBufferSize );
SpriteBufferOut = new( CurrentBufferSize );
GPUSortingBuffer = new( CurrentBufferSize );
GPUDistanceBuffer = new( CurrentBufferSize );
SpriteAtomicCounter = new( 1 );
}
/// <summary>
/// Create the initialize sprite mesh that will be instanced
/// </summary>
private void InitializeSpriteMesh()
{
// Vertex pulling buffer
const float spriteSize = 10f;
SpriteVertex[] vertices =
[
new ( new ( -spriteSize, -spriteSize, 0 ), Vector3.Forward, new ( 0, 0 ) ),
new ( new ( spriteSize, -spriteSize, 0 ), Vector3.Forward, new ( 1, 0 ) ),
new ( new ( spriteSize, spriteSize, 0 ), Vector3.Forward, new ( 1, 1 ) ),
new ( new ( -spriteSize, spriteSize, 0 ), Vector3.Forward, new ( 0, 1 ) ),
];
VertexBuffer = new( 4 );
VertexBuffer.SetData( vertices.AsSpan() );
// Index buffer
int[] indices = { 0, 1, 2, 0, 2, 3 };
IndexBuffer = new( 6, GpuBuffer.UsageFlags.Index );
IndexBuffer.SetData( indices );
}
~SpriteBatchSceneObject()
{
if ( SpriteDataBufferRented )
{
ArrayPool<SpriteData>.Shared.Return( SpriteDataBuffer, clearArray: false );
}
}
/// <summary>
/// Resizes GPU buffers to the nearest power of 2
/// </summary>
public void ResizeBuffers()
{
int allocationSize = SplotCount + SpriteCount;
if ( allocationSize <= CurrentBufferSize )
{
return;
}
ResizeBuffers( allocationSize );
}
/// <summary>
/// Resizes GPU buffers to accommodate the specified allocation size
/// </summary>
private void ResizeBuffers( int allocationSize )
{
CurrentBufferSize = (int)System.Numerics.BitOperations.RoundUpToPowerOf2( (uint)allocationSize );
SpriteBuffer?.Dispose();
GPUSortingBuffer?.Dispose();
GPUDistanceBuffer?.Dispose();
SpriteBuffer = new( CurrentBufferSize );
SpriteBufferOut = new( CurrentBufferSize );
GPUSortingBuffer = new( CurrentBufferSize );
GPUDistanceBuffer = new( CurrentBufferSize );
}
private readonly Dictionary<Guid, int> _precomputedSplotCounts = [];
// Pre-allocated buffer to avoid GC allocations in hot path
private SpriteRenderer[] _componentBuffer = new SpriteRenderer[16];
public void RegisterSprite( Guid ownerId, SpriteData[] sharedSprites, int offset, int count, int splotCount )
{
SpriteGroups[ownerId] = new( sharedSprites, offset, count );
_precomputedSplotCounts[ownerId] = splotCount;
OnChanged();
}
public void RegisterSprite( Guid id, SpriteRenderer component )
{
Components[id] = component;
OnChanged();
}
public void UnregisterSprite( Guid id )
{
Components.Remove( id );
OnChanged();
}
public void UnregisterSpriteGroup( Guid ownerId )
{
if ( SpriteGroups.Remove( ownerId ) )
{
// Clean up precomputed splot count
_precomputedSplotCounts.Remove( ownerId );
OnChanged();
}
}
public void UpdateSprite( Guid id, SpriteRenderer component )
{
if ( Components.ContainsKey( id ) )
{
Components[id] = component;
OnChanged();
}
}
public bool ContainsSprite( Guid id )
{
return Components.ContainsKey( id );
}
public void OnChanged()
{
int requiredSize = SplotCount + SpriteCount;
// Clear cached splot count to force recalculation
_splotCount = 0;
// Only resize if we actually need more space
if ( requiredSize > CurrentBufferSize )
{
ResizeBuffers( requiredSize );
}
GPUUploadQueued = true;
}
/// <summary>
/// Copy host buffers onto GPU
/// </summary>
public void UploadOnHost()
{
if ( !GPUUploadQueued && !Sorted )
{
return;
}
int spriteCount = SpriteCount;
if ( SpriteDataBuffer == null || SpriteDataBuffer.Length < spriteCount )
{
if ( SpriteDataBufferRented )
{
ArrayPool<SpriteData>.Shared.Return( SpriteDataBuffer, clearArray: false );
}
SpriteDataBuffer = ArrayPool<SpriteData>.Shared.Rent( spriteCount );
SpriteDataBufferRented = true;
}
// Upload sprites
int componentCount = Components.Count;
if ( componentCount > 0 )
{
// Use pre-allocated buffer to avoid GC allocation
if ( _componentBuffer.Length < componentCount )
{
_componentBuffer = new SpriteRenderer[componentCount * 2];
}
int index = 0;
foreach ( var component in Components.Values )
{
_componentBuffer[index++] = component;
}
Parallel.For( 0, componentCount, i =>
{
var c = _componentBuffer[i];
var transform = c.WorldTransform;
var spriteSize = c.Size;
var rotation = c.WorldRotation.Angles().AsVector3();
if ( c.Billboard == SpriteRenderer.BillboardMode.Always || c.Billboard == SpriteRenderer.BillboardMode.YOnly )
{
// We only care about roll in this case
rotation.x = 0;
rotation.y = 0;
}
spriteSize = spriteSize.Abs();
// Adjust for aspect ratio
var aspectRatio = (c.Texture?.Width ?? 1) / (float)(c.Texture?.Height ?? 1);
var size = spriteSize / 2f;
var pos = transform.Position;
var scale = new Vector3( transform.Scale.x * size.x, transform.Scale.y, transform.Scale.z * size.y );
if ( aspectRatio < 1f )
scale *= new Vector3( aspectRatio, 1f, 1f );
else
scale *= new Vector3( 1f, 1f, 1f / aspectRatio );
pos = pos.RotateAround( transform.Position, transform.Rotation );
transform = transform.WithScale( scale ).WithPosition( pos );
var flipFlags = SpriteRenderer.FlipFlags.None;
if ( c.FlipHorizontal ) flipFlags |= SpriteRenderer.FlipFlags.FlipX;
if ( c.FlipVertical ) flipFlags |= SpriteRenderer.FlipFlags.FlipY;
var rgbe = c.Color.ToRgbe();
var alpha = (byte)(c.Color.a.Clamp( 0.0f, 1.0f ) * 255.0f);
var tintColor = new Color32( rgbe.r, rgbe.g, rgbe.b, alpha );
var overlayRgbe = c.OverlayColor.ToRgbe();
var overlayAlpha = (byte)(c.OverlayColor.a.Clamp( 0.0f, 1.0f ) * 255.0f);
var overlayColor = new Color32( overlayRgbe.r, overlayRgbe.g, overlayRgbe.b, overlayAlpha );
int lightingFlag = c.Lighting ? 1 : 0;
uint packedExponent = (uint)(((byte)lightingFlag) | rgbe.a << 16);
SpriteDataBuffer[i] = new SpriteData
{
Position = transform.Position,
Rotation = new( rotation.x, rotation.y, rotation.z ),
Scale = new( transform.Scale.x, transform.Scale.z ),
TextureHandle = c.Texture is null ? Texture.Invalid.Index : c.Texture.Index,
TintColor = tintColor.RawInt,
OverlayColor = overlayColor.RawInt,
RenderFlags = (int)flipFlags,
BillboardMode = (uint)c.Billboard,
FogStrength = c.FogStrength,
Lighting = packedExponent,
DepthFeather = c.DepthFeather,
SamplerIndex = SamplerState.GetBindlessIndex( sampler with { Filter = c.TextureFilter } ),
Offset = c.Pivot
};
} );
}
// Upload components to GPU first
if ( Components.Count > 0 )
{
SpriteBuffer.SetData( SpriteDataBuffer );
}
// Upload each particle group directly to GPU with offset
int currentOffset = Components.Count;
foreach ( var spriteGroup in SpriteGroups.Values )
{
unsafe
{
var sourceSpan = spriteGroup.SharedSprites.AsSpan( spriteGroup.Offset, spriteGroup.Count );
// Upload directly to GPU at the correct offset
SpriteBuffer.SetData( sourceSpan, currentOffset );
currentOffset += spriteGroup.Count;
}
}
GPUUploadQueued = false;
}
private const int GroupSize = 256;
private const int MaxDimGroups = 1024;
private const int MaxDimThreads = GroupSize * MaxDimGroups;
private void PreSort()
{
if ( SpriteCount < 2 ) return;
// First we clear the buffers to prepare for sorting
SortComputeShader.Attributes.SetCombo( "D_CLEAR", 1 );
SortComputeShader.Attributes.Set( "SortBuffer", GPUSortingBuffer );
SortComputeShader.Attributes.Set( "DistanceBuffer", GPUDistanceBuffer );
SortComputeShader.Attributes.Set( "Count", CurrentBufferSize );
SortComputeShader.Dispatch( CurrentBufferSize, 1, 1 );
Graphics.ResourceBarrierTransition( GPUSortingBuffer, ResourceState.UnorderedAccess, ResourceState.UnorderedAccess );
Graphics.ResourceBarrierTransition( GPUDistanceBuffer, ResourceState.UnorderedAccess, ResourceState.UnorderedAccess );
}
/// <summary>
/// Performs a GPU bitonic sort
/// </summary>
private void Sort()
{
// Distance buffer is already filled by GPU compute shader, no need to update from CPU
Graphics.ResourceBarrierTransition( GPUDistanceBuffer, Sandbox.Rendering.ResourceState.Common );
// Sort
SortComputeShader.Attributes.SetCombo( "D_CLEAR", 0 );
var x = Math.Min( CurrentBufferSize, MaxDimThreads );
var y = (CurrentBufferSize + MaxDimThreads - 1) / MaxDimThreads;
var z = 1;
for ( var dim = 2; dim <= CurrentBufferSize; dim <<= 1 )
{
SortComputeShader.Attributes.Set( "Dim", dim );
for ( var block = dim >> 1; block > 0; block >>= 1 )
{
SortComputeShader.Attributes.Set( "Block", block );
SortComputeShader.Dispatch( x, y, z );
// Make sure sort buffer is ready to use
Graphics.ResourceBarrierTransition( GPUSortingBuffer, ResourceState.UnorderedAccess, ResourceState.UnorderedAccess );
Graphics.ResourceBarrierTransition( GPUDistanceBuffer, ResourceState.UnorderedAccess, ResourceState.UnorderedAccess );
}
}
}
/// <summary>
/// Rendering logic of the sprites
/// </summary>
public override void RenderSceneObject()
{
base.RenderSceneObject();
if ( SpriteCount == 0 )
{
return;
}
if ( Sorted )
{
PreSort();
}
// Generate trails and UVs (this is mainly for particles)
SpriteAtomicCounter.SetData( [0] ); // Reset atomic counter
Graphics.ResourceBarrierTransition( SpriteAtomicCounter, ResourceState.Common );
Graphics.ResourceBarrierTransition( SpriteBuffer, ResourceState.Common );
Graphics.ResourceBarrierTransition( SpriteBufferOut, ResourceState.Common );
Graphics.ResourceBarrierTransition( GPUDistanceBuffer, ResourceState.Common );
var attributes = RenderAttributes.Pool.Get();
attributes.Set( "Sprites", SpriteBuffer );
attributes.Set( "SpriteBufferOut", SpriteBufferOut );
attributes.Set( "SpriteCount", SpriteCount );
attributes.Set( "AtomicCounter", SpriteAtomicCounter );
// Sorting
attributes.Set( "DistanceBuffer", GPUDistanceBuffer );
attributes.Set( "CameraPosition", Graphics.CameraPosition );
SpriteComputeShader.DispatchWithAttributes( attributes, SpriteCount, 1, 1 );
RenderAttributes.Pool.Return( attributes );
// Barried for the new sprites generated
Graphics.ResourceBarrierTransition( SpriteAtomicCounter, ResourceState.Common );
Graphics.ResourceBarrierTransition( SpriteBufferOut, ResourceState.Common );
Graphics.Attributes.SetCombo( "D_BLEND", Additive ? 1 : 0 );
// Sort
if ( Sorted )
{
Sort();
}
// Draw the sprites
Graphics.Attributes.Set( "IsSorted", Sorted ? 1 : 0 );
Graphics.Attributes.Set( "SpriteCount", SpriteCount + SplotCount );
Graphics.Attributes.Set( "Filtered", Filtered );
Graphics.Attributes.Set( "Sprites", SpriteBufferOut );
Graphics.Attributes.Set( "SortLUT", GPUSortingBuffer ); // Always bind even if not used
// Vertex Pulling
Graphics.Attributes.Set( "Vertices", VertexBuffer );
Graphics.Attributes.Set( "g_bNonDirectionalDiffuseLighting", true );
Graphics.DrawIndexedInstanced( IndexBuffer, SpriteMaterial, SpriteCount + SplotCount );
}
}