渔樵问对

Cover Image

版权信息#

COPYRIGHT#

书名:渔樵问对:白话解读版

作者:(北宋) 邵雍 著 紫鑫 译注

出版社:国文出版社

出版时间:2025年09月

ISBN:9787512520073

字数:43千字

#

我们总是忙忙碌碌,在这喧嚣的世界里奔波,都快忘了静下心来思考一些有意义的事儿。当我偶然翻开《渔樵问对》,就像走进了另一个世界。这里面渔夫和樵夫的对话,那可太妙了。他们唠的不仅是普通生活所见,更是天地、阴阳、古今的智慧。这些智慧,我们可能觉得高高在上,离我们挺远,可经他俩一聊,好像突然就变得亲近了,变得让我们普通人也能品出点不一样的滋味儿来。

《渔樵问对》,是北宋邵雍所著,里面讲了一位渔夫和一位樵夫在偶然相遇时的对话,他们只是世间最普通的人。渔夫出没风波,于浩渺烟水间讨生活;樵夫往来山林,在幽篁翠影中谋生计。他们不仅像我们身边的友人,也是智者的化身,是哲理的探寻者与传播者。其对话仿若两条奔腾的思想溪流,汇聚之处,溅起的是关于宇宙、人生、社会、历史等诸多重大命题的智慧水花。

对话先是从百姓的寻常生活入手,抽丝剥茧般层层递进,最终将话题引向了深邃的人生哲学领域,为读者开启了一场从日常烟火迈向精神思辨的奇妙思想之旅。

这正像普通人从平凡的生活出发,因好奇而不断探索,最终走向深度思考人生的过程,对话把这样的成长轨迹具象化,给予我们诸多启示。

看完这本书,我受益匪浅,同时希望能让更多的人知道这本书。现在好多人被各种琐事缠着,被那些花里胡哨的东西迷了眼,没机会好好感受老祖宗留下来的智慧宝藏,所以我决定尽我所能,把它翻译成大白话,让大家能轻松地读懂。让更多的人对《渔樵问对》产生兴趣,跟随两位智者开始思考那些关于人生的问题,并且在生活中运用古人的智慧让自己的生活变得越来越好。但因为本人学识有限,理解和感悟难免有诸多不足之处,敬请广大读者交流商榷。

《渔樵问对》中的人生哲理和我们的生活息息相关,不管你是忙忙碌碌打拼的上班族,还是在学校里读书的学生娃,又或是在家安享晚年的长辈,我都希望你能在《渔樵问对》里受到启发,也许樵夫的问题也是你的问题呢!希望在这里找到能让你心里一动的东西。说不定读完它,你看世界、看自己的眼光都会不一样了呢。

准备好了吗?大家赶紧跟着渔夫和樵夫开启这奇妙的智慧之旅吧!

一 『利害』之辩#

[原]渔者垂钓于伊水之上。

[译]渔夫在伊水边钓鱼。

[原]樵者过之,弛担息肩,坐于磐石之上,而问于渔者,曰:“鱼可钩取乎?”

[译]有一个樵夫路过,放下柴担休息,坐在大石头上,问渔夫:“鱼可以用鱼钩钓到吗?”

[原]曰:“然。”

[译]渔夫回答:“能。”

[原]曰:“钩非饵可乎?”

[译]樵夫又问:“鱼钩上没有鱼饵能钓到鱼吗?”

[原]曰:“否。”

[译]渔夫答:“不能。”

[原]曰:“非钩也,饵也。鱼利食而见害,人利鱼而蒙利,其利同也,其害异也。敢问何故?”

[译]樵夫说:“把鱼钓上来的不是鱼钩而是鱼饵。鱼为了吃食而被害,人却因为得到鱼而获利,二者都是为了得到,但结果不同,请问这是为何?”

[原]曰:“子樵者也,与吾异治,安得侵吾事乎?然亦可以为子试言之。彼之利,犹此之利也;彼之害,亦犹此之害也。子知其小,未知其大。”

[译]渔夫说:“你是樵夫,与我的工作不同,怎能明白我的事呢?我可以给你解释一下。鱼的利与我的利本质相同,鱼的害与我的害也相同。你只看到一点,却未看到全面。”

[原]“鱼之利食,吾亦利乎食也;鱼之害食,吾亦害乎食也。子知鱼终日得食为利,又安知鱼终日不得食为害?如是,则食之害也重,而钩之害也轻。子知吾终日得鱼为利,又安知吾终日不得鱼不为害也?”

[译]“鱼以食为利,我也以食为利;鱼因食受害,我也会因食受害。你只知鱼得到吃食为利,又怎知鱼不停地追求吃食是不是一种伤害呢?所以食的诱惑对鱼的危害大,鱼钩的危害却不是主要的。你只知道我得到鱼为利,又怎知我若终日钓不到鱼不是一种伤害呢?”

[原]“如是,则吾之害也重,鱼之害也轻。以鱼之一身,当人之食,是鱼之害多矣;以人之一身,当鱼之一食,则人之害亦多矣。又安知钓乎大江大海,则无易地之患焉?鱼利乎水,人利乎陆,水与陆异,其利一也。”

[译]“如此看来,我受到的伤害是重的,而鱼的伤害是轻的,鱼的全身成为人的食物,鱼的害大;人的一生只为了钓鱼,把鱼当作唯一的食物,人所受到的害也很多。况且在大江大海钓鱼,还可能有其他祸患。鱼生活在水里,人生活在陆地,水与陆不同,但利害是一样的。”

[原]“鱼害乎饵,人害乎财,饵与财异,其害一也。又何必分乎彼此哉!子之言,体也,独不知用尔。”

[译]“鱼因鱼饵受害,人因财物受害,虽鱼饵与财物不同,但害处是一样的。何必区分彼此呢?你说的只是事物的表象,却不知其根本。”

我们很多时候只看到事物的一面,却看不到事物的全面,同样是追求食物,鱼成了人的餐中食,人却因鱼而吃饱饭。从表面上看是完全不一样的结果,但跳出来看整体是一样的。就像鱼每日都在追求吃食而获得满足,人也是如此,每日奋斗,是为了获得更好的生活。但鱼在追求贪欲时,就算有钩也想尝试而受害。人也因贪欲,过度追求而受到伤害!自以为的获利也许是一种伤害。所以不管是鱼还是人,其实都是一样的,对万事万物的追求一旦过度都会有反向的发展,贪欲过旺必遭反噬。

这个故事就是在提醒我们,好比平常看事儿,不能光看表面就下结论。要把方方面面都考虑进去才行。

生活里好多人都有这样的毛病,看啥都“着急”,看到什么就觉得是什么,却看不到事物的本质。可实际上,每件事背后都跟蜘蛛网一样,有着千丝万缕的联系。事物之间的利害关系都是相互交织、相互影响和相互转化的。有时候表面看着是好事,没准儿背后藏着坏的一面;反过来,看起来倒霉的事,说不定也能引出好的结果来。很多事都有它内在的规律,就像鱼为什么容易被鱼饵诱惑,人又为什么为了利益愿意冒险,这里面藏着人性,生活里的那些道理,得用心去发掘、去感悟。

一些人评价别人或者看待事物的时候,总是太片面了。例如,看到一个明星有才华,就觉得他很优秀;结果过一段时间他出轨了,又觉得他很可恶;后来他有一次做公益,帮助了很多人,又觉得他善良有爱心;可事后传出他做公益是为了炒作,又变得很讨厌他。对同一个人在不同时候就有很多看法和评判,以一面和一时就定论岂不是很片面。又如,和别人闹了别扭,光想着自己受委屈了,认为对方全是错,如果站在对方角度也琢磨一下,也许就能理解事件的真相,并能更好地解决问题。总之,遇到事仔细想想,把方方面面都考虑全了,才能少走弯路,把日子过得更明白,不被那些表面现象给糊弄住了。

这种智慧,可让人心胸开阔、头脑清晰,更能综合考量与事件相关的各种因素,才能突破表象的局限,对事件有完整、透彻且准确的理解,洞察到事物背后的真相与脉络,进而做出明智的应对举措,届时你的人生就能豁然开朗。

身边故事#

我有个同学在一家互联网公司工作,他想以最快的速度当上领导,自己就能说话管用,于是从入职起就兢兢业业。他对每一个项目都全力以赴,力求做到最好。经过几年的努力,他成为公司里最年轻的经理。一开始,他满心欢喜,觉得自己的付出得到了认可,未来充满了希望。升职后的日子却并没有想象的那么美好。新职位带来了更多的责任和更大的压力,他需要管理一个更大的团队,协调各种复杂的关系。团队里的一些老员工对他的升职并不服气,在工作中故意不配合,对他的安排阳奉阴违,有时还给他制造麻烦,故意拖延进度或者提供不准确的信息。

升职也让他陷入了困境,每天要花费大量的精力去处理团队内部的矛盾和外部的合作问题。工作压力越来越大,他经常失眠,甚至开始怀疑自己的能力。也没有了当初的自信与活力。他深刻地认识到,自己所追求的名利并不一定都是美好的,同时也会带来更多的困难和挑战。

人呀,要知道,你追求什么,什么就会伤害你;你在乎什么,就会被什么所控制。有个朋友,从小就梦想拥有一辆豪车。他努力攒钱,又向家人朋友借钱凑了首付,终于买到了心仪的豪车。一开始,他开着豪车很有成就感,四处炫耀,觉得是自己拥有了它,殊不知自己也被它所控。不仅有债务的压力,还有豪车的保养费和油耗都非常高,动不动修理保养,都成了烦恼和压力。他还不得不减少其他方面的开支,舍不得吃喝,甚至放弃了一些原本的爱好和社交活动。当热爱变淡,他便开始后悔当初为了得到这辆豪车而付出的代价。

二 『水火』之辩#

[原]樵者又问曰:“鱼可生食乎?”

[译]樵夫又问:“鱼能生吃吗?”

[原]曰:“烹之可也。”

[译]渔夫答:“煮熟可以吃。”

[原]曰:“必吾薪济子之鱼乎?”

[译]樵夫问:“必须用我的柴来煮你的鱼吗?”

[原]曰:“然。”

[译]渔夫答:“是的。”

[原]曰:“吾知有用乎子矣。”

[译]樵夫说:“我知道我对你有用了。”

[原]曰:“然则子知子之薪,能济吾之鱼,不知子之薪所以能济吾之鱼也。薪之能济鱼久矣,不待子而后知。苟世未知火之能用薪,则子之薪虽积丘山,独且奈何哉?”

[译]渔夫说:“你知道你的柴能煮我的鱼,却不知你的柴为何能煮我的鱼。柴能煮鱼由来已久,在你之前世人就知道,若世人不知柴的作用是火,你的柴堆积如山又有什么用呢?”

22关于ImGui

第 22 集是 Cherno 系列中非常爽的一集,因为它让你告别了“硬编码”坐标的痛苦,转而使用可视化 UI 来直接操控显卡里的数据。

代码#

https://github.com/ocornut/imgui 下载1.6版本,和视频中保持一致

根目录下所有.h和.cpp文件,以及examples\opengl3_example文件夹中的.h和.cpp文件都复制到项目中 不要include main.cpp文件

修改imgui_impl_glfw_gl3.cpp中开头的 #include <GL/gl3w.h>#include <GL/glew.h>

添加头文件#


#include "imgui_1.60/imgui.h"
#include "imgui_1.60/imgui_impl_glfw_gl3.h"

shader后的修改#


		
		//=======imgui添加============
		//imGui创建上下文
		// Setup ImGui binding
		ImGui::CreateContext();
		//windows ,告诉 ImGui 你的程序窗口在哪里
		//允许 ImGui 自动接管窗口的回调函数(比如鼠标点击、滚轮滚动)
		ImGui_ImplGlfwGL3_Init(window, true);
		//设置 UI 的主题配色
		ImGui::StyleColorsDark();

		//解决白屏问题2:在进入 while 循环前,手动清一次屏并交换缓冲
		//设置“清除颜色”
		glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
		//用上面选好的“油漆”填满整个颜色缓冲区(Color Buffer)
		glClear(GL_COLOR_BUFFER_BIT);
		//交换缓冲区
		glfwSwapBuffers(window); // 这一步把“深绿色”推送到显卡
		//解决白屏问题3:最后才显示窗口
		glfwShowWindow(window);


		//=======imgui添加============
		bool show_demo_window = true;
		bool show_another_window = false;
		ImVec4 clear_color = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);

		glm::vec3 translation(200, 200, 0);

		// 游戏/渲染主循环
		while (!glfwWindowShouldClose(window))
		{
			// 清理屏幕颜色缓冲区
			renderer.Clear();

			//=======imgui添加============
			ImGui_ImplGlfwGL3_NewFrame();


			//必须先绑定program,因为vao不负责着色器程序的切换
			//va.Bind();

			if (r > 1.0f)
				increment = -0.05f;
			else if (r < 0.0f)
				increment = 0.05f;

			r += increment;

			//=======imgui添加============
			//绘图前重新绑定
			shader.Bind();
			//在u_Color的位置上设置数值
			shader.SetUniform4f("u_Color", r, 0.3f, 0.0f, 1.0f);
			//========设置uniform========

			renderer.Draw(va, ib, shader);

			//=======imgui添加============
			//小窗口
			{
				static float f = 0.0f;
				static int counter = 0;
				ImGui::Text("Hello, world!");                           // Display some text (you can use a format string too)
				//这里传入translation.x的地址,imgui会在这个地址上修改值
				//第一个参数是标签,表示在imgui界面上显示的名字,第二个参数是要修改的值的地址,后面是这个值的范围
				ImGui::SliderFloat3("Translation", &translation.x, 0.0f, 960.0f);            // Edit 1 float using a slider from 0.0f to 1.0f  
				ImGui::SliderFloat("float", &f, 0.0f, 1.0f);            // Edit 1 float using a slider from 0.0f to 1.0f    
				ImGui::ColorEdit3("clear color", (float*)&clear_color); // Edit 3 floats representing a color

				ImGui::Checkbox("Demo Window", &show_demo_window);      // Edit bools storing our windows open/close state
				ImGui::Checkbox("Another Window", &show_another_window);

				if (ImGui::Button("Button"))                            // Buttons return true when clicked (NB: most widgets return true when edited/activated)
					counter++;
				ImGui::SameLine();
				ImGui::Text("counter = %d", counter);

				ImGui::Text("Application average %.3f ms/frame (%.1f FPS)", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate);
			}

			//imgui:这里吧mvp相关代码移到while循环中
			//向右向上移动200
			glm::mat4 model = glm::translate(glm::mat4(1.0f), translation);

			glm::mat4 mvp = proj * view * model;
			shader.SetUniformMat4f("u_MVP", mvp);


			//=======imgui添加============
			ImGui::Render();

			ImGui_ImplGlfwGL3_RenderDrawData(ImGui::GetDrawData());

			// 交换前后缓冲区以刷新画面
			glfwSwapBuffers(window);

			// 轮询并处理窗口事件(如键盘输入、关闭动作)
			glfwPollEvents();
		}

		//最后解绑vao
		//glBindVertexArray(0);
	}

	//=======imgui添加============
	ImGui_ImplGlfwGL3_Shutdown();

	// 退出前清理资源
	//glfwTerminate会破坏Context,导致析构函数中的glGetError()
	//返回一个OpenGL错误
	glfwTerminate();
	return 0;

21关于模型视图投影矩阵

第 21 集通常是 Cherno 教程中从“固定画面”转向“动态交互”的关键点,也是将 M、V、P 三者真正融合进代码的时刻。

按照程序运行的时间线,我们可以把这套复杂的逻辑分为以下阶段:

1. 初始化阶段:建立“尺子” (P)#

在程序启动时,你先定义了投影矩阵 (Projection Matrix)

  • 动作glm::ortho(0.0f, 960.0f, 0.0f, 540.0f, -1.0f, 1.0f)
  • 目的:建立一套“像素坐标系”。它就像一把尺子,告诉 GPU:“以后我给你 这个数,你就把它当成屏幕的最右边。”
  • 状态:此时你还没决定看哪,也没决定物体在哪。

2. 空间布局阶段:摆放相机与物体 (V & M)#

在每一帧渲染开始前,你需要在 CPU 中计算当前的位置。

  • 视图矩阵 (View Matrix)
    • 动作glm::translate(glm::mat4(1.0f), glm::vec3(x, y, z))(通常是反向平移)。
    • 目的:模拟摄像机的移动。如果你想让相机往右移,代码里其实是把整个世界往左拽。
  • 模型矩阵 (Model Matrix)
    • 动作glm::translateglm::rotate
    • 目的:决定这个特定的物体(比如那个 logo)在世界里的具体坐标、旋转角度和缩放比例。

分工#

旋转、缩放、平移,这“三兄弟”通常全部都在 M(Model 矩阵)里完成。

虽然在数学上,你可以把它们放进任何矩阵,但在图形学的标准逻辑(即第 21 集所讲的逻辑)中,它们分工非常明确:

1. M (Model 矩阵) —— 物体自己的“属性”#

适用范围:旋转、缩放、平移(全占了)。

  • 平移 (Translation):你想把方块放到屏幕左边还是右边?
  • 旋转 (Rotation):你想让方块站着还是躺着?
  • 缩放 (Scaling):你想让方块变大还是变小?

核心逻辑:Model 矩阵负责定义物体在世界里的样子。一个 logo 无论它怎么转、怎么挪,那都是它作为一个“物体”的行为。

20关于投影矩阵

既然已经学到了第 20 集(通常是 MVP 矩阵 的收尾和调试),我们可以把这套复杂的“坐标变换流水线”按时间顺序梳理一遍。

这不仅是代码的运行顺序,也是你大脑中理解 OpenGL 坐标变换的“心路历程”:

诞生:模型空间 (Model Space)#

  • 时间点:你在 main 函数里定义 float positions[] 的那一刻。
  • 状态:此时的坐标是纯粹的几何数据。比如矩形宽 100,高 100
  • 你的认知:这些数字没有单位,它们只是相对于物体中心(原点)的位置。

变换:世界空间 (World Space) —— M (Model Matrix)#

  • 时间点:在渲染循环中,当你想要移动、旋转这个矩形时。
  • 动作:给模型乘以一个 Model 矩阵
  • 结果:矩形被“摆放”到了虚拟世界里的某个位置(比如 )。

观察:摄像机空间 (View Space) —— V (View Matrix)#

  • 时间点:你想决定从哪个角度看这个世界。
  • 动作:乘以 View 矩阵(就像把摄像机架好)。
  • 结果:所有物体的坐标现在变成了“相对于摄像机”的位置。如果摄像机向右移,物体在坐标系中就向左移。

关键:投影变换 (Projection Space) —— P (Projection Matrix)#

  • 时间点这就是你在第 19-20 集重点攻克的部分。
  • 动作:乘以你定义的 glm::ortho(正交投影)矩阵。
  • 核心逻辑
    • 矩阵的工作:它把你在正交矩阵里定义的范围(比如 08000 \sim 800强行压缩[1.0,1.0][-1.0, 1.0]
    • Shader 执行:在 Vertex Shader 里执行 u_MVP * position
    • 赋值:结果存入 gl_Position

正交投影和透视投影#

  • 正交投影:渲染UI(用户界面),2D游戏(通常)等
  • 透视投影:3D,第一视角

19关于数学库

总结#

引入 数学库 (Math Library),特别是处理向量(Vector)和矩阵(Matrix)运算。对于图形学来说,数学就是“画笔”,而矩阵就是“坐标转换器”。

以下是按时间线整理的核心知识点总结:

为什么需要第三方数学库?#

  • 原生局限:C++ 标准库没有提供专门针对图形学的向量(如 vec3)和矩阵(如 mat4)运算。
  • GLSL 同步:我们需要在 CPU 端计算好矩阵,然后传递给 Shader。如果 CPU 端的数学库逻辑(比如数据排布)能与 Shader 中的 GLSL 保持一致,开发效率会极高。
  • GLM 的选择:Cherno 选择了 GLM (OpenGL Mathematics)。它是一个 Header-only(仅头文件)的库,设计思路完全模仿 GLSL,且与 OpenGL 兼容性完美。

https://github.com/g-truc/glm/releases/tag/1.0.3

项目属性中设置:

之后这里把Texture.cpp的头文件前几行改为

#include "Texture.h"
#include "Renderer.h"
#include <iostream>

#include "stb_image/stb_image.h" //去除了vendor/
  1. 把 glm这个文件夹 (ui-右键)include
  2. glm/detail/glm.cppglm/glm.cppm (ui-右键)exclude project

向量与矩阵的基本概念#

  • 向量 (Vector)不仅代表位置,还代表方向。在 2D 中是 vec2,3D 中是 vec3,带上齐次坐标则是 vec4
  • 矩阵 (Matrix):图形学中的“魔法方阵”。mat4(4x4 矩阵)可以同时包含 旋转 (Rotation)平移 (Translation)缩放 (Scale) 信息。

正交投影矩阵 (Orthographic Projection)#

这是本集的实战重点。Cherno 引入了 glm::ortho 函数:

18关于混合

这一集是关于如何处理==透明度(Alpha Channel)==的关键。当两个物体重叠时,GPU 如何决定最终像素的颜色?这就是“混合”要解决的问题。

问题的引入:为什么我的图片有“黑边”?#

在加载带透明通道的 .png 纹理时,如果不开启混合,透明区域通常会显示为纯黑色纯白色。这是因为:

  • 默认情况下,OpenGL 只是简单地用新像素 ==覆盖(Overwrite)==旧像素。
  • 即使你的纹理有 Alpha 值(例如 0.00.0 ),如果不告诉 OpenGL 如何处理它,它依然会把这个“透明”的像素颜色画上去。

开启混合 (Enable Blending)#

OpenGL 是一个状态机,混合功能默认是关闭的。你必须手动开启:

GLCall(glEnable(GL_BLEND));

开启后,你需要定义混合函数(Blend Function),即告诉 OpenGL:“拿新颜色(源)和旧颜色(目标)怎么算?”

核心公式:混合方程式#

这是这一集最硬核的数学部分。OpenGL 计算最终像素颜色的公式如下:

Cresult=(CsrcFsrc)+(CdestFdest)C_{result} = (C_{src} * F_{src}) + (C_{dest} * F_{dest})
  • (Source Color):即将画上去的颜色(来自 Fragment Shader)。
  • (Source Factor):源颜色的权重。
  • (Destination Color):已经在颜色缓冲区里的颜色(背景色)。
  • (Destination Factor):目标颜色的权重。

最常用的配置:实现透明效果#

为了实现自然的透明(即:新物体的透明度越高,透出的背景越多),Cherno 给出了最经典的配置方案:

GLCall(glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA));
//函数glBlendFunc第一个参数默认值为GL_ONE第二个参数默认值为GL_ZERO

逻辑拆解:

  1. Fsrc=GL_SRC_ALPHAF_{src} = GL\_SRC\_ALPHA 源颜色的权重是它自己的 AlphaAlpha 值。
  2. Fdest=GL_ONE_MINUS_SRC_ALPHAF_{dest} = GL\_ONE\_MINUS\_SRC\_ALPHA 背景颜色的权重是 1Alpha1 - Alpha

举个例子: 如果你画一个 Alpha 为 0.30.330%30\% 不透明)的红色方块在黑色背景上:

17关于纹理

第 17 集是 OpenGL 系列中视觉效果实现飞跃的一集。在此之前,你只能画出单色的三角形;学完纹理后,你就能把图片贴在你的几何图形上。

即便你现在还不学习具体的 API,了解其背后的“映射逻辑”对你理解未来的 3D 渲染至关重要。

简述#

纹理的基本原理:坐标映射#

  • 核心概念:纹理坐标(UV 坐标)。

  • UV 坐标系

    • 它是归一化的, uuvv 的范围都是 0.00.01.01.0
    • 左下角是 (0,0)(0, 0) ,右上角是 (1,1)(1, 1)
  • 映射逻辑:你在顶点数据中多加两个 float(),告诉 GPU:“这个顶点对应图片上的哪个点”。GPU 会自动在顶点之间进行插值,算出中间每个像素该涂什么颜色。

图像加载与 stb_image#

  • 痛点:OpenGL 本身不认识 .png.jpg 这种压缩格式。

  • 解决方案:引入一个极简的开源库 stb_image

    • 这是一个单头文件库,非常符合 Cherno 的极简风格。
    • 它的作用是将磁盘上的图片文件解码成内存中的 RGBA 字节数组(即一串 unsigned char

OpenGL 纹理对象 (Texture Object)#

  • 创建与绑定

    • 类似于 VertexBuffer,你需要 glGenTextures 生成一个 ID,然后 glBindTexture
  • 关键参数设置 (Parameters)

    • 过滤 (Filtering):当图片被放大或缩小时,像素该怎么补全?(GL_LINEAR 线性平滑,或 GL_NEAREST 像素风)。
    • 包装 (Wrapping):当 UV 坐标超过 1.01.0 时,图片是重复(Repeat)还是拉伸(Clamp)?
  • 数据上传:使用 glTexImage2D 将 CPU 内存中的像素数组发送到 GPU 显存。

13-16抽象OpenGL成类

将零散的 OpenGL 原生 API 封装成 C++ 类。这不仅是为了让 main 函数变干净,更是为了构建一个可复用的渲染引擎底层

13-16抽象OpenGL成类#

为什么要抽象?(工程思维)#

  • 现状main.cpp 已经膨胀到几百行,充斥着大量的 glGenglBind
  • 目标:我们不希望在业务逻辑里看到底层的 unsigned int ID。我们要操作的是“对象”(Object)。
  • 类划分预想
    • VertexBuffer:管理 GL_ARRAY_BUFFER
    • IndexBuffer:管理 GL_ELEMENT_ARRAY_BUFFER
    • VertexArray:管理属性布局(Layout)和 VAO。

封装 VertexBuffer 类#

这是最基础的一步。将 VBO 的创建、绑定和销毁封装起来。

  • 构造函数:接收数据和大小,直接 glGenglBufferData
  • 析构函数:调用 glDeleteBuffers,实现 RAII(资源获取即初始化)机制,防止内存泄漏。
  • 关键方法Bind()Unbind()
// 你的博客可以展示这个极简结构
class VertexBuffer {
private:
    unsigned int m_RendererID; // Cherno 喜欢用 m_ 前缀表示成员变量
public:
    VertexBuffer(const void* data, unsigned int size);
    ~VertexBuffer();
    void Bind() const;
    void Unbind() const;
};

封装 IndexBuffer 类#

与 VertexBuffer 几乎一模一样,但有两点不同:

  1. 目标类型:固定为 GL_ELEMENT_ARRAY_BUFFER
  2. 计数器:增加一个 m_Count 变量,记录有多少个索引(Indices),因为 glDrawElements 绘图时需要这个数字。

代码#

Render 渲染器#

//Renderer.h
//编译器第一次遇到这个文件,正常读取内容。
//编译器会记下这个文件的物理路径。
//当后续代码再次尝试 #include 这个路径的文件时,编译器直接跳过,不再读取。
#pragma once

#include <GL/glew.h> 
#include "VertexArray.h"
#include "IndexBuffer.h"
#include "Shader.h"

//_debugbreak() 是msvc特有的
//__FILE__和__LINE__ 是所有编译器都支持的
//#x:字符串化操作符。它会将你传入的代码直接转成字符串
//__FILE__ 和 __LINE__:编译器内置宏,自动获取当前代码所在的文件名和行号
#define ASSERT(x) if(!(x)) __debugbreak();
#define GLCall(x) GLClearError();\
	x;\
	ASSERT(GLLogCall(#x,__FILE__,__LINE__));

void GLClearError();

bool GLLogCall(const char* function,
	const char* file, int line);

class Renderer
{
public:
	void Clear() const;
	void Draw(const VertexArray& va, const IndexBuffer& ib, const Shader& shader) const;
};
//Renderer.cpp
#include "Renderer.h"
#include <iostream>

void GLClearError()
{
	//直到不是GL_NO_ERROR,就退出while循环
	while (glGetError() != GL_NO_ERROR);
}

bool GLLogCall(const char* function,
	const char* file, int line)
{
	while (GLenum error = glGetError())
	{
		std::cout << "[OpenGL Error](" << error << "): "
			<< function << " " << file << ":" << line << std::endl;
		return false;
	}

	return true;
}

void Renderer::Clear() const
{
	GLCall(glClear(GL_COLOR_BUFFER_BIT));
}

void Renderer::Draw(const VertexArray& va, const IndexBuffer& ib, const Shader& shader) const
{

	//绘图前重新绑定
	shader.Bind();

	//必须先绑定program,因为vao不负责着色器程序的切换
	va.Bind();

	//这个可以不用,已经在va中录制了
	//ib.Bind();

	GLCall(glDrawElements(GL_TRIANGLES, ib.GetCount(), GL_UNSIGNED_INT, nullptr));

	//绘制后经常不需要再解绑,因为这会消耗性能,而且
	//我们经常会在下一帧继续使用它们并覆盖 
}

VertextBuffer 顶点缓冲区#

//VertextBuffer.h

#pragma once

//专门处理顶点缓冲区的生成、绑定、解绑及内存释放
class VertexBuffer
{
private:
	//一个相关的渲染器id
	unsigned int m_RendererID;
public:
	//size:大小
	VertexBuffer(const void* data, unsigned int size);
	~VertexBuffer();

	void Bind() const;
	void Unbind() const;

};
//VertexBuffer.cpp
#include "VertexBuffer.h"
#include "Render.h"


VertexBuffer::VertexBuffer(const void* data, unsigned int size)
{ 
	// 生成一个缓冲区 ID
	GLCall(glGenBuffers(1, &m_RendererID));
	// 绑定该 ID 到顶点缓冲区插槽
	GLCall(glBindBuffer(GL_ARRAY_BUFFER, m_RendererID));
	// 将顶点数据从 CPU 内存拷贝到 GPU 显存,设为静态读取模式
	GLCall(glBufferData(GL_ARRAY_BUFFER, size,
		data, GL_STATIC_DRAW));
}

VertexBuffer::~VertexBuffer()
{
	GLCall(glDeleteBuffers(1, &m_RendererID));
}

void VertexBuffer::Bind() const
{
	// 绑定该 ID 到顶点缓冲区插槽
	GLCall(glBindBuffer(GL_ARRAY_BUFFER, m_RendererID));
}

void VertexBuffer::Unbind() const
{
	// 绑定该 ID 到顶点缓冲区插槽
	GLCall(glBindBuffer(GL_ARRAY_BUFFER, 0));
}

IndexBuffer 索引缓冲区#

//IndexBuffer.h
#pragma once

//专门处理索引缓冲区的生成、绑定、解绑及内存释放
//索引缓冲区:可以用来只绘制某个面(如果顶点缓冲区
//包括了很多顶点的话)
class IndexBuffer
{
private:
	//一个相关的渲染器id
	unsigned int m_RendererID;
	unsigned int m_Count;
public:
	//count:个数
	IndexBuffer(const unsigned int* data, unsigned int count);
	~IndexBuffer();

	void Bind() const;
	void Unbind() const;

	inline unsigned int GetCount() const { return m_Count; }

};
//IndexBuffer.cpp
#include "IndexBuffer.h"
#include "Render.h"

IndexBuffer::IndexBuffer(const unsigned int* data, unsigned int count)
	:m_Count(count)
{  
	ASSERT(sizeof(unsigned int) == sizeof(GLuint));
	// 生成一个缓冲区 ID
	GLCall(glGenBuffers(1, &m_RendererID));
	// 绑定该 ID 到元素数组缓冲区 (Element Array Buffer)插槽,
	//也称索引缓冲区对象
	GLCall(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_RendererID));
	// 将顶点数据从 CPU 内存拷贝到 GPU 显存,设为静态读取模式
	GLCall(glBufferData(GL_ELEMENT_ARRAY_BUFFER, count * sizeof(unsigned int),
		data, GL_STATIC_DRAW));
}

IndexBuffer::~IndexBuffer()
{
	GLCall(glDeleteBuffers(1, &m_RendererID));
}

void IndexBuffer::Bind() const
{
	// 绑定该 ID 到元素数组缓冲区插槽
	GLCall(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_RendererID));
}

void IndexBuffer::Unbind() const
{
	// 绑定该 ID 到元素数组缓冲区插槽
	GLCall(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0));
}

VertexBufferLayout 顶点缓冲区布局设置#

//VertexBufferLayout.h
#pragma once
#include <vector>
#include <GL/glew.h>
#include "Renderer.h"

struct VertexBufferElement
{
	unsigned int type;
	int count;
	unsigned char normalized;

	static unsigned int GetSizeOfType(unsigned int type)
	{
		switch (type)
		{
		case GL_FLOAT: return 4;
		case GL_UNSIGNED_INT: return 4;
		case GL_UNSIGNED_BYTE: return 1;

		}
		ASSERT(false);
		return 0;
	}
};

class VertexBufferLayout
{
private:
	std::vector<VertexBufferElement> m_Elements;
	unsigned int m_Stride;//步长
public :
	VertexBufferLayout()
		:m_Stride(0) {}

	//解释一下,第n个push的就是第n组属性的布局
	template<typename T>
	void Push(int cout)
	{
		static_assert(false);
	}

	//位置 ($x, y, z$)
	template<>
	void Push<float>(int count)
	{
		m_Elements.push_back({ GL_FLOAT,count,GL_FALSE });
		m_Stride += count * VertexBufferElement::GetSizeOfType(GL_FLOAT);
	}

	//ID、索引
	template<>
	void Push<unsigned int >(int count)
	{
		m_Elements.push_back({ GL_UNSIGNED_INT,count,GL_FALSE });
		m_Stride += count * VertexBufferElement::GetSizeOfType(GL_UNSIGNED_INT);
	}

	//颜色 (R, G, B, A)
	//如果你设置 GL_TRUE (归一化):OpenGL 会自动帮你做除法。比如你传 255,Shader 拿到的是 $255 / 255 = 1.0$;你传 128,Shader 拿到的是 $0.5$
	template<>
	void Push<unsigned char>(int count)
	{
		m_Elements.push_back({ GL_UNSIGNED_BYTE,count,GL_TRUE });
		m_Stride += count * VertexBufferElement::GetSizeOfType(GL_UNSIGNED_BYTE);
	}

	inline const std::vector<VertexBufferElement> GetElements() const { return m_Elements; }
	inline unsigned int GetStrde() const { return m_Stride; }
};

VertexArray 顶点数组(属性)绑定#

//VertexArray.h
#pragma once
#include "VertexBuffer.h" 
//#include "VertexBufferLayout.h"

//它告诉编译器:“哥们,后面会有一个类叫 VertexBufferLayout,我现在先提一嘴,你先别管它长啥样,只要知道它是个‘类’就行。”
class VertexBufferLayout;

class VertexArray
{
private:
	unsigned int m_RendererID;
public:
	VertexArray();
	~VertexArray();

	void AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout);

	void Bind() const;
	void Unbind() const;
};
//VertexArray.cpp

#include "VertexArray.h"
//这里要真正调用 layout.GetElements()。这时候编译器必须看到具体的类定义,才能知道 GetElements() 函数长什么样。
#include "VertexBufferLayout.h"

VertexArray::VertexArray()
{ 
	glGenVertexArrays(1, &m_RendererID);

}

VertexArray::~VertexArray()
{
	glDeleteVertexArrays(1, &m_RendererID);

}

void VertexArray::AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout)
{
	Bind();
	vb.Bind();
	const auto& elements = layout.GetElements();
	unsigned int offset = 0;
	for (unsigned int i=0;i<elements.size();i++)
	{
		const auto& element = elements[i];
		// 启用索引为 i 的(顶点)属性 
		glEnableVertexAttribArray(i);
		//1. 打标签:它把当前 GL_ARRAY_BUFFER 里的数据流,贴上了“i号”的标签。2. 定规则:它告诉 GPU,当你(Shader)想要 location = 0 的数据时,请按照“每 count 个 float 为一组”的规则去缓存里抓取。
		glVertexAttribPointer(i, element.count, element.type, element.normalized, layout.GetStrde(), (const void*)offset);

		offset += element.count * VertexBufferElement::GetSizeOfType(element.type);
	}

}

void VertexArray::Bind() const
{
	glBindVertexArray(m_RendererID);
}

void VertexArray::Unbind() const
{
	glBindVertexArray(0);

	//这里是否要禁用呢,但是视频里面没有存count,所以也不知道总共有几个
	/*for (unsigned int i = 0; i < elements.size(); i++)
	{
		glDisableVertexAttribArray(i);
	}*/

}

Shader着色器#

//Shader.h
#pragma once
#include <string>
#include <unordered_map>

struct ShaderProgramSource
{
	std::string VertexSource;
	std::string FragmentSource;
};

class Shader
{
private:
	std::string m_FilePath;
	unsigned int m_RendererID;
	std::unordered_map<std::string, unsigned int> m_UniformLocationCache;

	// caching for uniforms
public:
	Shader(const std::string& filepath);
	~Shader();

	void Bind() const;
	void Unbind() const;

	void SetUniform4f(const std::string& name, float v0, float v1,
		float v2, float v3);
private:
	bool CompileShader();
	unsigned int GetUniformLocation(const std::string& name);

	ShaderProgramSource ParseShader(const std::string& filepath);
	unsigned int CompileShader(unsigned int type,
		const std::string& source);
	//创建着色器程序对象:编译并链接顶点着色器和片段着色器
	unsigned int CreateShader(const std::string& vertexShader,
		const std::string& fragmentShader);
};
//Shader.cpp
#pragma once
#include "Shader.h"
#include <iostream>
#include <fstream>
#include <sstream> 
#include "Render.h"

Shader::Shader(const std::string& filepath)
	:m_FilePath(filepath),m_RendererID(0)
{
	ShaderProgramSource source = ParseShader(filepath);
	m_RendererID = CreateShader(source.VertexSource, source.FragmentSource);

}

Shader::~Shader()
{
	glDeleteProgram(m_RendererID);
}

void Shader::Bind() const
{
	glUseProgram(m_RendererID);
}

void Shader::Unbind() const
{
		glUseProgram(0);
}

 ShaderProgramSource Shader::ParseShader(const std::string& filepath)
{

	enum class ShaderType
	{
		NONE = -1, VERTEX = 0, FRAGMENT = 1
	};

	//input file stream
	std::ifstream stream(filepath);

	std::string line;
	std::stringstream ss[2];
	ShaderType type = ShaderType::NONE;

	while (getline(stream, line))
	{
		if (line.find("#shader") != std::string::npos)
		{
			if (line.find("vertex") != std::string::npos)
			{
				type = ShaderType::VERTEX;
			}
			else if (line.find("fragment") != std::string::npos)
			{
				type = ShaderType::FRAGMENT;
			}
		}
		else
		{
			ss[(int)type] << line << '\n';
		}
	}

	return { ss[0].str(),ss[1].str() };

}

//GLenum也就是unsigned int,这里不用GLenum是为了解耦
unsigned int Shader::CompileShader(unsigned int type,
	const std::string& source)
{
	//创建着色器对象,向 OpenGL 申请一个空的“容器”来存放你的代码
	//把光标放在 glCreateShader( 的左括号后面,然后按下 Ctrl + Shift + Space。会弹出一个小黑框,显示:GLuint glCreateShader(GLenum type)
	//到glew.h搜索,发现typedef unsigned int GLenum;
	unsigned int id = glCreateShader(type);
	//返回一个指向该字符串首地址的只读指针(const char*)
	const char* src = source.c_str();

	//有了容器后,你需要把写好的字符串代码塞进去(拷贝)
	//3种用法
	//1. glShaderSource(id, 1, &src, nullptr);	只有一个字符串,且它是以 \0 结尾的。
	//2. glShaderSource(id, 1, &src, &len);	只有一个字符串,长度由 len 指定。
	//3. glShaderSource(id, 2, strings, lengths);	有两个字符串片段(数组形式),长度分别由 lengths[0] 和 lengths[1] 指定。
	glShaderSource(id, 1, &src, nullptr);

	//将你的 GLSL 代码翻译成显卡能理解的机器指令
	glCompileShader(id);

	//错误处理
	int result;

	// 查询编译状态:询问 OpenGL 这个 shader 编译成功了吗?
	// GL_COMPILE_STATUS 会把结果存入 result 中(成功为 GL_TRUE,失败为 GL_FALSE)
	glGetShaderiv(id, GL_COMPILE_STATUS, &result);

	if (result == GL_FALSE)
	{
		int length;
		//获取错误日志长度:如果编译失败,先问一下错误信息一共有多少个字符
		glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
		//在栈上申请内存
		//如果申请的内存太大,alloca会导致栈溢出(1MB)
		char* message = (char*)alloca(length * sizeof(char));
		//提取错误信息:把显卡驱动里的具体报错文字拷贝到我们刚才申请的 message 内存中
		glGetShaderInfoLog(id, length, &length, message);
		std::cout << "Failed to compile " << ((type == GL_VERTEX_SHADER) ? "vertex" : "fragment") << " shader!";
		std::cout << message << std::endl;
		//清理:编译失败了,这个 shader 对象也就没用了,删掉它防止内存泄漏
		glDeleteShader(id);
		return 0;
	}


	return id;
}


 unsigned int Shader::CreateShader(const std::string& vertexShader,
	const std::string& fragmentShader)
{
	//在 GPU 中申请一个空的“程序对象”
	unsigned int program = glCreateProgram();

	//编译顶点着色器
	unsigned int vs = CompileShader(GL_VERTEX_SHADER,
		vertexShader);
	//编译片段着色器
	unsigned int fs = CompileShader(GL_FRAGMENT_SHADER,
		fragmentShader);

	//把已经编译好的顶点着色器和片段着色器附加到程序对象中
	glAttachShader(program, vs);
	glAttachShader(program, fs);
	//链接
	glLinkProgram(program);
	//验证当前的程序是否能在当前的 OpenGL 状态下执行(
	// 检查顶点着色器的输出是否与片段着色器的输入匹配,并
	// 生成最终的可执行二进制代码。)
	glValidateProgram(program);

	//删除临时的中间产物(类似编译完 C++ 后删除 .obj
	glDeleteShader(vs);
	glDeleteShader(fs);


	return program;
}


void Shader::SetUniform4f(const std::string& name, float v0, float v1, float v2, float v3)
{
	glUniform4f(GetUniformLocation(name), v0, v1, v2, v3);
}

unsigned int Shader::GetUniformLocation(const std::string& name)
{
	if (m_UniformLocationCache.find(name) != m_UniformLocationCache.end())
	{
		return m_UniformLocationCache[name];
	}
	unsigned int location=glGetUniformLocation(m_RendererID, name.c_str());
	if (location == -1)
		std::cout << "Warning: uniform '" << name << "' doesn't exist!" << std::endl;

	m_UniformLocationCache[name] = location;
	return location;
}

Main 主文件#

//Main.cpp
#ifdef LY_EP16
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <fstream>
#include <sstream>
#include <string>

#include "Renderer.h"

#include "VertexBuffer.h"
#include "VertexBufferLayout.h"
#include "IndexBuffer.h"
#include "VertexArray.h"
#include "Shader.h"


int main(void)
{
	{

#pragma region 一些初始化
		GLFWwindow* window;
		// 初始化 GLFW 库,失败则退出
		if (!glfwInit())
			return -1;

		//强制指定使用 Core Profile(核心模式),如果
		//没有手动写着色器则不会渲染;如果不是核心模式,
		//在固定管线中默认颜色是白色,且默认顶点在NDC标准
		//设备坐标系中
		glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
		//这是兼容配置模式,会让VAO0成为默认对象
		//glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);
		glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
		glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

		// 创建窗口对象
		window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
		if (!window)
		{
			// 创建失败则清理并退出
			glfwTerminate();
			return -1;
		}

		// 将当前窗口的上下文设置为 OpenGL 渲染的目标
		glfwMakeContextCurrent(window);

		glfwSwapInterval(2);

		// 初始化 GLEW 以加载 OpenGL 函数指针,需在有上下文后执行
		if (glewInit() != GLEW_OK)
		{
			std::cout << "Error!" << std::endl;
		}

#pragma endregion

		// 定义三角形的顶点坐标(CPU 内存)
		float positions[] = {
			-0.5f, -0.5f,//0
			0.5f, -0.5f,//1
			0.5f, 0.5f,//2

			//0.5f, 0.5f,
			-0.5f, 0.5f,//3
			//-0.5f, -0.5f,
		};

		//
		unsigned int indices[] = {
			0,1,2,
			2,3,0
		};

		VertexArray va;

		//申请创建一个GPU上的缓冲区,绑定并复制进去数据构造函
		//数中已经绑定了,这样glVertexAttribPointer才有效果
		VertexBuffer vb(positions, 4 * 2 * sizeof(float));

		VertexBufferLayout layout;
		layout.Push<float>(2);
		va.AddBuffer(vb, layout);

		//申请创建一个GPU上的缓冲区,绑定并复制进去数据
		IndexBuffer ib(indices, 6);

		Shader shader("res/shaders/Basic.shader");
		shader.Bind();

		shader.SetUniform4f("u_Color", 0.8f, 0.3f, 0.8f, 1.0f);


		//===这里故意把他解绑了(假设他去绑定别的去了)===
		vb.Unbind();

		shader.Unbind();
		va.Unbind();

		//element_array_buffer 和vertexAttribArray不能
		//在解绑vao之前处理,否则就记录进去了
		ib.Unbind();

		//========================================

		float r = 0.0f;
		float increment = 0.05f;

		Renderer renderer;

		// 游戏/渲染主循环
		while (!glfwWindowShouldClose(window))
		{
			// 清理屏幕颜色缓冲区
			renderer.Clear();


			//必须先绑定program,因为vao不负责着色器程序的切换
			//va.Bind();

			if (r > 1.0f)
				increment = -0.05f;
			else if (r < 0.0f)
				increment = 0.05f;

			r += increment;

			//绘图前重新绑定
			shader.Bind();
			//在u_Color的位置上设置数值
			shader.SetUniform4f("u_Color", r, 0.3f, 0.0f, 1.0f);
			//========设置uniform========

			renderer.Draw(va, ib, shader);
			 

			// 交换前后缓冲区以刷新画面
			glfwSwapBuffers(window);

			// 轮询并处理窗口事件(如键盘输入、关闭动作)
			glfwPollEvents();
		}

		//最后解绑vao
		//glBindVertexArray(0);
	}

	// 退出前清理资源
	//glfwTerminate会破坏Context,导致析构函数中的glGetError()
	//返回一个OpenGL错误
	glfwTerminate();
	return 0;
}

#endif

12顶点数组

第 12 集是整个系列中非常关键的架构转折点。Cherno 在这一集深入讲解了 Vertex Array Object (VAO)

如果你之前觉得代码里那堆 glVertexAttribPointer 既乱又难记,那么这一集就是为你准备的“整理收纳柜”。

总述#

为什么要用 VAO?(痛点分析)#

  • 现状:在核心模式(Core Profile)下,OpenGL 要求必须绑定一个 VAO 才能绘图。
  • 问题:如果你有多个模型(比如一个三角形、一个正方形),每个模型都有自己的 VBOIBO 和复杂的 VertexBufferLayout(顶点布局)。
  • 后果:每次切换模型画图,你都要重新写一遍 glBindBufferglEnableVertexAttribArrayglVertexAttribPointer。这太啰嗦了!

VAO 的本质:状态的“存档”#

  • 定义:VAO 就像是一个配置记录仪。它不会存储实际的顶点坐标数据,但它会记住

    1. 哪个 VBO 与它绑定了。
    2. 属性布局(多少个 float、偏移量是多少、是否归一化)。
  • 优势:一旦你设置好了 VAO,下次画这个模型时,只需要一行 glBindVertexArray(vaoID),所有的 VBO 绑定和属性设置就会瞬间还原

兼容性大坑:Core vs Compatibility#

  • Compatibility Profile(兼容模式)OpenGL 会默认帮你创建一个“隐藏的 VAO (ID为0)”。所以你之前不写 VAO 也能画出图
  • Core Profile(核心模式):必须显式地 glGenVertexArrays。如果不绑定 VAO 就直接调用 glVertexAttribPointer,程序会直接崩溃或报错。
  • Cherno 的建议:永远手动创建 VAO,这样你的代码在任何显卡驱动和模式下都是健壮的。

编码实践:如何创建 VAO#

  • 标准流程

    1. unsigned int vao; glGenVertexArrays(1, &vao);
    2. glBindVertexArray(vao);
    3. glBindBuffer(GL_ARRAY_BUFFER, vbo);
    4. glVertexAttribPointer(...);
    5. glEnableVertexAttribArray(0);
  • 注意:顺序很重要!必须先绑定 VAO,再去绑定 VBO 和设置属性,这样 VAO 才能“录制”下这些操作。