r3f-scene-router 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/README.md +133 -0
- package/dist/index.d.ts +139 -0
- package/dist/index.js +203 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# r3f-scene-router
|
|
2
|
+
|
|
3
|
+
**Declarative scene router for React Three Fiber — one persistent Canvas, many scenes.**
|
|
4
|
+
|
|
5
|
+
Define your 3D scenes with camera positions, controls, and post-processing effects. The router handles smooth transitions between them while keeping the WebGL context alive.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
React Three Fiber apps typically face a choice:
|
|
10
|
+
- **One giant scene** with visibility toggling (complex, poor separation)
|
|
11
|
+
- **Multiple Canvases** (each remounts the WebGL context — expensive, flickery)
|
|
12
|
+
|
|
13
|
+
`r3f-scene-router` gives you the third option: **one Canvas, many scenes**, with declarative configuration for camera, controls, and effects per scene.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { SceneRouter, defineScenes } from "r3f-scene-router";
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
|
|
21
|
+
const scenes = defineScenes({
|
|
22
|
+
home: {
|
|
23
|
+
Component: HomeScene,
|
|
24
|
+
position: [0, 5, 10],
|
|
25
|
+
controls: "orbit",
|
|
26
|
+
postProcessing: { bloom: true, depthOfField: { focusDistance: 5 } }
|
|
27
|
+
},
|
|
28
|
+
gallery: {
|
|
29
|
+
Component: GalleryScene,
|
|
30
|
+
position: [0, 2, 0],
|
|
31
|
+
controls: "pointer-lock",
|
|
32
|
+
transition: { duration: 1.2 }
|
|
33
|
+
},
|
|
34
|
+
physics: {
|
|
35
|
+
Component: PhysicsScene,
|
|
36
|
+
position: [-20, 0, 0],
|
|
37
|
+
controls: "orbit",
|
|
38
|
+
postProcessing: { vignette: true }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function App() {
|
|
43
|
+
const [scene, setScene] = useState("home");
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<nav>
|
|
48
|
+
<button onClick={() => setScene("home")}>Home</button>
|
|
49
|
+
<button onClick={() => setScene("gallery")}>Gallery</button>
|
|
50
|
+
<button onClick={() => setScene("physics")}>Physics</button>
|
|
51
|
+
</nav>
|
|
52
|
+
<SceneRouter scenes={scenes} activeScene={scene} />
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- **Smooth camera transitions** — lerp between positions/rotations with configurable easing
|
|
61
|
+
- **Per-scene controls** — orbit, fly, trackball, pointer-lock, map, or none
|
|
62
|
+
- **Per-scene post-processing** — bloom, depth-of-field, vignette (composable)
|
|
63
|
+
- **Persistent Canvas** — WebGL context never remounts, no flicker
|
|
64
|
+
- **Type-safe** — full TypeScript with `SceneConfig` interface
|
|
65
|
+
- **Framework-agnostic routing** — bring your own URL strategy (Next.js, React Router, or plain state)
|
|
66
|
+
- **Hooks** — `useScene()` for accessing config/navigation inside scenes
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### `defineScenes(config)`
|
|
71
|
+
|
|
72
|
+
Helper to create a scene map with sensible defaults.
|
|
73
|
+
|
|
74
|
+
### `<SceneRouter>`
|
|
75
|
+
|
|
76
|
+
| Prop | Type | Default | Description |
|
|
77
|
+
|------|------|---------|-------------|
|
|
78
|
+
| `scenes` | `Map<string, SceneConfig>` | required | Scene definitions |
|
|
79
|
+
| `activeScene` | `string` | required | Currently active scene key |
|
|
80
|
+
| `fallback` | `string` | — | Scene to show if `activeScene` doesn't match |
|
|
81
|
+
| `defaultTransition` | `TransitionConfig` | `{ duration: 0.8 }` | Default camera transition |
|
|
82
|
+
| `defaultControls` | `ControlsType` | `"orbit"` | Default control scheme |
|
|
83
|
+
| `shadows` | `boolean` | `true` | Enable shadows |
|
|
84
|
+
|
|
85
|
+
### `SceneConfig`
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
interface SceneConfig {
|
|
89
|
+
Component: () => JSX.Element;
|
|
90
|
+
position?: [number, number, number];
|
|
91
|
+
rotation?: [number, number, number];
|
|
92
|
+
fov?: number;
|
|
93
|
+
controls?: "orbit" | "fly" | "trackball" | "head" | "pointer-lock" | "map" | "none";
|
|
94
|
+
postProcessing?: {
|
|
95
|
+
bloom?: boolean | { intensity?: number; radius?: number };
|
|
96
|
+
depthOfField?: boolean | { focusDistance?: number; focusRange?: number; bokehScale?: number };
|
|
97
|
+
vignette?: boolean | { offset?: number; darkness?: number };
|
|
98
|
+
};
|
|
99
|
+
transition?: { duration?: number; easing?: string };
|
|
100
|
+
camera?: "perspective" | "orthographic";
|
|
101
|
+
meta?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `useScene()`
|
|
106
|
+
|
|
107
|
+
Hook for scene components to access routing context:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
function MyScene() {
|
|
111
|
+
const { activeScene, config, navigate, isTransitioning } = useScene();
|
|
112
|
+
return <mesh onClick={() => navigate("other-scene")} />;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Integration with URL Routing
|
|
117
|
+
|
|
118
|
+
The router doesn't own the URL — you do. Wire it to any routing strategy:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
// Next.js
|
|
122
|
+
const scene = useRouter().query.view as string || "home";
|
|
123
|
+
|
|
124
|
+
// React Router
|
|
125
|
+
const { scene } = useParams();
|
|
126
|
+
|
|
127
|
+
// Plain state
|
|
128
|
+
const [scene, setScene] = useState("home");
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type ControlsType = "orbit" | "fly" | "trackball" | "head" | "pointer-lock" | "map" | "none";
|
|
5
|
+
interface PostProcessingConfig {
|
|
6
|
+
bloom?: boolean | {
|
|
7
|
+
intensity?: number;
|
|
8
|
+
radius?: number;
|
|
9
|
+
};
|
|
10
|
+
depthOfField?: boolean | {
|
|
11
|
+
focusDistance?: number;
|
|
12
|
+
focusRange?: number;
|
|
13
|
+
bokehScale?: number;
|
|
14
|
+
};
|
|
15
|
+
vignette?: boolean | {
|
|
16
|
+
offset?: number;
|
|
17
|
+
darkness?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
interface TransitionConfig {
|
|
21
|
+
/** Duration in seconds for camera position/rotation lerp */
|
|
22
|
+
duration?: number;
|
|
23
|
+
/** Easing function name */
|
|
24
|
+
easing?: "linear" | "easeInOut" | "easeIn" | "easeOut";
|
|
25
|
+
/** Whether to fade between scenes */
|
|
26
|
+
fade?: boolean;
|
|
27
|
+
/** Fade duration in seconds */
|
|
28
|
+
fadeDuration?: number;
|
|
29
|
+
}
|
|
30
|
+
interface SceneConfig {
|
|
31
|
+
/** The R3F component to render inside the canvas */
|
|
32
|
+
Component: () => JSX.Element;
|
|
33
|
+
/** Camera position [x, y, z] */
|
|
34
|
+
position?: [number, number, number];
|
|
35
|
+
/** Camera rotation [x, y, z] in radians */
|
|
36
|
+
rotation?: [number, number, number];
|
|
37
|
+
/** Camera field of view (perspective only) */
|
|
38
|
+
fov?: number;
|
|
39
|
+
/** Control scheme for this scene */
|
|
40
|
+
controls?: ControlsType;
|
|
41
|
+
/** Post-processing effects */
|
|
42
|
+
postProcessing?: PostProcessingConfig;
|
|
43
|
+
/** Transition configuration when entering this scene */
|
|
44
|
+
transition?: TransitionConfig;
|
|
45
|
+
/** Camera type */
|
|
46
|
+
camera?: "perspective" | "orthographic";
|
|
47
|
+
/** Optional scene-level metadata */
|
|
48
|
+
meta?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
interface SceneRouterProps {
|
|
51
|
+
/** Map of scene name → config */
|
|
52
|
+
scenes: Map<string, SceneConfig>;
|
|
53
|
+
/** The active scene key. Pass from your own URL/state management. */
|
|
54
|
+
activeScene: string;
|
|
55
|
+
/** Fallback scene if activeScene doesn't match */
|
|
56
|
+
fallback?: string;
|
|
57
|
+
/** Global default transition config */
|
|
58
|
+
defaultTransition?: TransitionConfig;
|
|
59
|
+
/** Global default controls type */
|
|
60
|
+
defaultControls?: ControlsType;
|
|
61
|
+
/** Callback when a scene component calls navigate() — use this to update your state/URL */
|
|
62
|
+
onNavigate?: (scene: string) => void;
|
|
63
|
+
/** Canvas props to forward */
|
|
64
|
+
shadows?: boolean;
|
|
65
|
+
/** Children rendered outside the scene (e.g., HUD, UI overlays) */
|
|
66
|
+
children?: ReactNode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The main scene router. Renders a persistent Canvas and swaps scene
|
|
71
|
+
* content, camera, controls, and post-processing based on activeScene.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```tsx
|
|
75
|
+
* import { SceneRouter, defineScenes } from "r3f-scene-router";
|
|
76
|
+
*
|
|
77
|
+
* const scenes = defineScenes({
|
|
78
|
+
* home: { Component: HomeScene, position: [0, 5, 10], controls: "orbit" },
|
|
79
|
+
* gallery: { Component: GalleryScene, controls: "pointer-lock" }
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* function App() {
|
|
83
|
+
* const [scene, setScene] = useState("home");
|
|
84
|
+
* return <SceneRouter scenes={scenes} activeScene={scene} />;
|
|
85
|
+
* }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
declare function SceneRouter({ scenes, activeScene, fallback, defaultTransition, defaultControls, onNavigate, shadows, children, }: SceneRouterProps): react_jsx_runtime.JSX.Element;
|
|
89
|
+
|
|
90
|
+
interface SceneContextValue {
|
|
91
|
+
activeScene: string;
|
|
92
|
+
config: SceneConfig | undefined;
|
|
93
|
+
navigate: (scene: string) => void;
|
|
94
|
+
isTransitioning: boolean;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Access the current scene configuration and navigation.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* function MyScene() {
|
|
102
|
+
* const { activeScene, navigate } = useScene();
|
|
103
|
+
* return <mesh onClick={() => navigate("other-scene")} />;
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
declare function useScene(): SceneContextValue;
|
|
108
|
+
/**
|
|
109
|
+
* Access transition state for building custom transitions.
|
|
110
|
+
*/
|
|
111
|
+
declare function useSceneTransition(): {
|
|
112
|
+
isTransitioning: boolean;
|
|
113
|
+
transition: TransitionConfig | undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Define scenes declaratively.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```tsx
|
|
121
|
+
* const scenes = defineScenes({
|
|
122
|
+
* home: {
|
|
123
|
+
* Component: HomeScene,
|
|
124
|
+
* position: [0, 5, 10],
|
|
125
|
+
* controls: "orbit",
|
|
126
|
+
* postProcessing: { bloom: true }
|
|
127
|
+
* },
|
|
128
|
+
* gallery: {
|
|
129
|
+
* Component: GalleryScene,
|
|
130
|
+
* position: [0, 2, 0],
|
|
131
|
+
* controls: "pointer-lock",
|
|
132
|
+
* postProcessing: { depthOfField: { focusDistance: 5 } }
|
|
133
|
+
* }
|
|
134
|
+
* });
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
declare function defineScenes(config: Record<string, SceneConfig>): Map<string, SceneConfig>;
|
|
138
|
+
|
|
139
|
+
export { type ControlsType, type PostProcessingConfig, type SceneConfig, SceneRouter, type SceneRouterProps, type TransitionConfig, defineScenes, useScene, useSceneTransition };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// src/scene-router.tsx
|
|
2
|
+
import { useState, useEffect, useRef as useRef2, useMemo, useCallback as useCallback2 } from "react";
|
|
3
|
+
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
|
4
|
+
import {
|
|
5
|
+
OrbitControls,
|
|
6
|
+
FlyControls,
|
|
7
|
+
TrackballControls,
|
|
8
|
+
MapControls,
|
|
9
|
+
PointerLockControls
|
|
10
|
+
} from "@react-three/drei";
|
|
11
|
+
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
|
|
14
|
+
// src/hooks.ts
|
|
15
|
+
import { createContext, useContext } from "react";
|
|
16
|
+
var SceneContext = createContext({
|
|
17
|
+
activeScene: "",
|
|
18
|
+
config: void 0,
|
|
19
|
+
navigate: () => {
|
|
20
|
+
},
|
|
21
|
+
isTransitioning: false
|
|
22
|
+
});
|
|
23
|
+
function useScene() {
|
|
24
|
+
return useContext(SceneContext);
|
|
25
|
+
}
|
|
26
|
+
function useSceneTransition() {
|
|
27
|
+
const { isTransitioning, config } = useContext(SceneContext);
|
|
28
|
+
return {
|
|
29
|
+
isTransitioning,
|
|
30
|
+
transition: config?.transition
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/scene-router.tsx
|
|
35
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
36
|
+
function SceneCamera({ config, transitionDuration, sceneKey }) {
|
|
37
|
+
const { camera } = useThree();
|
|
38
|
+
const startPos = useRef2(new THREE.Vector3());
|
|
39
|
+
const startRot = useRef2(new THREE.Euler());
|
|
40
|
+
const endPos = useRef2(new THREE.Vector3());
|
|
41
|
+
const endRot = useRef2(new THREE.Euler());
|
|
42
|
+
const lastPos = useRef2(new THREE.Vector3());
|
|
43
|
+
const lastRot = useRef2(new THREE.Euler());
|
|
44
|
+
const progress = useRef2(1);
|
|
45
|
+
const initialized = useRef2(false);
|
|
46
|
+
if (!initialized.current) {
|
|
47
|
+
const [x, y, z] = config.position || [0, 0, 5];
|
|
48
|
+
camera.position.set(x, y, z);
|
|
49
|
+
const worldPos = config.meta?.worldPosition || [0, 0, 0];
|
|
50
|
+
camera.lookAt(worldPos[0], worldPos[1], worldPos[2]);
|
|
51
|
+
lastPos.current.set(x, y, z);
|
|
52
|
+
lastRot.current.set(camera.rotation.x, camera.rotation.y, camera.rotation.z);
|
|
53
|
+
initialized.current = true;
|
|
54
|
+
}
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (progress.current === 1 && !initialized.current) return;
|
|
57
|
+
startPos.current.set(camera.position.x, camera.position.y, camera.position.z);
|
|
58
|
+
startRot.current.set(camera.rotation.x, camera.rotation.y, camera.rotation.z);
|
|
59
|
+
const [x, y, z] = config.position || [0, 0, 5];
|
|
60
|
+
endPos.current.set(x, y, z);
|
|
61
|
+
const worldPos = config.meta?.worldPosition || [0, 0, 0];
|
|
62
|
+
const tempCam = camera.clone();
|
|
63
|
+
tempCam.position.set(x, y, z);
|
|
64
|
+
tempCam.lookAt(worldPos[0], worldPos[1], worldPos[2]);
|
|
65
|
+
endRot.current.set(tempCam.rotation.x, tempCam.rotation.y, tempCam.rotation.z);
|
|
66
|
+
progress.current = 0;
|
|
67
|
+
}, [sceneKey]);
|
|
68
|
+
useFrame((_, delta) => {
|
|
69
|
+
if (progress.current >= 1) {
|
|
70
|
+
lastPos.current.set(camera.position.x, camera.position.y, camera.position.z);
|
|
71
|
+
lastRot.current.set(camera.rotation.x, camera.rotation.y, camera.rotation.z);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const step = transitionDuration > 0 ? delta / transitionDuration : 1;
|
|
75
|
+
progress.current = Math.min(1, progress.current + step);
|
|
76
|
+
const t = easeInOut(progress.current);
|
|
77
|
+
camera.position.x = startPos.current.x + (endPos.current.x - startPos.current.x) * t;
|
|
78
|
+
camera.position.y = startPos.current.y + (endPos.current.y - startPos.current.y) * t;
|
|
79
|
+
camera.position.z = startPos.current.z + (endPos.current.z - startPos.current.z) * t;
|
|
80
|
+
camera.rotation.x = startRot.current.x + (endRot.current.x - startRot.current.x) * t;
|
|
81
|
+
camera.rotation.y = startRot.current.y + (endRot.current.y - startRot.current.y) * t;
|
|
82
|
+
camera.rotation.z = startRot.current.z + (endRot.current.z - startRot.current.z) * t;
|
|
83
|
+
}, -1);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function easeInOut(t) {
|
|
87
|
+
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
88
|
+
}
|
|
89
|
+
function SceneControls({ type, enabled, target }) {
|
|
90
|
+
const [ready, setReady] = useState(false);
|
|
91
|
+
const { camera } = useThree();
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (enabled) {
|
|
94
|
+
const id = requestAnimationFrame(() => setReady(true));
|
|
95
|
+
return () => cancelAnimationFrame(id);
|
|
96
|
+
} else {
|
|
97
|
+
setReady(false);
|
|
98
|
+
}
|
|
99
|
+
}, [enabled]);
|
|
100
|
+
if (!ready) return null;
|
|
101
|
+
const orbitTarget = target || [0, 0, 0];
|
|
102
|
+
switch (type) {
|
|
103
|
+
case "orbit":
|
|
104
|
+
return /* @__PURE__ */ jsx(
|
|
105
|
+
OrbitControls,
|
|
106
|
+
{
|
|
107
|
+
target: orbitTarget,
|
|
108
|
+
enableDamping: false,
|
|
109
|
+
minPolarAngle: 0,
|
|
110
|
+
maxPolarAngle: Math.PI
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
case "fly":
|
|
114
|
+
return /* @__PURE__ */ jsx(FlyControls, { movementSpeed: 5, rollSpeed: 0.5 });
|
|
115
|
+
case "trackball":
|
|
116
|
+
return /* @__PURE__ */ jsx(TrackballControls, {});
|
|
117
|
+
case "map":
|
|
118
|
+
return /* @__PURE__ */ jsx(MapControls, { target: orbitTarget });
|
|
119
|
+
case "pointer-lock":
|
|
120
|
+
return /* @__PURE__ */ jsx(PointerLockControls, {});
|
|
121
|
+
case "head":
|
|
122
|
+
return /* @__PURE__ */ jsx(OrbitControls, {});
|
|
123
|
+
case "none":
|
|
124
|
+
default:
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function ScenePostProcessing({ config }) {
|
|
129
|
+
if (!config) return null;
|
|
130
|
+
const hasAnyEffect = config.bloom || config.depthOfField || config.vignette;
|
|
131
|
+
if (!hasAnyEffect) return null;
|
|
132
|
+
const bloomProps = config.bloom === true ? { intensity: 1, radius: 0 } : typeof config.bloom === "object" ? config.bloom : void 0;
|
|
133
|
+
const dofProps = config.depthOfField === true ? { focusDistance: 2, focusRange: 1, bokehScale: 5 } : typeof config.depthOfField === "object" ? config.depthOfField : void 0;
|
|
134
|
+
const vignetteProps = config.vignette === true ? { offset: 0.3, darkness: 0.7 } : typeof config.vignette === "object" ? config.vignette : void 0;
|
|
135
|
+
const effects = [];
|
|
136
|
+
if (dofProps) effects.push(/* @__PURE__ */ jsx(DepthOfField, { ...dofProps }, "dof"));
|
|
137
|
+
if (bloomProps) effects.push(/* @__PURE__ */ jsx(Bloom, { ...bloomProps }, "bloom"));
|
|
138
|
+
if (vignetteProps) effects.push(/* @__PURE__ */ jsx(Vignette, { ...vignetteProps }, "vignette"));
|
|
139
|
+
return /* @__PURE__ */ jsx(EffectComposer, { children: effects.filter(Boolean) });
|
|
140
|
+
}
|
|
141
|
+
function SceneRouter({
|
|
142
|
+
scenes,
|
|
143
|
+
activeScene,
|
|
144
|
+
fallback,
|
|
145
|
+
defaultTransition = { duration: 0.8, easing: "easeInOut" },
|
|
146
|
+
defaultControls = "orbit",
|
|
147
|
+
onNavigate,
|
|
148
|
+
shadows = true,
|
|
149
|
+
children
|
|
150
|
+
}) {
|
|
151
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
152
|
+
const config = scenes.get(activeScene) || (fallback ? scenes.get(fallback) : void 0);
|
|
153
|
+
const controls = config?.controls || defaultControls;
|
|
154
|
+
const transitionDuration = config?.transition?.duration ?? defaultTransition.duration ?? 0.8;
|
|
155
|
+
const navigate = useCallback2((scene) => {
|
|
156
|
+
setIsTransitioning(true);
|
|
157
|
+
setTimeout(() => setIsTransitioning(false), transitionDuration * 1e3);
|
|
158
|
+
if (onNavigate) onNavigate(scene);
|
|
159
|
+
}, [transitionDuration, onNavigate]);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
setIsTransitioning(true);
|
|
162
|
+
const timer = setTimeout(() => setIsTransitioning(false), transitionDuration * 1e3);
|
|
163
|
+
return () => clearTimeout(timer);
|
|
164
|
+
}, [activeScene, transitionDuration]);
|
|
165
|
+
const contextValue = useMemo(() => ({
|
|
166
|
+
activeScene,
|
|
167
|
+
config,
|
|
168
|
+
navigate,
|
|
169
|
+
isTransitioning
|
|
170
|
+
}), [activeScene, config, navigate, isTransitioning]);
|
|
171
|
+
return /* @__PURE__ */ jsxs(SceneContext.Provider, { value: contextValue, children: [
|
|
172
|
+
/* @__PURE__ */ jsxs(Canvas, { shadows, children: [
|
|
173
|
+
config && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
174
|
+
/* @__PURE__ */ jsx(SceneCamera, { config, transitionDuration, sceneKey: activeScene }),
|
|
175
|
+
/* @__PURE__ */ jsx(SceneControls, { type: controls, enabled: !isTransitioning, target: config.meta?.worldPosition }),
|
|
176
|
+
/* @__PURE__ */ jsx(ScenePostProcessing, { config: config.postProcessing })
|
|
177
|
+
] }),
|
|
178
|
+
Array.from(scenes.entries()).map(([key, sceneConfig]) => /* @__PURE__ */ jsx("group", { position: sceneConfig.meta?.worldPosition || [0, 0, 0], children: /* @__PURE__ */ jsx(sceneConfig.Component, {}) }, key))
|
|
179
|
+
] }),
|
|
180
|
+
children
|
|
181
|
+
] });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/define-scenes.ts
|
|
185
|
+
function defineScenes(config) {
|
|
186
|
+
const map = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const [key, value] of Object.entries(config)) {
|
|
188
|
+
map.set(key, {
|
|
189
|
+
position: [0, 0, 5],
|
|
190
|
+
rotation: [0, 0, 0],
|
|
191
|
+
controls: "orbit",
|
|
192
|
+
camera: "perspective",
|
|
193
|
+
...value
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return map;
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
SceneRouter,
|
|
200
|
+
defineScenes,
|
|
201
|
+
useScene,
|
|
202
|
+
useSceneTransition
|
|
203
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "r3f-scene-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declarative scene router for React Three Fiber — one persistent Canvas, many scenes with automatic camera transitions, controls, and post-processing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
14
|
+
"demo": "vite",
|
|
15
|
+
"demo:build": "vite build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-three-fiber",
|
|
19
|
+
"r3f",
|
|
20
|
+
"threejs",
|
|
21
|
+
"webgl",
|
|
22
|
+
"router",
|
|
23
|
+
"3d",
|
|
24
|
+
"scene-management",
|
|
25
|
+
"camera-transitions",
|
|
26
|
+
"post-processing"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/slaterhaus/r3f-scene-router"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18.0.0",
|
|
35
|
+
"@react-three/fiber": ">=8.0.0",
|
|
36
|
+
"@react-three/drei": ">=9.0.0",
|
|
37
|
+
"@react-three/postprocessing": ">=2.0.0",
|
|
38
|
+
"three": ">=0.150.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"typescript": "^5.5.0",
|
|
43
|
+
"vite": "^6.0.0",
|
|
44
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
45
|
+
"react": "^18.3.0",
|
|
46
|
+
"react-dom": "^18.3.0",
|
|
47
|
+
"@react-three/fiber": "^8.16.0",
|
|
48
|
+
"@react-three/drei": "^9.100.0",
|
|
49
|
+
"@react-three/postprocessing": "^2.16.0",
|
|
50
|
+
"three": "^0.165.0",
|
|
51
|
+
"@types/react": "^18.3.0",
|
|
52
|
+
"@types/react-dom": "^18.3.0",
|
|
53
|
+
"@types/three": "^0.165.0"
|
|
54
|
+
}
|
|
55
|
+
}
|