现实中南方下雪还挺碰运气的,就算下雪了,想要在雪地上 "书写" 文字、画画什么的,这种想法更是不切实际;那只能想办法在 "赛博空间" 中满足个人雪地写字的愿望了~
先看一下实现后的效果.
下雪效果:
雪地写字效果:
行走脚印效果:
虽然美观度上有些欠缺,不过无论如何是满足了 "雪地写字" 的关键诉求,我甚至还额外贴心地加了 "飘雪" 的氛围感,你就说走不走心吧...
1. 源起
事情的起因是上周看到了这个开源案例源码演示 "雪地实时地形重塑" 的技术( 说人话就是 "雪地脚印"这种特效):
这种雪地脚印在游戏中很常见,比如在黑神话悟空里就有:
不过网上使用 Threejs 来实现这种雪地脚印效果的教程或者源码寥寥无几,因此我看到这个就挺稀罕。
抽空拜读了作者源码,看完源码后我就寻思着能否自己在这上面改造改造。为了额外达成"雪地写字"的效果,我在作者源码的基础上简单做了几点改造:
- 支持通过鼠标在雪地上写字:这个是我最想要的效果,起初我是想要让人物在雪地上走动写字的,试了一下,这难度还不如让我去开飞机
- 新增下雪效果:主要是为了突出氛围感,没有下雪光走路多没意思啊
- 其他的一些交互细节 :比如写字的时候就不要让相机运动等等,自己接管
OrbitControls
控件事件等
其实就是在作者源码的基础上做 "加法",对作者的关键源码基本没有改动。改造后,就可以像文章开头展示的那样可以自由写字了:
对于没有美术功底的我,在功能层面基本上已经达到我个人的预期,进一步场景润色我也有心无力....(论美术审美修养的重要性)。
文章到这里,对于不想继续看源码解读部分的读者,可以拖到底部获取我提供的个人改造的代码版本(附带简单的操作说明),源码是 MIT 协议,拿去随便改吧,想怎么改就怎么改~~
为了完整性,文章接下来的内容还是会从技术角度上解释一下如何实现。
毕竟很久没输出技术文章了, 手痒痒,接下来即将进入稍显枯燥的源码解读阶段,虽枯燥但是我相信对技术提升还是有一些帮助的。
友情提示:阅读源码需要 Three.js 、React 基础知识,本文默认读者已经有这两方面的基础,不然这文章要写得没完没了
2. 源码导读
这里放一下原作者的仓库和文章,顺带表达一下对作者的感谢,从中学到了很多实用的技术:
源码仓库地址 :github.com/oguzhantufe...
作者对应的文章:《Creating Dynamic Terrain Deformation with React Three Fiber》 tympanus.net/codrops/202...
本文尽可能简单导读一下代码结构,不在这里大段贴源代码占用篇幅,旨在给读者对源码有个概览。
先从简单的开始
2.1 FPSLimiter
帧率限定器
作者短短几行代码就实现了3D 渲染的帧率(FPS) 限定器,允许开发者指定具体的目标帧率(默认为 60fps)
主要作用:在不同设备上统一帧率体验,避免性能差异带来的不一致。
一般使用场景为:
- 在移动设备上运行 3D 应用时,可以通过降低帧率来节省电量
- 当不需要极高的刷新率时,可以限制帧率来减少 GPU 负载
这个组件实用方便,直接可以 copy 过来放在其他项目中使用
体会到阅读优秀源码的好处了,抄作业都很方便
2.2 lerpAngle
线性角度插值
lerpAngle
(线性角度插值)函数的源码如下:
typescript
export const lerpAngle = (a, b, t) => {
const difference = b - a;
const shortestAngle =
((((difference + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) -
Math.PI;
return a + shortestAngle * t;
};
其实就是简单的线性插值函数,这种技法在 3D 动画场景挺常用,源码简单解释如下:
difference = b - a
: 计算两个角度的差值shortestAngle
的计算过程确保选择最短的旋转路径:-
- 通过复杂的模运算将角度差值规范化到 -π 到 π 之间
- 这样可以避免角度旋转超过180度
- 最后使用线性插值公式:
a + shortestAngle * t
这个函数就是让你的动画 "如德芙般丝滑",提供良好的用户体验,常用在:
- 在3D动画中平滑过渡物体的旋转
- 相机视角的平滑转换
- 指针或箭头的旋转动画
2.3 雾特效
它使用着色器(shader)来实现一个基于距离的渐变透明效果,代码没几行:
typescript
const FogMaterial = shaderMaterial(
// 统一变量(uniforms)
{
uCenter: new THREE.Vector2(0.5, 0.5), // 雾效果的中心点
uRadius: 0.031, // 雾效果的半径
uColor: new THREE.Color(0xffffff), // 雾的颜色
},
// 顶点着色器
`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
// 片段着色器
`
uniform vec2 uCenter;
uniform float uRadius;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
float dist = distance(vUv, uCenter);
float alpha = smoothstep(uRadius - 0.01, uRadius + 0.01, dist);
gl_FragColor = vec4(uColor, alpha);
}
`
);
我们将源码中颜色改成红色的,就能看出这个 shader 的外观样子了:
作者这里也采用了取巧的方式,将颜色设置成白色,由于背景也是白色的,恰好就营造出 "白雪茫茫的一片" 的视觉效果。
在当前场景比较适合,毕竟背景、前景都是白茫茫一片,所以作者简单处理了。其他场景并不太实用,因此客观来说,该处理方法没有普适性
但不能说作者这么实现不好,离开具体场景判断一个技术的好坏是有失偏颇的。"如无必要,勿增实体",如果简单方法就能满足需求,就不要无所谓地叠加技能点~像作者这样简单处理,既满足了视觉效果,又不造成性能问题,性价比很高。
2.3 InfiniteSnowGround
无限雪地
这个InfiniteSnowWorld
组件的主要内容和作用,是整个 Demo 演示的核心组件。这个文件其实整体结构是很清晰的,就是代码量有 700 多行,放在一起还是略微有点多,阅读起来稍微还是有些吃力。
代码就不贴了,我总结一下整体框架(基本自己制作小游戏也是这么个套路,写过的读者应该不会陌生):
- 无限雪地世界生成
-
使用分块(
chunk
)系统生成无限延伸的雪地 -
根据角色位置动态加载和卸载地形
-
实现无缝衔接的地形系统
- 角色控制系统
-
支持键盘(WASD/方向键)控制
-
支持移动设备触摸控制
-
平滑的角色移动和旋转
-
根据移动状态切换行走/待机动画
- 雪地变形效果
-
角色脚步会在雪地上留下痕迹
-
实现雪地的动态变形、包含波纹效果
-
保存和加载已变形的地形状态
- 相机系统
-
跟随角色的第三人称相机
-
平滑的相机移动和旋转
-
可调整的相机偏移和视角
- 音效系统
- 脚步声效果
- 根据移动状态自动播放/停止
这里简单穿插讲解一下如何在 VSCode 编辑器中如何预览 3D 模型文件,估计有些读者比较感兴趣。当前应用所需要的模型、材质都放在 public 目录下了:
其中这个explorer.glb
文件就是人物的 3D 模型,只不过是压缩后的,我们来看一下如何在 VSCode 中预览它。
首先我们在 VSCode 中安装流行的 glTF Tools 插件:
右键 选择 explorer.glb 文件后,选择glTF: import from GLB
进行转换
在弹出的文件框里选择 explorer.glb 文件点击确定,就会转换成可阅读的 GLTF 格式文件了:
之后使用快捷键Ctrl + Shift + P
呼出 Preview 3D Model 功能,就可以预览该模型了
通过预览插件,我们选择 Three.js 模式,看到这个模型就是人物模型:
同时你还会看到它额外还携带 3 个动画(从命名上可以猜测出是基于流行的 mixamo 制作的): 第一个名为Armature.001|mixamo.com|Layer0
动画名,是人物原地活动的动画(懒散抖腿的效果):
第二个是名为Armature|mixamo.com|Layer0
的步行动画效果:
另外其中关于无限雪地的技术细节,推荐查看原作者写的文章《Creating Dynamic Terrain Deformation with React Three Fiber 》,文中作者详细介绍了如何使用 React Three Fiber 创建动态地形变形效果。
我这里也简单提及一下,核心是这个deformMesh
函数,它用于实现雪地的动态变形效果。主要功能创建角色脚步踩踏雪地时的变形效果,包括下陷和波纹效果。
源码详细解析(大概看一下有个印象就行,真需要了再细读即可...):
typescript
const deformMesh = useCallback((mesh, point) => {
// 1. 基础检查
if (!mesh) return;
// 2. 获取受影响的相邻区块
const neighboringChunks = getNeighboringChunks(point, chunksRef);
const tempVertex = new THREE.Vector3();
const geometriesToUpdate = [];
// 3. 处理每个相邻区块
neighboringChunks.forEach((chunk) => {
const geometry = chunk.geometry;
if (!geometry?.attributes?.position) return;
const positionAttribute = geometry.attributes.position;
const vertices = positionAttribute.array;
let hasDeformation = false;
// 4. 处理每个顶点
for (let i = 0; i < positionAttribute.count; i++) {
// 获取顶点位置
tempVertex.fromArray(vertices, i * 3);
chunk.localToWorld(tempVertex);
// 计算到撞击点的距离
const distance = tempVertex.distanceTo(point);
// 5. 在影响半径内进行变形
if (distance < DEFORM_RADIUS) {
// 计算影响强度
const influence = Math.pow(
(DEFORM_RADIUS - distance) / DEFORM_RADIUS,
3
);
// 计算下陷效果
const yOffset = influence * 10;
tempVertex.y -= yOffset * Math.sin((distance / DEFORM_RADIUS) * Math.PI);
// 添加波纹效果
**tempVertex.y += WAVE_AMPLITUDE * Math.sin(WAVE_FREQUENCY * distance);**
// 更新顶点位置
chunk.worldToLocal(tempVertex);
tempVertex.toArray(vertices, i * 3);
hasDeformation = true;
}
}
// 6. 更新变形后的几何体
if (hasDeformation) {
positionAttribute.needsUpdate = true;
geometriesToUpdate.push(geometry);
saveChunkDeformation(chunk);
}
});
// 7. 重新计算法线
if (geometriesToUpdate.length > 0) {
geometriesToUpdate.forEach((geometry) => geometry.computeVertexNormals());
}
}, [getNeighboringChunks, chunksRef, saveChunkDeformation]);
整体逻辑大致分成以下几点:
- 确定受影响的区块
- 遍历每个区块的顶点
- 计算顶点到撞击点的距离
- 根据距离计算变形程度
- 应用下陷和波纹效果
- 更新几何体和法线
- 保存变形状态
关键概念解释
- 变形计算
typescript
const influence = Math.pow((DEFORM_RADIUS - distance) / DEFORM_RADIUS, 3);
- 计算每个顶点受影响的程度
- 距离越近,影响越大
- 使用三次方使过渡更自然
- 下陷效果
typescript
const yOffset = influence * 10;
tempVertex.y -= yOffset * Math.sin((distance / DEFORM_RADIUS) * Math.PI);
- 创建向下的凹陷
- 使用正弦函数使边缘平滑
- 波纹效果
typescript
tempVertex.y += WAVE_AMPLITUDE * Math.sin(WAVE_FREQUENCY * distance);
- 添加环形波纹
WAVE_AMPLITUDE
控制波浪高度WAVE_FREQUENCY
控制波浪密度
从这里可以看出这个函数是实现雪地动态变形效果的核心,通过精确的数学计算创造出逼真的雪地交互效果。
好了,原作者的源码解读基本就到这儿了,你会发现代码实现 "短小精悍" ,麻雀虽小、五脏俱全,调理清晰,整体阅读起来难度不大,很多代码甚至直接拷贝放在其他 R3F 项目中使用。
3. 源码改造
看完源码后我就寻思着能否自己在这上面改造改造。为了达成"雪地"的效果,我在作者源码的基础上简单做了几点改造:
- 支持通过鼠标在雪地上写字: 开启"自定义绘制模式"后,用户就可以在雪地上写字了
- 新增了
SnowEffect
组件: 制造下雪氛围 - 其他的一些交互细节:新增了 leva 控制面板,方便用户选择性开启一些功能
其实就是在作者源码的基础上做 "加法",对作者的关键源码基本没有改动。
3.1 实现雪地写字
这个功能的关键点有两个:
- 获取鼠标在 3D 平面上的坐标轨迹
- 然后调用作者提供的
deformMesh
方法实行变形效果即可。
关键的代码已经标注到下面的截图中,从这里可以看出这个雪地写字 功能 "虽然看上去很复杂,其实也就 just so so":
3.2 下雪氛围
下雪的氛围我单独抽成一个SnowEffect
组件,所以也是很方便 copy 到其他项目中使用的:
该下雪效果的的实现我参考了《23a How to make falling snow three.js 》:www.youtube.com/watch?v=OXp... 这个视频,原文是 three.js 写的,我将其改造成 React 组件。
该组件的功能作用:
- 主要功能: 创建一个3D雪花效果,包含
15000
个雪花粒子,这些雪花会在一个固定空间内持续飘落。 - 组件的主体工作流程图如下:
这里关键的就是addSnowFlakes
方法,源码解释如下:
typescript
// 雪花生成函数
function addSnowFlakes() {
// 定义雪花数量和活动范围
const numberSnowflakes = 15000;
const maxRange = 1000;
// 为每个雪花设置随机位置和速度
for (let i = 0; i < numberSnowflakes; i++) {
// 位置设置
const x = Math.floor(Math.random() * maxRange - minRange);
// ...
// 速度设置
velocities.push(/*...*/);
}
}
实现的时候需要关注的一些点:
- 性能优化:
typescript
useFrame(() => {
// 只在可见时更新动画
if (!isVisible.current || !snowflakes.current) return;
// ...
});
- 边界处理:
typescript
// 当雪花落到底部时,重置到顶部
if (positions.array[i * 3 + 1] < 0) {
positions.array[i * 3 + 1] = 750;
}
// 限制水平方向的移动范围
if (Math.abs(positions.array[i * 3]) > 500) {
positions.array[i * 3] *= -0.95;
}
- 状态管理:
typescript
useEffect(() => {
// 监听show属性变化
if (snowflakes.current) {
snowflakes.current.visible = show;
}
isVisible.current = show;
}, [show]);
- 资源清理:
typescript
return () => {
if (snowflakes.current) {
snowRef.current.remove(snowflakes.current);
snowflakes.current.geometry.dispose();
snowflakes.current.material.dispose();
}
};
总结一下这个组件的主要特点是:
- 高性能:使用
BufferGeometry
处理大量粒子 - 可控制:通过
show
属性控制显示/隐藏 - 内存友好:包含适当的资源清理
- 物理仿真:模拟真实的雪花飘落效果
调用该组件的方式如下,也很直观:
typescript
<SnowEffect show={true} />
组件适合用在需要雪花效果的场景,比如圣诞节主题的网页或冬季主题的3D场景中。
3.3 其他细节交互的处理
为了方便按照自己的思路进行交互,在作者的 InfiniteSnowGround
组件中做了一些改造,主要是引入了 Leva 控制面板,通过useControls
提供了三个控制项:
typescript
import { useControls } from "leva";
...
const { customDraw, enableCameraFollow, showSnow } = useControls({
customDraw: {
value: false,
label: "自定义绘制模式",
},
enableCameraFollow: {
value: true,
label: "开启相机跟随",
},
showSnow: {
value: true,
label: "开启下雪效果",
}
});
分别介绍一下这三个控制项的作用:
customDraw
(自定义绘制模式):
关键代码:
typescript
useEffect(() => {
if (!customDraw) return;
// 处理鼠标事件,实现在雪地上画画的功能
const handleMouseDown = (event) => {
isDrawing.current = true;
handleMouseMove(event);
};
// ...
}, [customDraw]);
enableCameraFollow
(相机跟随):
关键代码:
typescript
// 在 useFrame 中
if (enableCameraFollow && !customDraw) {
cameraTargetRef.current
.copy(characterPosition)
.add(new THREE.Vector3(0, 0, 0));
const offsetRotated = cameraOffset
.clone()
.applyAxisAngle(new THREE.Vector3(0, 0, 0), currentRotation.current);
const targetCameraPosition = characterPosition.clone().add(offsetRotated);
camera.position.lerp(targetCameraPosition, 0.01);
camera.lookAt(
cameraTargetRef.current.x,
cameraTargetRef.current.y + 7,
cameraTargetRef.current.z
);
}
enableCameraFollow
(相机跟随):
关键代码:
typescript
// 在渲染部分
<SnowEffect show={showSnow} />
// 在 SnowEffect 组件中
useEffect(() => {
if (snowflakes.current) {
snowflakes.current.visible = show;
}
isVisible.current = show;
}, [show]);
这三个控制项的组合效果:
- 当
customDraw = true
时: -
- 可以用鼠标在雪地上画画
- 相机控制会被禁用
- 相机跟随会被禁用
- 当
enableCameraFollow = true
时: -
- 相机会跟随角色移动
- 但如果
customDraw
开启,则此功能会被覆盖
- 当
showSnow = true
时: -
- 会显示飘落的雪花效果
- 不影响其他功能的使用
这些控制项提供了良好的交互体验,让用户可以:
- 自由切换是否要在雪地上画画
- 控制相机是否跟随角色
- 开关雪花特效
通过以上这些改造,就完成了文章开头看到的那个可交互的冬季场景了。
4. 源码获取方式
搜索公众号 "JSCON简时空 " ,回复"241216 " 即可获取本人修改的源代码。 项目是典型的 vite 工程,执行npm install
后再npm run dev
即可查看效果
读者可以发挥想象力在这份代码基础上进行更改,创造出自己独特的场景、融入自己独特的想法。在这个年代,代码从来不是稀缺品,稀缺的是你的想象力和创造力~
以下是个人能想到的一些改造点,提供一些参考思路:
- 将人物模型更换成火狐、小白兔等自己喜欢动物模型,模型最好是带行走动画的
- 优化人物的动作交互,真正实现 "用人物" 在雪地上步行写字。自带的行走勉强能看,真要走出"字" 来,还是挺考验骚操作的
- 可以提供"输入文字",然后将文字 "印压" 在雪地上,会规整一些;(我个人还是喜欢这种手绘涂鸦风,更贴近实际)
- 提供一些运镜模版之类的,方便制作短视频
- 添加背景音乐,烘托氛围...
5. 小结
本人学习 Web 3D 方面的知识不久,3D 领域要学习掌握的东西太多,浩如烟海。这次写这篇文章,在开源的演示源码上,添加了雪地写字 + 下雪的效果,算是 "开源小创",也是对 Web 3D 知识的一次练习,以练促学,才能学有所悟。
预计以后的技术文章也将越来越多往 3D 方面,喜欢的可以关注一下本人公众号~ 欢迎评论区交流
全文完