Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

绘制轨迹 #105

Open
XXHolic opened this issue Feb 3, 2022 · 0 comments
Open

绘制轨迹 #105

XXHolic opened this issue Feb 3, 2022 · 0 comments

Comments

@XXHolic
Copy link
Owner

XXHolic commented Feb 3, 2022

目录

引子

了解绘制粒子之后,接着去看如何绘制粒子轨迹。

绘制轨迹

在原文中提到绘制轨迹的方法是将粒子绘制到纹理中,然后在下一帧上使用该纹理作为背景(稍微变暗),并每一帧交换输入/目标纹理。这里涉及两个重点使用的 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 一次?个人也没找到比较好的解释。

参考资料

🗑️

最近看了电影《酷爱电影的庞波小姐 》,看了内容后感觉这名称跟作品主体不是很贴切。

这个类型和《白箱》有些类似,电影里面后续感觉有些太顺畅,可能是限于篇幅不能讲的过多。

里面剪辑影片时候展现的效果很不错,让我想起来《黑客帝国》第一部里面选择武器装备时候的效果。

99-poster

@XXHolic XXHolic mentioned this issue Feb 3, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant