OpenGL实现基本的光线追踪

2021年9月8日 5点热度 0条评论 来源: Morphlng

光线追踪

NZL 21/1/13

一、前言

这是我图形学这门课的期末作业,我觉得还挺有意思的就写一篇博客记录一下。本身这门课学的技术都是四五十年前的了,相当于图形学的入门课程,所以用到的公式,方法可能都过时了。我只是按照老师教的方法,结合一些论文的辅助,把一个基本的光线追踪算法实现了而已,如果有图形学大佬那么尽可以对本文嗤之以鼻。

二、项目结构

由于实现光照渲染需要用到GLSL,而我又没有该方面基础,因此使用了一个模板:framework.h/cpp,它来源于国外一门课程的仓库。按照模板的说明,你不应该动其中的任何一部分,通过实现framework.cpp中的几个函数即可运行。本次光线追踪项目,我们主要通过定义一系列物品、光、场景等结构,最终实现onInitializationonDisplay函数,从而完成渲染。

当然,这个模板需要依赖freeglutglew,若要运行,需要将glew32.dllfreeglut.dll两个文件放入系统的System32文件夹中,又或者放入生成的.exe文件所在文件夹中(一般在Debug里)。

三、材质定义

enum MaterialType {  ROUGH, REFLECTIVE, REFRACTIVE };

struct Material
{ 
	vec3 ka, kd, ks;	// 环境光照(Ambient),漫反射(Diffuse),镜面反射(Specular)系数,用于phong模型计算
	float shininess;	// 表面平整程度,用于镜面反射计算
	vec3 F0;			// 垂直入射时,反射光的占比。计算公式需要折射率n和消逝率k:[(n-1)^2+k^2]/[(n+1)^2+k^2]
	float ior;			// 折射率(index of refraction)
	MaterialType type;
	Material(MaterialType t) {  type = t; }
};

Material是我们定义的一个基类,表示物体的材质。根据枚举类型不难理解,我们将物体分为三种材质,粗糙(Rough)、反射型(Reflective)、折射型/透明物体(Refractive/Transparent)。

实际上,这三种材质划分了光线照射在物体表面的三种主要行为:

  1. 对于粗糙物体我们用Phong模型求解,ka,kd,ks分别为环境光系数漫反射系数镜面反射系数,特别的,在Phong模型中,镜面反射的公式为: I s = I i n c o m i n g ∗ k s ∗ ( R ⋅ V ) s h i n i n e s s I_s=I_{incoming}*k_s*(R·V)^{shininess} Is=Iincomingks(RV)shininess
    其中R·V表示反射光与视线的角度( c o s θ cos\theta cosθ),而shininess表示物体表面的平整程度(或者你也可以理解为镜面反射强度),因此单独设立一个变量表示。不过在实际计算时,我们用Halfway vector这个方法加速计算(因为R不好算),后面光线追踪时再说。

  2. 反射型和折射型的计算则用到了一个叫做Fresnel Function的公式(准确的说是简化版),具体的内容可以看这个论文:Optic Fundamentals。这里面定义了一个F0,表示“反射光的占比”,其计算公式为: ( n − 1 ) 2 + κ 2 ( n + 1 ) 2 + κ 2 \frac{(n-1)^2+\kappa^2}{(n+1)^2+\kappa^2} (n+1)2+κ2(n1)2+κ2
    这里面n是物体的折射率, κ \kappa κ是“消光系数”,表示光射入物体后的衰弱(diminish)的快慢,它的含义你可以看这里:Optical Properties of Electronic Materials: Fundamentals and Characterization
    这个公式中, κ \kappa κ这一项实际上只对金属物体有作用,因为金属的 κ \kappa κ值很大,计算出来的F0趋近于1,即所有光几乎都进行了反射。

三、射线类Ray、交点类Hit

光线追踪的一个简单的场景模型如下:

场景(Scene)中有一个摄像机,若干个物体,若干个光源。我们追踪的“光线”,实际上是从摄像机出发,穿过视窗上某一像素点的一条射线。追踪这条射线,求其与场景中所有物体是否有交,如果与所有物体都没有交点,则返回环境光;否则,计算距离最近的那个交点位置的着色,如果该交点处的材质是反射型的,那么继续追踪其反射射线,直到达到递归深度上限,或不再有交。

所以你看到这个描述之后就知道,我们需要定义需要追踪的射线“Ray”,以及交点“Hit”:

struct Ray		// 射线(从视角出发追踪的射线)
{ 
	vec3 start, dir;				// 起点,方向
	Ray(vec3 _start, vec3 _dir) { 
		start = _start;
		dir = normalize(_dir);		// 方向要进行归一化,因为在光照计算时我们认为参与运算的都是归一化的向量。
	}
};
struct Hit		// 光线和物体表面交点
{ 
	float t;					// 交点距光线起点距离,当t大于0时表示相交,默认取-1表示无交点
	vec3 position, normal;		// 交点坐标,法线
	Material* material;			// 交点处表面的材质
	Hit() {  t = -1; }
};

四、物品类、光类

上面的场景中还有两个重要的东西,就是“物体”与“光”。目前我已经扩展了平面类、球类与圆柱类,点光源。

class Intersectable			// 定义一个基类(接口),可交
{ 
public:
	virtual Hit intersect(const Ray& ray) = 0;		// 需要根据表面类型实现

protected:
	Material* material;
};
struct Light { 			// 定义光源
	vec3 direction;
	vec3 Le;			// 光照强度
	Light(vec3 _direction, vec3 _Le)
	{ 
		direction = _direction;
		Le = _Le;
	}
};

4.1 球体求交点

class Sphere : public Intersectable		// 定义球体
{ 
	vec3 center;
	float radius;
public:
	Sphere(const vec3& _center, float _radius, Material* _material)
	{ 
		center = _center;
		radius = _radius;
		material = _material;
	}
	~Sphere() { }

	Hit intersect(const Ray& ray){ }
};

由于这里我们用的全部都是向量表示,因此下面展示的是向量求解法,联立直线方程与球方程:

{ p = s + t ∗ d ∣ P 0 P ∣ = R \begin{cases} p=s+t*d\\ |P_0P|=R\\ \end{cases} { p=s+tdP0P=R

其中s表示光线起点的坐标(或者说是从世界坐标系原点到s点的向量),d表示光线的方向向量,P0是球心坐标,R为半径。

代入后的结果为:

d 2 ∗ t 2 + 2 ∗ d ∗ ( s − P 0 ) ∗ t + ( s − P 0 ) 2 − R 2 = 0 d^2*t^2+2*d*(s-P_0)*t+(s-P_0)^2-R^2=0 d2t2+2d(sP0)t+(sP0)2R2=0

a = d 2 , b = 2 ∗ d ∗ ( s − P 0 ) , c = ( s − P 0 ) 2 − R 2 a=d^2,b=2*d*(s-P_0),c=(s-P_0)^2-R^2 a=d2,b=2d(sP0),c=(sP0)2R2

这就很简单了(都变成标量了),求解 Δ \Delta Δ的正负就知道是否有交,而t值也很容易求出来。因此把上述过程写为代码就是:

Hit intersect(const Ray& ray)
{ 
	Hit hit;
	vec3 dist = ray.start - center;			// 距离
	float a = dot(ray.dir, ray.dir);		// dot表示点乘,这里是联立光线与球面方程
	float b = dot(dist, ray.dir) * 2.0f;
	float c = dot(dist, dist) - radius * radius;
	float discr = b * b - 4.0f * a * c;		// b^2-4ac
	if (discr < 0)		// 无交点
		return hit;
	float sqrt_discr = sqrtf(discr);
	float t1 = (-b + sqrt_discr) / 2.0f / a;	// 求得两个交点,t1 >= t2
	float t2 = (-b - sqrt_discr) / 2.0f / a;
	if (t1 <= 0)
		return hit;
	hit.t = (t2 > 0) ? t2 : t1;		// 取近的那个交点
	hit.position = ray.start + ray.dir * hit.t;
	hit.normal = (hit.position - center) / radius;
	hit.material = material;
	return hit;
}

4.2 平面求交点

class Plane :public Intersectable
{ 	// 点法式方程表示平面
	vec3 normal;		// 法线
	vec3 p0;			// 线上一点坐标,N(p-p0)=0
public:
	Plane(vec3 _p0, vec3 _normal, Material* _material)
	{ 
		normal = normalize(_normal);
		p0 = _p0;
		material = _material;
	}

	Hit intersect(const Ray& ray);
};

这里平面依然是用向量的表示方式,一个点、一条法线即可确定一个平面,方程为: N ( P − P 0 ) = 0 N(P-P_0)=0 N(PP0)=0

把直线方程代入之后,可以把t表示出来(推导可见)

N ⋅ P 0 − N ⋅ S N ⋅ D \frac{N·P_0-N·S}{N·D} NDNP0NS

注意,分母中的部分,用视线与平面法向量点乘,如果这个结果为0,那就表示视线是平行于平面观察的,那么自然不会有交点。而当解出的t小于0时,则表示交点在视线起点后方,自然是排出的。综上,代码如下:

Hit intersect(const Ray& ray)
{ 
	Hit hit;
	float nD = dot(ray.dir, normal);	// 射线方向与法向量点乘,为0表示平行
	if (nD == 0)
		return hit;
		
	float t1 = (dot(normal, p0) - dot(normal, ray.start)) / nD;
	if (t1 < 0)
		return hit;
	hit.t = t1;
	hit.position = ray.start + ray.dir * hit.t;
	hit.normal = normal;
	hit.material = material;
	return hit;
}

4.3 圆柱求交点

4.3.1 公式定义

圆柱体用向量表示的方法好像并不固定,这里我参考了一篇论文中给出的公式:

( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 (q-pa-(va,q-pa)va)^2-r^2=0 (qpa(va,qpa)va)2r2=0

在stackoverflow上面,我找到了一个合理的推导过程,因此我们就使用这个公式来表示圆柱。公式中括号表示点乘

上面这个公式中变量的含义如下:

  1. q,圆柱侧面上一点
  2. pa,圆柱任一截面的中心
  3. va,圆柱旋转轴的方向向量,归一化
  4. r,圆柱的旋转半径

不难发现,这样定义的实际上是一个无限长的圆柱面,而要给它加上底面也很简单,只需要在提供一个上底面圆心与下底面圆心即可,有限长圆柱体的表达式为:

( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 , ( v a , q − p 1 ) > 0 , ( v a , q − p 2 ) < 0 (q-pa-(va,q-pa)va)^2-r^2=0,(va,q-p1)>0,(va,q-p2)<0 (qpa(va,qpa)va)2r2=0,(va,qp1)>0,(va,qp2)<0

如下图所示:

因此圆柱的类型定义为:

class Cylinder :public Intersectable
{ 	// 无(有)限长圆柱面,(q-pa-(va,q-pa)va)^2-r^2=0,q是面上一点
	// (va,q-pa),是点乘
	vec3 va;		// 转轴方向
	vec3 pa;		// 转轴中心点(或者理解成某一截面的中心)
	float radius;	// 旋转半径
	vec3 p1;		// 下底面圆心,有(va,q-p1)>0,这个参数不给就是无限长圆柱
	vec3 p2;		// 上底面圆心,有(va,q-p2)<0,这个参数不给就是无限长圆柱

public:
	Cylinder(vec3 _pa, vec3 _va, float _radius, Material* _material, vec3 _p1 = vec3(0,0,0), vec3 _p2 = vec3(0,0,0))
	{ 
		pa = _pa;
		va = normalize(_va);
		radius = _radius;
		material = _material;
		p1 = _p1;	// 如果为(0,0,0)则说明用户定义无限长圆柱面
		p2 = _p2;
	}

	Hit intersect(const Ray& ray);

4.3.2 求交过程

对于一个圆柱体求交点的过程如下(以下步骤针对有限长圆柱,无限长圆柱面只需要第一步):

  1. 联立直线与侧面方程,求得可能交点t1、t2,将非负,且满足 ( v a , q i − p 1 ) > 0 (va,q_i-p_1)>0 (va,qip1)>0 ( v a , q i − p 2 ) < 0 (va,q_i-p_2)<0 (va,qip2)<0的点加入候选数组
  2. 联立直线与上、下底面方程,求得可能交点t3、t4,将非负,且满足 ( q 3 − p 1 ) 2 < r 2 (q_3-p_1)^2<r^2 (q3p1)2<r2 ( q 4 − p 2 ) 2 < r 2 (q_4-p_2)^2<r^2 (q4p2)2<r2的点加入候选数组
  3. 若候选数组非空,则选择最小的t值作为交点;反之,无交点

第二步推导很简单,下面写一下第一步的推导:

直 线 : q = s + d t , 侧 面 : ( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 直线:q=s+dt,侧面:(q-pa-(va,q-pa)va)^2-r^2=0 线q=s+dt(qpa(va,qpa)va)2r2=0

将 直 线 代 入 侧 面 方 程 : ( s − p a + d t − ( v a , s − p a + d t ) v a ) 2 − r 2 = 0 将直线代入侧面方程:(s-pa+dt-(va,s-pa+dt)va)^2-r^2=0 线(spa+dt(va,spa+dt)va)2r2=0

( d − ( d , v a ) v a ) 2 t + 2 ( d − ( d , v a ) v a , Δ p − ( Δ p , v a ) v a ) t + ( Δ p − ( Δ p , v a ) v a ) 2 − r 2 = 0 , 其 中 Δ p = s − p a (d-(d,va)va)^2t+2(d-(d,va)va,\Delta p-(\Delta p,va)va)t+(\Delta p-(\Delta p,va)va)^2-r^2=0,其中\Delta p=s-pa (d(d,va)va)2t+2(d(d,va)va,Δp(Δp,va)va)t+(Δp(Δp,va)va)2r2=0Δp=spa

上式为标准的 A t 2 + B t + C = 0 At^2+Bt+C=0 At2+Bt+C=0形式,因此可以通过判别式的正负确定有无交点,并易求t值。代码实现如下:

Hit intersect(const Ray &ray)
{ 
    Hit hit;
    // 把直线方程 q = s + dt 代入,则
    // (s - pa + dt - (va, s - pa + dt)va)^2 - r^2 = 0
    // t为未知量,形式为At^2+Bt+C=0
    // A = (d - (d,va)va)^2
    // B = 2(d - (d,va)va, Δp-(Δp,va)va)
    // C = (Δp - (Δp,va)va)^2 - r^2
    // Δp = s - pa
    vec3 A_operand = ray.dir - dot(ray.dir, va) * va;
    float A = dot(A_operand, A_operand);
    vec3 delta_p = ray.start - pa;
    vec3 B_operand = delta_p - dot(delta_p, va) * va;
    float B = 2 * dot(A_operand, B_operand);
    float C = dot(B_operand, B_operand) - radius * radius;
    float discr = B * B - 4 * A * C;
    if (discr < 0)
        return hit;

    float sqrt_discr = sqrtf(discr);
    float t1 = (-B + sqrt_discr) / 2 * A; // t1>=t2
    float t2 = (-B - sqrt_discr) / 2 * A;

    if (p1 == vec3(0, 0, 0) || p2 == vec3(0, 0, 0))
    { 
        if (t1 < 0)
            return hit;

        hit.t = (t2 > 0) ? t2 : t1; // 取近的那个交点
        hit.position = ray.start + ray.dir * hit.t;
        vec3 N = hit.position - pa - dot(va, hit.position - pa) * va;
        hit.normal = normalize(N);
        hit.material = material;
        //cout << hit << endl;
        return hit;
    }
    else
    { 
        vector<float> t_candidate;
        // 对于有限圆柱还要看交点是不是在上下底之间
        // 另外要判断上下底是否有交点

        // 第一步,判断侧面的两个交点是否有效(在上下底之间)
        vec3 q1 = ray.start + ray.dir * t1;
        vec3 q2 = ray.start + ray.dir * t2;
        if (t1 >= 0 && dot(va, q1 - p1) > 0 && dot(va, q1 - p2) < 0)
            t_candidate.push_back(t1);

        if (t2 >= 0 && dot(va, q2 - p1) > 0 && dot(va, q2 - p2) < 0)
            t_candidate.push_back(t2);

        // 第二步,求与上下底面的交点
        // 把直线公式代入下底面为:
        // (va,d)t + (va,s) - (va,p1) = 0
        float vad = dot(va, ray.dir);
        float vas = dot(va, ray.start);
        float vap1 = dot(va, p1);
        float t_bottom = (vap1 - vas) / vad;
        if (t_bottom >= 0)
        { 
            vec3 q3 = ray.start + ray.dir * t_bottom;
            if (dot(q3 - p1, q3 - p1) < radius * radius)
                t_candidate.push_back(t_bottom);
        }

        // 把直线公式代入上底面,则
        float vap2 = dot(va, p2);
        float t_up = (vap2 - vas) / vad;
        if (t_up >= 0)
        { 
            vec3 q4 = ray.start + ray.dir * t_up;
            if (dot(q4 - p2, q4 - p2) < radius * radius)
                t_candidate.push_back(t_up);
        }

        if (t_candidate.size() == 0)
            return hit;

        // 遍历所有候选t,找到最小的
        float mint = MAXINT;
        for (auto it = t_candidate.begin(); it != t_candidate.end(); it++)
        { 
            if (*it < mint)
                mint = *it;
        }

        hit.t = mint; // 取近的那个交点
        hit.position = ray.start + ray.dir * hit.t;
        if (mint == t1 || mint == t2)
        { 
            vec3 N = hit.position - pa - dot(va, hit.position - pa) * va;
            hit.normal = normalize(N);
        }
        else if (mint == t_up)
            hit.normal = va;
        else if (mint == t_bottom)
            hit.normal = -va;
        hit.material = material;
    }
    return hit;
}

五、摄像机类Camera

场景中有一台摄像机,表示用户的视角,我们主要是定义摄像机的位置,以及视窗(Image)的大小:

class Camera { 		// 用相机表示用户视线
	vec3 eye, lookat, right, up;		// eye用来定义用户位置;lookat(视线中心),right和up共同定义了视窗大小
	float fov;
public:
	void set(vec3 _eye, vec3 _lookat, vec3 _up, float _fov) // fov视场角
	{ 
		eye = _eye;
		lookat = _lookat;
		fov = _fov;
		vec3 w = eye - lookat;			
		float windowSize = length(w) * tanf(fov / 2);
		right = normalize(cross(_up, w)) * windowSize;  // 要确保up、right与eye到lookat的向量垂直(所以叉乘)
		up = normalize(cross(w, right)) * windowSize;
	}

	Ray getRay(int X, int Y)        // 返回穿过(x,y)位置像素的射线
	{ 
		vec3 dir = lookat + right * (2 * (X + 0.5f) / windowWidth - 1) + up * (2 * (Y + 0.5f) / windowHeight - 1) - eye;
		return Ray(eye, dir);
	}

	void Animate(float dt)		// 修改eye的位置(旋转)
	{ 
		vec3 d = eye - lookat;
		eye = vec3(d.x * cos(dt) + d.z * sin(dt), d.y, -d.x * sin(dt) + d.z * cos(dt)) + lookat;
		set(eye, lookat, up, fov);
	}
};

六、场景类Scene

现在我们已经万事俱备了,可以真正搭建起要进行光线追踪的“场景”了。

class Scene { 			// 场景,物品和光源集合
	vector<Intersectable*> objects;
	vector<Light*> lights;
	Camera camera;
	vec3 La;		// 环境光
public:
    void build();
    void render();
    Hit firstIntersect(Ray);
    bool shadowIntersect(Ray);
    vec3 trace(Ray,int);
    void Animate(float dt);
}

就像之前图中展示的,一个场景中有若干个物品、若干个光源,一个摄像机,这里多加了一个环境光(因为环境光不是点光源,所以不放在Light中),用于做基本的返回值。

这里面光线追踪以及其相关函数下面会讲,但其他函数简单说一下:

  1. build(),这是初始化函数,定义了用户(摄像机)的初始位置,环境光La,向光源集合、物品集合中添加物件。
  2. render(),渲染视窗上每个点的着色(逐像素调用trace函数)
  3. Animate(float),调用摄像机的Animate,让视角旋转起来

七 光线追踪 trace

光线追踪的基本思想我在第三部分说了,我们直接按部分划分讲求解过程:

7.1 漫反射

之前说了,我们用Phong模型计算视线与Rough材质交点处的着色,其公式为: I s = I i n c o m i n g ∗ k s ∗ ( R ⋅ V ) s h i n i n e s s I_s=I_{incoming}*k_s*(R·V)^{shininess} Is=Iincomingks(RV)shininess
但是在实际计算时,反射光线R较难求解,而且我们最终并不需要它,我们只需要其与观察方向的夹角,因此这里有一个性能优化的trick:

我们看上面这张图中的向量h,定义为 h = v + s h=v+s h=v+s。直观的可以看到,h和n之间的夹角与v和r之间的夹角几乎相同,而h比r好求很多(因为s、v和n都是已知的),如果我们用h与n之间的夹角去代替v与r的夹角,将大幅提高Phong模型的计算速度,代码如下:

if (hit.material->type == ROUGH)
{ 
	vec3 outRadiance = hit.material->ka * La;		// 初始化返回光线(利用环境光)
	for (Light* light : lights)
	{ 
		Ray shadowRay(hit.position + hit.normal * epsilon, light->direction);
		float cosTheta = dot(hit.normal, light->direction);
		if (cosTheta > 0)	// 如果cos小于0(钝角),说明光找到的是物体背面,用户看不到
		{ 
			if (!shadowIntersect(shadowRay))	// 如果与其他物体有交,则处于阴影中;反之,按Phong模型计算
			{ 
				outRadiance = outRadiance + light->Le * hit.material->kd * cosTheta;
				vec3 halfway = normalize(-ray.dir + light->direction);
				float cosDelta = dot(hit.normal, halfway);
				if (cosDelta > 0)
					outRadiance = outRadiance + light->Le * hit.material->ks * powf(cosDelta, hit.material->shininess);
			}
	}
	return outRadiance;
}

这里还有一个点,那就是ShadowRay。ShadowRay是学名,它表示直指光源的那一段射线,这里我们需要求该射线是否与其他物体有交,一旦有交则说明光源被遮挡了,那么这里在用户看来就是阴影,其着色用环境光赋予即可。

7.2 镜面反射

除了Rough材质的物体,Reflective和Refractive材质的物品都会发生镜面反射,镜面反射的光线我们需要继续追踪,直到达到迭代深度,或者截止于一个Rough材质物体。按照Fresnel理论,一束光到达物体表面后,会分为折射光线与反射光线两种,这里我们需要用到结构体中定义的F0,即反射光线的占比:

float cosa = -dot(ray.dir, hit.normal);		// 镜面反射(继续追踪)
vec3 one(1, 1, 1);
vec3 F = hit.material->F0 + (one - hit.material->F0) * pow(1 - cosa, 5);
vec3 reflectedDir = ray.dir - hit.normal * dot(hit.normal, ray.dir) * 2.0f;		// 反射光线R = v + 2Ncosa
vec3 outRadiance = trace(Ray(hit.position + hit.normal * epsilon, reflectedDir), depth + 1) * F;

7.3 折射光线

既然我们已经用了Fresnel公式,那么折射的计算也是举手之劳:

if (hit.material->type == REFRACTIVE)	 // 对于透明物体,计算折射(继续追踪)
{ 
	float disc = 1 - (1 - cosa * cosa) / hit.material->ior / hit.material->ior;
	if (disc >= 0)
	{ 
		vec3 refractedDir = ray.dir / hit.material->ior + hit.normal * (cosa / hit.material->ior - sqrt(disc));
		outRadiance = outRadiance + trace(Ray(hit.position - hit.normal * epsilon, refractedDir), depth + 1) * (one - F);
	}
}

八、辅助类与函数说明

至此我们的光线追踪算法已经实现了,剩下的部分就是利用OpenGL的渲染器将它显示出来,这一部分用到了GLSL,我并不是很懂,但是大体的运行逻辑如下:

  1. FullScreenTextureQuad类是一个用来初始化顶点着色器与片段着色器的类,其构造函数中生成了VertexArray与Buffer缓冲。
  2. 我们在Scene类中定义过一个Render函数,用来逐像素的求取着色,这里我们就利用该函数返回的image,作为要被渲染的材质使用,通过LoadTexture函数让FragmentShader知道用什么颜色给像素点赋值。
  3. Draw函数,利用glDrawArrays把刚刚的image绘制出来。
  4. onInitialization是整个函数的初始化,我们设定好视窗、初始化场景、初始化着色器,并创建gpu进程
  5. onDisplay,显示函数。调用render求取光线追踪的结果,存到image中,然后用LoadTexture更新当前帧的图像,最后Draw出来

九、结语

虽然学到的东西都很老,但是这种思维方式的培养我认为是很有帮助的。图形学这门课的老师说的最好的一句话就是,“永远按照计算机的思维设计算法”。

该项目的完整内容可见我的gitee仓库

    原文作者:Morphlng
    原文地址: https://blog.csdn.net/weixin_44151650/article/details/112578589
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。