fluent-gpu
August 21, 2026 · View on GitHub
These contracts are binding. The 8 specialist agents design within them. Where an agent needs a new type, it goes in the namespace/assembly fixed in §7 and obeys the handle/alloc rules in §1. Disputes resolve toward: footprint, zero per-frame alloc, NativeAOT, cross-platform seam.
Decisions already locked by the user (not relitigated): own batched 2D renderer on D3D12 (D3D11 dropped as reference — ComputeSharp is DX12-only and is our COM-interop seed), DirectWrite glyphs, DComp present, pure Win32 (no WinRT/WinAppSDK), NativeAOT, keep Reactor programming model but patch OUR RenderNode tree.
Legend: ▽ = a value with sub-pixel coverage (alpha mask). All structs are [StructLayout(Sequential)]
unless noted Explicit. Sizes are x64.
1. CORE PRIMITIVES & MEMORY (FluentGpu.Foundation)
1.1 Generational handles — the universal hot-path reference
RULE: On hot paths (per-frame: scene mutation, layout, drawlist record, GPU submit, input hit-test, animation tick) NOTHING holds a GC reference into a pooled collection. Everything refers to slab/arena/pool entries by a generational handle. GC references appear ONLY at the "edges": user delegates (event handlers, effects),
Elementrecords (immutable, short-lived, reconcile-only),Componentinstances, and COMComPtr<T>ownership roots.
A handle is a 64-bit POD: 32-bit index + 32-bit generation (canonical: architecture-spec.md §4.1;
see SPEC-INDEX.md). Generation is bumped on alloc AND free and defeats ABA (a freed
slot reused for a different node yields a stale handle that fails validation). The kind tag is NOT
packed into the handle bits — it lives on the zero-cost typed wrapper as a [Conditional("DEBUG")]
assert (the wrapper statically knows its kind). This buys the full 32-bit generation back ( alloc/free
cycles per slot ≈ 400 days/slot at 60 fps) and removes the park-slot-forever-on-wrap policy that the
earlier handle form forced — a policy that violated the no-growth invariant.
namespace FluentGpu.Foundation;
// 8 bytes. {u32 index, u32 gen}. Kind is on the typed wrapper, NOT in the bits.
[StructLayout(LayoutKind.Sequential, Size = 8)]
public readonly struct Handle : IEquatable<Handle>
{
public readonly uint Index; // slot index into the owning slab column (0 = null sentinel)
public readonly uint Gen; // 32-bit generation; bumped on alloc AND free (ABA defense)
public bool IsNull => Index == 0 && Gen == 0; // index 0 / gen 0 reserved = Null
public ulong Packed => Unsafe.BitCast<Handle, ulong>(this); // for DepKey/sortkey packing
public bool IsLive(uint slotGen) => Gen == slotGen; // the one validation, in DEBUG hot paths
public bool Equals(Handle o) => Index == o.Index && Gen == o.Gen;
public override int GetHashCode() => (int)(Index * 2654435761u ^ Gen);
public static readonly Handle Null = default;
}
// HandleKind is carried by the typed wrapper's DEBUG assert (below), NOT packed into Handle.
public enum HandleKind : byte
{ None=0, Node=1, Brush=2, Clip=3, Layer=4, GlyphRunRef=5, ImageRef=6, GpuBuffer=7,
Texture=8, Pipeline=9, FontFace=10, AnimTrack=11, EffectChain=12, PathGeom=13 }
Strongly-typed wrappers (zero-cost; readonly struct over Handle) prevent cross-kind mixups
at compile time. Specialists MUST use the typed forms in public signatures:
public readonly struct NodeHandle { internal readonly Handle H; } // Kind=Node
public readonly struct BrushHandle { internal readonly Handle H; }
public readonly struct ClipHandle { internal readonly Handle H; }
public readonly struct TextureHandle { internal readonly Handle H; }
public readonly struct BufferHandle { internal readonly Handle H; }
public readonly struct PipelineHandle{ internal readonly Handle H; }
public readonly struct FontFaceHandle{ internal readonly Handle H; }
// ... one per HandleKind. All blittable, all 8 bytes.
1.2 The four allocator archetypes
All live in FluentGpu.Foundation. All are class instances owned at the edge (per-window/per-device),
but their contents are addressed by handle/offset on hot paths.
┌─ SlabAllocator<T> where T : unmanaged ────────────────────────────────────┐
│ Stable, generationally-versioned storage for long-lived hot structs │
│ (RenderNode SoA columns, Brush table, Clip table, GlyphRun table). │
│ T[] _items; uint[] _generations; int[] _freeList; int _freeHead; │
│ int _count, _capacity; │
│ Handle Alloc(in T value); // pop free slot or grow; gen++ │
│ ref T Get(Handle h); // validates gen; throws on stale │
│ bool TryGet(Handle h, out ...); // hot path: returns false stale │
│ void Free(Handle h); // push slot to free list; gen++ │
│ Growth: geometric ×2; never shrinks (footprint stable across frames). │
└────────────────────────────────────────────────────────────────────────────┘
┌─ ArenaAllocator (bump / linear) ───────────────────────────────────────────┐
│ Per-frame scratch. ONE contiguous byte region; bump pointer; O(1) Reset(). │
│ Backs the DrawList command stream, transient layout scratch, LIS scratch. │
│ byte[] _buffer; int _head; │
│ Span<byte> Alloc(int bytes, int align); │
│ ref T AllocStruct<T>() where T: unmanaged; │
│ int Mark(); void ResetTo(int mark); void Reset(); // frame boundary │
│ NEVER stores cross-frame state. Cleared (head=0) at frame start. │
└────────────────────────────────────────────────────────────────────────────┘
┌─ ObjectPool<T> where T : class, new() ─────────────────────────────────────┐
│ For the unavoidable GC objects we want to recycle: Component RenderContext, │
│ HookState boxes, ChildReconciler scratch dictionaries. Per-type stack, │
│ cap 32 (Reactor ElementPool contract). Explicit Reset() on return — no │
│ reflection. Used at the EDGE, not the per-frame hot loop. │
└────────────────────────────────────────────────────────────────────────────┘
┌─ HandleTable<T> (= SlabAllocator specialization for COM/GPU resources) ─────┐
│ Maps Texture/Buffer/Pipeline/FontFace handles → owned ComPtr<T> + metadata. │
│ The ONLY place GC/COM ownership meets handles. RHI hands out handles; │
│ internally keeps ComPtr alive. Disposed at device teardown. │
└────────────────────────────────────────────────────────────────────────────┘
Free-list strategy: intrusive singly-linked free list inside the slab (_freeList[i] = next
free index, _freeHead = head). Alloc pops head; Free pushes. _generations[i]++ on both
Alloc and Free so any handle captured before a Free is invalidated. Generation wraps at
(~16M reuses of one slot — practically unreachable in a session; documented limitation).
1.3 Strings & interning
- No per-frame string allocation. Text content reaches the renderer as already-shaped glyph
runs (§5), never as
stringon the paint path. StringId= interned 32-bit id.StringInterner(in Foundation) is an append-onlyDictionary<string,int>+string[]reverse table, populated at the edge (theme keys, font family names, accessibility names, attached-property keys). Hot paths compareStringId(int) notstring.- Element
Keystaysstring?(reconcile-only, edge), but the keyed reconciler hashes toStringIdfor its scratch map to avoid string hashing churn. - Font family / theme resource keys: interned once at registration; resolved to
FontFaceHandle/BrushHandlethereafter.
1.4 Span-based mutation conventions (binding on all specialists)
- Public mutation APIs take
ReadOnlySpan<T>/Span<T>, neverparams T[]on hot paths. Hook dependencies:ReadOnlySpan<DepKey>(see §6.4), NOTparams object[]. - "Get a ref, mutate in place":
ref T Get(Handle)returns arefinto the slab; callers mutate fields directly (no read-modify-write copy). Marked[MethodImpl(AggressiveInlining)]. - COM:
ComPtr<T>(vendored from ComputeSharp,unsafe struct,T : unmanaged) is the ONLY COM lifetime primitive. RawT*only inside the OS backend (FluentGpu.WindowsD3D12/, Pal/, DirectWrite/). - No
IEnumerable/LINQ/iterator state machines on per-frame paths. Index loops only. EqualityComparer<T>.Defaultis allowed (AOT-safe singleton); custom structEqualspreferred.
2. RETAINED TREE — RenderNode storage (FluentGpu.Scene)
Decision: Struct-of-Arrays (SoA), columns in a SlabAllocator-backed SceneStore.
Justification vs Array-of-Structs:
- Layout, render-walk, and dirty-scan each touch disjoint subsets of fields. SoA streams only
the hot columns (e.g. dirty-scan reads only
Flags[]), maximizing cache-line utilization — AoS would drag full ~160B nodes through cache for a 4-byte flag read. - SoA columns are plain
T[]→ triviallySpan<T>-iterable, SIMD-friendly (transform compose, bounds union), and resize independently. - A single shared generation/free-list (one
SlabAllocator"spine") indexes all columns byNodeHandle.Index, so handle validation is one check regardless of column count. - Matches WinUI's lesson (avoid per-element COM objects / sparse property bags): our node is flat data, no inheritance, no property system.
namespace FluentGpu.Scene;
public sealed class SceneStore
{
// ── spine: generations + free list (the SlabAllocator core) ──
uint[] _generation; // [i] bumped on alloc/free
int[] _freeNext; // intrusive free list
int _freeHead, _count, _capacity;
// ── topology columns (handles, not refs) ──
int[] Parent; // NodeHandle.Index of parent, -1 root
int[] FirstChild; // -1 none
int[] NextSibling; // -1 none (singly-linked sibling list; prev not needed for paint)
int[] PrevSibling; // kept for O(1) reorder during reconcile
int[] ChildCount;
// ── identity / reconcile linkage (edge-ish, but cheap to colocate) ──
StringId[] Key; // 0 = none
ushort[] ElementTypeId; // source-gen'd per Element record type (no System.Type on hot path)
int[] ComponentSlot; // -1, or index into ComponentTable for Component/Func nodes
// ── LAYOUT INPUT (mirrors YogaStyle subset that affects this node) ──
// Co-located here so the layout pass reads node→style without chasing a YogaNode object.
LayoutInput[] Layout; // struct: width/height/min/max, margin/padding (4 edges),
// flex dir/grow/shrink/basis, align, justify, position kind, gap.
// ~96B; see FluentGpu.Layout. Index-parallel to YogaNode if used.
// ── LAYOUT RESULT (arrange output; the bridge layout→paint) ──
LayoutRect[] Bounds; // struct { float X,Y,W,H } absolute, post-arrange, pixel-snapped
// (margin/border/padding result edges live in LayoutResult side-table only if needed;
// paint needs final content rect = Bounds.)
// ── PAINT columns (consumed by the render-walk → DrawList) ──
Affine2D[] LocalTransform; // 2x3 float affine (rotation/scale/translate/skew); identity common
float[] Opacity; // 0..1; 1 common
BrushHandle[] Fill; // background/fill brush; Null = none
BrushHandle[] Stroke; // border brush; Null = none
float[] StrokeWidth;
CornerRadius4[] Corners; // float TL,TR,BR,BL (rounded-rect/border)
ClipHandle[] Clip; // explicit geometry clip; Null = inherit/none
NodeVisual VisualKind; // enum: Container, RoundRect, Text, Path, Image, Backdrop
// ── per-kind payload reference (into side tables, by handle/index) ──
int[] PayloadRef; // Text→GlyphRunSet index; Path→PathGeom; Image→ImageRef; else -1
// ── DIRTY / STATE flags (the hot scan column) ──
NodeFlags[] Flags; // see below; single 32-bit read per node for invalidation
}
[Flags] public enum NodeFlags : uint
{
None = 0,
LayoutDirty = 1<<0, // measure/arrange needed for this subtree
LayoutSelfDirty = 1<<1, // only own measure (size-affecting prop changed)
TransformDirty = 1<<2,
PaintDirty = 1<<3, // visual prop changed; re-record drawlist for subtree
ClipDirty = 1<<4,
OpacityDirty = 1<<5,
SubtreeDirty = 1<<6, // a descendant is dirty (propagated UPWARD, lazy — WinUI lesson)
NeedsLayer = 1<<7, // opacity<1 OR backdrop OR clip-with-AA → push offscreen layer
Hidden = 1<<8, // Visibility.Collapsed (skip layout+paint)
Hittable = 1<<9, // participates in hit-test
Focusable = 1<<10,
HasHandlers = 1<<11, // pointer/key handlers present (input dispatch gate)
Realized = 1<<12, // has a cached realization (text/path) still valid
}
Memory layout of the key co-located structs (x64)
LayoutRect 16B : float X, Y, W, H
Affine2D 24B : float m11,m12,m21,m22,dx,dy (2x3 row-major)
CornerRadius4 16B : float TL,TR,BR,BL
LayoutInput ~96B : sizing + flex + spacing (see §Layout owner)
How layout data and paint data co-locate (and stay decoupled):
LayoutInput (read by measure) and Bounds (written by arrange) are columns in the SAME store as
the paint columns. The layout pass writes Bounds[i]; the render-walk reads Bounds[i] plus
LocalTransform/Opacity/Fill/.... One NodeHandle.Index indexes all. No separate "layout tree" /
"visual tree" duality (WinUI's HWCompNode mirror is the heaviness we explicitly reject) — there is
ONE node store; the DrawList (§3) is the only derived per-frame artifact.
Yoga integration: the ported YogaAlgorithm (full reuse, WinUI deps stripped) operates over a thin
YogaNode facade whose Style getters read SceneStore.Layout[i] and whose LayoutResults writer
writes SceneStore.Bounds[i]. Measure callbacks for text route through the Text seam (§5). The
8-entry CachedMeasurement ring (confirmed in LayoutResults.cs) is preserved per node in a
parallel LayoutCache[] side-column owned by FluentGpu.Layout.
3. DRAWLIST ENCODING (FluentGpu.Render)
Decision: **Flat, contiguous, POD command stream in the per-frame ArenaAllocator,
rebuilt-per-dirty-subtree; brushes/clips/glyphruns referenced by handle (stable tables).**
The DrawList is the seam between the render-walk (CPU, produces commands) and the batcher (CPU, sorts/instances) → RHI (GPU). It is retained per top-level layer and only the dirty subtrees are re-recorded (incremental); clean subtrees keep their previously-recorded command span (the "rebuild-vs-incremental" story below).
3.1 Stream physical format
Arena byte region (one per layer/window):
┌──────────┬───────────────────────────┬──────────┬─────────────────────────┐
│ DrawCmd │ payload (cmd-specific POD) │ DrawCmd │ payload ... │
│ (header)│ fixed size per opcode │ (header) │ │
└──────────┴───────────────────────────┴──────────┴─────────────────────────┘
namespace FluentGpu.Render;
public enum DrawOp : byte
{
FillRoundRect, FillRect, StrokeRoundRect,
DrawGlyphRun, FillPath, StrokePath,
DrawImage, DrawBackdrop,
PushClipRect, PushClipPath, PopClip,
PushLayer, PopLayer, // layer carries opacity + optional effect chain
PushTransform, PopTransform, // 2x3 affine; stack composed on CPU but emitted for GPU clip math
SetScissor,
}
// AS-BUILT NOTE: this sketch enum (byte-tagged, distinct opcode set — FillRect/StrokeRoundRect/PushClipPath/
// SetScissor never shipped) and the 8-byte DrawCmd header below do not match the real encoder. The shipped
// DrawOp (src/FluentGpu.Engine/Render/DrawList.cs) is `enum DrawOp : int`, and the wire format has NO header
// struct at all — a 4-byte int op tag is written directly, immediately followed by the fixed-size POD
// payload for that op (no Flags/PayloadSz/reserved fields). FillPath/StrokePath ARE now built, as 19/20 on
// that int-tagged enum. See gpu-renderer.md §3.1/§5 and scene-memory.md §4.1 (the encoding framework's
// owning doc, which retains the same design-sketch print) for the as-built shape and the full opcode list.
// 8-byte header; payloads are separate fixed-size POD structs, arena-adjacent.
[StructLayout(LayoutKind.Sequential)]
public struct DrawCmd
{
public DrawOp Op; // 1
public byte Flags; // 1 (e.g. AA mode, premultiplied)
public ushort PayloadSz;// 2 (bytes following header)
public int SortKey; // 4 (layer/z + pipeline class for batch sorting)
}
3.2 Representative payloads (all blittable, handle/offset references only)
[StructLayout(LayoutKind.Sequential)]
public struct FillRoundRectCmd // 56B
{ public LayoutRect Rect; public CornerRadius4 Radii; public BrushHandle Brush; public float AA; }
[StructLayout(LayoutKind.Sequential)]
public struct DrawGlyphRunCmd // glyphs already shaped+atlased
{ public int GlyphRunRef; // index into GlyphRunTable (StringId-free; ids+positions+atlas page)
public BrushHandle Brush; // text color
public float BaselineX, BaselineY; }
public struct FillPathCmd
{ public int PathGeomRef; // tessellated vertex span in the frame's vertex arena
public BrushHandle Brush; public byte FillRule; }
public struct PushLayerCmd
{ public float Opacity; public int EffectChainRef; /* -1 none */ public LayoutRect Bounds; }
public struct PushClipRectCmd { public LayoutRect Rect; public CornerRadius4 Radii; }
public struct DrawImageCmd { public TextureHandle Tex; public LayoutRect Dst; public LayoutRect SrcUV; public float Opacity; }
3.3 Reference model (zero embedded heap data)
- Brushes →
BrushHandleintoSceneStore-adjacentBrushTable(aSlabAllocator<BrushData>).BrushDatais a tagged union POD:{ BrushKind kind; ColorF solid; GradientRef grad; TextureHandle img; }. Gradients store stops in a side slab; referenced byGradientRef(offset+count). No GC. - Clips →
ClipHandleintoClipTable(SlabAllocator<ClipData>: rect+radii, or PathGeomRef). - GlyphRuns →
GlyphRunRefint index into the frameGlyphRunTable(built during render-walk from cached shaping; see §5). Contains:FontFaceHandle, float fontSize, AtlasPage, Span<PackedGlyph>wherePackedGlyph { ushort atlasX, atlasY, w, h; float advanceX, offsetX, offsetY; }. - Paths →
PathGeomRefindex into the frame vertex arena (tessellated triangles) OR a retainedPathGeomslab entry if the geometry is stable across frames (realization cache). - Effects →
EffectChainRefintoEffectTable(blur radius, drop-shadow params, backdrop kind).
3.4 Zero-alloc rebuild-vs-incremental story
Per frame:
1. arena.Reset() // O(1), reclaims all last-frame command bytes
2. For each ROOT LAYER:
if (layer.RootNode flags has SubtreeDirty | PaintDirty)
RecordLayer(layer) // walk subtree, append DrawCmds into arena
layer.CommandSpan = (markStart..arena.head) // remember span
else
// CLEAN: copy retained command bytes? NO — instead keep last frame's arena alive.
Refinement (the actual contract): we keep two arenas, double-buffered (_front, _back).
Clean layers' command spans are memcpy'd from back→front arena (a single Buffer.MemoryCopy,
no per-command work, no allocation — handles/offsets inside remain valid because tables are
retained slabs, not arena data). Dirty layers are re-recorded fresh into the front arena. At
frame end, swap. This gives:
- Zero managed allocation per frame (arenas are pre-grown
byte[], reused). - Incremental cost ∝ dirty subtree size + a bulk copy of clean spans (cache-friendly, no branching).
- Full rebuild = special case where every layer is dirty (first frame, resize, theme change).
GPU upload: the batcher walks the final front-arena command stream once, sorts by SortKey,
coalesces FillRoundRect/DrawGlyphRun into instanced quad batches (one instance buffer per
pipeline class), and emits RHI draw calls. Instance data (per-quad: rect, radii, brush params,
atlas UV) is written into a BufferHandle-backed upload ring (§4).
4. PAL + RHI SEAM (FluentGpu.Pal, FluentGpu.Rhi)
Portability rule: the
Seams/PalandSeams/Rhifolders (namespacesFluentGpu.Pal/FluentGpu.Rhi) are interface-only (+ POD descs); the OS backendFluentGpu.Windows(Pal/ / D3D12/) holds the reference impls. Engine code (Scene, Render, Layout, Reconciler) depends ONLY on the interfaces. A Metal/CoreText backend later implements the SAME interfaces with zero engine changes.
4.1 PAL — platform/OS seam
namespace FluentGpu.Pal;
public interface IPlatformApp // app/process lifetime + factory
{
IPlatformWindow CreateWindow(in WindowDesc desc);
IPlatformAppLoop CreateAppLoop();
IClipboard Clipboard { get; }
double GetDisplayScale(IPlatformWindow w); // DPI; portable concept
}
public interface IPlatformWindow
{
nuint NativeHandle { get; } // HWND on Win; NSView* on mac. Opaque to engine.
Size2 ClientSize { get; }
double Scale { get; }
event Action<Size2> Resized;
event Action<InputEvent> Input; // unified InputEvent (see §6)
event Action Closed;
void SetTitle(ReadOnlySpan<char> t);
void Invalidate(); // request a frame
}
public interface IPlatformAppLoop // message pump / run loop
{
// Drives frames; calls back the registered frame callback at vsync cadence.
void Run(Action<FrameTime> onFrame);
void RequestStop();
void Post(Action work); // marshal to UI thread (Reactor's dispatcher analog)
bool HasThreadAccess { get; }
}
public interface IClipboard
{ bool TryGetText(out string text); void SetText(ReadOnlySpan<char> text); /* + data formats */ }
public interface IImeSession // composition/candidate window
{ void SetCompositionRect(in LayoutRect caretRect); void Enable(bool on);
event Action<ImeCompositionEvent> Composition; }
| PAL piece | Windows-specific impl | Portable contract |
|---|---|---|
IPlatformWindow | HWND, RegisterClassEx, WndProc | size/scale/input/handle |
IPlatformAppLoop | GetMessage/PeekMessage, DispatcherQueue-free custom pump | run/post/stop |
IClipboard | OpenClipboard/GetClipboardData | text + format blobs |
IImeSession | ITfThreadMgr / WM_IME_* | composition rect + events |
| DPI | GetDpiForWindow | double Scale |
Windows-specific (NOT in portable interface, lives in FluentGpu.Windows Pal/): HWND, WndProc, raw WM_*,
TSF, win32 cursor/caret. Everything the engine sees is the portable InputEvent/Size2/scale.
4.2 RHI — render hardware seam (graphics pipeline; D3D12 reference)
Designed graphics-first (vertex+pixel+swapchain+present), which ComputeSharp does NOT provide — ComputeSharp seeds only the COM bindings +
ComPtr<T>+ device/queue/fence/pool patterns. RHI is a hand-rolled graphics layer over those bindings.
namespace FluentGpu.Rhi;
public interface IGpuDevice : IDisposable
{
ISwapchain CreateSwapchain(in SwapchainDesc d, IPlatformWindow window);
IGpuBuffer CreateBuffer(in BufferDesc d); // vertex/index/instance/constant/upload
ITexture CreateTexture(in TextureDesc d); // atlas pages, layer RTs, images
IPipeline CreatePipeline(in GraphicsPipelineDesc d);// PSO (vs+ps+blend+raster+rootsig)
IShaderModule CreateShader(ReadOnlySpan<byte> dxil); // precompiled bytecode (AOT: embedded)
ICommandEncoder BeginFrame(); // rents a command list from pool
void Submit(ICommandEncoder enc); // execute + signal fence
void WaitIdle();
bool IsDeviceLost { get; } // triggers resource recreation
GpuLimits Limits { get; }
}
public interface ISwapchain : IDisposable
{
ITexture AcquireBackbuffer(); // current RT
void Present(PresentMode mode); // DXGI Present → DComp commit on Windows
void Resize(Size2 px);
Size2 Size { get; }
}
public interface ICommandEncoder // records ONE frame's GPU work; not retained
{
void BeginRenderPass(ITexture target, in ClearValue clear, in Viewport vp);
void SetPipeline(IPipeline p);
void SetScissor(in RectI r);
void BindVertexBuffer(IGpuBuffer vb, int stride);
void BindInstanceBuffer(IGpuBuffer ib, int stride);
void BindIndexBuffer(IGpuBuffer ib, IndexFormat fmt);
void BindConstants(int slot, IGpuBuffer cb);
void BindTexture(int slot, ITexture t); // glyph atlas, image, layer-as-input
void DrawInstanced(int vtxPerInst, int instCount, int baseVtx, int baseInst);
void EndRenderPass();
// layer compositing handled as render-pass-to-texture then a textured-quad draw.
}
public interface IGpuBuffer : IDisposable
{ BufferKind Kind { get; } int SizeBytes { get; }
Span<byte> Map(); // upload-heap ring; no per-frame alloc
void Unmap(); }
public interface ITexture : IDisposable
{ Size2 Size { get; } TextureFormat Format { get; } bool IsRenderTarget { get; } }
public interface IPipeline : IDisposable { } // opaque PSO handle
public interface IShaderModule : IDisposable { }
| RHI concept | D3D12 reference (Windows D3D12/) | Metal later (Windows.Mac Metal/) |
|---|---|---|
IGpuDevice | ID3D12Device + queues + fences (ComputeSharp pattern) | MTLDevice |
ISwapchain | IDXGISwapChain3 + IDCompositionTarget/Visual present | CAMetalLayer |
ICommandEncoder | ID3D12GraphicsCommandList (pooled per ComputeSharp) | MTLRenderCommandEncoder |
IGpuBuffer | ID3D12Resource (upload/default heap) + D3D12MA optional | MTLBuffer |
ITexture | ID3D12Resource + RTV/SRV descriptor (NEW vs ComputeSharp's SRV/UAV-only) | MTLTexture |
IPipeline | ID3D12PipelineState + ID3D12RootSignature | MTLRenderPipelineState |
IShaderModule | DXIL bytecode (embedded at build; no runtime fxc) | MSL/metallib |
| present compose | DirectComposition (IDCompositionDevice, flat C API) | CoreAnimation |
Windows-specific (in FluentGpu.Windows D3D12/ / Pal/ only): all ComPtr<T>, raw COM vtables, DXGI,
D3D12, DComp (NEW binding we author), DWrite (NEW, in the DirectWrite/ impl). RHI/PAL interfaces above
contain ZERO COM types — only POD descs, handles, spans. This is the swap line.
Seed-from-ComputeSharp (vendored into FluentGpu.Windows D3D12/ + Interop/): ComPtr<T>, DXGI + D3D12 bindings, command-list
pool, fence/device-lost pattern, ILLink substitutions, source-gen scaffolding. NEW (author): RTV/DSV
descriptor mgmt, GraphicsPipelineDesc→PSO, vertex/instance/index buffers, swapchain+DComp present.
5. TEXT SEAM (seam FluentGpu.Text = Engine Seams/Text/, impl FluentGpu.Windows DirectWrite/)
All text is shaped+rasterized into a GPU glyph atlas; the paint path never sees
string. DirectWrite implements the seam on Windows; CoreText implements the SAME seam on macOS later.
namespace FluentGpu.Text;
public interface IFontSystem : IDisposable
{
FontFaceHandle GetFace(StringId family, FontWeight w, FontStyle s, FontStretch st);
FontFaceHandle GetFallbackFace(uint codepoint, in FontQuery q); // fallback chain
IFontFace Resolve(FontFaceHandle h);
ITextShaper Shaper { get; }
IGlyphRasterizer Rasterizer { get; }
IGlyphAtlas Atlas { get; }
}
public interface IFontFace
{ FontFaceHandle Handle { get; } FontMetrics Metrics { get; } // ascent/descent/lineGap/unitsPerEm
bool TryGetGlyphIndex(uint codepoint, out ushort glyphId); }
// Shaping: text + run props -> shaped glyphs. Caller owns output buffers (span, zero-alloc).
public interface ITextShaper
{
void Shape(ReadOnlySpan<char> text, in ShapeParams p, ref ShapedRunBuilder outRun);
// ShapeParams: FontFaceHandle, fontSizePx, ScriptTag, Direction (LTR/RTL), language, features.
}
// ShapedRunBuilder fills caller-provided spans:
public ref struct ShapedRunBuilder
{
public Span<ushort> GlyphIds; // out count via Written
public Span<float> Advances; // per-glyph advance (x)
public Span<float> OffsetsX; // per-glyph x offset
public Span<float> OffsetsY; // per-glyph y offset
public Span<int> Clusters; // glyph→source char-cluster map (caret/selection/hit-test)
public int Written;
}
// Rasterization: glyph -> alpha (or RGB ClearType) mask. Caller passes target span or atlas page.
public interface IGlyphRasterizer
{
GlyphRasterResult Rasterize(FontFaceHandle face, ushort glyphId, float sizePx,
in SubpixelOffset sp, GlyphRenderMode mode, Span<byte> dst);
// mode: Alpha8 (grayscale AA) | ClearType (RGB ▽ subpixel) | SDF (scalable, for transforms)
}
public interface IGlyphAtlas
{
// Returns atlas placement for a (face, glyph, size, subpixel, mode) key; rasterizes on miss.
bool TryGetOrAdd(in GlyphKey key, out GlyphSlot slot); // slot: AtlasPage, x,y,w,h, bearing
TextureHandle Page(int index); // GPU texture for binding
void Compact(); // evict LRU when full (edge, not hot)
}
public readonly struct GlyphKey // 24B, hashable; the realization cache key
{ public FontFaceHandle Face; public ushort GlyphId; public ushort SizeQ; // quantized px*4
public byte SubpixelBucket; public GlyphRenderMode Mode; }
public struct GlyphSlot { public int Page; public ushort X,Y,W,H; public float BearingX, BearingY; }
public struct PackedGlyph { public ushort AtlasX,AtlasY,W,H; public float AdvanceX, OffX, OffY; }
Data flow (text node → DrawGlyphRun):
TextElement.Content (string, edge)
└─ reconcile → SceneStore node (VisualKind=Text, PayloadRef→TextRunSpec{StringId,FontFaceHandle,size})
└─ layout MEASURE: ITextShaper.Shape → advances → line metrics → node Bounds
└─ render-walk: for each shaped glyph, IGlyphAtlas.TryGetOrAdd → PackedGlyph[]
└─ append DrawGlyphRunCmd { GlyphRunRef → GlyphRunTable, Brush, baseline }
└─ batcher: instanced textured quads sampling atlas page
| Seam method | Windows (DirectWrite/) | macOS later (CoreText/) |
|---|---|---|
IFontSystem | IDWriteFactory, IDWriteFontCollection | CTFontCollection |
IFontFace | IDWriteFontFace | CTFont |
ITextShaper | IDWriteTextAnalyzer (script/bidi/shaping) | CTLine/CTRun |
IGlyphRasterizer | IDWriteGlyphRunAnalysis → alpha/ClearType | CGContext glyph draw |
IGlyphAtlas | engine-owned (portable); uploads via RHI | same (portable) |
DWrite + DComp COM bindings are NOT in ComputeSharp → authored fresh in the FluentGpu.Windows
DirectWrite/ + D3D12/ folders using the ComputeSharp ComPtr<T> + hand-vtable IComObject pattern on the per-frame
hot path; cold/warm COM (DWrite setup, UIA, TSF, OLE) uses source-generated
[GeneratedComInterface]/[GeneratedComClass] (canonical COM ruling: dotnet10-csharp14-zero-alloc.md §4).
6. FRAME LIFECYCLE & THREADING
⊳ Canonical threading model. The single-thread decision below is build-order step 1 (
hardened-v1-plan.md§6), not the shipping topology. The canonical model is the render-thread seam —hardened-v1-plan.md§2 +subsystems/threading-render-seam.md§14 (seeSPEC-INDEX.md): record/batch/submit/present move to a dedicated render thread fed by an immutableSceneFramesnapshot, gated by a consume-basedQUARANTINE = RenderInFlightDepth. The DrawList POD stream is the hand-off, exactly as designed here.
Decision: Single UI/render thread for v1 (reconcile + layout + record + submit all on the
UI thread); present completion + vsync signaling on the OS compositor thread (DComp, out of our control). Optional future: move render-walk+record+submit to a dedicated render thread fed by an immutable snapshot. Designed so the threading boundary is the DrawList (already immutable POD).
Justification: Reactor's proven model is UI-thread-affine with an async coalescing dispatcher; SceneStore mutation + Yoga layout are not thread-safe by design (mutable SoA). The DrawList POD stream is the clean hand-off point IF we later pipeline. Keeping v1 single-threaded removes data-race surface, matches NativeAOT/zero-alloc goals, and still hits 60fps for UI workloads.
6.1 Ordered phases (one frame)
UI THREAD (frame N) OS COMPOSITOR THREAD
┌──────────────────────────────────────────────┐
│ 0. PUMP IPlatformAppLoop drains WM_* │
│ → InputEvent queue │
│ 1. INPUT dispatch InputEvents: │
│ DISPATCH hit-test SceneStore (Bounds+ │
│ Clip+Flags.Hittable), route │
│ to handlers; gestures FSM │
│ → user delegates set state │
│ 2. HOOK FLUSH apply queued UseState setters; │
│ run UseReducer; mark components │
│ dirty (coalesced, CAS-guarded) │
│ 3. RENDER for each dirty Component: │
│ Component.Render() → Element │
│ (immutable record tree, edge) │
│ 4. RECONCILE diff old vs new Element tree; │
│ patch SceneStore via │
│ ISceneBackend (Mount/Update/ │
│ Unmount); ChildReconciler LIS; │
│ sets NodeFlags dirty bits │
│ 5. LAYOUT Yoga measure/arrange over dirty │
│ subtrees (LayoutDirty scope); │
│ text measure via ITextShaper; │
│ writes Bounds[] │
│ 6. ANIMATION advance AnimTrack timelines by │
│ FrameTime.Delta; write animated │
│ Opacity/Transform/Bounds; │
│ mark Paint/TransformDirty │
│ 7. RECORD render-walk dirty layers → │
│ DrawList (arena); clean layers │
│ memcpy'd from back arena │
│ 8. BATCH sort by SortKey, build instance │
│ buffers, glyph atlas uploads │
│ 9. SUBMIT ICommandEncoder: render passes, │
│ draws → IGpuDevice.Submit ──────┼──► GPU executes
│10. PRESENT ISwapchain.Present ─────────────┼──► DXGI → DComp commit ──► vsync scanout
│11. EFFECTS run UseEffect bodies + pending │
│ FLUSH cleanups (AFTER paint, like │ (compositor signals next vsync,
│ React commit-then-effect) │ re-arms IPlatformAppLoop.onFrame)
│12. ARENA SWAP swap front/back command arenas; │
│ arena.Reset() │
└──────────────────────────────────────────────┘
Key ordering contracts (binding):
- Effects run AFTER present (phase 11), never mid-render — matches Reactor/React commit semantics; effect bodies that call setState schedule frame N+1 (coalesced), never re-enter frame N.
- Hook state flush (2) precedes Render (3); setters during Render/Reconcile/Layout are queued
for N+1 (reentrancy guard
_isRendering, Reactor pattern). - Layout (5) is dirty-scoped: only subtrees with
LayoutDirty/SubtreeDirtyre-measure; Yoga's generation-counter cache (confirmed inLayoutResults) validates the rest. - Animation (6) is between layout and record so animated transforms/opacity land in this frame's DrawList without a reconcile (composition-style independent animation — WinUI lesson).
- Render priority policy (Reactor
RenderPriorityPolicy): 16ms budget; if a frame overruns, next frame enqueued at lower priority to yield to input pump. No frame skipping; state changes coalesce.
6.2 Threading/affinity model
ThreadAffinity(Reactor pattern): all SceneStore/Component/Hook mutation asserts UI-thread.IPlatformAppLoop.Post(Action)marshals off-thread state changes (async effect completions, background data) onto the UI thread before phase 2.- The ONLY cross-thread surface is GPU submit→present (RHI owns its fence; engine never blocks on
GPU except
WaitIdleat resize/teardown).
6.3 InputEvent (portable, §4 PAL emits it)
public readonly struct InputEvent
{ public InputKind Kind; // PointerDown/Move/Up/Wheel, KeyDown/Up, Char, Focus, ...
public Point2 Position; // client px
public PointerId Pointer; public ModifierKeys Mods; public int Delta; public uint KeyOrChar;
public long TimestampUs; }
6.4 Hook dependency contract (zero-alloc, AOT)
No params object[]. Dependencies are a ReadOnlySpan<DepKey> where DepKey is a 16-byte tagged
value (kind + inline scalar bits, or interned StringId, or a Handle). Source generator emits
UseEffect(Action, ReadOnlySpan<DepKey>) overloads and a DepKey.From(...) set covering common
scalar types. RenderContext compares dep spans field-wise (no boxing). This supersedes Reactor's
params object[] (the one explicit divergence the explore reports flagged as AOT-hostile).
7. PROJECT / MODULE LAYOUT (4 libs + 4 satellites, acyclic, inward-only deps)
The repo is 4 libraries + 4 satellites = 8 projects (src/FluentGpu.slnx). The portable engine that
once spanned ~14 projects is now FOLDERS inside the single FluentGpu.Engine library (RootNamespace= FluentGpu; namespaces unchanged — FluentGpu.Scene, FluentGpu.Rhi, FluentGpu.Dsl, …). The boxes below
are those folders; the inward-only dependency direction is now a review-enforced folder/namespace
discipline inside Engine, except the one load-bearing edge that stays a real .csproj boundary:
Engine never references FluentGpu.Windows (the OS backend), so the backend stays swappable.
Dependency direction: arrows point to dependencies. NO cycles. The OS backend depends INWARD only.
FluentGpu.Engine (ONE library; the boxes are FOLDERS, namespaces verbatim)
─────────────────────────────────────────────────────────────────────────────────────
┌────────────────────────┐
│ Foundation/ │ Handle, allocators, StringInterner,
│ (no deps) │ math (Affine2D, LayoutRect, ColorF), spans
└────────────┬───────────┘
┌───────────────┬────────────┼─────────────┬───────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Seams/Rhi│ │ Seams/Pal│ │Seams/Text│ │ Scene/ │ │ Layout/ │ │ Input/ │
│ (iface) │ │ (iface) │ │ (seam) │ │ SceneStore│ │ Yoga port│ │ events, │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ hit-test │
│ │ │ │ │ └────┬─────┘
│ │ │ └──────┬───────┘ │
│ │ │ ▼ │
│ │ │ ┌──────────────┐ │
│ │ └───────────►│ Render/ │◄─────────────┘
│ │ │ DrawList, │ sees: Scene, Text(seam),
│ │ │ batcher │ Rhi(iface), Layout, Foundation
│ │ └──────┬───────┘
│ │ ▼
│ │ ┌──────────────┐
│ │ │ Reconciler/ │ sees: Scene, Render,
│ │ │ ISceneBackend│ Layout, Foundation, Dsl
│ │ └──────┬───────┘ (declares VirtualListEl)
│ │ ┌────────────┐ ┌─────┴────────┐ ┌──────────────┐
│ │ │ Hooks/ │ │ Dsl/ │ │ Animation/ │
│ │ │ RenderCtx │ │ Element recs,│ │ Curve, track │
│ │ └─────┬──────┘ │ factories, │ └──────┬───────┘
│ │ │ │ modifiers │ │
│ │ └────────┴──────┬───────┴─────────┘
│ │ ▼
│ │ ┌──────────────┐
│ │ │ Hosting/ │ app/window/run-loop wiring,
│ │ │ (composition │ frame lifecycle orchestrator;
│ │ │ root) │ sees ALL above + the seam ifaces
│ │ └──────┬───────┘ (+ Media/ and Headless/{Rhi,Pal,Text}/
│ │ │ test backends also live in Engine)
─────┼──────────────┼─────────────────────────────────┼───────────────────────────────────────────
│ │ │ bound at the composition root (WindowsApp)
▼ ▼ ▼
┌──────────────────────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ FluentGpu.Windows (the OS BACKEND; │ │ FluentGpu.Controls │ │ FluentGpu.WindowsApi│
│ refs Engine + 1 TerraFX package) │ │ SDK control kit; │ │ OS-services scaffold│
│ D3D12/ (ComPtr,DXGI,D3D12,DComp) │ │ refs Engine ONLY; │ │ (Notifications/ │
│ Pal/ (HWND,WndProc,TSF,clip) │ │ TerraFX-free │ │ Credentials/ │
│ DirectWrite/ · Uia/ · Wic/ · Interop/│ │ Button/Slider/Nav │ │ Packaging/ │
└──────────────────────────────────────┘ └────────────────────┘ │ Activation/) │
the OS backend implements the Seams/Pal/Rhi/Text interfaces; └────────────────────┘
Engine NEVER references it (compiler-enforced) — swappable wholesale.
Satellites: FluentGpu.SourceGen + FluentGpu.Interop.SourceGen (Roslyn analyzers; build-time only;
netstandard2.0 — cannot merge into the net10 libs), FluentGpu.VerticalSlice (AOT harness exe),
FluentGpu.WindowsApp (gallery exe / composition root — refs Engine+Controls+Windows).
Project list (trimmable, AOT):
FluentGpu.Engine— the portable engine core (RootNamespace=FluentGpu, no NuGet). One folder per former project, namespaces verbatim:Foundation/(primitives, allocators, math, interner — the root),Seams/Rhi/Seams/Pal/Seams/Text/(interface-only seams + POD descs/InputEvent/glyph-atlas POD),Scene/(SceneStore SoA + Brush/Clip/GlyphRun tables +VirtualLayout),Layout/(Yoga port + cache + text-measure bridge),Render/(DrawList encoding, render-walk, batcher),Dsl/(Element records, factories, modifiers — Reactor model verbatim),Hooks/(RenderContext, hook states, Context),Reconciler/(ISceneBackend, reconciler, ChildReconciler — declaresVirtualListEl),Animation/(Curve/Easing/AnimTrack),Input/(gesture FSM, focus, hit-test),Media/,Hosting/(composition root / frame loop), andHeadless/{Rhi,Pal,Text}/(the test backends; referenced by test hosts only).FluentGpu.Controls— the SDK controls layer (Button/IconButton/ToggleButton/Slider/ScrollBar/ NavigationView + Navigator/PageHost/Route/Nav + Repeater + theVirtualfactory +Icons). Refs Engine only; stays TerraFX-free. Acyclic:VirtualListElis declared inEngine(Reconciler/), soControls → Reconcileris one-way with no back-edge. Content owned bysubsystems/controls.md. (Aspirational looklessControlTemplate/ControlTheme/VisualStatekit is the stated future target; as-shipped Phase 0 is the composition-factory hoist + per-controlStylerecords — see that doc.)FluentGpu.Windows— the swappable Windows OS backend. Refs Engine + the oneTerraFX.Interop.Windowspackage. Folders:Interop/(vendored ComputeSharp bindings + DComp/DWrite/UIA/ WIC),Pal/(Win32 windowing/input/IME),D3D12/(GPU backend, ComputeSharp-seeded COM),DirectWrite/(DWrite shape+raster),Wic/(image codecs),Uia/(UIA bridge). Implements theSeams/*interfaces.FluentGpu.WindowsApi— empty scaffold for OS services (Notifications//Credentials//Packaging//Activation/); refs Engine. (MSIX packaging is app-side —.wapproj/packaging props — not here.)- Satellites:
FluentGpu.SourceGen(portable Roslyn analyzer: ElementTypeId table, DepKey overloads, HLSL→DXIL embed, property-dispatch; referenced by Engine) +FluentGpu.Interop.SourceGen(the COM-binding generator; referenced byFluentGpu.Windowsonly) — both netstandard2.0, build-time only.FluentGpu.VerticalSlice(AOT validation harness exe; refs Engine+Controls; closure stays TerraFX-free).FluentGpu.WindowsApp(WinExe gallery / composition root; refs Engine+Controls+Windows).
Cycle-prevention invariants (binding):
- Compiler-enforced:
FluentGpu.Enginereferences no OS backend —FluentGpu.Windowsis bound only at the composition root, so the backend swaps wholesale (macOS = a futureFluentGpu.Windows.Mac).FluentGpu.ControlsreferencesEngineonly and stays TerraFX-free. - Review-enforced (intra-
Enginefolder discipline):Render/sees theSeams/Rhi/Seams/Textinterfaces, never the OS impls (which live in a library it cannot reference). The OS-backend folders implement theSeams/Pal//Rhi//Textinterfaces and are bound only byHosting/. Dsl//Hooks/know nothing ofScene/Render/Rhi(programming-model layer is GPU-agnostic;Reconciler/is the only bridge from Element-world to SceneStore-world viaISceneBackend).Foundation/depends on nothing. Everything depends onFoundation/.
7.1 ISceneBackend — the reconciler↔scene bridge (replaces Reactor's IRenderBackend)
namespace FluentGpu.Reconciler;
public interface ISceneBackend
{
NodeHandle Mount(Element e, in MountContext ctx); // ctx = readonly ref struct
NodeHandle Update(Element old, Element @new, NodeHandle n, in UpdateContext ctx); // Null = patched in place
void Unmount(NodeHandle n);
void SetKey(NodeHandle n, StringId key);
IChildSlots Children(NodeHandle n); // Insert/Move/Replace/Remove by index → SceneStore topology
}
MountContext/UpdateContext are readonly ref struct (Reactor zero-alloc pattern). The Windows
backend is the only ISceneBackend impl in v1; it writes SceneStore columns directly. The
ChildReconciler LIS algorithm (Reactor, host-agnostic) and RenderContext hooks are reused verbatim.
CONTRACT SUMMARY (the load-bearing decisions specialists must honor)
Handle= 8B{u32 index, u32 gen}(kind on the typed wrapper's DEBUG assert, not in the bits — see §1.1); typed wrappers per kind. Handles on hot paths; GC refs only at edges.- Four allocators:
SlabAllocator<T>(versioned stable),ArenaAllocator(per-frame bump),ObjectPool<T>(edge GC recycle),HandleTable<T>(COM/GPU). Intrusive free list, gen-bump on alloc+free. - SceneStore is SoA; one generation/free-list spine; layout-input/result + paint columns co-located, indexed by NodeHandle.
- DrawList = flat POD
DrawCmd+payload stream in double-buffered arenas; brushes/clips/glyphs by handle/index; incremental via dirty-subtree re-record + memcpy of clean spans; zero per-frame managed alloc. - PAL + RHI are interface-only (Engine
Seams/); D3D12 + Win32 + DComp are reference impls in the swappableFluentGpu.Windowsbackend (D3D12/, Pal/, Interop/); DrawList POD is the swap line. - Text seam: IFontSystem/IFontFace/ITextShaper(→spans)/IGlyphRasterizer/IGlyphAtlas; DWrite now, CoreText later; no
stringon paint path. - Frame phases fixed (input→hookflush→render→reconcile→layout→anim→record→batch→submit→present→effects→arenaswap); single UI thread v1; effects after present.
- 4 libraries + 4 satellites = 8 projects (
src/FluentGpu.slnx): the portable engine is folders inFluentGpu.Engine, plusFluentGpu.Controls, the swappableFluentGpu.Windowsbackend,FluentGpu.WindowsApi, and the SourceGen/Interop.SourceGen analyzers + VerticalSlice/WindowsApp exes. Acyclic;Enginenever referencesWindows(compiler-enforced);Foundation/is the root with no deps. (Earlier passes counted "27 projects"/"18 assemblies" before the consolidation ; restated witharchitecture-spec.md§3 anddesign/README.md.FluentGpu.Controlssits aboveReconciler/— see §7.) - Hook deps =
ReadOnlySpan<DepKey>(notparams object[]); source-gen'd. Reactor Element/Hooks/ChildReconciler reused verbatim; reconciler bridges viaISceneBackend. - ComputeSharp: vendor
ComPtr<T>+ DXGI/D3D12 bindings + pools + source-gen + AOT config intoFluentGpu.Windows(D3D12/ + Interop/); author DWrite + DComp + graphics-PSO/swapchain ourselves; do NOT take D2D1 for core; D3D12MA optional.