资源推荐
WhitestormJS:Three.js 项目结构化框架

用 Three.js 写项目太累?这个框架曾试图帮你把结构“收起来”

如果你用 Three.js 写过不止一个项目,大概率都会遇到同一个阶段:

第一个 demo 写得很顺,
到第二个、第三个项目时,你开始厌烦那些“永远一样”的代码。

创建场景、相机、渲染器,把 canvas 插进页面,写一个不会停的动画循环,再处理窗口尺寸变化。
这些事情你已经非常熟了,但问题在于——
它们在每个项目里都要从头来一遍。

whs.js(WhitestormJS)正是在这种背景下出现的。

whs.js 到底在做什么?

它让你不再从“搭架子”开始写 Three.js 项目。

在原生 Three.js 中,项目一开始,你往往要亲自把整套启动流程串起来:
什么时候创建场景,什么时候初始化相机,渲染器如何挂载,动画循环如何组织。

这些代码没有错,但它们有一个共同特点——
每个项目几乎都一样,却又无法省略。

whs.js 做的事情,就是把这一整套“基础流程”收进一个统一的结构里。
你不再需要反复实现这些步骤,而只需要告诉它:
这个项目需要渲染能力,需要场景,需要相机。

在此之上,whs.js 还顺手做了一件很重要的事:
它把 Three.js 中原本比较零散的写法,整理成了一种更集中的表达方式。

创建物体时,几何、材质、位置可以一次性描述清楚;
动画更新也不再被迫全部塞进同一个 animate() 里,而是可以拆成多段各自独立的逻辑。

所以你可以把 whs.js 理解为:

它没有改变 Three.js 能做什么,只是改变了你组织代码的方式。


为什么 Three.js 项目会越来越“难受”

在看代码之前,先设想一个非常普通的需求:

  • 一个 3D 场景

  • 一个地面

  • 一个会持续旋转的物体

  • 后续可能会加交互、动画,甚至物理

用 Three.js 写这个需求并不难,真正让人头疼的,是第二次改需求的时候

初始化代码已经写完了,但 animate 里开始变长;
某个物体的逻辑和别的逻辑交织在一起;
你逐渐发现,很难只“改一小块”,而不牵动其他部分。

案例

先从最外层结构看起。

// 创建 whs.js 应用实例,传入所需的模块数组
const app = new WHS.App([
  // ElementModule: 负责创建和管理 DOM 容器(canvas 元素)
  new WHS.ElementModule(),
  
  // SceneModule: 创建 Three.js 的 Scene 对象,所有 3D 物体都会添加到这个场景中
  new WHS.SceneModule(),
  
  // CameraModule: 创建相机,定义观察场景的视角
  // position 设置相机位置:x=0, y=10(高度),z=35(距离)
  new WHS.CameraModule({
    position: new THREE.Vector3(0, 10, 35)
  }),
  
  // RenderingModule: 创建 WebGL 渲染器,负责将场景绘制到屏幕
  // bgColor 设置背景色为深蓝灰色 (#162129)
  new WHS.RenderingModule({ bgColor: 0x162129 }),
  
  // ResizeModule: 监听窗口尺寸变化,自动调整渲染器和相机比例
  new WHS.ResizeModule()
]);

如果你写过 Three.js,这一段并不陌生。
它对应的正是你每个项目都会写的那套初始化流程。

不同之处在于,这些步骤不再以"过程代码"的形式散落在各处,
而是被收进了一个统一的入口里。

你不需要再关心它们的执行顺序,也不需要在多个文件之间来回跳转,
只需要声明:这个项目需要这些基础能力。


接着看立方体的创建。

const cube = new WHS.Box({
  geometry: { width: 5, height: 5, depth: 5 },
  material: new THREE.MeshBasicMaterial({ color: 0xf2f2f2 }),
  position: [0, 8, 0]
});
 
cube.addTo(app);

这段代码"像 Three.js,又不太像"。

在原生写法里,你通常会先创建 Mesh,再手动加到 scene,
然后在别的地方再调整位置或旋转。

而在这里,这些事情被合并成了一次描述。
这个立方体从一开始,就被当作一个完整的对象来看待,而不是需要到处配置的零件。


地面的创建方式也是同样的思路。

new WHS.Plane({
  geometry: { width: 120, height: 120 },
  material: new THREE.MeshBasicMaterial({ color: 0x447f8b }),
  rotation: { x: -Math.PI / 2 },
  position: [0, 0, 0]
}).addTo(app);

每一个 3D 元素,只关心"自己是什么样子",
而不是"我应该在什么时候被加进场景"。

当场景里的对象越来越多时,这种写法在可读性上的优势会逐渐体现出来。


接下来是动画部分。

new WHS.Loop(() => {
  cube.rotation.y += 0.02;
  cube.rotation.x += 0.01;
}).start(app);

在传统 Three.js 项目中,这段逻辑大概率会被塞进一个不断膨胀的 animate() 里;
而在这里,它被单独拎出来,只负责一件事:更新立方体的旋转。

你不需要思考"这段代码应该放在 animate 的哪个位置",
它天然就是一段独立的更新逻辑。


最后,整个应用通过一句话启动:

app.start();

从这一刻开始,渲染、更新、窗口变化都已经被接管了。
你需要关心的,只剩下场景里有哪些对象,以及它们各自做什么


完整代码

将上面的片段整合到一起,你会得到一个完整可运行的示例:

 
 
  
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>whs.js Demo - Rotating Cube</title>
    <style>
      html,
      body {
        margin: 0;
        height: 100%;
        overflow: hidden;
        background: #0f1418;
      }
    </style>
    <!-- Three.js r92 (whitestorm.js 2.x 对 three 版本较敏感,使用 r92 更稳) -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r92/three.min.js"></script>
    <!-- WhitestormJS / whs.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/whitestorm.js/2.1.4/whs.min.js"></script>
  
  
    <script>
      // 1) 创建应用并装配基础模块
      const app = new WHS.App([
        new WHS.ElementModule(), // 挂载到 DOM(默认挂到 body)
        new WHS.SceneModule(),   // 创建 Scene
        new WHS.CameraModule({   // 创建 Camera
          position: new THREE.Vector3(0, 10, 35)
        }),
        new WHS.RenderingModule({ // 创建 Renderer
          bgColor: 0x162129
        }),
        new WHS.ResizeModule()   // 自动处理窗口尺寸变化
      ]);
      // 2) 立方体
      const cube = new WHS.Box({
        geometry: { width: 5, height: 5, depth: 5 },
        material: new THREE.MeshBasicMaterial({ color: 0xf2f2f2 }),
        position: [0, 8, 0]
      });
      cube.addTo(app);
      // 3) 地面
      new WHS.Plane({
        geometry: { width: 120, height: 120 },
        material: new THREE.MeshBasicMaterial({ color: 0x447f8b }),
        rotation: { x: -Math.PI / 2 },
        position: [0, 0, 0]
      }).addTo(app);
      // 4) 动画循环:让立方体旋转
      new WHS.Loop(() => {
        cube.rotation.y += 0.02;
        cube.rotation.x += 0.01;
      }).start(app);
      // 5) 启动
      app.start();
    </script>
  

whs.js 和 React Three Fiber 的区别

如果你关注过 Three.js 的生态,可能会发现一个现象:
提到"用框架简化 Three.js 开发",现在大部分人会想到 React Three Fiber(r3f)
而 whs.js 似乎很少被提起。

这两者解决的问题其实很接近,但路径完全不同。

whs.js 是纯 JavaScript 框架,把 Three.js 的初始化流程封装成模块化结构,不依赖任何前端框架。

React Three Fiber 把 Three.js 带进 React 的世界,用 JSX 描述 3D 场景,交给 React 的渲染机制管理。

用代码对比会更清楚:

// whs.js 的写法
const cube = new WHS.Box({
  geometry: { width: 5, height: 5, depth: 5 },
  material: new THREE.MeshBasicMaterial({ color: 0xf2f2f2 }),
  position: [0, 8, 0]
});
cube.addTo(app);
// React Three Fiber 的写法
<mesh position="{[0," 8,="" 0]}=""><boxgeometry args="{[5," 5,="" 5]}=""><meshbasicmaterial color="#f2f2f2"></meshbasicmaterial></boxgeometry></mesh>

前者是"封装好的对象",后者是"声明式的组件"。

为什么 r3f 更流行?

whs.js 出现得更早,也确实解决了 Three.js 项目的结构问题,但它没能像 r3f 那样形成广泛的影响力。

最关键的原因是 React 生态的崛起。whs.js 诞生时,React 还没有完全统治前端开发;但到 2020 年前后,React 已经成为主流,r3f 恰好在这个时间点上成熟起来。对于已经在用 React 的开发者,r3f 的接入成本几乎为零——它就是"React 的一部分"。

其次是 前端工作流的变化。组件化思维已经普及,用 JSX 描述 UI(包括 3D UI)变得自然直观;而 whs.js 的"模块化思路"与现代"组件化工作流"之间,始终有一层隔阂。加上 r3f 背后有 Poimandres 团队的持续维护,以及 drei、zustand 等配套工具,它形成了一整套开发体系。

whs.js 和 r3f 的出发点几乎一致:让 Three.js 的代码更容易组织。只是前端开发的大环境变了,组件化框架的普及,让 r3f 这条路走得更顺。

whs.js 没有"错",只是站在了一个逐渐被边缘化的技术路线上。


写在最后

whs.js 本身可能不会成为你的首选工具,但它的设计思路,值得在任何"重复搭架子"的项目中参考。

可以迁移的设计模式

1. 模块化初始化

不要让初始化代码散落在各处,而是把它们收进独立的模块。
每个模块只负责一件事:ElementModule 管 DOM,SceneModule 管场景,CameraModule 管相机。

这种思路不仅适用于 Three.js。
任何需要反复初始化的库(比如 Canvas 2D、WebGL、音频引擎),都可以用这种方式组织代码。

你需要的不是"一个超级函数把所有东西初始化完",
而是"一组可以自由组合的模块,让你声明项目需要什么能力"。

2. 声明式配置

不要写"先创建 A,再配置 B,最后把它们关联起来"的流程代码,
而是用一个对象把所有配置描述清楚。

// 不要这样
const geometry = new THREE.BoxGeometry(5, 5, 5);
const material = new THREE.MeshBasicMaterial({ color: 0xf2f2f2 });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(0, 8, 0);
scene.add(cube);
 
// 可以这样
const cube = new Box({
  geometry: { width: 5, height: 5, depth: 5 },
  material: { color: 0xf2f2f2 },
  position: [0, 8, 0]
});
cube.addTo(scene);

这种写法的好处不是"代码更短",
而是"配置和行为分离了"。

3. 独立的更新逻辑

不要把所有动画逻辑塞进一个不断膨胀的 animate() 里,
而是让每个对象的更新逻辑独立出来。

// 不要这样
function animate() {
  cube.rotation.y += 0.02;
  sphere.position.y = Math.sin(Date.now() * 0.001) * 5;
  camera.position.x = mouse.x * 10;
  // ... 越来越长
}
 
// 可以这样
new Loop(() => cube.rotation.y += 0.02).start();
new Loop(() => sphere.position.y = Math.sin(Date.now() * 0.001) * 5).start();
new Loop(() => camera.position.x = mouse.x * 10).start();

当项目变复杂时,你会发现这种拆分的价值。

4. 统一的对象接口

不要让不同类型的对象有不同的创建方式,
而是让它们遵循同样的规则。

这样做的好处是,你在写第 N 个对象时,
不需要去翻文档查"这个东西应该怎么创建"。


这些思路适用于所有"重复性工作"

whs.js 的核心价值不是"让 Three.js 更强",而是"让重复的事情不再重复"。

这种思路可以迁移到任何场景:

  • 如果你在写 Canvas 2D 项目,每次都要手动创建 canvas、获取 context、设置尺寸、监听 resize,
    你可以封装一个 CanvasApp 模块来统一处理这些事。

  • 如果你在写数据可视化,每次都要初始化 SVG、设置 viewBox、创建 g 容器,
    你可以把这些步骤收进一个配置对象。

  • 如果你在写游戏,每次都要创建场景、加载资源、启动循环,
    你可以把它们拆成独立模块。

关键不是"用什么框架",
而是"意识到自己在反复做同一件事,并主动把它们收拢起来"。