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