在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纹理TextureShader关键字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把“太阳从哪个方向照来、太阳有多亮、太阳是什么颜色”传给ShaderShader再把这些数值变成屏幕上的明暗。四、顶点坐标、法线和矩阵物体如何知道自己在世界中的位置光照计算不能只知道“光从哪里来”还必须知道“物体在哪里、表面朝向哪里”。因此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去采样Lightmapfloat2 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中插值得到一个近似环境光。这种环境光通常使用球谐函数SHSpherical 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);随后用反射方向采样Cubemapfloat3 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当前材质影响物体的灯光阴影状态LightmapLight ProbeReflection Probe后处理和渲染管线设置。2. Unity选择合适的Shader变体例如判断是否开启主光阴影是否启用额外光源是否使用Lightmap是否启用雾效是否有法线贴图。然后选择对应的Shader Variant。3. Unity上传矩阵、材质和灯光参数例如上传unity_ObjectToWorld相机位置主光颜色和方向灯光缓冲区Shadow MapLightmapReflection Probe Cubemap球谐环境光参数4. 顶点着色器执行顶点着色器负责顶点从对象空间变换到裁剪空间法线转换到世界空间传递UV计算Lightmap UV生成阴影坐标把世界坐标、法线等插值数据传给片元着色器。5. 片元着色器执行片元着色器负责采样材质贴图获取世界空间法线获取主光和额外光计算阴影采样Lightmap或Light Probe采样反射探针计算漫反射、高光、环境光、菲涅尔效果输出最终颜色。最终一个像素的颜色可能近似为FinalColor DirectDiffuse DirectSpecular IndirectDiffuse IndirectSpecular Emission;其中DirectDiffuse直接漫反射DirectSpecular直接高光IndirectDiffuseLightmap、Light Probe、环境光IndirectSpecularReflection Probe反射Emission自发光。结语Unity把光照传递到Shader的过程本质上是一场精密的数据协作。场景中的Light组件只是开始Unity需要根据摄像机、物体、渲染管线和性能策略对灯光进行筛选、编码和打包。随后光源颜色、方向、位置等小型数据会进入常量缓冲区阴影、Lightmap、反射环境等大型数据会以纹理形式传递Shader Keyword则决定当前Shader到底启用哪些光照功能。最终Shader并不是在“理解场景”而是在读取Unity为它准备好的光照资料然后依据数学公式把这些资料还原为屏幕上的明暗、阴影、反射和色彩。理解这一过程后我们在编写Unity Shader时就会明白每一个看似普通的变量、每一张阴影贴图、每一个GetMainLight()函数背后都连接着Unity渲染管线中一整套从CPU到GPU的数据传递机制。