using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using IFSEngine.Model;
using IFSEngine.Rendering;
using IFSEngine.Utility;
using IFSRenderer.AvaloniaShell.Rendering;
using IFSRenderer.Shared;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
namespace IFSRenderer.AvaloniaShell;
/// <summary>
/// Owns the OpenGL context and the <see cref="RendererGL"/> on a single dedicated
/// thread, and publishes finished frames as a BGRA byte buffer for the UI.
/// </summary>
/// <remarks>
/// <para>
/// An OpenGL context is current on exactly ONE thread, so every engine call must
/// happen on the thread that created it. All UI interaction therefore goes through
/// <see cref="Post"/> / <see cref="PostAsync"/>, which queue work onto that thread.
/// </para>
/// <para>
/// The engine is async internally. We install a <see cref="QueueSynchronizationContext"/>
/// so any continuation is pumped back onto the GL thread rather than escaping to the
/// ThreadPool - where a GL call would segfault inside libGLdispatch. This is the role
/// WPF's Dispatcher played in the original app.
/// </para>
/// <para>
/// Frames reach the UI as a CPU copy (<see cref="RendererGL.CopyPixelDataToBitmap"/>,
/// the engine's own bitmap path, which emits BGRA8 - exactly Avalonia's Bgra8888).
/// This deliberately avoids sharing the GL context with Avalonia's compositor, whose
/// context is not guaranteed to expose the GL 4.3+ compute shaders the engine needs.
/// </para>
/// </remarks>
public sealed class RenderService : IRenderQueue
{
private readonly BlockingCollection<Action> _queue = new();
private readonly ManualResetEventSlim _started = new(false);
private Thread _thread;
private NativeWindow _window;
private RendererGL _renderer;
/// <summary>The narrow view of <see cref="_renderer"/> handed to queued UI actions.</summary>
private IRenderer _api;
private volatile bool _running;
private volatile bool _paused;
/// <summary>Guards <see cref="FrameBuffer"/> against the UI thread reading a torn frame.</summary>
public object FrameLock { get; } = new();
/// <summary>Bottom-up BGRA8 pixels, <see cref="FrameWidth"/> x <see cref="FrameHeight"/>.</summary>
public byte[] FrameBuffer { get; private set; } = [];
public int FrameWidth { get; private set; }
public int FrameHeight { get; private set; }
public event Action FrameReady;
public event Action<Exception> Failed;
public string GpuName { get; private set; } = "(not initialised)";
public Exception StartupError { get; private set; }
public IReadOnlyList<TransformPlugin> Transforms { get; private set; } = [];
public IReadOnlyList<EffectPlugin> Effects { get; private set; } = [];
public IReadOnlyList<string> IncludeSources { get; private set; } = [];
public IFS Current { get; private set; }
public ulong TotalIterations => _renderer?.TotalIterations ?? 0;
public int IterationLevel => _renderer is null || _renderer.HistogramWidth == 0
? 0
: System.Numerics.BitOperations.Log2(1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
public double Fps { get; private set; }
/// <summary>Iterations per second, measured over the last sampling window.</summary>
public double IterationSpeed { get; private set; }
/// <summary>Iterations each GPU invocation performs; the engine tunes this to hit TargetFramerate.</summary>
public int InvocationIters => _renderer?.InvocationIters ?? 0;
public int WorkgroupCount => _renderer?.WorkgroupCount ?? 0;
/// <summary>Pauses compute dispatching (e.g. while a modal export runs).</summary>
/// <remarks>Resuming clears <see cref="LastError"/>: the user is retrying.</remarks>
public bool Paused
{
get => _paused;
set
{
if (!value)
LastError = null;
_paused = value;
}
}
/// <summary>
/// The exception that stopped the render loop, or null while it is healthy.
/// </summary>
/// <remarks>
/// A throw from <see cref="RendererGL.DispatchCompute"/> pauses the loop rather than
/// spinning on a failing GL call. That pause used to be invisible — <see cref="Failed"/>
/// wrote to the status bar, which the twice-a-second status ticker overwrote immediately,
/// so the renderer simply appeared to stop for no reason. Keeping the error here lets the
/// UI keep showing it until rendering is actually resumed.
/// </remarks>
public Exception LastError { get; private set; }
// ---------------------------------------------------------------- lifecycle
/// <summary>
/// Loads the plugin library off-thread, then starts the GL thread.
/// File I/O is done here, BEFORE the GL context exists, so its async
/// continuations cannot land on the GL thread.
/// </summary>
public async Task StartAsync(string libraryDir, string transformsDir)
{
string includesDir = Path.Combine(libraryDir, "Includes");
string postFxDir = Path.Combine(libraryDir, "PostEffects");
var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
IncludeSources = (await Task.WhenAll(includeFiles.Select(f => File.ReadAllTextAsync(f)))).ToList();
var includeNames = includeFiles.Select(Path.GetFileName).ToList();
var allTransforms = (await Task.WhenAll(Directory
.GetFiles(transformsDir, "*.ifstf", SearchOption.AllDirectories)
.Select(TransformPlugin.FromFile))).ToList();
Transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
var allEffects = Directory.Exists(postFxDir)
? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
.Select(EffectPlugin.FromFile))).ToList()
: [];
Effects = allEffects.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
_running = true;
_thread = new Thread(ThreadMain)
{
Name = "IFSEngine GL",
IsBackground = true,
};
_thread.Start();
await Task.Run(() => _started.Wait());
if (StartupError is not null)
throw new InvalidOperationException("Renderer failed to start.", StartupError);
}
private void ThreadMain()
{
try
{
SynchronizationContext.SetSynchronizationContext(new QueueSynchronizationContext(_queue));
// The window was created on the main thread (GLFW requires that);
// acquiring its context here is legal and is what pins the engine
// to this thread for the rest of its life.
_window = Program.GlWindow;
_window.Context.MakeCurrent();
GpuName = OpenTK.Graphics.OpenGL.GL.GetString(OpenTK.Graphics.OpenGL.StringName.Renderer);
_renderer = new RendererGL(_window.Context);
_api = new RendererGLAdapter(_renderer);
PumpUntilComplete(_renderer.Initialize(IncludeSources, Transforms, Effects));
}
catch (Exception ex)
{
StartupError = ex;
_started.Set();
return;
}
_started.Set();
var frameTimer = System.Diagnostics.Stopwatch.StartNew();
var fpsTimer = System.Diagnostics.Stopwatch.StartNew();
int framesSincePresent = 0;
ulong lastIterations = 0;
while (_running)
{
DrainQueue();
if (!_running)
break;
if (!_paused && Current is not null)
{
try
{
_renderer.DispatchCompute();
}
catch (Exception ex)
{
LastError = ex;
_paused = true;
Failed?.Invoke(ex);
}
}
else
{
Thread.Sleep(5);
}
// ~30 fps presentation; the compute loop itself runs as fast as it can.
if (frameTimer.ElapsedMilliseconds >= 33 && Current is not null && !_paused)
{
Present();
framesSincePresent++;
frameTimer.Restart();
}
if (fpsTimer.ElapsedMilliseconds >= 1000)
{
double seconds = fpsTimer.ElapsedMilliseconds / 1000.0;
Fps = framesSincePresent / seconds;
ulong now = _renderer.TotalIterations;
// TotalIterations resets whenever the histogram is invalidated, so a
// negative delta means "reset", not "went backwards".
IterationSpeed = now >= lastIterations ? (now - lastIterations) / seconds : 0;
lastIterations = now;
framesSincePresent = 0;
fpsTimer.Restart();
}
}
try
{
PumpUntilComplete(_renderer.DisposeAsync().AsTask());
// The window itself is owned by Program (main thread) - don't dispose it here.
_window.Context.MakeNoneCurrent();
}
catch { /* shutting down */ }
}
private void Present()
{
_renderer.RenderImage();
int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
if (w <= 0 || h <= 0)
return;
lock (FrameLock)
{
int needed = w * h * 4;
if (FrameBuffer.Length != needed)
FrameBuffer = new byte[needed];
GCHandle pin = GCHandle.Alloc(FrameBuffer, GCHandleType.Pinned);
try
{
PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject()));
}
finally
{
pin.Free();
}
FrameWidth = w;
FrameHeight = h;
}
FrameReady?.Invoke();
}
// ------------------------------------------------------------- GL-thread ops
/// <summary>Queues an action to run on the GL thread with the engine.</summary>
public void Post(Action<IRenderer> action)
{
if (!_running)
return;
_queue.Add(() =>
{
try { action(_api); }
catch (Exception ex) { Failed?.Invoke(ex); }
});
}
/// <summary>Queues an action and awaits its completion.</summary>
public Task PostAsync(Action<IRenderer> action)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_running)
{
tcs.SetResult();
return tcs.Task;
}
_queue.Add(() =>
{
try { action(_api); tcs.TrySetResult(); }
catch (Exception ex) { tcs.TrySetException(ex); }
});
return tcs.Task;
}
/// <summary>Loads a fractal and sizes the preview histogram to the viewport.</summary>
public void LoadFractal(IFS fractal, int viewportWidth, int viewportHeight)
{
Current = fractal;
Post(r =>
{
r.LoadParams(fractal);
if (viewportWidth > 0 && viewportHeight > 0)
{
_renderer.SetDisplayResolution(viewportWidth, viewportHeight);
r.SetHistogramScaleToDisplay();
}
});
}
/// <summary>
/// Sizes the preview to the viewport.
/// </summary>
/// <param name="fillViewport">
/// In exploration mode the preview is rendered at exactly the viewport's pixel size, so it
/// fills the window. The engine always gives the histogram the ASPECT of
/// <c>ImageResolution</c> and fits it at 98% (<c>SetHistogramScaleToDisplay</c>), so a
/// 16:9 fractal in a wider viewport is letterboxed; matching the resolution to the viewport
/// removes both the bars and the 2% inset. Export is unaffected — ExportAsync sets the
/// resolution it was asked for and restores it afterwards.
/// </param>
public void SetViewport(int width, int height, bool fillViewport = true)
{
if (width <= 0 || height <= 0)
return;
Post(r =>
{
_renderer.SetDisplayResolution(width, height);
IFS f = Current;
if (fillViewport && f is not null)
{
f.ImageResolution = new System.Drawing.Size(width, height);
r.LoadParams(f);
r.SetHistogramScale(1.0);
}
else
{
r.SetHistogramScaleToDisplay();
}
});
}
/// <summary>
/// Accumulates samples until the histogram reaches <paramref name="targetLevel"/>.
/// </summary>
/// <remarks>
/// <para>
/// Shared by both export paths so they agree on when a frame is finished.
/// </para>
/// <para>
/// Dispatches at least once, and that is the whole point. <see cref="RendererGL"/> clears
/// the histogram and zeroes <see cref="RendererGL.TotalIterations"/> INSIDE
/// <see cref="RendererGL.DispatchCompute"/>, when it observes the flags raised by
/// <see cref="RendererGL.LoadParams"/> (RendererGL.cs:420-432) - invalidating alone changes
/// no counter. Testing the level first therefore reads the PREVIOUS frame's exhausted
/// counter, which is by construction already at the target, so the loop is skipped, the
/// reset never happens, and the frame is tonemapped from the stale histogram. That is what
/// made a clip export write N identical copies of frame 0.
/// </para>
/// </remarks>
internal static void AccumulateToLevel(int targetLevel, Func<int> level, Action dispatch,
Action<int> onLevel = null)
{
int lastReported = -1;
int lvl;
do
{
dispatch();
lvl = level();
if (lvl != lastReported)
{
lastReported = lvl;
onLevel?.Invoke(lvl);
}
}
while (lvl < targetLevel);
}
/// <summary>
/// Renders the loaded fractal at full resolution to the target iteration level
/// and writes it out, then restores the interactive preview scale.
/// </summary>
/// <param name="metadata">
/// Export options from Settings: whether to write image metadata, whether to embed the
/// params, and the author name to attribute.
/// </param>
public Task ExportAsync(string path, int width, int height, int targetLevel,
Action<int> onLevel = null, ExportMetadata metadata = null)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_queue.Add(() =>
{
try
{
IFS fractal = Current;
var previousResolution = fractal.ImageResolution;
double previousLevel = fractal.TargetIterationLevel;
fractal.ImageResolution = new System.Drawing.Size(width, height);
fractal.TargetIterationLevel = targetLevel;
_renderer.LoadParams(fractal);
_renderer.SetHistogramScale(1.0);
AccumulateToLevel(targetLevel, Level, _renderer.DispatchCompute, onLevel);
_renderer.RenderImage();
if (path.EndsWith(".exr", StringComparison.OrdinalIgnoreCase))
{
float[][][] hdr = null;
PumpUntilComplete(_renderer.ReadHistogramData().ContinueWith(t => hdr = t.Result));
using var fs = File.Create(path);
OpenEXR.WriteStream(fs, hdr);
}
else
{
int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
byte[] bgra = new byte[w * h * 4];
GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
finally { pin.Free(); }
ExportMetadata m = metadata ?? ExportMetadata.Default;
PngParams.SavePng(path, bgra, w, h, fractal,
m.IncludeParams, m.SaveMetadata, m.Author, m.CreatedUtc);
}
// restore interactive preview
fractal.ImageResolution = previousResolution;
fractal.TargetIterationLevel = previousLevel;
_renderer.LoadParams(fractal);
_renderer.SetHistogramScaleToDisplay();
tcs.TrySetResult();
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
int Level() => System.Numerics.BitOperations.Log2(
1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
}
/// <summary>
/// Renders every frame of the clip to a PNG sequence, evaluating the dopesheet at each
/// frame time. Runs entirely on the GL thread, so the preview is naturally suspended.
/// </summary>
/// <param name="rawExr">
/// Write 32-bit .exr frames instead of 8-bit .png. Settings > Animations > Frame export
/// format.
/// </param>
/// <param name="frameSink">
/// When set, receives each frame's BGRA bytes INSTEAD of a png being written - used to pipe
/// straight into an encoder. The caller owns whatever it writes to. Ignored for
/// <paramref name="rawExr"/>, which has no streaming form.
/// </param>
public Task ExportFramesAsync(string directory, int width, int height, int targetLevel,
Action<int, int> onProgress, Func<bool> isCancelled, bool rawExr = false,
Action<byte[], int, int> frameSink = null)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_queue.Add(() =>
{
try
{
IFS fractal = Current;
var previousResolution = fractal.ImageResolution;
double previousLevel = fractal.TargetIterationLevel;
int fps = Math.Max(fractal.Dopesheet.Fps, 1);
int frames = Math.Max((int)Math.Round(fractal.Dopesheet.Length.TotalSeconds * fps), 1);
fractal.ImageResolution = new System.Drawing.Size(width, height);
fractal.TargetIterationLevel = targetLevel;
Directory.CreateDirectory(directory);
for (int frame = 0; frame < frames; frame++)
{
if (isCancelled())
break;
double t = frame / (double)fps;
fractal.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)));
// A different seed per frame stops the noise pattern from freezing.
_renderer.Seed = (uint)frame;
_renderer.LoadParams(fractal);
_renderer.SetHistogramScale(1.0);
AccumulateToLevel(targetLevel, Level, _renderer.DispatchCompute);
_renderer.RenderImage();
if (rawExr)
{
float[][][] hdr = null;
PumpUntilComplete(_renderer.ReadHistogramData().ContinueWith(t => hdr = t.Result));
using var fs = File.Create(Path.Combine(directory, $"frame_{frame:D5}.exr"));
OpenEXR.WriteStream(fs, hdr);
}
else
{
int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
byte[] bgra = new byte[w * h * 4];
GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
finally { pin.Free(); }
if (frameSink is not null)
{
// Straight to the encoder; nothing touches the disk. A slow encoder
// blocks here, which is the backpressure we want.
frameSink(bgra, w, h);
}
else
{
// Frames never carry params: they would bloat a few thousand files.
PngParams.SavePng(Path.Combine(directory, $"frame_{frame:D5}.png"), bgra, w, h,
null, includeParams: false, saveMetadata: false);
}
}
onProgress?.Invoke(frame + 1, frames);
}
fractal.ImageResolution = previousResolution;
fractal.TargetIterationLevel = previousLevel;
_renderer.LoadParams(fractal);
_renderer.SetHistogramScaleToDisplay();
tcs.TrySetResult();
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
int Level() => System.Numerics.BitOperations.Log2(
1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
}
public Task SetWorkgroupCountAsync(int count) =>
PostAsync(r => PumpUntilComplete(r.SetWorkgroupCount(count)));
/// <summary>
/// Re-reads the transform and effect plugin files from disk and recompiles the kernel,
/// so edits to a .ifstf/.ifsfx can be seen without restarting.
/// </summary>
public async Task ReloadPluginsAsync(string libraryDir, string transformsDir)
{
// File I/O off the GL thread first, exactly as at startup.
string includesDir = Path.Combine(libraryDir, "Includes");
string postFxDir = Path.Combine(libraryDir, "PostEffects");
var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
var includes = (await Task.WhenAll(includeFiles.Select(f => File.ReadAllTextAsync(f)))).ToList();
var includeNames = includeFiles.Select(Path.GetFileName).ToList();
var allTransforms = (await Task.WhenAll(Directory
.GetFiles(transformsDir, "*.ifstf", SearchOption.AllDirectories)
.Select(TransformPlugin.FromFile))).ToList();
var transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
var allEffects = Directory.Exists(postFxDir)
? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
.Select(EffectPlugin.FromFile))).ToList()
: [];
var effects = allEffects.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
await PostAsync(r =>
{
PumpUntilComplete(_renderer.LoadPlugins(includes, transforms, effects));
IncludeSources = includes;
Transforms = transforms;
Effects = effects;
// Re-bind the open fractal to the freshly parsed plugin instances.
Current?.ReloadPlugins(transforms, effects);
if (Current is not null)
r.LoadParams(Current);
});
}
/// <summary>
/// Renders a small preview of an arbitrary fractal without disturbing the open one,
/// used by the generator to show candidates.
/// </summary>
public Task<byte[]> RenderThumbnailAsync(IFS candidate, int width, int height, int level)
{
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
_queue.Add(() =>
{
try
{
IFS restore = Current;
candidate.ImageResolution = new System.Drawing.Size(width, height);
_renderer.LoadParams(candidate);
_renderer.SetHistogramScale(1.0);
while (Level() < level)
_renderer.DispatchCompute();
_renderer.RenderImage();
int w = _renderer.HistogramWidth, h = _renderer.HistogramHeight;
byte[] bgra = new byte[w * h * 4];
GCHandle pin = GCHandle.Alloc(bgra, GCHandleType.Pinned);
try { PumpUntilComplete(_renderer.CopyPixelDataToBitmap(pin.AddrOfPinnedObject())); }
finally { pin.Free(); }
if (restore is not null)
{
_renderer.LoadParams(restore);
_renderer.SetHistogramScaleToDisplay();
}
tcs.TrySetResult(bgra);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
int Level() => System.Numerics.BitOperations.Log2(
1 + _renderer.TotalIterations / (ulong)(_renderer.HistogramWidth * _renderer.HistogramHeight));
}
// ---------------------------------------------------------------- internals
private void DrainQueue()
{
while (_queue.TryTake(out Action action))
action();
}
/// <summary>
/// Runs the GL thread's continuation queue until <paramref name="task"/> finishes.
/// Never blocks on the task directly - its continuations may need this very thread.
/// </summary>
private void PumpUntilComplete(Task task)
{
while (!task.IsCompleted)
{
if (_queue.TryTake(out Action action, 5))
action();
}
task.GetAwaiter().GetResult();
}
private bool _disposed;
/// <summary>Stops the render thread and releases the queue. Safe to call more than once.</summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_running = false;
// Wake the thread so it observes _running and unwinds, then let it finish before the
// queue goes away — adding to a disposed BlockingCollection throws.
try { _queue.Add(() => { }); }
catch (ObjectDisposedException) { /* already torn down */ }
_thread?.Join(3000);
_queue.Dispose();
_started.Dispose();
}
/// <summary>Posts continuations back onto the GL thread's work queue.</summary>
private sealed class QueueSynchronizationContext : SynchronizationContext
{
private readonly BlockingCollection<Action> _target;
public QueueSynchronizationContext(BlockingCollection<Action> target) => _target = target;
public override void Post(SendOrPostCallback d, object state) => _target.Add(() => d(state));
public override void Send(SendOrPostCallback d, object state) => d(state);
}
}