一个名为 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 最小功能定义

为了让这个创意能落地,先明确一个“最小可用版本”需要包含哪些功能:

  1. 一个黑色背景的 Three.js 场景。
  2. 由文字生成的粒子点云,主题词为 ATHENA
  3. 摄像机沿固定路径运动,并在合适时间点倒放,形成时间循环感。
  4. 鼠标移动可以产生轻微视差,增强交互感。
  5. 点击页面后,用 Web Audio 生成一段低音氛围音。
  6. 使用 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 里实现对称可以用几种思路:

  1. 在粒子生成阶段就做镜像,把 x 坐标复制为 -x ,形成左右完全对称的点云。
  2. 复制整个场景,使用 Scale (-1, 1, 1) 的 Group,再错开透明度。
  3. 在后期处理中做屏幕空间镜像分屏。

对于这个项目,最简单的是第一种。在 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 面板记录一段交互,观察帧率和渲染耗时。然后按下面顺序排查:

  1. 是否把 renderer.setPixelRatio 设置为 Math.min(window.devicePixelRatio, 2) ,避免在 3 倍屏上渲染 3 倍像素。
  2. step 是否过小。调到 3 或 4,粒子数量会明显下降。
  3. 是否每次都新建对象。动画循环里不要创建新的 Vector3 Color BufferAttribute
  4. 是否开启了后处理。 UnrealBloomPass 是 GPU 开销较大的效果,可以在移动端动态关闭。
  5. 检查 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、一段循环路径和一层光晕,已经足够让人产生看了一部短片的错觉。下一步,把文字换成你自己的名字、城市名,或者一句歌词,观察粒子密度和摄像机节奏如何改变整页的气质。这是最值得做的小实验。

更多推荐