Files
MineGeo/components/Panorama.tsx

199 lines
6.9 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Plus, Minus, RotateCcw } from "lucide-react";
import * as THREE from "three";
const textureCache = new Map<string, Promise<THREE.Texture>>();
export function preloadFaces(urls: string[]) {
return Promise.all(
urls.map((url) => {
if (!textureCache.has(url))
textureCache.set(
url,
new THREE.TextureLoader()
.loadAsync(url)
.then((t) => {
t.colorSpace = THREE.SRGBColorSpace;
return t;
})
.catch((e) => {
textureCache.delete(url);
throw e;
}),
);
return textureCache.get(url)!;
}),
);
}
// Explicit projection matches the six positive-FOV camera images emitted by
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection;
void main(){vec3 d=normalize(vDirection);vec3 a=abs(d);vec2 uv;vec4 color;if(a.x>=a.y&&a.x>=a.z){if(d.x>0.0){uv=vec2(d.z,d.y)/a.x*.5+.5;color=texture2D(px,uv);}else{uv=vec2(-d.z,d.y)/a.x*.5+.5;color=texture2D(nx,uv);}}else if(a.y>=a.z){if(d.y>0.0){uv=vec2(-d.x,-d.z)/a.y*.5+.5;color=texture2D(py,uv);}else{uv=vec2(-d.x,d.z)/a.y*.5+.5;color=texture2D(ny,uv);}}else{if(d.z>0.0){uv=vec2(-d.x,d.y)/a.z*.5+.5;color=texture2D(pz,uv);}else{uv=vec2(d.x,d.y)/a.z*.5+.5;color=texture2D(nz,uv);}}gl_FragColor=color;
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`;
export function Panorama({
urls,
zoom,
onReady,
onError,
}: {
urls: string[];
zoom: boolean;
onReady: () => void;
onError: (message: string) => void;
}) {
const mount = useRef<HTMLDivElement>(null),
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
[fov, setFov] = useState(80);
const callbacks = useRef({ onReady, onError });
callbacks.current = { onReady, onError };
useEffect(() => {
let disposed = false,
renderer: THREE.WebGLRenderer | undefined,
frame = 0,
observer: ResizeObserver | undefined;
let material: THREE.ShaderMaterial | undefined,
geometry: THREE.SphereGeometry | undefined;
const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => {
if (e.button !== 0) return;
element.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY };
},
move = (e: PointerEvent) => {
if (!drag) return;
view.current.yaw -= (e.clientX - drag.x) * 0.003;
view.current.pitch = Math.max(
-1.5,
Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003),
);
drag = { x: e.clientX, y: e.clientY };
},
up = () => {
drag = null;
},
wheel = (e: WheelEvent) => {
if (!zoom) return;
e.preventDefault();
view.current.fov = Math.max(
35,
Math.min(100, view.current.fov + e.deltaY * 0.04),
);
};
let drag: { x: number; y: number } | null = null;
preloadFaces(urls)
.then((textures) => {
if (disposed) return;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
element.appendChild(renderer.domElement);
const scene = new THREE.Scene(),
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
geometry = new THREE.SphereGeometry(5, 32, 16);
material = new THREE.ShaderMaterial({
side: THREE.BackSide,
uniforms: Object.fromEntries(
["px", "nx", "py", "ny", "pz", "nz"].map((f, i) => [
f,
{ value: textures[i] },
]),
),
vertexShader:
"varying vec3 vDirection;void main(){vDirection=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}",
fragmentShader: fragment,
});
scene.add(new THREE.Mesh(geometry, material));
const resize = () => {
camera.aspect = element.clientWidth / element.clientHeight;
camera.updateProjectionMatrix();
renderer!.setSize(element.clientWidth, element.clientHeight);
};
observer = new ResizeObserver(resize);
observer.observe(element);
resize();
function draw() {
if (disposed) return;
const v = view.current;
camera.fov = zoom ? v.fov : 80;
camera.updateProjectionMatrix();
camera.lookAt(
Math.sin(v.yaw) * Math.cos(v.pitch),
Math.sin(v.pitch),
-Math.cos(v.yaw) * Math.cos(v.pitch),
);
renderer!.render(scene, camera);
frame = requestAnimationFrame(draw);
}
draw();
element.addEventListener("pointerdown", down);
element.addEventListener("pointermove", move);
element.addEventListener("pointerup", up);
element.addEventListener("pointercancel", up);
element.addEventListener("wheel", wheel, { passive: false });
callbacks.current.onReady();
})
.catch((e) =>
callbacks.current.onError(
"Panorama could not load. " + (e instanceof Error ? e.message : ""),
),
);
return () => {
disposed = true;
cancelAnimationFrame(frame);
observer?.disconnect();
element.removeEventListener("pointerdown", down);
element.removeEventListener("pointermove", move);
element.removeEventListener("pointerup", up);
element.removeEventListener("pointercancel", up);
element.removeEventListener("wheel", wheel);
geometry?.dispose();
material?.dispose();
renderer?.dispose();
renderer?.domElement.remove();
};
}, [urls, zoom]);
useEffect(() => {
view.current.fov = fov;
}, [fov]);
return (
<>
<div
className="panorama"
ref={mount}
aria-label="360 degree panorama: drag to look around"
/>
{
<div className="panorama-controls">
{zoom && (
<>
<button
aria-label="Zoom in"
onClick={() => setFov((v) => Math.max(35, v - 10))}
>
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
onClick={() => setFov((v) => Math.min(100, v + 10))}
>
<Minus size={17} />
</button>
</>
)}
<button
aria-label="Reset view"
onClick={() => {
view.current = { yaw: 0, pitch: 0, fov: 80 };
setFov(80);
}}
>
<RotateCcw size={17} />
</button>
</div>
}
</>
);
}