普通视图

发现新文章,点击刷新页面。
昨天以前首页

用 Three.js 写了一个《我的世界》,结果老外差点给我众筹做游戏?

作者 何贤
2026年3月13日 10:07

用 Three.js 写了一个《我的世界》,结果老外差点给我众筹做游戏?

logo.png

0.失踪人员回归?

注意看,这个男人叫小帅,他莫名其妙地躺在你的关注列表里。

“爷爷,你关注的掘友更新了!!!”

距离上一次发文已经接近两个月。这段时间里,在群友们持续不断的催更下,我一边理直气壮地鸽着,一边反复安慰自己:

一转眼年都过完了,文章却一笔没动。
看着粉丝数慢慢上涨,再看看那个依然空白的文档,说不慌那肯定是假的。

不行啊老何!

你当初的目标是什么?——攒够粉丝圈米,咳咳。

把 WEB3D 带出小圈子,推到更多人面前!

Snipaste_2026-03-11_16-29-03.png

1. 为什么要做这个项目?

路人:诶,老何。之前文章你不是说不再出游戏了吗?

老何:2025 年老何说的话,关 2026 年的老何什么事?

随着各家旗舰模型的军备竞赛不断升级—— Gemini 3.1 Pro、GPT 5.4、Claude 4.6 opus 等模型已经能够很好地处理常规前端任务。

传统的前端 benchmark 开始逐渐失去区分度,于是评测者把目光转向了 Web3D 与复杂前端动画,把它们当作新的能力试金石。

在 Twitter 上,你大概经常能看到这种内容:

“我让 AI 写了一个 Minecraft clone。”

然后评论区就会开始出现熟悉的言论:

Frontend is cooked. 前端已死。

01_new.png

老实说,《我的世界》是我初高中最喜欢的游戏,陪我度过了非常艰难的时光;而 Three.js 则是我工作后最热爱的技术方向。在我看来,这些 Demo 既没有真正的技术深度,也谈不上视觉表现力。但它们却常被拿来作为“前端已死”的论据。

先不论第一人称的 Minecraft 风格体素引擎早在几年前就有开源实现,现在 AI 构建的所谓逻辑 (单从以上构建的作品来看),更多还是简单的代码搬运。在下一次旗舰模型迭代前,没有的功能依然出不来,缺乏真正的创新;更致命的是,目前 AI 直接生成的画面实在差强人意,很难称得上“好看”。

我很好奇:纯 AI vibe codingThree.js 高级开发者 + AI 辅助Web3D 领域到底能拉开多大的差距。

于是这个项目诞生了。

Third-Person-MC

2. 项目全貌(源码)

多图警告⚠️,以下章节会出现大量 GIF 以及 图片,注意自己流量哦

这个项目其实是一个完整的 第三人称 Minecraft 风格沙盒原型,包含:

  • 完整菜单系统
  • 第三人称角色控制
  • 相机系统
  • 昼夜循环
  • 挖掘与放置
  • 敌人系统
  • 程序化地形
  • 多生态系统

接下来简单介绍几个核心部分。

2.1 游戏前厅:完善的菜单与配置系统

游戏启动后会进入一个完整的 主菜单界面

这里包括:

  • 皮肤选择
  • 游戏设置
  • 新手说明

其中 皮肤选择器 是一个独立的 Three.js 场景,用来实时预览角色模型与光照效果。

玩家切换皮肤时,材质会即时更新。

游戏配置则通过 Pinia 统一管理,例如:

  • 渲染距离
  • 鼠标灵敏度
  • 画面参数

修改后通过 mitt 事件总线 实时同步到 Three.js 渲染系统。

主菜单 玩法说明
02.png 02-2.png
皮肤选择器 设置菜单
02-3.png 02-04.png

2.2 第三人称角色控制

为了在 Web 端提供媲美原生端游的操控手感,项目打造了完善的第三人称八向移动系统。根据移动状态自动切换姿态,走路、奔跑、跳跃,动作连贯自然。

无论你想仔细游览还是急速赶路,移动的快慢都能完美匹配你的心意,当你想快速穿梭在这片广阔天地时,只需按下 Shift 键,角色便会瞬间提速,进入敏捷的全力奔跑状态;如果遇到危险的边缘或需要小心翼翼穿过的狭窄通道,按下 V 键,角色就会压低身姿,以最慢的速率悄悄蹲行。不同的姿态有着完全不同的移动效率。

你完全不必担心角色的动作看起来生硬或像是一个木偶。无论你是从静止突然起跑、在狂奔中纵身一跃,还是从半空中稳稳落地,角色的所有动作都会在后台进行自动混合与无缝联动。没有突兀的闪烁与卡顿,起步时的加速、停下时的缓冲,一切动作的切换都如同真实世界里的惯性一样自然柔和。

行走状态 奔跑状态
八向移动-走路.gif 八向移动-奔跑.gif
潜行状态 状态丝滑过度
八向移动-潜行.gif 八向移动-状态.gif

2.3 第三人称相机

相机系统支持多种视角模式:

  • 越肩视角
  • 背身视角
  • 望远视角

同时实现了两个关键功能:

1 相机碰撞检测

防止相机被地形方块遮挡。

2 地形自适应

当玩家靠近墙体或山体时,相机会自动调整距离。

左侧越肩(Q键) 右侧越肩(E键)
左侧越肩.gif 右侧越肩.gif
背身视角( ~ 键) 地形自适应
背身视角.gif 地形适应.gif

所有参数都可以在 调试面板实时修改,方便二次开发。

相机配置.png

2.4 昼夜循环与环境系统

绷不住了 掘金能不能把视频上传修一下,这 15 MB画质压缩成啥了

在上述演示片段中您可能已经注意到了这一点,画面中的背景似乎在发生变化。 没错,在此项目中我加入了昼夜系统,每一轮昼夜和 Minecraft 游戏中的昼夜时长一致,随着时间流逝,周遭雾气&阳光都会发生变化。

是的,游戏里的一天并不只是黑白交替,而是被精心切分成了午夜、日出、早晨、正午、下午、日落、黄昏 7 个不同氛围的时段。随着时间推移,系统会自动在这 7 个阶段中平滑过渡。当然了好看的天空还不够,整个世界的环境必须跟着走。太阳光会东升西落,光线从清晨的暖黄变成正午的炽白;到了夜晚,幽蓝的月光会自动亮起。不仅如此,就连远处的环境雾气也会跟着天空变色——比如日落时会有粉红色的雾,深夜则是深邃的黑雾以及周遭会出现萤火虫。

昼夜轮询.gif

2.5 挖掘与放置

既然是 Minecraft style 的沙盒游戏,当然少不了传统的挖掘与放置啦!

在游戏中,玩家可以通过屏幕中心的准星,自由地改造整个方块世界。整个交互过程非常自然且直观,主要分为三个步骤:

  • 游戏会时刻追踪你准星看向的位置。当你靠近并看向某个方块时,系统立刻就能识别出你盯住的是哪一块、甚至是方块的哪一个面,并在该方块上显示高亮边框。
  • 想要开采资源或清理地形?对准方块长按鼠标左键即可。为了还原真实的劳作感,挖掘并不是瞬间完成的,而是会在准星处伴随一个“进度环”。只有你保持准星不移开并按住鼠标直到进度读满,方块才会被敲碎。如果中途松手或视线移开,挖掘动作就会被打断。
  • 建造同样简单。你只需要在屏幕下方的快捷栏中选中想要的方块,对准已有方块的表面点击鼠标右键,新方块就会顺理成章地“贴”在那个表面上。每次放置都会实时扣除你背包快捷栏中的库存数量。
挖掘系统 放置系统
挖掘2.gif 放置.gif

2.6 敌人与战斗

如果只是挖挖东西,探索一个无尽的开放世界,似乎上述各系统已经足够支持这个项目进行游玩,但我并不是和平模式的狂热爱好者,所以——

当夕阳沉入地平线,这个广阔的方块世界就不再绝对安全。我在游戏中引入了基础的敌人系统——随着午夜降临,潜伏在黑暗中的僵尸就会悄然现身。当它们逼近时,你甚至能从屏幕视觉上感受到那种心跳加速的“压迫感”。你必须利用灵活的跑位、借助你搭建的地形来保护自己,捱过漫漫长夜。

僵尸游荡 僵尸追逐
僵尸游荡.gif 僵尸追逐.gif
战斗系统 僵尸消失
战斗系统.gif 僵尸消失.gif

敌人系统在这个项目中没有设置的特别难,它们只会在距离你不远不近的暗处悄悄出现,给你留出充足的反应和应对时间。系统最多只会允许少数几只僵尸在你的周围游荡,它们会每隔几秒钟接连出现,保持着持续但可控的压迫感。如果你觉得夜晚的野外太危险,大可以直接跑开。只要你拉开了足够的距离,或者硬挺到了黎明破晓、白天到来,那些追逐你的僵尸就会自发地消散在空气中。

当然本项目为了方便大家在此基础上二开将体素物理碰撞引擎敌人实体管理逻辑抽离并构建了单独的类组件。如果您后续向自己加入一些特别的怪物都可以很快将其集成到项目中

敌人.png

2.7 地形 & 生态系统

分区块和无尽地形应该是所有 Minecraft Style 构建的开放世界都实现的功能,有太多优秀的开源项目实现了这一点,但包括融合生态系统等功能。我没办法说我的实现方式最优秀或者最优雅。但应该会是最不会让你感到卡顿的方案。这也是后续我出系列文章时重点会解释的一部分。

这篇介绍文还是让我们少聊一点技术话题,下面是地形和生态系统的介绍。

生态系统

关于生态系统,项目中构建了包含 平原、白桦林、樱花树林、沙漠、恶地、冻洋等多种生态系统

平原 白桦木林
平原.png 白桦林.png
樱花树林 沙漠
樱花林.png 沙漠.png
恶地 冻洋
恶地.png 冻洋.png
森林 生态地形混合
森林.png 生态混合.png
无尽地形

地形生成

本项目中关于地形的生成采用的是比较大众的 SimplexNoise + FBM + PRNG 方案。对于不那么理解的朋友在这里做简短介绍,后续文章针对这一章节会有详细的实现原理解析

  • RNG在此处并不是皇族的简写,而是指 Random Number Generator。玩过我的世界都知道seed的概念,而在本项目中 RNG 就承担了原版 MC 世界中 seed的角色 ,不同与常规 的Math.random, RNG 产出的结果虽然看起来随机,但在传入相同的值时,产出的结果却是可以复现的,正因为他的存在,只要你把世界的种子码分享给朋友,不管你们相隔多远,你们在这个世界里看到的每一座高山、每一条沟壑,都会长得一模一样。这也为后续可扩展 ws 联机留下可能。

  • SimplexNoise + fbm 构建的地形则显得更加真实,这里可以简单的理解为 SimplexNoise 负责的是粗略的山峰和山谷的构建,而 fbm 则负责在这个大体基础上构建更加真实的地理细节

  • 最后,我同样在调控模式下增加了大量的可调控面板,方便您进行二次开发。

seed 同地形复现 fbm细节调控
PRNG.gif fbm.gif
区块加载 丰富调控面板
区块.gif 调控面板.png

2.8 源码地址 & 项目地址

当然这个项目还有很多我没有提及的小细节,比如程序化生成树、成就系统、专属与这个项目的事件监控系统等。这里为了不占用篇幅就不在这里写了。

熟悉我的老粉依旧知道这里是贴转发贴的环节。因为这个项目在开发过程中已经被很多官推和大佬们转发过,这里我就不一一贴出来了。

twitter-02.png

PC端在线预览地址(需要魔法): third-person-mc.vercel.app/

DeBug 在线调试界面: third-person-mc.vercel.app/#debug

Github 仓库地址: github.com/hexianWeb/T…

3.事情开始变得离谱

那么在介绍完所有的内容后,该回答一下标题了。目前看来这只是一个比较好的 Three.js 作品,那么目前来看与标题毫不相干啊?老何你也为了流量当起了标题党?

让我们将时间调回到 2026 年 1 月底,当时处于项目初期,大部分时候都是在做 Demo 验证,并根据早期的PRD.md逐步进行功能开发与完善。

在这期间收获了很多鼓励和认可,每次发帖都会被 Three.js 官推转发,与群友分享获得群友认可。刚好当时也是年尾没有什么工作,正反馈拉满 + 空余时间 结合,开发效率自然高的飞起。

直到某一天早上。

我像往常一样打开 Twitter。

结果通知栏直接炸了。

那条项目展示的推文,在短时间内获得了 10 万+ 浏览,还被很多我喜欢的博主转发。

但真正让我懵的是一条评论:

嘿,伙计,你知道有些人正在为你的项目进行众筹吗?

crowdfunding-01.png

我当时一脸问号。

众筹?众筹什么?谁众筹?我吗?

Snipaste_2026-03-12_19-08-18.png

接下来的十个小时里,评论区越来越离谱。

很多人开始提醒我:

“有人真的在给这个项目众筹。”

甚至有人催我去 claim 众筹页面

骗局.png

我该怎么办?我该在当下去用社交媒体 claim(声明)这个项目开始众筹吗?这么多人喜欢这个项目,我要做的不应该是顺应这个情怀市场?让更多的人加入进来,水涨船高然后发一笔横财?

或许它能成为我的副业?毕竟我真的很讨厌打工?有机会干自己喜欢的事情这也太爽了吧!

但想了一会儿之后,我还是做了一个决定:

不认领。

因为这个项目从一开始就只是一个 开源项目

我也不想成为那种靠情怀众筹然后跑路的开发者。

于是我在评论区反复解释:

这个项目不会商业化, 也不会正式进行众筹。

它只会是一个开源项目,起码我认为这是一个好的归宿对于这个项目来说。

4.质疑与警告

质疑

当然随着越来越多的人涌入评论区,逐渐就开始有一些不好的声音出现——

攻击言论.png

坦白的说,其实攻击力并不算是很强哈哈。但虽然还是少数,但这些话...确实刺痛。我其实就是一个普通的开源作者,群友也打趣的说黑红也是红。

警告

但真正让我担心的是评论区偶尔重复的两个词 Eaglercraft 以及 DMCA。后者我倒是有所耳闻,美国的数字版权法嘛!但是前者是什么?

随后我就深入了解了一下:

在 2021 年,Github 上曾经有一个非常火的项目:Eaglercraft

Eaglercraft 是一个由社区开发的项目,核心特点是:

  • Minecraft Java Edition 1.5.2 / 1.8.8
  • 反编译 → 修改 → 编译为 JavaScript / WebAssembly
  • 使其 可以直接在浏览器中运行

这个项目很快在社区爆火。

然后在 2022 年——Microsoft 法务介入。

以版权方身份提交 DMCA 通知

eaglercraft.png

Github 仓库被直接下架。

所有 fork 一并清理。

目前代码是部署在俄罗斯的 GitFlic(就像中国的 Gitee)。

eaglercraft-01.png

但我认为 Eaglercraft 被DMCA 的主要原因是因为 Eaglercraft 分发了受版权保护的 Minecraft 客户端代码与资源,且未获 Mojang / Microsoft 授权

5.项目终止

我应该还好吧?我做的是粉丝向的网站,我又不赚钱也没盈利,而且我也没有分发任何代码。怎么可能会有这种风险?

但事实是没有做过研究,就不能妄下定论。随着越来越多的人提醒我潜在的 DMCA 风险,我还是认真的查看 Minecraft 官网的 usage-guidelines

要了老命了!看完之后我真的木讷得待在电脑桌前有十分钟。

首先我阅读了个人使用规则:

"如果您决定与社区分享您的作品,当然可以。事实上,我们非常期待看到您的成果。当您决定与社区分享内容时(无论您是否计划从中获利),我们都将其视为商业行为。进行商业活动时,您必须遵守商业用途准则。"

minecraft-guidelines-01.png

只要公开发布,就算商业行为。好吧,看起来我应该遵守的是商业准则:

minecraft-guidelines-02.png

禁止事项 ❌

  • ❌ 使用Minecraft Logo
  • ❌ 使用官方美术素材
  • ❌ 使用看起来像官方Logo的字体
  • ❌ 模仿品牌样式

几乎全踩了。

此时我的心里有一万头草泥马奔腾而过,为什么不早一点做相关研究呢?为啥直接就开写这个项目了?这本是一场如果在初期只花大概20分钟就能规避的窘境,但如今只能看最后这个项目在官方眼里到底是否该被DMCA。总之,这个项目目前真的是听天由命了。无奈、遗憾 —— 两个月的心血...

于是我做了一个决定:

停止继续开发。

目前这个项目只保留为一个技术 Demo,最低限度完成部分功能并且。。。

不会再继续扩展功能。

6.再会

我觉得现在可能是个人开发者 机会最多的时代之一

AI 工具爆发、数字资产越来越容易获取,技术实现的门槛确实在快速下降。很多以前需要团队才能完成的事情,现在一个人加上一些 AI 工具,也许几天就能做出一个Demo

但这次项目也让我意识到:

技术门槛的降低,伴随的新的门槛的出现。

两个月的心血因为版权红线被迫中止,多少会有些遗憾。

但如果我的这“翻车”经历,能让后来者在开始项目之前多花二十分钟查一下版权规则,或者让 skills 市场推出类似的技能,甚至大模型厂商内部增加版权限制。那么这篇文章大概就已经有价值了。

至少它让我学到了很多东西—— 不仅是技术,还有那些以前从没认真考虑过的事情。

而且说到底,开发本来就是一条很长的路。

项目会结束,但开发者不会。

我还会继续做下去。

因为我始终相信:

我最好的作品,永远会是下一个。

7. 还有一些话

重要的事情说三遍

不建议用 Three.js 做游戏

不建议用 Three.js 做游戏

真的 不建议用 Three.js 做游戏

在这里先叠个甲,这不是在说Three.js有什么不好,也没有贬低正在使用 Three.js做游戏的任何人。

恰恰相反—— 我非常喜欢 Three.js,而我的另一个项目 CubeCity(Three.js Game) 已经获取了 1K+ star 了。我非常了解Three.js的能力。

但正因为如此,我才想非常真诚的想提一嘴:

Three.js 是一个 3D 渲染库,而不是游戏引擎

即使现在 Three.js 在逐步增加 WebGPU(WIP)的支持,即使随着 WebTransport 等技术的出现,一些过去WebSocket的网络瓶颈再被慢慢解决。

但本质上,它依然只是一个图形渲染库,有着太多的事情需要你自己做。

Threejs 做游戏不是不能做,而是代价非常高。

本专栏的愿景

本专栏的愿景是通过分享 Three.js 的中高级应用和实战技巧,帮助开发者更好地将 3D 技术应用到实际项目中,打造令人印象深刻的 Hero Section。我们希望通过本专栏的内容,能够激发开发者的创造力,推动 Web3D 技术的普及和应用。

加入社区,共同成长

如果您对 Threejs 这个 3D 图像框架很感兴趣,或者您也深信未来国内会涌现越来越多 3D 设计风格的网站,欢迎加入 ice 图形学社区。这里是国内 Web 图形学最全的知识库,致力于打造一个全新的图形学生态体系!您可以在认证达人里找到我这个 Threejs 爱好者和其他大佬。

此外,如果您很喜欢 Threejs 又在烦恼其原生开发的繁琐,那么我诚邀您尝试 TresjsTvTjs, 他们都是基于 VueThreejs 框架。 TvTjs 也为您提供了大量的可使用案例,并且拥有较为活跃的开发社区,在这里你能碰到志同道合的朋友一起做开源!

8. 往期回顾

🎮 前端也能造城市?源码公开:那个被外网 2.7 万人围观的 Three.js 小游戏

😲我又写出了被 Three.js 官推转发的项目?!🥳🥳(源码分享)

😮😮😮 我写出了被 Threejs 官推转发的项目🚀✨?!

【Three.js 与 Shader】编写你的第一个自定义着色器,让模型拥有灵魂

作者 叶智辽
2026年3月13日 08:46

前言

以前我觉得 Shader 是神仙才能看懂的东西,直到我发现它其实就是告诉 GPU“怎么画”的说明书。

两年前我第一次接触 Shader,看了一篇教程,开头就是 gl_FragColorvaryinguniform 这些天书一样的词。我心想:这玩意儿是人写的吗?

后来项目里有个需求:让模型边缘发蓝光。网上搜了一堆方案,最后发现除了自己写 Shader 别无他法。硬着头皮啃了一周,终于把第一个能跑起来的着色器怼出来了。运行起来的那一刻,模型边缘真的泛着蓝光,我当时激动得差点原地蹦起来。

原来 Shader 没那么可怕,它就像一本固定的菜谱,你告诉 GPU“颜色怎么混合、顶点怎么移动”,它就能画出你想要的效果。

今天我就用最笨的方式,带你写三个最简单的自定义着色器。不扯虚的,代码直接跑,效果直接看。


一、Shader 是啥?

通俗点说,渲染一个 3D 模型要经过两个阶段:

  • 顶点着色器(Vertex Shader):负责处理每个顶点的位置、变换。好比整容医生,决定骨架长啥样。
  • 片元着色器(Fragment Shader):负责计算每个像素的颜色。好比化妆师,给每个点涂上什么颜色。

Three.js 默认的材质(比如 MeshStandardMaterial)内部已经有一套写好的着色器。我们用自定义着色器,就是替换掉默认的,自己控制这两个阶段。


二、Three.js 里的自定义材质

Three.js 提供了两种方式来写自定义着色器:

  • ShaderMaterial:自动帮你补全一些默认的 uniform 和 attribute,适合初学者。
  • RawShaderMaterial:完全自己控制,什么都不帮你补,适合进阶。

我们先从 ShaderMaterial 开始,省事。

基础结构

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 },
    color: { value: new THREE.Color(0xffaa00) }
  },
  vertexShader: `
    void main() {
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform vec3 color;
    void main() {
      gl_FragColor = vec4(color, 1.0);
    }
  `
});
  • uniforms:可以从 JavaScript 传给着色器的变量,每帧可以更新。
  • vertexShader:顶点着色器代码,字符串形式。
  • fragmentShader:片元着色器代码。

gl_Position 是顶点着色器必须输出的最终位置,gl_FragColor 是片元着色器必须输出的最终颜色。


三、第一个例子:让模型颜色随时间变化

我们用上面的基础结构,加一个 time uniform,让颜色在红色和蓝色之间循环。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

new OrbitControls(camera, renderer.domElement);

// 创建一个立方体
const geometry = new THREE.BoxGeometry(2, 2, 2);

// 自定义着色器材质
const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    void main() {
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform float time;
    void main() {
      // 让红色分量在 0.5 到 1.0 之间变化,蓝色分量反向变化
      float r = 0.6 + 0.4 * sin(time);
      float b = 0.6 + 0.4 * cos(time);
      gl_FragColor = vec4(r, 0.2, b, 1.0);
    }
  `
});

const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

function animate() {
  requestAnimationFrame(animate);

  // 更新 uniform 中的时间
  material.uniforms.time.value += 0.05;

  renderer.render(scene, camera);
}
animate();

把这段代码贴进一个 HTML 文件里运行,你会看到一个立方体颜色在紫色系里渐变。


四、第二个例子:让顶点动起来(波浪效果)

现在我们来动顶点。让立方体的顶点按照正弦波上下浮动,像一块果冻。

const geometry = new THREE.BoxGeometry(2, 2, 2, 32, 32, 32); // 增加细分段数,让波浪更平滑

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    uniform float time;
    void main() {
      // 根据顶点原来的位置计算偏移量
      float offsetX = sin(position.y * 2.0 + time * 3.0) * 0.2;
      float offsetZ = cos(position.y * 2.0 + time * 2.0) * 0.2;
      vec3 newPosition = position + vec3(offsetX, 0.0, offsetZ);
      
      gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
    }
  `,
  fragmentShader: `
    void main() {
      gl_FragColor = vec4(0.6, 0.8, 1.0, 1.0);
    }
  `
});

这里的关键是:我们在顶点着色器里修改了 position,然后再进行 MVP 变换。注意 position 是模型局部坐标,计算时要小心不要破坏模型结构。

运行后,立方体的侧面会像波浪一样起伏。


五、第三个例子:简单边缘光

边缘光(Fresnel Effect)是让模型边缘发光的常见效果。原理是视线方向与法线方向越垂直(边缘),光越强。

我们需要在顶点着色器里把法线和视线方向传给片元着色器。

const material = new THREE.ShaderMaterial({
  uniforms: {
    time: { value: 0 }
  },
  vertexShader: `
    varying vec3 vNormal;
    varying vec3 vViewPosition;
    
    void main() {
      vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
      vViewPosition = -mvPosition.xyz; // 指向相机的方向
      vNormal = normalize(normalMatrix * normal); // 将法线转换到视图空间
      
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    varying vec3 vNormal;
    varying vec3 vViewPosition;
    
    void main() {
      vec3 normal = normalize(vNormal);
      vec3 viewDir = normalize(vViewPosition);
      
      // 计算视线与法线的点积,越接近0(垂直)强度越大
      float fresnel = 1.0 - abs(dot(normal, viewDir));
      fresnel = pow(fresnel, 2.0); // 增强对比
      
      vec3 baseColor = vec3(0.3, 0.6, 1.0);
      vec3 edgeColor = vec3(0.8, 0.9, 1.0);
      
      vec3 finalColor = mix(baseColor, edgeColor, fresnel);
      
      gl_FragColor = vec4(finalColor, 1.0);
    }
  `
});

这个效果在球体上最明显,模型边缘会有一圈亮光,非常有科技感。


六、坑点总结

  1. 矩阵顺序projectionMatrix * modelViewMatrix * vec4(position, 1.0) 顺序不能错,Three.js 的矩阵是右乘,坐标从右向左变换。
  2. 法线变换:不能用 modelViewMatrix 直接乘法线,要用 normalMatrix(它是 modelViewMatrix 的逆转置的左上3x3)。
  3. 变量精度:移动设备上可能需要指定精度,比如 precision highp float; 放在着色器开头。
  4. uniform 更新:记得在动画循环里更新 material.uniforms.xxx.value
  5. 调试技巧:可以用 gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 先确认片元着色器是否运行,用 gl_Position = vec4(position, 1.0);(跳过 MVP)看顶点原始位置。

七、进阶方向

这三个例子只是冰山一角。有了自定义着色器,你还可以做:

  • 流光效果
  • 溶解消失
  • 噪声纹理生成地形
  • 后处理滤镜

Three.js 官方提供了很多现成的着色器例子,在 examples/jsm/shaders/ 目录下。有空可以翻翻源码,看看大佬们怎么写。


互动

你写过最得意的 Shader 效果是啥?或者你在学习 Shader 时遇到过什么坑?评论区分享出来,咱们一起讨论 😏

下篇预告:【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感

Three.js3D编辑器必备的相机视图插件

作者 答案answer
2026年3月10日 09:46

前言

分享一个非常实用的Three.js3D场景相机视图插件

three-viewport-gizmo 一个专为 Three.js 打造的 UI 辅助组件。它在场景中生成一个交互式的“坐标轴操纵杆”(类似 Blender 或 Unity 右上角的控制器),用于指示空间方向并快速切换视角。

可以使你更加灵活轻松的控制Three.js场景相机的移动

截屏2026-03-08 12.13.15.png

安装

npm

npm install three-viewport-gizmo

或者 cdn 引入

import * as THREE from "https://unpkg.com/three@0.173.0/build/three.module.js";

使用

使用起来也是非常的简单,只需要引入ViewportGizmo即可然后配合 OrbitControls 使用

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { ViewportGizmo } from "three-viewport-gizmo";
const controls = new OrbitControls(camera, renderer.domElement);

const gizmo = new ViewportGizmo(camera, renderer, { type: "cube" });
gizmo.attachControls(controls);

function animation(time) {
  gizmo.render();
}

参数

通过查看插件源码的ts类型可以发现该插件提供了非常多的可以传入参数项目,通过这些参数项配置可以实现不同的插件展示效果


type GizmoOptions = {
  container?: HTMLElement | string;
  type?: "sphere" | "cube" | "rounded-cube";
  size?: number;
  placement?:
    | "top-left"
    | "top-center"
    | "top-right"
    | "center-left"
    | "center-center"
    | "center-right"
    | "bottom-left"
    | "bottom-center"
    | "bottom-right";

  offset?: {
    left?: number;
    top?: number;
    right?: number;
    bottom?: number;
  };

  animated?: boolean;
  speed?: number;
  resolution?: number;
  lineWidth?: number;
  id?: string;
  className?: string;

  font?: {
    family?: string;
    weight?: string | number;
  };

  background?: {
    enabled?: boolean;
    color?: ColorRepresentation;
    opacity?: number;

    hover?: {
      color?: ColorRepresentation;
      opacity?: number;
    };
  };

  corners?: {
    enabled?: boolean;
    color?: ColorRepresentation;
    opacity?: number;
    scale?: number;
    radius?: number;
    smoothness?: number;
    hover?: {
      color?: ColorRepresentation;
      opacity?: number;
      scale?: number;
    };
  };

  edges?: {
    enabled?: boolean;
    color?: ColorRepresentation;
    opacity?: number;
    scale?: number;
    radius?: number;
    smoothness?: number;
    hover?: {
      color?: ColorRepresentation;
      opacity?: number;
      scale?: number;
    };
  };

  radius?: number;
  smoothness?: number;

  x?: GizmoAxisOptions;
  y?: GizmoAxisOptions;
  z?: GizmoAxisOptions;
  nx?: GizmoAxisOptions;
  ny?: GizmoAxisOptions;
  nz?: GizmoAxisOptions;

  right?: GizmoAxisOptions;
  top?: GizmoAxisOptions;
  front?: GizmoAxisOptions;
  left?: GizmoAxisOptions;
  bottom?: GizmoAxisOptions;
  back?: GizmoAxisOptions;
};

type GizmoAxisOptions = {
  enabled?: boolean;
  label?: string;
  opacity?: number;
  scale?: number;
  line?: boolean;
  color?: ColorRepresentation;
  labelColor?: ColorRepresentation;

  border?: {
    size: number;
    color: ColorRepresentation;
  };

  hover?: {
    color?: ColorRepresentation;
    labelColor?: ColorRepresentation;
    opacity?: number;
    scale?: number;
    border?: {
      size: number;
      color: ColorRepresentation;
    };
  };
};

参数

为了方便使用者调试项目官网还提供了一个专门的参数在线预览调试页面,并且可以将调试成功的参数进行复制

通过传入不同类型的 type 可以实现创建不同风格的控制器样式

插件目前提供了三种类型的样式 cube | sphere | rounded-cube

截屏2026-03-09 20.57.30.png

调试页面地址:fennec-hub.github.io/three-viewp…

项目地址

Github: github.com/fennec-hub/…

总结

在开发一些3D编辑器类型的项目时为了方便知道当前场景的方位和信息,往往都会需要这样一个小的相机视图窗口插件方便我们去操作。

three-viewport-gizmo 它能完美适配 OrbitControlscamera-controls 等Three.js主流相机控制器 ,而且在UI设计上也非常美观同时插件样式内容也高度自定义化。

如果该项目对于有帮助就去给这个项目留下个Star吧

用 Three.js 和 D3 在 Vue 中打造 3D 苏州地图

作者 天下权
2026年3月9日 17:22

前言

地理信息可视化一直是前端领域的热门话题。传统的 2D 地图已经无法满足我们对于视觉效果和交互体验的追求,而 3D 地图则可以提供更直观、更震撼的空间认知。本文将带你从零开始,在 Vue 项目中结合 Three.js 和 D3.js 的地理投影模块,将一个普通的 GeoJSON 文件转化为可交互的 3D 挤出地图,并支持旋转、缩放等操作。最终效果是一个具有立体感和边缘高亮的苏州各区地图。

本文所有代码均基于 Vue 3 + Three.js + d3-geo 实现,你可以直接复制代码运行体验。

image.png

原理:从经纬度到 3D 几何体

要将平面地图“立”起来,我们需要解决两个核心问题:

  1. 坐标转换:地理坐标(经纬度)无法直接在 Three.js 的笛卡尔坐标系中使用。我们需要使用地图投影(如墨卡托投影)将经纬度转换为平面上的 x、y 坐标。这里我们选择 D3.js 提供的 geoMercator 投影,它可以精确地将球面坐标映射到平面,并且可以通过 center 和 scale 参数将地图定位到场景中心。
  2. 三维挤出:有了平面轮廓后,我们可以利用 Three.js 的 ExtrudeGeometry 将平面形状挤出厚度,从而形成立体感。挤出的几何体可以赋予半透明的材质,使其看起来像一块块漂浮的玻璃板。同时,为了增强轮廓的清晰度,我们还可以在边缘绘制线条,让每个区域的分界更加明显。

整个流程可以概括为:
加载 GeoJSON → 解析几何类型(Polygon/MultiPolygon)→ 投影坐标 → 创建 Shape → 挤出 Mesh → 添加边缘 Line。

技术教程

1. 环境准备

首先创建一个 Vue 3 项目(如果你还没有),然后安装必要的依赖:

bash

npm install three d3-geo

注意:d3-geo 是 D3 的地理投影模块,我们只需要它,无需安装整个 D3。

2. 基础场景搭建

在 Vue 组件中,我们先初始化 Three.js 的核心组件:场景、相机、渲染器、轨道控制器。相机使用透视相机,并设置一个较远的初始位置(比如 z=300),以便后续加载的地图能够完整显示。

为了让画面更清晰,我们关闭阴影,限制像素比,并设置深色背景以减少视觉闪烁。

javascript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.set(0, 0, 300);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = false; // 关闭阴影
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.update();

3. 加载并解析 GeoJSON

GeoJSON 是一种常用的地理数据格式。我们准备了一份苏州市区的 GeoJSON 文件(可以在网上寻找或自行制作),其中包含了各区(姑苏区、虎丘区、吴中区等)的边界坐标。由于网络请求可能失败,我们添加了错误处理,并使用默认多边形作为备用。

javascript

async function loadGeoJSON() {
  try {
    const response = await fetch('/苏州市区.geojson');
    const geojson = await response.json();
    processGeoJSON(geojson);
  } catch (error) {
    console.error('加载失败,使用默认数据', error);
    const defaultGeoJSON = {
      type: "FeatureCollection",
      features: [{
        type: "Feature",
        properties: { name: "默认区域" },
        geometry: {
          type: "Polygon",
          coordinates: [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]
        }
      }]
    };
    processGeoJSON(defaultGeoJSON);
  }
}

4. 投影转换

在 processGeoJSON 中,我们需要遍历每一个 Feature,根据几何类型(Polygon 或 MultiPolygon)提取坐标环。使用 D3 的墨卡托投影将经纬度转换为平面坐标:

javascript

import { geoMercator } from 'd3-geo';

const projection = geoMercator()
  .center([120.41453, 31.342948]) // 苏州市中心经纬度
  .translate([0, 0])
  .scale(10000);

center 设置地图中心点,scale 控制缩放比例,translate 偏移设为 [0,0] 意味着投影后的坐标原点位于 (0,0),这样我们可以直接将坐标用于 Three.js。

5. 绘制挤出几何体

对于每一个坐标环(多边形轮廓),我们创建一个 THREE.Shape,然后通过 ExtrudeGeometry 挤出厚度。这里我们使用半透明的黄色材质,并开启一定的透明度,让内部结构隐约可见。

javascript

function drawExtrudeMesh(polygon, districtName) {
  const shape = new THREE.Shape();
  polygon.forEach((point, index) => {
    const [x, y] = projection(point);
    if (index === 0) shape.moveTo(x, y);
    else shape.lineTo(x, y);
  });

  const extrudeSettings = {
    depth: 10,
    bevelEnabled: false,
    steps: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({
    color: 'yellow',
    transparent: true,
    opacity: 0.5
  });
  return new THREE.Mesh(geometry, material);
}

注意:这里的 depth 控制挤出高度,可以根据视觉效果调整。

6. 添加边缘线条

为了区分不同区域并增强轮廓,我们在每个多边形边缘绘制一条线。线条的 z 坐标稍微抬高(比如设为 9),使其浮在挤出体的上方,避免被遮挡。

javascript

function lineEdge(polygon) {
  const points = polygon.map(point => {
    const [x, y] = projection(point);
    return new THREE.Vector3(x, y, 9);
  });
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({ color: 'yellow' });
  return new THREE.Line(geometry, material);
}

7. 处理 MultiPolygon

GeoJSON 中可能存在 MultiPolygon(多个多边形构成一个区域)。我们需要递归处理,将每个子多边形分别转为 Mesh 和 Line。

javascript

if (feature.geometry.type === 'MultiPolygon') {
  coordinates.forEach(coordinate => {
    coordinate.forEach(rows => {
      map.add(drawExtrudeMesh(rows, districtName));
      map.add(lineEdge(rows));
    });
  });
} else if (feature.geometry.type === 'Polygon') {
  coordinates.forEach(rows => {
    map.add(drawExtrudeMesh(rows, districtName));
    map.add(lineEdge(rows));
  });
}

将所有生成的物体添加到一个 THREE.Object3D(即 map)中,最后将这个组添加到场景。

8. 添加辅助和光照

为了让空间感更强,我们添加了坐标轴辅助线,并设置环境光(虽然 MeshBasicMaterial 不需要光照,但为了扩展性保留)。

javascript

const axes = new THREE.AxesHelper(700);
scene.add(axes);

const light = new THREE.AmbientLight(0xffffff);
scene.add(light);

9. 启动动画循环

最后,在数据加载完成后启动动画循环,不断渲染场景。

javascript

async function init() {
  await loadGeoJSON();
  animate();
}

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

init();

10. 完整代码整合

将上述所有片段整合到一个 Vue 组件的 <script setup> 中,即可得到一个完整的 3D 地图应用。记得将 GeoJSON 文件放置在 public 目录下。

效果预览与优化方向

运行项目后,你会看到一个悬浮在黑暗空间中的黄色半透明苏州地图,每个区都有清晰的边缘线条,你可以使用鼠标旋转、缩放查看各个角度。

下章优化点:

  • 为不同区域赋予不同颜色,提高辨识度。
  • 添加鼠标悬停效果,高亮当前区域并显示名称。
  • 加入底图或街道标签,丰富信息层次。
  • 使用 ShaderMaterial 实现发光边缘等特效。
  • 添加飞线效果

Three.js多视口渲染:如何在一个屏幕上同时展示三个视角

作者 叶智辽
2026年3月7日 20:43

前言

客户说:“我要一个监控大屏,左边看整体,中间看特写,右边看俯视图。” 我说:“行,加钱就行。”

上次写了篇画中画,没想到反响还不错。评论区有人问:“能不能一个屏幕放三个视角?像监控室那种。”

我心想这不就是多视口渲染的升级版吗?一个画中画不够,那就来三个。

其实原理都一样:一个场景,多个相机,分区域渲染。只不过从两个变成三个,需要多处理一些布局和交互细节。

今天就用一个监控大屏的例子,把多视口渲染讲透。最终效果:左边是全局俯视,中间是自由跟随相机,右边是某个设备的特写。三个视角实时更新,互不干扰。


一、最终效果预览

先描述一下我们要实现的效果:

  • 左侧视口:固定俯视视角,看整个车间布局。
  • 中间视口:自由相机,可以拖拽旋转,观察任意角度。
  • 右侧视口:特写某个设备,相机始终盯着它,跟随移动。

三个视口共用同一个场景,但各有各的相机和控制逻辑。运行起来就像监控室里的多块屏幕。


二、核心思路

Three.js 的渲染器允许我们在同一帧里多次调用 render() 方法,只要每次渲染前用 setViewportsetScissor 设置好渲染区域就行。

关键点:

  1. 创建多个相机,分别设置位置和朝向。
  2. 在动画循环里,依次设置视口并渲染。
  3. 处理深度清除:第二个及之后的视口渲染前要清除深度缓冲区,否则画面会错乱。
  4. 如果有交互(比如控制器),需要判断鼠标落在哪个视口,激活对应的控制器。

三、代码实现

1. 基础设置

先搭好场景、光照和几个简单的物体(用立方体和球体模拟车间设备)。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);

// 添加一些物体
const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
scene.add(gridHelper);

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  new THREE.MeshStandardMaterial({ color: 0xff8844 })
);
cube.position.set(2, 1, 2);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);

const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(1.5, 32, 16),
  new THREE.MeshStandardMaterial({ color: 0x44aaff })
);
sphere.position.set(-2, 1.5, -1);
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);

const cylinder = new THREE.Mesh(
  new THREE.CylinderGeometry(1, 1, 3, 32),
  new THREE.MeshStandardMaterial({ color: 0x88cc44 })
);
cylinder.position.set(0, 1.5, -3);
cylinder.castShadow = true;
cylinder.receiveShadow = true;
scene.add(cylinder);

// 灯光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

2. 创建三个相机

每个相机负责一个视角。

// 相机1:俯视固定
const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraTop.position.set(0, 15, 0);
cameraTop.lookAt(0, 0, 0);

// 相机2:自由视角
const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraFree.position.set(5, 5, 10);
cameraFree.lookAt(0, 2, 0);

// 相机3:特写立方体
const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraCloseup.position.set(4, 3, 4);
cameraCloseup.lookAt(cube.position); // 盯着立方体

注意:宽高比我们还没设置,等渲染时根据视口大小动态更新。

3. 设置控制器

自由视角的相机需要控制器,其他两个不需要(或者也可以加,但本例中俯视和特写是固定的)。

const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
controlsFree.enableDamping = true;
controlsFree.target.set(0, 2, 0);

但控制器会监听整个画布的鼠标事件,我们需要判断鼠标当前在哪个视口,只有落在自由视口时才让 controlsFree 生效。后面会处理。

4. 定义视口布局

假设屏幕宽度为 window.innerWidth,高度为 window.innerHeight。我们分成三等份,每个视口占三分之一宽度,高度占满。

const viewports = [
  { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
  { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
  { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
];

5. 处理鼠标事件,激活对应控制器

我们需要知道鼠标当前落在哪个视口,然后决定哪个控制器应该启用。其他控制器的 enabled 设为 false

function onMouseClick(event) {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 遍历视口,判断鼠标是否在内部
  let activeIndex = -1;
  viewports.forEach((vp, index) => {
    if (mouseX >= vp.left && mouseX <= vp.left + vp.width &&
        mouseY >= window.innerHeight - vp.bottom - vp.height && mouseY <= window.innerHeight - vp.bottom) {
      activeIndex = index;
    }
  });

  // 根据 activeIndex 启用/禁用控制器
  // 这里我们只有自由相机需要控制器,其他两个不需要
  if (activeIndex === 1) {
    controlsFree.enabled = true;
  } else {
    controlsFree.enabled = false;
  }
}

renderer.domElement.addEventListener('click', onMouseClick);

注意坐标转换:屏幕坐标系原点在左上角,而 setViewport 用的是左下角原点,所以判断时需要转换。上面代码中的 mouseY 判断已转换。

6. 动画循环:多视口渲染

这是核心。每帧先更新控制器(如果启用),然后依次渲染每个视口。

function animate() {
  requestAnimationFrame(animate);

  // 更新自由相机的控制器
  controlsFree.update();

  // 让特写相机始终盯着立方体(如果立方体在动)
  cameraCloseup.lookAt(cube.position);

  // 为每个视口设置视口并渲染
  viewports.forEach((vp) => {
    // 设置视口
    renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
    
    // 设置剪裁区域(可选,避免渲染到其他区域)
    renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
    renderer.setScissorTest(true);

    // 更新相机的宽高比
    const aspect = vp.width / vp.height;
    vp.camera.aspect = aspect;
    vp.camera.updateProjectionMatrix();

    // 如果是第一个视口,清除颜色和深度;后面的只清除深度
    if (vp === viewports[0]) {
      renderer.clear();
    } else {
      renderer.clearDepth();
    }

    // 渲染当前相机
    renderer.render(scene, vp.camera);
  });

  // 渲染完成后关闭剪裁测试(可选)
  renderer.setScissorTest(false);
}
animate();

这里使用了 setScissorsetScissorTest(true) 来确保每个相机的渲染只在自己区域内,防止绘制到其他区域。同时用 clearDepth 避免深度冲突。

7. 窗口大小变化时更新布局

window.addEventListener('resize', () => {
  renderer.setSize(window.innerWidth, window.innerHeight);

  viewports[0].width = window.innerWidth / 3;
  viewports[0].height = window.innerHeight;
  viewports[1].left = window.innerWidth / 3;
  viewports[1].width = window.innerWidth / 3;
  viewports[1].height = window.innerHeight;
  viewports[2].left = 2 * window.innerWidth / 3;
  viewports[2].width = window.innerWidth / 3;
  viewports[2].height = window.innerHeight;
});

四、完整代码

把上面的代码片段组合起来,就是一个完整的多视口示例。为了方便你直接运行,我整理成一个完整的 HTML 文件,并加了一点动画让立方体旋转,让效果更生动。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 多视口渲染:三屏监控</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Microsoft YaHei'; }
        #info {
            position: absolute; top: 20px; left: 20px;
            background: rgba(0,0,0,0.7); color: white;
            padding: 8px 16px; border-radius: 20px;
            z-index: 100; pointer-events: none;
        }
        .label {
            position: absolute; bottom: 20px;
            background: rgba(0,0,0,0.5); color: white;
            padding: 4px 12px; border-radius: 12px;
            font-size: 14px; pointer-events: none;
            z-index: 200;
        }
        #label-left { left: calc(16.67% - 50px); }
        #label-center { left: 50%; transform: translateX(-50%); }
        #label-right { right: calc(16.67% - 60px); }
    </style>
</head>
<body>
    <div id="info">🎥 三视口监控:俯视 | 自由 | 特写</div>
    <div class="label" id="label-left">📐 俯视固定</div>
    <div class="label" id="label-center">🎮 自由视角 (点击激活)</div>
    <div class="label" id="label-right">🔍 设备特写</div>

    <!-- 引入 Three.js 核心库和 OrbitControls -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.128.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        // --- 初始化场景 ---
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x111122);

        // 网格地面
        const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
        scene.add(gridHelper);

        // 添加一些物体
        const cubeGeo = new THREE.BoxGeometry(2, 2, 2);
        const cubeMat = new THREE.MeshStandardMaterial({ color: 0xff8844, emissive: 0x221100 });
        const cube = new THREE.Mesh(cubeGeo, cubeMat);
        cube.position.set(2, 1, 2);
        cube.castShadow = true;
        cube.receiveShadow = true;
        scene.add(cube);

        const sphereGeo = new THREE.SphereGeometry(1.5, 32, 16);
        const sphereMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, emissive: 0x001122 });
        const sphere = new THREE.Mesh(sphereGeo, sphereMat);
        sphere.position.set(-2, 1.5, -1);
        sphere.castShadow = true;
        sphere.receiveShadow = true;
        scene.add(sphere);

        const cylinderGeo = new THREE.CylinderGeometry(1, 1, 3, 32);
        const cylinderMat = new THREE.MeshStandardMaterial({ color: 0x88cc44, emissive: 0x112200 });
        const cylinder = new THREE.Mesh(cylinderGeo, cylinderMat);
        cylinder.position.set(0, 1.5, -3);
        cylinder.castShadow = true;
        cylinder.receiveShadow = true;
        scene.add(cylinder);

        // 添加一个移动的小球作为动态元素
        const ballGeo = new THREE.SphereGeometry(0.5, 16);
        const ballMat = new THREE.MeshStandardMaterial({ color: 0xffaa33 });
        const ball = new THREE.Mesh(ballGeo, ballMat);
        ball.castShadow = true;
        scene.add(ball);

        // 灯光
        const ambientLight = new THREE.AmbientLight(0x404060);
        scene.add(ambientLight);

        const dirLight = new THREE.DirectionalLight(0xffffff, 1);
        dirLight.position.set(5, 10, 7);
        dirLight.castShadow = true;
        dirLight.shadow.mapSize.width = 1024;
        dirLight.shadow.mapSize.height = 1024;
        scene.add(dirLight);

        const fillLight = new THREE.PointLight(0x4466aa, 0.5);
        fillLight.position.set(-3, 2, 4);
        scene.add(fillLight);

        // --- 渲染器 ---
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // --- 三个相机 ---
        // 1. 俯视相机
        const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraTop.position.set(0, 15, 0);
        cameraTop.lookAt(0, 0, 0);

        // 2. 自由相机
        const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraFree.position.set(5, 5, 10);
        cameraFree.lookAt(0, 2, 0);

        // 3. 特写相机
        const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
        cameraCloseup.position.set(4, 3, 4);
        cameraCloseup.lookAt(cube.position);

        // --- 控制器(只给自由相机)---
        const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
        controlsFree.enableDamping = true;
        controlsFree.target.set(0, 2, 0);

        // --- 视口定义 ---
        const viewports = [
            { left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
            { left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
            { left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
        ];

        // --- 鼠标点击激活对应控制器 ---
        function onMouseClick(event) {
            const mouseX = event.clientX;
            const mouseY = event.clientY;

            let activeIndex = -1;
            for (let i = 0; i < viewports.length; i++) {
                const vp = viewports[i];
                // 转换鼠标坐标(左下原点)
                const vpLeft = vp.left;
                const vpRight = vp.left + vp.width;
                const vpBottom = vp.bottom;
                const vpTop = vp.bottom + vp.height;

                if (mouseX >= vpLeft && mouseX <= vpRight && 
                    mouseY >= window.innerHeight - vpTop && mouseY <= window.innerHeight - vpBottom) {
                    activeIndex = i;
                    break;
                }
            }

            // 自由相机索引为1,其他相机没有控制器
            controlsFree.enabled = (activeIndex === 1);
        }
        renderer.domElement.addEventListener('click', onMouseClick);

        // --- 窗口大小自适应 ---
        window.addEventListener('resize', () => {
            renderer.setSize(window.innerWidth, window.innerHeight);

            viewports[0].width = window.innerWidth / 3;
            viewports[0].height = window.innerHeight;
            viewports[1].left = window.innerWidth / 3;
            viewports[1].width = window.innerWidth / 3;
            viewports[1].height = window.innerHeight;
            viewports[2].left = 2 * window.innerWidth / 3;
            viewports[2].width = window.innerWidth / 3;
            viewports[2].height = window.innerHeight;
        });

        // --- 动画变量 ---
        let time = 0;

        // --- 动画循环 ---
        function animate() {
            requestAnimationFrame(animate);

            // 让小球围绕中心旋转
            time += 0.01;
            ball.position.x = Math.sin(time) * 3;
            ball.position.z = Math.cos(time) * 3;
            ball.position.y = 0.5 + Math.sin(time * 2) * 0.5;

            // 让立方体旋转
            cube.rotation.y += 0.01;

            // 更新自由相机的控制器
            controlsFree.update();

            // 让特写相机始终盯着立方体
            cameraCloseup.lookAt(cube.position);

            // 依次渲染每个视口
            viewports.forEach((vp, index) => {
                // 设置视口
                renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
                renderer.setScissorTest(true);

                // 更新相机宽高比
                vp.camera.aspect = vp.width / vp.height;
                vp.camera.updateProjectionMatrix();

                // 第一个视口清除颜色和深度,后续只清除深度
                if (index === 0) {
                    renderer.clear();
                } else {
                    renderer.clearDepth();
                }

                renderer.render(scene, vp.camera);
            });

            renderer.setScissorTest(false);
        }

        animate();
    </script>
</body>
</html>

五、坑点总结

  1. 视口坐标setViewportsetScissor 用的都是左下角原点,而鼠标事件是左上角原点,转换时要注意。
  2. 深度清除:多视口渲染时,第二个及之后的视口必须调用 clearDepth(),否则旧深度会导致新画面显示不全。
  3. 控制器冲突:多个控制器同时监听同一个画布会互相干扰,必须根据鼠标位置动态启用/禁用。
  4. 性能:渲染多个视口意味着每帧多次渲染,对性能有影响。可以适当降低分辨率或关闭阴影来优化。
  5. 宽高比:每个相机要单独设置 aspect 并调用 updateProjectionMatrix

六、拓展想法

这个多视口技术还有很多玩法:

  • 给每个视口添加不同的后期效果(比如一个泛光,一个黑白)。
  • 实现分屏游戏(比如左右分屏的双人竞技)。
  • 结合 CSS 把视口放在 HTML 元素上,实现 3D 画中画嵌套 HTML。

我正准备写一篇《Three.js 后期处理进阶:给每个视口加上不同滤镜》,感兴趣的话可以关注后续。


互动

你用过 Three.js 的多视口渲染吗?实现了什么有趣的效果?欢迎评论区晒出来,让我也开开眼 😏

一个非常实用的Three.js3D模型爆破💥和切割开源插件

作者 答案answer
2026年3月2日 09:29

前言

给大家分享一个非常实用Three.js3D模型爆破切割插件,这个插件能够使前端可以直接在浏览器中,对 3D 模型进行实时且物理效果真实的 “爆破”“粉碎”“切片” 处理。

Mar-01-2026 18-55-28.gif

安装

安装也是非常的简单直接通过 npm 安装即可

但需要注意的是Three.js版本需要大于 0.158

npm install @dgreenheck/three-pinata

使用

使用也是非常的简单只要将插件提供的方法引入即可

DestructibleMesh 用于创建可切割或爆破的物体物体

FractureOptions 用于设置参数配置

import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options);

fragments.forEach((fragment) => scene.add(fragment));
mesh.visible = false;

参数方法

该插件大概提供了7种不同的针对3D模型爆破和切割的场景方法,并且官网示例都可以直接查看演示效果

比如这个砸碎物体方法:

image.png

image.png

又或者说这个物体切片方法:

image.png

image.png

项目仓库

该项目插件是一个外国大佬开发,如果有使用Three.js开发一个小游戏的需求,或者说想丰富一下你的3D网站这个插件都会可以给你提供不错的帮助的

项目演示地址:three-pinata-demo.vercel.app/

Github: github.com/dgreenheck/…

❌
❌