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 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
@@ -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
+ }