Mission Vishwakarma Vishwakarma Download Roadmap Pricing

Graphics API

API-choice

API stands for Application Programming Interface. Basically a set of conventions / standards, compute engineers have come up with to write the software into. We need to pick sides here.

Choosing a graphics API to base our software upon is one of the most fundamental design we are going to make. For all practical purpose (read sunk man-month reasons) once we choose an API we will be “stuck” with it forever. This is one of the topics where I intentionally choose Performance over Development velocity. We could speed up software development by choosing a ready built engines such as open source ImGUI, GoDot, QT etc. Though, “engines” isolate the software from underlying APIs, we may get constrained by the engine itself at some point in future. We rule out closed source engines such as Unity and Unreal Engine for political reasons ! Fun Fact: This attitude is sometimes called NIH Syndrome i.e. Not-Invented-Here Syndrome. ;) So coming back to lower level APIs, we have limited APIs on each of the Operating Systems.

On windows, we have DirectX 9 / 10 / 11 / 12, OpenGL and Vulkan. OpenGL has been deprecated long back and newer graphics features such as Ray Tracing aren’t supported by it. Vulkan is generally a 2nd class citizen in windows compared to DirectX. Hence we choose the most modern flavor DirectX12. Remember, DirectX12 itself was 1st released in 2014. Hence setting it as a baseline requirement for our software is a reasonable decision. Hence DirectX12 is our ONLY graphics API for Windows Operating System. We support Windows 10 and 11 both for now (2025). This covers perhaps 90% of our target worldwide users. We also presume support of Heap_Tier_2 inside DirectX12. Note: Heap_Tier_2 started appearing in 2015/2016 timeline. What ShaderModel Level ? To be figured out. If you are feeling over-hyped to get deep down, read the 1st ( of 4 ) tutorial on DirectX12 here. It is ~100 pages !

Next most “market-share” operating system is MacOS on Apple Devices. In Apple world, Metal APIs are the only recommended ( non-deprecated ) APIs, hence we go with Metal. Even Vulkan works though a translation layer such as MoltenVK etc. Still for performance and 1st party support, we choose Metal API. Mac Graphics / Metal API shall also be partially reusable on iPhone / iPad devices, since they also have Metal as the preferred API.

Next up is Linux ( Ubuntu ) Operating System. This being open source operating system, open standard Vulkan is preferred here. We want our software to be available on even free operating systems. Hence we must have a Vulkan based US as well. Another reason for keeping this Vulkan interface is due to overlap with Android Mobile Operating System. For Android Phones, we have only 2 options, deprecated OpenGL or modern Vulkan. Hence we choose Vulkan. The within last 10 year version ! i.e. Vulkan 1.1.

Above 3 APIs are for desktop application. Next up is Browser based engine. Here upcoming ( as on 2025) API named WebGPU is chosen-one. This is supported by all major web-browser vendors i.e. Google Chrome, Apple Safari and Mozilla.

Hardware baseline and install-time checks

TiledResourcesTier >= 1 (buffers) is a baseline requirement, to be verified at install time alongside the existing Heap Tier 2 requirement; InitD3DDeviceOnly queries and logs the tier as a startup backstop.

The Memory reservation is sized from the hardware rather than from a compile-time constant, which is what turns “capability varies with hardware” into behaviour instead of a caveat.

Having made above decisions, we have to be realistic about our core-engineering-degree-holder software developers. We can’t expect a chemical / civil / electrical / instrumentation / mechanical background people/developers to be familiar with such deep computer science concepts. Hence we structure our code in sort of mini-engine (NIH?), where adding a new UI element doesn’t involve fiddling deep down in graphics APIs. This will be sorted out progressively as our software matures.

At startup, pick up the GPU with the highest VRAM. All rendering happens on that one device only β€” exactly one device is supported for rendering. The OS may still send the finished frame to a monitor connected to another / integrated GPU.

Our software installer will verify that all the relevant APIs are present on the system, before installation. So this way, inside application, we don’t check every time whether a particular feature is supported by available hardware. Unless the initial installed-hardware itself changes. By default this check shouldn’t take more than a few micro-seconds during application startups.

Shaders are compiled against Shader Model 6.0 β€” what the project’s FxCompile items set for both configurations.

Architecture

The four groups and two layers(control flow)

The rendering code is organized into three clearly separated groups, plus the two layers they sit on:

Group Responsibility One-line contract
Scene3D Renderer Render one specific 3D scene into an already-bound render target “Given a Scene3D container + camera + viewport, record its draw commands”
Page2D Renderer Render one specific 2D page into an already-bound render target “Given a Page2D container + pan/zoom view + viewport, record its draw commands”
Compositor Decide which views / active tabs appear in which window, stitch the results, present Owns render threads, windows, swap chains, RTT→backbuffer copy, present, migration/resize
UI Overlay Ribbon / tab bands / data tree / property pane, always drawn on top The UserInterface* module β€” its own group, independent of the two renderers
GPU Foundation Device, queues, copy thread, VRAM paging, upload ring Shared singleton (ΰ€Άΰ€‚ΰ€•ΰ€°) serving both renderers β€” neither 3D nor 2D specific

Platform separation and file naming

Platform-agnostic code lives in one place (headers where possible). Each platform (DirectX12/Windows now; Vulkan/Linux+Android and Metal/iOS+Mac later) provides its own definitions of the same function names, selected at build time by compiling exactly one platform .cpp per module. No virtual interfaces, no #ifdef forests. The naming convention: <Module>.h/.cpp is platform-agnostic; <Module>-<Platform>.h/.cpp is per-platform (e.g. RenderPage2D.cpp vs RenderPage2D-DirectX12.cpp), and GPUPlatformSelector.h is the only file that names a platform header.

Threading model

  • Separate render threads (one per monitor) and a single copy thread. The copy thread is the ringmaster of VRAM!
  • Each render thread is in VSync with its monitor’s unique refresh rate, and has its own render queue (e.g. one at 60 Hz, one at 144 Hz, one at 30 Hz), command queue, allocator and command list.
  • We use ExecuteIndirect with a start-vertex location instead of DrawIndexedInstanced per object.

One render thread per monitor drives the whole flow top-to-bottom:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ COMPOSITOR - one render thread  β”‚
β”‚ per monitor:                    β”‚
β”‚   pick window -> tab -> view    β”‚
β”‚   bind that window's RTT        β”‚
β”‚   dispatch to a renderer  ↓     β”‚
β”‚   draw UI overlay on top        β”‚
β”‚   RTT -> backbuffer -> present  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β–Ό           β–Ό           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Scene3D β”‚  β”‚Page2D  β”‚  β”‚  UI    β”‚
β”‚camera  β”‚  β”‚pan/zoomβ”‚  β”‚ribbon  β”‚
β”‚pick    β”‚  β”‚tools   β”‚  β”‚bands   β”‚
β”‚cube    β”‚  β”‚select  β”‚  β”‚tree    β”‚
β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ GPU FOUNDATION (shared)         β”‚
β”‚   device, queues, fences        β”‚
β”‚   VRAM pages + upload ring      β”‚
β”‚   copy thread = sole VRAM       β”‚
β”‚   writer                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key boundary rule: the two renderers (Scene3D, Page2D) never touch a swap chain, never present, and never decide what to draw. They receive a container memoryId, a view state (camera or pan/zoom) and a viewport, and record commands into a command list handed to them by the compositor. The compositor never touches geometry pages or PSOs directly. The UI overlay is always recorded last, by the compositor, on top of whichever renderer ran.

Terminology

Term Lifetime / responsibility
Persistent engineering object ID Durable file/project identity, resolved again after loading a project.
memoryID 64-bit, process-local, monotonically assigned CPU identity (MemoryID::next). Never reused during one process lifetime; remains the engineering-thread ↔ copy-thread command key.
gpuInstanceIndex Dense 32-bit renderer identity. Stable for the object’s whole GPU lifetime β€” unchanged when the object is modified, moves geometry page, or is relocated by defragmentation. Reusable only after fence-safe retirement, paired with a CPU-side generation counter.
instanceSlot Physical location of the object’s 64-byte record in the instance arena. Changes on every transform edit. Known only to the copy thread and the redirect table.
SubTab Content selection: a set of Scene3D containers, or a set of Page2D containers, plus filter and display state.
Viewport Camera, input ownership, render-target rectangle, presentation state. References a SubTab; several Viewports may show one SubTab with different cameras.

No sane user is expected to work simultaneously on more than 64 views! It’s an engineering judgement. Hence 64 SubTabs per tab is the design limit, represented directly by a 64-bit membership word (uint2 in shaders). MV_MAX_SUBTABS is also 64, deliberately. This is compile time ascertained and must not change. Too many graphics engine internals depend on this variable being exactly 64. Equalizing them makes every open SubTab addressable.

Every tab owns its own stores and snapshots because tabs are independent. Device, copy queue, upload ring, heap arena, memory budget and fence retirement stay global, so the application keeps one coherent VRAM budget.

The four Invariants

These four rules are load-bearing. Every step below preserves them.

  1. Geometry pages and instance data are never associated. An object’s geometry page is a movable location; its gpuInstanceIndex is stable identity. Tying them together would mean a transform edit clones geometry, and a page change breaks identity.
  2. Nothing a published frame can read is ever mutated β€” with exactly one exception: a single naturally-aligned store of ≀ 8 bytes whose old and new values are both valid. Reads are then old-or-new, never torn. This is a hardware guarantee, not a D3D12 API guarantee; D3D12 is silent on the question and its debug layer will not flag the race either way.
  3. Everything shared per-tab retires on min(all monitor render fences). Render threads run at their monitors’ refresh rates and never coordinate, so no fixed frame depth is correct for a shared resource.
  4. Per-window / per-Viewport resources have exactly one owning render thread, so classic N-deep buffering against that thread’s own fence is correct for them.

Why instance data is not paged alongside geometry: a 4 MB geometry page holds anywhere between ~224 objects (a 36Γ—18 sphere) and ~9,200 (a cuboid), so the two page systems can never line up; objects change geometry page on modify and on defragmentation, which would destroy index stability, and the GPU pick id is the instance identity; and a per-page instance buffer forces one ExecuteIndirect per page forever, forfeiting the single-call-per-Viewport design.

The four workload budgets

One tab holds up to 10 million simultaneously GPU-resident Scene3D engineering objects, presented through up to 64 independently filtered SubTabs. The objective is not to redraw 10 million objects in every SubTab at 60 Hz; it is to build the identity, memory and command-generation architecture that makes that scale possible, then progressively reduce each SubTab to what its camera needs.

The figure is aspirational, and it counts engineering objects β€” not the graphics objects the renderer actually addresses. The two are not the same: one engineering object can emit several graphics objects (a pipe as inner radius cylinder, outer radius cylinder plus end caps, an object plus its centerline or its stress contour), each with its own gpuInstanceIndex, transform, appearance and visibility. MV_MAX_INSTANCES_PER_TAB bounds the graphics count, so the engineering count it supports is that figure divided by the average parts per object.

What a given machine actually achieves varies with its hardware, and the goal is to maximise it rather than to guarantee a number: the arena is reserved GPU address space committed on demand, the registry is committed host RAM at 40 bytes per graphics object, and geometry is whatever the model needs. Two hardware limits bound the reservation itself and belong in the same install-time check as Heap Tier 2 and TiledResourcesTier β€” MaxGPUVirtualAddressBitsPerResource, which can be as low as 31 bits (2 GB) on the lowest tier and therefore caps a single tab’s arena at ~33M records, and MaxGPUVirtualAddressBitsPerProcess across all open tabs. Sizing the reservation from the queried value rather than from a compile-time constant is what turns “varies with hardware” into behaviour instead of a caveat.

Every decision below is driven by four concrete workloads. They are the acceptance criteria:

Workload Must cost Must not cost
Insert ~100 objects into a live 10M scene Clone the 1–2 append-target geometry pages plus their argument rebuild Anything proportional to 10M
Move 10–1000 objects scattered anywhere in the 10M One new 64-byte instance record and one 4-byte redirect write per object Any geometry page clone. 1000 objects spread over 1000 pages would be ~4 GB of clone traffic
Hide / show any subset; 64 SubTabs showing different subsets One atomic mask write per object Any clone, any argument rebuild, any upload
Several monitors at different refresh rates, one render thread each Nothing extra Any scheme needing a fixed number of frames in flight

The insert path already meets its budget: today’s RCU clone is O(pages touched), not O(scene). Move and hide are what drive the rest of this design.

Miscellaneous specification

  • A uniform 64-bit object ID, unique across all objects in the entire process memory. The renderer maps exactly one gpuInstanceIndex to one such ID; multiple simultaneous geometry variations per object make the key (memoryID, variation), packed into the id’s free high 8 bits at no storage cost β€” see Geometry variations.
  • We expect roughly 1000–5000 draw calls per frame at present. That budget is what the one-ExecuteIndirect-per-page model was sized for; the shipped path meets it with one compacted ExecuteIndirect per Viewport regardless of page count (see GPU command compaction), which is what makes the budget survive to 10M objects.
  • Multiple partially-overlapping windows, each independently resizable / maximize / minimize.
  • The lowest distance between an object and all the different view-camera positions is used by the logic threads to decide the Level of Detail.
  • A mechanism to manage memory over-pressure, signalling the logic threads to reduce level of detail within some distance.
  • The GPU memory manager is a singleton β€” exactly one instance manages all GPU memory.

On a desktop PC with two discrete GPUs and one integrated GPU, each driving one active monitor, we still use exactly one device for rendering all monitors. Windows 10/11 WDDM supports heterogeneous multi-adapter: when a window moves, DWM composites surfaces and copies the frame across adapters if needed. This works but is slow, since every such frame must traverse the PCIe bus.

Identity and per-tab stores

Object identity: memoryID, gpuInstanceIndex, instanceSlot

Identity is separated from location. The copy thread is the sole owner of a per-tab registry:

memoryID          -> gpuInstanceIndex     the only hash map
gpuInstanceIndex  -> { memoryID, GeometryLocation(page, pageSlot),
                       instanceSlot, worldCentre }                 flat array

Only the first is a hash map; because the index is dense, everything else is a flat array. This matters concretely β€” three unordered_maps at 10M entries would cost well over a gigabyte of CPU node overhead, and the single global objectLocation map this replaced was already on that trajectory.

As implemented, the reverse direction is one array of 40-byte entries rather than the three separate arrays sketched above: the same bytes, one commit path, and one cache line per lookup β€” which is exactly what a pick does. The array is a VirtualMemory reservation with 64 KB blocks committed as the index space grows, mirroring the GPU arena. That is not only about sparseness: the render threads read entries, so the base address must never move under them, which a std::vector cannot promise. The worldCentre field is transform shadow.

The gpuInstanceIndex -> generation array is deliberately not built yet. Nothing caches a GPU handle to validate against it, and the zombie interval below is already enforced by the fence-gated free list. Add the counter with the first handle cache.

gpuInstanceIndex is identity, never a page location: it does not change when the object is modified or when an RCU clone moves it between GeometryPages. The clone step therefore re-points the registry with a direct array write per live record β€” the identity lookup this used to need is gone. Engineering code keeps addressing objects by memoryID and never learns page coordinates or GPU indices. A transient handle may be cached in META_DATA but is never persisted to disk.

The GPU pick id is now gpuInstanceIndex + 1 (it was matrixIndex + 1), so a pick resolves in O(1) through the registry instead of a linear scan over every object of every page. IndirectCommand carries gpuInstanceIndex in place of matrixIndex and stays 24 bytes.

Deletion uses a zombie interval:

active -> absent from newly published snapshots -> zombie -> all old frames retire -> reusable index

Only once every snapshot that could reference the index is past its retire fence may the index return to the free list. This prevents a stale command or an old page from addressing a newly assigned object. Mechanically it is the same holding list and the same safeRetireFence sweep that Fence-gated retirement built for matrix slots; what changed is that MODIFY no longer vacates anything, because identity survives a modify. Only REMOVE starts a zombie interval.

historical design improvements With the index stable but no redirect table yet, the arena was addressed directly by gpuInstanceIndex, so a MODIFY rewrote the record in place. A frame still drawing the pre-publish snapshot read that index while the copy queue overwrote it β€” one frame of a stale, or if the 64-byte copy were observed mid-write garbled, transform. That is invariant 2’s hazard, and it was accepted for exactly one step because interactive 3D drag does not exist yet, so MODIFY fires at property-edit rates. New redirect mechanism removed it: a fresh instanceSlot plus one aligned 4-byte flip.

The per-tab store table

Store Unit Bytes at 10M Mutability
GeometryStore 4 MB double-ended pages ~4.6 GB for trivial solids Immutable after publish; RCU clone on change
InstanceArena 64-byte records, slot-allocated 640 MB live + hole overhead Records immutable while published; edits write a new slot
InstanceRegistry (CPU) 40-byte entry per gpuInstanceIndex 400 MB host RAM Copy-thread owned, read lock-free by the pick resolve
InstanceSlotOf uint per gpuInstanceIndex 40 MB Mutated in place, one aligned 4-byte store
VisibilityMask uint2 per gpuInstanceIndex 80 MB Mutated in place, aligned stores
DrawTemplates one per object ~320 MB Immutable; rebuilt when its geometry page is cloned
VisibleIndirect per active Viewport capped Transient GPU output

The arena, the redirect table and the mask are all indexed by the same dense gpuInstanceIndex, and none of them knows anything about geometry pages. The registry is the CPU-side half of that same index space β€” it is the only store here that maps back to memoryID and to a geometry page, and it never reaches the GPU. Both the arena and the registry are reserve-then-commit allocations (GPU tiles, host pages), so those byte figures are the fully-populated ceiling, not what an open tab costs.

The write model

This is the heart of the design; everything else follows from it.

uint slot = InstanceSlotOf[gpuInstanceIndex];  // 4 B, atomically rewritten on edit
InstanceRecord r = Instances[slot];            // immutable while any frame can read it
uint2 vis = VisibilityMask[gpuInstanceIndex];  // aligned, old-or-new
  • Transform edit: allocate a fresh instanceSlot, write the record there (no frame can reference it yet), then flip InstanceSlotOf[idx] with one aligned 4-byte store. The old slot goes on the min-fence-gated free list. A concurrent reader sees the old or the new slot β€” both are valid transforms, so the worst case is one frame of staleness on one monitor. No geometry page is touched and nothing is cloned.
  • Hide / show and SubTab membership: one aligned write to the mask. Each Viewport tests one bit, which lives in one word, so even a torn 8-byte mask cannot be observed inconsistently by any single reader.
  • Why records are never mutated in place: the arena is write-combined memory, so a 64-byte memcpy can be observed half-old and half-new β€” a garbage matrix, not a stale one. The redirect exists precisely to turn a 64-byte update into a 4-byte atomic one.

Dragging burns one slot per edit, but steady state ping-pongs between 2–3 slots per object: a fresh slot is needed only while the previous one is still referenced by an in-flight frame. Slots freed by moves accumulate as holes and are reclaimed by the defragmentation pass in Page compaction. Index contiguity is deliberately not maintained during normal operation.

Reserved-tile instance arena

The per-tab world-matrix buffer is now one reserved (tiled) device-local resource per tab: MV_MAX_INSTANCES_PER_TAB (10,485,760) records Γ— 64 B = 640 MB of GPU virtual address reserved at tab creation, with physical 64 KB tiles committed on demand through UpdateTileMappings as the arena grows. 64 KB = 1024 records, so growth is fine-grained and an empty tab costs zero physical bytes.

The point is not merely avoiding a reallocation. The virtual address never changes for the tab’s lifetime, which deleted:

  • the whole-table copy in GrowMatrixTable, unaffordable at 640 MB;
  • the retiredBuffers path for matrix tables, and with it the “640 MB pinned until the slowest monitor catches up” hazard β€” TabGeometryStorage no longer has a retired-buffer queue at all;
  • the worldMatrixVAShared / worldMatrixDataShared / matrixCapacityShared mirrors and their publish ordering, which existed only because the buffer address moved. All four readers (scene, pick, highlight, PrinterController) now bind a plain instanceArenaVA written once at creation.

As implemented:

  • One heap per growth step, not one per tile. The step doubles from 4096 records (4 tiles, 256 KB), so ~12 heaps back the whole 640 MB range. One 64 KB heap per tile would be 10,240 allocations, against the same WDDM low-thousands guidance that motivates chained page doubling in Later.
  • UpdateTileMappings is issued on the copy queue. It is a queue operation, not a command-list one; the copy thread owns arena growth, and the mapping is enqueued ahead of the ExecuteCommandLists that writes records into the new tiles. Growth needs no fence gating either, because newly committed tiles back indices beyond instanceCount that no published snapshot can reference. Verified against TiledResourcesTier 3 hardware.
  • Records now ride the upload ring. A device-local arena is not CPU-mappable, so the plain XMStoreFloat4x4 into a persistently mapped upload heap became a 64-byte ring staging plus one CopyBufferRegion per record, in the same chunk recording as the geometry. EstimateStagingBytes accounts for it.
  • The pick resolve’s world-space AABB centre moved onto a CPU-side transform shadow β€” three floats per instance inside the registry entry, written by the copy thread at upload time. Putting it in the pick shader was the alternative; the shader has no access to an object’s AABB, so the shadow is both cheaper and the only one of the two that actually works without a second per-object GPU buffer.

Instance redirect table (the move path)

InstanceSlotOf[gpuInstanceIndex] is a second reserved-tile buffer alongside the arena (4 bytes per index, 40 MB of reserved VA, one 64 KB tile = 16384 entries), and the scene and pick vertex shaders now use the two-load form shown in The write model. Besides being the move path, this closed the in-place-record-write window the stable-index work knowingly opened. MODIFY splits into two paths:

  • transform-only β†’ new slot, write record, atomic redirect flip. No geometry page clone, no argument rebuild, nothing published.
  • geometry changed β†’ the existing path (relocate into a cloned page, rebuild that page’s arguments) plus a new slot.

This is the step that pays for the whole design: “move 1000 scattered objects” drops from up to 4 GB of page cloning to ~68 KB of writes. Done when: moving N scattered objects clones zero geometry pages.

As implemented:

  • The flip is a 4-byte CopyBufferRegion on the copy queue, not a CPU store. Both buffers are device-local, so the record write and the flip share one ring allocation and one command list; the copy queue’s strict in-order execution is what guarantees the record lands before the redirect that publishes it β€” the same guarantee the page clone already relies on. The 4-byte write is naturally aligned and both its old and new values are valid slots, which is invariant 2 exactly.

  • A transform-only chunk publishes nothing, and that turned out to be a trap. The per-chunk handover of vacated identities sat after the “nothing to publish β†’ continue” early exit, so a chunk of pure moves would have leaked one slot per edit. The handover is now a lambda called from both exits. It still has to run after PublishPages on the publishing path: the fence it reads must be at or beyond the one the retired pages were tagged with, or a frame submitted in between still draws the pre-publish snapshot naming a just-freed index.

  • Two free lists, two different churn rates. An index is vacated only by REMOVE; a slot is vacated by every MODIFY. Both ride the same safeRetireFence sweep, and the heartbeat now reports them separately (idx(pending/free), slots(pending/free), moves).

  • The transform-only path now has a producer. It is keyed off a MODIFY whose payload carries a world matrix but empty vertex/index vectors (IsTransformOnlyEdit). That is emitted by TranslateSelectedSceneObjects, which writes each selected object’s rigid placement and lets GeometryForObject compose it into GeometryData::worldMatrix β€” see Object placement below. Two debug keys drive the path: m (a raw matrix for every object in the container, predating the placement and touching no stored state) and v (the real producer, on the selection). Measured: moves climbs while clones and cloneMB stay flat.

    What this cost, against the estimate. An earlier revision of this section claimed the only remaining prerequisite was “give the generators a real world matrix”. That badly understated it: META_DATA had no transform field at all, and all 15 3D types store absolute world coordinates in their own members (SPHERE::center, CYLINDER::p1/p2, CUBOID::vertices, …). Persistence is one type-specific proto blob per object with no shared envelope, so the placement had to be added to all 15 .proto files. It was a data-model and schema change, not a generator tweak.

  • The “clones zero geometry pages” criterion was not actually met until later, and it took two fixes, not one. The chunk’s Pass 1 built its affected-page set from every non-ADD command, so a move dragged its own page in and cloned ~5.5 MB to publish a byte-identical replacement. Less obviously, the append-candidate scan that follows gates on cmd.geometry.has_value() β€” which is true for a transform-only edit, because carrying the world matrix is precisely what the otherwise-empty GeometryData is for β€” so even after Pass 1 was fixed, a chunk of pure moves still force-cloned one append page per container. Both scans now skip IsTransformOnlyEdit. Measured on the heartbeat: 27 moves, clones and cloneMB unchanged, scene visibly translated. The lesson generalizes to anything else that rides the MODIFY command β€” two independent scans decide what gets cloned, and an edit that touches no bytes must opt out of both.

An InstanceRecord is exactly 64 bytes:

struct InstanceRecord {
    float4 transformA;     // 48 bytes across transformA/B/C: affine object transform
    float4 transformB;
    float4 transformC;
    uint   materialIndex;  // 16-byte object render payload
    uint   packedColor;
    uint   renderFlags;
    uint   packedParams;   // e.g. opacity plus a future scalar parameter
}; // 64 bytes

The final affine row is implicit, so shaders must use an explicit affine-transform helper β€” the record is no longer safe to hand to a generic float4x4 mul. The convention, settled byte-for-byte: transformA/B/C are the first three rows of transpose(world), i.e. the three columns of the row-vector world matrix.

transformA = (W00, W10, W20, tx)      point  : dot(float4(p,1), transformA/B/C)
transformB = (W01, W11, W21, ty)      normal : dot(n, transformA/B/C .xyz)
transformC = (W02, W12, W22, tz)      dropped row is always (0,0,0,1)

That is byte-identical to the leading 48 bytes of the XMFLOAT4X4 the arena stored before this step, so the CPU writer is still one XMMatrixTranspose β€” it just stops copying the last row. Consumers that changed together: ShaderSceneVertex, ShaderScenePickVertex (which duplicates the struct β€” there is no .hlsli include mechanism in the build, so the two definitions carry cross-reference comments) and the highlight path, which reuses the scene vertex shader. The rotation-cube overlay did not change: it has its own root signature and never reads instance data. The CPU pick resolve did not change either β€” since the arena became device-local it reads the registry’s world-centre shadow rather than a matrix.

The three transform vectors retain the 3Γ—3 part used for normals; the uniform-scale assumption stands, with an inverse-transpose or separate normal-transform policy left to a later revision.

Appearance is packed into the record for authored, infrequently changed state: material, base colour, opaque/transparent classification, opacity, render flags. High-frequency interaction state (hover, selection, SubTab membership, temporary hide) stays out of it β€” that state lives in Visibility mask’s mask, so an interaction never allocates a slot.

The defect this section predicted has since fired, and is fixed β€” packedColor now has a producer. The prediction was: a transform-only MODIFY allocates a fresh slot and writes all 64 bytes of a new record, but the copy thread has no source for the appearance half, and the arena is device-local so it cannot be read back β€” therefore the moment appearance became real, dragging an object would silently reset its colour and opacity. The 16-byte vertex made appearance real (see Vertex format), and the fix shipped in the same change, as required.

Two details differ from what was written here in advance, both in the cheaper direction:

  • Only 4 bytes are shadowed, not 16. materialIndex, renderFlags and packedParams still have no producer, so there is nothing to preserve across a move yet; they are still written as zero. Each gains its shadow when it gains its producer.
  • It was free, not 40 β†’ 56 bytes. InstanceRegistryEntry was 40 bytes with only 36 used β€” uint32_t packedColor landed in the existing tail padding, so the static_assert(sizeof == 40) did not move and the entry is still one cache line per lookup.

Verified: select an object, move it, deselect β€” sampled RGB before (44,159,73) and after (45,163,75), the 1.5% delta being the hemispherical lighting term at the new position. On the same run the heartbeat read moves=1 with clones and cloneMB unchanged, so the move still clones nothing.

Visibility mask

VisibilityMask[gpuInstanceIndex] is a third reserved-tile buffer alongside the arena and the redirect table β€” one 64-bit SubTab membership word, uint2 in shaders for broad compatibility β€” plus a per-draw SubTab bit index. CPU-side membership changes stay keyed by memoryID; the copy thread resolves them to dense indices and writes the mask directly under invariant 2.

Two consumption paths, in this order:

  • Interim, with today’s per-page ExecuteIndirect: the vertex shader tests the bit and emits a degenerate position when it is clear. Toggling is one atomic write β€” no rebuild, no clone, no upload. Vertex shading still runs for hidden geometry, which is the accepted interim cost.
  • Final, in GPU command compaction: compute compaction drops hidden objects before they ever become draw commands.

Per-object hide therefore ships well before the compute path exists. Reusing a SubTab bit requires clearing its prior membership first.

Note that whole-page container filtering β€” ExecuteIndirect argument count 0 when page.containerMemoryId is not the active container β€” is a page mechanism, not per-object hide. It stays as a cheap first-level reject.

As implemented:

  • The bit index is the sub-tab slot, passed as a root constant (b2) in its own range rather than widened into b1 β€” b1 is what the command signature rewrites per command, so a render-thread value sharing that range would be overwritten by ExecuteIndirect. A view with no bit passes a sentinel (kNoSubTabBit) that disables the test and shows everything. That used to catch sub-tab slots past 63, back when MV_MAX_SUBTABS was 128 against a 64-bit mask; with both now 64 the only remaining caller is the print path, which draws with no sub-tab at all.
  • All four readers must bind it, not just the scene. Scene, selection highlight, GPU pick and PrinterController share one root signature, and t2 is a root descriptor β€” no bounds check, no null check β€” so a path that skips the bind reads garbage rather than failing loudly. The pick pass applies the identical bit: an object the user cannot see must not be clickable, and the print path uses the on-screen sub-tab’s bit so a print matches what is displayed, hides included.
  • The default is written explicitly on every ADD. A freshly committed D3D12 tile has undefined contents, so an unwritten mask is garbage rather than zero β€” and a recycled gpuInstanceIndex would otherwise inherit the hides of whatever owned it before. That costs 8 bytes of ring staging per object added, accounted for in EstimateStagingBytes.
  • Mask commands must stay out of the batch deduplication pass. That pass keys on memoryID alone, so an ADD and a hide of the same object in one batch would collapse to whichever came last β€” and if that were the hide, the geometry would silently never be uploaded. They are also excluded from the affected-page scan, since pulling an object’s page in there would clone 4 MB to change 8 bytes, which is precisely the cost this step exists to avoid. Each mask command names one bit rather than a whole word, so they are applied in order and never collapsed.
  • A hidden-object shadow replaces the compute dispatch. The copy thread keeps a map of only those indices whose mask is not all-ones. It answers “what is this object’s current word” without reading back from device-local memory, and it bounds the clear-on-slot-reuse sweep by the number of hidden objects instead of by the index space β€” so the “one compute dispatch over the mask array” this section used to require is not needed, and neither is the shader-visible descriptor heap that the compute compaction path would otherwise need. The sweep is capped per chunk and re-queues its remainder, because a single command that fans out over millions of entries would otherwise overrun the upload ring and fall back to a committed buffer per entry.
  • The bit is restored at the fence-gated FREE transition, not at close. Frames still drawing a closing view keep their hides until they retire, instead of objects popping back mid-flight. It also has to happen there for a locking reason: the retire path runs under storageObjectsMutex, and enqueueing there would nest it inside toCopyThreadMutex β€” against the never-nested discipline the geometry producers follow, and a way to stall every render thread (they take storageObjectsMutex each frame resolving the window’s view) behind a copy-thread drain.
  • Producers: the HIDE_SELECTED / HIDE_UNSELECTED / HIDE_RESET ribbon buttons, which existed as unwired rows. Each touches only the objects it names β€” “Hide Selected” does not silently un-hide everything else β€” so the three compose the way a user expects, and an empty selection makes the two hide actions no-ops rather than blanking the view. Hide state is session-only; nothing is persisted.
  • Appearance state stays split as designed: authored, infrequently changed state (material, colour, opacity) lives in the 64-byte InstanceRecord; hover, selection and hide live here, so an interaction never allocates an arena slot.

Fence-gated retirement

The bug this fixes: REMOVE and MODIFY returned the matrix slot to freeMatrixSlots immediately, so a later ADD in the same batch could take it and overwrite the transform while in-flight frames still drew the pre-publish snapshot β€” one frame of an object wearing a stranger’s transform. Highly visible, unlike the benign one-frame staleness discussed elsewhere.

Slots vacated during a chunk now collect in a per-chunk list, are tagged at publish time with the global render fence, and are handed back to the free list only by the safeRetireFence sweep β€” the same rule and the same sweep that governs retired pages and snapshots. Small, self-contained, and the discipline the redirect table depends on. (The stable-index work renamed this machinery from matrix slots to instance indices and made it REMOVE-only; outgrown matrix tables, the third thing the sweep used to free, no longer exist at all.)

Done when: a REMOVE + ADD in one batch cannot reuse a slot until every monitor’s fence has passed it. The slots(pending/free) telemetry counter shows the holding list draining.

Graphics objects vs engineering objects

(planned β€” nothing below is built)

Prioritized ahead of defragmentation because it is the single biggest VRAM lever for plant models, and because GPU command compaction removed its hardest prerequisite.

What this is, and what it deliberately is not

An earlier roadmap promised “Instanced rendering: InstanceCount > 1 + per-instance matrix indirection in the vertex shader.” That plan is superseded. What is built here is shared geometry: one draw command per object with InstanceCount = 1, many commands pointing at the same BaseVertexLocation / StartIndexLocation inside a library buffer that stores the mesh once.

InstanceCount > 1 was the wrong target. It needs per-instance data contiguous and indexed by SV_InstanceID, which fights every property the identity and mask design established β€” you could not hide one bolt of a batch, move one, or recolour one without breaking the run. Shared geometry gives the identical VRAM saving and the identical draw-call count (one ExecuteIndirect per Viewport either way), while every object keeps its own gpuInstanceIndex, InstanceRecord, redirect entry and mask bit. Colour, transparency, hide, move and pick all keep working with no new machinery.

GPU command compaction is what made this cheap. Because the compacted command carries its own VBV/IBV, a command drawing from a library buffer sits in the same output buffer as one drawing from an ordinary geometry page. Under the per-page model, library geometry would have needed its own ExecuteIndirect.

Two libraries

gpu.primitiveLibrary β€” global, immutable, LOD’d. One buffer, built at startup, alive for the process, shared by every tab. Deliberately outside the RCU/page system: it never changes, so it needs no snapshot, no container directory, no retirement and no fence gating. Tab 0 owns it conceptually; it lives in the ΰ€Άΰ€‚ΰ€•ΰ€° singleton so the draw loop never has to walk two tabs’ snapshots.

It holds the shapes whose only freedom is size, so one canonical mesh plus a non-uniform scale covers every instance: sphere (uniform), cuboid (3-axis), cylinder outward-facing and inward-facing (radius + length), disc (radius), and one or two tori at default tube ratios. The two cylinder entries exist because a bore is the same mesh with inverted winding β€” a second entry rather than a render-state change, unless double-sided rendering becomes universal for sectioning.

As built: three entries β€” sphere 0, cuboid 1, cylinder 2 β€” 24 rows, 156.6 KB. Those three already serve four object types: LINE_MEMBER reuses the cuboid and the cylinder for its solid rectangular and circular profiles (Structural members that are library primitives). The inward-facing cylinder is deferred with PIPE, its only consumer; disc and the tori have not been built.

Every entry carries 8 LOD levels, always 8, even where fewer are meaningful: a cuboid’s eight slots all point at the same 12-triangle mesh. Uniform array width keeps the shader branch-free and duplicate table entries cost 40 bytes each. The table is (shapeId, lod) β†’ { indexCount, startIndexLocation, baseVertexLocation, boundingRadius } β€” ~12 shapes Γ— 8 LODs Γ— 16 bytes β‰ˆ 1.5 KB, a root SRV. (It was 12 bytes until the first non-uniformly scaled shape needed the bounding radius on the GPU; see LOD.)

Tab-owned template library β€” created on demand, no LOD. Everything whose shape has a parametric ratio, plus non-parametric imported templates. Created on first use, lives until the tab closes, no reference counting in the first implementation β€” so a tab that creates many templates and then deletes their users holds that space until it closes. Acceptable initially; worth a telemetry counter to find out whether it matters.

The split falls on ratios, not on parametric versus non-parametric. Scaling changes size, never shape ratios, so a shape with an internal ratio needs one mesh per ratio β€” a catalog, not a scale. Elbows and flanges are therefore ASME catalog entries in the tab library; pipes, bolts and nuts are composed from primitives.

Pipes as composed primitives

(not built β€” PIPE stays bespoke geometry; see Deferred with PIPE below for the decision and what it costs.)

A pipe decomposes into an outer wall, an inner wall and two annular end caps. The walls are scale-instanced cylinders; the caps are catalog items. Bolts and nuts compose the same way. This works because each part is its own graphics object with its own transform, so the engineering thread derives four transforms from the pipe’s own p1, p2 and radii and each lands in an ordinary InstanceRecord β€” no part-transform table, no matrix multiply per vertex, no second template struct.

Decomposition does not remove the wall ratio, it relocates it. The two walls become ratio-free, each a plain cylinder scaled by (r, r, L). But the annular cap is disc(ro) βˆ’ disc(ri), and no affine transform can move two concentric radii independently, so the cap stays a catalog item keyed by schedule.

Why this still beats one capped-tube mesh per schedule: nearly all the triangles are in the walls β€” a 36-segment wall is 72 triangles against an annulus’s 36 β€” so putting the walls in the global library gives pipe LOD for the expensive part automatically, where a capped-tube entry would need all 8 LODs per schedule in the tab library. Caps are also only needed at free ends; most pipe ends join a flange, elbow or another pipe.

The template-only page

An instanced object contributes no vertex or index bytes anywhere, so its page needs no 4 MB geometry buffer β€” only a template buffer and placement records. An ordinary page is 4 MB + 256 KB β‰ˆ 4.25 MB; a template-only page is ~256 KB.

The page names a source buffer rather than owning one. That single decision keeps every existing draw path working: the legacy per-page path, the GPU pick pass and the print path all bind VBV/IBV per page, and for a template-only page those come from the referenced library instead of from page.buffer. Nothing is forced onto the compute path. The rule is that one template-only page draws from exactly one source, so a container has at most two (global, tab) beside its ordinary pages.

Second-order win: adding one bolt to a scene of 100,000 bolts clones a 256 KB page, not 4.25 MB. Instancing improves the edit path, not only VRAM.

Two template formats, one output buffer. The template formats genuinely differ β€” a bespoke template names byte offsets in its own page, an instanced one names a shape id and needs a LOD lookup. The compacted output format does not differ at all: both emit the same 56-byte VisibleIndirectCommand, one with views pointing at a geometry page and the other at a library, same PSO and same vertex shader. So the split belongs in the compute pass, not the draw β€” a template-only page dispatches the instanced shader, an ordinary page the bespoke one, both InterlockedAdd into one count and one output buffer. Splitting the draw as well would double the scratch regions once PSO buckets land (16 instead of 8, each with its own headroom) for no pipeline-level gain, and instanced-versus-bespoke is not a PSO distinction the way transparency, culling, topology and vertex format are. Cheaply reversible if a reason appears.

Placement records in such a page have no meaningful byte offsets; those fields carry (libraryEntryId, lod) instead, and GeometryPlacementRecordInPage has 7 spare bytes in its 64.

Emission and mutation

GetGeometry for an eligible object emits a library reference instead of vertices and indices: shape id, LOD hint, and the transform mapping the canonical mesh onto the object’s engineering parameters. Eligibility is always-instance-when-possible β€” no repeat-count threshold, because an instanced object costs zero geometry bytes, so instancing a one-off still beats not instancing it.

An object stays eligible while nothing modifies its geometry. Drill a hole and it stops being: GetGeometry emits real vertices and the copy thread relocates it into an ordinary page. That is an ordinary geometry MODIFY on the same graphics object, not a change of identity β€” the gpuInstanceIndex survives, and with it the object’s colour, hide state and current selection. Had the two forms carried different ids, drilling a hole would silently reset all three. A composed object can be partly instanced, since the parts were independent graphics objects to begin with.

One engineering object, up to 256 graphics objects

The 8 free high bits of memoryID key a graphics object: one engineering object maps to as many as 256, each with a fully independent gpuInstanceIndex, InstanceRecord, redirect entry, mask word and registry entry. That independence is deliberate β€” it is what lets a composed pipe give each part its own transform with no new machinery.

This unifies with the variation key. Composition parts and alternate representations are the same question β€” which GPU object of this engineering object β€” so they share one field rather than competing for it. A pipe’s four walls and caps, plus its centerline and a stress contour, are six graphics objects of one pipe. 0 is the whole object; the rest of the structure is deliberately left open for now, with the caveat that it hardens by accident once several composed types have baked their own assignments in.

Three prices, accepted knowingly:

  • Interactions cost N writes, not one. Hiding a pipe is four mask writes; moving it is four arena slots and four redirect flips. Still ~270 bytes against a 4 MB clone, so the move budget holds β€” but the workload table’s “one atomic mask write per object” means per graphics object.
  • The identity ceiling counts graphics objects. MV_MAX_INSTANCES_PER_TAB bounds those, so the engineering count it supports is that figure divided by the average part count. See Goal and workload.
  • Selection and pick must mask the low 56 bits. Clicking any part selects the pipe; highlighting the pipe highlights all four. Both paths compare engineering ids, not graphics ids β€” the same rule the variation key already required.

The engineering thread expands a composed object, through a method on the object’s own class, and the graphics engine is oblivious to composition entirely: it receives four independent ADDs with four sub-ids, exactly as if four unrelated objects had been created. The batch deduplication pass therefore needs no change (it keys on the full 64-bit id, and the four are distinct), and the composition method extends the Object placement rule rather than bypassing it. The one piece of bookkeeping required is the emitted part count, stored on the engineering object β€” re-deriving it from current parameters is wrong once those parameters have changed.

Geometry memory

Vertex format

Vertex layout is common to all geometry:

  • 3 Γ— 4 bytes for Position, 4 bytes for Normal, 4 bytes for Color (RGBA β€” 8 bytes if an HDR monitor is present) = 20 / 24 bytes per vertex.
  • Always go with the 24-byte format. Tone mapping (HDR β†’ SDR) happens in the pixel shader.
  • Initial development is on R8G8B8A8; when we implement HDR later we will upgrade. Some hardware may not support HDR, so keep both versions of the shaders.
  • Whether to load HDR or SDR shaders is decided at application startup. If the graphics card supports HDR and at least one monitor is HDR-capable, switch to HDR. Once HDR is ON, the application keeps HDR shaders even if the HDR monitor disconnects β€” until the app closes.

Superseded β€” the 24-byte layout above is history. It was live until the vertex was cut down to 16 bytes: position R32G32B32_FLOAT (12) + normal R8G8B8A8_SNORM (4) + color R16G16B16A16_FLOAT (8).

As implemented, the base vertex is 16 bytes β€” position R32G32B32_FLOAT (12) + normal R8G8B8A8_SNORM (4), nothing else. Colour moved into the per-object instance record as InstanceRecord::packedColor (RGBA8, alpha = opacity), which the vertex shader unpacks once per vertex. That is a 33% cut on the vertex half of every page across the bulk of a model. Per-vertex colour is still genuinely needed (FEA contours, imported meshes with baked colour), but as separate variants with their own pipelines and their own pages rather than as a tax on every vertex β€” see Vertex format variants below.

What it cost, stated plainly: per-face colour. Every 3D type stores several face colors (CONE::colorBase / colorIncline, PIPE::colorOuter / colorInner / colorCap, …) and drew each face in its own. With one colour per object, each generator now nominates its dominant surface β€” incline for the cones and cylinders, outer wall for the pipes, colorMain for a line member, the annular faces for a flange β€” and writes it to GeometryData::color, a field that existed and had no reader until now. Storage did not change at all: every face colour is still in the struct and still in its .proto, so per-face colour returns intact with per-face disaggregation of an object, as a variation rather than as 8 bytes charged to every vertex in the model. Nothing in the file format had to move for this.

Two consequences worth stating, because both were live traps:

  • A transform-only edit must now carry appearance. A move writes a whole fresh 64-byte instance record but arrives with empty vertex/index vectors, and the arena is device-local so the old record cannot be read back. InstanceRegistryEntry therefore shadows packedColor β€” free, as it landed in the struct’s existing tail padding and the entry is still 40 bytes. Without it a drag repaints the object black. This is the defect Instance redirect table flagged and left waiting for a producer; the 16-byte vertex is that producer, so the two had to ship together.
  • Per-object colour is now RGBA8, not FP16. Vertex colour had been FP16 from day one, which is why the vertex format needed no change for HDR. An 8-bit per-object colour cannot exceed 1.0, so an emissive / overbright base colour is no longer expressible. Nothing visible changes today β€” CAD surface colors are authored in [0,1] and tonemapping is entirely output-side (render-target format, tonemap pass, swap chain β€” see the HDR item under Next evolution steps) β€” but the FP16 payload variant is where overbright would have to go if it is ever wanted.

The shaders are split by stride rather than versioned in place: ShaderSceneVertex_16.hlsl / ShaderScenePickVertex_16.hlsl are what every PSO binds, and _24 twins are kept compiling (nothing includes their headers) so the per-vertex-colour variants have a maintained starting point. The pixel shaders are not twinned β€” they are stride-independent, which is also why the 16-byte vertex shader deliberately does not mark its colour interpolant nointerpolation: that modifier would have to be matched in the shared pixel shader and would silently flat-shade the per-vertex-colour variant.

Vertex format variants

Per-object colour makes the 8-byte per-vertex colour dead weight for ordinary geometry. It does not disappear β€” it moves into dedicated variants, each with its own pipeline and its own pages:

Variant Vertex Lifetime State
Base (lean) pos 12 + normal 4 = 16 B authored, persistent Shipped β€” the only format the engine draws
Scalar field (FEA results) pos 12 + normal 4 + FP16 scalar = 18 B (pad 20) computed, transient, per load case Planned
Baked vertex colour (imported PLY/OBJ/scan) pos 12 + normal 4 + RGBA8 = 20 B authored, persistent Planned
Legacy per-vertex FP16 colour pos 12 + normal 4 + FP16 RGBA = 24 B β€” Retired as the base; shaders parked as *_24.hlsl

The 24-byte row is the starting point for the two planned variants, which is why its shaders are kept compiling rather than deleted. ShaderSceneVertex_24.hlsl and ShaderScenePickVertex_24.hlsl are byte-for-byte what shipped as the base format, still registered as FxCompile items with their own VariableNames (g_sceneVertexShader24, …) so they cannot bit-rot, but nothing includes their generated headers. The pixel shaders are shared across strides and deliberately not twinned β€” which is the constraint that keeps the base vertex shader from marking its colour interpolant nointerpolation, since a flat modifier would have to be matched in the shared pixel shader and would defeat the two per-vertex-colour variants.

Storing the scalar rather than a baked RGBA is the decision that matters. A contour is a scalar field pushed through a colormap; baking the colour means a full re-upload to change the colormap, the legend range, or linear↔log scaling. Keeping the scalar and putting colormap and range in a per-Viewport root constant plus a small 1D texture makes all three free β€” and dragging the legend range is the most-used interaction in results post-processing. Imported colors are almost always 8-bit at source, so RGBA8 rather than FP16; the shader expands, and HDR is unaffected since tonemapping is on the output side.

Note the lean vertex is 16 bytes, a power of two, so lean pages could use the cheap AlignUp mask; RoundUpToMultiple was kept anyway, precisely because it is already correct for the non-power-of-two variant strides. The alignment problem moves to those strides rather than disappearing. What the base downgrade deliberately did not build is the per-page part: sizeof(Vertex) is still a single global constant, so GeometryPage has no vertexStride and VertexAlign / IsFull are still static. Adding a second live format is what forces all of that, and with it the three independent sites that compute vertexByteOffset / sizeof(Vertex) (RebuildIndirectBuffer, the Selection3D highlight path, the compaction relocation), every one of which must then switch to the page’s stride. That is verbatim the trap that produced the 16-byte-alignment defect under Page structure; one of the three being missed is a silently misplaced draw, not a crash.

The duplication is accepted knowingly. A contour variation repeats position and normal β€” 16 bytes per vertex, ~8 MB for a 500k-vertex model. The alternative, a parallel colour stream bound as vertex slot 1 (the command signature can carry a second VERTEX_BUFFER_VIEW with a different Slot), duplicates nothing but couples two page systems through byte offsets: defragmenting a geometry page would have to relocate the colour page in lockstep, forever. That is precisely the association invariant 1 forbids, and 8 MB is a cheap price for keeping the two independent. Separate pages also mean re-solving a load case re-uploads only the scalar pages and clones nothing of the base.

Normals

The industry-standard solution for normals is not 16-bit floats but packed 10-bit integers β€” format DXGI_FORMAT_R10G10B10A2_UNORM:

  • X: 10 bits (0–1023), Y: 10 bits, Z: 10 bits, padding: 2 bits (unused). Total: 32 bits (4 bytes).
  • Size: 3Γ— smaller than a 12-byte normal.
  • Precision: 10 bits gives 2¹⁰ = 1024 steps. Since normals lie between βˆ’1.0 and 1.0, that’s ~0.002 precision β€” visually indistinguishable from 32-bit floats for lighting, even in high-end CAD.
  • Vertex-shader normalization: Normal = Input.Normal * 2.0 - 1.0.

As implemented: we shipped DXGI_FORMAT_R8G8B8A8_SNORM instead β€” same 4 bytes, signed, zero shader remap (SNORM unpacks straight to βˆ’1..1). 8 bits (~0.008 steps) has shown no banding on CAD lighting so far; the 10-bit layout above remains the documented upgrade path if it ever does.

World matrix and object placement

All vertices are positioned in object-local space; the world matrix is applied in the vertex shader. This lets us move even a 1000-vertex object with just a 48-byte world-matrix update per object. We use a packed 48-byte world matrix instead of 64 bytes to save bandwidth β€” the last row is always 0,0,0,1, so we omit it and reconstruct it in the shader.

As implemented, with one important qualification: the shader side is exactly as described, but “object-local space” is currently authored space, not a canonical local frame. Every generator emits vertices at the coordinates the object was drawn at (a sphere at center, a cylinder between p1 and p2), and the world matrix carries only the object’s placement β€” the rigid transform accumulated since it was drawn (see Object placement). So a freshly created object has an identity world matrix and world-coordinate vertices; only a moved one has a non-identity matrix.

That is enough for the move path to be free, because a move never changes the vertices. It is not enough for two of the things the local-space claim would otherwise buy, and both are worth stating plainly since the sentence above reads as though we already have them:

  • Geometry can never be shared between two identical objects in this form. Two bolts at different places produce different vertex bytes. This no longer blocks instancing, which is the single biggest VRAM lever for plant models: Shared geometry and the primitive libraries gets its canonical meshes from a library built in code at startup, and an eligible object emits a reference plus a transform rather than vertices at all β€” so no generator has to be rewritten to emit a canonical local frame. What the authored-space form still costs is the point below.
  • Float32 precision is spent on the offset. A Ø300 flange at world (4000, 2000, βˆ’300) stores absolute coordinates, burning most of the mantissa on position and leaving little for shape. Kilometer-scale sites with millimeter features are where this bites.

Moving the generators to a canonical local frame β€” shape around the origin, position entirely in the placement β€” is the natural follow-on. SPHERE, CUBOID and CYLINDER have made the move; the rest have not. The axis-defined types (cylinder, cone, pipe, tee, line member) were expected to be the awkward ones because their two endpoints are engineering data β€” and the resolution, now that the cylinder has done it, is that the endpoints stay the stored truth and the transform is derived from them inside WorldMatrixForObject rather than replacing them. CUBOID is the deliberate opposite case: its eight corner vertices were only ever an encoding of something simpler, so the storage itself changed (see Reshaping CUBOID to a box).

The canonical local frame, and its single composition point

(SPHERE, CUBOID and CYLINDER are migrated; every other type still emits authored-space vertices)

SPHERE::GetGeometry emits a unit sphere at the origin. center and radius are unchanged as stored engineering data β€” they simply stop being baked into 684 vertices and become the object’s transform instead. Nothing in the schema moved. On a unit sphere at the origin the outward normal is the position, so the per-vertex normalize the authored form needed is gone too.

This is the prerequisite for instancing, not merely a precision win: until a sphere’s vertex bytes stopped depending on its own parameters, no two spheres could share a mesh.

The load-bearing part is that an object’s world matrix is now composed in exactly one place β€” WorldMatrixForObject. It is two transforms, in row-vector order:

local -> authored    identity for authored-space types; scale(radius) then translate(center) for SPHERE
authored -> world    the object's rigid Placement3D

Why that has to be a function rather than a line inside GeometryForObject is the whole lesson of this step, and it cost two defects to learn:

  • There are two producers of a world matrix and only one of them regenerates geometry. A geometry ADD/MODIFY goes through GeometryForObject; a move emits a transform-only MODIFY carrying a matrix and no vertices, and built that matrix from placement->ToMatrix() alone. That was indistinguishable from correct for exactly as long as every generator left the local matrix at identity. The moment SPHERE stopped, moving a sphere silently discarded its radius and centre and redrew it as a unit sphere at the placement origin.
  • The ~35 direct GetGeometry() call sites are no longer harmless. Every interactive creation path (CreatePrimitiveGeometryElement, addRandomGeometryElement, the STD importer) builds its GeometryData by calling the type’s generator directly, so it inherits an identity matrix. A newly created sphere therefore drew as a unit sphere at the world origin. The fix is one composition at RegisterGeneratedGeometryElement, the single choke point all of them funnel through β€” which also closes the “created with a placement will not show it until reload” defect recorded below, at the correct place rather than at 35 of them.

Adding the next canonical-frame type means editing that switch, not just its generator β€” the two must describe the same frame, exactly as PlacementForObject and GeometryForObject must.

Object placement β€” the producer side of the move path

The instance arena and its redirect table make the GPU able to move an object for the price of one instance record and one redirect flip. Nothing could ask for that until an object had somewhere to record where it had been moved to. Placement3D is that field:

struct Placement3D {
    XMFLOAT3 origin;    // translation
    XMFLOAT4 rotation;  // unit quaternion; identity = never moved
};

One per 3D object, on all 15 types, persisted as Placement placement = 20 β€” field number 20 in every 3D proto message, by convention, so the rule is stateable rather than fifteen separate facts. Absent means identity, which is what every object written before the field existed decodes to.

It is rigid on purpose. No scale factor: the InstanceRecord already documents a uniform-scale assumption, and rigidity is what makes every scalar dimension (radius, diameter, section parameter) invariant under a placement, so only point fields ever need converting between spaces.

GeometryForObject is the single point where a placement takes effect β€” it composes the placement into GeometryData::worldMatrix after the per-type geometry switch. Every ADD, geometry MODIFY, file load and import inherits it there without knowing it exists. (Superseded in the detail: the composition itself moved into WorldMatrixForObject, which GeometryForObject now calls, because the move path and the ~35 direct-GetGeometry() creation sites need the same matrix without regenerating geometry. The creation-path bypass this paragraph warned about β€” “an object created with a placement will not show it until reload” β€” is fixed at RegisterGeneratedGeometryElement. See The canonical local frame, and its single composition point.)

A move writes the placement and emits a transform-only MODIFY. No geometry is regenerated, so no page is cloned β€” measured on the heartbeat as moves climbing while clones and cloneMB stay flat.

Consequences that are easy to miss, and both were live defects until fixed:

  • Stored coordinates are authored, not world. Anything reading SPHERE::center or CYLINDER::p1 directly gets where the object was drawn. The Properties Pane therefore composes on read and solves back on write, so the user still sees and types world coordinates; an edit to one world component rewrites all three authored components, because under a rotation each depends on all three. Point triples are declared per type (pointGroupFirstField), not inferred from “the first three fields are a point”.
  • Any consumer of geometry.vertices for spatial reasoning must transform first. Zoom-to-fit computed camera extents from authored vertices and framed moved objects at their old positions until it was fixed. Clash detection, export and bounds will each have the same obligation.

The hot-drag / active-mutation path: the data path is done, what is left is tooling. An interactive drag would once have been a MODIFY per mouse-move β€” a 4 MB page clone per frame. The instance arena and its redirect table made a whole-object move write one new instance slot plus one atomic redirect, cloning nothing; the placement above gave objects somewhere to record the move, so the transform-only command has a real producer. A move persists, survives save/reload, and shows up correctly in the Properties Pane. (The per-frame matrix double-buffer once listed as a prerequisite was not the right fix β€” no fixed frame depth is correct when render threads run at different refresh rates, and a 64-byte record cannot be written in place without tearing.) What remains is a translate/rotate gizmo, screen-ray-to-drag-plane projection, and an undo story (undo/redo is designed only). Rotation is also untested end to end β€” nothing yet produces a non-identity quaternion.

Page structure

Putting the vertex and index buffer in the same page is the superior architectural choice for three reasons:

  1. Halves allocation overhead β€” one heap/resource per 4 MB page instead of two.
  2. Cache locality β€” the GPU fetches vertices and indices from physically close VRAM (same page), slightly improving cache hit rates.
  3. Double-ended layout β€” vertices start at offset 0 and grow up; indices start at offset max (4 MB) and grow down. Free space is always the gap in the middle. The page is full when the vertex head pointer meets or crosses the index tail pointer. A mandatory 64-byte gap in the middle handles alignment concerns.

Vertex offsets are a whole number of vertices, always. Every object’s vertexByteOffset is rounded up to a multiple of sizeof(Vertex) (16 today), never to a power-of-two boundary as such, because the draw path addresses vertices by BaseVertexLocation = vertexByteOffset / sizeof(Vertex) in units of stride β€” and the Selection3D highlight path recomputes the same division independently. The stride already satisfies CopyBufferRegion, so a separate alignment buys nothing. Index offsets stay 4-byte aligned.

The historic trap is worth keeping, because it comes back with the variants: 24 is not a power of two, so the usual (v + a - 1) & ~(a - 1) helper could not express it and a separate round-up-to-multiple was required. An earlier revision aligned to 16 and survived only because every generator happened to emit an even vertex count; the first odd-count object β€” an imported mesh of N triangles gives 3N vertices β€” would have silently misaligned the next object in that page by 8 bytes, with no error or warning. The lean vertex being 16 makes the mask trick valid again, and RoundUpToMultiple was deliberately left in place anyway: it is correct for any stride, costs one divide per object append, and is exactly what the 24-byte variant needs back.

Lazy creation:

  • New tab β†’ allocated memory = 0 MB.
  • User draws a bolt (solid) β†’ allocate Solid_Page_0 (4 MB).
  • User draws a glass window β†’ allocate Transparent_Page_0 (4 MB).
  • User never draws a wireframe β†’ Wireframe_Page stays null.

Pages are created in COMMON and never explicitly transitioned; implicit promotion covers every read-only use they have. (An earlier revision of this section specified a combined VERTEX_AND_CONSTANT_BUFFER | INDEX_BUFFER state β€” that contradicted the page lifecycle above and was never built.)

Feature Decision Benefit
Page content One container, one vertex format, one index width. PSO class is not a page property β€” it is a per-object bit resolved into draw buckets (planned) Container keeps the coarse reject and O(1) teardown; vertex format and index width are byte-layout facts a page cannot mix; PSO class does not need to be a page fact once commands are compacted per Viewport
Growth logic Fixed-size pages; a container grows by adding pages No moving old data; uniform pages retire / recycle cleanly
Page size 4 MB fixed today; chained doubling 4β†’8→…→64 MB queued (Later) Cheap RCU clones now; page count stays in the low thousands even on a 48 GB card later; jumbo objects still bypass via the big-buffer fallback
Allocation Lazy (on demand) Keeps “Hello World” tabs lightweight
Sub-allocation Double-ended stack Maximizes usage for varying vertex/index ratios

New geometry is appended (in the middle) only if both the new vertex and index buffers fit inside; otherwise a new buffer is allocated. The copy thread also batches β€” it aggregates all objects that fit in the current buffer into a single GPU upload, coalescing updates into single ExecuteCommandList calls where possible to reduce API overhead.

Indirect argument buffer sizing

Every 4 MB page used to reserve a flat 65,536 Γ— 24 B = 1.5 MB argument buffer, sized for a pathological page of single triangles: 27% of each page’s 5.5 MB footprint, and ~2.4 GB of pure reservation at 10M objects. Measured against a real scene β€” 56 pages holding ~178 objects each β€” that was 84 MB reserved to hold 240 KB, a ~350Γ— over-reservation. The starting reservation is now 256 KB (kIndirectInitialBytes), taking a page from 5.5 MB to ~4.25 MB.

256 KB is deliberately not the measured minimum. A page densely packed with cuboids holds ~9,200 objects and still fits inside it, and instancing will push objects-per-page higher still once shared geometry lets one page back many more of them β€” so the figure leaves room rather than tracking today’s numbers.

The 16-byte vertex ate most of that room, and this is the one place it cost something. A denser vertex means more objects per 4 MB page and therefore more 24-byte arguments per page: the dense-cuboid case went from ~6,470 objects (155 KB, 59% of the reservation) to ~9,200 (216 KB, 84%). It still fits, and AllocateIndirectBuffer doubling covers anything that does not, so this is a headroom note rather than a defect β€” but argGrow is now a much more sensitive instrument than it was, and a non-zero reading should be read as “the reservation is undersized for the new density”, not as an exotic workload. Pages that genuinely need more grow: AllocateIndirectBuffer doubles the capacity, and RebuildIndirectBuffer is the single growth point. That is safe there and nowhere else, because every page reaching it is a clone or a brand-new page β€” never a published one β€” so replacing the buffer cannot be observed by a render thread, and the rebuild rewrites the whole buffer anyway. A steady non-zero argGrow rate on the heartbeat would mean pages routinely outgrow the reservation; measured 0 in testing, both before and after the vertex change.

Lock-free VRAM management (RCU)

We use ExecuteIndirect + versioned geometry pages (max page size 4 MB initially). On geometry modify (Add / Modify / Delete):

  • If the new geometry (plus the filled-up last active page) exceeds the 4 MB page threshold, create new pages β€” do not touch existing ones β€” and then publish.
  • Otherwise allocate a new page via the copy queue. The copy queue makes a read-only copy of the existing page to create a newPage (not yet published for rendering). DirectQueue0/1/2… keep rendering as usual. Leave the old page in COMMON state permanently; never explicitly transition it to VERTEX. Both render and copy-source are allowed on their respective queues by implicit promotion from COMMON.
  • The copy queue finalizes the copied newPage and uploads the delta. For additions, just add; for modify / delete, if page free space drops below threshold β†’ rebuild / defragment the page.
  • Publish the pointer swap atomically. Once all render threads have passed a fence, retire the old page later by releasing its buffers.

Geometry is NOT kept in CPU RAM once uploaded to VRAM (memory efficiency, keeping iGPU systems in mind). Objects are generated on demand by the engineering thread and handed to the copy queue. To be able to defragment, the copy queue stores the byte/index ranges of every object loaded into a page.

The copy queue prepares newPage (vertex buffer, index buffer, ExecuteIndirect buffers) and uploads it to VRAM. This PCIe transfer happens in parallel while the render threads keep running. Iteration over all objects has been removed from the engine entirely. There are two levels of batching: the engineering thread batches changes to some extent, and the copy thread batches further by draining the submission list.

Geometry page lifecycle: created in COMMON β€’ never explicitly transitioned β€’ only used in read-only states β€’ copied from (COPY_SOURCE) β€’ copied into (COPY_DEST) only before publishing (once published there is no write) β€’ drawn from (VERTEX / INDEX / INDIRECT).

Strict invariants:

  • Geometry pages are immutable after publish.
  • No explicit state transitions for page buffers.
  • All page swaps are atomic.
  • Old pages are destroyed only after all queues retire.

There are multiple views per tab. As implemented, the ExecuteIndirect argument buffer is per page (one, not per-view double-buffered) and is regenerated whenever a page is cloned; a delete soft-marks the placement record (isDeleted) and the next rebuild drops it. Per-view argument buffers proved unnecessary β€” a view draws only the pages of the containers in its SubTab set, reached through the snapshot’s container directory (SubTabs, Viewports and container sets). The earlier form of this, issuing an argument count of 0 for pages of inactive containers, is gone: those pages are no longer visited at all.

Free-list allocator (designed, not built β€” demoted to a telemetry-gated decision; the implemented path is a per-batch largest-gap scan): maintain a CPU-side segregated free list, per tab. The allocator knows, e.g., “I have a 12 KB middle gap in Page 3 and a 40 KB middle gap in Page 8.” When a 10 KB request comes in, it immediately returns “Page 3” β€” no iterating through page objects. If the free list says no existing page can accommodate the geometry, create a new heap / placed-resource buffer. The free list tracks only middle empty space, not internal holes from deleted objects; aggregate holes are tracked per page and defragmented occasionally.

When a buffer accumulates more than 25% holes, it creates a new defragmented buffer and switches over once complete (for new geometry additions). At most one buffer is defragmented at a time (between two frames). Since pages are 4 MB, this does not produce a high-latency stall while running async with the copy thread.

Upload ring and chunked submission

The ring. One persistent-mapped upload buffer per process β€” 64 MB to start β€” serving all copy-thread staging: Scene3D geometry, indirect-argument and draw-template rebuilds, Page2D record uploads, texture uploads. (Everything but textures is there today; the texture path is blocked on a fence defect β€” see below.) Allocation is a bump pointer with wraparound; every allocation is tagged with the copy-fence value that will release it, and a region becomes reusable once copyFence->GetCompletedValue() has passed that value. Today every object upload creates its own committed staging resource in RecordGeometryUpload, which is exactly the stall the original roadmap predicted. The dead per-tab upload heaps go at the same time: InitD3DPerTab commits and persistently maps 64 MB (vertex) + 16 MB (index) that nothing ever writes β€” ~80 MB per tab, directly against the “Hello-World tabs stay lightweight” rule.

Submission is driven by ring capacity, not by pass boundaries. This is what makes bulk import work. Model loading hands the copy thread lakhs of objects at once, and the ring must never be asked to hold more than it has:

open command list
for each command in the drained batch:
    need = vertexBytes + indexBytes          // or record / template / texture bytes
    if the ring cannot satisfy `need`:
        Close + ExecuteCommandLists + Signal(copyFence)   // flush what is staged
        wait until the oldest tagged region's fence releases enough space
        Reset allocator + command list                    // rotate 2-3 allocators
    write into the ring; record CopyBufferRegion
Close + ExecuteCommandLists + Signal + wait; publish

The rule is therefore one submit per ring-full. For an ordinary interactive edit β€” a handful of objects β€” that degenerates to exactly one submit per batch, which was the original intent. The CPU fence wait at a flush is not a stall to be optimized away: it is the back-pressure that throttles the engineering thread’s production to the GPU’s ingestion rate, and it is the only thing keeping staging memory bounded during import.

Within one chunk, clone β†’ upload β†’ argument rebuild is a single recording. The copy queue executes strictly in order, so a clone completes before the copies that write into it; the three record / execute / CPU-wait cycles the batch path performs today are unnecessary. One CPU wait remains, immediately before publish. (Removing even that, via GPU-side cross-queue waits, stays under Later.)

Publish per chunk, not per batch. Each chunk ends with fully uploaded pages and rebuilt arguments, so publishing it is safe β€” and the user watches the model appear progressively instead of waiting through a multi-second freeze. A page left partially filled at a chunk boundary is simply re-cloned by the next chunk: 4 MB of extra copy per chunk, noise against 64 MB. Preferring to flush at the moment the current append page fills avoids even that.

Retirement must be swept between chunks β€” this is a correctness requirement, not an optimization. Publishing per chunk retires the append-target page on every chunk, but the fence-gated reclaim sweep historically ran once per copy-thread iteration, i.e. only after the whole batch returned. A batch producing many chunks therefore accumulated retired 5.5 MB pages (geometry + argument buffer) with nothing freeing them, and exhausted VRAM outright β€” E_OUTOFMEMORY partway through a bulk import, observed in testing before the fix. The sweep is now a callable function invoked both per copy-thread iteration and after each published chunk. Because reclaim is gated on min(all monitor render fences), a copy thread that outruns the monitors can still accumulate; so when the backlog stays above a cap (~64 pages) the chunk loop takes a few short bounded waits before continuing, which throttles the copy thread against the renderer instead of letting retention grow without bound. The waits are bounded so a frozen monitor degrades to the old behaviour β€” visible in the retire-backlog counter β€” rather than hanging the copy thread.

Oversize fallback. An upload larger than the entire ring β€” a jumbo STL mesh β€” gets a one-off committed staging buffer, so it can never deadlock waiting for space that will never exist.

Cap the CPU-side drain too. The ring bounds GPU staging; it does nothing for the std::vector<CommandToCopyThread> that GpuCopyThread fills with while (!commandToCopyThreadQueue.empty()). Each command carries a GeometryData holding two heap vectors, so lakhs of objects materialize as hundreds of megabytes and millions of small allocations before a single byte reaches the GPU. Drain until an estimated-bytes cap (a small multiple of the ring) instead of until the queue is empty, and leave the remainder queued β€” the queue then supplies upstream back-pressure. This closes the open throttling TODO in GpuCopyThread.

Page2D record uploads are on the ring too. ProcessCad2DCopyBatch takes the copy thread’s allocator and command list as parameters instead of creating a pair per batch, and CreateUploadWithData / UploadVector β€” which committed a fresh UPLOAD resource per vector, up to six per container page times every container, on every batch β€” are gone. It cannot pre-chunk the way the Scene3D path does, because a 2D batch rebuilds every container’s page wholesale and the total is not known until the records are expanded; submission is therefore driven from the other side, by the ring itself, with AcquireStaging flushing and retrying when it cannot satisfy a request. That reaches the same one-submit-per-ring-full rule. Flushing mid-rebuild is safe because every destination is a freshly created page nothing can reach until PublishCad2DPages at the end.

Texture uploads are the last holdout, and they are blocked on a fence defect rather than on effort. ProcessTextureUpload still commits an upload buffer and creates its own allocator and command list per texture. It cannot simply join the ring, because it signals a fence value reserved on another thread before the copy thread reaches the request (three sites in UserInterface-DirectX12.cpp), while the copy thread bumps copyFenceValue once per loop iteration and once per published chunk. A reserved value can therefore be lower than one the copy queue has already signalled β€” the icon-atlas rebuild on a DPI or monitor-topology change is exactly when that happens. GpuUploadRing::TagSubmission pushes onto a deque that Reclaim pops front-first while front().fence <= completed, which is correct only for monotonically increasing fence values; feed it an out-of-order one and reclaim releases ring bytes the GPU is still reading, with no debug-layer warning. Prerequisite: move fence-value allocation to signal time on the copy thread and publish it back through req.completionFence β€” the non-UI fallback branch in ProcessTextureUpload already does exactly this β€” then have the three UI callers wait on the written-back value. That also fixes the latent non-monotonic signal on its own. Texture staging joins the ring after that, and needs an alignment argument on Allocate (UpdateSubresources places footprints at 512, the ring aligns to 256).

Sizing sanity check, taking one ring fill as the unit of work:

Object Bytes each Objects per 64 MB fill
Cuboid (24 vertices, 36 indices) 456 B ~147,000
Sphere (36 Γ— 18 β€” 684 vertices, 3,888 indices) ~18 KB ~3,600

A lakh of cuboids is roughly two-thirds of one ring fill; a lakh of spheres is ~29 fills and ~1.9 GB of geometry. (Both figures are on the 16-byte vertex; they were 648 B / ~24 KB per object at 24 bytes, so every byte count in this section fell by about 30%.) Argument and template staging is small beside this β€” even a densely packed 4 MB page rebuilds in ~155 KB.

Page compaction / Defragmentation

(Rewritten July 2026: the original freeze-based design predated the RCU page system that was actually built. Same requirements, simpler mechanism.)

Defragmentation rides the existing RCU clone path and needs no frame freeze and no resource-state gymnastics:

  • Every ADD / MODIFY / REMOVE batch already clones the affected pages on the copy queue, applies changes to the clones, rebuilds each clone’s ExecuteIndirect argument buffer and publishes the new snapshot atomically. Render threads keep drawing the old pages until the swap, so nothing ever freezes.
  • When a page’s holeBytes cross the ~25% threshold, its clone step switches from whole-page CopyResource to per-live-object CopyBufferRegion, driven by the GeometryPlacementRecordInPage table, packing survivors tight. Offsets are remapped in CPU metadata; the argument-buffer rebuild β€” mandatory on every clone anyway β€” picks up the new offsets for free.
  • Relocating an object never touches byte contents: indices are object-relative, resolved per draw through BaseVertexLocation / StartIndexLocation.
  • At most one page compacts per batch (bounds the extra copy volume); a clone that ends up empty is dropped instead of published (empty-page GC β€” already implemented).
  • EI argument buffers stay tightly coupled to pages: regenerate per clone, never patch. (Unchanged rule.)

As implemented:

  • The punch and the compact can never be the same batch, by construction. Holes are added to the clone during Pass 3, while the compaction decision is made in Pass 2 against the old page. So a batch that creates holes always copies whole-page, and only a later batch that re-clones that page packs it. This is not a limitation to design around β€” it falls out of RCU and keeps the decision on immutable data.
  • Compaction competes with the empty-page GC, and the GC usually wins. A page whose objects all leave drains to objectCount == 0 and is dropped rather than compacted, which is strictly better. Compaction therefore only ever fires on pages that keep survivors β€” exactly the case the GC cannot handle. A test that modifies every object measures nothing; it must modify a fraction and then come back for the rest.
  • The clone no longer copies the old argument buffer. Every clone rebuilds it unconditionally a few lines later, so the CopyResource was moving 1.5 MB that was immediately overwritten β€” and it quietly contradicted the “regenerate, never patch” rule above. Removing it took a clone from ~5.5 MB to ~4.0 MB, a 27% cut in all clone traffic, compacted or not. Only the first indirectCount commands are ever read, so the untouched tail of a fresh buffer is harmless.
  • Survivors keep their relative order, so a packed offset can never exceed the original and the page cannot overflow. Placement follows the same rules as a fresh append β€” whole multiples of sizeof(Vertex) up, 4-byte-aligned indices down β€” because the draw path derives BaseVertexLocation by dividing, and Selection3D repeats that division independently.
  • Measured on a 10,000-object scene: page count stabilized at 56 with compaction against 340 and still climbing without it, and the compacted counter is on the heartbeat next to clones.

Growth logic needs no special path either: a container simply gets more pages β€” new geometry lands in the page with the largest middle gap, else in a fresh page. Pages are 4 MB today; once chained doubling lands (Later), successive new pages of a fast-growing container double up to 64 MB, but the rule is unchanged: nothing grows in place, so nothing freezes. The old plan (grow a heap in place while the tab’s views freeze) is gone.

Defragmentation

Two arenas accumulate holes. Neither is compacted eagerly; both wait until a hole threshold is crossed.

  • Geometry pages β€” as already specified in Defragmentation logic above: when a page’s holeBytes crosses ~25%, its next RCU clone copies live ranges packed (per-object CopyBufferRegion from the placement records) instead of a whole-page CopyResource, at most one page per batch. The argument/template rebuild is mandatory on every clone anyway, so it picks up the remapped offsets for free. Cross-page compaction is the new part, and it needs a deliberate pass rather than riding a clone.
  • Instance arena β€” every move burns a slot, so holes accumulate at edit rate. Note that the fence-gated free list already recycles them, so the arena does not grow without bound; what a pass buys is index contiguity, by relocating live records and rewriting the affected redirect entries. gpuInstanceIndex is untouched throughout, which is precisely what the redirect indirection buys.

Index contiguity is deliberately not maintained between passes, and no allocation policy attempts to preserve it. None of this is in the first implementation.

Big-buffer fallback

if Allocation_Size > Max_Page_Size, allocate a dedicated committed resource just for that object, bypassing the paging system. This handles large STL meshes or terrain maps. Treat big buffers as a special page type with a separate “large object list”. Don’t jam them into the standard EI logic if they need unique per-object resource bindings β€” one separate draw call per jumbo object. Keep a separate std::vector<BigObject> in the tab structure. Rendering: loop through pages (ExecuteIndirect), then loop through big objects (standard DrawIndexedInstanced, or EI with count 1).

Per-tab VRAM isolation

Each tab has its own completely separate VRAM, except for the un-closeable tab 0 which stores common textures and UI elements.

To support hundreds of simultaneous tabs, we start with a small heap (say 4 MB per tab) and grow it only when necessary. Each page can be a mixture of geometry types (cylinders, cubes, I-beams, …) instead of one giant 256 MB buffer. Don’t manually destroy heaps on tab switch β€” use Evict and let the OS handle caching. If the user clicks back to a heavy tab, MakeResident is faster than re-creating heaps. Tab 0 is always resident. Eviction happens with a lag of a few seconds. A more advanced, system-memory-budget-based eviction strategy comes after the rest of the spec is implemented.

Each page carries a corresponding ExecuteIndirect argument buffer. When we defragment a page, we must simultaneously rebuild its argument buffer.

Shared Geometry and the primitive libraries

Shipped for SPHERE, CUBOID, CYLINDER, and LINE_MEMBER’s five solid-section profile series (global library, template-only pages, per-frame LOD in the compute pass, inverse-transpose normals). Every other shape is still bespoke β€” PIPE deliberately so.

Build sequence: sphere first, in two steps

The gate this step waited on is open: it had to follow the vertex-format migration so the library buffer is built once, in the lean 16-byte layout, and both the 16-byte vertex and the packedColor registry shadow have shipped. The remaining prerequisite that was not on the original list is the canonical local frame β€” a library mesh is only shareable if an object’s vertex bytes stop depending on its own parameters β€” and that is now done for SPHERE (see The canonical local frame, and its single composition point).

Sphere only, to begin with. A sphere is uniformly scaled, which means it needs no inverse-transpose normal transform; that work belongs with the cylinder, which is where non-uniform scale first appears. Cuboid, cylinder (both windings), disc and the tori follow once the mechanism is proven.

As it landed: cuboid and the solid cylinder followed, together, in the step after the sphere β€” with the inverse-transpose normal transform, since both are non-uniformly scaled. The cylinder’s second, inward-facing winding did not follow, and neither did PIPE; see Deferred with PIPE below for why that pair travels together.

The step splits in two, and the split is worth taking because it isolates a failure to one cause:

Step 1 β€” shared geometry, CPU-fixed LOD. No HLSL changes at all. Shipped. The compacted command already carries its own buffer views, and the CPU supplies them per page as root constants, so a template-only page whose views name the library instead of page.buffer flows through ShaderSceneCull.hlsl unmodified β€” not one line of shader changed. The CPU writes the fixed LOD’s real offsets into an ordinary 24-byte template. What it touched:

  • gpu.primitiveLibrary plus a CPU-side (shapeId, lod) β†’ {indexCount, startIndexLocation, baseVertexLocation} table, built and uploaded in InitD3DDeviceOnly.
  • GeometryData::libraryShapeId (int16_t, βˆ’1 = bespoke); SPHERE::GetGeometry emits a library reference and no vertices.
  • IsTransformOnlyEdit becomes empty payload and libraryShapeId < 0. This is what keeps the trap below from firing, and it costs nothing: a move arrives from TranslateSelectedSceneObjects, which knows nothing of libraries, so it is still correctly a redirect flip; a radius edit arrives through GeometryForObject carrying a shape id, so it takes the full path and rewrites the template. That path clones a 256 KB template-only page, not 4 MB, so the cheaper “shape id unchanged β†’ also transform-only” optimisation is deliberately not built β€” it needs a registry lookup and buys little until drag-resize exists.
  • GeometryPage::kind, the page-kind branch in the page walks and in RebuildIndirectBuffer, and template capacity as the append criterion.

Two things it turned out to need that were not on the list, both found by reading the code rather than by running it: the buffer-view construction was written out separately in three files (scene loop, BindPageBuffers, print collector), so a second page kind would have been three places to remember β€” they now share PageVertexBufferView / PageIndexBufferView; and the vertexByteOffset / sizeof(Vertex) division that graphics.md already flagged as triplicated would have become six sites, so it is now one ResolveObjectDrawRange. Both are net deletions of duplication rather than additions to it, which is the only reason the page-kind branch is as small as it is.

An instanced page’s RCU clone copies no vertex or index data because there is none to copy. drawn tracks commands exactly and overflow stayed 0 throughout, so the compaction pass is still emitting one ExecuteIndirect per Viewport across both page kinds.

Step 2 β€” LOD in the compute pass. CullParams 12 β†’ 16 DWORDs, three more root SRVs (redirect table, arena, library table), shape id riding in StartInstanceLocation. Signature 30 of 64 DWORDs. The library’s (shapeId, lod) table rides in the same buffer as the mesh, after the indices, so the whole library stays one resource and one upload. Details and the three consequences are under LOD below.

Doing LOD now, before the cull cache exists, is deliberate β€” the cache is deferred with SceneEpoch, so camera-dependent command content can be designed into its key from the start rather than retrofitted. Step 2 also settles an open question in Planned: draw buckets, which asks whether the cull pass should reach renderFlags by binding the arena and redirect table or by copying flag bits into the template. LOD needs those two bound anyway, so the first option β€” the one that preserves “an appearance change never touches geometry” β€” becomes the incumbent at no extra cost.

One draw call, not two β€” where the two kinds actually diverge

The obvious reading of “a second page kind” is a second set of draw calls. It is not, and preserving that is the point.

On the compute path a template-only page dispatches into the same output buffer under the same atomic counter as every bespoke page, so a Viewport remains one ExecuteIndirect whatever mix of page kinds it holds. Both kinds emit the identical 56-byte VisibleIndirectCommand, same PSO, same vertex shader; one command’s views name a library, the other’s name a geometry page. In Step 1 even the compute shader is shared β€” only the root-constant values differ.

What actually changes is one branch inside four page walks β€” the scene loop, the pick pass, the highlight pass and the print path β€” selecting library views instead of page.buffer views. The three per-page paths still issue one call per page, but that is what they already did.

Why the library is not a tab 0 GeometryPage

The library must sit outside the RCU/page system, and the reason is sharper than “it never changes”. A compacted command carries the raw GPU virtual address of its vertex buffer, and every GeometryPage is RCU-managed: any modify to any object in that page clones it to a new buffer at a new address and retires the old one. A cross-tab reference into a tab 0 page would hold an address the copy thread is free to invalidate. Tab 0 being un-closable does not help β€” the clone is what breaks it, not tab lifetime.

So it is one immutable committed resource in the ΰ€Άΰ€‚ΰ€•ΰ€° singleton, uploaded once in InitD3DDeviceOnly before any thread exists: fixed address for the process, no snapshot, no container directory, no retirement, no fence gating.

Why a template-only page rather than mixing kinds in one page

The cull dispatch passes one VBV/IBV per page as root constants. A page mixing library-sourced and page-sourced objects cannot do that, since the views differ per object. Mixing therefore forces one of two costs: per-command views in the persistent template, taking it from 24 to 56 bytes β€” 320 MB at 10M objects β€” or a second dispatch per page with different constants. A template-only page keeps templates at 24 bytes and keeps Step 1 free of shader changes.

Second-order win, unchanged from the original argument: such a page has no 4 MB geometry buffer, so it costs ~256 KB, and adding one sphere to a scene of 100,000 spheres clones 256 KB rather than 4.25 MB.

One deviation from what this section says below: GeometryPlacementRecordInPage has 7 spare bytes, so (libraryShapeId, lod) goes there rather than overloading the byte-offset fields. Leaving vertexByteOffset / indexByteOffset / vertexSize / indexSize at literal zero makes the hole accounting and compaction arithmetic correct by construction instead of needing guards.

The sphere LOD ladder

Eight levels, LOD 0 coarsest, keeping the existing UV-sphere parameterisation (slices Γ— stacks, smooth normals β€” on a canonical unit sphere the normal is the position):

LOD slices Γ— stacks triangles vertices indices selected at
0 4 Γ— 2 16 12 48 < 2 px
1 8 Γ— 4 64 40 192 2–4 px
2 12 Γ— 6 144 84 432 4–8 px
3 16 Γ— 8 256 144 768 8–16 px
4 24 Γ— 12 576 312 1728 16–32 px
5 36 Γ— 18 1296 684 3888 32–64 px
6 48 Γ— 24 2304 1200 6912 64–128 px
7 64 Γ— 32 4096 2112 12288 β‰₯ 128 px

The whole ladder is ~123 KB (72 KB vertices + 51 KB indices), once, for the entire process β€” less than one bespoke object’s share of a page. The largest entry is 2112 vertices, so 16-bit indices stay valid with room to spare.

LOD 5 is byte-identical to the mesh that ships today, which is what makes Step 1 verifiable as an unchanged image, and is why the UV sphere was kept over an icosphere β€” icosphere subdivision (20, 80, 320, 1280, 5120) fills only five of the eight slots naturally and would forfeit that check.

Selection is by projected pixel diameter, not raw distance. Distance alone is wrong under zoom: the same sphere at the same distance deserves different tessellation at 4K and at 1080p, and at 20Β° FOV against 60Β°.

focalPx    = sceneHeightPx / (2 Β· tan(fovY/2))                  // per Viewport, one root constant
centre     = (transformA.w, transformB.w, transformC.w)
radius     = length(float3(transformA.x, transformB.x, transformC.x))   // canonical radius is 1
diameterPx = 2 Β· radius Β· focalPx / max(distance(cameraPos, centre), eps)
lod        = clamp(floor(log2(max(diameterPx, 1))), 0, 7)

Coarsest-first ordering is what removes the subtraction. The input is bucketed by powers of two, so firstbithigh((uint)max(diameterPx, 1)) gives the same answer in one integer instruction with no transcendental β€” clamp still required above 256 px. Note this makes typical scenes coarser than today, where every sphere pays 1296 triangles whether it covers 10 px or 500 px.

A debug key pinning a fixed LOD belongs with Step 2, matching the existing k / m / v habit, so popping can be A/B’d against Step 1.

The cuboid and cylinder ladders

A cuboid has nothing to tessellate away, so one 12-triangle mesh (24 vertices, 36 indices, bifurcated for flat per-face normals) serves all eight LOD slots. Uniform array width is what keeps the cull shader’s (shapeId * 8 + lod) indexing branch-free, and a duplicate table row costs 40 bytes β€” far cheaper than a per-shape LOD count the shader would have to read.

A cylinder needs no stack subdivision either; only the radial segment count varies:

LOD 0 1 2 3 4 5 6 7
segments 3 4 6 8 12 36 48 64

LOD 5 = 36 segments is what CYLINDER::GetGeometry emitted before the library took the mesh over, which keeps the same “a pinned level reproduces the previous tessellation” property the sphere ladder has. At 10 vertices and 12 indices per segment and 181 segments summed over the ladder, the cylinder entry is ~33 KB and the cuboid is 456 bytes; the whole three-shape library measures 156.6 KB, one upload, once for the process.

The canonical cylinder is a solid rod along +Z, radius 1, length 1, centred on z (spanning βˆ’0.5..+0.5). Centring is not about the projection β€” which does not care where a mesh’s local origin sits β€” but about two things downstream. SelectLodForInstance reads the object’s position as the transform’s translation row, which for a centred mesh is the geometric centre rather than one end; and the canonical bounding radius comes out sqrt(rΒ² + (L/2)Β²) = 1.118 instead of the far-rim corner’s sqrt(2) β‰ˆ 1.414, so the projected-size estimate is less conservative. It also makes the registry’s worldCentre shadow come out exactly equal to the translation.

Winding: front faces point into the solid

This one cost a debugging session and it fails as a hole rather than as an error. The scene, pick and highlight PSOs all back-cull β€” CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT) is CULL_BACK with FrontCounterClockwise = FALSE β€” and the camera matrices are left-handed (XMMatrixLookAtLH / XMMatrixPerspectiveFovLH). Under that pair a triangle is front-facing when cross(v1 βˆ’ v0, v2 βˆ’ v0) β€” the ordinary right-hand rule β€” points AWAY from the viewer, i.e. into the solid.

So winding and the stored per-vertex normal point in opposite directions, deliberately: the normal is outward because lighting needs it outward, the winding is inward because the rasterizer’s front-face test needs it inward. AppendUnitSphereMesh already obeyed this β€” measured, not assumed: all 1224 of its non-degenerate triangles at 36Γ—18 wind cross-opposite-normal.

The new cylinder was first written the intuitive way, outward-wound, and the symptom was a cap-shaped hole in the near end of a solid rod β€” the near cap and the far wall both culled, sky visible straight through. Nothing logged, nothing asserted; the far cap still drew, so the object still looked like a cylinder until you looked at the end of it. The check that settles it is arithmetic rather than visual: for every triangle, dot(normalize(cross(v1βˆ’v0, v2βˆ’v0)), storedNormal) must be β‰ˆ βˆ’1.

Worth recording as a separate observation, not fixed here: CUBOID::GetGeometry wound its six faces outward before the library took its mesh over, so cuboids had always been rasterised from their far faces and shaded by normals belonging to surfaces the viewer cannot see. The library mesh corrects it. The remaining bespoke generators (PYRAMID, PARALLELEPIPED, CONE, FRUSTUM_*, the tori) have not been audited against this rule.

Reshaping CUBOID to a box

Cuboid was called “nearly free” while the mesh was the only thing being considered. The mesh is free; the transform was not, because the type stored eight free corner vertices with nothing enforcing that they formed a box, and a unit cube maps onto eight arbitrary points only if those points are an affine image of a cube. Instancing was impossible until the storage said “box” rather than “eight points”.

So the storage changed, and stored .yyy files broke β€” no migration and no version gate, because the software is unreleased. CUBOID now holds:

XMFLOAT3 center;       // authored centre
XMFLOAT3 size;         // FULL edge lengths along the box's own X/Y/Z
XMFLOAT4 orientation;  // unit quaternion; identity = axis-aligned

Orientation lives on the type, not only in Placement3D. Three degrees of freedom are genuinely needed β€” a single direction vector fixes only two and leaves roll free, which a box (unlike a cylinder) very much notices β€” and it has to be a separate rotation from the placement, because the placement means “moved since drawn”: a box authored at 30Β° and a box the user rotated by 30Β° would otherwise be indistinguishable. A sphere never raised the question because a sphere has no orientation.

The canonical→authored matrix is scale(size) · rotate(orientation) · translate(center), with Placement3D composing authored→world on top exactly as before. That is still scale → rotate → translate with no shear, so the cheap normal shortcut below stays valid. PARALLELEPIPED is the same unit cube under a sheared transform and would need the full inverse-transpose — out of scope, and worth not painting into a corner.

In the proto the new fields take new numbers and field 1 is reserved. That is not caution for its own sake: protobuf merges a repeated field into a singular one by taking the last element, and absent new fields default to zero, so reusing field 1 would make a pre-change cuboid decode silently as a zero-size box at its last corner vertex. Reserving it drops the stale data instead of aliasing it. (Field 20 stays the placement, by the convention that holds across every 3D message.)

CUBOID also gets a Properties Pane table for the first time β€” it was a vertex-list type with no table at all. Centre is the single point group; the three sizes are scalars a rigid placement cannot touch; and the quaternion is presented as XYZ Euler angles in degrees, composed on read and solved back on write, which is the same shape as the existing world-coordinate handling for point fields rather than a new mechanism. Writing one angle rebuilds the whole quaternion from all three, for the same reason editing one world component of a point rewrites all three authored components.

Structural members that are library primitives

An RC beam or column is a rectangular section swept along a line, which is exactly a scaled and oriented unit cube β€” and a circular one is the unit cylinder. So every LINE_MEMBER whose profile is a single solid rectangle or circle emits a library reference instead of vertices, with no new library entry and no new renderer path:

Profile series Library shape Scale
PARAMETRIC RECT cuboid (width b, depth h, length)
PARAMETRIC CIRC cylinder (d/2, d/2, length)
BAR SQUARE cuboid (a, a, length)
BAR FLAT cuboid (width a, thickness b, length)
BAR ROUND cylinder (a/2, a/2, length)

Measured against the bespoke extruder they replaced: the eight corners of the transformed unit cube coincide bit-exactly with the 24 vertices AppendExtrudedConvexPrism emitted for the same member. The two circular cases additionally gain LOD β€” they were a fixed 24-gon and are now selected from 3 to 64 segments by projected size.

The two families are sized from different places, and conflating them would be silent. userParameter1/2 are declared parametric-family fields β€” RC sections carry their dimensions on the member, with 0 meaning “use the catalog row”. A BAR is sized by its catalog row alone, so reading those fields for a bar would quietly resize every bar a user had ever typed a parameter into. The helper keeps the two rules apart, and a regression check sets 999/888 on a BAR FLAT and confirms it still draws at its catalog 100Γ—8.

This is the first type whose local frame depends on its DATA rather than on its type. Every other canonical-frame type answers “which frame” from the ObjectType alone; a member answers it from its profile row β€” canonical for PARAMETRIC:RECT and PARAMETRIC:CIRC, identity for the I-sections, channels, angles and the rest, which still bake world coordinates into their vertices. That breaks the “one type, one frame” reading of WorldMatrixForObject’s switch, and the failure it invites is the one that bit SPHERE: if the generator and the matrix builder ever disagreed about whether a member owns vertices, moving it would redraw it at the wrong size. Both therefore call one function, LineMemberCanonicalFrame, which returns the shape id and optionally the matrix β€” the generator asks for the verdict alone. That helper also owns the userParameter > 0 ? userParameter : catalog default fallback, which would otherwise be duplicated.

The section basis is not the cylinder’s, and the two are not interchangeable. MemberSectionBasis stands the section’s local +y along world +Z β€” the STAAD BETA = 0 convention, so section depths are vertical by default β€” and falls back to +X for vertical members. CYLINDER’s own frame uses +Y. Both are “an orthonormal basis about the axis” and they differ by a roll, which a circular section does not notice and a rectangular one very much does; using the wrong one would tip every beam’s section about its own axis with nothing to show for it but a subtly wrong model.

What stays bespoke, and why: PARAMETRIC OCT, PARAMETRIC HEX and BAR HEX would each need their own prism entry. Every multi-piece family β€” I, channel, tee, angle, RHS, bulb, rail β€” emits two to five convex pieces from one GeometryData, and libraryShapeId is a single field, so they wait on One engineering object, up to 256 graphics objects. CHS waits on the bore deferred with PIPE.

One deliberate asymmetry inside BAR: the generator treats anything that is not ROUND / HEX / SQUARE as FLAT, where the helper names FLAT explicitly. A series neither has heard of therefore draws bespoke-as-flat rather than instanced-as-flat β€” the same picture, and the safe direction for the two to disagree in.

Byproduct: AppendExtrudedConvexPrism winds outward, so every member it built was rasterised from its far faces under the rule in Winding: front faces point into the solid. The instanced profiles inherit the correctly-wound library mesh and stop doing that; the families still using the extruder do not, which is what the audit item under Next is for.

LOD

LOD is selected per frame, in the compute pass. Camera position plus a focal length go into CullParams, which had a spare cullPadding β€” 12 DWORDs becomes 16. Object position comes from the instance record’s transformA/B/C.w, so the shader binds the redirect table and arena, two more root SRVs with tab-lifetime fixed addresses β€” the same two-load pattern the vertex shaders already perform. The shader writes the chosen LOD’s offsets into the output command; VBV/IBV are unchanged, since all 8 LODs live in the same library buffer.

The metric is projected size in pixels, not raw distance. Distance alone is the wrong quantity: the same sphere at the same distance deserves different tessellation at 4K and at 1080p, and at 20Β° FOV against 60Β°. focalPixels = sceneHeightPx / (2Β·tan(fovY/2)) folds resolution and FOV in on the CPU, and the shader needs one divide:

diameterPx = 2 Β· radius Β· focalPixels / distance(camera, centre)
lod        = clamp(floor(log2(max(diameterPx, 1))), 0, 7)

radius is the largest of the transform’s three row lengths times the entry’s canonical bounding radius. Row i of the row-vector world matrix is (transformA[i], transformB[i], transformC[i]), so its length is the scale along local axis i. Reading row 0 alone was exact while a unit sphere was the only shape β€” bounding radius 1, uniform scale β€” but a cylinder is scale(r, r, L), and row 0 alone sees only r, picking far too coarse a level for a long rod seen end-on. PrimitiveLibraryEntry therefore carries a boundingRadius, measured from the entry’s own vertices rather than assumed (1.0 for the unit sphere, 1.118 for the unit cylinder, 0.866 for the unit cube), and PrimitiveLibraryDrawRange grew 12 β†’ 16 bytes to carry it to the shader. It is a property of the shape, not of the level, so the selector reads LOD 0’s entry before a level has been chosen β€” which is also what avoids a circular dependency on the answer being computed. Because the ladder buckets by powers of two, floor(log2(x)) is firstbithigh β€” one integer instruction, no transcendental β€” and coarsest-first LOD ordering is what removes the subtraction a fine-first ladder would need.

An instanced template carries its shape id in StartInstanceLocation as shapeId + 1, so 0 still means “bespoke” and shape 0 stays distinguishable β€” no template format change and no second template struct. That field is otherwise dead weight, but it is a real draw argument and the legacy, pick and print paths execute these templates directly; it is inert only because nothing here uses SV_InstanceID or a per-instance vertex stream. The compacted output command therefore always writes 0 rather than passing the marker through. Adding either of those two things would break this silently.

Three consequences. The legacy path draws instanced geometry at a CPU-fixed LOD, since its templates are static β€” correct, just not adaptive, which is acceptable for a reference path. Camera motion must join the cull-cache key, because the deferred (epoch, filter revision, visibility revision) cache from GPU command compaction no longer holds once command content is camera-dependent; that cache exists for static Viewports, so it is a fair trade, but it has to be designed in rather than discovered.

The third was not predicted and is worth recording, because it is visible rather than subtle: the selection highlight z-fights the object it highlights. The overlay redraws the same object through an ordinary CPU draw at the CPU-fixed level while the scene draws a level the GPU picked, so two tessellations of one sphere sit at nearly equal depth and LESS_EQUAL passes in some pixels and fails in others β€” the object’s own colour speckles through the highlight in a lattice following the tessellation. Matching the two levels is not cheaply possible from the CPU: the transform it would need to compute the same LOD lives in the device-local arena. The fix is a negative depth bias on the highlight pipeline, which also covers every future case where an overlay mesh and the scene mesh disagree rather than only this one. Depth write stays off, so nothing downstream inherits the shifted depth.

A debug key (l) pins instanced templates back to the CPU-chosen level, which is what makes LOD popping β€” a tuning question rather than a correctness one β€” A/B-able without a rebuild.

Non-uniform scale and normals

(shipped with the cuboid and cylinder β€” it had to be, since both are non-uniformly scaled)

Instancing a cylinder needs scale(r, r, L). Positions are fine β€” the vertex shader computes p Β· M and does not care what M contains. Normals are the whole issue, and it is wrong lighting rather than a crash, so it would ship unnoticed.

Normals transform by inverse(transpose(M₃ₓ₃)), not by M. Under uniform scale the two differ only by a scalar that normalize() cancels β€” which is exactly why the shader is correct today and why the InstanceRecord’s uniform-scale assumption was safe to write down. Under non-uniform scale it does not cancel: scale a sphere to (1, 1, 10) and the shading bands stop matching the silhouette.

The fix costs no storage. For M = SΒ·R, row i of M is sα΅’ Β· Rα΅’, so sα΅’ = length(rowα΅’) and inverse(transpose(M)) = S⁻¹·R, whose row i is Mα΅’ / sα΅’Β² β€” three lengths, three divides, then normalize, on a transform the shader already loads. It imposes one rule: composed transforms must stay scale β†’ rotate β†’ translate, never sheared.

As implemented, two details differ from that sketch and both are simplifications. There are no square roots: scaling the normal by the three reciprocal squared row lengths before the same three dot products the shader already did is algebraically identical, so dot(rowα΅’, rowα΅’) is used directly. And it touches ShaderSceneVertex_16.hlsl only, with the parked _24 twin kept in step β€” the pick shaders forward no normal at all, so they never needed it, and the highlight path inherits the fix by reusing the scene vertex shader. A max(…, 1e-12) guards a degenerate zero-scale transform, which would otherwise turn into NaN across the whole object at the pixel shader’s normalize().

The scale is derived, never stored β€” radius and length are the object’s own engineering fields, composed by GeometryForObject. Placement3D stays rigid and nothing in the schema changes.

Deferred with PIPE: the inward-facing bore

PIPE stays bespoke geometry β€” no decomposition into outer wall, inner wall and annular end caps; PIPE::GetGeometry keeps emitting its own vertices. Worth stating plainly, because a pipe was the headline case for instancing in this document: this is a deliberate deferral of the biggest VRAM win, not an oversight. The annular end cap would stay a catalog item regardless, since no affine transform moves two concentric radii independently.

The inward-facing bore entry is deferred with it, and only because its consumer went away: the bore’s sole user would have been a pipe’s inner wall, so building it now would be an entry with no producer. The reasoning for keeping inner and outer as separate library entries rather than one mesh with a flipped rasterizer state is untouched and should be reread when pipes come back: a bore is the same surface seen from inside, so it needs front faces pointing inward, and flipping that with rasterizer state would mean a second PSO and therefore a second ExecuteIndirect per Viewport. A second library entry with reversed winding costs one table row and keeps the single-call property.

Arena reservation sizing

reserve = min(MV_MAX_INSTANCES_PER_TAB, allowedByHardware Γ— 0.75)

allowedByHardware comes from MaxGPUVirtualAddressBitsPerResource β€” as low as 31 bits (2 GB) on the lowest tier, capping one tab’s arena at ~33M records β€” with MaxGPUVirtualAddressBitsPerProcess bounding the sum across open tabs. Both are queried at startup beside the existing Heap Tier 2 and TiledResourcesTier checks. The 25% margin is deliberate headroom for everything else competing for the same address space: geometry pages, template buffers, textures, swap chains, the upload ring, and the redirect and mask buffers. A first estimate, to be tuned once there is real data.

Build order, and seven places the current code fails quietly

The library buffer is a VBV source, so its stride must match the PSO input layout. It is therefore built only after the vertex format migration, in the lean 16-byte layout, so it is never built twice. The sequence is: appearance payload and registry shadow β†’ vertex 24 β†’ 16 with the material table β†’ canonical local frame β†’ this step. Everything before the last item has shipped.

Six things broke silently under this design. The first four were predicted, the last two were found only by reading the code β€” and all six are fixed as of Step 1. They are kept here because each one is a trap the next shape will walk into again, and because none of them would have failed loudly. A seventh arrived with the next shape, exactly as predicted β€” see Winding: front faces point into the solid, which the cuboid and cylinder both got backwards on the first attempt:

  • PageIsRenderable rejects every template-only page. It requires vertexHead != 0 && indexTail != pageSize, and a template-only page has neither β€” so the draw loop, pick pass and print path all skip instanced geometry with no error anywhere. The predicate must branch on page kind. Note it is duplicated three times, not centralised: Selection3D-DirectX12.cpp owns the named function, the scene loop repeats it inline, and the print collector repeats it a third time. Fixing one and not the others makes instanced geometry vanish from that path alone.
  • IsTransformOnlyEdit misclassifies every instanced MODIFY. It infers “transform-only” from an empty vertex/index payload β€” and an instanced object’s payload is always empty. Moving a pipe and changing its schedule become indistinguishable: the first is correctly a redirect flip, the second silently keeps the old library entry, so the wall thickness never changes on screen. The libraryShapeId discriminator above is the explicit marker this asks for. This is the transform-only edit’s own lesson reappearing from the other direction.
  • The registry’s worldCentre has no source. It is computed at upload time from the geometry’s AABB, and an instanced object uploads none. It must come from the library entry’s AABB transformed by the instance transform, or every instanced object reports (0,0,0) and silently breaks zoom-to-fit.
  • Append-target page selection has no meaning for a template-only page. “Largest middle gap” is a vertex/index-region concept; the criterion there is template capacity (indirectCapacity).
  • The copy thread rejects empty geometry outright. ADD logs Skipping upload of empty geometry and breaks; the geometry-MODIFY path does the same silently. Since an instanced payload is empty by definition, this is the first line that has to change β€” today an instanced ADD is dropped with a console warning and nothing is drawn.
  • Zoom-to-fit iterates geometry.vertices on the CPU. It regenerates geometry through GeometryForObject and walks the vertices, so instanced objects contribute nothing and a sphere-only scene makes zoom-to-fit a no-op. This is the same shape as the worldCentre gap but a separate code path β€” fixing the registry AABB does not fix it. It needs the canonical AABB’s eight corners transformed by the world matrix.

The draw path

What a Viewport draws: SubTabs, Viewports, container sets

  1. Rename the content-level β€œview” concept to SubTab. A SubTab has exactly one content type β€” Scene3D or Page2D, never mixed, because a mixed SubTab would have ambiguous renderer and interaction semantics β€” and holds a set of containers of that type. VIEW_INSIDE_DATASETTAB, tab.views and activeViewIndex are dead code referenced only from a comment; delete them rather than renaming them.
  2. Introduce Viewport as a separate object owning the Scene3D camera (or Page2D pan/zoom), input/pick state, render-target rectangle and update scheduling. Per-view cameras and Page2D pan/zoom already exist per sub-tab slot; this step lifts them out so several Viewports can show one SubTab with different cameras, and so a window can later host several side by side. The shared DX12ResourcesPerTab::camera write-through is already gone β€” the camera is passed down as a parameter.
  3. Replace one-container-only render selection with a SubTab container set. When the set alone defines the subset, store it as a compact rule; do not set a per-object mask bit for every member merely to express “this whole container”.
  4. Add a snapshot-level containerMemoryId -> GeometryPage list directory, so a SubTab that selects a few containers stops walking every page in the tab. Independently of that directory, hoist the container test above the vertex/index buffer binds in RenderScene3D: today every page pays two IA binds before its argument count is discovered to be zero.

As implemented:

  • SubTabContainerSet is inline storage, not a vector. Render threads read it lock-free every frame, so a heap buffer the engineering thread could reallocate underneath them is exactly the hazard to avoid; the compositor copies the set by value once per frame and the render thread owns that copy for the frame. It is also the “compact rule” the step asks for β€” container membership is one entry here, never a per-object mask bit, so opening a SubTab stays O(1) instead of O(objects).
  • The directory subsumed the hoist. Items 3 and 4 turned out to be one change: once the draw loop iterates the set’s containers through pagesByContainer, pages of other containers are never visited, so there is nothing left to hoist above the IA binds β€” and the ExecuteIndirect-with-count-0 trick that used to express “wrong container” is gone entirely. The pick and highlight paths share one ForEachSubTabPage helper. The scene draw loop (RenderScene3D) and the print collector (Collect3DPages) each carry their own copy of the same walk β€” three copies of the predicate, not one, because the print path takes ComPtr copies rather than visiting pages in place. They agree today; RenderScene3D’s copy is the one that has since grown a compute-cull branch, so it is where a divergence would appear first.
  • PageIsRenderable lost its container argument. Reaching pages through the directory means a page a caller can see already belongs to the SubTab; leaving the test in would have been a second, redundant source of truth.
  • The Viewport lift was small because the accessors already existed. Camera access had already been funnelled through ActiveSceneCamera and Page2D pan/zoom through Cad2DInputView, so moving both into Viewport touched about ten call sites rather than the sprawl it would have been a few phases ago. TabCad2DStorage::views[] moved out wholesale: pan/zoom is view state, and it now sits beside the camera in the object that owns both.
  • RenderPage2D now takes a const Cad2DViewState& instead of a slot index, mirroring how the Scene3D renderer takes a camera. That is the Key boundary rule above finally holding on both sides: each renderer receives a container, a view state and a viewport, and neither reaches for view state itself.
  • Viewport-to-SubTab is 1:1 today (viewports[i] drives subTabs[i]), recorded in Viewport::subTabSlot so the mapping is a stored fact rather than an assumption baked into every reader. Nothing outside the open/close path assumes it any more, which is the whole point β€” a second Viewport onto one SubTab now needs a slot allocator, not a refactor.
  • Deliberately not built: the render-target rectangle and update scheduling fields named in item 2. The rectangle is still derived per frame by the compositor and scheduling does not exist yet β€” those belong with Viewport scheduling and scale limits, and adding empty fields now would only invite them to drift out of date.

Composing multiple containers into one Viewport (drag-to-compose): item 3’s container set was seeded with exactly one container until now β€” a Building (Civil) and its Plumbing (Mechanical) could coexist in a tab but not in one view. The set is now populated at runtime: dragging a Scene3D out of the data tree and dropping it on the inline scene appends its containerMemoryId (ADD_CONTAINER_TO_SUBTAB), and a top-centre chip strip removes composed containers again (REMOVE_CONTAINER_FROM_SUBTAB); the home container is never removable. It is home-plus-composable β€” a Scene3D keeps at most one home SubTab (double-click opens/focuses it) and is composed into others by reference. New geometry still parents to the home container.

  • Composition is a container-SET operation, not a 64-bit-mask one, and this is the load-bearing decision. Adding a whole Scene3D to a view is one O(1) append; its objects show by default because the mask is all-ones, so no per-object write is needed. The mask stays what Visibility mask made it: per-object hide within the composed set. Expressing whole-container membership through the mask instead would be O(objects) per compose and would burn one of the 64 bits per view β€” exactly what item 3 warns against.
  • Geometry is never duplicated by showing one Scene3D in several SubTabs. Pages are stored once per container and referenced by containerMemoryId from each SubTab’s set; several SubTabs listing the same container draw the same GPU buffers with different cameras. (Repeated placements of one Scene3D reuse geometry too β€” that is what the instance arena is for.) This is why per-container page ownership was kept rather than switching to a flat shared page pool: flat pages would force every active Viewport to cull all tab objects each frame (cost ∝ active-SubTabs Γ— total objects, with no spatial acceleration yet) and make closing / deleting / evicting a whole Scene3D O(objects) + defrag instead of dropping its pages. Flat pages + mask-only filtering is the end-state described under Spatial data and real GPU culling (once a spatial hierarchy provides the coarse reject the container set provides today), not an interim option. The tiny-many-containers page-waste that motivates flat packing is addressed separately β€” see the page-sizing note under Later β€” not by going flat.
  • The two Instance redirect table/5 producers were left behind by composition β€” fixed. TranslateSelectedSceneObjects (move) and ApplySceneVisibilityAction (hide) both filtered objects by subTabs[slot].containerMemoryId, the home container, and skipped anything whose memoryIDContainer differed. The pick pass did not: it walks the whole set through ForEachSubTabPage. So an object in a composed container could be selected and would then silently refuse to move or hide. Both now go through one SubTabDrawsContainer predicate that tests the container SET, falling back to the home container when the set is empty exactly as ResolveWindowViewTarget does β€” verified by moving an object belonging to a composed container and watching the producer report 1 object(s) translated where it previously reported none. This is the general shape of the hazard, worth stating once: composition made “the view’s container” a set everywhere except in code written before it, so every consumer that resolves a Viewport to a single containerMemoryId is suspect.
  • Deliberately not built (deferred): dropping onto an extracted WINDOW_KIND_VIEW window (cross-window mouse tracking); several side-by-side Viewports in one window (needs the Viewport slot allocator noted above); more than MV_MAX_CONTAINERS_PER_SUBTAB (8) containers per SubTab; and the “fully independent copies” multiplicity where double-click always spawns a fresh SubTab of an already-open container.

GPU command compaction

(draw path implemented; SceneEpoch and template retirement deferred)

The two halves of this step turned out to be separable, which the design did not anticipate. The draw-call half has shipped: a Viewport is one ExecuteIndirect. The publishing half β€” the SceneEpoch directory, the revision-keyed cull cache and the retirement of the per-page indirect buffers β€” has not, and is no longer a prerequisite for it. Read the design below as written, then the As implemented notes for what that reordering cost and bought.

Geometry pages, draw templates and later spatial pages cannot be published as unrelated β€œlatest” resources. Publish one atomic directory instead:

SceneEpoch
  GeometryPage directory
  DrawTemplate directory
  container -> page directory
  revision numbers

A render thread acquires one SceneEpoch and binds only what is reachable from it. The instance arena, redirect table and mask are deliberately not in the epoch: they are mutated under invariant 2 rather than republished, and that is exactly what keeps moves and hides free of clone traffic.

Then retire the persistent executable per-page indirect buffers β€” without removing draw information from the GPU:

DrawTemplateBuffer
  Persistent, GPU-readable: gpuInstanceIndex, index count, start index,
  base vertex, source page, flags.

VisibleIndirectBuffer
  Per-Viewport GPU output: compacted commands plus a count buffer.
SubTab container/filter/membership -> scan relevant draw templates
                                  -> compact matching commands on GPU
                                  -> VisibleIndirectBuffer + count
                                  -> one ExecuteIndirect

Four constraints must be designed in rather than discovered:

  • StartIndexLocation must be absolute β€” done. It used to be (indexByteOffset - page.indexTail) / 2, and indexTail moves down on every append, so appending one object silently invalidated every other object’s start index in that page. All three draw paths now bind the index buffer view at the page base over the whole page and RebuildIndirectBuffer emits indexByteOffset / 2, stable for the object’s stay in the page. The low-byte overlap with the vertex region is harmless: no draw references indices down there. This was the hard prerequisite for a persistent draw template, and it is no longer in the way. Note that a full rebuild per clone is still performed β€” that is now a choice (the offsets change under compaction anyway), not a constraint.
  • Index width stays a page property. Overtaken by the implementation. This said 16-bit versus 32-bit index format is set by IASetIndexBuffer per page, so the page-kind axis had to survive for it. Once the compacted command carries its own D3D12_INDEX_BUFFER_VIEW, the format rides along per command, and one ExecuteIndirect can mix 16-bit and 32-bit pages freely. The page-kind axis is still wanted for PSO-level concerns (transparency, wireframe topology, culling) and for the big-object fallback, which has no page at all β€” but index width is no longer one of its reasons on this path. Transparency can still be a per-object renderFlags bit routed to a separate output buffer (classification only; ordering remains a later concern).
  • MaxCommandCount is a CPU-side constant β€” done. SceneCullScratch::kMaxCommands is 65,536 per Viewport, the shader clamps against it, and overflows are counted in telemetry. The buffer is persistent rather than drawn from a per-frame ring, as required. Two parts remain open: it is allocated per monitor rather than per active visible Viewport (correct today, and see the invariant-4 note under Still deferred), and the cull cache that motivated “persistent, not a ring” is not built yet.
  • Compute needs descriptor infrastructure that does not exist yet. Avoided, not met. A shader-visible heap and a global descriptor allocator were listed as prerequisites. Passing each page’s buffer views as root constants kept the whole compaction path on root descriptors, so neither was needed. They come back only with the page directory, i.e. with the deferred items. What did hold exactly as written: the cull dispatch is recorded on the same per-monitor direct command list as the draws, with a barrier between the last dispatch and ExecuteIndirect, so no cross-queue synchronization is involved.

As implemented (ShaderSceneCull.hlsl + the compute branch in RenderScene3D): a Viewport is one ExecuteIndirect, whatever its page count. The compute pass still dispatches per page β€” one thread per template β€” but every page writes into the same output buffer under the same atomic counter, and a single call then draws the lot.

What made that affordable was putting the buffer views back into the command. A 24-byte template is drawable only against its own page’s vertex and index buffers, which is precisely why the draw loop used to bind them and issue a call per page. The compacted output is therefore a 56-byte VisibleIndirectCommand β€” {VBV, IBV, root-constant b1, DRAW_INDEXED}, a second command signature alongside the 24-byte one β€” so commands originating in different pages, and different containers, can sit side by side in one buffer. The extra 32 bytes are paid only for visible commands in a per-monitor scratch, never per object in VRAM: the persistent templates stay 24 bytes.

The CPU passes each page’s views to its dispatch as root constants (SceneCullConstants, 12 DWORDs), which is what keeps the whole path on root descriptors only. That is the load-bearing simplification, and it is why the fourth constraint above did not have to be met: with the views traveling in the constants rather than in a GPU-side page directory the shader looks them up in, there is still no shader-visible descriptor heap and no global descriptor allocator, and the SceneEpoch directory is not a prerequisite for one call per Viewport after all. The design listed a page directory as the alternative to per-command views; it is in fact only needed to collapse the remaining per-page dispatches, not the draws.

Three things the per-page form did that this deliberately does not:

  • No barrier between page dispatches. They touch the count only through InterlockedAdd, so they are order-independent and free to overlap. Command order within the buffer therefore varies between frames, which is immaterial for depth-tested opaque draws. The old form barriered UNORDERED_ACCESS ↔ INDIRECT_ARGUMENT around every page, serializing the entire loop.
  • No count reset per page β€” one 4-byte reset per Viewport, before the first dispatch.
  • No IASetVertexBuffers / IASetIndexBuffer at all. Two IA binds per page are simply gone.

Overflow is bounded twice over and cannot corrupt anything: the shader drops a command whose InterlockedAdd slot lands past kMaxCommands (65,536) rather than wrapping, and ExecuteIndirect independently executes min(MaxCommandCount, count). The count is left deliberately over-counted so the read-back below can report the overflow.

A fence-gated visibleCount read-back is wired, closing the last item on the deferred list this step started with. Each Viewport’s surviving-command count is copied into a per-monitor READBACK buffer between the dispatches and the draw (the count is reset by the next Viewport, so it has to happen there), tagged with the frame’s fence, and consumed one frame later. It surfaces on the debug heartbeat as [gpu][cull] path= drawn= overflow=. drawn is a live gauge, not a running total β€” it is the number the plan’s “reduce each SubTab to what its camera needs” is ultimately measured by β€” and overflow must stay 0.

The default is now ON, and the legacy path stays maintained. Before this step the toggle was a lateral move β€” per-page draws either way β€” so it defaulted off. Now the compute path draws a whole view in one call with no IA binds and no per-page barriers, which the legacy path cannot match, so gUseComputeCull defaults to true and the k key is for A/B comparison rather than for bring-up. The legacy per-page path is kept as the A/B reference and as the fallback should per-command buffer views ever misbehave on some driver. Treat any change to the draw loop as a change to both paths.

Measured, on a 182-object scene composed with a second 6-object container:

Check Result
Compute vs legacy, static scene 0 differing pixels of 131,520 sampled
drawn against objects uploaded 182 vs commands=182, hidden=0
Hide Unselected (1 selected) drawn 182 β†’ 1, hidden=181, clones/cloneMB unchanged
Hide Reset drawn back to 182, hidden=0
Two containers composed into one Viewport drawn=188 β€” two pages, two containers, one ExecuteIndirect
overflow 0 throughout

The hide row is the one worth reading twice: 181 objects left the frame for 181 mask writes and zero geometry-clone traffic, and they were dropped before becoming draw commands rather than vertex-shaded into degenerates.

What “compaction” moves here β€” commands, not geometry. This is the point most likely to be misread, because the word also names the geometry defragmentation in Defragmentation logic / Page compaction. The two are unrelated:

  • GPU draw-command compaction (this step, render thread). Reads the array of 24-byte IndirectCommand templates and writes the survivors out as 56-byte VisibleIndirectCommands. Vertex and index bytes are never read, copied or moved; the geometry pages stay exactly where they are β€” only the addresses of those pages are copied, into each command’s buffer views. There is no temporary allocation: the output lands in a persistent per-monitor scratch (SceneCullScratch: a 65,536-command visibleIndirect buffer, 3.5 MB, plus a 4-byte visibleCount), created once when the render thread starts and reused every frame. Per Viewport the render thread resets the count once with a 4-byte CopyBufferRegion from a shared zero buffer, dispatches one thread per template of each of the Viewport’s pages with nothing between them, barriers the scratch UNORDERED_ACCESS β†’ INDIRECT_ARGUMENT, and issues one ExecuteIndirect whose command count comes from visibleCount. The scratch is reused by the next Viewport on that monitor β€” safely, because they are recorded in order into one command list and the next Viewport’s barrier back to UNORDERED_ACCESS drains the previous one’s draw first.
  • Page compaction (Page compaction / Defragmentation logic, copy thread). Does allocate β€” a fresh 4 MB page via CreateNewPage β€” and copies only the live objects’ vertex/index ranges into it with CopyBufferRegion, dropping the holes left by deleted geometry, then publishes the clone and retires the old page. That is the one that “creates a temporary allocation by copying only the valid buffers”; the command compaction above never does.

Where the 64-bit visibility flag is processed. Inside the compute shader, and that test is the compaction filter. Each thread calls IsVisibleInSubTab(cmd.gpuInstanceIndex, subTabBit), which loads the object’s VisibilityMask[gpuInstanceIndex] (the 64-bit SubTab-membership word from Visibility mask, carried as uint2) and tests the single bit for the SubTab this Viewport is drawing (subTabBit, a root constant; >= 64 means “show all”). Bit set β†’ the command is appended via one InterlockedAdd on the count; bit clear β†’ the object is dropped and never becomes a draw. This is the same predicate the scene and pick vertex shaders apply on the legacy path (collapsing a hidden object to a degenerate primitive), moved upstream so hidden and cross-SubTab-filtered objects cost nothing past this dispatch instead of being vertex-shaded and discarded. The mask is authored by the copy thread’s WriteVisibilityMask (from the HIDE_* ribbon rows via SET_VISIBILITY / CLEAR_SUBTAB_HIDES); the compute shader only reads it, as SRV t1. Note the coarser, container-level SubTab filter (SubTabs, Viewports and container sets’s container-set directory) still runs first on the CPU β€” it decides which pages a Viewport visits at all; the 64-bit mask is the finer, per-object level within those pages.

Still deferred, in rough build order:

  • The SceneEpoch atomic directory and the revision-keyed cull cache. The compaction still regenerates every frame, so a Viewport nobody is touching re-culls needlessly. The cache is keyed by (SceneEpoch, SubTab filter revision, visibility revision) β€” three revision counters that do not exist yet β€” and its output buffer must be persistent rather than drawn from a ring. Note one thing the cache cannot simply be: per Viewport. Two windows on two monitors can resolve to the same tab’s active sub-tab, so a per-Viewport buffer would have two owning render threads and break invariant 4. The scratch is per monitor today for exactly that reason, and a cache must be keyed per monitor per Viewport, not per Viewport alone.
  • Retiring the persistent per-page indirect buffers. Much less urgent than it was: right-sizing the reservation to 256 KB removed the ~2.4 GB of pure waste that made this the sharpest VRAM item, so what remains is the structural cleanup rather than a memory emergency. The templates become an SRV-only DrawTemplateBuffer, which in turn forces the GPU pick pass and the print path off the 24-byte per-page ExecuteIndirect they still execute, and needs the page directory so the shader can expand a sourcePage reference into buffer views. A template could also shed InstanceCount and StartInstanceLocation once it stops being executable, 24 β†’ 16 bytes.
  • Collapsing the per-page dispatches into one. The draws are already one call; the dispatches are not. This is what the page directory actually buys, and it is worth little until page counts are large.

Object representation

The realistic “worst case” hierarchy for a CAD frame:

  • Index depth: 16-bit vs 32-bit (hardware requirement) β€” e.g. nuts/bolts (16) vs engine blocks (32).
  • Transparency: opaque vs transparent (sorting requirement) β€” transparent objects must be drawn last for alpha blending.
  • Topology: triangles (solid) vs lines (wireframe) (PSO requirement) β€” we cannot draw lines and triangles in the same call.
  • Culling: single-sided vs double-sided (PSO requirement) β€” sheet metal vs solids. Since sectioning is a common use case, we may make all geometry double-sided; to be ascertained later.
  • Buffer pages (N): how many 4 MB pages are in use.

Total unique batches = 2 Γ— 2 Γ— 2 Γ— 2 Γ— N = 16 Γ— N. This ensures no pipeline-state reset while rendering a single page β€” one ExecuteIndirect call per page.

Superseded at the top end β€” and this has now shipped: the one-call-per-page model is what the legacy draw path still does, but the primary path is GPU-compacted, per-Viewport command buffers and a single ExecuteIndirect per Viewport. See GPU command compaction.

The axes do not behave alike any more

Once draw commands are compacted per Viewport rather than issued per page, the four axes above split into two kinds β€” and the split, not the count, is what should drive the design:

  • PSO-only axes β€” transparency, topology, culling. These change pipeline state and nothing else, so each can be a per-object bit that the compute pass reads to route commands into separate output regions: one ExecuteIndirect per non-empty bucket, with pages free to mix them. 2 Γ— 2 Γ— 2 = 8 buckets worst case, independent of N. (Planned β€” today everything draws with one PSO; specified under Appearance, variations and display state*.)*
  • Layout axes β€” vertex format. These change the byte layout of the page itself: stride drives vertexByteOffset alignment, IsFull, and BaseVertexLocation = vertexByteOffset / stride. No amount of bucketing helps, so vertex format must stay a page property. (Planned β€” and note it is the one axis that costs a draw call as well: the input layout is PSO state and, unlike the index-buffer view below, does not ride in the compacted command, so N live vertex formats means N ExecuteIndirects per Viewport. Only one format is live today, which is what keeps GPU command compaction’s single call per Viewport true.)
  • Index depth is neither β€” it stays a page property but is no longer a bucket key. (Implemented.) This is the axis that has already changed. StartIndexLocation is an element offset in units of the command’s own format, and each compacted command carries its own D3D12_INDEX_BUFFER_VIEW including Format; index format is not PSO state for triangle lists. So a 16-bit page and a 32-bit page draw in one call. Uniformity within a page is not enforced by the API β€” but keep it anyway, because the call-sharing benefit is already had between pages and mixing would put a per-object divisor back into the three sites that independently compute these offsets.

Enabling 32-bit pages is therefore small: a page.indexFormat, and RebuildIndirectBuffer dividing by the page’s element size instead of sizeof(uint16_t). Index offsets are already 4-byte aligned, which satisfies R32_UINT unchanged. Note that 32-bit indices and the big-object fallback are different thresholds β€” 65,536 indices is only 128 KB, so a 100k-index mesh needs 32-bit indices while still fitting easily in a 4 MB page.

Answered by Shared geometry and the primitive libraries: repeated geometry (e.g. bolts) needs only one set of vertex/index buffers, drawn with different world matrices. Shared geometry and the primitive libraries stores that one set in a shared library and points every instance’s command at it.

Pipeline Stage Object and Root Signature Layout

A PSO fixes the decisions the hardware cannot change mid-draw β€” input layout, rasterize, blend, depth, render-target formats, topology class β€” and a root signature fixes the shape of the data every shader in that PSO can reach. Both are immutable once created. So the division between them is a design decision rather than an implementation detail: anything that must vary per draw is either a root argument or a second pipeline, and the second option is the expensive one because it also costs a draw call. This section records what is bound where today, and which axes of variation are already paid for against which ones cost a new PSO.

What exists, and at what scope

Ten distinct pipelines and eight root signatures. Nothing is created lazily or looked up by state vector: each is built once at init and only bound thereafter.

Scope Created by Pipelines Root signatures
Device (one per process) InitSkyGradientResources, InitSceneCullResources, InitUIResources Sky gradient, Scene cull (compute), UI overlay Sky Gradient, Scene Cull, UI Overlay
Per tab InitD3DPerTab β†’ InitSelection3DResources, InitCad2DTabResources Scene, Pick, Highlight, Rotation cube, Cad2D line, Cad2D curve, Cad2D text Scene3D, Selection3D Cube, Cad2D Line, Cad2D Curve, Cad2D Text
Per window / per render thread β€” none none

Windows and render threads own render targets, constant buffers, command allocators and cull scratch β€” never pipeline state. That is exactly what lets one monitor’s command list record several windows back to back with nothing between them but a SetPipelineState.

The Scene3D root signature

Six parameters, 10 of the 64 available DWORDs. Every parameter is a root descriptor or a root constant: there is no descriptor table, and no shader-visible descriptor heap anywhere on the 3D draw path.

Slot Kind Register Visibility DWORDs Written by Holds
0 CBV root descriptor b0 VERTEX 2 render thread, per window winRes.constantBuffer β€” transposed viewProj
1 SRV root descriptor t0 VERTEX 2 render thread, per view instanceArena.va β€” the 64-byte InstanceRecords
2 32-bit constants Γ—1 b1 VERTEX 1 ExecuteIndirect, per command gpuInstanceIndex
3 SRV root descriptor t1 VERTEX 2 render thread, per view instanceSlotOf.va β€” the redirect table
4 SRV root descriptor t2 VERTEX 2 render thread, per view visibilityMask.va
5 32-bit constants Γ—1 b2 VERTEX 1 render thread, per view subTabBit

Two properties are load-bearing:

  • Slot 2 is the only slot ExecuteIndirect overwrites, which is why slot 5 is a separate constant range rather than a second value inside b1. Folding the two together would have the command signature clobber the render thread’s subTabBit on the first command. Adding parameters is otherwise free here, because the command signature addresses parameter index 2 and that index does not move.
  • Every parameter is VERTEX-visible; the pixel stage binds nothing at all. ShaderScenePixel, ShaderSceneHighlightPixel and ShaderScenePickPixel consume interpolants only. The first pixel-stage data β€” a material table, a texture, tonemap parameters β€” is therefore a root-signature change, not merely a PSO change.

Three pipelines share this signature: scene, pick and highlight. That sharing is why the pick pass and the highlight pass inherit visibility filtering, the redirect indirection and per-object colour without a line of their own code.

Per-pipeline state

Pipeline VS / PS Input layout Raster Blend Depth Render targets Topology type
Scene SceneVertex_16 / ScenePixel 16 B: POSITION R32G32B32_FLOAT @0, NORMAL R8G8B8A8_SNORM @12 default, back-cull replace LESS, write ON gpu.rttFormat; DSV D32_FLOAT TRIANGLE
Pick ScenePickVertex_16 / ScenePickPixel same 16 B (the VS declares only POSITION) default, back-cull replace LESS, write ON two: R32_UINT id + R32_FLOAT NDC depth; DSV D32_FLOAT TRIANGLE
Highlight SceneVertex_16 / SceneHighlightPixel same 16 B default, back-cull replace LESS_EQUAL, write OFF gpu.rttFormat; DSV D32_FLOAT TRIANGLE
Rotation cube CubeVertex / CubePixel 24 B: position + normal, both R32G32B32_FLOAT cull NONE src-alpha over disabled gpu.rttFormat; DSV D32_FLOAT TRIANGLE
Sky gradient SkyGradientVertex / SkyGradientPixel none β€” 4 vertices synthesized from SV_VertexID cull NONE replace disabled gpu.rttFormat; DSV D32_FLOAT TRIANGLE, drawn as a strip
Scene cull compute ShaderSceneCull β€” β€” β€” β€” β€” β€”
UI overlay UIVertex / UIPixel 24 B: pos RG32_FLOAT, uv RG32_FLOAT, colour R32_UINT, atlas index R32_UINT cull NONE src-alpha over disabled hardcoded R8G8B8A8_UNORM TRIANGLE
Cad2D line 2D_LineVertex / 2D_LinePixel none β€” 6 vertices per instance, expanded from the records SRV cull NONE src-alpha over disabled gpu.rttFormat TRIANGLE
Cad2D curve 2D_CurveVertex / 2D_CurvePixel none cull NONE src-alpha over disabled gpu.rttFormat TRIANGLE
Cad2D text 2D_TextVertex / 2D_TextPixel 24 B, the same layout as the UI overlay cull NONE src-alpha over disabled gpu.rttFormat TRIANGLE

The sky and the two Cad2D geometry pipelines are vertex-buffer-less: an empty input layout, with positions computed in the vertex shader from SV_VertexID / SV_InstanceID plus a structured buffer. They are immune to the vertex-format axis entirely, which is worth remembering when that axis starts multiplying pipelines below.

The remaining root signatures are small: Scene Cull is 20 DWORDs, Sky Gradient 8 (all constants), UI Overlay 4 (a CBV plus an SRV table and a sampler table), Cad2D Line / Cad2D Curve / Cad2D Text 4 each, Selection3D Cube 2.

The cull compute root signature

Slot Kind Register Rebound Holds
0 32-bit constants Γ—12 b0 per page templateCount, subTabBit, maxCommands, the page’s VBV (address lo/hi, size, stride) and IBV (address lo/hi, size, format), padding
1 SRV root descriptor t0 per page page.indirectBuffer β€” the 24-byte templates
2 SRV root descriptor t1 per Viewport visibilityMask.va
3 UAV root descriptor u0 per Viewport cullScratch.visibleIndirect
4 UAV root descriptor u1 per Viewport cullScratch.visibleCount

Carrying the buffer views in slot 0, rather than in a GPU-side page directory the shader would look them up in, is what kept this whole path on root descriptors β€” no shader-visible heap and no global descriptor allocator, the GPU command compaction prerequisite that was avoided rather than met. The price is that slots 0 and 1 are per-page, so the dispatches stay per page even though the draw is one call. Note also that a root SRV/UAV is a raw address with no bounds checking of any kind: the shader is bounded by templateCount and maxCommands, and by nothing else.

What is bound, in frame order

One window’s Scene3D frame on the compute path:

sky        : RS = Sky Gradient  PSO = Sky        b0 = 8 constants; DrawInstanced(4)
scene setup: RS = Scene3D       PSO = Scene      b0 = viewProj CBV; t0/t1/t2 = arena/redirect/mask; b2 = subTabBit
cull       : RS = Scene Cull    PSO = Cull       t1,u0,u1 per Viewport; b0 + t0 per page; one Dispatch per page
draw       :                    PSO = Scene      one ExecuteIndirect(visibleCommandSignature), count from visibleCount
highlight  : RS = Scene3D       PSO = Highlight  rebinds b0/t0/t1/t2/b2; b1 per object; DrawIndexedInstanced
cube       : RS = Cube          PSO = Cube       b0 = MVP + colour CBV
UI         : RS = UI Overlay    PSO = UI         b0 = ortho CBV; SRV table; sampler table
pick       : RS = Scene3D       PSO = Pick       own render targets; ExecuteIndirect(commandSignature) per page

The compute and graphics root signatures occupy independent slots on a command list, so the dispatches never disturb the graphics bindings β€” only the shared pipeline-state slot has to be restored before the draw. Viewport and scissor are rasterize state and are untouched by a dispatch. Every pass after the compacted draw binds its own IA state before drawing, so nothing depends on what the indirect commands left behind.

Command signatures belong to root signatures, not to PSOs

This is the detail most likely to be misread, and it is what lets the two draw paths coexist:

Signature Stride Arguments Executed by
commandSignature 24 B CONSTANT(param 2, 1 value), DRAW_INDEXED legacy per-page draw, GPU pick pass, print path
visibleCommandSignature 56 B VERTEX_BUFFER_VIEW(slot 0), INDEX_BUFFER_VIEW, CONSTANT(param 2, 1 value), DRAW_INDEXED the one compacted draw per Viewport

Both were created against tabRes.rootSignature, because both set a root argument. A consequence worth banking: the pick pipeline can already execute the 56-byte signature, since it shares that root signature β€” so moving the pick pass off per-page ExecuteIndirect (GPU command compaction’s deferred item) needs no new command signature. The Cad2D line and curve signatures are a bare DRAW with a null root signature, since they set no root arguments at all.

Four observations about the layout as built

  1. The UI pipeline hardcodes DXGI_FORMAT_R8G8B8A8_UNORM where every other pipeline reads gpu.rttFormat. The two are the same value today. On the HDR switch they stop being, and the UI overlay becomes the one pipeline that does not follow the format change β€” a PSO/RTV mismatch on the last pass of every frame.
  2. Root signatures and pipelines are per tab, though nothing in either is tab-specific. Seven pipelines and five root signatures per tab, differing only in the virtual addresses bound into them. At the “hundreds of simultaneous tabs” target that is hundreds of identical pipelines. Hoisting them to device scope is close to a pure deletion, with the caveat that InitSelection3DResources and both command signatures are currently created against the per-tab signature object.
  3. ShaderUIPixelMSDF.hlsl is not a build input. It is absent from the project’s FxCompile items and nothing includes a generated header for it; the MSDF median math lives inline in ShaderUIPixel.hlsl. The file is dead, and is recorded here rather than removed.
  4. The scene pipeline writes exactly one render target. SSAO and anything deferred-shaped want a normal/depth target alongside it, which is a change to the main PSO β€” NumRenderTargets, RTVFormats[1] β€” rather than an added pipeline.

Planned: draw buckets

The three PSO-only axes from The axes do not behave alike any more become per-object renderFlags bits that the compute pass routes into separate output regions, one ExecuteIndirect per non-empty bucket, pages free to mix them:

Bit PSO field it changes Clear Set
transparent BlendState + DepthStencilState.DepthWriteMask replace, depth write ON src-alpha over, depth write OFF
double-sided RasterizerState.CullMode BACK NONE
line topology PrimitiveTopologyType TRIANGLE LINE

Eight pipelines worst case, independent of page count and of object count. Two things have to be designed in rather than discovered:

  • The line bucket needs IASetPrimitiveTopology(LINELIST) as well as its PSO. PrimitiveTopologyType is PSO state and IASetPrimitiveTopology is command-list state; the two must agree, and the draw loop sets TRIANGLELIST once for the whole frame today.
  • The cull shader cannot see renderFlags at all today. It reads a 24-byte template that has no such field, and it binds neither the arena nor the redirect table. Routing by flags therefore needs one of two things, and they are not equivalent: bind the redirect table and arena in the cull pass β€” two more root SRVs, which Shared geometry and the primitive libraries’s LOD selection needs anyway, at the cost of the same two dependent loads the vertex shader already performs β€” or copy the flag bits into the template, which makes changing an object’s appearance rebuild its page’s argument buffer and clone its page. The first preserves the property every step since Instance redirect table has been protecting, that an appearance or interaction change never touches geometry. Decide it before the bucket work starts.

Planned: vertex-format variants, and what does not cost a pipeline

The input layout is PSO state and, unlike the index buffer view, does not ride in the compacted command. Live vertex formats therefore multiply the bucket count: 8 buckets Γ— N formats of pipelines, and at least N ExecuteIndirects per Viewport. With the four formats listed under Vertex format variants, the ceiling is 32 pipelines per tab β€” which is the point at which build-them-all-at-init stops being the right shape and a lazily populated cache keyed on (vertexFormat, renderFlags) earns its place.

Equally worth stating, so the axes stay honest, is everything that does not add a pipeline: index width, since the format rides per command; the big-object fallback, which is different bindings and a separate call on the same pipeline; non-uniform-scale normals, a vertex-shader change all three scene pipelines inherit at once; and shared geometry, where a library buffer is only another VBV address inside the command.

Planned: the rest, in roadmap order

  • HDR output. rttFormat becoming R16G16B16A16_FLOAT changes RTVFormats[0] on every graphics pipeline except the pick pass, whose R32_UINT / R32_FLOAT targets are unaffected. Because the HDR decision is taken at startup before any tab exists, this is a different value at creation rather than a runtime rebuild β€” provided every pipeline actually reads gpu.rttFormat, which is observation 1 above. It also adds one pipeline: a full-screen tonemap replacing the RTTβ†’backbuffer CopyResource, with its own root signature (a source SRV plus tonemap constants) and the engine’s first pixel-stage bindings.
  • Shared geometry (Shared geometry and the primitive libraries). A second compute pipeline β€” instanced-template expansion with per-frame LOD selection β€” sharing one output buffer and one count with the bespoke one, so the graphics side does not split. The cull root signature grows: CullParams from 12 to 16 DWORDs for the camera position, plus root SRVs for the redirect table, the arena and the (shapeId, lod) library table. Roughly eight parameters and ~30 DWORDs, still far inside the limit.
  • Per-Viewport display state. A DisplayModeMask root SRV beside the visibility mask, and per-Viewport override constants (ghost alpha, wireframe) widening b2 β€” never b1, for the reason recorded above. The ghost bucket is simply the transparent pipeline with depth write off, which is what makes it order-independent and click-through for free.
  • Transparency sorting. Either a compute pipeline sorting the transparent bucket, or order-independent transparency β€” the latter being the first pipeline to need a UAV bound to the graphics stage and pixel-visible root parameters.
  • GPU command compaction’s deferred half. Retiring the per-page indirect buffers in favour of an SRV-only DrawTemplateBuffer brings the shader-visible descriptor heap back and turns the cull signature’s per-page root descriptors into descriptor tables.
  • Mesh shaders (Later, pipes only). A structural break rather than another row in the table: CreatePipelineState with a D3D12_PIPELINE_STATE_STREAM_DESC instead of CreateGraphicsPipelineState, and a DISPATCH_MESH command signature.

Lighting

Initially, hemispheric ambient lighting:

Factor       = (Normal.z Γ— 0.5) + 0.5
AmbientLight = Lerp(GroundColor, SkyColor, Factor)

Screen Space Ambient Occlusion (SSAO) to darken creases and corners is planned for a future revision.

Selection, picking and highlight

Refer selection.md

Render to texture, freeze and present

Use Render To Texture (RTT) to implement frame freezes, since the swap chain is FLIP_DISCARD. RTT is now the standard frame path (draw β†’ RTT β†’ copy into backbuffer β†’ present), so a freeze is simply “keep presenting the last RTT”. With RCU pages, defragmentation no longer needs freezes at all (see above); the mechanism stays valuable for eviction safety, device-loss handling, HDR tonemapping (the copy becomes a draw), UI composition and multi-monitor flexibility.

Appearance and display state

The appearance payload

(packedColor is built; the rest is planned)

The sections above build identity, memory and command generation. This is the layer that decides what an object looks like and which of several forms of it a given Viewport shows. It is designed as one piece because the four parts share machinery: the bucket keys live in the same 16 bytes, the variants are addressed by the same memoryID space, and per-Viewport display state is the same mask mechanism Visibility mask already built.

The 16-byte appearance payload

The InstanceRecord’s second half finally acquires producers and consumers β€” packedColor first, because dropping per-vertex colour to reach the 16-byte vertex is what forced it (see Vertex format). The other three still have no producer:

Field Holds
packedColor RGBA8 β€” base colour and 0–255 opacity. No separate transparency field is needed. Built: written on ADD and geometry MODIFY from GeometryData::color, which each generator sets from its dominant face; shadowed in InstanceRegistryEntry so a transform-only edit preserves it. Opacity is carried but not yet consumed β€” everything still draws through the one opaque PSO until the render-flag buckets below exist.
materialIndex Index into a material table (texture ids, roughness, …), not a raw texture id β€” same 4 bytes, far more headroom.
renderFlags The three bucket keys β€” transparent, double-sided, line topology β€” plus depth-write behaviour.
packedParams Spare.

The split established by the redirect table holds: authored, infrequently changed state here; hover, selection, SubTab membership and hide in the masks, so an interaction never allocates an arena slot. The transform-edit defect this opens is described under Instance redirect table (the move path) and must be fixed in the same change.

Geometry variations

A centerline, an origin marker and a stress contour are all different geometry for the same engineering object. The Miscellaneous specification already anticipates the key becoming (memoryID, variation); this is the packing that costs nothing.

memoryID is assigned monotonically from 1 and is process-local. 2⁡⁢ is ~7Γ—10¹⁢ β€” at ten million allocations a second, 228 years β€” so the high 8 bits are free and hold a variation number, giving 256 variations per object with no storage change at all. Every existing map, command struct and staging estimate is untouched, and persistence is unaffected because memoryID never reaches disk. To the GPU a variation is simply an independent object: its own InstanceRecord, redirect entry, visibility mask, registry entry and geometry.

Three costs to price in:

  • It multiplies the one remaining hash map. indexOfMemoryId is the single unordered_map the registry kept, already flagged as costing well over a gigabyte of node overhead at 10M entries; three variations per object triples it. If that bites, allocate a variation’s gpuInstanceIndex contiguously with its base and store a variation count in the registry entry, so lookup becomes Find(base) + v and the map stays at 10M β€” at the cost of needing contiguous runs from the free list.
  • Variations spend the same index space as real objects. 10M objects with centerline and origins is 30M identities, which exceeds MV_MAX_INSTANCES_PER_TAB (10,485,760) outright and puts the registry at ~1.2 GB of host RAM. Raising the constant costs nothing but address space on the GPU side, but the registry is real committed memory. 10M is the identity ceiling, not the object ceiling.
  • Selection must mask the low 56 bits. Clicking a centerline should select its parent, so the pick resolve strips the variation before writing selectedObjectIds. Otherwise the lookup against the base id silently fails and the object refuses to move β€” the same shape as the composed-container gap fixed in SubTabs, Viewports and container sets.

Additive versus substitutive is a required property, not an emergent one. A centerline or origin marker is additive: drawn alongside the base. A contour is substitutive: the same triangles at the same positions, so drawing both is guaranteed z-fighting. A substitutive variation must therefore hide its base in that Viewport. The hide mechanism already exists; what must be added is the declaration, and a rule to drive it (“this Viewport shows variation N of container X”) rather than a mask write per object.

Lifecycle is deliberately asymmetric. Centerline, origin markers and similar are generated eagerly alongside the base object β€” they are small and always potentially wanted. Contours are generated on demand per load case when a Viewport asks for them, and discarded eagerly when the user closes that view: they are the large ones, and most of the time nobody is looking at them. That asymmetry is the argument for packing variations into the id rather than inventing a separate object type β€” a transient contour rides the ordinary ADD / REMOVE path with no lifecycle machinery of its own, closing the view is a REMOVE per variation, and the empty-page GC drops its pages.

Per-Viewport display state

Display class resolves at three levels, cheapest first:

  1. Per object, Viewport-independent β€” the InstanceRecord (this window is glass at 40%).
  2. Per Viewport, object-independent β€” a root constant (this whole view is wireframe; ghost everything at 15%). No per-object storage at all.
  3. Per object per Viewport β€” a second 8-byte mask array, DisplayModeMask[gpuInstanceIndex] alongside VisibilityMask, one bit per SubTab.

Level 3 must be a second array, not a wider mask. Widening to uint4 (2 bits per SubTab) breaks invariant 2, which permits exactly one naturally-aligned store of ≀ 8 bytes whose old and new values are both valid. Two independent 8-byte arrays are each individually safe, and the one-frame skew between them β€” a reader seeing new hide with old display mode β€” is visually meaningless. It also stays free for tabs that never ghost anything, being another reserved-tile buffer.

Pair it with a container-level rule, exactly as SubTabs, Viewports and container sets did for composition: “ghost this whole container in this Viewport” is one entry, never a write per object. Per-object bits are the escape hatch for “ghost everything except these three bolts”.

Worked example β€” an engine block inside a truck, the truck near-transparent so the engine’s position reads. The engine’s bit is clear, so it routes to the opaque bucket; the truck body’s bit is set and the Viewport override supplies a fixed alpha, so it routes to the ghosted bucket. Two ExecuteIndirects, regardless of page count. Level 1 alone could not express it (the truck would be ghosted everywhere) and level 2 alone could not either (the engine would be ghosted too), which is what makes this the case that justifies level 3.

Two things fall out for free, and they agree with each other: ghosting drawn with depth-write off is order-independent, so it survives the nondeterministic command order InterlockedAdd produces across overlapping dispatches; and the same depth-write-off makes the pick pass click straight through the ghosted body to the engine, which is what a user expects.

Transparency, ghosting and ordering

Not solved by any of this: genuine sorted transparency β€” two glass panels with different per-object alpha, one behind the other β€” flickers under GPU-generated command order and needs either a sort pass over the transparent bucket or order-independent transparency. See the transparency item under Next evolution steps, which this section rewrites the plan for.

  • Transparency sorting (needs the PSO buckets). The original plan here β€” accept imperfect order during camera motion, then CPU sort + argument rebuild when the camera stops β€” no longer describes a path that exists. Draw commands are GPU-authored now: the compaction pass assigns slots by InterlockedAdd across overlapping dispatches, so there is no CPU-side argument buffer to sort and the order varies frame to frame even with a still camera. Two viable replacements: a GPU sort pass over the transparent bucket only (it is small β€” the bucket holds just the transparent survivors of one Viewport), or order-independent transparency. Note that the common CAD case, ghosting, needs neither: drawn with depth-write off it is order-independent by construction, and gets click-through in the pick pass for free. See Per-Viewport display state.

Telemetry

Copy-thread counters

A first set of copy-thread counters already exists (GpuCopyStats, printed on the debug FPS heartbeat): batches, chunks, commands, pages cloned, clones-per-chunk, clone bytes, ring bytes and high-water, oversize-staging count, deferred-queue depth, max active pages, pending/free instance indices (idx, which moves only on REMOVE β€” a MODIFY keeps its index), pending/free arena slots (slots, which every MODIFY churns), the transform-only edit count (moves), visibility-mask writes and the live hidden-object count (mask), and retire backlog as both a live gauge and an all-time peak. They are the numbers the four workload budgets above are measured against. Note which counters are cumulative, which are live and which are high-water marks β€” a peak reads like a stuck value when it is only recording a stall that has since cleared. Wiring them into the ImprovementData pipeline and the Application Tab stats pane is the remaining work.

Render-thread counters

A render-thread counterpart now sits beside it (GpuRenderStats, [gpu][cull]): the active draw path, drawn β€” commands the last completed frame actually issued on this monitor after compaction, a live gauge fed by the fence-gated visibleCount read-back (GPU command compaction) β€” and commandOverflows. Together with the copy line these read as a pair: a hide should drop drawn while leaving clones and cloneMB flat, and a move should leave both flat while moves climbs. That pairing is how each workload budget is actually checked.

  • Per-tab VRAM usage graphs: page count, liveBytes, holeBytes, matrix-table size, big-object list size.
  • Page fragmentation heatmap + compaction-trigger counters.
  • Copy-thread health: batch latency and stall time at the publish fence wait (the rest is covered by GpuCopyStats).
  • Retire-backlog depth β€” promoted out of _DEBUG to a real counter. It catches the frozen-monitor-fence β†’ unbounded-retirement failure mode, and it is what caught the per-chunk-publish VRAM exhaustion described in Upload ring and chunked submission.
  • Eviction frequency counters (ground work for the residency item under Later).

Next evolution steps

Now

  • Canonical local frame for SPHERE β€” unit sphere at the origin, center / radius carried as the object’s transform, with the composition consolidated into WorldMatrixForObject. The prerequisite for sharing a mesh between two spheres, and a float32-precision win on its own. See The canonical local frame, and its single composition point.
  • Shared sphere geometry, Step 1 β€” gpu.primitiveLibrary (123 KB, eight LODs), template-only pages, fixed LOD 5, zero HLSL changes. Measured: six spheres added +0 MB of clone traffic against +24 MB for six cuboids, with the same number of page clones either way.
  • Compute LOD, Step 2 β€” per-frame level from projected pixel size, CullParams 12 β†’ 16 DWORDs, three more root SRVs. Settles the renderFlags-routing question in Planned: draw buckets: the cull pass now binds the arena and redirect table anyway, so routing can read them instead of copying flag bits into the draw template β€” which is what preserves “an appearance change never touches geometry”.
  • Cuboid and cylinder in the library β€” library 156.6 KB / three shapes / 24 rows, so shapeId != 0 and the (shapeId * 8 + lod) indexing finally run for real. Carried four things with it: CUBOID’s storage reshaped from eight free corner vertices to centre + size + orientation (Reshaping CUBOID to a box), with its first Properties Pane table; the inverse-transpose normal transform, since both shapes are non-uniformly scaled; a generalised LOD radius using a measured boundingRadius; and the CYLINDER axis defect below. Measured: three positions of a placed cuboid translated +2 Z twice deviate 0.087 px from a straight screen line (|cross| / (len1Β·len2) = 0.00025), against ~0.7 for a wrong matrix.
  • LINE_MEMBER’s five solid-section profile series instanced β€” an RC beam or column IS a scaled, oriented library primitive, so PARAMETRIC RECT, BAR SQUARE and BAR FLAT reuse the cuboid while PARAMETRIC CIRC and BAR ROUND reuse the cylinder, with no new entry and no new renderer path. Measured: the transformed unit cube’s eight corners coincide bit-exactly with the 24 vertices the bespoke extruder emitted for the same member; verified end to end by importing a real 1139-line STAAD model whose 44 PRIS YD/ZD records become 600x500-class RC sections. The two circular series also gain LOD they did not have (fixed 24-gon to 3..64 by projected size). See Structural members that are library primitives for the three traps it introduced: a frame that depends on the profile row rather than the type, a section basis that is NOT the cylinder’s, and two families that take their dimensions from different places.
  • CYLINDER::GetGeometry ignored its own axis β€” it computed axis = normalize(p2 - p1) and never used it, building both rims flat in the XZ plane at p1.y / p2.y, so any cylinder whose axis was not parallel to Y drew sheared. The canonical rewrite fixes it as a byproduct: WorldMatrixForObject builds an orthonormal basis about the axis, the same construction PIPE::GetGeometry always used. Its normals also pointed inward, so cylinders were lit as though from inside while spheres and cuboids were lit from outside.

Next

  • Disc and the tori in the library. Cylinder and cuboid landed under Now; the 180Β° fallback that item worried about never had to be written, because building an orthonormal basis around the axis has no anti-parallel singularity to fall back from β€” the reference-vector swap is the whole degenerate case.
  • Pipes as composed primitives. Now unblocked β€” more than one library shape exists β€” but deliberately not taken with this step; see Deferred with PIPE: the inward-facing bore, which also explains why the bore entry waits for its only consumer.
  • LINE_MEMBER’s remaining single-piece profiles β€” PARAMETRIC OCT / HEX and BAR HEX need one prism entry each; everything else single-piece is done.
  • Audit the remaining bespoke generators against the winding rule (PYRAMID, PARALLELEPIPED, CONE, FRUSTUM_*, the tori, and AppendExtrudedConvexPrism, which is confirmed outward-wound). CUBOID’s was outward β€” i.e. back-facing β€” until the library took its mesh over, and nothing about that failed loudly.

Later

  • Page-type axes β€” no longer a page kind, but PSO buckets. Pages are currently keyed by container only and everything draws with one PSO (opaque triangles, 16-bit indices, back-culled). The shape of the fix changed with GPU command compaction: instead of a page kind next to containerMemoryId, the three PSO-only axes (transparent / double-sided / line topology) become per-object renderFlags bits that the compute pass routes into separate output regions β€” one ExecuteIndirect per non-empty bucket, pages free to mix. Only vertex format stays a page property, and index width needs nothing beyond a page.indexFormat. This unlocks wireframe display modes, transparency and large meshes; none of the items below can start without it. Specified under The axes do not behave alike any more and Appearance, variations and display state.

  • Big-object fallback. Wire the dormant BigGeometryObject path: dedicated committed resource, 32-bit indices, own draw call. Today one object above 65,536 vertices silently wraps its 16-bit indices β€” must land before STL / terrain import ships.

  • SSAO.

  • HDR output pipeline (reworded β€” the vertex side is already done, colors are FP16): rttFormat β†’ R16G16B16A16_FLOAT, tonemap draw replacing the RTTβ†’backbuffer CopyResource (the full-screen-quad path returns), HDR swap chain + the startup detection rules from the Vertex format section.

performance & telemetry (wire into the existing ImprovementData pipeline; this phase also settles the demoted items)

  • Decision gate: segregated free-list allocator β€” build only if the page-selection scan shows up in these numbers.

extreme performance (only after everything above is done and stable)

  • Chained page doubling (4β†’8β†’16β†’32β†’64 MB). Fixed 4 MB pages are right while a model’s geometry sits in the low GBs, but the arithmetic fails at the top end: filling a 48 GB professional card would need ~12,000 pages β€” ~24,600 committed resources (each page = geometry buffer + argument buffer) and ~37,000 bind/ExecuteIndirect calls per frame, an order of magnitude past our 1000–5000 draw-call budget and against WDDM guidance to keep allocation counts in the low thousands (there is no hard API cap; creation cost, residency tracking and per-draw binding are what bite). Doubling each container’s next-page size up to 64 MB puts the same card at ~800 pages. The code is half-ready: GeometryPage::pageSize is already per-page and only CreateNewPage hardcodes 4 MB β€” but IsFull, clone/compaction volume and argument-buffer sizing must all follow the variable size. Trigger from the page-count telemetry; schedule before (or with) residency management below, which also gets cheaper with fewer, larger allocations. The same variable-size machinery is also the fix in the shrinking direction: a container’s first page can start small (say 64 KB) and grow toward 4 MB, so a tab holding many tiny Scene3D containers β€” the case that would otherwise pay a whole page per near-empty container and tempt a flat shared-page pool (see the composition note under SubTabs, Viewports and container sets) β€” costs bytes proportional to content while keeping per-container page ownership. Right-sizing the argument buffer already took the per-container floor from 5.5 MB to ~4.25 MB; the 4 MB geometry buffer is now what dominates a near-empty container, and only variable page size addresses that.
  • Residency management (promoted from the known-issues list): Evict a tab’s pages a few seconds after tab switch, MakeResident on return, budget-driven via IDXGIAdapter3::QueryVideoMemoryInfo + budget-change notifications. Tab 0 always resident.
  • Replace the final copy-batch CPU fence wait with GPU-side cross-queue waits (fence-tagged snapshots).
  • LOD optimization based on camera distance. The instanced case is already specified under Shared geometry and the primitive libraries, which selects LOD per frame in the compute pass from a library’s 8 levels; what is left here is LOD for bespoke geometry, which has no equivalent level set to choose from.
  • Compute-shader frustum culling.
  • Mesh-shader implementation (supported hardware, pipes only).
  • GPU-based defragmentation (compute compaction β€” the CPU-driven clone compaction should carry us a long way first).
  • Asynchronous resource creation (reduce stalls during page allocation bursts).
  • Page-level optimization: static pages β†’ single draw, semi-dynamic β†’ EI, highly dynamic β†’ EI + GPU compaction.

Spatial data and real GPU culling (deferred)

Bounds and spatial data are deliberately deferred until the previous steps are correct. Design the draw template and page headers so they can later acquire world bounds, a spatial-cell/BVH reference and LOD data.

The culling path then evolves without changing object identity or paging:

SubTab filter -> container-page rejection -> spatial-page rejection
              -> frustum culling -> LOD selection -> optional Hi-Z occlusion
              -> compact visible indirect commands

The GPU command compaction filter/compaction path remains useful as a correctness baseline and fallback.

Viewport scheduling and scale limits

64 independent SubTabs do not imply 64 equal-rate full-resolution renders. The compositor schedules Viewports according to user value:

Viewport class Typical policy
Focused interactive 30–60 Hz, highest LOD budget
Visible secondary Budgeted refresh rate
Static background Render only when dirty
Dashboard/thumbnail Lower resolution and low refresh rate
Occluded/minimized No rendering

This policy, together with cached cull output and the later spatial hierarchy, is what turns the 10-million-object / 64-SubTab requirement into bounded GPU work rather than 640 million draw commands every frame.

Not to do

  • Multi-GPU rendering (too complex for now; Windows’ multi-adapter support is limited).
  • Face-wise geometry colors (implementation detail; maybe needed later for mechanical parts). This is not contradicted by the per-vertex colour variants under Appearance, variations and display state: those exist for computed scalar fields and for imported meshes that arrive with colour baked in, neither of which is an authoring feature on our own primitives.

Appendix: superseded decisions

 ← the 24-byte vertex, freeze-based defrag, InstanceCount>1 instancing,
   per-view argument buffers, the doubling matrix table, SceneEpoch-as-prerequisite

Resolved since this list was written: selection highlighter methodology β€” shipped as the GPU pick pass + highlight overlay + rotation cube (Selection3D module); vertex page offsets misaligned against the 24-byte stride β€” see the alignment rule under Page structure; world-matrix slots recycled before the frames referencing them retired β€” see Fence-gated retirement; and the pick resolve’s world-space AABB centre, which was transforming a local centre by the transposed matrix (harmless only because every generator bakes positions into vertices and leaves worldMatrix identity) β€” the registry’s transform shadow computes it with the same row-vector convention the vertex shader uses.