1. 从零画一个点:three.js 粒子最小可运行示例踩坑记
three.js 里的粒子系统,说白了就是用一个Points对象把一堆顶点一次性丢给 GPU 去画。它和普通网格最大的区别在于:网格每个顶点要组成三角形面,而粒子每个顶点就是一个独立的点,渲染开销小、数量可以堆到几万甚至几十万,做星空、雪花、数据点云都靠它。适合谁?刚接触 WebGL、想先跑出一个能看见的东西、再慢慢理解相机和场景的前端开发者。
我见过太多人卡在第一步:照着老教程写THREE.Geometry和ParticleBasicMaterial,结果控制台直接报THREE.Geometry is not a constructor。原因很简单,three.js 从 r125 开始就把Geometry移除了,ParticleBasicMaterial也早就改名成PointsMaterial,ParticleSystem改成了Points。老代码不是错,是版本对不上。所以这篇不讲历史包袱,直接给你一份当前版本能跑的单文件 HTML,再顺手把「用 TaoToken 统一 Key 调模型帮我生成注释和调参建议」这条链路走通——毕竟调粒子参数(数量、尺寸、颜色、衰减)靠手改数字试,效率太低,让模型给你几组候选值会快很多。
核心检索词先摆出来:three.js 粒子系统怎么画一个点、Points 与 PointsMaterial 最小示例、粒子数量与尺寸参数怎么配。这三个问题下面都会落到具体代码和具体数字上,不是泛泛而谈。
先说清楚整体结构。一个 three.js 场景永远逃不开四件套:渲染器(renderer)、相机(camera)、场景(scene)、物体(object)。粒子 Demo 也一样,只是物体换成了Points。渲染器负责把画面画到 canvas 上,相机决定你从哪个角度看,场景是容器,Points是真正被画出来的东西。很多人第一次写会漏掉renderer.setSize或者相机没lookAt,结果页面一片黑,其实不是粒子没画出来,是相机没对准或者画布尺寸是 0。
还有一个高频坑:div容器如果没给高度,clientHeight就是 0,renderer.setSize(0, 0)之后什么都看不见。所以 CSS 里必须给容器一个明确高度,比如height: 600px。这个细节老教程里经常一笔带过,但它是新手黑屏的第一大原因。
下面这份代码你可以直接存成index.html双击打开,不需要 npm、不需要构建工具,用 CDN 引入 three.js 即可。我会把粒子数量、尺寸、颜色都做成顶部常量,方便你改一个数字就重新刷新看效果。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>three.js 粒子最小示例</title> <style> html, body { margin: 0; padding: 0; } #canvas-frame { width: 100%; height: 600px; background-color: #0b0e14; cursor: pointer; } </style> </head> <body> <div id="canvas-frame"></div> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } } </script> <script type="module"> import * as THREE from 'three'; // ===== 可调参数:改这里就能看效果 ===== const PARTICLE_COUNT = 2000; // 粒子数量 const PARTICLE_SIZE = 0.15; // 粒子尺寸 const PARTICLE_COLOR = 0x00ffff; // 粒子颜色 const SPREAD = 50; // 分布范围(立方体边长) const container = document.getElementById('canvas-frame'); const width = container.clientWidth; const height = container.clientHeight; // 1. 渲染器 const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); renderer.setClearColor(0x0b0e14, 1.0); container.appendChild(renderer.domElement); // 2. 相机 const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); camera.position.set(0, 0, 80); camera.lookAt(0, 0, 0); // 3. 场景 const scene = new THREE.Scene(); // 4. 粒子几何体:用 BufferGeometry 存顶点 const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(PARTICLE_COUNT * 3); for (let i = 0; i < PARTICLE_COUNT; i++) { positions[i * 3 + 0] = (Math.random() - 0.5) * SPREAD; positions[i * 3 + 1] = (Math.random() - 0.5) * SPREAD; positions[i * 3 + 2] = (Math.random() - 0.5) * SPREAD; } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); // 5. 粒子材质 const material = new THREE.PointsMaterial({ color: PARTICLE_COLOR, size: PARTICLE_SIZE, sizeAttenuation: true, // 近大远小 transparent: true, opacity: 0.9 }); // 6. 粒子系统 const points = new THREE.Points(geometry, material); scene.add(points); // 7. 渲染循环 function render() { points.rotation.y += 0.002; points.rotation.x += 0.001; renderer.render(scene, camera); requestAnimationFrame(render); } render(); // 8. 窗口自适应 window.addEventListener('resize', () => { const w = container.clientWidth; const h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }); </script> </body> </html>打开后你应该看到一片深色背景上散布着青色小点,整体缓慢旋转。如果只看到一个点,说明PARTICLE_COUNT被改成了 1,或者SPREAD太小所有点重叠在一起。如果全黑,先按 F12 看 Console 有没有报错,再检查容器高度是不是 0。
这里有个关键点值得展开:为什么用BufferGeometry而不是Geometry?因为Geometry是面向对象的顶点结构,每个顶点是一个Vector3对象,几万个点就是几万个对象,内存和 GC 压力都大。BufferGeometry直接把顶点坐标塞进一个Float32Array,一次性传给 GPU,性能差一个数量级。粒子系统动辄上万点,所以必须用BufferGeometry。setAttribute('position', ...)里的第二个参数3表示每个顶点有 x、y、z 三个分量,这个数字写错会导致顶点错位,画面会变成一团乱麻。
sizeAttenuation: true也值得说一句。开启后,离相机近的粒子看起来大,远的看起来小,符合真实透视;关掉的话所有粒子不管远近都是同样像素大小,适合做 UI 风格的均匀点阵。做星空一般开,做数据散点图一般关,看你想要什么观感。
到这一步,一个能跑的粒子 Demo 就完成了。但参数怎么调才好看?PARTICLE_COUNT给多少合适?PARTICLE_SIZE和SPREAD的比例关系是什么?这些靠盲试很费时间,接下来就轮到 TaoToken 出场了。
2. TaoToken 统一 Key 前置:一个 Key 打通模型调用通道
先说清楚 TaoToken 在这里扮演什么角色。它是一个统一的模型 API 通道,你注册后拿到一个 Key,就能用同一套请求格式去调不同的模型,不用为每个模型单独申请账号、记不同的 Base URL 和鉴权方式。对做粒子 Demo 这种小工具来说,价值在于:我想让模型帮我生成粒子参数的调参建议、给代码加注释、解释某段 shader 逻辑,直接一个 Key 发请求就行,不用在多个平台之间来回切。
官网入口在这里:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册流程不复杂,邮箱加密码即可。注册完进控制台,找到 API Keys 页面创建一个 Key,复制出来先存好,后面配置要用。
控制台地址:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API Keys 管理页:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
这里要强调一个概念:Base URL 和 Key 是两件事。Base URL 是请求发往哪个地址,Key 是证明你有权限。TaoToken 的 API 根地址是https://taotoken.net/api,注意这个地址不带任何查询参数,是纯粹的接口前缀。很多新手会把带 UTM 的官网地址当成 API 地址填进去,结果请求 404,这是最常见的配置错误之一。
模型 ID 是第三件事。不同模型有不同的 ID,比如对话类、代码类各有各的标识。你在控制台或文档里能看到当前可用的模型列表。请求时把模型 ID 放进请求体,服务端就知道该路由到哪个模型。
文档地址:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
如果你用的是 Claude Code 这类命令行编码工具,TaoToken 也提供了对应的接入方式,文档里有说明。核心还是那三件套:Base URL 填https://taotoken.net/api,Key 填你创建的那串,Model ID 填你要用的模型。
为什么强调「统一 Key」?因为在实际开发里,你可能今天想让模型解释粒子材质参数,明天想让它帮你写一段自定义 shader,后天想让它 review 你的渲染循环有没有性能问题。如果每个能力都要换一个平台、换一套鉴权,光是配置就耗掉大量时间。统一通道的意义就是把「调用模型」这件事的固定成本降到最低,让你专注在 three.js 本身。
还有一个实际考虑:粒子调参是个反复试错的过程。你改一个数字、刷新、看效果、不满意再改。如果每次都要手动想「这个参数该往哪个方向调」,效率很低。让模型基于当前参数给几组候选值,你直接粘贴测试,能省不少来回。这就是下面要做的。
需要提醒的是,TaoToken 是模型调用通道,不是 three.js 的替代品,也不是编辑器插件。它不会帮你渲染粒子,它帮你的是「生成代码、解释代码、给参数建议」这类文本工作。把定位搞清楚,用起来才不会拧巴。
3. 可复制配置:把 Key 接进你的粒子项目
这一节给你可以直接复制的配置片段。分两种场景:一种是你想在浏览器里直接发请求(比如做个调参小面板),一种是你想在 Node 脚本里批量生成参数建议。两种都基于同一套 Base URL 和 Key。
先看最通用的请求格式。TaoToken 的接口兼容 OpenAI 风格的 chat completions,所以请求体结构是固定的:
{ "model": "你的模型ID", "messages": [ { "role": "system", "content": "你是一个 three.js 图形开发助手,擅长粒子系统参数调优。" }, { "role": "user", "content": "当前粒子参数:数量2000,尺寸0.15,分布范围50,颜色青色。请给出3组不同的参数组合,分别适合星空、雪花、数据点云三种效果,用JSON数组返回。" } ], "temperature": 0.7 }请求地址是https://taotoken.net/api/v1/chat/completions,请求头里带Authorization: Bearer 你的Key和Content-Type: application/json。注意/v1/chat/completions这个路径,不同兼容层可能略有差异,以文档为准。
如果你用 Node 脚本,可以这样写:
// particle-tuner.mjs const API_BASE = 'https://taotoken.net/api'; const API_KEY = process.env.TAOTOKEN_API_KEY; // 从环境变量读,别硬编码 const MODEL_ID = '你的模型ID'; async function getTuningAdvice(currentParams) { const res = await fetch(`${API_BASE}/v1/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: MODEL_ID, messages: [ { role: 'system', content: '你是 three.js 粒子系统调参助手,只返回 JSON。' }, { role: 'user', content: `当前参数:${JSON.stringify(currentParams)}。给出3组候选参数,字段为 count、size、spread、color,返回 JSON 数组。` } ], temperature: 0.7 }) }); if (!res.ok) { const errText = await res.text(); throw new Error(`请求失败 ${res.status}: ${errText}`); } const data = await res.json(); return data.choices[0].message.content; } const advice = await getTuningAdvice({ count: 2000, size: 0.15, spread: 50, color: '#00ffff' }); console.log(advice);运行前设置环境变量:export TAOTOKEN_API_KEY=你的Key,然后node particle-tuner.mjs。把 Key 放环境变量而不是写死在代码里,是因为一旦提交到 Git 就泄露了,这个习惯要养成。
如果你用 Claude Code 做开发,配置方式类似,在它的设置里填 Base URL、Key、Model ID 三件套。文档里有针对 Claude Code 的专门说明,路径在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,照着填即可。
还有一种场景:你想在浏览器页面里加一个「让 AI 给建议」的按钮。这时候要注意,前端直接暴露 Key 是不安全的,任何人都能从 Network 面板看到。正确做法是加一个自己的后端中转,前端请求你的后端,后端再带 Key 请求 TaoToken。如果只是本地自己玩,那无所谓,但别把带 Key 的页面部署到公网。
配置里最容易出错的三个地方:一是 Base URL 多写了斜杠或者带了 UTM 参数,正确是https://taotoken.net/api;二是 Key 前面忘了Bearer前缀;三是 Model ID 填成了展示名称而不是实际 ID。这三个错误下面排障章节会逐个对照。
4. 验证请求:从返回状态到粒子渲染的完整闭环
配置写完,得验证它真的通了。验证分两层:先验证 API 请求能拿到正常返回,再验证粒子在浏览器里正常渲染。两层都过,才算闭环。
先验证 API。用 curl 发一个最小请求:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "用一句话说明 three.js 里 PointsMaterial 的 sizeAttenuation 参数作用"}] }'正常返回是一个 JSON,结构里choices[0].message.content就是模型输出。如果返回 200 但 content 是空的,检查一下模型 ID 是否正确。如果返回 401,是 Key 问题。如果返回 404,是路径或 Base URL 问题。
拿到返回后,把模型给的调参建议贴回你的 HTML。比如它可能返回类似这样的候选:
| 效果 | count | size | spread | color |
|---|---|---|---|---|
| 星空 | 8000 | 0.08 | 200 | #ffffff |
| 雪花 | 3000 | 0.2 | 80 | #e0f7ff |
| 数据点云 | 1500 | 0.12 | 40 | #00ffff |
你把这些数字填进 HTML 顶部的常量,刷新页面看效果。星空那组因为 spread 大、size 小,会呈现稀疏的远景点;雪花那组 size 大一些,近处颗粒感明显;数据点云分布集中,适合叠加坐标轴。
验证粒子渲染是否成功,看三个信号:第一,页面背景色是不是你设的0x0b0e14,如果是白色说明setClearColor没生效;第二,有没有看到旋转的点群,如果静止不动说明requestAnimationFrame没跑起来;第三,打开 F12 的 Performance 面板,看帧率是否稳定在 60 左右,如果掉到 20 以下,说明粒子数量对你的设备来说太多了,往下调。
我试过把PARTICLE_COUNT直接拉到 100000,在集显笔记本上帧率掉到 15 左右,画面明显卡顿。降到 20000 就流畅了。所以数量不是越多越好,得看目标设备的 GPU 能力。sizeAttenuation开启时,size 的实际像素大小还和相机距离有关,相机拉远,点会变小,这个要有预期。
还有一个验证点:窗口缩放。拖动浏览器窗口大小,粒子应该跟着重新适配,不会拉伸变形。如果变形了,说明 resize 监听里的camera.updateProjectionMatrix()漏了。这个调用必须每次改完camera.aspect后执行,否则投影矩阵还是旧的。
到这里,API 通了、粒子渲染也正常,整个链路就闭环了。接下来把常见报错集中排一遍。
5. 常见报错排查:401、local proxy failed、reading choices 逐个击破
这一节按真实报错来。你在接入过程中大概率会遇到下面几个,我按出现频率排。
401 Unauthorized。返回体通常是{"error":{"message":"Invalid API key"}}之类。原因有三个:Key 复制时多了空格或换行;请求头里没加Bearer前缀;Key 已经被删除或过期。排查方法:把 Key 重新复制一遍,确认Authorization头的值是Bearer sk-xxxx这种格式,中间一个空格。如果还不行,去控制台重新创建一个 Key。
local proxy failed / connection refused。这个报错通常出现在你本地起了代理或者网络环境有拦截的时候。注意,这里说的不是让你去配代理,而是说如果你本地有某些网络工具在跑,可能会干扰请求。排查方法:先确认https://taotoken.net/api这个地址能正常访问,用 curl 直接测。如果 curl 通但代码不通,检查代码里 Base URL 是不是写成了http而不是https,或者多了一层路径。
Cannot read properties of undefined (reading 'choices')。这个报错说明data.choices是 undefined,也就是返回体结构和你预期的不一样。最常见原因是请求根本没成功,返回的是一个错误对象,但你的代码直接去读data.choices[0]。修复方法:在读 choices 之前先判断res.ok,不 ok 就把res.text()打出来看。上面 Node 示例里已经这么做了,照抄即可。
OAuth / authentication failed。如果你用的是 Claude Code 或类似工具,可能会遇到 OAuth 相关的报错。这类工具有时默认走 OAuth 流程,但 TaoToken 用的是 API Key 鉴权,所以要在设置里明确选择 API Key 模式,填 Base URL、Key、Model ID 三件套。文档里有针对性的配置说明,路径在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
THREE.Points is not a constructor。这个不是 API 报错,是 three.js 版本问题。检查你 import 的 three.js 版本,r125 之后Points是存在的,但如果用了很老的 CDN 地址可能加载到旧版本。用 importmap 指定three@0.160.0这种明确版本号,避免加载到不确定的版本。
页面全黑但无报错。按顺序检查:容器高度是否为 0;相机位置是否在粒子分布范围之外(比如相机在 z=80,粒子 spread=50,那粒子在 -25 到 25 之间,相机能看到);renderer.render是否真的被调用;scene.add(points)是否执行。这四个点覆盖了 90% 的黑屏情况。
粒子颜色不对。PointsMaterial的color参数接受十六进制数字,比如0x00ffff,不是字符串'#00ffff'。如果你从模型建议里拿到的是字符串,要转换一下,或者用new THREE.Color('#00ffff')。
排障的核心思路就一条:先确认请求层通不通(curl 测),再确认代码层读得对不对(打印返回体),最后确认渲染层画没画出来(看背景色和帧率)。三层分开查,比一股脑改代码快得多。
6. 继续往下走:把粒子 Demo 变成你的调参工作台
跑通最小示例只是起点。真正有意思的是把它变成一个能快速试参数的工作台。我的做法是在页面右上角加几个滑块,分别控制 count、size、spread,滑块一动就重建BufferGeometry的 position 属性,实时看效果。这样配合模型给的候选参数,调起来非常快。
具体实现上,重建几何体时记得把旧的geometry.dispose()掉,否则显存会持续增长。PointsMaterial如果只是改 size 和 color,不用重建,直接改属性即可,但改完要设material.needsUpdate = true。这些细节不注意,跑久了页面会越来越卡。
如果你想让粒子有更丰富的效果,下一步可以研究自定义 shader。PointsMaterial是内置材质,能力有限;换成ShaderMaterial后,你可以控制每个粒子的透明度、大小随距离变化的曲线、甚至用纹理贴图做圆形粒子。这时候让模型帮你写一段 GLSL 顶点着色器,会比你自己查文档快很多。
长期做 three.js 开发的话,可以考虑用 Coding Plan 把模型调用额度固定下来,这样调参、生成代码、解释报错都能随时用,不用每次单独申请。入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
想直接和模型对话验证效果,可以用模型对话入口:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
最后留一个实用技巧:把常用的调参 prompt 存成一个模板,每次只替换当前参数部分。比如「当前粒子参数 X,请给出适合 Y 效果的候选值,返回 JSON」,这样你每次调参只需要改 X 和 Y,不用重新组织语言。模板化之后,调参这件事就从「想怎么说」变成「填两个空」,效率提升很明显。
粒子系统的魅力在于,同样的代码,参数一变就是完全不同的视觉。多试几组,你会对 size、spread、count 三者的关系有直觉。这种直觉,比任何教程都管用。