这两天,很多群里都在疯传一个视频,视频演示了纯前端实现的“量子纠缠”效果,不少前端er表示:“前端白学了”。

原视频如下:

全网疯传的前端量子纠缠效果,源码来了!

体验地址:3d example using three.js and multiple windows

视频作者昨晚开源一个简化版的实现源码(截止发文,该项目在 Github 上已获得超过 1k Star),本文就来看看他是怎么实现的!

简化版

根据作者的描述,该项目是使用three.jslocalStorage实现的在同一源上设置跨窗口的 3D 场景。

把源码克隆到本地,用 Live Server 启动一下,简化版的效果是这样的:

在线体验:https://bgstaal.github.io/multipleWindow3dScene/

虽然没有原视频那么炫酷,但基本原理应该差不多。

源码包含多个文件,最主要的文件如下:

  • index.html

  • main.js:主文件

  • WindowManager.js:窗口管理

源码

index.html文件中引入了three.js的压缩包,以及main.js

3d example using three.js and multiple windows*{margin: 0;padding: 0;}

这没啥可说的,下面就来看看 main.js 中都写了点啥。代码如下:

import WindowManager from './WindowManager.js'const t = THREE;let camera, scene, renderer, world;let near, far;let pixR = window.devicePixelRatio " /> {if (document.visibilityState != 'hidden' && !initialized) {init();}});// 确保在窗口完全加载后,只有在页面可见时才执行初始化逻辑window.onload = () => {if (document.visibilityState != 'hidden') {init();}};// 初始化操作function init () {initialized = true;// 短时间内window.offsetX属性返回的值可能不准确,需要添加一个短暂的延迟,等待一段时间后再执行相关操作。setTimeout(() => {setupScene();setupWindowManager();resize();updateWindowShape(false);render();window.addEventListener('resize', resize);}, 500)}// 设置场景相关的配置function setupScene () {camera = new t.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);camera.position.z = 2.5;near = camera.position.z - .5;far = camera.position.z + 0.5;scene = new t.Scene();scene.background = new t.Color(0.0);scene.add( camera );renderer = new t.WebGLRenderer({antialias: true, depthBuffer: true});renderer.setPixelRatio(pixR);world = new t.Object3D();scene.add(world);renderer.domElement.setAttribute("id", "scene");document.body.appendChild( renderer.domElement );}// 设置窗口管理器的相关配置function setupWindowManager () {windowManager = new WindowManager();windowManager.setWinShapeChangeCallback(updateWindowShape);windowManager.setWinChangeCallback(windowsUpdated);let metaData = {foo: "bar"};// 初始化窗口管理器(windowmanager)并将当前窗口添加到窗口池中。windowManager.init(metaData);windowsUpdated();}function windowsUpdated () {updateNumberOfCubes();}function updateNumberOfCubes () {let wins = windowManager.getWindows();cubes.forEach((c) => {world.remove(c);})cubes = [];for (let i = 0; i < wins.length; i++) {let win = wins[i];let c = new t.Color();c.setHSL(i * .1, 1.0, .5);let s = 100 + i * 50;let cube = new t.Mesh(new t.BoxGeometry(s, s, s), new t.MeshBasicMaterial({color: c , wireframe: true}));cube.position.x = win.shape.x + (win.shape.w * .5);cube.position.y = win.shape.y + (win.shape.h * .5);world.add(cube);cubes.push(cube);}}function updateWindowShape (easing = true) {sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};if (!easing) sceneOffset = sceneOffsetTarget;}function render () {let t = getTime();windowManager.update();// 根据当前位置和新位置之间的偏移量以及一个平滑系数来计算出窗口的新位置let falloff = .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;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 * .5), y: win.shape.y + (win.shape.h * .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 * .5;cube.rotation.y = _t * .3;};renderer.render(scene, camera);requestAnimationFrame(render);}// 调整渲染器大小以适合窗口大小function resize () {let width = window.innerWidth;let height = window.innerHeightcamera = new t.OrthographicCamera(0, width, 0, height, -10000, 10000);camera.updateProjectionMatrix();renderer.setSize( width, height );}}

这段代码主要实现以下几点:

  • 初始化场景和渲染器:在setupScene函数中,设置了一个正交相机、场景和渲染器,并将渲染器的 DOM 元素添加到页面中。

  • 初始化窗口管理器:在setupWindowManager函数中,创建了一个窗口管理器实例,并初始化了窗口并添加到窗口池中。

  • 更新立方体数量和位置:通过updateNumberOfCubes函数,根据窗口管理器中窗口的数量和位置信息,动态创建立方体并根据窗口位置更新其在场景中的位置。

  • 渲染循环:在render函数中,使用requestAnimationFrame不断循环渲染场景,并根据窗口管理器中窗口的位置更新立方体的位置和旋转。

  • 响应窗口大小变化:通过resize函数,在窗口大小变化时重新设置相机的宽高比和渲染器的大小,以适应新的窗口尺寸。

接下来看看最核心的实现:WindowManager,代码如下:

 class WindowManager {#windows;#count;#id;#winData;#winShapeChangeCallback;#winChangeCallback;constructor () {let that = this;// 监听 localStorage 是否被其他窗口更改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);// 从窗口列表中移除当前窗口并更新 localStoragethat.#windows.splice(index, 1);that.updateWindowsLocalStorage();});}// 检查窗口列表是否有变化#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;}}// 初始化当前窗口(添加元数据以将自定义数据存储在每个窗口实例中)init (metaData) {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);localStorage.setItem("count", this.#count);this.updateWindowsLocalStorage();}getWinShape () {let shape = {x: window.screenLeft, y: window.screenTop, w: window.innerWidth, h: window.innerHeight};return shape;}getWindowIndexFromId (id) {let index = -1;for (let i = 0; i < this.#windows.length; i++) {if (this.#windows[i].id == id) index = i;}return index;}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();}}setWinShapeChangeCallback (callback) {this.#winShapeChangeCallback = callback;}setWinChangeCallback (callback) {this.#winChangeCallback = callback;}getWindows () {return this.#windows;}getThisWindowData () {return this.#winData;}getThisWindowID () {return this.#id;}}export default WindowManager;

这段代码定义了一个WindowManager类,用于管理窗口的创建、更新和删除等操作,并将其作为模块导出。

该类包含以下私有属性:

  • #windows: 存储所有窗口的数组。

  • #count: 记录窗口的数量。

  • #id: 当前窗口的唯一标识符。

  • #winData: 当前窗口的元数据,包括窗口的形状、自定义数据等。

  • #winShapeChangeCallback: 当窗口形状发生变化时调用的回调函数。

  • #winChangeCallback: 当窗口列表发生变化时调用的回调函数。

该类包含以下公共方法:

  • init(metaData): 初始化当前窗口,并添加到窗口列表中。

  • getWindows(): 获取所有窗口的数组。

  • getThisWindowData(): 获取当前窗口的元数据。

  • getThisWindowID(): 获取当前窗口的标识符。

  • setWinShapeChangeCallback(callback): 设置窗口形状变化时的回调函数。

  • setWinChangeCallback(callback): 设置窗口列表变化时的回调函数。

  • update(): 更新当前窗口的形状信息,并将更新后的窗口列表存储到本地存储中。

可以看到,作者使用window.screenLeftwindow.screenTopwindow.innerWidthwindow.innerHeight这些属性来计算立方体的位置和大小信息,通过localstorage来在不同窗口之间共享不同的位置信息。

当新增一个窗口时,就将其保存到localstorage中,每个窗口使用唯一的id进行标记,并储存立方体的位置和大小信息。不同浏览器窗口都可以获得所有的窗口信息,以确保实时更新。

当窗口的位置,即screenTopscreenLeft发生变化时,就更新立方体。

这里就不再详细解释了,可以查看完整源码:https://github.com/bgstaal/multipleWindow3dScene