commit 3b034714d89f1f7b1ad602b73806f9c9bfb7f958 Author: irrlicht Date: Mon May 11 12:41:04 2026 +0200 Initial + split from my custom roguelike engine diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dac60bb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "pbio" +version = "0.1.0" +edition = "2024" + +[dependencies] +glfw = "0.60.0" +wgpu = "*" +pollster = "0.4.0" +bytemuck = { version = "1.24.0", features = ["derive"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..56dd28c --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# pbio: simple pixel buffer I/O + +Pixelbuffer-basiertes Fenster mit Palette-Lookup-Renderer (GLFW + wgpu). + +pbio nimmt einen Bytearray, interpretiert diesen als 8-Bit-Pixelbuffer und rendert ihn auf ein Fenster. Optional mit Letterbox für ein festes Seitenverhältnis + +## Verwendung + +```rust +use pbio::{Event, Key, Platform, PlatformConfig}; + +fn main() { + let mut p = Platform::new(PlatformConfig { + title: "Demo".into(), + window_size: (1024, 768), + framebuffer_size: (320, 240), + ..Default::default() + }); + + while !p.should_close() { + p.poll_events(None); + + for ev in p.drain_events() { + if let Event::Key { key: Key::Escape, pressed: true, .. } = ev { + p.request_close(); + } + } + + let (w, _h) = p.framebuffer_size(); + let fb = p.framebuffer_mut(); + for (i, px) in fb.iter_mut().enumerate() { + *px = ((i as u32 % w) ^ (i as u32 / w)) as u8; + } + + p.present(); + } +} +``` + +## API-Überblick + +- `PlatformConfig` – Titel, Fenster-/Framebuffer-Größe, optionales + `aspect_ratio` (Letterbox), Palette, VSync. Default = 800×600 Fenster, + 320×240 Framebuffer, 4:3, `palette::RGB332`. +- `Platform::new(cfg)` – Fenster + GPU-Pipeline aufsetzen. +- `poll_events(timeout)` – GLFW-Events einlesen (`None` = blockierend). +- `drain_events()` – Iterator über `Event` (`Key`, `Char`, `MouseMove`, + `MouseBtn`, `Resize`, `CloseRequested`). +- `framebuffer_mut()` – `&mut [u8]` der Länge `w*h`, je Pixel ein Paletten-Index. +- `present()` – Frame auf das Fenster bringen. +- `should_close()` / `request_close()`. + +## Paletten + +`palette::RGB332` ist als Default eingebaut. Eigene Paletten sind ein +`[[u8; 3]; 256]` (RGB pro Index) und werden über `PlatformConfig::palette` +übergeben. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..991629f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,445 @@ +//! pbio — Pixelbuffer-basiertes Fenster mit Palette-Lookup-Renderer. + +use std::collections::VecDeque; +use std::time::Duration; + +use wgpu::util::DeviceExt; + +pub mod palette; + +// --- Konfiguration --------------------------------------------------------- + +pub struct PlatformConfig { + pub title: String, + pub window_size: (u32, u32), + pub framebuffer_size: (u32, u32), + pub aspect_ratio: Option, + pub palette: [[u8; 3]; 256], + pub vsync: bool, +} + +impl Default for PlatformConfig { + fn default() -> Self { + Self { + title: "pbio".into(), + window_size: (800, 600), + framebuffer_size: (320, 240), + aspect_ratio: Some(4.0 / 3.0), + palette: palette::RGB332, + vsync: true, + } + } +} + +// --- Events ---------------------------------------------------------------- + +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Key { + A, B, C, D, E, F, G, H, I, J, K, L, M, + N, O, P, Q, R, S, T, U, V, W, X, Y, Z, + Num0, Num1, Num2, Num3, Num4, + Num5, Num6, Num7, Num8, Num9, + Up, Down, Left, Right, + Space, Enter, Escape, Tab, Backspace, + LShift, RShift, LCtrl, RCtrl, LAlt, RAlt, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, +} + +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MouseButton { Left, Right, Middle } + +#[non_exhaustive] +#[derive(Clone, Copy, Debug)] +pub enum Event { + Key { key: Key, pressed: bool, repeat: bool }, + Char (char), + MouseMove { x: f32, y: f32 }, + MouseBtn { button: MouseButton, pressed: bool }, + Resize { width: u32, height: u32 }, + CloseRequested, +} + +// Muss zum Shader-Uniform passen (32 Byte, 16-Byte-aligned). +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + src_size: [f32; 2], + content_offset: [f32; 2], + content_size: [f32; 2], + _pad: [f32; 2], +} + +// --- Platform -------------------------------------------------------------- + +pub struct Platform { + // GPU-Ressourcen vor Surface, Surface vor Window/Glfw (Drop-Reihenfolge). + bind_group: wgpu::BindGroup, + pipeline: wgpu::RenderPipeline, + index_tex: wgpu::Texture, + #[allow(dead_code)] + palette_tex: wgpu::Texture, + params_buf: wgpu::Buffer, + params: Params, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + device: wgpu::Device, + queue: wgpu::Queue, + instance: wgpu::Instance, + + window: glfw::PWindow, + events: glfw::GlfwReceiver<(f64, glfw::WindowEvent)>, + glfw: glfw::Glfw, + + framebuffer: Vec, + framebuffer_size: (u32, u32), + aspect_ratio: f32, // 0.0 = keine Letterbox + content_rect: (u32, u32, u32, u32), + event_queue: VecDeque, + close_requested: bool, +} + +impl Platform { + pub fn new(config: PlatformConfig) -> Self { + pollster::block_on(Self::new_async(config)) + } + + async fn new_async(cfg: PlatformConfig) -> Self { + use glfw::fail_on_errors; + + let mut glfw = glfw::init(fail_on_errors!()).unwrap(); + glfw.window_hint(glfw::WindowHint::ClientApi(glfw::ClientApiHint::NoApi)); + + let (mut window, events) = glfw + .create_window(cfg.window_size.0, cfg.window_size.1, &cfg.title, glfw::WindowMode::Windowed) + .expect("failed to create GLFW window"); + + window.set_framebuffer_size_polling(true); + window.set_key_polling(true); + window.set_char_polling(true); + window.set_cursor_pos_polling(true); + window.set_mouse_button_polling(true); + window.set_close_polling(true); + + let win_size = window.get_framebuffer_size(); + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + let surface = instance.create_surface(window.render_context()).expect("surface"); + + let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }).await.expect("no suitable wgpu adapter"); + + let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("pbio device"), + ..Default::default() + }).await.expect("device"); + + let caps = surface.get_capabilities(&adapter); + let format = caps.formats.iter().copied().find(|f| f.is_srgb()).unwrap_or(caps.formats[0]); + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: win_size.0 as u32, + height: win_size.1 as u32, + present_mode: if cfg.vsync { wgpu::PresentMode::Mailbox } else { wgpu::PresentMode::Immediate }, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&device, &surface_config); + + // --- Texturen --- + let (src_w, src_h) = cfg.framebuffer_size; + let index_tex = make_tex(&device, "pbio index_tex", src_w, src_h, wgpu::TextureFormat::R8Uint); + let palette_tex = make_tex(&device, "pbio palette_tex", 256, 1, wgpu::TextureFormat::Rgba8Unorm); + let index_view = index_tex.create_view(&Default::default()); + let palette_view = palette_tex.create_view(&Default::default()); + + let palette_rgba: Vec = cfg.palette.iter() + .flat_map(|[r, g, b]| [*r, *g, *b, 255]) + .collect(); + queue.write_texture( + wgpu::TexelCopyTextureInfoBase { texture: &palette_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &palette_rgba, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256 * 4), rows_per_image: None }, + wgpu::Extent3d { width: 256, height: 1, depth_or_array_layers: 1 }, + ); + + // --- Uniform --- + let aspect_ratio = cfg.aspect_ratio.unwrap_or(0.0); + let content_rect = compute_content_rect((surface_config.width, surface_config.height), aspect_ratio); + let params = Params { + src_size: [src_w as f32, src_h as f32], + content_offset: [content_rect.0 as f32, content_rect.1 as f32], + content_size: [content_rect.2 as f32, content_rect.3 as f32], + _pad: [0.0, 0.0], + }; + let params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pbio params_buf"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // --- Pipeline --- + let (bgl, pipeline) = build_pipeline(&device, surface_config.format); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pbio bind_group"), + layout: &bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: params_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&index_view) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&palette_view) }, + ], + }); + + Self { + bind_group, pipeline, index_tex, palette_tex, params_buf, params, + surface, config: surface_config, device, queue, instance, + window, events, glfw, + framebuffer: vec![0u8; (src_w * src_h) as usize], + framebuffer_size: (src_w, src_h), + aspect_ratio, content_rect, + event_queue: VecDeque::new(), + close_requested: false, + } + } + + pub fn should_close(&self) -> bool { self.close_requested || self.window.should_close() } + pub fn request_close(&mut self) { self.close_requested = true; self.window.set_should_close(true); } + + pub fn poll_events(&mut self, timeout: Option) { + match timeout { + Some(t) => self.glfw.wait_events_timeout(t.as_secs_f64()), + None => self.glfw.wait_events(), + } + // flush_messages borrowt &events, handle_glfw_event will &mut self → erst sammeln. + let drained: Vec<_> = glfw::flush_messages(&self.events).map(|(_, ev)| ev).collect(); + for ev in drained { self.handle_glfw_event(ev); } + } + + pub fn drain_events(&mut self) -> impl Iterator + '_ { + self.event_queue.drain(..) + } + + pub fn framebuffer_mut(&mut self) -> &mut [u8] { &mut self.framebuffer } + pub fn framebuffer_size(&self) -> (u32, u32) { self.framebuffer_size } + + pub fn present(&mut self) { + let (w, h) = self.framebuffer_size; + self.queue.write_texture( + wgpu::TexelCopyTextureInfoBase { texture: &self.index_tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &self.framebuffer, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w), rows_per_image: None }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + + let drawable = match self.surface.get_current_texture() { + Ok(d) => d, + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + self.surface = self.instance.create_surface(self.window.render_context()).expect("recreate surface"); + self.surface.configure(&self.device, &self.config); + return; + } + Err(e) => { eprintln!("pbio: surface error: {:?}", e); return; } + }; + + let view = drawable.texture.create_view(&Default::default()); + let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pbio encoder") }); + let (cx, cy, cw, ch) = self.content_rect; + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pbio pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + pass.set_viewport(cx as f32, cy as f32, cw as f32, ch as f32, 0.0, 1.0); + pass.set_scissor_rect(cx, cy, cw, ch); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + } + self.queue.submit([encoder.finish()]); + drawable.present(); + } + + fn handle_glfw_event(&mut self, ev: glfw::WindowEvent) { + use glfw::WindowEvent as W; + match ev { + W::FramebufferSize(w, h) => { + if w > 0 && h > 0 { + self.config.width = w as u32; + self.config.height = h as u32; + self.surface.configure(&self.device, &self.config); + self.content_rect = compute_content_rect((self.config.width, self.config.height), self.aspect_ratio); + let (x, y, cw, ch) = self.content_rect; + self.params.content_offset = [x as f32, y as f32]; + self.params.content_size = [cw as f32, ch as f32]; + self.queue.write_buffer(&self.params_buf, 0, bytemuck::bytes_of(&self.params)); + } + self.event_queue.push_back(Event::Resize { width: w.max(0) as u32, height: h.max(0) as u32 }); + } + W::Key(k, _, action, _) => if let Some(key) = from_glfw_key(k) { + self.event_queue.push_back(Event::Key { + key, + pressed: action != glfw::Action::Release, + repeat: action == glfw::Action::Repeat, + }); + } + W::Char(c) => self.event_queue.push_back(Event::Char(c)), + W::CursorPos(x, y) => { + let (cx, cy, cw, ch) = self.content_rect; + let (fw, fh) = self.framebuffer_size; + let fx = ((x - cx as f64) / cw as f64 * fw as f64) as f32; + let fy = ((y - cy as f64) / ch as f64 * fh as f64) as f32; + self.event_queue.push_back(Event::MouseMove { x: fx, y: fy }); + } + W::MouseButton(btn, action, _) => if let Some(button) = from_glfw_button(btn) { + self.event_queue.push_back(Event::MouseBtn { button, pressed: action != glfw::Action::Release }); + } + W::Close => { + self.close_requested = true; + self.event_queue.push_back(Event::CloseRequested); + } + _ => {} + } + } +} + +// --- Helpers --------------------------------------------------------------- + +fn compute_content_rect((win_w, win_h): (u32, u32), ar: f32) -> (u32, u32, u32, u32) { + let w = win_w.max(1) as f32; + let h = win_h.max(1) as f32; + if ar <= 0.0 { return (0, 0, w as u32, h as u32); } + let (cw, ch) = if w / h > ar { (h * ar, h) } else { (w, w / ar) }; + let ox = (w - cw) * 0.5; + let oy = (h - ch) * 0.5; + (ox.floor().max(0.0) as u32, oy.floor().max(0.0) as u32, + cw.floor().max(1.0) as u32, ch.floor().max(1.0) as u32) +} + +fn make_tex(device: &wgpu::Device, label: &str, w: u32, h: u32, format: wgpu::TextureFormat) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) +} + +fn from_glfw_key(k: glfw::Key) -> Option { + use glfw::Key as G; + macro_rules! same { ($($n:ident),* $(,)?) => { match k { + $(G::$n => Some(Key::$n),)* + G::LeftShift => Some(Key::LShift), G::RightShift => Some(Key::RShift), + G::LeftControl => Some(Key::LCtrl), G::RightControl => Some(Key::RCtrl), + G::LeftAlt => Some(Key::LAlt), G::RightAlt => Some(Key::RAlt), + _ => None, + }}; } + same!( + A, B, C, D, E, F, G, H, I, J, K, L, M, + N, O, P, Q, R, S, T, U, V, W, X, Y, Z, + Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9, + Up, Down, Left, Right, + Space, Enter, Escape, Tab, Backspace, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, + ) +} + +fn from_glfw_button(b: glfw::MouseButton) -> Option { + match b { + glfw::MouseButton::Button1 => Some(MouseButton::Left), + glfw::MouseButton::Button2 => Some(MouseButton::Right), + glfw::MouseButton::Button3 => Some(MouseButton::Middle), + _ => None, + } +} + +fn build_pipeline( + device: &wgpu::Device, + surface_format: wgpu::TextureFormat, +) -> (wgpu::BindGroupLayout, wgpu::RenderPipeline) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pbio shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), + }); + + let tex_entry = |binding, sample_type| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, + }, + count: None, + }; + + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pbio BGL"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), + }, + count: None, + }, + tex_entry(1, wgpu::TextureSampleType::Uint), + tex_entry(2, wgpu::TextureSampleType::Float { filterable: false }), + ], + }); + + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pbio pipeline layout"), + bind_group_layouts: &[&bgl], + push_constant_ranges: &[], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pbio pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, entry_point: Some("vs_main"), + compilation_options: Default::default(), buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + (bgl, pipeline) +} diff --git a/src/palette.rs b/src/palette.rs new file mode 100644 index 0000000..25e3bde --- /dev/null +++ b/src/palette.rs @@ -0,0 +1,19 @@ +//! Palette-Presets. + +/// RGB332-Palette: 8 Levels Rot, 8 Levels Grün, 4 Levels Blau. +/// +/// Index-Bits: `RRRGGGBB` — `r = idx / 32`, `g = (idx / 4) % 8`, `b = idx % 4`. +pub const RGB332: [[u8; 3]; 256] = { + let mut p = [[0u8; 3]; 256]; + let mut i = 0; + while i < 256 { + let r = (i / 32) as u32; // 0..=7 + let g = ((i / 4) % 8) as u32; // 0..=7 + let b = (i % 4) as u32; // 0..=3 + p[i][0] = ((r * 255) / 7) as u8; + p[i][1] = ((g * 255) / 7) as u8; + p[i][2] = ((b * 255) / 3) as u8; + i += 1; + } + p +}; diff --git a/src/shader.wgsl b/src/shader.wgsl new file mode 100644 index 0000000..c58120d --- /dev/null +++ b/src/shader.wgsl @@ -0,0 +1,32 @@ +struct Params { + src_size: vec2, + content_offset: vec2, + content_size: vec2, +} + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var src_indices: texture_2d; +@group(0) @binding(2) var palette_tex: texture_2d; + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + var pos = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0) + ); + return vec4(pos[vid], 0.0, 1.0); +} + +@fragment +fn fs_main(@builtin(position) frag_xy: vec4) -> @location(0) vec4 { + let uv = (frag_xy.xy - params.content_offset) / params.content_size; + + let sx = clamp(i32(floor(uv.x * params.src_size.x)), 0, i32(params.src_size.x) - 1); + let sy = clamp(i32(floor(uv.y * params.src_size.y)), 0, i32(params.src_size.y) - 1); + + let idx: u32 = textureLoad(src_indices, vec2(sx, sy), 0).r & 0xFFu; + let rgb = textureLoad(palette_tex, vec2(i32(idx), 0), 0).rgb; + + return vec4(rgb, 1.0); +}