Bokeh 浏览器端粒子动画:基于 CustomJS、requestAnimationFrame 与 WebGL 的高频渲染架构解析
2026/9/14 18:57:59 网站建设 项目流程

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.jsgravity.jswave.jschaotic.jsmagnetic.jscurl_noise.jsfountain.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"dtstrengthratetime五个字段。
  • 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:包含xyvxvylife以及归一化的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,000fountain模式则从发射器位置随机撒粒子并预先按弹道外推位置。magnetic模式还会给粒子赋一组正弦调制的初始速度,让磁场效果更快显现。


四、cb_data逐帧传参:内核只推进、不重置

README 明确约定:每一帧 driver 通过cb_data传入action: "step"strengthratetimedt;Python 负责模式相关的初始化,因此内核只在两次重置之间推进模拟

以内核文件的开头统一模式为例,gravity.js 和 vortex.js 都写成:

if (cb_data.action === "step") { const {dt, strength, rate} = cb_data // ... 数值推进 }

这说明action字段是一种可扩展的协议:将来若需要"reset"等动作,Python 只要发送带不同actioncb_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参与背景流相位,使流动随时间缓慢演变。strengthrate分别映射为环量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_HEIGHT250 / 200粒子网格尺寸(50,000 = 250×200
X_MIN/X_MAX-3 / 3水平边界
Y_MIN/Y_MAX-2 / 2垂直边界
MAX_SPEED2.5速度上限(归一化到 1 的基准)
SOFTENING_SQUARED0.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_particleintegrate_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 内核拼到浏览器里;
  • 重置/换模式:重新生成centersparticles两个ColumnDataSource的完整数据——粒子回到网格或发射器初始状态;
  • 控制更新controls.data始终同步 Python 侧的最新值,driver 每帧从浏览器端直接读取。

mode_changedreset_requested的判定来自ViewerState的版本号机制:state.py 中每次update()都会revision += 1,显式重置时额外reset_count += 1simulation.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.jsmagnetic.jscurl_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数组在这里才真正发挥作用。


九、可拖拽场中心:PointDrawToolHoverTool的配合

centers之所以"可拖拽",是因为 simulation.py 用PointDrawToolHoverTool装饰了两个中心的散点渲染器:

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, )

三个要点:

  1. BokehASGI(modify_document):把 simulation.py 的modify_document()包装成 ASGI 应用,每个浏览器会话都会获得一个独立文档;
  2. Mount("/bkapp", ...):Bokeh 挂载在/bkapp路径,Streamlit 前端通过st.iframe(f"/bkapp/?viewer={viewer_id}", height=660)嵌入(ui.py);
  3. 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")拉起该应用做端到端验证。


十一、源码级验证:单元测试如何守护这套架构

该示例不是孤立代码,仓库里有两处测试直接覆盖它:

  1. 端到端集成测试:tests/integration/server_e2e/test_examples.py 启动streamlit_particles.app:app并交互验证;
  2. ASGI 单元测试:tests/unit/bokeh/server/test_asgi.py 做了多层面断言:
    • 校验streamlit_particles/app.pyui.py的配对关系;
    • 加载 state.py 与 simulation.py 源码,断言chaoticcurl_noise等全部 7 个模式均被识别;
    • 导入viewer_states验证 ViewerRegistry 的线程安全状态读写。

这些测试印证了 README 描述的两个协议约定:其一,modes.toml是模式元数据的唯一事实源(modes.py 解析、测试校验);其二,cb_dataaction字段契约(内核统一以if (cb_data.action === "step")入口)。


总结

从 js/README.md 出发,本文还原了一套可复用的 Bokeh 浏览器端动画架构模板:

  • 驱动与内核分离driver.js只管requestAnimationFrame节流与调度,物理逻辑全部收敛到按模式分文件的CustomJS内核;
  • 数据流单向且低频:Python 只在换模式/重置时通过CustomJS.code替换与float32二进制数组初始化介入,动画期间的逐帧更新完全在浏览器本地完成;
  • 无分配内循环helpers.js的速度限制、周期边界与半隐式积分均以逐元素方式工作,支撑 50,000 粒子的高频遍历;
  • 元数据驱动 UImodes.toml同时驱动方程展示、控制滑杆、颜色标题与参考文献,新增一种物理模式只需新增一个 TOML 条目与一个 JS 内核文件。

这套"Python 初始化、浏览器演化、WebSocket 只传输低频快照"的模式,可作为 Bokeh 高频可视化(粒子系统、流体示意、物理演示)的参考基线:在需要真正交互式仿真时,优先考虑把高帧率计算放在CustomJS侧,而不是让数据每帧跨网络往返。

【免费下载链接】bokehInteractive Data Visualization in the browser, from Python项目地址: https://gitcode.com/GitHub_Trending/bo/bokeh

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询