还没下雪嘛?等不及了,自己整个 3D 雪地写写字!

现实中南方下雪还挺碰运气的,就算下雪了,想要在雪地上 "书写" 文字、画画什么的,这种想法更是不切实际;那只能想办法在 "赛博空间" 中满足个人雪地写字的愿望了~

先看一下实现后的效果.

下雪效果

雪地写字效果

行走脚印效果

虽然美观度上有些欠缺,不过无论如何是满足了 "雪地写字" 的关键诉求,我甚至还额外贴心地加了 "飘雪" 的氛围感,你就说走不走心吧...

1. 源起

事情的起因是上周看到了这个开源案例源码演示 "雪地实时地形重塑" 的技术( 说人话就是 "雪地脚印"这种特效):

这种雪地脚印在游戏中很常见,比如在黑神话悟空里就有:

不过网上使用 Threejs 来实现这种雪地脚印效果的教程或者源码寥寥无几,因此我看到这个就挺稀罕。

抽空拜读了作者源码,看完源码后我就寻思着能否自己在这上面改造改造。为了额外达成"雪地写字"的效果,我在作者源码的基础上简单做了几点改造:

  1. 支持通过鼠标在雪地上写字:这个是我最想要的效果,起初我是想要让人物在雪地上走动写字的,试了一下,这难度还不如让我去开飞机
  2. 新增下雪效果:主要是为了突出氛围感,没有下雪光走路多没意思啊
  3. 其他的一些交互细节 :比如写字的时候就不要让相机运动等等,自己接管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 动画场景挺常用,源码简单解释如下:

  1. difference = b - a: 计算两个角度的差值
  2. shortestAngle的计算过程确保选择最短的旋转路径:
    • 通过复杂的模运算将角度差值规范化到 -π 到 π 之间
    • 这样可以避免角度旋转超过180度
  3. 最后使用线性插值公式: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 多行,放在一起还是略微有点多,阅读起来稍微还是有些吃力。

代码就不贴了,我总结一下整体框架(基本自己制作小游戏也是这么个套路,写过的读者应该不会陌生):

  1. 无限雪地世界生成
  • 使用分块(chunk)系统生成无限延伸的雪地

  • 根据角色位置动态加载和卸载地形

  • 实现无缝衔接的地形系统

  1. 角色控制系统
  • 支持键盘(WASD/方向键)控制

  • 支持移动设备触摸控制

  • 平滑的角色移动和旋转

  • 根据移动状态切换行走/待机动画

  1. 雪地变形效果
  • 角色脚步会在雪地上留下痕迹

  • 实现雪地的动态变形、包含波纹效果

  • 保存和加载已变形的地形状态

  1. 相机系统
  • 跟随角色的第三人称相机

  • 平滑的相机移动和旋转

  • 可调整的相机偏移和视角

  1. 音效系统
  • 脚步声效果
  • 根据移动状态自动播放/停止

这里简单穿插讲解一下如何在 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]);

整体逻辑大致分成以下几点:

  1. 确定受影响的区块
  2. 遍历每个区块的顶点
  3. 计算顶点到撞击点的距离
  4. 根据距离计算变形程度
  5. 应用下陷和波纹效果
  6. 更新几何体和法线
  7. 保存变形状态

关键概念解释

  1. 变形计算
typescript 复制代码
const influence = Math.pow((DEFORM_RADIUS - distance) / DEFORM_RADIUS, 3);
  • 计算每个顶点受影响的程度
  • 距离越近,影响越大
  • 使用三次方使过渡更自然
  1. 下陷效果
typescript 复制代码
const yOffset = influence * 10;
tempVertex.y -= yOffset * Math.sin((distance / DEFORM_RADIUS) * Math.PI);
  • 创建向下的凹陷
  • 使用正弦函数使边缘平滑
  1. 波纹效果
typescript 复制代码
tempVertex.y += WAVE_AMPLITUDE * Math.sin(WAVE_FREQUENCY * distance);
  • 添加环形波纹
  • WAVE_AMPLITUDE控制波浪高度
  • WAVE_FREQUENCY控制波浪密度

从这里可以看出这个函数是实现雪地动态变形效果的核心,通过精确的数学计算创造出逼真的雪地交互效果。

好了,原作者的源码解读基本就到这儿了,你会发现代码实现 "短小精悍" ,麻雀虽小、五脏俱全,调理清晰,整体阅读起来难度不大,很多代码甚至直接拷贝放在其他 R3F 项目中使用。

3. 源码改造

看完源码后我就寻思着能否自己在这上面改造改造。为了达成"雪地"的效果,我在作者源码的基础上简单做了几点改造:

  1. 支持通过鼠标在雪地上写字: 开启"自定义绘制模式"后,用户就可以在雪地上写字了
  2. 新增了SnowEffect 组件: 制造下雪氛围
  3. 其他的一些交互细节:新增了 leva 控制面板,方便用户选择性开启一些功能

其实就是在作者源码的基础上做 "加法",对作者的关键源码基本没有改动。

3.1 实现雪地写字

这个功能的关键点有两个:

  1. 获取鼠标在 3D 平面上的坐标轨迹
  2. 然后调用作者提供的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 组件。

该组件的功能作用:

  1. 主要功能: 创建一个3D雪花效果,包含15000 个雪花粒子,这些雪花会在一个固定空间内持续飘落。
  2. 组件的主体工作流程图如下:

这里关键的就是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: "开启下雪效果",
    }
});

分别介绍一下这三个控制项的作用:

  1. customDraw(自定义绘制模式):

关键代码:

typescript 复制代码
useEffect(() => {
    if (!customDraw) return;
    // 处理鼠标事件,实现在雪地上画画的功能
    const handleMouseDown = (event) => {
        isDrawing.current = true;
        handleMouseMove(event);
    };
    // ...
}, [customDraw]);
  1. 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
    );
}
  1. enableCameraFollow(相机跟随):

关键代码:

typescript 复制代码
// 在渲染部分
<SnowEffect show={showSnow} />

// 在 SnowEffect 组件中
useEffect(() => {
    if (snowflakes.current) {
        snowflakes.current.visible = show;
    }
    isVisible.current = show;
}, [show]);

这三个控制项的组合效果:

  1. customDraw = true 时:
    • 可以用鼠标在雪地上画画
    • 相机控制会被禁用
    • 相机跟随会被禁用
  2. enableCameraFollow = true 时:
    • 相机会跟随角色移动
    • 但如果customDraw 开启,则此功能会被覆盖
  3. showSnow = true 时:
    • 会显示飘落的雪花效果
    • 不影响其他功能的使用

这些控制项提供了良好的交互体验,让用户可以:

  • 自由切换是否要在雪地上画画
  • 控制相机是否跟随角色
  • 开关雪花特效

通过以上这些改造,就完成了文章开头看到的那个可交互的冬季场景了。

4. 源码获取方式

搜索公众号 "JSCON简时空 " ,回复"241216 " 即可获取本人修改的源代码。 项目是典型的 vite 工程,执行npm install 后再npm run dev 即可查看效果

读者可以发挥想象力在这份代码基础上进行更改,创造出自己独特的场景、融入自己独特的想法。在这个年代,代码从来不是稀缺品,稀缺的是你的想象力和创造力~

以下是个人能想到的一些改造点,提供一些参考思路:

  1. 将人物模型更换成火狐、小白兔等自己喜欢动物模型,模型最好是带行走动画的
  2. 优化人物的动作交互,真正实现 "用人物" 在雪地上步行写字。自带的行走勉强能看,真要走出"字" 来,还是挺考验骚操作的
  3. 可以提供"输入文字",然后将文字 "印压" 在雪地上,会规整一些;(我个人还是喜欢这种手绘涂鸦风,更贴近实际)
  4. 提供一些运镜模版之类的,方便制作短视频
  5. 添加背景音乐,烘托氛围...

5. 小结

本人学习 Web 3D 方面的知识不久,3D 领域要学习掌握的东西太多,浩如烟海。这次写这篇文章,在开源的演示源码上,添加了雪地写字 + 下雪的效果,算是 "开源小创",也是对 Web 3D 知识的一次练习,以练促学,才能学有所悟。

预计以后的技术文章也将越来越多往 3D 方面,喜欢的可以关注一下本人公众号~ 欢迎评论区交流

全文完

相关推荐
玩电脑的辣条哥3 分钟前
语音识别失败 chrome下获取浏览器录音功能,因为安全性问题,需要在localhost或127.0.0.1或https下才能获取权限
前端·chrome·https
LaughingZhu41 分钟前
Github Action Bot
前端·经验分享·github
好开心3344 分钟前
2.17、vue的生命周期
java·开发语言·前端·javascript·vue.js·前端框架·ecmascript
Hacker_Oldv1 小时前
Web网络安全
前端·安全·web安全
拼图2091 小时前
图片底部空白缝隙解决法方案(CSS)
前端·css
会思想的苇草i1 小时前
JavaScript--原型与原型链
开发语言·前端·javascript·设计模式·原型·原型模式·原型链
CL_IN1 小时前
高效数据集成:钉钉与企业系统无缝对接
java·前端·钉钉
和风中看海2 小时前
JSX:JavaScript的XML
前端·react.js
想退休的搬砖人2 小时前
canvas(填充描边,绘制矩形、路径、文本、图像,变换,阴影,渐变等)
前端·canva
Domain-zhuo2 小时前
Webpack是什么?
前端·javascript·webpack·前端框架·node.js·ecmascript