iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
Modern Web

從 Web 到宇宙:Three.js × WebGL 互動式太陽系與光影實作系列 第 30 篇

Day 30 - Final Project:30 天打造互動式太陽系

  • 分享至 

  • xImage
  •  

終於來到 Day 30。
從第一天認識 Modern Web,到今天我們已經完成一個可以實際操作的:
Interactive Solar System

這 30 天不是單純把八顆行星畫在畫面上,而是一步一步把
各種學習到的東西整合成一個完整的網頁作品。
今天不再新增新的大型功能,而是把前面所有內容整理成 Final Version
並提供完整

index.html
style.css
main.js
textures/

讓整個系列正式完成。

一、最終作品功能

目前的互動式太陽系包含太陽和八大行星
並加入:

Planet Self Rotation
Planet Revolution
Orbit Lines

Planet Textures

PointLight
AmbientLight
Shadow

Earth Night Lights

Moon
Moon Orbit

Venus Atmosphere

Saturn Ring

Starfield

OrbitControls

Play / Pause

Animation Speed

Raycaster

Planet Selection

Orbit Highlight

Planet Focus

Planet Information

Camera Presets

Responsive Design

Desktop / Mobile Support

二、最終專案結構

整理後的資料夾:

solar-system/
│
├── index.html
│
├── css/
│   └── style.css
│
├── js/
│   └── main.js
│
└── textures/
    ├── sun.jpg
    ├── mercury.jpg
    ├── venus.jpg
    ├── venus_atmosphere.jpg
    ├── earth.jpg
    ├── earth_night.jpg
    ├── moon.jpg
    ├── mars.jpg
    ├── jupiter.jpg
    ├── saturn.jpg
    ├── saturn_ring.png
    ├── uranus.jpg
    └── neptune.jpg

最重要的是路徑一定要正確。

三、最終版的執行方式

這個專案使用 type="module"

以及 Three.js import * as THREE from "three";
建議使用 VS Code:Live Server 開啟專案。
在 VS Code 對 index.html 按右鍵:
Open with Live Server
接著瀏覽器通常會開啟:
http://127.0.0.1:5500/

四、手機實機觀看

這次我們也真的測試了手機版。
方法是讓手機 + 筆電位於同一個區域網路。
例如手機開啟熱點:
手機 Hotspot
↓
筆電連上手機 Hotspot

接著 Windows CMD:
ipconfig

找到筆電目前的:
IPv4 Address

假設 192.168.43.100
Live Server Port 是 5500
手機就輸入:
http://192.168.43.100:5500/

即可從手機直接觀看作品。
如果遇到 ERR_CONNECTION_TIMED_OUT
除了檢查:
IP
Port
Windows Firewall
之外,也可以更換較穩定的區域網路環境。
這次實際測試時,改用:
手機 Hotspot
↓
筆電連接

之後就能正常開啟。
這也是整個系列很實際的一次
Network Debug 經驗。

五、關於太陽系比例

這裡也需要再次說明:

本作品以太陽系真實特徵為參考,但為兼顧網頁視覺效果與互動操作,行星尺寸、距離與運行速度皆採視覺化比例呈現,並非真實天文尺度。

例如目前:

Mercury  → 4
Venus    → 6
Earth    → 8.5
Mars     → 12
Jupiter  → 18
Saturn   → 24
Uranus   → 30
Neptune  → 36

這些 distance 是 Three.js Scene 裡的視覺化距離。
不是真實天文距離
同樣地:
rotationSpeed
orbitSpeed
也是為了讓網頁動畫容易觀察所設定的速度。

六、完整 index.html

<!DOCTYPE html>
<html lang="zh-TW">

<head>

    <meta charset="UTF-8">

    <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0"
    >

    <title>
        從 Web 到宇宙
    </title>


    <link
        rel="stylesheet"
        href="css/style.css"
    >


    <script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
        }
    }
    </script>

</head>


<body>

    <div id="scene"></div>


    <div class="title">

        <h1>
            從 Web 到宇宙
        </h1>

        <p>
            Interactive Solar System
        </p>

    </div>


    <div class="control-panel">

        <h2>
            Solar System Controls
        </h2>


        <label>

            <input
                type="checkbox"
                id="orbitToggle"
                checked
            >

            顯示軌道

        </label>


        <label>

            <input
                type="checkbox"
                id="venusToggle"
                checked
            >

            金星大氣層

        </label>


        <label>

            <input
                type="checkbox"
                id="starToggle"
                checked
            >

            星空背景

        </label>


        <hr>


        <button id="playPauseButton">
            ⏸ Pause
        </button>


        <div class="speed-control">

            <label for="speedSlider">
                Animation Speed
            </label>


            <input
                type="range"
                id="speedSlider"
                min="0.1"
                max="5"
                step="0.1"
                value="1"
            >


            <span id="speedValue">
                1.0x
            </span>

        </div>

    </div>


    <div
        class="planet-info"
        id="planetInfo"
    >

        <h2 id="planetName">
            Planet
        </h2>

        <p id="planetChineseName"></p>

        <p id="planetOrder"></p>

        <p id="planetDescription"></p>


        <button id="returnButton">
            返回太陽系
        </button>

    </div>


    <script
        type="module"
        src="js/main.js"
    ></script>

</body>

</html>

七、完整 css/style.css

* {
    box-sizing: border-box;
}


html,
body {
    margin: 0;

    width: 100%;
    height: 100%;

    overflow: hidden;
}


body {
    background: #000000;

    color: white;

    font-family:
        Arial,
        sans-serif;
}


/* ========================
   Three.js Scene
======================== */

#scene {
    position: fixed;

    top: 0;
    left: 0;

    width: 100vw;
    height: 100vh;
}


#scene canvas {
    display: block;

    width: 100%;
    height: 100%;
}


/* ========================
   Title
======================== */

.title {
    position: fixed;

    top: 30px;
    left: 40px;

    z-index: 10;

    pointer-events: none;
}


.title h1 {
    margin: 0;

    font-size: 36px;

    letter-spacing: 2px;
}


.title p {
    margin-top: 8px;

    color: #aaaaaa;

    font-size: 16px;
}


/* ========================
   Control Panel
======================== */

.control-panel {
    position: fixed;

    top: 30px;
    right: 30px;

    z-index: 20;

    width: 230px;

    padding: 18px;

    background:
        rgba(
            15,
            15,
            25,
            0.8
        );

    border:
        1px solid
        rgba(
            255,
            255,
            255,
            0.15
        );

    border-radius: 12px;

    backdrop-filter:
        blur(10px);
}


.control-panel h2 {
    margin:
        0
        0
        16px;

    font-size: 16px;
}


.control-panel label {
    display: block;

    margin-top: 12px;

    font-size: 14px;

    cursor: pointer;
}


.control-panel input {
    margin-right: 8px;

    cursor: pointer;
}


.control-panel hr {
    margin: 18px 0;

    border: none;

    border-top:
        1px solid
        rgba(
            255,
            255,
            255,
            0.15
        );
}


.control-panel button {
    width: 100%;

    padding: 10px;

    border: none;

    border-radius: 8px;

    background:
        rgba(
            255,
            255,
            255,
            0.12
        );

    color: white;

    font-size: 14px;

    cursor: pointer;
}


.control-panel button:hover {
    background:
        rgba(
            255,
            255,
            255,
            0.2
        );
}


/* ========================
   Speed Control
======================== */

.speed-control {
    margin-top: 18px;
}


.speed-control label {
    margin-bottom: 8px;
}


.speed-control input {
    width: 100%;

    margin: 8px 0;
}


#speedValue {
    display: block;

    text-align: right;

    color: #aaaaaa;

    font-size: 13px;
}


/* ========================
   Planet Info
======================== */

.planet-info {
    position: fixed;

    left: 40px;
    bottom: 40px;

    z-index: 20;

    width: 280px;

    padding: 20px;

    background:
        rgba(
            15,
            15,
            25,
            0.82
        );

    border:
        1px solid
        rgba(
            255,
            255,
            255,
            0.15
        );

    border-radius: 12px;

    backdrop-filter:
        blur(10px);

    display: none;
}


.planet-info.active {
    display: block;
}


.planet-info h2 {
    margin:
        0
        0
        12px;
}


.planet-info p {
    margin: 8px 0;

    color: #cccccc;

    font-size: 14px;

    line-height: 1.5;
}


.planet-info button {
    width: 100%;

    margin-top: 14px;

    padding: 10px;

    border: none;

    border-radius: 8px;

    background:
        rgba(
            255,
            255,
            255,
            0.12
        );

    color: white;

    cursor: pointer;
}


.planet-info button:hover {
    background:
        rgba(
            255,
            255,
            255,
            0.2
        );
}


/* ========================
   Responsive
======================== */

@media (
    max-width: 768px
) {

    .title {
        top: 18px;
        left: 18px;
    }


    .title h1 {
        font-size: 24px;
    }


    .title p {
        font-size: 13px;
    }


    .control-panel {
        top: 90px;
        right: 12px;

        width: 190px;

        padding: 14px;

        max-height:
            calc(
                100vh -
                110px
            );

        overflow-y: auto;
    }


    .control-panel h2 {
        font-size: 14px;
    }


    .control-panel label,
    .control-panel button {
        font-size: 13px;
    }


    .planet-info {
        left: 12px;
        right: 12px;
        bottom: 12px;

        width: auto;

        padding: 16px;
    }

}

八、完整 js/main.js

import * as THREE from
    "three";

import { OrbitControls } from
    "three/addons/controls/OrbitControls.js";


// ========================
// Scene
// ========================

const scene =
    new THREE.Scene();

scene.background =
    new THREE.Color(
        0x000000
    );


// ========================
// Camera
// ========================

const camera =
    new THREE.PerspectiveCamera(
        60,
        window.innerWidth /
        window.innerHeight,
        0.1,
        1000
    );


if (
    window.innerWidth <=
    768
) {

    camera.position.set(
        45,
        40,
        55
    );

} else {

    camera.position.set(
        30,
        25,
        30
    );

}


camera.lookAt(
    0,
    0,
    0
);


// ========================
// Renderer
// ========================

const renderer =
    new THREE.WebGLRenderer({
        antialias: true
    });


renderer.setSize(
    window.innerWidth,
    window.innerHeight
);


renderer.setPixelRatio(
    Math.min(
        window.devicePixelRatio,
        2
    )
);


renderer.shadowMap.enabled =
    true;


renderer.shadowMap.type =
    THREE.PCFSoftShadowMap;


document
    .getElementById(
        "scene"
    )
    .appendChild(
        renderer.domElement
    );


// ========================
// OrbitControls
// ========================

const controls =
    new OrbitControls(
        camera,
        renderer.domElement
    );


controls.enableDamping =
    true;


controls.dampingFactor =
    0.05;


controls.minDistance =
    2;


controls.maxDistance =
    100;


controls.target.set(
    0,
    0,
    0
);


controls.update();


// ========================
// Clock / State
// ========================

const clock =
    new THREE.Clock();


let isPlaying =
    true;


let timeScale =
    1;


let selectedPlanet =
    null;


let focusMode =
    false;


// ========================
// Focus Tracking
// ========================

const previousFocusPosition =
    new THREE.Vector3();


const focusMovement =
    new THREE.Vector3();


// ========================
// Raycaster
// ========================

const raycaster =
    new THREE.Raycaster();


const mouse =
    new THREE.Vector2();


const pointerStart =
    new THREE.Vector2();


const defaultOrbitColor =
    0x444444;


const selectedOrbitColor =
    0xffcc66;


// ========================
// Texture Loader
// ========================

const textureLoader =
    new THREE.TextureLoader();


// ========================
// Sun
// ========================

const sunTexture =
    textureLoader.load(
        "textures/sun.jpg"
    );


sunTexture.colorSpace =
    THREE.SRGBColorSpace;


const sunGeometry =
    new THREE.SphereGeometry(
        2,
        64,
        64
    );


const sunMaterial =
    new THREE.MeshBasicMaterial({
        map:
            sunTexture
    });


const sun =
    new THREE.Mesh(
        sunGeometry,
        sunMaterial
    );


scene.add(
    sun
);


// ========================
// Lights
// ========================

const sunLight =
    new THREE.PointLight(
        0xffffff,
        500,
        100
    );


sunLight.position.set(
    0,
    0,
    0
);


sunLight.castShadow =
    true;


sunLight.shadow.mapSize.width =
    512;


sunLight.shadow.mapSize.height =
    512;


sunLight.shadow.camera.near =
    0.1;


sunLight.shadow.camera.far =
    100;


scene.add(
    sunLight
);


const ambientLight =
    new THREE.AmbientLight(
        0xffffff,
        0.08
    );


scene.add(
    ambientLight
);


// ========================
// Planet Data
// ========================

const planetData = [

    {
        name: "Mercury",
        chineseName: "水星",
        description:
            "距離太陽最近的行星。",
        order:
            "第一顆行星",
        size: 0.45,
        distance: 4,
        rotationSpeed: 0.8,
        orbitSpeed: 1.2,
        texture:
            "textures/mercury.jpg"
    },

    {
        name: "Venus",
        chineseName: "金星",
        description:
            "擁有非常濃厚的大氣層。",
        order:
            "第二顆行星",
        size: 0.65,
        distance: 6,
        rotationSpeed: -0.3,
        orbitSpeed: 0.95,
        texture:
            "textures/venus.jpg"
    },

    {
        name: "Earth",
        chineseName: "地球",
        description:
            "目前已知唯一已知存在生命的行星。",
        order:
            "第三顆行星",
        size: 0.7,
        distance: 8.5,
        rotationSpeed: 1.0,
        orbitSpeed: 0.8,
        texture:
            "textures/earth.jpg"
    },

    {
        name: "Mars",
        chineseName: "火星",
        description:
            "表面富含氧化鐵,因此呈現紅色。",
        order:
            "第四顆行星",
        size: 0.55,
        distance: 12,
        rotationSpeed: 0.9,
        orbitSpeed: 0.65,
        texture:
            "textures/mars.jpg"
    },

    {
        name: "Jupiter",
        chineseName: "木星",
        description:
            "太陽系中體積最大的行星。",
        order:
            "第五顆行星",
        size: 1.3,
        distance: 18,
        rotationSpeed: 2.0,
        orbitSpeed: 0.4,
        texture:
            "textures/jupiter.jpg"
    },

    {
        name: "Saturn",
        chineseName: "土星",
        description:
            "以明顯的行星環系統聞名。",
        order:
            "第六顆行星",
        size: 1.1,
        distance: 24,
        rotationSpeed: 1.8,
        orbitSpeed: 0.3,
        texture:
            "textures/saturn.jpg"
    },

    {
        name: "Uranus",
        chineseName: "天王星",
        description:
            "自轉軸傾斜程度非常明顯。",
        order:
            "第七顆行星",
        size: 0.9,
        distance: 30,
        rotationSpeed: 1.2,
        orbitSpeed: 0.22,
        texture:
            "textures/uranus.jpg"
    },

    {
        name: "Neptune",
        chineseName: "海王星",
        description:
            "八大行星中距離太陽最遠。",
        order:
            "第八顆行星",
        size: 0.85,
        distance: 36,
        rotationSpeed: 1.1,
        orbitSpeed: 0.16,
        texture:
            "textures/neptune.jpg"
    }

];


// ========================
// Special Textures
// ========================

const earthNightTexture =
    textureLoader.load(
        "textures/earth_night.jpg"
    );


earthNightTexture.colorSpace =
    THREE.SRGBColorSpace;


const venusAtmosphereTexture =
    textureLoader.load(
        "textures/venus_atmosphere.jpg"
    );


venusAtmosphereTexture.colorSpace =
    THREE.SRGBColorSpace;


const saturnRingTexture =
    textureLoader.load(
        "textures/saturn_ring.png"
    );


saturnRingTexture.colorSpace =
    THREE.SRGBColorSpace;


// ========================
// Starfield
// ========================

const starGeometry =
    new THREE.BufferGeometry();


const starPositions =
    [];


const starCount =
    2000;


for (
    let i = 0;
    i < starCount;
    i++
) {

    const radius =
        100 +
        Math.random() *
        40;


    const theta =
        Math.random() *
        Math.PI *
        2;


    const phi =
        Math.acos(
            2 *
            Math.random() -
            1
        );


    const x =
        radius *
        Math.sin(phi) *
        Math.cos(theta);


    const y =
        radius *
        Math.cos(phi);


    const z =
        radius *
        Math.sin(phi) *
        Math.sin(theta);


    starPositions.push(
        x,
        y,
        z
    );

}


starGeometry.setAttribute(

    "position",

    new THREE.Float32BufferAttribute(
        starPositions,
        3
    )

);


const starMaterial =
    new THREE.PointsMaterial({

        color:
            0xffffff,

        size:
            0.25,

        sizeAttenuation:
            true

    });


const stars =
    new THREE.Points(
        starGeometry,
        starMaterial
    );


scene.add(
    stars
);


// ========================
// Planet Orbits
// ========================

const planetOrbits =
    [];


planetData.forEach(
    (data) => {

        const points =
            [];


        const segments =
            128;


        for (
            let i = 0;
            i < segments;
            i++
        ) {

            const angle =
                (
                    i /
                    segments
                ) *
                Math.PI *
                2;


            points.push(

                new THREE.Vector3(

                    Math.cos(
                        angle
                    ) *
                    data.distance,

                    0,

                    Math.sin(
                        angle
                    ) *
                    data.distance

                )

            );

        }


        const orbitGeometry =
            new THREE.BufferGeometry()
                .setFromPoints(
                    points
                );


        const orbitMaterial =
            new THREE.LineBasicMaterial({

                color:
                    defaultOrbitColor

            });


        const orbit =
            new THREE.LineLoop(
                orbitGeometry,
                orbitMaterial
            );


        scene.add(
            orbit
        );


        planetOrbits.push({

            name:
                data.name,

            line:
                orbit

        });

    }
);


// ========================
// Create Planets
// ========================

const planets =
    [];


let earth =
    null;


let venusAtmosphere =
    null;


planetData.forEach(
    (data, index) => {

        const planetTexture =
            textureLoader.load(
                data.texture
            );


        planetTexture.colorSpace =
            THREE.SRGBColorSpace;


        const geometry =
            new THREE.SphereGeometry(
                data.size,
                48,
                48
            );


        let material;


        // ========================
        // Earth Night Lights
        // ========================

        if (
            data.name ===
            "Earth"
        ) {

            material =
                new THREE.MeshStandardMaterial({

                    map:
                        planetTexture,

                    emissiveMap:
                        earthNightTexture,

                    emissive:
                        0xffffff,

                    emissiveIntensity:
                        0.6,

                    roughness:
                        1,

                    metalness:
                        0

                });

        } else {

            material =
                new THREE.MeshStandardMaterial({

                    map:
                        planetTexture,

                    roughness:
                        1,

                    metalness:
                        0

                });

        }


        const planet =
            new THREE.Mesh(
                geometry,
                material
            );


        // ========================
        // Shadow
        // ========================

        planet.castShadow =
            data.name ===
            "Earth" ||
            data.name ===
            "Saturn";


        planet.receiveShadow =
            data.name ===
            "Earth";


        // ========================
        // Venus Atmosphere
        // ========================

        if (
            data.name ===
            "Venus"
        ) {

            const atmosphereGeometry =
                new THREE.SphereGeometry(

                    data.size *
                    1.03,

                    48,
                    48

                );


            const atmosphereMaterial =
                new THREE.MeshBasicMaterial({

                    map:
                        venusAtmosphereTexture,

                    transparent:
                        true,

                    opacity:
                        0.55,

                    side:
                        THREE.DoubleSide,

                    depthWrite:
                        false

                });


            venusAtmosphere =
                new THREE.Mesh(
                    atmosphereGeometry,
                    atmosphereMaterial
                );


            planet.add(
                venusAtmosphere
            );

        }


        // ========================
        // Saturn Ring
        // ========================

        if (
            data.name ===
            "Saturn"
        ) {

            const innerRadius =
                data.size *
                1.3;


            const outerRadius =
                data.size *
                2.2;


            const ringGeometry =
                new THREE.RingGeometry(
                    innerRadius,
                    outerRadius,
                    128
                );


            // ========================
            // Saturn Ring UV
            // ========================

            const position =
                ringGeometry
                    .attributes
                    .position;


            const uv =
                ringGeometry
                    .attributes
                    .uv;


            for (
                let i = 0;
                i < position.count;
                i++
            ) {

                const x =
                    position.getX(
                        i
                    );


                const y =
                    position.getY(
                        i
                    );


                const distance =
                    Math.sqrt(
                        x * x +
                        y * y
                    );


                const normalizedRadius =
                    (
                        distance -
                        innerRadius
                    ) /
                    (
                        outerRadius -
                        innerRadius
                    );


                uv.setXY(
                    i,
                    normalizedRadius,
                    0.5
                );

            }


            uv.needsUpdate =
                true;


            const ringMaterial =
                new THREE.MeshStandardMaterial({

                    map:
                        saturnRingTexture,

                    transparent:
                        true,

                    side:
                        THREE.DoubleSide,

                    depthWrite:
                        false,

                    roughness:
                        1,

                    metalness:
                        0,

                    emissive:
                        0xffffff,

                    emissiveMap:
                        saturnRingTexture,

                    emissiveIntensity:
                        0.35

                });


            const saturnRing =
                new THREE.Mesh(
                    ringGeometry,
                    ringMaterial
                );


            saturnRing.receiveShadow =
                false;


            saturnRing.rotation.x =
                Math.PI /
                2;


            saturnRing.rotation.z =
                THREE.MathUtils.degToRad(
                    27
                );


            planet.add(
                saturnRing
            );

        }


        // ========================
        // Initial Position
        // ========================

        const startAngle =
            index *
            0.8;


        planet.position.x =
            Math.cos(
                startAngle
            ) *
            data.distance;


        planet.position.z =
            Math.sin(
                startAngle
            ) *
            data.distance;


        scene.add(
            planet
        );


        if (
            data.name ===
            "Earth"
        ) {

            earth =
                planet;

        }


        planets.push({

            name:
                data.name,

            chineseName:
                data.chineseName,

            description:
                data.description,

            order:
                data.order,

            mesh:
                planet,

            size:
                data.size,

            distance:
                data.distance,

            rotationSpeed:
                data.rotationSpeed,

            orbitSpeed:
                data.orbitSpeed,

            angle:
                startAngle

        });

    }
);


// ========================
// Moon
// ========================

const moonTexture =
    textureLoader.load(
        "textures/moon.jpg"
    );


moonTexture.colorSpace =
    THREE.SRGBColorSpace;


const moonGeometry =
    new THREE.SphereGeometry(
        0.2,
        32,
        32
    );


const moonMaterial =
    new THREE.MeshStandardMaterial({

        map:
            moonTexture,

        roughness:
            1,

        metalness:
            0

    });


const moon =
    new THREE.Mesh(
        moonGeometry,
        moonMaterial
    );


moon.castShadow =
    true;


moon.receiveShadow =
    true;


scene.add(
    moon
);


const moonDistance =
    1.3;


const moonOrbitSpeed =
    2;


let moonAngle =
    0;


// ========================
// Moon Orbit
// ========================

const moonOrbitPoints =
    [];


const moonOrbitSegments =
    64;


for (
    let i = 0;
    i < moonOrbitSegments;
    i++
) {

    const angle =
        (
            i /
            moonOrbitSegments
        ) *
        Math.PI *
        2;


    moonOrbitPoints.push(

        new THREE.Vector3(

            Math.cos(
                angle
            ) *
            moonDistance,

            0,

            Math.sin(
                angle
            ) *
            moonDistance

        )

    );

}


const moonOrbitGeometry =
    new THREE.BufferGeometry()
        .setFromPoints(
            moonOrbitPoints
        );


const moonOrbitMaterial =
    new THREE.LineBasicMaterial({

        color:
            0x666666

    });


const moonOrbit =
    new THREE.LineLoop(
        moonOrbitGeometry,
        moonOrbitMaterial
    );


scene.add(
    moonOrbit
);


// ========================
// DOM
// ========================

const planetInfo =
    document.getElementById(
        "planetInfo"
    );


const planetName =
    document.getElementById(
        "planetName"
    );


const planetChineseName =
    document.getElementById(
        "planetChineseName"
    );


const planetOrder =
    document.getElementById(
        "planetOrder"
    );


const planetDescription =
    document.getElementById(
        "planetDescription"
    );


const returnButton =
    document.getElementById(
        "returnButton"
    );


const orbitToggle =
    document.getElementById(
        "orbitToggle"
    );


const venusToggle =
    document.getElementById(
        "venusToggle"
    );


const starToggle =
    document.getElementById(
        "starToggle"
    );


const playPauseButton =
    document.getElementById(
        "playPauseButton"
    );


const speedSlider =
    document.getElementById(
        "speedSlider"
    );


const speedValue =
    document.getElementById(
        "speedValue"
    );


// ========================
// Orbit Functions
// ========================

function resetOrbitColors() {

    planetOrbits.forEach(
        (orbit) => {

            orbit.line
                .material
                .color
                .setHex(
                    defaultOrbitColor
                );

        }
    );

}


function highlightOrbit(
    planetNameValue
) {

    const orbit =
        planetOrbits.find(
            (orbit) =>
                orbit.name ===
                planetNameValue
        );


    if (
        orbit
    ) {

        orbit.line
            .material
            .color
            .setHex(
                selectedOrbitColor
            );

    }

}


// ========================
// Planet Information
// ========================

function showPlanetInfo(
    planet
) {

    planetName.textContent =
        planet.name;


    planetChineseName.textContent =
        "中文名稱:" +
        planet.chineseName;


    planetOrder.textContent =
        "位置:" +
        planet.order;


    planetDescription.textContent =
        planet.description;


    planetInfo.classList.add(
        "active"
    );

}


// ========================
// Focus Functions
// ========================

function focusPlanet(
    planet
) {

    selectedPlanet =
        planet;


    focusMode =
        true;


    const focusDistance =
        planet.size *
        6 +
        3;


    camera.position.set(

        planet.mesh.position.x +
            focusDistance,

        planet.mesh.position.y +
            focusDistance *
            0.5,

        planet.mesh.position.z +
            focusDistance

    );


    controls.target.copy(
        planet.mesh.position
    );


    previousFocusPosition.copy(
        planet.mesh.position
    );


    controls.update();


    showPlanetInfo(
        planet
    );

}


function returnToSolarSystem() {

    focusMode =
        false;


    selectedPlanet =
        null;


    if (
        window.innerWidth <=
        768
    ) {

        camera.position.set(
            45,
            40,
            55
        );

    } else {

        camera.position.set(
            30,
            25,
            30
        );

    }


    controls.target.set(
        0,
        0,
        0
    );


    controls.update();


    planetInfo.classList.remove(
        "active"
    );


    resetOrbitColors();

}


// ========================
// Planet Selection
// ========================

function selectPlanet(
    event
) {

    const rect =
        renderer.domElement
            .getBoundingClientRect();


    mouse.x =
        (
            (
                event.clientX -
                rect.left
            ) /
            rect.width
        ) *
        2 -
        1;


    mouse.y =
        -(
            (
                event.clientY -
                rect.top
            ) /
            rect.height
        ) *
        2 +
        1;


    raycaster.setFromCamera(
        mouse,
        camera
    );


    const planetMeshes =
        planets.map(
            (planet) =>
                planet.mesh
        );


    const intersects =
        raycaster.intersectObjects(
            planetMeshes,
            false
        );


    if (
        intersects.length ===
        0
    ) {

        return;

    }


    const clickedMesh =
        intersects[0].object;


    const clickedPlanet =
        planets.find(
            (planet) =>
                planet.mesh ===
                clickedMesh
        );


    if (
        !clickedPlanet
    ) {

        return;

    }


    resetOrbitColors();


    highlightOrbit(
        clickedPlanet.name
    );


    focusPlanet(
        clickedPlanet
    );

}


// ========================
// Pointer Events
// ========================

renderer.domElement.addEventListener(
    "pointerdown",
    (event) => {

        pointerStart.set(
            event.clientX,
            event.clientY
        );

    }
);


renderer.domElement.addEventListener(
    "pointerup",
    (event) => {

        const pointerEnd =
            new THREE.Vector2(
                event.clientX,
                event.clientY
            );


        const pointerDistance =
            pointerStart.distanceTo(
                pointerEnd
            );


        if (
            pointerDistance >
            5
        ) {

            return;

        }


        selectPlanet(
            event
        );

    }
);


// ========================
// UI Events
// ========================

orbitToggle.addEventListener(
    "change",
    () => {

        planetOrbits.forEach(
            (orbit) => {

                orbit.line.visible =
                    orbitToggle.checked;

            }
        );


        moonOrbit.visible =
            orbitToggle.checked;

    }
);


venusToggle.addEventListener(
    "change",
    () => {

        if (
            venusAtmosphere
        ) {

            venusAtmosphere.visible =
                venusToggle.checked;

        }

    }
);


starToggle.addEventListener(
    "change",
    () => {

        stars.visible =
            starToggle.checked;

    }
);


playPauseButton.addEventListener(
    "click",
    () => {

        isPlaying =
            !isPlaying;


        playPauseButton.textContent =
            isPlaying
                ? "⏸ Pause"
                : "▶ Play";

    }
);


speedSlider.addEventListener(
    "input",
    () => {

        timeScale =
            Number(
                speedSlider.value
            );


        speedValue.textContent =
            timeScale.toFixed(
                1
            ) +
            "x";

    }
);


returnButton.addEventListener(
    "click",
    returnToSolarSystem
);


// ========================
// Keyboard Camera
// ========================

window.addEventListener(
    "keydown",
    (event) => {

        if (
            ![
                "1",
                "2",
                "3",
                "4"
            ].includes(
                event.key
            )
        ) {

            return;

        }


        focusMode =
            false;


        selectedPlanet =
            null;


        planetInfo.classList.remove(
            "active"
        );


        resetOrbitColors();


        if (
            event.key ===
            "1"
        ) {

            camera.position.set(
                0,
                0,
                50
            );

        }


        if (
            event.key ===
            "2"
        ) {

            camera.position.set(
                30,
                50,
                40
            );

        }


        if (
            event.key ===
            "3"
        ) {

            camera.position.set(
                0,
                65,
                0.1
            );

        }


        if (
            event.key ===
            "4"
        ) {

            camera.position.set(
                0,
                12,
                20
            );

        }


        controls.target.set(
            0,
            0,
            0
        );


        controls.update();

    }
);


// ========================
// Resize
// ========================

window.addEventListener(
    "resize",
    () => {

        camera.aspect =
            window.innerWidth /
            window.innerHeight;


        camera.updateProjectionMatrix();


        renderer.setSize(
            window.innerWidth,
            window.innerHeight
        );


        renderer.setPixelRatio(
            Math.min(
                window.devicePixelRatio,
                2
            )
        );

    }
);


// ========================
// Animation
// ========================

function animate() {

    requestAnimationFrame(
        animate
    );


    const delta =
        clock.getDelta();


    const scaledDelta =
        delta *
        timeScale;


    if (
        isPlaying
    ) {

        // Sun

        sun.rotation.y +=
            0.5 *
            scaledDelta;


        // Planets

        planets.forEach(
            (planet) => {

                planet.mesh.rotation.y +=
                    planet.rotationSpeed *
                    scaledDelta;


                planet.angle +=
                    planet.orbitSpeed *
                    scaledDelta;


                planet.mesh.position.x =
                    Math.cos(
                        planet.angle
                    ) *
                    planet.distance;


                planet.mesh.position.z =
                    Math.sin(
                        planet.angle
                    ) *
                    planet.distance;

            }
        );


        // Venus Atmosphere

        if (
            venusAtmosphere
        ) {

            venusAtmosphere.rotation.y +=
                0.15 *
                scaledDelta;

        }


        // Moon

        moonAngle +=
            moonOrbitSpeed *
            scaledDelta;


        moon.position.x =
            earth.position.x +
            Math.cos(
                moonAngle
            ) *
            moonDistance;


        moon.position.y =
            earth.position.y;


        moon.position.z =
            earth.position.z +
            Math.sin(
                moonAngle
            ) *
            moonDistance;


        moon.rotation.y +=
            0.4 *
            scaledDelta;


        moonOrbit.position.copy(
            earth.position
        );

    }


    // ========================
    // Focus Tracking
    // ========================

    if (
        focusMode &&
        selectedPlanet
    ) {

        focusMovement
            .copy(
                selectedPlanet
                    .mesh
                    .position
            )
            .sub(
                previousFocusPosition
            );


        camera.position.add(
            focusMovement
        );


        controls.target.copy(
            selectedPlanet
                .mesh
                .position
        );


        previousFocusPosition.copy(
            selectedPlanet
                .mesh
                .position
        );

    }


    controls.update();


    renderer.render(
        scene,
        camera
    );

}


animate();

九、最終版 Texture 說明

我們這次主要使用:
Solar System Scope
Solar Textures
提供的太陽系貼圖。
Solar System Scope:https://www.solarsystemscope.com/textures/

十、這個作品還可以繼續做什麼?

Day 30 並不代表:
這個太陽系不能再修改

反而可以把它當成下一階段的起點。
未來可以繼續加入

Planet Label
真實 Planet Data
更多 Moon
...

甚至可以把 Planet Data 從 JavaScript 寫死的資料改成:
JSON
API
Database

讓它真正變成資料驅動的 3D Web Application

十一、30 天總結

從最初Hello Modern Web
一路做到:

可以旋轉
可以公轉
可以縮放
可以點擊
可以追蹤
可以控制時間
可以顯示資訊
可以在手機上操作

這也是這次 30 天最大的成果。
我們不是單純學 Three.js API
而是實際走過:

想法
↓
HTML / CSS / JavaScript
↓
3D Scene
↓
功能
↓
互動
↓
Debug
↓
Responsive
↓
實機測試
↓
完整作品

這就是 Modern Web 開發真正有趣的地方。

完結

《從 Web 到宇宙:30 天打造互動式太陽系|Modern Web × Three.js 三維視覺化與光照實作》
30 天前,我們從一個普通的 Web Page 開始。
30 天後,我們已經可以在瀏覽器裡打造一個自己的宇宙!!


上一篇
Day 29 - Integration × Debug:最終整合與問題修正
系列文
從 Web 到宇宙:Three.js × WebGL 互動式太陽系與光影實作 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言