Three.js打造诺兰式WebGL粒子短片:ATHENA的代码实现
一个名为
Show HN: Christopher Nolan's Hymn to Athena
的项目标题,第一次看到时很容易让人误以为是一部未公开的电影短片。放到 Hacker News 的 Show HN 语境里,这通常意味着一个可以打开、可以交互、可以体验的网页作品。标题里有三个关键词:诺兰、雅典娜、赞歌。它们组合在一起,指向的并不是传统产品,而是一种带有叙事感和仪式感的视觉体验。这篇文章从工程角度复原这个创意:用 Three.js 搭一个电影感 WebGL 页面,把“ATHENA”几个字变成三维金色粒子,让摄像机像诺兰电影一样在时间循环里正放、倒放,再用后期特效和氛围音效补足“赞歌”的情绪。
如果你正在寻找一个适合入门 WebGL、又能在展示项目时让人眼前一亮的练手作品,这个方向非常适合。整条实现链路不会依赖美术素材或外部模型,所有效果都用代码生成,因此可以完整复现,也可以按自己的审美改造成其他主题。完成后,你能得到一个学习环境里可以直接运行的页面,同时理解粒子系统、摄像机动画、后处理 Bloom、浏览器音频这些常见技术的配合方式。
1. 这个创意项目到底在做什么
1.1 从标题能看到的产品意图
Show HN
是一个公开作品标签,它强调“可访问、可尝试、可反馈”。
Christopher Nolan
在这里不是指人物肖像,而是指一种视觉气质:对称构图、宏大空间、非线性时间、低音轰鸣。
Hymn to Athena
则是主题,雅典娜是希腊神话中的智慧与战争女神,也是雅典城的守护神,天然适合深色背景、金色粒子、史诗氛围。
把这些词翻译成技术需求,可以拆成三条:
- 页面打开后要有强烈的视觉记忆点,不能只是一个普通背景图。
- 交互要自然,但不用复杂,鼠标移动、点击、滚动都可以成为叙事的一部分。
- 整体观感要像一段短片,而不是一个管理后台或数据可视化页面。
这就是一个典型的“创意 WebGL 展示页”项目。它不追求业务复杂度,而是追求“看一眼就记住”。从学习价值看,它比写一个 3D 旋转立方体要更进一步,因为会涉及多个 Three.js 核心模块的组合。
1.2 为什么是 WebGL 而不是视频或图片
如果只是想要电影感,直接放一段视频或一张高清图可能更快。但视频和图有两个问题:一是文件体积大,加载受限于网络;二是无法交互,观众只能被动观看,没有“进入作品”的感觉。
WebGL 的优势在于:
- 所有画面由代码实时生成,首屏可以很小,加载速度更快。
- 用户可以影响摄像机、粒子、音效,形成半沉浸式体验。
- 内容可以被参数化,换一个主题文字,甚至换一套配色,就能变成新的作品。
- 不依赖剪辑软件,所有“运镜”和“特效”都由代码控制。
当然,WebGL 也有学习成本和性能风险。低端手机上如果不做像素比限制和粒子数量控制,很容易卡顿。这一点在后面性能部分会专门说明。
用表格对比几种实现方案会更直观:
| 方案 | 交互能力 | 体积 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| 视频 | 弱 | 大 | 低 | 纯展示、不需要用户参与 |
| 图片 | 无 | 中 | 低 | 静态海报 |
| CSS 3D | 中等 | 小 | 低 | 简单卡片翻转、视差 |
| WebGL 原生 | 强 | 小 | 高 | 复杂渲染、图形学学习 |
| Three.js | 强 | 中 | 中 | 快速搭建 3D 场景、创意展示 |
具体到这个项目,Three.js 是更合理的选择。它封装了大部分 WebGL 细节,同时保留了足够灵活的底层能力,比如自定义粒子材质、后处理管线。
1.3 最小功能定义
为了让这个创意能落地,先明确一个“最小可用版本”需要包含哪些功能:
- 一个黑色背景的 Three.js 场景。
-
由文字生成的粒子点云,主题词为
ATHENA。 - 摄像机沿固定路径运动,并在合适时间点倒放,形成时间循环感。
- 鼠标移动可以产生轻微视差,增强交互感。
- 点击页面后,用 Web Audio 生成一段低音氛围音。
- 使用 Bloom 后处理模拟电影光晕。
满足这六点,已经可以组成一个完整的展示页面。后面所有扩展都在这条主线上继续。
2. 环境准备:用 Vite 搭建 Three.js 项目
2.1 本地开发环境要求
开始写代码前,先确认本地环境。项目使用 Vite 作为开发服务器和打包工具,因此需要 Node.js 环境。
推荐版本和工具:
| 工具或依赖 | 建议要求 | 说明 |
|---|---|---|
| Node.js | 18 及以上 | Vite 在低版本下可能运行异常 |
| npm | 9 及以上 | 也可以使用 pnpm 或 yarn |
| 浏览器 | Chrome / Edge 最新版 | 对 WebGL 和 Web Audio 支持较好 |
| Three.js | 0.160.0 左右 | 版本会更新,代码 API 可能略有差异 |
如果你的电脑已经安装了旧版本 Node,建议先升级或使用 nvm 切换版本。不要在旧环境上硬跑,否则后面会遇到一堆依赖兼容问题。
2.2 初始化项目
在命令行执行以下命令:
npm create vite@latest hymn-to-athena -- --template vanilla
这个命令会创建一个基于原生 JavaScript 模板的 Vite 项目。执行完后进入目录:
cd hymn-to-athena
接着安装基础依赖和 Three.js:
npm install
npm install three
安装完成后,可以打开
package.json
确认
three
已经出现在
dependencies
中。
这里解释一下为什么不选择 React 模板:这个项目不需要复杂 UI 状态管理,原生 JavaScript 模板更精简,也更容易让初学者看清 Three.js 本身的逻辑。如果你习惯 React,后续可以自行改成 React Three Fiber,但这不是本文的重点。
2.3 项目结构与目录说明
初始模板会有一些默认文件。为了后续维护清晰,建议整理成下面这样:
hymn-to-athena/
├─ index.html
├─ package.json
├─ vite.config.js
└─ src/
├─ main.js
├─ particles.js
├─ cameraPath.js
└─ audio.js
每个文件的作用:
-
index.html:页面入口,包含容器节点和标题信息。 -
src/main.js:创建渲染器、场景、相机,组织动画循环和后处理。 -
src/particles.js:从 Canvas 文字像素生成三维粒子。 -
src/cameraPath.js:定义摄像机的时间循环路径。 -
src/audio.js:用 Web Audio 生成氛围音。
这样的划分让每块逻辑相对独立,排查问题的时候不用在单个大文件里反复翻。
2.4 启动验证
先启动开发服务器,确认基础环境可用:
npm run dev
浏览器打开终端提示的地址,通常是
http://localhost:5173
。如果看到 Vite 默认页面说明环境正常。此时还没有加入任何 Three.js 逻辑,后面我们会逐步替换
src/main.js
。
注意:如果默认端口被占用,Vite 会自动换一个端口。看到终端输出的实际地址,直接打开那个地址即可。
3. 核心机制:从文字生成粒子点云
3.1 为什么使用 Canvas 读取像素生成三维散点
要让“ATHENA”这个词本身成为视觉主体,最简单的方式不是建模,而是用 Canvas 先把文字画出来,再读取每个像素的透明度,把不透明的像素坐标映射到三维空间。这是很多 WebGL 文字粒子效果的通用做法。
这样做有三个好处:
- 不需要外部字体文件,系统自带的衬线字体就能产生足够气质。
- 数据来源是像素坐标,数据和实际显示完全对应,不会出现文字漂移。
-
后续换文字只需要改动一个入参,比如把
ATHENA改成任意字符串。
当然也有局限:Canvas 像素点只能描述二维形状,所以要给粒子增加随机
z
轴厚度,让它在三维空间里不再是一张薄片。
3.2 写入
particles.js
的完整实现
新建
src/particles.js
,代码如下:
import * as THREE from 'three';
export function createTextParticles(text, options = {}) {
const {
fontSize = 120,
step = 2,
depth = 1.5,
color = [0.85, 0.75, 0.55],
scale = 0.02
} = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const width = 512;
const height = 256;
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${fontSize}px "Times New Roman", serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
const imageData = ctx.getImageData(0, 0, width, height);
const positions = [];
const colors = [];
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
const index = (y * width + x) * 4;
if (imageData.data[index + 3] > 128) {
const px = (x - width / 2) * scale;
const py = -(y - height / 2) * scale;
const pz = (Math.random() - 0.5) * depth;
positions.push(px, py, pz);
colors.push(...color);
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.045,
vertexColors: true,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const points = new THREE.Points(geometry, material);
return points;
}
这段代码的关键点:
-
ctx.getImageData返回每个像素的 RGBA 值,data[index + 3]是透明度。 -
step控制采样间隔。step = 1时粒子数量最多,step = 2时数量约为前者的四分之一。 - 像素坐标需要先居中,再乘以一个缩放系数,才能让“ATHENA”在三维空间中尺寸合适。
-
pz使用随机值,让粒子在纵深方向散开,形成云团感。 -
材质使用
AdditiveBlending,多个粒子叠加后会变亮,适合深色背景上的光点效果。
3.3 粒子数量和性能的关系
粒子数量直接影响渲染性能。用 Canvas 像素采样时,粒子数量可以由下面的公式估算:
大约粒子数 ≈ (可见文字像素数) / (step * step)
step
越小,粒子越密,文字轮廓越清晰,但性能开销越大。
step
越大,粒子越稀疏,性能越好,但轮廓会变碎。
| step 值 | 粒子数量级 | 视觉效果 | 性能表现 |
|---|---|---|---|
| 1 | 非常密集 | 文字清晰,光晕细腻 | 中低端设备可能卡顿 |
| 2 | 中等 | 文字清晰,粒子感明显 | 推荐 |
| 3 | 较少 | 文字边缘开始破碎 | 性能优先 |
| 4 | 很少 | 只能看到大致形状 | 适合移动端极端情况 |
实际项目中建议把
step
作为配置项暴露在函数入参里,方便根据目标设备动态调节。如果在手机端测试,可以设置
step = 3
或
4
。
3.4 颜色和材质调参
默认的金色
[0.85, 0.75, 0.55]
来自希腊大理石的暖色想象。修改颜色时要注意,
PointsMaterial
的
color
会被
vertexColors
覆盖,所以这里直接写入了每个粒子的颜色数组。如果你希望整体是蓝色,可以改成
[0.3, 0.6, 1.0]
。
size
参数也很关键。
0.045
在相机距离 6 到 8 的情况下看起来比较合适。如果换成大尺寸模型或远镜头,需要重新调整。只改
size
不调整摄像机距离,容易出现粒子过大或过小的问题。
4. 让镜头学会倒放:时间循环与摄像机路径
4.1 诺兰式时间结构如何用代码表达
诺兰电影里的“倒放”最有代表性的处理方式,不是单纯把视频反过来播,而是让观众意识到时间在正放和倒放之间循环。对 WebGL 页面来说,最简单且效果明显的方法,是让摄像机沿着一条路径运动,前进一段时间后,再沿着刚才的路径返回,周而复始。
用代码表达,需要一个时间比例
t
。
t
从 0 递增到 1,再递减回 0,整个过程对应一个循环周期。
4.2 CameraPath 的实现
新建
src/cameraPath.js
:
export class CameraPath {
constructor(duration = 12) {
this.duration = duration;
}
update(camera, time) {
const total = this.duration * 2;
const current = time % total;
let t;
if (current < this.duration) {
t = current / this.duration;
} else {
t = 1 - (current - this.duration) / this.duration;
}
const angle = t * Math.PI * 2;
const radius = 6;
const height = Math.sin(angle * 2) * 0.8 + 1;
camera.position.x = Math.sin(angle) * radius;
camera.position.y = height;
camera.position.z = Math.cos(angle) * radius;
camera.lookAt(0, 0, 0);
}
}
这个逻辑的核心是:整个循环是一个长度为
2 * duration
的周期。前半段正向推进,后半段反向回退,于是画面在 12 秒内从起点绕到终点,在接下来 12 秒内按原路倒放。
height
使用
sin(angle * 2)
,会让摄像机在环绕的同时上下浮动,避免轨迹过于单调。
在
main.js
中使用它:
import * as THREE from 'three';
import { createTextParticles } from './particles.js';
import { CameraPath } from './cameraPath.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050508);
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 });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const points = createTextParticles('ATHENA');
scene.add(points);
const path = new CameraPath(12);
const clock = new THREE.Clock();
let elapsed = 0;
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
elapsed += delta;
path.update(camera, elapsed);
points.rotation.y += delta * 0.12;
points.rotation.x = Math.sin(elapsed * 0.2) * 0.05;
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
这里没有加后处理和鼠标交互,先保证核心循环能跑通。如果你打开页面后看到一个金色粒子文字在黑色空间中环绕穿梭,说明摄像机路径已经生效。
4.3 在路径中加入鼠标视差
摄像机路径已经给了很强的动感,但是纯粹自动运动会让观众感到距离感。加入鼠标视差,可以让画面在细微处跟随鼠标位置偏移,提升“可以控制”的感觉。
在
main.js
中加入全局鼠标变量:
let mouseX = 0;
let mouseY = 0;
window.addEventListener('mousemove', (event) => {
mouseX = (event.clientX / window.innerWidth - 0.5) * 2;
mouseY = (event.clientY / window.innerHeight - 0.5) * 2;
});
然后在
animate
中,相机位置根据鼠标偏移做叠加:
const cameraOffset = path.update(camera, elapsed);
camera.position.x += mouseX * 0.35;
camera.position.y += -mouseY * 0.25 + mouseX * 0.1;
camera.lookAt(0, 0, 0);
注意,
CameraPath.update
仍然负责基本轨迹,鼠标只是叠加微小偏移。这样做的好处是,即使鼠标不动,自动路径依然存在;鼠标移动时,观众会觉得场景与自己有轻微呼应。
5. 电影感从哪来:后期光晕、对称与配色
5.1 使用 EffectComposer 添加 Bloom
裸场景的粒子点只能算是“点”,加上泛光后才会变成柔和的光星,这是电影感的一个重要来源。Three.js 官方示例中自带后处理模块,可以直接引入。
在
main.js
中补充后处理相关代码:
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.2,
0.4,
0.85
);
composer.addPass(bloomPass);
然后把渲染改成:
composer.render();
UnrealBloomPass
的构造参数依次是:
| 参数 | 含义 | 默认值 | 调大效果 | 调小效果 |
|---|---|---|---|---|
| resolution | 渲染分辨率 | 视口尺寸 | 更清晰但更耗时 | 模糊但性能好 |
| strength | 光晕强度 | 1.0 | 画面过曝 | 几乎无光晕 |
| radius | 光晕扩散半径 | 0.4 | 光晕大范围扩散 | 光晕集中在高亮点 |
| threshold | 亮度阈值 | 0.85 | 只有很亮的点发光 | 大部分亮度都发光 |
threshold = 0.85
意味着只有亮度较高的像素会被勾出来变成光晕。粒子本身使用
AdditiveBlending
后会叠加,中心很亮,所以会产生明显的星云效果。
5.2 对称构图的实现思路
诺兰的很多画面喜欢对称构图。WebGL 里实现对称可以用几种思路:
-
在粒子生成阶段就做镜像,把
x坐标复制为-x,形成左右完全对称的点云。 -
复制整个场景,使用
Scale为(-1, 1, 1)的 Group,再错开透明度。 - 在后期处理中做屏幕空间镜像分屏。
对于这个项目,最简单的是第一种。在
particles.js
中增加一个
mirror
选项,当为
true
时,每个粒子同时生成两个位置:
if (options.mirror) {
positions.push(-px, py, pz);
colors.push(...color);
}
但要注意,文字本身不对称,直接镜像会形成类似万花筒的图案,视觉上不再是清晰文字,而更像神庙纹样。因此可以把
mirror
做成可选项,默认关闭。实现时也可以把原始粒子和镜像粒子放在两个
Points
对象中,分别控制透明度,营造“倒影感”。
5.3 配色与背景
背景使用接近黑的
0x050508
,比纯黑色多一点点蓝紫,衬托金色粒子更干净。信息文字用米白色
rgba(232, 216, 176, 0.7)
,模仿电影字幕。
在
index.html
中可以加入信息提示:
<div id="info">Hymn to Athena · A Nolan-inspired WebGL Experience</div>
对应 CSS:
#info {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
color: rgba(232, 216, 176, 0.7);
font-family: "Times New Roman", serif;
font-size: 14px;
letter-spacing: 4px;
text-transform: uppercase;
opacity: 0.8;
pointer-events: none;
z-index: 10;
}
这段文字虽然不在 WebGL 场景里,但会与 3D 画面叠加成完整的“片头字幕”观感。
6. 音效和交互:让页面成为一场仪式
6.1 浏览器音频策略
浏览器默认禁止网页在没有任何用户手势的情况下自动播放音频。因此,如果想要在页面加载后播放氛围音,必须通过一次点击或触摸来启动
AudioContext
。
对于这个项目,最自然的做法是:显示一个“点击开启声音”的提示按钮,用户点击后才初始化音频。这样既符合浏览器规范,也让用户有“主动进入仪式”的参与感。
6.2 Web Audio 生成低音 drone
新建
src/audio.js
:
let audioStarted = false;
export function startAthenaDrone() {
if (audioStarted) return;
audioStarted = true;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContextClass();
const master = ctx.createGain();
master.gain.value = 0.12;
master.connect(ctx.destination);
const frequencies = [55, 55.5, 110, 164.8];
frequencies.forEach((freq) => {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = freq;
osc.detune.value = Math.random() * 4 - 2;
const gain = ctx.createGain();
gain.gain.value = 0.2;
osc.connect(gain);
gain.connect(master);
osc.start();
});
const lfo = ctx.createOscillator();
lfo.frequency.value = 0.08;
const lfoGain = ctx.createGain();
lfoGain.gain.value = 0.04;
lfo.connect(lfoGain);
lfoGain.connect(master.gain);
lfo.start();
return ctx;
}
这段代码创建了几个低频正弦波振荡器。基频 55Hz 对应 A1,加上 55.5Hz 的轻微失谐,会产生类似大齐奏的“厚”感。110Hz 和 164.8Hz 提供泛音层。低频振荡器又让总音量缓慢起伏,像远处的风或人声暗涌。
在
main.js
中监听首次点击:
import { startAthenaDrone } from './audio.js';
window.addEventListener('click', startAthenaDrone, { once: true });
{ once: true }
保证只绑定一次,点击后不会再创建新的音频节点。
6.3 交互设计
除了鼠标视差和点击启动音频,还可以增加以下交互:
-
滚动页面时进度条前进,控制摄像机路径的
time。 - 键盘左右键切换不同文字主题。
- 按钮控制是否开启 Bloom 效果。
但最小版本不需要一次做完。建议先把鼠标视差和音频交互加入进去,体验已经足够完整。后续扩展时可以在此基础上增加 UI 控件。
7. 运行验证与性能排查
7.1 运行与验证清单
打开页面后,按顺序检查以下几点:
| 检查项 | 预期结果 | 如果不符合怎么处理 |
|---|---|---|
| 页面是否显示黑色背景 | 是,带轻微蓝紫色 |
检查
scene.background
是否设置
|
| 是否能看到金色粒子 | 是,且能看到“ATHENA”轮廓 |
检查
particles.js
的 Canvas 绘制逻辑
|
| 粒子是否持续运动 | 是,摄像机自动环绕 |
检查
CameraPath
是否在动画循环中被调用
|
| 鼠标移动是否有视差 | 是,速度缓和 |
检查
mousemove
和偏移量
|
| Bloom 光晕是否出现 | 是,亮处有柔光 |
检查
EffectComposer
和
UnrealBloomPass
|
| 点击页面后是否有低音 | 是,声音低沉不刺耳 |
检查
AudioContext
和音频节点连接
|
7.2 性能检测方法
如果页面卡顿,先用浏览器开发者工具的 Performance 面板记录一段交互,观察帧率和渲染耗时。然后按下面顺序排查:
-
是否把
renderer.setPixelRatio设置为Math.min(window.devicePixelRatio, 2),避免在 3 倍屏上渲染 3 倍像素。 -
step是否过小。调到 3 或 4,粒子数量会明显下降。 -
是否每次都新建对象。动画循环里不要创建新的
Vector3、Color、BufferAttribute。 -
是否开启了后处理。
UnrealBloomPass是 GPU 开销较大的效果,可以在移动端动态关闭。 -
检查
drawcalls。单个Points的 draw call 很低,但如果场景对象过多,也会增加开销。
7.3 移动端注意事项
移动端和桌面端有两点主要差异:触摸事件和性能上限。
鼠标视差在移动端需要适配触摸:
window.addEventListener('pointermove', (event) => {
const x = event.clientX;
const y = event.clientY;
mouseX = (x / window.innerWidth - 0.5) * 2;
mouseY = (y / window.innerHeight - 0.5) * 2;
});
pointermove
同时覆盖鼠标和触摸笔事件,在大部分现代浏览器中可用。
性能上,建议移动端开启低配模式:
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
if (isMobile) {
renderer.setPixelRatio(1);
points.material.size = 0.06;
}
低配模式下可以关闭或者降低 Bloom 参数,优先保证流畅性。
8. 常见问题清单与最佳实践
8.1 常见问题与排查表
实际运行中容易遇到的问题,整理成速查表:
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 页面黑屏且控制台无报错 |
renderer.render
没有被调用,或场景没有添加任何对象
|
检查
animate
循环,确认
scene.add(points)
|
在动画中打印
camera.position
确认相机在移动
|
| 粒子不显示 |
Canvas 字体未加载,或者
getImageData
读取到全透明数据
|
在 Canvas 绘制后手动把
visibility
置为可见验证
| 等页面字体加载完成后创建粒子,或使用系统字体 |
| 粒子文字模糊 | Canvas 尺寸太小,采样步长过大 | 查看粒子数量 |
增大 Canvas 宽高,或减小
step
|
| Bloom 太亮或全屏发白 |
strength
过大,或
threshold
过低
|
逐步降低
strength
到 0.5 观察
|
调高
threshold
,只让亮星发光
|
| 声音没有反应 |
没有触发用户手势就创建
AudioContext
| 检查点击事件是否绑定成功 |
使用
{ once: true }
的
click
监听
|
| 移动端粒子很少或很卡 |
pixelRatio
太高,
step
太小
| 查看设备像素比和粒子数量 | 切换到低配模式,关闭 Bloom |
控制台报
UnrealBloomPass is not a constructor
| 导入路径错误或 Three.js 版本不匹配 |
检查 import 路径是否有
/examples/jsm/
|
升级或降级
three
版本,确认官方示例路径
|
排查顺序也重要。遇到异常先看输入数据是否正确,再看文件路径和命名,然后检查依赖版本,最后看日志。不要一上来就以为是相机参数问题。
8.2 学习环境与生产环境差异
开发时使用 Vite dev server,它有热更新和错误提示,适合调试。但发布时不能把 dev server 当作生产服务。
构建命令:
npm run build
构建后生成
dist
目录。可以本地预览:
npm run preview
生产环境还需要额外考虑:
| 关注点 | 建议 |
|---|---|
| 资源路径 |
如果部署到子路径,在
vite.config.js
中配置
base: './'
|
| 加载性能 | 将 Three.js 拆分为独立 chunk,或使用 CDN 引入 |
| 错误监控 |
接入前端日志或错误上报服务,至少要有
window.onerror
|
| 音频降级 | 如果用户没有点击,不要自动播放音频,避免移动端流量消耗 |
| 浏览器兼容 |
WebGL 在老旧浏览器不支持,提供
WebGLRenderingContext
检测并显示降级提示
|
8.3 代码组织最佳实践
这个项目虽然不大,但代码组织会影响后续扩展和维护。以下几条直接可用:
-
不要把整个场景写在单个
main.js中,至少拆出particles、cameraPath、audio三个模块。 - 使用配置对象而不是魔法数字。比如粒子颜色、大小、摄像机半径、周期时长,都应该集中到一个配置文件中。
-
不要在动画循环里执行耗时操作,比如网络请求、大的数组排序、
getImageData等。 -
监听
visibilitychange事件,页面切到后台时暂停requestAnimationFrame,节省 CPU 和 GPU 资源。
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 停止更新相机时间
} else {
// 继续动画
}
});
8.4 发布前检查清单
最后发布前,用下面这个清单过一遍:
- 控制台没有报错和 warning。
-
npm run build成功,dist目录生成。 -
使用
npm run preview预览,确认资源路径正确。 - 在无痕窗口打开,确认 WebGL 和音频功能正常。
- 使用手机浏览器访问一次,确认不是白屏或严重掉帧。
- 关闭声音后页面体验仍然完整,不能把声音作为唯一叙事手段。
9. 扩展方向:从短片效果走向完整项目
9.1 交互叙事扩展
当前版本只有摄像机倒放和粒子旋转,还可以把故事层次做出来。比如把时间循环分成三个章节:
- 第一章:文字粒子从模糊聚合为清晰。
- 第二章:摄像机沿路径正放。
- 第三章:摄像机倒放,粒子逐渐散开。
这些状态可以用一个
state
变量控制,在动画循环中根据
elapsed
切换。配合文字提示,观众会明确感觉自己经历了一段叙事。
如果愿意继续深入,可以引入 GSAP 管理补间动画,在章节之间做平滑过渡。GSAP 和 Three.js 配合得很好,适合做摄像机变焦、场景淡入淡出、颜色渐变。
9.2 视觉技术与部署扩展
视觉方面,可以继续做自定义 Shader 粒子,让每个粒子根据距离产生大小变化,或者让粒子颜色在金色和蓝色之间渐变。还可以把普通
PointsMaterial
换成
ShaderMaterial
,实现更细腻的呼吸效果。
部署方面,可以把构建产物放到任意静态托管平台,例如 GitHub Pages、Netlify、Vercel。在
vite.config.js
中配置好
base
,上传
dist
目录即可。这样项目就能成为真正可分享的
Show HN
链接。
如果把整篇文章压缩成一句话,我会说:Hymn to Athena 不一定需要复杂的 3D 模型,一个 Canvas、一段循环路径和一层光晕,已经足够让人产生看了一部短片的错觉。下一步,把文字换成你自己的名字、城市名,或者一句歌词,观察粒子密度和摄像机节奏如何改变整页的气质。这是最值得做的小实验。
更多推荐
所有评论(0)