Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
110594483c | ||
|
|
619f54c49c | ||
|
|
bc9c275776 | ||
|
|
36dcdffff2 | ||
|
|
f6680161d7 | ||
|
|
2e28c9c3d5 | ||
|
|
b0503669b0 | ||
|
|
224ae60cc8 | ||
|
|
beaa656026 | ||
|
|
793a7c9da3 | ||
|
|
bc98e23f6a | ||
|
|
de292361a2 | ||
|
|
f071bbe9f7 | ||
|
|
ff5d1d52ef | ||
|
|
cad617000d | ||
|
|
e7897e8bb1 | ||
|
|
fcb8c2a78b | ||
|
|
1ba60a5dc3 | ||
|
|
e2e9a357de | ||
|
|
d224c033ca | ||
|
|
5f2d86368b | ||
|
|
bf7f803a99 | ||
|
|
21c48e44ee | ||
|
|
0b7cdd9f05 | ||
|
|
a60e01e4d1 | ||
|
|
98e387c490 |
@@ -0,0 +1,7 @@
|
|||||||
|
# Скопируй в .env и заполни
|
||||||
|
SERVER_PORT=3000
|
||||||
|
CLIENT_PORT=5000
|
||||||
|
|
||||||
|
# Добавь свои переменные ниже
|
||||||
|
# DATABASE_URL=
|
||||||
|
# JWT_SECRET=
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.aggtaa.com
|
||||||
|
IMAGE_PREFIX: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── BUILD SERVER ──────────────────────────────────────────────────────────────
|
||||||
|
build-server:
|
||||||
|
runs-on: docker1
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: sh
|
||||||
|
working-directory: /workspace
|
||||||
|
steps:
|
||||||
|
- name: Check paths
|
||||||
|
run: |
|
||||||
|
ls -la /data/tools/ || echo "no /data/tools"
|
||||||
|
mount | grep overlay || echo "no overlay"
|
||||||
|
ls /bin/
|
||||||
|
ls /usr/bin/ | grep -E "bash|sh"
|
||||||
|
- name: Check docker
|
||||||
|
run: |
|
||||||
|
find /usr -name "docker-buildx" 2>/dev/null || echo "not found in /usr"
|
||||||
|
find /root -name "docker-buildx" 2>/dev/null || echo "not found in /root"
|
||||||
|
ls /usr/libexec/ 2>/dev/null || echo "no /usr/libexec"
|
||||||
|
- name: Checkout
|
||||||
|
working-directory: /
|
||||||
|
run: |
|
||||||
|
mkdir -p /workspace
|
||||||
|
if [ -d /workspace/.git ]; then
|
||||||
|
cd /workspace && git fetch origin && git reset --hard origin/${{ gitea.ref_name }}
|
||||||
|
else
|
||||||
|
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git /workspace
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build & push server
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||||
|
|
||||||
|
/data/tools/docker-buildx build \
|
||||||
|
--cache-from type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:cache \
|
||||||
|
--cache-to type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:cache,mode=max \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:latest \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:${{ gitea.sha }} \
|
||||||
|
--file server.Dockerfile \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# ── BUILD WEBCLIENT ───────────────────────────────────────────────────────────
|
||||||
|
build-webclient:
|
||||||
|
runs-on: docker1
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: /workspace
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
working-directory: /
|
||||||
|
run: |
|
||||||
|
mkdir -p /workspace
|
||||||
|
if [ -d /workspace/.git ]; then
|
||||||
|
cd /workspace && git fetch origin && git reset --hard origin/${{ gitea.ref_name }}
|
||||||
|
else
|
||||||
|
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git /workspace
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build & push webclient
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
|
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||||
|
|
||||||
|
/data/tools/docker-buildx build \
|
||||||
|
--cache-from type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:cache \
|
||||||
|
--cache-to type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:cache,mode=max \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:latest \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:${{ gitea.sha }} \
|
||||||
|
--file webclient.Dockerfile \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# ── DEPLOY ────────────────────────────────────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
runs-on: docker1
|
||||||
|
needs: [build-server, build-webclient]
|
||||||
|
if: always() && !failure() && !cancelled()
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
mkdir -p "/app-cache/${{ gitea.repository }}"
|
||||||
|
cd "/app-cache/${{ gitea.repository }}"
|
||||||
|
if [ ! -d .git ]; then
|
||||||
|
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git .
|
||||||
|
else
|
||||||
|
git pull origin main
|
||||||
|
fi
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: server.Dockerfile
|
||||||
|
cache_from:
|
||||||
|
- ${CI_REGISTRY_IMAGE:-blockly3d}/server:cache
|
||||||
|
image: ${CI_REGISTRY_IMAGE:-blockly3d}/server:${CI_COMMIT_SHA:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- blockly3d
|
||||||
|
|
||||||
|
webclient:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: webclient.Dockerfile
|
||||||
|
cache_from:
|
||||||
|
- ${CI_REGISTRY_IMAGE:-blockly3d}/webclient:cache
|
||||||
|
image: ${CI_REGISTRY_IMAGE:-blockly3d}/webclient:${CI_COMMIT_SHA:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${CLIENT_PORT:-5000}:5000"
|
||||||
|
networks:
|
||||||
|
- blockly3d
|
||||||
|
|
||||||
|
networks:
|
||||||
|
blockly3d:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:22.22.3-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# ── deps ──────────────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем только манифесты — слой кешируется пока lock не менялся
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --filter server...
|
||||||
|
|
||||||
|
# ── build ─────────────────────────────────────────────────────────────────────
|
||||||
|
FROM deps AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server ./server
|
||||||
|
|
||||||
|
RUN pnpm --filter server build
|
||||||
|
|
||||||
|
# ── runtime ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:22.22.3-alpine AS runner
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --filter server... --prod
|
||||||
|
|
||||||
|
COPY --from=builder /app/server/dist ./server/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM node:22.22.3-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# ── deps ──────────────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY webclient/package.json ./webclient/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --filter webclient...
|
||||||
|
|
||||||
|
# ── build ─────────────────────────────────────────────────────────────────────
|
||||||
|
FROM deps AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY webclient ./webclient
|
||||||
|
|
||||||
|
RUN pnpm --filter webclient build
|
||||||
|
|
||||||
|
# ── runtime ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:22.22.3-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
COPY --from=builder /app/webclient/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["serve", "-s", "dist", "-l", "5000"]
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
.editor-layout {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.counter {
|
.counter {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
|
|||||||
@@ -92,11 +92,11 @@ function HomePage() {
|
|||||||
|
|
||||||
function EditorLayout() {
|
function EditorLayout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="editor-layout">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<LayeredThreeView />
|
<LayeredThreeView />
|
||||||
<Panels />
|
<Panels />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.overlay-panels {
|
.overlay-panels {
|
||||||
z-index: 15000;
|
z-index: 9000;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -5,48 +5,31 @@ import { observer } from 'mobx-react-lite';
|
|||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import { SceneView } from './SceneView';
|
import { SceneView } from './SceneView';
|
||||||
import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three';
|
import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three';
|
||||||
import { CameraSync } from './tools/CameraSync';
|
import { useSyncCamera } from '../utils/hooks/useSyncCamera';
|
||||||
import { Physics } from '@react-three/rapier';
|
import { Physics } from '@react-three/rapier';
|
||||||
|
|
||||||
|
|
||||||
export const SceneEditorView = observer(function () {
|
export const SceneEditorView = observer(function () {
|
||||||
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
|
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
|
||||||
|
|
||||||
const cameraAtStart = useRef<string>('');
|
useSyncCamera(() => state.view.camera.camera);
|
||||||
|
|
||||||
function cameraSnapshot(camera: OrthographicCamera | PerspectiveCamera): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
position: camera.position,
|
|
||||||
rotation: camera.rotation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStart = () => {
|
|
||||||
const controls = controlsRef.current;
|
|
||||||
if (!controls)
|
|
||||||
return;
|
|
||||||
cameraAtStart.current = cameraSnapshot(controls.object);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
const controls = controlsRef.current;
|
const controls = controlsRef.current;
|
||||||
if (!controls)
|
if (!controls)
|
||||||
return;
|
return;
|
||||||
const snapshot = cameraSnapshot(controls.object);
|
|
||||||
if (snapshot === cameraAtStart.current)
|
|
||||||
return;
|
|
||||||
const [x, y, z] = controls.object.rotation.toArray();
|
const [x, y, z] = controls.object.rotation.toArray();
|
||||||
state.worldEditor.setCamera({
|
state.view.camera.camera = {
|
||||||
position: controls.object.position.toArray(),
|
position: controls.object.position.toArray(),
|
||||||
look: [x, y, z],
|
look: [x, y, z],
|
||||||
});
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (<>
|
return (<>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
ref={controlsRef}
|
ref={controlsRef}
|
||||||
onStart={handleStart}
|
|
||||||
onEnd={handleEnd}
|
onEnd={handleEnd}
|
||||||
makeDefault
|
makeDefault
|
||||||
mouseButtons={{
|
mouseButtons={{
|
||||||
@@ -57,7 +40,6 @@ export const SceneEditorView = observer(function () {
|
|||||||
rotateSpeed={0.2}
|
rotateSpeed={0.2}
|
||||||
enableDamping={false}
|
enableDamping={false}
|
||||||
/>
|
/>
|
||||||
<CameraSync camera={state.worldEditor.camera} />
|
|
||||||
{/* <Grid
|
{/* <Grid
|
||||||
sectionThickness={0.5}
|
sectionThickness={0.5}
|
||||||
cellThickness={0}
|
cellThickness={0}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { measuredRenderTime } from "../../utils/layerTimings";
|
||||||
|
|
||||||
export const DebugLabelsLayer = observer(function () {
|
export const DebugLabelsLayer = observer(measuredRenderTime("DebugLabels", function () {
|
||||||
return null; //TODO
|
return null; //TODO
|
||||||
});
|
}));
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useFrame } from "@react-three/fiber";
|
|
||||||
import type { Pos3 } from "../../types";
|
|
||||||
import { Euler, Quaternion, Vector3 } from "three";
|
|
||||||
|
|
||||||
const _q = new Quaternion();
|
|
||||||
const _e = new Euler(0, 0, 0, 'YXZ');
|
|
||||||
const _offset = new Vector3();
|
|
||||||
const _charPos = new Vector3();
|
|
||||||
const _lookAt = new Vector3();
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
camera: Pos3;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CameraLayer({ camera: cameraPos }: Props) {
|
|
||||||
|
|
||||||
useFrame(({ camera }) => {
|
|
||||||
// const t = rb.translation();
|
|
||||||
// _charPos.set(t.x, t.y, t.z);
|
|
||||||
// _offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
|
|
||||||
// camera.position.copy(_charPos).add(_offset);
|
|
||||||
// _lookAt.set(t.x, t.y + lookAtY, t.z);
|
|
||||||
camera.lookAt(_lookAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ObjectGeomertiesLayer } from "./ObjectGeomertiesLayer";
|
import { ObjectGeometriesLayer } from "./ObjectGeometriesLayer";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { Perf } from "r3f-perf";
|
import { SceneEditorCameraLayer } from "./SceneEditor/SceneEditorGameraLayer";
|
||||||
import { state } from "../../state";
|
import { SceneEditorObjectSelectionLayer } from "./SceneEditor/SceneEditorObjectSelectionLayer";
|
||||||
import { CameraLayer } from "./GameraLayer";
|
import { LightsLayer } from "./LighsLayer";
|
||||||
|
import { StatsLayer } from './StatsLayer';
|
||||||
|
import { SceneEditorObjectGizmosLayer } from "./SceneEditor/SceneEditorObjectGizmosLayer";
|
||||||
|
|
||||||
export const LayeredThreeView = observer(function () {
|
export const LayeredThreeView = observer(function () {
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Canvas
|
<Canvas>
|
||||||
// camera={state.world.character.camera}
|
<LightsLayer />
|
||||||
>
|
{/* <Perf position="bottom-right" /> */}
|
||||||
<ambientLight intensity={0.5} />
|
<SceneEditorCameraLayer />
|
||||||
<directionalLight position={[5, 5, 5]} intensity={1} />
|
<ObjectGeometriesLayer>
|
||||||
|
<SceneEditorObjectSelectionLayer />
|
||||||
<Perf position="bottom-right" />
|
<SceneEditorObjectGizmosLayer />
|
||||||
|
</ObjectGeometriesLayer>
|
||||||
<CameraLayer camera={state.worldEditor.camera} />
|
<StatsLayer />
|
||||||
<ObjectGeomertiesLayer />
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</>;
|
</>;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { measuredRenderTime } from "../../utils/layerTimings";
|
||||||
|
|
||||||
|
export const LightsLayer = observer(measuredRenderTime("Lights", function () {
|
||||||
|
return <>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<directionalLight position={[5, 5, 5]} intensity={1} />
|
||||||
|
</>;
|
||||||
|
}));
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useThree, useFrame } from "@react-three/fiber";
|
|
||||||
import { ObjectGeometryList } from "../../rendering/ObjectGeometryList";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { state } from "../../state";
|
|
||||||
|
|
||||||
export const ObjectGeomertiesLayer = observer(function () {
|
|
||||||
|
|
||||||
const geometries = state.view.objectGeometries;
|
|
||||||
|
|
||||||
const threeScene = useThree(s => s.scene);
|
|
||||||
const list = useRef<ObjectGeometryList | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const l = new ObjectGeometryList(threeScene, geometries);
|
|
||||||
list.current = l;
|
|
||||||
return () => {
|
|
||||||
l.dispose();
|
|
||||||
list.current = null;
|
|
||||||
};
|
|
||||||
}, [threeScene, geometries]);
|
|
||||||
|
|
||||||
useFrame(() => list.current?.tick());
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import { ObjectGeometryList } from "../../rendering/ObjectGeometryList";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { state } from "../../state";
|
||||||
|
import { useLayerFrame } from "../../utils/hooks/useLayerFrame";
|
||||||
|
import { measuredRenderTime } from "../../utils/layerTimings";
|
||||||
|
|
||||||
|
export const ObjectGeometryListContext = createContext<React.RefObject<ObjectGeometryList | null>>(
|
||||||
|
{ current: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useObjectGeometryList() {
|
||||||
|
return useContext(ObjectGeometryListContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObjectGeometriesLayer = observer(measuredRenderTime("ObjectGeometries", function ({ children }: { children?: React.ReactNode }) {
|
||||||
|
const typeGeometries = state.view.objectTypeGeometries;
|
||||||
|
|
||||||
|
const threeScene = useThree(s => s.scene);
|
||||||
|
const list = useRef<ObjectGeometryList | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const l = new ObjectGeometryList(threeScene, typeGeometries);
|
||||||
|
list.current = l;
|
||||||
|
return () => {
|
||||||
|
l.dispose();
|
||||||
|
list.current = null;
|
||||||
|
};
|
||||||
|
}, [threeScene, typeGeometries]);
|
||||||
|
|
||||||
|
useLayerFrame("ObjectGeometries", () => list.current?.tick());
|
||||||
|
|
||||||
|
return <ObjectGeometryListContext.Provider value={list}>
|
||||||
|
{children}
|
||||||
|
</ObjectGeometryListContext.Provider>;
|
||||||
|
}));
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { state } from "../../../state";
|
||||||
|
import type { ThreeEvent } from "@react-three/fiber";
|
||||||
|
import type { ObjectGeometryViewItem } from "../../../state/viewLayers";
|
||||||
|
|
||||||
|
export function ObjectClickTarget({ item }: { item: ObjectGeometryViewItem }) {
|
||||||
|
const obj = item.object;
|
||||||
|
const bb = item.objectTypeGeometry.boundingBox;
|
||||||
|
|
||||||
|
const size = useMemo(() => {
|
||||||
|
if (!bb)
|
||||||
|
return [1, 1, 1] as const;
|
||||||
|
|
||||||
|
return [bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]] as const;
|
||||||
|
}, [bb]);
|
||||||
|
|
||||||
|
const center = useMemo(() => {
|
||||||
|
if (!bb)
|
||||||
|
return [0.5, 0.5, 0.5] as const;
|
||||||
|
|
||||||
|
return [
|
||||||
|
(bb.min[0] + bb.max[0]) / 2,
|
||||||
|
(bb.min[1] + bb.max[1]) / 2,
|
||||||
|
(bb.min[2] + bb.max[2]) / 2,
|
||||||
|
] as const;
|
||||||
|
}, [bb]);
|
||||||
|
|
||||||
|
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
state.worldEditor.setSelectedObject({ id: obj.id, editMode: "translate" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return <mesh
|
||||||
|
position={obj.position}
|
||||||
|
rotation={obj.rotation}
|
||||||
|
scale={obj.scale}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<mesh position={center}>
|
||||||
|
<boxGeometry args={size} />
|
||||||
|
<meshBasicMaterial visible={false} />
|
||||||
|
</mesh>
|
||||||
|
</mesh>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { OrbitControls } from "@react-three/drei";
|
||||||
|
import { state } from "../../../state";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useSyncCamera } from "../../../utils/hooks/useSyncCamera";
|
||||||
|
import { MOUSE } from "three";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import type { V3 } from "../../../types";
|
||||||
|
import { measuredRenderTime } from "../../../utils/layerTimings";
|
||||||
|
|
||||||
|
export const SceneEditorCameraLayer = observer(measuredRenderTime("Camera", function () {
|
||||||
|
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
|
||||||
|
|
||||||
|
useSyncCamera(() => state.view.camera?.camera);
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
const controls = controlsRef.current;
|
||||||
|
const camera = state.view.camera;
|
||||||
|
if (!controls || !camera)
|
||||||
|
return;
|
||||||
|
|
||||||
|
camera.camera = {
|
||||||
|
position: controls.object.position.toArray(),
|
||||||
|
look: controls.object.rotation.toArray().slice(0, 3) as V3,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state.view.camera)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return <OrbitControls
|
||||||
|
ref={controlsRef}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
makeDefault
|
||||||
|
mouseButtons={{
|
||||||
|
LEFT: MOUSE.ROTATE,
|
||||||
|
MIDDLE: MOUSE.PAN,
|
||||||
|
RIGHT: MOUSE.PAN,
|
||||||
|
}}
|
||||||
|
rotateSpeed={0.2}
|
||||||
|
enableDamping={false}
|
||||||
|
/>;
|
||||||
|
}));
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { TransformControls } from "@react-three/drei";
|
||||||
|
import { state } from "../../../state";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { measuredRenderTime } from "../../../utils/layerTimings";
|
||||||
|
import type { V3, R3 } from "../../../types";
|
||||||
|
|
||||||
|
export const SceneEditorObjectGizmosLayer = observer(measuredRenderTime(
|
||||||
|
"ObjectGizmos",
|
||||||
|
function () {
|
||||||
|
const selection = state.view.selectedObject;
|
||||||
|
|
||||||
|
const onObjectChange = useCallback((e?: THREE.Event) => {
|
||||||
|
if (!selection || !e)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const controlled = (e.target as { object?: THREE.Object3D }).object;
|
||||||
|
if (!controlled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
selection.geometry.setObjectTransform(
|
||||||
|
controlled.position.toArray() as V3,
|
||||||
|
controlled.rotation.toArray().slice(0, 3) as R3,
|
||||||
|
controlled.scale.toArray() as V3,
|
||||||
|
);
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
|
if (!selection)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const obj = selection.geometry.object;
|
||||||
|
|
||||||
|
return <TransformControls
|
||||||
|
mode={selection.mode}
|
||||||
|
position={obj.position}
|
||||||
|
rotation={obj.rotation}
|
||||||
|
scale={obj.scale}
|
||||||
|
onObjectChange={onObjectChange}
|
||||||
|
/>;
|
||||||
|
},
|
||||||
|
));
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { state } from "../../../state";
|
||||||
|
import { nextObjectEditMode } from "../../../state/worldEditorState";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { SelectionBoundingBox } from "./SelectionBoundingBox";
|
||||||
|
import { measuredRenderTime } from "../../../utils/layerTimings";
|
||||||
|
import { useObjectGeometryList } from "../ObjectGeometriesLayer";
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
|
||||||
|
export const SceneEditorObjectSelectionLayer = observer(measuredRenderTime(
|
||||||
|
"ObjectSelection",
|
||||||
|
function () {
|
||||||
|
const geometryListRef = useObjectGeometryList();
|
||||||
|
|
||||||
|
const selection = state.view.selectedObject;
|
||||||
|
const { camera, gl, size } = useThree();
|
||||||
|
const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: PointerEvent) => {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return;
|
||||||
|
pointerDownPos.current = { x: e.offsetX, y: e.offsetY };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback((e: PointerEvent) => {
|
||||||
|
if (e.button !== 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const down = pointerDownPos.current;
|
||||||
|
pointerDownPos.current = null;
|
||||||
|
if (!down)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const dx = e.offsetX - down.x;
|
||||||
|
const dy = e.offsetY - down.y;
|
||||||
|
if (dx * dx + dy * dy > DRAG_THRESHOLD * DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pointer.x = (e.offsetX / size.width) * 2 - 1;
|
||||||
|
pointer.y = -(e.offsetY / size.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
|
||||||
|
const instancedMeshes = state.view.objectTypeGeometries.values.filter((ot) => ot.meshes?.length).flatMap((ot) => ot.meshes!);
|
||||||
|
|
||||||
|
const intersections = raycaster.intersectObjects(instancedMeshes, false);
|
||||||
|
|
||||||
|
if (intersections.length === 0) {
|
||||||
|
state.worldEditor.resetSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = intersections[0];
|
||||||
|
|
||||||
|
const instanceId = hit.instanceId;
|
||||||
|
if (instanceId === undefined) {
|
||||||
|
state.worldEditor.resetSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometryList = geometryListRef.current;
|
||||||
|
if (!geometryList)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const mesh = hit.object as THREE.InstancedMesh;
|
||||||
|
const instanceGeometry = geometryList.getObjectGeometryByMesh(mesh, instanceId);
|
||||||
|
if (instanceGeometry) {
|
||||||
|
const sel = state.worldEditor.selection;
|
||||||
|
const editMode = sel?.type === 'object' && sel.id === instanceGeometry.object.id
|
||||||
|
? nextObjectEditMode(sel.editMode)
|
||||||
|
: 'translate';
|
||||||
|
state.worldEditor.setSelectedObject({ id: instanceGeometry.object.id, editMode });
|
||||||
|
} else {
|
||||||
|
state.worldEditor.resetSelection();
|
||||||
|
}
|
||||||
|
}, [camera, size, geometryListRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const domElement = gl.domElement;
|
||||||
|
domElement.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
domElement.addEventListener("pointerup", handlePointerUp);
|
||||||
|
return () => {
|
||||||
|
domElement.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
domElement.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [gl, handlePointerDown, handlePointerUp]);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{selection &&
|
||||||
|
<SelectionBoundingBox item={selection.geometry} />
|
||||||
|
}
|
||||||
|
</>;
|
||||||
|
},
|
||||||
|
));
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import type { ObjectGeometryViewItem } from "../../../state/viewLayers";
|
||||||
|
|
||||||
|
export const SelectionBoundingBox = observer(function({ item }: { item: ObjectGeometryViewItem }) {
|
||||||
|
const obj = item.object;
|
||||||
|
const bb = item.objectTypeGeometry.boundingBox;
|
||||||
|
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
if (!bb)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const box = new THREE.BoxGeometry(
|
||||||
|
bb.max[0] - bb.min[0],
|
||||||
|
bb.max[1] - bb.min[1],
|
||||||
|
bb.max[2] - bb.min[2],
|
||||||
|
);
|
||||||
|
return new THREE.EdgesGeometry(box);
|
||||||
|
}, [bb]);
|
||||||
|
|
||||||
|
const center = useMemo(() => {
|
||||||
|
if (!bb)
|
||||||
|
return [0.5, 0.5, 0.5] as const;
|
||||||
|
|
||||||
|
return [
|
||||||
|
(bb.min[0] + bb.max[0]) / 2,
|
||||||
|
(bb.min[1] + bb.max[1]) / 2,
|
||||||
|
(bb.min[2] + bb.max[2]) / 2,
|
||||||
|
] as const;
|
||||||
|
}, [bb]);
|
||||||
|
|
||||||
|
if (!geometry)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return <mesh
|
||||||
|
position={obj.position}
|
||||||
|
rotation={obj.rotation}
|
||||||
|
scale={obj.scale}
|
||||||
|
>
|
||||||
|
<lineSegments position={center} geometry={geometry}>
|
||||||
|
<lineBasicMaterial color="#ffff00" />
|
||||||
|
</lineSegments>
|
||||||
|
</mesh>;
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { layerTimings } from "../../utils/layerTimings";
|
||||||
|
|
||||||
|
export const StatsLayer = function () {
|
||||||
|
const divRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const fpsFrames = useRef(0);
|
||||||
|
const fpsLastTime = useRef(0);
|
||||||
|
const renderAcc = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fpsLastTime.current = performance.now();
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
Object.assign(div.style, {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "8px",
|
||||||
|
right: "8px",
|
||||||
|
color: "#0f0",
|
||||||
|
fontSize: "10px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
zIndex: "9999",
|
||||||
|
pointerEvents: "none",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
});
|
||||||
|
document.body.appendChild(div);
|
||||||
|
divRef.current = div;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.removeChild(div);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ gl, scene, camera }) => {
|
||||||
|
const renderStart = performance.now();
|
||||||
|
gl.render(scene, camera);
|
||||||
|
renderAcc.current += performance.now() - renderStart;
|
||||||
|
|
||||||
|
fpsFrames.current++;
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = now - fpsLastTime.current;
|
||||||
|
if (elapsed >= 1000) {
|
||||||
|
const frames = fpsFrames.current;
|
||||||
|
const fps = Math.round(frames * 1000 / elapsed);
|
||||||
|
const renderAvg = (renderAcc.current / frames).toFixed(3);
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${fps} FPS gl.render ${renderAvg} ms`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, entry] of Object.entries(layerTimings)) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`frame ${(entry.frame.frames > 0 ? (entry.frame.acc / entry.frame.frames) : 0).toFixed(3)}`);
|
||||||
|
parts.push(`render ${(entry.render.frames > 0 ? (entry.render.acc / entry.render.frames) : 0).toFixed(3)}`);
|
||||||
|
lines.push(`${name} ${parts.join(" ")} ms`);
|
||||||
|
|
||||||
|
entry.frame.acc = 0;
|
||||||
|
entry.frame.frames = 0;
|
||||||
|
entry.render.acc = 0;
|
||||||
|
entry.render.frames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (divRef.current) {
|
||||||
|
divRef.current.textContent = lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fpsFrames.current = 0;
|
||||||
|
renderAcc.current = 0;
|
||||||
|
fpsLastTime.current = now;
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useThree } from "@react-three/fiber";
|
|
||||||
import type { Pos3 } from "../../types";
|
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
export const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
|
|
||||||
const { camera: threeCamera } = useThree();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
|
|
||||||
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
|
|
||||||
threeCamera.updateProjectionMatrix();
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
@@ -1,37 +1,28 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { MobxReconcilerMap, type ReconcilerMap } from '../utils/reconciler';
|
import { MobxReconcilerMap, type ReconcilerMap } from '../utils/reconciler';
|
||||||
import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewState';
|
import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewLayers';
|
||||||
|
|
||||||
export class ObjectGeometryList {
|
export class ObjectGeometryList {
|
||||||
private items: ReconcilerMap<ObjectGeometryViewItem>;
|
private items: ReconcilerMap<ObjectTypeGeometryViewItem>;
|
||||||
private readonly typeRefCounts = new Map<string, number>();
|
|
||||||
|
private readonly instancesByMesh = new Map<THREE.InstancedMesh, ObjectGeometryViewItem[]>();
|
||||||
|
|
||||||
private readonly matrixObj = new THREE.Object3D();
|
private readonly matrixObj = new THREE.Object3D();
|
||||||
private readonly _p = new THREE.Vector3();
|
private readonly _p = new THREE.Vector3();
|
||||||
private readonly _e = new THREE.Euler();
|
private readonly _e = new THREE.Euler();
|
||||||
private readonly _q = new THREE.Quaternion();
|
private readonly _q = new THREE.Quaternion();
|
||||||
|
|
||||||
constructor(scene: THREE.Scene, geometiries: Iterable<ObjectGeometryViewItem>) {
|
constructor(scene: THREE.Scene, typeGeometries: Iterable<ObjectTypeGeometryViewItem>) {
|
||||||
this.items = new MobxReconcilerMap<ObjectGeometryViewItem>({
|
this.items = new MobxReconcilerMap<ObjectTypeGeometryViewItem>({
|
||||||
reaction: () => geometiries,
|
reaction: () => typeGeometries,
|
||||||
keyOf: (item) => item.object.id,
|
keyOf: (item) => item.objectType.id,
|
||||||
initializeItem: (item) => {
|
initializeItem: (item) => {
|
||||||
const typeId = item.objectTypeGeometry.objectType.id;
|
for (const mesh of item.meshes ?? [])
|
||||||
const count = (this.typeRefCounts.get(typeId) ?? 0) + 1;
|
scene.add(mesh);
|
||||||
this.typeRefCounts.set(typeId, count);
|
|
||||||
if (count === 1) {
|
|
||||||
for (const mesh of item.objectTypeGeometry.meshes ?? [])
|
|
||||||
scene.add(mesh);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
disposeItem: (item) => {
|
disposeItem: (item) => {
|
||||||
const typeId = item.objectTypeGeometry.objectType.id;
|
for (const mesh of item.meshes ?? [])
|
||||||
const count = (this.typeRefCounts.get(typeId) ?? 1) - 1;
|
scene.remove(mesh);
|
||||||
this.typeRefCounts.set(typeId, count);
|
|
||||||
if (count === 0) {
|
|
||||||
for (const mesh of item.objectTypeGeometry.meshes ?? [])
|
|
||||||
scene.remove(mesh);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,37 +31,37 @@ export class ObjectGeometryList {
|
|||||||
this.items?.dispose();
|
this.items?.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called once per frame — reads MobX observables directly outside any reactive
|
getObjectGeometryByMesh(mesh: THREE.InstancedMesh, instanceId: number): ObjectGeometryViewItem | undefined {
|
||||||
// context, so no subscriptions are created and no re-renders are triggered.
|
return this.instancesByMesh.get(mesh)?.[instanceId];
|
||||||
|
}
|
||||||
|
|
||||||
tick(): void {
|
tick(): void {
|
||||||
const touchedObjectTypes: Record<string, { typeGeometry: ObjectTypeGeometryViewItem, instanceCount: number }> = {};
|
this.instancesByMesh.clear();
|
||||||
|
|
||||||
this.items.values.forEach((item) => {
|
this.items.values.forEach((typeGeometry) => {
|
||||||
const obj = item.object;
|
const instances = typeGeometry.instances;
|
||||||
|
const meshes = typeGeometry.meshes ?? [];
|
||||||
|
|
||||||
this._p.set(obj.position[0], obj.position[1], obj.position[2]);
|
instances.forEach((item, instanceIdx) => {
|
||||||
this._e.set(obj.rotation[0], obj.rotation[1], obj.rotation[2]);
|
const obj = item.object;
|
||||||
this._q.setFromEuler(this._e);
|
|
||||||
|
|
||||||
const typeId = item.objectTypeGeometry.objectType.id;
|
this._p.set(...obj.position);
|
||||||
if (!touchedObjectTypes[typeId])
|
this._e.set(...obj.rotation);
|
||||||
touchedObjectTypes[typeId] = { typeGeometry: item.objectTypeGeometry, instanceCount: 0 };
|
this._q.setFromEuler(this._e);
|
||||||
const instanceIdx = touchedObjectTypes[typeId].instanceCount++;
|
|
||||||
|
|
||||||
for (const mesh of item.objectTypeGeometry.meshes ?? []) {
|
|
||||||
this.matrixObj.position.copy(this._p);
|
this.matrixObj.position.copy(this._p);
|
||||||
this.matrixObj.quaternion.copy(this._q);
|
this.matrixObj.quaternion.copy(this._q);
|
||||||
this.matrixObj.scale.set(obj.scale[0], obj.scale[1], obj.scale[2]);
|
this.matrixObj.scale.set(...obj.scale);
|
||||||
this.matrixObj.updateMatrix();
|
this.matrixObj.updateMatrix();
|
||||||
mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const objectType of Object.values(touchedObjectTypes)) {
|
for (const mesh of meshes)
|
||||||
for (const mesh of objectType.typeGeometry.meshes ?? []) {
|
mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix);
|
||||||
mesh.count = objectType.instanceCount;
|
});
|
||||||
|
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
this.instancesByMesh.set(mesh, typeGeometry.instances);
|
||||||
|
mesh.count = instances.length;
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ export type RenderInfo = {
|
|||||||
|
|
||||||
export class RootState {
|
export class RootState {
|
||||||
public readonly world = new WorldState();
|
public readonly world = new WorldState();
|
||||||
public readonly worldEditor: WorldEditorState;
|
public readonly worldEditor = new WorldEditorState(this.world);
|
||||||
public game: GameState | undefined;
|
public game: GameState | undefined;
|
||||||
public renderInfo: RenderInfo | undefined;
|
public renderInfo: RenderInfo | undefined;
|
||||||
public readonly menu = new MenuState();
|
public readonly menu = new MenuState();
|
||||||
public readonly view = new ViewState(this.world);
|
public readonly view = new ViewState(this.world, this.worldEditor);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.worldEditor = new WorldEditorState(this.world);
|
|
||||||
|
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
this,
|
this,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export abstract class ViewItem {
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Pos3 } from "../../types";
|
||||||
|
import { state } from "../rootState";
|
||||||
|
import { ViewItem } from "./base";
|
||||||
|
|
||||||
|
export abstract class CameraViewItem extends ViewItem {
|
||||||
|
public abstract get camera(): Pos3;
|
||||||
|
public abstract set camera(value: Pos3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ControlledCameraViewItem extends CameraViewItem {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get camera(): Pos3 {
|
||||||
|
return state.worldEditor.camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set camera(value: Pos3) {
|
||||||
|
state.worldEditor.setCamera(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ThirdPersonCameraViewItem extends CameraViewItem {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './base';
|
||||||
|
export * from './objectTypeGeometry';
|
||||||
|
export * from './objectGeometry';
|
||||||
|
export * from './camera';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ObjectInstance, ObjectInstanceRigidBody, V3 } from "../../types";
|
||||||
|
import { state } from "../rootState";
|
||||||
|
import { ViewItem } from "./base";
|
||||||
|
import type { ObjectTypeGeometryViewItem } from './objectTypeGeometry';
|
||||||
|
|
||||||
|
export class ObjectGeometryViewItem extends ViewItem {
|
||||||
|
public readonly object: ObjectInstance;
|
||||||
|
public readonly objectTypeGeometry: ObjectTypeGeometryViewItem;
|
||||||
|
|
||||||
|
public rb: (() => ObjectInstanceRigidBody | null) | undefined;
|
||||||
|
|
||||||
|
constructor(object: ObjectInstance, objectTypeGeometry: ObjectTypeGeometryViewItem) {
|
||||||
|
super();
|
||||||
|
this.object = object;
|
||||||
|
this.objectTypeGeometry = objectTypeGeometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
setObjectTransform(position: V3, rotation: V3, scale: V3) {
|
||||||
|
state.worldEditor.setObjectTransform(this.object.id, position, rotation, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { action, makeObservable, observable } from "mobx";
|
||||||
|
import { nextObjectEditMode, type ObjectEditMode } from "../worldEditorState";
|
||||||
|
import { ViewItem } from "./base";
|
||||||
|
import type { ObjectGeometryViewItem } from "./objectGeometry";
|
||||||
|
|
||||||
|
export class ObjectSelectionViewItem extends ViewItem {
|
||||||
|
public readonly geometry: ObjectGeometryViewItem;
|
||||||
|
public mode: ObjectEditMode;
|
||||||
|
|
||||||
|
constructor(geometry: ObjectGeometryViewItem, mode: ObjectEditMode) {
|
||||||
|
super();
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.mode = mode;
|
||||||
|
|
||||||
|
makeObservable(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
geometry: observable.ref,
|
||||||
|
mode: true,
|
||||||
|
nextMode: action,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextMode(): void {
|
||||||
|
this.mode = nextObjectEditMode(this.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { ObjectType, V3, Voxel, VoxelGroup } from "../../types";
|
||||||
|
import { ViewItem } from "./base";
|
||||||
|
import { action, computed, makeObservable, observable, reaction, type IReactionDisposer } from 'mobx';
|
||||||
|
import { getObjectVoxelGroups } from '../../utils/graphics/voxelGroup';
|
||||||
|
import { buildVoxelGroupMesh } from '../../utils/graphics/voxelMesh';
|
||||||
|
import { state } from '../rootState';
|
||||||
|
import { buildObjectTrimesh } from '../../utils/graphics/mesh';
|
||||||
|
import { computeBoundingBox } from '../../utils/runtime';
|
||||||
|
import type { ObjectGeometryViewItem } from './objectGeometry';
|
||||||
|
import type { ViewState } from '../viewState';
|
||||||
|
|
||||||
|
export class ObjectTypeGeometryViewItem extends ViewItem {
|
||||||
|
public readonly owner: ViewState;
|
||||||
|
public readonly objectType: ObjectType;
|
||||||
|
|
||||||
|
public voxelGroups: VoxelGroup[] | undefined;
|
||||||
|
public colliderMesh: [Float32Array, Uint32Array] | undefined;
|
||||||
|
public boundingBox: { min: V3; max: V3 } | undefined;
|
||||||
|
public meshes: THREE.InstancedMesh[] | undefined;
|
||||||
|
|
||||||
|
private readonly _disposeVoxelReaction: IReactionDisposer;
|
||||||
|
|
||||||
|
constructor(owner: ViewState, objectType: ObjectType) {
|
||||||
|
super();
|
||||||
|
this.owner = owner;
|
||||||
|
this.objectType = objectType;
|
||||||
|
makeObservable(this, {
|
||||||
|
voxelGroups: observable.ref,
|
||||||
|
colliderMesh: observable.ref,
|
||||||
|
boundingBox: observable.ref,
|
||||||
|
meshes: observable.ref,
|
||||||
|
updateRuntimeData: action,
|
||||||
|
instances: computed,
|
||||||
|
});
|
||||||
|
this._disposeVoxelReaction = reaction(
|
||||||
|
() => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'),
|
||||||
|
() => this.updateRuntimeData(),
|
||||||
|
);
|
||||||
|
this.updateRuntimeData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this._disposeVoxelReaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public addVoxel(voxel: Voxel): boolean {
|
||||||
|
const occupied = this.objectType.voxels.some(v =>
|
||||||
|
v.position[0] === voxel.position[0] &&
|
||||||
|
v.position[1] === voxel.position[1] &&
|
||||||
|
v.position[2] === voxel.position[2]
|
||||||
|
);
|
||||||
|
if (!occupied) {
|
||||||
|
this.objectType.voxels.push(voxel);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeVoxel(position: V3): Voxel | undefined {
|
||||||
|
const idx = this.objectType.voxels.findIndex(v =>
|
||||||
|
v.position[0] === position[0] &&
|
||||||
|
v.position[1] === position[1] &&
|
||||||
|
v.position[2] === position[2]
|
||||||
|
);
|
||||||
|
if (idx !== -1)
|
||||||
|
return this.objectType.voxels.splice(idx, 1)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateRuntimeData(): void {
|
||||||
|
this.voxelGroups = getObjectVoxelGroups(this.objectType, state.world.data.voxelTypes);
|
||||||
|
this.meshes = [];
|
||||||
|
for (const vg of this.voxelGroups) {
|
||||||
|
const geo = buildVoxelGroupMesh(vg.positions);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color: vg.color,
|
||||||
|
opacity: vg.opacity,
|
||||||
|
transparent: vg.opacity < 1,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.InstancedMesh(geo, mat, 1000);
|
||||||
|
this.meshes.push(mesh);
|
||||||
|
}
|
||||||
|
this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes);
|
||||||
|
this.boundingBox = computeBoundingBox(this.objectType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get instances(): ObjectGeometryViewItem[] {
|
||||||
|
return this.owner.objectGeometries.values.filter((o) => o.objectTypeGeometry === this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,113 +1,31 @@
|
|||||||
import * as THREE from 'three';
|
import { makeAutoObservable } from "mobx";
|
||||||
import { action, makeAutoObservable, makeObservable, observable, reaction, type IReactionDisposer } from "mobx";
|
import type { ObjectInstance, ObjectType } from "../types";
|
||||||
import type { ObjectInstance, ObjectInstanceRigidBody, ObjectType, Scene, V3, Voxel, VoxelGroup } from "../types";
|
|
||||||
import { state } from "./rootState";
|
|
||||||
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
|
|
||||||
import { buildVoxelGroupMesh } from "../utils/graphics/voxelMesh";
|
|
||||||
import { buildObjectTrimesh } from '../utils/graphics/mesh';
|
|
||||||
import { computeBoundingBox } from '../utils/runtime';
|
|
||||||
import { MobxReconcilerMap } from '../utils/reconciler';
|
import { MobxReconcilerMap } from '../utils/reconciler';
|
||||||
|
import { ObjectGeometryViewItem, ObjectTypeGeometryViewItem, ControlledCameraViewItem, ViewItem } from './viewLayers';
|
||||||
export abstract class ViewItem {
|
import type { WorldState } from "./worldState";
|
||||||
}
|
import { ObjectSelectionViewItem } from "./viewLayers/objectSelection";
|
||||||
|
import type { WorldEditorState } from "./worldEditorState";
|
||||||
export class ObjectTypeGeometryViewItem extends ViewItem {
|
|
||||||
public readonly objectType: ObjectType;
|
|
||||||
|
|
||||||
public voxelGroups: VoxelGroup[] | undefined;
|
|
||||||
public colliderMesh: [Float32Array, Uint32Array] | undefined;
|
|
||||||
public boundingBox: { min: V3; max: V3 } | undefined;
|
|
||||||
public meshes: THREE.InstancedMesh[] | undefined;
|
|
||||||
|
|
||||||
private readonly _disposeVoxelReaction: IReactionDisposer;
|
|
||||||
|
|
||||||
constructor(objectType: ObjectType) {
|
|
||||||
super();
|
|
||||||
this.objectType = objectType;
|
|
||||||
makeObservable(this, {
|
|
||||||
voxelGroups: observable.ref,
|
|
||||||
colliderMesh: observable.ref,
|
|
||||||
boundingBox: observable.ref,
|
|
||||||
meshes: observable.ref,
|
|
||||||
updateRuntimeData: action,
|
|
||||||
});
|
|
||||||
this._disposeVoxelReaction = reaction(
|
|
||||||
() => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'),
|
|
||||||
() => this.updateRuntimeData(),
|
|
||||||
);
|
|
||||||
this.updateRuntimeData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispose(): void {
|
|
||||||
this._disposeVoxelReaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
public addVoxel(voxel: Voxel): boolean {
|
|
||||||
const occupied = this.objectType.voxels.some(v =>
|
|
||||||
v.position[0] === voxel.position[0] &&
|
|
||||||
v.position[1] === voxel.position[1] &&
|
|
||||||
v.position[2] === voxel.position[2]
|
|
||||||
);
|
|
||||||
if (!occupied) {
|
|
||||||
this.objectType.voxels.push(voxel);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeVoxel(position: V3): Voxel | undefined {
|
|
||||||
const idx = this.objectType.voxels.findIndex(v =>
|
|
||||||
v.position[0] === position[0] &&
|
|
||||||
v.position[1] === position[1] &&
|
|
||||||
v.position[2] === position[2]
|
|
||||||
);
|
|
||||||
if (idx !== -1)
|
|
||||||
return this.objectType.voxels.splice(idx, 1)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateRuntimeData(): void {
|
|
||||||
this.voxelGroups = getObjectVoxelGroups(this.objectType, state.world.data.voxelTypes);
|
|
||||||
this.meshes = [];
|
|
||||||
for (const vg of this.voxelGroups) {
|
|
||||||
const geo = buildVoxelGroupMesh(vg.positions);
|
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
|
||||||
color: vg.color,
|
|
||||||
opacity: vg.opacity,
|
|
||||||
transparent: vg.opacity < 1,
|
|
||||||
});
|
|
||||||
const mesh = new THREE.InstancedMesh(geo, mat, 1000);
|
|
||||||
this.meshes.push(mesh);
|
|
||||||
}
|
|
||||||
this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes);
|
|
||||||
this.boundingBox = computeBoundingBox(this.objectType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ObjectGeometryViewItem extends ViewItem {
|
|
||||||
public readonly object: ObjectInstance;
|
|
||||||
public readonly objectTypeGeometry: ObjectTypeGeometryViewItem;
|
|
||||||
|
|
||||||
public rb: (() => ObjectInstanceRigidBody | null) | undefined;
|
|
||||||
|
|
||||||
constructor(object: ObjectInstance, objectTypeGeometry: ObjectTypeGeometryViewItem) {
|
|
||||||
super();
|
|
||||||
this.object = object;
|
|
||||||
this.objectTypeGeometry = objectTypeGeometry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ViewState {
|
export class ViewState {
|
||||||
public objectTypeGeometries: MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>;
|
public objectTypeGeometries: MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>;
|
||||||
public objectGeometries: MobxReconcilerMap<ObjectInstance, ObjectGeometryViewItem>;
|
public objectGeometries: MobxReconcilerMap<ObjectInstance, ObjectGeometryViewItem>;
|
||||||
|
public camera?: ControlledCameraViewItem;
|
||||||
|
|
||||||
|
private readonly editor: WorldEditorState;
|
||||||
|
|
||||||
|
constructor(world: WorldState, editor: WorldEditorState) {
|
||||||
|
this.editor = editor;
|
||||||
|
|
||||||
constructor(world: { data: { objectTypes: Record<string, ObjectType>; initialScene: Scene } }) {
|
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
this,
|
this,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.camera = new ControlledCameraViewItem();
|
||||||
|
|
||||||
this.objectTypeGeometries = new MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>({
|
this.objectTypeGeometries = new MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>({
|
||||||
reaction: () => Object.values(world.data.objectTypes),
|
reaction: () => Object.values(world.data.objectTypes),
|
||||||
keyOf: (ot) => ot.id,
|
keyOf: (ot) => ot.id,
|
||||||
mapItem: (ot) => new ObjectTypeGeometryViewItem(ot),
|
mapItem: (ot) => new ObjectTypeGeometryViewItem(this, ot),
|
||||||
updateItem: () => true,
|
updateItem: () => true,
|
||||||
disposeItem: (item) => { item.dispose(); },
|
disposeItem: (item) => { item.dispose(); },
|
||||||
});
|
});
|
||||||
@@ -126,10 +44,24 @@ export class ViewState {
|
|||||||
this.objectGeometries.dispose();
|
this.objectGeometries.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get selectedObject(): ObjectSelectionViewItem | undefined {
|
||||||
|
const sel = this.editor.selection;
|
||||||
|
if (sel?.type !== 'object')
|
||||||
|
return;
|
||||||
|
|
||||||
|
const geom = this.objectGeometries.get(sel.id);
|
||||||
|
if (!geom)
|
||||||
|
return;
|
||||||
|
|
||||||
|
return new ObjectSelectionViewItem(geom, sel.editMode);
|
||||||
|
}
|
||||||
|
|
||||||
public get items(): ViewItem[] {
|
public get items(): ViewItem[] {
|
||||||
return [
|
return [
|
||||||
...this.objectTypeGeometries,
|
...this.objectTypeGeometries,
|
||||||
...this.objectGeometries,
|
...this.objectGeometries,
|
||||||
|
...[this.camera ? [this.camera] : []],
|
||||||
|
...[this.selectedObject ? [this.selectedObject] : []],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export class WorldEditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setCamera(value: Pos3): void {
|
public setCamera(value: Pos3): void {
|
||||||
|
if (JSON.stringify(value) === JSON.stringify(this.world.data.editorCamera))
|
||||||
|
return;
|
||||||
this.world.data.editorCamera = value;
|
this.world.data.editorCamera = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { autorun, makeAutoObservable, toJS } from "mobx";
|
import { autorun, makeAutoObservable, toJS } from "mobx";
|
||||||
import { WorldFactory } from "../model/worldFactory";
|
import { WorldFactory } from "../model/worldFactory";
|
||||||
import { type ObjectInstance, type ObjectType, type World, type VoxelType, type RuntimeWorld } from "../types";
|
import { type ObjectInstance, type ObjectType, type World, type VoxelType } from "../types";
|
||||||
import { state } from "./rootState";
|
import { state } from "./rootState";
|
||||||
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
|
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
|
||||||
import { wolf } from "../model/objectPrefabs/wolf";
|
import { wolf } from "../model/objectPrefabs/wolf";
|
||||||
@@ -8,7 +8,7 @@ import { terrainXZ } from "../model/objectPrefabs/terrain";
|
|||||||
import { populateRuntimeWorld } from "../utils/runtime";
|
import { populateRuntimeWorld } from "../utils/runtime";
|
||||||
|
|
||||||
export class WorldState {
|
export class WorldState {
|
||||||
public data: RuntimeWorld = WorldFactory.create();
|
public data: World = WorldFactory.create();
|
||||||
|
|
||||||
private _buildSave(): World {
|
private _buildSave(): World {
|
||||||
const d = this.data;
|
const d = this.data;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { GameRules } from "./gameRules";
|
import type { GameRules } from "./gameRules";
|
||||||
import type { ObjectType } from "./object";
|
import type { ObjectType } from "./object";
|
||||||
import type { RuntimeScene, Scene } from "./scene";
|
import type { Scene } from "./scene";
|
||||||
import type { Pos3 } from "../3d";
|
import type { Pos3 } from "../3d";
|
||||||
import type { VoxelType } from "./voxel";
|
import type { VoxelType } from "./voxel";
|
||||||
|
|
||||||
@@ -11,7 +11,3 @@ export type World = {
|
|||||||
initialScene: Scene;
|
initialScene: Scene;
|
||||||
gameRules: GameRules;
|
gameRules: GameRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeWorld = World & {
|
|
||||||
initialScene: RuntimeScene;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useFrame, type RootState } from "@react-three/fiber";
|
||||||
|
import { recordLayerFrameTime } from "../layerTimings";
|
||||||
|
|
||||||
|
export function useLayerFrame(
|
||||||
|
name: string,
|
||||||
|
callback: (state: RootState, delta: number) => void,
|
||||||
|
priority?: number,
|
||||||
|
) {
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
const start = performance.now();
|
||||||
|
callback(state, delta);
|
||||||
|
recordLayerFrameTime(name, performance.now() - start);
|
||||||
|
}, priority);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import type { Pos3 } from "../../types";
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { autorun } from "mobx";
|
||||||
|
|
||||||
|
export function useSyncCamera(getCamera: () => Pos3 | undefined) {
|
||||||
|
const { camera: threeCamera } = useThree();
|
||||||
|
|
||||||
|
useLayoutEffect(() => autorun(() => {
|
||||||
|
const camera = getCamera();
|
||||||
|
if (camera) {
|
||||||
|
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
|
||||||
|
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
|
||||||
|
threeCamera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}), [getCamera, threeCamera]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type LayerTiming = {
|
||||||
|
frame: { acc: number; frames: number };
|
||||||
|
render: { acc: number; frames: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const layerTimings: Record<string, LayerTiming> = {};
|
||||||
|
|
||||||
|
function getEntry(name: string): LayerTiming {
|
||||||
|
return layerTimings[name] ??= {
|
||||||
|
frame: { acc: 0, frames: 0 },
|
||||||
|
render: { acc: 0, frames: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordLayerFrameTime(name: string, ms: number) {
|
||||||
|
const t = getEntry(name).frame;
|
||||||
|
t.acc += ms;
|
||||||
|
t.frames++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLayerRender(name: string): () => void {
|
||||||
|
const start = performance.now();
|
||||||
|
return () => {
|
||||||
|
const t = getEntry(name).render;
|
||||||
|
t.acc += performance.now() - start;
|
||||||
|
t.frames++;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function measuredRenderTime<TArgs extends any[], TResult extends ReactNode>(
|
||||||
|
name: string,
|
||||||
|
callback: (...args: TArgs) => TResult
|
||||||
|
): ((...args: TArgs) => TResult) {
|
||||||
|
return (...args) => {
|
||||||
|
const endRender = startLayerRender(name);
|
||||||
|
const result = callback(...args);
|
||||||
|
endRender();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user