react-three-game 0.0.49 → 0.0.50
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/README.md +6 -2
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +11 -6
- package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +9 -0
- package/dist/tools/prefabeditor/components/MaterialComponent.js +27 -3
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +15 -2
- package/package.json +1 -1
- package/react-three-game-skill/react-three-game/SKILL.md +64 -80
- package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +349 -0
- package/src/tools/prefabeditor/PrefabRoot.tsx +17 -1
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +37 -8
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +18 -1
package/README.md
CHANGED
|
@@ -163,9 +163,13 @@ Keys: **T**ranslate / **R**otate / **S**cale. Drag tree nodes to reparent. Impor
|
|
|
163
163
|
## Tree Utilities
|
|
164
164
|
|
|
165
165
|
```typescript
|
|
166
|
-
import { findNode, updateNode, deleteNode, cloneNode } from 'react-three-game';
|
|
166
|
+
import { findNode, updateNode, updateNodeById, deleteNode, cloneNode, exportGLBData } from 'react-three-game';
|
|
167
167
|
|
|
168
|
-
const
|
|
168
|
+
const node = findNode(root, nodeId);
|
|
169
|
+
const updated = updateNode(root, nodeId, n => ({ ...n, disabled: true })); // or updateNodeById
|
|
170
|
+
const afterDelete = deleteNode(root, nodeId);
|
|
171
|
+
const cloned = cloneNode(node);
|
|
172
|
+
const glbData = await exportGLBData(sceneRoot); // export scene to GLB ArrayBuffer
|
|
169
173
|
```
|
|
170
174
|
|
|
171
175
|
## Development
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Group, Matrix4, Object3D, Texture } from "three";
|
|
2
2
|
import { ThreeEvent } from "@react-three/fiber";
|
|
3
3
|
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
4
|
+
import type { RapierRigidBody } from "@react-three/rapier";
|
|
4
5
|
export interface PrefabRootRef {
|
|
5
6
|
root: Group | null;
|
|
7
|
+
rigidBodyRefs: Map<string, RapierRigidBody | null>;
|
|
6
8
|
}
|
|
7
9
|
export declare const PrefabRoot: import("react").ForwardRefExoticComponent<{
|
|
8
10
|
editMode?: boolean;
|
|
@@ -20,6 +22,7 @@ interface RendererProps {
|
|
|
20
22
|
onSelect?: (id: string) => void;
|
|
21
23
|
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
22
24
|
registerRef: (id: string, obj: Object3D | null) => void;
|
|
25
|
+
registerRigidBodyRef: (id: string, rb: RapierRigidBody | null) => void;
|
|
23
26
|
loadedModels: Record<string, Object3D>;
|
|
24
27
|
loadedTextures: Record<string, Texture>;
|
|
25
28
|
editMode?: boolean;
|
|
@@ -30,16 +30,21 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
30
30
|
const [textures, setTextures] = useState({});
|
|
31
31
|
const loading = useRef(new Set());
|
|
32
32
|
const objectRefs = useRef({});
|
|
33
|
+
const rigidBodyRefs = useRef(new Map());
|
|
33
34
|
const [selectedObject, setSelectedObject] = useState(null);
|
|
34
35
|
const rootRef = useRef(null);
|
|
35
36
|
useImperativeHandle(ref, () => ({
|
|
36
|
-
root: rootRef.current
|
|
37
|
+
root: rootRef.current,
|
|
38
|
+
rigidBodyRefs: rigidBodyRefs.current
|
|
37
39
|
}), []);
|
|
38
40
|
const registerRef = useCallback((id, obj) => {
|
|
39
41
|
objectRefs.current[id] = obj;
|
|
40
42
|
if (id === selectedId)
|
|
41
43
|
setSelectedObject(obj);
|
|
42
44
|
}, [selectedId]);
|
|
45
|
+
const registerRigidBodyRef = useCallback((id, rb) => {
|
|
46
|
+
rigidBodyRefs.current.set(id, rb);
|
|
47
|
+
}, []);
|
|
43
48
|
useEffect(() => {
|
|
44
49
|
const originalError = console.error;
|
|
45
50
|
console.error = (...args) => {
|
|
@@ -103,7 +108,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
103
108
|
});
|
|
104
109
|
});
|
|
105
110
|
}, [data, models, textures]);
|
|
106
|
-
return (_jsxs("group", { ref: rootRef, children: [_jsx(GameInstanceProvider, { models: models, selectedId: selectedId, editMode: editMode, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, onClick: onClick, registerRef: registerRef, loadedModels: models, loadedTextures: textures, editMode: editMode, parentMatrix: IDENTITY }) }), editMode && (_jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange, translationSnap: snapResolution > 0 ? snapResolution : undefined, rotationSnap: snapResolution > 0 ? snapResolution : undefined, scaleSnap: snapResolution > 0 ? snapResolution : undefined }, `transform-${snapResolution}`))] }))] }));
|
|
111
|
+
return (_jsxs("group", { ref: rootRef, children: [_jsx(GameInstanceProvider, { models: models, selectedId: selectedId, editMode: editMode, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, onClick: onClick, registerRef: registerRef, registerRigidBodyRef: registerRigidBodyRef, loadedModels: models, loadedTextures: textures, editMode: editMode, parentMatrix: IDENTITY }) }), editMode && (_jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange, translationSnap: snapResolution > 0 ? snapResolution : undefined, rotationSnap: snapResolution > 0 ? snapResolution : undefined, scaleSnap: snapResolution > 0 ? snapResolution : undefined }, `transform-${snapResolution}`))] }))] }));
|
|
107
112
|
});
|
|
108
113
|
export function GameObjectRenderer(props) {
|
|
109
114
|
var _a, _b, _c;
|
|
@@ -160,7 +165,7 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
|
|
|
160
165
|
}
|
|
161
166
|
return (_jsx(GameInstance, { id: gameObject.id, modelUrl: (_k = (_j = (_h = gameObject.components) === null || _h === void 0 ? void 0 : _h.model) === null || _j === void 0 ? void 0 : _j.properties) === null || _k === void 0 ? void 0 : _k.filename, position: worldPosition, rotation: worldRotation, scale: worldScale, physics: physicsProps }));
|
|
162
167
|
}
|
|
163
|
-
function StandardNode({ gameObject, selectedId, onSelect, onClick, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = IDENTITY, }) {
|
|
168
|
+
function StandardNode({ gameObject, selectedId, onSelect, onClick, registerRef, registerRigidBodyRef, loadedModels, loadedTextures, editMode, parentMatrix = IDENTITY, }) {
|
|
164
169
|
var _a, _b, _c, _d, _e, _f;
|
|
165
170
|
const groupRef = useRef(null);
|
|
166
171
|
const helperRef = useRef(null);
|
|
@@ -193,12 +198,12 @@ function StandardNode({ gameObject, selectedId, onSelect, onClick, registerRef,
|
|
|
193
198
|
const physicsDef = hasPhysics ? getComponent("Physics") : null;
|
|
194
199
|
const isInstanced = (_e = (_d = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model) === null || _d === void 0 ? void 0 : _d.properties) === null || _e === void 0 ? void 0 : _e.instanced;
|
|
195
200
|
const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
196
|
-
const inner = (_jsxs("group", { onPointerDown: editMode ? onDown : undefined, onPointerMove: editMode ? () => (clickValid.current = false) : undefined, onPointerUp: editMode ? onUp : undefined, children: [renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix), (_f = gameObject.children) === null || _f === void 0 ? void 0 : _f.map(child => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, onClick: onClick, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: world }, child.id)))] }));
|
|
201
|
+
const inner = (_jsxs("group", { onPointerDown: editMode ? onDown : undefined, onPointerMove: editMode ? () => (clickValid.current = false) : undefined, onPointerUp: editMode ? onUp : undefined, children: [renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix), (_f = gameObject.children) === null || _f === void 0 ? void 0 : _f.map(child => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, onClick: onClick, registerRef: registerRef, registerRigidBodyRef: registerRigidBodyRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: world }, child.id)))] }));
|
|
197
202
|
if (editMode) {
|
|
198
|
-
return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: _jsx("mesh", { visible: false, children: _jsx("boxGeometry", { args: [0.01, 0.01, 0.01] }) }) }), _jsx("group", { ref: helperRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: inner }), hasPhysics && (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) ? (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, children: inner }, physicsKey)) : null] }));
|
|
203
|
+
return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: _jsx("mesh", { visible: false, children: _jsx("boxGeometry", { args: [0.01, 0.01, 0.01] }) }) }), _jsx("group", { ref: helperRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: inner }), hasPhysics && (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) ? (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, nodeId: gameObject.id, registerRigidBodyRef: registerRigidBodyRef, children: inner }, physicsKey)) : null] }));
|
|
199
204
|
}
|
|
200
205
|
if (hasPhysics && (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View)) {
|
|
201
|
-
return (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, children: inner }, physicsKey));
|
|
206
|
+
return (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, nodeId: gameObject.id, registerRigidBodyRef: registerRigidBodyRef, children: inner }, physicsKey));
|
|
202
207
|
}
|
|
203
208
|
return (_jsx("group", { ref: groupRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, onPointerDown: onDown, onPointerMove: () => (clickValid.current = false), onPointerUp: onUp, children: inner }));
|
|
204
209
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
import { Component } from './ComponentRegistry';
|
|
2
|
+
import { MeshStandardMaterialProperties } from 'three';
|
|
3
|
+
export interface MaterialProps extends Omit<MeshStandardMaterialProperties, 'args'> {
|
|
4
|
+
texture?: string;
|
|
5
|
+
repeat?: boolean;
|
|
6
|
+
repeatCount?: [number, number];
|
|
7
|
+
generateMipmaps?: boolean;
|
|
8
|
+
minFilter?: string;
|
|
9
|
+
magFilter?: string;
|
|
10
|
+
}
|
|
2
11
|
declare const MaterialComponent: Component;
|
|
3
12
|
export default MaterialComponent;
|
|
@@ -1,3 +1,14 @@
|
|
|
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
|
+
};
|
|
1
12
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
13
|
import { SingleTextureViewer, TextureListViewer } from '../../assetviewer/page';
|
|
3
14
|
import { useEffect, useState } from 'react';
|
|
@@ -25,6 +36,13 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
|
|
|
25
36
|
const fields = [
|
|
26
37
|
{ name: 'color', type: 'color', label: 'Color' },
|
|
27
38
|
{ name: 'wireframe', type: 'boolean', label: 'Wireframe' },
|
|
39
|
+
{ name: 'transparent', type: 'boolean', label: 'Transparent' },
|
|
40
|
+
{ name: 'opacity', type: 'number', label: 'Opacity', min: 0, max: 1, step: 0.01 },
|
|
41
|
+
{ name: 'metalness', type: 'number', label: 'Metalness', min: 0, max: 1, step: 0.01 },
|
|
42
|
+
{ name: 'roughness', type: 'number', label: 'Roughness', min: 0, max: 1, step: 0.01 },
|
|
43
|
+
{ name: 'transmission', type: 'number', label: 'Transmission', min: 0, max: 1, step: 0.01 },
|
|
44
|
+
{ name: 'thickness', type: 'number', label: 'Thickness', min: 0, step: 0.1 },
|
|
45
|
+
{ name: 'ior', type: 'number', label: 'IOR (Index of Refraction)', min: 1, max: 2.333, step: 0.01 },
|
|
28
46
|
{
|
|
29
47
|
name: 'texture',
|
|
30
48
|
type: 'custom',
|
|
@@ -80,6 +98,9 @@ function MaterialComponentView({ properties, loadedTextures }) {
|
|
|
80
98
|
const minFilter = (properties === null || properties === void 0 ? void 0 : properties.minFilter) || 'LinearMipmapLinearFilter';
|
|
81
99
|
const magFilter = (properties === null || properties === void 0 ? void 0 : properties.magFilter) || 'LinearFilter';
|
|
82
100
|
const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
|
|
101
|
+
// Destructure all material props and separate custom texture handling props
|
|
102
|
+
const _b = properties || {}, { texture: _texture, repeat: _repeat, repeatCount: _repeatCount, generateMipmaps: _generateMipmaps, minFilter: _minFilter, magFilter: _magFilter, map: _map } = _b, // Filter out map since we set it explicitly
|
|
103
|
+
materialProps = __rest(_b, ["texture", "repeat", "repeatCount", "generateMipmaps", "minFilter", "magFilter", "map"]);
|
|
83
104
|
const minFilterMap = {
|
|
84
105
|
NearestFilter,
|
|
85
106
|
LinearFilter,
|
|
@@ -116,8 +137,7 @@ function MaterialComponentView({ properties, loadedTextures }) {
|
|
|
116
137
|
if (!properties) {
|
|
117
138
|
return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
|
|
118
139
|
}
|
|
119
|
-
|
|
120
|
-
return (_jsx("meshStandardMaterial", { color: color, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
|
|
140
|
+
return (_jsx("meshStandardMaterial", Object.assign({ map: finalTexture }, materialProps), (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
|
|
121
141
|
}
|
|
122
142
|
const MaterialComponent = {
|
|
123
143
|
name: 'Material',
|
|
@@ -126,7 +146,11 @@ const MaterialComponent = {
|
|
|
126
146
|
nonComposable: true,
|
|
127
147
|
defaultProperties: {
|
|
128
148
|
color: '#ffffff',
|
|
129
|
-
wireframe: false
|
|
149
|
+
wireframe: false,
|
|
150
|
+
transparent: false,
|
|
151
|
+
opacity: 1,
|
|
152
|
+
metalness: 0,
|
|
153
|
+
roughness: 1
|
|
130
154
|
}
|
|
131
155
|
};
|
|
132
156
|
export default MaterialComponent;
|
|
@@ -11,6 +11,7 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
13
|
import { RigidBody } from "@react-three/rapier";
|
|
14
|
+
import { useRef, useEffect } from 'react';
|
|
14
15
|
import { FieldRenderer } from "./Input";
|
|
15
16
|
const physicsFields = [
|
|
16
17
|
{
|
|
@@ -79,15 +80,27 @@ const physicsFields = [
|
|
|
79
80
|
function PhysicsComponentEditor({ component, onUpdate }) {
|
|
80
81
|
return (_jsx(FieldRenderer, { fields: physicsFields, values: component.properties, onChange: (props) => onUpdate(Object.assign(Object.assign({}, component), { properties: Object.assign(Object.assign({}, component.properties), props) })) }));
|
|
81
82
|
}
|
|
82
|
-
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }) {
|
|
83
|
+
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }) {
|
|
83
84
|
const { type, colliders } = properties, otherProps = __rest(properties, ["type", "colliders"]);
|
|
84
85
|
const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
|
|
86
|
+
const rigidBodyRef = useRef(null);
|
|
87
|
+
// Register RigidBody ref when it's available
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (nodeId && registerRigidBodyRef && rigidBodyRef.current) {
|
|
90
|
+
registerRigidBodyRef(nodeId, rigidBodyRef.current);
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
if (nodeId && registerRigidBodyRef) {
|
|
94
|
+
registerRigidBodyRef(nodeId, null);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}, [nodeId, registerRigidBodyRef]);
|
|
85
98
|
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
86
99
|
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
87
100
|
const rbKey = editMode
|
|
88
101
|
? `${type || 'dynamic'}_${colliderType}_${position === null || position === void 0 ? void 0 : position.join(',')}_${rotation === null || rotation === void 0 ? void 0 : rotation.join(',')}`
|
|
89
102
|
: `${type || 'dynamic'}_${colliderType}`;
|
|
90
|
-
return (_jsx(RigidBody, Object.assign({ type: type, colliders: colliderType, position: position, rotation: rotation, scale: scale }, otherProps, { children: children }), rbKey));
|
|
103
|
+
return (_jsx(RigidBody, Object.assign({ ref: rigidBodyRef, type: type, colliders: colliderType, position: position, rotation: rotation, scale: scale }, otherProps, { children: children }), rbKey));
|
|
91
104
|
}
|
|
92
105
|
const PhysicsComponent = {
|
|
93
106
|
name: 'Physics',
|
package/package.json
CHANGED
|
@@ -116,7 +116,7 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
116
116
|
| Transform | `Transform` | `position: [x,y,z]`, `rotation: [x,y,z]` (radians), `scale: [x,y,z]` |
|
|
117
117
|
| Geometry | `Geometry` | `geometryType`: box/sphere/plane/cylinder, `args`: dimension array |
|
|
118
118
|
| Material | `Material` | `color`, `texture?`, `metalness?`, `roughness?`, `repeat?`, `repeatCount?` |
|
|
119
|
-
| Physics | `Physics` | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?`, `friction?`, `linearDamping?`, `angularDamping?`, `gravityScale?`, plus any Rapier RigidBody props |
|
|
119
|
+
| Physics | `Physics` | `type`: dynamic/fixed/kinematicPosition/kinematicVelocity, `mass?`, `restitution?`, `friction?`, `linearDamping?`, `angularDamping?`, `gravityScale?`, plus any Rapier RigidBody props - [See advanced physics guide](./rules/ADVANCED_PHYSICS.md) |
|
|
120
120
|
| Model | `Model` | `filename` (GLB/FBX path), `instanced?` for GPU batching |
|
|
121
121
|
| SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
|
|
122
122
|
| DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
|
|
@@ -191,102 +191,71 @@ import { PrefabEditor } from 'react-three-game';
|
|
|
191
191
|
### Tree Utilities
|
|
192
192
|
|
|
193
193
|
```typescript
|
|
194
|
-
import {
|
|
194
|
+
import { findNode, updateNode, updateNodeById, deleteNode, cloneNode, exportGLBData } from 'react-three-game';
|
|
195
195
|
|
|
196
|
-
const updated = updateNodeById(root, nodeId, node => ({ ...node, disabled: true }));
|
|
197
196
|
const node = findNode(root, nodeId);
|
|
197
|
+
const updated = updateNode(root, nodeId, n => ({ ...n, disabled: true })); // or updateNodeById (identical)
|
|
198
198
|
const afterDelete = deleteNode(root, nodeId);
|
|
199
199
|
const cloned = cloneNode(node);
|
|
200
200
|
const glbData = await exportGLBData(sceneRoot);
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
##
|
|
203
|
+
## Hybrid JSON + R3F Children Pattern
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
**Prefabs define static scene structure, R3F children add dynamic behavior**:
|
|
206
206
|
|
|
207
|
-
```
|
|
208
|
-
{
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 1, 40] } },
|
|
213
|
-
"material": { "type": "Material", "properties": { "texture": "/textures/floor.png", "repeat": true, "repeatCount": [20, 20] } },
|
|
214
|
-
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Platform
|
|
220
|
-
|
|
221
|
-
```json
|
|
222
|
-
{
|
|
223
|
-
"id": "platform",
|
|
224
|
-
"components": {
|
|
225
|
-
"transform": { "type": "Transform", "properties": { "position": [-8, 2, -5] } },
|
|
226
|
-
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [6, 0.5, 4] } },
|
|
227
|
-
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
### Ramp
|
|
233
|
-
|
|
234
|
-
```json
|
|
235
|
-
{
|
|
236
|
-
"id": "ramp",
|
|
237
|
-
"components": {
|
|
238
|
-
"transform": { "type": "Transform", "properties": { "position": [-12, 1, -5], "rotation": [0, 0, 0.3] } },
|
|
239
|
-
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [5, 0.3, 3] } },
|
|
240
|
-
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
### Wall Pattern
|
|
246
|
-
### Wall
|
|
207
|
+
```tsx
|
|
208
|
+
import { useRef } from 'react';
|
|
209
|
+
import { useFrame } from '@react-three/fiber';
|
|
210
|
+
import { PrefabEditor, findNode } from 'react-three-game';
|
|
211
|
+
import type { PrefabEditorRef } from 'react-three-game';
|
|
247
212
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
213
|
+
function DynamicLight() {
|
|
214
|
+
const lightRef = useRef<THREE.SpotLight>(null!);
|
|
215
|
+
|
|
216
|
+
useFrame(({ clock }) => {
|
|
217
|
+
lightRef.current.intensity = 100 + Math.sin(clock.elapsedTime) * 50;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return <spotLight ref={lightRef} position={[10, 15, 10]} angle={0.5} />;
|
|
256
221
|
}
|
|
257
|
-
```
|
|
258
222
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
{ "id": "spot", "components": { "transform": { "properties": { "position": [10, 15, 10] } }, "spotlight": { "type": "SpotLight", "properties": { "intensity": 200, "angle": 0.8, "castShadow": true } } } },
|
|
264
|
-
{ "id": "ambient", "components": { "ambientlight": { "type": "AmbientLight", "properties": { "intensity": 0.4 } } } }
|
|
265
|
-
]
|
|
223
|
+
<PrefabEditor initialPrefab={staticScenePrefab}>
|
|
224
|
+
<DynamicLight />
|
|
225
|
+
<CustomController />
|
|
226
|
+
</PrefabEditor>
|
|
266
227
|
```
|
|
267
228
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
```json
|
|
271
|
-
{
|
|
272
|
-
"id": "text",
|
|
273
|
-
"components": {
|
|
274
|
-
"transform": { "type": "Transform", "properties": { "position": [0, 3, 0] } },
|
|
275
|
-
"text": { "type": "Text", "properties": { "text": "Welcome", "font": "/fonts/font.ttf", "size": 1, "depth": 0.1 } }
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
```
|
|
229
|
+
**Use cases**: Player controllers, AI behaviors, procedural animation, real-time effects.
|
|
279
230
|
|
|
280
|
-
|
|
231
|
+
## Quick Reference Examples
|
|
281
232
|
|
|
282
233
|
```json
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
}
|
|
234
|
+
// Static geometry with physics (floor, wall, platform, ramp)
|
|
235
|
+
{ "id": "floor", "components": {
|
|
236
|
+
"transform": { "type": "Transform", "properties": { "position": [0, -0.5, 0] } },
|
|
237
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [40, 1, 40] } },
|
|
238
|
+
"material": { "type": "Material", "properties": { "texture": "/textures/floor.png", "repeat": true, "repeatCount": [20, 20] } },
|
|
239
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
240
|
+
}}
|
|
241
|
+
|
|
242
|
+
// Lighting
|
|
243
|
+
{ "id": "spot", "components": {
|
|
244
|
+
"transform": { "type": "Transform", "properties": { "position": [10, 15, 10] } },
|
|
245
|
+
"spotlight": { "type": "SpotLight", "properties": { "intensity": 200, "angle": 0.8, "castShadow": true } }
|
|
246
|
+
}}
|
|
247
|
+
|
|
248
|
+
// 3D Text
|
|
249
|
+
{ "id": "title", "components": {
|
|
250
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 3, 0] } },
|
|
251
|
+
"text": { "type": "Text", "properties": { "text": "Welcome", "font": "/fonts/font.ttf", "size": 1, "depth": 0.1 } }
|
|
252
|
+
}}
|
|
253
|
+
|
|
254
|
+
// GLB Model
|
|
255
|
+
{ "id": "tree", "components": {
|
|
256
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 0, 0], "scale": [1.5, 1.5, 1.5] } },
|
|
257
|
+
"model": { "type": "Model", "properties": { "filename": "/models/tree.glb" } }
|
|
258
|
+
}}
|
|
290
259
|
```
|
|
291
260
|
|
|
292
261
|
## Editor
|
|
@@ -301,6 +270,21 @@ import { PrefabEditor } from 'react-three-game';
|
|
|
301
270
|
|
|
302
271
|
Keyboard shortcuts: **T** (Translate), **R** (Rotate), **S** (Scale)
|
|
303
272
|
|
|
273
|
+
### Camera Control
|
|
274
|
+
|
|
275
|
+
By default, `PrefabEditor` uses an orbit camera. **Override it by adding a custom camera with `makeDefault`**:
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
import { PerspectiveCamera } from '@react-three/drei';
|
|
279
|
+
import { PrefabEditor } from 'react-three-game';
|
|
280
|
+
|
|
281
|
+
<PrefabEditor initialPrefab={prefab}>
|
|
282
|
+
<PerspectiveCamera makeDefault position={[0, 5, 10]} fov={75} />
|
|
283
|
+
</PrefabEditor>
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Any R3F camera component works: `PerspectiveCamera`, `OrthographicCamera`, or custom camera controllers.
|
|
287
|
+
|
|
304
288
|
### Programmatic Updates
|
|
305
289
|
|
|
306
290
|
```jsx
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Advanced Physics & Patterns
|
|
2
|
+
|
|
3
|
+
## Physics Type Decision Tree
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Need physics?
|
|
7
|
+
├─ No → Don't add Physics component
|
|
8
|
+
└─ Yes → Does it move?
|
|
9
|
+
├─ Never moves (walls, floor, static props)
|
|
10
|
+
│ └─ type: "fixed"
|
|
11
|
+
│
|
|
12
|
+
├─ Moves via forces/gravity (balls, boxes, ragdolls)
|
|
13
|
+
│ └─ type: "dynamic"
|
|
14
|
+
│ ├─ Fast moving? → ccd: true
|
|
15
|
+
│ └─ Heavy? → mass: 10+
|
|
16
|
+
│
|
|
17
|
+
├─ Scripted animation (moving platforms, doors)
|
|
18
|
+
│ └─ type: "kinematicPosition"
|
|
19
|
+
│ └─ Update transform via updateNodeById
|
|
20
|
+
│
|
|
21
|
+
└─ Velocity-driven (conveyor belts, wind zones)
|
|
22
|
+
└─ type: "kinematicVelocity"
|
|
23
|
+
└─ Set velocity via RigidBody ref
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Type descriptions**:
|
|
27
|
+
- **fixed**: Immovable, infinite mass (ground, walls, buildings)
|
|
28
|
+
- **dynamic**: Affected by forces and gravity (player, projectiles, props)
|
|
29
|
+
- **kinematicPosition**: Move via code, push dynamic bodies (elevators, doors)
|
|
30
|
+
- **kinematicVelocity**: Set constant velocity, push dynamic bodies (conveyors)
|
|
31
|
+
|
|
32
|
+
**Performance tip**: Use `fixed` for anything that never moves - it's cheapest.
|
|
33
|
+
|
|
34
|
+
## Physics Material Properties
|
|
35
|
+
|
|
36
|
+
Complete reference for `Physics` component properties:
|
|
37
|
+
|
|
38
|
+
| Property | Type | Default | Description |
|
|
39
|
+
|----------|------|---------|-------------|
|
|
40
|
+
| `type` | `'dynamic'` \| `'fixed'` \| `'kinematicPosition'` \| `'kinematicVelocity'` | `'dynamic'` | Body type (see decision tree above) |
|
|
41
|
+
| `mass` | `number` | `1` | Body mass (dynamic only) |
|
|
42
|
+
| `restitution` | `number` | `0` | Bounciness (0 = no bounce, 1 = perfect bounce) |
|
|
43
|
+
| `friction` | `number` | `0.5` | Surface friction (0 = ice, 1+ = sticky) |
|
|
44
|
+
| `linearDamping` | `number` | `0` | Velocity decay (0 = none, 1 = full stop) |
|
|
45
|
+
| `angularDamping` | `number` | `0` | Rotation decay |
|
|
46
|
+
| `gravityScale` | `number` | `1` | Gravity multiplier (0 = floating, 2 = heavy) |
|
|
47
|
+
| `lockTranslations` | `boolean` | `false` | Freeze position |
|
|
48
|
+
| `lockRotations` | `boolean` | `false` | Freeze rotation |
|
|
49
|
+
| `enabledTranslations` | `[bool, bool, bool]` | `[true, true, true]` | Lock per axis (X, Y, Z) |
|
|
50
|
+
| `enabledRotations` | `[bool, bool, bool]` | `[true, true, true]` | Lock rotation per axis |
|
|
51
|
+
| `ccd` | `boolean` | `false` | Continuous collision detection (fast objects) |
|
|
52
|
+
| `sensor` | `boolean` | `false` | Trigger only, no collision response |
|
|
53
|
+
| `collisionGroups` | `number` | - | Rapier collision groups bitfield |
|
|
54
|
+
| `solverGroups` | `number` | - | Rapier solver groups bitfield |
|
|
55
|
+
|
|
56
|
+
**Example - Bouncy Ball**:
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"physics": {
|
|
60
|
+
"type": "Physics",
|
|
61
|
+
"properties": {
|
|
62
|
+
"type": "dynamic",
|
|
63
|
+
"mass": 0.5,
|
|
64
|
+
"restitution": 0.9,
|
|
65
|
+
"friction": 0.1,
|
|
66
|
+
"linearDamping": 0.05
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Example - Ice Surface**:
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"physics": {
|
|
76
|
+
"type": "Physics",
|
|
77
|
+
"properties": {
|
|
78
|
+
"type": "fixed",
|
|
79
|
+
"friction": 0,
|
|
80
|
+
"restitution": 0.1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Force & Impulse Application
|
|
87
|
+
|
|
88
|
+
Access RigidBody refs via `PrefabRootRef.rigidBodyRefs` to apply physics forces to prefab objects:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { useRef, useEffect } from 'react';
|
|
92
|
+
import { PrefabEditor } from 'react-three-game';
|
|
93
|
+
import type { PrefabEditorRef } from 'react-three-game';
|
|
94
|
+
import type { RapierRigidBody } from '@react-three/rapier';
|
|
95
|
+
|
|
96
|
+
function ForceApplier({ editorRef }: { editorRef: React.RefObject<PrefabEditorRef> }) {
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const interval = setInterval(() => {
|
|
99
|
+
const rootRef = editorRef.current?.rootRef.current;
|
|
100
|
+
if (!rootRef) return;
|
|
101
|
+
|
|
102
|
+
// Access RigidBody ref by node ID
|
|
103
|
+
const rigidBody = rootRef.rigidBodyRefs.get('ball') as RapierRigidBody;
|
|
104
|
+
|
|
105
|
+
if (rigidBody) {
|
|
106
|
+
// Apply upward impulse
|
|
107
|
+
rigidBody.applyImpulse({ x: 0, y: 5, z: 0 }, true);
|
|
108
|
+
|
|
109
|
+
// Or apply continuous force
|
|
110
|
+
rigidBody.addForce({ x: 0, y: 10, z: 0 }, true);
|
|
111
|
+
|
|
112
|
+
// Apply torque
|
|
113
|
+
rigidBody.applyTorqueImpulse({ x: 0, y: 1, z: 0 }, true);
|
|
114
|
+
}
|
|
115
|
+
}, 2000);
|
|
116
|
+
|
|
117
|
+
return () => clearInterval(interval);
|
|
118
|
+
}, [editorRef]);
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Scene() {
|
|
124
|
+
const editorRef = useRef<PrefabEditorRef>(null);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<PrefabEditor ref={editorRef} initialPrefab={prefab}>
|
|
128
|
+
<ForceApplier editorRef={editorRef} />
|
|
129
|
+
</PrefabEditor>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Alternative: Custom R3F components**
|
|
135
|
+
|
|
136
|
+
For fully custom physics objects, create R3F components with their own RigidBody refs:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { useRef } from 'react';
|
|
140
|
+
import { RigidBody } from '@react-three/rapier';
|
|
141
|
+
import type { RapierRigidBody } from '@react-three/rapier';
|
|
142
|
+
import { useFrame } from '@react-three/fiber';
|
|
143
|
+
import { PrefabEditor } from 'react-three-game';
|
|
144
|
+
|
|
145
|
+
function PhysicsBall() {
|
|
146
|
+
const rigidBodyRef = useRef<RapierRigidBody>(null);
|
|
147
|
+
|
|
148
|
+
useFrame(() => {
|
|
149
|
+
if (rigidBodyRef.current) {
|
|
150
|
+
// Apply jump force on interval
|
|
151
|
+
rigidBodyRef.current.applyImpulse({ x: 0, y: 5, z: 0 }, true);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<RigidBody ref={rigidBodyRef} position={[0, 5, 0]} type="dynamic">
|
|
157
|
+
<mesh castShadow>
|
|
158
|
+
<sphereGeometry args={[0.5, 32, 32]} />
|
|
159
|
+
<meshStandardMaterial color="orange" />
|
|
160
|
+
</mesh>
|
|
161
|
+
</RigidBody>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
<PrefabEditor initialPrefab={prefab}>
|
|
166
|
+
<PhysicsBall />
|
|
167
|
+
</PrefabEditor>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Alternative: Kinematic position updates**
|
|
171
|
+
|
|
172
|
+
For smooth animated movement without forces, use `kinematicPosition` and update via `updateNodeById`:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import { useRef } from 'react';
|
|
176
|
+
import { useFrame } from '@react-three/fiber';
|
|
177
|
+
import { PrefabEditor, updateNodeById } from 'react-three-game';
|
|
178
|
+
import type { PrefabEditorRef } from 'react-three-game';
|
|
179
|
+
|
|
180
|
+
function KinematicMover({ editorRef }: { editorRef: React.RefObject<PrefabEditorRef> }) {
|
|
181
|
+
useFrame(({ clock }) => {
|
|
182
|
+
const prefab = editorRef.current?.prefab;
|
|
183
|
+
if (!prefab) return;
|
|
184
|
+
|
|
185
|
+
const y = 2 + Math.sin(clock.elapsedTime * 2) * 3;
|
|
186
|
+
|
|
187
|
+
const newRoot = updateNodeById(prefab.root, "platform", node => ({
|
|
188
|
+
...node,
|
|
189
|
+
components: {
|
|
190
|
+
...node.components,
|
|
191
|
+
transform: {
|
|
192
|
+
...node.components!.transform!,
|
|
193
|
+
properties: {
|
|
194
|
+
...node.components!.transform!.properties,
|
|
195
|
+
position: [0, y, 0]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
editorRef.current!.setPrefab({ ...prefab, root: newRoot });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Rapier RigidBody methods**:
|
|
209
|
+
- `applyImpulse(vector, wakeUp)` - Instantaneous velocity change
|
|
210
|
+
- `addForce(vector, wakeUp)` - Continuous force application
|
|
211
|
+
- `applyTorqueImpulse(vector, wakeUp)` - Rotational impulse
|
|
212
|
+
- `addTorque(vector, wakeUp)` - Continuous torque
|
|
213
|
+
- `setLinvel(vector, wakeUp)` - Set linear velocity directly
|
|
214
|
+
- `setAngvel(vector, wakeUp)` - Set angular velocity directly
|
|
215
|
+
|
|
216
|
+
## Tilted Surfaces & Containment
|
|
217
|
+
|
|
218
|
+
**⚠️ Tilted walls don't contain objects** - physics objects slide off angled surfaces.
|
|
219
|
+
|
|
220
|
+
### ❌ Wrong Approach
|
|
221
|
+
```json
|
|
222
|
+
{
|
|
223
|
+
"id": "tilted-wall",
|
|
224
|
+
"components": {
|
|
225
|
+
"transform": { "type": "Transform", "properties": { "rotation": [0, 0, 0.3] } },
|
|
226
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [10, 5, 1] } },
|
|
227
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
Objects will **slide off** the tilted surface.
|
|
232
|
+
|
|
233
|
+
### ✅ Correct Pattern - Perpendicular Walls
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"id": "container",
|
|
237
|
+
"children": [
|
|
238
|
+
{
|
|
239
|
+
"id": "floor",
|
|
240
|
+
"components": {
|
|
241
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 0, 0] } },
|
|
242
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [20, 1, 20] } },
|
|
243
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"id": "wall-north",
|
|
248
|
+
"components": {
|
|
249
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 2.5, -10] } },
|
|
250
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [20, 5, 1] } },
|
|
251
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"id": "wall-south",
|
|
256
|
+
"components": {
|
|
257
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 2.5, 10] } },
|
|
258
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [20, 5, 1] } },
|
|
259
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"id": "wall-east",
|
|
264
|
+
"components": {
|
|
265
|
+
"transform": { "type": "Transform", "properties": { "position": [10, 2.5, 0] } },
|
|
266
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [1, 5, 20] } },
|
|
267
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"id": "wall-west",
|
|
272
|
+
"components": {
|
|
273
|
+
"transform": { "type": "Transform", "properties": { "position": [-10, 2.5, 0] } },
|
|
274
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "box", "args": [1, 5, 20] } },
|
|
275
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Key principle**: Walls must be **perpendicular to gravity** to contain dynamic objects.
|
|
283
|
+
|
|
284
|
+
## Instanced Physics
|
|
285
|
+
|
|
286
|
+
When using `"instanced": true` on models, physics behaves differently than standard objects. **All instances of the same model share a single `InstancedRigidBodies` component** for optimal GPU performance.
|
|
287
|
+
|
|
288
|
+
### Standard vs Instanced Physics
|
|
289
|
+
|
|
290
|
+
| Aspect | Standard Physics | Instanced Physics |
|
|
291
|
+
|--------|------------------|-------------------|
|
|
292
|
+
| RigidBody Component | Individual `<RigidBody>` per object | Single `<InstancedRigidBodies>` for all instances |
|
|
293
|
+
| Ref Access | `rigidBodyRefs.get(nodeId)` returns single RigidBody | Not accessible via `rigidBodyRefs` |
|
|
294
|
+
| Force Application | Direct per-object | Must access via InstancedRigidBodies ref |
|
|
295
|
+
| Collider Type | `hull` (dynamic) or `trimesh` (fixed) | Same, auto-selected |
|
|
296
|
+
| Performance | One draw call per object | One draw call for all instances |
|
|
297
|
+
|
|
298
|
+
### Defining Instanced Objects
|
|
299
|
+
|
|
300
|
+
Set `"instanced": true` in the model component. **All instances of the same model+physics type are automatically batched**:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"id": "tree1",
|
|
305
|
+
"components": {
|
|
306
|
+
"transform": { "type": "Transform", "properties": { "position": [0, 0, 0] } },
|
|
307
|
+
"model": { "type": "Model", "properties": { "filename": "models/tree.glb", "instanced": true } },
|
|
308
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Add multiple instances - they'll be automatically batched:
|
|
314
|
+
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"id": "tree2",
|
|
318
|
+
"components": {
|
|
319
|
+
"transform": { "type": "Transform", "properties": { "position": [5, 0, 3] } },
|
|
320
|
+
"model": { "type": "Model", "properties": { "filename": "models/tree.glb", "instanced": true } },
|
|
321
|
+
"physics": { "type": "Physics", "properties": { "type": "fixed" } }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Force Application on Instanced Objects
|
|
327
|
+
|
|
328
|
+
**Instanced physics bodies are not individually accessible.** For objects requiring force/impulse control, use non-instanced physics (`"instanced": false` or omit the property).
|
|
329
|
+
|
|
330
|
+
### When to Use Instanced Physics
|
|
331
|
+
|
|
332
|
+
✅ **Good for:**
|
|
333
|
+
- Many copies of the same static object (trees, rocks, buildings)
|
|
334
|
+
- Large scenes with 100+ similar objects
|
|
335
|
+
- Fixed physics bodies that never move
|
|
336
|
+
- Background props and decorations
|
|
337
|
+
|
|
338
|
+
❌ **Avoid for:**
|
|
339
|
+
- Objects requiring individual force/impulse control
|
|
340
|
+
- Dynamic objects with unique behaviors
|
|
341
|
+
- Objects that need to be individually removed/spawned
|
|
342
|
+
- Fewer than ~20 instances (overhead not worth it)
|
|
343
|
+
|
|
344
|
+
### Performance Notes
|
|
345
|
+
|
|
346
|
+
- **Batching**: All instances with the same `filename` and `physics.type` are rendered in a single draw call
|
|
347
|
+
- **Scale handling**: Visual scale is applied per-instance, but collider scale may differ
|
|
348
|
+
- **Transform updates**: Use `updateNodeById` to move instances (triggers re-sync)
|
|
349
|
+
- **Memory**: One set of GPU buffers shared across all instances
|
|
@@ -11,6 +11,7 @@ import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./Instance
|
|
|
11
11
|
import { updateNode } from "./utils";
|
|
12
12
|
import { PhysicsProps } from "./components/PhysicsComponent";
|
|
13
13
|
import { EditorContext } from "./EditorContext";
|
|
14
|
+
import type { RapierRigidBody } from "@react-three/rapier";
|
|
14
15
|
|
|
15
16
|
components.forEach(registerComponent);
|
|
16
17
|
|
|
@@ -18,6 +19,7 @@ const IDENTITY = new Matrix4();
|
|
|
18
19
|
|
|
19
20
|
export interface PrefabRootRef {
|
|
20
21
|
root: Group | null;
|
|
22
|
+
rigidBodyRefs: Map<string, RapierRigidBody | null>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
@@ -40,11 +42,13 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
|
40
42
|
const [textures, setTextures] = useState<Record<string, Texture>>({});
|
|
41
43
|
const loading = useRef(new Set<string>());
|
|
42
44
|
const objectRefs = useRef<Record<string, Object3D | null>>({});
|
|
45
|
+
const rigidBodyRefs = useRef<Map<string, RapierRigidBody | null>>(new Map());
|
|
43
46
|
const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
|
|
44
47
|
const rootRef = useRef<Group>(null);
|
|
45
48
|
|
|
46
49
|
useImperativeHandle(ref, () => ({
|
|
47
|
-
root: rootRef.current
|
|
50
|
+
root: rootRef.current,
|
|
51
|
+
rigidBodyRefs: rigidBodyRefs.current
|
|
48
52
|
}), []);
|
|
49
53
|
|
|
50
54
|
const registerRef = useCallback((id: string, obj: Object3D | null) => {
|
|
@@ -52,6 +56,10 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
|
52
56
|
if (id === selectedId) setSelectedObject(obj);
|
|
53
57
|
}, [selectedId]);
|
|
54
58
|
|
|
59
|
+
const registerRigidBodyRef = useCallback((id: string, rb: RapierRigidBody | null) => {
|
|
60
|
+
rigidBodyRefs.current.set(id, rb);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
55
63
|
useEffect(() => {
|
|
56
64
|
const originalError = console.error;
|
|
57
65
|
console.error = (...args: any[]) => {
|
|
@@ -145,6 +153,7 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
|
145
153
|
onSelect={editMode ? onSelect : undefined}
|
|
146
154
|
onClick={onClick}
|
|
147
155
|
registerRef={registerRef}
|
|
156
|
+
registerRigidBodyRef={registerRigidBodyRef}
|
|
148
157
|
loadedModels={models}
|
|
149
158
|
loadedTextures={textures}
|
|
150
159
|
editMode={editMode}
|
|
@@ -278,6 +287,7 @@ function StandardNode({
|
|
|
278
287
|
onSelect,
|
|
279
288
|
onClick,
|
|
280
289
|
registerRef,
|
|
290
|
+
registerRigidBodyRef,
|
|
281
291
|
loadedModels,
|
|
282
292
|
loadedTextures,
|
|
283
293
|
editMode,
|
|
@@ -341,6 +351,7 @@ function StandardNode({
|
|
|
341
351
|
onSelect={onSelect}
|
|
342
352
|
onClick={onClick}
|
|
343
353
|
registerRef={registerRef}
|
|
354
|
+
registerRigidBodyRef={registerRigidBodyRef}
|
|
344
355
|
loadedModels={loadedModels}
|
|
345
356
|
loadedTextures={loadedTextures}
|
|
346
357
|
editMode={editMode}
|
|
@@ -379,6 +390,8 @@ function StandardNode({
|
|
|
379
390
|
rotation={transform.rotation}
|
|
380
391
|
scale={transform.scale}
|
|
381
392
|
editMode={editMode}
|
|
393
|
+
nodeId={gameObject.id}
|
|
394
|
+
registerRigidBodyRef={registerRigidBodyRef}
|
|
382
395
|
>{inner}</physicsDef.View>
|
|
383
396
|
) : null}
|
|
384
397
|
</>
|
|
@@ -394,6 +407,8 @@ function StandardNode({
|
|
|
394
407
|
rotation={transform.rotation}
|
|
395
408
|
scale={transform.scale}
|
|
396
409
|
editMode={editMode}
|
|
410
|
+
nodeId={gameObject.id}
|
|
411
|
+
registerRigidBodyRef={registerRigidBodyRef}
|
|
397
412
|
>{inner}</physicsDef.View>
|
|
398
413
|
);
|
|
399
414
|
}
|
|
@@ -419,6 +434,7 @@ interface RendererProps {
|
|
|
419
434
|
onSelect?: (id: string) => void;
|
|
420
435
|
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
421
436
|
registerRef: (id: string, obj: Object3D | null) => void;
|
|
437
|
+
registerRigidBodyRef: (id: string, rb: RapierRigidBody | null) => void;
|
|
422
438
|
loadedModels: Record<string, Object3D>;
|
|
423
439
|
loadedTextures: Record<string, Texture>;
|
|
424
440
|
editMode?: boolean;
|
|
@@ -15,9 +15,19 @@ import {
|
|
|
15
15
|
LinearMipmapNearestFilter,
|
|
16
16
|
LinearMipmapLinearFilter,
|
|
17
17
|
MinificationTextureFilter,
|
|
18
|
-
MagnificationTextureFilter
|
|
18
|
+
MagnificationTextureFilter,
|
|
19
|
+
MeshStandardMaterialProperties
|
|
19
20
|
} from 'three';
|
|
20
21
|
|
|
22
|
+
export interface MaterialProps extends Omit<MeshStandardMaterialProperties, 'args'> {
|
|
23
|
+
texture?: string;
|
|
24
|
+
repeat?: boolean;
|
|
25
|
+
repeatCount?: [number, number];
|
|
26
|
+
generateMipmaps?: boolean;
|
|
27
|
+
minFilter?: string;
|
|
28
|
+
magFilter?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
function TexturePicker({
|
|
22
32
|
value,
|
|
23
33
|
onChange,
|
|
@@ -71,6 +81,13 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
|
|
|
71
81
|
const fields: FieldDefinition[] = [
|
|
72
82
|
{ name: 'color', type: 'color', label: 'Color' },
|
|
73
83
|
{ name: 'wireframe', type: 'boolean', label: 'Wireframe' },
|
|
84
|
+
{ name: 'transparent', type: 'boolean', label: 'Transparent' },
|
|
85
|
+
{ name: 'opacity', type: 'number', label: 'Opacity', min: 0, max: 1, step: 0.01 },
|
|
86
|
+
{ name: 'metalness', type: 'number', label: 'Metalness', min: 0, max: 1, step: 0.01 },
|
|
87
|
+
{ name: 'roughness', type: 'number', label: 'Roughness', min: 0, max: 1, step: 0.01 },
|
|
88
|
+
{ name: 'transmission', type: 'number', label: 'Transmission', min: 0, max: 1, step: 0.01 },
|
|
89
|
+
{ name: 'thickness', type: 'number', label: 'Thickness', min: 0, step: 0.1 },
|
|
90
|
+
{ name: 'ior', type: 'number', label: 'IOR (Index of Refraction)', min: 1, max: 2.333, step: 0.01 },
|
|
74
91
|
{
|
|
75
92
|
name: 'texture',
|
|
76
93
|
type: 'custom',
|
|
@@ -135,7 +152,7 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
|
|
|
135
152
|
}
|
|
136
153
|
|
|
137
154
|
// View for Material component
|
|
138
|
-
function MaterialComponentView({ properties, loadedTextures }: { properties:
|
|
155
|
+
function MaterialComponentView({ properties, loadedTextures }: { properties: MaterialProps, loadedTextures?: Record<string, Texture> }) {
|
|
139
156
|
const textureName = properties?.texture;
|
|
140
157
|
const repeat = properties?.repeat;
|
|
141
158
|
const repeatCount = properties?.repeatCount;
|
|
@@ -144,6 +161,18 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: any
|
|
|
144
161
|
const magFilter = properties?.magFilter || 'LinearFilter';
|
|
145
162
|
const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
|
|
146
163
|
|
|
164
|
+
// Destructure all material props and separate custom texture handling props
|
|
165
|
+
const {
|
|
166
|
+
texture: _texture,
|
|
167
|
+
repeat: _repeat,
|
|
168
|
+
repeatCount: _repeatCount,
|
|
169
|
+
generateMipmaps: _generateMipmaps,
|
|
170
|
+
minFilter: _minFilter,
|
|
171
|
+
magFilter: _magFilter,
|
|
172
|
+
map: _map, // Filter out map since we set it explicitly
|
|
173
|
+
...materialProps
|
|
174
|
+
} = properties || {};
|
|
175
|
+
|
|
147
176
|
const minFilterMap: Record<string, MinificationTextureFilter> = {
|
|
148
177
|
NearestFilter,
|
|
149
178
|
LinearFilter,
|
|
@@ -180,15 +209,11 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: any
|
|
|
180
209
|
return <meshStandardMaterial color="red" wireframe />;
|
|
181
210
|
}
|
|
182
211
|
|
|
183
|
-
const { color, wireframe = false } = properties;
|
|
184
|
-
|
|
185
212
|
return (
|
|
186
213
|
<meshStandardMaterial
|
|
187
214
|
key={finalTexture?.uuid ?? 'no-texture'}
|
|
188
|
-
color={color}
|
|
189
|
-
wireframe={wireframe}
|
|
190
215
|
map={finalTexture}
|
|
191
|
-
|
|
216
|
+
{...materialProps}
|
|
192
217
|
/>
|
|
193
218
|
);
|
|
194
219
|
}
|
|
@@ -200,7 +225,11 @@ const MaterialComponent: Component = {
|
|
|
200
225
|
nonComposable: true,
|
|
201
226
|
defaultProperties: {
|
|
202
227
|
color: '#ffffff',
|
|
203
|
-
wireframe: false
|
|
228
|
+
wireframe: false,
|
|
229
|
+
transparent: false,
|
|
230
|
+
opacity: 1,
|
|
231
|
+
metalness: 0,
|
|
232
|
+
roughness: 1
|
|
204
233
|
}
|
|
205
234
|
};
|
|
206
235
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RigidBody, RapierRigidBody } from "@react-three/rapier";
|
|
2
2
|
import type { RigidBodyOptions } from "@react-three/rapier";
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { useRef, useEffect } from 'react';
|
|
4
5
|
import { Component } from "./ComponentRegistry";
|
|
5
6
|
import { FieldRenderer, FieldDefinition } from "./Input";
|
|
6
7
|
import { ComponentData } from "../types";
|
|
@@ -89,11 +90,26 @@ interface PhysicsViewProps {
|
|
|
89
90
|
position?: [number, number, number];
|
|
90
91
|
rotation?: [number, number, number];
|
|
91
92
|
scale?: [number, number, number];
|
|
93
|
+
nodeId?: string;
|
|
94
|
+
registerRigidBodyRef?: (id: string, rb: RapierRigidBody | null) => void;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }: PhysicsViewProps) {
|
|
97
|
+
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode, nodeId, registerRigidBodyRef }: PhysicsViewProps) {
|
|
95
98
|
const { type, colliders, ...otherProps } = properties;
|
|
96
99
|
const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
|
|
100
|
+
const rigidBodyRef = useRef<RapierRigidBody>(null);
|
|
101
|
+
|
|
102
|
+
// Register RigidBody ref when it's available
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (nodeId && registerRigidBodyRef && rigidBodyRef.current) {
|
|
105
|
+
registerRigidBodyRef(nodeId, rigidBodyRef.current);
|
|
106
|
+
}
|
|
107
|
+
return () => {
|
|
108
|
+
if (nodeId && registerRigidBodyRef) {
|
|
109
|
+
registerRigidBodyRef(nodeId, null);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, [nodeId, registerRigidBodyRef]);
|
|
97
113
|
|
|
98
114
|
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
99
115
|
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
@@ -104,6 +120,7 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
|
|
|
104
120
|
return (
|
|
105
121
|
<RigidBody
|
|
106
122
|
key={rbKey}
|
|
123
|
+
ref={rigidBodyRef}
|
|
107
124
|
type={type}
|
|
108
125
|
colliders={colliderType as any}
|
|
109
126
|
position={position}
|