Unity光照原理:从CPU到GPU的数据传递链
在Unity中,我们看到的光照效果——太阳照在地面上的明暗变化、角色脸上的阴影、金属表面的高光、墙角柔和的间接光——本质上都来自一条完整的数据传递链路:
场景中的灯光、材质、环境信息
→ Unity在CPU端整理
→ 上传到GPU常量缓冲区、纹理和关键字
→ Shader读取这些数据
→ 顶点着色器和片元着色器完成最终计算
如果把Unity渲染过程比作一场舞台演出,那么场景中的灯光组件就是“灯光师”,CPU是“舞台监督”,GPU是“演员”,而Shader则是“表演剧本”。Unity并不会把“场景里有一盏灯”这样抽象的信息直接交给Shader,而是会把灯的位置、颜色、方向、强度、阴影贴图、光照贴图等内容,转换成GPU能快速读取的数字资料。
下面我们以一次物体渲染为线索,详细看看光照数据是怎样进入Shader的。
一、场景中的灯光,首先是C#组件数据
在Unity编辑器中创建一盏Directional Light、Point Light或者Spot Light时,实际上是在场景里创建了一个带有Light组件的GameObject。
例如一盏方向光通常具有以下信息:
- 光的颜色:
Light.color - 光照强度:
Light.intensity - 光的方向:由Transform朝向决定
- 阴影类型:无阴影、硬阴影或软阴影
- 阴影距离、偏移量等参数
- 是否烘焙
- 是否影响特定物体或Layer
点光源则还会额外拥有:
- 世界坐标位置
- 光照范围Range
- 衰减方式
聚光灯还会拥有:
- 聚光角度Spot Angle
- 内外锥角
- 光照方向
- 距离衰减和角度衰减参数
这些信息一开始都保存在CPU侧。它们是Unity引擎对象系统中的数据,还不是Shader能直接读取的GPU数据。
当摄像机准备渲染某个物体时,Unity会开始判断:
- 这个物体是否在摄像机视野中;
- 它使用了什么材质和Shader;
- 场景中哪些灯会影响它;
- 是否需要阴影;
- 是否有Lightmap、Reflection Probe、Light Probe等间接光信息;
- 当前使用Built-in Render Pipeline、URP还是HDRP。
然后,Unity将这些信息组织成适合GPU读取的形式。
二、Shader并不是“看到”场景,而是“读取”Unity准备好的数据
Shader自身并不知道场景里有哪些GameObject,也不能主动遍历所有灯光。Shader只能读取Unity预先传入的数据。
这些数据大体有三种主要形式:
- Uniform变量或常量缓冲区(Constant Buffer)
- 纹理(Texture)
- Shader关键字(Keyword)与宏定义(Macro)
可以把它们理解为三种不同的快递方式:
- 常量缓冲区:传递少量、频繁使用的数值,例如灯光颜色、方向、矩阵;
- 纹理:传递大量图像数据,例如阴影图、Lightmap、反射探针;
- 关键字:告诉Shader“这次该走哪条计算分支”,例如是否启用阴影、是否使用Lightmap。
三、最基本的光照数据:主光源颜色和方向
对于最常见的方向光,Unity通常会把它整理成两个核心量:
float4 _LightColor0; float4 _WorldSpaceLightPos0;这是Built-in Render Pipeline中比较经典的写法。
其中:
_LightColor0通常表示主光源颜色和强度,例如:
float4(1.0, 0.95, 0.8, 1.0)它意味着这是一束略微偏暖的白光。
而:
_WorldSpaceLightPos0对于方向光,通常保存的是光照方向。虽然它名字中带有Pos,但对方向光来说,它并不代表位置,而是方向向量。
Shader中常见写法如下:
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 lightColor = _LightColor0.rgb;然后根据法线和光照方向计算漫反射:
float NdotL = saturate(dot(normalWS, lightDir)); float3 diffuse = albedo * lightColor * NdotL;这里的normalWS是世界空间法线,albedo是材质本身的颜色。
这一段代码所表达的物理意义非常直观:
- 物体表面正对光源时,
NdotL接近1,表面更亮; - 表面侧对光源时,
NdotL接近0,表面逐渐变暗; - 表面背向光源时,点积为负,通过
saturate限制为0,不接受直射光。
也就是说,Unity把“太阳从哪个方向照来、太阳有多亮、太阳是什么颜色”传给Shader,Shader再把这些数值变成屏幕上的明暗。
四、顶点坐标、法线和矩阵:物体如何知道自己在世界中的位置
光照计算不能只知道“光从哪里来”,还必须知道“物体在哪里、表面朝向哪里”。
因此,Unity还会给Shader传递一系列矩阵。例如:
UNITY_MATRIX_M UNITY_MATRIX_V UNITY_MATRIX_P UNITY_MATRIX_MVP它们分别代表:
M:模型矩阵,物体局部空间到世界空间;V:观察矩阵,世界空间到摄像机空间;P:投影矩阵,摄像机空间到裁剪空间;MVP:模型、观察、投影组合矩阵。
顶点着色器中常见代码:
float4 positionCS = mul(UNITY_MATRIX_MVP, float4(positionOS, 1.0));其中:
positionOS是模型顶点在对象空间中的位置;positionCS是最终用于屏幕投影的裁剪空间坐标。
而法线需要从对象空间转换到世界空间:
float3 normalWS = normalize(mul((float3x3)unity_ObjectToWorld, normalOS));有了世界空间位置和法线,Shader才能计算:
- 光照方向;
- 视线方向;
- 点光源距离;
- 阴影坐标;
- 反射方向;
- Fresnel边缘光;
- 镜面高光。
因此,光照并不是单独传一个“光颜色”就够了。Unity需要把物体、摄像机、光源三方的空间关系一并交给Shader。
五、点光源和聚光灯:Unity如何传递位置、范围与衰减
方向光像太阳,理论上无限远,所有位置接收到的光方向都一致。
但点光源更像灯泡:靠近它更亮,远离它更暗。于是Shader必须知道光源位置。
其核心数据可能类似:
float4 _WorldSpaceLightPos0; float4 _LightColor0;对于点光源:
_WorldSpaceLightPos0.xyz就是真实的世界坐标位置。
Shader可以这样计算光照方向:
float3 lightVector = _WorldSpaceLightPos0.xyz - positionWS; float distanceToLight = length(lightVector); float3 lightDir = lightVector / distanceToLight;接下来要计算衰减:
float attenuation = saturate(1.0 - distanceToLight / lightRange); attenuation *= attenuation;最终漫反射可能是:
float NdotL = saturate(dot(normalWS, lightDir)); float3 diffuse = albedo * lightColor * NdotL * attenuation;实际Unity内部的衰减计算通常比这更复杂,可能利用查找纹理、平方距离计算、平滑过渡等方式优化性能和视觉效果。
聚光灯则在点光源的基础上,还会计算“当前像素是否位于光锥范围内”。这需要比较灯光方向与“从灯到像素”的方向夹角:
float spotEffect = dot(-lightDir, spotDirection); float coneMask = smoothstep(outerCone, innerCone, spotEffect);这样就能得到类似手电筒的锥形照明效果。
六、阴影是如何传给Shader的:Shadow Map登场
阴影并不是简单地传递一个“这里是黑色”的参数。Unity通常使用的是阴影贴图Shadow Map。
它的过程可以理解为:
- 先站在光源的角度看场景;
- 从光源视角渲染一次深度图;
- 这张深度图记录“光最先照到哪里”;
- 渲染正常画面时,再判断当前像素是否被遮挡。
例如太阳照射一棵树和地面。树会先被记录到阴影贴图中。当地面Shader计算光照时,会把地面像素转换到光源视角,再和阴影图中的深度比较:
- 如果当前地面点比阴影图记录的位置更远,说明中间有物体挡住了光;
- 如果深度接近,说明它直接暴露在光下。
在Shader中,阴影相关的数据通常包括:
- 阴影贴图;
- 光源视图投影矩阵;
- 阴影偏移参数;
- 阴影采样器;
- 级联阴影参数;
- 阴影强度。
例如在URP中,常见的思路是:
Light mainLight = GetMainLight(shadowCoord);这个函数会帮我们取得主光颜色、方向、距离衰减和阴影衰减等数据。
之后可以使用:
float shadowAttenuation = mainLight.shadowAttenuation;若像素完全在阴影中,它可能接近0;若完全被照亮,则接近1;若是软阴影边缘,则介于两者之间。
最终光照计算变成:
float3 diffuse = albedo * mainLight.color * NdotL * mainLight.shadowAttenuation;于是,阴影并不是“把物体涂黑”,而是“减少该像素收到的直接光”。
七、烘焙光照:Lightmap如何进入Shader
实时光照每帧都要计算,灵活但昂贵。对于墙壁、地面、建筑等静态物体,Unity经常使用烘焙光照。
烘焙时,Unity会提前计算:
- 间接光反弹;
- 环境遮蔽;
- 静态阴影;
- 多次反射;
- 彩色光照反弹;
- 柔和的角落暗部。
这些结果被保存到一张或多张Lightmap纹理中。
可以把Lightmap理解成“提前画好的光照地图”。模型的每个静态表面都会拥有一套额外UV坐标,通常称为UV2。Shader根据这个UV去采样Lightmap:
float2 lightmapUV; float3 bakedGI = SAMPLE_TEXTURE2D(_Lightmap, sampler_Lightmap, lightmapUV).rgb;最终颜色中加入烘焙间接光:
float3 finalColor = diffuseDirect + bakedGI;需要注意的是,Lightmap不只是普通贴图。Unity还需要传递每个Renderer对应的Lightmap索引、缩放、偏移信息。因为一个场景中可能有多张Lightmap,而多个物体可能共用同一张大图的不同区域。
因此,Shader中的Lightmap UV往往还需要经过变换:
lightmapUV = uv2 * unity_LightmapST.xy + unity_LightmapST.zw;其中:
unity_LightmapST就包含了当前物体在Lightmap中的缩放和偏移。
这也是为什么一个模型即使材质相同,只要它们位于不同的Lightmap区域,最终渲染时仍可能需要不同的数据。
八、动态物体没有Lightmap怎么办:Light Probe
角色、车辆、掉落物等动态物体通常不能直接使用静态Lightmap,否则它们移动后,光照会“粘”在原来的位置上。
Unity为此提供了Light Probe。
Light Probe可以理解为在空间中布置了许多“光照采样球”。每个球记录该位置附近的环境光信息。动态物体移动时,Unity会根据它所在位置,从周围多个Probe中插值得到一个近似环境光。
这种环境光通常使用球谐函数SH,Spherical Harmonics保存。
Shader中可能会看到类似:
unity_SHAr unity_SHAg unity_SHAb unity_SHBr unity_SHBg unity_SHBb unity_SHC这些看似复杂的参数,实际是环境光在不同方向上的数学展开系数。
Shader根据表面法线,计算来自天空、地面、周围墙壁等方向的间接光:
float3 ambient = ShadeSH9(float4(normalWS, 1.0));最终:
float3 finalColor = directLight + ambient;这样,即使角色没有烘焙到Lightmap中,也能在蓝天环境下略带蓝色边缘,在暖色室内环境中受到暖光影响。
九、反射探针:金属和光滑表面如何“看到”环境
金属球、汽车漆面、水面、玻璃等材质,不仅需要直接光,还需要反射周围环境。
Unity通常通过Reflection Probe生成Cubemap,也就是六个方向组成的环境贴图。Shader根据视线方向和法线计算反射方向:
float3 viewDir = normalize(_WorldSpaceCameraPos - positionWS); float3 reflectDir = reflect(-viewDir, normalWS);随后用反射方向采样Cubemap:
float3 reflection = SAMPLE_TEXTURECUBE( unity_SpecCube0, samplerunity_SpecCube0, reflectDir ).rgb;粗糙表面会采样更模糊的Mip层级,光滑表面则使用更清晰的反射。
因此,Reflection Probe实际上也是Unity传给Shader的一种光照数据,只不过它不是几个浮点数,而是一张环境纹理。
十、URP中的光照传递:从全局变量变成结构化数据
在URP中,Unity更倾向于使用封装好的HLSL函数和结构体,而不是让开发者直接访问大量底层变量。
常见写法如下:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"然后在片元着色器中:
Light mainLight = GetMainLight();这个Light结构体通常包含:
struct Light { half3 direction; half3 color; float distanceAttenuation; half shadowAttenuation; };使用方式:
float3 lightDir = normalize(mainLight.direction); float NdotL = saturate(dot(normalWS, lightDir)); float3 directDiffuse = albedo * mainLight.color * NdotL * mainLight.distanceAttenuation * mainLight.shadowAttenuation;对于额外灯光,URP通常会先获得灯光数量:
uint lightCount = GetAdditionalLightsCount();然后循环读取:
for (uint i = 0; i < lightCount; i++) { Light light = GetAdditionalLight(i, positionWS); float NdotL = saturate(dot(normalWS, light.direction)); color += albedo * light.color * NdotL * light.distanceAttenuation * light.shadowAttenuation; }这里的关键在于:Unity已经在CPU或渲染管线阶段筛选过“哪些额外灯光与当前物体相关”,并将这些灯的信息上传到GPU缓冲区。Shader只负责读取和计算。
十一、Shader Keyword:决定Shader走哪条光照路径
Unity并不会总是让Shader执行全部光照功能,因为那样会造成浪费。
例如一个物体可能:
- 没有阴影;
- 没有Lightmap;
- 没有法线贴图;
- 不接收额外光源;
- 不使用雾效;
- 没有Reflection Probe。
如果所有功能都强制执行,性能会很差。
于是Unity使用Shader Keyword来生成不同变体。例如:
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile _ LIGHTMAP_ON当场景开启主光阴影时,Unity会选择带有:
_MAIN_LIGHT_SHADOWS定义的Shader变体。
当物体使用Lightmap时,则可能启用:
LIGHTMAP_ON在Shader内部可以通过条件编译处理:
#if defined(LIGHTMAP_ON) // 采样Lightmap #else // 使用Light Probe或环境光 #endif这像是Unity在渲染前告诉Shader:
“这次这个物体有烘焙光照,请走Lightmap路线。”
“这次主光源投射阴影,请读取Shadow Map。”
“这次没有额外灯光,不要做额外灯光循环。”
十二、常量缓冲区:为什么Unity能高效上传这些数据
现代GPU并不喜欢CPU一个变量一个变量地零散传数据。为了效率,Unity会将相关数据打包到常量缓冲区,也叫Constant Buffer,简称CBUFFER。
例如URP Shader中常见:
CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _BaseMap_ST; float _Metallic; float _Smoothness; CBUFFER_END这表示这些材质参数会作为一组数据上传。
Unity中的数据通常可分成几类:
- 每帧数据:时间、屏幕尺寸、相机参数;
- 每个相机数据:视图矩阵、投影矩阵;
- 每个物体数据:模型矩阵、Lightmap偏移;
- 每个材质数据:颜色、贴图缩放、金属度;
- 每个灯光数据:颜色、方向、位置、阴影参数。
这也是SRP Batcher能够提升性能的原因之一。只要多个物体使用相同Shader变体,并且材质参数的布局一致,Unity就能更高效地切换和提交渲染数据。
十三、从Unity场景到Shader的一次完整流程
现在把前面的内容串起来,可以把一次典型的渲染过程概括为以下步骤。
1. Unity收集场景信息
Unity检查:
- 当前摄像机;
- 可见Renderer;
- 当前材质;
- 影响物体的灯光;
- 阴影状态;
- Lightmap;
- Light Probe;
- Reflection Probe;
- 后处理和渲染管线设置。
2. Unity选择合适的Shader变体
例如判断:
- 是否开启主光阴影;
- 是否启用额外光源;
- 是否使用Lightmap;
- 是否启用雾效;
- 是否有法线贴图。
然后选择对应的Shader Variant。
3. Unity上传矩阵、材质和灯光参数
例如上传:
unity_ObjectToWorld- 相机位置
- 主光颜色和方向
- 灯光缓冲区
- Shadow Map
- Lightmap
- Reflection Probe Cubemap
- 球谐环境光参数
4. 顶点着色器执行
顶点着色器负责:
- 顶点从对象空间变换到裁剪空间;
- 法线转换到世界空间;
- 传递UV;
- 计算Lightmap UV;
- 生成阴影坐标;
- 把世界坐标、法线等插值数据传给片元着色器。
5. 片元着色器执行
片元着色器负责:
- 采样材质贴图;
- 获取世界空间法线;
- 获取主光和额外光;
- 计算阴影;
- 采样Lightmap或Light Probe;
- 采样反射探针;
- 计算漫反射、高光、环境光、菲涅尔效果;
- 输出最终颜色。
最终,一个像素的颜色可能近似为:
FinalColor = DirectDiffuse + DirectSpecular + IndirectDiffuse + IndirectSpecular + Emission;其中:
DirectDiffuse:直接漫反射;DirectSpecular:直接高光;IndirectDiffuse:Lightmap、Light Probe、环境光;IndirectSpecular:Reflection Probe反射;Emission:自发光。
结语
Unity把光照传递到Shader的过程,本质上是一场精密的数据协作。
场景中的Light组件只是开始;Unity需要根据摄像机、物体、渲染管线和性能策略,对灯光进行筛选、编码和打包。随后,光源颜色、方向、位置等小型数据会进入常量缓冲区;阴影、Lightmap、反射环境等大型数据会以纹理形式传递;Shader Keyword则决定当前Shader到底启用哪些光照功能。
最终,Shader并不是在“理解场景”,而是在读取Unity为它准备好的光照资料,然后依据数学公式,把这些资料还原为屏幕上的明暗、阴影、反射和色彩。
理解这一过程后,我们在编写Unity Shader时就会明白:每一个看似普通的变量、每一张阴影贴图、每一个GetMainLight()函数背后,都连接着Unity渲染管线中一整套从CPU到GPU的数据传递机制。
