0%

上一章完成了相机类的实现,对之前所学的内容进行了封装与整理,现在要学习新的内容。

抗锯齿

我们放大之前渲染的图片,往往会发现我们渲染的图像边缘有尖锐的”阶梯”性质。这种阶梯状被称为”锯齿”。当真实的相机拍照时,边缘通常没有锯齿,这是因为真实的边缘时前景和背景颜色的混合,而不是简单的二值化。而且真实的世界是连续的,而我们渲染的图像是离散的,也就是说真实世界具有无限的分辨率 ,而我们的=图像的分辨率是有限的。我们可以通过对每个像素进行多次采样并取平均值,来近似实现此效果。

我们目前采用的采样方式被称为”点采样“,即在每个像素的中心发射一条射线来进行采样,但是同时也面临一个问题,当我们对较远的地方的图像进行采样时,可能会出现”非黑即白”的情况。但实际上我们应该看到的是灰色,而不是黑白分明的点。这是我们的眼睛很自然的对远处的图形进行了处理,而这种处理正是我们想要的效果。

所以为了解决这个问题,我们采用”多采样”的方式,来对我们的图片实现采样。我们需要对像素周围的光线进行采样,然后对采样的结果进行整合,以近似真实世界的连续效果。

为了实现这种效果,我们采用最为简单的方式,我们对以像素为中心的正方形区域进行采样,这个正方形区域会延伸到每个相邻的像素的一般位置。这个方法可能效果一般,但是便于实现,具体的内容可以参考文献像素不是一个小方块,下面是一个多采样草图

image.png

数学工具:随机数生成

为了实现函数的多采样,我们需要一个能够返回真实随机数的随机数生成器。这个函数为我们返回一个(0,1)的随机数,我们可以使用标准库<cstdlib>中的std::rand()函数,这个函数会返回一个[0,RAND_MAX]之间的随机整数。我们通过以下改动,可以得到真正的随机数函数,我们写在rtweekend.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <limits>
#include <memory>
...
//实用函数
inline double degree_to_radius(double degrees){
return degrees * pi / 180.0;
}
inline double random_double(){
//返回[0,1)的数
return std::rand() / (RAND_MAX + 1.0);
}
inline double random_double(double min,double max){
//返回[min,max)的数
return min + (max - min)*random_double();
}

不过由于rand()具有随机性较差等特点,所以在C++11标准下有其他的随机数函数写法:

1
2
3
4
5
6
7
8
9
...
#include <random>
...
inline double random_double(){
static std::uniform_real_distribution<double> distribution(0.0,1.0);
static std::mt19937 generator;
return distribution(generator);
}
...

不过看不太懂就是了

使用多采样式生成像素

对于由多个样本组成的像素,我们将从像素周围的区域选择样本,并将颜色(光值)平均在一起

我们需要更新我们的write_color()函数以考虑我们的样本数量,不过在此之前,我们需要添加一个用于区间判断的辅助函数interval::clamp(x),以确保最终的结果的颜色分量保持在正确的[0,1]范围:

1
2
3
4
5
6
7
8
9
class interval{
public:
...
//包含
double clamp(double x) const{
if(x < min) return min;
if(x > max) return max;
return x;
}

接下来我们更新write_color()函数,其包含区间的限制功能:

1
2
3
4
5
6
7
8
9
10
11
  void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
//使用区间RGB[0,1]计算RGB值
static const interval intensity(0.000,0.999);
int rbyte = int (256*intensity.clamp(r));
int gbyte = int (256*intensity.clamp(g));
int bbyte = int (256*intensity.clamp(b));
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

这样保证了我们的的rgb分量不会超出[0,1]的范围,这样更加安全。

接下来我们需要更新相机类,以定义和使用一个新的camera::get_ray(i,j)函数,该函数将为每个像素生成不同的样本。这个函数将使用一个新的辅助函数sample_squred(),该函数在以原点为中心的正方形内生成一个随机样本点。然后我们将这个正方形中的随机样本转换为我们当前正在采样的特定像素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H

#include "hittable.h"

class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
color pixel_color(0,0,0);
for(int sample = 0;sample < samples_per_pixel; sample++){
ray r = get_ray(i,j);
pixel_color += ray_color(r,world);
}
write_color(std::cout,pixel_color*pixel_samples_scale);
}
}
std::clog << "\rDone. \n";
}

private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto viewport_height = 2.0;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}

ray get_ray(int i,int j){
//构造一个从原点开始的随机采样射线,指向(i,j)像素

auto offset = sample_square();
auto pixel_sample = pixel00_loc + ((i+offset.x())*pixel_delta_u) + ((j+offset.y())*pixel_delta_v);

auto ray_origin = camera_center;
auto ray_direction = pixel_sample - ray_origin;

return ray(ray_origin,ray_direction);
}

vec3 sample_square() const {
//返回一个一个随机的点,在[-0.5,-0.5]~[+0.5,+0.5]的单位矩阵内
return {random_double() - 0.5, random_double() - 0.5,0};
}

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,interval(0,infinity),rec)){
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}
};

#endif //RENDER_C___CAMERA_H

这是新的camera,我们更新了get_ray()sample_square(),还有新的公有私有属性

接下来设置一下主函数的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

int main(){
hittable_list world;
world.add(make_shared<sphere>(point3(0,0,-1),0.5));
world.add(make_shared<sphere>(point3(0,-100.5,-1),100));

camera cam;

cam.aspect_radio = 16.0/9.0;
cam.image_width = 400;
cam.samples_per_pixel = 100; // 设置采样次数

cam.render(world);
}
image.png

这里可以看到左图的锯齿明显减少了,我们的抗锯齿设置的十分成功,今天就到此为止了

Camera类

我们之前学了很多的图形学知识和相关的程序,现在我们停下脚步,来好好整理一下我们学习的内容,我们将之前的视口代码和渲染代码合并到一个新的单类camera.h,这个类主要负责两项任务:

  • 构建并发射光线到世界中
  • 使用光线的信息来构建渲染图像

这次的重构,我们收集以下几个功能:

  • ray_color()
  • 图像设置
  • 相机设置
  • 渲染

新的相机类将包含两个公有方法:initialize()render() 以及两个私有辅助方法 get_ray()ray_color()

相机类的设计应该遵循尽可能的简单的方式,让我们在后续使用时操作尽可能的简单,使用默认构造函数,且避免复杂的初始化过程。同时允许用户通过直接赋值改变公共变量,避免复杂的setter方法。并且在渲染函数开始时,自动的调用initialize()操作,避免用户操作的复杂性。

现在我们先搭建其camera类的框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H
#include "hittable.h"
class camera{
public:

//这里设置公有的属性

void render(const hittable& world){
}
private:

//这里放置私有的属性

void initialize(){
}

color ray_color(const ray&r,const hittable& world) const{
}
};
#endif //RENDER_C___CAMERA_H

然后一一将我们的方法和属性完善首先是将main中的上色部分ray_color移动到里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class camera {
...
private:
...
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
#endif

然后还有剩下的相机的建立和图像的设置也移动到里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#ifndef RENDER_C___CAMERA_H
#define RENDER_C___CAMERA_H

#include "hittable.h"

class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r,world);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

private:
int image_height; //渲染图像的高度
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto viewport_height = 2.0;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,interval(0,infinity),rec)){
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}
};

#endif //RENDER_C___CAMERA_H

我们使用新的类来实现对main函数的简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "rtweekend.h"

#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"

int main(){
hittable_list world;
world.add(make_shared<sphere>(point3(0,0,-1),0.5));
world.add(make_shared<sphere>(point3(0,-100.5,-1),100));

camera cam;

cam.aspect_radio = 16.0/9.0;
cam.image_width = 800;

cam.render(world);
}

这样的操作极大的简化了后续我们的图形的渲染,你看这是渲染出来的放大版:

image.png

那么这一章就到此为止啦

感觉学的还是有点慢啊,刚刚往后看了一点,感觉后面的知识越来越难,我得赶快点学。

法向量和多物体处理

根据法向量进行表面着色

我们需要计算出表面法线进而进行着色。(法线是垂直于交点处的表面的向量)

法向量的计算使用决策有以下两种情况:

  • 法向量可以有任意长度,这些长度可以附带额外的信息,比如反射啥的
  • 法向量全部进行单位化,此时法向量仅仅代表其法线方向

我们可以看到法线的单位化过程中有开方操作,这个操作可能会花费较多的时间,但是我们仍然保留它,理由有三:

  • 你不应该等到要用的时候再进行单位化,设置条件可能会导致程序更加复杂
  • 我们会经常用到单位化的法向量
  • 法向量的单位化比想象中的简单,根据特定的几何类可以定制不同的单位化策略,如在球体中可以通过除以radius实现法向量的单位化

综上考虑,我们对所有的法向量都进行单位化。

球体的外法线的计算方式也很简单:

1
2
vec(d) = point(P) - point(C)
unit(vec(d)) = vec(d)/radius
image.png

由于我们的单位向量的大小是1也就是说,单位法向量在其他任意方向的分量范围都在[-1,1]内,所以我们可以将其每个分量都映射到[0,1]的区间内,然后将(x,y,z)映射到 (red,green,blue)

我们需要计算出t从而计算出法向量的信息,我们确保球体是在摄像机的前方,所以不必考虑t的负值。当t有多个解的时候,我们只考虑离我们最近的交点(最小的t值),这样我们可以计算出我们的法向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
//判断delta值,并计算t(这里计算的是较小的t)
if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

color ray_color(ray & r){
auto t = hit_sphere(point3(0,0,-1),0.5,r);
//根据t的分量进行上色
if(t > 0.0){
vec3 N = unit_vector(r.at(t) - point3(0,0,-1));
return 0.5*color(N.x()+1,N.y()+1,N.z()+1);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将程序修改后运行渲染得到了新的图片:

image.png

光线计算的简化

我们查看原本的交点计算方程:

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (-b - std::sqrt(discriminant)) / (2.0*a);
}
}

我们注意到b中有一个-2的因子,那么我们考虑将其分离出来,令b = -2h

于是方程可以进行以下变换实现简化:

image.png

同时我们知道向量的自己和自己的点积,等同于向量的平方dot(vec(v),vec(v)) = vec(v)^2

所以我们的程序可以改进成以下形式(没看出改进太多啊):

1
2
3
4
5
6
7
8
9
10
11
12
13
double hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;

if(discriminant < 0.0){
return -1.0;
}else{
return (h - std::sqrt(discriminant)) / a;
}
}

可被(光线)击中类的抽象

接下来我们需要考虑当有多个球体的情况,你可能觉得很简单,可以创建一个数组来实现,但实际上有更好的选择——创建一个抽象类,这里我们称其为hittable类。

这个抽象类将接受ray作为参数。同时我们添加一个有效的命中区间(t_min到t_max),这样便于我们对t的计算,只有在有效命中区内,t才被采用,命中才计数。同时我们还要考虑一个问题,那就是我们只需要考虑并计算离我们较近的东西的法线,所以我们需要采取一些方案来计算他们,以下是一个抽象类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

#ifndef RENDER_C___HITTABLE_H
#define RENDER_C___HITTABLE_H

#include "ray.h"

//用于存储光线与物体相交的重要信息
class hit_record{
public:
point3 p;
vec3 normal;
double t;
};

class hittable{
public:
virtual ~hittable() = default;
//纯虚函数,用来实现函数的多态性
virtual bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const = 0;
};

#endif //RENDER_C___HITTABLE_H

在此基础上实现一个球体的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

#ifndef RENDER_C___SPHERE_H
#define RENDER_C___SPHERE_H

#include "vec3.h"
#include "hittable.h"

class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {}
//fmax返回两个浮点数参数较大的一个,fmin同理
bool hit(const ray& r, double ray_min,double ray_max,hit_record& rec) const override{
vec3 oc = center - r.origin(); // override 重写基类的虚函数
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(root <= ray_min || ray_max <= root){
root = (h + sqrtd) / a;
if(root <= ray_min || ray_max <= root)
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius;

return true;
}

private:
point3 center;
double radius;
};


#endif //RENDER_C___SPHERE_H

前表面与后表面

我们先前提到了关于法线的几个决策,现在我们需要决定法线是否应该始终指向外面。当然,我们可以利用法线始终指向外面的方式来进行光线来向的判断,并帮助我们区分前后表面。

image.png

以这张图为例,我们可以通过计算光线方向和法线(始终朝外)的点积,并判断它的正负来实现其方向的判断。如果其点积为正,说明光线与法线同向,照射到的是内表面。如果点积为负,说明光线与法线反向,照射到的是外表面。我们可以通过以下程序实现判断:

1
2
3
4
5
6
7
8
9
10
bool front_face;
if(dot(ray_direction,outward_normal) > 0.0){
// 光线在球体内部
normal = -outward_normal; //实际的法线方向,需要翻转
front_face = false;
}else{
// 光线在球体外部
normal = outward_normal;
front_face = true;
}

我们将其添加到我们的hittable.hsphere中:

1
2
3
4
5
6
7
8
9
10
11
12
13
class hit_record{
public:
point3 p;
vec3 normal;
double t;
bool front_face;

void set_face_normal(const ray& r,const vec3& outward_normal){
//设置交点的法线方向
front_face = dot(r.direction(),outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class sphere : public hittable {
public:
...
bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const {
...

rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);

return true;
}
...
};

可击中对象的列表

我们之前创建了一个抽象类hittabel.h,用来描述光线可以相交的物体。现在我们再创建一个类来存储具有hittable特性的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

#ifndef RENDER_C___HITTABLE_LIST_H
#define RENDER_C___HITTABLE_LIST_H

#include "hittable.h"

#include <memory>
#include <vector>

using std::make_shared;
using std::shared_ptr;

class hittable_list : public hittable {
public:
std::vector<shared_ptr<hittable>> objects;
// 创建了一个可击中对象的数组
hittable_list(){}
hittable_list(shared_ptr<hittable> object) {add(object);}

void clear() {objects.clear();}

void add(shared_ptr<hittable> object) {
objects.push_back(object);
}

bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const override{
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_tmax;

for(const auto& object : objects){
if(object->hit(r,ray_tmin,closest_so_far,temp_rec)){
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};

#endif //RENDER_C___HITTABLE_LIST_H

这里有些地方用到了C++的一些特性,需要特别强调一下。

shared_ptr<> 是一个智能指针,被包含在<memory>中,用来指向已经分配的类型,它可以自动管理内存,当对象不再被任何shared_ptr引用时,自动释放内存。我们可以使用make_shared<thing>(...)为数据类型thing创建一个实例,并返回一个shared_ptr<thing>例如:

1
2
3
auto double_ptr = make_shared<double>(0.41);
auto vec3_ptr = make_shared<vec3>(1.1,1.2,1.3);
auto sphere_ptr = make_shared<sphere>(point3(0,0,0),1.0)

还有一个需要注意的用到的特性是std::vector,其被包含在<vector>中,其用来生成一个动态数组,并且支持对其集合仅从指定的操作。例如:

1
2
3
4
5
6
7
8
9
std::vector<shared_ptr<hittable>> objects;
//添加一个值
objects.push_back(object);
//删除所有值
objects.clear();
//数组大小
objects.size();
//访问
objects[]

大概涉及到这些吧,剩下的之后再慢慢了解

常用常量和实用函数

我们设置一个头文件,我们放置一些常用的数学常量和一些常用的头文件,这样方便我们的调取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

#ifndef RENDER_C___RTWEEKEND_H
#define RENDER_C___RTWEEKEND_H

#include <cmath>
#include <iostream>
#include <limits>
#include <memory>

using std::make_shared;
using std::shared_ptr;

//常量设置
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

//实用函数
inline double degree_to_radius(double degrees){
return degrees * pi / 180.0;
}

//常用文件头
#include "color.h"
#include "ray.h"
#include "vec3.h"

#endif //RENDER_C___RTWEEKEND_H

现在我们的rtweekend.h里面已经包含了大多数常用的文件头,

我们现在利用上述新创建文件头,写一个新的main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//这里我们修改了一下头文件还有ray_color函数
#include "rtweekend.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"

#include <iostream>

color ray_color(ray & r,const hittable& world){
hit_record rec;
if(world.hit(r,0,infinity,rec)){ //只有t在(0,+无穷)的时候才成立
return 0.5*(rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y()+1.0);
return (1.0 - a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//球体列表
hittable_list world;

world.add(make_shared<sphere>(point3(0,0,-1),0.5));
world.add(make_shared<sphere>(point3(0,-100.5,-1),100));

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r,world);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

这个是我们渲染出来的新图片,我们设置了一个大的球体在下面作为一个地面。

image.png

区间类

我们在判断是否击中的hit()函数中我们经常设置一个区间(tmin,tmax),为了方便之后的使用,我们将这个区间类给抽象出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

#ifndef RENDER_C___INTERVAL_H
#define RENDER_C___INTERVAL_H

#include "rtweekend.h"

class interval{
public:
double min,max;
//默认区间是空的
interval() : min(+infinity),max(-infinity) {}

interval(double min,double max): min(min),max(max) {}

double size() const{
return max - min;
}
//闭区间
bool contains(double x) const {
return min <= x && x <= max;
}
//开区间
bool surrounds(double x) const{
return min < x && x < max;
}

static const interval empty,universe;
};

const interval interval::empty = interval(+infinity,-infinity);
const interval interval::universe = interval(-infinity,+infinity);

#endif //RENDER_C___INTERVAL_H

我们现在可以用这个头文件去更新之前用到了区间的程序

1
2
3
4
5
6
//hittable.h
class hittable {
public:
...
virtual bool hit(const ray& r, interval ray_t, hit_record& rec) const = 0;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//hittable_list.h
class hittable_list : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_t.max;

for (const auto& object : objects) {
if (object->hit(r, interval(ray_t.min, closest_so_far), temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}

return hit_anything;
}
...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//sphere.h
class sphere : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
...

auto root = (h - sqrtd) / a;
if (!ray_t.surrounds(root)) {
root = (h + sqrtd) / a;
if (!ray_t.surrounds(root))
return false;
}
...
}
...
};
1
2
3
4
5
6
7
8
9
10
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

以上就是需要更新的地方啦

这一篇的内容比较多,写了差不多两天,得好好消化一下啦。

学到一半区研究旋转立方体去了,确实好玩,受益匪浅啊,刚好搞明白了上一章相机的设置和视图的投影,现在继续学习。

添加一个球

我们现在向我们的光线追踪器中添加一个物体。我们从添加一个球体开始(因为它是最容易分析实现的)

射线-球面的交汇

这一部分将有大量的公式,但是我现在还没有搞明白Latex的渲染问题(这个太麻烦了,我已经试过几次了),可能会用比较丑陋方式来表达或者直接上截图

我们知道球体的表达式:

1
x^2 + y^2 + z^2 = r^2

当球心为C(C_x,C_y,C_z)时,我们有:

1
(C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

但是这个是数值上的运算,我们需要想办法将它转换成向量vec3的形式,这里观察可得,实际上我们可以用一点P(x,y,z)来表示球体上的任意一点,也就是说从CP向量,可以用point(C)-point(P)来表示,现在我们可以用向量的概念去理解这个式子:

1
(point(C)-point(P))*(point(C)-point(P)) = (C_x - x)^2 + (C_y - y)^2 + (C_z - z)^2 = r^2

现在将射线的路径和球体的方程式联立起来:

1
2
3
4
5
6
P(t) = Q + td
(point(C)-point(P))*(point(C)-point(P)) = r^2

联立得:

t^2*vec(d)*vec(d) - 2*t*(point(C)-point(Q)) + (point(C)-point(Q))*(point(C)-point(Q)) - r^2 = 0

这是一个关于t的一元二次方程,我们可以根据医院二次方程的求解方程式来计算t,并得到射线与球体的相交情况(注意以下的乘法都是点乘):

1
2
3
4
5
6
求解方程式:
x = (-b +- sqrt(b^2 - 4*a*c))/(2*a)
根据联立方程式得到的数值:
a = vec(d)*vec(d)
b = -2*vec(d)*(point(C)-point(Q))
c = (point(C)-point(Q))*(point(C)-point(Q)) - r^2

带入以上的数据就可以解出t,同时可以判断光线与球体的相交情况

image.png

创建一个带有小球的光追图像

我们现在将刚刚计算得到的数学公式硬编码进入我们的程序,并设置球体的中心在(0,0,-1),半径为0.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool hit_sphere(const point3& center,double radius,const ray& r){
vec3 oc = center - r.origin(); //计算(point(C)-point(Q))
auto a = dot(r.direction(),r.direction());
auto b = -2.0 * dot(r.direction(),oc);
auto c = dot(oc,oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
return (discriminant >= 0);
}

color ray_color(ray & r){
if(hit_sphere(point3(0,0,-1),0.5,r))
return {1,0,0};

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们将这一部分添加进入先前的代码可以得到我们渲染出来的图像,中间的红色是我们添加的球体。

image.png

不过此时程序仍然存在一个问题,我们并不能区分摄像机前后的物体,由于射线的对称性,当球体位于(0,0,1)的时候,存在t的解为负数,导致渲染出相同的图片。

同时,我们现在还不能对物体进行前后的判断,还有阴影,反射光线等功能还有多个物体的共存,我们在接下来的学习中,慢慢尝试解决这个问题,今天就到此为止啦。

看到一个视频,对其内容比较感兴趣,刚好今天没什么事,我们来深入解析一下这个项目

视频链接:终端字符旋转立方体

参考链接:Donut math: how donut.c works

这是我们最终想要实现的效果(一个旋转的立方体):

image.png

旋转

怎么让一个立方体转起来,我们是怎么实现的?实际上这里用到的是线性代数的几何原理。

这里直接给出实现方法,通过三个旋转矩阵对向量进行三次线性变换,从而实现对点的旋转效果:

  • 我们以正方体的几何中心作为原点,此时正方体表面的任意一点可被表示为向量(i, j, k)
  • 现在使用通过绕X轴旋转的旋转矩阵,来实现变换image.png
  • 想要对于Y轴和Z轴的旋转的话,乘以对应的旋转矩阵即可实现image.png

这样我们就通过矩阵乘法实现了向量旋转的效果,我们可以得到旋转后的点的位置

这里我们使用wiki百科中的内旋矩阵来实现我们的计算image.png

接下来我们可以写出以下代码来计算我们的在任意时刻的点位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double A,B,C;
float calculateX(int i,int j,int k){
return i*cos(A)cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(int i,int j,int k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculteZ(int i,int j,int k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}

现在我们对立方体的模拟已经完成了,接下来我们需要想办法将其投影到我们的视图上面。

投影

我们根据这张图来进行解释image.png

这个object就是我们模拟出来的物体,也就是我们的正方体。screen是我们的屏幕,我们现在根据投影将物体投射在我们的屏幕上呈现出我们看到的效果。

这里可以看到得到等式关系y/z = y0/z0也就是说y0 = y*(z0/z)其中z0的值是保持不变的,我们将其设置为K1。我们就可以用等式xp = K1*ooz*x*2;yp = K1*ooz*y来表示它的坐标(这里xp需要乘2是因为终端中的字符有一定的长宽比,如果不乘以2,则投影出来的效果会比较窄)。

所以我们就可以写出我们的投影函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 变量
int idx;
int xp,yp;
float ooz;
float cubeWidth = 16; //正方体大小
int width = 160,height = 44; //视口大小
int distanceFromCamera = 60; //视口距离物体几何中心的距离
int K1 = 40; //相机到视口的距离
float zbuffer[160*44];
char buffer[160*44];

void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
//原点(0,0,0)实际上是相机的位置(即视点)
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z; //one over zero
xp = (int)(width/2 + K1*ooz*x*2);//这里乘2平衡字符的宽高比
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width; //计算在数列中的位置
if(idx >= 0 && idx < width*height){
// z缓冲
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}

以上就是我们的投影函数了,你可能会注意到这一部分:

1
2
3
4
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}

这就是大名鼎鼎的Z缓冲技术(Z-buffering),如果没有这个程序可能我们投影出来的效果就会是透视的,我们看物体的前面却能看到物体的背面,这是因为后面的字符覆盖到了前面的字符。所以在这里我们需要对每个点进行深度检测,即计算他们的ooz,如果z越大说明离得越远,反之则说明离得近。每次进行缓冲区的覆写之前我们都先对其做一次判断,如果当前Z的深度大于先前的点,则忽略。

这样的设计确保只有最近的表面可见,避免远处的物体错误的覆盖近处的物体。

展示

现在我们已经完成了我们的模拟函数和渲染和函数,接下来就需要进行一系列的初始化,让正方体动起来

我们直接将main函数给出,然后进行解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;
int main(){
printf("\x1b[2j"); //cls的转义序列,用于清屏
while(1){
memset(buffer,backgroundASCIIcode,width*height);//设置背景为' '
memset(zbuffer,0,width*height*4); //设置深度为无限深度
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H"); //将光标置于左上角的转义序列,确保打印的视图稳定完整
printf("\x1b[?25l");//隐藏光标的转义序列,保证观看的优美
fflush(stdout);
//用于打印缓冲图像,并且用三元运算符号判断换行时机
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
//设置转动
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

你可能会对这一部分代码感到困惑:

1
2
3
4
5
6
7
8
9
10
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}

实际上这是对正方体的初始化,和对其点位的跟踪计算,我们将正方体的几何中心视作原点(0,0,0),那么根据这个原点建系。Z轴指向垂直向内,Y轴指向竖直向上,X轴指向水平向右。那么我们就可以确定正方形六个面的位置了:

固定坐标 变化的坐标 几何意义
前面 Z = -10 XY 靠近屏幕的面(Z最小)
后面 Z = +10 XY 远离屏幕的面(Z最大)
右面 X = +10 YZ 立方体右侧的面(X最大)
左面 X = -10 YZ 立方体左侧的面(X最小)
顶面 Y = +10 XZ 立方体顶部的面(Y最大)
底面 Y = -10 XZ 立方体底部的面(Y最小)

至于CubeX和CubeY的值则是用来确定位置,以确保方向一定。

这么解释理解起来比较麻烦,实际上你仍然可以理解成一个矩阵变换的过程:

  • 我们设当前面上一点的位置为(x,y,z)
  • 那么右面上的点的表示即为,绕Y轴旋转90度的旋转矩阵的线性变换
  • 其他五个面同理,我们可以得到以下的线性关系:
面上一点的表示
前面 (x,y,z)
后面 (-x,y,-z)
右面 (-z,y,x)
左面 (z,y,-x)
顶面 (x,z,-y)
底面 (-x,z,y)

然后依次进行计算即可,简而言之这是一个将正方体内的坐标系转换到3D世界中的坐标系的一个过程

至此我们的程序就完成了,效果见下图:

image.png

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include<stdio.h>
#include<math.h>
#include<unistd.h>

int idx;
int xp,yp;
float ooz;
float x,y,z;
double A,B,C;
float cubeWidth = 16;
int width = 160,height = 44;
int distanceFromCamera = 60;
int K1 = 40;
float zbuffer[160*44];
char buffer[160*44];
int backgroundASCIIcode = ' ';
float increamentSpeed = 0.5;

float calculateX(float i,float j,float k){
return i*cos(A)*cos(B) +
j*cos(A)*sin(B)*sin(C) - j*sin(A)*cos(C) +
k*cos(A)*sin(B)*cos(C) + k*sin(A)*sin(C);
}
float calculateY(float i,float j,float k){
return i*sin(A)*cos(B) +
j*sin(A)*sin(B)*sin(C) + j*cos(A)*cos(C) +
k*sin(A)*sin(B)*cos(C) - k*cos(A)*sin(C);
}
float calculateZ(float i,float j,float k){
return -i*sin(B) +
j*cos(B)*sin(C) +
k*cos(B)*cos(C);
}
void calculateForSurface(float cubeX,float cubeY,float cubeZ,char ch){
x = calculateX(cubeX,cubeY,cubeZ);
y = calculateY(cubeX,cubeY,cubeZ);
z = calculateZ(cubeX,cubeY,cubeZ) + distanceFromCamera;

ooz = 1/z;
xp = (int)(width/2 + K1*ooz*x*2);
yp = (int)(height/2 + K1*ooz*y);

idx = xp + yp*width;
if(idx >= 0 && idx < width*height){
if(ooz > zbuffer[idx]){
zbuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}


int main(){
printf("\x1b[2j");
while(1){
memset(buffer,backgroundASCIIcode,width*height);
memset(zbuffer,0,width*height*4);
for(float cubeX = -cubeWidth;cubeX < cubeWidth;cubeX+=increamentSpeed){
for(float cubeY = -cubeWidth;cubeY < cubeWidth;cubeY+=increamentSpeed){
calculateForSurface(cubeX,cubeY,-cubeWidth,'o');
calculateForSurface(cubeWidth,cubeY,cubeX,'.');
calculateForSurface(-cubeWidth,cubeY,-cubeX,'@');
calculateForSurface(-cubeX,cubeY,cubeWidth,'^');
calculateForSurface(cubeX,-cubeWidth,-cubeY,'+');
calculateForSurface(cubeX,cubeWidth,cubeY,'-');
}
}
printf("\x1b[H");
printf("\x1b[?25l");
fflush(stdout);
for(int k=0 ;k< width*height;k++){
putchar(k%width ?buffer[k] : 10);
}
A += 0.04;
B += 0.04;
C += 0.04;
usleep(8000);
}
}

昨天休息了一天,今天继续图形学的学习

向场景发射光线

现在我们我们准备做一个光线追踪器。其核心在于,光线追踪程序通过每个像素发送光线。这意味着对于图像中的每个像素点,程序都会计算一天从观察者出发,穿过该像素的光线。并且计算这个光线的方向上所看到的像素的颜色。其步骤为以下几点:

  • 计算从“眼睛”发出的通过像素的光线
  • 确定光线与物体的交汇
  • 计算交点像素的颜色和性质

设置一个摄像机

除了设置渲染图像的像素维度,我们还需要一个虚拟视口,通过这个视口传递场景光线。这个视口是3D世界中的一个虚拟矩形,其中包含图像像素网格的位置。如果像素在水平方向和垂直方向上的距离相同(正方形像素),那么用于生成这些像素的视口也将具有具有相同的渲染比例。两个相近的像素之间的距离称之为像素间距,通常被单位化,用于计算。

首先我们随意设置一个为2的视口高度,并将视口宽度缩放,以获得我们需要的宽高比例,下面是渲染图像的设置:

1
2
3
4
5
6
7
8
9
10
auto aspect_radio = 16.0/9.0;	//长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);

这里之所以没有使用aspect_radio来计算视口宽度是因为,为了确保其比例更加的真实。因为aspect_ratio是一个理想的比例,但是并不真实。图像的宽高比可能会在四舍五入的过程中丢失精度,所以此时的aspect_ratio并不准确,要得到真正的宽高比,我们直接使用图像的宽高比来进行计算。

接下里我们需要定义摄像机的中心:一个3D空间中的点,所有的场景光线都将从这个点出发(这个点通常也被称之为视点)。从相机中心到视口中心的向量将与视口垂直。我们将视口到视点的距离看作一个单位。这个距离我们称其为焦距。

我们可以通过这张图理解视点和视口的关系,但是忽略图上的方向,我们只需要知道Z轴是从视点指向视口的方向即可

image.png

实际上我们将视口的左上角定为(0,0),然后扫描像素时从左上角开始,逐行从左到右扫描,然后逐行从上往下扫描。为了方便导航像素网格,我们设置从左往右的向量u⃗和从上往下的向量v⃗ 。然后我们根据像素间距,将像素视口均匀的分成了高x宽网格空间

image.png

下面我们写一个实现相机的代码,我们创建一个函数ray_color(const ray& r),它返回给定场景中的射线的颜色——我们先设置为总返回黑色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include "color.h"
#include "vec3.h"
#include "ray.h"

#include <iostream>

color ray_color(const ray& r){
return {0,0,0};
}

int main(){
auto aspect_radio = 16.0/9.0; //长宽比
int image_width = 400;

//计算图像的高度,并确保图像的高度至少为1(单位长度)
int image_height = int (image_width / aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

//确保视口的宽高比和图像的宽高比一样
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0,0,0);

//设置视口向量与单位长度
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
auto pixel_delta_u = viewport_u/image_width;
auto pixel_delta_v = viewport_v/image_height;

//计算像素点位
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
auto pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);


//渲染器
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto pixel_center = pixel00_loc + (i*pixel_delta_u) + (j*pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center,ray_direction);

color pixel_color = ray_color(r);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone. \n";
}

以上就是一个摄像机的实现了,接下来我们用它来实现背景的渲染。

渲染背景

我们接下来想要渲染一个随y变化的由蓝变白的背景,这就需要我们修改ray_color(ray)函数,从而实现一个简单的梯度函数,这个函数将根据y值来按线性规则混合白色和蓝色。

我们使用一个标准的图形技巧来实现线性混合:

blendValue = (1 − a) * startValue + a * endValue

当a接近0时,颜色趋近为起始颜色。当a接近1时,颜色接近目标颜色。

我们修改函数ray_color(ray& r)实现这个功能:

1
2
3
4
5
color ray_color(ray & r){
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0,1.0,1.0) + a*color(0.5,0.7,1.0);
}

我们编译执行,得到了渲染后的图片:

image.png

今天就先到这里啦。

今天简单的学习一下相关类的定义和作用

Vec3类

图形程序中需要一些用于存储几何向量和颜色的类,这里的话我们设置一个最简单的,只需要三个坐标就够了。我们使用相同的类vec3来表示颜色、位置、方向、偏移。所以我们会为这个类声明另外两个别名point3color,但是要注意,不要将一个point3添加到一个color

我们定义一个类文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#ifndef RENDER_C___VEC3_H
#define RENDER_C___VEC3_H

#include <cmath>
#include <iostream>
// 向量类
class vec3 {
public:
double e[3];

vec3(): e{0,0,0} {}
vec3(double e0,double e1,double e2): e{e0,e1,e2} {}

double x() const{ return e[0]; }
double y() const{ return e[1]; }
double z() const{ return e[2]; }

vec3 operator-() const {return {-e[0],-e[1],-e[2]};}
double operator[](int i) const { return e[i];}
double & operator[](int i) {return e[i];}

vec3& operator+=(const vec3& v){
e[0] += v.e[0];
e[1] += v.e[1];
e[2] += v.e[2];
return *this;
}

vec3& operator*=(double t){
e[0] *= t;
e[1] *= t;
e[2] *= t;
return *this;
}

vec3& operator/=(double t){
return *this *= 1/t;
}

double length_squared() const{
return e[0]*e[0]+e[1]*e[1]+e[2]*e[2];
}

double length() const {
return std::sqrt(length_squared());
}
};

// 设置别名
using point3 = vec3;

// 设置内联函数
inline std::ostream& operator<<(std::ostream& out,const vec3& v){
return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}

inline vec3 operator+(const vec3& u,const vec3& v){
return {u.e[0]+v.e[0],u.e[1]+v.e[1],u.e[2]+v.e[2]};
}

inline vec3 operator-(const vec3& u,const vec3& v){
return {u.e[0]-v.e[0],u.e[1]-v.e[1],u.e[2]-v.e[2]};
}

inline vec3 operator*(const vec3& u,const vec3& v){
return {u.e[0]*v.e[0],u.e[1]*v.e[1],u.e[2]*v.e[2]};
}

inline vec3 operator*(double t,const vec3& v){
return {t*v.e[0],t*v.e[1],t*v.e[2]};
}

inline vec3 operator*(const vec3& v,double t){
return t*v;
}

inline vec3 operator/(const vec3& v,double t){
return (1/t)*v;
}

inline double dot(const vec3& u,const vec3& v){
return u.e[0]*v.e[0] + u.e[1]*v.e[1] + u.e[2]*v.e[2];
}

inline vec3 cross(const vec3& u,const vec3& v){
return {u.e[1]*v.e[2]-u.e[2]*v.e[1],u.e[2]*v.e[0]-u.e[0]*v.e[2],u.e[0]*v.e[1]-u.e[1]*v.e[0]};
}

inline vec3 uint_vector(const vec3& v){
return v/v.length();
}

#endif //RENDER_C___VEC3_H

其中大量应用了函数的重载,不过对着《c++ primer plus》还是看的差不多了。

这里的小数我们使用了double数据类型,因为它更加的准确,不过还是以自己使用的机器为主,看你内存空间咯

vec3类在颜色中的应用

基于vec3类,定义一个color.h,并向里面定义一个写入像素的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

#ifndef RENDER_C___COLOR_H
#define RENDER_C___COLOR_H

#include "vec3.h"

using color = vec3;

void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

int rbyte = int (255.999 * r);
int gbyte = int (255.999 * g);
int bbyte = int (255.999 * b);

out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

#endif //RENDER_C___COLOR_H

现在我们可以用我们定义的类去实现我们昨天手搓出来的图片了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "color.h"
#include "vec3.h"

#include <iostream>

int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: "<< (image_height-j) << '\r' << std::flush;
for(int i =0;i<image_width;i++){
auto pixel_color = color(double(i)/(image_width-1),double(j)/(image_height-1),0);
write_color(std::cout,pixel_color);
}
}
std::clog << "\rDone\n";
}

虽然没有节省多少内容,但是在后续的过程中,这个简便性会慢慢体现出来。

基于vec3实现射线类

我们需要一个光线类,来实现对光线的计算。我们可以用函数P(t) = A + bt来实现光线路径的模拟。其中A指的是射线的起点,b指的是射线的方向,t是光线的延伸。我们将这个射线的概念封装为一个类,其中用于计算的函数P(t)用函数ray::at(t)表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef RENDER_C___RAY_H
#define RENDER_C___RAY_H

#include "vec3.h"

class ray{
public:
ray(){}

ray(const point3& origin,const vec3& direction): orig(origin),dir(direction){}

const point3& origin() const {return orig;}
const vec3& direction() const {return dir;}

point3 at(double t) const{
return orig + t*dir;
}
private:
point3 orig;
vec3 dir;
};

#endif //RENDER_C___RAY_H

这里将射线的起点和方向进行了封装,只能通过接口访问以确保程序的完整与安全

现在我们接下来要用到几个类就设置完成啦

最近对图形学比较感兴趣,虽然感觉找不到工作,但是帅,我花点时间学一下,刚好练习一下C++

输出一个图像

ppm

当我们启动一个渲染器时,我们需要一种方式来查看我们的图像。最简单的方式就是将它写入文件中,这里我们使用PPM格式(因为它比较简单)

image.png

这是PPM文件格式在维基中的表现:

  • 第一行是文件描述子 什么P1 P2 P3代表了不同的图像类型,这里我们使用P3——像素图
  • 第二行是像素的宽高 先宽后高
  • 第三行表示每个像素分量的最大值 255 即8位色彩深度
  • 下面是像素的信息,每一行的最后用换行符结尾

图形学的Hello World

我们可以用简单的代码实现一个这样的图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << " " << image_width << "\n255\n";

for (int j =0 ;j<image_height;j++){
for(int i=0;i<image_width;i++){
auto r = double (i)/(image_width-1);
auto g = double (j)/(image_height -1);
auto b = 0.0;

int ir = int (255.999 *r);
int ig = int (255.999 *g);
int ib = int (255.999 *b);
// 255.999确保在浮点数计算中的极小误差在转换为整数的过程中不会被放大,可以理解成这是一种技巧,用来避免色彩的丢失
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
}

我们将输入定向到一个文件中,然后更改后缀名打开

image.png

可以看到我们生成的图片啦

添加一个程序进度指示器

有时候长时间渲染需要进行跟踪,以判断是否陷入死循环中,或者查看渲染进度。

我们可以通过添加日志输出流来实现这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
int main(){
int image_width = 256;
int image_height = 256;

std::cout << "P3\n" << image_width << " " << image_width << "\n255\n";

for (int j =0 ;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
auto r = double (i)/(image_width-1);
auto g = double (j)/(image_height -1);
auto b = 0.0;

int ir = int (255.999 *r);
int ig = int (255.999 *g);
int ib = int (255.999 *b);

std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
std::clog << "\rDone \n";
}

这样就可以啦

你真的喜欢你现在正在做的事情吗?这真是你想学的吗?你以后还会继续喜欢它吗?

我和一位学艺术的同学聊到这个话题,我们都很喜欢自己正在做的事情。他说 “我很怕自己以后突然有一天不喜欢画画了” ,我听得很难过,因为我已经开始不喜欢我正在做得事情了。好不容易找到自己想做的事,难道仅仅也是因为新鲜感吗?他接着说 “我觉得很多人并不是喜欢这件事,而是喜欢它为你带来的价值。真的有人喜欢数学吗?有但是少数,可是为什么那么多人说自己喜欢数学呢?因为在数学上他比别人更强,数学能够为他带来优越感,和各种荣誉。”我又何尝不是这样呢?我学的大多数知识已经是为比赛开始服务了,我喜欢的是这些知识,还是比赛为我带来的荣誉呢?

我突然很害怕,这是我早就意识到的一个问题。大概是新生赛时期拿到过一次第一吧,大多数人对我的印象是一个很优秀很有潜力的CTFer,我也这么觉得,我觉得就该这么下去,以后一定会越来越强。大概是从这个时候开始我就变了吧,我搞丢了我的初心。我可以拿CTF为由去拒绝学习其他我不想学的东西,即使我不上课,不写作业,文化课乱成一团,我也觉得情有可原。别人也很“理解”我,“没关系,你看你技术学的那么好”。真的是如此吗?

我一点也不喜欢这样,我可以肯定。这些我都不在乎,我想得到的不是这些,我想知道的是计算机背后的细节,一个个门电路是怎么搭建出的计算机,程序是怎么链接加载的,电脑是怎么运行的。我想写自己的东西,不是给别人看的东西,不是为了比赛而匆匆赶制的作品。很多人说不好就业,这样焦虑那样焦虑,并不重要,我是在做我喜欢的事情,很多人一辈子都不知道自己想做什么,只顾着养活自己。但是我有我自己想做的事情,我不甘心被这些东西阻挠,这些那些放做一旁。我有很多想做的事情,想学的知识。

当然,无论怎么做总有人质疑你,我以前总是被这个困扰,我想做出最好的选择,让所有人都认可。但从来都不是这样的,你有理想,别人嫉妒你,说理想不能当饭吃,你还是得养活自己。你现实,别人忌惮你,说别只知道学习,别太功利,不然会很累。大多数人是见不得别人好的,见不得别人有自己的事情要做。巴不得所有人和自己一样过的迷茫错误。上了大学才发现,这种人到处都是。

我的同学是一个很纯粹的艺术生,喜欢素描和速写,他追求的是真正的艺术,能让自己满意,能让他人开心的艺术,我很羡慕他。其实我和他也一样,我追求纯粹的知识,我想知道的,能让我说出原来如此的知识,仅此而已。希望之后的学习之旅,能够不忘初心,慢慢加油。

项目地址 https://www.jmeiners.com/lc3-vm/#running-the-vm

什么是虚拟机

虚拟机是一种像计算机一样的程序。它模拟CPU和一些其他的硬件组件的功能,使其能够实现算术运算,内存读写,还有与I/O设备的交互。最重要的是,你可以理解你为其定义的机器语言,然后用它来编程。

LC-3架构

这次编写的虚拟机将模拟LC-3架构。和X86架构相比,它的指令集更加简单

内存

LC-3中有65536个内存位置(这是16位无符号整数能表示的最大地址),每个位置存储一个16位的值。这意味它只能存储128KB的值,我们将其存储在一个数组中

1
2
3
// Memory Storage
#define MEMORY_MAX (1<<16)
uint16_t memory[MEMORY_MAX]; //65536个16位的内存空间

寄存器

LC-3共有10个寄存器,每个寄存器都为16位的。其功能分布如下:

  • 通用寄存器(R0-R7)—— 8:执行程序计算
  • 程序计数器(PC)—— 1:表示内存中将要执行的下一条指令
  • 条件寄存器(COND)—— 1:计算的信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Registers
enum{
R_R0 = 0,
R_R1,
R_R2,
R_R3,
R_R4,
R_R5,
R_R6,
R_R7,
R_PC,
R_COND,
R_COUNT
};

我们也将其保存在一个数组中

1
2
// Register Storage
uint16_t reg[R_COUNT];

指令集

LC-3中只有16个操作指令,我们将用他们实现计算机中的各种内容。每个指令的长度是16位,我们用左边的4位用来存储操作码(操作指令对应的机器码),其它位置用来存放参数

接下来定义操作码,并分配对应的枚举值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Opcodes
enum{
OP_BR = 0, //条件分支
OP_ADD, //加
OP_LD, //加载数据
OP_ST, //存储数据
OP_JSR, //子程序调用
OP_AND, //按位与
OP_LDR, //基址加载
OP_STR, //基址存储
OP_RTI, //不使用
OP_NOT, //按位或
OP_LDI, //间接加载
OP_STI, //间接存储
OP_JMP, //跳转
OP_RES, //不使用
OP_LEA, //加载有效地址
OP_TRAP //系统调用
};

条件标志

R_COND寄存器存储条件标志,这些标志提供了最近的计算的信息,使得程序能检查逻辑条件。这里LC-3至使用3个条件标志,这些条件标志指示前一次的计算符号

1
2
3
4
5
6
// Condition Flags
enum{
FL_POS = 1 << 0, //P
FL_ZRO = 1 << 1, //Z
FL_NEG = 1 << 2, //N
}

现在我们已经完成虚拟机中硬件组件的设置。可以进一步尝试虚拟机的实现了,此时文件结构应该是这样

1
2
3
4
5
@{Includes}

@{Registers}
@{Condition Flags}
@{Opcodes}

汇编示例

现在我们尝试写一个LC-3汇编的程序,以了解虚拟机上发生的事情

1
2
3
4
5
6
.ORIG x3000			;程序将被加载到内存的这个位置
LEA R0,HELLO_STR ;将字符串加载到R0中
PUTs ;将R0指向的字符串输出
HALT ;中断
HELLO_STR .STRINGZ "Hello,World!" ;程序中字符串存储于此
.END ;结束标记
  • 这个指令是线性执行的,和C语言以及其他语言中的{}作用域不同
  • .ORIG.STRINGZ是汇编器指令,用于生成一段代码或数据(像宏一样)

尝试循环和条件语句的使用通常使用类似goto的指令实现。比如计数到10:

1
2
3
4
5
AND R0, R0, 0		;清空R0
LOOP ;设置跳转标签
ADD R0, R0, 1 ;R0 = R0 + 1
ADD R1, R0, -10 ;R1 = R0 - 10
BRn LOOP ;若为负数则跳转

执行程序

上面的例子让你大致理解了这个虚拟机的功能。使用LC-3的汇编语言,你甚至能在你的虚拟机上浏览网页,或者Linux系统

过程

以下是我们编写程序的步骤:

  • 从PC寄存器存储的内存地址中加载一条指令
  • 递增PC寄存器
  • 查看指令的操作码以确定它执行那种类型的指令
  • 使用指令中的参数执行指令
  • 返回第一步

当然我们也需要whileif之类的指令,帮我们实现指令的跳转,不然编程量会很大。所以在这里我们会通过类似于goto的指令来跳转PC从而改变执行流程,我们开始编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Main Loop
int main(int argc,const char * argv[]){
@{Load Arguments}
@{Setup}

//无论何时都应该设定一个条件标志,这里设置Z标志
reg[R_COND] = FL_ZRO;

//为PC设置一个起始的内存加载地址,0x3000为默认
enum{PC_START = 0x3000};
reg[R_PC] = PCSTART;

int running = 1;
while (running){
//读取
uint16_t instr = mem_read(reg[R_PC]++);
uint16_t op = instr >> 12;

switch(op){
case OP_ADD:
@{ADD}
break;
case OP_AND:
@{AND}
break;
case OP_NOT:
@{NOT}
break;
case OP_BR:
@{BR}
break;
case OP_JMP:
@{JMP}
break;
case OP_JSR:
@{JSR}
break;
case OP_LD:
@{LD}
break;
case OP_LDI:
@{LDI}
break;
case OP_LDR:
@{LDR}
break;
case OP_LEA:
@{LEA}
break;
case OP_ST:
@{ST}
break;
case OP_STI:
@{STI}
break;
case OP_STR:
@{STR}
break;
case OP_TRAP:
@{TRAP}
break;
case OP_RES:
case OP_RTI:
default:
@{BAD OPCADE}
break;
}
}
@{Shutdown}
}

我们在主循环中,处理命令行参数,使我们的程序可用。我们期望输入的参数是一个虚拟机镜像,如果不是的话,则给出参数输入的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
// Load Arguments
if(argc < 2){
//展示用法
printf("lc3 [image-file1] ...\n");
exit(2);
}

for(int j = 1;j < argc; ++j){
if(!read_image(argv[j])){
printf("failed to load image: %s\n",argv[j]);
exit(1);
}
}

指令实现

我们现在需要将每个操作码情况都填充为正确的实现,我们可以在LC-3中找到指令的正确实现。下面会演示两个指令的实现

ADD

ADD指令取两个数字相加,并将结果存储在寄存器中。其规范如下:

image.png

编码显示了两行,因为这条指令有两种模式。可以看到前四位(这里是小端序)都是一样的,0001是OP_ADD的操作码。接下来3位都是DR,这代表目标寄存器。目标寄存器是用来存储加和的位置。再接下来3位是SR1,这是包含第一个要加的数字的寄存器。

区别在于最后五位,注意第5位,这个位表示ADD是立即数模式还是寄存器模式,在寄存器模式中,则是将两个寄存器相加,第二个寄存器被标记为SR2。其使用方法如下:

1
ADD R2 R0 R1	;R2 = R0 + R1

立即数模式则是将第二个值嵌入指令本身,如图中所示标识为imm5,消除了编写将值从内存中读取的需要。不过限于指令的长度,数字最多25 = 32(无符号),这使得即时模式主要用于递增和递减,其使用如下:

1
ADD R0 R0 1		;R0 = R0 + 1

其具体规范如下:

If bit [5] is 0, the second source operand is obtained from SR2. If bit [5] is 1, the second source operand is obtained by sign-extending the imm5 field to 16 bits. In both cases, the second source operand is added to the contents of SR1 and the result stored in DR. (Pg. 526)

这里的话和我们刚刚分析的差不多,但是在于它提到的"sign-extending"。这是啥用的呢?要知道,我们的加法是16位的,但我们的立即数是5位的,所以我们需要将它拓展到16位以匹配另一个数字。对于正数我们只需要补充0即可,但是对于负数我们需要用1填充,从而保持原始值

1
2
3
4
5
6
7
// Sign Extend
uint16_t sign_extend(uint16_t x, int bit_count){
if((x>>(bit_count-1))&1){
x |= (0xFFFF << bit_count);
}
return x;
}

规范中还有一句填充:

The condition codes are set, based on whether the result is negative, zero, or positive. (Pg. 526)

条件码根据结果是否为负,0,正来设置。我们之前定义了一个条件标志枚举,现在我们需要用到他们。每当有一个值被写入寄存器中,我们需要更新条件标志寄存器来标识它的符号。我们写一个函数来进行这个过程:

1
2
3
4
5
6
7
8
9
// Update Flags
void update_flags(uint16_t r){
if(reg[r] == 0)
reg[R_COND] = FL_ZRO;
else if(reg[r] >> 15) //当1是最高位时说明这是一个负数
reg[R_COND] = FL_NEG;
else
reg[R_COND] = FL_POS
}

至此我们可以编写ADD了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ADD
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//第一操作数
uint16_t r1 = (instr >> 6) & 0x7;
//是否是立即数
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] + imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] + reg[r2];
}
update_flags[r0]
}

至此,我们的ADD指令编写完成了,接下来我们将使用符号拓展,不同模式和更新标志的组合去实现其他的指令

LDI

LDI的话是间接加载的意思。这个指令将内存地址中的值加载到寄存器中,下面是它的指令样式:

image.png

可以看到前面的结构和ADD差不多,一个操作码,加上一个目标寄存器,剩余的位被标记为PCoffset9。这是一个嵌入在指令中的立即值(类似imm5)。由于这个指令从内存中读取,所以推测这个数字是一个地址,告诉我们从哪里加载:

An address is computed by sign-extending bits [8:0] to 16 bits and adding this value to the incremented PC. What is stored in memory at this address is the address of the data to be loaded into DR. (Pg. 532)

大概的意思是,我们需要将这个9位的值进行符号拓展,将其加到当前的PC上,结果之和则是一个内存的地址,这个地址包含另一个值,也就是我们将要加载的值的地址

这种方式读取内存可能很绕,但实际上这是必不可少的。LD指令仅限于9位地址偏移量,但是内存需要16位寻址,LDI对于加载存储在离当前PC位置较远的数据时更有用。与之前一样,将值放到DR后需要更新标志,其实现如下:

1
2
3
4
5
6
7
8
9
10
// LDI
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//初始化PCoffset
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
//将偏移值与PC相加,并读取对应的内存中的值到寄存器中
reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
update_flags(r0);
}

现在我们实现了两个主要的指令,接下来的用差不多的方式就可以写出来了

其他

RTI & RES

并不使用

1
2
// BAD OPCODE
abort();

AND

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// AND
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] & imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] & reg[r2];
}
update_flags(r0)
}

NOT

1
2
3
4
5
6
7
8
// NOT
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;

reg[r0] = ~reg[r1];
update_flags(r0);
}

BR

1
2
3
4
5
6
7
8
// BR
{
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
uint16_t cond_flag = (instr >> 9) & 0x7;
if(cond_flag & reg[R_COND]){ //cond_flag设置了跳转的条件
reg[R_PC] += pc_offset;
}
}

JMP

这里我们将RET视作JMP的一种特殊情况

1
2
3
4
5
// JMP
{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1];
}

JSR

1
2
3
4
5
6
7
8
9
10
11
12
// JSR
{
uint16_t long_flag = (instr >> 11) & 1;
reg[R_R7] = reg[R_PC];
if(long_flag){
uint16_t long_pc_offset = sign_extend(intstr & 0x7FF,11);
reg[R_PC] += long_pc_offset; //JSR,加上偏移值
}else{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1]; //JSRR,跳转到寄存器指定地址
}
}

LD

1
2
3
4
5
6
7
// LD
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = mem_read(reg[R_PC] + pc_offset); //在PC上偏移
update_flags(r0);
}

LDR

1
2
3
4
5
6
7
8
// LDR
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
reg[r0] = mem_read(reg[r1] + offset);
update_flags(r0);
}

LEA

1
2
3
4
5
6
7
// LEA
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = reg[R_PC] + pc_offset;
update_flags(r0);
}

ST

1
2
3
4
5
6
// ST
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(reg[R_PC] + pc_offset,reg[r0]);
}

STI

1
2
3
4
5
6
// STI
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(mem_read(reg[R_PC] + pc_offset),reg[r0]);
}

STR

1
2
3
4
5
6
7
// STR
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(intstr & 0x3F,6);
mem_write(reg[r1] + offset,reg[r0])
}

中断例程 Trap routines

LC-3中提供了一些预定的例程,用来执行常见的任务,以及与I/O间的设备交互。例如,从键盘获取输入和向控制台显示字符串的中断例程。可以将其作为LC-3的操作系统API理解。每个例程分配了一个中断码,我们定义一个枚举来实现他们,下面是TRAP指令的格式

image.png

其中的0~7位用于存放中断码,我们据此来实现他们:

1
2
3
4
5
6
7
8
9
// TRAP Codes
enum{
TRAP_GETC = 0x20, //从键盘中读取输入,但是不打印至终端
TRAP_OUT = 0x21, //输出字符
TRAP_PUTS = 0x22, //输出字符串(双字节单字符)
TRAP_IN = 0x23, //从键盘中读取输入,且打印至终端
TRAP_PUTSP = 0x24, //输出字符串(单字节单字符)
TRAP_HALT = 0x25 //中断程序
};

中断例程并不是指令,但是它为LC-3的运行提供了各种快捷的方式。在LC-3中,这些例程实际上是使用汇编编写的,当中断例程被调用了,程序被跳转到这里,执行后返回程序。(为什么程序从0x3000开始加载而不是0x0也是因为这个原因,需要一部分空间用来存储中断例程的代码)

在LC-3中我们直接使用C语言进行中断例程的编写,而不是汇编。我们不用从汇编开始写自己的I/O设备,我们可以发挥虚拟机的优势,使用更好的更搞层次的设备去仿真这个过程

我们在TRAP中使用一个switch来进一步的编写这个指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// TRAP
reg[R_R7] = reg[R_PC];
switch(instr & 0xFF){
case TRAP_GETC:
@{TRAP_GETC}
break;
case TRAP_OUT:
@{TRAP_OUT}
break;
case TRAP_PUTS:
@{TRAP_PUTS}
break;
case TRAP_IN:
@{TRAP_IN}
break;
case TRAP_PUTSP:
@{TRAP_PUTSP}
break;
case TRAP_HALT:
@{TRAP_HALT}
break;
}

接下来进一步的实现例程中的内容

PUTS

功能的话类似于C语言中的printf,但是我们字符串和C中的有所不同,LC-3中的字符并不是被单独存储为一个字节,而是被存储为一个内存位置。LC-3中的内存位置并不是16位,所以字符串中的每个字符都是16位的。为了用C的方法将其打印出来,我们需要将它转换为一个字符并单独输出

1
2
3
4
5
6
7
8
9
10
// TRAP PUTS
{
// 每个字符占一个字(16bits)
uint16_t *c = memory + reg[R_R0];
while(*c){
putc((char)*c,stdout); //强制类型转换
++c;
}
fflush(stdout);
}

以这个为例,我们可以进一步的编写其他的例程

GETC

1
2
3
4
5
6
// TRAP GETC
{
// 读取一个ASCII字符
reg[R_R0] = (uint16_t)getchar();
update_flags(R_R0);
}

OUT

1
2
3
4
5
// TRAP OUT
{
putc((char)reg[R_R0],stdout);
fflush(stdout);
}

IN

1
2
3
4
5
6
7
8
9
// TRAP IN
{
printf("Enter a character: ");
char c = getchar();
putc(c,stdout);
fflush(stdout);
reg[R_R0] = (uint16_t)c;
update_flags(R_R0);
}

PUTSP

1
2
3
4
5
6
7
8
9
10
11
12
13
// TRAP PUTSP
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}

HALT

1
2
3
4
5
6
//HALT	
{
puts("HALT");
fflush(stdout);
running = 0;
}

程序加载

我们也许可以注意到指令是从内存中加载和进行执行的,但是指令是怎么进入内存的呢?当汇编被转换为机器指令时,它变成了一个包含了一系列的数据与指令的文件,可以将它复制到内存中进行加载。

16位的程序文件头指出了程序在内存中起始的地址。这个地址被称之为起始地址(origin)。它首先被读取,然后从起始地址开始将其余数据从文件读入内存。以下是LC-3中加载内存的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Reas Image File
void read_image_file(FILE * file){
//起始地址让我们知道内存从什么地方开始加载
uint16_t origin;
fread(&origin,sizeof(orgin),1,file);
origin = swap16(origin);

//由于我们有最大的内存范围,所以我们可以通过起始位置,确定加载范围
uint16_t max_read = MEMORY_MAX - origin;
uint16_t *p = memory + origin;
size_t read = fread(p,sizeof(uint16_t),max_read,file);

//转换为小端序
while(read-- > 0){
*p = swap16(*p);
++p;
}
}

注意这里swap16被调用于每一个数据块,这是因为LC-3是大端序结构,但是大多数现代计算机采用的是小端序结构,所以我们需要将每个uint16数据都转换一遍

1
2
3
uint16_t swap16(uint16_t x){
return (x << 8)|(x >> 8);
}

我们进一步的封装read_image_file()程序:

1
2
3
4
5
6
7
int read_image(const char * image_path){
FILE *file = fopen(image_path,"rb");
if(!file){return 0;};
read_image_file(file);
fclose(file);
return 1;
}

内存映射寄存器

有一些特殊的寄存器是不能通过普通的寄存器组去访问的。相反,在内存中为其保留了特殊的地址。要读写这些寄存器只需要读写他们的内存地址。这个过程便被称为内存映射寄存器。这通常用于一些特殊的硬件设备操作

LC-3有两个内存映射寄存器需要实现。一个是键盘状态寄存器(KBSR),一个是键盘数据寄存器(KBDR)。KBSR用来指示按键是否被按下,KBDR用来指示哪个按键被按下了

虽然可以使用GETC来完成对键盘输入的读取,但是在读取到输入之前,它会一直阻塞程序的执行。而KBSR和KBDR则可以实现对键盘设备的轮询,以确保程序在等待输入的时候持续执行

1
2
3
4
5
//Memory Mapped Registers
enum{
MR_KBSR = 0xFE00,
MR_KBDR = 0xFE02
};

内存映射寄存器使得内存访问变得有点复杂,使得你不能直接对内存数组进行访问。所以我们需要编写函数以实现对内存的读写。当从KBSR读取内存时,读取函数将检查键盘并更新两个内存位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Memory Access
void mem_write(uint16_t address,uint16_t val){
memory[address] = val;
}

uint16_t mem_read(uint16_t address){
if(address == MR_KBSR){
if(check_key()){
memory[MR_KBSR] = (1 << 15);
memory[MR_KBDR] = getchar();
}else{
memory[MR_KBSR] = 0;
}
}
return memory[address];
}

到此为止,VM的最后一个组件也模拟实现了,我们可以尝试运行它了。

平台特定信息

不同系统中的键盘读入和终端打印的实现可能有所不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Input Buffering
struct termios original_tio;

void disable_input_buffering(){
tcgetattr(STDIN_FILENO,&original_tio);
struct termios new_tio = original_tio;
new_tio.c_lflag &= ~ICANON & ~ECHO;
tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);
}

void restore_input_buffering(){
tcsetattr(STDIN_FILENO,TCSANOW,&original_tio);
}

uint16_t check_key(){
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO,&readfds);

struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;
return select(1,&readfds,NULL,NULL,&timeout) != 0;
}

组合程序

我们利用刚刚写好的程序来对缓冲进行设置,以实现正确处理终端输入。

在main函数开头设置代码:

1
2
3
//Setup
signal(SIGINT,handle_interrupt);
disable_input_buffering();

当程序结束后,我们将中断设置为正常状态:

1
2
//Shutdown
restore_input_buffering();

设置也应该在接收到结束信号时恢复:

1
2
3
4
5
6
//Handle Interrupt
void handle_interrupt(int signal){
restore_input_buffering();
printf("\n");
exit(-2);
}

OK 我们的程序将要实现,我们将其组合便可得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@{Memory Mapped Registers}
@{TRAP Codes}

@{Memory Storage}
@{Register Storage}

@{Input Buffering}
@{Handle Interrupt}
@{Sign Extend}
@{Swap}
@{Update Flags}
@{Read Image File}
@{Read Image}
@{Memory Access}

@{Main Loop}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
#include <stdio.h>
#include <stdint.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/termios.h>
#include <sys/mman.h>

#define MEMORY_MAX (1<<16)


//Memory Mapped Registers
enum{
MR_KBSR = 0xFE00,
MR_KBDR = 0xFE02
};

enum{
TRAP_GETC = 0x20, //从键盘中读取输入,但是不打印至终端
TRAP_OUT = 0x21, //输出字符
TRAP_PUTS = 0x22, //输出字符串(双字节单字符)
TRAP_IN = 0x23, //从键盘中读取输入,且打印至终端
TRAP_PUTSP = 0x24, //输出字符串(单字节单字符)
TRAP_HALT = 0x25 //中断程序
};
enum{
OP_BR = 0, //条件分支
OP_ADD, //加
OP_LD, //加载数据
OP_ST, //存储数据
OP_JSR, //子程序调用
OP_AND, //按位与
OP_LDR, //基址加载
OP_STR, //基址存储
OP_RTI, //不使用
OP_NOT, //按位或
OP_LDI, //间接加载
OP_STI, //间接存储
OP_JMP, //跳转
OP_RES, //不使用
OP_LEA, //加载有效地址
OP_TRAP //系统调用
};
uint16_t memory[MEMORY_MAX]; //65536个16位的内存空间
enum{
R_R0 = 0,
R_R1,
R_R2,
R_R3,
R_R4,
R_R5,
R_R6,
R_R7,
R_PC,
R_COND,
R_COUNT
};
// Register Storage
uint16_t reg[R_COUNT];
enum{
FL_POS = 1 << 0, //P
FL_ZRO = 1 << 1, //Z
FL_NEG = 1 << 2, //N
};

struct termios original_tio;

void disable_input_buffering(){
tcgetattr(STDIN_FILENO,&original_tio);
struct termios new_tio = original_tio;
new_tio.c_lflag &= ~ICANON & ~ECHO;
tcsetattr(STDIN_FILENO,TCSANOW,&new_tio);
}

void restore_input_buffering(){
tcsetattr(STDIN_FILENO,TCSANOW,&original_tio);
}

uint16_t check_key(){
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO,&readfds);

struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;
return select(1,&readfds,NULL,NULL,&timeout) != 0;
}

//Handle Interrupt
void handle_interrupt(int signal){
restore_input_buffering();
printf("\n");
exit(-2);
}

uint16_t sign_extend(uint16_t x, int bit_count){
if((x>>(bit_count-1))&1){
x |= (0xFFFF << bit_count);
}
return x;
}

uint16_t swap16(uint16_t x){
return (x << 8)|(x >> 8);
}

void update_flags(uint16_t r){
if(reg[r] == 0)
reg[R_COND] = FL_ZRO;
else if(reg[r] >> 15) //当1是最高位时说明这是一个负数
reg[R_COND] = FL_NEG;
else
reg[R_COND] = FL_POS;
}

void read_image_file(FILE * file){
//起始地址让我们知道内存从什么地方开始加载
uint16_t origin;
fread(&origin,sizeof(origin),1,file);
origin = swap16(origin);

//由于我们有最大的内存范围,所以我们可以通过起始位置,确定加载范围
uint16_t max_read = MEMORY_MAX - origin;
uint16_t *p = memory + origin;
size_t read = fread(p,sizeof(uint16_t),max_read,file);

//转换为小端序
while(read-- > 0){
*p = swap16(*p);
++p;
}
}

int read_image(const char * image_path){
FILE *file = fopen(image_path,"rb");
if(!file){return 0;};
read_image_file(file);
fclose(file);
return 1;
}

void mem_write(uint16_t address,uint16_t val){
memory[address] = val;
}

uint16_t mem_read(uint16_t address){
if(address == MR_KBSR){
if(check_key()){
memory[MR_KBSR] = (1 << 15);
memory[MR_KBDR] = getchar();
}else{
memory[MR_KBSR] = 0;
}
}
return memory[address];
}

int main(int argc,const char * argv[]){
if(argc < 2){
//展示用法
printf("lc3 [image-file1] ...\n");
exit(2);
}

for(int j = 1;j < argc; ++j){
if(!read_image(argv[j])){
printf("failed to load image: %s\n",argv[j]);
exit(1);
}
}
signal(SIGINT,handle_interrupt);
disable_input_buffering();

//无论何时都应该设定一个条件标志,这里设置Z标志
reg[R_COND] = FL_ZRO;

//为PC设置一个起始的内存加载地址,0x3000为默认
enum{PC_START = 0x3000};
reg[R_PC] = PC_START;

int running = 1;
while (running){
//读取
uint16_t instr = mem_read(reg[R_PC]++);
uint16_t op = instr >> 12;

switch(op){
case OP_ADD:
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//第一操作数
uint16_t r1 = (instr >> 6) & 0x7;
//是否是立即数
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] + imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] + reg[r2];
}
update_flags(r0);
}
break;
case OP_AND:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t imm_flag = (instr >> 5) & 0x1;

if(imm_flag){
uint16_t imm5 = sign_extend(instr & 0x1F,5);
reg[r0] = reg[r1] & imm5;
}else{
uint16_t r2 = instr & 0x7;
reg[r0] = reg[r1] & reg[r2];
}
update_flags(r0);
}
break;
case OP_NOT:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;

reg[r0] = ~reg[r1];
update_flags(r0);
}
break;
case OP_BR:
{
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
uint16_t cond_flag = (instr >> 9) & 0x7;
if(cond_flag & reg[R_COND]){ //cond_flag设置了跳转的条件
reg[R_PC] += pc_offset;
}
}
break;
case OP_JMP:
{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1];
}
break;
case OP_JSR:
{
uint16_t long_flag = (instr >> 11) & 1;
reg[R_R7] = reg[R_PC];
if(long_flag){
uint16_t long_pc_offset = sign_extend(instr & 0x7FF,11);
reg[R_PC] += long_pc_offset; //JSR,加上偏移值
}else{
uint16_t r1 = (instr >> 6) & 0x7;
reg[R_PC] = reg[r1]; //JSRR,跳转到寄存器指定地址
}
}
break;
case OP_LD:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = mem_read(reg[R_PC] + pc_offset); //在PC上偏移
update_flags(r0);
}
break;
case OP_LDI:
{
//目标寄存器
uint16_t r0 = (instr >> 9) & 0x7;
//初始化PCoffset
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
//将偏移值与PC相加,并读取对应的内存中的值到寄存器中
reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
update_flags(r0);
}
break;
case OP_LDR:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
reg[r0] = mem_read(reg[r1] + offset);
update_flags(r0);
}
break;
case OP_LEA:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
reg[r0] = reg[R_PC] + pc_offset;
update_flags(r0);
}
break;
case OP_ST:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(reg[R_PC] + pc_offset,reg[r0]);
}
break;
case OP_STI:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t pc_offset = sign_extend(instr & 0x1FF,9);
mem_write(mem_read(reg[R_PC] + pc_offset),reg[r0]);
}
break;
case OP_STR:
{
uint16_t r0 = (instr >> 9) & 0x7;
uint16_t r1 = (instr >> 6) & 0x7;
uint16_t offset = sign_extend(instr & 0x3F,6);
mem_write(reg[r1] + offset,reg[r0]);
}
break;
case OP_TRAP:
{
reg[R_R7] = reg[R_PC];
switch(instr & 0xFF){
case TRAP_GETC:
{
// 读取一个ASCII字符
reg[R_R0] = (uint16_t)getchar();
update_flags(R_R0);
}
break;
case TRAP_OUT:
{
putc((char)reg[R_R0],stdout);
fflush(stdout);
}
break;
case TRAP_PUTS:
{
// 每个字符占一个字(16bits)
uint16_t *c = memory + reg[R_R0];
while(*c){
putc((char)*c,stdout); //强制类型转换
++c;
}
fflush(stdout);
}
break;
case TRAP_IN:
{
printf("Enter a character: ");
char c = getchar();
putc(c,stdout);
fflush(stdout);
reg[R_R0] = (uint16_t)c;
update_flags(R_R0);
}
break;
case TRAP_PUTSP:
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}
break;
case TRAP_HALT:
{
//刚刚的字符串打印是适用于双字节单字符的情况,现在是单字节单字符的情况
uint16_t *c = memory + reg[R_R0];
while(*c){
char char1 = (*c) & 0xFF;
putc(char1,stdout);
char char2 = (*c) >> 8;
if(char2) putc(char2,stdout);
++c;
}
fflush(stdout);
}
break;
}
}
break;
case OP_RES:
case OP_RTI:
default:
abort();
break;
}
}
restore_input_buffering();
}

至此为止,对LC-3的虚拟机仿真结束