Loading
S7ARRY_SPACE
0%
INITIALIZING
Album Cover
焚蝶
铁痕电台
0:00
Xiaohongshu
从零开始的SRP学习(三)
DOC_ID // 379fd5ONLINE

从零开始的SRP学习(三)

2026-6-8
UPDATED: 2026-6-10
学习笔记
6443 CHARS
#UNITY#SRP
S7ARRY
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/Lit Shader,让材质可以根据法线和光源方向计算明暗。
  • 新建 Lighting C# 类,把场景里的方向光数据传给 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.hlslUnlit.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,后面再增加 metallicsmoothnessviewDirection 都比较自然。
教程也提到,不一定要把字段叫 normalWSSurface 本身不关心数据在哪个空间,只要参与同一组计算的 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,所以用 CustomShaderGUIOpaque / Clip / Fade / Transparent 预设,比手动调材质参数可靠很多。
学完这一章后,自定义 RP 已经不只是“能画物体”,而是开始具备一个基础实时渲染管线该有的材质和光照框架。后续阴影、环境光、烘焙光照和更多光源类型,都会继续挂在这套 Surface -> Light -> BRDF -> Lighting 的结构上。

参考

NAVIGATION // Related Articles
Loading...