前言
不知道掘金有多少在神经影像行业工作的开发,但是我是,之前用过的一个影像展示是一款年代很久远的库,曾一直想重构但是影像这方面的资料少之甚少,现在得益于ai的发展,资料检索起来方便了很多,所以我就开发了这么一款神经影像可视化库。
当然,业内也有比较成熟的VTK.js和Cornerstone等成熟的库,但是都太过于繁重,没有轻量的现代语法的库,可以嵌入到任意 Web项目。
neuroviz 正是为了填补这个空白而设计的:一个基于 TypeScript + Three.js + Canvas 2D 的浏览器端神经影像可视化库,支持 GIfTI、FreeSurfer、MNI OBJ 格式的三维脑表面渲染,以及 NIfTI 格式的三切面体积查看,提供完整的 TypeScript 类型定义。
本文将系统介绍 neuroviz 的整体架构、两大子系统的实现细节、关键技术决策以及在开发过程中遇到的难点与解决方案。
一、整体架构
1.1 两个独立的子系统
neuroviz 在设计上分为两个相对独立的子系统,分别对应神经影像可视化的两种主流场景:
neuroviz
├── Surface 子系统(三维表面渲染)
│ ├── SurfaceViewer 门面类,对外暴露所有公共 API
│ ├── Scene Three.js 场景封装
│ ├── Interaction 鼠标交互(旋转/平移/缩放)
│ ├── MeshBuilder 几何体构建与颜色映射
│ └── AnnotationManager 顶点标记点管理
│
└── Volume 子系统(二维切片渲染)
├── VolumeViewer 组合三个切面,统一管理
└── SliceRenderer 单轴切片渲染(可独立使用)
两个子系统完全独立,可以单独使用,也可以联动——例如点击三维表面上的某个顶点,通过 tkRas 坐标变换矩阵自动跳转到体积查看器对应的位置,实现表面与体积的坐标联动。
1.2 共享基础设施
两个子系统共享以下基础模块:
| 模块 |
位置 |
作用 |
EventEmitter |
src/core/event-emitter.ts |
轻量级事件系统,两个 Viewer 都继承自它 |
PathOrFile |
src/types/index.ts |
统一的文件输入接口(URL 或 ArrayBuffer) |
| 公共类型定义 |
src/types/index.ts |
VolumeData、ModelData、TkRas 等共享数据结构 |
这种划分让两个子系统保持了 API 的一致性,同时避免了相互耦合。
二、Surface 子系统:三维脑表面渲染
Surface 子系统是 neuroviz 的核心功能,基于 Three.js WebGL 渲染引擎,支持加载不同格式的大脑皮层表面模型,并在其上叠加功能数据(Overlay)的颜色映射。
2.1 文件格式支持
神经影像领域有多种脑表面文件格式,neuroviz 通过 Web Worker 并行解析,支持以下三种:
GIfTI(.gii、.gii.gz)
GIfTI 是神经影像社区广泛使用的通用脑成像文件格式。其内部结构是 XML,顶点坐标和面索引以 Base64 编码的二进制数据嵌入在 XML 节点中,同时支持 zlib 压缩(.gii.gz)。解析流程:
- 如果是
.gz 文件,先用 pako 进行 zlib 解压
- 用
DOMParser 解析 XML
- 找到
<DataArray> 节点,提取 Base64 数据并解码
- 根据
DataType 字段(NIFTI_TYPE_FLOAT32 等)转换为对应的 TypedArray
由于 XML 解析和大量字符串操作在大型模型(数十万顶点)上耗时可能达到数秒,GIfTI 解析必须放在 Web Worker 中执行,避免阻塞主线程。
FreeSurfer 二进制格式
FreeSurfer 是神经影像分析领域最常用的软件套件,其输出的表面文件没有固定的文件扩展名(通常命名为 lh.pial、rh.white 等)。neuroviz 通过读取文件头部的魔数(Magic Number)自动识别:
-
0xFF 0xFF 0xFE(16进制):三角形表面文件(Triangle Surface)
-
0xFF 0xFF 0xFF:曲率文件(Curvature)
三角形表面文件的结构:魔数(3字节)→ 注释字符串 → 顶点数 → 面数 → 顶点坐标数组(Float32,大端序) → 面索引数组(Int32,大端序)。
由于 FreeSurfer 的真实皮层表面通常有 15 万到 30 万顶点,加载时间较长,同样放在 Worker 中处理。
MNI OBJ 格式(.obj、.obj.gz)
这是 McGill Neurological Institute(蒙特利尔神经学研究所)的自定义 OBJ 格式,与标准 Wavefront OBJ 格式不同,不能互换使用。其文本格式包含顶点坐标、法线、颜色和面索引。neuroviz 内置了专用的 MNI OBJ 解析器处理此格式。
2.2 Web Worker 架构
Surface 子系统的文件解析全部在 Web Worker 中进行,主线程只负责接收解析结果并构建 Three.js 几何体。架构示意:
主线程 Worker
──────────────────────────────────────────────────────
SurfaceViewer.load()
│
├── new Worker(gifti.worker.js) ──────────→ 解析 XML + Base64
├── new Worker(overlay.worker.js) ─────────→ 解析文本 + 解压
│ │
│ ←──── postMessage(ModelData) ────────────┘
│ Transferable: Float32Array/Uint32Array
│
└── MeshBuilder.build(modelData)
└── new THREE.BufferGeometry()
Transferable 对象传输
Worker 和主线程之间交换的主要数据是 Float32Array(顶点坐标、法线、颜色)和 Uint32Array(面索引)。这些 TypedArray 通过 Transferable 接口传输,而不是序列化拷贝:
// Worker 内部
self.postMessage(
{ vertices, indices, normals },
[vertices.buffer, indices.buffer, normals.buffer] // 转移所有权
);
Transferable 传输会将 ArrayBuffer 的所有权从 Worker 转移给主线程,整个过程是零拷贝的,耗时在 1ms 以内。而如果采用默认的结构化克隆(Structured Clone),30 万顶点的 Float32Array(约 3.6MB)需要完整复制,耗时可能达到数十毫秒。
Worker 路径解析
new Worker(url) 需要一个可访问的脚本 URL,这在不同的部署环境下是个挑战:直接引用 dist 目录、通过 CDN 加载、集成到 Vite/Webpack 项目中,Worker 文件的路径都不一样。
neuroviz 通过 worker-config.ts 实现了两层路径解析策略:
let _baseUrl: URL | null = null;
export function getWorkerUrl(filename: string): URL {
if (_baseUrl) return new URL(filename, _baseUrl); // 用户配置优先
return new URL(`./${filename}`, import.meta.url); // 默认:相对当前模块
}
import.meta.url 在 ESM 环境下指向当前模块文件的 URL,默认情况下 Worker 文件会被解析为与主 bundle 同级目录,覆盖了最常见的 npm 包使用场景。对于特殊环境,用户在应用初始化时调用一次即可:
import { setWorkerBaseUrl } from 'neuroviz';
setWorkerBaseUrl('https://cdn.example.com/workers/');
Worker bundle 格式选择 IIFE 而非 ESM,原因是兼容性:new Worker(url) 默认加载经典脚本(classic script),不支持 ESM 的 import 语句。Safari 对 Module Worker 的支持也较晚。IIFE 格式将所有依赖(pako、gifti-reader-js 等)内联进单个文件,加载后直接可用,不需要额外的网络请求。
2.3 MeshBuilder:从数据到 Three.js 几何体
MeshBuilder 负责将 Worker 解析好的原始数据构建成 Three.js 可以渲染的 BufferGeometry。
几何体构建
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3));
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
if (normals && normals.length > 0) {
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
} else {
geometry.computeVertexNormals(); // 从面索引自动计算法线
geometry.normalizeNormals();
}
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
材质使用 MeshPhongMaterial 并开启 vertexColors: true,让每个顶点的颜色从 color attribute 中读取——这是 Overlay 颜色映射的基础。DoubleSide 确保表面内外都能被渲染,对于半透明模式尤其重要。
Overlay 颜色映射
Overlay 是一维的浮点数数组,每个值对应一个顶点,表示该顶点处的功能数据(如激活强度、皮层厚度等)。颜色映射的过程:
- 遍历 Overlay 数据,找出最小值
lo 和最大值 hi(若用户指定了 range 则使用指定范围)
- 对每个顶点值,将其归一化到
[0, 1] 区间
- 乘以颜色映射表的长度,取对应颜色条目(
RGB 三通道,范围 0–1)
- 写入
BufferGeometry 的 color attribute
这个过程中,computeOverlayParams 和 mapValueToColor 被提取为静态方法,由 build() 和 updateColors() 两处共用,避免逻辑重复。
顶点空间索引
几何体构建完成后,MeshBuilder 立即建立一个空间位置索引:
private buildPositionIndex(): void {
const attr = this.mesh.geometry.getAttribute("position");
for (let i = 0; i < attr.count; i++) {
const key = `${attr.getX(i)},${attr.getY(i)},${attr.getZ(i)}`;
if (!this.positionIndex.has(key)) {
this.positionIndex.set(key, i);
}
}
}
Map<string, number> 以 "x,y,z" 字符串为键,将 getIndexByPosition 的时间复杂度从 O(n) 降至 O(1)。对于 30 万顶点的模型,这是必要的优化。
当传入坐标存在浮点精度误差时(射线求交得到的交点坐标往往有微小偏差),精确字符串匹配会失败,此时降级为带 epsilon 容差的 O(n) 线性搜索作为保底。
2.4 交互系统
Interaction 类负责鼠标交互:左键拖拽旋转、右键拖拽平移、滚轮缩放。它接受任意 THREE.Object3D 作为操作目标,完全不依赖 Scene。
旋转使用 rotateOnWorldAxis——在世界坐标系中旋转,而非对象自身坐标系——这样无论当前旋转状态如何,拖拽方向与模型旋转方向始终对应,避免了万向锁导致的反直觉操作。
性能细节
mousemove 事件频率极高(60fps+)。旋转计算需要两个轴向量 (0,1,0) 和 (1,0,0),早期版本每次事件都创建 new THREE.Vector3(0, 1, 0),产生不必要的对象分配。优化后提取为 static readonly 类常量:
private static readonly AXIS_Y = new THREE.Vector3(0, 1, 0);
private static readonly AXIS_X = new THREE.Vector3(1, 0, 0);
类常量只创建一次,整个生命周期内所有事件复用,减少了 GC 触发频率。
区分点击与拖拽
旋转结束时,浏览器也会触发 click 事件。如果不加区分,用户旋转完模型后松开鼠标,就会误触发顶点拾取(Raycasting)。
Interaction 在 mousedown 时记录起始坐标,mouseup 时记录结束坐标:
readonly mousemovePosition: [THREE.Vector2, THREE.Vector2] = [
new THREE.Vector2(0, 0), // mousedown 时记录
new THREE.Vector2(0, 0), // mouseup 时记录
];
SurfaceViewer 在 click 事件处理器里检查两点距离,距离大于 0 说明是拖拽,直接 return:
if (mp[0].distanceTo(mp[1]) > 0) return; // 是拖拽,忽略
2.5 顶点拾取(Raycasting)
点击表面时,通过 Three.js 的 Raycaster 将屏幕坐标转换为 3D 射线,找到射线与网格的交叉点,取出对应的顶点索引。
这里有一个隐蔽的 bug:线框模式(Wireframe)的实现是在原始 mesh 上挂载一个 THREE.LineSegments 子对象。如果 Raycasting 使用 recursive: true 递归检测子对象,LineSegments 会参与检测,且由于线段没有面积,它比 Mesh 更容易被射线命中,导致点击表面时实际拾取的是不可见的线框而非网格。
解决方案是双重过滤:
const intersects = raycaster
.intersectObjects(this.scene.modelGroup.children, false) // 非递归,跳过子对象
.filter((i) => i.object instanceof THREE.Mesh); // 只接受 Mesh,排除 LineSegments
const hit = intersects.find((i) => this.handles.has(i.object.name)); // 只认注册的模型
2.6 标记点系统(AnnotationManager)
AnnotationManager 随每个 load() 调用一起返回,允许在任意顶点处放置球形标记。每个标记是一个 THREE.SphereGeometry 构建的小球,作为对应 mesh 的子对象挂载在场景中,自动跟随模型旋转和缩放。
生命周期守卫
当用户调用 viewer.removeModel(name) 后,模型的 mesh 从场景中移除(mesh.parent === null),但外部代码可能仍持有该模型的 AnnotationManager 引用并继续调用 add()。没有守卫的情况下,标记球会被挂到一个"孤岛" mesh 上——它不在场景里,不被渲染,但占用内存,且后续行为不可预期。
add(vertex: number, options = {}): Annotation | null {
if (!this.#mesh.parent) return null; // mesh 已从场景移除,拒绝操作
// ...
}
返回 null 让调用方可以明确感知到操作失败。
激活状态
activate(vertex) 将指定标记高亮(切换到 activeColor),其他标记恢复原色:
activate(vertex: number): void {
this.annotations.forEach((annotation) => {
const mat = annotation.marker.material as THREE.MeshPhongMaterial;
mat.color.setHex(
annotation.vertex === vertex ? this.activeColor : annotation.color
);
});
}
每次添加标记时会自动 activate 新标记,提供视觉反馈。
2.7 多模型与交互目标切换
SurfaceViewer 使用 Map<string, ModelHandle> 管理所有已加载的模型。ModelHandle 是句柄模式的体现——它封装了"对哪个模型操作"的上下文,使得多模型场景下的操作自然简洁:
const { handle: lh } = await viewer.load({ model: { url: 'lh.pial.gii', name: 'lh' } });
const { handle: rh } = await viewer.load({ model: { url: 'rh.pial.gii', name: 'rh' } });
lh.setTransparency(0.5); // 只影响左脑半球
rh.setVisible(false); // 只隐藏右脑半球
viewer.setTransparency(0.8); // 同时影响所有模型
setInteractionTarget 允许把鼠标交互目标从整个模型组切换到单个模型:
viewer.setInteractionTarget(lh); // 只旋转左脑,右脑静止
viewer.setInteractionTarget('group'); // 恢复所有模型联动
实现上,每次切换都是销毁旧 Interaction 实例(通过 AbortController.abort() 一次性移除所有事件监听),再 new 一个新的绑定新目标——不需要 Interaction 自身感知"切换"这件事,没有状态残留风险。
三、Volume 子系统:NIfTI 体积数据三切面渲染
Volume 子系统基于 Canvas 2D API,以轴向(Axial)、冠状(Coronal)、矢状(Sagittal)三个正交切面展示 NIfTI 格式的脑体积数据。
3.1 NIfTI 解析
NIfTI(Neuroimaging Informatics Technology Initiative)是神经影像领域最常用的体积数据格式,有 .nii(单文件)和 .nii.gz(gzip 压缩)两种形式。
为什么不用 Worker?
与 GIfTI 不同,NIfTI 是纯二进制格式:
- 头部:固定 348 字节(NIfTI-1)或 544 字节(NIfTI-2),包含维度、数据类型、体素尺寸等元信息
- 数据体:紧跟头部之后,是连续的体素数值数组
解析过程完全基于 DataView 读取头部字段,再将数据体直接 cast 成对应的 TypedArray(Int16Array、Float32Array、Uint8Array 等)。整个过程没有 XML 解析、没有 Base64 解码,对于 100MB 的 NIfTI 文件,解析时间通常在 50–100ms 以内——主线程完全可以接受。
如果用 Worker,反而会增加 ArrayBuffer 序列化传输的额外开销,得不偿失。
轴向信息(AxisInfo)
NIfTI 头部包含每个空间轴的完整信息:
type AxisInfo = {
name: AxisName; // "xspace" | "yspace" | "zspace"
space_length: number; // 该轴的体素数量
step: number; // 体素物理尺寸(mm),可为负(表示方向翻转)
start: number; // 起始坐标(mm)
direction_cosines: [number, number, number]; // 方向余弦
offset: number; // 在一维 data 数组中的步长
};
offset 是关键字段。NIfTI 数据在内存中以一维数组存储,给定体素坐标 (x, y, z),其在数组中的索引为:
idx = x * header.xspace.offset + y * header.yspace.offset + z * header.zspace.offset
offset 由文件的维度顺序决定(慢轴到快轴),neuroviz 在解析头部时自动计算。
3.2 SliceRenderer:单轴切片渲染
SliceRenderer 是体积渲染的核心,负责将三维体素数据中的一个二维切面渲染到 <canvas> 元素上。
轴布局
每个轴的切面由两个正交轴构成:
private static readonly LAYOUT: Record<SliceAxis, { col: AxisName; row: AxisName }> = {
xspace: { col: "yspace", row: "zspace" }, // 矢状面
yspace: { col: "xspace", row: "zspace" }, // 冠状面
zspace: { col: "xspace", row: "yspace" }, // 轴向面
};
col 轴对应 canvas 的水平方向,row 轴对应垂直方向。
渲染流程
draw(): void {
const { col, row } = SliceRenderer.LAYOUT[this.axis];
const colLen = header[col].space_length;
const rowLen = header[row].space_length;
const imageData = this.ctx.createImageData(colLen, rowLen);
const sliceOff = header[this.axis].offset * this.sliceIndex;
for (let r = 0; r < rowLen; r++) {
for (let c = 0; c < colLen; c++) {
const voxIdx = sliceOff + header[row].offset * r + header[col].offset * c;
const raw = this.volume.data[voxIdx];
// 窗宽窗位映射
const normalized = clamp((raw - lower) / (upper - lower), 0, 1);
// LUT 查找
const lutIdx = Math.round(normalized * 255) * 3;
pixels[...] = lut[lutIdx], lut[lutIdx+1], lut[lutIdx+2];
}
}
this.ctx.putImageData(imageData, 0, 0);
if (this.showCursor) this.drawCursor(colLen, rowLen);
}
窗宽窗位(Window/Level)
窗宽窗位是医学影像显示的标准调参方式:
-
Window Level(窗位):显示范围的中心值
-
Window Width(窗宽):显示范围的宽度
体素值在 [level - width/2, level + width/2] 范围内线性映射到 [0, 1],范围外的值截断到 0 或 1。这允许用户针对感兴趣的组织类型(灰质、白质、脑脊液)调整最佳对比度。
各向异性体素的显示矫正
这是开发过程中发现并修复的一个实际 bug。
SliceRenderer 把每个体素渲染为一个 canvas 像素,canvas 的 width 和 height 设置为体素数量(如 256×160)。CSS 再把 canvas 拉伸填满容器,如果容器宽高比与体素数量比不一致,图像就会变形。
更深层的问题是各向异性体素:如果 X 方向的体素物理尺寸(step)是 1mm,Z 方向是 2mm,那么一个 256×160 体素的切面物理上是 256mm × 320mm,而不是 256×160 的正方形。渲染时每个体素用一个像素表示,纵轴被"压缩"了一半。
修复方案:在 setVolume() 时,根据物理尺寸设置 CSS 的 aspect-ratio:
const physW = header[col].space_length * Math.abs(header[col].step);
const physH = header[row].space_length * Math.abs(header[row].step);
this.canvas.style.aspectRatio = `${physW} / ${physH}`;
canvas 的内部像素分辨率(width/height 属性)保持体素数量不变,aspect-ratio 只影响 CSS 显示尺寸,不影响渲染逻辑和坐标换算——优雅地解耦了"渲染精度"和"显示比例"两个关注点。
坐标换算
点击 canvas 时,需要将 CSS 像素坐标转换为体素坐标。canvas 有两套坐标系:CSS 像素(受 CSS 缩放影响)和 canvas 像素(绘制分辨率),两者比例不同:
// VolumeViewer 处理点击
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left; // CSS 像素
const py = e.clientY - rect.top;
const scaleX = canvas.width / rect.width; // canvas 像素 / CSS 像素
const scaleY = canvas.height / rect.height;
const voxel = renderer.canvasToVoxel(px * scaleX, py * scaleY);
这样无论 canvas 被 CSS 如何缩放(大屏、高 DPR 设备),体素坐标换算都是准确的。
3.3 VolumeViewer:三切面联动
VolumeViewer 是三个 SliceRenderer 的组合器,负责:
- 加载 NIfTI 文件,解析后分发给三个渲染器
- 点击任意切面时,更新三个切面的位置和游标,并广播
positionchange 事件
- 全局的窗宽窗位、颜色映射、游标显示控制同步到三个渲染器
三切面联动的游标同步逻辑:
private syncCursors(): void {
// 矢状面(x 轴切面):canvas x=yspace, y=zspace
this.renderers.xspace.setCursor(this.position.yspace, this.position.zspace);
// 冠状面(y 轴切面):canvas x=xspace, y=zspace
this.renderers.yspace.setCursor(this.position.xspace, this.position.zspace);
// 轴向面(z 轴切面):canvas x=xspace, y=yspace
this.renderers.zspace.setCursor(this.position.xspace, this.position.yspace);
}
SliceRenderer 单独暴露的意义
SliceRenderer 作为公共 API 单独导出,而不仅仅是 VolumeViewer 的内部实现。原因是灵活性:并非所有场景都需要三切面联动。有人可能只需要一个轴向切面,或者需要四宫格布局(三切面 + 一个 3D 视图)。单独暴露后,用户可以自由组合任意数量的 SliceRenderer,构建自定义的影像查看器。
四、共享基础设施
4.1 EventEmitter
SurfaceViewer 和 VolumeViewer 都继承自 EventEmitter,统一的事件接口让两者可以以相同的方式被使用:
surfaceViewer
.on('load', ({ handle, annotations }) => { ... })
.on('vertexClick', ({ index, point, volCoord }) => { ... });
volumeViewer
.on('load', (volume) => { ... })
.on('positionchange', ({ xspace, yspace, zspace }) => { ... });
EventEmitter 自实现,仅 30 行代码,不依赖任何外部库。选择自实现而非 Node.js events polyfill 的原因:这是一个浏览器库,不应引入 Node.js 环境依赖。选择自实现而非 RxJS 的原因:RxJS 约 50KB,对于仅需 on/off/emit/once 四个方法的场景是严重过度设计。
once 的实现利用闭包实现触发后自移除:
once<T>(event: string, handler: EventHandler<T>): this {
const wrapper: EventHandler<T> = (data) => {
handler(data);
this.off(event, wrapper); // 执行后从监听器列表移除自身
};
return this.on(event, wrapper);
}
4.2 PathOrFile 判别联合类型
神经影像的工作流多样:数据可能来自服务器(URL)、本地文件上传(File → ArrayBuffer)、或者内存中已有的缓冲区。为了统一处理,neuroviz 定义了 PathOrFile 判别联合类型:
export type PathOrFile =
| { url: string; file?: never }
| { file: ArrayBuffer; url?: never };
never 标记互斥字段——两者不能同时存在——TypeScript 可以在条件分支内精确收窄类型,无需非空断言:
function resolveSource(source: PathOrFile, fromURL, fromFile) {
return source.url
? fromURL(source.url) // 此处 url 类型是 string,不是 string | undefined
: fromFile(source.file!);
}
如果用简单的可选字段 { url?: string; file?: ArrayBuffer },{} 也是合法值,且 TypeScript 无法自动收窄类型,需要到处写 source.url!。
这种设计还统一了 Surface 和 Volume 的加载 API——两者都接受同一个 PathOrFile,用户可以在两者之间自由切换,不需要记忆不同的方法名。
五、内存管理与资源释放
WebGL 和 DOM 事件监听器是浏览器中最常见的两类内存泄漏来源。neuroviz 对此进行了系统性的处理。
5.1 WebGL 资源释放
SurfaceViewer.dispose() 形成完整的资源清理链:
dispose(): void {
this.abortController.abort(); // 一次性清理所有 DOM 事件监听
this.interaction.dispose(); // Interaction 的事件监听
this.handles.forEach((handle) => {
const mesh = handle.meshBuilder.mesh;
mesh.geometry.dispose(); // 释放 GPU 顶点缓冲区(VBO)
mesh.material.dispose(); // 释放 GPU 材质资源
});
this.handles.clear();
this.scene.dispose(); // 取消 rAF + 断开 ResizeObserver + 销毁 WebGL context
}
THREE.BufferGeometry.dispose() 和 THREE.Material.dispose() 释放的是 GPU 端的内存(顶点缓冲区 VBO、纹理等),这部分内存不受 JavaScript GC 管理,必须手动释放。
5.2 AbortController 统一管理事件监听
传统的事件监听清理方式需要保存每个 handler 的引用才能调用 removeEventListener,繁琐且容易遗漏。neuroviz 统一使用 AbortController 的 signal 机制:
// 注册事件
element.addEventListener('mousemove', handler, {
signal: this.abortController.signal
});
// 一次性清理所有注册在这个 signal 上的监听器
this.abortController.abort();
AbortController 是浏览器原生 API,无需任何 polyfill。调用一次 abort(),所有带该 signal 的监听器全部自动移除,不需要逐个手动 removeEventListener,也不需要保存 handler 引用。
5.3 ResizeObserver
Surface 查看器需要随容器尺寸变化更新渲染器和相机参数。传统方式是监听 window.resize,但这在容器尺寸变化不涉及窗口大小时(如 flex 布局中面板拖拽调整)无效。neuroviz 使用 ResizeObserver 直接观测容器元素:
private bindResize(): void {
this.resizeObserver = new ResizeObserver(() => {
const w = this.container.offsetWidth;
const h = this.container.offsetHeight || 1;
this.renderer.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
});
this.resizeObserver.observe(this.container);
}
dispose() 时通过 resizeObserver.disconnect() 停止观测,避免泄漏。
六、构建系统
6.1 为什么选 Rollup
neuroviz 选择 Rollup 而非 Webpack、Vite 或 tsup 的核心原因是多入口 IIFE 打包需求:
export default [
// 主入口:ESM + UMD 双格式
{
input: 'src/index.ts',
output: [{ format: 'esm' }, { format: 'umd', name: 'neuroviz' }]
},
// 四个 Worker 入口:各自打成 IIFE
...['gifti', 'mni-obj', 'freesurfer', 'overlay'].map(name => ({
input: `src/worker/${name}.worker.ts`,
output: { file: `dist/${name}.worker.js`, format: 'iife' }
}))
];
Worker 文件必须打成 IIFE(立即执行函数表达式),因为 new Worker(url) 默认加载经典脚本,不支持 ESM 的 import 语句。Rollup 对多入口、多格式的控制最直接,且 tree-shaking 效果是同类工具中最好的——对于库来说,最小化 bundle 体积是首要目标。
6.2 双 tsconfig 方案
构建时需要两个 tsconfig 文件,解决 vendor 文件与类型声明生成之间的冲突:
tsconfig.json(Rollup 编译时使用)
-
allowJs: true:允许处理 src/vendor/three.r154.js
- 在 Rollup 的 typescript 插件中传入
{ declaration: false, declarationMap: false } 覆盖,禁止 Rollup 生成类型声明(它无法正确生成合并后 bundle 的类型)
tsconfig.types.json(单独生成类型时使用)
- 继承
tsconfig.json
-
allowJs: false + exclude: ["src/vendor"]:排除 vendor 目录
three.r154.js 没有类型注释,tsc 无法为它生成 .d.ts(报 TS9005 错误),必须在生成类型时排除。
构建脚本分两阶段:
"build": "rollup -c && tsc -p tsconfig.types.json"
-
rollup -c:编译 TypeScript + 打包(禁用 declaration)
-
tsc -p tsconfig.types.json:按导出图生成单入口 dist/index.d.ts
6.3 Three.js 的 Vendor 策略
neuroviz 将 Three.js r154 以本地文件形式放入 src/vendor/,而非通过 npm 安装。理由是版本固定:神经影像可视化对 Three.js 的渲染行为有较强依赖,本地 vendor 确保任何环境下行为完全一致,不受用户项目升级 Three.js 的影响。
代价是 bundle 体积较大(Three.js 压缩后约 500KB),以及 @types/three 版本需要手动与 r154 对齐(@types/three@0.157.x 对应 r157,差距不大,关键 API 类型一致)。
七、API 设计哲学
7.1 门面模式与句柄模式
SurfaceViewer 是门面(Facade),对外隐藏了 Scene、Interaction、MeshBuilder 等内部实现细节;ModelHandle 是句柄(Handle),封装了"对哪个模型操作"的上下文,让多模型管理不需要到处传递 model name。
7.2 渐进式复杂度
API 设计遵循"简单场景简单,复杂场景可行"的原则:
- 最简单的用法:3 行代码创建 Surface 查看器并加载模型
- 需要多模型?
loads([...]) 批量加载,每个返回独立的 ModelHandle
- 需要自定义布局?
SliceRenderer 可以独立使用,不必绑定 VolumeViewer
- 需要自定义 Worker 路径?一次
setWorkerBaseUrl() 全局生效
7.3 一致性
Surface 和 Volume 的加载 API 使用相同的 PathOrFile 类型,事件接口使用相同的 on/off/once 方法,资源释放使用相同的 dispose() 方法。用户在两个子系统之间切换时,不需要重新学习新的接口约定。
八、使用示例
Surface Viewer 完整示例
import { SurfaceViewer } from 'neuroviz';
const viewer = new SurfaceViewer(document.getElementById('container'));
// 加载左右脑半球
const [left, right] = await viewer.loads([
{
model: { url: '/lh.pial.gii', name: 'lh' },
overlay: { url: '/lh.activation.txt.gz' },
colorMap: { url: '/hot.txt' },
range: { min: -3, max: 3 },
},
{
model: { url: '/rh.pial.gii', name: 'rh' },
overlay: { url: '/rh.activation.txt.gz' },
colorMap: { url: '/hot.txt' },
range: { min: -3, max: 3 },
},
]);
// 设置视角
viewer.setView('lateral');
// 点击顶点时添加标记并打印坐标
viewer.on('vertexClick', ({ index, point, volCoord }) => {
left.annotations.add(index, {
color: 0x00aaff,
name: `ROI-${index}`,
data: { activation: left.handle.getPositionByIndex(index) }
});
console.log('World coord:', point);
console.log('Volume coord:', volCoord); // 需要加载 tkRas
});
// 独立控制左右脑
left.handle.setTransparency(0.6);
right.handle.setVisible(false);
// 导出截图
const png = viewer.canvasDataURL();
// 清理
viewer.dispose();
Volume Viewer 完整示例
import { VolumeViewer } from 'neuroviz';
const viewer = new VolumeViewer(
document.getElementById('axial') as HTMLCanvasElement,
document.getElementById('coronal') as HTMLCanvasElement,
document.getElementById('sagittal') as HTMLCanvasElement,
{ highlightColor: '#00ff88', backgroundColor: '#111111' }
);
// 从文件输入加载(本地文件)
const [file] = input.files;
await viewer.load({ file: await file.arrayBuffer() });
// 加载完成后读取元信息
viewer.on('load', (volume) => {
console.log('Dimensions:', volume.header.xspace.space_length,
volume.header.yspace.space_length, volume.header.zspace.space_length);
});
// 跟踪当前位置
viewer.on('positionchange', (pos) => {
const world = viewer.getWorldPosition();
const val = viewer.getVoxelValue();
console.log(`Voxel(${pos.xspace}, ${pos.yspace}, ${pos.zspace})`);
console.log(`World: ${world.x.toFixed(1)}, ${world.y.toFixed(1)}, ${world.z.toFixed(1)} mm`);
console.log(`Intensity: ${val}`);
});
// 跳转到特定位置
viewer.setPosition({ xspace: 128, yspace: 128, zspace: 90 });
// 调整显示
viewer.setWindowLevel(500, 800);
viewer.setColormap('viridis');
viewer.dispose();
九、总结
neuroviz 是一个专注于神经影像可视化的浏览器端 JavaScript 库,核心设计理念是:
-
职责分离:每个类只做一件事,内部模块高内聚低耦合
-
性能优先:Web Worker 异步解析、Transferable 零拷贝传输、顶点空间索引、高频事件对象复用
-
资源安全:统一的
dispose() 清理链,AbortController 管理事件监听,防悬空引用守卫
-
API 一致:Surface 和 Volume 使用相同的文件输入接口、事件接口和生命周期方法
-
类型安全:完整的 TypeScript 类型定义,判别联合类型消除运行时错误
对于需要在 Web 应用中嵌入神经影像可视化能力的开发者,neuroviz 提供了一个开箱即用、可深度定制的解决方案。
目前作者的npm账号已经丢失了所以还没有发布到npm上,所以有需要的开发可以先研究代码,着急可以拉下代码本地build进行嵌入,后续账号找回会第一时间发到npm上。