二(合)、3D 项目说明文档(v1)

3D 项目说明文档

这是一个基于 Three.js 的前端 3D 展示项目。打开页面后,你会先看到一个 3D 相册式加载页,等模型加载完成后,点击“进入”,就可以进入一个 3D 笔记本电脑场景

这个场景不只是展示一个电脑模型,还包含:

  • 电脑屏幕中嵌入网页
  • 可切换播放本地视频
  • 环绕场景的“企鹅”模型
  • 旗帜、粒子束、发光文字等特效
  • 相机可拖动旋转查看
  • 键盘快捷键可触发一些交互功能

如果你是小白,可以把这个项目理解成:

一个“用网页技术做出来的 3D 展示页面”。

image
image
image
image


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 / .gltf 3D 模型
  • 当前加载的是一个电脑模型
  • 加载完成后会把模型加入场景

另外,代码还对模型材质做了一些“修复”:

  • 统一替换成 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

逻辑大致是:

  1. 创建一个 HTML 容器 screenWrap
  2. 容器里放:
    • iframe
    • video
    • loading 提示
  3. 再把这个容器包装成 CSS3DObject
  4. 放到 3D 场景对应的屏幕位置

这样做的好处是:

  • 网页是真实 HTML,可交互
  • 视频也是真实视频标签
  • 不需要把网页渲染成贴图

V 键后:

  • 隐藏 iframe
  • 显示 video
  • 首次进入时才设置 video.src

这叫做:懒加载

好处是:

首屏更快,不会一开始就去加载视频资源。


6.9 恢复初始视角

代码里记录了“模型刚加载完成时”的相机位置。

按下 U 键后,会执行恢复:

  • camera.position
  • controls.target
  • camera.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 相册加载页
  • 电脑模型展示
  • 屏幕网页 / 视频切换
  • 企鹅轨道动画
  • 旗帜和粒子特效
  • 视角恢复与调试快捷键

如果你是小白,建议你按下面顺序阅读代码:

  1. 先看 index.html
  2. 再看 album-loading.css
  3. 然后重点看 js/main.js
  4. 遇到看不懂的函数,先理解“它是做什么”,不要一开始纠结每一行

14. 给小白的阅读建议

如果你是第一次接触这种项目,我建议这样学:

第一步:先跑起来

先本地运行,看看效果。

第二步:只看入口文件

先看 index.html,弄懂资源是怎么引进来的。

第三步:在 main.js 里只找这些关键词

  • Scene
  • Camera
  • Renderer
  • loader.load
  • animate
  • addEventListener

先理解这些主干。

第四步:再看特效

比如:

  • 企鹅怎么动
  • 粒子怎么做
  • 旗帜为什么会摆动

第五步:自己改一个小功能

比如:

  • 改网页地址
  • 改视频
  • 改加载页图片
  • 改背景颜色

你只要能改成功一次,理解就会快很多。


15. 最后说明

这个 README 是基于当前项目文件和代码内容整理出来的“详细中文说明版”,重点是:

  • 让小白能看懂
  • 知道每个文件干什么
  • 知道怎么运行
  • 知道从哪里开始改

如果你后续还想继续升级这个项目,建议下一步先做:

main.js 拆分模块

这样项目会更清晰,也更适合继续开发。

posted @ 2026-03-26 01:45  游翔  阅读(4)  评论(0)    收藏  举报