9 Commits

Author SHA1 Message Date
Tom Boullay a2fc417be6 fix(environment): preserve grass blade colors
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-28 01:12:21 +02:00
Tom Boullay 57498b9bb1 tune(environment): rebalance procedural grass zones 2026-05-28 01:08:33 +02:00
Tom Boullay b87a7e929c feat(environment): vary grass density by procedural zones 2026-05-28 01:05:40 +02:00
Tom Boullay ea23b4bb46 fix(environment): tune grass and terrain grounding 2026-05-28 00:59:36 +02:00
Tom Boullay 3881e38a6d feat(collision): include static map models in octree 2026-05-28 00:47:40 +02:00
Tom Boullay 7a72743e5c refactor(environment): use player-centered ghibli grass patch 2026-05-28 00:31:45 +02:00
Tom Boullay 65651405b6 feat(editor): add multi-selection transforms 2026-05-28 00:29:00 +02:00
Tom Boullay 81cd935bba fix(environment): reduce grass generation cost 2026-05-28 00:25:23 +02:00
Tom Boullay fe989c9550 refactor(environment): replace grass with terrain-mesh ghibli shader 2026-05-28 00:20:01 +02:00
17 changed files with 862 additions and 438 deletions
+5 -3
View File
@@ -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
View File
@@ -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.
+10 -2
View File
@@ -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">
+159 -29
View File
@@ -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,
);
+6 -3
View File
@@ -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} />
+41 -1
View File
@@ -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";
}
+2 -1
View File
@@ -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 };
+25 -8
View File
@@ -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
View File
@@ -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} />;
}
+7 -129
View File
@@ -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>
);
}
+32 -29
View File
@@ -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
View File
@@ -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);
}
`;
+192
View File
@@ -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],
);
}
+42 -7
View File
@@ -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);
});