解析外国大佬的跨窗口渲染动画原理与尝试
外国大佬实现的效果图 😲😲😲(一万个震撼)
原地址:https://twitter.com/nonfigurativ/status/1727322594570027343
后续大佬也开源了它的简单案例代码
知识点
经过分析源码,其实整体思路并不复杂,主要使用到以下几个 api
:
window.requestAnimationFrame
:要求浏览器在下次重绘之前调用指定的回调函数更新动画,类似setTimeout
,只不过它是按帧运行,比如在 60HZ 的显示器下,每秒执行60次。localStorage
:本地存储。window.screenLeft
、window.screenTop
:浏览器到屏幕边缘的距离像素。
动画原理对于会不会
WebGL
其实并不重要,只是外国大佬的案例效果是使用WebGL
呈现,您也可以使用Canvas
或者div
来实现该动画。
源码分析
function init() {
initialized = true
setTimeout(() => {
setupScene()
setupWindowManager()
resize()
updateWindowShape(false)
render()
window.addEventListener('resize', resize)
}, 500)
}
init
方法是整个场景的入口函数,用于初始化整个场景。其中关键代码是 setupWindowManager
方法 和 render
方法,而 setupScene
和 resize
只不过是使用 ThreeJS 实现 3d 的初始化处理(初始化 Camera
、Scene
、WebGLRender
)。
setupWindowManager
方法
setupWindowManager
方法,内部实现就是 WindowManager
类的初始化。
function setupWindowManager() {
windowManager = new WindowManager()
windowManager.setWinShapeChangeCallback(updateWindowShape)
windowManager.setWinChangeCallback(windowsUpdated)
let metaData = { foo: 'bar' }
windowManager.init(metaData)
windowsUpdated()
}
而 WindowManager
类就是用于管理多个浏览器的数据和保存当前浏览器信息。
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
- 首先在构造函数里,监听
storage
事件和beforeunload
事件。storage
事件是当存储区域被修改时触发,代码中主要对名为windows
键值的数据进行监听处理,当数据有修改时,执行winChangeCallback
回调。beforeunload
事件的处理是当浏览器关闭时,从windows
移除当前浏览器的数据(并同步至localStorage
)。
- 而在 main.js 的
setupWindowManager
里调用WindowManager
实例中的setWinShapeChangeCallback
和setWinChangeCallback
,只是设置对应的回调方法,分别对应#winShapeChangeCallback
和#winChangeCallback
。#winShapeChangeCallback
回调的执行时机在当前浏览器的大小和位置修改时触发,比如修改窗口大小,或者移动浏览器的位置。#winChangeCallback
回调的执行时机在localStorage
的windows
数据发生改变(评定方法在#didWindowsChange
)
init
方法,获取localStorage
中的windows
和count
并缓存,以及初始化当前浏览器数据,更新localStorage
。update
方法后续再解析。
通过上面代码已知:
windows
保存的是所有浏览器数据,即winData
数组。count
记录历史打开的浏览器数,同时作为浏览器的id。winData
当前浏览器的数据,包含id、shape、metaData。
通过实例化 WindowManager
,内部已获取到最新的 windows
,并且初始化当前浏览器数据保存到 winData
。 此时 setupWindowManager
执行 windowsUpdated
方法,根据浏览器数据渲染图像。根据效果图,我们很容易误解,以为每个浏览器只画对应它的 winData
数据的图案,其实不是的,每一个浏览器都画 windows.length
个图案,只是它们的 position
不一样,超出了画布看不到而已。
可能这时有一些疑惑,在 windowsUpdated
方法中,它们的定位并不是居中,但为什么在每个浏览器时图案都显示居中?
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️⃣
let t = getTime()
windowManager.update()
getTime()
获取当天0点至当前时间的秒数。
已知在 Threejs 中可以通过
clock.getElapsedTime()
获取自时钟启动后的秒数。一开始我也很疑惑,为什么不是用这个方法?后来才明白是为了同步动画。从上面的解析已知,其实每个浏览器都是都是画着 “同一” 幅图(两个浏览器时渲染图),当两个浏览器有交集时,如下图
红色正方体好像悬浮在两个浏览器交界处,而且动画依旧同步进行。
getTime()
就是在这里施展魔法,这里是由两张图合并而成的,为了看起来像是一个图像(红色正方体)和让动画同步,因此需要保持动画的时间是一样的。🙋♂️ 打个比方: 正方体的动画每帧是
t * Math.PI()
,因为t
都是当天0点至当前时间的秒数,因此同一时刻两个浏览器的红色正方体旋转角是相同的。 但如果t
是clock.getElapsedTime()
时,因为打开浏览器的时机不同,导致时钟启动的时间不同,因此动画是不会同步的。
windowManager.update()
其内部实现就是重新获取浏览器的位置和大小,与 winData 对比是否更新,如果有更新则执行 #winShapeChangeCallback
(可以把 windowManager.update()
理解为监听浏览器位置大小修改事件)。回调函数中则是记录当前浏览器的位置,如下代码。
function updateWindowShape(easing = true) {
// 获取浏览器到桌面左边界和上边界距离
sceneOffsetTarget = { x: -window.screenX, y: -window.screenY }
if (!easing) sceneOffset = sceneOffsetTarget
}
2️⃣
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️⃣ 注释掉进行查看。
可见橙色图案实际位置是偏右下的。
sceneOffsetTarget
保存的是下一次偏移值,也是浏览器距离屏幕左边界和上边界的负值;sceneOffset
保存的是当前的偏移值。world
可以理解为一个容器或者组。 sceneOffsetTarget
的值就是在 1️⃣ updateWindowShape
回调中修改,也就是浏览器位置或大小发生改变时。 代码的最终结果是 sceneOffset
逐步变成sceneOffsetTarget
的值,这么做的目的是平滑的过渡。呈现的结果就是橙色圆形居中了。解析图如下
而当两个浏览器时,为什么各自都能显示对应圆形居中的原因。
3️⃣
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
里的 Shader
。 glsl
是以 C语言为基础的着色语言,大部分的概念和 WebGL 一致,同样存在向量的定义和矩阵的定义,只是定义的方法不一样而已。
例如:
// 以 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; // 布尔型向量
更多的类型请自行查阅官网。
存储限定词
attribute
、uniform
都是全局定义的,attribute
只存在与顶点着色器;uniform
是只读的;varying
用于在顶点着色器和片元着色器之间传递数据,比如顶点着色器定义 varying float vDistance
,片元着色器定义相同 varying
变量后就可以获取得到。
内置限定词
内置变量 | 数据类型 | 描述 |
---|---|---|
gl_PointSize | float | 绘制点模式时,绘制的点的大小 |
gl_Position | vec4 | 逐顶点处理时,当前顶点位置坐标 |
gl_FragColor | vec4 | 逐片元处理时,当前片元颜色 |
gl_FrageCoord | vec2 | 片元在canvas坐标系中的坐标 |
gl_PointCoord | vec2 | 绘制点模式时,当前片元在所属点内的坐标(从0.0到1.0) |
gl_FrontFacing | bool | 在 WebGL 中,使用gl_FrontFacing可以判断当前渲染的图像是正面还是背面。如果当前渲染的图像是正面,那么它的值为true;否则为false。 |
gl_FragData[gl_MaxDrawBuffers] | vec4 |
坐标系统
变量 | 描述 |
---|---|
gl_Position | 顶点在世界坐标系中的坐标 |
gl_FrageCoord | 片元在以Canvas 画布窗口坐标系统中的坐标,默认是画布的长和宽 |
gl_PointCoord | 点域图元光栅化后的片元,表示的坐标就是 gl_PointSize 定义的区域内的片元系统,区间是[0, 1] |