react-three-game 0.0.16 → 0.0.18

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.
Files changed (33) hide show
  1. package/README.md +88 -113
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/tools/prefabeditor/EditorTree.js +27 -15
  5. package/dist/tools/prefabeditor/EditorUI.js +2 -8
  6. package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
  7. package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
  8. package/dist/tools/prefabeditor/PrefabEditor.js +128 -59
  9. package/dist/tools/prefabeditor/PrefabRoot.js +51 -33
  10. package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +2 -0
  11. package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
  12. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +114 -0
  13. package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
  14. package/dist/tools/prefabeditor/components/RotatorComponent.d.ts +3 -0
  15. package/dist/tools/prefabeditor/components/RotatorComponent.js +42 -0
  16. package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
  17. package/dist/tools/prefabeditor/components/TransformComponent.js +28 -3
  18. package/dist/tools/prefabeditor/components/index.js +2 -0
  19. package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
  20. package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
  21. package/package.json +8 -8
  22. package/src/index.ts +4 -0
  23. package/src/tools/prefabeditor/EditorTree.tsx +39 -16
  24. package/src/tools/prefabeditor/EditorUI.tsx +2 -27
  25. package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
  26. package/src/tools/prefabeditor/PrefabEditor.tsx +202 -86
  27. package/src/tools/prefabeditor/PrefabRoot.tsx +62 -54
  28. package/src/tools/prefabeditor/components/ComponentRegistry.ts +7 -1
  29. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +332 -0
  30. package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
  31. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
  32. package/src/tools/prefabeditor/components/TransformComponent.tsx +69 -16
  33. package/src/tools/prefabeditor/components/index.ts +2 -0
@@ -1,6 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- function TransformComponentEditor({ component, onUpdate }) {
3
- return _jsxs("div", { className: "flex flex-col", children: [_jsx(Vector3Input, { label: "Position", value: component.properties.position, onChange: v => onUpdate({ position: v }) }), _jsx(Vector3Input, { label: "Rotation", value: component.properties.rotation, onChange: v => onUpdate({ rotation: v }) }), _jsx(Vector3Input, { label: "Scale", value: component.properties.scale, onChange: v => onUpdate({ scale: v }) })] });
2
+ function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }) {
3
+ const s = {
4
+ button: {
5
+ padding: '2px 6px',
6
+ background: 'transparent',
7
+ color: 'rgba(255,255,255,0.9)',
8
+ border: '1px solid rgba(255,255,255,0.14)',
9
+ borderRadius: 4,
10
+ cursor: 'pointer',
11
+ font: 'inherit',
12
+ },
13
+ buttonActive: {
14
+ background: 'rgba(255,255,255,0.10)',
15
+ },
16
+ };
17
+ return _jsxs("div", { className: "flex flex-col", children: [transformMode && setTransformMode && (_jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: "Transform Mode" }), _jsx("div", { style: { display: 'flex', gap: 6 }, children: ["translate", "rotate", "scale"].map(mode => (_jsx("button", { onClick: () => setTransformMode(mode), style: Object.assign(Object.assign(Object.assign({}, s.button), { flex: 1 }), (transformMode === mode ? s.buttonActive : {})), onPointerEnter: (e) => {
18
+ if (transformMode !== mode)
19
+ e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
20
+ }, onPointerLeave: (e) => {
21
+ if (transformMode !== mode)
22
+ e.currentTarget.style.background = 'transparent';
23
+ }, children: mode }, mode))) })] })), _jsx(Vector3Input, { label: "Position", value: component.properties.position, onChange: v => onUpdate({ position: v }) }), _jsx(Vector3Input, { label: "Rotation", value: component.properties.rotation, onChange: v => onUpdate({ rotation: v }) }), _jsx(Vector3Input, { label: "Scale", value: component.properties.scale, onChange: v => onUpdate({ scale: v }) })] });
4
24
  }
5
25
  const TransformComponent = {
6
26
  name: 'Transform',
@@ -18,5 +38,10 @@ export function Vector3Input({ label, value, onChange }) {
18
38
  newValue[index] = parseFloat(val) || 0;
19
39
  onChange(newValue);
20
40
  };
21
- return _jsxs("div", { className: "mb-1", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: label }), _jsxs("div", { className: "flex gap-0.5", children: [_jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-red-400/80 font-mono", children: "X" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[0], onChange: e => handleChange(0, e.target.value) })] }), _jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-green-400/80 font-mono", children: "Y" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[1], onChange: e => handleChange(1, e.target.value) })] }), _jsxs("div", { className: "relative flex-1", children: [_jsx("span", { className: "absolute left-0.5 top-0 text-[8px] text-blue-400/80 font-mono", children: "Z" }), _jsx("input", { className: "w-full bg-black/40 border border-cyan-500/30 pl-3 pr-0.5 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50", type: "number", step: "0.1", value: value[2], onChange: e => handleChange(2, e.target.value) })] })] })] });
41
+ const axes = [
42
+ { key: 'x', color: 'red', index: 0 },
43
+ { key: 'y', color: 'green', index: 1 },
44
+ { key: 'z', color: 'blue', index: 2 }
45
+ ];
46
+ return _jsxs("div", { className: "mb-2", children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1", children: label }), _jsx("div", { className: "flex gap-1", children: axes.map(({ key, color, index }) => (_jsxs("div", { className: "flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]", children: [_jsx("span", { className: `text-xs font-bold text-${color}-400 w-3`, children: key.toUpperCase() }), _jsx("input", { className: "flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0", type: "number", step: "0.1", value: value[index].toFixed(2), onChange: e => handleChange(index, e.target.value), onFocus: e => e.target.select() })] }, key))) })] });
22
47
  }
@@ -3,6 +3,7 @@ import TransformComponent from './TransformComponent';
3
3
  import MaterialComponent from './MaterialComponent';
4
4
  import PhysicsComponent from './PhysicsComponent';
5
5
  import SpotLightComponent from './SpotLightComponent';
6
+ import DirectionalLightComponent from './DirectionalLightComponent';
6
7
  import ModelComponent from './ModelComponent';
7
8
  export default [
8
9
  GeometryComponent,
@@ -10,5 +11,6 @@ export default [
10
11
  MaterialComponent,
11
12
  PhysicsComponent,
12
13
  SpotLightComponent,
14
+ DirectionalLightComponent,
13
15
  ModelComponent
14
16
  ];
@@ -0,0 +1,10 @@
1
+ import { Object3D } from 'three';
2
+ /**
3
+ * Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
4
+ * Returns the loaded model with proper caching and suspense support
5
+ */
6
+ export declare function useModel(filename: string | undefined): Object3D | null;
7
+ /**
8
+ * Preload a model to avoid suspense boundaries during runtime
9
+ */
10
+ export declare function preloadModel(filename: string): void;
@@ -0,0 +1,40 @@
1
+ import { useGLTF, useFBX } from '@react-three/drei';
2
+ import { useMemo } from 'react';
3
+ /**
4
+ * Hook to load a model (GLB/GLTF/FBX) using drei's optimized loaders
5
+ * Returns the loaded model with proper caching and suspense support
6
+ */
7
+ export function useModel(filename) {
8
+ const isFBX = filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.fbx');
9
+ const isGLTF = (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.glb')) || (filename === null || filename === void 0 ? void 0 : filename.toLowerCase().endsWith('.gltf'));
10
+ // Normalize path (ensure leading slash)
11
+ const normalizedPath = useMemo(() => {
12
+ if (!filename)
13
+ return '';
14
+ return filename.startsWith('/') ? filename : `/${filename}`;
15
+ }, [filename]);
16
+ // Load models using drei hooks (these handle caching automatically)
17
+ const gltf = useGLTF(isGLTF && normalizedPath ? normalizedPath : '', true);
18
+ const fbx = useFBX(isFBX && normalizedPath ? normalizedPath : '');
19
+ // Return the appropriate model
20
+ if (!filename)
21
+ return null;
22
+ if (isGLTF)
23
+ return gltf.scene;
24
+ if (isFBX)
25
+ return fbx;
26
+ return null;
27
+ }
28
+ /**
29
+ * Preload a model to avoid suspense boundaries during runtime
30
+ */
31
+ export function preloadModel(filename) {
32
+ const normalizedPath = filename.startsWith('/') ? filename : `/${filename}`;
33
+ const isFBX = filename.toLowerCase().endsWith('.fbx');
34
+ if (isFBX) {
35
+ useFBX.preload(normalizedPath);
36
+ }
37
+ else {
38
+ useGLTF.preload(normalizedPath);
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -17,12 +17,12 @@
17
17
  "type": "module",
18
18
  "workspaces": ["docs"],
19
19
  "peerDependencies": {
20
- "@react-three/fiber": "^9.0.0",
21
- "@react-three/drei": "^10.0.0",
22
- "@react-three/rapier": "^2.0.0",
23
- "react": "^18.0.0 || ^19.0.0",
24
- "react-dom": "^18.0.0 || ^19.0.0",
25
- "three": "^0.181.0"
20
+ "@react-three/fiber": ">=9.0.0",
21
+ "@react-three/drei": ">=10.0.0",
22
+ "@react-three/rapier": ">=2.0.0",
23
+ "react": ">=18.0.0",
24
+ "react-dom": ">=18.0.0",
25
+ "three": ">=0.182.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@react-three/drei": "^10.7.7",
@@ -34,7 +34,7 @@
34
34
  "concurrently": "^9.2.1",
35
35
  "react": "^19.2.0",
36
36
  "react-dom": "^19.2.0",
37
- "three": "^0.181.2",
37
+ "three": "^0.182.0",
38
38
  "typescript": "^5.9.3"
39
39
  }
40
40
  }
package/src/index.ts CHANGED
@@ -10,6 +10,10 @@ export {
10
10
  SharedCanvas,
11
11
  } from './tools/assetviewer/page';
12
12
 
13
+ // Component Registry
14
+ export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
15
+ export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
16
+
13
17
  // Helpers
14
18
  export * from './helpers';
15
19
 
@@ -74,6 +74,17 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
74
74
  overflow: "hidden",
75
75
  textOverflow: "ellipsis",
76
76
  },
77
+ dragHandle: {
78
+ width: 14,
79
+ height: 14,
80
+ display: "flex",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ marginRight: 4,
84
+ opacity: 0.4,
85
+ cursor: "grab",
86
+ fontSize: 10,
87
+ },
77
88
  contextMenu: {
78
89
  position: "fixed",
79
90
  zIndex: 50,
@@ -179,40 +190,38 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
179
190
 
180
191
  // Drag and Drop
181
192
  const handleDragStart = (e: React.DragEvent, id: string) => {
182
- e.stopPropagation();
183
193
  if (id === prefabData.root.id) {
184
- e.preventDefault(); // Cannot drag root
194
+ e.preventDefault();
185
195
  return;
186
196
  }
187
- setDraggedId(id);
188
197
  e.dataTransfer.effectAllowed = "move";
198
+ e.dataTransfer.setData("text/plain", id);
199
+ setDraggedId(id);
200
+ };
201
+
202
+ const handleDragEnd = () => {
203
+ setDraggedId(null);
189
204
  };
190
205
 
191
206
  const handleDragOver = (e: React.DragEvent, targetId: string) => {
192
- e.preventDefault();
193
- e.stopPropagation();
194
207
  if (!draggedId || draggedId === targetId) return;
195
-
196
- // Check for cycles: target cannot be a descendant of dragged node
197
208
  const draggedNode = findNode(prefabData.root, draggedId);
198
209
  if (draggedNode && findNode(draggedNode, targetId)) return;
199
210
 
211
+ e.preventDefault();
200
212
  e.dataTransfer.dropEffect = "move";
201
213
  };
202
214
 
203
215
  const handleDrop = (e: React.DragEvent, targetId: string) => {
204
- e.preventDefault();
205
- e.stopPropagation();
206
216
  if (!draggedId || draggedId === targetId) return;
207
217
 
218
+ e.preventDefault();
219
+
208
220
  setPrefabData(prev => {
209
221
  const newRoot = JSON.parse(JSON.stringify(prev.root));
222
+ const draggedNode = findNode(newRoot, draggedId);
223
+ if (draggedNode && findNode(draggedNode, targetId)) return prev;
210
224
 
211
- // Check cycle again on the fresh tree
212
- const draggedNodeRef = findNode(newRoot, draggedId);
213
- if (draggedNodeRef && findNode(draggedNodeRef, targetId)) return prev;
214
-
215
- // Remove from old parent
216
225
  const parent = findParent(newRoot, draggedId);
217
226
  if (!parent) return prev;
218
227
 
@@ -221,7 +230,6 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
221
230
 
222
231
  parent.children = parent.children!.filter(c => c.id !== draggedId);
223
232
 
224
- // Add to new parent
225
233
  const target = findNode(newRoot, targetId);
226
234
  if (target) {
227
235
  target.children = target.children || [];
@@ -247,11 +255,13 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
247
255
  ...styles.row,
248
256
  ...(isSelected ? styles.rowSelected : null),
249
257
  paddingLeft: `${depth * 10 + 6}px`,
258
+ cursor: node.id !== prefabData.root.id ? "grab" : "pointer",
250
259
  }}
260
+ draggable={node.id !== prefabData.root.id}
251
261
  onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
252
262
  onContextMenu={(e) => handleContextMenu(e, node.id)}
253
- draggable={node.id !== prefabData.root.id}
254
263
  onDragStart={(e) => handleDragStart(e, node.id)}
264
+ onDragEnd={handleDragEnd}
255
265
  onDragOver={(e) => handleDragOver(e, node.id)}
256
266
  onDrop={(e) => handleDrop(e, node.id)}
257
267
  onPointerEnter={(e) => {
@@ -276,6 +286,19 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
276
286
  >
277
287
  {isCollapsed ? '▶' : '▼'}
278
288
  </span>
289
+ {node.id !== prefabData.root.id && (
290
+ <span
291
+ style={styles.dragHandle}
292
+ onPointerEnter={(e) => {
293
+ (e.currentTarget as HTMLSpanElement).style.opacity = "0.9";
294
+ }}
295
+ onPointerLeave={(e) => {
296
+ (e.currentTarget as HTMLSpanElement).style.opacity = "0.4";
297
+ }}
298
+ >
299
+ ⋮⋮
300
+ </span>
301
+ )}
279
302
  <span style={styles.idText}>
280
303
  {node.id}
281
304
  </span>
@@ -256,33 +256,6 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
256
256
  </button>
257
257
  </div>
258
258
 
259
- <div style={s.section}>
260
- <label style={s.label}>Mode</label>
261
- <div style={{ display: 'flex', gap: 6 }}>
262
- {["translate", "rotate", "scale"].map(mode => (
263
- <button
264
- key={mode}
265
- onClick={() => setTransformMode(mode as any)}
266
- style={{
267
- ...s.button,
268
- flex: 1,
269
- ...(transformMode === mode ? s.buttonActive : null),
270
- }}
271
- onPointerEnter={(e) => {
272
- if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.08)';
273
- }}
274
- onPointerLeave={(e) => {
275
- if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
276
- }}
277
- >
278
- {mode[0].toUpperCase()}
279
- </button>
280
- ))}
281
- </div>
282
- </div>
283
-
284
- {/* Components (legacy renderer removed) */}
285
-
286
259
  {node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
287
260
  if (!comp) return null;
288
261
  const componentDef = ALL_COMPONENTS[comp.type];
@@ -322,6 +295,8 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
322
295
  }
323
296
  }))}
324
297
  basePath={basePath}
298
+ transformMode={transformMode}
299
+ setTransformMode={setTransformMode}
325
300
  />
326
301
  ) : null}
327
302
  </div>
@@ -1,7 +1,7 @@
1
1
  import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
2
2
  import { Merged } from '@react-three/drei';
3
- import * as THREE from 'three';
4
3
  import { InstancedRigidBodies } from "@react-three/rapier";
4
+ import { Mesh, Matrix4, Object3D, Group } from "three";
5
5
 
6
6
  // --- Types ---
7
7
  export type InstanceData = {
@@ -13,7 +13,8 @@ export type InstanceData = {
13
13
  physics?: { type: 'dynamic' | 'fixed' };
14
14
  };
15
15
 
16
- function arrayEquals(a: number[], b: number[]) {
16
+ // Helper functions for comparison
17
+ function arrayEquals(a: number[], b: number[]): boolean {
17
18
  if (a === b) return true;
18
19
  if (a.length !== b.length) return false;
19
20
  for (let i = 0; i < a.length; i++) {
@@ -22,7 +23,7 @@ function arrayEquals(a: number[], b: number[]) {
22
23
  return true;
23
24
  }
24
25
 
25
- function instanceEquals(a: InstanceData, b: InstanceData) {
26
+ function instanceEquals(a: InstanceData, b: InstanceData): boolean {
26
27
  return a.id === b.id &&
27
28
  a.meshPath === b.meshPath &&
28
29
  arrayEquals(a.position, b.position) &&
@@ -36,7 +37,7 @@ type GameInstanceContextType = {
36
37
  addInstance: (instance: InstanceData) => void;
37
38
  removeInstance: (id: string) => void;
38
39
  instances: InstanceData[];
39
- meshes: Record<string, THREE.Mesh>;
40
+ meshes: Record<string, Mesh>;
40
41
  instancesMap?: Record<string, React.ComponentType<any>>;
41
42
  modelParts?: Record<string, number>;
42
43
  };
@@ -44,13 +45,14 @@ const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
44
45
 
45
46
  export function GameInstanceProvider({
46
47
  children,
47
- models
48
- , onSelect, registerRef
48
+ models,
49
+ onSelect,
50
+ registerRef
49
51
  }: {
50
52
  children: React.ReactNode,
51
- models: { [filename: string]: THREE.Object3D },
53
+ models: { [filename: string]: Object3D },
52
54
  onSelect?: (id: string | null) => void,
53
- registerRef?: (id: string, obj: THREE.Object3D | null) => void,
55
+ registerRef?: (id: string, obj: Object3D | null) => void,
54
56
  }) {
55
57
  const [instances, setInstances] = useState<InstanceData[]>([]);
56
58
 
@@ -58,6 +60,7 @@ export function GameInstanceProvider({
58
60
  setInstances(prev => {
59
61
  const idx = prev.findIndex(i => i.id === instance.id);
60
62
  if (idx !== -1) {
63
+ // Update existing if changed
61
64
  if (instanceEquals(prev[idx], instance)) {
62
65
  return prev;
63
66
  }
@@ -65,6 +68,7 @@ export function GameInstanceProvider({
65
68
  copy[idx] = instance;
66
69
  return copy;
67
70
  }
71
+ // Add new
68
72
  return [...prev, instance];
69
73
  });
70
74
  }, []);
@@ -76,27 +80,26 @@ export function GameInstanceProvider({
76
80
  });
77
81
  }, []);
78
82
 
79
- // Flatten all model meshes once
83
+ // Flatten all model meshes once (models → flat mesh parts)
80
84
  const { flatMeshes, modelParts } = useMemo(() => {
81
- const flatMeshes: Record<string, THREE.Mesh> = {};
85
+ const flatMeshes: Record<string, Mesh> = {};
82
86
  const modelParts: Record<string, number> = {};
83
87
 
84
88
  Object.entries(models).forEach(([modelKey, model]) => {
85
89
  const root = model;
86
90
  root.updateWorldMatrix(false, true);
87
- const rootInverse = new THREE.Matrix4().copy(root.matrixWorld).invert();
91
+ const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
88
92
 
89
93
  let partIndex = 0;
90
94
 
91
95
  root.traverse((obj: any) => {
92
96
  if (obj.isMesh) {
93
97
  const geom = obj.geometry.clone();
94
-
95
98
  const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
96
99
  geom.applyMatrix4(relativeTransform);
97
100
 
98
101
  const partKey = `${modelKey}__${partIndex}`;
99
- flatMeshes[partKey] = new THREE.Mesh(geom, obj.material);
102
+ flatMeshes[partKey] = new Mesh(geom, obj.material);
100
103
  partIndex++;
101
104
  }
102
105
  });
@@ -106,7 +109,7 @@ export function GameInstanceProvider({
106
109
  return { flatMeshes, modelParts };
107
110
  }, [models]);
108
111
 
109
- // Group instances by meshPath + physics type
112
+ // Group instances by meshPath + physics type for batch rendering
110
113
  const grouped = useMemo(() => {
111
114
  const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
112
115
  for (const inst of instances) {
@@ -128,10 +131,10 @@ export function GameInstanceProvider({
128
131
  modelParts
129
132
  }}
130
133
  >
131
- {/* 1) Normal prefab hierarchy: NOT inside any <Merged> */}
134
+ {/* Render normal prefab hierarchy (non-instanced objects) */}
132
135
  {children}
133
136
 
134
- {/* 2) Physics instanced groups: no <Merged>, just InstancedRigidBodies */}
137
+ {/* Render physics-enabled instanced groups using InstancedRigidBodies */}
135
138
  {Object.entries(grouped).map(([key, group]) => {
136
139
  if (group.physicsType === 'none') return null;
137
140
  const modelKey = group.instances[0].meshPath;
@@ -149,7 +152,7 @@ export function GameInstanceProvider({
149
152
  );
150
153
  })}
151
154
 
152
- {/* 3) Non-physics instanced visuals: own <Merged> per model */}
155
+ {/* Render non-physics instanced visuals using Merged (one per model type) */}
153
156
  {Object.entries(grouped).map(([key, group]) => {
154
157
  if (group.physicsType !== 'none') return null;
155
158
 
@@ -157,8 +160,8 @@ export function GameInstanceProvider({
157
160
  const partCount = modelParts[modelKey] || 0;
158
161
  if (partCount === 0) return null;
159
162
 
160
- // Restrict meshes to just this model's parts for this Merged
161
- const meshesForModel: Record<string, THREE.Mesh> = {};
163
+ // Create mesh subset for this specific model
164
+ const meshesForModel: Record<string, Mesh> = {};
162
165
  for (let i = 0; i < partCount; i++) {
163
166
  const partKey = `${modelKey}__${i}`;
164
167
  meshesForModel[partKey] = flatMeshes[partKey];
@@ -188,7 +191,7 @@ export function GameInstanceProvider({
188
191
  );
189
192
  }
190
193
 
191
- // Physics instancing stays the same
194
+ // Render physics-enabled instances using InstancedRigidBodies
192
195
  function InstancedRigidGroup({
193
196
  group,
194
197
  modelKey,
@@ -198,7 +201,7 @@ function InstancedRigidGroup({
198
201
  group: { physicsType: string, instances: InstanceData[] },
199
202
  modelKey: string,
200
203
  partCount: number,
201
- flatMeshes: Record<string, THREE.Mesh>
204
+ flatMeshes: Record<string, Mesh>
202
205
  }) {
203
206
  const instances = useMemo(
204
207
  () => group.instances.map(inst => ({
@@ -232,24 +235,33 @@ function InstancedRigidGroup({
232
235
  );
233
236
  }
234
237
 
235
- // Non-physics instanced visuals: per-instance group using Merged's Instance components
238
+ // Render non-physics instances using Merged's per-instance groups
236
239
  function NonPhysicsInstancedGroup({
237
240
  modelKey,
238
241
  group,
239
242
  partCount,
240
- instancesMap
241
- , onSelect, registerRef
243
+ instancesMap,
244
+ onSelect,
245
+ registerRef
242
246
  }: {
243
247
  modelKey: string;
244
248
  group: { physicsType: string, instances: InstanceData[] };
245
249
  partCount: number;
246
250
  instancesMap: Record<string, React.ComponentType<any>>;
247
251
  onSelect?: (id: string | null) => void;
248
- registerRef?: (id: string, obj: THREE.Object3D | null) => void;
252
+ registerRef?: (id: string, obj: Object3D | null) => void;
249
253
  }) {
250
254
  const clickValid = useRef(false);
251
- const handlePointerDown = (e: any) => { e.stopPropagation(); clickValid.current = true; };
252
- const handlePointerMove = () => { if (clickValid.current) clickValid.current = 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
+
253
265
  const handlePointerUp = (e: any, id: string) => {
254
266
  if (clickValid.current) {
255
267
  e.stopPropagation();
@@ -263,7 +275,7 @@ function NonPhysicsInstancedGroup({
263
275
  {group.instances.map(inst => (
264
276
  <group
265
277
  key={inst.id}
266
- ref={(el) => { registerRef?.(inst.id, el as unknown as THREE.Object3D | null); }}
278
+ ref={(el) => { registerRef?.(inst.id, el as unknown as Object3D | null); }}
267
279
  position={inst.position}
268
280
  rotation={inst.rotation}
269
281
  scale={inst.scale}
@@ -283,8 +295,8 @@ function NonPhysicsInstancedGroup({
283
295
  }
284
296
 
285
297
 
286
- // --- GameInstance: just registers an instance, renders nothing ---
287
- export const GameInstance = React.forwardRef<THREE.Group, {
298
+ // GameInstance component: registers an instance for batch rendering (renders nothing itself)
299
+ export const GameInstance = React.forwardRef<Group, {
288
300
  id: string;
289
301
  modelUrl: string;
290
302
  position: [number, number, number];
@@ -320,7 +332,6 @@ export const GameInstance = React.forwardRef<THREE.Group, {
320
332
  };
321
333
  }, [addInstance, removeInstance, instance]);
322
334
 
323
-
324
- // No visual here – provider will render visuals for all instances
335
+ // No visual rendering - provider handles all instanced visuals
325
336
  return null;
326
337
  });