using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using IFSEngine.Generation;
using IFSEngine.Model;
using IFSEngine.Rendering;
using IFSEngine.Serialization;
using IFSEngine.Utility;
using IFSRenderer.Shared;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace IFSRenderer.LinuxCli;
/// <summary>
/// Native Linux headless host for IFSEngine.
/// Supplies the <see cref="IGraphicsContext"/> that the engine needs via a hidden
/// GLFW window, so no WPF / WinForms / Windows dependency is involved.
/// </summary>
/// <summary>
/// Pumps async continuations back onto the thread that owns the GL context.
/// <para>
/// This is the piece WPF supplied implicitly: its Dispatcher is a
/// SynchronizationContext, so every <c>await</c> inside IFSEngine resumed on the
/// UI thread, where the GL context was current. A console app has NO
/// SynchronizationContext, so continuations resume on arbitrary ThreadPool
/// threads - and any GL call made there segfaults inside libGLdispatch, because
/// an OpenGL context is current on ONE thread at a time.
/// </para>
/// </summary>
internal sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object)> _queue = new();
public override void Post(SendOrPostCallback d, object state) => _queue.Add((d, state));
public override void Send(SendOrPostCallback d, object state) => throw new NotSupportedException();
public void RunOnCurrentThread()
{
foreach ((SendOrPostCallback callback, object state) in _queue.GetConsumingEnumerable())
callback(state);
}
public void Complete() => _queue.CompleteAdding();
}
internal static class Program
{
private static int Main(string[] args)
{
var syncCtx = new SingleThreadSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(syncCtx);
Task<int> task = RunAsync(args);
_ = task.ContinueWith(_ => syncCtx.Complete(), TaskScheduler.Default);
syncCtx.RunOnCurrentThread();
return task.GetAwaiter().GetResult();
}
private static async Task<int> RunAsync(string[] args)
{
if (args.Contains("--help") || args.Contains("-h"))
{
Console.WriteLine("""
ifsrender - native Linux renderer for IFSRenderer fractals
--input <file.ifsjson> Render an existing fractal parameter file
--generate Generate a random fractal instead
--seed <n> RNG seed for --generate
--out <file> Output path (.png or .exr). Default: out.png
--width <n> Image width (default: from file, else 1920)
--height <n> Image height (default: from file, else 1080)
--quality <n> Target iteration level (default: from file, else 12)
--library <dir> Library dir (Includes/PostEffects/Templates)
--transforms <dir> Transform plugin dir (*.ifstf)
""");
return 0;
}
string libraryDir = GetArg(args, "--library") ?? AssetPaths.Library;
string transformsDir = GetArg(args, "--transforms") ?? AssetPaths.Transforms;
if (!Directory.Exists(transformsDir))
{
Console.Error.WriteLine("Could not find the plugin library.\n" + AssetPaths.Describe());
return 1;
}
string outPath = GetArg(args, "--out") ?? "out.png";
string inputPath = GetArg(args, "--input");
// ---- 1. GL context. This is the ONLY piece the WPF app provided that we must replace.
Console.WriteLine("Creating OpenGL context...");
var nws = new NativeWindowSettings
{
ClientSize = new OpenTK.Mathematics.Vector2i(320, 240),
APIVersion = new Version(4, 5),
Profile = ContextProfile.Core,
Flags = ContextFlags.Debug,
StartVisible = false,
StartFocused = false,
Title = "ifsrender",
};
using var window = new NativeWindow(nws);
window.Context.MakeCurrent();
Console.WriteLine($" GL vendor: {OpenTK.Graphics.OpenGL.GL.GetString(OpenTK.Graphics.OpenGL.StringName.Vendor)}");
Console.WriteLine($" GL renderer: {OpenTK.Graphics.OpenGL.GL.GetString(OpenTK.Graphics.OpenGL.StringName.Renderer)}");
Console.WriteLine($" GL version: {OpenTK.Graphics.OpenGL.GL.GetString(OpenTK.Graphics.OpenGL.StringName.Version)}");
// ---- 2. Load the plugin library exactly like Workspace.LoadSourceLibrary does.
string includesDir = Path.Combine(libraryDir, "Includes");
string postFxDir = Path.Combine(libraryDir, "PostEffects");
var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
var 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();
var transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
var allPostFx = Directory.Exists(postFxDir)
? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
.Select(EffectPlugin.FromFile))).ToList()
: [];
var postFxs = allPostFx.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
Console.WriteLine($"Loaded {transforms.Count} transforms, {postFxs.Count} post-effects, {includeSources.Count} includes.");
// ---- 3. Engine init (compiles the GLSL compute kernel against the loaded plugins).
await using var renderer = new RendererGL(window.Context);
Console.WriteLine("Compiling shaders...");
await renderer.Initialize(includeSources, transforms, postFxs);
// ---- 4. The fractal itself.
IFS fractal;
if (inputPath is not null)
{
Console.WriteLine($"Loading {inputPath}");
fractal = await IfsSerializer.LoadJsonFileAsync(inputPath, transforms, postFxs, true);
}
else
{
uint seed = uint.TryParse(GetArg(args, "--seed"), out uint s) ? s : (uint)Random.Shared.Next();
Console.WriteLine($"Generating a random fractal (seed {seed})");
var generator = new Generator(transforms) { };
fractal = generator.GenerateOne(new GeneratorOptions { });
}
int width = int.TryParse(GetArg(args, "--width"), out int w) ? w
: (fractal.ImageResolution.Width > 0 ? fractal.ImageResolution.Width : 1920);
int height = int.TryParse(GetArg(args, "--height"), out int h) ? h
: (fractal.ImageResolution.Height > 0 ? fractal.ImageResolution.Height : 1080);
fractal.ImageResolution = new System.Drawing.Size(width, height);
if (int.TryParse(GetArg(args, "--quality"), out int q))
fractal.TargetIterationLevel = q;
renderer.LoadParams(fractal);
// LoadParams downscales the histogram to the *display* size; for a final
// render we want 1:1, same as the app's own export path does.
renderer.SetHistogramScale(1.0);
Console.WriteLine($"Rendering {width}x{height} to iteration level {fractal.TargetIterationLevel}...");
var sw = System.Diagnostics.Stopwatch.StartNew();
int dispatches = 0;
// NOTE: RendererGL only updates IsTargetIterationReached inside its own
// StartRenderLoop (which requires a swapchain and calls SwapBuffers).
// Driving DispatchCompute directly for a headless render means we must
// evaluate the same stop condition ourselves.
while (!IsTargetReached(renderer, fractal))
{
renderer.DispatchCompute();
dispatches++;
if (dispatches % 200 == 0)
Console.WriteLine($" density level {CurrentIterationLevel(renderer)}/{fractal.TargetIterationLevel}, {sw.Elapsed.TotalSeconds:F1}s");
}
renderer.RenderImage();
sw.Stop();
Console.WriteLine($"Done in {sw.Elapsed.TotalSeconds:F1}s ({dispatches} dispatches, {renderer.TotalIterations:N0} iterations).");
// ---- 5. Write the image.
if (outPath.EndsWith(".exr", StringComparison.OrdinalIgnoreCase))
{
var histogram = await renderer.ReadHistogramData();
using var fs = File.Create(outPath);
OpenEXR.WriteStream(fs, histogram);
}
else
{
float[,,] px = await renderer.ReadPixelData();
int ph = px.GetLength(0), pw = px.GetLength(1);
using var img = new Image<Rgba32>(pw, ph);
for (int y = 0; y < ph; y++)
{
// GL reads bottom-up; flip to top-down (the app does RotateNoneFlipY).
int srcY = ph - 1 - y;
for (int x = 0; x < pw; x++)
{
img[x, y] = new Rgba32(
Math.Clamp(px[srcY, x, 0], 0f, 1f),
Math.Clamp(px[srcY, x, 1], 0f, 1f),
Math.Clamp(px[srcY, x, 2], 0f, 1f),
Math.Clamp(px[srcY, x, 3], 0f, 1f));
}
}
await img.SaveAsync(outPath);
}
Console.WriteLine($"Wrote {outPath}");
return 0;
}
/// <summary>Mirrors the stop condition in <c>RendererGL.StartRenderLoop</c>.</summary>
private static int CurrentIterationLevel(RendererGL r) =>
System.Numerics.BitOperations.Log2(1 + r.TotalIterations / (ulong)(r.HistogramWidth * r.HistogramHeight));
private static bool IsTargetReached(RendererGL r, IFS fractal) =>
CurrentIterationLevel(r) >= fractal.TargetIterationLevel;
private static string GetArg(string[] args, string name)
{
int i = Array.IndexOf(args, name);
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
}
}