react-three-game 0.0.25 → 0.0.27
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.
- package/dist/tools/prefabeditor/InstanceProvider.d.ts +12 -9
- package/dist/tools/prefabeditor/InstanceProvider.js +151 -94
- package/dist/tools/prefabeditor/PrefabRoot.js +12 -6
- package/dist/tools/prefabeditor/components/MaterialComponent.js +2 -3
- package/package.json +1 -1
- package/src/tools/prefabeditor/InstanceProvider.tsx +249 -167
- package/src/tools/prefabeditor/PrefabRoot.tsx +21 -6
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +2 -3
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
import { Object3D } from "three";
|
|
2
|
+
import { Object3D, Group } from "three";
|
|
4
3
|
export type InstanceData = {
|
|
5
4
|
id: string;
|
|
6
|
-
meshPath: string;
|
|
7
5
|
position: [number, number, number];
|
|
8
6
|
rotation: [number, number, number];
|
|
9
7
|
scale: [number, number, number];
|
|
8
|
+
meshPath: string;
|
|
10
9
|
physics?: {
|
|
11
|
-
type:
|
|
10
|
+
type: 'dynamic' | 'fixed';
|
|
12
11
|
};
|
|
13
12
|
};
|
|
14
|
-
export declare function GameInstanceProvider({ children, models, onSelect, registerRef }: {
|
|
13
|
+
export declare function GameInstanceProvider({ children, models, onSelect, registerRef, selectedId, editMode }: {
|
|
15
14
|
children: React.ReactNode;
|
|
16
|
-
models:
|
|
15
|
+
models: {
|
|
16
|
+
[filename: string]: Object3D;
|
|
17
|
+
};
|
|
17
18
|
onSelect?: (id: string | null) => void;
|
|
18
19
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
20
|
+
selectedId?: string | null;
|
|
21
|
+
editMode?: boolean;
|
|
19
22
|
}): import("react/jsx-runtime").JSX.Element;
|
|
20
|
-
export declare
|
|
23
|
+
export declare const GameInstance: React.ForwardRefExoticComponent<{
|
|
21
24
|
id: string;
|
|
22
25
|
modelUrl: string;
|
|
23
26
|
position: [number, number, number];
|
|
24
27
|
rotation: [number, number, number];
|
|
25
28
|
scale: [number, number, number];
|
|
26
29
|
physics?: {
|
|
27
|
-
type:
|
|
30
|
+
type: "dynamic" | "fixed";
|
|
28
31
|
};
|
|
29
|
-
})
|
|
32
|
+
} & React.RefAttributes<Group<import("three").Object3DEventMap>>>;
|
|
@@ -1,140 +1,194 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
3
|
-
import { Merged } from '@react-three/drei';
|
|
2
|
+
import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
3
|
+
import { Merged, useHelper } from '@react-three/drei';
|
|
4
4
|
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
5
|
-
import { Mesh, Matrix4, Quaternion,
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { Mesh, Matrix4, Vector3, Quaternion, Euler, BoxHelper } from "three";
|
|
6
|
+
// Helper functions for comparison
|
|
7
|
+
function arrayEquals(a, b) {
|
|
8
|
+
if (a === b)
|
|
9
|
+
return true;
|
|
10
|
+
if (a.length !== b.length)
|
|
11
|
+
return false;
|
|
12
|
+
for (let i = 0; i < a.length; i++) {
|
|
13
|
+
if (a[i] !== b[i])
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
function instanceEquals(a, b) {
|
|
9
19
|
var _a, _b;
|
|
10
|
-
return a.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function extractMeshParts(model) {
|
|
17
|
-
model.updateWorldMatrix(false, true);
|
|
18
|
-
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
19
|
-
const parts = [];
|
|
20
|
-
model.traverse(child => {
|
|
21
|
-
if (child.isMesh) {
|
|
22
|
-
const mesh = child;
|
|
23
|
-
const geometry = mesh.geometry.clone();
|
|
24
|
-
geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
|
|
25
|
-
parts.push(new Mesh(geometry, mesh.material));
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
return parts;
|
|
20
|
+
return a.id === b.id &&
|
|
21
|
+
a.meshPath === b.meshPath &&
|
|
22
|
+
arrayEquals(a.position, b.position) &&
|
|
23
|
+
arrayEquals(a.rotation, b.rotation) &&
|
|
24
|
+
arrayEquals(a.scale, b.scale) &&
|
|
25
|
+
((_a = a.physics) === null || _a === void 0 ? void 0 : _a.type) === ((_b = b.physics) === null || _b === void 0 ? void 0 : _b.type);
|
|
29
26
|
}
|
|
30
|
-
// --- Context ---
|
|
31
27
|
const GameInstanceContext = createContext(null);
|
|
32
|
-
|
|
33
|
-
export function GameInstanceProvider({ children, models, onSelect, registerRef }) {
|
|
28
|
+
export function GameInstanceProvider({ children, models, onSelect, registerRef, selectedId, editMode }) {
|
|
34
29
|
const [instances, setInstances] = useState([]);
|
|
35
30
|
const addInstance = useCallback((instance) => {
|
|
36
31
|
setInstances(prev => {
|
|
37
32
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
38
|
-
if (idx
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
if (idx !== -1) {
|
|
34
|
+
// Update existing if changed
|
|
35
|
+
if (instanceEquals(prev[idx], instance)) {
|
|
36
|
+
return prev;
|
|
37
|
+
}
|
|
38
|
+
const copy = [...prev];
|
|
39
|
+
copy[idx] = instance;
|
|
40
|
+
return copy;
|
|
41
|
+
}
|
|
42
|
+
// Add new
|
|
43
|
+
return [...prev, instance];
|
|
45
44
|
});
|
|
46
45
|
}, []);
|
|
47
46
|
const removeInstance = useCallback((id) => {
|
|
48
|
-
setInstances(prev =>
|
|
47
|
+
setInstances(prev => {
|
|
48
|
+
if (!prev.find(i => i.id === id))
|
|
49
|
+
return prev;
|
|
50
|
+
return prev.filter(i => i.id !== id);
|
|
51
|
+
});
|
|
49
52
|
}, []);
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
53
|
+
// Flatten all model meshes once (models → flat mesh parts)
|
|
54
|
+
// Note: Geometry is cloned with baked transforms for instancing
|
|
55
|
+
const { flatMeshes, modelParts } = useMemo(() => {
|
|
56
|
+
const flatMeshes = {};
|
|
57
|
+
const modelParts = {};
|
|
54
58
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
model.updateWorldMatrix(false, true);
|
|
60
|
+
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
61
|
+
let partIndex = 0;
|
|
62
|
+
model.traverse((obj) => {
|
|
63
|
+
if (obj.isMesh) {
|
|
64
|
+
// Clone geometry and bake relative transform
|
|
65
|
+
const geom = obj.geometry.clone();
|
|
66
|
+
geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
|
|
67
|
+
const partKey = `${modelKey}__${partIndex}`;
|
|
68
|
+
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
69
|
+
partIndex++;
|
|
70
|
+
}
|
|
58
71
|
});
|
|
59
|
-
|
|
72
|
+
modelParts[modelKey] = partIndex;
|
|
60
73
|
});
|
|
61
|
-
return {
|
|
74
|
+
return { flatMeshes, modelParts };
|
|
62
75
|
}, [models]);
|
|
63
|
-
// Cleanup
|
|
64
|
-
useEffect(() =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
// Cleanup geometries when models change
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
|
|
80
|
+
};
|
|
81
|
+
}, [flatMeshes]);
|
|
82
|
+
// Group instances by meshPath + physics type for batch rendering
|
|
68
83
|
const grouped = useMemo(() => {
|
|
84
|
+
var _a;
|
|
69
85
|
const groups = {};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
for (const inst of instances) {
|
|
87
|
+
const type = ((_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) || 'none';
|
|
88
|
+
const key = `${inst.meshPath}__${type}`;
|
|
89
|
+
if (!groups[key])
|
|
90
|
+
groups[key] = { physicsType: type, instances: [] };
|
|
75
91
|
groups[key].instances.push(inst);
|
|
76
|
-
}
|
|
92
|
+
}
|
|
77
93
|
return groups;
|
|
78
94
|
}, [instances]);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
return (_jsxs(GameInstanceContext.Provider, { value: {
|
|
96
|
+
addInstance,
|
|
97
|
+
removeInstance,
|
|
98
|
+
instances,
|
|
99
|
+
meshes: flatMeshes,
|
|
100
|
+
modelParts
|
|
101
|
+
}, children: [children, Object.entries(grouped).map(([key, group]) => {
|
|
102
|
+
if (group.physicsType === 'none')
|
|
103
|
+
return null;
|
|
104
|
+
const modelKey = group.instances[0].meshPath;
|
|
105
|
+
const partCount = modelParts[modelKey] || 0;
|
|
106
|
+
if (partCount === 0)
|
|
107
|
+
return null;
|
|
108
|
+
return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes }, key));
|
|
109
|
+
}), Object.entries(grouped).map(([key, group]) => {
|
|
110
|
+
if (group.physicsType !== 'none')
|
|
111
|
+
return null;
|
|
82
112
|
const modelKey = group.instances[0].meshPath;
|
|
83
|
-
const partCount =
|
|
113
|
+
const partCount = modelParts[modelKey] || 0;
|
|
84
114
|
if (partCount === 0)
|
|
85
115
|
return null;
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
// Create mesh subset for this specific model
|
|
117
|
+
const meshesForModel = {};
|
|
118
|
+
for (let i = 0; i < partCount; i++) {
|
|
119
|
+
const partKey = `${modelKey}__${i}`;
|
|
120
|
+
meshesForModel[partKey] = flatMeshes[partKey];
|
|
88
121
|
}
|
|
89
|
-
|
|
90
|
-
return (_jsx(Merged, { meshes: modelMeshes, castShadow: true, receiveShadow: true, children: (Components) => (_jsx(StaticInstances, { instances: group.instances, modelKey: modelKey, partCount: partCount, Components: Components, onSelect: onSelect, registerRef: registerRef })) }, key));
|
|
122
|
+
return (_jsx(Merged, { meshes: meshesForModel, castShadow: true, receiveShadow: true, children: (instancesMap) => (_jsx(NonPhysicsInstancedGroup, { modelKey: modelKey, group: group, partCount: partCount, instancesMap: instancesMap, onSelect: onSelect, registerRef: registerRef, selectedId: selectedId, editMode: editMode })) }, key));
|
|
91
123
|
})] }));
|
|
92
124
|
}
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
// We apply scale once when instances change via useEffect.
|
|
96
|
-
function PhysicsInstances({ instances, physicsType, modelKey, partCount, meshParts }) {
|
|
125
|
+
// Render physics-enabled instances using InstancedRigidBodies
|
|
126
|
+
function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
|
|
97
127
|
const meshRefs = useRef([]);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
128
|
+
const instances = useMemo(() => group.instances.map(inst => ({
|
|
129
|
+
key: inst.id,
|
|
130
|
+
position: inst.position,
|
|
131
|
+
rotation: inst.rotation,
|
|
132
|
+
scale: inst.scale,
|
|
133
|
+
})), [group.instances]);
|
|
134
|
+
// Apply scale to visual meshes (InstancedRigidBodies only scales colliders, not visuals)
|
|
101
135
|
useEffect(() => {
|
|
102
136
|
const matrix = new Matrix4();
|
|
103
137
|
const pos = new Vector3();
|
|
104
138
|
const quat = new Quaternion();
|
|
139
|
+
const euler = new Euler();
|
|
105
140
|
const scl = new Vector3();
|
|
106
141
|
meshRefs.current.forEach(mesh => {
|
|
107
142
|
if (!mesh)
|
|
108
143
|
return;
|
|
109
|
-
instances.forEach((inst, i) => {
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
group.instances.forEach((inst, i) => {
|
|
145
|
+
pos.set(...inst.position);
|
|
146
|
+
euler.set(...inst.rotation);
|
|
147
|
+
quat.setFromEuler(euler);
|
|
112
148
|
scl.set(...inst.scale);
|
|
113
149
|
matrix.compose(pos, quat, scl);
|
|
114
150
|
mesh.setMatrixAt(i, matrix);
|
|
115
151
|
});
|
|
116
152
|
mesh.instanceMatrix.needsUpdate = true;
|
|
117
153
|
});
|
|
118
|
-
}, [instances]);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
}, [group.instances]);
|
|
155
|
+
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
156
|
+
return (_jsx(InstancedRigidBodies, { instances: instances, colliders: colliders, type: group.physicsType, children: Array.from({ length: partCount }).map((_, i) => {
|
|
157
|
+
const mesh = flatMeshes[`${modelKey}__${i}`];
|
|
158
|
+
if (!mesh)
|
|
159
|
+
return null;
|
|
160
|
+
return (_jsx("instancedMesh", { ref: el => { meshRefs.current[i] = el; }, args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
|
|
122
161
|
}) }));
|
|
123
162
|
}
|
|
124
|
-
//
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
// Render non-physics instances using Merged (instancing without rigid bodies)
|
|
164
|
+
function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, onSelect, registerRef, selectedId, editMode }) {
|
|
165
|
+
// Pre-compute which Instance components exist for this model
|
|
166
|
+
const InstanceComponents = useMemo(() => Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean), [instancesMap, modelKey, partCount]);
|
|
167
|
+
return (_jsx(_Fragment, { children: group.instances.map(inst => (_jsx(InstanceGroupItem, { instance: inst, InstanceComponents: InstanceComponents, onSelect: onSelect, registerRef: registerRef, selectedId: selectedId, editMode: editMode }, inst.id))) }));
|
|
128
168
|
}
|
|
129
|
-
//
|
|
130
|
-
function
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
// Individual instance item with its own click state
|
|
170
|
+
function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef, selectedId, editMode }) {
|
|
171
|
+
const clickValid = useRef(false);
|
|
172
|
+
const groupRef = useRef(null);
|
|
173
|
+
const isSelected = selectedId === instance.id;
|
|
174
|
+
// Use BoxHelper when object is selected in edit mode
|
|
175
|
+
useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, groupRef.current);
|
|
178
|
+
}, [instance.id, registerRef]);
|
|
179
|
+
return (_jsx("group", { ref: groupRef, position: instance.position, rotation: instance.rotation, scale: instance.scale, onPointerDown: (e) => { e.stopPropagation(); clickValid.current = true; }, onPointerMove: () => { clickValid.current = false; }, onPointerUp: (e) => {
|
|
180
|
+
if (clickValid.current) {
|
|
181
|
+
e.stopPropagation();
|
|
182
|
+
onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id);
|
|
183
|
+
}
|
|
184
|
+
clickValid.current = false;
|
|
185
|
+
}, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
|
|
134
186
|
}
|
|
135
|
-
//
|
|
136
|
-
export
|
|
187
|
+
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
188
|
+
export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
|
|
137
189
|
const ctx = useContext(GameInstanceContext);
|
|
190
|
+
const addInstance = ctx === null || ctx === void 0 ? void 0 : ctx.addInstance;
|
|
191
|
+
const removeInstance = ctx === null || ctx === void 0 ? void 0 : ctx.removeInstance;
|
|
138
192
|
const instance = useMemo(() => ({
|
|
139
193
|
id,
|
|
140
194
|
meshPath: modelUrl,
|
|
@@ -144,10 +198,13 @@ export function GameInstance({ id, modelUrl, position, rotation, scale, physics
|
|
|
144
198
|
physics,
|
|
145
199
|
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
146
200
|
useEffect(() => {
|
|
147
|
-
if (!
|
|
201
|
+
if (!addInstance || !removeInstance)
|
|
148
202
|
return;
|
|
149
|
-
|
|
150
|
-
return () =>
|
|
151
|
-
|
|
203
|
+
addInstance(instance);
|
|
204
|
+
return () => {
|
|
205
|
+
removeInstance(instance.id);
|
|
206
|
+
};
|
|
207
|
+
}, [addInstance, removeInstance, instance]);
|
|
208
|
+
// No visual rendering - provider handles all instanced visuals
|
|
152
209
|
return null;
|
|
153
|
-
}
|
|
210
|
+
});
|
|
@@ -9,9 +9,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
12
|
-
import { MapControls, TransformControls } from "@react-three/drei";
|
|
12
|
+
import { MapControls, TransformControls, useHelper } from "@react-three/drei";
|
|
13
13
|
import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
|
|
14
|
-
import { Vector3, Euler, Quaternion, SRGBColorSpace, TextureLoader, Matrix4 } from "three";
|
|
14
|
+
import { Vector3, Euler, Quaternion, SRGBColorSpace, TextureLoader, Matrix4, BoxHelper } from "three";
|
|
15
15
|
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
16
16
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
17
17
|
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
@@ -105,7 +105,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
105
105
|
});
|
|
106
106
|
loadAssets();
|
|
107
107
|
}, [data, loadedModels, loadedTextures]);
|
|
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 }))] })] });
|
|
108
|
+
return _jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: loadedModels, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, selectedId: selectedId, editMode: editMode, 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
111
|
var _a, _b, _c, _d, _e;
|
|
@@ -146,8 +146,15 @@ 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. Inner content group with full transform ---
|
|
150
|
-
const
|
|
149
|
+
// --- 6. Inner content group with full transform and selection helper ---
|
|
150
|
+
const groupRef = useRef(null);
|
|
151
|
+
const isSelected = selectedId === gameObject.id;
|
|
152
|
+
// Show BoxHelper when selected in edit mode
|
|
153
|
+
useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
registerRef(gameObject.id, groupRef.current);
|
|
156
|
+
}, [gameObject.id, registerRef]);
|
|
157
|
+
const innerGroup = (_jsxs("group", { ref: groupRef, position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [core, children] }));
|
|
151
158
|
// --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
|
|
152
159
|
const physics = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.physics;
|
|
153
160
|
if (physics && !editMode) {
|
|
@@ -183,7 +190,6 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
|
|
|
183
190
|
const contextProps = {
|
|
184
191
|
loadedModels: ctx.loadedModels,
|
|
185
192
|
loadedTextures: ctx.loadedTextures,
|
|
186
|
-
isSelected: ctx.selectedId === gameObject.id,
|
|
187
193
|
editMode: ctx.editMode,
|
|
188
194
|
parentMatrix,
|
|
189
195
|
registerRef: ctx.registerRef,
|
|
@@ -25,7 +25,7 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
|
25
25
|
import { useMemo } from 'react';
|
|
26
26
|
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace } from 'three';
|
|
27
27
|
// View for Material component
|
|
28
|
-
function MaterialComponentView({ properties, loadedTextures
|
|
28
|
+
function MaterialComponentView({ properties, loadedTextures }) {
|
|
29
29
|
var _a;
|
|
30
30
|
const textureName = properties === null || properties === void 0 ? void 0 : properties.texture;
|
|
31
31
|
const repeat = properties === null || properties === void 0 ? void 0 : properties.repeat;
|
|
@@ -52,8 +52,7 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }) {
|
|
|
52
52
|
return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
|
|
53
53
|
}
|
|
54
54
|
const { color, wireframe = false } = properties;
|
|
55
|
-
|
|
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'));
|
|
55
|
+
return (_jsx("meshStandardMaterial", { color: color, 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
56
|
}
|
|
58
57
|
const MaterialComponent = {
|
|
59
58
|
name: 'Material',
|
package/package.json
CHANGED
|
@@ -1,159 +1,198 @@
|
|
|
1
1
|
import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
2
|
-
import { Merged } from '@react-three/drei';
|
|
3
|
-
import { InstancedRigidBodies
|
|
4
|
-
import { Mesh, Matrix4, Object3D,
|
|
2
|
+
import { Merged, useHelper } from '@react-three/drei';
|
|
3
|
+
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
4
|
+
import { Mesh, Matrix4, Object3D, Group, Vector3, Quaternion, Euler, InstancedMesh, BoxHelper } from "three";
|
|
5
5
|
|
|
6
6
|
// --- Types ---
|
|
7
7
|
export type InstanceData = {
|
|
8
8
|
id: string;
|
|
9
|
-
meshPath: string;
|
|
10
9
|
position: [number, number, number];
|
|
11
10
|
rotation: [number, number, number];
|
|
12
11
|
scale: [number, number, number];
|
|
13
|
-
|
|
12
|
+
meshPath: string;
|
|
13
|
+
physics?: { type: 'dynamic' | 'fixed' };
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
// Helper functions for comparison
|
|
17
|
+
function arrayEquals(a: number[], b: number[]): boolean {
|
|
18
|
+
if (a === b) return true;
|
|
19
|
+
if (a.length !== b.length) return false;
|
|
20
|
+
for (let i = 0; i < a.length; i++) {
|
|
21
|
+
if (a[i] !== b[i]) return false;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
20
25
|
|
|
26
|
+
function instanceEquals(a: InstanceData, b: InstanceData): boolean {
|
|
27
|
+
return a.id === b.id &&
|
|
28
|
+
a.meshPath === b.meshPath &&
|
|
29
|
+
arrayEquals(a.position, b.position) &&
|
|
30
|
+
arrayEquals(a.rotation, b.rotation) &&
|
|
31
|
+
arrayEquals(a.scale, b.scale) &&
|
|
32
|
+
a.physics?.type === b.physics?.type;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Context ---
|
|
21
36
|
type GameInstanceContextType = {
|
|
22
37
|
addInstance: (instance: InstanceData) => void;
|
|
23
38
|
removeInstance: (id: string) => void;
|
|
39
|
+
instances: InstanceData[];
|
|
40
|
+
meshes: Record<string, Mesh>;
|
|
41
|
+
modelParts?: Record<string, number>;
|
|
24
42
|
};
|
|
25
|
-
|
|
26
|
-
// --- Helpers ---
|
|
27
|
-
const tupleEqual = (a: readonly number[], b: readonly number[]) =>
|
|
28
|
-
a.length === b.length && a.every((v, i) => v === b[i]);
|
|
29
|
-
|
|
30
|
-
const instanceChanged = (a: InstanceData, b: InstanceData) =>
|
|
31
|
-
a.meshPath !== b.meshPath ||
|
|
32
|
-
a.physics?.type !== b.physics?.type ||
|
|
33
|
-
!tupleEqual(a.position, b.position) ||
|
|
34
|
-
!tupleEqual(a.rotation, b.rotation) ||
|
|
35
|
-
!tupleEqual(a.scale, b.scale);
|
|
36
|
-
|
|
37
|
-
function extractMeshParts(model: Object3D): Mesh[] {
|
|
38
|
-
model.updateWorldMatrix(false, true);
|
|
39
|
-
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
40
|
-
const parts: Mesh[] = [];
|
|
41
|
-
|
|
42
|
-
model.traverse(child => {
|
|
43
|
-
if ((child as Mesh).isMesh) {
|
|
44
|
-
const mesh = child as Mesh;
|
|
45
|
-
const geometry = mesh.geometry.clone();
|
|
46
|
-
geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
|
|
47
|
-
parts.push(new Mesh(geometry, mesh.material));
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
return parts;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- Context ---
|
|
55
43
|
const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
56
44
|
|
|
57
|
-
// --- Provider ---
|
|
58
45
|
export function GameInstanceProvider({
|
|
59
46
|
children,
|
|
60
47
|
models,
|
|
61
48
|
onSelect,
|
|
62
|
-
registerRef
|
|
49
|
+
registerRef,
|
|
50
|
+
selectedId,
|
|
51
|
+
editMode
|
|
63
52
|
}: {
|
|
64
|
-
children: React.ReactNode
|
|
65
|
-
models:
|
|
66
|
-
onSelect?: (id: string | null) => void
|
|
67
|
-
registerRef?: (id: string, obj: Object3D | null) => void
|
|
53
|
+
children: React.ReactNode,
|
|
54
|
+
models: { [filename: string]: Object3D },
|
|
55
|
+
onSelect?: (id: string | null) => void,
|
|
56
|
+
registerRef?: (id: string, obj: Object3D | null) => void,
|
|
57
|
+
selectedId?: string | null,
|
|
58
|
+
editMode?: boolean
|
|
68
59
|
}) {
|
|
69
60
|
const [instances, setInstances] = useState<InstanceData[]>([]);
|
|
70
61
|
|
|
71
62
|
const addInstance = useCallback((instance: InstanceData) => {
|
|
72
63
|
setInstances(prev => {
|
|
73
64
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
74
|
-
if (idx
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
if (idx !== -1) {
|
|
66
|
+
// Update existing if changed
|
|
67
|
+
if (instanceEquals(prev[idx], instance)) {
|
|
68
|
+
return prev;
|
|
69
|
+
}
|
|
70
|
+
const copy = [...prev];
|
|
71
|
+
copy[idx] = instance;
|
|
72
|
+
return copy;
|
|
73
|
+
}
|
|
74
|
+
// Add new
|
|
75
|
+
return [...prev, instance];
|
|
79
76
|
});
|
|
80
77
|
}, []);
|
|
81
78
|
|
|
82
79
|
const removeInstance = useCallback((id: string) => {
|
|
83
|
-
setInstances(prev =>
|
|
80
|
+
setInstances(prev => {
|
|
81
|
+
if (!prev.find(i => i.id === id)) return prev;
|
|
82
|
+
return prev.filter(i => i.id !== id);
|
|
83
|
+
});
|
|
84
84
|
}, []);
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
86
|
+
// Flatten all model meshes once (models → flat mesh parts)
|
|
87
|
+
// Note: Geometry is cloned with baked transforms for instancing
|
|
88
|
+
const { flatMeshes, modelParts } = useMemo(() => {
|
|
89
|
+
const flatMeshes: Record<string, Mesh> = {};
|
|
90
|
+
const modelParts: Record<string, number> = {};
|
|
90
91
|
|
|
91
92
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
model.updateWorldMatrix(false, true);
|
|
94
|
+
const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
|
|
95
|
+
|
|
96
|
+
let partIndex = 0;
|
|
97
|
+
model.traverse((obj: any) => {
|
|
98
|
+
if (obj.isMesh) {
|
|
99
|
+
// Clone geometry and bake relative transform
|
|
100
|
+
const geom = obj.geometry.clone();
|
|
101
|
+
geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
|
|
102
|
+
|
|
103
|
+
const partKey = `${modelKey}__${partIndex}`;
|
|
104
|
+
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
105
|
+
partIndex++;
|
|
106
|
+
}
|
|
95
107
|
});
|
|
96
|
-
|
|
108
|
+
modelParts[modelKey] = partIndex;
|
|
97
109
|
});
|
|
98
110
|
|
|
99
|
-
return {
|
|
111
|
+
return { flatMeshes, modelParts };
|
|
100
112
|
}, [models]);
|
|
101
113
|
|
|
102
|
-
// Cleanup
|
|
103
|
-
useEffect(() =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
// Cleanup geometries when models change
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
return () => {
|
|
117
|
+
Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
|
|
118
|
+
};
|
|
119
|
+
}, [flatMeshes]);
|
|
120
|
+
|
|
121
|
+
// Group instances by meshPath + physics type for batch rendering
|
|
122
|
+
const grouped = useMemo(() => {
|
|
123
|
+
const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
|
|
124
|
+
for (const inst of instances) {
|
|
125
|
+
const type = inst.physics?.type || 'none';
|
|
126
|
+
const key = `${inst.meshPath}__${type}`;
|
|
127
|
+
if (!groups[key]) groups[key] = { physicsType: type, instances: [] };
|
|
114
128
|
groups[key].instances.push(inst);
|
|
115
|
-
}
|
|
129
|
+
}
|
|
116
130
|
return groups;
|
|
117
131
|
}, [instances]);
|
|
118
132
|
|
|
119
|
-
const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
|
|
120
|
-
|
|
121
133
|
return (
|
|
122
|
-
<GameInstanceContext.Provider
|
|
134
|
+
<GameInstanceContext.Provider
|
|
135
|
+
value={{
|
|
136
|
+
addInstance,
|
|
137
|
+
removeInstance,
|
|
138
|
+
instances,
|
|
139
|
+
meshes: flatMeshes,
|
|
140
|
+
modelParts
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
123
144
|
{children}
|
|
124
145
|
|
|
146
|
+
{/* Render physics-enabled instanced groups using InstancedRigidBodies */}
|
|
125
147
|
{Object.entries(grouped).map(([key, group]) => {
|
|
148
|
+
if (group.physicsType === 'none') return null;
|
|
126
149
|
const modelKey = group.instances[0].meshPath;
|
|
127
|
-
const partCount =
|
|
150
|
+
const partCount = modelParts[modelKey] || 0;
|
|
128
151
|
if (partCount === 0) return null;
|
|
129
152
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
meshParts={meshParts}
|
|
139
|
-
/>
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const modelMeshes = Object.fromEntries(
|
|
144
|
-
Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]])
|
|
153
|
+
return (
|
|
154
|
+
<InstancedRigidGroup
|
|
155
|
+
key={key}
|
|
156
|
+
group={group}
|
|
157
|
+
modelKey={modelKey}
|
|
158
|
+
partCount={partCount}
|
|
159
|
+
flatMeshes={flatMeshes}
|
|
160
|
+
/>
|
|
145
161
|
);
|
|
162
|
+
})}
|
|
163
|
+
|
|
164
|
+
{/* Render non-physics instanced visuals using Merged (one per model type) */}
|
|
165
|
+
{Object.entries(grouped).map(([key, group]) => {
|
|
166
|
+
if (group.physicsType !== 'none') return null;
|
|
167
|
+
|
|
168
|
+
const modelKey = group.instances[0].meshPath;
|
|
169
|
+
const partCount = modelParts[modelKey] || 0;
|
|
170
|
+
if (partCount === 0) return null;
|
|
171
|
+
|
|
172
|
+
// Create mesh subset for this specific model
|
|
173
|
+
const meshesForModel: Record<string, Mesh> = {};
|
|
174
|
+
for (let i = 0; i < partCount; i++) {
|
|
175
|
+
const partKey = `${modelKey}__${i}`;
|
|
176
|
+
meshesForModel[partKey] = flatMeshes[partKey];
|
|
177
|
+
}
|
|
146
178
|
|
|
147
179
|
return (
|
|
148
|
-
<Merged
|
|
149
|
-
{
|
|
150
|
-
|
|
151
|
-
|
|
180
|
+
<Merged
|
|
181
|
+
key={key}
|
|
182
|
+
meshes={meshesForModel}
|
|
183
|
+
castShadow
|
|
184
|
+
receiveShadow
|
|
185
|
+
>
|
|
186
|
+
{(instancesMap: any) => (
|
|
187
|
+
<NonPhysicsInstancedGroup
|
|
152
188
|
modelKey={modelKey}
|
|
189
|
+
group={group}
|
|
153
190
|
partCount={partCount}
|
|
154
|
-
|
|
191
|
+
instancesMap={instancesMap}
|
|
155
192
|
onSelect={onSelect}
|
|
156
193
|
registerRef={registerRef}
|
|
194
|
+
selectedId={selectedId}
|
|
195
|
+
editMode={editMode}
|
|
157
196
|
/>
|
|
158
197
|
)}
|
|
159
198
|
</Merged>
|
|
@@ -163,150 +202,190 @@ export function GameInstanceProvider({
|
|
|
163
202
|
);
|
|
164
203
|
}
|
|
165
204
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
function PhysicsInstances({
|
|
170
|
-
instances,
|
|
171
|
-
physicsType,
|
|
205
|
+
// Render physics-enabled instances using InstancedRigidBodies
|
|
206
|
+
function InstancedRigidGroup({
|
|
207
|
+
group,
|
|
172
208
|
modelKey,
|
|
173
209
|
partCount,
|
|
174
|
-
|
|
210
|
+
flatMeshes
|
|
175
211
|
}: {
|
|
176
|
-
instances: InstanceData[]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
meshParts: Record<string, Mesh>;
|
|
212
|
+
group: { physicsType: string, instances: InstanceData[] },
|
|
213
|
+
modelKey: string,
|
|
214
|
+
partCount: number,
|
|
215
|
+
flatMeshes: Record<string, Mesh>
|
|
181
216
|
}) {
|
|
182
217
|
const meshRefs = useRef<(InstancedMesh | null)[]>([]);
|
|
183
218
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
219
|
+
const instances = useMemo(
|
|
220
|
+
() => group.instances.map(inst => ({
|
|
221
|
+
key: inst.id,
|
|
222
|
+
position: inst.position,
|
|
223
|
+
rotation: inst.rotation,
|
|
224
|
+
scale: inst.scale,
|
|
225
|
+
})),
|
|
226
|
+
[group.instances]
|
|
188
227
|
);
|
|
189
228
|
|
|
190
|
-
// Apply scale
|
|
229
|
+
// Apply scale to visual meshes (InstancedRigidBodies only scales colliders, not visuals)
|
|
191
230
|
useEffect(() => {
|
|
192
231
|
const matrix = new Matrix4();
|
|
193
232
|
const pos = new Vector3();
|
|
194
233
|
const quat = new Quaternion();
|
|
234
|
+
const euler = new Euler();
|
|
195
235
|
const scl = new Vector3();
|
|
196
236
|
|
|
197
237
|
meshRefs.current.forEach(mesh => {
|
|
198
238
|
if (!mesh) return;
|
|
199
239
|
|
|
200
|
-
instances.forEach((inst, i) => {
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
group.instances.forEach((inst, i) => {
|
|
241
|
+
pos.set(...inst.position);
|
|
242
|
+
euler.set(...inst.rotation);
|
|
243
|
+
quat.setFromEuler(euler);
|
|
203
244
|
scl.set(...inst.scale);
|
|
204
245
|
matrix.compose(pos, quat, scl);
|
|
205
246
|
mesh.setMatrixAt(i, matrix);
|
|
206
247
|
});
|
|
207
248
|
mesh.instanceMatrix.needsUpdate = true;
|
|
208
249
|
});
|
|
209
|
-
}, [instances]);
|
|
250
|
+
}, [group.instances]);
|
|
251
|
+
|
|
252
|
+
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
210
253
|
|
|
211
254
|
return (
|
|
212
255
|
<InstancedRigidBodies
|
|
213
|
-
instances={
|
|
214
|
-
|
|
215
|
-
|
|
256
|
+
instances={instances}
|
|
257
|
+
colliders={colliders}
|
|
258
|
+
type={group.physicsType as 'dynamic' | 'fixed'}
|
|
216
259
|
>
|
|
217
|
-
{Array.from({ length: partCount }
|
|
218
|
-
const mesh =
|
|
219
|
-
|
|
260
|
+
{Array.from({ length: partCount }).map((_, i) => {
|
|
261
|
+
const mesh = flatMeshes[`${modelKey}__${i}`];
|
|
262
|
+
if (!mesh) return null;
|
|
263
|
+
return (
|
|
220
264
|
<instancedMesh
|
|
221
265
|
key={i}
|
|
222
266
|
ref={el => { meshRefs.current[i] = el; }}
|
|
223
|
-
args={[mesh.geometry, mesh.material, instances.length]}
|
|
224
|
-
frustumCulled={false}
|
|
267
|
+
args={[mesh.geometry, mesh.material, group.instances.length]}
|
|
225
268
|
castShadow
|
|
226
269
|
receiveShadow
|
|
270
|
+
frustumCulled={false}
|
|
227
271
|
/>
|
|
228
|
-
)
|
|
272
|
+
);
|
|
229
273
|
})}
|
|
230
274
|
</InstancedRigidBodies>
|
|
231
275
|
);
|
|
232
276
|
}
|
|
233
277
|
|
|
234
|
-
//
|
|
235
|
-
function
|
|
236
|
-
instances,
|
|
278
|
+
// Render non-physics instances using Merged (instancing without rigid bodies)
|
|
279
|
+
function NonPhysicsInstancedGroup({
|
|
237
280
|
modelKey,
|
|
281
|
+
group,
|
|
238
282
|
partCount,
|
|
239
|
-
|
|
283
|
+
instancesMap,
|
|
240
284
|
onSelect,
|
|
241
|
-
registerRef
|
|
285
|
+
registerRef,
|
|
286
|
+
selectedId,
|
|
287
|
+
editMode
|
|
242
288
|
}: {
|
|
243
|
-
instances: InstanceData[];
|
|
244
289
|
modelKey: string;
|
|
290
|
+
group: { physicsType: string, instances: InstanceData[] };
|
|
245
291
|
partCount: number;
|
|
246
|
-
|
|
292
|
+
instancesMap: Record<string, React.ComponentType<any>>;
|
|
247
293
|
onSelect?: (id: string | null) => void;
|
|
248
294
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
295
|
+
selectedId?: string | null;
|
|
296
|
+
editMode?: boolean;
|
|
249
297
|
}) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
298
|
+
// Pre-compute which Instance components exist for this model
|
|
299
|
+
const InstanceComponents = useMemo(() =>
|
|
300
|
+
Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean),
|
|
301
|
+
[instancesMap, modelKey, partCount]
|
|
253
302
|
);
|
|
254
303
|
|
|
255
304
|
return (
|
|
256
305
|
<>
|
|
257
|
-
{instances.map(inst => (
|
|
258
|
-
<
|
|
306
|
+
{group.instances.map(inst => (
|
|
307
|
+
<InstanceGroupItem
|
|
308
|
+
key={inst.id}
|
|
309
|
+
instance={inst}
|
|
310
|
+
InstanceComponents={InstanceComponents}
|
|
311
|
+
onSelect={onSelect}
|
|
312
|
+
registerRef={registerRef}
|
|
313
|
+
selectedId={selectedId}
|
|
314
|
+
editMode={editMode}
|
|
315
|
+
/>
|
|
259
316
|
))}
|
|
260
317
|
</>
|
|
261
318
|
);
|
|
262
319
|
}
|
|
263
320
|
|
|
264
|
-
//
|
|
265
|
-
function
|
|
321
|
+
// Individual instance item with its own click state
|
|
322
|
+
function InstanceGroupItem({
|
|
266
323
|
instance,
|
|
267
|
-
|
|
324
|
+
InstanceComponents,
|
|
268
325
|
onSelect,
|
|
269
|
-
registerRef
|
|
326
|
+
registerRef,
|
|
327
|
+
selectedId,
|
|
328
|
+
editMode
|
|
270
329
|
}: {
|
|
271
330
|
instance: InstanceData;
|
|
272
|
-
|
|
331
|
+
InstanceComponents: React.ComponentType<any>[];
|
|
273
332
|
onSelect?: (id: string | null) => void;
|
|
274
333
|
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
334
|
+
selectedId?: string | null;
|
|
335
|
+
editMode?: boolean;
|
|
275
336
|
}) {
|
|
276
|
-
const
|
|
337
|
+
const clickValid = useRef(false);
|
|
338
|
+
const groupRef = useRef<Group>(null!);
|
|
339
|
+
const isSelected = selectedId === instance.id;
|
|
340
|
+
|
|
341
|
+
// Use BoxHelper when object is selected in edit mode
|
|
342
|
+
useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
registerRef?.(instance.id, groupRef.current);
|
|
346
|
+
}, [instance.id, registerRef]);
|
|
277
347
|
|
|
278
348
|
return (
|
|
279
349
|
<group
|
|
280
|
-
ref={
|
|
350
|
+
ref={groupRef}
|
|
281
351
|
position={instance.position}
|
|
282
352
|
rotation={instance.rotation}
|
|
283
353
|
scale={instance.scale}
|
|
284
|
-
onPointerDown={e => { e.stopPropagation();
|
|
285
|
-
onPointerMove={() => {
|
|
286
|
-
onPointerUp={e => {
|
|
354
|
+
onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
|
|
355
|
+
onPointerMove={() => { clickValid.current = false; }}
|
|
356
|
+
onPointerUp={(e) => {
|
|
357
|
+
if (clickValid.current) {
|
|
358
|
+
e.stopPropagation();
|
|
359
|
+
onSelect?.(instance.id);
|
|
360
|
+
}
|
|
361
|
+
clickValid.current = false;
|
|
362
|
+
}}
|
|
287
363
|
>
|
|
288
|
-
{
|
|
364
|
+
{InstanceComponents.map((Instance, i) => <Instance key={i} />)}
|
|
289
365
|
</group>
|
|
290
366
|
);
|
|
291
367
|
}
|
|
292
368
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
modelUrl,
|
|
297
|
-
position,
|
|
298
|
-
rotation,
|
|
299
|
-
scale,
|
|
300
|
-
physics
|
|
301
|
-
}: {
|
|
369
|
+
|
|
370
|
+
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
371
|
+
export const GameInstance = React.forwardRef<Group, {
|
|
302
372
|
id: string;
|
|
303
373
|
modelUrl: string;
|
|
304
374
|
position: [number, number, number];
|
|
305
375
|
rotation: [number, number, number];
|
|
306
376
|
scale: [number, number, number];
|
|
307
|
-
physics?: { type:
|
|
308
|
-
}
|
|
377
|
+
physics?: { type: 'dynamic' | 'fixed' };
|
|
378
|
+
}>(({
|
|
379
|
+
id,
|
|
380
|
+
modelUrl,
|
|
381
|
+
position,
|
|
382
|
+
rotation,
|
|
383
|
+
scale,
|
|
384
|
+
physics = undefined,
|
|
385
|
+
}, ref) => {
|
|
309
386
|
const ctx = useContext(GameInstanceContext);
|
|
387
|
+
const addInstance = ctx?.addInstance;
|
|
388
|
+
const removeInstance = ctx?.removeInstance;
|
|
310
389
|
|
|
311
390
|
const instance = useMemo<InstanceData>(() => ({
|
|
312
391
|
id,
|
|
@@ -318,10 +397,13 @@ export function GameInstance({
|
|
|
318
397
|
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
319
398
|
|
|
320
399
|
useEffect(() => {
|
|
321
|
-
if (!
|
|
322
|
-
|
|
323
|
-
return () =>
|
|
324
|
-
|
|
325
|
-
|
|
400
|
+
if (!addInstance || !removeInstance) return;
|
|
401
|
+
addInstance(instance);
|
|
402
|
+
return () => {
|
|
403
|
+
removeInstance(instance.id);
|
|
404
|
+
};
|
|
405
|
+
}, [addInstance, removeInstance, instance]);
|
|
406
|
+
|
|
407
|
+
// No visual rendering - provider handles all instanced visuals
|
|
326
408
|
return null;
|
|
327
|
-
}
|
|
409
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { MapControls, TransformControls } from "@react-three/drei";
|
|
3
|
+
import { MapControls, TransformControls, useHelper } from "@react-three/drei";
|
|
4
4
|
import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
|
|
5
|
-
import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
|
|
5
|
+
import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4, BoxHelper } from "three";
|
|
6
6
|
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
7
7
|
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
8
8
|
import { ThreeEvent } from "@react-three/fiber";
|
|
@@ -128,7 +128,13 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
128
128
|
|
|
129
129
|
|
|
130
130
|
return <group ref={ref}>
|
|
131
|
-
<GameInstanceProvider
|
|
131
|
+
<GameInstanceProvider
|
|
132
|
+
models={loadedModels}
|
|
133
|
+
onSelect={editMode ? onSelect : undefined}
|
|
134
|
+
registerRef={registerRef}
|
|
135
|
+
selectedId={selectedId}
|
|
136
|
+
editMode={editMode}
|
|
137
|
+
>
|
|
132
138
|
<GameObjectRenderer
|
|
133
139
|
gameObject={data.root}
|
|
134
140
|
selectedId={selectedId}
|
|
@@ -237,10 +243,20 @@ function GameObjectRenderer({
|
|
|
237
243
|
/>
|
|
238
244
|
));
|
|
239
245
|
|
|
240
|
-
// --- 6. Inner content group with full transform ---
|
|
246
|
+
// --- 6. Inner content group with full transform and selection helper ---
|
|
247
|
+
const groupRef = useRef<Group>(null!);
|
|
248
|
+
const isSelected = selectedId === gameObject.id;
|
|
249
|
+
|
|
250
|
+
// Show BoxHelper when selected in edit mode
|
|
251
|
+
useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
registerRef(gameObject.id, groupRef.current);
|
|
255
|
+
}, [gameObject.id, registerRef]);
|
|
256
|
+
|
|
241
257
|
const innerGroup = (
|
|
242
258
|
<group
|
|
243
|
-
ref={
|
|
259
|
+
ref={groupRef}
|
|
244
260
|
position={transformProps.position}
|
|
245
261
|
rotation={transformProps.rotation}
|
|
246
262
|
scale={transformProps.scale}
|
|
@@ -304,7 +320,6 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
304
320
|
const contextProps = {
|
|
305
321
|
loadedModels: ctx.loadedModels,
|
|
306
322
|
loadedTextures: ctx.loadedTextures,
|
|
307
|
-
isSelected: ctx.selectedId === gameObject.id,
|
|
308
323
|
editMode: ctx.editMode,
|
|
309
324
|
parentMatrix,
|
|
310
325
|
registerRef: ctx.registerRef,
|
|
@@ -102,7 +102,7 @@ import { useMemo } from 'react';
|
|
|
102
102
|
import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
|
|
103
103
|
|
|
104
104
|
// View for Material component
|
|
105
|
-
function MaterialComponentView({ properties, loadedTextures
|
|
105
|
+
function MaterialComponentView({ properties, loadedTextures }: { properties: any, loadedTextures?: Record<string, Texture> }) {
|
|
106
106
|
const textureName = properties?.texture;
|
|
107
107
|
const repeat = properties?.repeat;
|
|
108
108
|
const repeatCount = properties?.repeatCount;
|
|
@@ -128,12 +128,11 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }: { pro
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
const { color, wireframe = false } = properties;
|
|
131
|
-
const displayColor = isSelected ? "cyan" : color;
|
|
132
131
|
|
|
133
132
|
return (
|
|
134
133
|
<meshStandardMaterial
|
|
135
134
|
key={finalTexture?.uuid ?? 'no-texture'}
|
|
136
|
-
color={
|
|
135
|
+
color={color}
|
|
137
136
|
wireframe={wireframe}
|
|
138
137
|
map={finalTexture}
|
|
139
138
|
transparent={!!finalTexture}
|