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
@@ -83,6 +83,8 @@ return;
return () => {
canvas.removeEventListener('click', onClick);
document.removeEventListener('mousemove', onMouseMove);
if (document.pointerLockElement === canvas)
document.exitPointerLock();
};
}, [gl]);
+7 -5
View File
@@ -6,6 +6,7 @@ import type { RuntimeObjectInstance } from "../types";
import { debugLabelsStore } from "../state/debugLabelsStore";
import { state } from "../state";
import { observer } from "mobx-react-lite";
import { formatV3 } from "../utils";
export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) {
const groupRef = useRef<THREE.Group>(null);
@@ -13,8 +14,8 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
const rb = obj.rb();
const texts = [
`${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`,
`position: ${obj.position}`,
`rotation: ${obj.rotation}`,
`position: ${formatV3(obj.position)}`,
`rotation: ${formatV3(obj.rotation)}`,
...Object.entries(debugLabelsStore.getAll(obj.id))
.map(([k, v]) => `${k}: ${v}`),
];
@@ -51,13 +52,14 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
<div style={{
background: 'rgba(0,0,0,0.65)',
color: '#fff',
padding: '2px 7px',
borderRadius: 4,
fontSize: 12,
padding: 10,
borderRadius: 10,
fontSize: 10,
fontFamily: 'monospace',
whiteSpace: 'pre',
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.3,
}}>
{text}
</div>
+2 -1
View File
@@ -4,7 +4,7 @@ import { debugLabelsStore } from "../state/debugLabelsStore";
import { DebugLabel } from "./DebugLabel";
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(
() => {
return () => { debugLabelsStore.clear(); };
@@ -15,6 +15,7 @@ export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeSce
return <>
{
Object.entries(scene.objects)
.filter(([_, obj]) => editMode || obj.visible)
.map(([objectId, obj]) => {
return <DebugLabel key={objectId} obj={obj} />;
})
@@ -7,6 +7,7 @@ import { PlayerObjectView } from "./CharacterView";
export type GameObjectViewProps = {
object: RuntimeGameObjectInstance;
isPlayer: boolean;
isSceneEditor?: boolean;
}
export const GameObjectView = observer(function (props: GameObjectViewProps) {
@@ -25,5 +26,6 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) {
{...props}
objectType={objectType}
onClick={handleClick}
isSceneEditor={props.isSceneEditor}
/>;
});
@@ -78,7 +78,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
function handleTransformChange() {
const group = dataRef.current?.group;
if (!group) return;
if (!group)
return;
const pos = group.getWorldPosition(new Vector3());
const quat = group.getWorldQuaternion(new Quaternion());
const euler = new Euler().setFromQuaternion(quat);
@@ -90,7 +91,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
function handleTransformEnd() {
const group = dataRef.current?.group;
if (!group) return;
if (!group)
return;
const pos = group.getWorldPosition(new Vector3());
const quat = group.getWorldQuaternion(new Quaternion());
const euler = new Euler().setFromQuaternion(quat);
@@ -113,8 +115,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
<ObjectViewInternal
ref={dataRef}
object={object}
isEditor
editMode
isPlayer={isPlayer}
isSceneEditor
objectType={objectType}
onClick={handleClick}
/>
+40 -10
View File
@@ -1,22 +1,24 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import type { VoxelGroup } from "../types/runtime/voxelGroup";
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 { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils";
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 geo = useMemo(() => greedyMesh(vg.positions), [vg]);
const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) {
const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]);
useEffect(() => () => { geo.dispose(); }, [geo]);
const op = vg.opacity * opacity;
return (
<mesh geometry={geo}>
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
<mesh geometry={geo} userData={{ baseOpacity: vg.opacity }}>
<meshStandardMaterial color={vg.color} opacity={op} transparent={op < 1} />
</mesh>
);
});
@@ -29,8 +31,9 @@ export type ObjectViewInternalHandle = {
type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType;
isEditor?: boolean;
editMode?: boolean;
isPlayer?: boolean;
isSceneEditor?: boolean;
onClick?: () => void;
ref?: Ref<ObjectViewInternalHandle>;
}
@@ -72,6 +75,29 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
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(
() => {
runInAction(() => { gameObj.rb = () => rbRef.current; });
@@ -97,7 +123,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
gravityScale={object.gravityScale}
position={object.position}
rotation={object.rotation}
onCollisionEnter={props.isEditor ? undefined : (payload) => {
onCollisionEnter={props.editMode ? undefined : (payload) => {
const game = state.game;
if (!game)
return;
@@ -114,7 +140,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
runInAction(() => {
object.position = data.position;
object.rotation = data.rotation;
if (!props.isEditor) {
if (!props.editMode) {
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
}
@@ -130,7 +156,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
>
{
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]]} />}
+10 -4
View File
@@ -4,7 +4,7 @@ import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
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 { Physics } from '@react-three/rapier';
@@ -49,17 +49,23 @@ export const SceneEditorView = observer(function () {
onStart={handleStart}
onEnd={handleEnd}
makeDefault
mouseButtons={{
LEFT: MOUSE.PAN,
MIDDLE: MOUSE.ROTATE,
RIGHT: MOUSE.ROTATE,
}}
rotateSpeed={0.2}
enableDamping={false}
/>
<CameraSync camera={state.worldEditor.camera} />
<Grid
{/* <Grid
sectionThickness={0.5}
cellThickness={0}
sectionColor="white"
infiniteGrid
/>
/> */}
<Physics colliders={false} paused={true}>
<SceneView scene={state.worldEditor.scene} editMode debugOverlay />
<SceneView scene={state.worldEditor.scene} isEditor debugOverlay />
</Physics>
</Suspense>
</>);
+8 -7
View File
@@ -6,7 +6,7 @@ import { DebugLabelsView } from "./DebugLabelsView";
type SceneViewProps = {
scene: RuntimeScene,
editMode?: boolean;
isEditor?: boolean;
debugOverlay?: boolean;
}
@@ -15,12 +15,13 @@ export const SceneView = observer(function (props: SceneViewProps) {
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
{
Object.values(props.scene.objects).map((obj) => (
!props.editMode
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} />
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
))
Object.values(props.scene.objects)
.map((obj) => (
!props.isEditor
? <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 { KeyboardControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
@@ -134,7 +134,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
<div style={{ flex: 1, position: 'relative' }}>
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
<VoxelEditorScene
voxels={objectType.voxels}
voxels={objectType.voxels.slice()}
mode={mode}
color={color}
typeId="stone"
@@ -1,7 +1,9 @@
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 { Ray } from 'three';
import type { Voxel, V3 } from '../../types';
import { buildVoxelMesh } from '../../utils/graphics/voxelMesh';
type Props = {
voxels: Voxel[];
@@ -13,42 +15,110 @@ type Props = {
};
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;
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) {
const [ghost, setGhost] = useState<V3 | null>(null);
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
if (e.delta > DRAG_THRESHOLD)
return;
const colorGroups = useMemo(() => {
const map = new Map<string, V3[]>();
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();
const hit = raycastVoxels(e.ray, voxels);
if (!hit)
return;
if (mode === 'remove') {
onRemove(voxel.position);
setGhost(null);
setGhost(hit.position);
} else {
if (!e.face)
return;
const { x, y, z } = e.face.normal;
const pos = adjPosition(voxel.position, x, y, z);
onAdd({ typeId, position: pos, color });
const [x, y, z] = hit.position;
const [nx, ny, nz] = hit.normal;
setGhost([x + nx, y + ny, z + nz]);
}
}
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
function handleMeshClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > DRAG_THRESHOLD)
return;
e.stopPropagation();
const hit = raycastVoxels(e.ray, voxels);
if (!hit)
return;
if (mode === 'remove') {
setGhost(voxel.position);
} else {
onRemove(hit.position);
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')
return;
e.stopPropagation();
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
onAdd({ typeId, position: pos, color });
onAdd({ typeId, position: [Math.floor(e.point.x), 0, Math.floor(e.point.z)], color });
}
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)]);
}
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 (
<>
<ambientLight intensity={0.7} />
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
<OrbitControls makeDefault />
{/* Floor plane */}
<mesh
rotation={[-Math.PI / 2, 0, 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]} />
{/* Voxels */}
{voxels.map((v, i) => {
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
return (
<mesh
key={i}
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
onClick={(e) => handleVoxelClick(e, v)}
onPointerMove={(e) => handleVoxelMove(e, v)}
onPointerLeave={() => setGhost(null)}
>
<boxGeometry />
<meshStandardMaterial
color={v.color ?? '#888888'}
opacity={isHovered ? 0.4 : 1}
transparent={isHovered}
/>
</mesh>
);
})}
{colorGroups.map(({ color: groupColor, positions }) => (
<MergedVoxelMesh
key={groupColor}
color={groupColor}
positions={positions}
onPointerMove={handleMeshMove}
onClick={handleMeshClick}
onPointerLeave={() => setGhost(null)}
/>
))}
{/* Ghost voxel preview */}
{ghost && mode === 'add' && !ghostOccupied && (
<mesh
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
raycast={() => null}
>
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
<boxGeometry />
<meshStandardMaterial color={color} opacity={0.45} transparent />
</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>
)}
</>
);
}