Compare commits
9 Commits
e15fb18d6b
...
a2fc417be6
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fc417be6 | |||
| 57498b9bb1 | |||
| b87a7e929c | |||
| ea23b4bb46 | |||
| 3881e38a6d | |||
| 7a72743e5c | |||
| 65651405b6 | |||
| 81cd935bba | |||
| fe989c9550 |
@@ -52,7 +52,7 @@ src/
|
|||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
|
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as primary selected object, selected object indexes, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
|
||||||
|
|
||||||
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
|
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ src/
|
|||||||
|
|
||||||
`src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
|
`src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
|
||||||
|
|
||||||
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
|
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. For multi-selection, it attaches `TransformControls` to a temporary group centered on the selected nodes, then decomposes the group delta back into each selected node transform.
|
||||||
|
|
||||||
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
|
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
|
||||||
|
|
||||||
@@ -115,11 +115,12 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
||||||
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
||||||
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
||||||
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
|
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- Click: select a node.
|
- Click: select a node.
|
||||||
|
- `Shift` + right click: add or remove a node from the multi-selection.
|
||||||
- `Esc`: clear selection.
|
- `Esc`: clear selection.
|
||||||
- Click empty space: clear selection.
|
- Click empty space: clear selection.
|
||||||
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
|
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
|
||||||
@@ -128,6 +129,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
- `R`: rotate mode.
|
- `R`: rotate mode.
|
||||||
- `S`: scale mode.
|
- `S`: scale mode.
|
||||||
- Snap terrain on move: enabled by default and applied while translating an object.
|
- Snap terrain on move: enabled by default and applied while translating an object.
|
||||||
|
- Multi-selection transforms use a temporary centered group and write the resulting position, rotation, and scale back to every selected map node.
|
||||||
- Lock terrain: enabled by default so terrain remains visible but ignores selection clicks.
|
- Lock terrain: enabled by default so terrain remains visible but ignores selection clicks.
|
||||||
- Camera action: centers on the selected object or resets to the editor home view.
|
- Camera action: centers on the selected object or resets to the editor home view.
|
||||||
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
|
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
|
||||||
|
|||||||
+12
-8
@@ -45,14 +45,15 @@ Only the `Editor` group is open by default. Open the other groups when you need
|
|||||||
|
|
||||||
1. Open `/editor` in the local app.
|
1. Open `/editor` in the local app.
|
||||||
2. Click an object in the scene to select it.
|
2. Click an object in the scene to select it.
|
||||||
3. Choose a transform mode: translate, rotate, or scale.
|
3. Use `Shift + right click` on other objects to add or remove them from the current multi-selection.
|
||||||
4. Drag the transform gizmo in the 3D view.
|
4. Choose a transform mode: translate, rotate, or scale.
|
||||||
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
5. Drag the transform gizmo in the 3D view. With multiple objects selected, the gizmo transforms the selected group and writes each object transform back to `map.json`.
|
||||||
6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
|
6. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
||||||
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
7. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
|
||||||
8. Check the JSON inspector if you need exact values.
|
8. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||||
9. Use undo or redo if the transform is not correct.
|
9. Check the JSON inspector if you need exact values.
|
||||||
10. Export the JSON or save it to the dev server.
|
10. Use undo or redo if the transform is not correct.
|
||||||
|
11. Export the JSON or save it to the dev server.
|
||||||
|
|
||||||
## Adding And Deleting Nodes
|
## Adding And Deleting Nodes
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre
|
|||||||
| Action | Input |
|
| Action | Input |
|
||||||
| -------------------- | -------------------------- |
|
| -------------------- | -------------------------- |
|
||||||
| Select object | Click object |
|
| Select object | Click object |
|
||||||
|
| Toggle multi-select | `Shift` + right click |
|
||||||
| Deselect | `Esc` or click empty space |
|
| Deselect | `Esc` or click empty space |
|
||||||
| Lock selection | `Lock` button in Selection |
|
| Lock selection | `Lock` button in Selection |
|
||||||
| Clear selection | `X` button in Selection |
|
| Clear selection | `X` button in Selection |
|
||||||
@@ -87,6 +89,8 @@ Use the trash button in `Selection` to delete the selected node from the map tre
|
|||||||
The `Selection` section shows the selected object name and its index in `public/map.json`.
|
The `Selection` section shows the selected object name and its index in `public/map.json`.
|
||||||
|
|
||||||
- Click an object to select it.
|
- Click an object to select it.
|
||||||
|
- Use `Shift + right click` on objects to add or remove them from a multi-selection.
|
||||||
|
- When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node.
|
||||||
- Click empty space or press `Esc` to clear the selection.
|
- Click empty space or press `Esc` to clear the selection.
|
||||||
- Use the `X` button to clear the selection explicitly.
|
- Use the `X` button to clear the selection explicitly.
|
||||||
- Use the `Lock` button to protect the current selection while editing.
|
- Use the `Lock` button to protect the current selection while editing.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -29,6 +29,7 @@ interface EditorControlsProps {
|
|||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
onTransformModeChange: (mode: TransformMode) => void;
|
onTransformModeChange: (mode: TransformMode) => void;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
|
selectedNodeIndexes: number[];
|
||||||
mapNodes: MapNode[];
|
mapNodes: MapNode[];
|
||||||
nodesCount: number;
|
nodesCount: number;
|
||||||
selectedNodeName: string | null;
|
selectedNodeName: string | null;
|
||||||
@@ -66,6 +67,7 @@ const TRANSFORM_OPTIONS = [
|
|||||||
|
|
||||||
const EDITOR_SHORTCUTS = [
|
const EDITOR_SHORTCUTS = [
|
||||||
["Click", "Select object"],
|
["Click", "Select object"],
|
||||||
|
["Shift + Right click", "Toggle multi-selection"],
|
||||||
["T / R / S", "Transform mode"],
|
["T / R / S", "Transform mode"],
|
||||||
["Ctrl Z / Y", "Undo / redo"],
|
["Ctrl Z / Y", "Undo / redo"],
|
||||||
["Esc", "Deselect"],
|
["Esc", "Deselect"],
|
||||||
@@ -103,6 +105,7 @@ export function EditorControls({
|
|||||||
transformMode,
|
transformMode,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
|
selectedNodeIndexes,
|
||||||
mapNodes,
|
mapNodes,
|
||||||
nodesCount,
|
nodesCount,
|
||||||
selectedNodeName,
|
selectedNodeName,
|
||||||
@@ -135,6 +138,7 @@ export function EditorControls({
|
|||||||
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
||||||
const selectedNode =
|
const selectedNode =
|
||||||
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
|
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
|
||||||
|
const selectionCount = selectedNodeIndexes.length;
|
||||||
const transformValues = getTransformValues(selectedNode ?? null);
|
const transformValues = getTransformValues(selectedNode ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,10 +244,14 @@ export function EditorControls({
|
|||||||
<Box size={17} aria-hidden="true" />
|
<Box size={17} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<strong>
|
<strong>
|
||||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
{selectionCount > 1
|
||||||
|
? `${selectionCount} selected nodes`
|
||||||
|
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||||
</strong>
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
Index {selectedNodeIndex + 1} of {nodesCount}
|
{selectionCount > 1
|
||||||
|
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
|
||||||
|
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-selected-actions">
|
<div className="editor-selected-actions">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { Grid, TransformControls } from "@react-three/drei";
|
import { Grid, TransformControls } from "@react-three/drei";
|
||||||
import type { ThreeEvent } from "@react-three/fiber";
|
import type { ThreeEvent } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
interface EditorMapProps {
|
interface EditorMapProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
|
selectedNodeIndexes: number[];
|
||||||
onSelectNode: (index: number | null) => void;
|
onSelectNode: (index: number | null) => void;
|
||||||
|
onToggleNodeSelection: (index: number) => void;
|
||||||
isSelectionLocked: boolean;
|
isSelectionLocked: boolean;
|
||||||
hoveredNodeIndex: number | null;
|
hoveredNodeIndex: number | null;
|
||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
@@ -37,16 +39,31 @@ interface EditorNodeCommonProps {
|
|||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
objectsMapRef: EditorNodeObjectRef;
|
objectsMapRef: EditorNodeObjectRef;
|
||||||
onSelectNode: (index: number | null) => void;
|
onSelectNode: (index: number | null) => void;
|
||||||
|
onToggleNodeSelection: (index: number) => void;
|
||||||
isSelectionLocked: boolean;
|
isSelectionLocked: boolean;
|
||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorNodePointerHandlers {
|
interface EditorNodePointerHandlers {
|
||||||
onClick: (event: ThreeEvent<MouseEvent>) => void;
|
onClick: (event: ThreeEvent<MouseEvent>) => void;
|
||||||
|
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
|
||||||
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
|
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
|
||||||
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
|
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TransformSnapshot {
|
||||||
|
groupMatrix: THREE.Matrix4;
|
||||||
|
objects: Map<number, THREE.Matrix4>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMP_BOX = new THREE.Box3();
|
||||||
|
const TEMP_CENTER = new THREE.Vector3();
|
||||||
|
const TEMP_DELTA_MATRIX = new THREE.Matrix4();
|
||||||
|
const TEMP_INVERSE_GROUP_MATRIX = new THREE.Matrix4();
|
||||||
|
const TEMP_POSITION = new THREE.Vector3();
|
||||||
|
const TEMP_QUATERNION = new THREE.Quaternion();
|
||||||
|
const TEMP_SCALE = new THREE.Vector3();
|
||||||
|
|
||||||
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
||||||
object.position.set(...node.position);
|
object.position.set(...node.position);
|
||||||
object.rotation.set(...node.rotation);
|
object.rotation.set(...node.rotation);
|
||||||
@@ -118,6 +135,7 @@ function getNodeHighlightColor(
|
|||||||
function createEditorNodePointerHandlers(
|
function createEditorNodePointerHandlers(
|
||||||
index: number,
|
index: number,
|
||||||
onSelectNode: (index: number | null) => void,
|
onSelectNode: (index: number | null) => void,
|
||||||
|
onToggleNodeSelection: (index: number) => void,
|
||||||
isSelectionLocked: boolean,
|
isSelectionLocked: boolean,
|
||||||
onHoverNode: (index: number | null) => void,
|
onHoverNode: (index: number | null) => void,
|
||||||
): EditorNodePointerHandlers {
|
): EditorNodePointerHandlers {
|
||||||
@@ -127,6 +145,12 @@ function createEditorNodePointerHandlers(
|
|||||||
if (isSelectionLocked) return;
|
if (isSelectionLocked) return;
|
||||||
onSelectNode(index);
|
onSelectNode(index);
|
||||||
},
|
},
|
||||||
|
onContextMenu: (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.nativeEvent.preventDefault();
|
||||||
|
if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
|
||||||
|
onToggleNodeSelection(index);
|
||||||
|
},
|
||||||
onPointerEnter: (event) => {
|
onPointerEnter: (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onHoverNode(index);
|
onHoverNode(index);
|
||||||
@@ -141,7 +165,9 @@ function createEditorNodePointerHandlers(
|
|||||||
export function EditorMap({
|
export function EditorMap({
|
||||||
sceneData,
|
sceneData,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
|
selectedNodeIndexes,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
hoveredNodeIndex,
|
hoveredNodeIndex,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
@@ -153,18 +179,110 @@ export function EditorMap({
|
|||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
}: EditorMapProps): React.JSX.Element {
|
}: EditorMapProps): React.JSX.Element {
|
||||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||||
|
const transformGroupRef = useRef<THREE.Group>(null);
|
||||||
|
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
|
||||||
const terrainHeight = useTerrainHeightSampler();
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
|
||||||
const handleTransformMouseDown = () => {
|
const selectedIndexSet = new Set(selectedNodeIndexes);
|
||||||
onTransformStart();
|
const isMultiSelection = selectedNodeIndexes.length > 1;
|
||||||
};
|
|
||||||
|
|
||||||
const handleTransformMouseUp = () => {
|
const getTransformObject = useCallback(() => {
|
||||||
syncSelectedObjectTransform();
|
if (isMultiSelection) {
|
||||||
onTransformEnd();
|
return transformGroupRef.current;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (selectedNodeIndex !== null) {
|
||||||
|
return objectsMapRef.current.get(selectedNodeIndex) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [isMultiSelection, selectedNodeIndex]);
|
||||||
|
|
||||||
|
const prepareTransformGroup = useCallback(() => {
|
||||||
|
if (!isMultiSelection || !transformGroupRef.current) return;
|
||||||
|
|
||||||
|
const selectedObjects = selectedNodeIndexes
|
||||||
|
.map((index) => objectsMapRef.current.get(index))
|
||||||
|
.filter((object): object is THREE.Object3D => Boolean(object));
|
||||||
|
|
||||||
|
if (selectedObjects.length === 0) return;
|
||||||
|
|
||||||
|
TEMP_BOX.makeEmpty();
|
||||||
|
for (const object of selectedObjects) {
|
||||||
|
object.updateWorldMatrix(true, false);
|
||||||
|
TEMP_BOX.expandByPoint(object.getWorldPosition(TEMP_CENTER));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMP_BOX.getCenter(TEMP_CENTER);
|
||||||
|
transformGroupRef.current.position.copy(TEMP_CENTER);
|
||||||
|
transformGroupRef.current.rotation.set(0, 0, 0);
|
||||||
|
transformGroupRef.current.scale.set(1, 1, 1);
|
||||||
|
transformGroupRef.current.updateMatrixWorld(true);
|
||||||
|
}, [isMultiSelection, selectedNodeIndexes]);
|
||||||
|
|
||||||
|
const createTransformSnapshot = useCallback((): TransformSnapshot | null => {
|
||||||
|
const transformGroup = transformGroupRef.current;
|
||||||
|
|
||||||
|
if (!isMultiSelection || !transformGroup) return null;
|
||||||
|
|
||||||
|
const objects = new Map<number, THREE.Matrix4>();
|
||||||
|
for (const index of selectedNodeIndexes) {
|
||||||
|
const object = objectsMapRef.current.get(index);
|
||||||
|
if (!object) continue;
|
||||||
|
|
||||||
|
object.updateMatrixWorld(true);
|
||||||
|
objects.set(index, object.matrix.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
transformGroup.updateMatrixWorld(true);
|
||||||
|
return {
|
||||||
|
groupMatrix: transformGroup.matrix.clone(),
|
||||||
|
objects,
|
||||||
|
};
|
||||||
|
}, [isMultiSelection, selectedNodeIndexes]);
|
||||||
|
|
||||||
const syncSelectedObjectTransform = () => {
|
const syncSelectedObjectTransform = () => {
|
||||||
|
if (isMultiSelection) {
|
||||||
|
const transformGroup = transformGroupRef.current;
|
||||||
|
const snapshot = transformSnapshotRef.current;
|
||||||
|
if (!transformGroup || !snapshot) return;
|
||||||
|
|
||||||
|
transformGroup.updateMatrix();
|
||||||
|
TEMP_INVERSE_GROUP_MATRIX.copy(snapshot.groupMatrix).invert();
|
||||||
|
TEMP_DELTA_MATRIX.multiplyMatrices(
|
||||||
|
transformGroup.matrix,
|
||||||
|
TEMP_INVERSE_GROUP_MATRIX,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, startMatrix] of snapshot.objects) {
|
||||||
|
const obj = objectsMapRef.current.get(index);
|
||||||
|
const node = sceneData.mapNodes[index];
|
||||||
|
if (!obj || !node) continue;
|
||||||
|
|
||||||
|
const nextMatrix = TEMP_DELTA_MATRIX.clone().multiply(startMatrix);
|
||||||
|
nextMatrix.decompose(TEMP_POSITION, TEMP_QUATERNION, TEMP_SCALE);
|
||||||
|
obj.position.copy(TEMP_POSITION);
|
||||||
|
obj.quaternion.copy(TEMP_QUATERNION);
|
||||||
|
obj.scale.copy(TEMP_SCALE);
|
||||||
|
|
||||||
|
const terrainY = snapToTerrain
|
||||||
|
? terrainHeight.getHeight(obj.position.x, obj.position.z)
|
||||||
|
: null;
|
||||||
|
if (terrainY !== null && transformMode === "translate") {
|
||||||
|
obj.position.y = terrainY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeTransform(index, {
|
||||||
|
...node,
|
||||||
|
position: [obj.position.x, obj.position.y, obj.position.z],
|
||||||
|
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
|
||||||
|
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedNodeIndex !== null) {
|
if (selectedNodeIndex !== null) {
|
||||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
@@ -194,25 +312,30 @@ export function EditorMap({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
const handleTransformMouseDown = () => {
|
||||||
null,
|
prepareTransformGroup();
|
||||||
);
|
transformSnapshotRef.current = createTransformSnapshot();
|
||||||
|
onTransformStart();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransformMouseUp = () => {
|
||||||
|
syncSelectedObjectTransform();
|
||||||
|
transformSnapshotRef.current = null;
|
||||||
|
prepareTransformGroup();
|
||||||
|
onTransformEnd();
|
||||||
|
};
|
||||||
|
|
||||||
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
|
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||||
const terrainNodeIndex = terrainNode
|
const terrainNodeIndex = terrainNode
|
||||||
? sceneData.mapNodes.indexOf(terrainNode)
|
? sceneData.mapNodes.indexOf(terrainNode)
|
||||||
: -1;
|
: -1;
|
||||||
const selectedNode =
|
useLayoutEffect(() => {
|
||||||
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
|
prepareTransformGroup();
|
||||||
const selectedModelName = selectedNode?.name ?? null;
|
}, [prepareTransformGroup]);
|
||||||
|
|
||||||
useEffect(() => {
|
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
|
||||||
if (selectedNodeIndex !== null) {
|
// eslint-disable-next-line react-hooks/refs
|
||||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
const selectedObject = getTransformObject();
|
||||||
setSelectedObject(obj || null);
|
|
||||||
} else {
|
|
||||||
setSelectedObject(null);
|
|
||||||
}
|
|
||||||
}, [selectedNodeIndex]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -236,11 +359,12 @@ export function EditorMap({
|
|||||||
<EditorTerrainNode
|
<EditorTerrainNode
|
||||||
index={terrainNodeIndex}
|
index={terrainNodeIndex}
|
||||||
node={terrainNode}
|
node={terrainNode}
|
||||||
isSelected={selectedNodeIndex === terrainNodeIndex}
|
isSelected={selectedIndexSet.has(terrainNodeIndex)}
|
||||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||||
lockTerrainSelection={lockTerrainSelection}
|
lockTerrainSelection={lockTerrainSelection}
|
||||||
objectsMapRef={objectsMapRef}
|
objectsMapRef={objectsMapRef}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
@@ -250,10 +374,6 @@ export function EditorMap({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedModelName && node.name !== selectedModelName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelUrl = sceneData.models.get(node.name);
|
const modelUrl = sceneData.models.get(node.name);
|
||||||
|
|
||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
@@ -263,10 +383,11 @@ export function EditorMap({
|
|||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
modelUrl={modelUrl}
|
modelUrl={modelUrl}
|
||||||
isSelected={selectedNodeIndex === index}
|
isSelected={selectedIndexSet.has(index)}
|
||||||
isHovered={hoveredNodeIndex === index}
|
isHovered={hoveredNodeIndex === index}
|
||||||
objectsMapRef={objectsMapRef}
|
objectsMapRef={objectsMapRef}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
@@ -277,10 +398,11 @@ export function EditorMap({
|
|||||||
key={index}
|
key={index}
|
||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
isSelected={selectedNodeIndex === index}
|
isSelected={selectedIndexSet.has(index)}
|
||||||
isHovered={hoveredNodeIndex === index}
|
isHovered={hoveredNodeIndex === index}
|
||||||
objectsMapRef={objectsMapRef}
|
objectsMapRef={objectsMapRef}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
@@ -289,6 +411,8 @@ export function EditorMap({
|
|||||||
})}
|
})}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<group ref={transformGroupRef} />
|
||||||
|
|
||||||
{selectedObject && (
|
{selectedObject && (
|
||||||
<TransformControls
|
<TransformControls
|
||||||
object={selectedObject}
|
object={selectedObject}
|
||||||
@@ -310,6 +434,7 @@ function EditorModelNode({
|
|||||||
isHovered,
|
isHovered,
|
||||||
objectsMapRef,
|
objectsMapRef,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
}: EditorNodeCommonProps & {
|
}: EditorNodeCommonProps & {
|
||||||
@@ -329,6 +454,7 @@ function EditorModelNode({
|
|||||||
const pointerHandlers = createEditorNodePointerHandlers(
|
const pointerHandlers = createEditorNodePointerHandlers(
|
||||||
index,
|
index,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
);
|
);
|
||||||
@@ -403,6 +529,7 @@ function EditorTerrainNode({
|
|||||||
lockTerrainSelection,
|
lockTerrainSelection,
|
||||||
objectsMapRef,
|
objectsMapRef,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
|
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
|
||||||
@@ -410,6 +537,7 @@ function EditorTerrainNode({
|
|||||||
const pointerHandlers = createEditorNodePointerHandlers(
|
const pointerHandlers = createEditorNodePointerHandlers(
|
||||||
index,
|
index,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
);
|
);
|
||||||
@@ -435,6 +563,7 @@ function EditorFallbackNode({
|
|||||||
isHovered,
|
isHovered,
|
||||||
objectsMapRef,
|
objectsMapRef,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
}: EditorNodeCommonProps) {
|
}: EditorNodeCommonProps) {
|
||||||
@@ -442,6 +571,7 @@ function EditorFallbackNode({
|
|||||||
const pointerHandlers = createEditorNodePointerHandlers(
|
const pointerHandlers = createEditorNodePointerHandlers(
|
||||||
index,
|
index,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import gsap from "gsap";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||||
@@ -21,7 +20,9 @@ export interface EditorCinematicPreviewRequest {
|
|||||||
interface EditorSceneProps {
|
interface EditorSceneProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
|
selectedNodeIndexes: number[];
|
||||||
onSelectNode: (index: number | null) => void;
|
onSelectNode: (index: number | null) => void;
|
||||||
|
onToggleNodeSelection: (index: number) => void;
|
||||||
isSelectionLocked: boolean;
|
isSelectionLocked: boolean;
|
||||||
hoveredNodeIndex: number | null;
|
hoveredNodeIndex: number | null;
|
||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
@@ -44,7 +45,9 @@ interface EditorSceneProps {
|
|||||||
export function EditorScene({
|
export function EditorScene({
|
||||||
sceneData,
|
sceneData,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
|
selectedNodeIndexes,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onToggleNodeSelection,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
hoveredNodeIndex,
|
hoveredNodeIndex,
|
||||||
onHoverNode,
|
onHoverNode,
|
||||||
@@ -209,7 +212,9 @@ export function EditorScene({
|
|||||||
<EditorMap
|
<EditorMap
|
||||||
sceneData={sceneData}
|
sceneData={sceneData}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
|
selectedNodeIndexes={selectedNodeIndexes}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
hoveredNodeIndex={hoveredNodeIndex}
|
hoveredNodeIndex={hoveredNodeIndex}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
@@ -221,8 +226,6 @@ export function EditorScene({
|
|||||||
onNodeTransform={onNodeTransform}
|
onNodeTransform={onNodeTransform}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TerrainModel />
|
|
||||||
|
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
|
||||||
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
|
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
|
||||||
const [transformMode, setTransformMode] =
|
const [transformMode, setTransformMode] =
|
||||||
useState<TransformMode>("translate");
|
useState<TransformMode>("translate");
|
||||||
@@ -370,13 +371,31 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleSelectNode = useCallback((index: number | null) => {
|
const handleSelectNode = useCallback((index: number | null) => {
|
||||||
setSelectedNodeIndex(index);
|
setSelectedNodeIndex(index);
|
||||||
|
setSelectedNodeIndexes(index === null ? [] : [index]);
|
||||||
if (index !== null) {
|
if (index !== null) {
|
||||||
setCameraViewMode("object");
|
setCameraViewMode("object");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleNodeSelection = useCallback((index: number) => {
|
||||||
|
setSelectedNodeIndexes((currentIndexes) => {
|
||||||
|
const isSelected = currentIndexes.includes(index);
|
||||||
|
const nextIndexes = isSelected
|
||||||
|
? currentIndexes.filter((item) => item !== index)
|
||||||
|
: [...currentIndexes, index];
|
||||||
|
|
||||||
|
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
|
||||||
|
if (nextIndexes.length > 0) {
|
||||||
|
setCameraViewMode("object");
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextIndexes;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
|
setSelectedNodeIndexes([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectionLockToggle = useCallback(() => {
|
const handleSelectionLockToggle = useCallback(() => {
|
||||||
@@ -401,7 +420,21 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
if (currentIndex === null) return null;
|
if (currentIndex === null) return null;
|
||||||
|
|
||||||
const selectedNode = sceneData?.mapNodes[currentIndex];
|
const selectedNode = sceneData?.mapNodes[currentIndex];
|
||||||
return selectedNode?.name === "terrain" ? null : currentIndex;
|
if (selectedNode?.name === "terrain") {
|
||||||
|
setSelectedNodeIndexes((indexes) =>
|
||||||
|
indexes.filter(
|
||||||
|
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedNodeIndexes((indexes) =>
|
||||||
|
indexes.filter(
|
||||||
|
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return currentIndex;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sceneData],
|
[sceneData],
|
||||||
@@ -544,12 +577,14 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const newNode = createNewMapNode(newNodeName);
|
const newNode = createNewMapNode(newNodeName);
|
||||||
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
|
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
|
||||||
setSelectedNodeIndex(mapNodes.length - 1);
|
setSelectedNodeIndex(mapNodes.length - 1);
|
||||||
|
setSelectedNodeIndexes([mapNodes.length - 1]);
|
||||||
return { ...prev, mapNodes };
|
return { ...prev, mapNodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
||||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
||||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
||||||
|
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
|
||||||
return nextSceneData;
|
return nextSceneData;
|
||||||
});
|
});
|
||||||
}, [newNodeName, setSceneData]);
|
}, [newNodeName, setSceneData]);
|
||||||
@@ -563,6 +598,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
if (!currentNode) return prev;
|
if (!currentNode) return prev;
|
||||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
|
setSelectedNodeIndexes([]);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
mapNodes: prev.mapNodes.filter(
|
mapNodes: prev.mapNodes.filter(
|
||||||
@@ -576,6 +612,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
currentNode.sourcePath,
|
currentNode.sourcePath,
|
||||||
);
|
);
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
|
setSelectedNodeIndexes([]);
|
||||||
return updateSceneDataTree(prev, mapTree);
|
return updateSceneDataTree(prev, mapTree);
|
||||||
});
|
});
|
||||||
}, [selectedNodeIndex, setSceneData]);
|
}, [selectedNodeIndex, setSceneData]);
|
||||||
@@ -660,7 +697,9 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
<EditorScene
|
<EditorScene
|
||||||
sceneData={sceneData!}
|
sceneData={sceneData!}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
|
selectedNodeIndexes={selectedNodeIndexes}
|
||||||
onSelectNode={handleSelectNode}
|
onSelectNode={handleSelectNode}
|
||||||
|
onToggleNodeSelection={handleToggleNodeSelection}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
hoveredNodeIndex={hoveredNodeIndex}
|
hoveredNodeIndex={hoveredNodeIndex}
|
||||||
onHoverNode={handleHoverNode}
|
onHoverNode={handleHoverNode}
|
||||||
@@ -689,6 +728,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
onTransformModeChange={handleTransformModeChange}
|
onTransformModeChange={handleTransformModeChange}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
|
selectedNodeIndexes={selectedNodeIndexes}
|
||||||
mapNodes={sceneData.mapNodes}
|
mapNodes={sceneData.mapNodes}
|
||||||
nodesCount={sceneData.mapNodes.length}
|
nodesCount={sceneData.mapNodes.length}
|
||||||
selectedNodeName={
|
selectedNodeName={
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function isRuntimeSingleMapNode(node: MapNode): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRuntimeCollisionMapNode(node: MapNode): boolean {
|
||||||
|
return node.name === "terrain" || isRuntimeSingleMapNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
export function isEditorVisibleMapNode(node: MapNode): boolean {
|
export function isEditorVisibleMapNode(node: MapNode): boolean {
|
||||||
return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
|
return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { logger } from "@/utils/core/Logger";
|
|||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
import {
|
import {
|
||||||
getTerrainMapNode,
|
getTerrainMapNode,
|
||||||
|
isRuntimeCollisionMapNode,
|
||||||
isRuntimeSingleMapNode,
|
isRuntimeSingleMapNode,
|
||||||
} from "@/utils/map/mapRuntimeClassification";
|
} from "@/utils/map/mapRuntimeClassification";
|
||||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||||
@@ -178,7 +179,7 @@ export function GameMap({
|
|||||||
return { node, modelUrl: modelUrl ?? null };
|
return { node, modelUrl: modelUrl ?? null };
|
||||||
});
|
});
|
||||||
const loadedCollisionNodes = sceneData.mapNodes
|
const loadedCollisionNodes = sceneData.mapNodes
|
||||||
.filter((node) => node.name === "terrain")
|
.filter(isRuntimeCollisionMapNode)
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
const modelUrl = sceneData.models.get(node.name);
|
const modelUrl = sceneData.models.get(node.name);
|
||||||
return { node, modelUrl: modelUrl ?? null };
|
return { node, modelUrl: modelUrl ?? null };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -11,6 +12,11 @@ import * as THREE from "three";
|
|||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
||||||
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
|
normalizeMapScale,
|
||||||
|
useTerrainHeightSampler,
|
||||||
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
||||||
import type { MapNode } from "@/types/editor/editor";
|
import type { MapNode } from "@/types/editor/editor";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
@@ -27,6 +33,8 @@ interface ResolvedGameMapCollisionNode {
|
|||||||
modelUrl: string;
|
modelUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerrainHeightSampler = ReturnType<typeof useTerrainHeightSampler>;
|
||||||
|
|
||||||
interface GameMapCollisionProps {
|
interface GameMapCollisionProps {
|
||||||
buildOctree?: boolean;
|
buildOctree?: boolean;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
@@ -47,8 +55,6 @@ interface CollisionErrorBoundaryState {
|
|||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
|
|
||||||
|
|
||||||
class CollisionErrorBoundary extends Component<
|
class CollisionErrorBoundary extends Component<
|
||||||
CollisionErrorBoundaryProps,
|
CollisionErrorBoundaryProps,
|
||||||
CollisionErrorBoundaryState
|
CollisionErrorBoundaryState
|
||||||
@@ -88,9 +94,7 @@ class CollisionErrorBoundary extends Component<
|
|||||||
function isCollisionNode(
|
function isCollisionNode(
|
||||||
mapNode: GameMapCollisionNode,
|
mapNode: GameMapCollisionNode,
|
||||||
): mapNode is ResolvedGameMapCollisionNode {
|
): mapNode is ResolvedGameMapCollisionNode {
|
||||||
return (
|
return mapNode.modelUrl !== null;
|
||||||
mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameMapCollision({
|
export function GameMapCollision({
|
||||||
@@ -105,6 +109,7 @@ export function GameMapCollision({
|
|||||||
const settledCollisionNodesRef = useRef(new Set<number>());
|
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||||
const loadedNotifiedRef = useRef(false);
|
const loadedNotifiedRef = useRef(false);
|
||||||
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
const collisionNodes = nodes.filter(isCollisionNode);
|
const collisionNodes = nodes.filter(isCollisionNode);
|
||||||
const collisionReady =
|
const collisionReady =
|
||||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
||||||
@@ -188,6 +193,7 @@ export function GameMapCollision({
|
|||||||
node={mapNode.node}
|
node={mapNode.node}
|
||||||
modelUrl={mapNode.modelUrl}
|
modelUrl={mapNode.modelUrl}
|
||||||
onLoaded={() => handleCollisionNodeSettled(index)}
|
onLoaded={() => handleCollisionNodeSettled(index)}
|
||||||
|
terrainHeight={terrainHeight}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</CollisionErrorBoundary>
|
</CollisionErrorBoundary>
|
||||||
@@ -201,19 +207,30 @@ function CollisionModelInstance({
|
|||||||
node,
|
node,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
|
terrainHeight,
|
||||||
}: {
|
}: {
|
||||||
node: MapNode;
|
node: MapNode;
|
||||||
modelUrl: string;
|
modelUrl: string;
|
||||||
onLoaded: () => void;
|
onLoaded: () => void;
|
||||||
|
terrainHeight: TerrainHeightSampler;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { position, rotation, scale } = node;
|
const { position, rotation, scale } = node;
|
||||||
|
const normalizedScale = normalizeMapScale(scale);
|
||||||
const { scene } = useLoggedGLTF(modelUrl, {
|
const { scene } = useLoggedGLTF(modelUrl, {
|
||||||
scope: "GameMapCollision.ModelInstance",
|
scope: "GameMapCollision.ModelInstance",
|
||||||
position,
|
position,
|
||||||
rotation,
|
rotation,
|
||||||
scale,
|
scale: normalizedScale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
const collisionPosition = useMemo(() => {
|
||||||
|
if (node.name === "terrain") return position;
|
||||||
|
|
||||||
|
const [x, y, z] = position;
|
||||||
|
const height = terrainHeight.getHeight(x, z);
|
||||||
|
const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
|
||||||
|
return [x, height !== null ? height + bottomOffset : y, z] as const;
|
||||||
|
}, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onLoaded();
|
onLoaded();
|
||||||
@@ -222,9 +239,9 @@ function CollisionModelInstance({
|
|||||||
return (
|
return (
|
||||||
<primitive
|
<primitive
|
||||||
object={sceneInstance}
|
object={sceneInstance}
|
||||||
position={position}
|
position={collisionPosition}
|
||||||
rotation={rotation}
|
rotation={rotation}
|
||||||
scale={scale}
|
scale={normalizedScale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-196
@@ -1,32 +1,22 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useTexture } from "@react-three/drei";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig";
|
|
||||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
|
||||||
import { useWind } from "@/hooks/world/useWind";
|
import { useWind } from "@/hooks/world/useWind";
|
||||||
import type { TerrainSurfaceData } from "@/types/world/terrainSurface";
|
|
||||||
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
|
|
||||||
import {
|
import {
|
||||||
getGrassTipColor,
|
GRASS_BASE_COLOR,
|
||||||
|
GRASS_COLORS,
|
||||||
GRASS_CONFIG,
|
GRASS_CONFIG,
|
||||||
GRASS_SURFACE_KEYS,
|
|
||||||
} from "@/world/grass/grassConfig";
|
} from "@/world/grass/grassConfig";
|
||||||
import {
|
import {
|
||||||
grassFragmentShader,
|
grassFragmentShader,
|
||||||
grassVertexShader,
|
grassVertexShader,
|
||||||
} from "@/world/grass/grassShaders";
|
} from "@/world/grass/grassShaders";
|
||||||
|
import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||||
|
|
||||||
interface GrassPatchProps {
|
interface GrassPatchProps {
|
||||||
chunkX: number;
|
|
||||||
chunkZ: number;
|
|
||||||
density: number;
|
density: number;
|
||||||
terrainSurfaceData: TerrainSurfaceData;
|
terrainSampler: TerrainGrassSampler;
|
||||||
}
|
|
||||||
|
|
||||||
interface GrassBladeVertexData {
|
|
||||||
color: number[];
|
|
||||||
heightFactor: number;
|
|
||||||
position: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function random01(seed: number): number {
|
function random01(seed: number): number {
|
||||||
@@ -34,216 +24,200 @@ function random01(seed: number): number {
|
|||||||
return value - Math.floor(value);
|
return value - Math.floor(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerp(min: number, max: number, ratio: number): number {
|
function pushVector(target: number[], value: THREE.Vector3): void {
|
||||||
return min + (max - min) * ratio;
|
target.push(value.x, value.y, value.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGrassMaterial(): THREE.ShaderMaterial {
|
function pushColor(target: number[], value: THREE.Color): void {
|
||||||
return new THREE.ShaderMaterial({
|
target.push(value.r, value.g, value.b);
|
||||||
side: THREE.DoubleSide,
|
|
||||||
vertexColors: true,
|
|
||||||
vertexShader: grassVertexShader,
|
|
||||||
fragmentShader: grassFragmentShader,
|
|
||||||
uniforms: {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uWindDirection: { value: 0 },
|
|
||||||
uWindSpeed: { value: 0 },
|
|
||||||
uWindStrength: { value: 0 },
|
|
||||||
uWindNoiseScale: { value: GRASS_CONFIG.windNoiseScale },
|
|
||||||
uBendStrength: { value: GRASS_CONFIG.windBendStrength },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addGrassBlade(
|
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
||||||
positions: number[],
|
|
||||||
colors: number[],
|
|
||||||
bladeBases: number[],
|
|
||||||
heightFactors: number[],
|
|
||||||
windPhases: number[],
|
|
||||||
basePosition: THREE.Vector3,
|
|
||||||
yaw: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
baseColor: THREE.Color,
|
|
||||||
tipColor: THREE.Color,
|
|
||||||
windPhase: number,
|
|
||||||
): void {
|
|
||||||
const rightX = Math.cos(yaw) * width * 0.5;
|
|
||||||
const rightZ = Math.sin(yaw) * width * 0.5;
|
|
||||||
const leanX = Math.cos(yaw + Math.PI * 0.5) * width * 0.22;
|
|
||||||
const leanZ = Math.sin(yaw + Math.PI * 0.5) * width * 0.22;
|
|
||||||
const vertexData: GrassBladeVertexData[] = [
|
|
||||||
{
|
|
||||||
position: [
|
|
||||||
basePosition.x - rightX,
|
|
||||||
basePosition.y,
|
|
||||||
basePosition.z - rightZ,
|
|
||||||
],
|
|
||||||
color: [baseColor.r, baseColor.g, baseColor.b],
|
|
||||||
heightFactor: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
position: [
|
|
||||||
basePosition.x + rightX,
|
|
||||||
basePosition.y,
|
|
||||||
basePosition.z + rightZ,
|
|
||||||
],
|
|
||||||
color: [baseColor.r, baseColor.g, baseColor.b],
|
|
||||||
heightFactor: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
position: [
|
|
||||||
basePosition.x + leanX,
|
|
||||||
basePosition.y + height,
|
|
||||||
basePosition.z + leanZ,
|
|
||||||
],
|
|
||||||
color: [tipColor.r, tipColor.g, tipColor.b],
|
|
||||||
heightFactor: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const vertex of vertexData) {
|
|
||||||
positions.push(...vertex.position);
|
|
||||||
colors.push(...vertex.color);
|
|
||||||
bladeBases.push(basePosition.x, basePosition.y, basePosition.z);
|
|
||||||
heightFactors.push(vertex.heightFactor);
|
|
||||||
windPhases.push(windPhase);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGrassGeometry(
|
|
||||||
chunkX: number,
|
|
||||||
chunkZ: number,
|
|
||||||
density: number,
|
|
||||||
terrainSurfaceData: TerrainSurfaceData,
|
|
||||||
getHeight: (x: number, z: number) => number | null,
|
|
||||||
): THREE.BufferGeometry | null {
|
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
const colors: number[] = [];
|
const colors: number[] = [];
|
||||||
const bladeBases: number[] = [];
|
const uvs: number[] = [];
|
||||||
const heightFactors: number[] = [];
|
const bladeOrigins: number[] = [];
|
||||||
const windPhases: number[] = [];
|
const yaws: number[] = [];
|
||||||
const baseColor = new THREE.Color(GRASS_CONFIG.baseColor);
|
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
|
||||||
const startX = chunkX * GRASS_CONFIG.chunkSize;
|
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
||||||
const startZ = chunkZ * GRASS_CONFIG.chunkSize;
|
|
||||||
const endX = startX + GRASS_CONFIG.chunkSize;
|
|
||||||
const endZ = startZ + GRASS_CONFIG.chunkSize;
|
|
||||||
const bladeBudget = Math.round(GRASS_CONFIG.maxBladesPerChunk * density);
|
|
||||||
let bladeCount = 0;
|
|
||||||
|
|
||||||
for (let x = startX; x < endX; x += GRASS_CONFIG.sampleStep) {
|
for (let index = 0; index < bladeCount; index++) {
|
||||||
for (let z = startZ; z < endZ; z += GRASS_CONFIG.sampleStep) {
|
const seed = index * 997;
|
||||||
for (
|
const origin = new THREE.Vector3(
|
||||||
let bladeIndex = 0;
|
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||||
bladeIndex < GRASS_CONFIG.bladesPerCell;
|
0,
|
||||||
bladeIndex++
|
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||||
) {
|
);
|
||||||
if (bladeCount >= bladeBudget) break;
|
const yawAngle = random01(seed + 3) * Math.PI * 2;
|
||||||
|
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
|
||||||
|
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
|
||||||
|
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
|
||||||
|
const markerColors = [
|
||||||
|
new THREE.Color(0.1, 0, 0),
|
||||||
|
new THREE.Color(0, 0, 0.1),
|
||||||
|
new THREE.Color(1, 1, 1),
|
||||||
|
] as const;
|
||||||
|
const uv = new THREE.Vector2(
|
||||||
|
origin.x / GRASS_CONFIG.patchSize + 0.5,
|
||||||
|
origin.z / GRASS_CONFIG.patchSize + 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
const seed =
|
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
||||||
(chunkX + 101) * 92821 +
|
pushVector(positions, origin);
|
||||||
(chunkZ + 103) * 68917 +
|
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
|
||||||
Math.round(x * 13) * 193 +
|
pushVector(bladeOrigins, origin);
|
||||||
Math.round(z * 17) * 389 +
|
pushVector(yaws, yaw);
|
||||||
bladeIndex * 997;
|
pushColor(colors, color);
|
||||||
if (random01(seed) > density) continue;
|
uvs.push(uv.x, uv.y);
|
||||||
|
|
||||||
const sampleX = x + (random01(seed + 1) - 0.5) * GRASS_CONFIG.jitter;
|
|
||||||
const sampleZ = z + (random01(seed + 2) - 0.5) * GRASS_CONFIG.jitter;
|
|
||||||
const sample = sampleTerrainSurfaceAtXZ(
|
|
||||||
terrainSurfaceData.imageData,
|
|
||||||
sampleX,
|
|
||||||
sampleZ,
|
|
||||||
terrainSurfaceData.bounds,
|
|
||||||
TERRAIN_SURFACE_PROJECTION,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sample.key || !GRASS_SURFACE_KEYS.has(sample.key as never))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const height = getHeight(sampleX, sampleZ);
|
|
||||||
if (height === null) continue;
|
|
||||||
|
|
||||||
const heightRatio = random01(seed + 3);
|
|
||||||
const widthRatio = random01(seed + 4);
|
|
||||||
const tipColor = new THREE.Color(getGrassTipColor(sample.key));
|
|
||||||
const basePosition = new THREE.Vector3(
|
|
||||||
sampleX,
|
|
||||||
height + GRASS_CONFIG.surfaceOffset,
|
|
||||||
sampleZ,
|
|
||||||
);
|
|
||||||
|
|
||||||
addGrassBlade(
|
|
||||||
positions,
|
|
||||||
colors,
|
|
||||||
bladeBases,
|
|
||||||
heightFactors,
|
|
||||||
windPhases,
|
|
||||||
basePosition,
|
|
||||||
random01(seed + 5) * Math.PI * 2,
|
|
||||||
GRASS_CONFIG.bladeWidth * lerp(0.75, 1.25, widthRatio),
|
|
||||||
lerp(
|
|
||||||
GRASS_CONFIG.minBladeHeight,
|
|
||||||
GRASS_CONFIG.maxBladeHeight,
|
|
||||||
heightRatio,
|
|
||||||
),
|
|
||||||
baseColor,
|
|
||||||
tipColor,
|
|
||||||
random01(seed + 6) * Math.PI * 2,
|
|
||||||
);
|
|
||||||
bladeCount += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bladeCount === 0) return null;
|
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
const markerColorValues: number[] = [];
|
||||||
|
const bladeColorValues: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < colors.length; index += 6) {
|
||||||
|
markerColorValues.push(
|
||||||
|
colors[index] ?? 0,
|
||||||
|
colors[index + 1] ?? 0,
|
||||||
|
colors[index + 2] ?? 0,
|
||||||
|
);
|
||||||
|
bladeColorValues.push(
|
||||||
|
colors[index + 3] ?? 0,
|
||||||
|
colors[index + 4] ?? 0,
|
||||||
|
colors[index + 5] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"position",
|
"position",
|
||||||
new THREE.Float32BufferAttribute(positions, 3),
|
new THREE.Float32BufferAttribute(positions, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aBladeBase",
|
"color",
|
||||||
new THREE.Float32BufferAttribute(bladeBases, 3),
|
new THREE.Float32BufferAttribute(markerColorValues, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aHeightFactor",
|
"aBladeColor",
|
||||||
new THREE.Float32BufferAttribute(heightFactors, 1),
|
new THREE.Float32BufferAttribute(bladeColorValues, 3),
|
||||||
);
|
);
|
||||||
|
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aWindPhase",
|
"aBladeOrigin",
|
||||||
new THREE.Float32BufferAttribute(windPhases, 1),
|
new THREE.Float32BufferAttribute(bladeOrigins, 3),
|
||||||
);
|
);
|
||||||
|
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGrassMaterial(
|
||||||
|
terrainSampler: TerrainGrassSampler,
|
||||||
|
noiseTexture: THREE.Texture,
|
||||||
|
grassTexture: THREE.Texture,
|
||||||
|
): THREE.ShaderMaterial {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
vertexShader: grassVertexShader,
|
||||||
|
fragmentShader: grassFragmentShader,
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uNoiseTexture: { value: noiseTexture },
|
||||||
|
uDiffuseMap: { value: grassTexture },
|
||||||
|
uHeightMap: { value: terrainSampler.heightTexture },
|
||||||
|
uPlayerPosition: { value: new THREE.Vector3() },
|
||||||
|
uBaseBladeColor: { value: new THREE.Color(GRASS_BASE_COLOR) },
|
||||||
|
uBoundingBoxMin: {
|
||||||
|
value: new THREE.Vector3(
|
||||||
|
terrainSampler.bounds.minX,
|
||||||
|
terrainSampler.minHeight,
|
||||||
|
terrainSampler.bounds.minZ,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uBoundingBoxMax: {
|
||||||
|
value: new THREE.Vector3(
|
||||||
|
terrainSampler.bounds.maxX,
|
||||||
|
terrainSampler.maxHeight,
|
||||||
|
terrainSampler.bounds.maxZ,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uPatchSize: { value: GRASS_CONFIG.patchSize },
|
||||||
|
uBladeWidth: { value: GRASS_CONFIG.bladeWidth },
|
||||||
|
uWindDirection: { value: 0 },
|
||||||
|
uWindSpeed: { value: 0 },
|
||||||
|
uWindNoiseScale: { value: GRASS_CONFIG.windNoiseScale },
|
||||||
|
uWindStrength: { value: GRASS_CONFIG.windStrength },
|
||||||
|
uBaldPatchModifier: { value: GRASS_CONFIG.baldPatchModifier },
|
||||||
|
uFalloffSharpness: { value: GRASS_CONFIG.falloffSharpness },
|
||||||
|
uHeightNoiseFrequency: { value: GRASS_CONFIG.heightNoiseFrequency },
|
||||||
|
uHeightNoiseAmplitude: { value: GRASS_CONFIG.heightNoiseAmplitude },
|
||||||
|
uClumpFrequency: { value: GRASS_CONFIG.clumpFrequency },
|
||||||
|
uClumpThreshold: { value: GRASS_CONFIG.clumpThreshold },
|
||||||
|
uClumpSoftness: { value: GRASS_CONFIG.clumpSoftness },
|
||||||
|
uZoneFrequency: { value: GRASS_CONFIG.zoneFrequency },
|
||||||
|
uNoGrassZoneThreshold: { value: GRASS_CONFIG.noGrassZoneThreshold },
|
||||||
|
uSparseZoneThreshold: { value: GRASS_CONFIG.sparseZoneThreshold },
|
||||||
|
uMediumZoneThreshold: { value: GRASS_CONFIG.mediumZoneThreshold },
|
||||||
|
uZoneSoftness: { value: GRASS_CONFIG.zoneSoftness },
|
||||||
|
uNoGrassZoneHeight: { value: GRASS_CONFIG.noGrassZoneHeight },
|
||||||
|
uSparseZoneHeight: { value: GRASS_CONFIG.sparseZoneHeight },
|
||||||
|
uMediumZoneHeight: { value: GRASS_CONFIG.mediumZoneHeight },
|
||||||
|
uTallZoneHeight: { value: GRASS_CONFIG.tallZoneHeight },
|
||||||
|
uNoGrassZoneDensity: { value: GRASS_CONFIG.noGrassZoneDensity },
|
||||||
|
uSparseZoneDensity: { value: GRASS_CONFIG.sparseZoneDensity },
|
||||||
|
uMediumZoneDensity: { value: GRASS_CONFIG.mediumZoneDensity },
|
||||||
|
uTallZoneDensity: { value: GRASS_CONFIG.tallZoneDensity },
|
||||||
|
uMaxBendAngle: { value: GRASS_CONFIG.maxBendAngle },
|
||||||
|
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
||||||
|
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
||||||
|
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function GrassPatch({
|
export function GrassPatch({
|
||||||
chunkX,
|
|
||||||
chunkZ,
|
|
||||||
density,
|
density,
|
||||||
terrainSurfaceData,
|
terrainSampler,
|
||||||
}: GrassPatchProps): React.JSX.Element | null {
|
}: GrassPatchProps): React.JSX.Element {
|
||||||
const terrainHeight = useTerrainHeightSampler();
|
const camera = useThree((state) => state.camera);
|
||||||
const wind = useWind();
|
const wind = useWind();
|
||||||
|
const [noiseTexture, grassTexture] = useTexture([
|
||||||
|
"/textures/grass/noise.png",
|
||||||
|
"/textures/grass/grass.jpg",
|
||||||
|
]) as [THREE.Texture, THREE.Texture];
|
||||||
|
const grassTextures = useMemo(() => {
|
||||||
|
const noise = noiseTexture.clone();
|
||||||
|
const grass = grassTexture.clone();
|
||||||
|
|
||||||
|
noise.wrapS = noise.wrapT = THREE.RepeatWrapping;
|
||||||
|
grass.wrapS = grass.wrapT = THREE.MirroredRepeatWrapping;
|
||||||
|
noise.needsUpdate = true;
|
||||||
|
grass.needsUpdate = true;
|
||||||
|
|
||||||
|
return { grass, noise };
|
||||||
|
}, [grassTexture, noiseTexture]);
|
||||||
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
|
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(() => createGrassGeometry(density), [density]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
grassTextures.grass.dispose();
|
||||||
|
grassTextures.noise.dispose();
|
||||||
|
};
|
||||||
|
}, [grassTextures]);
|
||||||
|
|
||||||
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createGrassGeometry(
|
createGrassMaterial(
|
||||||
chunkX,
|
terrainSampler,
|
||||||
chunkZ,
|
grassTextures.noise,
|
||||||
density,
|
grassTextures.grass,
|
||||||
terrainSurfaceData,
|
|
||||||
terrainHeight.getHeight,
|
|
||||||
),
|
),
|
||||||
[chunkX, chunkZ, density, terrainHeight.getHeight, terrainSurfaceData],
|
[grassTextures, terrainSampler],
|
||||||
);
|
);
|
||||||
const material = useMemo(() => createGrassMaterial(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
materialRef.current = material;
|
materialRef.current = material;
|
||||||
@@ -255,7 +229,7 @@ export function GrassPatch({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
geometry?.dispose();
|
geometry.dispose();
|
||||||
};
|
};
|
||||||
}, [geometry]);
|
}, [geometry]);
|
||||||
|
|
||||||
@@ -265,16 +239,16 @@ export function GrassPatch({
|
|||||||
|
|
||||||
const uniforms = currentMaterial.uniforms;
|
const uniforms = currentMaterial.uniforms;
|
||||||
if (uniforms.uTime) uniforms.uTime.value = clock.elapsedTime;
|
if (uniforms.uTime) uniforms.uTime.value = clock.elapsedTime;
|
||||||
|
if (uniforms.uPlayerPosition) {
|
||||||
|
uniforms.uPlayerPosition.value.copy(camera.position);
|
||||||
|
}
|
||||||
if (uniforms.uWindDirection) uniforms.uWindDirection.value = wind.direction;
|
if (uniforms.uWindDirection) uniforms.uWindDirection.value = wind.direction;
|
||||||
if (uniforms.uWindSpeed) uniforms.uWindSpeed.value = wind.speed;
|
if (uniforms.uWindSpeed) uniforms.uWindSpeed.value = wind.speed;
|
||||||
if (uniforms.uWindStrength) uniforms.uWindStrength.value = wind.strength;
|
|
||||||
if (uniforms.uWindNoiseScale) {
|
if (uniforms.uWindNoiseScale) {
|
||||||
uniforms.uWindNoiseScale.value =
|
uniforms.uWindNoiseScale.value =
|
||||||
GRASS_CONFIG.windNoiseScale * wind.noiseScale;
|
GRASS_CONFIG.windNoiseScale * wind.noiseScale;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!geometry) return null;
|
return <mesh geometry={geometry} material={material} frustumCulled={false} />;
|
||||||
|
|
||||||
return <mesh geometry={geometry} material={material} frustumCulled />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,25 @@
|
|||||||
import { Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { Suspense } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
|
||||||
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
|
|
||||||
import {
|
import {
|
||||||
useDynamicGrass,
|
useDynamicGrass,
|
||||||
useGrassDensity,
|
useGrassDensity,
|
||||||
} from "@/hooks/world/useGraphicsSettings";
|
} from "@/hooks/world/useGraphicsSettings";
|
||||||
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
|
||||||
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
||||||
import { GrassPatch } from "@/world/grass/GrassPatch";
|
import { GrassPatch } from "@/world/grass/GrassPatch";
|
||||||
|
import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||||
interface GrassChunk {
|
|
||||||
centerX: number;
|
|
||||||
centerZ: number;
|
|
||||||
key: string;
|
|
||||||
x: number;
|
|
||||||
z: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChunkRange(min: number, max: number): number[] {
|
|
||||||
const start = Math.floor(min / GRASS_CONFIG.chunkSize);
|
|
||||||
const end = Math.floor(max / GRASS_CONFIG.chunkSize);
|
|
||||||
const chunks: number[] = [];
|
|
||||||
|
|
||||||
for (let value = start; value <= end; value++) {
|
|
||||||
chunks.push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGrassChunks(bounds: TerrainSurfaceBounds): GrassChunk[] {
|
|
||||||
const chunks: GrassChunk[] = [];
|
|
||||||
const xChunks = getChunkRange(bounds.minX, bounds.maxX);
|
|
||||||
const zChunks = getChunkRange(bounds.minZ, bounds.maxZ);
|
|
||||||
|
|
||||||
for (const x of xChunks) {
|
|
||||||
for (const z of zChunks) {
|
|
||||||
chunks.push({
|
|
||||||
centerX: x * GRASS_CONFIG.chunkSize + GRASS_CONFIG.chunkSize * 0.5,
|
|
||||||
centerZ: z * GRASS_CONFIG.chunkSize + GRASS_CONFIG.chunkSize * 0.5,
|
|
||||||
key: `${x}:${z}`,
|
|
||||||
x,
|
|
||||||
z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GrassSystem(): React.JSX.Element | null {
|
export function GrassSystem(): React.JSX.Element | null {
|
||||||
const camera = useThree((state) => state.camera);
|
const terrainSampler = useTerrainGrassSampler();
|
||||||
const terrainSurfaceData = useTerrainSurfaceData();
|
|
||||||
const sceneMode = useSceneMode();
|
|
||||||
const dynamicGrass = useDynamicGrass();
|
const dynamicGrass = useDynamicGrass();
|
||||||
const grassDensity = useGrassDensity();
|
const grassDensity = useGrassDensity();
|
||||||
const lastUpdateRef = useRef(-GRASS_CONFIG.updateInterval);
|
|
||||||
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const density = Math.max(0, grassDensity);
|
const density = Math.max(0, grassDensity);
|
||||||
const chunks = useMemo(
|
|
||||||
() =>
|
|
||||||
terrainSurfaceData ? createGrassChunks(terrainSurfaceData.bounds) : [],
|
|
||||||
[terrainSurfaceData],
|
|
||||||
);
|
|
||||||
const streamingEnabled = sceneMode === "game";
|
|
||||||
|
|
||||||
const updateActiveChunks = useCallback(() => {
|
if (!GRASS_CONFIG.enabled || !dynamicGrass || density <= 0) {
|
||||||
const nextKeys = new Set<string>();
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
const distance = Math.hypot(
|
|
||||||
chunk.centerX - camera.position.x,
|
|
||||||
chunk.centerZ - camera.position.z,
|
|
||||||
);
|
|
||||||
const wasActive = activeChunkKeys.has(chunk.key);
|
|
||||||
const radius = wasActive
|
|
||||||
? GRASS_CONFIG.unloadRadius
|
|
||||||
: GRASS_CONFIG.loadRadius;
|
|
||||||
|
|
||||||
if (distance <= radius) {
|
|
||||||
nextKeys.add(chunk.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextKeys.size === activeChunkKeys.size &&
|
|
||||||
[...nextKeys].every((key) => activeChunkKeys.has(key))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveChunkKeys(nextKeys);
|
|
||||||
}, [activeChunkKeys, camera, chunks]);
|
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!streamingEnabled) return;
|
|
||||||
|
|
||||||
const now = clock.elapsedTime * 1000;
|
|
||||||
if (now - lastUpdateRef.current < GRASS_CONFIG.updateInterval) return;
|
|
||||||
lastUpdateRef.current = now;
|
|
||||||
|
|
||||||
updateActiveChunks();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!GRASS_CONFIG.enabled ||
|
|
||||||
!dynamicGrass ||
|
|
||||||
density <= 0 ||
|
|
||||||
!terrainSurfaceData
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleChunks = streamingEnabled
|
|
||||||
? chunks.filter((chunk) => {
|
|
||||||
if (activeChunkKeys.size > 0) {
|
|
||||||
return activeChunkKeys.has(chunk.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
Math.hypot(
|
|
||||||
chunk.centerX - camera.position.x,
|
|
||||||
chunk.centerZ - camera.position.z,
|
|
||||||
) <= GRASS_CONFIG.loadRadius
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: chunks;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group name="grass-system">
|
<Suspense fallback={null}>
|
||||||
{visibleChunks.map((chunk) => (
|
<GrassPatch density={density} terrainSampler={terrainSampler} />
|
||||||
<Suspense key={chunk.key} fallback={null}>
|
</Suspense>
|
||||||
<GrassPatch
|
|
||||||
chunkX={chunk.x}
|
|
||||||
chunkZ={chunk.z}
|
|
||||||
density={density}
|
|
||||||
terrainSurfaceData={terrainSurfaceData}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
|
||||||
|
|
||||||
export const GRASS_CONFIG = {
|
export const GRASS_CONFIG = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
chunkSize: 20,
|
patchSize: 30,
|
||||||
loadRadius: 30,
|
bladeCount: 32000,
|
||||||
unloadRadius: 34,
|
bladeWidth: 0.08,
|
||||||
updateInterval: 250,
|
maxBladeHeight: 0.56,
|
||||||
sampleStep: 1.15,
|
randomHeightAmount: 0.25,
|
||||||
jitter: 0.42,
|
surfaceOffset: 0.025,
|
||||||
bladesPerCell: 2,
|
heightTextureSize: 128,
|
||||||
maxBladesPerChunk: 720,
|
windNoiseScale: 0.9,
|
||||||
bladeWidth: 0.12,
|
windStrength: 0.35,
|
||||||
minBladeHeight: 0.42,
|
baldPatchModifier: 1.1,
|
||||||
maxBladeHeight: 0.82,
|
falloffSharpness: 0.35,
|
||||||
surfaceOffset: 0.06,
|
heightNoiseFrequency: 9,
|
||||||
baseColor: "#1f3512",
|
heightNoiseAmplitude: 1,
|
||||||
windBendStrength: 0.42,
|
clumpFrequency: 2.6,
|
||||||
windNoiseScale: 0.09,
|
clumpThreshold: 0.18,
|
||||||
|
clumpSoftness: 0.45,
|
||||||
|
zoneFrequency: 0.035,
|
||||||
|
noGrassZoneThreshold: 0.2,
|
||||||
|
sparseZoneThreshold: 0.4,
|
||||||
|
mediumZoneThreshold: 0.65,
|
||||||
|
zoneSoftness: 0.08,
|
||||||
|
noGrassZoneHeight: 0,
|
||||||
|
sparseZoneHeight: 0.08,
|
||||||
|
mediumZoneHeight: 0.45,
|
||||||
|
tallZoneHeight: 1,
|
||||||
|
noGrassZoneDensity: 0,
|
||||||
|
sparseZoneDensity: 0.08,
|
||||||
|
mediumZoneDensity: 0.72,
|
||||||
|
tallZoneDensity: 1,
|
||||||
|
maxBendAngle: 14,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const GRASS_SURFACE_KEYS = new Set([
|
export const GRASS_COLORS = ["#84C66B", "#67B058", "#A3CA5B"] as const;
|
||||||
"grass1",
|
export const GRASS_BASE_COLOR = "#1A3A1A" as const;
|
||||||
"grass2",
|
|
||||||
"grass3",
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
export function getGrassTipColor(surfaceKey: string | null): string {
|
|
||||||
if (surfaceKey === "grass1") return TERRAIN_COLORS.grass1.grassTipColor;
|
|
||||||
if (surfaceKey === "grass2") return TERRAIN_COLORS.grass2.grassTipColor;
|
|
||||||
if (surfaceKey === "grass3") return TERRAIN_COLORS.grass3.grassTipColor;
|
|
||||||
return TERRAIN_COLORS.grass1.grassTipColor;
|
|
||||||
}
|
|
||||||
|
|||||||
+149
-22
@@ -1,40 +1,167 @@
|
|||||||
export const grassVertexShader = /* glsl */ `
|
export const grassVertexShader = /* glsl */ `
|
||||||
attribute vec3 aColor;
|
attribute vec3 aYaw;
|
||||||
attribute vec3 aBladeBase;
|
attribute vec3 aBladeOrigin;
|
||||||
attribute float aHeightFactor;
|
attribute vec3 aBladeColor;
|
||||||
attribute float aWindPhase;
|
|
||||||
|
|
||||||
varying vec3 vColor;
|
varying vec3 vColor;
|
||||||
|
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
|
uniform vec3 uPlayerPosition;
|
||||||
|
uniform vec3 uBaseBladeColor;
|
||||||
|
uniform sampler2D uHeightMap;
|
||||||
|
uniform sampler2D uDiffuseMap;
|
||||||
|
uniform sampler2D uNoiseTexture;
|
||||||
|
uniform vec3 uBoundingBoxMin;
|
||||||
|
uniform vec3 uBoundingBoxMax;
|
||||||
|
uniform float uPatchSize;
|
||||||
|
uniform float uBladeWidth;
|
||||||
uniform float uWindDirection;
|
uniform float uWindDirection;
|
||||||
uniform float uWindSpeed;
|
uniform float uWindSpeed;
|
||||||
uniform float uWindStrength;
|
|
||||||
uniform float uWindNoiseScale;
|
uniform float uWindNoiseScale;
|
||||||
uniform float uBendStrength;
|
uniform float uWindStrength;
|
||||||
|
uniform float uBaldPatchModifier;
|
||||||
|
uniform float uFalloffSharpness;
|
||||||
|
uniform float uHeightNoiseFrequency;
|
||||||
|
uniform float uHeightNoiseAmplitude;
|
||||||
|
uniform float uClumpFrequency;
|
||||||
|
uniform float uClumpThreshold;
|
||||||
|
uniform float uClumpSoftness;
|
||||||
|
uniform float uZoneFrequency;
|
||||||
|
uniform float uNoGrassZoneThreshold;
|
||||||
|
uniform float uSparseZoneThreshold;
|
||||||
|
uniform float uMediumZoneThreshold;
|
||||||
|
uniform float uZoneSoftness;
|
||||||
|
uniform float uNoGrassZoneHeight;
|
||||||
|
uniform float uSparseZoneHeight;
|
||||||
|
uniform float uMediumZoneHeight;
|
||||||
|
uniform float uTallZoneHeight;
|
||||||
|
uniform float uNoGrassZoneDensity;
|
||||||
|
uniform float uSparseZoneDensity;
|
||||||
|
uniform float uMediumZoneDensity;
|
||||||
|
uniform float uTallZoneDensity;
|
||||||
|
uniform float uMaxBendAngle;
|
||||||
|
uniform float uMaxBladeHeight;
|
||||||
|
uniform float uRandomHeightAmount;
|
||||||
|
uniform float uSurfaceOffset;
|
||||||
|
|
||||||
|
float random(vec2 st) {
|
||||||
|
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat3 rotate3d(in vec3 axis, const in float angle) {
|
||||||
|
axis = normalize(axis);
|
||||||
|
float s = sin(angle);
|
||||||
|
float c = cos(angle);
|
||||||
|
float oc = 1.0 - c;
|
||||||
|
return mat3(
|
||||||
|
oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s,
|
||||||
|
oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s,
|
||||||
|
oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
float mapValue(float value, float inMin, float inMax, float outMin, float outMax) {
|
||||||
|
return mix(outMin, outMax, (value - inMin) / (inMax - inMin));
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 transformed = position;
|
vec3 transformed = position;
|
||||||
float topFactor = aHeightFactor * aHeightFactor;
|
vec3 origin = aBladeOrigin;
|
||||||
vec2 windDirection = normalize(vec2(cos(uWindDirection), sin(uWindDirection)));
|
float halfPatchSize = uPatchSize * 0.5;
|
||||||
|
|
||||||
float primaryWind = sin(
|
origin.x = mod(origin.x - uPlayerPosition.x + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||||
uTime * max(uWindSpeed, 0.05) +
|
origin.z = mod(origin.z - uPlayerPosition.z + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||||
aWindPhase +
|
|
||||||
aBladeBase.x * uWindNoiseScale +
|
vec3 worldPos = vec3(uPlayerPosition.x + origin.x, 0.0, uPlayerPosition.z + origin.z);
|
||||||
aBladeBase.z * uWindNoiseScale
|
transformed.x = worldPos.x;
|
||||||
|
transformed.z = worldPos.z;
|
||||||
|
|
||||||
|
vec2 terrainUv = vec2(
|
||||||
|
mapValue(worldPos.x, uBoundingBoxMin.x, uBoundingBoxMax.x, 0.0, 1.0),
|
||||||
|
mapValue(worldPos.z, uBoundingBoxMin.z, uBoundingBoxMax.z, 0.0, 1.0)
|
||||||
);
|
);
|
||||||
float secondaryWind = sin(
|
terrainUv = clamp(terrainUv, 0.0, 1.0);
|
||||||
uTime * max(uWindSpeed, 0.05) * 1.73 +
|
|
||||||
aWindPhase * 0.71 +
|
|
||||||
aBladeBase.x * uWindNoiseScale * 0.53 -
|
|
||||||
aBladeBase.z * uWindNoiseScale * 0.89
|
|
||||||
) * 0.35;
|
|
||||||
|
|
||||||
float bend = (primaryWind + secondaryWind) * uWindStrength * uBendStrength * topFactor;
|
float terrainHeightRatio = texture2D(uHeightMap, terrainUv).r;
|
||||||
transformed.xz += windDirection * bend;
|
float terrainHeight = mix(uBoundingBoxMin.y, uBoundingBoxMax.y, terrainHeightRatio);
|
||||||
|
transformed.y = terrainHeight + uSurfaceOffset;
|
||||||
|
|
||||||
|
vec3 heightNoise = texture2D(uNoiseTexture, terrainUv.yx * vec2(uHeightNoiseFrequency)).rgb;
|
||||||
|
float heightNoiseAverage = (heightNoise.r + heightNoise.g + heightNoise.b) / 3.0;
|
||||||
|
vec2 clumpUv = (worldPos.xz / uPatchSize) * uClumpFrequency;
|
||||||
|
float clumpNoise = texture2D(uNoiseTexture, clumpUv).r;
|
||||||
|
float clumpMask = smoothstep(uClumpThreshold, uClumpThreshold + uClumpSoftness, clumpNoise);
|
||||||
|
float zoneNoise = texture2D(uNoiseTexture, worldPos.xz * uZoneFrequency).r;
|
||||||
|
float noGrassZone = 1.0 - smoothstep(uNoGrassZoneThreshold, uNoGrassZoneThreshold + uZoneSoftness, zoneNoise);
|
||||||
|
float sparseZone =
|
||||||
|
smoothstep(uNoGrassZoneThreshold, uNoGrassZoneThreshold + uZoneSoftness, zoneNoise) *
|
||||||
|
(1.0 - smoothstep(uSparseZoneThreshold, uSparseZoneThreshold + uZoneSoftness, zoneNoise));
|
||||||
|
float mediumZone =
|
||||||
|
smoothstep(uSparseZoneThreshold, uSparseZoneThreshold + uZoneSoftness, zoneNoise) *
|
||||||
|
(1.0 - smoothstep(uMediumZoneThreshold, uMediumZoneThreshold + uZoneSoftness, zoneNoise));
|
||||||
|
float tallZone = smoothstep(uMediumZoneThreshold, uMediumZoneThreshold + uZoneSoftness, zoneNoise);
|
||||||
|
float zoneHeight =
|
||||||
|
noGrassZone * uNoGrassZoneHeight +
|
||||||
|
sparseZone * uSparseZoneHeight +
|
||||||
|
mediumZone * uMediumZoneHeight +
|
||||||
|
tallZone * uTallZoneHeight;
|
||||||
|
float zoneDensity =
|
||||||
|
noGrassZone * uNoGrassZoneDensity +
|
||||||
|
sparseZone * uSparseZoneDensity +
|
||||||
|
mediumZone * uMediumZoneDensity +
|
||||||
|
tallZone * uTallZoneDensity;
|
||||||
|
float bladeVisibility = step(random(worldPos.xz), zoneDensity);
|
||||||
|
float heightModifier = uMaxBladeHeight * mix(0.35, 1.0, heightNoiseAverage) * uHeightNoiseAmplitude;
|
||||||
|
heightModifier += random(terrainUv) * (uRandomHeightAmount * 0.1);
|
||||||
|
heightModifier = clamp(heightModifier, 0.0, uMaxBladeHeight);
|
||||||
|
heightModifier *= zoneHeight * bladeVisibility;
|
||||||
|
|
||||||
|
float edgeDistanceX = abs(origin.x) / halfPatchSize;
|
||||||
|
float edgeDistanceZ = abs(origin.z) / halfPatchSize;
|
||||||
|
float edgeFactor = 1.0 - max(edgeDistanceX, edgeDistanceZ);
|
||||||
|
edgeFactor = pow(clamp(edgeFactor, 0.0, 1.0), uFalloffSharpness);
|
||||||
|
|
||||||
|
float baldPatchOffset = heightNoise.r * (uBaldPatchModifier * (1.0 - edgeFactor));
|
||||||
|
heightModifier -= baldPatchOffset;
|
||||||
|
heightModifier = max(heightModifier, 0.0);
|
||||||
|
|
||||||
|
float edgeFade =
|
||||||
|
smoothstep(uBoundingBoxMin.x, uBoundingBoxMin.x + 2.0, worldPos.x) *
|
||||||
|
smoothstep(uBoundingBoxMax.x, uBoundingBoxMax.x - 2.0, worldPos.x) *
|
||||||
|
smoothstep(uBoundingBoxMin.z, uBoundingBoxMin.z + 2.0, worldPos.z) *
|
||||||
|
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
|
||||||
|
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
|
||||||
|
|
||||||
|
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
|
||||||
|
float tipFactor = color.g;
|
||||||
|
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
|
||||||
|
transformed += aYaw * (width / 2.0) * sideFactor;
|
||||||
|
|
||||||
|
vColor = mix(uBaseBladeColor, aBladeColor, tipFactor);
|
||||||
|
|
||||||
|
float distanceFromCenter = length(origin.xz) / halfPatchSize;
|
||||||
|
float innerCircleFactor = clamp(smoothstep(0.0, 0.5, distanceFromCenter), 0.0, 1.0);
|
||||||
|
heightModifier *= mix(0.25, 1.0, innerCircleFactor);
|
||||||
|
|
||||||
|
float noiseScale = uWindNoiseScale * 0.1;
|
||||||
|
vec2 noiseUV = vec2(origin.x * noiseScale, origin.z * noiseScale);
|
||||||
|
mat2 rotation = mat2(
|
||||||
|
cos(uWindDirection), -sin(uWindDirection),
|
||||||
|
sin(uWindDirection), cos(uWindDirection)
|
||||||
|
);
|
||||||
|
vec2 rotatedNoiseUV = rotation * noiseUV + uTime * vec2(uWindSpeed);
|
||||||
|
vec3 windNoise = texture2D(uNoiseTexture, rotatedNoiseUV).rgb;
|
||||||
|
|
||||||
|
vec3 axis = vec3(windNoise.g, 0.0, windNoise.b);
|
||||||
|
float angle = radians(mapValue(windNoise.g + windNoise.b, 0.0, 2.0, -uMaxBendAngle, uMaxBendAngle)) * tipFactor * uWindStrength;
|
||||||
|
mat3 rotationMatrix = rotate3d(axis, angle);
|
||||||
|
|
||||||
|
vec3 basePosition = vec3(transformed.x, transformed.y - heightModifier, transformed.z);
|
||||||
|
vec3 relativePosition = transformed - basePosition;
|
||||||
|
relativePosition = rotationMatrix * relativePosition;
|
||||||
|
transformed = basePosition + relativePosition;
|
||||||
|
transformed.y += heightModifier * tipFactor;
|
||||||
|
|
||||||
vColor = aColor;
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||||
|
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
|
||||||
|
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
||||||
|
|
||||||
|
const RAYCAST_Y = 500;
|
||||||
|
const RAYCAST_FAR = 1000;
|
||||||
|
const DOWN = new THREE.Vector3(0, -1, 0);
|
||||||
|
const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0];
|
||||||
|
const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||||
|
const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1];
|
||||||
|
|
||||||
|
export interface TerrainGrassSample {
|
||||||
|
normal: THREE.Vector3;
|
||||||
|
position: THREE.Vector3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerrainGrassSampler {
|
||||||
|
bounds: TerrainSurfaceBounds;
|
||||||
|
heightTexture: THREE.DataTexture;
|
||||||
|
maxHeight: number;
|
||||||
|
minHeight: number;
|
||||||
|
sample: (x: number, z: number) => TerrainGrassSample | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackBounds(): TerrainSurfaceBounds {
|
||||||
|
return {
|
||||||
|
minX: -120,
|
||||||
|
maxX: 120,
|
||||||
|
minZ: -120,
|
||||||
|
maxZ: 120,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerrainMatrix(
|
||||||
|
position: Vector3Tuple,
|
||||||
|
rotation: Vector3Tuple,
|
||||||
|
scale: Vector3Tuple,
|
||||||
|
): THREE.Matrix4 {
|
||||||
|
return new THREE.Matrix4().compose(
|
||||||
|
new THREE.Vector3(...position),
|
||||||
|
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
|
||||||
|
new THREE.Vector3(...scale),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerrainGrassSampler(
|
||||||
|
scene: THREE.Object3D,
|
||||||
|
position: Vector3Tuple,
|
||||||
|
rotation: Vector3Tuple,
|
||||||
|
scale: Vector3Tuple,
|
||||||
|
): TerrainGrassSampler {
|
||||||
|
const meshes: THREE.Mesh[] = [];
|
||||||
|
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
|
||||||
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
|
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
|
||||||
|
const raycaster = new THREE.Raycaster(
|
||||||
|
new THREE.Vector3(),
|
||||||
|
DOWN,
|
||||||
|
0,
|
||||||
|
RAYCAST_FAR,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
meshes.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const terrainBounds = new THREE.Box3().setFromObject(scene);
|
||||||
|
if (!terrainBounds.isEmpty()) {
|
||||||
|
terrainBounds.applyMatrix4(terrainMatrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = terrainBounds.isEmpty()
|
||||||
|
? createFallbackBounds()
|
||||||
|
: {
|
||||||
|
minX: terrainBounds.min.x,
|
||||||
|
maxX: terrainBounds.max.x,
|
||||||
|
minZ: terrainBounds.min.z,
|
||||||
|
maxZ: terrainBounds.max.z,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
||||||
|
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
||||||
|
inverseTerrainMatrix,
|
||||||
|
);
|
||||||
|
const localDirection =
|
||||||
|
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
|
||||||
|
raycaster.set(localOrigin, localDirection);
|
||||||
|
const hit = raycaster.intersectObjects(meshes, false)[0];
|
||||||
|
if (!hit) return null;
|
||||||
|
|
||||||
|
const normal = hit.face?.normal
|
||||||
|
.clone()
|
||||||
|
.transformDirection(hit.object.matrixWorld)
|
||||||
|
.applyMatrix3(normalMatrix)
|
||||||
|
.normalize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
||||||
|
normal: normal ?? new THREE.Vector3(0, 1, 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { heightTexture, maxHeight, minHeight } = createTerrainHeightTexture(
|
||||||
|
bounds,
|
||||||
|
sample,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bounds,
|
||||||
|
heightTexture,
|
||||||
|
maxHeight,
|
||||||
|
minHeight,
|
||||||
|
sample,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerrainHeightTexture(
|
||||||
|
bounds: TerrainSurfaceBounds,
|
||||||
|
sample: (x: number, z: number) => TerrainGrassSample | null,
|
||||||
|
): { heightTexture: THREE.DataTexture; maxHeight: number; minHeight: number } {
|
||||||
|
const size = GRASS_CONFIG.heightTextureSize;
|
||||||
|
const heights = new Float32Array(size * size);
|
||||||
|
let minHeight = Number.POSITIVE_INFINITY;
|
||||||
|
let maxHeight = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let zIndex = 0; zIndex < size; zIndex++) {
|
||||||
|
for (let xIndex = 0; xIndex < size; xIndex++) {
|
||||||
|
const xRatio = size <= 1 ? 0 : xIndex / (size - 1);
|
||||||
|
const zRatio = size <= 1 ? 0 : zIndex / (size - 1);
|
||||||
|
const x = bounds.minX + (bounds.maxX - bounds.minX) * xRatio;
|
||||||
|
const z = bounds.minZ + (bounds.maxZ - bounds.minZ) * zRatio;
|
||||||
|
const terrainSample = sample(x, z);
|
||||||
|
const height = terrainSample?.position.y ?? 0;
|
||||||
|
const index = zIndex * size + xIndex;
|
||||||
|
|
||||||
|
heights[index] = height;
|
||||||
|
minHeight = Math.min(minHeight, height);
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) {
|
||||||
|
minHeight = 0;
|
||||||
|
maxHeight = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = Math.max(maxHeight - minHeight, 0.0001);
|
||||||
|
const data = new Uint8Array(size * size);
|
||||||
|
|
||||||
|
for (let index = 0; index < heights.length; index++) {
|
||||||
|
data[index] = Math.round(
|
||||||
|
(((heights[index] ?? minHeight) - minHeight) / range) * 255,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightTexture = new THREE.DataTexture(
|
||||||
|
data,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
THREE.RedFormat,
|
||||||
|
THREE.UnsignedByteType,
|
||||||
|
);
|
||||||
|
heightTexture.magFilter = THREE.LinearFilter;
|
||||||
|
heightTexture.minFilter = THREE.LinearFilter;
|
||||||
|
heightTexture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
heightTexture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
heightTexture.needsUpdate = true;
|
||||||
|
|
||||||
|
return { heightTexture, maxHeight, minHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTerrainGrassSampler(): TerrainGrassSampler {
|
||||||
|
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||||
|
const terrainNode = getMapNodesByName("terrain")[0];
|
||||||
|
const position = terrainNode?.position ?? DEFAULT_TERRAIN_POSITION;
|
||||||
|
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
|
||||||
|
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => createTerrainGrassSampler(scene, position, rotation, scale),
|
||||||
|
[position, rotation, scale, scene],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
|
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||||
import { InteractionManager } from "@/managers/InteractionManager";
|
import { InteractionManager } from "@/managers/InteractionManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
@@ -47,6 +48,10 @@ const DEFAULT_KEYS: Keys = {
|
|||||||
jump: false,
|
jump: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PLAYER_COLLISION_ITERATIONS = 3;
|
||||||
|
const PLAYER_FLOOR_NORMAL_MIN = 0.15;
|
||||||
|
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
||||||
|
|
||||||
interface PlayerControllerProps {
|
interface PlayerControllerProps {
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
spawnPosition: Vector3Tuple;
|
spawnPosition: Vector3Tuple;
|
||||||
@@ -113,12 +118,17 @@ function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCapsuleFootY(capsule: Capsule): number {
|
||||||
|
return capsule.start.y - capsule.radius;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayerController({
|
export function PlayerController({
|
||||||
octree,
|
octree,
|
||||||
spawnPosition,
|
spawnPosition,
|
||||||
}: PlayerControllerProps): null {
|
}: PlayerControllerProps): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const movementLocked = useRepairMovementLocked();
|
const movementLocked = useRepairMovementLocked();
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
const movementLockedRef = useRef(movementLocked);
|
const movementLockedRef = useRef(movementLocked);
|
||||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||||
const velocity = useRef(new THREE.Vector3());
|
const velocity = useRef(new THREE.Vector3());
|
||||||
@@ -300,17 +310,26 @@ export function PlayerController({
|
|||||||
capsule.current.translate(_translateVec);
|
capsule.current.translate(_translateVec);
|
||||||
|
|
||||||
if (octree) {
|
if (octree) {
|
||||||
const result = octree.capsuleIntersect(capsule.current);
|
|
||||||
onFloor.current = false;
|
onFloor.current = false;
|
||||||
|
|
||||||
if (result) {
|
for (let index = 0; index < PLAYER_COLLISION_ITERATIONS; index++) {
|
||||||
onFloor.current = result.normal.y > 0;
|
const result = octree.capsuleIntersect(capsule.current);
|
||||||
|
if (!result) break;
|
||||||
|
|
||||||
if (!onFloor.current) {
|
const isFloorCollision = result.normal.y > PLAYER_FLOOR_NORMAL_MIN;
|
||||||
const vn = result.normal.dot(velocity.current);
|
onFloor.current ||= isFloorCollision;
|
||||||
velocity.current.addScaledVector(result.normal, -vn);
|
const normalVelocity = result.normal.dot(velocity.current);
|
||||||
} else {
|
|
||||||
|
if (!isFloorCollision && normalVelocity < 0) {
|
||||||
|
velocity.current.addScaledVector(result.normal, -normalVelocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFloorCollision) {
|
||||||
velocity.current.y = Math.max(0, velocity.current.y);
|
velocity.current.y = Math.max(0, velocity.current.y);
|
||||||
|
capsule.current.translate(
|
||||||
|
_collisionCorrection.set(0, result.depth / result.normal.y, 0),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
capsule.current.translate(
|
capsule.current.translate(
|
||||||
@@ -319,6 +338,22 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groundHeight = terrainHeight.getHeight(
|
||||||
|
capsule.current.end.x,
|
||||||
|
capsule.current.end.z,
|
||||||
|
);
|
||||||
|
if (groundHeight !== null && velocity.current.y <= 0) {
|
||||||
|
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
|
||||||
|
|
||||||
|
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
|
||||||
|
capsule.current.translate(
|
||||||
|
_collisionCorrection.set(0, -groundOffset, 0),
|
||||||
|
);
|
||||||
|
velocity.current.y = 0;
|
||||||
|
onFloor.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user