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 { 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,137 @@
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
- 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) {
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
- 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);
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 !== -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 (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
- // 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
55
  model.updateWorldMatrix(false, true);
60
56
  const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
61
57
  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);
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
- modelParts[modelKey] = partIndex;
67
+ partCounts[modelKey] = partIndex;
73
68
  });
74
- return { flatMeshes, modelParts };
69
+ return { meshParts, partCounts };
75
70
  }, [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
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
- 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: [] };
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
- 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;
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 = modelParts[modelKey] || 0;
91
+ const partCount = (_a = partCounts[modelKey]) !== null && _a !== void 0 ? _a : 0;
114
92
  if (partCount === 0)
115
93
  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];
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
- 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));
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
- // 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}`];
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 null;
138
- return (_jsx("instancedMesh", { args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
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
- // 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))) }));
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
- // 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)) }));
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 component: registers an instance for batch rendering (renders nothing itself)
159
- export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
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 (!addInstance || !removeInstance)
144
+ if (!ctx)
173
145
  return;
174
- addInstance(instance);
175
- return () => {
176
- removeInstance(instance.id);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
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,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
- // 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> = {};
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
- 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);
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
- modelParts[modelKey] = partIndex;
109
+ partCounts[modelKey] = partIndex;
106
110
  });
107
111
 
108
- return { flatMeshes, modelParts };
112
+ return { meshParts, partCounts };
109
113
  }, [models]);
110
114
 
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: [] };
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 = modelParts[modelKey] || 0;
140
+ const partCount = partCounts[modelKey] ?? 0;
167
141
  if (partCount === 0) return null;
168
142
 
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];
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
- key={key}
179
- meshes={meshesForModel}
180
- castShadow
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
- instancesMap={instancesMap}
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
- // Render physics-enabled instances using InstancedRigidBodies
201
- function InstancedRigidGroup({
202
- group,
179
+ // --- Physics Instances ---
180
+ function PhysicsInstances({
181
+ instances,
182
+ physicsType,
203
183
  modelKey,
204
184
  partCount,
205
- flatMeshes
185
+ meshParts
206
186
  }: {
207
- group: { physicsType: string, instances: InstanceData[] },
208
- modelKey: string,
209
- partCount: number,
210
- flatMeshes: Record<string, Mesh>
187
+ instances: InstanceData[];
188
+ physicsType: RigidBodyProps['type'];
189
+ modelKey: string;
190
+ partCount: number;
191
+ meshParts: Record<string, Mesh>;
211
192
  }) {
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]
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
- const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
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={instances}
227
- colliders={colliders}
228
- type={group.physicsType as 'dynamic' | 'fixed'}
213
+ instances={rigidBodyInstances}
214
+ type={physicsType}
215
+ colliders={physicsType === 'fixed' ? 'trimesh' : 'hull'}
229
216
  >
230
- {Array.from({ length: partCount }).map((_, i) => {
231
- const mesh = flatMeshes[`${modelKey}__${i}`];
232
- if (!mesh) return null;
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
- 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}
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
- // Render non-physics instances using Merged's per-instance groups
248
- function NonPhysicsInstancedGroup({
234
+ // --- Static Instances (non-physics) ---
235
+ function StaticInstances({
236
+ instances,
249
237
  modelKey,
250
- group,
251
238
  partCount,
252
- instancesMap,
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
- instancesMap: Record<string, React.ComponentType<any>>;
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
- // 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]
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
- {group.instances.map(inst => (
272
- <InstanceGroupItem
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
- // Individual instance item with its own click state
285
- function InstanceGroupItem({
264
+ // --- Single Instance ---
265
+ function InstanceItem({
286
266
  instance,
287
- InstanceComponents,
267
+ Parts,
288
268
  onSelect,
289
269
  registerRef
290
270
  }: {
291
271
  instance: InstanceData;
292
- InstanceComponents: React.ComponentType<any>[];
272
+ Parts: React.ComponentType[];
293
273
  onSelect?: (id: string | null) => void;
294
274
  registerRef?: (id: string, obj: Object3D | null) => void;
295
275
  }) {
296
- const clickValid = useRef(false);
276
+ const moved = useRef(false);
297
277
 
298
278
  return (
299
279
  <group
300
- ref={(el) => registerRef?.(instance.id, el)}
280
+ ref={el => registerRef?.(instance.id, el)}
301
281
  position={instance.position}
302
282
  rotation={instance.rotation}
303
283
  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
- }}
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
- {InstanceComponents.map((Instance, i) => <Instance key={i} />)}
288
+ {Parts.map((Part, i) => <Part key={i} />)}
315
289
  </group>
316
290
  );
317
291
  }
318
292
 
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
- }>(({
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 = undefined,
335
- }, 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
+ }) {
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 (!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
321
+ if (!ctx) return;
322
+ ctx.addInstance(instance);
323
+ return () => ctx.removeInstance(id);
324
+ }, [ctx, instance, id]);
325
+
358
326
  return null;
359
- });
327
+ }