Unity3D Shader 法线与基础光照
前言
前面的文章里,我们已经反复提到"法线"这个词。
比如:
- 顶点法线外扩做描边
- 光照方向和法线方向做点乘
- 法线贴图会改变表面受光表现
那法线到底是什么?
为什么一旦涉及光照,几乎就一定要用到法线?
这一篇,我们从最基础的角度出发,讲清楚:
- 法线是什么
- 光照为什么和法线有关
dot点乘在光照里代表什么- 如何写一个最基础的 Lambert 漫反射 Shader
什么是法线(Normal)
法线可以简单理解为:
一个表面"朝向哪里"的方向。
它通常是一个单位向量。
例如一个朝上的平面,它的法线可能是:
c
(0, 1, 0)
也就是说,这个面朝向 Y 轴正方向。
为什么法线和光照有关
因为光照最基本的问题就是:
光线打到这个表面时,它到底是正面迎光,还是斜着迎光,还是背光?
而判断这件事,最直接的方法就是:
- 看表面的法线方向
- 看光照方向
这两个方向越一致,说明表面越正对光源,通常就越亮。
越偏离,通常就越暗。
dot 点乘为什么这么重要
在 Shader 光照中,最常见的一行代码就是:
c
dot(normal, lightDir)
点乘可以理解成:
两个方向有多接近。
它的结果范围大致是:
1:完全同方向0:互相垂直-1:完全相反
在光照里通常会写成:
c
saturate(dot(normal, lightDir))
意思是:
- 小于 0 的部分直接压成 0
- 大于 1 的部分压成 1
这样就得到一个 0~1 的受光强度。
最基础的漫反射思想
最经典的基础光照模型之一,就是 Lambert 漫反射。
它的核心就是:
c
diffuse = max(0, dot(N, L))
其中:
N:法线方向(Normal)L:光照方向(Light Direction)
当表面正对光源时:
dot接近 1- 物体更亮
当表面转到侧面时:
dot接近 0- 物体更暗
这就是最基础的"明暗变化"。
在 Shader 中如何拿到法线
Mesh 顶点输入里,通常这样声明:
c
float3 normal : NORMAL;
例如:
c
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
这表示从模型数据中读取法线。
但要注意:
这里的法线通常是 对象空间法线。
而做光照时,我们通常更常用 世界空间法线。
因此往往要转换一下:
c
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
为什么光照计算常用世界空间
因为:
- 灯光方向通常在世界空间下更容易理解
- 摄像机位置是世界空间
- 物体在场景中的朝向变化,也更方便统一计算
只要参与同一次运算的数据都在同一个空间,结果才是可靠的。
一个最基础的 Lambert 光照 Shader
下面写一个最基础的版本:
- 支持主方向光
- 包含环境光 + 漫反射
- 使用法线和光照方向做点乘
c
Shader "Custom/SimpleLambert"
{
Properties
{
_Color ("Main Color", Color) = (1,1,1,1)
}
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
};
fixed4 _Color;
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed3 N = normalize(i.worldNormal);
fixed3 L = normalize(_WorldSpaceLightPos0.xyz);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Color.rgb;
fixed ndotl = saturate(dot(N, L));
fixed3 diffuse = _LightColor0.rgb * _Color.rgb * ndotl;
return fixed4(ambient + diffuse, _Color.a);
}
ENDCG
}
}
}
代码拆解
1 从模型里读取法线
c
float3 normal : NORMAL;
2 把法线转到世界空间
c
o.worldNormal = UnityObjectToWorldNormal(v.normal);
3 获取主光方向
c
fixed3 L = normalize(_WorldSpaceLightPos0.xyz);
这里我们主要讨论 方向光(Directional Light) 的基础情况。
4 计算漫反射强度
c
fixed ndotl = saturate(dot(N, L));
这一步就是核心:
- 法线越朝向光源 → 值越大
- 法线越背离光源 → 值越小
5 乘上灯光颜色和材质颜色
c
fixed3 diffuse = _LightColor0.rgb * _Color.rgb * ndotl;
6 加上一点环境光
c
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Color.rgb;
如果没有环境光,背光面会黑得太死。
效果会是什么样
这个 Shader 的效果特点是:
- 朝向光源的一面更亮
- 背向光源的一面更暗
- 比纯色 Unlit 更有体积感
这就是最基础的"受光"感觉。
Half-Lambert:让阴影面别那么黑
标准 Lambert 在某些卡通或风格化场景里,会显得阴影过硬。
一个常见改法是:
c
float halfLambert = dot(N, L) * 0.5 + 0.5;
再做一次限制:
c
halfLambert = saturate(halfLambert);
这样背光面不会一下掉到纯黑,会显得更"柔和"。
很多早期卡通渲染教程都会用这种方法。
法线方向不对会发生什么
如果法线错误,最直接的表现就是:
- 明暗反了
- 某些面不该亮却很亮
- 旋转模型后受光异常
所以当你发现光照不对时,除了看代码,也要怀疑:
- 模型法线是否正确
- 空间是否统一
- 有没有忘记
normalize
为什么几乎总要 normalize
因为法线和光照方向参与点乘时,我们通常希望比较的是"方向",而不是长度。
所以会习惯这样写:
c
fixed3 N = normalize(i.worldNormal);
fixed3 L = normalize(_WorldSpaceLightPos0.xyz);
不归一化虽然有时"看起来也能跑",但结果可能会失真。
常见坑
1 法线和光照方向不在同一个空间
例如:
- 法线在对象空间
- 光照方向在世界空间
直接点乘就是错的。
2 忘了归一化
点乘结果会不稳定。
3 以为法线就是顶点位置方向
法线是"表面朝向",不是顶点坐标。
4 背光面全黑,以为 Shader 写错了
很多时候只是因为你只写了 Lambert,没加环境光。
总结
这一篇最重要的核心只有一句:
基础光照,本质上就是"表面朝向"和"光照方向"的关系。
在 Shader 中最关键的公式就是:
c
saturate(dot(N, L))
你可以先记住下面这几个点:
NORMAL用来接收模型法线- 光照计算常用世界空间法线
dot(N, L)表示受光强度Lambert是最基础的漫反射模型- 环境光可以避免背光面死黑
后面你继续学:
- Blinn-Phong 高光
- Fresnel
- 法线贴图
- PBR
其实都是在这个基础上不断往上加东西。