Tururi virtuale imobiliare: WebGL și tehnologie web 3D
Statisticile vorbesc de la sine: proprietățile cu tururi virtuale primesc Cu 87% mai multe vizualizări comparativ cu cei cu fotografii doar statice, iar agențiile care le adoptă reduc vizitele fizice necalificate cu 60%, în timp ce crește clienții potențiali calificați cu 40%. Cu toate acestea, majoritatea implementărilor există se bazează pe soluții SaaS costisitoare și cu personalizare redusă. În acest articol vom construi un sistem complet de tururi virtuale imobiliare în întregime în browser, profitând de WebGL, Trei.js și tehnologii web moderne pentru a crea experiențe captivante care sunt scalabile și pot fi integrate cu orice platformă de listare.
De la redarea panoramelor la 360 de grade la navigarea între camere, de la punctele de informare la măsurători interactiv: vom acoperi fiecare aspect tehnic cu cod TypeScript gata de producție.
Ce vei învăța
- Arhitectura unui vizualizator WebGL pentru tururi virtuale cu Three.js și React Three Fiber
- Încărcarea și optimizarea panoramelor echirectangulare (fotografii/videoclipuri 360)
- Navigare în mai multe camere cu tranziții fluide și hotspot-uri interactive
- Măsurători interactive cu turnare de raze și geometrie 3D
- Optimizarea performanței: LOD, redarea texturii și încărcare leneră
- Integrare cu WebXR pentru VR/AR pe dispozitive compatibile
- Conductă CI/CD pentru procesarea automată a imaginii 360
- Încorporare și integrare API cu platformele de listare existente
Fundamentele WebGL și Three.js pentru imobiliare
WebGL și un API JavaScript care permite redarea 2D și 3D de înaltă performanță direct în browser fără pluginuri, profitând de GPU-ul dispozitivului. Trei.js complexitatea rezumatelor de WebGL cu un API de nivel înalt care gestionează scene, camere, lumini, materiale și geometrie. Pentru excursii imobiliare virtuală, modelul fundamental și panorama sferei: o sferă uriașă cu susul în jos cu textura echirectangulară proiectată spre interior, cu camera poziționată în centru.
Stivă de tehnologie recomandată
- Trei.js 0.170+: randare 3D de bază
- Reacționează trei fibre: Legături Declarative React pentru Three.js
- Drei: ajutoare și abstracții pre-construite (PointerLockControls, Html, etc.)
- GSAP: animații de tranziție lină
- Panellum: alternativă ușoară pentru panorame pure
- Ascuțit (Node.js): procesare a imaginii 360 la nivelul serverului
- FFmpeg: procesare video 360
Arhitectura sistemului
Un sistem de tur virtual de producție este compus din trei niveluri distincte: conductă de procesare partea de server (conversie, optimizare, generare de tile), the strat de livrare (CDN, streaming adaptativ) și cel motor de randare partea clientului. Această separare este esențială de asigurat performanță optimă pe orice dispozitiv.
// Struttura dati per un tour virtuale
interface VirtualTour {
id: string;
propertyId: string;
name: string;
rooms: TourRoom[];
startRoomId: string;
metadata: TourMetadata;
}
interface TourRoom {
id: string;
name: string; // "Soggiorno", "Camera principale"
panoramaUrl: string; // URL immagine equirettangolare (8192x4096px)
thumbnailUrl: string;
hotspots: Hotspot[];
floorPlanPosition: { x: number; y: number };
cameraDefaultYaw: number; // orientamento iniziale (gradi)
cameraDefaultPitch: number;
}
interface Hotspot {
id: string;
type: 'navigation' | 'info' | 'measurement' | 'media';
position: { yaw: number; pitch: number }; // coordinate sferiche
targetRoomId?: string; // per type='navigation'
label?: string;
description?: string;
mediaUrl?: string;
}
interface TourMetadata {
property: {
address: string;
squareMeters: number;
price: number;
currency: string;
};
capturedAt: Date;
camera: string;
floorPlanUrl?: string;
measurementsEnabled: boolean;
}
Implementarea Vizualizatorului Three.js
Inima sistemului este vizualizatorul WebGL. Să folosim unul SphereGeometry cu raza mare,
geometrie inversată (textură spre interior) e OrbitControls configurat pentru a simula
comportamentul unei camere panoramice (numai rotire, fără pan/zoom excesiv).
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
export class PanoramaViewer {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: OrbitControls;
private sphere: THREE.Mesh;
private hotspotGroup: THREE.Group;
private currentRoom: TourRoom | null = null;
private textureLoader: THREE.TextureLoader;
constructor(private container: HTMLElement) {
this.scene = new THREE.Scene();
// Camera con FOV tipico per panoramiche (75-90 gradi)
this.camera = new THREE.PerspectiveCamera(
75,
container.clientWidth / container.clientHeight,
0.1,
1000
);
this.camera.position.set(0, 0, 0.1);
// Renderer con antialiasing e supporto HDR
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
});
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(this.renderer.domElement);
// Controlli orbitali configurati per panoramiche
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enablePan = false;
this.controls.enableZoom = true;
this.controls.zoomSpeed = 0.3;
this.controls.rotateSpeed = -0.3; // Inverso per comportamento naturale
this.controls.minDistance = 0;
this.controls.maxDistance = 0;
this.controls.minPolarAngle = Math.PI * 0.1;
this.controls.maxPolarAngle = Math.PI * 0.9;
// Sfera panoramica
const geometry = new THREE.SphereGeometry(500, 60, 40);
// Capovolgi la geometria per renderizzare verso l'interno
geometry.scale(-1, 1, 1);
const material = new THREE.MeshBasicMaterial({
side: THREE.BackSide, // Faccia interna visibile
});
this.sphere = new THREE.Mesh(geometry, material);
this.scene.add(this.sphere);
this.hotspotGroup = new THREE.Group();
this.scene.add(this.hotspotGroup);
this.textureLoader = new THREE.TextureLoader();
this.setupResizeObserver();
this.animate();
}
async loadRoom(room: TourRoom): Promise<void> {
this.currentRoom = room;
// Carica texture con progressive loading (prima bassa ris, poi alta)
const lowResTexture = await this.loadTexture(
room.panoramaUrl.replace('.jpg', '_low.jpg')
);
(this.sphere.material as THREE.MeshBasicMaterial).map = lowResTexture;
(this.sphere.material as THREE.MeshBasicMaterial).needsUpdate = true;
// Carica alta risoluzione in background
const highResTexture = await this.loadTexture(room.panoramaUrl);
(this.sphere.material as THREE.MeshBasicMaterial).map = highResTexture;
(this.sphere.material as THREE.MeshBasicMaterial).needsUpdate = true;
// Pulisci e ricrea hotspot
this.clearHotspots();
room.hotspots.forEach(hotspot => this.addHotspot(hotspot));
// Ruota la camera alla posizione di default della stanza
this.setInitialOrientation(room.cameraDefaultYaw, room.cameraDefaultPitch);
}
private loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
this.textureLoader.load(url, resolve, undefined, reject);
});
}
private sphericalToCartesian(yaw: number, pitch: number, radius = 100): THREE.Vector3 {
const phi = (90 - pitch) * (Math.PI / 180);
const theta = yaw * (Math.PI / 180);
return new THREE.Vector3(
radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
}
private addHotspot(hotspot: Hotspot): void {
const position = this.sphericalToCartesian(
hotspot.position.yaw,
hotspot.position.pitch
);
// Sprite per hotspot (sempre orientato verso la camera)
const spriteMaterial = new THREE.SpriteMaterial({
map: this.getHotspotTexture(hotspot.type),
transparent: true,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(8, 8, 1);
sprite.userData = { hotspot };
this.hotspotGroup.add(sprite);
}
private animate = (): void => {
requestAnimationFrame(this.animate);
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
private setupResizeObserver(): void {
const observer = new ResizeObserver(() => {
const w = this.container.clientWidth;
const h = this.container.clientHeight;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
});
observer.observe(this.container);
}
dispose(): void {
this.renderer.dispose();
this.controls.dispose();
}
}
Sistem de navigație cu mai multe camere cu tranziții
Navigarea între camere ar trebui să fie lină și intuitivă. Să implementăm o tranziție de fade-out/fade-in animat și preîncărcarea următoarei camere în fundal pentru a elimina timpii de așteptare percepuți.
export class TourNavigator {
private viewer: PanoramaViewer;
private tour: VirtualTour;
private roomCache: Map<string, THREE.Texture> = new Map();
private isTransitioning = false;
constructor(viewer: PanoramaViewer, tour: VirtualTour) {
this.viewer = viewer;
this.tour = tour;
// Preloading preventivo delle stanze adiacenti
this.preloadAdjacentRooms(tour.startRoomId);
}
async navigateToRoom(
roomId: string,
transition: 'fade' | 'blur' | 'zoom' = 'fade'
): Promise<void> {
if (this.isTransitioning) return;
this.isTransitioning = true;
const room = this.tour.rooms.find(r => r.id === roomId);
if (!room) {
this.isTransitioning = false;
return;
}
// Fase 1: fade out
await this.animateOverlay(0, 1, 300, transition);
// Fase 2: carica nuova stanza (probabilmente già in cache)
await this.viewer.loadRoom(room);
// Fase 3: preloading stanze adiacenti in background
this.preloadAdjacentRooms(roomId);
// Fase 4: fade in
await this.animateOverlay(1, 0, 300, transition);
this.isTransitioning = false;
// Analytics: traccia navigazione
this.trackRoomVisit(roomId);
}
private async preloadAdjacentRooms(currentRoomId: string): Promise<void> {
const currentRoom = this.tour.rooms.find(r => r.id === currentRoomId);
if (!currentRoom) return;
const navigationHotspots = currentRoom.hotspots
.filter(h => h.type === 'navigation' && h.targetRoomId)
.map(h => h.targetRoomId!);
// Preload in parallelo con bassa priorità
for (const roomId of navigationHotspots) {
if (!this.roomCache.has(roomId)) {
const room = this.tour.rooms.find(r => r.id === roomId);
if (room) {
// Usa Intersection Observer API per priorità basata sulla visibilità
requestIdleCallback(() => {
this.preloadRoomTexture(room);
});
}
}
}
}
private animateOverlay(
fromOpacity: number,
toOpacity: number,
durationMs: number,
effect: string
): Promise<void> {
return new Promise(resolve => {
const overlay = document.getElementById('tour-overlay')!;
overlay.style.transition = `opacity ${durationMs}ms ease-in-out`;
overlay.style.opacity = String(toOpacity);
setTimeout(resolve, durationMs);
});
}
private trackRoomVisit(roomId: string): void {
// Integrazione con Google Analytics 4
if (typeof gtag !== 'undefined') {
gtag('event', 'virtual_tour_room_visit', {
tour_id: this.tour.id,
room_id: roomId,
property_id: this.tour.propertyId,
});
}
}
}
Hotspot-uri interactive și măsurători
Hotspot-urile sunt elemente fundamentale ale experienței: vă permit să navigați între camere, spectacol informații contextuale și, în implementările mai avansate, efectuați măsurători spațiale. Implementăm un sistem de turnare cu raze pentru detectarea precisă a clicurilor pe sfera panoramică.
// Sistema di interazione con hotspot via raycasting
export class HotspotInteractionManager {
private raycaster = new THREE.Raycaster();
private mouse = new THREE.Vector2();
private onHotspotClick: (hotspot: Hotspot) => void;
constructor(
private camera: THREE.PerspectiveCamera,
private hotspotGroup: THREE.Group,
private canvas: HTMLCanvasElement,
onHotspotClick: (hotspot: Hotspot) => void
) {
this.onHotspotClick = onHotspotClick;
this.setupEventListeners();
}
private setupEventListeners(): void {
// Supporto touch e mouse
this.canvas.addEventListener('click', this.handleClick);
this.canvas.addEventListener('touchend', this.handleTouch);
}
private handleClick = (event: MouseEvent): void => {
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.checkIntersection();
};
private handleTouch = (event: TouchEvent): void => {
if (event.changedTouches.length === 0) return;
const touch = event.changedTouches[0];
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
this.checkIntersection();
};
private checkIntersection(): void {
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(
this.hotspotGroup.children,
true
);
if (intersects.length > 0) {
const hotspotData = intersects[0].object.userData['hotspot'] as Hotspot;
if (hotspotData) {
this.onHotspotClick(hotspotData);
}
}
}
}
// Misurazione interattiva: calcolo distanza tra due punti sulla sfera
export class MeasurementTool {
private measurePoints: THREE.Vector3[] = [];
private measureLines: THREE.Line[] = [];
addMeasurePoint(yaw: number, pitch: number, realWorldRadius: number): void {
const point = this.sphericalToCartesian(yaw, pitch, 1);
this.measurePoints.push(point);
if (this.measurePoints.length === 2) {
const [p1, p2] = this.measurePoints;
// Angolo tra i due vettori in radianti
const angle = p1.angleTo(p2);
// Distanza approssimativa in metri (richiede calibrazione con dati reali)
const estimatedDistance = angle * realWorldRadius * 2;
console.log(`Distanza stimata: ${estimatedDistance.toFixed(2)} m`);
this.measurePoints = [];
}
}
private sphericalToCartesian(yaw: number, pitch: number, radius: number): THREE.Vector3 {
const phi = (90 - pitch) * (Math.PI / 180);
const theta = yaw * (Math.PI / 180);
return new THREE.Vector3(
radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
}
}
Conducta de procesare a imaginilor pe partea serverului
Calitatea imaginilor 360 este fundamentală pentru experiență. Să implementăm o conductă Node.js care optimizează automat imaginile brute de la camere precum cea Ricoh Theta Z1 sau cel Insta360 X4, generând rezoluții multiple pentru streaming adaptiv.
import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';
interface ProcessingConfig {
inputPath: string;
outputDir: string;
qualities: Array<{ width: number; suffix: string; quality: number }>;
}
export async function processPanoramaImage(config: ProcessingConfig): Promise<void> {
const { inputPath, outputDir, qualities } = config;
await fs.mkdir(outputDir, { recursive: true });
const baseName = path.basename(inputPath, path.extname(inputPath));
// Genera versioni multiple per progressive loading
for (const quality of qualities) {
const outputPath = path.join(outputDir, `${baseName}_${quality.suffix}.webp`);
await sharp(inputPath)
.resize(quality.width, quality.width / 2, {
fit: 'fill',
kernel: 'lanczos3',
})
.webp({
quality: quality.quality,
effort: 6, // Bilanciamento velocità/compressione
lossless: false,
})
.toFile(outputPath);
console.log(`Generated: ${outputPath} (${quality.width}x${quality.width / 2})`);
}
// Genera thumbnail per la mappa del piano
await sharp(inputPath)
.resize(400, 200, { fit: 'fill' })
.webp({ quality: 70 })
.toFile(path.join(outputDir, `${baseName}_thumb.webp`));
}
// Configurazione per produzione
const productionConfig: ProcessingConfig = {
inputPath: './raw/living-room.jpg',
outputDir: './processed/living-room',
qualities: [
{ width: 1024, suffix: 'low', quality: 65 }, // ~200KB - caricamento iniziale
{ width: 4096, suffix: 'med', quality: 80 }, // ~1.5MB - qualità standard
{ width: 8192, suffix: 'high', quality: 90 }, // ~5MB - massima qualità
],
};
Integrare WebXR pentru VR/AR
Integrarea cu API-ul WebXR permite utilizatorilor cu căști compatibile (Meta Quest, Apple Vision Pro prin browser, HTC Vive) pentru a vă scufunda complet în proprietate. Adăugarea și surprinzător de simplu cu Three.js datorită suportului nativ pentru randamentul.
// Abilitazione WebXR nel renderer Three.js
export function enableWebXR(
renderer: THREE.WebGLRenderer,
container: HTMLElement
): void {
renderer.xr.enabled = true;
// Verifica supporto WebXR nel browser
if ('xr' in navigator) {
navigator.xr?.isSessionSupported('immersive-vr').then(supported => {
if (supported) {
// Mostra pulsante "Entra in VR"
const vrButton = createVRButton(renderer);
container.appendChild(vrButton);
}
});
}
}
function createVRButton(renderer: THREE.WebGLRenderer): HTMLButtonElement {
const button = document.createElement('button');
button.textContent = 'Visita in VR';
button.style.cssText = `
position: absolute;
bottom: 20px;
right: 20px;
padding: 12px 24px;
background: #0066cc;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
`;
button.addEventListener('click', async () => {
try {
const session = await navigator.xr!.requestSession('immersive-vr', {
optionalFeatures: ['local-floor', 'bounded-floor'],
});
await renderer.xr.setSession(session);
button.textContent = 'Uscita VR';
} catch (err) {
console.error('Errore avvio sessione VR:', err);
}
});
return button;
}
// Nel loop di animazione, gestisci sia VR che non-VR
function animate(renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.PerspectiveCamera): void {
renderer.setAnimationLoop(() => {
// In VR: il renderer gestisce automaticamente i due occhi
// Fuori VR: rendering standard
renderer.render(scene, camera);
});
}
Încorporarea API-ului și integrarea cu platformele de listare
Pentru a maximiza adopția, spectatorul trebuie să fie ușor integrat în orice platformă existent printr-o etichetă HTML simplă sau un iframe. Să creăm o componentă web standard cu Custom Elements API.
// Web Component per embedding universale
class RealEstateTourElement extends HTMLElement {
private viewer: PanoramaViewer | null = null;
static get observedAttributes(): string[] {
return ['tour-id', 'api-key', 'start-room'];
}
connectedCallback(): void {
this.render();
this.initTour();
}
disconnectedCallback(): void {
this.viewer?.dispose();
}
private render(): void {
this.innerHTML = `
<div style="width:100%;height:100%;position:relative;">
<div id="tour-container" style="width:100%;height:100%;"></div>
<div id="tour-overlay" style="position:absolute;top:0;left:0;width:100%;height:100%;
background:black;opacity:0;pointer-events:none;"></div>
<div id="tour-controls" style="position:absolute;bottom:16px;left:16px;"></div>
</div>
`;
}
private async initTour(): Promise<void> {
const tourId = this.getAttribute('tour-id');
const apiKey = this.getAttribute('api-key');
if (!tourId || !apiKey) return;
const response = await fetch(
`https://api.realestate-tours.com/v1/tours/${tourId}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const tour: VirtualTour = await response.json();
const container = this.querySelector('#tour-container') as HTMLElement;
this.viewer = new PanoramaViewer(container);
const startRoom = tour.rooms.find(r => r.id === tour.startRoomId)!;
await this.viewer.loadRoom(startRoom);
}
}
// Registrazione come Custom Element
customElements.define('realestate-tour', RealEstateTourElement);
// Utilizzo in qualsiasi HTML:
// <realestate-tour tour-id="prop-12345" api-key="your-key" style="height:500px"></realestate-tour>
Optimizarea performanței
Performanța este critică: un tur care durează mai mult de 3 secunde pentru a se încărca pierde 53% dintre utilizatori (dată de Google). Iată strategiile fundamentale.
Strategii de optimizare
- Textura de streaming adaptivă: încărcați mai întâi rezoluție joasă (200KB), apoi înlocuiți cu rezoluție înaltă (5MB)
- Preîncărcare inteligentă: preîncarcă încăperile accesibile din cea actuală în timpul inactiv
- WebP cu rezervă: WebP reduce cu 30-35% în comparație cu JPEG de aceeași calitate
- CDN cu edge caching: distribuiți imaginile geografic (Cloudflare Images, AWS CloudFront)
- LOD (nivel de detaliu): rezoluție mai mică pe dispozitivele mobile bazate pe devicePixelRatio
- Instanțare GPU: pentru mai multe hotspot-uri, utilizați InstancedMesh în loc de Sprite-uri separate
- requestIdleCallback: efectuați preîncărcarea numai când browserul este inactiv
// Selezione risoluzione adattiva basata su dispositivo
function getOptimalResolution(
devicePixelRatio: number,
connectionType: string
): 'low' | 'med' | 'high' {
// Navigator Connection API (dove disponibile)
const connection = (navigator as any).connection;
const effectiveType = connection?.effectiveType ?? '4g';
if (effectiveType === '2g' || effectiveType === 'slow-2g') {
return 'low';
}
if (devicePixelRatio <= 1 || effectiveType === '3g') {
return 'med';
}
return 'high';
}
// Utilizzo
const resolution = getOptimalResolution(
window.devicePixelRatio,
''
);
const panoramaUrl = room.panoramaUrl.replace('.webp', `_${resolution}.webp`);
Valori de afaceri și analize
Valoarea reală a tururilor virtuale este măsurată cu date concrete. Integram un sistem de analiza specific turneului care urmărește valorile cheie pentru echipele de vânzări imobiliare.
| Metric | Definiţie | Benchmark-uri |
|---|---|---|
| Rata de finalizare a turului | % utilizatori care vizitează cel puțin 3 camere | >45% |
| Durata medie a sesiunii | Durata medie a vizitei virtuale | 4-8 min |
| Rata de implicare a hotspotului | % hotspot-uri pe care s-a făcut clic pe sesiune | >30% |
| Conversie de clienți potențiali | % sesiuni care duc la contact | 8-15% |
| Reducerea vizitelor fizice | Reducerea vizitelor fizice necalificate | 50-60% |
Avertisment: Copyright Images 360
Imaginile panoramice sunt supuse dreptului de autor. Asigurați-vă că aveți acorduri contractuale clar cu fotografi/agentiile care produc materialul. Include în termenii de utilizare a limitări explicite ale platformei privind descărcarea și redistribuirea imaginilor. Din punct de vedere tehnic, aplică filigrane de rezoluție scăzută și dezactivează clic dreapta pe pânza WebGL.
Concluzii și pașii următori
Am construit un sistem complet de tur virtual bazat pe tehnologii web standard: WebGL/Three.js pentru randare, elemente personalizate pentru încorporare universală, conductă Sharp pentru optimizare imagini. Această abordare elimină dependența de furnizorii SaaS scumpi și permite completarea personalizarea experienței utilizatorului.
Următorul pas natural este integrarea cu modele 3D fotorealiste generate de Splatting Gaussian o NeRF (Câmpuri de radiație neuronală), care sunt revoluționând vizualizarea imobiliară în 2025-2026 cu reconstrucții complete 3D de la simple video cu smartphone.







