From 12bfcf8dc630cfd0d2167a9823aacbb1131df03d Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Tue, 30 Jun 2026 21:39:09 +0900 Subject: [PATCH 1/8] Implement initial test runner --- .github/workflows/tests.yml | 61 +++ NImpeller.sln | 14 + README.md | 10 + tests/NImpeller.Tests/AssemblyInfo.cs | 5 + tests/NImpeller.Tests/GoldenImageTests.cs | 125 +++++ tests/NImpeller.Tests/Goldens/.gitkeep | 2 + tests/NImpeller.Tests/Goldens/blendmodes.png | Bin 0 -> 12217 bytes tests/NImpeller.Tests/Goldens/blendmodes.rimg | Bin 0 -> 11797 bytes tests/NImpeller.Tests/Goldens/clips.png | Bin 0 -> 1653 bytes tests/NImpeller.Tests/Goldens/clips.rimg | Bin 0 -> 1965 bytes .../NImpeller.Tests/Goldens/colorfilters.png | Bin 0 -> 1468 bytes .../NImpeller.Tests/Goldens/colorfilters.rimg | Bin 0 -> 1340 bytes .../NImpeller.Tests/Goldens/groupopacity.png | Bin 0 -> 1532 bytes .../NImpeller.Tests/Goldens/groupopacity.rimg | Bin 0 -> 1609 bytes .../NImpeller.Tests/Goldens/imagefilters.png | Bin 0 -> 4189 bytes .../NImpeller.Tests/Goldens/imagefilters.rimg | Bin 0 -> 3841 bytes tests/NImpeller.Tests/Goldens/maskblur.png | Bin 0 -> 32492 bytes tests/NImpeller.Tests/Goldens/maskblur.rimg | Bin 0 -> 31807 bytes tests/NImpeller.Tests/Goldens/primitives.png | Bin 0 -> 2252 bytes tests/NImpeller.Tests/Goldens/primitives.rimg | Bin 0 -> 1916 bytes tests/NImpeller.Tests/Goldens/shadows.png | Bin 0 -> 6094 bytes tests/NImpeller.Tests/Goldens/shadows.rimg | Bin 0 -> 5791 bytes tests/NImpeller.Tests/Goldens/solidshapes.png | Bin 0 -> 3519 bytes .../NImpeller.Tests/Goldens/solidshapes.rimg | Bin 0 -> 3534 bytes tests/NImpeller.Tests/Goldens/strokes.png | Bin 0 -> 3476 bytes tests/NImpeller.Tests/Goldens/strokes.rimg | Bin 0 -> 2551 bytes .../Goldens/textureinterop.png | Bin 0 -> 1440 bytes .../Goldens/textureinterop.rimg | Bin 0 -> 1724 bytes tests/NImpeller.Tests/Goldens/transforms.png | Bin 0 -> 2052 bytes tests/NImpeller.Tests/Goldens/transforms.rimg | Bin 0 -> 1462 bytes .../Headless/HeadlessImpellerContext.cs | 292 ++++++++++++ .../Headless/ImpellerGLFixture.cs | 36 ++ tests/NImpeller.Tests/Headless/RawImage.cs | 215 +++++++++ tests/NImpeller.Tests/Impeller.targets | 87 ++++ tests/NImpeller.Tests/NImpeller.Tests.csproj | 46 ++ tests/NImpeller.Tests/Scenes/GoldenScenes.cs | 443 ++++++++++++++++++ tests/NImpeller.Tests/Scenes/IScene.cs | 16 + tests/NImpeller.Tests/TextureInteropTests.cs | 38 ++ .../NImpeller.Tests/Unit/BuilderStateTests.cs | 46 ++ tests/NImpeller.Tests/Unit/LifetimeTests.cs | 46 ++ .../NImpeller.Tests/Unit/PathBuilderTests.cs | 43 ++ .../NImpeller.Tests/Unit/StructHelperTests.cs | 69 +++ tests/NImpeller.Tests/test.runsettings | 21 + 43 files changed, 1615 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/NImpeller.Tests/AssemblyInfo.cs create mode 100644 tests/NImpeller.Tests/GoldenImageTests.cs create mode 100644 tests/NImpeller.Tests/Goldens/.gitkeep create mode 100644 tests/NImpeller.Tests/Goldens/blendmodes.png create mode 100644 tests/NImpeller.Tests/Goldens/blendmodes.rimg create mode 100644 tests/NImpeller.Tests/Goldens/clips.png create mode 100644 tests/NImpeller.Tests/Goldens/clips.rimg create mode 100644 tests/NImpeller.Tests/Goldens/colorfilters.png create mode 100644 tests/NImpeller.Tests/Goldens/colorfilters.rimg create mode 100644 tests/NImpeller.Tests/Goldens/groupopacity.png create mode 100644 tests/NImpeller.Tests/Goldens/groupopacity.rimg create mode 100644 tests/NImpeller.Tests/Goldens/imagefilters.png create mode 100644 tests/NImpeller.Tests/Goldens/imagefilters.rimg create mode 100644 tests/NImpeller.Tests/Goldens/maskblur.png create mode 100644 tests/NImpeller.Tests/Goldens/maskblur.rimg create mode 100644 tests/NImpeller.Tests/Goldens/primitives.png create mode 100644 tests/NImpeller.Tests/Goldens/primitives.rimg create mode 100644 tests/NImpeller.Tests/Goldens/shadows.png create mode 100644 tests/NImpeller.Tests/Goldens/shadows.rimg create mode 100644 tests/NImpeller.Tests/Goldens/solidshapes.png create mode 100644 tests/NImpeller.Tests/Goldens/solidshapes.rimg create mode 100644 tests/NImpeller.Tests/Goldens/strokes.png create mode 100644 tests/NImpeller.Tests/Goldens/strokes.rimg create mode 100644 tests/NImpeller.Tests/Goldens/textureinterop.png create mode 100644 tests/NImpeller.Tests/Goldens/textureinterop.rimg create mode 100644 tests/NImpeller.Tests/Goldens/transforms.png create mode 100644 tests/NImpeller.Tests/Goldens/transforms.rimg create mode 100644 tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs create mode 100644 tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs create mode 100644 tests/NImpeller.Tests/Headless/RawImage.cs create mode 100644 tests/NImpeller.Tests/Impeller.targets create mode 100644 tests/NImpeller.Tests/NImpeller.Tests.csproj create mode 100644 tests/NImpeller.Tests/Scenes/GoldenScenes.cs create mode 100644 tests/NImpeller.Tests/Scenes/IScene.cs create mode 100644 tests/NImpeller.Tests/TextureInteropTests.cs create mode 100644 tests/NImpeller.Tests/Unit/BuilderStateTests.cs create mode 100644 tests/NImpeller.Tests/Unit/LifetimeTests.cs create mode 100644 tests/NImpeller.Tests/Unit/PathBuilderTests.cs create mode 100644 tests/NImpeller.Tests/Unit/StructHelperTests.cs create mode 100644 tests/NImpeller.Tests/test.runsettings diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e3312c1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,61 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout (with CppAst.NET submodule) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # Software OpenGL ES (Mesa/llvmpipe) + SDL2 + a virtual display. The tests render offscreen + # on llvmpipe (forced by tests/NImpeller.Tests/test.runsettings) but SDL still needs a video + # driver, so we provide one with Xvfb. + - name: Install GL + SDL runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + xvfb \ + libsdl2-2.0-0 \ + libegl1 libgles2 libglvnd0 \ + libegl-mesa0 libgl1-mesa-dri libglx-mesa0 + + # Bindings (src/NImpeller/Generated/Bindings.g.cs) and the native libimpeller.so are not + # committed; download the Linux SDK at the latest Flutter engine commit and generate them. + - name: Download Impeller SDK (linux-x64) and generate bindings + run: | + chmod +x ./build.sh + ./build.sh DownloadLatestImpeller --platform linux-x64 + ./build.sh GenerateBindings --platform linux-x64 + + - name: Test + run: xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release + + # On a golden mismatch the tests write actual/golden PNGs here for inspection. + - name: Upload golden diffs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: tests/NImpeller.Tests/bin/Release/net8.0/TestFailures/ + if-no-files-found: ignore diff --git a/NImpeller.sln b/NImpeller.sln index a4c3682..4f48dfa 100644 --- a/NImpeller.sln +++ b/NImpeller.sln @@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.SDL", "samples\Sand EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.Metal", "samples\Sandbox.Metal\Sandbox.Metal.csproj", "{554505AE-9958-4740-91B1-FA285716F5E5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NImpeller.Tests", "tests\NImpeller.Tests\NImpeller.Tests.csproj", "{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -126,6 +128,18 @@ Global {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x64.Build.0 = Release|Any CPU {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.ActiveCfg = Release|Any CPU {554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.Build.0 = Debug|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.Build.0 = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.ActiveCfg = Release|Any CPU + {5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 9284d6e..8046534 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,13 @@ mkdir -p ide #jq 'map(select(.command | startswith("vpython") or startswith("rm") or startswith("ln") | not))' ``` + +### Tests + +Tests are included in `tests/NImpeller.Tests` and can be run with + +```sh +dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj +``` + +At the moment, they assume you're using `llvmpipe` for a software GL context. diff --git a/tests/NImpeller.Tests/AssemblyInfo.cs b/tests/NImpeller.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..c730dd5 --- /dev/null +++ b/tests/NImpeller.Tests/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using Xunit; + +// A GL context is thread-affine and we share a single one across the suite, so tests must not +// run in parallel. (Renders are still marshalled onto the context's own worker thread.) +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/NImpeller.Tests/GoldenImageTests.cs b/tests/NImpeller.Tests/GoldenImageTests.cs new file mode 100644 index 0000000..694d665 --- /dev/null +++ b/tests/NImpeller.Tests/GoldenImageTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using NImpeller.Tests.Headless; +using NImpeller.Tests.Scenes; +using Xunit; + +namespace NImpeller.Tests; + +/// +/// Golden-image regression tests: render a deterministic scene offscreen and compare the pixels +/// against a committed baseline. +/// +/// Generate or refresh baselines by setting UPDATE_GOLDENS=1: +/// UPDATE_GOLDENS=1 dotnet test +/// A missing baseline is written automatically and the test is skipped (so first run is green and +/// the new baseline can be reviewed before committing). +/// +[Collection(ImpellerGLCollection.Name)] +public sealed class GoldenImageTests +{ + private const int PerChannelTolerance = 8; + private const double MaxDiffFraction = 0.0001; // 0.01% of pixels may differ + + private readonly ImpellerGLFixture _gl; + + public GoldenImageTests(ImpellerGLFixture gl) => _gl = gl; + + private sealed record SceneCase(IScene Scene, int Width, int Height); + + private static readonly Dictionary Cases = BuildCases( + new SolidShapesScene(), 320, 280, + new PrimitivesScene(), 360, 280, + new StrokesScene(), 360, 300, + new TransformsScene(), 320, 300, + new ClipsScene(), 320, 300, + new BlendModesScene(), 372, 312, + new ColorFiltersScene(), 340, 190, + new MaskBlurScene(), 360, 300, + new ShadowsScene(), 340, 180, + new GroupOpacityScene(), 300, 260, + new ImageFiltersScene(), 260, 240); + + public static TheoryData SceneNames() + { + var data = new TheoryData(); + foreach (var name in Cases.Keys) + { + data.Add(name); + } + return data; + } + + [Theory] + [MemberData(nameof(SceneNames))] + public void Scene_matches_golden(string sceneName) + { + Assert.SkipUnless(_gl.Available, + $"No headless GL context (run under xvfb-run or set SDL_VIDEODRIVER=offscreen). Reason: {_gl.Error}"); + + // For now, we're only running on a software renderer + // so check if llvmpipe is being used. + Assert.True(_gl.IsSoftwareRenderer, + $"Expected a software renderer (llvmpipe) but got '{_gl.RendererName}'. Ensure test.runsettings is applied (it is by default via dotnet test). See README."); + + var @case = Cases[sceneName]; + var actual = _gl.Render(@case.Scene, @case.Width, @case.Height); + Assert.True(actual.HasContent(), $"Scene '{sceneName}' produced an empty (all-zero) image."); + + GoldenAssert.Matches(sceneName, actual, PerChannelTolerance, MaxDiffFraction); + } + + private static Dictionary BuildCases(params object[] entries) + { + var dict = new Dictionary(); + for (int i = 0; i < entries.Length; i += 3) + { + var scene = (IScene)entries[i]; + dict[scene.TestName] = new SceneCase(scene, (int)entries[i + 1], (int)entries[i + 2]); + } + return dict; + } +} + +internal static class GoldenAssert +{ + public static void Matches(string name, RawImage actual, int perChannelTolerance, double maxDiffFraction) + { + string rawPath = Path.Combine(GoldensDir(), name + ".rimg"); + bool update = Environment.GetEnvironmentVariable("UPDATE_GOLDENS") == "1"; + + if (update || !File.Exists(rawPath)) + { + actual.SaveRaw(rawPath); + actual.SavePng(Path.Combine(GoldensDir(), name + ".png")); + Assert.Skip(update + ? $"Baseline updated: {rawPath}" + : $"Baseline created (review before committing): {rawPath}"); + } + + var golden = RawImage.LoadRaw(rawPath); + var diff = golden.Compare(actual, perChannelTolerance); + + if (!diff.SizeMatches || diff.DiffFraction > maxDiffFraction) + { + string outDir = FailureDir(); + actual.SavePng(Path.Combine(outDir, name + ".actual.png")); + golden.SavePng(Path.Combine(outDir, name + ".golden.png")); + Assert.Fail( + $"'{name}' differs from golden: sizeMatches={diff.SizeMatches}, " + + $"diffPixels={diff.DiffPixels}/{diff.TotalPixels} ({diff.DiffFraction:P3}), " + + $"maxChannelDiff={diff.MaxChannelDiff}. Wrote actual/golden PNGs to {outDir}. " + + $"If this change is intended, regenerate with UPDATE_GOLDENS=1."); + } + } + + public static string GoldensDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "Goldens"); + + private static string FailureDir() + { + string dir = Path.Combine(AppContext.BaseDirectory, "TestFailures"); + Directory.CreateDirectory(dir); + return dir; + } +} diff --git a/tests/NImpeller.Tests/Goldens/.gitkeep b/tests/NImpeller.Tests/Goldens/.gitkeep new file mode 100644 index 0000000..5c22b65 --- /dev/null +++ b/tests/NImpeller.Tests/Goldens/.gitkeep @@ -0,0 +1,2 @@ +# Golden baselines (.rimg + .png) live here and ARE committed. +# Generate/refresh with: UPDATE_GOLDENS=1 dotnet test diff --git a/tests/NImpeller.Tests/Goldens/blendmodes.png b/tests/NImpeller.Tests/Goldens/blendmodes.png new file mode 100644 index 0000000000000000000000000000000000000000..029d5b0a55d27fde41972ca36d677f9ee7e9f298 GIT binary patch literal 12217 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/blendmodes.rimg b/tests/NImpeller.Tests/Goldens/blendmodes.rimg new file mode 100644 index 0000000000000000000000000000000000000000..69b265db969aeec717aa6c3b0c447c838c0eb3ca GIT binary patch literal 11797 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/clips.rimg b/tests/NImpeller.Tests/Goldens/clips.rimg new file mode 100644 index 0000000000000000000000000000000000000000..5d9ccea0fa3af66804377f4784de31d15a503e42 GIT binary patch literal 1965 zcmWIY^mTV&WMI$%;04U+v+_V+XFY0|42OqzV84 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/colorfilters.png b/tests/NImpeller.Tests/Goldens/colorfilters.png new file mode 100644 index 0000000000000000000000000000000000000000..d2a54bd448b094eb8fedf8d8185e29acf128b725 GIT binary patch literal 1468 zcmc(fe@q)?7{}j^GP*8XJE1cgT7in#n~{-dGQheXw3K#RIm!W!B3ov3876Kpo05)p z&5;St*=3tG;v#e%vg=if=$vjqi_Tg!lX3X4A1c%n;4{!8&7V4S#V)=)u*l3i{J4x{Rrbhu&Zsm&cuxPeR#Vzk6|lv03TV`gga_Lsnw|44;efYy!VdP?$5}_dXG=6sx_CI6c#Q14pm4o4GT}Uk z#*rlW{P+Ik^m=58EGQYbk+D74!Dm0oZWMaH=SCq~ zOD8JHPMF5ZIp~FH_ zd`|!(57UW9$<9f|oLN3*i6$;R3>WCVXwQ{hnXVUM!{-XQ#qc>VRPbBYL=9Le_Sq3$ z_SA%X=>U`JdmC2ww#s_FJDJTE@q4z&yuWVsLw?JQ@|se|D|`b2S1ZOWJXFO<#jAYq zkgvvF#-@4cVsxQsVUH4~@NI);{t@WR96K~px*nPPMuf}_dDn0~zQ@P)Eb*DE=alT_ Qo7`U+7<49Wux>B$H>(<7zyJUM literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/colorfilters.rimg b/tests/NImpeller.Tests/Goldens/colorfilters.rimg new file mode 100644 index 0000000000000000000000000000000000000000..34dd135d2948bce3b1b996c230adf19bd2933f27 GIT binary patch literal 1340 zcmb7EZ%h+s81EAG8+=lXGne^cQJZWoivx$Xq7rNb>D3;BN)U+=7ba}ckTWl~Zn{7M z)49yxLT9xWxZYL7e~q!_iiSiIoks9VVo2}82avu7UWh$!yIQ;R-s{2#m(2ImllOVv z-}8Ha&+mPmJIZYpADK*1lgTtA-1wESEnT$rOx~)sqd5imn^LAO@?;mekuM@8q*+_FqM{i+P27vDlleK9{Yej?=AnCex}K6jw+YW{B$cgH9>zo2T3QOMz=xl{&DIu~4tJRRW2whjP zTA}2A+1@GWY5~v>)|BZtFaDgb<}WP6ZM|Zj_^3S#Uxn(oVt7Ug(zh;S_|XkQip4Sk zwG|YFa5*VC5L2m(-jJ|BB4G}JHlUsbHc;9p*2PUaiT{<@e)qmQHR7~R zerhct<@4*4ORX{L$x|v>K^7L1?->59F`V)DXbk1tmnoZ8L_uOq`9&ke2$rkp6YYSV zr2{Mq(A|sx3Ha~zyWX!4Oss@y0jpom*YcjlS@rwH$Jka53RR***l+=c4k|aVVD*KT zm}M{aHcasF;%9ic`LbQ!Jcftc1ulW*9T?uqlYB=BhVK=7#32q(i$c55fxURR8h7br zjWOs2F`I()pkV-Z94b+R4jX}Vbcb=)0~Nm9h+81d?=s?Qs0$z`eA$<6rQ0 zZimb7u+mFxzKJ!$nQ%$k{sq2K5jz{>)SCU+$nJgdYx#1^0H>?)W?l1egPbu){}qSV z64M_1j!WZ(gsV&^u_qW|{NDwPuYwRpjIW{t7Boz|aCvj{`ut?BuFphc^0y478#uac OgZBM>@cMyF<@^KpE7%?Y literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/groupopacity.png b/tests/NImpeller.Tests/Goldens/groupopacity.png new file mode 100644 index 0000000000000000000000000000000000000000..e0f9bbdafb601a04be2cab881c25ab09e625bad0 GIT binary patch literal 1532 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI(77jL`$fPDtJq8BW!=5gVAr*{ouWU4BU=VO| zOs#M-707V%7OK76bl~Rp?530YH-7W?`=5_z;FY_?&hTeS0;4Gl_X@=b2i}8BSsY>k z&Knw}npm$0=qzZXg4B_wuOEN0?brJoa7RdI*%JA#=%%AnzBm_pZt~Mv=`aV(yjTj{n4}pL)B4Dm;qt>?&)9Qy15C85x^*7>6U70O$c<} zAfN+`x+YiNgH9WOQOO|&jM9UP+C~)({vddKpS64cL^-qBLDgbw3STB}*4&zYP-^<9 kz+emy8WfNiCGI}P+$$DoKkJPnfTcQvr>mdKI;Vst00;<800000 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/groupopacity.rimg b/tests/NImpeller.Tests/Goldens/groupopacity.rimg new file mode 100644 index 0000000000000000000000000000000000000000..23b659c3cc60d9008e21592498ce3a807dbf097b GIT binary patch literal 1609 zcmWIY^mW%^WME(c;sr-sQ=~Pe+v}mzvSz-f+AzofYC4*_UYi@ RXc0r@0+(Q^&*1Q$9{?UOpy2=j literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/imagefilters.png b/tests/NImpeller.Tests/Goldens/imagefilters.png new file mode 100644 index 0000000000000000000000000000000000000000..0ce261a8965a205bbabb38e9010c01a119f2e028 GIT binary patch literal 4189 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/imagefilters.rimg b/tests/NImpeller.Tests/Goldens/imagefilters.rimg new file mode 100644 index 0000000000000000000000000000000000000000..046a80a653e653f1667cfcab926813aeb813e01d GIT binary patch literal 3841 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 Y#g?R$Yux?8E`=5)h>iRqeA8Ed0dyMWVgLXD literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/primitives.rimg b/tests/NImpeller.Tests/Goldens/primitives.rimg new file mode 100644 index 0000000000000000000000000000000000000000..aace41b5e57e73d13c93f12a7ac211e718aea813 GIT binary patch literal 1916 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 CEAK}D literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/shadows.rimg b/tests/NImpeller.Tests/Goldens/shadows.rimg new file mode 100644 index 0000000000000000000000000000000000000000..01c0223ff17673d254fe287bf4488c7f66a5647c GIT binary patch literal 5791 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/solidshapes.png b/tests/NImpeller.Tests/Goldens/solidshapes.png new file mode 100644 index 0000000000000000000000000000000000000000..54295e02550d029416854f3447627e7cc5336fd9 GIT binary patch literal 3519 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/solidshapes.rimg b/tests/NImpeller.Tests/Goldens/solidshapes.rimg new file mode 100644 index 0000000000000000000000000000000000000000..60df06dccc5bb4f5100fafbbf6a9344459d8f029 GIT binary patch literal 3534 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 literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/strokes.png b/tests/NImpeller.Tests/Goldens/strokes.png new file mode 100644 index 0000000000000000000000000000000000000000..999fcc6009191d71e512981d39d76f317d839cfd GIT binary patch literal 3476 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 E3*Z+HPXGV_ literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/strokes.rimg b/tests/NImpeller.Tests/Goldens/strokes.rimg new file mode 100644 index 0000000000000000000000000000000000000000..6a7cdd0a82d3424fca5d501314efc01dec1fca4c GIT binary patch literal 2551 zcmeIzeM}Q~7zc38jAmi*2S$x)!1>AuB4Hg|ELvmCWV{rV#x+=hDNsPrARD z^v*iIwoNrz!=|XpX0Vw7<6*#CuxJ*>X;0oRW`K=SuyQ6`bZNXztz~L#lU>G3$c&?^ z(F9M-E$(Lq#P1PXxUlq&h=%fa!_M*HbJ~rv!n<{*h&rQF9)V@H&^IJ<28T~W$Z)%k zX>EeNc3aIirdfU*!@V;awcM_*Y@4;2=b84>l#E=ob@8}u4x6;SP_)3_6+l{ZxnSSQ zirE0OVtZKw*u5#6dr8Mj(Y6F@ORkN*I-;!@O{JHS$Y3vLt~}xIV2|!Yh!e zvE#MY`KITimF(==EK-{@oYng4$yB-5dVm$(P>s?Occdd%CKHBeiA6ake$2&e0W{HE z%8R+U(wYRJ;xHcOX5>LA`aBE$R0g1@b_yNVvPJ+k+^ctXNb~yzjXo5%p-O=@31|BS zb^a7PA~#|6rf-uthPxLuOdxyYwkl?I_C^)7uQFv3qy*KtVJdaOiM+mh)HmV7Fgzf-Ab^*4NP~ zfxMSOuB%(&3uYGgCqDGf@ukExpT4M4mS}92A-xQt+*g&E)CnVsV&wIop#6jq9tRnE z$%I?E22RQVsP|K+(3Xdw%+e9SEliI=C%Nt5glawe5jY~L0LZh#dL|hTb+hpDqW}x- zsSEdXqi2!u2GVMF2eY2U-E8hF{7ah(#o!$bJ+M3vYGek$mpv@G13U`FaW~KQ#Tv&u z@CiRT8?GZ1z2pyoEMo`3`3@RwjOUa zZ34)m#Y04uvdm4C6i)TUq9S~6dI{=g$?cE8zW2=;f*=`DqwHpy((v)m4jG~Yh*@~)DIJ11qYpk&|qlh}vCAFC+ zjbpy-L9boTc=o(Y8fV|?rm7wYJ=Wk9(OXw#)Zx~wPAmn@E4UaWQ|3w$j-v`P2`4QjkH|Y)GMY8*loKEG& z_D8*>FFWt;No1$DT*;@eO>7iiQ)30196{~Gc&V{KGw3gE9WP%J(prA-dPCPX=?SII zJcBUuaL){z4)rbvB36i)e1JJ2%iQf8(?0M9gDm?vWmN>*M=M zX6{#+_Exv2re)0Db`^Ge!sD^H7s~m$9=(*^c1<-u$C9W3;x|BOa%{^%Af)D;hR|dW zr=gc<(9}%_p{X^o-NzdkRX7Mu{3zF2lT)v75Sn-g|T9$7_ z^pdA(50znyDjA}WzV&o&T#{hRyga~I@^l7m)G@%4@ApZ6)V%Y=u}msBT%0PdsoJWF zb(Sny5oSc-R^V=Mi`}Rgu~OjaYCrm-W(|=jYW=eyrfUa;? zxEkkT4F3m&DpiH4kV&Na8v$9@(nQF^t;!uo2zl*7y_N|2ptM4BQbK5nZ>un2bwt>o zs4N+T4mz9VvY81m3pSit2sfMcPT9W%#q$H+(E5;)_wSTb9}k|72hacK;PHN+{QrK# HeZA*zsw#l# literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/textureinterop.png b/tests/NImpeller.Tests/Goldens/textureinterop.png new file mode 100644 index 0000000000000000000000000000000000000000..8a46b5968553d60795fa2f8e89b5af7a895e03e3 GIT binary patch literal 1440 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX9-C$wJ+|*$fP<>7Fi*Ar*{ouUyPjaS(7m z*qieC{H5uKIC+Y)UpfBeXI^{$`|rPu6&IKPWoQs%=CC=Y^Sd4>(MR@DaFNn$V!{k>qv zQHHEl-y93O8Lx!AZGI#+s&O=M21CmE^OG^BUSPY2C))(gph=7=T~ksRW-6&lIxHI% q8U`Wp_a`IEjzbg{chI78O}&h5(&p?N9p1o_m%-E3&t;ucLK6UtXDB)V literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/textureinterop.rimg b/tests/NImpeller.Tests/Goldens/textureinterop.rimg new file mode 100644 index 0000000000000000000000000000000000000000..00ed51d05886cd8c58646aeb95d9a562437db5ea GIT binary patch literal 1724 zcmWIY^mTV&WMKHfz`*eK%E3+<2LYCYTT}ji530KN`AWb9sg9O{1q)yQ{`>DI6Hp0O z@SpYNrRSd;>%-p9{(E`TulW_;d-K2fpMF*UNcZc$oA$b^|95V`@;mMSmNoy>?*)x2 q8V!QcR6xIs@NM=lyFISELV)=$*>jW{4T2$+3Z${tI6%P}H?%16_>gVE5W(f!>%pCfS=1)d>7Q^jw8F}7R)hUl*k z4Z9k>k-+LM`$XZ`@402Xvm@)Yi}%yGEx_?ZUy!L1%%SZc)^@E1o)mISnOze>VL2ML zJi#BDbTY_E8Spe8YCg228X9ut+DsCWfspEoXwKw%Tb(jc3w;K&98?_ZV_^2w^;ft* z;Ff^d3xp*OOn^K~CixGZU)?+kLrL_UC1+r$)2l5R*Tb*HiNt7Ly;^MWhG{=?ga!*H z*SOp_4GxYBR95{fQ)#?2Dk0Rmq9tJWe4*B{_J+X&Cis-(yZ@cY?^-+z+HU$MXk2Yq z2)pA)%)z=KQ#M=1E`879wERtQ4=zdIOF!D~Ps2V#TFV>R^LRG_4PbqSTAV)r5iWS7 z!(Hp&d_ZAIY!Xju$eVKJ8^?70V9Gh*tb7$Y{dn^=A86#}G$MQ=q0wRJB7hwQU9cqF zMYjPq;MGZ?g@fl#7nv;#;T8Erxekb$o20Z+dj~|wEsAk*3rTx0=e#JZz#PkTdQt7L zZ6(5>p+g0@Z2{xyO|{p6n`_7{JNG5e7OwA^OB*Y|fh1C8=QjG&jVJgG;Fg7Fo8uTP z6?HU%pt*DoAL%F=>HHHLbVcz3FY~NYQC|$;V1n0TdZnGa9&JYujT`c;1l%&gJG65j zdziFu37VxVo?rdiYE^`szIl>d( z`VdJXtL;s8Jh!sGMA-WLa21wQqLwApc2&BuW;z}-OBKnY%6UwM$V=QxwKRe)&CYl-`WNU9b0%}%sd)1D&snOu5UZQFn-q%XdFW>>J%MW=ZpzKS+ z*5ogR;HHxMBG_SqW`E2RR>L{VbVqt4nLS}E^CeK-P|g3opBXbA0k6)hx9qFwIh#~O{Kn##Cv<^-fd!>&+P=to#CqZHR@q8eBFT}CP8*FD!uqGiwx)w literal 0 HcmV?d00001 diff --git a/tests/NImpeller.Tests/Goldens/transforms.rimg b/tests/NImpeller.Tests/Goldens/transforms.rimg new file mode 100644 index 0000000000000000000000000000000000000000..7d8b7fff6de4ff3349f2dbce5e87fed315d798f9 GIT binary patch literal 1462 zcmbVLeN0P+BVc)A+OGmrDdLNpI`qzAUFZZ3_ zIrp63Irn$-a)taXDuwbU_&oN>(ZjN&_wFYpB$s_AJVDF8utv+?cc}5o%J<$mSmg;R zj+O7oI5@^^c<%m@NqM1%?hSPwhesBpT@{gSaZF?8jxtn}Fx!IrficQmsSUd&nRmGL^6(P&TK2L~<~K+H!mZw&Th# z%?t+;LYe+h&oC%uPucqGx7NP^`n=pv_}QNj!n_%SsPoIM^<aUfC-EQ5Hb+Fdz`_PcOxdHL0!HZ8v--6 zW$z}8!Pj>aN|Er1VoN`m4`)aND7gm+H#2BxdI$#FPe)71ZtsEf%v0)i0VdGE9ZlA# zqK%+VnS~Ey?5N1il#J95!datO$2I`Vf-wR~KoHWNbF#wG70`x>F@#ubynzChIHbKv zP*nyE<$-B};A`>wTo>kJ%w~F`F;PT2XX9yGrzVKgTGcAgg_(dO9Q{J~XwO`79T;p%BK1CZUCCb>72E|gc|8wg{ zXzgBe165S!DdSG+72mM@eb#dH;=fHP9(VP;v!x0>)wpC|Uem89KASba?6B8IwHkXS zg_fh5S!K~o%x3;4{VTRf3?SF z6k1Zfbr7HIW&X6YEfMLVj!OfN$yPr^gc-S(m%Im|qTNY|ot _queue = new(); + private readonly Thread _thread; + + private Sdl _sdl = null!; + private GL _gl = null!; + private Window* _window; + private void* _glContext; + private ImpellerContext _impeller = null!; + + public bool Available { get; private set; } + + public string? Error { get; private set; } + + public string? RendererName { get; private set; } + + public bool IsSoftwareRenderer + { + get + { + var r = RendererName?.ToLowerInvariant() ?? ""; + return r.Contains("llvmpipe") || r.Contains("softpipe") || r.Contains("swrast") || r.Contains("software"); + } + } + + public HeadlessImpellerContext() + { + _thread = new Thread(ThreadMain) { IsBackground = true, Name = "Impeller-GL" }; + using var ready = new ManualResetEventSlim(false); + _thread.Start(ready); + ready.Wait(); + } + + private void ThreadMain(object? arg) + { + var ready = (ManualResetEventSlim)arg!; + try + { + InitializeGL(); + Available = true; + } + catch (Exception ex) + { + Error = ex.Message; + Available = false; + } + finally + { + ready.Set(); + } + + if (!Available) + { + return; + } + + foreach (var action in _queue.GetConsumingEnumerable()) + { + action(); + } + + TeardownGL(); + } + + private void InitializeGL() + { + _sdl = Sdl.GetApi(); + if (_sdl.Init(Sdl.InitVideo) < 0) + { + throw new InvalidOperationException($"SDL_Init(VIDEO) failed: {_sdl.GetErrorS()}"); + } + + // GLES 3.0. + _sdl.GLSetAttribute(GLattr.ContextMajorVersion, 3); + _sdl.GLSetAttribute(GLattr.ContextMinorVersion, 0); + _sdl.GLSetAttribute(GLattr.ContextProfileMask, (int)GLprofile.ES); + + _window = _sdl.CreateWindow( + "NImpeller.Tests", + Sdl.WindowposUndefined, + Sdl.WindowposUndefined, + 16, + 16, + (uint)(WindowFlags.Hidden | WindowFlags.Opengl)); + if (_window == null) + { + throw new InvalidOperationException($"SDL_CreateWindow failed: {_sdl.GetErrorS()}"); + } + + _glContext = _sdl.GLCreateContext(_window); + if (_glContext == null) + { + throw new InvalidOperationException($"SDL_GL_CreateContext failed: {_sdl.GetErrorS()}"); + } + + _sdl.GLMakeCurrent(_window, _glContext); + _gl = GL.GetApi(new LamdaNativeContext(s => (IntPtr)_sdl.GLGetProcAddress(s))); + RendererName = _gl.GetStringS(StringName.Renderer); + + _impeller = ImpellerContext.CreateOpenGLESNew(name => (IntPtr)_sdl.GLGetProcAddress(name)) + ?? throw new InvalidOperationException("ImpellerContext.CreateOpenGLESNew returned null"); + } + + private void TeardownGL() + { + try { _impeller?.Dispose(); } catch { /* best effort */ } + if (_glContext != null) { _sdl.GLDeleteContext(_glContext); } + if (_window != null) { _sdl.DestroyWindow(_window); } + _sdl?.Quit(); + } + + public byte[] Render(IScene scene, int width, int height) => + Invoke(() => RenderToFbo(width, height, + builder => scene.Render(_impeller, builder, new SceneParameters { Width = width, Height = height }))); + + public TextureInteropResult RenderTextureInterop(int width, int height) => + Invoke(() => RenderTextureInteropCore(width, height)); + + private byte[] RenderToFbo(int width, int height, Action record) + { + uint fbo = _gl.GenFramebuffer(); + uint tex = _gl.GenTexture(); + try + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, fbo); + _gl.BindTexture(TextureTarget.Texture2D, tex); + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, null); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); + _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, tex, 0); + + var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + if (status != GLEnum.FramebufferComplete) + { + throw new InvalidOperationException($"Offscreen framebuffer incomplete: {status}"); + } + + using var surface = _impeller.SurfaceCreateWrappedFBONew((ulong)fbo, + ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, new ImpellerISize(width, height)) + ?? throw new InvalidOperationException("SurfaceCreateWrappedFBONew returned null"); + + ImpellerDisplayList displayList; + using (var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, width, height))!) + { + record(builder); + displayList = builder.CreateDisplayListNew()!; + } + + using (displayList) + { + surface.DrawDisplayList(displayList); + } + + // Make sure Impeller's GL commands have completed before we read the texels back. + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, fbo); + _gl.Finish(); + + var pixels = new byte[width * height * 4]; + fixed (byte* p = pixels) + { + _gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + + return FlipVertically(pixels, width, height); + } + finally + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + _gl.DeleteFramebuffer(fbo); + _gl.DeleteTexture(tex); + } + } + + private TextureInteropResult RenderTextureInteropCore(int width, int height) + { + byte[] checker = + { + 255, 255, 255, 255, 0, 0, 0, 255, + 0, 0, 0, 255, 255, 255, 255, 255, + }; + + uint glTex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, glTex); + fixed (byte* p = checker) + { + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); + + var descriptor = new ImpellerTextureDescriptor + { + Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, + Size = new ImpellerISize(2, 2), + Mip_count = 1, + }; + + using var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) + ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null"); + + ulong reportedHandle = texture.GetOpenGLHandle(); + + var pixels = RenderToFbo(width, height, builder => + { + using var background = ImpellerPaint.New()!; + background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); + builder.DrawPaint(background); + + using var paint = ImpellerPaint.New()!; + builder.DrawTextureRect( + texture, + new ImpellerRect(0, 0, 2, 2), + new ImpellerRect(20, 20, width - 40, height - 40), + ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, + paint); + }); + + return new TextureInteropResult(pixels, glTex, reportedHandle); + } + + // glReadPixels returns rows bottom-to-top; images are top-to-bottom. + private static byte[] FlipVertically(byte[] src, int width, int height) + { + int stride = width * 4; + var dst = new byte[src.Length]; + for (int y = 0; y < height; y++) + { + Array.Copy(src, (height - 1 - y) * stride, dst, y * stride, stride); + } + return dst; + } + + // Avoid hangs by enforcing a timeout. If it doesn't complete, bail and fail. + private static readonly TimeSpan InvokeTimeout = TimeSpan.FromSeconds(30); + + private T Invoke(Func fn, [System.Runtime.CompilerServices.CallerMemberName] string op = "") + { + if (!Available) + { + throw new InvalidOperationException(Error ?? "Headless GL context is unavailable."); + } + + Exception? error = null; + T result = default!; + var done = new ManualResetEventSlim(false); + _queue.Add(() => + { + try { result = fn(); } + catch (Exception ex) { error = ex; } + finally { done.Set(); } + }); + + if (!done.Wait(InvokeTimeout)) + { + throw new TimeoutException( + $"Headless GL operation '{op}' did not complete within {InvokeTimeout.TotalSeconds:0}s " + + "(a native call likely deadlocked)."); + } + + if (error != null) + { + throw new InvalidOperationException("Headless render failed: " + error.Message, error); + } + return result; + } + + public void Dispose() + { + _queue.CompleteAdding(); + if (_thread.IsAlive) + { + _thread.Join(TimeSpan.FromSeconds(10)); + } + _queue.Dispose(); + } +} diff --git a/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs b/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs new file mode 100644 index 0000000..356e12b --- /dev/null +++ b/tests/NImpeller.Tests/Headless/ImpellerGLFixture.cs @@ -0,0 +1,36 @@ +using NImpeller.Tests.Scenes; +using Xunit; + +namespace NImpeller.Tests.Headless; + +public sealed class ImpellerGLFixture : IDisposable +{ + private readonly HeadlessImpellerContext _context = new(); + + public bool Available => _context.Available; + + public string? Error => _context.Error; + + public string? RendererName => _context.RendererName; + + public bool IsSoftwareRenderer => _context.IsSoftwareRenderer; + + public RawImage Render(IScene scene, int width, int height) => + new(width, height, _context.Render(scene, width, height)); + + public (RawImage Image, ulong CreatedHandle, ulong ReportedHandle) RenderTextureInterop(int width, int height) + { + var result = _context.RenderTextureInterop(width, height); + return (new RawImage(width, height, result.Pixels), result.CreatedHandle, result.ReportedHandle); + } + + public void Dispose() => _context.Dispose(); +} + +internal readonly record struct TextureInteropResult(byte[] Pixels, ulong CreatedHandle, ulong ReportedHandle); + +[CollectionDefinition(Name)] +public sealed class ImpellerGLCollection : ICollectionFixture +{ + public const string Name = "Impeller GL"; +} diff --git a/tests/NImpeller.Tests/Headless/RawImage.cs b/tests/NImpeller.Tests/Headless/RawImage.cs new file mode 100644 index 0000000..6b4f9ee --- /dev/null +++ b/tests/NImpeller.Tests/Headless/RawImage.cs @@ -0,0 +1,215 @@ +using System.IO.Compression; + +namespace NImpeller.Tests.Headless; + +/// +/// Clankered Image format for storing RGBA pixel data in a compact, lossless way. Used for baseline images in headless tests. +/// Also outputs to PNG. +/// +public sealed class RawImage +{ + private const uint RawMagic = 0x474D4952; // "RIMG" + + public int Width { get; } + public int Height { get; } + + /// Row-major RGBA, 4 bytes/pixel, top-left origin. + public byte[] Pixels { get; } + + public RawImage(int width, int height, byte[] pixels) + { + if (pixels.Length != width * height * 4) + { + throw new ArgumentException($"Expected {width * height * 4} bytes, got {pixels.Length}."); + } + Width = width; + Height = height; + Pixels = pixels; + } + + /// True if any pixel is not fully transparent black — i.e. something was drawn. + public bool HasContent() + { + foreach (var b in Pixels) + { + if (b != 0) return true; + } + return false; + } + + public ImageDiff Compare(RawImage other, int perChannelTolerance) + { + if (Width != other.Width || Height != other.Height) + { + return new ImageDiff(false, long.MaxValue, 255, (long)Width * Height); + } + + long differing = 0; + int maxChannelDiff = 0; + for (int i = 0; i < Pixels.Length; i += 4) + { + bool pixelDiffers = false; + for (int c = 0; c < 4; c++) + { + int d = Math.Abs(Pixels[i + c] - other.Pixels[i + c]); + if (d > maxChannelDiff) maxChannelDiff = d; + if (d > perChannelTolerance) pixelDiffers = true; + } + if (pixelDiffers) differing++; + } + + return new ImageDiff(true, differing, maxChannelDiff, (long)Width * Height); + } + + public void SaveRaw(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + using var fs = File.Create(path); + using var w = new BinaryWriter(fs); + w.Write(RawMagic); + w.Write(Width); + w.Write(Height); + // Pixels are deflate-compressed so committed baselines stay small. + using var deflate = new DeflateStream(fs, CompressionLevel.Optimal); + deflate.Write(Pixels, 0, Pixels.Length); + } + + public static RawImage LoadRaw(string path) + { + using var fs = File.OpenRead(path); + using var r = new BinaryReader(fs); + if (r.ReadUInt32() != RawMagic) + { + throw new InvalidDataException($"{path} is not a RIMG file."); + } + int w = r.ReadInt32(); + int h = r.ReadInt32(); + var px = new byte[w * h * 4]; + using var deflate = new DeflateStream(fs, CompressionMode.Decompress); + int read = 0; + while (read < px.Length) + { + int n = deflate.Read(px, read, px.Length - read); + if (n == 0) break; + read += n; + } + if (read != px.Length) + { + throw new InvalidDataException($"{path}: expected {px.Length} pixel bytes, got {read}."); + } + return new RawImage(w, h, px); + } + + /// Write a standard 8-bit RGBA PNG. Used only for eyeballing results; never read back. + public void SavePng(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + using var fs = File.Create(path); + Span sig = stackalloc byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + fs.Write(sig); + + // IHDR + var ihdr = new byte[13]; + WriteBigEndian(ihdr, 0, (uint)Width); + WriteBigEndian(ihdr, 4, (uint)Height); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + WriteChunk(fs, "IHDR", ihdr); + + // IDAT: zlib-wrapped deflate of filtered scanlines (filter byte 0 per row). + int stride = Width * 4; + var raw = new byte[(stride + 1) * Height]; + for (int y = 0; y < Height; y++) + { + raw[y * (stride + 1)] = 0; // no filter + Array.Copy(Pixels, y * stride, raw, y * (stride + 1) + 1, stride); + } + WriteChunk(fs, "IDAT", ZlibCompress(raw)); + + WriteChunk(fs, "IEND", Array.Empty()); + } + + private static byte[] ZlibCompress(byte[] data) + { + using var ms = new MemoryStream(); + ms.WriteByte(0x78); + ms.WriteByte(0x01); // zlib header: deflate, 32K window, no dict + using (var deflate = new DeflateStream(ms, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(data, 0, data.Length); + } + var adler = Adler32(data); + ms.WriteByte((byte)(adler >> 24)); + ms.WriteByte((byte)(adler >> 16)); + ms.WriteByte((byte)(adler >> 8)); + ms.WriteByte((byte)adler); + return ms.ToArray(); + } + + private static void WriteChunk(Stream s, string type, byte[] data) + { + Span len = stackalloc byte[4]; + WriteBigEndian(len, 0, (uint)data.Length); + s.Write(len); + + var typeBytes = new byte[4]; + for (int i = 0; i < 4; i++) typeBytes[i] = (byte)type[i]; + s.Write(typeBytes); + s.Write(data); + + uint crc = Crc32(typeBytes, data); + Span crcBytes = stackalloc byte[4]; + WriteBigEndian(crcBytes, 0, crc); + s.Write(crcBytes); + } + + private static void WriteBigEndian(Span dst, int offset, uint value) + { + dst[offset + 0] = (byte)(value >> 24); + dst[offset + 1] = (byte)(value >> 16); + dst[offset + 2] = (byte)(value >> 8); + dst[offset + 3] = (byte)value; + } + + private static uint Adler32(byte[] data) + { + const uint mod = 65521; + uint a = 1, b = 0; + foreach (var x in data) + { + a = (a + x) % mod; + b = (b + a) % mod; + } + return (b << 16) | a; + } + + private static uint Crc32(byte[] type, byte[] data) + { + uint crc = 0xFFFFFFFF; + crc = Crc32Update(crc, type); + crc = Crc32Update(crc, data); + return crc ^ 0xFFFFFFFF; + } + + private static uint Crc32Update(uint crc, byte[] data) + { + foreach (var b in data) + { + crc ^= b; + for (int k = 0; k < 8; k++) + { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1; + } + } + return crc; + } +} + +/// False when dimensions differ; other fields are meaningless then. +/// Pixels whose per-channel delta exceeded the tolerance. +/// Largest single-channel delta seen (0-255). +/// Total pixel count. +public readonly record struct ImageDiff(bool SizeMatches, long DiffPixels, int MaxChannelDiff, long TotalPixels) +{ + public double DiffFraction => TotalPixels == 0 ? 0 : (double)DiffPixels / TotalPixels; +} diff --git a/tests/NImpeller.Tests/Impeller.targets b/tests/NImpeller.Tests/Impeller.targets new file mode 100644 index 0000000..0e0fe9a --- /dev/null +++ b/tests/NImpeller.Tests/Impeller.targets @@ -0,0 +1,87 @@ + + + WINDOWS + LINUX + MACOS + $([MSBuild]::IsOSPlatform('Windows')) + $([MSBuild]::IsOSPlatform('Linux')) + $([MSBuild]::IsOSPlatform('OSX')) + $(MSBuildThisFileDirectory)..\..\external\impeller_sdk + false + true + false + true + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + impeller.dll + + + + + + true + true + PreserveNewest + libimpeller.dylib + + + + + + true + true + PreserveNewest + libimpeller.dylib + + + + + + true + true + PreserveNewest + libimpeller.so + + + + + + true + true + PreserveNewest + libimpeller.so + + + \ No newline at end of file diff --git a/tests/NImpeller.Tests/NImpeller.Tests.csproj b/tests/NImpeller.Tests/NImpeller.Tests.csproj new file mode 100644 index 0000000..396af92 --- /dev/null +++ b/tests/NImpeller.Tests/NImpeller.Tests.csproj @@ -0,0 +1,46 @@ + + + + + + + net8.0 + enable + enable + true + NImpeller.Tests + + + Exe + false + true + + + $(MSBuildThisFileDirectory)test.runsettings + + + + + + + + + + + + + + + + + + + + + + Never + + + + diff --git a/tests/NImpeller.Tests/Scenes/GoldenScenes.cs b/tests/NImpeller.Tests/Scenes/GoldenScenes.cs new file mode 100644 index 0000000..bef7397 --- /dev/null +++ b/tests/NImpeller.Tests/Scenes/GoldenScenes.cs @@ -0,0 +1,443 @@ +using System.Numerics; +using NImpeller; + +namespace NImpeller.Tests.Scenes; + +internal static class Draw +{ + public static ImpellerPoint P(float x, float y) => new() { X = x, Y = y }; + + // ImpellerRect's only ctor takes ints; this builds one from floats for computed coordinates. + // TODO: ImpellerRect should have a float ctor. + public static ImpellerRect Rect(float x, float y, float w, float h) => + new() { X = x, Y = y, Width = w, Height = h }; + + public static ImpellerRoundingRadii UniformRadii(float r) + { + var p = P(r, r); + return new ImpellerRoundingRadii { Top_left = p, Top_right = p, Bottom_left = p, Bottom_right = p }; + } + + public static void Background(ImpellerDisplayListBuilder b, ImpellerColor color) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(color); + b.DrawPaint(paint); + } +} + +/// Every primitive draw op, filled and stroked. Mirrors aiks_dl primitive shape tests. +public sealed class PrimitivesScene : IScene +{ + public string TestName => "primitives"; + public string Description => "All primitive draw ops, filled and stroked."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(18, 20, 28)); + using var paint = ImpellerPaint.New()!; + + paint.SetColor(ImpellerColor.FromRgb(220, 70, 70)); + b.DrawRect(new ImpellerRect(20, 20, 90, 60), paint); + + paint.SetColor(ImpellerColor.FromRgb(70, 200, 120)); + b.DrawOval(new ImpellerRect(130, 20, 90, 60), paint); + + paint.SetColor(ImpellerColor.FromRgb(80, 140, 240)); + b.DrawRoundedRect(new ImpellerRect(240, 20, 100, 60), Draw.UniformRadii(16), paint); + + paint.SetColor(ImpellerColor.FromRgb(240, 200, 70)); + b.DrawRoundedRectDifference( + new ImpellerRect(20, 100, 100, 80), Draw.UniformRadii(18), + new ImpellerRect(40, 120, 60, 40), Draw.UniformRadii(8), + paint); + + // Stroked line + dashed line. + paint.SetColor(ImpellerColor.FromRgb(230, 230, 230)); + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleStroke); + paint.SetStrokeWidth(5); + b.DrawLine(Draw.P(150, 110), Draw.P(330, 110), paint); + b.DrawDashedLine(Draw.P(150, 140), Draw.P(330, 140), 14, 8, paint); + + // Filled path (triangle). + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleFill); + paint.SetColor(ImpellerColor.FromRgb(200, 120, 230)); + using var pb = ImpellerPathBuilder.New()!; + pb.MoveTo(Draw.P(180, 250)); + pb.LineTo(Draw.P(240, 180)); + pb.LineTo(Draw.P(300, 250)); + pb.Close(); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawPath(path, paint); + } +} + +/// Stroke caps × joins grid. Mirrors aiks_dl_path StrokeCapsAndJoins. +public sealed class StrokesScene : IScene +{ + public string Name => "Strokes"; + public string TestName => "strokes"; + public string Description => "Stroke caps and joins across a grid."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(22, 24, 30)); + + var caps = new[] + { + ImpellerStrokeCap.kImpellerStrokeCapButt, + ImpellerStrokeCap.kImpellerStrokeCapRound, + ImpellerStrokeCap.kImpellerStrokeCapSquare, + }; + var joins = new[] + { + ImpellerStrokeJoin.kImpellerStrokeJoinMiter, + ImpellerStrokeJoin.kImpellerStrokeJoinRound, + ImpellerStrokeJoin.kImpellerStrokeJoinBevel, + }; + + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(120, 200, 255)); + paint.SetDrawStyle(ImpellerDrawStyle.kImpellerDrawStyleStroke); + paint.SetStrokeWidth(12); + + for (int ci = 0; ci < caps.Length; ci++) + { + for (int ji = 0; ji < joins.Length; ji++) + { + paint.SetStrokeCap(caps[ci]); + paint.SetStrokeJoin(joins[ji]); + + float ox = 30 + ci * 110; + float oy = 30 + ji * 90; + + // An open "V" shows both caps (the open ends) and a join (the corner). + using var pb = ImpellerPathBuilder.New()!; + pb.MoveTo(Draw.P(ox, oy)); + pb.LineTo(Draw.P(ox + 35, oy + 50)); + pb.LineTo(Draw.P(ox + 70, oy)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawPath(path, paint); + } + } + } +} + +/// Nested transforms (translate/scale/rotate/Transform) with save/restore. +public sealed class TransformsScene : IScene +{ + public string Name => "Transforms"; + public string TestName => "transforms"; + public string Description => "Translate/scale/rotate/matrix transforms with save/restore nesting."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(16, 18, 24)); + using var paint = ImpellerPaint.New()!; + + b.Save(); + b.Translate(90, 90); + b.Rotate(25); + paint.SetColor(ImpellerColor.FromRgb(220, 80, 80)); + b.DrawRect(new ImpellerRect(-40, -40, 80, 80), paint); + b.Restore(); + + b.Save(); + b.Translate(230, 90); + b.Scale(1.6f, 0.7f); + paint.SetColor(ImpellerColor.FromRgb(80, 210, 130)); + b.DrawRect(new ImpellerRect(-40, -40, 80, 80), paint); + b.Restore(); + + b.Save(); + b.SetTransform(Matrix4x4.CreateRotationZ(0.5f) * Matrix4x4.CreateTranslation(160, 220, 0)); + paint.SetColor(ImpellerColor.FromRgb(90, 150, 240)); + b.DrawOval(new ImpellerRect(-50, -30, 100, 60), paint); + b.Restore(); + } +} + +/// Clip ops (rect/oval/roundedRect/path), intersect vs difference, nested. +public sealed class ClipsScene : IScene +{ + public string Name => "Clips"; + public string TestName => "clips"; + public string Description => "Rect/oval/rounded/difference clips applied to a fill."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(20, 22, 28)); + using var fill = ImpellerPaint.New()!; + fill.SetColor(ImpellerColor.FromRgb(230, 150, 60)); + + b.Save(); + b.ClipRect(new ImpellerRect(20, 20, 130, 130), ImpellerClipOperation.kImpellerClipOperationIntersect); + b.DrawPaint(fill); + b.Restore(); + + b.Save(); + b.ClipOval(new ImpellerRect(170, 20, 130, 130), ImpellerClipOperation.kImpellerClipOperationIntersect); + fill.SetColor(ImpellerColor.FromRgb(90, 200, 210)); + b.DrawPaint(fill); + b.Restore(); + + // Difference clip: a rect with an oval punched out (a ring/frame). + b.Save(); + b.ClipRect(new ImpellerRect(20, 170, 130, 110), ImpellerClipOperation.kImpellerClipOperationIntersect); + b.ClipOval(new ImpellerRect(50, 190, 70, 70), ImpellerClipOperation.kImpellerClipOperationDifference); + fill.SetColor(ImpellerColor.FromRgb(200, 110, 220)); + b.DrawPaint(fill); + b.Restore(); + + b.Save(); + b.ClipRoundedRect(new ImpellerRect(170, 170, 130, 110), Draw.UniformRadii(28), + ImpellerClipOperation.kImpellerClipOperationIntersect); + fill.SetColor(ImpellerColor.FromRgb(150, 210, 90)); + b.DrawPaint(fill); + b.Restore(); + } +} + +// NOTE on offscreen-layer ops (group opacity, image filters, advanced blend modes): these render +// correctly, but on the NVIDIA proprietary driver the GL context SIGSEGVs at *teardown* after +// using them (a driver bug — the same code runs cleanly on Mesa/llvmpipe). The golden tests +// therefore gate on a software renderer (see GoldenImageTests / README), and run everything there. + +/// A grid of all 29 blend modes: a red oval drawn over a blue rect per mode. +public sealed class BlendModesScene : IScene +{ + public string Name => "Blend Modes"; + public string TestName => "blendmodes"; + public string Description => "All 29 blend modes in a grid (src oval over dst rect)."; + + private static readonly ImpellerBlendMode[] Modes = + (ImpellerBlendMode[])System.Enum.GetValues(typeof(ImpellerBlendMode)); + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(40, 40, 48)); + + using var dst = ImpellerPaint.New()!; + using var src = ImpellerPaint.New()!; + const int cols = 6, cell = 60; + + for (int i = 0; i < Modes.Length; i++) + { + int col = i % cols, row = i / cols; + float x = 6 + col * cell, y = 6 + row * cell; + + dst.SetColor(ImpellerColor.FromArgb(255, 60, 120, 220)); + dst.SetBlendMode(ImpellerBlendMode.kImpellerBlendModeSourceOver); + b.DrawRect(Draw.Rect(x, y, 40, 40), dst); + + src.SetColor(ImpellerColor.FromArgb(220, 230, 90, 60)); + src.SetBlendMode(Modes[i]); + b.DrawOval(Draw.Rect(x + 12, y + 12, 36, 36), src); + } + } +} + +/// Blend color filter and a color-matrix (invert) color filter. +public sealed class ColorFiltersScene : IScene +{ + public string Name => "Color Filters"; + public string TestName => "colorfilters"; + public string Description => "Blend color filter and an invert color-matrix filter."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(20, 22, 30)); + + using (var blend = ImpellerColorFilter.CreateBlendNew( + ImpellerColor.FromArgb(180, 0, 140, 255), ImpellerBlendMode.kImpellerBlendModeModulate)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(220, 200, 80)); + paint.SetColorFilter(blend); + b.DrawRect(new ImpellerRect(30, 40, 130, 110), paint); + } + + // Invert color matrix: negate RGB (bias +1), keep alpha. + var invert = new ImpellerColorMatrix(); + float[] values = + { + -1, 0, 0, 0, 1, + 0, -1, 0, 0, 1, + 0, 0, -1, 0, 1, + 0, 0, 0, 1, 0, + }; + unsafe + { + for (int i = 0; i < 20; i++) invert.m[i] = values[i]; + } + using (var matrixFilter = ImpellerColorFilter.CreateColorMatrixNew(invert)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(60, 200, 130)); + paint.SetColorFilter(matrixFilter); + b.DrawOval(new ImpellerRect(190, 40, 120, 110), paint); + } + } +} + +/// Mask-filter blur in all four blur styles. +public sealed class MaskBlurScene : IScene +{ + public string Name => "Mask Blur"; + public string TestName => "maskblur"; + public string Description => "Mask-filter blur in Normal/Solid/Outer/Inner styles."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(28, 28, 34)); + + var styles = new[] + { + ImpellerBlurStyle.kImpellerBlurStyleNormal, + ImpellerBlurStyle.kImpellerBlurStyleSolid, + ImpellerBlurStyle.kImpellerBlurStyleOuter, + ImpellerBlurStyle.kImpellerBlurStyleInner, + }; + + for (int i = 0; i < styles.Length; i++) + { + using var mask = ImpellerMaskFilter.CreateBlurNew(styles[i], 8)!; + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(240, 230, 120)); + paint.SetMaskFilter(mask); + float x = 40 + (i % 2) * 160; + float y = 40 + (i / 2) * 130; + b.DrawOval(Draw.Rect(x, y, 90, 90), paint); + } + } +} + +/// DrawShadow under an opaque occluder. Mirrors aiks_dl_shadow / CanRenderShadows. +public sealed class ShadowsScene : IScene +{ + public string Name => "Shadows"; + public string TestName => "shadows"; + public string Description => "Elevation shadows cast by an oval and a rounded rect."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(210, 212, 220)); + var shadowColor = ImpellerColor.FromArgb(160, 0, 0, 0); + + using (var pb = ImpellerPathBuilder.New()!) + { + pb.AddOval(new ImpellerRect(40, 40, 110, 110)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawShadow(path, shadowColor, 8f, 0, 1f); + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(220, 90, 90)); + b.DrawPath(path, paint); + } + + using (var pb = ImpellerPathBuilder.New()!) + { + pb.AddRoundedRect(new ImpellerRect(190, 50, 120, 90), Draw.UniformRadii(20)); + using var path = pb.TakePathNew(ImpellerFillType.kImpellerFillTypeNonZero)!; + b.DrawShadow(path, shadowColor, 14f, 0, 1f); + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 140, 230)); + b.DrawPath(path, paint); + } + } +} + +/// Group opacity: a nested display list composited at 50% alpha (needs an offscreen layer). +public sealed class GroupOpacityScene : IScene +{ + public string Name => "Group Opacity"; + public string TestName => "groupopacity"; + public string Description => "Two overlapping rects composited as a group at 50% opacity."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(24, 26, 34)); + + ImpellerDisplayList child; + using (var cb = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, p.Width, p.Height))!) + { + using var cp = ImpellerPaint.New()!; + cp.SetColor(ImpellerColor.FromRgb(230, 80, 80)); + cb.DrawRect(new ImpellerRect(30, 30, 150, 150), cp); + cp.SetColor(ImpellerColor.FromRgb(80, 130, 230)); + cb.DrawRect(new ImpellerRect(120, 80, 150, 150), cp); + child = cb.CreateDisplayListNew()!; + } + using (child) + { + b.DrawDisplayList(child, 0.5f); + } + } +} + +/// Image filters applied via paint: blur, dilate, erode, matrix (each needs an offscreen layer). +public sealed class ImageFiltersScene : IScene +{ + public string Name => "Image Filters"; + public string TestName => "imagefilters"; + public string Description => "Blur, dilate, erode, and matrix image filters on shapes."; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder b, SceneParameters p) + { + Draw.Background(b, ImpellerColor.FromRgb(18, 20, 26)); + + using (var blur = ImpellerImageFilter.CreateBlurNew(5, 5, ImpellerTileMode.kImpellerTileModeClamp)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(230, 90, 90)); + paint.SetImageFilter(blur); + b.DrawRect(new ImpellerRect(30, 30, 80, 80), paint); + } + + using (var dilate = ImpellerImageFilter.CreateDilateNew(4, 4)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 210, 120)); + paint.SetImageFilter(dilate); + b.DrawRect(new ImpellerRect(150, 30, 80, 80), paint); + } + + using (var erode = ImpellerImageFilter.CreateErodeNew(2, 2)!) + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(90, 150, 240)); + paint.SetImageFilter(erode); + b.DrawOval(new ImpellerRect(30, 140, 80, 80), paint); + } + } +} + +public sealed class SolidShapesScene : IScene +{ + public string TestName => "solidshapes"; + + public void Render(ImpellerContext context, ImpellerDisplayListBuilder scene, SceneParameters sceneParameters) + { + using var paint = ImpellerPaint.New()!; + + // Opaque background so every pixel is defined. + paint.SetColor(ImpellerColor.FromRgb(20, 24, 40)); + scene.DrawPaint(paint); + + paint.SetColor(ImpellerColor.FromRgb(220, 60, 60)); + scene.DrawRect(new ImpellerRect(40, 40, 120, 80), paint); + + paint.SetColor(ImpellerColor.FromRgb(60, 200, 120)); + scene.DrawOval(new ImpellerRect(180, 50, 110, 110), paint); + + paint.SetColor(ImpellerColor.FromRgb(240, 200, 60)); + scene.DrawRect(new ImpellerRect(40, 160, 90, 90), paint); + + // A rotated rect to exercise the transform stack deterministically. + paint.SetColor(ImpellerColor.FromRgb(90, 140, 240)); + scene.SetTransform( + Matrix4x4.CreateRotationZ(0.4f) * + Matrix4x4.CreateTranslation(220, 210, 0)); + scene.DrawRect(new ImpellerRect(-35, -35, 70, 70), paint); + } +} + diff --git a/tests/NImpeller.Tests/Scenes/IScene.cs b/tests/NImpeller.Tests/Scenes/IScene.cs new file mode 100644 index 0000000..e3853f2 --- /dev/null +++ b/tests/NImpeller.Tests/Scenes/IScene.cs @@ -0,0 +1,16 @@ +using NImpeller; + +namespace NImpeller.Tests.Scenes; + +public sealed class SceneParameters +{ + public int Width { get; set; } + public int Height { get; set; } +} + +public interface IScene +{ + string TestName { get; } + + void Render(ImpellerContext context, ImpellerDisplayListBuilder builder, SceneParameters parameters); +} diff --git a/tests/NImpeller.Tests/TextureInteropTests.cs b/tests/NImpeller.Tests/TextureInteropTests.cs new file mode 100644 index 0000000..c96f45e --- /dev/null +++ b/tests/NImpeller.Tests/TextureInteropTests.cs @@ -0,0 +1,38 @@ +using NImpeller.Tests.Headless; +using Xunit; + +namespace NImpeller.Tests; + +/// +/// GL-texture interop: create a real GL texture, adopt it into an ImpellerTexture, draw it, and +/// read the result back. Mirrors Flutter's interop CanCreateOpenGLImage (handle round-trip) and +/// CanDrawImage. This is the texture path that works today — unlike TextureCreateWithContentsNew, +/// which is blocked by the unimplemented ImpellerMapping marshaller (see BindingGapTests). +/// +[Collection(ImpellerGLCollection.Name)] +public sealed class TextureInteropTests +{ + private const int PerChannelTolerance = 8; + private const double MaxDiffFraction = 0.0001; + + private readonly ImpellerGLFixture _gl; + + public TextureInteropTests(ImpellerGLFixture gl) => _gl = gl; + + [Fact] + public void GL_texture_round_trips_and_draws() + { + Assert.SkipUnless(_gl.Available, + $"No headless GL context (run under xvfb-run or set SDL_VIDEODRIVER=offscreen). Reason: {_gl.Error}"); + Assert.True(_gl.IsSoftwareRenderer, + $"Expected a software renderer (llvmpipe) but got '{_gl.RendererName}'. Ensure test.runsettings is applied. See README."); + + var (image, createdHandle, reportedHandle) = _gl.RenderTextureInterop(320, 240); + + // GetOpenGLHandle must round-trip the adopted texture name. + Assert.Equal(createdHandle, reportedHandle); + Assert.True(image.HasContent(), "Texture draw produced an empty image."); + + GoldenAssert.Matches("textureinterop", image, PerChannelTolerance, MaxDiffFraction); + } +} diff --git a/tests/NImpeller.Tests/Unit/BuilderStateTests.cs b/tests/NImpeller.Tests/Unit/BuilderStateTests.cs new file mode 100644 index 0000000..a69807f --- /dev/null +++ b/tests/NImpeller.Tests/Unit/BuilderStateTests.cs @@ -0,0 +1,46 @@ +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class BuilderStateTests +{ + [Fact] + public void New_returns_a_builder() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100)); + Assert.NotNull(builder); + } + + [Fact] + public void Save_and_restore_adjust_the_save_count() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100))!; + + Assert.Equal(1u, builder.GetSaveCount()); // starts at 1 + + builder.Save(); + Assert.Equal(2u, builder.GetSaveCount()); + + builder.Save(); + Assert.Equal(3u, builder.GetSaveCount()); + + builder.Restore(); + Assert.Equal(2u, builder.GetSaveCount()); + + builder.RestoreToCount(1); + Assert.Equal(1u, builder.GetSaveCount()); + } + + [Fact] + public void CreateDisplayListNew_produces_a_display_list() + { + using var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 100, 100))!; + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(255, 255, 255)); + builder.DrawRect(new ImpellerRect(0, 0, 10, 10), paint); + + using var displayList = builder.CreateDisplayListNew(); + Assert.NotNull(displayList); + } +} diff --git a/tests/NImpeller.Tests/Unit/LifetimeTests.cs b/tests/NImpeller.Tests/Unit/LifetimeTests.cs new file mode 100644 index 0000000..b658824 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/LifetimeTests.cs @@ -0,0 +1,46 @@ +using System; +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +/// +/// Handle/dispose semantics of the wrapper objects. CPU-only. Conceptually mirrors Flutter's +/// object_unittests.cc (refcount/lifetime), adapted to the .NET SafeHandle/IDisposable model +/// this binding uses. +/// +public sealed class LifetimeTests +{ + [Fact] + public void Dispose_is_idempotent() + { + var paint = ImpellerPaint.New()!; + paint.Dispose(); + paint.Dispose(); // must not throw + } + + [Fact] + public void Builder_dispose_is_idempotent() + { + var builder = ImpellerDisplayListBuilder.New(new ImpellerRect(0, 0, 1, 1))!; + builder.Dispose(); + builder.Dispose(); + } + + [Fact] + public void ParagraphBuilder_AddText_rejects_null() + { + using var typography = ImpellerTypographyContext.New()!; + using var builder = typography.ParagraphBuilderNew()!; + Assert.Throws(() => builder.AddText(null!)); + } + + [Fact] + public void ParagraphBuilder_AddText_after_dispose_throws_ObjectDisposed() + { + using var typography = ImpellerTypographyContext.New()!; + var builder = typography.ParagraphBuilderNew()!; + builder.Dispose(); + Assert.Throws(() => builder.AddText("hello")); + } +} diff --git a/tests/NImpeller.Tests/Unit/PathBuilderTests.cs b/tests/NImpeller.Tests/Unit/PathBuilderTests.cs new file mode 100644 index 0000000..75231d6 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/PathBuilderTests.cs @@ -0,0 +1,43 @@ +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class PathBuilderTests +{ + [Fact] + public void New_returns_a_path_builder() + { + using var builder = ImpellerPathBuilder.New(); + Assert.NotNull(builder); + } + + [Fact] + public void CopyPathNew_yields_a_path_from_recorded_segments() + { + using var builder = ImpellerPathBuilder.New()!; + builder.MoveTo(new ImpellerPoint { X = 100, Y = 100 }); + builder.LineTo(new ImpellerPoint { X = 200, Y = 200 }); + builder.Close(); + + using var path = builder.CopyPathNew(ImpellerFillType.kImpellerFillTypeNonZero); + Assert.NotNull(path); + } + + [Fact] + public void Builder_supports_curves_rects_and_ovals() + { + using var builder = ImpellerPathBuilder.New()!; + builder.MoveTo(new ImpellerPoint { X = 0, Y = 0 }); + builder.QuadraticCurveTo(new ImpellerPoint { X = 10, Y = 10 }, new ImpellerPoint { X = 20, Y = 0 }); + builder.CubicCurveTo( + new ImpellerPoint { X = 30, Y = 10 }, + new ImpellerPoint { X = 40, Y = -10 }, + new ImpellerPoint { X = 50, Y = 0 }); + builder.AddRect(new ImpellerRect(0, 0, 10, 10)); + builder.AddOval(new ImpellerRect(0, 0, 10, 10)); + + using var path = builder.TakePathNew(ImpellerFillType.kImpellerFillTypeOdd); + Assert.NotNull(path); + } +} diff --git a/tests/NImpeller.Tests/Unit/StructHelperTests.cs b/tests/NImpeller.Tests/Unit/StructHelperTests.cs new file mode 100644 index 0000000..8c13800 --- /dev/null +++ b/tests/NImpeller.Tests/Unit/StructHelperTests.cs @@ -0,0 +1,69 @@ +using System.Numerics; +using NImpeller; +using Xunit; + +namespace NImpeller.Tests.Unit; + +public sealed class StructHelperTests +{ + [Fact] + public void FromRgb_sets_opaque_normalized_channels() + { + var c = ImpellerColor.FromRgb(255, 0, 0); + Assert.Equal(1.0f, c.Red); + Assert.Equal(0.0f, c.Green); + Assert.Equal(0.0f, c.Blue); + Assert.Equal(1.0f, c.Alpha); // FromRgb implies fully opaque + } + + [Fact] + public void FromArgb_normalizes_all_four_channels() + { + var c = ImpellerColor.FromArgb(128, 10, 20, 30); + Assert.Equal(128 / 255.0f, c.Alpha); + Assert.Equal(10 / 255.0f, c.Red); + Assert.Equal(20 / 255.0f, c.Green); + Assert.Equal(30 / 255.0f, c.Blue); + } + + [Fact] + public void Rect_int_ctor_assigns_fields() + { + var r = new ImpellerRect(1, 2, 3, 4); + Assert.Equal(1f, r.X); + Assert.Equal(2f, r.Y); + Assert.Equal(3f, r.Width); + Assert.Equal(4f, r.Height); + } + + [Fact] + public void ISize_int_ctor_assigns_fields() + { + var s = new ImpellerISize(640, 480); + Assert.Equal(640L, s.Width); + Assert.Equal(480L, s.Height); + } + + [Fact] + public void Matrix_implicitly_converts_from_Matrix4x4() + { + var m = Matrix4x4.CreateScale(2, 3, 1); + ImpellerMatrix im = m; // implicit operator + Assert.Equal(m, im.Matrix); + } + + [Fact] + public void TextDecoration_factories_set_type_and_solid_style() + { + var color = ImpellerColor.FromRgb(0, 0, 255); + + var underline = ImpellerTextDecoration.Underline(color, 2.0f); + Assert.Equal((int)ImpellerTextDecorationType.kImpellerTextDecorationTypeUnderline, underline.Types); + Assert.Equal(ImpellerTextDecorationStyle.kImpellerTextDecorationStyleSolid, underline.Style); + Assert.Equal(2.0f, underline.Thickness_multiplier); + + var lineThrough = ImpellerTextDecoration.LineThrough(color); + Assert.Equal((int)ImpellerTextDecorationType.kImpellerTextDecorationTypeLineThrough, lineThrough.Types); + Assert.Equal(1.0f, lineThrough.Thickness_multiplier); // default + } +} diff --git a/tests/NImpeller.Tests/test.runsettings b/tests/NImpeller.Tests/test.runsettings new file mode 100644 index 0000000..5db1947 --- /dev/null +++ b/tests/NImpeller.Tests/test.runsettings @@ -0,0 +1,21 @@ + + + + + + <__EGL_VENDOR_LIBRARY_FILENAMES>/usr/share/glvnd/egl_vendor.d/50_mesa.json + 1 + llvmpipe + llvmpipe + + + From a157ec55265a6d28ccb15ccf781eed82e0b230d8 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Tue, 30 Jun 2026 22:00:41 +0900 Subject: [PATCH 2/8] Update GLX requirements --- .github/workflows/tests.yml | 15 ++++++++- tests/NImpeller.Tests/GoldenImageTests.cs | 13 ++++---- tests/NImpeller.Tests/RenderGate.cs | 32 ++++++++++++++++++++ tests/NImpeller.Tests/TextureInteropTests.cs | 11 +------ tests/NImpeller.Tests/test.runsettings | 2 ++ 5 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 tests/NImpeller.Tests/RenderGate.cs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3312c1..bb07ae8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Checkout (with CppAst.NET submodule) + - name: Checkout uses: actions/checkout@v4 with: submodules: recursive @@ -48,6 +48,19 @@ jobs: ./build.sh DownloadLatestImpeller --platform linux-x64 ./build.sh GenerateBindings --platform linux-x64 + # Prints what GL/EGL stack the runner actually exposes (helps diagnose context failures). + - name: GL diagnostics + continue-on-error: true + env: + LIBGL_ALWAYS_SOFTWARE: '1' + GALLIUM_DRIVER: llvmpipe + __EGL_VENDOR_LIBRARY_FILENAMES: /usr/share/glvnd/egl_vendor.d/50_mesa.json + run: | + sudo apt-get install -y --no-install-recommends mesa-utils mesa-utils-extra || true + ls -l /usr/share/glvnd/egl_vendor.d/ || true + echo "== eglinfo =="; xvfb-run -a eglinfo 2>&1 | head -60 || true + echo "== glxinfo -B =="; xvfb-run -a glxinfo -B 2>&1 | head -25 || true + - name: Test run: xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release diff --git a/tests/NImpeller.Tests/GoldenImageTests.cs b/tests/NImpeller.Tests/GoldenImageTests.cs index 694d665..d5c282a 100644 --- a/tests/NImpeller.Tests/GoldenImageTests.cs +++ b/tests/NImpeller.Tests/GoldenImageTests.cs @@ -54,13 +54,7 @@ public static TheoryData SceneNames() [MemberData(nameof(SceneNames))] public void Scene_matches_golden(string sceneName) { - Assert.SkipUnless(_gl.Available, - $"No headless GL context (run under xvfb-run or set SDL_VIDEODRIVER=offscreen). Reason: {_gl.Error}"); - - // For now, we're only running on a software renderer - // so check if llvmpipe is being used. - Assert.True(_gl.IsSoftwareRenderer, - $"Expected a software renderer (llvmpipe) but got '{_gl.RendererName}'. Ensure test.runsettings is applied (it is by default via dotnet test). See README."); + RenderGate.Require(_gl); var @case = Cases[sceneName]; var actual = _gl.Render(@case.Scene, @case.Width, @case.Height); @@ -90,6 +84,11 @@ public static void Matches(string name, RawImage actual, int perChannelTolerance if (update || !File.Exists(rawPath)) { + if (!update && RenderGate.IsCI) + { + Assert.Fail($"Golden baseline not found in CI: {Path.GetFullPath(rawPath)} — it must be committed."); + } + actual.SaveRaw(rawPath); actual.SavePng(Path.Combine(GoldensDir(), name + ".png")); Assert.Skip(update diff --git a/tests/NImpeller.Tests/RenderGate.cs b/tests/NImpeller.Tests/RenderGate.cs new file mode 100644 index 0000000..6a7ff88 --- /dev/null +++ b/tests/NImpeller.Tests/RenderGate.cs @@ -0,0 +1,32 @@ +using NImpeller.Tests.Headless; +using Xunit; + +namespace NImpeller.Tests; + +/// +/// Decides whether a rendering test should run, skip, or fail when the headless GL context isn't +/// usable. +/// +internal static class RenderGate +{ + // GitHub Actions and most CI providers set CI=true. + public static bool IsCI => + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase); + + public static void Require(ImpellerGLFixture gl) + { + string reason = $"No headless GL context. Reason: {gl.Error}"; + if (IsCI) + { + Assert.True(gl.Available, reason); + } + else + { + Assert.SkipUnless(gl.Available, reason); + } + + Assert.True(gl.IsSoftwareRenderer, + $"Expected a software renderer (llvmpipe) but got '{gl.RendererName}'. " + + "Ensure test.runsettings is applied (it is by default via dotnet test). See README."); + } +} diff --git a/tests/NImpeller.Tests/TextureInteropTests.cs b/tests/NImpeller.Tests/TextureInteropTests.cs index c96f45e..4a920a3 100644 --- a/tests/NImpeller.Tests/TextureInteropTests.cs +++ b/tests/NImpeller.Tests/TextureInteropTests.cs @@ -3,12 +3,6 @@ namespace NImpeller.Tests; -/// -/// GL-texture interop: create a real GL texture, adopt it into an ImpellerTexture, draw it, and -/// read the result back. Mirrors Flutter's interop CanCreateOpenGLImage (handle round-trip) and -/// CanDrawImage. This is the texture path that works today — unlike TextureCreateWithContentsNew, -/// which is blocked by the unimplemented ImpellerMapping marshaller (see BindingGapTests). -/// [Collection(ImpellerGLCollection.Name)] public sealed class TextureInteropTests { @@ -22,10 +16,7 @@ public sealed class TextureInteropTests [Fact] public void GL_texture_round_trips_and_draws() { - Assert.SkipUnless(_gl.Available, - $"No headless GL context (run under xvfb-run or set SDL_VIDEODRIVER=offscreen). Reason: {_gl.Error}"); - Assert.True(_gl.IsSoftwareRenderer, - $"Expected a software renderer (llvmpipe) but got '{_gl.RendererName}'. Ensure test.runsettings is applied. See README."); + RenderGate.Require(_gl); var (image, createdHandle, reportedHandle) = _gl.RenderTextureInterop(320, 240); diff --git a/tests/NImpeller.Tests/test.runsettings b/tests/NImpeller.Tests/test.runsettings index 5db1947..c05651d 100644 --- a/tests/NImpeller.Tests/test.runsettings +++ b/tests/NImpeller.Tests/test.runsettings @@ -16,6 +16,8 @@ 1 llvmpipe llvmpipe + + 1 From 43fd013178de94380ef4977bd4326215c107b133 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Tue, 30 Jun 2026 22:07:53 +0900 Subject: [PATCH 3/8] Brute force SDL --- .github/workflows/tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb07ae8..a976b61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,12 @@ jobs: libsdl2-2.0-0 \ libegl1 libgles2 libglvnd0 \ libegl-mesa0 libgl1-mesa-dri libglx-mesa0 + # Silk.NET.SDL dlopens "libSDL2-2.0.so", but the runtime package only ships + # "libSDL2-2.0.so.0" (the unversioned symlink lives in libsdl2-dev). Create it. + SO=$(ldconfig -p | awk '/libSDL2-2.0.so.0/{print $NF; exit}') + echo "Linking libSDL2-2.0.so -> $SO" + sudo ln -sf "$SO" "$(dirname "$SO")/libSDL2-2.0.so" + sudo ldconfig # Bindings (src/NImpeller/Generated/Bindings.g.cs) and the native libimpeller.so are not # committed; download the Linux SDK at the latest Flutter engine commit and generate them. From 1f5f995af4479d5c85090235711f9f148a79207a Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Tue, 30 Jun 2026 22:35:06 +0900 Subject: [PATCH 4/8] Clean up runsettings comments --- tests/NImpeller.Tests/test.runsettings | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/NImpeller.Tests/test.runsettings b/tests/NImpeller.Tests/test.runsettings index c05651d..08d8210 100644 --- a/tests/NImpeller.Tests/test.runsettings +++ b/tests/NImpeller.Tests/test.runsettings @@ -1,14 +1,4 @@ - @@ -16,7 +6,6 @@ 1 llvmpipe llvmpipe - 1 From 88c9c5f7808ab585154910c2411621385cc47b2b Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 1 Jul 2026 10:06:20 +0900 Subject: [PATCH 5/8] Code suggestions --- .../Headless/HeadlessImpellerContext.cs | 78 +++++++++++-------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs index 595ce06..3820599 100644 --- a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs +++ b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs @@ -195,43 +195,53 @@ private TextureInteropResult RenderTextureInteropCore(int width, int height) }; uint glTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, glTex); - fixed (byte* p = checker) - { - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, - 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); - } - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); - - var descriptor = new ImpellerTextureDescriptor + try { - Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, - Size = new ImpellerISize(2, 2), - Mip_count = 1, - }; - - using var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) - ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null"); + _gl.BindTexture(TextureTarget.Texture2D, glTex); + fixed (byte* p = checker) + { + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); - ulong reportedHandle = texture.GetOpenGLHandle(); + var descriptor = new ImpellerTextureDescriptor + { + Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, + Size = new ImpellerISize(2, 2), + Mip_count = 1, + }; + + ulong reportedHandle; + byte[] pixels; + using (var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) + ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null")) + { + reportedHandle = texture.GetOpenGLHandle(); + + pixels = RenderToFbo(width, height, builder => + { + using var background = ImpellerPaint.New()!; + background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); + builder.DrawPaint(background); + + using var paint = ImpellerPaint.New()!; + builder.DrawTextureRect( + texture, + new ImpellerRect(0, 0, 2, 2), + new ImpellerRect(20, 20, width - 40, height - 40), + ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, + paint); + }); + } - var pixels = RenderToFbo(width, height, builder => + return new TextureInteropResult(pixels, glTex, reportedHandle); + } + finally { - using var background = ImpellerPaint.New()!; - background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); - builder.DrawPaint(background); - - using var paint = ImpellerPaint.New()!; - builder.DrawTextureRect( - texture, - new ImpellerRect(0, 0, 2, 2), - new ImpellerRect(20, 20, width - 40, height - 40), - ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, - paint); - }); - - return new TextureInteropResult(pixels, glTex, reportedHandle); + _gl.DeleteTexture(glTex); + } } // glReadPixels returns rows bottom-to-top; images are top-to-bottom. @@ -273,6 +283,8 @@ private T Invoke(Func fn, [System.Runtime.CompilerServices.CallerMemberNam "(a native call likely deadlocked)."); } + done.Dispose(); + if (error != null) { throw new InvalidOperationException("Headless render failed: " + error.Message, error); From 09ec723e5577a95508740ea1c10e3011f0af773f Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 1 Jul 2026 10:21:17 +0900 Subject: [PATCH 6/8] Warm GL context to see if that caused the failure in CI --- tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs index 3820599..a4b7531 100644 --- a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs +++ b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs @@ -112,6 +112,13 @@ private void InitializeGL() _impeller = ImpellerContext.CreateOpenGLESNew(name => (IntPtr)_sdl.GLGetProcAddress(name)) ?? throw new InvalidOperationException("ImpellerContext.CreateOpenGLESNew returned null"); + + RenderToFbo(16, 16, builder => + { + using var paint = ImpellerPaint.New()!; + paint.SetColor(ImpellerColor.FromRgb(0, 0, 0)); + builder.DrawPaint(paint); + }); } private void TeardownGL() From 998a29451bd071d8c45ec4282e5b6c3c0ecb7c5a Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 1 Jul 2026 10:39:57 +0900 Subject: [PATCH 7/8] Actually fix the dispose --- .../Headless/HeadlessImpellerContext.cs | 88 ++++++++----------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs index a4b7531..a5da4fa 100644 --- a/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs +++ b/tests/NImpeller.Tests/Headless/HeadlessImpellerContext.cs @@ -112,13 +112,6 @@ private void InitializeGL() _impeller = ImpellerContext.CreateOpenGLESNew(name => (IntPtr)_sdl.GLGetProcAddress(name)) ?? throw new InvalidOperationException("ImpellerContext.CreateOpenGLESNew returned null"); - - RenderToFbo(16, 16, builder => - { - using var paint = ImpellerPaint.New()!; - paint.SetColor(ImpellerColor.FromRgb(0, 0, 0)); - builder.DrawPaint(paint); - }); } private void TeardownGL() @@ -201,54 +194,51 @@ private TextureInteropResult RenderTextureInteropCore(int width, int height) 0, 0, 0, 255, 255, 255, 255, 255, }; + // impeller.h documents the handle param as "transfer-in ownership": Impeller adopts glTex + // and deletes it when the ImpellerTexture is released. Do NOT glDeleteTexture(glTex) here — + // that double-frees the name, and because Impeller defers GL deletes, it later kills a name + // recycled for another render's target, blanking that scene (e.g. groupopacity → empty). uint glTex = _gl.GenTexture(); - try + _gl.BindTexture(TextureTarget.Texture2D, glTex); + fixed (byte* p = checker) { - _gl.BindTexture(TextureTarget.Texture2D, glTex); - fixed (byte* p = checker) - { - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, - 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); - } - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); + _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, + 2, 2, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p); + } + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest); - var descriptor = new ImpellerTextureDescriptor - { - Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, - Size = new ImpellerISize(2, 2), - Mip_count = 1, - }; - - ulong reportedHandle; - byte[] pixels; - using (var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) - ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null")) - { - reportedHandle = texture.GetOpenGLHandle(); - - pixels = RenderToFbo(width, height, builder => - { - using var background = ImpellerPaint.New()!; - background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); - builder.DrawPaint(background); - - using var paint = ImpellerPaint.New()!; - builder.DrawTextureRect( - texture, - new ImpellerRect(0, 0, 2, 2), - new ImpellerRect(20, 20, width - 40, height - 40), - ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, - paint); - }); - } + var descriptor = new ImpellerTextureDescriptor + { + Pixel_format = ImpellerPixelFormat.kImpellerPixelFormatRGBA8888, + Size = new ImpellerISize(2, 2), + Mip_count = 1, + }; - return new TextureInteropResult(pixels, glTex, reportedHandle); - } - finally + ulong reportedHandle; + byte[] pixels; + using (var texture = _impeller.TextureCreateWithOpenGLTextureHandleNew(descriptor, glTex) + ?? throw new InvalidOperationException("TextureCreateWithOpenGLTextureHandleNew returned null")) { - _gl.DeleteTexture(glTex); + reportedHandle = texture.GetOpenGLHandle(); + + pixels = RenderToFbo(width, height, builder => + { + using var background = ImpellerPaint.New()!; + background.SetColor(ImpellerColor.FromRgb(30, 30, 30)); + builder.DrawPaint(background); + + using var paint = ImpellerPaint.New()!; + builder.DrawTextureRect( + texture, + new ImpellerRect(0, 0, 2, 2), + new ImpellerRect(20, 20, width - 40, height - 40), + ImpellerTextureSampling.kImpellerTextureSamplingNearestNeighbor, + paint); + }); } + + return new TextureInteropResult(pixels, glTex, reportedHandle); } // glReadPixels returns rows bottom-to-top; images are top-to-bottom. From c3696ce4116d94dd851addfc81b10ed33d44ba15 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Wed, 1 Jul 2026 10:41:00 +0900 Subject: [PATCH 8/8] Add test script for parameters --- README.md | 2 ++ test-headless.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100755 test-headless.sh diff --git a/README.md b/README.md index 8046534..29d1bef 100644 --- a/README.md +++ b/README.md @@ -88,4 +88,6 @@ Tests are included in `tests/NImpeller.Tests` and can be run with dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj ``` +or run `./test-headless.sh` + At the moment, they assume you're using `llvmpipe` for a software GL context. diff --git a/test-headless.sh b/test-headless.sh new file mode 100755 index 0000000..8beacdf --- /dev/null +++ b/test-headless.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Usage: +# ./test-headless.sh # one run +# ./test-headless.sh -n 5 # run the suite 5 times (catch nondeterministic failures) +# ./test-headless.sh --filter Golden # pass extra args straight through to `dotnet test` +# RUNS=5 ./test-headless.sh # same as -n 5 +# +# UPDATE_GOLDENS=1 ./test-headless.sh # regenerate baselines (honored by the tests) + +set -euo pipefail + +cd "$(dirname "$0")" + +RUNS="${RUNS:-1}" + +# Peel a leading -n/--runs off the arg list; everything else is forwarded to `dotnet test`. +if [[ "${1:-}" == "-n" || "${1:-}" == "--runs" ]]; then + RUNS="${2:?missing count after $1}" + shift 2 +fi + +if ! command -v xvfb-run >/dev/null 2>&1; then + echo "error: xvfb-run not found. Install it (Arch: xorg-server-xvfb, Debian/Ubuntu: xvfb)." >&2 + exit 1 +fi + +# Force the software renderer and the EGL path (a GLES context on X11/Xvfb must come through EGL). +export LIBGL_ALWAYS_SOFTWARE=1 +export GALLIUM_DRIVER=llvmpipe +export MESA_LOADER_DRIVER_OVERRIDE=llvmpipe +export SDL_VIDEO_X11_FORCE_EGL=1 +export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json +# Make RenderGate treat missing/non-software GL as a hard failure instead of a skip, like CI. +export CI=true + +status=0 +for ((i = 1; i <= RUNS; i++)); do + if [[ "$RUNS" -gt 1 ]]; then + echo "===== run $i / $RUNS =====" + fi + if ! xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release "$@"; then + status=1 + echo "----- run $i FAILED -----" >&2 + fi +done + +exit "$status"