@octanejs/three 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/LICENSE +49 -0
- package/README.md +215 -0
- package/UPSTREAM.md +58 -0
- package/package.json +77 -0
- package/src/config.ts +50 -0
- package/src/core/attach.ts +117 -0
- package/src/core/catalogue.ts +245 -0
- package/src/core/driver.ts +1609 -0
- package/src/core/events.ts +539 -0
- package/src/core/hooks.ts +203 -0
- package/src/core/index.ts +86 -0
- package/src/core/loader.ts +210 -0
- package/src/core/loop.ts +210 -0
- package/src/core/portal.ts +236 -0
- package/src/core/props.ts +645 -0
- package/src/core/root.ts +652 -0
- package/src/core/store.ts +741 -0
- package/src/index.ts +86 -0
- package/src/intrinsics.ts +18 -0
- package/src/renderer.ts +12 -0
- package/src/testing.ts +259 -0
- package/src/web/Canvas.tsrx +295 -0
- package/src/web/Canvas.tsrx.d.ts +23 -0
- package/src/web/events.ts +79 -0
- package/src/web/measure.ts +96 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas-scoped hooks and graph helpers for the Octane Three renderer.
|
|
3
|
+
*
|
|
4
|
+
* These hooks live in a plain TypeScript module which is intentionally excluded
|
|
5
|
+
* from Octane's hook transform. Compiled callers pass their call-site symbol as
|
|
6
|
+
* the final argument, so every composed universal hook receives a stable,
|
|
7
|
+
* distinct sub-slot derived from that symbol.
|
|
8
|
+
*/
|
|
9
|
+
import * as THREE from 'three';
|
|
10
|
+
import {
|
|
11
|
+
useContext,
|
|
12
|
+
useLayoutEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useSyncExternalStore,
|
|
16
|
+
} from 'octane/universal';
|
|
17
|
+
import { getThreeEventStore, getThreeInstance, type Instance } from './driver.js';
|
|
18
|
+
import {
|
|
19
|
+
getRootObjectStore,
|
|
20
|
+
RootStoreContext,
|
|
21
|
+
useRootStoreSelector,
|
|
22
|
+
type RenderCallback,
|
|
23
|
+
type RootState,
|
|
24
|
+
type RootStore,
|
|
25
|
+
} from './store.js';
|
|
26
|
+
|
|
27
|
+
export interface ObjectMap {
|
|
28
|
+
nodes: Record<string, THREE.Object3D>;
|
|
29
|
+
materials: Record<string, THREE.Material>;
|
|
30
|
+
meshes: Record<string, THREE.Mesh>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RefObject<T> {
|
|
34
|
+
current: T | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type EqualityFn<T> = (previous: T, next: T) => boolean;
|
|
38
|
+
type Selector<T> = (state: RootState) => T;
|
|
39
|
+
|
|
40
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
41
|
+
|
|
42
|
+
function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
43
|
+
// Universal hooks allocate an owner-local implicit slot when an uncompiled
|
|
44
|
+
// caller provides no symbol. Preserve that fallback; a shared tag-only symbol
|
|
45
|
+
// would make two direct calls to the same composed hook collide.
|
|
46
|
+
if (slot === undefined) return undefined;
|
|
47
|
+
let byTag = subSlotCache.get(slot);
|
|
48
|
+
if (byTag === undefined) {
|
|
49
|
+
byTag = new Map();
|
|
50
|
+
subSlotCache.set(slot, byTag);
|
|
51
|
+
}
|
|
52
|
+
let result = byTag.get(tag);
|
|
53
|
+
if (result === undefined) {
|
|
54
|
+
result = Symbol.for(`${slot.description ?? ''}:@octanejs/three:${tag}`);
|
|
55
|
+
byTag.set(tag, result);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function splitSlot(args: readonly unknown[]): [readonly unknown[], symbol | undefined] {
|
|
61
|
+
const tail = args.at(-1);
|
|
62
|
+
return typeof tail === 'symbol' ? [args.slice(0, -1), tail] : [args, undefined];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const identity = <T>(value: T): T => value;
|
|
66
|
+
|
|
67
|
+
/** Returns the nearest store, or selects it through a compiler-visible hook call. */
|
|
68
|
+
export function useStore(): RootStore;
|
|
69
|
+
export function useStore<T>(selector: Selector<T>, equalityFn?: EqualityFn<T>): T;
|
|
70
|
+
export function useStore<T = RootStore>(...args: unknown[]): T {
|
|
71
|
+
const [userArgs, slot] = splitSlot(args);
|
|
72
|
+
const store = useContext(RootStoreContext);
|
|
73
|
+
if (store === null) {
|
|
74
|
+
throw new Error('R3F: Hooks can only be used within the Canvas component!');
|
|
75
|
+
}
|
|
76
|
+
if (typeof userArgs[0] !== 'function') return store as T;
|
|
77
|
+
const selector = userArgs[0] as Selector<T>;
|
|
78
|
+
const equalityFn = (typeof userArgs[1] === 'function' ? userArgs[1] : Object.is) as EqualityFn<T>;
|
|
79
|
+
return useRootStoreSelector(store, selector, equalityFn, slot);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Selects reactive state from the nearest Three Canvas/root. */
|
|
83
|
+
export function useThree<T = RootState>(selector?: Selector<T>, equalityFn?: EqualityFn<T>): T;
|
|
84
|
+
export function useThree<T = RootState>(...args: unknown[]): T {
|
|
85
|
+
const [userArgs, slot] = splitSlot(args);
|
|
86
|
+
const selector = (typeof userArgs[0] === 'function' ? userArgs[0] : identity) as Selector<T>;
|
|
87
|
+
const equalityFn = (typeof userArgs[1] === 'function' ? userArgs[1] : Object.is) as EqualityFn<T>;
|
|
88
|
+
const store = useStore();
|
|
89
|
+
const cache = useRef<
|
|
90
|
+
| {
|
|
91
|
+
readonly state: RootState;
|
|
92
|
+
readonly selector: Selector<T>;
|
|
93
|
+
readonly selection: T;
|
|
94
|
+
}
|
|
95
|
+
| undefined
|
|
96
|
+
>(undefined, subSlot(slot, 'useThree:cache'));
|
|
97
|
+
|
|
98
|
+
const getSnapshot = (): T => {
|
|
99
|
+
const state = store.getState();
|
|
100
|
+
const previous = cache.current;
|
|
101
|
+
if (
|
|
102
|
+
previous !== undefined &&
|
|
103
|
+
previous.selector === selector &&
|
|
104
|
+
Object.is(previous.state, state)
|
|
105
|
+
) {
|
|
106
|
+
return previous.selection;
|
|
107
|
+
}
|
|
108
|
+
const selection = selector(state);
|
|
109
|
+
if (previous !== undefined && equalityFn(previous.selection, selection)) {
|
|
110
|
+
cache.current = { state, selector, selection: previous.selection };
|
|
111
|
+
return previous.selection;
|
|
112
|
+
}
|
|
113
|
+
cache.current = { state, selector, selection };
|
|
114
|
+
return selection;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return useSyncExternalStore(
|
|
118
|
+
store.subscribe,
|
|
119
|
+
getSnapshot,
|
|
120
|
+
getSnapshot,
|
|
121
|
+
subSlot(slot, 'useThree:store'),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Runs a callback before a root renders a frame. */
|
|
126
|
+
export function useFrame(callback: RenderCallback, renderPriority?: number): null;
|
|
127
|
+
export function useFrame(callback: RenderCallback, ...args: unknown[]): null {
|
|
128
|
+
const [userArgs, slot] = splitSlot(args);
|
|
129
|
+
const renderPriority = typeof userArgs[0] === 'number' ? userArgs[0] : 0;
|
|
130
|
+
const store = useStore();
|
|
131
|
+
const callbackRef = useRef(callback, subSlot(slot, 'useFrame:callback'));
|
|
132
|
+
|
|
133
|
+
useLayoutEffect(
|
|
134
|
+
() => {
|
|
135
|
+
callbackRef.current = callback;
|
|
136
|
+
},
|
|
137
|
+
[callback],
|
|
138
|
+
subSlot(slot, 'useFrame:latest'),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const subscribe = store.getState().internal.subscribe;
|
|
142
|
+
useLayoutEffect(
|
|
143
|
+
() => subscribe(callbackRef, renderPriority, store),
|
|
144
|
+
[renderPriority, store, subscribe],
|
|
145
|
+
subSlot(slot, 'useFrame:subscribe'),
|
|
146
|
+
);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Collects named objects, meshes, and materials from a Three object graph. */
|
|
151
|
+
export function buildGraph(object: THREE.Object3D): ObjectMap {
|
|
152
|
+
const graph: ObjectMap = { nodes: {}, materials: {}, meshes: {} };
|
|
153
|
+
object?.traverse((node) => {
|
|
154
|
+
if (node.name !== '') graph.nodes[node.name] = node;
|
|
155
|
+
const mesh = node as THREE.Mesh;
|
|
156
|
+
const material = mesh.material;
|
|
157
|
+
if (
|
|
158
|
+
material !== undefined &&
|
|
159
|
+
!Array.isArray(material) &&
|
|
160
|
+
material.name !== '' &&
|
|
161
|
+
graph.materials[material.name] === undefined
|
|
162
|
+
) {
|
|
163
|
+
graph.materials[material.name] = material;
|
|
164
|
+
}
|
|
165
|
+
if (mesh.isMesh === true && node.name !== '' && graph.meshes[node.name] === undefined) {
|
|
166
|
+
graph.meshes[node.name] = mesh;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return graph;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Memoizes the named graph for one Three object. */
|
|
173
|
+
export function useGraph(object: THREE.Object3D): ObjectMap;
|
|
174
|
+
export function useGraph(object: THREE.Object3D, slot?: symbol): ObjectMap {
|
|
175
|
+
return useMemo(() => buildGraph(object), [object], subSlot(slot, 'useGraph'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Returns the root state which owns a managed Three object. */
|
|
179
|
+
export function getRootState<T extends THREE.Object3D = THREE.Object3D>(
|
|
180
|
+
object: T,
|
|
181
|
+
): RootState | undefined {
|
|
182
|
+
return (getThreeEventStore(object) ?? getRootObjectStore(object))?.getState();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Exposes the stable public instance descriptor behind a managed Three ref. */
|
|
186
|
+
export function useInstanceHandle<T extends object>(ref: RefObject<T>): RefObject<Instance<T>>;
|
|
187
|
+
export function useInstanceHandle<T extends object>(
|
|
188
|
+
ref: RefObject<T>,
|
|
189
|
+
slot?: symbol,
|
|
190
|
+
): RefObject<Instance<T>> {
|
|
191
|
+
const handle = useRef<Instance<T> | null>(null, subSlot(slot, 'useInstanceHandle:ref'));
|
|
192
|
+
useLayoutEffect(
|
|
193
|
+
() => {
|
|
194
|
+
handle.current = ref.current === null ? null : getThreeInstance(ref.current);
|
|
195
|
+
return () => {
|
|
196
|
+
handle.current = null;
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
[ref],
|
|
200
|
+
subSlot(slot, 'useInstanceHandle:layout'),
|
|
201
|
+
);
|
|
202
|
+
return handle;
|
|
203
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export { extend } from './catalogue.js';
|
|
2
|
+
export { applyProps } from './driver.js';
|
|
3
|
+
export {
|
|
4
|
+
buildGraph,
|
|
5
|
+
getRootState,
|
|
6
|
+
useFrame,
|
|
7
|
+
useGraph,
|
|
8
|
+
useInstanceHandle,
|
|
9
|
+
useStore,
|
|
10
|
+
useThree,
|
|
11
|
+
} from './hooks.js';
|
|
12
|
+
export { useLoader } from './loader.js';
|
|
13
|
+
export { createPortal } from './portal.js';
|
|
14
|
+
export { dispose } from './props.js';
|
|
15
|
+
export {
|
|
16
|
+
addAfterEffect,
|
|
17
|
+
addEffect,
|
|
18
|
+
addTail,
|
|
19
|
+
advance,
|
|
20
|
+
flushGlobalEffects,
|
|
21
|
+
invalidate,
|
|
22
|
+
} from './loop.js';
|
|
23
|
+
export { createRoot, unmountComponentAtNode } from './root.js';
|
|
24
|
+
export { RootStoreContext as context, calculateDpr } from './store.js';
|
|
25
|
+
export { createEvents } from './events.js';
|
|
26
|
+
export type {
|
|
27
|
+
Args,
|
|
28
|
+
Attach,
|
|
29
|
+
Catalogue,
|
|
30
|
+
ConstructorRepresentation,
|
|
31
|
+
EventProps,
|
|
32
|
+
InstanceProps,
|
|
33
|
+
MathRepresentation,
|
|
34
|
+
MathType,
|
|
35
|
+
MathTypes,
|
|
36
|
+
PrimitiveProps,
|
|
37
|
+
RaycastableRepresentation,
|
|
38
|
+
ThreeElement,
|
|
39
|
+
ThreeElements,
|
|
40
|
+
ThreeInstanceProps,
|
|
41
|
+
ThreeKey,
|
|
42
|
+
ThreeRef,
|
|
43
|
+
ThreeToElements,
|
|
44
|
+
ThreeToJSXElements,
|
|
45
|
+
VectorRepresentation,
|
|
46
|
+
} from './catalogue.js';
|
|
47
|
+
export type {
|
|
48
|
+
ComputeFunction,
|
|
49
|
+
DomEvent,
|
|
50
|
+
EventHandlers,
|
|
51
|
+
EventManager,
|
|
52
|
+
Events,
|
|
53
|
+
FilterFunction,
|
|
54
|
+
Intersection,
|
|
55
|
+
IntersectionEvent,
|
|
56
|
+
PointerCaptureTarget,
|
|
57
|
+
ThreeEvent,
|
|
58
|
+
} from './events.js';
|
|
59
|
+
export type { Instance } from './driver.js';
|
|
60
|
+
export type { ObjectMap, RefObject } from './hooks.js';
|
|
61
|
+
export type { Extensions, LoaderResult } from './loader.js';
|
|
62
|
+
export type { InjectState } from './portal.js';
|
|
63
|
+
export type { GlobalEffectType, GlobalRenderCallback } from './loop.js';
|
|
64
|
+
export type {
|
|
65
|
+
CameraProps,
|
|
66
|
+
CanvasLike,
|
|
67
|
+
DefaultGLProps,
|
|
68
|
+
GLProps,
|
|
69
|
+
RenderProps,
|
|
70
|
+
ThreeRoot,
|
|
71
|
+
} from './root.js';
|
|
72
|
+
export type {
|
|
73
|
+
Camera,
|
|
74
|
+
Dpr,
|
|
75
|
+
Frameloop,
|
|
76
|
+
InternalState,
|
|
77
|
+
Performance,
|
|
78
|
+
RenderCallback,
|
|
79
|
+
Renderer,
|
|
80
|
+
RootState,
|
|
81
|
+
RootStore,
|
|
82
|
+
Size,
|
|
83
|
+
Subscription,
|
|
84
|
+
Viewport,
|
|
85
|
+
XRManager,
|
|
86
|
+
} from './store.js';
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suspense-aware Three loader cache.
|
|
3
|
+
*
|
|
4
|
+
* The cache key deliberately mirrors React Three Fiber: the loader identity and
|
|
5
|
+
* each normalized input participate in the key, while extensions and progress
|
|
6
|
+
* callbacks configure only the first request for that key.
|
|
7
|
+
*/
|
|
8
|
+
import * as THREE from 'three';
|
|
9
|
+
import { use } from 'octane/universal';
|
|
10
|
+
import { buildGraph, type ObjectMap } from './hooks.js';
|
|
11
|
+
|
|
12
|
+
type InputLike = string | string[] | string[][] | Readonly<string | string[] | string[][]>;
|
|
13
|
+
type LoaderLike = THREE.Loader<any, InputLike>;
|
|
14
|
+
type LoaderConstructor = new (...args: any[]) => LoaderLike;
|
|
15
|
+
type LoaderSource = LoaderLike | LoaderConstructor;
|
|
16
|
+
type LoaderInstance<T extends LoaderSource> = T extends LoaderConstructor ? InstanceType<T> : T;
|
|
17
|
+
type GLTFLike = { scene: THREE.Object3D };
|
|
18
|
+
|
|
19
|
+
/** The resolved value produced by a loader, augmented for GLTF-shaped results. */
|
|
20
|
+
export type LoaderResult<T extends LoaderSource> =
|
|
21
|
+
Awaited<ReturnType<LoaderInstance<T>['loadAsync']>> extends infer Result
|
|
22
|
+
? Result extends GLTFLike
|
|
23
|
+
? Result & ObjectMap
|
|
24
|
+
: Result
|
|
25
|
+
: never;
|
|
26
|
+
|
|
27
|
+
/** Configures the concrete loader instance used for a request. */
|
|
28
|
+
export type Extensions<T extends LoaderSource> = (loader: LoaderInstance<T>) => void;
|
|
29
|
+
|
|
30
|
+
type InputKey = string | readonly string[];
|
|
31
|
+
|
|
32
|
+
interface TrackedLoaderPromise<T> extends Promise<T> {
|
|
33
|
+
status?: 'pending' | 'fulfilled' | 'rejected';
|
|
34
|
+
value?: T;
|
|
35
|
+
reason?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LoaderCacheEntry {
|
|
39
|
+
readonly keys: readonly InputKey[];
|
|
40
|
+
readonly promise: TrackedLoaderPromise<unknown[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const memoizedLoaders = new WeakMap<LoaderConstructor, LoaderLike>();
|
|
44
|
+
const loaderCache = new WeakMap<LoaderSource, LoaderCacheEntry[]>();
|
|
45
|
+
|
|
46
|
+
function isConstructor(value: LoaderSource): value is LoaderConstructor {
|
|
47
|
+
return typeof value === 'function' && value.prototype?.constructor === value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function inputKeyEquals(previous: InputKey, next: InputKey): boolean {
|
|
51
|
+
if (previous === next) return true;
|
|
52
|
+
if (!Array.isArray(previous) || !Array.isArray(next) || previous.length !== next.length) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return previous.every((value, index) => value === next[index]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function keysEqual(previous: readonly InputKey[], next: readonly InputKey[]): boolean {
|
|
59
|
+
return (
|
|
60
|
+
previous.length === next.length &&
|
|
61
|
+
previous.every((value, index) => inputKeyEquals(value, next[index]))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeInput(input: InputLike): InputKey[] {
|
|
66
|
+
return (Array.isArray(input) ? input : [input]) as InputKey[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveLoader(source: LoaderSource): LoaderLike {
|
|
70
|
+
if (!isConstructor(source)) return source;
|
|
71
|
+
let loader = memoizedLoaders.get(source);
|
|
72
|
+
if (loader === undefined) {
|
|
73
|
+
loader = new source();
|
|
74
|
+
memoizedLoaders.set(source, loader);
|
|
75
|
+
}
|
|
76
|
+
return loader;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function trackPromise<T>(promise: Promise<T>): TrackedLoaderPromise<T> {
|
|
80
|
+
const tracked = promise as TrackedLoaderPromise<T>;
|
|
81
|
+
tracked.status = 'pending';
|
|
82
|
+
promise.then(
|
|
83
|
+
(value) => {
|
|
84
|
+
tracked.status = 'fulfilled';
|
|
85
|
+
tracked.value = value;
|
|
86
|
+
},
|
|
87
|
+
(reason) => {
|
|
88
|
+
tracked.status = 'rejected';
|
|
89
|
+
tracked.reason = reason;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
return tracked;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadInputs<L extends LoaderSource>(
|
|
96
|
+
source: L,
|
|
97
|
+
keys: readonly InputKey[],
|
|
98
|
+
extensions?: Extensions<L>,
|
|
99
|
+
onProgress?: (event: ProgressEvent<EventTarget>) => void,
|
|
100
|
+
): TrackedLoaderPromise<unknown[]> {
|
|
101
|
+
const loader = resolveLoader(source) as LoaderInstance<L>;
|
|
102
|
+
extensions?.(loader);
|
|
103
|
+
|
|
104
|
+
const promise = Promise.all(
|
|
105
|
+
keys.map(
|
|
106
|
+
(input) =>
|
|
107
|
+
new Promise<unknown>((resolve, reject) => {
|
|
108
|
+
loader.load(
|
|
109
|
+
input as InputLike,
|
|
110
|
+
(data) => {
|
|
111
|
+
const scene = (data as { scene?: unknown } | null)?.scene;
|
|
112
|
+
if ((scene as THREE.Object3D | undefined)?.isObject3D === true) {
|
|
113
|
+
Object.assign(data as object, buildGraph(scene as THREE.Object3D));
|
|
114
|
+
}
|
|
115
|
+
resolve(data);
|
|
116
|
+
},
|
|
117
|
+
onProgress,
|
|
118
|
+
(error) => {
|
|
119
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
120
|
+
reject(new Error(`Could not load ${input}: ${message}`));
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
return trackPromise(promise);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getEntry<L extends LoaderSource>(
|
|
130
|
+
source: L,
|
|
131
|
+
keys: readonly InputKey[],
|
|
132
|
+
extensions?: Extensions<L>,
|
|
133
|
+
onProgress?: (event: ProgressEvent<EventTarget>) => void,
|
|
134
|
+
): LoaderCacheEntry {
|
|
135
|
+
let entries = loaderCache.get(source);
|
|
136
|
+
const cached = entries?.find((entry) => keysEqual(entry.keys, keys));
|
|
137
|
+
if (cached !== undefined) return cached;
|
|
138
|
+
|
|
139
|
+
const entry: LoaderCacheEntry = {
|
|
140
|
+
keys: [...keys],
|
|
141
|
+
promise: loadInputs(source, keys, extensions, onProgress),
|
|
142
|
+
};
|
|
143
|
+
if (entries === undefined) {
|
|
144
|
+
entries = [];
|
|
145
|
+
loaderCache.set(source, entries);
|
|
146
|
+
}
|
|
147
|
+
entries.push(entry);
|
|
148
|
+
return entry;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function splitHookArguments(
|
|
152
|
+
args: readonly unknown[],
|
|
153
|
+
): readonly [
|
|
154
|
+
Extensions<LoaderSource> | undefined,
|
|
155
|
+
((event: ProgressEvent<EventTarget>) => void) | undefined,
|
|
156
|
+
] {
|
|
157
|
+
const userArgs = typeof args.at(-1) === 'symbol' ? args.slice(0, -1) : args;
|
|
158
|
+
return [
|
|
159
|
+
typeof userArgs[0] === 'function' ? (userArgs[0] as Extensions<LoaderSource>) : undefined,
|
|
160
|
+
typeof userArgs[1] === 'function'
|
|
161
|
+
? (userArgs[1] as (event: ProgressEvent<EventTarget>) => void)
|
|
162
|
+
: undefined,
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface UseLoader {
|
|
167
|
+
<I extends InputLike, L extends LoaderSource>(
|
|
168
|
+
loader: L,
|
|
169
|
+
input: I,
|
|
170
|
+
extensions?: Extensions<L>,
|
|
171
|
+
onProgress?: (event: ProgressEvent<EventTarget>) => void,
|
|
172
|
+
): I extends any[] ? LoaderResult<L>[] : LoaderResult<L>;
|
|
173
|
+
preload<I extends InputLike, L extends LoaderSource>(
|
|
174
|
+
loader: L,
|
|
175
|
+
input: I,
|
|
176
|
+
extensions?: Extensions<L>,
|
|
177
|
+
): void;
|
|
178
|
+
clear<I extends InputLike, L extends LoaderSource>(loader: L, input: I): void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function useLoaderImplementation<L extends LoaderSource>(
|
|
182
|
+
loader: L,
|
|
183
|
+
input: InputLike,
|
|
184
|
+
...args: unknown[]
|
|
185
|
+
): unknown {
|
|
186
|
+
const [extensions, onProgress] = splitHookArguments(args);
|
|
187
|
+
const keys = normalizeInput(input);
|
|
188
|
+
const results = use(
|
|
189
|
+
getEntry(loader, keys, extensions as Extensions<L> | undefined, onProgress).promise,
|
|
190
|
+
);
|
|
191
|
+
return Array.isArray(input) ? results : results[0];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Synchronously reads and caches assets loaded by a Three loader.
|
|
196
|
+
* The owning component must provide a Suspense boundary.
|
|
197
|
+
*/
|
|
198
|
+
export const useLoader = Object.assign(useLoaderImplementation, {
|
|
199
|
+
preload<L extends LoaderSource>(loader: L, input: InputLike, extensions?: Extensions<L>): void {
|
|
200
|
+
getEntry(loader, normalizeInput(input), extensions);
|
|
201
|
+
},
|
|
202
|
+
clear(loader: LoaderSource, input: InputLike): void {
|
|
203
|
+
const entries = loaderCache.get(loader);
|
|
204
|
+
if (entries === undefined) return;
|
|
205
|
+
const keys = normalizeInput(input);
|
|
206
|
+
const index = entries.findIndex((entry) => keysEqual(entry.keys, keys));
|
|
207
|
+
if (index !== -1) entries.splice(index, 1);
|
|
208
|
+
if (entries.length === 0) loaderCache.delete(loader);
|
|
209
|
+
},
|
|
210
|
+
}) as UseLoader;
|
package/src/core/loop.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { RenderCallback, RootState, RootStore, Subscription } from './store.js';
|
|
2
|
+
|
|
3
|
+
export type GlobalRenderCallback = (timestamp: number) => void;
|
|
4
|
+
export type GlobalEffectType = 'before' | 'after' | 'tail';
|
|
5
|
+
|
|
6
|
+
interface GlobalSubscription {
|
|
7
|
+
callback: GlobalRenderCallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FrameRenderer {
|
|
11
|
+
render?: (scene: RootState['scene'], camera: RootState['camera']) => void;
|
|
12
|
+
xr?: { isPresenting?: boolean };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const roots = new Set<RootStore>();
|
|
16
|
+
const globalEffects = new Set<GlobalSubscription>();
|
|
17
|
+
const globalAfterEffects = new Set<GlobalSubscription>();
|
|
18
|
+
const globalTailEffects = new Set<GlobalSubscription>();
|
|
19
|
+
|
|
20
|
+
let running = false;
|
|
21
|
+
let useFrameInProgress = false;
|
|
22
|
+
|
|
23
|
+
function subscribeGlobal(
|
|
24
|
+
callback: GlobalRenderCallback,
|
|
25
|
+
subscriptions: Set<GlobalSubscription>,
|
|
26
|
+
): () => void {
|
|
27
|
+
const subscription = { callback };
|
|
28
|
+
subscriptions.add(subscription);
|
|
29
|
+
return () => {
|
|
30
|
+
subscriptions.delete(subscription);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runGlobalSubscriptions(subscriptions: Set<GlobalSubscription>, timestamp: number): void {
|
|
35
|
+
for (const subscription of subscriptions) {
|
|
36
|
+
subscription.callback(timestamp);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getRenderer(state: RootState): FrameRenderer | undefined {
|
|
41
|
+
const compatibleState = state as RootState & {
|
|
42
|
+
gl?: FrameRenderer;
|
|
43
|
+
renderer?: FrameRenderer;
|
|
44
|
+
};
|
|
45
|
+
return compatibleState.gl ?? compatibleState.renderer;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requestFrame(callback: FrameRequestCallback): number | null {
|
|
49
|
+
if (typeof globalThis.requestAnimationFrame !== 'function') return null;
|
|
50
|
+
return globalThis.requestAnimationFrame(callback);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cancelFrame(frame: number): void {
|
|
54
|
+
if (typeof globalThis.cancelAnimationFrame === 'function') {
|
|
55
|
+
globalThis.cancelAnimationFrame(frame);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function update(timestamp: number, state: RootState, frame?: XRFrame): number {
|
|
60
|
+
let delta = state.clock.getDelta();
|
|
61
|
+
|
|
62
|
+
if (state.frameloop === 'never') {
|
|
63
|
+
delta = timestamp - state.clock.elapsedTime;
|
|
64
|
+
state.clock.oldTime = state.clock.elapsedTime;
|
|
65
|
+
state.clock.elapsedTime = timestamp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const subscribers = state.internal.subscribers;
|
|
69
|
+
for (let index = 0; index < subscribers.length; index++) {
|
|
70
|
+
const subscription: Subscription = subscribers[index];
|
|
71
|
+
const callback: RenderCallback = subscription.ref.current;
|
|
72
|
+
callback(subscription.store.getState(), delta, frame);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const renderer = getRenderer(state);
|
|
76
|
+
if (state.internal.priority === 0 && renderer?.render) {
|
|
77
|
+
renderer.render(state.scene, state.camera);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
state.internal.frames = Math.max(0, state.internal.frames - 1);
|
|
81
|
+
return state.frameloop === 'always' ? 1 : state.internal.frames;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Registers a configured root with the shared frame scheduler.
|
|
86
|
+
*
|
|
87
|
+
* This is an integration hook for the root implementation, not a public
|
|
88
|
+
* application API. Registering the same store more than once is idempotent.
|
|
89
|
+
*/
|
|
90
|
+
export function registerRootStore(store: RootStore): () => void {
|
|
91
|
+
roots.add(store);
|
|
92
|
+
return () => unregisterRootStore(store);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Removes a root from the shared frame scheduler. */
|
|
96
|
+
export function unregisterRootStore(store: RootStore): void {
|
|
97
|
+
roots.delete(store);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Adds a callback that runs before active roots render each frame. */
|
|
101
|
+
export function addEffect(callback: GlobalRenderCallback): () => void {
|
|
102
|
+
return subscribeGlobal(callback, globalEffects);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Adds a callback that runs after active roots render each frame. */
|
|
106
|
+
export function addAfterEffect(callback: GlobalRenderCallback): () => void {
|
|
107
|
+
return subscribeGlobal(callback, globalAfterEffects);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Adds a callback that runs when the shared frame loop becomes idle. */
|
|
111
|
+
export function addTail(callback: GlobalRenderCallback): () => void {
|
|
112
|
+
return subscribeGlobal(callback, globalTailEffects);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Runs one phase of the registered global frame callbacks. */
|
|
116
|
+
export function flushGlobalEffects(type: GlobalEffectType, timestamp: number): void {
|
|
117
|
+
switch (type) {
|
|
118
|
+
case 'before':
|
|
119
|
+
runGlobalSubscriptions(globalEffects, timestamp);
|
|
120
|
+
break;
|
|
121
|
+
case 'after':
|
|
122
|
+
runGlobalSubscriptions(globalAfterEffects, timestamp);
|
|
123
|
+
break;
|
|
124
|
+
case 'tail':
|
|
125
|
+
runGlobalSubscriptions(globalTailEffects, timestamp);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Runs one scheduled frame for every active registered root. */
|
|
131
|
+
export function loop(timestamp: number): void {
|
|
132
|
+
const nextFrame = requestFrame(loop);
|
|
133
|
+
running = nextFrame !== null;
|
|
134
|
+
let repeat = 0;
|
|
135
|
+
|
|
136
|
+
flushGlobalEffects('before', timestamp);
|
|
137
|
+
|
|
138
|
+
useFrameInProgress = true;
|
|
139
|
+
try {
|
|
140
|
+
for (const store of roots) {
|
|
141
|
+
const state = store.getState();
|
|
142
|
+
const renderer = getRenderer(state);
|
|
143
|
+
if (
|
|
144
|
+
state.internal.active &&
|
|
145
|
+
(state.frameloop === 'always' || state.internal.frames > 0) &&
|
|
146
|
+
!renderer?.xr?.isPresenting
|
|
147
|
+
) {
|
|
148
|
+
repeat += update(timestamp, state);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
useFrameInProgress = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
flushGlobalEffects('after', timestamp);
|
|
156
|
+
|
|
157
|
+
if (repeat === 0) {
|
|
158
|
+
flushGlobalEffects('tail', timestamp);
|
|
159
|
+
running = false;
|
|
160
|
+
if (nextFrame !== null) cancelFrame(nextFrame);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Requests a render from one root, or every registered root when state is
|
|
166
|
+
* omitted. Demand-mode requests coalesce into one shared animation frame.
|
|
167
|
+
*/
|
|
168
|
+
export function invalidate(state?: RootState, frames = 1): void {
|
|
169
|
+
if (state === undefined) {
|
|
170
|
+
for (const store of roots) invalidate(store.getState(), frames);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const renderer = getRenderer(state);
|
|
175
|
+
if (renderer?.xr?.isPresenting || !state.internal.active || state.frameloop === 'never') {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (frames > 1) {
|
|
180
|
+
state.internal.frames = Math.min(60, state.internal.frames + frames);
|
|
181
|
+
} else {
|
|
182
|
+
state.internal.frames = useFrameInProgress ? 2 : 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!running) {
|
|
186
|
+
const frame = requestFrame(loop);
|
|
187
|
+
running = frame !== null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Advances one root, or every registered root, without scheduling a RAF.
|
|
193
|
+
* This is the deterministic rendering path for `frameloop="never"`.
|
|
194
|
+
*/
|
|
195
|
+
export function advance(
|
|
196
|
+
timestamp: number,
|
|
197
|
+
runGlobalEffects = true,
|
|
198
|
+
state?: RootState,
|
|
199
|
+
frame?: XRFrame,
|
|
200
|
+
): void {
|
|
201
|
+
if (runGlobalEffects) flushGlobalEffects('before', timestamp);
|
|
202
|
+
|
|
203
|
+
if (state === undefined) {
|
|
204
|
+
for (const store of roots) update(timestamp, store.getState());
|
|
205
|
+
} else {
|
|
206
|
+
update(timestamp, state, frame);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (runGlobalEffects) flushGlobalEffects('after', timestamp);
|
|
210
|
+
}
|