iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

Day 14 我們已經使用 THREE.Clock,讓行星的自轉與公轉速度改成依照時間計算。
今天我們要處理另一個很重要的東西 Camera。
目前太陽系雖然已經會動,但我們一直都只是從固定位置觀看。
今天會開始認識 PerspectiveCamera 的幾個重要參數,也會實際改變 Camera 的位置,看看同一個太陽系從不同角度看起來有什麼差別。

一、今天完成目標

今天會完成:

  • 認識 THREE.PerspectiveCamera
  • 了解 FOV、Aspect、Near、Far
  • 使用 camera.position.set() 改變觀看位置
  • 使用 camera.lookAt() 指定相機觀看方向
  • 使用 鍵盤 1(正面) / 2(斜角) / 3(俯視) / 4(近距離) 切換不同視角

今天完成之後,太陽系本身功能不會改變,但我們會更了解:
「相機放在哪裡」會直接影響整個 3D 世界看起來的樣子。

二、完整程式碼

index.html

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

css/style.css

與 Day 14 相同,不需要修改。(完整在 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(
        60,
        window.innerWidth /
        window.innerHeight,
        0.1,
        1000
    );

// 斜上方觀看太陽系
camera.position.set(
    0,
    28,
    38
);

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

三、認識 PerspectiveCamera

我們目前使用的是:

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

這四個數值分別代表:

PerspectiveCamera(
FOV,
Aspect,
Near,
Far
)

也就是:

FOV → 60
Aspect → window.innerWidth / window.innerHeight
Near → 0.1
Far → 1000

這四個參數會決定 Camera 可以看到多少東西,以及可以看到多近和多遠。

四、程式碼簡單說明

  1. 第一個參數FOV

FOV 就是 Field of View 也就是相機的「視野範圍」。
可以簡單理解成相機鏡頭有多廣。
例如:

new THREE.PerspectiveCamera(
    30,
    ...
);

視野比較窄而:

new THREE.PerspectiveCamera(
    90,
    ...
);

視野比較廣。
FOV 越大,可以看到的範圍越多。
但畫面的透視感也會更強。
今天我們先使用 60 讓太陽系看起來比較自然。

  1. 第二個參數 Aspect

Aspect 就是 畫面寬度 / 畫面高度。
這個比例必須和瀏覽器一致。
不然畫面可能會被拉長、壓扁、變形,
所以視窗大小改變時,我們還要:

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

camera.updateProjectionMatrix();

重新更新 Camera。

  1. 第三個參數 Near 和 第四個參數 Far

Near 代表 Camera 最近可以看到的位置。
Far 代表 Camera 最遠可以看到的位置。
如果物件距離 Camera 太近,小於 Near 會看不到,
如果距離太遠,超過 Far 也看不到,
目前我們的太陽系範圍大概只有幾十個 Three.js 單位,所以 0.1 、 1000 已經足夠使用。

  1. Camera Position

今天我們把 Camera 改成:

camera.position.set(
    0,
    28,
    38
);

也就是:

X = 0
Y = 28
Z = 38

其中 Y 比 Day 14 更高。
所以 Camera 會從比較高的位置往下看。
這樣可以更清楚看到八條軌道。

  1. camera.lookAt()

雖然 Camera 移動到了 0, 28, 38
但是我們還需要告訴它你要看哪裡?
所以使用:

camera.lookAt(
    0,
    0,
    0
);

意思是讓 Camera 永遠看向:

X = 0
Y = 0
Z = 0

而這個位置剛好就是太陽所以 Camera 會一直看向太陽系中心。

  1. 加入鍵盤切換 Camera

今天新增一個功能就是:

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

    }
);

當使用者按下鍵盤按鍵時 event.key 就可以知道使用者按了什麼。
例如當按下 if (event.key === "1") 就代表使用者按下數字 1。
接著:

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

Camera 就會移動到新的位置。
目前我們準備四個視角:

1 正面視角 (0,0,40)
2 斜角視角 (30,25,30)
3 俯視視角 (0,45,0.1)
4 近距離視角 (0,12,20)

這也可以讓我們理解 Camera 距離對 3D 畫面的影響。

六、小試身手

今天可以自己加入:
第 5 種 Camera 視角
按下 5 切換到另外一個角度。
可以在 keydown 裡加入:

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

    camera.position.set(
        X,
        Y,
        Z
    );

}

這樣就會從另外一側觀看太陽系。
也可以自己測試不同參數看看會得到什麼視角。
也可以試著改 FOV 比較不同鏡頭視野下,太陽系看起來有什麼不同。

七、今天小結

今天太陽系本身沒有增加新的星球或新的運動方式。
但是我們加入了一個真正可以讓使用者操作的功能:
鍵盤切換 Camera
同時我們也正式認識 PerspectiveCamera、FOV、Aspect、Near、Far、camera.position、camera.lookAt

目前太陽系已經完成:

全螢幕場景、太陽、八大行星、Wireframe、自轉、公轉、公轉軌道、不同運行速度、THREE.Clock、Camera 視角,以及鍵盤切換 Camera。

不過現在 Camera 還是有一個限制。雖然比以前方便很多,但我們還是只能在四個固定位置之間切換。
如果想自己用滑鼠隨意轉動視角,目前還做不到。

Day 16 預告

「OrbitControls:用滑鼠自由觀看整個太陽系」

Day 16 我們會加入 OrbitControls 讓使用者可以直接滑鼠左鍵拖曳和旋轉觀看角度、
滑鼠滾輪放大 / 縮小、滑鼠右鍵拖曳平移畫面變成自由 3D 視角。
完成 Day 16 之後,這個太陽系就會第一次真正有「互動式 3D 網頁」的感覺。


上一篇
Day 14 - 不同速度:讓八大行星擁有自己的運行節奏
下一篇
Day 16 - OrbitControls:用滑鼠自由觀看整個太陽系
系列文
從 Web 到宇宙:Three.js × WebGL 互動式太陽系與光影實作 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言