otherplane 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/otherplane.mjs +489 -0
- package/engine/eslint.config.mjs +25 -0
- package/engine/next.config.ts +43 -0
- package/engine/package-lock.json +6848 -0
- package/engine/package.json +36 -0
- package/engine/postcss.config.mjs +5 -0
- package/engine/src/app/LandingRedirect.tsx +15 -0
- package/engine/src/app/[room]/RoomViewer.tsx +413 -0
- package/engine/src/app/[room]/page.tsx +30 -0
- package/engine/src/app/favicon.ico +0 -0
- package/engine/src/app/layout.tsx +45 -0
- package/engine/src/app/page.tsx +11 -0
- package/engine/src/app/providers.tsx +22 -0
- package/engine/src/components/controls/MobileHud.tsx +25 -0
- package/engine/src/components/controls/PlayerController.tsx +170 -0
- package/engine/src/components/controls/TouchLookController.tsx +93 -0
- package/engine/src/components/controls/VirtualStick.tsx +153 -0
- package/engine/src/components/edit/EditCapture.tsx +182 -0
- package/engine/src/components/edit/EditorPanel.tsx +265 -0
- package/engine/src/components/edit/Markers.tsx +91 -0
- package/engine/src/components/hud/Button.tsx +228 -0
- package/engine/src/components/hud/ClickToPlay.tsx +13 -0
- package/engine/src/components/hud/ContentOverlay.tsx +44 -0
- package/engine/src/components/hud/NavHeader.module.css +24 -0
- package/engine/src/components/scene/Artifacts.tsx +85 -0
- package/engine/src/components/scene/Exits.tsx +92 -0
- package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
- package/engine/src/components/scene/WorldScene.tsx +164 -0
- package/engine/src/components/spark/SparkLayer.tsx +112 -0
- package/engine/src/components/spark/SplatWorld.tsx +156 -0
- package/engine/src/config/audio.ts +11 -0
- package/engine/src/data/editApi.ts +73 -0
- package/engine/src/data/presets.ts +34 -0
- package/engine/src/data/room.ts +100 -0
- package/engine/src/data/site.ts +50 -0
- package/engine/src/data/universeconfig.ts +19 -0
- package/engine/src/icons/ArrowLeft.tsx +20 -0
- package/engine/src/icons/ChevronDown.tsx +23 -0
- package/engine/src/icons/ChevronLeft.tsx +22 -0
- package/engine/src/icons/Home.tsx +22 -0
- package/engine/src/icons/Spinner.module.css +13 -0
- package/engine/src/icons/Spinner.tsx +28 -0
- package/engine/src/icons/VolumeMax.tsx +21 -0
- package/engine/src/icons/VolumeX.tsx +22 -0
- package/engine/src/icons/icons.interface.ts +7 -0
- package/engine/src/icons/index.ts +27 -0
- package/engine/src/physics/RapierProvider.tsx +302 -0
- package/engine/src/physics/index.ts +2 -0
- package/engine/src/physics/types.ts +9 -0
- package/engine/src/providers/audio.tsx +215 -0
- package/engine/src/providers/edit.tsx +357 -0
- package/engine/src/providers/pointerLock.tsx +88 -0
- package/engine/src/styles/globals.css +88 -0
- package/engine/tailwind.config.js +184 -0
- package/engine/tsconfig.json +27 -0
- package/otherplane.config.example.json +6 -0
- package/package.json +56 -0
- package/schema/room.schema.json +77 -0
- package/scripts/gen_world.py +147 -0
- package/skill.md +94 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Artifacts. Lives inside the Canvas. Detects when the player is near an artifact
|
|
4
|
+
// AND looking at it (gaze from the camera/eye, 3D — artifacts can be above/below
|
|
5
|
+
// eye level), reports it active so a DOM hint can show, and opens its URL when E
|
|
6
|
+
// is pressed. The doorway/object in the splat IS the artifact — no visual marker.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
9
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import { useRapierWorld } from '@/physics';
|
|
12
|
+
import { usePointerLock } from '@/providers/pointerLock';
|
|
13
|
+
import type { Artifact } from '@/data/room';
|
|
14
|
+
|
|
15
|
+
const INTERACT_CODE = 'KeyE';
|
|
16
|
+
const GAZE_DOT = Math.cos((45 * Math.PI) / 180); // within ~45° of looking at it
|
|
17
|
+
|
|
18
|
+
export default function Artifacts({
|
|
19
|
+
artifacts,
|
|
20
|
+
onOpen,
|
|
21
|
+
onActiveChange,
|
|
22
|
+
}: {
|
|
23
|
+
artifacts: Artifact[];
|
|
24
|
+
onOpen: (url: string) => void;
|
|
25
|
+
onActiveChange: (active: boolean) => void;
|
|
26
|
+
}) {
|
|
27
|
+
const { playerBody } = useRapierWorld();
|
|
28
|
+
const { camera } = useThree();
|
|
29
|
+
const { isLocked } = usePointerLock();
|
|
30
|
+
const activeRef = useRef<string | null>(null);
|
|
31
|
+
const fwd = useMemo(() => new THREE.Vector3(), []);
|
|
32
|
+
const eye = useMemo(() => new THREE.Vector3(), []);
|
|
33
|
+
|
|
34
|
+
const setActive = useMemo(
|
|
35
|
+
() => (url: string | null) => {
|
|
36
|
+
if (activeRef.current !== url) {
|
|
37
|
+
activeRef.current = url;
|
|
38
|
+
onActiveChange(url != null);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
[onActiveChange],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useFrame(() => {
|
|
45
|
+
if (!playerBody || artifacts.length === 0 || !isLocked) {
|
|
46
|
+
setActive(null);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
camera.getWorldPosition(eye);
|
|
50
|
+
camera.getWorldDirection(fwd); // normalized
|
|
51
|
+
|
|
52
|
+
let active: string | null = null;
|
|
53
|
+
for (const a of artifacts) {
|
|
54
|
+
const r = a.radius || 1.0;
|
|
55
|
+
const dx = a.pos[0] - eye.x;
|
|
56
|
+
const dy = a.pos[1] - eye.y;
|
|
57
|
+
const dz = a.pos[2] - eye.z;
|
|
58
|
+
const dist2 = dx * dx + dy * dy + dz * dz;
|
|
59
|
+
if (dist2 > r * r) continue;
|
|
60
|
+
const dLen = Math.sqrt(dist2) || 1;
|
|
61
|
+
const dot = (dx / dLen) * fwd.x + (dy / dLen) * fwd.y + (dz / dLen) * fwd.z;
|
|
62
|
+
if (dot >= GAZE_DOT) {
|
|
63
|
+
active = a.url;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
setActive(active);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Interact key opens the active artifact's URL.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const onKey = (e: KeyboardEvent) => {
|
|
73
|
+
if (e.code !== INTERACT_CODE) return;
|
|
74
|
+
const url = activeRef.current;
|
|
75
|
+
if (url) {
|
|
76
|
+
setActive(null);
|
|
77
|
+
onOpen(url);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
window.addEventListener('keydown', onKey);
|
|
81
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
82
|
+
}, [onOpen, setActive]);
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Exits. Lives inside the Canvas. Given the current room's exits, it (1) detects
|
|
4
|
+
// when the player is near an exit AND looking at it, reporting it as "active" so a
|
|
5
|
+
// DOM hint ("Press E") can show, and (2) follows the exit's link when E is pressed
|
|
6
|
+
// while one is active. No visual marker — the doorway in the splat IS the exit.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
9
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import { useRapierWorld } from '@/physics';
|
|
12
|
+
import { usePointerLock } from '@/providers/pointerLock';
|
|
13
|
+
import type { Exit } from '@/data/room';
|
|
14
|
+
|
|
15
|
+
const INTERACT_CODE = 'KeyE';
|
|
16
|
+
const GAZE_DOT = Math.cos((45 * Math.PI) / 180); // within ~45° of looking at it
|
|
17
|
+
|
|
18
|
+
export default function Exits({
|
|
19
|
+
exits,
|
|
20
|
+
onExit,
|
|
21
|
+
onActiveChange,
|
|
22
|
+
}: {
|
|
23
|
+
exits: Exit[];
|
|
24
|
+
onExit: (to: string) => void;
|
|
25
|
+
onActiveChange: (active: boolean) => void;
|
|
26
|
+
}) {
|
|
27
|
+
const { playerBody } = useRapierWorld();
|
|
28
|
+
const { camera } = useThree();
|
|
29
|
+
const { isLocked } = usePointerLock();
|
|
30
|
+
const activeRef = useRef<string | null>(null);
|
|
31
|
+
const armAt = useRef(0); // suppress interaction until performance.now() >= this
|
|
32
|
+
const fwd = useMemo(() => new THREE.Vector3(), []);
|
|
33
|
+
|
|
34
|
+
const setActive = useMemo(
|
|
35
|
+
() => (to: string | null) => {
|
|
36
|
+
if (activeRef.current !== to) {
|
|
37
|
+
activeRef.current = to;
|
|
38
|
+
onActiveChange(to != null);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
[onActiveChange],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// On room switch, clear the active exit and suppress interaction for a beat
|
|
45
|
+
// while the player settles into the new room.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
armAt.current = performance.now() + 600;
|
|
48
|
+
setActive(null);
|
|
49
|
+
}, [exits, setActive]);
|
|
50
|
+
|
|
51
|
+
useFrame(() => {
|
|
52
|
+
if (!playerBody || exits.length === 0 || !isLocked || performance.now() < armAt.current) {
|
|
53
|
+
setActive(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const p = playerBody.translation();
|
|
57
|
+
camera.getWorldDirection(fwd);
|
|
58
|
+
const fhLen = Math.hypot(fwd.x, fwd.z) || 1;
|
|
59
|
+
|
|
60
|
+
let active: string | null = null;
|
|
61
|
+
for (const e of exits) {
|
|
62
|
+
const r = e.radius || 1.3;
|
|
63
|
+
const dx = e.pos[0] - p.x;
|
|
64
|
+
const dz = e.pos[2] - p.z;
|
|
65
|
+
if (dx * dx + dz * dz > r * r) continue;
|
|
66
|
+
// gaze: is the exit roughly in front of where we're looking?
|
|
67
|
+
const dLen = Math.hypot(dx, dz) || 1;
|
|
68
|
+
const dot = (dx / dLen) * (fwd.x / fhLen) + (dz / dLen) * (fwd.z / fhLen);
|
|
69
|
+
if (dot >= GAZE_DOT) {
|
|
70
|
+
active = e.to;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
setActive(active);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Interact key follows the active exit's link.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const onKey = (e: KeyboardEvent) => {
|
|
80
|
+
if (e.code !== INTERACT_CODE) return;
|
|
81
|
+
const to = activeRef.current;
|
|
82
|
+
if (to && performance.now() >= armAt.current) {
|
|
83
|
+
setActive(null);
|
|
84
|
+
onExit(to);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
window.addEventListener('keydown', onKey);
|
|
88
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
89
|
+
}, [onExit, setActive]);
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useThree } from '@react-three/fiber';
|
|
5
|
+
import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';
|
|
6
|
+
import { usePointerLockRegistration } from '@/providers/pointerLock';
|
|
7
|
+
|
|
8
|
+
export default function PointerLockBridge() {
|
|
9
|
+
const { camera, gl } = useThree();
|
|
10
|
+
const { register } = usePointerLockRegistration();
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const controls = new PointerLockControls(camera, gl.domElement);
|
|
14
|
+
|
|
15
|
+
// optional tuning
|
|
16
|
+
controls.pointerSpeed = 1.0;
|
|
17
|
+
controls.minPolarAngle = 0; // looking straight up/down clamp
|
|
18
|
+
controls.maxPolarAngle = Math.PI; // leave default free
|
|
19
|
+
|
|
20
|
+
register(controls);
|
|
21
|
+
return () => {
|
|
22
|
+
register(null);
|
|
23
|
+
controls.dispose();
|
|
24
|
+
};
|
|
25
|
+
}, [camera, gl, register]);
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useRef } from 'react';
|
|
4
|
+
import { Canvas } from '@react-three/fiber';
|
|
5
|
+
|
|
6
|
+
import SparkLayer from '@/components/spark/SparkLayer';
|
|
7
|
+
import SplatWorld from '@/components/spark/SplatWorld';
|
|
8
|
+
import PlayerController from '@/components/controls/PlayerController';
|
|
9
|
+
import PointerLockBridge from '@/components/scene/PointerLockBridge';
|
|
10
|
+
import TouchLookController from '@/components/controls/TouchLookController';
|
|
11
|
+
import EditCapture from '@/components/edit/EditCapture';
|
|
12
|
+
import Markers from '@/components/edit/Markers';
|
|
13
|
+
import Exits from '@/components/scene/Exits';
|
|
14
|
+
import ArtifactsLayer from '@/components/scene/Artifacts';
|
|
15
|
+
import type { WorldDef } from '@/data/presets';
|
|
16
|
+
import type { Exit, Artifact } from '@/data/room';
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
world: WorldDef;
|
|
20
|
+
playerMoveSpeed?: number;
|
|
21
|
+
onLoadingChange?: (isLoading: boolean, error?: string) => void;
|
|
22
|
+
mobileInputRef?: React.MutableRefObject<{x:number;y:number}>;
|
|
23
|
+
exits?: Exit[];
|
|
24
|
+
onExit?: (to: string) => void;
|
|
25
|
+
onActiveExitChange?: (active: boolean) => void;
|
|
26
|
+
artifacts?: Artifact[];
|
|
27
|
+
onArtifactOpen?: (url: string) => void;
|
|
28
|
+
onActiveArtifactChange?: (active: boolean) => void;
|
|
29
|
+
spawnYaw?: number;
|
|
30
|
+
spawnKey?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function SceneInner({
|
|
34
|
+
world,
|
|
35
|
+
playerMoveSpeed,
|
|
36
|
+
onLoadingChange,
|
|
37
|
+
mobileInputRef,
|
|
38
|
+
exits,
|
|
39
|
+
onExit,
|
|
40
|
+
onActiveExitChange,
|
|
41
|
+
artifacts,
|
|
42
|
+
onArtifactOpen,
|
|
43
|
+
onActiveArtifactChange,
|
|
44
|
+
spawnYaw,
|
|
45
|
+
spawnKey }: Props) {
|
|
46
|
+
const localMobileInputRef = useRef<{x:number;y:number}>({x:0,y:0});
|
|
47
|
+
const inputRef = mobileInputRef || localMobileInputRef;
|
|
48
|
+
|
|
49
|
+
const handleLoadingChange = useCallback((loading: boolean, error?: string) => {
|
|
50
|
+
onLoadingChange?.(loading, error);
|
|
51
|
+
}, [onLoadingChange]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{/* FPS-style player controls */}
|
|
56
|
+
<PlayerController
|
|
57
|
+
mobileInputRef={inputRef}
|
|
58
|
+
moveSpeed={playerMoveSpeed}
|
|
59
|
+
spawnYaw={spawnYaw}
|
|
60
|
+
spawnKey={spawnKey}
|
|
61
|
+
/>
|
|
62
|
+
|
|
63
|
+
{/* Edit-mode marking capture + marker orbs (no-op unless edit mode) */}
|
|
64
|
+
<EditCapture />
|
|
65
|
+
<Markers />
|
|
66
|
+
|
|
67
|
+
{/* Exits → links to other rooms */}
|
|
68
|
+
{exits && onExit && (
|
|
69
|
+
<Exits
|
|
70
|
+
exits={exits}
|
|
71
|
+
onExit={onExit}
|
|
72
|
+
onActiveChange={onActiveExitChange ?? (() => {})}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Artifacts → open a web URL in an overlay */}
|
|
77
|
+
{artifacts && onArtifactOpen && (
|
|
78
|
+
<ArtifactsLayer
|
|
79
|
+
artifacts={artifacts}
|
|
80
|
+
onOpen={onArtifactOpen}
|
|
81
|
+
onActiveChange={onActiveArtifactChange ?? (() => {})}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Touch-based camera look for mobile */}
|
|
86
|
+
<TouchLookController />
|
|
87
|
+
|
|
88
|
+
{/* Spark renderer + the current Splat world */}
|
|
89
|
+
<SparkLayer />
|
|
90
|
+
<SplatWorld
|
|
91
|
+
key={world.url}
|
|
92
|
+
url={world.url}
|
|
93
|
+
position={world.position}
|
|
94
|
+
quaternion={world.quaternion}
|
|
95
|
+
scale={world.scale}
|
|
96
|
+
onLoadingChange={handleLoadingChange}
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
{/* Usual lighting for mesh-based objects */}
|
|
100
|
+
<ambientLight intensity={0.5} />
|
|
101
|
+
<directionalLight position={[5, 10, 5]} intensity={0.8} />
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default function WorldScene({
|
|
107
|
+
world,
|
|
108
|
+
playerMoveSpeed,
|
|
109
|
+
onLoadingChange,
|
|
110
|
+
mobileInputRef,
|
|
111
|
+
exits,
|
|
112
|
+
onExit,
|
|
113
|
+
onActiveExitChange,
|
|
114
|
+
artifacts,
|
|
115
|
+
onArtifactOpen,
|
|
116
|
+
onActiveArtifactChange,
|
|
117
|
+
spawnYaw,
|
|
118
|
+
spawnKey }: Props) {
|
|
119
|
+
const handleLoadingChange = useCallback((loading: boolean, error?: string) => {
|
|
120
|
+
onLoadingChange?.(loading, error);
|
|
121
|
+
}, [onLoadingChange]);
|
|
122
|
+
|
|
123
|
+
// Cap DPR more aggressively on mobile for performance
|
|
124
|
+
const dprCap = typeof window !== 'undefined' && matchMedia('(pointer: coarse)').matches ? 1.0 : 1.5;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="canvas-root absolute inset-0">
|
|
128
|
+
<Canvas
|
|
129
|
+
// Spark guidance: leave antialias off for better performance with splats
|
|
130
|
+
gl={{
|
|
131
|
+
antialias: false,
|
|
132
|
+
// Avoid preserveDrawingBuffer; it increases memory pressure
|
|
133
|
+
preserveDrawingBuffer: false,
|
|
134
|
+
// Enable context loss recovery
|
|
135
|
+
failIfMajorPerformanceCaveat: false,
|
|
136
|
+
// Power preference for better compatibility
|
|
137
|
+
powerPreference: "high-performance"
|
|
138
|
+
}}
|
|
139
|
+
dpr={[1, dprCap]}
|
|
140
|
+
// Disable shadows for now to reduce GPU pressure
|
|
141
|
+
shadows={false}
|
|
142
|
+
camera={{ fov: 60, near: 0.1, far: 1000, position: [0, 1.2, 3] }}
|
|
143
|
+
>
|
|
144
|
+
<PointerLockBridge />
|
|
145
|
+
<TouchLookController />
|
|
146
|
+
|
|
147
|
+
<SceneInner
|
|
148
|
+
world={world}
|
|
149
|
+
playerMoveSpeed={playerMoveSpeed}
|
|
150
|
+
onLoadingChange={handleLoadingChange}
|
|
151
|
+
mobileInputRef={mobileInputRef}
|
|
152
|
+
exits={exits}
|
|
153
|
+
onExit={onExit}
|
|
154
|
+
onActiveExitChange={onActiveExitChange}
|
|
155
|
+
artifacts={artifacts}
|
|
156
|
+
onArtifactOpen={onArtifactOpen}
|
|
157
|
+
onActiveArtifactChange={onActiveArtifactChange}
|
|
158
|
+
spawnYaw={spawnYaw}
|
|
159
|
+
spawnKey={spawnKey}
|
|
160
|
+
/>
|
|
161
|
+
</Canvas>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import { useThree } from '@react-three/fiber';
|
|
5
|
+
import type { SparkRenderer } from '@sparkjsdev/spark';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adds a SparkRenderer and parents it to the camera.
|
|
9
|
+
* Per docs, putting SparkRenderer under the camera improves precision across large scenes.
|
|
10
|
+
*/
|
|
11
|
+
export default function SparkLayer() {
|
|
12
|
+
const { gl, camera, scene, invalidate } = useThree();
|
|
13
|
+
const sparkRef = useRef<SparkRenderer | null>(null);
|
|
14
|
+
// Keep a symbol on WebGLRenderer to avoid double SparkRenderer attachment
|
|
15
|
+
const SPARK_TAG = '__spark_attached__' as const;
|
|
16
|
+
const contextLostRef = useRef(false);
|
|
17
|
+
|
|
18
|
+
const initializeSparkRenderer = useCallback(async () => {
|
|
19
|
+
try {
|
|
20
|
+
// Clean up existing renderer if it exists
|
|
21
|
+
if (sparkRef.current) {
|
|
22
|
+
camera.remove(sparkRef.current);
|
|
23
|
+
sparkRef.current = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Ensure we only attach one SparkRenderer per WebGLRenderer
|
|
27
|
+
if ((gl as unknown as Record<string, unknown>)[SPARK_TAG]) {
|
|
28
|
+
console.log('SparkRenderer already attached to this WebGLRenderer');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Create SparkRenderer with the existing WebGL context (conservative settings)
|
|
33
|
+
const { SparkRenderer } = await import('@sparkjsdev/spark');
|
|
34
|
+
const spark = new SparkRenderer({
|
|
35
|
+
renderer: gl,
|
|
36
|
+
// Lower splat pixel radius to reduce overdraw and memory pressure
|
|
37
|
+
maxPixelRadius: 256,
|
|
38
|
+
// Slightly reduce kernel width to limit very large splats
|
|
39
|
+
maxStdDev: Math.sqrt(6),
|
|
40
|
+
// Keep alpha threshold modest to limit long tails
|
|
41
|
+
minAlpha: 1 / 255,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
sparkRef.current = spark;
|
|
45
|
+
camera.add(spark); // follows camera; improves float16 locality
|
|
46
|
+
(gl as unknown as Record<string, unknown>)[SPARK_TAG] = true;
|
|
47
|
+
|
|
48
|
+
// ensure camera is in the scene graph
|
|
49
|
+
if (!camera.parent) scene.add(camera);
|
|
50
|
+
|
|
51
|
+
console.log('SparkRenderer initialized successfully');
|
|
52
|
+
// Log GL caps/extensions for diagnostics
|
|
53
|
+
try {
|
|
54
|
+
const webgl2 = (gl.domElement as HTMLCanvasElement).getContext('webgl2') as WebGL2RenderingContext | null;
|
|
55
|
+
const webgl = webgl2 ?? ((gl.domElement as HTMLCanvasElement).getContext('webgl') as WebGLRenderingContext | null);
|
|
56
|
+
const glCtx: WebGLRenderingContext | WebGL2RenderingContext | null = webgl;
|
|
57
|
+
const debugInfo = glCtx ? {
|
|
58
|
+
version: glCtx.getParameter(glCtx.VERSION),
|
|
59
|
+
shadingLanguageVersion: glCtx.getParameter(glCtx.SHADING_LANGUAGE_VERSION),
|
|
60
|
+
vendor: glCtx.getParameter(glCtx.VENDOR),
|
|
61
|
+
renderer: glCtx.getParameter(glCtx.RENDERER),
|
|
62
|
+
maxTextureSize: gl.capabilities.maxTextureSize,
|
|
63
|
+
isWebGL2: 'bindBufferBase' in glCtx,
|
|
64
|
+
} : null;
|
|
65
|
+
console.log('GL diagnostics', debugInfo);
|
|
66
|
+
} catch {}
|
|
67
|
+
contextLostRef.current = false;
|
|
68
|
+
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('Failed to initialize SparkRenderer:', error);
|
|
71
|
+
}
|
|
72
|
+
}, [gl, camera, scene]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
initializeSparkRenderer();
|
|
76
|
+
|
|
77
|
+
// Add WebGL context loss handlers
|
|
78
|
+
const handleContextLost = (event: Event) => {
|
|
79
|
+
console.warn('WebGL context lost, preventing default behavior');
|
|
80
|
+
contextLostRef.current = true;
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleContextRestored = () => {
|
|
85
|
+
console.log('WebGL context restored, reinitializing...');
|
|
86
|
+
// Small delay to ensure context is fully restored
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
initializeSparkRenderer();
|
|
89
|
+
invalidate(); // Force a re-render
|
|
90
|
+
// Dispatch a custom event to notify other components
|
|
91
|
+
window.dispatchEvent(new CustomEvent('webgl-context-restored'));
|
|
92
|
+
}, 100);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
gl.domElement.addEventListener('webglcontextlost', handleContextLost);
|
|
96
|
+
gl.domElement.addEventListener('webglcontextrestored', handleContextRestored);
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
gl.domElement.removeEventListener('webglcontextlost', handleContextLost);
|
|
100
|
+
gl.domElement.removeEventListener('webglcontextrestored', handleContextRestored);
|
|
101
|
+
|
|
102
|
+
if (sparkRef.current) {
|
|
103
|
+
camera.remove(sparkRef.current);
|
|
104
|
+
sparkRef.current = null;
|
|
105
|
+
}
|
|
106
|
+
// Clear the tag so a new SparkRenderer can be attached after dispose
|
|
107
|
+
delete (gl as unknown as Record<string, unknown>)[SPARK_TAG];
|
|
108
|
+
};
|
|
109
|
+
}, [gl, camera, scene, initializeSparkRenderer, invalidate]);
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
import { useThree } from '@react-three/fiber';
|
|
5
|
+
import type { SplatMesh } from '@sparkjsdev/spark';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
url: string;
|
|
9
|
+
position?: [number, number, number];
|
|
10
|
+
quaternion?: [number, number, number, number]; // x,y,z,w
|
|
11
|
+
scale?: number;
|
|
12
|
+
onLoadingChange?: (isLoading: boolean, error?: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Imperatively adds a SplatMesh to the scene (simplest interop).
|
|
17
|
+
* You could also "extend" Spark classes to use JSX <primitive />, but this is robust & minimal.
|
|
18
|
+
*/
|
|
19
|
+
export default function SplatWorld({ url, position = [0, 0, 0], quaternion, scale = 1, onLoadingChange }: Props) {
|
|
20
|
+
const { scene } = useThree();
|
|
21
|
+
const meshRef = useRef<SplatMesh | null>(null);
|
|
22
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
23
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
const loadSplatMesh = useCallback(async () => {
|
|
26
|
+
// token to ignore stale async completions
|
|
27
|
+
const loadToken = crypto.randomUUID();
|
|
28
|
+
(loadSplatMesh as unknown as { currentToken?: string }).currentToken = loadToken;
|
|
29
|
+
|
|
30
|
+
setLoadError(null);
|
|
31
|
+
setIsLoading(true);
|
|
32
|
+
|
|
33
|
+
// Clean up existing mesh first
|
|
34
|
+
if (meshRef.current) {
|
|
35
|
+
scene.remove(meshRef.current);
|
|
36
|
+
try {
|
|
37
|
+
meshRef.current.dispose?.();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.warn('Error disposing existing splat mesh:', error);
|
|
40
|
+
}
|
|
41
|
+
meshRef.current = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const t0 = performance.now();
|
|
46
|
+
console.log(`Loading splat mesh from: ${url}`);
|
|
47
|
+
|
|
48
|
+
// Check if URL is accessible (for local files)
|
|
49
|
+
let abortController: AbortController | null = null;
|
|
50
|
+
if (url.startsWith('/')) {
|
|
51
|
+
try {
|
|
52
|
+
abortController = new AbortController();
|
|
53
|
+
const response = await fetch(url, { method: 'HEAD', signal: abortController.signal });
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error(`Failed to load ${url}: ${response.status} ${response.statusText}`);
|
|
56
|
+
}
|
|
57
|
+
} catch (fetchError) {
|
|
58
|
+
console.error('Failed to fetch splat file:', fetchError);
|
|
59
|
+
setLoadError(`Failed to load splat file: ${url}`);
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// If a newer call started, bail out before heavy work
|
|
66
|
+
if ((loadSplatMesh as unknown as { currentToken?: string }).currentToken !== loadToken) {
|
|
67
|
+
abortController?.abort();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { SplatMesh } = await import('@sparkjsdev/spark');
|
|
72
|
+
const mesh = new SplatMesh({ url });
|
|
73
|
+
meshRef.current = mesh;
|
|
74
|
+
const t1 = performance.now();
|
|
75
|
+
// Wait for mesh to be initialized before setting transform
|
|
76
|
+
try {
|
|
77
|
+
await mesh.initialized;
|
|
78
|
+
const t2 = performance.now();
|
|
79
|
+
|
|
80
|
+
// If a newer load started or component unmounted, dispose and stop
|
|
81
|
+
if ((loadSplatMesh as unknown as { currentToken?: string }).currentToken !== loadToken || !meshRef.current) {
|
|
82
|
+
try { mesh.dispose?.(); } catch {}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('Splat mesh initialized successfully', { createMs: Math.round(t1 - t0), initMs: Math.round(t2 - t1) });
|
|
87
|
+
|
|
88
|
+
mesh.position.set(...position);
|
|
89
|
+
if (quaternion) {
|
|
90
|
+
mesh.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
|
|
91
|
+
}
|
|
92
|
+
if (scale !== 1) {
|
|
93
|
+
mesh.scale.setScalar(scale);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
scene.add(mesh);
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
|
|
99
|
+
} catch (initError) {
|
|
100
|
+
console.error('Failed to initialize splat mesh:', initError);
|
|
101
|
+
setLoadError(`Failed to initialize splat mesh: ${initError instanceof Error ? initError.message : 'Unknown error'}`);
|
|
102
|
+
setIsLoading(false);
|
|
103
|
+
try { mesh.dispose?.(); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error creating splat mesh:', error);
|
|
108
|
+
setLoadError(`Error loading splat: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
109
|
+
setIsLoading(false);
|
|
110
|
+
}
|
|
111
|
+
}, [scene, url, position, quaternion, scale]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
loadSplatMesh();
|
|
115
|
+
|
|
116
|
+
// Listen for WebGL context restoration
|
|
117
|
+
const handleContextRestored = () => {
|
|
118
|
+
console.log('SplatWorld: WebGL context restored, reloading splat mesh');
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
loadSplatMesh();
|
|
121
|
+
}, 200); // Small delay to ensure SparkRenderer is ready
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
window.addEventListener('webgl-context-restored', handleContextRestored);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
// Invalidate any in-flight load calls by bumping the token
|
|
128
|
+
(loadSplatMesh as unknown as { currentToken?: string }).currentToken = crypto.randomUUID();
|
|
129
|
+
window.removeEventListener('webgl-context-restored', handleContextRestored);
|
|
130
|
+
if (meshRef.current) {
|
|
131
|
+
scene.remove(meshRef.current);
|
|
132
|
+
// Free GPU memory
|
|
133
|
+
try {
|
|
134
|
+
meshRef.current.dispose?.();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.warn('Error disposing splat mesh:', error);
|
|
137
|
+
}
|
|
138
|
+
meshRef.current = null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}, [loadSplatMesh, scene]);
|
|
142
|
+
|
|
143
|
+
// Log loading state for debugging and notify parent
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (loadError) {
|
|
146
|
+
console.error('SplatWorld load error:', loadError);
|
|
147
|
+
} else if (!isLoading) {
|
|
148
|
+
console.log('SplatWorld loaded successfully');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Notify parent component about loading state changes
|
|
152
|
+
onLoadingChange?.(isLoading, loadError || undefined);
|
|
153
|
+
}, [loadError, isLoading, onLoadingChange]);
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Audio configuration
|
|
2
|
+
export const AUDIO_CONFIG = {
|
|
3
|
+
MUSIC_VOLUME: 0.3, // 0.0 to 1.0
|
|
4
|
+
MUSIC_FILES: {
|
|
5
|
+
SUNLIT_GROVE: '/music/Sunlit_Grove_Ambient.mp3',
|
|
6
|
+
},
|
|
7
|
+
// Add more configuration as needed
|
|
8
|
+
FADE_DURATION: 1.0, // seconds for fade in/out
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type MusicTrack = keyof typeof AUDIO_CONFIG.MUSIC_FILES;
|