主页3D小凯伊的制作

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$ 函数被加进了一个一直在增长的变量中。

逻辑拆解

  1. 线性部分 ($\text{Progress}$):如果没有后面的 $sin$,镜头会像钟表的秒针一样,匀速转圈。
  2. 扰动部分 ($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 展示(尤其是二次元模型)中,这种数学技巧被广泛用于**“自动运镜”**:

  1. 增加观赏时间:通常我们会通过调整 Math.PI * 1.1 这个偏移量(相位),让相机在正脸或者侧颜 45° 的位置速度最慢。
  2. 电影感:匀速旋转看起来像工业扫描,而这种忽快忽慢的旋转看起来更像是摄影师在手动推轨道,富有节奏感。

总结

  • $A < 1$:变速前进(你的现状)。
  • $A = 1$:瞬间停顿(非常有力量感的卡点)。
  • $A > 1$:来回抽搐(通常是 Bug,除非你想做某种疯狂的镜头特效)。

总结对比

比较维度光环 (位置映射)镜头 (相位扰动)
数学结果产生一个闭合的区间 $[-A, A]$产生一个变速的射线 $[0, \infty)$
视觉感受悬浮、漂浮、呼吸感灵动、抓拍、重点突出
代码表现pos = base + sinangle = progress + sin

你想试试把光环的“单纯往复”改成带有一点点“相位扰动”的非线性浮动吗?这能让它的悬浮感看起来更像是有灵魂的魔法物体。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

目录