第 25 章:数据更新机制——modified()、dataChange() 与重新渲染
学习目标
你已经会连接 Reader、Filter、Mapper 和 Actor。本章解释连接好以后为什么有些修改能出现,有些修改却像没有发生。读完应能区分:JavaScript 内存里的值、vtk.js 对象的修改序号、Filter 的输出缓存以及最终屏幕画面;知道直接修改 vtkDataArray 的 TypedArray 后应调用什么;能判断某次操作是否需要重新计算几何。
本章按教程固定的 @kitware/vtk.js 37.4.2 解释。第 8 阶段示例可逐步观察下文提到的状态。
25.1 四层状态不能混为一谈
观察一条等值面管线:
vtkDataArray 的 TypedArray
│
▼
vtkImageData(含 Point Data)
│
▼
vtkImageMarchingCubes 的 vtkPolyData 输出
│
▼
vtkMapper → vtkActor → RenderWindow 中的画面
第一层是一串真实数值;第二层告诉算法这些数值属于哪个空间采样点;第三层是以前两层和阈值为输入算出的三角形;第四层才是 GPU 上的可见结果。只让一层改变,不保证其余层自动改变。比如 scalars.getData()[i]=250 已写入内存,但 vtk.js 还不知道缓存的范围和等值面应失效。反过来,actor.getProperty().setColor(...) 改变外观,不需要重算数值和三角形。
对熟悉 Three.js 的读者,可把它与修改 BufferAttribute.array 后设置 needsUpdate 作类比:直接改底层数组与通知框架是两个动作。但 vtk.js 还多一条 Reader/Filter 数据管线,因此不应把 needsUpdate 机械翻译成单个 vtk.js 属性。
25.2 getMTime() 是修改序号,不是时间戳
vtk.js 对象有 modified() 和 getMTime()。在本版本的 macros.obj 实现里,调用 modified() 会取得新的全局递增序号,并通知该对象的 onModified 订阅者。getMTime() 用于比较对象和输出谁更新,数值不是 Date.now(),也不表示运行了多少毫秒。
const before = iso.getMTime();
iso.setContourValue(90);
const after = iso.getMTime();
console.log(after > before); // 这次参数确实改变时为 true
多数 vtk.js setter 在值确实改变后会自动 modified()。同值重复设置通常无需重算。vtkImageData 的 getMTime() 还会考虑内部 Point Data、Cell Data 等对象;Point Data 的修改序号又会考虑其中数组。因此在本版本中 scalars.dataChange() 可以通过这条层级关系让 image.getMTime() 变新,即使没有显式调用 image.modified()。这说的是修改序号聚合,不代表 image.onModified(...) 一定因子数组变化而触发;对象事件和查询到的聚合序号要分开理解。
若想监听某个对象自己的修改,可订阅并在不再需要时取消:
const subscription = scalars.onModified(() => {
console.log('scalars changed', scalars.getMTime());
});
scalars.setValue(100, 250);
subscription.unsubscribe();
订阅回调是观察通知,不会替你调用 renderWindow.render()。要观察子数组变化,就监听该数组;不能把父数据对象聚合后的 getMTime() 当成父对象一定发出过 onModified 事件。
25.3 原位改数值:用 dataChange() 清缓存并通知
看一个最容易踩坑的例子:
const scalars = image.getPointData().getScalars();
const oldRange = [...scalars.getRange()]; // 先让范围进入缓存
const values = scalars.getData();
values[100] = 250; // 只修改 TypedArray
此时 values[100] 确实为 250,但 scalars.getMTime() 没变,scalars.getRange() 可能仍返回旧缓存。Filter 依据修改序号判断是否重算,也可能继续返回旧等值面。**只调用 renderWindow.render() 不能替代数据通知。**正确完成这次原位编辑:
scalars.dataChange();
const newRange = scalars.getRange();
const newSurface = iso.getOutputData();
renderWindow.render();
dataChange() 在 37.4.2 的 vtkDataArray 实现中做两件关键事:把缓存的 ranges 清空,再调用 modified()。因此范围下次会从实际数值重算,下游也能看到较新的修改序号。只调用 scalars.modified() 会提高序号,却不清除 getRange() 缓存;只调用 image.modified() 同样不能替数组清缓存。第 8 章已经介绍过这个方法,本章解释它为何影响整条管线。
若是修改一个值,也可使用 scalars.setValue(index, value) 或 setComponent(tupleIndex, componentIndex, value):本版本中这些方法在值变化时会调用 dataChange()。批量改成千上万个值时,直接写 TypedArray、最后调用一次 dataChange() 往往更合适,避免每个值都触发一次通知。整体替换数组可用 scalars.setData(newTypedArray, numberOfComponents);要保持元组数、数据类型和 ImageData 点数一致。
25.4 getOutputData()、管线缓存与 render()
vtkImageMarchingCubes 是一个算法对象。它的 setInputData(image) 或 setInputConnection(source.getOutputPort()) 指定输入;setContourValue(...) 改变算法参数。调用 iso.getOutputData() 时,vtk.js 会检查自己、上游输入和已有输出的修改序号,必要时执行 requestData 生成新输出。若都未变化,它可以复用已有输出。Mapper 接 iso.getOutputPort() 时,取数据也会沿连接请求上游结果。
iso.setInputData(image);
const mapper = vtkMapper.newInstance();
mapper.setInputConnection(iso.getOutputPort());
// 修改标量并 scalars.dataChange() 后:
const surface = iso.getOutputData(); // 需要时重新算等值面
renderWindow.render(); // 请求刷新画面
getOutputData() 负责取得数据计算结果;renderWindow.render() 负责请求画面更新。两者不能互代。改阈值但不请求数据或绘制,屏幕可能保持旧帧;只绘制却未正确通知原位数组变化,过滤器可能继续给旧几何。真实应用不一定必须手动调用 getOutputData() 才能显示:若 Mapper 通过输出端口连接,绘制时会取上游数据。本教程显式调用它,是为了打印点数、单元数和计算耗时。
如果你把 iso.getOutputData() 的当前对象用 mapper.setInputData(surface) 接上,而不是接输出端口,那么后来 Filter 重新产生的对象未必就是 Mapper 持有的那个对象。希望参数变化自动沿管线传播时,应保持 mapper.setInputConnection(iso.getOutputPort())。每次更新后也应重新取一次 getOutputData(),不要长久依赖之前保存的 Filter 输出引用。
25.5 哪些变化重算什么
| 操作 | 数据数值 | Filter 输出几何 | 画面刷新 |
|---|---|---|---|
原位写 getData()[i],未通知 |
已变 | 通常仍复用旧输出 | 单独 render() 也可能画旧结果 |
原位写后 scalars.dataChange() |
已变,范围缓存失效 | 下一次取输出时应重算 | 还需请求绘制 |
iso.setContourValue(q) |
不变 | 下一次取输出时应重算 | 还需请求绘制 |
actor.getProperty().setColor(...) |
不变 | 无须重算 | 请求绘制后颜色改变 |
camera.setPosition(...) |
不变 | 无须重算 | 请求绘制后视角改变 |
这里的“应重算”描述本例的过滤器与连接方式。每个 Filter 的 requestData 如何处理输入、能否局部更新、是否缓存额外结果,仍须查具体实现。一个漂亮的画面变化也不说明数据几何发生了变化;可比较 iso.getOutputData().getNumberOfCells()、点坐标和输出 getMTime()。

25.6 异步 Reader 不是同步管线更新
第 23 章的 vtkHttpDataSetReader 要先完成网络 Promise:
await reader.setUrl('/sample/index.json', { loadData: false });
reader.enableArray('pointData', 'Density', true);
await reader.loadData();
const image = reader.getOutputData();
getOutputData() 检查管线修改序号,不能替代 await reader.loadData() 等待网络数组。数据到达、设置 active scalars 后,再让下游请求输出和画面。若出现“第一次渲染空白,第二次才出现”,先查异步顺序,再查数据与显示设置。
25.7 一套短的排错记录
当画面没更新时,按同一顺序打印:
console.log('value', scalars.getData()[100]);
console.log('range', scalars.getRange());
console.log('mtimes', scalars.getMTime(), image.getMTime(), iso.getMTime());
const surface = iso.getOutputData();
console.log('surface', surface.getNumberOfCells(), surface.getMTime());
renderWindow.render();
先确认值,再确认范围,再确认修改序号,接着确认 Filter 输出,最后看屏幕。getRange() 是缓存值,测试原位修改时应像阶段示例一样另外扫描原始数组作对照。第 8 阶段示例还记录 getOutputData() 的同步耗时:未失效时通常很短,阈值或数组更新后通常变长,但时间数值依设备和数据而异。
本章小结
vtk.js 的更新并非一个“刷新按钮”:原始 TypedArray、对象修改序号、Filter 输出缓存和画面各有边界。批量改值后调用 dataChange(),参数变化后从输出端口请求新结果,并在需要时 renderWindow.render(),能让更新变得可解释、可验证。下一章进一步看数据变大时哪一层先变慢、怎样测量。