Skip to main content

· 24 min read

最近在深入探究 GLSL,每次想写一段 shader 看下效果,都要新开一个 HTML、写 <script> 标签、配 <canvas>、引入threejs包、起本地服务、改完刷新看效果 —— 即便有 AI 加持,这一套流程的心智负担还是太重了。我其实只是想验证一些变换公式,查看顶点变换或颜色变化等效果,而不是搭一个项目

试了一圈现成的在线 GLSL playground,要么功能停在能跑就行,要么稍微改点代码就出 bug,再要么就是必须登录或者收费的复杂产品。于是决定自己撸一个 playground —— ShaderPad

目标

  • 「打开浏览器 30 秒内跑起一个 shader」:零登录、零配置、零下载,面向 Web 着色器学习和调试的极简 playground
  • 支持分享和快速复现3d场景
  • 提供可快速插入 mdx 文本里的 npm 包

在线体验 ,对应的 github 仓库

整体效果

shaderpad首页

整体页面结构跟大部分在线编辑器类似,左边是代码编辑区,提供了顶点着色器和片元着色器的编辑功能,会高亮 glsl 语法。右边是基于 threejs 的3d场景,实时预览当前编辑的代码的效果,并且提供了悬浮的控制台面板,方便查看一些报错或log

为了方便观察,3d场景内置了辅助网格和辅助坐标轴,并且引入了 OrbitControls,用户可以通过鼠标旋转场景,查看不同的角度

技术栈

维度选型理由
前端框架Astro 4.15 + React Island群岛架构 + 首屏友好
编辑器Monaco Editor 0.50VSCode 同款,TS 智能提示、GLSL 语法高亮
3d渲染Three.js 0.170(WebGLRenderer + RawShaderMaterial)
状态管理nanostores 0.11极小(<1KB),React 集成通过 @nanostores/react
包管理pnpm 8.11 workspace硬链接节省空间,monorepo 友好
部署轻量服务器 + Nginx Proxy Manager + GitHub Actions

为什么选 Astro?

做 ShaderPad 之前我其实没怎么用过 Astro。这次之所以选它,是被它的「默认零 JS」设计打动了。它并非基于 React 构建,而是允许你通过 integrations 把 React / Vue / Svelte 等当作「岛屿」嵌入到静态 HTML 中。

对比 Gatsby、Docusaurus 或 Next.js 这些默认把整个 React 运行时推到浏览器的重型方案,Astro 显得非常克制。在「孤岛架构」下,它把整个页面当成一片静态海洋,里面散落着几个交互小岛。对 ShaderPad 来说,文档内容就是静态海面,只有 Playground 这个组件才是真正的交互孤岛。

底层原理上的优势:

  1. 极致的编译时剥离:Astro 的渲染模式是 SSG。在 build 时它会把 .astro 组件全跑一遍,暴力剥离掉所有不需要在客户端执行的 JS 逻辑,只输出纯 HTML。
  2. 精准的按需水合:因为 Playground 强依赖 WebGL(完全没法在 Node 端执行),我给它加了 client:only="react" 指令。Astro 遇到它时,只会在 HTML 里留个带有 astro-island 标签的占位 DOM,并注入极轻量的调度器(几 KB)。等页面加载完,调度器才会去拉取 React 运行时和组件代码,在客户端完成「水合」。

落到实际产物上:构建出来的文档站 index 页面只有 6.84 kB(Gzip 后 2.73 kB),大头全在按需加载的 Playground 孤岛里(约 500+ kB)。这种「主静态、副交互」的颗粒度控制,完美契合了重客户端工具的需求,彻底甩掉了全站 React 渲染的性能包袱。这也是为什么 Astro 在「文档站 + 工具型网站」场景下越来越受欢迎的原因——它把"该省的省到极致"这件事做得很彻底。

架构设计

Monorepo 结构

monorepo 算是现在多仓管理的标配。它不光能在一个仓库里管多个 package,更像是在逼我面对一个问题:"如果这个项目要嵌进别人的网页里,边界该画在哪?"。所以从一开始我就带着 SDK 视角在搭:核心引擎、UI 组件、样式层各管一摊,公共部分一律上提到 packages/,主站只负责壳子和体验。

  • @shaderpad/runtime 抽离出 LanguageAdapter 接口(GLSL 轻量语法预检),未来扩展到 Node / Tauri 桌面端可直接复用。
  • @lucascv/shaderpad-playground 把 Playground 抽成独立 npm 包,独立发版、可嵌入到任何 React 文档站。
  • apps/web 主站保持轻量,作为包的消费者,部署产物也更加干净。
shaderPad/
├── apps/
│ └── web/ # 主站(Astro)
│ ├── src/
│ │ ├── pages/ # 路由(index / play / learn/*)
│ │ ├── components/ # React 组件
│ │ ├── lib/
│ │ │ ├── runtime/ # 浏览器侧渲染引擎
│ │ │ └── share/ # URL/localStorage 持久化
│ │ └── shaders/examples.ts # 内置示例库
│ └── astro.config.mjs
├── packages/
│ ├── shader-runtime/ # 跨端共享核心(未来扩展桌面端)
│ │ └── src/languages/ # GLSL / TSL / WGSL adapter
│ └── shader-playground/ # 可独立发版的 npm 包
│ ├── src/
│ │ ├── runtime/three-engine.ts
│ │ ├── ui/ # ShaderPlayground / CodeEditor / PreviewCanvas
│ │ └── styles/playground.css
│ └── tsup.config.ts
└── .github/workflows/
├── deploy-web.yml # 主站部署
└── release.yml # npm 自动发版(OIDC)

数据流

[Monaco Editor]  --change-->  Playground state (codeRef)
|
|--auto save (1s debounce)--> localStorage
|
'--compileAndRun()--> [ShaderEngine]
|
+---------+---------+
| |
(vertex/fragment) (uniforms)
| |
v v
Three.js RawShaderMaterial <-- OrbitControls / Grid / Axes

核心渲染模块

ShaderEngine(运行时核心)

要让代码在网页上跑起来,必须有一套稳定、高效的渲染器。我封装了 ShaderEngine 这个核心类来处理 Three.js 的脏活累活。

它不仅是对 WebGLRenderer 的简单包装,更重要的是它接管了渲染的生命周期与错误捕获,对外只暴露最极简的 API:

class ShaderEngine {
init() // 创建 WebGLRenderer + 透视相机 + 辅助坐标系
applyShader(source, mode) // 将用户的源码注入 RawShaderMaterial
forceCompile() // 绕过 Three.js 顶层,直接调用 WebGL API 预编译并捕获行号
setGeometry(type) // 无缝切换几何体(复用 Material,不闪烁)
start() / stop() / dispose() // 挂载 RAF 动画循环,并确保销毁时不漏内存
}

内置模块

提供了以下几种 threejs 常见的几何体:

  • PlaneGeometry 平面
  • BoxGeometry 立方体
  • SphereGeometry 球体

默认是 PlaneGeometry,用户可以自行切换。针对不同几何体提供了几种不同的常见 shader 示例,比如时间渐变、鼠标跟随、噪声效果等,选中后就可以查看效果,用户可以根据需要选择。

另外比较关键的是,我还内置了一些开发中常见的 uniform 变量,如下:

uniform float u_time;       // 自启动以来的秒数,每帧递增
uniform vec2 u_resolution; // 画布宽高(像素)
uniform vec2 u_mouse; // 鼠标位置,归一化到 [0,1](Y 已翻转)
uniform float u_random; // applyShader 时的随机数 [0,1)

这样就可以在 shader 中直接使用这些变量来做一些动态效果。

当然目前没法完全自定义 uniform,暂时是逐步加入一些常见变量,有需求的可以评论或者追加 github issue

为什么用 RawShaderMaterial?

在实现 ShaderEngine 时,我面临一个取舍:用 ShaderMaterial 还是 RawShaderMaterial

ShaderMaterial 很方便,它会自动帮你注入一堆 Three.js 内置的 uniforms 和 attributes(比如 cameraPositionmodelViewMatrix 等)。但在「教学和调试」场景下,这反而成了致命缺点——用户会很困惑:「我明明没声明这个变量,为什么它能跑?」

为了做到「所见即所得」,我最终选择了 RawShaderMaterial。它是一张白纸,不注入任何隐藏代码,用户写的 source 就是最终跑在 GPU 里的 GLSL。 这也意味着报错行号能做到 1:1 绝对对应,不会出现「明明只有 10 行代码,控制台却报第 150 行错误」的灵异事件。代价是用户必须在代码开头显式声明所需的内置矩阵:

attribute vec3 position;
attribute vec2 uv;
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

但这换来的是运行机制的完全透明,对于一个学习工具来说,这个权衡是非常值得的。

编译错误的精确定位

Three.js 编译失败的报错信息默认是「WebGL: ERROR: 0:5: 'foo' : undeclared identifier」这种字符串,没法结构化处理。Playground 在 ShaderEngine 里直接绕过 Three.js 的封装,调底层 gl.getShaderInfoLog + gl.getShaderSource 自己解析,把行号 / 列号 / 错误消息拆成结构体再浮条展示:

{ line: 5, column: 12, message: "'foo' : undeclared identifier" }

这样写 GLSL 时,看到的都是真实可定位的错误,而不是"WebGL: ERROR: 0:5"这种天书。

模块沉淀:从单一工具到通用的 npm 包

做完主站后我意识到——「实时编辑 + 实时预览」这套交互本身非常有价值,它不应该只局限在 ShaderPad 自己的网站里。如果能在任何 MDX 文档或技术博客里直接嵌入一个能跑的 Shader,阅读体验会呈指数级上升。

比如现在你可以直接修改下面的代码,实时查看效果(试试把 cos 改成 sin,或者调一下 vec3(0, 2, 4) 的颜色偏移):

Neon Plasma (可编辑)
Loading...

于是我把 Playground 抽成了一个独立的 npm 包:@lucascv/shaderpad-playground5 行代码就能嵌进任何 React 文档站。

pnpm add @lucascv/shaderpad-playground react three monaco-editor @monaco-editor/react
import { ShaderPlayground } from "@lucascv/shaderpad-playground";
import "@lucascv/shaderpad-playground/styles";

<ShaderPlayground
code="void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }"
storageKey="my-article/hello"
/>;

Live Demo:shaderpad.lucaslib.net/embed-test | npm:@lucascv/shaderpad-playground

封装过程踩到几个值得记一笔的点:

为什么必须是 MDX(前提中的前提)

这事能成立,关键在 MDX 这个东西本身。普通 Markdown 只能写文字 + 代码块,碰到 <ShaderPlayground /> 这种 JSX 直接懵——<div> 都识别不了,更别说塞交互组件。

MDX(Markdown + JSX)就是为这个问题生的:它扩展了 Markdown 语法,允许在文档里直接写 React 组件。原理不复杂——编译期用 @mdx-js/mdx 这类工具把 .mdx 文件解析成 React 树:Markdown 部分走 remark 管线出 React 元素,JSX 部分原样透传,最后合成一棵完整的组件树渲染。

而且目前主流文档框架几乎都原生支持 MDX:Docusaurus(你正在看的这个博客就是)、Astro、Nextra、VitePress 都是开箱即用,不用额外搭脚手架。如果你的博客 / 文档站已经在用这些技术栈,零迁移成本就能接入——这点很关键,意味着 Playground 的受众不是只有 React 重度用户,而是几乎所有写技术文档的人。

这套机制让「散文 + 代码块 + 实时 demo」在同一个文件里无缝混排。其他路线都做不到这种程度:

  • iframe 嵌外部 playground:样式割裂、跨域通信麻烦、宿主主题融不进去
  • 截图 + 跳 CodePen / ShaderToy:读者被迫跳出当前阅读流
  • 录视频:完全丧失可编辑性,等于把核心价值阉了

MDX 路线能让交互组件和正文真正长在一起

持久化代码

用户编辑过的代码要保留下来(下次打开还是他改过的版本),但如果作者改了文章的示例代码,旧草稿就会"幽灵生效"——看起来加载了,但内容是上一版的。

包里的做法是把源文件内容用 djb2 算一个短 hash,写进 localStorage key 里

// v2 key 格式:embed-test/pair-box:a3f9b1c2
function buildKey(storageKey, source) {
return `${storageKey}:${shortHash(source)}`;
}

源文件一变 hash 就变,自动生成新 key,旧草稿自然绕过。这套机制上线后,文档示例库再迭代也没出过"代码不匹配"的玄学问题。

单 / 双着色器配置

最简的用法是只传一个 code 字段,跑单 stage。但有些场景(顶点动画、varying 传递)必须 vertex + fragment 联动才能跑起来,所以包内同时提供了 pair 配置:

<ShaderPlayground
pair={{
vertex: `void main() { gl_Position = vec4(position, 1.0); }`,
fragment: `void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }`,
}}
storageKey="docs/glsl/coords"
/>

pair 时画布会自动切到 Vertex / Fragment 双 tab,编辑器用单一实例、两边各自一份代码,互不打架。

组件包打包

为什么选 tsup?ESM/CJS 格式怎么控?

在抽离 @lucascv/shaderpad-playground 这个独立的 npm 包时,我需要一个打包工具。之所以选 tsup,是因为它底层基于 esbuild,打包速度极快,而且开箱即用支持 .d.ts 类型生成。对于这种不用配复杂 Webpack loader 的纯 TS/React UI 组件库来说,体验简直是降维打击。

但我在这里踩了一个经典的 ESM/CJS 双格式导出的坑。

一开始打包后,Astro 主站引入组件时报了页面水合(Hydration)失败的 SyntaxError。排查后发现,是因为包的 package.json 声明了 "type": "module",导致宿主在解析依赖时,拿到了格式不匹配的产物。

为了同时完美支持现代框架(需要 ESM 以支持 Tree-shaking)和类似 Docusaurus 2.x 这种可能依赖旧版 Webpack 设定的工具(需要 CJS),必须在 tsup.config.ts 里手动接管输出扩展名:

export default defineConfig({
format: ["esm", "cjs"],
outExtension({ format }) {
// 强制把 ESM 产物后缀设为 .js(因为 type: module),CJS 产物后缀设为 .cjs
return { js: format === "cjs" ? ".cjs" : ".js" };
},
});

同时,package.json 里的 exports 字段必须严丝合缝地对齐

"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // ESM 消费者走这里
"require": "./dist/index.cjs" // CJS 消费者走这里
}
}

只有当这两边绝对对齐,各种宿主框架在根据自身环境 importrequire 时,才能精准命中正确的模块,彻底消灭 Hydration 报错。

几个工程取舍

  • CSS 变量全用 spg- 前缀:颜色 / 边框 / 强调色全部走 CSS 变量,主题跟随 documentElement[data-theme]和宿主站主题自然融合,不会突兀地"白底黑字"。
  • 响应式断点 720px:宽屏左右分屏(编辑器 + 画布),窄屏自动堆叠成上下结构,手机也能直接看效果
  • React 17 / 18 / 19 全兼容react / react-dom / three / monaco-editor 全是 peerDependencies不打包进 dist,包体核心 ~66KB(gzip),按需由消费方装。本网站基于 Docusaurus 2.4 + React 17 这个老古董环境,也都支持集成。

URL 分享功能

作为一个在线调试工具,如果不做后端数据库,怎么分享代码?

这里的方案是:把整个 Shader 源码通过 LZString 压缩 + Base64 编码后,直接塞进 URL 的 hash 路由参数里。可以在 ShaderPad 右上角点击 share,然后新打开标签页粘贴体验

为什么是这套组合

https://shaderpad.lucaslib.net/?a=1&b=2#/playground?code=xxx
└── query ──┘ └────── hash ──────┘

整段 URL 只有 hash 留在客户端,query 和 path 都会被发到服务器——意味着要后端配合、还要防日志和 CDN 污染。改 hash 不触发 HTTP 请求,对「无后端 + 静态部署」是天然选择。

  • LZString 压缩。 GLSL 天然高冗余,关键字和模板片段反复出现。LZString 是为「短字符串 + URL」场景设计的,输出本身就是 string,压缩比通常 3~5x。
  • Base64 兜底「URL 安全」。 压缩后的字节流是二进制,里面可能混着控制字符。Base64 把任意字节映射到 64 个 URL-safe 字符,~33% 的体积代价被上一层的压缩比覆盖。

浏览器对 URL 长度有隐性上限(实测 Chrome 大概在 8KB~32KB 之间),代码长了会被截断。所以分享出去的链接天然适合"短小精悍的示例"——这其实和调试场景挺契合的,单文件 shader 本来就不该太长。

这样任何人拿到链接,打开就能直接还原当前的编辑状态,完全不需要后端的介入,真正做到了「无状态」的极简分享。

部署

刚好最近换了台新服务器,ShaderPad 就作为第一个部署的应用上线了。关于新服务器配置环境,还专门写了一篇文章 《linux个人云服务器开荒指南》

顺便吐槽一句某某云:旧那台 1 核 2G 的云服务器续费依旧贵得离谱,反而新买一台 2 核 4G 首年还有大折扣,算下来差不多。旧机器上也没跑几个应用,迁移成本不高,索性换台配置高一点的。

部署的核心组件是 Nginx Proxy Manager(下文简称 NPM,注意和 Node 的 npm 不是一回事)。

Nginx Proxy Manager

NPM 是一个基于 Nginx 的可视化反向代理管理工具,包装成 Docker 镜像后一行命令就能起,很香。

  • 提供 Web 管理界面,不用手写 nginx.conf、不用 nginx -s reload
  • SSL 证书申请 + 部署一条龙(Let's Encrypt 自动化),告别以前去某某云控制台手动申请再 vim nginx.conf 的繁琐

端口规划(默认会占三个,记得在某某云防火墙里放行规则):

端口用途暴露建议
80HTTP公开
443HTTPS公开
81管理界面只对可信 IP 开放

自动化部署

走 GitHub Actions + rsync:

  • push 到 main → 触发 .github/workflows/deploy-web.yml
  • pnpm install + pnpm --filter web build,产物在 apps/web/dist/
  • rsync-deployments action 把 dist/ 推到服务器的 /var/www/shaderpad/dist/(与 NPM 静态资源目录保持一致)

踩过的坑

Nginx 与 CI 环境的琐碎坑点

  • Nginx 500 错误:启动 NPM 容器的时候没正确挂载宿主机的静态资源目录(如 /home),导致 Nginx 找不到文件,修改 docker-compose.yml 补上 volumes 映射即可。
  • pnpm@10 lockfile 冲突:GitHub Actions CI 环境默认拉了最新的 pnpm v10,解析项目 v8 的 pnpm-lock.yaml 时报 ERR_PNPM_LOCKFILE_BREAKING_CHANGE 错误。解决方案是在 CI 步骤里明确指定安装 pnpm@8.11.0 版本。

总结与思考

以前我一直想做个在线工具,但总觉得市面上轮子已经够多了,加上开发和部署成本,迟迟没有动手。这次在 AI 的加持下(核心代码大量借助了 Minimax-M3 等大模型),极大地压缩了「从想法到上线」的周期。

ShaderPad 不仅让我调试和学习 glsl 代码更方便,也让我跑通了从「单体应用开发」到「通用组件抽离」,再到「自动化发版部署」的完整工程化闭环。

第一版先保持极简,后续如果大家觉得好用,会考虑扩展对 TSL 和 WGSL 的支持。欢迎来玩!

在线体验地址:ShaderPad ,对应 github 仓库

· 18 min read

其实我的需求很简单。原先自己的知识库网站基于 Algolia 的纯文本检索有点落后了,于是想接入 AI 问答,可以同时检索多处内容并做对话式的总结,而不是简单的返回带有文本字符串的文章列表。(Algolia 后面推出了 NeuralSearch,不过得额外开启还要付费)。

作为前端,我一直想找个切入点探索 AI Agent 相关技术,而给自己的知识库接入基于 RAG(检索增强生成)的 AI 问答,无疑是跑通 Prompt 工程和 RAG 技术最好的练手项目。

怎么交互呢?因为原先 Algolia 的检索窗口在右上角,所以我直接在旁边加上 AI 问答按钮,触发对话窗口。效果如下:

ai问答效果演示

点击本网站右上角【Ask AI】就可以体验

这有点像客服助手——说起来,客服助手可以算是入门 AI Agent 最好的项目了,根据问题设计多种回复 Prompt,通过 Function Calling 调用 API 方法或查询数据库,我刚开始练手就是搞了一个客服助手,熟悉了 AI Agent 的基本体系。

接下来,我需要一个轻量的后端服务来实现 RAG 服务接口,对接向量数据库。

整体设计与技术选型

做全栈开发,第一步是定技术栈。我的核心诉求是轻量、低成本、免运维

  • 前端:docusaurus (SSG)。博客原有的基建,负责展示和发起问答。
  • 后端:hono。没有选 Express,Hono 是一个极度轻量的 Web 框架 (12-15kb),原生支持 Web Standard API,不仅能在 Node.js 跑,还能跨运行时支持 Bun/Deno。API 风格跟 Express 很接近,这次主要是尝鲜,服务也不复杂,就用 Hono 来实现。
  • 向量数据库:chroma db。这是一个轻量的向量数据库,基于 SQLite(文件型存储),Node.js 也能快速接入。我的知识库属于个人场景,单用户访问,不需要分布式高 QPS。关于向量,可以先记住这句话:语义相近的文本,向量在空间中的距离就小
  • 大模型:deepseek-v3。这种场景只是简单的总结和问答,不需要复杂的模型,性价比极高。
  • 向量模型:阿里的 text-embedding-3。中文支持好,费用低。

关键工程问题

下面这几个模块,刚好按 RAG 的数据流转顺序铺开:离线数据生产(切片 -> 灌库) -> 在线服务接口(Prompt 拼接 -> LLM 流式透传) -> 前端展示(流式渲染)

1. 文档切片 (Chunking)

切片策略:根据标题栏和段落,按语义切分文本。

我们需要将 Docusaurus 项目中 docs/blog/ 目录下的所有 Markdown (.md/.mdx) 文档提取出来,进行合理的切片处理,以便后续进行向量化 (Embedding)。 因为 Markdown 本身就是结构化的,H1/H2/H3 天然是语义边界,切出来的每片都是一个完整的小主题,直接喂给 embedding 模型。每个切片必须保留路由信息 (sourceUrl),以便于前端问答时回显来源链接。

文档切片

这里使用了几个库来协助处理:

  • unified + remark-parse:用于解析 Markdown 生成 AST(抽象语法树)。如果不这么做,只能正则匹配 #,碰到代码块里的 # 就误判。
  • remark-frontmatter:用于识别并提取文件头部的 YAML 属性(如 title/tags)。切片丢了标题,回显时无法显示"这是哪篇文档"。
  • mdast-util-to-string:用于将 AST 节点转回纯文本进行分析。因为经过上述处理后拿到的还是带结构的对象,没法直接喂给 embedding。

整个 pipeline

.md 源文件
→ unified + remark-parse:解析成 AST(树状结构)
→ remark-frontmatter:把头部的 YAML 抽出来作为 metadata
→ 遍历 AST 找 H2/H3 节点作为切片锚点
→ mdast-util-to-string:把切片节点的文本内容转成纯文本
→ 输出:{ sourceUrl, title, content, ...metadata }

为什么不一步到位?Markdown 看似简单,其实头部的 YAML、代码块、链接、图片都是「结构化信息」,得先把它们都识别出来,才能精准地切、按层级地切、不破坏语境地切。

注意:只切到 H2/H3 比较合适,再细就碎了;遇到特别长的章节,可以按段落再细分。

可以配置 package.json 的 scripts 命令,这样在 GitHub Workflow 流程里就能执行脚本触发文档切片,并自动上传向量数据库:

"scripts": {
"rag:parse": "tsx scripts/rag/test-parser.ts"
}

2. 文本向量化与增量灌库

借助 text-embedding-3 模型,把切好的文本转成向量表示(就是把「文字」转成「坐标」,后续在向量空间里找距离最近的那几个)。

embedding

存到 ChromaDB 时有几个细节:

  • 向量维度text-embedding-3 默认输出 1024 维,每片文本对应一个 1024 维的浮点数数组。
  • 元数据一起存:除了向量,把原文、文档路径、章节标题作为 metadata 一起入库,召回时靠 metadata 做过滤和展示。

文本向量化

切完的真实数据格式如下:

{
"content": "[文档路径: /docs/afreshjs/Node.js/高级核心概念 | 章节: 高级核心概念 > 高级核心概念 > 3. 模块化的底层原理 (CJS vs ESM) > 3.2 循环引用 (Circular Dependency)]\n### 3.2 循环引用 (Circular Dependency)\n\n当 `a.js` 引用 `b.js`,同时 `b.js` 又引用 `a.js` 时:\n\n- **CommonJS 的表现**:\n CJS 在加载模块时,会优先在 `require.cache` 中创建该模块的空对象 `module.exports`。当发生循环引用时,`b.js` 会拿到 `a.js` **还没执行完的、不完整的 `exports` 对象**。这会导致运行时拿到 `undefined` 而报错。\n _(CJS 导出的是值的拷贝/浅拷贝)_\n\n- **ESM (ECMAScript Modules) 的表现**:\n ESM 的加载分为“解析”、“实例化”、“执行”三个阶段。ESM 导出的是**实时绑定 (Live Bindings)**,即导出的变量和原模块内部的变量指向同一块内存地址。\n 因此在处理循环引用时,只要你不立刻去读取那个还没初始化的变量,引擎就能完美处理好模块的依赖图谱。\n\n---",
"metadata": {
"sourceUrl": "/docs/afreshjs/Node.js/高级核心概念",
"title": "高级核心概念",
"h1": "高级核心概念",
"h2": "3. 模块化的底层原理 (CJS vs ESM)",
"h3": "3.2 循环引用 (Circular Dependency)",
"chunkIndex": 5
}
}

增量灌库

第一次全量灌库时有个小插曲:接口携带的数据太大,导致 Nginx 报了 body 体积过大的异常 (413)。除了调整 Nginx 阈值 (client_max_body_size),稳妥起见还用了多个接口分批上传,避免触发接口超时 (504)。

后续更新采用增量灌库:基于 Git Diff 仅对变更文件重新 Embedding,避免每次修改知识库都要全量上传。这部分逻辑写在 GitHub Actions 中。

ChromaDB 的 API 设计非常契合这种场景,底层 HNSW 索引是自维护的,不用手动 rebuild:

// 新增
await collection.add({ ids, documents, embeddings, metadatas });
// 有则更新、无则新增(按 id)—— 灌库最常用
await collection.upsert({ ids, documents, embeddings, metadatas });
// 更新已有(不存在会报错)
await collection.update({ ids, embeddings, metadatas });
// 删除
await collection.delete({ ids: [...] });

3. Prompt 设计

针对 AI 问答的回复,我设计了如下 Prompt,严格限制 AI 的行为,避免幻觉:

# 角色

你是一个知识库助手,只能基于「参考文档」回答用户问题,不要编造。

# 回答要求

1. 先用 1-2 句话直接回答用户问题
2. 再补充关键细节(来自参考文档)
3. 最后列出引用来源(仅列文档名 + 章节,不要贴 URL 长链接)
4. 如果参考文档不足以回答,明确说"知识库没有相关内容"

# 参考文档

${retrieved_chunks}

# 用户问题

${user_query}

4. 流式问答 (SSE) 与 Nginx 代理坑点

这就是 SSE 的典型应用场景:用户输入问题,后端检索数据库将最相关的数据喂给模型,然后将模型返回的结果处理成一个流式响应,前端实时接收并展示给用户(打字机效果)。

开启 SSE 接口,三件事缺一不可:响应头 + 后端实现 + Nginx 反代配置

1. 响应头

content-type: text/event-stream
cache-control: no-cache # 防止中间代理缓存
connection: keep-alive # 保持长连接
x-accel-buffering: no # 告诉 Nginx 不要缓冲(关键)

2. 后端实现 (Hono)hono/streamingstreamSSE(把后端响应包成 SSE 格式 data: ...\n\n),上游 stream: true 让 DeepSeek 边想边吐,核心是「透传上游流」:

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();

app.get("/rag-api/ask", async (c) => {
return streamSSE(c, async (stream) => {
// 1. 请求上游 LLM,开启流式
const upstream = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
/* ... */
},
body: JSON.stringify({ /* ... */ stream: true }),
});

if (!upstream.body) return;

// 2. 透传上游流:read → 解析 → writeSSE
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

// 监听客户端断开,清理上游(不浪费 token)
stream.onAbort(async () => {
await reader.cancel();
});

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // 最后一行可能不完整,留到下轮

for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") {
await stream.writeSSE({ data: "[DONE]" });
continue;
}
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content || "";
if (content)
await stream.writeSSE({ data: JSON.stringify({ content }) });
} catch (e) {
console.error("SSE 解析错误:", e);
}
}
}
});
});

3. Nginx 反代必须关缓冲(最常踩的坑) Nginx 默认会缓冲响应,SSE 会被"卡住"看不到流,等缓冲满了才一次性吐出来。必须加:

location /rag-api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_buffering off; # 关键!禁用缓冲
proxy_cache off; # 禁用缓存
proxy_http_version 1.1; # SSE 需要 HTTP/1.1
chunked_transfer_encoding on;
proxy_read_timeout 60s; # 防止超时断流
}

总结下易踩的坑:

症状解法
Nginx 默认缓冲流"卡"住,输出一大坨proxy_buffering off
客户端关闭但上游还在跑Token 继续消耗stream.onAbort + reader.cancel
chunk 跨行JSON.parse 报错buffer 累积 + lines.pop()
EventSource 不支持 POST传参不方便@microsoft/fetch-event-source

为啥要用 @microsoft/fetch-event-source 这个库来做前端 SSE 请求?

  • 原生 EventSource 只支持 GET 请求,header 也不能自定义(传个 token 都麻烦)。
  • fetch + getReader() 在微信内置浏览器(X5/WKWebView)会拿到 null 直接抛异常。

这个库底层用 fetch 发请求,支持 POST 和自定义 Header,内部封装了 ReadableStream 兼容处理,业务代码只关心 onmessage 回调即可。

总结下上述流程:

DeepSeek API (ReadableStream)
│ raw bytes (SSE 格式: data: {...}\n\n)

getReader() 异步迭代


TextDecoder → buffer 累积 → 按 \n 切行


JSON.parse → 提取 content


writeSSE({ data: JSON.stringify({content}) })


Hono 内部写到 response.body (writable)


Nginx 不缓冲 → 客户端立即可见

评估模型回复的质量

RAG 跑起来之后,怎么知道它"答得好不好"?靠人工抽查显然不靠谱。这里用了一个比较简单的评估流程:

一、建立「黄金评测集」 人工标注一批「问题-标准答案」对,覆盖知识库核心知识点。起步 50~100 条就够,跟着系统一起迭代。

二、给每个回答打个分 省事打法:直接让 LLM 当裁判 A/B 评分,准备一份评分 Prompt,让 LLM 对比「标准答案」和「模型回答」打分。

三、问答日志落库 每次问答记录 query, retrievedChunks, finalAnswer, feedback (用户点赞/踩)。落库后方便每周抽样核对,找出「召回失败」的反例。

四、指标差了怎么调优——先定位再下手

现象问题出在调优方向
召回到的 chunk 不对召回阶段换切片策略 / 换 embedding / 调 Top-K
召回到的 chunk 对了,但 LLM 没理解生成阶段改 prompt / 升档模型

黄金思路:召回问题比生成问题更常见,先看召回。90% 的"AI 答错"案例其实是"压根没找到对的资料"。

Token 消耗把控

目前只是通过 Prompt 限制输入输出 token,以及增量灌库减少向量模型 token 的消耗。如果后面要用上高级模型的话,肯定得做好更精细的把控,毕竟真的贵。

自动化部署

最近 vibe coding 了几个 Web App,基本都走的这个流程,还挺方便的:

  • GitHub Actions 编写 workflow deploy 脚本,push 代码后自动触发镜像构建。
  • 编写 Dockerfile,上传 Docker Hub。
  • 个人服务器从 Docker Hub 拉取镜像部署。

注意:要先在 GitHub 设置 secrets,以及在服务器相关项目目录里设置环境变量,避免明文存储敏感信息。

最后

坦白说,这只是一个简单的 RAG 服务,仅仅是向量召回和模型总结回复(Naive RAG)。在实际使用中,你会发现搜极度具体的专有名词时,纯向量检索很容易漏召回。

我也是刚接触 ai agent 开发,修行尚浅,望路过大佬们见谅。后面其实还能做进阶优化,这也是我打算在这个专栏接着探索的尝试:

  1. Hybrid Search (混合检索):可叠加 BM25 关键词,解决专业术语丢失的问题。
  2. Re-ranking (重排):引入专门的重排模型(如 BGE-Reranker),精准过滤无效的“相似废话”。
  3. Agentic Flow (智能体):接入 Function Calling,从「问答」走向「任务执行」,比如让其检索其他数据库的内容或者联网搜索外部资源。

拥抱 AI 时代,把手弄脏,我们下篇见。

· 18 min read

autopilot 需要数据才能跑起来,想了下自己造数据太麻烦了,所以基于 konva(canvas)做一个场景编辑器来造点数据,目标是通过编辑器编辑地图和基于地图编辑一个基础场景(自车+车道+环境车+行驶模拟),并在 autopilot 应用中跑起来

这里咱直接用成熟的 canvas 库 konva 来搭建页面,这个库可以先用官网的 editor-demo 体验下。konva 的图层层级结构分为 Stage -> Layer(层) (-> Group) -> Shape(元素),可以先熟悉下这几个概念,不熟的话可能比较难以看懂下面的示例代码

页面布局

稍微设计一下:中间画布+顶部操作栏+右侧属性面板。关于组件库的话,antd 够用了,如下图所示:

页面布局

暂时先只做一个地图和一个场景,后面再扩展下多对多的情况

数据管理

通过 mobxmobx-react-lite 来管理编辑器的数据。新建变量的时候,很多情况下你只需要一个全局变量而不是响应式变量,所以还是建议在 makeObservable 指定变量是否可观察(而不是一味 makeAutoObservable 虽然用这个有点爽),另外可以善用下observable.shallow 做浅层的观察,毕竟复杂的大对象在生成响应式对象时还是有一定开销的

// store/index.ts
import Konva from "konva";
import { makeObservable, observable, computed, action } from "mobx";

class EditorStore {
stage: IStage = {
ref: null, // 暂存konva画布实例
};
editMode = EditMode.Map; // 编辑模式,分为地图和场景编辑
isEdit = false; // 编辑锁
isDrawLine = false; // 画线锁
// 统一处理场景元素的绘制
drawCallForScene = (type: ESceneElement) => {
this.isEdit = true;
switch (type) {
case ESceneElement.Vehicle: {
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 统一处理地图元素的绘制
drawCallForMap = (type: EMapElement) => {
this.isEdit = true;
switch (type) {
case EMapElement.Line: {
this.isDrawLine = true;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 在初始化画布时执行,挂载画布事件
initStage = () => {
const stage = this.stage.ref!;
const layer = stage.getLayers()[0];
// ...
};
// 地图
mapList = [];
currentMap = "";
// 场景
sceneList = [];
currentScene = "";
// 元素
selectedElement = null;
// 当前选中的元素的属性,基础属性包括位置、颜色、旋转、大小、名称等
selectedElementProps = null;

constructor() {
makeObservable(this, {
stage: observable.shallow,
editMode: observable,
// ...文章代码可能不全,感兴趣自行查阅github源码
});
}
}

初始化页面

新建一个 react 路由页面,初始化画布,监听画布事件比如点击、鼠标事件等,并自适应容器宽高

// scene-editor/index.tsx
const SceneEditor = observer(() => {
const { stage } = editorStore;
const containerRef = useRef<HTMLDivElement>(null);
const [shapes, setShapes] = useState<Konva.ShapeConfig[]>([]);
const [selectedId, setSelectedId] = useState<string>();

// 初始化画布
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
stage.ref = new Konva.Stage({
container: containerRef.current,
width: container.clientWidth,
height: container.clientHeight,
draggable: true,
});
// 初始化图层
const layer = new Konva.Layer();
stage.ref.add(layer);
const resizeStage = () => {
stage.ref!.width(container.clientWidth);
stage.ref!.height(container.clientHeight);
// 批量绘制(放到同一帧绘制)
stage.ref!.batchDraw();
};
editorStore.initStage();
// 监听窗口变化
window.addEventListener("resize", resizeStage);
// 监听缩放
stage.ref.on("wheel", (e) => {
e.evt.preventDefault();
handleWheel(e.evt);
});
return () => {
stage.ref?.destroy();
window.removeEventListener("resize", resizeStage);
};
}
}, []);

return (
<div className="scene-editor">
{/* 顶部状态栏 */}
<Header />
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{/* 侧边栏 */}
<RightSider />
</div>
);
});

代码可能不全,可以查阅 github 源码

自车

自车默认位于原点位置,这里先简单用一个绿色矩形框来替代,在初始化的时候就自动添加到场景中。并且这里要先加一个元素控制器 Konva.Transformer,便于调整矩形框的大小和旋转角度

// store/index.ts
// ...
initStage = () => {
// ...
// 初始化控制器
this.transformer = new Konva.Transformer({
// 启用旋转
rotateEnabled: true,
// 设置旋转吸附角度
rotationSnaps: [0, 90, 180, 270],
});
layer.add(this.transformer);
// ...然后监听点击事件,在点击到具体shape的时候动态加上控制器
stage.on("click", (e) => {
if (e.target === stage) {
this.transformer!.nodes([]);
} else {
const target = e.target as Konva.Shape;
// 这里其实可以做下限制,有些元素可能是不允许编辑的
// 比如地图元素在场景编辑模式下是不可编辑的
// if (target !== this.currentLine) {
this.transformer!.nodes([target]);
this.selectedElement = target;
// }
}
});
};

自车相关的代码如下:

// store/index.ts
// ...
initStage = () => {
//...
// 绘制自车
const autoCar = createRect({
fill: "green",
x: 0,
y: 0,
width: 30,
height: 20,
strokeWidth: 1,
});
layer.add(autoCar);
};

自车和控制器

基础图形封装

其实 konva 本身已经封装了现成的图形绘制函数,这里是二次封装简化下代码量,基类代码如下:

import Konva from "konva";

type ShapeType = "rect" | "circle" | "line" | "polygon" | "triangle";

export function createRect(config: Konva.RectConfig) {
return createShape("rect", {
width: 100,
height: 80,
...config,
});
}

export function createCircle(config: Konva.CircleConfig) {
return createShape("circle", {
radius: config?.radius || 50,
...config,
});
}

export function createTriangle(config: Konva.ShapeConfig) {
return createShape("triangle", {
offset: { x: 0, y: -15 }, // 居中调整
...config,
});
}

/**
* 图形基类
*/
export function createShape(type: ShapeType, config: any) {
const defaults = {
x: 0,
y: 0,
fill: Konva.Util.getRandomColor(),
draggable: true,
stroke: "#333",
strokeWidth: 2,
};

const shapeConfig = { ...defaults, ...config };
let shape: Konva.Shape;

switch (type) {
case "rect":
shape = new Konva.Rect(shapeConfig);
break;
case "circle":
shape = new Konva.Circle({
radius: 50,
...shapeConfig,
});
break;
case "line":
shape = new Konva.Line({
points: [0, 0, 100, 100], // 默认对角线
lineCap: "round",
...shapeConfig,
});
break;
case "polygon":
shape = new Konva.RegularPolygon({
sides: 5, // 默认五边形
radius: 60,
...shapeConfig,
});
break;
case "triangle":
shape = new Konva.Shape({
sceneFunc: function (context: Konva.Context, shape: Konva.Shape) {
context.beginPath();
context.moveTo(0, -30);
context.lineTo(30, 30);
context.lineTo(-30, 30);
context.closePath();
context.fillStrokeShape(shape);
},
...shapeConfig,
});
break;
}
shape!.on("click", () => console.log(`${type} clicked`));
shape!.on("dragend", () => console.log(`${type} moved`));
return shape!;
}

交互实现

整体交互是先在场景编辑器编辑地图和交通场景,通过 localStorage 保存数据,然后通过指定按钮快速跳转到 autopilot 3d 页面查看仿真效果,可以翻到最后先看下录屏效果

地图交互

地图拖动。配置支持 draggable

new Konva.Stage({
container: containerRef.current,
width: container.clientWidth,
height: container.clientHeight,
draggable: true,
});

地图缩放。下面以鼠标滚轮缩放为例,基于鼠标位置做画布的缩放

// scene-editor/index.tsx
// 监听鼠标滚轮事件
const MIN_SCALE = 0.5; // 最小缩放比例
const MAX_SCALE = 3; // 最大缩放比例
const handleWheel = (e: any) => {
const stageRef = stage.ref!;
const step = 1.1;
const oldScale = stageRef.scaleX();
const pointer = stageRef.getPointerPosition()!;
let newScale = e.deltaY < 0 ? oldScale * step : oldScale / step;
// 有最大和最小范围限制
newScale = Math.min(Math.max(newScale, MIN_SCALE), MAX_SCALE);
const mousePointTo = {
x: (pointer.x - stageRef.x()) / oldScale,
y: (pointer.y - stageRef.y()) / oldScale,
};
stageRef.scale({ x: newScale, y: newScale });
const newPos = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
stageRef.position(newPos);
stageRef.batchDraw();
};
// ...

一键返回原点。可以在左上角加个悬浮按钮居中画布原点

// store/index.ts
focusOrigin = () => {
if (this.stage.ref) {
// 清除拖拽产生的位移偏差
this.stage.ref.x(0);
this.stage.ref.y(0);
const centerX = -this.stage.ref.width() / 2;
const centerY = -this.stage.ref.height() / 2;
this.stage.ref.offset({ x: centerX, y: centerY });
}
};

线段绘制

  • 支持绘制直线和虚线
  • 编辑过程加绘制锁,按 Q 键(Quit)退出线段绘制

这里需要在 initStage 监听鼠标点击和移动事件,具体看代码注释清晰点:

// store/index.ts
// 暂存的线段对象,用于绘制多段直线
// ...
isDrawLine = false;
currentLine: Konva.Line | null = null;
// 点击顶部操作栏的线段按钮,会加编辑锁,便于多段线段的绘制
drawCallForMap = (type: EMapElement) => {
this.isEdit = true;
switch (type) {
case EMapElement.Line: {
this.isDrawLine = true;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 在初始化画布时执行,挂载画布事件
initStage = () => {
const stage = this.stage.ref!;
const layer = stage.getLayers()[0];
// 点击时新建线段
stage.on("mousedown", () => {
if (this.isDrawLine) {
const pos = stage.getPointerPosition()!;
this.currentLine = new Konva.Line({
points: [pos.x, pos.y],
fill: "yellow",
stroke: "yellow",
strokeWidth: 2,
});
layer.add(this.currentLine);
}
});
// 移动鼠标可以选择下一段线段的落点
// TODO 这里其实可以做下节流
stage.on("mousemove", () => {
if (this.currentLine && this.isDrawLine) {
const pos = stage.getPointerPosition()!;
let newPoints = [];
if (this.currentLine.points().length > 2) {
newPoints = this.currentLine
.points()
.slice(0, -2)
.concat([pos.x, pos.y]);
} else {
newPoints = this.currentLine.points().concat([pos.x, pos.y]);
}
this.currentLine.points(newPoints);
layer.batchDraw();
}
});
// 监听键盘事件,这里设定在点击ESC键时结束线段的绘制
const onKeydown = (e: any) => {
if (e.key === "q") {
if (this.isDrawLine && this.currentLine) {
const newPoints = this.currentLine.points().slice(0, -2);
this.currentLine.points(newPoints);
layer.batchDraw();
this.isDrawLine = false;
this.currentLine = null;
}
}
};
window.addEventListener("keydown", onKeydown);
};

绘制多段线段

车道绘制

这里设计为三段式编辑,第一二次点击确定车道宽度,第三次点击确定长度,并且结束车道绘制。在第三次点击前有一个框体的高亮效果辅助定位范围

// store/index.ts
isDrawMapElement: EMapElement | null = null;
// 车道辅助绘制,计算当前点击的次数
drawLaneCount = 0;
// 当前车道,多边形对象
currentLane: Konva.Shape | null = null;
stage.on("mousedown", () => {
// ...
if (this.isDrawMapElement === EMapElement.Lane) {
// 第一次点击,先绘制宽度线
if (this.drawLaneCount === 0) {
this.currentLine = new Konva.Line({
points: [pointX, pointY],
fill: "yellow",
stroke: "yellow",
strokeWidth: 2,
});
layer.add(this.currentLine);
// 第二次点击,确定宽度
} else if (this.drawLaneCount === 1) {
this.currentLine!.points().push(pointX, pointY);
// 第三次点击,确定长度
} else if (this.drawLaneCount === 2) {
this.currentLane?.destroy();
const points = [
this.currentLine!.points()[0],
this.currentLine!.points()[1],
this.currentLine!.points()[2],
this.currentLine!.points()[3],
pointX,
pointY,
pointX -
(this.currentLine!.points()[2] - this.currentLine!.points()[0]),
pointY -
(this.currentLine!.points()[3] - this.currentLine!.points()[1]),
];
this.currentLane = new Konva.Shape({
// 顶点坐标数组
points,
fill: "yellow",
stroke: "green",
opacity: 0.2,
strokeWidth: 2,
sceneFunc: function (ctx, shape) {
const points = shape.getAttr("points");
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
ctx.fillStrokeShape(shape);
},
});
layer.add(this.currentLane);
this.drawDone(EMapElement.Lane);
}
this.drawLaneCount++;
}
})
stage.on("mousemove", () => {
// ...
if (this.currentLine && this.isDrawMapElement === EMapElement.Lane) {
const pos = stage.getPointerPosition()!;
const pointX = stage.offset().x + pos.x;
const pointY = stage.offset().y + pos.y;
let newPoints = [];
if (this.drawLaneCount === 1) {
if (this.currentLine.points().length > 2) {
newPoints = this.currentLine
.points()
.slice(0, -2)
.concat([pointX, pointY]);
} else {
newPoints = this.currentLine.points().concat([pointX, pointY]);
}
this.currentLine.points(newPoints);
layer.batchDraw();
} else if (this.drawLaneCount === 2) {
this.currentLane?.destroy();
// NOTE 注意按逆时针排,和threejs自定义多边形点集一致
const points = [
this.currentLine!.points()[0],
this.currentLine!.points()[1],
this.currentLine!.points()[2],
this.currentLine!.points()[3],
pointX,
pointY,
pointX -
(this.currentLine!.points()[2] - this.currentLine!.points()[0]),
pointY -
(this.currentLine!.points()[3] - this.currentLine!.points()[1]),
];
this.currentLane = new Konva.Shape({
points,
fill: "yellow",
stroke: "green",
opacity: 0.2,
strokeWidth: 2,
sceneFunc: function (ctx, shape) {
const points = shape.getAttr("points");
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
ctx.fillStrokeShape(shape);
},
});
layer.add(this.currentLane);
}
}
})
// 在这里统一处理绘制结束的逻辑
drawDone = (type: EMapElement | ESceneElement) => {
// ...
if (type === EMapElement.Lane) {
const contour = _.chunk(this.currentLane?.getAttr("points"), 2).map(
(item) =>
({
x: item[0],
y: item[1],
z: 0,
} as { x: number; y: number; z: number })
);
const data: IFreespace = {
id: "freespace" + this.mapElements.lanes.length,
contour,
};
this.mapElements.lanes.push(data);
this.currentLine?.destroy();
this.currentLane = null;
this.currentLine = null;
this.drawLaneCount = 0;
}
}

绘制车道

场景编辑

先支持他车的编辑,以矩形框替代,可以设置高度,看看这个交互是咋样的:

  1. 点击他车按钮
  2. 点击画布任意处新增他车矩形框
  3. 添加他车矩形框,矩形框支持旋转、改变大小和拖动
  4. 处于选中状态时,右侧表单自动回填属性,包括名称、宽高、位置等,可以修改,保存场景时会以最新的属性为准

添加代码参考:

// store/index.ts
// 当前正在绘制的场景元素
isDrawSceneElement: ESceneElement | null = null;
drawCallForScene = (type: ESceneElement) => {
this.isEdit = true;
switch (type) {
case ESceneElement.Vehicle: {
this.isDrawSceneElement = ESceneElement.Vehicle;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// ...
stage.on("click", (e) => {
if (e.target === stage) {
// ...
if (this.isDrawSceneElement === ESceneElement.Vehicle) {
const pos = stage.getPointerPosition()!;
const pointX = stage.offset().x + pos.x;
const pointY = stage.offset().y + pos.y;
const vehicle = createRect({
fill: "blue",
x: pointX,
y: pointY,
width: 40,
height: 30,
strokeWidth: 1,
// 居中
offsetX: 15,
offsetY: 10,
});
layer.add(vehicle);
layer.batchDraw();
// 自动选中
this.transformer!.nodes([vehicle]);
this.isDrawSceneElement = null;
}
} else {
// ...
}
});

添加他车

自车或他车路线编辑

这里先做成匀速的场景,后面可以扩展到更多行驶场景,当然这需要一定的算法 ~ 路线绘制的话,可以复用上面的画线逻辑

选中目标后,路线编辑按钮可点击,这个时候可以编辑对应的路线,路线起点是选中目标的原点,不过这里要注意加一下和他车或自车的关联。另外选中状态下,可以在右侧表单中设置速度

下面是绘制自车路线的示例,环境车其实也类似,注意和路线做下关联就行了:

绘制自车路线

和 autopilot 交互

顶部右侧有一个按钮,可以点击后跳转到 autopilot 页面,快速预览 3d 效果,先简单从 localStorage 读取数据。数据结构方面,2d 元素这里其实和 3d 元素只有一个 z 轴的区别,其他结构其实差不多,场景元素主要都是点集,这里注意下坐标轴方向(编辑器现在 y 轴是反向...有点别扭其实)

保存场景数据

需要遍历场景元素,转换成 autopilot 能接受的接口数据(json),场景数据参考下面的类型定义:

// 编辑器输出的场景数据
export interface ISceneData {
autoCar: {
pos: number[];
rotation: number;
path: number[][];
};
map: IMapElements;
scene: ISceneElements;
}
// 自车属性
export interface IAutoCar {
ref: Konva.Rect | null;
config: {
x: number;
y: number;
rotation: number;
};
path: number[][];
speed: number;
}
// 地图元素集合
export interface IMapElements {
lines: ILine[];
lanes: IFreespace[];
}
// 场景元素集合
export interface ISceneElements {
vehicles: ICube[];
obstacles: ICube[];
}

看下编辑器和 autopilot 初步联动的静态效果:

编辑器:

autopilot 俯视:

再把速度加上,这里就假定车子是匀速,然后车子朝向固定为前后俩点的方向,下面是自车速度的实现示例,当然实际业务研发中,路线主要是上游算法或地图给的点集数据,这里纯粹做个演示,然后他车的匀速行驶也类似:

// renderer/index.ts
// 模拟匀速行驶
// ...
if (path.length > 0) {
// 生成平滑曲线
const curve = new THREE.CatmullRomCurve3(
path.map((p) => new THREE.Vector3(p[0] / 100, -p[1] / 100, 0)),
false
);
const totalLength = curve.getLength();
// 总时长 ms
this.totalDuration = (totalLength / speed) * 1000;
// 拆分为5000个点
this.pathPoints = curve.getPoints(5000);
}
// ...
totalDuration = 0;
pathPoints: any[] = [];
startTime = 0;
currentIndex = 0;
mockAutoDrive() {
if (!this.startTime) this.startTime = performance.now();
const elapsed = performance.now() - this.startTime;
const progress = Math.min(elapsed / this.totalDuration, 1);
this.currentIndex = Math.floor(progress * (this.pathPoints.length - 1));
if (this.currentIndex < this.pathPoints.length - 1) {
const currentPoint = this.pathPoints[this.currentIndex];
const nextPoint = this.pathPoints[this.currentIndex + 1];
// 动态计算自车位置
this.egoCar!.group.position.set(currentPoint.x, -currentPoint.y, 0);
// 自车朝向
const dx = nextPoint.x - currentPoint.x;
const dy = nextPoint.y - currentPoint.y;
this.egoCar!.group.rotation.z = -Math.PI / 2 + Math.atan2(dx, dy);
}
}

sim

最后

现在元素还比较少,功能相对简单,后面复杂一点还要碰一碰算法和性能问题

anyway,麻雀虽小,bug 多多<抱拳>,keep learning and respect

· 12 min read

之前在 绘制他车参与物TextGeometryFontLoader 实现了 3d 文字,但其实能展示的内容比较有限,并且观察受视角影响,比较简单的解决方法是用 2d 的悬浮标签卡片(DOM 元素)来展示更多的信息,支持点击 3d 物体打开标签文本,并且能实时跟随物体

Raycaster

光线投射 Raycaster主要用于进行鼠标拾取,帮助我们在三维场景里计算出鼠标点击到的物体。因为在 threejs 场景里面渲染一个物体是三维形式的,但是最终展示在屏幕上都是二维的,这里是先将三维的世界坐标经过矩阵变换和投影计算,最终算出它在屏幕上对应的位置,主要方法是 raycaster.intersectObjects(objects: Array,recursive:Boolean,optionalTarget:Array)。当第二个参数设置为true时,intersectObjects方法会递归检查传入对象的所有后代对象,不仅检查传入的直接对象,还会检查该对象的所有子对象等

从下面这段官方示例出发:

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function onPointerMove(event) {
// 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1,1)
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function render() {
// 通过相机和鼠标位置更新射线
raycaster.setFromCamera(pointer, camera);
// 计算出和射线相交的物体
const intersects = raycaster.intersectObjects(scene.children);
for (let i = 0; i < intersects.length; i++) {
intersects[i].object.material.color.set(0xff0000);
}
renderer.render(scene, camera);
}
window.addEventListener("pointermove", onPointerMove);
  1. 当鼠标移过 mesh 时,收集到当前鼠标的屏幕坐标,将其归一化为标准设备坐标(Normalized Device Coordinates,NDC)。这个转换过程可以参考下图,首先是明确 canvas 的标准设备坐标系是中点为(0,0),然后 x/y 轴范围在(-1,1)之间(和 canvas 坐标系是有差异的,比如 y 轴方向和归一化),然后再思考怎么将屏幕坐标系的坐标(下图蓝色)转换为标准设备坐标(下图红色)

屏幕坐标系和标准设备坐标系

  1. 渲染循环中更新射线,也就是更改 pointer,这条射线指的是从 camera 发出并指向 pointer 的射线
  2. 计算 3d 场景中与射线相交的所有物体 intersects,这里面会涉及到矩阵变化和投影计算
  3. 将经过的物体材质设置为红色。如下图的视椎体示例:

射线图示

标签卡片

这个卡片主要是放在自车、参与物或障碍物上方,用于显示一些信息,比如自车或他车的 id、类型、速度和大小等信息。和上面的示例一样,主要实现原理是世界坐标和屏幕坐标的互相转换,然后用携带指定样式的 div 来显示那些文本信息,并且在实时场景下,能跟随在参与物的上方

// dom节点操作
const dom = document.createElement("div");
dom.setAttribute("id", egoCarLabelString);
dom.setAttribute("class", "label-box");
// 往canvas画布添加绝对定位的悬浮dom
canvasContainer.appendChild(dom);
// 移除dom
// canvasContainer.appendChild(dom);
// 如果不是第一次生成,只需要调整display就行
// dom.style.display = 'none' | 'block'
// ...

标签文本的样式参考,然后通过 translate 实现移动,可以实现标签文本跟随物体的效果

/* 标签文本样式 */
.label-box {
display: block;
position: absolute;
top: 0;
left: 0;
padding: 2px;
color: #fff;
font-size: 10px;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.6);
}

全局变量

简单点直接挂载到 window 变量上(后面计划引入 mobx 来维护全局 store),这里先存一下画布的 dom 节点和宽高信息,然后别忘了在页面 resize 的时候更新下宽高

// src/renderer/index.ts
initialize() {
const container = document.getElementById("my-canvas");
const width = container.offsetWidth,
height = container.offsetHeight;
window.canvasRef = {
container,
width,
height,
};
}
// ...
window.addEventListener("resize", this.onResize, false);
onResize() {
const container = document.getElementById("my-canvas");
const width = container.offsetWidth,
height = container.offsetHeight;
// 更新画布宽高
window.canvasRef.width = width;
window.canvasRef.height = height;
}

点击显示

比如我们要在 3d 场景的自车附近支持点击打开一个展示自车详细信息的标签卡片,先监听 canvas 节点的点击事件:

export default class EgoCar {
constructor(scene: THREE.Scene) {
this.scene = scene;
this.initialze();
this.clickObject = this.clickObject.bind(this);
window.canvasRef.container.addEventListener("click", this.clickObject);
}
clickObject() {}
// ...
}

然后在点击事件里判断射线和自车是否相交,是的话将 label 卡片显示出来,显示出来后加个状态锁 showLabel,说明当前已打开标签卡片,如果再点击则视为关闭标签卡片,所以其实会有两次坐标转换:

  • 点击自车时,从屏幕坐标转世界坐标,才能判断是否点击到了自车
  • 标签显示时,从世界坐标转屏幕坐标,让标签卡片显示在正确的屏幕位置

当然你也可以用 CSS2DRenderer 这个扩展库来简化上述坐标转换的代码

// ...
showLabel = false;
// 自车详细信息
carData = {
name: "egoCar",
velocity: {
x: 10,
y: 20,
},
};
// ...
clickObject(e: any) {
const canvasRef = window.canvasRef;
const mouseVector = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
mouseVector.x = (e.offsetX / canvasRef.width) * 2 - 1;
mouseVector.y = -(e.offsetY / canvasRef.height) * 2 + 1;
raycaster.setFromCamera(mouseVector, this.camera);
// 第二个参数是指是否递归检查
const intersects = raycaster.intersectObjects(this.car.children, true);
if (intersects.length > 0) {
this.triggerLabelBox();
}
}

triggerLabelBox() {
const canvasContainer = this.container!;
const dom = document.getElementById(egoCarLabelString);
if (!dom) {
const newBox = document.createElement("div");
newBox.setAttribute("id", egoCarLabelString);
newBox.setAttribute("class", "label-box");
canvasContainer.appendChild(newBox);
this.updateLabelBox(newBox);
this.showLabel = true;
} else {
if (this.showLabel) {
dom.style.display = "block";
this.updateLabelBox(dom);
} else {
dom.style.display = "none";
}
}
}

updateLabelBox(dom: HTMLElement) {
const canvasRef = window.canvasRef;
const x = this.group.position.x;
const y = this.group.position.y;
const vector = new THREE.Vector3(x, y, 0.1);
// 将世界坐标转为标准设备坐标
vector.project(this.camera);
const w = canvasRef.width / 2;
const h = canvasRef.height / 2;
const offsetX = Math.round(vector.x * w + w);
const offsetY = Math.round(-vector.y * h + h);
dom.innerText = `${this.carData.name}\nvx:${this.carData.velocity.x} vy:${this.carData.velocity.y}`;
dom.style.transform = `translate(${offsetX}px,${offsetY}px)`;
}

自车标签文本

自动显示

先关联下他车 id 和对应的cube,先将 id 挂载到 cubeuserData上(如果需要支持点击显示,那别放到 cube 对象上,因为它是一个 Group,射线会检测不出来,这时候可以放到 cube 的第一个子 mesh 上)然后可以把需要显示到标签文本的信息比如长宽高、type 和速度等信息挂载上去

// src/renderer/cube.ts
// ...
draw(datas: ICube[]) {
// 遍历创建cube group
datas.forEach((data) => {
const group = new THREE.Group();
// ...
group.userData.id = data.id;
group.userData.type = data.type;
group.userData.width = data.width;
group.userData.height = data.height;
this.scene.add(group);
})
}

他车参与物在道路场景里是经常变化的,它们也可以展示一些标签卡片,并且随着参与物位置的变化实时变化标签卡片的位置。不过他车可能还会多一些展示信息比如 id 和类别等,并且这里需要将他车 id 和标签卡片的 dom id 关联起来,方便后续查询并更新标签卡片内容。主体坐标转换的逻辑和自车的标签卡片是一样的,代码参考以下:

// src/renderer/cube.ts
// ...
triggerLabelBox() {
const canvasContainer = window.canvasRef.container!;
this.cubes.forEach((cube) => {
// 关联他车id和标签文本的dom节点,便于后续查询和更新
const dom = document.getElementById(`cube-label-${cube.id}`);
if (!dom) {
const newBox = document.createElement("div");
newBox.setAttribute("id", `cube-label-${cube.id}`);
newBox.setAttribute("class", "label-box");
canvasContainer.appendChild(newBox);
this.updateLabelBox();
} else {
dom.style.display = "block";
this.updateLabelBox();
}
});
}

updateLabelBox() {
const canvasRef = window.canvasRef;
this.cubes.forEach((cube) => {
const dom = document.getElementById(`cube-label-${cube.id}`);
if (dom) {
const x = cube.position.x;
const y = cube.position.y;
const vector = new THREE.Vector3(x, y, 0.1);
// 将世界坐标转为标准设备坐标
vector.project(this.camera);
const w = canvasRef.width / 2;
const h = canvasRef.height / 2;
const offsetX = Math.round(vector.x * w + w);
const offsetY = Math.round(-vector.y * h + h);
dom.innerText = `${cube.userData.id}-${cube.userData.type}\nsize:[1.3,2.4,1.2]`;
dom.style.transform = `translate(${offsetX}px,${offsetY}px)`;
}
});
}

但这里要注意下他车数量可能很多,会造成 dom 节点过多且经常回流重绘的情况,这里最起码需要确保的一点是,在他车或障碍物不可见的时候,将对应的标签卡片的 dom 节点移除掉。判断可见的逻辑可以参考:

// 1.有些他车不在视椎体范围内,但仍然有数据,可以把标签文本移除掉
const vector = new THREE.Vector3(x, y, height / 2);
const temp = vector
.applyMatrix4(this.camera.matrixWorldInverse)
.applyMatrix4(this.camera.projectionMatrix);
if (Math.abs(temp.x) > 1 || Math.abs(temp.y) > 1 || Math.abs(temp.z) > 1) {
// 在视野外,移除对应dom节点
window.canvasRef.container.removeChild(dom);
} else {
// 在视野内,更新文本
}
// 2.上游数据主动将他车移除掉的时候,也要同步做下移除dom节点

ok,mock 几个他车的数据,看下行驶后标签文本跟随的效果

标签文本跟随动画

目前还是纯前端模拟行驶动画,正常业务场景下应该是算法数据驱动,后面把数据链路和场景元素都完善了再补一个更准确的场景吧

最后

  • 仓库地址
  • 原文地址

· 8 min read

在智驾场景里,元素的坐标数据可能基于自车坐标系(自车不动)或者原点坐标系(自车动)。为了简单点,autopilot 先基于原点坐标系来 mock 道路和障碍物等,然后回基于 tween 来做行驶的动画演示,既然是原点坐标系,那意味着自车需要实时更新位置信息和偏转方向,所以就需要实现跟车相机

跟车相机

跟车相机朝向约定为 x 轴正向和 y 轴正向,这样在俯视视角正符合二维坐标轴的情况,便于后面 mock 行车的数据

camera.up.set(0, 0, 1);
camera.position.set(-4, -0.4, 1.4);

自车 Group

这里先将自车携带的元素加到一个 Group里,比如车灯、扩散光环等,便于在自车更新位置和朝向的时候统一更新

export default class EgoCar {
group = new THREE.Group();

loadEgoCar() {
const loadEgoCar = abortWrapper(
loadDracoGLTFWithPromise(carModelWithDraco)
);
return loadEgoCar.then((gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(-Math.PI / 2);
// 车灯和扩散光环类似,通过group.add加入
this.group.add(car);
this.scene.add(this.group);
});
}
}

lookAt

在渲染循环里动态更新相机位置,始终位于相机后上方,用 lookAt 让相机始终朝向目标,然后用 tween 模拟一段向前行驶的动画

// ...
const egoCar = new EgoCar(scene);
this.camera.lookAt(egoCar.group.position);
// ...
function animate() {
controls.update();
camera.position.x = egoCar.group.position.x - 5;
camera.position.y = egoCar.group.position.y - 0.4;
camera.lookAt(egoCar.group.position);
renderer.render(scene, camera);
}
// ...
// 模拟向前行驶
runEgoCar() {
if (this.egoCar) {
const animate = new Tween(this.egoCar.group.position)
.to({ x: 10, y: 0, z: 0 }, 5000)
.easing(Easing.Quadratic.InOut)
.start();
setInterval(() => {
animate.update();
}, 50);
}
}

但这里后发现OrbitControls并不能正确转动了,因为在渲染循环里动态改了相机位置,导致控制相机也始终固定在相机那个位置

camera.position.x = egoCar.group.position.x - 5;
camera.position.y = egoCar.group.position.y - 0.4;

这里可以尝试加一个 fakeCamera 作为控制器的辅助相机,camera 始终与其同步,比如旋转相机时更新相机的顺序:fakeCamera > camera,然后再根据自车运动距离进一步更新相机位置,并且可以把更新相机的函数抽出来

// ...
function updateCamera() {
const position = egoCar.group.position;
// 将 fakeCamera 的属性同步给 camera
camera.copy(fakeCamera);
const x = fakeCamera.position.x;
const y = fakeCamera.position.y;
// 相机和自车保持一个固定的偏移
camera.position.x = position.x + x;
camera.position.y = position.y + y;
}
function animate() {
updateCamera();
controls.update();
renderer.render(scene, camera);
}

自车直线行驶

转向

一般情况下可以从规控数据中拿到自车偏转角 yaw,可以转成 x/y 轴平面的弧度值。同样这里先用 tween 模拟一下自车转向,需要加多几组动画。这里怎么统一更新 tween 动画?类似于 THREE.Group,tween 也支持 Group,可以统一管理一组动画的更新,在旧版本的 tween 可以直接用 TWEEN.update,新版已经标记为弃用了

const tweenGroup = new TWEEN.Group();
// ...
const animate = () => {
this.updateCamera();
// 统一更新动画
tweenGroup.update();
this.controls!.update();
this.renderer.render(scene, camera);
};
// ...
runEgoCar() {
if (this.egoCar) {
const animate2 = new Tween(this.egoCar.group.position)
.to(
{
y: -0.5,
},
2000
)
.start();
const animate = new Tween(this.egoCar.group.position)
.delay(500)
.to(
{
x: 10,
},
5000
)
.easing(Easing.Quadratic.In)
.start();
const rotationAnimate = new Tween(this.egoCar.group.rotation)
.to(
{
z: -Math.PI / 4,
},
1200
)
.start()
.onComplete(() => {
const rotationAnimate2 = new Tween(this.egoCar!.group.rotation)
.to(
{
z: 0,
},
1600
)
.start();
tweenGroup.add(rotationAnimate2);
});
tweenGroup.add(animate, animate2, rotationAnimate);
}
}

更新自车转向的时候,相机也应该有一个同样的偏转,目标就是让相机能和自车保持相对静止。这里主要就是做一些正余弦计算来获取更新后的相机位置,计算逻辑参考代码:

const fakeCameraDirection = new THREE.Vector3();
// ...
updateCamera = () => {
// 将 fakeCamera 的属性同步给 camera,也就是旋转或缩放场景后更新的相机属性
this.camera.copy(this.fakeCamera);
const position = this.egoCar.group.position;
const rotation = this.egoCar.group.rotation;
const x = this.fakeCamera.position.x;
const y = this.fakeCamera.position.y;
// 获取相机视线的方向向量
this.fakeCamera.getWorldDirection(fakeCameraDirection);
// 计算相机方向在xy平面上的弧度值
const directionTheta = Math.atan2(
fakeCameraDirection.y,
fakeCameraDirection.x
);
const camera2egocarDistance = Math.sqrt(x * x + y * y);
this.camera.position.x =
position.x - camera2egocarDistance * Math.cos(rotation.z + directionTheta);
this.camera.position.y =
position.y - camera2egocarDistance * Math.sin(rotation.z + directionTheta);
this.camera.lookAt(position.x, position.y, position.z);
};

自车转向

更新视角

主要是跟车视角和俯视视角的切换,或者可以记住用户自定义的视角。这里先看下怎么支持做这个切换,之前其实简单加过一版视角切换,但因为我们新增了 fakeCamera 所以这里要更新下实现逻辑

// src/renderer/index.ts
// ...
switchCameraView(view = EViewType.FollowCar) {
this.cameraView = view;
switch (view) {
// 跟车
case EViewType.FollowCar: {
this.resetFakeCamera();
this.fakeCamera.position.set(-4, -0.4, 1.4);
break;
}
// 俯视横向
case EViewType.Overlook: {
this.resetFakeCamera();
this.fakeCamera.position.set(0, 0, 20);
break;
}
// 俯视纵向
case EViewType.OverlookVertical: {
this.resetFakeCamera();
this.fakeCamera.position.set(0, 0, 20);
this.controls.rotate(Math.PI / 2);
break;
}
default:
break;
}
}

但是新版的 controls 没有直接暴露 rotate 方法,因为我之前用的 three 旧版本有提供这个方法,可以很方便地改变控制相机的方向,暂时还没找到平替的方法(知道的大佬可以帮忙解答一下 thx~ three 官方也有相关issueissue2)这里我先直接改的源文件,增加了一个 rotate 方法,文件位置在src/helper/three/OrbitControls.js,然后再重新引入

// src/helper/OrbitControls.js
// ...
this.rotate = function (degrees) {
rotateLeft(degrees);
this.update();
};
// src/renderer/index.ts
import { OrbitControls } from "../helper/three/OrbitControls.js";

自车转向

观察相机

需要一个辅助相机,然后借助 CameraHelper来观察我们正在用的透视相机

// 辅助相机
const camera2 = new THREE.PerspectiveCamera(45, width / height, 0.01, 1000);
camera2.position.set(-10, -5, 4);
// camera2.lookAt(0, 0, 0);
camera2.up.set(0, 0, 1);
// 观察原有相机
const cameraHelper = new THREE.CameraHelper(camera);
scene.add(cameraHelper);
const controls = new OrbitControls(camera2, renderer.domElement);
this.controls = controls;
const animate = () => {
// ...
this.renderer.render(scene, camera2);
};

观察相机

PerspectiveCamera(fov: number, aspect: number, near: number, far: number)可以调节相机参数直观地看看效果,这里也可以换成正交相机试试

  • fov — 摄像机视锥体垂直视野角度
  • aspect — 摄像机视锥体长宽比
  • near — 摄像机视锥体近端面
  • far — 摄像机视锥体远端面

最后

· 12 min read

有时候为了提高智驾 3d 场景的视觉体验,可能会提一些动画的需求,比如扩散光环、雷达波、避障警告和泊车指示等,这些其实涉及到 threejs 渲染循环、补间动画、场景亮度和相机等的配合

动画最基础的概念是 关键帧,每个关键帧由三部分组成:时间、属性和值

例如定义下面几帧,针对 mesh 某些属性值做一些变化:

  • 在 0 秒 position是(0,0,0)
  • 在 3 秒 position是(1,0,0) scale是(2,2,2)
  • 在 6 秒 position是(2,0,0) scale是(3,3,3)

这个动画就是物体随着时间沿着 x 轴平移并且慢慢变大。动画说白了其实就是一帧帧图像组合起来的,只要帧率够高,人的眼睛就感觉不到卡顿。其实我们这个 autopilot 应用本身就有一个渲染循环,所以可以根据时间、距离等值的变化来动态修改几何体或材质的属性从而实现一些动画效果,比如让一个 mesh 绕 z 轴自旋转:

// ...
renderer.setAnimationLoop(animate);
function animate() {
controls.update();
// 每帧绕z轴旋转0.01弧度
mesh.rotateZ(0.01);
renderer.render(scene, camera);
}

而为了确保动画效果平滑,我们可以借助 tween.js来做补间动画,这是 官方使用 tween.js 的示例。下文都是用 tween.js 来做补间动画,注意别装错包了 pnpm i @tweenjs/tween.js

扩散光环

可以在自车底部用一个带渐变色的扩散光环表示自车处于智驾状态,通过光环颜色、渐变程度或者其他样式可以达到区分不同功能状态的效果

贴图版本

这个方法就比较简单了,通过创建一个 CircleGeometry 几何体,然后把贴图加载上去,动态调整大小 scale 和透明度 opacity 来实现扩散效果。先封装一下自车类,便于后续针对自车增加功能。这里加载模型和贴图的函数都是之前封装过的,如果代码理解起来有些吃力建议先看下专栏之前的文章

// src/renderer/egoCar/index.ts
import * as THREE from "three";
import carModelWithDraco from "@/assets/models/su7-draco.glb";
import haloImg from "@/assets/textures/halo.png";
import { abortWrapper } from "../../helper/promise";
import { loadDracoGLTFWithPromise, loadTexture } from "../../helper";

export default class EgoCar {
scene = new THREE.Scene();
constructor(scene: THREE.Scene) {
this.scene = scene;
this.initialze();
}

loadEgoCar() {
const loadEgoCar = abortWrapper(
loadDracoGLTFWithPromise(carModelWithDraco)
);
return loadEgoCar.then((gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
});
}

async initialze() {
await this.loadEgoCar();
await this.drawDynamicHalo();
}

// 绘制光环
async drawDynamicHalo() {
const egoCarHalo = await loadTexture(haloImg);
const geometry = new THREE.CircleGeometry(1, 32);
const material = new THREE.MeshBasicMaterial({
map: egoCarHalo,
transparent: true,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = 0.02;
this.scene.add(mesh);
}
}

接下来做个动画,用 tween 做个补间动画,通过调整光环半径和透明度,从而实现动态的扩散光环

async drawDynamicHalo() {
// ...
const tweenScale = new Tween(mesh.scale)
.to({ x: 6, y: 6, z: 1 }, 1000)
.easing(Easing.Quadratic.In)
.start();
// 光环消失前做个透明度渐变
const tweenOpacity = new Tween(mesh.material).to({ opacity: 0.1 }, 500);
// 衔接两种补间动画
tweenScale.chain(tweenOpacity);
tweenOpacity.chain(tweenScale);
// 先用定时器做更新,不过还是建议放到渲染循环里
setInterval(() => {
tweenScale.update();
tweenOpacity.update();
}, 50);
}

贴图版本的扩散光环

感觉 tween 的链式调用好优雅呀 ~ 有 jq 那味

旋转角度时,会发现下方的车道线会消失,这是什么情况?其实是深度测试的问题,这里把光环材质的 depthWrite 属性设置为 false 可以解决,这个值表示渲染这个材质对深度缓冲区无影响,也就解决了遮挡问题。进一步了解深度测试可以参考 这篇文章

shader 版本

贴图的版本其实比较简单,在纯展示的场景下可以应付,不过如果需要变色或者做局部变形的,建议用自定义 shader 来实现。对于自定义 shader 材质和 glsl 语言不熟悉的,可以参考下我之前写的 threejs 自定义 shader 实现线元素。和一开始贴图的效果类似,最外层是传入的颜色,然后往中间做个透明度的线性变化。这里用到了 smoothstep 函数,它可以用来生成 0 到 1 的平滑过渡值

export function getHaloShader(option: {
// 最大半径
radius?: number;
opacity?: number;
// 颜色定义
color?: string;
}) {
const material = new THREE.ShaderMaterial({
uniforms: {
radius: { value: option.radius ?? 1 },
opacity: { value: option.opacity ?? 1.0 },
color: {
value: option.color ?? new THREE.Color("#00ffff"),
},
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
uniform float opacity;
varying vec2 vUv;
void main() {
float radius = length(vUv - 0.5); // uv坐标到中心的距离
float alpha = smoothstep(0.36, 0.5, radius) * opacity;
gl_FragColor = vec4(color, alpha);
}
`,
});
material.transparent = true;
material.side = THREE.FrontSide;
return material;
}

自车shader光环

ok 很像了,接下来搬出 tween.js 实现补间动画,把刚才那段代码加回去就行了

// ...
const tweenScale = new Tween(mesh.scale)
.to({ x: 6, y: 6, z: 1 }, 1000)
.easing(Easing.Quadratic.In)
.start()
.onStart(() => {
mesh.material.opacity = 1;
});
const tweenOpacity = new Tween(mesh.material).to({ opacity: 0.1 }, 300);
// 衔接两种补间动画
tweenScale.chain(tweenOpacity);
tweenOpacity.chain(tweenScale);
// 建议放到渲染循环里
setInterval(() => {
tweenScale.update();
tweenOpacity.update();
}, 50);

自车shader光环

但实际上还能做下优化,多加几个光圈,类似雷达波效果,这里先加三层光圈就行了,后面俩道光环滞后一定时间(tween 的 start 方法可以指定滞后时间)再开始做动画。这糙代码做个参考就行,其实还能做下封装 ~

// ...
const tweenScale = new Tween(mesh.scale)
.to({ x: 12, y: 12, z: 1 }, 3000)
.start(500)
.onStart(() => {
mesh.material.uniforms.opacity.value = 1;
});
// 注意自定义shaderMaterial得修改uniforms的opacity值
const tweenOpacity = new Tween(mesh.material.uniforms.opacity).to(
{ value: 0.1 },
500
);
const tweenScale2 = new Tween(mesh2.scale)
.to({ x: 12, y: 12, z: 1 }, 3000)
.start(2000)
.onStart(() => {
mesh2.material.uniforms.opacity.value = 1;
});
const tweenOpacity2 = new Tween(mesh2.material.uniforms.opacity).to(
{ value: 0.1 },
500
);
const tweenScale3 = new Tween(mesh3.scale)
.to({ x: 12, y: 12, z: 1 }, 3000)
.start(3000)
.onStart(() => {
mesh3.material.uniforms.opacity.value = 1;
});
const tweenOpacity3 = new Tween(mesh3.material.uniforms.opacity).to(
{ value: 0.1 },
500
);
// 衔接缩放和透明度的动画
tweenScale.chain(tweenOpacity);
tweenOpacity.chain(tweenScale);
tweenScale2.chain(tweenOpacity2);
tweenOpacity2.chain(tweenScale2);
tweenScale3.chain(tweenOpacity3);
tweenOpacity3.chain(tweenScale3);
// 可以直接用定时器做更新
setInterval(() => {
tweenScale.update();
tweenOpacity.update();
tweenScale2.update();
tweenOpacity2.update();
tweenScale3.update();
tweenOpacity3.update();
}, 50);

shader雷达波

这里抛个问题,怎么实现特定角度范围的雷达波?后面有机会再写写

行人动画

先要理解 threejs 的动画系统 的几个概念:关键帧 >> 轨迹(帧集合)>> 动画片段(AnimationClip) >> 动画播放控制(AnimationMixer) ,主要是动画片段 AnimationClip 和关键帧轨道 KeyframeTrack 的理解。可以参考 animation-system 解释的很详细。我们可以去官网找一个人物模型来模拟 https://threejs.org/examples/#webgl_animation_multiple。有几点需要注意:

  • 建模时需要设置动画片段,导出后动画信息存在于模型的 animations (AnimationClip 数组)上。但并不是所有模型都支持设置动画,比如 obj 就不支持
  • 这个模型主要有三个动画片段(站立呼吸/行走/奔跑),将它们一块展示出来看看,这里需要用 SkeletonUtils clone 来复制模型,因为模型自带的 clone 可能会导致骨骼与网格之间的关联丢失或出错

接下来封装一下行人的类:

// src/renderer/robot.ts
import * as THREE from "three";
import robotModel from "@/assets/models/robot.glb";
import { loadGLTFWithPromise } from "../helper";
import { SkeletonUtils } from "three/examples/jsm/Addons.js";

export default class Robot {
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer();
skeleton: any = null;
mixer: any = null;
mixers: any[] = [];
actions: any[] = [];
clock = new THREE.Clock();
constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {
this.scene = scene;
this.renderer = renderer;
this.initialze();
}

loadRobotModel() {
let self = this;
const loadEgoCar = loadGLTFWithPromise(robotModel);
return loadEgoCar.then((gltf) => {
const robot = gltf.scene;
robot.scale.set(0.15, 0.15, 0.15);
robot.position.set(0.5, -0.5, 0.02);
robot.rotateX(Math.PI / 2);
const clips = gltf.animations;
robot.traverse(function (object) {
// @ts-ignore
if (object.isMesh) object.castShadow = true;
});
// ...
});
}
async initialze() {
await this.loadRobotModel();
}
}

把 animations 数据打印出来看看

animation-data

可以看到,这里总共包含了四个动画片段,TPose 那个大家有兴趣的话可以实践下看看是啥动作 ~ 这里基本上跟之前讲的一样,层级结构从上至下就是动画片段、帧轨迹、关键帧。那怎么把这个动画片段播放出来,这里就需要借助 THREE.AnimationMixer,传入模型后,配合 clipAction可以做对应模型一个或多个动画片段的播放控制

// ...
let self = this;
const model1 = SkeletonUtils.clone(robot);
const model2 = SkeletonUtils.clone(robot);
const model3 = SkeletonUtils.clone(robot);
const mixer1 = new THREE.AnimationMixer(model1);
const mixer2 = new THREE.AnimationMixer(model2);
const mixer3 = new THREE.AnimationMixer(model3);
model1.position.x = -1;
model2.position.y = -1;
model3.position.y = 1;
mixer1.clipAction(clips[0]).play(); // idle
mixer2.clipAction(clips[1]).play(); // run
mixer3.clipAction(clips[3]).play(); // walk
this.scene.add(model1, model2, model3);
this.mixers.push(mixer1, mixer2, mixer3);
// 建议放到渲染循环里
setInterval(() => {
animate();
}, 50);
function animate() {
const delta = self.clock.getDelta();
for (const mixer of self.mixers) mixer.update(delta);
}

行人动画

最后

emm 这篇肝了蛮久,主要分享了下智驾 3d 场景里面可能涉及的关于 threejs 的一些动画实现,当然这种动效对于智驾并不是强需求,只是作为视觉体验的补充

· 12 min read

autopilot 系列更新到第五篇了,这一篇分享些跟自车相关的优化。关于 threejs 的模型加载,可以用 promise 封装一下,然后提供 abort 方法中断模型加载避免重叠问题,其次是模型加载速度的一些优化,比如 draco 压缩、缓存策略等,然后还有提供相机视角的切换,然后可以做个车灯光源提高下视觉效果

模型相关

自车用的是 gltf 模型,所以下面主要围绕这个格式来说

模型加载进度

默认模型加载器都有内置一个 DefaultLoadingManager,可以参考 GLTFLoader

// ...
loadEgoCar() {
gltfLoader.load(
carModel,
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
},
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
},
function (error) {
console.log(error);
}
);
}

THREE.LoadingManager 可以定制模型加载器

const manager = new THREE.LoadingManager();
manager.onLoad = () => {
console.log("===Loading complete!");
};
manager.onProgress = (url, loaded, total) => {
console.log("===loading", url, loaded, total);
};
const gltfLoader = new GLTFLoader(manager);

对于 GLTFLoader,它可能会加载多个资源(如网格、纹理、材质等),并且这些资源的加载可能是异步的,LoadingManageronProgress 回调中的 total 值可能只代表当前正在处理或已处理的特定资源的一部分或全部的总数据量,所以这里简单用 loaded/total 是不太准确的

加载函数

因为模型加载默认是回调函数,这里可以做个 promise 的封装,实现异步加载和渐进式显示

// src/helper/index.ts
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

export function loadGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}
// src/renderer/index.ts
// ...
loadEgoCar() {
loadGLTFWithPromise(carModel)
.then((gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
})
.catch((err) => console.log(err));
}

加载贴图

贴图也是异步加载的,这里和模型的加载可能存在顺序问题,可以用 Promise.allSettled 确保都模型和对应贴图加载完再更新。如果此时任一方加载失败了,那也可以在这里做个兜底处理,比如贴图换占位图或者提示加载模型失败。当然,如果贴图确实加载的很慢,那还是分开请求避免阻塞模型加载,更新贴图的逻辑其实类似的

// src/helper/index.ts
export function loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
resolve(texture);
},
undefined,
(error) => {
reject(error);
}
);
});
}
// ...
// 在没渲染之前,threejs默认会用占位图替代,避免报错
import testModal from "@/assets/models/xxx.glb";
import testTextures from "@/assets/textures/xxx.png";
Promise.allSettled([
loadGLTFWithPromise(testModal),
loadTexture(testTextures),
]).then((results) => {
const modelResult = results[0];
const textureResult = results[1];
if (modelResult.status === "fulfilled") {
if (textureResult.status === "fulfilled") {
modelResult.value.material.map = textureResult.value;
modelResult.value.material.needsUpdate = true;
}
return;
}
// 其他异常情况可以自行补充,比如提示模型加载失败或者贴图加载失败
// 实际可能有多个模型需要贴图
// model.traverse((child) => {
// if (child.isMesh) {
// model.material.map = texture;
// // 在下一帧应用更新
// model.material.needsUpdate = true;
// }
// });
});

中断异步加载

需要提供中断异步加载的功能,避免模型没加载完时用户又换成到其他模型,这种场景在有多个自车模型的时候会碰到,如果不中断可能造成模型重叠。因为之前用 promise 封装了,所以这里其实就等同于实现一个 promise 的 abort 方法

// 中断promise的辅助函数
export function abortWrapper(p1: Promise<any>) {
let abort;
const p2 = new Promise((resolve, reject) => (abort = reject));
const p = Promise.race([p1, p2]);
p.abort = abort;
return p;
}
// 比如可以调整下自车的加载逻辑
// ...
(this.egoCarLoader = abortWrapper(loadGLTFWithPromise(carModel))).then(
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
}
);
// ...
// 可能某个信号触发中断,直接调用abort, 这里用setTimeout模拟中断效果
setTimeout(() => {
this.egoCarLoader.abort();
}, 10);

但其实还不是很彻底,因为请求还是会正常进行和接收,那怎么中断请求?这一步倒不是很必要,也能做,但是得自行写一个模型加载器,然后通过 xhr 对象的 abort 方法或者 AbortController 来取消请求。可以跟踪下 threejs 的这个 issue,目前还是 open 状态,似乎有希望在未来版本中支持 abort

压缩

自车模型有 9M 左右,还是比较大,可以考虑压缩下模型,GLTF 模型可以通过高效的 Draco 算法来压缩几何数据,并且借助了 webAssembly 来加速计算,但是肯定有额外的解压耗时,一般都是值得的,当然你可以做下耗时对比

npm 全局安装 gltf-pipeline,然后到对应模型的目录下执行 gltf-pipeline -i xxx.glb -o xxx.glb -d。或者找个在线压缩 gltf 的地址快速压缩,比如 这个。如果经常要压缩一些模型的话,每次都这么手动处理肯定很麻烦,可以尝试用 node 写个批处理脚本

压缩后 9M 降到了 3.4M,体积减小还是很明显的。得到压缩后的文件后,那就该准备 DRACOLoader了。新增一个针对压缩后模型的加载函数

export function loadDracoGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
// 设置解压相关文件的路径
dracoLoader.setDecoderPath(
// 将 three/examples/jsm/libs/draco/gltf/ 拷贝到 public 目录来用
"./draco-gltf/"
// 或者也可以直接用这个
// "https://threejs.org/examples/jsm/libs/draco/gltf/"
);
// 使用js方式解压
// dracoLoader.setDecoderConfig({ type: "js" });
// 初始化 initDecoder 解码器
dracoLoader.preload();
// 设置GLTFLoader使用的压缩器
loader.setDRACOLoader(dracoLoader);
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}

缓存

一般场景里用到的模型,比如行人、障碍物、路标等都是经常会出现的,这个时候不能做重复加载,而是要尽可能复用。这里其实做个 map 对象,在接口数据里约定 type 来映射模型就行,可以在初始化界面的时候发起并行请求(Promise.all)获取模型,并保存到 map 对象上,后面可以直接通过 clone复用模型

因为有些识别出来的物体是会连续存在于多帧里的,可以用id唯一标识,这类非新增的物体就可以通过 id 找到模型并更新位置、大小、颜色等属性

其他关于模型的优化还有:

  • 纹理压缩
  • 建模的时候尽量减少顶点和面的数量,借助建模软件做网格简化和合并,从而实现减面
  • LOD 技术(Level of Detail),其实就是根据物体与相机的距离,动态选择渲染不同精度的模型。其实可以配合上述的纹理压缩和减面来做
  • CDN/......

有机会实践后再分享下吧 ~

相机优化

Threejs 相机有透视相机和正交相机。透视相机能模拟我们人眼所看到的景象,它是 web3d 中使用得最普遍的

camera分类

视角切换

因为 autopilot 这个应用主要是用于智驾算法调试,所以肯定涉及到视角切换,比如跟车视角、俯视横向纵向、又或者用户希望能自定义视角,那就需要设计这个视角切换的功能

先设计这几种视角:跟车、俯视横向、俯视纵向,其实实现很简单,就是改变相机的位置和方向。这里需要增加一个悬浮层,放一些交互按钮,注意悬浮层要加一个pointer-events: none来阻止鼠标行为,包括点击、悬停等交互,避免和 OrbitControls 交互冲突

// ...
switchCameraView(view: EViewType) {
this.cameraView = view;
switch (view) {
case EViewType.FollowCar: {
this.camera.up.set(0, 0, 1);
this.camera.position.set(-0.4, 4, 1.4);
break;
}
case EViewType.Overlook: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(0, -1, 0);
break;
}
case EViewType.OverlookVertical: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(1, 0, 0);
break;
}
default:
break;
}
}

俯视视角

上面这个还只是简单的调了下位置,这里 OrbitControls 在俯视视角用起来还有点问题,后面得优化下

车灯

我们之前其实接触过光源,比如整体场景的环境光 AmbientLight 还有投向自车的直行光 DirectionLight,其他光源还有 SpotLight 聚光灯

光源分类

车灯主要就是借助聚光灯光源来模拟,如果自车模型包含灯模型,通过灯的位置来设置光源位置就更准确了。但如果自车位置会变的话,还需要结合自车位置和转向来计算光源目标

// ...
const target1 = new THREE.Object3D();
target1.position.set(0.1, -0.2, 0.3);
const light1 = new THREE.SpotLight("#fff", 1.2, 3, Math.PI / 6, 0.1);
light1.position.set(0.1, 0.2, 0.3);
light1.castShadow = true;
light1.target = target1;
this.scene.add(target1);
this.scene.add(light1);
const target2 = new THREE.Object3D();
target2.position.set(-0.1, -0.2, 0.3);
const light2 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1);
light2.position.set(-0.1, 0.2, 0.3);
light2.castShadow = true;
light2.target = target2;
this.scene.add(target2);
this.scene.add(light2);

车灯效果

最后

· 16 min read

智驾场景地图上最重要的元素是啥?当属 Line 元素了,比如下图的车道线、规划线和预测线等(下图来自百度 apollo 公开课件,有点糊凑合看吧)

baidu-apollo

一些调整

先把相机位置调一下,因为在我们认知习惯里 z 轴应该垂直向上,水平面则是 x/y 轴,这个时候在不翻转场景的情况下,可以调整相机的 up属性,使其向上位置朝着 z 轴正向,这样一来给我们造成的视觉效果就是 z 轴垂直向上

camera和视椎体

但实际上还是符合右手定则,此时的 x 轴、y 轴和 z 轴如下图所示(蓝色向上是 z 轴正方向,红色向左是 x 轴正方向,绿色向屏幕内是 y 轴正方向)。部分修改代码如下:

carema.up.set(0, 0, 1);
// 注意调下相机位置确保看到自车
camera.position.set(-0.4, 4, 1.4);

threejs坐标轴z朝上

内置 Line

其实 threejs 也有内置的线条几何体,比如我们可以用 Line 实现几段基础的道路线,并在自车前方加一条简单的规划线

// ...
const points = [];
points.push(new THREE.Vector3(0.4, -20, 0));
points.push(new THREE.Vector3(0.4, 20, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: 0xffffff });
const line = new THREE.Line(geometry, material);
line.position.z = 0.1;
this.scene.add(line);
const points2 = [];
points2.push(new THREE.Vector3(-0.8, -20, 0));
points2.push(new THREE.Vector3(-0.8, 20, 0));
const geometry2 = new THREE.BufferGeometry().setFromPoints(points2);
const material2 = new THREE.LineBasicMaterial({ color: 0xffffff });
const line2 = new THREE.Line(geometry2, material2);
line2.position.z = 0.1;
this.scene.add(line2);
const points3 = [];
points3.push(new THREE.Vector3(-0.2, -20, 0));
points3.push(new THREE.Vector3(-0.2, 20, 0));
const geometry3 = new THREE.BufferGeometry().setFromPoints(points3);
// 虚线材质
const material3 = new THREE.LineDashedMaterial({
color: 0xffffff,
dashSize: 1, // 显示线段的大小,默认为3
gapSize: 0.5, // 间隙的大小,默认为1
});
const line3 = new THREE.Line(geometry3, material3);
line3.position.z = 0.1;
// 注意虚线必须调用这个函数
line3.computeLineDistances();
this.scene.add(line3);
// 自车规划线
const points4 = [];
points4.push(new THREE.Vector3(0, -10, 0));
points4.push(new THREE.Vector3(0, 0, 0));
const geometry4 = new THREE.BufferGeometry().setFromPoints(points4);
const material4 = new THREE.LineBasicMaterial({ color: 0xffff00 });
const line4 = new THREE.Line(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

内置line实现实线和虚线示例

但这里发现规划线太细了,想要定义宽度 LineWidth 却发现没有效果,这里改用 Line2试试:

import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry, LineMaterial } from "three/examples/jsm/Addons.js";
// ...
// 规划线
const geometry4 = new LineGeometry();
geometry4.setPositions([0, -10, 0, 0, 0, 0]);
const material4 = new LineMaterial({
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight),
color: 0xffff00,
linewidth: 20,
});
const line4 = new Line2(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

先看下效果:

内置line2实现渐变色示例

看起来会有点像圆柱体,而且不随视角远近而改变大小,但其实我们期望的效果只需要车道保持平行的二维固定长度的线。车道线主要是实线和虚线以及多种颜色的组合,乍一看内置元素都还能勉强实现这些,其他内置线元素还有:

  • LineSegmentsTHREE.Line类似,但是可以通过一系列的点创建出多段线,可以调节线条宽度粗细,这个在 第二篇 一开始实现立方体的时候有用来绘制边框
  • LineLoop 首尾相连的线 ,可以形成一个闭合的图形,但也没法设置宽度粗细
  • CatmullRomCurve3 创建平滑的三维曲线

但后面突然算法找到你,说我们希望做条规划线,而且是渐变色的,渐变范围在某些点之间,可以用来表示速度变化趋势,那这个时候,内置的线元素就难办了。ok 总结一下,内置线元素有什么缺点:

  • 宽度定义比较难受
  • 实现虚线或者双线效果时处理数据会额外占用较多的 CPU 资源
  • 不支持渐变色、流光效果等

自定义 Line

我们其实可以自定义 Line 元素和 shader 材质来解决上面的这些限制,需要用到之前的 BufferGeometry 自定义几何体和 shaderMaterial。这里得稍微了解下 webgl 的渲染管线(图片来自 threejs 中文网):

webgl渲染管线

shaderMaterial

上图可以看到,先后经历了顶点着色器(vertex shaders)和片元着色器(fragment shaders),shader 代码用 GLSL 语言编写,是在 GPU 中执行的,其实有时候我们可以把一部分 CPU 的工作交给 GPU 来提升应用的性能

GLSL 入门可以参考 https://github.com/wshxbqq/GLSL-Card

这里有些属性要了解一下:

  • uniforms 传递给 shader 的参数,比如颜色值、透明度等
  • vertexShader 在顶点着色器中运行的代码片段
  • fragmentShader 在片元着色器中运行的代码片段

先画个双色的长方形看看:

// ...
const geometry = new THREE.PlaneGeometry(0.4, 1);
const shader = new THREE.ShaderMaterial({
uniforms: {
uColor: {
value: new THREE.Color("#ffff00"),
},
uColor1: {
value: new THREE.Color("orange"),
},
},
vertexShader: `
varying vec3 vPosition;
void main() {
vPosition = position;
// 计算顶点的位置,投影矩阵*模型视图矩阵*模型顶点坐标
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uColor;
uniform vec3 uColor1;
varying vec3 vPosition;
void main() {
gl_FragColor = vPosition.y < 0.0 ? vec4(uColor, 1.0) : vec4(uColor1, 1.0);
}`,
});
const plane = new THREE.Mesh(geometry, shader);
plane.position.y = -1;
plane.position.z = 0.1;
this.scene.add(plane);

shader实现双色长方形示例

实线

我们可以将线元素Line看成是一些三角形连接而成,然后宽度就是每个点往两边分别延伸一半,类似下图:

triangle-line

这里我们就需要先用 BufferGeometry 自定义 Line 元素,再把相关参数传入 shaderMaterial

先分别实现下基础的实线和虚线,老规矩先定下元素接口:

export interface ILine {
points: number[]; // 点集,[x,y,z]
color: string; // 颜色值
width: number; // 线宽
type: ELineType; // 线类型
}
export enum ELineType {
Solid = 0, // 实线
Dash = 1, // 虚线
Gradual = 10, // 渐变线
// 还可以扩展到双线、虚实线结合等
}

封装 Line,这里用到了 polyline-normals这个库,我们需要借助它来计算顶点的法向量,通过这个法向量和宽度来计算得到两边的顶点。但是这个库还没支持 ESModule,需要 require 引入,需要多安装一个 vite-plugin-commonjs插件来支持,同时修改vite.config.js 如下:

// pnpm i polyline-normals
// pnpm i -D vite-plugin-commonjs
// vite.config.js
import commonjs from "vite-plugin-commonjs";
// ...
export default defineConfig({
plugins: [react(), commonjs()],
// ...
});

接下来封装一下自定义的 Line 元素,BufferGeometry 自定义几何体在上一篇有比较多的内容,可以自行参考:

// src/renderer/line.ts
// ...
const getNormals = require("polyline-normals");

class Line {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

createGeometry(data: ILine, needDistance: boolean = false) {
const { points } = data;
const vertices: number[][] = [];
const indices: number[] = [];
const lineNormal: number[][] = [];
const lineMiter: number[][] = [];
const lineDistance: number[][] = [];
const lineAllDistance: number[][] = [];
const geometry = new THREE.BufferGeometry();
// 计算各个点的法向量
const normalsByPolyline = getNormals(points);
let indicesIdx = 0;
let index = 0;
let distance = 0;
points.forEach((point, i, list) => {
const idx = index;
if (i !== points.length - 1) {
// 添加索引以形成两个三角形
indices[indicesIdx++] = idx + 0;
indices[indicesIdx++] = idx + 1;
indices[indicesIdx++] = idx + 2;
indices[indicesIdx++] = idx + 2;
indices[indicesIdx++] = idx + 1;
indices[indicesIdx++] = idx + 3;
}
// 这里不用先计算,后面直接在shader里面借助GPU计算就行
vertices.push(point);
vertices.push(point);
});
normalsByPolyline.forEach((item: any) => {
const norm = item[0];
const miter = item[1];
lineNormal.push([norm[0], norm[1]], [norm[0], norm[1]]);
lineMiter.push([-miter], [miter]);
});
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices.flat(), 3)
);
geometry.setAttribute(
"lineNormal",
new THREE.Float32BufferAttribute(lineNormal.flat(), 2)
);
geometry.setAttribute(
"lineMiter",
new THREE.Float32BufferAttribute(lineMiter.flat(), 1)
);
geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));
return geometry;
}

draw(data: ILine) {
const { color = "#ffffff", width, type, endColor } = data;
let geometry;
let shader;
switch (type) {
case ELineType.Solid: {
geometry = this.createGeometry(data);
shader = getSolidLineShader({
width: width ?? 0.01,
color: color,
});
break;
}
case ELineType.Dash: {
// ...
break;
}
case ELineType.Gradual: {
// ...
break;
}
default:
break;
}
const plane = new THREE.Mesh(geometry, shader);
plane.position.z = 0.01;
this.scene.add(plane);
}
}

编写实线 shader 如下:

export function getSolidLineShader(option: any = {}) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
},
vertexShader: `
uniform float thickness;
attribute float lineMiter;
attribute vec2 lineNormal;
void main() {
// 通过法线和宽度计算得出线段中点对应的两个顶点
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
uniform vec3 diffuse;
uniform float opacity;
void main() {
gl_FragColor = vec4(diffuse, opacity);
}
`,
});
material.side = THREE.BackSide;
material.transparent = true;
return material;
}

虚线

虚线和实线是同一个 BufferGeometry,只不过接口要加一些参数,然后要另写一个 shader,主要是实现虚线的逻辑不一样,需要额外定义属性 lineDistance表示顶点距起点的累积直线距离,其他逻辑都类似实线

稍微解释下这个计算逻辑:

  • 比如实线 3m,虚线 2m,长度 4m (累积直线距离)的点明显在虚线区域,做个取模 4%(3+2)=4
  • 这个时候算出 4 大于实线长度,说明在虚线区域,就将这个地方的点设置为透明
  • 同理如果是在 2 的区域,2 小于实线长度,说明在实线区域,就正常填色

虚线计算逻辑

增加lineDistance属性,参考代码如下:

// src/renderer/line.ts
// ...
// 新增一个needDistance参数,主要用于虚线和渐变线
createGeometry(data: ILine, needDistance: boolean = false) {
const lineDistance: number[][] = [];
points.forEach((point, i, list) => {
// ...
if (needDistance) {
let d = 0;
if (i > 0) {
// 计算两点之间的直线距离
d = getPointsDistance(
[point[0], point[1]],
[list[i - 1][0], list[i - 1][1]]
);
}
distance += d;
lineDistance.push([distance], [distance]);
}
});
if (needDistance) {
geometry.setAttribute(
"lineDistance",
new THREE.Float32BufferAttribute(lineDistance.flat(), 1)
);
}
}

接口变化如下:

export interface ILine {
points: number[]; // 点集
color: string;
width: number;
type: ELineType; // 默认是实线
dashConfig?: {
// 实线长度
solidLength?: number;
// 虚线长度
dashLength?: number;
};
}

编写虚线 shader 如下:

// 用于画单色虚线
export function getDashedLineShader(option: ILine) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
// 虚线部分的长度
dashLength: { value: option?.dashInfo?.dashLength ?? 1.0 },
// 实线部分的长度
solidLength: { value: option?.dashInfo?.solidLength ?? 2.0 },
},
vertexShader: `
uniform float thickness;
attribute float lineMiter;
attribute vec2 lineNormal;
attribute float lineDistance;
varying float lineU;

void main() {
// 累积距离
lineU = lineDistance;
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
varying float lineU;
uniform vec3 diffuse;
uniform float opacity;
uniform float dashLength;
uniform float solidLength;

void main() {
// 取模
float lineUMod = mod(lineU, dashLength + solidLength);
// lineUMod>solidLength则返回0.0,说明在实线区域;否则返回1.0,说明在虚线区域
float dash = 1.0 - step(solidLength, lineUMod);
gl_FragColor = vec4(diffuse * vec3(dash), dash * opacity);
}
`,
});
material.transparent = true;
material.side = THREE.BackSide;
return material;
}

自定义shader绘制实线和虚线

ok 胜利近在眼前,目前这个针对直线支持最好,曲线的话,得多一些点集数据才能确保曲线平滑过渡,否则可能要用到贝塞尔曲线平滑一下...(不过一般来说也不会让前端去做平滑吧至少我没碰到)

渐变线

其实有两种方式,一种是发出多个线段,每个线段带一种颜色,造成一种“假”的渐变效果,其实有时候也足够满足算法需求了,这种相对简单点;第二种是给一段线段,然后在起点和终点之间做线性渐变。这里看下第二种咋实现,主要差异也是在接口和 shader 里,需要做下颜色的线性插值,可以借助之前的 lineDistance 和线段总长度 lineAllDistance 的比例来插值。lineAllDistance 是新增的属性,暂时先给每个点都加上,或许有大佬有更好的办法也可以给点建议,这里就不贴代码了,可以参考源码 ~

接口变化如下:

export interface ILine {
points: number[]; // 点集
color: string; // 拿来做起点颜色吧
width: number;
type: ELineType; // 默认是实线
endColor?: string; // 渐变色,作为终点颜色,color是起点颜色
}

编写 shader 如下:

// 渐变色
export function getGradientLineShader(option: any = {}) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
endColor: { value: new THREE.Color(option.endColor) },
},
vertexShader: `
uniform float thickness;
attribute vec2 lineNormal;
attribute float lineMiter;
attribute float lineDistance;
attribute float lineAllDistance;
varying float lineU;
varying float lineAll;

void main() {
lineU = lineDistance;
lineAll = lineAllDistance;
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
// 累积长度
varying float lineU;
varying float lineAll;
uniform float opacity;
uniform vec3 diffuse;
uniform vec3 endColor;

void main() {
vec3 aColor = (1.0-lineU/lineAll)*(diffuse-endColor)+endColor;
gl_FragColor =vec4(aColor, opacity);
}
`,
});
material.transparent = true;
material.side = THREE.DoubleSide;
return material;
}

自定义shader实现渐变色线条

数据自己 mock 的,想要线段更平滑就整多点数据吧 ~

最后

· 11 min read

除了自车和他车(其他车辆),还有一些规则的物体比如圆柱体、椎体等这些可以通过 threejs 内置的几何体来实现,但其实智驾场景里还有很多不规则物体,如果比较简单,比如多边形柱体这类就可以借助自定义几何体来实现,复杂点的就建议建模了

BufferGeometry

BufferGeometry 是 threejs 内置几何体的基类,通过此基类可以自定义几何体。老玩家可能见过Geometry,它和 BufferGeometry的区别是啥?新版 threejs 其实已经不推荐使用Geometry,但为了向后兼容也仍然保留该类,其实底层会自动将其转换为BufferGeometry,后者提供了更好的性能和更高效的内存使用

它主要有以下几个属性,可以通过 attributes访问:

  • position 顶点位置
  • normal 法线,也就是法向量,和光照有关
  • color 顶点颜色
  • uv 坐标,可以从贴图上提取像素映射到网格模型的几何体表面上
  • index 顶点索引。这个可以用来复用顶点数据,从而减少重复的顶点数据

几何体各个属性对应关系

在顶点 4 的地方,其实这里可以就算一个顶点索引,其实positionnormalcoloruv 是基于顶点相对应的,接着看段伪代码理解一下:

// 顶点位置
const positions = [
x1, y1, z1, // 顶点1
x2, y2, z2, // 顶点2
x3, y3, z3 // 顶点3
];
// 顶点颜色
const colors = [
r1, g1, b1, // 顶点1的颜色
r2, g2, b2, // 顶点2的颜色
r3, g3, b3 // 顶点3的颜色
];
// UV坐标
const uvs = [
u1, v1, // 顶点1的UV
u2, v2, // 顶点2的UV
u3, v3 // 顶点3的UV
];
// 索引,指定如何形成三角形,这里使用顶点0, 1, 2来形成一个三角形
const indices = [0, 1, 2];
// 将以上属性组合到BufferGeometry中
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));
// 接着将这个geometry与material结合,创建mesh并添加到场景中

长方体

其实 BoxGeometry 就是自定义几何体的实现,threejs 做了层封装方便我们使用,有余力的同学建议可以去读一读源码。那怎么用自定义几何体的方式实现一个长方体呢?

上面坐标数组的表示其实比较难读,这里可以借助 js 的扩展运算符来提高代码的可读性,主要是确立各个顶点的位置,法向量这里是垂直于所在面的正面向上,然后设置下几何体的属性,再加下材质和 Mesh,代码参考如下:

const vertices = [
// 注意按逆时针方向排序
// 正面, 因为没用到纹理,这里先不管uv坐标
{ position: [-1, -1, 1], normal: [0, 0, 1] },
{ position: [1, -1, 1], normal: [0, 0, 1] },
{ position: [-1, 1, 1], normal: [0, 0, 1] },
{ position: [-1, 1, 1], normal: [0, 0, 1] },
{ position: [1, -1, 1], normal: [0, 0, 1] },
{ position: [1, 1, 1], normal: [0, 0, 1] },
// ...其他面类似处理
];
const positions = [];
const normals = [];
for (const vertex of vertices) {
positions.push(...vertex.position);
normals.push(...vertex.normal);
}
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(positions), 3)
// 等同于 new THREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute(
"normal",
new THREE.BufferAttribute(new Float32Array(normals), 3)
);
const polygonMaterial = new THREE.MeshLambertMaterial({
transparent: true,
opacity: 0.2,
});
polygonMaterial.color.set(0, 1, 0);
const mesh = new THREE.Mesh(geometry, polygonMaterial);
this.scene.add(mesh);

这样一套方法实现下来,效果如下:

自定义立方体

自车包围的这一个立方体就是了,怎么有点像火影的尘遁?仔细看代码,其实会发现一个 BufferAttribute 对象,它能管理顶点着色器中的 Attribute 变量,通过此对象可以存储顶点位置信息、法线向量、顶点颜色等,并可以对其进行矩阵变换、拷贝、读写等

为了观察当前各个坐标轴方向,也可以引入 AxesHelper 协助观察坐标轴方向(如果你觉得右手定则麻烦的话),X 轴为红色,Y 轴为绿色,Z 轴为蓝色

const axes = new THREE.AxesHelper(1);
axes.position.y = 0.05;
this.scene.add(axes);

坐标轴辅助

顶点索引

通过 setIndex 可以设置索引,这个索引可以用来复用顶点数据,从而减少顶点数量。用上述例子来看,其实一个面只需要四个顶点,另外俩个是可以复用的,以正面为例:

const vertices = [
// 正面
{ position: [-1, -1, 1], normal: [0, 0, 1] }, // 顶点0
{ position: [1, -1, 1], normal: [0, 0, 1] }, // 顶点1
{ position: [-1, 1, 1], normal: [0, 0, 1] }, // 顶点2
// { position: [-1, 1, 1], normal: [0, 0, 1] }, // 复用顶点2
// { position: [1, -1, 1], normal: [0, 0, 1] }, // 复用顶点1
{ position: [1, 1, 1], normal: [0, 0, 1] }, // 顶点3
// ...
];
geometry.setIndex([
// 正面
0, 1, 2, 2, 1, 3,
// ...类似正面的方法设置下其他面
]);
const positions = [];
const normals = [];
// ...其他代码同上

使用后其实顶点 verticals 只需要设置 24 个顶点(每个面对应 4 个),如果不考虑法向量和 uv 坐标的话,甚至只需要 8 个。这么一套下来,麻烦吧 ~ 所以稍微复杂点的不规则物体,还是乖乖上建模吧

PolygonCylinder

这里的 PolygonCylinder 用来指代多边形柱体,也就是上下面是相等的多边形和指定高度组成的柱体,当然你如果想用 ExtrudeGeometry 来实现,也不是不行,就是搞复杂了,还会生成更多的顶点和面。下面还是想想办法怎么通过 BufferGeometry 实现一个多边形柱体,先定下图形接口:

export interface IPolygonCylinder {
id: string;
// 顶点,只需要底面几个顶点,顶面顶点通过高度可以计算出来
contour: IPos[];
// 高度
height: number;
color?: IColor;
}

类定义如下:

export class PolygonCylinder {
// ...
draw(data: IPolygonCylinder) {
const { contour, height, color = { r: 0, g: 0, b: 0 } } = data;
// 确保顶点顺序为逆时针
if (THREE.ShapeUtils.isClockWise(contour)) {
contour.reverse();
}
const vertices: number[][] = [];
const normals: number[][] = [];
const indexes: number[] = [];
// 索引辅助下标
let indexesIndex = 0;
// 总共的顶点数量 = 顶面顶点+底面顶点
// 确定顶面
for (let i = 0; i < contour.length; i++) {
const current = contour[i];
vertices.push([current.x, current?.y + height, current.z]);
normals.push([0, 1, 0]);
// 设置顶面索引, 底面一般看不到, 所以可以不用设置索引
// 三个点确定一个面, 注意按逆时针方向加入顶点索引
if (i >= 2) {
indexes[indexesIndex] = 0;
indexes[indexesIndex + 1] = i - 1;
indexes[indexesIndex + 2] = i;
indexesIndex += 3;
}
}
// 确定底面
for (let i = 0; i < contour.length; i++) {
const current = contour[i];
vertices.push([current.x, current.y, current.z]);
normals.push([-1, 0, -1]);
}
// 确定侧面, 这里复用下上下面的顶点就行
for (let topIndex = 0; topIndex < contour.length; topIndex++) {
const bottomIndex = topIndex + contour.length;
// 终点处理, 这里的topIndex+1==底面起点, bottomIndex就是底部终点
if (bottomIndex + 1 === 2 * contour.length) {
indexes[indexesIndex] = topIndex;
indexes[indexesIndex + 1] = bottomIndex;
indexes[indexesIndex + 2] = topIndex + 1;
indexes[indexesIndex + 3] = topIndex + 1;
indexes[indexesIndex + 4] = 0;
indexes[indexesIndex + 5] = topIndex;
} else {
// 一个面对应俩个三角形
indexes[indexesIndex] = topIndex;
indexes[indexesIndex + 1] = bottomIndex;
indexes[indexesIndex + 2] = bottomIndex + 1;
indexes[indexesIndex + 3] = bottomIndex + 1;
indexes[indexesIndex + 4] = topIndex + 1;
indexes[indexesIndex + 5] = topIndex;
}
indexesIndex += 6;
}
// 设置缓冲几何体属性
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices.flat(), 3)
);
// 自动计算法向量, 柱体结构不够清晰
// geometry.computeVertexNormals();
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute(normals.flat(), 3)
);
geometry.index = new THREE.Uint16BufferAttribute(indexes, 1);
const polygonMaterial = new THREE.MeshLambertMaterial({
transparent: true,
opacity: 0.8,
});
polygonMaterial.color.setRGB(color.r, color.g, color.b);
const polygonMesh = new THREE.Mesh(geometry, polygonMaterial);
this.scene.add(polygonMesh);
}
}
export default PolygonCylinder;

isClockWise这个工具函数,可以用于判断一组点在二维平面上的投影方向是顺时针还是逆时针,有一些场景需要用到,比如填充多边形、计算几何体的方向等,如果是顺时针方向,需要对顶点做下逆序处理 reverse。如下图所示,对应红色三角形的索引为 topIndex > bottomIndex+1 > topIndex,顺序不重要,确保逆时针方向就行:

顶点索引方向

OpenGL 默认遵循右手法则,右手除拇指之外的四指根据点的逆时针握住,大拇指的方向即为法线方向,其逆时针的一面为正面,可以接受到光照;顺时针为反面,无法接受光照

mock 些数据看看效果:

多边形柱体

GL_INVALID_OPERATION: Vertex buffer is not big enough for the draw call

如果遇到这个报错,大概率顶点索引越界了,检查下索引和顶点是不是不匹配吧 ~

最后

· 8 min read

除了自车,那智驾里最常见的参与物就是他车了,比如下面这种绿色长方体(图片源于百度 apollo),然后上面可能会携带一些文字信息

apollo他车示例

实现长方体

怎么做一个长方体?其实很简单,直接用内置几何体 BoxGeometry+EdgesGeometry 来实现,注意为了方便观察,这里要把 Mesh 做成透明的,绘制代码参考:

const material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
// 注意这里要设置一下,否则opacity不会生效
transparent: true,
opacity: 0.2,
});
const geometry = new THREE.BoxGeometry(0.4, 0.4, 1);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 0.2, -2);
this.scene.add(mesh);
const edges = new THREE.EdgesGeometry(geometry);
const edgesMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00 });
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.copy(mesh.position);
this.scene.add(line);

他车

ok 有点模样了。接下来封装下这个 Cube 类,先定个基础的数据接口:

interface ICube {
id: number;
// 他车类别,比如BUS、TRUCK等
type: string;
// IPos和IColor定义可以参照上一篇文章或者github代码里找
position: IPos;
color: IColor;
width: number;
height: number;
length: number;
}

Cube 类参考如下:

// src/renderer/cube.ts
class Cube {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: ICube) {
const { position, color, width, height, length } = data;
const material = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.2,
});
// 如果一定要用setRGB设置颜色,建议直接让上游先除以255再给到前端,咱前端能不计算就不要计算
material.color.setRGB(color.r, color.g, color.b);
const geometry = new THREE.BoxGeometry(width, height, length);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position.x, position.y, position.z ?? 0);
this.scene.add(mesh);
const edges = new THREE.EdgesGeometry(geometry);
const edgesMaterial = new THREE.LineBasicMaterial();
edgesMaterial.color.setRGB(color.r, color.g, color.b);
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.copy(mesh.position);
this.scene.add(line);
}
}

export default Cube;

顶部文字

他车其实携带了 id 和 type,为了直观看到这些属性,可以将他们绘制到对应的参与物顶部或其他容易观察的位置,可以用 TextGeometryFontLoader 实现 3d 文字。font 字体文件直接到 threejs 的 github 仓库 下载一个,我直接放 public 里了

老规矩,先定个基础接口:

export interface IText {
id: string;
// 字体大小
size: number;
color: IColor;
position: IPos;
content: string;
}

绘制函数参考如下:

// src/renderer/text.ts
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";

let font: any = null;
const fontLoader = new FontLoader();
fontLoader.load("gentilis_regular.typeface.json", (res) => {
font = res;
});

export function renderTextMesh(data: IText) {
const { content, color = { r: 1, g: 1, b: 1 }, position, size = 0.1 } = data;
const textGeo = new TextGeometry(content, {
font,
size,
depth: 0.01,
});
textGeo.computeBoundingBox();
const material = new THREE.MeshPhongMaterial();
material.color.setRGB(color.r, color.g, color.b);
const centerOffset =
-position.x * (textGeo.boundingBox!.max.x - textGeo.boundingBox!.min.x);
const textMesh = new THREE.Mesh(textGeo, material);
textMesh.position.set(centerOffset, position.y, position.z || 0);
textMesh.rotation.x = 0;
textMesh.rotation.y = Math.PI * 2;
return textMesh;
}

注意这里字体文件是异步加载的,在创建文字的时候需要先确保字体文件已经加载成功

然后怎么关联他车?注意 renderTextMesh 函数的返回值,在新建他车的时候,顺带新建一个 textMesh,并且在他车实例对象里保留这个 text 的引用,确保后续移除他车时能一并移除。在 cube 实例对象里追加下面的逻辑,具体位置看实际情况而定

// src/renderer/cube.ts
// ...
draw() {
// ...
this.scene.add(line);
// 绘制顶部文字
const text = id + "-" + type;
const textMesh = renderTextMesh({
id: text,
content: text,
position: {
x: mesh.position.x + width,
y: mesh.position.y + height / 2 + 0.1,
z: mesh.position.z,
},
});
this.scene.add(textMesh);
}

他车

文字缓存

这里针对 textMesh 可以做个缓存,因为大部分情况下他车在好几帧里面是重复的,对应的顶部文字也是相对固定的,毕竟 3d 文字渲染相对比较耗时,可以借助缓存直接复用对应的 Mesh 对象。因为 id+type 是唯一标识,可以作为缓存对象的 key,参考代码如下:

type TextCache = Record<string, THREE.Mesh>;
class Cube {
textCache: TextCache = {};
// ...
draw() {
// ...
if (this.textCache[text]) {
const textMesh = this.textCache[text];
mesh.textMesh = textMesh;
group.add(textMesh);
} else {
// ...
}
}
}

朝向箭头

这里可以用官方的三角箭头,但如果对各个视角的观察要求不是很高的话,其实这里只需要二维箭头,可以用 Shape来实现,在大量他车的场景下对渲染还是有一点点提升的。这里先做成固定长度的就行了,主要是提供一个方向的参数,也作为一个接口直接让上游控制箭头的绘制

// src/renderer/arrow.ts
export interface IArrow {
id: string;
// 箭头尾部坐标,可以确定方向和长度
endPoint: IPos;
origin: IPos;
length?: number;
// 颜色哈希值
hex?: string;
}

class Arrow {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: IArrow) {
const arrowHelper = drawArrow(data);
this.scene.add(arrowHelper);
}
}

export default Arrow;

export function drawArrow(data: IArrow) {
const { origin, endPoint, hex = 0xffff00 } = data;
// 通过箭头起点和终点计算方向向量
const dir = new THREE.Vector3(
endPoint.x - origin.x,
endPoint.y - origin.y,
(endPoint.z ?? 0) - (origin.z ?? 0)
);
// 获取箭头长度
const length = dir.length();
const dirData = new THREE.Vector3(dir.x, dir.y, dir.z);
dirData.normalize();
const originPos = new THREE.Vector3(origin.x, origin.y, origin.z ?? 0);
const arrowHelper = new THREE.ArrowHelper(dirData, originPos, length, hex);
return arrowHelper;
}

其实可以把他车、文字和朝向箭头做成一个 Group,好处是减少 drawcall 和便于一起操作,比如移动、旋转和缩放等。Cube对象修改代码参考如下:

// src/renderer/cube.ts
draw(){
// ...
// 绘制顶部文字
const text = id + "-" + type;
const textMesh = renderTextMesh({
id: text,
content: text,
position: {
x: mesh.position.x + width,
y: mesh.position.y + height / 2 + 0.1,
z: mesh.position.z,
},
});
// 挂载到他车Mesh上
mesh.textMesh = textMesh;
group.add(textMesh);
// 绘制朝向箭头
const arrowMesh = drawArrow({
id: data.id + "-" + "arrow",
endPoint: {
// 注意:这里只是模拟数据,实际endPoint应该根据算法结果来确定
// 或者给个偏转角,前端做个计算,固定长度也行
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z - length,
},
origin: {
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z,
},
});
mesh.arrowMesh = arrowMesh;
group.add(arrowMesh);
this.scene.add(group);
}

mock 些数据来看看,效果如下:

他车

最后

如果还要追求更好的场景效果,也可以用参与物模型替代立方体。但其实参与物也不全是这种规则的立方体,还需要实现一些柱体、不规则立方体等以满足实际场景需求,而且有时候 3d 文字并不能提供足够的信息,这时候就需要考虑 2d 小卡片了

· 9 min read

智能驾驶业务里常用 web 3d 来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用 threejs 来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图: 示例1 示例2

当然以上图片都是客户端的版本,web3d 版本的 ui 其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来

本专栏基于 three^0.167.1 版本

初始化项目

用 Vite 脚手架快速搭一个 react 项目用来调试

pnpm create vite autopilot --template react-ts

把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:

// src/renderer/index.ts
import * as THREE from "three";

class Renderer {
constructor() {
//
}

initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}

export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";

function App() {
useEffect(() => {
myRenderer.initialize();
}, []);

return (
<>
<div id="my-canvas"></div>
</>
);
}

export default App;

加载自车

ok,跨出第一步了,接下来整辆自车(egoCar)

“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶

可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。

这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();

class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}

但如果一定要放到 src/assets/models 目录里呢?然后通过 import 方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "*/.glb" to assetsInclude in your configuration):

vite-import-glb-error

怎么解?在 vite``.config.ts 文件加入 assetsInclude。顺带把 vite 指定路径别名 alias 也支持一下

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});

node:url 如果提示没有该模块,先安装下@types/node,可能要重启下 vscode 才能生效

pnpm i @types/node -D

接下来就可以直接用 import 导入 glb 文件来用了

import carModel from "@/assets/models/su7.glb";

class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}

OrbitControls

增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}

自车暗

光源设置

看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源

// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);

自车亮

地面网格

增加坐标网格,提供一个水平的基准面,便于观察。这里需要新建一个 Grid 对象

// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);

地面网格

道路实现

这里先简单实现一段不规则道路,封装一个 freespace 对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式

export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}

因为只是一个平面形状,所以可以用 THREE.Shape 来实现,它可以和 ExtrudeGeometryShapeGeometry 一起使用来创建二维形状

// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}

export default Freespace;

ok 先用 mock 的数据画一段带洞的直道,加在 initialize 代码后就行,其实道路上还应该有一些交通标线,后面再加上吧

freespace

最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景

  // ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}

最后

ok 先到这了,主要是先把项目搭起来,给专栏开个头,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现

· 17 min read

前文 基于 threejs 和 cannon-es 实现了一个简单的空中飞车小游戏,本文基于之前的场景继续完善一下,通过增加一些附带物理特性的 3d 障碍物提高一下游戏的难度,实现空中飞车 2.0 -.-||

初始化

先串讲下 3d 场景和物理世界的初始化

3D 场景

先创建好基础的三件套:场景 THREE.Scene、相机 THREE.Camera 和渲染器 THREE.WebGLRenderer,然后加一些环境光或其他光源,再调整下相机的位置,使其始终跟随自车,最后做一个循环的动画来更新场景,我这里限制了 60fps。代码参考如下:

init () {
const container = document.getElementById(initPayload.container);
const style = container!.getBoundingClientRect();
const width = style.width;
const height = style.height;
/***** 创建场景 *****/
const scene = new THREE.Scene();
/***** 创建环境光和其他光源 *****/
const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.castShadow = true;
// 设置方向光源位置
directionalLight.position.set(15, 30, 25);
scene.add(directionalLight);
/***** 创建一个具有透视效果的摄像机 *****/
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
this.camera = camera;
camera.position.x = 0;
/***** 创建一个 WebGL 渲染器 *****/
const renderer = new THREE.WebGLRenderer({
// 开启抗锯齿
antialias: true,
});
// 将渲染器的输出(canvas节点)插入到 body 中
document.body.appendChild(renderer.domElement);
// ......
const cameraOffsetY = 4;
const cameraOffsetZ = 16;
// 初始化事件,比如键盘方向键的逻辑
this.initEvents();
// 帧率监控
const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);
const animate = () => {
if (!this.world || !this.scene) {
return;
}
stats.begin();
// 相机跟随自车
camera.position.y = egoCar.position.y + cameraOffsetY;
camera.position.z = egoCar.position.z + cameraOffsetZ;
camera.lookAt(egoCar.position);
renderer.render(scene, camera);
stats.end();
};
animate();
setInterval(() => {
animate();
}, 16);
}

物理世界

借助 cannon-es,添加物体时需要加到 world 上,和 threejs 需要将物体添加到 scene 类似,然后监听物理世界的变化,动态更新 3d 场景物体的位置和方向信息等。这里留意下 physicObjects 这个数组,存放了所有设置了物理属性的 3d 场景物体,然后物体通过 physicBody 引用刚体

initConnonWorld() {
const world = new CANNON.World();
world.broadphase = new CANNON.SAPBroadphase(world);
// 设定重力
world.gravity.set(0, -9.8, 0);
world.defaultContactMaterial.friction = 0;
this.world = world;
}
// ...
// 存放所有实现了物理关系的3d场景物体
physicObjects: any[] = [];
// 物理计算
updatePhysics() {
if (!this.world) {
return;
}
// 对应3d场景的渲染帧率
this.world.step(1 / 60);
this.physicObjects.forEach((obj) => {
// 将物理世界计算后的位置和旋转信息赋值给3d场景物体本身,从而实现物理效果
obj.position.copy(obj.physicBody.position);
obj.quaternion.copy(obj.physicBody.quaternion);
});
}
// ...
const animate = () => {
// ...
renderer.render(scene, camera);
this.updatePhysics();
// ...
};

此前已经实现了小车和一些道路,小白最好参考下 专栏 之前的文章,然后自车物体和行驶部分的逻辑可以参考之前的文章和源码 - threejs 仿抖音漂移特效,这里就不赘述啦 ~

道路

本文会在之前搭建的场景的基础上扩展,新增三段道路,分别加些障碍物,比如雾墙、滚落巨球和大摆锤等,初始界面如下:

初始界面

车道

这一版车道做了简单的封装,将网格对象 THREE.Mesh和刚体 CANNON.Body的关联逻辑放在一起,可以针对车道位置和车道线做一些定制,参考代码如下:

export interface IRoadObjPayload extends IObjPayload {
// 车道旋转角度
rotation: number[];
// 车道设定为一个box,size是长宽高
size: number[];
// 是否显示车道线
hasLines?: boolean;
}
drawRoad(payload: IRoadObjPayload) {
const { pos, rotation, size, hasLines = true } = payload;
// 创建3d道路对象
const roadMaterial = new THREE.MeshLambertMaterial({
color: "#8c8585",
side: THREE.BackSide,
});
const roadGeometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
const roadMesh = new THREE.Mesh(roadGeometry, roadMaterial);
roadMesh.receiveShadow = true;
roadMesh.rotation.x = rotation[0];
roadMesh.position.set(pos[0], pos[1], pos[2]);
this.scene!.add(roadMesh);
// 创建物理刚体
const q4 = roadMesh.quaternion;
const roadShape = new CANNON.Box(
new CANNON.Vec3(size[0] / 2, size[1] / 2, size[2])
);
const roadBody = new CANNON.Body({ mass: 0 });
roadBody.addShape(roadShape);
roadBody.position.set(pos[0], pos[1], pos[2]);
roadBody.quaternion = new CANNON.Quaternion(q4._x, q4._y, q4._z, q4._w);
this.world!.addBody(roadBody);
roadMesh.physicBody = roadBody;
// 绘制车道线
if (hasLines) {
// 绘制虚线,这个函数逻辑参考后面提到的车道线的内容
this.drawLine(
{
width: 0.2,
pos: [pos[0], pos[1] + 0.1, pos[2]],
// 是否为虚线
dash: true,
},
roadMesh
);
this.drawLine(
{
width: 0.2,
pos: [-size[0] / 2 + 0.2, pos[1] + 0.1, pos[2]],
},
roadMesh
);
this.drawLine(
{
width: 0.2,
pos: [size[0] / 2 - 0.2, pos[1] + 0.1, pos[2]],
},
roadMesh
);
}
return roadMesh;
}

车道线

在道路上简单加些实线和虚线,让道路更真实一点,用 THREE.PlaneGeometry 实现就行。然后要实现虚线的话,需要分成几段(dashNum)来绘制,每段间隔多少 m(dashOffset),这些可以作为参数传入车道线的创建函数。这里要注意和车道 road 对象关联,比如车道线长度和车道长度保持一致、车道线对象倾斜度和 road 对齐等

this.drawLine(
{
// 线宽
size: 0.2,
// 起点(相对于road)。这一版暂时不支持斜线,感兴趣的可以自行再发挥下~
pos: [0, 0.1, 0],
},
// 传入3d场景的车道对象
road1
);
//...
// 绘制车道线 drawLine(lineInfo: ILineInfo, road: THREE.Mesh) {
const { pos, width, dash, dashNum = 10, dashOffset = 2 } = lineInfo;
// 获取道路 Mesh 的宽高信息
const roadSize = new THREE.Vector3();
const roadBox = new THREE.Box3().setFromObject(road);
roadBox.getSize(roadSize);
const lineMaterial = new THREE.MeshBasicMaterial({
color: lineInfo.color ?? 0xffffff,
side: THREE.DoubleSide,
});
// 绘制虚线
if (dash) {
const length = roadSize.z / dashNum - dashOffset;
// 表示整段虚线中,其中一段实线的起始位置
let nextZ = pos[2] + roadSize.z / 2 - dashOffset;
for (let i = 0; i < dashNum; i++) {
const lineGeometry = new THREE.PlaneGeometry(width, length);
const line = new THREE.Mesh(lineGeometry, lineMaterial);
line.position.set(pos[0], pos[1], nextZ);
if (road?.quaternion) {
// 车道线的旋转角度和车道对齐
line.rotation.setFromQuaternion(road.quaternion);
} else {
// 车道线默认是水平方向
line.rotation.x = Math.PI / 2;
}
this.scene!.add(line);
// 计算下一端实线的起点
nextZ = nextZ - (length + dashOffset);
}
// 绘制实线
} else {
const lineGeometry = new THREE.PlaneGeometry(width, roadSize.z);
const line = new THREE.Mesh(lineGeometry, lineMaterial);
line.position.set(pos[0], pos[1], pos[2]);
if (road?.quaternion) {
line.rotation.setFromQuaternion(road.quaternion);
} else {
line.rotation.x = Math.PI / 2;
}
this.scene!.add(line);
}
}
// 线条参数
export interface ILineInfo {
// 线条宽度
width: number;
// 起始位置
pos: number[];
// 默认白色
color?: string;
// 是否虚线,默认实线
dash?: boolean;
// 虚线间隔,默认2
dashOffset?: number;
// 虚线段数,默认10段
dashNum?: number;
}

车道线

一些障碍物

雾墙

首先得找张合适的烟雾图片做材质的纹理贴图,注意背景要选透明的,然后沿着 z 轴叠多几层平面几何体,做成一个半透明的雾墙。然后加点烟雾扩散的动画,通过在渲染循环 animate 里动态调整雾墙对象 scale的大小来模拟实现。当然,你还可以尝试加些位移或者其他变化,让这个烟雾效果更真实点

interface IObjPayload {
// 位置
pos: number[];
}

const textureLoader = new THREE.TextureLoader();
// ...
// 绘制雾墙
fogWalls: THREE.Mesh[] = [];
drawFogWalls(payload: IObjPayload) {
const { pos } = payload;
// 加载纹理贴图
const texture = textureLoader.load("/gta/cloud2.png");
const material = new THREE.MeshLambertMaterial({
map: texture,
transparent: true,
opacity: 0.8,
});
for (let i = 0; i < 10; i++) {
const smokeGeo = new THREE.PlaneGeometry(20, 20);
const mesh = new THREE.Mesh(smokeGeo, material);
mesh.position.set(pos[0], pos[1], pos[2] - i * 0.05);
mesh.rotation.z = Math.random() * Math.PI * 2;
this.scene!.add(mesh);
this.fogWalls.push(mesh);
}
}
// 做个简单的动画,每帧里遍历每一层,做个简单的扩散效果
updateFog() {
this.fogWalls.forEach((mesh) => {
mesh.scale.set(
mesh.scale.x + 0.0005,
mesh.scale.y + 0.0005,
mesh.scale.z
);
});
}
// ...
const animate = () => {
// ...
this.updateFog();
renderer.render(scene, camera);
// ...
};

雾墙

路障

这里做几个正方体盒子叠起来,然后自车开过去有个撞击效果。用 CANNON.BoxTHREE.BoxGeometry 就可以实现,注意刚体质量设置小一点,车子总得撞的过去。顺便用 THREE.EdgesGeometry 加下盒子边框。至于盒子叠放逻辑,这里就简单参考金字塔来叠了,10 个盒子对应 1234 的居中叠放方式

drawObstacles(payload: IObjPayload) {
const { pos } = payload;
const boxSize = 1.6;
const geometry = new THREE.BoxGeometry(boxSize, boxSize, boxSize);
const material = new THREE.MeshLambertMaterial({
color: "yellow",
});
const mesh = new THREE.Mesh(geometry, material);
// 加边框
const box = geometry.clone();
const edges = new THREE.EdgesGeometry(box);
const edgesMaterial = new THREE.LineBasicMaterial({
color: 0x333333,
});
const line = new THREE.LineSegments(edges, edgesMaterial);
// 组合起来
const group = new THREE.Group();
group.add(line, mesh);
const startPos = [pos[0], pos[1], pos[2]];
let newPos = [...startPos];
for (let i = 0; i < 10; i++) {
if (i < 4) {
newPos = [startPos[0] + i * 2, startPos[1] + boxSize, startPos[2]];
} else if (i < 7) {
newPos = [
startPos[0] + (i - 3.5) * 2,
startPos[1] + boxSize * 2,
startPos[2],
];
} else if (i < 9) {
newPos = [
startPos[0] + (i - 6) * 2,
startPos[1] + boxSize * 3,
startPos[2],
];
} else {
newPos = [startPos[0] + 3, startPos[1] + boxSize * 4, startPos[2]];
}
// 复制 mesh 对象,可以直接复用顶点数据,而不需要计算新的顶点
const obstacle = group.clone();
obstacle.position.set(newPos[0], newPos[1], newPos[2]);
this.scene!.add(obstacle);
// 创建刚体,并关联3d物体
const q3 = obstacle.quaternion;
const roadShape3 = new CANNON.Box(
new CANNON.Vec3(boxSize / 2, boxSize / 2, boxSize / 2)
);
const roadBody3 = new CANNON.Body({ mass: 3 });
roadBody3.addShape(roadShape3);
roadBody3.position.set(newPos[0], newPos[1], newPos[2]);
roadBody3.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
this.world!.addBody(roadBody3);
// 关联物理刚体
obstacle.physicBody = roadBody3;
// 更新 physicObjects
this.physicObjects.push(obstacle);
}
}

路障

注意这两段很重要 :

  • obstacle.physicBody = roadBody3;。3d 障碍物对象关联对应的刚体
  • this.physicObjects.push(obstacle);physicObjects其实存放了所有附带物理属性的 3d 物体,这样后续可以在渲染循环里通过遍历这个数组,从关联的刚体physicBody取到物理计算后的新值,赋值给 3d 物体,从而实现物理效果

滚落的球体

通过 THREE.SphereGeometry**CANNON.Sphere来实现球体和对应的刚体。因为在有倾斜度的道路上,小球在重力作用下是会向下滚动的,不过需要在自车到达一定位置后才触发滚动,一开始先把巨球设定为静止。小球的质量也比较大,自车被撞到后会明显减速,所以经过时要小心点

同样,这里也做个函数封装,将 3d 球体和刚体关联起来

  drawBalls(payload: IObjPayload) {
const { pos } = payload;
const geometry = new THREE.SphereGeometry(2);
const material = new THREE.MeshLambertMaterial({
color: "brown",
});
const obstacle = new THREE.Mesh(geometry, material);
obstacle.position.set(pos[0], pos[1], pos[2]);
this.scene!.add(obstacle);
const q3 = obstacle.quaternion;
const roadShape3 = new CANNON.Sphere(2);
const roadBody3 = new CANNON.Body({ mass: 200 });
roadBody3.addShape(roadShape3);
roadBody3.position.set(pos[0], pos[1], pos[2]);
roadBody3.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
obstacle.physicBody = roadBody3;
this.balls = [obstacle];
}

设定规则是自车到达一定位置时再触发巨球的滚动,之前都是静止。那其实就是自车行驶到指定位置的时候再将巨球加入物理世界就行

const animate = () => {
// ...
if (egoCar.position.z <= -10) {
this.balls.forEach((ball) => {
this.world!.addBody(ball.physicBody);
this.physicObjects.push(ball);
});
}
this.updatePhysics();
// ...
};

滚落的小球

疯狂大摆锤

做个大摆锤,先用两个圆柱体 THREE.CylinderGeometry实现一个悬挂空中的大摆锤,一个圆柱体做杆,一个圆柱体做锤,新建锤的刚体 CANNON.Cylinder。注意锤的刚体要设置质量 mass 为 0,确保不受重力影响,才能悬挂在空中

drawHammer(payload: IHammerPayload) {
const { pos, startAngle = -Math.PI / 2, duration = 1000 } = payload;
const geometry = new THREE.CylinderGeometry(0.2, 0.2, 13, 10);
const material = new THREE.MeshLambertMaterial({
color: "gray",
});
const mesh1 = new THREE.Mesh(geometry, material);
mesh1.position.set(-2, -6, -5);
const geometry2 = new THREE.CylinderGeometry(1, 1, 4, 10);
const material2 = new THREE.MeshLambertMaterial({
color: "gray",
});
const mesh2 = new THREE.Mesh(geometry2, material2);
mesh2.position.set(-2, -13.5, -5);
mesh2.rotation.z = Math.PI / 2;
const group = new THREE.Group();
group.add(mesh1, mesh2);
group.position.set(pos[0], pos[1], pos[2]);
group.rotation.z = startAngle;
this.scene!.add(group);
// 创建刚体
const q3 = mesh2.quaternion;
const roadShape = new CANNON.Cylinder(1, 1, 4, 10);
// 注意:设置mass为0,摆锤才可以悬浮在空中,不受重力影响
const roadBody = new CANNON.Body({ mass: 0 });
roadBody.addShape(roadShape);
roadBody.position.set(mesh2.position.x, mesh2.position.y, mesh2.position.z);
roadBody.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
this.world!.addBody(roadBody);
mesh2.physicBody = roadBody;
}

最后再用 tween.js 实现摆锤的动画,别忘了在渲染循环里执行动画更新 TWEEN.update()。这里整个效果实现的链路是:tween 平滑更新摆锤旋转角度 --> 获取最新的锤体的位置信息和旋转角度,更新刚体数据 --> 物理计算后再更新 3d 场景其他物体的位置和旋转角度,比如摆锤和自车相撞的效果

drawHammer(payload: IHammerPayload) {
// ...
// 摆锤动画
const tweenStart = new TWEEN.Tween(group.rotation)
// 终态
.to({ z: Math.PI / 2 }, duration)
// 延迟500ms再触发动画
.delay(500)
// 不断重复动画
.repeat(Infinity)
// 初态和终态之间平滑地来回变化
.yoyo(true)
.onUpdate((data) => {
// NOTE 注意这里要重新计算mesh2的位置信息,因为mesh2的position是相对group的,并不会变化
group.updateMatrixWorld(true);
const child = group.children[1];
const globalPosition = new THREE.Vector3();
const globalQuaternion = new THREE.Quaternion();
const pos = child.getWorldPosition(globalPosition);
const q = child.getWorldQuaternion(globalQuaternion);
// 更新刚体数据
roadBody.quaternion = new CANNON.Quaternion(q._x, q._y, q._z, q._w);
roadBody.position.set(pos.x, pos.y, pos.z);
})
.start();
}
// ...
const animate = () => {
// ...
this.updatePhysics();
TWEEN.update();
// ...
};

大摆锤

游戏逻辑

游戏结束

小车到达终点,也就是 z 值达到对应的值并且此时小车还在道路上,就说明到达终点,停住车并提示到达终点,过 5 秒后自动重新开始;或者掉落到某个边界值就显示游戏结束,过 5 秒后自动重新开始。这里借助了 mobx 做一个数据响应来触发提示。逻辑比较简单,可以自行参考源码 ~

写在最后

体验地址。期待一下 3.0 ?

· 7 min read

小游戏灵感来自于抖音的奶龙飞车 4 小游戏,感兴趣的可以搜索查看下 ~

奶龙飞车

这里尝试基于 threejs 实现一个空中飞车小游戏,可以通过键盘方向键控制小车的行驶和转向,经过前方多段不规则的路后到达终点。先看下需要实现哪些效果:

  • 首先整体场景是在半空中悬浮的,需要做个天空背景
  • 然后要实现几段悬浮的倾斜度不一样的道路
  • 第二段道路中间设定一些道路是左右移动的,稍微增加点游戏难度
  • 玩家可以根据方向键控制小车方向
  • 小车和道路的碰撞处理。这部分逻辑沿用前文的小车碰撞处理逻辑,继续用 cannon-es 这个库,以下代码也是基于前文的代码,所以物理库的相关逻辑请参照前文或代码注释,这篇文章不会赘述

还是拿之前自己实现的小车来当主角吧 ~

天空背景

其实做个天空盒子就可以了,盒子就是一个比较大的正方体 BoxGeometry,给六面材质都加上贴图来模拟天空,然后将场景包裹在里面。这里简单点就用一张浅蓝色背景的图,如果还想做的更好点,那就得准备 6 张不同的图,图里加一些云彩和渐变色

// 创建天空盒子
const skyGeometry = new THREE.BoxGeometry(1000, 1000, 1000);
const materialArray = [];
for (let i = 0; i < 6; i++)
materialArray.push(
new THREE.MeshBasicMaterial({
// 加载贴图
map: textureLoader.load("./gta/sky.jpg"),
// 只渲染背面就行
side: THREE.BackSide,
})
);
const skyBox = new THREE.Mesh(skyGeometry, materialArray);
scene.add(skyBox);

天空贴图

制作道路

一段道路其实就是一个平面多边形,可以用 PlaneGeometry 实现,实现代码参考:

// 加载道路的纹理贴图
function texturePromise() {
return new Promise((resolve, reject) => {
textureLoader.load("/gta/floor.jpg", (texture) => {
resolve(texture);
});
});
}
// 第一段道路
const planeGeometry = new THREE.PlaneGeometry(10, 50);
// 设置道路材质
const texture: any = await texturePromise();
const planeMaterial = new THREE.MeshLambertMaterial({
map: texture,
// 渲染两面,因为道路是悬浮的,位置有高低,相机可能会观察到俩面
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 接收阴影
plane.receiveShadow = true;
// 设置倾斜度
plane.rotation.x = Math.PI / 2;
// 增加到3d场景里
scene.add(plane);

然后咱们参考上述的代码多实现几段道路,分别赋予不同的位置(position)、长宽和倾斜度(rotation)

接下来就是创建每段道路的刚体,下面以一段道路为例:

import * as CANNON from "cannon-es";
// ...
// 第一段刚体
const q = plane.quaternion;
// 类比3d场景的Mesh
const roadShape = new CANNON.Box(new CANNON.Vec3(5, 25, 0.01));
// 类比3d场景的Geometry
const roadBody = new CANNON.Body({ mass: 0 });
roadBody.addShape(roadShape);
// 刚体位置和3d对象对齐
roadBody.position.set(0, 0, 0);
// 倾斜度和3d对象对齐
roadBody.quaternion = new CANNON.Quaternion(q._x, q._y, q._z, q._w);
// 添加到物理世界里
this.world.addBody(roadBody);

其他道路刚体的实现类似。ok,接下来调整下相机参数,使其在运动的时候跟随自车:

const cameraOffsetY = 4;
const cameraOffsetZ = 16;
// ...
/***** 创建一个具有透视效果的摄像机 *****/
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
const animate = () => {
// ...
// 相机跟随自车
camera.position.y = egoCar.position.y + cameraOffsetY;
camera.position.z = egoCar.position.z + cameraOffsetZ;
camera.lookAt(egoCar.position);
renderer.render(scene, camera);
// ...
};

然后第二段道路再加点倾斜度,这样两段路的效果如下图:

两段路

然后第二段道路设计成有几段小道路组成,穿插有左右移动的动画,怎么做这个动画呢?可以借助 tween.js 做个平滑动画。tween.js 其实是一个补间动画库,提供了丰富的缓动函数,可以确保在两个值之间平滑地过渡,经常配合 threejs 来实现一些动画效果,比如物体平移和旋转、镜头推进等效果

npm install @tweenjs/tween.js

第二段道路再分出几段小路做左右平移的动画,需要设置对应的动画参数,其中一段道路的代码如下:

import * as TWEEN from "@tweenjs/tween.js";
// ...
const tweenRoad1Start = new TWEEN.Tween(plane21.position)
// 在1s内向x为-2的位置移动
.to({ x: -2 }, 1000)
// 延迟动画
.delay(500)
// 重复动画的次数
.repeat(Infinity)
// 也就是在初始位置和目标位置来回运动
.yoyo(true)
// 更新回调
.onUpdate((data) => {
// 更新刚体数据
roadBody21.position.x = data.x;
})
// 启动动画
.start();

// ...循环里更新动画
const animate = () => {
// ...
updatePhysics();
// 更新tween动画
TWEEN.update();
// ...
};

其他几段道路做动画的逻辑类似,最终效果如图:

road-animate

ok,到这里就可以自己开一段路试试了。掉下去或者想重新开始,自己刷新下界面,懒得做边界逻辑...forgive me。具体的自车控制逻辑,可以参考前文或者源码 ~

  • 体验地址(仅支持 pc 端,通过方向键控制行驶和转向)

最后

当然,这一版实现还是很简单的,完全可以加些更复杂的场景元素或者障碍物,实现飞车 2.0?

· 17 min read

最近刷到了抖音的漂移停车 2 的视频,感觉还蛮有趣的

抖音原视频

乍一看,实现这个漂移停车的效果需要一些东西:

  • 一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧
  • 漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向
  • 和车库的碰撞处理
  • 停车后的计分逻辑

之前的文章实现了 3d 场景和可以凑合开的一辆小车,咱们拿来接着用一下

行车物理模拟

其实之前自己实现的自车行驶超级简单,加减速、转弯都做的比较粗糙,这里引入物理库 cannon-es(cannon.js 的增强版)来帮忙做这块逻辑。物理库的作用其实就是模拟一些真实的物理效果,比如行车、物理碰撞、重力等。具体 api 文档 戳这里,不过只有英文文档

npm install cannon-es

先初始化一个物理世界,其实和 threejs 场景的初始化有点像,之后也是需要将物理世界的物体和 threejs 的物体一一对应地关联起来,比如这里的地面、小车和车库,这样后面物理库做计算后,再将作用后的物体的位置信息赋值到 threejs 对应物体的属性上,最后通过循环渲染(animate)就能模拟行车场景了

import * as CANNON from "cannon-es";
// ...
const world = new CANNON.World();
// 物理世界预处理,这个可以快速排除明显不发生碰撞的物体对,提高模拟效率
world.broadphase = new CANNON.SAPBroadphase(world);
// 物理世界的重力向量
world.gravity.set(0, -9.8, 0);
// 刚体之间接触面的默认摩擦系数
world.defaultContactMaterial.friction = 0;

小车对象

cannon-esRaycastVehicle 类可以辅助我们管理物理世界的小车对象,它提供了很多蛮好用的 api,不仅可以帮助我们更好地管理车轮,而且能很好地根据地形运动

物理世界物体的基本要素有形状(常见的有Box长方体/Plane平面/Sphere球体)、材质 Material 和刚体 Body,类比 threejs 中的几何体、材质和 Mesh。创建刚体后别忘了将它添加到物理世界里,和 threejs 将物体添加到 scene 场景里类似

// 创建小车底盘形状,这里就是一个长方体
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.3, 2));
// 创建质量为150kg的小车刚体。物理世界的质量单位是kg
const chassisBody = new CANNON.Body({ mass: 150 });
// 关联刚体和形状
chassisBody.addShape(chassisShape);
// 设定刚体位置
chassisBody.position.set(0, 0.4, 0);
// 基于小车底盘创建小车对象
const vehicle = new CANNON.RaycastVehicle({
chassisBody,
// 定义车辆的方向轴(0:x轴,1:y轴,2:z轴),让它符合右手坐标系
// 车辆右侧
indexRightAxis: 0,
// 车辆上方
indexUpAxis: 1,
// 车辆前进方向
indexForwardAxis: 2,
});
// 将小车添加到物理世界里,类比 threejs 的 scene.add()
vehicle.addToWorld(world);

四个车轮

接下来定义下车轮对象,用到了 Cylinder这种圆柱体的形状,然后要注意做好旋转值 Quaternion 的调整。这部分会稍微复杂些,可以耐心看下注释:

// 车轮配置,详情配置参考 https://pmndrs.github.io/cannon-es/docs/classes/RaycastVehicle.html#addWheel
const options = {
radius: 0.4, // 轮子半径
directionLocal: new CANNON.Vec3(0, -1, 0), // 轮子方向向量,指轮子从中心点出发的旋转方向
suspensionStiffness: 45,
suspensionRestLength: 0.4,
frictionSlip: 5, // 滑动摩擦系数
dampingRelaxation: 2.3,
dampingCompression: 4.5,
maxSuspensionForce: 200000,
rollInfluence: 0.01,
axleLocal: new CANNON.Vec3(-1, 0, 0),
chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0),
maxSuspensionTravel: 0.25,
customSlidingRotationalSpeed: -30,
useCustomSlidingRotationalSpeed: true,
};
const axlewidth = 0.7;
// 设置第一个车轮的连接点位置
options.chassisConnectionPointLocal.set(axlewidth, 0, -1);
// 按指定配置给小车添加第一个车轮,其他车轮类似
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, -1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(axlewidth, 0, 1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, 1);
vehicle.addWheel(options);
// 四个车轮
const wheelBodies: CANNON.Body[] = [];
const wheelVisuals: THREE.Mesh[] = [];
vehicle.wheelInfos.forEach(function (wheel) {
const shape = new CANNON.Cylinder(
wheel.radius,
wheel.radius,
wheel.radius / 2,
20
);
const body = new CANNON.Body({ mass: 1, material: wheelMaterial });
// 刚体可以是动态(DYNAMIC)、静态(STATIC)或运动学(KINEMATIC)
body.type = CANNON.Body.KINEMATIC;
// 0表示这个刚体将与所有其他未设置特定过滤组的刚体进行碰撞检测
body.collisionFilterGroup = 0;
// 使用setFromEuler方法将欧拉角转换为四元数,欧拉角的值为-Math.PI / 2(即-90度或-π/2弧度)
const quaternion = new CANNON.Quaternion().setFromEuler(-Math.PI / 2, 0, 0);
body.addShape(shape, new CANNON.Vec3(), quaternion);
wheelBodies.push(body);
// 创建3d世界的车轮对象
const geometry = new THREE.CylinderGeometry(
wheel.radius,
wheel.radius,
0.4,
32
);
const material = new THREE.MeshPhongMaterial({
color: 0xd0901d,
emissive: 0xaa0000,
flatShading: true,
side: THREE.DoubleSide,
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.geometry.rotateZ(Math.PI / 2);
wheelVisuals.push(cylinder);
scene.add(cylinder);
});

这一步很关键,需要在每次物理模拟计算结束后 (postStep事件的回调函数) 更新车轮的位置和转角

// ...
world.addEventListener("postStep", function () {
for (let i = 0; i < vehicle.wheelInfos.length; i++) {
vehicle.updateWheelTransform(i);
const t = vehicle.wheelInfos[i].worldTransform;
// 更新物理世界车轮对象的属性
wheelBodies[i].position.copy(t.position);
wheelBodies[i].quaternion.copy(t.quaternion);
// 更新3d世界车轮对象的属性
wheelVisuals[i].position.copy(t.position);
wheelVisuals[i].quaternion.copy(t.quaternion);
}
});

车辆行驶和转向

监听键盘事件,按下上下方向键给一个前后的引擎动力,按下左右方向键给车轮一个转角值

// 引擎动力值
const engineForce = 3000;
// 转角值
const maxSteerVal = 0.7;
// 刹车作用力
const brakeForce = 20;
// ...
// 刹车
function brakeVehicle() {
// 四个车轮全部加刹车作用力
vehicle.setBrake(brakeForce, 0);
vehicle.setBrake(brakeForce, 1);
vehicle.setBrake(brakeForce, 2);
vehicle.setBrake(brakeForce, 3);
}
function handleNavigate(e: any) {
if (e.type != "keydown" && e.type != "keyup") {
return;
}
const isKeyup = e.type === "keyup";
switch (e.key) {
case "ArrowUp":
// 给第2/3个车轮加引擎动力
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 3);
break;
case "ArrowDown":
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 3);
break;
case "ArrowLeft":
// 设置车轮转角
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 3);
break;
}
brakeVehicle();
}
window.addEventListener("keydown", handleNavigate);
window.addEventListener("keyup", handleNavigate);

然后在每一帧里重新计算物体的物理值,并赋值给 3d 世界的小车属性,就可以实现行车效果

function updatePhysics() {
world.step(1 / 60);
egoCar.position.copy(chassisBody.position);
egoCar.quaternion.copy(chassisBody.quaternion);
}
// ...
const animate = () => {
stats.begin();
// ...
updatePhysics();
// ...
stats.end();
requestAnimationFrame(animate);
};
animate();

地面优化

地面看起来太光滑,显得有点假,咱们先给地面加上有磨砂质感的纹理贴图,同时隐藏掉辅助网格

// ...
// 加载纹理贴图
textureLoader.load("/gta/floor.jpg", (texture) => {
const planeMaterial = new THREE.MeshLambertMaterial({
// 将贴图对象赋值给材质
map: texture,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 地面接受阴影
plane.receiveShadow = true;
plane.rotation.x = Math.PI / 2;
scene.add(plane);
});

加载完贴图,生成 3d 场景的地面对象后,别忘了创建地面刚体并关联。这里还要定义地面刚体的物理材质,类比 threejs 的材质,会影响不同刚体之间摩擦和反弹的效果

// ...
// 定义地板的物理材质
const groundMaterial = new CANNON.Material("groundMaterial");
// 定义车轮的物理材质,其实之前代码用过了,可以留意下
const wheelMaterial = new CANNON.Material("wheelMaterial");
// 定义车轮和地板之间接触面的物理关联,在这里定义摩擦反弹等系数
const wheelGroundContactMaterial = new CANNON.ContactMaterial(
wheelMaterial,
groundMaterial,
{
// 摩擦系数
friction: 0.5,
// 反弹系数,0表示没有反弹
restitution: 0,
}
);
world.addContactMaterial(wheelGroundContactMaterial);
// ...
textureLoader.load("/gta/floor.jpg", (texture) => {
// ...
// 地面刚体
const q = plane.quaternion;
const planeBody = new CANNON.Body({
// 0说明物体是静止的,发生物理碰撞时不会相互移动
mass: 0,
// 应用接触面材质
material: groundMaterial,
shape: new CANNON.Plane(),
// 和3d场景的旋转值保持一致。在Cannon.js中,刚体的旋转可以通过四元数来表示,而不是传统的欧拉角或轴角表示法
quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w),
});
world.addBody(planeBody);
});

这回开起来可顺畅许多了,场景和自车旋转也变得更自然一些,感谢开源 ~

新车

搭建车库

咱就搭个棚,一个背景墙、两个侧边墙、加一个屋顶和地板,其实都是些立方体,拼装成网格对象 Mesh 后,按照一定的位置和旋转拼在一起组成小车库,参考代码:

createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
const sider1 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider1.rotation.y = Math.PI / 2;
sider1.position.set(-1.5, 0.1, -50);
this.scene.add(sider1);
const sider2 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider2.rotation.y = Math.PI / 2;
sider2.position.set(1.5, 0.1, -50);
this.scene.add(sider2);
// 创建屋顶
const roof = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({
color: 0xcccccc,
// 注意:这个值不为true的话,设置opacity是没用的
transparent: true,
opacity: 0.8,
})
);
roof.rotation.x = Math.PI / 2;
roof.position.set(0, 2, -50);
this.scene.add(roof);
// 创建地板
const floor = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({ color: 0x666666 })
);
floor.rotation.x = Math.PI / 2;
floor.position.set(0, 0.1, -50);
this.scene.add(floor);
}

好了,一个稍微有点模样的小车库就大功告成

小车库

创建车库刚体

先加个背景墙的物理刚体

createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
// ...
// physic
const houseShape = new CANNON.Box(new CANNON.Vec3(1.5, 4, 0.1));
const houseBody = new CANNON.Body({ mass: 0 });
houseBody.addShape(houseShape);
houseBody.position.set(0, 0, -53);
this.world.addBody(houseBody);
}
// ...

其他的墙体类似的处理,屋顶先不管吧,小车应该也够不着。来,先撞一下试试

撞墙模拟

漂移停车

其实达到一定速度,通过方向键就能做一个甩尾漂移倒车入库

  1. 提供一个弹射的初始动力
// ...
animate();
setTimeout(() => {
// 给后轮上点动力
vehicle.applyEngineForce(2000, 2);
vehicle.applyEngineForce(2000, 3);
}, 100);
  1. 电脑端根据方向键触发漂移,这里注意要消除后轮的动力
// ...
case "ArrowLeft":
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
// ...
  1. 移动端根据触屏方向触发。需要注意此时要把相机控制器关掉,避免和触屏操作冲突。计算触发方向的逻辑参考
// 计算划过的角度
function getAngle(angx: number, angy: number) {
return (Math.atan2(angy, angx) * 180) / Math.PI;
}
// 计算触屏方向
function getDirection(
startx: number,
starty: number,
endx: number,
endy: number
): ESlideDirection {
const angx = endx - startx;
const angy = endy - starty;
let result = ESlideDirection.None;
if (Math.abs(angx) < 2 && Math.abs(angy) < 2) {
return result;
}
const angle = getAngle(angx, angy);
if (angle >= -135 && angle <= -45) {
result = ESlideDirection.Top;
} else if (angle > 45 && angle < 135) {
result = ESlideDirection.Bottom;
} else if (
(angle >= 135 && angle <= 180) ||
(angle >= -180 && angle < -135)
) {
result = ESlideDirection.Left;
} else if (angle >= -45 && angle <= 45) {
result = ESlideDirection.Right;
}
return result;
}
let startx = 0;
let starty = 0;
document.addEventListener("touchstart", (e) => {
startx = e.touches[0].pageX;
starty = e.touches[0].pageY;
});
document.addEventListener("touchend", function (e) {
const endx = e.changedTouches[0].pageX;
const endy = e.changedTouches[0].pageY;
const direction = getDirection(startx, starty, endx, endy);
// 根据方向做转向和刹车的处理,和上面电脑侧左右键的逻辑一致就行了
// ...
});

计算分数

根据小车和车库角度偏差和中心点偏差来综合得分,这里就不细究了,浅浅定个规则:

  • 不入库或没倒车:0 分
  • 其他情况:50 分 + 角度分(20x 比例) + 中心分(30x 比例)

车停住后,先算出分数,再加个数字递增的效果,用 setInterval 实现就好了。不过这里要注意用回调函数的方式更新 state 值,避免闭包引起值不更新的问题

计分组件实现代码参考:

export const Overlay = observer(() => {
const [score, setScore] = useState(0);
useEffect(() => {
if (vehicleStore.score) {
// 计分动画
const timer = setInterval(() => {
// 回调方式更新state
setScore((score) => {
if (score + 1 === vehicleStore.score) {
clearInterval(timer);
}
return score + 1;
});
}, 10);
}
}, [vehicleStore.score]);

if (!vehicleStore.isStop) {
return null;
}

return (
<div className={styles["container"]}>
<div className={styles["score-box"]}>
<div className={styles["score-desc"]}>得分</div>
<div>{score}</div>
</div>
</div>
);
});

那么问题来了,怎么监听它停下了?可以加一个速度的阈值 velocityThreshold,如果小车刚体的速度低于这个阈值就判定小车停下了。然后通过 mobx 状态库建立一个 vehicleStore,主要是维护 isStop(是否停止) 和 score(分数) 这两个变量,变化后自动通知计分组件更新,这部分逻辑可以参考源码实现 ~

// ...
const velocityThreshold = 0.01;
function updatePhysics() {
world.step(1 / 60);
// ...
// 检查刚体的速度,小于阈值视为停止
if (
chassisBody.velocity.length() < velocityThreshold &&
// 停车标识
!vehicleStore.isStop
) {
console.log("小车已经停止");
vehicleStore.stop();
// 触发计分逻辑,自行参考源码
// ...
vehicleStore.setScore(score);
}
}
// ...

最终效果

在线体验,手机可以扫码快速体验(首次加载有点卡,多刷新下看看,后面再琢磨优化下叭 ~)

体验二维码

传送门

· 7 min read

在上一篇文章 threejs 都有些啥 搭建的场景的基础上,咱们尝试做一个简单的自动泊车,进一步探究下 threejs 中还有哪些东西

简易版小车

因为之前用的模型比较大,加载很慢,这里就先自己简单实现一辆小车(后面统称自车),如下图:

简易版小车

将车轮、车体和边框组合成一个 Group,便于后面做自车的一些操作,实现代码如下:

// 自车车体
const geometry = new THREE.BoxGeometry(2, 0.6, 3);
const material = new THREE.MeshBasicMaterial({
color: 0x00ffff,
side: THREE.DoubleSide,
});
const vehicle = new THREE.Mesh(geometry, material);
vehicle.position.set(0, 1, 0);
scene.add(vehicle);
// 增加自车边框
const box = geometry.clone();
const edges = new THREE.EdgesGeometry(box);
const edgesMaterial = new THREE.LineBasicMaterial({
color: 0x333333,
});
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.x = 0;
line.position.y = 1;
line.position.z = 0;
scene.add(line);
// 组成一个Group
const egoCar = new THREE.Group();
egoCar.name = "自车";
egoCar.add(vehicle, line);
scene.add(egoCar);
// 车轮
const axlewidth = 0.7;
const radius = 0.4;
const wheels: any[] = [];
const wheelObjects: any[] = [];
wheels.push({ position: [axlewidth, 0.4, -1], radius });
wheels.push({
position: [-axlewidth, 0.4, -1],
radius,
});
wheels.push({ position: [axlewidth, 0.4, 1], radius });
wheels.push({ position: [-axlewidth, 0.4, 1], radius });
wheels.forEach(function (wheel) {
const geometry = new THREE.CylinderGeometry(
wheel.radius,
wheel.radius,
0.4,
32
);
const material = new THREE.MeshPhongMaterial({
color: 0xd0901d,
emissive: 0xee0000,
side: THREE.DoubleSide,
flatShading: true,
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.geometry.rotateZ(Math.PI / 2);
cylinder.position.set(
wheel.position[0],
wheel.position[1],
wheel.position[2]
);
egoCar.add(cylinder);
// 后面修改车轮方向会用到
wheelObjects.push(cylinder);
});

跟车相机

让相机一直跟着自车,体验更好一点

// ...
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
// 设置摄像机位置,并将其朝向场景中心
camera.position.x = 0;
// camera.position.y = 10;
// camera.position.z = 20;
// camera.lookAt(scene.position);
camera.lookAt(egoCar.position);
// ...
function animate() {
stats.begin();
controls.update();
// 相机跟随自车
camera.position.y = egoCar.position.y + 15;
camera.position.z = egoCar.position.z + 25;
camera.lookAt(egoCar.position);
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
// ...

自车行驶

实现自车前行后退和左右转向

// ...
// 记录开始按下的时间
let startTime = 0;
const activeKeys = new Set();
let t = 0;
document.addEventListener("keydown", (e) => {
activeKeys.add(e.key);
if (startTime === 0) {
startTime = Date.now();
}
t = (Date.now() - startTime) / 1000;
if (t > 10) {
t = 10;
}
});
document.addEventListener("keyup", (e) => {
activeKeys.delete(e.key);
if (activeKeys.size === 0) {
startTime = 0;
}
});
function animate() {
stats.begin();
controls.update();
// 相机跟随自车
camera.position.y = egoCar.position.y + 15;
camera.position.z = egoCar.position.z + 25;
camera.lookAt(egoCar.position);
if (activeKeys.has("ArrowUp")) {
// 估算对应方向的移动距离
egoCar.position.z -= t * 0.1 * Math.cos(egoCar.rotation.y);
egoCar.position.x -= t * 0.1 * Math.sin(egoCar.rotation.y);
}
if (activeKeys.has("ArrowDown")) {
egoCar.position.z += t * 0.1 * Math.cos(egoCar.rotation.y);
egoCar.position.x += t * 0.1 * Math.sin(egoCar.rotation.y);
}
if (activeKeys.has("ArrowLeft")) {
egoCar.rotation.y += 0.01;
}
if (activeKeys.has("ArrowRight")) {
egoCar.rotation.y -= 0.01;
}
renderer.render(scene, camera);
stats.end();
requestAnimationFrame(animate);
}
//...

车轮转动

遍历车轮对象,动态修改车轮的偏转角 rotation,以车头方向为基准偏转固定的角度

function animate() {
// ...
if (activeKeys.has("ArrowLeft")) {
egoCar.rotation.y += 0.01;
wheelObjects.forEach((wheel) => {
wheel.rotation.y = egoCar.rotation.y + Math.PI / 4;
});
}
if (activeKeys.has("ArrowRight")) {
egoCar.rotation.y -= 0.01;
wheelObjects.forEach((wheel) => {
wheel.rotation.y = egoCar.rotation.y - Math.PI / 4;
});
}
// ...
}

行进效果还是有点僵硬(能用就行),这里的问题是行进方向应该是按车头方向,而不是固定按某个坐标轴方向,不过这里也只是简单模拟这个行进效果,后面再引入物理库 cannon.js优化下这块控制逻辑

小车车轮转向

泊车功能

车位实现

做一个贴地面的矩形框来模拟车位,可以使用 THREE.PlaneGeometry 来创建平面几何体

createParkingSpace() {
const plane = new THREE.PlaneGeometry(8, 5);
const material = new THREE.MeshPhongMaterial({
color: 0x666666,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(plane, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(10, 0.12, -20);
this.scene?.add(mesh);
// 增加自定义type,便于后面处理车位的选中逻辑
mesh.userData.type = "parkingSpace";
}

现在咱们把小车开过去停到那个位置

泊车1

自动泊车

需要实现点击车位后高亮对应的车位,之后小车自动行驶到对应的位置并停好。点击原理是用射线的方式采集第一个碰到的车位物体,当点击鼠标时,会发生以下步骤:

  1. 基于屏幕上的点击位置创建一个 THREE.Vector3 向量
  2. 使用 vector.unproject 方法将屏幕上点击位置的坐标转换成 three.js 场景中的坐标
  3. 创建 THREE.Raycaster可以从摄像机的位置向场景中鼠标的点击位置发出一条射线
  4. raycaster.intersectObjects 返回包含了所有被射线穿过的对象信息的数组(从摄像机位置开始由短到长)
function handleParkSpaceClick(event: any) {
let vector = new THREE.Vector3(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5
);
vector = vector.unproject(camera);
const raycaster = new THREE.Raycaster(
camera.position,
vector.sub(camera.position).normalize()
);
const intersects = raycaster.intersectObjects(scene.children);
for (let i = 0; i < intersects.length; i++) {
const obj = intersects[i];
// @ts-ignore
if (obj.object.userData.type === "parkingSpace")
// @ts-ignore
obj.object.material.color.set(0x00ff00);
}
}
document.addEventListener("click", handleParkSpaceClick);

自动泊车的实现逻辑也比较简单,这里简单记住了车位的位置信息,然后让小车按一定的偏移驶入,其实实际场景可能还要考虑躲避障碍物、加减速、偏转角等,一般也不由前端操心这些。实现代码参考 three-gta v0.1.1 -- 在线体验

泊车2

· 19 min read

three-mind

注意:以下内容适合入门党,并且仅提及一些常见的属性和方法,更全面的内容需要参照官方文档学习

Threejs 的背后是 webGL,WebGL 基于 OpenGL ES 2.0 提供 3d 图形接口,是在浏览器环境下进行 3d/2d 图像渲染的技术。基于它我们可以用 js 做以下这些事:

  • 创建 3d 几何图形
  • 给对象应用材质和纹理
  • 在 3d 场景中操作对象和实现动画
  • 加载 3d 模型

一个典型的 Three.js 应用至少包括渲染器、场景、相机、以及在场景中的物体。下面尝试实现中间放置一辆小车的场景,可以通过鼠标操作查看周围,然后可以通过键盘操作来开车Github 地址

初始化项目

我比较熟悉 React,所以基于 React + Vite + ts 先搭建个基本的项目

npm create vite@latest my-three-app -- --template react-ts

项目结构如下:

src
├─ assets
│ └─ react.svg
├─ components
│ └─ Playground # 3d区域组件
│ ├─ index.module.css
│ └─ index.tsx
├─ renderer
│ └─ index.ts # threejs实例
├─ App.css
├─ App.tsx
├─ index.css
├─ main.tsx
└─ vite-env.d.ts

先定义一个 threejs 渲染器的类:

// renderer/index.ts
class Renderer {
constructor() {
//...
}
init(initPayload: IInitPayload) {
const container = document.getElementById(initPayload.container);
const style = getComputedStyle(container!);
// 获取挂载元素的宽高,后面渲染器要用到
const width = parseFloat(style.width);
const height = parseFloat(style.height);
// ...
}
}

export const gtaRenderer = new Renderer();

然后在 Playground 组件里初始化这个实例

export function Playground() {
useEffect(() => {
gtaRenderer.init({
container: "playground",
});
}, []);

return <div id="playground" className={styles["playground"]}></div>;
}

当然觉得这样初始化麻烦的话,react-three-fiber 或许是你不错的选择

场景(Scene)

场景是光源、相机和所有物体的父容器,也就是我们前端开发艺术创作的空间了

场景的中心是点(0,0,0),也称为坐标系的原点,坐标系里的一个单位是一米

Three.js 默认使用右手坐标系,因为这是 webGL 默认的坐标系

创建场景对象

const scene = new THREE.Scene();
// 常见属性
// scene.background 设置背景
// scene.children 访问所有物体
// scene.fog 雾化效果,越远越模糊
// 常见方法
// scene.getChildByName(name) 通过指定name访问物体
// scene.traverse((obj) => {}) 遍历物体
// 更多属性参考 https://threejs.org/docs/#api/zh/scenes/Scene

Scene 继承了 Object3D 对象,所以有了 traversegetObjectByName 这些方法 包括后面提到的相机、几何体等

辅助网格

为了更好地理解和定位对象的位置,可以引入GridHelper这个插件,效果可以参考后文的图,使用代码如下:

init(initPayload: IInitPayload) {
// ...
const scene = new THREE.Scene();
const gridHelper = new THREE.GridHelper(100, 30, 0x2c2c2c, 0x888888);
scene.add(gridHelper);
}

相机(Camera)

有多种相机,比如基于透视投影的镜头PerspectiveCamera ,会模拟人的视觉效果(近大远小),从某个投射中心将物体投射到单一投影面上,是最常使用的投影模式。其他的投影方式还有正交投影,不同投影方式的对比可以参考 http://www.yanhuangxueyuan.com/Three.js_course/camera.html 讲的挺不错的

视锥体(Viewing frustum)

视锥体也就是中间那个椎体,也就是被渲染的物体所在的区域。视锥剔除(View frustum culling) 就是指从渲染过程中移除完全位于视截锥之外的对象的处理步骤

// 相机的观察角度
const fov = 35;
// 相机镜头画面的长宽比(也可以说是视椎体的长宽比),默认长宽比为1
const aspect = container.clientWidth / container.clientHeight;
// 视椎体近截面的距离 near clip plane
const near = 0.1;
// 视椎体远截面的距离 far clip plane
const far = 100;
// 透视投影
const camera = new PerspectiveCamera(fov, aspect, near, far);
// 定位相机
camera.position.set(0, 0, 5);

这里要选择透视投影,更接近现实场景,参考代码如下:

// 创建一个具有透视效果的摄像机
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
// 设置相机位置
camera.position.x = 10;
camera.position.y = 10;
camera.position.z = 30;
// == camera.position.set(10, 10, 30);
// 观察场景中心,默认是原点
camera.lookAt(scene.position);

渲染器(Renderer)

渲染器 WebGLRenderer 会将相机视椎体中的三维场景渲染成一个二维图像显示在 canvas 画布上

const renderer = new THREE.WebGLRenderer({
antialias: true, // 开启抗锯齿
});
renderer.render(scene, camera);
// 设置颜色及其透明度 setClearColor(string, number)
// renderer.setClearColor(0xffcc00);
// 设置设备像素比,防止 HiDPI 显示器模糊(视网膜显示器)
renderer.setPixelRatio(window.devicePixelRatio);
// 调整输出canvas的宽高并考虑设备像素比
renderer.setSize(width, height);
// 将渲染器的输出(此处是 canvas 元素)插入到 body 中
document.body.appendChild(renderer.domElement);

到这里,threejs 三剑客(场景、相机、渲染器)到齐,一个基础的 threejs 舞台就搭建好了

图形对象

基类图形只有点、线、三角形,其余图形都是在此基础上通过顶点着色算法组合而成

添加到场景中的对象会组成一个场景树,如下图:

那我们这里其实要新建一辆小车对象,这里先用一个长方体替代小车

const cube = new THREE.Mesh(
new THREE.CubeGeometry(1, 2, 3),
new THREE.MeshBasicMaterial({
color: 0xff0000,
})
);
scene.add(cube);

几何体(Geometry)

常见几何体有以下几种:

  • BoxGeometry 盒状几何体
  • CircleGeometry 圆形几何体
  • ConeGeometry 圆锥形几何体
  • CylinderGeometry 圆筒几何体
  • PlaneGeometry 平面几何体
  • SphereGeometry 球形几何体

BufferGeometry 是什么?

以上几何体其实都是继承自 BufferGeometry。相对于早期的 Geometry 类,BufferGeometry 通过将数据存储为类型化数组(存储在一组连续的内存缓冲区中),可以直接传输给 GPU 进行绘制,提高了数据访问和更新的效率,并且可以自定义顶点数据和索引数据,从而支持更复杂的几何体形状和操作

材质(Material)

常见材质有以下几种:

  • MeshBasicMaterial 基础网孔材质,为几何体赋予一种简单的颜色,或者显示几何体的线框
  • MeshPhongMaterial Phong 网孔材质,考虑光照的影响,可以创建光亮的物体
  • LineBasicMaterial 基础线条材质,可以用于 THREE.Line 几何体,从而创建着色的直线
  • LineDashedMaterial 虚线材质,类似于基础材质,但可以创建虚线效果

纹理(Texture)

需要搭配材质使用。通常用于给物体表面添加贴图,可以理解成物体的皮肤。可以是图像文件,比如 JPEG、PNG 或 GIF,也可以是视频或其他来源生成的图像。可以通过创建 THREE.Texture 对象来加载和使用纹理,使用示例如下:

// 创建纹理对象
const texture = new THREE.TextureLoader().load("texture.jpg");
// 也可以指定一个image对象
// const image = document.getElementById('myImage');
// const texture = new THREE.Texture(image);
// 应用到材质上
const material = new THREE.MeshBasicMaterial({ map: texture });
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

图形对象(Object)

图形对象其实就是上述其他几种对象的封装,网格对象 Mesh 是最常见的可见对象,它将材质和几何体拼装成一个可添加到场景中的对象。其他对象还有 Point/Line/Group

这里涉及到物体的基本转换,分为平移translation、旋转rotation和缩放scale,示例代码如下:

// 平移
cube.translateX(100); // 沿着x轴正方向平移100个单位
const axis = new THREE.Vector3(0, 1, 0);
cube.translateOnAxis(axis, 100); // 沿着axis轴表示方向平移100
// 旋转
mesh.rotateX(Math.PI / 2); // 绕x轴旋转π/2
// 缩放
cube.scale.x = 2; // x轴方向放大2倍
cube.scale.set(0.5, 0.5, 0.5); // 缩小为原来的0.5倍

加边框

MeshBasicMaterial.wireframe属性设置为true,网格对象会显示出线框,也就是网格模型的每一个三角形会通过 Line元素绘制出来,效果如下:

但其实多了一些额外的边框,这里只是给这个长方体加边框,可以用EdgesGeometry来实现,它本质上就是按照一定的算法重新组织已有几何体的顶点数据,然后通过线模型LineSegments绘制出来的几何体

// ...
// 增加边框
// 克隆长方体。克隆操作可以避免重复创建相同的对象,减少内存消耗。不过要注意:自定义属性不会克隆
const box = cubeGeometry.clone();
const edges = new THREE.EdgesGeometry(box);
const edgesMaterial = new THREE.LineBasicMaterial({
color: 0x333333,
});
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.x = 0;
line.position.y = 2;
line.position.z = 0;
scene.add(line);

效果如下:

画出线框后,借助 Group对象把长方体和线框组合起来,这样后面操作小车会更方便一些

// ...
const egoCar = new THREE.Group();
egoCar.name = "自车";
egoCar.add(car, line);
// egoCar.translateY(100);
scene.add(egoCar);

创建地面

PlaneGeometry 生成一个地平面

// ...
const planeGeometry = new THREE.PlaneGeometry(200, 200);
// 这里注意选择可以产生阴影的材质
const planeMaterial = new THREE.MeshLambertMaterial({ color: 0xc6c6c6 });
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 旋转90°贴合水平面
plane.rotation.x = (1 / 2) * Math.PI;
// 地面接受阴影
plane.receiveShadow = true;
scene.add(plane);

光源

光源照射物体,会产生光影效果。物体的材质在渲染的时候,和光源有很重要的关系,比如物体的纹理、色彩、透明度、光滑度、折射率、反光率等

  • AmbientLight 环境光,它的颜色会添加到整个场景和所有对象的当前颜色上
  • DirectionalLight 平行光,比如太阳光
  • PointLight 点光源,空间中的一点,朝所有的方向发射光线
  • SpotLight 聚光源,由点光源发出,这种类型的光也可以产生投影,有聚光的效果
// 创建环境光
const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
// 创建平行光
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.castShadow = true;
// 设置光源位置
directionalLight.position.set(15, 40, 35);
scene.add(directionalLight);

阴影

需要配合特定光源才能实现阴影效果。开启阴影的话一般需要有以下几个步骤:

// 渲染器要开启
renderer.shadowMap.enabled = true;
// 光源配置
directionalLight.castShadow = true;
// 地面接收阴影
plane.receiveShadow = true;
// 立方体接收阴影
cube.castShadow = true;

阴影的性能负担较大,所以 Threejs 是默认关闭的,一般不建议使用,或者用其他方式模拟(比如纹理贴图)

载入模型

既然做的差不多了,那是时候上真家伙了,咱们去找一个免费的小车模型并加载到场景里,替换掉长方体。常见的模型文件有以下几种:

  • gltf 是现阶段主流的模型类型,可以包含模型、动画、几何图形、材质、灯光、相机,甚至整个场景。glb 是它的二进制形式,体积小很多,不过会把纹理贴图也转成二进制格式,所以实际项目中尽量使用这种,不过 gltf 可读性更好
  • obj 是一种简单的文本格式,可以包含顶点位置、纹理坐标、法线等信息。缺点是文件体积相对较大,不支持二进制数据存储,无法存储动画数据,不支持材质和纹理的自定义属性等
  • mtl 是一种与 obj 配套使用的材质文件格式。mtl 文件包含了 obj 模型所需的材质属性信息,如颜色、纹理、法线贴图等。在加载 obj 模型时,可以通过 OBJLoader 来解析 obj 文件,并通过 MTLLoader 来加载对应的 mtl 文件。MTLLoader 会解析 mtl 文件中的信息,并将材质属性应用于对应的物体
  • fbx是一种 3D 通用模型文件。包含动画、材质特性、贴图、骨骼动画、灯光、摄像机等信息

以加载 fbx 文件为例:

import { FBXLoader } from "three/addons/loaders/FBXLoader.js";

const fbxLoader = new FBXLoader();
// ...
// load
fbxLoader.load("yourModel.fbx", (object) => {
const mesh = object.children[0];
mesh.traverse(function (child) {
child.castShadow = true;
child.receiveShadow = true;
});
mesh.position.set(0, 0, 0);
mesh.rotation.y = Math.PI;
mesh.rotation.x = Math.PI / 2;
mesh.scale.set(0.01, 0.01, 0.01);
scene.add(mesh);
});
// loadAsync
// const loadedData = await fbxLoader.loadAsync("yourModel.fbx");

好了,到这里基本就搭好一个简单的小车场景了。具体代码可以参考 https://github.com/GitHubJackson/three-gta/blob/main/src/renderer/index.ts

交互

查看场景

引入 OrbitControls 插件,可以方便我们通过鼠标旋转相机查看周围的场景和物体

import { OrbitControls } from "three/addons/controls/OrbitControls.js";
//...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();

键盘控制

通过上下左右键控制行进方向,并通过长按键盘的时间来模拟一个简单的加减速

// 记录开始按下的时间
let startTime = 0;
// 监听组合键
const activeKeys = new Set();
document.addEventListener("keydown", (e) => {
activeKeys.add(e.key);
if (startTime === 0) {
startTime = Date.now();
}
let t = (Date.now() - startTime) / 1000;
if (t > 10) {
t = 10;
}
if (activeKeys.has("ArrowUp")) {
carObj.position.z -= t * 0.3;
}
if (activeKeys.has("ArrowDown")) {
carObj.position.z += t * 0.3;
}
if (activeKeys.has("ArrowLeft")) {
carObj.position.x -= t * 0.3;
}
if (activeKeys.has("ArrowRight")) {
carObj.position.x += t * 0.3;
}
});
document.addEventListener("keyup", (e) => {
activeKeys.delete(e.key);
startTime = 0;
});

转弯

键盘事件默认只能监听一些特定的组合键,比如 Ctrl、Shift + 其他键,而这里需要同时监听两个方向键,用 Set 记录。如果同时按了冲突键(就是上对下,左对右),就不处理。然后这里左转右转逻辑是修改小车的 rotate 值,然后上下行进需要参照车头方向。自己实现可能比较麻烦,可以借助 cannon.js来实现这一部分的逻辑,自行发挥哈,后续有时间再完善这一块

性能监控

stats.js 为开发者提供了易用的性能监测功能,它目前支持四种模式:

  • 帧率
  • 每帧的渲染时间
  • 内存占用量
  • 用户自定义

使用组件

npm install stats.js
import Stats from "stats.js";
// ...
const stats = new Stats();
// 0: fps, 1: ms, 2: mb, 3+: custom
stats.showPanel(0);
document.body.appendChild(stats.dom);
function animate() {
stats.begin();
controls.update();
renderer.render(scene, camera);
// ...其他处理
stats.end();
requestAnimationFrame(animate);
}
animate();

其他 tips

自适应屏幕

在屏幕大小变化的时候,需要自动更新场景,确保画面不会被截断

window.addEventListener("resize", onResize, false);
function onResize() {
const container = document.getElementById(initPayload.container);
const style = container!.getBoundingClientRect();
if (style.width) {
const width = style.width;
const height = style.height;
// canvas纵横比变化,需要同步更新相机的aspect属性
camera.aspect = width / height;
// 渲染器执行render方法的时候会读取相机对象的投影矩阵属性 projectionMatrix,但不会每一帧都重新计算投影矩阵
// 如果相机的一些属性发生了变化,需要执行 updateProjectionMatrix 方法更新相机的投影矩阵
camera.updateProjectionMatrix();
// 更新画布大小
renderer.setSize(width, height);
}
}

设置画布全屏

比如这里设定双击屏幕会打开全屏模式,再次双击退出全屏模式

document.addEventListener("dblclick", () => {
// 判断当前是否处于全屏模式
if (document.fullscreenElement) {
// 退出全屏模式
document.exitFullscreen();
return;
}
// 全屏展示画布
renderer.domElement.requestFullscreen();
});

最后

当然,还有一些 TODO,比如引入动画和声音,还有实现加减速和物理碰撞效果,模拟更真实的行车场景,最好还有个司机可以上下车,== 想想还蛮有趣的。原文(有所修改)

参考

· 9 min read

前端操作二进制数据的场景还是比较多的,像 webGL 中浏览器和 GPU 的通信、用户上传下载文件、canvas 导出图片、FileReader 读取本地文件、图片/音视频处理等等

注意:下文相关名词最好参考下 MDN,这里只做简短介绍

相关协议

除了 HttpURL,还有一些二进制数据相关的伪协议

  • object URLBlob URL)。表示存储在浏览器内存中的 File 或 Blob 对象
// 创建 objectURL
URL.createObjectURL(Blob | File);
// 释放 objectURL
URL.revokeObjectURL(Blob | File);
  • Data URL。携带 base64 数据

上述两种协议都可以作为资源 url 使用,比如在 img 标签中使用等等

相关对象

先看下几种二进制对象的关系:

二进制对象关系

原始二进制数据

我们知道,字节(byte)是计算机技术中关于二进制数据的一种基本单位,1 字节有 8 个二进制位,即 8 比特(bit)。前端可以通过 fetch 或者 ajax 请求从服务端获取二进制数据,具体表现形式其实就是字节流

fetch(httpURL | dataURL).then((res) => res.blob());
// res.arrayBuffer()
// res.formData() 表单数据
// res.json() JSON
// res.text() 文本

ArrayBuffer

ArrayBuffer对象用于表示通用的原始二进制数据缓冲区,其实就是字节数组。通过这个对象,JavaScript 可以读写二进制数据,但是不能直接读写,可以借助 DataViewTypedArray 来读写 ArrayBuffer

// 创建一个 8 字节的 ArrayBuffer 数据
const arrayBuffer = new ArrayBuffer(8);

转换原始的二进制数据为 ArrayBuffer 对象,比如 fetch 内置了对应的方法进行转换:

fetch(url).then((res) => res.arrayBuffer());

怎么理解 JavaScript 中的 ArrayBuffer?

Blob

Blob 对象(binary large object)表示一个不可变、原始数据的类文件对象,它可以表示包含任意类型数据的不可变原始数据,例如图像、音频、视频、压缩文件等。Blob 提供了更便捷的文件操作方法,通过 Blob 对象可以实现文件上传、预览、读取、下载等操作,然后也可以借助它提供的 slice 方法实现数据切分上传

new Blob(array, option);
// array 可以是服务端返回的二进制文件流、blob、ArrayBuffer、DOMString等
// option.type 指定 MIME 类型

注意:浏览器通常使用 MIME 类型(而不是文件扩展名)来确定如何处理 URL,因此 Web 服务器在响应头中添加正确的 MIME 类型非常重要(详见 MDN-MIME

ArrayBuffer 转 Blob:

const buffer = new ArrayBuffer(32);
const blob = new Blob([buffer]);

Blob 转 ArrayBuffer,借助 FileReader:

const blob2 = new Blob(["blob string"], {
type: "text/plain",
});
const reader = new FileReader();
reader.readAsArrayBuffer(blob2);
reader.onload = function (e) {
console.log(reader.result); // ArrayBuffer {}
const buf = new DataView(reader.result);
console.log(buf); // DataView {}
reader.readAsText(new Blob([buf]), "utf-8");
reader.onload = function () {
console.log(reader.result); // blob string
};
};

BlobArrayBuffer 的关系:

  • 需要对二进制数据进行写入/编辑操作时使用 ArrayBuffer,否则一般使用 Blob
  • Blob 对象不可变,而 ArrayBuffer 可以通过 TypedArraysDataView 操作
  • ArrayBuffer 是内存上一段连续的二进制数据,Blob 是一个封装二进制数据的整体,可以封装 ArrayBuffer 数据,可以位于磁盘、高速缓存内存和其他不同位置

File

继承了 Blob,并将其扩展使其支持用户系统上的文件,File 对象还包含 lastModifiedname 等属性

const file = new File([Blob | ArrayBuffer], fileName);

哪些场景会生成 File?

  • input[file] 选择的文件
  • 拖拽生成的 dataTransfer 对象

base64

base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法,常用于在处理文本数据的场合,表示、传输和存储一些二进制数据

可以通过 atobbtoa 这两个 api 处理解码和编码 base64 字符串

base64 转成 File:

function dataURLtoFile(dataurl, filename) {
const arr = dataurl.split(",");
// 将 base64 编码转为字符串
const byteStr = atob(arr[1]);
const mimeStr = dataURI.split(",")[0].split(":")[1].split(";")[0];
let n = byteStr.length;
// 创建初始化为0的长度为n的无符号整型数组
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = byteStr.charCodeAt(n);
}
return new File([u8arr], filename, {
type: mimeStr,
});
}

或者可以先转成 blob,再转成 File:

function dataURItoBlob(dataURI) {
const arr = dataurl.split(",");
// 将 base64 编码转为字符串
const byteStr = atob(arr[1]);
const mimeStr = dataURI.split(",")[0].split(":")[1].split(";")[0];
const ab = new ArrayBuffer(byteStr.length);
const ia = new Uint8Array(ab);
for (var i = 0; i < byteStr.length; i++) {
ia[i] = byteStr.charCodeAt(i);
}
return new Blob([ab], { type: mimeStr });
}

const file = new File(dataURItoBlob(dataURL));

FileReader

FileReader  是浏览器提供的一个 API,用于读取文件内容。通过 FileReader,我们可以通过异步方式读取文件,并将文件内容转换为可用的数据形式,比如文本数据或二进制数据

const reader = new FileReader();
reader.readAsDataURL(Blob | File);
// .readAsText()
// .readAsArrayBuffer()

应用场景

下载文件

如果请求函数是基于 axios:

  1. axios 对象需要配置 responseType: 'blob'
  2. 前端接收到数据后,使用 Blob 对象来接收二进制流,并且创建 a 标签进行下载;或者将二进制数据转成 base64 再下载
function saveBlob(content, fileName) {
let downLink = document.createElement("a");
downLink.download = fileName;
let blob = new Blob([content]);
// 生成 BlobURL
downLink.href = URL.createObjectURL(blob);
document.body.appendChild(downLink);
downLink.click();
document.body.removeChild(downLink);
}

上传文件

借助 FormData,我们可以上传二进制数据

// 可以先将文件转成 Blob
const file = new Blob();
const formData = new FormData();
formData.add("user", "JacksonZhou");
formData.add("file", file);
axios.post("url", formData);

不论是 Blob/File 还是 ArrayBuffer 对象,浏览器最终还是会将其转成字节流再进行传输

分片上传

blob.slice() 可以用于实现大文件的分片上传,这里简单带过,后续有时间再深入研究下 ~

const chunkSize = 100000;
const url = "https://file.zhouweibin.top/upload";
async function selectFile(e) {
const file = e.target.files[0];
await uploadFile(file);
}
async function uploadFile(file) {
for (let start = 0; start < file.size; start += chunkSize) {
const chunk = file.slice(start, start + chunkSize + 1);
const formData = new FormData();
formData.append("data", chunk);
await fetch(url, { method: "post", body: formData }).then((res) => {
console.log(res);
});
}
}

文件预览

  • 图片预览:File -> FileReader -> Blob -> objectUrl -> img

文件处理

  • 拼接两个音频文件:data -> ArrayBuffer -> TypedArray -> 拼接成一个 TypedArray -> ArrayBuffer -> Blob -> Object URL
  • 图片压缩:canvas.toBlob(callback, mimeType, qualityArgument)
  • 图片灰度化,需要操作到图片像素数据
function draw() {
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, 230, 230);
// 基础像素数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const grayscale = function () {
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg;
data[i + 1] = avg;
data[i + 2] = avg;
}
ctx.putImageData(imageData, 0, 0);
};
}

其他文件操作方法

  • File System API。一组用于在 Web 应用程序中访问和操作本地文件系统的 JavaScript API。它允许 Web 应用程序创建、读取、写入、复制、移动和删除文件和目录,以及查询文件系统的状态和属性
  • 更快速且安全的本地文件读写 - OPFS

参考

聊聊 JS 的二进制家族

一文教你学会 Blob

· 16 min read

之前有个想法,把自己日常收集和整理的文档整合起来,包括前端知识、环境相关(mac/服务器)、手写代码等。之前这些文档都存在 github 上,比较散乱。如果有个文档网页承接这些内容,不仅利于整理,有助于我平时阅览,也可以让更多人看到我的内容

为什么选 docusaurus?其实 gatsby 也可以做,但是 docusaurus 主要是做内容网页这一块,有很多开箱即用的功能,包括文档目录、自动生成导航栏、暗黑模式、docsearch、seo 支持等,gatsby 还需要额外引入一些插件来完成这些事,会比较麻烦点。其他方面其实都差不多,比如基于 react,支持 typescript、mdx 等等

这篇文章也主要总结下我在搭建过程遇到的一些经验,当然,目前只是个基础版本,欢迎 comment ~ Lucas's docs

新建项目

注意:Node.js >= 16.14(node -v)。可以用 fnm 或 nvm 快捷切换 Node.js 版本

npx create-docusaurus@latest [项目名称] classic --typescript

基本项目结构如下:

├── blog # 内置博客存放文档的位置,可以直接在这里存个人的博客文档。用不上博客的话直接删除就行了
├── docs # 内置文档存放的位置
├── src
│ ├── css
│ └── pages # 页面,此目录中的任何扩展名为 JSX/TSX/MDX 文件都将被转换为网站的独立页面
├── static # 存放静态文件
├── docusaurus.config.js # 网页配置文件
├── sidebars.js # 侧边栏,可以在这里指定侧边栏中的文档顺序或生成规则
├── package.json
├── README.md
└── yarn.lock

本地调试和打包

npm install
# 本地调试
npm start
# 生成静态文件
npm run build

配置

项目根目录会有这个文件 docusaurus.config.js,用于配置一些内置功能。可以参考我的这份配置:

const config = {
// 网站标题
title: "Lucas's Docs",
tagline: "Dinosaurs are cool",
// 网站图标
favicon: "img/favicon.ico",
// 网站网址
url: "https://docs.zhouweibin.top",
baseUrl: "/",
organizationName: "facebook", // Usually your GitHub org/user name.
projectName: "docusaurus", // Usually your repo name.
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
// editUrl:
// "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
},
// 不需要博客功能可以注释掉
// blog: {
// showReadingTime: true,
// // Please change this to your repo.
// // Remove this to remove the "edit this page" links.
// // editUrl:
// // "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
// },
// 自定义主题和样式
theme: {
customCss: require.resolve("./src/css/custom.css"),
},
// google搜索需要
gtag: {
trackingID: "G-G4NRSZR6K3",
anonymizeIP: true,
},
}),
],
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
// 最左侧图标
image: "img/docusaurus-social-card.jpg",
navbar: {
// hideOnScroll: true, 滚动时是否隐藏顶部栏
title: "Lucas's docs",
logo: {
alt: "Lucas's Docs Logo",
src: "img/logo.svg",
},
items: [
// 顶部栏
{
type: "docSidebar",
sidebarId: "awesome-dev",
position: "left",
label: "awesome-FED",
},
// 顶部栏图标配置,后面会讲到怎么设置图标
{
href: "https://github.com/GitHubJackson/my-docs",
position: "right",
className: "header-github-link",
"aria-label": "GitHub",
},
{
href: "https://mail.google.com/?view=cm&fs=1&tf=1&to=jacksonzhou52017@gmail.com",
position: "right",
className: "header-email-link",
"aria-label": "Email",
},
],
},
// 底部栏
footer: {
style: "dark",
links: [
{
title: "Link",
items: [
{
label: "Lucas's Blog",
to: "https://blog.zhouweibin.top",
},
],
},
{
title: "Community",
},
],
copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
},
// 主题配置
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
},
// docsearch 后面会讲到,需要替换成自己的
algolia: {
appId: "xxx", // Application ID
apiKey: "xxx", // Search-Only API Key
indexName: "lucas'docs",
},
}),
};

module.exports = config;

自定义样式和主题

src/css/custom.css 这里定义样式和主题色,可以点击 这里 快速配置和阅览效果

编写文档

直接在 docs 目录下新增 markdown 文件,默认会自动显示到侧边栏。文档顶部配置参考如下:

---
sidebar_position: 1 # 设置文档顺序,值越小越排前
title: Github Actions # 文档标题。不设置的话默认以文件名或用#指定标题
description: desc # 文档描述
tags: # 文档相关标签,会显示在文档末尾
- tag1
- tag2
---

正文...

更详细的参数参考 https://www.docusaurus.cn/docs/create-doc

侧边栏设置

侧边栏

可以调整 sidebars.js,更改侧边栏的选项和顺序

const sidebars = {
// key 对应顶部栏,顶部栏会在下面讲到
"awesome-dev": [
{
type: "autogenerated", // 根据文件夹内容自动生成,推荐这个配置
dirName: "awesome-dev", // 该目录相对于docs目录,也就是在docs下新增一个文件夹
},
],
};
module.exports = sidebars;

更多参考 https://www.docusaurus.cn/docs/sidebar

增加顶部栏

顶部菜单

  1. docusaurus.config.js > themeConfig 增加选项,以 new-tab 为例:
// ...
themeConfig: {
navbar: {
title: "Lucas's docs",
logo: {
alt: "Lucas's Docs Logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "awesome-dev",
position: "left",
label: "awesome-FED",
},
{
type: "docSidebar",
label: "新栏目",
sidebarId: "new-tab",
position: "left",
},
],
},
}
// ...
  1. 记得别忘了在 sidebars.js 配置对应的侧边栏
const sidebars = {
// key 对应顶部栏,顶部栏会在下面讲到
"awesome-dev": [
{
type: "autogenerated", // 根据文件夹内容自动生成,推荐这个配置
dirName: "awesome-dev",
},
],
"new-tab": [
{
type: "autogenerated",
dirName: "new-tab",
},
],
};

之后页面就会新增一个 new-tab 的顶部栏。那有时候只想加个图标,比如夜间模式切换图标和 github 图标,需要怎么实现呢?也不难,参考以下步骤:

  1. docusaurus.config.js > themeConfig 增加选项,以 email 为例:
// ...
themeConfig: {
navbar: {
title: "Lucas's docs",
logo: {
alt: "Lucas's Docs Logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "awesome-dev",
position: "left",
label: "awesome-FED",
},
{
// email 自行搜索各邮件的接口格式,这里是 gmail,点击后会重定向到发邮件地址,收件人会自动填入我指定的地址
href: "https://mail.google.com/?view=cm&fs=1&tf=1&to=jacksonzhou52017@gmail.com",
position: "right",
className: "header-email-link",
"aria-label": "Email",
},
],
},
}
  1. 定义图标,这里通过 css 加载图标
// src/css/custom.css
// ...
/* header-email-link */
.header-email-link:hover {
opacity: 0.6;
}

.header-email-link::before {
content: "";
width: 24px;
height: 24px;
display: flex;
background: url("data:image/svg+xml,%3Csvg class='icon' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M854.016 342.016V256L512 470.016 169.984 256v86.016L512 554.026zm0-172.032q34.005 0 59.008 25.984t25.003 59.99v512q0 34.005-25.003 59.989t-59.008 25.984h-683.99q-34.005 0-59.007-25.984t-25.003-59.99v-512q0-34.005 25.003-59.989t59.008-25.984h683.989z' fill='%23444'/%3E%3C/svg%3E")
no-repeat center;
background-size: 100% 100%;
}

[data-theme="dark"] .header-email-link::before {
background: url("data:image/svg+xml,%3Csvg class='icon' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M854.016 342.016V256L512 470.016 169.984 256v86.016L512 554.026zm0-172.032q34.005 0 59.008 25.984t25.003 59.99v512q0 34.005-25.003 59.989t-59.008 25.984h-683.99q-34.005 0-59.007-25.984t-25.003-59.99v-512q0-34.005 25.003-59.989t59.008-25.984h683.989z' fill='%23cdcdcd'/%3E%3C/svg%3E")
no-repeat center;
background-size: 100% 100%;
}

图标是 svg 格式,但是需要做下转义,推荐个工具 https://www.zhangxinxu.com/sp/svgo/

文章多级分类

通过多层文件夹可以实现文章多级分类

多层分类.png

比如这里的 explorer 对应网页顶部的一个菜单,图片这个文件夹对应左侧的一级分类,下面的内容属于二级分类,参考下图。_category_.json 可以实现一个下图所示的父级分类卡片概览:

分类概览.png

_category_.json 内容参考:

{
"label": "Getting Started",
"position": 1,
"link": {
"type": "generated-index",
"description": "该分类的描述信息,在卡片概览上方显示"
}
}

评论功能

评论区.png

这个可以借助 giscus,需要先安装 giscus app

  1. 新增一个空的仓库用于承接评论信息(公开、启用 Discussions)
  2. https://giscus.app/zh-CN ,按照官网的步骤先做好配置,记住最后的代码片段(script 标签),后面会用到
  3. swizzling 对应的组件,默认文档组件是隐藏的,需要一次性导出,才能增加评论组件
yarn run swizzle @docusaurus/theme-classic DocItem/Layout -- --eject --typescript

这条命令会生成文件 src/theme/DocItem/Layout/index.tsx,我们需要对其进行修改,改动内容参考以下代码:

// ...
// 可以指定某些文档不允许评论,文章开头增加 hide_comment 配置即可
const { hide_comment: hideComment } = frontMatter;
const commentElement = useRef(null);
useEffect(() => {
// Update the document title using the browser API
let s = document.createElement("script");
s.src = "https://giscus.app/client.js";
s.setAttribute("data-repo", "xxx");
s.setAttribute("data-repo-id", "xxx");
s.setAttribute("data-category", "Announcements");
s.setAttribute("data-category-id", "xxx");
s.setAttribute("data-mapping", "pathname");
s.setAttribute("data-reactions-enabled", "1");
s.setAttribute("data-emit-metadata", "0");
s.setAttribute("data-input-position", "bottom");
s.setAttribute("data-theme", "light");
s.setAttribute("data-lang", "zh-CN");
s.setAttribute("crossorigin", "anonymous");
s.async = true;
commentElement.current.appendChild(s);
}, []);
// ...
return (
// ...
<DocItemPaginator />
{!hideComment && <div ref={commentElement}></div>}
// ...
)

部署项目

简单点,其实就是在服务器划分一个文件夹存储网页的静态资源,用 nginx 配置端口和网页资源的映射关系,然后本地 build 生成静态资源后,通过 scp 把静态资源上传到服务器指定的一个目录下(或者用 ftp 可视化工具上传也行)。如果要搞提交代码后自动部署网页,内容可能稍微有点多,需要的话可以参考我之前写的一些文章:

  1. 购置服务器,配置 nginx,参考 文章
  2. 配置前端环境(Node.js>=16.14),参考 文章
  3. 自动化部署。借助 github actions 完成代码提交后自动部署,搭建指南参考 Github Actions

添加文档搜索

docsearch

既然是内容网页,高效的内容检索就很必要了。docsearch 这个功能放到这里说,是因为跟 github actions 有关

  1. 先到 官网 注册,大概两天后会收到邮件通知注册成功,再接着往下操作
  2. 登录
  3. 创建一个新的 Application,选择左边的免费版本进行下一步
  4. 定义 index_name,选择响应快的服务进行创建。到这一步就创建成功了
  5. 打开 API keys,获取三个需要的字段 Application IDSearch-Only API keyAdmin API key
  6. 修改 docusaurus.config.js
// ...
themeConfig: {
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
},
// 新增
algolia: {
appId: "xxx", // Application ID
apiKey: "xxx", // Search-Only API Key
indexName: "xxx", // index_name
},
}
// ...

之后本地调试应该能看到 docsearch 的组件了

  1. 本地新增 docsearch.json 配置文件,注意几个字段要替换成自己的
{
// 需要替换
"index_name": "xxx",
// 需要替换。网站网址
"start_urls": ["xxx"],
// 需要替换。sitemap的网址,docusaurus 默认在根目录下生成 sitemap.xml
"sitemap_urls": ["xxx"],
"selectors": {
"lvl0": {
"selector": "(//ul[contains(@class,'menu__list')]//a[contains(@class, 'menu__link menu__link--sublist menu__link--active')]/text() | //nav[contains(@class, 'navbar')]//a[contains(@class, 'navbar__link--active')]/text())[last()]",
"type": "xpath",
"global": true,
"default_value": "Documentation"
},
"lvl1": "header h1, article h1",
"lvl2": "article h2",
"lvl3": "article h3",
"lvl4": "article h4",
"lvl5": "article h5, article td:first-child",
"lvl6": "article h6",
"text": "article p, article li, article td:last-child"
},
"custom_settings": {
"attributesForFaceting": [
"type",
"lang",
"language",
"version",
"docusaurus_tag"
],
"attributesToRetrieve": [
"hierarchy",
"content",
"anchor",
"url",
"url_without_anchor",
"type"
],
"attributesToHighlight": ["hierarchy", "content"],
"attributesToSnippet": ["content:10"],
"camelCaseAttributes": ["hierarchy", "content"],
"searchableAttributes": [
"unordered(hierarchy.lvl0)",
"unordered(hierarchy.lvl1)",
"unordered(hierarchy.lvl2)",
"unordered(hierarchy.lvl3)",
"unordered(hierarchy.lvl4)",
"unordered(hierarchy.lvl5)",
"unordered(hierarchy.lvl6)",
"content"
],
"distinct": true,
"attributeForDistinct": "url",
"customRanking": [
"desc(weight.pageRank)",
"desc(weight.level)",
"asc(weight.position)"
],
"ranking": [
"words",
"filters",
"typo",
"attribute",
"proximity",
"exact",
"custom"
],
"highlightPreTag": "<span class='algolia-docsearch-suggestion--highlight'>",
"highlightPostTag": "</span>",
"minWordSizefor1Typo": 3,
"minWordSizefor2Typos": 7,
"allowTyposOnNumericTokens": false,
"minProximity": 1,
"ignorePlurals": true,
"advancedSyntax": true,
"attributeCriteriaComputedByMinProximity": true,
"removeWordsIfNoResults": "allOptional",
"separatorsToIndex": "_",
"synonyms": [
["js", "javascript"],
["ts", "typescript"]
]
}
}
  1. 增加 .github/workflows/docseach.yml,在提交代码后提醒 algolia 服务更新本站资源。注意:需要先新增两个 key:ALGOLIA_APP_IDALGOLIA_API_KEY,分别对应 Application IDAdmin API key
name: docsearch

on:
push:
branches:
- main

jobs:
algolia:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Get the content of docsearch.json as config
id: algolia_config
run: echo "::set-output name=config::$(cat docsearch.json | jq -r tostring)"

- name: Run algolia/docsearch-scraper image
env:
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
CONFIG: ${{ steps.algolia_config.outputs.config }}
run: |
docker run \
--env APPLICATION_ID=${ALGOLIA_APP_ID} \
--env API_KEY=${ALGOLIA_API_KEY} \
--env "CONFIG=${CONFIG}" \
algolia/docsearch-scraper

SEO 相关

生成站点地图。docusaurus 在 build 时默认会创建 sitemap.xml,存放在打包文件夹根目录下。之后可以上传到 谷歌/bing/百度的站长管理平台,只需上传一次,后面他们都会定时去更新(不过最近几个月百度都没法上传 sitemap 了,一整个无语住了,有了解的同学可以 comment 下..)

在每篇文章开头补充详细的 keywords 和 description,也有利于搜索引擎检索

google analytics

可以分析你网站的运行情况,比如访问量、用户属性等,然后可以检查自己的网页有没有被检索,没有的话可以主动请求编入索引(当然,需要你的文章内容过关,并且适合用于移动网页)

添加教程参考 添加帐号。在 docusaurus 中使用 google analytics 需要用到插件 plugin-google-gtag

npm install --save @docusaurus/plugin-google-gtag

docusaurus.config.js 追加 gtag 配置如下:

// ...
presets: [
[
"classic",
({
// ...
gtag: {
// 从 google analytics 账号中获取
trackingID: "xxx",
anonymizeIP: true,
},
}),
],
],

robots.txt

放在 static 目录下即可,build 后会在网页根目录下。如果没有限制搜索引擎访问某些网页的需求,那直接用下面的内容就行了## 文章多级分类

通过多层文件夹可以实现文章多级分类

多层分类.png

比如这里的 explorer 对应网页顶部的一个菜单,图片这个文件夹对应左侧的一级分类,下面的内容属于二级分类,参考下图。_category_.json 可以实现一个下图所示的父级分类卡片概览:

分类概览.png

_category_.json 内容参考:

{
"label": "Getting Started",
"position": 1,
"link": {
"type": "generated-index",
"description": "该分类的描述信息,在卡片概览上方显示"
}
}
User-agent: *
Disallow:

自定义组件

待补充...

更多插件

待补充...

写在最后

有疑问或建议欢迎 comment.

· 15 min read

写在前面

什么是 WebGL?WebGL 基于 OpenGL ES 2.0 提供 3d 图形接口,是在浏览器环境下进行 3d/2d 图像渲染的技术,而 threejs 则是基于 webGL 的 3d 图形库,基于 threejs,我们可以用 js 做以下这些事:

  • 创建 3d 几何图形
  • 给对象应用材质和纹理
  • 在 3d 场景中移动对象、实现动画
  • 加载 3d 模型

基本概念

坐标系。Three.js 默认使用右手坐标系,因为这是 OpenGL 默认的坐标系

矩阵。用于坐标变换

相机(camera)。有多种相机,比如基于透视投影的镜头(PerspectiveCamera ),会模拟人的视觉效果(近大远小),从某个投射中心将物体投射到单一投影面上,是最常使用的投影模式

场景(scene)。存储并跟踪所有待渲染对象的容器

渲染器(renderer)。在指定的 camera 下绘制 scene。在 web 应用里,场景会被渲染器渲染到一个 canvas 上

视野(FOV)。表示可视范围,常用角度来表示(非弧度)。

视锥体(Viewing frustum)

视锥体也就是中间那个椎体,也就是被渲染的物体所在的区域。视锥剔除(View frustum culling) 是指从渲染过程中移除完全位于视截锥之外的对象的处理步骤

渲染管线

参考 https://www.cnblogs.com/wanbo/p/6754066.html

threejs

一个典型的 Three.js 应用至少包括渲染器、场景、相机、以及在场景中的物体

场景

场景是光源、相机和所有物体的父容器。场景的中心是点(0,0,0),也称为坐标系的原点

const scene = new THREE.Scene();
// 访问所有物体
// scene.children
// 通过指定name访问物体
// scene.getChildByMName(name)
// 遍历物体
// scene.traverse((obj) => {})

相机

有多种投影类型,分为 透视投影正交投影

const fov = 35; // AKA Field of View
const aspect = container.clientWidth / container.clientHeight;
const near = 0.1; // the near clipping plane
const far = 100; // the far clipping plane
// 透视投影
const camera = new PerspectiveCamera(fov, aspect, near, far);
// 定位相机
camera.position.set(0, 0, 5);
scene.add(camera);

渲染器

const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("#mainCanvas"),
// antialias: true, // 开启抗锯齿
});
renderer.render(scene, camera);
// 设置颜色及其透明度
renderer.setClearColor(0xffcc00);
// 设置设备像素比,防止 HiDPI 显示器模糊(视网膜显示器)
renderer.setPixelRatio(window.devicePixelRatio);
// 调整输出canvas的宽高并考虑设备像素比
renderer.setSize(window.innerWidth, window.innerHeight);

场景中的其他物体

基类图形只有点、线、三角形,其余图形都是在此基础上通过顶点着色算法组合而成。添加到场景中的对象如下图:

比如我们创建一个长方体

const cube = new THREE.Mesh(
new THREE.CubeGeometry(1, 2, 3),
new THREE.MeshBasicMaterial({
color: 0xff0000,
})
);
scene.add(cube);
// 拷贝长方体
// const clonedCube = cube.clone();
// 注意:自定义属性不会克隆

网格对象 Mesh 是 3D 计算机图形学中最常见的可见对象,包含几何体和材质。其他对象有 Point/Line/Group

这里涉及到物体的基本转换,分为平移translation、旋转rotation和缩放scale

// 平移
cube.translateX(100); // 沿着x轴正方向平移100个单位
const axis = new THREE.Vector3(0, 1, 0);
cube.translateOnAxis(axis, 100); // 沿着axis轴表示方向平移100
// 旋转
mesh.rotateX(Math.PI / 2); // 绕x轴旋转π/2
// 缩放
cube.scale.x = 2; // x轴方向放大2倍
cube.scale.set(0.5, 0.5, 0.5); // 缩小为原来的0.5倍

另外还有组 Group 的概念

const group = new Group();
group.add(cube);
group.add(light);
group.remove(light);
scene.add(group);

几何体(Geometry)

  • BoxGeometry 盒状几何体
  • CircleGeometry 圆形几何体
  • ConeGeometry 圆锥形几何体
  • CylinderGeometry 圆筒几何体
  • PlaneGeometry 平面几何体
  • SphereGeometry 球形几何体

更多几何体内容参考 https://github.com/zyj1022/awesome-threejs/blob/master/docs/hello-geometry.md

Geometry 和 BufferGeometry 的区别

如果你只是简单地创建和改变几何体,可以使用 Geometry。如果追求更高的性能和内存利用率,或者处理大规模复杂的场景,可以选择使用 BufferGeometry,它将几何体的数据存储在一组连续的内存缓冲区中,数据存储更加紧凑,可以直接传输给 GPU 进行绘制。但数据是静态的,不容易修改。适用于大规模复杂的场景或静态的几何体

材质(Material)

  • LineBasicMaterial 基础线条材质 -- 可以用于 THREE.Line 几何体,从而创建着色的直线
  • LineDashedMaterial 虚线材质 -- 类似与基础材质,但可以创建虚线效果
  • MeshBasicMaterial 基础网孔材质 - 为几何体赋予一种简单的颜色,或者显示几何体的线框
  • MeshDepthMaterial 深度网孔材质 - 根据网格到相机的距离,该材质决定如何给网格染色
  • MeshLambertMaterial 兰伯特网孔材质 - 考虑光照的影响,可以创建颜色暗淡,不光亮的物体
  • MeshNormalMaterial 法向量网孔材质 - 根据物体表面的法向量计算颜色
  • MeshPhongMaterial Phong 网孔材质 - 考虑光照的影响,可以创建光亮的物体

更多材质内容参考 https://github.com/zyj1022/awesome-threejs/blob/master/docs/hello-material.md

纹理(Texture)

通常用于给物体表面添加贴图,可以理解成物体的皮肤。可以是图像文件,比如 JPEG、PNG 或 GIF,也可以是视频或其他来源生成的图像。可以通过创建 THREE.Texture 对象来加载和使用纹理,使用示例如下:

// 创建纹理对象
const texture = new THREE.TextureLoader().load("texture.jpg");
// 也可以指定一个image节点
// const image = document.getElementById('myImage');
// const texture = new THREE.Texture(image);
// 应用到材质上
const material = new THREE.MeshBasicMaterial({ map: texture });
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

光源

光源照射物体,会产生光影效果。物体的材质在渲染的时候,和光源有很重要的关系。比如物体的纹理、色彩、透明度、光滑度、折射率、反光率等

基础光源:

  • AmbientLight 环境光 - 它的颜色会添加到整个场景和所有对象的当前颜色上
  • DirectionalLight 平行光 -- 例如:太阳光
  • PointLight 点光源 -- 空间中的一点,朝所有的方向发射光线
  • SpotLight 聚光源 - 聚光灯是由点光源发出,这种类型的光也可以产生投影,有聚光的效果

更多光源内容参考 https://github.com/zyj1022/awesome-threejs/blob/master/docs/hello-light.md

阴影

主要有三种阴影

  • DirectionalLightShadow 平行光阴影,对应 DirectionalLight 光源
  • PointLightShadow 点光源阴影,对应 PointLight 光源
  • SpotLightShadow 聚光灯阴影,对应 SpotLight 光源

阴影的性能负担较大,一般不建议使用,或者用纹理贴图模拟。使用方式可以参考 Three.js 基础之阴影

载入模型

有以下几种分类:

  • gltf。现阶段主流的模型类型,可以包含模型、动画、几何图形、材质、灯光、相机,甚至整个场景。glb是它的二进制形式,体积小很多,所以实际项目中尽量使用这种,不过gltf可读性更好
  • obj。一种简单的文本格式,可以包含顶点位置、纹理坐标、法线等信息。缺点是文件体积相对较大,不支持二进制数据存储,无法存储动画数据,不支持材质和纹理的自定义属性等
  • mtl。是一种与 obj 配套使用的材质文件格式。mtl 文件包含了 obj 模型所需的材质属性信息,如颜色、纹理、法线贴图等。在加载 obj 模型时,可以通过 OBJLoader 来解析 obj 文件,并通过 MTLLoader 来加载对应的 mtl 文件。MTLLoader 会解析 mtl 文件中的信息,并将材质属性应用于对应的物体
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
import { MTLLoader } from "three/addons/loaders/MTLLoader.js";

const gltfLoader = new GLTFLoader();
const objLoader = new OBJLoader();
const mtlLoader = new MTLLoader();
// 异步加载
const loadedData = await gltfLoader.loadAsync("yourModel.glb");
// load方法是回调的方式
// gltfLoader.load('yourModel.glb', (data) => { // handle data })

创建文字

webGL 中绘制文字会比较耗性能,建议用其他方法模拟,可以参考 https://threejs.org/docs/index.html#manual/zh/introduction/Creating-text

控制器

threejs 的控制器是一种用于交互式地控制场景中相机位置和视角的工具,可以帮助用户通过鼠标、触摸或其他输入设备来旋转、缩放和平移相机,以便浏览和操作场景。常见的控制器有

  • OrbitControls。最常见和流行的控制器,它允许用户通过鼠标左键拖动来旋转相机,鼠标右键拖动来平移相机,鼠标滚轮来缩放相机
  • TrackballControls。除了支持旋转、平移和缩放相机外,还可以通过鼠标拖动控制球(trackball)来自定义旋转方向和速度
  • FlyControls。通过键盘或其他输入设备来控制相机的位置和朝向,以便在场景中自由移动和浏览

除了上述控制器外,threejs 还提供了其他一些控制器,如 PointerLockControlsFirstPersonControlsDeviceOrientationControls 等,用于支持更特殊的交互需求或特殊设备的输入

动画

  • requestAnimationFrame
function animate() {
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.02;
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
  • tweenjs。包含了各种经典动画算法

demo

下面是一个 react Hook 中使用 threejs 的组件 demo

import React, { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

function MyThreeDemo() {
const canvasRef = useRef(null);
const statsRef = useRef(null);
function initStat() {
statsRef.current = new Stats();
container.appendChild(statsRef.current.dom);
}

useEffect(() => {
// 创建场景、相机和渲染器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
// 添加光源
let ambi = new THREE.AmbientLight(0x686868);
scene.add(ambi);
// 初始化帧率组件
initStat();

// 添加一个立方体到场景中
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 调整相机位置
camera.position.z = 5;
// 创建 OrbitControls 控制器并附加到相机上
const controls = new OrbitControls(camera, renderer.domElement);
// 渲染场景
const animate = () => {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
// 更新控制器
controls.update();
// 更新帧率
statsRef.current.update();
};
canvasRef.current.appendChild(renderer.domElement);
animate();

return () => {
// 销毁时清除渲染器占用的资源
renderer.dispose();
};
}, []);

return <div ref={canvasRef} />;
}

export default MyThreeDemo;

除了直接撸 threejs,也可以用react-three-fiber,能减少代码量,但有一定的上手成本

性能优化

做优化前肯定要有指标衡量性能,比如延迟和卡顿,延迟主要是针对实时应用,比如车端的 hmi(参考特斯拉的 hmi),数据或绘制处理不过来,其中因素还是比较多的,可能受硬件设备、网络影响,前端的话主要是尽可能减少数据解析和处理耗时吧。卡顿的话主要是看帧率,可以用 stats.js 这个三方库来协助监控帧率。下面给一些基础的优化建议

  • 绘制优化
    • 使用 BufferGeometry 创建物体
    • dispose 及时释放内存
    • 减少没必要执行的代码在周期渲染函数中的执行,必要时再执行
    • 尽量重用几何体和材质
    • 合理使用网格合并
  • 模型优化,比如模型压缩等
  • 数据优化。比如减小数据体积,减少传输耗时等
  • react 组件优化,比如减少组件不必要的重绘等
  • 借助 web worker 并行计算
  • 借助 wasm 加速计算

其他库

threejs 算是比较通用的 3d 库,提供的 api 都比较好用,可以轻松创建各种类型的 3d 场景和效果,社区也相对比较大,但在游戏处理和性能上并不占优

  • Babylon.js 专注于游戏开发,并提供了各种高级功能,如物理引擎、碰撞检测、粒子系统等。Babylon.js 的 API 简单易用,并具有高性能和跨平台支持

参考

· 8 min read

electron 是谁

Electron 是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架,底层借助浏览器壳子支持了跨平台,然后可以借助 Node.js 操作进程逻辑和调用系统能力,最后再渲染网页内容,整体组成如下:

Electron = Chromium + Node.js + Native API

快速开始

用脚手架 electron-quick-start 快速搭建项目

git clone https://github.com/electron/electron-quick-start
cd electron-quick-start
yarn
yarn start

项目结构比较简单

electron-quick-start
├─ LICENSE.md
├─ README.md
├─ index.html # html网页
├─ main.js # 主进程脚本
├─ package-lock.json
├─ package.json
├─ preload.js # 预执行脚本
├─ renderer.js # 页面js脚本
└─ styles.css
  • main.js:启动应用程序并创建一个浏览器窗口来呈现 HTML
  • preload.js:在渲染器进程加载之前运行的脚本,可以将 Electron 的不同类型的进程桥接在一起
// main.js
// ...
function createWindow() {
// 创建window对象
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// 加载preload脚本
preload: path.join(__dirname, "preload.js"),
},
});
// 加载网页
mainWindow.loadFile("index.html");
}

preload.js 应用示例

// preload.js
const { contextBridge } = require("electron");

// exposeInMainWorld 将 API 注入到 window 对象,之后渲染进程可通过 window[apiKey] 访问
contextBridge.exposeInMainWorld("versions", {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
});

现在渲染器能够全局访问 versions 了,让我们快快将里边的信息显示在窗口中

// renderer.js
const information = document.getElementById("info");
information.innerText = `本应用正在使用 Chrome (v${versions.chrome()}), Node.js (v${versions.node()}), 和 Electron (v${versions.electron()})`;

调整 index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
<link href="./styles.css" rel="stylesheet" />
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
<!-- We are using Node.js <span id="node-version"></span>,
Chromium <span id="chrome-version"></span>,
and Electron <span id="electron-version"></span>. -->
<p id="info"></p>
<!-- You can also require other files to run in this process -->
<script src="./renderer.js"></script>
</body>
</html>

当然脚手架本身的示例也是类似的效果,不过并不是用 contextBridge.exposeInMainWorld 的方式,而是在获取到进程信息后直接操作 dom 节点,殊途同归~

打包

借助 Electron-forge 可以打包 electron 应用,用法如下:

yarn add --dev @electron-forge/cli
npx electron-forge import
yarn make

Electron-forge 会创建 out 文件夹,在那里找到生成的软件包,直接运行试试!

当然也可以用 Electron Builder,它集成了许多插件,如自动更新、发布、代码签名等,感兴趣可以自行探究~

实际项目可能还要用到 typescript、react、代码工具等,所以建议用基于构建工具(比如 webpack)的脚手架,比如 electron-react-boilerplate,是个比较出名的 electron-react 脚手架。vue 也有对应的脚手架 electron-vue

调试

其实就跟浏览器调试一样了,devtools 全家桶

多进程架构

参考 https://chend0316.github.io/frontend/electron/#electron%E7%9A%84%E6%8A%80%E6%9C%AF%E6%9E%B6%E6%9E%84

每个 Electron 应用有且只有一个主进程(Main Process)、以及 n 个渲染进程(Renderer Process), 对应多个网页,除此之外还有 GPU 进程、扩展进程等

主要介绍下两个进程:主进程和渲染进程

主进程

  • 主进程使用 BrowserWindow 实例创建页面,主进程可以创建多个渲染进程
  • 响应应用(app)的生命周期事件
  • 负责与原生操作系统 API 进行通信,可以使用 Node.js 所有模块
  • 执行诸如注册全局快捷方式、创建菜单和对话框等操作

这里有俩个关键模块

  • app 控制应用程序的事件生命周期
  • BrowserWindow 创建和管理应用程序窗口

渲染进程

可以操作部分 Node.js api,比如 fs、cypto、path 等,参考下图

  • 当 BrowserWindow 实例销毁时,对应的渲染进程也会终止。渲染进程可以加载 Web 页面
  • 在窗口中包含 webContents 实例之前,实际上不会创建渲染器进程。一个窗口可以托管多个 webview,每个 webview 都有自己的 webContents 实例和渲染器进程
  • 渲染进程中可以使用所有 DOM API、Node.js APIElectron API 的子集
  • 渲染进程之间相互隔离,并且不允许它们直接访问操作系统级别的 API。当渲染进程需要访问系统级别 api,可以与主进程通信,由主进程实现其功能

preload 脚本

搭建渲染进程和主进程的桥梁,通过 contextBridge.exposeInMainWorld() 将 electron 或者 Node.js 的 api 或者自定义函数暴露到 window 上,使得网页代码中可以使用额外的 api,跟主进程进行通信

从 Electron 20 开始,预加载脚本默认沙盒化,不再拥有完整 Node.js 环境的访问权。可用 api 参考 https://www.electronjs.org/zh/docs/latest/tutorial/tutorial-preload

进程间通信

IPC 实现中使用结构化克隆算法序列化进程之间传递的对象,分为 ipcmain(主进程 IPC 协议)和 ipcRenderer(渲染进程 IPC 协议) ,这两个都是 EventEmitter 类的实例

  • 渲染进程向主进程(单向)on/send
  • 渲染进程向主进程(双向)handle/invoke
  • 主进程向渲染进程(单向)webContents
  • 渲染进程之间,主进程做消息代理或基于 MessagePort

代码示例参考 https://www.electronjs.org/zh/docs/latest/tutorial/ipc

其他桌面开发方式

  • Native(C++/C#/Objective-C) 不管从原生体验、包体积、性能方面来说都是最佳的选择,但是开发门槛和成本都比较高
  • Web 的代表ElectronNW.js ,相比后者,Electron 有更活跃的社区,像 Atom、vscode 这样的大型应用都是基于 Electron 开发的,性能相比于 Native 是肯定要差
  • QT 是基于 C++的跨平台开发框架,跨平台应用很广泛,比如 WPS 就是用 QT 开发的。性能很好,可以媲美 Native,但是开发门槛还是比较高的
  • Flutter 渲染性能优于 web,但稳定性和生态相对差一些

进阶方向

主要是以下几个大方向可以深入探究

  • 构建工具与 electron 配套的结合,进一步提高构建效率。这块可以学习下优秀的脚手架项目的实现
  • 深化与操作系统和 Node.js 的集成,以及可以试着写写 c++插件
  • 应用性能优化,比如减少包体积和白屏现象等

参考