Appearance
图形岗套卷
点乘叉乘应用
标准答案
点乘和叉乘都是向量运算,但解决的问题不一样:点乘主要用来判断“两个方向有多接近”,叉乘主要用来判断“垂直方向、左右关系、旋转方向”。
点乘 Dot 的应用
点乘公式是:
c
A · B = |A| * |B| * cos(theta)如果两个向量都归一化了,那么:
c
A · B = cos(theta)所以点乘常用来做:
- 判断目标在前方还是后方:
dot > 0在前方,dot < 0在后方。 - 判断是否在视野范围内:
dot > cos(半视野角)。 - 判断光照强弱:兰伯特光照里常用
max(0, dot(N, L))。 - 做投影:把一个向量投影到另一个方向上。
- 判断两个方向是否接近:比如角色朝向和移动方向是否一致。
叉乘 Cross 的应用
叉乘公式可以理解为:
A x B = 垂直于 A 和 B 所在平面的向量叉乘常用来做:
- 判断目标在左边还是右边。
- 求三角形法线:
normal = Cross(B - A, C - A)。 - 判断多边形顶点绕序。
- 计算平行四边形面积。
- 求旋转轴:从一个方向转到另一个方向时,叉乘结果就是旋转轴方向。
Unity 代码示例
c
using UnityEngine; // 引入 UnityEngine,使用 Vector3、Mathf 和 Transform。
public class DotCrossExample : MonoBehaviour // 定义一个演示点乘和叉乘的组件。
{ // 类开始。
public Transform target; // 保存目标对象的 Transform。
public float viewAngle = 90f; // 保存视野角度,例如 90 度。
public bool IsTargetInView() // 判断目标是否在角色视野内。
{ // 函数开始。
Vector3 toTarget = target.position - transform.position; // 计算从自己指向目标的方向。
toTarget.Normalize(); // 把方向向量归一化,方便点乘直接表示 cos 值。
Vector3 forward = transform.forward; // 取得角色当前前方方向。
float dot = Vector3.Dot(forward, toTarget); // 计算前方方向和目标方向的点乘。
float halfAngle = viewAngle * 0.5f; // 计算半视野角。
float limit = Mathf.Cos(halfAngle * Mathf.Deg2Rad); // 把角度转成 cos 阈值。
return dot >= limit; // 点乘大于阈值,说明目标在视野扇形内。
} // 函数结束。
public bool IsTargetOnRight() // 判断目标是否在角色右侧。
{ // 函数开始。
Vector3 toTarget = target.position - transform.position; // 计算从自己到目标的方向。
Vector3 cross = Vector3.Cross(transform.forward, toTarget); // 用叉乘判断目标在左边还是右边。
return cross.y > 0f; // 在 Unity 常见水平面里,y 大于 0 通常表示目标在右侧。
} // 函数结束。
} // 类结束。面试关键点
CAUTION
点乘得到的是一个数,适合回答“夹角大小、是否同向、投影长度”。
叉乘得到的是一个向量,适合回答“垂直方向、左还是右、法线朝哪边”。
最容易踩的坑是:做角度判断前通常要先归一化;叉乘的方向和参数顺序有关,A x B 和 B x A 方向相反。
MVP 矩阵
标准答案
MVP 矩阵就是 Model * View * Projection 这一套顶点变换链,用来把模型顶点从“模型自己的局部坐标”,一步步变到 GPU 可以裁剪和光栅化的“裁剪空间”。
底层原理
Model 矩阵:把模型从局部空间变到世界空间。比如一个角色 Mesh 原点在自己脚下,通过 Transform 的位置、旋转、缩放,摆到场景里的某个位置。
View 矩阵:把世界空间变到相机空间。可以理解为不是相机真的移动世界,而是把整个世界反向变换到“以相机为原点”的坐标系里。
Projection 矩阵:把相机空间变到裁剪空间。透视投影会产生近大远小,正交投影则不会近大远小。
完整流程是:
c
local -> world -> view -> clip -> NDC -> screenUnity Shader 里怎么用
c
struct appdata { // 定义从 Mesh 输入到顶点着色器的数据结构。
float4 vertex : POSITION; // 保存模型局部空间下的顶点坐标。
}; // appdata 结构体结束。
struct v2f { // 定义顶点着色器传给片元着色器的数据结构。
float4 pos : SV_POSITION; // 保存裁剪空间坐标,GPU 后续会用它做裁剪和光栅化。
}; // v2f 结构体结束。
v2f vert(appdata v) { // 定义顶点着色器函数。
v2f o; // 创建输出结构体变量。
o.pos = UnityObjectToClipPos(v.vertex); // 把局部空间顶点通过 MVP 变换到裁剪空间。
return o; // 返回顶点着色器输出。
} // 顶点着色器函数结束。面试关键点
WARNING
面试里不要只说“矩阵相乘”。要说清楚每个矩阵解决的问题:
Model 解决“物体放在哪里”。
View 解决“相机怎么看世界”。
Projection 解决“如何投影到屏幕前的裁剪空间”。
还要补一句:不同引擎、不同数学库可能有行向量、列向量、左乘、右乘、行主序、列主序差异,所以不要死背字面顺序,要看当前引擎约定。Unity Shader 里通常直接用 UnityObjectToClipPos 或 UNITY_MATRIX_MVP,避免自己把顺序写错。
深度测试和模板测试
标准答案
深度测试管“挡不挡”,模板测试管“让不让画”。
深度测试会拿当前片元的深度值和深度缓冲里的值比较,如果当前片元更靠近相机,就通过;如果更远,通常会被丢弃。模板测试会拿当前像素的模板值和 Shader 里设置的 Ref 做比较,只有符合规则的像素才允许继续绘制。
底层原理
深度缓冲可以理解成一张和屏幕一样大的“距离表”。每个像素记录当前已经画过的最近深度。新片元来了以后,GPU 比较它的深度:
- 更近:通过深度测试,可以写颜色,也可能更新深度。
- 更远:深度测试失败,被前面的物体挡住,不写颜色。
模板缓冲可以理解成一张和屏幕一样大的“标记表”。每个像素存一个整数值。Shader 可以先往模板缓冲写标记,再让后续 Pass 只在指定标记区域绘制。
Unity Shader 示例
c
Shader "Interview/DepthStencilDemo" // 定义一个演示深度和模板的 Shader。
{ // Shader 开始。
SubShader // 定义一个子着色器。
{ // SubShader 开始。
Tags { "Queue"="Geometry" } // 设置渲染队列为不透明队列。
Pass // 定义一个渲染 Pass。
{ // Pass 开始。
ZTest LEqual // 深度测试使用小于等于,近处或相同深度通过。
ZWrite On // 通过深度测试后写入深度缓冲。
Stencil // 开始配置模板测试。
{ // Stencil 配置开始。
Ref 1 // 设置当前 Pass 使用的模板参考值为 1。
Comp Always // 模板比较永远通过。
Pass Replace // 模板测试通过后,把模板缓冲写成 Ref 的值。
} // Stencil 配置结束。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。Unity 工程实践
不透明物体通常 ZWrite On、ZTest LEqual,这样可以正确建立深度缓冲,也有利于 Early-Z 优化,减少后面无效片元的计算。
透明物体通常 ZWrite Off,否则透明物体可能先写深度,导致后面的透明物体被错误挡掉。所以透明物体一般需要按距离排序,从远到近绘制。
模板测试常用于:
- UI Mask:只让子 UI 在遮罩区域显示。
- 描边:先写模板,再绘制膨胀模型时避开原模型区域。
- 传送门和镜子:先写一个门的区域,再只在这个区域绘制另一个画面。
- 特效裁剪:让技能特效只出现在指定形状内。
面试关键点
IMPORTANT
深度测试解决遮挡关系,模板测试解决像素级区域控制。
追问时可以补一句:深度测试不仅影响正确性,也影响性能。合理的不透明排序和 ZWrite On 可以让 Early-Z 提前丢弃被遮挡片元;而透明、Alpha Blend、discard 等情况可能削弱这类优化。
Forward 和 Deferred
标准答案
Forward 和 Deferred 是两种渲染路径。
Forward 是“画每个物体的时候直接把光照算完”。Deferred 是“先把不透明物体的几何和材质信息写到 GBuffer,再用屏幕空间的 Lighting Pass 统一算光”。
底层原理
Forward 的流程更直接:
物体 -> Shader -> 计算光照 -> 输出颜色每个物体在渲染时,就要把影响它的光源算进去。如果场景里物体很多、光源也很多,就可能变成“物体数量 × 光源数量”的压力。
Deferred 的流程分两步:
c
第一步:Geometry Pass -> 写 GBuffer
第二步:Lighting Pass -> 读取 GBuffer 统一算光GBuffer 里通常会保存:
- Albedo:基础颜色
- Normal:法线
- Depth:深度
- Smoothness / Roughness:粗糙度
- Metallic:金属度
- 其他材质参数
这样光照阶段不需要重新画每个物体,而是对屏幕上的像素做光照计算,所以大量动态光源时更有优势。
Unity 工程实践
Forward 适合:
- 移动端
- 光源少的项目
- 透明物体多的项目
- 需要 MSAA 的项目
- 带宽比较紧张的平台
Deferred 适合:
- 不透明物体多
- 动态光源很多
- 桌面、主机或高端设备
- 需要更复杂光照的场景
透明物体是 Deferred 的一个大坑。因为 Deferred 的 GBuffer 主要存一个像素最终可见的不透明表面信息,而透明物体需要混合多个层次,不能简单只存一层 GBuffer。所以常见做法是:不透明走 Deferred,透明仍然走 Forward。
面试关键点
TIP
Forward 的优点是流程简单、透明友好、MSAA 支持更自然、移动端更常见。缺点是大量动态光时容易重复计算。
Deferred 的优点是大量动态光更容易扩展,光照和几何阶段分离。缺点是 GBuffer 占显存和带宽,透明物体处理麻烦,MSAA 成本更高,移动端要谨慎。
可以这样总结:Forward 不是低级,Deferred 也不是一定更高级。真正的选择要看瓶颈。如果项目光源少、透明多、跑移动端,我更倾向 Forward;如果不透明场景多、动态光多、目标平台性能足够,我会考虑 Deferred 或 Forward+ / Clustered Forward。
Shadow Map
标准答案
Shadow Map 是“从光源视角渲染出来的一张深度图”。它不直接保存阴影颜色,而是保存光源能看到的最近深度。正常渲染时,把当前片元转换到光源空间,再和 Shadow Map 里的深度比较:如果当前片元更远,说明它被别的物体挡住了,就在阴影里。
底层原理
Shadow Map 一般分两遍:
第一遍从光源视角渲染场景,只写深度,不算颜色。这样得到一张深度纹理,里面每个像素记录“光源在这个方向上看到的最近物体深度”。
第二遍从主相机正常渲染场景。每个片元会被转换到光源空间,得到它相对光源的深度。然后采样 Shadow Map 中对应位置的深度:
c
currentDepth > shadowMapDepth + bias => 在阴影中
currentDepth <= shadowMapDepth + bias => 被光照到这里的 bias 是阴影偏移,用来缓解精度误差导致的自阴影问题。
Unity 工程实践
方向光通常会用 CSM,也就是 Cascaded Shadow Map。因为方向光要覆盖相机视野很大一块区域,如果只用一张 Shadow Map,近处阴影会很糊,所以把相机视锥分成多个级联,近处用更高精度。
聚光灯阴影比较像普通相机,从灯光方向渲染一张深度图。
点光源阴影更贵,因为点光源向四面八方照射,通常要用 Cubemap Shadow Map,相当于多个方向的深度图。
代码示意
c
float4 shadowCoord = mul(_LightViewProjection, worldPos); // 把世界坐标转换到光源裁剪空间。
shadowCoord.xyz /= shadowCoord.w; // 做透视除法,把坐标变到可采样范围前的标准空间。
float2 uv = shadowCoord.xy * 0.5 + 0.5; // 把 [-1,1] 范围转换到 [0,1] 的纹理 UV。
float currentDepth = shadowCoord.z; // 取得当前片元相对光源的深度。
float storedDepth = SAMPLE_DEPTH_TEXTURE(_ShadowMap, uv); // 从 Shadow Map 中采样光源记录的最近深度。
float bias = 0.001; // 设置一个小偏移,减少阴影痤疮。
float shadow = currentDepth > storedDepth + bias ? 0.0 : 1.0; // 当前深度更远则在阴影中,否则被光照到。
float3 finalColor = baseColor * lerp(0.35, 1.0, shadow); // 阴影区域降低直接光照强度。常见问题
NOTE
阴影痤疮:表面自己遮挡自己,出现密集条纹。原因通常是深度精度误差,可以用 Bias、Normal Bias、提高阴影分辨率、调整近平面远平面缓解。
Peter Panning:Bias 太大,阴影和物体分离,看起来像物体飘起来。
阴影锯齿:Shadow Map 分辨率有限,边缘会硬且锯齿明显。常用 PCF 多次采样做柔化。
移动端优化时,我会优先控制阴影距离、阴影分辨率、级联数量、实时阴影数量和软阴影开关。阴影质量不是只调高分辨率,很多时候要结合相机距离和关卡需求做分级。
法线贴图
标准答案
法线贴图就是用一张纹理保存表面法线方向,让低面数模型也能在光照上表现出凹凸细节。它不会真的改变模型形状,也不会改变轮廓,只是改变每个像素参与光照计算时使用的法线。
底层原理
普通贴图的 RGB 表示颜色,法线贴图的 RGB 表示方向。因为纹理颜色范围是 [0,1],而法线方向需要 [-1,1],所以采样后通常要解码:
c
normal = rgb * 2 - 1法线贴图大多是蓝紫色,因为在切线空间里,默认表面法线大致是:
(0, 0, 1)映射到颜色范围后就是:
(0.5, 0.5, 1.0)所以蓝色通道通常比较高,看起来就偏蓝。
TBN 是什么
法线贴图通常保存的是切线空间法线。切线空间是贴着模型表面的局部坐标系,由三个方向组成:
Tangent:沿着纹理 U 方向Bitangent:沿着纹理 V 方向Normal:表面原始法线方向
这三个方向组成 TBN 矩阵,用来把切线空间法线转换到世界空间,再参与光照计算。
Shader 示例
c
float3 normalTex = tex2D(_NormalMap, uv).rgb; // 从法线贴图中采样 RGB 数据。
float3 normalTS = normalTex * 2.0 - 1.0; // 把 [0,1] 范围解码到 [-1,1] 的切线空间法线。
normalTS = normalize(normalTS); // 归一化切线空间法线,避免长度误差影响光照。
float3 tangentWS = normalize(input.tangentWS); // 取得并归一化世界空间切线方向。
float3 bitangentWS = normalize(input.bitangentWS); // 取得并归一化世界空间副切线方向。
float3 normalWSBase = normalize(input.normalWS); // 取得并归一化模型原始世界空间法线。
float3x3 tbn = float3x3(tangentWS, bitangentWS, normalWSBase); // 用 T、B、N 组成 TBN 矩阵。
float3 normalWS = normalize(mul(normalTS, tbn)); // 把切线空间法线转换到世界空间。
float ndotl = saturate(dot(normalWS, lightDirWS)); // 用扰动后的法线和光方向做点乘。
float3 finalColor = albedo * lightColor * ndotl; // 根据光照强度得到最终漫反射颜色。Unity 工程实践
CAUTION
法线贴图常用于角色皮肤、布料、石头、砖墙、武器划痕、地表细节。它的优势是用比较低的模型面数表现高频细节,节省顶点和建模成本。
常见坑有三个:
导入设置要选 Normal Map,不要当普通颜色贴图处理。
不同 DCC 工具和引擎可能绿色通道方向不同,出现“凹凸反了”时经常要翻转 Green Channel。
法线贴图只影响光照,不改变真实轮廓。近距离看边缘还是低模形状,如果要真正改变轮廓,需要位移贴图、视差贴图或增加几何细节。
PBR
标准答案
PBR 是 Physically Based Rendering,也就是基于物理规律的渲染。它的目标不是“绝对真实”,而是让材质参数更符合物理直觉,在不同光照环境下表现更稳定、更一致。
底层原理
PBR 常见核心有四个:
能量守恒:反射出去的光不能比照进来的光更多。比如高光反射越强,漫反射部分就应该相应减少。
微表面模型:物体表面不是绝对光滑的,而是由很多微小表面组成。粗糙度越低,微表面方向越一致,高光越集中;粗糙度越高,高光越散。
Fresnel:视线越接近掠射角,反射越强。你看水面、金属边缘时会觉得边缘反光更明显,就是这个现象。
IBL:Image Based Lighting,用环境贴图提供间接光和环境反射。没有 IBL,金属材质会很难看,因为金属主要靠反射周围环境体现质感。
常见材质参数
Albedo 是基础颜色。非金属材质里它更像漫反射颜色;金属材质里它更影响反射颜色。
Metallic 表示金属度。0 更像塑料、木头、石头;1 更像铁、金、铜这类金属。
Roughness / Smoothness 控制粗糙度。粗糙度低,高光小而亮;粗糙度高,高光大而散。
Normal Map 改变像素级法线,让表面光照出现凹凸细节。
AO 表示环境遮蔽,用来压暗缝隙、角落这些环境光不容易到达的地方。
Shader 思路示例
c
float3 N = normalize(normalWS); // 归一化世界空间法线。
float3 V = normalize(viewDirWS); // 归一化从表面指向相机的方向。
float3 L = normalize(lightDirWS); // 归一化从表面指向光源的方向。
float3 H = normalize(V + L); // 计算半角向量,用于微表面高光。
float NdotL = saturate(dot(N, L)); // 计算法线和光方向夹角,得到受光强度。
float NdotV = saturate(dot(N, V)); // 计算法线和视线方向夹角,用于 Fresnel 和几何遮蔽。
float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic); // 非金属基础反射约为 0.04,金属用 albedo 作为反射颜色。
float3 F = FresnelSchlick(saturate(dot(H, V)), F0); // 计算 Fresnel,掠射角反射更强。
float D = DistributionGGX(N, H, roughness); // 计算微表面法线分布项。
float G = GeometrySmith(NdotV, NdotL, roughness); // 计算微表面遮蔽项。
float3 specular = D * G * F; // 组合得到镜面反射强度的主要部分。
float3 diffuse = albedo * (1.0 - metallic); // 金属几乎没有漫反射,非金属保留漫反射。
float3 color = (diffuse + specular) * lightColor * NdotL; // 合成直接光照结果。Unity 工程实践
Unity 的 Standard Shader、URP Lit、HDRP Lit 都是 PBR 思路。美术制作材质时,通常会按 Metallic/Roughness 或 Metallic/Smoothness 工作流输出贴图。
面试里可以这样说:PBR 的价值是把材质表达标准化。以前可能靠美术手调很多奇怪参数,在不同场景光照下容易崩;PBR 用更统一的物理参数,让同一套材质在晴天、室内、HDR 环境、反射探针下都更稳定。
常见坑
WARNING
PBR 很依赖正确的颜色空间。Albedo 通常按 sRGB 处理,而 Roughness、Metallic、Normal、AO 这类数据贴图通常不应该当颜色做 Gamma 校正。
金属度贴图不要乱画灰值。大多数真实材质要么是金属,要么是非金属,半金属通常更多是脏污、混合材质或过渡区域。
移动端要注意贴图数量、反射探针、环境反射、实时光数量和 Shader Variant。PBR 不是免费真实,效果背后是采样、带宽和计算成本。
后处理 Bloom 或 SSAO
标准答案
Bloom 和 SSAO 都是后处理,但作用不同:Bloom 是把高亮区域提取出来,模糊后叠回原图,让亮的地方产生泛光;SSAO 是根据屏幕空间的深度和法线估算环境遮蔽,让缝隙、角落、接触处更暗。
Bloom 原理
Bloom 的流程一般是:
原始画面 -> 亮部提取 -> 降采样 -> 模糊 -> 上采样 -> 叠回原图它不是让整张图都变亮,而是只提取超过阈值的亮部,比如太阳、技能特效、霓虹灯、爆炸,然后把这部分做模糊扩散,再加回原图。
SSAO 原理
SSAO 是 Screen Space Ambient Occlusion,屏幕空间环境遮蔽。它会利用深度图和法线图,在当前像素周围采样一圈,判断附近是否有几何体挡住环境光。
如果一个点周围有很多更近的深度,说明它可能处在缝隙或接触区域,就把环境光压暗。比如墙角、角色脚底、石头缝隙会更有层次。
简单 Shader 思路
c
float3 color = SAMPLE_TEXTURE2D(_SceneColor, sampler_SceneColor, uv).rgb; // 采样原始场景颜色。
float brightness = max(color.r, max(color.g, color.b)); // 取 RGB 最大值作为亮度近似。
float mask = step(_BloomThreshold, brightness); // 判断当前像素是否超过 Bloom 阈值。
float3 brightColor = color * mask; // 只保留超过阈值的高亮颜色。
float3 blurred = BlurBrightTexture(uv); // 对亮部纹理做模糊采样。
float3 finalColor = color + blurred * _BloomIntensity; // 把模糊后的亮部叠回原图。
return float4(finalColor, 1.0); // 输出最终颜色。
float depth = SampleSceneDepth(uv); // 采样当前像素的深度。
float3 normal = SampleSceneNormal(uv); // 采样当前像素的法线。
float occlusion = 0.0; // 初始化遮蔽值。
for (int i = 0; i < SAMPLE_COUNT; i++) // 遍历 SSAO 的采样点。
{ // 循环开始。
float2 sampleUv = uv + sampleOffsets[i] * _Radius; // 计算周围采样点的屏幕坐标。
float sampleDepth = SampleSceneDepth(sampleUv); // 采样周围点的深度。
occlusion += sampleDepth < depth ? 1.0 : 0.0; // 如果周围点更靠近相机,就增加遮蔽。
} // 循环结束。
occlusion = 1.0 - occlusion / SAMPLE_COUNT; // 把遮蔽次数转换成最终环境光系数。
return float4(occlusion, occlusion, occlusion, 1.0); // 输出 SSAO 遮蔽图。Unity 工程实践
Bloom 常用于提升表现力,尤其是技能、UI 特效、霓虹、魔法、科幻场景。它通常可以半分辨率甚至更低分辨率做,因为泛光本来就是模糊的。
SSAO 常用于增强空间层次,让物体接触处更真实。但它比较吃采样和带宽,而且屏幕空间算法只知道屏幕上已有的信息,看不到屏幕外或被遮挡的几何,所以会有近似误差。
移动端我一般会这样取舍:Bloom 可以保留但降低分辨率和迭代次数;SSAO 要谨慎,低端机可能关闭,中高端机降低采样数、半分辨率计算,并用模糊降噪。
面试关键点
IMPORTANT
Bloom 是亮部扩散,SSAO 是接触阴影近似。
二者都是全屏 Pass,性能压力主要来自 RenderTexture、采样次数、模糊迭代和带宽。面试里不要只说“加个后处理效果”,要能说出它的输入、处理流程、输出和移动端优化策略。
移动端渲染优化
标准答案
移动端渲染优化的核心不是“把效果全关掉”,而是先定位瓶颈,再做取舍。通常要先判断是 CPU 提交慢、GPU 像素压力大、带宽高、内存高,还是发热降频导致帧率下降。
底层原理
移动端 GPU 很容易被这几类问题拖慢:
DrawCall / SetPass 多,会增加 CPU 向 GPU 提交渲染命令的成本。
Overdraw 高,会让同一个屏幕像素被反复绘制,透明物体、粒子、全屏 UI 半透明尤其明显。
Shader 太复杂,会增加片元计算成本,比如多层采样、复杂光照、动态分支、高精度计算。
纹理和 RenderTexture 太大,会增加显存和带宽压力。移动端 Tile-Based GPU 对带宽尤其敏感。
阴影和后处理 常常很贵,因为阴影要额外渲染 Shadow Map,后处理要做全屏 Pass 和多次采样。
Unity 工程实践
我会先用工具定位:
Profiler看 CPU/GPU 时间、Camera.Render、RenderLoop、Canvas、粒子、后处理。Frame Debugger看 DrawCall、SetPass、渲染顺序、是否合批。RenderDoc / Xcode GPU Frame Capture / Android GPU Inspector看具体 GPU Pass、纹理带宽和瓶颈。- 真机长时间跑,观察温度、功耗和是否降频。
如果是 CPU 提交瓶颈,我会减少 DrawCall 和 SetPass:合并材质、图集、Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher,减少材质实例化和 Shader Variant 切换。
如果是 GPU 像素瓶颈,我会看 Overdraw、透明物体、粒子、UI 半透明层、全屏特效。减少大面积透明,控制粒子屏占比,UI 拆 Canvas,避免多层半透明叠加。
如果是 Shader 瓶颈,我会给低端机准备简化 Shader,减少纹理采样、复杂光照、动态分支和高精度计算,控制 Shader Variant 数量。
如果是带宽瓶颈,我会控制纹理尺寸、RenderTexture 数量、MSAA、后处理分辨率,贴图使用 ASTC、ETC2、PVRTC 等合适压缩格式,并开启 Mipmap。
高频优化点
阴影:控制阴影距离、分辨率、级联数量、实时光数量。移动端尽量使用烘焙光、Light Probe,低端机关闭软阴影或实时阴影。
后处理:Bloom 可以半分辨率、减少迭代;SSAO、DOF、Motion Blur 在移动端要谨慎,低端机通常关闭或降级。
LOD 和剔除:远处模型降低面数,超出视野或被遮挡的物体不渲染。大场景要配合 LOD Group、Occlusion Culling、分块加载。
UI:拆分动态和静态 Canvas,减少 Canvas Rebuild,关闭不必要的 Raycast Target,减少全屏半透明遮罩。
面试关键句
TIP
我会先用工具定位,而不是凭感觉优化。移动端渲染优化本质是在画质、性能、带宽、功耗、温度之间做取舍。优化后一定要拿数据验证,比如 FPS、CPU ms、GPU ms、DrawCall、SetPass、内存、温度和长时间运行后的稳定帧率。
手写一个简单 Shader
标准答案
最简单的 Shader 可以写一个 Unlit Shader:顶点着色器负责把模型顶点从局部空间变到裁剪空间,片元着色器直接输出一个颜色,不参与光照计算。
代码实现
c
Shader "Interview/SimpleUnlitColor" // 定义 Shader 名字,材质面板里会按这个路径显示。
{ // Shader 外层开始。
Properties // 定义暴露给材质面板的属性。
{ // Properties 开始。
_Color ("Color", Color) = (1, 1, 1, 1) // 定义一个颜色属性,默认是白色。
} // Properties 结束。
SubShader // 定义真正的渲染实现。
{ // SubShader 开始。
Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明物体,进入普通几何队列。
Pass // 定义一次渲染 Pass。
{ // Pass 开始。
CGPROGRAM // 开始编写 CG/HLSL 代码。
#pragma vertex vert // 指定顶点着色器函数名为 vert。
#pragma fragment frag // 指定片元着色器函数名为 frag。
#include "UnityCG.cginc" // 引入 Unity 常用 Shader 工具函数。
fixed4 _Color; // 声明从 Properties 传进来的颜色变量。
struct appdata // 定义顶点输入结构。
{ // appdata 开始。
float4 vertex : POSITION; // 接收模型局部空间下的顶点坐标。
}; // appdata 结束。
struct v2f // 定义顶点到片元的传递结构。
{ // v2f 开始。
float4 pos : SV_POSITION; // 保存裁剪空间坐标,GPU 用它进行光栅化。
}; // v2f 结束。
v2f vert(appdata v) // 定义顶点着色器函数。
{ // 顶点着色器开始。
v2f o; // 创建输出结构体。
o.pos = UnityObjectToClipPos(v.vertex); // 把局部空间顶点转换到裁剪空间。
return o; // 返回顶点阶段输出。
} // 顶点着色器结束。
fixed4 frag(v2f i) : SV_Target // 定义片元着色器函数,输出屏幕颜色。
{ // 片元着色器开始。
return _Color; // 直接返回材质颜色,不做光照计算。
} // 片元着色器结束。
ENDCG // 结束 CG/HLSL 代码。
} // Pass 结束。
} // SubShader 结束。
} // Shader 外层结束。面试解释
NOTE
这个 Shader 的流程很简单:Mesh 顶点进入 vert,通过 UnityObjectToClipPos 做 MVP 变换,得到裁剪空间坐标;三角形经过光栅化变成片元;每个片元进入 frag,直接返回 _Color。
它的优点是简单、稳定、成本低,适合调试模型、纯色材质、UI 特效底色、描边前置 Pass。缺点是没有光照、没有阴影、没有纹理细节,看起来不真实。
如果继续扩展,可以加纹理采样、顶点色、透明混合、Alpha Test、法线贴图、漫反射和高光。面试里先把这个基础版本写稳,再往上加功能,会比一上来写复杂 PBR 更稳。