From 63bd5e371536c08e7a7d1ab5df534c149b90dbf1 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 3 Jun 2026 20:54:03 +0300 Subject: [PATCH] GameObjectInstance.version solution to circular object rerenders --- src/components/ObjectEditorView.tsx | 22 +++-- src/components/ObjectViewInternal.tsx | 129 +++++++++++++++++--------- src/state/worldState.ts | 3 +- src/types/model/object.ts | 1 + src/utils/index.ts | 10 ++ 5 files changed, 111 insertions(+), 54 deletions(-) diff --git a/src/components/ObjectEditorView.tsx b/src/components/ObjectEditorView.tsx index 528041d..2c2d887 100644 --- a/src/components/ObjectEditorView.tsx +++ b/src/components/ObjectEditorView.tsx @@ -1,13 +1,13 @@ import { observer } from "mobx-react-lite"; import type { ObjectInstance, R3, Runtime } from "../types"; -import { useRef, type RefObject } from "react"; +import { useMemo, useRef, type RefObject } from "react"; import type { Group } from "three"; import { TransformControls, useHelper } from "@react-three/drei"; import { BoxHelper } from "three"; import type { ThreeEvent } from "@react-three/fiber"; import { state } from "../state"; import { nextSelectionEditMode } from "../state/worldEditorState"; -import { ObjectViewInternal } from "./ObjectViewInternal"; +import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal"; type ObjectEditorViewProps = { object: Runtime; @@ -15,17 +15,23 @@ type ObjectEditorViewProps = { type SelectionOverlayProps = { objectId: string; - groupRef: RefObject; + ref: RefObject; onTransformEnd: () => void; } // Separate observer so only the 2 affected instances (selected/deselected) // re-render on selection change, not all N objects in the scene. -const SelectionOverlay = observer(function ({ objectId, groupRef, onTransformEnd }: SelectionOverlayProps) { +const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) { const isSelected = state.worldEditor.isEnabled && state.worldEditor.selectedObjectId === objectId; const selectionMode = isSelected ? state.worldEditor.selectedObjectMode : undefined; + // Stable virtual ref that reads through to the group inside the handle + const groupRef = useMemo>( + () => ({ get current() { return ref.current?.group ?? null; } }), + [ref], + ); + useHelper(isSelected ? groupRef : { current: null } as any, BoxHelper, 'white'); if (!isSelected || selectionMode === undefined || !groupRef.current) @@ -41,7 +47,7 @@ const SelectionOverlay = observer(function ({ objectId, groupRef, onTransformEnd }); export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewProps) { - const groupRef = useRef(null); + const dataRef = useRef(null); // Only observes world object types — not selection state — so won't // re-render when a different object is selected. @@ -61,7 +67,7 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP } function handleTransformEnd() { - const group = groupRef.current; + const group = dataRef.current?.group; if (group) state.worldEditor.setObjectTransform( object.id, @@ -74,11 +80,11 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP return (<> ; objectType: ObjectType; isEditor?: boolean; onClick?: (e: ThreeEvent) => void; + ref?: Ref; } -export const ObjectViewInternal = observer(forwardRef( - function ({ object, objectType, ...props }, ref) { +// not observer, instead reacts only to object.version field +export const ObjectViewInternal = function ({ object, objectType, ref, ...props }: ObjectViewInternalProps) { + const rbRef = useRef(null); + const groupRef = useRef(null); + useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current })); - return ( - { - // TODO rewrite so that is does not trigger re-render - // runInAction(() => { - // object.position = data.position; - // object.rotation = data.rotation; - // if (!props.isEditor) { - // (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity; - // (object as RuntimeGameObjectInstance).radialVelocity = data.radialVelocity; - // } - // }); - }} - > - - { - object.cache.voxelGroups.map((vg) => - - - - {vg.positions.map((pos, i) => )} - - ) + useEffect( + () => reaction( + () => 'version' in object ? object.version : 0, + () => { + if (!rbRef.current) + return; + + const gameObj = object as RuntimeGameObjectInstance; + + // position + rbRef.current.setTranslation(v3toRapier(gameObj.position), true); + + // rotation + const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]); + const q = new Quaternion().setFromEuler(euler); + rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true); + + // scale + groupRef.current?.scale.set(...gameObj.scale); + + rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true); + + rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true); + }, + ), + [object.id] + ); + + return ( + { + runInAction(() => { + object.position = data.position; + object.rotation = data.rotation; + if (!props.isEditor) { + (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity; + (object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity; } - {object.cache.colliderMesh && } - - - ); - } -)); + // do not change object.version to not trigger rerender + }); + }} + > + + { + object.cache.voxelGroups.map((vg) => + + + + {vg.positions.map((pos, i) => )} + + ) + } + {object.cache.colliderMesh && } + + + ); +} diff --git a/src/state/worldState.ts b/src/state/worldState.ts index 004ac45..f4cb792 100644 --- a/src/state/worldState.ts +++ b/src/state/worldState.ts @@ -43,8 +43,9 @@ export class WorldState { .map((_, idx) => ({ id: `obj${idx}`, typeId: 'wolf', + physics: true, gravityScale: 1, - position: [idx * 10 - 10, 0, 0], + position: [idx * 10 - 10, 5, 0], rotation: [0, 0, 0], scale: [1, 1, 1], } as ObjectInstance)); diff --git a/src/types/model/object.ts b/src/types/model/object.ts index ceac200..956acb5 100644 --- a/src/types/model/object.ts +++ b/src/types/model/object.ts @@ -19,6 +19,7 @@ export type ObjectInstance = { } export type GameObjectData = { + version: number; // increment to force three and physics changes linearVelocity: V3; angularVelocity: R3; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 6c7d361..5b71639 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,6 @@ import { v4 as uuid } from 'uuid'; +import type { V3 } from '../types'; +import { type Vector3Object } from '@react-three/rapier'; export function clone(obj: T): T { return JSON.parse(JSON.stringify(obj)); @@ -7,3 +9,11 @@ export function clone(obj: T): T { export function randomId(): string { return uuid(); } + +export function v3toRapier(value: V3): Vector3Object { + return { + x: value[0], + y: value[1], + z: value[2], + } +}