Skip to main content

atrinik_render_wgpu/
lib.rs

1// Copyright 2026 The Atrinik Project
2// SPDX-License-Identifier: MIT
3
4#![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    /// Creates an offscreen Vulkan or Direct3D 12 renderer.
93    ///
94    /// # Errors
95    /// Returns a typed target, adapter, or device creation error.
96    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    /// Renders the same scene/material path directly into a native surface and
133    /// presents it using this renderer's existing adapter, device, and queue.
134    ///
135    /// # Errors
136    /// Returns a typed target, surface, resource, or backend error. Timeout and
137    /// occlusion are reported as unavailable frames; outdated surfaces are
138    /// reconfigured and retried once.
139    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}