1use std::collections::HashMap;
2
3use bytemuck::{Pod, Zeroable};
4use spatialrust_gpu::WgpuRuntime;
5use spatialrust_viz::{
6 Camera, ColorMap, DeviceIdentity, LinearRgba, PointColor, Projection, TransferDirection,
7 TransferEvent, TransferReceipt, VisualResidency, VisualStyle,
8};
9use wgpu::util::DeviceExt;
10
11use crate::{GpuGeometry, GpuGeometryKind, RenderError, RenderResult, WgpuRenderer};
12
13pub const RENDER_TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
15const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
16
17#[derive(Clone, Debug, PartialEq)]
19pub struct RenderOptions {
20 pub width: u32,
22 pub height: u32,
24 pub camera: Camera,
26 pub style: VisualStyle,
28 pub clear_color: LinearRgba,
30}
31
32impl RenderOptions {
33 pub fn try_new(
35 width: u32,
36 height: u32,
37 camera: Camera,
38 style: VisualStyle,
39 clear_color: LinearRgba,
40 ) -> RenderResult<Self> {
41 if width == 0 || height == 0 {
42 return Err(RenderError::GeometrySize(
43 "render target dimensions must be non-zero".into(),
44 ));
45 }
46 Ok(Self { width, height, camera, style, clear_color })
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct RenderReceipt {
53 pub adapter: DeviceIdentity,
55 pub stages: Vec<&'static str>,
57 pub draw_calls: u32,
59 pub element_count: u32,
61}
62
63pub struct GpuRenderTarget {
65 pub(crate) renderer_id: u64,
66 pub(crate) color: wgpu::Texture,
67 pub(crate) depth: wgpu::Texture,
68 point_ids: Option<wgpu::Texture>,
69 width: u32,
70 height: u32,
71 residency: VisualResidency,
72}
73
74#[derive(Debug)]
76pub struct ReadbackImage {
77 pub width: u32,
79 pub height: u32,
81 pub rgba: Vec<u8>,
83 pub transfers: TransferReceipt,
85}
86
87#[derive(Debug)]
89pub struct PickResult {
90 pub point_index: Option<u32>,
92 pub transfers: TransferReceipt,
94}
95
96impl GpuRenderTarget {
97 #[must_use]
99 pub const fn width(&self) -> u32 {
100 self.width
101 }
102
103 #[must_use]
105 pub const fn height(&self) -> u32 {
106 self.height
107 }
108
109 #[must_use]
111 pub const fn residency(&self) -> &VisualResidency {
112 &self.residency
113 }
114
115 #[must_use]
117 pub fn is_owned_by(&self, renderer: &WgpuRenderer) -> bool {
118 self.renderer_id == renderer.id
119 }
120}
121
122pub struct HeadlessRender {
124 pub target: GpuRenderTarget,
126 pub transfers: TransferReceipt,
128 pub receipt: RenderReceipt,
130}
131
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133enum ColorMode {
134 Uniform,
135 Rgb,
136 Scalar,
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140struct PipelineKey {
141 kind: GpuGeometryKind,
142 color: ColorMode,
143}
144
145#[derive(Default)]
146pub(crate) struct RenderPipelines {
147 bind_group_layout: Option<wgpu::BindGroupLayout>,
148 pipelines: HashMap<PipelineKey, wgpu::RenderPipeline>,
149 point_id_pipeline: Option<wgpu::RenderPipeline>,
150}
151
152#[repr(C)]
153#[derive(Clone, Copy, Debug, Pod, Zeroable)]
154struct RenderUniform {
155 view_projection: [[f32; 4]; 4],
156 color: [f32; 4],
157 scalar_range: [f32; 2],
158 viewport: [f32; 2],
159 point_size: f32,
160 color_map: u32,
161 _padding: [u32; 2],
162}
163
164impl WgpuRenderer {
165 pub fn render_headless(
170 &self,
171 geometry: &GpuGeometry,
172 options: &RenderOptions,
173 ) -> RenderResult<HeadlessRender> {
174 if geometry.renderer_id != self.id {
175 return Err(RenderError::RuntimeMismatch(
176 "geometry was uploaded by another renderer".into(),
177 ));
178 }
179 let max_dimension = self.runtime.device().limits().max_texture_dimension_2d;
180 if options.width > max_dimension || options.height > max_dimension {
181 return Err(RenderError::GeometrySize(format!(
182 "render target {}x{} exceeds adapter limit {max_dimension}",
183 options.width, options.height
184 )));
185 }
186 let (color_mode, uniform_color, scalar_range, point_size, color_map) =
187 resolve_style(geometry, &options.style)?;
188 let key = PipelineKey { kind: geometry.kind, color: color_mode };
189 let view_projection =
190 view_projection(options.camera, options.width as f32 / options.height as f32);
191 let uniform = RenderUniform {
192 view_projection,
193 color: [
194 uniform_color.red,
195 uniform_color.green,
196 uniform_color.blue,
197 uniform_color.alpha,
198 ],
199 scalar_range,
200 viewport: [options.width as f32, options.height as f32],
201 point_size,
202 color_map,
203 _padding: [0; 2],
204 };
205 let uniform_bytes = bytemuck::bytes_of(&uniform);
206 let uniform_buffer =
207 self.runtime.device().create_buffer_init(&wgpu::util::BufferInitDescriptor {
208 label: Some("spatialrust render uniform"),
209 contents: uniform_bytes,
210 usage: wgpu::BufferUsages::UNIFORM,
211 });
212 let mut pipelines = self.render_pipelines.lock().expect("render pipeline cache poisoned");
213 let RenderPipelines { bind_group_layout, pipelines: pipeline_map, point_id_pipeline } =
214 &mut *pipelines;
215 if bind_group_layout.is_none() {
216 *bind_group_layout = Some(self.runtime.device().create_bind_group_layout(
217 &wgpu::BindGroupLayoutDescriptor {
218 label: Some("spatialrust render uniform layout"),
219 entries: &[wgpu::BindGroupLayoutEntry {
220 binding: 0,
221 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
222 ty: wgpu::BindingType::Buffer {
223 ty: wgpu::BufferBindingType::Uniform,
224 has_dynamic_offset: false,
225 min_binding_size: None,
226 },
227 count: None,
228 }],
229 },
230 ));
231 }
232 let bind_group_layout = bind_group_layout.as_ref().expect("bind group layout was inserted");
233 let bind_group = self.runtime.device().create_bind_group(&wgpu::BindGroupDescriptor {
234 label: Some("spatialrust render uniform bind group"),
235 layout: bind_group_layout,
236 entries: &[wgpu::BindGroupEntry {
237 binding: 0,
238 resource: uniform_buffer.as_entire_binding(),
239 }],
240 });
241
242 let target = create_target(
243 self.runtime.device(),
244 self.id,
245 options.width,
246 options.height,
247 self.device_identity().clone(),
248 geometry.kind == GpuGeometryKind::Points,
249 );
250 let color_view = target.color.create_view(&wgpu::TextureViewDescriptor::default());
251 let depth_view = target.depth.create_view(&wgpu::TextureViewDescriptor::default());
252 let pipeline = pipeline_map
253 .entry(key)
254 .or_insert_with(|| create_pipeline(self.runtime.device(), bind_group_layout, key));
255 if geometry.kind == GpuGeometryKind::Points && point_id_pipeline.is_none() {
256 *point_id_pipeline =
257 Some(create_point_id_pipeline(self.runtime.device(), bind_group_layout));
258 }
259
260 let mut encoder =
261 self.runtime.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
262 label: Some("spatialrust headless render encoder"),
263 });
264 {
265 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
266 label: Some("spatialrust headless render pass"),
267 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
268 view: &color_view,
269 resolve_target: None,
270 ops: wgpu::Operations {
271 load: wgpu::LoadOp::Clear(wgpu::Color {
272 r: options.clear_color.red as f64,
273 g: options.clear_color.green as f64,
274 b: options.clear_color.blue as f64,
275 a: options.clear_color.alpha as f64,
276 }),
277 store: wgpu::StoreOp::Store,
278 },
279 })],
280 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
281 view: &depth_view,
282 depth_ops: Some(wgpu::Operations {
283 load: wgpu::LoadOp::Clear(1.0),
284 store: wgpu::StoreOp::Store,
285 }),
286 stencil_ops: None,
287 }),
288 timestamp_writes: None,
289 occlusion_query_set: None,
290 });
291 pass.set_pipeline(pipeline);
292 pass.set_bind_group(0, &bind_group, &[]);
293 pass.set_vertex_buffer(
294 0,
295 geometry
296 .positions
297 .as_ref()
298 .expect("uploaded geometry always owns positions")
299 .buffer
300 .slice(..),
301 );
302 match color_mode {
303 ColorMode::Uniform => {}
304 ColorMode::Rgb => pass.set_vertex_buffer(
305 1,
306 geometry.rgb.as_ref().expect("RGB style was validated").buffer.slice(..),
307 ),
308 ColorMode::Scalar => pass.set_vertex_buffer(
309 1,
310 geometry.scalar.as_ref().expect("scalar style was validated").buffer.slice(..),
311 ),
312 }
313 match geometry.kind {
314 GpuGeometryKind::Points => pass.draw(0..6, 0..geometry.vertex_count),
315 GpuGeometryKind::Lines => pass.draw(0..geometry.vertex_count, 0..1),
316 GpuGeometryKind::Triangles => {
317 let indices =
318 geometry.indices.as_ref().expect("triangle geometry owns indices");
319 pass.set_index_buffer(indices.buffer.slice(..), wgpu::IndexFormat::Uint32);
320 pass.draw_indexed(0..geometry.index_count, 0, 0..1);
321 }
322 }
323 }
324 if geometry.kind == GpuGeometryKind::Points {
325 let id_view = target
326 .point_ids
327 .as_ref()
328 .expect("point target owns an ID texture")
329 .create_view(&wgpu::TextureViewDescriptor::default());
330 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
331 label: Some("spatialrust point ID render pass"),
332 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
333 view: &id_view,
334 resolve_target: None,
335 ops: wgpu::Operations {
336 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
337 store: wgpu::StoreOp::Store,
338 },
339 })],
340 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
341 view: &depth_view,
342 depth_ops: Some(wgpu::Operations {
343 load: wgpu::LoadOp::Load,
344 store: wgpu::StoreOp::Store,
345 }),
346 stencil_ops: None,
347 }),
348 timestamp_writes: None,
349 occlusion_query_set: None,
350 });
351 pass.set_pipeline(
352 point_id_pipeline.as_ref().expect("point ID pipeline was initialized"),
353 );
354 pass.set_bind_group(0, &bind_group, &[]);
355 pass.set_vertex_buffer(
356 0,
357 geometry
358 .positions
359 .as_ref()
360 .expect("uploaded geometry always owns positions")
361 .buffer
362 .slice(..),
363 );
364 pass.draw(0..6, 0..geometry.vertex_count);
365 }
366 self.runtime.queue().submit(Some(encoder.finish()));
367 drop(pipelines);
368
369 let mut transfers = TransferReceipt::new();
370 transfers.push(
371 TransferEvent::try_new(
372 "render-uniform-upload",
373 TransferDirection::Upload,
374 VisualResidency::Host,
375 VisualResidency::Device(self.device_identity().clone()),
376 uniform_bytes.len() as u64,
377 )
378 .map_err(|error| RenderError::Transfer(error.to_string()))?,
379 );
380 let stage = match geometry.kind {
381 GpuGeometryKind::Points => "draw-points",
382 GpuGeometryKind::Lines => "draw-lines",
383 GpuGeometryKind::Triangles => "draw-triangles",
384 };
385 let element_count = match geometry.kind {
386 GpuGeometryKind::Triangles => geometry.index_count,
387 _ => geometry.vertex_count,
388 };
389 Ok(HeadlessRender {
390 target,
391 transfers,
392 receipt: RenderReceipt {
393 adapter: self.device_identity().clone(),
394 stages: if geometry.kind == GpuGeometryKind::Points {
395 vec!["clear-color-depth", stage, "draw-point-ids", "submit"]
396 } else {
397 vec!["clear-color-depth", stage, "submit"]
398 },
399 draw_calls: if geometry.kind == GpuGeometryKind::Points { 2 } else { 1 },
400 element_count,
401 },
402 })
403 }
404
405 #[must_use]
407 pub fn initialized_render_pipeline_count(&self) -> usize {
408 let pipelines = self.render_pipelines.lock().expect("render pipeline cache poisoned");
409 pipelines.pipelines.len() + usize::from(pipelines.point_id_pipeline.is_some())
410 }
411
412 pub fn readback_rgba(&self, target: &GpuRenderTarget) -> RenderResult<ReadbackImage> {
414 self.validate_target(target)?;
415 let unpadded_row_bytes = target
416 .width
417 .checked_mul(4)
418 .ok_or_else(|| RenderError::Readback("RGBA row byte count overflowed".into()))?;
419 let padded_row_bytes = align_up(unpadded_row_bytes, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)?;
420 let buffer_bytes = u64::from(padded_row_bytes)
421 .checked_mul(u64::from(target.height))
422 .ok_or_else(|| RenderError::Readback("padded readback byte count overflowed".into()))?;
423 let buffer = self.runtime.device().create_buffer(&wgpu::BufferDescriptor {
424 label: Some("spatialrust RGBA readback"),
425 size: buffer_bytes,
426 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
427 mapped_at_creation: false,
428 });
429 let mut encoder =
430 self.runtime.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
431 label: Some("spatialrust RGBA readback encoder"),
432 });
433 encoder.copy_texture_to_buffer(
434 wgpu::TexelCopyTextureInfo {
435 texture: &target.color,
436 mip_level: 0,
437 origin: wgpu::Origin3d::ZERO,
438 aspect: wgpu::TextureAspect::All,
439 },
440 wgpu::TexelCopyBufferInfo {
441 buffer: &buffer,
442 layout: wgpu::TexelCopyBufferLayout {
443 offset: 0,
444 bytes_per_row: Some(padded_row_bytes),
445 rows_per_image: Some(target.height),
446 },
447 },
448 wgpu::Extent3d { width: target.width, height: target.height, depth_or_array_layers: 1 },
449 );
450 self.runtime.queue().submit(Some(encoder.finish()));
451 let mapped = map_buffer(&self.runtime, &buffer)?;
452 let logical_bytes = usize::try_from(unpadded_row_bytes)
453 .ok()
454 .and_then(|row| row.checked_mul(target.height as usize))
455 .ok_or_else(|| RenderError::Readback("logical RGBA byte count overflowed".into()))?;
456 let mut rgba = Vec::with_capacity(logical_bytes);
457 for row in mapped.chunks_exact(padded_row_bytes as usize).take(target.height as usize) {
458 rgba.extend_from_slice(&row[..unpadded_row_bytes as usize]);
459 }
460 drop(mapped);
461 buffer.unmap();
462 let mut transfers = TransferReceipt::new();
463 transfers.push(self.readback_event("rgba-readback", logical_bytes as u64)?);
464 Ok(ReadbackImage { width: target.width, height: target.height, rgba, transfers })
465 }
466
467 pub fn pick_point(&self, target: &GpuRenderTarget, x: u32, y: u32) -> RenderResult<PickResult> {
469 self.validate_target(target)?;
470 if x >= target.width || y >= target.height {
471 return Err(RenderError::Readback("pick coordinates are outside the target".into()));
472 }
473 let texture = target.point_ids.as_ref().ok_or_else(|| {
474 RenderError::Readback("point picking requires a target rendered from points".into())
475 })?;
476 let buffer = self.runtime.device().create_buffer(&wgpu::BufferDescriptor {
477 label: Some("spatialrust point ID readback"),
478 size: 4,
479 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
480 mapped_at_creation: false,
481 });
482 let mut encoder =
483 self.runtime.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
484 label: Some("spatialrust point ID readback encoder"),
485 });
486 encoder.copy_texture_to_buffer(
487 wgpu::TexelCopyTextureInfo {
488 texture,
489 mip_level: 0,
490 origin: wgpu::Origin3d { x, y, z: 0 },
491 aspect: wgpu::TextureAspect::All,
492 },
493 wgpu::TexelCopyBufferInfo {
494 buffer: &buffer,
495 layout: wgpu::TexelCopyBufferLayout {
496 offset: 0,
497 bytes_per_row: None,
498 rows_per_image: None,
499 },
500 },
501 wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
502 );
503 self.runtime.queue().submit(Some(encoder.finish()));
504 let mapped = map_buffer(&self.runtime, &buffer)?;
505 let encoded = u32::from_le_bytes(mapped[..4].try_into().expect("mapped ID is four bytes"));
506 drop(mapped);
507 buffer.unmap();
508 let mut transfers = TransferReceipt::new();
509 transfers.push(self.readback_event("point-id-readback", 4)?);
510 Ok(PickResult { point_index: encoded.checked_sub(1), transfers })
511 }
512
513 fn validate_target(&self, target: &GpuRenderTarget) -> RenderResult<()> {
514 if target.renderer_id != self.id {
515 return Err(RenderError::RuntimeMismatch(
516 "render target belongs to another renderer".into(),
517 ));
518 }
519 Ok(())
520 }
521
522 fn readback_event(&self, stage: &str, bytes: u64) -> RenderResult<TransferEvent> {
523 TransferEvent::try_new(
524 stage,
525 TransferDirection::Readback,
526 VisualResidency::Device(self.device_identity().clone()),
527 VisualResidency::Host,
528 bytes,
529 )
530 .map_err(|error| RenderError::Transfer(error.to_string()))
531 }
532}
533
534fn resolve_style(
535 geometry: &GpuGeometry,
536 style: &VisualStyle,
537) -> RenderResult<(ColorMode, LinearRgba, [f32; 2], f32, u32)> {
538 match (geometry.kind, style) {
539 (GpuGeometryKind::Points, VisualStyle::Points(point_style)) => match point_style.color {
540 PointColor::Uniform(color) => {
541 Ok((ColorMode::Uniform, color, [0.0, 1.0], point_style.size, 0))
542 }
543 PointColor::Rgb if geometry.rgb.is_some() => {
544 Ok((ColorMode::Rgb, LinearRgba::WHITE, [0.0, 1.0], point_style.size, 0))
545 }
546 PointColor::Scalar { min, max, map } if geometry.scalar.is_some() => Ok((
547 ColorMode::Scalar,
548 LinearRgba::WHITE,
549 [min, max],
550 point_style.size,
551 color_map_id(map),
552 )),
553 PointColor::Rgb => {
554 Err(RenderError::GeometrySize("RGB style requires an uploaded RGB buffer".into()))
555 }
556 PointColor::Scalar { .. } => Err(RenderError::GeometrySize(
557 "scalar style requires an uploaded scalar buffer".into(),
558 )),
559 },
560 (GpuGeometryKind::Points, VisualStyle::Uniform(color)) => {
561 Ok((ColorMode::Uniform, *color, [0.0, 1.0], 1.0, 0))
562 }
563 (_, VisualStyle::Uniform(color)) => Ok((ColorMode::Uniform, *color, [0.0, 1.0], 1.0, 0)),
564 (_, VisualStyle::Points(_)) => Err(RenderError::GeometrySize(
565 "point style cannot be applied to line or triangle geometry".into(),
566 )),
567 }
568}
569
570const fn color_map_id(map: ColorMap) -> u32 {
571 match map {
572 ColorMap::Viridis => 0,
573 ColorMap::Turbo => 1,
574 ColorMap::Gray => 2,
575 }
576}
577
578fn create_target(
579 device: &wgpu::Device,
580 renderer_id: u64,
581 width: u32,
582 height: u32,
583 identity: DeviceIdentity,
584 point_picking: bool,
585) -> GpuRenderTarget {
586 let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
587 let color = device.create_texture(&wgpu::TextureDescriptor {
588 label: Some("spatialrust headless color"),
589 size,
590 mip_level_count: 1,
591 sample_count: 1,
592 dimension: wgpu::TextureDimension::D2,
593 format: RENDER_TARGET_FORMAT,
594 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
595 view_formats: &[],
596 });
597 let depth = device.create_texture(&wgpu::TextureDescriptor {
598 label: Some("spatialrust headless depth"),
599 size,
600 mip_level_count: 1,
601 sample_count: 1,
602 dimension: wgpu::TextureDimension::D2,
603 format: DEPTH_FORMAT,
604 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
605 view_formats: &[],
606 });
607 let point_ids = point_picking.then(|| {
608 device.create_texture(&wgpu::TextureDescriptor {
609 label: Some("spatialrust point IDs"),
610 size,
611 mip_level_count: 1,
612 sample_count: 1,
613 dimension: wgpu::TextureDimension::D2,
614 format: wgpu::TextureFormat::R32Uint,
615 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
616 view_formats: &[],
617 })
618 });
619 GpuRenderTarget {
620 renderer_id,
621 color,
622 depth,
623 point_ids,
624 width,
625 height,
626 residency: VisualResidency::Device(identity),
627 }
628}
629
630fn create_point_id_pipeline(
631 device: &wgpu::Device,
632 bind_group_layout: &wgpu::BindGroupLayout,
633) -> wgpu::RenderPipeline {
634 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
635 label: Some("spatialrust point ID shader"),
636 source: wgpu::ShaderSource::Wgsl(POINT_ID_SHADER.into()),
637 });
638 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
639 label: Some("spatialrust point ID pipeline layout"),
640 bind_group_layouts: &[bind_group_layout],
641 push_constant_ranges: &[],
642 });
643 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
644 label: Some("spatialrust point ID pipeline"),
645 layout: Some(&layout),
646 vertex: wgpu::VertexState {
647 module: &shader,
648 entry_point: Some("vs_main"),
649 compilation_options: wgpu::PipelineCompilationOptions::default(),
650 buffers: &[wgpu::VertexBufferLayout {
651 array_stride: 12,
652 step_mode: wgpu::VertexStepMode::Instance,
653 attributes: &[wgpu::VertexAttribute {
654 format: wgpu::VertexFormat::Float32x3,
655 offset: 0,
656 shader_location: 0,
657 }],
658 }],
659 },
660 primitive: wgpu::PrimitiveState::default(),
661 depth_stencil: Some(wgpu::DepthStencilState {
662 format: DEPTH_FORMAT,
663 depth_write_enabled: false,
664 depth_compare: wgpu::CompareFunction::LessEqual,
665 stencil: wgpu::StencilState::default(),
666 bias: wgpu::DepthBiasState::default(),
667 }),
668 multisample: wgpu::MultisampleState::default(),
669 fragment: Some(wgpu::FragmentState {
670 module: &shader,
671 entry_point: Some("fs_main"),
672 compilation_options: wgpu::PipelineCompilationOptions::default(),
673 targets: &[Some(wgpu::ColorTargetState {
674 format: wgpu::TextureFormat::R32Uint,
675 blend: None,
676 write_mask: wgpu::ColorWrites::ALL,
677 })],
678 }),
679 multiview: None,
680 cache: None,
681 })
682}
683
684fn map_buffer<'a>(
685 runtime: &WgpuRuntime,
686 buffer: &'a wgpu::Buffer,
687) -> RenderResult<wgpu::BufferView<'a>> {
688 let slice = buffer.slice(..);
689 let (sender, receiver) = std::sync::mpsc::channel();
690 slice.map_async(wgpu::MapMode::Read, move |result| {
691 let _ = sender.send(result);
692 });
693 runtime.device().poll(wgpu::Maintain::Wait);
694 receiver
695 .recv()
696 .map_err(|error| RenderError::Readback(format!("map callback dropped: {error}")))?
697 .map_err(|error| RenderError::Readback(format!("buffer map: {error}")))?;
698 Ok(slice.get_mapped_range())
699}
700
701fn align_up(value: u32, alignment: u32) -> RenderResult<u32> {
702 let remainder = value % alignment;
703 if remainder == 0 {
704 return Ok(value);
705 }
706 value
707 .checked_add(alignment - remainder)
708 .ok_or_else(|| RenderError::Readback("row alignment overflowed".into()))
709}
710
711fn create_pipeline(
712 device: &wgpu::Device,
713 bind_group_layout: &wgpu::BindGroupLayout,
714 key: PipelineKey,
715) -> wgpu::RenderPipeline {
716 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
717 label: Some("spatialrust headless shader"),
718 source: wgpu::ShaderSource::Wgsl(shader_source(key).into()),
719 });
720 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
721 label: Some("spatialrust headless pipeline layout"),
722 bind_group_layouts: &[bind_group_layout],
723 push_constant_ranges: &[],
724 });
725 let position_step = if key.kind == GpuGeometryKind::Points {
726 wgpu::VertexStepMode::Instance
727 } else {
728 wgpu::VertexStepMode::Vertex
729 };
730 let position = wgpu::VertexBufferLayout {
731 array_stride: 12,
732 step_mode: position_step,
733 attributes: &[wgpu::VertexAttribute {
734 format: wgpu::VertexFormat::Float32x3,
735 offset: 0,
736 shader_location: 0,
737 }],
738 };
739 let attribute = match key.color {
740 ColorMode::Uniform => None,
741 ColorMode::Rgb => Some(wgpu::VertexBufferLayout {
742 array_stride: 4,
743 step_mode: position_step,
744 attributes: &[wgpu::VertexAttribute {
745 format: wgpu::VertexFormat::Unorm8x4,
746 offset: 0,
747 shader_location: 1,
748 }],
749 }),
750 ColorMode::Scalar => Some(wgpu::VertexBufferLayout {
751 array_stride: 4,
752 step_mode: position_step,
753 attributes: &[wgpu::VertexAttribute {
754 format: wgpu::VertexFormat::Float32,
755 offset: 0,
756 shader_location: 1,
757 }],
758 }),
759 };
760 let mut buffers = vec![position];
761 if let Some(attribute) = attribute {
762 buffers.push(attribute);
763 }
764 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
765 label: Some("spatialrust headless render pipeline"),
766 layout: Some(&layout),
767 vertex: wgpu::VertexState {
768 module: &shader,
769 entry_point: Some("vs_main"),
770 compilation_options: wgpu::PipelineCompilationOptions::default(),
771 buffers: &buffers,
772 },
773 primitive: wgpu::PrimitiveState {
774 topology: match key.kind {
775 GpuGeometryKind::Points => wgpu::PrimitiveTopology::TriangleList,
776 GpuGeometryKind::Lines => wgpu::PrimitiveTopology::LineList,
777 GpuGeometryKind::Triangles => wgpu::PrimitiveTopology::TriangleList,
778 },
779 ..Default::default()
780 },
781 depth_stencil: Some(wgpu::DepthStencilState {
782 format: DEPTH_FORMAT,
783 depth_write_enabled: true,
784 depth_compare: wgpu::CompareFunction::Less,
785 stencil: wgpu::StencilState::default(),
786 bias: wgpu::DepthBiasState::default(),
787 }),
788 multisample: wgpu::MultisampleState::default(),
789 fragment: Some(wgpu::FragmentState {
790 module: &shader,
791 entry_point: Some("fs_main"),
792 compilation_options: wgpu::PipelineCompilationOptions::default(),
793 targets: &[Some(wgpu::ColorTargetState {
794 format: RENDER_TARGET_FORMAT,
795 blend: Some(wgpu::BlendState::ALPHA_BLENDING),
796 write_mask: wgpu::ColorWrites::ALL,
797 })],
798 }),
799 multiview: None,
800 cache: None,
801 })
802}
803
804fn shader_source(key: PipelineKey) -> &'static str {
805 match (key.kind, key.color) {
806 (GpuGeometryKind::Points, ColorMode::Uniform) => POINT_UNIFORM_SHADER,
807 (GpuGeometryKind::Points, ColorMode::Rgb) => POINT_RGB_SHADER,
808 (GpuGeometryKind::Points, ColorMode::Scalar) => POINT_SCALAR_SHADER,
809 (_, ColorMode::Uniform) => PRIMITIVE_UNIFORM_SHADER,
810 (_, _) => unreachable!("non-point styles are validated as uniform"),
811 }
812}
813
814fn view_projection(camera: Camera, aspect: f32) -> [[f32; 4]; 4] {
815 multiply4(projection_matrix(camera.projection, aspect), view_matrix(camera))
816}
817
818fn view_matrix(camera: Camera) -> [[f32; 4]; 4] {
819 let forward = (camera.target - camera.eye).normalize();
820 let side = forward.cross(camera.up).normalize();
821 let up = side.cross(forward);
822 [
823 [side.x, up.x, -forward.x, 0.0],
824 [side.y, up.y, -forward.y, 0.0],
825 [side.z, up.z, -forward.z, 0.0],
826 [-side.dot(camera.eye), -up.dot(camera.eye), forward.dot(camera.eye), 1.0],
827 ]
828}
829
830fn projection_matrix(projection: Projection, aspect: f32) -> [[f32; 4]; 4] {
831 match projection {
832 Projection::Perspective { vertical_fov_radians, near, far } => {
833 let f = 1.0 / (vertical_fov_radians * 0.5).tan();
834 [
835 [f / aspect, 0.0, 0.0, 0.0],
836 [0.0, f, 0.0, 0.0],
837 [0.0, 0.0, far / (near - far), -1.0],
838 [0.0, 0.0, far * near / (near - far), 0.0],
839 ]
840 }
841 Projection::Orthographic { vertical_span, near, far } => {
842 let horizontal_span = vertical_span * aspect;
843 [
844 [2.0 / horizontal_span, 0.0, 0.0, 0.0],
845 [0.0, 2.0 / vertical_span, 0.0, 0.0],
846 [0.0, 0.0, 1.0 / (near - far), 0.0],
847 [0.0, 0.0, near / (near - far), 1.0],
848 ]
849 }
850 }
851}
852
853fn multiply4(left: [[f32; 4]; 4], right: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
854 let mut output = [[0.0; 4]; 4];
855 for column in 0..4 {
856 for row in 0..4 {
857 output[column][row] = (0..4).map(|index| left[index][row] * right[column][index]).sum();
858 }
859 }
860 output
861}
862
863const POINT_ID_SHADER: &str = r#"
864struct Uniforms {
865 view_projection: mat4x4<f32>, color: vec4<f32>, scalar_range: vec2<f32>,
866 viewport: vec2<f32>, point_size: f32, color_map: u32, padding: vec2<u32>,
867};
868@group(0) @binding(0) var<uniform> uniforms: Uniforms;
869struct VertexOutput {
870 @builtin(position) position: vec4<f32>,
871 @location(0) @interpolate(flat) encoded_id: u32,
872};
873const OFFSETS = array<vec2<f32>, 6>(
874 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
875 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0)
876);
877@vertex fn vs_main(
878 @builtin(vertex_index) corner: u32,
879 @builtin(instance_index) point_id: u32,
880 @location(0) center: vec3<f32>
881) -> VertexOutput {
882 var output: VertexOutput;
883 output.position = uniforms.view_projection * vec4<f32>(center, 1.0);
884 let offset = OFFSETS[corner] * uniforms.point_size / uniforms.viewport * 2.0 * output.position.w;
885 output.position = vec4<f32>(output.position.xy + offset, output.position.zw);
886 output.encoded_id = point_id + 1u;
887 return output;
888}
889@fragment fn fs_main(input: VertexOutput) -> @location(0) u32 {
890 return input.encoded_id;
891}
892"#;
893
894const POINT_UNIFORM_SHADER: &str = r#"
895struct Uniforms {
896 view_projection: mat4x4<f32>,
897 color: vec4<f32>,
898 scalar_range: vec2<f32>,
899 viewport: vec2<f32>,
900 point_size: f32,
901 color_map: u32,
902 padding: vec2<u32>,
903};
904@group(0) @binding(0) var<uniform> uniforms: Uniforms;
905struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32> };
906const OFFSETS = array<vec2<f32>, 6>(
907 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
908 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0)
909);
910@vertex fn vs_main(@builtin(vertex_index) corner: u32, @location(0) center: vec3<f32>) -> VertexOutput {
911 var output: VertexOutput;
912 output.position = uniforms.view_projection * vec4<f32>(center, 1.0);
913 let offset = OFFSETS[corner] * uniforms.point_size / uniforms.viewport * 2.0 * output.position.w;
914 output.position = vec4<f32>(output.position.xy + offset, output.position.zw);
915 output.color = uniforms.color;
916 return output;
917}
918@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
919"#;
920
921const POINT_RGB_SHADER: &str = r#"
922struct Uniforms {
923 view_projection: mat4x4<f32>, color: vec4<f32>, scalar_range: vec2<f32>,
924 viewport: vec2<f32>, point_size: f32, color_map: u32, padding: vec2<u32>,
925};
926@group(0) @binding(0) var<uniform> uniforms: Uniforms;
927struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32> };
928const OFFSETS = array<vec2<f32>, 6>(
929 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
930 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0)
931);
932@vertex fn vs_main(
933 @builtin(vertex_index) corner: u32,
934 @location(0) center: vec3<f32>,
935 @location(1) color: vec4<f32>
936) -> VertexOutput {
937 var output: VertexOutput;
938 output.position = uniforms.view_projection * vec4<f32>(center, 1.0);
939 let offset = OFFSETS[corner] * uniforms.point_size / uniforms.viewport * 2.0 * output.position.w;
940 output.position = vec4<f32>(output.position.xy + offset, output.position.zw);
941 output.color = color;
942 return output;
943}
944@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
945"#;
946
947const POINT_SCALAR_SHADER: &str = r#"
948struct Uniforms {
949 view_projection: mat4x4<f32>, color: vec4<f32>, scalar_range: vec2<f32>,
950 viewport: vec2<f32>, point_size: f32, color_map: u32, padding: vec2<u32>,
951};
952@group(0) @binding(0) var<uniform> uniforms: Uniforms;
953struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32> };
954const OFFSETS = array<vec2<f32>, 6>(
955 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
956 vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0)
957);
958fn color_map(t_in: f32, map: u32) -> vec3<f32> {
959 let t = clamp(t_in, 0.0, 1.0);
960 if map == 2u { return vec3<f32>(t); }
961 if map == 1u {
962 return clamp(vec3<f32>(
963 1.5 - abs(4.0 * t - 3.0),
964 1.5 - abs(4.0 * t - 2.0),
965 1.5 - abs(4.0 * t - 1.0)
966 ), vec3<f32>(0.0), vec3<f32>(1.0));
967 }
968 return vec3<f32>(
969 0.267 + t * (0.993 - 0.267),
970 0.005 + t * (0.906 - 0.005),
971 0.329 + t * (0.144 - 0.329)
972 );
973}
974@vertex fn vs_main(
975 @builtin(vertex_index) corner: u32,
976 @location(0) center: vec3<f32>,
977 @location(1) scalar: f32
978) -> VertexOutput {
979 var output: VertexOutput;
980 output.position = uniforms.view_projection * vec4<f32>(center, 1.0);
981 let offset = OFFSETS[corner] * uniforms.point_size / uniforms.viewport * 2.0 * output.position.w;
982 output.position = vec4<f32>(output.position.xy + offset, output.position.zw);
983 let t = (scalar - uniforms.scalar_range.x) /
984 (uniforms.scalar_range.y - uniforms.scalar_range.x);
985 output.color = vec4<f32>(color_map(t, uniforms.color_map), 1.0);
986 return output;
987}
988@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
989"#;
990
991const PRIMITIVE_UNIFORM_SHADER: &str = r#"
992struct Uniforms {
993 view_projection: mat4x4<f32>, color: vec4<f32>, scalar_range: vec2<f32>,
994 viewport: vec2<f32>, point_size: f32, color_map: u32, padding: vec2<u32>,
995};
996@group(0) @binding(0) var<uniform> uniforms: Uniforms;
997struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32> };
998@vertex fn vs_main(@location(0) position: vec3<f32>) -> VertexOutput {
999 var output: VertexOutput;
1000 output.position = uniforms.view_projection * vec4<f32>(position, 1.0);
1001 output.color = uniforms.color;
1002 return output;
1003}
1004@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
1005"#;
1006
1007#[cfg(test)]
1008mod tests {
1009 use std::sync::Arc;
1010
1011 use spatialrust_gpu::WgpuRuntime;
1012 use spatialrust_math::Vec3;
1013 use spatialrust_viz::{
1014 Camera, ColorMap, LineListView, LinearRgba, PointCloudView, PointColor, PointStyle,
1015 PositionColumns3, Projection, Rgb8Columns, ScalarColumn, TriangleMeshView, VisualPrimitive,
1016 VisualStyle,
1017 };
1018
1019 use super::{RenderError, RenderOptions, WgpuRenderer};
1020
1021 fn camera() -> Camera {
1022 Camera::try_new(
1023 Vec3::new(0.0, 0.0, 3.0),
1024 Vec3::new(0.0, 0.0, 0.0),
1025 Vec3::new(0.0, 1.0, 0.0),
1026 Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 10.0 },
1027 )
1028 .unwrap()
1029 }
1030
1031 #[test]
1032 fn rejects_zero_target_size() {
1033 assert!(RenderOptions::try_new(
1034 0,
1035 64,
1036 camera(),
1037 VisualStyle::Uniform(LinearRgba::WHITE),
1038 LinearRgba::BLACK,
1039 )
1040 .is_err());
1041 }
1042
1043 #[test]
1044 fn renders_all_topologies_and_point_color_modes() {
1045 let Ok(runtime) = WgpuRuntime::new_headless() else {
1046 eprintln!("skipping headless render test: no adapter");
1047 return;
1048 };
1049 let runtime = Arc::new(runtime);
1050 let renderer = WgpuRenderer::new(Arc::clone(&runtime));
1051 let other_renderer = WgpuRenderer::new(runtime);
1052 let positions = PositionColumns3::try_new(&[0.0, 0.5], &[0.0, 0.0], &[0.0, 0.0]).unwrap();
1053 let rgb = Rgb8Columns::try_new(&[255, 0], &[0, 255], &[0, 0], 2).unwrap();
1054 let scalar = ScalarColumn::try_new("intensity", &[0.0, 1.0], 2).unwrap();
1055 let points = PointCloudView::positions_only(positions)
1056 .with_rgb(rgb)
1057 .unwrap()
1058 .with_scalar(scalar)
1059 .unwrap();
1060 let (gpu_points, _) = renderer.upload(VisualPrimitive::Points(points)).unwrap();
1061
1062 let styles = [
1063 VisualStyle::Points(
1064 PointStyle::try_new(
1065 9.0,
1066 PointColor::Uniform(LinearRgba::try_new(1.0, 0.0, 0.0, 1.0).unwrap()),
1067 )
1068 .unwrap(),
1069 ),
1070 VisualStyle::Points(PointStyle::try_new(5.0, PointColor::Rgb).unwrap()),
1071 VisualStyle::Points(
1072 PointStyle::try_new(
1073 5.0,
1074 PointColor::Scalar { min: 0.0, max: 1.0, map: ColorMap::Viridis },
1075 )
1076 .unwrap(),
1077 ),
1078 ];
1079 for (style_index, style) in styles.into_iter().enumerate() {
1080 let options =
1081 RenderOptions::try_new(64, 64, camera(), style, LinearRgba::BLACK).unwrap();
1082 let output = renderer.render_headless(&gpu_points, &options).unwrap();
1083 assert_eq!(output.target.width(), 64);
1084 assert_eq!(
1085 output.receipt.stages,
1086 ["clear-color-depth", "draw-points", "draw-point-ids", "submit"]
1087 );
1088 assert_eq!(output.transfers.events()[0].stage, "render-uniform-upload");
1089 assert_eq!(output.transfers.total_bytes().unwrap(), 112);
1090 if style_index == 0 {
1091 let image = renderer.readback_rgba(&output.target).unwrap();
1092 assert_eq!(image.rgba.len(), 64 * 64 * 4);
1093 let center = (32 * 64 + 32) * 4;
1094 assert_eq!(&image.rgba[center..center + 4], &[255, 0, 0, 255]);
1095 assert_eq!(&image.rgba[..4], &[0, 0, 0, 255]);
1096 assert_eq!(image.transfers.total_bytes().unwrap(), 64 * 64 * 4);
1097 let pick = renderer.pick_point(&output.target, 32, 32).unwrap();
1098 assert_eq!(pick.point_index, Some(0));
1099 assert_eq!(pick.transfers.total_bytes().unwrap(), 4);
1100 assert!(other_renderer.readback_rgba(&output.target).is_err());
1101 assert_eq!(
1102 renderer.pick_point(&output.target, 64, 32).unwrap_err(),
1103 RenderError::Readback("pick coordinates are outside the target".into())
1104 );
1105 }
1106 }
1107
1108 let padded_options = RenderOptions::try_new(
1109 17,
1110 19,
1111 camera(),
1112 VisualStyle::Points(
1113 PointStyle::try_new(
1114 7.0,
1115 PointColor::Uniform(LinearRgba::try_new(1.0, 0.0, 0.0, 1.0).unwrap()),
1116 )
1117 .unwrap(),
1118 ),
1119 LinearRgba::BLACK,
1120 )
1121 .unwrap();
1122 let padded_output = renderer.render_headless(&gpu_points, &padded_options).unwrap();
1123 let padded_image = renderer.readback_rgba(&padded_output.target).unwrap();
1124 assert_eq!(padded_image.rgba.len(), 17 * 19 * 4);
1125 assert!(renderer.pick_point(&padded_output.target, 8, 9).unwrap().point_index.is_some());
1126
1127 let lines = LineListView::try_new(&[-0.5, 0.0, 0.0, 0.5, 0.0, 0.0]).unwrap();
1128 let (gpu_lines, _) = renderer.upload(VisualPrimitive::Lines(lines)).unwrap();
1129 let uniform = VisualStyle::Uniform(LinearRgba::WHITE);
1130 let options =
1131 RenderOptions::try_new(64, 64, camera(), uniform.clone(), LinearRgba::BLACK).unwrap();
1132 let line_output = renderer.render_headless(&gpu_lines, &options).unwrap();
1133 assert_eq!(line_output.receipt.stages[1], "draw-lines");
1134 assert!(renderer.pick_point(&line_output.target, 32, 32).is_err());
1135
1136 let mesh = TriangleMeshView::try_new(
1137 &[-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0],
1138 &[0, 1, 2],
1139 )
1140 .unwrap();
1141 let (gpu_mesh, _) = renderer.upload(VisualPrimitive::Triangles(mesh)).unwrap();
1142 let options = RenderOptions::try_new(64, 64, camera(), uniform, LinearRgba::BLACK).unwrap();
1143 assert_eq!(
1144 renderer.render_headless(&gpu_mesh, &options).unwrap().receipt.stages[1],
1145 "draw-triangles"
1146 );
1147 renderer.runtime.wait_idle();
1148 assert_eq!(renderer.initialized_render_pipeline_count(), 6);
1149 }
1150}