react-three-game 0.0.21 → 0.0.22

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.
@@ -51,19 +51,19 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
51
51
  });
52
52
  }, []);
53
53
  // Flatten all model meshes once (models → flat mesh parts)
54
+ // Note: Geometry is cloned with baked transforms for instancing
54
55
  const { flatMeshes, modelParts } = useMemo(() => {
55
56
  const flatMeshes = {};
56
57
  const modelParts = {};
57
58
  Object.entries(models).forEach(([modelKey, model]) => {
58
- const root = model;
59
- root.updateWorldMatrix(false, true);
60
- const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
59
+ model.updateWorldMatrix(false, true);
60
+ const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
61
61
  let partIndex = 0;
62
- root.traverse((obj) => {
62
+ model.traverse((obj) => {
63
63
  if (obj.isMesh) {
64
+ // Clone geometry and bake relative transform
64
65
  const geom = obj.geometry.clone();
65
- const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
66
- geom.applyMatrix4(relativeTransform);
66
+ geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
67
67
  const partKey = `${modelKey}__${partIndex}`;
68
68
  flatMeshes[partKey] = new Mesh(geom, obj.material);
69
69
  partIndex++;
@@ -73,6 +73,12 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef }
73
73
  });
74
74
  return { flatMeshes, modelParts };
75
75
  }, [models]);
76
+ // Cleanup geometries when models change
77
+ useEffect(() => {
78
+ return () => {
79
+ Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
80
+ };
81
+ }, [flatMeshes]);
76
82
  // Group instances by meshPath + physics type for batch rendering
77
83
  const grouped = useMemo(() => {
78
84
  var _a;
@@ -124,35 +130,30 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
124
130
  rotation: inst.rotation,
125
131
  scale: inst.scale,
126
132
  })), [group.instances]);
127
- return (_jsx(InstancedRigidBodies, { instances: instances, colliders: group.physicsType === 'fixed' ? 'trimesh' : 'hull', type: group.physicsType, children: Array.from({ length: partCount }).map((_, i) => {
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) => {
128
135
  const mesh = flatMeshes[`${modelKey}__${i}`];
136
+ if (!mesh)
137
+ return null;
129
138
  return (_jsx("instancedMesh", { args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
130
139
  }) }));
131
140
  }
132
141
  // Render non-physics instances using Merged's per-instance groups
133
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))) }));
146
+ }
147
+ // Individual instance item with its own click state
148
+ function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef }) {
134
149
  const clickValid = useRef(false);
135
- const handlePointerDown = (e) => {
136
- e.stopPropagation();
137
- clickValid.current = true;
138
- };
139
- const handlePointerMove = () => {
140
- if (clickValid.current)
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
+ }
141
155
  clickValid.current = false;
142
- };
143
- const handlePointerUp = (e, id) => {
144
- if (clickValid.current) {
145
- e.stopPropagation();
146
- onSelect === null || onSelect === void 0 ? void 0 : onSelect(id);
147
- }
148
- clickValid.current = false;
149
- };
150
- return (_jsx(_Fragment, { children: group.instances.map(inst => (_jsx("group", { ref: (el) => { registerRef === null || registerRef === void 0 ? void 0 : registerRef(inst.id, el); }, position: inst.position, rotation: inst.rotation, scale: inst.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: (e) => handlePointerUp(e, inst.id), children: Array.from({ length: partCount }).map((_, i) => {
151
- const Instance = instancesMap[`${modelKey}__${i}`];
152
- if (!Instance)
153
- return null;
154
- return _jsx(Instance, {}, i);
155
- }) }, inst.id))) }));
156
+ }, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
156
157
  }
157
158
  // GameInstance component: registers an instance for batch rendering (renders nothing itself)
158
159
  export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
@@ -146,26 +146,17 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
146
146
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
147
147
  // --- 5. Render children recursively (always relative transforms) ---
148
148
  const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
149
- // --- 6. Common props for the wrapper element ---
150
- const wrapperProps = {
151
- position: transformProps.position,
152
- rotation: transformProps.rotation,
153
- onPointerDown: handlePointerDown,
154
- onPointerMove: handlePointerMove,
155
- onPointerUp: handlePointerUp,
156
- };
157
- // --- 7. Check if physics is needed ---
149
+ // --- 6. Inner content group with full transform ---
150
+ const innerGroup = (_jsxs("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [core, children] }));
151
+ // --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
158
152
  const physics = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.physics;
159
- const hasPhysics = physics && !editMode;
160
- // --- 8. Final structure: RigidBody outside with position/rotation, scale inside ---
161
- if (hasPhysics) {
153
+ if (physics && !editMode) {
162
154
  const physicsDef = getComponent('Physics');
163
155
  if (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) {
164
- return (_jsx(physicsDef.View, Object.assign({ properties: physics.properties, ref: (obj) => registerRef(gameObject.id, obj) }, wrapperProps, { children: _jsxs("group", { scale: transformProps.scale, children: [core, children] }) })));
156
+ return (_jsx(physicsDef.View, { properties: physics.properties, children: innerGroup }));
165
157
  }
166
158
  }
167
- // --- 9. No physics - standard group wrapper ---
168
- return (_jsxs("group", Object.assign({ ref: (el) => registerRef(gameObject.id, el), scale: transformProps.scale }, wrapperProps, { children: [core, children] })));
159
+ return innerGroup;
169
160
  }
170
161
  // Helper: render an instanced GameInstance (terminal node)
171
162
  function renderInstancedNode(gameObject, worldMatrix, ctx) {
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useRef, useEffect } from "react";
3
- import { useFrame, useThree } from "@react-three/fiber";
4
- import { CameraHelper, Object3D, Vector3 } from "three";
3
+ import { useFrame } from "@react-three/fiber";
4
+ import { Vector3 } from "three";
5
5
  function DirectionalLightComponentEditor({ component, onUpdate }) {
6
6
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
7
7
  const props = {
@@ -32,60 +32,24 @@ function DirectionalLightView({ properties, editMode }) {
32
32
  const shadowCameraLeft = (_j = properties.shadowCameraLeft) !== null && _j !== void 0 ? _j : -30;
33
33
  const shadowCameraRight = (_k = properties.shadowCameraRight) !== null && _k !== void 0 ? _k : 30;
34
34
  const targetOffset = (_l = properties.targetOffset) !== null && _l !== void 0 ? _l : [0, -5, 0];
35
- const { scene } = useThree();
36
35
  const directionalLightRef = useRef(null);
37
- const targetRef = useRef(new Object3D());
38
- const cameraHelperRef = useRef(null);
39
- // Add target to scene once
36
+ const targetRef = useRef(null);
37
+ // Set up light target reference when both refs are ready
40
38
  useEffect(() => {
41
- const target = targetRef.current;
42
- scene.add(target);
43
- return () => {
44
- scene.remove(target);
45
- };
46
- }, [scene]);
47
- // Set up light target reference once
48
- useEffect(() => {
49
- if (directionalLightRef.current) {
39
+ if (directionalLightRef.current && targetRef.current) {
50
40
  directionalLightRef.current.target = targetRef.current;
51
41
  }
52
42
  }, []);
53
- // Update target position and mark shadow for update when light moves or offset changes
43
+ // Update target world position based on light position + offset
54
44
  useFrame(() => {
55
- if (!directionalLightRef.current)
45
+ if (!directionalLightRef.current || !targetRef.current)
56
46
  return;
57
47
  const lightWorldPos = new Vector3();
58
48
  directionalLightRef.current.getWorldPosition(lightWorldPos);
59
- const newTargetPos = new Vector3(lightWorldPos.x + targetOffset[0], lightWorldPos.y + targetOffset[1], lightWorldPos.z + targetOffset[2]);
60
- // Only update if position actually changed
61
- if (!targetRef.current.position.equals(newTargetPos)) {
62
- targetRef.current.position.copy(newTargetPos);
63
- if (directionalLightRef.current.shadow) {
64
- directionalLightRef.current.shadow.needsUpdate = true;
65
- }
66
- }
67
- // Update camera helper in edit mode
68
- if (editMode && cameraHelperRef.current) {
69
- cameraHelperRef.current.update();
70
- }
49
+ // Target is positioned relative to light's world position
50
+ targetRef.current.position.set(lightWorldPos.x + targetOffset[0], lightWorldPos.y + targetOffset[1], lightWorldPos.z + targetOffset[2]);
71
51
  });
72
- // Create/destroy camera helper for edit mode
73
- useEffect(() => {
74
- var _a;
75
- if (editMode && ((_a = directionalLightRef.current) === null || _a === void 0 ? void 0 : _a.shadow.camera)) {
76
- const helper = new CameraHelper(directionalLightRef.current.shadow.camera);
77
- cameraHelperRef.current = helper;
78
- scene.add(helper);
79
- return () => {
80
- if (cameraHelperRef.current) {
81
- scene.remove(cameraHelperRef.current);
82
- cameraHelperRef.current.dispose();
83
- cameraHelperRef.current = null;
84
- }
85
- };
86
- }
87
- }, [editMode, scene]);
88
- return (_jsxs(_Fragment, { children: [_jsx("directionalLight", { ref: directionalLightRef, color: color, intensity: intensity, castShadow: castShadow, "shadow-mapSize": [shadowMapSize, shadowMapSize], "shadow-bias": -0.001, "shadow-normalBias": 0.02, children: _jsx("orthographicCamera", { attach: "shadow-camera", near: shadowCameraNear, far: shadowCameraFar, top: shadowCameraTop, bottom: shadowCameraBottom, left: shadowCameraLeft, right: shadowCameraRight }) }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.3, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: targetOffset, children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] }), _jsxs("line", { children: [_jsx("bufferGeometry", { onUpdate: (geo) => {
52
+ return (_jsxs(_Fragment, { children: [_jsx("directionalLight", { ref: directionalLightRef, color: color, intensity: intensity, castShadow: castShadow, "shadow-mapSize-width": shadowMapSize, "shadow-mapSize-height": shadowMapSize, "shadow-camera-near": shadowCameraNear, "shadow-camera-far": shadowCameraFar, "shadow-camera-top": shadowCameraTop, "shadow-camera-bottom": shadowCameraBottom, "shadow-camera-left": shadowCameraLeft, "shadow-camera-right": shadowCameraRight, "shadow-bias": -0.001, "shadow-normalBias": 0.02 }), _jsx("object3D", { ref: targetRef }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.3, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: targetOffset, children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] }), _jsxs("line", { children: [_jsx("bufferGeometry", { onUpdate: (geo) => {
89
53
  const points = [
90
54
  new Vector3(0, 0, 0),
91
55
  new Vector3(targetOffset[0], targetOffset[1], targetOffset[2])
@@ -53,7 +53,7 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }) {
53
53
  }
54
54
  const { color, wireframe = false } = properties;
55
55
  const displayColor = isSelected ? "cyan" : color;
56
- return _jsx("meshStandardMaterial", { color: displayColor, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture, side: DoubleSide }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture');
56
+ return (_jsx("meshStandardMaterial", { color: displayColor, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture, side: DoubleSide }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
57
57
  }
58
58
  const MaterialComponent = {
59
59
  name: 'Material',
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ModelListViewer } from '../../assetviewer/page';
3
- import { useEffect, useState } from 'react';
3
+ import { useEffect, useState, useMemo } from 'react';
4
4
  function ModelComponentEditor({ component, onUpdate, basePath = "" }) {
5
5
  const [modelFiles, setModelFiles] = useState([]);
6
6
  useEffect(() => {
@@ -22,19 +22,23 @@ function ModelComponentView({ properties, loadedModels, children }) {
22
22
  // Instanced models are handled elsewhere (GameInstance), so only render non-instanced here
23
23
  if (!properties.filename || properties.instanced)
24
24
  return _jsx(_Fragment, { children: children });
25
- if (loadedModels && loadedModels[properties.filename]) {
26
- const clonedModel = loadedModels[properties.filename].clone();
27
- // Enable shadows on all meshes in the model
28
- clonedModel.traverse((obj) => {
25
+ const sourceModel = loadedModels === null || loadedModels === void 0 ? void 0 : loadedModels[properties.filename];
26
+ // Clone model once and set up shadows - memoized to avoid cloning on every render
27
+ const clonedModel = useMemo(() => {
28
+ if (!sourceModel)
29
+ return null;
30
+ const clone = sourceModel.clone();
31
+ clone.traverse((obj) => {
29
32
  if (obj.isMesh) {
30
33
  obj.castShadow = true;
31
34
  obj.receiveShadow = true;
32
35
  }
33
36
  });
34
- return _jsx("primitive", { object: clonedModel, children: children });
35
- }
36
- // Model not loaded yet - render children only
37
- return _jsx(_Fragment, { children: children });
37
+ return clone;
38
+ }, [sourceModel]);
39
+ if (!clonedModel)
40
+ return _jsx(_Fragment, { children: children });
41
+ return _jsx("primitive", { object: clonedModel, children: children });
38
42
  }
39
43
  const ModelComponent = {
40
44
  name: 'Model',
@@ -1,14 +1,3 @@
1
- var __rest = (this && this.__rest) || function (s, e) {
2
- var t = {};
3
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
- t[p] = s[p];
5
- if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
- t[p[i]] = s[p[i]];
9
- }
10
- return t;
11
- };
12
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
13
2
  import { RigidBody } from "@react-three/rapier";
14
3
  const selectClass = "w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50";
@@ -17,12 +6,11 @@ function PhysicsComponentEditor({ component, onUpdate }) {
17
6
  const { type, collider = 'hull' } = component.properties;
18
7
  return (_jsxs("div", { children: [_jsx("label", { className: labelClass, children: "Type" }), _jsxs("select", { className: selectClass, value: type, onChange: e => onUpdate({ type: e.target.value }), children: [_jsx("option", { value: "dynamic", children: "Dynamic" }), _jsx("option", { value: "fixed", children: "Fixed" })] }), _jsx("label", { className: `${labelClass} mt-2`, children: "Collider" }), _jsxs("select", { className: selectClass, value: collider, onChange: e => onUpdate({ collider: e.target.value }), children: [_jsx("option", { value: "hull", children: "Hull (convex)" }), _jsx("option", { value: "trimesh", children: "Trimesh (exact)" }), _jsx("option", { value: "cuboid", children: "Cuboid (box)" }), _jsx("option", { value: "ball", children: "Ball (sphere)" })] })] }));
19
8
  }
20
- function PhysicsComponentView(_a) {
21
- var { properties, editMode, children } = _a, rigidBodyProps = __rest(_a, ["properties", "editMode", "children"]);
9
+ function PhysicsComponentView({ properties, editMode, children }) {
22
10
  if (editMode)
23
11
  return _jsx(_Fragment, { children: children });
24
12
  const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
25
- return (_jsx(RigidBody, Object.assign({ type: properties.type, colliders: colliders }, rigidBodyProps, { children: children })));
13
+ return (_jsx(RigidBody, { type: properties.type, colliders: colliders, children: children }));
26
14
  }
27
15
  const PhysicsComponent = {
28
16
  name: 'Physics',
@@ -27,7 +27,7 @@ function SpotLightView({ properties, editMode }) {
27
27
  spotLightRef.current.target = targetRef.current;
28
28
  }
29
29
  }, []);
30
- return (_jsxs(_Fragment, { children: [_jsx("spotLight", { ref: spotLightRef, color: color, intensity: intensity, angle: angle, penumbra: penumbra, distance: distance, castShadow: castShadow, "shadow-bias": -0.0001, "shadow-normalBias": 0.02 }), _jsx("object3D", { ref: targetRef, position: [0, -5, 0] }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: [0, -5, 0], children: [_jsx("sphereGeometry", { args: [0.15, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] })] }))] }));
30
+ return (_jsxs(_Fragment, { children: [_jsx("spotLight", { ref: spotLightRef, color: color, intensity: intensity, angle: angle, penumbra: penumbra, distance: distance, castShadow: castShadow, "shadow-mapSize-width": 1024, "shadow-mapSize-height": 1024, "shadow-bias": -0.0001, "shadow-normalBias": 0.02 }), _jsx("object3D", { ref: targetRef, position: [0, -5, 0] }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: [0, -5, 0], children: [_jsx("sphereGeometry", { args: [0.15, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] })] }))] }));
31
31
  }
32
32
  const SpotLightComponent = {
33
33
  name: 'SpotLight',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -81,22 +81,21 @@ export function GameInstanceProvider({
81
81
  }, []);
82
82
 
83
83
  // Flatten all model meshes once (models → flat mesh parts)
84
+ // Note: Geometry is cloned with baked transforms for instancing
84
85
  const { flatMeshes, modelParts } = useMemo(() => {
85
86
  const flatMeshes: Record<string, Mesh> = {};
86
87
  const modelParts: Record<string, number> = {};
87
88
 
88
89
  Object.entries(models).forEach(([modelKey, model]) => {
89
- const root = model;
90
- root.updateWorldMatrix(false, true);
91
- const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
90
+ model.updateWorldMatrix(false, true);
91
+ const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
92
92
 
93
93
  let partIndex = 0;
94
-
95
- root.traverse((obj: any) => {
94
+ model.traverse((obj: any) => {
96
95
  if (obj.isMesh) {
96
+ // Clone geometry and bake relative transform
97
97
  const geom = obj.geometry.clone();
98
- const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
99
- geom.applyMatrix4(relativeTransform);
98
+ geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
100
99
 
101
100
  const partKey = `${modelKey}__${partIndex}`;
102
101
  flatMeshes[partKey] = new Mesh(geom, obj.material);
@@ -109,6 +108,13 @@ export function GameInstanceProvider({
109
108
  return { flatMeshes, modelParts };
110
109
  }, [models]);
111
110
 
111
+ // Cleanup geometries when models change
112
+ useEffect(() => {
113
+ return () => {
114
+ Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
115
+ };
116
+ }, [flatMeshes]);
117
+
112
118
  // Group instances by meshPath + physics type for batch rendering
113
119
  const grouped = useMemo(() => {
114
120
  const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
@@ -213,21 +219,24 @@ function InstancedRigidGroup({
213
219
  [group.instances]
214
220
  );
215
221
 
222
+ const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
223
+
216
224
  return (
217
225
  <InstancedRigidBodies
218
226
  instances={instances}
219
- colliders={group.physicsType === 'fixed' ? 'trimesh' : 'hull'}
227
+ colliders={colliders}
220
228
  type={group.physicsType as 'dynamic' | 'fixed'}
221
229
  >
222
230
  {Array.from({ length: partCount }).map((_, i) => {
223
231
  const mesh = flatMeshes[`${modelKey}__${i}`];
232
+ if (!mesh) return null;
224
233
  return (
225
234
  <instancedMesh
226
235
  key={i}
227
236
  args={[mesh.geometry, mesh.material, group.instances.length]}
228
237
  castShadow
229
238
  receiveShadow
230
- frustumCulled={false}
239
+ frustumCulled={false} // Required: culling first instance hides all
231
240
  />
232
241
  );
233
242
  })}
@@ -251,49 +260,62 @@ function NonPhysicsInstancedGroup({
251
260
  onSelect?: (id: string | null) => void;
252
261
  registerRef?: (id: string, obj: Object3D | null) => void;
253
262
  }) {
254
- const clickValid = useRef(false);
255
-
256
- const handlePointerDown = (e: any) => {
257
- e.stopPropagation();
258
- clickValid.current = true;
259
- };
260
-
261
- const handlePointerMove = () => {
262
- if (clickValid.current) clickValid.current = false;
263
- };
264
-
265
- const handlePointerUp = (e: any, id: string) => {
266
- if (clickValid.current) {
267
- e.stopPropagation();
268
- onSelect?.(id);
269
- }
270
- clickValid.current = false;
271
- };
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]
267
+ );
272
268
 
273
269
  return (
274
270
  <>
275
271
  {group.instances.map(inst => (
276
- <group
272
+ <InstanceGroupItem
277
273
  key={inst.id}
278
- ref={(el) => { registerRef?.(inst.id, el as unknown as Object3D | null); }}
279
- position={inst.position}
280
- rotation={inst.rotation}
281
- scale={inst.scale}
282
- onPointerDown={handlePointerDown}
283
- onPointerMove={handlePointerMove}
284
- onPointerUp={(e) => handlePointerUp(e, inst.id)}
285
- >
286
- {Array.from({ length: partCount }).map((_, i) => {
287
- const Instance = instancesMap[`${modelKey}__${i}`];
288
- if (!Instance) return null;
289
- return <Instance key={i} />;
290
- })}
291
- </group>
274
+ instance={inst}
275
+ InstanceComponents={InstanceComponents}
276
+ onSelect={onSelect}
277
+ registerRef={registerRef}
278
+ />
292
279
  ))}
293
280
  </>
294
281
  );
295
282
  }
296
283
 
284
+ // Individual instance item with its own click state
285
+ function InstanceGroupItem({
286
+ instance,
287
+ InstanceComponents,
288
+ onSelect,
289
+ registerRef
290
+ }: {
291
+ instance: InstanceData;
292
+ InstanceComponents: React.ComponentType<any>[];
293
+ onSelect?: (id: string | null) => void;
294
+ registerRef?: (id: string, obj: Object3D | null) => void;
295
+ }) {
296
+ const clickValid = useRef(false);
297
+
298
+ return (
299
+ <group
300
+ ref={(el) => registerRef?.(instance.id, el)}
301
+ position={instance.position}
302
+ rotation={instance.rotation}
303
+ 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
+ }}
313
+ >
314
+ {InstanceComponents.map((Instance, i) => <Instance key={i} />)}
315
+ </group>
316
+ );
317
+ }
318
+
297
319
 
298
320
  // GameInstance component: registers an instance for batch rendering (renders nothing itself)
299
321
  export const GameInstance = React.forwardRef<Group, {
@@ -237,49 +237,36 @@ function GameObjectRenderer({
237
237
  />
238
238
  ));
239
239
 
240
- // --- 6. Common props for the wrapper element ---
241
- const wrapperProps = {
242
- position: transformProps.position,
243
- rotation: transformProps.rotation,
244
- onPointerDown: handlePointerDown,
245
- onPointerMove: handlePointerMove,
246
- onPointerUp: handlePointerUp,
247
- };
240
+ // --- 6. Inner content group with full transform ---
241
+ const innerGroup = (
242
+ <group
243
+ ref={(el) => registerRef(gameObject.id, el)}
244
+ position={transformProps.position}
245
+ rotation={transformProps.rotation}
246
+ scale={transformProps.scale}
247
+ onPointerDown={handlePointerDown}
248
+ onPointerMove={handlePointerMove}
249
+ onPointerUp={handlePointerUp}
250
+ >
251
+ {core}
252
+ {children}
253
+ </group>
254
+ );
248
255
 
249
- // --- 7. Check if physics is needed ---
256
+ // --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
250
257
  const physics = gameObject.components?.physics;
251
- const hasPhysics = physics && !editMode;
252
-
253
- // --- 8. Final structure: RigidBody outside with position/rotation, scale inside ---
254
- if (hasPhysics) {
258
+ if (physics && !editMode) {
255
259
  const physicsDef = getComponent('Physics');
256
260
  if (physicsDef?.View) {
257
261
  return (
258
- <physicsDef.View
259
- properties={physics.properties}
260
- ref={(obj: Object3D | null) => registerRef(gameObject.id, obj)}
261
- {...wrapperProps}
262
- >
263
- <group scale={transformProps.scale}>
264
- {core}
265
- {children}
266
- </group>
262
+ <physicsDef.View properties={physics.properties}>
263
+ {innerGroup}
267
264
  </physicsDef.View>
268
265
  );
269
266
  }
270
267
  }
271
268
 
272
- // --- 9. No physics - standard group wrapper ---
273
- return (
274
- <group
275
- ref={(el) => registerRef(gameObject.id, el)}
276
- scale={transformProps.scale}
277
- {...wrapperProps}
278
- >
279
- {core}
280
- {children}
281
- </group>
282
- );
269
+ return innerGroup;
283
270
  }
284
271
 
285
272
  // Helper: render an instanced GameInstance (terminal node)
@@ -1,7 +1,7 @@
1
1
  import { Component } from "./ComponentRegistry";
2
2
  import { useRef, useEffect } from "react";
3
- import { useFrame, useThree } from "@react-three/fiber";
4
- import { CameraHelper, DirectionalLight, Object3D, Vector3 } from "three";
3
+ import { useFrame } from "@react-three/fiber";
4
+ import { DirectionalLight, Object3D, Vector3 } from "three";
5
5
 
6
6
  function DirectionalLightComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
7
7
  const props = {
@@ -190,71 +190,31 @@ function DirectionalLightView({ properties, editMode }: { properties: any; editM
190
190
  const shadowCameraRight = properties.shadowCameraRight ?? 30;
191
191
  const targetOffset = properties.targetOffset ?? [0, -5, 0];
192
192
 
193
- const { scene } = useThree();
194
193
  const directionalLightRef = useRef<DirectionalLight>(null);
195
- const targetRef = useRef<Object3D>(new Object3D());
196
- const cameraHelperRef = useRef<CameraHelper | null>(null);
194
+ const targetRef = useRef<Object3D>(null);
197
195
 
198
- // Add target to scene once
196
+ // Set up light target reference when both refs are ready
199
197
  useEffect(() => {
200
- const target = targetRef.current;
201
- scene.add(target);
202
- return () => {
203
- scene.remove(target);
204
- };
205
- }, [scene]);
206
-
207
- // Set up light target reference once
208
- useEffect(() => {
209
- if (directionalLightRef.current) {
198
+ if (directionalLightRef.current && targetRef.current) {
210
199
  directionalLightRef.current.target = targetRef.current;
211
200
  }
212
201
  }, []);
213
202
 
214
- // Update target position and mark shadow for update when light moves or offset changes
203
+ // Update target world position based on light position + offset
215
204
  useFrame(() => {
216
- if (!directionalLightRef.current) return;
205
+ if (!directionalLightRef.current || !targetRef.current) return;
217
206
 
218
207
  const lightWorldPos = new Vector3();
219
208
  directionalLightRef.current.getWorldPosition(lightWorldPos);
220
209
 
221
- const newTargetPos = new Vector3(
210
+ // Target is positioned relative to light's world position
211
+ targetRef.current.position.set(
222
212
  lightWorldPos.x + targetOffset[0],
223
213
  lightWorldPos.y + targetOffset[1],
224
214
  lightWorldPos.z + targetOffset[2]
225
215
  );
226
-
227
- // Only update if position actually changed
228
- if (!targetRef.current.position.equals(newTargetPos)) {
229
- targetRef.current.position.copy(newTargetPos);
230
- if (directionalLightRef.current.shadow) {
231
- directionalLightRef.current.shadow.needsUpdate = true;
232
- }
233
- }
234
-
235
- // Update camera helper in edit mode
236
- if (editMode && cameraHelperRef.current) {
237
- cameraHelperRef.current.update();
238
- }
239
216
  });
240
217
 
241
- // Create/destroy camera helper for edit mode
242
- useEffect(() => {
243
- if (editMode && directionalLightRef.current?.shadow.camera) {
244
- const helper = new CameraHelper(directionalLightRef.current.shadow.camera);
245
- cameraHelperRef.current = helper;
246
- scene.add(helper);
247
-
248
- return () => {
249
- if (cameraHelperRef.current) {
250
- scene.remove(cameraHelperRef.current);
251
- cameraHelperRef.current.dispose();
252
- cameraHelperRef.current = null;
253
- }
254
- };
255
- }
256
- }, [editMode, scene]);
257
-
258
218
  return (
259
219
  <>
260
220
  <directionalLight
@@ -262,20 +222,19 @@ function DirectionalLightView({ properties, editMode }: { properties: any; editM
262
222
  color={color}
263
223
  intensity={intensity}
264
224
  castShadow={castShadow}
265
- shadow-mapSize={[shadowMapSize, shadowMapSize]}
225
+ shadow-mapSize-width={shadowMapSize}
226
+ shadow-mapSize-height={shadowMapSize}
227
+ shadow-camera-near={shadowCameraNear}
228
+ shadow-camera-far={shadowCameraFar}
229
+ shadow-camera-top={shadowCameraTop}
230
+ shadow-camera-bottom={shadowCameraBottom}
231
+ shadow-camera-left={shadowCameraLeft}
232
+ shadow-camera-right={shadowCameraRight}
266
233
  shadow-bias={-0.001}
267
234
  shadow-normalBias={0.02}
268
- >
269
- <orthographicCamera
270
- attach="shadow-camera"
271
- near={shadowCameraNear}
272
- far={shadowCameraFar}
273
- top={shadowCameraTop}
274
- bottom={shadowCameraBottom}
275
- left={shadowCameraLeft}
276
- right={shadowCameraRight}
277
- />
278
- </directionalLight>
235
+ />
236
+ {/* Target object - rendered declaratively in scene graph */}
237
+ <object3D ref={targetRef} />
279
238
  {editMode && (
280
239
  <>
281
240
  {/* Light source indicator */}
@@ -130,14 +130,16 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }: { pro
130
130
  const { color, wireframe = false } = properties;
131
131
  const displayColor = isSelected ? "cyan" : color;
132
132
 
133
- return <meshStandardMaterial
134
- key={finalTexture?.uuid ?? 'no-texture'}
135
- color={displayColor}
136
- wireframe={wireframe}
137
- map={finalTexture}
138
- transparent={!!finalTexture}
139
- side={DoubleSide}
140
- />;
133
+ return (
134
+ <meshStandardMaterial
135
+ key={finalTexture?.uuid ?? 'no-texture'}
136
+ color={displayColor}
137
+ wireframe={wireframe}
138
+ map={finalTexture}
139
+ transparent={!!finalTexture}
140
+ side={DoubleSide}
141
+ />
142
+ );
141
143
  }
142
144
 
143
145
  const MaterialComponent: Component = {
@@ -1,5 +1,5 @@
1
1
  import { ModelListViewer } from '../../assetviewer/page';
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useState, useMemo } from 'react';
3
3
  import { Component } from './ComponentRegistry';
4
4
 
5
5
  function ModelComponentEditor({ component, onUpdate, basePath = "" }: { component: any; onUpdate: (newComp: any) => void; basePath?: string }) {
@@ -49,20 +49,24 @@ function ModelComponentView({ properties, loadedModels, children }: { properties
49
49
  // Instanced models are handled elsewhere (GameInstance), so only render non-instanced here
50
50
  if (!properties.filename || properties.instanced) return <>{children}</>;
51
51
 
52
- if (loadedModels && loadedModels[properties.filename]) {
53
- const clonedModel = loadedModels[properties.filename].clone();
54
- // Enable shadows on all meshes in the model
55
- clonedModel.traverse((obj: any) => {
52
+ const sourceModel = loadedModels?.[properties.filename];
53
+
54
+ // Clone model once and set up shadows - memoized to avoid cloning on every render
55
+ const clonedModel = useMemo(() => {
56
+ if (!sourceModel) return null;
57
+ const clone = sourceModel.clone();
58
+ clone.traverse((obj: any) => {
56
59
  if (obj.isMesh) {
57
60
  obj.castShadow = true;
58
61
  obj.receiveShadow = true;
59
62
  }
60
63
  });
61
- return <primitive object={clonedModel}>{children}</primitive>;
62
- }
64
+ return clone;
65
+ }, [sourceModel]);
66
+
67
+ if (!clonedModel) return <>{children}</>;
63
68
 
64
- // Model not loaded yet - render children only
65
- return <>{children}</>;
69
+ return <primitive object={clonedModel}>{children}</primitive>;
66
70
  }
67
71
 
68
72
  const ModelComponent: Component = {
@@ -1,4 +1,4 @@
1
- import { RigidBody, RigidBodyProps } from "@react-three/rapier";
1
+ import { RigidBody } from "@react-three/rapier";
2
2
  import { Component } from "./ComponentRegistry";
3
3
 
4
4
  const selectClass = "w-full bg-black/40 border border-cyan-500/30 px-1 py-0.5 text-[10px] text-cyan-300 font-mono focus:outline-none focus:border-cyan-400/50";
@@ -25,18 +25,19 @@ function PhysicsComponentEditor({ component, onUpdate }: { component: any; onUpd
25
25
  );
26
26
  }
27
27
 
28
- interface PhysicsViewProps extends Omit<RigidBodyProps, 'type' | 'colliders'> {
29
- properties: { type: RigidBodyProps['type']; collider?: string };
28
+ interface PhysicsViewProps {
29
+ properties: { type: 'dynamic' | 'fixed'; collider?: string };
30
30
  editMode?: boolean;
31
+ children?: React.ReactNode;
31
32
  }
32
33
 
33
- function PhysicsComponentView({ properties, editMode, children, ...rigidBodyProps }: PhysicsViewProps) {
34
+ function PhysicsComponentView({ properties, editMode, children }: PhysicsViewProps) {
34
35
  if (editMode) return <>{children}</>;
35
36
 
36
37
  const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
37
38
 
38
39
  return (
39
- <RigidBody type={properties.type} colliders={colliders as any} {...rigidBodyProps}>
40
+ <RigidBody type={properties.type} colliders={colliders as any}>
40
41
  {children}
41
42
  </RigidBody>
42
43
  );
@@ -113,6 +113,8 @@ function SpotLightView({ properties, editMode }: { properties: any; editMode?: b
113
113
  penumbra={penumbra}
114
114
  distance={distance}
115
115
  castShadow={castShadow}
116
+ shadow-mapSize-width={1024}
117
+ shadow-mapSize-height={1024}
116
118
  shadow-bias={-0.0001}
117
119
  shadow-normalBias={0.02}
118
120
  />