diff --git a/package.json b/package.json
index 48974ba..5e4de86 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
+ "@react-three/rapier": "^2.2.0",
"@types/three": "^0.184.1",
"install": "^0.13.0",
"mobx": "^6.15.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e398580..55f7fe8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@react-three/fiber':
specifier: ^9.6.1
version: 9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)
+ '@react-three/rapier':
+ specifier: ^2.2.0
+ version: 2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0)
'@types/three':
specifier: ^0.184.1
version: 0.184.1
@@ -161,6 +164,9 @@ packages:
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
+ '@dimforge/rapier3d-compat@0.19.2':
+ resolution: {integrity: sha512-AZHL1jqUF55QJkJyU1yKeh4ImX2J93bVLIezT1+o0FZqTix6O06MOaqpKoJ4MmbDCsoZmwO+qc471/SDMDm2AA==}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -386,6 +392,13 @@ packages:
react-native:
optional: true
+ '@react-three/rapier@2.2.0':
+ resolution: {integrity: sha512-mVsqbKXlGZoN+XrqdhzFZUQmy8pibEOVzl4k7LC+LHe84bQnYBSagy1Hvbda6bL1PJDdTFyiDiBk5buKFinNIQ==}
+ peerDependencies:
+ '@react-three/fiber': ^9.0.4
+ react: ^19
+ three: '>=0.159.0'
+
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1532,6 +1545,8 @@ snapshots:
'@dimforge/rapier3d-compat@0.12.0': {}
+ '@dimforge/rapier3d-compat@0.19.2': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -1747,6 +1762,15 @@ snapshots:
- '@types/react'
- immer
+ '@react-three/rapier@2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0)':
+ dependencies:
+ '@dimforge/rapier3d-compat': 0.19.2
+ '@react-three/fiber': 9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)
+ react: 19.2.6
+ suspend-react: 0.1.3(react@19.2.6)
+ three: 0.184.0
+ three-stdlib: 2.36.1(three@0.184.0)
+
'@rolldown/binding-android-arm64@1.0.2':
optional: true
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 248c5b2..696c825 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,2 @@
allowBuilds:
- '@parcel/watcher': set this to true or false
+ '@parcel/watcher': false
diff --git a/src/components/CharacterView.tsx b/src/components/CharacterView.tsx
index 260cf6c..9087c3a 100644
--- a/src/components/CharacterView.tsx
+++ b/src/components/CharacterView.tsx
@@ -1,22 +1,35 @@
import { observer } from "mobx-react-lite";
import type { Character } from "../types";
+import { SyncRigidBody } from "./SyncRigidBody";
+import { state } from "../state";
export const CharacterView = observer(function ({ character }: { character: Character }) {
-
const pos = character.transform.position;
- return
- {/*
-
-
- */}
-
-
-
-
-
+ return (
+ {
+ state.game?.setCharacterTransform(
+ {
+ position: data.position,
+ look: character.transform.look,
+ },
+ data.linearVelocity,
+ undefined, // do not change radial velocity
+ );
+ }}
+ >
+
+
+
+
+
+
+
+ );
});
-
diff --git a/src/components/GameView.tsx b/src/components/GameView.tsx
index 18b6082..087add3 100644
--- a/src/components/GameView.tsx
+++ b/src/components/GameView.tsx
@@ -3,28 +3,35 @@ import { SceneView } from "./SceneView";
import { state } from "../state";
import { useFrame, useThree } from "@react-three/fiber";
import { PointerLockControls, useKeyboardControls } from "@react-three/drei";
-import { useEffect, useRef } from "react";
+import { Suspense, useEffect, useRef } from "react";
+import { Physics } from "@react-three/rapier";
function PlayerMovement() {
const [, get] = useKeyboardControls();
const dirty = useRef(false);
useFrame(({ camera }, dt) => {
+ if (state.game?.isPaused)
+ return;
+
const { forward, backward, left, right } = get();
const speed = 5 * dt;
- if (forward) { camera.translateZ(-speed); dirty.current = true; }
- if (backward) { camera.translateZ( speed); dirty.current = true; }
- if (left) { camera.translateX(-speed); dirty.current = true; }
- if (right) { camera.translateX( speed); dirty.current = true; }
+ if (forward) { camera.translateZ(-speed); dirty.current = true; }
+ if (backward) { camera.translateZ(speed); dirty.current = true; }
+ if (left) { camera.translateX(-speed); dirty.current = true; }
+ if (right) { camera.translateX(speed); dirty.current = true; }
if (!dirty.current) return;
dirty.current = false;
const [rx, ry, rz] = camera.rotation.toArray();
- state.game?.setCharacterTransform({
- position: camera.position.toArray(),
- look: [rx, ry, rz],
- });
+ state.game?.setCharacterTransform(
+ {
+ position: camera.position.toArray(),
+ look: [rx, ry, rz],
+ },
+ // do not change velocities
+ );
});
return { dirty.current = true; }} />;
@@ -50,7 +57,11 @@ export const GameView = observer(function () {
return null;
return (<>
-
-
+
+ {/* */}
+
+
+
+
>);
});
diff --git a/src/components/ObjectView.tsx b/src/components/ObjectView.tsx
index 6245240..cd042a0 100644
--- a/src/components/ObjectView.tsx
+++ b/src/components/ObjectView.tsx
@@ -1,6 +1,6 @@
import { observer } from "mobx-react-lite";
-import type { ObjectInstance, ObjectType } from "../types";
-import { useRef, type RefObject } from "react";
+import type { ObjectType, ObjectInstance } from "../types";
+import { useRef, useMemo, type RefObject } from "react";
import type { Group } from "three";
import { Instance, Instances, TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three";
@@ -8,35 +8,59 @@ import type { ThreeEvent } from "@react-three/fiber";
import { state } from "../state";
import { nextSelectionEditMode } from "../state/worldEditorState";
import type { R3 } from "../types/3d";
-
-type VoxelGroup = {
- id: string;
- color: string;
- opacity: number;
- positions: [number, number, number][];
-};
-
-function voxelGroups(objectType: ObjectType): VoxelGroup[] {
- const map = new Map();
- for (const idx in objectType.voxels) {
- const v = objectType.voxels[idx];
- const vt = state.world.getVoxelTypeById(v.typeId);
- const color = (v.color ?? vt?.color) ?? 'white';
- const opacity = (v.opacity ?? vt?.opacity) ?? 1;
- const key = `${color}-${opacity}`;
- if (!map.has(key))
- map.set(key, { id: key, color, opacity, positions: [] });
- const p = v.position;
- map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]);
- }
- return [...map.values()];
-}
+import { TrimeshCollider } from "@react-three/rapier";
+import { SyncRigidBody } from "./SyncRigidBody";
+import { runInAction } from "mobx";
+import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
type ObjectViewProps = {
object: ObjectInstance;
}
+function buildTrimesh(objectType: ObjectType, voxelTypes: typeof state.world.data.voxelTypes): [Float32Array, Uint32Array] | null {
+ const collidable = objectType.voxels.filter(
+ v => voxelTypes[v.typeId]?.collidable !== false
+ );
+ if (!collidable.length) return null;
+
+ const n = collidable.length;
+ const verts = new Float32Array(n * 8 * 3);
+ const idxs = new Uint32Array(n * 36);
+
+ for (let i = 0; i < n; i++) {
+ const p = collidable[i].position;
+ const vb = i * 8;
+
+ // 8 corners of unit box at position p (no +0.5 — group transform handles offset)
+ verts.set([
+ p[0], p[1], p[2],
+ p[0] + 1, p[1], p[2],
+ p[0], p[1] + 1, p[2],
+ p[0] + 1, p[1] + 1, p[2],
+ p[0], p[1], p[2] + 1,
+ p[0] + 1, p[1], p[2] + 1,
+ p[0], p[1] + 1, p[2] + 1,
+ p[0] + 1, p[1] + 1, p[2] + 1,
+ ], vb * 3);
+
+ // 12 triangles (CCW outward normals)
+ idxs.set([
+ vb, vb + 2, vb + 3, vb, vb + 3, vb + 1, // -Z
+ vb + 4, vb + 5, vb + 7, vb + 4, vb + 7, vb + 6, // +Z
+ vb, vb + 1, vb + 5, vb, vb + 5, vb + 4, // -Y
+ vb + 2, vb + 6, vb + 7, vb + 2, vb + 7, vb + 3, // +Y
+ vb, vb + 4, vb + 6, vb, vb + 6, vb + 2, // -X
+ vb + 1, vb + 3, vb + 7, vb + 1, vb + 7, vb + 5, // +X
+ ], i * 36);
+ }
+
+ return [verts, idxs];
+}
+
export const ObjectView = observer(function ({ object }: ObjectViewProps) {
+
+ const objectType = state.world.getObjectTypeById(object.typeId);
+
const groupRef = useRef(null);
const isSelected = state.worldEditor.isEnabled &&
@@ -44,9 +68,18 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) {
useHelper(isSelected ? groupRef : { current: null } as any, BoxHelper, 'white');
- const objectType = state.world.getObjectTypeById(object.typeId);
+ // Must be before early return to satisfy hooks rules
+ const trimeshArgs = useMemo(
+ () => objectType
+ ? buildTrimesh(objectType, state.world.data.voxelTypes)
+ : null,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [objectType?.id]
+ );
+
if (!objectType)
return null;
+
const selectionMode = isSelected
? state.worldEditor.selectedObjectMode
: undefined;
@@ -80,26 +113,41 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) {
onMouseUp={handleTransformEnd}
/>
}
- {
+ // console.log(`changed object ${object.id} ${JSON.stringify(data)}`);
+ runInAction(() => {
+ const obj = state.game?.scene.objects[object.id];
+ if (obj) {
+ obj.position = data.position;
+ obj.rotation = data.rotation;
+ obj.linearVelocity = data.linearVelocity;
+ obj.radialVelocity = data.radialVelocity;
+ }
+ });
+ }}
>
- {
- voxelGroups(objectType).map((vg) => {
- return
+
+ {getObjectVoxelGroups(objectType, state.world.data.voxelTypes).map((vg) =>
+
- {
- vg.positions
- .map((pos, i) => )
- }
+ {vg.positions.map((pos, i) => )}
- })
- }
-
+ )}
+ {trimeshArgs && }
+
+
>);
});
diff --git a/src/components/SceneEditorView.tsx b/src/components/SceneEditorView.tsx
index 230da2b..d887425 100644
--- a/src/components/SceneEditorView.tsx
+++ b/src/components/SceneEditorView.tsx
@@ -1,4 +1,4 @@
-import { useRef } from 'react';
+import { Suspense, useRef } from 'react';
import type React from 'react';
import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
@@ -6,6 +6,7 @@ import { state } from '../state';
import { SceneView } from './SceneView';
import { type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync';
+import { Physics } from '@react-three/rapier';
export const SceneEditorView = observer(function () {
@@ -42,20 +43,24 @@ export const SceneEditorView = observer(function () {
};
return (<>
-
-
-
-
+
+
+
+
+
+
+
+
>);
});
diff --git a/src/components/SceneView.tsx b/src/components/SceneView.tsx
index 0cfb1e0..9b696f7 100644
--- a/src/components/SceneView.tsx
+++ b/src/components/SceneView.tsx
@@ -2,18 +2,35 @@ import { observer } from "mobx-react-lite";
import type { Scene } from "../types";
import { CharacterView } from "./CharacterView";
import { ObjectView } from "./ObjectView";
+import { useFrame } from "@react-three/fiber";
+import { useRapier } from "@react-three/rapier";
+import { state } from "../state";
type SceneViewProps = {
scene: Scene,
- renderCharacter: boolean;
+ editMode?: boolean;
}
-export const SceneView = observer(function ({ scene, renderCharacter }: SceneViewProps) {
+export const SceneView = observer(function (props: SceneViewProps) {
+ const rapier = useRapier();
+
+ useFrame((_, dt) => {
+ // if (props.editMode)
+ // return;
+
+ // const game = state.game;
+ // if (!game || game.isPaused)
+ // return;
+
+ // rapier.step(dt);
+ })
+
return (<>
- {Object.values(scene.objects).map((obj) =>
+ {Object.values(props.scene.objects).map((obj) =>
)}
- {renderCharacter && }
+ {/* {props.editMode && } */}
+ {}
>);
});
diff --git a/src/components/SyncRigidBody.tsx b/src/components/SyncRigidBody.tsx
new file mode 100644
index 0000000..ade0e54
--- /dev/null
+++ b/src/components/SyncRigidBody.tsx
@@ -0,0 +1,74 @@
+import { RigidBody, type RigidBodyProps, type RapierRigidBody } from "@react-three/rapier";
+import { useFrame } from "@react-three/fiber";
+import { useRef } from "react";
+import { Euler, Quaternion } from "three";
+import type { R3, V3 } from "../types/3d";
+
+export type SyncRigidBodyData = {
+ position: V3;
+ rotation: R3;
+ linearVelocity: V3;
+ radialVelocity: R3;
+}
+
+export type SyncRigidBodyOnSyncFunction = (data: SyncRigidBodyData) => void;
+
+type SyncRigidBodyProps = RigidBodyProps & {
+ onSync: SyncRigidBodyOnSyncFunction;
+};
+
+const _q = new Quaternion();
+const _e = new Euler();
+const EPS = 1e-6;
+
+// indices: 0-2 position, 3-5 rotation, 6-8 linearVelocity, 9-11 radialVelocity
+const PREV_INIT = new Float64Array(12).fill(Infinity);
+
+function syncRigidBodyDataToArray(data: SyncRigidBodyData, arr: Float64Array): void {
+ arr[0] = data.position[0]; arr[1] = data.position[1]; arr[2] = data.position[2];
+ arr[3] = data.rotation[0]; arr[4] = data.rotation[1]; arr[5] = data.rotation[2];
+ arr[6] = data.linearVelocity[0]; arr[7] = data.linearVelocity[1]; arr[8] = data.linearVelocity[2];
+ arr[9] = data.radialVelocity[0]; arr[10] = data.radialVelocity[1]; arr[11] = data.radialVelocity[2];
+}
+
+function compareTwoFloatArrays(a: Float64Array, b: Float64Array, epsilon: number): boolean {
+ for (let i = 0; i < a.length; i++)
+ if (Math.abs(a[i] - b[i]) > epsilon)
+ return true;
+ return false;
+}
+
+export function SyncRigidBody({ onSync, children, ...props }: SyncRigidBodyProps) {
+ const rbRef = useRef(null);
+ const prevData = useRef(PREV_INIT.slice());
+ const currentData = useRef(PREV_INIT.slice());
+
+ useFrame(() => {
+ const body = rbRef.current;
+ if (!body)
+ return;
+
+ const { x, y, z } = body.translation();
+ const rot = body.rotation();
+ _q.set(rot.x, rot.y, rot.z, rot.w);
+ _e.setFromQuaternion(_q);
+ const lv = body.linvel();
+ const av = body.angvel();
+
+ const data: SyncRigidBodyData = {
+ position: [x, y, z],
+ rotation: [_e.x, _e.y, _e.z],
+ linearVelocity: [lv.x, lv.y, lv.z],
+ radialVelocity: [av.x, av.y, av.z],
+ };
+
+ syncRigidBodyDataToArray(data, currentData.current);
+
+ if (compareTwoFloatArrays(currentData.current, prevData.current, EPS)) {
+ prevData.current.set(currentData.current);
+ onSync(data);
+ }
+ });
+
+ return {children};
+}
diff --git a/src/model/defaultVoxelTypes.ts b/src/model/defaultVoxelTypes.ts
index b72664a..bde74cd 100644
--- a/src/model/defaultVoxelTypes.ts
+++ b/src/model/defaultVoxelTypes.ts
@@ -11,7 +11,7 @@ const stone: VoxelType = {
const dirt: VoxelType = {
id: 'dirt',
name: 'Dirt',
- opacity: 1,
+ opacity: 0.8,
collidable: true,
color: '#302520',
};
diff --git a/src/model/gameFactory.ts b/src/model/gameFactory.ts
index 9acac42..53b30bb 100644
--- a/src/model/gameFactory.ts
+++ b/src/model/gameFactory.ts
@@ -1,4 +1,4 @@
-import type { Game, World } from "../types";
+import type { Game, RuntimeScene, World } from "../types";
import { clone } from "../utils";
export class GameFactory {
@@ -7,7 +7,7 @@ export class GameFactory {
return {
paused: false,
time: 0,
- scene: clone(world.initialScene),
+ scene: clone(world.initialScene) as RuntimeScene,
}
}
diff --git a/src/state/gameState.ts b/src/state/gameState.ts
index bf56f62..5893126 100644
--- a/src/state/gameState.ts
+++ b/src/state/gameState.ts
@@ -1,7 +1,7 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
import type { WorldState } from "./worldState";
-import type { Game, Scene } from "../types";
-import type { Pos3, R3, V3 } from "../types/3d";
+import type { Game, RuntimeScene } from "../types";
+import type { Pos3, V3, R3 } from "../types/3d";
import type { CameraProps } from "@react-three/fiber";
import { GameFactory } from "../model/gameFactory";
@@ -34,11 +34,12 @@ export class GameState {
return this.data.paused;
}
- public get scene(): Scene {
+ public get scene(): RuntimeScene {
return this.data.scene;
}
public get camera(): Pos3 {
+ return this.world.data.editorCamera;
return this.scene.character.transform;
}
@@ -78,11 +79,21 @@ export class GameState {
this.data.paused = true;
}
- public setCharacterTransform(transform: Pos3): void {
+ public setCharacterTransform(
+ transform: Pos3,
+ linearVelocity?: V3,
+ radialVelocity?: R3,
+ ): void {
if (this.isPaused)
return;
this.scene.character.transform = transform;
+ if (linearVelocity)
+ this.scene.character.linearVelocity = linearVelocity;
+ if (radialVelocity)
+ this.scene.character.radialVelocity = radialVelocity;
+
+ // console.log(`changed character to ${JSON.stringify(this.scene.character)}`);
}
public tick(deltaTime: number): void {
diff --git a/src/state/worldState.ts b/src/state/worldState.ts
index aa7cc61..1fbaa1d 100644
--- a/src/state/worldState.ts
+++ b/src/state/worldState.ts
@@ -39,14 +39,15 @@ export class WorldState {
public loadMock() {
console.log('Mocking world...');
- const objects = Array(3).fill(0)
+ const objects = Array(0).fill(0)
.map((_, idx) => ({
id: `obj${idx}`,
typeId: 'wolf',
+ gravityScale: 1,
position: [idx * 10 - 10, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
- }));
+ } as ObjectInstance));
const objectMap = Object.fromEntries(
objects.map((obj) => [obj.id, obj]),
) as Record
@@ -74,12 +75,14 @@ export class WorldState {
transform: {
position: [0, 5, 20],
look: [0, 0, 0],
- }
+ },
},
objects: {
terrain: {
id: 'terrain',
typeId: 'terrain',
+ physics: false, // pinned
+ gravityScale: 0, // pinned
position: [0, -1, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
diff --git a/src/types/character.ts b/src/types/character.ts
index ba7422a..ab9c92d 100644
--- a/src/types/character.ts
+++ b/src/types/character.ts
@@ -1,5 +1,8 @@
import type { Pos3 } from "./3d";
+import type { RuntimeObjectData } from "./object";
export type Character = {
transform: Pos3,
}
+
+export type RuntimeCharacter = Character & RuntimeObjectData;
diff --git a/src/types/game.ts b/src/types/game.ts
index 5877dee..e48f5d1 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -1,7 +1,7 @@
-import type { Scene } from "./scene";
+import type { RuntimeScene } from "./scene";
export type Game = {
paused: boolean;
time: number;
- scene: Scene;
+ scene: RuntimeScene;
}
diff --git a/src/types/object.ts b/src/types/object.ts
index 6434ec5..cf8fb52 100644
--- a/src/types/object.ts
+++ b/src/types/object.ts
@@ -10,7 +10,16 @@ export type ObjectType = {
export type ObjectInstance = {
id: string;
typeId: string;
+ physics: boolean;
+ gravityScale: number;
position: V3;
rotation: R3;
scale: V3;
-}
\ No newline at end of file
+}
+
+export type RuntimeObjectData = {
+ linearVelocity: V3;
+ radialVelocity: R3;
+}
+
+export type RuntimeObjectInstance = ObjectInstance & RuntimeObjectData;
diff --git a/src/types/scene.ts b/src/types/scene.ts
index afe918a..9e74291 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -1,7 +1,12 @@
-import type { Character } from "./character";
-import type { ObjectInstance } from "./object";
+import type { Character, RuntimeCharacter } from "./character";
+import type { ObjectInstance, RuntimeObjectInstance } from "./object";
export type Scene = {
character: Character;
objects: Record;
-}
\ No newline at end of file
+}
+
+export type RuntimeScene = {
+ character: RuntimeCharacter;
+ objects: Record;
+}
diff --git a/src/utils/graphics/voxelGroup.ts b/src/utils/graphics/voxelGroup.ts
new file mode 100644
index 0000000..6f04d33
--- /dev/null
+++ b/src/utils/graphics/voxelGroup.ts
@@ -0,0 +1,28 @@
+import type { ObjectType } from "../../types";
+import type { VoxelType } from "../../types/voxel";
+
+export type VoxelGroup = {
+ id: string;
+ color: string;
+ opacity: number;
+ positions: [number, number, number][];
+};
+
+export function getObjectVoxelGroups(
+ object: ObjectType,
+ voxelTypes: Record,
+): VoxelGroup[] {
+ const map = new Map();
+ for (const idx in object.voxels) {
+ const v = object.voxels[idx];
+ const vt = voxelTypes[v.typeId];
+ const color = (v.color ?? vt?.color) ?? 'white';
+ const opacity = (v.opacity ?? vt?.opacity) ?? 1;
+ const key = `${color}-${opacity}`;
+ if (!map.has(key))
+ map.set(key, { id: key, color, opacity, positions: [] });
+ const p = v.position;
+ map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]);
+ }
+ return [...map.values()];
+}