OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / LinuxCli / Program.cs

237 lines · csharp ref:
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;
    }

}