一个 skill,做出 3D 特效网页,太炫酷了
从 2025 年 3 月开始,不少开发者发现,Claude 的 Artifacts 生成界面变得“能打”了。不是按钮变多了,而是它能真正输出一个完整可交互的 3D 场景:一个带有轨道控制的 3D 地球、粒子烟花、分形地形,甚至是一个小型的 3D 游戏原型。这一个多月的工程实践里,我反复用它做 3D 特效网页,基本摸清了哪些 skill 写法稳定可复现,哪些写法在模型上下文窗口里会直接翻车。这篇博客就把这套方法写全。
在动手之前,先要说清楚一个关键点:这个流程的本质并不是“让 AI 写代码”,而是“让 AI 在一个受限但完整的 3D 渲染沙箱里完成创作”。这个沙箱由 HTML、CSS、Three.js 以及少量辅助脚本组成。它足够小,小到模型可以在上下文里完整“看见”整个项目;它又足够完整,完整到可以直接运行 Three.js 的 3D 场景、交互事件和动画循环。所以这里的核心任务不是写业务代码,而是把“3D 特效需求”转化为一份模型能稳定执行的页面规格。
需要说明的是,这里的“skill”不涉及任何商业产品或未经确认的工具。它指的是你可以保存在本地文本提示词库、项目模板描述、或者团队共享知识库中的一套可复用“技能包”。你把它喂给模型时,它就知道该用什么结构、什么依赖、什么渲染管线来生成 3D 特效页面。下文会给出完整的 skill 定义内容、运行环境、验证方式和排错流程。
本文读者定位是:
- 已经用过 Claude 网页版,知道 Artifacts 是什么,但被生成的 3D 效果“看得到、转不动”困扰的开发者。
- 想在本地项目里复现“一套 3D 特效网页模板”的工程师。
- 需要把 3D 可视化、产品演示页、3D 数据大屏等需求快速交付的前端开发者。
读完这篇文章后,你会得到:
- 一个可以直接复用、保存到本地提示词库的“3D 特效网页 skill”模板。
- 一套从“文字需求”到“完成 3D 页面”的转换流程。
- 运行、调试、检查 3D 场景的关键代码片段和控制台 API。
- 一份覆盖常见问题的排查清单,避免把时间浪费在模型反复生成同一处错误上。
2. skill 的本质:它是给模型的上下文压缩协议,不是魔法
在开始写 skill 之前,先把这个概念讲透。很多人第一次看到“skill”时,会觉得它是个插件或者独立程序,但实际不是。
2.1 skill 到底是什么,解决什么问题
一个 skill,本质是一段结构化的文本指令。它描述的是“当用户提出某类需求时,你应该按照什么方式思考和输出”。
把它放在 Claude 的项目知识库、自定义指令或者个人提示词库中时,它能起三个作用:
- 明确输出目标:告诉模型生成什么样的页面、包含哪些交互、达到什么视觉效果。
- 约束实现方式:限定技术栈、依赖版本、渲染流程和关键 API 用法,减少模型随意的“自由发挥”。
- 提供自检路径:让模型能够在生成代码时,自动检查是否用了错误的 API、缺失了哪些渲染组件。
在 3D 特效网页场景里,skill 的作用尤其明显。因为 Three.js 项目涉及的东西非常多:场景(scene)、相机(camera)、渲染器(renderer)、几何体、材质、纹理、灯光、动画循环、窗口自适应、轨道控制、性能监控。如果让模型随意组织这些内容,它很容易生成一个看起来结构完整,但一运行就白屏或者不动的东西。
skill 就是把“三个月的 Three.js 使用经验”压缩成 300 行提示词。它不是代码库,而是控制模型生成的“规格说明书”。
2.2 为什么 3D 网页特别适合用 skill 驱动
3D 网页和其他前端页面相比,有一个很大的差异:它的“运行结果”在代码之外。一个普通按钮点击,你能立刻看到 DOM 变化;但一个 3D 模型渲染,它涉及 GPU、着色器、动画循环和坐标系统,很多错误不会直接报错,而是表现为“画面不动”“黑屏”“物体变形”“控制失效”。
这种特性导致了一个结果:模型生成 3D 页面时,很容易生成“自己觉得没问题,运行起来完全不对”的代码。而 skill 的价值,就是提前把可能出错的环节标准化。它对模型说:不要自己发明相机控制方式,用 OrbitControls;不要自己写动画循环,用 renderer.setAnimationLoop;不要手动管理所有 resize 事件,按指定方式处理。
这样模型输出的代码,即使细节不完美,至少骨架是安全的。
2.3 skill、agent、插件三者到底有什么区别
讨论 skill 时,经常被拿来跟 agent 和插件对比。简单理解如下:
| 概念 | 核心作用 | 持久化方式 | 典型场景 |
|---|---|---|---|
| skill | 定义提示词和输出规范 | 文本模板、知识库、项目文件 | 让模型按统一规格生成 3D 页面 |
| agent | 执行多步骤任务,决定下一步操作 | 脚本、API 调用逻辑 | 自动截图验证、迭代修改、调用外部工具 |
| 插件 | 扩展宿主应用的功能 | 浏览器扩展、服务端集成 | 给编辑器增加快捷键、给浏览器增加截图能力 |
在 Claude 生成 Artifacts 的场景里,skill 是最轻量级的控制手段。它不需要额外安装,不需要写独立服务,只要求你在项目知识库或者自定义指令里放一份规范的文本即可。它的核心优势是:模型能稳定地、可复现地输出你期望的页面结构。
3. 准备一套完整可用的“3D 特效网页 skill”模板
这一部分直接给出模板。你不用从头设计提示词,直接保存下面的内容,放到项目知识库、自定义指令或者个人提示词库中,就可以开始使用。
3.1 通用模板:从需求到 3D 页面的最小规格
下面是核心 skill 文本。它假设模型使用的是 Claude 的 Artifacts 或本地 HTML 预览环境,支持直接运行 Three.js CDN 脚本。如果你的环境无法运行,后面会有适配方案。
你是一名资深 Three.js 前端工程师,负责根据用户需求生成一个可直接在浏览器中运行的 3D 特效网页。 【输出要求】 生成一个完整 HTML 文件,包含 <!DOCTYPE html> 到 </html> 的所有内容。 页面必须满足以下要求: 1. 使用 Three.js r128 或更高版本,通过 CDN 引入,避免使用本地依赖。 2. 核心 3D 场景必须包含:场景 scene、透视相机 PerspectiveCamera、WebGL 渲染器 WebGLRenderer。 3. 默认使用 OrbitControls 实现鼠标拖拽旋转、滚轮缩放。 4. 使用 renderer.setAnimationLoop() 驱动动画循环,不要在循环外创建一次性渲染。 5. 自动适配窗口大小变化,resize 时更新相机 aspect 和渲染器尺寸。 6. 页面背景默认使用深色渐变,3D 物体使用高饱和材质,体现“炫酷”效果。 7. 必须提供静态文本标题,展示页面主题。 8. 所有代码必须运行于单个 HTML 文件,不额外引入 CSS 文件。 【3D 视觉要求】 - 使用 2 到 4 组不同类型的几何体构成视觉层次。 - 至少使用一种特效材质:MeshStandardMaterial、MeshPhongMaterial、ShaderMaterial 均可。 - 使用 2 个以上点光源或方向光,营造立体感。 - 可添加粒子系统、光晕、网格辅助线或环境反射效果。 - 动画必须持续运行,不能静止。 【交互要求】 - 鼠标拖拽旋转。 - 滚轮缩放。 - 提供“播放/暂停”按钮,控制动画开关。 【错误避免】 - 不要使用 document.write。 - 不要使用 Three.js 已废弃的 API(如 Geometry、BufferGeometry 除外)。 - 不要使用外部纹理图片,所有材质使用程序化生成颜色或渐变。 - 不要覆盖现有页面结构,整个页面由单个 HTML 输出。 【自检要求】 生成完成后,检查以下内容: - scene、camera、renderer 是否都已定义并正确初始化。 - OrbitControls 是否被实例化并传入 camera 和 renderer.domElement。 - 动画循环里是否有 scene.render。 - 是否有 window.addEventListener('resize', ...) 处理。 - 所有几何体 name 属性都已设置,方便后续调试。这套模板的核心逻辑是“把 3D 页面拆成固定要素”:场景、相机、渲染器、控制、动画、自适应、标题、交互。模型按照这份清单生成页面时,不容易遗漏关键环节。
3.2 进阶模板:支持主题换肤、参数配置和导出
当需要生成多个不同主题的 3D 页面时,可以增加一个“主题配置”层。下面这段是进阶版 skill 的扩展内容:
额外要求: - 页面顶部提供一个控制面板,包含以下配置项: - 主题选择:夜色、霓虹、极光、科技蓝。 - 动画速度滑块:范围 0.1 到 3.0,默认 1.0。 - 粒子数量选择:少(500)、中(2000)、多(6000)。 - 修改主题时,页面背景色、物体材质颜色、灯光强度必须同步变化。 - 配置项使用纯 JavaScript 实现,不使用第三方 UI 库。 - 控制面板样式使用固定定位,半透明背景,不遮挡 3D 场景拖拽区域。这个扩展的价值在于:模型生成的页面不再是一个固定演示,而是一个可以交给用户调参的“小工具”。在实际工作中,这种页面已经能用于产品概念展示、技术分享开场页、团队内部数据展厅。
3.3 模板放置在哪个位置,决定了它会不会生效
把 skill 文本写出来只是第一步。不同的使用方式,决定模型会不会真的按模板执行。
| 使用位置 | 生效条件 | 适合场景 |
|---|---|---|
| Claude 项目知识库 | 项目包含该文本文件,模型会读取 | 固定项目的持续创作 |
| 自定义指令 | 全局生效 | 个人高频复用的通用模板 |
| 每次对话里粘贴 | 当次生效 | 快速调试、临时生成 |
| 团队共享知识库 | 随项目内文件生效 | 多人协作、统一输出规格 |
这里有一个常见坑:如果你把 skill 放在很长的项目文件里,模型在选择读取哪些文件时,可能只检索到部分内容。建议把 skill 的标题写得足够明确,例如“threejs-skill-template.md”,并在文件开头写一句“生成 3D 特效网页时使用以下规则”。这样模型在检索时,更容易把它当成权威指令。
4. 用 skill 实际生成一个 3D 粒子地球页面
现在进入实操环节。目标是生成一个带粒子地球的 3D 特效页面。为什么选粒子地球?因为它同时具备三个特点:视觉效果好、Three.js 实现思路经典、适合验证 skill 是否生效。
4.1 先定义一个标准输入需求
一个优秀的“需求输入”是 skill 能发挥作用的起点。不要只说“做一个炫酷的 3D 效果”,而是给出具体的格式:
请按 skill 模板生成一个 3D 特效网页,主题为“粒子地球”。 具体要求: 1. 页面标题显示“Particle Earth”。 2. 背景使用深蓝色渐变,从 #050510 到 #0a0a2a。 3. 地球由 3000 个粒子组成,粒子分布在一个球面上。 4. 粒子颜色使用蓝、白、青渐变,透明度 0.9。 5. 地球缓慢自转,自转速度 0.002 弧度/帧。 6. 添加一个半透明的球体网格作为辅助视觉层。 7. 使用 OrbitControls 控制视角。 8. 页面底部显示操作提示:“拖拽旋转 / 滚轮缩放”。这段输入的优点是把视觉要求、粒子数量、颜色、动画速度、交互方式都做了明确限定。模型不需要猜你想表达什么,它只需要把这段规格翻译成 Three.js 代码。
4.2 让模型按 skill 模板输出
将 skill 模板和需求输入一起交给模型。如果你使用的是 Claude 项目知识库方式,直接把“需求输入”粘贴到对话中即可。如果使用的是自定义指令方式,也要在同一对话里把需求说清楚。
模型输出的结果通常是一份完整的 HTML 文件,代码结构大致如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Particle Earth</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: linear-gradient(135deg, #050510 0%, #0a0a2a 100%); font-family: 'Segoe UI', sans-serif; color: #fff; } #info { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); font-size: 14px; opacity: 0.7; z-index: 10; pointer-events: none; } #toggleBtn { position: fixed; top: 20px; right: 20px; z-index: 10; padding: 10px 18px; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.3); color: #fff; border-radius: 8px; cursor: pointer; backdrop-filter: blur(6px); } </style> </head> <body> <div id="title">Particle Earth</div> <button id="toggleBtn">暂停</button> <div id="info">拖拽旋转 / 滚轮缩放</div> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script> // ========== 场景、相机、渲染器 ========== const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(0, 0, 8); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); // ========== OrbitControls ========== const controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.autoRotate = false; // ========== 粒子地球 ========== const particleCount = 3000; const radius = 3; const positions = new Float32Array(particleCount * 3); const colors = new Float32Array(particleCount * 3); for (let i = 0; i < particleCount; i++) { // 在球面上均匀分布 const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const x = radius * Math.sin(phi) * Math.cos(theta); const y = radius * Math.sin(phi) * Math.sin(theta); const z = radius * Math.cos(phi); positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z; const t = Math.random(); // 蓝白青色渐变 colors[i * 3] = 0.1 + t * 0.3; colors[i * 3 + 1] = 0.4 + t * 0.4; colors[i * 3 + 2] = 0.9; } const particleGeo = new THREE.BufferGeometry(); particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); particleGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const particleMat = new THREE.PointsMaterial({ size: 0.05, vertexColors: true, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }); const particles = new THREE.Points(particleGeo, particleMat); particles.name = 'particleEarth'; scene.add(particles); // ========== 辅助球体网格 ========== const wireframeSphere = new THREE.Mesh( new THREE.SphereGeometry(radius, 32, 32), new THREE.MeshBasicMaterial({ color: 0x2a5f9e, wireframe: true, transparent: true, opacity: 0.15 }) ); wireframeSphere.name = 'gridSphere'; scene.add(wireframeSphere); // ========== 灯光 ========== const ambientLight = new THREE.AmbientLight(0xffffff, 0.3); const dirLight = new THREE.DirectionalLight(0x4aa8ff, 1.2); dirLight.position.set(5, 5, 5); scene.add(ambientLight); scene.add(dirLight); // ========== 动画循环 ========== let isPlaying = true; const animate = () => { if (isPlaying) { particles.rotation.y += 0.002; wireframeSphere.rotation.y += 0.002; } controls.update(); renderer.render(scene, camera); }; renderer.setAnimationLoop(animate); // ========== 播放/暂停 ========== const toggleBtn = document.getElementById('toggleBtn'); toggleBtn.addEventListener('click', () => { isPlaying = !isPlaying; toggleBtn.textContent = isPlaying ? '暂停' : '播放'; }); // ========== resize 自适应 ========== window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); </script> </body> </html>代码里的关键点已经不只是“生成一个 3D 页面”,而是体现了几条工程经验:
- 粒子位置使用
Math.acos(2 * Math.random() - 1)生成球面均匀分布,避免粒子在南北极聚集。 - 粒子材质使用
AdditiveBlending和depthWrite: false,让粒子叠加时产生光晕感,这是 3D 特效页面“炫酷”的重要来源。 - 使用
renderer.setAnimationLoop()而不是requestAnimationFrame单独管理循环,避免在多个地方出现多个循环导致动画卡顿。 controls.enableDamping = true让旋转更顺滑,不再有“一顿一顿”的感觉。
4.3 实际运行后如何确认 skill 生成了正确结果
页面在浏览器里打开后,不能只看一眼“好像有粒子”就认为成功。按下面这张检查表逐项验证:
| 检查项 | 预期结果 | 失败表现 |
|---|---|---|
| 页面标题 | 显示 Particle Earth | 页面无标题或标题错误 |
| 背景 | 深蓝色渐变 | 白屏或黑色纯色 |
| 粒子数量 | 3000 个粒子分布在球面 | 粒子明显过稀或过密 |
| 粒子颜色 | 蓝白青渐变 | 全白或全蓝无渐变 |
| 交互 | 拖拽旋转、滚轮缩放 | 拖拽无反应或页面整体拖动 |
| 播放/暂停 | 按钮可暂停动画 | 点击后无变化或按钮消失 |
| 自适应 | 浏览器窗口变化后画面不拉伸 | 画面变形或出现黑边 |
这一步很重要。因为模型生成代码时,即使 skill 写得很清楚,也可能因为 Three.js 版本 API 差异产生问题。你必须在真实浏览器里运行一次,才能确认这个 skill 模板在你当前环境里是可靠的。
5. 调试与常见问题排查
3D 页面最大的特点是错误表现形式和普通页面不一样。普通页面报错时,命令行会直接抛红;3D 页面很多错误会表现为“白屏”“黑屏”“不动”“变形”,而控制台可能没有任何异常。所以排查思路要从“看报错”变成“看画面特征”。
下面是我在实践中最常遇到的 5 类问题。
5.1 页面白屏且有报错:CDN 地址或 API 版本不匹配
现象:控制台出现THREE is not defined或Cannot read properties of undefined (reading 'PerspectiveCamera')。
常见原因:
- Three.js CDN 地址失效。
- 引用了较新版本,但代码使用旧 API。
- CDN 和本地代码使用不同版本。
排查方式:在浏览器控制台执行console.log(THREE.REVISION),确认加载的实际版本号。再检查代码里使用的 API 是否对应这个版本。
处理建议:
- 统一使用固定版本的 CDN 地址,例如
r128。 - 不要在页面中混用两个不同版本的 Three.js。
- 如果必须使用最新版本,先确认
THREE.OrbitControls的引入位置是否已改为独立模块方式。
5.2 页面黑屏但无报错:相机位置或灯光设置问题
现象:控制台无报错,但页面是纯黑背景,什么都看不到。
常见原因:
- 相机朝向错误,物体不在相机可见范围内。
- 灯光设置不合理,物体表面被完全照亮或完全没光。
- 物体的 scale 或 position 设置异常,导致模型被缩放成不可见。
排查方式:
- 在控制台执行
console.log(camera.position)和console.log(particles.position),确认二者位置合理。 - 把粒子材质临时改为
MeshBasicMaterial或把PointsMaterial的颜色改成亮色,确认物体是否真的在场景中。 - 把灯光强度临时调大,确认是否能看到轮廓。
处理建议:
- 相机默认放置在
(0, 0, 8),物体中心在原点(0, 0, 0)是最安全的组合。 - 使用
MeshBasicMaterial测试早期渲染,因为它是无光照材质,不受灯光影响。 - 如果物体使用
PointsMaterial,注意粒子尺寸太小也看不见,先调大size测试。
5.3 粒子地球两极粒子过密:球面分布算法不对
现象:粒子看起来堆积在顶部和底部,赤道附近稀疏。
原因:直接用Math.random() * Math.PI作为极角,会导致球面面积不均衡。在球面上均匀分布,必须让phi = Math.acos(2 * Math.random() - 1)。
解决方案:
for (let i = 0; i < particleCount; i++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); // 其余同前 }检查点:随机生成 3000 个点后,统计每个纬度带的点数量,应大致均匀。
5.4 控制失效:OrbitControls 被遮挡或重复实例化
现象:鼠标拖拽没有反应,或者页面另一个交互区域会抢到事件。
常见原因:
OrbitControls绑定到了错误的 DOM 元素。- 页面里有其他覆盖层,把鼠标事件吃掉了。
OrbitControls被实例化了两次,互相干扰。
排查方式:
- 检查
new THREE.OrbitControls(camera, renderer.domElement)中第二个参数是不是renderer.domElement。 - 检查是否有元素覆盖在 canvas 上方,设置
pointer-events: none。
处理建议:
- OrbitControls 统一绑定到
renderer.domElement。 - 控制面板、提示文本等浮层,不使用事件时设置
pointer-events: none。 - 不要在代码里重复
new OrbitControls,实例化一次即可。
5.5 动画只有一帧:渲染没有被持续驱动
现象:页面能显示初始画面,但物体始终不动,也没有报错。
原因:渲染代码只在初始化时执行了一次,没有进入循环。
解决方案:使用renderer.setAnimationLoop(animate),并确保animate函数里调用renderer.render(scene, camera)。
const animate = () => { // 更新物体 particles.rotation.y += 0.002; // 更新控制 controls.update(); // 渲染 renderer.render(scene, camera); }; renderer.setAnimationLoop(animate);同时要检查:暂停按钮的isPlaying状态是否误把动画一直暂停。
5.6 一张排查速查表
| 现象 | 可能原因 | 检查动作 | 快速处理 |
|---|---|---|---|
| 白屏 + 控制台报错 | CDN 失效或版本 API 不匹配 | console.log(THREE.REVISION) | 固定 CDN 版本 |
| 黑屏无报错 | 相机位置、灯光或物体过小 | 临时调大物体 size、查看 position | 把相机放到(0,0,8) |
| 粒子分布不均 | 球面坐标算法错误 | 统计纬度带数量 | 用Math.acos(2*random-1) |
| 无法拖拽旋转 | OrbitControls 绑定错误/被遮挡 | 检查绑定元素 pointer-events | 绑定 renderer.domElement |
| 动画静止 | 没有进入渲染循环 | 检查 setAnimationLoop | 在循环中调用 render |
| resize 后拉伸 | 未更新相机 aspect 和 renderer 尺寸 | 检查 resize 监听 | 更新 camera.aspect 并调用 renderer.setSize |
6. 从“能跑”到“能用”:生产级 3D 页面的设计要点
当一个小型 3D 页面在浏览器里跑通后,你在真实项目里还要考虑更多东西。这里把“能跑的 demo”和“能上线的页面”做一个边界梳理。
6.1 性能:粒子数量和渲染开销需要提前估算
3D 特效网页最容易犯的问题是:视觉效果很漂亮,但用户电脑风扇起飞。要控制性能,主要看两个指标:
- 粒子数量
- 每帧需要更新的对象数量
在 skill 模板中,我建议默认把粒子数量控制在 3000 以内。实际项目中,如果目标用户是普通办公电脑,6000 个 OpenGL 粒子的开销已经不小,尤其还要开启抗锯齿和 highp precision 时。
有一份可参考的经验值:
| 目标设备 | 建议粒子数量 | 建议像素比 | 备注 |
|---|---|---|---|
| 旗舰手机 | 2000 以内 | 2 | 关闭大尺寸全屏粒子 |
| 普通 Windows 笔记本 | 3000 左右 | 2 | 开启 antialias 影响不大 |
| 中高端台式机 | 6000 左右 | 2 | 可开启 Bloom 等后期特效 |
| 数据大屏专用机 | 10000 以上 | 2 | 需要配套 GPU 性能检测 |
为什么建议renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))?因为很多高分辨率屏幕的 devicePixelRatio 是 2 或更高,如果不限制,Canvas 实际渲染尺寸会变成屏幕逻辑尺寸的 3 倍、4 倍,GPU 开销指数上升。
6.2 工程化:从单文件 HTML 到组件化结构
当多个页面使用同一个 skill 模板时,你会希望把重复代码抽出来。比如把粒子地球、粒子星系、粒子流场分别封装成独立函数。
function createParticleSphere(scene, options) { const { radius, count, colorStart, colorEnd, size } = options; const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); for (let i = 0; i < count; i++) { const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta); positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta); positions[i * 3 + 2] = radius * Math.cos(phi); const t = Math.random(); colors[i * 3] = colorStart[0] + (colorEnd[0] - colorStart[0]) * t; colors[i * 3 + 1] = colorStart[1] + (colorEnd[1] - colorStart[1]) * t; colors[i * 3 + 2] = colorStart[2] + (colorEnd[2] - colorStart[2]) * t; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const material = new THREE.PointsMaterial({ size, vertexColors: true, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }); const points = new THREE.Points(geometry, material); points.name = options.name || 'particleSphere'; scene.add(points); return points; }这样做的意义在于:当业务方要求把粒子地球换成“3D 数据大屏的地球底座”时,不再需要让模型重新生成一整页,只需要调整createParticleSphere的参数。
6.3 交互:不能只做旋转缩放,要提供明确反馈
生产级 3D 页面需要具备以下交互要素:
- 操作反馈:点击某个 3D 物体时,该物体高亮、变色或弹出信息框。
- 状态反馈:播放/暂停、加载完成、数据更新时要有可见状态。
- 错误反馈:当浏览器不支持 WebGL 时,要显示降级信息。
- 可访问性:为按钮和文本提供合适的字体大小、对比度,必要时支持键盘操作。
一个简单的点击反馈示例:
const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); renderer.domElement.addEventListener('click', (event) => { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(scene.children, true); if (intersects.length > 0) { const obj = intersects[0].object; if (obj.name === 'particleEarth') { // 做高亮或跳转 console.log('particleEarth clicked', obj); } } });这里要记得:intersectObjects默认只检测网格对象,粒子系统需要开启对应设置。在 Three.js 中,Points对象可以被 Raycaster 检测,但粒子大小较小时,点击命中会比较困难,可以适当调大粒子 size 或用半透明包围球辅助检测。
6.4 兼容性:WebGL 不是所有环境都可用
在真实项目中必须考虑降级。你可以用下面这段代码判断浏览器是否支持 WebGL:
function isWebGLSupported() { try { const canvas = document.createElement('canvas'); return !!(window.WebGLRenderingContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))); } catch (e) { return false; } } if (!isWebGLSupported()) { // 显示静态提示或降级为 2D Canvas 效果 document.getElementById('fallback').style.display = 'block'; }在 skill 模板里加入这条检查,可以在页面白屏之前先给用户一个清晰提示,而不是让用户面对一个死了的页面。
7. 输出之前,把它变成自己的工具
最后要说的是:这套方法不能一直停留在“让模型顺便生成一个 3D 页面”的阶段。真正有价值的是,把 skill 模板沉淀成团队或个人的工具资产。
7.1 建立一个 3D 特效需求库
建议把常用的 3D 特效需求整理成一份“需求卡片”,放在项目知识库中。例如:
| 状态 | 需求描述 | 对应 skill 要点 |
|---|---|---|
| 已复用 | 3D 粒子地球 | 球面均匀分布、AdditiveBlending、OrbitControls |
| 已复用 | 3D 星球环形带 | 环形粒子带、旋转动画、双材质 |
| 待开发 | 3D 柱状图数据大屏 | 柱状几何体、数据纹理、Raycaster 点击 |
| 待开发 | 3D 烟花 | 粒子物理、重力、生命周期 |
建立这个库的核心目的,是在团队协作时让模型更稳定地输出同类页面。你不需要重新描述“粒子如何分布、动画如何循环”,只需要说“按 XXX skill 模板生成环形