Chapter 131: Real-Time Interaction Patterns
实时交互要解决的主问题,是把用户的一次动作稳定地转成场景状态、视图反馈和下一帧渲染结果。读完本章后,读者应能追踪一次拖拽、框选或属性编辑从输入事件进入系统,到状态更新、视口同步、渲染请求、GPU 提交和可见反馈之间的路径。
图形工具中的交互链路通常跨过 UI 层、场景层、渲染层和资源层。鼠标移动或触控笔轨迹本身只提供屏幕坐标、时间戳、按键和设备信息;编辑器还要把这些信息转换成相机操作、对象选择、gizmo 变换、属性面板值、撤销记录和预览帧。交互系统的设计质量取决于这些转换是否有明确状态边界。
本章贯穿一个可观察材料:一个三维材质编辑器,左侧为透视视口,右侧为正交视口,用户在透视视口中拖动平移 gizmo,同时属性面板显示对象坐标,预览 pass 更新阴影和后处理。这个场景覆盖输入事件、状态更新、多视图同步、事件驱动渲染、性能响应和高级编辑操作。
本章结论可以先放在这里:实时交互系统应把“事件采集”和“渲染帧生成”解耦,把“用户意图”和“场景状态”解耦,把“状态脏标记”和“实际 GPU 工作”解耦。只有这三层边界清楚,拖拽才会稳定,多视图才会一致,昂贵 pass 才能按优先级降级,撤销栈才会记录语义操作。
131.1 实时渲染交互架构
实时渲染交互架构的核心对象是一条闭环:输入事件进入工具,交互控制器修改编辑状态,状态变化触发渲染请求,渲染结果再通过视觉反馈影响用户下一次操作。这里的“闭环”指用户能在很短时间内看到动作结果,并根据反馈继续修正动作。浏览器中的 requestAnimationFrame() 由平台在下一次 repaint 前调用回调,MDN 对它的描述体现了一个关键边界:交互更新应以显示刷新节奏组织,动画或预览还应使用回调时间戳计算进度,相关说明可见 MDN Window.requestAnimationFrame。原生编辑器或游戏引擎使用不同 API,但同样要把输入处理、状态提交和显示刷新放到同一条帧节奏中判断。
贯穿场景中,用户按下 gizmo 的 X 轴手柄时,输入层只产生 pointerdown、位置、按钮和设备类型。交互层根据当前视口相机、对象包围盒和 gizmo hit test 得到一个语义状态:DragTranslateXStarted。场景层记录被编辑对象、初始 transform、约束轴和屏幕到世界空间的映射。渲染层收到视口脏标记,下一帧更新 gizmo 高亮、对象位置和阴影预览。反馈层显示坐标数值、轴向提示和可能的吸附状态。
下面这张图描述一次拖拽交互的最小闭环。图中没有展开具体 API,因为同一结构可以落到 Web、Qt、Dear ImGui、Unity 编辑器、Unreal 编辑器或自研工具中。
这条路径中最容易混淆的是“输入事件”和“交互意图”。输入事件是平台提供的原始材料,例如鼠标位置、触控笔压力、键盘修饰键和时间戳;交互意图是工具解释后的动作,例如选择对象、拖动轴向、框选区域、旋转相机或编辑属性。输入事件可以高频到每秒数百次,交互意图应保持语义稳定。拖拽期间鼠标离开 gizmo 几何区域,交互意图仍然保持为“沿 X 轴移动当前对象”,这就是状态机存在的意义。
Pointer Events 规范中的 pointer capture 提供了一个典型证据:指针捕获允许某个元素接收特定 pointer 的后续事件,即使命中测试结果已经落到别的元素上。W3C 规范把它用于滑块这类控件,说明拖动控件时事件归属应跟随已建立的交互语义,而非每一帧重新依赖当前光标下的元素,相关定义可见 W3C Pointer Events Level 3。图形编辑器中的 gizmo 拖拽、时间轴 scrub、曲线控制点调整,都应采用相同判断:按下时确定操作归属,移动时更新参数,释放时提交语义结果。
最小架构可以用一个交互状态机表达。示例使用 TypeScript 风格伪代码,只展示对象关系。它证明的点是:渲染请求由状态变化产生,事件回调本身不直接执行完整渲染。
class InteractionController {
private activeTool: ToolState = { kind: "idle" };
onPointerDown(event: PointerSample, view: ViewportState) {
const hit = hitTestGizmo(event.position, view.camera, scene.selection);
if (hit.kind === "translate-axis") {
this.activeTool = beginTranslate(hit.axis, event.position, scene.selection);
editor.markDirty("interaction-overlay");
editor.requestFrame("input");
}
}
onPointerMove(event: PointerSample, view: ViewportState) {
if (this.activeTool.kind === "translate") {
const delta = projectPointerDeltaToAxis(event.position, view.camera, this.activeTool.axis);
scene.previewTransform(this.activeTool.selection, delta);
editor.markDirty("scene-transform");
editor.markDirty("interaction-overlay");
editor.requestFrame("input");
}
}
onPointerUp(event: PointerSample) {
if (this.activeTool.kind === "translate") {
undoStack.commit(this.activeTool.toCommand(scene.currentTransform()));
this.activeTool = { kind: "idle" };
editor.requestFrame("commit");
}
}
}
这段伪代码的关键是 previewTransform 和 commit 分离。拖拽中的对象位置用于即时预览,松手后的命令才进入撤销栈。这样做可以让渲染频率跟随交互反馈,历史记录保持语义粒度。若每个 pointermove 都提交一次撤销命令,撤销栈会变成采样点列表,用户按一次撤销只回退极小距离,交互语义会被输入采样频率污染。
架构层还要区分几类脏标记。interaction-overlay 表示 gizmo、高亮、辅助线和 HUD 需要重绘;scene-transform 表示对象矩阵、包围盒、选择轮廓和依赖 pass 需要更新;resource-preview 表示纹理、材质编译、缩略图或缓存加载影响预览质量。脏标记越精确,渲染调度越容易控制成本。一次轻量 hover 高亮通常只更新 overlay pass;一次 transform 拖拽会更新对象矩阵和相关视口;一次材质贴图替换可能触发异步资源上传和渐进预览。
131.2 多视图同步与界面响应
多视图同步要解决的问题,是同一个场景状态如何在多个相机、多个渲染目标和多个 UI 面板中保持一致反馈。贯穿场景中,透视视口负责直接拖拽,正交视口负责几何校验,属性面板负责数值反馈,预览缩略图负责材质效果。它们观察同一个对象 transform,但各自拥有不同相机、投影、渲染分辨率、可见层级和交互焦点。
同步的第一层是共享编辑状态。对象 transform、选择集合、当前工具、吸附开关和坐标空间模式属于编辑状态,应由集中 store 或 scene graph 维护。视口相机、缩放级别、网格显示、局部 overlay 可见性属于视图状态,应归每个 viewport 自己维护。属性面板输入框的文本缓冲、焦点和正在编辑的小数位属于 UI 局部状态。三类状态混在一起时,多视图响应会变得不可预测:某个视口的相机平移可能触发属性面板刷新,某个输入框的临时文本可能提前写入场景 transform。
同步的第二层是变更传播。直接拖拽对象时,透视视口产生 transform preview,scene store 广播对象矩阵变化,正交视口收到 scene-transform 脏标记,属性面板收到数值变化,预览 pass 收到几何或材质依赖变化。广播内容应是“状态已经变化到什么值”,而非“请某个视口执行某个操作”。这样可以让每个视图根据自身成本决定更新粒度。
这张图的重点在于单向读写边界。视口和面板可以发起变更,但它们不直接改写彼此。透视视口拖动 gizmo 后,正交视口通过共享场景状态观察结果。属性面板输入数值后,透视视口通过同一状态刷新对象位置。这种结构可以减少双向绑定引发的循环更新。
多视图还要处理焦点和坐标空间。一个对象在透视视口被拖动时,鼠标位置属于透视视口的屏幕空间,运动约束可能属于世界空间、本地空间或相机空间,属性面板显示的是对象局部 transform 或世界 transform。同步系统应明确每个数值的空间归属。例如 gizmo 的 X 轴拖拽可以先把屏幕位移投影到世界空间约束轴,再写入对象世界矩阵;属性面板若显示本地坐标,应通过父节点逆矩阵换算后显示。
Dear ImGui 的 Multi Viewports 文档也提供了一个多窗口同步边界:启用多视口后,UI 窗口可以移动到主渲染上下文之外,并由平台和 renderer back-end 支持额外 OS 窗口和图形上下文;文档还提醒坐标系统会匹配 OS 或桌面坐标,相关说明可见 Dear ImGui Multi Viewports。这类工具经验说明,多视图问题会同时涉及 UI 坐标、平台窗口、图形上下文和 DPI。自研编辑器在多显示器、多 DPI、多窗口停靠时,也要把视口逻辑坐标、物理像素、渲染目标尺寸和平台窗口位置分开记录。
响应性可以用“谁先更新”判断。交互阶段最先更新的是低成本反馈:gizmo 高亮、对象代理、坐标 HUD、属性面板临时值。随后更新的是中等成本 pass:主视口颜色、深度、选择轮廓。再之后才更新高成本结果:阴影贴图、全局光照缓存、路径追踪预览、体数据重采样。多视图同步的目标是让用户先获得可操作反馈,再逐步获得高质量视觉结果。
一个实用的同步策略是把状态更新拆成三种消息:PreviewChanged、Committed 和 ResourceSettled。拖拽过程中广播 PreviewChanged,所有视口显示临时 transform,属性面板显示灰色临时数值;释放鼠标后广播 Committed,撤销栈记录命令,依赖系统更新包围盒和保存状态;异步贴图或 shader 编译完成后广播 ResourceSettled,缩略图和高质量预览替换占位结果。消息语义清楚后,多个视图可以响应同一状态,而不用互相调用。
131.3 事件驱动与渲染循环融合
事件驱动和渲染循环融合的核心判断,是事件可以随时到达,渲染帧只能按显示、GPU 和调度条件输出。输入层的频率由设备、操作系统和窗口系统决定;渲染层的频率由显示刷新、GPU 工作量、垂直同步、swap chain 或浏览器 repaint 决定。实时交互系统应把事件收集成最新状态,再在下一次帧边界生成画面。
贯穿场景中,拖拽 gizmo 时 pointermove 可能在一帧内到达多次。W3C Pointer Events Level 3 描述了 coalesced events:浏览器可以把多个指针移动合并到一个事件中,并提供原始移动序列供绘图程序生成更平滑曲线;规范还说明 predicted events 可用于推测未来位置以降低感知延迟,具体边界可见 W3C Pointer Events 的 coalesced 与 predicted events。图形编辑器可以借鉴这个思路:输入采样可以高频保存,场景状态更新和渲染输出要按帧边界合并。
融合模型可以写成一句话:事件回调只更新交互状态和脏标记,渲染循环读取最新状态并产出帧。这样可以吸收输入突发,也可以防止昂贵渲染在事件回调中重复执行。下面的伪代码展示了一个事件驱动外壳和一个帧调度核心。
class EditorFrameScheduler {
private frameRequested = false;
private dirty = new Set<DirtyKind>();
private latestInput: PointerSample | null = null;
pushPointerSample(sample: PointerSample) {
this.latestInput = sample;
this.dirty.add("interaction-overlay");
this.requestFrame();
}
markDirty(kind: DirtyKind) {
this.dirty.add(kind);
this.requestFrame();
}
requestFrame() {
if (this.frameRequested) return;
this.frameRequested = true;
platform.requestFrame((time) => this.renderFrame(time));
}
renderFrame(time: number) {
this.frameRequested = false;
const dirtySnapshot = new Set(this.dirty);
this.dirty.clear();
interaction.updateFromLatestInput(this.latestInput, time);
renderer.renderDirtyViews(scene, dirtySnapshot, time);
if (interaction.isAnimating() || loader.hasVisibleProgress()) {
this.requestFrame();
}
}
}
这段代码有三个可迁移判断。第一,frameRequested 合并多次请求,同一显示周期内多次输入不会排队生成多帧旧画面。第二,dirtySnapshot 让本帧使用一组稳定脏标记,渲染过程中新增的脏标记进入后续帧。第三,渲染循环的持续运行由动画、加载进度和交互状态决定;空闲时停止主动绘制,可以降低 CPU 与 GPU 开销。
事件和渲染循环之间还要处理 dirty region。二维 UI 或轻量 overlay 可以只更新局部区域;三维场景通常以 render target 为单位更新,因为深度、透明、后处理和 temporal history 让局部重绘难以保持正确。交互系统应把 dirty region 用在 UI 面板、图标、曲线编辑器、时间轴和小型 canvas 上,把 3D 主视口按 pass 或视图级别调度。若三维视口中只有 selection outline 变化,可以重用 scene color 和 depth,只重绘 overlay pass。
Web 平台上的 OffscreenCanvas 提供了一个线程边界样例:可见 canvas 可以把控制权转到 OffscreenCanvas,并在 worker 中取得渲染上下文;MDN 也描述了 transferToImageBitmap() 与 bitmaprenderer 用于显示生成帧的路径,见 MDN OffscreenCanvas。这类路径说明,渲染和 UI 事件可以跨线程拆分,但跨线程消息会带来延迟和同步边界。桌面工具中也类似:后台线程可以加载资源、构建 BVH、编译 shader 或生成缩略图;主线程仍要掌握输入焦点、UI 状态和最终提交顺序。
事件驱动系统还要给取消路径留位置。拖拽过程中用户按下 Esc,工具应恢复初始 transform、清理 active tool、撤销临时 overlay,并请求一帧显示恢复结果。材质预览加载时用户切换选择,旧资源请求完成后只能更新旧目标的缓存,不能覆盖当前选择的预览。稳定做法是给每个交互或资源任务分配 generation id,提交结果前检查它是否仍属于当前状态。
131.4 性能考量与响应优化
性能响应优化的目标,是让用户动作先得到低延迟反馈,再让高成本渲染结果按预算到达。这里的延迟包括输入采样到状态更新、状态更新到命令提交、命令提交到 GPU 完成、GPU 完成到显示刷新、显示刷新到用户感知。编辑器通常无法同时让所有 pass 保持最高质量,所以应把交互期和静止期分开调度。
贯穿场景中,用户拖动对象时最敏感的是对象代理位置、gizmo、坐标数值和选择轮廓。阴影柔化、屏幕空间反射、高分辨率材质预览和路径追踪 refinement 可以延后。交互期可以使用低分辨率阴影、简化材质、冻结昂贵后处理、降低体渲染采样数。释放鼠标后再补齐高质量 pass。这个策略的判断依据是:反馈是否影响用户继续操作。若一个 pass 的结果不改变拖拽方向、命中判断和数值确认,它就可以降级或延后。
性能优化要先定位瓶颈类型。CPU 侧瓶颈通常表现为事件处理、布局、场景遍历、命令构建或资源解码耗时;GPU 侧瓶颈通常表现为 shader 执行、纹理采样、render target 带宽、overdraw、同步等待或 present 阻塞。工具证据可以来自 profiler、frame capture、内置 timing HUD 或简单日志。正文这里不假设读者已经有特定 GPU 工具,但检查顺序应保持一致:先看输入到主线程是否排队,再看渲染请求是否合并,再看 GPU pass 是否超预算,再看显示刷新是否被垂直同步或窗口系统限制。
可以用一张预算表管理交互期。表中数值是工程初始目标,具体项目要按设备、视口复杂度和质量目标调整。
| 路径 | 交互期目标 | 证据 | 常见处理 |
|---|---|---|---|
| 输入事件到交互状态 | 小于一帧 | 事件时间戳与状态更新时间 | 合并移动事件,减少同步布局和命中测试范围 |
| 交互状态到可见 overlay | 当前或下一帧 | HUD 位置、gizmo 高亮、对象代理 | overlay pass 独立,重用主场景深度 |
| 主视口 scene pass | 稳定帧时间 | GPU timer、pass 耗时 | 降低采样、分辨率或更新频率 |
| 资源预览 | 可渐进 | loading 状态、缩略图时间 | 异步加载,完成后按 generation id 提交 |
| 提交与撤销记录 | 释放时完成 | undo command 数量 | 拖拽中预览,释放时合并为语义命令 |
这张表强调的是证据归属。输入延迟要看事件时间戳与状态更新时间,不能用最终帧耗时替代;GPU pass 超预算要看 pass timing,不能只看鼠标移动卡顿;撤销栈异常要看命令数量和语义粒度,不能归因到渲染器。
响应优化还要控制同步等待。资源上传、shader 编译、readback、occlusion query 读取和纹理生成都可能让主线程或渲染线程阻塞。交互期应减少 readback,把 CPU 需要的选择信息提前放入可访问结构,例如 BVH、object id buffer 的上一帧缓存或简化代理。若必须读取 GPU 结果,读取应落在可容忍延迟的路径,例如 hover 提示可以延后一帧,拖拽约束和数值面板应使用 CPU 可计算状态。
命中测试也是交互性能的常见成本点。框选和 gizmo 命中可以先用屏幕空间包围盒或简化 proxy,只有候选集合较小时再进行精确 mesh 或深度测试。大场景编辑器可以维护交互专用 acceleration structure,并在对象 transform 提交后增量更新。这样,拖拽中的每次移动不需要遍历全部可见 mesh。
响应质量还依赖视觉反馈设计。快速反馈不等于完整最终画面。拖拽中显示半透明代理、轴向投影线、吸附刻度和临时坐标,能让用户判断动作方向和幅度。高成本材质或阴影在静止后渐进更新,用户会把它理解为质量 refinement,而非操作失败。反馈要有状态边界:拖拽中、提交中、加载中、失败、已完成应有不同视觉表达。
131.5 可视化编辑器中的高级交互
高级交互的复杂点,在于一次操作通常同时影响选择状态、几何状态、UI 状态、撤销栈和实时预览。框选、拖拽、gizmo、属性面板、撤销栈和实时预览各自都能独立实现;真正困难的是它们组合后仍保持语义一致。贯穿场景中的对象拖拽就是最小例子:它从命中 gizmo 开始,经过轴向约束和吸附,更新两个视口与属性面板,释放后提交一个可撤销命令,并触发高质量预览刷新。
框选的交互链路是屏幕矩形到场景对象集合的查询。它需要视口坐标、相机矩阵、选择策略和可见性规则。选择策略可以是完全包含、部分相交、按深度优先、按层级过滤或按当前 selection mode 过滤。工具应把策略写入选择命令,这样撤销和重做时才能复现同一语义。若框选结果依赖当前视口遮挡关系,还应明确使用的是对象包围盒、深度 buffer、object id buffer 还是 CPU 几何测试。
拖拽和 gizmo 的链路是屏幕位移到约束空间参数的映射。平移 gizmo 把光标移动投影到轴、平面或视图平面;旋转 gizmo 把光标移动映射为角度;缩放 gizmo 把光标移动映射为比例。这里每个映射都要记录初始状态、当前相机、约束对象和数值单位。拖拽过程中相机如果发生变化,应明确工具策略:冻结拖拽开始时的相机,或允许相机变化并重建投影基准。多数编辑器会冻结交互开始时的求解基准,以保持拖拽连续。
属性面板属于另一条输入路径。用户直接输入 x = 10.0 时,系统绕过屏幕空间投影,直接写入数值编辑意图。面板编辑要区分临时文本和已提交数值。用户输入 1. 时,它是合法的编辑中间态,却可能不是最终数值;按 Enter、失焦或拖动数值滑块结束时,才应提交 scene mutation 和 undo command。数值面板与 gizmo 同时存在时,二者应写入同一 transform command 类型,这样撤销栈不关心操作来源,只关心语义结果。
撤销栈记录的是用户意图完成后的命令。一次拖拽产生一个 SetTransformCommand,其中包含对象 id、起始 transform、结束 transform、坐标空间和可选吸附信息。一次框选产生一个 SetSelectionCommand,包含旧选择集合、新选择集合和 selection mode。一次材质参数修改产生一个 SetMaterialParameterCommand。拖拽中间帧可以进入 preview state,但释放前不进入 undo history。这样,撤销栈和渲染帧率解耦,用户操作语义稳定。
实时预览是高级交互的反馈层。预览应回答“当前操作如果提交,会看到什么结果”。它可以使用低质量路径,但要保持关键关系正确。移动对象时,包围盒、gizmo、坐标轴和碰撞代理应跟随;阴影、反射和后处理可以渐进。编辑材质时,基础颜色、粗糙度和法线方向应尽快反馈;高采样路径追踪和缩略图集合可以延迟。预览路径要显示当前质量状态,例如 draft、refining、final 或 stale。
高级交互可以用一个操作生命周期统一管理。它把开始、预览、提交、取消和失败状态都纳入同一模型。
这张状态图的价值是把失败路径显式放进交互模型。目标对象被删除、资源加载失败、视口失焦、窗口 DPI 变化、设备断开、用户取消操作,都应能回到稳定状态。失败路径不应该把对象留在半预览 transform,也不应该向撤销栈提交半成品命令。
最后给出一个可复用检查顺序。处理一个图形编辑器交互问题时,先确认当前操作的语义对象:选择、变换、相机、材质、资源或 UI 面板。再确认输入事件是否被正确路由到 active tool。接着检查状态更新写入的是 preview state 还是 committed state。然后检查脏标记是否覆盖相关视图和 pass。再检查渲染调度是否合并请求并按预算输出反馈。最后检查撤销栈是否只记录完成后的语义命令。这个顺序可以定位“拖拽卡顿”“多视图不同步”“撤销碎片化”“属性面板跳值”“预览过慢”等问题。
最小自检任务
假设一个三维编辑器有两个视口:透视视口和顶视图。用户在透视视口拖动对象的 X 轴 gizmo 时,透视视口中的对象移动正常,顶视图延迟一帧才更新,属性面板每次鼠标移动都生成一条撤销记录。请按本章模型判断这个问题涉及哪些状态边界,并给出修复顺序。
答案要点
这个问题至少涉及三条边界。第一,输入事件和交互意图应分离:pointermove 只应更新拖拽中的 preview state,并请求下一帧反馈。第二,共享场景状态和视图状态应分离:透视视口产生 transform preview 后,顶视图应通过共享 scene store 收到同一 transform 脏标记;若顶视图固定延迟一帧,需要检查它是否只监听 committed state,或是否在自己的渲染循环中没有消费最新 preview state。第三,preview state 和 undo command 应分离:属性面板可以显示临时坐标,但撤销栈应在 pointerup 或确认提交时合并为一条 SetTransformCommand。
修复顺序可以这样执行。先检查 active tool 是否在 pointerdown 后锁定对象、轴向和初始 transform。再检查 pointermove 是否只调用 previewTransform,并向共享场景状态写入当前预览矩阵。接着检查脏标记是否同时覆盖透视视口、顶视图和属性面板。然后检查 frame scheduler 是否合并多次 pointermove 并让两个视口读取同一份最新状态。最后检查撤销栈提交点,把每次移动生成命令改成释放时提交一个包含起始 transform 与结束 transform 的语义命令。修复完成后,两个视口可以在同一帧边界显示同一对象状态,属性面板显示临时值,撤销操作回退整次拖拽。
本章知识点总结
- 交互闭环:实时交互把输入事件、状态更新、渲染请求和视觉反馈连接成一条可观察路径。
- 事件语义:输入事件提供原始坐标和时间信息,交互意图表达工具解释后的用户动作。
- 状态边界:共享场景状态、视图状态和 UI 局部状态应分别管理,多个视图通过共享状态同步。
- 拖拽归属:拖拽开始时确定 active tool、目标对象和约束空间,移动阶段持续更新同一语义操作。
- 帧合并:事件回调更新状态和脏标记,渲染循环在帧边界读取最新状态并输出画面。
- 脏标记:精确的脏标记可以区分 overlay、scene transform 和 resource preview 的更新成本。
- 多视图同步:多个视口和面板应响应同一份场景变更,视图之间不直接互相驱动。
- 坐标空间:gizmo、视口、属性面板和多显示器窗口都要明确逻辑坐标、物理像素和场景空间。
- 响应优先级:交互期先更新低成本反馈,静止期再补齐高质量阴影、后处理或渐进预览。
- 瓶颈定位:输入延迟、CPU 调度、GPU pass、资源加载和显示刷新要分别寻找证据。
- 高级编辑:框选、拖拽、gizmo、属性面板和实时预览应落到统一的语义命令模型。
- 撤销粒度:撤销栈记录完成后的用户意图,拖拽中间帧属于 preview state。
- 失败恢复:取消、目标失效、资源失败和设备变化都应回到稳定状态,并清理临时预览。
- 排查顺序:先看语义对象和事件路由,再看状态写入、脏标记、渲染调度和撤销记录。