当前位置: 首页 > article >正文

软件渲染器tinyrenderer

        tinyrenderer是一个不借助任何其他图形接口来实现三维渲染的一个学习项目。里面所以的代码和相应的库都是自己封装的,能够帮助我们很好地理解整个渲染的底层原理。

在windows下编译:

代码:

GitHub - ssloy/tinyrenderer: A brief computer graphics / rendering course

在克隆下的tinyrenderer 目录下执行:

D:\opencode\tinyrenderer>mkdir build

D:\opencode\tinyrenderer>cd build


D:\opencode\tinyrenderer\build>cmake  ..

生成工程:

Download TGA Viewer by IdeaMK    tga 格式打开的软件

Lesson 1: 线条绘制算法

Lesson 1: Bresenham’s Line Drawing Algorithm · ssloy/tinyrenderer Wiki · GitHub 

代码:

https://github.com/ssloy/tinyrenderer.git

切换到 tag: Chapter_One_Final

为了方便在windows上的路径加载,我直接写死了模型加载路径:

Model 的作用是加载.obj中的顶点

主要难点在于要读取.obj模型文件中的顶点数据。将这些顶点数据读出再依次绘线最后就可以看出模型的网格图了。 

绘制直线

 参考:tinyrenderer学习总结(1)-CSDN博客


void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
    bool steep = false;
    if (std::abs(x0-x1)<std::abs(y0-y1)) {
        std::swap(x0, y0);
        std::swap(x1, y1);
        steep = true;
    }
    if (x0>x1) {
        std::swap(x0, x1);
        std::swap(y0, y1);
    }

    for (int x=x0; x<=x1; x++) {
        float t = (x-x0)/(float)(x1-x0);
        int y = y0*(1.-t) + y1*t;
        if (steep) {
            image.set(y, x, color);
        } else {
            image.set(x, y, color);
        }
    }
}

 

 

obj文件格式

在 obj 文件目录下,有一个.obj格式的文件,它包括三维模型渲染的所有参数,分别是:

  • 顶点(Vertex),x,y,z坐标。
v 0.608654 -0.568839 -0.416318
  • 纹理坐标(vertex texture),表示一个纹理坐标 uvt,一般 3D 模型只需要 2D 纹理,故第三个分量一般是 0
vt  0.897 0.370 0.000 
  • 面(Face),第一参数索引,第二参数索引,第三参数索引。

每个坐标信息为:顶点索引/纹理索引/法线索引

f 1193/1240/1193 1180/1227/1180 1179/1226/1179

顶点法线(vertex normal),表示一个顶点的法线,计算光照会用到。

vn  0.617 0.401 0.678 

可以使用3D查看软件观察模型:

tga图像

第 2 课:三角形光栅化和背面剔除

 https://github.com/ssloy/tinyrenderer/wiki/Lesson-2:-Triangle-rasterization-and-back-face-culling

1.光栅化三角形,传统方法-扫描线段法

非常朴素的思想。

通过上一节课的line()函数绘制三角形。

void line(...){....}

struct triangle {
    Vec2i A, B, C;
    TGAColor color;
    TGAImage& img;

    triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) : A(t0), B(t1), C(t2), color(color), img(image) {}

    void draw() {
        line(A, B, img, color);
        line(B, C, img, color);
        line(C, A, img, color);
    }
};





int main(int argc, char** argv) {
    TGAImage image(width, height, TGAImage::RGB);

    Vec2i t0[3] = {Vec2i(10, 70), Vec2i(50, 160), Vec2i(70, 80)};
    Vec2i t1[3] = {Vec2i(180, 50), Vec2i(150, 1), Vec2i(70, 180)};
    Vec2i t2[3] = {Vec2i(180, 150), Vec2i(120, 160), Vec2i(130, 180)};

    triangle T0(t0[0], t0[1], t0[2], image, red);
    triangle T1(t1[0], t1[1], t1[2], image, white);
    triangle T2(t2[0], t2[1], t2[2], image, green);

    T0.draw();
    T1.draw();
    T2.draw();

    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");
    return 0;
}

根据上节课TinyRenderer Lesson 1模拟直线的方式,首先以按y值大小对顶点排序( Ay<By<Cy ),然后将三角形分为上半部分和下半部分填充。

void fill() {
    if (A.y > B.y) std::swap(A, B);
    if (A.y > C.y) std::swap(A, C);
    if (B.y > C.y) std::swap(B, C);

    float m0 = 1.f * (A.x - C.x) / (A.y - C.y);
    float m1 = 1.f * (A.x - B.x) / (A.y - B.y);
    float error_x0, error_x1;
    int x0 = A.x, x1 = A.x;

    for (int i = A.y; i <= B.y; i++) {  // 下半部分
        img.set(std::round(x0), i, color);
        img.set(std::round(x1), i, color);
        x0 += m0;
        x1 += m1;
    }
}

把上半部分补完即可:

void fill() {
    if (A.y > B.y) std::swap(A, B);
    if (A.y > C.y) std::swap(A, C);
    if (B.y > C.y) std::swap(B, C);

    float m0 = 1.f * (A.x - C.x) / (A.y - C.y);
    float m1 = 1.f * (A.x - B.x) / (A.y - B.y);
    float m2 = 1.f * (B.x - C.x) / (B.y - C.y);
    float x0 = A.x, x1 = A.x, x2 = B.x;

    for (int i = A.y; i <= B.y; i++) {  // 下半部分
        img.set(std::round(x0), i, color);
        img.set(std::round(x1), i, color);
        x0 += m0;
        x1 += m1;
    }

    for (int i = B.y; i <= C.y; i++) {  // 上半部分
        img.set(round(x0), i, color);
        img.set(round(x2), i, color);
        x0 += m0;
        x2 += m2;
    }
}

此时我们没有和Lesson 1 一样判断是否steep,原因是后续扫描时候会自动填充。

光栅化:

void fill() {
    if (A.y > B.y) std::swap(A, B);
    if (A.y > C.y) std::swap(A, C);
    if (B.y > C.y) std::swap(B, C);

    float m0 = 1.f * (A.x - C.x) / (A.y - C.y);
    float m1 = 1.f * (A.x - B.x) / (A.y - B.y);
    float m2 = 1.f * (B.x - C.x) / (B.y - C.y);
    float x0 = A.x, x1 = A.x, x2 = B.x;

    for (int i = A.y; i <= B.y; i++) {  // 下半部分
        int l = round(x0), r = round(x1);
        if (l > r) std::swap(l, r);
        for (int j = l; j <= r; j++) {
            img.set(j, i, color);
            img.set(j, i, color);
        }
        x0 += m0;
        x1 += m1;
    }

    for (int i = B.y; i <= C.y; i++) {  // 上半部分
        int l = round(x0), r = round(x2);
        if (l > r) std::swap(l, r);
        for (int j = l; j <= r; j++) {
            img.set(j, i, color);
            img.set(j, i, color);
        }
        x0 += m0;
        x2 += m2;
    }
}

2.光栅化三角形,现代方法

引理2.1:对于平面中直线上的两不同坐标点 A,B 和任意一点 P 总存在 P=αA+βB,且 α+β=1 ,且仅当 α⩾0,β⩾0 时有 P 在线段 AB 上。

定理2.2:对于平面中三个不同标点 A,B,C 和任意一点 P 总存在 P=αA+βB+γC,且 α+β+γ=1 ,且仅当 α⩾0,β⩾0,γ⩾0 时有 P 在 ABC 三点构成的三角形内(包括边界)

证:设 D 为直线 AB 上的任意一点,P为直线 PC 上任意一点,此时根据引理2.1有 {D=xA+(1−x)Bp=yD+(1−y)C

化简得 P=xyA+(y−xy)B+(1−y)C ,令 xy=α,y−xy=β,1−y=γ ,则 α+β+γ=1。当 P 在 ΔABC 内时(包括边界), x∈[0,1],y∈[0,1] 可以得到 α⩾0,β⩾0,γ⩾0.

该定理即三角形的重心坐标(barycentric coordinates)插值公式。三角形的重心即 (A3,B3,C3) .

推论定理2.2有一个更加直观的等价形式:

ΔABC 被点 P 分为 ΔA,ΔB,ΔC,设{α=SASΔABCβ=SBSΔABCγ=SCSΔABC当点 P 在三角形内时,显然有 α+β+γ=1 且 α⩾0,β⩾0,γ⩾0.

由叉乘公式的几何意义可知 SA=12‖PB→×PC→‖=12‖|xyzx1y1z1x2y2z2|‖ , SB,SC 同理。

本次实验中仅考虑二维向空间 xOy,设点 P(x,y) 计算可得

γ=(yA−yB)x+(xB−xA)y+xAyB−xByA(yA−yB)xC+(xB−xA)yC+xAyB−xByAβ=(yA−yC)x+(xC−xA)y+xAyC−xCyA(yA−yC)xB+(xC−xA)yB+xAyC−xCyAα=1−γ−β

所以判断某一点是否在三角形内只需判断 α⩾0,β⩾0,γ⩾0 即可。

在games 101 中判断点是否位于三角形内使用的是三角形顶点首尾相连的三个向量与该点连线向量构成的三个叉乘是否全部大于0。

Vec3f barycentric(Vec2i _P) {
    float gamma =
        1.f * ((v[0].y - v[1].y) * _P.x + (v[1].x - v[0].x) * _P.y + v[0].x * v[1].y - v[1].x * v[0].y) / ((v[0].y - v[1].y) * v[2].x + (v[1].x - v[0].x) * v[2].y + v[0].x * v[1].y - v[1].x * v[0].y);
    float beta =
        1.f * ((v[0].y - v[2].y) * _P.x + (v[2].x - v[0].x) * _P.y + v[0].x * v[2].y - v[2].x * v[0].y) / ((v[0].y - v[2].y) * v[1].x + (v[2].x - v[0].x) * v[1].y + v[0].x * v[2].y - v[2].x * v[0].y);
    float alpha = 1.f - gamma - beta;
    return Vec3f(alpha, beta, gamma);
}

通过找出三角形的包围盒(Bounding box)来确定该点是否需要光栅化。

包围盒比较简单的方法就是求出四个边界(还有效率高些的使用凸包求)。

void raster(TGAImage& img) {
    int minx = std::min({v[0].x, v[1].x, v[2].x});
    int miny = std::min({v[0].y, v[1].y, v[2].y});
    int maxx = std::max({v[0].x, v[1].x, v[2].x});
    int maxy = std::max({v[0].y, v[1].y, v[2].y});

    for (int i = minx; i <= maxx; i++) {
        for (int j = miny; j <= maxy; j++) {
            Vec3f arg = barycentric(Vec2i{i, j});
            if (arg.x < -EPS || arg.y < -EPS || arg.z < -EPS) continue;
            img.set(i, j, red);
        }
    }
}


初步渲染

视口变换(Viewport Transformation)

在上一篇笔记中,原作者提供了一个人脸三位模型,上一节课只将其三角形的边光栅化,现在对其进行着色。

回顾一下Games101课程中的MVP变换(Model View Projection Transformation)视口变换(Viewport Transformation)。当然,MVP变换会在后面实现。在默认初始状态下,我们可以认为这节课中的MVP变换是恒等变换,现在还剩下视口变换需要我们解决。

目前所有的点都位于NDC(Normalized Device Coordiantes,标准化设备坐标)空间中,即所有的物体归一化到 [−1,1]3 的有限空间中,不考虑深度 z 轴,映射结果便是一个 [0,w][0,h] 的屏幕空间。

S=[w200w0h20h00100001][xyz1]

以上仿射变换可以简写成 Sx=x+12w,Sy=y+12h

#include <algorithm>
#include <cmath>
#include <vector>
#include "model.h"
#include "tgaimage.h"

const TGAColor white = TGAColor(255, 255, 255, 255);
const TGAColor red = TGAColor(255, 0, 0, 255);
const TGAColor green = TGAColor(0, 255, 0, 255);

#define EPS 1E-5
#define INF 2E9

namespace rst {

struct rasterizer {
    std::vector<Vec3f> v;

    rasterizer(std::vector<Vec3f> _v) : v(_v) {}

    Vec3f barycentric(Vec2i _P) {}

    void raster(TGAImage& img) {
        int minx = std::min({v[0].x, v[1].x, v[2].x});
        int miny = std::min({v[0].y, v[1].y, v[2].y});
        int maxx = std::max({v[0].x, v[1].x, v[2].x});
        int maxy = std::max({v[0].y, v[1].y, v[2].y});

        TGAColor color(rand() % 255, rand() % 255, rand() % 255, 255);  // 随机染色

        for (int i = minx; i <= maxx; i++) {
            for (int j = miny; j <= maxy; j++) {
                Vec3f arg = barycentric(Vec2i{i, j});
                if (arg.x < -EPS || arg.y < -EPS || arg.z < -EPS) continue;
                img.set(i, j, color);
            }
        }
    }
};
}  // namespace rst

int main(int argc, char** argv) {
    const int width = 200;
    const int height = 200;
    Model* model = NULL;

    TGAImage image(width, height, TGAImage::RGB);

    if (2 == argc) {
        model = new Model(argv[1]);
    } else {
        model = new Model("obj/african_head.obj");
    }

    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<int> face = model->face(i);
        std::vector<Vec3f> screen_coords(3);

        for (int j = 0; j < 3; j++) {
            Vec3f v = model->vert(face[j]);                                                    // 获取第i个面片的第j个顶点
            screen_coords[j] = Vec3f((v.x + 1.) * width / 2., (v.y + 1.) * height / 2., v.z);  // 第三维度保留深度
        }
        rst::rasterizer r(screen_coords);
        r.raster(image);
    }
    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");
    return 0;
}

这种三角形面片上使用一样的颜色被称为平面着色(Flat Shading),不过此处用随机颜色着色的方法并没有考虑光照等其它因素,这种着色方法更准确说是固定着色(Pure shading)

该图原作者的分辨率更高

平面着色Flat Shading)/光照模型

上述渲染出来的结果有些诡异了。于是我们考虑使用布林·冯反射模型(Blinn-Phong Reflectance Model)把这个头重新着色一遍。

定义该模型一点的光照强度(Light intensity)= 环境光(Ambient)+漫反射(Diffuse)+高光/镜面反射(Specular)。在这节课中,作者阉割掉了环境光和高光,只剩下漫反射。

定律2.3:朗伯余弦定律(Lambert`s cosine law)

单位面积上,从面元法线方向射入的光照辐射度与在 θ 角方向上的光照辐射度的关系满足:

I(θ)=ncos⁡θ

基于朗伯余弦定律衍生出来的朗伯着色模型(Lambertian Shading)

Ld=kdIr2⋅max(0,n^⋅l^)

其中

  • Ld 代表漫反射光照辐射度。(diffusely reflected light)
  • kd 代表颜色的扩散系数(color diffuse coeffiicent),即这个物体越'粗糙',系数越大 。
  • Ir2 代表光源到着色点时的能量。

三角形面片上的法向量就更简单了,任意两条向量叉乘就是法线方向(或反方向),根据法线方向,我们可以确定唯一的光照强度在该三角形面片上,即平面着色

我们默认平行光线源位于 (0,0,−1) 即摄像头的位置。

此次实验中我们省略距离和其它常数。

背面剔除

当光线与法线夹角 ⩾π2 时我们认为不存在反射,即光线照射在三角形的背面上。既然看不到,那就不渲染它。

定义2.4:定义顺时针和逆时针的顶点绕序。顺时针的顶点绕序为三角形的各个顶点顺时针首尾相连,逆时针的顶点绕序为三角形的各个顶点逆时针首尾相连。

根据如上定义,我们可以容易找出三角形法线的朝向。

int main(int argc, char** argv) {
    const int width = 200;
    const int height = 200;

    const Vec3f light_dir(0, 0, -1);

    Model* model = NULL;

    TGAImage image(width, height, TGAImage::RGB);

    if (2 == argc) {
        model = new Model(argv[1]);
    } else {
        model = new Model("obj/african_head.obj");
    }

    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<int> face = model->face(i);
        std::vector<Vec3f> screen_coords(3);
        std::vector<Vec3f> world_coords(3);

        for (int j = 0; j < 3; j++) {
            Vec3f v = model->vert(face[j]);                                                    // 获取第i个面片的第j个顶点
            screen_coords[j] = Vec3f((v.x + 1.) * width / 2., (v.y + 1.) * height / 2., v.z);  // 第三维度保留深度
            world_coords[j] = v;
        }
        Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
        n.normalize();
        float light_intensity = n * light_dir;

        if (light_intensity > 0) {
            rst::rasterizer r(screen_coords, light_intensity);
            r.raster(image);
        }
    }
    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");
    return 0;
}

实验中采用逆时针顶点绕序,在右手系中法线 n 的方向与上面代码中的变量n 方向相反,且光线 l 也恰好相反,所以结果相同

当然,背面剔除并不能消去同向不同深度的面片,并且在光照方向和摄像机朝向不同时也会造成问题。在其它更复杂的模型中,会用到深度缓冲(Z-buffer)

第三课 z 缓冲

Lesson 3: Hidden faces removal (z buffer) · ssloy/tinyrenderer Wiki · GitHub

git checkout Chapter_Three_Final

深度缓冲(z-buffer)

在上一节课的末尾提到了深度缓冲,用于处理三维空间中物体的遮挡关系。

我们很容易想到一个朴素的方法,把场景中的所有元素按远近排列,先画远的再画近的,这样就能保证正确的遮挡关系,这种算法我们称为画家算法(Painter`s algorithm)。不过这种方法过于暴力,也无法解决元素相互遮挡的问题,下面介绍一种更优的算法。

深度缓冲算法记录的是屏幕上每像素的深度,每次更新像素时判断如果深度小于缓冲中的记录就更新

度使用上一节定理2.2中的结论:Pz=αAz+βBz+γCz,来对三角形面片内部的像素进行插值。

具体实现:

#include <vector>
#include <cmath>
#include <limits>
#include "tgaimage.h"
#include "model.h"
#include "geometry.h"

const int width  = 800;
const int height = 800;
const int depth  = 255;

Model *model = NULL;
int *zbuffer = NULL;
Vec3f light_dir(0,0,-1);

void triangle(Vec3i t0, Vec3i t1, Vec3i t2, TGAImage &image, TGAColor color, int *zbuffer) {
    if (t0.y==t1.y && t0.y==t2.y) return; // i dont care about degenerate triangles
    if (t0.y>t1.y) std::swap(t0, t1);
    if (t0.y>t2.y) std::swap(t0, t2);
    if (t1.y>t2.y) std::swap(t1, t2);
    int total_height = t2.y-t0.y;
    for (int i=0; i<total_height; i++) {
        bool second_half = i>t1.y-t0.y || t1.y==t0.y;
        int segment_height = second_half ? t2.y-t1.y : t1.y-t0.y;
        float alpha = (float)i/total_height;
        float beta  = (float)(i-(second_half ? t1.y-t0.y : 0))/segment_height; // be careful: with above conditions no division by zero here
        Vec3i A =               t0 + (t2-t0)*alpha;
        Vec3i B = second_half ? t1 + (t2-t1)*beta : t0 + (t1-t0)*beta;
        if (A.x>B.x) std::swap(A, B);
        for (int j=A.x; j<=B.x; j++) {
            float phi = B.x==A.x ? 1. : (float)(j-A.x)/(float)(B.x-A.x);
            Vec3i P = A + (B-A)*phi;
            P.x = j; P.y = t0.y+i; // a hack to fill holes (due to int cast precision problems)
            int idx = j+(t0.y+i)*width;
            if (zbuffer[idx]<P.z) {
                zbuffer[idx] = P.z;
                image.set(P.x, P.y, color); // attention, due to int casts t0.y+i != A.y
            }
        }
    }
}

int main(int argc, char** argv) {
    if (2==argc) {
        model = new Model(argv[1]);
    } else {
        model = new Model("D:\\opencode\\tiny_renderer_test\\tinyrenderer\\obj\\african_head.obj");
    }

    zbuffer = new int[width*height];
    for (int i=0; i<width*height; i++) {
        zbuffer[i] = std::numeric_limits<int>::min();
    }

    { // draw the model
        TGAImage image(width, height, TGAImage::RGB);
        for (int i=0; i<model->nfaces(); i++) {
            std::vector<int> face = model->face(i);
            Vec3i screen_coords[3];
            Vec3f world_coords[3];
            for (int j=0; j<3; j++) {
                Vec3f v = model->vert(face[j]);
                screen_coords[j] = Vec3i((v.x+1.)*width/2., (v.y+1.)*height/2., (v.z+1.)*depth/2.);
                world_coords[j]  = v;
            }
            Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]);
            n.normalize();
            float intensity = n*light_dir;
            if (intensity>0) {
                triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255), zbuffer);
            }
        }

        image.flip_vertically(); // i want to have the origin at the left bottom corner of the image
        image.write_tga_file("output.tga");
    }

    { // dump z-buffer (debugging purposes only)
        TGAImage zbimage(width, height, TGAImage::GRAYSCALE);
        for (int i=0; i<width; i++) {
            for (int j=0; j<height; j++) {
                zbimage.set(i, j, TGAColor(zbuffer[i+j*width], 1));
            }
        }
        zbimage.flip_vertically(); // i want to have the origin at the left bottom corner of the image
        zbimage.write_tga_file("zbuffer.tga");
    }
    delete model;
    delete [] zbuffer;
    return 0;
}

纹理映射

回顾TinyRenderer笔记 (1)中 .obj文件的格式,现在我们需要给模型套上一层纹理。

二维纹理上的点一一映射到模型上的每一个顶点,我们把这种映射定义为纹理映射(又称纹理贴图),把这样的二维坐标称作uv坐标,本实验中所用到的漫反射贴图就是纹理贴图中的一种。三角形面片中的顶点即可使用定理2.2进行插值。

Games101 Lecture08

为了实现代码需要更新以下内容:

从tinyrenderer master分支中找个这个文件放置到这个目录下

下载漫反射贴图(可能要使用魔法)

更新model.h :

#ifndef __MODEL_H__
#define __MODEL_H__

#include <vector>
#include "geometry.h"
#include "tgaimage.h"

class Model {
   private:
    std::vector<Vec3f> verts_;               // 顶点
    std::vector<std::vector<Vec3i>> faces_;  // 面
    std::vector<Vec3f> norms_;               // 法线
    std::vector<Vec2f> uv_;                  // uv映射
    TGAImage diffuseMap_;                    // 漫反射(纹理)贴图
    void loadTexture(std::string filename, const char* suffix, TGAImage& image);

   public:
    Model(const char* filename);
    ~Model();
    int nverts();
    int nfaces();
    Vec3f vert(int i);
    Vec2i uv(int i);
    std::vector<Vec3i> face(int idx);
    TGAColor diffuse(Vec2i uv);
};
#endif  //__MODEL_H__ 

更新model.cpp

#include "model.h"
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>

Model::Model(const char* filename) : verts_(), faces_() {
    std::ifstream in;
    in.open(filename, std::ifstream::in);
    if (in.fail()) return;
    std::string line;
    while (!in.eof()) {
        std::getline(in, line);
        std::istringstream iss(line.c_str());
        char trash;
        if (!line.compare(0, 2, "v ")) {
            iss >> trash;
            Vec3f v;
            for (int i = 0; i < 3; i++) iss >> v.raw[i];
            verts_.push_back(v);
        } else if (!line.compare(0, 3, "vt ")) {
            iss >> trash >> trash;
            Vec2f uv;
            for (int i = 0; i < 2; i++) iss >> uv.raw[i];
            uv_.push_back(uv);
        } else if (!line.compare(0, 3, "vn ")) {
            iss >> trash >> trash;
            Vec3f normal;
            for (int i = 0; i < 3; i++) iss >> normal.raw[i];
            norms_.push_back(normal);
        } else if (!line.compare(0, 2, "f ")) {
            std::vector<Vec3i> f;
            Vec3i idx;
            iss >> trash;
            while (iss >> idx.raw[0] >> trash >> idx.raw[1] >> trash >> idx.raw[2]) {
                for (int i = 0; i < 3; i++)  // in wavefront obj all indices start at 1, not zero
                    idx.raw[i]--;
                f.push_back(idx);
            }
            faces_.push_back(f);
        }
    }
    std::cerr << "# v# " << verts_.size() << " f# " << faces_.size() << std::endl;
    loadTexture(filename, "_diffuse.tga", diffuseMap_);
}

Model::~Model() {}

int Model::nverts() {
    return (int)verts_.size();
}

int Model::nfaces() {
    return (int)faces_.size();
}

std::vector<Vec3i> Model::face(int idx) {
    std::vector<Vec3i> res;
    for (auto item : faces_[idx]) res.push_back(item);
    return res;  // 返回三个顶点的下标
}

Vec3f Model::vert(int i) {
    return verts_[i];
}

void Model::loadTexture(std::string filename, const char* suffix, TGAImage& image) {
    std::string texfile(filename);
    size_t dot = texfile.find_last_of(".");
    if (dot != std::string::npos) {
        texfile = texfile.substr(0, dot) + std::string(suffix);
        std::cerr << "texture file " << texfile << " loading " << (image.read_tga_file(texfile.c_str()) ? "ok" : "failed") << std::endl;
        image.flip_vertically();
    }
}

TGAColor Model::diffuse(Vec2i uv) {
    return diffuseMap_.get(uv.x, uv.y);
}

Vec2i Model::uv(int i) {
    return Vec2i(uv_[i].x * diffuseMap_.get_width(), uv_[i].y * diffuseMap_.get_height());  // uv保存的坐标是归一化的
}

可能这里有点疑惑,关于Vec3 和Vec2 成员变量名的问题。

我们可以在geometry.h 中找到该对象的定义:

template <class t>
struct Vec3 {
    union {
        struct {
            t x, y, z;
        };
        struct {
            t ivert, iuv, inorm;
        };
        t raw[3];
    };

...
}

实际上由于 union 所有的成员占用同一块内存空间,所以 struct 子类中的变量类型相同那么它们就是等价的。例如 x 等价于 ivert 等价于 raw[0]

接下来就是写主函数了,这一步我们只需要将贴图中的颜色映射到像素块中就好了:

#include <algorithm>
#include <cmath>
#include <vector>
#include "geometry.h"
#include "model.h"
#include "tgaimage.h"

const TGAColor white = TGAColor(255, 255, 255, 255);
const TGAColor red = TGAColor(255, 0, 0, 255);
const TGAColor green = TGAColor(0, 255, 0, 255);

#define EPS 1E-9
#define INF 2E9

namespace rst {
    struct rasterizer {
        std::vector<Vec3f> v;
        std::vector<Vec2i> uv;
        float intensity;

        rasterizer(std::vector<Vec3f> _v, std::vector<Vec2i> _uv, float _intensity) : v(_v), uv(_uv), intensity(_intensity) {}

        Vec3f barycentric(Vec2i _P) {
            float gamma = 1.f * ((v[0].y - v[1].y) * _P.x + (v[1].x - v[0].x) * _P.y + v[0].x * v[1].y - v[1].x * v[0].y) /
                ((v[0].y - v[1].y) * v[2].x + (v[1].x - v[0].x) * v[2].y + v[0].x * v[1].y - v[1].x * v[0].y);
            float beta = 1.f * ((v[0].y - v[2].y) * _P.x + (v[2].x - v[0].x) * _P.y + v[0].x * v[2].y - v[2].x * v[0].y) /
                ((v[0].y - v[2].y) * v[1].x + (v[2].x - v[0].x) * v[1].y + v[0].x * v[2].y - v[2].x * v[0].y);
            float alpha = 1.f - gamma - beta;
            return Vec3f(alpha, beta, gamma);
        }

        void raster(std::vector<float>& Zbuffer, Model* model, TGAImage& img, int width) {
            int minx = std::min({ v[0].x, v[1].x, v[2].x });
            int miny = std::min({ v[0].y, v[1].y, v[2].y });
            int maxx = std::max({ v[0].x, v[1].x, v[2].x });
            int maxy = std::max({ v[0].y, v[1].y, v[2].y });

            for (int i = minx; i <= maxx; i++) {
                for (int j = miny; j <= maxy; j++) {
                    Vec3f arg = barycentric(Vec2i{ i, j });

                    if (arg.x < -EPS || arg.y < -EPS || arg.z < -EPS) continue;

                    float depth = arg.x * v[0].z + arg.y * v[1].z + arg.z * v[2].z;
                    if (Zbuffer[i + j * width] < depth) {
                        Vec2i barycentric_uv = uv[0] * arg.x + uv[1] * arg.y + uv[2] * arg.z;  // uv也要插值
                        TGAColor color = model->diffuse(barycentric_uv);
                        img.set(i, j, TGAColor(color.r * intensity, color.g * intensity, color.g * intensity, 255));
                        Zbuffer[i + j * width] = depth;
                    }
                }
            }
        }
    };
}  // namespace rst

int main(int argc, char** argv) {
    Model* model = NULL;
    const int width = 200;
    const int height = 200;

    const Vec3f light_dir(0, 0, -1);

    TGAImage image(width, height, TGAImage::RGB);

    if (2 == argc) {
        model = new Model(argv[1]);
    }
    else {
        model = new Model("D:\\opencode\\tiny_renderer_test\\tinyrenderer\\obj\\african_head.obj");
    }

    std::vector<float> Zbuffer((width + 1) * (height + 1), -INF);

    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<Vec3i> face = model->face(i);  // 获取模型的第i个面片的下标
        std::vector<Vec3f> screen_coords(3);
        std::vector<Vec3f> world_coords(3);
        std::vector<Vec2i> _uv(3);

        for (int j = 0; j < 3; j++) {
            Vec3f v = model->vert(face[j].ivert);  // 获取第i个面片的第j个顶点
            _uv[j] = model->uv(face[j].iuv);
            screen_coords[j] = Vec3f((v.x + 1.) * width / 2., (v.y + 1.) * height / 2., v.z);  // 第三维度保留深度
            world_coords[j] = v;
        }
        Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
        n.normalize();
        float light_intensity = n * light_dir;

        if (light_intensity >= 0) {
            rst::rasterizer r(screen_coords, _uv, light_intensity);
            r.raster(Zbuffer, model, image, width);
        }
    }

    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");
    return 0;
}

和之前的不同点:

效果

第四课 透视投影

正交投影(Orthographic Projection)

定义盒状可视空间,盒状可视空间是一个空间立方体 ([l,r][t,b][n,f]),由上下左右裁切面以及近裁切面、远裁切面组成,靠近原点的面为近裁切面,远离原点为远裁切面。只有在可视空间内的物体才会被绘制。

看向-z方向

正交投影的目的是将可视空间内的物体投影到 xOy 平面内,这一步只需要舍去 z 维度就可以了,但是我们需要更加规范化的形式,即将可视空间中的物体变换到 [−1,1]3的 NDC 空间中,所以我们需要进行平移和归一化的操作。

先平移再缩放可以得到正投影变换矩阵:

透视投影(Perspective Projection)

将视锥内的物体映射成盒状可视空间,符合近大远小的视觉特征。

本小节中的特殊投影

第五课 

在TinyRenderer Lesson 2中初步讲过了平面着色,这种着色方法虽然快,但是效果并不是很好。

现在介绍两种更加平滑的着色方式:

  • 高洛德着色(Gouraud shading)又称顶点着色,先计算面片顶点的光照强度,再根据定理2.2对光照值进行插值。
  • 冯氏着色(Phong Shading)又称像素着色,先对顶点的法向量进行插值,得到每个像素上的法向量结果,再计算光照。这种方法对于顶点着色原方法颜色渐变更加平滑,性能开销也更大。

实现(Phong Shading

model.h 和model.cpp 中添加方法

Vec3f Model::norm(int i) {
    return norms_[i];
}

修改main.cpp

#include <algorithm>
#include <cmath>
#include <vector>
#include "geometry.h"
#include "model.h"
#include "tgaimage.h"

const TGAColor white = TGAColor(255, 255, 255, 255);
const TGAColor red = TGAColor(255, 0, 0, 255);
const TGAColor green = TGAColor(0, 255, 0, 255);

#define EPS 1E-5
#define INF 2E9

namespace rst {

struct rasterizer {
    std::vector<Vec3f> v;
    std::vector<Vec3f> vn;
    std::vector<Vec2i> uv;

    rasterizer(std::vector<Vec3f> _v, std::vector<Vec3f> _vn, std::vector<Vec2i> _uv) : v(_v), vn(_vn), uv(_uv) {}

    Vec3f barycentric(Vec2i _P) {
        float gamma = 1.f * ((v[0].y - v[1].y) * _P.x + (v[1].x - v[0].x) * _P.y + v[0].x * v[1].y - v[1].x * v[0].y) /
                      ((v[0].y - v[1].y) * v[2].x + (v[1].x - v[0].x) * v[2].y + v[0].x * v[1].y - v[1].x * v[0].y);
        float beta = 1.f * ((v[0].y - v[2].y) * _P.x + (v[2].x - v[0].x) * _P.y + v[0].x * v[2].y - v[2].x * v[0].y) /
                     ((v[0].y - v[2].y) * v[1].x + (v[2].x - v[0].x) * v[1].y + v[0].x * v[2].y - v[2].x * v[0].y);
        float alpha = 1.f - gamma - beta;

        return Vec3f(alpha, beta, gamma);
    }

    void raster(std::vector<float>& Zbuffer, Model* model, TGAImage& img, int width, Vec3f light_dir) {
        int minx = std::min({v[0].x, v[1].x, v[2].x});
        int miny = std::min({v[0].y, v[1].y, v[2].y});
        int maxx = std::max({v[0].x, v[1].x, v[2].x});
        int maxy = std::max({v[0].y, v[1].y, v[2].y});

        for (int i = minx; i <= maxx; i++) {
            for (int j = miny; j <= maxy; j++) {
                Vec3f arg = barycentric(Vec2i{i, j});

                if (arg.x < -EPS || arg.y < -EPS || arg.z < -EPS) continue;

                float depth = arg.x * v[0].z + arg.y * v[1].z + arg.z * v[2].z;
                if (Zbuffer[i + j * width] < depth) {
                    Vec2i barycentric_uv = uv[0] * arg.x + uv[1] * arg.y + uv[2] * arg.z;  // uv也要插值
                    Vec3f barycentric_norm = (vn[0] * arg.x + vn[1] * arg.y + vn[2] * arg.z).normalize();
                    TGAColor color = model->diffuse(barycentric_uv);
                    float intensity = -(barycentric_norm * light_dir);  // 注意光线方向与夹角
                    if (intensity < -EPS) continue;

                    img.set(i, j, TGAColor(intensity * color.r, intensity * color.g, intensity * color.b, 255));
                    Zbuffer[i + j * width] = depth;
                }
            }
        }
    }
};

Matrix local_to_homo(Vec3f v);
Vec3f homo_to_vertices(Matrix m);
Matrix modelMatrix();
Matrix projectionMatrix(Vec3f cameraPos);
Matrix projectionDivision(Matrix m);

// 物体局部坐标变换成齐次坐标
Matrix local_to_homo(Vec3f v) {
    Matrix m(4, 1);
    m[0][0] = v.x;
    m[1][0] = v.y;
    m[2][0] = v.z;
    m[3][0] = 1.f;
    return m;
}

// 齐次坐标变换成顶点坐标
Vec3f homo_to_vertices(Matrix m) {
    m = projectionDivision(m);
    return Vec3f(m[0][0], m[1][0], m[2][0]);
}

// 模型变换矩阵
Matrix modelMatrix() {
    return Matrix::identity(4);
}

// 视图变换矩阵
Matrix viewMatrix() {
    return Matrix::identity(4);
}

// 透视投影变换矩阵
Matrix projectionMatrix(Vec3f cameraPos) {
    Matrix projection = Matrix::identity(4);
    projection[3][2] = -1.f / cameraPos.z;

    Matrix ortho = Matrix::identity(4);

    return ortho * projection;
}

// 透视除法
Matrix projectionDivision(Matrix m) {
    m[0][0] = m[0][0] / m[3][0];
    m[1][0] = m[1][0] / m[3][0];
    m[2][0] = m[2][0] / m[3][0];
    m[3][0] = 1.f;
    return m;
}

Matrix viewPortMatrix(int x, int y, int w, int h, float depth) {
    Matrix m = Matrix::identity(4);
    m[0][0] = w / 2.f;
    m[1][1] = h / 2.f;
    m[2][2] = depth / 2.f;

    m[0][3] = x + w / 2.f;
    m[1][3] = y + h / 2.f;
    m[2][3] = depth / 2.f;

    return m;
}

}  // namespace rst

int main(int argc, char** argv) {
    Model* model = NULL;
    const int width = 800;
    const int height = 800;
    const float depth = 255.f;

    const Vec3f light_dir(0, 0, -1.f);
    const Vec3f cameraPos(0, 0, 3.f);  // 摄像机摆放的位置

    TGAImage image(width, height, TGAImage::RGB);

    if (2 == argc) {
        model = new Model(argv[1]);
    } else {
        model = new Model("obj/african_head.obj");
    }

    std::vector<float> Zbuffer((width + 1) * (height + 1), -INF);

    Matrix projection = rst::projectionMatrix(cameraPos);
    Matrix viewport = rst::viewPortMatrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth);
    Matrix Transformation = viewport * projection;

    // 主缓冲区
    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<Vec3i> face = model->face(i);  // 获取模型的第i个面片的下标
        std::vector<Vec3f> screen_coords(3);
        std::vector<Vec3f> world_coords(3);
        std::vector<Vec3f> vn(3);
        std::vector<Vec2i> _uv(3);

        for (int j = 0; j < 3; j++) {
            Vec3f v = model->vert(face[j].ivert);  // 获取第i个面片的第j个顶点
            vn[j] = model->norm(face[j].inorm);
            _uv[j] = model->uv(face[j].iuv);
            screen_coords[j] = rst::homo_to_vertices(Transformation * rst::local_to_homo(v));  // 第三维度保留深度
            world_coords[j] = v;
        }
        rst::rasterizer r(screen_coords, vn, _uv);
        r.raster(Zbuffer, model, image, width, light_dir);
    }

    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");

    delete model;
    return 0;
}


不使用uv贴图:


视图变换(View Transform)

平移

// 视图变换矩阵
Matrix viewMatrix(Vec3f camera, Vec3f center, Vec3f up = {0.f, 1.f, 0.f}) {
    Vec3f z = (camera - center).normalize();
    Vec3f x = (up ^ z).normalize();
    Vec3f y = (z ^ x).normalize();
    Matrix Mr_inv = Matrix::identity(4);
    Matrix Mt_inv = Matrix::identity(4);

    for (int i = 0; i < 3; i++) {
        Mr_inv[0][i] = x.raw[i];
        Mr_inv[1][i] = y.raw[i];
        Mr_inv[2][i] = z.raw[i];

        Mt_inv[i][3] = -center.raw[i];
    }
    return Mr_inv * Mt_inv;
}

这里修复一个小bug,当平行光照方向与视野方向不同时,背面剔除了光源与法线夹角大于90度的点,虽然该点亮度为0,但是 Z-buffer 也不会记录位置,这里只要将Zbuffer更新位置提前到 if (intensity < -EPS) continue; 语句之前即可。
详细可见 main.cpp

#include <algorithm>
#include <cmath>+

#include <vector>
#include "geometry.h"
#include "model.h"
#include "tgaimage.h"

const TGAColor white = TGAColor(255, 255, 255, 255);
const TGAColor red = TGAColor(255, 0, 0, 255);
const TGAColor green = TGAColor(0, 255, 0, 255);

#define EPS 1E-5
#define INF 2E9

namespace rst {

struct rasterizer {
    std::vector<Vec3f> v;
    std::vector<Vec3f> vn;
    std::vector<Vec2i> uv;

    rasterizer(std::vector<Vec3f> _v, std::vector<Vec3f> _vn, std::vector<Vec2i> _uv) : v(_v), vn(_vn), uv(_uv) {}

    Vec3f barycentric(Vec2i _P) {
        float gamma = 1.f * ((v[0].y - v[1].y) * _P.x + (v[1].x - v[0].x) * _P.y + v[0].x * v[1].y - v[1].x * v[0].y) /
                      ((v[0].y - v[1].y) * v[2].x + (v[1].x - v[0].x) * v[2].y + v[0].x * v[1].y - v[1].x * v[0].y);
        float beta = 1.f * ((v[0].y - v[2].y) * _P.x + (v[2].x - v[0].x) * _P.y + v[0].x * v[2].y - v[2].x * v[0].y) /
                     ((v[0].y - v[2].y) * v[1].x + (v[2].x - v[0].x) * v[1].y + v[0].x * v[2].y - v[2].x * v[0].y);
        float alpha = 1.f - gamma - beta;

        return Vec3f(alpha, beta, gamma);
    }

    void raster(std::vector<float>& Zbuffer, Model* model, TGAImage& img, int width, Vec3f light_dir) {
        int minx = std::min({v[0].x, v[1].x, v[2].x});
        int miny = std::min({v[0].y, v[1].y, v[2].y});
        int maxx = std::max({v[0].x, v[1].x, v[2].x});
        int maxy = std::max({v[0].y, v[1].y, v[2].y});

        for (int i = minx; i <= maxx; i++) {
            for (int j = miny; j <= maxy; j++) {
                Vec3f arg = barycentric(Vec2i{i, j});

                if (arg.x < -EPS || arg.y < -EPS || arg.z < -EPS) continue;

                float depth = arg.x * v[0].z + arg.y * v[1].z + arg.z * v[2].z;
                if (Zbuffer[i + j * width] < depth) {
                    Vec2i barycentric_uv = uv[0] * arg.x + uv[1] * arg.y + uv[2] * arg.z;  // uv也要插值
                    Vec3f barycentric_norm = (vn[0] * arg.x + vn[1] * arg.y + vn[2] * arg.z).normalize();
                    TGAColor color = model->diffuse(barycentric_uv);
                    float intensity = -(barycentric_norm * light_dir);  // 注意光线方向与夹角

                    Zbuffer[i + j * width] = depth;
                    if (intensity < -EPS) continue;

                    img.set(i, j, TGAColor(intensity * color.r, intensity * color.g, intensity * color.b, 255));
                }
            }
        }
    }
};

Matrix local_to_homo(Vec3f v);
Vec3f homo_to_vertices(Matrix m);
Matrix modelMatrix();
Matrix viewMatrix(Vec3f camera, Vec3f center, Vec3f up);
Matrix projectionDivision(Matrix m);

// 物体局部坐标变换成齐次坐标
Matrix local_to_homo(Vec3f v) {
    Matrix m(4, 1);
    m[0][0] = v.x;
    m[1][0] = v.y;
    m[2][0] = v.z;
    m[3][0] = 1.f;
    return m;
}

// 齐次坐标变换成顶点坐标
Vec3f homo_to_vertices(Matrix m) {
    m = projectionDivision(m);
    return Vec3f(m[0][0], m[1][0], m[2][0]);
}

// 模型变换矩阵
Matrix modelMatrix() {
    return Matrix::identity(4);
}
// 视图变换矩阵
Matrix viewMatrix(Vec3f camera, Vec3f center, Vec3f up = {0.f, 1.f, 0.f}) {
    Vec3f z = (camera - center).normalize();
    Vec3f x = (up ^ z).normalize();
    Vec3f y = (z ^ x).normalize();
    Matrix Mr_inv = Matrix::identity(4);
    Matrix Mt_inv = Matrix::identity(4);

    for (int i = 0; i < 3; i++) {
        Mr_inv[0][i] = x.raw[i];
        Mr_inv[1][i] = y.raw[i];
        Mr_inv[2][i] = z.raw[i];

        Mt_inv[i][3] = -center.raw[i];
    }
    return Mr_inv * Mt_inv;
}

// 透视投影变换矩阵
Matrix projectionMatrix(Vec3f cameraPos) {
    Matrix projection = Matrix::identity(4);
    projection[3][2] = -1.f / cameraPos.z;

    Matrix ortho = Matrix::identity(4);

    return ortho * projection;
}

// 透视除法
Matrix projectionDivision(Matrix m) {
    m[0][0] = m[0][0] / m[3][0];
    m[1][0] = m[1][0] / m[3][0];
    m[2][0] = m[2][0] / m[3][0];
    m[3][0] = 1.f;
    return m;
}

Matrix viewPortMatrix(int x, int y, int w, int h, float depth) {
    Matrix m = Matrix::identity(4);
    m[0][0] = w / 2.f;
    m[1][1] = h / 2.f;
    m[2][2] = depth / 2.f;

    m[0][3] = x + w / 2.f;
    m[1][3] = y + h / 2.f;
    m[2][3] = depth / 2.f;

    return m;
}

}  // namespace rst

int main(int argc, char** argv) {
    Model* model = NULL;
    const int width = 800;
    const int height = 800;
    const float depth = 255.f;

    const Vec3f light_dir = Vec3f(-1.f, -3.f, -5.f).normalize();  // 平行光线方向
    const Vec3f cameraPos(1.f, 1.f, 3.f);                         // 摄像机摆放的位置
    const Vec3f center(0.f, 0.f, 0.f);                            // 摄像朝向的位置

    TGAImage image(width, height, TGAImage::RGB);

    if (2 == argc) {
        model = new Model(argv[1]);
    } else {
        model = new Model("obj/african_head.obj");
    }

    std::vector<float> Zbuffer((width + 1) * (height + 1), -INF);

    Matrix projection = rst::projectionMatrix(cameraPos);
    Matrix viewport = rst::viewPortMatrix(width / 8, height / 8, width * 3 / 4, height * 3 / 4, depth);
    Matrix viewtransform = rst::viewMatrix(cameraPos, center);
    Matrix Transformation = viewport * projection * viewtransform;

    // 主缓冲区
    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<Vec3i> face = model->face(i);  // 获取模型的第i个面片的下标
        std::vector<Vec3f> screen_coords(3);
        std::vector<Vec3f> world_coords(3);
        std::vector<Vec3f> vn(3);
        std::vector<Vec2i> _uv(3);

        for (int j = 0; j < 3; j++) {
            Vec3f v = model->vert(face[j].ivert);  // 获取第i个面片的第j个顶点
            vn[j] = model->norm(face[j].inorm);
            _uv[j] = model->uv(face[j].iuv);
            screen_coords[j] = rst::homo_to_vertices(Transformation * rst::local_to_homo(v));  // 第三维度保留深度
            world_coords[j] = v;
        }
        rst::rasterizer r(screen_coords, vn, _uv);
        r.raster(Zbuffer, model, image, width, light_dir);
    }

    image.flip_vertically();  // i want to have the origin at the left bottom corner of the image
    image.write_tga_file("output.tga");

    delete model;
    return 0;
}

上述代码还有一些问题:每个顶点的法线向量也需要进行变换,我会在下一小节详细解释

第六课 软着色器

todo


http://www.kler.cn/a/527960.html

相关文章:

  • 企业知识库的建设助力企业快速响应市场变化与提升内部效率
  • 我的创作纪念日——成为创作者的 第365天(1年)
  • Springboot使用AOP时,需不需要引入AspectJ?
  • 【图床配置】PicGO+Gitee方案
  • HTML DOM 对象
  • 游戏开发领域 - 游戏引擎 UE 与 Unity
  • OpenCV:SIFT关键点检测与描述子计算
  • caddy2配置http_basic用于验证用户名密码才允许访问页面
  • 代码随想录|动态规划1143.最长公共子序列 1035.不相交的线 53. 最大子序和 392.判断子序列
  • 零代码搭建个人博客—Zblog结合内网穿透发布公网
  • 2025 年,链上固定收益领域迈向新时代
  • I.MX6ULL 中断介绍上
  • 推荐一款好看的Typora主题页面
  • MATLAB R2023b下载与安装教程
  • MongoDb user自定义 role 添加 action(collStats, EstimateDocumentCount)
  • 【MATLAB例程】TOA和AOA混合的高精度定位程序,适用于三维、N锚点的情况
  • 【vue项目权限控制方案】
  • Linux stat 命令使用详解
  • 内部知识库提升组织效率与知识共享助力业务快速发展
  • 开源的瓷砖式图像板系统Pinry
  • MySQL 插入数据
  • 【环境搭建】1.1源码下载与同步
  • 计算机网络之ISO/OSI参考模型和TCP/IP模型
  • 【4Day创客实践入门教程】Day0 创想启程——课程与项目预览
  • 【Qt5】声明之后快速跳转
  • WPS mathtype间距太大、显示不全、公式一键改格式/大小