react-three-game 0.0.20 → 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) => {
@@ -108,7 +108,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
108
108
  return _jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: loadedModels, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: new Matrix4() }) }), editMode && _jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedId && selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] })] });
109
109
  });
110
110
  function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = new Matrix4(), }) {
111
- var _a, _b, _c, _d;
111
+ var _a, _b, _c, _d, _e;
112
112
  // Early return if gameObject is null or undefined
113
113
  if (!gameObject)
114
114
  return null;
@@ -144,12 +144,19 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
144
144
  }
145
145
  // --- 4. Render core content using component system ---
146
146
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
147
- // --- 5. Wrap with physics if needed (except in edit mode) ---
148
- const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
149
- // --- 6. Render children recursively (always relative transforms) ---
147
+ // --- 5. Render children recursively (always relative transforms) ---
150
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)));
151
- // --- 7. Final group wrapper with local transform ---
152
- return (_jsxs("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [physicsWrapped, children] }));
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) ---
152
+ const physics = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.physics;
153
+ if (physics && !editMode) {
154
+ const physicsDef = getComponent('Physics');
155
+ if (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) {
156
+ return (_jsx(physicsDef.View, { properties: physics.properties, children: innerGroup }));
157
+ }
158
+ }
159
+ return innerGroup;
153
160
  }
154
161
  // Helper: render an instanced GameInstance (terminal node)
155
162
  function renderInstancedNode(gameObject, worldMatrix, ctx) {
@@ -223,17 +230,6 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
223
230
  return _jsx(View, Object.assign({ properties: properties }, contextProps, { children: content }), key);
224
231
  }, coreContent);
225
232
  }
226
- // Helper: wrap core content with physics component when necessary
227
- function wrapPhysicsIfNeeded(gameObject, content, ctx) {
228
- var _a;
229
- const physics = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.physics;
230
- if (!physics)
231
- return content;
232
- const physicsDef = getComponent('Physics');
233
- if (!physicsDef || !physicsDef.View)
234
- return content;
235
- return (_jsx(physicsDef.View, { properties: Object.assign(Object.assign({}, physics.properties), { id: gameObject.id }), editMode: ctx.editMode, children: content }));
236
- }
237
233
  export default PrefabRoot;
238
234
  function getNodeTransformProps(node) {
239
235
  var _a, _b, _c, _d, _e;
@@ -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,19 +1,21 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { RigidBody } from "@react-three/rapier";
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";
4
+ const labelClass = "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5";
2
5
  function PhysicsComponentEditor({ component, onUpdate }) {
3
- return _jsxs("div", { children: [_jsx("label", { className: "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5", children: "Type" }), _jsxs("select", { className: "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", value: component.properties.type, onChange: e => onUpdate({ type: e.target.value }), children: [_jsx("option", { value: "dynamic", children: "Dynamic" }), _jsx("option", { value: "fixed", children: "Fixed" })] })] });
6
+ const { type, collider = 'hull' } = component.properties;
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)" })] })] }));
4
8
  }
5
- import { RigidBody } from "@react-three/rapier";
6
- function PhysicsComponentView({ properties, children, editMode }) {
9
+ function PhysicsComponentView({ properties, editMode, children }) {
7
10
  if (editMode)
8
- return children;
9
- return (_jsx(RigidBody, { type: properties.type, colliders: "cuboid", children: children }));
11
+ return _jsx(_Fragment, { children: children });
12
+ const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
13
+ return (_jsx(RigidBody, { type: properties.type, colliders: colliders, children: children }));
10
14
  }
11
15
  const PhysicsComponent = {
12
16
  name: 'Physics',
13
17
  Editor: PhysicsComponentEditor,
14
18
  View: PhysicsComponentView,
15
- defaultProperties: {
16
- type: 'dynamic'
17
- }
19
+ defaultProperties: { type: 'dynamic', collider: 'hull' }
18
20
  };
19
21
  export default PhysicsComponent;
@@ -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.20",
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, {
@@ -222,10 +222,7 @@ function GameObjectRenderer({
222
222
  // --- 4. Render core content using component system ---
223
223
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
224
224
 
225
- // --- 5. Wrap with physics if needed (except in edit mode) ---
226
- const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
227
-
228
- // --- 6. Render children recursively (always relative transforms) ---
225
+ // --- 5. Render children recursively (always relative transforms) ---
229
226
  const children = (gameObject.children ?? []).map((child) => (
230
227
  <GameObjectRenderer
231
228
  key={child.id}
@@ -240,8 +237,8 @@ function GameObjectRenderer({
240
237
  />
241
238
  ));
242
239
 
243
- // --- 7. Final group wrapper with local transform ---
244
- return (
240
+ // --- 6. Inner content group with full transform ---
241
+ const innerGroup = (
245
242
  <group
246
243
  ref={(el) => registerRef(gameObject.id, el)}
247
244
  position={transformProps.position}
@@ -251,10 +248,25 @@ function GameObjectRenderer({
251
248
  onPointerMove={handlePointerMove}
252
249
  onPointerUp={handlePointerUp}
253
250
  >
254
- {physicsWrapped}
251
+ {core}
255
252
  {children}
256
253
  </group>
257
254
  );
255
+
256
+ // --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
257
+ const physics = gameObject.components?.physics;
258
+ if (physics && !editMode) {
259
+ const physicsDef = getComponent('Physics');
260
+ if (physicsDef?.View) {
261
+ return (
262
+ <physicsDef.View properties={physics.properties}>
263
+ {innerGroup}
264
+ </physicsDef.View>
265
+ );
266
+ }
267
+ }
268
+
269
+ return innerGroup;
258
270
  }
259
271
 
260
272
  // Helper: render an instanced GameInstance (terminal node)
@@ -364,22 +376,6 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
364
376
  }, coreContent);
365
377
  }
366
378
 
367
- // Helper: wrap core content with physics component when necessary
368
- function wrapPhysicsIfNeeded(gameObject: GameObjectType, content: React.ReactNode, ctx: any) {
369
- const physics = gameObject.components?.physics;
370
- if (!physics) return content;
371
- const physicsDef = getComponent('Physics');
372
- if (!physicsDef || !physicsDef.View) return content;
373
- return (
374
- <physicsDef.View
375
- properties={{ ...physics.properties, id: gameObject.id }}
376
- editMode={ctx.editMode}
377
- >
378
- {content}
379
- </physicsDef.View>
380
- );
381
- }
382
-
383
379
 
384
380
 
385
381
 
@@ -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,29 +1,43 @@
1
+ import { RigidBody } from "@react-three/rapier";
1
2
  import { Component } from "./ComponentRegistry";
2
3
 
3
- function PhysicsComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
4
- return <div>
5
- <label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5">Type</label>
6
- <select
7
- className="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"
8
- value={component.properties.type}
9
- onChange={e => onUpdate({ type: e.target.value })}
10
- >
11
- <option value="dynamic">Dynamic</option>
12
- <option value="fixed">Fixed</option>
13
- </select>
14
- </div>;
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";
5
+ const labelClass = "block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-0.5";
6
+
7
+ function PhysicsComponentEditor({ component, onUpdate }: { component: any; onUpdate: (props: any) => void }) {
8
+ const { type, collider = 'hull' } = component.properties;
9
+ return (
10
+ <div>
11
+ <label className={labelClass}>Type</label>
12
+ <select className={selectClass} value={type} onChange={e => onUpdate({ type: e.target.value })}>
13
+ <option value="dynamic">Dynamic</option>
14
+ <option value="fixed">Fixed</option>
15
+ </select>
16
+
17
+ <label className={`${labelClass} mt-2`}>Collider</label>
18
+ <select className={selectClass} value={collider} onChange={e => onUpdate({ collider: e.target.value })}>
19
+ <option value="hull">Hull (convex)</option>
20
+ <option value="trimesh">Trimesh (exact)</option>
21
+ <option value="cuboid">Cuboid (box)</option>
22
+ <option value="ball">Ball (sphere)</option>
23
+ </select>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ interface PhysicsViewProps {
29
+ properties: { type: 'dynamic' | 'fixed'; collider?: string };
30
+ editMode?: boolean;
31
+ children?: React.ReactNode;
15
32
  }
16
33
 
34
+ function PhysicsComponentView({ properties, editMode, children }: PhysicsViewProps) {
35
+ if (editMode) return <>{children}</>;
17
36
 
18
- import { RigidBody } from "@react-three/rapier";
37
+ const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
19
38
 
20
- function PhysicsComponentView({ properties, children, editMode }: any) {
21
- if (editMode) return children;
22
39
  return (
23
- <RigidBody
24
- type={properties.type}
25
- colliders="cuboid"
26
- >
40
+ <RigidBody type={properties.type} colliders={colliders as any}>
27
41
  {children}
28
42
  </RigidBody>
29
43
  );
@@ -33,9 +47,7 @@ const PhysicsComponent: Component = {
33
47
  name: 'Physics',
34
48
  Editor: PhysicsComponentEditor,
35
49
  View: PhysicsComponentView,
36
- defaultProperties: {
37
- type: 'dynamic'
38
- }
50
+ defaultProperties: { type: 'dynamic', collider: 'hull' }
39
51
  };
40
52
 
41
53
  export default PhysicsComponent;
@@ -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
  />