16 Noise 细节法线与片元光照
前置知识:第 15 章的高度场法线、第 3 章的观察空间、第 8 章的线性颜色。
本章示例:平面上的程序化细节法线,完整源码。
学完本章能做什么
你将能在不移动顶点的前提下,用 noise 高度的局部斜率构造片元法线;让漫反射和高光随这些细节变化;并辨认它与真正几何起伏的边界。
先看现象
示例平面始终只有 1 × 1 个分段。切到“平面法线”,光照平滑;切到“细节法线”,表面出现许多微小明暗;“法线颜色”和“高度图”帮助分别观察方向与高度信号。改变光方向,高光会沿细节移动;从斜角看,网格的外轮廓仍是一条直线。
1. 相同的高度函数,不同的执行阶段
第 15 章在每个顶点查询 heightAt(position.xy),把高度写入局部 z。本章在每个片元查询同类函数,但绝不把它用于 gl_Position。在顶点着色器里只传下未位移的局部坐标:
// 顶点阶段;vLocalXY 是模型局部空间的平面坐标。
vLocalXY = position.xy;
vec4 viewPosition = modelViewMatrix * vec4( position, 1.0 );
vPositionView = viewPosition.xyz;
gl_Position = projectionMatrix * viewPosition;
片元间的 vLocalXY 由三角形插值,平面即使只有两片三角形,也能在每个像素重新计算 noise。高度函数仍使用局部 x/y 单位,输出局部 z 高度;uFrequency 是每局部单位的格数,uAmplitude 是假想的局部高度幅度。因为高度只用于光照,这些幅度不会改变几何边界或深度。
2. 从细节高度得到局部法线
片元处的中央值可用于查看信号,法线斜率则由四个邻近高度求得:
// 片元阶段;vLocalXY 与 uEpsilon 均属于模型局部空间。
float center = heightAt( vLocalXY );
float hx = ( heightAt( vLocalXY + vec2( uEpsilon, 0.0 ) ) -
heightAt( vLocalXY - vec2( uEpsilon, 0.0 ) ) ) /
( 2.0 * uEpsilon );
float hy = ( heightAt( vLocalXY + vec2( 0.0, uEpsilon ) ) -
heightAt( vLocalXY - vec2( 0.0, uEpsilon ) ) ) /
( 2.0 * uEpsilon );
vec3 detailNormalLocal = normalize( vec3( -hx, -hy, 1.0 ) );
center 表示实际高度,可用于调试;运行示例的“Noise 高度图”模式直接显示归一化的 signalAt,因此不会随 uAmplitude 改变。法线只需要四个邻居。数学形式与第 15 章一致,但运行次数不同:顶点法线按顶点计算并插值,本章的斜率按像素计算。整个屏幕高分辨率显示时,四次额外高度查询可能比顶点方案昂贵得多。uEpsilon 太大抹掉细节,太小则可能因浮点精度与高频 noise 产生不稳定方向。
这一法线是模型局部空间中一块假想高度场的方向,尚不能直接与观察空间光线做点乘。若只把 hx、hy 直接加到 RGB 颜色,得到的是颜色变化,并非正确的受光细节。
3. 把局部法线换到观察空间
在普通 ShaderMaterial 顶点阶段,可以使用 Three.js 提供的 normalMatrix。本示例的平面局部坐标轴固定为 x、y、z;把 normalMatrix 的三列分别传给片元:
// 顶点阶段;三列表示“局部法线分量 → 观察空间法线”的线性映射。
vNormalBasisX = normalMatrix * vec3( 1.0, 0.0, 0.0 );
vNormalBasisY = normalMatrix * vec3( 0.0, 1.0, 0.0 );
vNormalBasisZ = normalMatrix * vec3( 0.0, 0.0, 1.0 );
片元阶段按局部分量重新组合,再归一化:
// 片元阶段;结果为观察空间法线。
vec3 normalView = normalize(
vNormalBasisX * detailNormalLocal.x +
vNormalBasisY * detailNormalLocal.y +
vNormalBasisZ * detailNormalLocal.z
);
不能把三个基向量分别归一化后再组合;在非等比缩放下,它们的相对长度正是逆转置变换要保留的信息。这个写法适合当前的规则平面和局部 x/y 高度场;任意 UV 展开、曲面或法线贴图需要为实际表面建立切线、双切线与原法线组成的局部基底,并处理 UV 方向和镜像。把 vec3(-hx,-hy,1) 无条件当成任意网格的切线空间法线,是常见错误。
4. 在同一空间里计算漫反射与高光
示例的 uLightView 是观察空间方向,由 JavaScript 从控件设置。vPositionView 也是观察空间位置,视线方向可写成 normalize(-vPositionView):
// 片元阶段;三个方向都处于观察空间。
vec3 lightView = normalize( uLightView );
vec3 viewDirection = normalize( -vPositionView );
float diffuse = max( dot( normalView, lightView ), 0.0 );
vec3 halfVector = normalize( lightView + viewDirection );
float specular = pow( max( dot( normalView, halfVector ), 0.0 ), uShininess );
vec3 color = baseColor * ( 0.16 + 0.70 * diffuse ) +
vec3( 0.55 ) * specular;
这是便于观察法线的简化 Blinn–Phong 高光。uShininess 越大,高光越窄;它不是物理材质的粗糙度参数。若光线从表面背面来,实际项目通常还需根据材质两面性处理高光与阴影;示例从正面照亮平面。这里手动实现光照,场景里的 THREE.PointLight 不会自动作用到这个自定义 ShaderMaterial。输出颜色在 shader 中按线性 RGB 计算,最后通过 #include <colorspace_fragment> 转到显示空间。
5. 为什么细节法线不能代替几何位移
| 能观察到的结果 | 第 15 章:顶点位移 | 本章:片元细节法线 |
|---|---|---|
| 网格轮廓与深度变化 | 会,受分段数限制 | 不会 |
| 明暗纹理 | 会,需更新几何法线 | 会,逐片元计算 |
| 自遮挡与真实阴影形状 | 需要相应几何和阴影设置 | 单靠法线不会产生 |
| 高频细节 | 顶点不够时丢失 | 受像素采样与差分步长限制 |
两种方法可组合:先位移较低频的真实表面,再在它的局部切平面上叠加高频细节法线。但此时切线、双切线必须沿位移后的新表面定义,不能再把平面固定的 (1,0,0)、(0,1,0) 当作新切线。若已有解析 noise 导数,也应先经链式法则换到所用坐标空间。第 17 章还会讨论这些做法在不同设备上的精度与性能。
常见问题
- **高度图清晰,光照仍像平面:**确认选用了细节法线、且
normalView真正进入漫反射和高光计算。 - **光照方向反了:**检查
-hx、-hy的符号,以及光线和法线是否都在观察空间。 - **非等比缩放后细节方向不对:**使用未归一化的
normalMatrix三列组合局部法线,再统一归一化。 - **调小
uEpsilon后出现噪点:**差值接近浮点精度;适当增大步长或减少最高频层。 - **轮廓还是平的:**这是本章片元法线的预期;轮廓需要第 15 章的真实顶点位移。
- **高光在噪声变化时闪烁:**可能是细节频率超出像素采样能力,或差分步长太小;减少高频并检查光照强度。
本章小结
片元细节法线从局部高度场的 x/y 斜率得到,再用 normalMatrix 映射到观察空间,与同空间的光和视线计算光照。它能在低分段平面上制造丰富明暗,但不改变轮廓与深度;真实几何法线和片元细节法线服务于不同的空间尺度。