Virtuele rondleidingen in onroerend goed: WebGL en 3D-webtechnologie
De statistieken spreken voor zich: eigendommen met virtuele rondleidingen ontvangen de 87% meer weergaven vergeleken met die met alleen statische foto's, en de bureaus die deze gebruiken, verminderen het aantal ongekwalificeerde fysieke bezoeken met 60% terwijl het aantal gekwalificeerde leads met 40% toeneemt. Toch bestaan de meeste implementaties vertrouwt op dure en weinig aanpasbare SaaS-oplossingen. In dit artikel gaan we een compleet systeem bouwen van virtuele rondleidingen door onroerend goed, volledig in de browser, waarbij u profiteert van WebGL, Drie.js en moderne webtechnologieën om meeslepende ervaringen te creëren die schaalbaar zijn en kunnen worden geïntegreerd met elk advertentieplatform.
Van het maken van 360 graden panorama’s tot het navigeren tussen ruimtes, van informatiehotspots tot metingen interactief: we behandelen elk technisch aspect met productieklare TypeScript-code.
Wat je gaat leren
- Architectuur van een WebGL-viewer voor virtuele rondleidingen met Three.js en React Three Fiber
- Gelijkhoekige panorama's laden en optimaliseren (360 foto's/video's)
- Multi-room navigatie met vloeiende overgangen en interactieve hotspots
- Interactieve metingen met raycasting en 3D-geometrie
- Prestatie-optimalisatie: LOD, texture streaming en lazyloading
- Integratie met WebXR voor VR/AR op compatibele apparaten
- CI/CD-pijplijn voor automatische 360-beeldverwerking
- Embedding en API-integratie met bestaande listingplatforms
Basisprincipes van WebGL en Three.js voor onroerend goed
WebGL en een JavaScript-API die rechtstreeks hoogwaardige 2D- en 3D-weergave mogelijk maakt in de browser zonder plug-ins, waarbij gebruik wordt gemaakt van de GPU van het apparaat. Drie.js abstraheert complexiteit van WebGL met een hoogwaardige API die scènes, camera's, lichten, materialen en geometrie beheert. Voor rondleidingen virtueel onroerend goed, het fundamentele patroon en de bolpanorama: een gigantische omgekeerde bol met de equirectangulaire textuur naar binnen geprojecteerd, met de camera in het midden.
Aanbevolen technologiestapel
- Drie.js 0.170+: kern 3D-weergave
- Reageer drie vezels: Declaratieve React-bindingen voor Three.js
- Drie: helpers en vooraf gebouwde abstracties (PointerLockControls, Html, etc.)
- GAP: vloeiende overgangsanimaties
- Panellum: lichtgewicht alternatief voor pure panorama's
- Scherp (Node.js): 360-beeldverwerking aan de serverzijde
- FFmpeg: 360 videoverwerking
Systeemarchitectuur
Een virtueel productietoursysteem bestaat uit drie verschillende niveaus: de verwerkingspijplijn server-side (conversie, optimalisatie, tegelgeneratie), de levering laag (CDN, streaming adaptief) en de rendering-engine cliëntzijde. Deze scheiding is essentieel om te garanderen optimale prestaties op elk apparaat.
// 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;
}
Implementatie van de Three.js Viewer
Het hart van het systeem is de WebGL-viewer. Laten we er een gebruiken SphereGeometry met grote straal,
omgekeerde geometrie (binnenwaartse textuur) e OrbitControls geconfigureerd om te simuleren
het gedrag van een panoramische camera (alleen rotatie, geen overmatig pannen/zoomen).
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();
}
}
Multiroom-navigatiesysteem met overgangen
Navigatie tussen kamers moet soepel en intuïtief zijn. Laten we een fade-out/fade-in-overgang implementeren geanimeerd en de volgende kamer op de achtergrond vooraf geladen om waargenomen wachttijden te elimineren.
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,
});
}
}
}
Interactieve hotspots en metingen
Hotspots zijn fundamentele elementen van de ervaring: ze laten je tussen kamers navigeren, showen contextuele informatie en, in meer geavanceerde implementaties, ruimtemetingen uitvoeren. We implementeren een straalgietsysteem voor nauwkeurige klikdetectie op de panoramische bol.
// 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)
);
}
}
Pijplijn voor beeldverwerking aan de serverzijde
De kwaliteit van de 360-beelden is van fundamenteel belang voor de ervaring. Laten we een Node.js-pijplijn implementeren die optimaliseert automatisch onbewerkte beelden van camera's zoals de Ricoh Theta Z1 of de Insta360 X4, waardoor meerdere resoluties worden gegenereerd voor adaptieve streaming.
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à
],
};
WebXR-integratie voor VR/AR
Integratie met WebXR-API kunnen gebruikers met compatibele headsets (Meta Quest, Apple Vision Pro via browser, HTC Vive) om jezelf volledig onder te dompelen in de woning. De toevoeging en verrassend eenvoudig met Three.js dankzij native renderer-ondersteuning.
// 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 en integratie met listingplatforms insluiten
Om de adoptie te maximaliseren, moet de kijker eenvoudig in elk platform kunnen worden geïntegreerd bestaande via een eenvoudige HTML-tag of een iframe. Laten we een standaardwebcomponent maken met de 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>
Prestatie-optimalisatie
Prestaties zijn van cruciaal belang: bij een tour die meer dan 3 seconden nodig heeft om te laden, verliest u 53% van de gebruikers (gegeven door Google). Hier zijn de fundamentele strategieën.
Optimalisatiestrategieën
- Adaptieve streamingtextuur: laad eerst de lage resolutie (200 KB) en vervang deze vervolgens door een hoge resolutie (5 MB)
- Slim voorladen: laadt vooraf de kamers die bereikbaar zijn vanaf de huidige kamer tijdens inactiviteit
- WebP met terugval: WebP vermindert 30-35% vergeleken met JPEG van dezelfde kwaliteit
- CDN met edge-caching: afbeeldingen geografisch distribueren (Cloudflare Images, AWS CloudFront)
- LOD (niveau van detail): lagere resolutie op mobiele apparaten op basis van devicePixelRatio
- GPU-instantie: gebruik voor meerdere hotspots InstancedMesh in plaats van afzonderlijke Sprites
- requestIdleCallback: voer preload alleen uit als de browser inactief is
// 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`);
Bedrijfsstatistieken en analyses
De echte waarde van virtuele tours wordt gemeten met concrete data. We integreren een analysesysteem tourspecifiek dat belangrijke statistieken bijhoudt voor verkoopteams voor onroerend goed.
| Metrisch | Definitie | Benchmarks |
|---|---|---|
| Voltooiingspercentage van de tour | % gebruikers dat minstens 3 kamers bezoekt | >45% |
| Gemiddelde sessietijd | Gemiddelde duur van virtueel bezoek | 4-8 minuten |
| Betrokkenheidspercentage hotspot | % hotspots waarop is geklikt per sessie | >30% |
| Leadconversie | % sessies die tot contact leiden | 8-15% |
| Vermindering van fysieke bezoeken | Vermindering van ongekwalificeerde fysieke bezoeken | 50-60% |
Waarschuwing: Copyright Images 360
Op panoramische afbeeldingen rust auteursrecht. Zorg ervoor dat u contractuele afspraken maakt duidelijk met de fotografen/bureaus die het materiaal produceren. Omvat in de gebruiksvoorwaarden van platform expliciete beperkingen op het downloaden en herdistribueren van afbeeldingen. Technisch gezien past het watermerken met een lage resolutie toe en wordt het klikken met de rechtermuisknop op het WebGL-canvas uitgeschakeld.
Conclusies en volgende stappen
We hebben een compleet virtueel toursysteem gebouwd op basis van standaard webtechnologieën: WebGL/Three.js voor weergave, aangepaste elementen voor universele inbedding, scherpe pijplijn voor optimalisatie afbeeldingen. Deze aanpak elimineert de afhankelijkheid van dure SaaS-leveranciers en maakt volledige personalisatie van de gebruikerservaring.
De logische volgende stap is integratie met fotorealistische 3D-modellen gegenereerd door Gaussiaanse splatting o NeRF (Neural Radiance Fields), die dat wel zijn Een revolutie in de visualisatie van onroerend goed in 2025-2026 met volledige 3D-reconstructies van eenvoudig filmpje met smartphone.







