風場視覺化:繪製軌跡

XXHolic發表於2022-03-12

引子

瞭解繪製粒子之後,接著去看如何繪製粒子軌跡。

繪製軌跡

在原文中提到繪製軌跡的方法是將粒子繪製到紋理中,然後在下一幀上使用該紋理作為背景(稍微變暗),並每一幀交換輸入/目標紋理。這裡涉及兩個重點使用的 WebGL 功能點:

基於繪製粒子的基礎上,增加邏輯的主要思路:

  • 初始化時,增加了背景紋理 B 和螢幕紋理 S 。
  • 建立每個粒子相關資訊的資料時,存了兩個紋理 T20 和 T21 中。
  • 繪製時,先繪製背景紋理 B ,再根據紋理 T20 繪製所有粒子,接著繪製螢幕紋理 S,之後將螢幕紋理 S 作為下一幀的背景紋理 B 。
  • 最後基於紋理 T21 繪製新的結果,生成新的狀態紋理覆蓋 T20 ,開始下一幀繪製。

不包含隨機生成的粒子軌跡效果見示例,下面看看具體的實現。

紋理

新增紋理相關邏輯:

// 程式碼省略
resize() {
  const gl = this.gl;
  const emptyPixels = new Uint8Array(gl.canvas.width * gl.canvas.height * 4);
  // screen textures to hold the drawn screen for the previous and the current frame
  this.backgroundTexture = util.createTexture(gl, gl.NEAREST, emptyPixels, gl.canvas.width, gl.canvas.height);
  this.screenTexture = util.createTexture(gl, gl.NEAREST, emptyPixels, gl.canvas.width, gl.canvas.height);
}
// 程式碼省略

初始化的背景紋理和螢幕紋理都是以 Canvas 的寬高作為標準,同樣是以每個畫素 4 個分量儲存。

螢幕著色器程式

新增螢幕著色器程式物件,最終顯示可見的內容就是這個物件負責繪製:

this.screenProgram = webglUtil.createProgram(gl, quadVert, screenFrag);

頂點資料

頂點相關邏輯:

// 程式碼省略
  this.quadBuffer = util.createBuffer(gl, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]));
// 程式碼省略
  util.bindAttribute(gl, this.quadBuffer, program.a_pos, 2);
// 程式碼省略
  gl.drawArrays(gl.TRIANGLES, 0, 6);
// 程式碼省略

這裡可以看出以頂點資料按照二維解析,總共 6 個點,繪製的是一個矩形,為什座標都是 0 和 1 ,接著看下面的著色器。

頂點著色器

新增頂點著色器和對應繫結的變數:

const quadVert = `
  precision mediump float;

  attribute vec2 a_pos;

  varying vec2 v_tex_pos;

  void main() {
      v_tex_pos = a_pos;
      gl_Position = vec4(1.0 - 2.0 * a_pos, 0, 1);
  }
`;
// 程式碼省略
this.drawTexture(this.backgroundTexture, this.fadeOpacity);
// 程式碼省略
drawTexture(texture, opacity) {
  // 程式碼省略
  util.bindAttribute(gl, this.quadBuffer, program.a_pos, 2);
  // 程式碼省略
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// 程式碼省略

從這些分散的邏輯中,找到著色器中的變數對應的實際值:

  • a_posquadBuffer 中每個頂點二維資料。
  • v_tex_pos : 跟 a_pos 的值一樣,會在對應的片元著色器中使用。

這裡 gl_Position 的計算方式,結合前面說到的頂點座標都是 0 和 1 ,發現計算結果的範圍是 [-1.0, +1.0] ,在裁減空間範圍內,就可以顯示出來。

片元著色器

片元著色器和對應繫結的變數:

const screenFrag = `
  precision mediump float;

  uniform sampler2D u_screen;
  uniform float u_opacity;

  varying vec2 v_tex_pos;

  void main() {
      vec4 color = texture2D(u_screen, 1.0 - v_tex_pos);
      // a hack to guarantee opacity fade out even with a value close to 1.0
      gl_FragColor = vec4(floor(255.0 * color * u_opacity) / 255.0);
  }
`;
this.fadeOpacity = 0.996;
// 程式碼省略
drawTexture(texture, opacity) {
  // 程式碼省略
  gl.uniform1i(program.u_screen, 2);
  gl.uniform1f(program.u_opacity, opacity);

  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

從這些分散的邏輯中,找到著色器中的變數對應的實際值:

  • u_screen : 動態變化的紋理,需根據上下文判斷 。
  • u_opacity : 透明度,需根據上下文判斷。
  • v_tex_pos : 從頂點著色器傳遞過來,也就是 quadBuffer 中的資料。

1.0 - v_tex_pos 的範圍是 [0, 1] ,正好包含了整個紋理的範圍。最終顏色乘以動態 u_opacity 的效果就是原文中所說“稍微變暗”的目的。

更新著色器程式

新增更新著色器程式物件,是讓粒子產生移動軌跡的關鍵:

this.updateProgram = webglUtil.createProgram(gl, quadVert, updateFrag);

頂點資料

與螢幕著色器程式的頂點資料公用一套。

頂點著色器

與螢幕著色器程式的頂點著色器公用一套。

片元著色器

針對更新的片元著色器和對應繫結的變數:

const updateFrag = `
  precision highp float;

  uniform sampler2D u_particles;
  uniform sampler2D u_wind;
  uniform vec2 u_wind_res;
  uniform vec2 u_wind_min;
  uniform vec2 u_wind_max;

  varying vec2 v_tex_pos;

  // wind speed lookup; use manual bilinear filtering based on 4 adjacent pixels for smooth interpolation
  vec2 lookup_wind(const vec2 uv) {
      // return texture2D(u_wind, uv).rg; // lower-res hardware filtering
      vec2 px = 1.0 / u_wind_res;
      vec2 vc = (floor(uv * u_wind_res)) * px;
      vec2 f = fract(uv * u_wind_res);
      vec2 tl = texture2D(u_wind, vc).rg;
      vec2 tr = texture2D(u_wind, vc + vec2(px.x, 0)).rg;
      vec2 bl = texture2D(u_wind, vc + vec2(0, px.y)).rg;
      vec2 br = texture2D(u_wind, vc + px).rg;
      return mix(mix(tl, tr, f.x), mix(bl, br, f.x), f.y);
  }

  void main() {
      vec4 color = texture2D(u_particles, v_tex_pos);
      vec2 pos = vec2(
          color.r / 255.0 + color.b,
          color.g / 255.0 + color.a); // decode particle position from pixel RGBA

      vec2 velocity = mix(u_wind_min, u_wind_max, lookup_wind(pos));

      // take EPSG:4236 distortion into account for calculating where the particle moved
      float distortion = cos(radians(pos.y * 180.0 - 90.0));
      vec2 offset = vec2(velocity.x / distortion, -velocity.y) * 0.0001 * 0.25;

      // update particle position, wrapping around the date line
      pos = fract(1.0 + pos + offset);

      // encode the new particle position back into RGBA
      gl_FragColor = vec4(
          fract(pos * 255.0),
          floor(pos * 255.0) / 255.0);
  }
`;
// 程式碼省略
setWind(windData) {
  // 風場圖片的源資料
  this.windData = windData;
}
// 程式碼省略
util.bindTexture(gl, this.windTexture, 0);
util.bindTexture(gl, this.particleStateTexture0, 1);
// 程式碼省略
this.updateParticles();
// 程式碼省略
updateParticles() {
  // 程式碼省略
  const program = this.updateProgram;
  gl.useProgram(program.program);

  util.bindAttribute(gl, this.quadBuffer, program.a_pos, 2);

  gl.uniform1i(program.u_wind, 0); // 風紋理
  gl.uniform1i(program.u_particles, 1); // 粒子紋理

  gl.uniform2f(program.u_wind_res, this.windData.width, this.windData.height);
  gl.uniform2f(program.u_wind_min, this.windData.uMin, this.windData.vMin);
  gl.uniform2f(program.u_wind_max, this.windData.uMax, this.windData.vMax);

  gl.drawArrays(gl.TRIANGLES, 0, 6);
  // 程式碼省略
}

從這些分散的邏輯中,找到著色器中的變數對應的實際值:

  • u_wind :風場圖片生成的紋理 windTexture
  • u_particles :所有粒子顏色資訊的紋理 particleStateTexture0
  • u_wind_res : 生成圖片的寬高。
  • u_wind_min : 風場資料分量最小值。
  • u_wind_max : 風場資料分量最大值。

根據 quadBuffer 的頂點資料從紋理 particleStateTexture0 中獲取對應位置的畫素資訊,用畫素資訊解碼出粒子位置,通過 lookup_wind 方法獲取相鄰 4 個畫素的平滑插值,之後基於風場最大值和最小值得出偏移量 offset ,最後得到新的位置轉為顏色輸出。在這個過程中發現下面幾個重點:

  • 怎麼獲取相鄰 4 個畫素?
  • 二維地圖中,兩極和赤道粒子如何區別?

怎麼獲取相鄰 4 個畫素?

看主要方法:

vec2 lookup_wind(const vec2 uv) {
  vec2 px = 1.0 / u_wind_res;
  vec2 vc = (floor(uv * u_wind_res)) * px;
  vec2 f = fract(uv * u_wind_res);
  vec2 tl = texture2D(u_wind, vc).rg;
  vec2 tr = texture2D(u_wind, vc + vec2(px.x, 0)).rg;
  vec2 bl = texture2D(u_wind, vc + vec2(0, px.y)).rg;
  vec2 br = texture2D(u_wind, vc + px).rg;
  return mix(mix(tl, tr, f.x), mix(bl, br, f.x), f.y);
}
  • 以生成圖片的寬高作為基準,得到基本單位 px
  • 在新衡量標準下,向下取整得到近似位置 vc 作為第 1 個參考點,移動基本單位單個分量 px.x 得到第 2 個參考點;
  • 移動基本單位單個分量 px.y 得到第 3 個參考點,移動基本單位 px 得到第 4 個參考點。

二維地圖中,兩極和赤道粒子如何區別?

就像原文中:

在兩極附近,粒子沿 X 軸的移動速度應該比赤道上的粒子快得多,因為相同的經度表示的距離要小得多。

對應的處理邏輯:

float distortion = cos(radians(pos.y * 180.0 - 90.0));
vec2 offset = vec2(velocity.x / distortion, -velocity.y) * 0.0001 * u_speed_factor;

radians 方法將角度轉換為弧度值,pos.y * 180.0 - 90.0 猜測是風資料轉為角度的規則。cos 餘弦值在 [0,π] 之間逐漸變小,對應 offset 的第一個分量就會逐漸變大,效果看起來速度變快了。第二個分量加上了符號 -,推測是要跟圖片紋理一致,圖片紋理預設在 Y 軸上是反的。

繪製

繪製這塊變化很大:

  draw() {
    // 程式碼省略
    this.drawScreen();
    this.updateParticles();
  }
  drawScreen() {
    const gl = this.gl;
    // draw the screen into a temporary framebuffer to retain it as the background on the next frame
    util.bindFramebuffer(gl, this.framebuffer, this.screenTexture);
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    this.drawTexture(this.backgroundTexture, this.fadeOpacity);
    this.drawParticles();

    util.bindFramebuffer(gl, null);
    // enable blending to support drawing on top of an existing background (e.g. a map)
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    this.drawTexture(this.screenTexture, 1.0);
    gl.disable(gl.BLEND);

    // save the current screen as the background for the next frame
    const temp = this.backgroundTexture;
    this.backgroundTexture = this.screenTexture;
    this.screenTexture = temp;
  }
  drawTexture(texture, opacity) {
    const gl = this.gl;
    const program = this.screenProgram;
    gl.useProgram(program.program);
    // 程式碼省略
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }
  drawParticles() {
    const gl = this.gl;
    const program = this.drawProgram;
    gl.useProgram(program.program);
    // 程式碼省略
    gl.drawArrays(gl.POINTS, 0, this._numParticles);
  }
  updateParticles() {
    const gl = this.gl;
    util.bindFramebuffer(gl, this.framebuffer, this.particleStateTexture1);
    gl.viewport(
      0,
      0,
      this.particleStateResolution,
      this.particleStateResolution
    );

    const program = this.updateProgram;
    gl.useProgram(program.program);
    // 程式碼省略
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    // swap the particle state textures so the new one becomes the current one
    const temp = this.particleStateTexture0;
    this.particleStateTexture0 = this.particleStateTexture1;
    this.particleStateTexture1 = temp;
  }
  • 先切換到幀緩衝區,指定的紋理是 screenTexture ,注意從這裡開始繪製的結果是不可見的,接著繪製了整個背景紋理 backgroundTexture 和基於紋理 particleStateTexture0 的所有單個粒子,然後解除幀緩衝區繫結。這部分繪製結果會儲存在紋理 screenTexture 中。
  • 切換到預設的顏色緩衝區,注意從這裡開始繪製的結果可見,開啟 α 混合,blendFunc 設定的兩個引數效果是重疊的部分後繪製會覆蓋先繪製。然後繪製了整個紋理 screenTexture ,也就是說幀緩衝區的繪製結果都顯示到了畫布上。
  • 繪製完成後,使用了中間變數進行替換,紋理 backgroundTexture 變成了現在呈現的紋理內容,作為下一幀的背景。
  • 接著切換到幀緩衝區更新粒子狀態,指定的紋理是 particleStateTexture1,注意從這裡開始繪製的結果是不可見的,基於紋理 particleStateTexture0 繪製產生偏移後的狀態,整個繪製結果會儲存在紋理 particleStateTexture1 中。
  • 繪製完成後,使用了中間變數進行替換,紋理 particleStateTexture0 變成了移動後的紋理內容,作為下一幀粒子呈現的依據。這樣連續的幀繪製,看起來就是動態的效果。

疑惑

感覺好像是那麼回事,但有的還是不太明白。

偏移為什麼要用 lookup_wind 裡面的計算方式 ?

原文解釋說找平滑插值,但這裡面的數學原理是什麼?找到之後為什麼又要 mix 一次?個人也沒找到比較好的解釋。

參考資料

相關文章