尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Bokeh 浏览器端粒子动画:基于 CustomJS、requestAnimationFrame 与 WebGL 的高频渲染架构解析

Bokeh 浏览器端粒子动画:基于 CustomJS、requestAnimationFrame 与 WebGL 的高频渲染架构解析 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.jsPython 初始化模式切换时替换内核代码、重置时生成新的 NumPy 数组simulation.py模式元数据每种模式的控制项、方程、参考文献modes.tomlREADME 特别说明该目录下的 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.pyparticles ColumnDataSource(dataparticle_data(initial, initial_centers), nameparticles) centers ColumnDataSource(datainitial_centers, namecenters) controls ColumnDataSource(datacontrol_data(initial), namecontrols)而内核的组装方式为evolution CustomJS( args{centers: centers, particles: particles}, codekernel_code(initial.mode), nameparticle-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、strength、rate、time和dtPython 负责模式相关的初始化因此内核只在两次重置之间推进模拟。以内核文件的开头统一模式为例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.js50,000 粒子内循环的无分配地基README 强调Python 把helpers.js前置拼接到每个内核之前其中实现的辅助函数负责速度限制、周期边界与积分且在内循环中不分配任何对象——这是 50,000 粒子逐帧遍历能保持流畅的关键。helpers.js 定义了全局常量与四个函数常量值含义GRID_WIDTH/GRID_HEIGHT250 / 200粒子网格尺寸50,000 250×200X_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_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_{j1}^{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.pycolor_title/center_labelColorBar 标题与场中心间距标签equation/source_match/references/wikipedia渲染在Mathematical model面板的 LaTeX、出处说明与链接中ui.py。七个模式的物理主题与方程出处如下模式物理主题方程出处modes.toml 所引vortex对转点涡流场Nitsche 讲义 Eq. 6.3、6.4a-bgravity双软化引力井Plummer 势Shirokov Eq. (1)wave双源波干涉Feynman Vol. III Eqs. 1.2-1.4chaotic时变混沌搅拌Aref et al. (2017) Eq. (8)magnetic磁偶极子洛伦兹力偏转Feynman Vol. II Eq. 13.1curl_noise无散 curl 噪声湍流Bridson Eqs. (1)-(2) Nitsche 涡核fountain发射器-偏转器粒子喷泉NASA 弹道方程 Shirokov 空间核fountain是唯一打破网格 周期边界约定的模式它的粒子从发射器附近带初速抛出、受重力下落、被第二个中心偏转器排斥寿命超过4.8秒或飞出边界就通过pseudo_random()种子函数重生fountain.jslife数组在这里才真正发挥作用。九、可拖拽场中心PointDrawTool与HoverTool的配合centers之所以可拖拽是因为 simulation.py 用PointDrawTool和HoverTool装饰了两个中心的散点渲染器center_renderer plot.scatter(x, y, sourcecenters, size18, fill_colorcolor, line_color#f8fafc, ...) center_tool PointDrawTool(renderers[center_renderer], addFalse, dragTrue) center_hover HoverTool(renderers[center_renderer], tooltipsNone) plot.add_tools(center_tool, center_hover)addFalse禁止新增点只能拖动已有的两个中心dragTrue开启拖拽中心渲染器的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 dont 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, appbokeh_application)], lifespanlifespan, )三个要点BokehASGI(modify_document)把 simulation.py 的modify_document()包装成 ASGI 应用每个浏览器会话都会获得一个独立文档Mount(/bkapp, ...)Bokeh 挂载在/bkapp路径Streamlit 前端通过st.iframe(f/bkapp/?viewer{viewer_id}, height660)嵌入ui.pylifespan手动启停注释说明挂载的 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 粒子的高频遍历元数据驱动 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),仅供参考
返回列表