7 Commits

Author SHA1 Message Date
Tom Boullay 6d178dc59e feat: expose terrain surface data
🔍 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-25 16:03:22 +02:00
Tom Boullay b4a3545460 feat: add terrain surface sampler 2026-05-25 15:55:56 +02:00
Tom Boullay 1f6d9659ed chore: add terrain surface config 2026-05-25 15:51:02 +02:00
Tom Boullay a52d57ae6c fix: keep terrain collision during visual filtering 2026-05-25 01:28:25 +02:00
Tom Boullay d0497ec42c fix(debug): defer hand tracking startup 2026-05-25 01:19:14 +02:00
Tom Boullay 4c42e11268 fix(debug): stabilize map debug controls 2026-05-25 01:10:10 +02:00
Tom Boullay f175ad6240 tune(debug): refine fog and debug controls 2026-05-25 01:00:31 +02:00
17 changed files with 491 additions and 190 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist", "POC-grass"]),
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
+21 -71
View File
@@ -40201,7 +40201,7 @@
"scale": [1, 1, 1],
"children": [
{
"name": "panneauxcentre",
"name": "panneauclassique",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
@@ -40209,40 +40209,30 @@
"scale": [1, 1, 1],
"children": [
{
"name": "panneauxcentre",
"name": "panneauclassique",
"type": "Object3D",
"position": [-35.2895, 3.8515, 40.1864],
"rotation": [-3.0988, 0.9934, -3.1367],
"scale": [1, 1, 1],
"children": [
{
"name": "panneauxcentre",
"name": "panneauclassique",
"type": "Mesh",
"position": [-35.2895, 3.8515, 40.1864],
"rotation": [-3.0988, 0.9934, -3.1367],
"scale": [1, 1, 1]
}
]
}
]
},
{
"name": "panneauxdomaine",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
},
{
"name": "panneauxdomaine",
"name": "panneauclassique",
"type": "Object3D",
"position": [-2.4347, 3.7803, -56.1439],
"rotation": [0.0474, 0.2148, -0.0027],
"scale": [1, 1, 1],
"children": [
{
"name": "panneauxdomaine",
"name": "panneauclassique",
"type": "Mesh",
"position": [-2.4347, 3.7803, -56.1439],
"rotation": [0.0474, 0.2148, -0.0027],
@@ -40253,7 +40243,7 @@
]
},
{
"name": "panneaudirresidences2",
"name": "panneaufleche",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
@@ -40261,118 +40251,78 @@
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudirresidences2",
"name": "panneaufleche",
"type": "Object3D",
"position": [30.1743, 7.1944, -13.5781],
"rotation": [0.0374, -1.297, -0.0099],
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudirresidences2",
"name": "panneaufleche",
"type": "Mesh",
"position": [30.1743, 7.1944, -13.5781],
"rotation": [0.0374, -1.297, -0.0099],
"scale": [1, 1, 1]
}
]
}
]
},
{
"name": "panneaudirdomaine",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
},
{
"name": "panneaudirdomaine",
"name": "panneaufleche",
"type": "Object3D",
"position": [8.1032, 7.1918, -23.0004],
"rotation": [0.0462, -0.2443, -0.0027],
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudirdomaine",
"name": "panneaufleche",
"type": "Mesh",
"position": [8.1032, 7.1918, -23.0004],
"rotation": [0.0462, -0.2443, -0.0027],
"scale": [1, 1, 1]
}
]
}
]
},
{
"name": "panneaudirresidences1",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
},
{
"name": "panneaudirresidences1",
"name": "panneaufleche",
"type": "Object3D",
"position": [-10.1125, 7.2582, -11.4511],
"rotation": [0.0478, 0.3436, -0.0028],
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudirresidences1",
"name": "panneaufleche",
"type": "Mesh",
"position": [-10.1125, 7.2582, -11.4511],
"rotation": [0.0478, 0.3436, -0.0028],
"scale": [1, 1, 1]
}
]
}
]
},
{
"name": "panneaudircentre",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
},
{
"name": "panneaudircentre",
"name": "panneaufleche",
"type": "Object3D",
"position": [-10.5207, 7.0874, 19.3819],
"rotation": [-3.1416, 1.5139, -3.0947],
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudircentre",
"name": "panneaufleche",
"type": "Mesh",
"position": [-10.5207, 7.0874, 19.3819],
"rotation": [-3.1416, 1.5139, -3.0947],
"scale": [1, 1, 1]
}
]
}
]
},
{
"name": "panneaudirfabrik",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
},
{
"name": "panneaudirfabrik",
"name": "panneaufleche",
"type": "Object3D",
"position": [29.0899, 6.9811, 23.4988],
"rotation": [3.1416, -0.8852, 3.1253],
"scale": [1, 1, 1],
"children": [
{
"name": "panneaudirfabrik",
"name": "panneaufleche",
"type": "Mesh",
"position": [29.0899, 6.9811, 23.4988],
"rotation": [3.1416, -0.8852, 3.1253],
+7
View File
@@ -10,6 +10,13 @@ const MESH_NAME_MAPPINGS = {
eoliennes: "eolienne",
immeuble_1: "immeuble1",
buissons: "buisson",
panneauxcentre: "panneauclassique",
panneauxdomaine: "panneauclassique",
panneaudircentre: "panneaufleche",
panneaudirdomaine: "panneaufleche",
panneaudirfabrik: "panneaufleche",
panneaudirresidences1: "panneaufleche",
panneaudirresidences2: "panneaufleche",
panneauxquartier: "panneauaffichage",
};
+1 -1
View File
@@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
interface TerrainModelProps {
+1 -1
View File
@@ -2,7 +2,7 @@ import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
export const FOG_CONFIG = {
enabled: true,
color: "#eef3f5",
color: "#dce8df",
near: 38,
far: 45,
};
+21 -70
View File
@@ -1,108 +1,59 @@
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 15;
export const TERRAIN_WATER_HEIGHT = 0;
export const TERRAIN_TILE_SIZE = 1;
export const GRASS_BASE_COLOR = "#1a3a1a";
export const TERRAIN_COLORS = {
grass1: {
hex: "#84C66B",
rgb: [132, 198, 107] as const,
type: "grass" as const,
kind: "grass",
grassTipColor: "#84C66B",
},
grass2: {
hex: "#67B058",
rgb: [103, 176, 88] as const,
type: "grass" as const,
kind: "grass",
grassTipColor: "#67B058",
},
grass3: {
hex: "#A3CA5B",
rgb: [163, 202, 91] as const,
type: "grass" as const,
kind: "grass",
grassTipColor: "#A3CA5B",
},
potager: {
hex: "#342420",
rgb: [52, 36, 32] as const,
type: "tile" as const,
tileModel: "/models/potager/potager.gltf",
tileSize: 1,
kind: "garden",
modelPath: "/models/potager/potager.gltf",
tileSize: TERRAIN_TILE_SIZE,
},
terre: {
hex: "#513E2C",
rgb: [81, 62, 44] as const,
type: "none" as const,
kind: "dirt",
},
chemin: {
hex: "#F5D896",
rgb: [245, 216, 150] as const,
type: "tile" as const,
tileModel: "/models/chemins/model.gltf",
tileSize: 1,
kind: "path",
modelPath: "/models/chemins/model.gltf",
tileSize: TERRAIN_TILE_SIZE,
},
eau: {
hex: "#91DAF5",
rgb: [145, 218, 245] as const,
type: "water" as const,
kind: "water",
},
cailloux: {
hex: "#B6D3DE",
rgb: [182, 211, 222] as const,
type: "none" as const,
kind: "rock",
},
} as const;
} satisfies Record<string, TerrainSurfaceColorConfig>;
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
export type TerrainType = "grass" | "tile" | "water" | "none";
export const GRASS_BASE_COLOR = "#1a3a1a";
export const COLOR_TOLERANCE = 15;
export function colorMatchesTerrain(
r: number,
g: number,
b: number,
targetRgb: readonly [number, number, number],
tolerance: number = COLOR_TOLERANCE,
): boolean {
return (
Math.abs(r - targetRgb[0]) <= tolerance &&
Math.abs(g - targetRgb[1]) <= tolerance &&
Math.abs(b - targetRgb[2]) <= tolerance
);
}
export function getTerrainTypeFromColor(
r: number,
g: number,
b: number,
): TerrainColorKey | null {
for (const [key, config] of Object.entries(TERRAIN_COLORS)) {
if (colorMatchesTerrain(r, g, b, config.rgb)) {
return key as TerrainColorKey;
}
}
return null;
}
export function isGrassZone(r: number, g: number, b: number): boolean {
return (
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb) ||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb) ||
colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)
);
}
export function getGrassTipColor(
r: number,
g: number,
b: number,
): string | null {
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass1.rgb)) {
return TERRAIN_COLORS.grass1.grassTipColor;
}
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass2.rgb)) {
return TERRAIN_COLORS.grass2.grassTipColor;
}
if (colorMatchesTerrain(r, g, b, TERRAIN_COLORS.grass3.rgb)) {
return TERRAIN_COLORS.grass3.grassTipColor;
}
return null;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { Debug } from "@/utils/debug/Debug";
import {
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_NAMES,
@@ -14,7 +15,9 @@ function toLabel(value: string): string {
}
export function useMapPerformanceDebug(): void {
useDebugFolder("Performance / Map", (folder) => {
useDebugFolder("Map", (folder) => {
Debug.getInstance().addFogControl(folder);
const {
groups,
models,
+63
View File
@@ -0,0 +1,63 @@
import { useMemo } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type {
TerrainSurfaceBounds,
TerrainSurfaceData,
} from "@/types/world/terrainSurface";
import { createTerrainSurfaceImageData } from "@/utils/world/terrainSurfaceSampler";
function findTerrainBaseColorTexture(
scene: THREE.Object3D,
): THREE.Texture | null {
let texture: THREE.Texture | null = null;
scene.traverse((child) => {
if (texture || !(child instanceof THREE.Mesh)) return;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
if (material instanceof THREE.MeshStandardMaterial && material.map) {
texture = material.map;
return;
}
}
});
return texture;
}
function createTerrainSurfaceBounds(
scene: THREE.Object3D,
): TerrainSurfaceBounds {
scene.updateWorldMatrix(true, true);
const box = new THREE.Box3().setFromObject(scene);
return {
minX: box.min.x,
maxX: box.max.x,
minZ: box.min.z,
maxZ: box.max.z,
};
}
export function useTerrainSurfaceData(): TerrainSurfaceData | null {
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
return useMemo(() => {
const texture = findTerrainBaseColorTexture(scene);
if (!texture) return null;
const imageData = createTerrainSurfaceImageData(texture);
if (!imageData) return null;
return {
bounds: createTerrainSurfaceBounds(scene),
imageData,
};
}, [scene]);
}
+23 -12
View File
@@ -24,9 +24,6 @@ export function HandTrackingProvider({
children: ReactNode;
}): React.JSX.Element {
const sceneMode = useSceneMode();
const handTrackingSource = useDebugStore((debug) =>
debug.getHandTrackingSource(),
);
const repairNeedsHands = useGameStore((state) => {
switch (state.mainState) {
case "bike":
@@ -44,20 +41,34 @@ export function HandTrackingProvider({
const enabled =
repairNeedsHands ||
(sceneMode === "physics" && (nearby || holding || handHolding));
if (!enabled) {
return (
<HandTrackingContext value={HAND_TRACKING_IDLE_SNAPSHOT}>
{children}
</HandTrackingContext>
);
}
return <ActiveHandTrackingProvider>{children}</ActiveHandTrackingProvider>;
}
function ActiveHandTrackingProvider({
children,
}: {
children: ReactNode;
}): React.JSX.Element {
const handTrackingSource = useDebugStore((debug) =>
debug.getHandTrackingSource(),
);
const backendSnapshot = useRemoteHandTracking({
enabled: enabled && handTrackingSource === "backend",
enabled: handTrackingSource === "backend",
});
const browserSnapshot = useBrowserHandTracking({
enabled: enabled && handTrackingSource === "browser",
enabled: handTrackingSource === "browser",
});
const snapshot =
handTrackingSource === "browser" ? browserSnapshot : backendSnapshot;
return (
<HandTrackingContext
value={enabled ? snapshot : HAND_TRACKING_IDLE_SNAPSHOT}
>
{children}
</HandTrackingContext>
);
return <HandTrackingContext value={snapshot}>{children}</HandTrackingContext>;
}
+41
View File
@@ -0,0 +1,41 @@
export type TerrainSurfaceKind =
| "grass"
| "path"
| "water"
| "garden"
| "dirt"
| "rock";
export type TerrainSurfaceRgb = readonly [number, number, number];
export interface TerrainSurfaceUv {
u: number;
v: number;
}
export interface TerrainSurfaceBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface TerrainSurfaceColorConfig {
hex: string;
rgb: TerrainSurfaceRgb;
kind: TerrainSurfaceKind;
grassTipColor?: string;
modelPath?: string;
tileSize?: number;
}
export interface TerrainSurfaceSample {
rgb: TerrainSurfaceRgb;
key: string | null;
config: TerrainSurfaceColorConfig | null;
}
export interface TerrainSurfaceData {
bounds: TerrainSurfaceBounds;
imageData: ImageData;
}
+98 -13
View File
@@ -1,6 +1,7 @@
import GUI from "lil-gui";
import type { CameraMode, SceneMode } from "@/types/debug/debug";
import type { HandTrackingSource } from "@/types/handTracking/handTracking";
import { FOG_CONFIG } from "@/data/world/fogConfig";
import { EventEmitter } from "@/utils/core/EventEmitter";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
@@ -15,6 +16,14 @@ interface DebugEvents {
change: void;
}
const DEBUG_FOLDER_ORDER = [
"Lighting",
"Game",
"Interaction",
"Hand Tracking",
"Map",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -58,6 +67,7 @@ export class Debug {
private readonly folderRefCounts = new Map<string, number>();
private readonly controls: {
cameraMode: CameraMode;
fogEnabled: boolean;
handTrackingSource: HandTrackingSource;
showDebugOverlay: boolean;
showHandTrackingSvg: boolean;
@@ -80,7 +90,8 @@ export class Debug {
this.controls = {
cameraMode: storedControls.cameraMode ?? "player",
handTrackingSource: "backend",
fogEnabled: FOG_CONFIG.enabled,
handTrackingSource: "browser",
showDebugOverlay: true,
showHandTrackingSvg: false,
showInteractionSpheres: false,
@@ -88,14 +99,12 @@ export class Debug {
sceneMode: storedControls.sceneMode ?? "game",
};
this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null;
this.gui = this.active ? new GUI({ title: "La Fabrik" }) : null;
if (this.gui) {
const folder = this.createFolder("Debug");
this.gui.open();
if (!folder) return;
folder
this.gui
.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" })
.name("Camera Mode")
.onChange((value: CameraMode) => {
@@ -103,7 +112,7 @@ export class Debug {
this.saveAndEmit();
});
folder
this.gui
.add(this.controls, "sceneMode", { Game: "game", Physics: "physics" })
.name("Scene")
.onChange((value: SceneMode) => {
@@ -111,7 +120,7 @@ export class Debug {
this.saveAndEmit();
});
folder
this.gui
.add(this.controls, "showPerf")
.name("R3F Perf")
.onChange((value: boolean) => {
@@ -119,7 +128,7 @@ export class Debug {
this.emit();
});
folder
this.gui
.add(this.controls, "showDebugOverlay")
.name("Debug Overlay")
.onChange((value: boolean) => {
@@ -127,6 +136,8 @@ export class Debug {
this.emit();
});
this.createOrderedFolders();
const handTrackingFolder = this.createFolder("Hand Tracking");
handTrackingFolder
@@ -139,8 +150,8 @@ export class Debug {
handTrackingFolder
?.add(this.controls, "handTrackingSource", {
Backend: "backend",
"Browser JS": "browser",
Backend: "backend",
})
.name("Source")
.onChange((value: HandTrackingSource) => {
@@ -154,23 +165,47 @@ export class Debug {
* Acquires a named GUI folder. Returns the folder on first acquisition and null
* on subsequent acquisitions so callers only register controls once.
*/
createFolder(name: string): GUI | null {
createFolder(name: string, options?: { open?: boolean }): GUI | null {
if (!this.gui) return null;
const existing = this.folders.get(name);
if (existing) {
this.folderRefCounts.set(name, (this.folderRefCounts.get(name) ?? 0) + 1);
return null;
const refCount = this.folderRefCounts.get(name) ?? 0;
if (refCount > 0) {
this.folderRefCounts.set(name, refCount + 1);
return null;
}
this.folderRefCounts.set(name, 1);
return existing;
}
const folder = this.gui.addFolder(name);
this.folders.set(name, folder);
this.folderRefCounts.set(name, 1);
this.sortFolders();
if (options?.open) {
folder.open();
} else {
folder.close();
}
return folder;
}
addFogControl(folder: GUI): void {
folder
.add(this.controls, "fogEnabled")
.name("Fog")
.onChange((value: boolean) => {
this.controls.fogEnabled = value;
this.emit();
});
}
destroyFolder(name: string): void {
const folder = this.folders.get(name);
const refCount = this.folderRefCounts.get(name);
@@ -206,6 +241,10 @@ export class Debug {
return this.controls.handTrackingSource;
}
getFogEnabled(): boolean {
return this.controls.fogEnabled;
}
getShowInteractionSpheres(): boolean {
return this.controls.showInteractionSpheres;
}
@@ -247,4 +286,50 @@ export class Debug {
this.emit();
}
private createOrderedFolders(): void {
for (const folderName of DEBUG_FOLDER_ORDER) {
this.ensureFolder(folderName);
}
}
private ensureFolder(name: string): GUI | null {
if (!this.gui) return null;
const existing = this.folders.get(name);
if (existing) return existing;
const folder = this.gui.addFolder(name);
folder.close();
this.folders.set(name, folder);
this.folderRefCounts.set(name, 0);
this.sortFolders();
return folder;
}
private sortFolders(): void {
if (!this.gui) return;
const rootElement = this.gui.domElement.querySelector(".children");
if (!rootElement) return;
const orderedFolders = [...this.folders.entries()].sort(([a], [b]) => {
const aIndex = DEBUG_FOLDER_ORDER.indexOf(
a as (typeof DEBUG_FOLDER_ORDER)[number],
);
const bIndex = DEBUG_FOLDER_ORDER.indexOf(
b as (typeof DEBUG_FOLDER_ORDER)[number],
);
const safeAIndex = aIndex === -1 ? DEBUG_FOLDER_ORDER.length : aIndex;
const safeBIndex = bIndex === -1 ? DEBUG_FOLDER_ORDER.length : bIndex;
if (safeAIndex !== safeBIndex) return safeAIndex - safeBIndex;
return a.localeCompare(b);
});
for (const [, folder] of orderedFolders) {
rootElement.appendChild(folder.domElement);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import {
TERRAIN_COLORS,
TERRAIN_SURFACE_COLOR_TOLERANCE,
type TerrainColorKey,
} from "@/data/world/terrainConfig";
import type { TerrainSurfaceRgb } from "@/types/world/terrainSurface";
export function colorMatchesTerrainSurface(
r: number,
g: number,
b: number,
targetRgb: TerrainSurfaceRgb,
tolerance: number = TERRAIN_SURFACE_COLOR_TOLERANCE,
): boolean {
return (
Math.abs(r - targetRgb[0]) <= tolerance &&
Math.abs(g - targetRgb[1]) <= tolerance &&
Math.abs(b - targetRgb[2]) <= tolerance
);
}
export function getTerrainColorKeyFromRgb(
r: number,
g: number,
b: number,
): TerrainColorKey | null {
for (const [key, config] of Object.entries(TERRAIN_COLORS)) {
if (colorMatchesTerrainSurface(r, g, b, config.rgb)) {
return key as TerrainColorKey;
}
}
return null;
}
export function isGrassTerrainColor(r: number, g: number, b: number): boolean {
const key = getTerrainColorKeyFromRgb(r, g, b);
return key !== null && TERRAIN_COLORS[key].kind === "grass";
}
export function getGrassTipColorFromRgb(
r: number,
g: number,
b: number,
): string | null {
const key = getTerrainColorKeyFromRgb(r, g, b);
if (key === null) return null;
const terrainColor = TERRAIN_COLORS[key];
return "grassTipColor" in terrainColor ? terrainColor.grassTipColor : null;
}
+106
View File
@@ -0,0 +1,106 @@
import type * as THREE from "three";
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
import type {
TerrainSurfaceBounds,
TerrainSurfaceRgb,
TerrainSurfaceSample,
TerrainSurfaceUv,
} from "@/types/world/terrainSurface";
import { getTerrainColorKeyFromRgb } from "@/utils/world/terrainSurfaceColor";
type TerrainSurfaceImageSource =
| HTMLImageElement
| HTMLCanvasElement
| ImageBitmap;
const imageDataCache = new WeakMap<TerrainSurfaceImageSource, ImageData>();
function clamp01(value: number): number {
return Math.min(Math.max(value, 0), 1);
}
function isTerrainSurfaceImageSource(
value: unknown,
): value is TerrainSurfaceImageSource {
return (
value instanceof HTMLImageElement ||
value instanceof HTMLCanvasElement ||
(typeof ImageBitmap !== "undefined" && value instanceof ImageBitmap)
);
}
export function createTerrainSurfaceImageData(
texture: THREE.Texture,
): ImageData | null {
if (typeof document === "undefined") return null;
const image = texture.image as unknown;
if (!isTerrainSurfaceImageSource(image)) return null;
const cachedImageData = imageDataCache.get(image);
if (cachedImageData) return cachedImageData;
const width = image.width;
const height = image.height;
if (width <= 0 || height <= 0) return null;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return null;
canvas.width = width;
canvas.height = height;
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
imageDataCache.set(image, imageData);
return imageData;
}
export function sampleTerrainSurfaceAtUv(
imageData: ImageData,
uv: TerrainSurfaceUv,
): TerrainSurfaceSample {
const x = Math.round(clamp01(uv.u) * (imageData.width - 1));
const y = Math.round((1 - clamp01(uv.v)) * (imageData.height - 1));
const index = (y * imageData.width + x) * 4;
const rgb: TerrainSurfaceRgb = [
imageData.data[index] ?? 0,
imageData.data[index + 1] ?? 0,
imageData.data[index + 2] ?? 0,
];
const key = getTerrainColorKeyFromRgb(rgb[0], rgb[1], rgb[2]);
return {
rgb,
key,
config: key === null ? null : TERRAIN_COLORS[key],
};
}
export function terrainSurfaceUvFromXZ(
x: number,
z: number,
bounds: TerrainSurfaceBounds,
): TerrainSurfaceUv {
const width = bounds.maxX - bounds.minX;
const depth = bounds.maxZ - bounds.minZ;
return {
u: width === 0 ? 0 : (x - bounds.minX) / width,
v: depth === 0 ? 0 : (z - bounds.minZ) / depth,
};
}
export function sampleTerrainSurfaceAtXZ(
imageData: ImageData,
x: number,
z: number,
bounds: TerrainSurfaceBounds,
): TerrainSurfaceSample {
return sampleTerrainSurfaceAtUv(
imageData,
terrainSurfaceUvFromXZ(x, z, bounds),
);
}
+6 -1
View File
@@ -14,10 +14,12 @@ import {
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
import { SkyModel } from "@/components/three/world/SkyModel";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
export function Environment(): React.JSX.Element {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const fogEnabled = useDebugStore((debug) => debug.getFogEnabled());
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const showSky = isMapModelVisible("sky", { groups, models });
@@ -30,7 +32,10 @@ export function Environment(): React.JSX.Element {
return (
<>
{FOG_CONFIG.enabled && sceneMode === "game" && cameraMode === "player" ? (
{FOG_CONFIG.enabled &&
fogEnabled &&
sceneMode === "game" &&
cameraMode === "player" ? (
<fog
attach="fog"
args={[FOG_CONFIG.color, FOG_CONFIG.near, FOG_CONFIG.far]}
+22 -9
View File
@@ -111,10 +111,13 @@ export function GameMap({
const settledMapNodesRef = useRef(new Set<number>());
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [renderMapNodes, setRenderMapNodes] = useState<LoadedMapNode[]>([]);
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
[],
);
const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
const mapReady = mapLoaded && settledMapNodeCount >= mapNodes.length;
const mapReady = mapLoaded && settledMapNodeCount >= renderMapNodes.length;
const handleMapNodeSettled = useCallback((index: number) => {
if (settledMapNodesRef.current.has(index)) return;
@@ -125,7 +128,8 @@ export function GameMap({
const showEmptyMap = useCallback(
(currentStep: string) => {
setMapNodes([]);
setRenderMapNodes([]);
setCollisionMapNodes([]);
setMapLoaded(true);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
@@ -174,6 +178,12 @@ export function GameMap({
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
const loadedCollisionNodes = sceneData.mapNodes
.filter((node) => node.name === "terrain")
.map((node) => {
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
const missingModelCount = loadedMapNodes.filter(
(mapNode) => mapNode.modelUrl === null,
).length;
@@ -188,7 +198,8 @@ export function GameMap({
);
}
setMapNodes(loadedMapNodes);
setRenderMapNodes(loadedMapNodes);
setCollisionMapNodes(loadedCollisionNodes);
setMapLoaded(true);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
@@ -209,21 +220,23 @@ export function GameMap({
}, [onLoadingStateChange, showEmptyMap]);
useEffect(() => {
if (mapNodes.length === 0) return;
if (renderMapNodes.length === 0) return;
const renderProgress =
mapNodes.length === 0 ? 1 : settledMapNodeCount / mapNodes.length;
renderMapNodes.length === 0
? 1
: settledMapNodeCount / renderMapNodes.length;
onLoadingStateChange?.({
currentStep: "Chargement des modèles de la map",
progress: 0.25 + renderProgress * 0.45,
status: "loading",
});
}, [mapNodes.length, onLoadingStateChange, settledMapNodeCount]);
}, [renderMapNodes.length, onLoadingStateChange, settledMapNodeCount]);
return (
<>
<group>
{mapNodes.map((mapNode, index) => (
{renderMapNodes.map((mapNode, index) => (
<ModelErrorBoundary
key={index}
fallback={<FallbackMapNode node={mapNode.node} />}
@@ -251,7 +264,7 @@ export function GameMap({
<GameMapCollision
buildOctree={buildOctree}
mapReady={mapReady}
nodes={mapNodes}
nodes={collisionMapNodes}
onLoaded={onLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={onOctreeReady}
+24 -9
View File
@@ -1,4 +1,4 @@
import { Suspense, useMemo, useRef, useState } from "react";
import { Suspense, useCallback, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/fogConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
@@ -106,16 +106,21 @@ export function VegetationSystem(): React.JSX.Element | null {
}, [data, groups, models]);
const visibleChunks = streamingEnabled
? chunks.filter((chunk) => activeChunkKeys.has(chunk.key))
? 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,
) <= CHUNK_CONFIG.loadRadius
);
})
: chunks;
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
@@ -143,6 +148,16 @@ export function VegetationSystem(): React.JSX.Element | null {
}
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
if (isLoading || !data) {
+1 -1
View File
@@ -16,7 +16,7 @@ export const VEGETATION_TYPES = {
sapin: {
mapName: "sapin",
modelPath: "/models/sapin/model.gltf",
scaleMultiplier: 2,
scaleMultiplier: 5,
castShadow: true,
receiveShadow: true,
enabled: true,