//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad //! aus dem Renderer-Plan: //! //! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild //! ([`ScenePass`], PS1-Shader). //! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit //! 4:3-Letterbox (via Viewport) auf die Surface. use std::sync::Arc; use winit::window::Window; use crate::engine::tga::Image; use crate::render::math::Mat4; use crate::render::scene::{Mesh, ScenePass}; use crate::render::sprite::SpritePass; use crate::render::ui::Ui; pub const INTERNAL_W: u32 = 320; pub const INTERNAL_H: u32 = 240; /// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall /// (auch GL-Fallback) unterstützte Depth-Format. const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm; const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; pub struct Gpu { surface: wgpu::Surface<'static>, device: wgpu::Device, queue: wgpu::Queue, config: wgpu::SurfaceConfiguration, internal_view: wgpu::TextureView, depth_view: wgpu::TextureView, scene: ScenePass, sprite: SpritePass, blit_pipeline: wgpu::RenderPipeline, blit_bind: wgpu::BindGroup, } impl Gpu { pub fn new( window: Arc, display: winit::event_loop::OwnedDisplayHandle, mesh: &Mesh, images: &[Image], ui_textures: &[Image], ) -> Self { let size = window.inner_size(); // Display-Handle mitgeben: für den GL-Fallback (v.a. Wayland) // Pflicht; Vulkan ignoriert es. `with_env` erlaubt Overrides wie // WGPU_BACKEND=gl zum Testen des Fallback-Pfads. let instance = wgpu::Instance::new( wgpu::InstanceDescriptor::new_with_display_handle(Box::new(display)).with_env(), ); let surface = instance.create_surface(window).expect("wgpu: Surface"); let adapter = pollster::block_on(instance.request_adapter( &wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), ..Default::default() }, )).expect("wgpu: kein Adapter"); let info = adapter.get_info(); println!("[gpu] {} ({:?}, {:?})", info.name, info.backend, info.device_type); // Baseline-Limits/-Features: alles, was der Plan braucht, ist // WebGPU-Kern — nichts anfordern, dann läuft es auch auf HD 5500. let (device, queue) = pollster::block_on( adapter.request_device(&wgpu::DeviceDescriptor::default()), ).expect("wgpu: Device"); // Nicht-sRGB-8-Bit-Surface bevorzugen: der Fragment-Shader // quantisiert später selbst auf RGB555 — die Werte sollen // unverändert auf den Schirm, ohne Gamma-Umkodierung beim Blit. // Explizite Liste statt „erstes nicht-sRGB": Treiber bieten auch // 16-Bit-Formate an, die extra Device-Features bräuchten. let caps = surface.get_capabilities(&adapter); let format = [wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Rgba8Unorm] .into_iter() .find(|f| caps.formats.contains(f)) .unwrap_or(caps.formats[0]); println!("[gpu] Surface-Format {format:?}"); let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: size.width.max(1), height: size.height.max(1), present_mode: wgpu::PresentMode::AutoVsync, alpha_mode: caps.alpha_modes[0], view_formats: vec![], desired_maximum_frame_latency: 2, }; surface.configure(&device, &config); let (internal_view, depth_view) = make_internal_targets(&device); // Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer // (Decode in render::run); hier wird nur hochgeladen. let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images); // Overlay-Pass: zeichnet nach der Szene ins selbe interne Target. let sprite = SpritePass::new( &device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, [INTERNAL_W as f32, INTERNAL_H as f32], ui_textures, ); // Pass 2: internes Target nearest-gesampelt auf die Surface. let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("blit"), source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()), }); let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("blit"), layout: None, vertex: wgpu::VertexState { module: &blit_shader, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[], }, fragment: Some(wgpu::FragmentState { module: &blit_shader, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: &[Some(config.format.into())], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("blit nearest"), mag_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("blit"), layout: &blit_pipeline.get_bind_group_layout(0), entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&internal_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); Self { surface, device, queue, config, internal_view, depth_view, scene, sprite, blit_pipeline, blit_bind, } } pub fn resize(&mut self, width: u32, height: u32) { self.config.width = width.max(1); self.config.height = height.max(1); self.surface.configure(&self.device, &self.config); } /// Fenster-Pixel (physisch) → interne Pixel, über dieselbe Letterbox wie /// der Blit. Außerhalb des Bildbereichs auf die Ränder geklemmt. pub fn map_cursor(&self, win: [f32; 2]) -> [f32; 2] { let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height); let ix = (win[0] - vx) / vw * INTERNAL_W as f32; let iy = (win[1] - vy) / vh * INTERNAL_H as f32; [ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)] } /// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam); /// die Projektion gehört hierher, weil sie an der internen Auflösung /// hängt. pub fn frame(&mut self, view: &Mat4, ui: &Ui) { let proj = Mat4::perspective( 60f32.to_radians(), INTERNAL_W as f32 / INTERNAL_H as f32, 0.1, 100.0, ); let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5]; self.scene.prepare(&self.queue, &proj.mul(view), half_res); self.sprite.prepare(&self.queue, &ui.verts); use wgpu::CurrentSurfaceTexture as Cst; let frame = match self.surface.get_current_texture() { Cst::Success(f) | Cst::Suboptimal(f) => f, // Surface veraltet (Resize/Compositor): neu konfigurieren, // diesen Frame auslassen. Cst::Outdated | Cst::Lost => { self.surface.configure(&self.device, &self.config); return; } // Fenster verdeckt/minimiert oder Treiber-Timeout: auslassen. Cst::Timeout | Cst::Occluded => return, Cst::Validation => panic!("wgpu: Surface-Validation-Fehler"), }; let surface_view = frame.texture.create_view(&Default::default()); let mut enc = self.device.create_command_encoder(&Default::default()); // Pass 1: intern. { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("internal"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &self.internal_view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); self.scene.draw(&mut pass); // Overlay obenauf, im selben Pass (kein Tiefentest). self.sprite.draw(&mut pass, &ui.batches); } // Pass 2: Letterbox-Blit aufs Fenster. { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("blit"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &surface_view, depth_slice: None, resolve_target: None, ops: wgpu::Operations { // Schwarz = die Letterbox-Balken. load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height); pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0); pass.set_pipeline(&self.blit_pipeline); pass.set_bind_group(0, &self.blit_bind, &[]); pass.draw(0..3, 0..1); } self.queue.submit([enc.finish()]); frame.present(); } } fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) { let size = wgpu::Extent3d { width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1, }; let color = device.create_texture(&wgpu::TextureDescriptor { label: Some("internal color"), size, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: INTERNAL_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let depth = device.create_texture(&wgpu::TextureDescriptor { label: Some("internal depth"), size, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DEPTH_FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); (color.create_view(&Default::default()), depth.create_view(&Default::default())) } /// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert. /// Nicht-ganzzahlige Skalierung ist gewollt (volle Fensterausnutzung); /// die leicht ungleichen Pixel passen zum CRT-Vorbild. fn letterbox(win_w: u32, win_h: u32) -> (f32, f32, f32, f32) { let (w, h) = (win_w as f32, win_h as f32); let scale = (w / INTERNAL_W as f32).min(h / INTERNAL_H as f32); let vw = INTERNAL_W as f32 * scale; let vh = INTERNAL_H as f32 * scale; ((w - vw) * 0.5, (h - vh) * 0.5, vw, vh) }