Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fc417be6 | |||
| 57498b9bb1 | |||
| b87a7e929c | |||
| ea23b4bb46 | |||
| 3881e38a6d | |||
| 7a72743e5c | |||
| 65651405b6 | |||
| 81cd935bba | |||
| fe989c9550 |
@@ -52,7 +52,7 @@ src/
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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/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`.
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
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
|
||||
|
||||
- Click: select a node.
|
||||
- `Shift` + right click: add or remove a node from the multi-selection.
|
||||
- `Esc`: clear selection.
|
||||
- Click empty space: clear 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.
|
||||
- `S`: scale mode.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+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.
|
||||
2. Click an object in the scene to select it.
|
||||
3. Choose a transform mode: translate, rotate, or scale.
|
||||
4. Drag the transform gizmo in the 3D view.
|
||||
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
||||
6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
|
||||
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||
8. Check the JSON inspector if you need exact values.
|
||||
9. Use undo or redo if the transform is not correct.
|
||||
10. Export the JSON or save it to the dev server.
|
||||
3. Use `Shift + right click` on other objects to add or remove them from the current multi-selection.
|
||||
4. Choose a transform mode: translate, rotate, or scale.
|
||||
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. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
||||
7. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
|
||||
8. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||
9. Check the JSON inspector if you need exact values.
|
||||
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
|
||||
|
||||
@@ -70,6 +71,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre
|
||||
| Action | Input |
|
||||
| -------------------- | -------------------------- |
|
||||
| Select object | Click object |
|
||||
| Toggle multi-select | `Shift` + right click |
|
||||
| Deselect | `Esc` or click empty space |
|
||||
| Lock selection | `Lock` 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`.
|
||||
|
||||
- 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.
|
||||
- Use the `X` button to clear the selection explicitly.
|
||||
- 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;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
selectedNodeIndex: number | null;
|
||||
selectedNodeIndexes: number[];
|
||||
mapNodes: MapNode[];
|
||||
nodesCount: number;
|
||||
selectedNodeName: string | null;
|
||||
@@ -66,6 +67,7 @@ const TRANSFORM_OPTIONS = [
|
||||
|
||||
const EDITOR_SHORTCUTS = [
|
||||
["Click", "Select object"],
|
||||
["Shift + Right click", "Toggle multi-selection"],
|
||||
["T / R / S", "Transform mode"],
|
||||
["Ctrl Z / Y", "Undo / redo"],
|
||||
["Esc", "Deselect"],
|
||||
@@ -103,6 +105,7 @@ export function EditorControls({
|
||||
transformMode,
|
||||
onTransformModeChange,
|
||||
selectedNodeIndex,
|
||||
selectedNodeIndexes,
|
||||
mapNodes,
|
||||
nodesCount,
|
||||
selectedNodeName,
|
||||
@@ -135,6 +138,7 @@ export function EditorControls({
|
||||
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
||||
const selectedNode =
|
||||
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
|
||||
const selectionCount = selectedNodeIndexes.length;
|
||||
const transformValues = getTransformValues(selectedNode ?? null);
|
||||
|
||||
return (
|
||||
@@ -240,10 +244,14 @@ export function EditorControls({
|
||||
<Box size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||
{selectionCount > 1
|
||||
? `${selectionCount} selected nodes`
|
||||
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||
</strong>
|
||||
<span>
|
||||
Index {selectedNodeIndex + 1} of {nodesCount}
|
||||
{selectionCount > 1
|
||||
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
|
||||
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
|
||||
</span>
|
||||
</div>
|
||||
<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 type { ThreeEvent } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
selectedNodeIndexes: number[];
|
||||
onSelectNode: (index: number | null) => void;
|
||||
onToggleNodeSelection: (index: number) => void;
|
||||
isSelectionLocked: boolean;
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
@@ -37,16 +39,31 @@ interface EditorNodeCommonProps {
|
||||
isHovered: boolean;
|
||||
objectsMapRef: EditorNodeObjectRef;
|
||||
onSelectNode: (index: number | null) => void;
|
||||
onToggleNodeSelection: (index: number) => void;
|
||||
isSelectionLocked: boolean;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
}
|
||||
|
||||
interface EditorNodePointerHandlers {
|
||||
onClick: (event: ThreeEvent<MouseEvent>) => void;
|
||||
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
|
||||
onPointerEnter: (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 {
|
||||
object.position.set(...node.position);
|
||||
object.rotation.set(...node.rotation);
|
||||
@@ -118,6 +135,7 @@ function getNodeHighlightColor(
|
||||
function createEditorNodePointerHandlers(
|
||||
index: number,
|
||||
onSelectNode: (index: number | null) => void,
|
||||
onToggleNodeSelection: (index: number) => void,
|
||||
isSelectionLocked: boolean,
|
||||
onHoverNode: (index: number | null) => void,
|
||||
): EditorNodePointerHandlers {
|
||||
@@ -127,6 +145,12 @@ function createEditorNodePointerHandlers(
|
||||
if (isSelectionLocked) return;
|
||||
onSelectNode(index);
|
||||
},
|
||||
onContextMenu: (event) => {
|
||||
event.stopPropagation();
|
||||
event.nativeEvent.preventDefault();
|
||||
if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
|
||||
onToggleNodeSelection(index);
|
||||
},
|
||||
onPointerEnter: (event) => {
|
||||
event.stopPropagation();
|
||||
onHoverNode(index);
|
||||
@@ -141,7 +165,9 @@ function createEditorNodePointerHandlers(
|
||||
export function EditorMap({
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
selectedNodeIndexes,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
@@ -153,18 +179,110 @@ export function EditorMap({
|
||||
onNodeTransform,
|
||||
}: EditorMapProps): React.JSX.Element {
|
||||
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 handleTransformMouseDown = () => {
|
||||
onTransformStart();
|
||||
};
|
||||
const selectedIndexSet = new Set(selectedNodeIndexes);
|
||||
const isMultiSelection = selectedNodeIndexes.length > 1;
|
||||
|
||||
const handleTransformMouseUp = () => {
|
||||
syncSelectedObjectTransform();
|
||||
onTransformEnd();
|
||||
};
|
||||
const getTransformObject = useCallback(() => {
|
||||
if (isMultiSelection) {
|
||||
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 = () => {
|
||||
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) {
|
||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||
if (!obj) return;
|
||||
@@ -194,25 +312,30 @@ export function EditorMap({
|
||||
}
|
||||
};
|
||||
|
||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
||||
null,
|
||||
);
|
||||
const handleTransformMouseDown = () => {
|
||||
prepareTransformGroup();
|
||||
transformSnapshotRef.current = createTransformSnapshot();
|
||||
onTransformStart();
|
||||
};
|
||||
|
||||
const handleTransformMouseUp = () => {
|
||||
syncSelectedObjectTransform();
|
||||
transformSnapshotRef.current = null;
|
||||
prepareTransformGroup();
|
||||
onTransformEnd();
|
||||
};
|
||||
|
||||
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||
const terrainNodeIndex = terrainNode
|
||||
? sceneData.mapNodes.indexOf(terrainNode)
|
||||
: -1;
|
||||
const selectedNode =
|
||||
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
|
||||
const selectedModelName = selectedNode?.name ?? null;
|
||||
useLayoutEffect(() => {
|
||||
prepareTransformGroup();
|
||||
}, [prepareTransformGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||
setSelectedObject(obj || null);
|
||||
} else {
|
||||
setSelectedObject(null);
|
||||
}
|
||||
}, [selectedNodeIndex]);
|
||||
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const selectedObject = getTransformObject();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -236,11 +359,12 @@ export function EditorMap({
|
||||
<EditorTerrainNode
|
||||
index={terrainNodeIndex}
|
||||
node={terrainNode}
|
||||
isSelected={selectedNodeIndex === terrainNodeIndex}
|
||||
isSelected={selectedIndexSet.has(terrainNodeIndex)}
|
||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
@@ -250,10 +374,6 @@ export function EditorMap({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedModelName && node.name !== selectedModelName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
|
||||
if (modelUrl) {
|
||||
@@ -263,10 +383,11 @@ export function EditorMap({
|
||||
index={index}
|
||||
node={node}
|
||||
modelUrl={modelUrl}
|
||||
isSelected={selectedNodeIndex === index}
|
||||
isSelected={selectedIndexSet.has(index)}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
@@ -277,10 +398,11 @@ export function EditorMap({
|
||||
key={index}
|
||||
index={index}
|
||||
node={node}
|
||||
isSelected={selectedNodeIndex === index}
|
||||
isSelected={selectedIndexSet.has(index)}
|
||||
isHovered={hoveredNodeIndex === index}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
@@ -289,6 +411,8 @@ export function EditorMap({
|
||||
})}
|
||||
</group>
|
||||
|
||||
<group ref={transformGroupRef} />
|
||||
|
||||
{selectedObject && (
|
||||
<TransformControls
|
||||
object={selectedObject}
|
||||
@@ -310,6 +434,7 @@ function EditorModelNode({
|
||||
isHovered,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps & {
|
||||
@@ -329,6 +454,7 @@ function EditorModelNode({
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
@@ -403,6 +529,7 @@ function EditorTerrainNode({
|
||||
lockTerrainSelection,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
|
||||
@@ -410,6 +537,7 @@ function EditorTerrainNode({
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
@@ -435,6 +563,7 @@ function EditorFallbackNode({
|
||||
isHovered,
|
||||
objectsMapRef,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps) {
|
||||
@@ -442,6 +571,7 @@ function EditorFallbackNode({
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||
import { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||
@@ -21,7 +20,9 @@ export interface EditorCinematicPreviewRequest {
|
||||
interface EditorSceneProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
selectedNodeIndexes: number[];
|
||||
onSelectNode: (index: number | null) => void;
|
||||
onToggleNodeSelection: (index: number) => void;
|
||||
isSelectionLocked: boolean;
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
@@ -44,7 +45,9 @@ interface EditorSceneProps {
|
||||
export function EditorScene({
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
selectedNodeIndexes,
|
||||
onSelectNode,
|
||||
onToggleNodeSelection,
|
||||
isSelectionLocked,
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
@@ -209,7 +212,9 @@ export function EditorScene({
|
||||
<EditorMap
|
||||
sceneData={sceneData}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
selectedNodeIndexes={selectedNodeIndexes}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleNodeSelection={onToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
@@ -221,8 +226,6 @@ export function EditorScene({
|
||||
onNodeTransform={onNodeTransform}
|
||||
/>
|
||||
|
||||
<TerrainModel />
|
||||
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||
|
||||
@@ -313,6 +313,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
|
||||
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
|
||||
const [transformMode, setTransformMode] =
|
||||
useState<TransformMode>("translate");
|
||||
@@ -370,13 +371,31 @@ export function EditorPage(): React.JSX.Element {
|
||||
|
||||
const handleSelectNode = useCallback((index: number | null) => {
|
||||
setSelectedNodeIndex(index);
|
||||
setSelectedNodeIndexes(index === null ? [] : [index]);
|
||||
if (index !== null) {
|
||||
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(() => {
|
||||
setSelectedNodeIndex(null);
|
||||
setSelectedNodeIndexes([]);
|
||||
}, []);
|
||||
|
||||
const handleSelectionLockToggle = useCallback(() => {
|
||||
@@ -401,7 +420,21 @@ export function EditorPage(): React.JSX.Element {
|
||||
if (currentIndex === null) return null;
|
||||
|
||||
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],
|
||||
@@ -544,12 +577,14 @@ export function EditorPage(): React.JSX.Element {
|
||||
const newNode = createNewMapNode(newNodeName);
|
||||
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
|
||||
setSelectedNodeIndex(mapNodes.length - 1);
|
||||
setSelectedNodeIndexes([mapNodes.length - 1]);
|
||||
return { ...prev, mapNodes };
|
||||
}
|
||||
|
||||
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
||||
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
|
||||
return nextSceneData;
|
||||
});
|
||||
}, [newNodeName, setSceneData]);
|
||||
@@ -563,6 +598,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
if (!currentNode) return prev;
|
||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||
setSelectedNodeIndex(null);
|
||||
setSelectedNodeIndexes([]);
|
||||
return {
|
||||
...prev,
|
||||
mapNodes: prev.mapNodes.filter(
|
||||
@@ -576,6 +612,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
currentNode.sourcePath,
|
||||
);
|
||||
setSelectedNodeIndex(null);
|
||||
setSelectedNodeIndexes([]);
|
||||
return updateSceneDataTree(prev, mapTree);
|
||||
});
|
||||
}, [selectedNodeIndex, setSceneData]);
|
||||
@@ -660,7 +697,9 @@ export function EditorPage(): React.JSX.Element {
|
||||
<EditorScene
|
||||
sceneData={sceneData!}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
selectedNodeIndexes={selectedNodeIndexes}
|
||||
onSelectNode={handleSelectNode}
|
||||
onToggleNodeSelection={handleToggleNodeSelection}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={handleHoverNode}
|
||||
@@ -689,6 +728,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
selectedNodeIndexes={selectedNodeIndexes}
|
||||
mapNodes={sceneData.mapNodes}
|
||||
nodesCount={sceneData.mapNodes.length}
|
||||
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 {
|
||||
return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { logger } from "@/utils/core/Logger";
|
||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
import {
|
||||
getTerrainMapNode,
|
||||
isRuntimeCollisionMapNode,
|
||||
isRuntimeSingleMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
@@ -178,7 +179,7 @@ export function GameMap({
|
||||
return { node, modelUrl: modelUrl ?? null };
|
||||
});
|
||||
const loadedCollisionNodes = sceneData.mapNodes
|
||||
.filter((node) => node.name === "terrain")
|
||||
.filter(isRuntimeCollisionMapNode)
|
||||
.map((node) => {
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
return { node, modelUrl: modelUrl ?? null };
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -11,6 +12,11 @@ import * as THREE from "three";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
|
||||
import {
|
||||
getObjectBottomOffset,
|
||||
normalizeMapScale,
|
||||
useTerrainHeightSampler,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
@@ -27,6 +33,8 @@ interface ResolvedGameMapCollisionNode {
|
||||
modelUrl: string;
|
||||
}
|
||||
|
||||
type TerrainHeightSampler = ReturnType<typeof useTerrainHeightSampler>;
|
||||
|
||||
interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
@@ -47,8 +55,6 @@ interface CollisionErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
|
||||
|
||||
class CollisionErrorBoundary extends Component<
|
||||
CollisionErrorBoundaryProps,
|
||||
CollisionErrorBoundaryState
|
||||
@@ -88,9 +94,7 @@ class CollisionErrorBoundary extends Component<
|
||||
function isCollisionNode(
|
||||
mapNode: GameMapCollisionNode,
|
||||
): mapNode is ResolvedGameMapCollisionNode {
|
||||
return (
|
||||
mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name)
|
||||
);
|
||||
return mapNode.modelUrl !== null;
|
||||
}
|
||||
|
||||
export function GameMapCollision({
|
||||
@@ -105,6 +109,7 @@ export function GameMapCollision({
|
||||
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||
const loadedNotifiedRef = useRef(false);
|
||||
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const collisionNodes = nodes.filter(isCollisionNode);
|
||||
const collisionReady =
|
||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
||||
@@ -188,6 +193,7 @@ export function GameMapCollision({
|
||||
node={mapNode.node}
|
||||
modelUrl={mapNode.modelUrl}
|
||||
onLoaded={() => handleCollisionNodeSettled(index)}
|
||||
terrainHeight={terrainHeight}
|
||||
/>
|
||||
</Suspense>
|
||||
</CollisionErrorBoundary>
|
||||
@@ -201,19 +207,30 @@ function CollisionModelInstance({
|
||||
node,
|
||||
modelUrl,
|
||||
onLoaded,
|
||||
terrainHeight,
|
||||
}: {
|
||||
node: MapNode;
|
||||
modelUrl: string;
|
||||
onLoaded: () => void;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const normalizedScale = normalizeMapScale(scale);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "GameMapCollision.ModelInstance",
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
scale: normalizedScale,
|
||||
});
|
||||
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(() => {
|
||||
onLoaded();
|
||||
@@ -222,9 +239,9 @@ function CollisionModelInstance({
|
||||
return (
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={position}
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
scale={normalizedScale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+170
-196
@@ -1,32 +1,22 @@
|
||||
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 { TERRAIN_SURFACE_PROJECTION } from "@/data/world/terrainConfig";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import { useWind } from "@/hooks/world/useWind";
|
||||
import type { TerrainSurfaceData } from "@/types/world/terrainSurface";
|
||||
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
|
||||
import {
|
||||
getGrassTipColor,
|
||||
GRASS_BASE_COLOR,
|
||||
GRASS_COLORS,
|
||||
GRASS_CONFIG,
|
||||
GRASS_SURFACE_KEYS,
|
||||
} from "@/world/grass/grassConfig";
|
||||
import {
|
||||
grassFragmentShader,
|
||||
grassVertexShader,
|
||||
} from "@/world/grass/grassShaders";
|
||||
import type { TerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||
|
||||
interface GrassPatchProps {
|
||||
chunkX: number;
|
||||
chunkZ: number;
|
||||
density: number;
|
||||
terrainSurfaceData: TerrainSurfaceData;
|
||||
}
|
||||
|
||||
interface GrassBladeVertexData {
|
||||
color: number[];
|
||||
heightFactor: number;
|
||||
position: number[];
|
||||
terrainSampler: TerrainGrassSampler;
|
||||
}
|
||||
|
||||
function random01(seed: number): number {
|
||||
@@ -34,216 +24,200 @@ function random01(seed: number): number {
|
||||
return value - Math.floor(value);
|
||||
}
|
||||
|
||||
function lerp(min: number, max: number, ratio: number): number {
|
||||
return min + (max - min) * ratio;
|
||||
function pushVector(target: number[], value: THREE.Vector3): void {
|
||||
target.push(value.x, value.y, value.z);
|
||||
}
|
||||
|
||||
function createGrassMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
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 pushColor(target: number[], value: THREE.Color): void {
|
||||
target.push(value.r, value.g, value.b);
|
||||
}
|
||||
|
||||
function addGrassBlade(
|
||||
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 {
|
||||
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const colors: number[] = [];
|
||||
const bladeBases: number[] = [];
|
||||
const heightFactors: number[] = [];
|
||||
const windPhases: number[] = [];
|
||||
const baseColor = new THREE.Color(GRASS_CONFIG.baseColor);
|
||||
const startX = chunkX * GRASS_CONFIG.chunkSize;
|
||||
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;
|
||||
const uvs: number[] = [];
|
||||
const bladeOrigins: number[] = [];
|
||||
const yaws: number[] = [];
|
||||
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
|
||||
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
||||
|
||||
for (let x = startX; x < endX; x += GRASS_CONFIG.sampleStep) {
|
||||
for (let z = startZ; z < endZ; z += GRASS_CONFIG.sampleStep) {
|
||||
for (
|
||||
let bladeIndex = 0;
|
||||
bladeIndex < GRASS_CONFIG.bladesPerCell;
|
||||
bladeIndex++
|
||||
) {
|
||||
if (bladeCount >= bladeBudget) break;
|
||||
for (let index = 0; index < bladeCount; index++) {
|
||||
const seed = index * 997;
|
||||
const origin = new THREE.Vector3(
|
||||
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||
0,
|
||||
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
|
||||
);
|
||||
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 =
|
||||
(chunkX + 101) * 92821 +
|
||||
(chunkZ + 103) * 68917 +
|
||||
Math.round(x * 13) * 193 +
|
||||
Math.round(z * 17) * 389 +
|
||||
bladeIndex * 997;
|
||||
if (random01(seed) > density) continue;
|
||||
|
||||
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;
|
||||
}
|
||||
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
||||
pushVector(positions, origin);
|
||||
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
|
||||
pushVector(bladeOrigins, origin);
|
||||
pushVector(yaws, yaw);
|
||||
pushColor(colors, color);
|
||||
uvs.push(uv.x, uv.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (bladeCount === 0) return null;
|
||||
|
||||
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(
|
||||
"position",
|
||||
new THREE.Float32BufferAttribute(positions, 3),
|
||||
);
|
||||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||
geometry.setAttribute(
|
||||
"aBladeBase",
|
||||
new THREE.Float32BufferAttribute(bladeBases, 3),
|
||||
"color",
|
||||
new THREE.Float32BufferAttribute(markerColorValues, 3),
|
||||
);
|
||||
geometry.setAttribute(
|
||||
"aHeightFactor",
|
||||
new THREE.Float32BufferAttribute(heightFactors, 1),
|
||||
"aBladeColor",
|
||||
new THREE.Float32BufferAttribute(bladeColorValues, 3),
|
||||
);
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setAttribute(
|
||||
"aWindPhase",
|
||||
new THREE.Float32BufferAttribute(windPhases, 1),
|
||||
"aBladeOrigin",
|
||||
new THREE.Float32BufferAttribute(bladeOrigins, 3),
|
||||
);
|
||||
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
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({
|
||||
chunkX,
|
||||
chunkZ,
|
||||
density,
|
||||
terrainSurfaceData,
|
||||
}: GrassPatchProps): React.JSX.Element | null {
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
terrainSampler,
|
||||
}: GrassPatchProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
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 geometry = useMemo(
|
||||
const geometry = useMemo(() => createGrassGeometry(density), [density]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
grassTextures.grass.dispose();
|
||||
grassTextures.noise.dispose();
|
||||
};
|
||||
}, [grassTextures]);
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
createGrassGeometry(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
density,
|
||||
terrainSurfaceData,
|
||||
terrainHeight.getHeight,
|
||||
createGrassMaterial(
|
||||
terrainSampler,
|
||||
grassTextures.noise,
|
||||
grassTextures.grass,
|
||||
),
|
||||
[chunkX, chunkZ, density, terrainHeight.getHeight, terrainSurfaceData],
|
||||
[grassTextures, terrainSampler],
|
||||
);
|
||||
const material = useMemo(() => createGrassMaterial(), []);
|
||||
|
||||
useEffect(() => {
|
||||
materialRef.current = material;
|
||||
@@ -255,7 +229,7 @@ export function GrassPatch({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
geometry?.dispose();
|
||||
geometry.dispose();
|
||||
};
|
||||
}, [geometry]);
|
||||
|
||||
@@ -265,16 +239,16 @@ export function GrassPatch({
|
||||
|
||||
const uniforms = currentMaterial.uniforms;
|
||||
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.uWindSpeed) uniforms.uWindSpeed.value = wind.speed;
|
||||
if (uniforms.uWindStrength) uniforms.uWindStrength.value = wind.strength;
|
||||
if (uniforms.uWindNoiseScale) {
|
||||
uniforms.uWindNoiseScale.value =
|
||||
GRASS_CONFIG.windNoiseScale * wind.noiseScale;
|
||||
}
|
||||
});
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return <mesh geometry={geometry} material={material} frustumCulled />;
|
||||
return <mesh geometry={geometry} material={material} frustumCulled={false} />;
|
||||
}
|
||||
|
||||
@@ -1,147 +1,25 @@
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
useDynamicGrass,
|
||||
useGrassDensity,
|
||||
} from "@/hooks/world/useGraphicsSettings";
|
||||
import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
|
||||
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
|
||||
import { GrassPatch } from "@/world/grass/GrassPatch";
|
||||
|
||||
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;
|
||||
}
|
||||
import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
|
||||
|
||||
export function GrassSystem(): React.JSX.Element | null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const terrainSurfaceData = useTerrainSurfaceData();
|
||||
const sceneMode = useSceneMode();
|
||||
const terrainSampler = useTerrainGrassSampler();
|
||||
const dynamicGrass = useDynamicGrass();
|
||||
const grassDensity = useGrassDensity();
|
||||
const lastUpdateRef = useRef(-GRASS_CONFIG.updateInterval);
|
||||
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const density = Math.max(0, grassDensity);
|
||||
const chunks = useMemo(
|
||||
() =>
|
||||
terrainSurfaceData ? createGrassChunks(terrainSurfaceData.bounds) : [],
|
||||
[terrainSurfaceData],
|
||||
);
|
||||
const streamingEnabled = sceneMode === "game";
|
||||
|
||||
const updateActiveChunks = useCallback(() => {
|
||||
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
|
||||
) {
|
||||
if (!GRASS_CONFIG.enabled || !dynamicGrass || density <= 0) {
|
||||
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 (
|
||||
<group name="grass-system">
|
||||
{visibleChunks.map((chunk) => (
|
||||
<Suspense key={chunk.key} fallback={null}>
|
||||
<GrassPatch
|
||||
chunkX={chunk.x}
|
||||
chunkZ={chunk.z}
|
||||
density={density}
|
||||
terrainSurfaceData={terrainSurfaceData}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</group>
|
||||
<Suspense fallback={null}>
|
||||
<GrassPatch density={density} terrainSampler={terrainSampler} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
|
||||
|
||||
export const GRASS_CONFIG = {
|
||||
enabled: true,
|
||||
chunkSize: 20,
|
||||
loadRadius: 30,
|
||||
unloadRadius: 34,
|
||||
updateInterval: 250,
|
||||
sampleStep: 1.15,
|
||||
jitter: 0.42,
|
||||
bladesPerCell: 2,
|
||||
maxBladesPerChunk: 720,
|
||||
bladeWidth: 0.12,
|
||||
minBladeHeight: 0.42,
|
||||
maxBladeHeight: 0.82,
|
||||
surfaceOffset: 0.06,
|
||||
baseColor: "#1f3512",
|
||||
windBendStrength: 0.42,
|
||||
windNoiseScale: 0.09,
|
||||
patchSize: 30,
|
||||
bladeCount: 32000,
|
||||
bladeWidth: 0.08,
|
||||
maxBladeHeight: 0.56,
|
||||
randomHeightAmount: 0.25,
|
||||
surfaceOffset: 0.025,
|
||||
heightTextureSize: 128,
|
||||
windNoiseScale: 0.9,
|
||||
windStrength: 0.35,
|
||||
baldPatchModifier: 1.1,
|
||||
falloffSharpness: 0.35,
|
||||
heightNoiseFrequency: 9,
|
||||
heightNoiseAmplitude: 1,
|
||||
clumpFrequency: 2.6,
|
||||
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;
|
||||
|
||||
export const GRASS_SURFACE_KEYS = new Set([
|
||||
"grass1",
|
||||
"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;
|
||||
}
|
||||
export const GRASS_COLORS = ["#84C66B", "#67B058", "#A3CA5B"] as const;
|
||||
export const GRASS_BASE_COLOR = "#1A3A1A" as const;
|
||||
|
||||
+149
-22
@@ -1,40 +1,167 @@
|
||||
export const grassVertexShader = /* glsl */ `
|
||||
attribute vec3 aColor;
|
||||
attribute vec3 aBladeBase;
|
||||
attribute float aHeightFactor;
|
||||
attribute float aWindPhase;
|
||||
attribute vec3 aYaw;
|
||||
attribute vec3 aBladeOrigin;
|
||||
attribute vec3 aBladeColor;
|
||||
|
||||
varying vec3 vColor;
|
||||
|
||||
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 uWindSpeed;
|
||||
uniform float uWindStrength;
|
||||
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() {
|
||||
vec3 transformed = position;
|
||||
float topFactor = aHeightFactor * aHeightFactor;
|
||||
vec2 windDirection = normalize(vec2(cos(uWindDirection), sin(uWindDirection)));
|
||||
vec3 origin = aBladeOrigin;
|
||||
float halfPatchSize = uPatchSize * 0.5;
|
||||
|
||||
float primaryWind = sin(
|
||||
uTime * max(uWindSpeed, 0.05) +
|
||||
aWindPhase +
|
||||
aBladeBase.x * uWindNoiseScale +
|
||||
aBladeBase.z * uWindNoiseScale
|
||||
origin.x = mod(origin.x - uPlayerPosition.x + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||
origin.z = mod(origin.z - uPlayerPosition.z + halfPatchSize, uPatchSize) - halfPatchSize;
|
||||
|
||||
vec3 worldPos = vec3(uPlayerPosition.x + origin.x, 0.0, uPlayerPosition.z + origin.z);
|
||||
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(
|
||||
uTime * max(uWindSpeed, 0.05) * 1.73 +
|
||||
aWindPhase * 0.71 +
|
||||
aBladeBase.x * uWindNoiseScale * 0.53 -
|
||||
aBladeBase.z * uWindNoiseScale * 0.89
|
||||
) * 0.35;
|
||||
terrainUv = clamp(terrainUv, 0.0, 1.0);
|
||||
|
||||
float bend = (primaryWind + secondaryWind) * uWindStrength * uBendStrength * topFactor;
|
||||
transformed.xz += windDirection * bend;
|
||||
float terrainHeightRatio = texture2D(uHeightMap, terrainUv).r;
|
||||
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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
@@ -47,6 +48,10 @@ const DEFAULT_KEYS: Keys = {
|
||||
jump: false,
|
||||
};
|
||||
|
||||
const PLAYER_COLLISION_ITERATIONS = 3;
|
||||
const PLAYER_FLOOR_NORMAL_MIN = 0.15;
|
||||
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
||||
|
||||
interface PlayerControllerProps {
|
||||
octree: Octree | null;
|
||||
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({
|
||||
octree,
|
||||
spawnPosition,
|
||||
}: PlayerControllerProps): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const movementLockedRef = useRef(movementLocked);
|
||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||
const velocity = useRef(new THREE.Vector3());
|
||||
@@ -300,17 +310,26 @@ export function PlayerController({
|
||||
capsule.current.translate(_translateVec);
|
||||
|
||||
if (octree) {
|
||||
const result = octree.capsuleIntersect(capsule.current);
|
||||
onFloor.current = false;
|
||||
|
||||
if (result) {
|
||||
onFloor.current = result.normal.y > 0;
|
||||
for (let index = 0; index < PLAYER_COLLISION_ITERATIONS; index++) {
|
||||
const result = octree.capsuleIntersect(capsule.current);
|
||||
if (!result) break;
|
||||
|
||||
if (!onFloor.current) {
|
||||
const vn = result.normal.dot(velocity.current);
|
||||
velocity.current.addScaledVector(result.normal, -vn);
|
||||
} else {
|
||||
const isFloorCollision = result.normal.y > PLAYER_FLOOR_NORMAL_MIN;
|
||||
onFloor.current ||= isFloorCollision;
|
||||
const normalVelocity = result.normal.dot(velocity.current);
|
||||
|
||||
if (!isFloorCollision && normalVelocity < 0) {
|
||||
velocity.current.addScaledVector(result.normal, -normalVelocity);
|
||||
}
|
||||
|
||||
if (isFloorCollision) {
|
||||
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(
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user