3D多窗口交互系统:点线虚线与引力场效果详解
一、项目概述
这个项目创建了一个3D
多窗口交互系统,当用户打开多个窗口时,会在每个窗口中显示旋转的3D
立方体,并在两个窗口之间生成动态的引力场效果线。这些引力场线以点状虚线呈现,并带有垂直于主线的短线装饰。
1. 打开一个网页的效果如下:
1. 打开两个网页的效果如下(网页重合时):
1. 打开两个网页的效果如下(网页不重合时):
二、核心功能解析
1. 多窗口管理机制
1.1 WindowManager类
这个类是项目的核心控制器,负责管理所有关联窗口的状态:
class WindowManager {
#windows; // 存储所有窗口数据
#count; // 窗口计数器
#id; // 当前窗口ID
#winData; // 当前窗口数据
#winShapeChangeCallback; // 窗口形状变化回调
#winChangeCallback; // 窗口数量变化回调
}
1.2 窗口同步原理
localStorage
:所有窗口共享的存储空间storage
事件监听:当任何窗口更新数据时,其他窗口会收到通知beforeunload
事件:窗口关闭时自动清理自身数据
2. 3D场景搭建
2.1 Three.js基础设置
function setupScene() {
// 使用正交相机(适合2D/3D混合场景)
camera = new THREE.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);
// 创建场景并设置黑色背景
scene = new THREE.Scene();
scene.background = new THREE.Color(0.0);
// 创建WebGL渲染器
renderer = new THREE.WebGLRenderer({antialias: true, depthBuffer: true});
// 创建世界容器(方便整体移动)
world = new THREE.Object3D();
scene.add(world);
}
2.2 立方体创建逻辑
每个窗口对应一个彩色线框立方体:
let color = new THREE.Color();
color.setHSL(i * 0.1, 1.0, 0.5); // 使用HSL色彩空间,按窗口顺序分配颜色
let cube = new THREE.Mesh(
new THREE.BoxGeometry(size, size, size),
new THREE.MeshBasicMaterial({color: color, wireframe: true})
);
3. 引力场效果实现
3.1 引力场主曲线
// 创建点状虚线效果
const mainMaterial = new THREE.LineDashedMaterial({
color: color,
dashSize: 3, // 虚线段的长度
gapSize: 5, // 间隔的长度
linewidth: 2,
transparent: true,
opacity: 0.8
});
3.2 动态曲线路径
根据窗口距离计算曲线强度:
const strength = Math.min(1, 2000 / distance); // 距离越近强度越大
// 创建波浪形曲线路径
const curveAmount = 50 * (1 - strength) * Math.sin(t * Math.PI);
mainPoints.push(new THREE.Vector3(
x + curveAmount * Math.cos(t * Math.PI * 4),
y + curveAmount * Math.sin(t * Math.PI * 4),
0
));
3.3 顶点装饰短线
// 计算垂直于曲线的方向
const tangentX = nextPoint.x - point.x;
const tangentY = nextPoint.y - point.y;
const normalX = -tangentY / tangentLength; // 法线向量X
const normalY = tangentX / tangentLength; // 法线向量Y
// 创建垂直于主线的短线
vertexPoints.push(new THREE.Vector3(
point.x - normalX * vertexLineLength,
point.y - normalY * vertexLineLength,
0
));
三、关键技术点详解
1. 窗口位置同步
1.1 实时更新机制
function updateWindowShape(easing = true) {
// 计算场景偏移目标值(使所有窗口内容在全局坐标系中正确对齐)
sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};
if (!easing) sceneOffset = sceneOffsetTarget;
}
1.2 平滑过渡效果
// 使用缓动算法实现平滑移动
const falloff = 0.05;
sceneOffset.x = sceneOffset.x + ((sceneOffsetTarget.x - sceneOffset.x) * falloff);
sceneOffset.y = sceneOffset.y + ((sceneOffsetTarget.y - sceneOffset.y) * falloff);
2. 立方体动画
cube.rotation.x = time * 0.5; // 绕X轴旋转
cube.rotation.y = time * 0.3; // 绕Y轴旋转
// 平滑移动到目标位置
cube.position.x = cube.position.x + (targetPos.x - cube.position.x) * falloff;
cube.position.y = cube.position.y + (targetPos.y - cube.position.y) * falloff;
3. 响应式设计
function resize() {
// 根据窗口大小调整相机和渲染器
const width = window.innerWidth;
const height = window.innerHeight;
camera = new THREE.OrthographicCamera(0, width, 0, height, -10000, 10000);
renderer.setSize(width, height);
}
四、小白使用指南
1. 如何运行项目
- 将代码保存为
HTML
文件 - 右击选择在浏览器中打开该文件
- 复制地址栏
URL
并在新窗口中打开(至少打开2个窗口查看完整效果)
2. 调试技巧
- 添加
?clear
参数重置所有存储数据 - 按
F12
打开开发者工具查看控制台日志
3. 自定义修改建议
- 修改立方体样式:调整
BoxGeometry
参数和材质属性 - 改变引力场效果:修改
LineDashedMaterial
的dashSize/gapSize - 添加更多交互:在
render
函数中加入鼠标/键盘交互逻辑
五、总结与扩展
展示了如何利用现代Web
技术创建复杂的多窗口交互体验。关键创新点包括:
- 使用
localStorage
实现跨窗口通信 - 引用
Three.js
实现高性能3D渲染 - 利用点状虚线模拟引力场实现可视化
可扩展方向:
- 添加窗口碰撞检测和物理效果
六、【源代码】
<!DOCTYPE html>
<html lang="en">
<head>
<title>3D Multi-Window with Dot-Dashed Gravity Fields</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.124.0/build/three.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
}
#scene {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<script>
class WindowManager {
#windows;
#count;
#id;
#winData;
#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();
});
}
#didWindowsChange(pWins, nWins) {
if (pWins.length != nWins.length) return true;
for (let i = 0; i < pWins.length; i++) {
if (pWins[i].id != nWins[i].id) return true;
}
return false;
}
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() {
return {
x: window.screenLeft,
y: window.screenTop,
w: window.innerWidth,
h: window.innerHeight
};
}
getWindowIndexFromId(id) {
for (let i = 0; i < this.#windows.length; i++) {
if (this.#windows[i].id == id) return i;
}
return -1;
}
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;
}
}
// Main application
const THREE = window.THREE;
let camera, scene, renderer, world;
let pixR = window.devicePixelRatio ? window.devicePixelRatio : 1;
let cubes = [];
let gravityFields = [];
let sceneOffsetTarget = {x: 0, y: 0};
let sceneOffset = {x: 0, y: 0};
let today = new Date();
today.setHours(0);
today.setMinutes(0);
today.setSeconds(0);
today.setMilliseconds(0);
today = today.getTime();
let windowManager;
let initialized = false;
function getTime() {
return (new Date().getTime() - today) / 1000.0;
}
if (new URLSearchParams(window.location.search).get("clear")) {
localStorage.clear();
} else {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState != 'hidden' && !initialized) {
init();
}
});
window.onload = () => {
if (document.visibilityState != 'hidden') {
init();
}
};
function init() {
initialized = true;
setTimeout(() => {
setupScene();
setupWindowManager();
resize();
updateWindowShape(false);
render();
window.addEventListener('resize', resize);
}, 500);
}
function setupScene() {
camera = new THREE.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);
camera.position.z = 2.5;
scene = new THREE.Scene();
scene.background = new THREE.Color(0.0);
scene.add(camera);
renderer = new THREE.WebGLRenderer({antialias: true, depthBuffer: true});
renderer.setPixelRatio(pixR);
world = new THREE.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.init(metaData);
windowsUpdated();
}
function windowsUpdated() {
updateNumberOfCubes();
updateGravityFields();
}
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 color = new THREE.Color();
color.setHSL(i * 0.1, 1.0, 0.5);
let size = 100 + i * 50;
let cube = new THREE.Mesh(
new THREE.BoxGeometry(size, size, size),
new THREE.MeshBasicMaterial({color: color, wireframe: true})
);
cube.position.x = win.shape.x + (win.shape.w * 0.5);
cube.position.y = win.shape.y + (win.shape.h * 0.5);
world.add(cube);
cubes.push(cube);
}
}
function updateGravityFields() {
gravityFields.forEach((field) => {
world.remove(field);
});
gravityFields = [];
let wins = windowManager.getWindows();
if (wins.length === 2) {
const win1 = wins[0];
const win2 = wins[1];
const pos1 = {
x: win1.shape.x + (win1.shape.w * 0.5),
y: win1.shape.y + (win1.shape.h * 0.5)
};
const pos2 = {
x: win2.shape.x + (win2.shape.w * 0.5),
y: win2.shape.y + (win2.shape.h * 0.5)
};
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 创建主引力场线(点状虚线)
const mainPoints = [];
const segments = 30;
const strength = Math.min(1, 2000 / distance);
const color = new THREE.Color();
color.setHSL(0.7 * (1 - strength), 1.0, 0.5);
// 创建主曲线路径
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const x = pos1.x + t * dx;
const y = pos1.y + t * dy;
const curveAmount = 50 * (1 - strength) * Math.sin(t * Math.PI);
mainPoints.push(new THREE.Vector3(
x + curveAmount * Math.cos(t * Math.PI * 4),
y + curveAmount * Math.sin(t * Math.PI * 4),
0
));
}
// 主引力场线(点状虚线)
const mainGeometry = new THREE.BufferGeometry().setFromPoints(mainPoints);
const mainMaterial = new THREE.LineDashedMaterial({
color: color,
dashSize: 3,
gapSize: 5,
linewidth: 2,
transparent: true,
opacity: 0.8
});
const mainLine = new THREE.Line(mainGeometry, mainMaterial);
mainLine.computeLineDistances();
world.add(mainLine);
gravityFields.push(mainLine);
// 添加顶点虚线(从每个顶点向外辐射的短线)
const vertexSegmentCount = 8;
const vertexLineLength = 20 * strength;
for (let i = 0; i <= segments; i += 2) {
if (i === 0 || i === segments) continue; // 跳过端点
const point = mainPoints[i];
const nextPoint = mainPoints[i+1];
// 计算切线方向
const tangentX = nextPoint.x - point.x;
const tangentY = nextPoint.y - point.y;
const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
const normalX = -tangentY / tangentLength;
const normalY = tangentX / tangentLength;
// 创建垂直于曲线的短线
const vertexPoints = [];
vertexPoints.push(new THREE.Vector3(
point.x - normalX * vertexLineLength,
point.y - normalY * vertexLineLength,
0
));
vertexPoints.push(new THREE.Vector3(
point.x + normalX * vertexLineLength,
point.y + normalY * vertexLineLength,
0
));
const vertexGeometry = new THREE.BufferGeometry().setFromPoints(vertexPoints);
const vertexMaterial = new THREE.LineDashedMaterial({
color: color,
dashSize: 1,
gapSize: 2,
linewidth: 1,
transparent: true,
opacity: 0.6
});
const vertexLine = new THREE.Line(vertexGeometry, vertexMaterial);
vertexLine.computeLineDistances();
world.add(vertexLine);
gravityFields.push(vertexLine);
}
}
}
function updateWindowShape(easing = true) {
sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};
if (!easing) sceneOffset = sceneOffsetTarget;
}
function render() {
let time = getTime();
windowManager.update();
const 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;
let wins = windowManager.getWindows();
for (let i = 0; i < cubes.length; i++) {
let cube = cubes[i];
let win = wins[i];
let targetPos = {
x: win.shape.x + (win.shape.w * 0.5),
y: win.shape.y + (win.shape.h * 0.5)
};
cube.position.x = cube.position.x + (targetPos.x - cube.position.x) * falloff;
cube.position.y = cube.position.y + (targetPos.y - cube.position.y) * falloff;
cube.rotation.x = time * 0.5;
cube.rotation.y = time * 0.3;
};
if (wins.length === 2) {
updateGravityFields();
}
renderer.render(scene, camera);
requestAnimationFrame(render);
}
function resize() {
const width = window.innerWidth;
const height = window.innerHeight;
camera = new THREE.OrthographicCamera(0, width, 0, height, -10000, 10000);
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
}
</script>
</body>
</html>