ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity光照原理:从CPU到GPU的数据传递链

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会开始判断:

  1. 这个物体是否在摄像机视野中;
  2. 它使用了什么材质和Shader;
  3. 场景中哪些灯会影响它;
  4. 是否需要阴影;
  5. 是否有Lightmap、Reflection Probe、Light Probe等间接光信息;
  6. 当前使用Built-in Render Pipeline、URP还是HDRP。

然后,Unity将这些信息组织成适合GPU读取的形式。


二、Shader并不是“看到”场景,而是“读取”Unity准备好的数据

Shader自身并不知道场景里有哪些GameObject,也不能主动遍历所有灯光。Shader只能读取Unity预先传入的数据。

这些数据大体有三种主要形式:

  1. Uniform变量或常量缓冲区(Constant Buffer)
  2. 纹理(Texture)
  3. 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

它的过程可以理解为:

  1. 先站在光源的角度看场景;
  2. 从光源视角渲染一次深度图;
  3. 这张深度图记录“光最先照到哪里”;
  4. 渲染正常画面时,再判断当前像素是否被遮挡。

例如太阳照射一棵树和地面。树会先被记录到阴影贴图中。当地面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的数据传递机制。

返回列表