Compute to Surface
Compute-to-surface lets a compute shader write directly to a swapchain drawable, bypassing the rasterization pipeline entirely. There is no RenderPipeline, no vertex buffers, and no raster pass — just a compute dispatch that fills pixels.
When to use compute-to-surface
Use compute-to-surface when your rendering is naturally a per-pixel computation rather than geometry rasterization:
- Fullscreen image effects (plasma, fractals, ray marching)
- GPU-driven 2D renderers where the compute shader owns the output layout
- Post-processing that doesn't need triangle rasterization
- Prototyping visual effects without setting up a render pipeline
Use traditional rendering when you need the rasterization pipeline's features: triangle assembly, depth testing, MSAA, alpha blending, or vertex/fragment shader stages.
Surface exchange
Create a SurfaceExchange and call bind_destination to register direct compute-to-present in the scheme:
#![allow(unused)] fn main() { let surface = SurfaceExchange::new_with_depth(&ctx, &window, 3, SurfaceConfig::default())?; let (lease, present) = surface.bind_destination(&mut scheme)?; }
Bind the returned lease in a compute node with with_present(&lease). Goldy handles barrier insertion between compute writes and the presentation engine.
On CUDA+DX12, present scratch is a depth-3 imported staging ring (not the DXGI backbuffer). Compute does not reuse frame N's scratch in frame N+1; that extra memory is the interop tradeoff until CUDA/DX12 synchronization APIs improve.
Building the scheme
Record a retained scheme with a compute node that writes to the present lease:
#![allow(unused)] fn main() { let wg_x = width.div_ceil(8); let wg_y = height.div_ceil(8); let mut scheme = Scheme::new(&ctx); let (lease, present) = surface.bind_destination(&mut scheme)?; scheme .node("compute", &compute_pipeline) .with_parcel(&uniform_buffer, NodeAccess::Read) .with_present(&lease) .dispatch(wg_x, wg_y, 1); }
Submitting and presenting
Each frame, submit the scheme and consume the surface claim:
#![allow(unused)] fn main() { let mut submission = scheme.submit()?; present.claim(&mut submission)?.consume()?; }
submit resolves transient resources, compiles the scheme into a command stream, and submits to the GPU. Presentation happens when you call claim(...).consume() — the compute shader has already written the pixels.
The compute shader
The shader receives the output texture as a DirectSpatial<float4> — a read-write 2D texture accessed by integer coordinates:
import goldy_exp;
struct Uniforms {
uint width;
uint height;
float time;
float _padding;
};
[goldy_compute]
[numthreads(8, 8, 1)]
void cs_main(BufRO<Uniforms> uniforms_buf, DirectSpatial<float4> output, ThreadId tid) {
Uniforms u = uniforms_buf[0];
if (tid.x >= u.width || tid.y >= u.height)
return;
float2 uv = float2(float(tid.x) / float(u.width),
float(tid.y) / float(u.height));
// Compute pixel color...
float3 col = my_color_function(uv, u.time);
output[tid.xy] = float4(col, 1.0);
}
The [numthreads(8, 8, 1)] workgroup size maps naturally to 2D image tiles. Dispatch enough workgroups to cover the full resolution:
#![allow(unused)] fn main() { let wg_x = width.div_ceil(8); let wg_y = height.div_ceil(8); }
Guard against out-of-bounds writes in the shader when the resolution isn't a multiple of the workgroup size.
Full example sketch
#![allow(unused)] fn main() { use goldy::{ BufferKind, ComputePipeline, DeviceDescriptor, Instance, MemoryExchange, NodeAccess, PresentMode, RequestAdapterOptions, RetainedPool, Scheme, ShaderModule, SurfaceConfig, SurfaceExchange, }; let instance = Instance::new()?; let device = instance .request_adapter(&RequestAdapterOptions::default())? .request_device(&DeviceDescriptor::default())?; let ctx = device.create_context()?; let surface = SurfaceExchange::new_with_config( &ctx, &window, SurfaceConfig { present_mode: PresentMode::Fifo, depth_format: None, }, )?; let shader = ShaderModule::from_slang(&device, COMPUTE_SHADER)?; let compute_pipeline = ComputePipeline::new(&device, &shader)?; let mut retained_pool = RetainedPool::new(device.clone()); let uniform_buffer = retained_pool.acquire_buffer_with_data( &[Uniforms { width, height, time: 0.0, _padding: 0.0 }], BufferKind::Scattered, )?; let mut scheme = Scheme::new(&ctx); let (lease, present) = surface.bind_destination(&mut scheme)?; scheme .node("compute", &compute_pipeline) .with_parcel(&uniform_buffer, NodeAccess::Read) .with_present(&lease) .dispatch(width.div_ceil(8), height.div_ceil(8), 1); // --- Render loop --- let mut upload = Scheme::new(&ctx); let uniform_deposit = MemoryExchange::new(&ctx).bind_deposit_buffer( &mut upload, &uniform_buffer, std::mem::size_of::<Uniforms>() as u64, )?; uniform_deposit.write( &mut upload, 0, bytemuck::bytes_of(&Uniforms { width, height, time: elapsed, _padding: 0.0 }), )?; upload.submit()?; let mut submission = scheme.submit()?; present.claim(&mut submission)?.consume()?; }
See examples/compute_to_surface.rs for the complete winit application.