23 综合项目:动态图案海报、流动水面与程序化徽标

前置知识:第 6 章的 fract 重复、第 7 章的比例修正、第 10 章的波浪、第 14 章的 noise 组合、第 15~16 章的位移与法线,以及第 20~21 章的 SDF 组合和视觉效果。
本章示例:动态图案海报、流动水面、程序化徽标。完整源码分别见海报、水面、徽标。

学完本章能做什么

你将能从一个明确的视觉目标出发,把已有的坐标、函数、动画、距离场和光照知识装配成三个完整画面;根据图案是否需要改变网格轮廓与深度决定计算放在顶点还是片元阶段;并用分层查看模式定位组合项目里的问题。本章把三个项目作为讲解实例,独立练习仍放在章节末尾链接的练习文件中。

先看三个结果

海报在 3:2 平面上重复排列圆环。每格的半径随时间波动,格子编号改变相位和颜色;局部坐标与遮罩模式能拆开看每一层。水面由两组方向不同的正弦波与一层 value noise 共同改变真实网格高度,然后从同一个高度函数求观察空间法线;分段数、原法线和修正法线可以对照。徽标由圆环与三条线段胶囊形组成,在片元中做逆向旋转、等比缩放、阴影、光晕和渐变;等距线模式显示组合后的标量场。

三个示例都用仓库内的 Three.js r174、WebGLRenderer 和 ShaderMaterial。颜色先在线性 RGB 中计算,再通过 #include <colorspace_fragment> 进入渲染器的输出色彩空间。海报与徽标的平面只有 1 × 1 分段,图案细节由片元数决定;水面默认 96 × 64 分段,顶点数量直接限制真实波形。

项目 A:把重复坐标、波浪与颜色合成海报

1. 先让每格在物理尺寸上是正方形

平面几何尺寸是 3 × 2,因此横向格数设为纵向的 1.5 倍。默认纵向 4 格,横向就是 6 格:每格几何宽度 3/6=0.5,高度 2/4=0.5。这与浏览器窗口的宽高比无关;窗口变化时 Three.js 相机只负责完整展示这个 3:2 平面。片元中的重复坐标为:

// 片元阶段;vUv 是平面 UV,local 是单格内 [-0.5,0.5) 的局部坐标。
vec2 tileCount = vec2( 1.5 * uDensity, uDensity );
vec2 tiled = vUv * tileCount;
vec2 cell = floor( tiled );
vec2 local = fract( tiled ) - 0.5;

floor 给格子编号,fract 把每格重新映射到同一个局部区间。控件让 uDensity 取 2、4、6、8,对应整数的横向格数 3、6、9、12,不会在边缘留半格;若任意修改横向格数,圆环可能随格子拉成椭圆。局部查看模式直接把 local+0.5 映射到红、绿通道,以便检查重复坐标是否连续重置。

2. 再让每格不同,但仍可预测

格子相位用 dot(cell,vec2(1.9,2.3)) 生成确定性的差异;它不是随机数,也不需要依赖跨设备不稳定的浮点 hash。动画时间只改变这个相位:

// 片元阶段;local 是单格坐标,radius/ringD 以单格宽度为单位。
float phase = uTime * uSpeed + dot( cell, vec2( 1.9, 2.3 ) );
float radius = 0.23 + 0.08 * sin( phase );
float ringD = abs( length( local ) - radius ) - 0.035;
float aa = max( fwidth( ringD ), 0.001 );
float ring = 1.0 - smoothstep( -aa, aa, ringD );

radius 始终在 0.15~0.31,不会越过单格半宽 0.5。ringD=0 是内外两条圆形边界;fwidth 估计当前像素覆盖的距离宽度,让边缘平滑。uSpeed=0 时相位随时间不变,暂停开关则停止 JavaScript 中的模拟时间;两者得到的静态画面可能处在不同相位。遮罩模式只显示 ring,排除配色干扰。

完整海报先用沿纵向变化的底色,再叠一条由正弦嵌套形成的微弱波纹,最后根据格子编号在青色与珊瑚色之间选圆环颜色,并叠加中心点与外框。背景波纹只是片元颜色变化;没有真实起伏。这里既有“先算遮罩再上色”的第 8 章思路,也有第 10 章的时间相位,但各层职责分开,调试时可以暂时只输出其中一层。

项目 B:把波浪、noise 与修正法线组成水面

1. 让一个高度函数成为唯一来源

水面使用局部平面坐标 position.xy。两组正弦波方向、频率和速度不同,一组产生主要波峰,另一组打破整齐的平行感;value noise 加低幅度的不规则细节:

// 顶点阶段;localXY 是平面局部空间,返回值是局部 z 高度。
float heightAt( vec2 localXY ) {
  vec2 dir1 = normalize( vec2( 1.0, 0.35 ) );
  vec2 dir2 = normalize( vec2( -0.40, 1.0 ) );
  float primary = uAmplitude * sin( dot( localXY, dir1 ) * 6.0 - uTime * 1.4 );
  float secondary = uAmplitude * 0.32 *
    sin( dot( localXY, dir2 ) * 10.0 + uTime * 1.9 );
  float detail = uNoise * 2.0 *
    ( valueNoise( localXY * 5.0 + vec2( uTime * 0.20, -uTime * 0.15 ) ) - 0.5 );
  return primary + secondary + detail;
}

valueNoise 复用第 12~15 章的格点插值实现,标称范围为 [0,1);减 0.5 后把它大致放在零高度两侧。这个哈希式 noise 在大坐标、高频和不同设备上不保证逐像素一致;项目只在有限的局部坐标与四舍五入后的分段网格上取样。改变 uNoise 不会自动改变网格分段数,若细节频率超过网格可表示的范围,会出现混叠或丢失。

2. 用同一函数求法线

顶点变成 (position.xy,heightAt(position.xy)) 后,原平面法线不再与新表面垂直。示例在局部 x/y 两侧各查询高度一次,得到斜率 hx、hy,再构造局部法线:

// 顶点阶段;e、position.xy 与高度均为局部空间长度单位。
float e = 0.01;
float hx = ( heightAt( position.xy + vec2( e, 0.0 ) ) -
             heightAt( position.xy - vec2( e, 0.0 ) ) ) / ( 2.0 * e );
float hy = ( heightAt( position.xy + vec2( 0.0, e ) ) -
             heightAt( position.xy - vec2( 0.0, e ) ) ) / ( 2.0 * e );
vec3 normalLocal = normalize( vec3( -hx, -hy, 1.0 ) );
vec3 normalView = normalize( normalMatrix * normalLocal );

normalMatrix 把局部法线送到观察空间,片元中的 uLightView、视线方向和插值后的法线也都在观察空间。若改了高度公式,却继续用旧公式求斜率,明暗就会与轮廓分离。本例采用有限差分,因此只要 heightAt 是唯一高度来源,新加的波浪或 noise 会自动进入法线。代价是每个顶点至少额外查询四次高度。法线 RGB 模式可以直接检查方向;原平面法线明暗模式保留了错误示范,便于对比。

3. 区分网格细节与颜色细节

示例网格可调到横向 8~128 分段,纵向按 2/3 比例取整。横向只有 8 段时,每段约 3/8=0.375 个局部单位,无法稳定表达高频波与 noise;即使片元着色再细腻,轮廓仍沿粗网格折线走。网格查看模式让这件事直观可见。相反,第 16 章的片元细节法线可在不增加顶点的情况下改善局部明暗,但不会补回轮廓和几何深度。

片元着色把高度映射到深浅两种水色,再用观察空间的漫反射、高光与视角项改变颜色。它是便于学习的风格化水面:没有真实折射、透明度、环境反射或流体模拟。水面波峰高度来自顶点,亮点位置来自修正法线;两者分开检查比一次调所有颜色参数更容易找到问题。

项目 C:把 SDF 组合成可缩放的程序化徽标

徽标的外环是 abs(length(q)-0.41)-0.5*uStroke,其零边界分别位于中心半径 0.41 的两侧。字母形由三条线段胶囊形组成:左右两条斜边与中间横杠,再用 min 并入圆环。胶囊形到线段最近点的公式沿用第 19 章;端点为半圆,因此笔画末端不会出现方角。各笔画的半径取 uStroke 的固定比例,控件改变的是局部笔画宽度。

整枚徽标旋转、缩放时,先把查询点逆向变换到原始形状坐标:

// 片元阶段;p 为以整块平面高度为单位的二维坐标,uScale > 0。
vec2 q = rotate2D( -uAngle ) * p / uScale;
float ring = abs( length( q ) - 0.41 ) - 0.5 * uStroke;
float logoLocal = min( ring, min( min( left, right ), bar ) );
float d = logoLocal * uScale;

完整源码在计算 left/right/bar 前就有 q,上面只摘出变换与组合顺序。uAngle 在 JavaScript 中从角度转成弧度。末尾乘回 uScale 让返回的距离仍以画面长度单位表达;徽标放大时笔画本身也按设计一起变宽,但发光衰减距离 uGlow 仍是固定的画面单位。若忘记乘回比例,零边界可能还对,等距线与光晕宽度却会按错误的距离数值变化。硬并集交叠处的内部距离不一定精确,第 20 章已有反例;这不会妨碍当前边界填充。

徽标默认先绘制偏移的二维软阴影,再根据正距离叠加蓝色外光晕,最后以 1-smoothstep(-aa,aa,d) 混入渐变笔画。等距线模式把颜色层暂时拿掉,显示 d 的周期线和金色零边界;仅填充模式则可排除阴影与发光。阴影和光晕都只存在于这块平面的片元颜色中,网格轮廓与场景真实灯光没有改变。

把三个项目当成同一套排错方法

  1. 先确认实际输出的几何:海报、徽标是静态平面;水面是高分段且在顶点中位移的平面。
  2. 再确认一套坐标及单位:海报的格内坐标、徽标的平面高度单位、水面的局部高度和观察空间光照,不能混着比较数值。
  3. 把中间结果直接画出来:海报查看局部 UV/遮罩,水面查看网格/法线,徽标查看等距线/纯填充。
  4. 最后调颜色、光晕和性能:先保证形状与方向正确,再增加视觉层。连续动画用模拟时间 uTime,暂停时停止时间增长;速度为零仅停止对应公式中的时间影响。

全书其他症状和最短排查路径见附录 A。三个项目分别沿用了基础函数、noise/法线、SDF 三条学习线;独立练习会让你在每条线上做一次可观察的扩展。

本章小结

综合项目的关键是让每个量有明确职责:坐标决定在哪取样,函数生成图案或高度,法线描述新表面方向,距离场提供零边界,颜色映射负责外观。先单独验证这些中间量,再组合,能避免把几何、光照和显示错误混成一个问题。

本章练习