風場視覺化:繪製粒子

XXHolic發表於2022-03-05

引子

瞭解風場資料之後,接著去看如何繪製粒子。

繪製地圖粒子

檢視源庫,發現單獨有一個 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_indexparticleIndices 裡面的粒子索引資料。
  • u_particles :所有粒子顏色資訊的紋理 particleStateTexture
  • u_particles_resparticleStateResolution 的值,與紋理 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) : 會返回 xy 的線性混合,計算方式等同於 x*(1-a) + y*a

velocity 的值確保在 u_wind_minu_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 的寬高需要一致,猜測這樣一致才能達到均勻的效果。

參考資料

相關文章