稀土掘金技术社区 05月26日
我写出了被 Threejs 官推转发的项目!!!
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨如何使用Three.js和AI技术,定制个性化的3D场景。文章涵盖了Three.js基础、Shader编程、2D/3D资源获取、AI辅助建模等关键技术。通过AI工具生成和处理资源,结合Blender MCP进行场景搭建,并利用Octree实现碰撞检测,最终实现复古风格的角色控制。无论是资源获取,建模,还是场景搭建,都提供了详细的解决方案,旨在帮助开发者高效构建独一无二的3D世界。

🎨 介绍了如何利用AI技术辅助3D建模,通过文生图或图生图模型快速生成简易游戏画面,并结合Blender资源库,解决资源库中缺少元素的问题,从而高效地搭建场景。

🖼️ 详细阐述了2D客制化资源的获取与处理流程,利用GPT-4o的图像生成能力,统一UI风格,并结合Remove.bg等工具去除背景,以及Aspose PNG Splitter拆分图标,实现定制化UI元素。

🎮 深入讲解了如何使用Three.js实现复古风格的角色控制,通过监听键盘事件,精确控制角色在四个方向上的移动,并结合Octree进行碰撞检测,从而实现类似宝可梦城镇的经典四方向移动体验。

⚙️ 介绍了如何使用blender-mcp快速辅助3D建模、场景创建和操作,它通过 API 添加了对 Poly Haven 资产的支持,并且已经接入了hyper3d,意味着我们可以借用支持 MCP Servers的任何工具。

原创 何贤 2025-05-26 08:31 重庆

点击关注公众号,“技术干货” 及时达!前置条件hello! 欢迎阅读本篇文章!这篇文章会探讨如何高定制化地构建一个自己喜欢的 3D 场景。

点击关注公众号,“技术干货” 及时达!

前置条件

hello! 欢迎阅读本篇文章!这篇文章会探讨如何高定制化地构建一个自己喜欢的 3D 场景。我们将深入探讨 

Three.js

Shader(GLSL)

Cursor rules & MCP Servers

 以及 

2D & 3D 定制化资源获取

等技术领域。

在开始之前,请确保您已经具备以下基础知识:

「1. Three.js 基础」

    核心概念掌握:

      场景(

      Scene

      ):3D 空间的容器
      相机(

      Camera

      ):观察场景的视角
      渲染器(

      Renderer

      ):将 3D 场景绘制到屏幕
      几何体(

      Geometry

      ):物体的形状定义
      材质(

      Material

      ):物体的外观特性
      网格(

      Mesh

      ):几何体和材质的组合
推荐学习资源:Bruno Simon 的 

threejs-journey

 课程是非常优秀的入门教程。如果您希望了解我的个人学习路径,欢迎在评论区留言,当评论数达到一定程度时,我会专门撰写一篇详细的学习路线指南。「并且这篇文章所展示的作品会参加 Threejs-journey 在今年 5 月的作品挑战,第一名会得到一个免费的 threejs-journey,我相信这个作品会得到好的名次,并且承诺获得的任何奖品将会在下一篇稀土掘金文章或者沸点评论区中抽取一个已关注的读者赠予」

「2. Shader 编程基础」

    GLSL(OpenGL Shading Language)基础:

      顶点着色器(Vertex Shader):处理顶点位置和属性

      片元着色器(Fragment Shader):处理像素颜色和效果

      Three.js 中的自定义着色器实现

1.Page 预览

话说这天老何在准备去就业市场上看看自己几斤几两, 就和以前还有联系的老同学要了下他们公司 

HR

 的微信。把简历和个人网站发过去了。在经过 10 分钟的漫长等待后,我得到了以下尴尬的画面

那么让我们进入正题,究竟是什么样的网站能够让 HR 小姐姐对吴彦祖的关切问候置之不理 (指 "还在吗?")

这次展示的内容有点多,请见谅 (tips: 靠近带感叹号的物体按下 F 有惊喜哦)

PC 端在线预览地址 (需要魔法):island.vercel.app/

PC 端在线调试界面 (需要魔法):island.vercel.app/#debug

源码地址 (需要魔法):github.com/hexianWeb/i…

「Threejs 转发贴」

2.2D & 3D 资源获取

如果在以前,很难想象这仅仅是一个对建模以及图形设计一知半解的普通程序员能写出的场景。但现在是 AI 时代 (只不过大部分是 AI 拿鞭子抽我,而不是我拿鞭子抽 AI)。所以在低于平均水平的地方统统由

AI

接管。

对于简单场景,资源获取类型分为两类,分别是 2D 资源 & 3D 资源。普通通过资源网站上搜索以及下载的方式我不做过多介绍,详细介绍客制化资源获取 & 处理方式。

3D 「lowpoly」 风格模型网站

    market.pmnd.rs/

    www.kenney.nl/assets

    zsky2000.itch.io/

    poly.pizza/

    sketchfab.com/search?q=lo…

2.1 3D 客制化资源的获取与处理

现在的 

3D AI generation

 技术逐渐趋于成熟,虽然远远达不到工业化以及正规生产环境的水平,但是用来做一些小 demo 还是没问题的。这里附上我经常使用的 AI 3D 模型生成平台 & 生成工作流。
首先我们可以根据 

Blender

 资源库中有的模型资源利用任意 

文生图 or 图生图

模型为我们生成一个简易的游戏画面

就比如以下画面:

在确定场景风格和界面 UI 后,我们就可以开始着手场景搭建工作。在这个过程中,经常会遇到资源库中缺少所需元素的情况。对此,我是通过以下方式解决:

先利用如 

gpt-4o

等一些文生图模型来生成一个背景干净无杂物的 2.5 D lowpoly 风格视图。而在这一步给我最大帮助的是 awesome-gpt4o-images[13] ,「这可以帮助您即使不利用节点式图像生成平台也能生成统一风格且稳定的图片方式」

在这里我们参考 [案例 55:低多边形 (Low-Poly) 3D 渲染 (by @azed_ai[14])](github.com/jamez-bondo…[15]) 的提示词生成一个 

lowpoly

风格的马作为使用案例。相应的提示词为:

    一个 [subject] 的低多边形 3D 渲染图,由干净的三角形面构成,具有平坦的 [color1] 和 [color2] 表面。环境是一个风格化的数字沙漠,具有极简的几何形状和环境光遮蔽效果。

    随后再将这个对应的马导入任意的 

    AI 3D generation

     平台 (这里是后期的何贤: 您现在可以尝试 混元 3D V2.5[, 他提供较多的免费额度 每日 20 次)
    随后你可以很快的到一个 

    lowpoly

    风格的小马雕塑

    这样一来,您就可以在确立统一风格的前提下获取自己需要的 3D 资源,而不是场景中充斥着各种风格迥异的 3D 模型

    2.2 2D 客制化资源的获取与处理

    相信大家 GPT-4o 有着出色的利用 4o 固有的知识库和聊天上下文(包括转换上传的图像或将其用作视觉灵感)统一风格客制化图片生成以及输出能力,这也是我经常使用的统一风格 UI 生成工具。

    以下是我生成 UI 素材的工作流:

    首先我会确定我想要 UI 素材的风格,这里当然也可以通过 awesome-gpt4o-images 生成。让我们先假设我很想要的是 

    pixel

     像素化的 UI,那么我们可以根据以下这张图片作为参考让 

    gpt-4o

     生成图片。
    很明显的表述了我们想要的 UI 风格: 

    多色

    像素化

    规范大小

    随后我们可以输入以下提示词让 

    gpt 4o image

     生成图标墙

      Style the icon in the second photo in the same pixelated style as the icons in the left image. Give it the same style as the image on the right. Solid black background

      🎨 图标墙规划设计

      尺寸建议:正方形或横向 16:9 比例

      背景:纯黑色(#000000

      风格:参考右侧图片作为色系参考 (右侧图片就是我们一开始让 GPT 4o 设计的 游戏 UI 画面)

      🧱 图标排列(4x4 格式)

      照相机 📷 对话框 💬 爱心 ❤️ 数字1️⃣ 

      数字 2️⃣ 数字 3️⃣ 数字 4️⃣ 数字 5️⃣

      数字 6️⃣ 数字 8️⃣数字 7️⃣ 数字 9️⃣

      加号 ➕ 减号 ➖ 斜杠 / 乘号 X

      ✅ 图标样式细节建议

      照相机图标:小巧有镜头感,可添加一点反光效果

      对话框图标:经典漫画气泡形状,边缘加亮色描边

      爱心图标:红紫渐变,带像素锯齿感

      数字:3D立体像素字体风,配亮色阴影

      加减号/斜杠:对称结构,颜色统一为亮青/蓝紫过渡

      现在就生成了如上的图表墙,这个时候只要去除背景就可以使用了。可以使用如

      网站名称

      网站功能

      网站地址

      Remove.bg

      操作简单,专注于背景去除

      www.remove.bg/

      Photoshop

      专业级处理,灵活性高

      需本地安装

      Adobe Express

      集成度高,功能全面

      www.adobe.com/express/

      我这里就使用 remove bg 来实现这个需求

      这样就获得了对应的像素风格 

      UI

      当然如果说不想要图标墙或者使用雪碧图来使用图标的话可以使用 Aspose PNG Splitter[19] 等工具网站来拆分图标。这里就看个人喜好。这些方法特别适用于需要少量定制化 UI 元素的项目,如数据可视化、游戏界面等场景。

      3.场景搭建

      在场景搭建环境环境,我无法给出过多建议,因为这需要即使您已经会使用如

      hyper3D

       或者 

      混元3D

      等 

      AI 3D Generation

       工具,但您仍需要掌握一定程度的 

      blender

      基本使用能力。

      3.1 使用

      blender-mcp

       快速辅助 3D 建模、场景创建和操作

      blender-mcp,他通过 API 添加了对 Poly Haven 资产的支持,并且已经接入了 

      hyper3d

      ,意味着我们可以借用支持 

      MCP Servers

      的任何工具 (如 Cursor、Claude Desktop 以及最近刚刚支持 MCP 的 Trae)。

      Blender MCP

      提供很多 tool, 不仅可以通过其 

      get_scene_info

       tool 来获取当前 Blender 场景的详细信息,还可以通过

      execute_blender_code

       来在 Blender 中执行任意 Python 代码。这意味我们可以在场景物体中较多的情况下利用 

      Cursor

      批量的放置 & 调整物体的位置以及大小,并规范管理所有命名。
      除了这些基本功能外,

      blender-mcp

      拥有 tool 如 

      generate_hyper3d_model_via_images

       & 

      generate_hyper3d_model_via_text

      。意味着他可以直接通过终端传入图片或者文字来生成模型:

      随后可以在 Blender 里直接获得

      看起来还不错,但我仍然只推荐你把这个模型作为一个参考来构建自己的模型,或者单纯的让他充当一个

      地建

      的作用帮你获取构建一个场景的参考素材。

      很遗憾目前为止没有万能的银弹能够支持我们不需要任何学习成本就可以构建一个完整的可用模型所以你仍然需要能靠自己走到这一步

      4.核心代码部分

      在我今年的第一篇

      Threejs

      文章 2025 年了,我不允许有前端不会用 Trae 让页面 Hero Section 变得高级!!!(Threejs) 中提到过

      所以这篇文章我也只会将一部分核心功能是如何实现写出来,而不会从模型导入,光线 & 色彩管理等等功能怎么实现的一一往下叙述。那么 现在让我们进入本项目的代码核心片段。

      4.1 相机 以及 后处理

      让我们现将对应的模型简单的使用 gltf Viewer 进行查看

      但要要实现呈现 2.5D 效果视角的来说,我们需要使用到正交相机而不是透视相机

      此时我们将相机切换为正交相机

              const aspect = this.sizes.aspect

              this.frustumSize = 8

              this.orthographicCamera = new THREE.OrthographicCamera(

                -this.frustumSize * aspect,

                this.frustumSize * aspect,

                this.frustumSize,

                -this.frustumSize,

                -50,

                100,

              )

            this.scene.add(this.orthographicCamera)

        此时场景呈现效果变为

        随后使用合适的后处理优化场景呈现效果。这里我们追求展现出比较老旧游戏机风格的画面,所以我们选择使用 

        RenderPixelatedPass

         后处理效果来处理画面

              this.renderPass = new RenderPass(this.scene, this.camera.instance) 

              // 创建像素化Pass

              this.pixelPass = new RenderPixelatedPass(3this.scene, this.camera.instance)

              this.pixelPass.normalEdgeStrength = 0.53

              this.pixelPass.depthEdgeStrength = 0.4

              // 创建输出Pass用于提亮画面

              this.outputPass = new OutputPass()

              this.outputPass.exposure = 1.2 // 增加曝光度

              this.outputPass.toneMapping = THREE.ReinhardToneMapping // 使用Reinhard色调映射

              this.outputPass.toneMappingExposure = 1.2 // 色调映射曝光度

              this.composer = new EffectComposer(this.renderer)

              this.composer.addPass(this.renderPass)

              this.composer.addPass(this.pixelPass)

              // 此时像素化效果已经生效,但画面比较灰暗 需要提亮

              this.composer.addPass(this.outputPass) // 添加输出Pass

          此时画面就已经有点复古 

          GBA

           彩色游戏机画面的感觉了

          4.2 角色控制 以及 Octree

          在实现类似宝可梦城镇的经典四方向移动体验时,常规的 3D 角色控制器如

          ecctrl

          往往难以满足需求,这是因为它们通常提供的是自由的全方位移动控制。而要实现这种复古风格的受限移动,关键在于对键盘输入的特殊处理和移动方向的精确控制。

            传统 3D 控制器允许 360 度自由移动,而经典 RPG 需要限制为四个基本方向

            常规控制器通常处理的是连续输入,而我们需要离散的方向切换

          「其实是我将角色的移动方向限制在了 "上下左右" 四个方向」。那么我是如何做到的呢?

            首先我们需要让键盘能在合适的时候相应对应的行为

                // 按键按下事件

                window.addEventListener('keydown', (e) => {

                  switch (e.code) {

                    case 'ArrowUp':

                    case 'KeyW':

                      this.actions.up = true

                      this.keys.w = true

                      this.keys.arrowUp = true

                      break

                    case 'ArrowDown':

                    case 'KeyS':

                      this.actions.down = true

                      this.keys.s = true

                      this.keys.arrowDown = true

                      break

                    case 'ArrowLeft':

                    case 'KeyA':

                      this.actions.left = true

                      this.keys.a = true

                      this.keys.arrowLeft = true

                      break

                    case 'ArrowRight':

                    case 'KeyD':

                      this.actions.right = true

                      this.keys.d = true

                      this.keys.arrowRight = true

                      break

                    case 'Space':

                      this.actions.brake = true

                      this.keys.space = true

                      // 跳跃逻辑

                      if (e.code === 'Space' && this.playerOnFloor && !this.character.isSitting) {

                        this.jump()

                      }

                      break

                    case 'KeyR':

                      this.actions.reset = true

                      break

                    case 'KeyZ':

                      this.toggleSit()

                      break

                  }

                })

            在这里有一个小插曲来解释我「为什么使用 event.code 而不是 event.key」

            对于欧洲的玩家比如法国来说他们使用的键盘布局被称为

            AZERTY layout

            ,实际上对应键位如下

            「所以我们需要根据,键位在键盘上的物理位置来确定用户输入的指令。」

            物理键位能确保玩家获得相同的控制体验,无论使用什么语言的键盘。不论是

            AZERTY

            布局,或者我们常用的

            QWERTY

            布局都可以让方向控制保持一致。

            在能够精确的控制当前相应不同地域键盘的指令之后,我们来实现角色的移动逻辑

            移动逻辑相关 

            flowmap

            如下:

                  ┌───────────────┐

                  │ 1. 是否坐下?  │

                  │ isSitting?    │

                  └──────┬────────┘

                         |       │是

                         |       ▼

                         |   [直接返回]

                         │否

                         ▼

                  ┌────────────────────────────┐

                  │ 2. 初始化 moveX, moveZ,    │

                  │    newDirection            │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 3. 是否在地面?             │

                  │ !playerOnFloor             │

                  └──────┬─────────────┬───────┘

                         │否           │是

                         │             ▼

                         │     playerVelocity.y -= GRAVITY * dt

                         ▼

                  ┌────────────────────────────┐

                  │ 4. 计算 speedDelta         │

                  │   (地面上快, 空中慢)         │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 5. 检查按键输入            │

                  │  (WASD/方向键)             │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 6. 有移动输入?             │

                  │ (moveX ≠ 0 || moveZ ≠ 0)  │

                  └──────┬─────────────┬───────┘

                         │否           │是

                         │             ▼

                         │   updateCharacterRotation(调整朝向)

                      |

                         │   行走动画

                         │   playAnimation('walk')

                         │

                         │   playerVelocity.x/z += moveX/Z

                         ▼  ▼

                  ┌────────────────────────────┐

                  │ 7. 没有移动且在地面?       │

                  │ (!isSitting && onFloor)    │

                  └──────┬─────────────┬───────┘

                         │计算完毕      │是

                         │             ▼

                         │     playAnimation('idle')

                         ▼

                  ┌────────────────────────────┐

                  │ 8. 速度阻尼                │

                  │ playerVelocity *= damping  │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 9. 计算位移                │

                  │ deltaPosition =            │

                  │   playerVelocity * dt      │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 10. 移动碰撞体             │

                  │ playerCollider.translate   │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 11. 碰撞检测与修正         │

                  │ playerCollisions()         │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 12. 动画状态更新           │

                  │ updateAnimationState()     │

                  └────────────┬───────────────┘

                               ▼

                  ┌────────────────────────────┐

                  │ 13. 同步模型与碰撞体       │

                  │ updateModelFromCollider()  │

                  └────────────────────────────┘

              现在我们需要引入 

              Octree

              了,他算是在

              threejs

              中常见的碰撞检测方法。但这里我建议不要直接使用场景模型作为 

              Octree

               ,

              Octree

              适合稀疏三维空间,层级分明,而在动态物体多时重建开销大 。我非常推荐你构建一个专属于 

              Octree

               用的碰撞检测用 

              Object 3D

               网格基本体,他要做的事情很简单,只是尽可能的将那些场景中的 "实体" 包裹住,比如当前 ** 视觉场景 (图 1)** 如下:

              那么对应的 ** 碰撞用网格基本体 (图 2)** 为:

              简单的网格基本体能够降低用户设备的性能需求!毕竟不可能要求大家的电脑都是 4090。那么「用户操控的角色实际上更像是在图 2 中的碰撞专用基本体中移动,然后将位置实时映射到视觉场景中的详细模型上。当用户在 "Octree" 的世界中碰到障碍时,则会 “阻碍” 其继续进行移动」

              现在先让我们看看角色控制代码, 从一个 

              tick

              内需要执行那些逻辑我想会更好理解一点 (你可以理解一个

              tick

              就是画面上的 “一帧”,但这种从物理层面是错误的)

                   // 判断是否有移动动作

                    const isAnyMovementKeyPressed = this.actions.up || this.actions.down || this.actions.left || this.actions.right

                    // 角色移动

                    if (!this.character.isSitting) {

                      this.moveCharacter(deltaTime)

                    }

                    else if (!isAnyMovementKeyPressed && this.playerOnFloor) {

                      // 阻尼

                      const damping = Math.exp(-10 * deltaTime) - 1

                      this.playerVelocity.addScaledVector(this.playerVelocity, damping)

                      // 计算位移

                      const deltaPosition = this.playerVelocity.clone().multiplyScalar(deltaTime) //计算实际位移

                      this.playerCollider.translate(deltaPosition) //应用位置更新

                    }

                    this.playerCollisions() //碰撞检测与修正

                    this.updateModelFromCollider() //同步模型和碰撞体

                我们可以看到,当用户在没有坐着时触发移动指令则会调用

                moveCharacter

                 相关逻辑,而当用户停止移动时则会使用户缓慢停下来,这里使用

                Math.exp(-10 * deltaTime) - 1

                来模拟阻尼效果,使得 

                damping

                 得以迅速降为 -1。

                而在之前的角色控制流程图中提到移动的逻辑说白了就是计算位移,移动碰撞体,碰撞检测与修正最后同步模型与碰撞体。

                4.2.1 计算位移

                位移计算逻辑很简单,就像我们正常去操控一个网格基本体在三维空间的位置,速度 X 时间 = 位移 (标量), 确定方向将位移应用到该方向上构成矢量。

                    moveCharacter(deltaTime) {

                      if (this.character.isSitting)

                        return

                      // 计算移动方向

                      let moveX = 0

                      let moveZ = 0

                      let newDirection = null

                      // 重力

                      if (!this.playerOnFloor) {

                        this.playerVelocity.y -= this.GRAVITY * deltaTime

                      }

                      // 速度

                      const speedDelta = deltaTime * (this.playerOnFloor ? 25 : 8)

                      // 用 actions 判断移动

                      if (this.actions.up) {

                        moveZ = -speedDelta

                        newDirection = new THREE.Vector3(001// 朝向-Z

                      }

                      else if (this.actions.down) {

                        moveZ = speedDelta

                        newDirection = new THREE.Vector3(00, -1// 朝向+Z

                      }

                      else if (this.actions.left) {

                        moveX = -speedDelta

                        newDirection = new THREE.Vector3(100// 朝向-X

                      }

                      else if (this.actions.right) {

                        moveX = speedDelta

                        newDirection = new THREE.Vector3(-100// 朝向+X

                      }

                      // 添加速度

                      if (moveX !== 0 || moveZ !== 0) {

                        // 更新角色朝向

                        this.updateCharacterRotation(newDirection)

                        // 只有在地面且不是跳跃时才播放行走动画

                        if (this.playerOnFloor && this.currentAnimation !== this.animations.jump) {

                          this.playAnimation('walk')

                        }

                        // 添加速度

                        if (moveX !== 0) {

                          this.playerVelocity.x += moveX

                        }

                        if (moveZ !== 0) {

                          this.playerVelocity.z += moveZ

                        }

                      }

                      else if (this.playerOnFloor) {

                        // 没有移动时播放待机动画

                        if (!this.character.isSitting && this.currentAnimation !== this.animations.jump) {

                          this.playAnimation('idle')

                        }

                      }

                      // 阻尼

                      const damping = Math.exp(-4 * deltaTime) - 1

                      this.playerVelocity.addScaledVector(this.playerVelocity, damping)

                      // 位置更新

                      const deltaPosition = this.playerVelocity.clone().multiplyScalar(deltaTime)

                      this.playerCollider.translate(deltaPosition)

                      // 动画状态更新

                      this.updateAnimationState()

                    }

                  4.2.2 避免多转半圈

                  「这里唯一要注意的地方就是更新角色朝向我单独拿出来写了」,一般的思路不应该是在明确

                  WSAD

                  对应当前角色的东西南北面朝向前提下将代码写成以下形式吗?

                        if (this.actions.up) {

                          moveZ = -speedDelta

                          newDirection = new THREE.Vector3(001// 朝向-Z

                          newRotation = Math.PI/2//更新旋转

                        }

                        else if (this.actions.down) {

                          moveZ = speedDelta

                          newDirection = new THREE.Vector3(00, -1// 朝向+Z

                          newRotation = -1 * Math.PI/2//更新旋转

                        }

                        else if (this.actions.left) {

                          moveX = -speedDelta

                          newDirection = new THREE.Vector3(100// 朝向-X

                          newRotation = 0//更新旋转

                        }

                        else if (this.actions.right) {

                          moveX = speedDelta

                          newDirection = new THREE.Vector3(-100// 朝向+X

                          newRotation = Math.PI //更新旋转

                        }

                    但这种代码会在用户角色在从

                    +Z

                    轴转向 

                    -X

                    轴时出现「多转半圈」的问题, 因为我们转向用程序写出来就是从 

                    newRotation = -1 * Math.PI/2

                     状态渐变到 

                    newRotation = Math.PI

                    状态 ,不经过特殊处理他一定会有一种情况如下图 先从 -π/2 到 0 再到 π。

                    实际画面为

                    为了避免这种情况我们需要将转向限制在 [-PI, PI] 之间

                        updateCharacterRotation(newDirection) {

                          if (!newDirection || this.character.currentDirection.equals(newDirection))

                            return

                          // Store new direction

                          this.character.currentDirection = newDirection

                          // Calculate the appropriate rotation based on direction

                          let targetRotation = 0

                          if (newDirection.z === -1) {

                            targetRotation = 0 // Facing -Z

                          }

                          else if (newDirection.z === 1) {

                            targetRotation = Math.PI // Facing +Z

                          }

                          else if (newDirection.x === -1) {

                            targetRotation = Math.PI / 2 // Facing -X

                          }

                          else if (newDirection.x === 1) {

                            targetRotation = -Math.PI / 2 // Facing +X

                          }

                          // Get the current rotation

                          const currentRotation = this.character.instance.rotation.y

                          // Calculate the difference between the current rotation and the target rotation

                          let deltaRotation = targetRotation - currentRotation

                          // 归一化 deltaRotation 到 [-PI, PI] 区间

                          deltaRotation = ((deltaRotation + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI

                          // Calculate the new target rotation

                          const newTargetRotation = currentRotation + deltaRotation

                          // Animate rotation

                          gsap.to(this.character.instance.rotation, {

                            y: newTargetRotation,

                            duration0.2,

                            ease'power1.out',

                          })

                        }

                      这样一来就不会多转半圈了

                      4.2.3 碰撞检测和修正

                      _移动碰撞体_没什么好讲的,仅仅只是应用矢量到对应的对象上,让我们集中在碰撞检测上。首先我们得使用 

                      Octree

                      基于我们创建的碰撞专用基本体构建八叉树

                      Octree

                       提供给我们 

                      fromGraphNode

                      从 three.js 的 Object3D(通常是 Mesh 或 Group)中提取所有三角形,构建八叉树。

                        import { Octree } from 'three/addons/math/Octree.js'   

                            this.worldOctree = new Octree()

                            setupCollider() {

                                // Initialize octree from the collision model

                                if ("碰撞专用网格基本体") {

                                  this.worldOctree = new Octree()

                                  this.worldOctree.fromGraphNode("碰撞专用网格基本体")

                                }

                              }

                        随后为用户角色创建胶囊体

                          import { Capsule } from 'three/addons/math/Capsule.js'

                          this.playerCollider = new Capsule(

                                new THREE.Vector3(02.350),

                                new THREE.Vector3(030),

                                0.35,

                          )

                          此时场景实际上在 

                          Octree

                           那边可能是这样的

                          用户角色的碰撞体积以胶囊体为准,而场景的碰撞体则以图中黑色建筑为准。

                          而碰撞检测则需要用到

                          Octree

                          提供的另一个方法

                          capsuleIntersect

                           : 检测胶囊体与八叉树内所有三角形的碰撞,返回碰撞法线和深度。

                              playerCollisions() {

                                const result = this.worldOctree.capsuleIntersect(this.playerCollider) //检测胶囊体与八叉树内所有三角形的碰撞,返回碰撞法线和深度。

                                this.playerOnFloor = false

                                if (result) {

                                  this.playerOnFloor = result.normal.y > 0

                                  // Adjust position to prevent clipping

                                  if (result.depth >= 1e-10) {

                                    this.playerCollider.translate(result.normal.multiplyScalar(result.depth))

                                  }

                                }

                              }

                            那么他是如何起作用的呢?

                            正如前面提到

                            capsuleIntersect

                             : 检测胶囊体与八叉树内所有三角形的碰撞,返回碰撞法线和深度,则「当有返回法线矢量,则证明胶囊体与墙壁碰撞。接着让角色朝法线方向移动来抵消掉用户向墙里走的位移, 从而避免穿模的出现」

                                      this.playerCollider.translate(result.normal.multiplyScalar(result.depth))

                              如果你想了解更多的

                              Octree

                              碰撞检测逻辑,我推荐你看 

                              threejs

                              的 这个官方用例[23]

                              4.3. 岩浆与海

                              最后是场景中的水体,比如说岩浆和海,这里我简单拿岩浆举例

                              通常这种风格的水体在行业内被称为 “风格化水体 (stylized water | toon water) ”。你可以通过搜索这些关键词来获取不一样的水体风格,让我们来分析做这样一个水体需要经理那些步骤。

                              Step1: 我们需要一个水面纹理

                              Step2: 在靠近水体边缘的地方加入渐变浮沫

                              Step3: 让水面真正流动起来

                              4.3.1 水面纹理

                              首先我们需要让一个平平无奇的平面看起来像是水面

                              实现这种水面的方式由很多种,比如你可以在网上寻找这种水面遮罩素材

                              或者自己选择一种噪声来模拟水面,比如 这篇文章[24] 使用柏林噪声来模拟水面效果

                              而我使用的则是「蜂窝噪声 (Cellular Noise)」, 也被称为网格噪声。网格噪声基于距离场,这里的距离是指到一个特征点集最近的点的距离。这里同样为你奉上由

                              ShaderBook

                              写的蜂窝噪声相关文章[25], 但我仍会简单阐述其实现原理。

                              首先我们需要知道什么是距离场,即为 (SDF),定义说有符号距离场 (SDF) 是计算机图形学中常用于渲染的形状的数学表示。它是一个函数,接收空间中的一个点,并返回到该形状表面上最近点的距离,并用符号表示该点位于形状内部还是外部:

                                如果值为负,则该点位于形状内部。

                                如果值为零,则该点位于形状的表面上。

                                如果值为正,则该点位于形状之外。

                              让我们来看以下代码,

                                float sdfCircle(vec2 center, float r, vec2 pos) {

                                  return distance(center, pos) - r;

                                }

                                void main() {

                                  vec2 uv = gl_FragCoord.xy;

                                  float t = sdfCircle(iResolution * 0.5, iResolution.y * 0.4, uv);

                                  gl_FragColor = vec4(vec3(t), 1.0);

                                }

                                其中 

                                iResolution * 0.5

                                 就是画布中心的位置,而 

                                iResolution.y * 0.4

                                 则代表值为当前画布高度的 

                                0.4

                                 。

                                distance(center, pos) - r

                                 就是计算当前画布上的每一个点 

                                pos

                                 到画布中心 

                                center

                                 的举例跟 

                                iResolution.y * 0.4

                                 谁大谁小,举例
                                  当点 

                                  pos

                                   的距离小于 

                                  iResolution.y * 0.4

                                   时,则

                                  t

                                  为负数,此时

                                  gl_FragColor = vec4(vec3(t), 1.0);

                                   就是

                                  gl_FragColor = vec4(vec3(负数), 1.0);

                                   显示成黑色。
                                  当点 

                                  pos

                                   的距离大于 

                                  iResolution.y * 0.4

                                   时,则

                                  t

                                  为正数,此时

                                  gl_FragColor = vec4(vec3(t), 1.0);

                                   就是

                                  gl_FragColor = vec4(vec3(正数), 1.0);

                                   显示成灰色或白色。

                                最后得出的效果如下:

                                这是个很简单的理论,但也是蜂窝噪声的基石。再让我们会看这句话「计算它们与当前像素的距离并存储最接近的值」

                                假设现在我们平面上有 N 个 "中心点",那么我们现在要做的事分别求出单个片元到所有 "中心点" 的距离并求出最小值

                                  uniform vec2 iResolution;

                                  uniform float iTime;

                                  vec2 points[9];

                                  void init() {

                                    points[0] = vec2(0.05,0.15);

                                    points[1] = vec2(0.35,0.27);

                                    points[2] = vec2(0.78,0.04);

                                    points[3] =  vec2(0.25,0.46);

                                    points[4] = vec2(0.50,0.55);

                                    points[5] = vec2(0.91,0.37);

                                    points[6] = vec2(0.28,0.67);

                                    points[7] =  vec2(0.53,0.76);

                                    points[8] = vec2(0.73,0.75); 

                                  }

                                  vec2 getPoint(int index) {

                                    return sin(points[index] * 6.28 + iTime / 3.0) * 0.5 + 0.5;

                                  }

                                  void main() {

                                    init();

                                    vec2 uv = gl_FragCoord.xy / iResolution.xy;

                                    float m_dist = 1.0;

                                    for (int i = 0; i < 9; i++) {

                                      float dist = distance(uv, getPoint(i));

                                      m_dist = min(m_dist, dist);

                                    }

                                    gl_FragColor = vec4(0.0, m_dist * 2.250.01.0);

                                  }

                                  那么随着特征点越来越多,水面的效果也就随之显现了

                                  随后可以使用灰度作为混合因子,混合水面颜色和浮沫颜色即可,这点也非常简单

                                            uniform vec3 color1;  // 水色

                                            uniform vec3 color2;  // 白色

                                    片元着色器

                                              void main() {

                                                init();

                                                vec2 uv = gl_FragCoord.xy / iResolution.xy;

                                                float m_dist = 1.0;

                                                // 计算点的效果

                                                for (int i = 0; i < 11; i++) {

                                                  if (float(i) >= numPoints) break;

                                                  float dist = distance(uv, getPoint(i));

                                                  m_dist = min(m_dist, dist);

                                                }

                                      float factor = smoothstep(0.050.3, m_dist); 

                                                // 使用 factor 作为混合因子, 混合两种颜色

                                                vec3 waterColor = mix(color1, color2,factor);

                                                gl_FragColor = vec4(waterColor, 1.0);

                                              }

                                      「当然你现在可以增加多个特征点或者操控 uv 做出水面蠕动特效」,但我们后续会使用

                                      flowmap

                                      就不在这里操作了。

                                      4.3.2 边缘浮沫

                                      在水面边缘存在一圈白色的浮沫,这是一种 

                                      hack

                                      手段的模拟菲涅尔现象。我不推荐你学这一块,因为他的应用场景仅仅只在这个案例中,所以我只贴上基本的

                                      shader

                                      代码

                                      「顶点着色器」

                                                // Calculate edge glow effect

                                                float getEdgeGlow(vec2 uv) {

                                                  // Calculate distance to edge

                                                  float distToEdge = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));

                                                  // Use smooth step function to create a soft transition

                                                  return 1.0 - smoothstep(0.0, edgeWidth, distToEdge);

                                                }

                                        「片元着色器」

                                                  void main() {

                                                    init();

                                                    vec2 uv = gl_FragCoord.xy / iResolution.xy;

                                                    float m_dist = 1.0;

                                                    // Calculate point effect

                                                    for (int i = 0; i < 9; i++) {

                                                      if (float(i) >= numPoints) break;

                                                      float dist = distance(uv, getPoint(i));

                                                      m_dist = min(m_dist, dist);

                                                    }

                                                    // Calculate base color

                                                    vec3 baseColor = vec3(m_dist * colorIntensity);

                                                    // Add edge glow

                                                    float edgeGlow = getEdgeGlow(uv) * edgeIntensity; // 获取边缘强度做混合因子

                                                    // Mix base color and edge glow

                                                    vec3 finalColor = mix(baseColor, vec3(1.0), edgeGlow);

                                                    gl_FragColor = vec4(finalColor, 1.0);

                                                  }

                                          4.3.3 流动水面

                                          这里我要向你介绍游戏中流动水体或者模拟流体的一种常见方法: 

                                          flowmap

                                          。这里的

                                          flowmap

                                          指代的并不是程序中的流程图,而是「一张记录了 2D 向量信息的纹理 Flow map 上的颜色(通常为 RG 通道)记录该处向量场的方向,让模型上某一点表现出定量流动的特征」
                                          接下来我会向你解释 

                                          flowmap

                                          是如何工作的,以及如何低成本的构建一个

                                          flowmap

                                          。最后我会想你讲述如何将

                                          flowmap

                                          使用在 

                                          threejs

                                          4.3.3.1 

                                          flowmap

                                          的原理
                                          ** 将

                                          2D

                                          向量场信息编码到纹理的红色通道 & 绿色通道中,每个纹素代表一个流动方向向量,通过在 shader 中偏移 uv 再对纹理进行采样,来模拟流动效果。** 这就是

                                          flowmap

                                          的作用。

                                          通俗地说,当片元着色器读取 Flowmap 上某一点的颜色值时:

                                            红色分量 (R) 代表 X 轴方向的流动

                                            绿色分量 (G) 代表 Y 轴方向的流动

                                            颜色值的大小决定了流动的强度

                                          接下来让我们来理解这段话。我们使用一个如图所示的

                                          flowmap

                                          ,观察水面的流动方向。

                                          可以看到水面为橙色的会向右移动,水面为绿的则会向左移动.

                                          4.3.3.2 低成本的构建一个

                                          flowmap

                                          如果要构建一个正规的工业化 

                                          flowmap

                                           ,其实最好选用如 Houdini[26] 等一些专业的软件,但是如果不想学习 

                                          Houdini

                                           或者,需要再最短时间内拿出一个可运行的

                                          demo

                                           ,可以试试以下两个工具中的一个。

                                            cables.gl[27] - 基于节点的可视化编程工具

                                            FlowMap Painter[28] - 专门的 Flowmap 绘制工具

                                          这里我简单使用 cables.gl[29] , 构建一个 

                                          flowmap

                                          就比如这样 

                                          得到可用的效果后将 

                                          Flowmap Visualize

                                           的值降低为 

                                          0

                                           ,随后点击下载就获得你想要的

                                          flowmap

                                          了。
                                          4.3.3.3 如何在

                                          Threejs

                                          中使用

                                          flowmap

                                          第一种方法是使用 

                                          Threejs

                                           中自带的 

                                          Water2

                                           类,详情可以参考 Threejs Flowmap 官网案例[30]

                                          核心代码如下:

                                            // 创建水面几何体

                                            const waterGeometry = new THREE.PlaneGeometry(1010);

                                            // 加载Flowmap纹理

                                            const flowMap = textureLoader.load('textures/water/flowmap.png');

                                            // 创建水面效果

                                            water = new Water(waterGeometry, {

                                              scale1,

                                              textureWidth1024,

                                              textureHeight1024,

                                              flowMap: flowMap

                                            });

                                            // 设置位置和旋转

                                            water.position.y = 1;

                                            water.rotation.x = Math.PI * -0.5;

                                            scene.add(water);

                                            随后就会在平面上生成一个带有 flowmap 流动效果的透明平面

                                            但我们这里使用的是第二种方法, 即为实打实的利用

                                            fragment shader

                                            操控

                                            uv

                                            来达到流动效果

                                              // 从Flowmap获取流动向量(值范围从[0,1]映射到[-1,1])

                                              vec2 flow = texture2D(flowMap, vUv).rg * 2.0 - 1.0;

                                              // 计算时间相关的流动偏移量

                                              vec2 flowOffset = flow * flowSpeed * iTime;

                                              // 应用偏移到UV坐标

                                              vec2 uv = vUv + flowOffset;

                                              最后来的效果为

                                              具体效果还需要根据你自己的个人喜好来调整。完整实现代码可参考项目源码库。通过调整 Flowmap 纹理和着色器参数,您可以创建从平静水面到湍急河流等各种水体效果。

                                              5.心路历程

                                              在过去几个月的持续创作中,我收获了来自多个平台的关注和鼓励,甚至包括一些我曾视为行业偶像的人士的认可。这些支持让我既感到荣幸,也时常感到惶恐。

                                               但我时常看着平台消息通知倍感压力,我认为我远远没有达到能够 “系统化教授别人”

                                              threejs

                                              的地步。

                                              我必须坦诚地承认:

                                                我并非科班出身的专业开发者

                                                我的 Three.js 知识体系还存在许多不足

                                                持续的高质量输出对我而言是巨大的挑战

                                              这也是我想说的,我认为后续我会暂停目前 Three.js 技术文章的定期更新计划。没准会变回曾经的年更博主。但这并不意味着我会停止创作:

                                                我的 GitHub 仓库仍会持续更新有趣的项目

                                                社交媒体账号会分享新的探索和发现

                                                当有真正值得分享的内容时,我依然会撰写文章

                                              5.最后的一些话

                                              技术的未来与前端迁移

                                              随着 AI 技术的快速发展,各类技术的门槛正在大幅降低,以往被视为高门槛的 

                                              3D

                                               技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的 

                                              3D generation

                                               技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。

                                              本专栏的愿景

                                              本专栏的愿景是通过分享 

                                              Three.js

                                               的中高级应用和实战技巧,帮助开发者更好地将 

                                              3D

                                               技术应用到实际项目中,打造令人印象深刻的 

                                              Hero Section

                                              。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 

                                              Web3D

                                               技术的普及和应用。
                                              此外,如果您很喜欢 

                                              Threejs

                                               又在烦恼其原生开发的繁琐,那么我诚邀您尝试 Tresjs 和 TvTjs, 他们都是基于 

                                              Vue

                                               的 

                                              Threejs

                                               框架。 TvTjs 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!


                                              关注更多AI编程资讯请去AI Coding专区:https://juejin.cn/aicoding

                                              ""~

                                              阅读原文

                                              跳转微信打开

                                              Fish AI Reader

                                              Fish AI Reader

                                              AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

                                              FishAI

                                              FishAI

                                              鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

                                              联系邮箱 441953276@qq.com

                                              相关标签

                                              Three.js 3D场景 AI建模 Shader编程 游戏开发
                                              相关文章