Skip to content

解析外国大佬的跨窗口渲染动画原理与尝试

外国大佬实现的效果图 😲😲😲(一万个震撼)

量子纠缠效果图

原地址:https://twitter.com/nonfigurativ/status/1727322594570027343

后续大佬也开源了它的简单案例代码

知识点

经过分析源码,其实整体思路并不复杂,主要使用到以下几个 api

  • window.requestAnimationFrame :要求浏览器在下次重绘之前调用指定的回调函数更新动画,类似 setTimeout,只不过它是按帧运行,比如在 60HZ 的显示器下,每秒执行60次。
  • localStorage :本地存储。
  • window.screenLeftwindow.screenTop :浏览器到屏幕边缘的距离像素。

动画原理对于会不会 WebGL 其实并不重要,只是外国大佬的案例效果是使用 WebGL 呈现,您也可以使用 Canvas 或者 div 来实现该动画。

源码分析

jsx
function init() {
  initialized = true

  setTimeout(() => {
    setupScene()
    setupWindowManager()
    resize()
    updateWindowShape(false)
    render()
    window.addEventListener('resize', resize)
  }, 500)
}

init 方法是整个场景的入口函数,用于初始化整个场景。其中关键代码是 setupWindowManager 方法 和 render 方法,而 setupSceneresize 只不过是使用 ThreeJS 实现 3d 的初始化处理(初始化 CameraSceneWebGLRender)。

setupWindowManager 方法

setupWindowManager 方法,内部实现就是 WindowManager 类的初始化。

jsx
function setupWindowManager() {
  windowManager = new WindowManager()
  windowManager.setWinShapeChangeCallback(updateWindowShape)
  windowManager.setWinChangeCallback(windowsUpdated)

  let metaData = { foo: 'bar' }
  windowManager.init(metaData)
  windowsUpdated()
}

WindowManager 类就是用于管理多个浏览器的数据和保存当前浏览器信息。

jsx
class WindowManager {
  #windows // 记录所有窗口数据
  #count // 等于当前窗口ID
  #id
  #winData // 当前窗口数据(包含id,metaData,shape)
  #winShapeChangeCallback
  #winChangeCallback

  constructor() {
    let that = this

    addEventListener('storage', (event) => {
      if (event.key == 'windows') {
        let newWindows = JSON.parse(event.newValue)
        let winChange = that.#didWindowsChange(that.#windows, newWindows)

        that.#windows = newWindows
        if (winChange) {
          if (that.#winChangeCallback) that.#winChangeCallback()
        }
      }
    })

    window.addEventListener('beforeunload', function (e) {
      let index = that.getWindowIndexFromId(that.#id)
      that.#windows.splice(index, 1)
      that.updateWindowsLocalStorage()
    })
  }

  /**
   * @description: 判断窗口是否改变
   * @return {*}
   */
  #didWindowsChange(pWins, nWins) {
    if (pWins.length != nWins.length) {
      return true
    } else {
      let c = false

      for (let i = 0; i < pWins.length; i++) {
        if (pWins[i].id != nWins[i].id) c = true
      }

      return c
    }
  }

  /**
   * @description: 初始化
   * @param {*} metaData 自定义元数据
   * @return {*}
   */	
  init(metaData) {
		// 获取 windows 数组 和 初始id
    this.#windows = JSON.parse(localStorage.getItem('windows')) || []
    this.#count = localStorage.getItem('count') || 0
    this.#count++

    this.#id = this.#count
    let shape = this.getWinShape()
    this.#winData = { id: this.#id, shape: shape, metaData: metaData }
    this.#windows.push(this.#winData)
		// 更新 localhost count
    localStorage.setItem('count', this.#count)
    this.updateWindowsLocalStorage()
  }

  /**
   * @description: 返回浏览器窗口数据
   * @return {*}
   */	
  getWinShape() {
    let shape = {
      x: window.screenLeft,
      y: window.screenTop,
      w: window.innerWidth,
      h: window.innerHeight,
    }
    return shape
  }

  /**
   * @description: 获取索引
   * @param {*} id
   * @return {*}
   */	
  getWindowIndexFromId(id) {
    let index = -1

    for (let i = 0; i < this.#windows.length; i++) {
      if (this.#windows[i].id == id) index = i
    }

    return index
  }

  /**
   * @description: 更新 localstorage 的 windows 数据
   * @return {*}
   */	
  updateWindowsLocalStorage() {
    localStorage.setItem('windows', JSON.stringify(this.#windows))
  }

  update() {
    let winShape = this.getWinShape()

		// 判断最新窗口数据是否和缓存的一致
    if (
      winShape.x != this.#winData.shape.x ||
      winShape.y != this.#winData.shape.y ||
      winShape.w != this.#winData.shape.w ||
      winShape.h != this.#winData.shape.h
    ) {
      this.#winData.shape = winShape

      let index = this.getWindowIndexFromId(this.#id)
      this.#windows[index].shape = winShape

      if (this.#winShapeChangeCallback) this.#winShapeChangeCallback()
      this.updateWindowsLocalStorage()
    }
  }

  /**
   * @description: 设置 Shape 改变回调
   * @return {*}
   */
  setWinShapeChangeCallback(callback) {
    this.#winShapeChangeCallback = callback
  }

  /**
   * @description: 设置窗口修改回调
   * @return {*}
   */
  setWinChangeCallback(callback) {
    this.#winChangeCallback = callback
  }

  /**
   * @description: 获取所有 windows 数据
   * @return {*}
   */	
  getWindows() {
    return this.#windows
  }

  /**
   * @description: 获取当期窗口数据
   * @return {*}
   */	
  getThisWindowData() {
    return this.#winData
  }

  getThisWindowID() {
    return this.#id
  }
}

export default WindowManager
  1. 首先在构造函数里,监听 storage 事件和 beforeunload 事件。
    1. storage 事件是当存储区域被修改时触发,代码中主要对名为 windows 键值的数据进行监听处理,当数据有修改时,执行 winChangeCallback 回调。
    2. beforeunload 事件的处理是当浏览器关闭时,从 windows 移除当前浏览器的数据(并同步至 localStorage)。
  2. 而在 main.js 的 setupWindowManager 里调用 WindowManager 实例中的 setWinShapeChangeCallbacksetWinChangeCallback,只是设置对应的回调方法,分别对应 #winShapeChangeCallback#winChangeCallback
    1. #winShapeChangeCallback 回调的执行时机在当前浏览器的大小和位置修改时触发,比如修改窗口大小,或者移动浏览器的位置。
    2. #winChangeCallback 回调的执行时机在 localStoragewindows 数据发生改变(评定方法在 #didWindowsChange
  3. init 方法,获取 localStorage 中的 windowscount 并缓存,以及初始化当前浏览器数据,更新 localStorage
  4. update 方法后续再解析。

通过上面代码已知windows 保存的是所有浏览器数据,即 winData 数组。 count 记录历史打开的浏览器数,同时作为浏览器的id。 winData 当前浏览器的数据,包含id、shape、metaData。

winData数据解析图

通过实例化 WindowManager,内部已获取到最新的 windows,并且初始化当前浏览器数据保存到 winData 。 此时 setupWindowManager 执行 windowsUpdated 方法,根据浏览器数据渲染图像。根据效果图,我们很容易误解,以为每个浏览器只画对应它的 winData 数据的图案,其实不是的,每一个浏览器都画 windows.length 个图案,只是它们的 position 不一样,超出了画布看不到而已

两个浏览器时渲染图

可能这时有一些疑惑,在 windowsUpdated 方法中,它们的定位并不是居中,但为什么在每个浏览器时图案都显示居中?

jsx
cube.position.x = win.shape.x + win.shape.w * 0.5
cube.position.y = win.shape.y + win.shape.h * 0.5

通过代码发现,图案的坐标是由 浏览器到屏幕的距离 + 浏览器的宽或高一半(❗记住,后续用到);这么看来确实不是居中的,只有一种情况,浏览器左边界和上边界刚好对其浏览器左边界和上边界,这时为居中的。

举一反三?!

直接 win.shape.w * 0.5 不就居中了吗?为什么要额外加一个数( win.shape.x )? 因为每个浏览器都渲染同一张图,如果都是使用 win.shape.w 的一半,那么可能两浏览器没有重合的时候,已经暴露出两个圆形了,比如两个浏览器的宽度都是 100px 时,那么两个圆形的 x 坐标都是 50,此时两个圆形已经重叠了,和两个浏览器有没有交集已经没有关系了。

并且单看这段代码时,案例每个浏览器都渲染这一张图的话,并且它们都是使用相同的 windows 数据,按理浏览器2 看不到蓝色圆形,应该所有浏览器都只看到橙色圆形。

而它们的处理就是在接下来的 render 方法里。

render 方法

render 方法中代码量不多,但每处都充满魔法。 我将把它分成 3 个部分进行分析 🤖

1️⃣

jsx
let t = getTime()
windowManager.update()

getTime() 获取当天0点至当前时间的秒数。

已知在 Threejs 中可以通过 clock.getElapsedTime() 获取自时钟启动后的秒数。一开始我也很疑惑,为什么不是用这个方法?后来才明白是为了同步动画。

从上面的解析已知,其实每个浏览器都是都是画着 “同一” 幅图(两个浏览器时渲染图),当两个浏览器有交集时,如下图

Untitled

红色正方体好像悬浮在两个浏览器交界处,而且动画依旧同步进行。 getTime() 就是在这里施展魔法,这里是由两张图合并而成的,为了看起来像是一个图像(红色正方体)和让动画同步,因此需要保持动画的时间是一样的。

🙋‍♂️ 打个比方: 正方体的动画每帧是 t * Math.PI() ,因为 t 都是当天0点至当前时间的秒数,因此同一时刻两个浏览器的红色正方体旋转角是相同的。 但如果 tclock.getElapsedTime() 时,因为打开浏览器的时机不同,导致时钟启动的时间不同,因此动画是不会同步的。

windowManager.update() 其内部实现就是重新获取浏览器的位置和大小,与 winData 对比是否更新,如果有更新则执行 #winShapeChangeCallback (可以把 windowManager.update() 理解为监听浏览器位置大小修改事件)。回调函数中则是记录当前浏览器的位置,如下代码。

jsx
function updateWindowShape(easing = true) {
  // 获取浏览器到桌面左边界和上边界距离
  sceneOffsetTarget = { x: -window.screenX, y: -window.screenY }
  if (!easing) sceneOffset = sceneOffsetTarget
}

2️⃣

jsx
let falloff = 0.05
sceneOffset.x =
  sceneOffset.x + (sceneOffsetTarget.x - sceneOffset.x) * falloff
sceneOffset.y =
  sceneOffset.y + (sceneOffsetTarget.y - sceneOffset.y) * falloff

world.position.x = sceneOffset.x
world.position.y = sceneOffset.y

这一段代码就是解除 windowsUpdated 方法最后的疑惑(居中问题)我称之为镜头聚焦,将对应浏览器聚焦自己对应的图形。已知 windowsUpdated 方法最终的渲染效果如下,你可以尝试把 render 方法中的2️⃣ 3️⃣ 注释掉进行查看。

world偏移前展示图

可见橙色图案实际位置是偏右下的。

sceneOffsetTarget 保存的是下一次偏移值,也是浏览器距离屏幕左边界和上边界的负值sceneOffset 保存的是当前的偏移值。world 可以理解为一个容器或者组。 sceneOffsetTarget 的值就是在 1️⃣ updateWindowShape 回调中修改,也就是浏览器位置或大小发生改变时。 代码的最终结果是 sceneOffset 逐步变成sceneOffsetTarget 的值,这么做的目的是平滑的过渡。呈现的结果就是橙色圆形居中了。解析图如下

world偏移后展示图

而当两个浏览器时,为什么各自都能显示对应圆形居中的原因。

多个浏览器时图案居中解析图

3️⃣

jsx
let wins = windowManager.getWindows()

for (let i = 0; i < cubes.length; i++) {
  let cube = cubes[i]
  let win = wins[i]
  let _t = t // + i * .2;

  let posTarget = {
    x: win.shape.x + win.shape.w * 0.5,
    y: win.shape.y + win.shape.h * 0.5,
  }

  cube.position.x =
    cube.position.x + (posTarget.x - cube.position.x) * falloff
  cube.position.y =
    cube.position.y + (posTarget.y - cube.position.y) * falloff
  cube.rotation.x = _t * 0.5
  cube.rotation.y = _t * 0.3
}

这段代码的解析和 2️⃣ 相似,我们都已经知道每个图案的坐标是由 浏览器到屏幕的距离 + 浏览器的宽或高一半posTarget 保存的是下一次圆形的坐标,cube.position 是当前圆形的坐标,cube.position 逐步变成 posTarget 的值,同样是为了平滑过渡。

为什么图案的坐标也要变?

因为图案的坐标是与浏览器到屏幕的距离相关,因此当浏览器位置修改时,坐标也相应改变。

比如开头的效果图,两个浏览器慢慢相交到重叠时,这里称 浏览器A 和 浏览器B 吧,浏览器A 慢慢往浏览器 B 方向移动,这时对于浏览器A而言,world 偏移,A 里的 A圆形也在移动;对于浏览器B而言,world没有偏移(依旧聚焦自己的圆形),B里的 A 圆形逐步向 B圆形移动,进而出现跨浏览器两图案重叠的假象。

跨浏览器动画原理图

解析汇总

基本开源的简单案例代码全部解析完毕,它的思路并不难,利用浏览器渲染同一效果图(windowsUpdated),通过不同浏览器聚焦不同的图案(2️⃣),让人产生它只有一个图案的错觉,当修改浏览器的位置,多个浏览器相互相交重叠时,修改浏览器效果图的各图案位置(3️⃣),达到跨浏览器多个图案相互重叠的效果。

它们的通讯手段利用 localStorage

实践

实践仓库地址

原子效果

因为原子效果是通过 glsl 实现的,因此需要有 glsl 的知识,也就是 THREE 里的 Shaderglsl 是以 C语言为基础的着色语言,大部分的概念和 WebGL 一致,同样存在向量的定义和矩阵的定义,只是定义的方法不一样而已。

例如:

javascript
// 以 Threejs 为例
const pos = new THREE.Vector3(1, 1, 1)
// 读取可以通过 pos[0]

// glsl
vec3 pos = vec3(1.0, 1.0, 1.0); // 浮点型向量
// pos[0] pos.x 读取x, pos.xy 同时读取 xy两位
ivec3 v; // 整型向量
bvec3 b; // 布尔型向量

更多的类型请自行查阅官网。

存储限定词

attributeuniform 都是全局定义的,attribute 只存在与顶点着色器;uniform 是只读的;varying 用于在顶点着色器和片元着色器之间传递数据,比如顶点着色器定义 varying float vDistance ,片元着色器定义相同 varying 变量后就可以获取得到。

存储限定词

内置限定词

内置变量数据类型描述
gl_PointSizefloat绘制点模式时,绘制的点的大小
gl_Positionvec4逐顶点处理时,当前顶点位置坐标
gl_FragColorvec4逐片元处理时,当前片元颜色
gl_FrageCoordvec2片元在canvas坐标系中的坐标
gl_PointCoordvec2绘制点模式时,当前片元在所属点内的坐标(从0.0到1.0)
gl_FrontFacingbool在 WebGL 中,使用gl_FrontFacing可以判断当前渲染的图像是正面还是背面。如果当前渲染的图像是正面,那么它的值为true;否则为false。
gl_FragData[gl_MaxDrawBuffers]vec4

坐标系统

变量描述
gl_Position顶点在世界坐标系中的坐标
gl_FrageCoord片元在以Canvas 画布窗口坐标系统中的坐标,默认是画布的长和宽
gl_PointCoord点域图元光栅化后的片元,表示的坐标就是 gl_PointSize 定义的区域内的片元系统,区间是[0, 1]

坐标系统

Released under the MIT License.