引子
理解风场数据之后,接着去看如何绘制粒子。
- 源库:webgl-wind
- Origin
- My GitHub
绘制地图粒子
查看源库,发现独自有一个 Canvas 绘制地图,获取的世界地图海岸线坐标,次要格局如下:
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "scalerank": 1, "featureclass": "Coastline" }, "geometry": { "type": "LineString", "coordinates": [ [ -163.7128956777287, -78.59566741324154 ], // 数据省略 ] } }, // 数据省略 ]}
这些坐标对应的点连起来就能够造成整体的轮廓,次要逻辑如下:
// 省略 for (let i = 0; i < len; i++) { const coordinates = data[i].geometry.coordinates || []; const coordinatesNum = coordinates.length; for (let j = 0; j < coordinatesNum; j++) { context[j ? "lineTo" : "moveTo"]( ((coordinates[j][0] + 180) * node.width) / 360, ((-coordinates[j][1] + 90) * node.height) / 180 ); } // 省略
依照 Canvas 理论的宽高度,与生成的风场图片宽高按比例映射。
绘制地图的独自逻辑示例见这里。
绘制风粒子
查看源库,独自有一个 Canvas 绘制风粒子。看源码的时候,发现其中的逻辑波及较多状态,打算先独自弄明确绘制动态粒子的逻辑。
动态风粒子成果见示例。
先理一下实现的次要思路:
- 风速映射到像素色彩编码的 R 和 G 重量,由此生成了图片 W 。
- 创立显示用的色彩数据,并存放到纹理 T1 中。
- 依据粒子数,创立存储粒子索引的数据并缓冲。还创立每个粒子相干信息的数据,并存放到纹理 T2 中。
- 加载图片 W 并将图片数据寄存到纹理 T3 中。
- 顶点着色器解决的时候,会依据粒子索引从纹理 T2 中获取对应数据,进行转换会生成一个地位 P 传递给片元着色器。
- 片元着色器依据地位 P 从图片纹理 T3 中失去数据并进行线性混合失去一个值 N ,依据 N 在色彩纹理 T1 中失去对应的色彩。
上面就看看具体的实现。
色彩数据
生成色彩数据次要逻辑:
function getColorRamp(colors) { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); canvas.width = 256; canvas.height = 1; // createLinearGradient 用法: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient const gradient = ctx.createLinearGradient(0, 0, 256, 0); for (const stop in colors) { gradient.addColorStop(+stop, colors[stop]); } ctx.fillStyle = gradient; ctx.fillRect(0, 0, 256, 1); return new Uint8Array(ctx.getImageData(0, 0, 256, 1).data);}
这里通过创立一个突变的 Canvas 失去数据,因为跟色彩要对应,一个色彩重量存储为 8 位二进制,总共 256 种。
Canvas 外面的数据放到纹理中,须要足够的大小:16 * 16 = 256 。这里的宽高在前面的片元着色器会用到,须要这两个中央保持一致能力达到预期后果。
this.colorRampTexture = util.createTexture( this.gl, this.gl.LINEAR, getColorRamp(colors), 16, 16);
顶点数据和状态数据
次要逻辑:
set numParticles(numParticles) { const gl = this.gl; const particleRes = (this.particleStateResolution = Math.ceil( Math.sqrt(numParticles) )); // 总粒子数 this._numParticles = particleRes * particleRes; // 所有粒子的色彩信息 const particleState = new Uint8Array(this._numParticles * 4); for (let i = 0; i < particleState.length; i++) { // 生成随机色彩,色彩会对应到图片中的地位 particleState[i] = Math.floor(Math.random() * 256); } // 创立存储所有粒子色彩信息的纹理 this.particleStateTexture = util.createTexture( gl, gl.NEAREST, particleState, particleRes, particleRes ); // 粒子索引 const particleIndices = new Float32Array(this._numParticles); for (let i = 0; i < this._numParticles; i++) particleIndices[i] = i; this.particleIndexBuffer = util.createBuffer(gl, particleIndices);}
粒子的色彩信息会存在纹理中,这里创立了宽高相等的纹理,每个粒子色彩 RGBA 4 个重量,每个重量 8 位。留神这里生成随机色彩重量的大小范畴是 [0, 256) 。
从前面逻辑可知,这里顶点数据 particleIndexBuffer
是用来辅助计算最终地位,而理论地位跟纹理无关。更加具体见上面顶点着色器的具体实现。
顶点着色器
顶点着色器和对应绑定的变量:
const drawVert = ` precision mediump float; attribute float a_index; uniform sampler2D u_particles; uniform float u_particles_res; varying vec2 v_particle_pos; void main(){ vec4 color=texture2D(u_particles,vec2( fract(a_index/u_particles_res), floor(a_index/u_particles_res)/u_particles_res)); // 从像素的 RGBA 值解码以后粒子地位 v_particle_pos=vec2( color.r / 255.0 + color.b, color.g / 255.0 + color.a); gl_PointSize = 1.0; gl_Position = vec4(2.0 * v_particle_pos.x - 1.0, 1.0 - 2.0 * v_particle_pos.y, 0, 1); }`;// 代码省略util.bindAttribute(gl, this.particleIndexBuffer, program.a_index, 1);// 代码省略util.bindTexture(gl, this.particleStateTexture, 1);// 代码省略gl.uniform1i(program.u_particles, 1);// 代码省略gl.uniform1f(program.u_particles_res, this.particleStateResolution);
从这些扩散的逻辑中,找到着色器中变量对应的理论值:
a_index
:particleIndices
外面的粒子索引数据。u_particles
:所有粒子色彩信息的纹理particleStateTexture
。u_particles_res
:particleStateResolution
的值,与纹理particleStateTexture
的宽高统一,也是总粒子数的平方根,也是粒子索引数据长度的平方根。
依据这些对应值,再来看次要的解决逻辑:
vec4 color=texture2D(u_particles,vec2( fract(a_index/u_particles_res), floor(a_index/u_particles_res)/u_particles_res));
先介绍两个函数信息:
- floor(x) : 返回小于等于 x 的最大整数值。
- fract(x) : 返回
x - floor(x)
,即返回 x 的小数局部。
假如总粒子数是 4 ,那么 particleIndices = [0,1,2,3]
、u_particles_res = 2
,那么二维坐标顺次是 vec2(0,0)
、 vec2(0.5,0)、
vec2(0,0.5)
、 vec2(0.5,0.5)
。这里的计算形式确保了失去的坐标都在 0 到 1 之间,这样能力在纹理 particleStateTexture
中采集到色彩信息。
这里须要留神的是 texture2D
采集返回的值范畴是 [0, 1] ,具体原理见这里。
v_particle_pos=vec2( color.r / 255.0 + color.b, color.g / 255.0 + color.a);
源码正文说“从像素的 RGBA 值解码以后粒子地位”,联合后面数据来看,这样的计算形式失去重量实践范畴是 [0, 256/255] ,。变量 v_particle_pos
会在片元着色器中用到。
gl_Position = vec4(2.0 * v_particle_pos.x - 1.0, 1.0 - 2.0 * v_particle_pos.y, 0, 1);
gl_Position
变量是顶点转换到裁剪空间中的坐标值,裁减空间范畴 [-1.0, +1.0] ,想要显示就必须要在这个范畴内,这里的计算形式达到了这个目标。
片元着色器
片元着色器和对应绑定的变量:
const drawFrag = ` precision mediump float; uniform sampler2D u_wind; uniform vec2 u_wind_min; uniform vec2 u_wind_max; uniform sampler2D u_color_ramp; varying vec2 v_particle_pos; void main() { vec2 velocity = mix(u_wind_min, u_wind_max, texture2D(u_wind, v_particle_pos).rg); float speed_t = length(velocity) / length(u_wind_max); vec2 ramp_pos = vec2( fract(16.0 * speed_t), floor(16.0 * speed_t) / 16.0); gl_FragColor = texture2D(u_color_ramp, ramp_pos); }`;// 代码省略util.bindTexture(gl, this.windTexture, 0);// 代码省略gl.uniform1i(program.u_wind, 0); // 风纹理数据// 代码省略util.bindTexture(gl, this.colorRampTexture, 2);// 代码省略gl.uniform1i(program.u_color_ramp, 2); // 色彩数据// 代码省略gl.uniform2f(program.u_wind_min, this.windData.uMin, this.windData.vMin);gl.uniform2f(program.u_wind_max, this.windData.uMax, this.windData.vMax);
从这些扩散的逻辑中,找到着色器中变量对应的理论值:
u_wind
:风场图片生成的纹理windTexture
。u_wind_min
: 风场数据重量最小值。u_wind_max
: 风场数据重量最大值。u_color_ramp
: 创立的色彩纹理colorRampTexture
。v_particle_pos
: 在顶点着色器外面生成的地位。
vec2 velocity = mix(u_wind_min, u_wind_max, texture2D(u_wind, v_particle_pos).rg);float speed_t = length(velocity) / length(u_wind_max);
先介绍内置函数:
- mix(x, y, a) : 会返回
x
和y
的线性混合,计算形式等同于x*(1-a) + y*a
。
velocity
的值确保在 u_wind_min
和 u_wind_max
之间,那么 speed_t
的后果肯定是小于或等于 1 。依据 speed_t
依照肯定规定失去地位 ramp_pos
,在色彩纹理 colorRampTexture
中失去输入到屏幕的色彩。
绘制
在以上逻辑筹备好后,绘制依照失常的程序执行即可。
尽管是绘制动态的粒子,但在独自抽离的过程中发现,不同数量的粒子,如果只执行一次绘制 wind.draw()
,可能无奈实现绘制。
动态风粒子成果见示例。
小结
通过了下面代码逻辑剖析后,再回头看看一开始的次要思路,换个形式表述一下:
- 依据须要显示的粒子数,随机初始化每一个粒子的色彩编码信息并存放到纹理 T2 中;创立最终显示粒子的色彩纹理 T1 ;加载风速生成的图片 W 并存放到纹理 T3 中。
- 最终的目标是从色彩纹理 T1 中获取到色彩并显示,这个过程的形式就是依据纹理 T2 从纹理 T3 中找到一个对应的风速映射点,而后依据这个点从 T1 找到对应的显示色彩。
感觉比一开始的次要思路好懂了一些,但还是有一些疑难。
为什么不间接将纹理 T3 与色彩纹理 T1 关联映射?
目前这里只是整个风场可视化逻辑的一部分重现,回头看看残缺的实现成果:是动静的。那么为了跟踪每一个粒子的挪动,减少一个相干记录变量的实现形式,个人感觉在逻辑上会更加清晰一些,纹理 T2 次要是用来记录粒子数及状态,后续会持续深刻相干逻辑。
顶点着色器中用于纹理采样的二维向量计算根据是什么?
对应的就是为什么用上面这个逻辑:
vec2( fract(a_index/u_particles_res), floor(a_index/u_particles_res)/u_particles_res)
在后面的具体解释中有说,这样的计算形式确保了失去的坐标都在 0 到 1 之间,但能生成这个范畴内的形式应该不止这一种,为什么偏偏选这种,集体也不太分明。前面片元着色器中计算最终地位 ramp_pos
时也用了这样相似的形式。
片元着色器原本就曾经失去一个地位了,为什么还要计算 velocity
重新得到一个地位?
也就是为什么要有上面这段逻辑:
vec2 velocity = mix(u_wind_min, u_wind_max, texture2D(u_wind, v_particle_pos).rg);float speed_t = length(velocity) / length(u_wind_max);
从顶点着色器中失去地位 v_particle_pos
是基于随机生成的色彩纹理 T2 失去的,后面有说重量值计算实践范畴是 [0, 256/255] ,无奈保障肯定能够在风场图片中找到对应的点,那么通过 mix
函数就能够生成一种关联。
片元着色器中计算 ramp_pos
相乘的系数为什么是 16.0 ?
就是上面这段逻辑:
vec2 ramp_pos = vec2( fract(16.0 * speed_t), floor(16.0 * speed_t) / 16.0 );
通过尝试发现这里的 16.0
是跟后面生成最终显示用的色彩纹理 T1 的宽高须要统一,猜想这样统一能力达到平均的成果。
参考资料
- How I built a wind map with WebGL