Hi all, i like to share how to create basic 360 landing page using three.js, nuxt.js and vue3
install new nuxt.js project
npx nuxi@latest init simple-app
cd simple-app
npm i
install three.js
npm i three -D
now we are ready to go let’s create new component for our 360 landing page
create new component
npx nuxi add component Landing
now we will create the component content like this
<script setup lang="ts">
import {
PerspectiveCamera,
Scene,
SphereGeometry,
TextureLoader,
MeshBasicMaterial,
Mesh,
WebGLRenderer,
MathUtils,
} from "three";
const container = ref()
const props = defineProps({
panoramaUrl:{
type: String,
default: "img/hdri.webp"
},
});
let camera, scene, renderer;
let isUserInteracting = false,
onPointerDownMouseX = 0, onPointerDownMouseY = 0,
lon = 0, onPointerDownLon = 0,
lat = 0, onPointerDownLat = 0,
phi = 0, theta = 0;
camera = new PerspectiveCamera(40, window.innerWidth / window.innerHeight, 1, 1100);
scene = new Scene();
const geometry = new SphereGeometry(1000, 100, 100);
const reader = new FileReader();
const onWindowResize= () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const onPointerDown = (event) => {
if (event.isPrimary === false) return;
isUserInteracting = true;
onPointerDownMouseX = event.clientX;
onPointerDownMouseY = event.clientY;
onPointerDownLon = lon;
onPointerDownLat = lat;
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
}
const onPointerMove = (event) => {
if (event.isPrimary === false) return;
lon = (onPointerDownMouseX - event.clientX) * 0.1 + onPointerDownLon;
lat = (event.clientY - onPointerDownMouseY) * 0.1 + onPointerDownLat;
}
const onPointerUp = () => {
if (event.isPrimary === false) return;
isUserInteracting = false;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
}
const onDocumentMouseWheel = (event) => {
const fov = camera.fov + event.deltaY * 0.05;
camera.fov = MathUtils.clamp(fov, 10, 75);
camera.updateProjectionMatrix();
}
const animate = () => {
requestAnimationFrame(animate);
update();
}
const update = () => {
if (isUserInteracting === false) {
lon += 0.1;
}
lat = Math.max(- 85, Math.min(85, lat));
phi = MathUtils.degToRad(90 - lat);
theta = MathUtils.degToRad(lon);
const x = 500 * Math.sin(phi) * Math.cos(theta);
const y = 500 * Math.cos(phi);
const z = 500 * Math.sin(phi) * Math.sin(theta);
camera.lookAt(x, y, z);
renderer.render(scene, camera);
}
const dragover = (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
const dragenter = () => {
document.body.style.opacity = 0.5;
}
const dragleave = () => {
document.body.style.opacity = 1;
}
const load1 = (event) => {
material.map.image.src = event.target.result;
material.map.needsUpdate = true;
}
const drop = () => {
event.preventDefault();
reader.addEventListener('load', load1);
reader.readAsDataURL(event.dataTransfer.files[0]);
document.body.style.opacity = 1;
}
onMounted(() => {
init();
animate();
function init() {
// invert the geometry on the x-axis so that all of the faces point inward
geometry.scale(- 1, 1, 1);
const texture = new TextureLoader().load(props.panoramaUrl);
const material = new MeshBasicMaterial({ map: texture });
const mesh = new Mesh(geometry, material);
scene.add(mesh);
renderer = new WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.value.appendChild(renderer.domElement);
container.value.style.touchAction = 'none';
container.value.addEventListener('pointerdown', onPointerDown);
document.addEventListener('wheel', onDocumentMouseWheel);
document.addEventListener('dragover', dragover);
document.addEventListener('dragenter', dragenter);
document.addEventListener('dragleave', dragleave);
document.addEventListener('drop', drop);
window.addEventListener('resize', onWindowResize);
}
});
onUnmounted(() => {
reader.removeEventListener('load', load1);
container.value.replaceWith(container.value.cloneNode(true));
document.removeEventListener('wheel', onDocumentMouseWheel);
document.removeEventListener('dragover', dragover);
document.removeEventListener('dragenter', dragenter);
document.removeEventListener('dragleave', dragleave);
document.removeEventListener('drop', drop);
window.removeEventListener('resize', onWindowResize);
});
</script>
<template>
<div class="max-w-full h-screen w-full overflow-hidden">
<div ref="container"></div>
</div>
</template>
Note that: you can find info about each part of the code in the official documentation of three.js in the link below.
three.js documentation
now we need to create folder inside the public
with the name img
then we will create our image that use hdri
mode.
You can create your own image or you can find one from any resource in this part i basically use greater website called HDRi Haven
then i convert the image to webp just to minify the total size of the image also in my case i don’t care about the shadow reflex and that other powerful things.
Now we have the image img/hdri.webp
in our public folder we are ready to use our component in the index.vue page.
Note that: we may have some rendering problem if we use SSR
with our app so basically we will use <ClientOnly>
wen we call our component like bellow.
<ClientOnly>
<Landing/>
<template #fallback>
<!-- this will be rendered on server side -->
<p>Landing is loading...</p>
</template>
</ClientOnly>
That’s it, we have now a basic 360 landing page