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 { Object3D, Group } from "three";
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: 'dynamic' | 'fixed';
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 const GameInstance: React.ForwardRefExoticComponent<{
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: "dynamic" | "fixed";
27
+ type: RigidBodyProps['type'];
29
28
  };
30
- } & React.RefAttributes<Group<import("three").Object3DEventMap>>>;
29
+ }): null;
@@ -1,165 +1,120 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
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
- // Helper functions for comparison
7
- function arrayEquals(a, b) {
8
- if (a === b)
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.id === b.id &&
21
- a.meshPath === b.meshPath &&
22
- arrayEquals(a.position, b.position) &&
23
- arrayEquals(a.rotation, b.rotation) &&
24
- arrayEquals(a.scale, b.scale) &&
25
- ((_a = a.physics) === null || _a === void 0 ? void 0 : _a.type) === ((_b = b.physics) === null || _b === void 0 ? void 0 : _b.type);
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 !== -1) {
34
- // Update existing if changed
35
- if (instanceEquals(prev[idx], instance)) {
36
- return prev;
37
- }
38
- const copy = [...prev];
39
- copy[idx] = instance;
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
- // Flatten all model meshes once (models flat mesh parts)
54
- // Note: Geometry is cloned with baked transforms for instancing
55
- const { flatMeshes, modelParts } = useMemo(() => {
56
- const flatMeshes = {};
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.updateWorldMatrix(false, true);
60
- const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
61
- let partIndex = 0;
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
- modelParts[modelKey] = partIndex;
59
+ partCounts[modelKey] = parts.length;
73
60
  });
74
- return { flatMeshes, modelParts };
61
+ return { meshParts, partCounts };
75
62
  }, [models]);
76
- // Cleanup geometries when models change
77
- useEffect(() => {
78
- return () => {
79
- Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
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
- for (const inst of instances) {
87
- const type = ((_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) || 'none';
88
- const key = `${inst.meshPath}__${type}`;
89
- if (!groups[key])
90
- groups[key] = { physicsType: type, instances: [] };
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
- return (_jsxs(GameInstanceContext.Provider, { value: {
96
- addInstance,
97
- removeInstance,
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 = modelParts[modelKey] || 0;
83
+ const partCount = (_a = partCounts[modelKey]) !== null && _a !== void 0 ? _a : 0;
114
84
  if (partCount === 0)
115
85
  return null;
116
- // Create mesh subset for this specific model
117
- const meshesForModel = {};
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
- return (_jsx(Merged, { meshes: meshesForModel, castShadow: true, receiveShadow: true, children: (instancesMap) => (_jsx(NonPhysicsInstancedGroup, { modelKey: modelKey, group: group, partCount: partCount, instancesMap: instancesMap, onSelect: onSelect, registerRef: registerRef })) }, key));
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
- // Render physics-enabled instances using InstancedRigidBodies
126
- function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
127
- const instances = useMemo(() => group.instances.map(inst => ({
128
- key: inst.id,
129
- position: inst.position,
130
- rotation: inst.rotation,
131
- scale: inst.scale,
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}`];
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
- // Render non-physics instances using Merged's per-instance groups
142
- function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, onSelect, registerRef }) {
143
- // Pre-compute which Instance components exist for this model
144
- const InstanceComponents = useMemo(() => Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean), [instancesMap, modelKey, partCount]);
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
- // Individual instance item with its own click state
148
- function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef }) {
149
- const clickValid = useRef(false);
150
- 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(); clickValid.current = true; }, onPointerMove: () => { clickValid.current = false; }, onPointerUp: (e) => {
151
- if (clickValid.current) {
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 component: registers an instance for batch rendering (renders nothing itself)
159
- export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
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 (!addInstance || !removeInstance)
127
+ if (!ctx)
173
128
  return;
174
- addInstance(instance);
175
- return () => {
176
- removeInstance(instance.id);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -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, Group } from "three";
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
- meshPath: string;
13
- physics?: { type: 'dynamic' | 'fixed' };
13
+ physics?: { type: RigidBodyProps['type'] };
14
14
  };
15
15
 
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
- }
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: { [filename: string]: Object3D },
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 !== -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];
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
- // Flatten all model meshes once (models flat mesh parts)
84
- // Note: Geometry is cloned with baked transforms for instancing
85
- const { flatMeshes, modelParts } = useMemo(() => {
86
- const flatMeshes: Record<string, Mesh> = {};
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.updateWorldMatrix(false, true);
91
- const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
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
- modelParts[modelKey] = partIndex;
96
+ partCounts[modelKey] = parts.length;
106
97
  });
107
98
 
108
- return { flatMeshes, modelParts };
99
+ return { meshParts, partCounts };
109
100
  }, [models]);
110
101
 
111
- // Cleanup geometries when models change
112
- useEffect(() => {
113
- return () => {
114
- Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
115
- };
116
- }, [flatMeshes]);
117
-
118
- // Group instances by meshPath + physics type for batch rendering
119
- const grouped = useMemo(() => {
120
- const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
121
- for (const inst of instances) {
122
- const type = inst.physics?.type || 'none';
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 = modelParts[modelKey] || 0;
127
+ const partCount = partCounts[modelKey] ?? 0;
167
128
  if (partCount === 0) return null;
168
129
 
169
- // Create mesh subset for this specific model
170
- const meshesForModel: Record<string, Mesh> = {};
171
- for (let i = 0; i < partCount; i++) {
172
- const partKey = `${modelKey}__${i}`;
173
- meshesForModel[partKey] = flatMeshes[partKey];
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
- key={key}
179
- meshes={meshesForModel}
180
- castShadow
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
- instancesMap={instancesMap}
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
- // Render physics-enabled instances using InstancedRigidBodies
201
- function InstancedRigidGroup({
202
- group,
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
- flatMeshes
174
+ meshParts
206
175
  }: {
207
- group: { physicsType: string, instances: InstanceData[] },
208
- modelKey: string,
209
- partCount: number,
210
- flatMeshes: Record<string, Mesh>
176
+ instances: InstanceData[];
177
+ physicsType: RigidBodyProps['type'];
178
+ modelKey: string;
179
+ partCount: number;
180
+ meshParts: Record<string, Mesh>;
211
181
  }) {
212
- const instances = useMemo(
213
- () => group.instances.map(inst => ({
214
- key: inst.id,
215
- position: inst.position,
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={instances}
227
- colliders={colliders}
228
- type={group.physicsType as 'dynamic' | 'fixed'}
190
+ instances={rigidBodyInstances}
191
+ type={physicsType}
192
+ colliders={physicsType === 'fixed' ? 'trimesh' : 'hull'}
229
193
  >
230
- {Array.from({ length: partCount }).map((_, i) => {
231
- const mesh = flatMeshes[`${modelKey}__${i}`];
232
- if (!mesh) return null;
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, group.instances.length]}
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
- // Render non-physics instances using Merged's per-instance groups
248
- function NonPhysicsInstancedGroup({
210
+ // --- Static Instances (non-physics) ---
211
+ function StaticInstances({
212
+ instances,
249
213
  modelKey,
250
- group,
251
214
  partCount,
252
- instancesMap,
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
- instancesMap: Record<string, React.ComponentType<any>>;
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
- // Pre-compute which Instance components exist for this model
264
- const InstanceComponents = useMemo(() =>
265
- Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean),
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
- {group.instances.map(inst => (
272
- <InstanceGroupItem
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
- // Individual instance item with its own click state
285
- function InstanceGroupItem({
240
+ // --- Single Instance ---
241
+ function InstanceItem({
286
242
  instance,
287
- InstanceComponents,
243
+ Parts,
288
244
  onSelect,
289
245
  registerRef
290
246
  }: {
291
247
  instance: InstanceData;
292
- InstanceComponents: React.ComponentType<any>[];
248
+ Parts: React.ComponentType[];
293
249
  onSelect?: (id: string | null) => void;
294
250
  registerRef?: (id: string, obj: Object3D | null) => void;
295
251
  }) {
296
- const clickValid = useRef(false);
252
+ const moved = useRef(false);
297
253
 
298
254
  return (
299
255
  <group
300
- ref={(el) => registerRef?.(instance.id, el)}
256
+ ref={el => registerRef?.(instance.id, el)}
301
257
  position={instance.position}
302
258
  rotation={instance.rotation}
303
259
  scale={instance.scale}
304
- onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
305
- onPointerMove={() => { clickValid.current = false; }}
306
- onPointerUp={(e) => {
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
- {InstanceComponents.map((Instance, i) => <Instance key={i} />)}
264
+ {Parts.map((Part, i) => <Part key={i} />)}
315
265
  </group>
316
266
  );
317
267
  }
318
268
 
319
-
320
- // GameInstance component: registers an instance for batch rendering (renders nothing itself)
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 = undefined,
335
- }, ref) => {
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 (!addInstance || !removeInstance) return;
351
- addInstance(instance);
352
- return () => {
353
- removeInstance(instance.id);
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
+ }