react-three-game 0.0.22 → 0.0.24
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.
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { RigidBodyProps } from "@react-three/rapier";
|
|
3
|
+
import { Object3D } from "three";
|
|
3
4
|
export type InstanceData = {
|
|
4
5
|
id: string;
|
|
6
|
+
meshPath: string;
|
|
5
7
|
position: [number, number, number];
|
|
6
8
|
rotation: [number, number, number];
|
|
7
9
|
scale: [number, number, number];
|
|
8
|
-
meshPath: string;
|
|
9
10
|
physics?: {
|
|
10
|
-
type: '
|
|
11
|
+
type: RigidBodyProps['type'];
|
|
11
12
|
};
|
|
12
13
|
};
|
|
13
14
|
export declare function GameInstanceProvider({ children, models, onSelect, registerRef }: {
|
|
14
15
|
children: React.ReactNode;
|
|
15
|
-
models:
|
|
16
|
-
[filename: string]: Object3D;
|
|
17
|
-
};
|
|
16
|
+
models: Record<string, Object3D>;
|
|
18
17
|
onSelect?: (id: string | null) => void;
|
|
19
18
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
20
19
|
}): import("react/jsx-runtime").JSX.Element;
|
|
21
|
-
export declare
|
|
20
|
+
export declare function GameInstance({ id, modelUrl, position, rotation, scale, physics }: {
|
|
22
21
|
id: string;
|
|
23
22
|
modelUrl: string;
|
|
24
23
|
position: [number, number, number];
|
|
25
24
|
rotation: [number, number, number];
|
|
26
25
|
scale: [number, number, number];
|
|
27
26
|
physics?: {
|
|
28
|
-
type:
|
|
27
|
+
type: RigidBodyProps['type'];
|
|
29
28
|
};
|
|
30
|
-
}
|
|
29
|
+
}): null;
|
|
@@ -1,165 +1,120 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
3
3
|
import { Merged } from '@react-three/drei';
|
|
4
4
|
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
5
5
|
import { Mesh, Matrix4 } from "three";
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return true;
|
|
10
|
-
if (a.length !== b.length)
|
|
11
|
-
return false;
|
|
12
|
-
for (let i = 0; i < a.length; i++) {
|
|
13
|
-
if (a[i] !== b[i])
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
function instanceEquals(a, b) {
|
|
6
|
+
// --- Helpers ---
|
|
7
|
+
const tupleEqual = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
8
|
+
const instanceChanged = (a, b) => {
|
|
19
9
|
var _a, _b;
|
|
20
|
-
return a.
|
|
21
|
-
a.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
return a.meshPath !== b.meshPath ||
|
|
11
|
+
((_a = a.physics) === null || _a === void 0 ? void 0 : _a.type) !== ((_b = b.physics) === null || _b === void 0 ? void 0 : _b.type) ||
|
|
12
|
+
!tupleEqual(a.position, b.position) ||
|
|
13
|
+
!tupleEqual(a.rotation, b.rotation) ||
|
|
14
|
+
!tupleEqual(a.scale, b.scale);
|
|
15
|
+
};
|
|
16
|
+
function extractMeshParts(model) {
|
|
17
|
+
model.updateWorldMatrix(false, true);
|
|
18
|
+
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
19
|
+
const parts = [];
|
|
20
|
+
model.traverse(child => {
|
|
21
|
+
if (child.isMesh) {
|
|
22
|
+
const mesh = child;
|
|
23
|
+
const geometry = mesh.geometry.clone();
|
|
24
|
+
geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
|
|
25
|
+
parts.push(new Mesh(geometry, mesh.material));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return parts;
|
|
26
29
|
}
|
|
30
|
+
// --- Context ---
|
|
27
31
|
const GameInstanceContext = createContext(null);
|
|
32
|
+
// --- Provider ---
|
|
28
33
|
export function GameInstanceProvider({ children, models, onSelect, registerRef }) {
|
|
29
34
|
const [instances, setInstances] = useState([]);
|
|
30
35
|
const addInstance = useCallback((instance) => {
|
|
31
36
|
setInstances(prev => {
|
|
32
37
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
33
|
-
if (idx
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return copy;
|
|
41
|
-
}
|
|
42
|
-
// Add new
|
|
43
|
-
return [...prev, instance];
|
|
38
|
+
if (idx === -1)
|
|
39
|
+
return [...prev, instance];
|
|
40
|
+
if (!instanceChanged(prev[idx], instance))
|
|
41
|
+
return prev;
|
|
42
|
+
const updated = [...prev];
|
|
43
|
+
updated[idx] = instance;
|
|
44
|
+
return updated;
|
|
44
45
|
});
|
|
45
46
|
}, []);
|
|
46
47
|
const removeInstance = useCallback((id) => {
|
|
47
|
-
setInstances(prev =>
|
|
48
|
-
if (!prev.find(i => i.id === id))
|
|
49
|
-
return prev;
|
|
50
|
-
return prev.filter(i => i.id !== id);
|
|
51
|
-
});
|
|
48
|
+
setInstances(prev => prev.filter(i => i.id !== id));
|
|
52
49
|
}, []);
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const modelParts = {};
|
|
50
|
+
// Extract mesh parts from models with baked local transforms
|
|
51
|
+
const { meshParts, partCounts } = useMemo(() => {
|
|
52
|
+
const meshParts = {};
|
|
53
|
+
const partCounts = {};
|
|
58
54
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
59
|
-
model
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
model.traverse((obj) => {
|
|
63
|
-
if (obj.isMesh) {
|
|
64
|
-
// Clone geometry and bake relative transform
|
|
65
|
-
const geom = obj.geometry.clone();
|
|
66
|
-
geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
|
|
67
|
-
const partKey = `${modelKey}__${partIndex}`;
|
|
68
|
-
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
69
|
-
partIndex++;
|
|
70
|
-
}
|
|
55
|
+
const parts = extractMeshParts(model);
|
|
56
|
+
parts.forEach((mesh, i) => {
|
|
57
|
+
meshParts[`${modelKey}__${i}`] = mesh;
|
|
71
58
|
});
|
|
72
|
-
|
|
59
|
+
partCounts[modelKey] = parts.length;
|
|
73
60
|
});
|
|
74
|
-
return {
|
|
61
|
+
return { meshParts, partCounts };
|
|
75
62
|
}, [models]);
|
|
76
|
-
// Cleanup geometries
|
|
77
|
-
useEffect(() => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}, [flatMeshes]);
|
|
82
|
-
// Group instances by meshPath + physics type for batch rendering
|
|
63
|
+
// Cleanup cloned geometries
|
|
64
|
+
useEffect(() => () => {
|
|
65
|
+
Object.values(meshParts).forEach(mesh => mesh.geometry.dispose());
|
|
66
|
+
}, [meshParts]);
|
|
67
|
+
// Group instances by model + physics type
|
|
83
68
|
const grouped = useMemo(() => {
|
|
84
|
-
var _a;
|
|
85
69
|
const groups = {};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
70
|
+
instances.forEach(inst => {
|
|
71
|
+
var _a, _b, _c;
|
|
72
|
+
const physicsType = (_b = (_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) !== null && _b !== void 0 ? _b : 'none';
|
|
73
|
+
const key = `${inst.meshPath}__${physicsType}`;
|
|
74
|
+
(_c = groups[key]) !== null && _c !== void 0 ? _c : (groups[key] = { physicsType, instances: [] });
|
|
91
75
|
groups[key].instances.push(inst);
|
|
92
|
-
}
|
|
76
|
+
});
|
|
93
77
|
return groups;
|
|
94
78
|
}, [instances]);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
instances,
|
|
99
|
-
meshes: flatMeshes,
|
|
100
|
-
modelParts
|
|
101
|
-
}, children: [children, Object.entries(grouped).map(([key, group]) => {
|
|
102
|
-
if (group.physicsType === 'none')
|
|
103
|
-
return null;
|
|
104
|
-
const modelKey = group.instances[0].meshPath;
|
|
105
|
-
const partCount = modelParts[modelKey] || 0;
|
|
106
|
-
if (partCount === 0)
|
|
107
|
-
return null;
|
|
108
|
-
return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes }, key));
|
|
109
|
-
}), Object.entries(grouped).map(([key, group]) => {
|
|
110
|
-
if (group.physicsType !== 'none')
|
|
111
|
-
return null;
|
|
79
|
+
const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
|
|
80
|
+
return (_jsxs(GameInstanceContext.Provider, { value: contextValue, children: [children, Object.entries(grouped).map(([key, group]) => {
|
|
81
|
+
var _a;
|
|
112
82
|
const modelKey = group.instances[0].meshPath;
|
|
113
|
-
const partCount =
|
|
83
|
+
const partCount = (_a = partCounts[modelKey]) !== null && _a !== void 0 ? _a : 0;
|
|
114
84
|
if (partCount === 0)
|
|
115
85
|
return null;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
for (let i = 0; i < partCount; i++) {
|
|
119
|
-
const partKey = `${modelKey}__${i}`;
|
|
120
|
-
meshesForModel[partKey] = flatMeshes[partKey];
|
|
86
|
+
if (group.physicsType !== 'none') {
|
|
87
|
+
return (_jsx(PhysicsInstances, { instances: group.instances, physicsType: group.physicsType, modelKey: modelKey, partCount: partCount, meshParts: meshParts }, key));
|
|
121
88
|
}
|
|
122
|
-
|
|
89
|
+
const modelMeshes = Object.fromEntries(Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]]));
|
|
90
|
+
return (_jsx(Merged, { meshes: modelMeshes, castShadow: true, receiveShadow: true, children: (Components) => (_jsx(StaticInstances, { instances: group.instances, modelKey: modelKey, partCount: partCount, Components: Components, onSelect: onSelect, registerRef: registerRef })) }, key));
|
|
123
91
|
})] }));
|
|
124
92
|
}
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return (_jsx(InstancedRigidBodies, { instances: instances, colliders: colliders, type: group.physicsType, children: Array.from({ length: partCount }).map((_, i) => {
|
|
135
|
-
const mesh = flatMeshes[`${modelKey}__${i}`];
|
|
136
|
-
if (!mesh)
|
|
137
|
-
return null;
|
|
138
|
-
return (_jsx("instancedMesh", { args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
|
|
93
|
+
// --- Physics Instances ---
|
|
94
|
+
// InstancedRigidBodies handles position/rotation/scale via the instances prop.
|
|
95
|
+
// We pass scale in instances and let the library manage matrix updates.
|
|
96
|
+
function PhysicsInstances({ instances, physicsType, modelKey, partCount, meshParts }) {
|
|
97
|
+
// InstancedRigidBodies expects { key, position, rotation, scale }
|
|
98
|
+
const rigidBodyInstances = useMemo(() => instances.map(({ id, position, rotation, scale }) => ({ key: id, position, rotation, scale })), [instances]);
|
|
99
|
+
return (_jsx(InstancedRigidBodies, { instances: rigidBodyInstances, type: physicsType, colliders: physicsType === 'fixed' ? 'trimesh' : 'hull', children: Array.from({ length: partCount }, (_, i) => {
|
|
100
|
+
const mesh = meshParts[`${modelKey}__${i}`];
|
|
101
|
+
return mesh ? (_jsx("instancedMesh", { args: [mesh.geometry, mesh.material, instances.length], frustumCulled: false, castShadow: true, receiveShadow: true }, i)) : null;
|
|
139
102
|
}) }));
|
|
140
103
|
}
|
|
141
|
-
//
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return (_jsx(_Fragment, { children: group.instances.map(inst => (_jsx(InstanceGroupItem, { instance: inst, InstanceComponents: InstanceComponents, onSelect: onSelect, registerRef: registerRef }, inst.id))) }));
|
|
104
|
+
// --- Static Instances (non-physics) ---
|
|
105
|
+
function StaticInstances({ instances, modelKey, partCount, Components, onSelect, registerRef }) {
|
|
106
|
+
const Parts = useMemo(() => Array.from({ length: partCount }, (_, i) => Components[`${modelKey}__${i}`]).filter(Boolean), [Components, modelKey, partCount]);
|
|
107
|
+
return (_jsx(_Fragment, { children: instances.map(inst => (_jsx(InstanceItem, { instance: inst, Parts: Parts, onSelect: onSelect, registerRef: registerRef }, inst.id))) }));
|
|
146
108
|
}
|
|
147
|
-
//
|
|
148
|
-
function
|
|
149
|
-
const
|
|
150
|
-
return (_jsx("group", { ref:
|
|
151
|
-
|
|
152
|
-
e.stopPropagation();
|
|
153
|
-
onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id);
|
|
154
|
-
}
|
|
155
|
-
clickValid.current = false;
|
|
156
|
-
}, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
|
|
109
|
+
// --- Single Instance ---
|
|
110
|
+
function InstanceItem({ instance, Parts, onSelect, registerRef }) {
|
|
111
|
+
const moved = useRef(false);
|
|
112
|
+
return (_jsx("group", { ref: el => registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, el), position: instance.position, rotation: instance.rotation, scale: instance.scale, onPointerDown: e => { e.stopPropagation(); moved.current = false; }, onPointerMove: () => { moved.current = true; }, onPointerUp: e => { e.stopPropagation(); if (!moved.current)
|
|
113
|
+
onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id); }, children: Parts.map((Part, i) => _jsx(Part, {}, i)) }));
|
|
157
114
|
}
|
|
158
|
-
// GameInstance
|
|
159
|
-
export
|
|
115
|
+
// --- GameInstance (declarative registration) ---
|
|
116
|
+
export function GameInstance({ id, modelUrl, position, rotation, scale, physics }) {
|
|
160
117
|
const ctx = useContext(GameInstanceContext);
|
|
161
|
-
const addInstance = ctx === null || ctx === void 0 ? void 0 : ctx.addInstance;
|
|
162
|
-
const removeInstance = ctx === null || ctx === void 0 ? void 0 : ctx.removeInstance;
|
|
163
118
|
const instance = useMemo(() => ({
|
|
164
119
|
id,
|
|
165
120
|
meshPath: modelUrl,
|
|
@@ -169,13 +124,10 @@ export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation
|
|
|
169
124
|
physics,
|
|
170
125
|
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
171
126
|
useEffect(() => {
|
|
172
|
-
if (!
|
|
127
|
+
if (!ctx)
|
|
173
128
|
return;
|
|
174
|
-
addInstance(instance);
|
|
175
|
-
return () =>
|
|
176
|
-
|
|
177
|
-
};
|
|
178
|
-
}, [addInstance, removeInstance, instance]);
|
|
179
|
-
// No visual rendering - provider handles all instanced visuals
|
|
129
|
+
ctx.addInstance(instance);
|
|
130
|
+
return () => ctx.removeInstance(id);
|
|
131
|
+
}, [ctx, instance, id]);
|
|
180
132
|
return null;
|
|
181
|
-
}
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,191 +1,157 @@
|
|
|
1
1
|
import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { Merged } from '@react-three/drei';
|
|
3
|
-
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
4
|
-
import { Mesh, Matrix4, Object3D
|
|
3
|
+
import { InstancedRigidBodies, RigidBodyProps } from "@react-three/rapier";
|
|
4
|
+
import { Mesh, Matrix4, Object3D } from "three";
|
|
5
5
|
|
|
6
6
|
// --- Types ---
|
|
7
7
|
export type InstanceData = {
|
|
8
8
|
id: string;
|
|
9
|
+
meshPath: string;
|
|
9
10
|
position: [number, number, number];
|
|
10
11
|
rotation: [number, number, number];
|
|
11
12
|
scale: [number, number, number];
|
|
12
|
-
|
|
13
|
-
physics?: { type: 'dynamic' | 'fixed' };
|
|
13
|
+
physics?: { type: RigidBodyProps['type'] };
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
for (let i = 0; i < a.length; i++) {
|
|
21
|
-
if (a[i] !== b[i]) return false;
|
|
22
|
-
}
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function instanceEquals(a: InstanceData, b: InstanceData): boolean {
|
|
27
|
-
return a.id === b.id &&
|
|
28
|
-
a.meshPath === b.meshPath &&
|
|
29
|
-
arrayEquals(a.position, b.position) &&
|
|
30
|
-
arrayEquals(a.rotation, b.rotation) &&
|
|
31
|
-
arrayEquals(a.scale, b.scale) &&
|
|
32
|
-
a.physics?.type === b.physics?.type;
|
|
33
|
-
}
|
|
16
|
+
type GroupedInstances = Record<string, {
|
|
17
|
+
physicsType: string;
|
|
18
|
+
instances: InstanceData[];
|
|
19
|
+
}>;
|
|
34
20
|
|
|
35
|
-
// --- Context ---
|
|
36
21
|
type GameInstanceContextType = {
|
|
37
22
|
addInstance: (instance: InstanceData) => void;
|
|
38
23
|
removeInstance: (id: string) => void;
|
|
39
|
-
instances: InstanceData[];
|
|
40
|
-
meshes: Record<string, Mesh>;
|
|
41
|
-
instancesMap?: Record<string, React.ComponentType<any>>;
|
|
42
|
-
modelParts?: Record<string, number>;
|
|
43
24
|
};
|
|
25
|
+
|
|
26
|
+
// --- Helpers ---
|
|
27
|
+
const tupleEqual = (a: readonly number[], b: readonly number[]) =>
|
|
28
|
+
a.length === b.length && a.every((v, i) => v === b[i]);
|
|
29
|
+
|
|
30
|
+
const instanceChanged = (a: InstanceData, b: InstanceData) =>
|
|
31
|
+
a.meshPath !== b.meshPath ||
|
|
32
|
+
a.physics?.type !== b.physics?.type ||
|
|
33
|
+
!tupleEqual(a.position, b.position) ||
|
|
34
|
+
!tupleEqual(a.rotation, b.rotation) ||
|
|
35
|
+
!tupleEqual(a.scale, b.scale);
|
|
36
|
+
|
|
37
|
+
function extractMeshParts(model: Object3D): Mesh[] {
|
|
38
|
+
model.updateWorldMatrix(false, true);
|
|
39
|
+
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
40
|
+
const parts: Mesh[] = [];
|
|
41
|
+
|
|
42
|
+
model.traverse(child => {
|
|
43
|
+
if ((child as Mesh).isMesh) {
|
|
44
|
+
const mesh = child as Mesh;
|
|
45
|
+
const geometry = mesh.geometry.clone();
|
|
46
|
+
geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
|
|
47
|
+
parts.push(new Mesh(geometry, mesh.material));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return parts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Context ---
|
|
44
55
|
const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
45
56
|
|
|
57
|
+
// --- Provider ---
|
|
46
58
|
export function GameInstanceProvider({
|
|
47
59
|
children,
|
|
48
60
|
models,
|
|
49
61
|
onSelect,
|
|
50
62
|
registerRef
|
|
51
63
|
}: {
|
|
52
|
-
children: React.ReactNode
|
|
53
|
-
models:
|
|
54
|
-
onSelect?: (id: string | null) => void
|
|
55
|
-
registerRef?: (id: string, obj: Object3D | null) => void
|
|
64
|
+
children: React.ReactNode;
|
|
65
|
+
models: Record<string, Object3D>;
|
|
66
|
+
onSelect?: (id: string | null) => void;
|
|
67
|
+
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
56
68
|
}) {
|
|
57
69
|
const [instances, setInstances] = useState<InstanceData[]>([]);
|
|
58
70
|
|
|
59
71
|
const addInstance = useCallback((instance: InstanceData) => {
|
|
60
72
|
setInstances(prev => {
|
|
61
73
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
62
|
-
if (idx
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const copy = [...prev];
|
|
68
|
-
copy[idx] = instance;
|
|
69
|
-
return copy;
|
|
70
|
-
}
|
|
71
|
-
// Add new
|
|
72
|
-
return [...prev, instance];
|
|
74
|
+
if (idx === -1) return [...prev, instance];
|
|
75
|
+
if (!instanceChanged(prev[idx], instance)) return prev;
|
|
76
|
+
const updated = [...prev];
|
|
77
|
+
updated[idx] = instance;
|
|
78
|
+
return updated;
|
|
73
79
|
});
|
|
74
80
|
}, []);
|
|
75
81
|
|
|
76
82
|
const removeInstance = useCallback((id: string) => {
|
|
77
|
-
setInstances(prev =>
|
|
78
|
-
if (!prev.find(i => i.id === id)) return prev;
|
|
79
|
-
return prev.filter(i => i.id !== id);
|
|
80
|
-
});
|
|
83
|
+
setInstances(prev => prev.filter(i => i.id !== id));
|
|
81
84
|
}, []);
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const modelParts: Record<string, number> = {};
|
|
86
|
+
// Extract mesh parts from models with baked local transforms
|
|
87
|
+
const { meshParts, partCounts } = useMemo(() => {
|
|
88
|
+
const meshParts: Record<string, Mesh> = {};
|
|
89
|
+
const partCounts: Record<string, number> = {};
|
|
88
90
|
|
|
89
91
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
90
|
-
model
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let partIndex = 0;
|
|
94
|
-
model.traverse((obj: any) => {
|
|
95
|
-
if (obj.isMesh) {
|
|
96
|
-
// Clone geometry and bake relative transform
|
|
97
|
-
const geom = obj.geometry.clone();
|
|
98
|
-
geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
|
|
99
|
-
|
|
100
|
-
const partKey = `${modelKey}__${partIndex}`;
|
|
101
|
-
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
102
|
-
partIndex++;
|
|
103
|
-
}
|
|
92
|
+
const parts = extractMeshParts(model);
|
|
93
|
+
parts.forEach((mesh, i) => {
|
|
94
|
+
meshParts[`${modelKey}__${i}`] = mesh;
|
|
104
95
|
});
|
|
105
|
-
|
|
96
|
+
partCounts[modelKey] = parts.length;
|
|
106
97
|
});
|
|
107
98
|
|
|
108
|
-
return {
|
|
99
|
+
return { meshParts, partCounts };
|
|
109
100
|
}, [models]);
|
|
110
101
|
|
|
111
|
-
// Cleanup geometries
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const key = `${inst.meshPath}__${type}`;
|
|
124
|
-
if (!groups[key]) groups[key] = { physicsType: type, instances: [] };
|
|
102
|
+
// Cleanup cloned geometries
|
|
103
|
+
useEffect(() => () => {
|
|
104
|
+
Object.values(meshParts).forEach(mesh => mesh.geometry.dispose());
|
|
105
|
+
}, [meshParts]);
|
|
106
|
+
|
|
107
|
+
// Group instances by model + physics type
|
|
108
|
+
const grouped = useMemo<GroupedInstances>(() => {
|
|
109
|
+
const groups: GroupedInstances = {};
|
|
110
|
+
instances.forEach(inst => {
|
|
111
|
+
const physicsType = inst.physics?.type ?? 'none';
|
|
112
|
+
const key = `${inst.meshPath}__${physicsType}`;
|
|
113
|
+
groups[key] ??= { physicsType, instances: [] };
|
|
125
114
|
groups[key].instances.push(inst);
|
|
126
|
-
}
|
|
115
|
+
});
|
|
127
116
|
return groups;
|
|
128
117
|
}, [instances]);
|
|
129
118
|
|
|
119
|
+
const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
|
|
120
|
+
|
|
130
121
|
return (
|
|
131
|
-
<GameInstanceContext.Provider
|
|
132
|
-
value={{
|
|
133
|
-
addInstance,
|
|
134
|
-
removeInstance,
|
|
135
|
-
instances,
|
|
136
|
-
meshes: flatMeshes,
|
|
137
|
-
modelParts
|
|
138
|
-
}}
|
|
139
|
-
>
|
|
140
|
-
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
122
|
+
<GameInstanceContext.Provider value={contextValue}>
|
|
141
123
|
{children}
|
|
142
124
|
|
|
143
|
-
{/* Render physics-enabled instanced groups using InstancedRigidBodies */}
|
|
144
|
-
{Object.entries(grouped).map(([key, group]) => {
|
|
145
|
-
if (group.physicsType === 'none') return null;
|
|
146
|
-
const modelKey = group.instances[0].meshPath;
|
|
147
|
-
const partCount = modelParts[modelKey] || 0;
|
|
148
|
-
if (partCount === 0) return null;
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<InstancedRigidGroup
|
|
152
|
-
key={key}
|
|
153
|
-
group={group}
|
|
154
|
-
modelKey={modelKey}
|
|
155
|
-
partCount={partCount}
|
|
156
|
-
flatMeshes={flatMeshes}
|
|
157
|
-
/>
|
|
158
|
-
);
|
|
159
|
-
})}
|
|
160
|
-
|
|
161
|
-
{/* Render non-physics instanced visuals using Merged (one per model type) */}
|
|
162
125
|
{Object.entries(grouped).map(([key, group]) => {
|
|
163
|
-
if (group.physicsType !== 'none') return null;
|
|
164
|
-
|
|
165
126
|
const modelKey = group.instances[0].meshPath;
|
|
166
|
-
const partCount =
|
|
127
|
+
const partCount = partCounts[modelKey] ?? 0;
|
|
167
128
|
if (partCount === 0) return null;
|
|
168
129
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
130
|
+
if (group.physicsType !== 'none') {
|
|
131
|
+
return (
|
|
132
|
+
<PhysicsInstances
|
|
133
|
+
key={key}
|
|
134
|
+
instances={group.instances}
|
|
135
|
+
physicsType={group.physicsType as RigidBodyProps['type']}
|
|
136
|
+
modelKey={modelKey}
|
|
137
|
+
partCount={partCount}
|
|
138
|
+
meshParts={meshParts}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
174
141
|
}
|
|
175
142
|
|
|
143
|
+
const modelMeshes = Object.fromEntries(
|
|
144
|
+
Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]])
|
|
145
|
+
);
|
|
146
|
+
|
|
176
147
|
return (
|
|
177
|
-
<Merged
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
receiveShadow
|
|
182
|
-
>
|
|
183
|
-
{(instancesMap: any) => (
|
|
184
|
-
<NonPhysicsInstancedGroup
|
|
148
|
+
<Merged key={key} meshes={modelMeshes} castShadow receiveShadow>
|
|
149
|
+
{(Components: Record<string, React.ComponentType>) => (
|
|
150
|
+
<StaticInstances
|
|
151
|
+
instances={group.instances}
|
|
185
152
|
modelKey={modelKey}
|
|
186
|
-
group={group}
|
|
187
153
|
partCount={partCount}
|
|
188
|
-
|
|
154
|
+
Components={Components}
|
|
189
155
|
onSelect={onSelect}
|
|
190
156
|
registerRef={registerRef}
|
|
191
157
|
/>
|
|
@@ -197,145 +163,126 @@ export function GameInstanceProvider({
|
|
|
197
163
|
);
|
|
198
164
|
}
|
|
199
165
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
166
|
+
// --- Physics Instances ---
|
|
167
|
+
// InstancedRigidBodies handles position/rotation/scale via the instances prop.
|
|
168
|
+
// We pass scale in instances and let the library manage matrix updates.
|
|
169
|
+
function PhysicsInstances({
|
|
170
|
+
instances,
|
|
171
|
+
physicsType,
|
|
203
172
|
modelKey,
|
|
204
173
|
partCount,
|
|
205
|
-
|
|
174
|
+
meshParts
|
|
206
175
|
}: {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
176
|
+
instances: InstanceData[];
|
|
177
|
+
physicsType: RigidBodyProps['type'];
|
|
178
|
+
modelKey: string;
|
|
179
|
+
partCount: number;
|
|
180
|
+
meshParts: Record<string, Mesh>;
|
|
211
181
|
}) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
rotation: inst.rotation,
|
|
217
|
-
scale: inst.scale,
|
|
218
|
-
})),
|
|
219
|
-
[group.instances]
|
|
182
|
+
// InstancedRigidBodies expects { key, position, rotation, scale }
|
|
183
|
+
const rigidBodyInstances = useMemo(() =>
|
|
184
|
+
instances.map(({ id, position, rotation, scale }) => ({ key: id, position, rotation, scale })),
|
|
185
|
+
[instances]
|
|
220
186
|
);
|
|
221
187
|
|
|
222
|
-
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
223
|
-
|
|
224
188
|
return (
|
|
225
189
|
<InstancedRigidBodies
|
|
226
|
-
instances={
|
|
227
|
-
|
|
228
|
-
|
|
190
|
+
instances={rigidBodyInstances}
|
|
191
|
+
type={physicsType}
|
|
192
|
+
colliders={physicsType === 'fixed' ? 'trimesh' : 'hull'}
|
|
229
193
|
>
|
|
230
|
-
{Array.from({ length: partCount }
|
|
231
|
-
const mesh =
|
|
232
|
-
|
|
233
|
-
return (
|
|
194
|
+
{Array.from({ length: partCount }, (_, i) => {
|
|
195
|
+
const mesh = meshParts[`${modelKey}__${i}`];
|
|
196
|
+
return mesh ? (
|
|
234
197
|
<instancedMesh
|
|
235
198
|
key={i}
|
|
236
|
-
args={[mesh.geometry, mesh.material,
|
|
199
|
+
args={[mesh.geometry, mesh.material, instances.length]}
|
|
200
|
+
frustumCulled={false}
|
|
237
201
|
castShadow
|
|
238
202
|
receiveShadow
|
|
239
|
-
frustumCulled={false} // Required: culling first instance hides all
|
|
240
203
|
/>
|
|
241
|
-
);
|
|
204
|
+
) : null;
|
|
242
205
|
})}
|
|
243
206
|
</InstancedRigidBodies>
|
|
244
207
|
);
|
|
245
208
|
}
|
|
246
209
|
|
|
247
|
-
//
|
|
248
|
-
function
|
|
210
|
+
// --- Static Instances (non-physics) ---
|
|
211
|
+
function StaticInstances({
|
|
212
|
+
instances,
|
|
249
213
|
modelKey,
|
|
250
|
-
group,
|
|
251
214
|
partCount,
|
|
252
|
-
|
|
215
|
+
Components,
|
|
253
216
|
onSelect,
|
|
254
217
|
registerRef
|
|
255
218
|
}: {
|
|
219
|
+
instances: InstanceData[];
|
|
256
220
|
modelKey: string;
|
|
257
|
-
group: { physicsType: string, instances: InstanceData[] };
|
|
258
221
|
partCount: number;
|
|
259
|
-
|
|
222
|
+
Components: Record<string, React.ComponentType>;
|
|
260
223
|
onSelect?: (id: string | null) => void;
|
|
261
224
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
262
225
|
}) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
[instancesMap, modelKey, partCount]
|
|
226
|
+
const Parts = useMemo(() =>
|
|
227
|
+
Array.from({ length: partCount }, (_, i) => Components[`${modelKey}__${i}`]).filter(Boolean),
|
|
228
|
+
[Components, modelKey, partCount]
|
|
267
229
|
);
|
|
268
230
|
|
|
269
231
|
return (
|
|
270
232
|
<>
|
|
271
|
-
{
|
|
272
|
-
<
|
|
273
|
-
key={inst.id}
|
|
274
|
-
instance={inst}
|
|
275
|
-
InstanceComponents={InstanceComponents}
|
|
276
|
-
onSelect={onSelect}
|
|
277
|
-
registerRef={registerRef}
|
|
278
|
-
/>
|
|
233
|
+
{instances.map(inst => (
|
|
234
|
+
<InstanceItem key={inst.id} instance={inst} Parts={Parts} onSelect={onSelect} registerRef={registerRef} />
|
|
279
235
|
))}
|
|
280
236
|
</>
|
|
281
237
|
);
|
|
282
238
|
}
|
|
283
239
|
|
|
284
|
-
//
|
|
285
|
-
function
|
|
240
|
+
// --- Single Instance ---
|
|
241
|
+
function InstanceItem({
|
|
286
242
|
instance,
|
|
287
|
-
|
|
243
|
+
Parts,
|
|
288
244
|
onSelect,
|
|
289
245
|
registerRef
|
|
290
246
|
}: {
|
|
291
247
|
instance: InstanceData;
|
|
292
|
-
|
|
248
|
+
Parts: React.ComponentType[];
|
|
293
249
|
onSelect?: (id: string | null) => void;
|
|
294
250
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
295
251
|
}) {
|
|
296
|
-
const
|
|
252
|
+
const moved = useRef(false);
|
|
297
253
|
|
|
298
254
|
return (
|
|
299
255
|
<group
|
|
300
|
-
ref={
|
|
256
|
+
ref={el => registerRef?.(instance.id, el)}
|
|
301
257
|
position={instance.position}
|
|
302
258
|
rotation={instance.rotation}
|
|
303
259
|
scale={instance.scale}
|
|
304
|
-
onPointerDown={
|
|
305
|
-
onPointerMove={() => {
|
|
306
|
-
onPointerUp={
|
|
307
|
-
if (clickValid.current) {
|
|
308
|
-
e.stopPropagation();
|
|
309
|
-
onSelect?.(instance.id);
|
|
310
|
-
}
|
|
311
|
-
clickValid.current = false;
|
|
312
|
-
}}
|
|
260
|
+
onPointerDown={e => { e.stopPropagation(); moved.current = false; }}
|
|
261
|
+
onPointerMove={() => { moved.current = true; }}
|
|
262
|
+
onPointerUp={e => { e.stopPropagation(); if (!moved.current) onSelect?.(instance.id); }}
|
|
313
263
|
>
|
|
314
|
-
{
|
|
264
|
+
{Parts.map((Part, i) => <Part key={i} />)}
|
|
315
265
|
</group>
|
|
316
266
|
);
|
|
317
267
|
}
|
|
318
268
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
export const GameInstance = React.forwardRef<Group, {
|
|
322
|
-
id: string;
|
|
323
|
-
modelUrl: string;
|
|
324
|
-
position: [number, number, number];
|
|
325
|
-
rotation: [number, number, number];
|
|
326
|
-
scale: [number, number, number];
|
|
327
|
-
physics?: { type: 'dynamic' | 'fixed' };
|
|
328
|
-
}>(({
|
|
269
|
+
// --- GameInstance (declarative registration) ---
|
|
270
|
+
export function GameInstance({
|
|
329
271
|
id,
|
|
330
272
|
modelUrl,
|
|
331
273
|
position,
|
|
332
274
|
rotation,
|
|
333
275
|
scale,
|
|
334
|
-
physics
|
|
335
|
-
}
|
|
276
|
+
physics
|
|
277
|
+
}: {
|
|
278
|
+
id: string;
|
|
279
|
+
modelUrl: string;
|
|
280
|
+
position: [number, number, number];
|
|
281
|
+
rotation: [number, number, number];
|
|
282
|
+
scale: [number, number, number];
|
|
283
|
+
physics?: { type: RigidBodyProps['type'] };
|
|
284
|
+
}) {
|
|
336
285
|
const ctx = useContext(GameInstanceContext);
|
|
337
|
-
const addInstance = ctx?.addInstance;
|
|
338
|
-
const removeInstance = ctx?.removeInstance;
|
|
339
286
|
|
|
340
287
|
const instance = useMemo<InstanceData>(() => ({
|
|
341
288
|
id,
|
|
@@ -347,13 +294,10 @@ export const GameInstance = React.forwardRef<Group, {
|
|
|
347
294
|
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
348
295
|
|
|
349
296
|
useEffect(() => {
|
|
350
|
-
if (!
|
|
351
|
-
addInstance(instance);
|
|
352
|
-
return () =>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}, [addInstance, removeInstance, instance]);
|
|
356
|
-
|
|
357
|
-
// No visual rendering - provider handles all instanced visuals
|
|
297
|
+
if (!ctx) return;
|
|
298
|
+
ctx.addInstance(instance);
|
|
299
|
+
return () => ctx.removeInstance(id);
|
|
300
|
+
}, [ctx, instance, id]);
|
|
301
|
+
|
|
358
302
|
return null;
|
|
359
|
-
}
|
|
303
|
+
}
|