iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

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

Day 14 - 不同速度:讓八大行星擁有自己的運行節奏

  • 分享至 

  • xImage
  •  

Day 13 我們已經替八大行星加入公轉軌道,現在每顆行星都可以沿著自己的軌道繞太陽運行。
不過目前還有一個問題,我們的動畫速度是使用:

planet.angle +=
    planet.orbitSpeed;

這代表每執行一次畫面更新,就增加固定數值。
如果不同電腦每秒可以更新的畫面數量不同,動畫速度也可能跟著受到影響。
所以今天除了整理八大行星不同的運行速度,也會加入 THREE.Clock
讓動畫開始改成使用「時間」控制。

一、今天完成目標

今天會完成:

  • 調整八大行星不同的 orbitSpeed
  • 保留每顆行星不同的 rotationSpeed
  • 使用 THREE.Clock() 建立動畫計時器
  • 使用 getDelta() 取得每一幀經過的時間
  • 讓自轉、公轉速度改成依照時間計算
  • 降低不同電腦 FPS 對動畫速度的影響

二、完整程式碼

index.html

與 Day 13 相同,不需要修改。(完整在 Day 9)

css/style.css

與 Day 13 相同,不需要修改。(完整在 Day 9)

js/main.js

import * as THREE from
    "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js";


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

const scene =
    new THREE.Scene();

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


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

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

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

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
    )
);

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


// ========================
// Clock
// ========================

const clock =
    new THREE.Clock();


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

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

const sunMaterial =
    new THREE.MeshBasicMaterial({
        color: 0xffaa00
    });

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

const sunwireMaterial =
    new THREE.MeshBasicMaterial({
        color: 0xffffff,
        wireframe: true
    });

const sunwire =
    new THREE.Mesh(
        sunGeometry,
        sunwireMaterial
    );

sunwire.scale.set(
    1.03,
    1.03,
    1.03
);

sun.add(
    sunwire
);

scene.add(
    sun
);


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

const planetData = [

    {
        name: "Mercury",
        size: 0.45,
        color: 0xaaaaaa,
        distance: 4,
        rotationSpeed: 0.8,
        orbitSpeed: 1.2
    },

    {
        name: "Venus",
        size: 0.65,
        color: 0xd9a066,
        distance: 6,
        rotationSpeed: -0.3,
        orbitSpeed: 0.95
    },

    {
        name: "Earth",
        size: 0.7,
        color: 0x2277ff,
        distance: 8,
        rotationSpeed: 1.0,
        orbitSpeed: 0.8
    },

    {
        name: "Mars",
        size: 0.55,
        color: 0xcc4422,
        distance: 10,
        rotationSpeed: 0.9,
        orbitSpeed: 0.65
    },

    {
        name: "Jupiter",
        size: 1.3,
        color: 0xd8b38a,
        distance: 13,
        rotationSpeed: 2.0,
        orbitSpeed: 0.4
    },

    {
        name: "Saturn",
        size: 1.1,
        color: 0xe6c87a,
        distance: 16,
        rotationSpeed: 1.8,
        orbitSpeed: 0.3
    },

    {
        name: "Uranus",
        size: 0.9,
        color: 0x66ccdd,
        distance: 19,
        rotationSpeed: 1.2,
        orbitSpeed: 0.22
    },

    {
        name: "Neptune",
        size: 0.85,
        color: 0x3355ff,
        distance: 22,
        rotationSpeed: 1.1,
        orbitSpeed: 0.16
    }

];


// ========================
// Create Orbits
// ========================

planetData.forEach(
    (data) => {

        const points = [];

        const segments = 128;

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

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

            const x =
                Math.cos(angle) *
                data.distance;

            const z =
                Math.sin(angle) *
                data.distance;

            points.push(
                new THREE.Vector3(
                    x,
                    0,
                    z
                )
            );
        }


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


        const orbitMaterial =
            new THREE.LineBasicMaterial({
                color: 0x444444
            });


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


        scene.add(
            orbit
        );
    }
);


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

const planets = [];

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

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


        const material =
            new THREE.MeshBasicMaterial({
                color: data.color
            });


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


        // ========================
        // Wireframe
        // ========================

        const wireMaterial =
            new THREE.MeshBasicMaterial({
                color: 0xffffff,
                wireframe: true
            });


        const wire =
            new THREE.Mesh(
                geometry,
                wireMaterial
            );


        wire.scale.set(
            1.03,
            1.03,
            1.03
        );


        planet.add(
            wire
        );


        scene.add(
            planet
        );


        // 每顆行星不同起始位置
        const startAngle =
            index * 0.8;


        planets.push({
            mesh: planet,

            distance:
                data.distance,

            rotationSpeed:
                data.rotationSpeed,

            orbitSpeed:
                data.orbitSpeed,

            angle:
                startAngle
        });
    }
);


// ========================
// 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();


    // 太陽自轉
    sun.rotation.y +=
        0.5 * delta;


    planets.forEach(
        (planet) => {

            // ========================
            // 自轉
            // ========================

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


            // ========================
            // 公轉
            // ========================

            planet.angle +=
                planet.orbitSpeed *
                delta;


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


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

        }
    );


    renderer.render(
        scene,
        camera
    );
}

animate();

三、加入 THREE.Clock

今天最大的改變就是:

const clock =
    new THREE.Clock();

THREE.Clock 可以幫我們記錄程式運行時經過的時間。

接著在 animate() 裡加入:

const delta =
    clock.getDelta();

delta 代表上一個畫面到現在這個畫面,中間經過了多少秒。

60 FPS 大約每 0.016 秒更新一次畫面
所以 delta 可能會是 0.016
如果電腦只有大約 30 FPS 大約每 0.033 秒更新一次畫面
這時 delta 就可能接近 0.033
這樣我們就可以根據真正經過的時間更新動畫。

四、程式碼簡單說明

  1. 前幾天的動畫方式:
planet.angle +=
    planet.orbitSpeed;

假設 orbitSpeed: 0.02 每更新一次畫面就增加 0.02
如果一台電腦每秒更新 60 次 0.02 × 60
另一台電腦每秒只更新 30 次 0.02 × 30
兩台電腦實際看到的動畫速度就可能不一樣。
所以今天開始不再單純依靠「畫面更新幾次」。

  1. 使用 delta 計算自轉

今天改成:

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

例如地球 rotationSpeed: 1.0 and delta = 0.016
這一次動畫實際增加: 1.0 × 0.016 = 0.016
如果下一幀比較慢 delta = 0.03
則會增加: 1.0 × 0.03 = 0.03
也就是:

時間經過比較多 → 這一幀就移動比較多
時間經過比較少 → 這一幀就移動比較少

最後整體動畫速度就會比較穩定。

  1. 公轉也使用相同方法

原本:

planet.angle +=
    planet.orbitSpeed;

今天改成:

planet.angle +=
    planet.orbitSpeed *
    delta;

所以 orbitSpeed 現在為每秒增加多少公轉角度。
例如:

{
    name: "Earth",
    orbitSpeed: 0.8
}

代表地球每秒大約增加 0.8 radians 的公轉角度。

  1. 為什麼今天的速度數值變大了?

Day 13時地球是 orbitSpeed = 0.025
Day 14變成 orbitSpeed = 0.8
這不是突然讓地球快很多?
答案是不會原因是
前面的 0.025 是每一幀增加 0.025
而今天 0.8 則會再乘上 delta
例如 0.8 × 0.016 = 0.0128
所以兩種數值不能直接拿來比較。
從今天開始,我們統一採用 speed * delta 控制動畫速度。
之後新增其他動畫,也盡量使用這種方式。

五、今天小結

附圖
錄製內容 2026-09-22 171614
今天的畫面看起來可能和 Day 13 沒有非常大的差異。
因為八大行星原本就已經會 自轉 + 公轉 沿著軌道運行,
但今天真正重要的是動畫背後的計算方式改變了。
前幾天 angle += speed;
現在 > angle += speed * delta;
而 delta 來自:

const clock =
    new THREE.Clock();
const delta =
    clock.getDelta();

所以目前我們已經完成:

全螢幕場景、太陽、八大行星、不同大小、顏色、距離、Wireframe、自轉、公轉、八大行星軌道、不同運行速度,以及使用 THREE.Clock 控制動畫時間。

從今天開始,整個太陽系動畫的基礎就更加完整了。

Day 15 預告

「Camera:從不同角度觀察太陽系」

目前我們的 Camera 一直固定的,
也就是固定從一個角度看太陽系。
Day 15 我們會正式認識 THREE.PerspectiveCamera
裡面幾個重要設定:

FOV
Aspect
Near
Far

也會實際改變 camera.position 比較:

正面視角
俯視視角
斜角視角
近距離
遠距離

看看同一個太陽系,從不同 Camera 位置觀看會有什麼差別。


上一篇
Day 13 - 行星軌道:把看不見的公轉路線畫出來
下一篇
Day 15 - Camera:從不同角度觀察太陽系
系列文
從 Web 到宇宙:Three.js × WebGL 互動式太陽系與光影實作 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言