Pozicování HTML elementů

V této části si ukážeme, jak můžeme na canvasu pozicovat HTML elementy pomocí CSS transform vlastnosti podle nějakých určených bodů ve scéně. Pokud nechápete co tím chci říct, tak se podívejte na následující ukázku.

Startovní kód

Abychom si mohli zkusit pozicovat HTML elementy podle nějakého bodu ve scéně, tak musíme nějakou mít. Proto je tu pro vás připravený startovní kód, který scénu vytváří a přidává do ní alespoň kostku. Vytvořte si tedy pomocí startovního kódu z části o Webpacku nový projekt a do JavaScript souboru si zkopírujte kód z následující ukázky.

import './style.css';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// vytvoření scény
const scene = new THREE.Scene();

// vytvoření kamery
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 3;
scene.add(camera);

// přidání AmbientLight světla
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);

// přidání DirectionalLight světla
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.7);
directionalLight.position.set(0.5, 1.5, 0.3);
scene.add(directionalLight);

// přidání kostky do scény
const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshStandardMaterial({
        color: 0xFAB278,
        roughness: 0.4
    })
);
scene.add(cube);


// vytvoření rendereru
const renderer = new THREE.WebGLRenderer({
    canvas: document.getElementById("WebGLCanvas")
});
// nastavení velikosti canvasu a pixel ratio
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// přidání event listeneru pro změnu velikosti okna
window.addEventListener("resize", () => {
    // aktualizace poměru stran kamery
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    // změnění velikosti canvasu a pixel ratio
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

// vytvoření OrbitControls ovládání
const controls = new OrbitControls(camera, renderer.domElement);
// zapnutí tlumení při posunutí
controls.enableDamping = true;

// tato funkce je volána každý frame
function tick() {
    // aktualizace OrbitControls ovládání
    controls.update();
    // vyrenderování scény na canvas
    renderer.render(scene, camera);
}

// nastavení animační smyčky
// - funkce tick se bude volat každý frame
renderer.setAnimationLoop(tick);

Protože roztahujeme canvas přes celou velikost okna prohlížeče, tak si ještě zkopírujte následující CSS styly, pomocí kterých se zbavíme defaultních marginů a paddingů.

*, *::before, *::after {
    padding: 0;
    margin: 0;
}

body {
    overflow: hidden;
}

Po spuštění aplikace uvidíte ve scéně kostku.

Pozicování elementů na canvasu

Jak jsem již psal, tak si zkusíme na canvasu pozicovat HTML elementy podle nějakých bodů ve scéně. Nejdříve si elementy vytvoříme v HTML kódu, jak ukazuje následující ukázka.

<!DOCTYPE html>
<html lang="cs">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js projekt</title>
</head>
<body>
    <canvas id="WebGLCanvas"></canvas>
    <div id="Point1" class="point">1</div>
    <div id="Point2" class="point">2</div>
    <div id="Point3" class="point">3</div>
</body>
</html>

Abychom mohli elementy na canvasu pozicovat, tak musejí mít v CSS stylech nastavenou vlastnost position na absolute. Kód, který ukazuje následující ukázka se o to postará a také zajistí, že se elementy umístí na střed canvasu. Budou se nám tak podle bodů ve scéně lépe pozicovat. Také nastavuje pár dalších vlastností, které se týkají jen základního nastylování. Můžete si tedy tento kód zkopírovat.

/* ... */

.point {
    width: 32px;
    height: 32px;

    position: absolute;
    top: calc(50% - 16px);
    left: calc(50% - 16px);

    z-index: 10;

    background-color: #A4B4B7;
    border: 2px solid #429FAD;
    border-radius: 50%;

    font-size: 16px;
    font-weight: 700;
    text-align: center;
    color: #2B2D2E;
    line-height: 32px;
}

Pokud si aplikaci spustíte, tak se vám elementy objeví uprostřed canvasu. Všechny jsou momentálně naskládané na sobě.

V našem příkladu budeme chtít, aby se každý element pozicoval na roh kostky, který mu určíme. V JavaScript kódu si vytvoříme pole, které bude uchovávat objekty, ve kterých bude vždy element a bod ve scéně, podle kterého se má element pozicovat. Body můžeme reprezentovat jako Vector3.

/* ... */

const points = [
    {
        position: new THREE.Vector3(-0.5, -0.5, 0.5),
        element: document.getElementById("Point1")
    },
    {
        position: new THREE.Vector3(0.5, -0.5, 0.5),
        element: document.getElementById("Point2")
    },
    {
        position: new THREE.Vector3(0.5, 0.5, -0.5),
        element: document.getElementById("Point3")
    }
];

Budeme muset nějakým způsobem získávat pozici jednotlivých bodů na canvasu. Ve skutečnosti je to jednodušší než by jste si mohli myslet. Třída Vector3 k tomu má metodu jménem project. Jako parametr jí předáme kameru a vektor se transformuje na souřadnice kamery (prostě na souřadnice na obrazovce). Pomocí těchto souřadnic poté můžeme HTML elementy pozicovat. Souřadnice jsou po obou osách od -1 do 1 (pokud se bod nachází ve výhledu kamery). Takže když je třeba na ose X hodnota -1, tak je bod na levé straně obrazovky, když je hodnota 0, tak je uprostřed a když je 1, tak je napravo.

Nastavovat pozici elementům budeme při každém framu ve funkci tick. Pro každý element vždy zjistíme souřadnice jeho bodu na obrazovce a nastavíme mu podle nich pozici pomocí CSS vlastnosti transform. Následující ukázka ukazuje, jak to můžeme udělat. Jelikož jsou hodnoty souřadnic na obrazovce od -1 do 1, tak je pro nastavování pozice elementu ještě vynásobujeme velikostí canvasu (okna) a hodnotou 0.5. U osy Y navíc ještě nastavujeme mínus, jelikož ve Three.js po ní jdou kladné hodnoty nahoru, ale když nastavujeme pozici elementů pomocí CSS, tak po ní jdou kladné hodnoty dolů. Myslím že snad chápete co se tím snažím říct.

/* ... */

// tato funkce je volána každý frame
function tick() {
    // aktualizace OrbitControls ovládání
    controls.update();
    // procházíme každý element v poli points
    for (let point of points) {
        // naklonování pozice bodu (instanci třídy Vector3)
        // - funkce project totiž vektor mění
        const screenPosition = point.position.clone();
        // transformování souřadnic na souřadnice na obrazovce
        screenPosition.project(camera);

        // získání hodnot pro nastavení pozice elementu
        const x = screenPosition.x * window.innerWidth * 0.5;
        const y = -screenPosition.y * window.innerHeight * 0.5;

        // nastavení pozice elementu podle získaných hodnot
        point.element.style.transform = `translate(${x}px, ${y}px)`;
    }
    // vyrenderování scény na canvas
    renderer.render(scene, camera);
}

/* ... */

Po spuštění aplikace můžete vidět, že elementy se pozicují podle souřadnic rohů kostky, které jsme pro ně definovali.

Jelikož každý frame nastavujeme elementům pozici pomocí vlastnosti transform, tak tuto vlastnost můžeme v CSS trochu optimalizovat. Slouží k tomu vlastnost will-change. Neměli bychom ji používat úplně na všechno, ale v našem případě si myslím že se hodí ji použít, když vlastnost transform měníme každý frame. Sice nevím jak přesně vlastnost will-change funguje, ale vím že může vylepšit výkonnost. Následující ukázka ukazuje, jak ji můžeme pro vlastnost transform v CSS kódu nastavit. Možná bychom ji ani nastavovat nemuseli, záleží na vás. Každopádně ji ale můžete zkusit použít, pokud někdy budete mít problém s výkonem vaší aplikace.

/* ... */

.point {
    width: 32px;
    height: 32px;

    position: absolute;
    top: calc(50% - 16px);
    left: calc(50% - 16px);

    z-index: 10;

    background-color: #A4B4B7;
    border: 2px solid #429FAD;
    border-radius: 50%;

    font-size: 16px;
    font-weight: 700;
    text-align: center;
    color: #2B2D2E;
    line-height: 32px;

    /* optimalizace vlastnosti transform */
    will-change: transform;
}

Skrývání elementů

Pozicování elementů na canvasu bychom v našem příkladu mohli ještě vylepšit tak, že bychom skrývali elementy, jejichž body jsou kostkou zakryty. K tomu můžeme použít raycasting. Budeme vrhat paprsek z kamery podle pozice bodu na obrazovce a pokud paprsek před doražením k bodu kostku protne, tak element skryjeme. Nejdříve si ale body trochu posuneme, aby neleželi přímo na rozích kostky.

/* ... */

const points = [
    {
        position: new THREE.Vector3(-0.505, -0.505, 0.505),
        element: document.getElementById("Point1")
    },
    {
        position: new THREE.Vector3(0.505, -0.505, 0.505),
        element: document.getElementById("Point2")
    },
    {
        position: new THREE.Vector3(0.505, 0.505, -0.505),
        element: document.getElementById("Point3")
    }
];

Budeme postupovat takto. Vyšleme paprsek z kamery podle souřadnic bodu na canvasu a otestujeme jeho protnutí s kostkou. Pokud paprsek kostku neprotne, tak element zobrazíme. Pokud paprsek kostku protne, tak se zeptáme jestli je vzdálenost prvního protnutí menší než vzdálenost bodu od kamery. Pokud ano, tak element skryjeme, jinak jej zobrazíme. Následující ukázka to ukazuje v kódu. Před funkcí tick si deklarujeme raycaster a poté jej každý frame ve funkci tick používáme. Pro získání vzdálenosti bodu od kamery používáme metodu distanceTo.

/* ... */

// vytvoření Raycasteru
const raycaster = new THREE.Raycaster();

// tato funkce je volána každý frame
function tick() {
    // aktualizace OrbitControls ovládání
    controls.update();
    // procházíme každý element v poli points
    for (let point of points) {
        // naklonování pozice bodu (instanci třídy Vector3)
        // - funkce project totiž vektor mění
        const screenPosition = point.position.clone();
        // transformování souřadnic na souřadnice na obrazovce
        screenPosition.project(camera);

        // získání hodnot pro nastavení pozice elementu
        const x = screenPosition.x * window.innerWidth * 0.5;
        const y = -screenPosition.y * window.innerHeight * 0.5;

        // nastavení pozice elementu podle získaných hodnot
        point.element.style.transform = `translate(${x}px, ${y}px)`;

        // nastavení paprsku raycasteru podle
        // pozice bodu na obrazovce z kamery
        raycaster.setFromCamera(screenPosition, camera);
        // otestování protnutí paprsku s kostkou
        const intersects = raycaster.intersectObject(cube);
        
        if (intersects.length === 0) {
            // pokud nedošlo k žádným protnutím, tak element zobrazíme
            point.element.style.display = "block";
        } else {
            // pokud došlo k protnutí kostky, tak pokračujeme zde

            // získání vzdálenosti nejbližšího protnutí kostky
            // - (protnutí jsou v poli seřazeny podle vzdálenosti)
            const intersectionDistance = intersects[0].distance;
            // získání vzdálenosti bodu od kamery
            const pointDistance = point.position.distanceTo(camera.position);
            
            if (intersectionDistance < pointDistance) {
                // pokud je vzdálenost protnutí menší než vzdálenost
                // bodu od kamery, tak element skryjeme
                point.element.style.display = "none";
            } else {
                // pokud je vzdálenost protnutí větší než vzdálenost
                // bodu od kamery, tak element zobrazíme
                point.element.style.display = "block";
            }
        }
    }
    // vyrenderování scény na canvas
    renderer.render(scene, camera);
}

/* ... */

Po spuštění ukázky se vám elementy, u kterých jsou body zakrývany kostkou budou skrývat.

To je vše, co jsem vám chtěl o pozicování HTML elementů na canvasu ukázat. Jak jste viděli, tak to nemusí být zas tak složité.