Skip to content
Edgar Mesquita edited this page Aug 14, 2026 · 19 revisions

Photon: The Native GPU Engine

🌐 This page in: English · Português

Photon is eQuantic.UI's proprietary GPU rendering engine for native targets: a from-scratch, Impeller-inspired renderer that draws the component tree pixel by pixel on the GPU (Metal on macOS/iOS, Vulkan on Android), with no Skia tier and no WebView. It is the second realization target of the write-once component architecture: the same C# components that lower to DOM+CSS on the web are rasterized by Photon natively.

Why proprietary, why no Skia

Owning a native tier means owning the whole rendering stack: direct Metal/Vulkan backends with precompiled shaders, not a wrapper over Skia. That buys:

  • Predictable performance: fixed pipeline-state registry, zero shader compilation at runtime (the jank source Impeller was built to kill). Pipeline caches persist between launches on both backends.
  • A tiny surface: UI rendering needs a handful of primitives, not a general 2D library.
  • One normative model: the same math, testable to the pixel, across CPU reference and every GPU backend.

Architecture at a glance

C# components (write-once, eQuantic.UI.Primitives vocabulary)
        │  Build(ComponentContext)          // pure, token-based, mode-free
        ▼
eQuantic.UI.Native.Framework                // C# flex layout engine (spec A2)
        ▼
eQuantic.UI.Native.Components               // PhotonRealizer: abstract tree → display list
        ▼
eQuantic.UI.Native.Engine                   // geometry, color model, Sdf.cs (NORMATIVE), DisplayList
        ▼
IRenderBackend
   ├── eQuantic.UI.Native.Engine.Reference  // scalar CPU rasterizer (golden ground truth, never shipped)
   ├── eQuantic.UI.Native.Engine.Metal      // Apple GPUs (macOS, iOS)
   └── eQuantic.UI.Native.Engine.Vulkan     // Android

eQuantic.UI.Native.Shell.MacOS / .iOS / .Android   // the window/host per platform

The normative core

  • Sdf.cs is a spec, not a library: per-corner rounded-rect signed distance, centered stroke (|d| − w/2), 1-pixel anti-aliasing coverage ramp. Every rasterizer, the CPU reference and the GPU fragment shaders alike, implements exactly these formulas.
  • Color model: sRGB authoring → linear premultiplied blending → sRGB store, mirroring GPU hardware behavior with sRGB render targets.
  • Display lists: flat, heap-free DrawCommand records (Clear / FillRRect / StrokeRRect, solid or two-stop linear gradient paints, baked 2D transforms).

Golden-image harness

Every scene renders through a backend into a surface, reads back sRGB pixels, and compares against committed PNG goldens (dependency-free codec, EQ_UPDATE_GOLDENS=1 regeneration flow, ±2 tolerance, failure artifacts with amplified diffs). The scene catalog is shared: the Reference backend pins the goldens, the GPU backends run the same catalog for parity, and GPU↔CPU parity holds across the full shared catalog within the golden tolerance, on Metal and on Vulkan.

The GPU backends are driven through slim typed objc_msgSend/P-Invoke bindings: no binding framework, no C shims, zero third-party packages.

Component rendering

The native realizer draws real component chrome (token-resolved fills, inside borders, per-corner radii):

Subsystem State
Shapes, borders, gradients, transforms, blending, clip ✅ Full, golden-tested
Layout (flex + wrap, grid, stacks, adaptive, truncation) ✅ C# engine
Shadows (elevation §05) ✅ Analytic ShadowCoverage, both backends
Text ✅ REAL. Texture primitive (A8 coverage × tint) + platform ITextRasterizer; macOS = CoreText (system frameworks only, no HarfBuzz/FreeType where the platform has a text engine). One engine measures AND rasters, so line breaks agree by construction. Two faces: the system's proportional one and its monospaced one (TypeStyle.Mono). No glyph is ever clipped: the bitmap is sized from the line's INK box (CTLineGetImageBounds, not the font's declared metrics, since a deep g tail passes beyond them) and reports how far above the line box it had to reach, so the realizer raises the draw by exactly that and the line box still lands where layout put it
Icons ✅ REAL. Pure-C# SVG path parser (full command set, arcs via F.6.5) + CoreGraphics rasterizer, same Texture primitive
Group opacity / transforms ✅ Engine layers
Scroll compositor ✅ Path-keyed offsets, real Sticky pinning (vertical)
Motion ✅ Loop motion, value transitions, Presence enter/exit with command-snapshot replay
Gestures ✅ Hover pipeline, press with slop-cancel, drag-to-dismiss (follow + glide)
Anchored overlays ✅ Synthetic overlay layers positioned from absolute anchor bounds
Images SurfaceSubtle box, since decode/upload is not part of the current release (the remaining Texture consumer)

A sentence draws run by run

Since 0.2.0-preview.28

A rich paragraph (Text.Spans) used to rasterize as ONE block in the base style, so the bold word, the inline code span, the italic term and the mid-sentence link all dissolved into flat prose, the widest write-once divergence left, since the web has emitted a span per run from the day runs existed. The unit is now a FRAGMENT, a run on a line, because that is the only piece with a rectangle and both halves need one: each fragment rasterizes in its own style (weight, mono, italic, its own size), and a linked fragment's rectangle is a hit region the shell navigates from, so inline links are pressable on Photon, closing the roadmap item that named them.

Interaction is wired: PhotonHost holds a retained root, SetState invalidates, taps dispatch to hit regions (topmost wins, disabled swallows; ≥48dp under a finger, the control's own size under a pointer, see Density). The native Counter runs tap → SetState → rebuild end-to-end in tests. PhotonHost.FocusedPath reports what holds the keyboard, including a field being edited (the caret is that field's own focus indicator), because a platform accessibility bridge needs one honest answer, not the ring's half of it.

Fingers never hover, and hover survives the rebuild

Since 0.2.0-preview.24

Pointer events carry a PointerKind, per EVENT and not per host, because hover-ability belongs to the device in the hand (an iPad gains a trackpad, an Android gains a mouse). A Touch-labelled PointerMove drives drags, pans and selections exactly like a mouse and skips only the hover resolution; a touch PressUp/PointerCancel clears whatever hover a mixed-input session left behind, while a mouse keeps its hover through a click. The macOS shell installs an NSTrackingArea and routes MouseExited to PointerLeave(), so crossing the window edge clears the hover instead of latching it.

Hover identity is the layout PATH, not the node reference, and the press learned this first: a component rebuild replaces every instance, and a hover that only knew the old reference painted exactly one frame. It is also a CHAIN of every region under the pointer (CSS :hover matches every ancestor, and the native realizer answers the same question the web twin answers), which is what lets a hover-opened Anchored stay open while you hover the trigger inside it. Pressed still wins over hover on the box that consumed the swap.

The window learns the machine's language

Since 0.2.0-preview.24

A GUI process launched outside a terminal has no LANG/LC_*, so .NET starts invariant even on a pt-BR machine. The shells now resolve the platform's locale truth and apply it before the first frame: AppleLocale reads NSLocale, where preferredLanguages[0] is the UI culture (resources) and currentLocale the format culture (dates, separators), natively the same pair .NET models as CurrentUICulture/CurrentCulture, and AndroidLocale feeds both statics from Locale.getDefault(). PhotonCultureController owns the statics write (default-thread AND current-thread, so every later thread agrees) and repaints on Apply, the theme controller's shape; registered TryAdd, so an app that forces a culture wins. Observing the OS locale changing at runtime is a per-shell fence.

Layout: the CSS rules the engine owes you

The native engine is judged against the browser, because the web twin renders the same tree there:

  • Fill inherits indeterminacy: on an axis the parent sizes from content, a Fill child has nothing to fill and passes the question through; leftover is not distributed on such an axis.
  • Block semantics on width, and a DECIDED height hands itself down: a box whose width is decided stretches an auto-sized CONTAINER child across it (a div's child div is full-width), while a button, link or input hugs there: they are inline-block, and only a FLEX stretch reaches through them (StretchKind). The height half is below.

Since 0.2.0-preview.28 A box whose height is DECIDED (fixed, filled or clamped) hands it to its single child, on both targets. This is the rule that retired the recurring "toolbar glued to the top of its fixed-height bar" family, where every Row inside a 64dp Box needed a manual Height = Fill to reach the bottom of its own bar.

  • flex-shrink with a min-content floor: a line that does not fit shrinks its items instead of overflowing, and never below each item's min-content (the longest word of a text, the sum of a row's children). The floor matters doubly because the clip guards pixels AND pointer: a clipped press region cannot be pressed.
  • Min/Max reflow: when a clamp changes a box's extent, its child re-measures against the final size.

The shells: Photon in a real window

macOS (eQuantic.UI.Native.Shell.MacOS): an NSWindow hosting a CAMetalLayer, zero third-party packages, just slim typed objc_msgSend AppKit bindings. The Metal backend encodes each frame straight into the layer's drawable (per-pixel-format pipeline cache, BGRA8_sRGB for windows); PhotonHost.RenderScale rasters at backingScaleFactor while layout/input stay in dp (retina). OS mouse/scroll/keyboard events route into the ordinary input pipeline, so press visuals, hover diffs and anchored menus behave exactly like the tests. Real San Francisco text and real pack icons render on screen. Try it: dotnet run --project samples/PhotonDesktop. Two headless modes need no window at all: --Photon:MaxFrames 120 presents that many frames and exits, and --Photon:ScreenshotPath out.png (with --Photon:Mode Dark for the other palette) renders ONE settled frame through the reference backend with the same CoreText metrics. That is the CI screenshot step, and the way a fidelity pass against a design handoff is checked. Calling Run() off the main thread says so in .NET terms instead of dying inside AppKit. Resize the window freely: the host adopts the viewport with all state intact.

iOS (eQuantic.UI.Native.Shell.iOS): PhotonApp.Run is the entry point; the same engine presents through Metal.

Android (eQuantic.UI.Native.Shell.Android): the app provides CreateApp (no Main); presentation goes through Vulkan (the Reference backend is the fallback).

The caret, and what a selection has to show

A selection has two ends and only one of them moves. Which one is not something a person can deduce from the band, so the caret is drawn WITH the selection, not instead of it: the band says which characters are held, the caret says which END you are holding, the one ⇧-arrow will move, the one the next character replaces from.

Two rules go with it, and both are about being visible when it matters:

  • The blink phase starts at the last caret MOVE, not at the epoch. Off a free-running phase, a caret that just arrived somewhere can be in its OFF half for half a second: you press ⇧→ and look at nothing. Every write to the caret restarts the cycle, which is why the host routes them all through one property.
  • While a drag is drawing a selection the caret does not blink at all. The end following your pointer is the one thing you are watching.

On the web the field is a real <input> and the browser owns its caret; what is described here is what Photon paints, and what the editor surface (which both targets paint themselves) does.

The blink is PACED, not pinned

A caret is 2Hz motion, not vsync motion, so an editing caret SCHEDULES its frames instead of holding the render loop hot:

  • RenderFrame leaves NeedsRender false when the caret is the only reason for a next frame, and records when the next blink TRANSITION is due;
  • the shells' idle ticks (the macOS 120Hz timer, the phones' vsync callbacks) ask host.IsFrameDue(nowMs) alongside NeedsRender: one present per half-period, landing on the transition, ~2 a second instead of the display's refresh rate.

Real motion (a spinner, a glide, a transition) still keeps the loop hot, and the schedule exists only for the caret. While a drag draws a selection nothing is scheduled at all: the caret is forced solid and the pointer's own movement drives the frames. Ten simulated seconds of a focused field: 20 presents, every one flipping the blink, asserted in CaretPacingTests, count included.

The web needs none of this: the browser owns the <input> caret, and the editor surface's caret div can blink on the compositor.

Hot reload: dotnet watch run, and the app follows

Run any Photon app under dotnet watch run, edit a Build body, save, and the window redraws with the new code and ALL state intact, within a tick. No SDK command, no configuration: the plumbing ships in eQuantic.UI.Native.Components and every host signs up at construction.

The architecture is hot-reload-shaped by construction:

  • the tree is RE-EXPANDED from the retained root on every frame (Build runs during measure), so a patched method body simply takes effect on the next frame;
  • component state lives in a PATH-keyed store, not in the objects an edit replaces, so nothing has to be migrated. Three clicks of counter state survive a reload because nothing touched them.

PhotonHotReload (the [MetadataUpdateHandler]) supplies the missing piece, the next frame itself, because an idle window presents nothing and a metadata update arrives with no input event attached. It does exactly two things:

  1. wakes every live host through a bool the render loops already poll (macOS within 50ms, the phones on their next vsync), and
  2. bumps a GENERATION the host compares on its own render thread to drop the content caches once (text rasters, icons, images). The keys are content, so most edits would miss them, but a patched rasterizer or an edited icon path is exactly the edit a person makes while tuning visuals. Nothing is mutated from the handler's thread.

Rude edits (new types, changed signatures) are the runtime's to refuse. dotnet watch answers those with a restart, which is correct and needs nothing from the framework.

Mobile: the same registry serves the iOS and Android hosts (their vsync ticks poll the same bool), so hot reload works in the SIMULATOR. A physical device needs dotnet watch to reach the process, which is a deploy-channel question, not a framework one.

Shaders: ONE normative Slang source

Sdf.slang (transliterating Sdf.cs) compiles offline via the pinned slangc toolchain into committed MSL + SPIR-V: the SDF fragment plus the textured fragment (texel Load, nearest by definition, because rasters are device-scale, exact Reference parity). App developers never run the shader toolchain; they consume the committed artifacts.

Performance: the promises get numbers

PerfHarnessTests measures what the definition of done promises ("120 Hz, zero steady-state allocations, budgets met") instead of assuming it. On a dashboard-shaped scene (24 cards + a loop-motion strip), per steady-state frame (M-series baseline): 146 draw commands, realize p50 0.23 ms / p95 0.32 ms against the 8.33 ms budget, and steady-state allocation under 72 KB per frame with frame recycling on (67.5 KB measured).

What keeps the number down:

  • a path-string cache: a path is identity, so the host lends a dictionary that survives frames and steady state stops re-concatenating them;
  • a reused DisplayListBuilder: Reset() keeps buffer capacity; the shells run one builder per loop;
  • frame recycling (PhotonHost.RecycleFrames, opt-in, default off): RealizeResult carries an explicit ownership story. With recycling on, the host OWNS every frame it replaces, the replaced tree feeds a LayoutNodePool, and the next frame is built from it. The production shells (macOS/iOS/Android) turn it on because nothing retains their frames; tests and tooling that hold RealizeResults leave it off and keep immutable trees.

Ceilings are pinned as regression RATCHETS a margin above the measured numbers (152 KB default profile / 72 KB pooled, 200 commands, 33 ms alarm, time deliberately loose so shared CI never flakes). The GPU half of the frame stays covered by parity goldens, not CI timing.

IME: composition is the platform's text

While a field holds the caret, the macOS shell hands key events to the PLATFORM's input context (NSTextInputClient: the view conforms, registered at runtime like every other override):

  • A dead key or a CJK method composes over several keystrokes as marked text: held by the host (SetMarkedText), rendered INLINE at the caret with an underline, while the field's VALUE stays untouched. Commit (CommitText) enters the field through the same door every keystroke uses; cancel leaves the field exactly as it was. Composing over a selection replaces it, the same rule typing has. An obscured field composes blind (echoing the composition would echo the secret).
  • Editing keys come BACK from the input context as selectors (doCommandBySelector:) and re-enter through the same OnKey door, re-spelled to DOM names: Backspace, arrows (with Shift/Alt variants), Home/End, Escape. Command/Control chords never enter the context: ⌘A/⌘C/⌘Z belong to the app.
  • firstRectForCharacterRange anchors the candidate window at the CARET (host CaretRect() → y-flip → view → window → screen), so the CJK picker appears under the composition.
  • Proof: ImeCompositionTests at the host (dead-key sequence, cancel, compose-over-selection, caret-riding, ranges, focus-loss drops composition), and the window self-test drives the REAL registered selectors (setMarkedText:insertText:) against a live field. Type option+e e in any field to see é compose.
  • Scope: code surfaces compose blind. Commit works, but the editor's face renders no inline marked run.

Accessibility: the semantics tree

Since 0.2.0-preview.29

Photon draws its own pixels, so the OS sees one opaque view, and without a semantics tree the app is INVISIBLE to a screen reader. The tree is a first-class frame artifact:

  • SemanticsTree.Collect(frame) (shared, target-neutral) derives reading-order semantics from the realized layout, page AND overlay layers both, so dialogs are seen. Roles: StaticText, Button, Link, TextField, CodeField, Slider (Adjustable), Image (labelled Icon). A control's inner text is its accessible name (the web's <button>text</button> rule); a labelled Icon announces, an unlabelled one is decoration; TextEntry.Label names a field. Identity is the layout PATH, the same identity presses, focus and scroll use, because the tree is rebuilt every frame and references go stale.
  • Scrolled-out content is included, exactly like FocusStop and deliberately unlike pointer hit regions: linear navigation reaches below the fold; clipping guards the pointer, not the reader.
  • PhotonHost.Semantics() exposes the tree; PhotonHost.ActivatePath(path) runs a control the way a tap does (resolved out of the current frame's regions, refusing disabled); adjustable elements answer increment/decrement actions.
  • macOS bridge (PhotonAccessibility): the content view answers accessibilityChildren with EQAXElements (an NSAccessibilityElement that can be pressed): role, label, value, enabled, y-flipped accessibilityFrameInParentSpace, the path as accessibilityIdentifier, and accessibilityPerformPress routed through ActivatePath. Elements rebuild per query; the previous batch is released.
  • iOS bridge (PhotonAccessibility): the PhotonView is an accessibility CONTAINER whose elements are built from the tree in reading order: role mapped onto UIAccessibilityTraits, frames converted into screen space, the path as accessibilityIdentifier, and the double tap and the adjust swipe exported as accessibilityActivate / accessibilityIncrement / accessibilityDecrement, routed through ActivatePath and AdjustPath. A check is a button whose VALUE is "1"/"0", UIKit's own contract for a toggle, which is why the state is announced in the user's language without the framework shipping either word. Disclosure rides iOS 17's accessibilityExpandedStatus. Elements are rebuilt when the tree CHANGES rather than per query (VoiceOver asks for the list while walking it), and LayoutChanged is posted only while VoiceOver is running.
  • Android bridge (PhotonAccessibility, an AccessibilityNodeProvider): the PhotonSurfaceView answers for a VIRTUAL hierarchy: one child per semantics node, the role as the platform's class name (android.widget.Button, CheckBox, Switch, SeekBar, EditText, ImageView, TextView), bounds in screen pixels, ACTION_CLICK through ActivatePath, scroll forward/back through AdjustPath, and explore-by-touch answered from dispatchHoverEvent. Disclosure is an ACTION there rather than a flag: offering expand is how a node says it is closed. The view must declare itself IMPORTANT_FOR_ACCESSIBILITY_YES, since it is the only view in the window, and without that the window has no accessible root at all.
  • Two fences, one on each phone: neither platform has a third check state, so a mixed checkbox is announced without one rather than claiming to be off; and Android has no link class, so a link is announced as what it does.
  • Proof: SemanticsTests pin order, naming, dialog visibility, disabled surfacing and the activate action, plus the PARITY GATE SemanticsMatchFocusStops, which keeps the semantics walk and the input walk from ever drifting apart. Each shell's self-test then asks the PLATFORM, not the tree: the macOS window prints its live accessibilityChildren, the iOS controller sends the real Objective-C selectors, and on Android uiautomator dump reads the app from outside through the same service pipeline TalkBack uses. The same screen answers with the same 26 elements on all three.
  • Web parity is native to the web realizer (real <button>/aria-label/placeholder), so nothing is owed there. The tree is target-neutral and every shell now has its bridge.

Status

The native suite pins the engine's behavior with 87 committed golden images (component pairs light+dark, mid-drag sheet states, texture coverage) alongside the unit and host suites. The window runs the write-once showroom with real text, icons, menus and gestures on macOS, iOS and Android shells. Current scope fence: image decode/upload is not included, so Image renders its placeholder surface.

Clone this wiki locally