Skip to content

Commit 89f4dca

Browse files
committed
Add headless rendering feature for GPU testing without a display
1 parent 5f7623e commit 89f4dca

File tree

5 files changed

+606
-358
lines changed

5 files changed

+606
-358
lines changed

Cargo.lock

Lines changed: 28 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ edition = "2021"
99
default = ["use-compiled-tools"]
1010
use-installed-tools = ["spirv-builder/use-installed-tools"]
1111
use-compiled-tools = ["spirv-builder/use-compiled-tools"]
12+
headless = ["png"]
1213

1314
[dependencies]
1415
shared = { path = "shared" }
@@ -24,6 +25,7 @@ winit = "0.30.12"
2425
bytemuck = "1.20.0"
2526
env_logger = "0.11.6"
2627
ouroboros = "0.18.5"
28+
png = { version = "0.17", optional = true }
2729

2830
[build-dependencies]
2931
spirv-builder.workspace = true

src/headless.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use futures::executor::block_on;
2+
use shared::ShaderConstants;
3+
use std::error::Error;
4+
use wgpu::{self, InstanceDescriptor};
5+
use wgpu::{include_spirv, include_spirv_raw};
6+
7+
pub fn run() -> Result<(), Box<dyn Error>> {
8+
let width = 1280u32;
9+
let height = 720u32;
10+
11+
let instance = wgpu::Instance::new(&InstanceDescriptor::default());
12+
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
13+
power_preference: wgpu::PowerPreference::HighPerformance,
14+
compatible_surface: None,
15+
force_fallback_adapter: false,
16+
}))?;
17+
18+
let mut required_features = wgpu::Features::PUSH_CONSTANTS;
19+
if adapter
20+
.features()
21+
.contains(wgpu::Features::SPIRV_SHADER_PASSTHROUGH)
22+
{
23+
required_features |= wgpu::Features::SPIRV_SHADER_PASSTHROUGH;
24+
}
25+
26+
let required_limits = wgpu::Limits {
27+
max_push_constant_size: std::mem::size_of::<ShaderConstants>() as u32,
28+
..Default::default()
29+
};
30+
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
31+
label: None,
32+
required_features,
33+
required_limits,
34+
..Default::default()
35+
}))?;
36+
37+
let shader_module = if device
38+
.features()
39+
.contains(wgpu::Features::SPIRV_SHADER_PASSTHROUGH)
40+
{
41+
let x = include_spirv_raw!(env!("shadertoys_shaders.spv"));
42+
unsafe { device.create_shader_module_passthrough(x) }
43+
} else {
44+
device.create_shader_module(include_spirv!(env!("shadertoys_shaders.spv")))
45+
};
46+
47+
let texture_format = wgpu::TextureFormat::Rgba8UnormSrgb;
48+
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
49+
label: None,
50+
bind_group_layouts: &[],
51+
push_constant_ranges: &[wgpu::PushConstantRange {
52+
stages: wgpu::ShaderStages::VERTEX_FRAGMENT,
53+
range: 0..std::mem::size_of::<ShaderConstants>() as u32,
54+
}],
55+
});
56+
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
57+
label: None,
58+
layout: Some(&pipeline_layout),
59+
vertex: wgpu::VertexState {
60+
module: &shader_module,
61+
entry_point: Some("main_vs"),
62+
buffers: &[],
63+
compilation_options: Default::default(),
64+
},
65+
fragment: Some(wgpu::FragmentState {
66+
module: &shader_module,
67+
entry_point: Some("main_fs"),
68+
targets: &[Some(wgpu::ColorTargetState {
69+
format: texture_format,
70+
blend: Some(wgpu::BlendState::REPLACE),
71+
write_mask: wgpu::ColorWrites::ALL,
72+
})],
73+
compilation_options: Default::default(),
74+
}),
75+
primitive: wgpu::PrimitiveState {
76+
topology: wgpu::PrimitiveTopology::TriangleList,
77+
..Default::default()
78+
},
79+
depth_stencil: None,
80+
multisample: wgpu::MultisampleState::default(),
81+
multiview: None,
82+
cache: None,
83+
});
84+
85+
let texture = device.create_texture(&wgpu::TextureDescriptor {
86+
label: Some("headless render target"),
87+
size: wgpu::Extent3d {
88+
width,
89+
height,
90+
depth_or_array_layers: 1,
91+
},
92+
mip_level_count: 1,
93+
sample_count: 1,
94+
dimension: wgpu::TextureDimension::D2,
95+
format: texture_format,
96+
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
97+
view_formats: &[],
98+
});
99+
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
100+
101+
let bytes_per_pixel = 4u32;
102+
let unpadded_bytes_per_row = width * bytes_per_pixel;
103+
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
104+
let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
105+
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
106+
label: Some("headless output buffer"),
107+
size: (padded_bytes_per_row * height) as u64,
108+
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
109+
mapped_at_creation: false,
110+
});
111+
112+
let push_constants = ShaderConstants {
113+
width,
114+
height,
115+
time: 0.0,
116+
cursor_x: 0.0,
117+
cursor_y: 0.0,
118+
drag_start_x: 0.0,
119+
drag_start_y: 0.0,
120+
drag_end_x: 0.0,
121+
drag_end_y: 0.0,
122+
mouse_left_pressed: 0,
123+
mouse_left_clicked: 0,
124+
};
125+
126+
let mut encoder =
127+
device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
128+
{
129+
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
130+
label: None,
131+
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
132+
view: &view,
133+
resolve_target: None,
134+
ops: wgpu::Operations {
135+
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
136+
store: wgpu::StoreOp::Store,
137+
},
138+
})],
139+
depth_stencil_attachment: None,
140+
timestamp_writes: None,
141+
occlusion_query_set: None,
142+
});
143+
rpass.set_viewport(0.0, 0.0, width as f32, height as f32, 0.0, 1.0);
144+
rpass.set_pipeline(&render_pipeline);
145+
rpass.set_push_constants(
146+
wgpu::ShaderStages::VERTEX_FRAGMENT,
147+
0,
148+
bytemuck::bytes_of(&push_constants),
149+
);
150+
rpass.draw(0..3, 0..1);
151+
}
152+
encoder.copy_texture_to_buffer(
153+
wgpu::TexelCopyTextureInfo {
154+
texture: &texture,
155+
mip_level: 0,
156+
origin: wgpu::Origin3d::ZERO,
157+
aspect: wgpu::TextureAspect::All,
158+
},
159+
wgpu::TexelCopyBufferInfo {
160+
buffer: &output_buffer,
161+
layout: wgpu::TexelCopyBufferLayout {
162+
offset: 0,
163+
bytes_per_row: Some(padded_bytes_per_row),
164+
rows_per_image: Some(height),
165+
},
166+
},
167+
wgpu::Extent3d {
168+
width,
169+
height,
170+
depth_or_array_layers: 1,
171+
},
172+
);
173+
queue.submit(Some(encoder.finish()));
174+
175+
let buffer_slice = output_buffer.slice(..);
176+
let (tx, rx) = std::sync::mpsc::channel();
177+
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
178+
tx.send(result).unwrap();
179+
});
180+
device.poll(wgpu::PollType::Wait)?;
181+
rx.recv()??;
182+
183+
let data = buffer_slice.get_mapped_range();
184+
let output_path = "output.png";
185+
let file = std::fs::File::create(output_path)?;
186+
let w = &mut std::io::BufWriter::new(file);
187+
let mut encoder = png::Encoder::new(w, width, height);
188+
encoder.set_color(png::ColorType::Rgba);
189+
encoder.set_depth(png::BitDepth::Eight);
190+
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
191+
let mut writer = encoder.write_header()?;
192+
// Remove row padding
193+
let mut unpadded = Vec::with_capacity((unpadded_bytes_per_row * height) as usize);
194+
for row in 0..height as usize {
195+
let start = row * padded_bytes_per_row as usize;
196+
let end = start + unpadded_bytes_per_row as usize;
197+
unpadded.extend_from_slice(&data[start..end]);
198+
}
199+
writer.write_image_data(&unpadded)?;
200+
drop(data);
201+
output_buffer.unmap();
202+
203+
eprintln!("Rendered frame to {output_path}");
204+
Ok(())
205+
}

0 commit comments

Comments
 (0)