二(合)、3D 项目说明文档(v1)
3D 项目说明文档
这是一个基于 Three.js 的前端 3D 展示项目。打开页面后,你会先看到一个 3D 相册式加载页,等模型加载完成后,点击“进入”,就可以进入一个 3D 笔记本电脑场景。
这个场景不只是展示一个电脑模型,还包含:
- 电脑屏幕中嵌入网页
- 可切换播放本地视频
- 环绕场景的“企鹅”模型
- 旗帜、粒子束、发光文字等特效
- 相机可拖动旋转查看
- 键盘快捷键可触发一些交互功能
如果你是小白,可以把这个项目理解成:
一个“用网页技术做出来的 3D 展示页面”。




1. 项目能做什么
这个项目当前主要实现了下面这些效果:
1)加载页
- 页面一打开,先显示一个旋转的 3D 相册
- 相册下方会显示模型加载进度
- 模型加载完成后,会出现“点击进入”按钮
- 点击后,加载层淡出,进入主场景
2)3D 主场景
- 加载一个笔记本电脑 3D 模型:
assets/models/laptop.glb - 自动把相机移动到合适的位置,方便直接看到模型
- 支持鼠标拖动查看(OrbitControls)
3)电脑屏幕内容
- 屏幕里默认嵌入一个网页:
https://rockosdev.github.io/ - 按
V键后,屏幕可以切换成播放本地视频:assets/textures/video.mp4 - 再按一次
V,会切回网页
4)环境与特效
- 创建了一个类似“地核 / 经纬网”的球形环境氛围
- 加载了一个企鹅模型:
assets/models/qq.glb - 企鹅会围绕场景旋转
- 按
R键可以开启/关闭:- 旗帜效果
- 粒子束特效
- 浮动文字效果
5)辅助功能
- 按
U键恢复初始视角 - 有调试快捷键,可以微调屏幕位置、旋转、缩放
2. 项目目录结构说明
下面是这个项目的主要结构:
3d/
├── index.html # 页面入口
├── style.css # 预留样式文件(当前基本未使用)
├── album-loading.css # 3D 相册加载层样式
├── ALBUM_LOADING.md # 相册加载层的补充说明
├── README.md # 你现在看到的说明文档
├── js/
│ ├── main.js # 核心逻辑文件
│ └── main.js.bak # 备份文件
└── assets/
├── world.geojson # 地理数据文件(当前代码中未明显使用)
├── album/
│ └── img/ # 相册加载页使用的图片1.jpg (1~6).jpg
├── models/
│ ├── laptop.glb # 电脑模型
│ └── qq.glb # 企鹅模型
└── textures/
├── rain.jpg # 旗帜/贴图纹理
└── video.mp4 # 屏幕播放的视频
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D 可旋转电脑</title>
<!-- 引入 Three.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<link rel="stylesheet" href="./album-loading.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #1a1a1a;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: relative;
}
/* loading overlay 内的 HUD 样式在 album-loading.css 内 */
</style>
</head>
<body>
<div id="canvas-container">
<!-- 加载期间:播放 3D 相册;加载完成后提示“点击进入” -->
<div id="album-loading" aria-label="loading overlay">
<div>
<div class="album-box" aria-label="3d photo album">
<ul class="album-minbox">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<ol class="album-maxbox">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ol>
</div>
<div class="album-hud">
<div id="loading-text">加载模型中...</div>
<div id="progress">0%</div>
<button id="enter-btn" type="button">点击进入</button>
</div>
</div>
</div>
<!-- 兼容:保留原 id=loading(main.js 会用它判断“是否加载完”) -->
<div id="loading" style="display:none"></div>
</div>
<script type="module" src="./js/main.js"></script>
</body>
</html>
album-loading.css
/*
Album loading overlay styles (scoped)
说明:从 3d_photo_album/css/style.css 提取并做了作用域隔离,
只在 #album-loading 内生效,避免污染 3d-computer 其它 DOM。
*/
/* overlay 容器 */
#album-loading {
position: absolute;
inset: 0;
z-index: 999;
display: grid;
place-items: center;
background: #000;
color: #fff;
}
/* 用于淡出动画 */
#album-loading.is-fading {
opacity: 0;
transition: opacity 450ms ease;
}
/* 相册本体(来自原 .box) */
#album-loading .album-box {
width: 200px;
height: 200px;
position: relative;
-webkit-transform-style: preserve-3d;
-webkit-transform: rotateX(13deg);
-webkit-animation: albumMove 5s linear infinite;
}
/* 小立方体 */
#album-loading .album-minbox {
width: 100px;
height: 100px;
position: absolute;
left: 50px;
top: 30px;
-webkit-transform-style: preserve-3d;
}
#album-loading li {
list-style: none;
}
#album-loading .album-minbox li {
width: 100px;
height: 100px;
position: absolute;
left: 0;
top: 0;
background-position: center center;
background-size: contain;
background-repeat: no-repeat;
}
#album-loading .album-minbox li:nth-child(1) {
background-image: url(./assets/album/img/1.jpg);
-webkit-transform: translateZ(50px);
}
#album-loading .album-minbox li:nth-child(2) {
background-image: url(./assets/album/img/2.jpg);
-webkit-transform: rotateY(180deg) translateZ(50px);
}
#album-loading .album-minbox li:nth-child(3) {
background-image: url(./assets/album/img/3.jpg);
-webkit-transform: rotateX(-90deg) translateZ(50px);
}
#album-loading .album-minbox li:nth-child(4) {
background-image: url(./assets/album/img/4.jpg);
-webkit-transform: rotateX(90deg) translateZ(50px);
}
#album-loading .album-minbox li:nth-child(5) {
background-image: url(./assets/album/img/5.jpg);
-webkit-transform: rotateY(-90deg) translateZ(50px);
}
#album-loading .album-minbox li:nth-child(6) {
background-image: url(./assets/album/img/6.jpg);
-webkit-transform: rotateY(90deg) translateZ(50px);
}
/* 大立方体 */
#album-loading .album-maxbox {
width: 800px;
height: 400px;
position: absolute;
left: 0;
top: -20px;
-webkit-transform-style: preserve-3d;
}
#album-loading .album-maxbox li {
width: 200px;
height: 200px;
background: #fff;
background-position: center center;
background-size: contain;
background-repeat: no-repeat;
border: 1px solid #ccc;
position: absolute;
left: 0;
top: 0;
opacity: 0.2;
-webkit-transition: all 1s ease;
}
#album-loading .album-maxbox li:nth-child(1) {
background-image: url(./assets/album/img/1.jpg);
-webkit-transform: translateZ(100px);
}
#album-loading .album-maxbox li:nth-child(2) {
background-image: url(./assets/album/img/2.jpg);
-webkit-transform: rotateX(0deg) translateZ(-100px);
}
#album-loading .album-maxbox li:nth-child(3) {
background-image: url(./assets/album/img/3.jpg);
-webkit-transform: rotateX(-90deg) translateZ(100px);
}
#album-loading .album-maxbox li:nth-child(4) {
background-image: url(./assets/album/img/4.jpg);
-webkit-transform: rotateX(90deg) translateZ(100px);
}
#album-loading .album-maxbox li:nth-child(5) {
background-image: url(./assets/album/img/5.jpg);
-webkit-transform: rotateY(-90deg) translateZ(100px);
}
#album-loading .album-maxbox li:nth-child(6) {
background-image: url(./assets/album/img/6.jpg);
-webkit-transform: rotateY(90deg) translateZ(100px);
}
/* hover 展开(保留原效果:鼠标放上去会展开) */
#album-loading .album-box:hover .album-maxbox li:nth-child(1) {
-webkit-transform: translateZ(300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
#album-loading .album-box:hover .album-maxbox li:nth-child(2) {
-webkit-transform: rotateX(0deg) translateZ(-300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
#album-loading .album-box:hover .album-maxbox li:nth-child(3) {
-webkit-transform: rotateX(-90deg) translateZ(300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
#album-loading .album-box:hover .album-maxbox li:nth-child(4) {
-webkit-transform: rotateX(90deg) translateZ(300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
#album-loading .album-box:hover .album-maxbox li:nth-child(5) {
-webkit-transform: rotateY(-90deg) translateZ(300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
#album-loading .album-box:hover .album-maxbox li:nth-child(6) {
-webkit-transform: rotateY(90deg) translateZ(300px);
width: 400px;
height: 400px;
opacity: 0.8;
left: -100px;
top: -100px;
}
@keyframes albumMove {
0% {
-webkit-transform: rotateX(13deg) rotateY(0deg);
}
100% {
-webkit-transform: rotateX(13deg) rotateY(360deg);
}
}
/* 底部提示/按钮区域 */
#album-loading .album-hud {
margin-top: 28px;
text-align: center;
font-family: Arial, sans-serif;
}
#album-loading #progress {
margin-top: 8px;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
}
#album-loading #enter-btn {
display: none;
margin-top: 12px;
padding: 10px 18px;
font-size: 14px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-radius: 10px;
cursor: pointer;
backdrop-filter: blur(6px);
}
#album-loading #enter-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
#album-loading.is-ready #enter-btn {
display: inline-block;
}
main.js
// ============================================
// 3D 可旋转电脑 - 完整代码(R键触发+前上方60度+音效+文字提示)
// ============================================
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS3DRenderer, CSS3DObject } from 'three/addons/renderers/CSS3DRenderer.js';
// ============================================
// 全局状态管理(新增)
// ============================================
const state = {
isPanelVisible: false,
isAnimating: false,
panelTargetOpacity: 0,
panelCurrentOpacity: 0,
panelTargetScale: 0.1,
panelCurrentScale: 0.1
};
// 存储需要动画的对象(新增)
let backPanel = null;
let backPanelMaterial = null;
let connectionLines = [];
let panelLight = null;
let rainTextSprite = null; // "雨从未迟到"文字精灵
let earthCoreGroup = null;
let earthPulseMeshes = [];
let earthLatLonLines = [];
let earthGridShellMesh = null;
let earthGridRadius = 0;
let orbitRing = null;
let orbitTrackMesh = null;
let orbitPenguin = null;
let orbitRadius = 0;
let orbitCenter = new THREE.Vector3();
let orbitPenguinBaseY = 0;
let orbitTrackWidth = 0;
const ORBIT_PENGUIN_HEADING_OFFSET = Math.PI / 2;
let penguinFollowEffectEnabled = false;
let penguinFlagMesh = null;
let penguinFlagMaterial = null;
let penguinFlagBasePositions = null;
let penguinFlagSegmentsX = 0;
let penguinParticleBeam = null;
let penguinParticleBeamMaterial = null;
let penguinParticlePositions = null;
let penguinParticleData = [];
let penguinTrailLength = 0;
let penguinFlagPoleOffset = 0;
let penguinFlagHeightOffset = 0;
let penguinBeamGapOffset = 0;
let penguinFloatingTextSprite = null;
let penguinFloatingTextHeightOffset = 0;
function createPenguinFlagTexture(textureLoader) {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0b1117';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const canvasTexture = new THREE.CanvasTexture(canvas);
canvasTexture.colorSpace = THREE.SRGBColorSpace;
canvasTexture.wrapS = THREE.RepeatWrapping;
canvasTexture.wrapT = THREE.ClampToEdgeWrapping;
canvasTexture.repeat.set(1, 1);
canvasTexture.offset.set(0, 0);
function drawTexture(image = null) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (image) {
ctx.save();
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
ctx.restore();
} else {
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, '#07131c');
gradient.addColorStop(1, '#163040');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
canvasTexture.needsUpdate = true;
}
drawTexture();
textureLoader.load(
'./assets/textures/rain.jpg',
(loadedTexture) => {
const image = loadedTexture.image;
if (image) drawTexture(image);
},
undefined,
(error) => {
console.warn('旗面贴图加载失败,已使用文字底图代替:', error);
}
);
return canvasTexture;
}
function createPenguinFloatingTextSprite(flagWidth, flagHeight) {
if (penguinFloatingTextSprite) {
scene.remove(penguinFloatingTextSprite);
if (penguinFloatingTextSprite.geometry) {
penguinFloatingTextSprite.geometry.dispose();
}
if (penguinFloatingTextSprite.material.map) {
penguinFloatingTextSprite.material.map.dispose();
}
penguinFloatingTextSprite.material.dispose();
penguinFloatingTextSprite = null;
}
const canvas = document.createElement('canvas');
canvas.width = 2048;
canvas.height = 512;
const ctx = canvas.getContext('2d');
const displayText = '(:雨从未迟到~~';
const charSpacing = 22;
function drawSpacedCenteredText(drawCtx, text, x, y, mode) {
const chars = Array.from(text);
const widths = chars.map((char) => drawCtx.measureText(char).width);
const totalWidth = widths.reduce((sum, width) => sum + width, 0) + charSpacing * Math.max(0, chars.length - 1);
let cursorX = x - totalWidth / 2;
chars.forEach((char, index) => {
const charWidth = widths[index];
const drawX = cursorX + charWidth / 2;
if (mode === 'stroke') {
drawCtx.strokeText(char, drawX, y);
} else {
drawCtx.fillText(char, drawX, y);
}
cursorX += charWidth + charSpacing;
});
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.font = '136px "Microsoft YaHei", "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 255, 255, 0.95)';
ctx.shadowBlur = 12;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = 5;
ctx.strokeStyle = 'rgba(40, 120, 120, 0.9)';
drawSpacedCenteredText(ctx, displayText, canvas.width / 2, canvas.height / 2, 'stroke');
ctx.fillStyle = 'rgba(120, 255, 255, 1)';
drawSpacedCenteredText(ctx, displayText, canvas.width / 2, canvas.height / 2, 'fill');
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
const textGeometry = new THREE.PlaneGeometry(flagWidth * 1.75, flagHeight * 1.34);
const textMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide,
opacity: 0.95
});
penguinFloatingTextSprite = new THREE.Mesh(textGeometry, textMaterial);
penguinFloatingTextSprite.visible = false;
scene.add(penguinFloatingTextSprite);
}
function moveCameraBackToFitRadius(targetCenter, fitRadius, padding = 1.15) {
const direction = camera.position.clone().sub(targetCenter);
if (direction.lengthSq() === 0) {
direction.set(1, 0.35, 1);
}
direction.normalize();
const fov = THREE.MathUtils.degToRad(camera.fov);
const fitDistance = (fitRadius / Math.tan(fov / 2)) * padding;
camera.position.copy(targetCenter).add(direction.multiplyScalar(fitDistance));
controls.target.copy(targetCenter);
camera.updateProjectionMatrix();
controls.update();
}
function latLngToVector3(lat, lng, radius) {
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lng + 180);
return new THREE.Vector3(
-(radius * Math.sin(phi) * Math.cos(theta)),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
}
function latLngToCanvas(lat, lng, width, height) {
return {
x: ((lng + 180) / 360) * width,
y: ((90 - lat) / 180) * height
};
}
// 与电脑屏幕面保持一致的旋转(用于让贴图“与电脑面平行”)
// 注:createScreen 里屏幕 iframe 也使用同一角度
const SCREEN_ROT_X = -0.37;
// ============================================
// 第 1 步:获取容器
// ============================================
const container = document.getElementById('canvas-container');
// ============================================
// 加载 Overlay(3D 相册)DOM 引用(新增)
// 说明:只做“增添代码”,不影响原有 3D 电脑逻辑。
// - 加载中:显示相册 + 进度
// - 加载完成:显示“点击进入”按钮
// - 点击进入:淡出并移除 overlay
// ============================================
const albumOverlay = document.getElementById('album-loading');
const albumProgressEl = document.getElementById('progress');
const albumLoadingTextEl = document.getElementById('loading-text');
const albumEnterBtn = document.getElementById('enter-btn');
let __albumOverlayDismissed = false;
function __setAlbumProgress(text) {
if (albumProgressEl) albumProgressEl.textContent = text;
}
function __setAlbumLoadingText(text) {
if (albumLoadingTextEl) albumLoadingTextEl.textContent = text;
}
function __markAlbumReadyToEnter() {
if (!albumOverlay) return;
albumOverlay.classList.add('is-ready');
__setAlbumLoadingText('模型已加载完成');
__setAlbumProgress('');
}
function __dismissAlbumOverlay() {
if (__albumOverlayDismissed) return;
__albumOverlayDismissed = true;
// 兼容:保持原 #loading 隐藏
const legacyLoading = document.getElementById('loading');
if (legacyLoading) legacyLoading.style.display = 'none';
if (!albumOverlay) return;
albumOverlay.classList.add('is-fading');
window.setTimeout(() => {
albumOverlay.remove();
}, 480);
}
if (albumEnterBtn) {
albumEnterBtn.addEventListener('click', () => {
__dismissAlbumOverlay();
});
}
// ============================================
// 第 2 步:创建场景(Scene)
// ============================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05010a);
const cssScene = new THREE.Scene();
// ============================================
// 第 3 步:创建相机(Camera)
// ============================================
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(
35,
width / height,
10,
100000
);
camera.position.set(0, 0, 5000);
// ============================================
// 第 4 步:创建两个渲染器
// ============================================
// 4.1 WebGL 渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.top = '0';
renderer.domElement.style.left = '0';
renderer.domElement.style.zIndex = '1';
container.appendChild(renderer.domElement);
// 4.2 CSS3D 渲染器
const cssRenderer = new CSS3DRenderer();
cssRenderer.setSize(width, height);
cssRenderer.domElement.style.position = 'absolute';
cssRenderer.domElement.style.top = '0';
cssRenderer.domElement.style.left = '0';
cssRenderer.domElement.style.zIndex = '2';
container.appendChild(cssRenderer.domElement);
// ============================================
// 第 4.5 步:添加灯光
// ============================================
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(10, 20, 10);
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0xffffff, 0.5);
fillLight.position.set(-10, 10, -10);
scene.add(fillLight);
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.3);
bottomLight.position.set(0, -10, 0);
scene.add(bottomLight);
// ============================================
// 第 5 步:创建轨道控制器
// ============================================
const controls = new OrbitControls(camera, cssRenderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// ============================================
// 第 6 步:加载 3D 模型
// ============================================
const loader = new GLTFLoader();
const modelPath = './assets/models/laptop.glb';
// 存储电脑屏幕的四个角位置(用于连线)
let laptopScreenCorners = null;
let laptopFrontUpperPosition = null; // 修改为前上方位置
loader.load(
modelPath,
function (gltf) {
const model = gltf.scene;
// 修复材质
model.traverse((child) => {
if (child.isMesh) {
const oldMaterial = child.material;
const newMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.5,
metalness: 0.1,
});
if (oldMaterial && oldMaterial.map) {
newMaterial.map = oldMaterial.map;
newMaterial.map.flipY = false;
newMaterial.map.colorSpace = THREE.SRGBColorSpace;
}
child.material = newMaterial;
}
});
autoFitCamera(model);
// 记录“刚进入电脑时”的视角(用于 U 键恢复)
// 必须在 autoFitCamera + controls.target 更新之后保存,避免误记录默认视角。
__saveInitialViewStateOnce();
scene.add(model);
// ========================================
// 新增:计算电脑屏幕四角位置和前上方位置
// ========================================
calculateLaptopGeometry(model);
createEarthCoreEnvironment(model);
// 创建前上方信息板(初始隐藏)
createFrontUpperPanel();
// 创建连接线(初始隐藏)
createConnectionLines();
// 创建"雨从未迟到"文字
createRainText();
// 创建屏幕网页
createScreen();
// ========================================
// 新增:设置R键监听
// ========================================
setupRKeyListener();
// 原逻辑:加载完成后隐藏 #loading。
// 现在:不直接让用户“瞬间进入”,而是提示“点击进入”后再淡出相册 overlay。
const legacyLoading = document.getElementById('loading');
if (legacyLoading) legacyLoading.style.display = 'none';
__markAlbumReadyToEnter();
},
function (progress) {
const percent = (progress.loaded / progress.total * 100).toFixed(1);
const mb = (progress.loaded / 1024 / 1024).toFixed(1);
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
// 注意:不能再用 innerHTML 覆盖容器,否则会把相册 overlay 的 DOM 删掉。
__setAlbumLoadingText('加载模型中...');
__setAlbumProgress(`${percent}% (${mb}MB / ${totalMb}MB)`);
},
function (error) {
console.error('模型加载失败:', error);
alert('我嘞个豆儿,妈妈又把网给恰了:(');
}
);
// ============================================
// 第 6.5 步:计算电脑几何信息(修改:前上方60度)
// ============================================
function calculateLaptopGeometry(model) {
// 创建包围盒获取电脑尺寸
const box = new THREE.Box3().setFromObject(model);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
console.log('电脑尺寸:', size);
console.log('电脑中心:', center);
// 估算屏幕位置
const screenWidth = size.x * 0.9;
const screenHeight = size.y * 0.6;
// 屏幕中心位置
const screenCenter = new THREE.Vector3(
center.x,
center.y + size.y * 0.1,
center.z - size.z * 0.1
);
// 计算屏幕四角位置
const halfWidth = screenWidth / 2;
const halfHeight = screenHeight / 2;
laptopScreenCorners = {
frontTopLeft: new THREE.Vector3(
screenCenter.x - halfWidth,
screenCenter.y + halfHeight,
screenCenter.z
),
frontTopRight: new THREE.Vector3(
screenCenter.x + halfWidth,
screenCenter.y + halfHeight,
screenCenter.z
),
frontBottomLeft: new THREE.Vector3(
screenCenter.x - halfWidth,
screenCenter.y - halfHeight,
screenCenter.z
),
frontBottomRight: new THREE.Vector3(
screenCenter.x + halfWidth,
screenCenter.y - halfHeight,
screenCenter.z
),
center: screenCenter
};
// ========================================
// 修改:电脑“背面的上前方”位置(60度角,距离400)
// 约定:Z 轴正方向为“前方”,因此背面方向为 -Z
// ========================================
const distance = 400;
const angle = Math.PI / 3; // 60度
laptopFrontUpperPosition = new THREE.Vector3(
screenCenter.x,
screenCenter.y + distance * Math.sin(angle), // 上方
screenCenter.z - distance * Math.cos(angle) // 背面(-Z)
);
console.log('屏幕四角:', laptopScreenCorners);
console.log('前上方位置(60度):', laptopFrontUpperPosition);
}
// ============================================
// 新增:创建“地心”氛围与地球经纬网
// 说明:只增强场景环境,不改变原有电脑交互、按键与面板功能。
// ============================================
function createEarthCoreEnvironment(model) {
const box = new THREE.Box3().setFromObject(model);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const baseRadius = Math.max(size.x, size.y, size.z) * 1.7;
earthCoreGroup = new THREE.Group();
earthCoreGroup.position.copy(center);
scene.add(earthCoreGroup);
const shellRadius = baseRadius * 3.1;
earthGridRadius = shellRadius;
orbitTrackWidth = Math.max(baseRadius * 0.22 * 5, baseRadius * 0.5);
orbitRadius = earthGridRadius + orbitTrackWidth * 1.5;
const shellColor = 0x9fefff;
const latCount = 12;
const lonCount = 18;
const shellGeometry = new THREE.SphereGeometry(shellRadius, 48, 48);
const shellMaterial = new THREE.MeshBasicMaterial({
color: shellColor,
transparent: true,
opacity: 0.05,
side: THREE.DoubleSide,
depthWrite: false
});
earthGridShellMesh = new THREE.Mesh(shellGeometry, shellMaterial);
earthCoreGroup.add(earthGridShellMesh);
for (let i = 1; i < latCount; i++) {
const v = i / latCount;
const phi = -Math.PI / 2 + v * Math.PI;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const points = [];
for (let j = 0; j <= 180; j++) {
const theta = (j / 180) * Math.PI * 2;
points.push(new THREE.Vector3(
shellRadius * cosPhi * Math.cos(theta),
shellRadius * sinPhi,
shellRadius * cosPhi * Math.sin(theta)
));
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: shellColor,
transparent: true,
opacity: 0.22
});
const line = new THREE.LineLoop(geometry, material);
earthCoreGroup.add(line);
earthLatLonLines.push(line);
}
for (let i = 0; i < lonCount; i++) {
const theta = (i / lonCount) * Math.PI * 2;
const points = [];
for (let j = 0; j <= 180; j++) {
const phi = -Math.PI / 2 + (j / 180) * Math.PI;
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
points.push(new THREE.Vector3(
shellRadius * cosPhi * Math.cos(theta),
shellRadius * sinPhi,
shellRadius * cosPhi * Math.sin(theta)
));
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: shellColor,
transparent: true,
opacity: 0.18
});
const line = new THREE.Line(geometry, material);
earthCoreGroup.add(line);
earthLatLonLines.push(line);
}
const innerCoreLight = new THREE.PointLight(0xff7a1a, 18, baseRadius * 7, 2);
innerCoreLight.position.set(0, 0, 0);
earthCoreGroup.add(innerCoreLight);
const coolRimLight = new THREE.PointLight(0x3bc9ff, 6, baseRadius * 10, 2);
coolRimLight.position.set(baseRadius * 0.8, baseRadius * 0.3, baseRadius * 1.4);
earthCoreGroup.add(coolRimLight);
const sceneFitRadius = orbitRadius + orbitTrackWidth * 0.5;
moveCameraBackToFitRadius(center, sceneFitRadius, 1.2);
createOrbitRingAndPenguin(center, baseRadius);
console.log('🌍 地心经纬网环境已创建');
}
function createOrbitRingAndPenguin(center, baseRadius) {
orbitCenter.set(center.x, 0, center.z);
const penguinLoader = new GLTFLoader();
penguinLoader.load(
'./assets/models/qq.glb',
function (gltf) {
orbitPenguin = gltf.scene;
const penguinBox = new THREE.Box3().setFromObject(orbitPenguin);
const penguinSize = new THREE.Vector3();
penguinBox.getSize(penguinSize);
const penguinMaxDim = Math.max(penguinSize.x, penguinSize.y, penguinSize.z) || 1;
const targetSize = baseRadius * 0.22;
const scale = targetSize / penguinMaxDim;
orbitPenguin.scale.setScalar(scale);
createPenguinFollowEffects(targetSize);
createOrbitRingVisual();
orbitPenguin.traverse((child) => {
if (child.isMesh && child.material) {
child.castShadow = false;
child.receiveShadow = false;
}
});
orbitPenguinBaseY = orbitCenter.y + targetSize * 0.45;
orbitPenguin.position.set(orbitCenter.x + orbitRadius, orbitPenguinBaseY, orbitCenter.z);
scene.add(orbitPenguin);
},
undefined,
function (error) {
console.error('企鹅模型加载失败:', error);
}
);
}
function createOrbitRingVisual() {
if (orbitTrackMesh) {
scene.remove(orbitTrackMesh);
orbitTrackMesh.geometry.dispose();
orbitTrackMesh.material.dispose();
orbitTrackMesh = null;
}
if (orbitRing) {
scene.remove(orbitRing);
orbitRing.geometry.dispose();
orbitRing.material.dispose();
orbitRing = null;
}
if (orbitRadius <= 0 || orbitTrackWidth <= 0) return;
const innerRadius = Math.max(orbitRadius - orbitTrackWidth * 0.5, 10);
const outerRadius = orbitRadius + orbitTrackWidth * 0.5;
const trackGeometry = new THREE.RingGeometry(innerRadius, outerRadius, 180);
const trackMaterial = new THREE.MeshBasicMaterial({
color: 0x8ffcff,
transparent: true,
opacity: 0.14,
side: THREE.DoubleSide,
depthWrite: false
});
orbitTrackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
orbitTrackMesh.rotation.x = -Math.PI / 2;
orbitTrackMesh.position.set(orbitCenter.x, orbitCenter.y + 2, orbitCenter.z);
scene.add(orbitTrackMesh);
const ringCurve = new THREE.EllipseCurve(0, 0, orbitRadius, orbitRadius, 0, Math.PI * 2, false, 0);
const ringPoints2D = ringCurve.getPoints(240);
const ringPoints3D = ringPoints2D.map((point) => new THREE.Vector3(
orbitCenter.x + point.x,
orbitCenter.y + 5,
orbitCenter.z + point.y
));
const ringGeometry = new THREE.BufferGeometry().setFromPoints(ringPoints3D);
const ringMaterial = new THREE.LineBasicMaterial({
color: 0xb8ffff,
transparent: true,
opacity: 0.85
});
orbitRing = new THREE.LineLoop(ringGeometry, ringMaterial);
scene.add(orbitRing);
}
function updateEarthCoreEnvironment() {
if (!earthCoreGroup) return;
const time = Date.now() * 0.00025;
earthCoreGroup.rotation.y += 0.0012;
earthCoreGroup.rotation.z = Math.sin(time) * 0.05;
if (earthGridRadius > 0) {
const distanceToEarthCore = camera.position.distanceTo(earthCoreGroup.position);
const shouldShowLatLon = distanceToEarthCore > earthGridRadius;
if (earthGridShellMesh) {
earthGridShellMesh.visible = shouldShowLatLon;
}
earthLatLonLines.forEach((line) => {
line.visible = shouldShowLatLon;
});
}
earthPulseMeshes.forEach((mesh, index) => {
const pulse = 1 + Math.sin(time * 6 + index * 0.8) * 0.015;
mesh.scale.setScalar(pulse);
mesh.material.opacity = index === 0
? 0.1 + Math.sin(time * 7) * 0.018
: 0.04 + Math.sin(time * 5 + 1.2) * 0.01;
});
updateOrbitPenguin(time);
}
function updateOrbitPenguin(time) {
if (!orbitPenguin || orbitRadius <= 0) return;
const orbitSpeed = -time * 1.8;
const x = orbitCenter.x + Math.cos(orbitSpeed) * orbitRadius;
const z = orbitCenter.z + Math.sin(orbitSpeed) * orbitRadius;
const dirX = -Math.sin(orbitSpeed);
const dirZ = Math.cos(orbitSpeed);
const forwardDirection = new THREE.Vector3(dirX, 0, dirZ).normalize();
const heading = Math.atan2(dirX, dirZ) + ORBIT_PENGUIN_HEADING_OFFSET;
orbitPenguin.position.set(
x,
orbitPenguinBaseY + Math.sin(time * 10) * 8,
z
);
orbitPenguin.rotation.set(0, heading, 0);
updatePenguinFollowEffects(time, forwardDirection);
}
function createPenguinFollowEffects(targetSize) {
const textureLoader = new THREE.TextureLoader();
const flagTexture = createPenguinFlagTexture(textureLoader);
const flagWidth = targetSize * 2.8;
const flagHeight = targetSize * 1.55;
penguinFlagSegmentsX = 24;
const flagSegmentsY = 12;
const flagGeometry = new THREE.PlaneGeometry(flagWidth, flagHeight, penguinFlagSegmentsX, flagSegmentsY);
flagGeometry.translate(flagWidth * 0.5, 0, 0);
penguinFlagBasePositions = new Float32Array(flagGeometry.attributes.position.array);
penguinFlagMaterial = new THREE.MeshBasicMaterial({
map: flagTexture,
transparent: true,
opacity: 0.95,
side: THREE.DoubleSide,
depthWrite: false
});
penguinFlagMesh = new THREE.Mesh(flagGeometry, penguinFlagMaterial);
penguinFlagMesh.visible = false;
scene.add(penguinFlagMesh);
penguinTrailLength = targetSize * 8.5;
penguinFlagPoleOffset = targetSize * 0.9;
penguinFlagHeightOffset = targetSize * 0.75;
penguinBeamGapOffset = targetSize * 2.7;
penguinFloatingTextHeightOffset = targetSize * 2.7;
createPenguinFloatingTextSprite(flagWidth, flagHeight);
const particleCount = 180;
const particleGeometry = new THREE.BufferGeometry();
penguinParticlePositions = new Float32Array(particleCount * 3);
penguinParticleData = [];
for (let i = 0; i < particleCount; i++) {
penguinParticleData.push(createPenguinBeamParticle(Math.random()));
}
particleGeometry.setAttribute('position', new THREE.BufferAttribute(penguinParticlePositions, 3));
penguinParticleBeamMaterial = new THREE.PointsMaterial({
color: 0x9fefff,
size: targetSize * 0.18,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
});
penguinParticleBeam = new THREE.Points(particleGeometry, penguinParticleBeamMaterial);
penguinParticleBeam.visible = false;
scene.add(penguinParticleBeam);
}
function createPenguinBeamParticle(seed = 0) {
return {
progress: seed,
speed: 0.008 + Math.random() * 0.018,
spread: 84 + Math.random() * 192,
swirl: Math.random() * Math.PI * 2,
rise: (Math.random() - 0.5) * 108,
drift: 0.6 + Math.random() * 1.8
};
}
function togglePenguinFollowEffect() {
penguinFollowEffectEnabled = !penguinFollowEffectEnabled;
if (penguinFlagMesh) penguinFlagMesh.visible = penguinFollowEffectEnabled;
if (penguinParticleBeam) penguinParticleBeam.visible = penguinFollowEffectEnabled;
if (penguinFloatingTextSprite) penguinFloatingTextSprite.visible = penguinFollowEffectEnabled;
console.log(penguinFollowEffectEnabled ? '🏁 已开启企鹅旗帜与粒子束特效' : '🏁 已关闭企鹅旗帜与粒子束特效');
}
function updatePenguinFollowEffects(time, forwardDirection) {
if (!orbitPenguin || !penguinFlagMesh || !penguinParticleBeam || !penguinFlagBasePositions) return;
if (!penguinFollowEffectEnabled) return;
const backDirection = forwardDirection.clone().multiplyScalar(-1);
const sideDirection = new THREE.Vector3().crossVectors(backDirection, new THREE.Vector3(0, 1, 0)).normalize();
const upDirection = new THREE.Vector3().crossVectors(sideDirection, backDirection).normalize();
const frontDirection = forwardDirection.clone();
const frontFlagSideDirection = sideDirection.clone().multiplyScalar(-1);
const flagAnchor = orbitPenguin.position.clone()
.add(frontDirection.clone().multiplyScalar(penguinFlagPoleOffset))
.add(upDirection.clone().multiplyScalar(penguinFlagHeightOffset));
penguinFlagMesh.position.copy(flagAnchor);
penguinFlagMesh.setRotationFromMatrix(
new THREE.Matrix4().makeBasis(frontDirection, upDirection, frontFlagSideDirection)
);
if (penguinFloatingTextSprite) {
penguinFloatingTextSprite.position.copy(orbitPenguin.position)
.add(upDirection.clone().multiplyScalar(penguinFloatingTextHeightOffset))
.add(frontDirection.clone().multiplyScalar(penguinFlagPoleOffset * 0.2));
penguinFloatingTextSprite.setRotationFromMatrix(
new THREE.Matrix4().makeBasis(frontDirection, upDirection, frontFlagSideDirection)
);
penguinFloatingTextSprite.rotateZ(Math.sin(time * 3) * 0.03);
}
const flagPositions = penguinFlagMesh.geometry.attributes.position;
for (let i = 0; i < flagPositions.count; i++) {
const ix = i * 3;
const baseX = penguinFlagBasePositions[ix];
const baseY = penguinFlagBasePositions[ix + 1];
const normalizedX = Math.max(0, baseX / Math.max(1, penguinTrailLength * 0.33));
const waveA = Math.sin(baseX * 0.045 + time * 18) * (4 + normalizedX * 10);
const waveB = Math.cos(baseX * 0.025 + time * 11 + baseY * 0.03) * (2 + normalizedX * 5);
const flutter = Math.sin(-time * 24 + (i % (penguinFlagSegmentsX + 1)) * 0.55) * normalizedX * 2.2;
flagPositions.array[ix] = baseX;
flagPositions.array[ix + 1] = baseY + flutter;
flagPositions.array[ix + 2] = waveA + waveB;
}
flagPositions.needsUpdate = true;
penguinFlagMesh.geometry.computeVertexNormals();
const particleAttr = penguinParticleBeam.geometry.attributes.position;
const beamStart = orbitPenguin.position.clone()
.add(frontDirection.clone().multiplyScalar(penguinFlagPoleOffset + penguinBeamGapOffset + penguinTrailLength * 0.08))
.add(upDirection.clone().multiplyScalar(penguinFlagHeightOffset * 0.2));
for (let i = 0; i < penguinParticleData.length; i++) {
const p = penguinParticleData[i];
p.progress += p.speed;
if (p.progress > 1) {
penguinParticleData[i] = createPenguinBeamParticle(0);
Object.assign(p, penguinParticleData[i]);
}
const distance = p.progress * penguinTrailLength;
const swirl = p.swirl - time * p.drift - p.progress * 8;
const radial = p.progress * p.spread;
const pos = beamStart.clone()
.add(frontDirection.clone().multiplyScalar(distance))
.add(sideDirection.clone().multiplyScalar(Math.cos(swirl) * radial))
.add(upDirection.clone().multiplyScalar(Math.sin(swirl) * radial + p.rise));
particleAttr.array[i * 3] = pos.x;
particleAttr.array[i * 3 + 1] = pos.y;
particleAttr.array[i * 3 + 2] = pos.z;
}
particleAttr.needsUpdate = true;
}
// ============================================
// 第 6.6 步:创建前上方信息板(修改:BasicMaterial避免偏色)
// ============================================
function createFrontUpperPanel() {
if (!laptopFrontUpperPosition) return;
// 加载贴图
const textureLoader = new THREE.TextureLoader();
const panelTexture = textureLoader.load('./assets/textures/laptop.jpg');
// 设置颜色空间避免偏色
panelTexture.colorSpace = THREE.SRGBColorSpace;
const panelWidth = 400;
const panelHeight = 300;
const geometry = new THREE.PlaneGeometry(panelWidth, panelHeight);
// ========================================
// 修改:使用BasicMaterial避免灯光影响颜色
// ========================================
backPanelMaterial = new THREE.MeshBasicMaterial({
map: panelTexture,
side: THREE.DoubleSide,
transparent: true,
opacity: 0 // 初始完全透明
});
backPanel = new THREE.Mesh(geometry, backPanelMaterial);
backPanel.position.copy(laptopFrontUpperPosition);
// 让贴图与电脑面平行:不再 lookAt 电脑,而是使用与屏幕相同的倾角
// 并保持正面朝向镜头侧(PlaneGeometry 默认正面朝 +Z)
backPanel.rotation.set(SCREEN_ROT_X, 0, 0);
// 初始缩放为0.1
backPanel.scale.set(0.1, 0.1, 0.1);
scene.add(backPanel);
// 发光边框(初始隐藏)
const edges = new THREE.EdgesGeometry(geometry);
const lineMaterial = new THREE.LineBasicMaterial({
color: 0x00ffff,
transparent: true,
opacity: 0
});
const wireframe = new THREE.LineSegments(edges, lineMaterial);
wireframe.name = 'wireframe';
backPanel.add(wireframe);
// 点光源(初始关闭)
panelLight = new THREE.PointLight(0x00ffff, 0, 800);
panelLight.position.set(0, 0, 50);
backPanel.add(panelLight);
console.log('前上方信息板已创建(按R键显示)');
}
// ============================================
// 新增:创建"雨从未迟到"文字精灵
// ============================================
function createRainText() {
// 创建画布
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 128;
const ctx = canvas.getContext('2d');
// 清除画布
ctx.fillStyle = 'rgba(0, 0, 0, 0)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 设置文字样式
ctx.font = 'bold 48px "Microsoft YaHei", "SimHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 发光效果
ctx.shadowColor = '#00ffff';
ctx.shadowBlur = 20;
ctx.fillStyle = '#ffffff';
ctx.fillText('雨从未迟到', canvas.width / 2, canvas.height / 2);
// 创建纹理
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
// 创建精灵材质
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0 // 初始隐藏
});
// 创建精灵
rainTextSprite = new THREE.Sprite(spriteMaterial);
rainTextSprite.scale.set(200, 50, 1);
// 位置在信息板上方
if (laptopFrontUpperPosition) {
rainTextSprite.position.copy(laptopFrontUpperPosition);
rainTextSprite.position.y += 180; // 信息板上方
}
scene.add(rainTextSprite);
console.log('"雨从未迟到"文字已创建');
}
// ============================================
// 第 6.7 步:创建连接线(修改:连接到前上方)
// ============================================
function createConnectionLines() {
connectionLines.forEach((line) => {
scene.remove(line);
if (line.geometry) line.geometry.dispose();
if (line.material) line.material.dispose();
});
connectionLines = [];
console.log('已移除电脑四角引出的连接线');
}
// ============================================
// 新增:R键监听
// ============================================
function setupRKeyListener() {
console.log('⌨️ R键已设置为企鹅旗帜特效开关');
window.addEventListener('keydown', (e) => {
if (e.key === 'r' || e.key === 'R') {
togglePenguinFollowEffect();
}
});
}
// 显示信息板
function showPanel() {
console.log('>>> 显示信息板');
state.isAnimating = true;
state.isPanelVisible = true;
state.panelTargetOpacity = 1;
state.panelTargetScale = 1;
// 播放音效
playRainSound();
}
// 隐藏信息板
function hidePanel() {
console.log('>>> 隐藏信息板');
state.isAnimating = true;
state.isPanelVisible = false;
state.panelTargetOpacity = 0;
state.panelTargetScale = 0.1;
}
// ============================================
// 新增:音效(雨声+科技音)
// ============================================
function playRainSound() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
// 创建噪声(雨声)
const bufferSize = ctx.sampleRate * 2; // 2秒
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * 0.1; // 雨噪声
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
// 噪声滤波(更像雨声)
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = 'lowpass';
noiseFilter.frequency.value = 800;
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.05, ctx.currentTime);
noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(ctx.destination);
noise.start();
// 科技音效(和弦)
const frequencies = [440, 554, 659]; // A大调和弦
frequencies.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.03, ctx.currentTime + 0.1 + i * 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 1.5);
});
} catch (e) {
console.log('音效播放失败', e);
}
}
// ============================================
// 新增:动画更新
// ============================================
function updatePanelAnimation() {
if (!backPanel || !backPanelMaterial) return;
const dt = 0.016;
const speed = 2.0;
// 透明度渐变
const opacityDiff = state.panelTargetOpacity - state.panelCurrentOpacity;
if (Math.abs(opacityDiff) > 0.001) {
state.panelCurrentOpacity += opacityDiff * speed * dt;
backPanelMaterial.opacity = Math.max(0, Math.min(1, state.panelCurrentOpacity));
// 文字同步淡入淡出
if (rainTextSprite) {
rainTextSprite.material.opacity = state.panelCurrentOpacity;
}
}
// 缩放动画(弹性效果)
const scaleDiff = state.panelTargetScale - state.panelCurrentScale;
if (Math.abs(scaleDiff) > 0.001) {
state.panelCurrentScale += scaleDiff * speed * dt;
const scale = Math.max(0.1, state.panelCurrentScale);
backPanel.scale.set(scale, scale, scale);
}
// 边框发光
const wireframe = backPanel.getObjectByName('wireframe');
if (wireframe && wireframe.material) {
wireframe.material.opacity = state.panelCurrentOpacity * 0.8;
}
// 连接线延迟显示
connectionLines.forEach((line, index) => {
if (line.material) {
const delay = index * 0.03;
let effectiveOpacity;
if (state.isPanelVisible) {
effectiveOpacity = Math.max(0, state.panelCurrentOpacity - delay);
} else {
effectiveOpacity = state.panelCurrentOpacity;
}
line.material.opacity = effectiveOpacity * 0.6;
}
});
// 点光源
if (panelLight) {
const targetIntensity = state.isPanelVisible ? 1.5 : 0;
panelLight.intensity += (targetIntensity - panelLight.intensity) * speed * dt;
}
// 悬浮动画(显示后)
if (state.isPanelVisible && state.panelCurrentOpacity > 0.95) {
const time = Date.now() * 0.001;
backPanel.position.y = laptopFrontUpperPosition.y + Math.sin(time) * 5;
backPanel.rotation.z = Math.sin(time * 0.5) * 0.02;
// 文字跟随浮动
if (rainTextSprite) {
rainTextSprite.position.y = laptopFrontUpperPosition.y + 180 + Math.sin(time) * 5;
}
}
// 动画结束
if (state.isAnimating) {
const opacityDone = Math.abs(state.panelCurrentOpacity - state.panelTargetOpacity) < 0.01;
const scaleDone = Math.abs(state.panelCurrentScale - state.panelTargetScale) < 0.01;
if (opacityDone && scaleDone) {
state.isAnimating = false;
console.log(state.isPanelVisible ? '>>> 显示完成' : '>>> 隐藏完成');
}
}
}
// ============================================
// 第 7 步:自动对焦函数
// ============================================
function autoFitCamera(object) {
const box = new THREE.Box3().setFromObject(object);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
const cameraDistance = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 2.5;
camera.position.set(
center.x + maxDim * 0.5,
center.y + maxDim * 0.3,
center.z + cameraDistance
);
controls.target.copy(center);
controls.update();
}
// ============================================
// 第 7.2 步:添加光影效果
// ============================================
const ambientLightExtra = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLightExtra);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(10, 10, 10);
scene.add(directionalLight);
// ============================================
// 第 7.3 步:添加辅助线(保留!)
// ============================================
const gridHelper = new THREE.GridHelper(10000, 100);
scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(5000);
scene.add(axesHelper);
// ============================================
// 第 8 步:创建电脑屏幕(原有)
// ============================================
function createScreen() {
// 使用一个容器承载 iframe + video,这样视频会和屏幕一起移动/旋转/缩放
const screenWrap = document.createElement('div');
screenWrap.style.width = '1480px';
screenWrap.style.height = '1100px';
screenWrap.style.position = 'relative';
screenWrap.style.overflow = 'hidden';
screenWrap.style.backgroundColor = 'black';
// 1) 屏幕网页(默认显示)
const iframe = document.createElement('iframe');
iframe.src = 'https://rockosdev.github.io/';
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.style.backgroundColor = 'white';
// 2) 屏幕视频(默认隐藏;懒加载:不设置 src,避免首屏加载慢)
const video = document.createElement('video');
video.dataset.src = './assets/textures/video.mp4';
video.preload = 'none';
video.playsInline = true;
video.controls = true;
video.controlsList = 'nodownload';
video.loop = true; // 播完循环,直到按 V 返回
video.style.position = 'absolute';
video.style.inset = '0';
video.style.width = '100%';
video.style.height = '100%';
video.style.objectFit = 'contain';
video.style.background = '#000';
video.style.display = 'none';
// (可选)加载提示
const loadingHint = document.createElement('div');
loadingHint.textContent = '视频加载中...';
loadingHint.style.position = 'absolute';
loadingHint.style.left = '50%';
loadingHint.style.top = '50%';
loadingHint.style.transform = 'translate(-50%, -50%)';
loadingHint.style.color = 'rgba(255,255,255,0.85)';
loadingHint.style.fontFamily = 'Arial, sans-serif';
loadingHint.style.fontSize = '18px';
loadingHint.style.letterSpacing = '0.5px';
loadingHint.style.pointerEvents = 'none';
loadingHint.style.display = 'none';
video.addEventListener('waiting', () => {
if (video.style.display !== 'none') loadingHint.style.display = 'block';
});
video.addEventListener('playing', () => {
loadingHint.style.display = 'none';
});
video.addEventListener('canplay', () => {
loadingHint.style.display = 'none';
});
screenWrap.appendChild(iframe);
screenWrap.appendChild(video);
screenWrap.appendChild(loadingHint);
const screenObject = new CSS3DObject(screenWrap);
screenObject.position.set(0.00, 52.00, -8.00);
screenObject.rotation.x = SCREEN_ROT_X;
screenObject.scale.set(0.125, 0.112, 0.20);
screenObject.element.style.backfaceVisibility = 'hidden';
cssScene.add(screenObject);
console.log('屏幕已创建');
setupDebugControls(screenObject);
// V 键切换屏幕内视频播放(懒加载,不影响首屏加载)
setupVKeyToggleVideoOnScreen({ iframe, video, loadingHint });
}
// ============================================
// 新增:按 V 键在“电脑屏幕区域”播放视频,再按 V 返回网页
// 目标:不影响首屏加载(首次按 V 才设置 video.src)
// 行为:loop=true 循环播放;退出时 pause;再次进入继续从暂停位置播放
// ============================================
function setupVKeyToggleVideoOnScreen({ iframe, video, loadingHint }) {
let isVideoMode = false;
async function enterVideoMode() {
if (isVideoMode) return;
isVideoMode = true;
iframe.style.display = 'none';
video.style.display = 'block';
// 懒加载:首次进入才赋值 src
if (!video.getAttribute('src')) {
loadingHint && (loadingHint.style.display = 'block');
video.src = video.dataset.src;
}
const p = video.play();
if (p && typeof p.catch === 'function') {
p.catch(() => {
// 某些浏览器可能需要用户交互才能播放;此处不抛错
});
}
}
function exitVideoMode() {
if (!isVideoMode) return;
isVideoMode = false;
video.pause();
// 不重置 currentTime:下次继续播放
video.style.display = 'none';
iframe.style.display = 'block';
loadingHint && (loadingHint.style.display = 'none');
}
function toggle() {
if (isVideoMode) exitVideoMode();
else enterVideoMode();
}
window.addEventListener('keydown', (e) => {
const isV = e.key === 'v' || e.key === 'V';
if (!isV) return;
// 避免在输入框内误触(虽然当前屏幕是 iframe,但依然做保护)
const tag = (e.target && e.target.tagName) ? String(e.target.tagName).toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
e.preventDefault();
toggle();
});
}
// ============================================
// 第 9 步:键盘调试功能(保留原有+添加R键说明)
// ============================================
function setupDebugControls(screenObject) {
console.log('=== 调试模式已开启 ===');
console.log('R : 开关企鹅尾随旗帜 + 粒子束特效');
console.log('方向键 ↑↓←→ : 移动屏幕位置');
console.log('W/S : 前后移动');
console.log('Q/E : 旋转 X 轴');
console.log('A/D : 旋转 Y 轴');
console.log('Z/X : 旋转 Z 轴');
console.log('+/- : 缩放');
console.log('P : 打印当前坐标');
const step = 2;
const rotStep = 0.01;
window.addEventListener('keydown', (e) => {
// R键已在setupRKeyListener中处理,这里跳过
if (e.key === 'r' || e.key === 'R') return;
switch(e.key) {
case 'ArrowUp': screenObject.position.y += step; break;
case 'ArrowDown': screenObject.position.y -= step; break;
case 'ArrowLeft': screenObject.position.x -= step; break;
case 'ArrowRight': screenObject.position.x += step; break;
case 'w': case 'W': screenObject.position.z -= step; break;
case 's': case 'S': screenObject.position.z += step; break;
case 'q': case 'Q': screenObject.rotation.x += rotStep; break;
case 'e': case 'E': screenObject.rotation.x -= rotStep; break;
case 'a': case 'A': screenObject.rotation.y += rotStep; break;
case 'd': case 'D': screenObject.rotation.y -= rotStep; break;
case 'z': case 'Z': screenObject.rotation.z += rotStep; break;
case 'x': case 'X': screenObject.rotation.z -= rotStep; break;
case '+': case '=': screenObject.scale.multiplyScalar(1.1); break;
case '-': screenObject.scale.multiplyScalar(0.9); break;
case 'p': case 'P':
console.log('========== 当前坐标 ==========');
console.log(`position.set(${screenObject.position.x.toFixed(2)}, ${screenObject.position.y.toFixed(2)}, ${screenObject.position.z.toFixed(2)})`);
console.log(`rotation.set(${screenObject.rotation.x.toFixed(2)}, ${screenObject.rotation.y.toFixed(2)}, ${screenObject.rotation.z.toFixed(2)})`);
console.log(`scale.set(${screenObject.scale.x.toFixed(2)}, ${screenObject.scale.y.toFixed(2)}, ${screenObject.scale.z.toFixed(2)})`);
console.log('==============================');
break;
}
});
}
// ============================================
// 第 10 步:渲染循环(添加动画更新)
// ============================================
function animate() {
requestAnimationFrame(animate);
controls.update();
// 新增:更新面板动画
updatePanelAnimation();
updateEarthCoreEnvironment();
renderer.render(scene, camera);
cssRenderer.render(cssScene, camera);
}
animate();
// ============================================
// 第 11 步:响应窗口大小变化
// ============================================
window.addEventListener('resize', () => {
const newWidth = window.innerWidth;
const newHeight = window.innerHeight;
camera.aspect = newWidth / newHeight;
camera.updateProjectionMatrix();
renderer.setSize(newWidth, newHeight);
cssRenderer.setSize(newWidth, newHeight);
});
console.log('3d-computer initialized');
console.log('这网页能处,有事它真加载');
// ============================================
// 追加功能:按 U 键恢复“刚进入电脑时”的视角位置
// 约束:只增加代码,不修改原有逻辑/效果。
// 还原内容:camera.position、controls.target、camera.zoom。
// ============================================
const __initialViewState = {
saved: false,
cameraPosition: new THREE.Vector3(),
controlsTarget: new THREE.Vector3(),
cameraZoom: 1
};
function __saveInitialViewStateOnce() {
if (__initialViewState.saved) return;
__initialViewState.cameraPosition.copy(camera.position);
__initialViewState.controlsTarget.copy(controls.target);
__initialViewState.cameraZoom = camera.zoom;
__initialViewState.saved = true;
console.log('✅ 已记录初始视角(按 U 可恢复)');
}
function __restoreInitialViewState() {
if (!__initialViewState.saved) {
console.warn('⚠️ 初始视角尚未记录,无法恢复');
return;
}
camera.position.copy(__initialViewState.cameraPosition);
controls.target.copy(__initialViewState.controlsTarget);
camera.zoom = __initialViewState.cameraZoom;
camera.updateProjectionMatrix();
controls.update();
}
// 说明:初始视角的保存已经挪到 GLTF onLoad 里(autoFitCamera 之后),
// 不再通过 #loading 的 display 轮询判断,避免误判导致恢复视角错误。
// 监听 U 键
window.addEventListener('keydown', (e) => {
if (e.key === 'u' || e.key === 'U') {
__restoreInitialViewState();
}
});
Berk Gedik的电脑模型laptop.glb
ReedMan的企鹅模型qq.glb
3. 每个文件是干什么的
index.html
这是项目入口文件,浏览器打开后首先加载它。
它主要做了几件事:
- 定义基础 HTML 结构
- 通过
importmap从 CDN 引入 Three.js - 引入
album-loading.css - 创建加载页 DOM 结构
- 引入
js/main.js
你可以把它理解为:
“舞台搭建文件”,负责把页面骨架先放好。
js/main.js
这是整个项目最核心的文件,几乎所有功能都在这里。
主要包括:
- 创建 Three.js 场景、相机、渲染器
- 加载电脑模型和企鹅模型
- 创建灯光
- 创建 CSS3D 网页屏幕
- 管理加载页显示与隐藏
- 创建地球网格氛围环境
- 处理粒子、旗帜、文字等动态特效
- 监听键盘事件
- 实现动画循环
如果你以后要改功能,大概率主要改这个文件。
album-loading.css
这个文件专门负责“加载页的 3D 相册动画样式”。
特点:
- 样式都写在
#album-loading作用域下 - 不容易污染主页面其他元素
- 支持鼠标悬停展开图片
- 带进入按钮、加载进度、淡出动画
style.css
当前项目里这个文件存在,但从现有代码来看,主要样式并没有依赖它。
也就是说:
- 它现在不是主要样式来源
- 以后你可以把公共样式整理进来
ALBUM_LOADING.md
这是一个针对加载相册功能的补充文档,里面说明了:
- 加载页的目标
- 涉及的文件
- 如何替换相册图片
- 如何本地运行
4. 这个项目用了哪些技术
1)HTML
负责页面结构。
2)CSS
负责页面样式、加载页动画、按钮样式等。
3)JavaScript
负责整个项目逻辑。
4)Three.js
这是最核心的 3D 库,用来实现:
- 场景(Scene)
- 相机(Camera)
- 渲染器(Renderer)
- 灯光(Light)
- 模型加载(GLTFLoader)
- 控制器(OrbitControls)
- 3D 对象和动画
5)CSS3DRenderer
这个功能来自 Three.js 扩展,作用是:
把 HTML 元素(比如 iframe)当成 3D 对象放进场景。
所以电脑屏幕里的网页,实际上不是直接贴到纹理上的,而是通过 CSS3D 的方式放进去的。
5. 项目运行原理(小白版)
你可以把页面运行过程理解为下面几步:
第一步:打开 index.html
浏览器先读取页面结构,并开始加载 main.js。
第二步:显示加载页
这时 3D 相册会先出现,避免用户看到空白页面。
第三步:加载 3D 模型
main.js 会使用 GLTFLoader 去加载:
- 电脑模型
- 企鹅相关资源
- 纹理资源
加载过程中,页面会显示百分比和资源大小。
第四步:模型加载完成
模型加载成功后:
- 创建电脑屏幕
- 创建环境特效
- 显示“点击进入”按钮
第五步:进入主场景
点击按钮后,加载层消失,用户开始与 3D 场景交互。
第六步:持续动画渲染
通过 requestAnimationFrame() 不断循环:
- 更新控制器
- 更新面板/特效动画
- 更新企鹅轨道运动
- 重新渲染场景
6. 核心功能详细说明
6.1 Three.js 基础部分
在 main.js 中,最基础的几项内容是:
scene:3D 场景camera:透视相机renderer:WebGL 渲染器cssRenderer:CSS3D 渲染器controls:轨道控制器
简单理解:
scene= 舞台camera= 摄像机renderer= 把舞台画到浏览器上controls= 让你能拖动画面
6.2 加载电脑模型
代码中使用:
const loader = new GLTFLoader();
loader.load('./assets/models/laptop.glb', ...)
说明:
GLTFLoader用来读取.glb/.gltf3D 模型- 当前加载的是一个电脑模型
- 加载完成后会把模型加入场景
另外,代码还对模型材质做了一些“修复”:
- 统一替换成
MeshStandardMaterial - 处理贴图颜色空间
这样做通常是为了让模型显示更稳定。
6.3 自动对焦相机
项目中有一个 autoFitCamera() 函数。
它的作用是:
根据模型尺寸,自动把相机放到一个“能看清整个模型”的位置。
这样就不用你每次手动调相机了。
6.4 加载页逻辑
加载页相关元素有:
#album-loading#loading-text#progress#enter-btn
主要逻辑:
- 加载中:显示“加载模型中...”和进度
- 加载完成:调用
__markAlbumReadyToEnter() - 点击按钮:调用
__dismissAlbumOverlay()
这样用户体验会更自然,不会突然从空白页面跳到 3D 场景。
6.5 地心 / 经纬网环境
项目里有一个 createEarthCoreEnvironment(model) 函数。
它会根据电脑模型的位置和大小,在周围生成一个球形氛围层,包括:
- 球壳
- 经纬线
- 发光点光源
- 环形轨道
目的主要是增强视觉效果,让场景不只是一个单独的电脑模型,而更像一个“科幻展示空间”。
6.6 企鹅模型与轨道动画
项目会加载:
assets/models/qq.glb
这个模型会沿着轨道不断旋转。
相关功能包括:
- 自动缩放到合适大小
- 计算轨道半径
- 设置朝向,让企鹅看起来沿前进方向飞行/移动
- 上下轻微浮动,增强动态感
6.7 旗帜、粒子束、悬浮文字
当你按下 R 键时,会触发 togglePenguinFollowEffect()。
开启后会显示:
- 旗帜贴图
- 粒子束
- 一段悬浮发光文字
这些效果本质上是:
- 用平面几何体做旗帜
- 动态修改顶点位置,模拟飘动
- 用粒子系统模拟尾迹
- 用 Canvas 生成文字贴图,再贴到平面或精灵上
如果你是小白,不需要一开始就完全看懂,只要知道:
这些炫酷效果本质上还是“几何体 + 贴图 + 动画”。
6.8 电脑屏幕中的网页和视频
项目没有直接把网页内容贴到模型材质上,而是用了 CSS3DObject。
逻辑大致是:
- 创建一个 HTML 容器
screenWrap - 容器里放:
iframevideo- loading 提示
- 再把这个容器包装成
CSS3DObject - 放到 3D 场景对应的屏幕位置
这样做的好处是:
- 网页是真实 HTML,可交互
- 视频也是真实视频标签
- 不需要把网页渲染成贴图
按 V 键后:
- 隐藏 iframe
- 显示 video
- 首次进入时才设置
video.src
这叫做:懒加载。
好处是:
首屏更快,不会一开始就去加载视频资源。
6.9 恢复初始视角
代码里记录了“模型刚加载完成时”的相机位置。
按下 U 键后,会执行恢复:
camera.positioncontrols.targetcamera.zoom
这个功能很实用,因为用户拖动场景后可能会迷路。
7. 键盘快捷键说明
这是当前项目里比较重要的快捷键:
| 按键 | 功能 |
|---|---|
R |
开关企鹅旗帜 + 粒子束 + 文字特效 |
V |
屏幕在网页 / 视频之间切换 |
U |
恢复初始视角 |
↑ ↓ ← → |
调整屏幕位置 |
W / S |
前后移动屏幕 |
Q / E |
旋转屏幕 X 轴 |
A / D |
旋转屏幕 Y 轴 |
Z / X |
旋转屏幕 Z 轴 |
+ / - |
缩放屏幕 |
P |
在控制台打印屏幕当前坐标 |
说明:
- 这些键多数是给开发调试用的
- 普通用户不一定需要全部会用
- 如果你后续要调屏幕位置,这些键非常有帮助
8. 如何运行这个项目
方法一:推荐,本地静态服务器运行
不要直接双击 index.html 打开。
原因是:
type="module"的 JS 文件- 模型加载
- 视频资源
- 某些浏览器安全策略
都可能导致本地文件方式运行异常。
运行命令
在项目目录 /home/rock/Workspaces/3d 下运行:
python -m http.server 5173
然后浏览器打开:
http://localhost:5173/
9. 如何修改项目内容
9.1 修改加载页图片
替换下面目录中的图片即可:
assets/album/img/1.jpg
assets/album/img/2.jpg
assets/album/img/3.jpg
assets/album/img/4.jpg
assets/album/img/5.jpg
assets/album/img/6.jpg
最好保持文件名不变,这样就不用改 CSS。
9.2 修改电脑模型
替换:
assets/models/laptop.glb
但是要注意:
- 新模型大小可能不同
- 屏幕位置可能不匹配
calculateLaptopGeometry()的估算逻辑可能要重新调
9.3 修改企鹅模型
替换:
assets/models/qq.glb
如果替换后大小不对,可以去 createOrbitRingAndPenguin() 中调整缩放逻辑。
9.4 修改电脑屏幕显示的网址
在 main.js 里找到:
iframe.src = 'https://rockosdev.github.io/';
把它改成你想展示的网址即可。
注意:
- 某些网站不允许被 iframe 嵌入
- 如果被拒绝,会显示空白或报错
这是浏览器安全策略,不一定是代码问题。
9.5 修改视频文件
替换:
assets/textures/video.mp4
或者修改代码中的:
video.dataset.src = './assets/textures/video.mp4';
9.6 修改旗帜贴图
当前旗帜会尝试读取:
assets/textures/rain.jpg
如果你替换成别的图片,就能改变旗帜外观。
10. 新手最容易看不懂的点
下面是一些小白常见困惑,我提前帮你解释一下。
1)为什么有两个渲染器?
WebGLRenderer:用来渲染真正的 3D 模型CSS3DRenderer:用来渲染 iframe 这种 HTML 内容
因为电脑屏幕里是网页,所以要两个渲染器配合。
2)为什么模型加载后还要点“进入”?
因为作者做了一个更有仪式感的加载体验。
不是技术必须,但视觉体验更好。
3)为什么不用图片直接贴屏幕?
因为屏幕展示的是网页和视频,用 HTML 元素更灵活。
4)为什么有这么多 canvas?
因为有些文字和贴图不是现成图片,而是程序动态画出来的。
5)为什么 style.css 没怎么用?
说明样式大部分直接写在页面里,或者在 album-loading.css 中。
后期可以继续整理。
11. 当前项目的不足和注意点
作为说明文档,也要诚实告诉你项目现状。
1)代码量集中在 main.js
目前 main.js 很大,功能很多,后续维护会比较累。
建议以后拆分为:
- 场景初始化
- 模型加载
- 加载页逻辑
- 轨道与企鹅特效
- 屏幕网页/视频逻辑
- 键盘交互
2)部分资源路径耦合较强
比如图片数量、模型名称、视频位置都写死在代码里。
3)某些函数现在可能是“保留功能”
比如信息板相关变量和部分逻辑仍在代码中,但当前主要交互已经更偏向企鹅特效,不一定都在实际展示中明显使用。
4)外部网页 iframe 可能受限制
如果目标网站禁止嵌入 iframe,就不能正常显示。
12. 如果你想继续优化,可以做什么
如果你后面还想继续做,可以考虑这些方向:
适合新手的优化
- 给 README 再加截图
- 把快捷键显示到页面上
- 把
main.js拆分成多个文件 - 给按钮和 loading 区域做得更精致
- 增加手机端适配
适合进阶的优化
- 给模型增加阴影
- 加入后期特效(Bloom、FXAA 等)
- 优化性能
- 把配置提取到单独文件
- 支持多个场景切换
- 加入 UI 面板(如 lil-gui)
13. 一句话总结这个项目
这是一个使用 Three.js + CSS3DRenderer 制作的 3D 电脑展示网页项目,包含:
- 3D 相册加载页
- 电脑模型展示
- 屏幕网页 / 视频切换
- 企鹅轨道动画
- 旗帜和粒子特效
- 视角恢复与调试快捷键
如果你是小白,建议你按下面顺序阅读代码:
- 先看
index.html - 再看
album-loading.css - 然后重点看
js/main.js - 遇到看不懂的函数,先理解“它是做什么”,不要一开始纠结每一行
14. 给小白的阅读建议
如果你是第一次接触这种项目,我建议这样学:
第一步:先跑起来
先本地运行,看看效果。
第二步:只看入口文件
先看 index.html,弄懂资源是怎么引进来的。
第三步:在 main.js 里只找这些关键词
SceneCameraRendererloader.loadanimateaddEventListener
先理解这些主干。
第四步:再看特效
比如:
- 企鹅怎么动
- 粒子怎么做
- 旗帜为什么会摆动
第五步:自己改一个小功能
比如:
- 改网页地址
- 改视频
- 改加载页图片
- 改背景颜色
你只要能改成功一次,理解就会快很多。
15. 最后说明
这个 README 是基于当前项目文件和代码内容整理出来的“详细中文说明版”,重点是:
- 让小白能看懂
- 知道每个文件干什么
- 知道怎么运行
- 知道从哪里开始改
如果你后续还想继续升级这个项目,建议下一步先做:
把
main.js拆分模块。
这样项目会更清晰,也更适合继续开发。

浙公网安备 33010602011771号