Skip to content

Public rendering: fullscreen and portable raster

Link Ludus::GraphicsRhi and include rhi.h and render.h under ludus/graphics/rhi/. This slice supplies one vertex/fragment pipeline, one uniform binding and one non-indexed fullscreen triangle per frame. Ludus owns GPU creation, binding, upload synchronization, submission and cleanup. Applications own shader source, upload layout, colors, simulation, camera and UI.

Shader build and upload contract

After find_package(Ludus CONFIG REQUIRED), compile an application-owned shader:

add_executable(fullscreen main.cpp)
target_link_libraries(fullscreen PRIVATE Ludus::GraphicsRhi)
ludus_compile_shader(TARGET fullscreen NAME display SOURCE shaders/display.slang
    VERTEX vertexMain FRAGMENT fragmentMain
    INCLUDES shaders DEFINES DISPLAY_SCALE=1 DEPENDS generated_settings.slang)

Set LUDUS_SLANG_COMPILER to the pinned host tool. Linux/browser builds also require LUDUS_SPIRV_VALIDATOR; macOS builds emit MSL directly. Python 3.10+ is required. Configure/build never downloads tools. Slang 2026.1.2 compiles both targets; SPIRV-Tools validates Vulkan 1.1 SPIR-V at build time. The manifest records exact expanded commands, emitted entries, profiles, sizes and artifact hashes. WGSL receives actual browser shader/pipeline validation through resource creation; successful compilation alone does not establish runtime proof.

macOS Metal

Run ./scripts/shader-probe bootstrap on macOS to acquire the SHA-256-verified Slang 2026.1.2 release for arm64 or x86_64. Configure with -DLUDUS_SLANG_COMPILER="$PWD/out/shader-tools/slang/bin/slangc". ludus_compile_shader emits separate <name>.vertex.metal and <name>.fragment.metal, reflection JSON, a manifest, a depfile and the same generated Vertex() / Fragment() descriptions used by other backends. Source, include and define changes rebuild the artifacts; no-op builds do not. Slang's emitted #line directives name the source and its includes relative to the source directory or an INCLUDE directory, never by absolute path, so the generated header and manifest hashes are identical in every checkout. A compiler cache shared between checkouts (see building) can otherwise reuse a translation unit whose embedded MSL literal has another length. An included file outside those directories fails the build; add its directory to INCLUDE.

Slang 2026.1.2's Metal backend drops [noinline], and the Metal compiler then inlines every helper into the entry point. The shader helpers (fullscreen, raster/compute and render-graph stages) restore it: an emitted function whose #line source line, or the line above it, applies [noinline] to that name is defined with __attribute__((noinline)). Without that, the reference path tracer's resampled light sampling made its pipeline take 34–54 s to build on an Apple M5. On the macOS 15.7 CI runner the build failed with a bare "Compilation failed", and every later pipeline in the process failed too. With it, the pipeline builds in about 6 s. The rule matches by name, so keep [noinline] on the line of the function name or the line just above it. Methods, whose emitted names carry their type, are not matched. Code without #line directives is not matched either. That includes render-graph packages compiled through the in-process shader worker (ludus-shader-worker), whose Metal stages the Metal compiler may still inline.

The macOS header supplies Msl and MslEntry. Source is compiled as MSL 2.3 during CreateShader; entries and stages are checked against the actual library. Pipeline reflection rejects used textures/samplers/storage or buffers outside read-only buffer 0, and checks occupied bytes against the declared uniform size.

Start is synchronous and selects Metal under native Auto. It accepts a borrowed Cocoa window/content view or Headless, exposes a conservative 16384 texture limit on Mac2 / Apple4 devices and later, and caps uniforms at 16 KiB. Cocoa uses a CAMetalLayer with BGRA8Unorm, two drawables and bounded acquisition; hidden, minimized, zero-size or unavailable drawables skip. Keep the Platform window alive through shutdown. RHI restores its previous layer when disconnected. Headless uses a private BGRA8Unorm render target. Both report Unorm attachments. Two command-buffer-fenced uniform slots protect CPU/GPU reuse; submitted buffers retain resources after handle destruction. Shutdown drains submitted work and aborts open frames. Command-buffer errors fail the session at a subsequent frame boundary, requiring shutdown/restart; there are no facade mutations on GPU threads.

Tests verify clear/readback, live Cocoa presentation, shader and binding errors, changing uniforms, 40-frame bursts, resize, maximum-sized uploads and restart. GPU cases explicitly skip when no Metal adapter is present; policy/window checks still execute. LUDUS_TEST_COCOA=1 enables the WindowServer presentation case. Clang 18 ASan cannot start on the local macOS 26.6 host; macOS 14 CI executes the sanitizer suite. This slice provides the existing fullscreen API; general meshes, compute, textures, depth and blending are covered by the later R1–R4 slices below.

Optional GLSL ES 3.00 (WebGL 2) backend artifact

Set LUDUS_SPIRV_CROSS to the pinned build-time SPIR-V -> GLSL ES translator (config/spirv_cross_toolchain.json, SPIRV-Cross vulkan-sdk-1.4.313.0) to also emit GLSL ES 3.00 for the WebGL 2 fallback. With it unset, SPIR-V/WGSL builds are unchanged. When set, each stage's validated SPIR-V is translated to <name>.<stage>.essl, the browser build of the generated header carries both WGSL and GLSL ES, and ShaderDescription exposes GlslEs / GlslEsEntry (which link through main). The manifest adds glsl_es_entries, a glsl_es_layout derived independently from the emitted std140 block, the 300 es profile and translator provenance. See ADR 0013 and docs/development/webgl-shader-feasibility-evidence.json.

Author vertex inputs with SV_VulkanVertexID / SV_VulkanInstanceID (not SV_VertexID / SV_InstanceID): the latter require base-vertex/instance (SPV_KHR_shader_draw_parameters), which has no GLSL ES / WebGL 2 equivalent and the translator rejects. For a non-indexed, zero-base-vertex draw the values are identical on Vulkan, WebGPU and WebGL 2.

The generated display.h provides ludus::shaders::display::Vertex() and Fragment() descriptions, with explicit backend entry names and minimum uniform sizes from each target's reflection. Include it in application implementation code. Only the selected target's bytes are embedded in the application. Shader description storage is borrowed during CreateShader only. Raw descriptions can instead supply SPIR-V words and WGSL text with separate explicit entry names. Use validated artifacts; runtime shader creation does not replace full offline SPIR-V validation.

The shader has set/group 0, binding 0, one uniform block visible to both stages, vertex-index-generated positions, and location 0 RGBA fragment output. No vertex attributes, textures, samplers, storage, compute, blending, depth, culling, multiple draws, dynamic offsets or push constants are provided. Uniforms are 16..16384 bytes in multiples of 16. The 16 KiB ceiling permits full-resolution simulation snapshots while staying within OpenGL ES 3.0's minimum uniform-block limit. Shader reflection and runtime admission share this ceiling. CPU staging remains fixed (128 KiB per active backend for eight slots); updates allocate nothing. Eight resources of each kind are available; exceeding the pool or exhausting never-reused IDs fails explicitly.

CPU layouts are application contracts. Read separate SPIR-V, WGSL and Metal reflection and emitted layout; use explicit padding and C++ standard-layout/offsetof/size/ alignment assertions. Different targets may need different upload structs. The helper carries occupied size to prevent undersized bindings; it does not infer CPU field types or assert that packing matches. The diagnostic's independently checked fields are resolution float2 at 0, elapsed float at 8, direction float3 at 16 and tint float4 at 32: 48 bytes, alignment 16 for each target, padding at 12..15 and 28..31. These measurements apply to that shader only (ADR 0010).

Browser backend selection (WebGPU / WebGL 2)

On the browser, Start(app, window) selects BackendSelection::Auto: it attempts WebGPU (including its compatibility retry) and, only on a capability/startup failure, makes exactly one WebGL 2 attempt. Start(app, window, selection) forces WebGPU or WebGL2 for diagnosis; a forced backend that is unavailable fails explicitly rather than switching. Native accepts only Auto, selecting Vulkan on Linux and Metal on macOS. StartupInfo::SelectedBackend reports the committed backend, and WebGpu/WebGL2 carry bounded per-attempt errors for QA. The GLSL ES artifact requires LUDUS_SPIRV_CROSS at build time (ADR 0013/0013); otherwise the browser build is WebGPU-only. apps/smoke is the worked example of a backend-agnostic renderer driving this through the public API. See ADR 0014 and docs/development/webgl-fallback-sandbox-handoff.md.

Readiness and ownership

Explicit R0 device ownership

Include ludus/graphics/rhi/device.h for the first explicit R0 contract slice. It links through the same Ludus::GraphicsRhi target and shares the facade's single session, authoritative resource registry, callback isolation and backend teardown. Every explicit operation validates its device owner before delegating. Frame operations also validate the borrowed surface owner. A headless target is a surface in this slice; independent device-only/offscreen resource APIs remain future work. Serialize all operations on the main thread.

namespace rhi = ludus::graphics::rhi;
rhi::DeviceHandle device;
rhi::SurfaceHandle surface;
rhi::DeviceDescription request;
request.Limits.MinUniformBufferSize = 48;
request.Preferred.Compute = true;
rhi::StartupInfo failure;
const auto started = rhi::CreateDevice(app, window, request, device, surface, &failure);
// Ready or Pending owns both outputs. Otherwise inspect started and, for a
// synchronous backend startup failure, failure. Admission preserves all outputs.
if (started == rhi::DeviceStatus::Ready || started == rhi::DeviceStatus::Pending)
{
    rhi::DeviceInfo info;
    const auto polled = rhi::GetDeviceInfo(device, info);
    if (polled == rhi::DeviceStatus::Ready)
    {
        // Use device-aware CreateShader/CreateUniform/CreatePipeline, GetStatus,
        // UpdateUniform and DrawFullscreen overloads; check every result.
        // Inspect info.Enabled.Compute before choosing the GPU compute variant.
    }
    (void)rhi::DestroyDevice(device);
}

Poll once per application tick while Pending; continue only after Ready. Keep the borrowed Platform window/canvas alive until DestroyDevice. Required Compute or IndirectRendering rejects forced WebGL 2 before launching startup; other backends validate their enabled compute limits before Ready. Required PortableRaster validates the negotiated bounded profile before Ready, including each browser Auto attempt; see the portable raster contract. GetCompiledBackends reports compiled code, independent of adapter availability. Supported/Enabled report implemented fullscreen, portable raster and bounded compute operations only after Ready; they make no claim about raw adapter features. The copied request retains its preferred features, including unavailable ones. Effective limit requirements apply to every Auto attempt through the existing negotiation.

Use SetFrameTarget(device, surface, target), BeginFrame(device, surface) and EndFrame(device) for explicit frames. Skipped opens no frame. The output form GetFrameInfo(device, surface, info) preserves info on invalid ownership, pending startup or a closed frame. Resource overloads preserve the existing fullscreen ownership, failed-resource inspection and InUse rules; this slice does not introduce deferred destruction or a second resource registry.

WebGPU also accepts FrameTarget::ShouldPresentToSurface = false for an offscreen-only frame. Use nonzero logical dimensions, then record texture attachment passes or compute work through the normal frame/submission APIs. This mode does not configure, acquire, clear or present the canvas. Surface attachment passes and compatibility fullscreen draws return InvalidState; GetFrameInfo reports the logical target extent. Other backends return Unsupported from SetFrameTarget and preserve the previous target. The default remains a surface frame. This lets browser shadow preparation retain the last presented image while its candidate resources are pending.

Device owners never wrap/reappear across restart; callback attempt tokens remain separate so fallback retries keep the same device owner. Stale/foreign devices and surfaces return InvalidHandle without changing another session. Device loss stops explicit work with DeviceLost, clears enabled capabilities and invalidates resources; the owned lifecycle record remains queryable until destruction. Published asynchronous failure likewise still requires DestroyDevice. A synchronous startup failure preserves null outputs, copies optional startup diagnostics, tears down and leaves the session idle. Successful DestroyDevice clears its device argument; clear stale surface/resource variables yourself before reusing them as creation outputs.

Use either the explicit lifecycle or the facade lifecycle for a session. Duplicate starts are rejected. The compatibility Shutdown still cancels the shared session, making all explicit handles stale; it cannot leave an explicit owner pointing to a later facade session. Existing facade-only callers retain their behavior.

The reference-backend lifecycle suite tests admission, copied requirements, output preservation, cancellation, late completions, fallback generations, cross-owner misuse, loss and non-wrapping owner exhaustion. R1/R2 add typed slot/generation resources, retained command batches and deferred retirement; R3 adds ordered graph compilation. Profile negotiation adds enabled limit/format admission; independent devices/surfaces remain architecture extensions.

Profile, format and limit negotiation

DeviceDescription::RequiredProfile adds copied raster, compute and format constraints to the existing workload flags and fullscreen minima. Admission checks the selected backend's enabled limits before publishing Ready and before resource creation. Every Auto fallback attempt checks the same copied request. A nonzero required raster constraint or format request implies portable raster admission; a nonzero compute constraint implies compute admission. A forced WebGL 2 compute requirement rejects before launching startup.

rhi::DeviceDescription description;
description.RequiredProfile.Raster.MinBindings = 3;
description.RequiredProfile.Raster.MinVertexBuffers = 2;
description.RequiredProfile.Raster.MinUniformRange = 48;
description.RequiredProfile.Raster.MaxUniformOffsetAlignment = 256;
description.RequiredProfile.FormatCount = 1;
description.RequiredProfile.Formats[0] =
{
    .Format = rhi::RasterFormat::Rgba8Unorm,
    .Uses = { .Sampled = true, .Filterable = true, .ColorAttachment = true, .Blendable = true },
    .MinDimension2D = 96,
};

Zero limit fields impose no additional constraint. Min* fields ask for at least that effective capacity; MaxUniformOffsetAlignment and MaxStorageOffsetAlignment ask for an alignment no larger than that value. Workgroup axis limits and invocation product are independent bounds, not a promise that every combination fits. Limits are not memory reservations: resource creation still checks dimensions, mip packing, aggregate upload size and allocation errors.

While Ready, DeviceInfo::Profile contains the immutable raster/interface/compute limits and five FormatCapabilities entries indexed by RasterFormat. Include profile.h to use the profile vocabulary directly, or raster.h to call GetFormatCapabilities(device, format, output). Invalid owners or malformed format enums preserve query output; pending, failed or lost devices zero it. A known but unavailable format returns Unsupported with zero output. DeviceInfo profile snapshots are also zero outside Ready, so an earlier ready snapshot must not be used after loss/restart.

Format use flags describe implemented operations. All supported formats allow original-format sampled views and linear filtering. Only RGBA8 linear/sRGB support the current RGBA color/private-depth attachment and blend path, within backend format limits. R8/RG8 remain sampled only. L4 adds RGBA16F when HdrColor is enabled: it supports sampling/filtering/blending through an enabled color attachment. R9 also accepts original binary16 uploads and sampled mip chains. Attachment/blending capability is independent of sampled HDR support. SampleCounts is a bit mask (value 1 for one sample); R10 independently enables four samples and color resolves. Storage texture uses remain false. R8 enables bounded RGBA8Unorm texture copy source/destination uses; other formats remain false. Creation uploads and frame readback alone do not constitute those uses. Vulkan checks combined sampled/upload/copy/attachment usage and private-depth bounds; portable backends intersect their guaranteed normalized formats with Ludus's implemented profile. Texture and offscreen pipeline creation enforce the admitted snapshot.

A request contains up to seven unique format entries; invalid format values, counts, duplicates, empty use sets or invalid sample counts return InvalidDescription without launching a backend or publishing outputs. A required constraint miss reports StartupError::RequirementsUnsatisfied, RequirementFailure::Profile and StartupInfo::ProfileFailure. That bounded diagnostic records the first unmet field, requested/effective bound, format and axis where relevant. Browser attempt records retain their own profile failure; a successful fallback clears the final failure while preserving the first attempt's reason.

PreferredProfile uses the same vocabulary without failing admission or elevating limits. Inspect DeviceInfo::UnmetProfilePreference for its first unmet constraint and UnmetPreferences for unavailable preferred workload flags. These diagnostics are valid only while Ready. Supported/Enabled remain implemented operations on the enabled device; this API does not enumerate raw adapter maxima or enable a feature based on its backend name. The pinned WebGPU default-device request policy is preserved. Independent devices/surfaces remain a separate extension.

Compatibility facade

  1. Start RHI with the Platform window/canvas and poll GetStartup. Continue only when Ready. Browser startup is Pending; failures require Shutdown before Start.
  2. Create vertex/fragment shaders and one uniform into default handles. Ready or Pending transfers ownership of a handle. Poll GetStatus for each; Pending is never drawable. Wait for all three Ready before CreatePipeline, then poll it. Pending dependencies return NotReady without creating a pipeline; Pending creation always supplies an owned output handle. Failed creation can retain a Failed handle for inspection/destruction; partial backend objects are already released. Invalid descriptions/admission failures leave the output unchanged. Never retry into a nondefault handle.
  3. Update the complete uniform byte range. It must be updated before first draw. Upload storage is copied immediately, so application stack data is safe.
  4. SetFrameTarget from framebuffer dimensions, then BeginFrameStatus. Skipped opens no frame: retain resources and retry next tick. After Ready, GetFrameInfo reports actual pixels and Unorm/Srgb attachment conversion. Update resolution from that extent before the draw if native negotiation changed it.
  5. DrawFullscreen only with a Ready pipeline, once inside that frame. UpdateUniform is allowed before the first draw, including after begin; updates after draw are rejected. EndFrameStatus submits. Do not end a skipped/failed begin.
  6. Between frames destroy pipeline, then shaders/uniform. Dependencies of Ready or Pending pipelines return InUse. A failed pipeline holds no dependencies. Shutdown can replace individual destruction and also abort an open frame.

Copied handles refer to the same ownership; destroying one invalidates every copy. Destroy does not clear your local value: assign {} before reusing it as a creation output. Stale/default/previous-session handles return InvalidHandle. IDs are monotonic across shutdown/restart, and asynchronous callbacks carry only IDs in process-owned storage. They neither retain application pointers nor write into a reused resource slot. Serialize all operations on the main thread.

Vulkan allocates/map-persistently uploads two uniform slots at creation; draw copies the latest CPU shadow into the slot whose frame fence has completed. Resource destruction waits for GPU use; presentation fences retire WSI objects before resize/shutdown. WebGPU writes are ordered with submissions and submitted commands retain references when host resources are released. The engine creates no shader/buffer/pipeline resources or heap allocations per draw. Existing backend frame encoder/texture objects continue to follow the frame lifecycle.

Errors, resize and restart

Inspect each returned status and GetStartup after failures. Failed or DeviceLost sessions release resources and require Shutdown/Start plus recreation and upload. A zero-size target skips without invalidating resources. Frame acquisition outdated/timeout conditions skip; native suboptimal presentation schedules resize. Native attachment format changes fail rather than reuse an incompatible pipeline. GetFrameInfo outside an open frame returns zero dimensions.

Fragment coordinates have a top-left origin; positive viewport heights/no culling match the diagnostic on all targets. Shader output is numeric RGBA; Unorm attachments quantize it, while Srgb attachments apply linear-to-sRGB RGB conversion. GetFrameInfo reports the attachment conversion, so applications can choose their output convention explicitly. Palette and display choices belong to the application. Monitor/browser color management is outside these pixel tests.

See tests/sdk_consumer/render.cpp for complete exception-free public usage, layout assertions, skipped frames, resize, dependency rejection and restart.

Portable raster (R1)

Include ludus/graphics/rhi/raster.h and link the same Ludus::GraphicsRhi component. The first portable raster profile supplies immutable buffers, sampled textures, complete views, samplers, reflected stages, binding layouts, binding snapshots and indexed/instanced triangle pipelines on Vulkan, Metal, WebGPU and WebGL 2. It uses the explicit device and existing acquired frame surface/headless color target. It does not introduce a second device session. Request DeviceDescription::Required.PortableRaster = true when this workload is mandatory. R4 separately enables buffer compute and indexed indirect rendering on Vulkan, Metal and WebGPU; WebGL 2 requires an authored CPU/direct variant.

GetRasterCapabilities reports effective allocation, texture dimension, uniform offset/range, record and per-frame draw bounds. Limits are verified on the negotiated device/context, independently of the backend name. Creation requires a ready device and a null output; it is forbidden in an open frame. Only Ready or Pending publishes ownership. WebGPU creation returns Pending while both validation and out-of-memory scopes drain; poll the typed GetStatus once per tick. A dependency that is still pending returns NotReady without publishing an output. A published failed resource must still be destroyed. All public operations are serialized on the device's owner thread.

For native live development, set RasterShaderDescription::ShouldPrepareAsynchronously before creation. The backend consumes/copies the source and metadata before returning Pending and prepares it on one dedicated driver worker. Graphics and compute pipelines inherit that policy from their shader inputs; their description can also explicitly select it. Pending owns a handle: poll GetStatus, retain the active replacement target, and never bind a candidate before Ready. One-shot callers retain synchronous native creation by default; WebGPU keeps its existing asynchronous API and WebGL its context-thread policy.

Native preparation admits at most MAX_WORKER_QUEUE_TASKS jobs, including uncollected completions. Queue/startup/allocation failure is explicit and never falls back to blocking driver compilation. Captured shader bytes and task storage are charged to RHI host storage. The worker never mutates facade records, resource tables or render-pass caches. Owner-thread polling publishes results; pending callbacks retain dependencies even after public handles are detached. Shutdown joins the preparation lane before releasing the device, shaders, layouts or pass cache. Vulkan pipeline work uses no shared VkPipelineCache. See the development reload transaction.

The historical R1 profile below is deliberately bounded. L1 adds targets/depth, L2 adds R8 coverage, and L3 material/content versions expanded the original tables to 32 records per kind, sampled R8/RG8/RGBA8 mip chains and explicit mip samplers. L4 lighting expanded tables to 64, enables up to sixteen queried vertex attributes, and adds capability-gated single-mip sampleable/filterable/blendable RGBA16F attachments. Public declarations are the current API contract.

The original R1 profile:

Facility Contract
Records Configured Storage.ResourcesPerKind (default 128), including pending/detached records retained by dependencies or submitted work
Buffers Complete immutable initial bytes up to enabled MaxBufferSize, subject to logical resource budgets; Vertex, Index16, Index32 or Uniform role
Uniforms Bound range at most 16 KiB, range size a multiple of 16, offsets aligned to the queried limit (at least 256); allocation may be larger than a bound range
Textures RGBA8 linear or sRGB sampling, within the enabled per-format/device dimensions; one mip/layer/sample
Uploads Complete top-left RGBA8 rows, explicit pitch divisible by four; caller bytes consumed/copied before return; aggregate upload at most the maximum buffer size
Views Complete original-format sampled views; no reinterpretation, partial sampled range or attachment view
Bindings One group/set, up to sixteen implemented slots intersected with enabled binding/descriptor limits; uniform ranges, sampled 2D textures and non-comparison samplers
Vertex input At most two streams, per-vertex or divisor-one per-instance, stride divisible by four, 4..256 on native/WebGPU and 4..252 on WebGL 2; up to the queried maximum (at most sixteen) unique float2/3/4 attributes
Draw Triangle list, Index16/Index32, zero base vertex/first instance, configured Storage.DrawsPerFrame (default 1024); fixed primitive-restart indices are rejected
State No culling, one color target, one sample; explicit depth comparison/write and premultiplied-alpha blend; defaults retain less-than

Draws use canonical clip depth [0,1], Y-up geometry and texture UV (0,0) at the first uploaded row. Vulkan uses a negative-height viewport; the GLSL ES artifact converts clip depth. The frame's private depth attachment clears to one. Pipelines remain valid on resize because the session target format stays fixed. R2 lifetime services add retained batches and bounded buffer upload/readback rings; R3 adds offscreen raster passes and R4 adds buffer compute. R8 adds completion-gated buffer updates and bounded RGBA8 texture update/copy/readback. Mip generation and storage textures remain extensions. Initial Vulkan texture uploads normally use a synchronous setup copy. A timed-out copy remains Pending and retains staging until its fence signals; poll GetStatus. This is not a streaming upload strategy or a performance claim.

Shader generation and binding snapshots

ludus_compile_shader(TARGET renderer NAME mesh RASTER
    SOURCE shaders/mesh.slang VERTEX vertexMain FRAGMENT fragmentMain)

The RASTER option emits RasterShaderDescription, separate per-stage artifacts, and a manifest with per-target bindings, vertex inputs, occupied uniform sizes and member offsets. Tool acquisition and pinning follow the shader workflow above. Vulkan/Metal layouts are independent: match CPU packing to the selected artifact and verify offsets/size/alignment. Hand-authored artifacts must supply independently verified target metadata. Metal pipeline reflection and the actual linked WebGL program independently reject incompatible used resources/inputs.

Browser generation verifies emitted WGSL and GLSL ES uniform member offsets and sizes, rather than accepting equal total sizes alone. The first browser parser accepts flat float/vector uniform members and rejects unsupported shapes. It compiles WGSL stages separately because pinned Slang 2026.1.2's combined reflection omits per-stage resource-usage flags. The tool matches emitted named float inputs to stage reflection and explicitly normalizes their locations; inter-stage outputs and builtins are not rewritten. GLSL ES varyings are named from their independently verified SPIR-V location map so ES 3.00 can link stages by identifier. Mismatched interfaces reject generation. A texture paired with multiple samplers in one stage is outside this first GLSL profile. SPIRV-Cross gives a separate texture's Load and GetDimensions a dummy sampler; when the stage also samples that texture with exactly one sampler, generation routes its texelFetch and textureSize calls through that sampler, which they ignore.

Create a binding layout covering both shader stages, using the union of their requirements and visibility. CreateBindingSet requires exactly one entry per layout binding, with correct kinds, uniform roles, offsets and accessible sizes. The snapshot retains its layout and referenced buffers/views/samplers; a view retains its texture. A pipeline retains stages and layout independently of binding contents. DrawIndexed requires the exact layout identity, not just structural similarity, and a declared vertex slice for each stream. It checks every mesh-local index against VertexCount, accessible vertex/instance records, index bounds and completion capacity before issuing native work.

Create resources outside frames, then set a target, BeginFrame, DrawIndexed and EndFrame. Check every result. Portable draws and DrawFullscreen cannot share one frame. The complete public-only example is tests/sdk_consumer/raster.cpp, with its application-owned shader.

Destruction and validation evidence

Typed Destroy takes the handle by reference, invalidates public resolution and zeros that variable. Other copies become stale immediately. Physical release waits for immutable dependencies, the current frame's CPU leases, successful GPU completion, and pending backend validation scopes. Resource generations, callback request IDs and ordered submission ordinals never wrap/reappear. A capacity failure can therefore occur after public destruction while old work is still live. Shutdown/loss invalidates all records and drains/drops the native device; late browser callbacks cannot affect the next session.

Vulkan general allocations use the private MIT-licensed Vulkan Memory Allocator 3.3.0, pinned with header/license hashes in config/vulkan_memory_allocator.json. VMA handles mapped allocation flush alignment. The existing ordered queue's fences establish resource retirement; Metal uses command-buffer completion, WebGPU queue work-done callbacks, and WebGL nonblocking sync polling. The minimum retirement mechanism accompanies the first general resources; R2 exposes this same retirement ledger through the services below; dynamic descriptor arenas and texture streaming remain deferred.

The native RHI pixel fixture draws two textured instances, then a farther, differently tinted background; it checks texture orientation, instancing and depth after public resource destruction. Installed shader-helper checks compile and run the public-only SDK consumer, verify per-target interfaces, dependency rebuilds and binding rejection. The browser SDK harness tests/sdk_consumer/raster-browser-test.mjs checks pixels on forced WebGPU, forced WebGL 2, Auto and capability fallback, plus invalid shader/pipeline and device-loss paths in pinned Chromium. Browser software-GPU results are separate from physical GPU and hosted-browser acceptance. These checks make no throughput or allocation-performance claim. See the architecture's phase evidence.

Lifetime services (R2)

Include <ludus/graphics/rhi/lifetime.h> with a ready device that enabled PortableRaster. These services share R1's registry and dependency graph; there is no second device or ownership ledger. All calls run on the device's owner thread, between frames unless a query or tracked EndFrame says otherwise.

The first service profile has four command batches, 256 draw packets per batch, four upload slices and four readback slices independently, 256 KiB per slice, and sixteen independently owned pipeline requests. These are historical R2 bounds; R6 configures batch/packet/pipeline-request limits. R8 configures transfer rings within the initial four-slice, 256 KiB upper bounds. Query GetLifetimeCapabilities; retained/cancelled work counts against capacity. Records still count against the R1 per-kind limits. CapacityExceeded is retryable after safe retirement, without waiting inside an admission call.

Record, finish, submit or discard

BeginCommands creates a Recording batch. RecordDraw validates and copies one complete draw, retaining geometry, the binding snapshot, pipeline and their resource dependencies. No backend command is emitted during recording. Destroy public handles after acceptance if desired: the retained packet uses the same physical records. FinishCommands closes recording once. Submit the Finished batch to its device/surface or DiscardCommands either Recording or Finished work; discard emits no GPU work.

rhi::CommandBatch batch;
rhi::SubmissionToken completion;
// Check each result; these calls require Ready before advancing.
rhi::BeginCommands(device, batch);
rhi::RecordDraw(device, batch, draw);
rhi::FinishCommands(device, batch);
rhi::SubmitCommands(device, surface, batch, completion);

The snippet shows call order; the complete checked consumer is tests/sdk_consumer/raster.cpp. A skipped target returns NotReady and preserves the Finished batch for retry. Preflight or completion-capacity rejection preserves it too. Once encoding starts, an error may accompany an accepted partial submission; inspect both status and completion. A successful submission consumes the batch and shifts its leases to GPU retirement. A later resize acquires the current target without changing immutable pipeline identity. Shutdown/loss invalidates batches and releases their physical dependencies at the backend's safe teardown boundary.

EndFrame(device, completion) tracks direct and clear-only frames as well. GetStatus(device, completion) is a zero-wait query: Pending means accepted ordered work is outstanding, Ready means GPU execution completed. A complete higher token covers earlier accepted work on the same queue/session. Tokens are borrowed, require no release, never wrap, and are invalid across device restart. GPU completion does not prove presentation or pixel correctness.

Upload and readback rings

RequestBufferUpload copies complete immutable initial bytes before return. The ticket owns a ring slice and destination buffer; the caller can immediately reuse its byte storage. Poll its status on later owner/event-loop turns, then TakeUploadedBuffer transfers the Ready buffer into a null output. Release(ticket) cancels publication, preserving staging and the private result until GPU work and creation validation retire. Uniform/vertex/index role and size/alignment rules still apply. Index16 uploads pad only the private copy to four bytes; public size and index validation keep the original byte count.

RequestBufferReadback retains a Ready buffer and copies a bounded range to a separate ring. Offsets and sizes must be multiples of four within the original buffer size. After status becomes Ready, CopyReadback copies into caller storage; it never exposes a native mapping and preserves destination bytes on Pending/rejection/failure. Release the ticket even after a terminal request failure. Destroying the source handle before completion is safe. Logical cancellation does not recycle an in-flight copy or mapping.

Vulkan uses the existing private VMA adapter, cached mapped staging, narrow transfer/host barriers and zero-timeout fence queries. Direct3D 12 uses the existing direct queue, upload/default/readback heaps and exact fence queries; its D4a port record records the admitted transfer formats, offscreen support and validation scope. Metal uses shared staging and submitted blits. WebGPU uses copy buffers, asynchronous validation/work-done and read mapping; mapping success is separate from queue progress. WebGL 2 uses type-compatible copy buffers, zero-timeout sync queries and getBufferSubData only after completion. That final browser copy can incur IPC/CPU cost; this profile promises no driver time budget or throughput improvement. Native shutdown may wait for submitted work; browser callback cells stay occupied across shutdown until late callbacks drain safely. Admission skips those occupied callback cells so the remaining free slices stay usable after restart.

Pipeline requests

RequestPipeline copies the description and retains immutable shader/layout incarnations immediately. Equivalent descriptions share one pending/ready/failed record; comparison includes every active field, canonical attribute locations, fixed state and device session, with no pointer/padding/hash identity. PollLifetime launches at most one queued pipeline per call. WebGPU uses asynchronous driver creation; Vulkan, Metal and WebGL create on the context owner and may occupy that call. There is no portable worker-thread compilation or persistent driver-cache promise in this slice.

GetRequestedPipeline borrows the Ready pipeline while its request is owned. Do not Destroy that borrowed handle: release the request. Other requests and accepted batches retain independent leases. The last request removes the cache entry; callbacks and CPU/GPU work keep physical records until safe retirement. Cancelling a queued request before launch creates no backend pipeline. Failures remain explicit; no backend switch or fallback pipeline is inserted.

Reference tests cover finish/discard/submit, destruction at each boundary, capacity and delayed completion, cancellation, memory/validation failure and restart. Native pixel tests render from detached retained records; native transfer tests compare all four buffer roles. The installed SDK consumer checks uploads/readbacks, shared pipeline requests, completion and final-frame resource release on forced WebGPU/WebGL 2 and Auto. The browser harness adds pipeline, transfer and mapping failures and device loss. Software-GPU browser results remain separate from physical GPU/browser acceptance.

R8 extends these same rings with mutable buffers, texture transfers and mip initialization. Descriptor suballocation, extended pass APIs and a separate GraphicsDevice module remain later extensions.

Ordered raster graphs (R3)

Include <ludus/graphics/rhi/graph.h> and inspect DeviceInfo.Enabled.OrderedRasterGraph. Requiring this feature also requires portable raster admission. The compiler and direct raster passes use the existing RHI registry, ownership leases and ordered submission tokens. All calls run on the owner thread; setup, compilation and execution start between frames.

R6 makes graph, resource, pass, declaration, draw and transient-pool ceilings configurable through DeviceDescription.Storage. The historical first profile was four graphs, sixteen logical resources per graph, thirty-two ordered raster passes, sixteen declarations per pass, 256 retained draws per graph, and 512 semantic dependency report entries. Capacity rejection occurs before frame acquisition. The historical R3 attachment profile was one color and private same-size depth, one mip/layer/sample. R10 extends it with explicit color views, stencil, private aspect loads and color resolves, while retaining conservative whole-allocation hazards. R4 adds buffer compute; general copy passes and physical heap aliasing remain deferred. R5 adds explicit opt-in merging of compatible consecutive raster passes.

Author, compile and execute

Create an OrderedGraph, import each physical buffer/texture once, reserve new attachment versions with NextGraphVersion, then append GraphPassDescription values in intended execution order. Each pass copies its diagnostic name/source, resource declarations and complete draw packets, retaining their physical records. It does not retain callbacks or caller arrays. Declarations must cover vertex/index slices and every immutable binding entry, including unused extra bindings that contribute to WebGPU's rendering scope. Samplers are retained by binding snapshots and have no memory-content hazard declaration.

Buffer declarations carry byte ranges and roles. Reports explicitly identify conservative whole-buffer hazard tracking; the implementation does not claim subrange scheduling. Textures declare their complete color image. A pass cannot sample its own color attachment or declare conflicting uses of one physical resource. Clear establishes full initialization; Load requires defined prior contents; Discard followed by partial draws does not establish initialization. Store Discard invalidates contents. The acquired surface begins cleared by the existing frame path and must finish with defined stored color for presentation. Depth Clear uses explicit ClearDepth in [0,1], defaulting to one; Depth Load requires a stored prior depth image. A read-only depth pass requires Load and rejects pipelines that write depth. DepthWrite=false allows a depth-testing pipeline in that pass.

L1 adds RasterDepthCompare (Less, LessEqual, Greater, GreaterEqual and Always). Pipeline requests compare that field explicitly, so reverse-Z and conventional variants cannot alias. Existing R1 descriptions keep Less/clear one. Pass rectangles use top-left physical pixels and half-open edges. An explicit viewport must be nonempty and fit the actual attachment; scissors intersect drawable bounds and an empty intersection encodes no draw. Disabling either rectangle selects the complete actual attachment. Clears are whole-attachment regardless of rectangles, preserving R3's initialization contract. Use separate offscreen targets and Load composition for independently cleared split views; see L1 usage.

Offscreen viewport bounds validate during setup. Surface bounds validate against the actual acquired extent before switching direct passes; rejection preserves the current pass. A frozen graph can encounter a changed surface extent only after acquisition: an invalid surface viewport consumes that graph and returns InvalidDescription with any accepted clear-only completion published separately. It does not fault the device. GetTextureDescription copies an owned texture's immutable descriptor without native objects; IsNull(TextureHandle) identifies the surface selector, not resource readiness.

Versions identify contents, not new storage. Reads must name the current produced version at that authored position. A later producer, an overwritten version, a missing declaration or an undefined load/root fails compilation. In-place writes produce RAW, WAR and WAW dependencies against the same physical image. No pass is silently reordered. Present/surface passes, explicit side effects, and Export/History/Readback roots retain observable work; future-frame history writes are roots even when this frame never reads them. Culling follows content dependencies rather than retaining overwritten work solely for WAW/WAR edges. Reference compilation disables culling while retaining the same validation.

CompileOrderedGraph returns totals and copied diagnostic prefixes in GraphReport, including names, source locations, culling flags, first/last live uses, and RAW/WAR/WAW dependencies. Iterate the prefix arrays with ReportedPasses, ReportedResources and ReportedDependencies; full counts may exceed those arrays. Use the indexed diagnostic queries for the complete compiled plan. GRAPH_EXTERNAL_INDEX denotes external boundaries, unused resources and inapplicable error/group indices. No GPU work occurs during compilation. Successful compilation freezes setup. Discard a rejected plan and rebuild if its declarations need changing.

ExecuteOrderedGraph rechecks frozen import revisions, packet readiness, completion capacity and backend pass preparation before acquiring the current surface. Target skip and preflight failure preserve graph/token for retry. Once encoding begins, the graph is consumed even on failure; partial native/browser work faults the session rather than claiming rollback. Accepted execution publishes an R2 completion token and commits texture validity/use into the same registry used by direct drawing. Discarding an unexecuted graph drops CPU leases without GPU work. Shutdown/loss invalidates every graph incarnation.

The fully checked packet authoring is tests/sdk_consumer/ordered_frame.h, used by both native pixels and the installed-SDK consumer. It compiles an unused color pass, a depth-tested instanced scene, a Load pass with read-only depth, and a scene-texture composite to presentation. Pipeline Target selects the surface format or a matching RGBA8/RGBA16F offscreen format; dimensions are dynamic.

Imports, history and transient objects

GetTextureState returns the registry's exact accepted-content snapshot: non-wrapping revision, last submission dependency, semantic use, and color/depth validity. ImportGraphTexture requires that snapshot and a final semantic use. The compiler and executor reject stale snapshots, including changes made by accepted direct passes or sampled direct draws. Pending same-queue dependencies are ordered by GPU commands and barriers, without a CPU completion wait. History uses persistent owned attachment textures, roots their produced version, and imports the resulting snapshot on the next execution. The caller decides which persistent image is the history source/destination; there is no automatic frame-index rotation. Readback roots preserve a consumer's data; scheduling and copy-out remain separate lifetime services. R8 adds bounded texture transfers and readback between frames; see mutable transfers.

TextureDescription.Attachment=true allocates undefined color/private depth with an empty upload. Direct BeginRasterPass switches attachments inside an acquired frame with the same load/store checks. Direct frame end makes written attachments sampleable for subsequent draws and records that final use. R2 command batches remain surface-only; offscreen packet sequences use R3 graphs or explicit direct passes. Direct draws and R2 batches require a texture's last committed/draft sampled use to cover its binding visibility. Choose SampledBoth when exporting to arbitrary direct bindings; incompatible final color or narrower sampled use returns InvalidState before a draw/batch submission. Graph execution can transition these imports according to its declarations.

CreateGraphTexture acquires from a configured whole-description pool (default eight objects) during setup, before compilation. This early bounded acquisition lets callers build ordinary immutable texture views/binding snapshots before graph preflight; it is not interval-based physical aliasing. Every graph resource stays physically distinct through execution. Pool reuse requires all graph/view/binding/packet leases to end, validation callbacks to drain, and actual GPU completion. Idle objects may be evicted when descriptions change, such as resize. Fresh/reused contents start semantically undefined. The returned texture handle is borrowed: Destroy rejects it, graph consumption/discard invalidates it, and a later lease gets a new generation. Destroy owned views/binding sets when no longer needed or they keep the pool object retained. Persistent imports cannot borrow another graph's pool image; transient resources cannot escape through roots.

Backend lowering and acceptance

Vulkan retains the legacy 1.1 render-pass path, privately cached compatible pass objects and VMA attachment allocation. One mapper translates semantic texture uses to color-output, shader-stage and depth-test barriers/layout transitions; there are no ALL_COMMANDS barriers or newer feature requirements. WebGPU closes rendering scopes before sampling an earlier attachment, uses creation flags and private depth views, and relies on ordered queue validation/completion rather than exposing fake barriers. Metal uses separate render encoders with explicit load/store state. WebGL 2 uses owned FBOs, depth renderbuffers and owner-context commands. Its portable top-left texture convention requires a private flipped color blit at stored attachment boundaries; this copy cost is deliberate and has no performance-improvement claim. Discard still remains semantically undefined when a browser backend security-clears its storage.

Reference regressions cover packet retention, missing uses, forward/stale versions, feedback, undefined loads/stores/roots, RAW/WAR/WAW reports, history roots, direct/graph revision conflicts, retry/partial failure, identity/capacity and pool retirement across GPU completion and view leases. Native pixel tests exercise offscreen scene/UI/composite on Metal and Vulkan. The installed browser SDK harness adds forced WebGPU/WebGL graph/reference variants, validates pixels, compares complete optimized/reference images, and renders 120 frames. It retains all R1/R2 failure, fallback and transfer scenarios. Pinned Chromium software-GPU acceptance remains separate from physical-GPU/hosted-browser acceptance.

Buffer compute and indirect rendering (R4)

Include <ludus/graphics/rhi/compute.h> for compute pipelines and limits, and <ludus/graphics/rhi/graph.h> to execute dispatches. Vulkan, Metal and WebGPU expose DeviceInfo.Enabled.Compute and IndirectRendering only when their negotiated buffer profile is available. Vulkan requires compute support on its existing graphics queue. WebGL 2 exposes neither. Requiring either feature checks every Auto attempt before Ready; forced WebGL 2 fails before startup. Preferences allow an application to select its own CPU/direct-draw variant after Ready. The engine does not translate or emulate a compute kernel on WebGL 2.

Prepared buffer-only workloads and caches

<ludus/graphics/rhi/compute_workload.hpp> adds ComputeWorkload: an owned, reusable sequence of ordinary buffer compute kernels. Dispatch sizes are independent of image dimensions. It does not require Scene/View inputs, a color target, native ray queries, a machine-learning SDK or a runtime shader compiler. Vulkan and Metal use the existing compute backend; WebGPU can use the same contract where its negotiated limits admit the cooked kernels. WebGL 2 and unimplemented compute backends reject explicitly.

Describe immutable weight/model uploads, persistent embeddings and mutable scratch with ComputeWorkloadBuffer. InitialBytes must cover Size exactly; an empty upload zeroes the allocation. IsReadOnly rejects writable kernel bindings. External borrows an existing same-device storage allocation instead of uploading: retain its public owner until workload reset. MaximumBufferBytes charges every described buffer, including borrowed storage; simultaneous cache generations and device-wide resource budgets remain independent. It is a logical storage limit, not a limit on driver allocations or preparation metadata.

Each ComputeWorkloadKernel supplies a cooked Compute artifact, reflected storage bindings, exact ranges/access and workgroup counts. One allocation can appear only once in a kernel. Initialization copies metadata and uploads; artifact bytes remain alive until Poll completes. Ready or Pending acquires workload ownership; other results leave an empty owner unchanged. Preparation, reset and destruction run between frames on the device owner thread.

UseProgramFrom can name an earlier kernel that owns a program. Its shader is ignored, its bindings are checked against that owner's reflection, and it keeps its own descriptor set and dispatch. References to self, later kernels or another reference reject. This permits a deep inference workload to share one compiled shader/layout/pipeline without duplicating ownership or losing graph hazards. Default admission limits allow 512 compute passes, 2,048 dependencies, 1,024 resources per kind and 4,096 binding descriptors. These are lazy admission limits, not preallocated storage; host/device byte budgets still apply independently.

BuildGraph returns an uncompiled RHI OrderedGraph. Compile/execute it normally, or supply an output-version view to BuildGraph and append consumers using those final versions before compilation. When appending additional writers, pass shouldRootBuffers=false and root their final versions after the consumers; the graph does not allow conflicting roots for different versions of one allocation. It imports each allocation once and tracks versions across kernels; mutable final versions are history roots. Limits come from the negotiated RHI device, including StorageLimits::PassesPerGraph, not from the authored ray profile's four-pass bound. The workload reuses buffers and pipelines across graph builds. A frozen graph is valid only while its imported revisions remain unchanged; rebuild it after another writer executes. GetBuffer borrows a result for a subsequent GPU consumer. Workloads themselves never read results back to the CPU. RHI graph/submission leases retain resources when an owner resets, and no private queue or second lifetime ledger is added.

<ludus/graphics/rhi/compute_cache.hpp> adds completion-gated publication:

  1. Call Stage(device, key, description). Scene and model revisions are nonzero caller promises covering all relevant input bytes and preprocessing policy. Camera-only changes can reuse a view-independent scene key.
  2. Poll preparation until GetPhase() is Prepared, then call BuildCandidateGraph, compile and execute its graph.
  3. Call SetCandidateCompletion with the exact ticket from GetCandidateTicket and the submission covering its kernels. Foreign/stale attempts and invalid tokens are rejected, including retries of the same scene/model key.
  4. Drive ordinary PollLifetime and cache Poll. The active result changes only after GPU completion. Pending or rejected replacement leaves it available.

Cache candidates write only fresh owned buffers; writable External buffers are rejected. Shared read-only weights may be external, but their public owner must live outside the cache and outlive both generations. Do not borrow an active cache-owned allocation into its replacement: publication releases that owner. Admission failure preserves both generations. A failed prepared candidate remains inspectable; cancel it or stage a different key to repair. Superseding/cancelling an unsubmitted candidate also requires discarding its returned graph. The cache cannot prove that a caller-provided submission actually executed that graph; that association is an explicit API precondition. It does not promise temporal coherence for a model or execute a trained network automatically.

Buffers, kernels and limits

BufferRole::Storage, StorageVertex and StorageIndirect allocate buffers with complete initial bytes. The latter two also support vertex/instance input or indexed indirect arguments, respectively. Their role never changes. Initial sizes and storage binding lengths are multiples of four; binding offsets obey GetComputeCapabilities().StorageOffsetAlignment. Storage allocations obey Raster.MaxBufferSize; each bound range independently obeys Compute.MaxStorageRange. Layouts obey Compute.MaxStorageBindings, Interface.MaxBindings and each enabled per-stage descriptor count. Uniform buffers can also be read by compute. Compute storage entries use StorageRead or StorageReadWrite, RasterVisibility::Compute, and a reflected minimum element stride. Read-only StorageRead also admits RasterVisibility::Fragment on the compute/storage profile (Metal, Vulkan and WebGPU). Ordered raster passes declare that exact fragment read and receive compute-write-to-fragment-read synchronization. Vertex storage and raster storage writes remain unsupported; WebGL has no storage profile. The renderer's GPU image recipe uses fragment reads to present an image with constant draw geometry. Custom raster programs using this profile pass RASTER FRAGMENT_STORAGE to ludus_compile_shader; it emits native/WebGPU artifacts and omits the unsupported WebGL artifact. Built-in recipes declare this requirement in their manifest. Storage textures remain deferred; existing buffer updates follow the completion and lease rules of R8.

Compute WGSL uniform verification also admits signed/unsigned 32-bit scalars and vectors, plus bounded fixed vec4 integer arrays. Their emitted alignment, occupied range and named offsets must match that target's independent reflection. Integer fields use the existing opaque public uniform shape; they do not acquire a float material schema. Raster/WebGL uniform admission retains its float-only rules. This distinction avoids reinterpreting counters/random keys as floats.

The installed shader helper accepts one compute entry:

ludus_compile_shader(TARGET my_renderer NAME cull
    SOURCE shaders/cull.slang COMPUTE computeMain)

The generated ludus::shaders::cull::Compute() description selects the native MSL/SPIR-V or browser WGSL artifact, with target-specific reflection and verified workgroup dimensions. No GLSL ES compute artifact is generated. The bounded helper accepts structured-buffer scalar32, vector2 or vector4 elements (float32, uint32, int32); arbitrary structures, resource arrays and specialization-controlled local sizes are outside this helper profile. CreateRasterShader accepts its Compute stage, then CreateComputePipeline retains that shader and its exact group-zero layout. Poll Pending creation and check every result, as with raster resources. Pipeline creation, binding snapshots and destruction share the existing RHI registry and R2 leases.

Local dimensions are nonzero, at most 256/256/64 along x/y/z, and at most 256 invocations in total, further bounded by enabled device limits. Dispatch counts are nonzero and at most 65535 on each axis, again clamped to enabled limits. GetComputeCapabilities is authoritative. The renderer checks these limits and reflection; bounds checks, shader-local synchronization and race freedom are kernel obligations. There is no indirect dispatch or separate asynchronous queue.

Ordered dispatches and indirect commands

Set GraphPassDescription.Dispatch to one ComputeDispatch; omit raster draws and attachments. The graph copies and retains the packet. Multiple dependent dispatches need separate authored passes. Declare every buffer binding, including unused extra entries, with matching byte range and Compute visibility. Read-only storage names the current version. Read/write storage reserves the next version with NextGraphVersion; it also depends on the preceding contents, preserving bytes outside the declared write range. Complete creation bytes establish initial contents. Whole-buffer RAW/WAR/WAW tracking remains conservative; there is no subrange scheduling or physical aliasing. Export/History/Readback buffer roots preserve observable compute work. Frozen imports reject accepted writes from another graph, and successful submissions advance the shared content revision.

An indirect RasterDraw supplies a ready StorageIndirect buffer and a four-byte-aligned IndirectOffset containing IndexedIndirectArguments. Declare those twenty bytes with GraphAccessMode::Indirect. The same packet still supplies mesh/index/instance slices and nonzero maximum IndexCount and InstanceCount, which are validated before execution. The producing kernel must keep actual counts within those maxima and write zero FirstIndex, BaseVertex and FirstInstance; packet IndexOffset selects the mesh. Zero actual instance count culls the draw. The GPU arguments are not read back on the rendering hot path, so kernel correctness is part of this contract. The profile issues one indexed indirect command per packet, without indirect count buffers.

Vulkan lowers declared buffer uses to compute shader, vertex input, uniform shader or draw-indirect access/stage barriers on the existing queue. It retains writer visibility across multiple readers and subsequent R2 readback/copies. Metal uses tracked resources and separate compute/render encoders; WebGPU uses storage/vertex/indirect creation flags and ordered compute/render usage scopes. Neither exposes invented native barriers. Completion, deferred destruction, preflight retry and partial-encoding failure follow R2/R3.

Acceptance fixture

The shared ordered_frame.h fixture has an authored CPU culling function and the cull.slang GPU variant. A single invocation compacts four candidates into two visible instances and writes one indexed indirect command. GPU output buffers start at zero. Native tests read back the generated command through R2 and compare the complete rendered image against the CPU/direct variant. The installed browser consumer also reads back the command. Its harness compares compute/reference images to the CPU oracle, checks WebGL 2 and Auto fallback, rejects required compute on WebGL 2 before any frame, and preserves the existing readiness/loss/transfer scenarios over 120 frames.

Reference tests cover negotiated failure, layouts/local/dispatch bounds, missing uses, mutable-buffer RAW/WAR/WAW and history roots, culling, frozen revisions, retry, retention through completion and failure after encoding begins. Metal and pinned Chromium software-GPU checks run locally; Linux Vulkan, formal pinned SPIR-V validation and supported-host sanitizers are CI gates. Browser software GPU evidence remains separate from physical GPU/browser qualification. This fixture establishes correctness; it makes no performance improvement claim.

Measured pass merging (R5)

CompileOrderedGraph(device, graph, report, GraphCompileOptions{true, true}) opts into consecutive raster-pass merging. The first option controls culling, independently of merging. The existing bool reference overload preserves its R3/R4 behavior: culling is disabled in reference mode and merging stays disabled. Use {true, false} to compare the same live workload without merging, or {false, false} to inspect every legal authored pass. Validation, owner-thread use, frozen import revisions, retry, failure and completion contracts remain those of ordered graphs.

The current rule requires consecutive authored, live raster passes with the same physical color/depth attachment. Both must Store color and depth; the later pass must Load both, with matching depth-read-only mode, viewport and scissor. Read use lists must match field by field, including order, physical resource, version, range and visibility; only the validated color output version may advance. Different pipelines/bindings/draw packets remain legal and execute in authored order. Compute, declared side effects, a culled gap, clears, discards, changed read scopes or rectangles prevent merging. No barrier executes inside a group: its identical external read scope is synchronized before its first pass. The last store happens at group end. Every logical pass still updates the registry's color/depth/version ledger and retains its packets through R2 completion.

This adapts ordinary attachment ordering from Khronos's Vulkan Specification, Fragment Operations and the W3C/GPUWeb resource usage rules. Thanks to their authors for specifying the ordering and usage-scope constraints. It adds no subpasses, attachment feedback, query scope, aliasing or queue overlap. Vulkan/Metal/WebGPU reduce render-pass/encoder starts. WebGL2 also avoids repeated framebuffer setup and intermediate flipped sampling blits; final sampling still uses the established texture-origin conversion.

GraphReport separates authored/live passes from RasterPasses, ComputePasses, MergedPasses and DrawCount. RasterGroup maps each live raster pass to its first authored group member; GRAPH_EXTERNAL_INDEX denotes compute/culled entries. Dependencies, names, setup locations and logical lifetimes remain intact. Counts describe a successful plan, excluding the facade's acquisition clear, final barriers, driver-internal commands and presentation. A failed compile publishes no group/cost estimates.

PlanStorageBytes reports the graph control record and its owned payload capacities, including unused reserved capacity; shared table-page slack and compile scratch are accounted by GetStorageUsage instead. BufferBytes and TextureBytes sum all imported logical payloads, including unused imports, initialized mip levels and private 32-bit attachment depth. They exclude surface storage, native padding, staging, other graphs, retired resources and unused pool objects. These values are not physical backing or memory high-water measurements. Compilation and grouping use bounded storage; no additional engine heap allocation occurs for grouping. Driver/browser allocation counts remain unavailable in the current harness.

Paired native measurements

Build the native test target with the pinned toolchain and shader tools, then run:

python scripts/benchmark-rhi \
  --executable out/build/<preset>/modules/graphics/rhi/ludus_rhi_render_tests \
  --backend metal --device '<actual GPU, OS and driver>' \
  --output out/rhi-benchmark --runs 3

Use --backend vulkan on the native Vulkan host. Record actual device identity; software Vulkan evidence must be labelled as such. The harness compares one binary with merging on/off, alternates which mode runs first, warms 16 pairs per workload and records 128 pairs for each of 1/8/16/31 layers. Every completed RGBA image must match before a result is accepted; independent red/blue pixels verify painter order and reject identically empty results. It writes raw sample logs and JSON with p50/p95/p99 CPU distributions, executable hash, revision, dirty state, host, pass/draw counts and logical memory. It requires a complete sample set and fails on an image or execution failure. Timing thresholds are not CI correctness gates.

The shared SDK fixture draws a textured/instanced scene, painter-ordered overlays with differing hues/alpha and immutable binding snapshots, then samples the attachment into the surface. Setup CPU time includes authoring/compile; execute CPU time includes preflight/acquire/encode/submit. The subsequent full-image readback forces real completion outside those timers. Its host wait duration is neither GPU duration nor input-to-display latency. The original R5 harness leaves GPU timestamps, presentation latency and physical backing unavailable (null), never zero. R7 measurement adds optional device-clock intervals and scoped native API allocation observations.

Initial evidence, 2026-10-11: base f9cc7c8 plus this R5 implementation, pinned Clang 18.1.8, Development/RelWithDebInfo, no PCH, Apple M5 (8 GPU cores), Metal 4, macOS 26.6 (25G72), headless 96x64. Three runs passed 3,072 measured submissions and every paired full-image comparison. Generated results live in out/r5-painter-measurements/report.json and run-*.log in the task worktree.

Layers Raster scopes off/on Median execute CPU off/on (µs, range across three runs) p95 execute CPU off/on (µs, range)
1 2 / 2 23.9–25.9 / 23.7–26.0 27.5–94.7 / 28.1–82.2
8 9 / 2 47.4–65.9 / 22.2–31.5 51.1–107.6 / 26.1–91.0
16 17 / 2 85.0–105.6 / 27.6–33.0 95.6–208.2 / 35.0–54.2
31 32 / 2 209.2–272.0 / 42.0–58.8 245.0–405.6 / 52.8–89.2

Setup medians remained approximately 2.4–2.9, 6.3–8.8, 13.0–15.3 and 32.0–42.8 µs across the corresponding paired workloads. Both modes used 82,704 bytes of fixed plan-slot storage, 308 logical buffer bytes and 49,168 color/depth bytes; neither mode changed draw count or frame buffering. Host load produced visible tail variability. The single-layer control had no consistent benefit. Decision: adopt only as an explicit opt-in for compatible pass-heavy workloads. These tiny headless scenes establish an explainable CPU benefit, not a universal renderer/GPU/latency budget. Measure the target consumer before enabling it. Parallel recording, async queues, bindless services and native heap aliasing remain deferred pending their own workloads and evidence.

Unit regressions cover boundary mismatches, retained detached packets, versioned attachments, undefined discarded roots, skipped acquisition/retry and GPU-gated retirement. Native tests compare complete merged/unmerged images at all four sizes; the installed browser consumer compares merge/merge-reference on forced WebGPU/WebGL2 and tests Auto fallback. Existing fullscreen, raster, multipass, compute/CPU, upload/readback, resize and loss suites remain acceptance regressions; those cases have no new speedup claim. Physical/hosted browser, Vulkan/Wayland and presentation qualification are separate from software Chromium and headless Metal.

Scalable resource and plan storage (R6)

Set DeviceDescription.Storage before CreateDevice. The ready DeviceInfo.Profile.Storage copies those maxima and byte budgets; changing the original description cannot change an active device. Counts must be nonzero and below UINT32_MAX, and both byte budgets must be nonzero. These are admission ceilings, not eager reservations or promises that every combination fits memory. Query enabled raster limits as well as the copied storage profile.

rhi::DeviceDescription description;
description.Storage.ResourcesPerKind = 128;
description.Storage.ResourcesPerGraph = 160;
description.Storage.PassesPerGraph = 64;
description.Storage.DrawsPerPlan = 1536;
description.Storage.DrawsPerFrame = 2048;

The registry and Vulkan/Metal/WebGPU/WebGL resource tables grow in stable pages. Batch and graph payloads grow during setup, and compilation allocates its scratch and full diagnostics before execution. Frame admission prepares completion records before acquiring the target. Vulkan also prepares its configured RasterPassStates metadata cache before a frame; the default retains the historical 192 format/load/store/depth combinations. Other backends encode that state directly. Execution cannot grow engine storage. Whole-batch/graph frame-limit rejection happens before native draw work and preserves the plan for retry or discard. General limits do not enlarge the fullscreen compatibility facade or fixed upload/readback staging rings. Optional RT0 records now use the same stable-page host accounting with independent StorageLimits::RayResourcesPerKind; RayInstances requests the static TLAS instance ceiling reported by RayCapabilities::MaxInstances. Instance identity representation/device bounds and per-resource ray byte budgets remain enforced. Ray allocation telemetry retains GetRayAllocation/GetRayResourceStats; structure/buffer/scratch bytes stay in those categories rather than general ResourceBytes. See renderer L5.1.

GetStorageUsage copies current and peak accounting without allocating or waiting. HostBytes includes registry/backend metadata, optional retained CPU texture copies, owned packet/use/report arrays, compiler scratch, index shadows, page slack and overlapping allocations while growing. ResourceBytes counts logical buffer bytes and initialized texture mip extents, including private 32-bit depth for attachments. RGBA16F color counts eight bytes per texel. Pending creation, detached dependencies and submitted uses retain the charge until physical retirement. These counters exclude allocator headers, native driver padding/metadata, facade targets and transfer staging; they are not physical or resident GPU-memory measurements (R7).

Resource/plan operations report CapacityExceeded for capacity/budget pressure and OutOfMemory for allocation failure. Direct frame admission reports DeviceStatus::Failed when metadata preparation cannot fit/allocate, before acquiring the target; the ready device remains available for retry. Startup reports failure if a new host budget is below already committed metadata. Failed creation preserves caller outputs and refunds logical resource reservations. Setup can retain successfully allocated spare capacity when a later allocation fails; that capacity stays charged and reusable. Stable metadata pages are retained across shutdown/restart so late browser callback addresses remain valid. A new device cannot choose a host budget below those committed pages. Peaks describe the process storage epoch, rather than resetting on each device. Shutdown/loss still releases native objects and invalidates old handles, plans and completion tokens.

GraphReport keeps bounded copied prefixes for compatibility; PassCount, ResourceCount and DependencyCount cover the complete plan. After a compile attempt, call GetGraphPassDiagnostic, GetGraphResourceDiagnostic and GetGraphDependency with authored/indexed positions. Queries copy into caller-owned output, allocate nothing, and remain valid until graph mutation, discard or consumed execution. Missing diagnostics, invalid indices or stale handles preserve output.

The public SDK fixture creates at least 72 objects of every supported general resource kind, executes 405 prepared draws across 40 graph passes, and submits a separate 1,100-draw batch. Browser tests compare complete graph/direct images on forced WebGPU, forced WebGL 2 and both Auto paths. The native target ludus_rhi_r6_consumer exercises the same public API. Reference-backend tests additionally cover all nine kinds at 80 objects each, retained slot reuse, pending byte pressure, allocation rollback, checked host arithmetic, high-index WAR hazards and more than 512 diagnostic dependencies. These fixtures prove capacity/correctness; hardware performance remains R7.

GPU measurement and qualification (R7)

measurement.h adds optional frame probes, scoped allocation/retirement snapshots and selected-adapter observations. The public declarations own the API contract. Enable DeviceDescription::EnableGpuTiming before startup, then arm between frames:

#include <ludus/graphics/rhi/measurement.h>

// device/surface were created with EnableGpuTiming = true.
const auto armed = ArmGpuTiming(device);
if (armed == RasterStatus::Ready)
{
    // Submit an ordinary frame, command batch, or ordered graph.
    // Later, on the owner thread, poll without waiting:
    GpuTiming sample;
    if (GetGpuTiming(device, sample) == RasterStatus::Ready &&
        sample.State == GpuTimingState::Available)
    {
        const auto elapsedNanoseconds = sample.DurationNanoseconds;
        // Record elapsedNanoseconds alongside a separate CPU sample.
    }
}

There is one optional probe per session, independent of general resource/plan capacity. A skipped acquire keeps it armed; a recording frame also remains armed until submission. An armed/pending sample cannot be overwritten. Terminal samples persist until re-armed; destroying the device cancels them. Native teardown uses the existing drain before releasing query storage. WebGPU map callbacks carry a non-reused generation, so late callbacks after loss/destruction cannot publish into a new probe. Timing-disabled sessions issue no timing commands.

The observed interval encloses the acquired frame's clear, graph pass transitions, raster and compute commands. It is an elapsed device-clock interval, including GPU stalls; WebGL can include idle gaps while the CPU feeds commands. It excludes the later readback and display journey and does not measure GPU busy-cycle totals. CPU acquire/encode/submit, queue completion, GPU elapsed time and presentation completion remain separate observations. No result correlates device and CPU clock epochs or substitutes CPU elapsed time when a GPU result is unavailable.

Backend Timing implementation Precision and unavailable results
Metal Retained command buffer; GPUStartTime/GPUEndTime after successful completion API exposes seconds, but no precision guarantee; tick/valid-bit observations remain unknown. Invalid timestamps and command errors produce explicit terminal states.
Vulkan Two timestamps enclosing the graphics command buffer, nonblocking availability polling Queue timestampValidBits and device timestampPeriod are reported. Modular rollover is allowed only when the independent CPU elapsed bound is shorter than one counter period; ambiguous wraps are disjoint. A CPU bound validates the counter, never supplies the GPU duration.
WebGPU Optional timestamp-query, requested only when supported and opted in; two empty compute marker passes and asynchronous resolve/readback Four valid indices work around the pinned emdawn bridge's handling of absent indices. Indices 0/3 enclose every frame encoder. Privacy quantization/accuracy is unknown; a genuine quantized zero remains available. Unsupported timing does not fabricate a duration.
WebGL2 Optional EXT_disjoint_timer_query_webgl2 elapsed query Query availability is polled after returning to the event loop. GPU_DISJOINT_EXT, unsafe JavaScript integer results and context loss invalidate the result. Counter bits are reported; tick/precision are unknown.

Thanks to Apple for command-buffer timing and resource allocation sizes, Khronos for Vulkan timestamp queries and the WebGL timer-query extension, revision 4, and W3C/GPUWeb for pass timestamps and query resolution. The implementation adapts their lifecycle and availability contracts; it does not copy their sample code or assume common CPU/GPU epochs.

Memory observations and reporting cost

GetStorageUsage retains R6's host/logical high-water counters. GetAllocationTelemetry counts general raster/compute buffer and texture logical bytes through deferred retirement. Its resource API bytes use Metal allocatedSize or Vulkan VMA allocation spans, including private depth and retained texture-upload backing. Detached resources remain charged until dependencies and GPU completion permit physical release. Views/bindings do not double-count shared backing.

Vulkan also reports actual vkAllocateMemory block bytes in the raster allocator, including pool slack, transfer rings and ray allocations sharing that allocator. That broader scope is explicitly separate from general resource binding spans; it excludes the facade and swapchain. Thanks to AMD GPUOpen for Vulkan Memory Allocator 3.3.0 statistics, which distinguish block bytes from allocation bytes. Metal has no corresponding allocator-block observation. WebGPU/WebGL texture backing is opaque, so complete resource API byte totals are unavailable on those backends. No partial sum becomes a complete total. Physical residency remains unavailable everywhere; API allocations, VMA block bytes and unified-memory allocations must never be relabelled as physical VRAM/residency. General scope excludes facade/surface storage, transfer rings and ray objects; use the broader Vulkan block field or the existing ray contract for those separately identified scopes.

Peaks of API spans and allocator blocks are observed peaks at query calls; intervening allocation spikes can be missed. Sample at meaningful setup, submission, retirement and resize boundaries. The R6 logical/host peaks are continuously tracked by their owning counters. Reporting traverses current resource records and, on Vulkan, VMA statistics; it waits for no GPU and allocates no engine storage, but is not a constant-time hot-path operation. Timing setup/polling and extra marker/resolve commands also cost work. The paired harness records arm and poll/report CPU cost and runs the same workload with instrumentation disabled to expose that cost.

Named qualification records

python scripts/benchmark-rhi \
  --executable out/build/<preset>/modules/graphics/rhi/ludus_rhi_render_tests \
  --backend metal --device '<GPU, OS and driver label>' --gpu-timing \
  --qualification-name '<stable hardware-host-workload name>' \
  --output out/rhi-qualification --runs 3

The runner preserves raw logs and writes a versioned report.json with the actual selected-adapter observation, caller's host label, host OS, relevant enabled profile (raster/compute/ordered graph and resource/draw maxima), source revision/dirty state, executable hash, capture time, 96x64 workload, shader-enabled binary, warmup and sample identities. Each 1/8/16/31-layer merged/unmerged workload has CPU and available GPU p50/p95/p99 distributions, explicit GPU state counts, precision observations, logical/API/allocator memory scopes and sampled peaks. It rejects missing/duplicate samples, nonfinite values and a duration attached to an unavailable result. Timing thresholds are evidence, not correctness gates. Every paired complete image and independent painter-order pixel must pass first. Controls use the same executable, adapter and workload with timing disabled; their results are retained separately.

A record observes physical GPU identity only when the selected backend identifies hardware and reports its name. Software or privacy-hidden identities remain open. Physical residency, input-to-display observations and unexecuted host/surface journeys stay null/open. Local localhost software-browser coverage does not qualify a deployed hosted-browser journey or a physical browser GPU. A destroy/ restart test does not qualify actual hardware loss/recovery.

Initial R7 evidence, 2026-10-12, base 5014834 plus this implementation (dirty tree recorded): Clang 18.1.8, Development/RelWithDebInfo, no PCH, Apple M5, macOS 26.6 (25G72), headless 96x64. The named apple-m5-metal-headless-r7 record passed three runs: 3,072 instrumented samples plus 3,072 control samples, with every paired full image correct. Resource logical bytes were 98,628; resource API allocation bytes and observed peak were 197,044. Physical residency, allocator block sizes and display latency remained unavailable. Raw evidence lives in the task worktree's ignored out/r7-qualification/; the checked-in evidence is this summary and reproducible fixture/runner.

Layers GPU median off/on (µs, range across three runs) GPU p95 off/on (µs, range)
1 25.38–52.88 / 25.25–53.96 26.00–68.83 / 26.00–69.75
8 66.54–67.75 / 25.04–25.25 69.25–73.38 / 25.75–26.62
16 128.38–349.54 / 25.12–27.88 367.75–415.46 / 25.75–29.12
31 473.12–544.79 / 28.25–31.00 503.46–885.00 / 28.79–36.38

Here off/on means pass merging, with timing enabled in both modes. Median probe arm cost was 41–83 ns and combined poll/memory-report cost 375–792 ns; these are CPU observations on this workload, not universal overhead guarantees. Variability in unmerged runs is retained rather than filtered out.

Named journey Current evidence
apple-m5-metal-headless-r7 Paired physical GPU measurements and scoped memory above; enabled raster/compute/ordered graph, 64 resources per kind and 1024 frame draws.
apple-m5-cocoa-r7 Real WindowServer/Metal test: zero-size skip, 160→80→96 pixel target resize, acquired dimensions, completed timing, pending teardown, stale owner and device restart. Display latency and hardware-loss recovery remain open.
chromium-140-software-local-r7 Installed SDK, pinned Emscripten 4.0.23/emdawn and Chromium 140.0.7339.186: WebGPU/WebGL multipass pixels/timing, unsupported optional feature, delayed map callback after device loss, plus R1–R6 conformance. Physical GPU/hosted deployment remain open.
vulkan-physical-wayland-r7 Open: requires a named physical Vulkan GPU, driver and compositor/session, resize/loss journeys and measurement records. CI software Vulkan correctness is separate.
hosted-browser-physical-r7 Open: requires a deployed origin, actual browser/OS/GPU observation, canvas resize/device-loss/recovery and CPU/GPU distributions. Privacy-hidden identity/residency/display observations remain open.

R7's measurement API and qualification workflow are implemented. Physical Vulkan/ Wayland and deployed physical-browser qualification remain explicit acceptance work; the initial Metal and software-browser results do not close those gates.

Mutable transfers and mip initialization (R8)

Include ludus/graphics/rhi/lifetime.h. The ready portable raster profile supports existing-buffer updates for every enabled buffer role, and 2D Rgba8Unorm texture updates, GPU copies and readbacks on Vulkan, Metal, WebGPU and WebGL 2. Other transfer formats return Unsupported; R9 extends texture formats/types/views. FormatCapabilities::Uses.CopySource and CopyDestination report the enabled RGBA8Unorm transfer profile and can be required at startup. Allocation descriptions, sizes, formats and roles stay fixed. Every call uses the device/context owner between frames.

Mutate, complete and release

RequestBufferUpdate(device, buffer, offset, bytes, size, ticket) copies caller bytes before returning. Offset and size must be multiples of four, nonzero, and within the original buffer allocation and configured transfer limit. Index-buffer CPU validation copies change with accepted updates, so later draw validation checks the updated indices. A pending destination returns NotReady without publishing a ticket. Only Pending with a newly owned ticket accepts a mutation; preflight failures preserve the output and destination.

The conservative mutation rule rejects InvalidState while a binding/view, retained batch, graph, ray input or transfer holds a destination lease, or a submitted frame's actual GPU completion is outstanding. Release those owners and poll completion before retrying. Even disjoint byte/mip rectangles share this whole-resource rule. Retained records and host-visible memory are never rewritten beneath accepted work. Transfer commands use the existing ordered queue; no separate transfer queue or automatic resource renaming is promised. Accepted writes advance the R3 content revision after successful completion. Graphs import the resulting revision once the transfer has completed and its ticket is released; retained graphs reject external mutation.

TransferTicket owns notification, staging and destination/source leases. GetStatus or PollLifetime polls without waiting. The destination stays Pending until actual completion; a terminal transfer failure makes it failed rather than exposing uncertain contents. Release the ticket after completion before mutating again. Release can cancel notification early, but accepted GPU writes still execute. Destroying public handles also leaves physical backing retained until completion. Cancellation is not rollback.

Texture rectangles and rows

TextureRegion{mip, x, y, width, height} selects a nonempty top-oriented rectangle within one allocated mip. RequestTextureUpdate consumes exactly rowPitch * height bytes, with four-byte row alignment and at least width * 4 bytes per row. Padding is ignored. The service packs texels into owned, 256-byte-aligned staging rows on every backend; both caller bytes and internal packed bytes must fit the configured slice. Checked arithmetic rejects out-of-range coordinates, mip indices, pitches and overflow before native work. CPU-retained texture content remains immutable; updates/copy destinations with RetainCpuContent return Unsupported so ray/reference shading cannot see a stale shadow. Partial updates preserve the remaining initialized texels.

RequestTextureCopy copies equal-sized rectangles of distinct Rgba8Unorm textures entirely on the GPU. Source mips must be initialized. Same-texture copies reject, including disjoint rectangles; no overlapping-copy semantics are inferred. It retains the source and consumes an upload-direction request slot, but does not stream its pixels through caller memory.

RequestTextureReadback shares ReadbackTicket, GetStatus, CopyReadback and Release with buffer readback. The source mip must be initialized and the caller pitch must meet the same row rules. Call between frames on the ready owner thread with a null ticket. An accepted request returns Pending and publishes an owned ticket; poll its status and release it once, including on cancellation. Once Ready, copy-out returns rowPitch * height bytes in top-oriented row order and zeroes row padding. Pending, undersized output and failures preserve caller bytes. Native mappings are never exposed. WebGL reads the sampled backing's logical row order and maintains its flipped render backing when an attachment is mutated, preserving later Load passes.

Incremental initialized mip residency

Set TextureDescription::DeferredInitialization = true and pass an empty upload to allocate undefined Rgba8Unorm sampled mips. Attachments and copied CPU content cannot use this option. Each undefined mip requires a complete-mip update/copy: rectangle patches cannot prove that the remaining texels exist. Only successful GPU completion marks that mip initialized. Initialized mips may subsequently accept partial updates and readbacks.

GetTextureResidency returns allocated mip count, an initialized-mip bit mask and logical initialized texel bytes. These are content-validity observations, not physical residency, sparse allocation or an eviction policy. All backing is charged to the R6 resource budget at creation, including undefined mips. The initial sampled view spans the complete allocation and rejects until every mip is initialized. This makes R8b's incremental initialization usable safely with R8b. R9a's partial sampled views can now expose initialized mips before the remaining mips are defined. R3 hazards remain conservative whole-resource hazards.

For example, an 8-by-4 texture with three mips begins with mask 0 and no initialized bytes. Complete mip uploads publish masks 1, 3, then 7, with 128, 160 and 168 logical bytes respectively. A full-resource sampled view is legal only after mask 7 and transfer completion.

Staging budgets and qualification

DeviceDescription::Storage.TransferSlices configures 1–4 requests per direction. TransferBytes configures a four-byte-aligned 256–262144-byte staging slice; invalid configuration rejects startup. Defaults preserve R2's four 256 KiB slices. GetLifetimeCapabilities reports the configured values. Upload creation, mutations and texture copies share upload-direction slots; buffer/texture readbacks share readback slots. Completed owned tickets and cancelled unfinished work retain their slots. Backends allocate slices lazily, bounded by twice TransferSlices * TransferBytes per session; the existing fixed CPU packing/ copy-out scratch is separate. Transfer staging remains separate from R6's logical resource budget. R7 Vulkan allocator-block telemetry includes its allocator staging; general resource allocation telemetry keeps its documented scope.

Metal retains shared staging until command-buffer completion. Vulkan retains mapped slices, command buffers and fences, flushes uploads, invalidates readback and applies whole-resource transfer/host/shader dependencies. WebGPU waits for validation, queue progress and read mapping independently; late callbacks keep cells occupied across restart. WebGL uses fenced pixel-pack/unpack buffers and zero-timeout polls. A post-command GL error closes the session before unsafe reuse. Native teardown may wait; requests and polls do not issue blocking GPU waits. Driver calls can still incur host cost.

R8 implementation, PR 219, starts from merged R7 revision 6828502. The R8 reference tests cover output preservation, checked ranges/pitches, initialized sampling, copied source bytes, retained bindings/batches/graphs, unfinished frame ordinals, copy-source destruction, cancellation, pressure, terminal failure and loss/restart. The native and installed-SDK r8.cpp journey uses two 4 KiB slices, updates uniform/vertex data, incrementally initializes three mips, copies a rectangle after which its source is destroyed, checks padded buffer/texture round trips, and renders/readbacks the resulting four-color image. Browser conformance compares complete images on forced WebGPU, forced WebGL 2, Auto and authored fallback. Pinned CI additionally runs Vulkan validation, sanitizers, browser validation, static analysis and public SDK documentation. The browser harness also rejects an injected invalid texture copy and proves that delayed completion after device destruction cannot publish old work. Software Chromium acceptance does not establish physical-browser performance; R7's open hardware/host qualification gates remain open.

Texture types, formats and views (R9)

R9a adds original-format sampled color views and uploaded Rgba16Float mip chains. R9b adds D2Array and Cube; R9c adds D3 volumes and optional Bc1RgbaUnorm/Bc3RgbaUnorm encoded textures. These operations use the existing RHI identities, R2 retention and R3 whole-allocation GDI hazards. They do not introduce bindless descriptors, finer synchronization or sparse backing.

Creation and packing

TextureDescription.Dimension selects D2, D2Array, Cube or D3. DepthOrLayers is one for D2, an immutable layer count for D2Array, exactly six for Cube, or the base depth for D3. Cube faces must be square and upload in +X, -X, +Y, -Y, +Z, -Z order. Volume depth halves at each mip, clamped to one; array layers and cube faces never shrink. Attachments and retained CPU content remain D2; deferred initialization and mutable R8 transfers remain RGBA8 D2.

Uploads contain every mip in ascending order. Each mip contains all of its layers/faces/depth slices in ascending order. For an uncompressed format a slice occupies RowPitch * mipHeight bytes. For BC it occupies RowPitch * ceil(mipHeight / 4) encoded bytes. BC1 uses eight bytes per 4-by-4 block; BC3 and the R18 BC5 profile use sixteen. Pitches must cover ceil(mipWidth / 4) blocks and be multiples of the block byte count. Base BC width/height must be multiples of four unless HasUnalignedBlockExtents is enabled (native Vulkan/Metal profiles); small tail mips and unaligned native base edges still occupy complete blocks. There is no compression, format conversion, mip generation or implicit padding insertion.

Mips must be tightly concatenated; supplied offsets and the exact total byte length are validated before backend work. Uncompressed pitches are four-byte aligned (binary16 RGBA pitches are eight-byte aligned). HDR bytes are original IEEE binary16 channel encodings. Creation consumes/copies all caller bytes and metadata before returning; Pending owns asynchronous validation/upload work. The complete borrowed blob, including row padding, retains the existing RasterCapabilities.MaxTextureUploadBytes limit; the logical allocation separately consumes StorageLimits.ResourceBytes. This is independent of buffer allocation size and of the R8 transfer-ring slice size. Logical allocation bytes include every slice and rounded-up compressed block, and independently consume StorageLimits.ResourceBytes. The dimension/layer maxima do not reserve memory or remove these byte limits. Required mip counts are bounded by the selected dimension's largest enabled spatial extent; array layer and cube face counts never add mip levels.

Views and bindings

The legacy CreateTextureView(device, texture, output) selects the allocation's full dimension and range. The overload taking TextureViewDescription selects mip and layer ranges; zero counts mean the remaining range. Shader LOD zero refers to BaseMip, and shader array layer zero refers to BaseLayer. An array/cube may expose a single layer as D2. Cube views select all six faces; D3 views select mips, with no layer/depth-slice reinterpretation. Formats cannot be reinterpreted. TextureAspect::Color is enabled; Depth/Stencil explicitly use the separately enabled R10 private-aspect sampling profile.

Only selected sampled mips must be initialized. Thus a deferred RGBA8 texture with only mip two initialized can expose { .BaseMip = 2, .MipCount = 1 }; the legacy full view still rejects. Attachments retain the existing clear/store rules. A view retains its allocation after the public texture owner is destroyed. All views, bindings, batches, graphs and submitted uses still exclude mutable transfers on that whole allocation, even for disjoint ranges. GDI recognizes all new sampled binding kinds and checks initialized views before executing reads.

RasterBindingKind::Texture2DArray, TextureCube and Texture3D must match the view dimension. A mismatch rejects binding creation before native work. The offline shader helper reflects each target independently and checks the emitted WGSL and combined GLSL ES sampler dimension. Metal also verifies its native pipeline reflection. Descriptor arrays and compute-stage texture bindings remain outside this profile. SamplerDescription.W selects volume-depth addressing; mip filtering must be enabled to sample LODs beyond level zero.

Enabled backend matrix

Operation Vulkan Metal WebGPU WebGL 2
Normalized D2/array/cube/volume upload and sampling Queried Enabled Device limits Context limits
Original RGBA16F upload and sampling Queried Enabled Enabled Enabled
Original-format mip/layer color views Native views Native views Native views Budgeted sampled copies
BC1/BC3/BC5_UNORM D2/array/cube upload and sampling Enabled BC feature + format queries Device BC support Enabled texture-compression-bc Unsupported
BC volumes, cube arrays, format reinterpretation Unsupported Unsupported Unsupported Unsupported
HDR partial view without float color attachment support Queried Enabled Enabled Unsupported

FormatCapabilities.MaxArrayLayers, MaxCubeDimension, MaxDimension3D and PartialViews describe enabled operations, not raw hardware potential. Array layers and volume extents use independently enabled backend limits; mip chains remain bounded at sixteen. R18 removes the historical 4096 sampled 2D/cube ceiling. Vulkan queries combined image usage/type/creation flags; browser limits come from the enabled device/context. Compression is independently optional. Required and preferred format entries may specify Dimension, MinDepthOrLayers and PartialViews; startup reports unmet constraints before Ready.

WebGL 2 has no native texture views. A partial sampled view owns a GPU copy of its range, rebased to mip/layer zero. Its logical bytes consume ResourceBytes while retained, and destruction follows the usual completion gate. Full views share the backing. Float snapshot copies require the enabled float color-buffer extension. These copies preserve sampled texel order without introducing an attachment flip. API allocation/residency remains unknown on the browser paths; logical copy bytes are not a physical-residency claim.

The public tests/sdk_consumer/r9.cpp journey validates rendered readbacks for partial initialized mips, array layers, all six cube directions, volume slices and tail mips, binary16 HDR values, and BC1/BC3/BC5 where enabled. It destroys texture owners before drawing to exercise retained views. The same fixture is built natively and through the installed browser SDK; browser QA runs forced WebGPU, forced WebGL 2, Auto and Auto fallback. Reference regressions cover malformed slice/block packing, range bounds, typed binding mismatches, CPU view-relative mips and conservative mutation exclusion. Physical host qualification remains R7b.

Reference-scene texture profiles (R18)

R18a adds RasterFormat::Bc5RgUnorm for original BC5_UNORM blocks, including ATI2 DDS payloads prepared by a caller. It uses the R9 D2/array/cube upload, partial-view, typed-binding, graph-retention and completion contracts. Each 4-by-4 block occupies sixteen bytes, with independent red and green endpoint palettes and index bits. Native sampling returns (R,G,0,1) in normalized linear space. Normal reconstruction belongs to the caller. Signed BC5, BC volumes, compressed attachments, retained CPU texels and mutable compressed transfers remain unsupported; no decoder, compressor or automatic conversion is introduced. Format capability queries explicitly reject the WebGL profile and devices without enabled BC compression. HasUnalignedBlockExtents reports native base-edge support, and RequiresUnalignedBlockExtents admits that requirement before Ready. This covers prepared ATI2 payloads whose base sizes are not divisible by four. The pinned WebGPU profile requires block-aligned compressed base dimensions and returns Unsupported for unaligned bases; callers supply a separately authored supported texture. The RHI does not pad or resize it silently.

R18b publishes enabled texture width/height limits instead of clamping general raster to 4096. Vulkan intersects native image/type/usage format limits with the device limit. Metal uses the existing startup-selected Mac2/Apple4-or-later 16384 guarantee. WebGPU requests the adapter's reported 2D maximum before creating its device, then publishes the resulting device limit; the adapter alone is not an enabled profile. WebGL uses its sampled context limit, with separate cube limits and attachment limits intersected with depth-renderbuffer/viewport bounds. Existing required/preferred Raster.MinTextureDimension2D and Formats[].MinDimension2D admission checks apply before Ready. Required misses fail with requested/available diagnostics; preferred misses retain the enabled profile and report the first unmet preference. A per-format/use limit may be lower than Raster.MaxTextureDimension2D; callers must check both.

R18c separates allocation size (Raster.MaxBufferSize), complete creation-upload bytes (Raster.MaxTextureUploadBytes) and bound compute range (Compute.MaxStorageRange). Vulkan uses its Vulkan 1.1 maintenance3 allocation limit and storage-range properties; Metal uses maxBufferLength; WebGPU requests adapter buffer/storage maxima before creation and publishes enabled device limits. WebGL has no queryable buffer-size maximum, so its implemented ceiling is the signed 32-bit byte envelope. Successful admission does not reserve memory: logical allocations separately consume StorageLimits.ResourceBytes and may fail with CapacityExceeded or OutOfMemory, preserving outputs and ownership. A complete 8240-by-8200 RGBA8 blob requires both an upload limit and remaining resource budget large enough for its roughly 258 MiB payload; width alone does not establish support. There is no implicit resizing or conversion.

Binding metadata and copied dynamic-offset/shader-name tables now have sixteen slots (RASTER_BINDING_COUNT). Interface.MaxBindings and Binding.MaxBindings report the enabled entry/index envelope. Independently check Binding.MaxUniformBuffersPerStage, MaxSampledTexturesPerStage, MaxSamplersPerStage, Compute.MaxStorageBindings and Binding.MaxDescriptorsPerSet, MaxUniformBuffersPerSet and MaxSamplersPerSet. Vulkan set-wide uniform/sampler limits additionally constrain mixed-stage layouts. Texture array elements count as descriptors; visibility in both raster stages counts in each. Dynamic-offset limits remain separate. Layout admission rejects excessive per-stage counts before backend allocation, even when total entries fit. Required/preferred requests use Raster.MinTextureUploadBytes, MinUniformBindings, MinTextureBindings, MinSamplerBindings, MinUniformBindingsPerSet, MinSamplerBindingsPerSet, and Compute.MinStorageBindings alongside existing MinBufferSize, MinBindings and MinStorageRange. Required misses fail before Ready; preferred misses report requested/available diagnostics without changing the enabled profile.

Array layers and volume dimensions are independently queried for Vulkan image format/type/usage, enabled WebGPU device limits, and WebGL context limits. Metal uses the selected Mac2/Apple4-or-later guarantee of 2048 layers and 2048 volume texels per axis. Formats may expose lower or zero limits; compressed volumes remain unsupported. Formats[].Dimension and MinDepthOrLayers admit each requested profile before Ready. Initialized mip chains remain bounded at sixteen. R8 streaming still uses its separately configured bounded slices and supported formats/operations. R18c does not enable new compute, descriptor kinds, indirect commands or ray operations.

The offline raw-texel cooker accepts dimensions through 32768. Its default complete-chain budget remains 64 MiB; an authored max_upload_bytes integer can explicitly select a budget through 512 MiB. The cooker checks the entire padded footprint before processing texels. This offline envelope is not a runtime device guarantee; callers still negotiate and budget the upload.

The extended tests/sdk_consumer/r9.cpp fixture checks independent BC5 channel palettes in both interpolation modes, encoded index ordering, blue/alpha, small tail mips, array layers, a cube face and unaligned native base edges, then reads back the rendered pixels. It retains views after texture-owner destruction. Its patterned image uses 8240 texels when admitted, otherwise the actual lower enabled width, and up to 2049 admitted rows. Explicit row padding keeps its complete upload above 64 MiB on lower dimension profiles; a 2048-limit WebGL regression checks this path and its pixels. It samples array layer 256 when enabled (otherwise the highest admitted layer of a reported 256-layer array) and a 512-deep volume mip chain when enabled, otherwise a reported lower volume depth. A WebGL browser regression reports a 256-texel volume limit and verifies the admitted mip chain and pixels. These prepared textures exercise the old ceilings without loading a reference scene. Unit tests cover copied required/preferred requests, unsupported BC5, enabled 8192/16384 limits, malformed mip/slice/pitch lengths, native rollback and resource-byte budgets. The tests/sdk_consumer/r18c.cpp installed-SDK fixture allocates a 67109120-byte storage buffer, binds a 16777472-byte range, and reads its far-end authored value in a compute graph with five storage and four uniform descriptors. GPU output readback must equal 20. Native Metal/Vulkan and real Chromium WebGPU exercise this producer/consumer path; WebGL explicitly lacks compute and separately qualifies the R9 textures. Exact-limit/over-limit and per-stage admission tests cover unpublished handles, resource-budget rollback and preferred diagnostics. These correctness fixtures do not complete R7b/R7c physical reference-scene qualification.

Thanks to Microsoft, Block Compression (Direct3D 10), BC5, for the two-channel block contract; Apple, Metal Feature Set Tables, for the selected-family dimension guarantee; and W3C's WebGPU device required limits, for the adapter-to-enabled-device distinction; and Khronos Group, Vulkan maintenance3 allocation limits, for the enabled allocation bound. The milestone ownership and remaining binding/compute/ray/resource work live in the canonical RHI/GDI assignment.

Attachment extensions (R10)

R10a adds up to four offscreen color attachments, private depth sampling and independent stencil allocation/testing/sampling capabilities. R10b adds capability-enabled four-sample color attachments with pass-end color resolves. R10c adds writable views of individual attachment mips and array/cube layers, with initialization tracked separately for color, depth and stencil. R13's finer hazard tracking remains planned: synchronization and mutation exclusion still cover the complete allocation, even for disjoint views or aspects.

Allocate and select attachments

TextureDescription.Attachment allocates undefined color and a private depth image. Stencil=true selects a private depth/stencil image. Each allocation's dimensions, mip count, layers, sample count and stencil choice are immutable. Color attachments remain RGBA8 linear/sRGB or enabled RGBA16F; R8/RG8, compressed formats and volumes are sampled-only profiles. Single-sample attachments permit enabled mip chains, arrays and cubes. Four-sample allocations require D2, one mip and one layer; direct sampling and R8 transfers reject them.

Create a TextureViewDescription with Attachment=true, dimension D2 and exactly one selected mip/layer. This view retains its color allocation and the corresponding private depth/stencil subresource. It can be created before a clear, but cannot enter a sampled binding set. A regular color view or Aspect=Depth/Stencil selects a sampled view instead. Depth/stencil sampling requires a single-sample allocation and D2 view of one layer; shader mips are relative to BaseMip. There is no public format reinterpretation, standalone depth allocation or depth/stencil resolve in this profile.

FormatCapabilities publishes AttachmentViews, MaxAttachmentMipLevels, MaxAttachmentLayers, MaxAttachmentCubeDimension, DepthSampled, StencilAttachment, StencilSampled and the enabled SampleCounts mask. RasterInterfaceCapabilities.MaxColorAttachments describes the pass count. Format requirements can independently require attachment views, depth sampling, stencil allocation or stencil sampling; existing dimension/mip/layer/sample constraints apply to attachment uses too. Required misses reject before Ready; preferred misses report their exact diagnostic without silently changing a pass.

R10 operation Vulkan Metal WebGPU WebGL 2
Multiple offscreen RGBA color attachments Queried, up to four Four Four Context limits, up to four
Writable mip/array/cube-face views Queried native views Native views Native views D2 mip zero only; larger allocations reject
Private depth loads Queried sampled depth Enabled Enabled Unsupported
Stencil allocation and per-face operations Queried depth/stencil Enabled Enabled Enabled
Private stencil integer loads Queried sampled stencil Enabled Enabled Unsupported
Four samples and color resolve Combined color/depth/stencil query Device sample-count support Enabled for admitted colors Per-format color/depth sample query
Surface MSAA/MRT, depth/stencil resolves, sampled MSAA Unsupported Unsupported Unsupported Unsupported

WebGL's authored alternative uses single-sample D2 attachments and the enabled stencil test; shaders can use authored color data when private aspect sampling is unavailable. The SDK fixture explicitly selects these alternatives. Browser software-GPU correctness is separate from R7's open physical-host qualification.

Pass and pipeline compatibility

The legacy RasterPassDescription.Color path remains one D2 mip-zero, single-sample color/private-depth pair or the acquired surface. For an explicit offscreen pass, set Colors to borrowed RasterColorAttachment entries and ColorCount to their count; Color must be null. BeginRasterPass consumes the entries before returning; AddGraphPass copies them and retains the views. Every source has the same selected extent and sample count. The first color view supplies private depth/stencil. Sources and resolve destinations must be distinct allocations under the conservative hazard contract.

Each color entry has its own Clear/Load/Discard and Store/Discard. The selected subresource is initialized only by Clear, or Load of already-defined contents, followed by Store. Clear covers the full selected subresource, independently of viewport/scissor. Draw coverage does not repair undefined initialization. Depth and stencil have independent load/store controls; their read-only modes require Load and reject depth-writing or nonzero stencil-write-mask pipelines.

An optional Resolve selects a single-sample writable view with matching format and selected extent. The source must have four samples and Clear or defined Load. Resolve initializes the destination color before the source store, so source Store=Discard is valid. It does not initialize destination depth/stencil. R8 transfers remain single-sample RGBA8 D2 operations, and update the same selected mip initialization ledger after actual completion.

RasterPipelineDescription.ColorFormats is the copied offscreen format sequence; empty retains the legacy Target compatibility. Explicit pipelines specify the matching SampleCount and private Stencil choice. Stencil front/back faces have independent comparison/fail/depth-fail/pass operations and shared eight-bit read/write masks and reference. Disabled depth preserves private depth even when stencil testing is active. Unsupported combinations and mismatched pass/pipeline formats, counts, samples or stencil choices reject before an accepted draw.

TextureDepth2D reflects Texture2D<float> and uses unfiltered scalar loads; TextureStencil2D reflects Texture2D<uint> and uses unsigned loads. These bindings reject color views, each other's aspect and writable attachment views. The shader tool checks the emitted WGSL scalar type and GLSL sampler dimension; unfiltered GLSL loads carry an explicit no-sampler sentinel. Comparison samplers, depth filtering and compute-stage texture access are outside the profile.

GDI declarations, retention and evidence

Import each attachment allocation once. Declare each color source's new version as ColorWrite, or ColorReadWrite when its color load is Load; declare every resolve destination as a separate ColorWrite. Private depth/stencil operations belong to the first color allocation's version. Sampled declarations cover the source allocation, and the compiler verifies the actual retained view's selected initialized mips/layers/aspect. Missing attachment/resolve declarations, feedback, undefined loads and reads of uninitialized siblings reject during compilation. Whole-allocation export/readback roots still require every color subresource to be defined. RasterTextureState reports whole-allocation color/depth/stencil validity; its revision also protects partial initialization changes.

Advanced attachment passes preserve their boundaries; optional R5 merging does not move resolves or stencil load/store effects. Plans retain their attachment views even after the caller destroys view and texture owners. Native objects, including cached Vulkan framebuffers, retire after actual completion. Logical resource bytes include all mips/layers, enabled samples and private depth/stencil; WebGL's separate render/sample color backing is also charged. API allocation and physical residency remain separate R7 measurements.

The installed SDK's tests/sdk_consumer/r10.cpp journey compares direct and GDI pixels for two color outputs, MSAA resolves with discarded sources, selected array/cube-face mips, depth/stencil loads, stencil comparison accept/reject, and detached attachment owners. It performs seventeen RGBA8 readbacks on each selected backend, with explicit fallback cases for unavailable profiles. Native tests add malformed range/sample/resolve, typed-binding, undefined-sibling, declaration and completion-retention checks. Existing resize/loss suites continue to exercise the shared device/frame/resource lifecycle.

Scalable bindings and optional bindless (R11)

R11a adds completion-gated descriptor arenas and packet-local dynamic buffer offsets. R11b adds reflected, fully populated fixed arrays of sampled color Texture2D descriptors and an independently requested non-uniform indexing profile. The architecture milestone owns the assignment; the API comments own its detailed caller contracts.

Operation Vulkan Metal WebGPU WebGL 2
Dynamic uniform offsets Enabled up to negotiated limits Enabled Enabled up to negotiated limits Enabled via uniform range binding
Dynamic compute storage offsets Enabled up to negotiated limits Enabled Enabled up to negotiated limits Unsupported with compute
Fixed color texture descriptor arrays Enabled up to configured/native limits Enabled within 31 texture registers; array ranges cannot overlap Unsupported Unsupported
Dynamically uniform array selection Enabled only with the actual device feature Enabled Unsupported Unsupported
Non-uniform fixed array selection Explicit request, Vulkan 1.2 and enabled sampled-image indexing feature Explicit request and verified native lowering Unsupported Unsupported
Fixed color array/cube/volume descriptor arrays Enabled within sampled-image limits Enabled within texture register limits Unsupported Unsupported
Fixed sampler descriptor arrays, constant selection Enabled within sampler limits Enabled within sampler register limits Unsupported Unsupported
Fixed uniform/compute-storage descriptor arrays, constant selection Enabled within each buffer limit; no dynamic offsets Unsupported Unsupported Unsupported
Unsized sampled color descriptor arrays Explicit independent request; Vulkan 1.2 runtime and variable-count features Unsupported Unsupported Unsupported
Logical partially populated arrays Explicit layout flag; retained element-zero fallback fills holes Same fallback contract Unsupported Unsupported
Publish replacement after prior binding/submission Immutable snapshot publication Immutable snapshot publication Publish after asynchronous replacement reaches Ready Immutable snapshot publication

R11c table allocation and publication

RasterBinding::IsRuntimeSized admits an unsized sampled color shader array. Request DeviceDescription::Required.HasRuntimeTextureArrays or its preferred counterpart independently of BindlessTextureIndexing. Vulkan enables both runtimeDescriptorArray and descriptorBindingVariableDescriptorCount only when requested and available. The table occupies RUNTIME_BINDING_ARRAY_SLOT (binding 15); RUNTIME_BINDING_LENGTH_SLOT (14) is reserved for RHI-owned length metadata. Other consumer bindings remain available. The layout Count is the allocation ceiling; CreateRuntimeBindingSet chooses a positive actual length up to that ceiling. Fixed bindings retain their original lengths. Native layout support and variable-count limits are checked before layout publication.

Cook with NATIVE_BINDING_ARRAYS RUNTIME_BINDING_ARRAYS and include the installed runtime_bindings.slang. Declare the unsized texture array at the reserved table slot, and use BoundRuntimeBindingIndex(index) before indexing. Per-invocation indices additionally need the existing NonUniformResourceIndex annotation and independent indexing capability. The helper clamps against an immutable uint4 length buffer belonging to the bound snapshot. The emitted SPIR-V verifier checks the exact unsigned-vector layout, byte offset and first-component load; ordinary caller uniforms cannot substitute for the owned length. Unbounded access, wrong control layouts and unknown pointer paths reject. Generated shader Count is one, the verified minimum safe allocation. A single artifact can therefore grow or shrink between any positive admitted lengths without compilation or CPU readback. Hand-authored fixed bounds still impose their declared minimum.

Do not supply the length entry in CreateBindingSet or CreateRuntimeBindingSet. RHI creates and initializes its immutable uniform buffer during snapshot preparation and retains it with that snapshot. It consumes one buffer record, one uniform descriptor, and its checked host/resource bytes. Descriptor budgets charge the actual allocated table length, including fallback holes, plus this control descriptor and ordinary bindings. Replacements need overlap capacity until previous work completes. The owned control buffer has no public resource identity to import into GDI; its immutable read and lifetime follow the retained binding snapshot. All consumer resources still require ordinary graph declarations.

Runtime arrays require an authored fixed-array/scalar alternative on Metal and browser targets; no toolchain pins change. The existing texture-indexing limits remain independent of runtime allocation support.

RasterBinding::HasPartialBinding allows omitted array entries. Element zero must be supplied and is copied into every hole as a retained fallback. Therefore even a shader branch accessing a logical hole has a valid descriptor. Host GetBindingIndex rejects holes; non-uniform reachability declarations enumerate populated elements. GDI continues to require all physical resources, including fallbacks. This is logical partial population, not sparse descriptor allocation: the full allocated length consumes native and engine descriptor budgets. Native PARTIALLY_BOUND is not enabled or claimed.

PublishBindingSetUpdate(device, current, replacement) publishes a separately prepared Ready snapshot with the exact same layout. Call it on the owner thread, including after an earlier draw in an open frame; prepare replacements before beginning the frame. Publication performs no allocation, collection or native descriptor mutation. Success consumes replacement, invalidates the old public identity and its host index tokens, and makes subsequent work use the replacement. Previously retained graphs, batches and submissions keep the old snapshot and all its resources through completion. Failed or pending publication preserves both handles. This supports safe updates after earlier binds/submissions through snapshot replacement; it does not mutate a recorded command's descriptors or promise Vulkan's native UPDATE_AFTER_BIND most-recent-before-submit semantics. No native mutable-descriptor feature is inferred from this API.

Fixed sampler and buffer descriptor arrays use constant selection verified in the emitted target. Buffer arrays exclude dynamic offsets. Vulkan accepts uniform buffers and the existing compute storage kinds; storage textures and raster storage access remain R12. Metal supports fixed sampler and sampled color descriptor arrays, with separate nonoverlapping sampler/texture register ranges; buffer descriptor arrays remain explicitly unsupported.

Inspect DeviceInfo::Profile.Binding. Array count never implies non-uniform selection. Set DeviceDescription::Required.BindlessTextureIndexing when the workload cannot run without it, or Preferred.BindlessTextureIndexing when an authored scalar/constant-array alternative exists. A missing required feature rejects device admission; an unavailable preference is reported in UnmetPreferences. Foreign Vulkan devices cannot enable new features and do not infer indexing from adapter support. Portable scalar binding remains available.

Budgets, immutable snapshots and offsets

StorageLimits::BindingDescriptors limits populated descriptors across live, pending, detached and submitted snapshots. DescriptorsPerSet configures the per-snapshot bound, clamped by the backend. Descriptor payloads use budgeted Ludus storage during creation; command encoding neither grows metadata nor creates new sets. ResourcesPerKind still limits snapshot records, and HostBytes and ResourceBytes retain their distinct metadata/backing scopes.

Vulkan shares pools among snapshots with identical descriptor-type counts, including separate dynamic uniform/storage types. It frees sets only after completion and destroys an empty arena. Pool reservations are amortized in bounded groups, with checked sizing; allocation/creation failure rolls back the unpublished snapshot and native reservation. Metal/WebGL retain bounded copied binding payloads; WebGPU uses immutable bind groups. Replacing a snapshot creates a new identity and retains the old backing through its last submitted use.

Declare RasterBinding::HasDynamicOffset for a buffer entry. Supply additional bytes in RasterDraw::BindingOffsets.Bytes[binding] or ComputeDispatch::BindingOffsets. Absent/static entries require zero. The RHI checks alignment, addition overflow and snapshot.Offset + dynamicOffset + Size against the complete buffer before any native work. Native dynamic offsets are ordered by binding number, regardless of authored layout order. GDI declarations must cover the effective range. Batches/graphs copy offsets, so later mutation of caller packets cannot change retained work. Dynamic offsets do not authorize mutation of retained or in-flight buffer contents; the R8 exclusion rules remain.

Cook, declare and retain array access

Use the installed shader helper with the explicit native profile:

ludus_compile_shader(TARGET app NAME material RASTER NATIVE_BINDING_ARRAYS
    SOURCE material.slang VERTEX VertexMain FRAGMENT FragmentMain)

The helper supplies the RHI-owned raster_bindings.slang include directory. Declare a fixed color texture array with LUDUS_SHADER_BINDING, and bound a runtime unsigned index before access, for example Images[NonUniformResourceIndex(min(index, 3U))] for four elements. The cooker reflects counts and derives Constant/Uniform/NonUniform access from emitted SPIR-V. It admits constant in-range indices and verified unsigned min/clamp bounds; unsized arrays additionally require the explicit R11c allocation bound described above. Unknown pointer/access paths and unbounded indices reject. Metal uses the existing pinned SPIRV-Cross MSL 2.4 lowering for this profile, with independently checked emitted registers/counts and runtime Metal pipeline reflection. Direct Slang Metal lowering does not admit the pinned NonUniformResourceIndex operation. Pins are unchanged. This helper profile requires the pinned translator and an authored browser alternative.

CreateBindingSet requires exactly one entry for every (Binding, ArrayElement) unless HasPartialBinding supplies the R11c fallback contract above. Duplicates, wrong views and out-of-range elements reject. The immutable snapshot is the exhaustive reachable-resource table. Every resource stays retained, including array entries whose branches are inactive. Obtain a borrowed RasterBindingIndex with GetBindingIndex, and resolve it against that exact snapshot with GetBindingArrayElement. Destruction/replacement invalidates host index resolution; numerical shader indices are not transferable ownership.

For NonUniform arrays, RasterDraw::ReachableBindings must declare each populated array element exactly once using tokens from the bound snapshot. Missing, duplicate, stale or foreign declarations reject before encoding. GDI additionally requires sampled use declarations for every reachable physical texture, and retains the whole table through completion. Hazards remain conservative for complete allocations until R13. The verified shader bound prevents descriptor-table escape; resource lifetime is never inferred from whichever branch happened to execute on one frame. Hand-authored shader artifacts must independently verify the documented bounds and supply HasVerifiedArrayBounds.

Correctness and measurement evidence

ludus_rhi_r11_consumer and the installed ludus_sdk_r11_consumer compare static snapshots, dynamic offsets, GDI execution and constant/non-uniform native arrays against the same independently checked color stripes. GPU compute readback verifies a dynamic storage write at byte 256 with an untouched prefix/suffix. The native fixture destroys array texture/view owners before drawing and the snapshot before readback; retained backing must still produce the expected pixels. Optional non-uniform execution is reported unavailable when a Vulkan device cannot enable it. The source fixture enables its native array cases when the pinned translator is configured (LUDUS_R11_NATIVE_ARRAYS); the installed native SDK fixture requires that translator and always qualifies the array profile. r11.html exercises dynamic offset pixels, rejected misalignment and exact GDI ranges on forced WebGPU/WebGL 2; WebGPU also checks the dynamic storage write. Registry tests cover creation rollback, descriptor pressure across delayed completion, stale indices, retained array elements, 72-descriptor payloads, copied compute offsets and incorrect graph ranges. Reflection regressions cover fixed/runtime counts, target registers, indexing modes and unsafe bounds.

The native fixture reports CPU creation time for 32 static snapshots versus one reused dynamic snapshot, alongside populated descriptors and actual Vulkan pool reservations. R7 AllocationTelemetry adds BindingDescriptors, RetiringBindingDescriptors, DescriptorArenaPools and ReservedArenaDescriptors. Descriptor counts are not driver bytes or physical residency; driver descriptor allocation bytes remain unavailable. Query GetStorageUsage for budgeted metadata bytes. These observations compare the chosen binding workload, not GPU duration, display latency or universal speedup. Named physical GPU qualification remains an R7b gate; optional bindless never becomes the default merely because it is implemented.

R11a/R11b implementation is recorded in PR #239. At code revision 3dace88df9db11a0126fae3d7e2ae7241e5bf01b, the Linux Development and SDK gate passed source and installed R11 Vulkan execution under validation layers with no validation errors. Both constant and optional non-uniform arrays ran. The installed fixture reported 1,412,602 ns to create 32 static snapshots versus 41,968 ns for one dynamic snapshot, 33 populated descriptors and 3 native pools reserving 48 slots. Host metadata grew by 165,656 bytes for the static snapshots (including cold page/pool growth), then 24 bytes for the additional dynamic snapshot. This is one software-driver observation, not a repeated benchmark or physical-GPU qualification. The macOS native gate passed its installed Metal shader/consumer step, and the packaged browser gate passed the installed SDK's forced WebGPU/WebGL 2 pixel and dynamic-offset journeys. Pinned formatting, native/browser analysis, Linux/macOS sanitizers and API coverage gates also passed for that code revision.

Thanks to Khronos Group, Vulkan Specification, vkCmdBindDescriptorSets, and Vulkan Guide, Descriptor Arrays, for dynamic-offset ordering and independently enabled indexing/lifetime rules; and to the Slang and SPIRV-Cross teams for the target reflection/native lowering.

Raster world coordinate grid

ViewDescription::WorldGrid opts a raster view into an adaptive world grid. Set IsVisible, the positive FocusDistance in world units, and the drawable Width and Height in pixels. Perspective views use the XZ ground plane. Orthographic views choose XY, XZ or YZ from the camera's dominant forward direction. The grid includes red X, green Y and blue Z axes. BuildWorldGridLines exposes the same bounded layout for a UI that has no GPU scene yet.

DrawView, RasterViewport and conventional Submit draw one instanced guide batch after opaque geometry and before screen overlays. Conventional and reverse-Z depth tests use the scene's comparison direction with depth writes disabled. The guide does not consume scene mesh/item slots, change scene bounds or source-ID reports, enter shadow caster views, or appear in reference/path tracing captures. The draw count includes its batch. Grid buffers belong to the immutable prepared view and retire through ordinary RHI retention; pending or failed preparation preserves the caller's previous view. Grid scratch and per-view ownership records are charged to the renderer host budget, and GPU buffers/pipelines consume ordinary RHI storage budgets. The ribbon grid needs no new shader recipe.

Analytic grid

Renderer::InitializeGrid opts into the analytic grid after Initialize is Ready, with the cooked renderer_grid stages: twelve vertex locations (the flat program's position and instance streams, with the instance fields reused) and no bindings. A visible grid then draws one world-space quad on the grid plane, whose fragment stage measures each pixel's distance to the nearest minor, major and axis line (BuildWorldGridPlane supplies the plane, spacing, colours and fade distances). Coverage is computed in pixels, so lines keep their width at any zoom, are antialiased without a resolve, and fade out where neighbouring lines would be closer than ten pixels. A radial fade replaces the abrupt edge of the ribbon grid. Only the coordinate axis off the plane stays a ribbon. The pipelines blend premultiplied coverage and keep the ribbon grid's depth convention, so the draw count of a grid view is two instead of one.

The ribbon grid remains the fallback: without InitializeGrid, with a flat program that binds uniforms or visibility inputs, or when the grid shaders fail, views keep their 165 ribbons. While the shaders are pending PrepareView returns Pending for grid views. The editor opts in when it starts a viewport generation, as PreviewShaderProgram::Grid.

To measure the pass cost on the host's device, run LUDUS_RENDERER_BENCHMARK=1 ludus_renderer_render_tests "[grid]". It prints GRIDBENCH,<variant>,<size>,<frames>,<ns> lines for the same 1080p frame with no grid, with ribbons and with the analytic plane, each including clear and readback; subtract none for the pass cost. Set LUDUS_GRID_IMAGE=<prefix> to also write both images as PPM files.

Measured on the software Vulkan device (lavapipe, 8 cores, 1080p, 40 frames, eleven runs), a frame with the grid hidden took 0.11 ms, with ribbons 0.12 ms and with the analytic plane 0.17 ms, including clear and readback. The analytic pass therefore costs about 0.06 ms more than the ribbons on a software rasteriser, which shades every covered pixel on the CPU. A hardware GPU has not been measured yet; run the benchmark there before changing the default.

The editor enables this option on its camera publications, including project render graphs. Games keep the default disabled option. Custom render-graph shaders still control their own vertex/fragment behavior; attachment discard/load and multi-pass depth behavior remain the graph's authored contract.

Native texture sharing

native_interop.h provides a Qt-independent, same-device, same-queue color-read bridge on Metal and Vulkan. It admits an existing native context for headless RHI rendering and publishes a leased GPU texture for an external sampler. The bridge performs no CPU pixel readback, image upload, staging copy or silent backend fallback. GPU readback in the SDK fixture is solely its final pixel oracle.

The initial profile is one initialized Rgba8Unorm, D2, single-mip/layer, single-sample color attachment. Export its resolved single-sample destination when producing with MSAA. Depth/stencil, HDR, arrays, cubes, volumes, foreign writes, different queues and cross-device external memory/semaphore imports are outside this profile and return Unsupported. Formats and numeric RGBA values are unchanged; the consumer must choose compatible alpha and display-encoding rules. The editor's first tone-mapped preview is opaque.

Admit the actual compositor context

NativeInstance, NativePhysicalDevice, NativeLogicalDevice and NativeCommandQueue are distinct borrowed roles. Explicit constructors wrap the matching native objects; GetObject() performs the conversion at the framework boundary. NativeMetalTexture and NativeVulkanImage similarly distinguish image objects/bits. These types add no ownership and cannot be interchanged implicitly.

Use CreateNativeDevice with NativeDeviceDescription, rather than creating a second device on the same physical GPU. Both the logical device and command queue must match the consumer. Vulkan also carries the instance, physical device and created queue family/index; Metal requires the command queue's device to be the provided device. Vulkan admission requires an instance and physical device at least Vulkan 1.1 and does not infer enabled features from adapter support: BC compression and Vulkan native ray queries are disabled for an admitted context. The existing single-session limit remains; this bridge does not complete R14's independent multi-device/surface milestone.

The source owner supplies Retain/Release callbacks. A successful retain promises the native objects remain valid until release, including loss and shutdown cleanup. RHI owns its allocations, command pools and fences, and never destroys the foreign Vulkan instance/device. Metal references are strongly held. Callbacks run on the owner thread, must not reenter RHI, and release exactly once after successful retain. A rejected request preserves output handles. Structural native-profile rejection occurs before retaining the source; failures found during backend/capability negotiation balance a successful retain with release. Native handles must be valid objects with accurate provenance; arbitrary pointer values cannot be safely probed.

All RHI work and foreign queue access must be serialized on the RHI owner thread. GetNativeInteropCapabilities reports the enabled profile and R6 ResourcesPerKind bound for each native lease kind. Native lease tables and backend bookkeeping use the existing host allocation budget. The bridge pins existing texture allocations rather than allocating copied presentation images.

Produce, borrow, submit and retire

  1. Pin the context with AcquireNativeDevice, and compare the consumer's actual native context with GetNativeDeviceInfo. Registry ID/UUID describe the adapter; agreement on the adapter alone does not establish image compatibility.
  2. Render the image through ordinary direct/GDI commands and retain its accepted SubmissionToken. AcquireNativeTextureRead requires that token to match the texture's most recent accepted RHI use. The producer may still be in flight.
  3. Acquire the read before the foreign command buffer is enqueued or submitted. Vulkan records a memory/layout barrier that makes producer writes visible to every supported shader stage on the shared queue; Metal uses tracked texture hazards and an ordered command-buffer publication. The descriptor carries the exact native image, extent, original format, RHI incarnation/state revision and Vulkan shader-read-only layout. Copying a descriptor or handle adds no ownership.
  4. Wrap and sample the image on that same context. Do not write the image, change its ownership/layout, or enqueue new foreign reads after requesting release. RHI attachment/resolve writers and mutable transfers reject while any native read is live. A replacement texture can render while an old incarnation is held.
  5. After every consumer has actually submitted its reads, request ReleaseNativeTextureRead. Recording completion is insufficient. A GPU marker follows foreign queue work. Pending retains the handle/backing; query GetStatus, destroy foreign wrappers, and repeat release after completion. The descriptor getter rejects once release starts. Multiple readers each hold their own lease and completion obligation. DrainNativeTextureRead is an explicit blocking maintenance/teardown operation, never a presentation path.
  6. Destroy every foreign borrower of an exported context, then ReleaseNativeDevice. The original owner of an admitted context remains independent of this exported lease. Release rejects while its image reads live.

DestroyDevice/Shutdown immediately invalidate ordinary RHI handles and callback tokens. With native leases held, the bridge conservatively retains the old session's native allocations/device until all consumers complete and pins release. Owned presentation targets are detached at logical shutdown, so ordinary Platform window/view lifetime does not extend through that image retirement. Borrowed headless Vulkan pools/targets remain pinned until final retirement; admission and cleanup never drain the foreign device's unrelated queues. Maintenance waits cover only accepted RHI frame, texture-upload and transfer fences. GetNativeDeviceInfo then reports Retiring; no new reads may be acquired and new sessions reject while retirement remains pending. Texture/device backing therefore survives renderer destruction, resize/replacement and cancellation. GPU loss is terminal: stop new native use and retire leases; the source lifetime handshake still applies. Do not delete the foreign device behind an outstanding source retain.

Qt integration boundary

The editor adapter embeds a native QQuickWindow in its World splitter and admits the device/queue actually returned by Qt. Its basic render loop serializes all engine and Qt submissions on the GUI owner thread. Texture wrappers remain read-only and retain RHI leases through consumer submission/completion. Qt-specific code belongs in the editor adapter. Qt's Metal texture import and Vulkan texture import wrap original native images on the scene-graph render thread without owning them. They do not perform the RHI lifetime/synchronization protocol above.

For QQuickWidget, admit the device actually returned by its renderer interface. Qt 6.4.3's widget implementation selects its top-level backing-store device and overwrites a separately supplied QQuickGraphicsDevice. Multiple preview widgets in that top-level window can share one RHI session only when every native identity matches. Moving between top-level windows or recreating the scene graph requires controlled retirement before the source device disappears and re-admission of the new context.

Metal's native device/queue queries and image wrapper are available within the editor's Qt 6.4 minimum. The documented Vulkan graphics queue family/index queries require Qt 6.6; the Qt adapter must report Unsupported on older Qt Vulkan builds rather than guess a created queue or include private Qt headers. The editor then uses its own headless device and CPU readback instead (see the editor workspace guide). Native Vulkan consumers with known valid queue provenance do not depend on Qt.

Produce/acquire at beforeFrameBegin, before Qt enqueues its consumer frame. Seal a frame's foreign reads at afterFrameEnd, which follows submission. afterRendering describes recording and is insufficient. Use direct connections on the owner/render thread; QQuickWidget's render loop is not threaded. The adapter must coordinate scene-graph invalidation, native source cleanup and widget destruction with the source retain/release contract.

Validation

ludus_rhi_native_interop_consumer and its relocated installed-SDK counterpart create a separately owned native device, admit that exact context into RHI, produce an oriented four-color image, and sample its original native texture through independent Metal/Vulkan GPU commands. The Vulkan fixture additionally requires timeline semaphores (Vulkan 1.2) to keep foreign queue work blocked until after RHI admission and logical shutdown return; the bridge itself retains its Vulkan 1.1 minimum. They destroy ordinary texture and device owners before verifying all sixteen consumer pixels, then prove balanced source release after consumer completion. The fixture includes no RHI-private headers. Unit tests cover mismatched logical device/queue, preserved outputs, source retention failure, multiple readers, graph/direct/transfer write exclusion, replacement, stale identities, GPU-loss status and deferred shutdown. Browser backends explicitly reject native sharing. Native CI additionally runs the Vulkan journey under validation layers; hardware/workload qualification remains R7 evidence.

Thanks to The Qt Company for the native interface and QQuickWidget contracts, and to Khronos for Vulkan synchronization and fence ordering. The adopted bridge uses one serialized shared queue; generalized external memory and cross-queue synchronization are separate future profiles.

Compute reads of raster attachments

Compute-only layouts may bind one non-array Texture2D color view or TextureDepth2D depth view at each declared slot. These are unfiltered loads; compute samplers, other texture dimensions, stencil and texture arrays remain outside this profile. Slang uses Texture2D<float4> for color and Texture2D<float> for depth. Reflection must match the exact view kind.

Declare the physical image version as GraphAccessMode::Sampled with RasterVisibility::Compute, and use RasterTextureUse::SampledCompute when that is the imported image's final consumer. The ordered graph checks the selected aspect's initialization, keeps its raster writer live, orders attachment writes before compute loads, and retains the views and backing through completion. Color initialization alone does not permit a depth load after depth Discard. See raster first hits for the renderer-owned visibility/depth integration.