Bokeh 浏览器端粒子动画:基于 CustomJS、requestAnimationFrame 与 WebGL 的高频渲染架构解析
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
导读
本文以 bokeh 仓库中streamlit_particles示例的 js/README.md 为骨架,深入解析一套"Python 只负责初始化与切换、JavaScript 在浏览器端逐帧推进"的高频动画架构:driver.js持有requestAnimationFrame循环驱动 50,000 个粒子,各物理模式的CustomJS内核只做数值步进,helpers.js提供无分配的速度限制与周期边界工具。读完本文,你将掌握 BokehCustomJS.args传递模型、cb_data逐帧传参、float32NumPy 数组二进制序列化,以及 Streamlit + BokehASGI 下多模式粒子模拟的完整实战方案。
一、示例概览:一次"状态在 Python、演化在浏览器"的分工
streamlit_particles是位于 examples/server/api/asgi/streamlit_particles/ 的完整示例:Streamlit 负责 UI(模式选择、滑杆、暂停、重置),Bokeh 通过BokehASGI挂载在/bkapp路径下渲染带 WebGL 输出的散点图,而每一帧的物理演化完全发生在浏览器里的 JavaScript 中。
整个浏览器端动画的说明集中在 js/README.md,其核心分工可概括为:
| 层 | 职责 | 关键文件 |
|---|---|---|
| 动画驱动 | 持有requestAnimationFrame循环,读取控制状态、调用内核、触发重绘 | driver.js |
| 物理内核 | 每种模式一个,只处理action: "step"时的数值推进 | vortex.js、gravity.js、wave.js、chaotic.js、magnetic.js、curl_noise.js、fountain.js |
| 共享工具 | 速度限制、周期边界、无分配积分辅助函数 | helpers.js |
| Python 初始化 | 模式切换时替换内核代码、重置时生成新的 NumPy 数组 | simulation.py |
| 模式元数据 | 每种模式的控制项、方程、参考文献 | modes.toml |
README 特别说明:该目录下的 JavaScript 是本示例专属生成代码;modes.toml中的引用支撑控制方程,而数值常数、阻尼、速度上限、边界处理与时间步进策略均是本演示的自定义选择。换言之,物理内核属于"教学演示实现"而非通用求解器——例如 gravity 模式在 modes.toml 中明确写道"it is not an N-body solver"。
二、driver.js:掌控 45ms 帧间隔的动画循环
driver.js 是整个动画的"心脏",全文只有 35 行,却完成了三件事:节流、步进、重绘。
// Keep the high-frequency animation loop beside BokehJS in the browser. const FRAME_INTERVAL = 45 let previous = performance.now() async function frame(now) { try { const elapsed = now - previous if (elapsed < FRAME_INTERVAL) return const data = controls.data const strength = data.strength[0] const rate = data.rate[0] const paused = data.paused[0] if (!paused) { await evolution.execute(controls, { action: "step", dt: Math.min(elapsed, 50)/1000, strength, rate, time: now/1000, }) particles.change.emit() } previous = now } catch (error) { console.error("Particle simulation frame failed", error) previous = now } finally { requestAnimationFrame(frame) } } requestAnimationFrame(frame)逐段拆解其设计要点:
- 帧率控制:
FRAME_INTERVAL = 45(毫秒)是最小帧间隔,配合previous时间戳实现节流;elapsed超过间隔才真正推进一帧。这样即使浏览器标签页在后台被节流,也不会累积出不可控的步长。 - 状态读取:
controls.data是一个单行ColumnDataSource,其中strength[0]、rate[0]、paused[0]分别对应强度、速率与暂停开关。数据结构的构建在 Python 侧 simulation.py 的control_data()中完成。 - 逐帧调用:
evolution.execute(controls, {...})是CustomJS模型的execute方法(即CustomJS.execute),每次调用会触发一次CustomJS代码执行;传入的第二个参数即 README 所说的cb_data,包含action: "step"、dt、strength、rate、time五个字段。 - dt 的取值:
Math.min(elapsed, 50)/1000,单位是秒,且被硬性钳制在 50ms 以内,避免掉帧后出现大时间步导致的数值发散。 - 主动重绘:
particles.change.emit()手动发出ColumnDataSource的 change 信号,驱动 Bokeh 重绘散点。这是"数据留在浏览器、不经过 WebSocket"的关键一步。 - 异常兜底:try/catch 捕获单帧错误并打印日志,
finally中无论成败都续上下一帧requestAnimationFrame(frame),保证循环永不中断。
这个 driver 本身也是一个CustomJS模型(name 为particle-driver),由 simulation.py 通过doc.js_on_event(DocumentReady, driver)在文档就绪时启动。它被单独doc.add_root(driver)加入文档——注释明确说明:保持这个非可视 driver 在文档中,以便控件与内核的改动被同步到浏览器。
三、两个 Bokeh 模型通过CustomJS.args传入内核
README 指出:每个内核都通过CustomJS.args接收两个 Bokeh 模型:
particles:包含x、y、vx、vy、life以及归一化的speed数组;centers:两个可拖拽的场中心位置。
对应的构造代码位于 simulation.py:
particles = ColumnDataSource(data=particle_data(initial, initial_centers), name="particles") centers = ColumnDataSource(data=initial_centers, name="centers") controls = ColumnDataSource(data=control_data(initial), name="controls")而内核的组装方式为:
evolution = CustomJS( args={"centers": centers, "particles": particles}, code=kernel_code(initial.mode), name="particle-evolution", )kernel_code()则是"helpers + 模式内核"的拼接:
def kernel_code(mode: str) -> str: return f"{read_javascript('helpers.js')}\n{read_javascript(f'{mode}.js')}"所以浏览器中最终执行的CustomJS.code是 helpers.js 与某个模式文件(如 vortex.js)的串联,helpers 里的常量与函数对所有内核可见。
particles数据中的六个数组由 particle_data() 生成:默认模式在[-3, 3] × [-2, 2]的 250×200 网格上均匀布点并加 ±0.008 抖动,得到POINT_COUNT = 250 × 200 = 50,000;fountain模式则从发射器位置随机撒粒子并预先按弹道外推位置。magnetic模式还会给粒子赋一组正弦调制的初始速度,让磁场效果更快显现。
四、cb_data逐帧传参:内核只推进、不重置
README 明确约定:每一帧 driver 通过cb_data传入action: "step"、strength、rate、time和dt;Python 负责模式相关的初始化,因此内核只在两次重置之间推进模拟。
以内核文件的开头统一模式为例,gravity.js 和 vortex.js 都写成:
if (cb_data.action === "step") { const {dt, strength, rate} = cb_data // ... 数值推进 }这说明action字段是一种可扩展的协议:将来若需要"reset"等动作,Python 只要发送带不同action的cb_data即可,而当前重置路径完全由 Python 侧生成新数组来完成(见下文第六节)。
以默认的 vortex 模式为例,vortex.js 用两个软化的点涡旋核叠加一个弱背景流:
const step = dt*(0.25 + 0.18*rate) const circulation = 0.55*strength for (let i = 0; i < x.length; i++) { const left_dx = x[i] - center_x[0] const left_dy = y[i] - center_y[0] // ... 右涡旋同理 const left_r2 = left_dx*left_dx + left_dy*left_dy + SOFTENING_SQUARED const velocity_x = circulation*(-left_dy/left_r2 + right_dy/right_r2) + 0.16*Math.cos(1.6*y[i] + 0.35*time) const velocity_y = circulation*(left_dx/left_r2 - right_dx/right_r2) + 0.10*Math.sin(1.4*x[i] - 0.25*time) advect_particle(x, y, speed, i, velocity_x, velocity_y, step) }其中SOFTENING_SQUARED = 0.18来自 helpers,用于避免奇点;time参与背景流相位,使流动随时间缓慢演变。strength与rate分别映射为环量0.55*strength和步长因子dt*(0.25 + 0.18*rate),这正是 modes.toml 中 vortex 模式"Circulation strength / Flow rate"两个控制项的语义。
五、helpers.js:50,000 粒子内循环的"无分配"地基
README 强调:Python 把helpers.js前置拼接到每个内核之前,其中实现的辅助函数负责速度限制、周期边界与积分,且在内循环中不分配任何对象——这是 50,000 粒子逐帧遍历能保持流畅的关键。
helpers.js 定义了全局常量与四个函数:
| 常量 | 值 | 含义 |
|---|---|---|
GRID_WIDTH/GRID_HEIGHT | 250 / 200 | 粒子网格尺寸(50,000 = 250×200) |
X_MIN/X_MAX | -3 / 3 | 水平边界 |
Y_MIN/Y_MAX | -2 / 2 | 垂直边界 |
MAX_SPEED | 2.5 | 速度上限(归一化到 1 的基准) |
SOFTENING_SQUARED | 0.18 | 软化参数 ε²,防止力发散 |
四个函数的作用:
wrap(value, minimum, maximum):周期边界。粒子越界后从对侧回来,等价于在环面上演化——注意它是"绕回"而不是"弹回",因此粒子总量守恒、永不逃离视口。clamp_unit(value):把任意值钳制到[0, 1],用于归一化speed数组以驱动颜色映射。advect_particle(...):平流积分(一阶),适用于涡旋、波等只有速度场、不维护独立速度数组的模式。内部先按MAX_SPEED缩放速度,再wrap更新位置,并写入归一化speed。integrate_particle(...):半隐式欧拉积分,适用于 gravity、magnetic、fountain 等需要维护vx/vy的加速度模式,同时写回速度与位置。
注意advect_particle与integrate_particle都以"逐元素"的方式工作(接收index与已算好的velocity_x/velocity_y),完全避免在for循环内创建中间对象,这正是 README 所说"without allocating objects inside the 50,000-particle loops"的具体体现。
六、Python 侧的两个关键动作:换内核、重置数组
README 指出:模式切换时 Python 替换内核的CustomJS.code,视图重置时发送重新初始化的 NumPy 数组。对应实现位于 simulation.py 的refresh(),它由doc.add_periodic_callback(refresh, 100)每 100ms 轮询一次:
if mode_changed: evolution.code = kernel_code(current.mode) if mode_changed or reset_requested: reset_centers = center_data(current.mode) centers.data = reset_centers particles.data = particle_data(current, reset_centers) controls.data = control_data(current)这段逻辑与 README 完全对应:
- 模式切换:只替换
evolution.code,即把新模式的 JS 内核拼到浏览器里; - 重置/换模式:重新生成
centers与particles两个ColumnDataSource的完整数据——粒子回到网格或发射器初始状态; - 控制更新:
controls.data始终同步 Python 侧的最新值,driver 每帧从浏览器端直接读取。
mode_changed与reset_requested的判定来自ViewerState的版本号机制:state.py 中每次update()都会revision += 1,显式重置时额外reset_count += 1。simulation.py通过比对last_revision/last_reset_count侦测变化。值得注意的边界:ViewerState.update()会校验强度在[0.2, 3.0]、速率在[0.2, 5.0]、模式必须存在于MODES,否则抛ValueError——这保证了写入 Bokeh 数据源的值始终合法。
状态的分发路径是:Streamlit 的publish()(ui.py)→viewer_state.update()→refresh()轮询读取。这套"Streamlit 写 Python 快照、Bokeh 轮询同步"的机制,让两种框架的会话状态得以打通。
七、二进制传输:float32 数组的 WebSocket 之旅
README 的最后一句话点出了性能核心:Bokeh 将重置时的float32NumPy 数组序列化为二进制 WebSocket 缓冲;动画期间数据始终留在浏览器,因此粒子位置不会逐帧穿越 WebSocket。
这与上一节代码互相印证:
- 在 particle_data() 中,所有数组都显式
.astype(np.float32),从源头保证 32 位精度; - 重置只在
mode_changed or reset_requested时发生,一次particles.data = ...赋值即完成一次二进制传输; - 之后每一帧,driver 都在本地修改
particles.data里的Float32Array并通过particles.change.emit()触发重绘,没有任何网络往返。
对比"每帧把数据发回 Python"的朴素做法,这种设计把 50,000 × 6 个 float32(约 1.2 MB)的传输从"每帧一次"降到"每次重置一次",是浏览器端动画能达到流畅帧率的结构性原因。README 也说明这些 JavaScript 的数值常数与阻尼等是"choices made for this demo",即性能与观感属于演示调优而非通用保证。
八、七种物理模式与modes.toml元数据驱动
内核按模式分文件存放:vortex.js、gravity.js、wave.js、chaotic.js、magnetic.js、curl_noise.js、fountain.js。它们的控制方程、UI 文案、参考文献全部由 modes.toml 描述,并在 modes.py 中通过tomllib解析成冻结的Modedataclass。
每个模式的 TOML 条目结构一致,以 gravity 为例:
[modes.gravity] label = "Binary gravity" plot_title = "Binary softened-gravity field" controls = ["Gravity strength", "Time scale"] color_title = "gravity particle speed" center_label = "gravity-well separation" description = "Particles accelerate around two softened gravity wells." equation = ''' \ddot{\mathbf r}_i=-G\sum_{j=1}^{2}\frac{\mathbf r_i-\mathbf c_j}{\left(\lVert\mathbf r_i-\mathbf c_j\rVert^2+\varepsilon^2\right)^{3/2}} ''' source_match = ''' **Source match:** Shirokov Eq. (1) has exactly the Plummer denominator ... it is not an N-body solver. '''各字段的消费方式:
label/plot_title/description:展示在 Streamlit 按钮、Bokeh 图标题与状态栏中(见 status_text());controls:两个字符串,动态生成两个滑杆的标题(ui.py);color_title/center_label:ColorBar 标题与场中心间距标签;equation/source_match/references/wikipedia:渲染在"Mathematical model"面板的 LaTeX、出处说明与链接中(ui.py)。
七个模式的物理主题与方程出处如下:
| 模式 | 物理主题 | 方程出处(modes.toml 所引) |
|---|---|---|
| vortex | 对转点涡流场 | Nitsche 讲义 Eq. 6.3、6.4a-b |
| gravity | 双软化引力井(Plummer 势) | Shirokov Eq. (1) |
| wave | 双源波干涉 | Feynman Vol. III Eqs. 1.2-1.4 |
| chaotic | 时变混沌搅拌 | Aref et al. (2017) Eq. (8) |
| magnetic | 磁偶极子洛伦兹力偏转 | Feynman Vol. II Eq. 13.1 |
| curl_noise | 无散 curl 噪声湍流 | Bridson Eqs. (1)-(2) + Nitsche 涡核 |
| fountain | 发射器-偏转器粒子喷泉 | NASA 弹道方程 + Shirokov 空间核 |
fountain是唯一打破"网格 + 周期边界"约定的模式:它的粒子从发射器附近带初速抛出、受重力下落、被第二个中心(偏转器)排斥,寿命超过4.8秒或飞出边界就通过pseudo_random()种子函数重生(fountain.js),life数组在这里才真正发挥作用。
九、可拖拽场中心:PointDrawTool与HoverTool的配合
centers之所以"可拖拽",是因为 simulation.py 用PointDrawTool和HoverTool装饰了两个中心的散点渲染器:
center_renderer = plot.scatter("x", "y", source=centers, size=18, fill_color="color", line_color="#f8fafc", ...) center_tool = PointDrawTool(renderers=[center_renderer], add=False, drag=True) center_hover = HoverTool(renderers=[center_renderer], tooltips=None) plot.add_tools(center_tool, center_hover)add=False禁止新增点,只能拖动已有的两个中心;drag=True开启拖拽;- 中心渲染器的
hover_glyph被克隆并加粗线宽,悬停时给出反馈; - 颜色用
#fb7185(玫瑰)与#38bdf8(天蓝)区分两个中心,颜色本身也是centers数据的一列,由 center_data() 提供。
拖动后的数据变化通过centers.on_change("data", centers_changed)监听:回调实时刷新状态栏的center_label间距文本;若点被意外删到不足两个,则回退到默认位置(simulation.py)。由于内核每帧直接从centers.data读取坐标,拖拽是"零延迟"生效的——这就是 README 中"two draggable field-center positions"的完整链路。
十、把整个演示跑起来
该示例的运行方式与仓库内其他 ASGI 示例一致。核心入口是 app.py,它把 Bokeh 应用挂进 Streamlit 应用:
bokeh_application = BokehASGI(modify_document) @asynccontextmanager async def lifespan(_app: st.App) -> AsyncGenerator[None, None]: # Mounted ASGI applications don't receive lifespan events, so the host # starts and stops Bokeh alongside Streamlit. await bokeh_application.core.start() try: yield finally: await bokeh_application.core.stop() app = st.App( Path(__file__).with_name("ui.py"), routes=[Mount("/bkapp", app=bokeh_application)], lifespan=lifespan, )三个要点:
BokehASGI(modify_document):把 simulation.py 的modify_document()包装成 ASGI 应用,每个浏览器会话都会获得一个独立文档;Mount("/bkapp", ...):Bokeh 挂载在/bkapp路径,Streamlit 前端通过st.iframe(f"/bkapp/?viewer={viewer_id}", height=660)嵌入(ui.py);lifespan手动启停:注释说明"挂载的 ASGI 应用收不到 lifespan 事件",因此宿主(Streamlit)负责在启动/停止时同步启停 Bokeh 核心。
viewer_id是 Streamlit 会话里uuid4().hex生成的随机串,通过 URL 查询参数传给 Bokeh 文档;simulation.py 从session_context.request.arguments中解析它,从而在ViewerRegistry中按视图隔离状态。README 层面需要注意的局限在 ui.py 有明确说明:viewer 注册表是进程本地的,多 worker 部署时需要换成外部存储或消息代理。
运行时,Streamlit 会给出一个本地地址(典型如http://localhost:8501),浏览器打开即进入演示页。仓库的集成测试 tests/integration/server_e2e/test_examples.py 正是用_running_app("streamlit_particles.app:app")拉起该应用做端到端验证。
十一、源码级验证:单元测试如何守护这套架构
该示例不是孤立代码,仓库里有两处测试直接覆盖它:
- 端到端集成测试:tests/integration/server_e2e/test_examples.py 启动
streamlit_particles.app:app并交互验证; - ASGI 单元测试:tests/unit/bokeh/server/test_asgi.py 做了多层面断言:
- 校验
streamlit_particles/app.py与ui.py的配对关系; - 加载 state.py 与 simulation.py 源码,断言
chaotic、curl_noise等全部 7 个模式均被识别; - 导入
viewer_states验证 ViewerRegistry 的线程安全状态读写。
- 校验
这些测试印证了 README 描述的两个协议约定:其一,modes.toml是模式元数据的唯一事实源(modes.py 解析、测试校验);其二,cb_data的action字段契约(内核统一以if (cb_data.action === "step")入口)。
总结
从 js/README.md 出发,本文还原了一套可复用的 Bokeh 浏览器端动画架构模板:
- 驱动与内核分离:
driver.js只管requestAnimationFrame节流与调度,物理逻辑全部收敛到按模式分文件的CustomJS内核; - 数据流单向且低频:Python 只在换模式/重置时通过
CustomJS.code替换与float32二进制数组初始化介入,动画期间的逐帧更新完全在浏览器本地完成; - 无分配内循环:
helpers.js的速度限制、周期边界与半隐式积分均以逐元素方式工作,支撑 50,000 粒子的高频遍历; - 元数据驱动 UI:
modes.toml同时驱动方程展示、控制滑杆、颜色标题与参考文献,新增一种物理模式只需新增一个 TOML 条目与一个 JS 内核文件。
这套"Python 初始化、浏览器演化、WebSocket 只传输低频快照"的模式,可作为 Bokeh 高频可视化(粒子系统、流体示意、物理演示)的参考基线:在需要真正交互式仿真时,优先考虑把高帧率计算放在CustomJS侧,而不是让数据每帧跨网络往返。
【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考