Platforms and Render Backends
The same code runs on five platforms, but the GL context comes from somewhere different on each. This page is the master index of platform differences, plus the GLES-subset pitfalls for custom passes — blank viewports and first-frame crashes usually trace back here.
Render backend per platform
| Platform | Host renderer | How Aura3D gets a GL context | Application-side configuration |
|---|---|---|---|
| Windows / Linux | Desktop OpenGL | Avalonia OpenGlControlBase | None |
| Android | OpenGL ES | Avalonia OpenGlControlBase | None |
| macOS | OpenGL | Avalonia OpenGlControlBase | No platform special-casing; if the viewport stays blank, pin AvaloniaNativeRenderingMode.OpenGl explicitly in AppBuilder |
| iOS | Metal (Avalonia default) | Self-hosted ANGLE(Metal) context composed through a Skia lease; falls back to OpenGlControlBase when the host explicitly selects OpenGl | No platform special-casing, but the ANGLE iOS frameworks must be linked (injected automatically by the package) |
| Browser (wasm) | WebGL2 (Avalonia.Browser's Skia compositor) | Borrows the compositor's WebGL2 context and composes through a Skia lease; GLES 3.0 calls reach WebGL2 via the emscripten shim | Link switches are injected by the package; .NET 10 Release publishing must explicitly disable trimming, AOT and icall linking as described below |
The three paths live in Aura3DViewBase.OpenGl.cs, Aura3DViewBase.Angle.cs (iOS) and Aura3DViewBase.WebGl.cs (browser); the shared flow is in Aura3DViewBase.cs. The Aura3DView API an application uses is identical on every platform.
iOS: why a self-hosted ANGLE context
Avalonia's iOS host defaults to the Metal compositor, and in that mode Avalonia offers no GL interop — OpenGlControlBase silently fails to initialize: a blank white surface, no exception. The previous workaround was to force the whole host application onto iOSRenderingMode.OpenGl, which pinned the app to Apple's deprecated EAGL backend (known issues at the end of this page).
The iOS target of Aura3D.Avalonia now carries a second path: it creates its own ANGLE(Metal) EGL context, renders into it, and composites the resulting MTLTexture onto the screen through a zero-copy Skia lease. The host application no longer has to give up anything about its rendering mode, and writing iOSRenderingMode.OpenGl is no longer needed — that old configuration is deprecated.
Both paths are compiled into one binary and ownership is decided once, on the first frame:
- Authoritative signal —
OpenGlControlBase.OnOpenGlInitis called, which means the host enabled OpenGL mode; the ANGLE branch stands down entirely and Avalonia drives rendering. - Secondary signal — the Skia lease's
GRContext.Backendis read once:Metalmeans ANGLE takes over, anything else hands control back toOpenGlControlBase.
TIP
The decision is logged as [aura3d-angle] compositor backend=..., path=.... When debugging iOS rendering, check that line first to see which path was taken.
iOS: where the ANGLE frameworks come from
Aura3D.Avalonia resolves ANGLE's EGL/GLES2 symbols through DllImport("__Internal") (to avoid clashing with Apple's own OpenGLES symbols), so libEGL.framework and libGLESv2.framework have to be linked into the application executable rather than living inside the class library.
That part is now automatic. Aura3D.Avalonia's iOS target depends on Aura3D.Angle.iOS, and the package's buildTransitive targets pick the slice by $(RuntimeIdentifier) and inject the NativeReference items into the app project — an app needs zero ANGLE configuration:
dotnet add package Aura3D.Avalonia # pulls Aura3D.Angle.iOS in on iOSIMPORTANT
The version range is pinned exactly ([0.1.0]): the slice and the P/Invoke signatures are an ABI pair, so a consumer must never be silently upgraded onto a slice that was not tested against them. Replacing the slices means bumping that version and releasing Aura3D.Avalonia again.
The package ships two ANGLE slices, iossimulator-arm64 and ios-arm64, and has its own publishing channel:
- Trigger
build-ios-lib.ymlmanually and type the version into the form (the job packs with-p:Version=<input>, overriding the literal<Version>in the project, so a release needs no repository edit); the same job packs, verifies the version and both slices are inside the nupkg, and pushes to nuget.org through GitHub OIDC. pack.ymlno longer publishes it and its artifact set no longer contains it (the browser-sideAura3D.Avalonia.Browserworks the same way throughbuild-browser-lib.yml).- A missing slice fails the build loudly instead of quietly producing a blank viewport; a version nuget.org already serves fails the publish job.
- The version lives in two places that no longer reference each other:
<Version>inAura3D.Angle.iOS.csprojis what an in-repo build produces, andAura3DAngleIosVersioninDirectory.Packages.propsonly feeds the main package's exact range. A slice hotfix therefore runs:- publish the slice with the new version through the slice workflow;
- update both places to it — leave them disagreeing and the slice in this repository is no longer the one consumers resolve from nuget.org, while any later pack without
-pproduces the wrong number (the workflow flags that as an error); forget only the props side and the main package keeps pinning the old slice (a warning); - run
pack.ymlto re-publish the main package.
- Running
pack.ymlfirst is wrong too, and it now fails on its own: it restores that exact range from nuget.org, so pinning a version that was never published turns the restore red instead of shipping a package whose dependency nobody can resolve.
Manual route (when you build ANGLE yourself):
- Build ANGLE for iOS from a standalone ANGLE checkout (not a Chromium checkout) with the Metal backend enabled; the only required gn argument is
enable_rust=false. Drop the resultinglibEGL.frameworkandlibGLESv2.frameworkintosrc/Aura3D.Angle.iOS/native/iossimulator-arm64/andnative/ios-arm64/(both slices are committed to the repository and packed as-is).src/Aura3D.Angle.iOS/build-angle-ios.sh --devicecodifies this; the device slice additionally needsios_enable_code_signing = false, otherwisegn genfails while looking for an "Apple Development" identity — exactly the situation in a certificate-free CI. - Adding
<PackageReference Include="Aura3D.Angle.iOS" />to your own app works just as well as getting it transitively fromAura3D.Avalonia.
WARNING
Do not guard such an ItemGroup with an Exists(...) condition: when the frameworks are missing the group is skipped silently, the app still compiles, and the viewport stays blank because the ANGLE session fails. When you cannot find why nothing renders, verify the frameworks really were linked — check that they appear under <App>.app/Frameworks/.
Hardware requirements: ANGLE's Metal backend needs Metal GPU family 4 (A11 or later); tvOS is not supported. The committed slices are built with ANGLE's default ios_deployment_target, i.e. minos 18.0, so an app deployment target below that produces a linker version mismatch warning.
Browser: why the compositor's WebGL2 context is borrowed
Avalonia.Browser's WebGlContext is a singleton: CanCreateSharedContext is false and it exposes no GPU interop beyond ISkiaSharpApiLeaseFeature. So OpenGlControlBase can never initialize on wasm — the same silent failure as iOS under the Metal compositor — while a second WebGL context of our own could not share resources with the one Skia composites from.
The browser branch therefore does not create a context. A custom draw operation (ICustomDrawOperation) already runs on the compositor's render thread with Skia's WebGL2 context current, so the session allocates its output texture and FBO in that context, the pipeline renders into it, and the texture is imported zero-copy through the lease's GRContext via GRGlTextureInfo. The engine's GLES 3.0 calls do not go through Avalonia's GL bindings either: DllImport("libSkiaSharp") resolves emscripten's eglGetProcAddress, which returns real function pointers inside the application's wasm module (called as delegate* unmanaged, which is what Silk.NET's GL.GetApi expects), and emscripten's GLES→WebGL2 shim translates them.
Ownership is decided once, exactly like iOS, with the [aura3d-webgl] log prefix:
- Authoritative signal —
OpenGlControlBase.OnOpenGlInitis called (cannot happen today; reserved as the fallback for whenever Avalonia supports shared contexts). - Secondary signal — the Skia lease's
GRContext.Backendis read once:OpenGLmeans the WebGL2 branch takes over, anything else hands control back toOpenGlControlBase.
Browser: where the wasm link switches come from
The GLES entry points have to come out of the application's own wasm module. Without native linking the module simply has no libSkiaSharp symbols, and the app dies at startup with System.DllNotFoundException: libSkiaSharp (thrown from SKImageInfo's static constructor, with no context to go on). Three things must therefore hold in the app project: WasmBuildNative=true, -s FULL_ES3=1, and -s MIN/MAX_WEBGL_VERSION=2 (GLES3 entry points — VAO, UBO, 3D textures, blit — only exist on a WebGL2 context).
Those three link switches are automatic. Aura3D.Avalonia's browser target depends on Aura3D.Avalonia.Browser, whose buildTransitive props inject them for *-browser targets only, so an application does not hand-write emscripten arguments. This mirrors the iOS slice package, including the exact [0.1.0] pin: the switches are paired with the library's GLES call surface, so they bump together with a browser verification run.
NOTE
This automation does not include the .NET 10 publish-time runtime settings below; the application project must still declare those three properties explicitly.
WasmBuildNative=true is however only a necessary condition: if the local SDK has no wasm-tools/emsdk workload, linking still does not happen and the build still succeeds. The SDK's own warning reads "neither $(WasmBuildNative), nor $(RunAOTCompilation) are 'true'" — a hardcoded string, misleading here because WasmBuildNative is in fact true (measured: dotnet.native.wasm is 3.0 MB, versus 25.6 MB once native is linked). The package's buildTransitive targets therefore raise an error when RuntimeIdentifier=browser-wasm and WasmNativeWorkloadAvailable!=true, naming dotnet workload install wasm-tools; projects that do not need this backend can set Aura3DSkipWasmWorkloadCheck=true. Same stance as on iOS: error out rather than silently render nothing.
To check the wiring landed:
dotnet msbuild <App>.Browser.csproj -getProperty:EmccExtraLDFlags -getProperty:WasmBuildNative -getProperty:WasmNativeWorkloadAvailable(the last one is the direct verdict on the workload), and confirm the linked dotnet.native.wasm exports GLES3 symbols such as glGenVertexArrays — that is the shim being present.
Required settings for .NET 10 Release / static publishing
With the current .NET 10 WebAssembly toolchain, every Aura3D net10.0-browser application must explicitly add the following settings to the application .csproj. They form one required combination; do not enable only a subset:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishTrimmed>false</PublishTrimmed>
<RunAOTCompilation>false</RunAOTCompilation>
<WasmLinkIcalls>false</WasmLinkIcalls>
</PropertyGroup>- Release full trimming removes declarations which incidentally supply some WASM interpreter-to-native trampolines. When Silk.NET first calls
gl.ClearColor(float, float, float, float), theVFFFFsignature is missing. The log reaches[aura3d-webgl] output ..., then terminates withaot-runtime-wasm.c:188 <disabled>andProgram terminated with exit(1). Theaot-runtime-wasm.cfilename does not mean that the app enabled AOT. - Disabling trimming alone restores the trampolines, but the .NET 10 linked-icall table can still disagree with the metadata tokens in the loaded
System.Private.CoreLib. Startup then reportsYour mono runtime and class libraries are out of sync/function signature mismatch.WasmLinkIcalls=falseis therefore required as well. - Enabling AOT alone, using
TrimMode=partial, or rooting onlySilk.NET.OpenGLESdoes not fix both failures and is not a substitute for this configuration.
After changing these properties, use fresh bin, obj and publish directories. An isolated artifacts root is the recommended workflow:
dotnet publish .\gallery\Aura3D.Gallery.Browser\Aura3D.Gallery.Browser.csproj `
-c Release `
--no-incremental `
-p:UseArtifactsOutput=true `
-p:ArtifactsPath="$PWD\artifacts\browser-release-clean"The site is written to artifacts/browser-release-clean/publish/Aura3D.Gallery.Browser/release/wwwroot. Deploy it by replacing the remote static-site directory as a whole; do not overlay it onto an old _framework directory. Also invalidate dotnet.js in browser, Service Worker and CDN caches. Otherwise an old native wasm/CoreLib can be loaded with a new manifest and produce the same CoreLib/icall failure.
NOTE
WEBGL_debug_renderer_info not enabled / INVALID_ENUM is a harmless warning emitted while emscripten queries an anonymous renderer; it is unrelated to either crash above.
The GLES 3.0 subset: constraints for custom passes
Shaders and GL calls are written against a plain OpenGL ES 3.0 subset so that one pipeline works on desktop GL, Android GLES, ANGLE on iOS and WebGL2 in the browser. Some of these constraints bite for real under ANGLE:
- ANGLE's Metal backend only exposes ES 3.0 — requesting an ES 3.1/3.2 context fails with
EGL_BAD_MATCH— and it does not provideGL_EXT_float_blend. Drawing with blending enabled into a 32-bit float color attachment is treated asGL_INVALID_OPERATIONand the entire draw is dropped silently; the symptom is a black image, not an error. The framework's HDR render targets are therefore allRgba16f; do not allocate anRgba32fcolor attachment and then enable blending in a custom pass. - Depth attachments
DEPTH_COMPONENT16/24/32FandDEPTH24_STENCIL8/DEPTH32F_STENCIL8are ES 3.0 core and work fine under ANGLE — the restriction above concerns color attachments only. - No compute. 3D textures (e.g.
TexImage3D) cannot be imported as external textures. - ANGLE translates GLSL to MSL and compiles it at runtime; there is no on-disk binary cache. Measured first-frame cost on iOS (iPhone 17 simulator / iOS 27.0): roughly 5.6–5.9 s for a PBR scene with cascaded shadows, 0.4–0.7 s for simple scenes. This is expected, not a hang.
WebGL2 validates more strictly than desktop GL and ANGLE. The following are "fine" elsewhere but are treated as GL_INVALID_OPERATION on WebGL2, which drops the whole draw silently:
- Every enabled draw buffer must map to a fragment output that is actually written. Two ways to trip over this: (1) a framebuffer with only a depth attachment still has
GL_DRAW_BUFFER0defaulting toCOLOR_ATTACHMENT0, while the shadow fragment shader has no color output — a depth-only FBO must callglDrawBuffers([GL_NONE])explicitly (RenderTarget,CubeRenderTargetand the CSM path all do); (2) a fragment shader declaresoutbut no#ifdefbranch ever writes it, so the compiled program has zero fragment outputs. - Shader-variant macros must be selected before the program is bound.
UseShader(defines…)only records the macros;UseShader_Internal()is what compiles and callsglUseProgram. Writing the two in the opposite order binds the previous frame's variant (on frame 1, the empty-macro one). The engine papered over this as "one frame late but stable" — the real cost was an empty first frame plus a WebGL2 error. New passes always goUseShader→UseShader_Internal→ set uniforms → draw. - RGB-family internal formats are not color-renderable. WebGL2 does not list
RGB8/RGB16F/RGB32Fas color-renderable; attaching one yieldsFRAMEBUFFER_INCOMPLETE_ATTACHMENT. Render targets must use the RGBA family (the irradiance map and the prefiltered environment map moved fromRgb16ftoRgba16f). - There is no
glGetTexLevelParameteriv. When importing a GL texture into Skia the internal format has to be stated explicitly inGRGlTextureInfo, otherwiseSKImage.FromTexturereturnsnull(Skia queries the format on desktop GL; WebGL2 exposes no entry point for it).
The full macro system and UseShader / UseShader_Internal usage is in Custom Render Pipelines; symptom-driven debugging is in Common Pitfalls and Troubleshooting.
Frame scheduling and thread semantics
RequestNextFrameRendering()keeps the same signature on both backends. WithAutoRequestNextFrameRendering = falsethe application requests each frame itself.- On iOS the frame callback runs on the compositor render thread, unlike desktop where the callback is already on the UI thread.
SceneInitialized/SceneUpdated/ContextLost/ContextRestoredare marshalled back to the UI thread, so handlers can touch the control safely. The browser branch is structured the same way, and its context is thread-affine to the render thread — when the control detaches, GL calls cannot be issued from the UI thread; teardown has to be posted as aCompositor.RequestCompositionUpdatetask so it runs there. - Detaching the control from the visual tree destroys the context and actually deletes GL objects, returning VRAM. Scene, nodes and CPU-side resources are preserved, and re-attaching raises
ContextRestoredrather thanSceneInitializedagain. This matches desktopOnOpenGlDeinitsemantics; see GPU Resource Lifecycle for details.
Known issues on the GL (EAGL) fallback path
This path is only used when the host explicitly sets iOSRenderingMode.OpenGl (Avalonia OpenGlControlBase on Apple EAGL). Default iOS configurations never reach it. Current knowledge comes from simulator runs and has not been verified on a device:
- Scenes using the PBR deferred pipeline take minutes per frame — the simulator's EAGL is a translated/software path and cannot carry several large float render targets plus cascaded shadows. On-device EAGL is hardware-driven and may behave differently.
- Under cascaded shadow maps the ground plane stops producing fragments from the third frame on, while a minimal program that only consumes
gl_VertexIDstill lands in the same frame — which points at a silently dropped draw inside the simulator's GLES-on-Metal layer.
IMPORTANT
Bottom line: stay on the default (ANGLE) path on iOS; there is no reason to force OpenGL mode anymore.
Browser performance and known gaps
- Debug and the currently verified Release configuration both run on the Mono interpreter, and first-frame cost for heavy scenes can be measured in minutes (a cascaded-shadow scene plus the HDR-to-cubemap passes took over 10 minutes to reach frame 1 in headless Chromium). This is host-side execution speed, not the render path. Do not enable
RunAOTCompilationby itself as a performance workaround today: AOT does not restore a missing trampoline and still crashes on the first frame. Release performance work must wait for an upstream fix or for the Browser package to explicitly preserve every call signature required by Silk.NET. - While an automated page stays
hidden,requestAnimationFrameandResizeObservernever fire at all, so neither Avalonia's render loop nor the canvas size moves. Verifying locally in a headless browser needs a temporary rAF/ResizeObserver shim — such shims must not go intowwwroot. - Model import depends on the Assimp native library, and how that links in the browser is not verified yet. Model import on the other platforms is unaffected.
Runnable examples
The Gallery app itself (gallery/Aura3D.Gallery) and its browser host (gallery/Aura3D.Gallery.Browser) are the direct proof that all five platforms share one demo codebase — every setting above can be cross-checked in their .csproj files.