react-three-game 0.0.21 → 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,185 +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 { Mesh, Matrix4, Object3D, Group } from "three";
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
- meshPath: string;
13
- physics?: { type: 'dynamic' | 'fixed' };
14
+ physics?: { type: RigidBodyProps['type'] };
14
15
  };
15
16
 
16
- // Helper functions for comparison
17
- function arrayEquals(a: number[], b: number[]): boolean {
18
- if (a === b) return true;
19
- if (a.length !== b.length) return false;
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: { [filename: string]: Object3D },
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 !== -1) {
63
- // Update existing if changed
64
- if (instanceEquals(prev[idx], instance)) {
65
- return prev;
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
- // Flatten all model meshes once (models flat mesh parts)
84
- const { flatMeshes, modelParts } = useMemo(() => {
85
- const flatMeshes: Record<string, Mesh> = {};
86
- 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> = {};
87
94
 
88
95
  Object.entries(models).forEach(([modelKey, model]) => {
89
- const root = model;
90
- root.updateWorldMatrix(false, true);
91
- const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
92
-
96
+ model.updateWorldMatrix(false, true);
97
+ const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
93
98
  let partIndex = 0;
94
99
 
95
- root.traverse((obj: any) => {
96
- if (obj.isMesh) {
97
- const geom = obj.geometry.clone();
98
- const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
99
- geom.applyMatrix4(relativeTransform);
100
-
101
- const partKey = `${modelKey}__${partIndex}`;
102
- flatMeshes[partKey] = new Mesh(geom, obj.material);
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);
103
106
  partIndex++;
104
107
  }
105
108
  });
106
- modelParts[modelKey] = partIndex;
109
+ partCounts[modelKey] = partIndex;
107
110
  });
108
111
 
109
- return { flatMeshes, modelParts };
112
+ return { meshParts, partCounts };
110
113
  }, [models]);
111
114
 
112
- // Group instances by meshPath + physics type for batch rendering
113
- const grouped = useMemo(() => {
114
- const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
115
- for (const inst of instances) {
116
- const type = inst.physics?.type || 'none';
117
- const key = `${inst.meshPath}__${type}`;
118
- 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: [] };
119
127
  groups[key].instances.push(inst);
120
- }
128
+ });
121
129
  return groups;
122
130
  }, [instances]);
123
131
 
132
+ const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
133
+
124
134
  return (
125
- <GameInstanceContext.Provider
126
- value={{
127
- addInstance,
128
- removeInstance,
129
- instances,
130
- meshes: flatMeshes,
131
- modelParts
132
- }}
133
- >
134
- {/* Render normal prefab hierarchy (non-instanced objects) */}
135
+ <GameInstanceContext.Provider value={contextValue}>
135
136
  {children}
136
137
 
137
- {/* Render physics-enabled instanced groups using InstancedRigidBodies */}
138
138
  {Object.entries(grouped).map(([key, group]) => {
139
- if (group.physicsType === 'none') return null;
140
139
  const modelKey = group.instances[0].meshPath;
141
- const partCount = modelParts[modelKey] || 0;
140
+ const partCount = partCounts[modelKey] ?? 0;
142
141
  if (partCount === 0) return null;
143
142
 
144
- return (
145
- <InstancedRigidGroup
146
- key={key}
147
- group={group}
148
- modelKey={modelKey}
149
- partCount={partCount}
150
- flatMeshes={flatMeshes}
151
- />
152
- );
153
- })}
154
-
155
- {/* Render non-physics instanced visuals using Merged (one per model type) */}
156
- {Object.entries(grouped).map(([key, group]) => {
157
- if (group.physicsType !== 'none') return null;
158
-
159
- const modelKey = group.instances[0].meshPath;
160
- const partCount = modelParts[modelKey] || 0;
161
- if (partCount === 0) return null;
162
-
163
- // Create mesh subset for this specific model
164
- const meshesForModel: Record<string, Mesh> = {};
165
- for (let i = 0; i < partCount; i++) {
166
- const partKey = `${modelKey}__${i}`;
167
- meshesForModel[partKey] = flatMeshes[partKey];
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
+ );
168
154
  }
169
155
 
156
+ const modelMeshes = Object.fromEntries(
157
+ Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]])
158
+ );
159
+
170
160
  return (
171
- <Merged
172
- key={key}
173
- meshes={meshesForModel}
174
- castShadow
175
- receiveShadow
176
- >
177
- {(instancesMap: any) => (
178
- <NonPhysicsInstancedGroup
161
+ <Merged key={key} meshes={modelMeshes} castShadow receiveShadow>
162
+ {(Components: Record<string, React.ComponentType>) => (
163
+ <StaticInstances
164
+ instances={group.instances}
179
165
  modelKey={modelKey}
180
- group={group}
181
166
  partCount={partCount}
182
- instancesMap={instancesMap}
167
+ Components={Components}
183
168
  onSelect={onSelect}
184
169
  registerRef={registerRef}
185
170
  />
@@ -191,129 +176,137 @@ export function GameInstanceProvider({
191
176
  );
192
177
  }
193
178
 
194
- // Render physics-enabled instances using InstancedRigidBodies
195
- function InstancedRigidGroup({
196
- group,
179
+ // --- Physics Instances ---
180
+ function PhysicsInstances({
181
+ instances,
182
+ physicsType,
197
183
  modelKey,
198
184
  partCount,
199
- flatMeshes
185
+ meshParts
200
186
  }: {
201
- group: { physicsType: string, instances: InstanceData[] },
202
- modelKey: string,
203
- partCount: number,
204
- flatMeshes: Record<string, Mesh>
187
+ instances: InstanceData[];
188
+ physicsType: RigidBodyProps['type'];
189
+ modelKey: string;
190
+ partCount: number;
191
+ meshParts: Record<string, Mesh>;
205
192
  }) {
206
- const instances = useMemo(
207
- () => group.instances.map(inst => ({
208
- key: inst.id,
209
- position: inst.position,
210
- rotation: inst.rotation,
211
- scale: inst.scale,
212
- })),
213
- [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]
214
198
  );
215
199
 
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
+ });
210
+
216
211
  return (
217
212
  <InstancedRigidBodies
218
- instances={instances}
219
- colliders={group.physicsType === 'fixed' ? 'trimesh' : 'hull'}
220
- type={group.physicsType as 'dynamic' | 'fixed'}
213
+ instances={rigidBodyInstances}
214
+ type={physicsType}
215
+ colliders={physicsType === 'fixed' ? 'trimesh' : 'hull'}
221
216
  >
222
- {Array.from({ length: partCount }).map((_, i) => {
223
- const mesh = flatMeshes[`${modelKey}__${i}`];
224
- return (
217
+ {Array.from({ length: partCount }, (_, i) => {
218
+ const mesh = meshParts[`${modelKey}__${i}`];
219
+ return mesh ? (
225
220
  <instancedMesh
226
221
  key={i}
227
- args={[mesh.geometry, mesh.material, group.instances.length]}
222
+ ref={el => { meshRefs.current[i] = el; }}
223
+ args={[mesh.geometry, mesh.material, instances.length]}
224
+ frustumCulled={false}
228
225
  castShadow
229
226
  receiveShadow
230
- frustumCulled={false}
231
227
  />
232
- );
228
+ ) : null;
233
229
  })}
234
230
  </InstancedRigidBodies>
235
231
  );
236
232
  }
237
233
 
238
- // Render non-physics instances using Merged's per-instance groups
239
- function NonPhysicsInstancedGroup({
234
+ // --- Static Instances (non-physics) ---
235
+ function StaticInstances({
236
+ instances,
240
237
  modelKey,
241
- group,
242
238
  partCount,
243
- instancesMap,
239
+ Components,
244
240
  onSelect,
245
241
  registerRef
246
242
  }: {
243
+ instances: InstanceData[];
247
244
  modelKey: string;
248
- group: { physicsType: string, instances: InstanceData[] };
249
245
  partCount: number;
250
- instancesMap: Record<string, React.ComponentType<any>>;
246
+ Components: Record<string, React.ComponentType>;
251
247
  onSelect?: (id: string | null) => void;
252
248
  registerRef?: (id: string, obj: Object3D | null) => void;
253
249
  }) {
254
- const clickValid = useRef(false);
255
-
256
- const handlePointerDown = (e: any) => {
257
- e.stopPropagation();
258
- clickValid.current = true;
259
- };
260
-
261
- const handlePointerMove = () => {
262
- if (clickValid.current) clickValid.current = false;
263
- };
264
-
265
- const handlePointerUp = (e: any, id: string) => {
266
- if (clickValid.current) {
267
- e.stopPropagation();
268
- onSelect?.(id);
269
- }
270
- clickValid.current = false;
271
- };
250
+ const Parts = useMemo(() =>
251
+ Array.from({ length: partCount }, (_, i) => Components[`${modelKey}__${i}`]).filter(Boolean),
252
+ [Components, modelKey, partCount]
253
+ );
272
254
 
273
255
  return (
274
256
  <>
275
- {group.instances.map(inst => (
276
- <group
277
- key={inst.id}
278
- ref={(el) => { registerRef?.(inst.id, el as unknown as Object3D | null); }}
279
- position={inst.position}
280
- rotation={inst.rotation}
281
- scale={inst.scale}
282
- onPointerDown={handlePointerDown}
283
- onPointerMove={handlePointerMove}
284
- onPointerUp={(e) => handlePointerUp(e, inst.id)}
285
- >
286
- {Array.from({ length: partCount }).map((_, i) => {
287
- const Instance = instancesMap[`${modelKey}__${i}`];
288
- if (!Instance) return null;
289
- return <Instance key={i} />;
290
- })}
291
- </group>
257
+ {instances.map(inst => (
258
+ <InstanceItem key={inst.id} instance={inst} Parts={Parts} onSelect={onSelect} registerRef={registerRef} />
292
259
  ))}
293
260
  </>
294
261
  );
295
262
  }
296
263
 
264
+ // --- Single Instance ---
265
+ function InstanceItem({
266
+ instance,
267
+ Parts,
268
+ onSelect,
269
+ registerRef
270
+ }: {
271
+ instance: InstanceData;
272
+ Parts: React.ComponentType[];
273
+ onSelect?: (id: string | null) => void;
274
+ registerRef?: (id: string, obj: Object3D | null) => void;
275
+ }) {
276
+ const moved = useRef(false);
277
+
278
+ return (
279
+ <group
280
+ ref={el => registerRef?.(instance.id, el)}
281
+ position={instance.position}
282
+ rotation={instance.rotation}
283
+ scale={instance.scale}
284
+ onPointerDown={e => { e.stopPropagation(); moved.current = false; }}
285
+ onPointerMove={() => { moved.current = true; }}
286
+ onPointerUp={e => { e.stopPropagation(); if (!moved.current) onSelect?.(instance.id); }}
287
+ >
288
+ {Parts.map((Part, i) => <Part key={i} />)}
289
+ </group>
290
+ );
291
+ }
297
292
 
298
- // GameInstance component: registers an instance for batch rendering (renders nothing itself)
299
- export const GameInstance = React.forwardRef<Group, {
300
- id: string;
301
- modelUrl: string;
302
- position: [number, number, number];
303
- rotation: [number, number, number];
304
- scale: [number, number, number];
305
- physics?: { type: 'dynamic' | 'fixed' };
306
- }>(({
293
+ // --- GameInstance (declarative registration) ---
294
+ export function GameInstance({
307
295
  id,
308
296
  modelUrl,
309
297
  position,
310
298
  rotation,
311
299
  scale,
312
- physics = undefined,
313
- }, ref) => {
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
+ }) {
314
309
  const ctx = useContext(GameInstanceContext);
315
- const addInstance = ctx?.addInstance;
316
- const removeInstance = ctx?.removeInstance;
317
310
 
318
311
  const instance = useMemo<InstanceData>(() => ({
319
312
  id,
@@ -325,13 +318,10 @@ export const GameInstance = React.forwardRef<Group, {
325
318
  }), [id, modelUrl, position, rotation, scale, physics]);
326
319
 
327
320
  useEffect(() => {
328
- if (!addInstance || !removeInstance) return;
329
- addInstance(instance);
330
- return () => {
331
- removeInstance(instance.id);
332
- };
333
- }, [addInstance, removeInstance, instance]);
334
-
335
- // 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
+
336
326
  return null;
337
- });
327
+ }
@@ -237,49 +237,36 @@ function GameObjectRenderer({
237
237
  />
238
238
  ));
239
239
 
240
- // --- 6. Common props for the wrapper element ---
241
- const wrapperProps = {
242
- position: transformProps.position,
243
- rotation: transformProps.rotation,
244
- onPointerDown: handlePointerDown,
245
- onPointerMove: handlePointerMove,
246
- onPointerUp: handlePointerUp,
247
- };
240
+ // --- 6. Inner content group with full transform ---
241
+ const innerGroup = (
242
+ <group
243
+ ref={(el) => registerRef(gameObject.id, el)}
244
+ position={transformProps.position}
245
+ rotation={transformProps.rotation}
246
+ scale={transformProps.scale}
247
+ onPointerDown={handlePointerDown}
248
+ onPointerMove={handlePointerMove}
249
+ onPointerUp={handlePointerUp}
250
+ >
251
+ {core}
252
+ {children}
253
+ </group>
254
+ );
248
255
 
249
- // --- 7. Check if physics is needed ---
256
+ // --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
250
257
  const physics = gameObject.components?.physics;
251
- const hasPhysics = physics && !editMode;
252
-
253
- // --- 8. Final structure: RigidBody outside with position/rotation, scale inside ---
254
- if (hasPhysics) {
258
+ if (physics && !editMode) {
255
259
  const physicsDef = getComponent('Physics');
256
260
  if (physicsDef?.View) {
257
261
  return (
258
- <physicsDef.View
259
- properties={physics.properties}
260
- ref={(obj: Object3D | null) => registerRef(gameObject.id, obj)}
261
- {...wrapperProps}
262
- >
263
- <group scale={transformProps.scale}>
264
- {core}
265
- {children}
266
- </group>
262
+ <physicsDef.View properties={physics.properties}>
263
+ {innerGroup}
267
264
  </physicsDef.View>
268
265
  );
269
266
  }
270
267
  }
271
268
 
272
- // --- 9. No physics - standard group wrapper ---
273
- return (
274
- <group
275
- ref={(el) => registerRef(gameObject.id, el)}
276
- scale={transformProps.scale}
277
- {...wrapperProps}
278
- >
279
- {core}
280
- {children}
281
- </group>
282
- );
269
+ return innerGroup;
283
270
  }
284
271
 
285
272
  // Helper: render an instanced GameInstance (terminal node)