OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(10):从“像素画师”到“硅基神明”:一个CAD开发者穿越GPU着色器管线的十年进化史)

@TOC

代码仓库入口:


系列文章规划:

巨人的肩膀:

  • OpenGL 4.6 Specification
  • Vulkan 1.3 Specification
  • Khronos Group SPIR-V Whitepaper
  • 历代GPU架构白皮书(NVIDIA Fermi至Blackwell,AMD GCN至RDNA 4)

从"像素画师"到"硅基神明":一个CAD开发者穿越GPU着色器管线的十年进化史


序幕:当你的渲染器开始"不听话"

前几篇文章里,你成功地把CAD图纸变成了GPU能理解的数据------顶点坐标存进VBO,变换矩阵传进Uniform,然后满怀期待地调用glDrawArrays

屏幕上出现了你画的那个螺栓。六角头、螺纹、倒角,一切看起来都很完美。你甚至用上了我们之前聊的DLSS思路,用AI给画面补了细节,老板看了直夸"这渲染质量堪比3A大作"。

直到有一天,产品经理走过来,提出了一个看似无害的需求:

"小C啊,客户说想在螺栓表面加上'拉丝金属'的质感。不是贴图那种假的,是真的------光打上去的时候,高光要顺着螺纹的走向拉长,像这样。" 他拿出一张真实的CNC加工零件的照片。

你信心满满地打开片段着色器,准备写一段"各向异性高光"的算法。你查了公式,算好了切线和副法线,把代码写了进去,编译、运行------

画面一片漆黑。

控制台弹出一行冰冷的错误:

复制代码
ERROR: 0:42: 'textureGrad' : no matching overloaded function found 

你愣住了。textureGrad是GLSL里的标准函数啊,怎么会找不到?你Google了半天,终于在某个论坛的角落里找到答案:你当前用的OpenGL 2.1上下文,根本不支持这个函数。它需要OpenGL 3.0以上。

你陷入了沉思:你一直以为OpenGL就是"调用函数画图",就像用一支魔法画笔。但现在你发现,这支笔其实是一支被无数历史版本、硬件差异、驱动程序修修补补过的、内部结构极其复杂的精密仪器。你不了解它的内部构造,就永远会被"为什么这段shader在这个显卡上能跑,在那个上就崩"的噩梦困扰。

你决定,从今天起,彻底搞懂着色器管线(Shader Pipeline)的前世今生。这不再只是为了完成需求,而是为了真正获得对GPU的绝对掌控权


第一代:固定管线时代------GPU是台"傻瓜相机"

时间拨回1992年,OpenGL 1.0发布。

你想象一下,如果你是最早用OpenGL写CAD软件的开发者,你面对的是什么?那时候根本没有"着色器"这个概念。你想画一个带光照的立方体,代码大概是这样的:

cpp 复制代码
glEnable(GL_LIGHTING);      // 打开光照总开关
glEnable(GL_LIGHT0);        // 打开第0号光源
glLightfv(GL_LIGHT0, GL_POSITION, lightPos); // 设置光源位置

glEnable(GL_COLOR_MATERIAL); // 让材质跟随当前颜色
glColor3f(1.0, 0.0, 0.0);    // 红色材质

glBegin(GL_TRIANGLES);
    glNormal3f(0, 0, 1);     // 设置法线(光照必须)
    glVertex3f(-0.5, -0.5, 0); // 顶点
    glNormal3f(0, 0, 1);
    glVertex3f( 0.5, -0.5, 0);
    glNormal3f(0, 0, 1);
    glVertex3f( 0.0,  0.5, 0);
glEnd();

你发现了吗?你根本没有写任何关于"光照怎么计算"的代码。你只是打开了GL_LIGHTING这个开关,GPU就自动帮你完成了从顶点位置、法线、光源方向到最终颜色的所有计算。

这就是固定管线(Fixed Function Pipeline)。

它就像一台傻瓜相机 :你只需要按快门(调用glVertex),相机(GPU)会自动帮你调焦、测光、对焦。你不需要知道光圈快门ISO是什么,更不需要知道CMOS感光元件怎么把光子转成电子信号。

傻瓜相机也有"专业模式"------状态机与开关

但即使是傻瓜相机,也有一些可以调的参数:闪光灯开不开?曝光补偿加一档?这些在固定管线里对应的是状态(State)开关(Switch)

OpenGL内部维护了一个巨大的状态机,里面记录着:

  • 当前颜色是什么?(glColor)
  • 光照开没开?(glEnable(GL_LIGHTING))
  • 如果开了光照,用的是哪一套光照模型?(Phong?没有,早期只有Gouraud着色)
  • 纹理混合模式是什么?(glTexEnvi)

你作为开发者,能做的就是在绘图之前,先设置好这些开关。GPU就像一个死板的工人,严格按照你设定的状态去执行它内置的那几套公式。

为什么会有固定管线?

你可能会问:为什么要把这些功能写死在硬件里?让开发者自己写不好吗?

答案很简单:1992年的晶体管数量根本不够。 第一块支持OpenGL的3D加速卡(3dfx Voodoo)只有约100万晶体管,而今天的RTX 4090有763亿。在当时的工艺下,GPU能做的事情极其有限,只能把最通用、最耗时的功能(几何变换、光照计算、纹理采样)用专用电路实现。

固定管线的逻辑在GPU里是**硬连线(Hardwired)**的。你调用glEnable(GL_LIGHTING),实际上是在芯片上给某一个电路单元通了电。快,但毫无灵活性可言。

痛点:当"专业模式"也不够用的时候

回到你的CAD场景。你画了一个螺栓,想实现一种特殊效果:让螺栓的螺纹部分有自阴影,看起来更立体。

你查了图形学论文,发现需要用法线贴图(Normal Mapping) 。法线贴图需要在片段着色器里做切线空间的光照计算。但固定管线根本不支持"切线空间"这个概念------它只知道世界空间和模型空间。

你又想:那我自己实现一个边缘光(Rim Light) ,让螺栓的轮廓有一圈亮边。边缘光的公式是 dot(normal, viewDir) 的某种变换,但固定管线的光照模型是写死的,你插不进去自定义的数学公式。

你就像拿着傻瓜相机的摄影师,想拍出背景虚化的人像,但相机菜单里没有"光圈优先"模式。你被硬件锁死了。

深度解析:固定管线的硬件本质

1. 固定管线的数据流

下图展示了固定管线时代,一个顶点从你调用glVertex3f到最终出现在屏幕上的完整旅程:

复制代码
顶点坐标 (Object Space)
    ↓
[模型视图矩阵变换]  ← glMatrixMode(GL_MODELVIEW), glTranslate/Rotate/Scale
    ↓
顶点坐标 (Eye Space)
    ↓
[光照计算 (Lighting)] ← glEnable(GL_LIGHTING), glLight*, glMaterial*
    ↓
顶点颜色 (带光照结果)
    ↓
[投影矩阵变换] ← glMatrixMode(GL_PROJECTION), glFrustum/Ortho
    ↓
裁剪坐标 (Clip Space)
    ↓
[透视除法与视口变换] ← glViewport, glDepthRange
    ↓
窗口坐标 (Window Space)
    ↓
[光栅化 (Rasterization)] ← 硬件自动进行三角形设置与遍历
    ↓
[纹理映射与混合] ← glTexEnv*, glEnable(GL_BLEND), glBlendFunc
    ↓
片段颜色 → 帧缓冲

注意 :这整个流程中,除了最后的纹理混合有少量可选公式,其他步骤的数学公式都是硬件写死的。

2. 固定管线的光照模型:Gouraud着色与Phong反射模型

早期OpenGL 1.x的光照计算公式是固定的Phong反射模型(环境光+漫反射+镜面反射),但着色方式有两种:

  • Gouraud着色(默认) :在顶点着色器位置计算光照,得到的颜色在三角形内部线性插值。优点:计算量小(只算三个顶点)。缺点:高光会丢失细节,出现"多边形感"。
  • 真正的Phong着色 :在片段级别计算光照,需要法线贴图支持,固定管线做不到

所以固定管线里的"Phong光照"其实是指Phong反射模型 + Gouraud着色,这是很多初学者困惑的地方。

3. 纹理组合器(Texture Combiner)------固定管线最后的挣扎

到了OpenGL 1.3/1.5,硬件厂商意识到用户需要更多灵活性,于是引入了纹理组合器(Texture Combiners)。它允许你用类似"公式编辑器"的方式,组合多个纹理单元的采样结果。比如:

cpp 复制代码
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE);
glTexEnvi(GL_TEXTURE_ENV, GL_COMBINE_RGB, GL_MODULATE);
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE0_RGB, GL_TEXTURE);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND0_RGB, GL_SRC_COLOR);
glTexEnvi(GL_TEXTURE_ENV, GL_SOURCE1_RGB, GL_PRIMARY_COLOR);
glTexEnvi(GL_TEXTURE_ENV, GL_OPERAND1_RGB, GL_SRC_COLOR);

这段代码的意思是:最终颜色 = 纹理颜色 × 顶点颜色。你可以组合出AddSubtractDot3(用于简单凹凸贴图)等操作。这是固定管线向可编程管线过渡的雏形

4. 硬件实现的物理限制

固定管线的每个阶段都有专用电路:

  • T&L引擎(Transform & Lighting):负责顶点变换和光照,最早在GeForce 256(1999)中硬件实现。
  • 纹理采样单元(TMU):负责从纹理内存中读取数据并滤波。
  • 光栅操作单元(ROP):负责深度测试、混合、写入帧缓冲。

这些单元的数量是固定的,比如某个GPU有4个TMU,意味着每个周期最多采样4个纹理。开发者必须考虑纹理单元的限制glGetIntegerv(GL_MAX_TEXTURE_UNITS)),这是现代GPU中已经消失的烦恼。


第二代:可编程的黎明------Vertex Shader 与 Fragment Shader

时间来到2001年,GeForce 3发布,带来了一个革命性的概念:可编程着色器。

NVIDIA给这个技术取了个名字叫nFinite FX Engine,它允许开发者用类似汇编的语言(Vertex Programs / Fragment Programs)来编写顶点变换和像素着色的逻辑。

你作为一名CAD开发者,终于可以写出自己的第一段着色器代码了(那时候还不叫GLSL,而是ARB汇编语言):

assembly 复制代码
!!ARBvp1.0
# 顶点程序 - 简单变换
PARAM mvp[4] = { state.matrix.mvp };  # 模型视图投影矩阵
MOV result.position, mvp[0];           # 输出位置
END

虽然看起来像天书,但它的意义是划时代的:你第一次可以在GPU上执行自己写的算法了!

为什么是"顶点"和"片元"这两个入口?

GPU的设计者们很聪明。他们分析了过去十年的图形应用,发现开发者最常需要定制的就两件事:

  1. 顶点怎么变换------我想实现波浪效果、骨骼动画、或者特殊的投影方式。
  2. 像素怎么着色------我想实现法线贴图、过程化纹理、或者风格化渲染。

于是,他们把固定管线中对应的两个阶段切开,暴露出来,让你填入自己的代码:

  • 顶点着色器(Vertex Shader) 替换了原来的 T&L引擎
  • 片元着色器(Fragment Shader) 替换了原来的 纹理组合器 + 固定混合逻辑

管线变成了这样:

复制代码
[你的顶点数据] → [Vertex Shader(你写!)] → [光栅化(硬件自动)] → [Fragment Shader(你写!)] → [帧缓冲]

注意,中间的光栅化(Rasterization)仍然是硬件写死的。你不需要自己写三角形遍历和像素插值的代码,这部分是GPU最核心、最高效的固定电路。

光栅化器(Rasterizer):沉默的插值大师

当你写完顶点着色器,输出了一堆经过变换的顶点(每个顶点带有gl_Position、颜色、纹理坐标、法线等属性)之后,这些数据就流入了光栅化器。

光栅化器就像一个极高效率的填色工人

  1. 它接收三角形的三个顶点。
  2. 它判断这个三角形覆盖了屏幕上的哪些像素。
  3. 对于每一个被覆盖的像素,它自动根据该像素在三角形内的位置,对三个顶点的所有属性进行线性插值,生成该像素对应的属性值。
  4. 把这些插值后的属性打包,交给你的片段着色器。

比如,你给三角形三个顶点的颜色是红、绿、蓝,光栅化器就会自动算出中间像素的渐变色。你什么都没做,它就帮你搞定了。

专家视角:插值的代价

这个插值过程不是免费的。硬件内部使用**重心坐标(Barycentric Coordinates)**算法来计算每个像素相对于三个顶点的权重。这个电路经过极致的优化,能以每周期数十亿次的速度完成计算。这也是为什么在早期的可编程管线中,光栅化器是绝对不能动的一部分------动了它,性能会下降几个数量级。

GLSL诞生:让着色器代码像C语言一样亲切

汇编语言太难写了。2004年,OpenGL 2.0发布,带来了GLSL(OpenGL Shading Language) 。它语法类似C语言,有vec3mat4这样的数学类型,有内置函数normalizedotreflect

你终于可以像写普通C++代码一样写着色器了:

glsl 复制代码
// 顶点着色器
#version 120
attribute vec3 aPos;
attribute vec3 aNormal;
uniform mat4 uMVP;
uniform mat4 uModelView;
varying vec3 vNormal;  // 传递给片段着色器

void main() {
    gl_Position = uMVP * vec4(aPos, 1.0);
    vNormal = (uModelView * vec4(aNormal, 0.0)).xyz;
}
glsl 复制代码
// 片段着色器
#version 120
varying vec3 vNormal;
uniform vec3 uLightDir;

void main() {
    float diff = max(dot(normalize(vNormal), uLightDir), 0.0);
    gl_FragColor = vec4(vec3(diff), 1.0);
}

你用这两个着色器,实现了自己的定向光漫反射。你再也不受固定管线的束缚了------你可以写任何你想要的数学公式,只要它不超出硬件的指令集限制。

新的痛点:我想"凭空"创造顶点

你继续优化你的CAD渲染器。你发现一个性能瓶颈:LOD(细节层次)

你想实现的效果是:当螺栓离相机很远时,用低精度模型(比如100个三角形);离近了再切换到高精度模型(比如10000个三角形)。但在现有的顶点-片段管线里,你能做的只是切换整个模型------也就是CPU端必须准备好两套顶点数据,然后根据距离决定用哪一套Draw。

这有两个问题:

  1. 内存翻倍:你得存两份模型。
  2. 切换有延迟:CPU要判断距离,GPU要等待新模型上传。

你想:要是能在GPU内部 ,根据当前顶点到相机的距离,动态增加或减少三角形的数量,那该多好啊?

你把这个需求告诉了显卡厂商的工程师。他们说:"你想在GPU里增删顶点?这需要改动光栅化器之前的数据流------我们考虑一下。"

于是,几何着色器(Geometry Shader) 被提上了日程。

深度解析:顶点与片元着色器的架构细节

1. 数据传递的两种方式:Attribute/Varying vs. In/Out

早期GLSL(1.20)使用 attribute(顶点输入)和 varying(顶点到片元插值输出)。现代GLSL(3.30+)统一为 inout

glsl 复制代码
// 现代写法
in vec3 aPos;        // 顶点输入
out vec3 vNormal;    // 传递给下一阶段(光栅化器插值)

这种改变反映了硬件架构的演进:早期GPU有专门的"插值单元",只处理varying变量;现代GPU统一了数据流,任何out变量都可以被插值。

2. 顶点着色器的执行模型:SIMD与顶点批处理

GPU内部的顶点着色器并不是一次只处理一个顶点。它是以**线程束(Warp/NVIDIA)波前(Wavefront/AMD)**为单位,同时处理32个或64个顶点。

这意味着如果你的着色器里有if分支,而这32个顶点走了不同的分支,GPU会把两条分支都执行一遍(分支发散,Branch Divergence),浪费一半的计算能力。因此,写着色器时应尽量避免依赖顶点数据的动态分支。

3. 片段着色器的执行模型:Quad与导数指令

片段着色器也不是一个像素一个像素执行的。它以**2x2像素块(Quad)**为单位执行。这4个像素的数据会被同时加载到寄存器中。

这个设计让GLSL中的导数函数dFdx, dFdy)成为可能:

glsl 复制代码
float dx = dFdx(texCoord.x); // 计算当前像素与右边像素的UV差值

因为同一Quad内的相邻像素数据都在寄存器里,dFdx几乎不消耗额外周期。这是现代渲染技术(如基于导数的纹理采样、抗锯齿)的基石。

4. 插值的本质:重心坐标与透视校正

光栅化器在插值顶点属性时,需要处理透视投影带来的非线性问题 。直接线性插值屏幕空间坐标会导致纹理在透视面上扭曲。硬件会自动进行透视校正插值(Perspective-Correct Interpolation)

对于任意属性A,它在屏幕空间中的插值公式为:
A f r a g m e n t = A 0 w 0 ⋅ α + A 1 w 1 ⋅ β + A 2 w 2 ⋅ γ 1 w 0 ⋅ α + 1 w 1 ⋅ β + 1 w 2 ⋅ γ A_{fragment} = \frac{ \frac{A_0}{w_0} \cdot \alpha + \frac{A_1}{w_1} \cdot \beta + \frac{A_2}{w_2} \cdot \gamma }{ \frac{1}{w_0} \cdot \alpha + \frac{1}{w_1} \cdot \beta + \frac{1}{w_2} \cdot \gamma } Afragment=w01⋅α+w11⋅β+w21⋅γw0A0⋅α+w1A1⋅β+w2A2⋅γ

其中 w 是裁剪坐标的w分量(即视图空间深度)。这个计算由硬件中的专用单元完成,如果你在着色器中用 noperspective 关键字可以强制关闭它,但会导致纹理走样。

5. 内置变量与内置函数的历史演进

GLSL版本 顶点着色器输出 片段着色器输出 主要新增功能
1.20 (GL2.0) gl_Position, gl_TexCoord[] gl_FragColor 基础数学函数,纹理采样
3.30 (GL3.3) 任意out变量 任意out变量 整数类型、位操作、textureSize
4.00 (GL4.0) 同上 同上 原子计数器、着色器子程序
4.60 (GL4.6) 同上 同上 SPIR-V支持、非均匀控制流

当你看到老代码里的gl_FragColor,就知道它一定是GLSL 1.x时代的产物。


第三代:拓扑结构的掌控------Geometry Shader

时间来到2006年,DirectX 10发布,带来了几何着色器(Geometry Shader)。OpenGL 3.2在2009年也跟进支持。

几何着色器位于顶点着色器之后、光栅化器之前。它的输入不是一个顶点,而是一个完整的图元(Primitive) ------可以是点、线段、或者三角形。它的输出也是图元,而且输出的图元数量可以和输入完全不同

你的LOD梦想终于可以实现了------在GPU内部!

几何着色器能做什么?

glsl 复制代码
#version 330
layout(triangles) in;
layout(triangle_strip, max_vertices=3) out;

void main() {
    // 输入三角形,直接输出(相当于pass-through)
    for(int i = 0; i < 3; i++) {
        gl_Position = gl_in[i].gl_Position;
        EmitVertex();
    }
    EndPrimitive();
}

这个最简单的几何着色器,接收三角形(triangles),输出三角形带(triangle_strip),每个输入顶点发射一次,相当于什么都没改。

但如果我们要实现一个"根据距离细分三角形"的效果呢?

glsl 复制代码
layout(triangles) in;
layout(triangle_strip, max_vertices=12) out;  // 最多输出12个顶点(一个三角形可以细分成多个)

uniform float uLODDistance;
uniform vec3 uCameraPos;

void main() {
    // 计算三角形中心到相机的距离
    vec3 center = (gl_in[0].gl_Position.xyz + gl_in[1].gl_Position.xyz + gl_in[2].gl_Position.xyz) / 3.0;
    float dist = distance(center, uCameraPos);
    
    if(dist < uLODDistance) {
        // 近处:把一个三角形细分成4个小三角形
        SubdivideAndEmit(gl_in[0], gl_in[1], gl_in[2]);
    } else {
        // 远处:直接输出原三角形
        EmitVertex(gl_in[0]);
        EmitVertex(gl_in[1]);
        EmitVertex(gl_in[2]);
        EndPrimitive();
    }
}

你终于可以在GPU里动态控制三角形的数量了!而且完全不需要CPU参与,不需要准备多套模型。

几何着色器的黄金时代应用

在2006-2012年间,几何着色器被广泛应用在以下场景:

  1. 公告板(Billboards):输入一个点,输出一个始终面向相机的四边形,用于渲染草、树叶、火花等粒子。
  2. 毛发生成:输入一个三角形,沿着法线方向"挤出"多个小三角形,形成一层绒毛。
  3. 阴影体(Shadow Volume):输入三角形,沿光方向挤出阴影侧面。
  4. 单遍渲染立方体贴图:输入一次几何体,输出到6个不同的面(用于生成环境贴图或点光源阴影)。

几何着色器的阿喀琉斯之踵:性能

然而,随着几何着色器的普及,开发者们很快发现了它的致命弱点:性能开销极大

原因有三:

  1. 不可预测的输出数量:输入3个顶点,可能输出100个顶点,也可能输出0个。这导致GPU内部的顶点缓冲区分配变得非常低效,常常出现"气泡(Bubbles)"------即后续阶段在等待几何着色器输出,造成流水线停顿。
  2. 有限的顶点复用 :在标准顶点着色器流水线中,一个顶点可以被多个三角形共享(通过索引缓冲)。但几何着色器输出的顶点是独立的,没有索引,导致顶点变换后的缓存(Post-T&L Cache)失效,带宽压力暴增。
  3. 图元装配开销 :几何着色器输出的是完整的图元(带拓扑连接信息),这意味着硬件需要重新进行图元装配,这在固定硬件中是串行的瓶颈。

简单来说:几何着色器让你可以"无中生有",但每一份"无中生有"的代价都极其昂贵。

从"万能瑞士军刀"到"慎用工具箱"

到了2015年左右,业界形成共识:

  • 能用实例化渲染实现的粒子,绝不用几何着色器。
  • 能用Compute Shader做GPU剔除的,绝不用几何着色器生成几何。
  • 唯一还在大规模使用几何着色器的场景是阴影体渲染 ,但这个技术也渐渐被Shadow Mapping取代。

几何着色器从一个革命性的技术,变成了一个**"知道有这个东西,但除非万不得已不要用"**的存在。

深度解析:几何着色器的硬件实现与性能模型

1. 几何着色器在GPU流水线中的物理位置

在早期的统一着色器架构(如NVIDIA G80、AMD R600)中,顶点、几何、片段着色器共用同一种计算单元(CUDA Core / Stream Processor)。这意味着几何着色器会和顶点/片段着色器抢占计算资源

而几何着色器的计算特征非常特殊:它需要等待一个完整的图元(3个顶点对于三角形)都从顶点着色器输出后,才能开始执行。这导致了同步开销

2. 几何着色器的输出缓冲与内存模型

几何着色器输出顶点时,并不是直接送到光栅化器,而是先写入一个称为GS输出缓冲(GS Output Buffer)的片上内存。这个缓冲区的大小是有限的,通常为几个KB。如果你的几何着色器输出的顶点数量超过了这个限制(例如max_vertices设得太大),驱动程序会分批次执行几何着色器,导致性能剧降。

查询这个限制:glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxVerts);

3. 几何着色器的常见优化技巧

  • 尽早输出 :能用EmitVertex()尽快输出就不要延迟,让后续的光栅化器尽早开始工作。
  • 避免动态分支 :基于输入数据的if分支会导致严重的分支发散。
  • 限制max_vertices :精确估计并设置一个小的max_vertices值,帮助驱动分配缓冲区。

4. 几何着色器的替代方案:GPU-Driven Rendering

现代引擎(如UE5的Nanite)彻底抛弃了几何着色器,改用Compute Shader进行全GPU端的几何处理

  • 用Compute Shader做视锥剔除和LOD选择
  • 将选中的三角形索引写入一个间接绘制缓冲(Indirect Draw Buffer)
  • 最后用glMultiDrawElementsIndirect一次性提交

这种方式避免了所有固定管线的图元装配开销,实现了真正意义上的GPU-Driven Pipeline


第四代:细节的极致------Tessellation Shaders

时间来到2010年,DirectX 11和OpenGL 4.0发布,引入了曲面细分(Tessellation)着色器。

你还记得你用几何着色器实现LOD时遇到的性能问题吗?显卡厂商也在思考同样的问题:"既然几何着色器做细分太慢,那我们就专门为'细分'这个任务设计一套全新的硬件电路和着色器阶段。"

于是,细分控制着色器(Tessellation Control Shader, TCS)细分计算着色器(Tessellation Evaluation Shader, TES) ,以及它们之间的固定功能细分器(Fixed-Function Tessellator),被添加到了管线中。

曲面细分的哲学:"快刀斩乱麻"

这套新管线的设计哲学非常清晰:

  1. TCS(可编程):决定"切多细"。你告诉硬件,这个面片要沿着U方向分几份,V方向分几份。
  2. 固定功能细分器(硬件写死):收到指令后,用极快的专用电路,在面片内部生成海量的新顶点。
  3. TES(可编程) :决定"新顶点放哪儿"。硬件生成的新顶点都带有重心坐标(UV),你可以在TES中根据这个UV去采样一张置换贴图(Displacement Map),把顶点沿着法线方向"推"出去,形成真正的凹凸。

管线中的位置

曲面细分着色器位于顶点着色器之后、几何着色器之前:

复制代码
[Vertex Shader] → [TCS] → [固定细分器] → [TES] → [Geometry Shader] → [Rasterizer]

注意 :TCS和TES都是可选的。如果你不需要细分,可以直接从顶点着色器跳到几何着色器。

实战:用曲面细分实现"动态LOD地形"

回到你的CAD场景,你正在渲染一个复杂的地形扫描模型。你想实现的效果是:离相机近的地方,地形要精细到能看到每一块小石头;远的地方,一个三角形覆盖几十米也无所谓。

用几何着色器做这事儿,你的显卡风扇会像直升机起飞。用曲面细分,则如丝般顺滑:

glsl 复制代码
// ---- TCS: 决定细分级别 ----
#version 400
layout(vertices = 3) out;  // 输入三角形
uniform vec3 uCameraPos;

void main() {
    // 传递控制点(原三角形顶点)给TES
    gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;
    
    // 只在第一个调用中计算细分级别(gl_InvocationID == 0)
    if(gl_InvocationID == 0) {
        // 计算三角形中心到相机的距离
        vec3 center = (gl_in[0].gl_Position.xyz + gl_in[1].gl_Position.xyz + gl_in[2].gl_Position.xyz) / 3.0;
        float dist = distance(center, uCameraPos);
        
        // 距离近 -> 细分多;距离远 -> 细分少
        float tessLevel = max(1.0, 64.0 / dist);
        
        gl_TessLevelOuter[0] = tessLevel;  // 三条边的细分级别
        gl_TessLevelOuter[1] = tessLevel;
        gl_TessLevelOuter[2] = tessLevel;
        gl_TessLevelInner[0] = tessLevel;  // 内部的细分级别
    }
}
glsl 复制代码
// ---- TES: 处理细分器生成的新顶点 ----
#version 400
layout(triangles, equal_spacing, ccw) in;  // 接收三角形细分结果
uniform sampler2D uDisplacementMap;
uniform mat4 uMVP;

void main() {
    // gl_TessCoord是细分器自动生成的重心坐标 (u, v, w)
    // 用它插值出原始顶点的位置和法线
    vec3 pos = gl_TessCoord.x * gl_in[0].gl_Position.xyz +
               gl_TessCoord.y * gl_in[1].gl_Position.xyz +
               gl_TessCoord.z * gl_in[2].gl_Position.xyz;
    
    vec3 normal = ... // 同理插值法线
    
    // 采样置换贴图,沿法线方向偏移顶点
    float displacement = texture(uDisplacementMap, uv).r;
    pos += normal * displacement;
    
    gl_Position = uMVP * vec4(pos, 1.0);
}

你运行这个着色器,发现地形在相机移动时平滑地过渡细节------没有突然的LOD跳变,没有几何着色器带来的卡顿。你终于找到了LOD的正确打开方式。

曲面细分的代价与局限

当然,它也不是银弹:

  1. 只有四边面(Quads)和三角形面片能得到最优细分。如果你输入的是三角带或线,细分器效率会下降。
  2. TCS和TES之间传递的数据量受硬件限制。你不能在TCS里输出一大堆varying变量。
  3. 细分器生成的顶点数量有上限glGetIntegerv(GL_MAX_TESS_GEN_LEVEL)),通常是64。你不能无限细分。

但是,对于地形、角色皮肤、CAD曲面、置换贴图细节这些场景,曲面细分仍然是迄今为止最高效的方案。

深度解析:曲面细分的数学与硬件实现

1. 细分器的核心算法:贝塞尔曲面与Catmull-Clark细分

固定功能细分器内部实现的是均匀或有理贝塞尔曲面细分 。当你设置layout(triangles, equal_spacing)时,它会在三角形域上生成均匀分布的点。你也可以选择fractional_even_spacingfractional_odd_spacing来获得非均匀分布,用于更好的抗锯齿。

2. 细分级别(Tessellation Level)的精确控制

gl_TessLevelOutergl_TessLevelInner是浮点数,但实际生成的顶点数量是整数。硬件会根据浮点值的小数部分 在两个整数级别之间插值,实现平滑的过渡------这就是所谓的分数阶细分(Fractional Tessellation)。这是曲面细分能实现无跳变LOD的关键。

3. 数据流与内存带宽分析

曲面细分最大的优势在于带宽节省 。传统方法要显示高精度模型,必须把几万个三角形的顶点数据从CPU上传到GPU。而曲面细分只需要上传一个低精度的控制笼(Control Cage)(比如只有几百个面片),再加上一张置换贴图,GPU就能自己生成几万个三角形。

比如,一个1K×1K的置换贴图,包含约100万个纹素。如果用传统方式,你需要上传100万个顶点的模型(约12MB顶点数据)。而用曲面细分,你只需上传几百个控制点+贴图(约1MB)。带宽节省了10倍以上。

4. 与几何着色器的对比

特性 几何着色器 曲面细分着色器
增删顶点能力 灵活,但慢 只能按固定模式细分,但极快
输出图元拓扑 可改变(点→三角形) 不可改变
硬件支持 通用计算单元 专用细分器硬件
典型吞吐量 每秒数千万顶点 每秒数十亿顶点
适用场景 粒子、公告板 LOD地形、置换贴图

5. 高级技巧:自适应细分(Adaptive Tessellation)

在TCS中,你可以根据多种因素动态调整细分级别:

  • 屏幕空间边长:保证每个细分后三角形的边长在屏幕上不超过N个像素。
  • 曲率:曲面弯曲的地方多细分,平坦的地方少细分。
  • 视锥边界:靠近视锥边缘的少细分(反正会被裁剪)。

这些技巧可以在保持画质的同时,再节省30%-50%的细分性能开销。


第五代:打破藩篱------Compute Shader

时间来到2012年,OpenGL 4.3引入了计算着色器(Compute Shader)。

你发现,虽然曲面细分解决了LOD的性能问题,但你的CAD软件面临的新挑战,已经不再是"怎么画得更好看",而是:

  • 用户点击屏幕上的一个螺栓,你需要立刻计算出射线命中了哪个三角形,然后高亮显示------这个计算如果用CPU做,图纸一大就卡。
  • 你想在模型表面做物理模拟------比如布料落在零件上的形态------这需要解成千上万个质点的运动方程。
  • 你想做遮挡剔除------在GPU端判断哪些物体根本看不见,不用提交给渲染管线。

这些任务的共同特点是:它们跟"画三角形"没有直接关系,但都需要GPU的海量并行算力。

计算着色器的哲学:"别走流程了,直接办事"

传统的图形管线是一条固定的流水线:顶点→细分→几何→光栅化→片元。即使你什么都不画,GPU也必须走一遍这个流程。

而计算着色器完全绕开了图形管线 。它不产生任何像素,不经过光栅化器,也不需要帧缓冲。它就是赤裸裸的并行计算------你分配几千个线程,每个线程读写显存里的任意Buffer,算完收工。

计算着色器怎么用?

glsl 复制代码
#version 430
layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in;  // 每个工作组256个线程

layout(std430, binding = 0) buffer InputBuffer {
    float dataIn[];
};

layout(std430, binding = 1) buffer OutputBuffer {
    float dataOut[];
};

void main() {
    uint idx = gl_GlobalInvocationID.x;  // 全局线程ID
    dataOut[idx] = sqrt(dataIn[idx]);    // 并行计算平方根
}

在C++端,你只需要:

cpp 复制代码
glUseProgram(computeProgram);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, inputBuffer);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, outputBuffer);
glDispatchCompute(N / 256, 1, 1);  // 启动N个线程
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); // 确保写入完成

就这么简单。你没有创建窗口,没有绑定纹理,没有设置视口。你只是借用了GPU的几千个核心,算了一堆平方根。

计算着色器在CAD渲染中的实战应用

1. GPU剔除(GPU Culling)

你之前用BVH在CPU端做视锥剔除。现在你可以把BVH的节点数据存进一个Buffer,用计算着色器并行遍历BVH,生成一个"可见物体列表",然后直接调用glMultiDrawElementsIndirect渲染。CPU完全解放。

2. 粒子系统物理更新

你的CAD软件里需要展示"切削液飞溅"的效果。每帧有10万个液滴需要更新位置、速度、生命周期。用计算着色器,每个液滴一个线程,物理更新和渲染数据在同一个Buffer里完成,零拷贝。

3. 后处理与图像分析

你想实现一个"自动检测图纸中的尺寸标注"功能。你可以把渲染出的画面直接作为输入Texture,用计算着色器做边缘检测、霍夫变换,找出所有箭头和数字的位置。

4. 异步计算(Async Compute)

现代GPU(如NVIDIA从Maxwell开始,AMD从GCN开始)支持图形队列和计算队列并行执行。你可以在渲染当前帧的同时,用计算着色器准备下一帧的剔除数据。这能把帧率再提升20%-30%。

计算着色器的局限

当然,计算着色器也不是万能药:

  1. 没有固定功能硬件辅助 :比如纹理采样的滤波、各向异性,计算着色器里要自己写(或使用texture函数,但有限制)。
  2. 同步开销 :你需要仔细管理glMemoryBarrierglTextureBarrier,否则会出现数据竞争。
  3. 调试困难:计算着色器里的Bug很难用传统图形调试器(如RenderDoc)定位,因为它的输入输出都是裸Buffer。

但瑕不掩瑜。计算着色器标志着GPU从"图形专用处理器"进化为"通用并行处理器" 。CUDA和OpenCL试图做同样的事,但计算着色器的优势在于它与OpenGL/Vulkan无缝集成------数据在Buffer里,既能给计算着色器算,又能直接绑给顶点着色器画,无需经过CPU中转。

深度解析:计算着色器的硬件执行模型

1. 工作组(Work Group)与线程(Invocation)

计算着色器的线程组织成三级结构:

  • 全局线程IDgl_GlobalInvocationID,在整个调度范围内唯一。
  • 本地线程IDgl_LocalInvocationID,在工作组内唯一。
  • 工作组IDgl_WorkGroupID

一个工作组内的所有线程在同一个流式多处理器(SM / CU)上执行,可以共享一块极快的 共享内存(Shared Memory,在GLSL中用shared关键字)。这类似于CPU的L1缓存,但完全由你管理。

2. 线程束(Warp)与分支发散

和顶点/片段着色器一样,计算着色器也以线程束(NVIDIA 32线程 / AMD 64线程)为单位执行。如果你的工作组大小不是32的倍数,会导致硬件利用率下降。如果工作组内线程走了不同分支,会造成分支发散

最佳实践:local_size_x设为32或64的倍数。

3. 内存模型与屏障(Barrier)

计算着色器可以访问多种内存:

  • 全局内存(Global Memory) :显存,通过bufferimage访问。延迟极高(几百个周期)。
  • 共享内存(Shared Memory):片上内存,延迟极低(几个周期),但容量小(通常每SM 64KB-128KB)。
  • 图像内存(Image Memory):带纹理缓存的全局内存,适合二维空间局部性访问。

屏障用于同步:

  • barrier():等待工作组内所有线程到达同一点。
  • memoryBarrierShared():确保共享内存写入对其他线程可见。
  • groupMemoryBarrier():确保工作组内内存操作顺序。

4. 原子操作与无锁数据结构

GLSL提供了一系列原子操作atomicAdd, atomicMax, atomicCompSwap等),可以在计算着色器中实现无锁的计数器、链表等数据结构。这对于GPU剔除(需要动态生成可见物体列表)至关重要。

例如,一个简单的原子计数器:

glsl 复制代码
layout(binding = 0) buffer CounterBuffer {
    uint count;
    uint data[];
};

void main() {
    if(visible) {
        uint idx = atomicAdd(count, 1);  // 原子递增,返回旧值
        data[idx] = objectID;            // 写入列表
    }
}

5. 计算着色器与图形管线的数据交换

计算着色器写入的Buffer,可以直接通过glBindVertexBufferglBindBufferRange绑给顶点着色器使用。这实现了零拷贝的GPU-Driven Pipeline

注意同步:计算着色器写入Buffer后,必须调用glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT),确保后续顶点着色器看到的是新数据。

6. 高级用法:计算着色器做光栅化

由于计算着色器可以写入任意Buffer,你甚至可以自己实现光栅化。比如,对于非常小的三角形(几个像素),硬件光栅化器的启动开销可能比计算着色器直接画像素还大。现代引擎有时会混合使用两者,达到极致优化。


第六代:解决兼容性与速度的终极方案------SPIR-V

时间来到2015年,Vulkan 1.0发布;2016年,OpenGL 4.6发布。两者共同引入了一个革命性的中间语言:SPIR-V。

你还记得你写的那段"拉丝金属"着色器,在某些显卡上编译失败的经历吗?那还只是冰山一角。

实际上,GLSL着色器的编译过程是这样的:

  1. 你把GLSL源码字符串传给驱动(glShaderSource)。
  2. 驱动程序调用它内部的GLSL编译器,把GLSL翻译成GPU能执行的机器码。
  3. 这个编译器是显卡厂商自己写的 ,而且每个厂商、每个驱动版本的编译器行为都可能不同

这意味着什么?意味着你写了一段语法完全正确的GLSL代码,在NVIDIA的驱动上可能编译通过,在AMD的驱动上可能报错"语法不支持的隐式转换",在Intel的集显上可能直接崩溃。跨平台着色器兼容性,是图形开发者的噩梦。

SPIR-V的解决方案:把"编译"前置到开发阶段

SPIR-V是一种中间二进制格式,就像Java的字节码(Bytecode)。它的工作流程改变了:

以前:

复制代码
[你的GLSL源码] → (运行时在用户机器上) → [驱动编译器] → [GPU机器码]

现在(使用SPIR-V):

复制代码
[你的GLSL源码] → (开发时在你的机器上) → [glslangValidator编译器] → [SPIR-V二进制文件]
                                                              ↓
                                  (运行时在用户机器上) → [驱动加载SPIR-V] → [GPU机器码]

好处是颠覆性的:

  1. 编译速度 :驱动不再需要解析文本源码、做词法分析、语法分析。它只需要把SPIR-V二进制直接翻译成机器码,加载速度快10倍以上。大型游戏启动时,着色器编译从几分钟缩短到几秒。

  2. 兼容性 :SPIR-V是一个严格标准化的格式(由Khronos Group维护)。glslangValidator会严格按照标准检查你的代码,你编译出来的SPIR-V在任何符合标准的驱动上都能保证运行。不会再出现"NVIDIA能跑,AMD崩溃"的情况。

  3. 语言无关性:虽然你还在写GLSL,但SPIR-V的设计是语言无关的。未来你可以用HLSL、Rust、甚至Python写着色器,只要它们能编译成SPIR-V,就能在Vulkan/OpenGL上跑。

  4. 代码混淆与保护:GLSL源码是明文,任何人都能提取你的着色器逻辑。SPIR-V是二进制,逆向难度大大增加,保护了你的核心渲染算法。

在你的CAD项目中使用SPIR-V

步骤非常简单:

  1. 安装glslangValidator(Vulkan SDK自带,或单独下载)。

  2. 把你的.vert/.frag编译成.spv

    bash 复制代码
    glslangValidator -V shader.vert -o shader.vert.spv
    glslangValidator -V shader.frag -o shader.frag.spv
  3. 在C++代码中加载二进制文件:

    cpp 复制代码
    std::vector<char> spvData = ReadFile("shader.vert.spv");
    GLuint shader = glCreateShader(GL_VERTEX_SHADER);
    glShaderBinary(1, &shader, GL_SHADER_BINARY_FORMAT_SPIR_V, 
                   spvData.data(), spvData.size());
    glSpecializeShader(shader, "main", 0, nullptr, nullptr);

    (OpenGL 4.6+支持glShaderBinary直接加载SPIR-V;对于更早的版本,可以用GL_ARB_gl_spirv扩展。)

从此,你的着色器加载既快又稳,再也不用看显卡驱动的脸色了。

深度解析:SPIR-V的设计哲学与生态系统

1. SPIR-V是什么?

SPIR-V是Standard Portable Intermediate Representation - V的缩写。它是一种基于**SSA(Static Single Assignment,静态单赋值)**形式的中间表示,专为并行计算和图形设计。

它的核心数据结构是一个指令流 。每条指令有一个操作码(OpCode)和若干操作数。例如,OpIAdd %result %a %b 表示整数加法。

2. SPIR-V的模块结构

一个SPIR-V模块包含:

  • 头部(Header):魔数、版本、生成器ID、Bound(最大ID数)。
  • 能力声明(Capabilities) :告诉驱动这个模块用了哪些功能(如ShaderMatrixFloat64)。
  • 扩展声明(Extensions) :如SPV_KHR_ray_tracing
  • 导入指令(OpExtInstImport):导入GLSL.std.450这样的扩展指令集。
  • 内存模型(OpMemoryModel):定义寻址方式和内存一致性。
  • 入口点(OpEntryPoint) :定义main函数和其输入输出接口。
  • 执行模式(OpExecutionMode) :如LocalSize(用于计算着色器)、OriginUpperLeft(片段着色器原点)。
  • 调试信息(可选) :源代码映射、变量名(OpNameOpSource)。
  • 类型/常量/变量声明
  • 函数定义:包含基本块和指令。

3. SPIR-V与Vulkan/OpenGL的关系

Vulkan 强制要求 使用SPIR-V,不接受GLSL源码。OpenGL 4.6原生支持SPIR-V(通过GL_ARB_gl_spirv)。这意味着:

  • 如果你用Vulkan,你必须生成SPIR-V。
  • 如果你用OpenGL 4.6+,你可以选择用SPIR-V,也可以继续用GLSL源码。

4. SPIR-V的编译工具链

工具 作用
glslangValidator Khronos官方GLSL/HLSL → SPIR-V编译器
spirv-opt SPIR-V优化器(死代码消除、内联、常量折叠)
spirv-cross SPIR-V → GLSL/HLSL/MSL反编译器(用于跨平台)
spirv-val SPIR-V验证器,检查模块是否符合规范
spirv-dis SPIR-V反汇编器,把二进制转为可读文本
spirv-as SPIR-V汇编器,把文本转为二进制

5. 高级技巧:SPIR-V的反射(Reflection)

由于SPIR-V是二进制,你需要一种方法在运行时查询着色器的输入输出(如Uniform的位置、顶点属性的布局)。这可以通过:

  • SPIR-V Reflection :解析SPIR-V的OpDecorate指令,提取bindinglocation等信息。
  • 使用spirv-cross生成反射JSON
  • 在Vulkan中,通过VkPipelineLayout手动指定

在你的C++项目中,你可以写一个工具,在编译着色器时同时生成一个.json文件,记录所有Uniform和Attribute的绑定信息,运行时直接读取,避免手动硬编码glGetUniformLocation

6. 未来趋势:从GLSL到HLSL/GLSL混合编译

由于SPIR-V的语言无关性,业界正在向统一着色器语言 靠拢。微软的HLSL通过**DirectX Shader Compiler (DXC)**可以直接输出SPIR-V。这意味着你可以用同一套HLSL代码,既能在DirectX 12上跑,也能在Vulkan上跑。

对于跨平台的CAD软件来说,这是巨大的福音:你只需要维护一套着色器源码(HLSL),通过DXC编译成DXIL(给DirectX)和SPIR-V(给Vulkan),一份代码覆盖所有平台。


终章:现代专家眼中的完整管线流转图

经历了这六代演进,你终于可以完整地画出2026年一个三角形从CPU提交到屏幕显示的全流程

复制代码
                    [CPU: 准备顶点数据,设置状态]
                                    ↓
┌───────────────────────────────────────────────────────────────┐
│                     GPU 图形命令处理器                         │
└───────────────────────────────────────────────────────────────┘
                                    ↓
                    [顶点着色器 (Vertex Shader)]
                      • 变换顶点位置
                      • 计算逐顶点属性
                                    ↓
                    [细分控制着色器 (TCS)] ← 可选
                      • 决定细分级别
                                    ↓
                    [固定功能细分器 (Tessellator)] ← 硬件高速生成顶点
                                    ↓
                    [细分计算着色器 (TES)] ← 可选
                      • 位移映射
                                    ↓
                    [几何着色器 (Geometry Shader)] ← 可选,慎用
                      • 增删图元
                                    ↓
        ┌───────────────────────────────────────────┐
        │   图元装配 (Primitive Assembly)           │
        └───────────────────────────────────────────┘
                                    ↓
                    [裁剪与视口变换 (Clip & Viewport)]
                                    ↓
                    [光栅化器 (Rasterizer)] ← 硬件插值属性
                                    ↓
                    [片段着色器 (Fragment Shader)]
                      • 纹理采样
                      • 光照计算
                      • 输出颜色
                                    ↓
                    [逐片段操作 (Per-Fragment Ops)]
                      • 深度测试 • 模板测试 • 混合
                                    ↓
                    [帧缓冲 (Framebuffer)]
                                    ↓
                            [显示器]

与此同时,计算着色器(Compute Shader)在旁边并行运行:

复制代码
[CPU调度] → [计算着色器工作组] → [Shader Storage Buffer / Image]
                                          ↑↓
                          (通过 glMemoryBarrier 同步)
                                          ↑↓
                    [间接绘制命令缓冲] → [图形管线消费]

而SPIR-V像一条看不见的丝线,贯穿所有着色器阶段:

复制代码
[GLSL/HLSL源码] → (开发时) → [glslangValidator/DXC] → [SPIR-V二进制]
                                                              ↓
                                          (运行时) → [驱动SPIR-V解析器]
                                                              ↓
                                                  [GPU各着色器阶段执行]

尾声:你的下一步

你合上这本厚重的"GPU宪法",长舒一口气。你现在明白了:

  • 为什么固定管线时代会诞生那么多"开关式"的OpenGL函数。
  • 为什么顶点和片段着色器是最基础的可编程单元。
  • 为什么几何着色器性能差,而曲面细分是为细分任务量身定制的。
  • 为什么计算着色器能让你跳出图形管线的束缚,把GPU当超算用。
  • 为什么SPIR-V是解决着色器兼容性问题的终极方案。

回到你的CAD项目。你再看到那行ERROR: 'textureGrad' : no matching overloaded function时,不再是恐慌,而是淡定地:

  1. 检查你的OpenGL版本(glGetString(GL_VERSION))。
  2. 如果是3.0以下,升级上下文或者用替代函数。
  3. 如果是驱动问题,把你的GLSL编译成SPIR-V再加载。

你甚至开始计划,用计算着色器重构你的BVH射线拾取,用曲面细分优化你的地形扫描模型渲染,用SPIR-V统一Windows和Linux上的着色器加载流程。

着色器管线不再是黑盒。你成了真正驾驭GPU的人。


相关推荐
AIminminHu5 天前
OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(7):从“显卡不听话”到“GPU秒懂你”:一个CAD老兵的着色器驯服史))
着色器·编译流程·着色器语言 glsl·创建着色器对象·glcreateshader·gluseprogram·glcreateprogram
♡すぎ♡5 天前
ShaderLab:线条几何体旋转
unity·计算机图形学·着色器·shaderlab
AIminminHu7 天前
OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(3):GPU 着色器进化史:从傻瓜相机到 AI 画师,你的显卡里藏着一场战争)
人工智能·着色器
UQ_rookie11 天前
【Unity3D】在URP渲染管线下使用liltoon插件出现粉色无法渲染情况的解决方案
unity·游戏引擎·shader·urp·着色器·vrchat·liltoon
sp42a16 天前
如何在 NativeScript 中使用 iOS 的 Metal 着色器
ios·着色器·nativescript
mxwin20 天前
Unity Shader 逐像素光照 vs 逐顶点光照性能与画质的权衡策略
unity·游戏引擎·shader·着色器
mxwin20 天前
Unity URP 全局光照 (GI) 完全指南 Lightmap 采样与实时 GI(光照探针、反射探针)的 Shader 集成
unity·游戏引擎·shader·着色器
mxwin20 天前
Unity URP 下的 Early-Z / Depth Prepass 解决复杂片元着色器造成的 Overdraw 问题
unity·游戏引擎·着色器
mxwin23 天前
Unity URP 阴影映射 深度纹理、阴影采样与分辨率控制的深度解析
unity·游戏引擎·shader·着色器