| … | … | @@ -0,0 +1,254 @@ |
| 1 | --- |
| 2 | name: ifsrenderer-linux |
| 3 | description: IFSRenderer Linux fork — a 3D IFS fractal flame renderer/editor (C#/.NET 10). Adds an Avalonia editor, a headless CLI and a Blender add-on beside upstream's Windows-only WPF app. Read before touching the animation/keyframe model, either shell's UI, the OpenGL compute renderer, the .ifsjson format, or the AppImage/pear release path. |
| 4 | category: project |
| 5 | priority: normal |
| 6 | status: active |
| 7 | updated: 2026-09-05 |
| 8 | --- |
| 9 | |
| 10 | # ifsrenderer-linux |
| 11 | |
| 12 | Fork of **[bezo97/IFSRenderer](https://github.com/bezo97/IFSRenderer)** (GPLv3, © |
| 13 | Dócs Zoltán & contributors) that adds Linux support. The fractal flame engine, the |
| 14 | GLSL kernel, the `.ifstf` transform plugins and the file format are upstream's. |
| 15 | Engine bugs get reported there; UI work happens here. |
| 16 | |
| 17 | - **Working copy:** `hyper-fleeky/IFSRenderer/` (top-level, canonical) |
| 18 | - **Branch:** `linux-port` — all fork work. `master` tracks upstream. |
| 19 | - **Remotes:** `origin` = github bezo97/IFSRenderer · `pear` = the forge |
| 20 | - **Forge repo:** `ifsrenderer-linux`, under the **`labs`** org |
| 21 | |
| 22 | ## Where the knowledge actually lives |
| 23 | |
| 24 | **The repo's own issue tracker is empty** (`pgh issue ifsrenderer-linux --counts` |
| 25 | → 0/0/0). Everything is on the **labs org**: |
| 26 | |
| 27 | | labs # | Covers | |
| 28 | |---|---| |
| 29 | | **#3** | UI parity audit — Avalonia shell vs canonical WPF, panel by panel (21KB, the big one) | |
| 30 | | **#6** | Animation fixes + the Blender add-on; three IFSEngine defects | |
| 31 | | **#7** | Blender scene integration + ffmpeg export | |
| 32 | | **#8** | Bake orientation, point-cloud path | |
| 33 | | **#9** | Release-path fixes, volume bake | |
| 34 | | **#10** | A release fix that shipped; add-on work reverted | |
| 35 | |
| 36 | ```bash |
| 37 | pgh issue --org labs --status all # all of it |
| 38 | pgh issue ifsrenderer-linux # empty — don't stop here |
| 39 | ``` |
| 40 | |
| 41 | In-repo: `PARITY-AUDIT.md`, `POST-MORTEM.md`, `CHANGELOG.md`, `README.md`, |
| 42 | `blender/*.md`. Animation coverage in those is thin — the org issues are better. |
| 43 | |
| 44 | ## Layout |
| 45 | |
| 46 | | Path | What | |
| 47 | |---|---| |
| 48 | | `IFSEngine/` | Upstream engine: model, animation, `RendererGL` (OpenGL 4.5 compute), serialization | |
| 49 | | `WpfDisplay/` | Upstream's Windows editor (~9.9k lines). **The parity reference** — read it before inventing UI | |
| 50 | | `AvaloniaShell/` | Our Linux editor (~9.7k lines). `MainWindow.axaml.cs` is 3380 of them | |
| 51 | | `AvaloniaShell.Tests/` | Headless-Avalonia xUnit suite (~3.3k lines, 22 files) | |
| 52 | | `Shared/` | Portable: `ParamPath`, undo, palettes, PNG metadata, settings | |
| 53 | | `LinuxCli/` | Headless PNG/EXR renderer, no display server | |
| 54 | | `Probe/` | Round-trips against the real shipped `.ifsjson` data | |
| 55 | | `Transforms/` | Vendored `.ifstf` plugins — vendored, not the submodule, because CI uses `git archive` | |
| 56 | | `blender/` | Pure-Python Blender add-on (see below) | |
| 57 | | `packaging/` | `run-tests.sh`, `build-tarball.sh`, `build-appimage.sh` | |
| 58 | |
| 59 | **`IFSEngine/` is NOT byte-for-byte upstream, whatever the README says.** 14 files |
| 60 | differ from `origin/master` — mostly the upstream `palette-manager` merge, plus our |
| 61 | own `c57ac9e` (`EasingDirection.Out`). Modifying the engine is established, but it |
| 62 | is where file-format compatibility gets broken, so read the compat note below first. |
| 63 | |
| 64 | ## Build, test, release |
| 65 | |
| 66 | **`dotnet` is not on `PATH`.** It lives at `~/.dotnet/dotnet`, SDK **10.0.302**. |
| 67 | `global.json` pins `6.0.x`; builds succeed anyway — don't "fix" it. |
| 68 | |
| 69 | ```bash |
| 70 | export PATH="$HOME/.dotnet:$PATH" |
| 71 | dotnet build IFSEngine/IFSEngine.csproj -c Release # ~4s |
| 72 | dotnet build AvaloniaShell/AvaloniaShell.csproj -c Release |
| 73 | sh packaging/run-tests.sh # the gate CI uses |
| 74 | ``` |
| 75 | |
| 76 | `run-tests.sh` unsets `Version`/`VersionPrefix`/`VersionSuffix` and passes |
| 77 | `-p:Version=` explicitly: the pear-git runner exports `Version=<branch>-<sha>`, |
| 78 | which NuGet rejects as a version string. Keep both halves. |
| 79 | |
| 80 | **Deploy is `git push pear linux-port`** — `.pear-git/ci.yaml` runs the test suite, |
| 81 | **gates the release on `grep -qE "^ *Passed! " test.log`**, builds the tarball and |
| 82 | AppImage, and stages `by-arch/linux-x64/app/ifsrenderer.AppImage` + `package.json` |
| 83 | for `pear install`. `vdrive: true` gives each release a durable `pear://`. |
| 84 | Channels: `experimental` ← branches `linux-port`/`master`; `production` ← `v*` tags. |
| 85 | |
| 86 | The staged app name must stay **`ifsrenderer`**: `pear install` derives appName from |
| 87 | `productName ?? name`, then extracts `<appName>.desktop` from the AppImage and throws |
| 88 | if absent. Every failing CI step writes to a log file and a separate always-succeeding |
| 89 | step prints it, because the runner discards output from failing steps. |
| 90 | |
| 91 | ## The animation model — read this before touching keyframes |
| 92 | |
| 93 | Five small files under `IFSEngine/Animation/`. The whole evaluator is |
| 94 | `Channel.EvaluateAt` (99 lines). |
| 95 | |
| 96 | ``` |
| 97 | Dopesheet ──has──▶ Dictionary<string path, Channel> |
| 98 | Channel ──has──▶ List<Keyframe> + optional AudioChannelDriver |
| 99 | Keyframe = { t, Value, Type, InterpolationMode, EasingDirection, EasingPower, |
| 100 | Left/RightHandleType, Left/RightHandleT, Left/RightHandleValue, |
| 101 | BackOvershoot, ElasticAmplitude, ElasticPeriod } |
| 102 | IFS.EvaluateAt(t) → per channel: Channel.EvaluateAt(t) → NestedReflectionHelper |
| 103 | .SetMemberValueByPath(this, path, val) ← writes onto the fractal |
| 104 | ``` |
| 105 | |
| 106 | **There are TWO evaluators, chosen per keyframe by `Type`.** |
| 107 | |
| 108 | - `Type` (`KeyframeType`) = `Legacy` + Blender's 13 presets: Constant, Linear, Bezier, |
| 109 | Sinusoidal→Circular, Back, Bounce, Elastic. `Legacy = 0`, so a keyframe from a file |
| 110 | written before this existed deserializes to it and evaluates exactly as it always |
| 111 | did. **`Legacy` does NOT mean Catmull-Rom** — it means "evaluate through |
| 112 | `InterpolationMode`", which may be any of the three. |
| 113 | - `InterpolationMode` = `{ Linear, Constant, CatmullRom }`, the legacy path only, and |
| 114 | **still defaults to `CatmullRom`** (`Keyframe.cs:5`) so old files are untouched. |
| 115 | - Setting `Type` projects `InterpolationMode` onto the nearest legacy equivalent, so a |
| 116 | file written here still evaluates — approximately — in the upstream Windows app. |
| 117 | - A segment is governed by its **left** keyframe's mode — except `InOut` easing, |
| 118 | which also reads the **right** key's `EasingPower` (`Channel.cs:51`). |
| 119 | - `Dopesheet.AddOrUpdateChannel` — the ◇ diamond path, and the only creation site — |
| 120 | stamps **`Bezier` + `AutoClamped` handles**, which is Blender's default. This is where |
| 121 | the fix for the wobble lives: it is a creation-time default, NOT a change to |
| 122 | `Keyframe`'s own default, precisely so loaded files keep their behaviour. |
| 123 | - Keys within `0.99/Fps` of an existing key update it instead of inserting. |
| 124 | |
| 125 | ### The Catmull-Rom tangent, and why holds wobbled |
| 126 | |
| 127 | Still the behaviour of any `Legacy` keyframe. `Channel.cs` — `t1 = (p2 - p0) / 2`, from |
| 128 | the neighbours' **values only**: |
| 129 | |
| 130 | - It **never flattens.** Two equal keys with unequal neighbours still get a slope, |
| 131 | so a hold must leave its value and come back. Measured on keys |
| 132 | `(3s,0) (5s,1) (7s,1) (9s,2)` with engine defaults: **min 0.9519 @ 6.58s, |
| 133 | max 1.0481 @ 5.42s — 9.62% peak-to-peak** on a value the user pinned twice. |
| 134 | - It **ignores Δt** (uniform, not centripetal), so tight keys beside wide ones |
| 135 | overshoot. |
| 136 | - Blender does not have this problem because its default is Bezier with |
| 137 | **Auto Clamped** handles, which zeroes the tangent at a local extremum. |
| 138 | |
| 139 | **`BezierHandles.cs` is the replacement path.** Handles for `Automatic`, `AutoClamped` |
| 140 | and `Vector` are **derived on every read**, never cached on the keyframe — `Keyframe` is |
| 141 | a plain mutable object in a plain `List<T>`, so a moved key raises no notification and a |
| 142 | cache would go stale the first time one was dragged. Only `Free` and `Aligned` store |
| 143 | coordinates, and only those two are serialized. `AutoClamped` zeroes the tangent at a |
| 144 | local extremum (which two equal keys in a row always are) and clamps handles into the |
| 145 | neighbour range; `Correct` shortens handles that reach past the segment in time, so the |
| 146 | curve cannot fold back; the time→parameter inversion is bisection, because it is |
| 147 | unconditionally convergent on a monotonic x. |
| 148 | |
| 149 | ### Where the keyframe controls hide |
| 150 | |
| 151 | Both shells expose interpolation/easing/power; neither is discoverable. |
| 152 | |
| 153 | - **WPF (Windows):** right-click the dope diamond → context menu |
| 154 | (`WpfDisplay/Views/Animation/DopeButtonResources.xaml:38-64`). |
| 155 | - **Avalonia (Linux):** the `KEYFRAME` editor in the animation tab's left column, |
| 156 | `IsVisible=false` until a key is selected. Landed `450d3aa` (2026-08-16) when the tab |
| 157 | was uncrowded. Selection is wired from both canvases. It now leads with a **type** |
| 158 | dropdown carrying Blender's presets in Blender's order; `ShowRowsFor` reveals only the |
| 159 | rows that type uses (handles for Bezier, easing for the easing/dynamic presets, interp |
| 160 | + power for Legacy alone). Bezier handles draw and drag in `CurveCanvas` — dragging a |
| 161 | derived handle makes it `Free`, as Blender does, or the position would spring back. |
| 162 | |
| 163 | Upstream's `WpfDisplay/Views/Animation/CurveEditor.xaml` is a **29-line |
| 164 | placeholder** reading "🚧 Under development" — there is no curve editor upstream to |
| 165 | reach parity with, and PARITY-AUDIT.md was wrong to list one (corrected on labs #3). |
| 166 | `AvaloniaShell/CurveCanvas.cs` is ours, and its plot is sampled from |
| 167 | `Channel.EvaluateAt` per pixel column so it cannot drift from what renders. |
| 168 | |
| 169 | ### `.ifsjson` compatibility — enums are bare integers |
| 170 | |
| 171 | `IfsSerializer` uses **Newtonsoft** with no `StringEnumConverter`, so enums |
| 172 | serialize as numbers. Measured: |
| 173 | |
| 174 | ```json |
| 175 | { "InterpolationMode": 2, "EasingPower": 1.0, "EasingDirection": 2, "t": 0.0, "Value": 1.0 } |
| 176 | ``` |
| 177 | |
| 178 | This is why `KeyframeType` is a separate field rather than new members on |
| 179 | `InterpolationMode`: **adding a member to `InterpolationMode` writes an integer upstream |
| 180 | cannot read.** |
| 181 | `Channel.EvaluateAt` ends in `else throw new NotImplementedException()`, so a file |
| 182 | using a new mode crashes the Windows app at evaluation time — breaking the README's |
| 183 | "fully compatible with the Windows app". Newtonsoft ignores **unknown properties** |
| 184 | by default, so *new fields* on `Keyframe` degrade gracefully where new enum values |
| 185 | do not. Prefer additive fields, and keep `InterpolationMode` set to the closest |
| 186 | legacy mode so upstream still renders something sane. |
| 187 | |
| 188 | `Keyframe` uses **`ShouldSerialize<Property>()`** (a Newtonsoft feature) so a keyframe |
| 189 | writes only the fields its type actually uses. A `Legacy` keyframe is byte-identical to |
| 190 | what it was before any of this existed; a new one adds three fields: |
| 191 | |
| 192 | ```json |
| 193 | {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":5.0,"Value":1.0, |
| 194 | "Type":3,"LeftHandleType":4,"RightHandleType":4} |
| 195 | ``` |
| 196 | |
| 197 | Verified through `IfsSerializer` itself, not just `JsonConvert` — `IfsConverter` does not |
| 198 | special-case the dopesheet, so the default object serializer (and thus `ShouldSerialize`) |
| 199 | is the real path. |
| 200 | |
| 201 | ### Viewport refresh asymmetry (fixed) |
| 202 | |
| 203 | `AnimationView.TimeChanged` posts `f.EvaluateAt(t)` + `r.InvalidateParamsBuffer()`. |
| 204 | `AnimationView.Changed` — raised by **every keyframe edit** — used to call only |
| 205 | `PushStateToUi` + `NotifyAnimationStateChanged` and **never re-evaluate**, so an edit |
| 206 | reached the viewport only once the playhead moved. It now goes through |
| 207 | `PostThenRefresh`, which orders the UI push after the evaluation on the render thread. |
| 208 | Use that helper for anything that mutates the fractal and then reads it back: a bare |
| 209 | `Post` plus an immediate UI read is a race. |
| 210 | |
| 211 | ## Engine defects this port surfaced (all affect Windows) |
| 212 | |
| 213 | 1. **`EasingDirection.Out` ran every segment backwards** — `1 - t^p` is 1 at t=0. |
| 214 | Fixed here (`c57ac9e`, `Channel.cs:50`); **still open upstream**. |
| 215 | 2. **`alias_tables`** declared `vec4[MAX_ITERATORS]`, indexed to `itnum²` — 160KB |
| 216 | against a 64KB UBO cap. |
| 217 | 3. **`GradientResolution = 256`** against `vec4 palette[150]` — over-runs by 106. |
| 218 | |
| 219 | (2) and (3) are out-of-bounds UBO reads NVIDIA tolerates. Report to bezo97. |
| 220 | |
| 221 | ## Renderer gotcha |
| 222 | |
| 223 | `RendererGL` clears the histogram and zeroes `TotalIterations` **inside** |
| 224 | `DispatchCompute`, when it sees the flags `LoadParams` raised — invalidating alone |
| 225 | changes no counter. Any accumulation gate must be `do/while`, never `while`: |
| 226 | **the dispatch IS the reset.** A `while` gate shipped 300 identical frames from |
| 227 | "Export animation". |
| 228 | |
| 229 | ## The Blender add-on (`blender/`) |
| 230 | |
| 231 | Pure Python, no .NET at runtime; `build_addon.py` makes the zip. **Your GLSL is used |
| 232 | verbatim** — the kernel and all 46 `.ifstf` plugins compile unmodified; only the host |
| 233 | layer (`FromString`, std140 packing, `AliasMethod`, the loader) is reimplemented. |
| 234 | Verified 0 mismatches on plugins and alias tables. |
| 235 | |
| 236 | Blender's GPU module forces: no `GPUStorageBuf` (histogram is an R32UI image with |
| 237 | fixed-point `imageAtomicAdd` + a drain pass), no float image atomics, **`max_images |
| 238 | = 8`** and a declared-but-unused image still burns a slot, unused push constants are |
| 239 | **stripped** (so `uniform_int` raises where GL returns -1), default image qualifier is |
| 240 | write-only, and the Vulkan backend segfaults at 13/16/32 images. Samplers are a |
| 241 | separate 32-slot budget. |
| 242 | |
| 243 | Run its tests with Blender's Python, not the system one. |
| 244 | |
| 245 | ## Traps |
| 246 | |
| 247 | - **The repo issues page is empty.** Org issues are the record. |
| 248 | - **`dotnet` is not on `PATH`.** |
| 249 | - **`IFSEngine/` is modified** despite the README's "byte-for-byte unmodified". |
| 250 | - **Enum values are file-format breaking; new fields are not.** |
| 251 | - Almost every bug this project has produced was **silent** — a wrong std140 stride, |
| 252 | a mirrored camera basis, an unclamped tangent. They render a fractal, just not the |
| 253 | right one. Verify differentially (against the engine's own C#, or Blender's own |
| 254 | projection), never by reasoning about it. |