Skip to content

saivs/com.saivs.plugin.bindless.vulkan

Repository files navigation

Unity Vulkan Bindless Plugin

A native plugin that brings bindless textures to Unity on Vulkan.

Bindless Test

Companion to the Unity Multi-Draw Indirect Plugin — together they enable fully GPU-driven rendering: MDI removes the per-draw CPU cost, bindless removes the per-draw texture state.

What is Bindless Rendering?

In the classic binding model the CPU tells the GPU, before every draw, exactly which textures that draw is allowed to read: "slot 0 is the albedo map, slot 1 is the normal map". Those bindings are part of the draw's state — so every object with a different material needs its own draw call, and the CPU pays for every rebind. Batching systems spend most of their effort fighting exactly this.

Bindless flips the model. All textures live in one large table on the GPU that is bound once. A shader picks any texture at runtime by an integer index — usually read from a per-instance or per-material buffer:

half4 albedo = BINDLESS_SAMPLE(material.albedoSlot, uv);

The texture stops being state and becomes data. That changes what's possible:

  • A single Multi-Draw Indirect batch can render thousands of objects with different materials — the GPU fetches each object's texture indices from a buffer and samples the right resources itself. Without bindless, every draw in the batch is stuck with the same bound textures.
  • Any mix of textures in one table. Every slot keeps its own resolution, format, compression — and its own sampler: filter, wrap and aniso come from that texture's import settings. Compare that to the classic workarounds: atlases break tiling and bleed across mips, and Texture2DArray demands same-size-same-format slices sampled through one shared sampler.
  • Texture streaming becomes trivial. Slots are dynamic: registering and unregistering is a cheap descriptor write (update-after-bind), safe while frames are in flight. Load what the camera needs, evict what it doesn't — the index simply points at new content. No atlas repacking, no array rebuilds, no material or draw-call changes; the renderer doesn't even notice.
  • Material data becomes plain structs in a StructuredBuffer with texture indices inside — the foundation modern engines (Nanite, id Tech, Frostbite) are built on.

Unity does not expose bindless resources in any form. This plugin adds them natively: a global descriptor heap inside Unity's own Vulkan device, a C# API to register textures into it, and a shader include to sample them by index.

Supported Platforms

Platform Status Notes
Windows x86_64 ✅ Supported Editor + player, validated (Unity 6000.3, RTX-class and older desktop GPUs)
Android arm64-v8a / armeabi-v7a ✅ Supported Prebuilt .so, 16 KB page aligned. Requires a descriptor-indexing GPU: Qualcomm Adreno, ARM Mali from Valhall on
Linux x86_64 ✅ Supported Prebuilt .so (glibc 2.34+ / Ubuntu 22.04, Unity 6's baseline; static libstdc++). Editor + player

Hardware requirement: descriptor indexing with update-after-bind (VK_EXT_descriptor_indexing / Vulkan 1.2 core) — effectively every desktop GPU of the last decade and most modern mobile GPUs. On anything older the plugin cleanly reports IsSupported == false and stays out of the way.

Limitations

  • GPU coverage: the heap is built on update-after-bind descriptor indexing, which older mobile GPUs (notably pre-Valhall Mali) don't support — there the plugin currently disables itself. A compatibility fallback is planned — see Roadmap.
  • RenderTexture: fully automatic — a native layout tracker flips registered RTs back to a readable layout right after any write (render pass, CopyTexture, blit, clear), wherever the bindless read happens (see Technical Deep Dive). Registration rejects what the heap can't hold, with a reason: MSAA RTs (resolve into a non-MSAA RT first), depth-only RTs, non-2D dimensions. RTs with autoGenerateMips: during mip generation Unity manages per-level layouts itself and the tracker steps aside until the image is re-unified — sampling is guaranteed after that point. Transient RenderGraph textures alias memory and must never be registered — persistent RenderTexture objects only.
  • Texture3D / TextureCube: not supported yet — the heap currently holds 2D sampled textures. See Roadmap.
  • Runtime sampler changes from script (texture.filterMode = ...) don't recreate the native texture and can't be observed by the plugin — call VulkanBindless.Update(slot, texture) after them. Editor re-imports (resize, format, importer sampler settings) are picked up automatically.
  • One editor restart is required when the package is first installed — the Editor loads graphics plugins from packages too late for Vulkan interception (Unity issue), so the package maintains editor-only copies (Windows .dll + Linux .so, each OS-tagged) under Assets/Plugins/VulkanBindless.Editor/ and prompts for that first restart itself. Package updates refresh the copies silently — the already-loaded version keeps working and the new one loads with the next editor restart. Committing the copies covers Windows and Linux teammates from a single commit. Builds are unaffected.
  • Material inspector previews render without your property blocks; if your shader requires a slot buffer, the preview draw skips itself with a console message. Harmless and editor-only.

Installation

Add the package via Unity Package Manager using a git URL:

  1. Open Window > Package Manager
  2. Click + > Add package from git URL...
  3. Enter:
    https://github.com/saivs/com.saivs.plugin.bindless.vulkan.git
    
  4. The package installs its editor support automatically; if the Editor is already running Vulkan it asks for one restart. Done. (On other graphics APIs the plugin stays inert and activates with the restart that switching to Vulkan requires anyway.)

Usage

Registering textures

Any 2D texture — Texture2D or RenderTexture — can be registered into the global heap at any time. Registration returns a slot — a stable integer index that identifies the texture in shaders until it is unregistered:

using Saivs.Graphics.Core.Bindless;

if (!VulkanBindless.IsSupported)
    return; // fall back — see GetDiagnostics() for the reason

uint slot = VulkanBindless.Register(texture);   // texture (or RenderTexture) is now in the heap
// ...
VulkanBindless.Unregister(slot);                // slot is free again

The slot's sampler automatically reproduces the texture's Unity import settings — filter mode, wrap modes, aniso level, mip bias — so a Point/Clamp texture samples as Point/Clamp straight from the heap.

Slots are plain uints: store them in material structs, per-instance GraphicsBuffers, constants — wherever your pipeline keeps material data — and hand them to shaders like any other value:

var slotsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, slots.Length, sizeof(uint));
slotsBuffer.SetData(slots);
propertyBlock.SetBuffer("_TexSlots", slotsBuffer);

Texture lifetime is managed for you: editor re-imports transparently re-register the slot, destroying a registered texture frees it, and a stale slot samples opaque white — never garbage, never a crash.

Fixed slots

Sometimes a slot index wants to be a compile-time constant — a global blue-noise texture, an environment BRDF LUT — so shaders reference it directly instead of reading it from a buffer. Lock a range to keep the auto-allocator out of it, then claim slots inside explicitly:

VulkanBindless.LockRange(0, 16);                     // slots 0–15: manual only
bool ok = VulkanBindless.TryRegister(blueNoise, 7);  // false if 7 is already taken
#define SLOT_BLUE_NOISE 7
half4 noise = BINDLESS_SAMPLE(SLOT_BLUE_NOISE, uv);

TryRegister works anywhere in the heap, but inside a locked range the index stays yours even after the texture is destroyed or unregistered — outside one, a freed slot returns to the automatic pool. Lock ranges early, before bulk registrations: locking doesn't evict slots the auto-allocator has already handed out. UnlockRange(first, count) hands the range back to the allocator.

Sampling in shaders

HLSLPROGRAM
#pragma target 4.5
#pragma vertex vert
#pragma fragment frag

// Also applies '#pragma use_dxc' and the Vulkan-only 'exclude_renderers'
// from the header itself:
#include_with_pragmas "Packages/com.saivs.plugin.bindless.vulkan/Runtime/ShaderLibrary/Bindless.hlsl"

StructuredBuffer<uint> _TexSlots;

half4 frag(Varyings i) : SV_Target
{
    uint slot = _TexSlots[i.instanceID % _TextureCount];
    return BINDLESS_SAMPLE(slot, i.uv);
}
ENDHLSL

Bindless.hlsl macros

Macro Purpose
BINDLESS_SAMPLE(slot, uv) Sample with the texture's own import-settings sampler. Index may vary per pixel/instance
BINDLESS_SAMPLE_LOD(slot, uv, lod) Explicit mip level (float; fractional lod blends under Trilinear). Works in vertex/compute
BINDLESS_TEXTURE2D(slot) Raw Texture2D — any HLSL method on it: Load, GetDimensions, SampleGrad, ...
BINDLESS_SAMPLER(slot) Raw SamplerState of the slot

Roadmap

  • Linux editor validation. The Linux .so is built and the editor bootstrap installs it, but the plugin has so far been validated in the editor on Windows — Linux-editor testing is pending.
  • MSAA / depth RenderTextures. Color RTs are supported (see Technical Deep Dive); MSAA targets need a resolve step and depth targets an aspect-masked view — both on the list.
  • Texture3D / TextureCube tables. Additional heap bindings for other texture dimensions — same slot API, new BINDLESS_TEXTURE3D / BINDLESS_TEXTURECUBE accessors.
  • Compatibility fallback for GPUs without descriptor indexing. The goal is the same C# and shader API everywhere — graceful constraints instead of a hard IsSupported == false. The building blocks exist on virtually all Vulkan hardware: dynamically-indexed fixed-size texture arrays are a core Vulkan 1.0 feature, and descriptors can be updated between frames instead of update-after-bind. The fallback tier would mean a smaller heap (hundreds of slots instead of tens of thousands), registrations applied at frame boundaries, and on the oldest hardware uniform-only indexing (the whole draw samples the same slot from a given expression) — but per-instance texture selection through the regular macros keeps working, and projects need no second code path.
  • More graphics APIs. Bindless is a hardware capability, not a Vulkan feature — and the plugin's architecture (a global heap, shader-table redirection, the Register/slot API) maps onto every modern API:
    • D3D12 — descriptor heaps plus root-signature patching; Meetem's PoC already proved the interception approach on Unity's D3D12 backend.
    • Metal — argument buffers give the same unbounded-table model; the MDI plugin's Metal groundwork (method swizzling around Unity's encoder) carries over.

Technical Deep Dive

Unity exposes neither bindless resources nor the Vulkan internals to build them — every layer of the stack had its own wall, and each one turned out to have a way around it:

The wall The way around
Descriptor-indexing features must be enabled when the VkDevice is created — and Unity creates it The binary is named GfxPlugin* so it loads before graphics init, registers a Vulkan init interception (IUnityGraphicsVulkanV2) and patches vkCreateDevice on the fly
The Editor loads package plugins after the device exists An editor bootstrap maintains an Assets/Plugins copy, where the early-load rule still works
Unity's shader compiler rejects any resource outside binding space 0 — no [[vk::binding]], no register spaces The heap is declared as unbounded arrays in space 0; DXC emits SPIR-V runtime arrays, and a vkCreateShaderModule hook finds them structurally (runtime arrays of images/samplers exist only through Bindless.hlsl) and rewrites their set/binding decorations to the heap's set
Unity's pipeline layouts know nothing about the heap A vkCreatePipelineLayout hook appends the heap's set layout at set 3 — Unity itself uses sets 0–1, and 4 bound sets is the mobile minimum
The set must be bound when Unity records its draws A vkCmdBindPipeline hook re-binds the global set after every pipeline bind
Unity destroys textures under the heap (re-import, unload) A vkDestroyImage hook parks affected slots on a built-in white dummy the moment the image dies, then a C# pump re-registers slots whose Texture object is still alive
Per-texture samplers aren't exposed by Unity's native interface C# marshals the texture's import settings and the plugin recreates the exact VkSampler (deduplicated in a cache), mirrored into a second heap binding slot-for-slot
A RenderTexture's image layout changes with every write, and Unity restores a readable layout only for its own descriptor reads — bindless reads are invisible to it A native layout tracker: hooks on the render-pass and transfer commands flip registered RTs back to a readable home layout right after any write, and every Unity barrier touching a tracked image gets its oldLayout rewritten to the tracker's ground truth, so Unity's stale bookkeeping never reaches the GPU

Texture registration itself runs on the render thread through a plugin event: AccessTexture resolves the VkImage with a proper layout transition, a VkImageView is created and written into the update-after-bind heap — no stalls, safe with frames in flight.

RenderTexture support is entirely this layout tracker — no user-facing API. The home layout is chosen per image to keep framebuffer compression (AFBC/UBWC/DCC) intact: regular RTs live in SHADER_READ_ONLY, which samplers read straight through the compressed representation, while Random Write RTs live in GENERAL — storage images must be there anyway (so compute writes need no tracking at all) and storage usage already precludes compression. When Unity's own materials sample a GENERAL-home RT, its home adapts to SHADER_READ_ONLY and the heap descriptors are rewritten in place (update-after-bind makes that safe with frames in flight). Mip generation is the one case the tracker steps away from: it transitions single mip levels, per-level layouts diverge, and the tracker suspends itself for that image until a whole-image barrier re-unifies it.

Related

Releases

Packages

Contributors

Languages