Appearance
图形拔高
为什么延迟渲染不适合大量透明物体?
标准答案
延迟渲染不适合大量透明物体,核心原因是:延迟渲染依赖 G-Buffer 保存“每个像素当前最前面的一个表面信息”,而透明物体需要多个表面按顺序混合。一个像素里可能同时有玻璃、烟雾、粒子、半透明特效,它们不能只保留一层数据。
底层原理
延迟渲染通常分两步:
- Geometry Pass:不透明物体写入
G-Buffer,比如位置、法线、颜色、粗糙度、金属度。 - Lighting Pass:根据
G-Buffer对屏幕像素统一算光照。
这个流程对不透明物体很适合,因为一个像素最终只需要显示最靠前的那个表面。
但透明物体不同。Alpha Blend 的结果依赖绘制顺序:
c
finalColor = srcColor * alpha + dstColor * (1 - alpha)这里的 dstColor 是已经画好的背景颜色。也就是说透明物体必须知道“后面已经有什么颜色”,并且通常要从远到近绘制。顺序错了,结果就会错。
所以透明物体有几个问题:
G-Buffer只能存一层,不能表达多层透明。- 透明混合依赖顺序,延迟光照阶段没有这个顺序信息。
- 透明物体通常不写深度,容易排序错误。
- 大量透明粒子会造成严重
Overdraw,同一个像素被反复混合。 - 如果强行做透明延迟,需要 OIT、Depth Peeling 等方案,成本很高。
Unity/游戏场景
在 Unity 或常见游戏引擎里,通常是:
- 不透明物体走 Deferred。
- 透明物体在延迟光照之后,单独走 Forward Transparent Pass。
- 粒子、玻璃、水面、UI 特效、半透明技能特效都属于重点优化对象。
面试里可以这样说:
“延迟渲染适合大量不透明物体和多光源,因为它把光照从物体维度转成屏幕像素维度。但透明物体需要排序和背景混合,G-Buffer 单层结构表达不了多层透明,所以大量透明物体通常会退回 Forward 渲染,并且容易带来 Overdraw 和排序问题。”
优化点
大量透明物体要重点控制:
- 减少透明物体屏幕占比。
- 减少粒子数量和层数。
- 用软粒子、贴图裁剪、LOD 控制特效复杂度。
- 尽量避免大面积半透明叠加。
- 移动端尤其要看
Overdraw和带宽压力。
TAA 为什么会有拖影?
标准答案
TAA 会有拖影,是因为它不是只用当前帧画面做抗锯齿,而是会复用上一帧的颜色结果。它通过运动向量把上一帧像素重投影到当前帧,再和当前帧混合。如果历史帧找错位置,或者历史权重太高,旧画面就会残留在新画面里,看起来像拖影。
底层原理
TAA 的基本流程是:
- 当前帧相机做轻微抖动采样,减少空间锯齿。
- 用 motion vector 找到当前像素在上一帧的位置。
- 取上一帧的历史颜色。
- 当前颜色和历史颜色混合。
- 得到更稳定、更平滑的画面。
问题在于,历史帧不一定可靠。
比如一个角色快速移动,上一帧角色在左边,当前帧角色已经到了右边。如果 motion vector 不准,TAA 可能会把上一帧左边的角色颜色也混进来,于是角色后面就出现残影。
常见拖影原因
- 运动向量错误或缺失 骨骼动画、顶点动画、Shader 位移、粒子、透明物体如果没有正确写 motion vector,TAA 就不知道历史颜色应该从哪里取。
- 遮挡关系变化 一个物体移开后,后面的背景刚露出来。这个背景在上一帧可能被挡住,没有可靠历史颜色,TAA 如果继续复用旧颜色,就会脏。
- 历史权重太高 历史权重越高,画面越稳定,但响应越慢。快速运动时,旧画面会更久地残留。
- 透明物体和粒子复杂 透明物体排序、深度、运动向量都更复杂。烟雾、头发、玻璃、半透明特效很容易出现 TAA 拖影。
Unity/游戏场景
在 Unity HDRP 或其他现代渲染管线里,TAA 常用于减少闪烁、锯齿和 Shader 高频噪声,但移动快的角色、粒子特效、透明材质、植被摆动、头发边缘都容易出拖影。
工程上常见处理方式:
- 给动态物体正确输出 motion vector。
- 对历史颜色做 clamp,避免历史颜色偏离当前邻域太远。
- 检测深度、法线变化,变化太大就降低历史权重。
- 快速运动、镜头切换、传送时重置历史帧。
- 对粒子、透明物体降低或关闭 TAA 影响。
- 在移动端慎用高权重 TAA,因为拖影和模糊会更明显。
面试加分句
TIP
“TAA 的拖影本质是历史帧复用错误。它用历史帧提升稳定性,但如果运动向量、深度、遮挡关系或透明排序不可靠,历史颜色就会污染当前帧。所以 TAA 的关键不是简单混合,而是历史验证、历史钳制和动态权重控制。”
SSR 为什么会有屏幕空间限制?
标准答案
SSR,Screen Space Reflection,屏幕空间反射,会有屏幕空间限制,是因为它只使用“当前屏幕里已经渲染出来的数据”来做反射。它不知道屏幕外有什么,也不知道被前景物体挡住的东西是什么。
所以 SSR 不是物理上真正追踪世界空间反射,而是在当前帧的颜色图、深度图、法线图里找一个“看起来像反射命中点”的像素。
底层原理
SSR 的大致流程是:
- 当前像素是一个反射表面,比如地板、水面、金属。
- 根据视线方向和法线算出反射方向。
- 把反射射线投到屏幕空间。
- 沿屏幕空间做 ray marching。
- 用深度图判断射线是否碰到屏幕里已有的几何体。
- 如果命中,就采样当前帧颜色图作为反射颜色。
问题就在这里:它只能查当前屏幕缓冲。
如果反射方向指向屏幕外,SSR 没数据。 如果物体被别的物体挡住,深度图只保存最前面的表面,后面的信息已经丢了。 如果反射的是相机背后的东西,屏幕里根本没有它。 如果是透明物体、薄物体、粒子,深度和交点也很难稳定判断。
典型现象
SSR 常见问题包括:
- 反射到屏幕边缘时突然消失。
- 镜头一转,反射内容变化很明显。
- 被遮挡的物体无法出现在反射里。
- 薄物体、栏杆、细线容易漏反射。
- 粗糙表面可能噪声大,需要模糊和时域稳定。
- 透明物体通常很难被 SSR 正确处理。
Unity/游戏实践
在 Unity HDRP 或其他引擎中,SSR 通常适合做:
- 地面反射。
- 水面局部反射。
- 金属表面反射。
- 室内屏幕内可见物体的反射增强。
但它一般需要 fallback:
- SSR 命中失败时,用 Reflection Probe。
- 平面镜面效果强的水面或镜子,用 Planar Reflection。
- 高端平台可以用 Ray Tracing Reflection。
- 边缘区域通常做 fade,避免突然断裂太明显。
- 移动端要谨慎,因为 ray marching 和模糊都有成本。
面试加分句
NOTE
“SSR 的限制来自它的数据来源。它只依赖当前帧的颜色、深度和法线缓冲,所以屏幕外、被遮挡、背面、没写入深度的数据都不可见。工程上通常会把 SSR 当成局部增强,而不是完整反射方案,并用 Reflection Probe、Planar Reflection 或 Ray Tracing 做补充。”
法线贴图为什么要从切线空间转换到世界空间?
标准答案
法线贴图通常存的是切线空间 Tangent Space里的法线,而光照方向、视线方向、世界坐标位置通常在世界空间 World Space里计算。做光照时要算 dot(N, L),也就是法线和光照方向的夹角。如果 N 和 L 不在同一个坐标空间,点乘结果就是错的。
所以要么把法线贴图采样出来的 normalTS 转到世界空间,要么把光照方向转到切线空间。实际项目里更常见的是把法线转成 normalWS,再参与后续光照。
底层原理
法线贴图的 RGB 不是颜色意义上的颜色,而是一个方向:
R通常表示切线空间 X 方向。G通常表示切线空间 Y 方向。B通常表示切线空间 Z 方向。- 默认平面法线大约是
(0, 0, 1),所以法线贴图通常偏蓝。
切线空间由三个方向组成:
Tangent:切线方向,通常对应 UV 的 U 方向。Bitangent:副切线方向,通常对应 UV 的 V 方向。Normal:模型表面原始法线方向。
这三个向量组成 TBN 矩阵,用来把法线从切线空间转换到世界空间:
c
normalWS = normalize(TBN * normalTS)核心点是:切线空间描述的是“相对当前表面”的方向,世界空间描述的是“场景里的真实方向”。
为什么法线贴图要存在切线空间
如果法线贴图直接存世界空间法线,那这张贴图只能用于某个固定朝向的模型。模型一旋转,贴图里的法线方向就不对了。
切线空间的好处是它跟着模型表面和 UV 走。这样同一张砖墙法线贴图,可以贴在地面、墙面、斜坡、角色装备上,都能根据表面自己的 TBN 转成正确世界方向。
Unity/Shader 示例
下面是典型思路,每一行都带注释:
c
float3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv)); // 从法线贴图采样并解码出切线空间法线
float3 tangentWS = normalize(input.tangentWS.xyz); // 取得并归一化世界空间切线方向
float3 normalBaseWS = normalize(input.normalWS); // 取得并归一化模型原始世界空间法线
float tangentSign = input.tangentWS.w; // 读取切线符号,用来处理镜像 UV 和左右手坐标差异
float3 bitangentWS = normalize(cross(normalBaseWS, tangentWS) * tangentSign); // 通过法线和切线叉乘得到世界空间副切线
float3x3 tbn = float3x3(tangentWS, bitangentWS, normalBaseWS); // 用 T、B、N 三个世界空间方向组成 TBN 矩阵
float3 normalWS = normalize(mul(normalTS, tbn)); // 把切线空间法线转换到世界空间
float3 lightDirWS = normalize(_MainLightPosition.xyz); // 取得并归一化世界空间光照方向
float ndotl = saturate(dot(normalWS, lightDirWS)); // 在同一空间里做点乘,得到漫反射强度不同 Shader 框架里 mul(normalTS, tbn) 或 mul(tbn, normalTS) 写法可能不同,取决于矩阵行列约定。面试时重点不是背某个写法,而是说明:TBN 的作用是把切线空间法线变到光照计算使用的空间。
常见坑
- 忘记统一空间
normalTS直接和lightDirWS点乘,光照会明显错误。 - TBN 没归一化 模型缩放、插值后,
Tangent、Normal长度可能不是 1,需要 normalize。 - 副切线方向错 镜像 UV、不同平台法线贴图绿色通道方向不同,都可能导致凹凸反了。
- 模型没有切线数据 如果 Mesh 没有 Tangent,切线空间法线贴图就无法正确工作。
- 非等比缩放影响法线 法线不是普通位置向量,模型变换复杂时要用正确的法线变换方式。
面试加分句
IMPORTANT
“法线贴图的法线一般是切线空间的,因为这样贴图能跟着 UV 和表面方向复用。光照计算里的光照方向、视线方向通常在世界空间,所以需要用 TBN 矩阵把 normalTS 转成 normalWS。关键不是一定转到世界空间,而是参与点乘的向量必须在同一个空间。”
Cascaded Shadow Map 如何划分级联?
标准答案
Cascaded Shadow Map,简称 CSM,通常是把相机视锥沿深度方向切成多个区间,每个区间使用一张独立的 Shadow Map。近处级联覆盖范围小,所以同样分辨率下阴影更清晰;远处级联覆盖范围大,阴影精度相对低。
一句话:CSM 是把有限的阴影贴图分辨率优先分给近处。
底层原理
如果只用一张 Shadow Map 覆盖整个相机可见范围,会出现一个问题:远近物体共用同一张阴影图。远处范围很大,导致近处每个阴影 texel 对应的世界空间面积也很大,近处阴影就会锯齿、抖动、糊。
CSM 的做法是:
- 根据相机
near到shadow distance的范围划分多个深度段。 - 每个深度段对应一个 cascade。
- 取该段相机视锥的 8 个角点。
- 把这些角点变换到光源空间。
- 在光源空间里求包围盒。
- 用这个包围盒构造该级联的正交投影。
- 渲染该级联范围内的阴影贴图。
- 最终屏幕像素根据自己的深度选择对应 cascade 采样。
常见划分方式有三种:
- 线性划分:每段距离差不多,简单,但近处分辨率可能不够。
- 对数划分:近处切得更密,符合透视下近大远小的需求。
- 混合划分:线性和对数加权混合,实际引擎里很常见。
常见混合思路可以理解成:
c
split = lerp(linearSplit, logSplit, lambda)lambda 越接近 1,越偏向对数划分,近处质量更高;越接近 0,越接近线性划分,分布更平均。
Unity/游戏实践
在 Unity 里,主方向光阴影常见参数包括:
Shadow Distance:阴影最远绘制距离。Cascade Count:级联数量,常见 2 或 4。Cascade Splits:每个级联的距离比例。Shadow Resolution:阴影贴图分辨率。Cascade Border或类似过渡参数:减少级联边界突变。
实际项目里一般会这样调:
- 室内或小场景:shadow distance 可以小一些,cascade 数量少一些。
- 开放世界:shadow distance 更远,但要控制远处阴影质量和成本。
- 移动端:通常减少 cascade 数量,降低 shadow distance。
- 主机或 PC:可以用 4 cascades,提高近中距离阴影质量。
常见坑
- 级联越多不一定越好 每个 cascade 都可能需要额外渲染 shadow caster,数量多会增加 CPU 和 GPU 成本。
- Shadow Distance 太大会降低整体精度 阴影覆盖距离越远,每级需要覆盖的世界范围越大,阴影越容易糊。
- 级联边界可能有接缝 不同 cascade 分辨率、投影范围、bias 不同,边界处可能出现跳变,需要 fade 或 blend。
- 阴影会随相机移动抖动 光源空间正交投影如果没有对齐 shadow texel,相机移动时阴影采样会闪。常见做法是 texel snapping。
- Bias 设置不当 Bias 太小会有 shadow acne,太大又会 Peter Panning。
面试加分句
WARNING
“CSM 的划分不是简单平均切几段,而是在阴影精度和性能之间做分配。近处对阴影质量更敏感,所以通常给更小的覆盖范围和更高 texel density;远处可以牺牲一些精度。工程上还要处理级联边界过渡、光源投影稳定、shadow distance 和 cascade count 的成本。”
Clustered Forward Rendering 解决什么问题?
标准答案
Clustered Forward Rendering 主要解决的是:传统 Forward 渲染在大量动态光源下,每个像素或物体需要遍历太多灯,光照计算成本过高的问题。
它的做法是把相机空间划分成很多 3D 小格子,也就是 Cluster。每个 Cluster 只记录影响自己的灯光列表。最终像素着色时,不再遍历场景里所有灯,而是只遍历自己所在 Cluster 的那一小部分灯。
底层原理
传统 Forward 渲染可以理解成:
每个像素 = 遍历所有可能影响它的灯如果场景里有几十个甚至上百个动态点光源,片元 Shader 里的光照循环会很重。
Clustered Forward 的流程是:
- 把屏幕按 XY 切成 tile。
- 再按深度 Z 切成 slice。
- XY + Z 组合成 3D Cluster。
- 每帧计算每个灯影响哪些 Cluster。
- 给每个 Cluster 建立 light index list。
- 片元着色时,根据屏幕坐标和深度找到自己的 Cluster。
- 只遍历该 Cluster 里的灯。
所以它的核心是:
全局灯列表 -> 局部灯列表它解决了哪些问题
- Forward 多光源成本高 传统 Forward 如果灯很多,片元可能循环大量灯。Clustered Forward 把灯过滤到局部 Cluster,显著减少每个像素需要计算的灯数。
- Deferred 对透明物体不友好 Deferred 很适合大量不透明物体和多光源,但透明物体通常还要走 Forward。Clustered Forward 本身就是 Forward 路线,所以透明物体也可以使用 Cluster 灯列表。
- Deferred 的 G-Buffer 带宽压力 Deferred 需要写多张 G-Buffer,对移动端、VR、带宽敏感平台压力较大。Clustered Forward 不需要完整 G-Buffer,带宽压力通常更小。
- MSAA 支持更自然 Forward 路线下 MSAA 处理通常比 Deferred 更直接。Deferred 因为光照在 G-Buffer 后处理阶段做,MSAA 成本和实现复杂度更高。
它没有解决什么
Clustered Forward 不是主要用来减少 DrawCall 的。 它解决的是光照循环规模,不是物体提交次数。
DrawCall 多还是要靠:
- SRP Batcher
- GPU Instancing
- Static Batching
- Dynamic Batching
- 合理材质合并
- 减少状态切换
Unity/游戏实践
在游戏里,Clustered Forward 适合这些场景:
- 很多动态点光源。
- 很多透明特效。
- 希望保留 Forward 材质路径。
- 移动端或 VR 需要控制 G-Buffer 带宽。
- 场景里灯光影响范围比较局部。
但它也有代价:
- 每帧要构建 Cluster。
- 要把灯分配到 Cluster。
- 需要维护 light list buffer。
- Cluster 尺寸、Z 切片数量会影响质量和性能。
- 灯非常大、覆盖全屏时,优化效果会下降。
面试加分句
IMPORTANT
“Clustered Forward 的核心不是减少 DrawCall,而是减少每个像素参与光照计算的灯数。它把相机空间划分成 3D Cluster,预先建立 Cluster 到 Light List 的映射,最终片元只遍历局部灯。它保留了 Forward 对透明和 MSAA 的优势,同时缓解了传统 Forward 多光源性能差的问题。”
GPU Driven Rendering 是什么?
标准答案
GPU Driven Rendering 是一种把更多渲染决策从 CPU 转移到 GPU 的渲染架构。它不是 CPU 一个个对象判断、排序、提交 DrawCall,而是 CPU 上传场景数据,GPU 通过 Compute Shader 并行完成剔除、LOD、实例筛选、绘制参数生成,最后用 Indirect Draw 批量绘制。
一句话:CPU 提供数据,GPU 决定哪些对象真正画。
底层原理
传统 CPU Driven 流程通常是:
- CPU 遍历场景对象。
- CPU 做视锥剔除、遮挡判断、LOD 选择。
- CPU 组织渲染队列。
- CPU 向图形 API 提交 DrawCall。
- GPU 执行绘制。
对象数量很大时,CPU 主线程和渲染线程容易成为瓶颈。
GPU Driven 的流程是:
- CPU 把实例数据上传到 GPU Buffer。
- GPU 用 Compute Shader 做视锥剔除。
- GPU 用 Hi-Z 深度金字塔做遮挡剔除。
- GPU 选择 LOD。
- GPU 把可见实例压缩到 visible list。
- GPU 生成 indirect draw args。
- GPU 执行
DrawIndirect或类似接口。
核心数据一般包括:
Instance Buffer:所有实例的位置、包围盒、材质 ID、LOD 数据。Visible List:GPU 筛出来的可见实例索引。Indirect Args Buffer:间接绘制需要的参数,比如实例数量。Hi-Z Depth:用于 GPU 遮挡剔除的深度金字塔。LOD Buffer:不同 LOD 的 Mesh 或绘制范围数据。
它解决什么问题
- 解决 CPU 遍历大量对象的问题 大世界、海量植被、碎石、建筑件,如果每帧都让 CPU 遍历和提交,CPU 压力很大。GPU Driven 可以让 GPU 并行处理这些对象。
- 解决 DrawCall 提交成本问题 传统做法可能是大量小 DrawCall。GPU Driven 通常配合 Instancing、Indirect Draw,把很多实例合成少量间接绘制。
- 解决可见性计算规模问题 视锥剔除、遮挡剔除、LOD 选择都适合并行。GPU 天然适合处理大量实例的同类计算。
- 适合开放世界和海量实例场景 草、树、石头、远景建筑、地表装饰物,都适合 GPU Driven。
Unity/游戏实践
在 Unity 里,可以把 GPU Driven 理解成这些方向:
DrawMeshInstancedIndirect或类似间接绘制。- Compute Shader 做实例剔除。
- GPU Instancing 批量绘制相同 Mesh。
- DOTS / Entities Graphics 的批量渲染思路。
- 大量草地、植被、石块、场景小物件的 GPU Culling。
它和普通 GPU Instancing 的区别是:
- GPU Instancing 重点是“一次 Draw 画多个实例”。
- GPU Driven 更进一步,重点是“GPU 自己决定哪些实例可见,并生成绘制参数”。
缺点和代价
GPU Driven 不是免费优化,它会带来这些成本:
- 系统复杂度高。
- 调试难度大,很多数据在 GPU Buffer 里。
- GPU Compute 阶段也会消耗性能。
- CPU 和 GPU 数据同步要小心,避免读回造成卡顿。
- 材质、Mesh、LOD、Batch 组织不好,收益会下降。
- 对透明物体、排序要求高的对象处理更复杂。
面试加分句
TIP
“GPU Driven 的核心不是完全不用 CPU,而是把 CPU 从逐对象剔除和逐 DrawCall 提交中解放出来。CPU 负责上传结构化场景数据,GPU 负责并行做可见性、LOD 和间接绘制参数生成。它适合海量实例和开放世界,但代价是 Buffer 管理、调试、同步和渲染架构复杂度都会上升。”
Compute Shader 适合做什么?
标准答案
Compute Shader 适合做大量、独立、重复、可并行的数据计算。它本质上是运行在 GPU 上的通用计算程序,不走传统顶点/片元固定渲染流程,而是通过 Dispatch 启动很多线程组,并行处理 Buffer 或 Texture。
典型适合场景:
- 图像处理:模糊、降采样、边缘检测、后处理。
- 粒子模拟:大量粒子位置、速度、生命周期更新。
- GPU 剔除:视锥剔除、Hi-Z 遮挡剔除、LOD 选择。
- 数据生成:噪声、地形、高度图、体素、贴图生成。
- GPU Driven:生成可见列表、间接绘制参数。
- 并行算法:前缀和、排序、归约、矩阵/数组计算。
底层原理
CPU 调用 Dispatch(x, y, z) 后,GPU 会启动很多线程组。每个线程根据自己的 SV_DispatchThreadID 找到要处理的数据下标。
它适合的任务一般有三个特征:
- 数据量大。
- 每个元素计算方式类似。
- 元素之间依赖少。
不适合:
- 强顺序逻辑。
- 分支特别复杂且每个线程走不同分支。
- 数据量很小的任务。
- 每帧频繁从 GPU
GetData读回 CPU。 - 需要直接操作 Unity GameObject 的逻辑。
代码示例:两个数组在 GPU 上相加
c
#pragma kernel CSMain // 指定 Compute Shader 的入口函数是 CSMain
StructuredBuffer<float> A; // 输入数组 A,只读 Buffer
StructuredBuffer<float> B; // 输入数组 B,只读 Buffer
RWStructuredBuffer<float> Result; // 输出数组 Result,可写 Buffer
uint Count; // 数组有效长度,防止线程越界
[numthreads(64, 1, 1)] // 每个线程组包含 64 个线程
void CSMain(uint3 id : SV_DispatchThreadID) // 每个线程都会执行一次,并拿到全局线程 ID
{ // 函数体开始
uint index = id.x; // 使用线程的 x 坐标作为数组下标
if (index >= Count) return; // 如果线程下标超过数组长度,就直接退出
Result[index] = A[index] + B[index]; // 当前线程负责计算一个元素的相加结果
} // 函数体结束
using UnityEngine; // 引入 Unity 引擎命名空间
public class ComputeAddExample : MonoBehaviour // 定义一个挂在 GameObject 上的测试脚本
{ // 类开始
public ComputeShader shader; // 在 Inspector 中绑定 Compute Shader
private ComputeBuffer aBuffer; // 保存数组 A 的 GPU Buffer
private ComputeBuffer bBuffer; // 保存数组 B 的 GPU Buffer
private ComputeBuffer resultBuffer; // 保存结果数组的 GPU Buffer
private void Start() // Unity 启动时执行一次
{ // Start 开始
int count = 1024; // 设置要计算的元素数量
float[] a = new float[count]; // 创建 CPU 侧数组 A
float[] b = new float[count]; // 创建 CPU 侧数组 B
float[] result = new float[count]; // 创建 CPU 侧结果数组
for (int i = 0; i < count; i++) // 初始化测试数据
{ // for 开始
a[i] = i; // 给数组 A 填入当前下标
b[i] = i * 2; // 给数组 B 填入当前下标的两倍
} // for 结束
aBuffer = new ComputeBuffer(count, sizeof(float)); // 创建 GPU Buffer A
bBuffer = new ComputeBuffer(count, sizeof(float)); // 创建 GPU Buffer B
resultBuffer = new ComputeBuffer(count, sizeof(float)); // 创建 GPU Buffer Result
aBuffer.SetData(a); // 把 CPU 数组 A 上传到 GPU
bBuffer.SetData(b); // 把 CPU 数组 B 上传到 GPU
int kernel = shader.FindKernel("CSMain"); // 找到 Compute Shader 入口函数
shader.SetBuffer(kernel, "A", aBuffer); // 绑定输入 Buffer A
shader.SetBuffer(kernel, "B", bBuffer); // 绑定输入 Buffer B
shader.SetBuffer(kernel, "Result", resultBuffer); // 绑定输出 Buffer Result
shader.SetInt("Count", count); // 设置数组长度参数
int groupCount = Mathf.CeilToInt(count / 64.0f); // 计算需要多少个线程组
shader.Dispatch(kernel, groupCount, 1, 1); // 启动 GPU 并行计算
resultBuffer.GetData(result); // 把结果从 GPU 读回 CPU,真实项目里不要频繁这样做
Debug.Log(result[10]); // 输出一个结果用于验证
} // Start 结束
private void OnDestroy() // 对象销毁时释放 GPU 资源
{ // OnDestroy 开始
aBuffer?.Release(); // 释放 Buffer A,避免显存泄漏
bBuffer?.Release(); // 释放 Buffer B,避免显存泄漏
resultBuffer?.Release(); // 释放结果 Buffer,避免显存泄漏
} // OnDestroy 结束
} // 类结束Unity 工程实践
NOTE
Compute Shader 很适合用来把 CPU 压力转移到 GPU,比如大规模粒子、GPU Culling、GPU Driven Rendering、地形贴图生成。但要注意:不要频繁 GetData,因为 CPU 读回 GPU 数据会造成同步等待,可能直接卡主线程。
面试加分句:
“我会优先把 Compute Shader 用在数据量大、依赖少、结果可以留在 GPU 继续使用的场景。它不是万能加速器,如果任务很小、分支复杂,或者每帧都要读回 CPU,反而可能更慢。”
Occlusion Culling 和 Frustum Culling 区别是什么?
标准答案
Frustum Culling 是视锥裁剪:判断物体是否在相机能看到的视锥范围内。 Occlusion Culling 是遮挡裁剪:判断物体虽然在相机视锥内,但是否被墙、建筑、地形等遮挡物挡住。
一句话区分:
Frustum Culling 解决“相机外不画”,Occlusion Culling 解决“相机内但被挡住不画”。
底层原理
Frustum Culling 通常用相机视锥的 6 个平面测试物体包围盒:
- 左平面
- 右平面
- 上平面
- 下平面
- 近平面
- 远平面
如果物体包围盒完全在视锥外,就不进入渲染队列。
Occlusion Culling 更进一步。它不是只看物体是否在相机范围内,还要判断这个物体是否被其他物体挡住。比如一个箱子在相机前方,但前面有一堵墙完全挡住它,那么它虽然在视锥内,也没必要渲染。
执行顺序
实际渲染系统通常是:
- 先做 Frustum Culling,排除相机外对象。
- 再做 Occlusion Culling,排除被遮挡对象。
- 剩下的对象才进入渲染队列。
- 再按材质、Pass、距离等规则排序和提交。
Unity/游戏实践
Unity 中,Frustum Culling 通常是基础能力,Camera 会基于 Renderer 的 Bounds 做可见性判断。
Occlusion Culling 则更依赖场景结构。它适合:
- 室内场景。
- 城市场景。
- 有大量墙体、建筑、房间遮挡的场景。
- 走廊、关卡、箱庭地图。
不太适合:
- 大草原、沙漠、海面这种开阔场景。
- 遮挡物很少的场景。
- 大量动态遮挡关系变化的场景。
常见坑
- Bounds 错误会误裁剪 如果 Mesh 或 Renderer Bounds 不正确,可能物体还看得见却被裁掉。
- Occlusion Culling 不是免费 它有计算和数据维护成本。遮挡少的场景收益可能不明显。
- 动态物体更麻烦 静态场景可以预计算遮挡数据,动态物体和动态遮挡物需要额外处理。
- 透明物体不能可靠当遮挡物 透明玻璃虽然视觉上挡住一部分画面,但它不适合作为普通遮挡体。
- 剔除粒度过大可能错误 大物体 Bounds 很大,即使只露出一点,也可能整体被认为可见。
面试加分句
NOTE
“Frustum Culling 是基于相机视锥的几何范围裁剪,成本低,是最基础的可见性判断。Occlusion Culling 是基于遮挡关系的可见性判断,可以进一步减少被遮住对象的提交和绘制,但它更依赖场景结构,也有额外计算和数据维护成本。一般先做视锥裁剪,再做遮挡裁剪。”
为什么移动端 Tile-Based GPU 对带宽敏感?
标准答案
移动端 Tile-Based GPU 对带宽敏感,是因为它的核心优势就是尽量把一个 Tile 的中间渲染结果留在片上高速内存里处理,避免频繁访问外部显存。外部显存访问在移动端非常贵,会带来性能下降、功耗上升、发热和降频。
一句话:Tile-Based GPU 快,是因为少搬数据;一旦频繁搬数据,就会变慢、发热。
底层原理
Tile-Based GPU 会把屏幕切成很多小块,也就是 Tile。渲染时大致流程是:
- 先把屏幕划分成 Tile。
- 判断每个 Tile 里有哪些三角形。
- 把一个 Tile 的颜色、深度、模板等中间结果放在片上 Tile Memory。
- 在片上完成该 Tile 的光栅化、深度测试、混合等操作。
- 最后只把最终颜色写回外部显存。
片上 Tile Memory 速度快、功耗低,但容量有限。外部显存容量大,但访问慢、功耗高。
所以移动端 GPU 很怕这些操作:
- 频繁
Load旧 RenderTarget。 - 频繁
Store中间 RenderTarget。 - MSAA Resolve。
- 多次 Blit。
- 多张 MRT,比如 Deferred G-Buffer。
- 高分辨率 HDR RenderTexture。
- Copy Color、Copy Depth。
- 大量后处理全屏 Pass。
- 大面积透明和 UI Overdraw。
为什么后处理容易贵
后处理经常是:
读一张全屏 RT -> 写一张全屏 RT -> 再读 -> 再写每一次读写都可能访问外部显存。分辨率越高、格式越大、Pass 越多,带宽越高。
比如 1080p 的一张 RGBA16F 颜色 RT,本身就比普通 RGBA8 更贵;如果再做 Bloom、Tone Mapping、Color Grading、FXAA,每个 Pass 都可能增加读写成本。
Unity/游戏实践
移动端项目里要重点看这些点:
- 减少不必要的 RenderTexture。
- 降低 RT 分辨率和格式。
- 慎用 HDR、MSAA、高精度深度。
- 减少全屏 Blit。
- 避免不必要的 Copy Color 和 Copy Depth。
- 不需要保留深度时设置为 discard 或 memoryless。
- 控制透明特效和 UI Overdraw。
- 尽量合并后处理 Pass。
- 在 URP 里关注 Render Pass 的 Load/Store Action。
- 避免 Camera Stack 造成额外中间纹理读写。
面试加分句
TIP
“Tile-Based GPU 的优势是片上 Tile Memory 能减少中间结果写回外部显存。但移动端外部带宽和功耗都很敏感,如果大量使用 MRT、HDR RT、MSAA Resolve、后处理 Blit、透明 Overdraw,就会破坏 Tile 架构省带宽的优势。所以移动端优化不是只看算力,还要重点看 RenderTarget 读写次数、格式、分辨率和 Load/Store 行为。”