Repository navigation
RFC: entry point redesign ideation #449
Description
Activity
I have lots of ideas here and a 1/2 written blog post and spec. I think modeling it as a fn is wrong in general.
Reacted by Firestar99I personally really like explicit arguments that are "normal Rust" rather than magical intrinsics-like functions. So current situation with arguments is fine, deriving something on a struct is fine too as long as that struct is then an explicit argument of the shader entrypoint.
I did raise a question about const-like things in Discord. It'd be nice to be able to use things like
const SUBGROUP_SIZE: u32in const generics to size various data structures like arrays. It is workable today as is through DCE, but I'd be interested in maybe seeing a proper integration of those things into Rust type system.Reacted by brendan c, Timur Tugushev and Lauro OyenI think in regards to descriptor sets/bindings, the issue is more on being able to consistently use the same sets over and over again.
In slang, you can declare binds globally, and then by importing the module you have access to it. That has some plus and minuses.
My current approach that has me very happy, but involves my own custom macro, is as follows:
// Hardcoded descriptor mappings: (marker, qualifier, descriptor_set, binding) // Add or modify entries here to expose new parameter marker attributes. const DESCRIPTORS: &[(&str, &str, u32, u32)] = &[ ("framebuffer_indo", "storage_buffer", 0, 0), ("camera", "storage_buffer", 0, 1), ("mesh_info", "storage_buffer", 0, 2), ("instance_data", "storage_buffer", 0, 3), ("material", "storage_buffer", 1, 0), ("instance_material", "storage_buffer", 1, 1), ]; use proc_macro::TokenStream; use proc_macro2::{Delimiter, Group, TokenTree}; use quote::{TokenStreamExt, format_ident, quote}; use spirv_std_types::spirv_attr_version::spirv_attr_with_version; #[proc_macro_attribute] pub fn scene_descriptors(_attr: TokenStream, item: TokenStream) -> TokenStream { let spirv_ident = format_ident!("{}", &spirv_attr_with_version()); // prepend with #[rust_gpu::spirv(..)] // let attr: proc_macro2::TokenStream = attr.into(); let mut tokens: proc_macro2::TokenStream = proc_macro2::TokenStream::new(); let item: proc_macro2::TokenStream = item.into(); for tt in item { match tt { TokenTree::Group(group) if group.delimiter() == Delimiter::Parenthesis => { let mut group_tokens = proc_macro2::TokenStream::new(); let mut last_token_hashtag = false; for tt in group.stream() { let is_token_hashtag = matches!(&tt, TokenTree::Punct(punct) if punct.as_char() == '#'); // If this is an attribute group (#[...]) whose marker matches one of our // hardcoded descriptors, replace it with the full spirv descriptor attribute. if let TokenTree::Group(bracket_group) = &tt { if bracket_group.delimiter() == Delimiter::Bracket && last_token_hashtag { if let Some(TokenTree::Ident(marker_ident)) = bracket_group.stream().into_iter().next() { let marker = marker_ident.to_string(); if let Some((_, qualifier, set, binding)) = DESCRIPTORS.iter().find(|(name, ..)| *name == marker) { let qualifier_ident = format_ident!("{}", qualifier); group_tokens.extend(quote! { [cfg_attr(target_arch="spirv", rust_gpu::#spirv_ident (#qualifier_ident, descriptor_set = #set, binding = #binding))] }); last_token_hashtag = is_token_hashtag; continue; } } } } group_tokens.append(tt); last_token_hashtag = is_token_hashtag; } let mut out = Group::new(Delimiter::Parenthesis, group_tokens); out.set_span(group.span()); tokens.append(out); } _ => tokens.append(tt), } } tokens.into() }
Then, when writing shader's, it turns out quite clean IMO:
#[scene_descriptors] #[spirv(vertex)] pub fn vertex_color_material_vert( #[camera] cameras: &[Camera], #[instance_data] instance_data: &[InstanceData], #[instance_material] instance_materials: &[VertexColorMaterialData], #[spirv(instance_index)] instance_index: u32, vertex: PositionColor, out_data: &mut VertexOutput, #[spirv(position)] out_pos: &mut Vec4, ) { let my_instance_data = &instance_data[instance_index as usize]; let my_material_data = &instance_materials[instance_index as usize]; let world_pos = my_instance_data.world_matrix * vertex.position; let clip_pos = cameras[0].projection_view * world_pos; *out_pos = clip_pos; out_data.color = my_material_data.color * vertex.color; }
Maybe we can provide helper macros to setup common descriptor sets like this - and even enforce the actual type they check against.
Mine is just a rough implementation cause I'm the only consumer.
- changed the title
[-]Design: entry point redesign ideation[/-][+]RFC: entry point redesign ideation[/+]on Oct 30, 2025 I made a first prototype for querying read-only builtins as functions within
spirv_std: #459I love this idea. Passing things around as descriptor sets defined on structs sounds nice, and might also allow us to auto generate linkage for runtimes like
wgpu.generate linkage for runtimes like
wgpu.That would be really nice! The amount of boilerplate required right now is way too much.
From the description:
Offer derive macros to create bind group layouts / descriptor set layouts / pipeline layouts directly from the structs above for wgpu / vulkan. We'd likely want our own struct representation of descriptor sets and pipeline layouts to derive to, which may be used by the application as metadata. We'd likely want a generic & const-generic variant for compile time checked code like below (from my project): ...
It would be trivial to emit said metadata, since we already have a derive (or attr) macro on the struct. It would just need to implement a few traits:
impl PipelineLayoutTrait for MyPipelineLayout { const LAYOUT: PipelineLayout<'static> = PipelineLayout { descriptor_sets: &[ DescriptorSet { set: 0, bindings: <BindGroupForSystemX as DescriptorSet>::BINDINGS, }, DescriptorSet { set: 1, bindings: <BindGroupForSystemY as DescriptorSet>::BINDINGS, }, ]; push_constant: None, } } impl DescriptorSetTrait for BindGroupForSystemX { const BINDINGS: &[Binding] = &[ Binding { binding: 0, type: BindingType::Buffer, }, Binding { binding: 1, type: BindingType::Image, }, Binding { binding: 2, type: BindingType::Sampler, }, ]; }
Given these traits in spirv-std:
pub trait PipelineLayoutTrait { const LAYOUT: PipelineLayout<'static>; } pub trait DescriptorSetTrait { const BINDINGS: &'static [Binding]; } pub struct PipelineLayout<'a> { pub descriptor_sets: &'a [DescriptorSet], pub push_constant: Option<PushConstant>, } pub struct DescriptorSet<'a> { pub set: u32, pub bindings: &'a [Binding], } pub struct Binding { pub binding: u32, pub type: BindingType, } pub enum BindingType { Buffer, StorageImage, SampledImage, Sampler, }
And then offer some conversion functions directly to wgpu or ash types, hidden behind some CPU-only feature.
Reacted by Schell Carl ScivallyI like the struct approach, but one immediate thing that comes to mind is that it needs to support generics.
I have pipeline layouts that are near identical except for one-two types depending on material that are re-used everywhere.
e.g.:
#[spirv(descriptor_set)] struct SceneBindGroup { #[binding(0)] data: TypedBuffer<MyData>, #[binding(1)] base_color: Image2D, #[binding(2)] sampler: Sampler, } #[spirv(descriptor_set)] struct MaterialBindGroup<MaterialData> { #[binding(0)] data: TypedBuffer<MaterialData>, } #[spirv(pipeline_layout)] struct SceneLayout<MaterialData> { #[descriptor_set(0)] scene_group: SceneBindGroup, #[descriptor_set(1)] material_group: MaterialBindGroup<MaterialData>, #[push_constant] push: MyPushConstant, } struct MyMaterialData { } #[spirv(vertex)] fn my_fragment(pipeline: MyPipelineLayout<MyMatrialData>) {...} #[spirv(fragment)] fn my_fragment(pipeline: MyPipelineLayout<MyMaterialData>) {...}
pub struct PipelineLayout<'a> { pub descriptor_sets: &'a [DescriptorSet], pub push_constant: Option<PushConstant>, }
If the
PushConstantstruct here correlates to one you'd actually use in the shader and this would be generated asash::vk::PipelineLayoutCreateInfoCPU side then my main concern is how you'd correlate that to the push constant range.In a bindless system it seems people generally prefer doing oversized ranges (128/256 byte) with all shader stages, while for bindful it generally seems to be the smallest size with the lowest amount of shader stages (which there'd be no way to provide the info for here). But, sometimes people also seem to have multiple push constant structs at different offsets for the different entry points (which neither proposal would support really).
I think for that aspect it'd probably be best to have the user provide the push constant layout manually when generating the vulkan structs CPU side, so it's the most flexible for all those use cases.
I could see two different approaches to modelling push constants.
- Force users to use the one type to define the push constants for all shader stages. That struct can then contain other structs for each individual shader. It would limit you to assigning push constants to all shader stages and always start at offset 0, like the ash code below:
PushConstantRange { offset: 0, size: size_of::<MyPushConstant>() as u32, stage_flags: ALL, }
- We could allow some kind of attribute that allows specifying an offset.
I think the main question is: How do you model that a vertex and fragment shader are compatible, possibly at compile time? This must include the
PipelineLayout(which containsPushConstant) but also the input & output interface between the shaders. And how to deal with values passing through the rasterizer, with integers needing to be#[spirv(flat)]. That's still completely absent from this design.I feel like we should focus on a simpler design for just compute shaders first, and then worry about graphics in a second pass. Allowing us to ignore Input, Output and interpolation params.
Reacted by FacelessTigerAn approach I've been considering and I think might just work is to have:
- All variables must have a spirv attibute
- This means
locationneeds one as well, but perhaps we can have anautolike#[spirv(location =auto(1)]
- This means
- All spirv attributes on structs and members are recursively expanded
- Start from the basics, and add abstractions around these as needed (PipelineLayouts, Frag Location -> Vertex Location mapping)
I'm of the opinion of the base principles are correct, we can stack up compile time checks and other things as we go.
As I've been implementing and iterating a lot of render shaders (not many compute yet), I've been envisioning something like this:
Example code
struct Camera { view_matrix: Mat4, projection_matrix: Mat4, } struct AmbientLight {} struct DirectionalLight {} trait MaterialData{} struct BaisicMaterialData { albedo_color: Vec4, roughness: f32, metallic: f32, } impl MaterialData for BasicMaterialData {} trait Vertex {} struct BasicVertex { position: Vec4, normal: Vec4, tangent: Vec4, tex_coords: Vec2, } impl Vertex for BasicVertex {} struct InstanceData { world_matrix: Mat4, material_index: u32, mesh_index: u32, } struct Material<M: MaterialData> { #[spirv(descriptor_set = 3, binding = 0)] instance_data: &[InstanceData], #[spirv(descriptor_set = 3, binding = 1)] material_data: &[M], } impl <M: MaterialData> Material<M> { fn get_instance_data(&self, index: u32) -> &InstanceData { &self.instance_data[index as usize] } fn get_material_data(&self, index: u32) -> &M { &self.material_data[index as usize] } } struct Scene { #[spirv(descriptor_set = 2, binding = 0)] camera: &Camera, } struct Lights { #[spirv(descriptor_set = 1, binding = 0)] ambient_lights: &[AmbientLight], #[spirv(descriptor_set = 1, binding = 1)] directional_lights: &[DirectionalLight], } struct Globals<V: Vertex> { #[spirv(descriptor_set = 0, binding = 0)] textures: &RuntimeArray< Image!(2D, type=f32, sampled), >, #[spirv(descriptor_set = 0, binding = 0)] meshes: &RuntimeArray< &[V] >, } impl <V: Vertex> Globals<V> { fn get_mesh(&self, index: u32) -> &[V] { &self.meshes[index as usize] } } struct SceneMaterialPipeline<V: Vertex, M: MaterialData> { globals: &Globals<V>, scene: &Scene, lights: &Lights, material: &Material<M>, } struct VertexOutput1 { position: Vec4, normal: Vec4, tangent: Vec4, tex_coords: Vec2, #[spirv(flat)] flat: VertexOutputFlat, } struct VertexOutputFlat { data: u32, } struct VertexOuptut2 { #[spirv(location = 1, auto)] extra_data: Vec4, } #[spirv(location = 2, auto)] struct VertexOutput3 { more_data: Vec4, } #[spirv(vertex)] pub fn monomorphized_vertex_main( pipeline: &SceneMaterialPipeline<BasicVertex, BasicMaterialData>, #[spirv(vertex_index)] vertex_index: u32, #[spirv(instance_index)] instance_index: u32, #[spirv(location=0, auto)] vout1: &mut VertexOutput1, vout2: &mut VertexOuptut2, vout3: &mut VertexOutput3, // ... ) -> VertexOutput { let instance = pipeline.material.get_instance_data(instance_index); let mesh = pipeline.globals.get_mesh(instance.mesh_index); // ... } #[spirv(fragment)] pub fn monomorphized_fragment_main( pipeline: &SceneMaterialPipeline<BasicVertex, BasicMaterialData>, #[spirv(location=0)] vin1: &VertexOutput1, vin2: &VertexOuptut2, vin3: &VertexOutput3, // ... ) { // ... // ... }
I think one of the biggest usability issues is that unlike other dedicated shader languages, we don't have globals for bindings, which is fine because of the model itself - but how can we make it all easier to use?
I think that lies in being able to attach a bunch of the spirv attributes to a data structure type we can pass around everywhere, break apart and compose how we need to.
- All variables must have a spirv attibute
- @marstaik From what I've gathered, you want to be able to compose N layers of systems, not just 2 layers (descriptor_set and binding) like my first suggestion. The main problem I see in your design is two systems declaring the same descriptor set and bindings for something, but with different values (eg. Buffer and Image). These systems would no longer be composable due to the conflict, even worse, if you got those graphics libs from crates.io since you can't "easily" change them.
Which is why my design allows systems to only assign bindings, not descriptor sets. So that the entry point (or PipelineLayout), the final consumer of the systems, can assign each system a unique descriptor set. And do so at it's declaration, not spread out over the codebase.
-
Generics: At codegen level, all the generics have been monomorphization away and we only see concrete types. So consider generics to be effectively free to support.
-
Rasterization: I'd like to focus on descriptor sets and bindings first, and worry about the graphics pipeline's capabilities later. Such as Input and Output declarations with locations and interpolation. Apart from
#[flat]in structs missing, I think they can stay as they are for quite a while longer, until we've got the resources to address them. Especially since I fixed location assignment recently, you can now pass as many structs as inputs & outputs as you want (once this is merged). -
(Also could you wrap your code examples in this to make them expandable? thx)
<details><summary>Example code</summary> code here </details>Context
With some macro
abusemagic I managed to get something similar towgsl-syntax working (I only converted simple shaders without generics).
The macros generate a new function, which is the realspirventry point, with everything expanded to be valid forrust-gpu. Additionally definitions forwgpuare generated.My use case are many vertex and fragment shaders, where I want to reuse a definition for
Vertex,Camera, etc.wgslrustnote uniform struct struct vertex struct struct bind group struct contains multiple #[binding = ...]bind set function arguments single #[bind_group = ...]entry point function pipeline macro combine the used bind groups from multiple functions Bind groups
#[derive(Copy, Clone, Pod, Zeroable)] #[repr(C)] pub struct Settings { pub speed: f32, pub time: f32, pub color_scale: f32, } #[derive(rust_to_wgpu::Arguments)] #[rust_to_wgpu(bind_group)] pub struct SettingsUniform<'a> { #[rust_to_wgpu(binding = 0, uniform)] settings: &'a Settings, } #[derive(rust_to_wgpu::Arguments)] #[rust_to_wgpu(bind_group)] pub struct Texture<'a> { #[rust_to_wgpu(binding = 0)] pub image: &'a Image!(2D, type=f32, sampled), #[rust_to_wgpu(binding = 1)] pub sampler: &'a Sampler, } fn function_stub( #[rust_to_wgpu(bind_group = 0)] settings: SettingsUniform<'a>, #[rust_to_wgpu(bind_group = 1)] texture: Texture<'a>, ) { ... }
Vertex Buffer
#[derive(Debug, Copy, Clone, Pod, Zeroable, rust_to_wgpu::Arguments)] #[repr(C)] #[rust_to_wgpu(attributes, step_mode = vertex)] pub struct Vertex { #[rust_to_wgpu(location = 0)] pub position: Vec2, #[rust_to_wgpu(location = 1)] pub uv: Vec2, } fn function_stub( #[rust_to_wgpu(arguments)] vertex: Vertex, ) { ... }
Full example
#![cfg_attr(not(feature = "native"), no_std)] #![allow(unused_imports)] use bytemuck::{Pod, Zeroable}; use glam::{Vec2, Vec3, Vec4}; // #[cfg(target_arch = "spirv")] // use spirv_std::num_traits::Float; use spirv_std::{Image, Sampler}; #[derive(Copy, Clone, Pod, Zeroable)] #[repr(C)] pub struct Settings { pub speed: f32, pub time: f32, pub color_scale: f32, } #[derive(rust_to_wgpu::Arguments)] #[rust_to_wgpu(bind_group)] pub struct SettingsUniform<'a> { #[rust_to_wgpu(binding = 0, uniform)] settings: &'a Settings, } #[derive(Debug, Copy, Clone, Pod, Zeroable, rust_to_wgpu::Arguments)] #[repr(C)] #[rust_to_wgpu(attributes, step_mode = vertex)] pub struct Vertex { #[rust_to_wgpu(location = 0)] pub position: Vec2, #[rust_to_wgpu(location = 1)] pub uv: Vec2, } #[derive(Debug, Copy, Clone, Pod, Zeroable, rust_to_wgpu::Arguments)] #[repr(C)] #[rust_to_wgpu(attributes, step_mode = instance)] pub struct Instance { #[rust_to_wgpu(location = 2)] pub color: Vec3, #[rust_to_wgpu(location = 3)] pub offset: f32, } #[derive(Debug, rust_to_wgpu::Arguments)] pub struct VertexOutput { #[rust_to_wgpu(output = "position", input = "frag_coord")] vtx_pos: Vec4, #[rust_to_wgpu(location = 0)] vtx_color: Vec3, #[rust_to_wgpu(location = 1)] uv: Vec2, } #[rust_to_wgpu::entry] #[spirv(vertex)] pub fn main_vs<'a>( #[rust_to_wgpu(bind_group = 0)] settings: SettingsUniform<'a>, #[rust_to_wgpu(arguments)] vertex: Vertex, #[rust_to_wgpu(arguments)] instance: Instance, ) -> VertexOutput { let angle = settings.settings.time * settings.settings.speed + instance.offset; let position = glam::Mat2::from_angle(angle).mul_vec2(vertex.position); VertexOutput { vtx_pos: Vec4::from((position, 0.0, 1.0)), vtx_color: instance.color, uv: vertex.uv, } } #[derive(rust_to_wgpu::Arguments)] #[rust_to_wgpu(bind_group)] pub struct Texture<'a> { #[rust_to_wgpu(binding = 0)] pub image: &'a Image!(2D, type=f32, sampled), #[rust_to_wgpu(binding = 1)] pub sampler: &'a Sampler, } #[rust_to_wgpu::entry] #[spirv(fragment)] pub fn main_fs<'a>( #[rust_to_wgpu(bind_group = 0)] settings: SettingsUniform<'a>, #[rust_to_wgpu(bind_group = 1)] texture: Texture<'a>, #[rust_to_wgpu(arguments)] input: VertexOutput, ) -> shaders_dep::FragmentOutput { let color = texture.image.sample(*texture.sampler, input.uv); shaders_dep::FragmentOutput { color: Vec4::from((input.vtx_color * settings.settings.color_scale, 1.0)) * color, } } rust_to_wgpu::bind_groups!(main_vs, main_fs);
The Repository is CodeBerg:rust-to-wgpu 1 and the example with wgpu code CodeBerg:rust-to-wgpu/example 2
My conclusion
- I think some built-in way to reuse definitions is better (macros are possible, but very brittle)
- For me normal return arguments are a must, I don't like
&mut-out-parameters - I did not use a pipeline-struct, because
wgpurequires for every bind group what stage (vertex/fragment/compute) uses it- The pipeline would be passed in full to all stages, no clue if this changes the generated
spirv
- The pipeline would be passed in full to all stages, no clue if this changes the generated
Footnotes
You can also check out some thoughts in https://www.vectorware.com/blog/threads-on-gpu/. I don't think functions are the right abstraction for entrypoints, though I am not saying main w/ threads is the best either.
And while on CPU
main()is a function, it is handled special by the language and compiler. If a function ends up being the right abstraction, I would expect the function itself would be special (like having const generics for dispatch sizes, special apis to get inputs and outputs not in the signutre a la env vars, etc).
Roughly ordered from easiest to hardest to design / implement:
It would be amazing if you could pass them around within this struct (you currently can't), since you could do something like this:
Note how the descriptor_set / bind group id is declared at the entry point, so you could move it to whatever id you'd want and thus avoid conflicts if two systems use the same id? Also I pass it in as a reference, so you can't change eg. the buffer that's in
systemx.datato another. We may want that as a feature, but assigning it non-uniformly makes it potentially problematic.__DynamicResourceWe'd also want to offer a non-generic variant for dynamic use-cases, like within this function. And from there offer conversion functions from there to the various wgpu & vulkan objects, hidden behind features. It's just so annoying having to keep bindings and descriptor set IDs in sync!
Feel free to add suggestions!