Part 1 场景搭建和简单处理
整个过程中用 Three.js Editor 辅助检查角色模型。
一上来Gemini就给我写好了一个可以拖拽的3D场景,角色直接就在里面走起来了,因为读取的是角色的第一段动画。由于glTF格式自动关联贴图,所以贴图都自动贴好了。材质还是lambert。
写了个灯光绕着角色转的效果,也就是把灯光作为场景原点的空物体lightPivot的子物体,并旋转lightPivot。本来只是想让Gemini帮我调试一下灯光的位置,Gemini锦上添花给我写了个会转的太阳用来表示灯光的位置。
写了个相机自动匀速旋转,使用的是Three.js的OrbitControl的一些方法,原理是方位角,直接autoRotate,用户如果在手动拖拽画面旋转那就暂停自动旋转。
做了多平台速度同步,因为三端的运行速度不一样,电脑上能跑300fps但手机上可能只能120fps。方法是const delta = clock.getDelta();获得delta值,用这个delta值乘各种速度。
给角色赋予了Three.js内置的ToonMaterial。
给角色加了描边,方法是复制一个模型,只渲染背面,然后法线外扩。描边的材质是baseMaterial,直接给黑色颜色。
简单画了张三色阶gradientMap,用于lambert对其进行采样得到角色阴影色阶。
给角色加了投影到地面。原理是在灯光处向角色做投影,在地平面上放了一个大圆形平面用于接受投影,这个平面的材质设置为shadowMaterial,渲染出来的效果是透明的只有阴影。
后来对相机自动匀速旋转不是很满意,想要相机进行缓动旋转,也就是旋转到越靠近角色正面速度越慢,越远离角色正面速度越快。使用了sin来控制Azimuth角缓动,但由于这是个非线性方程,如果中途用户进行了拖拽,我无法将相机自然地从当前位置继续按照这个方程进行旋转,试了好久校正参数都没有完全对齐位置,果断放弃对齐,选择把相机直接平滑移动到拖拽之前的那条轨道上。
Part 2 角色渲染
自己完全重写了着色器来代替ToonMaterial,因为ToonMaterial完全就是个黑盒,不让改太多参数。
做了很多修正。把着色器写得比较完善。
用Blender平滑法线为角色的头发烘焙了一张法线贴图,给头发的阴影做细节微调用。把混合调小一点(约0.05)效果比较好。
在着色器里这样写:
// --- 计算法线 ---
vec3 N = normalize(vWorldNormal);
if(useNormalMap) {
// 1. 采样法线贴图 (从 0~1 映射回 -1~1)
vec3 normalData = texture2D(normalMap, vUv).rgb * 2.0 - 1.0;
normalData.xy *= uNormalScale;
normalData = normalize(normalData);
// 2. 构建 TBN 矩阵,将贴图法线转到世界空间
mat3 tbn = mat3(normalize(vWorldTangent), normalize(vWorldBinormal), normalize(vWorldNormal));
// 3. 混合法线
N = normalize(tbn * normalData);
}
着色器编写
要厘清楚着色器怎么写、各种效果的顺序是什么、这样的混合方式和顺序正确吗?
要注意方向光和环境光的颜色。通过dirIntensity、AmbientIntensity控制强度。
其次使用gradientOffset、clampMax阴影强度。
在角色提取出的所有贴图文件里,发现 CH0335_Hair_Mask.png 的 G 通道是 Ambient Occlusion 贴图,且色相被颠倒,需要用1减去采样值。

float AOData = 1.0 - texture2D(AOMap, vUv).r;
float AOMask = clamp((AOData + AOOffset) * uAOIntensity, 0.0, 1.0);
之后最重要的是,光照混合一定要理清楚。
// --- 混合光照 ---
vec3 rawColor = texColor.rgb;
// 总亮度 = 环境光强度 + (阴影权重 * 灯光强度)
// 这样环境光会带上贴图的颜色,而不是死板的叠加灰色
float dirLightFactor = shadowStep * uDirLightIntensity;
vec3 dirLight = dirLightFactor * uDirLightColor;
vec3 ambientLight = uAmbientIntensity * uAmbientColor;
vec3 diffuse = (dirLight + ambientLight) * rawColor * uColor;
最后用Saturate、transitionSaturate控制饱和度(都1.5较合适)。
// 调整饱和度函数
vec3 srv_saturate(vec3 rgb, float adjustment) {
const vec3 W = vec3(0.2125, 0.7154, 0.0721);
vec3 intensity = vec3(dot(rgb, W));
return mix(intensity, rgb, adjustment);
}
用emissiveStrength控制自发光,来把整体色调调暖。
调整RimPower和RimIntensity。边缘光应只在光照一侧(NdotL>0)有。
然后混合。
// --- 颜色计算结果 ---
vec3 finalColor = diffuse;
// 补偿阴影处的自发光(注意:这里通常也应该乘以 rawColor 以保持色相)
finalColor += rawColor * uEmissive * uEmissiveIntensity * (1.0 - shadowMask);
finalColor += rimEffect;
色彩空间一定要对,自定义ShaderMaterial需要在最后的最后手动做gamma校正。
finalColor = pow(finalColor, vec3(1.0 / 2.2));
附录:关于缓动的思考
光环是上下来回缓动。
相机绕轨道的旋转则更复杂,每绕到一个特定的角度附近时速度最小,远离则变快。
要深入理解这两者的区别,我们需要把数学公式从代码中抽离出来,观察它们在时间轴上的行为。
这两者虽然都用了 $sin$ 函数,但一个是位置(Position)的直接映射,另一个是相位(Phase)的扰动。
1. 光环缓动:简谐运动 (Simple Harmonic Motion)
光环的原理是典型的物理简谐运动。它的目标是让物体在固定参考点附近做往复运动。
数学公式
$$f(t) = A \cdot \sin(\omega \cdot t + \phi) + B$$
- $B$ (基准值/锚点):这是你代码中的
initialPos.y。没有它,光环会跳到世界坐标的原点。- $A$ (振幅):波动的最大距离。
- $\omega$ (角频率):控制波动的快慢。
- $\sin$ 的角色:它在这里是一个数值发生器,输出范围严格锁定在 $[-1, 1]$。
运动特征
如果我们对时间求导(看它的速度),你会发现:
- 在最高点和最低点,速度为 0(瞬间静止)。
- 在中间平衡位置,速度达到最大。这种速度变化非常符合直觉,让光环看起来有“重量感”。
2. 镜头绕转:相位调制 (Phase Modulation)
你镜头的代码逻辑更高级一些,它本质上是在改变时间的流逝感。
数学公式
$$\theta(t) = \text{Progress}(t) + A \cdot \sin(\text{Progress}(t) + \phi)$$
$$\text{Progress} += \text{delta} * \text{baseSpeed}$$
这个 $\text{Progress}(t)$ 其实就相当于 $\text{elapsedTime}$
这里极其关键的区别是:$sin$ 函数被加进了一个一直在增长的变量中。
逻辑拆解
- 线性部分 ($\text{Progress}$):如果没有后面的 $sin$,镜头会像钟表的秒针一样,匀速转圈。
- 扰动部分 ($A \cdot \sin$ ):
- 当 $sin$ 的斜率为正时,它在推着镜头往前跑,速度 = $1 + \cos$。
- 当 $sin$ 的斜率为负时,它在拉着镜头往后拽,速度 = $1 – \cos$。
运动特征:非线性角速度
这种做法不会让镜头往复运动(因为它始终在往前走),但它会造成视觉上的强调:
- 快进:跳过不重要的侧面角度。
- 慢放:在特定角度(如正面)产生一种“运镜停顿感”。
相机旋转具体细节探讨
之所以相机会在某个角度变慢,是因为你把 $sin$ 函数叠加到了一个线性增长的进度上。当 $sin$ 函数的“后退速度”快要抵消掉进度的“前进速度”时,相机就慢下来了。
1. 为什么不会反过来转?(1.0 的秘密)
你的直觉是对的。要保证相机永远向前转而不往回退,你的扰动系数(振幅 $A$)确实不能超过某个临界值。
我们来看这个角度公式的导数(即瞬时旋转速度):
$$\theta = t + A \cdot \sin(t)$$
$$\theta’ = 1 + A \cdot \cos(t)$$
- 进度速度是恒定的 $1$。
- 扰动速度是 $A \cdot \cos(t)$,它的取值范围是 $[-A, A]$。
结论:
- 如果 $A < 1.0$:总速度 $1 + A \cdot \cos(t)$ 永远大于 $0$。相机虽然会忽快忽慢,但始终在往前走。
- 如果 $A = 1.0$:在某个瞬间(当 $\cos(t) = -1$ 时),总速度刚好变为 $0$。相机会在那个瞬间完全停住,然后再次启动。
- 如果 $A > 1.0$:总速度会出现负值。相机会在旋转过程中突然往回倒退一小段,然后再继续往前。
2. 为什么在“固定角度”附近变慢?
在你的代码
const theoryAngle = rotationProgress + Math.sin(rotationProgress + Math.PI * 1.1) * 0.8;中:
- 你的 $A$ 是 $0.8$。
- 因为 $0.8 < 1.0$,所以相机不会倒退,但它最慢的时候速度只有 $1 – 0.8 = 0.2$。
为什么是固定角度?
$sin(x + \phi)$ 只有在特定的弧度值时才会达到最小速度。
- 当
rotationProgress + Math.PI * 1.1接近 $\pi$ (180°)、$3\pi$ 等奇数倍周期时,$\cos$ 的值为 $-1$。- 这时,扰动项正在全力“向后拽”相机。
- 因为
rotationProgress与时间挂钩,所以每转一圈,相机都会在同一个空间方位进入这个“低速区”。
3. 这种“相位调制”的视觉意义
在 3D 展示(尤其是二次元模型)中,这种数学技巧被广泛用于**“自动运镜”**:
- 增加观赏时间:通常我们会通过调整
Math.PI * 1.1这个偏移量(相位),让相机在正脸或者侧颜 45° 的位置速度最慢。- 电影感:匀速旋转看起来像工业扫描,而这种忽快忽慢的旋转看起来更像是摄影师在手动推轨道,富有节奏感。
总结
- $A < 1$:变速前进(你的现状)。
- $A = 1$:瞬间停顿(非常有力量感的卡点)。
- $A > 1$:来回抽搐(通常是 Bug,除非你想做某种疯狂的镜头特效)。
总结对比
比较维度 光环 (位置映射) 镜头 (相位扰动) 数学结果 产生一个闭合的区间 $[-A, A]$ 产生一个变速的射线 $[0, \infty)$ 视觉感受 悬浮、漂浮、呼吸感 灵动、抓拍、重点突出 代码表现 pos = base + sinangle = progress + sin你想试试把光环的“单纯往复”改成带有一点点“相位扰动”的非线性浮动吗?这能让它的悬浮感看起来更像是有灵魂的魔法物体。

发表回复