不動産バーチャル ツアー: WebGL と 3D Web テクノロジー
統計がそれを物語っています。バーチャル ツアーのある宿泊施設では、 視聴回数が 87% 増加 静止写真のみを使用するものと比較して、それらを採用する代理店は不適格な物理的訪問を削減します。 60% 増加し、適格なリードを 40% 増加させます。それでもほとんどの実装は存在します 高価でカスタマイズ性の低い SaaS ソリューションに依存しています。この記事では、完全なシステムを構築します 不動産バーチャル ツアーを完全にブラウザ内で実現し、 WebGL, Three.js 最新の Web テクノロジーを利用して、スケーラブルであらゆるリスティング プラットフォームと統合できる没入型エクスペリエンスを作成します。
360 度パノラマのレンダリングから部屋間の移動、情報ホットスポットから測定まで インタラクティブ: 本番環境に対応した TypeScript コードであらゆる技術的側面をカバーします。
何を学ぶか
- Three.js と React Three Fiber を使用したバーチャル ツアー用の WebGL ビューアのアーキテクチャ
- 正距円筒図法パノラマ (360 枚の写真/ビデオ) の読み込みと最適化
- スムーズな遷移とインタラクティブなホットスポットを備えたマルチルーム ナビゲーション
- レイキャスティングと 3D ジオメトリを使用したインタラクティブな測定
- パフォーマンスの最適化: LOD、テクスチャ ストリーミング、遅延読み込み
- 互換性のあるデバイス上の VR/AR 用の WebXR との統合
- 自動 360 画像処理のための CI/CD パイプライン
- 既存のリスティングプラットフォームへの埋め込みとAPI統合
不動産向け WebGL と Three.js の基礎
WebGL 高性能の 2D および 3D レンダリングを直接可能にする JavaScript API プラグインを使用せずにブラウザーで、デバイスの GPU を利用します。 Three.js 抽象的な複雑さ シーン、カメラ、ライト、マテリアル、ジオメトリを管理する高レベル API を備えた WebGL の機能。ツアー用 仮想不動産、基本パターンと 球体パノラマ: 巨大な逆さまの球体 正距円筒テクスチャーが内側に投影され、カメラが中央に配置されます。
推奨されるテクノロジースタック
- Three.js 0.170+: コア 3D レンダリング
- リアクトスリーファイバー: Three.js の宣言型 React バインディング
- ドライ: ヘルパーと事前に構築された抽象化 (PointerLockControls、Html など)
- GSAP: スムーズなトランジションアニメーション
- パネラム: 純粋なパノラマの軽量代替品
- シャープ (Node.js): サーバーサイドの360度画像処理
- FFmpeg: 360度ビデオ処理
システムアーキテクチャ
実稼働バーチャル ツアー システムは、次の 3 つの異なるレベルで構成されます。 処理パイプライン サーバー側 (変換、最適化、タイル生成)、 配信層 (CDN、ストリーミング アダプティブ)および レンダリングエンジン クライアント側。この分離は、確実に行うために不可欠です。 あらゆるデバイスで最適なパフォーマンスを実現します。
// 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;
}
Three.js ビューアの実装
システムの中心となるのは WebGL ビューアです。一つ使ってみましょう SphereGeometry 大きな半径で、
反転ジオメトリ (内側テクスチャ) e OrbitControls シミュレートするように構成されている
パノラマ カメラの動作 (回転のみ、過度のパン/ズームなし)。
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();
}
}
トランジション付きマルチルーム ナビゲーション システム
部屋間のナビゲーションはスムーズかつ直感的である必要があります。フェードアウト/フェードイントランジションを実装してみましょう バックグラウンドで次の部屋をアニメーション化してプリロードすることで、待ち時間をなくします。
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,
});
}
}
}
インタラクティブなホットスポットと測定
ホットスポットはエクスペリエンスの基本的な要素です。ホットスポットを使用すると、部屋間を移動したり、ショーを表示したりできます。 コンテキスト情報を取得し、より高度な実装では空間測定を実行します。 パノラマ球上で正確なクリック検出を行うためのレイ キャスティング システムを実装しています。
// 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)
);
}
}
サーバー側の画像処理パイプライン
360 画像の品質は体験の基礎です。 Node.js パイプラインを実装してみましょう。 などのカメラからの生画像を自動的に最適化します。 リコー シータ Z1 または インスタ360X4、アダプティブ ストリーミング用に複数の解像度を生成します。
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à
],
};
VR/AR のための WebXR 統合
との統合 WebXR API ユーザーは互換性のあるヘッドセット (Meta Quest、 Apple Vision Pro (ブラウザ経由、HTC Vive) を使用して、施設に完全に没頭できます。追加と Three.js はネイティブ レンダラ サポートのおかげで驚くほど簡単です。
// 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);
});
}
APIの埋め込みとリスティングプラットフォームとの統合
導入を最大限に高めるには、ビューアをあらゆるプラットフォームに簡単に統合できる必要があります 単純な HTML タグまたは iframe を介して存在します。カスタム要素 API を使用して標準 Web コンポーネントを作成しましょう。
// 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>
パフォーマンスの最適化
パフォーマンスは重要です。ロードに 3 秒以上かかるツアーでは 53% のユーザーが失われます (Google から提供されました)。基本的な戦略は次のとおりです。
最適化戦略
- アダプティブ ストリーミング テクスチャ: 最初に低解像度 (200KB) をロードし、次に高解像度 (5MB) に置き換えます
- スマートなプリロード: アイドル時間中に現在の部屋から到達可能な部屋をプリロードします
- フォールバックを使用した WebP: WebP は、同じ品質の JPEG と比較して 30 ~ 35% 削減されます。
- エッジ キャッシュを使用した CDN: イメージを地理的に配布する (Cloudflare イメージ、AWS CloudFront)
- LOD (詳細レベル): devicePixelRatio に基づくモバイル デバイスの解像度の低下
- GPU インスタンス化: 複数のホットスポットの場合は、個別のスプライトの代わりにインスタンスメッシュを使用します。
- requestIdleCallback: ブラウザがアイドル状態のときにのみプリロードを実行します
// 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`);
ビジネス指標と分析
バーチャルツアーの本当の価値は、具体的なデータで測られます。分析システムを統合します 不動産営業チームの主要な指標を追跡するツアー固有のサービス。
| メトリック | 意味 | ベンチマーク |
|---|---|---|
| ツアー完走率 | 少なくとも 3 つの部屋を訪問するユーザーの割合 | >45% |
| 平均セッション時間 | 仮想訪問の平均所要時間 | 4~8分 |
| ホットスポットのエンゲージメント率 | セッションごとにクリックされたホットスポットの割合 | >30% |
| リードの変換 | コンタクトに至ったセッションの割合 | 8-15% |
| 訪問の削減 | 無資格の健康診断の削減 | 50-60% |
警告: 著作権画像 360
パノラマ画像は著作権の対象です。契約上の合意があることを確認してください 素材を制作した写真家/エージェンシーとの合意を明確にします。利用規約に含まれるもの 画像のダウンロードと再配布に関するプラットフォームの明示的な制限。 技術的には、低解像度の透かしを適用し、WebGL キャンバスでの右クリックを無効にします。
結論と次のステップ
私たちは、標準的な Web テクノロジーである WebGL/Three.js に基づいた完全なバーチャル ツアー システムを構築しました。 レンダリング用、ユニバーサル埋め込み用のカスタム要素、最適化用のシャープ パイプライン 画像。このアプローチにより、高価な SaaS ベンダーへの依存が排除され、完全な ユーザーエクスペリエンスのパーソナライズ。
自然な次のステップは、によって生成されたフォトリアリスティックな 3D モデルとの統合です。 ガウス スプラッティング o NeRF (神経放射フィールド)、これは 単純なものから完全な 3D 再構築により、2025 ~ 2026 年に不動産ビジュアライゼーションに革命を起こす スマホで動画。







