Rust 像素级绘图入门:Pixels 库核心机制解析
在现代游戏开发中,我们习惯了使用已经封装好的引擎(如 Bevy 或 Godot)。但如果你想回归原始,像 80 年代的工程师一样直接控制屏幕上的每一个像素点 ,那么 pixels 库是 Rust 生态中的首选。
我们将通过一个反弹方块的极简程序,来进行Pixels 库的学习。
一、完整代码
1. 目录结构
rust
untitled8
├── src
│ └── main.rs
├── Cargo.lock
└── Cargo.toml
2. Cargo.toml
toml
[package]
name = "untitled8"
version = "0.1.0"
edition = "2024"
[dependencies]
pixels = "0.15.0"
winit = "0.30.12"
3. main.rs
rust
#![windows_subsystem = "windows"]
use pixels::{Pixels, SurfaceTexture};
use std::sync::Arc;
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
// 1. 定义逻辑分辨率
const WIDTH: u32 = 320;
const HEIGHT: u32 = 240;
struct BoxApp {
pixels: Option<Pixels<'static>>,
window: Option<Arc<Window>>,
// 方块的状态
box_x: f32,
box_y: f32,
vel_x: f32,
vel_y: f32,
}
impl ApplicationHandler for BoxApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window_attributes = Window::default_attributes()
.with_title("Pixels 入门: 碰撞方块")
.with_inner_size(winit::dpi::LogicalSize::new(640.0, 480.0));
let window = Arc::new(event_loop.create_window(window_attributes).unwrap());
// 2. 初始化 Pixels
let surface_texture = SurfaceTexture::new(
window.inner_size().width,
window.inner_size().height,
window.clone()
);
let pixels = Pixels::new(WIDTH, HEIGHT, surface_texture).expect("Pixels error");
self.pixels = Some(pixels);
self.window = Some(window);
}
fn window_event(&mut self, _event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => _event_loop.exit(),
WindowEvent::RedrawRequested => {
// 3. 更新逻辑 (物理移动)
self.update();
// 4. 执行渲染
self.draw();
self.window.as_ref().unwrap().request_redraw();
}
_ => (),
}
}
}
impl BoxApp {
fn update(&mut self) {
self.box_x += self.vel_x;
self.box_y += self.vel_y;
// 简单的碰撞检测:撞墙反弹
if self.box_x <= 0.0 || self.box_x >= (WIDTH - 20) as f32 { self.vel_x *= -1.0; }
if self.box_y <= 0.0 || self.box_y >= (HEIGHT - 20) as f32 { self.vel_y *= -1.0; }
}
fn draw(&mut self) {
if let Some(pixels) = self.pixels.as_mut() {
let frame = pixels.frame_mut();
// --- 步骤 A: 清屏 (填充背景颜色) ---
for pixel in frame.chunks_exact_mut(4) {
pixel.copy_from_slice(&[30, 30, 46, 255]); // 深蓝色
}
// --- 步骤 B: 绘制方块 (手动操作像素) ---
let box_size = 20;
for y in 0..box_size {
for x in 0..box_size {
let px = (self.box_x as i32 + x) as usize;
let py = (self.box_y as i32 + y) as usize;
// 计算在一维数组中的索引: (y * 宽度 + x) * 4字节(RGBA)
if px < WIDTH as usize && py < HEIGHT as usize {
let i = (py * WIDTH as usize + px) * 4;
frame[i..i + 4].copy_from_slice(&[255, 200, 0, 255]); // 橙黄色
}
}
}
pixels.render().unwrap();
}
}
}
fn main() {
let event_loop = EventLoop::new().unwrap();
let mut app = BoxApp {
pixels: None, window: None,
box_x: 150.0, box_y: 110.0,
vel_x: 2.0, vel_y: 2.0,
};
let _ = event_loop.run_app(&mut app);
}
二、Pixels 入门
1. 核心概念:像素缓冲区
当你使用 pixels 库时,你面对的不再是一个复杂的图形 API,而是一个巨大的一维字节数组(u8 Array) 。
每一个像素由 4 个连续的字节 组成,分别对应 R(红)、G(绿)、B(蓝)、A(透明度) 。 例如,数组中的前四个元素 [255, 0, 0, 255] 就在屏幕左上角定义了一个纯红色的像素点。
例:
rust
frame = [像素0, 像素1, 像素2, 像素3, ..., 像素76799]
每个像素占 4 个字节,所以实际的字节长度是:
rust
总字节数 = WIDTH × HEIGHT × 4
= 320 × 240 × 4
= 76,800 × 4
= 307,200 字节
2. 逻辑分辨率 和 物理分辨率
在代码开头,我们定义了两个常量:
Rust
// 1. 定义逻辑分辨率
const WIDTH: u32 = 320;
const HEIGHT: u32 = 240;
这是 逻辑分辨率 。无论你的窗口拉伸到多大(物理分辨率),你在代码中处理的永远只有这 <math xmlns="http://www.w3.org/1998/Math/MathML"> 320 × 240 320 \times 240 </math>320×240 个点。pixels 会自动处理缩放,并保持像素的"颗粒感"。
3. 初始化渲染环境
在 ApplicationHandler 的 resumed 方法中,是渲染环境诞生的时刻。这不仅仅是几行初始化代码,它实际上建立了一条逻辑像素 -> GPU 纹理 -> 窗口屏幕的映射通道。
3.1 创建表面纹理 (SurfaceTexture)
Rust
let surface_texture = SurfaceTexture::new(
window.inner_size().width,
window.inner_size().height,
window.clone()
);
SurfaceTexture 是像素点的"目的地"。它关联了窗口的实际物理尺寸。
- 窗口像素不等于逻辑像素 :你的显示器可能是 4K 的,但你的游戏逻辑可能只需要 320x240。
SurfaceTexture负责捕捉窗口的大小变化,为后续的自动缩放做准备。
3.2 初始化 Pixels 实例
Rust
let pixels = Pixels::new(WIDTH, HEIGHT, surface_texture).expect("Pixels error");
这是最关键的一步。Pixels::new 在内存中开辟了一个巨大的一维字节数组。
- 参数 1 & 2 (
WIDTH , HEIGHT ) :定义了你的"画布"有多大。 - 内部机制 :
pixels库会在后台创建一个 GPU 纹理(Texture)。当你调用渲染时,它会通过 WGPU(Rust 的高性能图形库)将你内存中的数组数据以极快的速度"推"给显卡。
3.3 理解"映射通道"的三个层级
| 层级 | 表现形式 | 职责 | 示例 |
|---|---|---|---|
| 逻辑层 (CPU) | Vec<u8>(字节数组) |
负责计算"这个点应该是红色还是蓝色" | frame[i..i+4].copy_from_slice(...) |
| 中转层 (GPU) | Texture (纹理) | 将数组转化为显存中的图像,利用硬件加速缩放 | pixels.render() |
| 物理层 (屏幕) | Window (窗口) | 最终呈现在用户眼前的光点 | window.request_redraw() |
4. 核心绘图循环:操作颜色字节数组
很多图形库会提供 draw_rect 或 draw_circle 这样的高级 API,但 pixels 不同。它极其纯粹:它只给你一个巨大的、一维的 [u8] 字节数组。
你的所有绘图行为,本质上都是在修改这个数组里的数字。
4.1 内存中的像素长什么样?
当你初始化了一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> 320 × 240 320 \times 240 </math>320×240 的画布时,pixels 会在内存中准备一个长度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 320 × 240 × 4 = 307 , 200 320 \times 240 \times 4 = 307,200 </math>320×240×4=307,200 字节的缓冲区。
每 4 个字节 构成一个像素点,分别代表 RGBA:
- R (Red): 红色通道 (0-255)
- G (Green): 绿色通道 (0-255)
- B (Blue): 蓝色通道 (0-255)
- A (Alpha): 不透明度 (0-255)
4.2 清屏:批量操作内存
在每一帧开始时,我们通常需要擦除上一帧的内容。这在代码中表现为一次全数组的遍历:
Rust
let frame = pixels.frame_mut(); // 获取内存缓冲区的可变引用
// 将数组按每 4 个元素(一个像素)切分,并进行填充
for pixel in frame.chunks_exact_mut(4) {
pixel.copy_from_slice(&[30, 30, 46, 255]); // 赋予一个深色背景
}
这里利用了 Rust 的 chunks_exact_mut,它能高效地确保我们每次处理的都是完整的 RGBA 单元,避免了手动计算索引的繁琐。
4.3 定点打击:坐标到索引的数学转换
显示器是一个二维平面 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y ) (x, y) </math>(x,y),但内存是线性的。如何精准地找到屏幕上坐标为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( p x , p y ) (px, py) </math>(px,py) 的那个像素在数组里的位置?
我们需要一套空间到索引的转换公式:
Rust
// 计算在一维数组中的起始索引
let idx = (py as usize * WIDTH as usize + px as usize) * 4;
// 写入颜色数据
frame[idx..idx+4].copy_from_slice(&[255, 200, 0, 255]); // 橙黄色
公式拆解:
-
py * WIDTH :每一行有WIDTH个像素。如果你想在第py行画点,你必须先跳过前面整整py行的所有像素。 -
+ px :在当前行中,向右平移px个像素,找到目标点。 -
* 4:因为每个像素在内存里实际上占用了 4 个字节,所以最终的字节偏移量需要乘以 4。
4.4 "原始"操作方式的特点
这种"原始"的操作方式赋予了开发者极高的自由度:
- 性能极高:你直接操作的是内存,没有复杂的对象封装和函数调用开销。
- 算法透明:无论是画线、画圆,还是实现复古的滤镜效果(如扫描线、噪声),本质上都是在编写逻辑来决定哪些索引应该填入什么颜色。
- 所见即所得 :当你调用
pixels.render()时,显卡会直接读取这块缓冲区,将其投射到屏幕上。
三、Pixels 进阶
1. 碰撞检测:赋予像素物理规则
有了画布和像素操作方法,下一步就是让物体"动起来"。在示例代码中,方块的移动和反弹逻辑集中在 update() 方法里。这其实是一个极简的物理引擎模拟。
1.1 运动的基本原理
在每一帧渲染之前,我们都会更新方块的坐标。
Rust
fn update(&mut self) {
// 每一帧,方块根据当前速度向量进行位移
self.box_x += self.vel_x;
self.box_y += self.vel_y;
}
1.2 边缘碰撞检测 (AABB Collision)
如果只让坐标增加,方块很快就会飞出屏幕。为了让它留在逻辑分辨率(320x240)内,我们需要设置"无形的墙"。
Rust
// 简单的碰撞检测:撞墙反弹
// 检查水平边界 (X轴)
if self.box_x <= 0.0 || self.box_x >= (WIDTH - 20) as f32 {
self.vel_x *= -1.0; // 速度取反,实现反弹效果
}
// 检查垂直边界 (Y轴)
if self.box_y <= 0.0 || self.box_y >= (HEIGHT - 20) as f32 {
self.vel_y *= -1.0;
}
逻辑要点:
- 左侧/上侧边界 (0.0) :当方块坐标小于或等于 0 时,说明触碰了左边或顶边。
- 右侧/下侧边界 (WIDTH - size) :注意,方块的坐标通常是它的左上角。因此,检测右边界时,需要减去方块自身的尺寸(这里是 20 像素),否则方块会有一部分穿过墙壁才反弹。
- 反弹原理 :通过
vel *= -1.0,我们将速度向量瞬间反向。如果原本向右跑(速度为正),撞墙后立刻变为向左跑(速度为负)。
1.3 从逻辑坐标到渲染的安全检查
在 draw() 函数中,有一个非常重要的细节,它防止了程序的崩溃:
Rust
if px < WIDTH as usize && py < HEIGHT as usize {
let i = (py * WIDTH as usize + px) * 4;
frame[i..i + 4].copy_from_slice(&[255, 200, 0, 255]);
}
为什么需要这个 if 判断?
虽然我们在 update 中做了碰撞检测,但在处理浮点数转整数(f32 到 usize)时,可能会因为精度问题或逻辑微调产生超出边界的索引。
- 如果访问了
frame数组之外的下标,Rust 会触发panic导致程序崩溃。 - 这个
if语句就像一个护栏,确保只有在画布范围内的像素才会被写入颜色。
四、结语:从像素点开始,构建你的无限世界
通过这个简单的"碰撞方块"项目,我们不仅学会了如何使用 pixels 库,更重要的是,我们重新审视了图形开发的本质:所有的视觉奇观,归根结底都是内存中字节的艺术。
虽然现代引擎提供了无数开箱即用的高级功能,但掌握这种"底层像素操作"的能力,就像是学会了分子层面的化学反应。无论是开发复古风格的独立游戏、编写自定义的图像处理算法,还是仅仅为了深入理解计算机图形学,pixels 库都为你提供了一个纯粹且高效的实验室。