简介:
本文章旨在分析我在学习和制作github上面的tinyraycaster时遇到的一些问题,以及我理解的技术原理。注:本文章并非中文教程,只是解释了一些代码的原理
文章阅读方法:进入以下github地址阅读原版教程,遇到看不懂的地方转到 此文章查阅
源地址:https://github.com/ssloy/tinyraycaster
1.图片处理
首先图像中每一个像素可以用4个值来展示,即rgba,分别代表红绿蓝和alpha通道,给与不同的分量便可以展示不同的颜色。
pack_color
uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) {
return (a<<24) + (b<<16) + (g<<8) + r;
}
这段代码输入的是rgba分量的值,其中每一个分量都是一个八位无符号数,将a左移24位,b左移16位,g左移8位再加上r,返回一个32位无符号整数代表颜色。
例如 a = 255,b =254, g = 253,r = 252
return = 11111111 11111110 11111101 11111100
unpack_color
void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) {
r = (color >> 0) & 255;
g = (color >> 8) & 255;
b = (color >> 16) & 255;
a = (color >> 24) & 255;
}
输入一个32位颜色值,将其分解成r,g,b,a分量储存。
取g来看,g = (color >> 8) & 255;
表示将color右移 8 位再和255(11111111)进行按位与,将255看作32位整数
假设颜色值color = 11111111 11111110 11111101 11111100
color >> 8 =00000000 11111111 11111110 11111101
(255)D = 00000000 00000000 00000000 11111111
进行按位与之后,前面24位全部归零,得到的结果自然就是最后8位 11111101,即g的值
drop_ppm_image:将颜色输出到图片,不解释
绘制背景图
std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red
for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients
for (size_t i = 0; i<win_w; i++) {
uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical
uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal
uint8_t b = 0;
framebuffer[i+j*win_w] = pack_color(r, g, b);
}
}
drop_ppm_image("./out.ppm", framebuffer, win_w, win_h);
首先定义了一个一维数组来存储每一个像素的信息,数组大小为win_w * win_h
接下来遍历每一行,每一个像素,对其颜色计算之后输出到图像中
需要实现颜色的渐变效果,r = 255 * j / float(win_h)
更好理解的写法是 r = j * (255 / float(win_h))
j 从 0 到 win_h 进行递增,乘以对应颜色递增的量,即可得到当前像素的颜色
因为 r 的值从上到下递增,即红色分量从上到下递增
g 从左到右递增,即绿色分量。蓝色分量b始终为0
最终输出的图像
2.绘制地图
作者使用简单粗暴的方式定义了一张16*16的地图
const size_t map_w = 16; // map width
const size_t map_h = 16; // map height
const char map[] = "0000222222220000"\
"1 0"\
"1 11111 0"\
"1 0 0"\
"0 0 1110000"\
"0 3 0"\
"0 10000 0"\
"0 0 11100 0"\
"0 0 0 0"\
"0 0 1 00000"\
"0 1 0"\
"2 1 0"\
"0 0 0"\
"0 0000000 0"\
"0 0"\
"0002222222200000"; // our game map
assert(sizeof(map) == map_w*map_h+1); // +1 for the null terminated string
其中有数字的地方代表墙壁,其余地方代表空。
1.draw rectangle
作者直接将地图分成不同的长方形,有点类似我的世界,通过在特定的位置放置长方形以进行墙壁的绘制,地图中每一个数字单元都是一个方块
void draw_rectangle(std::vector<uint32_t> &img, const size_t img_w, const size_t img_h, const size_t x, const size_t y, const size_t w, const size_t h, const uint32_t color) {
assert(img.size()==img_w*img_h);
for (size_t i=0; i<w; i++) {
for (size_t j=0; j<h; j++) {
size_t cx = x+i;
size_t cy = y+j;
assert(cx<img_w && cy<img_h);
img[cx + cy*img_w] = color;
}
}
}
此代码用于绘制长方形,传入场景(即上面定义的framebuffer),传入场景的长宽(像素单位)
长方形左上角在场景的位置(x,y),长方形的宽和高(w,h),以及长方形的颜色
遍历长方形中的每一个像素,则长方形中的像素某个像素位置为(x+i,y+j),
在场景中将此位置的像素颜色设置为color
如下图在小格子的像素图中绘制绿色的正方形
长方形的宽和高通过 如下方法计算:场景的大小 / 地图大小 (512 / 16)
const size_t rect_w = win_w/map_w;
const size_t rect_h = win_h/map_h;
绘制一整张地图
for (size_t j=0; j<map_h; j++) { // draw the map
for (size_t i=0; i<map_w; i++) {
if (map[i+j*map_w]==' ') continue; // skip empty spaces
size_t rect_x = i*rect_w;
size_t rect_y = j*rect_h;
draw_rectangle(framebuffer, win_w, win_h, rect_x, rect_y, rect_w, rect_h, pack_color(0, 255, 255));
}
}
遍历整个地图(16*16),如果遍历到墙壁,即地图中非空格位置,则计算长方形左上角在场景(512*512)中的位置,因为整个场景是被地图平均分成512/16个大格子,所以长方形在左上角的位置直接通过如下方式计算
rect_x = i * rect_w; rect_y = j * rect_h
绘制出来的图片如下:
2.添加玩家
在地图(16 * 16)中非墙壁的位置放置一个玩家,调用draw tectangle绘制一个小的正方形
float player_x = 3.456; // player x position
float player_y = 2.345; // player y position
draw_rectangle(framebuffer, win_w, win_h, player_x*rect_w, player_y*rect_h, 5, 5, pack_color(255, 255, 255));
3.射线!
1.绘制一根射线
一条射线通过以上方式计算,t 为射线的长度,通过遍历 t ,寻找当射线长度为 t 时射线在该点的像素位置
当超过射线总长度(20)或者撞到墙壁时停止前进
float player_a = 1.523; // 玩家视线方向
for (float t=0; t<20; t+=.05) {
//计算射线位置
float cx = player_x + t*cos(player_a);
float cy = player_y + t*sin(player_a);
//撞到墙壁则停止前进
if (map[int(cx)+int(cy)*map_w]!=' ') break;
//通过地图坐标(16*16)计算场景像素坐标(512*512)
size_t pix_x = cx*rect_w;
size_t pix_y = cy*rect_h;
//对应位置绘制
framebuffer[pix_x + pix_y*win_w] = pack_color(255, 255, 255);
}
绘制出来的射线如图
2.绘制一组射线
定义一个可视角度fov,角度angle
angle方向由玩家视线方向player_a决定
从 player_a - fov/2 开始发射射线,一直到player_a + fov/2
一共需要发射512根射线,以此来对应即将绘制的3d场景(512*512)中,每一列将使用一根射线来绘制
所以代码如下
for (size_t i=0; i<win_w; i++) { // 发射一组(512根)射线
float angle = player_a-fov/2 + fov*i/float(win_w);
//每一根射线跨越的角度大小为 fov / win_w(512)
绘制一根射线
}
4.3D化
1.初步3D
首先扩展图片到原先的两倍大小(1024 * 512)
左边用于绘制地图,右边用于绘制3D图像
将右边图像为512根射线分配像素列,每一根射线对应一列像素
按照透视图像近大远小的规律,当射线撞到墙壁时
对于射线长度 t 大的一列像素,绘制高度小
对于射线长度 t 小的一列像素,绘制高度大
所以绘制的高度直接取 win_h / t;绘制的长方体宽度为1
if (map[int(cx)+int(cy)*map_w]!=' ') { // our ray touches a wall, so draw the vertical column to create an illusion of 3D
size_t column_height = win_h/t;
draw_rectangle(framebuffer, win_w, win_h, win_w/2+i, win_h/2-column_height/2, 1, column_height, pack_color(0, 255, 255));
break;
注意:因为图片已经扩展到两倍大小,所以原来的一些坐标需要变换
通过改变玩家位置player_x,player_y,以及改变玩家视线方向player_a可以实现运动效果
2.处理鱼眼畸变
当你渲染出3D图像时你会发现,在观察平直的墙壁,或者近处的物体会发生很严重的扭曲,即鱼眼畸变
产生鱼眼畸变的原因:column_height = win_h / t;
这一行代码计算了3D场景中绘制的像素高度,但是其计算方法是一个反函数
查看反函数在正数范围内的图像,随着 x 增大,其 y 减小,但是随着 x 增大,y减小的速率越来越小,在 x 很小的时候 y 减小的速率非常大。其递减规律并不是线性的
所以在我们观察近处物体时,t 值很小,则通过 t 计算出来的高度变换会非常剧烈,于是产生了鱼眼现象
如何解决鱼眼畸变,作者将反函数处理成这样一个函数
column_height = win_h/(t*cos(angle-player_a))
试着分析一下,
假设当前玩家旁边有一面墙,玩家的视线方向与x轴夹角为a,玩家与墙面垂直距离为d
射线从 angle = 0 扫描到 angle = a的过程中有
此时 t = (d / cos(angle))
将式子修改一下得到
column_height = win_h/(d/cos(angle)*cos(angle-player_a))
假设当前玩家视线方向a = 1rad,d = 3,将 angle 当成自变量 x
查看函数图像有
可以看到从0到1.5之间的图像过度都非常平缓了。
而且我们只需要angle < a + fov/2 && angle > a-fov/2 的图像
于是在玩家正对着墙壁的时候,鱼眼畸变问题被完美解决
但也带来了新的问题
绘制出来的图像解决了鱼眼畸变问题,但是同样,墙壁边缘还是有和函数图像类似的变形。