├── README.md ├── ball.js ├── images └── sprite.png ├── index.html ├── index.js ├── storage.js ├── three ├── three.cjs ├── three.js ├── three.min.js ├── three.module.js └── three.module.min.js ├── v1.gif ├── v1.html ├── v2.1.gif ├── v2.gif └── win.js /README.md: -------------------------------------------------------------------------------- 1 | # mark-cross-window-render 2 | 3 | 跨窗口量子纠缠粒子效果 4 | 5 | v2.0 Demo: https://markz-demo.github.io/mark-cross-window-render/ 6 | 7 | ### v2.1 8 | 9 | 需要去掉`ball.js`文件顶部常量注释。 10 | 11 | ![Alt text](v2.1.gif) 12 | 13 | ### v2.0 14 | 15 | ![Alt text](v2.gif) 16 | 17 | ### v1.0 18 | 19 | 纯js实现Web跨窗口渲染动画效果 20 | 21 | ![Alt text](v1.gif) -------------------------------------------------------------------------------- /ball.js: -------------------------------------------------------------------------------- 1 | import * as THREE from './three/three.module.js'; 2 | 3 | // const OUTER_RADIUS = 150, INNER_RADIUS = 80; // 大小球体的半径 4 | // const OUTER_SPRITE_WIDTH = 50, INNER_RSPRITE_WIDTH = 20; // 大小球体上的粒子高宽 5 | // const OUTER_SPRITE_COUNT = 100, INNER_RSPRITE_COUNT = 50, MOVE_SPRITE_COUNT = 10; // 大小球体、及连线上的粒子个数 6 | // const MOVE_FRAME_NUM = 100; // 连线动画的粒子移动的帧数,数越小移动速度越快 7 | // const MOVE_FRAME_GAP = 10; // 连线动画的粒子先后移动的帧数间隔,数值越小越密集 8 | // const COLORS = [0, 60 / 360, 120 / 360, 180 / 360, 240 / 360]; // 粒子颜色数组,一个球体对应一个颜色 9 | 10 | // v2.1版小粒子效果 11 | 12 | const OUTER_RADIUS = 150, INNER_RADIUS = 80; // 大小球体的半径 13 | const OUTER_SPRITE_WIDTH = 10, INNER_RSPRITE_WIDTH = 5; // 大小球体上的粒子高宽 14 | const OUTER_SPRITE_COUNT = 300, INNER_RSPRITE_COUNT = 50, MOVE_SPRITE_COUNT = 200; // 大小球体、及连线上的粒子个数 15 | const MOVE_FRAME_NUM = 100; // 连线动画的粒子移动的帧数 16 | const MOVE_FRAME_GAP = 2; // 连线动画的粒子先后移动的帧数间隔,数值越小越密集 17 | const COLORS = [0, 60 / 360, 120 / 360, 180 / 360, 240 / 360]; // 粒子颜色数组,一个球体对应一个颜色 18 | 19 | 20 | // 创建粒子材质 21 | const materialTemp = (function () { 22 | // 创建纹理贴图 23 | const textureLoader = new THREE.TextureLoader(); 24 | const map = textureLoader.load('images/sprite.png'); 25 | map.colorSpace = THREE.SRGBColorSpace; 26 | // 精灵材质 27 | return new THREE.SpriteMaterial({ map: map, color: 0xffffff, fog: true }); 28 | })(); 29 | 30 | // 字体类,一个球体对应一个对象 31 | export default class BALL { 32 | 33 | camera; 34 | renderer; 35 | scene; 36 | key; 37 | group1; 38 | group2; 39 | offset; 40 | movePList = []; 41 | moving = false; 42 | destroyed = false; 43 | 44 | constructor(camera, renderer, scene, offset, key) { 45 | this.camera = camera; 46 | this.renderer = renderer; 47 | this.scene = scene; 48 | this.offset = offset; 49 | this.key = key; 50 | } 51 | 52 | // 实例化,渲染两个球体Group(一大一小)和连线粒子list 53 | init(keys) { 54 | this.group1 = new THREE.Group(); 55 | this.group2 = new THREE.Group(); 56 | for (let i = 0; i < OUTER_SPRITE_COUNT; i++) { 57 | const sprite = this.createSprite(); 58 | this.group1.add(sprite); 59 | } 60 | for (let i = 0; i < INNER_RSPRITE_COUNT; i++) { 61 | const sprite = this.createSprite(keys, i); 62 | this.group2.add(sprite); 63 | this.group2.visible = keys.length > 0; 64 | } 65 | this.scene.add(this.group1); 66 | this.scene.add(this.group2); 67 | 68 | this.movePList = []; 69 | // 除了自身,有几个其它球体,就实例化几组粒子,用来链接球体动画 70 | keys.forEach(k => { 71 | const groups = []; 72 | for (let i = 0; i < MOVE_SPRITE_COUNT; i++) { 73 | const sprite = this.createSprite(); 74 | const group = new THREE.Group(); 75 | group.visible = false; 76 | group.add(sprite); 77 | this.scene.add(group); 78 | groups.push(group); 79 | } 80 | this.movePList.push({ key: k, groups, moving: false, frame: 0, index: 0 }) 81 | }); 82 | } 83 | 84 | // 创建粒子对象 85 | createSprite(keys, i) { 86 | const x = Math.random() - 0.5; 87 | const y = Math.random() - 0.5; 88 | const z = Math.random() - 0.5; 89 | const inner = !!keys; 90 | const radius = !inner ? OUTER_RADIUS : INNER_RADIUS; 91 | const width = !inner ? OUTER_SPRITE_WIDTH : INNER_RSPRITE_WIDTH; 92 | let key = this.key; 93 | if (inner && keys.length > 0) { 94 | key = keys[i % keys.length]; // 如果除了当前球体,还有其它多个球体,需要将内圆球体粒子显示多种颜色 95 | } 96 | 97 | // clone材质 98 | const material = materialTemp.clone(); 99 | // 粒子颜色的对比度随机 100 | material.color.setHSL(COLORS[(key - 1) % 3], 0.75/* Math.random() */, 0.5); 101 | material.map.offset.set(- 0.5, - 0.5); 102 | material.map.repeat.set(2, 2); 103 | // 创建精灵 104 | const sprite = new THREE.Sprite(material); 105 | sprite.position.set(x, y, z); 106 | sprite.position.normalize(); 107 | sprite.position.multiplyScalar(radius); 108 | sprite.scale.set(width, width, 1.0); 109 | return sprite; 110 | } 111 | 112 | // 移除当前球体所有相关对象,并置为销毁状态 113 | remove() { 114 | this.scene.remove(this.group1); 115 | this.scene.remove(this.group2); 116 | this.movePList.forEach(({ groups }) => { 117 | groups.forEach(group => { 118 | this.scene.remove(group); 119 | }); 120 | }); 121 | this.destroyed = true; 122 | } 123 | 124 | // 渲染动画 125 | render(offset, movePs) { 126 | if (this.destroyed) return; 127 | // 渲染粒子绕球体3d旋转动画 128 | this.renderRotation(); 129 | 130 | // 如果球体不在窗口中心,需要按offset位移坐标 131 | if (offset) { 132 | this.offset = offset; 133 | this.group1.position.x = this.group2.position.x = this.offset.x; 134 | this.group1.position.y = this.group2.position.y = this.offset.y; 135 | } 136 | 137 | // 渲染球体之间连线动画 138 | this.renderMove(movePs); 139 | 140 | this.renderer.render(this.scene, this.camera); 141 | } 142 | 143 | // 渲染粒子绕球体3d旋转动画 144 | renderRotation() { 145 | const time = Date.now() / 1000; 146 | this.group1.children.forEach(sprite => { 147 | sprite.material.rotation -= 0.1; 148 | }); 149 | this.group1.rotation.x = this.group2.rotation.x = time * 0.5; 150 | this.group1.rotation.y = this.group2.rotation.y = time * 0.75; 151 | this.group1.rotation.z = this.group2.rotation.z = time * 1.0; 152 | 153 | // this.movePList.forEach(({ groups }) => { 154 | // groups.forEach(group => { 155 | // group.rotation.x = time * 0.5; 156 | // group.rotation.y = time * 0.75; 157 | // group.rotation.z = time * 1.0; 158 | // }); 159 | // }); 160 | } 161 | 162 | // 渲染球体之间连线动画 163 | renderMove(movePs) { 164 | const { group1, movePList } = this; 165 | // 如果除了当前球体还有别的多个球体,需要遍历其它球体数据,渲染连线动画 166 | movePs.forEach(({ key, moveP }) => { 167 | const move = movePList.find(item => item.key == key); 168 | 169 | // 如果两个球体重合了,停止连线动画,隐藏所有粒子 170 | if (!moveP || (Math.abs(moveP.x) <= OUTER_RADIUS * 2 && Math.abs(moveP.y) <= OUTER_RADIUS * 2)) { 171 | if (move.moving == true) { 172 | move.moving = false; 173 | move.frame = 0; 174 | move.index = 0; 175 | move.groups.forEach(group => { 176 | group.visible = false; 177 | this.clearGroupPosition(group); 178 | }); 179 | } 180 | return; 181 | } 182 | 183 | if ((move.p && (moveP.x != move.p.x || moveP.y != move.p.y)) || move.moving == false) { 184 | move.p = moveP; 185 | move.groups.forEach(group => { 186 | this.clearGroupPosition(group); 187 | }) 188 | move.moving = true; 189 | move.frame = 0; 190 | move.index = 0; 191 | } 192 | 193 | const moveX = moveP.x + group1.position.x; 194 | const moveY = moveP.y + group1.position.y; 195 | 196 | move.frame++; 197 | if (move.frame % MOVE_FRAME_GAP === 0) { // 每10帧开始移动一个粒子,达到粒子先后依次移动射线效果 198 | move.index++; 199 | } 200 | move.groups.forEach((group, i) => { 201 | group.index ||= 0; 202 | if (i > move.index) { 203 | return; 204 | } 205 | const gapX = Math.abs(moveX - group.position.x); 206 | const gapY = Math.abs(moveY - group.position.y); 207 | 208 | if (group.index == MOVE_FRAME_NUM) { // 粒子移动具体帧数后,重置位置,重新移动 209 | group.visible = false; 210 | group.index = 0; 211 | this.clearGroupPosition(group); 212 | } 213 | else if (gapX < 0.5 || gapY < 0.5) { // 粒子移动临近目的位置时,隐藏,等待帧数结束重新移动 214 | group.index++; 215 | group.visible = false; 216 | } 217 | else { 218 | group.index++; 219 | group.visible = true; 220 | group.position.x += moveP.x / MOVE_FRAME_NUM; // 改变粒子坐标,从球1到球2位置 221 | group.position.y += moveP.y / MOVE_FRAME_NUM; 222 | const radius = OUTER_RADIUS - group.index * (OUTER_RADIUS - INNER_RADIUS / 2) / MOVE_FRAME_NUM; 223 | const width = OUTER_SPRITE_WIDTH - group.index * (OUTER_SPRITE_WIDTH - INNER_RSPRITE_WIDTH) / MOVE_FRAME_NUM; 224 | group.children[0].position.normalize(); 225 | group.children[0].position.multiplyScalar(radius); // 改变粒子环绕半径,从大到小 226 | group.children[0].scale.set(width, width, 1.0); // 改变粒子高宽,从大到小 227 | } 228 | }); 229 | }) 230 | } 231 | 232 | // 重置所有连线粒子状态 233 | clearGroupPosition(group) { 234 | const { group1 } = this; 235 | group.index = 0; 236 | group.position.x = group1.position.x; 237 | group.position.y = group1.position.y; 238 | group.children[0].position.normalize(); 239 | group.children[0].position.multiplyScalar(OUTER_RADIUS); 240 | group.children[0].scale.set(OUTER_SPRITE_WIDTH, OUTER_SPRITE_WIDTH, 1.0); 241 | } 242 | } -------------------------------------------------------------------------------- /images/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markz-demo/mark-cross-window-render/45278c4b091163ca5cf2e32e0b55eb2aa33c270b/images/sprite.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 33 | 34 | 35 | 36 |
37 | 38 | 39 |
40 |
41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import BALL from './ball.js'; 2 | import * as win from './win.js'; 3 | import * as storage from './storage.js'; 4 | 5 | const alert1 = document.getElementById('alert1'); 6 | const clear1 = document.getElementById('clear1'); 7 | const btn1 = document.getElementById('btn1'); 8 | btn1.onclick = function () { }; 9 | clear1.onclick = function () { storage.clear() }; 10 | 11 | let camera, renderer, scene; 12 | let key, balls = []; 13 | 14 | // 进入页面后延迟200ms,延迟初始化 15 | setTimeout(() => init(), 500); 16 | 17 | // 初始化 18 | function init() { 19 | ({ camera, renderer, scene } = win.init()); // 创建three摄像机、场景、渲染器 20 | key = storage.init(); // 生成当前窗口key值,并且存入storage 21 | 22 | initBalls(); // 初始化渲染球体 23 | animate(); // 开始动画渲染 24 | 25 | // 监听其它窗口storage变化事件 26 | // 如果窗口数量变化,则需要重新实例化球体数据 27 | // 如果数量没有变化,则需要更新所有球体对应窗口坐标 28 | window.addEventListener("storage", function (event) { 29 | if (event.key == "demo") { 30 | let wins = JSON.parse(event.newValue); 31 | if (JSON.parse(event.oldValue).length != JSON.parse(event.newValue).length) { 32 | 33 | balls.forEach(({ ball, key }) => { 34 | ball.remove(); 35 | }) 36 | initBalls(); 37 | } 38 | else { 39 | balls.forEach(item => { 40 | item.win = wins.find(w => w.key == item.key); 41 | }); 42 | } 43 | } 44 | }); 45 | // 监听页面unload事件,从storage删除对应数据 46 | window.addEventListener("unload", function () { 47 | storage.remove(key); 48 | }); 49 | // 监听页面resize事件,更新摄像机比例和渲染器size 50 | window.addEventListener('resize', function () { 51 | win.resize(camera, renderer); 52 | }); 53 | } 54 | 55 | // 初始化渲染球体,并且把多球体数据存入全局变量balls中 56 | function initBalls() { 57 | balls = []; 58 | const wins = storage.getAll(); 59 | wins.forEach(win => { 60 | const ball = new BALL(camera, renderer, scene, { x: 0, y: 0 }, win.key); 61 | const keys = wins.filter(item => item.key != win.key).map(item => item.key); 62 | ball.init(keys); 63 | /* 64 | ball 球体three对象 65 | key 球体唯一key 66 | win 球体窗口坐标 67 | */ 68 | balls.push({ ball, key: win.key, win }); 69 | }) 70 | } 71 | 72 | // 开始动画渲染 73 | function animate() { 74 | requestAnimationFrame(animate); 75 | 76 | const { screenX, screenY } = window; 77 | const currentWin = storage.get(key); 78 | // 判断当前窗口坐标是否有变化(即窗口是否移动),若有变化,则更新storage及ball数据 79 | if (currentWin.screenX != screenX || currentWin.screenY != screenY) { 80 | const value = { key, screenX, screenY }; 81 | storage.set(key, value); 82 | balls.find(item => item.key == key).win = value; 83 | } 84 | 85 | // 循环所有球体数据,并在当前窗口渲染出来所有球体动画 86 | balls.forEach(({ ball, key: k, win }) => { 87 | const offset = { 88 | x: (win.screenX - screenX), 89 | y: -(win.screenY - screenY), 90 | }; 91 | const movePs = []; 92 | // 过滤并遍历其它球体数据,用于渲染球体之间的连线动画 93 | balls.filter(item => item.key != k).forEach(({ ball: ball2, key: k2, win: win2 }) => { 94 | const moveP = { 95 | x: (win2.screenX - win.screenX), 96 | y: -(win2.screenY - win.screenY), 97 | }; 98 | movePs.push({ key: k2, moveP }); 99 | }); 100 | 101 | // 渲染球体及其连线 102 | ball.render(offset, movePs); 103 | }) 104 | 105 | // log(); 106 | } 107 | 108 | // 打log,把数据json序列化显示在dom中 109 | function log() { 110 | const { screenX, screenY, innerWidth, innerHeight } = window; 111 | alert1.textContent = JSON.stringify({ 112 | key, 113 | screenX, screenY, innerWidth, innerHeight, 114 | storage: storage.getAll(), 115 | }, null, 2); 116 | } -------------------------------------------------------------------------------- /storage.js: -------------------------------------------------------------------------------- 1 | export const init = () => { 2 | const { screenX, screenY } = window; // 获取浏览器相对屏幕坐标 3 | const wins = getAll(); 4 | const keys = wins.map(item => item.key); 5 | const key = keys.length == 0 ? 1 : keys.at(-1) + 1; // 自增最大的key序号,定义自己窗口storage key 6 | wins.push({ key, screenX, screenY }); 7 | localStorage.setItem('demo', JSON.stringify(wins)); 8 | return key; 9 | } 10 | 11 | export const getAll = () => { 12 | const value = localStorage.getItem('demo') || JSON.stringify([]); 13 | const wins = JSON.parse(value).sort((a, b) => a.key - b.key); 14 | return wins; 15 | } 16 | export const get = (key) => { 17 | const wins = getAll(); 18 | return wins.find(item => item.key == key); 19 | } 20 | export const set = (key, value) => { 21 | let wins = getAll(); 22 | wins = wins.map(item => { 23 | if (item.key == key) { 24 | return value; 25 | } 26 | return item; 27 | }) 28 | localStorage.setItem('demo', JSON.stringify(wins)); 29 | return wins; 30 | } 31 | 32 | export const remove = (key) => { 33 | let wins = getAll(); 34 | wins = wins.filter(item => item.key != key); 35 | localStorage.setItem('demo', JSON.stringify(wins)); 36 | } 37 | 38 | export const clear = () => { 39 | localStorage.clear(); 40 | } -------------------------------------------------------------------------------- /v1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markz-demo/mark-cross-window-render/45278c4b091163ca5cf2e32e0b55eb2aa33c270b/v1.gif -------------------------------------------------------------------------------- /v1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 15 | 16 | 17 | 18 |
19 |
20 |
21 | 22 | 23 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /v2.1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markz-demo/mark-cross-window-render/45278c4b091163ca5cf2e32e0b55eb2aa33c270b/v2.1.gif -------------------------------------------------------------------------------- /v2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markz-demo/mark-cross-window-render/45278c4b091163ca5cf2e32e0b55eb2aa33c270b/v2.gif -------------------------------------------------------------------------------- /win.js: -------------------------------------------------------------------------------- 1 | import * as THREE from './three/three.module.js'; 2 | 3 | export const init = () => { 4 | const { innerWidth, innerHeight, devicePixelRatio } = window; 5 | 6 | // 摄像机 7 | const perspective = 800; 8 | const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI; 9 | const camera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 1, 1000); 10 | camera.position.set(0, 0, perspective); 11 | 12 | // 场景 13 | const scene = new THREE.Scene(); 14 | 15 | // 渲染器 16 | const renderer = new THREE.WebGLRenderer(); 17 | renderer.setPixelRatio(devicePixelRatio); 18 | renderer.setSize(innerWidth, innerHeight); 19 | document.body.appendChild(renderer.domElement); 20 | 21 | // const geometry = new THREE.BoxGeometry(100, 100, 100); 22 | // const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); 23 | // const cube = new THREE.Mesh(geometry, material); 24 | // scene.add(cube); 25 | 26 | return { camera, renderer, scene }; 27 | } 28 | 29 | export const resize = (camera, renderer) => { 30 | const { innerWidth, innerHeight, devicePixelRatio } = window; 31 | camera.aspect = innerWidth / innerHeight; 32 | camera.updateProjectionMatrix(); 33 | renderer.setPixelRatio(devicePixelRatio); 34 | renderer.setSize(innerWidth, innerHeight); 35 | } --------------------------------------------------------------------------------