Virtuální prohlídky nemovitostí: WebGL a 3D webová technologie
Statistiky hovoří samy za sebe: nemovitosti s virtuální prohlídkou obdrží O 87 % více zhlédnutí ve srovnání s těmi, kteří mají pouze statické fotografie, a agentury, které je přijímají, snižují nekvalifikované fyzické návštěvy o 60 % a zároveň zvýšení kvalifikovaných potenciálních zákazníků o 40 %. Přesto existuje většina implementací spoléhá na drahá a málo přizpůsobitelná řešení SaaS. V tomto článku vytvoříme kompletní systém virtuálních prohlídek nemovitostí zcela v prohlížeči s využitím WebGL, Three.js a moderní webové technologie k vytvoření pohlcujících zážitků, které jsou škálovatelné a lze je integrovat s jakoukoli platformou výpisů.
Od vykreslování 360° panoramat po navigaci mezi místnostmi, od informačních hotspotů po měření interaktivní: Pokryjeme každý technický aspekt kódem TypeScript připraveným pro výrobu.
Co se naučíte
- Architektura prohlížeče WebGL pro virtuální prohlídky s Three.js a React Three Fiber
- Načítání a optimalizace ekviditárních panoramat (360° fotografie/videa)
- Navigace ve více místnostech s plynulými přechody a interaktivními hotspoty
- Interaktivní měření s odléváním paprsků a 3D geometrií
- Optimalizace výkonu: LOD, streamování textur a líné načítání
- Integrace s WebXR pro VR/AR na kompatibilních zařízeních
- CI/CD potrubí pro automatické zpracování 360° obrazu
- Vkládání a integrace API se stávajícími platformami výpisů
Základy WebGL a Three.js pro nemovitosti
WebGL a JavaScript API, které přímo umožňuje vysoce výkonné 2D a 3D vykreslování v prohlížeči bez pluginů s využitím výhod GPU zařízení. Three.js abstrahuje složitost WebGL s rozhraním API na vysoké úrovni, které spravuje scény, kamery, světla, materiály a geometrii. Na výlety virtuální nemovitosti, základní vzor a sférické panorama: obří koule obrácená vzhůru nohama s ekvidaktulární texturou promítnutou dovnitř, s kamerou umístěnou uprostřed.
Doporučený balíček technologií
- Three.js 0,170+: základní 3D vykreslování
- Reagovat Three Fibers: Deklarativní vazby React pro Three.js
- Drei: pomocníci a předem vytvořené abstrakce (PointerLockControls, Html atd.)
- GSAP: Hladké přechodové animace
- Panellum: lehká alternativa pro čisté panoramata
- Ostrý (Node.js): zpracování 360° obrazu na straně serveru
- FFmpeg: Zpracování 360° videa
Architektura systému
Systém virtuální prohlídky výroby se skládá ze tří odlišných úrovní: zpracovatelské potrubí na straně serveru (konverze, optimalizace, generování dlaždic), doručovací vrstva (CDN, streamování adaptivní) a renderovací engine na straně klienta. Toto oddělení je nezbytné zajistit optimální výkon na jakémkoli zařízení.
// 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;
}
Implementace prohlížeče Three.js
Srdcem systému je prohlížeč WebGL. Použijme jeden SphereGeometry s velkým poloměrem,
převrácená geometrie (vnitřní textura) e OrbitControls nakonfigurován tak, aby simuloval
chování panoramatické kamery (pouze rotace, žádné nadměrné pan/zoom).
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();
}
}
Navigační systém pro více místností s přechody
Navigace mezi místnostmi by měla být plynulá a intuitivní. Pojďme implementovat přechod zeslabení/zatmívání animované a předběžné načtení další místnosti na pozadí, aby se eliminovaly vnímané doby čekání.
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,
});
}
}
}
Interaktivní hotspoty a měření
Hotspoty jsou základními prvky zážitku: umožňují vám procházet mezi místnostmi, zobrazovat se kontextové informace a v pokročilejších implementacích provádět měření prostoru. Implementujeme ray casting systém pro přesnou detekci kliknutí na panoramatické kouli.
// 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)
);
}
}
Potrubí pro zpracování obrazu na straně serveru
Kvalita 360° snímků je pro zážitek zásadní. Pojďme implementovat kanál Node.js automaticky optimalizuje nezpracované snímky z kamer, jako je např Ricoh Theta Z1 nebo Insta360 X4, generující více rozlišení pro adaptivní streamování.
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à
],
};
Integrace WebXR pro VR/AR
Integrace s WebXR API umožňuje uživatelům používat kompatibilní sluchátka (Meta Quest, Apple Vision Pro prostřednictvím prohlížeče, HTC Vive), abyste se mohli plně ponořit do nemovitosti. Doplnění a překvapivě jednoduché s Three.js díky podpoře nativního rendereru.
// 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);
});
}
Vkládání API a integrace s platformami výpisů
Chcete-li maximalizovat přijetí, musí být divák snadno integrován do jakékoli platformy existující prostřednictvím jednoduché značky HTML nebo prvku iframe. Pojďme vytvořit standardní webovou komponentu s rozhraním 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>
Optimalizace výkonu
Výkon je kritický: prohlídka, jejíž načítání trvá déle než 3 sekundy, ztratí 53 % uživatelů (dává Google). Zde jsou základní strategie.
Optimalizační strategie
- Adaptivní streamovací textura: nejprve načtěte nízké rozlišení (200 kB), poté nahraďte vysokým rozlišením (5 MB)
- Inteligentní přednačítání: přednačte místnosti dosažitelné z aktuální během doby nečinnosti
- WebP s nouzovou: WebP snižuje o 30–35 % ve srovnání s JPEG ve stejné kvalitě
- CDN s mezipamětí okraje: geograficky distribuovat obrázky (Cloudflare Images, AWS CloudFront)
- LOD (úroveň detailu): nižší rozlišení na mobilních zařízeních na základě devicePixelRatio
- Instance GPU: pro více aktivních bodů použijte InstancedMesh místo samostatných Sprites
- requestIdleCallback: provést předběžné načtení pouze v době, kdy je prohlížeč nečinný
// 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`);
Obchodní metriky a analýzy
Skutečná hodnota virtuálních prohlídek je měřena konkrétními údaji. Integrujeme analytický systém specifické pro turné, které sleduje klíčové metriky pro týmy prodeje nemovitostí.
| Metrický | Definice | Srovnávací hodnoty |
|---|---|---|
| Míra dokončení prohlídky | % uživatelů, kteří navštíví alespoň 3 místnosti | >45 % |
| Průměrná doba relace | Průměrná doba trvání virtuální návštěvy | 4-8 min |
| Míra zapojení hotspotu | % aktivních bodů kliknutí na relaci | >30 % |
| Konverze olova | % relací vedoucích ke kontaktu | 8–15 % |
| Snížení fyzické návštěvy | Snížení nekvalifikovaných fyzických návštěv | 50–60 % |
Upozornění: Copyright Images 360
Panoramatické snímky podléhají autorským právům. Ujistěte se, že máte smluvní ujednání jasné s fotografy/agenturami, které produkují materiál. Zahrnuje v podmínkách použití explicitní omezení platformy pro stahování a redistribuci obrázků. Technicky používá vodoznaky s nízkým rozlišením a deaktivuje klepnutí pravým tlačítkem na plátno WebGL.
Závěry a další kroky
Vybudovali jsme kompletní systém virtuálních prohlídek založený na standardních webových technologiích: WebGL/Three.js pro vykreslování, Custom Elements pro univerzální vkládání, Sharp pipeline pro optimalizaci obrázky. Tento přístup eliminuje závislost na drahých prodejcích SaaS a umožňuje plné personalizace uživatelské zkušenosti.
Přirozeným dalším krokem je integrace s fotorealistickými 3D modely vytvořenými pomocí Gaussovské stříkání o NeRF (Neural Radiance Fields), která jsou revoluční vizualizace nemovitostí v letech 2025-2026 s kompletními 3D rekonstrukcemi od jednoduchých video se smartphonem.







