custom voxel mesh builder

object visibility
This commit is contained in:
azykov@mail.ru
2026-06-17 10:54:13 +03:00
parent 7ac154dd58
commit 31c77b0a72
27 changed files with 416 additions and 266 deletions
+38
View File
@@ -63,6 +63,9 @@ importers:
blockly: blockly:
specifier: ^12.5.1 specifier: ^12.5.1
version: 12.5.1 version: 12.5.1
earcut:
specifier: ^3.0.2
version: 3.0.2
mobx: mobx:
specifier: ^6.15.4 specifier: ^6.15.4
version: 6.15.4 version: 6.15.4
@@ -75,6 +78,9 @@ importers:
nipplejs: nipplejs:
specifier: ^1.0.4 specifier: ^1.0.4
version: 1.0.4 version: 1.0.4
polygon-clipping:
specifier: ^0.15.7
version: 0.15.7
r3f-perf: r3f-perf:
specifier: ^7.2.3 specifier: ^7.2.3
version: 7.2.3(@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))(@types/react@19.2.15)(@types/three@0.184.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)(use-sync-external-store@1.6.0(react@19.2.6)) version: 7.2.3(@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))(@types/react@19.2.15)(@types/three@0.184.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)(use-sync-external-store@1.6.0(react@19.2.6))
@@ -94,6 +100,9 @@ importers:
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.0 version: 14.0.0
devDependencies: devDependencies:
'@types/earcut':
specifier: ^3.0.0
version: 3.0.0
'@types/react': '@types/react':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.15 version: 19.2.15
@@ -793,6 +802,9 @@ packages:
'@types/draco3d@1.4.10': '@types/draco3d@1.4.10':
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
'@types/earcut@3.0.0':
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
'@types/esrecurse@4.3.1': '@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -1061,6 +1073,9 @@ packages:
draco3d@1.5.7: draco3d@1.5.7:
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
electron-to-chromium@1.5.364: electron-to-chromium@1.5.364:
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
@@ -1539,6 +1554,9 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true hasBin: true
polygon-clipping@0.15.7:
resolution: {integrity: sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==}
postcss@8.5.15: postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1654,6 +1672,9 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rolldown@1.0.2: rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1715,6 +1736,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
splaytree@3.2.3:
resolution: {integrity: sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==}
engines: {node: '>=18.20 || >=20'}
split2@4.2.0: split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
@@ -2606,6 +2631,8 @@ snapshots:
'@types/draco3d@1.4.10': {} '@types/draco3d@1.4.10': {}
'@types/earcut@3.0.0': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@@ -2881,6 +2908,8 @@ snapshots:
draco3d@1.5.7: {} draco3d@1.5.7: {}
earcut@3.0.2: {}
electron-to-chromium@1.5.364: {} electron-to-chromium@1.5.364: {}
entities@6.0.1: {} entities@6.0.1: {}
@@ -3374,6 +3403,11 @@ snapshots:
sonic-boom: 4.2.1 sonic-boom: 4.2.1
thread-stream: 4.2.0 thread-stream: 4.2.0
polygon-clipping@0.15.7:
dependencies:
robust-predicates: 3.0.3
splaytree: 3.2.3
postcss@8.5.15: postcss@8.5.15:
dependencies: dependencies:
nanoid: 3.3.12 nanoid: 3.3.12
@@ -3469,6 +3503,8 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
robust-predicates@3.0.3: {}
rolldown@1.0.2: rolldown@1.0.2:
dependencies: dependencies:
'@oxc-project/types': 0.132.0 '@oxc-project/types': 0.132.0
@@ -3534,6 +3570,8 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
splaytree@3.2.3: {}
split2@4.2.0: {} split2@4.2.0: {}
stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0): stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
+3
View File
@@ -15,10 +15,12 @@
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@types/three": "^0.184.1", "@types/three": "^0.184.1",
"blockly": "^12.5.1", "blockly": "^12.5.1",
"earcut": "^3.0.2",
"mobx": "^6.15.4", "mobx": "^6.15.4",
"mobx-react-lite": "^4.1.1", "mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1", "mobx-utils": "^6.1.1",
"nipplejs": "^1.0.4", "nipplejs": "^1.0.4",
"polygon-clipping": "^0.15.7",
"r3f-perf": "^7.2.3", "r3f-perf": "^7.2.3",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -27,6 +29,7 @@
"uuid": "^14.0.0" "uuid": "^14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/earcut": "^3.0.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -83,6 +83,8 @@ return;
return () => { return () => {
canvas.removeEventListener('click', onClick); canvas.removeEventListener('click', onClick);
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
if (document.pointerLockElement === canvas)
document.exitPointerLock();
}; };
}, [gl]); }, [gl]);
+7 -5
View File
@@ -6,6 +6,7 @@ import type { RuntimeObjectInstance } from "../types";
import { debugLabelsStore } from "../state/debugLabelsStore"; import { debugLabelsStore } from "../state/debugLabelsStore";
import { state } from "../state"; import { state } from "../state";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { formatV3 } from "../utils";
export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) { export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
@@ -13,8 +14,8 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
const rb = obj.rb(); const rb = obj.rb();
const texts = [ const texts = [
`${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`, `${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`,
`position: ${obj.position}`, `position: ${formatV3(obj.position)}`,
`rotation: ${obj.rotation}`, `rotation: ${formatV3(obj.rotation)}`,
...Object.entries(debugLabelsStore.getAll(obj.id)) ...Object.entries(debugLabelsStore.getAll(obj.id))
.map(([k, v]) => `${k}: ${v}`), .map(([k, v]) => `${k}: ${v}`),
]; ];
@@ -51,13 +52,14 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
<div style={{ <div style={{
background: 'rgba(0,0,0,0.65)', background: 'rgba(0,0,0,0.65)',
color: '#fff', color: '#fff',
padding: '2px 7px', padding: 10,
borderRadius: 4, borderRadius: 10,
fontSize: 12, fontSize: 10,
fontFamily: 'monospace', fontFamily: 'monospace',
whiteSpace: 'pre', whiteSpace: 'pre',
pointerEvents: 'none', pointerEvents: 'none',
userSelect: 'none', userSelect: 'none',
opacity: 0.3,
}}> }}>
{text} {text}
</div> </div>
+2 -1
View File
@@ -4,7 +4,7 @@ import { debugLabelsStore } from "../state/debugLabelsStore";
import { DebugLabel } from "./DebugLabel"; import { DebugLabel } from "./DebugLabel";
import type { RuntimeScene } from "../types"; import type { RuntimeScene } from "../types";
export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeScene }) { export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: RuntimeScene, editMode?: boolean }) {
useEffect( useEffect(
() => { () => {
return () => { debugLabelsStore.clear(); }; return () => { debugLabelsStore.clear(); };
@@ -15,6 +15,7 @@ export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeSce
return <> return <>
{ {
Object.entries(scene.objects) Object.entries(scene.objects)
.filter(([_, obj]) => editMode || obj.visible)
.map(([objectId, obj]) => { .map(([objectId, obj]) => {
return <DebugLabel key={objectId} obj={obj} />; return <DebugLabel key={objectId} obj={obj} />;
}) })
@@ -7,6 +7,7 @@ import { PlayerObjectView } from "./CharacterView";
export type GameObjectViewProps = { export type GameObjectViewProps = {
object: RuntimeGameObjectInstance; object: RuntimeGameObjectInstance;
isPlayer: boolean; isPlayer: boolean;
isSceneEditor?: boolean;
} }
export const GameObjectView = observer(function (props: GameObjectViewProps) { export const GameObjectView = observer(function (props: GameObjectViewProps) {
@@ -25,5 +26,6 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) {
{...props} {...props}
objectType={objectType} objectType={objectType}
onClick={handleClick} onClick={handleClick}
isSceneEditor={props.isSceneEditor}
/>; />;
}); });
@@ -78,7 +78,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
function handleTransformChange() { function handleTransformChange() {
const group = dataRef.current?.group; const group = dataRef.current?.group;
if (!group) return; if (!group)
return;
const pos = group.getWorldPosition(new Vector3()); const pos = group.getWorldPosition(new Vector3());
const quat = group.getWorldQuaternion(new Quaternion()); const quat = group.getWorldQuaternion(new Quaternion());
const euler = new Euler().setFromQuaternion(quat); const euler = new Euler().setFromQuaternion(quat);
@@ -90,7 +91,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
function handleTransformEnd() { function handleTransformEnd() {
const group = dataRef.current?.group; const group = dataRef.current?.group;
if (!group) return; if (!group)
return;
const pos = group.getWorldPosition(new Vector3()); const pos = group.getWorldPosition(new Vector3());
const quat = group.getWorldQuaternion(new Quaternion()); const quat = group.getWorldQuaternion(new Quaternion());
const euler = new Euler().setFromQuaternion(quat); const euler = new Euler().setFromQuaternion(quat);
@@ -113,8 +115,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
<ObjectViewInternal <ObjectViewInternal
ref={dataRef} ref={dataRef}
object={object} object={object}
isEditor editMode
isPlayer={isPlayer} isPlayer={isPlayer}
isSceneEditor
objectType={objectType} objectType={objectType}
onClick={handleClick} onClick={handleClick}
/> />
+40 -10
View File
@@ -1,22 +1,24 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types"; import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import type { VoxelGroup } from "../types/runtime/voxelGroup"; import type { VoxelGroup } from "../types/runtime/voxelGroup";
import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react"; import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react";
import { Euler, Quaternion, type Group } from "three"; import { Euler, Quaternion, Mesh, MeshStandardMaterial, type Group } from "three";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier"; import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody"; import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx"; import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils"; import { v3toRapier } from "../utils";
import { state } from "../state"; import { state } from "../state";
import { greedyMesh } from "../utils/graphics/greedyMesh"; import { buildVoxelMesh } from "../utils/graphics/voxelMesh";
const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg }: { vg: VoxelGroup }) { const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) {
const geo = useMemo(() => greedyMesh(vg.positions), [vg]); const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]);
useEffect(() => () => { geo.dispose(); }, [geo]); useEffect(() => () => { geo.dispose(); }, [geo]);
const op = vg.opacity * opacity;
return ( return (
<mesh geometry={geo}> <mesh geometry={geo} userData={{ baseOpacity: vg.opacity }}>
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} /> <meshStandardMaterial color={vg.color} opacity={op} transparent={op < 1} />
</mesh> </mesh>
); );
}); });
@@ -29,8 +31,9 @@ export type ObjectViewInternalHandle = {
type ObjectViewInternalProps = { type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>; object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType; objectType: ObjectType;
isEditor?: boolean; editMode?: boolean;
isPlayer?: boolean; isPlayer?: boolean;
isSceneEditor?: boolean;
onClick?: () => void; onClick?: () => void;
ref?: Ref<ObjectViewInternalHandle>; ref?: Ref<ObjectViewInternalHandle>;
} }
@@ -72,6 +75,29 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
const gameObj = object as RuntimeGameObjectInstance; const gameObj = object as RuntimeGameObjectInstance;
useEffect(
() => {
return reaction(
() => object.visible,
(objectVisible) => {
if (!groupRef.current)
return;
groupRef.current.visible = props.isSceneEditor || objectVisible;
groupRef.current.traverse((child) => {
if (child instanceof Mesh && child.material instanceof MeshStandardMaterial) {
const baseOpacity: number = child.userData.baseOpacity ?? 1;
child.material.opacity = baseOpacity * (objectVisible ? 1 : 0.5);
child.material.transparent = child.material.opacity < 1;
child.material.needsUpdate = true;
}
});
},
{ fireImmediately: true },
);
},
[object, props.isSceneEditor],
);
useEffect( useEffect(
() => { () => {
runInAction(() => { gameObj.rb = () => rbRef.current; }); runInAction(() => { gameObj.rb = () => rbRef.current; });
@@ -97,7 +123,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
gravityScale={object.gravityScale} gravityScale={object.gravityScale}
position={object.position} position={object.position}
rotation={object.rotation} rotation={object.rotation}
onCollisionEnter={props.isEditor ? undefined : (payload) => { onCollisionEnter={props.editMode ? undefined : (payload) => {
const game = state.game; const game = state.game;
if (!game) if (!game)
return; return;
@@ -114,7 +140,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
runInAction(() => { runInAction(() => {
object.position = data.position; object.position = data.position;
object.rotation = data.rotation; object.rotation = data.rotation;
if (!props.isEditor) { if (!props.editMode) {
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity; (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity; (object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
} }
@@ -130,7 +156,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
> >
{ {
object.cache.voxelGroups.map((vg) => object.cache.voxelGroups.map((vg) =>
<VoxelGroupMesh key={`${vg.id}-${object.cache.updatedAt}`} vg={vg} /> <VoxelGroupMesh
key={`${vg.id}-${object.cache.updatedAt}`}
vg={vg}
opacity={object.visible ? 1 : 0.5}
/>
) )
} }
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />} {object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
+10 -4
View File
@@ -4,7 +4,7 @@ import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { state } from '../state'; import { state } from '../state';
import { SceneView } from './SceneView'; import { SceneView } from './SceneView';
import { type OrthographicCamera, type PerspectiveCamera } from 'three'; import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync'; import { CameraSync } from './tools/CameraSync';
import { Physics } from '@react-three/rapier'; import { Physics } from '@react-three/rapier';
@@ -49,17 +49,23 @@ export const SceneEditorView = observer(function () {
onStart={handleStart} onStart={handleStart}
onEnd={handleEnd} onEnd={handleEnd}
makeDefault makeDefault
mouseButtons={{
LEFT: MOUSE.PAN,
MIDDLE: MOUSE.ROTATE,
RIGHT: MOUSE.ROTATE,
}}
rotateSpeed={0.2}
enableDamping={false} enableDamping={false}
/> />
<CameraSync camera={state.worldEditor.camera} /> <CameraSync camera={state.worldEditor.camera} />
<Grid {/* <Grid
sectionThickness={0.5} sectionThickness={0.5}
cellThickness={0} cellThickness={0}
sectionColor="white" sectionColor="white"
infiniteGrid infiniteGrid
/> /> */}
<Physics colliders={false} paused={true}> <Physics colliders={false} paused={true}>
<SceneView scene={state.worldEditor.scene} editMode debugOverlay /> <SceneView scene={state.worldEditor.scene} isEditor debugOverlay />
</Physics> </Physics>
</Suspense> </Suspense>
</>); </>);
+8 -7
View File
@@ -6,7 +6,7 @@ import { DebugLabelsView } from "./DebugLabelsView";
type SceneViewProps = { type SceneViewProps = {
scene: RuntimeScene, scene: RuntimeScene,
editMode?: boolean; isEditor?: boolean;
debugOverlay?: boolean; debugOverlay?: boolean;
} }
@@ -15,12 +15,13 @@ export const SceneView = observer(function (props: SceneViewProps) {
<ambientLight intensity={0.5} /> <ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} /> <directionalLight position={[5, 5, 5]} intensity={1} />
{ {
Object.values(props.scene.objects).map((obj) => ( Object.values(props.scene.objects)
!props.editMode .map((obj) => (
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} /> !props.isEditor
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} /> ? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} isSceneEditor={props.isEditor} />
)) : <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
))
} }
{props.debugOverlay && <DebugLabelsView scene={props.scene} />} {props.debugOverlay && <DebugLabelsView scene={props.scene} editMode={props.isEditor} />}
</>); </>);
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { Perf } from 'r3f-perf' import { Perf } from 'r3f-perf';
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { KeyboardControls } from '@react-three/drei'; import { KeyboardControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@@ -134,7 +134,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
<div style={{ flex: 1, position: 'relative' }}> <div style={{ flex: 1, position: 'relative' }}>
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}> <Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
<VoxelEditorScene <VoxelEditorScene
voxels={objectType.voxels} voxels={objectType.voxels.slice()}
mode={mode} mode={mode}
color={color} color={color}
typeId="stone" typeId="stone"
@@ -1,7 +1,9 @@
import { OrbitControls } from '@react-three/drei'; import { OrbitControls } from '@react-three/drei';
import { useState } from 'react'; import { useState, useMemo, useEffect } from 'react';
import type { ThreeEvent } from '@react-three/fiber'; import type { ThreeEvent } from '@react-three/fiber';
import type { Ray } from 'three';
import type { Voxel, V3 } from '../../types'; import type { Voxel, V3 } from '../../types';
import { buildVoxelMesh } from '../../utils/graphics/voxelMesh';
type Props = { type Props = {
voxels: Voxel[]; voxels: Voxel[];
@@ -13,42 +15,110 @@ type Props = {
}; };
const FLOOR_Y = 0; const FLOOR_Y = 0;
function adjPosition(pos: V3, nx: number, ny: number, nz: number): V3 {
return [pos[0] + Math.round(nx), pos[1] + Math.round(ny), pos[2] + Math.round(nz)];
}
function posEq(a: V3, b: V3) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
const DRAG_THRESHOLD = 5; const DRAG_THRESHOLD = 5;
function raycastVoxels(ray: Ray, voxels: Voxel[]): { position: V3; normal: V3 } | null {
const od = [ray.origin.x, ray.origin.y, ray.origin.z];
const dd = [ray.direction.x, ray.direction.y, ray.direction.z];
let closestT = Infinity;
let result: { position: V3; normal: V3 } | null = null;
for (const v of voxels) {
const p = v.position;
let tmin = -Infinity, tmax = Infinity;
let hitAxis = 0;
let miss = false;
for (let i = 0; i < 3; i++) {
const lo = p[i], hi = p[i] + 1;
if (Math.abs(dd[i]) < 1e-8) {
if (od[i] < lo || od[i] > hi) { miss = true; break; }
} else {
const inv = 1 / dd[i];
let t0 = (lo - od[i]) * inv;
let t1 = (hi - od[i]) * inv;
if (t0 > t1) { const tmp = t0; t0 = t1; t1 = tmp; }
if (t0 > tmin) { tmin = t0; hitAxis = i; }
tmax = Math.min(tmax, t1);
}
}
if (miss || tmin > tmax || tmin < 0 || tmin >= closestT)
continue;
closestT = tmin;
const normal: V3 = [0, 0, 0];
normal[hitAxis] = dd[hitAxis] < 0 ? 1 : -1;
result = { position: p, normal };
}
return result;
}
interface MergedVoxelMeshProps {
color: string;
positions: V3[];
onPointerMove: (e: ThreeEvent<PointerEvent>) => void;
onClick: (e: ThreeEvent<MouseEvent>) => void;
onPointerLeave: () => void;
}
function MergedVoxelMesh({ color, positions, onPointerMove, onClick, onPointerLeave }: MergedVoxelMeshProps) {
const geo = useMemo(
() => buildVoxelMesh(positions),
[positions],
);
useEffect(() => () => { geo.dispose(); }, [geo]);
return (<>
<mesh geometry={geo} onClick={onClick} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<meshStandardMaterial color={color} />
</mesh>
</>
);
}
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) { export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
const [ghost, setGhost] = useState<V3 | null>(null); const [ghost, setGhost] = useState<V3 | null>(null);
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) { const colorGroups = useMemo(() => {
if (e.delta > DRAG_THRESHOLD) const map = new Map<string, V3[]>();
return; for (const v of voxels) {
const c = v.color ?? '#888888';
if (!map.has(c))
map.set(c, []);
map.get(c)!.push(v.position);
}
return [...map.entries()].map(([c, positions]) => ({ color: c, positions }));
}, [voxels]);
function handleMeshMove(e: ThreeEvent<PointerEvent>) {
e.stopPropagation(); e.stopPropagation();
const hit = raycastVoxels(e.ray, voxels);
if (!hit)
return;
if (mode === 'remove') { if (mode === 'remove') {
onRemove(voxel.position); setGhost(hit.position);
setGhost(null);
} else { } else {
if (!e.face) const [x, y, z] = hit.position;
return; const [nx, ny, nz] = hit.normal;
const { x, y, z } = e.face.normal; setGhost([x + nx, y + ny, z + nz]);
const pos = adjPosition(voxel.position, x, y, z);
onAdd({ typeId, position: pos, color });
} }
} }
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) { function handleMeshClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > DRAG_THRESHOLD)
return;
e.stopPropagation(); e.stopPropagation();
const hit = raycastVoxels(e.ray, voxels);
if (!hit)
return;
if (mode === 'remove') { if (mode === 'remove') {
setGhost(voxel.position); onRemove(hit.position);
} else {
setGhost(null); setGhost(null);
} else {
const [x, y, z] = hit.position;
const [nx, ny, nz] = hit.normal;
onAdd({ typeId, position: [x + nx, y + ny, z + nz], color });
} }
} }
@@ -56,8 +126,7 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
if (e.delta > DRAG_THRESHOLD || mode !== 'add') if (e.delta > DRAG_THRESHOLD || mode !== 'add')
return; return;
e.stopPropagation(); e.stopPropagation();
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)]; onAdd({ typeId, position: [Math.floor(e.point.x), 0, Math.floor(e.point.z)], color });
onAdd({ typeId, position: pos, color });
} }
function handleFloorMove(e: ThreeEvent<PointerEvent>) { function handleFloorMove(e: ThreeEvent<PointerEvent>) {
@@ -66,16 +135,16 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]); setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
} }
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost)); const ghostOccupied = ghost !== null && voxels.some(
v => v.position[0] === ghost[0] && v.position[1] === ghost[1] && v.position[2] === ghost[2],
);
return ( return (
<> <>
<ambientLight intensity={0.7} /> <ambientLight intensity={0.7} />
<directionalLight position={[8, 12, 6]} intensity={1} castShadow /> <directionalLight position={[8, 12, 6]} intensity={1} castShadow />
<OrbitControls makeDefault /> <OrbitControls makeDefault />
{/* Floor plane */}
<mesh <mesh
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
position={[0, FLOOR_Y, 0]} position={[0, FLOOR_Y, 0]}
@@ -89,37 +158,30 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} /> <gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
{/* Voxels */} {colorGroups.map(({ color: groupColor, positions }) => (
{voxels.map((v, i) => { <MergedVoxelMesh
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost); key={groupColor}
return ( color={groupColor}
<mesh positions={positions}
key={i} onPointerMove={handleMeshMove}
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]} onClick={handleMeshClick}
onClick={(e) => handleVoxelClick(e, v)} onPointerLeave={() => setGhost(null)}
onPointerMove={(e) => handleVoxelMove(e, v)} />
onPointerLeave={() => setGhost(null)} ))}
>
<boxGeometry />
<meshStandardMaterial
color={v.color ?? '#888888'}
opacity={isHovered ? 0.4 : 1}
transparent={isHovered}
/>
</mesh>
);
})}
{/* Ghost voxel preview */}
{ghost && mode === 'add' && !ghostOccupied && ( {ghost && mode === 'add' && !ghostOccupied && (
<mesh <mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
raycast={() => null}
>
<boxGeometry /> <boxGeometry />
<meshStandardMaterial color={color} opacity={0.45} transparent /> <meshStandardMaterial color={color} opacity={0.45} transparent />
</mesh> </mesh>
)} )}
{ghost && mode === 'remove' && ghostOccupied && (
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
<boxGeometry args={[1.02, 1.02, 1.02]} />
<meshStandardMaterial color="#ff4444" opacity={0.4} transparent />
</mesh>
)}
</> </>
); );
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { toJS } from "mobx"; import { toJS } from "mobx";
import type { Game, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types"; import type { Game, GameScene, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types";
import { clone } from "../utils"; import { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime"; import { populateRuntimeScene } from "../utils/runtime";
import { ScriptGameEventBus } from "../scripts/eventBus"; import { ScriptGameEventBus } from "../scripts/eventBus";
@@ -36,7 +36,7 @@ export class GameFactory {
} }
} }
public static save(game: Game): void { public static save(game: Game<GameScene>): void {
localStorage.setItem("game", JSON.stringify(game)); localStorage.setItem("game", JSON.stringify(game));
} }
+16 -12
View File
@@ -1,6 +1,6 @@
import { autorun, makeAutoObservable, toJS } from "mobx"; import { autorun, makeAutoObservable, toJS } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import type { Game, GameObjectInstance, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } from "../types"; import type { Game, GameObjectInstance, GameScene, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } from "../types";
import type { CameraProps } from "@react-three/fiber"; import type { CameraProps } from "@react-three/fiber";
import type { ScriptGameEventBus } from "../scripts/eventBus"; import type { ScriptGameEventBus } from "../scripts/eventBus";
import { GameFactory } from "../model/gameFactory"; import { GameFactory } from "../model/gameFactory";
@@ -18,7 +18,7 @@ export class GameState {
private coroutines = new ScriptCoroutines(); private coroutines = new ScriptCoroutines();
private eventBus: ScriptGameEventBus; private eventBus: ScriptGameEventBus;
private _buildGameObjects() { private sceneObjectsAsSaveables(): GameScene['objects'] {
return Object.fromEntries( return Object.fromEntries(
Object.entries(this.scene.objects).map(([id, obj]) => [id, { Object.entries(this.scene.objects).map(([id, obj]) => [id, {
id: obj.id, id: obj.id,
@@ -31,17 +31,25 @@ export class GameState {
version: obj.version, version: obj.version,
linearVelocity: toJS(obj.linearVelocity), linearVelocity: toJS(obj.linearVelocity),
angularVelocity: toJS(obj.angularVelocity), angularVelocity: toJS(obj.angularVelocity),
visible: toJS(obj.visible),
} satisfies GameObjectInstance]) } satisfies GameObjectInstance])
) as unknown as RuntimeGameScene['objects']; ) as unknown as RuntimeGameScene['objects'];
} }
public get asSaveableGame(): Game<GameScene> {
return {
paused: this.isPaused,
time: this.time,
scene: {
playerObjectId: this.scene.playerObjectId,
objects: this.sceneObjectsAsSaveables(),
},
};
}
private startAutoSave() { private startAutoSave() {
return autorun(() => { return autorun(() => {
GameFactory.save({ this.save();
paused: this.isPaused,
time: this.time,
scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() },
});
}, { delay: 5000 }); }, { delay: 5000 });
} }
@@ -118,11 +126,7 @@ export class GameState {
public save(): void { public save(): void {
console.log('Saving game...'); console.log('Saving game...');
GameFactory.save({ GameFactory.save(this.asSaveableGame);
paused: this.isPaused,
time: this.time,
scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() },
});
} }
public resume(): void { public resume(): void {
+8 -1
View File
@@ -1,7 +1,7 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import { state } from "./rootState"; import { state } from "./rootState";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react'; import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit, IconEye, IconEyeClosed } from '@tabler/icons-react';
export type MenuNodeAction = { export type MenuNodeAction = {
id: string; id: string;
@@ -53,6 +53,13 @@ export class MenuState {
}); });
} }
actions.push({
id: 'toggle-object-instance-visibility',
content: o.visible ? <IconEye size="1em" /> : <IconEyeClosed size="1em" />,
tooltip: 'Toggle visibility',
onClick: () => { editor.setObjectVisible(o.id, !o.visible); },
});
return { return {
id: `o-${o.id}`, id: `o-${o.id}`,
title: <> title: <>
+11 -6
View File
@@ -147,6 +147,11 @@ export class WorldEditorState {
delete (this.scene.objects[objectId]); delete (this.scene.objects[objectId]);
} }
public setObjectVisible(objectId: string, value: boolean) {
if (this.scene.objects[objectId])
this.scene.objects[objectId].visible = value;
}
public addObjectType(): ObjectType { public addObjectType(): ObjectType {
const objectType: ObjectType = { const objectType: ObjectType = {
id: randomId(), id: randomId(),
@@ -166,38 +171,38 @@ export class WorldEditorState {
public addVoxelToObjectType(typeId: string, voxel: Voxel): void { public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
const objectType = this.world.getObjectTypeById(typeId); const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) if (!objectType)
return; return;
const occupied = objectType.voxels.some(v => const occupied = objectType.voxels.some(v =>
v.position[0] === voxel.position[0] && v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] && v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2] v.position[2] === voxel.position[2]
); );
if (!occupied) if (!occupied)
objectType.voxels.push(voxel); objectType.voxels.push(voxel);
} }
public removeVoxelFromObjectType(typeId: string, position: V3): void { public removeVoxelFromObjectType(typeId: string, position: V3): void {
const objectType = this.world.getObjectTypeById(typeId); const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) if (!objectType)
return; return;
const idx = objectType.voxels.findIndex(v => const idx = objectType.voxels.findIndex(v =>
v.position[0] === position[0] && v.position[0] === position[0] &&
v.position[1] === position[1] && v.position[1] === position[1] &&
v.position[2] === position[2] v.position[2] === position[2]
); );
if (idx !== -1) if (idx !== -1)
objectType.voxels.splice(idx, 1); objectType.voxels.splice(idx, 1);
} }
public refreshObjectTypeCaches(typeId: string): void { public refreshObjectTypeCaches(typeId: string): void {
const objectType = this.world.getObjectTypeById(typeId); const objectType = this.world.getObjectTypeById(typeId);
if (!objectType) if (!objectType)
return; return;
const world = this.world.data; const world = this.world.data;
const now = Date.now(); const now = Date.now();
for (const obj of Object.values(this.scene.objects)) { for (const obj of Object.values(this.scene.objects)) {
if (obj.typeId !== typeId) if (obj.typeId !== typeId)
continue; continue;
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes); obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes); obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
obj.cache.boundingBox = computeBoundingBox(objectType); obj.cache.boundingBox = computeBoundingBox(objectType);
+1 -3
View File
@@ -1,6 +1,4 @@
import type { Vector3Tuple } from "three"; export type V3 = [number, number, number];
export type V3 = Vector3Tuple;
export type R3 = V3; // Euler angles in radians, order XYZ export type R3 = V3; // Euler angles in radians, order XYZ
+3 -3
View File
@@ -1,7 +1,7 @@
import type { RuntimeGameScene } from "./scene"; import type { GameScene, RuntimeGameScene } from "./scene";
export type Game = { export type Game<TScene extends GameScene = RuntimeGameScene> = {
paused: boolean; paused: boolean;
time: number; time: number;
scene: RuntimeGameScene; scene: TScene;
} }
+1
View File
@@ -18,6 +18,7 @@ export type ObjectInstance = {
position: V3; position: V3;
rotation: R3; rotation: R3;
scale: V3; scale: V3;
visible: boolean;
} }
export type GameObjectData = { export type GameObjectData = {
+5 -13
View File
@@ -1,18 +1,10 @@
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object"; import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
export type Scene = { export type Scene<TObject extends ObjectInstance> = {
objects: Record<string, ObjectInstance>; objects: Record<string, TObject>;
playerObjectId: string | undefined; playerObjectId: string | undefined;
} }
export type RuntimeScene = Scene & { export type GameScene = Scene<GameObjectInstance>;
objects: Record<string, RuntimeObjectInstance>; export type RuntimeScene = Scene<RuntimeObjectInstance>;
} export type RuntimeGameScene = Scene<RuntimeGameObjectInstance>;
export type GameScene = Scene & {
objects: Record<string, GameObjectInstance>;
}
export type RuntimeGameScene = Scene & {
objects: Record<string, RuntimeGameObjectInstance>;
}
+9
View File
@@ -0,0 +1,9 @@
import type { V3 } from "../types";
export function formatNumber(value: number, digits: number = 2) {
return value.toFixed(digits);
}
export function formatV3(value: V3, digits: number = 2) {
return `[${formatNumber(value[0], digits)},${formatNumber(value[1], digits)},${formatNumber(value[2], digits)}]`;
}
-133
View File
@@ -1,133 +0,0 @@
import { BufferGeometry, Float32BufferAttribute } from 'three';
import type { V3 } from '../../types';
// tangent axes for each main axis
const U_OF = [1, 0, 0] as const;
const V_OF = [2, 2, 1] as const;
/**
* Greedy meshing: merges face-adjacent same-material voxels into minimal quads.
* A 50×1×50 flat layer produces 6 quads (12 triangles), not 50×50×6×2.
*
* Only face-adjacency counts — diagonal neighbours do not merge.
*/
export function greedyMesh(positions: V3[]): BufferGeometry {
if (!positions.length)
return new BufferGeometry();
const occ = new Set<string>();
let x0 = Infinity, y0 = Infinity, z0 = Infinity;
let x1 = -Infinity, y1 = -Infinity, z1 = -Infinity;
for (const [x, y, z] of positions) {
occ.add(`${x},${y},${z}`);
if (x < x0)
x0 = x;
if (x > x1)
x1 = x;
if (y < y0)
y0 = y;
if (y > y1)
y1 = y;
if (z < z0)
z0 = z;
if (z > z1)
z1 = z;
}
const has = (x: number, y: number, z: number) => occ.has(`${x},${y},${z}`);
const lo = [x0, y0, z0];
const hi = [x1, y1, z1];
const verts: number[] = [];
const inds: number[] = [];
for (let axis = 0; axis < 3; axis++) {
const u = U_OF[axis];
const v = V_OF[axis];
const dLen = hi[axis] - lo[axis] + 1;
const uLen = hi[u] - lo[u] + 1;
const vLen = hi[v] - lo[v] + 1;
const mask = new Uint8Array(uLen * vLen);
const done = new Uint8Array(uLen * vLen);
for (let dir = -1; dir <= 1; dir += 2) {
// For corner order (u0,v0)(u1,v0)(u1,v1)(u0,v1),
// cross product = +e_axis when axis≠Y, −e_Y when axis=Y.
// flip reverses winding to point outward for the given dir.
const flip = axis === 1 ? dir > 0 : dir < 0;
for (let di = 0; di < dLen; di++) {
const D = lo[axis] + di;
mask.fill(0);
done.fill(0);
for (let vi = 0; vi < vLen; vi++) {
for (let ui = 0; ui < uLen; ui++) {
const p: [number, number, number] = [0, 0, 0];
p[axis] = D;
p[u] = lo[u] + ui;
p[v] = lo[v] + vi;
if (!has(p[0], p[1], p[2]))
continue;
const np: [number, number, number] = [p[0], p[1], p[2]];
np[axis] += dir;
if (!has(np[0], np[1], np[2]))
mask[ui + vi * uLen] = 1;
}
}
for (let vi = 0; vi < vLen; vi++) {
for (let ui = 0; ui < uLen; ui++) {
if (!mask[ui + vi * uLen] || done[ui + vi * uLen])
continue;
let wu = 1;
while (ui + wu < uLen && mask[ui + wu + vi * uLen] && !done[ui + wu + vi * uLen])
wu++;
let wv = 1;
expand: while (vi + wv < vLen) {
for (let k = 0; k < wu; k++)
if (!mask[ui + k + (vi + wv) * uLen] || done[ui + k + (vi + wv) * uLen])
break expand;
wv++;
}
for (let dv = 0; dv < wv; dv++)
for (let du = 0; du < wu; du++)
done[ui + du + (vi + dv) * uLen] = 1;
const dp = D + (dir > 0 ? 0.5 : -0.5);
const uA = lo[u] + ui - 0.5;
const uB = uA + wu;
const vA = lo[v] + vi - 0.5;
const vB = vA + wv;
const base = verts.length / 3;
for (const [cu, cv] of [[uA, vA], [uB, vA], [uB, vB], [uA, vB]] as const) {
const pt: [number, number, number] = [0, 0, 0];
pt[axis] = dp;
pt[u] = cu;
pt[v] = cv;
verts.push(pt[0], pt[1], pt[2]);
}
if (flip) {
inds.push(base, base + 2, base + 1, base, base + 3, base + 2);
} else {
inds.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
}
}
}
}
const geo = new BufferGeometry();
geo.setAttribute('position', new Float32BufferAttribute(verts, 3));
geo.setIndex(inds);
geo.computeVertexNormals();
return geo;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function getObjectVoxelGroups(
if (!map.has(key)) if (!map.has(key))
map.set(key, { id: key, color, opacity, positions: [] }); map.set(key, { id: key, color, opacity, positions: [] });
const p = v.position; const p = v.position;
map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]); map.get(key)!.positions.push(p);
} }
return [...map.values()]; return [...map.values()];
} }
+115
View File
@@ -0,0 +1,115 @@
import * as PolygonClipping from 'polygon-clipping';
import { BufferGeometry, Float32BufferAttribute } from "three";
import type { V3 } from "../../types";
import earcut from 'earcut';
type V2 = [number, number];
export type Loop = V3[]; // closed, halfedges are v[idx]=>v[(idx+1)%size]
function triangulatePolygon(polygon: PolygonClipping.Polygon): V2[] {
// polygon-clipping closes rings (last === first), earcut expects open rings
const outerRing = polygon[0].slice(0, -1);
const holes = polygon.slice(1).map(h => h.slice(0, -1));
const coords: number[] = outerRing.flat();
const holeIndices: number[] = [];
for (const hole of holes) {
holeIndices.push(coords.length / 2);
coords.push(...hole.flat());
}
const indices = earcut(coords, holeIndices.length ? holeIndices : undefined, 2);
const result: V2[] = [];
for (const idx of indices) {
result.push([coords[idx * 2], coords[idx * 2 + 1]]);
}
return result;
}
export function buildVoxelMesh(voxels: V3[]): BufferGeometry {
const vertices: V3[] = [];
const normals: V3[] = [];
const occ = new Set(voxels.map(([x, y, z]) => `${x},${y},${z}`));
const has = (pos: V3) => occ.has(`${pos[0]},${pos[1]},${pos[2]}`);
const isVisible = (pos: V3, normal: V3) => !has([pos[0] + normal[0], pos[1] + normal[1], pos[2] + normal[2]]);
let min = [Infinity, Infinity, Infinity];
let max = [-Infinity, -Infinity, -Infinity];
for (const v of voxels) {
min = min.map((c, idx) => Math.min(c, v[idx]));
max = max.map((c, idx) => Math.max(c, v[idx]));
}
for (let axis = 0; axis < 3; axis++) { // x, y, z
// x: uv=yz, y: uv=zx, z: uv=xy
const uAxis = (axis + 1) % 3;
const vAxis = (axis + 2) % 3;
const uSize = max[uAxis] - min[uAxis] + 1;
const vSize = max[vAxis] - min[vAxis] + 1;
const slicePixels = new Uint8Array(uSize * vSize);
for (const dir of [-1, +1]) {
const normal = [0, 0, 0] as V3;
normal[axis] = dir; // [-1, 0, 0], [+1, 0, 0], [0, -1, 0], [0, +1, 0], [0, 0, -1], [0, 0, +1]
for (let d = min[axis]; d <= max[axis]; d++) { //slice by slice
slicePixels.fill(0);
const sliceOrigin = [0, 0, 0] as V3;
sliceOrigin[axis] = d;
const faceOrigin = [...sliceOrigin];
if (dir > 0)
faceOrigin[axis]++;
for (let v = 0; v < vSize; v++)
for (let u = 0; u < uSize; u++) {
const pos = [...sliceOrigin] as V3;
pos[uAxis] = u + min[uAxis];
pos[vAxis] = v + min[vAxis];
// if (has(pos))
if (has(pos) && isVisible(pos, normal)) // voxel is not empty and face is not occluded by ajacent voxel
slicePixels[v * uSize + u] = 1;
}
const pixelPolygons = Array.from(slicePixels)
.map((pixel, idx) => pixel ? idx : -1) // only indices of 1 pixels
.filter((idx) => idx >= 0)
.map((idx) => [idx % uSize, Math.floor(idx / uSize)]) // to [x,y]
.map(([x, y]) => [[[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1]]] as PolygonClipping.Polygon); // polygons
const slicePolygons = PolygonClipping.union(pixelPolygons);
slicePolygons.forEach((polygon) => {
const verts2d = triangulatePolygon(polygon);
if (dir < 0)
verts2d.reverse();
const verts3d = verts2d
.map((v) => {
const v3d = [...faceOrigin] as V3;
v3d[uAxis] = v[0] + min[uAxis];
v3d[vAxis] = v[1] + min[vAxis];
return v3d;
});
const norms = new Array(verts3d.length).fill(normal) as V3[];
vertices.push(...verts3d);
normals.push(...norms);
});
}
}
}
const g = new BufferGeometry();
g.setAttribute('position', new Float32BufferAttribute(vertices.flat(1), 3));
g.setAttribute('normal', new Float32BufferAttribute(normals.flat(1), 3));
return g;
}
+2
View File
@@ -2,6 +2,8 @@ import { v4 as uuid } from 'uuid';
import type { V3 } from '../types'; import type { V3 } from '../types';
import { type Vector3Object } from '@react-three/rapier'; import { type Vector3Object } from '@react-three/rapier';
export * from './format';
export function clone<T>(obj: T): T { export function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import { getObjectVoxelGroups } from "../graphics/voxelGroup"; import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance, V3, World } from "../../types"; import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh"; import { buildObjectTrimesh } from "../graphics/mesh";
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } { export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {