type
Post
status
Published
date
Jun 8, 2026
slug
srp03
summary
Catlike Coding Custom SRP 第三章 Directional Lights 学习笔记,按 Lighting、Lights、BRDF、Transparency、Shader GUI 整理方向光数据传递、多方向光、Surface/Light/BRDF 结构、金属度光滑度、高光、预乘透明和材质预设。
tags
Unity
SRP
category
学习笔记
icon
password
Directional Lights
这篇是 Catlike Coding Custom SRP 系列第三章
Directional Lights 的学习笔记。第一章我们把自定义 SRP 的基础流程跑起来了,第二章开始写自己的 Unlit Shader,并处理 Draw Call、Batching、贴图和透明。到了这一章,重点就从“物体怎么被画出来”继续推进到“物体怎么被光照亮”。这一章主要做了五件事:
- 写一个
Custom RP/LitShader,让材质可以根据法线和光源方向计算明暗。
- 新建
LightingC# 类,把场景里的方向光数据传给 GPU。
- 支持最多四个可见方向光,并在 Shader 里循环累加光照。
- 引入 BRDF,让材质有金属度、光滑度和高光。
- 处理 Lit 透明材质,并用自定义 Shader GUI 做材质模式预设。
前两章的管线更多是在解决“渲染流程”和“绘制提交”的问题,这一章开始才真正进入材质和光照。也就是说,
DrawRenderers 只是把 Renderer 送进 GPU,真正决定它看起来像什么的,是 Shader 里对 Surface、Light 和 BRDF 的计算。1. Lighting:从 Unlit 走向 Lit
如果想让场景看起来更接近真实,就不能只输出材质颜色,而是要模拟光和表面的交互。Unlit Shader 不关心光照,所以无论物体朝向哪里,颜色都一样。Lit Shader 则至少需要知道两个东西:表面的朝向,以及光从哪里来。
Lit Shader:新建支持光照的 Shader
教程先从第二章的
UnlitPass.hlsl 和 Unlit.shader 复制出一套 Lit 版本:ShaderLab 里也改成:
这里有两个点比较关键。
第一,Lit Shader 的默认颜色不是白色,而是灰色。因为一个完全白的物体在比较强的光照下很容易显得过曝,URP 默认材质也用了类似思路。
第二,Pass 里加了:
第一章里我们只支持
SRPDefaultUnlit。现在要画 Lit 材质,就必须让 CameraRenderer 认识这个新的 Shader Pass:然后通过
drawingSettings.SetShaderPassName(1, litShaderTagId) 把 Lit Pass 也加入可绘制列表。这里可以把
LightMode 理解成管线和 Shader 之间的约定:Shader 说“我有一个叫 CustomLit 的 Pass”,管线说“我愿意画 CustomLit 这个 Pass”。如果两边名字对不上,这个材质就不会被正常绘制。Normal Vectors:法线决定表面朝向
光照最基础的问题是:光照到表面时,表面是正对光、斜着对光,还是背对光?这个信息来自法线。
法线也是 Mesh 顶点数据的一部分,和顶点位置一样,最开始在 Object Space 里。所以
Attributes 需要多接收一个 normalOS:因为光照最终在 fragment 阶段算,所以还要把法线从 vertex 阶段传过去:
法线不能直接用 Object Space 的值,需要变换到和光照计算一致的空间。教程这里选择 World Space:
这一步容易忽略的地方是:法线不是位置。位置变换要考虑平移,方向向量不需要平移;如果对象有非等比缩放,法线还不能简单乘 object-to-world 矩阵,否则表面方向会错。Core RP Library 的
TransformObjectToWorldNormal 已经处理了这些细节。如果确定所有实例都是等比缩放,可以用:
这能少传一些矩阵数据,尤其对 GPU Instancing 有帮助。但它的前提很明确:只能在确实没有非等比缩放时使用。
Interpolated Normals:片元阶段还要重新归一化
顶点法线在 vertex 阶段可能是单位长度,但经过三角形插值之后,fragment 阶段拿到的法线长度不一定还是 1。
所以在真正用于光照前,需要重新归一化:
这一点看起来很小,但光照公式里经常会用
dot(normal, lightDirection)。如果 normal 不是单位向量,点乘结果就不再只是角度关系,还会混入长度误差,明暗就会不稳定。Surface Properties:把表面数据整理成结构体
接下来教程把表面相关数据放进一个
Surface 结构:然后在
LitPassFragment 里填充:这里我觉得是这一章结构上比较重要的一步。因为后面光照会越来越复杂,如果每个函数都传一堆散乱参数,代码会很快变乱。把“表面本身是什么样”统一放进
Surface,后面再增加 metallic、smoothness、viewDirection 都比较自然。教程也提到,不一定要把字段叫
normalWS。Surface 本身不关心数据在哪个空间,只要参与同一组计算的 normal、light direction、view direction 都在同一个空间里就行。当前我们使用 World Space,但这个设计让后面换空间也更方便。Calculating Lighting:先用法线 Y 分量模拟从上往下的光
真正写光照前,教程先用一个很简单的函数测试效果:
这表示表面法线越朝上,越亮;越朝侧面,越暗;朝下时结果为负,但颜色显示看不到负值。
这个阶段虽然还没有真正的灯光数据,但已经能看到漫反射的核心直觉:
2. Lights:把方向光数据传给 GPU
上一节只是用
normal.y 假装有一个从上往下照的光。真正的方向光需要有颜色、强度和方向,而且这些数据来自 Unity 场景。方向光可以理解为距离非常远的光源。它的位置不重要,重要的是方向。太阳光就是典型例子:对地面上的物体来说,太阳离得足够远,同一小片场景里的光线方向基本可以看成一致。
Light Structure:Shader 端的光源结构
教程新建
Light.hlsl,用结构体保存方向光数据:最开始的
GetDirectionalLight 先返回一个固定白光和向上的方向:这里的
direction 表示“光从哪里来”,不是“光要射向哪里”。所以 Unity 里方向光的 transform.forward 后面会取负值。Lighting Functions:点乘得到入射光比例
有了 Surface 和 Light,就可以写基础的入射光计算:
dot(normal, light.direction) 是这章最核心的基础公式。两个向量都是单位向量时,点乘结果就是夹角的 cos 值:所以要用
saturate 把负数压到 0。它会把值限制在 0 到 1 之间。最终单个光源的基础漫反射可以写成:
这里的
surface.color 可以理解为 albedo,也就是表面对漫反射光的基础颜色。白色表示几乎不吸收,其他颜色表示吸收掉一部分光能。Sending Light Data to the GPU:用 Lighting 类发送光照数据
固定白光肯定不够。下一步是在 C# 端创建
Lighting 类,把 Unity 场景里的方向光传给 Shader。Shader 端先定义全局 light buffer:
然后
GetDirectionalLight 从这些全局变量里取值。C# 端的
Lighting 类和 CameraRenderer 有点像:它也有自己的 CommandBuffer,负责在渲染物体前设置全局光照数据。一开始可以通过
RenderSettings.sun 获取场景主方向光:这里有几个细节:
SetGlobalVector实际上传的是四维向量,即使 Shader 里只声明float3也没问题。
light.color.linear * light.intensity才是最终用于光照的颜色强度。
- 方向要用
-light.transform.forward,因为 Shader 里需要的是光来的方向。
然后在
CameraRenderer.Render 中,清屏和设置相机属性之后、绘制可见物体之前调用:这说明光照数据是全局状态,必须在物体绘制前准备好,否则 Lit Shader 拿不到正确的光源信息。
Visible Lights:使用剔除结果里的可见光源
只用
RenderSettings.sun 有一个限制:它只能代表一个主光。Unity 在 Culling 时已经会计算当前相机可见范围内有哪些光,所以教程改用:这要求
Lighting.Setup 接收 CullingResults:NativeArray 可以简单理解成 Unity 原生内存和 C# 之间共享的一种数组结构。这里不用普通托管数组,是因为可见光数据来自 Unity 底层引擎,NativeArray 更适合这种跨托管和原生的场景。Multiple Directional Lights:最多支持四个方向光
要支持多个方向光,Shader 端不能只接收一组 color 和 direction,而要接收数组和数量:
C# 端也对应准备数组:
然后遍历
visibleLights,只处理 LightType.Directional:这里用
ref VisibleLight 是因为 VisibleLight 结构体比较大,直接传值会复制一份。通过引用传递可以少一次拷贝。设置单个方向光时,颜色用
visibleLight.finalColor,方向从 localToWorldMatrix 第三列取,然后取负:还需要在管线构造函数里开启线性光照强度:
否则
VisibleLight.finalColor 默认不会按线性空间处理,和前面项目设置里的 Linear Color Space 就接不上。Shader Loop:Shader 端循环累加光源
Shader 端根据
_DirectionalLightCount 取出每个方向光:然后在
GetLighting 里循环累加:到这里,我们的 Shader 就能支持最多四个方向光。
教程最后还加了:
这是因为早期 OpenGL ES 2.0 / WebGL 1.0 对可变长度循环支持不好。既然这个自定义 RP 本身已经依赖线性光照和现代渲染特性,就干脆提高 shader target,避免为太老的平台绕一堆复杂逻辑。
3. BRDF:让材质有漫反射和高光
前面的光照模型还很简单,基本只适合完全漫反射的表面。现实里表面既可能把光散射出去,也可能产生镜面反射。为了得到更丰富的材质表现,教程引入 BRDF。
BRDF 全称是 Bidirectional Reflectance Distribution Function,可以理解成:给定光从某个方向来、相机从某个方向看,表面会把多少光反射到相机方向。
教程这里使用的是和 URP 类似的一套直接光 BRDF,牺牲一部分真实度换性能。
Incoming Light 和 Outgoing Light:我们看到的是反射出去的光
入射光的部分前面已经处理了:
N * L 决定当前 fragment 接收到多少光。但人眼和相机看到的不是“照到表面的光”,而是“从表面反射到观察方向的光”。
如果表面非常光滑,光会像镜子一样按反射方向弹出去,只有观察方向刚好接近反射方向时才能看到强高光,这就是 specular reflection。
如果表面粗糙,光会被许多细小表面打散,高光会变宽、变弱。极端情况下就是完全漫反射:无论从哪个方向看,看到的漫反射亮度都差不多。
这一节真正要解决的就是:同样一束入射光,不同材质应该如何分成 diffuse 和 specular。
Surface Properties:Metallic 和 Smoothness
教程采用 metallic workflow,所以 Lit Shader 增加两个材质属性:
Metallic 表示金属度。0 是非金属,也叫 dielectric;1 是完全金属。真实材质里也可能混合,所以用 0 到 1 的滑条。Smoothness 表示光滑度。0 是粗糙,1 是光滑。它会影响高光集中程度。这两个属性也要加入
UnityPerMaterial,并扩展 Surface:然后在片元函数里填入:
如果第二章的
PerObjectMaterialProperties 还在用,也可以把 metallic 和 smoothness 加进去,让每个对象有不同的材质参数。只是要记得,MaterialPropertyBlock 对 SRP Batcher 的影响仍然存在。BRDF Properties:拆分 diffuse、specular 和 roughness
教程新建
BRDF.hlsl:diffuse 表示漫反射部分,specular 表示镜面反射部分,roughness 表示粗糙度。最开始可以先写成完全漫反射:
然后
GetLighting 不再直接乘 surface.color,而是乘 BRDF 算出来的结果。这样 Lighting 只关心“光怎么进来”,BRDF 负责“材质怎么反射”。Reflectivity:金属和非金属的反射差异
金属和非金属最大的差别之一,是能量分配方式不同。
金属基本没有 diffuse,它的颜色主要体现在 specular 上。非金属则大部分是 diffuse,但仍然会有少量白色 specular。教程用 0.04 作为非金属的最小反射率:
然后根据 metallic 计算 diffuse 和 specular:
这里的直觉是:
这也体现了能量守恒。光反射到 specular 方向的能量多了,能留给 diffuse 的能量就少了。
Roughness:Smoothness 的反面
Smoothness 是给人调材质时更直观的参数,但 BRDF 计算里常用的是 roughness。教程用 Core RP Library 的函数做转换:
这需要 include:
这里的 perceptual roughness 是为了让材质面板里的滑条更符合人的感知。直接线性调整粗糙度,视觉变化不一定均匀;用感知空间转换后,编辑材质时会更顺手。
View Direction:高光需要知道相机方向
漫反射只需要 normal 和 light direction,但 specular 还需要知道相机从哪里看。
Unity 会提供:
为了计算 view direction,
Varyings 需要传 world-space position:顶点阶段填入:
然后 Surface 里增加:
片元阶段计算:
这里的方向是从当前表面点指向相机。
Specular Strength:计算高光强度
教程使用的是 URP 里类似 Minimalist CookTorrance BRDF 的公式。完整理论比较复杂,这里主要记住它依赖几组向量关系:
N:表面法线。
L:光照方向。
V:观察方向。
H:光照方向和观察方向之间的 halfway vector。
代码里大概是:
SafeNormalize 是为了避免光照方向和观察方向刚好相反时出现除零问题。最后直接光 BRDF 是:
于是单个光源的最终计算变成:
这样我们就不只是得到漫反射明暗,还能看到高光。
这里有一个现象也值得记住:越光滑的表面,高光越集中,也可能越亮。因为能量被集中到更小的方向范围里,看起来就会非常强。完全光滑的表面如果没有环境反射,反而可能看不到明显高光,因为高光范围太小。
教程也提醒了:当前结果对金属来说会偏暗,因为还没有环境反射。金属很多视觉信息来自环境反射,只做直接光照是不完整的。后续章节会继续补这部分。
Mesh Ball:实例材质也支持 Metallic 和 Smoothness
第二章的
MeshBall 只随机颜色和 alpha,这一章可以再加 metallic 和 smoothness 数组:然后用
MaterialPropertyBlock 传数组:教程里让 25% 的实例成为金属,并随机 smoothness:
这样一个 Instanced Mesh Ball 里就能看到不同材质响应:有些球更像普通塑料,有些球更像金属,有些高光宽,有些高光窄。
4. Transparency:Lit 材质的透明问题
第二章已经做过透明,但那时是 Unlit。到了 Lit Shader,透明会多出一个问题:透明不只是颜色 alpha 变小,光照结果也会跟着变化。
普通 alpha blending 会把最终输出颜色整体按 alpha 淡出。对于漫反射来说这合理,因为部分光穿过表面,不再被漫反射回来。但对于玻璃类材质,specular 高光不应该也一起消失。清玻璃虽然透明,但边缘高光仍然很明显。
Premultiplied Alpha:只让 diffuse 变透明
普通透明通常是:
也就是输出颜色整体乘 alpha。这样 diffuse 和 specular 都会变淡。
预乘 alpha 的做法是:
这时 GPU 不再自动把 source color 乘 alpha,所以我们要在 BRDF 内部只把 diffuse 乘 alpha:
这样得到的效果是:
- diffuse 会按 alpha 淡出。
- specular 保持完整。
- 材质更接近玻璃、清漆这类透明但仍有高光的表面。
Premultiplication Toggle:用 Keyword 区分两种透明
教程没有把所有透明都强制做成 premultiplied alpha,而是给
GetBRDF 加一个参数:然后通过
_PREMULTIPLY_ALPHA keyword 选择:Shader 里对应加:
这就得到两种透明模式:
这部分和第二章的透明状态也接上了。透明材质仍然需要合适的 Blend、ZWrite 和 Render Queue,只是 Lit 材质还要考虑 BRDF 里 diffuse 和 specular 如何受 alpha 影响。
5. Shader GUI:给材质模式做预设按钮
现在 Lit Shader 的可调参数已经很多:
_Clipping
_PremulAlpha
_SrcBlend
_DstBlend
_ZWrite
- Render Queue
如果每次都手动改这些值,很容易漏一个。例如开了透明但忘记关 ZWrite,或者开了 Alpha Clip 但 Render Queue 还在 Geometry。教程最后用自定义 Shader GUI 做预设按钮。
Custom Shader GUI:替换材质 Inspector
在 Lit Shader 最后加:
然后创建一个继承
ShaderGUI 的编辑器脚本:base.OnGUI 会保留默认材质 Inspector,后面我们只是在它下面加预设 UI。Setting Properties and Keywords:同时设置属性和 Keyword
为了改材质,需要保存三个东西:
materials = materialEditor.targets 是为了支持多选材质编辑。多个材质一起选中时,预设应该同时应用到所有目标。设置 float 属性可以用:
设置 keyword 则要遍历所有 material:
然后把常用设置封装成属性:
Render Queue 不能通过
MaterialProperty 设置,而是直接改每个材质的 renderQueue。Preset Buttons:Opaque、Clip、Fade、Transparent
教程定义四个预设:
四个预设对应的核心状态可以整理成这样:
Preset | Clipping | Premul Alpha | SrcBlend | DstBlend | ZWrite | Queue |
Opaque | Off | Off | One | Zero | On | Geometry |
Clip | On | Off | One | Zero | On | AlphaTest |
Fade | Off | Off | SrcAlpha | OneMinusSrcAlpha | Off | Transparent |
Transparent | Off | On | One | OneMinusSrcAlpha | Off | Transparent |
按钮通过
GUILayout.Button 创建,点击前用 RegisterPropertyChangeUndo 记录撤销:最后用
EditorGUILayout.Foldout 把这些按钮放进一个默认折叠的 Presets 区域里。这样 Inspector 不会一直被按钮占据,但需要切换模式时又很方便。Presets for Unlit:让 Unlit 也复用 GUI
教程最后把
CustomShaderGUI 也加到 Unlit Shader。因为 Unlit 没有 _PremulAlpha,直接点 Transparent 预设会找不到属性,所以要让 SetProperty 能处理不存在的属性:然后用
HasProperty 判断是否显示 Transparent 预设:这样 Lit 材质能看到四个预设,Unlit 材质则隐藏不适合它的 Transparent 预设。
总结
这一章的主线是:从 Unlit 材质升级到能响应方向光的 Lit 材质。
Lighting 部分先让 Shader 拿到法线,把法线从 Object Space 变到 World Space,并在 fragment 阶段重新 normalize。接着用
Surface 结构整理表面数据,用 normal.y 做最简单的光照测试,建立“表面朝向影响亮度”的直觉。Lights 部分把真实方向光接进管线。C# 端的
Lighting 类从 CullingResults.visibleLights 里取可见光,只保留方向光,把颜色、强度和方向整理成数组传给 GPU。Shader 端用 _DirectionalLightCount 和方向光数组循环累加,最多支持四个方向光。BRDF 部分让材质不再只有漫反射。
Metallic 决定 diffuse 和 specular 之间的能量分配,Smoothness 通过 roughness 影响高光形状。加入 view direction 后,Shader 可以计算 specular strength,并用 IncomingLight * DirectBRDF 得到更可信的直接光照。Transparency 部分说明 Lit 透明和 Unlit 透明不完全一样。普通 alpha 会让 diffuse 和 specular 一起淡出;premultiplied alpha 则只让 diffuse 受 alpha 影响,保留 specular,高光更像玻璃类材质。
Shader GUI 部分是工作流优化。因为渲染模式会同时影响 Blend、ZWrite、Render Queue 和 Keyword,所以用
CustomShaderGUI 做 Opaque / Clip / Fade / Transparent 预设,比手动调材质参数可靠很多。学完这一章后,自定义 RP 已经不只是“能画物体”,而是开始具备一个基础实时渲染管线该有的材质和光照框架。后续阴影、环境光、烘焙光照和更多光源类型,都会继续挂在这套
Surface -> Light -> BRDF -> Lighting 的结构上。
