Virtual Entry Points

Goldy's virtual entry points let you write shader entry points with clean, typed parameters instead of raw uniform uint slots and SV_* semantics. You annotate your function with [goldy_compute], [goldy_vertex], or [goldy_fragment], and a source-to-source transform generates the real Slang [shader("...")] entry point with all the bindless plumbing wired up.

The Attributes

AttributeStageGenerated Slang Attribute
[goldy_compute]Compute[shader("compute")]
[goldy_vertex]Vertex[shader("vertex")]
[goldy_fragment]Fragment[shader("fragment")]
[goldy_raygen]Ray generation[shader("raygeneration")]
[goldy_miss]Miss[shader("miss")]
[goldy_closesthit]Closest hit[shader("closesthit")]
[goldy_mesh]Mesh[shader("mesh")]
[goldy_amplification]Amplification / task[shader("amplification")]

A minimal example:

import goldy_exp;

[goldy_compute]
[numthreads(64, 1, 1)]
void cs_main(Scattered<uint> data, ThreadId id) {
    data[id.x] = data[id.x] * 2;
}

This is equivalent to manually writing a [shader("compute")] entry point with uniform uint push-constant parameters, descriptor heap lookups, and SV_DispatchThreadID — but without any of that boilerplate.

What Virtual Entry Points Accept

Resource Parameters

Each resource parameter occupies one bindless slot (a 16-bit index packed into push constants). The generated wrapper calls the corresponding goldy_* free function to resolve the slot to a live GPU handle.

Parameter TypeResolves ViaDescription
Scattered<T>goldy_scattered<T>(slot)Read/write storage buffer
BufRO<T>goldy_buf_ro<T>(slot)Read-only storage buffer
Interpolated<T>goldy_interpolated<T>(slot)Sampled 2D texture
DirectSpatial<T>goldy_direct_spatial<T>(slot)Read/write 2D texture
ByteAddressgoldy_byte_address(slot)Raw byte-address buffer
Filtergoldy_filter(slot)Sampler state
Accelgoldy_accel(slot)Top-level acceleration structure (RayQuery / TraceRay, when RuntimeCapabilities::ray_query or ray_tracing_pipelines is set)

Broadcast Parameters

Any user-defined struct type that isn't a recognized resource or system-value type is treated as a broadcast (constant buffer). The generated code calls goldy_broadcast<T>(slot) to fetch the entire struct from a uniform buffer:

struct SimParams { float dt; uint count; };

[goldy_compute]
[numthreads(64, 1, 1)]
void cs_main(SimParams params, Scattered<Particle> data, ThreadId id) {
    // params is fetched from a constant buffer automatically
}

In vertex and fragment shaders, the last unrecognized struct is treated as the stage input (vertex attributes or fragment varyings) rather than a broadcast. All preceding unrecognized structs are broadcasts. Payload structs are shader-owned: define them once (or with matching semantics in each module) and Goldy links producer → consumer structurally. Rust never repeats a Varying type.

System-Value Parameters

System-value wrapper types are mapped to SV_* semantics. The generated entry point declares the raw semantic parameter and constructs the wrapper:

Wrapper TypeMaps ToAvailable Fields
ThreadIdSV_DispatchThreadID.x, .y, .z, .xy, .xyz
GroupThreadIdSV_GroupThreadID.x, .y, .z, .xy, .xyz
GroupIdSV_GroupID.x, .y, .z, .xy, .xyz
VertexIdSV_VertexID.value
InstanceIdSV_InstanceID.value
IsFrontFaceSV_IsFrontFace.value
DispatchRaysIndexSV_DispatchRaysIndex.x, .y, .z (raygen)
DispatchRaysDimensionsSV_DispatchRaysDimensions.x, .y, .z (raygen)

Scalar Parameters

Plain scalar types (uint, float, int, bool, and vector variants) become user parameters — full-precision u32 words in a separate region of the push constants. Bind them with with_param on the compute node builder (after with_parcel calls for resource params):

#![allow(unused)]
fn main() {
scheme
    .node("offset", &pipeline)
    .with_parcel(&data, NodeAccess::ReadWrite)
    .with_param(offset)
    .dispatch(1, 1, 1);
}

Scalar params are the only facts the runtime can bake. A value in a Scattered buffer or a broadcast struct is a parcel read; Goldy cannot see the word, so it cannot specialize on it. A mode flag, loop bound, or feature toggle passed with with_param is already on the CPU. If it holds still across clean submits, retained-scheme prediction recompiles the dispatch with that word as a preprocessor literal — a real Slang + driver compile, not a constant patch — and the driver can delete whatever that constant makes unreachable. Gate expensive work behind those scalars (if (has_tint != 0u) { ... }) rather than behind a load from a constant buffer if that elision is the point. Authors do not opt sites in; putting the fact in the right place is the whole contract. Details, including when this is worth expecting, are in Shader Specialization Prediction.

Pass-Through Parameters

In vertex and fragment shaders, the last unrecognized struct parameter passes through as a stage input (vertex attributes or interpolated varyings). It appears directly in the generated entry point signature without bindless resolution:

[goldy_fragment]
float4 fs_main(MyUniforms cfg, FullscreenVarying input) : SV_Target {
    // cfg → broadcast (slot 0)
    // input → pass-through stage input (interpolated varyings)
    return float4(cfg.time, 0, 0, 1);
}

The Source-to-Source Transform

The transform (implemented in slang/virtual_main.rs) runs before Slang compilation and performs three operations:

  1. Generates a wrapper function with the real [shader("...")] attribute and a fixed 16-word push-constant signature (mesh wrappers also declare out vertices / out indices).
  2. Renames the user function to _goldy_user_<name> (or goldy_mesh / goldy_amplification for those stages) so both can coexist.
  3. Removes the [goldy_*] attribute and [numthreads] / [outputtopology] from the renamed helper (they live on the generated wrapper).

Push Constant Layout

The generated entry point always declares a fixed signature regardless of how many parameters the user function has:

Words  0–7:  _bw0.._bw7   — 16 × u16 bindless indices packed 2 per word
Words  8–15: _uw0.._uw7   — 8 × u32 user scalar parameters

Bindless indices are packed as pairs into 32-bit words: the low 16 bits of _bw0 hold slot 0, the high 16 bits hold slot 1, and so on. This fits up to 16 resource/broadcast parameters and 8 scalar parameters in 64 bytes of push constants.

Before and After

What you write:

[goldy_compute]
[numthreads(64, 1, 1)]
void cs_main(TimeUniforms cfg, Scattered<uint> data, ThreadId id) {
    data[id.x] = data[id.x] + cfg.base;
}

What gets compiled (generated wrapper prepended, user function renamed):

[shader("compute")]
[numthreads(64, 1, 1)]
void cs_main(uniform uint _bw0, ..., uniform uint _bw7,
             uniform uint _uw0, ..., uniform uint _uw7,
             uint3 _sv0 : SV_DispatchThreadID) {
    TimeUniforms cfg = goldy_broadcast<TimeUniforms>(_bw0 & 0xFFFFu);
    Scattered<uint> data = goldy_scattered<uint>((_bw0 >> 16u) & 0xFFFFu);
    ThreadId id = ThreadId(_sv0);
    _goldy_user_cs_main(cfg, data, id);
}

// Original function, renamed:
void _goldy_user_cs_main(TimeUniforms cfg, Scattered<uint> data, ThreadId id) {
    data[id.x] = data[id.x] + cfg.base;
}

The #line 1 directive is inserted between the generated wrapper and the user source so that compiler diagnostics report correct line numbers.

Vertex/Fragment Example

Define the interpolated payload once. Each stage lists only the resources it reads. Goldy links VSOutput to the fragment input by semantic (SV_Position, TEXCOORD0, …), not by struct name, and merges scene into one pipeline slot.

struct VSOutput {
    float4 position : SV_Position;
    float2 uv       : TEXCOORD0;
};

[goldy_vertex]
VSOutput vs_main(SceneUniforms scene, Scattered<Instance> instances, VertexId vid, InstanceId iid) {
    Instance inst = instances[iid.value];
    VSOutput out;
    // ... transform vertex ...
    return out;
}

[goldy_fragment]
float4 fs_main(SceneUniforms scene, Interpolated<float4> albedo, Filter samp,
               VSOutput input) : SV_Target {
    return albedo.Sample(samp, input.uv) * scene.tint;
}

On the CPU, bind by those parameter names:

#![allow(unused)]
fn main() {
pass.with_shader_bindings(&[
    ShaderBinding::read("scene", &scene),
    ShaderBinding::read("instances", &instances),
    ShaderBinding::read("albedo", &albedo),
    ShaderBinding::sampler("samp", &sampler),
]);
pass.set_pipeline(&pipeline);
}

The merged raster contract is fragment-first (scene, albedo, samp) then unique vertex resources (instances). Wrappers remap each stage onto that shared push layout, so the vertex shader still sees scene as its local first parameter while reading pipeline slot 0.

Builtins (VertexId, InstanceId) are invocation-provided; they are not part of the resource contract.

Preprocessor Conditionals

Virtual entry points support #ifdef/#else/#endif blocks directly inside the parameter list. This is useful for shader variants like MSAA:

[goldy_compute]
[numthreads(4, 16, 1)]
void cs_main(BufRO<uint> config,
#ifdef msaa
             BufRO<uint> mask_lut, DirectSpatial<float4> out_tex,
#else
             DirectSpatial<float4> out_tex,
#endif
             ThreadId tid) {
    // ...
}

The transform generates conditional blocks in the wrapper's signature, body, and call arguments so that the correct branch is selected at compile time based on preprocessor defines.