引子
瞭解風場資料之後,接著去看如何繪製粒子。
繪製地圖粒子
檢視源庫,發現單獨有一個 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 的寬高需要一致,猜測這樣一致才能達到均勻的效果。