Skip to content

图形岗套卷

点乘叉乘应用

标准答案

点乘和叉乘都是向量运算,但解决的问题不一样:点乘主要用来判断“两个方向有多接近”,叉乘主要用来判断“垂直方向、左右关系、旋转方向”。

vector-dot-cross-applications

点乘 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 BB x A 方向相反。

MVP 矩阵

标准答案

MVP 矩阵就是 Model * View * Projection 这一套顶点变换链,用来把模型顶点从“模型自己的局部坐标”,一步步变到 GPU 可以裁剪和光栅化的“裁剪空间”。

graphics-mvp-matrix

底层原理

Model 矩阵:把模型从局部空间变到世界空间。比如一个角色 Mesh 原点在自己脚下,通过 Transform 的位置、旋转、缩放,摆到场景里的某个位置。

View 矩阵:把世界空间变到相机空间。可以理解为不是相机真的移动世界,而是把整个世界反向变换到“以相机为原点”的坐标系里。

Projection 矩阵:把相机空间变到裁剪空间。透视投影会产生近大远小,正交投影则不会近大远小。

完整流程是:

c
local -> world -> view -> clip -> NDC -> screen

Unity 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 里通常直接用 UnityObjectToClipPosUNITY_MATRIX_MVP,避免自己把顺序写错。

深度测试和模板测试

标准答案

深度测试管“挡不挡”,模板测试管“让不让画”。

深度测试会拿当前片元的深度值和深度缓冲里的值比较,如果当前片元更靠近相机,就通过;如果更远,通常会被丢弃。模板测试会拿当前像素的模板值和 Shader 里设置的 Ref 做比较,只有符合规则的像素才允许继续绘制。

graphics-depth-stencil-test

底层原理

深度缓冲可以理解成一张和屏幕一样大的“距离表”。每个像素记录当前已经画过的最近深度。新片元来了以后,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 OnZTest 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 统一算光”。

graphics-forward-deferred-rendering

底层原理

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 里的深度比较:如果当前片元更远,说明它被别的物体挡住了,就在阴影里。

graphics-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 多次采样做柔化。

移动端优化时,我会优先控制阴影距离、阴影分辨率、级联数量、实时阴影数量和软阴影开关。阴影质量不是只调高分辨率,很多时候要结合相机距离和关卡需求做分级。

法线贴图

标准答案

法线贴图就是用一张纹理保存表面法线方向,让低面数模型也能在光照上表现出凹凸细节。它不会真的改变模型形状,也不会改变轮廓,只是改变每个像素参与光照计算时使用的法线。

graphics-normal-map

底层原理

普通贴图的 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,也就是基于物理规律的渲染。它的目标不是“绝对真实”,而是让材质参数更符合物理直觉,在不同光照环境下表现更稳定、更一致。

graphics-pbr-principle

底层原理

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 是根据屏幕空间的深度和法线估算环境遮蔽,让缝隙、角落、接触处更暗。

graphics-postprocess-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 像素压力大、带宽高、内存高,还是发热降频导致帧率下降。

unity-mobile-rendering-optimization

底层原理

移动端 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:顶点着色器负责把模型顶点从局部空间变到裁剪空间,片元着色器直接输出一个颜色,不参与光照计算。

unity-simple-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 更稳。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0