// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Threading.Tasks;
using SixLabors.ImageSharp.Drawing.Processing.Backends;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Drawing.Processing {
///
/// Queues normalized composition commands emitted by
/// and prepares them in deterministic draw order.
///
///
/// The batcher owns command buffering and replay ordering only; it does not rasterize or composite.
/// Draw commands are stored in the command buffer until a timeline command-range entry references
/// them. Existing retained scenes passed through are stored
/// separately and referenced by timeline entry index. During disposal replay, command ranges are
/// lowered to short-lived backend scenes at the position where the canvas recorded the range.
///
internal sealed class DrawingCanvasBatcher
where TPixel : unmanaged, IPixel
{
private readonly Configuration configuration;
// Draw commands stay in this buffer until replay lowers referenced command ranges
// into backend scenes at their recorded timeline position.
private CompositionSceneCommand[] commands;
private int commandCount;
private int sealedCommandCount;
// Layer metadata is range-sensitive, so sealing advances this alongside command
// sealing instead of letting layer state leak across later command ranges.
private int layerCommandCount;
private int sealedLayerCommandCount;
// Clip and dash flags gate whole-buffer command preparation; prepared commands
// remain in the same command buffer until replay consumes it.
private bool hasClips;
private bool hasDashes;
// Timeline entries keep compact indexes into the command, barrier, and retained
// scene buffers while preserving the order recorded by the canvas.
private DrawingCanvasTimelineEntry[] entries;
// Apply barriers carry replay-time target read/process/write operations.
private ApplyBarrier[] applyBarriers;
private int applyBarrierCount;
// These are existing retained scenes recorded through RenderScene, not scenes
// produced later from this batcher's own command ranges.
private DrawingBackendScene[] insertedScenes;
private int insertedSceneCount;
internal DrawingCanvasBatcher(Configuration configuration)
{
this.configuration = configuration;
this.commands = [];
this.entries = [];
this.applyBarriers = [];
this.insertedScenes = [];
}
///
/// Gets a value indicating whether there are queued commands or timeline entries.
///
public bool HasRecordedWork => this.commandCount > 0 || this.TimelineEntryCount > 0;
///
/// Gets the number of ordered replay items recorded in the canvas timeline.
///
///
/// This is not a draw-command count. A single entry can represent a contiguous command range,
/// an apply barrier, or an inserted retained scene.
///
public int TimelineEntryCount { get; private set; }
///
/// Appends one normalized composition command to the pending queue.
///
/// The command to queue.
public void AddComposition(in CompositionCommand composition)
{
this.EnsureCommandCapacity(this.commandCount + 1);
this.commands[this.commandCount++] = new PathCompositionSceneCommand(composition);
if (composition.Kind is not CompositionCommandKind.FillLayer)
{
this.layerCommandCount++;
}
this.hasClips |= composition.ClipPaths is not null;
}
///
/// Appends one stroked path command to the pending queue.
///
/// The command to queue.
public void AddStrokePath(in StrokePathCommand command)
{
this.EnsureCommandCapacity(this.commandCount + 1);
this.commands[this.commandCount++] = new StrokePathCompositionSceneCommand(command);
this.hasClips |= command.ClipPaths is not null;
this.hasDashes |= command.Pen.StrokePattern.Length >= 2;
}
///
/// Appends one explicit stroked line-segment command to the pending queue.
///
/// The command to queue.
public void AddStrokeLineSegment(in StrokeLineSegmentCommand command)
{
this.EnsureCommandCapacity(this.commandCount + 1);
this.commands[this.commandCount++] = new LineSegmentCompositionSceneCommand(command);
}
///
/// Appends one explicit stroked polyline command to the pending queue.
///
/// The command to queue.
public void AddStrokePolyline(in StrokePolylineCommand command)
{
this.EnsureCommandCapacity(this.commandCount + 1);
this.commands[this.commandCount++] = new PolylineCompositionSceneCommand(command);
}
///
/// Seals currently queued commands into the replay timeline.
///
///
/// This records a command range only. Backend scenes are created later by the replay path
/// from the referenced command range, so sealing does not render or allocate backend scene state.
///
public void SealCommands()
{
int count = this.commandCount - this.sealedCommandCount;
if (count == 0)
{
return;
}
this.EnsureEntryCapacity(this.TimelineEntryCount + 1);
this.entries[this.TimelineEntryCount++] = DrawingCanvasTimelineEntry.CreateCommandRange(
this.sealedCommandCount,
count,
this.layerCommandCount != this.sealedLayerCommandCount);
this.sealedCommandCount = this.commandCount;
this.sealedLayerCommandCount = this.layerCommandCount;
}
///
/// Appends an apply barrier to the replay timeline after sealing queued commands.
///
/// The apply barrier to append.
internal void AddApplyBarrier(ApplyBarrier barrier)
{
this.SealCommands();
this.EnsureApplyBarrierCapacity(this.applyBarrierCount + 1);
int barrierIndex = this.applyBarrierCount;
this.applyBarriers[this.applyBarrierCount++] = barrier;
this.EnsureEntryCapacity(this.TimelineEntryCount + 1);
this.entries[this.TimelineEntryCount++] = DrawingCanvasTimelineEntry.CreateApplyBarrier(barrierIndex);
}
///
/// Records an existing retained scene in the replay timeline after sealing queued commands.
///
///
/// This stores only scenes passed to . Scenes produced
/// from this canvas's own command ranges are created later by the backend from command batches.
///
/// The retained scene to render at this point in the timeline.
public void AddScene(DrawingBackendScene scene)
{
this.SealCommands();
this.EnsureInsertedSceneCapacity(this.insertedSceneCount + 1);
int sceneIndex = this.insertedSceneCount;
this.insertedScenes[this.insertedSceneCount++] = scene;
this.EnsureEntryCapacity(this.TimelineEntryCount + 1);
this.entries[this.TimelineEntryCount++] = DrawingCanvasTimelineEntry.CreateScene(sceneIndex);
}
///
/// Creates a retained backend scene from the recorded timeline.
///
/// The backend used to create the retained scene.
/// The target bounds used for target-dependent scene creation.
/// The resources that must stay alive for the returned scene.
/// The retained backend scene.
public DrawingBackendScene CreateScene(
IDrawingBackend backend,
Rectangle targetBounds,
IReadOnlyList? ownedResources)
{
if (!this.HasRecordedWork)
{
throw new InvalidOperationException("Cannot create a retained scene from an empty canvas.");
}
this.SealAndPrepareCommands();
return backend.CreateScene(
this.configuration,
targetBounds,
new DrawingCommandBatch(this.commands, this.commandCount, this.layerCommandCount > 0),
ownedResources);
}
///
/// Seals any pending commands and prepares queued command data for backend scene creation.
///
public void SealAndPrepareCommands()
{
this.SealCommands();
this.PrepareCommands();
}
///
/// Creates a command batch over one recorded command-range timeline entry.
///
/// The command-range timeline entry.
/// The command batch.
public DrawingCommandBatch CreateCommandBatch(DrawingCanvasTimelineEntry entry)
=> new(this.commands, entry.Index, entry.Count, entry.HasLayers);
///
/// Gets one recorded timeline entry.
///
/// The entry index.
/// The recorded timeline entry.
public DrawingCanvasTimelineEntry GetEntry(int index)
=> this.entries[index];
///
/// Gets one recorded apply barrier.
///
/// The apply-barrier index.
/// The recorded apply barrier.
internal ApplyBarrier GetApplyBarrier(int index)
=> this.applyBarriers[index];
///
/// Gets one retained scene reference recorded through .
///
/// The retained-scene reference index.
/// The retained scene to render at the timeline entry.
public DrawingBackendScene GetInsertedScene(int index)
=> this.insertedScenes[index];
///
/// Clears command references after a prepared batch has been consumed.
///
public void ClearCommandBatch()
{
Array.Clear(this.commands, 0, this.commandCount);
Array.Clear(this.entries, 0, this.TimelineEntryCount);
Array.Clear(this.applyBarriers, 0, this.applyBarrierCount);
Array.Clear(this.insertedScenes, 0, this.insertedSceneCount);
this.commandCount = 0;
this.sealedCommandCount = 0;
this.layerCommandCount = 0;
this.sealedLayerCommandCount = 0;
this.TimelineEntryCount = 0;
this.applyBarrierCount = 0;
this.insertedSceneCount = 0;
this.hasClips = false;
this.hasDashes = false;
}
///
/// Ensures that the command buffer can store the requested command count without reallocating.
///
/// The required command capacity.
private void EnsureCommandCapacity(int requiredCapacity)
{
if (requiredCapacity <= this.commands.Length)
{
return;
}
int nextCapacity = this.commands.Length == 0 ? 16 : this.commands.Length * 2;
if (nextCapacity < requiredCapacity)
{
nextCapacity = requiredCapacity;
}
Array.Resize(ref this.commands, nextCapacity);
}
///
/// Ensures that the timeline entry buffer can store the requested entry count without reallocating.
///
/// The required entry capacity.
private void EnsureEntryCapacity(int requiredCapacity)
{
if (requiredCapacity <= this.entries.Length)
{
return;
}
int nextCapacity = this.entries.Length == 0 ? 4 : this.entries.Length * 2;
if (nextCapacity < requiredCapacity)
{
nextCapacity = requiredCapacity;
}
Array.Resize(ref this.entries, nextCapacity);
}
///
/// Ensures that the apply-barrier buffer can store the requested barrier count without reallocating.
///
/// The required barrier capacity.
private void EnsureApplyBarrierCapacity(int requiredCapacity)
{
if (requiredCapacity <= this.applyBarriers.Length)
{
return;
}
int nextCapacity = this.applyBarriers.Length == 0 ? 2 : this.applyBarriers.Length * 2;
if (nextCapacity < requiredCapacity)
{
nextCapacity = requiredCapacity;
}
Array.Resize(ref this.applyBarriers, nextCapacity);
}
///
/// Ensures that the inserted-scene buffer can store the requested scene count without reallocating.
///
/// The required scene capacity.
private void EnsureInsertedSceneCapacity(int requiredCapacity)
{
if (requiredCapacity <= this.insertedScenes.Length)
{
return;
}
int nextCapacity = this.insertedScenes.Length == 0 ? 2 : this.insertedScenes.Length * 2;
if (nextCapacity < requiredCapacity)
{
nextCapacity = requiredCapacity;
}
Array.Resize(ref this.insertedScenes, nextCapacity);
}
private void PrepareCommands()
{
if (!this.hasClips && !this.hasDashes)
{
return;
}
// If clipping is present we need to apply that now before handing the command
// to the backend. This avoids complicating the backend with clipping logic
// and allows us to reuse the same optimized backend code for clipped and unclipped paths.
int requestedParallelism = this.configuration.MaxDegreeOfParallelism;
int partitionCount = ParallelExecutionHelper.GetPartitionCount(requestedParallelism, this.commandCount);
if (partitionCount <= 1)
{
for (int i = 0; i < this.commandCount; i++)
{
PrepareCommand(ref this.commands[i]);
}
return;
}
_ = Parallel.For(
0,
partitionCount,
ParallelExecutionHelper.CreateParallelOptions(requestedParallelism, partitionCount),
partitionIndex =>
{
// Integer division splits the commands into contiguous half-open ranges,
// keeping the partitions balanced while assigning each command exactly once.
int commandStart = (partitionIndex * this.commandCount) / partitionCount;
int commandEnd = ((partitionIndex + 1) * this.commandCount) / partitionCount;
for (int i = commandStart; i < commandEnd; i++)
{
PrepareCommand(ref this.commands[i]);
}
});
}
private static void PrepareCommand(ref CompositionSceneCommand command)
{
if (command is PathCompositionSceneCommand pathCommand)
{
CompositionCommand composition = pathCommand.Command;
if (composition.ClipPaths is { Count: > 0 })
{
IPath path = composition.SourcePath;
DrawingOptions sourceOptions = composition.DrawingOptions;
if (sourceOptions.Transform != Matrix4x4.Identity)
{
path = path.Transform(sourceOptions.Transform);
}
path = path.Clip(sourceOptions.ShapeOptions, composition.ClipPaths);
RasterizerOptions rasterizerOptions = composition.RasterizerOptions;
DrawingOptions preparedOptions = WithIdentityTransform(sourceOptions);
// Update the command with the clipped path.
pathCommand.Command = CompositionCommand.Create(
path,
composition.Brush.Transform(sourceOptions.Transform),
preparedOptions,
in rasterizerOptions,
composition.TargetBounds,
composition.DestinationOffset,
null,
composition.IsInsideLayer);
}
}
else if (command is StrokePathCompositionSceneCommand strokePathCommand)
{
StrokePathCommand composition = strokePathCommand.Command;
if (composition.ClipPaths is { Count: > 0 })
{
IPath path = composition.Pen.GeneratePath(composition.SourcePath);
DrawingOptions sourceOptions = composition.DrawingOptions;
if (sourceOptions.Transform != Matrix4x4.Identity)
{
path = path.Transform(sourceOptions.Transform);
}
path = path.Clip(sourceOptions.ShapeOptions, composition.ClipPaths);
RasterizerOptions rasterizerOptions = composition.RasterizerOptions;
DrawingOptions preparedOptions = WithIdentityTransform(sourceOptions);
command = new PathCompositionSceneCommand(
CompositionCommand.Create(
path,
composition.Brush.Transform(sourceOptions.Transform),
preparedOptions,
in rasterizerOptions,
composition.TargetBounds,
composition.DestinationOffset,
null,
composition.IsInsideLayer));
}
else
{
// We need to dash the path here before sending it to the backend.
Pen pen = composition.Pen;
if (pen.StrokePattern.Length >= 2)
{
strokePathCommand.Command = new StrokePathCommand(
composition.SourcePath.GenerateDashes(pen.StrokeWidth, pen.StrokePattern.Span),
composition.Brush,
composition.DrawingOptions,
composition.RasterizerOptions,
composition.TargetBounds,
composition.DestinationOffset,
composition.Pen,
null,
composition.IsInsideLayer);
}
}
}
}
private static DrawingOptions WithIdentityTransform(DrawingOptions source)
=> source.Transform == Matrix4x4.Identity
? source
: new DrawingOptions(source.GraphicsOptions, source.ShapeOptions, Matrix4x4.Identity);
}
}