十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cesium地形粒子高效渲染:GPGPU与着色器碰撞技术实践

Cesium地形粒子高效渲染:GPGPU与着色器碰撞技术实践 如果你的项目需要在 Cesium 场景里做大量带碰撞和喷涌效果的地形粒子那么 CPU 侧的粒子逻辑很快就会碰到性能瓶颈。把粒子状态更新转移到 GPU 上让物理计算、地形碰撞、喷射反弹都发生在着色器里才有可能在大场景、大数量粒子下仍然保持流畅。本文会从原理到实现拆解一套 Cesium GPGPU 的粒子系统路线。内容包括总体架构、高程纹理准备、Ping-Pong 状态更新、着色器里的地形碰撞模型、60FPS 优化清单和常见坑点。如果你是做数字孪生、地形动态效果或三维可视化平台的开发者这套思路可以直接作为技术方案的参考。1. 为什么大范围地形粒子需要 GPGPU1.1 CPU 粒子系统的瓶颈在哪里Cesium 自带粒子系统最基本的工作方式是在 JavaScript 侧保存粒子数组每帧遍历每个粒子更新位置、速度、生命周期然后把粒子位置交给场景渲染。这种方案的优点是方便但问题也很直接。当粒子数量从几千涨到几万甚至几十万时每帧需要同步遍历的对象会变得非常多。JavaScript 的数组遍历看起来不慢但你还要处理碰撞检测、喷泉发射逻辑、粒子与地形的相对位置计算。一旦场景里同时还有大量 3D Tiles、实时地形、标注和动态实体浏览器主线程就会被任务塞满。在真实的大地形项目中CPU 粒子往往不是单独卡而是和场景渲染抢时间。Cesium 每帧都需要更新相机、遍历 Primitive、处理地形瓦片调度、上传渲染指令。如果你的粒子逻辑还要在主线程里跑大量for循环帧率就会从 60 掉到 30甚至更低。1.2 GPGPU 能解决什么问题GPGPU 全称是 General-Purpose computing on Graphics Processing Unit也就是用 GPU 做通用计算。在图形学领域一个常用思路是把粒子状态存入纹理。粒子的位置、速度、剩余寿命都变成纹理像素。更新粒子状态时GPU 会并行处理每一个像素相当于同时更新每一个粒子。因为 GPU 的核心数量远多于 CPU几个并行的着色器 pass 就能更新几十万粒子很多计算还不需要回到 CPU。我们平时说的 GPU 粒子、GPU Simulation本质都是这种数据驱动方案。CPU 不再逐粒子计算物理只需要把地形高度纹理、发射器参数、时间步长等小数据传给 GPU。这样一来粒子数量对主线程的压力会小很多Cesium 的相机交互、瓦片加载、场景渲染就拥有了更充裕的帧时间。1.3 概念澄清粒子效果不等于粒子群算法搜索资料时容易看到“粒子群算法”这个概念例如粒子群优化算法、多目标粒子群优化算法。这里需要澄清一下。粒子群优化算法是一种仿生智能优化算法模拟鸟群觅食用于求解最优化问题。而本文讨论的是计算机图形学里的粒子系统用于模拟喷泉、烟雾、地形喷涌等视觉效果。两者虽然都叫粒子但目标完全不同。本文所有内容围绕粒子系统展开不涉及粒子群优化算法。理解这一点能避免后续阅读资料时被混淆。2. 总体方案与架构设计2.1 常见实现路线对比在 Cesium 项目里实现大规模 GPU 粒子一般有几种路线。第一种是直接改 Cesium 内部 Primitive 渲染在场景的 draw call 链里插入自定义着色器这是深度最高的方案。优点是可以和 3D Tiles、地形做真实遮挡和深度测试缺点是需要了解 Cesium 内部的上下文、Framebuffer 管理版本升级时存在接口变动风险。第二种是使用 CustomShader 在 Primitive 级做表现层处理但它更多用于修饰模型材质不是为维护几十万粒子的物理模拟而设计的不能完全替代 GPGPU 状态更新。第三种是双画布方案。Cesium 主场景保持自己的 canvas粒子的 GPGPU 模拟和渲染放在另一个叠加的 canvas 上通过透明背景与 Cesium 合成。第三种方案工程化最容易也是我个人比较推荐的起步路线。它不侵入 Cesium 渲染内核把 GPGPU 部分收敛成独立模块底层使用 WebGL2 做纹理计算和粒子渲染。缺点是遮挡精度比内嵌 Primitive 略弱但通过投影矩阵计算粒子位置依然能正确显示在地形表面附近。本文的代码以双画布方案为载体。原因很简单它能让系统在较常见版本的 Cesium 上稳定运行不会因为 Cesium 内部私有 API 变化而让整个项目崩塌。2.2 GPGPU 粒子系统数据流整体流程可以拆成下面几个环节创建两个 canvas一个 Cesium 场景 canvas一个粒子 canvas。用粒子 canvas 的 WebGL2 上下文创建位置纹理和速度纹理。地形高程经过预处理生成一张高度图纹理。每一帧在 GPU 上执行一次 Ping-Pong 更新读取上一帧粒子状态写入下一帧粒子状态。用当前帧粒子状态生成gl.POINTS渲染并把投影矩阵从 Cesium 同步到粒子着色器。粒子 canvas 透明叠加在 Cesium 场景上。这里最关键的点是粒子状态更新过程中数据不需要回读 CPU。CPU 每帧要做的事情很少主要是同步矩阵、传 Uniform、触发绘制命令。2.3 本方案中的“地形粒子动态碰撞”在大范围地形场景中粒子碰撞通常不需要和每个三角网格做精确求交。地形网格数量太大精确碰撞的代价极高。实际项目中更常见的做法是把地形转成高程场。以一定分辨率的地形纵向范围为准建立二维高度纹理。粒子在物理更新时从高度纹理中读取当前位置下方地形高度从而判断是否发生碰撞。这个思路类似地形转栅格后的高度场计算。也就是说你不需要知道当前粒子正下方是哪个三角形只需要知道大地坐标(x, z)对应的地表高度y。标题里说的动态碰撞主要有两部分粒子从喷涌源喷出后受重力影响下落撞击到地形表面。粒子撞击地形后发生反弹、滚动或沿坡面下滑。这两部分都可以在着色器内完成。2.4 为什么高帧率需要控制 Pass 数量稳定 60 帧的前提是单帧 GPU 负担不要过重。粒子模拟中最容易拖垮帧率的不是粒子数量而是多个重复的全屏/全纹理 pass。如果每次更新都拆成重力 pass、碰撞 pass、渲染 pass每个 pass 都要绑定纹理、切换 Framebuffer、重新设置视口开销就会被放大。我们的方案要把更新逻辑尽量压成一个 pass。粒子发射、重力、阻力、地形碰撞、寿命判断全部写在一个 fragment shader 里。渲染再单独一个 pass这样每帧最多两个 pass。对于大量粒子这是非常关键的架构决策。3. 环境准备与版本说明3.1 开发环境建议本文属于中等难度的图形学开发需要具备基本的 WebGL 和三维数学基础。建议环境如下浏览器Chrome 或 Edge需要支持 WebGL2。目前在主流桌面浏览器上 WebGL2 已基本普及。前端框架可以使用 Vite、Webpack 或直接纯 HTML 演示。本文示例偏向纯 HTML JavaScript。Cesium建议使用较新的发布版本。Cesium 的 Camera、Matrix4、Transforms 等 API 比较稳定大量版本都能运行。粒子绘制层原生 WebGL2 API不依赖第三方粒子库。版本需要根据你的项目实际情况调整。本文示例以常见环境为例重点演示配置思路。3.2 页面结构与双画布初始化首先准备一上一下两个画布。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCesium GPGPU Terrain Particle/title style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } #cesiumContainer { position: relative; } #gpgpuCanvas { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; } /style /head body div idcesiumContainer/div canvas idgpgpuCanvas/canvas script srchttps://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Cesium.js/script script srchttps://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Widgets/widgets.css/script script // 后续核心代码放这里 /script /body /htmlCSS 中把pointer-events设置为none是为了粒子不拦截用户的鼠标操作。用户依然可以旋转、缩放 Cesium 场景。需要说明的是上面引用的版本号只是示例。实际项目建议到 Cesium 官方查看当前稳定版本避免使用太旧或太新的版本导致 API 不一致。然后在 JavaScript 中初始化两个上下文。const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, shouldAnimate: true }); viewer.scene.globe.enableLighting false; viewer.scene.globe.depthTestAgainstTerrain true;粒子画布需要单独的 WebGL2 上下文。const particleCanvas document.getElementById(gpgpuCanvas); const gl particleCanvas.getContext(webgl2, { alpha: true, premultipliedAlpha: false, antialias: false, depth: false }); if (!gl) { throw new Error(当前浏览器不支持 WebGL2); }这里有一个容易忽略的细节粒子画布不要开启 depth因为它是透明叠加层主要做粒子点渲染。加 antialias 反而可能影响性能和透明合成。3.3 与 Cesium 场景同步GPGPU 粒子的更新需要和 Cesium 每帧渲染保持一致不能单独使用requestAnimationFrame死循环。更推荐在 Cesium 的postRender事件里触发粒子模拟。这样无论 Cesium 场景渲染频率怎么变化粒子更新都会跟随。viewer.scene.postRender.addEventListener(() { const clock viewer.clock; const dt clock.currentTime.secondsOfDay - lastTimeSecondsOfDay; lastTimeSecondsOfDay clock.currentTime.secondsOfDay; const projectedDt Math.min(dt, 1.0 / 30.0); const localToWorld Cesium.Transforms.eastNorthUpToFixedFrame(origin); const worldToClip Cesium.Matrix4.multiply( viewer.scene.camera.frustum.projectionMatrix, viewer.scene.camera.viewMatrix, new Cesium.Matrix4() ); particleSystem.update(projectedDt); particleSystem.render(localToWorld, worldToClip); });上面的lastTimeSecondsOfDay需要在一开始记录。对 dt 做上限限制是为了避免浏览器窗口被切到后台恢复后粒子一次性跨过大量时间导致位置跳跃。localToWorld是把粒子本地模拟坐标系转换到世界坐标系。使用东北上坐标系作为粒子模拟坐标系可以避免直接使用经纬度高精度坐标时出现的数值精度问题。4. 地形高程纹理与碰撞预处理4.1 为什么需要高度纹理Cesium 的地形网格在每帧会动态调度客户端很难在着色器里实时拿到网格高度。因此要在地形上做粒子碰撞我们需要把地形表面高度提前提取出来形成一张二维高度纹理。这张纹理可以理解为“地形转栅格”结果把一片范围划分成等间距网格记录每个网格位置的地表高度。然后粒子在 GPGPU 更新阶段读取该纹理得到当前经纬度位置的地形高度进而实现碰撞。如果你看过“地形起伏度如何计算”这类问题思路是类似的。起伏度本质上是单位区域内最高点与最低点的高差。起伏越大的区域高度纹理需要的分辨率越高否则粒子碰撞结果会比较粗糙。4.2 地形高度采集策略地形高度采集分为离线预处理和运行时采样两种。如果是数字孪生项目地形范围固定推荐离线把高度数据导出成栅格图片或二进制数组运行时直接上传纹理。如果是运行时动态切换地形则可以使用 Cesium 的地形采样接口先对一组经纬度点进行采样再把结果写入纹理。下面是一个运行时采样的思路async function createHeightTexture(viewer, lonLatBounds, gridSize) { const terrainProvider viewer.terrainProvider; const west lonLatBounds.west; const south lonLatBounds.south; const east lonLatBounds.east; const north lonLatBounds.north; const width gridSize.width; const height gridSize.height; const positions []; for (let row 0; row height; row) { for (let col 0; col width; col) { const lng west (east - west) * col / (width - 1); const lat south (north - south) * row / (height - 1); positions.push(Cesium.Cartographic.fromDegrees(lng, lat)); } } // 对采样点进行地形高度采样 const sampled await Cesium.sampleTerrainMostDetailed(terrainProvider, positions); // sampled 数组中每个元素都有 height 属性 const heightArray new Float32Array(width * height); for (let i 0; i sampled.length; i) { const h sampled[i].height; heightArray[i] Cesium.defined(h) ? h : 0; } return uploadHeightTexture(width, height, heightArray); }这段代码需要结合你的实际地形范围调整。使用时要注意sampleTerrainMostDetailed在粒子数量大、采样点数多时耗时较长因此尽量不要每帧调用而是在地形加载完成后做一次。4.3 高度纹理上传到 WebGL2在 WebGL2 中可以直接创建浮点纹理。如果精度支持足够建议使用gl.RGBA32F或gl.R32F。R32F单通道浮点纹理在部分设备上可能存在采样限制。为了兼容性更稳妥的做法是使用RGBA32F把高度放在 R 通道G、B、A 暂时留空或存放标志位。function uploadHeightTexture(width, height, heightArray) { const texture gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, width, height); const rgbaData new Float32Array(width * height * 4); for (let i 0; i width * height; i) { rgbaData[i * 4] heightArray[i]; } gl.texSubImage2D( gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.FLOAT, rgbaData ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); return texture; }这里必须使用NEAREST过滤。高度纹理不建议用线性过滤因为地形高度在边界处可能突变线性插值会造成粒子被埋进地形或浮在半空。需要注意高度纹理的宽高不需要与粒子纹理一致它可以独立设置。高度纹理的分辨率由地形范围和精度需求共同决定。4.4 纹理坐标与经纬度映射在着色器里进行碰撞检测时我们需要把粒子的本地坐标换算成高度纹理的纹理坐标。假设粒子模拟坐标系的原点设置为地形范围的西南角东北方向为 X北方为 Z那么粒子本地坐标(x, z)可以直接映射到高度纹理u (x - worldMinX) / worldSizeX v (z - worldMinZ) / worldSizeZ本地坐标系单位建议统一为米。这样你可以直观地用米作为物理单位例如重力取9.8粒子初速度取25.0碰撞反弹高度取0.1米。假如粒子原点直接使用经纬度物理计算会变得复杂。因此本文强烈建议粒子模拟在本地平面坐标系中进行只在渲染时通过localToWorld矩阵转换到 Cesium 世界坐标。5. GPGPU 粒子物理核心实现5.1 粒子状态纹理与 Ping-Pong粒子系统最核心的数据是状态纹理。我们使用两张位置纹理和两张速度纹理组成双缓冲。每帧更新时读取上一缓冲区的粒子数据把计算结果写入下一缓冲区然后交换引用。这个过程叫 Ping-Pong。它之所以必要是因为 GPU 不能在同一帧里同时读写同一张纹理否则不同像素会读到新旧混杂的数据。一个典型的位置纹理布局RGB当前粒子在本地坐标系中的位置。A粒子已经存活的时间。速度纹理布局RGB当前速度向量。A粒子最大生命时间。function createStateTexture(width, height) { const texture gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, width, height); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.bindTexture(gl.TEXTURE_2D, null); return texture; }两个纹理都绑定到同一个 Framebuffer 的不同颜色附件上这样一次更新 pass 就可以同时输出位置和速度。function createParticleFBO(posTex, velTex) { const fbo gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, posTex, 0); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, velTex, 0); gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]); gl.bindFramebuffer(gl.FRAMEBUFFER, null); return fbo; }WebGL2 中默认支持多渲染目标不需要额外扩展。这一点对更新性能很重要一个 pass 输出两张纹理比拆成两个 pass 高效很多。5.2 更新着色器发射、重力与碰撞更新 pass 使用全屏四边形的方式。片元遍历的每个像素对应一个粒子ID片元着色器中通过gl_FragCoord.xy作为粒子索引。这是目前比较标准的 GPGPU 做法。粒子数量不是非得是纹理宽度乘纹理高度但为了简化建议让粒子总数等于纹理像素总数。下面是一段核心更新着色器集中展示了喷涌、重力和高度碰撞的逻辑#version 300 es precision highp float; uniform sampler2D u_posTex; uniform sampler2D u_velTex; uniform sampler2D u_heightTex; uniform float u_dt; uniform vec3 u_emitterPos; uniform vec3 u_emitterDir; uniform float u_emitterStrength; uniform float u_gravity; // 本地坐标范围用于高度纹理采样 uniform vec2 u_worldMin; uniform vec2 u_worldSize; uniform vec2 u_texelSize; layout(location 0) out vec4 outPos; layout(location 1) out vec4 outVel; float hash21(vec2 p) { p fract(p * vec2(123.34, 456.21)); p dot(p, p 45.32); return fract(p.x * p.y); } void main() { ivec2 texelCoord ivec2(gl_FragCoord.xy); vec4 posData texelFetch(u_posTex, texelCoord, 0); vec4 velData texelFetch(u_velTex, texelCoord, 0); vec3 pos posData.xyz; vec3 vel velData.xyz; float life velData.a; float age posData.a; if (age life) { // 重新从发射器生成粒子 float seed1 hash21(vec2(float(texelCoord.x), float(texelCoord.y))); float seed2 hash21(vec2(float(texelCoord.y), float(texelCoord.x))); vec3 randomDir normalize(vec3( seed1 * 2.0 - 1.0, 0.5 seed2 * 0.8, seed1 * 2.0 - 1.0 )); pos u_emitterPos; vel u_emitterDir * u_emitterStrength * (0.6 seed1 * 0.8); vel randomDir * u_emitterStrength * 0.35; age 0.0; life 2.0 seed2 * 3.0; } // 重力累积 vel.y - u_gravity * u_dt; // 简单的空气阻力避免粒子飞舞速度过快 vel * max(0.0, 1.0 - 0.08 * u_dt); // 更新位置 pos vel * u_dt; age u_dt; // 高度纹理碰撞 vec2 terrainUV (pos.xz - u_worldMin) / u_worldSize; if (terrainUV.x 0.0 terrainUV.x 1.0 terrainUV.y 0.0 terrainUV.y 1.0) { float groundHeight texture(u_heightTex, terrainUV).r; if (pos.y groundHeight 0.05) { pos.y groundHeight 0.05; // 反弹同时削弱速度 if (vel.y 0.0) { vel.y -vel.y * 0.32; } // 增加地面摩擦力 vel.xz * max(0.0, 1.0 - 1.8 * u_dt); } } outPos vec4(pos, age); outVel vec4(vel, life); }这是整个系统最关键的一段逻辑。你可以看到喷涌和地形碰撞并没有拆成多个 pass而是在同一个片元着色器中完成。片元着色器结束后位置和速度会分别写入下一轮纹理。5.3 发射器参数设计发射器应该抽象成可配置参数而不是硬编码在着色器里。一般情况下喷涌源会包含这些参数发射器位置用于指定粒子出生点。发射器方向决定粒子整体喷射朝向。初速度大小影响喷涌高度。喷射锥角决定粒子扩散范围。重力大小影响粒子抛物线轨迹。粒子寿命范围控制粒子从出生到消失的时间。代码里可以通过 Uniform 传入。项目中如果同时存在多个喷涌点可以把发射器参数存入一张纹理或 Uniform 数组。数量较少时Uniform 数组最简单。gl.useProgram(updateProgram); gl.uniform3f(uEmitterPos, 0.0, 10.0, 0.0); gl.uniform3f(uEmitterDir, 0.0, 1.0, 0.0); gl.uniform1f(uEmitterStrength, 60.0); gl.uniform1f(uGravity, 35.0);如果你使用的是虚拟现实场景喷射方向和速度也可以设计成通过向量旋转来控制使效果适合数字人、交互等场景。5.4 粒子渲染与矩阵同步更新完成后下一步是渲染粒子。粒子渲染使用gl.POINTS每个粒子最终绘制为屏幕上的一个点。顶点着色器根据粒子纹理采样到的数据计算当前粒子坐标再转换到裁剪空间。#version 300 es precision highp float; uniform sampler2D u_posTex; uniform sampler2D u_velTex; uniform mat4 u_localToWorld; uniform mat4 u_worldToClip; uniform float u_textureWidth; uniform float u_textureHeight; uniform vec2 u_viewportSize; out float vAge; out float vLife; void main() { int row gl_VertexID / int(u_textureWidth); int col gl_VertexID % int(u_textureWidth); ivec2 texCoord ivec2(col, row); vec4 posData texelFetch(u_posTex, texCoord, 0); vec4 velData texelFetch(u_velTex, texCoord, 0); vec3 localPos posData.xyz; vec4 worldPos u_localToWorld * vec4(localPos, 1.0); gl_Position u_worldToClip * worldPos; vAge posData.a; vLife velData.a; float sizeScale 8.0; gl_PointSize sizeScale * (300.0 / max(1.0, gl_Position.w)); }片元着色器负责把粒子画成柔和的圆点。#version 300 es precision highp float; in float vAge; in float vLife; out vec4 fragColor; void main() { vec2 uv gl_PointCoord - vec2(0.5); float dist length(uv); float alpha 1.0 - smoothstep(0.1, 0.5, dist); float lifeRatio vLife 0.0 ? clamp(vAge / vLife, 0.0, 1.0) : 1.0; alpha * 1.0 - lifeRatio; fragColor vec4(1.0, 0.55, 0.2, alpha * 0.9); }在 WebGL 中启用混合模式粒子叠加效果会更自然。gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);如果需要热粒子或发光粒子也可以把混合模式改成gl.ONE, gl.ONE_MINUS_SRC_ALPHA或者使用加法混合。加法混合适合模拟岩浆喷涌、能量特效效果更亮眼。然后是传递矩阵。Cesium 的矩阵是列主序WebGL 的uniformMatrix4fv默认也是列主序因此可以直接传入不需要转置。gl.uniformMatrix4fv(uLocalToWorldLoc, false, localToWorld); gl.uniformMatrix4fv(uWorldToClipLoc, false, worldToClip);接下来执行粒子绘制gl.drawArrays(gl.POINTS, 0, particleCount);一次drawArrays绘制所有粒子这就是渲染性能高的另一个原因。CPU 不需要连续调用上千次绘制接口只需要一次。6. 喷涌效果与地形碰撞细节打磨6.1 喷涌初速度模型喷涌效果的视觉质量通常取决于初速度模型。如果所有粒子初速度都一样喷泉形状会像一个整齐的圆柱不够自然。自然界的喷涌具有随机性。可以让粒子在主轴方向拥有一个基础速度同时叠加围绕主轴的随机扰动。具体到着色器实现核心思路是取发射方向作为基础向量。在发射方向周围生成一个随机小锥角。给一部分粒子添加随机切向分量让喷涌有“炸开”的感觉。下面是常见的处理片段float randomAngle seed1 * 6.2831853; float randomRadius sqrt(seed2) * 0.6; vec3 tangent1 normalize(cross(u_emitterDir, vec3(0.0, 1.0, 0.0))); if (length(tangent1) 0.01) { tangent1 normalize(cross(u_emitterDir, vec3(1.0, 0.0, 0.0))); } vec3 tangent2 normalize(cross(u_emitterDir, tangent1)); vec3 offsetDir u_emitterDir tangent1 * cos(randomAngle) * randomRadius tangent2 * sin(randomAngle) * randomRadius; vel normalize(offsetDir) * u_emitterStrength;这种方法能很方便地做圆形喷射、锥形喷涌和尘暴效果。大规模地形粒子要做得耐看发射源和随机模型的组合非常关键。6.2 地形碰撞后的坡面滑动前面提到高度纹理碰撞后粒子会弹起。但在真实地形中粒子落在斜坡上时不会原地反复垂直反弹。它应该沿坡面向下滚动或滑落。实现方式可以不必做真实的刚体碰撞只需要在高度纹理采样时加上梯度计算思想。通过比较当前点前后左右的高度差估算坡面方向。vec2 uv clamp(terrainUV, vec2(0.0), vec2(1.0)); float hCenter texture(u_heightTex, uv).r; float hRight texture(u_heightTex, uv vec2(u_texelSize.x, 0.0)).r; float hLeft texture(u_heightTex, uv - vec2(u_texelSize.x, 0.0)).r; float hUp texture(u_heightTex, uv vec2(0.0, u_texelSize.y)).r; float hDown texture(u_heightTex, uv - vec2(0.0, u_texelSize.y)).r; vec3 slope vec3(hLeft - hRight, 0.0, hDown - hUp); slope.y -2.0; slope normalize(slope);然后在地面摩擦阶段把速度向量的一部分投影到坡面切向。这种模拟并不追求物理绝对精确但视觉上已经能让人感觉到粒子在地形上“流动”。6.3 与地形起伏度相关的细节如果项目关注地形起伏度可以额外考虑一点在起伏度较大的山区高度纹理采样间隔如果太稀疏粒子容易看到“穿地”或“悬浮”现象。这是因为高度纹理没有保存地形变化最剧烈的信息。遇到这种情况增大高度纹理分辨率即可。比如把 256x256 的高度纹理提升到 1024x1024碰撞精度会明显提升。但提高分辨率也意味着内存和采样开销增加。实际工程中建议按“视线范围”选择不同分辨率或者把地形分成多个区块每格拥有独立的局部高度纹理。7. 稳定 60 FPS 的性能优化清单7.1 避免 CPU 回读 GPU 数据在粒子系统中最需要警惕的操作是从 GPU 纹理中读取数据回 CPU。如果每帧都执行gl.readPixels读取粒子状态相当于把 GPU 的并行计算退化成同步等待帧率会急剧下降。调试阶段可以回读但在生产逻辑中应避免。所有物理判断都尽可能在着色器内完成。例如碰撞检测、生命周期判断、粒子是否进入新发射区都不回读。只把最终的渲染结果留在 GPU 上这是稳定 60 帧的基础。7.2 粒子纹理尺寸与数量匹配纹理尺寸和粒子总数不一定完全相等但为了简化索引最好让二者一致。一个纹理像素数如果乘以系统的单粒子状态可以得到 GPU 内存占用。下面是常见纹理尺寸与最大粒子数关系纹理尺寸粒子数适用场景128 x 12816384小规模喷洒、喷泉测试256 x 25665536中规模地形粒子512 x 512262144较大范围场景粒子1024 x 10241048576极限粒子数量场景需要注意粒子数量越大更新着色器和渲染开销越高。通常不用一上来就设成 100 万粒子要根据实际屏幕覆盖率和视觉需求调整。如果你的目标只是地面喷涌效果屏幕粒子可能同时只有 2 万到 10 万处于可视范围。此时没有必要创建过大的纹理。更成熟的方案是按屏幕 LOD 分级远处粒子使用较低更新频率或合并成大的精灵近处粒子保证精细运动。这个优化需要在设计阶段预留分层接口。7.3 用帧率变化粒度约束物理步长粒子系统的物理更新需要时间步长。之前已经提到把 dt 限制在1/30秒左右。实际上还可以更好把物理步长固定为 1/60 秒然后按累积时间多次计算。不过对视觉粒子系统帧率波动较大时过度精确的固定步长可能导致粒子“闪跳”。我的经验是限制最大 dt 就够了。在慢动作或高刷屏场景中可以动态调整步长但总时间不能跳跃。7.4 控制绘制纹理采样数量和额外开销粒子更新、粒子渲染、高度纹理采样已经各自绑定一张纹理。如果场景还存在多个喷射源、阴影纹理、速度场纹理那么纹理单元数量会上升。纹理单元数量过高时即使计算量不大也会影响缓存命中率。建议在调试面板里直观显示当前绑定的纹理数量。出现帧率异常时优先检查update 着色器是否进行了多余的采样。渲染 pass 是否绑定不必要的高度纹理。是否每帧创建了新的 WebGLProgram。是否每帧创建新的 Float32Array 数据。在双画布方案中保持粒子的 canvas 尺寸与 Cesium canvas 接近即可避免过大的渲染尺寸。因为粒子 canvas 的最终像素范围并不需要很高真正影响画面精度的只有粒子的渲染位置和大小。7.5 Cesium 场景侧的处理Cesium 渲染本身就比较重启用 MSAA、高质量阴影、超高分辨率后留给粒子的帧预算会受到挤压。如果要稳定 60 帧建议从整体场景考虑合理设置Cesium.Viewer的分辨率比例。避免不必要的抗锯齿叠加。视野中有大量 3D Tiles 瓦片在加载时可以先减少粒子数量。用场景相机距离控制粒子数量或喷射速率。粒子层在 GPU 上的开销本身可以很小但整条链路的瓶颈常常是 Cesium 场景自身的渲染负载。把粒子系统和场景资源调度一起优化才有机会在复杂地形场景中保持高帧率。8. 常见问题与排查思路实际操作中GPGPU 粒子最大的难题往往不是数学而是 WebGL 状态管理和纹理格式支持。下面整理几类最常见的问题。问题现象常见原因解决思路页面黑屏没有粒子WebGL2 不支持或着色器编译失败打开浏览器控制台查看着色器日志确认是 WebGL2 环境粒子不动但控制台无报错更新 pass 没有正确绑定 Framebuffer检查是否在绘制前绑定了更新 FBO绘制后是否解绑粒子一直显示同一个点位置纹理没有初始化或初始值全为 0初始化纹理时写入发射器位置和随机速度粒子从画面中飞出本地坐标范围与矩阵参数不匹配检查 localToWorld 和 worldToClip 矩阵确认模拟原点粒子碰撞后闪烁高度纹理过滤方式用了线性过滤将 MIN_FILTER 和 MAG_FILTER 改为 NEAREST粒子大量“穿模”高度纹理分辨率太低没有反映地形陡坡提高高度纹理分辨率或者减小高度纹理覆盖范围粒子会穿透地表后撕裂碰撞后位置只是简单钳制没有修正速度检查地面碰撞阶段是否把 y 方向速度反向帧率很低每帧做了
返回列表