1#![forbid(unsafe_code)]
5
6use atrinik_render_api::{
7 BackendPreference, Error, FrameOutput, RecoveryMetrics, RenderRequest, Renderer,
8 TargetDescriptor, TargetKind, TargetLimits,
9};
10use atrinik_render_resources::{
11 DEFAULT_MAXIMUM_RESOURCE_BYTES, DEFAULT_MAXIMUM_TEXTURE_WORKING_SET_BYTES, ResourceProvider,
12 ResourceRequest, Rgba8Image, add_working_set_bytes, for_each_sprite_pixel, sample_rgba8,
13 shade_rgba8,
14};
15use atrinik_scene::{Digest256, Layer, ResourceId, Sampling, SceneBundle, Sprite};
16use bytemuck::{Pod, Zeroable};
17use std::{
18 borrow::Cow,
19 collections::{HashMap, HashSet},
20 sync::{Arc, mpsc},
21 time::Instant,
22};
23
24const OUTPUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
25const MAXIMUM_CACHED_TEXTURES: usize = 4_096;
26type TextureKey = (ResourceId, u64, Digest256);
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct AdapterDescription {
30 pub name: String,
31 pub driver: String,
32 pub backend: String,
33 pub device_type: String,
34 pub maximum_texture_dimension_2d: u32,
35 pub maximum_buffer_size: u64,
36 pub startup_micros: u64,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct PresentationDescription {
41 pub width: u32,
42 pub height: u32,
43 pub format: String,
44 pub reconfigured: bool,
45}
46
47pub struct WgpuRenderer {
48 target: TargetDescriptor,
49 instance: wgpu::Instance,
50 adapter_handle: wgpu::Adapter,
51 device: wgpu::Device,
52 queue: wgpu::Queue,
53 pipeline: wgpu::RenderPipeline,
54 surface_pipelines: HashMap<wgpu::TextureFormat, wgpu::RenderPipeline>,
55 texture_layout: wgpu::BindGroupLayout,
56 adapter: AdapterDescription,
57 metrics: RecoveryMetrics,
58 offscreen: Option<OffscreenTarget>,
59 surface_depth: Option<SurfaceDepthTarget>,
60 vertices: Option<VertexCache>,
61 textures: HashMap<TextureKey, TextureCacheEntry>,
62 texture_bytes: u64,
63}
64
65struct OffscreenTarget {
66 size: wgpu::Extent3d,
67 color: wgpu::Texture,
68 color_view: wgpu::TextureView,
69 depth_view: wgpu::TextureView,
70 readback: wgpu::Buffer,
71 unpadded_row: u32,
72 padded_row: u32,
73}
74
75struct TextureCacheEntry {
76 image: Arc<Rgba8Image>,
77 nearest: wgpu::BindGroup,
78 linear: wgpu::BindGroup,
79}
80
81struct SurfaceDepthTarget {
82 size: wgpu::Extent3d,
83 view: wgpu::TextureView,
84}
85
86struct VertexCache {
87 buffer: wgpu::Buffer,
88 capacity: u64,
89}
90
91impl WgpuRenderer {
92 pub fn new(target: TargetDescriptor) -> Result<Self, Error> {
97 let startup = Instant::now();
98 let target = target.validate(TargetLimits::default())?;
99 let (instance, adapter_handle, device, queue, adapter) =
100 initialize_gpu(target.backend, startup)?;
101 let texture_layout = create_texture_layout(&device);
102 let pipeline = create_pipeline(&device, &texture_layout, OUTPUT_FORMAT);
103 Ok(Self {
104 target,
105 instance,
106 adapter_handle,
107 device,
108 queue,
109 pipeline,
110 surface_pipelines: HashMap::new(),
111 texture_layout,
112 adapter,
113 metrics: RecoveryMetrics::default(),
114 offscreen: None,
115 surface_depth: None,
116 vertices: None,
117 textures: HashMap::new(),
118 texture_bytes: 0,
119 })
120 }
121
122 #[must_use]
123 pub fn adapter(&self) -> &AdapterDescription {
124 &self.adapter
125 }
126
127 #[must_use]
128 pub const fn metrics(&self) -> RecoveryMetrics {
129 self.metrics
130 }
131
132 pub fn present_surface<'window, S>(
140 &mut self,
141 source: S,
142 request: &RenderRequest<'_>,
143 ) -> Result<PresentationDescription, Error>
144 where
145 S: wgpu::rwh::HasWindowHandle + wgpu::rwh::HasDisplayHandle + Send + Sync + 'window,
146 {
147 let started = Instant::now();
148 if !matches!(self.target.kind, TargetKind::Window | TargetKind::Embedded)
149 || request.scene.viewport().width != self.target.width
150 || request.scene.viewport().height != self.target.height
151 {
152 return Err(Error::InvalidTarget);
153 }
154 let surface = self
155 .instance
156 .create_surface(source)
157 .map_err(|error| Error::Internal(error.to_string()))?;
158 let configuration = self.surface_configuration(&surface)?;
159 surface.configure(&self.device, &configuration);
160 let (frame, reconfigured) = self.acquire_surface_frame(&surface, &configuration)?;
161 let view = frame
162 .texture
163 .create_view(&wgpu::TextureViewDescriptor::default());
164 let (depth_view, depth_allocated) = self.ensure_surface_depth();
165 let prepared = self.prepare_sprites(request.scene, request.resources.as_ref())?;
166 let vertices = vertices(request.scene);
167 let (vertex_buffer, vertex_allocation_bytes) = self.ensure_vertex_buffer(&vertices)?;
168 let pipeline = self.ensure_surface_pipeline(configuration.format);
169 let mut encoder = self
170 .device
171 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
172 label: Some("Atrinik surface frame encoder"),
173 });
174 encode_scene(
175 &mut encoder,
176 &pipeline,
177 &view,
178 &depth_view,
179 &vertex_buffer,
180 &prepared,
181 request.scene.clear_color(),
182 )?;
183 self.queue.submit(Some(encoder.finish()));
184 self.queue.present(frame);
185 self.metrics.frames_submitted += 1;
186 self.metrics.resource_requests = prepared
187 .iter()
188 .filter(|sprite| sprite.uploaded_bytes != 0)
189 .count() as u64;
190 self.metrics.uploaded_bytes = prepared
191 .iter()
192 .try_fold(0_u64, |total, sprite| {
193 total.checked_add(sprite.uploaded_bytes)
194 })
195 .ok_or(Error::LimitExceeded)?;
196 self.metrics.vertex_count = vertices.len() as u64;
197 self.metrics.vertex_allocation_bytes = vertex_allocation_bytes;
198 self.metrics.target_allocation_bytes = if depth_allocated {
199 u64::from(self.target.width)
200 .checked_mul(u64::from(self.target.height))
201 .and_then(|pixels| pixels.checked_mul(4))
202 .ok_or(Error::LimitExceeded)?
203 } else {
204 0
205 };
206 self.metrics.readback_bytes = 0;
207 self.metrics.frame_cpu_micros = duration_micros(started.elapsed());
208 if reconfigured {
209 self.metrics.surface_reconfigurations += 1;
210 }
211 Ok(PresentationDescription {
212 width: self.target.width,
213 height: self.target.height,
214 format: format!("{:?}", configuration.format),
215 reconfigured,
216 })
217 }
218
219 fn surface_configuration(
220 &self,
221 surface: &wgpu::Surface<'_>,
222 ) -> Result<wgpu::SurfaceConfiguration, Error> {
223 let capabilities = surface.get_capabilities(&self.adapter_handle);
224 let format = capabilities
225 .formats
226 .iter()
227 .copied()
228 .find(|format| !format.is_srgb())
229 .or_else(|| capabilities.formats.first().copied())
230 .ok_or_else(|| Error::BackendUnavailable("surface exposes no formats".to_owned()))?;
231 let present_mode = if capabilities
232 .present_modes
233 .contains(&wgpu::PresentMode::Fifo)
234 {
235 wgpu::PresentMode::Fifo
236 } else {
237 *capabilities.present_modes.first().ok_or_else(|| {
238 Error::BackendUnavailable("surface exposes no presentation modes".to_owned())
239 })?
240 };
241 let alpha_mode = *capabilities.alpha_modes.first().ok_or_else(|| {
242 Error::BackendUnavailable("surface exposes no alpha modes".to_owned())
243 })?;
244 Ok(wgpu::SurfaceConfiguration {
245 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
246 format,
247 color_space: wgpu::SurfaceColorSpace::Auto,
248 width: self.target.width,
249 height: self.target.height,
250 present_mode,
251 alpha_mode,
252 view_formats: Vec::new(),
253 desired_maximum_frame_latency: 2,
254 })
255 }
256
257 fn acquire_surface_frame(
258 &mut self,
259 surface: &wgpu::Surface<'_>,
260 configuration: &wgpu::SurfaceConfiguration,
261 ) -> Result<(wgpu::SurfaceTexture, bool), Error> {
262 match surface.get_current_texture() {
263 wgpu::CurrentSurfaceTexture::Success(frame)
264 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => Ok((frame, false)),
265 wgpu::CurrentSurfaceTexture::Outdated => {
266 surface.configure(&self.device, configuration);
267 match surface.get_current_texture() {
268 wgpu::CurrentSurfaceTexture::Success(frame)
269 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => Ok((frame, true)),
270 _ => Err(Error::SurfaceLost),
271 }
272 }
273 wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
274 self.metrics.skipped_frames += 1;
275 Err(Error::SurfaceUnavailable)
276 }
277 wgpu::CurrentSurfaceTexture::Lost => Err(Error::SurfaceLost),
278 other @ wgpu::CurrentSurfaceTexture::Validation => Err(Error::Internal(format!(
279 "surface acquisition failed: {other:?}"
280 ))),
281 }
282 }
283
284 fn ensure_surface_pipeline(&mut self, format: wgpu::TextureFormat) -> wgpu::RenderPipeline {
285 self.surface_pipelines
286 .entry(format)
287 .or_insert_with(|| create_pipeline(&self.device, &self.texture_layout, format))
288 .clone()
289 }
290
291 fn ensure_surface_depth(&mut self) -> (wgpu::TextureView, bool) {
292 let size = target_extent(self.target);
293 if let Some(target) = self
294 .surface_depth
295 .as_ref()
296 .filter(|target| target.size == size)
297 {
298 return (target.view.clone(), false);
299 }
300 let texture =
301 create_depth_target(&self.device, size, "Atrinik surface depth/stencil target");
302 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
303 self.surface_depth = Some(SurfaceDepthTarget {
304 size,
305 view: view.clone(),
306 });
307 (view, true)
308 }
309
310 fn ensure_offscreen_target(&mut self) -> Result<bool, Error> {
311 if self.offscreen.as_ref().is_some_and(|target| {
312 target.size.width == self.target.width && target.size.height == self.target.height
313 }) {
314 return Ok(false);
315 }
316 let size = target_extent(self.target);
317 let color = self.device.create_texture(&wgpu::TextureDescriptor {
318 label: Some("Atrinik offscreen color target"),
319 size,
320 mip_level_count: 1,
321 sample_count: 1,
322 dimension: wgpu::TextureDimension::D2,
323 format: OUTPUT_FORMAT,
324 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
325 view_formats: &[],
326 });
327 let color_view = color.create_view(&wgpu::TextureViewDescriptor::default());
328 let depth =
329 create_depth_target(&self.device, size, "Atrinik offscreen depth/stencil target");
330 let depth_view = depth.create_view(&wgpu::TextureViewDescriptor::default());
331 let unpadded_row = self
332 .target
333 .width
334 .checked_mul(4)
335 .ok_or(Error::LimitExceeded)?;
336 let padded_row = unpadded_row
337 .div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
338 .checked_mul(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
339 .ok_or(Error::LimitExceeded)?;
340 let readback_size = u64::from(padded_row)
341 .checked_mul(u64::from(self.target.height))
342 .ok_or(Error::LimitExceeded)?;
343 let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
344 label: Some("Atrinik offscreen readback"),
345 size: readback_size,
346 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
347 mapped_at_creation: false,
348 });
349 self.offscreen = Some(OffscreenTarget {
350 size,
351 color,
352 color_view,
353 depth_view,
354 readback,
355 unpadded_row,
356 padded_row,
357 });
358 Ok(true)
359 }
360
361 fn render_frame(
362 &mut self,
363 scene: &SceneBundle,
364 resources: &dyn ResourceProvider,
365 ) -> Result<(Vec<u8>, SemanticPlanes, FrameWork), Error> {
366 let prepared = self.prepare_sprites(scene, resources)?;
367 let vertices = vertices(scene);
368 let (vertex_buffer, vertex_allocation_bytes) = self.ensure_vertex_buffer(&vertices)?;
369 let allocated = self.ensure_offscreen_target()?;
370 let target = self
371 .offscreen
372 .as_ref()
373 .ok_or_else(|| Error::Internal("offscreen target missing after creation".to_owned()))?;
374 let mut encoder = self
375 .device
376 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
377 label: Some("Atrinik frame encoder"),
378 });
379 encode_scene(
380 &mut encoder,
381 &self.pipeline,
382 &target.color_view,
383 &target.depth_view,
384 &vertex_buffer,
385 &prepared,
386 scene.clear_color(),
387 )?;
388 encoder.copy_texture_to_buffer(
389 wgpu::TexelCopyTextureInfo {
390 texture: &target.color,
391 mip_level: 0,
392 origin: wgpu::Origin3d::ZERO,
393 aspect: wgpu::TextureAspect::All,
394 },
395 wgpu::TexelCopyBufferInfo {
396 buffer: &target.readback,
397 layout: wgpu::TexelCopyBufferLayout {
398 offset: 0,
399 bytes_per_row: Some(target.padded_row),
400 rows_per_image: Some(self.target.height),
401 },
402 },
403 target.size,
404 );
405 self.queue.submit(Some(encoder.finish()));
406 let slice = target.readback.slice(..);
407 let (sender, receiver) = mpsc::sync_channel(1);
408 slice.map_async(wgpu::MapMode::Read, move |result| {
409 let _ignored = sender.send(result);
410 });
411 self.device
412 .poll(wgpu::PollType::Wait {
413 submission_index: None,
414 timeout: None,
415 })
416 .map_err(|error| Error::Internal(error.to_string()))?;
417 receiver
418 .recv()
419 .map_err(|error| Error::Internal(error.to_string()))?
420 .map_err(|error| Error::Internal(error.to_string()))?;
421 let mapped = slice
422 .get_mapped_range()
423 .map_err(|error| Error::Internal(error.to_string()))?;
424 let capacity = usize::try_from(target.unpadded_row)
425 .ok()
426 .and_then(|row| row.checked_mul(self.target.height as usize))
427 .ok_or(Error::LimitExceeded)?;
428 let mut rgba = Vec::with_capacity(capacity);
429 for row in mapped.chunks_exact(target.padded_row as usize) {
430 rgba.extend_from_slice(&row[..target.unpadded_row as usize]);
431 }
432 drop(mapped);
433 target.readback.unmap();
434 let planes = semantic_planes(scene, &prepared)?;
435 let uploaded_bytes = prepared
436 .iter()
437 .try_fold(0_u64, |total, sprite| {
438 total.checked_add(sprite.uploaded_bytes)
439 })
440 .ok_or(Error::LimitExceeded)?;
441 let pixel_count = u64::from(self.target.width) * u64::from(self.target.height);
442 Ok((
443 rgba,
444 planes,
445 FrameWork {
446 resource_requests: prepared
447 .iter()
448 .filter(|sprite| sprite.uploaded_bytes != 0)
449 .count() as u64,
450 uploaded_bytes,
451 vertex_count: vertices.len() as u64,
452 vertex_allocation_bytes,
453 target_allocation_bytes: if allocated {
454 pixel_count.checked_mul(12).ok_or(Error::LimitExceeded)?
455 } else {
456 0
457 },
458 readback_bytes: pixel_count.checked_mul(4).ok_or(Error::LimitExceeded)?,
459 },
460 ))
461 }
462
463 fn ensure_vertex_buffer(&mut self, vertices: &[Vertex]) -> Result<(wgpu::Buffer, u64), Error> {
464 let contents = bytemuck::cast_slice(vertices);
465 let required = u64::try_from(contents.len().max(4)).map_err(|_| Error::LimitExceeded)?;
466 let allocated = if self
467 .vertices
468 .as_ref()
469 .is_none_or(|cache| cache.capacity < required)
470 {
471 let capacity = required
472 .checked_next_power_of_two()
473 .ok_or(Error::LimitExceeded)?;
474 self.vertices = Some(VertexCache {
475 buffer: self.device.create_buffer(&wgpu::BufferDescriptor {
476 label: Some("Atrinik shared frame vertices"),
477 size: capacity,
478 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
479 mapped_at_creation: false,
480 }),
481 capacity,
482 });
483 capacity
484 } else {
485 0
486 };
487 let cache = self
488 .vertices
489 .as_ref()
490 .ok_or_else(|| Error::Internal("vertex cache insertion failed".to_owned()))?;
491 if !contents.is_empty() {
492 self.queue.write_buffer(&cache.buffer, 0, contents);
493 }
494 Ok((cache.buffer.clone(), allocated))
495 }
496
497 fn prepare_sprites(
498 &mut self,
499 scene: &SceneBundle,
500 resources: &dyn ResourceProvider,
501 ) -> Result<Vec<PreparedSprite>, Error> {
502 let mut prepared = Vec::with_capacity(scene.sprites().len());
503 let missing = scene
504 .sprites()
505 .iter()
506 .map(|sprite| {
507 (
508 sprite.resource_id,
509 sprite.resource_revision,
510 sprite.resource_digest,
511 )
512 })
513 .filter(|key| !self.textures.contains_key(key))
514 .collect::<std::collections::HashSet<_>>();
515 if missing.len() > MAXIMUM_CACHED_TEXTURES {
516 return Err(Error::LimitExceeded);
517 }
518 if self.textures.len().saturating_add(missing.len()) > MAXIMUM_CACHED_TEXTURES {
519 self.textures.clear();
520 self.texture_bytes = 0;
521 }
522 let mut working_set = HashSet::with_capacity(missing.len());
523 let mut working_set_bytes = 0_u64;
524 for sprite in scene.sprites() {
525 let key = (
526 sprite.resource_id,
527 sprite.resource_revision,
528 sprite.resource_digest,
529 );
530 let uploaded_bytes = if self.textures.contains_key(&key) {
531 0
532 } else {
533 self.load_texture(key, resources)?
534 };
535 let cached = self
536 .textures
537 .get(&key)
538 .ok_or_else(|| Error::Internal("texture cache insertion failed".to_owned()))?;
539 if working_set.insert(key) {
540 working_set_bytes =
541 add_working_set_bytes(working_set_bytes, cached.image.pixels().len() as u64)
542 .map_err(|_| Error::LimitExceeded)?;
543 }
544 let image = cached.image.clone();
545 let bind_group = match sprite.sampling {
546 Sampling::Nearest => cached.nearest.clone(),
547 Sampling::Linear => cached.linear.clone(),
548 };
549 prepared.push(PreparedSprite {
550 bind_group,
551 image,
552 uploaded_bytes,
553 });
554 }
555 Ok(prepared)
556 }
557
558 fn load_texture(
559 &mut self,
560 key: TextureKey,
561 resources: &dyn ResourceProvider,
562 ) -> Result<u64, Error> {
563 let blob = resources
564 .load(ResourceRequest {
565 id: key.0,
566 revision: key.1,
567 digest: key.2,
568 maximum_bytes: DEFAULT_MAXIMUM_RESOURCE_BYTES,
569 })
570 .map_err(|error| Error::Resource(error.to_string()))?;
571 let image = Arc::new(
572 Rgba8Image::decode(&blob, 8_192, 16_777_216)
573 .map_err(|error| Error::Resource(error.to_string()))?,
574 );
575 let size = wgpu::Extent3d {
576 width: image.width(),
577 height: image.height(),
578 depth_or_array_layers: 1,
579 };
580 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
581 label: Some("Atrinik sprite texture"),
582 size,
583 mip_level_count: 1,
584 sample_count: 1,
585 dimension: wgpu::TextureDimension::D2,
586 format: wgpu::TextureFormat::Rgba8Unorm,
587 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
588 view_formats: &[],
589 });
590 self.queue.write_texture(
591 wgpu::TexelCopyTextureInfo {
592 texture: &texture,
593 mip_level: 0,
594 origin: wgpu::Origin3d::ZERO,
595 aspect: wgpu::TextureAspect::All,
596 },
597 image.pixels(),
598 wgpu::TexelCopyBufferLayout {
599 offset: 0,
600 bytes_per_row: Some(image.width() * 4),
601 rows_per_image: Some(image.height()),
602 },
603 size,
604 );
605 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
606 let nearest = create_texture_binding(
607 &self.device,
608 &self.texture_layout,
609 &view,
610 wgpu::FilterMode::Nearest,
611 );
612 let linear = create_texture_binding(
613 &self.device,
614 &self.texture_layout,
615 &view,
616 wgpu::FilterMode::Linear,
617 );
618 let uploaded_bytes = image.pixels().len() as u64;
619 if self
620 .texture_bytes
621 .checked_add(uploaded_bytes)
622 .is_none_or(|bytes| bytes > DEFAULT_MAXIMUM_TEXTURE_WORKING_SET_BYTES)
623 {
624 self.textures.clear();
625 self.texture_bytes = 0;
626 }
627 self.texture_bytes = self
628 .texture_bytes
629 .checked_add(uploaded_bytes)
630 .ok_or(Error::LimitExceeded)?;
631 self.textures.insert(
632 key,
633 TextureCacheEntry {
634 image,
635 nearest,
636 linear,
637 },
638 );
639 Ok(uploaded_bytes)
640 }
641}
642
643impl Renderer for WgpuRenderer {
644 fn target(&self) -> TargetDescriptor {
645 self.target
646 }
647
648 fn resize(&mut self, width: u32, height: u32) -> Result<(), Error> {
649 self.target = TargetDescriptor {
650 width,
651 height,
652 ..self.target
653 }
654 .validate(TargetLimits::default())?;
655 self.metrics.surface_reconfigurations += 1;
656 self.offscreen = None;
657 self.surface_depth = None;
658 Ok(())
659 }
660
661 fn render(&mut self, request: RenderRequest<'_>) -> Result<FrameOutput, Error> {
662 if request.scene.viewport().width != self.target.width
663 || request.scene.viewport().height != self.target.height
664 {
665 return Err(Error::InvalidTarget);
666 }
667 let started = Instant::now();
668 let (rgba8, (semantic_ids, depth, coverage), work) =
669 self.render_frame(request.scene, request.resources.as_ref())?;
670 self.metrics.frames_submitted += 1;
671 self.metrics.resource_requests = work.resource_requests;
672 self.metrics.uploaded_bytes = work.uploaded_bytes;
673 self.metrics.vertex_count = work.vertex_count;
674 self.metrics.vertex_allocation_bytes = work.vertex_allocation_bytes;
675 self.metrics.target_allocation_bytes = work.target_allocation_bytes;
676 self.metrics.readback_bytes = work.readback_bytes;
677 self.metrics.frame_cpu_micros = duration_micros(started.elapsed());
678 let output = FrameOutput {
679 width: self.target.width,
680 height: self.target.height,
681 rgba8,
682 semantic_ids,
683 depth,
684 coverage,
685 metrics: self.metrics,
686 };
687 output.validate()?;
688 Ok(output)
689 }
690}
691
692type GpuInitialization = (
693 wgpu::Instance,
694 wgpu::Adapter,
695 wgpu::Device,
696 wgpu::Queue,
697 AdapterDescription,
698);
699
700fn initialize_gpu(
701 preference: BackendPreference,
702 startup: Instant,
703) -> Result<GpuInitialization, Error> {
704 let backends = match preference {
705 BackendPreference::Automatic => wgpu::Backends::VULKAN | wgpu::Backends::DX12,
706 BackendPreference::Vulkan => wgpu::Backends::VULKAN,
707 BackendPreference::Direct3D12 => wgpu::Backends::DX12,
708 };
709 let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
710 descriptor.backends &= backends;
711 let instance = wgpu::Instance::new(descriptor);
712 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
713 power_preference: wgpu::PowerPreference::HighPerformance,
714 force_fallback_adapter: false,
715 compatible_surface: None,
716 apply_limit_buckets: false,
717 }))
718 .map_err(|error| {
719 Error::BackendUnavailable(format!(
720 "no adapter for requested {preference:?} backend policy: {error}"
721 ))
722 })?;
723 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
724 label: Some("Atrinik renderer device"),
725 required_features: wgpu::Features::empty(),
726 required_limits: wgpu::Limits::downlevel_defaults(),
727 memory_hints: wgpu::MemoryHints::Performance,
728 experimental_features: wgpu::ExperimentalFeatures::disabled(),
729 trace: wgpu::Trace::Off,
730 }))
731 .map_err(|error| Error::Internal(error.to_string()))?;
732 let info = adapter.get_info();
733 let description = AdapterDescription {
734 name: info.name,
735 driver: info.driver,
736 backend: format!("{:?}", info.backend),
737 device_type: format!("{:?}", info.device_type),
738 maximum_texture_dimension_2d: adapter.limits().max_texture_dimension_2d,
739 maximum_buffer_size: adapter.limits().max_buffer_size,
740 startup_micros: duration_micros(startup.elapsed()),
741 };
742 Ok((instance, adapter, device, queue, description))
743}
744
745fn create_texture_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
746 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
747 label: Some("Atrinik sprite texture layout"),
748 entries: &[
749 wgpu::BindGroupLayoutEntry {
750 binding: 0,
751 visibility: wgpu::ShaderStages::FRAGMENT,
752 ty: wgpu::BindingType::Texture {
753 sample_type: wgpu::TextureSampleType::Float { filterable: true },
754 view_dimension: wgpu::TextureViewDimension::D2,
755 multisampled: false,
756 },
757 count: None,
758 },
759 wgpu::BindGroupLayoutEntry {
760 binding: 1,
761 visibility: wgpu::ShaderStages::FRAGMENT,
762 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
763 count: None,
764 },
765 ],
766 })
767}
768
769fn create_texture_binding(
770 device: &wgpu::Device,
771 layout: &wgpu::BindGroupLayout,
772 view: &wgpu::TextureView,
773 filter: wgpu::FilterMode,
774) -> wgpu::BindGroup {
775 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
776 label: Some("Atrinik sprite sampler"),
777 address_mode_u: wgpu::AddressMode::ClampToEdge,
778 address_mode_v: wgpu::AddressMode::ClampToEdge,
779 mag_filter: filter,
780 min_filter: filter,
781 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
782 ..Default::default()
783 });
784 device.create_bind_group(&wgpu::BindGroupDescriptor {
785 label: Some("Atrinik sprite texture binding"),
786 layout,
787 entries: &[
788 wgpu::BindGroupEntry {
789 binding: 0,
790 resource: wgpu::BindingResource::TextureView(view),
791 },
792 wgpu::BindGroupEntry {
793 binding: 1,
794 resource: wgpu::BindingResource::Sampler(&sampler),
795 },
796 ],
797 })
798}
799
800fn target_extent(target: TargetDescriptor) -> wgpu::Extent3d {
801 wgpu::Extent3d {
802 width: target.width,
803 height: target.height,
804 depth_or_array_layers: 1,
805 }
806}
807
808fn create_depth_target(device: &wgpu::Device, size: wgpu::Extent3d, label: &str) -> wgpu::Texture {
809 device.create_texture(&wgpu::TextureDescriptor {
810 label: Some(label),
811 size,
812 mip_level_count: 1,
813 sample_count: 1,
814 dimension: wgpu::TextureDimension::D2,
815 format: wgpu::TextureFormat::Depth24PlusStencil8,
816 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
817 view_formats: &[],
818 })
819}
820
821fn create_pipeline(
822 device: &wgpu::Device,
823 texture_layout: &wgpu::BindGroupLayout,
824 format: wgpu::TextureFormat,
825) -> wgpu::RenderPipeline {
826 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
827 label: Some("Atrinik sprite shader"),
828 source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shader.wgsl"))),
829 });
830 let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
831 label: Some("Atrinik sprite pipeline layout"),
832 bind_group_layouts: &[Some(texture_layout)],
833 immediate_size: 0,
834 });
835 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
836 label: Some("Atrinik sprite pipeline"),
837 layout: Some(&layout),
838 vertex: wgpu::VertexState {
839 module: &shader,
840 entry_point: Some("vertex_main"),
841 compilation_options: wgpu::PipelineCompilationOptions::default(),
842 buffers: &[Some(Vertex::layout())],
843 },
844 fragment: Some(wgpu::FragmentState {
845 module: &shader,
846 entry_point: Some("fragment_main"),
847 compilation_options: wgpu::PipelineCompilationOptions::default(),
848 targets: &[Some(wgpu::ColorTargetState {
849 format,
850 blend: Some(wgpu::BlendState::ALPHA_BLENDING),
851 write_mask: wgpu::ColorWrites::ALL,
852 })],
853 }),
854 primitive: wgpu::PrimitiveState::default(),
855 depth_stencil: Some(wgpu::DepthStencilState {
856 format: wgpu::TextureFormat::Depth24PlusStencil8,
857 depth_write_enabled: Some(true),
858 depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
859 stencil: wgpu::StencilState::default(),
860 bias: wgpu::DepthBiasState::default(),
861 }),
862 multisample: wgpu::MultisampleState::default(),
863 multiview_mask: None,
864 cache: None,
865 })
866}
867
868fn encode_scene(
869 encoder: &mut wgpu::CommandEncoder,
870 pipeline: &wgpu::RenderPipeline,
871 color_view: &wgpu::TextureView,
872 depth_view: &wgpu::TextureView,
873 vertex_buffer: &wgpu::Buffer,
874 sprites: &[PreparedSprite],
875 clear: [f32; 4],
876) -> Result<(), Error> {
877 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
878 label: Some("Atrinik scene pass"),
879 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
880 view: color_view,
881 depth_slice: None,
882 resolve_target: None,
883 ops: wgpu::Operations {
884 load: wgpu::LoadOp::Clear(wgpu::Color {
885 r: f64::from(clear[0]),
886 g: f64::from(clear[1]),
887 b: f64::from(clear[2]),
888 a: f64::from(clear[3]),
889 }),
890 store: wgpu::StoreOp::Store,
891 },
892 })],
893 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
894 view: depth_view,
895 depth_ops: Some(wgpu::Operations {
896 load: wgpu::LoadOp::Clear(0.0),
897 store: wgpu::StoreOp::Store,
898 }),
899 stencil_ops: Some(wgpu::Operations {
900 load: wgpu::LoadOp::Clear(0),
901 store: wgpu::StoreOp::Store,
902 }),
903 }),
904 timestamp_writes: None,
905 occlusion_query_set: None,
906 multiview_mask: None,
907 });
908 pass.set_pipeline(pipeline);
909 pass.set_vertex_buffer(0, vertex_buffer.slice(..));
910 for (index, sprite) in sprites.iter().enumerate() {
911 let first = u32::try_from(index)
912 .ok()
913 .and_then(|value| value.checked_mul(6))
914 .ok_or(Error::LimitExceeded)?;
915 pass.set_bind_group(0, &sprite.bind_group, &[]);
916 pass.draw(first..first + 6, 0..1);
917 }
918 Ok(())
919}
920
921#[repr(C)]
922#[derive(Clone, Copy, Pod, Zeroable)]
923struct Vertex {
924 position: [f32; 3],
925 color: [f32; 4],
926 uv: [f32; 2],
927 effect: f32,
928}
929
930impl Vertex {
931 const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
932 0 => Float32x3, 1 => Float32x4, 2 => Float32x2, 3 => Float32
933 ];
934
935 fn layout() -> wgpu::VertexBufferLayout<'static> {
936 wgpu::VertexBufferLayout {
937 array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
938 step_mode: wgpu::VertexStepMode::Vertex,
939 attributes: &Self::ATTRIBUTES,
940 }
941 }
942}
943
944struct PreparedSprite {
945 bind_group: wgpu::BindGroup,
946 image: Arc<Rgba8Image>,
947 uploaded_bytes: u64,
948}
949
950#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
951struct FrameWork {
952 resource_requests: u64,
953 uploaded_bytes: u64,
954 vertex_count: u64,
955 vertex_allocation_bytes: u64,
956 target_allocation_bytes: u64,
957 readback_bytes: u64,
958}
959
960fn duration_micros(duration: std::time::Duration) -> u64 {
961 u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
962}
963
964#[allow(clippy::cast_precision_loss)]
965fn vertices(scene: &SceneBundle) -> Vec<Vertex> {
966 let viewport = scene.viewport();
967 let mut output = Vec::with_capacity(scene.sprites().len().saturating_mul(6));
968 for sprite in scene.sprites() {
969 let left = sprite.x.mul_add(2.0 / viewport.width as f32, -1.0);
970 let right = (sprite.x + sprite.width).mul_add(2.0 / viewport.width as f32, -1.0);
971 let top = 1.0 - sprite.y * 2.0 / viewport.height as f32;
972 let bottom = 1.0 - (sprite.y + sprite.height) * 2.0 / viewport.height as f32;
973 let z = depth_value(sprite);
974 let effect = effect_amount(sprite, scene.clock_millis());
975 let vertex = |position: [f32; 2], uv| Vertex {
976 position: [position[0], position[1], z],
977 color: sprite.color,
978 uv,
979 effect,
980 };
981 output.extend_from_slice(&[
982 vertex([left, top], [0.0, 0.0]),
983 vertex([left, bottom], [0.0, 1.0]),
984 vertex([right, bottom], [1.0, 1.0]),
985 vertex([left, top], [0.0, 0.0]),
986 vertex([right, bottom], [1.0, 1.0]),
987 vertex([right, top], [1.0, 0.0]),
988 ]);
989 }
990 output
991}
992
993fn depth_value(sprite: &Sprite) -> f32 {
994 let layer = match sprite.layer {
995 Layer::World => 0.0,
996 Layer::Effect => 1.0,
997 Layer::Interface => 2.0,
998 };
999 let depth = (f32::from(sprite.depth) - f32::from(i16::MIN)) / f32::from(u16::MAX);
1000 (layer + depth) / 3.0
1001}
1002
1003#[allow(clippy::cast_precision_loss)]
1004fn effect_amount(sprite: &Sprite, clock_millis: u64) -> f32 {
1005 if sprite.layer == Layer::Effect {
1006 (clock_millis % 1_000) as f32 / 1_000.0 * 0.15
1007 } else {
1008 0.0
1009 }
1010}
1011
1012type SemanticPlanes = (Vec<u32>, Vec<i16>, Vec<u8>);
1013
1014fn semantic_planes(
1015 scene: &SceneBundle,
1016 prepared: &[PreparedSprite],
1017) -> Result<SemanticPlanes, Error> {
1018 let viewport = scene.viewport();
1019 let pixels = (viewport.width as usize)
1020 .checked_mul(viewport.height as usize)
1021 .ok_or(Error::LimitExceeded)?;
1022 let mut semantic = vec![0; pixels];
1023 let mut depth = vec![i16::MIN; pixels];
1024 let mut coverage = vec![0; pixels];
1025 for (sprite, prepared) in scene.sprites().iter().zip(prepared) {
1026 mark_sprite(
1027 viewport.width,
1028 viewport.height,
1029 sprite,
1030 &mut semantic,
1031 &mut depth,
1032 &mut coverage,
1033 &prepared.image,
1034 );
1035 }
1036 Ok((semantic, depth, coverage))
1037}
1038
1039fn mark_sprite(
1040 width: u32,
1041 height: u32,
1042 sprite: &Sprite,
1043 semantic: &mut [u32],
1044 depth: &mut [i16],
1045 coverage: &mut [u8],
1046 image: &Rgba8Image,
1047) {
1048 for_each_sprite_pixel(sprite, width, height, |_x, _y, index, u, v| {
1049 let alpha = shade_rgba8(
1050 sample_rgba8(image, u, v, sprite.sampling),
1051 sprite.color,
1052 sprite.layer,
1053 0,
1054 )[3];
1055 if alpha != 0 {
1056 semantic[index] = sprite.semantic_id.0;
1057 depth[index] = sprite.depth;
1058 coverage[index] = alpha;
1059 }
1060 });
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::WgpuRenderer;
1066 use atrinik_render_api::{RenderRequest, Renderer};
1067 use atrinik_render_testkit::{
1068 ReferenceRenderer, offscreen_target, synthetic_provider, synthetic_scene,
1069 };
1070
1071 #[test]
1072 fn renders_offscreen_when_a_gpu_adapter_is_available() {
1073 let Ok(mut renderer) = WgpuRenderer::new(offscreen_target(16, 16)) else {
1074 eprintln!("skipping GPU smoke test: no supported adapter");
1075 return;
1076 };
1077 let scene = synthetic_scene(16, 16).unwrap();
1078 let request = RenderRequest {
1079 scene: &scene,
1080 resources: synthetic_provider().unwrap(),
1081 };
1082 let frame = renderer.render(request.clone()).unwrap();
1083 let reference = ReferenceRenderer::new(offscreen_target(16, 16))
1084 .unwrap()
1085 .render(request)
1086 .unwrap();
1087 assert_eq!(frame.rgba8.len(), 16 * 16 * 4);
1088 assert!(frame.rgba8.windows(4).any(|pixel| pixel != [5, 8, 13, 255]));
1089 assert_eq!(frame.semantic_ids, reference.semantic_ids);
1090 assert_eq!(frame.depth, reference.depth);
1091 assert_eq!(frame.coverage, reference.coverage);
1092 let (maximum_index, maximum_channel_difference) = frame
1093 .rgba8
1094 .iter()
1095 .zip(reference.rgba8.iter())
1096 .enumerate()
1097 .map(|(index, (actual, expected))| (index, actual.abs_diff(*expected)))
1098 .max_by_key(|(_, difference)| *difference)
1099 .unwrap();
1100 assert!(
1101 maximum_channel_difference <= 1,
1102 "maximum RGBA difference: {maximum_channel_difference} at byte {maximum_index}; GPU={}, reference={}",
1103 frame.rgba8[maximum_index],
1104 reference.rgba8[maximum_index],
1105 );
1106 assert_eq!(frame.metrics.frames_submitted, 1);
1107 assert_eq!(frame.metrics.resource_requests, 1);
1108 assert_eq!(frame.metrics.uploaded_bytes, 16);
1109 assert_eq!(frame.metrics.vertex_count, 18);
1110 assert_eq!(frame.metrics.vertex_allocation_bytes, 1_024);
1111 assert_eq!(frame.metrics.target_allocation_bytes, 16 * 16 * 12);
1112
1113 let reused = renderer
1114 .render(RenderRequest {
1115 scene: &scene,
1116 resources: synthetic_provider().unwrap(),
1117 })
1118 .unwrap();
1119 assert_eq!(reused.metrics.frames_submitted, 2);
1120 assert_eq!(reused.metrics.target_allocation_bytes, 0);
1121 assert_eq!(reused.metrics.vertex_allocation_bytes, 0);
1122 assert_eq!(reused.metrics.resource_requests, 0);
1123 assert_eq!(reused.metrics.uploaded_bytes, 0);
1124
1125 renderer.resize(31, 17).unwrap();
1126 let resized_scene = synthetic_scene(31, 17).unwrap();
1127 let resized = renderer
1128 .render(RenderRequest {
1129 scene: &resized_scene,
1130 resources: synthetic_provider().unwrap(),
1131 })
1132 .unwrap();
1133 assert_eq!(resized.metrics.frames_submitted, 3);
1134 assert_eq!(resized.metrics.surface_reconfigurations, 1);
1135 assert_eq!(resized.metrics.vertex_allocation_bytes, 0);
1136
1137 for _ in 0..3 {
1138 let mut recreated = WgpuRenderer::new(offscreen_target(8, 8)).unwrap();
1139 let recreated_scene = synthetic_scene(8, 8).unwrap();
1140 recreated
1141 .render(RenderRequest {
1142 scene: &recreated_scene,
1143 resources: synthetic_provider().unwrap(),
1144 })
1145 .unwrap();
1146 }
1147 }
1148
1149 #[test]
1150 fn shader_is_valid_portable_wgsl() {
1151 let source = include_str!("shader.wgsl");
1152 let module = naga::front::wgsl::parse_str(source).unwrap();
1153 naga::valid::Validator::new(
1154 naga::valid::ValidationFlags::all(),
1155 naga::valid::Capabilities::empty(),
1156 )
1157 .validate(&module)
1158 .unwrap();
1159 }
1160}