原创 何贤 2025-05-26 08:31 重庆
点击关注公众号,“技术干货” 及时达!前置条件hello! 欢迎阅读本篇文章!这篇文章会探讨如何高定制化地构建一个自己喜欢的 3D 场景。
点击关注公众号,“技术干货” 及时达!
前置条件
Three.js
、Shader(GLSL)
、Cursor rules & MCP Servers
以及2D & 3D 定制化资源获取
等技术领域。在开始之前,请确保您已经具备以下基础知识:
「1. Three.js 基础」
核心概念掌握:
Scene
):3D 空间的容器Camera
):观察场景的视角Renderer
):将 3D 场景绘制到屏幕Geometry
):物体的形状定义Material
):物体的外观特性Mesh
):几何体和材质的组合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
接管。对于简单场景,资源获取类型分为两类,分别是 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] ,「这可以帮助您即使不利用节点式图像生成平台也能生成统一风格且稳定的图片方式」lowpoly
风格的马作为使用案例。相应的提示词为:一个 [subject] 的低多边形 3D 渲染图,由干净的三角形面构成,具有平坦的 [color1] 和 [color2] 表面。环境是一个风格化的数字沙漠,具有极简的几何形状和环境光遮蔽效果。
AI 3D generation
平台 (这里是后期的何贤: 您现在可以尝试 混元 3D V2.5[, 他提供较多的免费额度 每日 20 次)lowpoly
风格的小马雕塑这样一来,您就可以在确立统一风格的前提下获取自己需要的 3D 资源,而不是场景中充斥着各种风格迥异的 3D 模型
2.2 2D 客制化资源的获取与处理
相信大家 GPT-4o 有着出色的利用 4o 固有的知识库和聊天上下文(包括转换上传的图像或将其用作视觉灵感)统一风格客制化图片生成以及输出能力,这也是我经常使用的统一风格 UI 生成工具。
以下是我生成 UI 素材的工作流:
pixel
像素化的 UI,那么我们可以根据以下这张图片作为参考让gpt-4o
生成图片。多色
像素化
规范大小
。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 建模、场景创建和操作
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(3, this.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
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(0, 0, 1) // 朝向-Z
}
else if (this.actions.down) {
moveZ = speedDelta
newDirection = new THREE.Vector3(0, 0, -1) // 朝向+Z
}
else if (this.actions.left) {
moveX = -speedDelta
newDirection = new THREE.Vector3(1, 0, 0) // 朝向-X
}
else if (this.actions.right) {
moveX = speedDelta
newDirection = new THREE.Vector3(-1, 0, 0) // 朝向+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(0, 0, 1) // 朝向-Z
newRotation = Math.PI/2//更新旋转
}
else if (this.actions.down) {
moveZ = speedDelta
newDirection = new THREE.Vector3(0, 0, -1) // 朝向+Z
newRotation = -1 * Math.PI/2//更新旋转
}
else if (this.actions.left) {
moveX = -speedDelta
newDirection = new THREE.Vector3(1, 0, 0) // 朝向-X
newRotation = 0//更新旋转
}
else if (this.actions.right) {
moveX = speedDelta
newDirection = new THREE.Vector3(-1, 0, 0) // 朝向+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,
duration: 0.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(0, 2.35, 0),
new THREE.Vector3(0, 3, 0),
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] 使用柏林噪声来模拟水面效果
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.25, 0.0, 1.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.05, 0.3, m_dist);
// 使用 factor 作为混合因子, 混合两种颜色
vec3 waterColor = mix(color1, color2,factor);
gl_FragColor = vec4(waterColor, 1.0);
}
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 绘制工具
flowmap
。就比如这样
Flowmap Visualize
的值降低为0
,随后点击下载就获得你想要的flowmap
了。4.3.3.3 如何在
Threejs
中使用flowmap
Threejs
中自带的Water2
类,详情可以参考 Threejs Flowmap 官网案例[30]。核心代码如下:
// 创建水面几何体
const waterGeometry = new THREE.PlaneGeometry(10, 10);
// 加载Flowmap纹理
const flowMap = textureLoader.load('textures/water/flowmap.png');
// 创建水面效果
water = new Water(waterGeometry, {
scale: 1,
textureWidth: 1024,
textureHeight: 1024,
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.最后的一些话
技术的未来与前端迁移
3D
技术也不例外。与此同时,过去困扰开发者的数字资产构建成本问题,也正在被最新的3D generation
技术所攻克。这意味着,在不久的将来,前端开发将迎来一次技术迁移,开发者需要掌握更新颖的交互方式和更出色的视觉效果。本专栏的愿景
Three.js
的中高级应用和实战技巧,帮助开发者更好地将3D
技术应用到实际项目中,打造令人印象深刻的Hero Section
。我们希望通过本专栏的内容,能够激发开发者的创造力,推动Web3D
技术的普及和应用。❝此外,如果您很喜欢❞Threejs
又在烦恼其原生开发的繁琐,那么我诚邀您尝试 「Tresjs」 和 「TvTjs」, 他们都是基于Vue
的Threejs
框架。 「TvTjs」 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!
关注更多AI编程资讯请去AI Coding专区:https://juejin.cn/aicoding
点击"阅读原文"了解详情~