+ ).mock.calls.find(
+ ([event]) => event === EventType.ASSETS_LOADING_PROGRESS,
+ )?.[1] as ((payload: unknown) => void) | undefined;
expect(progressHandler).toBeDefined();
diff --git a/packages/client/tests/unit/screens/StreamingMode.component.test.tsx b/packages/client/tests/unit/screens/StreamingMode.component.test.tsx
index ae8320a77..ad06ec098 100644
--- a/packages/client/tests/unit/screens/StreamingMode.component.test.tsx
+++ b/packages/client/tests/unit/screens/StreamingMode.component.test.tsx
@@ -122,10 +122,14 @@ vi.mock("../../../src/components/streaming/StreamingOverlay", () => ({
state,
}: {
state: { cycle?: { phase?: string } } | null;
- }) => {state?.cycle?.phase ?? "NONE"}
,
+ }) => (
+ {state?.cycle?.phase ?? "NONE"}
+ ),
}));
-function createStreamingState(phase: "ANNOUNCEMENT" | "COUNTDOWN" | "FIGHTING" = "FIGHTING") {
+function createStreamingState(
+ phase: "ANNOUNCEMENT" | "COUNTDOWN" | "FIGHTING" = "FIGHTING",
+) {
return {
type: "STREAMING_STATE_UPDATE" as const,
cycle: {
@@ -196,13 +200,17 @@ describe("StreamingMode component", () => {
json: async () => createStreamingState(),
})),
);
- (window as Window & {
- __HYPERSCAPE_STREAM_READY__?: boolean;
- __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: unknown;
- }).__HYPERSCAPE_STREAM_READY__ = false;
- (window as Window & {
- __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: unknown;
- }).__HYPERSCAPE_STREAM_RENDERER_HEALTH__ = null;
+ (
+ window as Window & {
+ __HYPERSCAPE_STREAM_READY__?: boolean;
+ __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: unknown;
+ }
+ ).__HYPERSCAPE_STREAM_READY__ = false;
+ (
+ window as Window & {
+ __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: unknown;
+ }
+ ).__HYPERSCAPE_STREAM_RENDERER_HEALTH__ = null;
});
afterEach(() => {
@@ -240,19 +248,23 @@ describe("StreamingMode component", () => {
await waitFor(() => {
expect(
- (window as Window & {
- __HYPERSCAPE_STREAM_READY__?: boolean;
- __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: {
- degradedReason?: string | null;
- } | null;
- }).__HYPERSCAPE_STREAM_READY__,
+ (
+ window as Window & {
+ __HYPERSCAPE_STREAM_READY__?: boolean;
+ __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: {
+ degradedReason?: string | null;
+ } | null;
+ }
+ ).__HYPERSCAPE_STREAM_READY__,
).toBe(false);
expect(
- (window as Window & {
- __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: {
- degradedReason?: string | null;
- } | null;
- }).__HYPERSCAPE_STREAM_RENDERER_HEALTH__?.degradedReason,
+ (
+ window as Window & {
+ __HYPERSCAPE_STREAM_RENDERER_HEALTH__?: {
+ degradedReason?: string | null;
+ } | null;
+ }
+ ).__HYPERSCAPE_STREAM_RENDERER_HEALTH__?.degradedReason,
).toBe("initialization_failed");
});
});
diff --git a/packages/procgen/src/building/town/TownGenerator.ts b/packages/procgen/src/building/town/TownGenerator.ts
index 1a5c337f8..d42cce5ef 100644
--- a/packages/procgen/src/building/town/TownGenerator.ts
+++ b/packages/procgen/src/building/town/TownGenerator.ts
@@ -38,6 +38,7 @@ import {
WATER_CHECK_DIRECTIONS,
WATER_CHECK_MAX_DISTANCE,
WATER_CHECK_STEP,
+ DEFAULT_WATER_THRESHOLD,
} from "./constants";
// Import grid alignment utilities from building generator
@@ -212,7 +213,8 @@ export class TownGenerator {
const terrain = createTerrainProviderFromGenerator(terrainGenerator);
// Extract water threshold from terrain generator if available
- const waterThreshold = terrainGenerator.getWaterThreshold?.() ?? 5.4;
+ const waterThreshold =
+ terrainGenerator.getWaterThreshold?.() ?? DEFAULT_WATER_THRESHOLD;
return new TownGenerator({
...options,
diff --git a/packages/procgen/src/building/town/constants.ts b/packages/procgen/src/building/town/constants.ts
index 9225a57bd..a2452afd6 100644
--- a/packages/procgen/src/building/town/constants.ts
+++ b/packages/procgen/src/building/town/constants.ts
@@ -21,9 +21,8 @@ export const DEFAULT_WORLD_SIZE = 10000;
export const DEFAULT_MIN_TOWN_SPACING = 800;
export const DEFAULT_FLATNESS_SAMPLE_RADIUS = 40;
export const DEFAULT_FLATNESS_SAMPLE_COUNT = 16;
-// IMPORTANT: Water threshold must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0)
-// This ensures town candidates are placed on actual land, not underwater areas
-export const DEFAULT_WATER_THRESHOLD = 9.0;
+import { DEFAULT_WATER_THRESHOLD } from "../../terrain/constants";
+export { DEFAULT_WATER_THRESHOLD };
export const DEFAULT_OPTIMAL_WATER_DISTANCE_MIN = 30;
export const DEFAULT_OPTIMAL_WATER_DISTANCE_MAX = 150;
diff --git a/packages/procgen/src/items/dock/DockGenerator.ts b/packages/procgen/src/items/dock/DockGenerator.ts
index e88a0722d..47a915b89 100644
--- a/packages/procgen/src/items/dock/DockGenerator.ts
+++ b/packages/procgen/src/items/dock/DockGenerator.ts
@@ -47,8 +47,9 @@ import { createRng, type RNG } from "../../math/Random.js";
// CONSTANTS
// ============================================================================
-/** Default water level (Y coordinate) */
-const DEFAULT_WATER_LEVEL = 5.0;
+import { GAME_WATER_LEVEL } from "../../terrain/constants";
+
+const DEFAULT_WATER_LEVEL = GAME_WATER_LEVEL;
/** Default water floor depth below water level */
const DEFAULT_WATER_FLOOR_DEPTH = 3.0;
diff --git a/packages/procgen/src/terrain/TerrainGenerator.ts b/packages/procgen/src/terrain/TerrainGenerator.ts
index 7c5d72634..dd6103c8c 100644
--- a/packages/procgen/src/terrain/TerrainGenerator.ts
+++ b/packages/procgen/src/terrain/TerrainGenerator.ts
@@ -11,6 +11,7 @@
import { NoiseGenerator, createTileRNG } from "./NoiseGenerator";
import { BiomeSystem } from "./BiomeSystem";
import { IslandMask, DEFAULT_ISLAND_CONFIG } from "./IslandMask";
+import { DEFAULT_MAX_HEIGHT, DEFAULT_WATER_THRESHOLD } from "./constants";
import type {
TerrainConfig,
TerrainNoiseConfig,
@@ -76,8 +77,8 @@ export const DEFAULT_TERRAIN_CONFIG: TerrainConfig = {
tileSize: 100,
worldSize: 100,
tileResolution: 64,
- maxHeight: 30,
- waterThreshold: 5.4,
+ maxHeight: DEFAULT_MAX_HEIGHT,
+ waterThreshold: DEFAULT_WATER_THRESHOLD,
seed: 0,
noise: DEFAULT_NOISE_CONFIG,
biomes: {
diff --git a/packages/procgen/src/terrain/TerrainShaderTSL.ts b/packages/procgen/src/terrain/TerrainShaderTSL.ts
index e0e36bebb..93b817417 100644
--- a/packages/procgen/src/terrain/TerrainShaderTSL.ts
+++ b/packages/procgen/src/terrain/TerrainShaderTSL.ts
@@ -52,7 +52,7 @@ export const TERRAIN_CONSTANTS = {
DIRT_THRESHOLD: 0.5,
LOD_FULL_DETAIL: 100.0,
LOD_MEDIUM_DETAIL: 200.0,
- WATER_LEVEL: 5.0,
+ WATER_LEVEL: 16, // Overridden at runtime by game; standalone default for procgen previews
FOG_COLOR: new THREE.Color(0xd4c8b8),
} as const;
diff --git a/packages/procgen/src/terrain/constants.ts b/packages/procgen/src/terrain/constants.ts
new file mode 100644
index 000000000..eeb44a606
--- /dev/null
+++ b/packages/procgen/src/terrain/constants.ts
@@ -0,0 +1,26 @@
+/**
+ * Procgen Terrain Constants — single source of truth for terrain defaults.
+ *
+ * These values define the procgen package's default terrain parameters.
+ * All files in @hyperscape/procgen should reference these instead of
+ * hardcoding magic numbers.
+ *
+ * NOTE: The game runtime in @hyperscape/shared has its own
+ * TERRAIN_CONSTANTS.WATER_THRESHOLD (= 16) and MAX_HEIGHT (= 50).
+ * The procgen defaults below use a different height scale (maxHeight=30).
+ * When procgen is used inside the game, the game passes its own values
+ * via config overrides — these are just standalone defaults.
+ */
+
+/** Default max terrain height in world units */
+export const DEFAULT_MAX_HEIGHT = 30;
+
+/** Default water threshold in world units (terrain below this is underwater) */
+export const DEFAULT_WATER_THRESHOLD = 5.4;
+
+/**
+ * Water level in world-space Y for the game runtime.
+ * Must match TERRAIN_CONSTANTS.WATER_THRESHOLD in @hyperscape/shared.
+ * Used by systems that need the actual game water Y (e.g. DockGenerator).
+ */
+export const GAME_WATER_LEVEL = 16;
diff --git a/packages/procgen/src/terrain/index.ts b/packages/procgen/src/terrain/index.ts
index 4c0591302..7a2757990 100644
--- a/packages/procgen/src/terrain/index.ts
+++ b/packages/procgen/src/terrain/index.ts
@@ -21,6 +21,13 @@
* ```
*/
+// Shared constants (single source of truth for procgen terrain defaults)
+export {
+ DEFAULT_MAX_HEIGHT,
+ DEFAULT_WATER_THRESHOLD,
+ GAME_WATER_LEVEL,
+} from "./constants";
+
// Core generator
export {
TerrainGenerator,
diff --git a/packages/procgen/src/terrain/presets.ts b/packages/procgen/src/terrain/presets.ts
index b8ffa0899..232bc9a8d 100644
--- a/packages/procgen/src/terrain/presets.ts
+++ b/packages/procgen/src/terrain/presets.ts
@@ -7,6 +7,7 @@
import type { TerrainConfig, TerrainPreset } from "./types";
import { DEFAULT_TERRAIN_CONFIG } from "./TerrainGenerator";
+import { DEFAULT_MAX_HEIGHT, DEFAULT_WATER_THRESHOLD } from "./constants";
/**
* Small Island preset
@@ -20,8 +21,8 @@ export const SMALL_ISLAND_PRESET: TerrainPreset = {
config: {
tileSize: 100,
worldSize: 10, // 1km x 1km
- maxHeight: 30,
- waterThreshold: 5.4,
+ maxHeight: DEFAULT_MAX_HEIGHT,
+ waterThreshold: DEFAULT_WATER_THRESHOLD,
island: {
enabled: true,
maxWorldSizeTiles: 10,
@@ -52,8 +53,8 @@ export const LARGE_ISLAND_PRESET: TerrainPreset = {
config: {
tileSize: 100,
worldSize: 100, // 10km x 10km
- maxHeight: 30,
- waterThreshold: 5.4,
+ maxHeight: DEFAULT_MAX_HEIGHT,
+ waterThreshold: DEFAULT_WATER_THRESHOLD,
island: {
enabled: true,
maxWorldSizeTiles: 100,
diff --git a/packages/procgen/src/vegetation/types.ts b/packages/procgen/src/vegetation/types.ts
index 6a2ce7517..e926c0d8f 100644
--- a/packages/procgen/src/vegetation/types.ts
+++ b/packages/procgen/src/vegetation/types.ts
@@ -169,8 +169,8 @@ export const DEFAULT_PLACER_CONFIG: VegetationPlacerConfig = {
steepSlopeThreshold: 0.6,
};
-/** Default water threshold when not provided */
-export const DEFAULT_WATER_THRESHOLD = 5.4;
+import { DEFAULT_WATER_THRESHOLD } from "../terrain/constants";
+export { DEFAULT_WATER_THRESHOLD };
/** Generator-like interface for terrain provider adapter */
export interface TerrainGeneratorLike {
diff --git a/packages/server/scripts/stream-to-rtmp.ts b/packages/server/scripts/stream-to-rtmp.ts
index 48791f0ec..bc5c7ade9 100644
--- a/packages/server/scripts/stream-to-rtmp.ts
+++ b/packages/server/scripts/stream-to-rtmp.ts
@@ -100,9 +100,8 @@ function withViewerAccessToken(rawUrl: string): string {
const GAME_URL_CANDIDATES = Array.from(
new Set([GAME_URL, ...GAME_FALLBACK_URLS].map(withViewerAccessToken)),
);
-const ALLOWED_CAPTURE_ORIGINS = resolveAllowedCaptureOrigins(
- GAME_URL_CANDIDATES,
-);
+const ALLOWED_CAPTURE_ORIGINS =
+ resolveAllowedCaptureOrigins(GAME_URL_CANDIDATES);
const BRIDGE_PORT = parseInt(process.env.RTMP_BRIDGE_PORT || "8765", 10);
const BRIDGE_URL = `ws://localhost:${BRIDGE_PORT}`;
@@ -497,7 +496,9 @@ function assertAllowedCaptureNavigation(rawUrl: string): void {
);
}
-async function abortCaptureForUnexpectedNavigation(rawUrl: string): Promise {
+async function abortCaptureForUnexpectedNavigation(
+ rawUrl: string,
+): Promise {
if (captureNavigationAbortInFlight) {
return;
}
diff --git a/packages/server/src/routes/streaming.ts b/packages/server/src/routes/streaming.ts
index 2f9bbf178..37e1385f6 100644
--- a/packages/server/src/routes/streaming.ts
+++ b/packages/server/src/routes/streaming.ts
@@ -77,7 +77,10 @@ const STREAMING_SSE_MAX_PENDING_BYTES = Math.max(
128 * 1024,
Math.min(
16 * 1024 * 1024,
- Number.parseInt(process.env.STREAMING_SSE_MAX_PENDING_BYTES || "1048576", 10),
+ Number.parseInt(
+ process.env.STREAMING_SSE_MAX_PENDING_BYTES || "1048576",
+ 10,
+ ),
),
);
const STREAMING_SSE_REPLAY_MAX_BYTES = Math.max(
diff --git a/packages/server/src/startup/uws-server.ts b/packages/server/src/startup/uws-server.ts
index a6a02a73e..50d227cca 100644
--- a/packages/server/src/startup/uws-server.ts
+++ b/packages/server/src/startup/uws-server.ts
@@ -121,11 +121,9 @@ export function createUwsServer(
userData.query,
),
).catch((err: unknown) => {
- const msg = err instanceof Error ? err.message : String(err);
- console.error(
- `[uWS] onConnection error for ${userData.wsId}: ${msg}`,
- );
- });
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[uWS] onConnection error for ${userData.wsId}: ${msg}`);
+ });
},
message: (ws, message, _isBinary) => {
diff --git a/packages/server/src/streaming/captureBrowserPolicy.ts b/packages/server/src/streaming/captureBrowserPolicy.ts
index d124674e8..cf67f3fcf 100644
--- a/packages/server/src/streaming/captureBrowserPolicy.ts
+++ b/packages/server/src/streaming/captureBrowserPolicy.ts
@@ -82,7 +82,10 @@ export function shouldAcceptCaptureReadiness(params: {
return false;
}
- if (snapshot.degradedReason && snapshot.degradedReason !== "loading_overlay_active") {
+ if (
+ snapshot.degradedReason &&
+ snapshot.degradedReason !== "loading_overlay_active"
+ ) {
return false;
}
diff --git a/packages/server/src/streaming/redactStreamingUrl.ts b/packages/server/src/streaming/redactStreamingUrl.ts
index f5465c5f2..90e49db94 100644
--- a/packages/server/src/streaming/redactStreamingUrl.ts
+++ b/packages/server/src/streaming/redactStreamingUrl.ts
@@ -20,7 +20,8 @@ export function redactStreamingSecretsFromUrl(rawUrl: string): string {
const baseWithQuery = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
const rawHash = hashIndex >= 0 ? rawUrl.slice(hashIndex + 1) : "";
const queryIndex = baseWithQuery.indexOf("?");
- const base = queryIndex >= 0 ? baseWithQuery.slice(0, queryIndex) : baseWithQuery;
+ const base =
+ queryIndex >= 0 ? baseWithQuery.slice(0, queryIndex) : baseWithQuery;
const rawQuery = queryIndex >= 0 ? baseWithQuery.slice(queryIndex + 1) : "";
const sanitizedQuery = stripTokenFromSegment(rawQuery);
const sanitizedHash = stripTokenFromSegment(rawHash);
diff --git a/packages/server/tests/unit/routes/streaming-betting-auth.test.ts b/packages/server/tests/unit/routes/streaming-betting-auth.test.ts
index aa570f8cb..f6675700e 100644
--- a/packages/server/tests/unit/routes/streaming-betting-auth.test.ts
+++ b/packages/server/tests/unit/routes/streaming-betting-auth.test.ts
@@ -34,10 +34,7 @@ describe("streaming-betting-auth", () => {
});
it("does not accept query tokens unless the route explicitly allows them", () => {
- expect(
- extractBettingFeedToken({
- }),
- ).toBeNull();
+ expect(extractBettingFeedToken({})).toBeNull();
});
it("prefers BETTING_FEED_ACCESS_TOKEN over the viewer token", () => {
diff --git a/packages/server/tests/unit/routes/streaming-betting-renderer-health.test.ts b/packages/server/tests/unit/routes/streaming-betting-renderer-health.test.ts
index 83be9f10d..0404c9f73 100644
--- a/packages/server/tests/unit/routes/streaming-betting-renderer-health.test.ts
+++ b/packages/server/tests/unit/routes/streaming-betting-renderer-health.test.ts
@@ -2,7 +2,10 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-import { deriveBettingRendererHealth, loadExternalRtmpStatusSnapshot } from "../../../src/routes/streaming-betting-routes.js";
+import {
+ deriveBettingRendererHealth,
+ loadExternalRtmpStatusSnapshot,
+} from "../../../src/routes/streaming-betting-routes.js";
import type { StreamingDuelCycle } from "../../../src/systems/StreamingDuelScheduler/types.js";
function createCycle(
diff --git a/packages/server/tests/unit/routes/streaming-betting-routes.test.ts b/packages/server/tests/unit/routes/streaming-betting-routes.test.ts
index a86b8d11e..dbbfa4dba 100644
--- a/packages/server/tests/unit/routes/streaming-betting-routes.test.ts
+++ b/packages/server/tests/unit/routes/streaming-betting-routes.test.ts
@@ -252,9 +252,9 @@ describe("streaming-betting-routes", () => {
});
it("accepts only one explicit internal betting CORS origin", () => {
- expect(
- normalizeInternalAllowedOrigin("https://bets.example.com"),
- ).toBe("https://bets.example.com");
+ expect(normalizeInternalAllowedOrigin("https://bets.example.com")).toBe(
+ "https://bets.example.com",
+ );
expect(normalizeInternalAllowedOrigin("*")).toBeNull();
expect(normalizeInternalAllowedOrigin("null")).toBeNull();
expect(
@@ -297,9 +297,10 @@ describe("streaming-betting-routes", () => {
});
it("allocates betting client ids without colliding after wraparound", () => {
- const allocation = allocateNextBettingClientId(Number.MAX_SAFE_INTEGER - 1, [
- 1,
- ]);
+ const allocation = allocateNextBettingClientId(
+ Number.MAX_SAFE_INTEGER - 1,
+ [1],
+ );
expect(allocation).toEqual({
clientId: Number.MAX_SAFE_INTEGER - 1,
diff --git a/packages/server/tests/unit/systems/DuelScheduler/DuelBettingBridge.test.ts b/packages/server/tests/unit/systems/DuelScheduler/DuelBettingBridge.test.ts
index d4a739fb6..8f3a6a7db 100644
--- a/packages/server/tests/unit/systems/DuelScheduler/DuelBettingBridge.test.ts
+++ b/packages/server/tests/unit/systems/DuelScheduler/DuelBettingBridge.test.ts
@@ -72,7 +72,9 @@ describe("DuelBettingBridge streaming reconciliation", () => {
let world: ReturnType;
let bridge: DuelBettingBridge;
let bridgeHarness: DuelBettingBridgeTestHarness;
- let scheduler: { getCurrentCycle: () => ReturnType | null } | null;
+ let scheduler: {
+ getCurrentCycle: () => ReturnType | null;
+ } | null;
beforeEach(() => {
vi.useFakeTimers();
diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts
index f095c3149..6261e8d56 100644
--- a/packages/shared/src/constants/GameConstants.ts
+++ b/packages/shared/src/constants/GameConstants.ts
@@ -83,7 +83,7 @@ export const TERRAIN_CONSTANTS = {
* Terrain below this height is underwater and impassable.
* Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem
*/
- WATER_THRESHOLD: 8.0,
+ WATER_THRESHOLD: 16,
/**
* Buffer distance above water where vegetation shouldn't spawn.
@@ -102,9 +102,9 @@ export const TERRAIN_CONSTANTS = {
/**
* Maximum slope for walkable terrain (tan of angle).
* Slopes steeper than this block movement.
- * 1.5 ≈ 56 degree angle.
+ * 2.5 ≈ 68 degree angle.
*/
- MAX_WALKABLE_SLOPE: 1.5,
+ MAX_WALKABLE_SLOPE: 2.5,
/**
* Distance to sample for slope calculation (in meters).
diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts
index 33c21e927..4276d6ec4 100644
--- a/packages/shared/src/constants/TreeTypes.ts
+++ b/packages/shared/src/constants/TreeTypes.ts
@@ -20,19 +20,16 @@
* Use this instead of hardcoded "tree_xxx" strings everywhere.
*/
export enum TreeId {
- Fir = "tree_fir",
Pine = "tree_pine",
Oak = "tree_oak",
Birch = "tree_birch",
- Bamboo = "tree_bamboo",
- ChinaPine = "tree_chinaPine",
Maple = "tree_maple",
- Coconut = "tree_coconut",
Palm = "tree_palm",
+ Banana = "tree_banana",
Dead = "tree_dead",
- Cactus = "tree_cactus",
Knotwood = "tree_knotwood",
- WindPine = "tree_windPine",
+ PineDead = "tree_pineDead",
+ PineSnow = "tree_pineSnow",
}
/** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */
@@ -45,10 +42,15 @@ export interface TreePlacementRules {
/**
* How strongly this tree prefers water-adjacent placement (0–1).
* 0 = no preference, 1 = only spawns near water.
- * At intermediate values, spawn probability scales with water proximity.
+ * When > 0, a radial distance-to-water search is performed and trees
+ * beyond waterMaxDistance are rejected with probability waterAffinity.
*/
waterAffinity?: number;
- /** If waterAffinity > 0, the max height above water to consider "near water" */
+ /** Horizontal search radius (meters) when looking for nearby water. Default 40. */
+ waterSearchRadius?: number;
+ /** Max horizontal distance from shore (meters) before rejection kicks in. Default 30. */
+ waterMaxDistance?: number;
+ /** @deprecated Use waterMaxDistance instead. Kept for backward compat. */
waterProximityHeight?: number;
/** Reject placement if position is below this height above water threshold */
avoidsWaterBelow?: number;
@@ -69,6 +71,10 @@ export interface TreeTypeDefinition {
name: string;
/** Woodcutting level required to chop */
levelRequired: number;
+ /** Tree can receive snow in tundra biome (via R-channel or normal fallback) */
+ snowCapable?: boolean;
+ /** Model vertex-color R channel contains explicit snow mask data */
+ snowVertexData?: boolean;
}
/**
@@ -78,19 +84,21 @@ export interface TreeTypeDefinition {
* in TerrainBiomeTypes.ts.
*/
export const TREE_TYPES = {
- fir: { name: "Fir Tree", levelRequired: 1 },
- pine: { name: "Pine Tree", levelRequired: 1 },
+ pine: { name: "Pine Tree", levelRequired: 1, snowCapable: true },
oak: { name: "Oak Tree", levelRequired: 15 },
birch: { name: "Birch Tree", levelRequired: 1 },
- bamboo: { name: "Bamboo Tree", levelRequired: 1 },
- chinaPine: { name: "China Pine", levelRequired: 1 },
maple: { name: "Maple Tree", levelRequired: 45 },
- coconut: { name: "Coconut Palm", levelRequired: 1 },
palm: { name: "Desert Palm", levelRequired: 1 },
+ banana: { name: "Banana Tree", levelRequired: 1 },
dead: { name: "Dead Tree", levelRequired: 1 },
- cactus: { name: "Cactus", levelRequired: 1 },
knotwood: { name: "Knotwood Tree", levelRequired: 1 },
- windPine: { name: "Wind Pine", levelRequired: 1 },
+ pineDead: { name: "Dead Pine", levelRequired: 1, snowCapable: true },
+ pineSnow: {
+ name: "Snow Pine",
+ levelRequired: 1,
+ snowCapable: true,
+ snowVertexData: true,
+ },
} as const satisfies Record;
/** All valid tree subtype keys (e.g., "oak", "willow") */
diff --git a/packages/shared/src/core/World.ts b/packages/shared/src/core/World.ts
index 65f0cc1f8..c0fab424c 100644
--- a/packages/shared/src/core/World.ts
+++ b/packages/shared/src/core/World.ts
@@ -993,7 +993,7 @@ export class World extends EventEmitter {
// - far (2000): Optimized draw distance for performance
// Sky geometry is scaled to fit within this range (follows camera)
// This prevents z-fighting without needing expensive logarithmic depth buffers
- this.camera = new THREE.PerspectiveCamera(70, 16 / 9, 0.2, 800);
+ this.camera = new THREE.PerspectiveCamera(70, 16 / 9, 0.2, 10000);
// Enable layer 1 for main camera (vegetation, water, grass, building walls are on layer 1)
// Enable layer 2 for building floors (walkable surfaces for click-to-move)
diff --git a/packages/shared/src/data/arena-layout.ts b/packages/shared/src/data/arena-layout.ts
new file mode 100644
index 000000000..59e08db77
--- /dev/null
+++ b/packages/shared/src/data/arena-layout.ts
@@ -0,0 +1,65 @@
+/**
+ * Arena Layout Constants — Single Source of Truth
+ *
+ * ALL arena positioning derives from the values in this file.
+ * To relocate the arena complex, edit the constants here and
+ * everything (visuals, server logic, zone bounds) follows automatically.
+ */
+
+// ---------------------------------------------------------------------------
+// Arena Grid
+// ---------------------------------------------------------------------------
+export const ARENA_BASE_X = 340;
+export const ARENA_BASE_Z = 394;
+export const ARENA_BASE_Y = 0.42;
+export const ARENA_WIDTH = 20;
+export const ARENA_LENGTH = 24;
+export const ARENA_GAP = 4;
+export const ARENA_COLUMNS = 2;
+export const ARENA_ROWS = 3;
+export const ARENA_COUNT = 6;
+export const ARENA_SPAWN_OFFSET = 8;
+
+// ---------------------------------------------------------------------------
+// Lobby (south of arenas, right side)
+// ---------------------------------------------------------------------------
+export const LOBBY_CENTER_X = 385;
+export const LOBBY_CENTER_Z = 376;
+export const LOBBY_WIDTH = 40;
+export const LOBBY_LENGTH = 25;
+
+// ---------------------------------------------------------------------------
+// Hospital (south of arenas, left side)
+// ---------------------------------------------------------------------------
+export const HOSPITAL_CENTER_X = 345;
+export const HOSPITAL_CENTER_Z = 376;
+export const HOSPITAL_WIDTH = 28;
+export const HOSPITAL_LENGTH = 23;
+
+// ---------------------------------------------------------------------------
+// Lobby Spawn Point (where players appear in the lobby)
+// ---------------------------------------------------------------------------
+export const LOBBY_SPAWN_X = 385;
+export const LOBBY_SPAWN_Y = 0.42;
+export const LOBBY_SPAWN_Z = 374;
+
+// ---------------------------------------------------------------------------
+// Derived: overall zone bounds (encompasses arenas + lobby + hospital + margin)
+// ---------------------------------------------------------------------------
+const gridMaxX =
+ ARENA_BASE_X + ARENA_COLUMNS * ARENA_WIDTH + (ARENA_COLUMNS - 1) * ARENA_GAP;
+const gridMaxZ =
+ ARENA_BASE_Z + ARENA_ROWS * ARENA_LENGTH + (ARENA_ROWS - 1) * ARENA_GAP;
+const lobbyMinX = LOBBY_CENTER_X - LOBBY_WIDTH / 2;
+const lobbyMaxX = LOBBY_CENTER_X + LOBBY_WIDTH / 2;
+const lobbyMinZ = LOBBY_CENTER_Z - LOBBY_LENGTH / 2;
+const hospMinX = HOSPITAL_CENTER_X - HOSPITAL_WIDTH / 2;
+const hospMinZ = HOSPITAL_CENTER_Z - HOSPITAL_LENGTH / 2;
+
+const MARGIN = 15;
+export const ZONE_BOUNDS_MIN_X =
+ Math.min(ARENA_BASE_X, lobbyMinX, hospMinX) - MARGIN;
+export const ZONE_BOUNDS_MAX_X = Math.max(gridMaxX, lobbyMaxX) + MARGIN;
+export const ZONE_BOUNDS_MIN_Z =
+ Math.min(ARENA_BASE_Z, lobbyMinZ, hospMinZ) - MARGIN;
+export const ZONE_BOUNDS_MAX_Z = Math.max(gridMaxZ) + MARGIN;
diff --git a/packages/shared/src/data/duel-manifest.ts b/packages/shared/src/data/duel-manifest.ts
index 14ad6a94e..3f0e98bf6 100644
--- a/packages/shared/src/data/duel-manifest.ts
+++ b/packages/shared/src/data/duel-manifest.ts
@@ -238,10 +238,25 @@ export function areRulesCompatible(
// ============================================================================
import { ALL_WORLD_AREAS } from "./world-areas";
+import {
+ ARENA_BASE_X,
+ ARENA_BASE_Z,
+ ARENA_BASE_Y,
+ ARENA_WIDTH,
+ ARENA_LENGTH,
+ ARENA_GAP,
+ ARENA_COLUMNS,
+ ARENA_ROWS,
+ ARENA_COUNT,
+ ARENA_SPAWN_OFFSET,
+ LOBBY_SPAWN_X,
+ LOBBY_SPAWN_Y,
+ LOBBY_SPAWN_Z,
+} from "./arena-layout";
/**
* Arena layout configuration derived from world-areas.json manifest.
- * Single source of truth for arena positioning and dimensions.
+ * Positions come from arena-layout.ts (single source of truth).
*/
export interface DuelArenaConfig {
/** Base X coordinate for arena grid */
@@ -272,17 +287,17 @@ export interface DuelArenaConfig {
* Default arena config (fallback if manifest not loaded)
*/
const DEFAULT_ARENA_CONFIG: DuelArenaConfig = {
- baseX: 60,
- baseZ: 80,
- baseY: 0.42,
- arenaWidth: 20,
- arenaLength: 24,
- arenaGap: 4,
- columns: 2,
- rows: 3,
- arenaCount: 6,
- spawnOffset: 8,
- lobbySpawnPoint: { x: 105, y: 0.42, z: 60 },
+ baseX: ARENA_BASE_X,
+ baseZ: ARENA_BASE_Z,
+ baseY: ARENA_BASE_Y,
+ arenaWidth: ARENA_WIDTH,
+ arenaLength: ARENA_LENGTH,
+ arenaGap: ARENA_GAP,
+ columns: ARENA_COLUMNS,
+ rows: ARENA_ROWS,
+ arenaCount: ARENA_COUNT,
+ spawnOffset: ARENA_SPAWN_OFFSET,
+ lobbySpawnPoint: { x: LOBBY_SPAWN_X, y: LOBBY_SPAWN_Y, z: LOBBY_SPAWN_Z },
};
/**
diff --git a/packages/shared/src/data/world-areas.ts b/packages/shared/src/data/world-areas.ts
index a78dd6d2f..710193310 100644
--- a/packages/shared/src/data/world-areas.ts
+++ b/packages/shared/src/data/world-areas.ts
@@ -21,6 +21,12 @@ import type {
StationLocation,
WorldArea,
} from "../types/core/core";
+import {
+ ZONE_BOUNDS_MIN_X,
+ ZONE_BOUNDS_MAX_X,
+ ZONE_BOUNDS_MIN_Z,
+ ZONE_BOUNDS_MAX_Z,
+} from "./arena-layout";
// Re-export types from core
export type {
@@ -38,36 +44,6 @@ export type {
* DEFAULT: If JSON is empty, use this hardcoded starter area
*/
export const ALL_WORLD_AREAS: Record = {
- starter_area: {
- id: "starter_area",
- name: "Starter Area",
- description: "A peaceful area for new adventurers",
- difficultyLevel: 0,
- bounds: {
- minX: -50,
- maxX: 50,
- minZ: -50,
- maxZ: 50,
- },
- biomeType: "plains",
- safeZone: true,
- npcs: [
- {
- id: "bank_clerk",
- type: "bank",
- position: { x: 5, y: 0, z: -5 },
- // All other NPC data (name, services, model, description) comes from npcs.json
- },
- ],
- resources: [
- // Resources are now defined in world-areas.json manifest only
- // Do not add hardcoded resources here
- ],
- mobSpawns: [
- // Starter area is a safe zone - no mob spawns
- // The default test goblin is spawned by MobNPCSpawnerSystem near origin
- ],
- },
// Wilderness test zone - PvP enabled area for testing player vs player combat
wilderness_test: {
id: "wilderness_test",
@@ -99,11 +75,10 @@ export const ALL_WORLD_AREAS: Record = {
"A gladiatorial arena where players can engage in honorable combat. Stake items and fight!",
difficultyLevel: 0,
bounds: {
- // Encompasses all arenas, lobby, and hospital
- minX: 35, // Hospital left edge (65 - 15)
- maxX: 145, // Lobby right edge (105 + 20 + some margin)
- minZ: 37, // Lobby/hospital bottom edge (62 - 12.5)
- maxZ: 140, // Arena 6 top edge
+ minX: ZONE_BOUNDS_MIN_X,
+ maxX: ZONE_BOUNDS_MAX_X,
+ minZ: ZONE_BOUNDS_MIN_Z,
+ maxZ: ZONE_BOUNDS_MAX_Z,
},
biomeType: "plains",
safeZone: true, // Lobby area is safe
@@ -118,9 +93,7 @@ export const ALL_WORLD_AREAS: Record = {
/**
* Starter Towns - Populated by DataManager from world-areas.json
*/
-export const STARTER_TOWNS: Record = {
- starter_area: ALL_WORLD_AREAS["starter_area"],
-};
+export const STARTER_TOWNS: Record = {};
/**
* Helper Functions
diff --git a/packages/shared/src/data/world-structure.ts b/packages/shared/src/data/world-structure.ts
index 719a31567..f76ebc930 100644
--- a/packages/shared/src/data/world-structure.ts
+++ b/packages/shared/src/data/world-structure.ts
@@ -117,7 +117,7 @@ export function getTerrainHeight(_x: number, _z: number): number {
export const WORLD_STRUCTURE_CONSTANTS = {
GRID_SIZE: 4, // Block size for grid-based movement
DEFAULT_SPAWN_HEIGHT: 2,
- WATER_LEVEL: 0,
+ WATER_LEVEL: 16, // Must match TERRAIN_CONSTANTS.WATER_THRESHOLD
MAX_BUILD_HEIGHT: 100,
SAFE_ZONE_RADIUS: 15, // Radius around starter towns with no hostile mobs
// Note: Death/respawn timing constants are in COMBAT_CONSTANTS (tick-based)
diff --git a/packages/shared/src/extras/three/three.ts b/packages/shared/src/extras/three/three.ts
index effea7625..270efb7fc 100644
--- a/packages/shared/src/extras/three/three.ts
+++ b/packages/shared/src/extras/three/three.ts
@@ -41,6 +41,7 @@ export const {
positionView,
normalLocal,
normalWorld,
+ normalWorldGeometry,
normalView,
cameraPosition,
cameraProjectionMatrix,
diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts
index c2323423f..2e8fdbe0a 100644
--- a/packages/shared/src/runtime/createClientWorld.ts
+++ b/packages/shared/src/runtime/createClientWorld.ts
@@ -314,7 +314,7 @@ export function createClientWorld() {
world.register("towns", TownSystem);
world.register("pois", POISystem);
- world.register("roads", RoadNetworkSystem);
+ // world.register("roads", RoadNetworkSystem);
// ============================================================================
// BUILDING RENDERING SYSTEM
@@ -369,7 +369,6 @@ export function createClientWorld() {
// ============================================================================
// DOCK SYSTEM
// ============================================================================
- // Procedural docks for ponds and lakes — collision + mesh on client
world.register("docks", ProceduralDocks);
// ============================================================================
diff --git a/packages/shared/src/runtime/createServerWorld.ts b/packages/shared/src/runtime/createServerWorld.ts
index 56f512cef..8ac373b2f 100644
--- a/packages/shared/src/runtime/createServerWorld.ts
+++ b/packages/shared/src/runtime/createServerWorld.ts
@@ -100,7 +100,7 @@ export async function createServerWorld(): Promise {
world.register("towns", TownSystem);
world.register("pois", POISystem);
- world.register("roads", RoadNetworkSystem);
+ // world.register("roads", RoadNetworkSystem);
// ============================================================================
// RPG GAME SYSTEMS
diff --git a/packages/shared/src/systems/client/ClientCameraSystem.ts b/packages/shared/src/systems/client/ClientCameraSystem.ts
index 63f4f9ea0..ff76f160c 100644
--- a/packages/shared/src/systems/client/ClientCameraSystem.ts
+++ b/packages/shared/src/systems/client/ClientCameraSystem.ts
@@ -16,6 +16,7 @@ import {
isEmbeddedSpectatorViewport,
isStreamPageRoute,
} from "../../runtime/clientViewportMode";
+import { getDuelArenaConfig } from "../../data/duel-manifest";
import { RaycastService } from "./interaction/services/RaycastService";
// CameraTarget interface moved to shared types
@@ -1254,11 +1255,10 @@ export class ClientCameraSystem extends SystemBase {
/** Park the camera at the duel arena lobby when no entities exist. */
private positionCameraAtArenaFallback(): void {
if (this._arenaFallbackApplied || !this.camera) return;
- // Arena lobby: default { x: 105, y: 0.42, z: 60 }
- // We offset the camera above and behind the lobby point.
- const lobbyX = 105;
- const lobbyY = 0.42;
- const lobbyZ = 60;
+ const lobby = getDuelArenaConfig().lobbySpawnPoint;
+ const lobbyX = lobby.x;
+ const lobbyY = lobby.y;
+ const lobbyZ = lobby.z;
this.camera.position.set(lobbyX - 15, lobbyY + 20, lobbyZ + 25);
this.camera.lookAt(lobbyX, lobbyY + 2, lobbyZ);
this._arenaFallbackApplied = true;
diff --git a/packages/shared/src/systems/client/ClientGraphics.ts b/packages/shared/src/systems/client/ClientGraphics.ts
index 8697b49ad..4803240fa 100644
--- a/packages/shared/src/systems/client/ClientGraphics.ts
+++ b/packages/shared/src/systems/client/ClientGraphics.ts
@@ -358,10 +358,8 @@ export class ClientGraphics extends System {
render() {
if (!this.usePostprocessing || !this.composer) {
- // Direct rendering without post-processing
this.renderer.render(this.world.stage.scene, this.world.camera);
} else {
- // Render with post-processing (bloom via TSL)
this.composer.render();
}
this.hasRendered = true;
diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
index a5ddb9658..03f9fc483 100644
--- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
+++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
@@ -18,7 +18,7 @@
* - 6 rectangular arenas in a 2x3 grid
* - Each arena is 20m wide x 24m long
* - 4m gap between arenas
- * - Base coordinates: x=60, z=80 (near spawn)
+ * - Base coordinates: see arena-layout.ts (single source of truth)
*/
import THREE, {
@@ -51,18 +51,27 @@ import type { Physics } from "../shared/interaction/Physics";
import type { PxRigidStatic } from "../../types/systems/physics";
import type { ParticleSystem } from "../shared/presentation/ParticleSystem";
import type { FlatZone } from "../../types/world/terrain";
+import {
+ ARENA_BASE_X,
+ ARENA_BASE_Z,
+ ARENA_WIDTH,
+ ARENA_LENGTH,
+ ARENA_GAP,
+ ARENA_COUNT,
+ LOBBY_CENTER_X,
+ LOBBY_CENTER_Z,
+ LOBBY_WIDTH,
+ LOBBY_LENGTH,
+ HOSPITAL_CENTER_X,
+ HOSPITAL_CENTER_Z,
+ HOSPITAL_WIDTH,
+ HOSPITAL_LENGTH,
+} from "../../data/arena-layout";
// ============================================================================
-// Arena Configuration (matches ArenaPoolManager)
+// Arena Configuration (layout from arena-layout.ts)
// ============================================================================
-const ARENA_BASE_X = 60;
-const ARENA_BASE_Z = 80;
-const ARENA_WIDTH = 20;
-const ARENA_LENGTH = 24;
-const ARENA_GAP = 4;
-const ARENA_COUNT = 6;
-
const FENCE_HEIGHT = 1.5;
const FENCE_POST_SPACING = 2.0;
const FENCE_POST_SIZE = 0.2;
@@ -72,16 +81,6 @@ const FENCE_RAIL_HEIGHTS = [0.3, 0.75, 1.2];
const FLOOR_THICKNESS = 0.3;
const FLOOR_HEIGHT_OFFSET = 0.27;
-const LOBBY_CENTER_X = 105;
-const LOBBY_CENTER_Z = 62;
-const LOBBY_WIDTH = 40;
-const LOBBY_LENGTH = 25;
-
-const HOSPITAL_CENTER_X = 65;
-const HOSPITAL_CENTER_Z = 62;
-const HOSPITAL_WIDTH = 30;
-const HOSPITAL_LENGTH = 25;
-
const LOBBY_FLOOR_COLOR = 0xc9b896;
const HOSPITAL_FLOOR_COLOR = 0xffffff;
@@ -919,9 +918,9 @@ export class DuelArenaVisualsSystem extends System {
);
}
- // Lobby corner pillars (4)
- const lobbyHW = LOBBY_WIDTH / 2;
- const lobbyHL = LOBBY_LENGTH / 2;
+ // Lobby corner pillars (4) — inset by half the pillar base so they sit exactly at the floor corner
+ const lobbyHW = LOBBY_WIDTH / 2 - PILLAR_BASE_SIZE / 2;
+ const lobbyHL = LOBBY_LENGTH / 2 - PILLAR_BASE_SIZE / 2;
for (const c of [
{ x: LOBBY_CENTER_X - lobbyHW, z: LOBBY_CENTER_Z - lobbyHL },
{ x: LOBBY_CENTER_X + lobbyHW, z: LOBBY_CENTER_Z - lobbyHL },
@@ -934,9 +933,9 @@ export class DuelArenaVisualsSystem extends System {
});
}
- // Hospital corner pillars (4)
- const hospHW = HOSPITAL_WIDTH / 2;
- const hospHL = HOSPITAL_LENGTH / 2;
+ // Hospital corner pillars (4) — same inset as lobby
+ const hospHW = HOSPITAL_WIDTH / 2 - PILLAR_BASE_SIZE / 2;
+ const hospHL = HOSPITAL_LENGTH / 2 - PILLAR_BASE_SIZE / 2;
for (const c of [
{ x: HOSPITAL_CENTER_X - hospHW, z: HOSPITAL_CENTER_Z - hospHL },
{ x: HOSPITAL_CENTER_X + hospHW, z: HOSPITAL_CENTER_Z - hospHL },
@@ -1284,8 +1283,6 @@ export class DuelArenaVisualsSystem extends System {
const clothMats = uniqueColors.map((c) => {
const m = new MeshStandardNodeMaterial({
color: c,
- emissive: c,
- emissiveIntensity: 0.3,
side: THREE.DoubleSide,
});
this.materials.push(m);
@@ -1348,8 +1345,6 @@ export class DuelArenaVisualsSystem extends System {
const material = new MeshStandardNodeMaterial({
color: LOBBY_FLOOR_COLOR,
map: tileTexture,
- emissive: LOBBY_FLOOR_COLOR,
- emissiveIntensity: 0.3,
});
const floor = new THREE.Mesh(geometry, material);
@@ -1457,8 +1452,6 @@ export class DuelArenaVisualsSystem extends System {
const material = new MeshStandardNodeMaterial({
color: HOSPITAL_FLOOR_COLOR,
- emissive: HOSPITAL_FLOOR_COLOR,
- emissiveIntensity: 0.3,
});
const floor = new THREE.Mesh(geometry, material);
@@ -1495,8 +1488,6 @@ export class DuelArenaVisualsSystem extends System {
const crossMaterial = new MeshStandardNodeMaterial({
color: 0xff0000,
- emissive: 0xff0000,
- emissiveIntensity: 0.5,
});
this.materials.push(crossMaterial);
diff --git a/packages/shared/src/systems/client/WaterfallVisualsSystem.ts b/packages/shared/src/systems/client/WaterfallVisualsSystem.ts
index d5a89d7e4..b35b96f6a 100644
--- a/packages/shared/src/systems/client/WaterfallVisualsSystem.ts
+++ b/packages/shared/src/systems/client/WaterfallVisualsSystem.ts
@@ -25,8 +25,6 @@ import {
abs,
} from "three/tsl";
import type { WaterfallDefinition } from "../shared/world/WaterfallDefinition";
-import { computeWaterfalls } from "../shared/world/WaterfallDefinition";
-import type { TerrainSystem } from "../shared/world/TerrainSystem";
interface WaterfallMeshHandle {
mesh: THREE.Mesh;
@@ -58,17 +56,8 @@ export class WaterfallVisualsSystem extends SystemBase {
async start(): Promise {
if (this.world.isServer) return;
- const terrain = this.world.getSystem("terrain") as TerrainSystem | null;
- if (!terrain) return;
-
- const registry = terrain.getWaterBodyRegistry();
- if (!registry) return;
-
- const riverDef = registry.getRiverDef();
- if (!riverDef) return;
-
- // Detect waterfalls along the river
- const waterfalls = computeWaterfalls(riverDef, 2.0, 2.0);
+ // Waterfalls require a river definition — currently disabled (no manual river)
+ const waterfalls: WaterfallDefinition[] = [];
if (waterfalls.length === 0) return;
// Create shared TSL material for all waterfalls
diff --git a/packages/shared/src/systems/client/ZoneVisualsSystem.ts b/packages/shared/src/systems/client/ZoneVisualsSystem.ts
index 5d3f4b1ed..0e9957aa7 100644
--- a/packages/shared/src/systems/client/ZoneVisualsSystem.ts
+++ b/packages/shared/src/systems/client/ZoneVisualsSystem.ts
@@ -142,10 +142,10 @@ export class ZoneVisualsSystem extends SystemBase {
if (area.id === "duel_arena") {
return "swords";
}
- // Wilderness/PvP zones (not duel arena) get skull
- if (!area.safeZone || (area.pvpEnabled && area.id !== "duel_arena")) {
- return "skull";
- }
+ // Wilderness/PvP zones (not duel arena) — skull disabled for now
+ // if (!area.safeZone || (area.pvpEnabled && area.id !== "duel_arena")) {
+ // return "skull";
+ // }
// Safe towns get home
if (area.safeZone && !area.pvpEnabled) {
return "home";
@@ -156,9 +156,9 @@ export class ZoneVisualsSystem extends SystemBase {
/**
* Check if zone should have a red border band
*/
- private shouldHaveBorder(area: WorldArea): boolean {
- // Only wilderness/dangerous zones get the red border (not duel arena)
- return !area.safeZone && area.id !== "duel_arena";
+ private shouldHaveBorder(_area: WorldArea): boolean {
+ // Borders disabled for now
+ return false;
}
/**
diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts
index ed55aca4b..6d77859c8 100644
--- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts
+++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts
@@ -866,8 +866,100 @@ export class ResourceSystem extends SystemBase {
if (spawnPoints.length === 0) return;
- // Only spawn actual entities on the server (authoritative)
if (!this.world.isServer) {
+ for (const spawnPoint of spawnPoints) {
+ const resource = this.createResourceFromSpawnPoint(spawnPoint);
+ if (!resource) continue;
+
+ const rid = createResourceID(resource.id);
+ this.resources.set(rid, resource);
+ if (isManifest) this.manifestResourceIds.add(rid);
+
+ const yRotation = spawnPoint.rotation ?? Math.random() * Math.PI * 2;
+ const footprint: ResourceFootprint = resource.footprint || "standard";
+ const anchorTile = worldToTile(
+ resource.position.x,
+ resource.position.z,
+ );
+ const occupiedTiles = this.getOccupiedTiles(anchorTile, footprint);
+ const baseScale = this.getScaleForResource(
+ resource.type,
+ spawnPoint.subType,
+ );
+ const scaleVariation = spawnPoint.scale ?? 1.0;
+ const finalScale = baseScale * scaleVariation;
+ const baseDepletedScale = this.getDepletedScaleForResource(
+ resource.type,
+ spawnPoint.subType,
+ );
+ const finalDepletedScale = baseDepletedScale * scaleVariation;
+
+ const entityData = {
+ id: resource.id,
+ type: "resource" as const,
+ name: resource.name,
+ position: [
+ resource.position.x,
+ resource.position.y,
+ resource.position.z,
+ ] as [number, number, number],
+ quaternion: [
+ 0,
+ Math.sin(yRotation / 2),
+ 0,
+ Math.cos(yRotation / 2),
+ ] as [number, number, number, number],
+ scale: { x: 1, y: 1, z: 1 },
+ visible: true,
+ interactable: true,
+ interactionType: "harvest",
+ interactionDistance: 3,
+ description: `${resource.name} - Requires level ${resource.levelRequired} ${resource.skillRequired}`,
+ model: this.getModelPathForResource(
+ resource.type,
+ spawnPoint.subType,
+ ),
+ properties: {},
+ resourceType: resource.type === "ore" ? "mining_rock" : resource.type,
+ resourceId: spawnPoint.subType
+ ? `${resource.type}_${spawnPoint.subType}`
+ : `${resource.type}_normal`,
+ harvestSkill: resource.skillRequired,
+ requiredLevel: resource.levelRequired,
+ harvestTime: 3000,
+ harvestYield: resource.drops.map((drop) => ({
+ itemId: drop.itemId,
+ quantity: drop.quantity,
+ chance: drop.chance,
+ })),
+ respawnTime: resource.respawnTime,
+ depleted: false,
+ depletedModelPath: this.getDepletedModelPathForResource(
+ resource.type,
+ spawnPoint.subType,
+ ),
+ modelScale: finalScale,
+ depletedModelScale: finalDepletedScale,
+ lod1Model: this.getLod1ModelPathForResource(
+ resource.type,
+ spawnPoint.subType,
+ ),
+ lod1ModelScale: finalScale,
+ procgenPreset: this.getProcgenPresetForResource(
+ resource.type,
+ spawnPoint.subType,
+ ),
+ modelVariants: this.getModelVariantsForResource(
+ resource.type,
+ spawnPoint.subType,
+ ),
+ footprint,
+ anchorTile,
+ occupiedTiles,
+ };
+
+ this.world.entities.add(entityData);
+ }
return;
}
diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts
index c66c1ea2f..23d07a93f 100644
--- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts
+++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts
@@ -29,6 +29,198 @@ import {
import type { TreePlacementRules } from "../../../constants/TreeTypes";
import { getTreeConfigForBiome } from "./TerrainBiomeTypes";
+// ---------------------------------------------------------------------------
+// Bridson's Poisson Disk Sampling — O(n) blue-noise point generation
+// ---------------------------------------------------------------------------
+
+/**
+ * Generate well-spaced 2D points using Bridson's algorithm.
+ * Points are guaranteed to be at least `minDistance` apart while packing
+ * as densely as possible (blue noise distribution).
+ *
+ * An optional `padding` expands the sampling area beyond the output bounds
+ * so that points near tile edges respect spacing against hypothetical
+ * neighbors, reducing cross-tile clumping.
+ */
+function poissonDiskSample2D(
+ width: number,
+ height: number,
+ minDistance: number,
+ rng: () => number,
+ padding = 0,
+ k = 30,
+): Array<{ x: number; z: number }> {
+ if (width <= 0 || height <= 0 || minDistance <= 0) return [];
+
+ const r2 = minDistance * minDistance;
+ const cellSize = minDistance / Math.SQRT2;
+
+ const xMin = -padding;
+ const zMin = -padding;
+ const xMax = width + padding;
+ const zMax = height + padding;
+ const sampleW = xMax - xMin;
+ const sampleH = zMax - zMin;
+
+ const cols = Math.ceil(sampleW / cellSize);
+ const rows = Math.ceil(sampleH / cellSize);
+ const grid = new Int32Array(cols * rows).fill(-1);
+
+ const samplesX: number[] = [];
+ const samplesZ: number[] = [];
+ const active: number[] = [];
+
+ const addSample = (x: number, z: number): void => {
+ const idx = samplesX.length;
+ samplesX.push(x);
+ samplesZ.push(z);
+ active.push(idx);
+ const ix = Math.floor((x - xMin) / cellSize);
+ const iz = Math.floor((z - zMin) / cellSize);
+ grid[iz * cols + ix] = idx;
+ };
+
+ const isFarEnough = (x: number, z: number): boolean => {
+ const ix = Math.floor((x - xMin) / cellSize);
+ const iz = Math.floor((z - zMin) / cellSize);
+ for (let dz = -2; dz <= 2; dz++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ const nx = ix + dx;
+ const nz = iz + dz;
+ if (nx < 0 || nz < 0 || nx >= cols || nz >= rows) continue;
+ const si = grid[nz * cols + nx];
+ if (si === -1) continue;
+ const ddx = x - samplesX[si];
+ const ddz = z - samplesZ[si];
+ if (ddx * ddx + ddz * ddz < r2) return false;
+ }
+ }
+ return true;
+ };
+
+ addSample(xMin + rng() * sampleW, zMin + rng() * sampleH);
+
+ const twoPi = Math.PI * 2;
+ while (active.length > 0) {
+ const ai = Math.floor(rng() * active.length);
+ const pi = active[ai];
+ const px = samplesX[pi];
+ const pz = samplesZ[pi];
+
+ let found = false;
+ for (let i = 0; i < k; i++) {
+ const radius = minDistance * Math.sqrt(1 + 3 * rng());
+ const theta = rng() * twoPi;
+ const x = px + radius * Math.cos(theta);
+ const z = pz + radius * Math.sin(theta);
+
+ if (x < xMin || x >= xMax || z < zMin || z >= zMax) continue;
+ if (!isFarEnough(x, z)) continue;
+
+ addSample(x, z);
+ found = true;
+ break;
+ }
+
+ if (!found) {
+ active[ai] = active[active.length - 1];
+ active.pop();
+ }
+ }
+
+ const result: Array<{ x: number; z: number }> = [];
+ for (let i = 0; i < samplesX.length; i++) {
+ const x = samplesX[i];
+ const z = samplesZ[i];
+ if (x >= 0 && x < width && z >= 0 && z < height) {
+ result.push({ x, z });
+ }
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Species zoning — smooth 2D value noise so nearby trees tend to be the same type
+// ---------------------------------------------------------------------------
+
+const SPECIES_ZONE_SCALE = 0.012; // ~80m species zones
+const SPECIES_ZONE_BOOST = 3.0; // preferred species gets 3x weight
+const WATER_PROXIMITY_BOOST = 20.0; // water-affinity trees get 20x weight near water
+const WATER_HEIGHT_PRECHECK = 35; // only run expensive water search within this height above water
+
+const DENSITY_NOISE_SCALE = 0.006; // ~170m dense/sparse zones
+const DENSITY_NOISE_MIN = 0.15; // sparse zones still get 15% of trees
+const DENSITY_NOISE_POWER = 1.5; // push noise toward extremes
+
+function intHash2D(ix: number, iz: number): number {
+ let h = (ix * 374761393 + iz * 668265263) | 0;
+ h = ((h ^ (h >>> 13)) * 1274126177) | 0;
+ h = (h ^ (h >>> 16)) | 0;
+ return (h & 0x7fffffff) / 0x7fffffff;
+}
+
+function speciesNoise2D(x: number, z: number): number {
+ const ix = Math.floor(x);
+ const iz = Math.floor(z);
+ const fx = x - ix;
+ const fz = z - iz;
+ const ux = fx * fx * (3 - 2 * fx);
+ const uz = fz * fz * (3 - 2 * fz);
+ const h00 = intHash2D(ix, iz);
+ const h10 = intHash2D(ix + 1, iz);
+ const h01 = intHash2D(ix, iz + 1);
+ const h11 = intHash2D(ix + 1, iz + 1);
+ return (
+ h00 * (1 - ux) * (1 - uz) +
+ h10 * ux * (1 - uz) +
+ h01 * (1 - ux) * uz +
+ h11 * ux * uz
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Distance-to-water sampling — 8-direction radial search for nearest shoreline
+// ---------------------------------------------------------------------------
+
+const WATER_SEARCH_DIRECTIONS = 8;
+const WATER_SEARCH_STEP = 5; // meters between samples
+
+/**
+ * Find the horizontal distance from (worldX, worldZ) to the nearest water cell.
+ * Casts rays in 8 directions, stepping every WATER_SEARCH_STEP meters.
+ * Returns the distance in meters, or Infinity if no water found within searchRadius.
+ */
+function distanceToWater(
+ worldX: number,
+ worldZ: number,
+ getHeightAt: (x: number, z: number) => number,
+ waterThreshold: number,
+ searchRadius: number,
+): number {
+ let nearest = Infinity;
+ const angleStep = (Math.PI * 2) / WATER_SEARCH_DIRECTIONS;
+ const maxSteps = Math.ceil(searchRadius / WATER_SEARCH_STEP);
+
+ for (let dir = 0; dir < WATER_SEARCH_DIRECTIONS; dir++) {
+ const angle = dir * angleStep;
+ const dx = Math.cos(angle) * WATER_SEARCH_STEP;
+ const dz = Math.sin(angle) * WATER_SEARCH_STEP;
+
+ for (let step = 1; step <= maxSteps; step++) {
+ const dist = step * WATER_SEARCH_STEP;
+ if (dist >= nearest) break; // can't beat current best
+ const sx = worldX + dx * step;
+ const sz = worldZ + dz * step;
+ if (getHeightAt(sx, sz) < waterThreshold) {
+ nearest = dist;
+ break; // this direction found water, move to next
+ }
+ }
+ }
+
+ return nearest;
+}
+
/**
* Context provided by TerrainSystem for resource generation.
*/
@@ -141,116 +333,97 @@ export function generateTrees(
return [];
}
- const resources: ResourceNode[] = [];
-
- // Calculate tree count based on density and tile size
const tileArea = (ctx.tileSize / 100) * (ctx.tileSize / 100);
const baseCount = Math.floor(treeConfig.density * tileArea);
-
- if (baseCount === 0) {
- return [];
- }
+ if (baseCount === 0) return [];
const maxSlope = treeConfig.maxSlope ?? Infinity;
-
- // Use deterministic RNG for reproducible placement
const rng = ctx.createRng("trees");
- // Pre-compute tile-level distribution from the merged trees map
const tileTreeMap = treeConfig.trees;
const tileTreeTypes = Object.keys(tileTreeMap);
- if (tileTreeTypes.length === 0) {
- return [];
- }
+ if (tileTreeTypes.length === 0) return [];
const tileTotalWeight = Object.values(tileTreeMap).reduce(
(sum, cfg) => sum + cfg.weight,
0,
);
- if (tileTotalWeight === 0) {
- return [];
- }
+ if (tileTotalWeight === 0) return [];
- // Generate tree positions
- const placedPositions: Array<{ x: number; z: number }> = [];
+ // --- Phase 1: Generate well-spaced candidate positions via Poisson disk ---
const minSpacing = treeConfig.minSpacing;
- const minSpacingSq = minSpacing * minSpacing;
+ let candidates = poissonDiskSample2D(
+ ctx.tileSize,
+ ctx.tileSize,
+ minSpacing,
+ rng,
+ minSpacing,
+ );
- // If clustering is enabled, generate cluster centers first
- const clusterCenters: Array<{ x: number; z: number }> = [];
- if (treeConfig.clustering && treeConfig.clusterSize) {
- const numClusters = Math.max(
- 1,
- Math.ceil(baseCount / treeConfig.clusterSize),
+ // Clustering: place well-spaced cluster centers via Poisson disk, then keep
+ // only candidates within a cluster radius. Creates natural groves with clearings.
+ if (treeConfig.clustering) {
+ const treesPerCluster = treeConfig.clusterSize ?? 4;
+ const numClusters = Math.max(1, Math.ceil(baseCount / treesPerCluster));
+ const clusterRadius =
+ treeConfig.clusterRadius ?? treesPerCluster * minSpacing;
+ const clusterSpacing = treeConfig.clusterSpacing ?? clusterRadius * 2;
+ const clusterRadiusSq = clusterRadius * clusterRadius;
+
+ const centers = poissonDiskSample2D(
+ ctx.tileSize,
+ ctx.tileSize,
+ clusterSpacing,
+ rng,
+ clusterSpacing,
);
- for (let i = 0; i < numClusters; i++) {
- clusterCenters.push({
- x: rng() * ctx.tileSize,
- z: rng() * ctx.tileSize,
- });
+ // Shuffle and trim to desired cluster count
+ for (let i = centers.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1));
+ const tmp = centers[i];
+ centers[i] = centers[j];
+ centers[j] = tmp;
}
- }
-
- let treesPlaced = 0;
- const maxAttempts = baseCount * 10;
- let attempts = 0;
+ centers.length = Math.min(centers.length, numClusters);
- while (treesPlaced < baseCount && attempts < maxAttempts) {
- attempts++;
+ candidates = candidates.filter((p) => {
+ for (const c of centers) {
+ const dx = p.x - c.x;
+ const dz = p.z - c.z;
+ if (dx * dx + dz * dz < clusterRadiusSq) return true;
+ }
+ return false;
+ });
+ }
- // Generate position (clustered or uniform)
- let localX: number;
- let localZ: number;
+ // Shuffle candidates so terrain-filter order doesn't create spatial bias
+ for (let i = candidates.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1));
+ const tmp = candidates[i];
+ candidates[i] = candidates[j];
+ candidates[j] = tmp;
+ }
- if (treeConfig.clustering && clusterCenters.length > 0) {
- // Pick a random cluster center and scatter around it
- const cluster = clusterCenters[Math.floor(rng() * clusterCenters.length)];
- const scatterRadius = treeConfig.clusterSize! * 3;
- const angle = rng() * Math.PI * 2;
- const distance = rng() * scatterRadius;
- localX = cluster.x + Math.cos(angle) * distance;
- localZ = cluster.z + Math.sin(angle) * distance;
- } else {
- // Uniform random placement
- localX = rng() * ctx.tileSize;
- localZ = rng() * ctx.tileSize;
- }
+ // --- Phase 2: Filter candidates through terrain + biome rules ---
+ const resources: ResourceNode[] = [];
+ const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2];
- // Clamp to tile bounds
- localX = Math.max(0, Math.min(ctx.tileSize, localX));
- localZ = Math.max(0, Math.min(ctx.tileSize, localZ));
+ for (const pos of candidates) {
+ if (resources.length >= baseCount) break;
- // Convert to world coordinates for height lookup
+ const localX = pos.x;
+ const localZ = pos.z;
const worldX = ctx.tileX * ctx.tileSize + localX;
const worldZ = ctx.tileZ * ctx.tileSize + localZ;
const height = ctx.getHeightAt(worldX, worldZ);
- // Skip if underwater (ocean or river/pond)
- if (height < ctx.waterThreshold) {
- continue;
- }
+ if (height < ctx.waterThreshold) continue;
if (ctx.getWaterSurfaceAt) {
const waterY = ctx.getWaterSurfaceAt(worldX, worldZ);
- if (height <= waterY + 1.0) continue; // 1m buffer above water surface
+ if (height <= waterY + 1.0) continue;
}
- // Check minimum spacing
- let tooClose = false;
- for (const pos of placedPositions) {
- const dx = localX - pos.x;
- const dz = localZ - pos.z;
- if (dx * dx + dz * dz < minSpacingSq) {
- tooClose = true;
- break;
- }
- }
- if (tooClose) continue;
-
- // Check if on road
- if (ctx.isOnRoad?.(worldX, worldZ)) {
- continue;
- }
+ if (ctx.isOnRoad?.(worldX, worldZ)) continue;
- // Reject steep slopes — sample 4 neighbors to estimate gradient magnitude
if (maxSlope < Infinity) {
const sd = 1.0;
const dhdx =
@@ -264,12 +437,19 @@ export function generateTrees(
if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue;
}
- // Resolve the tree map for THIS position. If we have a per-position
- // biome callback, use it to get the actual biome here instead of
- // relying on the single tile-center biome.
+ // Density noise — natural dense groves and sparse clearings
+ const rawDensity = speciesNoise2D(
+ (worldX + 500) * DENSITY_NOISE_SCALE,
+ (worldZ + 500) * DENSITY_NOISE_SCALE,
+ );
+ const densityChance =
+ DENSITY_NOISE_MIN +
+ (1 - DENSITY_NOISE_MIN) * Math.pow(rawDensity, DENSITY_NOISE_POWER);
+ if (rng() > densityChance) continue;
+
+ // Per-position biome override at biome boundaries
let activeTreeMap = tileTreeMap;
let treeTypes = tileTreeTypes;
- let totalWeight = tileTotalWeight;
if (ctx.getDominantBiome) {
const positionBiome = ctx.getDominantBiome(worldX, worldZ);
@@ -277,20 +457,79 @@ export function generateTrees(
if (posConfig && posConfig.trees !== tileTreeMap) {
activeTreeMap = posConfig.trees;
treeTypes = Object.keys(activeTreeMap);
- totalWeight = Object.values(activeTreeMap).reduce(
+ const tw = Object.values(activeTreeMap).reduce(
(sum, cfg) => sum + cfg.weight,
0,
);
- if (totalWeight === 0 || treeTypes.length === 0) continue;
+ if (tw === 0 || treeTypes.length === 0) continue;
+ }
+ }
+
+ // Water proximity (cached per-position)
+ const heightAboveWater = height - ctx.waterThreshold;
+ let posDistToWater = Infinity;
+ let waterChecked = false;
+
+ if (heightAboveWater <= WATER_HEIGHT_PRECHECK) {
+ let maxSearch = 0;
+ for (const treeType of treeTypes) {
+ const cfg = activeTreeMap[treeType];
+ if (cfg.waterAffinity && cfg.waterAffinity > 0) {
+ maxSearch = Math.max(maxSearch, cfg.waterSearchRadius ?? 40);
+ }
+ }
+ if (maxSearch > 0) {
+ posDistToWater = distanceToWater(
+ worldX,
+ worldZ,
+ ctx.getHeightAt,
+ ctx.waterThreshold,
+ maxSearch,
+ );
+ waterChecked = true;
+ }
+ }
+
+ const nearWater = waterChecked && posDistToWater < Infinity;
+
+ // Species selection — weight-proportional zoning + water proximity boost
+ const zoneVal = speciesNoise2D(
+ worldX * SPECIES_ZONE_SCALE,
+ worldZ * SPECIES_ZONE_SCALE,
+ );
+ let activeTotalWeight = 0;
+ for (const tt of treeTypes) activeTotalWeight += activeTreeMap[tt].weight;
+ const scaledZone = zoneVal * activeTotalWeight;
+ let zoneAccum = 0;
+ let preferredSpecies = treeTypes[0];
+ for (const tt of treeTypes) {
+ zoneAccum += activeTreeMap[tt].weight;
+ if (scaledZone < zoneAccum) {
+ preferredSpecies = tt;
+ break;
+ }
+ }
+
+ let boostedTotal = 0;
+ for (const treeType of treeTypes) {
+ let w = activeTreeMap[treeType].weight;
+ if (treeType === preferredSpecies) w *= SPECIES_ZONE_BOOST;
+ if (nearWater && activeTreeMap[treeType].waterAffinity) {
+ w *= WATER_PROXIMITY_BOOST;
}
+ boostedTotal += w;
}
- // Select tree type based on weighted distribution
let selectedTreeId = treeTypes[0];
- const roll = rng() * totalWeight;
+ const roll = rng() * boostedTotal;
let cumulative = 0;
for (const treeType of treeTypes) {
- cumulative += activeTreeMap[treeType].weight;
+ let w = activeTreeMap[treeType].weight;
+ if (treeType === preferredSpecies) w *= SPECIES_ZONE_BOOST;
+ if (nearWater && activeTreeMap[treeType].waterAffinity) {
+ w *= WATER_PROXIMITY_BOOST;
+ }
+ cumulative += w;
if (roll < cumulative) {
selectedTreeId = treeType;
break;
@@ -298,11 +537,9 @@ export function generateTrees(
}
const selectedType = treeIdToSubType(selectedTreeId);
- // Apply per-tree placement rules from the merged config
+ // Per-tree placement rules (height, water affinity, etc.)
const rules: TreePlacementRules | undefined = activeTreeMap[selectedTreeId];
if (rules) {
- const heightAboveWater = height - ctx.waterThreshold;
-
if (rules.minHeight !== undefined && height < rules.minHeight) continue;
if (rules.maxHeight !== undefined && height > rules.maxHeight) continue;
@@ -313,39 +550,43 @@ export function generateTrees(
continue;
if (rules.waterAffinity && rules.waterAffinity > 0) {
- const proximityLimit = rules.waterProximityHeight ?? 10;
- if (heightAboveWater > proximityLimit) {
- if (rng() < rules.waterAffinity) continue;
+ const maxDist = rules.waterMaxDistance ?? 30;
+ let dist = posDistToWater;
+ if (!waterChecked) {
+ const searchRadius = rules.waterSearchRadius ?? 40;
+ dist = distanceToWater(
+ worldX,
+ worldZ,
+ ctx.getHeightAt,
+ ctx.waterThreshold,
+ searchRadius,
+ );
+ }
+ if (dist === Infinity) continue;
+ if (dist > maxDist) {
+ if (rules.waterAffinity >= 0.5 || rng() < rules.waterAffinity)
+ continue;
}
}
}
- // Generate random scale within variation range
- const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2];
const scale = minScale + rng() * (maxScale - minScale);
-
- // Generate random Y-axis rotation
const rotation = rng() * Math.PI * 2;
- // Create resource node
- const resource: ResourceNode = {
- id: `${ctx.tileKey}_tree_${treesPlaced}`,
+ resources.push({
+ id: `${ctx.tileKey}_tree_${resources.length}`,
type: "tree",
subType: selectedType as ResourceSubType,
position: { x: localX, y: height, z: localZ },
mesh: null,
health: 100,
maxHealth: 100,
- respawnTime: 300000, // 5 minutes
+ respawnTime: 300000,
harvestable: true,
requiredLevel: getTreeLevelRequirement(selectedType),
scale,
rotation,
- };
-
- resources.push(resource);
- placedPositions.push({ x: localX, z: localZ });
- treesPlaced++;
+ });
}
return resources;
diff --git a/packages/shared/src/systems/shared/world/BridgeDefinition.ts b/packages/shared/src/systems/shared/world/BridgeDefinition.ts
index 54b9567e2..50a844293 100644
--- a/packages/shared/src/systems/shared/world/BridgeDefinition.ts
+++ b/packages/shared/src/systems/shared/world/BridgeDefinition.ts
@@ -1,8 +1,10 @@
/**
- * BridgeDefinition — data for bridge placements across the river.
+ * BridgeDefinition — data for bridge placements.
*
* Each bridge specifies start/end positions and style. The BridgeSystem
* computes deck height from terrain + arch curve and generates collision.
+ *
+ * Devs assign exact start/end positions — no automatic river snapping.
*/
export type BridgeStyle = "stone" | "wood";
@@ -26,61 +28,19 @@ export interface BridgeDefinition {
}
/**
- * Bridge placements along the island river.
- *
- * Positioned to cross the river at strategic locations. Z endpoints are
- * auto-fitted by BridgeSystem to match the actual river geometry, so
- * startZ/endZ here are fallback values only.
- *
- * All bridges are wooden with fencing (posts + rails on both sides).
+ * Bridge placements — devs define exact start/end coordinates.
+ * Add entries here to place bridges anywhere on the map.
*/
export const ISLAND_BRIDGES: BridgeDefinition[] = [
{
- // Bridge 1: Western crossing
- id: "bridge_west",
- startX: -330,
- startZ: -100,
- endX: -330,
- endZ: -60,
- width: 4,
- railingHeight: 1.2,
- archHeight: 1.0,
- style: "wood",
- },
- {
- // Bridge 2: Central crossing (main route from spawn south)
- id: "bridge_central",
- startX: -60,
- startZ: -150,
- endX: -60,
- endZ: -110,
- width: 4.5,
- railingHeight: 1.2,
- archHeight: 1.2,
- style: "wood",
- },
- {
- // Bridge 3: Eastern forest crossing
- id: "bridge_east",
- startX: 230,
- startZ: -150,
- endX: 230,
- endZ: -110,
- width: 4,
- railingHeight: 1.2,
- archHeight: 1.0,
- style: "wood",
- },
- {
- // Bridge 4: Far east crossing near eastern coast
- id: "bridge_coastal",
- startX: 440,
- startZ: -70,
- endX: 440,
- endZ: -20,
- width: 4,
+ id: "bridge_river_crossing",
+ startX: 877.5,
+ startZ: 512.5,
+ endX: 1057.5,
+ endZ: 603.5,
+ width: 8,
railingHeight: 1.2,
- archHeight: 0.8,
+ archHeight: 2.0,
style: "wood",
},
];
diff --git a/packages/shared/src/systems/shared/world/BridgeSystem.ts b/packages/shared/src/systems/shared/world/BridgeSystem.ts
index 5c6aeed3b..ebcbad7c9 100644
--- a/packages/shared/src/systems/shared/world/BridgeSystem.ts
+++ b/packages/shared/src/systems/shared/world/BridgeSystem.ts
@@ -14,8 +14,6 @@
import type { World } from "../../../types";
import { SystemBase } from "../infrastructure/SystemBase";
import { ISLAND_BRIDGES, type BridgeDefinition } from "./BridgeDefinition";
-import { ISLAND_RIVER } from "./RiverDefinition";
-import { findRiverCenterAtX } from "./RiverUtils";
import { CollisionFlag } from "../movement/CollisionFlags";
import type { TerrainSystem } from "./TerrainSystem";
import THREE from "../../../extras/three/three";
@@ -190,7 +188,6 @@ export class BridgeSystem extends SystemBase {
private deckHeights: Map = new Map();
private bridgeMeshes: THREE.Object3D[] = [];
private bridgesRegistered = false;
- /** Bridge definitions with Z endpoints auto-fitted to the actual river. */
private adjustedBridges: BridgeDefinition[] | null = null;
/**
* Cached endpoint heights per bridge (startY, endY, waterY).
@@ -417,40 +414,18 @@ export class BridgeSystem extends SystemBase {
}
}
- /**
- * Get bridge definitions with Z endpoints auto-fitted to the actual river.
- * Computed lazily on first call — ISLAND_RIVER.waypoints must already be
- * subdivided by TerrainSystem (which calls registerBridgeCollision, triggering this).
- */
+ /** Get bridge definitions — uses positions as defined in BridgeDefinition. */
private getBridges(): BridgeDefinition[] {
if (!this.adjustedBridges) {
- this.adjustedBridges = ISLAND_BRIDGES.map((bridge) => {
- const rAt = findRiverCenterAtX(bridge.startX, ISLAND_RIVER);
- if (!rAt) return bridge;
- return {
- ...bridge,
- startZ: rAt.z - rAt.halfWidth - BRIDGE_BANK_APPROACH,
- endZ: rAt.z + rAt.halfWidth + BRIDGE_BANK_APPROACH,
- };
- });
+ this.adjustedBridges = [...ISLAND_BRIDGES];
}
return this.adjustedBridges;
}
- /** Get the river water surface Y at a given X coordinate. Returns 0 if unknown. */
- private getRiverSurfaceYAtX(x: number): number {
- const wps = ISLAND_RIVER.waypoints;
- for (let i = 0; i < wps.length - 1; i++) {
- const a = wps[i];
- const b = wps[i + 1];
- if ((a.x <= x && x <= b.x) || (b.x <= x && x <= a.x)) {
- const dx = b.x - a.x;
- if (Math.abs(dx) < 1e-6) continue;
- const t = (x - a.x) / dx;
- return (a.surfaceY ?? 0) + ((b.surfaceY ?? 0) - (a.surfaceY ?? 0)) * t;
- }
- }
- return 0;
+ /** Get water surface Y at a bridge position. Returns water threshold from terrain. */
+ private getRiverSurfaceYAtX(_x: number): number {
+ const terrain = this.world.getSystem("terrain") as TerrainSystem | null;
+ return terrain?.getWaterBodyRegistry()?.getOceanLevel() ?? 0;
}
async start(): Promise {
diff --git a/packages/shared/src/systems/shared/world/DockDefinition.ts b/packages/shared/src/systems/shared/world/DockDefinition.ts
new file mode 100644
index 000000000..669634f1e
--- /dev/null
+++ b/packages/shared/src/systems/shared/world/DockDefinition.ts
@@ -0,0 +1,41 @@
+/**
+ * DockDefinition — data for dock placements.
+ *
+ * Each dock specifies a position, rotation, and dimensions.
+ * Devs assign exact positions — no automatic shoreline detection.
+ *
+ * `rotation` is degrees (compass bearing) for the direction the dock
+ * extends over water: 0° = north (−Z), 90° = east (+X), 180° = south (+Z), 270° = west (−X).
+ */
+
+export interface DockDefinition {
+ id: string;
+ /** Shore-side anchor X (where the dock meets land) */
+ x: number;
+ /** Shore-side anchor Z */
+ z: number;
+ /** Compass bearing in degrees — direction the dock extends over water */
+ rotation: number;
+ /** Deck width in meters (tiles across, default 3) */
+ width?: number;
+ /** Deck length in meters (tiles into water, default 12) */
+ length?: number;
+ /** Label shown on interaction (default "Dock") */
+ label?: string;
+}
+
+/**
+ * Dock placements — devs define exact positions and directions.
+ * Add entries here to place docks anywhere on the map.
+ */
+export const ISLAND_DOCKS: DockDefinition[] = [
+ {
+ id: "dock_test",
+ x: 1075.5,
+ z: 1172.5,
+ rotation: 180, // south
+ width: 3,
+ length: 12,
+ label: "Dock",
+ },
+];
diff --git a/packages/shared/src/systems/shared/world/Environment.ts b/packages/shared/src/systems/shared/world/Environment.ts
index 173116cb6..105f151ff 100644
--- a/packages/shared/src/systems/shared/world/Environment.ts
+++ b/packages/shared/src/systems/shared/world/Environment.ts
@@ -8,6 +8,14 @@ import { System } from "../infrastructure/System";
import { SkySystem } from "./SkySystem";
import { setLamppostNightMix } from "./LamppostLightMask";
import { FOG_NEAR, FOG_FAR } from "./FogConfig";
+import {
+ DAY_CYCLE,
+ SUN_LIGHT,
+ HEMISPHERE_LIGHT,
+ AMBIENT_LIGHT,
+ EXPOSURE,
+ FOG_COLORS,
+} from "./LightingConfig";
import type {
BaseEnvironment,
EnvironmentModel,
@@ -53,8 +61,8 @@ export function isCsmEnabled(): boolean {
return false;
}
-const SINGLE_SHADOW_MAP_SIZE = 2048;
-const SINGLE_SHADOW_FRUSTUM = 80;
+const SINGLE_SHADOW_MAP_SIZE = 4096;
+const SINGLE_SHADOW_FRUSTUM = 200;
// IMPORTANT: Vegetation fade distances should be <= maxFar so trees don't appear unshadowed
export const csmLevels = {
@@ -154,11 +162,7 @@ export class Environment extends System {
private lastLightAnchor: THREE.Vector3 = new THREE.Vector3(); // Camera anchor position
private readonly LIGHT_DISTANCE = 400; // Distance from target to light
- // Auto exposure settings - mimics eye adaptation to different light levels
- // Higher exposure at night compensates for lower light, keeping things visible
- private readonly DAY_EXPOSURE = 0.85; // Standard exposure for bright daylight
- private readonly NIGHT_EXPOSURE = 1.7; // Boosted exposure for night visibility
- private currentExposure: number = 0.85; // Smoothed current value
+ private currentExposure: number = EXPOSURE.DAY;
// CSMShadowNode for WebGPU cascaded shadows
private csmShadowNode: InstanceType | null = null;
@@ -531,24 +535,34 @@ export class Environment extends System {
// ===================
// TRANSITION FADE - fade light out during sun/moon swap
// ===================
- const DAWN_START = 0.22;
- const DAWN_MID = 0.25;
- const DAWN_END = 0.28;
- const DUSK_START = 0.72;
- const DUSK_MID = 0.75;
- const DUSK_END = 0.78;
-
let transitionFade = 1.0;
- if (dayPhase >= DAWN_START && dayPhase < DAWN_MID) {
+ if (dayPhase >= DAY_CYCLE.DAWN_START && dayPhase < DAY_CYCLE.DAWN_MID) {
+ transitionFade =
+ 1.0 -
+ (dayPhase - DAY_CYCLE.DAWN_START) /
+ (DAY_CYCLE.DAWN_MID - DAY_CYCLE.DAWN_START);
+ } else if (
+ dayPhase >= DAY_CYCLE.DAWN_MID &&
+ dayPhase < DAY_CYCLE.DAWN_END
+ ) {
+ transitionFade =
+ (dayPhase - DAY_CYCLE.DAWN_MID) /
+ (DAY_CYCLE.DAWN_END - DAY_CYCLE.DAWN_MID);
+ } else if (
+ dayPhase >= DAY_CYCLE.DUSK_START &&
+ dayPhase < DAY_CYCLE.DUSK_MID
+ ) {
transitionFade =
- 1.0 - (dayPhase - DAWN_START) / (DAWN_MID - DAWN_START);
- } else if (dayPhase >= DAWN_MID && dayPhase < DAWN_END) {
- transitionFade = (dayPhase - DAWN_MID) / (DAWN_END - DAWN_MID);
- } else if (dayPhase >= DUSK_START && dayPhase < DUSK_MID) {
+ 1.0 -
+ (dayPhase - DAY_CYCLE.DUSK_START) /
+ (DAY_CYCLE.DUSK_MID - DAY_CYCLE.DUSK_START);
+ } else if (
+ dayPhase >= DAY_CYCLE.DUSK_MID &&
+ dayPhase < DAY_CYCLE.DUSK_END
+ ) {
transitionFade =
- 1.0 - (dayPhase - DUSK_START) / (DUSK_MID - DUSK_START);
- } else if (dayPhase >= DUSK_MID && dayPhase < DUSK_END) {
- transitionFade = (dayPhase - DUSK_MID) / (DUSK_END - DUSK_MID);
+ (dayPhase - DAY_CYCLE.DUSK_MID) /
+ (DAY_CYCLE.DUSK_END - DAY_CYCLE.DUSK_MID);
}
transitionFade =
transitionFade * transitionFade * (3 - 2 * transitionFade); // smoothstep
@@ -558,40 +572,40 @@ export class Environment extends System {
// Use target direction + interpolation to prevent sudden jumps
// ===================
if (isDay) {
- // Daytime: light comes FROM the sun (negate sunDirection which points TO sun)
this.targetLightDirection.copy(this.skySystem.sunDirection).negate();
} else {
- // Nighttime: light comes FROM the moon (at -sunDirection position)
this.targetLightDirection.copy(this.skySystem.sunDirection);
}
- // Smooth interpolation to prevent sudden direction changes causing flicker
- // Lerp factor of 0.02 = ~50 frames to reach target (smooth over ~1 second at 60fps)
- this.lightDirection.lerp(this.targetLightDirection, 0.02);
+ this.lightDirection.lerp(
+ this.targetLightDirection,
+ SUN_LIGHT.DIRECTION_LERP,
+ );
// ===================
// LIGHT INTENSITY & COLOR - Single light, simple and correct
// ===================
if (isDay) {
- // Sunlight - warm golden light
- const sunIntensity = dayIntensity * 1.8 * transitionFade;
+ const sunIntensity =
+ dayIntensity * SUN_LIGHT.DAY_INTENSITY_MULTIPLIER * transitionFade;
this.sunLight.intensity = sunIntensity;
- // Golden hour coloring near horizon
- const nearHorizon =
- (dayPhase >= 0.22 && dayPhase < 0.32) ||
- (dayPhase >= 0.68 && dayPhase < 0.78);
+ const nearHorizon = SUN_LIGHT.GOLDEN_HOUR_RANGES.some(
+ ([start, end]) => dayPhase >= start && dayPhase < end,
+ );
if (nearHorizon) {
- this.sunLight.color.setRGB(1.0, 0.85, 0.6);
+ this.sunLight.color.setRGB(...SUN_LIGHT.GOLDEN_HOUR_COLOR);
} else {
- this.sunLight.color.setRGB(1.0, 0.98, 0.92);
+ this.sunLight.color.setRGB(...SUN_LIGHT.DAY_COLOR);
}
} else {
- // Moonlight - cool blue light (stronger for better night visibility)
const nightIntensity = 1 - dayIntensity;
- const moonIntensity = nightIntensity * 0.6 * transitionFade;
+ const moonIntensity =
+ nightIntensity *
+ SUN_LIGHT.MOON_INTENSITY_MULTIPLIER *
+ transitionFade;
this.sunLight.intensity = moonIntensity;
- this.sunLight.color.setRGB(0.6, 0.7, 0.9);
+ this.sunLight.color.setRGB(...SUN_LIGHT.MOON_COLOR);
}
// ===================
@@ -744,35 +758,38 @@ export class Environment extends System {
const nightIntensity = 1 - dayIntensity;
if (this.hemisphereLight) {
- // Hemisphere light: brighter during day, visible at night
- // Day: 0.9, Night: 0.4 (auto exposure handles the rest)
- this.hemisphereLight.intensity = 0.4 + dayIntensity * 0.5;
+ this.hemisphereLight.intensity =
+ HEMISPHERE_LIGHT.INTENSITY_BASE +
+ dayIntensity * HEMISPHERE_LIGHT.INTENSITY_DAY_ADD;
- // Shift sky color from bright blue (day) to blue-silver (night)
+ const [dR, dG, dB] = HEMISPHERE_LIGHT.DAY_SKY_COLOR;
+ const [nR, nG, nB] = HEMISPHERE_LIGHT.NIGHT_SKY_COLOR;
this.hemisphereLight.color.setRGB(
- 0.53 * dayIntensity + 0.25 * nightIntensity, // R: moonlit sky
- 0.81 * dayIntensity + 0.35 * nightIntensity, // G: moonlit sky
- 0.92 * dayIntensity + 0.5 * nightIntensity, // B: blue tint at night
+ dR * dayIntensity + nR * nightIntensity,
+ dG * dayIntensity + nG * nightIntensity,
+ dB * dayIntensity + nB * nightIntensity,
);
- // Ground color: warm brown during day, blue-grey at night
+ const [dgR, dgG, dgB] = HEMISPHERE_LIGHT.DAY_GROUND_COLOR;
+ const [ngR, ngG, ngB] = HEMISPHERE_LIGHT.NIGHT_GROUND_COLOR;
this.hemisphereLight.groundColor.setRGB(
- 0.36 * dayIntensity + 0.15 * nightIntensity,
- 0.27 * dayIntensity + 0.15 * nightIntensity,
- 0.18 * dayIntensity + 0.2 * nightIntensity,
+ dgR * dayIntensity + ngR * nightIntensity,
+ dgG * dayIntensity + ngG * nightIntensity,
+ dgB * dayIntensity + ngB * nightIntensity,
);
}
if (this.ambientLight) {
- // Ambient fill: provides base visibility
- // Day: 0.5, Night: 0.3 (auto exposure handles the rest)
- this.ambientLight.intensity = 0.3 + dayIntensity * 0.2;
+ this.ambientLight.intensity =
+ AMBIENT_LIGHT.INTENSITY_BASE +
+ dayIntensity * AMBIENT_LIGHT.INTENSITY_DAY_ADD;
- // Day: warm neutral white, Night: brighter blue moonlight tint
+ const [adR, adG, adB] = AMBIENT_LIGHT.DAY_COLOR;
+ const [anR, anG, anB] = AMBIENT_LIGHT.NIGHT_COLOR;
this.ambientLight.color.setRGB(
- 0.5 + dayIntensity * 0.5, // R: 0.5 at night, 1.0 at day
- 0.55 + dayIntensity * 0.4, // G: 0.55 at night, 0.95 at day
- 0.7 + dayIntensity * 0.25, // B: 0.7 at night, 0.95 at day (bluer at night)
+ anR + dayIntensity * (adR - anR),
+ anG + dayIntensity * (adG - anG),
+ anB + dayIntensity * (adB - anB),
);
}
}
@@ -788,8 +805,7 @@ export class Environment extends System {
// Calculate target exposure based on current dayIntensity using same formula as update
const dayIntensity = this.skySystem.dayIntensity;
const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity); // smoothstep
- this.currentExposure =
- this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t;
+ this.currentExposure = EXPOSURE.NIGHT + (EXPOSURE.DAY - EXPOSURE.NIGHT) * t;
// Apply immediately to renderer
const graphics = this.world.graphics as
@@ -806,31 +822,23 @@ export class Environment extends System {
* @param dayIntensity 0-1 (0 = night, 1 = day)
*/
private updateAutoExposure(dayIntensity: number): void {
- // Get renderer reference
const graphics = this.world.graphics as
| { renderer?: { toneMappingExposure?: number } }
| undefined;
if (!graphics?.renderer) return;
-
- // Calculate target exposure: lerp from night (high) to day (low)
// Using smoothstep for natural-feeling transitions
const t = dayIntensity * dayIntensity * (3 - 2 * dayIntensity); // smoothstep
- const targetExposure =
- this.NIGHT_EXPOSURE + (this.DAY_EXPOSURE - this.NIGHT_EXPOSURE) * t;
+ const targetExposure = EXPOSURE.NIGHT + (EXPOSURE.DAY - EXPOSURE.NIGHT) * t;
- // Smooth interpolation to prevent jarring changes
- // Lerp factor of 0.03 = gradual adaptation over ~30 frames
- this.currentExposure += (targetExposure - this.currentExposure) * 0.03;
+ this.currentExposure +=
+ (targetExposure - this.currentExposure) * EXPOSURE.LERP_SPEED;
// Apply to renderer
graphics.renderer.toneMappingExposure = this.currentExposure;
}
- // Day fog color: warm beige
- private readonly dayFogColor = new THREE.Color(0xd4c8b8);
- // Night fog color: dark blue to blend with night sky (slightly lighter for visibility)
- private readonly nightFogColor = new THREE.Color(0x2b3445);
- // Blended fog color (updated each frame)
+ private readonly dayFogColor = new THREE.Color(FOG_COLORS.DAY);
+ private readonly nightFogColor = new THREE.Color(FOG_COLORS.NIGHT);
private readonly blendedFogColor = new THREE.Color();
/**
@@ -928,21 +936,17 @@ export class Environment extends System {
const scene = this.world.stage.scene;
- // Hemisphere light - sky color from above, ground color from below
- // Provides natural ambient lighting that varies with direction
this.hemisphereLight = new THREE.HemisphereLight(
- 0x87ceeb, // Sky color (light blue)
- 0x5d4837, // Ground color (warm brown)
- 0.5, // Higher intensity for better ambient
+ HEMISPHERE_LIGHT.INITIAL_SKY_COLOR,
+ HEMISPHERE_LIGHT.INITIAL_GROUND_COLOR,
+ HEMISPHERE_LIGHT.INITIAL_INTENSITY,
);
this.hemisphereLight.name = "EnvironmentHemisphereLight";
scene.add(this.hemisphereLight);
- // Ambient light - flat fill light for base visibility
- // Ensures objects are never completely black (especially important without env map)
this.ambientLight = new THREE.AmbientLight(
- 0x606070, // Neutral with slight cool tint
- 0.5, // Higher intensity since we removed env map
+ AMBIENT_LIGHT.INITIAL_COLOR,
+ AMBIENT_LIGHT.INITIAL_INTENSITY,
);
this.ambientLight.name = "EnvironmentAmbientLight";
scene.add(this.ambientLight);
diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts
index 06aed1082..b863e0e18 100644
--- a/packages/shared/src/systems/shared/world/FogConfig.ts
+++ b/packages/shared/src/systems/shared/world/FogConfig.ts
@@ -39,6 +39,7 @@ import {
mix,
dot,
sub,
+ mul,
smoothstep,
Fn,
output,
@@ -48,8 +49,8 @@ import {
// Fog distance parameters
// smoothstep(NEAR_SQ, FAR_SQ, distSq) gives 0% fog at NEAR, 100% at FAR.
// ---------------------------------------------------------------------------
-export const FOG_NEAR = 60;
-export const FOG_FAR = 150;
+export const FOG_NEAR = 400;
+export const FOG_FAR = 800;
// Pre-computed squared distances — avoids per-fragment sqrt on the GPU.
// Shaders compare dot(toCamera, toCamera) directly against these.
@@ -108,3 +109,20 @@ export function applySkyFog(material: {
return vec4(mix(litColor.rgb, fogTex.rgb, fogFactor), litColor.a);
})();
}
+
+// ---------------------------------------------------------------------------
+// HELPER: Apply elevation-based fade to cloud materials.
+// Reduces cloud opacity based on elevation angle so lower clouds
+// gradually dissolve into the sky at the horizon.
+// fadeAmount: 0 = fully visible, 1 = fully transparent
+// ---------------------------------------------------------------------------
+export function applyCloudFog(
+ material: { fog: boolean; outputNode: unknown },
+ fadeAmount: number,
+): void {
+ material.fog = false;
+ material.outputNode = Fn(() => {
+ const litColor = output;
+ return vec4(litColor.rgb, mul(litColor.a, float(1.0 - fadeAmount)));
+ })();
+}
diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts
index 07cd1ad03..534da7251 100644
--- a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts
+++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts
@@ -16,6 +16,9 @@
import THREE from "../../../extras/three/three";
import type { World } from "../../../core/World";
+import { TREE_TYPES, treeIdToSubType } from "../../../constants/TreeTypes";
+import type { TreeTypeDefinition } from "../../../constants/TreeTypes";
+import { SNOW_BIOMES } from "./TerrainBiomeTypes";
import { modelCache } from "../../../utils/rendering/ModelCache";
import {
createTreeDissolveMaterial,
@@ -41,9 +44,10 @@ const _scale = new THREE.Vector3();
// ---- Batch color channel layout ----
// R = highlight intensity (1.0 = normal, >1.0 = highlighted via HL_COLOR_INTENSITY)
-// G = highlight intensity (same as R — shader detects highlight via step(1.01, max(R, G)))
+// G = biome snow weight (0.0 = no snow, 1.0 = full snow) — set once on add/LOD swap
// B = 1.0 - dissolveVal (1.0 = fully visible, 0.0 = fully dissolved)
-// Only modify channels through applyHighlightColor (R/G) and applyDissolveColor (B).
+// Only modify channels through applyHighlightColor (R), applyDissolveColor (B),
+// and addToPool (G for snow).
// NOTE: If the underlying color buffer is Uint8 (256 levels), dissolve precision is
// ~0.004 per step. At 0.3s duration / 60fps (~18 steps) this is more than sufficient.
const _defaultColor = new THREE.Color(1, 1, 1);
@@ -59,6 +63,7 @@ interface TreeSlot {
yOffset: number;
currentLOD: 0 | 1 | 2;
variantIndex: number;
+ snowWeight: number;
}
interface BatchedLODPool {
@@ -89,9 +94,10 @@ interface TreeTypePool {
yOffset: number;
modelHeight: number;
modelRadius: number;
+ snowCapable: boolean;
}
-const resourceLOD = getLODDistances("resource");
+const resourceLOD = getLODDistances("tree");
// ---- Module state ----
let scene: THREE.Scene | null = null;
@@ -342,6 +348,12 @@ async function ensureTreeTypePool(
if (pending) return pending;
const promise = (async (): Promise => {
+ const treeDef = (TREE_TYPES as Record)[
+ treeType
+ ];
+ const isSnowCapable = !!treeDef?.snowCapable;
+ const hasSnowVertexData = !!treeDef?.snowVertexData;
+
const dissolveOpts = {
fadeStart: GPU_VEG_CONFIG.FADE_START,
fadeEnd: GPU_VEG_CONFIG.FADE_END,
@@ -359,6 +371,8 @@ async function ensureTreeTypePool(
...dissolveOpts,
batched: true,
isLeafMaterial: isLeaf,
+ enableSnow: isSnowCapable,
+ snowVertexData: hasSnowVertexData,
} as TreeMaterialOptions);
dm.side = THREE.DoubleSide;
enableTextureRepeat(dm);
@@ -483,6 +497,7 @@ async function ensureTreeTypePool(
yOffset: bounds.yOffset,
modelHeight: bounds.height,
modelRadius: bounds.radius,
+ snowCapable: isSnowCapable,
};
pools.set(treeType, pool);
@@ -519,28 +534,29 @@ function addToPool(
mat: THREE.Matrix4,
variantIndex: number,
dissolve = 0,
+ snowWeight = 0,
): void {
- const ids: number[] = [];
for (let i = 0; i < pool.batches.length; i++) {
const numVariants = pool.geometryIds[i].length;
const clampedIdx = variantIndex % numVariants;
- const geoId = pool.geometryIds[i][clampedIdx];
- if (geoId === undefined) {
+ if (pool.geometryIds[i][clampedIdx] === undefined) {
console.warn(
- `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`,
+ `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}, aborting addToPool`,
);
- continue;
+ return;
}
+ }
+
+ const ids: number[] = new Array(pool.batches.length);
+ _tmpColor.setRGB(1, snowWeight, 1.0 - dissolve);
+ for (let i = 0; i < pool.batches.length; i++) {
+ const numVariants = pool.geometryIds[i].length;
+ const clampedIdx = variantIndex % numVariants;
+ const geoId = pool.geometryIds[i][clampedIdx];
const instId = pool.batches[i].addInstance(geoId);
pool.batches[i].setMatrixAt(instId, mat);
- if (dissolve > 0) {
- // Write dissolve into blue channel immediately to avoid a 1-frame flash
- _tmpColor.setRGB(1, 1, 1.0 - dissolve);
- pool.batches[i].setColorAt(instId, _tmpColor);
- } else {
- pool.batches[i].setColorAt(instId, _defaultColor);
- }
- ids.push(instId);
+ pool.batches[i].setColorAt(instId, _tmpColor);
+ ids[i] = instId;
}
pool.instanceIds.set(entityId, ids);
}
@@ -568,11 +584,11 @@ function applyHighlightColor(
): void {
const ids = pool.instanceIds.get(entityId);
if (!ids) return;
- const rg = on ? HL_COLOR_INTENSITY : 1.0;
+ const r = on ? HL_COLOR_INTENSITY : 1.0;
for (let i = 0; i < pool.batches.length; i++) {
- // Preserve blue channel (encodes dissolve state)
+ // Only modify R (highlight); preserve G (snow weight) and B (dissolve)
pool.batches[i].getColorAt(ids[i], _tmpColor);
- _tmpColor.setRGB(rg, rg, _tmpColor.b);
+ _tmpColor.setRGB(r, _tmpColor.g, _tmpColor.b);
pool.batches[i].setColorAt(ids[i], _tmpColor);
}
}
@@ -620,25 +636,77 @@ export async function addInstance(
): Promise {
if (!scene || !world) return false;
+ if (entityToTreeType.has(entityId)) {
+ removeInstance(entityId);
+ }
+
try {
const pool = await ensureTreeTypePool(treeType, variantPaths);
+ // Pick initial LOD based on camera distance to avoid LOD0 pop-in at range
+ let initialLOD: 0 | 1 | 2 = 0;
+ if (world?.camera) {
+ const cp = world.camera.position;
+ const dx = cp.x - position.x;
+ const dz = cp.z - position.z;
+ const distSq = dx * dx + dz * dz;
+ if (distSq >= resourceLOD.lod2DistanceSq) {
+ initialLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0;
+ } else if (distSq >= resourceLOD.lod1DistanceSq) {
+ initialLOD = pool.lod1 ? 1 : 0;
+ }
+ }
+
+ let snowWeight = 0;
+ if (pool.snowCapable) {
+ const terrain = world!.getSystem("terrain");
+ if (terrain?.computeBiomeWeightsByPosition) {
+ const weights = terrain.computeBiomeWeightsByPosition(
+ position.x,
+ position.z,
+ ) as Record;
+ const totalWeight = Object.values(weights).reduce(
+ (a: number, b: number) => a + b,
+ 0,
+ );
+ if (totalWeight > 0) {
+ let snowSum = 0;
+ for (const [biome, w] of Object.entries(weights)) {
+ if (SNOW_BIOMES.has(biome)) snowSum += w;
+ }
+ snowWeight = snowSum / totalWeight;
+ }
+ } else {
+ snowWeight = 1.0;
+ }
+ }
+
const slot: TreeSlot = {
entityId,
position: position.clone(),
rotation,
scale,
yOffset: pool.yOffset,
- currentLOD: 0,
+ currentLOD: initialLOD,
variantIndex,
+ snowWeight,
};
pool.instances.set(entityId, slot);
entityToTreeType.set(entityId, treeType);
const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset);
- if (pool.lod0)
- addToPool(pool.lod0, entityId, mat, variantIndex, initialDissolve);
+ const initialPool =
+ initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2;
+ if (initialPool)
+ addToPool(
+ initialPool,
+ entityId,
+ mat,
+ variantIndex,
+ initialDissolve,
+ snowWeight,
+ );
return true;
} catch (error) {
@@ -878,6 +946,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void {
mat,
slot.variantIndex,
wasDissolveVal,
+ slot.snowWeight,
);
if (wasHl) applyHighlightColor(newPool, slot.entityId, true);
}
@@ -898,6 +967,7 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void {
sunLight?: { intensity: number };
lightDirection?: THREE.Vector3;
hemisphereLight?: { color: THREE.Color };
+ getDayIntensity?: () => number;
} | null;
const wind = world.getSystem("wind") as Wind | null;
@@ -927,16 +997,8 @@ export function updateGLBTreeBatchedInstancer(deltaTime: number): void {
2.0,
);
}
- if (env?.hemisphereLight) {
- const c = env.hemisphereLight.color;
- const avg = (c.r + c.g + c.b) / 3;
- if (avg > 0.01) {
- treeMat.treeUniforms.shadeColor.value.setRGB(
- c.r / avg,
- c.g / avg,
- c.b / avg,
- );
- }
+ if (env?.getDayIntensity) {
+ treeMat.treeUniforms.dayIntensity.value = env.getDayIntensity();
}
if (wind) {
treeMat.treeUniforms.windTime.value = wind.uniforms.time.value;
diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts
index 48c252585..4e06cb9a3 100644
--- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts
+++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts
@@ -95,7 +95,7 @@ let world: World | null = null;
const pools = new Map();
const entityToModel = new Map();
-// ---- Geometry extraction (portfolio pattern: reference, not clone) ----
+// ---- Geometry extraction (reference, not clone) ----
interface MeshPart {
geometry: THREE.BufferGeometry;
@@ -123,12 +123,14 @@ function createSharedGeometry(
): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry();
for (const name in source.attributes) {
- geo.setAttribute(name, source.attributes[name]);
+ geo.setAttribute(name, source.attributes[name].clone());
}
- if (source.index) geo.setIndex(source.index);
+ if (source.index) geo.setIndex(source.index.clone());
if (source.morphAttributes) {
for (const name in source.morphAttributes) {
- geo.morphAttributes[name] = source.morphAttributes[name];
+ geo.morphAttributes[name] = source.morphAttributes[name].map((a) =>
+ a.clone(),
+ );
}
}
if (source.groups.length > 0) {
@@ -363,7 +365,8 @@ function addToPool(
entityId: string,
mat: THREE.Matrix4,
dissolve = 0,
-): void {
+): boolean {
+ if (pool.activeCount >= MAX_INSTANCES) return false;
const idx = pool.activeCount;
for (const im of pool.meshes) {
im.setMatrixAt(idx, mat);
@@ -374,6 +377,7 @@ function addToPool(
if (dissolve > 0) pool.dissolveDirty = true;
pool.activeCount++;
pool.dirty = true;
+ return true;
}
function removeFromPool(pool: LODPool, entityId: string): void {
@@ -452,14 +456,25 @@ export async function addInstance(
): Promise {
if (!scene || !world) return false;
+ if (entityToModel.has(entityId)) {
+ removeInstance(entityId);
+ }
+
try {
const pool = await ensureModelPool(modelPath, lod1ModelPath, lod2ModelPath);
- if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) {
- console.warn(
- `[GLBTreeInstancer] LOD0 pool full for ${modelPath}, cannot add ${entityId}`,
- );
- return false;
+ // Pick initial LOD based on camera distance to avoid LOD0 pop-in at range
+ let initialLOD: 0 | 1 | 2 = 0;
+ if (world?.camera) {
+ const cp = world.camera.position;
+ const dx = cp.x - position.x;
+ const dz = cp.z - position.z;
+ const distSq = dx * dx + dz * dz;
+ if (distSq >= resourceLOD.lod2DistanceSq) {
+ initialLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0;
+ } else if (distSq >= resourceLOD.lod1DistanceSq) {
+ initialLOD = pool.lod1 ? 1 : 0;
+ }
}
const slot: TreeSlot = {
@@ -468,14 +483,26 @@ export async function addInstance(
rotation,
scale,
yOffset: pool.yOffset,
- currentLOD: 0,
+ currentLOD: initialLOD,
};
pool.instances.set(entityId, slot);
entityToModel.set(entityId, modelPath);
const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset);
- addToPool(pool.lod0!, entityId, mat, initialDissolve);
+ const initialPool =
+ initialLOD === 0 ? pool.lod0 : initialLOD === 1 ? pool.lod1 : pool.lod2;
+ if (
+ initialPool &&
+ !addToPool(initialPool, entityId, mat, initialDissolve)
+ ) {
+ console.warn(
+ `[GLBTreeInstancer] LOD${initialLOD} pool full for ${modelPath}, cannot add ${entityId}`,
+ );
+ pool.instances.delete(entityId);
+ entityToModel.delete(entityId);
+ return false;
+ }
return true;
} catch (error) {
@@ -750,6 +777,7 @@ export function updateGLBTreeInstancer(deltaTime: number): void {
sunLight?: { intensity: number };
lightDirection?: THREE.Vector3;
hemisphereLight?: { color: THREE.Color };
+ getDayIntensity?: () => number;
} | null;
const wind = world.getSystem("wind") as Wind | null;
@@ -794,16 +822,8 @@ export function updateGLBTreeInstancer(deltaTime: number): void {
2.0,
);
}
- if (env?.hemisphereLight) {
- const c = env.hemisphereLight.color;
- const avg = (c.r + c.g + c.b) / 3;
- if (avg > 0.01) {
- treeMat.treeUniforms.shadeColor.value.setRGB(
- c.r / avg,
- c.g / avg,
- c.b / avg,
- );
- }
+ if (env?.getDayIntensity) {
+ treeMat.treeUniforms.dayIntensity.value = env.getDayIntensity();
}
if (wind) {
treeMat.treeUniforms.windTime.value = wind.uniforms.time.value;
diff --git a/packages/shared/src/systems/shared/world/GPUMaterials.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts
index 9abd9e748..5d342d4f0 100644
--- a/packages/shared/src/systems/shared/world/GPUMaterials.ts
+++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts
@@ -52,6 +52,7 @@ import {
viewportCoordinate,
normalView,
normalWorld,
+ normalWorldGeometry,
normalize,
pow,
attribute,
@@ -62,6 +63,7 @@ import {
import { varyingProperty } from "three/tsl";
import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig";
import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
+import { SUN_SHADE, SUN_LIGHT, NIGHT, applySunShade } from "./LightingConfig";
// ============================================================================
// CONFIGURATION
@@ -73,10 +75,10 @@ import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
*/
export const GPU_VEG_CONFIG = {
/** Distance where far fade begins (fully opaque inside) - default for generic dissolve */
- FADE_START: 270,
+ FADE_START: 1000,
/** Distance where fully invisible (far) - default for generic dissolve */
- FADE_END: 300,
+ FADE_END: 1200,
/** Distance where near fade ends (fully opaque outside) */
NEAR_FADE_END: 3,
@@ -128,9 +130,8 @@ export const GPU_VEG_CONFIG = {
NEAR_CAMERA_FADE_END: 0.05,
// ========== TREE DEPLETION DISSOLVE ==========
- // BatchedMesh encodes dissolve in the **blue channel** of per-instance batch
- // colors (blue = 1.0 - dissolveVal). R/G channels carry highlight intensity.
- // See GLBTreeBatchedInstancer.applyDissolveColor / applyHighlightColor.
+ // BatchedMesh channel layout: R = highlight (1.0 or >1.0), G = snow weight,
+ // B = 1.0 - dissolveVal. See GLBTreeBatchedInstancer channel layout comment.
// InstancedMesh uses a dedicated per-instance `instanceDissolve` float attribute.
/**
@@ -154,37 +155,6 @@ export const GPU_VEG_CONFIG = {
DISSOLVE_ALPHA_SCALE: 0.7,
} as const;
-// ============================================================================
-// SHARED TERRAIN LIGHTING (used by both TerrainShader and tree terrain blend)
-// ============================================================================
-
-/** Sun shade strength — identical in terrain shader and tree shader */
-export const SHADE_STRENGTH = 0.3;
-
-/**
- * Compute sun shade (shadow-side sky tint) on a pre-lit color.
- * Used by both the terrain shader's outputNode and the tree shader's
- * terrain color blend so both produce the exact same result.
- *
- * @param color - Already-lit color (e.g. PBR output or manually-lit albedo)
- * @param normal - Surface normal (world space)
- * @param sunDir - Sun direction uniform (normalised inside)
- * @param shadeColor - Normalised hemisphere sky color for shadow tint
- */
-export function applyTerrainSunShade(
- color: any,
- normal: any,
- sunDir: any,
- shadeColor: any,
-) {
- const L = normalize(sunDir);
- const N = normalize(normal);
- const NdotL = dot(N, L);
- const shade = sub(float(0.5), mul(NdotL, float(0.5)));
- const tinted = mul(color, shadeColor);
- return mix(color, tinted, mul(shade, float(SHADE_STRENGTH)));
-}
-
// ============================================================================
// TYPES
// ============================================================================
@@ -476,7 +446,7 @@ export function createGPUVegetationMaterial(
material.transparent = false;
material.opacity = 1.0;
- material.alphaTest = 0.5;
+ material.alphaTest = 0.1;
material.side = THREE.DoubleSide;
material.depthWrite = true;
@@ -999,7 +969,7 @@ export function applyRimHighlight(
}
// ============================================================================
-// TREE DISSOLVE MATERIAL (FORTNITE-STYLE FOLIAGE SHADING)
+// TREE DISSOLVE MATERIAL (TOON FOLIAGE SHADING)
// ============================================================================
/**
@@ -1008,13 +978,17 @@ export function applyRimHighlight(
export type TreeMaterialOptions = DissolveMaterialOptions & {
/** Whether this material covers leaf geometry (enables wind + SSS) */
isLeafMaterial?: boolean;
+ /** Enable snow blending driven by per-instance biome weight */
+ enableSnow?: boolean;
+ /** Model has explicit snow mask in vertex-color R channel (skip normal fallback) */
+ snowVertexData?: boolean;
};
/**
- * Tree-specific dissolve material with soft stylized shading.
+ * Tree-specific dissolve material with toon shading.
* Extends DissolveMaterial with:
- * - Soft clamped lighting (Lambert compressed to [0.7, 1.0], no harsh shadows)
- * - Fresnel edge brightening on leaves (morpho-style rim glow)
+ * - Quantized 3-band toon lighting (hard-edged shadow / mid / bright)
+ * - Hard-edged Fresnel rim on leaves
* - Back-SSS translucency for leaves (warm glow when backlit by sun)
* - Wind vertex animation for leaves
* - Vertex-color AO (G channel darkens crevices)
@@ -1024,6 +998,7 @@ export type TreeDissolveMaterial = DissolveMaterial & {
treeUniforms: {
sunDirection: { value: THREE.Vector3 };
sunIntensity: { value: number };
+ dayIntensity: { value: number };
shadeColor: { value: THREE.Color };
windTime: { value: number };
windStrength: { value: number };
@@ -1032,13 +1007,13 @@ export type TreeDissolveMaterial = DissolveMaterial & {
};
/**
- * Creates a tree dissolve material with soft clamped lighting, SSS, and wind.
+ * Creates a tree dissolve material with toon lighting, SSS, and wind.
*
- * 1. **Soft lighting** — Lambert + smoothstep clamped to [0.7, 1.0] (no harsh shadows).
+ * 1. **Toon lighting** — Quantized 3-band Lambert (hard shadow / mid / bright).
* 2. **AO** — Vertex color G channel as ambient occlusion.
* 3. **SSS** — Back-scatter translucency on leaf materials (warm glow when backlit).
* 4. **Wind** — Sine-wave vertex displacement on leaf materials.
- * 5. **Edge brightening** — Fresnel-based rim glow on leaves (morpho effect).
+ * 5. **Rim** — Hard-edged Fresnel rim on leaves.
* 6. **Saturation** — Subtle boost keeps colors rich.
* 7. **Rim highlight** — Per-instance Fresnel glow for hover feedback.
*
@@ -1058,32 +1033,54 @@ export function createTreeDissolveMaterial(
const material = baseDm as unknown as THREE.MeshStandardNodeMaterial;
const isLeaf = options.isLeafMaterial ?? false;
- const hasVertexColors = !!(source as any).vertexColors;
- material.vertexColors = false;
+ // Vertex-color detection: check material flag AND geometry attribute.
+ // GLTFLoader sets material.vertexColors=true when COLOR_0 is present,
+ // but we also check geometry as a fallback for manual mesh construction.
+ const srcMat = source as any;
+ const hasVertexColors =
+ !!srcMat.vertexColors ||
+ !!(srcMat._geometry ?? srcMat.geometry)?.attributes?.color;
+ material.vertexColors = hasVertexColors;
// --- Uniforms ---
- const uSunDir = uniform(new THREE.Vector3(0.5, 0.8, 0.3));
+ const uSunDir = uniform(new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION));
const uSunIntensity = uniform(1.0);
- const uShadeColor = uniform(new THREE.Color(0.7, 1.08, 1.22));
+ const uDayIntensity = uniform(1.0);
+ const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR));
const uHighlightColor = uniform(new THREE.Color(0x00ffff));
const uWindTime = uniform(0.0);
const uWindStrength = uniform(0.3);
const uWindDir = uniform(new THREE.Vector2(1, 0));
+ const enableSnow = options.enableSnow ?? false;
+ const snowVertexData = options.snowVertexData ?? false;
+
// --- Tuning ---
- const AO_POWER = 1.8;
+ // Vertex color channels (non-snow): R = bark/leaf mask (1=bark, 0=leaf), G = AO, B = unused
+ // Vertex color channels (snow vtx): R = snow mask (0=no snow, 1=full snow), G = AO, B = unused
+ const AO_POWER = 1.6;
const AO_DARK = 0.35;
+ const AO_BARK_DARK = 0.45;
+
+ // Snow tuning — R-channel path (models with explicit snow vertex data)
+ const SNOW_COLOR: [number, number, number] = [0.92, 0.95, 0.98];
+ const SNOW_AO_TINT: [number, number, number] = [0.55, 0.6, 0.72];
+ const SNOW_SMOOTH_LO = 0.05;
+ const SNOW_SMOOTH_HI = 0.15;
+ // Normal-based fallback (models WITHOUT R-channel snow data)
+ const SNOW_NORMAL_LO = 0.05;
+ const SNOW_NORMAL_HI = 0.35;
+ const SNOW_NORMAL_STRENGTH = 3.5;
const SAT_BOOST = 1.15;
const HL_BRIGHTEN = 0.08;
const HL_RIM_POWER = 2.5;
const HL_RIM_STRENGTH = 0.4;
- const LIGHT_AMBIENT = 0.3;
- const LIGHT_DIFFUSE_STR = 2.0;
- const LIGHT_CLAMP_LO = 0.7;
- const LIGHT_CLAMP_HI = 1.0;
- const LIGHT_AMBIENT_BOOST = 0.15;
- const EDGE_BRIGHT = 1.25;
- const NIGHT_MIN_BRIGHTNESS = 0.3;
+ const TOON_BRIGHT_EDGE = 0.7;
+ const TOON_MID_EDGE = 0.35;
+ const TOON_SHADOW_EDGE = 0.0;
+ const TOON_RIM_THRESHOLD = 0.3;
+ const TOON_RIM_BRIGHT = 1.3;
+ const NIGHT_MIN_BRIGHTNESS = NIGHT.BRIGHTNESS;
// --- Wind vertex displacement (leaf materials only) ---
// Displacement is proportional to local Y so it auto-scales to any model
@@ -1107,6 +1104,18 @@ export function createTreeDissolveMaterial(
})();
}
+ // --- Alpha cutout sharpening for leaf materials ---
+ // Sharpen texture alpha to binary (0 or 1) so semi-transparent edge pixels
+ // are cleanly discarded instead of rendering as opaque fringe that flickers
+ // with wind animation.
+ if (isLeaf && material.map) {
+ const leafCutoutMap = material.map;
+ material.opacityNode = Fn(() => {
+ const uv = attribute("uv", "vec2");
+ return step(float(0.5), texture(leafCutoutMap, uv).a);
+ })();
+ }
+
// --- Sky-color fog (same as terrain/vegetation) ---
const treeFogTex = texture(fogRenderTarget.texture, screenUV);
const treeToCam = sub(cameraPosition, positionWorld);
@@ -1118,7 +1127,7 @@ export function createTreeDissolveMaterial(
);
material.fog = false;
- // --- Output: soft clamped lighting (bypass PBR, compute Lambert from scratch) ---
+ // --- Output: toon lighting (bypass PBR, compute Lambert from scratch) ---
const albedoMap = material.map;
const matColor = vec3(material.color.r, material.color.g, material.color.b);
@@ -1132,41 +1141,103 @@ export function createTreeDissolveMaterial(
: vec4(1, 1, 1, 1);
let baseAlbedo: any = mul(albedoSample.rgb, matColor);
- // ---- Vertex-color AO ----
+ // ---- Vertex-color AO (+ optional snow) ----
if (hasVertexColors) {
- const aoRaw = attribute("color", "vec3").y;
- const aoFactor = pow(aoRaw, float(AO_POWER));
- const aoMul = mix(float(AO_DARK), float(1.0), aoFactor);
- baseAlbedo = mul(baseAlbedo, aoMul);
+ const vtxColor = attribute("color", "vec3");
+ const aoRaw = vtxColor.y;
+
+ if (enableSnow) {
+ // Detect default/unset vertex colors (all channels ~1.0).
+ // Real AO data always has variation; LOD models often have flat white.
+ // When detected, zero out snow to avoid all-white LOD trees.
+ const isDefaultVtx = step(float(0.98), mul(vtxColor.x, vtxColor.y));
+ const effectiveAO = mix(aoRaw, float(0.5), isDefaultVtx);
+
+ const aoFactor = pow(effectiveAO, float(AO_POWER));
+ const aoMul = mix(float(AO_BARK_DARK), float(1.0), aoFactor);
+ baseAlbedo = mul(baseAlbedo, aoMul);
+
+ let snowMask: any;
+ if (snowVertexData) {
+ const rawSnowMask = vtxColor.x;
+ const rMask = smoothstep(
+ float(SNOW_SMOOTH_LO),
+ float(SNOW_SMOOTH_HI),
+ rawSnowMask,
+ );
+ const upFacing = smoothstep(
+ float(SNOW_NORMAL_LO),
+ float(SNOW_NORMAL_HI),
+ normalWorldGeometry.y,
+ );
+ const fallback = clamp(
+ mul(mul(upFacing, effectiveAO), float(SNOW_NORMAL_STRENGTH)),
+ float(0.0),
+ float(1.0),
+ );
+ snowMask = mix(rMask, fallback, isDefaultVtx);
+ } else {
+ const upFacing = smoothstep(
+ float(SNOW_NORMAL_LO),
+ float(SNOW_NORMAL_HI),
+ normalWorldGeometry.y,
+ );
+ snowMask = clamp(
+ mul(mul(upFacing, effectiveAO), float(SNOW_NORMAL_STRENGTH)),
+ float(0.0),
+ float(1.0),
+ );
+ }
+
+ const batchColor = varyingProperty("vec3", "vBatchColor");
+ const biomeSnowStrength = clamp(batchColor.y, float(0.0), float(1.0));
+ const snowBase = vec3(...SNOW_COLOR);
+ const snowAO = vec3(...SNOW_AO_TINT);
+ const snowCol = mix(snowAO, snowBase, aoFactor);
+ const rawWeight = mul(snowMask, biomeSnowStrength);
+ const snowWeight = smoothstep(float(0.15), float(0.35), rawWeight);
+ baseAlbedo = mix(baseAlbedo, snowCol, snowWeight);
+ } else {
+ // Standard path: R = bark/leaf mask, G = AO
+ const barkMask = vtxColor.x;
+ const aoFactor = pow(aoRaw, float(AO_POWER));
+ const aoDarkFloor = mix(float(AO_DARK), float(AO_BARK_DARK), barkMask);
+ const aoMul = mix(aoDarkFloor, float(1.0), aoFactor);
+ baseAlbedo = mul(baseAlbedo, aoMul);
+ }
}
- // ---- Custom Lambert lighting (sphere normals baked into vertex attribute) ----
- const N = normalize(mul(modelNormalMatrix, normalLocal));
- const L = normalize(vec3(uSunDir));
- const NdotL = dot(N, L);
+ // ---- dayFactor (used by shade, toon, SSS, saturation) ----
const sunI = clamp(uSunIntensity, float(0.0), float(2.0));
const dayFactor = div(sunI, float(2.0));
- const diffuse = mul(
- mul(max(NdotL, float(0.0)), float(LIGHT_DIFFUSE_STR)),
- sunI,
- );
- const ambient = float(LIGHT_AMBIENT);
- const totalLight = add(ambient, diffuse);
- const softLight = clamp(
- smoothstep(float(0.9), float(1.1), totalLight),
- float(LIGHT_CLAMP_LO),
- float(LIGHT_CLAMP_HI),
- );
- const nightDim = mix(float(NIGHT_MIN_BRIGHTNESS), float(1.0), dayFactor);
- let result: any = mul(
- baseAlbedo,
- mul(add(softLight, float(LIGHT_AMBIENT_BOOST)), nightDim),
- );
- // ---- Sun shade (shadow-side sky tint, matches terrain) ----
- result = applyTerrainSunShade(result, N, L, vec3(uShadeColor));
+ // ---- Sun shade on albedo (driven by dayIntensity to match scene light timing) ----
+ baseAlbedo = applySunShade(baseAlbedo, uDayIntensity, vec3(uShadeColor));
+
+ // ---- 4-band Ghibli toon lighting (warm highlights → cool shadows) ----
+ // Derive 4 hue-shifted color variants from sampled texture albedo.
+ // Highlights shift warm (golden), shadows shift cool (teal).
+ const L = normalize(vec3(uSunDir));
+ const N = normalize(normalWorldGeometry);
+ const NdotL = dot(N, L);
+
+ const band0Color = mul(baseAlbedo, vec3(1.35, 1.08, 0.82));
+ const band1Color = baseAlbedo;
+ const band2Color = mul(baseAlbedo, vec3(0.65, 0.78, 0.82));
+ const band3Color = mul(baseAlbedo, vec3(0.38, 0.52, 0.68));
+
+ const s0 = step(float(TOON_BRIGHT_EDGE), NdotL);
+ const s1 = step(float(TOON_MID_EDGE), NdotL);
+ const s2 = step(float(TOON_SHADOW_EDGE), NdotL);
+
+ const toonStep0 = mix(band3Color, band2Color, s2);
+ const toonStep1 = mix(toonStep0, band1Color, s1);
+ const toonColor = mix(toonStep1, band0Color, s0);
+
+ const nightDim = mix(float(NIGHT_MIN_BRIGHTNESS), float(1.0), dayFactor);
+ let result: any = mul(toonColor, nightDim);
- // ---- SSS + Fresnel edge brightening (leaf only, scaled by dayFactor) ----
+ // ---- SSS + hard-edged toon rim (leaf only, scaled by dayFactor) ----
if (isLeaf) {
const V = normalize(sub(cameraPosition, positionWorld));
@@ -1179,14 +1250,15 @@ export function createTreeDissolveMaterial(
);
result = add(result, mul(vec3(0.95, 1.0, 0.7), sssFactor));
- // Edge brightening (fades at night)
+ // Hard-edged toon rim (fades at night)
const EDotN = clamp(dot(V, N), float(0.0), float(1.0));
- const edgeBright = mix(
- float(EDGE_BRIGHT),
+ const rimMask = sub(float(1.0), step(float(TOON_RIM_THRESHOLD), EDotN));
+ const rimBright = mix(
float(1.0),
- sub(float(1.0), dayFactor),
+ float(TOON_RIM_BRIGHT),
+ mul(rimMask, dayFactor),
);
- result = mix(mul(result, edgeBright), result, EDotN);
+ result = mul(result, rimBright);
}
// ---- Saturation boost (scales with dayFactor so night stays muted) ----
@@ -1201,8 +1273,8 @@ export function createTreeDissolveMaterial(
let hlIntensity;
if (options.batched) {
const batchColor = varyingProperty("vec3", "vBatchColor");
- // Only check R/G for highlight — blue channel is reserved for dissolve state
- hlIntensity = step(float(1.01), max(batchColor.x, batchColor.y));
+ // Only check R for highlight — G = snow weight, B = dissolve state
+ hlIntensity = step(float(1.01), batchColor.x);
} else {
hlIntensity = attribute("instanceHighlight", "float");
}
@@ -1234,6 +1306,7 @@ export function createTreeDissolveMaterial(
treeMat.treeUniforms = {
sunDirection: uSunDir as unknown as { value: THREE.Vector3 },
sunIntensity: uSunIntensity as unknown as { value: number },
+ dayIntensity: uDayIntensity as unknown as { value: number },
shadeColor: uShadeColor as unknown as { value: THREE.Color },
windTime: uWindTime as unknown as { value: number },
windStrength: uWindStrength as unknown as { value: number },
diff --git a/packages/shared/src/systems/shared/world/GrassVisualManager.ts b/packages/shared/src/systems/shared/world/GrassVisualManager.ts
new file mode 100644
index 000000000..2ae1792a5
--- /dev/null
+++ b/packages/shared/src/systems/shared/world/GrassVisualManager.ts
@@ -0,0 +1,1232 @@
+// @ts-nocheck -- TSL type definitions are incomplete for Fn() callbacks and node reassignment
+/**
+ * GrassVisualManager — Procedural instanced grass clumps aligned with the
+ * terrain quad-tree. Each clump is a single geometry containing multiple
+ * blades with pre-baked local offsets, rotations and height variation.
+ * Each max-depth quad-tree leaf gets one InstancedMesh of clumps.
+ *
+ * Follows the same QuadTreeListener pattern as WaterVisualManager.
+ * CLIENT-ONLY: Only used when USE_QUADTREE_LOD is true.
+ */
+
+import THREE, {
+ uniform,
+ Fn,
+ float,
+ sin,
+ time,
+ positionLocal,
+ attribute,
+ cos,
+ uv,
+ pow,
+ vec3,
+ vec4,
+ mix,
+ smoothstep,
+ sub,
+ dot,
+ clamp,
+ mul,
+ modelWorldMatrix,
+ cameraViewMatrix,
+ output,
+} from "../../../extras/three/three";
+import { SUN_LIGHT } from "./LightingConfig";
+import { applyAnimeShade, TERRAIN_SHADER_CONSTANTS } from "./TerrainShader";
+import { MeshStandardNodeMaterial } from "three/webgpu";
+import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree";
+import {
+ getGrassWorkerPool,
+ terminateGrassWorkerPool,
+ type GrassWorkerInput,
+ type GrassWorkerOutput,
+} from "../../../utils/workers/GrassWorker";
+import type { TerrainWorkerConfig } from "../../../utils/workers/TerrainWorker";
+import type { BiomeGrassConfigWorker } from "../../../utils/workers/GrassWorker";
+
+// ---------------------------------------------------------------------------
+// Configuration — tweak these to control grass appearance & performance
+// ---------------------------------------------------------------------------
+
+export const GRASS_CONFIG = {
+ // -- Density & distribution -----------------------------------------------
+ /** Average distance between clump centers in meters (lower = denser) */
+ CLUMP_SPACING: 0.7,
+
+ // -- Clump composition ----------------------------------------------------
+ /** Number of blades baked into each clump geometry */
+ BLADES_PER_CLUMP: 24,
+ /** Outer radius of the clump ring in meters */
+ CLUMP_RADIUS: 0.7,
+ /** Inner radius ratio (0-1, blades placed between inner*radius and radius) */
+ CLUMP_INNER_RATIO: 0.05,
+
+ // -- Blade shape ----------------------------------------------------------
+ /** Segments per blade (4 rows of 2 verts + 1 tip = 9 verts) */
+ BLADE_SEGMENTS: 3,
+ /** Blade width scales with height: width = height * WIDTH_RATIO */
+ BLADE_WIDTH_RATIO: 0.04,
+ /** Blade min height in world units */
+ BLADE_HEIGHT_MIN: 0.45,
+ /** Blade max height in world units */
+ BLADE_HEIGHT_MAX: 1.15,
+ /** Arc curvature ratio (arc distance ≈ height * this) */
+ BLADE_ARC_RATIO: 0.18,
+ /** Tip taper (0 = rectangle, 1 = full point) */
+ BLADE_TAPER: 0.85,
+
+ // -- Per-instance variation -----------------------------------------------
+ /** Clump-level scale range */
+ SCALE_MIN: 0.7,
+ SCALE_MAX: 1.3,
+
+ // -- Wind -----------------------------------------------------------------
+ WIND_SPEED: 1.8,
+ WIND_STRENGTH: 0.15,
+
+ // -- Color ----------------------------------------------------------------
+ /** Gradient power curve (higher = root color persists longer up the blade) */
+ GRADIENT_FALLOFF: 1.7,
+
+ // -- Distance limits -------------------------------------------------------
+ /** Grass starts shrinking at this distance (meters) */
+ FADE_START: 350,
+ /** Grass fully invisible / chunks pruned beyond this distance */
+ MAX_RENDER_DISTANCE: 500,
+
+ // -- LOD tiers (by distance from camera) ------------------------------------
+ LOD_TIERS: [
+ { maxDistance: 80, bladesPerClump: 24, bladeSegments: 3, spacingMul: 1.0 },
+ { maxDistance: 200, bladesPerClump: 12, bladeSegments: 2, spacingMul: 1.0 },
+ {
+ maxDistance: 500,
+ bladesPerClump: 4,
+ bladeSegments: 1,
+ spacingMul: 5.0,
+ },
+ ],
+ LOD_HYSTERESIS: 0.1,
+
+ /** Deterministic seed */
+ SEED: 73856093,
+};
+
+// ---------------------------------------------------------------------------
+// Interleave groundColor (vec3) + grassTint (vec4) into a single vertex buffer
+// to stay within WebGPU's 8-buffer limit.
+// ---------------------------------------------------------------------------
+
+function setColorTintInterleaved(
+ geo: THREE.BufferGeometry,
+ groundColors: Float32Array,
+ grassTints: Float32Array,
+ count: number,
+): void {
+ const stride = 7; // 3 (color) + 4 (tint)
+ const buf = new Float32Array(count * stride);
+ for (let i = 0; i < count; i++) {
+ const s = i * stride;
+ const c = i * 3;
+ const t = i * 4;
+ buf[s] = groundColors[c];
+ buf[s + 1] = groundColors[c + 1];
+ buf[s + 2] = groundColors[c + 2];
+ buf[s + 3] = grassTints[t];
+ buf[s + 4] = grassTints[t + 1];
+ buf[s + 5] = grassTints[t + 2];
+ buf[s + 6] = grassTints[t + 3];
+ }
+ const ib = new THREE.InstancedInterleavedBuffer(buf, stride);
+ geo.setAttribute(
+ "instanceGroundColor",
+ new THREE.InterleavedBufferAttribute(ib, 3, 0),
+ );
+ geo.setAttribute(
+ "instanceGrassTint",
+ new THREE.InterleavedBufferAttribute(ib, 4, 3),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Seeded PRNG
+// ---------------------------------------------------------------------------
+
+function mulberry32(seed: number): () => number {
+ let s = seed | 0;
+ return () => {
+ s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Procedural clump geometry — sunflower-spiral blade arrangement with baked
+// arc curvature, per-blade rotation/height/width variation.
+// ---------------------------------------------------------------------------
+
+function createClumpGeometry(
+ bladesPerClump = GRASS_CONFIG.BLADES_PER_CLUMP,
+ bladeSegments = GRASS_CONFIG.BLADE_SEGMENTS,
+): THREE.BufferGeometry {
+ const N = bladesPerClump;
+ const segs = bladeSegments;
+ const {
+ CLUMP_RADIUS,
+ CLUMP_INNER_RATIO,
+ BLADE_WIDTH_RATIO,
+ BLADE_HEIGHT_MIN: hMin,
+ BLADE_HEIGHT_MAX: hMax,
+ BLADE_ARC_RATIO,
+ BLADE_TAPER: taper,
+ } = GRASS_CONFIG;
+
+ const vertsPerBlade = segs * 2 + 1;
+ const trisPerBlade = (segs - 1) * 2 + 1;
+ const totalVerts = vertsPerBlade * N;
+ const totalIdx = trisPerBlade * 3 * N;
+
+ const positions = new Float32Array(totalVerts * 3);
+ const normals = new Float32Array(totalVerts * 3);
+ const uvs = new Float32Array(totalVerts * 2);
+ const indices = new Uint16Array(totalIdx);
+
+ const rng = mulberry32(91827364);
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
+
+ let vi = 0;
+ let ii = 0;
+
+ for (let b = 0; b < N; b++) {
+ const t01 = b / N;
+ const angle = b * GOLDEN_ANGLE;
+ const rNorm = CLUMP_INNER_RATIO + (1 - CLUMP_INNER_RATIO) * Math.sqrt(t01);
+ const r = rNorm * CLUMP_RADIUS;
+ const jitter = (rng() - 0.5) * 0.15 * CLUMP_RADIUS;
+ const ox = Math.cos(angle) * r + Math.cos(angle + 1.3) * jitter;
+ const oz = Math.sin(angle) * r + Math.sin(angle + 1.3) * jitter;
+
+ const facingAngle = angle + Math.PI * 0.5 + (rng() - 0.5) * Math.PI;
+ const cr = Math.cos(facingAngle);
+ const sr = Math.sin(facingAngle);
+
+ const h = hMin + (hMax - hMin) * (0.3 + 0.7 * t01 + (rng() - 0.5) * 0.4);
+ const w = h * BLADE_WIDTH_RATIO;
+
+ const curveAngle = angle + (rng() - 0.5) * Math.PI * 0.6;
+ const arcDist = h * BLADE_ARC_RATIO * (0.8 + rng() * 0.4);
+ const curveDirX = Math.cos(curveAngle) * arcDist;
+ const curveDirZ = Math.sin(curveAngle) * arcDist;
+
+ rng(); // consume one RNG value to keep deterministic sequence stable
+ const baseVert = vi;
+
+ for (let i = 0; i < segs; i++) {
+ const t = i / segs;
+ const y = t * h;
+ const hw = w * 0.5 * (1.0 - t * taper);
+ const arc = t * t;
+ const arcX = curveDirX * arc;
+ const arcZ = curveDirZ * arc;
+
+ for (let side = 0; side < 2; side++) {
+ const lx = side === 0 ? -hw : hw;
+ positions[vi * 3] = lx * cr + arcX + ox;
+ positions[vi * 3 + 1] = y;
+ positions[vi * 3 + 2] = lx * sr + arcZ + oz;
+ normals[vi * 3] = -sr;
+ normals[vi * 3 + 1] = 0;
+ normals[vi * 3 + 2] = cr;
+ uvs[vi * 2] = side;
+ uvs[vi * 2 + 1] = t;
+ vi++;
+ }
+ }
+
+ positions[vi * 3] = curveDirX + ox;
+ positions[vi * 3 + 1] = h;
+ positions[vi * 3 + 2] = curveDirZ + oz;
+ normals[vi * 3] = -sr;
+ normals[vi * 3 + 1] = 0;
+ normals[vi * 3 + 2] = cr;
+ uvs[vi * 2] = 0.5;
+ uvs[vi * 2 + 1] = 1.0;
+ vi++;
+
+ for (let i = 0; i < segs - 1; i++) {
+ const bv = baseVert + i * 2;
+ indices[ii++] = bv;
+ indices[ii++] = bv + 1;
+ indices[ii++] = bv + 2;
+ indices[ii++] = bv + 1;
+ indices[ii++] = bv + 3;
+ indices[ii++] = bv + 2;
+ }
+ const lastRow = baseVert + (segs - 1) * 2;
+ const tipIdx = baseVert + segs * 2;
+ indices[ii++] = lastRow;
+ indices[ii++] = lastRow + 1;
+ indices[ii++] = tipIdx;
+ }
+
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
+ geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
+ geo.setIndex(new THREE.BufferAttribute(indices, 1));
+ return geo;
+}
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface GrassChunk {
+ nodeId: number;
+ mesh: THREE.InstancedMesh;
+ box: THREE.Box3;
+ lodLevel: number;
+ node: TerrainQuadNode;
+}
+
+// ---------------------------------------------------------------------------
+// GrassVisualManager
+// ---------------------------------------------------------------------------
+
+/** Config data for the grass worker, passed from TerrainSystem at construction. */
+export interface GrassWorkerSetup {
+ terrainConfig: TerrainWorkerConfig;
+ seed: number;
+ biomeCenters: Array<{
+ x: number;
+ z: number;
+ type: string;
+ influence: number;
+ }>;
+ biomes: Record<
+ string,
+ { heightModifier: number; color: { r: number; g: number; b: number } }
+ >;
+ grassConfigs: Record;
+ tileSize: number;
+ getRoadSegmentsForRegion: (
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ) => Array<{
+ startX: number;
+ startZ: number;
+ endX: number;
+ endZ: number;
+ width: number;
+ }>;
+ getFlatZonesForRegion: (
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ) => Array<{
+ centerX: number;
+ centerZ: number;
+ halfWidth: number;
+ halfDepth: number;
+ blendRadius: number;
+ }>;
+}
+
+export class GrassVisualManager implements QuadTreeListener {
+ private container: THREE.Group;
+ private getHeightAt: (x: number, z: number) => number;
+ private getRoadInfluence: (wx: number, wz: number) => number;
+ private isInFlatZone: (wx: number, wz: number) => boolean;
+ private getTerrainColorAt: (
+ wx: number,
+ wz: number,
+ ) => {
+ r: number;
+ g: number;
+ b: number;
+ grassWeight: number;
+ grassPlacement: number;
+ grassHeightScale: number;
+ tintR: number;
+ tintG: number;
+ tintB: number;
+ tintStrength: number;
+ nx: number;
+ ny: number;
+ nz: number;
+ };
+ private sunDirUniform: ReturnType>;
+ private dayIntensityUniform: ReturnType>;
+ private playerPosUniform: ReturnType>;
+ private waterThreshold: number;
+ private chunks = new Map();
+ private material: MeshStandardNodeMaterial;
+ private lodGeometries: THREE.BufferGeometry[];
+
+ private frustum = new THREE.Frustum();
+ private projScreenMatrix = new THREE.Matrix4();
+ private playerX = 0;
+ private playerZ = 0;
+
+ private pendingNodes: { node: TerrainQuadNode; lod?: number }[] = [];
+ private static readonly MAX_CHUNKS_PER_FRAME = 2;
+
+ private workerSetup: GrassWorkerSetup | null = null;
+ private workerInflight = new Set();
+ private pendingLodSwap = new Map<
+ string,
+ { node: TerrainQuadNode; desiredLod: number }
+ >();
+ private destroyed = false;
+
+ constructor(
+ container: THREE.Group,
+ getHeightAt: (x: number, z: number) => number,
+ waterThreshold: number,
+ getRoadInfluence: (wx: number, wz: number) => number,
+ isInFlatZone: (wx: number, wz: number) => boolean,
+ getTerrainColorAt: (
+ wx: number,
+ wz: number,
+ ) => {
+ r: number;
+ g: number;
+ b: number;
+ grassWeight: number;
+ grassPlacement: number;
+ grassHeightScale: number;
+ },
+ workerSetup?: GrassWorkerSetup,
+ ) {
+ this.container = container;
+ this.getHeightAt = getHeightAt;
+ this.waterThreshold = waterThreshold;
+ this.getRoadInfluence = getRoadInfluence;
+ this.isInFlatZone = isInFlatZone;
+ this.getTerrainColorAt = getTerrainColorAt;
+ this.workerSetup = workerSetup ?? null;
+
+ this.lodGeometries = GRASS_CONFIG.LOD_TIERS.map((tier) =>
+ createClumpGeometry(tier.bladesPerClump, tier.bladeSegments),
+ );
+ this.material = this.createMaterial();
+
+ const pool = getGrassWorkerPool();
+ const workerStatus = pool ? "workers available" : "sync fallback";
+
+ const tierDescs = GRASS_CONFIG.LOD_TIERS.map((t, i) => {
+ const g = this.lodGeometries[i];
+ return (
+ `LOD${i}(${t.bladesPerClump}b/${t.bladeSegments}s, ` +
+ `${g.attributes.position.count}v, ${g.index!.count / 3}t, ` +
+ `<${t.maxDistance === Infinity ? "inf" : t.maxDistance}m, ` +
+ `×${t.spacingMul})`
+ );
+ });
+ console.log(
+ `[GrassVisualManager] ${tierDescs.length} LOD tiers | ` +
+ `spacing ${GRASS_CONFIG.CLUMP_SPACING}m | ${workerStatus} | ${tierDescs.join(" | ")}`,
+ );
+ }
+
+ // -- Public API -----------------------------------------------------------
+
+ setPlayerPosition(x: number, z: number): void {
+ this.playerX = x;
+ this.playerZ = z;
+ }
+
+ updateLighting(sunDir: THREE.Vector3): void {
+ this.sunDirUniform.value.copy(sunDir);
+ }
+
+ updateDayIntensity(val: number): void {
+ this.dayIntensityUniform.value = val;
+ }
+
+ update(playerX: number, playerZ: number, camera?: THREE.Camera): void {
+ this.playerX = playerX;
+ this.playerZ = playerZ;
+ this.playerPosUniform.value.set(playerX, 0, playerZ);
+
+ // Drain pending queue — dispatch to worker or build sync (fallback only)
+ let built = 0;
+ const pool = getGrassWorkerPool();
+ while (
+ this.pendingNodes.length > 0 &&
+ built < GrassVisualManager.MAX_CHUNKS_PER_FRAME
+ ) {
+ const { node, lod } = this.pendingNodes.shift()!;
+ const key = this.chunkKey(node);
+ if (this.chunks.has(key) || this.workerInflight.has(key)) continue;
+ if (pool && this.workerSetup) {
+ this.dispatchToWorker(node, key);
+ } else {
+ this.createChunkMesh(node, lod);
+ built++;
+ }
+ }
+
+ if (!camera || this.chunks.size === 0) return;
+
+ this.projScreenMatrix.multiplyMatrices(
+ camera.projectionMatrix,
+ camera.matrixWorldInverse,
+ );
+ this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
+
+ const tiers = GRASS_CONFIG.LOD_TIERS;
+ const hysteresis = GRASS_CONFIG.LOD_HYSTERESIS;
+
+ const maxDistSq =
+ GRASS_CONFIG.MAX_RENDER_DISTANCE * GRASS_CONFIG.MAX_RENDER_DISTANCE;
+ const pruneKeys: string[] = [];
+
+ for (const [key, chunk] of this.chunks) {
+ const dx = chunk.node.centerX - playerX;
+ const dz = chunk.node.centerZ - playerZ;
+ const distSq = dx * dx + dz * dz;
+
+ if (distSq > maxDistSq) {
+ pruneKeys.push(key);
+ chunk.mesh.visible = false;
+ continue;
+ }
+
+ chunk.mesh.visible = this.frustum.intersectsBox(chunk.box);
+ if (
+ !chunk.mesh.visible ||
+ built >= GrassVisualManager.MAX_CHUNKS_PER_FRAME
+ )
+ continue;
+
+ const dist = Math.sqrt(distSq);
+ const desiredLod = this.getLodLevel(chunk.node);
+
+ if (desiredLod !== chunk.lodLevel) {
+ const currentTier = tiers[chunk.lodLevel];
+ const boundary =
+ desiredLod < chunk.lodLevel
+ ? currentTier.maxDistance
+ : (tiers[desiredLod - 1]?.maxDistance ?? 0);
+ const threshold =
+ boundary *
+ (1 + (desiredLod < chunk.lodLevel ? -hysteresis : hysteresis));
+ const shouldSwitch =
+ desiredLod < chunk.lodLevel ? dist < threshold : dist > threshold;
+
+ if (shouldSwitch) {
+ const nodeKey = this.chunkKey(chunk.node);
+ const workerPool = getGrassWorkerPool();
+ if (workerPool && this.workerSetup) {
+ this.pendingLodSwap.set(nodeKey, {
+ node: chunk.node,
+ desiredLod,
+ });
+ if (!this.workerInflight.has(nodeKey)) {
+ this.dispatchLodSwap(chunk.node, nodeKey, desiredLod);
+ }
+ } else {
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ this.chunks.delete(nodeKey);
+ this.createChunkMesh(chunk.node, desiredLod);
+ built++;
+ }
+ }
+ }
+ }
+
+ for (const key of pruneKeys) {
+ const chunk = this.chunks.get(key);
+ if (chunk) {
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ this.chunks.delete(key);
+ }
+ this.workerInflight.delete(key);
+ this.pendingLodSwap.delete(key);
+ }
+ }
+
+ // -- QuadTreeListener -----------------------------------------------------
+
+ onNodeNeedsGeometry(node: TerrainQuadNode): void {
+ if (!node.isMaxDepth) return;
+ const dx = node.centerX - this.playerX;
+ const dz = node.centerZ - this.playerZ;
+ if (
+ dx * dx + dz * dz >
+ GRASS_CONFIG.MAX_RENDER_DISTANCE * GRASS_CONFIG.MAX_RENDER_DISTANCE
+ )
+ return;
+ const key = this.chunkKey(node);
+ if (this.chunks.has(key)) return;
+ if (this.workerInflight.has(key)) return;
+
+ const pool = getGrassWorkerPool();
+ if (pool && this.workerSetup) {
+ this.dispatchToWorker(node, key);
+ } else {
+ if (!this.pendingNodes.some((p) => p.node === node)) {
+ this.pendingNodes.push({ node });
+ }
+ }
+ }
+
+ private dispatchLodSwap(
+ node: TerrainQuadNode,
+ key: string,
+ desiredLod: number,
+ ): void {
+ const ws = this.workerSetup!;
+ const pool = getGrassWorkerPool()!;
+ const tier = GRASS_CONFIG.LOD_TIERS[desiredLod];
+
+ const half = node.halfSize;
+ const roadSegments = ws.getRoadSegmentsForRegion(
+ node.centerX - half,
+ node.centerZ - half,
+ node.centerX + half,
+ node.centerZ + half,
+ );
+ const flatZones = ws.getFlatZonesForRegion(
+ node.centerX - half,
+ node.centerZ - half,
+ node.centerX + half,
+ node.centerZ + half,
+ );
+
+ const input: GrassWorkerInput = {
+ type: "generateGrassInstances",
+ chunkKey: key,
+ centerX: node.centerX,
+ centerZ: node.centerZ,
+ size: node.size,
+ spacingMul: tier.spacingMul,
+ config: ws.terrainConfig,
+ seed: ws.seed,
+ biomeCenters: ws.biomeCenters,
+ biomes: ws.biomes,
+ grassSeed: GRASS_CONFIG.SEED,
+ clumpSpacing: GRASS_CONFIG.CLUMP_SPACING,
+ scaleMin: GRASS_CONFIG.SCALE_MIN,
+ scaleMax: GRASS_CONFIG.SCALE_MAX,
+ waterThreshold: this.waterThreshold,
+ grassConfigs: ws.grassConfigs,
+ shaderConstants: {
+ NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.NOISE_SCALE,
+ DISTORT_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE,
+ VARIATION_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE,
+ ROCK_DISTORT_STRENGTH: TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH,
+ HEIGHT_DISTORT_STRENGTH:
+ TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH,
+ DIRT_THRESHOLD: TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD,
+ SATURATION_BOOST: TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST,
+ },
+ roadSegments,
+ roadBlendWidth: 0.5,
+ tileSize: ws.tileSize,
+ flatZones,
+ };
+
+ this.workerInflight.add(key);
+
+ pool
+ .execute(input)
+ .then((output: GrassWorkerOutput) => {
+ this.workerInflight.delete(key);
+ if (this.destroyed) return;
+
+ const latest = this.pendingLodSwap.get(key);
+ const finalLod = latest ? latest.desiredLod : desiredLod;
+ this.pendingLodSwap.delete(key);
+
+ const oldChunk = this.chunks.get(key);
+ if (oldChunk) {
+ if (oldChunk.mesh.parent) oldChunk.mesh.parent.remove(oldChunk.mesh);
+ oldChunk.mesh.geometry.dispose();
+ this.chunks.delete(key);
+ }
+
+ if (output.count === 0) return;
+ this.createChunkMeshFromWorkerData(node, output, finalLod);
+ })
+ .catch((err: unknown) => {
+ this.workerInflight.delete(key);
+ this.pendingLodSwap.delete(key);
+ if (this.destroyed) return;
+ console.warn(
+ `[GrassVisualManager] LOD swap worker failed for ${key}:`,
+ err,
+ );
+ });
+ }
+
+ private dispatchToWorker(node: TerrainQuadNode, key: string): void {
+ const ws = this.workerSetup!;
+ const pool = getGrassWorkerPool()!;
+ const lod = this.getLodLevel(node);
+ const tier = GRASS_CONFIG.LOD_TIERS[lod];
+
+ const half = node.halfSize;
+ const roadSegments = ws.getRoadSegmentsForRegion(
+ node.centerX - half,
+ node.centerZ - half,
+ node.centerX + half,
+ node.centerZ + half,
+ );
+ const flatZones = ws.getFlatZonesForRegion(
+ node.centerX - half,
+ node.centerZ - half,
+ node.centerX + half,
+ node.centerZ + half,
+ );
+
+ const input: GrassWorkerInput = {
+ type: "generateGrassInstances",
+ chunkKey: key,
+ centerX: node.centerX,
+ centerZ: node.centerZ,
+ size: node.size,
+ spacingMul: tier.spacingMul,
+ config: ws.terrainConfig,
+ seed: ws.seed,
+ biomeCenters: ws.biomeCenters,
+ biomes: ws.biomes,
+ grassSeed: GRASS_CONFIG.SEED,
+ clumpSpacing: GRASS_CONFIG.CLUMP_SPACING,
+ scaleMin: GRASS_CONFIG.SCALE_MIN,
+ scaleMax: GRASS_CONFIG.SCALE_MAX,
+ waterThreshold: this.waterThreshold,
+ grassConfigs: ws.grassConfigs,
+ shaderConstants: {
+ NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.NOISE_SCALE,
+ DISTORT_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE,
+ VARIATION_NOISE_SCALE: TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE,
+ ROCK_DISTORT_STRENGTH: TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH,
+ HEIGHT_DISTORT_STRENGTH:
+ TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH,
+ DIRT_THRESHOLD: TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD,
+ SATURATION_BOOST: TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST,
+ },
+ roadSegments,
+ roadBlendWidth: 0.5,
+ tileSize: ws.tileSize,
+ flatZones,
+ };
+
+ this.workerInflight.add(key);
+
+ pool
+ .execute(input)
+ .then((output: GrassWorkerOutput) => {
+ this.workerInflight.delete(key);
+ if (this.destroyed) return;
+ if (this.chunks.has(key)) return;
+ if (output.count === 0) return;
+ this.createChunkMeshFromWorkerData(node, output, lod);
+ })
+ .catch((err: unknown) => {
+ this.workerInflight.delete(key);
+ if (this.destroyed) return;
+ console.warn(
+ `[GrassVisualManager] Worker failed for ${key}, falling back to sync:`,
+ err,
+ );
+ if (
+ !this.chunks.has(key) &&
+ !this.pendingNodes.some((p) => p.node === node)
+ ) {
+ this.pendingNodes.push({ node });
+ }
+ });
+ }
+
+ private createChunkMeshFromWorkerData(
+ node: TerrainQuadNode,
+ data: GrassWorkerOutput,
+ lodLevel: number,
+ ): void {
+ const key = data.chunkKey;
+ if (this.chunks.has(key)) return;
+ if (data.count === 0) return;
+
+ const geo = this.lodGeometries[lodLevel].clone();
+ geo.setAttribute(
+ "instanceOffset",
+ new THREE.InstancedBufferAttribute(data.offsets, 3),
+ );
+ geo.setAttribute(
+ "instanceRotScaleHash",
+ new THREE.InstancedBufferAttribute(data.rotScaleHash, 3),
+ );
+ setColorTintInterleaved(
+ geo,
+ data.groundColors,
+ data.grassTints,
+ data.count,
+ );
+ geo.setAttribute(
+ "instanceGroundNormal",
+ new THREE.InstancedBufferAttribute(data.groundNormals, 3),
+ );
+
+ const mesh = new THREE.InstancedMesh(geo, this.material, data.count);
+ mesh.position.set(node.centerX, 0, node.centerZ);
+ mesh.name = `GrassQT_${key}`;
+ mesh.frustumCulled = false;
+ mesh.receiveShadow = true;
+ mesh.castShadow = false;
+ mesh.userData = { type: "grass", walkable: false, clickable: false };
+
+ const identity = new THREE.Matrix4();
+ for (let i = 0; i < data.count; i++) {
+ mesh.setMatrixAt(i, identity);
+ }
+ mesh.instanceMatrix.needsUpdate = true;
+
+ const half = node.halfSize;
+ const box = new THREE.Box3(
+ new THREE.Vector3(node.centerX - half, -50, node.centerZ - half),
+ new THREE.Vector3(node.centerX + half, 200, node.centerZ + half),
+ );
+
+ this.container.add(mesh);
+ this.chunks.set(key, { nodeId: node.id, mesh, box, lodLevel, node });
+ }
+
+ onNodeDestroyGeometry(node: TerrainQuadNode): void {
+ const key = this.chunkKey(node);
+ const chunk = this.chunks.get(key);
+ if (chunk) {
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ this.chunks.delete(key);
+ }
+ this.workerInflight.delete(key);
+ this.pendingLodSwap.delete(key);
+ }
+
+ destroy(): void {
+ this.destroyed = true;
+ this.pendingNodes.length = 0;
+ this.workerInflight.clear();
+ this.pendingLodSwap.clear();
+ terminateGrassWorkerPool();
+ for (const [, chunk] of this.chunks) {
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ }
+ this.chunks.clear();
+ this.lodGeometries.forEach((g) => g.dispose());
+ if (this.material) this.material.dispose();
+ if (this.container.parent) this.container.parent.remove(this.container);
+ }
+
+ /**
+ * Destroy and recreate all grass chunks (e.g. after road data loads).
+ */
+ rebuildAllChunks(): void {
+ const nodes: TerrainQuadNode[] = [];
+ for (const chunk of this.chunks.values()) {
+ nodes.push(chunk.node);
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ }
+ this.chunks.clear();
+ this.workerInflight.clear();
+ this.pendingLodSwap.clear();
+ for (const node of nodes) {
+ node.visualChunkKey = null;
+ if (!this.pendingNodes.some((p) => p.node === node)) {
+ this.pendingNodes.push({ node });
+ }
+ }
+ }
+
+ /**
+ * Invalidate grass chunks that overlap a world-space bounding box.
+ * Used when flat zones are registered/unregistered so grass regenerates
+ * with correct heights and placement.
+ */
+ invalidateRegion(
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ): void {
+ const toRebuild: TerrainQuadNode[] = [];
+ for (const [key, chunk] of this.chunks) {
+ const half = chunk.node.halfSize;
+ const cx = chunk.node.centerX;
+ const cz = chunk.node.centerZ;
+ if (
+ cx + half < minX ||
+ cx - half > maxX ||
+ cz + half < minZ ||
+ cz - half > maxZ
+ )
+ continue;
+ toRebuild.push(chunk.node);
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ this.chunks.delete(key);
+ this.workerInflight.delete(key);
+ this.pendingLodSwap.delete(key);
+ }
+ for (const node of toRebuild) {
+ if (!this.pendingNodes.some((p) => p.node === node)) {
+ this.pendingNodes.push({ node });
+ }
+ }
+ }
+
+ // -- LOD helpers -----------------------------------------------------------
+
+ private getLodLevel(node: TerrainQuadNode): number {
+ const dx = node.centerX - this.playerX;
+ const dz = node.centerZ - this.playerZ;
+ const dist = Math.sqrt(dx * dx + dz * dz);
+ const tiers = GRASS_CONFIG.LOD_TIERS;
+ for (let i = 0; i < tiers.length; i++) {
+ if (dist < tiers[i].maxDistance) return i;
+ }
+ return tiers.length - 1;
+ }
+
+ // -- Chunk mesh creation --------------------------------------------------
+
+ private createChunkMesh(node: TerrainQuadNode, lodLevel?: number): void {
+ const key = this.chunkKey(node);
+ if (this.chunks.has(key)) return;
+
+ const lod = lodLevel ?? this.getLodLevel(node);
+ const tier = GRASS_CONFIG.LOD_TIERS[lod];
+ const instanceData = this.generateInstanceData(node, tier.spacingMul);
+ if (!instanceData || instanceData.count === 0) return;
+
+ const geo = this.lodGeometries[lod].clone();
+ geo.setAttribute(
+ "instanceOffset",
+ new THREE.InstancedBufferAttribute(instanceData.offsets, 3),
+ );
+ geo.setAttribute(
+ "instanceRotScaleHash",
+ new THREE.InstancedBufferAttribute(instanceData.rotScaleHash, 3),
+ );
+ setColorTintInterleaved(
+ geo,
+ instanceData.groundColors,
+ instanceData.grassTints,
+ instanceData.count,
+ );
+ geo.setAttribute(
+ "instanceGroundNormal",
+ new THREE.InstancedBufferAttribute(instanceData.groundNormals, 3),
+ );
+
+ const mesh = new THREE.InstancedMesh(
+ geo,
+ this.material,
+ instanceData.count,
+ );
+ mesh.position.set(node.centerX, 0, node.centerZ);
+ mesh.name = `GrassQT_${key}`;
+ mesh.frustumCulled = false;
+ mesh.receiveShadow = true;
+ mesh.castShadow = false;
+ mesh.userData = { type: "grass", walkable: false, clickable: false };
+
+ const identity = new THREE.Matrix4();
+ for (let i = 0; i < instanceData.count; i++) {
+ mesh.setMatrixAt(i, identity);
+ }
+ mesh.instanceMatrix.needsUpdate = true;
+
+ const half = node.halfSize;
+ const box = new THREE.Box3(
+ new THREE.Vector3(node.centerX - half, -50, node.centerZ - half),
+ new THREE.Vector3(node.centerX + half, 200, node.centerZ + half),
+ );
+
+ this.container.add(mesh);
+ this.chunks.set(key, { nodeId: node.id, mesh, box, lodLevel: lod, node });
+ }
+
+ // -- Instance data generation ---------------------------------------------
+
+ private generateInstanceData(
+ node: TerrainQuadNode,
+ spacingMul = 1,
+ ): {
+ offsets: Float32Array;
+ rotScaleHash: Float32Array;
+ groundColors: Float32Array;
+ grassTints: Float32Array;
+ groundNormals: Float32Array;
+ count: number;
+ } | null {
+ const spacing = GRASS_CONFIG.CLUMP_SPACING * spacingMul;
+ const maxCount = Math.ceil((node.size * node.size) / (spacing * spacing));
+ const rng = mulberry32(
+ GRASS_CONFIG.SEED ^
+ ((node.centerX * 374761393 + node.centerZ * 668265263) | 0),
+ );
+
+ const offsets = new Float32Array(maxCount * 3);
+ const rotScaleHash = new Float32Array(maxCount * 3);
+ const groundColors = new Float32Array(maxCount * 3);
+ const grassTints = new Float32Array(maxCount * 4);
+ const groundNormals = new Float32Array(maxCount * 3);
+
+ let count = 0;
+
+ for (let i = 0; i < maxCount; i++) {
+ const lx = (rng() - 0.5) * node.size;
+ const lz = (rng() - 0.5) * node.size;
+ const clumpRng = rng();
+
+ const wx = node.centerX + lx;
+ const wz = node.centerZ + lz;
+ const ty = this.getHeightAt(wx, wz);
+
+ if (ty < this.waterThreshold + 0.1) continue;
+
+ if (this.isInFlatZone(wx, wz)) continue;
+
+ const roadInf = this.getRoadInfluence(wx, wz);
+ if (roadInf > 0.8) continue;
+
+ const {
+ r,
+ g,
+ b,
+ grassPlacement: rawGP,
+ grassHeightScale,
+ tintR,
+ tintG,
+ tintB,
+ tintStrength,
+ nx,
+ ny,
+ nz,
+ } = this.getTerrainColorAt(wx, wz);
+ const grassPlacement = Math.max(0, rawGP - roadInf);
+
+ if (grassPlacement <= 0) continue;
+ if (clumpRng > grassPlacement) continue;
+
+ offsets[count * 3] = lx;
+ offsets[count * 3 + 1] = ty;
+ offsets[count * 3 + 2] = lz;
+
+ const rotation = rng() * Math.PI * 2;
+ const scale =
+ (GRASS_CONFIG.SCALE_MIN +
+ clumpRng * (GRASS_CONFIG.SCALE_MAX - GRASS_CONFIG.SCALE_MIN)) *
+ grassHeightScale;
+ rotScaleHash[count * 3] = rotation;
+ rotScaleHash[count * 3 + 1] = scale;
+ rotScaleHash[count * 3 + 2] = clumpRng;
+
+ groundColors[count * 3] = r;
+ groundColors[count * 3 + 1] = g;
+ groundColors[count * 3 + 2] = b;
+
+ grassTints[count * 4] = tintR;
+ grassTints[count * 4 + 1] = tintG;
+ grassTints[count * 4 + 2] = tintB;
+ grassTints[count * 4 + 3] = tintStrength;
+
+ groundNormals[count * 3] = nx;
+ groundNormals[count * 3 + 1] = ny;
+ groundNormals[count * 3 + 2] = nz;
+
+ count++;
+ }
+
+ if (count === 0) return null;
+
+ return {
+ offsets: offsets.slice(0, count * 3),
+ rotScaleHash: rotScaleHash.slice(0, count * 3),
+ groundColors: groundColors.slice(0, count * 3),
+ grassTints: grassTints.slice(0, count * 4),
+ groundNormals: groundNormals.slice(0, count * 3),
+ count,
+ };
+ }
+
+ // -- TSL Material ---------------------------------------------------------
+
+ private createMaterial(): MeshStandardNodeMaterial {
+ const mat = new MeshStandardNodeMaterial();
+ mat.side = THREE.DoubleSide;
+ mat.transparent = false;
+ mat.depthWrite = true;
+ mat.roughness = 1.0;
+ mat.metalness = 0.0;
+ mat.fog = false;
+
+ const uWindSpeed = uniform(GRASS_CONFIG.WIND_SPEED);
+ const uWindStrength = uniform(GRASS_CONFIG.WIND_STRENGTH);
+ const uBladeHeight = uniform(GRASS_CONFIG.BLADE_HEIGHT_MAX);
+ this.sunDirUniform = uniform(
+ new THREE.Vector3(...SUN_LIGHT.DEFAULT_DIRECTION),
+ );
+ this.dayIntensityUniform = uniform(1.0);
+ this.playerPosUniform = uniform(new THREE.Vector3(0, 0, 0));
+
+ const uPlayerPos = this.playerPosUniform;
+ const uFadeStart = float(GRASS_CONFIG.FADE_START);
+ const uFadeEnd = float(GRASS_CONFIG.MAX_RENDER_DISTANCE);
+
+ mat.positionNode = Fn(() => {
+ const localPos = positionLocal.toVar("gp");
+
+ const offset = attribute("instanceOffset", "vec3");
+ const rsh = attribute("instanceRotScaleHash", "vec3");
+ const rot = rsh.x;
+ const scale = rsh.y;
+
+ const t = uv().y;
+
+ // Compute world-space XZ of the instance base for distance fade.
+ // offset is chunk-local; modelWorldMatrix translates by mesh.position (chunk center).
+ const worldBase = modelWorldMatrix.mul(
+ vec4(offset.x, float(0), offset.z, float(1.0)),
+ );
+ const toPlayer = sub(
+ vec3(worldBase.x, float(0), worldBase.z),
+ vec3(uPlayerPos.x, float(0), uPlayerPos.z),
+ );
+ const distSq = dot(toPlayer, toPlayer);
+ const dist = pow(distSq, float(0.5));
+ const fadeFactor = clamp(
+ sub(float(1.0), smoothstep(uFadeStart, uFadeEnd, dist)),
+ float(0.0),
+ float(1.0),
+ );
+
+ // Scale entire clump (with distance fade on Y)
+ localPos.x.assign(localPos.x.mul(scale));
+ localPos.y.assign(localPos.y.mul(scale).mul(fadeFactor));
+ localPos.z.assign(localPos.z.mul(scale));
+
+ // Rotate entire clump around Y — snapshot x/z first because TSL assign
+ // is sequential in WGSL (second assign would read the modified first).
+ const cosR = cos(rot);
+ const sinR = sin(rot);
+ const preRotX = localPos.x.toVar("preRotX");
+ const preRotZ = localPos.z.toVar("preRotZ");
+ localPos.x.assign(preRotX.mul(cosR).sub(preRotZ.mul(sinR)));
+ localPos.z.assign(preRotX.mul(sinR).add(preRotZ.mul(cosR)));
+
+ // Tilt grass to align with terrain slope (axis-angle rotation from Y-up to ground normal).
+ // Uses Rodrigues' rotation matrix with axis = cross((0,1,0), N) = (nz, 0, -nx).
+ // The 1/(1+ny) substitution avoids acos/sin and has no singularity for upward-facing normals.
+ const gn = attribute("instanceGroundNormal", "vec3");
+ const nx = gn.x;
+ const ny = gn.y;
+ const nz = gn.z;
+ const invOnePlusNy = float(1.0).div(ny.add(float(1.0)));
+ const nxnzTerm = nx.mul(nz).mul(invOnePlusNy).negate();
+
+ const preTiltX = localPos.x.toVar("preTiltX");
+ const preTiltY = localPos.y.toVar("preTiltY");
+ const preTiltZ = localPos.z.toVar("preTiltZ");
+
+ localPos.x.assign(
+ preTiltX
+ .mul(ny.add(nz.mul(nz).mul(invOnePlusNy)))
+ .add(preTiltY.mul(nx))
+ .add(preTiltZ.mul(nxnzTerm)),
+ );
+ localPos.y.assign(
+ preTiltX
+ .mul(nx.negate())
+ .add(preTiltY.mul(ny))
+ .add(preTiltZ.mul(nz.negate())),
+ );
+ localPos.z.assign(
+ preTiltX
+ .mul(nxnzTerm)
+ .add(preTiltY.mul(nz))
+ .add(preTiltZ.mul(ny.add(nx.mul(nx).mul(invOnePlusNy)))),
+ );
+
+ // Wind: displace tips via sine waves keyed to world-space offset
+ const wt = time.mul(uWindSpeed);
+ const bendFactor = pow(t, float(1.8));
+ localPos.x.addAssign(
+ sin(wt.add(offset.x.mul(0.35)).add(offset.z.mul(0.12)))
+ .mul(uWindStrength)
+ .mul(bendFactor)
+ .mul(uBladeHeight),
+ );
+ localPos.z.addAssign(
+ sin(
+ wt.mul(0.67).add(offset.x.mul(0.18)).add(offset.z.mul(0.28)).add(2.0),
+ )
+ .mul(uWindStrength)
+ .mul(0.55)
+ .mul(bendFactor)
+ .mul(uBladeHeight),
+ );
+
+ // Translate to instance world position (chunk-local XZ + baked terrainY)
+ localPos.x.addAssign(offset.x);
+ localPos.y.addAssign(offset.y);
+ localPos.z.addAssign(offset.z);
+
+ return localPos;
+ })();
+
+ const uSunDir = this.sunDirUniform;
+ const terrainNormal = attribute("instanceGroundNormal", "vec3");
+
+ // Override PBR surface normal to terrain normal (world→view transform).
+ // mat4.transformDirection(vec3) is left-multiply: matrixWorldInverse * normal
+ // = correct world→view. With normalNode set, PBR's faceDirection flip is
+ // bypassed so both sides of a blade get the same terrain N·L.
+ mat.normalNode = cameraViewMatrix.transformDirection(terrainNormal);
+
+ mat.colorNode = Fn(() => {
+ const groundCol = attribute("instanceGroundColor", "vec3");
+ const tint = attribute("instanceGrassTint", "vec4");
+ const tintCol = tint.xyz;
+ const tintStr = tint.w;
+ const t = uv().y;
+ const tintedCol = mix(groundCol, tintCol, tintStr);
+ const tipCol = mix(
+ groundCol,
+ tintedCol,
+ smoothstep(float(0.0), float(1.0), t),
+ ).mul(1.4);
+ const bladeCol = mix(
+ groundCol,
+ tipCol,
+ smoothstep(float(0.0), float(1.0), t),
+ );
+ return applyAnimeShade(bladeCol, terrainNormal, uSunDir);
+ })();
+
+ mat.outputNode = Fn(() => {
+ return vec4(output.rgb, output.a);
+ })();
+
+ return mat;
+ }
+
+ // -- Helpers --------------------------------------------------------------
+
+ private chunkKey(node: TerrainQuadNode): string {
+ return `gq_${node.id}_d${node.depth}_${node.centerX}_${node.centerZ}`;
+ }
+}
diff --git a/packages/shared/src/systems/shared/world/LODConfig.ts b/packages/shared/src/systems/shared/world/LODConfig.ts
index f1f17c342..67ca1fa8c 100644
--- a/packages/shared/src/systems/shared/world/LODConfig.ts
+++ b/packages/shared/src/systems/shared/world/LODConfig.ts
@@ -63,78 +63,78 @@ export interface LODDistancesWithSq extends LODDistances {
* Categories can be customized based on object size and visual importance.
*/
export const LOD_DISTANCES: Record = {
- // Large vegetation - aggressive LOD for performance
+ // Large vegetation
tree: {
- lod1Distance: 30,
- lod2Distance: 60,
- imposterDistance: 100,
- fadeDistance: 180,
+ lod1Distance: 800,
+ lod2Distance: 1000,
+ imposterDistance: 1200,
+ fadeDistance: 1800,
},
// Medium vegetation
bush: {
- lod1Distance: 40,
- lod2Distance: 80,
- imposterDistance: 120,
- fadeDistance: 200,
+ lod1Distance: 350,
+ lod2Distance: 500,
+ imposterDistance: 650,
+ fadeDistance: 1000,
},
fern: {
- lod1Distance: 30,
- lod2Distance: 55,
- imposterDistance: 80,
- fadeDistance: 120,
+ lod1Distance: 250,
+ lod2Distance: 400,
+ imposterDistance: 500,
+ fadeDistance: 800,
},
rock: {
- lod1Distance: 50,
- lod2Distance: 100,
- imposterDistance: 150,
- fadeDistance: 250,
+ lod1Distance: 400,
+ lod2Distance: 600,
+ imposterDistance: 800,
+ fadeDistance: 1200,
},
fallen_tree: {
- lod1Distance: 45,
- lod2Distance: 90,
- imposterDistance: 130,
- fadeDistance: 200,
+ lod1Distance: 350,
+ lod2Distance: 500,
+ imposterDistance: 650,
+ fadeDistance: 1000,
},
- // Small vegetation (aggressive culling - skip LOD2, go straight to impostor)
+ // Small vegetation
flower: {
- lod1Distance: 25,
- lod2Distance: 45, // Same as imposter - skip LOD2
- imposterDistance: 60,
- fadeDistance: 100,
+ lod1Distance: 200,
+ lod2Distance: 300,
+ imposterDistance: 400,
+ fadeDistance: 650,
},
mushroom: {
- lod1Distance: 15,
- lod2Distance: 30, // Same as imposter - skip LOD2
- imposterDistance: 40,
- fadeDistance: 80,
+ lod1Distance: 180,
+ lod2Distance: 280,
+ imposterDistance: 350,
+ fadeDistance: 550,
},
grass: {
- lod1Distance: 10,
- lod2Distance: 20, // Same as imposter - skip LOD2
- imposterDistance: 30,
- fadeDistance: 60,
+ lod1Distance: 150,
+ lod2Distance: 220,
+ imposterDistance: 280,
+ fadeDistance: 450,
},
// Resources (harvestable objects)
resource: {
- lod1Distance: 45,
- lod2Distance: 85,
- imposterDistance: 120,
- fadeDistance: 200,
+ lod1Distance: 380,
+ lod2Distance: 550,
+ imposterDistance: 700,
+ fadeDistance: 1100,
},
tree_resource: {
- lod1Distance: 25, // Switch to LOD1 very early
- lod2Distance: 50,
- imposterDistance: 80, // Switch to impostor ASAP
- fadeDistance: 150, // Fade out earlier
+ lod1Distance: 400,
+ lod2Distance: 550,
+ imposterDistance: 700,
+ fadeDistance: 1000,
},
rock_resource: {
- lod1Distance: 50,
- lod2Distance: 100,
- imposterDistance: 150,
- fadeDistance: 250,
+ lod1Distance: 400,
+ lod2Distance: 600,
+ imposterDistance: 800,
+ fadeDistance: 1200,
},
// Buildings - simple geometry, skip intermediate LODs and go directly to impostor
diff --git a/packages/shared/src/systems/shared/world/LightingConfig.ts b/packages/shared/src/systems/shared/world/LightingConfig.ts
new file mode 100644
index 000000000..9ab6cb008
--- /dev/null
+++ b/packages/shared/src/systems/shared/world/LightingConfig.ts
@@ -0,0 +1,219 @@
+/**
+ * LightingConfig - Central lighting & sky settings for the entire app
+ *
+ * Single source of truth for all lighting-related constants:
+ * sun shade, hemisphere/ambient lights, exposure, fog colors, day-night cycle.
+ *
+ * Imported by Environment, SkySystem, TerrainShader, GPUMaterials, and any
+ * other system that needs lighting parameters.
+ *
+ * @module LightingConfig
+ */
+
+// ============================================================================
+// DAY / NIGHT CYCLE
+// ============================================================================
+
+export const DAY_CYCLE = {
+ /** Full day cycle duration in seconds */
+ DURATION_SEC: 240,
+
+ /** dayPhase thresholds — 0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset */
+ DAWN_START: 0.22,
+ DAWN_MID: 0.25,
+ DAWN_END: 0.28,
+ DUSK_START: 0.72,
+ DUSK_MID: 0.75,
+ DUSK_END: 0.78,
+
+ /** During full day the intensity oscillates between this floor and 1.0 */
+ NOON_MIN_INTENSITY: 0.85,
+} as const;
+
+// ============================================================================
+// DIRECTIONAL LIGHT (SUN / MOON)
+// ============================================================================
+
+export const SUN_LIGHT = {
+ /** Fallback sun direction before SkySystem takes over */
+ DEFAULT_DIRECTION: [0.5, 0.8, 0.3] as readonly [number, number, number],
+
+ /** Sun intensity = dayIntensity × this × transitionFade */
+ DAY_INTENSITY_MULTIPLIER: 1.8,
+ DAY_COLOR: [1.0, 0.98, 0.92] as readonly [number, number, number],
+
+ /** Golden-hour phase ranges [start, end] (pairs) */
+ GOLDEN_HOUR_RANGES: [
+ [0.22, 0.32],
+ [0.68, 0.78],
+ ] as readonly (readonly [number, number])[],
+ GOLDEN_HOUR_COLOR: [1.0, 0.85, 0.6] as readonly [number, number, number],
+
+ /** Moon intensity = nightIntensity × this × transitionFade */
+ MOON_INTENSITY_MULTIPLIER: 0.35,
+ MOON_COLOR: [0.05, 0.5, 0.7] as readonly [number, number, number],
+
+ /** Z-axis tilt of the sun arc (0 = flat E-W, 1 = full N-S) */
+ TILT: 0.3,
+
+ /** Lerp speed for smooth light-direction interpolation (per frame) */
+ DIRECTION_LERP: 0.02,
+} as const;
+
+// ============================================================================
+// SUN SHADE (shadow-side sky tint applied in shaders)
+// ============================================================================
+
+export const SUN_SHADE = {
+ /**
+ * Mix strength: 0 = no shade, 1 = full shade.
+ * The shade factor is `0.5 − dot(N,L) × 0.5` (0 on lit side, 1 on shadow side).
+ * Applied on the ALBEDO (before lighting) to avoid double-darkening.
+ */
+ STRENGTH: 1.0,
+
+ /**
+ * Fixed shade tint color: vec3(0.0, 0.5, 0.7).
+ * Applied as: tinted = color × TINT_COLOR, then mixed by shade factor.
+ * Dynamic behavior comes from the shade factor (sun position), not this color.
+ */
+ TINT_COLOR: [0.0, 0.5, 0.7] as readonly [number, number, number],
+} as const;
+
+// ============================================================================
+// NIGHT BRIGHTNESS (master knob for tree + terrain)
+// ============================================================================
+
+export const NIGHT = {
+ /** Master night brightness (0 = pitch black, 1 = full day brightness).
+ * Controls both the tree shader nightDim floor and scene light intensity bases. */
+ BRIGHTNESS: 0.8,
+} as const;
+
+// ============================================================================
+// HEMISPHERE LIGHT (sky / ground ambient)
+// ============================================================================
+
+const HEMISPHERE_DAY_TOTAL = 0.9;
+
+export const HEMISPHERE_LIGHT = {
+ INITIAL_SKY_COLOR: 0x87ceeb,
+ INITIAL_GROUND_COLOR: 0x5d4837,
+ INITIAL_INTENSITY: 0.5,
+
+ /** Runtime intensity = NIGHT.BRIGHTNESS + dayIntensity × DAY_ADD */
+ INTENSITY_BASE: NIGHT.BRIGHTNESS,
+ INTENSITY_DAY_ADD: HEMISPHERE_DAY_TOTAL - NIGHT.BRIGHTNESS,
+
+ /** Sky color lerped between NIGHT → DAY based on dayIntensity */
+ DAY_SKY_COLOR: [0.53, 0.81, 0.92] as readonly [number, number, number],
+ NIGHT_SKY_COLOR: [0.0, 0.15, 0.3] as readonly [number, number, number],
+
+ /** Ground color lerped between NIGHT → DAY */
+ DAY_GROUND_COLOR: [0.36, 0.27, 0.18] as readonly [number, number, number],
+ NIGHT_GROUND_COLOR: [0.02, 0.05, 0.1] as readonly [number, number, number],
+} as const;
+
+// ============================================================================
+// AMBIENT LIGHT (flat fill)
+// ============================================================================
+
+const AMBIENT_DAY_TOTAL = 0.5;
+
+export const AMBIENT_LIGHT = {
+ INITIAL_COLOR: 0x606070,
+ INITIAL_INTENSITY: 0.5,
+
+ /** Runtime intensity = NIGHT.BRIGHTNESS + dayIntensity × DAY_ADD */
+ INTENSITY_BASE: NIGHT.BRIGHTNESS,
+ INTENSITY_DAY_ADD: AMBIENT_DAY_TOTAL - NIGHT.BRIGHTNESS,
+
+ /** Color lerped between NIGHT → DAY */
+ DAY_COLOR: [1.0, 0.95, 0.95] as readonly [number, number, number],
+ NIGHT_COLOR: [0.05, 0.35, 0.5] as readonly [number, number, number],
+} as const;
+
+// ============================================================================
+// AUTO EXPOSURE (eye adaptation)
+// ============================================================================
+
+export const EXPOSURE = {
+ DAY: 0.85,
+ /** Slight boost at night */
+ NIGHT: 1.1,
+ /** Per-frame lerp speed toward target exposure */
+ LERP_SPEED: 0.03,
+} as const;
+
+// ============================================================================
+// SHARED SUN-SHADE SHADER FUNCTION (TSL)
+// ============================================================================
+
+import {
+ sub,
+ mul,
+ float,
+ mix,
+ vec3,
+ add,
+ dot,
+ normalize,
+} from "../../../extras/three/three";
+
+/**
+ * Apply a day/night sky-tint to a colour in TSL.
+ *
+ * Driven by `dayIntensity` (0 = full night, 1 = full day) so the tint
+ * transitions at the exact same rate as the scene lights. Reusable for
+ * any custom shader that bypasses standard PBR lighting.
+ *
+ * Should be called on the raw **albedo** (before lighting) so the hue
+ * shift survives through subsequent brightness reductions.
+ *
+ * @param color - Albedo / base color node
+ * @param dayIntensity - Uniform or node with range [0, 1]
+ * @param shadeColor - Tint color node (e.g. `vec3(...SUN_SHADE.TINT_COLOR)`)
+ */
+export function applySunShade(color: any, dayIntensity: any, shadeColor: any) {
+ const shadeFactor = sub(float(1.0), dayIntensity);
+ const tinted = mul(color, shadeColor);
+ return mix(color, tinted, shadeFactor);
+}
+
+/**
+ * Shared custom lighting for terrain and grass — bypasses PBR entirely.
+ * Applies day/night tint + half-lambert directional shading + ambient floor.
+ * Both terrain and grass call this with the same parameters to guarantee
+ * identical pixel output at the same world position.
+ *
+ * @param albedo - Pre-lit base color (after anime shade + vertex lights)
+ * @param normal - Surface normal (terrain slope)
+ * @param sunDirNode - Sun direction uniform
+ * @param dayIntensity - Day intensity uniform [0,1]
+ * @param shadeColor - Night tint color node
+ */
+export function applyCustomLighting(
+ albedo: any,
+ normal: any,
+ sunDirNode: any,
+ dayIntensity: any,
+ shadeColor: any,
+): any {
+ const tinted = applySunShade(albedo, dayIntensity, shadeColor);
+ const sunDir = normalize(vec3(sunDirNode));
+ const NdotL = dot(normal, sunDir);
+ const halfLambert = add(mul(NdotL, float(0.5)), float(0.5));
+ const ambient = float(0.35);
+ const lighting = add(ambient, mul(float(0.65), halfLambert));
+ const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayIntensity);
+ return mul(tinted, mul(lighting, nightDim));
+}
+
+// ============================================================================
+// FOG COLORS (day / night scene fog — separate from sky-fog render target)
+// ============================================================================
+
+export const FOG_COLORS = {
+ DAY: 0xd4c8b8,
+ NIGHT: 0x2b3445,
+} as const;
diff --git a/packages/shared/src/systems/shared/world/ProceduralDocks.ts b/packages/shared/src/systems/shared/world/ProceduralDocks.ts
index 7ea8e5ecf..086a5ece6 100644
--- a/packages/shared/src/systems/shared/world/ProceduralDocks.ts
+++ b/packages/shared/src/systems/shared/world/ProceduralDocks.ts
@@ -1,7 +1,7 @@
/**
* ProceduralDocks.ts - Dock Generation & Collision System
*
- * Generates procedural docks on water bodies (ponds, lakes, river banks).
+ * Generates procedural docks on water bodies.
* Follows the BridgeSystem pattern for collision registration:
* - Walkable tiles: remove WATER flag, add DOCK flag
* - Edge blocking: OSRS dual-tile wall flags on dock perimeter
@@ -27,7 +27,7 @@ import {
type GeneratedDock,
type DockRecipe,
} from "@hyperscape/procgen/items/dock";
-import type { WaterBodyRegistry } from "./WaterBodyRegistry";
+import { ISLAND_DOCKS, type DockDefinition } from "./DockDefinition";
import { CollisionFlag, getOppositeWallFlag } from "../movement/CollisionFlags";
// TSL imports for dock material (client-only, same pattern as BridgeSystem)
@@ -50,9 +50,9 @@ import {
min as tslMin,
} from "three/tsl";
-// Constants
+// Constants — single source of truth from GameConstants
const WATER_THRESHOLD = TERRAIN_CONSTANTS.WATER_THRESHOLD;
-const WATER_LEVEL = 5.0;
+const WATER_LEVEL = TERRAIN_CONSTANTS.WATER_THRESHOLD;
// ── Dock geometry constants (matching BridgeSystem visual quality) ──
const DOCK_POST_CAP_OVERHANG = 0.05;
@@ -127,196 +127,17 @@ const dockWoodUV = Fn(() => {
return mix(vertUV, deckUV, horiz);
});
-const SHORELINE_SAMPLES = 64;
-const MAX_SLOPE_FOR_DOCK = 0.4;
-const MIN_WATER_DEPTH = 1.5;
-
-/** Pre-allocated test points for isTerrainReady() — avoids per-tick allocation. */
+/** Pre-allocated test points for isTerrainReady(). */
const TERRAIN_READY_TEST_POINTS: ReadonlyArray<{ x: number; z: number }> = [
{ x: 0, z: 0 },
{ x: 50, z: 50 },
{ x: -50, z: -50 },
];
-/** Pack tile coordinates into a single 32-bit key (matches bridge pattern). */
function dockTileKey(tx: number, tz: number): number {
return ((tx + 32768) << 16) | (tz + 32768);
}
-/** Snap a direction vector to the nearest cardinal direction (+X, -X, +Z, -Z). */
-function snapToCardinal(dx: number, dz: number): { x: number; z: number } {
- if (Math.abs(dx) >= Math.abs(dz)) {
- return { x: dx >= 0 ? 1 : -1, z: 0 };
- }
- return { x: 0, z: dz >= 0 ? 1 : -1 };
-}
-
-/** Find shoreline points around a water body using binary search */
-function findShorelinePoints(
- waterBody: WaterBody,
- getTerrainHeight: (x: number, z: number) => number,
- samples: number = SHORELINE_SAMPLES,
- waterLevel: number = WATER_THRESHOLD,
-): ShorelinePoint[] {
- const shorelinePoints: ShorelinePoint[] = [];
- const { center, radius } = waterBody;
-
- for (let i = 0; i < samples; i++) {
- const angle = (i / samples) * Math.PI * 2;
- const dirX = Math.cos(angle);
- const dirZ = Math.sin(angle);
-
- let minDist = radius * 0.3;
- let maxDist = radius * 1.5;
- let foundShoreline = false;
-
- for (let step = 0; step < 20; step++) {
- const midDist = (minDist + maxDist) / 2;
- const x = center.x + dirX * midDist;
- const z = center.z + dirZ * midDist;
- const height = getTerrainHeight(x, z);
-
- if (height < waterLevel) {
- minDist = midDist;
- } else {
- maxDist = midDist;
- foundShoreline = true;
- }
-
- if (maxDist - minDist < 0.5) break;
- }
-
- if (!foundShoreline) continue;
-
- const shorelineDist = (minDist + maxDist) / 2;
- const shoreX = center.x + dirX * shorelineDist;
- const shoreZ = center.z + dirZ * shorelineDist;
- const height = getTerrainHeight(shoreX, shoreZ);
-
- const sampleDist = 1.0;
- const heightInward = getTerrainHeight(
- shoreX - dirX * sampleDist,
- shoreZ - dirZ * sampleDist,
- );
- const heightOutward = getTerrainHeight(
- shoreX + dirX * sampleDist,
- shoreZ + dirZ * sampleDist,
- );
- const slope = Math.abs(heightOutward - heightInward) / (sampleDist * 2);
-
- shorelinePoints.push({
- position: { x: shoreX, y: height, z: shoreZ },
- landwardNormal: { x: dirX, z: dirZ },
- waterwardNormal: { x: -dirX, z: -dirZ },
- height,
- slope,
- distanceFromCenter: shorelineDist,
- });
- }
-
- return shorelinePoints;
-}
-
-interface PlacementCandidate {
- point: ShorelinePoint;
- score: number;
- components: {
- flatness: number;
- waterDepth: number;
- clearance: number;
- orientation: number;
- };
-}
-
-/** Score a shoreline point for dock placement */
-function scorePlacementPoint(
- point: ShorelinePoint,
- getTerrainHeight: (x: number, z: number) => number,
- checkObstacle: (x: number, z: number) => boolean,
- dockLength: number,
- waterLevel: number = WATER_THRESHOLD,
-): PlacementCandidate {
- const components = {
- flatness: 0,
- waterDepth: 0,
- clearance: 0,
- orientation: 0,
- };
-
- components.flatness = Math.max(0, 1 - point.slope / MAX_SLOPE_FOR_DOCK);
-
- const endX = point.position.x + point.waterwardNormal.x * dockLength;
- const endZ = point.position.z + point.waterwardNormal.z * dockLength;
- const endHeight = getTerrainHeight(endX, endZ);
- const waterDepth = waterLevel - endHeight;
-
- if (waterDepth < MIN_WATER_DEPTH) {
- components.waterDepth = 0;
- } else {
- components.waterDepth = Math.min(1, waterDepth / (MIN_WATER_DEPTH * 2));
- }
-
- let obstacleCount = 0;
- for (let d = 1; d <= 5; d++) {
- const checkX = point.position.x + point.landwardNormal.x * d;
- const checkZ = point.position.z + point.landwardNormal.z * d;
- if (checkObstacle(checkX, checkZ)) {
- obstacleCount++;
- }
- }
- components.clearance = Math.max(0, 1 - obstacleCount / 3);
-
- const facingSouth = point.waterwardNormal.z;
- components.orientation = 0.5 + facingSouth * 0.5;
-
- const weights = {
- flatness: 0.35,
- waterDepth: 0.3,
- clearance: 0.2,
- orientation: 0.15,
- };
-
- const score =
- components.flatness * weights.flatness +
- components.waterDepth * weights.waterDepth +
- components.clearance * weights.clearance +
- components.orientation * weights.orientation;
-
- return { point, score, components };
-}
-
-/** Select the best dock placement from candidates using weighted random */
-function selectBestPlacement(
- candidates: PlacementCandidate[],
- seed: number,
-): PlacementCandidate | null {
- if (candidates.length === 0) return null;
-
- const viableCandidates = candidates.filter((c) => c.score > 0.4);
- if (viableCandidates.length === 0) {
- candidates.sort((a, b) => b.score - a.score);
- return candidates[0];
- }
-
- viableCandidates.sort((a, b) => b.score - a.score);
-
- const topCandidates = viableCandidates.slice(0, 3);
- const totalScore = topCandidates.reduce((sum, c) => sum + c.score, 0);
-
- const seededRandom = (seed * 9301 + 49297) % 233280;
- const threshold = (seededRandom / 233280) * totalScore;
-
- let accumulated = 0;
- for (const candidate of topCandidates) {
- accumulated += candidate.score;
- if (accumulated >= threshold) {
- return candidate;
- }
- }
-
- return topCandidates[0];
-}
-
interface DockInstance {
id: string;
waterBodyId: string;
@@ -326,7 +147,6 @@ interface DockInstance {
interface TerrainSystemInterface {
getHeightAt(x: number, z: number): number;
- getWaterBodyRegistry(): WaterBodyRegistry | null;
}
interface StageSystemInterface {
@@ -351,11 +171,7 @@ export class ProceduralDocks extends System {
private dockMeshes: THREE.Mesh[] = [];
/** Pending dock generation queue — processed one per tick to avoid spikes. */
- private pendingDockQueue: Array<{
- waterBody: WaterBody;
- seed: string;
- waterSurfaceY: number;
- }> = [];
+ private pendingDockQueue: DockDefinition[] = [];
constructor(world: World) {
super(world);
@@ -410,144 +226,50 @@ export class ProceduralDocks extends System {
}
/**
- * Enqueue docks for all water bodies (ponds + river banks).
+ * Enqueue docks from ISLAND_DOCKS definitions.
* Builds a queue that update() drains one dock per tick to avoid spikes.
*/
- private enqueueDocks(seed: string = "island-docks"): void {
- if (!this.terrainSystem) return;
-
- const registry = this.terrainSystem.getWaterBodyRegistry();
- if (!registry) return;
-
- // Elevated ponds/lakes from WaterBodyRegistry
- for (const body of registry.getAllBodies()) {
- if (body.sourceType === "river_segment") continue;
-
- this.pendingDockQueue.push({
- waterBody: {
- id: body.id,
- type: "pond",
- center: { x: body.centerX, z: body.centerZ },
- radius: body.radius,
- },
- seed: `${seed}-${body.id}`,
- waterSurfaceY: body.surfaceY,
- });
+ private enqueueDocks(): void {
+ for (const def of ISLAND_DOCKS) {
+ this.pendingDockQueue.push(def);
}
-
- // Docks are only placed at elevated ponds/lakes — no rivers or ocean.
}
/**
- * Generate a dock for a specific water body.
+ * Generate a dock from a developer-assigned DockDefinition.
* Works on both client (mesh + collision) and server (collision only).
*/
- generateDockForWaterBody(
- waterBody: WaterBody,
- seed: string,
- waterSurfaceY?: number,
- ): GeneratedDock | null {
+ generateDockFromDefinition(def: DockDefinition): GeneratedDock | null {
if (!this.terrainSystem) return null;
- const waterLevel = waterSurfaceY ?? WATER_LEVEL;
+ const waterLevel = WATER_LEVEL;
- const getTerrainHeight = (x: number, z: number): number => {
- return this.terrainSystem!.getHeightAt(x, z);
- };
+ // Convert compass bearing (degrees) to direction vector
+ // 0° = north (−Z), 90° = east (+X), 180° = south (+Z), 270° = west (−X)
+ const rad = (def.rotation * Math.PI) / 180;
+ const waterwardDir = { x: Math.sin(rad), z: Math.cos(rad) };
+ const landwardDir = { x: -waterwardDir.x, z: -waterwardDir.z };
- const checkObstacle = (x: number, z: number): boolean => {
- const height = getTerrainHeight(x, z);
- if (height < waterLevel) return true;
+ const snappedX = def.x;
+ const snappedZ = def.z;
- const sampleDist = 0.5;
- const heightN = getTerrainHeight(x, z + sampleDist);
- const heightS = getTerrainHeight(x, z - sampleDist);
- const heightE = getTerrainHeight(x + sampleDist, z);
- const heightW = getTerrainHeight(x - sampleDist, z);
-
- const slopeNS = Math.abs(heightN - heightS) / (sampleDist * 2);
- const slopeEW = Math.abs(heightE - heightW) / (sampleDist * 2);
- const maxSlope = Math.max(slopeNS, slopeEW);
- return maxSlope > 0.5;
- };
-
- // Use per-body water level for shoreline detection
- const shorelinePoints = findShorelinePoints(
- waterBody,
- getTerrainHeight,
- SHORELINE_SAMPLES,
- waterLevel,
- );
-
- if (shorelinePoints.length === 0) return null;
-
- const estimatedLength =
- (DEFAULT_DOCK_PARAMS.lengthRange[0] +
- DEFAULT_DOCK_PARAMS.lengthRange[1]) /
- 2;
-
- const candidates = shorelinePoints.map((point) =>
- scorePlacementPoint(
- point,
- getTerrainHeight,
- checkObstacle,
- estimatedLength,
- waterLevel,
- ),
- );
-
- const seedNum = this.hashString(seed);
- const selected = selectBestPlacement(candidates, seedNum);
- if (!selected) return null;
-
- // ── Tile-alignment: snap direction to nearest cardinal ──
- // This ensures the dock is axis-aligned with the 1m tile grid.
- const rawDir = selected.point.waterwardNormal;
- const snappedDir = snapToCardinal(rawDir.x, rawDir.z);
- const snappedLand = { x: -snappedDir.x, z: -snappedDir.z };
-
- // ── Tile-alignment: snap position to tile grid ──
- // Extend 3m landward from shoreline, then snap:
- // - Along dock direction: start at tile boundary (integer)
- // - Perpendicular: centerline at tile center (integer + 0.5)
- const landwardOffset = 3;
- const rawX = selected.point.position.x + snappedLand.x * landwardOffset;
- const rawZ = selected.point.position.z + snappedLand.z * landwardOffset;
-
- // Dock runs along direction axis. Snap start to tile edge (integer)
- // along that axis, and center to tile center (N+0.5) across it.
- let snappedX: number;
- let snappedZ: number;
- if (Math.abs(snappedDir.x) > Math.abs(snappedDir.z)) {
- // Dock runs along X axis → snap X to integer, Z to N+0.5
- snappedX = Math.round(rawX);
- snappedZ = Math.floor(rawZ) + 0.5;
- } else {
- // Dock runs along Z axis → snap Z to integer, X to N+0.5
- snappedX = Math.floor(rawX) + 0.5;
- snappedZ = Math.round(rawZ);
- }
+ const anchorY = this.terrainSystem.getHeightAt(def.x, def.z);
const adjustedPoint: ShorelinePoint = {
- ...selected.point,
- position: {
- x: snappedX,
- y: selected.point.position.y,
- z: snappedZ,
- },
- waterwardNormal: snappedDir,
- landwardNormal: snappedLand,
+ position: { x: snappedX, y: anchorY, z: snappedZ },
+ waterwardNormal: waterwardDir,
+ landwardNormal: landwardDir,
+ height: anchorY,
+ slope: 0,
+ distanceFromCenter: 0,
};
- // ── Tile-alignment: force width = 3 tiles, integer length ──
- const dockWidth = 3.0;
- const dockLength = Math.round(
- DEFAULT_DOCK_PARAMS.lengthRange[0] + landwardOffset + 4 + (seedNum % 5), // deterministic variation 0-4 extra tiles
- );
+ const dockWidth = def.width ?? 3.0;
+ const dockLength = def.length ?? 12;
const recipe: DockRecipe = {
...DEFAULT_DOCK_PARAMS,
- label: "Pond Dock",
+ label: def.label ?? "Dock",
widthRange: [dockWidth, dockWidth],
lengthRange: [dockLength, dockLength],
};
@@ -555,18 +277,14 @@ export class ProceduralDocks extends System {
const waterFloorDepth = 3.0;
const waterFloorY = waterLevel - waterFloorDepth;
- // Skip geometry+material creation when there's no scene (server).
- // On server we only need layout + collision data from DockGenerator.
const isServer = !this.scene;
const dock = this.generator.generate(recipe, adjustedPoint, {
- seed,
+ seed: def.id,
waterLevel,
waterFloorDepth,
skipMesh: isServer,
});
- // Dispose the DockGenerator's local-space mesh (we build world-space mesh).
- // On server with skipMesh, the mesh is an empty Group — dispose is a no-op.
dock.mesh.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
@@ -576,7 +294,6 @@ export class ProceduralDocks extends System {
}
});
- // Build world-space mesh (client only — server has no scene)
let worldMesh: THREE.Mesh | null = null;
if (this.scene) {
worldMesh = this.buildDockMeshWorldSpace(
@@ -591,13 +308,11 @@ export class ProceduralDocks extends System {
}
}
- // Register collision flags and deck heights
this.registerDockCollision(dock);
- // Store instance
const instance: DockInstance = {
- id: `dock-${waterBody.id}`,
- waterBodyId: waterBody.id,
+ id: `dock-${def.id}`,
+ waterBodyId: def.id,
dock,
mesh: worldMesh,
};
@@ -1483,16 +1198,6 @@ export class ProceduralDocks extends System {
this.dockDeckHeights.clear();
}
- private hashString(str: string): number {
- let hash = 0;
- for (let i = 0; i < str.length; i++) {
- const char = str.charCodeAt(i);
- hash = (hash << 5) - hash + char;
- hash = hash & hash;
- }
- return Math.abs(hash);
- }
-
update(_deltaTime: number): void {
if (this.docksGenerated) return;
@@ -1507,13 +1212,9 @@ export class ProceduralDocks extends System {
// Process one dock per tick to avoid synchronous spikes
if (this.pendingDockQueue.length > 0) {
- const item = this.pendingDockQueue.shift()!;
+ const def = this.pendingDockQueue.shift()!;
try {
- this.generateDockForWaterBody(
- item.waterBody,
- item.seed,
- item.waterSurfaceY,
- );
+ this.generateDockFromDefinition(def);
} catch (err) {
console.error("[ProceduralDocks] Error generating dock:", err);
}
diff --git a/packages/shared/src/systems/shared/world/ProceduralGrass.ts b/packages/shared/src/systems/shared/world/ProceduralGrass.ts
index ba013a1b3..ae07bb6b6 100644
--- a/packages/shared/src/systems/shared/world/ProceduralGrass.ts
+++ b/packages/shared/src/systems/shared/world/ProceduralGrass.ts
@@ -26,21 +26,15 @@
/**
* ProceduralGrass.ts - GPU Grass System
*
- * Key architecture:
- * - SpriteNodeMaterial for billboard grass
- * - Two-buffer SSBO (vec4 + float) with bit-packed data
- * - Visibility NOT used for opacity/scale (only for offscreen culling)
- * - Wind stored as vec2 displacement
- * - Heightmap Y offset integration with terrain system
+ * LOD0 architecture (near-player blades):
+ * - Single THREE.Mesh with ~78K triangles (one per blade)
+ * - MeshBasicNodeMaterial with TSL positionNode/colorNode
+ * - Toroidal mod() wrapping centered on player position
+ * - Heightmap sampling, billboard rotation, wind, distance fade all in vertex shader
+ * - Zero compute shaders, zero SSBO — one draw call
*
- * **PERFORMANCE OPTIMIZATIONS:**
- * - Heightmap generation uses chunked async processing (no main thread blocking)
- * - Compute shaders pre-initialized during load (no first-frame spike)
- * - All heavy work done before gameplay starts
- *
- * **RELATED MODULES:**
- * - For standalone grass generation (Asset Forge, viewers), see @hyperscape/procgen GrassGen module
- * - GrassGen provides simpler grass geometry/materials without SSBO compute integration
+ * LOD1 architecture (far-field tiles):
+ * - InstancedMesh with GPU-driven tile positioning/culling in vertex shader
*
* @module ProceduralGrass
*/
@@ -55,6 +49,7 @@ import THREE, {
vertexColor,
sin,
cos,
+ atan,
mix,
uv,
floor,
@@ -84,8 +79,9 @@ import THREE, {
length,
positionLocal,
Loop,
+ attribute,
} from "../../../extras/three/three";
-import { MeshBasicNodeMaterial, SpriteNodeMaterial } from "three/webgpu";
+import { MeshBasicNodeMaterial } from "three/webgpu";
import { System } from "../infrastructure/System";
import type { World } from "../../../types";
import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
@@ -150,11 +146,13 @@ function shouldYield(startTime: number, budgetMs = 8): boolean {
// ============================================================================
// LOD0: Individual grass blades (near player)
+// Default uses MEDIUM preset (~78K blades) for reasonable performance.
+// HIGH (~166K) was causing GPU bottleneck with per-frame compute shaders.
const getConfig = () => {
- const BLADE_WIDTH = 0.12; // Thicker for stylized look (was 0.08)
- const BLADE_HEIGHT = 0.6; // Taller for Zelda feel (was 0.25)
- const TILE_SIZE = 16; // 8m radius
- const BLADES_PER_SIDE = 408; // ~166k blades (2x thicker near grass)
+ const BLADE_WIDTH = 0.12;
+ const BLADE_HEIGHT = 0.6;
+ const TILE_SIZE = 60; // 30m radius — large enough for 3rd-person camera offset
+ const BLADES_PER_SIDE = 350; // ~122K blades, spacing=0.17m (~34 blades/m²)
return {
BLADE_WIDTH,
@@ -166,7 +164,7 @@ const getConfig = () => {
COUNT: BLADES_PER_SIDE * BLADES_PER_SIDE,
SPACING: TILE_SIZE / BLADES_PER_SIDE,
WORKGROUP_SIZE: 256,
- SEGMENTS: 4,
+ SEGMENTS: 3, // MEDIUM: 3 segments (was 4 for HIGH)
};
};
@@ -174,31 +172,30 @@ const config = getConfig();
/** Quality presets for different devices */
export enum GrassQuality {
- LOW = "low", // Mobile: ~40K blades, 20m range
- MEDIUM = "medium", // Tablet: ~78K blades, 25m range
- HIGH = "high", // Desktop: ~166K blades, full range
+ LOW = "low", // Mobile: ~40K blades, 40m field
+ MEDIUM = "medium", // Default: ~122K blades, 60m field
+ HIGH = "high", // Desktop: ~202K blades, 80m field
}
const QUALITY_PRESETS: Record<
GrassQuality,
{ bladesPerSide: number; tileSize: number; segments: number }
> = {
- [GrassQuality.LOW]: { bladesPerSide: 200, tileSize: 10, segments: 2 },
- [GrassQuality.MEDIUM]: { bladesPerSide: 280, tileSize: 12, segments: 3 },
- [GrassQuality.HIGH]: { bladesPerSide: 408, tileSize: 16, segments: 4 },
+ [GrassQuality.LOW]: { bladesPerSide: 200, tileSize: 40, segments: 2 },
+ [GrassQuality.MEDIUM]: { bladesPerSide: 350, tileSize: 60, segments: 3 },
+ [GrassQuality.HIGH]: { bladesPerSide: 450, tileSize: 80, segments: 4 },
};
// Grass tile settings (matches procgen viewer defaults)
-// LOD TRANSITION DIAGRAM (complementary dither crossfade):
+// LOD TRANSITION DIAGRAM (LOD0 uses player distance, LOD1 uses camera distance):
// ┌─────────────────────────────────────────────────────────────────────────┐
-// │ LOD0 (blades) ████████████████████░░░░░░ │
-// │ 0m 10m 13m │
+// │ LOD0 (blades) ████████████████████████████░░░░░░ │
+// │ (from player) 0m 25m 28m │
// │ │
-// │ LOD1 (tiles) ░░░░░░░░████████████████████████████████░░░░░░ │
-// │ 8m 13m 40m 50m │
+// │ LOD1 (tiles) ░░░░░░░████████████████████████████░░░░░░ │
+// │ (from camera) 20m 27m 40m 50m │
// └─────────────────────────────────────────────────────────────────────────┘
-// ░ = heavy dither fade zone, █ = full visibility
-// LOD1 fade-in (8-13m) overlaps LOD0 fade-out (10-13m) for seamless crossfade
+// ░ = fade zone, █ = full visibility
const GRASS_TILE_SETTINGS = {
TILE_SIZE: 1.0,
DENSITY: 64, // Reduced from 128 for perf
@@ -212,13 +209,13 @@ const GRASS_TILE_SETTINGS = {
CLUMP_SPREAD: 0.04,
CLUMP_HEIGHT_VARIATION: 0.4,
CLUMP_WIDTH_VARIATION: 0.2,
- // LOD0 (blade grass): 0-13m, fade 10-13m
- LOD0_FADE_START: 10,
- LOD0_FADE_END: 13,
- // LOD1 (instanced tiles): 8-50m
- // Fade in 8-13m overlaps with LOD0 fade-out for seamless crossfade
- LOD1_FADE_IN_START: 8,
- LOD1_FADE_IN_END: 13,
+ // LOD0 (blade grass): 0-28m from player, fade 25-28m
+ LOD0_FADE_START: 25,
+ LOD0_FADE_END: 28,
+ // LOD1 (instanced tiles): 20-50m
+ // Fade in 20-27m overlaps with LOD0 fade-out for seamless crossfade
+ LOD1_FADE_IN_START: 20,
+ LOD1_FADE_IN_END: 27,
LOD1_FADE_OUT_START: 40,
LOD1_FADE_OUT_END: 50,
// Grid sizes
@@ -298,17 +295,15 @@ const uniforms = {
uWindColorStrength: uniform(0.7), // Strong lighter crests in wind
uBaseWindShade: uniform(0.5), // Wind darkening
uBaseShadeHeight: uniform(1.0),
- // Stochastic distance culling - extends grass rendering distance
- // Works WITH fade: grass gradually thins AND fades at the same time
- uR0: uniform(10), // Full density within 10m
- uR1: uniform(13), // Thin to minimum by 13m (3m transition zone)
- uPMin: uniform(0.2), // Heavy dither at edge for visible effect
- // Distance fade - overlaps with stochastic culling for natural falloff
- // Matches LOD1 fade-in (8-13m) for seamless crossfade
- uFadeStart: uniform(10), // Start fading at 10m
- uFadeEnd: uniform(13), // Fully faded by 13m
- // Camera forward bias - extends grass in view direction
- uForwardBias: uniform(15), // More grass in front of camera
+ // Stochastic distance culling - thins grass at tile edges
+ // Field is 60m (±30m). Fade uses PLAYER distance, not camera.
+ uR0: uniform(22), // Full density within 22m of player
+ uR1: uniform(27), // Thin to minimum by 27m from player
+ uPMin: uniform(0.2),
+ // Distance fade from player (not camera) — collapse at wrapping edge
+ uFadeStart: uniform(25), // Start fading at 25m from player
+ uFadeEnd: uniform(28), // Fully faded by 28m (before 30m wrap edge)
+ uForwardBias: uniform(10),
// Rotation
uBaseBending: uniform(3.0), // More bending (was 2.0)
// Bottom fade - dither grass base into ground (0.3 UV = 0.15m on 0.5m blade)
@@ -359,21 +354,8 @@ const HEIGHTMAP_CONFIG = {
} as const;
// ============================================================================
-// GRASS SSBO - Three-buffer structure
+// HEIGHTMAP TEXTURE NODE
// ============================================================================
-// Buffer1 (vec4):
-// x -> offsetX (local to player)
-// y -> offsetZ (local to player)
-// z -> 0/12 windX - 12/12 windZ
-// w -> 0/8 current scale - 8/8 original scale - 16/1 shadow - 17/1 visibility - 18/6 wind noise factor
-//
-// Buffer2 (float):
-// 0/4 position based noise
-//
-// Buffer3 (float): HYPERSCAPE ADDITION
-// heightmapY - terrain height at this blade's world position
-
-type InstancedArrayBuffer = ReturnType;
// Heightmap texture node for compute shader
let heightmapTextureNode: ReturnType | null = null;
@@ -664,970 +646,305 @@ bayerTexture.magFilter = THREE.NearestFilter;
bayerTexture.needsUpdate = true;
const bayerTextureNode = texture(bayerTexture);
-class GrassSsbo {
- private buffer1: InstancedArrayBuffer;
- private buffer2: InstancedArrayBuffer;
- private buffer3: InstancedArrayBuffer; // heightmapY
- private buffer4: InstancedArrayBuffer; // static visibility (0=culled, 1=visible)
-
- // Compute shaders - initialized in constructor after buffers
- computeInit: any;
- computeStatic: any;
- computeUpdate: any;
-
- // Track initialization state
- private _initialized = false;
-
- constructor() {
- // Create buffers FIRST
- this.buffer1 = instancedArray(config.COUNT, "vec4");
- this.buffer2 = instancedArray(config.COUNT, "float");
- this.buffer3 = instancedArray(config.COUNT, "float");
- this.buffer4 = instancedArray(config.COUNT, "float"); // Static visibility cache
-
- // Then create compute shaders that reference the buffers
- this.computeInit = this.createComputeInit();
- this.computeStatic = this.createComputeStatic();
- this.computeUpdate = this.createComputeUpdate();
-
- // NOTE: We DO NOT use onInit callback here anymore!
- // This caused first-frame spikes because computeInit ran during gameplay.
- // Instead, call runInitialization() explicitly during loading.
- }
-
- /**
- * Run compute initialization explicitly during loading phase.
- * This avoids first-frame spikes by pre-running heavy compute work.
- * @returns Promise that resolves when initialization is complete
- */
- async runInitialization(renderer: THREE.WebGPURenderer): Promise {
- if (this._initialized) return;
-
- console.log("[ProceduralGrass] Running SSBO compute initialization...");
- const startTime = performance.now();
-
- // Run compute init - this populates all grass blade positions
- await renderer.computeAsync(this.computeInit);
-
- this._initialized = true;
- console.log(
- `[ProceduralGrass] SSBO init complete: ${(performance.now() - startTime).toFixed(1)}ms`,
- );
- }
-
- get isInitialized(): boolean {
- return this._initialized;
- }
-
- get computeBuffer1(): InstancedArrayBuffer {
- return this.buffer1;
- }
-
- get computeBuffer2(): InstancedArrayBuffer {
- return this.buffer2;
- }
-
- get computeBuffer3(): InstancedArrayBuffer {
- return this.buffer3;
- }
-
- get computeBuffer4(): InstancedArrayBuffer {
- return this.buffer4;
- }
-
- // Unpacking functions
- getWind = Fn(([data = vec4(0)]) => {
- const x = tslUtils.unpackUnits(data.z, 0, 12, -2, 2);
- const z = tslUtils.unpackUnits(data.z, 12, 12, -2, 2);
- return vec2(x, z);
- });
-
- getScale = Fn(([data = vec4(0)]) => {
- return tslUtils.unpackUnits(
- data.w,
- 0,
- 8,
- uniforms.uTrailMinScale,
- uniforms.uBladeMaxScale,
- );
- });
-
- getOriginalScale = Fn(([data = vec4(0)]) => {
- return tslUtils.unpackUnits(
- data.w,
- 8,
- 8,
- uniforms.uBladeMinScale,
- uniforms.uBladeMaxScale,
- );
- });
-
- getVisibility = Fn(([data = vec4(0)]) => {
- return tslUtils.unpackFlag(data.w, 17);
- });
-
- getWindNoise = Fn(([data = vec4(0)]) => {
- return tslUtils.unpackUnit(data.w, 18, 6);
- });
-
- getPositionNoise = Fn(([data = float(0)]) => {
- return tslUtils.unpackUnit(data, 0, 4);
- });
-
- // Packing functions
- private setWind = Fn(([data = vec4(0), value = vec2(0)]) => {
- data.z = tslUtils.packUnits(data.z, 0, 12, value.x, -2, 2);
- data.z = tslUtils.packUnits(data.z, 12, 12, value.y, -2, 2);
- return data;
- });
-
- private setScale = Fn(([data = vec4(0), value = float(0)]) => {
- data.w = tslUtils.packUnits(
- data.w,
- 0,
- 8,
- value,
- uniforms.uTrailMinScale,
- uniforms.uBladeMaxScale,
- );
- return data;
- });
-
- private setOriginalScale = Fn(([data = vec4(0), value = float(0)]) => {
- data.w = tslUtils.packUnits(
- data.w,
- 8,
- 8,
- value,
- uniforms.uBladeMinScale,
- uniforms.uBladeMaxScale,
- );
- return data;
- });
-
- private setVisibility = Fn(([data = vec4(0), value = float(0)]) => {
- data.w = tslUtils.packFlag(data.w, 17, value);
- return data;
- });
-
- private setWindNoise = Fn(([data = vec4(0), value = float(0)]) => {
- data.w = tslUtils.packUnit(data.w, 18, 6, value);
- return data;
- });
-
- private setPositionNoise = Fn(([data = float(0), value = float(0)]) => {
- return tslUtils.packUnit(data, 0, 4, value);
- });
-
- // Compute Init
- private createComputeInit() {
- return Fn(() => {
- const data1 = this.buffer1.element(instanceIndex);
- const data2 = this.buffer2.element(instanceIndex);
- const data3 = this.buffer3.element(instanceIndex);
-
- // Position XZ in grid
- const row = floor(float(instanceIndex).div(config.BLADES_PER_SIDE));
- const col = float(instanceIndex).mod(config.BLADES_PER_SIDE);
- const randX = hash(instanceIndex.add(4321));
- const randZ = hash(instanceIndex.add(1234));
- const offsetX = col
- .mul(config.SPACING)
- .sub(config.TILE_HALF_SIZE)
- .add(randX.mul(config.SPACING * 0.5));
- const offsetZ = row
- .mul(config.SPACING)
- .sub(config.TILE_HALF_SIZE)
- .add(randZ.mul(config.SPACING * 0.5));
-
- // ========== MEGA SIMPLIFIED - use per-instance hash instead of tile-scale noise ==========
- // Use instanceIndex-based hash for jitter to avoid 80m tile patterns
- const noiseR = hash(instanceIndex.mul(0.73));
- const noiseB = hash(instanceIndex.mul(0.91));
-
- // Small per-instance jitter to break up grid pattern
- const jitterX = noiseR.sub(0.5).mul(config.SPACING * 0.8);
- const jitterZ = noiseB.sub(0.5).mul(config.SPACING * 0.8);
- data1.x = offsetX.add(jitterX);
- data1.y = offsetZ.add(jitterZ);
-
- data2.assign(this.setPositionNoise(data2, noiseR));
-
- // Scale - random within range (shaped distribution)
- const n = noiseB;
- const shaped = n.mul(n);
- const randomScale = remap(
- shaped,
- 0,
- 1,
- uniforms.uBladeMinScale,
- uniforms.uBladeMaxScale,
- );
- data1.assign(this.setScale(data1, randomScale));
- data1.assign(this.setOriginalScale(data1, randomScale));
+// ============================================================================
+// SIMPLE GRASS LOD0 - Single mesh with mod() wrapping in vertex shader
+// Replaces GrassSsbo (3 compute shaders) + GrassMaterial (SpriteNodeMaterial)
+// Inspired by infinite-world-master and folio-2025-main
+// ============================================================================
- // Set visibility to 1 initially (visible)
- data1.assign(this.setVisibility(data1, float(1)));
+/**
+ * Build a non-indexed triangle mesh: one triangle per blade, gridSize^2 blades.
+ * Attributes:
+ * position (vec3) - blade vertex offsets (-halfW,0,0), (0,1,0), (+halfW,0,0) normalised
+ * center (vec2) - grid center XZ (same for all 3 verts of a blade)
+ * aHeightRandom (float) - per-blade random 0.6-1.0 (same for all 3 verts)
+ */
+function createSimpleGrassGeometry(): THREE.BufferGeometry {
+ const gridSize = config.BLADES_PER_SIDE;
+ const fieldSize = config.TILE_SIZE;
+ const bladeCount = gridSize * gridSize;
+ const vertexCount = bladeCount * 3;
+ const cellSize = fieldSize / gridSize;
+
+ const positions = new Float32Array(vertexCount * 3);
+ const centers = new Float32Array(vertexCount * 2);
+ const heightRandoms = new Float32Array(vertexCount);
+
+ let seed = 12345;
+ const rng = () => {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ return seed / 0x7fffffff;
+ };
- // Initialize heightmapY to 0 (will be updated in computeUpdate)
- data3.assign(float(0));
- })().compute(config.COUNT, [config.WORKGROUP_SIZE]);
+ const halfField = fieldSize * 0.5;
+ let vi = 0;
+ for (let row = 0; row < gridSize; row++) {
+ for (let col = 0; col < gridSize; col++) {
+ const cx =
+ (col + 0.5) * cellSize - halfField + (rng() - 0.5) * cellSize * 0.8;
+ const cz =
+ (row + 0.5) * cellSize - halfField + (rng() - 0.5) * cellSize * 0.8;
+ const hRand = 0.6 + rng() * 0.4;
+
+ // Vertex 0 : base-left (-1, 0, 0)
+ positions[vi * 3] = -1;
+ positions[vi * 3 + 1] = 0;
+ positions[vi * 3 + 2] = 0;
+ centers[vi * 2] = cx;
+ centers[vi * 2 + 1] = cz;
+ heightRandoms[vi] = hRand;
+ vi++;
+
+ // Vertex 1 : tip ( 0, 1, 0)
+ positions[vi * 3] = 0;
+ positions[vi * 3 + 1] = 1;
+ positions[vi * 3 + 2] = 0;
+ centers[vi * 2] = cx;
+ centers[vi * 2 + 1] = cz;
+ heightRandoms[vi] = hRand;
+ vi++;
+
+ // Vertex 2 : base-right (+1, 0, 0)
+ positions[vi * 3] = 1;
+ positions[vi * 3 + 1] = 0;
+ positions[vi * 3 + 2] = 0;
+ centers[vi * 2] = cx;
+ centers[vi * 2 + 1] = cz;
+ heightRandoms[vi] = hRand;
+ vi++;
+ }
}
- // Compute Wind - Enhanced with turbulence and gust patches (inspired by Grass_Journey)
- private computeWind = Fn(
- ([prevWindXZ = vec2(0), worldPos = vec3(0), positionNoise = float(0)]) => {
- const intensity = smoothstep(0.2, 0.5, windManager.uIntensity);
- const dir = windManager.uDirection.negate();
- const strength = uniforms.uWindStrength.add(intensity);
-
- // Gentle per-instance speed jitter (±10%)
- const speed = uniforms.uWindSpeed.mul(
- positionNoise.remap(0, 1, 0.95, 2.05),
- );
-
- // Base UV + scroll
- const uvBase = worldPos.xz.mul(0.01).mul(uniforms.uvWindScale);
- const scroll = dir.mul(speed).mul(time);
-
- // Sample noise textures for wind
- const uvA = uvBase.add(scroll);
- const uvB = uvBase.mul(1.37).add(scroll.mul(1.11));
-
- // Use texture if available, otherwise hash fallback
- const sampleNoise = (uvCoord: ReturnType) => {
- if (noiseAtlasTexture) {
- return texture(noiseAtlasTexture, uvCoord).mul(2.0).sub(1.0);
- }
- return vec3(hash(uvCoord.x.add(uvCoord.y.mul(100))))
- .mul(2.0)
- .sub(1.0);
- };
-
- const nA = sampleNoise(uvA);
- const nB = sampleNoise(uvB);
-
- // Mix noises
- const mixRand = fract(sin(positionNoise.mul(12.9898)).mul(78.233));
- const mixTime = sin(time.mul(0.4).add(positionNoise.mul(0.1))).mul(0.25);
- const w = clamp(mixRand.add(mixTime), 0.2, 0.8);
- const n = mix(nA, nB, w);
-
- // ========== GUST PATCHES (like Grass_Journey) ==========
- // Noise threshold creates areas of wind vs calm - like wind "clouds" passing
- const gustNoiseThreshold = float(0.45); // Below this = calm, above = gusty
- const gustMask = smoothstep(
- gustNoiseThreshold,
- gustNoiseThreshold.add(0.2),
- n.x.add(0.5).mul(0.5), // Remap -1..1 to 0..1
- );
-
- // ========== TURBULENCE LAYER (erratic per-blade movement) ==========
- // Multiple fast sin waves at different frequencies for chaotic motion
- const phase = positionNoise.mul(6.28); // Per-blade phase offset
- const turbulenceTime = time.mul(20.0).add(phase.mul(100.0)); // Fast variation
-
- // 3 overlapping waves at different frequencies (Grass_Journey style)
- const turb1 = sin(turbulenceTime).mul(0.15);
- const turb2 = sin(turbulenceTime.mul(1.7).add(2.3)).mul(0.12);
- const turb3 = cos(turbulenceTime.mul(0.8).add(phase.mul(50.0))).mul(0.1);
- const turbulenceAmount = turb1.add(turb2).add(turb3);
-
- // Turbulence scales with global strength (visible everywhere, not just gusts)
- const turbulence = turbulenceAmount.mul(strength).mul(0.6);
-
- // ========== COMBINE WIND COMPONENTS ==========
- const baseMag = n.x.mul(strength);
- const gustMag = n.y.mul(strength).mul(0.35).mul(gustMask); // Gusts only in gust patches
-
- // Total wind factor = base + gusts + turbulence
- const windFactor = baseMag.add(gustMag).add(turbulence);
-
- const target = dir.mul(windFactor);
- const k = mix(0.08, 0.25, n.z.abs());
- const newWind = prevWindXZ.add(target.sub(prevWindXZ).mul(k));
-
- // Return wind XZ + gust mask (for potential cloud shadow effect)
- return vec3(newWind, gustMask.mul(windFactor.abs()));
- },
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geometry.setAttribute("center", new THREE.BufferAttribute(centers, 2));
+ geometry.setAttribute(
+ "aHeightRandom",
+ new THREE.BufferAttribute(heightRandoms, 1),
);
-
- // Compute Trail Scale - grass bends when player walks through
- private computeTrailScale = Fn(
- ([
- originalScale = float(0),
- currentScale = float(0),
- isStepped = float(0),
- ]) => {
- const up = currentScale.add(
- originalScale.sub(currentScale).mul(uniforms.uTrailGrowthRate),
- );
- const down = currentScale.add(
- uniforms.uTrailMinScale.sub(currentScale).mul(uniforms.uKDown),
- );
- const blended = mix(up, down, isStepped);
- return clamp(blended, uniforms.uTrailMinScale, originalScale);
- },
+ geometry.boundingSphere = new THREE.Sphere(
+ new THREE.Vector3(),
+ fieldSize * 2,
);
+ return geometry;
+}
- // Compute Stochastic Keep - Distance-based dithered thinning
- private computeStochasticKeep = Fn(([distSq = float(0)]) => {
- const R0 = uniforms.uR0;
- const R1 = uniforms.uR1;
- const pMin = uniforms.uPMin;
- const R0Sq = R0.mul(R0);
- const R1Sq = R1.mul(R1);
-
- // t: 0 inside R0, 1 at/after R1
- const t = clamp(
- distSq.sub(R0Sq).div(max(R1Sq.sub(R0Sq), float(0.00001))),
- 0.0,
- 1.0,
- );
+const lod0Uniforms = {
+ uPlayerCenter: uniform(new THREE.Vector2(0, 0)),
+ uFieldSize: uniform(config.TILE_SIZE),
+ uBladeWidth: uniform(config.BLADE_WIDTH),
+ uBladeHeight: uniform(config.BLADE_HEIGHT),
+};
- // Keep probability: 1 at center → pMin at edge
- const p = mix(float(1.0), pMin, t);
+/**
+ * MeshBasicNodeMaterial with TSL positionNode + colorNode.
+ * positionNode: mod-wrap around player, heightmap sample, billboard, wind, fade.
+ * colorNode: base/tip gradient, day/night tint.
+ */
+function createSimpleGrassMaterial(): MeshBasicNodeMaterial {
+ const material = new MeshBasicNodeMaterial();
+ material.side = THREE.DoubleSide;
+ material.transparent = false;
+ material.depthWrite = true;
+ material.fog = false;
- // Deterministic RNG per blade (stable across frames)
- const rnd = hash(float(instanceIndex).mul(0.73));
+ // --- positionNode ---
+ material.positionNode = Fn(() => {
+ const localVert = positionLocal.toVar("localVert"); // (-1|0|+1, 0|1, 0)
+ const centerAttr = attribute("center", "vec2");
+ const hRand = attribute("aHeightRandom", "float");
- // Keep if random < probability
- return step(rnd, p);
- });
+ const tipness = localVert.y; // 0 for base, 1 for tip
- // Compute Distance Fade - OLD SCHOOL BAYER DITHER
- // Creates retro ordered dithering pattern that fades grass into terrain
- // @ts-expect-error TSL array destructuring
- private computeDistanceFade: ReturnType = Fn(
- ([distSq = float(0)]) => {
- const fadeStartSq = uniforms.uFadeStart.mul(uniforms.uFadeStart);
- const fadeEndSq = uniforms.uFadeEnd.mul(uniforms.uFadeEnd);
-
- // Linear fade: 1.0 at fadeStart, 0.0 at fadeEnd
- const linearFade = float(1.0).sub(
- clamp(
- distSq
- .sub(fadeStartSq)
- .div(max(fadeEndSq.sub(fadeStartSq), float(0.001))),
- 0.0,
- 1.0,
- ),
- );
+ // 1) Toroidal wrapping around player
+ const half = lod0Uniforms.uFieldSize.mul(0.5);
+ const rel = centerAttr.sub(lod0Uniforms.uPlayerCenter).toVar("rel");
+ rel.x.assign(mod(rel.x.add(half), lod0Uniforms.uFieldSize).sub(half));
+ rel.y.assign(mod(rel.y.add(half), lod0Uniforms.uFieldSize).sub(half));
- // OLD SCHOOL BAYER 8x8 ORDERED DITHERING
- // Use instance index to create screen-like pattern (stable per-blade)
- // This creates the classic retro dither look
- const bayerX = float(instanceIndex).mod(8.0);
- const bayerY = floor(float(instanceIndex).div(8.0)).mod(8.0);
-
- // Bayer 8x8 matrix threshold calculation (normalized 0-1)
- // Uses bit interleaving pattern for proper Bayer sequence
- const _x = bayerX.toInt();
- const _y = bayerY.toInt();
-
- // Reconstruct Bayer pattern using bit operations
- // bayer(x,y) = (bit_reverse(x XOR y) / 64)
- // Simplified: use hash of position for deterministic per-pixel threshold
- const bayerPattern = hash(bayerX.add(bayerY.mul(8.0)).add(0.123));
-
- // Quantize to 8 levels for chunky retro look
- const quantizedThreshold = floor(bayerPattern.mul(8.0)).div(8.0);
-
- // Compare fade against Bayer threshold
- // This creates the ordered dither pattern - grass either fully visible or culled
- return step(quantizedThreshold, linearFade);
- },
- );
+ const worldX = rel.x.add(lod0Uniforms.uPlayerCenter.x).toVar("worldX");
+ const worldZ = rel.y.add(lod0Uniforms.uPlayerCenter.y).toVar("worldZ");
- // Compute Frustum Visibility for a single point
- // Uses generous padding to prevent popping at frustum edges
- private computeVisibility = Fn(([worldPos = vec3(0)]) => {
- const clipPos = uniforms.uCameraMatrix.mul(vec4(worldPos, 1.0));
- const ndc = clipPos.xyz.div(clipPos.w);
- // More generous padding - at least 0.15 NDC units + scaled radius
- const baseRadius = float(config.BLADE_BOUNDING_SPHERE_RADIUS).div(
- max(clipPos.w, float(0.1)),
+ // 2) Heightmap terrain Y
+ const hmWorldSize = max(uHeightmapWorldSize, float(0.001));
+ const hmHalf = hmWorldSize.mul(0.5);
+ const hmU = worldX
+ .sub(uHeightmapCenterX)
+ .add(hmHalf)
+ .div(hmWorldSize)
+ .clamp(0.001, 0.999);
+ const hmV = worldZ
+ .sub(uHeightmapCenterZ)
+ .add(hmHalf)
+ .div(hmWorldSize)
+ .clamp(0.001, 0.999);
+ const hmUV = vec2(hmU, hmV);
+
+ const hmSample = heightmapTextureNode
+ ? heightmapTextureNode.sample(hmUV)
+ : vec4(0);
+ const hmLoaded = step(float(0.001), hmSample.r);
+ const rawTerrainY = hmSample.r.mul(uHeightmapMax);
+ const terrainY = mix(
+ uniforms.uCameraPosition.y,
+ rawTerrainY,
+ hmLoaded,
+ ).toVar("terrainY");
+
+ // 3) Build blade vertex
+ const bladeH = lod0Uniforms.uBladeHeight.mul(hRand);
+ const bladeHW = lod0Uniforms.uBladeWidth.mul(0.5);
+ const vertexOffset = vec3(
+ localVert.x.mul(bladeHW),
+ localVert.y.mul(bladeH),
+ float(0),
+ ).toVar("vertexOffset");
+
+ // 4) Billboard: rotate blade to face camera
+ const dx = worldX.sub(uniforms.uCameraPosition.x);
+ const dz = worldZ.sub(uniforms.uCameraPosition.z);
+ const angleToCamera = atan(dx, dz);
+ const cosA = cos(angleToCamera);
+ const sinA = sin(angleToCamera);
+ const rotX = vertexOffset.x.mul(cosA).add(vertexOffset.z.mul(sinA));
+ const rotZ = vertexOffset.z.mul(cosA).sub(vertexOffset.x.mul(sinA));
+ vertexOffset.x.assign(rotX);
+ vertexOffset.z.assign(rotZ);
+
+ // 5) World position
+ const worldPos = vec3(worldX, terrainY, worldZ)
+ .add(vertexOffset)
+ .toVar("worldPos");
+
+ // 6) Wind (displace tips only)
+ const windTime = time.mul(uniforms.uWindSpeed);
+ const windUV = vec2(
+ worldX.mul(0.02).add(windTime.mul(0.05)),
+ worldZ.mul(0.02).add(windTime.mul(0.03)),
+ );
+ const windSample = noiseAtlasTexture
+ ? texture(noiseAtlasTexture).sample(windUV)
+ : vec4(0.5);
+ const windStrength = uniforms.uWindStrength;
+ worldPos.x.addAssign(windSample.x.sub(0.5).mul(tipness).mul(windStrength));
+ worldPos.z.addAssign(windSample.y.sub(0.5).mul(tipness).mul(windStrength));
+
+ // 7) Distance fade: collapse toward center point
+ // Use PLAYER distance (not camera) so grass is always visible around the character
+ const distToPlayer = length(
+ vec2(
+ worldX.sub(lod0Uniforms.uPlayerCenter.x),
+ worldZ.sub(lod0Uniforms.uPlayerCenter.y),
+ ),
);
- const paddingX = max(baseRadius, float(0.15)); // Generous horizontal padding
- const paddingY = max(baseRadius, float(0.2)); // Extra vertical padding (camera tilt)
- const one = float(1);
- const visible = step(one.negate().sub(paddingX), ndc.x)
- .mul(step(ndc.x, one.add(paddingX)))
- .mul(step(one.negate().sub(paddingY), ndc.y))
- .mul(step(ndc.y, one.add(paddingY)))
- .mul(step(float(-0.1), ndc.z)) // Allow slightly behind near plane
- .mul(step(ndc.z, float(1.1))); // Allow slightly beyond far plane
- return visible;
- });
-
- // ========== STATIC COMPUTE PASS ==========
- // Runs periodically (not every frame) - handles terrain-dependent culling
- // Stores static visibility in buffer4 and heightmapY in buffer3
- private createComputeStatic() {
- return Fn(() => {
- const data1 = this.buffer1.element(instanceIndex);
- const data3 = this.buffer3.element(instanceIndex);
- const data4 = this.buffer4.element(instanceIndex);
-
- // Read positions (wrapping is handled by the dynamic pass which runs every frame)
- const offsetX = data1.x;
- const offsetZ = data1.y;
- const worldX = offsetX.add(uniforms.uCameraPosition.x);
- const worldZ = offsetZ.add(uniforms.uCameraPosition.z);
-
- // Heightmap sampling - single sample, read pre-baked slope from G channel
- const halfWorld = uHeightmapWorldSize.mul(0.5);
- const hmUvX = worldX
- .sub(uHeightmapCenterX)
- .add(halfWorld)
- .div(uHeightmapWorldSize);
- const hmUvZ = worldZ
- .sub(uHeightmapCenterZ)
- .add(halfWorld)
- .div(uHeightmapWorldSize);
- const hmUV = vec2(
- hmUvX.clamp(float(0.001), float(0.999)),
- hmUvZ.clamp(float(0.001), float(0.999)),
- );
-
- let heightmapY: ReturnType;
- let slope: ReturnType;
- if (heightmapTextureNode) {
- const hmSample = heightmapTextureNode.sample(hmUV);
- heightmapY = hmSample.r.mul(uHeightmapMax);
- slope = hmSample.g; // Pre-baked slope from heightmap G channel
- } else {
- heightmapY = uniforms.uCameraPosition.y;
- slope = float(0);
- }
- data3.assign(heightmapY);
-
- // === CAMERA-BASED DISTANCE (for stochastic culling) ===
- const toGrassFromCam = vec2(offsetX, offsetZ);
- const camForwardXZ = uniforms.uCameraForward.xz;
- const forwardDot = toGrassFromCam.dot(camForwardXZ);
- const forwardFactor = smoothstep(float(-0.5), float(1.0), forwardDot);
- const biasAmount = uniforms.uForwardBias.mul(forwardFactor);
- const camDist = toGrassFromCam.length();
- const effectiveDist = max(camDist.sub(biasAmount), float(0.0));
- const effectiveDistSq = effectiveDist.mul(effectiveDist);
-
- // 1. Stochastic distance culling (dithered thinning)
- const _stochasticKeep = this.computeStochasticKeep(effectiveDistSq);
-
- // 2. Water/shoreline culling - gradual fade near water
- const waterFade = smoothstep(
- uWaterHardCutoff,
- uWaterFadeStart,
- heightmapY,
- );
- const waterDitherRand = hash(float(instanceIndex).mul(3.71));
- const _aboveWater = step(waterDitherRand, waterFade);
-
- // 3. Dirt/terrain culling - matches TerrainShader.ts
- const TERRAIN_NOISE_SCALE = 0.0008;
- const DIRT_THRESHOLD = 0.5;
- const noiseUV = vec2(worldX, worldZ).mul(TERRAIN_NOISE_SCALE);
- let terrainNoiseValue: ReturnType;
- if (noiseAtlasTexture) {
- terrainNoiseValue = texture(noiseAtlasTexture, noiseUV).r;
- } else {
- terrainNoiseValue = hash(
- worldX
- .mul(TERRAIN_NOISE_SCALE * 1000)
- .add(worldZ.mul(TERRAIN_NOISE_SCALE * 1000)),
- );
- }
-
- const dirtPatchFactor = smoothstep(
- float(DIRT_THRESHOLD - 0.05),
- float(DIRT_THRESHOLD + 0.15),
- terrainNoiseValue,
- );
-
- // Use pre-baked slope from heightmap G channel (no extra texture samples needed)
- const flatnessFactor = smoothstep(float(0.3), float(0.05), slope);
- const slopeFactor = smoothstep(float(0.25), float(0.6), slope);
-
- const _combinedDirtFactor = max(
- dirtPatchFactor.mul(flatnessFactor).mul(0.7),
- slopeFactor.mul(0.6),
- );
- const dirtDitherRand = hash(float(instanceIndex).mul(5.67));
- const _notOnDirt = step(_combinedDirtFactor, dirtDitherRand.mul(0.5));
-
- // 4. Sand zone (6-10m height on flat ground)
- const sandZone = smoothstep(float(10.0), float(6.0), heightmapY).mul(
- flatnessFactor,
- );
- const sandDitherRand = hash(float(instanceIndex).mul(6.78));
- const _notOnSand = step(sandZone.mul(0.8), sandDitherRand.mul(0.4));
-
- // 5. Road influence culling
- let _notOnRoad2: ReturnType;
- {
- const hasRoadData = step(float(2), uRoadInfluenceWorldSize);
- const roadHalfWorld = uRoadInfluenceWorldSize.mul(0.5);
- const roadDx = abs(worldX.sub(uRoadInfluenceCenterX));
- const roadDz = abs(worldZ.sub(uRoadInfluenceCenterZ));
- const insideMask = step(roadDx, roadHalfWorld).mul(
- step(roadDz, roadHalfWorld),
- );
- const useMask = hasRoadData.mul(insideMask);
- const roadUV = computeExclusionUV(
- worldX,
- worldZ,
- uRoadInfluenceCenterX,
- uRoadInfluenceCenterZ,
- uRoadInfluenceWorldSize,
- );
- const roadInfluence = roadInfluenceTextureNode.sample(roadUV).r;
- const roadDitherRand = hash(float(instanceIndex).mul(9.12));
- _notOnRoad2 = mix(
- float(1.0),
- step(
- roadInfluence,
- uRoadInfluenceThreshold.add(roadDitherRand.mul(0.15)),
- ),
- useMask,
- );
- }
-
- // 6. Exclusion zone culling - HYBRID: grid + legacy
- let _notExcluded2: ReturnType;
- {
- const gridNotExcluded = float(1.0).toVar();
- if (useGridBasedExclusion && gridExclusionTextureNode) {
- const gridUV = computeExclusionUV(
- worldX,
- worldZ,
- uGridExclusionCenterX,
- uGridExclusionCenterZ,
- uGridExclusionWorldSize,
- );
- const gridExclusionValue = gridExclusionTextureNode.sample(gridUV).r;
-
- const exclDitherRand = hash(float(instanceIndex).mul(10.34));
- const exclDitherRand2 = hash(float(instanceIndex).mul(17.89));
- const tileEdgeDist = min(
- min(fract(worldX), float(1).sub(fract(worldX))),
- min(fract(worldZ), float(1).sub(fract(worldZ))),
- );
- const edgeSoftness = smoothstep(float(0), float(0.4), tileEdgeDist);
- const baseNotExcluded = float(1).sub(gridExclusionValue);
- const softEdgeBonus = gridExclusionValue
- .mul(float(1).sub(edgeSoftness))
- .mul(exclDitherRand.lessThan(0.35).select(1, 0))
- .mul(exclDitherRand2.mul(0.5).add(0.5));
- gridNotExcluded.assign(
- baseNotExcluded.add(softEdgeBonus).clamp(0, 1),
- );
- }
-
- const legacyUV = computeExclusionUV(
- worldX,
- worldZ,
- uExclusionCenterX,
- uExclusionCenterZ,
- uExclusionWorldSize,
- );
- const legacyExclusionValue = exclusionTextureNode.sample(legacyUV).r;
- const legacyDitherRand = hash(float(instanceIndex).mul(11.45));
- const legacyNotExcluded = step(
- legacyExclusionValue,
- float(0.6).add(legacyDitherRand.mul(0.2)),
- );
-
- _notExcluded2 = gridNotExcluded.mul(legacyNotExcluded);
- }
-
- // Store combined static visibility in buffer4 (0=culled, 1=visible)
- const staticVisible = _stochasticKeep
- .mul(_aboveWater)
- .mul(_notOnDirt)
- .mul(_notOnSand)
- .mul(_notOnRoad2)
- .mul(_notExcluded2);
- data4.assign(staticVisible);
- })().compute(config.COUNT, [config.WORKGROUP_SIZE]);
- }
-
- // ========== DYNAMIC COMPUTE PASS ==========
- // Runs every frame - handles camera-dependent culling, wind, and trail
- // Reads static visibility from buffer4, heightmapY from buffer3
- private createComputeUpdate() {
- return Fn(() => {
- const data1 = this.buffer1.element(instanceIndex);
- const data2 = this.buffer2.element(instanceIndex);
- const data3 = this.buffer3.element(instanceIndex);
- const data4 = this.buffer4.element(instanceIndex);
-
- // Position wrapping - still needed for delta tracking each frame
- const wrappedPos = VegetationSsboUtils.wrapPosition(
- vec2(data1.x, data1.y),
- uniforms.uPlayerDeltaXZ,
- float(config.TILE_SIZE),
- );
- data1.x = wrappedPos.x;
- data1.y = wrappedPos.z;
-
- const offsetX = wrappedPos.x;
- const offsetZ = wrappedPos.z;
- const worldX = offsetX.add(uniforms.uCameraPosition.x);
- const worldZ = offsetZ.add(uniforms.uCameraPosition.z);
-
- // Read heightmapY from buffer3 (pre-computed in static pass)
- const heightmapY = data3;
- const worldPos = vec3(worldX, heightmapY, worldZ);
-
- // Read static visibility from buffer4
- const staticVisible = data4;
-
- // === CAMERA-BASED DISTANCE CULLING (changes every frame) ===
- const toGrassFromCam = vec2(offsetX, offsetZ);
- const camForwardXZ = uniforms.uCameraForward.xz;
- const forwardDot = toGrassFromCam.dot(camForwardXZ);
- const forwardFactor = smoothstep(float(-0.5), float(1.0), forwardDot);
- const biasAmount = uniforms.uForwardBias.mul(forwardFactor);
- const camDist = toGrassFromCam.length();
- const effectiveDist = max(camDist.sub(biasAmount), float(0.0));
- const effectiveDistSq = effectiveDist.mul(effectiveDist);
-
- // 1. Per-instance frustum culling (camera-dependent)
- const _frustumVisible = this.computeVisibility(worldPos);
-
- // 2. Distance fade with Bayer dither (camera-dependent)
- const distanceFade = this.computeDistanceFade(effectiveDistSq);
- const ditherRand = hash(float(instanceIndex).mul(1.31));
- const _ditherVisible = step(ditherRand, distanceFade);
-
- // Combine: staticVisible * frustumVisible * ditherVisible = final visibility
- const isVisible = staticVisible.mul(_frustumVisible).mul(_ditherVisible);
- data1.assign(this.setVisibility(data1, isVisible));
-
- // Skip expensive wind/trail for statically culled blades
- // (roads, water, dirt, exclusions - these blades will never be visible)
- const isStaticAlive = step(float(0.5), staticVisible);
-
- // === BENDING/TRAIL EFFECT ===
- // Either multi-character bending (texture-based) or legacy single-player trail
- let contact: ReturnType;
-
- if (useMultiCharacterBending && characterBendingTextureNode) {
- // MULTI-CHARACTER BENDING - Sample texture for each character
- const maxContact = float(0).toVar();
- const texWidth = float(CHARACTER_TEXTURE_WIDTH);
- const bendingTexture = characterBendingTextureNode;
-
- // Early skip: if blade is beyond maximum possible trail radius (3m), skip character loop entirely
- const maxTrailRadius = float(3.0); // No character has radius > 3m
- const bladeDistFromCam = toGrassFromCam.length();
- // Only process trail for blades that are reasonably close (within max view distance for trails)
- const trailRelevant = step(bladeDistFromCam, float(15.0)); // Trails only visible within 15m
-
- Loop(uCharacterCount, ({ i }) => {
- const charU = float(i).add(0.5).div(texWidth);
-
- const posUV = vec2(charU, float(0.25));
- const posData = bendingTexture.sample(posUV);
- const charX = posData.x;
- const charY = posData.y;
- const charZ = posData.z;
- const charRadius = posData.w;
-
- const velUV = vec2(charU, float(0.75));
- const velData = bendingTexture.sample(velUV);
- const charSpeed = velData.w;
-
- const hasRadius = charRadius.greaterThan(0.01);
-
- const dx = worldX.sub(charX);
- const dz = worldZ.sub(charZ);
- const distSq = dx.mul(dx).add(dz.mul(dz));
- const radiusSq = charRadius.mul(charRadius);
-
- const charHeightDiff = charY.sub(heightmapY).abs();
- const isCharGrounded = smoothstep(
- float(5.0),
- float(0.0),
- charHeightDiff,
- );
-
- const innerRadiusSq = radiusSq.mul(0.2);
- const outerRadiusSq = radiusSq;
-
- const charContact = float(1.0)
- .sub(smoothstep(innerRadiusSq, outerRadiusSq, distSq))
- .mul(isCharGrounded)
- .mul(hasRadius.select(1, 0));
-
- const speedFactor = charSpeed.mul(0.5).add(0.5).clamp(0.5, 2.0);
- const boostedContact = charContact.mul(speedFactor);
-
- maxContact.assign(max(maxContact, boostedContact));
- });
-
- contact = maxContact.clamp(0, 1).mul(trailRelevant).mul(isStaticAlive);
- } else {
- // LEGACY: SINGLE-PLAYER TRAIL EFFECT
- const playerWorldX = offsetX.add(uniforms.uCameraPosition.x);
- const playerWorldZ = offsetZ.add(uniforms.uCameraPosition.z);
- const diff = vec2(playerWorldX, playerWorldZ).sub(
- uniforms.uPlayerPosition.xz,
- );
- const distSqPlayer = diff.dot(diff);
-
- const heightDiff = uniforms.uPlayerPosition.y.sub(heightmapY).abs();
- const isPlayerGrounded = smoothstep(float(5.0), float(0.0), heightDiff);
-
- const innerRadiusSq = uniforms.uTrailRadiusSquared.mul(0.2);
- const outerRadiusSq = uniforms.uTrailRadiusSquared;
-
- contact = float(1.0)
- .sub(smoothstep(innerRadiusSq, outerRadiusSq, distSqPlayer))
- .mul(isPlayerGrounded);
- contact = contact.mul(isStaticAlive);
- }
-
- // Trail scale - flatten grass based on contact
- const currentTrailScale = this.getScale(data1);
- const originalScale = this.getOriginalScale(data1);
- const newScale = this.computeTrailScale(
- originalScale,
- currentTrailScale,
- contact,
- );
- data1.assign(this.setScale(data1, newScale));
-
- // Wind
- const positionNoise = this.getPositionNoise(data2);
- const prevWind = this.getWind(data1);
- const newWind = this.computeWind(prevWind, worldPos, positionNoise);
- data1.assign(
- this.setWind(
- data1,
- vec2(newWind.x.mul(isStaticAlive), newWind.y.mul(isStaticAlive)),
- ),
- );
- data1.assign(this.setWindNoise(data1, newWind.z.mul(isStaticAlive)));
- })().compute(config.COUNT, [config.WORKGROUP_SIZE]);
- }
-}
-
-// ============================================================================
-// GRASS MATERIAL - SpriteNodeMaterial
-// ============================================================================
-
-class GrassMaterial extends SpriteNodeMaterial {
- private ssbo: GrassSsbo;
-
- constructor(ssbo: GrassSsbo) {
- super();
- this.ssbo = ssbo;
- this.createGrassMaterial();
- }
-
- private createGrassMaterial(): void {
- // Material settings - transparent for bottom dither dissolve
- this.precision = "mediump";
- this.transparent = true;
- this.alphaTest = 0.1;
-
- // Get data from SSBO
- const data1 = this.ssbo.computeBuffer1.element(instanceIndex);
- const data2 = this.ssbo.computeBuffer2.element(instanceIndex);
- const data3 = this.ssbo.computeBuffer3.element(instanceIndex);
- const offsetX = data1.x;
- const offsetZ = data1.y;
- const _windXZ = this.ssbo.getWind(data1);
- const _scaleY = this.ssbo.getScale(data1);
- const isVisible = this.ssbo.getVisibility(data1);
- const _windNoiseFactor = this.ssbo.getWindNoise(data1);
- const _positionNoise = this.ssbo.getPositionNoise(data2);
-
- // Get heightmapY from compute shader
- const heightmapY = data3;
-
- // Height gradient for blade
- const h = uv().y;
-
- // BOTTOM DITHER DISSOLVE - fade grass base into ground, 100% above fade zone
- const bottomFade = smoothstep(float(0), uniforms.uBottomFadeHeight, h);
- // Only apply dither noise within the fade zone, full opacity above
- const inFadeZone = float(1.0).sub(step(uniforms.uBottomFadeHeight, h));
- const ditherNoise = hash(instanceIndex.add(h.mul(1000)))
- .mul(0.4)
- .mul(inFadeZone);
- const bottomOpacity = clamp(
- bottomFade.add(ditherNoise.sub(0.2).mul(inFadeZone)),
- 0,
- 1,
+ const distScale = smoothstep(
+ uniforms.uFadeEnd,
+ uniforms.uFadeStart,
+ distToPlayer,
);
+ const centerPt = vec3(worldX, terrainY, worldZ);
+ worldPos.assign(mix(centerPt, worldPos, distScale));
- // BAYER SCREEN-SPACE DITHERING - texture lookup (replaces 12 ALU bit ops)
- const fragCoord = viewportCoordinate;
- const bayerUV = vec2(
- mod(floor(fragCoord.x), float(4.0)).add(0.5).div(4.0),
- mod(floor(fragCoord.y), float(4.0)).add(0.5).div(4.0),
- );
- const bayerThreshold = bayerTextureNode.sample(bayerUV).r;
-
- // Distance-based opacity fade with Bayer dithering
- const distFromCamSq = data1.x.mul(data1.x).add(data1.y.mul(data1.y));
- const fadeStartSq = uniforms.uFadeStart.mul(uniforms.uFadeStart);
- const fadeEndSq = uniforms.uFadeEnd.mul(uniforms.uFadeEnd);
- const distFade = float(1.0).sub(
- clamp(
- distFromCamSq
- .sub(fadeStartSq)
- .div(max(fadeEndSq.sub(fadeStartSq), float(0.001))),
- 0.0,
- 1.0,
- ),
+ // 8) Water culling: collapse blades below water
+ const waterFade = smoothstep(
+ uWaterHardCutoff.sub(1.0),
+ uWaterHardCutoff,
+ rawTerrainY,
);
+ const waterScale = max(waterFade, float(1).sub(hmLoaded));
+ worldPos.assign(mix(centerPt, worldPos, waterScale));
- // Apply Bayer dither to distance fade - creates chunky retro dissolve
- const bayerFadeVisible = step(bayerThreshold, distFade);
-
- this.opacityNode = isVisible.mul(bottomOpacity).mul(bayerFadeVisible);
-
- // SCALE - use computed scale from SSBO (but keep some height for flattened grass)
- // When flattened, grass is rotated rather than shrunk
- const originalScale = this.ssbo.getOriginalScale(data1);
- const flattenAmount = float(1.0).sub(
- _scaleY.div(max(originalScale, float(0.01))),
- ); // 0 = upright, 1 = fully flattened
- // Keep most of the height, just rotate it down
- const adjustedScale = mix(
- _scaleY,
- originalScale.mul(0.9),
- flattenAmount.mul(0.7),
- );
- this.scaleNode = vec3(0.5, adjustedScale, 1);
-
- // ========== FLATTEN ROTATION (grass bends away from player) ==========
- // Calculate world position of this blade
- const worldX = uniforms.uCameraPosition.x.add(offsetX);
- const worldZ = uniforms.uCameraPosition.z.add(offsetZ);
-
- // Direction from player to grass (grass bends AWAY from player)
- const toGrassX = worldX.sub(uniforms.uPlayerPosition.x);
- const toGrassZ = worldZ.sub(uniforms.uPlayerPosition.z);
- const toGrassDist = sqrt(
- toGrassX.mul(toGrassX).add(toGrassZ.mul(toGrassZ)),
+ // Exclusion checks are only meaningful for nearby blades (perf optimization).
+ // Far blades are already fading out, so we blend exclusion to 1.0 (always visible)
+ // beyond 15m to reduce the impact of texture sampling on distant vertices.
+ const nearPlayer = smoothstep(float(16.0), float(14.0), distToPlayer);
+
+ // 9) Road exclusion: collapse blades on roads
+ const roadUV = computeExclusionUV(
+ worldX,
+ worldZ,
+ uRoadInfluenceCenterX,
+ uRoadInfluenceCenterZ,
+ uRoadInfluenceWorldSize,
);
- const toGrassDirX = toGrassX.div(max(toGrassDist, float(0.01)));
- const toGrassDirZ = toGrassZ.div(max(toGrassDist, float(0.01)));
-
- // Flatten angle - up to 80 degrees (1.4 radians) when fully flattened
- const flattenAngle = flattenAmount.mul(1.4);
-
- // Apply flatten rotation in the direction away from player
- // Rotation around X axis bends forward/back, around Z axis bends left/right
- const flattenRotX = toGrassDirZ.mul(flattenAngle); // Bend in Z direction -> rotate around X
- const flattenRotZ = toGrassDirX.negate().mul(flattenAngle); // Bend in X direction -> rotate around Z
-
- // ROTATION - combine wind bending + flatten rotation
- // Wind causes grass to lean in wind direction, more at top
- const windBendStrength = h.mul(h).mul(0.5); // Quadratic falloff from tip
- const windRotX = _windXZ.x.mul(windBendStrength);
- const windRotZ = _windXZ.y.mul(windBendStrength);
-
- // Flatten rotation affects the whole blade, wind affects tips more
- const totalRotX = windRotZ.add(flattenRotX);
- const totalRotZ = windRotX.negate().add(flattenRotZ);
- this.rotationNode = vec3(totalRotX, 0, totalRotZ);
-
- // POSITION - heightmap Y + wind sway offset
- const offsetY = heightmapY;
-
- // Wind sway - apply wind offset to position, more at blade top
- const windSwayStrength = h.mul(0.25).mul(_windNoiseFactor.add(0.5));
- const windSwayX = _windXZ.x.mul(windSwayStrength);
- const windSwayZ = _windXZ.y.mul(windSwayStrength);
-
- const bladePosition = vec3(
- offsetX.add(windSwayX),
- offsetY,
- offsetZ.add(windSwayZ),
+ const roadSample = roadInfluenceTextureNode.sample(roadUV).r;
+ const roadRaw = smoothstep(
+ uRoadInfluenceThreshold.add(0.05),
+ uRoadInfluenceThreshold,
+ roadSample,
);
+ const roadVisible = mix(float(1.0), roadRaw, nearPlayer);
+ worldPos.assign(mix(centerPt, worldPos, roadVisible));
- this.positionNode = bladePosition;
-
- // COLOR - SAMPLE TERRAIN COLOR AT WORLD POSITION
- // worldX and worldZ already calculated above for flatten rotation
-
- // ========== TERRAIN SLOPE FROM HEIGHTMAP G CHANNEL ==========
- // Pre-baked slope avoids 3 heightmap samples per fragment
- const halfWorld = uHeightmapWorldSize.mul(0.5);
- const bladeHmUvX = worldX
- .sub(uHeightmapCenterX)
- .add(halfWorld)
- .div(uHeightmapWorldSize);
- const bladeHmUvZ = worldZ
- .sub(uHeightmapCenterZ)
- .add(halfWorld)
- .div(uHeightmapWorldSize);
- const bladeHmUV = vec2(
- bladeHmUvX.clamp(0.001, 0.999),
- bladeHmUvZ.clamp(0.001, 0.999),
+ // 10) Legacy exclusion: collapse blades on buildings/arenas
+ const exclUV = computeExclusionUV(
+ worldX,
+ worldZ,
+ uExclusionCenterX,
+ uExclusionCenterZ,
+ uExclusionWorldSize,
);
- let slope: ReturnType;
- if (heightmapTextureNode) {
- slope = heightmapTextureNode.sample(bladeHmUV).g; // Pre-baked slope from G channel
- } else {
- slope = float(0);
- }
+ const exclSample = exclusionTextureNode.sample(exclUV).r;
+ const exclRaw = smoothstep(float(0.5), float(0.3), exclSample);
+ const exclVisible = mix(float(1.0), exclRaw, nearPlayer);
+ worldPos.assign(mix(centerPt, worldPos, exclVisible));
- // ========== GRASS COLOR - Simplified (dirt/rock blades already culled by compute) ==========
- const TERRAIN_NOISE_SCALE = 0.0008;
- const noiseUV = vec2(worldX, worldZ).mul(TERRAIN_NOISE_SCALE);
- let terrainNoiseValue: ReturnType;
- if (noiseAtlasTexture) {
- terrainNoiseValue = texture(noiseAtlasTexture, noiseUV).r;
- } else {
- terrainNoiseValue = hash(worldX.mul(0.8).add(worldZ.mul(1.3)));
+ // 11) Grid-based exclusion: collapse blades on collision-blocked tiles
+ if (gridExclusionTextureNode && useGridBasedExclusion) {
+ const gridExclUV = computeExclusionUV(
+ worldX,
+ worldZ,
+ uGridExclusionCenterX,
+ uGridExclusionCenterZ,
+ uGridExclusionWorldSize,
+ );
+ const gridExclSample = gridExclusionTextureNode.sample(gridExclUV).r;
+ const gridExclRaw = smoothstep(float(0.5), float(0.3), gridExclSample);
+ const gridExclVisible = mix(float(1.0), gridExclRaw, nearPlayer);
+ worldPos.assign(mix(centerPt, worldPos, gridExclVisible));
}
- // Grass green/dark variation (the only color variation that matters for visible grass)
- const noiseValue2 = sin(terrainNoiseValue.mul(6.28)).mul(0.3).add(0.5);
- const grassGreen = vec3(0.3, 0.55, 0.15);
- const grassDark = vec3(0.22, 0.42, 0.1);
- const grassVariation = smoothstep(float(0.4), float(0.6), noiseValue2);
- let grassColor = mix(grassGreen, grassDark, grassVariation);
-
- // Simple height gradient: base to tip
- const tipColor = grassColor.mul(1.1);
- const colorProfile = h.mul(uniforms.uColorMixFactor).clamp();
- const baseToTip = mix(grassColor, tipColor, colorProfile);
+ return worldPos;
+ })();
- // Simple ambient occlusion near camera center
- const r2 = offsetX.mul(offsetX).add(offsetZ.mul(offsetZ));
- const near = float(1).sub(smoothstep(0, uniforms.uAoRadiusSquared, r2));
- const hWeight = float(1).sub(smoothstep(0.1, 0.85, h));
- const ao = float(1).sub(uniforms.uAoScale.mul(0.15).mul(near.mul(hWeight)));
+ // --- colorNode ---
+ material.colorNode = Fn(() => {
+ const localVert = positionLocal;
+ const tipness = localVert.y; // 0 base, 1 tip
+ const centerAttr = attribute("center", "vec2");
+
+ const baseColor = uniforms.uBaseColor;
+ const tipColor = uniforms.uTipColor;
+ const grassColor = mix(
+ baseColor,
+ tipColor,
+ tipness.mul(uniforms.uColorMixFactor),
+ ).toVar("grassColor");
+
+ // Per-blade variation using hash of center position
+ const variation = hash(centerAttr.x.add(centerAttr.y.mul(1234.5)))
+ .mul(0.15)
+ .sub(0.075);
+ grassColor.r.addAssign(variation);
+ grassColor.g.addAssign(variation.mul(0.5));
// Day/night tinting
- const dayNightTint = mix(
- uniforms.uNightColor,
- uniforms.uDayColor,
- uniforms.uDayNightMix,
- );
+ const dayTint = uniforms.uDayColor;
+ const nightTint = uniforms.uNightColor;
+ const timeTint = mix(nightTint, dayTint, uniforms.uDayNightMix);
+ grassColor.assign(grassColor.mul(timeTint));
- // ========== WIND CLOUD SHADOW EFFECT (Grass_Journey style) ==========
- // Grass in gusty areas is slightly darker (like clouds passing over)
- // _windNoiseFactor contains the gust mask * wind intensity from computeWind
- const cloudShadowStrength = float(0.15); // How dark the shadow gets (0-1)
- const cloudShadow = float(1.0).sub(
- _windNoiseFactor.mul(cloudShadowStrength),
- );
+ // Light intensity
+ grassColor.assign(grassColor.mul(uniforms.uLightIntensity));
- this.colorNode = baseToTip.mul(ao).mul(dayNightTint).mul(cloudShadow);
- }
+ return grassColor;
+ })();
+
+ return material;
}
// ============================================================================
-// GPU-DRIVEN TILE MATERIAL - All culling in vertex shader
+// GPU-DRIVEN TILE MATERIAL - All culling in vertex shader (LOD1)
// ============================================================================
/**
@@ -1690,14 +1007,22 @@ function createGpuDrivenTileMaterial(
.div(uHeightmapWorldSize);
const hmUV = vec2(hmUvX.clamp(0.001, 0.999), hmUvZ.clamp(0.001, 0.999));
- // Sample heightmap - use 0 if texture not available yet
+ // Sample heightmap - use camera Y as fallback for unloaded terrain
let terrainY: ReturnType;
let hmSampleFull: ReturnType | null = null;
+ let tileHeightmapLoaded: ReturnType;
if (heightmapTextureNode) {
hmSampleFull = heightmapTextureNode.sample(hmUV);
- terrainY = hmSampleFull.r.mul(uHeightmapMax);
+ const rawHeight = hmSampleFull.r.mul(uHeightmapMax);
+ tileHeightmapLoaded = step(float(0.001), hmSampleFull.r);
+ terrainY = mix(
+ tileUniforms.uTileCameraPos.y,
+ rawHeight,
+ tileHeightmapLoaded,
+ );
} else {
- terrainY = float(0);
+ terrainY = tileUniforms.uTileCameraPos.y;
+ tileHeightmapLoaded = float(0);
}
// ========== ROAD INFLUENCE EXCLUSION ==========
@@ -1753,8 +1078,9 @@ function createGpuDrivenTileMaterial(
);
// ========== WATER CULLING ==========
- // Hide grass below water level
- const waterVisible = select(
+ // Hide grass below water level. Skip for unloaded heightmap areas
+ // to prevent all tiles from being culled before terrain loads.
+ const waterVisibleRaw = select(
terrainY.lessThan(uWaterHardCutoff),
float(0),
select(
@@ -1763,6 +1089,10 @@ function createGpuDrivenTileMaterial(
float(1),
),
);
+ const waterVisible = max(
+ waterVisibleRaw,
+ float(1).sub(tileHeightmapLoaded),
+ );
// Variation: rotation and mirror based on tile hash (moved up for dirt/sand dither)
const tileHash = gridX.mul(374761393).add(gridZ.mul(668265263));
@@ -1989,9 +1319,8 @@ function createGpuDrivenTileMesh(
// ============================================================================
export class ProceduralGrassSystem extends System {
- // LOD0 - Individual blades
- private mesh: THREE.InstancedMesh | null = null;
- private ssbo: GrassSsbo | null = null;
+ // LOD0 - Simple single-mesh grass (no SSBO, no compute)
+ private mesh: THREE.Mesh | null = null;
private useBladeGrass = true;
private quality: GrassQuality = GrassQuality.HIGH;
@@ -2006,16 +1335,6 @@ export class ProceduralGrassSystem extends System {
private loggedHeightSampleError = false;
private noiseTexture: THREE.Texture | null = null;
- // Position tracking for world-stable grass
- private lastCameraX = 0;
- private lastCameraZ = 0;
-
- // Static compute tracking - only re-run when player moves significantly
- private lastStaticComputeX = 0;
- private lastStaticComputeZ = 0;
- private static readonly STATIC_COMPUTE_THRESHOLD = 2; // Re-run static pass when player moves 2m
- private staticComputeInitialized = false;
-
// ========== STREAMING HEIGHTMAP STATE ==========
/** Center of the heightmap in world coordinates */
private heightmapCenterX = 0;
@@ -3177,7 +2496,6 @@ export class ProceduralGrassSystem extends System {
}
}
- // Mark texture for GPU upload
if (heightmapTexture && updated > 0) {
heightmapTexture.needsUpdate = true;
}
@@ -3284,7 +2602,6 @@ export class ProceduralGrassSystem extends System {
}
}
- // Mark texture for GPU upload
if (heightmapTexture) {
heightmapTexture.needsUpdate = true;
}
@@ -3375,63 +2692,27 @@ export class ProceduralGrassSystem extends System {
);
}
- // STEP 2: Create SSBO and meshes
+ // STEP 2: Create simple grass mesh (no SSBO, no compute shaders)
if (this.useBladeGrass) {
- this.ssbo = new GrassSsbo();
-
- // Create geometry
- const geometry = this.createGeometry(config.SEGMENTS);
+ const geometry = createSimpleGrassGeometry();
+ const material = createSimpleGrassMaterial();
- // Create material
- const material = new GrassMaterial(this.ssbo);
-
- // Create instanced mesh
- this.mesh = new THREE.InstancedMesh(geometry, material, config.COUNT);
+ this.mesh = new THREE.Mesh(geometry, material);
this.mesh.frustumCulled = false;
- this.mesh.name = "ProceduralGrass_GPU";
-
- // CRITICAL: Render order 76 = after silhouette (50), before player (100)
- // This prevents silhouette from showing through grass
+ this.mesh.name = "ProceduralGrass_Simple";
this.mesh.renderOrder = 76;
-
- // Layer 1 = main camera only (matches other vegetation)
this.mesh.layers.set(1);
stage.scene.add(this.mesh);
} else {
this.mesh = null;
- this.ssbo = null;
}
- // STEP 3: Pre-run GPU compute initialization during loading
- // This is the KEY OPTIMIZATION - run heavy compute BEFORE gameplay starts
- // Previously this ran via onInit callback causing first-frame spikes
- if (this.useBladeGrass && this.ssbo) {
- console.log(
- "[ProceduralGrass] Pre-running GPU compute initialization...",
- );
-
- // Run LOD0 SSBO initialization only (LOD1 disabled)
- await this.ssbo.runInitialization(this.renderer);
-
- // Yield to ensure GPU work is flushed
- await yieldToMain();
-
- // STEP 3.5: Prime visibility with a compute pass (roads/exclusion/distance)
- // This ensures the first rendered frame already respects roads and culling.
- const camPos = this.world.camera?.position;
- if (camPos) {
- uniforms.uCameraPosition.value.copy(camPos);
- uniforms.uPlayerPosition.value.copy(camPos);
- uniforms.uPlayerDeltaXZ.value.set(0, 0);
- }
- // Run static pass first (terrain-dependent culling), then dynamic pass (frustum/wind)
- await this.renderer.computeAsync(this.ssbo.computeStatic);
- await this.renderer.computeAsync(this.ssbo.computeUpdate);
- this.staticComputeInitialized = true;
- this.lastStaticComputeX = camPos?.x ?? 0;
- this.lastStaticComputeZ = camPos?.z ?? 0;
- await yieldToMain();
+ // Prime uniforms with initial camera/player position
+ const camPos = this.world.camera?.position;
+ if (camPos) {
+ uniforms.uCameraPosition.value.copy(camPos);
+ lod0Uniforms.uPlayerCenter.value.set(camPos.x, camPos.z);
}
this.grassInitialized = true;
@@ -3442,7 +2723,7 @@ export class ProceduralGrassSystem extends System {
const totalTime = performance.now() - initStartTime;
console.log(
`[ProceduralGrass] Initialization complete: ${totalTime.toFixed(1)}ms total\n` +
- ` LOD0: ${this.useBladeGrass ? `${config.COUNT.toLocaleString()} blades with Bayer dither fade` : "DISABLED (tile instancing)"}\n` +
+ ` LOD0: ${this.useBladeGrass ? `${config.COUNT.toLocaleString()} blades (simple vertex shader)` : "DISABLED"}\n` +
` LOD1: Tile instances (async bake)`,
);
} catch (error) {
@@ -3770,88 +3051,7 @@ export class ProceduralGrassSystem extends System {
return t * t * t * (t * (t * 6 - 15) + 10);
}
- private createGeometry(nSegments: number): THREE.BufferGeometry {
- // Geometry creation
- const segments = Math.max(1, Math.floor(nSegments));
- const height = config.BLADE_HEIGHT;
- const halfWidthBase = config.BLADE_WIDTH * 0.5;
-
- const rowCount = segments;
- const vertexCount = rowCount * 2 + 1;
- const quadCount = Math.max(0, rowCount - 1);
- const indexCount = quadCount * 6 + 3;
-
- const positions = new Float32Array(vertexCount * 3);
- const uvs = new Float32Array(vertexCount * 2);
- const indices = new Uint8Array(indexCount);
-
- const taper = (t: number) => halfWidthBase * (1.0 - 0.7 * t);
-
- let idx = 0;
- for (let row = 0; row < rowCount; row++) {
- const v = row / segments;
- const y = v * height;
- const halfWidth = taper(v);
-
- const left = row * 2;
- const right = left + 1;
-
- positions[3 * left + 0] = -halfWidth;
- positions[3 * left + 1] = y;
- positions[3 * left + 2] = 0;
-
- positions[3 * right + 0] = halfWidth;
- positions[3 * right + 1] = y;
- positions[3 * right + 2] = 0;
-
- uvs[2 * left + 0] = 0.0;
- uvs[2 * left + 1] = v;
- uvs[2 * right + 0] = 1.0;
- uvs[2 * right + 1] = v;
-
- if (row > 0) {
- const prevLeft = (row - 1) * 2;
- const prevRight = prevLeft + 1;
-
- indices[idx++] = prevLeft;
- indices[idx++] = prevRight;
- indices[idx++] = right;
-
- indices[idx++] = prevLeft;
- indices[idx++] = right;
- indices[idx++] = left;
- }
- }
-
- const tip = rowCount * 2;
- positions[3 * tip + 0] = 0;
- positions[3 * tip + 1] = height;
- positions[3 * tip + 2] = 0;
- uvs[2 * tip + 0] = 0.5;
- uvs[2 * tip + 1] = 1.0;
-
- const lastLeft = (rowCount - 1) * 2;
- const lastRight = lastLeft + 1;
- indices[idx++] = lastLeft;
- indices[idx++] = lastRight;
- indices[idx++] = tip;
-
- const geom = new THREE.BufferGeometry();
-
- const posAttribute = new THREE.BufferAttribute(positions, 3);
- posAttribute.setUsage(THREE.StaticDrawUsage);
- geom.setAttribute("position", posAttribute);
-
- const uvAttribute = new THREE.BufferAttribute(uvs, 2);
- uvAttribute.setUsage(THREE.StaticDrawUsage);
- geom.setAttribute("uv", uvAttribute);
-
- const indexAttribute = new THREE.BufferAttribute(indices, 1);
- indexAttribute.setUsage(THREE.StaticDrawUsage);
- geom.setIndex(indexAttribute);
-
- return geom;
- }
+ // Old createGeometry removed - replaced by module-level createSimpleGrassGeometry()
update(_deltaTime: number): void {
if (!this.grassInitialized || !this.renderer) return;
@@ -3861,49 +3061,42 @@ export class ProceduralGrassSystem extends System {
const cameraPos = camera.position;
- // Get actual player position for trail effect (may differ from camera in 3rd person)
- // Use player.node.position (the 3D mesh position) not player.position (entity data)
+ // Player position for grass centering (3rd person: follow player, not camera)
const players = this.world.getPlayers?.() as
| { node?: { position?: THREE.Vector3 } }[]
| undefined;
const player = players?.[0];
const playerPos = player?.node?.position ?? cameraPos;
- // Get camera forward direction for frustum culling
const cameraForward = new THREE.Vector3();
camera.getWorldDirection(cameraForward);
- // ========== GPU-DRIVEN TILE UPDATE ==========
- // Update shared tile uniforms (used by vertex shader for all tile positioning/culling)
- // This is the ONLY per-frame work needed - everything else happens in shaders
+ // ========== GPU-DRIVEN TILE UPDATE (LOD1) ==========
tileUniforms.uTileCameraPos.value.copy(cameraPos);
tileUniforms.uTileCameraForward.value.copy(cameraForward);
- if (!this.useBladeGrass || !this.mesh || !this.ssbo) {
+ if (!this.useBladeGrass || !this.mesh) {
return;
}
- // Calculate camera movement delta for position wrapping
- const deltaX = cameraPos.x - this.lastCameraX;
- const deltaZ = cameraPos.z - this.lastCameraZ;
- this.lastCameraX = cameraPos.x;
- this.lastCameraZ = cameraPos.z;
+ // ========== LOD0 UNIFORM UPDATES (no compute dispatches) ==========
+ lod0Uniforms.uPlayerCenter.value.set(playerPos.x, playerPos.z);
+ uniforms.uCameraPosition.value.copy(cameraPos);
+
+ uHeightmapCenterX.value = this.heightmapCenterX;
+ uHeightmapCenterZ.value = this.heightmapCenterZ;
// ========== STREAMING HEIGHTMAP UPDATE ==========
- // Check if heightmap needs re-centering based on player movement
this.checkHeightmapRecenter(cameraPos.x, cameraPos.z);
// ========== GRID-BASED EXCLUSION UPDATE ==========
- // Update grid-based exclusion (for collision-blocked tiles: rocks, trees)
if (this.useGridExclusion && this.exclusionGrid) {
this.exclusionGrid.update(cameraPos.x, cameraPos.z);
}
// ========== ROAD EXCLUSION POLLING ==========
- // Poll for road data if not yet loaded (handles timing issues with road generation)
if (!this.roadTextureLoaded) {
this.roadPollCounter++;
- // Check every 10 frames (~167ms at 60fps) for faster response
if (this.roadPollCounter >= 10) {
this.roadPollCounter = 0;
this.checkExistingRoads();
@@ -3911,62 +3104,16 @@ export class ProceduralGrassSystem extends System {
}
// ========== LEGACY EXCLUSION TEXTURE UPDATE ==========
- // ALWAYS update legacy texture (for buildings, duel arenas that don't set collision flags)
- // This runs in ADDITION to grid-based, not instead of
this.checkExclusionRecenter(cameraPos.x, cameraPos.z);
// ========== MULTI-CHARACTER BENDING UPDATE ==========
- // Update character influence manager for multi-character grass bending
if (this.useMultiCharacterBending && this.characterInfluence) {
this.characterInfluence.updateFromWorld(cameraPos);
}
-
- // Update uniforms
- uniforms.uCameraPosition.value.copy(cameraPos); // For distance culling
- uniforms.uPlayerPosition.value.copy(playerPos); // For trail effect only
- uniforms.uPlayerDeltaXZ.value.set(deltaX, deltaZ); // For position wrapping
-
- // Update heightmap center for GPU shader sampling
- // The shader needs to know where the heightmap is centered to sample correctly
- uHeightmapCenterX.value = this.heightmapCenterX;
- uHeightmapCenterZ.value = this.heightmapCenterZ;
-
- // NOTE: Lighting is controlled by Environment.updateGrassLighting()
- // via setLightIntensity(). DO NOT override here - let Environment
- // be the authoritative source for day/night cycle.
-
- // Camera frustum data
- const proj = camera.projectionMatrix;
- uniforms.uCameraMatrix.value.copy(proj).multiply(camera.matrixWorldInverse);
- camera.getWorldDirection(uniforms.uCameraForward.value);
-
- // Move grass meshes to follow CAMERA (density is camera-based)
- this.mesh.position.set(cameraPos.x, 0, cameraPos.z);
-
- // ========== STATIC COMPUTE PASS ==========
- // Run static pass (heightmap, exclusions, dirt, water) only when player moves significantly
- // This avoids re-running expensive terrain checks every frame
- const staticDx = cameraPos.x - this.lastStaticComputeX;
- const staticDz = cameraPos.z - this.lastStaticComputeZ;
- const staticDistSq = staticDx * staticDx + staticDz * staticDz;
- const thresholdSq =
- ProceduralGrassSystem.STATIC_COMPUTE_THRESHOLD *
- ProceduralGrassSystem.STATIC_COMPUTE_THRESHOLD;
-
- if (!this.staticComputeInitialized || staticDistSq > thresholdSq) {
- this.renderer.computeAsync(this.ssbo.computeStatic);
- this.lastStaticComputeX = cameraPos.x;
- this.lastStaticComputeZ = cameraPos.z;
- this.staticComputeInitialized = true;
- }
-
- // ========== DYNAMIC COMPUTE PASS ==========
- // Run dynamic pass every frame (wind, trail, frustum, distance fade)
- this.renderer.computeAsync(this.ssbo.computeUpdate);
}
// Public API
- getMesh(): THREE.InstancedMesh | null {
+ getMesh(): THREE.Mesh | null {
return this.mesh;
}
@@ -4016,14 +3163,6 @@ export class ProceduralGrassSystem extends System {
// Mark road texture as loaded to stop polling
this.roadTextureLoaded = true;
- // Force SSBO to recalculate visibility on next frame
- // This ensures grass is culled immediately after road texture is set
- if (this.ssbo) {
- // Touch the camera position to trigger a visibility update
- uniforms.uCameraPosition.value.x += 0.001;
- uniforms.uCameraPosition.value.x -= 0.001;
- }
-
console.log(
`[ProceduralGrass] ✅ Road influence texture updated: ${width}x${height}, ` +
`${worldSize}m coverage, center (${centerX}, ${centerZ})`,
@@ -4440,7 +3579,6 @@ export class ProceduralGrassSystem extends System {
this.mesh?.geometry.dispose();
(this.mesh?.material as THREE.Material | undefined)?.dispose();
this.mesh = null;
- this.ssbo = null;
// GPU-driven LOD1 cleanup
if (this.gpuLod1Mesh) {
diff --git a/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts b/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts
index 44db39279..b10d83eff 100644
--- a/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts
+++ b/packages/shared/src/systems/shared/world/ProcgenTreeInstancer.ts
@@ -3245,22 +3245,17 @@ export class ProcgenTreeInstancer {
inst.hasGlobalLeaves = false;
}
- removeInstance(preset: string, id: string, _lodLevel = 0): void {
+ removeInstance(_preset: string, id: string, _lodLevel = 0): void {
const tracked = this.instances.get(id);
if (!tracked) return;
- // Remove leaves and clusters from global buffers
this.removeTreeLeavesFromGlobal(tracked.inst);
this.removeTreeClustersFromGlobal(tracked.inst);
- // NOTE: Grass exclusion is handled via texture regeneration when needed.
- // Call ProceduralGrass.collectAndRefreshExclusionTexture() after major changes.
-
this.transitions.delete(id);
for (let lod = 0; lod < 4; lod++)
- this.removeFromLOD(preset, tracked.inst, lod);
+ this.removeFromLOD(tracked.preset, tracked.inst, lod);
- // Remove from spatial partitioning
this.removeFromSpatialChunk(id);
this.instances.delete(id);
@@ -3725,6 +3720,13 @@ export class ProcgenTreeInstancer {
}
private showInMesh(data: MeshData, inst: TreeInstance): number {
+ if (data.count >= MAX_INSTANCES) {
+ console.warn(
+ `[ProcgenTreeInstancer] MAX_INSTANCES (${MAX_INSTANCES}) reached, cannot show ${inst.id}`,
+ );
+ return -1;
+ }
+
const idx = data.nextIdx++;
if (data.nextIdx >= MAX_INSTANCES) data.nextIdx = 0;
diff --git a/packages/shared/src/systems/shared/world/SkySystem.ts b/packages/shared/src/systems/shared/world/SkySystem.ts
index c2addbfaa..43197748b 100644
--- a/packages/shared/src/systems/shared/world/SkySystem.ts
+++ b/packages/shared/src/systems/shared/world/SkySystem.ts
@@ -16,16 +16,23 @@ import {
add,
clamp,
cos,
+ distance,
+ div,
dot,
float,
Fn,
length,
+ max,
+ min,
MeshBasicNodeMaterial,
mix,
mul,
normalize,
positionLocal,
+ positionWorld,
pow,
+ screenUV,
+ sin,
smoothstep,
sub,
texture,
@@ -37,7 +44,18 @@ import {
type ShaderNode,
} from "../../../extras/three/three";
import type { World, WorldOptions } from "../../../types";
-import { fogRenderTarget } from "./FogConfig";
+import { applyCloudFog, fogRenderTarget } from "./FogConfig";
+import { DAY_CYCLE, SUN_LIGHT } from "./LightingConfig";
+
+const SKY_DOME_RADIUS = 5000;
+
+const SKY_RENDER_ORDER = {
+ SKY_DOME: -1000,
+ CELESTIAL_GLOW_OUTER: -999,
+ CELESTIAL_GLOW_INNER: -998,
+ CELESTIAL_DISC: -997,
+ CLOUDS: -995,
+} as const;
// -----------------------------
// Utility: Procedural noise textures (avoids external deps)
@@ -60,24 +78,304 @@ function createNoiseTexture(size = 128): THREE.DataTexture {
}
// -----------------------------
-// Cloud configuration with texture atlas sampling
-// The cloud textures are 2x4 sprite sheets (8 clouds per texture)
-// UV offset selects which cloud sprite to use
-// PERFORMANCE: Reduced from 6 to 4 clouds (barely noticeable, saves draw calls + GPU)
+// Cloud configuration — 28 clouds on a sky-dome ring, 2x4 sprite atlas per texture.
+// Each entry specifies (az, el) spherical coordinates on SKY_DOME_RADIUS.
// -----------------------------
type CloudDef = {
- az: number; // azimuth in degrees
- el: number; // elevation in degrees
- tex: number; // which texture (1-4)
- sprite: number; // which sprite in atlas (0-7)
- scale: number; // size multiplier
+ az: number;
+ el: number;
+ tex: number; // 1-4 (cloud1.png through cloud4.png)
+ sprite: number; // 0-7 within 2x4 atlas
+ w: number; // width in world units
+ h: number; // height in world units
+ dSpeed: number; // distortion speed
+ dRange: number; // distortion range
};
const CLOUD_DEFS: CloudDef[] = [
- { az: 30, el: 25, tex: 1, sprite: 0, scale: 1.3 },
- { az: 120, el: 32, tex: 2, sprite: 2, scale: 1.1 },
- { az: 210, el: 28, tex: 1, sprite: 4, scale: 1.4 },
- { az: 300, el: 35, tex: 2, sprite: 5, scale: 1.0 },
+ // --- Texture 3 (cloud3) — 12 low-altitude horizon clouds ---
+ {
+ az: 14.4,
+ el: 4.1,
+ tex: 3,
+ sprite: 0,
+ w: 300,
+ h: 200,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 0,
+ el: 4.1,
+ tex: 3,
+ sprite: 6,
+ w: 300,
+ h: 200,
+ dSpeed: 0.11,
+ dRange: 0.05,
+ },
+ {
+ az: 50.4,
+ el: 4.1,
+ tex: 3,
+ sprite: 1,
+ w: 300,
+ h: 180,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 28.8,
+ el: 4.1,
+ tex: 3,
+ sprite: 6,
+ w: 300,
+ h: 180,
+ dSpeed: 0.1,
+ dRange: 0.0,
+ },
+ {
+ az: 100.8,
+ el: 4.1,
+ tex: 3,
+ sprite: 2,
+ w: 400,
+ h: 200,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 108,
+ el: 4.1,
+ tex: 3,
+ sprite: 6,
+ w: 400,
+ h: 200,
+ dSpeed: 0.12,
+ dRange: 0.05,
+ },
+ {
+ az: 162,
+ el: 4.1,
+ tex: 3,
+ sprite: 3,
+ w: 400,
+ h: 200,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 180,
+ el: 4.1,
+ tex: 3,
+ sprite: 7,
+ w: 400,
+ h: 200,
+ dSpeed: 0.1,
+ dRange: 0.1,
+ },
+ {
+ az: 248.4,
+ el: 4.1,
+ tex: 3,
+ sprite: 4,
+ w: 350,
+ h: 175,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 270,
+ el: 4.1,
+ tex: 3,
+ sprite: 6,
+ w: 350,
+ h: 175,
+ dSpeed: 0.12,
+ dRange: 0.0,
+ },
+ {
+ az: 288,
+ el: 4.1,
+ tex: 3,
+ sprite: 5,
+ w: 350,
+ h: 175,
+ dSpeed: 0.12,
+ dRange: 0.1,
+ },
+ {
+ az: 306,
+ el: 4.1,
+ tex: 3,
+ sprite: 7,
+ w: 500,
+ h: 200,
+ dSpeed: 0.1,
+ dRange: 0.05,
+ },
+ // --- Texture 1 (cloud1) — 8 mid/high altitude clouds ---
+ {
+ az: 0,
+ el: 20.2,
+ tex: 1,
+ sprite: 0,
+ w: 230,
+ h: 115,
+ dSpeed: 0.1,
+ dRange: 0.5,
+ },
+ {
+ az: 54,
+ el: 29.9,
+ tex: 1,
+ sprite: 1,
+ w: 180,
+ h: 90,
+ dSpeed: 0.12,
+ dRange: 0.4,
+ },
+ {
+ az: 82.8,
+ el: 37.9,
+ tex: 1,
+ sprite: 2,
+ w: 210,
+ h: 105,
+ dSpeed: 0.13,
+ dRange: 0.35,
+ },
+ {
+ az: 122.4,
+ el: 7.9,
+ tex: 1,
+ sprite: 3,
+ w: 250,
+ h: 125,
+ dSpeed: 0.15,
+ dRange: 0.4,
+ },
+ {
+ az: 165.6,
+ el: 20.7,
+ tex: 1,
+ sprite: 4,
+ w: 230,
+ h: 115,
+ dSpeed: 0.16,
+ dRange: 0.35,
+ },
+ {
+ az: 208.8,
+ el: 30.3,
+ tex: 1,
+ sprite: 5,
+ w: 290,
+ h: 145,
+ dSpeed: 0.12,
+ dRange: 0.4,
+ },
+ {
+ az: 270,
+ el: 21.2,
+ tex: 1,
+ sprite: 6,
+ w: 150,
+ h: 75,
+ dSpeed: 0.2,
+ dRange: 0.45,
+ },
+ {
+ az: 324,
+ el: 20.5,
+ tex: 1,
+ sprite: 7,
+ w: 240,
+ h: 120,
+ dSpeed: 0.17,
+ dRange: 0.5,
+ },
+ // --- Texture 4 (cloud4) — 8 mid/high altitude clouds ---
+ {
+ az: 216,
+ el: 5.8,
+ tex: 4,
+ sprite: 7,
+ w: 300,
+ h: 150,
+ dSpeed: 0.1,
+ dRange: 0.5,
+ },
+ {
+ az: 72,
+ el: 5.8,
+ tex: 4,
+ sprite: 6,
+ w: 200,
+ h: 100,
+ dSpeed: 0.12,
+ dRange: 0.4,
+ },
+ {
+ az: 129.6,
+ el: 30.4,
+ tex: 4,
+ sprite: 5,
+ w: 250,
+ h: 120,
+ dSpeed: 0.13,
+ dRange: 0.35,
+ },
+ {
+ az: 180,
+ el: 36.6,
+ tex: 4,
+ sprite: 4,
+ w: 280,
+ h: 170,
+ dSpeed: 0.15,
+ dRange: 0.4,
+ },
+ {
+ az: 248.4,
+ el: 30.3,
+ tex: 4,
+ sprite: 3,
+ w: 350,
+ h: 200,
+ dSpeed: 0.16,
+ dRange: 0.35,
+ },
+ {
+ az: 284.4,
+ el: 40.8,
+ tex: 4,
+ sprite: 2,
+ w: 390,
+ h: 200,
+ dSpeed: 0.12,
+ dRange: 0.4,
+ },
+ {
+ az: 306,
+ el: 29.3,
+ tex: 4,
+ sprite: 1,
+ w: 380,
+ h: 190,
+ dSpeed: 0.2,
+ dRange: 0.45,
+ },
+ {
+ az: 342,
+ el: 38.9,
+ tex: 4,
+ sprite: 0,
+ w: 150,
+ h: 100,
+ dSpeed: 0.17,
+ dRange: 0.5,
+ },
];
// -----------------------------
@@ -104,7 +402,7 @@ type SkyMaterialUniforms = {
type CloudMaterialUniforms = {
uTime: TSLUniformFloat;
uSunPosition: TSLUniformVec3;
- uDayIntensity: TSLUniformFloat;
+ uCloudRadius: TSLUniformFloat;
};
type SunMaterialUniforms = {
@@ -160,7 +458,7 @@ export class SkySystem extends System {
private galaxyTextureUniform: { value: THREE.Texture | null } | null = null;
private elapsed = 0;
- private dayDurationSec = 240; // full day cycle in seconds
+ private dayDurationSec = DAY_CYCLE.DURATION_SEC;
// Pre-allocated vector for sun direction to avoid per-frame allocation
private _sunDir = new THREE.Vector3();
private _dayPhase = 0;
@@ -316,9 +614,7 @@ export class SkySystem extends System {
private createSun(): void {
if (!this.group) return;
- // Sun disc geometry - bright core (scaled for 600 far plane)
- // Sun disc sized to fit within 600 far plane
- const sunGeom = new THREE.CircleGeometry(15, 32);
+ const sunGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.03, 32);
// TSL uniform for opacity control
const uOpacity = uniform(float(1.0));
@@ -360,13 +656,11 @@ export class SkySystem extends System {
this.sun = new THREE.Mesh(sunGeom, sunMat);
this.sun.name = "SkySun";
this.sun.frustumCulled = false;
- this.sun.renderOrder = 1000; // Render AFTER terrain so depth test works
+ this.sun.renderOrder = SKY_RENDER_ORDER.CELESTIAL_DISC;
this.sun.layers.set(1); // Main camera only, not minimap
this.group.add(this.sun);
- // Inner glow - medium sized, intense (scaled for 600 far plane)
- // Inner glow sized to fit within 600 far plane
- const innerGlowGeom = new THREE.CircleGeometry(50, 32);
+ const innerGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.1, 32);
const innerGlowColorNode = Fn(() => {
const uvCoord = uv();
const center = vec3(0.5, 0.5, 0.0);
@@ -396,16 +690,14 @@ export class SkySystem extends System {
const innerGlow = new THREE.Mesh(innerGlowGeom, innerGlowMat);
innerGlow.name = "SkySunInnerGlow";
innerGlow.frustumCulled = false;
- innerGlow.renderOrder = 999; // Render after terrain, before sun
+ innerGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_INNER;
innerGlow.layers.set(1); // Main camera only, not minimap
this.group.add(innerGlow);
// Store for position updates
(this.group as THREE.Group & { sunInnerGlow?: THREE.Mesh }).sunInnerGlow =
innerGlow;
- // Outer glow - large, soft halo for atmosphere effect (scaled for 600 far plane)
- // Outer glow sized to fit within 600 far plane
- const outerGlowGeom = new THREE.CircleGeometry(100, 32);
+ const outerGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.2, 32);
const outerGlowColorNode = Fn(() => {
const uvCoord = uv();
const center = vec3(0.5, 0.5, 0.0);
@@ -439,7 +731,7 @@ export class SkySystem extends System {
this.sunGlow = new THREE.Mesh(outerGlowGeom, outerGlowMat);
this.sunGlow.name = "SkySunGlow";
this.sunGlow.frustumCulled = false;
- this.sunGlow.renderOrder = 998; // Render after terrain, before inner glow
+ this.sunGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_OUTER;
this.sunGlow.layers.set(1); // Main camera only, not minimap
this.group.add(this.sunGlow);
}
@@ -450,8 +742,10 @@ export class SkySystem extends System {
private createMoon(): void {
if (!this.group) return;
- // Moon sized for 600 far plane
- const moonGeom = new THREE.PlaneGeometry(35, 35);
+ const moonGeom = new THREE.PlaneGeometry(
+ SKY_DOME_RADIUS * 0.07,
+ SKY_DOME_RADIUS * 0.07,
+ );
// TSL uniform for opacity control
const uOpacity = uniform(float(1.0));
@@ -481,12 +775,11 @@ export class SkySystem extends System {
this.moon = new THREE.Mesh(moonGeom, moonMat);
this.moon.name = "SkyMoon";
this.moon.frustumCulled = false;
- this.moon.renderOrder = 1000; // Render AFTER terrain so depth test works
+ this.moon.renderOrder = SKY_RENDER_ORDER.CELESTIAL_DISC;
this.moon.layers.set(1); // Main camera only, not minimap
this.group.add(this.moon);
- // Moon glow effect - soft halo around moon (scaled for 600 far plane)
- const moonGlowGeom = new THREE.CircleGeometry(50, 32);
+ const moonGlowGeom = new THREE.CircleGeometry(SKY_DOME_RADIUS * 0.1, 32);
const moonGlowColorNode = Fn(() => {
const uvCoord = uv();
@@ -518,7 +811,7 @@ export class SkySystem extends System {
this.moonGlow = new THREE.Mesh(moonGlowGeom, moonGlowMat);
this.moonGlow.name = "SkyMoonGlow";
this.moonGlow.frustumCulled = false;
- this.moonGlow.renderOrder = 999; // Render after terrain, before moon
+ this.moonGlow.renderOrder = SKY_RENDER_ORDER.CELESTIAL_GLOW_INNER;
this.moonGlow.layers.set(1); // Main camera only, not minimap
this.group.add(this.moonGlow);
}
@@ -530,11 +823,7 @@ export class SkySystem extends System {
private createSkyDome(): void {
if (!this.group) return;
- // Use high segment count to prevent color banding
- // Sky sphere sized to fit within camera far plane (600)
- // Sky follows camera so this creates infinite sky illusion
- // Sky sphere must fit inside camera far plane (600) - use 500
- const skyGeom = new THREE.SphereGeometry(500, 128, 64);
+ const skyGeom = new THREE.SphereGeometry(SKY_DOME_RADIUS, 128, 64);
// Create TSL uniforms
const uTime = uniform(float(0));
@@ -771,7 +1060,7 @@ export class SkySystem extends System {
this.skyMesh = new THREE.Mesh(skyGeom, skyMat);
this.skyMesh.frustumCulled = false;
- this.skyMesh.renderOrder = -1000; // Render first, behind everything
+ this.skyMesh.renderOrder = SKY_RENDER_ORDER.SKY_DOME;
this.skyMesh.name = "AdvancedSkydome";
this.skyMesh.layers.set(1); // Main camera only, not minimap
this.group.add(this.skyMesh);
@@ -788,7 +1077,7 @@ export class SkySystem extends System {
this.fogScene = new THREE.Scene();
this.fogCamera = this.world.camera.clone() as THREE.PerspectiveCamera;
- const fogSkyGeom = new THREE.SphereGeometry(500, 64, 32);
+ const fogSkyGeom = new THREE.SphereGeometry(SKY_DOME_RADIUS, 64, 32);
const uSunPosition = uniform(vec3(0, 1, 0));
const uDayCycleProgress = uniform(float(0));
@@ -894,149 +1183,172 @@ export class SkySystem extends System {
private cloudGroup: THREE.Group | null = null;
/**
- * Create cloud billboards using cloud textures
- * Each cloud samples from a sprite atlas (2 columns x 4 rows = 8 sprites per texture)
- * Clouds dissolve at night using noise-based alpha erosion
+ * Create cloud billboards with custom shader:
+ * - noise UV distortion for organic movement
+ * - B-channel alpha with sin-based oscillation
+ * - Day/night coloring from sun height
+ * - Sun proximity brightness boost + G-channel additive glow
+ * - R-channel dark/bright color interpolation
*/
private createClouds(): void {
if (!this.group) return;
- // Cloud distances scaled for 600 far plane
- // Sky must fit inside camera far plane (600) - use 500 to leave some margin
- const SKY_RADIUS = 500;
- const BASE_SIZE = 160;
+ const R = SKY_DOME_RADIUS;
- // Create a group to hold all cloud meshes (for rotation)
this.cloudGroup = new THREE.Group();
this.cloudGroup.name = "CloudGroup";
- this.cloudGroup.layers.set(1); // Main camera only, not minimap
+ this.cloudGroup.layers.set(1);
- // Get textures array for easy lookup
const textures = [this.cloud1, this.cloud2, this.cloud3, this.cloud4];
+ const noiseTex = this.noiseB; // noise2.png for UV distortion
- // Reference noise texture for dissolve effect
- const noiseRef = this.noiseA;
-
- // Shared uniforms for all clouds
+ // Shared uniforms
const uTime = uniform(float(0));
- const uSunDir = uniform(vec3(0, 1, 0));
- const uDayIntensity = uniform(float(1.0)); // For dissolve effect at night
+ const uSunPos = uniform(vec3(0, R, 0));
+ const uCloudRadius = uniform(float(R));
- // Store uniforms at class level for updates
this.cloudMaterialUniforms = {
uTime,
- uSunPosition: uSunDir,
- uDayIntensity,
+ uSunPosition: uSunPos,
+ uCloudRadius,
} as CloudMaterialUniforms;
for (let i = 0; i < CLOUD_DEFS.length; i++) {
const def = CLOUD_DEFS[i];
- const tex = textures[def.tex - 1]; // tex is 1-indexed
-
+ const tex = textures[def.tex - 1];
if (!tex) continue;
- // Calculate UV offset for sprite in atlas (2 cols x 4 rows)
+ // Sprite atlas UV offset (2 cols x 4 rows)
const col = def.sprite % 2;
const row = Math.floor(def.sprite / 2);
- const uOffset = col * 0.5;
- const vOffset = 0.75 - row * 0.25; // rows go from top
+ const uOff = col * 0.5;
+ const vOff = 0.75 - row * 0.25;
- // Create geometry with adjusted UVs for this sprite
const geom = new THREE.PlaneGeometry(1, 1);
const uvAttr = geom.attributes.uv;
for (let j = 0; j < uvAttr.count; j++) {
- const u = uvAttr.getX(j) * 0.5 + uOffset;
- const v = uvAttr.getY(j) * 0.25 + vOffset;
- uvAttr.setXY(j, u, v);
+ uvAttr.setXY(
+ j,
+ uvAttr.getX(j) * 0.5 + uOff,
+ uvAttr.getY(j) * 0.25 + vOff,
+ );
}
uvAttr.needsUpdate = true;
- // Create material with perlin noise dissolve fade in/out
- // Each cloud gets a unique phase offset based on its index
- const cloudIndex = float(i);
+ // Compute cloud world position on the sky sphere (group-local)
+ const azRad = (def.az * Math.PI) / 180;
+ const elRad = (def.el * Math.PI) / 180;
+ const cx = R * Math.cos(elRad) * Math.sin(azRad);
+ const cy = R * Math.sin(elRad);
+ const cz = R * Math.cos(elRad) * Math.cos(azRad);
- // PERFORMANCE: Simplified cloud shader - removed UV swirl animation
- // Keeps day/night fade and simple dissolve, removes expensive per-fragment cos/sin
- const cloudColorNode = Fn(() => {
- const uvCoord = uv();
- const cloudTex = texture(tex, uvCoord);
+ // Per-cloud uniforms
+ const uDistSpeed = float(def.dSpeed);
+ const uDistRange = float((1 - def.dRange) * 2);
+ const uCloudPos = vec3(cx, cy, cz);
- // Day/night color - clouds are bright during day
- const dayColor = vec3(1.0, 1.0, 1.0);
- const nightColor = vec3(0.3, 0.35, 0.45);
- const cloudColor = mix(nightColor, dayColor, uDayIntensity);
+ // ---- Cloud shader (TSL) ----
+ const cloudOutputNode = Fn(() => {
+ const uvCoord = uv();
- // Simple drifting noise UVs (no swirl rotation)
- const noiseUV = add(
- mul(uvCoord, float(2.0)),
- mul(vec2(uTime, mul(uTime, float(0.6))), float(0.03)),
+ // Noise UV distortion for organic cloud morphing
+ const noiseUV = vec2(
+ add(uvCoord.x, mul(uTime, mul(uDistSpeed, float(0.1)))),
+ add(uvCoord.y, mul(uTime, mul(uDistSpeed, float(0.2)))),
);
- const noiseSample = noiseRef
- ? texture(noiseRef, noiseUV)
+ const noiseSample = noiseTex
+ ? texture(noiseTex, noiseUV)
: vec4(0.5, 0.5, 0.5, 1.0);
- const noiseValue = noiseSample.r;
-
- // Simple fade cycle (uses cos but only once per cloud, not per-pixel)
- const fadeSpeed = float(0.025);
- const cloudPhase = mul(cloudIndex, float(2.5));
- const fadeCycle = add(mul(uTime, fadeSpeed), cloudPhase);
- const fadeProgress = mul(add(cos(fadeCycle), float(1.0)), float(0.5));
-
- // Combined dissolve (day/night and cycle in one pass)
- const nightFade = mul(sub(float(1.0), uDayIntensity), float(0.7));
- const dissolveThreshold = add(sub(float(1.0), fadeProgress), nightFade);
- const dissolveMask = smoothstep(
- dissolveThreshold,
- add(dissolveThreshold, float(0.2)),
- noiseValue,
+ const distortedUV = add(
+ uvCoord,
+ mul(vec2(noiseSample.r, noiseSample.b), float(0.01)),
+ );
+
+ const cloud = texture(tex, distortedUV);
+
+ // B-channel alpha dissolve — sin-based oscillation morphs the cloud shape over time
+ const alphaLerp = mix(
+ add(
+ mul(sin(mul(uTime, uDistSpeed)), float(0.78)),
+ mul(float(0.78), uDistRange),
+ ),
+ float(1.0),
+ float(0.1),
+ );
+ const cloudStep = sub(float(1.0), alphaLerp);
+ const cloudLerp = smoothstep(float(0.95), float(1.0), alphaLerp);
+ const alphaBase = smoothstep(
+ clamp(sub(cloudStep, float(0.1)), float(0.0), float(1.0)),
+ cloudStep,
+ cloud.b,
+ );
+ const cloudAlpha = clamp(
+ mix(alphaBase, cloud.a, cloudLerp),
+ float(0.0),
+ cloud.a,
+ );
+
+ // Day/night color from sun height
+ const sunNightStep = smoothstep(
+ float(-0.3),
+ float(0.25),
+ div(uSunPos.y, uCloudRadius),
+ );
+ const brightColor = mix(
+ vec3(0.141, 0.607, 0.94),
+ vec3(1.0, 1.0, 1.0),
+ sunNightStep,
+ );
+ const darkColor = mix(
+ vec3(0.024, 0.32, 0.59),
+ vec3(0.22, 0.5, 0.85),
+ sunNightStep,
);
- const finalAlpha = mul(cloudTex.a, dissolveMask);
- return vec4(cloudColor, finalAlpha);
+ // Sun proximity brightness
+ const sunDist = distance(uCloudPos, uSunPos);
+ const brightLerp = smoothstep(float(0.0), uCloudRadius, sunDist);
+ const bright = mix(float(2.0), float(1.0), brightLerp);
+
+ // R-channel color interpolation + G-channel additive sun glow
+ const cloudColor = add(
+ mul(mix(darkColor, brightColor, cloud.r), bright),
+ mul(cloud.g, sub(float(1.0), brightLerp)),
+ );
+
+ // Per-fragment horizon fog: sample sky fog texture, blend by world Y
+ const fogTex = texture(fogRenderTarget.texture, screenUV);
+ const worldElev = clamp(
+ div(positionWorld.y, float(5000.0)),
+ float(0.0),
+ float(1.0),
+ );
+ const fogStr = smoothstep(float(0.6), float(0.0), worldElev);
+ const finalColor = mix(cloudColor, fogTex.rgb, fogStr);
+
+ return vec4(finalColor, cloudAlpha);
})();
const mat = new MeshBasicNodeMaterial();
- mat.colorNode = cloudColorNode;
+ mat.colorNode = cloudOutputNode;
mat.side = THREE.DoubleSide;
mat.transparent = true;
mat.depthWrite = false;
- mat.depthTest = true; // Check depth buffer - don't render over closer objects
+ mat.depthTest = true;
mat.toneMapped = false;
- mat.fog = false; // Don't let scene fog affect clouds
+ mat.fog = false;
- // Create mesh
const mesh = new THREE.Mesh(geom, mat);
mesh.frustumCulled = false;
- mesh.renderOrder = -995; // Render after sky dome but before scene objects
-
- // Position on sky dome
- const azRad = (def.az * Math.PI) / 180;
- const elRad = (def.el * Math.PI) / 180;
-
- const x = SKY_RADIUS * Math.cos(elRad) * Math.sin(azRad);
- const y = SKY_RADIUS * Math.sin(elRad);
- const z = SKY_RADIUS * Math.cos(elRad) * Math.cos(azRad);
-
- mesh.position.set(x, y, z);
-
- // Rotate to face center (billboard)
+ mesh.renderOrder = SKY_RENDER_ORDER.CLOUDS;
+ mesh.position.set(cx, cy, cz);
mesh.rotation.y = azRad + Math.PI;
-
- // Scale
- const w = BASE_SIZE * def.scale * 1.5;
- const h = BASE_SIZE * def.scale * 0.7;
- mesh.scale.set(w, h, 1);
-
- // Store base scale for animation
- mesh.userData.baseScale = new THREE.Vector3(w, h, 1);
-
- // Main camera only, not minimap
+ mesh.scale.set(def.w * 10, def.h * 10, 1);
mesh.layers.set(1);
this.cloudGroup.add(mesh);
}
- // Store reference (use first mesh for uniform updates)
this.clouds = this.cloudGroup.children[0] as THREE.InstancedMesh;
this.group.add(this.cloudGroup);
}
@@ -1065,31 +1377,28 @@ export class SkySystem extends System {
// Calculate day intensity with SHARP transitions at sunrise/sunset
// Night stays truly dark until sunrise, then rapid transition
// This creates the feeling of "darkest before dawn" then sudden light
- const DAWN_START = 0.22; // Start brightening just before sunrise
- const DAWN_END = 0.28; // Full brightness shortly after sunrise
- const DUSK_START = 0.72; // Start darkening just before sunset
- const DUSK_END = 0.78; // Full darkness shortly after sunset
-
- // Smoothstep helper for smooth but sharp transitions
const smoothstep = (edge0: number, edge1: number, x: number) => {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
};
let dayIntensity: number;
- if (dayPhase < DAWN_START || dayPhase >= DUSK_END) {
- // Deep night - completely dark
+ if (dayPhase < DAY_CYCLE.DAWN_START || dayPhase >= DAY_CYCLE.DUSK_END) {
dayIntensity = 0;
- } else if (dayPhase < DAWN_END) {
- // Dawn transition - rapid brightening
- dayIntensity = smoothstep(DAWN_START, DAWN_END, dayPhase);
- } else if (dayPhase < DUSK_START) {
- // Full day - slight variation with noon being brightest
- const noonFactor = 1 - Math.abs(dayPhase - 0.5) * 2; // 0 at edges, 1 at noon
- dayIntensity = 0.85 + noonFactor * 0.15; // 0.85 to 1.0
+ } else if (dayPhase < DAY_CYCLE.DAWN_END) {
+ dayIntensity = smoothstep(
+ DAY_CYCLE.DAWN_START,
+ DAY_CYCLE.DAWN_END,
+ dayPhase,
+ );
+ } else if (dayPhase < DAY_CYCLE.DUSK_START) {
+ const noonFactor = 1 - Math.abs(dayPhase - 0.5) * 2;
+ dayIntensity =
+ DAY_CYCLE.NOON_MIN_INTENSITY +
+ noonFactor * (1 - DAY_CYCLE.NOON_MIN_INTENSITY);
} else {
- // Dusk transition - rapid darkening
- dayIntensity = 1 - smoothstep(DUSK_START, DUSK_END, dayPhase);
+ dayIntensity =
+ 1 - smoothstep(DAY_CYCLE.DUSK_START, DAY_CYCLE.DUSK_END, dayPhase);
}
this._dayIntensity = dayIntensity;
@@ -1107,8 +1416,7 @@ export class SkySystem extends System {
const sunElevation = Math.sin(sunArcAngle); // -1 to 1, peaks at noon
const sunAzimuth = Math.cos(sunArcAngle); // 1 at sunrise, -1 at sunset
- // Add slight Z offset for sun path tilt (makes shadows more interesting)
- const sunTilt = 0.3; // 30% tilt toward/away from camera
+ const sunTilt = SUN_LIGHT.TILT;
this._sunDir
.set(
@@ -1123,9 +1431,7 @@ export class SkySystem extends System {
this.skyUniforms.sunPosition.value.copy(this._sunDir);
this.skyUniforms.dayCycleProgress.value = dayPhase;
- // Position sun/moon (scaled for 600 far plane)
- // Position celestial bodies inside sky dome (must fit in camera far plane)
- const radius = 450;
+ const radius = SKY_DOME_RADIUS * 0.9;
if (this.sun) {
this.sun.position.set(
this._sunDir.x * radius,
@@ -1200,42 +1506,18 @@ export class SkySystem extends System {
this.skyTSLUniforms.uDayIntensity.value = this._dayIntensity; // Sharp transition value
}
- // Update cloud material uniforms via class-level reference
+ // Update cloud material uniforms — sun position as world-space point on sky sphere
if (this.cloudMaterialUniforms) {
this.cloudMaterialUniforms.uTime.value = this.elapsed;
- this.cloudMaterialUniforms.uSunPosition.value.copy(this._sunDir);
- this.cloudMaterialUniforms.uDayIntensity.value = this._dayIntensity;
+ this.cloudMaterialUniforms.uSunPosition.value.set(
+ this._sunDir.x * SKY_DOME_RADIUS,
+ this._sunDir.y * SKY_DOME_RADIUS,
+ this._sunDir.z * SKY_DOME_RADIUS,
+ );
}
- // Ensure render order - render AFTER terrain so depth test works (terrain occludes celestials)
- if (this.sun) this.sun.renderOrder = 1000;
- if (this.moon) this.moon.renderOrder = 1000;
-
- // Gently rotate cloud cover and animate scale
- if (this.cloudGroup) {
- // ~1 full rotation per 20 minutes (0.005 radians/sec) - subtle movement
- this.cloudGroup.rotation.y += delta * 0.005;
-
- // Animate each cloud's scale for gentle breathing effect
- this.cloudGroup.children.forEach((mesh, i) => {
- if (mesh instanceof THREE.Mesh) {
- const baseScale = mesh.userData.baseScale as
- | THREE.Vector3
- | undefined;
- if (baseScale) {
- // Each cloud has different phase
- const phase = this.elapsed * 0.3 + i * 1.5;
- // Scale oscillates between 95% and 105%
- const scaleMod = 1.0 + Math.sin(phase) * 0.05;
- mesh.scale.set(
- baseScale.x * scaleMod,
- baseScale.y * scaleMod,
- baseScale.z,
- );
- }
- }
- });
- }
+ // Clouds are static on the ring — movement comes from the shader's
+ // noise UV distortion and alpha oscillation.
}
override lateUpdate(_delta: number): void {
diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts
index 8bd408c3a..07fa96d02 100644
--- a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts
+++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts
@@ -39,18 +39,18 @@ export function buildBiomeConstantsJS(): string {
const FOREST_TREE_CONFIG: BiomeTreeConfig = {
enabled: true,
trees: {
- [TreeId.Knotwood]: { weight: 40, maxHeight: 30 },
- [TreeId.Oak]: { weight: 20, maxHeight: 30 },
- [TreeId.Birch]: { weight: 20, maxHeight: 30 },
- [TreeId.Maple]: { weight: 40, maxHeight: 30 },
- [TreeId.Fir]: { weight: 15, maxHeight: 30 },
- [TreeId.Pine]: { weight: 15, maxHeight: 30 },
- [TreeId.ChinaPine]: { weight: 15, minHeight: 30, maxHeight: 60 },
- [TreeId.Bamboo]: { weight: 15, minHeight: 35 },
+ [TreeId.Oak]: { weight: 30, maxHeight: 60 },
+ [TreeId.Birch]: { weight: 20, maxHeight: 60 },
+ [TreeId.Pine]: { weight: 20, minHeight: 60 },
+ [TreeId.Knotwood]: { weight: 5, maxHeight: 60 },
+ [TreeId.Maple]: { weight: 5, maxHeight: 60 },
},
- density: 15,
- minSpacing: 12,
- clustering: false,
+ density: 5,
+ minSpacing: 5,
+ clustering: true,
+ clusterSize: 30,
+ clusterRadius: 100,
+ clusterSpacing: 200,
scaleVariation: [0.8, 1.2],
maxSlope: 1.5,
};
@@ -58,39 +58,42 @@ const FOREST_TREE_CONFIG: BiomeTreeConfig = {
const CANYON_TREE_CONFIG: BiomeTreeConfig = {
enabled: true,
trees: {
- [TreeId.Cactus]: { weight: 20, avoidsWaterBelow: 3 },
- [TreeId.Dead]: { weight: 20, minHeight: 20 },
+ [TreeId.Oak]: { weight: 20 },
+ [TreeId.Dead]: { weight: 25 },
[TreeId.Palm]: {
- weight: 20,
- waterAffinity: 0.3,
- waterProximityHeight: 9,
- maxHeight: 15,
+ weight: 25,
+ waterAffinity: 0.8,
+ waterSearchRadius: 100,
+ waterMaxDistance: 80,
},
- [TreeId.Coconut]: {
- weight: 10,
- waterAffinity: 0.6,
- waterProximityHeight: 9,
- maxHeight: 15,
+ [TreeId.Banana]: {
+ weight: 25,
+ waterAffinity: 0.8,
+ waterSearchRadius: 100,
+ waterMaxDistance: 80,
},
},
- density: 15,
- minSpacing: 18,
+ density: 2,
+ minSpacing: 60,
clustering: false,
scaleVariation: [0.7, 1.3],
- maxSlope: 2.0,
+ maxSlope: 0.1,
};
const TUNDRA_TREE_CONFIG: BiomeTreeConfig = {
enabled: true,
+ enableSnow: true,
trees: {
- [TreeId.WindPine]: { weight: 40, minHeight: 15 },
- [TreeId.Fir]: { weight: 30, minHeight: 10 },
- [TreeId.Pine]: { weight: 25, minHeight: 8 },
- [TreeId.Birch]: { weight: 10 },
+ [TreeId.PineSnow]: { weight: 40, minHeight: 38 },
+ [TreeId.PineDead]: { weight: 20, minHeight: 38 },
+ [TreeId.Pine]: { weight: 15, minHeight: 35 },
},
- density: 10,
- minSpacing: 12,
- clustering: false,
+ density: 5,
+ minSpacing: 5,
+ clustering: true,
+ clusterSize: 30,
+ clusterRadius: 100,
+ clusterSpacing: 200,
scaleVariation: [0.6, 1.0],
maxSlope: 1.5,
};
@@ -108,3 +111,74 @@ const BIOME_TREE_CONFIGS: Record = {
export function getTreeConfigForBiome(biomeId: string): BiomeTreeConfig {
return BIOME_TREE_CONFIGS[biomeId as BiomeType] ?? FOREST_TREE_CONFIG;
}
+
+// ---------------------------------------------------------------------------
+// Per-biome grass configs
+// ---------------------------------------------------------------------------
+
+export interface BiomeGrassConfig {
+ /** Overall density multiplier (0 = no grass, 1 = full density) */
+ density: number;
+ /** Max terrain slope (0-1, same metric as GPU shader) for grass placement */
+ maxSlope: number;
+ /** Minimum grassWeight from terrain color to allow placement */
+ minGrassWeight: number;
+ /** Blade height scale relative to global BLADE_HEIGHT_MIN/MAX */
+ heightScale: number;
+ /** Patchiness (0 = uniform spread, 1 = highly clustered islands) */
+ patchiness: number;
+ /** World-space noise frequency for patch mask (higher = smaller patches) */
+ patchScale: number;
+ /** Optional grass tint color [r, g, b] in 0-1 range. Blended over the terrain color. */
+ tintColor?: [number, number, number];
+ /** How strongly tintColor is applied (0 = terrain color, 1 = full tint). Default 0. */
+ tintStrength?: number;
+}
+
+const FOREST_GRASS_CONFIG: BiomeGrassConfig = {
+ density: 1.0,
+ maxSlope: 0.4,
+ minGrassWeight: 0.8,
+ heightScale: 1.0,
+ patchiness: 0.0,
+ patchScale: 0.02,
+};
+
+const CANYON_GRASS_CONFIG: BiomeGrassConfig = {
+ density: 1.0,
+ maxSlope: 0.15,
+ minGrassWeight: 0.8,
+ heightScale: 1.5,
+ patchiness: 0.95,
+ patchScale: 0.025,
+ tintColor: [0.35, 0.4, 0.15],
+ tintStrength: 0.4,
+};
+
+const TUNDRA_GRASS_CONFIG: BiomeGrassConfig = {
+ density: 1.0,
+ maxSlope: 0.3,
+ minGrassWeight: 0.8,
+ heightScale: 1.0,
+ patchiness: 0.6,
+ patchScale: 0.018,
+ tintColor: [1.0, 1.0, 1.0],
+ tintStrength: 0.4,
+};
+
+const BIOME_GRASS_CONFIGS: Record = {
+ [BiomeType.Forest]: FOREST_GRASS_CONFIG,
+ [BiomeType.Canyon]: CANYON_GRASS_CONFIG,
+ [BiomeType.Tundra]: TUNDRA_GRASS_CONFIG,
+};
+
+export function getGrassConfigForBiome(biomeId: string): BiomeGrassConfig {
+ return BIOME_GRASS_CONFIGS[biomeId as BiomeType] ?? FOREST_GRASS_CONFIG;
+}
+
+/** Biome IDs whose tree config has enableSnow set to true. */
+export const SNOW_BIOMES: ReadonlySet = new Set(
+ Object.entries(BIOME_TREE_CONFIGS)
+ .filter(([, cfg]) => cfg.enableSnow)
+ .map(([id]) => id),
+);
diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts
index ddb14366a..933873ef6 100644
--- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts
+++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts
@@ -11,9 +11,12 @@
import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes";
import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
-import type { RiverDefinition } from "./RiverDefinition";
-import type { RiverSegmentAABB } from "./RiverUtils";
-import { applyRiverCarvingPure, buildApplyRiverCarvingJS } from "./RiverUtils";
+import {
+ smoothstep,
+ mapRangeSmooth,
+ normalizeFbmRange,
+ pingpong,
+} from "../../../utils/NoiseGenerator";
// ---------------------------------------------------------------------------
// Core terrain generation constants
@@ -44,144 +47,180 @@ export const SHORELINE_CONFIG = {
} as const;
// ---------------------------------------------------------------------------
-// Noise layer definitions — drive getBaseHeightAt()
+// Per-biome terrain config — each biome is a complete height function
// ---------------------------------------------------------------------------
-export interface NoiseLayerDef {
- scale: number;
- weight: number;
- octaves?: number;
- persistence?: number;
- lacunarity?: number;
- /** Only for erosion noise */
- iterations?: number;
+export interface BiomeTerrainConfig {
+ seedOffset: number;
+ frequency: number;
+ amplitude: number;
+ octaves: number;
+ gain: number;
+ lacunarity: number;
+ noiseOffset: number;
+ altitude: number;
+ altitudeVariation: number;
+ erosion: number;
+ erosionSoftness: number;
+ rivers: number;
+ riverWidth: number;
+ lakes: number;
+ lakesFalloff: number;
+ heightScale: number;
+ powerCurve: number;
+ smoothLowerPlanes: number;
+ canyonMode: boolean;
+ canyonFreqScale: number;
+ canyonAmpScale: number;
+ cliffLow: number;
+ cliffHigh: number;
+ terraceSteps: number;
+ terraceStrength: number;
+ terraceSharpness: number;
+ terraceHeightScale: number;
+ terraceSlope: number;
}
-export const CONTINENT_LAYER: NoiseLayerDef = {
- scale: 0.0004,
- octaves: 5,
- persistence: 0.7,
- lacunarity: 2.0,
- weight: 0.35,
-};
-
-export const RIDGE_LAYER: NoiseLayerDef = {
- scale: 0.0015,
- weight: 0.15,
-};
+export interface BiomeNoiseAdapter {
+ simplexFbm2D(
+ x: number,
+ y: number,
+ octaves: number,
+ amplitude: number,
+ frequency: number,
+ gain: number,
+ lacunarity: number,
+ offset: number,
+ ): number;
+}
-export const HILL_LAYER: NoiseLayerDef = {
- scale: 0.008,
- octaves: 4,
- persistence: 0.6,
- lacunarity: 2.2,
- weight: 0.25,
-};
+export interface BiomeNoiseSet {
+ main: BiomeNoiseAdapter;
+ variation: BiomeNoiseAdapter;
+ erosion: BiomeNoiseAdapter;
+}
-export const EROSION_LAYER: NoiseLayerDef = {
- scale: 0.0025,
- iterations: 3,
- weight: 0.1,
-};
+// ---------------------------------------------------------------------------
+// Global terrain shaping constants
+// ---------------------------------------------------------------------------
-export const DETAIL_LAYER: NoiseLayerDef = {
- scale: 0.02,
- octaves: 2,
- persistence: 0.3,
- lacunarity: 2.5,
- weight: 0.08,
-};
+export const TERRAIN_SCALE = 32;
+export const BASE_OFFSET = 22;
+export const FEATURE_SCALE = 1.4;
-/** Power curve applied after blending noise layers and normalizing to [0,1] */
-export const HEIGHT_POWER_CURVE = 1.1;
+const NOISE_COORD_SCALE = 55 / 2450;
// ---------------------------------------------------------------------------
-// Biome noise profiles — per-biome noise weight blending (Option A)
+// Per-biome config defaults
// ---------------------------------------------------------------------------
-/** Global terrace step count — shared across all biomes to prevent boundary artifacts. */
-export const TERRACE_STEPS = 10;
-
-export interface BiomeNoiseProfile {
- continentWeight: number;
- ridgeWeight: number;
- hillWeight: number;
- erosionWeight: number;
- detailWeight: number;
- powerCurve: number;
- /** 0–1 blend from smooth to terraced (0 = disabled, higher = more visible) */
- terraceStrength: number;
- /** 0–1 flat zone per step (0.8 = 80% flat shelf, 20% cliff transition) */
- terraceSharpness: number;
- /**
- * Stretches shelf positions around the midpoint to make cliffs taller.
- * 1 = normal (5m cliffs at MAX_HEIGHT=50, TERRACE_STEPS=10)
- * 3 = 3x taller cliffs (~15m). Vary per biome for visual diversity.
- */
- terraceHeightScale: number;
- /**
- * 0–1 how much natural terrain slope is preserved on each shelf.
- * 0 = perfectly flat shelves, 0.3 = 30% of natural slope, 1 = no flattening.
- */
- terraceSlope: number;
-}
-
-export const TUNDRA_PROFILE: BiomeNoiseProfile = {
- continentWeight: 0.32,
- ridgeWeight: 0.15,
- hillWeight: 0.28,
- erosionWeight: 0.1,
- detailWeight: 0.1,
- powerCurve: 1.1,
- terraceStrength: 0.4,
- terraceSharpness: 0.7,
- terraceHeightScale: 2.5,
- terraceSlope: 0.25,
+export const FOREST_CONFIG: BiomeTerrainConfig = {
+ seedOffset: 0,
+ frequency: 0.07,
+ amplitude: 0.5,
+ octaves: 10,
+ gain: 0.5,
+ lacunarity: 2.0,
+ noiseOffset: 0.25,
+ altitude: 0.1,
+ altitudeVariation: 1.4,
+ erosion: 0.6,
+ erosionSoftness: 0.3,
+ rivers: 0.11,
+ riverWidth: 0,
+ lakes: 0.34,
+ lakesFalloff: 0.27,
+ heightScale: 2.8,
+ powerCurve: 1.0,
+ smoothLowerPlanes: 0,
+ canyonMode: false,
+ canyonFreqScale: 0.3,
+ canyonAmpScale: 1.5,
+ cliffLow: 0.5,
+ cliffHigh: 0.8,
+ terraceSteps: 10,
+ terraceStrength: 0,
+ terraceSharpness: 0,
+ terraceHeightScale: 1,
+ terraceSlope: 0,
};
-export const FOREST_PROFILE: BiomeNoiseProfile = {
- continentWeight: 0.15,
- ridgeWeight: 0.08,
- hillWeight: 0.1,
- erosionWeight: 0.05,
- detailWeight: 0.05,
- powerCurve: 1,
+export const TUNDRA_CONFIG: BiomeTerrainConfig = {
+ seedOffset: 100,
+ frequency: 0.07,
+ amplitude: 0.5,
+ octaves: 10,
+ gain: 0.5,
+ lacunarity: 2.0,
+ noiseOffset: 0.25,
+ altitude: 0.1,
+ altitudeVariation: 1.4,
+ erosion: 0.6,
+ erosionSoftness: 0.3,
+ rivers: 0,
+ riverWidth: 0,
+ lakes: 0,
+ lakesFalloff: 0,
+ heightScale: 2.8,
+ powerCurve: 1.0,
+ smoothLowerPlanes: 0,
+ canyonMode: false,
+ canyonFreqScale: 0.3,
+ canyonAmpScale: 1.5,
+ cliffLow: 0.5,
+ cliffHigh: 0.8,
+ terraceSteps: 10,
terraceStrength: 0,
terraceSharpness: 0,
terraceHeightScale: 1,
terraceSlope: 0,
};
-export const CANYON_PROFILE: BiomeNoiseProfile = {
- continentWeight: 0.32,
- ridgeWeight: 0.25,
- hillWeight: 0.18,
- erosionWeight: 0.2,
- detailWeight: 0.05,
- powerCurve: 1.45,
- terraceStrength: 0.6,
- terraceSharpness: 0.8,
- terraceHeightScale: 7,
- terraceSlope: 0.35,
+export const CANYON_CONFIG: BiomeTerrainConfig = {
+ seedOffset: 200,
+ frequency: 0.07,
+ amplitude: 0.5,
+ octaves: 10,
+ gain: 0.5,
+ lacunarity: 2.0,
+ noiseOffset: 0.25,
+ altitude: 0.0,
+ altitudeVariation: 0.8,
+ erosion: 0.0,
+ erosionSoftness: 0.3,
+ rivers: 0,
+ riverWidth: 0,
+ lakes: 0,
+ lakesFalloff: 0,
+ heightScale: 2.8,
+ powerCurve: 1.0,
+ smoothLowerPlanes: 0,
+ canyonMode: true,
+ canyonFreqScale: 0.3,
+ canyonAmpScale: 1.5,
+ cliffLow: 0.5,
+ cliffHigh: 0.8,
+ terraceSteps: 10,
+ terraceStrength: 0,
+ terraceSharpness: 0,
+ terraceHeightScale: 1,
+ terraceSlope: 0,
};
-export const BIOME_PROFILES: Record = {
- [BiomeType.Tundra]: TUNDRA_PROFILE,
- [BiomeType.Forest]: FOREST_PROFILE,
- [BiomeType.Canyon]: CANYON_PROFILE,
+export const BIOME_CONFIGS: Record = {
+ [BiomeType.Tundra]: TUNDRA_CONFIG,
+ [BiomeType.Forest]: FOREST_CONFIG,
+ [BiomeType.Canyon]: CANYON_CONFIG,
};
// ---------------------------------------------------------------------------
// Island configuration
// ---------------------------------------------------------------------------
-export const ISLAND_RADIUS = 788;
+export const ISLAND_RADIUS = 2419;
export const ISLAND_FALLOFF = 450;
export const ISLAND_DEEP_OCEAN_BUFFER = 113;
-export const BASE_ELEVATION = 0.42;
-export const OCEAN_FLOOR_HEIGHT = 0.05;
-/** height = terrain * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask */
-export const HEIGHT_TERRAIN_MIX = 0.2;
+export const OCEAN_FLOOR_HEIGHT = 2.5;
export const BEACH_PROFILE_POWER = 3.0;
// ---------------------------------------------------------------------------
@@ -197,13 +236,11 @@ export const BIOME_CONFIG = {
} as const;
// ---------------------------------------------------------------------------
-// Landscape features — mountains & ponds, independent of biomes
+// Landscape features — positioned lakes, independent of biomes
// ---------------------------------------------------------------------------
export enum LandscapeType {
- Mountain = "mountain",
- Pond = "pond",
- River = "river",
+ Lake = "lake",
}
export interface LandscapeFeatureDef {
@@ -212,85 +249,14 @@ export interface LandscapeFeatureDef {
z: number;
radius: number;
strength: number;
- layers: number;
shapePower: number;
- edgeSharpness: number;
- layerSlope: number;
noiseScale: number;
noiseAmount: number;
+ lakes: number;
+ lakesFalloff: number;
}
-/**
- * Predefined landscape features — add/remove entries here to control
- * exactly where mountains, ponds, and plateaus appear on the island.
- *
- * Algorithm: radial envelope × domain-warped noise → terrace quantization.
- * The envelope defines the feature's footprint; noise drives internal terrain.
- * Terracing follows noise contours, producing organic (non-circular) layers.
- *
- * Parameter guide:
- * layers - number of terrace levels (1 = single plateau, 6+ = tiered mountain)
- * shapePower - envelope falloff (0.3 = dome, 1 = cone, 4+ = flat-topped mesa)
- * edgeSharpness - terrace cliff sharpness (0 = smooth ramp, 1 = hard cliff)
- * layerSlope - incline within each shelf (0 = flat, 0.5 = gentle slope, 1 = full slope)
- * noiseScale - frequency of internal terrain (0.02 = broad ridges, 0.05 = fine detail)
- * noiseAmount - noise vs envelope blend (0 = smooth dome, 0.6 = organic ridges)
- */
-export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [
- {
- type: LandscapeType.Mountain,
- x: -168.5,
- z: -352.5,
- radius: 250,
- strength: 5.5,
- layers: 5,
- shapePower: 2.0,
- edgeSharpness: 0.2,
- layerSlope: 0.9,
- noiseScale: 0.025,
- noiseAmount: 0.6,
- },
- {
- type: LandscapeType.Mountain,
- x: 265.5,
- z: 322.5,
- radius: 130,
- strength: 2.5,
- layers: 5,
- shapePower: 1.3,
- edgeSharpness: 0.3,
- layerSlope: 0.55,
- noiseScale: 0.025,
- noiseAmount: 0.55,
- },
- {
- type: LandscapeType.Pond,
- x: -28.5,
- z: 327.5,
- radius: 90,
- strength: 1.5,
- layers: 1,
- shapePower: 3.5,
- edgeSharpness: 0.1,
- layerSlope: 0.8,
- noiseScale: 0.015,
- noiseAmount: 0.06,
- },
- // Elevated mountain pond — tests per-body water at elevations above sea level
- {
- type: LandscapeType.Pond,
- x: -120,
- z: -290,
- radius: 35,
- strength: 1.8,
- layers: 1,
- shapePower: 3.0,
- edgeSharpness: 0.1,
- layerSlope: 0.8,
- noiseScale: 0.015,
- noiseAmount: 0.06,
- },
-];
+export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [];
// ---------------------------------------------------------------------------
// Coastline noise — varies the island radius for irregular shoreline
@@ -322,7 +288,7 @@ export const COAST_SMALL = {
// Legacy exports — kept for backward compatibility with TerrainWorker.ts
// ---------------------------------------------------------------------------
-// Legacy POND_ constants removed — landscape features replaced the hardcoded pond.
+// Legacy lake/pond constants removed — terrain is fully procedural.
// ═══════════════════════════════════════════════════════════════════════════
// SINGLE SOURCE OF TRUTH — pure TypeScript functions
@@ -347,98 +313,135 @@ export interface TerrainNoiseAdapter {
simplex2D(x: number, z: number): number;
}
-export function applyLandscapeFeaturesPure(
- height: number,
- worldX: number,
- worldZ: number,
- features: ReadonlyArray,
- noise: TerrainNoiseAdapter,
- riverDef?: RiverDefinition,
- riverAABBs?: RiverSegmentAABB[],
-): number {
- for (let i = 0; i < features.length; i++) {
- const feat = features[i];
- const dx = worldX - feat.x;
- const dz = worldZ - feat.z;
- const dist = Math.sqrt(dx * dx + dz * dz);
-
- // Pond berm: raise terrain in a 5m ring outside the pond radius to
- // prevent small puddles forming from natural terrain noise dipping
- // below water level. Smoothly tapers off to avoid visible seams.
- if (
- feat.type === LandscapeType.Pond &&
- dist >= feat.radius &&
- dist < feat.radius + 5
- ) {
- const bermT = 1 - (dist - feat.radius) / 5;
- const minH = WATER_LEVEL_NORMALIZED + 0.005; // ~0.5m above water in world units
- if (height < minH) {
- height += (minH - height) * bermT;
- }
- continue;
- }
+// ---------------------------------------------------------------------------
+// Per-biome height functions — normal mode (reference defaultTerrain)
+// ---------------------------------------------------------------------------
- if (dist >= feat.radius) continue;
-
- const t = Math.max(0, 1 - dist / feat.radius);
- const envelope = Math.pow(t, feat.shapePower);
-
- const warpScale = feat.noiseScale * 0.4;
- const warpStr = feat.radius * feat.noiseAmount * 0.3;
- const warpX =
- noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr;
- const warpZ =
- noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) *
- warpStr;
-
- const sx = (worldX + warpX) * feat.noiseScale;
- const sz = (worldZ + warpZ) * feat.noiseScale;
-
- const ridgeN = noise.ridgeNoise2D(sx, sz);
- const detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0);
- const mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5;
-
- let rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise);
- rawH = Math.max(0, Math.min(1, rawH));
-
- let influence: number;
- if (feat.layers >= 1) {
- const stepped = Math.floor(rawH * feat.layers) / feat.layers;
- const nextStep = Math.min(1, stepped + 1 / feat.layers);
- const frac = (rawH - stepped) * feat.layers;
- const blendStart = 1 - feat.edgeSharpness;
- const edgeBlend =
- frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart);
- const flatStep = stepped + edgeBlend * (nextStep - stepped);
- const slopedStep = stepped + frac * (nextStep - stepped);
- influence = flatStep + feat.layerSlope * (slopedStep - flatStep);
- } else {
- influence = rawH;
- }
+function computeNormalHeight(
+ x: number,
+ z: number,
+ cfg: BiomeTerrainConfig,
+ ns: BiomeNoiseSet,
+ coordScale: number,
+): { y: number; water: number } {
+ const { main, variation, erosion } = ns;
+ const nx = x * coordScale;
+ const nz = z * coordScale;
+
+ let terrainNoise = main.simplexFbm2D(
+ nx,
+ nz,
+ cfg.octaves,
+ cfg.amplitude,
+ cfg.frequency,
+ cfg.gain,
+ cfg.lacunarity,
+ cfg.noiseOffset,
+ );
- if (feat.type === LandscapeType.Pond) {
- height -= influence * feat.strength;
- } else {
- height += influence * feat.strength;
- }
- }
+ const erosionVariation =
+ variation.simplexFbm2D(nx + 500, nz + 500, 1, 1.0, 0.012, 0.5, 2.0, 0) *
+ 0.6 -
+ 0.1;
+ const erosionSoft = erosionVariation + cfg.erosionSoftness;
+ let ero = erosion.simplexFbm2D(nx, nz, 3, 0.2, cfg.frequency, 0.5, 1.8, 0.3);
+ ero = smoothstep(ero, 0, 1);
+ ero = Math.pow(ero, 1 + erosionSoft);
+ ero = Math.max(0, pingpong(ero * 2, 1) - 0.3);
+ terrainNoise *= 1 - cfg.erosion + cfg.erosion * ero;
+
+ const altitudeNoise =
+ variation.simplexFbm2D(nx, nz, 1, 1.0, 0.012, 0.5, 2.0, 0) *
+ cfg.altitudeVariation -
+ 0.75;
+ terrainNoise += cfg.altitude + altitudeNoise;
+
+ const water =
+ mapRangeSmooth(
+ terrainNoise,
+ -(1 - cfg.lakes),
+ -(1 - cfg.lakes) + cfg.lakesFalloff,
+ 3,
+ 0,
+ ) * 0.2;
+
+ terrainNoise =
+ terrainNoise * terrainNoise * (1 - cfg.smoothLowerPlanes) +
+ terrainNoise * terrainNoise * terrainNoise * cfg.smoothLowerPlanes;
+
+ const y =
+ terrainNoise * (1 - Math.max(0, Math.min(1, water * cfg.rivers * 3))) +
+ -3 * Math.max(0, Math.min(1, water * cfg.rivers * 3));
+
+ return { y, water };
+}
- // River carving — applied after landscape features so the channel
- // cuts through mountains/ponds cleanly.
- if (riverDef && riverAABBs) {
- height = applyRiverCarvingPure(
- height,
- worldX,
- worldZ,
- riverDef,
- riverAABBs,
- MAX_HEIGHT,
- );
+// ---------------------------------------------------------------------------
+// Per-biome height: canyon mode (reference desertTerrain)
+// ---------------------------------------------------------------------------
+
+function computeCanyonHeight(
+ x: number,
+ z: number,
+ cfg: BiomeTerrainConfig,
+ ns: BiomeNoiseSet,
+ coordScale: number,
+): { y: number; water: number } {
+ const { main } = ns;
+ const nx = x * coordScale;
+ const nz = z * coordScale;
+
+ const canyonFbm = main.simplexFbm2D(
+ nx,
+ nz,
+ cfg.octaves,
+ cfg.amplitude * cfg.canyonAmpScale,
+ cfg.frequency * cfg.canyonFreqScale,
+ cfg.gain,
+ cfg.lacunarity,
+ cfg.noiseOffset,
+ );
+ const terrainNoise = normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset));
+
+ const cliffs = mapRangeSmooth(
+ terrainNoise,
+ cfg.cliffLow,
+ cfg.cliffHigh,
+ 0,
+ 1,
+ );
+
+ return { y: cliffs, water: 0 };
+}
+
+// ---------------------------------------------------------------------------
+// Per-biome height dispatcher + power curve
+// ---------------------------------------------------------------------------
+
+function computeBiomeHeight(
+ x: number,
+ z: number,
+ cfg: BiomeTerrainConfig,
+ ns: BiomeNoiseSet,
+ coordScale: number,
+): { y: number; water: number } {
+ const raw = cfg.canyonMode
+ ? computeCanyonHeight(x, z, cfg, ns, coordScale)
+ : computeNormalHeight(x, z, cfg, ns, coordScale);
+
+ let y = raw.y * cfg.heightScale;
+
+ if (cfg.powerCurve !== 1.0) {
+ y = Math.sign(y) * Math.pow(Math.abs(y), cfg.powerCurve);
}
- return height;
+ return { y, water: raw.water };
}
+// ---------------------------------------------------------------------------
+// Main: computeBaseHeight — blends independent per-biome heights
+// ---------------------------------------------------------------------------
+
/**
* THE height generation algorithm. Main thread calls this directly.
* Workers use the JS-string mirror (buildGetBaseHeightAtJS) which
@@ -447,115 +450,44 @@ export function applyLandscapeFeaturesPure(
export function computeBaseHeight(
worldX: number,
worldZ: number,
- noise: TerrainNoiseAdapter,
+ sharedNoise: TerrainNoiseAdapter,
+ biomeNoiseSets: Record,
biomeWeights: Record,
- features: ReadonlyArray,
- maxHeight: number,
- riverDef?: RiverDefinition,
- riverAABBs?: RiverSegmentAABB[],
): number {
- // ── 1. Sample noise layers ──────────────────────────────────────────
- const cN = noise.fractal2D(
- worldX * CONTINENT_LAYER.scale,
- worldZ * CONTINENT_LAYER.scale,
- CONTINENT_LAYER.octaves!,
- CONTINENT_LAYER.persistence!,
- CONTINENT_LAYER.lacunarity!,
- );
- const rN = noise.ridgeNoise2D(
- worldX * RIDGE_LAYER.scale,
- worldZ * RIDGE_LAYER.scale,
- );
- const hN = noise.fractal2D(
- worldX * HILL_LAYER.scale,
- worldZ * HILL_LAYER.scale,
- HILL_LAYER.octaves!,
- HILL_LAYER.persistence!,
- HILL_LAYER.lacunarity!,
- );
- const eN = noise.erosionNoise2D(
- worldX * EROSION_LAYER.scale,
- worldZ * EROSION_LAYER.scale,
- EROSION_LAYER.iterations!,
- );
- const dN = noise.fractal2D(
- worldX * DETAIL_LAYER.scale,
- worldZ * DETAIL_LAYER.scale,
- DETAIL_LAYER.octaves!,
- DETAIL_LAYER.persistence!,
- DETAIL_LAYER.lacunarity!,
- );
-
- // ── 2. Blend noise using biome-weighted profiles ────────────────────
- let cW = 0,
- rW = 0,
- hW = 0,
- eW = 0,
- dW = 0,
- pC = 0,
- tS = 0,
- tSh = 0,
- tHS = 0,
- tSl = 0;
+ // ── 1. Blend per-biome heights ──────────────────────────────────────
+ const coordScale = NOISE_COORD_SCALE * FEATURE_SCALE;
+ let height = 0;
for (const key of Object.keys(biomeWeights)) {
const w = biomeWeights[key];
- const p = BIOME_PROFILES[key] ?? BIOME_PROFILES[DEFAULT_BIOME];
- cW += p.continentWeight * w;
- rW += p.ridgeWeight * w;
- hW += p.hillWeight * w;
- eW += p.erosionWeight * w;
- dW += p.detailWeight * w;
- pC += p.powerCurve * w;
- tS += p.terraceStrength * w;
- tSh += p.terraceSharpness * w;
- tHS += p.terraceHeightScale * w;
- tSl += p.terraceSlope * w;
+ if (w < 0.01) continue;
+ const biomeCfg = BIOME_CONFIGS[key] ?? BIOME_CONFIGS[DEFAULT_BIOME];
+ const ns = biomeNoiseSets[key];
+ if (!ns) continue;
+ const result = computeBiomeHeight(worldX, worldZ, biomeCfg, ns, coordScale);
+ height += result.y * w;
}
- // ── 3. Combine, normalize, power curve ──────────────────────────────
- let height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW;
- height = (height + 1) * 0.5;
- height = Math.max(0, Math.min(1, height));
- height = Math.pow(height, pC);
-
- // ── 4. Terracing — floor-quantize into flat shelves with cliff edges ─
- // tHS stretches shelf positions around 0.5 so cliffs are taller per biome.
- // tSl blends natural slope back onto shelves (0 = flat, 1 = full natural slope).
- const steps = TERRACE_STEPS;
- const ths = Math.max(1, tHS);
- if (tS > 0.01 && steps >= 2) {
- const stepped = Math.floor(height * steps) / steps;
- const nextStep = Math.min(1, stepped + 1 / steps);
- const frac = (height - stepped) * steps;
- const edgeBlend = frac < tSh ? 0 : (frac - tSh) / (1 - tSh + 0.001);
- const flatStep = stepped + edgeBlend * (nextStep - stepped);
- const slopedStep = stepped + frac * (nextStep - stepped);
- const terraced = flatStep + tSl * (slopedStep - flatStep);
- const scaled = Math.max(0, Math.min(1, 0.5 + (terraced - 0.5) * ths));
- height = height + (scaled - height) * tS;
- }
-
- // ── 5. Coastline noise → island mask ────────────────────────────────
+ // ── 2. Coastline noise → island mask ────────────────────────────────
const distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ);
const angle = Math.atan2(worldZ, worldX);
const cnx = Math.cos(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS;
const cnz = Math.sin(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS;
- const cst1 = noise.fractal2D(
+ const cst1 = sharedNoise.fractal2D(
cnx,
cnz,
COAST_LARGE.octaves,
COAST_LARGE.persistence,
COAST_LARGE.lacunarity,
);
- const cst2 = noise.fractal2D(
+ const cst2 = sharedNoise.fractal2D(
cnx * COAST_MEDIUM.freqMultiplier,
cnz * COAST_MEDIUM.freqMultiplier,
COAST_MEDIUM.octaves,
COAST_MEDIUM.persistence,
COAST_MEDIUM.lacunarity,
);
- const cst3 = noise.simplex2D(
+ const cst3 = sharedNoise.simplex2D(
cnx * COAST_SMALL.freqMultiplier,
cnz * COAST_SMALL.freqMultiplier,
);
@@ -575,23 +507,15 @@ export function computeBaseHeight(
islandMask = 0;
}
- // ── 6. Island mask + landscape features ─────────────────────────────
- height = height * islandMask;
- height = applyLandscapeFeaturesPure(
- height,
- worldX,
- worldZ,
- features,
- noise,
- riverDef,
- riverAABBs,
- );
+ height *= islandMask;
+ height *= TERRAIN_SCALE;
+ height += BASE_OFFSET * islandMask;
if (islandMask === 0) {
height = OCEAN_FLOOR_HEIGHT;
}
- return height * maxHeight;
+ return height;
}
export interface ShorelineConfig {
@@ -686,125 +610,113 @@ export function buildComputeBiomeWeightsJS(): string {
}
/**
- * JS source — worker mirror of applyLandscapeFeaturesPure().
- * Depends on: landscapeFeatures array injected into worker scope, noise object.
+ * Bake BiomeTerrainConfig per-biome into worker JS.
*/
-export function buildApplyLandscapeFeaturesJS(): string {
- return `
- function applyLandscapeFeatures(height, worldX, worldZ) {
- for (var i = 0; i < landscapeFeatures.length; i++) {
- var feat = landscapeFeatures[i];
- var dx = worldX - feat.x;
- var dz = worldZ - feat.z;
- var dist = Math.sqrt(dx * dx + dz * dz);
-
- if (feat.type === '${LandscapeType.Pond}' && dist >= feat.radius && dist < feat.radius + 5) {
- var bermT = 1 - (dist - feat.radius) / 5;
- var minH = ${WATER_LEVEL_NORMALIZED} + 0.005;
- if (height < minH) {
- height += (minH - height) * bermT;
- }
- continue;
- }
-
- if (dist >= feat.radius) continue;
-
- var t = Math.max(0, 1 - dist / feat.radius);
- var envelope = Math.pow(t, feat.shapePower);
-
- var warpScale = feat.noiseScale * 0.4;
- var warpStr = feat.radius * feat.noiseAmount * 0.3;
- var warpX = noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr;
- var warpZ = noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) * warpStr;
-
- var sx = (worldX + warpX) * feat.noiseScale;
- var sz = (worldZ + warpZ) * feat.noiseScale;
-
- var ridgeN = noise.ridgeNoise2D(sx, sz);
- var detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0);
- var mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5;
-
- var rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise);
- rawH = Math.max(0, Math.min(1, rawH));
-
- var influence;
- if (feat.layers >= 1) {
- var stepped = Math.floor(rawH * feat.layers) / feat.layers;
- var nextStep = Math.min(1, stepped + 1 / feat.layers);
- var frac = (rawH - stepped) * feat.layers;
- var blendStart = 1 - feat.edgeSharpness;
- var edgeBlend = frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart);
- var flatStep = stepped + edgeBlend * (nextStep - stepped);
- var slopedStep = stepped + frac * (nextStep - stepped);
- influence = flatStep + feat.layerSlope * (slopedStep - flatStep);
- } else {
- influence = rawH;
- }
-
- if (feat.type === '${LandscapeType.Pond}') {
- height -= influence * feat.strength;
- } else {
- height += influence * feat.strength;
- }
- }
- if (typeof applyRiverCarving === 'function') {
- height = applyRiverCarving(height, worldX, worldZ);
- }
- return height;
- }`;
+function biomeConfigToJS(name: string, cfg: BiomeTerrainConfig): string {
+ return `BIOME_CONFIGS[${name}] = {
+ seedOffset:${cfg.seedOffset}, frequency:${cfg.frequency}, amplitude:${cfg.amplitude},
+ octaves:${cfg.octaves}, gain:${cfg.gain}, lacunarity:${cfg.lacunarity}, noiseOffset:${cfg.noiseOffset},
+ altitude:${cfg.altitude}, altitudeVariation:${cfg.altitudeVariation},
+ erosion:${cfg.erosion}, erosionSoftness:${cfg.erosionSoftness},
+ rivers:${cfg.rivers}, riverWidth:${cfg.riverWidth}, lakes:${cfg.lakes}, lakesFalloff:${cfg.lakesFalloff},
+ heightScale:${cfg.heightScale}, powerCurve:${cfg.powerCurve}, smoothLowerPlanes:${cfg.smoothLowerPlanes},
+ canyonMode:${cfg.canyonMode}, canyonFreqScale:${cfg.canyonFreqScale}, canyonAmpScale:${cfg.canyonAmpScale},
+ cliffLow:${cfg.cliffLow}, cliffHigh:${cfg.cliffHigh},
+ terraceSteps:${cfg.terraceSteps}, terraceStrength:${cfg.terraceStrength},
+ terraceSharpness:${cfg.terraceSharpness}, terraceHeightScale:${cfg.terraceHeightScale}, terraceSlope:${cfg.terraceSlope}
+ };`;
}
-// Biome profile constants baked into JS for workers
-const PROFILES_JS = `
- var BIOME_PROFILES = {};
- BIOME_PROFILES[BT_TUNDRA] = { cW: ${TUNDRA_PROFILE.continentWeight}, rW: ${TUNDRA_PROFILE.ridgeWeight}, hW: ${TUNDRA_PROFILE.hillWeight}, eW: ${TUNDRA_PROFILE.erosionWeight}, dW: ${TUNDRA_PROFILE.detailWeight}, pC: ${TUNDRA_PROFILE.powerCurve}, tS: ${TUNDRA_PROFILE.terraceStrength}, tSh: ${TUNDRA_PROFILE.terraceSharpness}, tHS: ${TUNDRA_PROFILE.terraceHeightScale}, tSl: ${TUNDRA_PROFILE.terraceSlope} };
- BIOME_PROFILES[BT_FOREST] = { cW: ${FOREST_PROFILE.continentWeight}, rW: ${FOREST_PROFILE.ridgeWeight}, hW: ${FOREST_PROFILE.hillWeight}, eW: ${FOREST_PROFILE.erosionWeight}, dW: ${FOREST_PROFILE.detailWeight}, pC: ${FOREST_PROFILE.powerCurve}, tS: ${FOREST_PROFILE.terraceStrength}, tSh: ${FOREST_PROFILE.terraceSharpness}, tHS: ${FOREST_PROFILE.terraceHeightScale}, tSl: ${FOREST_PROFILE.terraceSlope} };
- BIOME_PROFILES[BT_CANYON] = { cW: ${CANYON_PROFILE.continentWeight}, rW: ${CANYON_PROFILE.ridgeWeight}, hW: ${CANYON_PROFILE.hillWeight}, eW: ${CANYON_PROFILE.erosionWeight}, dW: ${CANYON_PROFILE.detailWeight}, pC: ${CANYON_PROFILE.powerCurve}, tS: ${CANYON_PROFILE.terraceStrength}, tSh: ${CANYON_PROFILE.terraceSharpness}, tHS: ${CANYON_PROFILE.terraceHeightScale}, tSl: ${CANYON_PROFILE.terraceSlope} };
+const BIOME_CONFIGS_JS = `
+ var BIOME_CONFIGS = {};
+ ${biomeConfigToJS("BT_TUNDRA", TUNDRA_CONFIG)}
+ ${biomeConfigToJS("BT_FOREST", FOREST_CONFIG)}
+ ${biomeConfigToJS("BT_CANYON", CANYON_CONFIG)}
`;
/**
- * JS source — worker mirror of computeBaseHeight().
- * Accepts biome weights and blends noise per-biome.
- * MUST stay in sync with computeBaseHeight() above.
+ * JS source — worker mirror of computeBaseHeight() and per-biome height functions.
+ * MUST stay in sync with the TS functions above.
+ *
+ * Expects in worker scope: noise, biomeNoiseSets, BIOME_CONFIGS, BT_DEFAULT,
+ * computeBiomeWeightsByPosition, applyLandscapeFeatures, landscapeFeatures.
*/
export function buildGetBaseHeightAtJS(): string {
return `
- ${PROFILES_JS}
+ ${BIOME_CONFIGS_JS}
- function getBaseHeightAt(worldX, worldZ, biomeWeights) {
- var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ);
+ var NOISE_COORD_SCALE = ${NOISE_COORD_SCALE};
+ var TERRAIN_SCALE_VAL = ${TERRAIN_SCALE};
+ var BASE_OFFSET_VAL = ${BASE_OFFSET};
+ var FEATURE_SCALE_VAL = ${FEATURE_SCALE};
- var cN = noise.fractal2D(worldX * ${CONTINENT_LAYER.scale}, worldZ * ${CONTINENT_LAYER.scale}, ${CONTINENT_LAYER.octaves}, ${CONTINENT_LAYER.persistence}, ${CONTINENT_LAYER.lacunarity});
- var rN = noise.ridgeNoise2D(worldX * ${RIDGE_LAYER.scale}, worldZ * ${RIDGE_LAYER.scale});
- var hN = noise.fractal2D(worldX * ${HILL_LAYER.scale}, worldZ * ${HILL_LAYER.scale}, ${HILL_LAYER.octaves}, ${HILL_LAYER.persistence}, ${HILL_LAYER.lacunarity});
- var eN = noise.erosionNoise2D(worldX * ${EROSION_LAYER.scale}, worldZ * ${EROSION_LAYER.scale}, ${EROSION_LAYER.iterations});
- var dN = noise.fractal2D(worldX * ${DETAIL_LAYER.scale}, worldZ * ${DETAIL_LAYER.scale}, ${DETAIL_LAYER.octaves}, ${DETAIL_LAYER.persistence}, ${DETAIL_LAYER.lacunarity});
+ function _smoothstep(x, edge0, edge1) {
+ var t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
+ return t * t * (3 - 2 * t);
+ }
+ function _mapRangeSmooth(val, a1, a2, b1, b2) {
+ return b1 + _smoothstep(val, a1, a2) * (b2 - b1);
+ }
+ function _normalizeFbmRange(fbmNoise) {
+ return Math.min(1, Math.max(0, (fbmNoise + 0.4) / 1.3));
+ }
+ function _pingpong(x, length) {
+ var t = x % (length * 2);
+ return length - Math.abs(t - length);
+ }
- var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tSh = 0, tHS = 0, tSl = 0;
- for (var key in bw) {
- var w = bw[key];
- var p = BIOME_PROFILES[key] || BIOME_PROFILES[BT_DEFAULT];
- cW += p.cW * w; rW += p.rW * w; hW += p.hW * w; eW += p.eW * w; dW += p.dW * w;
- pC += p.pC * w; tS += p.tS * w; tSh += p.tSh * w; tHS += p.tHS * w; tSl += p.tSl * w;
+ function computeNormalHeight(x, z, cfg, ns, coordScale) {
+ var nx = x * coordScale;
+ var nz = z * coordScale;
+ var terrainNoise = ns.main.simplexFbm2D(nx, nz, cfg.octaves, cfg.amplitude, cfg.frequency, cfg.gain, cfg.lacunarity, cfg.noiseOffset);
+
+ var erosionVariation = ns.variation.simplexFbm2D(nx + 500, nz + 500, 1, 1.0, 0.012, 0.5, 2.0, 0) * 0.6 - 0.1;
+ var erosionSoft = erosionVariation + cfg.erosionSoftness;
+ var ero = ns.erosion.simplexFbm2D(nx, nz, 3, 0.2, cfg.frequency, 0.5, 1.8, 0.3);
+ ero = _smoothstep(ero, 0, 1);
+ ero = Math.pow(ero, 1 + erosionSoft);
+ ero = Math.max(0, _pingpong(ero * 2, 1) - 0.3);
+ terrainNoise *= (1 - cfg.erosion) + cfg.erosion * ero;
+
+ var altitudeNoise = ns.variation.simplexFbm2D(nx, nz, 1, 1.0, 0.012, 0.5, 2.0, 0) * cfg.altitudeVariation - 0.75;
+ terrainNoise += cfg.altitude + altitudeNoise;
+
+ var water = _mapRangeSmooth(terrainNoise, -(1 - cfg.lakes), -(1 - cfg.lakes) + cfg.lakesFalloff, 3, 0) * 0.2;
+ terrainNoise = terrainNoise * terrainNoise * (1 - cfg.smoothLowerPlanes) + terrainNoise * terrainNoise * terrainNoise * cfg.smoothLowerPlanes;
+ var y = terrainNoise * (1 - Math.max(0, Math.min(1, water * cfg.rivers * 3))) + (-3) * Math.max(0, Math.min(1, water * cfg.rivers * 3));
+ return { y: y, water: water };
+ }
+
+ function computeCanyonHeight(x, z, cfg, ns, coordScale) {
+ var nx = x * coordScale;
+ var nz = z * coordScale;
+ var canyonFbm = ns.main.simplexFbm2D(nx, nz, cfg.octaves, cfg.amplitude * cfg.canyonAmpScale, cfg.frequency * cfg.canyonFreqScale, cfg.gain, cfg.lacunarity, cfg.noiseOffset);
+ var terrainNoise = _normalizeFbmRange(Math.abs(canyonFbm - cfg.noiseOffset));
+ var cliffs = _mapRangeSmooth(terrainNoise, cfg.cliffLow, cfg.cliffHigh, 0, 1);
+ return { y: cliffs, water: 0 };
+ }
+
+ function computeBiomeHeight(x, z, cfg, ns, coordScale) {
+ var raw = cfg.canyonMode ? computeCanyonHeight(x, z, cfg, ns, coordScale) : computeNormalHeight(x, z, cfg, ns, coordScale);
+ var y = raw.y * cfg.heightScale;
+ if (cfg.powerCurve !== 1.0) {
+ y = (y >= 0 ? 1 : -1) * Math.pow(Math.abs(y), cfg.powerCurve);
}
+ return { y: y, water: raw.water };
+ }
- var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW;
- height = (height + 1) * 0.5;
- height = Math.max(0, Math.min(1, height));
- height = Math.pow(height, pC);
-
- var gSteps = ${TERRACE_STEPS};
- var ths = Math.max(1, tHS);
- if (tS > 0.01 && gSteps >= 2) {
- var gStepped = Math.floor(height * gSteps) / gSteps;
- var gNextStep = Math.min(1, gStepped + 1 / gSteps);
- var gFrac = (height - gStepped) * gSteps;
- var gEdgeBlend = gFrac < tSh ? 0 : (gFrac - tSh) / (1 - tSh + 0.001);
- var gFlatStep = gStepped + gEdgeBlend * (gNextStep - gStepped);
- var gSlopedStep = gStepped + gFrac * (gNextStep - gStepped);
- var gTerraced = gFlatStep + tSl * (gSlopedStep - gFlatStep);
- var gScaled = Math.max(0, Math.min(1, 0.5 + (gTerraced - 0.5) * ths));
- height = height + (gScaled - height) * tS;
+ function getBaseHeightAt(worldX, worldZ, biomeWeights) {
+ var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ);
+ var coordScale = NOISE_COORD_SCALE * FEATURE_SCALE_VAL;
+ var height = 0;
+ for (var key in bw) {
+ var w = bw[key];
+ if (w < 0.01) continue;
+ var biomeCfg = BIOME_CONFIGS[key] || BIOME_CONFIGS[BT_DEFAULT];
+ var ns = biomeNoiseSets[key];
+ if (!ns) continue;
+ var result = computeBiomeHeight(worldX, worldZ, biomeCfg, ns, coordScale);
+ height += result.y * w;
}
var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ);
@@ -827,10 +739,11 @@ export function buildGetBaseHeightAtJS(): string {
islandMask = 0;
}
- height = height * islandMask;
- height = applyLandscapeFeatures(height, worldX, worldZ);
+ height *= islandMask;
+ height *= TERRAIN_SCALE_VAL;
+ height += BASE_OFFSET_VAL * islandMask;
if (islandMask === 0) { height = ${OCEAN_FLOOR_HEIGHT}; }
- return height * MAX_HEIGHT;
+ return height;
}`;
}
diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts
index 52b1e7f60..322a775dc 100644
--- a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts
+++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts
@@ -333,6 +333,25 @@ export interface QuadTreeListener {
onNodeDestroyGeometry(node: TerrainQuadNode): void;
}
+/**
+ * Forwards quad-tree events to multiple listeners (e.g. terrain + water).
+ */
+export class CompositeQuadTreeListener implements QuadTreeListener {
+ private listeners: QuadTreeListener[] = [];
+
+ add(listener: QuadTreeListener): void {
+ this.listeners.push(listener);
+ }
+
+ onNodeNeedsGeometry(node: TerrainQuadNode): void {
+ for (const l of this.listeners) l.onNodeNeedsGeometry(node);
+ }
+
+ onNodeDestroyGeometry(node: TerrainQuadNode): void {
+ for (const l of this.listeners) l.onNodeDestroyGeometry(node);
+ }
+}
+
/**
* Manages the top-level quad-tree: root chunk grid, split/unsplit,
* neighbor resolution, and player tracking.
diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts
index 05087786f..48a7adb94 100644
--- a/packages/shared/src/systems/shared/world/TerrainShader.ts
+++ b/packages/shared/src/systems/shared/world/TerrainShader.ts
@@ -37,64 +37,121 @@ import {
abs,
sin,
cos,
+ pow,
+ clamp,
+ max,
+ floor,
+ normalize,
Fn,
output,
type ShaderNode,
} from "../../../extras/three/three";
import { getRoadInfluenceTextureState } from "./RoadInfluenceMask";
import { getLamppostLightTextureState } from "./LamppostLightMask";
+import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig";
-import { applyTerrainSunShade } from "./GPUMaterials";
+import { SUN_LIGHT, SUN_SHADE } from "./LightingConfig";
export const TERRAIN_SHADER_CONSTANTS = {
TRIPLANAR_SCALE: 0.5,
- SNOW_HEIGHT: 50.0,
+ SNOW_HEIGHT: 90.0,
NOISE_SCALE: 0.0008,
DIRT_THRESHOLD: 0.5,
LOD_FULL_DETAIL: 100.0,
LOD_MEDIUM_DETAIL: 200.0,
- WATER_LEVEL: 5.0,
+ WATER_LEVEL: TERRAIN_CONSTANTS.WATER_THRESHOLD,
+ DISTORT_NOISE_SCALE: 0.067,
+ VARIATION_NOISE_SCALE: 0.0015,
+ ROCK_DISTORT_STRENGTH: 0.5,
+ HEIGHT_DISTORT_STRENGTH: 8.0,
+ SATURATION_BOOST: 1.35,
};
-const TERRAIN_TEX_TILE = 0.08;
+/**
+ * Half-lambert anime shade: wraps N·L to [0,1] for soft fill, then
+ * tints the shadow side with a cool blue-teal hue shift (Genshin-style).
+ * Applied to albedo before PBR so the colour shift survives lighting.
+ */
+export const TERRAIN_SHADE = {
+ TINT_COLOR: SUN_SHADE.TINT_COLOR,
+ STRENGTH: 0.7,
+ FRESNEL_POWER: 3.0,
+ FRESNEL_INTENSITY: 0.2,
+};
+
+/**
+ * Shared TSL anime shading: half-lambert cool tint + fresnel rim highlight.
+ * Used by both terrain and grass so the shading stays in sync.
+ */
+export function applyAnimeShade(
+ baseColor: any,
+ normal: any,
+ sunDirNode: any,
+): any {
+ const sDir = normalize(vec3(sunDirNode));
+ const NdotL = dot(normal, sDir);
+ const halfLambert = add(mul(NdotL, float(0.5)), float(0.5));
+ const shadeFactor = sub(float(1.0), halfLambert);
+ const coolTint = vec3(...TERRAIN_SHADE.TINT_COLOR);
+ const tintedBase = mul(baseColor, coolTint);
+ const shaded = mix(
+ baseColor,
+ tintedBase,
+ mul(shadeFactor, float(TERRAIN_SHADE.STRENGTH)),
+ );
+
+ const viewDir = normalize(sub(positionWorld, cameraPosition));
+ const rim = clamp(
+ add(float(1.0), dot(viewDir, normal)),
+ float(0.0),
+ float(1.0),
+ );
+ const fresnelRim = mul(
+ pow(rim, float(TERRAIN_SHADE.FRESNEL_POWER)),
+ float(TERRAIN_SHADE.FRESNEL_INTENSITY),
+ );
+ return add(shaded, vec3(fresnelRim, fresnelRim, fresnelRim));
+}
+
+const TERRAIN_TEX_TILE = 0.3;
const TERRAIN_TEX_DIR = "textures/terrain-biomes";
const TERRAIN_BIOME_TEXTURES = {
grass: {
file: "grass.png",
- fallback: [0.3, 0.58, 0.15] as [number, number, number],
+ fallback: [0.39, 0.63, 0.2] as [number, number, number],
},
dirt: {
file: "dirt.png",
- fallback: [0.35, 0.24, 0.12] as [number, number, number],
+ fallback: [0.82, 0.64, 0.34] as [number, number, number],
},
cliff: {
file: "cliff.png",
- fallback: [0.4, 0.38, 0.32] as [number, number, number],
+ fallback: [0.71, 0.67, 0.6] as [number, number, number],
},
desertGrass: {
file: "desertGrass.png",
- fallback: [0.82, 0.52, 0.28] as [number, number, number],
+ fallback: [0.51, 0.41, 0.28] as [number, number, number],
},
desertDirt: {
file: "desertDirt.png",
- fallback: [0.62, 0.28, 0.15] as [number, number, number],
+ fallback: [0.54, 0.42, 0.32] as [number, number, number],
},
desertCliff: {
- file: "desertCliff.png",
- fallback: [0.72, 0.38, 0.18] as [number, number, number],
+ file: "desertDirt.png",
+ fallback: [0.54, 0.42, 0.32] as [number, number, number],
},
snowGrass: {
file: "snowgrass.png",
- fallback: [0.78, 0.82, 0.85] as [number, number, number],
+ fallback: [0.79, 0.8, 0.8] as [number, number, number],
},
snowDirt: {
file: "snowdirt.png",
- fallback: [0.55, 0.55, 0.58] as [number, number, number],
+ fallback: [0.78, 0.82, 0.84] as [number, number, number],
},
snowCliff: {
file: "snowdirt.png",
- fallback: [0.5, 0.52, 0.56] as [number, number, number],
+ fallback: [0.78, 0.82, 0.84] as [number, number, number],
},
};
@@ -165,6 +222,8 @@ function createTerrainBiomeTex(
// --- Tundra palette: snowy white-blue with frozen grey stone ---
const TUNDRA_GRASS = vec3(0.78, 0.82, 0.85);
const TUNDRA_GRASS_DARK = vec3(0.65, 0.7, 0.75);
+const TUNDRA_GRASS_HIGH = vec3(0.68, 0.72, 0.78);
+const TUNDRA_VARIATION = vec3(0.6, 0.64, 0.7);
const TUNDRA_DIRT = vec3(0.55, 0.55, 0.58);
const TUNDRA_DIRT_DARK = vec3(0.42, 0.42, 0.45);
const TUNDRA_CLIFF = vec3(0.5, 0.52, 0.56);
@@ -173,6 +232,8 @@ const TUNDRA_CLIFF_DARK = vec3(0.38, 0.4, 0.44);
// --- Forest palette: vibrant energetic greens with warm brown earth ---
const FOREST_GRASS = vec3(0.3, 0.58, 0.15);
const FOREST_GRASS_DARK = vec3(0.18, 0.42, 0.08);
+const FOREST_GRASS_HIGH = vec3(0.24, 0.45, 0.18);
+const FOREST_VARIATION = vec3(0.15, 0.35, 0.1);
const FOREST_DIRT = vec3(0.35, 0.24, 0.12);
const FOREST_DIRT_DARK = vec3(0.22, 0.15, 0.08);
const FOREST_CLIFF = vec3(0.4, 0.38, 0.32);
@@ -181,11 +242,15 @@ const FOREST_CLIFF_DARK = vec3(0.28, 0.26, 0.22);
// --- Canyon palette: red-orange sand with deep crimson rock ---
const CANYON_SAND = vec3(0.82, 0.52, 0.28);
const CANYON_SAND_DARK = vec3(0.72, 0.42, 0.2);
+const CANYON_SAND_HIGH = vec3(0.62, 0.38, 0.22);
+const CANYON_VARIATION = vec3(0.58, 0.34, 0.16);
const CANYON_ROCK = vec3(0.62, 0.28, 0.15);
const CANYON_ROCK_DARK = vec3(0.48, 0.2, 0.1);
const CANYON_CLIFF = vec3(0.72, 0.38, 0.18);
const CANYON_CLIFF_DARK = vec3(0.55, 0.25, 0.12);
+const CLIFF_TINT = vec3(0.28, 0.3, 0.36);
+
// Legacy aliases used by road overlay and other shader sections (default = forest)
const GRASS_GREEN = FOREST_GRASS;
const GRASS_DARK = FOREST_GRASS_DARK;
@@ -200,28 +265,42 @@ const WATER_EDGE = vec3(0.08, 0.06, 0.04);
/**
* Compute the procedural terrain base color at a world position.
- * This is the exact same logic the terrain shader uses (height + slope + noise),
- * extracted so the tree shader can call it for ground-blending.
- *
- * @param height - positionWorld.y
- * @param slope - 1 - abs(normalWorld.y) (0 = flat, 1 = vertical)
- * @param noiseVal - primary Perlin noise sample (noiseTex @ worldXZ * NOISE_SCALE)
- * @param noiseVal2 - derived noise: sin(noiseVal * 6.28) * 0.3 + 0.5
- * @param forestWeight - biome weight for forest [0..1]
- * @param canyonWeight - biome weight for canyon [0..1]
+ * Uses noise-distorted coordinate mapping (ported from reference) for organic
+ * cliff/dirt/shoreline boundaries instead of clean smoothstep bands.
*/
export function computeTerrainBaseColor(
- height: ShaderNode,
- slope: ShaderNode,
- noiseVal: ShaderNode,
- noiseVal2: ShaderNode,
- forestWeight?: ShaderNode,
- canyonWeight?: ShaderNode,
+ height: any,
+ slope: any,
+ noiseVal: any,
+ noiseVal2: any,
+ distortNoise: any,
+ variationNoise: any,
+ forestWeight?: any,
+ canyonWeight?: any,
) {
const fW = forestWeight ?? float(0.0);
const dW = canyonWeight ?? float(0.0);
const tW = sub(float(1.0), add(fW, dW));
+ // Distorted slope: offset normalY by noise for organic cliff edges
+ const distortedNY = add(
+ sub(float(1.0), slope),
+ mul(
+ sub(distortNoise, float(0.5)),
+ float(TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH),
+ ),
+ );
+ const dSlope = sub(float(1.0), distortedNY);
+
+ // Distorted height for organic sand/shoreline boundaries
+ const dHeight = add(
+ height,
+ mul(
+ sub(distortNoise, float(0.5)),
+ float(TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH),
+ ),
+ );
+
// Biome-blended grass
const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2);
const tundraGrass = mix(TUNDRA_GRASS, TUNDRA_GRASS_DARK, grassVariation);
@@ -232,13 +311,30 @@ export function computeTerrainBaseColor(
mul(canyonGrass, dW),
);
- // Biome-blended dirt
- const dirtPatchFactor = smoothstep(
- float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05),
- float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15),
- noiseVal,
+ // Height-based ground color gradient (subtle shift at altitude)
+ const heightGrad = mul(
+ smoothstep(float(25.0), float(55.0), height),
+ float(0.3),
+ );
+ const grassHigh = add(
+ add(mul(TUNDRA_GRASS_HIGH, tW), mul(FOREST_GRASS_HIGH, fW)),
+ mul(CANYON_SAND_HIGH, dW),
+ );
+ c = mix(c, grassHigh, heightGrad);
+
+ // Low-frequency ground variation overlay (patchy color areas)
+ const gVar = clamp(
+ pow(add(variationNoise, float(0.3)), float(5.0)),
+ float(0.0),
+ float(1.0),
+ );
+ const varColor = add(
+ add(mul(TUNDRA_VARIATION, tW), mul(FOREST_VARIATION, fW)),
+ mul(CANYON_VARIATION, dW),
);
- const flatnessFactor = smoothstep(float(0.3), float(0.05), slope);
+ c = mix(c, varColor, mul(gVar, float(0.25)));
+
+ // Biome-blended dirt
const dirtVariation = smoothstep(float(0.3), float(0.7), noiseVal2);
const tundraDirt = mix(TUNDRA_DIRT, TUNDRA_DIRT_DARK, dirtVariation);
const forestDirt = mix(FOREST_DIRT, FOREST_DIRT_DARK, dirtVariation);
@@ -247,51 +343,70 @@ export function computeTerrainBaseColor(
add(mul(tundraDirt, tW), mul(forestDirt, fW)),
mul(canyonDirt, dW),
);
- c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor));
- // Slope-based dirt — fades out at steep slopes where cliff color takes over
- const dirtSlopeFactor = mul(
- smoothstep(float(0.15), float(0.4), slope),
- smoothstep(float(0.6), float(0.3), slope),
- );
- c = mix(c, dirtColor, mul(dirtSlopeFactor, float(0.6)));
-
- // Per-biome cliff color on steep slopes (terrace sides, rock faces)
+ // Per-biome cliff color with rock texture variation
const cliffVariation = smoothstep(float(0.3), float(0.7), noiseVal);
const tundraCliff = mix(TUNDRA_CLIFF, TUNDRA_CLIFF_DARK, cliffVariation);
const forestCliff = mix(FOREST_CLIFF, FOREST_CLIFF_DARK, cliffVariation);
const canyonCliff = mix(CANYON_CLIFF, CANYON_CLIFF_DARK, cliffVariation);
- const cliffColor = add(
+ let cliffColor: any = add(
add(mul(tundraCliff, tW), mul(forestCliff, fW)),
mul(canyonCliff, dW),
);
- c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope));
+ const rockTexVar = mul(pow(distortNoise, float(0.5)), float(0.3));
+ cliffColor = mix(cliffColor, CLIFF_TINT, rockTexVar);
+
+ // Noise-driven dirt patches on flat areas (using distorted slope)
+ const nDirtFactor = mul(
+ smoothstep(
+ float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05),
+ float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15),
+ noiseVal,
+ ),
+ smoothstep(float(0.3), float(0.05), dSlope),
+ );
+ c = mix(c, dirtColor, nDirtFactor);
- // Sand near water (flat areas, stronger in canyon)
+ // Dirt on moderate slopes (bell curve, using distorted slope)
+ const dirtSlopeF = mul(
+ smoothstep(float(0.15), float(0.4), dSlope),
+ smoothstep(float(0.6), float(0.3), dSlope),
+ );
+ c = mix(c, dirtColor, mul(dirtSlopeF, float(0.6)));
+
+ // Cliff on steep slopes (using distorted slope)
+ c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), dSlope));
+
+ // Sand near water (flat areas, stronger in canyon — using distorted height)
const sandBlend = mul(
- smoothstep(float(10.0), float(6.0), height),
+ smoothstep(float(18.0), float(12.0), dHeight),
smoothstep(float(0.25), float(0.0), slope),
);
const sandStrength = mix(float(0.6), float(0.9), dW);
c = mix(c, SAND_YELLOW, mul(sandBlend, sandStrength));
- // Shoreline transitions
+ // Shoreline transitions (using distorted height)
c = mix(
c,
DIRT_DARK,
- mul(smoothstep(float(14.0), float(8.0), height), float(0.4)),
+ mul(smoothstep(float(22.0), float(14.0), dHeight), float(0.4)),
);
c = mix(
c,
MUD_BROWN,
- mul(smoothstep(float(9.0), float(6.0), height), float(0.7)),
+ mul(smoothstep(float(15.0), float(10.0), dHeight), float(0.7)),
);
c = mix(
c,
WATER_EDGE,
- mul(smoothstep(float(6.5), float(5.0), height), float(0.9)),
+ mul(smoothstep(float(11.0), float(7.0), dHeight), float(0.9)),
);
+ // Saturation boost: pull color away from grey
+ const luma = dot(c, vec3(0.299, 0.587, 0.114));
+ const grey = vec3(luma, luma, luma);
+ c = mix(grey, c, float(TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST));
+
return c;
}
@@ -617,6 +732,295 @@ export function calculateSlope(
return slope;
}
+// ============================================================================
+// CPU TERRAIN COLOR — mirrors computeTerrainBaseColor() for grass placement
+// ============================================================================
+
+type RGB = { r: number; g: number; b: number };
+
+// sRGB channel → linear. GPU auto-converts SRGBColorSpace textures to linear
+// before any math. All CPU constants must also be in linear so the blending
+// (mixRGB / blendBiome / darken) produces identical results to the GPU shader.
+function srgbCh(c: number): number {
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+}
+const lin = (r: number, g: number, b: number): RGB => ({
+ r: srgbCh(r),
+ g: srgbCh(g),
+ b: srgbCh(b),
+});
+
+// GPU shader darkens textures by multiplying with TEX_DARKEN (0.65).
+const TEX_DARKEN_CPU = 0.65;
+const darken = (c: RGB): RGB => ({
+ r: c.r * TEX_DARKEN_CPU,
+ g: c.g * TEX_DARKEN_CPU,
+ b: c.b * TEX_DARKEN_CPU,
+});
+
+// Texture-matching constants: sRGB fallback values → linear via lin().
+// Non-texture constants: raw linear values matching GPU vec3() exactly.
+
+// Tundra/snow — snowgrass.png avg sRGB (0.79, 0.80, 0.80)
+const _TUNDRA_GRASS: RGB = lin(0.79, 0.8, 0.8);
+const _TUNDRA_GRASS_DARK: RGB = darken(_TUNDRA_GRASS);
+const _TUNDRA_GRASS_HIGH: RGB = { r: 0.68, g: 0.72, b: 0.78 };
+const _TUNDRA_VARIATION: RGB = { r: 0.6, g: 0.64, b: 0.7 };
+// snowdirt.png avg sRGB (0.78, 0.82, 0.84)
+const _TUNDRA_DIRT: RGB = lin(0.78, 0.82, 0.84);
+const _TUNDRA_DIRT_DARK: RGB = darken(_TUNDRA_DIRT);
+const _TUNDRA_CLIFF: RGB = lin(0.78, 0.82, 0.84);
+const _TUNDRA_CLIFF_DARK: RGB = darken(_TUNDRA_CLIFF);
+
+// Forest — grass.png avg sRGB (0.39, 0.63, 0.20)
+const _FOREST_GRASS: RGB = lin(0.39, 0.63, 0.2);
+const _FOREST_GRASS_DARK: RGB = darken(_FOREST_GRASS);
+const _FOREST_GRASS_HIGH: RGB = { r: 0.24, g: 0.45, b: 0.18 };
+const _FOREST_VARIATION: RGB = { r: 0.15, g: 0.35, b: 0.1 };
+// dirt.png avg sRGB (0.82, 0.64, 0.34)
+const _FOREST_DIRT: RGB = lin(0.82, 0.64, 0.34);
+const _FOREST_DIRT_DARK: RGB = darken(_FOREST_DIRT);
+// cliff.png avg sRGB (0.71, 0.67, 0.60)
+const _FOREST_CLIFF: RGB = lin(0.71, 0.67, 0.6);
+const _FOREST_CLIFF_DARK: RGB = darken(_FOREST_CLIFF);
+
+// Canyon/desert — desertGrass.png avg sRGB (0.51, 0.41, 0.28)
+const _CANYON_SAND: RGB = lin(0.51, 0.41, 0.28);
+const _CANYON_SAND_DARK: RGB = darken(_CANYON_SAND);
+const _CANYON_SAND_HIGH: RGB = { r: 0.62, g: 0.38, b: 0.22 };
+const _CANYON_VARIATION: RGB = { r: 0.58, g: 0.34, b: 0.16 };
+// desertDirt.png avg sRGB (0.54, 0.42, 0.32)
+const _CANYON_ROCK: RGB = lin(0.54, 0.42, 0.32);
+const _CANYON_ROCK_DARK: RGB = darken(_CANYON_ROCK);
+const _CANYON_CLIFF: RGB = lin(0.54, 0.42, 0.32);
+const _CANYON_CLIFF_DARK: RGB = darken(_CANYON_CLIFF);
+
+const _CLIFF_TINT: RGB = { r: 0.28, g: 0.3, b: 0.36 };
+const _SAND_YELLOW: RGB = { r: 0.7, g: 0.6, b: 0.38 };
+const _DIRT_DARK_CPU: RGB = { r: 0.22, g: 0.15, b: 0.08 };
+const _MUD_BROWN: RGB = { r: 0.18, g: 0.12, b: 0.08 };
+const _WATER_EDGE: RGB = { r: 0.08, g: 0.06, b: 0.04 };
+
+function mixRGB(a: RGB, b: RGB, t: number): RGB {
+ return {
+ r: a.r + (b.r - a.r) * t,
+ g: a.g + (b.g - a.g) * t,
+ b: a.b + (b.b - a.b) * t,
+ };
+}
+
+function blendBiome(
+ tundra: RGB,
+ forest: RGB,
+ canyon: RGB,
+ tW: number,
+ fW: number,
+ dW: number,
+): RGB {
+ return {
+ r: tundra.r * tW + forest.r * fW + canyon.r * dW,
+ g: tundra.g * tW + forest.g * fW + canyon.g * dW,
+ b: tundra.b * tW + forest.b * fW + canyon.b * dW,
+ };
+}
+
+function sampleNoiseCPU(worldX: number, worldZ: number, scale: number): number {
+ const tex = cachedNoiseTexture;
+ if (tex?.image?.data) {
+ const data = tex.image.data as Uint8Array;
+ const u = worldX * scale;
+ const v = worldZ * scale;
+ // Bilinear sample matching GPU's LinearFilter + RepeatWrapping
+ const px = (((u % 1) + 1) % 1) * NOISE_SIZE - 0.5;
+ const py = (((v % 1) + 1) % 1) * NOISE_SIZE - 0.5;
+ const x0 = Math.floor(px);
+ const y0 = Math.floor(py);
+ const fx = px - x0;
+ const fy = py - y0;
+ const ix0 = ((x0 % NOISE_SIZE) + NOISE_SIZE) % NOISE_SIZE;
+ const iy0 = ((y0 % NOISE_SIZE) + NOISE_SIZE) % NOISE_SIZE;
+ const ix1 = (ix0 + 1) % NOISE_SIZE;
+ const iy1 = (iy0 + 1) % NOISE_SIZE;
+ const v00 = data[(iy0 * NOISE_SIZE + ix0) * 4] / 255;
+ const v10 = data[(iy0 * NOISE_SIZE + ix1) * 4] / 255;
+ const v01 = data[(iy1 * NOISE_SIZE + ix0) * 4] / 255;
+ const v11 = data[(iy1 * NOISE_SIZE + ix1) * 4] / 255;
+ return (
+ v00 * (1 - fx) * (1 - fy) +
+ v10 * fx * (1 - fy) +
+ v01 * (1 - fx) * fy +
+ v11 * fx * fy
+ );
+ }
+ // Fallback: direct computation if texture not yet generated
+ if (!cachedPerm) cachedPerm = createPermutation(12345);
+ const u = worldX * scale;
+ const v = worldZ * scale;
+ const wu = u - Math.floor(u);
+ const wv = v - Math.floor(v);
+ return (seamlessFbm(wu, wv, cachedPerm, 4) + 1) * 0.5;
+}
+
+/**
+ * CPU mirror of the GPU `computeTerrainBaseColor()`.
+ * Returns the procedural terrain color AND a grassWeight (0-1) indicating
+ * how much of the surface is "grass texture" vs dirt/cliff/sand/shoreline.
+ */
+export function computeTerrainColorCPU(
+ worldX: number,
+ worldZ: number,
+ height: number,
+ slope: number,
+ forestW: number,
+ canyonW: number,
+): { r: number; g: number; b: number; grassWeight: number } {
+ const fW = forestW;
+ const dW = canyonW;
+ const tW = 1 - fW - dW;
+
+ const noiseVal = sampleNoiseCPU(
+ worldX,
+ worldZ,
+ TERRAIN_SHADER_CONSTANTS.NOISE_SCALE,
+ );
+ const noiseVal2 = Math.sin(noiseVal * 6.28) * 0.3 + 0.5;
+ const distortN = sampleNoiseCPU(
+ worldX,
+ worldZ,
+ TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE,
+ );
+ const variationN = sampleNoiseCPU(
+ worldX,
+ worldZ,
+ TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE,
+ );
+
+ const distortedNY =
+ 1 -
+ slope +
+ (distortN - 0.5) * TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH;
+ const dSlope = 1 - distortedNY;
+ const dHeight =
+ height +
+ (distortN - 0.5) * TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH;
+
+ // Biome-blended grass
+ const grassVar = smoothstepCPU(0.4, 0.6, noiseVal2);
+ const tundraGrass = mixRGB(_TUNDRA_GRASS, _TUNDRA_GRASS_DARK, grassVar);
+ const forestGrass = mixRGB(_FOREST_GRASS, _FOREST_GRASS_DARK, grassVar);
+ const canyonGrass = mixRGB(_CANYON_SAND, _CANYON_SAND_DARK, grassVar);
+ let c = blendBiome(tundraGrass, forestGrass, canyonGrass, tW, fW, dW);
+
+ // Height-based gradient
+ const heightGrad = smoothstepCPU(25, 55, height) * 0.3;
+ const grassHigh = blendBiome(
+ _TUNDRA_GRASS_HIGH,
+ _FOREST_GRASS_HIGH,
+ _CANYON_SAND_HIGH,
+ tW,
+ fW,
+ dW,
+ );
+ c = mixRGB(c, grassHigh, heightGrad);
+
+ // Low-frequency variation
+ const gVar = Math.max(0, Math.min(1, Math.pow(variationN + 0.3, 5)));
+ const varColor = blendBiome(
+ _TUNDRA_VARIATION,
+ _FOREST_VARIATION,
+ _CANYON_VARIATION,
+ tW,
+ fW,
+ dW,
+ );
+ c = mixRGB(c, varColor, gVar * 0.25);
+
+ // Biome-blended dirt
+ const dirtVar = smoothstepCPU(0.3, 0.7, noiseVal2);
+ const dirtColor = blendBiome(
+ mixRGB(_TUNDRA_DIRT, _TUNDRA_DIRT_DARK, dirtVar),
+ mixRGB(_FOREST_DIRT, _FOREST_DIRT_DARK, dirtVar),
+ mixRGB(_CANYON_ROCK, _CANYON_ROCK_DARK, dirtVar),
+ tW,
+ fW,
+ dW,
+ );
+
+ // Biome-blended cliff
+ const cliffVar = smoothstepCPU(0.3, 0.7, noiseVal);
+ let cliffColor = blendBiome(
+ mixRGB(_TUNDRA_CLIFF, _TUNDRA_CLIFF_DARK, cliffVar),
+ mixRGB(_FOREST_CLIFF, _FOREST_CLIFF_DARK, cliffVar),
+ mixRGB(_CANYON_CLIFF, _CANYON_CLIFF_DARK, cliffVar),
+ tW,
+ fW,
+ dW,
+ );
+ const rockTexV = Math.pow(distortN, 0.5) * 0.3;
+ cliffColor = mixRGB(cliffColor, _CLIFF_TINT, rockTexV);
+
+ // Track grass weight: starts at 1, reduced by each non-grass layer
+ let grassWeight = 1.0;
+
+ // Dirt patches on flat areas
+ const nDirtF =
+ smoothstepCPU(
+ TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05,
+ TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15,
+ noiseVal,
+ ) * smoothstepCPU(0.3, 0.05, dSlope);
+ c = mixRGB(c, dirtColor, nDirtF);
+ grassWeight -= nDirtF;
+
+ // Dirt on moderate slopes
+ const dirtSlopeF =
+ smoothstepCPU(0.15, 0.4, dSlope) * smoothstepCPU(0.6, 0.3, dSlope) * 0.6;
+ c = mixRGB(c, dirtColor, dirtSlopeF);
+ grassWeight -= dirtSlopeF;
+
+ // Cliff on steep slopes
+ const cliffF = smoothstepCPU(0.3, 0.55, dSlope);
+ c = mixRGB(c, cliffColor, cliffF);
+ grassWeight -= cliffF;
+
+ // Sand near water
+ const sandBlend =
+ smoothstepCPU(18, 12, dHeight) * smoothstepCPU(0.25, 0.0, slope);
+ const sandStr = 0.6 + (0.9 - 0.6) * dW;
+ const sandF = sandBlend * sandStr;
+ c = mixRGB(c, _SAND_YELLOW, sandF);
+ grassWeight -= sandF;
+
+ // Shoreline transitions
+ const shore1 = smoothstepCPU(22, 14, dHeight) * 0.4;
+ c = mixRGB(c, _DIRT_DARK_CPU, shore1);
+ grassWeight -= shore1;
+
+ const shore2 = smoothstepCPU(15, 10, dHeight) * 0.7;
+ c = mixRGB(c, _MUD_BROWN, shore2);
+ grassWeight -= shore2;
+
+ const shore3 = smoothstepCPU(11, 7, dHeight) * 0.9;
+ c = mixRGB(c, _WATER_EDGE, shore3);
+ grassWeight -= shore3;
+
+ // Saturation boost
+ const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
+ const sat = TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST;
+ c = {
+ r: luma + (c.r - luma) * sat,
+ g: luma + (c.g - luma) * sat,
+ b: luma + (c.b - luma) * sat,
+ };
+
+ return {
+ r: c.r,
+ g: c.g,
+ b: c.b,
+ grassWeight: Math.max(0, Math.min(1, grassWeight)),
+ };
+}
+
// ============================================================================
// TERRAIN MATERIAL - OSRS Style (No Textures)
// ============================================================================
@@ -630,9 +1034,9 @@ export const MAX_VERTEX_LIGHTS = 8;
export type TerrainUniforms = {
sunPosition: { value: THREE.Vector3 };
sunDirection: { value: THREE.Vector3 };
- shadeColor: { value: THREE.Color };
time: { value: number };
fogEnabled: { value: number }; // 1.0 = fog enabled, 0.0 = fog disabled (for minimap)
+ dayIntensity: { value: number }; // 0 = night, 1 = day
// Vertex lighting uniforms (lampposts, etc.)
vertexLightPositions: { value: THREE.Vector3 }[]; // Array of 8 light positions
vertexLightColors: { value: THREE.Vector3 }[]; // Array of 8 light colors
@@ -687,8 +1091,7 @@ export function createTerrainMaterial(): THREE.Material & {
const noiseTex = generateNoiseTexture();
const sunPositionUniform = uniform(vec3(100, 100, 100));
- const sunDirectionUniform = uniform(vec3(0.5, 0.8, 0.3));
- const shadeColorUniform = uniform(vec3(0.7, 1.08, 1.22));
+ const sunDirectionUniform = uniform(vec3(...SUN_LIGHT.DEFAULT_DIRECTION));
const timeUniform = uniform(float(0));
const noiseScale = uniform(float(TERRAIN_SHADER_CONSTANTS.NOISE_SCALE));
@@ -730,6 +1133,20 @@ export function createTerrainMaterial(): THREE.Material & {
float(0.5),
);
+ // Distortion noise (high-freq) for organic cliff/shoreline edges
+ const distortNoiseUV = mul(
+ vec2(worldPos.x, worldPos.z),
+ float(TERRAIN_SHADER_CONSTANTS.DISTORT_NOISE_SCALE),
+ );
+ const distortNoise = texture(noiseTex, distortNoiseUV).r;
+
+ // Variation noise (low-freq) for large-scale ground color patches
+ const variationNoiseUV = mul(
+ vec2(worldPos.x, worldPos.z),
+ float(TERRAIN_SHADER_CONSTANTS.VARIATION_NOISE_SCALE),
+ );
+ const variationNoise = texture(noiseTex, variationNoiseUV).r;
+
// Fine detail noise (LOD-gated)
const closeEnough = smoothstep(float(12000.0), float(8000.0), distSq);
const noiseUV3 = mul(vec2(worldPos.x, worldPos.z), float(0.12));
@@ -764,11 +1181,18 @@ export function createTerrainMaterial(): THREE.Material & {
const tSnowDirt = loadBiomeTex("snowDirt");
const tSnowCliff = loadBiomeTex("snowCliff");
- // UV projections (top-down for grass/dirt, triplanar for cliffs)
+ // UV projections — dual-scale blend to break visible texture tiling.
+ // Sample at primary scale and a non-harmonic secondary scale (×0.27),
+ // blend 50/50 with noise so the two grids never visually align.
const tileScale = float(TERRAIN_TEX_TILE);
+ const tileScale2 = float(TERRAIN_TEX_TILE * 0.13);
const uvFlat = mul(vec2(worldPos.x, worldPos.z), tileScale);
const uvFront = mul(vec2(worldPos.x, worldPos.y), tileScale);
const uvSide = mul(vec2(worldPos.z, worldPos.y), tileScale);
+ const uvFlat2 = mul(vec2(worldPos.x, worldPos.z), tileScale2);
+ const uvFront2 = mul(vec2(worldPos.x, worldPos.y), tileScale2);
+ const uvSide2 = mul(vec2(worldPos.z, worldPos.y), tileScale2);
+ const tileBlend = smoothstep(float(0.2), float(0.8), noiseValue);
// Triplanar blend weights for cliff textures (^4 sharpening)
const tnx = abs(worldNormal.x);
@@ -782,26 +1206,53 @@ export function createTerrainMaterial(): THREE.Material & {
const twY = div(tw4y, twSum);
const twZ = div(tw4z, twSum);
- // Flat textures sampled top-down (grass/dirt on mostly-flat surfaces)
- const sGrass = texture(tGrass, uvFlat).rgb;
- const sDirt = texture(tDirt, uvFlat).rgb;
- const sDesertGrass = texture(tDesertGrass, uvFlat).rgb;
- const sDesertDirt = texture(tDesertDirt, uvFlat).rgb;
- const sSnowGrass = texture(tSnowGrass, uvFlat).rgb;
- const sSnowDirt = texture(tSnowDirt, uvFlat).rgb;
-
- // Cliff textures sampled triplanarly (avoids stretching on steep faces)
- const triCliff = (t: THREE.Texture) =>
- add(
+ // Flat textures — blend two scales per biome texture
+ const dualFlat = (t: THREE.Texture) =>
+ mix(texture(t, uvFlat).rgb, texture(t, uvFlat2).rgb, tileBlend);
+ const sGrass = dualFlat(tGrass);
+ const sDirt = dualFlat(tDirt);
+ const sDesertGrass = dualFlat(tDesertGrass);
+ const sDesertDirt = dualFlat(tDesertDirt);
+ const sSnowGrass = dualFlat(tSnowGrass);
+ const sSnowDirt = dualFlat(tSnowDirt);
+
+ // Cliff textures — triplanar with dual-scale blend
+ const triCliff = (t: THREE.Texture) => {
+ const s1 = add(
add(mul(texture(t, uvFlat).rgb, twY), mul(texture(t, uvSide).rgb, twX)),
mul(texture(t, uvFront).rgb, twZ),
);
+ const s2 = add(
+ add(mul(texture(t, uvFlat2).rgb, twY), mul(texture(t, uvSide2).rgb, twX)),
+ mul(texture(t, uvFront2).rgb, twZ),
+ );
+ return mix(s1, s2, tileBlend);
+ };
const sCliff = triCliff(tCliff);
const sDesertCliff = triCliff(tDesertCliff);
const sSnowCliff = triCliff(tSnowCliff);
const TEX_DARKEN = float(0.65);
+ // Distorted slope: offset normalY by noise for organic cliff edges
+ const distortedNY = add(
+ abs(worldNormal.y),
+ mul(
+ sub(distortNoise, float(0.5)),
+ float(TERRAIN_SHADER_CONSTANTS.ROCK_DISTORT_STRENGTH),
+ ),
+ );
+ const dSlope = sub(float(1.0), distortedNY);
+
+ // Distorted height for organic sand/shoreline boundaries
+ const dHeight = add(
+ height,
+ mul(
+ sub(distortNoise, float(0.5)),
+ float(TERRAIN_SHADER_CONSTANTS.HEIGHT_DISTORT_STRENGTH),
+ ),
+ );
+
// Biome-blended grass (textured)
const grassVar = smoothstep(float(0.4), float(0.6), noiseValue2);
const tundraGrassC = mix(sSnowGrass, mul(sSnowGrass, TEX_DARKEN), grassVar);
@@ -816,13 +1267,30 @@ export function createTerrainMaterial(): THREE.Material & {
mul(canyonGrassC, dW),
);
- // Biome-blended dirt patches
- const dirtPatchFactor = smoothstep(
- float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05),
- float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15),
- noiseValue,
+ // Height-based ground color gradient (subtle shift at altitude)
+ const heightGrad = mul(
+ smoothstep(float(25.0), float(55.0), height),
+ float(0.3),
);
- const flatnessFactor = smoothstep(float(0.3), float(0.05), slope);
+ const grassHighC = add(
+ add(mul(TUNDRA_GRASS_HIGH, tW), mul(FOREST_GRASS_HIGH, fW)),
+ mul(CANYON_SAND_HIGH, dW),
+ );
+ baseColor = mix(baseColor, grassHighC, heightGrad);
+
+ // Low-frequency ground variation overlay (patchy color areas)
+ const gVariation = clamp(
+ pow(add(variationNoise, float(0.3)), float(5.0)),
+ float(0.0),
+ float(1.0),
+ );
+ const varColorC = add(
+ add(mul(TUNDRA_VARIATION, tW), mul(FOREST_VARIATION, fW)),
+ mul(CANYON_VARIATION, dW),
+ );
+ baseColor = mix(baseColor, varColorC, mul(gVariation, float(0.25)));
+
+ // Biome-blended dirt
const dirtVar = smoothstep(float(0.3), float(0.7), noiseValue2);
const tundraDirtC = mix(sSnowDirt, mul(sSnowDirt, TEX_DARKEN), dirtVar);
const forestDirtC = mix(sDirt, mul(sDirt, TEX_DARKEN), dirtVar);
@@ -831,16 +1299,8 @@ export function createTerrainMaterial(): THREE.Material & {
add(mul(tundraDirtC, tW), mul(forestDirtC, fW)),
mul(canyonDirtC, dW),
);
- baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor));
- // Slope-based dirt
- const dirtSlopeFactor = mul(
- smoothstep(float(0.15), float(0.4), slope),
- smoothstep(float(0.6), float(0.3), slope),
- );
- baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6)));
-
- // Per-biome cliff on steep slopes (triplanar textured)
+ // Per-biome cliff with rock texture variation
const cliffVar = smoothstep(float(0.3), float(0.7), noiseValue);
const tundraCliffC = mix(sSnowCliff, mul(sSnowCliff, TEX_DARKEN), cliffVar);
const forestCliffC = mix(sCliff, mul(sCliff, TEX_DARKEN), cliffVar);
@@ -849,39 +1309,68 @@ export function createTerrainMaterial(): THREE.Material & {
mul(sDesertCliff, TEX_DARKEN),
cliffVar,
);
- const cliffColor = add(
+ let cliffColor: any = add(
add(mul(tundraCliffC, tW), mul(forestCliffC, fW)),
mul(canyonCliffC, dW),
);
+ const rockTexVarC = mul(pow(distortNoise, float(0.5)), float(0.3));
+ cliffColor = mix(cliffColor, CLIFF_TINT, rockTexVarC);
+
+ // Noise-driven dirt patches on flat areas (using distorted slope)
+ const dirtPatchFactor = smoothstep(
+ float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05),
+ float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15),
+ noiseValue,
+ );
+ const flatnessFactor = smoothstep(float(0.3), float(0.05), dSlope);
+ baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor));
+
+ // Dirt on moderate slopes (bell curve, using distorted slope)
+ const dirtSlopeFactor = mul(
+ smoothstep(float(0.15), float(0.4), dSlope),
+ smoothstep(float(0.6), float(0.3), dSlope),
+ );
+ baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6)));
+
+ // Cliff on steep slopes (using distorted slope)
baseColor = mix(
baseColor,
cliffColor,
- smoothstep(float(0.3), float(0.55), slope),
+ smoothstep(float(0.3), float(0.55), dSlope),
);
- // Sand near water (keep flat color - no sand texture)
+ // Sand near water (flat areas, stronger in canyon — using distorted height)
const sandBlend = mul(
- smoothstep(float(10.0), float(6.0), height),
+ smoothstep(float(18.0), float(12.0), dHeight),
smoothstep(float(0.25), float(0.0), slope),
);
const sandStrength = mix(float(0.6), float(0.9), dW);
baseColor = mix(baseColor, SAND_YELLOW, mul(sandBlend, sandStrength));
- // Shoreline transitions (keep flat colors)
+ // Shoreline transitions (using distorted height)
baseColor = mix(
baseColor,
DIRT_DARK,
- mul(smoothstep(float(14.0), float(8.0), height), float(0.4)),
+ mul(smoothstep(float(22.0), float(14.0), dHeight), float(0.4)),
);
baseColor = mix(
baseColor,
MUD_BROWN,
- mul(smoothstep(float(9.0), float(6.0), height), float(0.7)),
+ mul(smoothstep(float(15.0), float(10.0), dHeight), float(0.7)),
);
baseColor = mix(
baseColor,
WATER_EDGE,
- mul(smoothstep(float(6.5), float(5.0), height), float(0.9)),
+ mul(smoothstep(float(11.0), float(7.0), dHeight), float(0.9)),
+ );
+
+ // Saturation boost: pull color away from grey
+ const lumaB = dot(baseColor, vec3(0.299, 0.587, 0.114));
+ const greyB = vec3(lumaB, lumaB, lumaB);
+ baseColor = mix(
+ greyB,
+ baseColor,
+ float(TERRAIN_SHADER_CONSTANTS.SATURATION_BOOST),
);
// === RIVER BED / BANK COLORING ===
@@ -907,8 +1396,6 @@ export function createTerrainMaterial(): THREE.Material & {
);
// === ROAD OVERLAY ===
- // Roads are compacted dirt paths - reuse existing dirt colors for consistency
- // Use shared road mask when available, fall back to per-vertex attribute
const roadInfluenceAttr = attribute("roadInfluence", "float");
const roadMaskState = getRoadInfluenceTextureState();
const roadHalfWorld = roadMaskState.uWorldSize.mul(0.5);
@@ -931,31 +1418,34 @@ export function createTerrainMaterial(): THREE.Material & {
const dz = abs(worldPos.z.sub(roadMaskState.uCenterZ));
const insideMask = step(dx, roadHalfWorld).mul(step(dz, roadHalfWorld));
const useMask = hasRoadMask.mul(insideMask);
- const roadInfluence = mix(roadInfluenceAttr, roadMask, useMask);
+ const roadInfluenceRaw = mix(roadInfluenceAttr, roadMask, useMask);
+ const roadInfluence = smoothstep(float(0.0), float(1.0), roadInfluenceRaw);
- // Reuse existing dirt colors with natural noise variation
- const roadNoiseVar = mul(noiseValue2, float(0.5)); // Natural dirt variation
+ const roadNoiseVar = mul(noiseValue2, float(0.5));
const roadBaseColor = mix(DIRT_BROWN, DIRT_DARK, roadNoiseVar);
- // Gravel/Cobblestone effect: High frequency noise for texture
- // Use fineNoise (highest freq) to create small stones
const stoneNoise = smoothstep(float(0.4), float(0.7), fineNoise);
const stoneColor = mix(ROCK_GRAY, ROCK_DARK, float(0.5));
- // Mix stones into dirt base - more stones in center of road
const roadDetailColor = mix(
roadBaseColor,
stoneColor,
mul(stoneNoise, float(0.6)),
);
- // Road center is slightly worn/darker from foot traffic
const roadCenterDarken = mul(roadInfluence, float(0.08));
const compactedRoadColor = sub(roadDetailColor, vec3(roadCenterDarken));
- // Blend road color with terrain based on influence
const baseWithRoads = mix(variedColor, compactedRoadColor, roadInfluence);
+ // Half-lambert cool tint + fresnel rim — tints the ALBEDO before PBR.
+ // PBR then adds a single Lambert N·L + shadow on top.
+ const animeBase = applyAnimeShade(
+ baseWithRoads,
+ worldNormal,
+ sunDirectionUniform,
+ );
+
// ============================================================================
// VERTEX LIGHTING (lampposts, torches, etc.)
// Simple additive point lights with smooth attenuation
@@ -1092,7 +1582,7 @@ export function createTerrainMaterial(): THREE.Material & {
// Apply vertex lighting additively (multiply base by (1 + lightAccum))
// This brightens terrain near lights without washing out colors
- const litTerrain = mul(baseWithRoads, add(vec3(1, 1, 1), lightAccum));
+ const litTerrain = mul(animeBase, add(vec3(1, 1, 1), lightAccum));
// === DISTANCE FOG (smoothstep with squared distances — avoids per-fragment sqrt) ===
const baseFogFactor = smoothstep(
@@ -1104,6 +1594,9 @@ export function createTerrainMaterial(): THREE.Material & {
const fogColor = fogTexNode.rgb;
// === CREATE MATERIAL ===
+ // Base color + vertex lights only. PBR handles Lambert N·L + shadow.
+ const dayIntensityUniform = uniform(1.0);
+
const material = new MeshStandardNodeMaterial();
material.colorNode = litTerrain;
material.roughness = 1.0;
@@ -1111,27 +1604,16 @@ export function createTerrainMaterial(): THREE.Material & {
material.side = THREE.FrontSide;
material.fog = false;
- // Apply sun shade + fog AFTER PBR lighting via outputNode
material.outputNode = Fn(() => {
- const litColor = output;
-
- // Sun shade: shared function (identical to tree shader terrain blend)
- const shaded = applyTerrainSunShade(
- litColor.rgb,
- normalWorld,
- vec3(sunDirectionUniform),
- vec3(shadeColorUniform),
- );
-
- return vec4(mix(shaded, fogColor, fogFactor), litColor.a);
+ return vec4(mix(output.rgb, fogColor, fogFactor), output.a);
})();
const terrainUniforms: TerrainUniforms = {
sunPosition: sunPositionUniform,
sunDirection: sunDirectionUniform as unknown as { value: THREE.Vector3 },
- shadeColor: shadeColorUniform as unknown as { value: THREE.Color },
time: timeUniform,
fogEnabled: fogEnabledUniform,
+ dayIntensity: dayIntensityUniform as unknown as { value: number },
// Vertex lighting arrays
vertexLightPositions: vertexLightPositionUniforms.map(
(u) => u as unknown as { value: THREE.Vector3 },
diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts
index e625b32bf..33d35ba03 100644
--- a/packages/shared/src/systems/shared/world/TerrainSystem.ts
+++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts
@@ -18,30 +18,20 @@ import {
type TerrainWorkerOutput,
} from "../../../utils/workers";
import {
- LANDSCAPE_FEATURES,
ISLAND_RADIUS,
computeBaseHeight,
adjustShorelineHeight,
buildComputeBiomeWeightsJS,
- buildApplyLandscapeFeaturesJS,
MAX_HEIGHT,
WATER_LEVEL_NORMALIZED,
SHORELINE_CONFIG,
BIOME_CONFIG,
+ BIOME_CONFIGS,
} from "./TerrainHeightParams";
-import type {
- LandscapeFeatureDef,
- ShorelineConfig,
-} from "./TerrainHeightParams";
+import type { ShorelineConfig, BiomeNoiseSet } from "./TerrainHeightParams";
import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes";
-import { LandscapeType } from "./TerrainHeightParams";
import { WaterBodyRegistry } from "./WaterBodyRegistry";
-import { getGrassExclusionManager } from "./GrassExclusionManager";
-import { ISLAND_RIVER, MAX_RIVER_ELEVATION } from "./RiverDefinition";
-import { computeRiverSegmentAABBs, projectOntoRiver } from "./RiverUtils";
-import type { RiverSegmentAABB } from "./RiverUtils";
import type { BridgeSystem } from "./BridgeSystem";
-
// Import terrain generator from procgen package
import {
TerrainGenerator,
@@ -85,7 +75,10 @@ import {
// generatePlants, // DISABLED - plants not working/looking good yet
type ResourceGenerationContext,
} from "./BiomeResourceGenerator";
-import { getTreeConfigForBiome } from "./TerrainBiomeTypes";
+import {
+ getTreeConfigForBiome,
+ getGrassConfigForBiome,
+} from "./TerrainBiomeTypes";
import {
setProcgenRockWorld,
addRockInstance,
@@ -109,6 +102,7 @@ import {
updateTerrainVertexLights,
createTerrainMaterial,
TerrainUniforms,
+ computeTerrainColorCPU,
} from "./TerrainShader";
import { isLamppostLightTextureReady } from "./LamppostLightMask";
import { isCsmEnabled } from "./Environment";
@@ -124,6 +118,13 @@ import {
TerrainVisualManager,
type VisualManagerTerrainProvider,
} from "./TerrainVisualManager";
+import { WaterVisualManager } from "./WaterVisualManager";
+import {
+ GrassVisualManager,
+ type GrassWorkerSetup,
+} from "./GrassVisualManager";
+import { terminateGrassWorkerPool } from "../../../utils/workers/GrassWorker";
+import { CompositeQuadTreeListener } from "./TerrainQuadTree";
import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker";
import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker";
@@ -188,8 +189,7 @@ export class TerrainSystem extends System {
private updateTimer = 0;
private terrainTime = 0; // For animated caustics
private noise!: NoiseGenerator;
- private landscapeFeatures: LandscapeFeatureDef[] = [];
- private riverAABBs: RiverSegmentAABB[] = [];
+ private biomeNoiseSets: Record = {};
private _loggedWorkerTileBiome = 0;
private _loggedSyncTileBiome = 0;
private databaseSystem!: {
@@ -246,7 +246,6 @@ export class TerrainSystem extends System {
private _tempVec2_2 = new THREE.Vector2();
private _tempVec2_3 = new THREE.Vector2(); // For road distance calculations
private _tempBox3 = new THREE.Box3();
- private _tempColor = new THREE.Color(); // For biome color parsing
private _spectatorFocusPos = new THREE.Vector3();
private lamppostLightUpdateTimer = 0;
private lamppostActiveLights: VertexLight[] = [];
@@ -267,6 +266,8 @@ export class TerrainSystem extends System {
// Quad-tree LOD visual manager (client-only, when CONFIG.USE_QUADTREE_LOD is true)
private quadTreeVisualManager: TerrainVisualManager | null = null;
+ private waterVisualManager: WaterVisualManager | null = null;
+ private grassVisualManager: GrassVisualManager | null = null;
// Unified terrain generator from @hyperscape/procgen
// Provides deterministic height/biome calculation independent of rendering
@@ -825,40 +826,8 @@ export class TerrainSystem extends System {
SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND,
UNDERWATER_DEPTH_MULTIPLIER:
SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER,
- landscapeFeatures: this.landscapeFeatures.map((f) => ({
- type: f.type,
- x: f.x,
- z: f.z,
- radius: f.radius,
- strength: f.strength,
- layers: f.layers,
- shapePower: f.shapePower,
- edgeSharpness: f.edgeSharpness,
- layerSlope: f.layerSlope,
- noiseScale: f.noiseScale,
- noiseAmount: f.noiseAmount,
- })),
- riverFeatures: ISLAND_RIVER.waypoints.map((wp) => ({
- x: wp.x,
- z: wp.z,
- halfWidth: wp.halfWidth,
- depth: wp.depth,
- surfaceY: wp.surfaceY,
- })),
- riverAABBs: this.riverAABBs.map((bb) => ({
- minX: bb.minX,
- maxX: bb.maxX,
- minZ: bb.minZ,
- maxZ: bb.maxZ,
- })),
};
- console.log(
- `[TerrainSystem] Worker config: ${workerConfig.riverFeatures?.length ?? 0} river waypoints, ` +
- `${workerConfig.riverAABBs?.length ?? 0} AABBs, ` +
- `surfaceY[0]=${workerConfig.riverFeatures?.[0]?.surfaceY?.toFixed(2) ?? "N/A"}`,
- );
-
// Build simplified biome data for worker
const biomeData: Record<
string,
@@ -983,8 +952,7 @@ export class TerrainSystem extends System {
}
}
- // Set attributes
- geometry.setAttribute("color", new THREE.BufferAttribute(colorData, 3));
+ // Set attributes (color attribute removed — shader computes colors procedurally)
geometry.setAttribute(
"biomeId",
new THREE.BufferAttribute(new Float32Array(biomeData), 1),
@@ -1066,8 +1034,9 @@ export class TerrainSystem extends System {
this.applyFlatZoneCarve(geometry, tileX, tileZ);
- // Store height data for persistence
- this.storeHeightData(tileX, tileZ, Array.from(heightData));
+ // Attach heightData to geometry so the caller can store it AFTER the tile
+ // is added to terrainTiles (storeHeightData requires the tile to exist).
+ geometry.userData.heightData = Array.from(heightData);
return geometry;
}
@@ -1227,7 +1196,9 @@ export class TerrainSystem extends System {
this.generateTileResources(tile);
}
this.generateVisualFeatures(tile);
- this.generateWaterMeshes(tile);
+ if (!this.CONFIG.USE_QUADTREE_LOD) {
+ this.generateWaterMeshes(tile);
+ }
// Queue resource instances for deferred creation (spreads work across frames)
if (tile.resources.length > 0 && tile.mesh) {
@@ -1305,6 +1276,14 @@ export class TerrainSystem extends System {
this.terrainTiles.set(key, tile);
this.activeChunks.add(key);
+ // Store height data NOW that the tile is in the map.
+ // createTileGeometryFromWorkerData attaches heightData to geometry.userData
+ // because storeHeightData requires the tile to exist in terrainTiles.
+ if (geometry.userData.heightData) {
+ tile.heightData = geometry.userData.heightData;
+ tile.needsSave = true;
+ }
+
// Synchronously bake WATER and STEEP_SLOPE collision flags (server-only).
// Must match generateTile() — worker-generated tiles need the same
// walkability baking (WATER flags, bridge collision overrides, etc.).
@@ -1315,25 +1294,6 @@ export class TerrainSystem extends System {
return tile;
}
- private initializeLandscapeFeatures(): void {
- this.landscapeFeatures = [...LANDSCAPE_FEATURES];
- console.log(
- "[TerrainSystem] Landscape features:",
- this.landscapeFeatures.length,
- JSON.stringify(this.landscapeFeatures),
- );
- // Spot-check: test height at feature locations after terrain is ready
- setTimeout(() => {
- for (const f of this.landscapeFeatures) {
- const h = this.getHeightAt(f.x, f.z);
- const hNearby = this.getHeightAt(f.x + 200, f.z + 200);
- console.log(
- `[LandscapeDebug] ${f.type} at (${f.x}, ${f.z}): height=${h.toFixed(2)}, nearby height=${hNearby.toFixed(2)}, diff=${(h - hNearby).toFixed(2)}`,
- );
- }
- }, 5000);
- }
-
/**
* Initialize the unified TerrainGenerator from @hyperscape/procgen
* This creates a standalone generator that matches this system's configuration
@@ -1454,11 +1414,11 @@ export class TerrainSystem extends System {
WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD,
// LOD (Level of Detail) - Resolution tiers based on distance
- LOD_DISTANCES: [100, 200, 350], // Distance thresholds
+ LOD_DISTANCES: [200, 400, 700], // Distance thresholds
LOD_RESOLUTIONS: [64, 32, 16, 8], // Resolution at each LOD level
- // Chunking - Only adjacent tiles
- VIEW_DISTANCE: 1, // Load only 1 tile in each direction (3x3 = 9 tiles)
+ // Chunking
+ VIEW_DISTANCE: 5, // Load 5 tiles in each direction (11x11 = 121 tiles, ~1100m)
UPDATE_INTERVAL: 0.5, // Check player movement every 0.5 seconds
// Movement Constraints
@@ -1543,178 +1503,14 @@ export class TerrainSystem extends System {
this.runtimeIsServer = runtimeRole.isServer;
this.runtimeIsClient = runtimeRole.isClient;
- // Initialize deterministic noise from world id
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
-
- this.initializeLandscapeFeatures();
+ // Initialize deterministic noise from world id + per-biome noise sets
+ this.ensureNoiseInitialized();
// Initialize the unified terrain generator from @hyperscape/procgen
- // This provides a standalone, testable height generation system
this.initializeTerrainGenerator();
- // Build water body registry — register elevated ponds whose rim is above ocean level
+ // Water body registry — ocean level only (no manual rivers/ponds)
this.waterBodyRegistry = new WaterBodyRegistry(this.CONFIG.WATER_THRESHOLD);
- for (const feat of this.landscapeFeatures) {
- if (feat.type === LandscapeType.Pond) {
- const rimHeight = WaterBodyRegistry.computeRimHeight(feat, (x, z) =>
- this.getHeightAt(x, z),
- );
- if (rimHeight > this.CONFIG.WATER_THRESHOLD) {
- this.waterBodyRegistry.register({
- id: `pond_${feat.x}_${feat.z}`,
- centerX: feat.x,
- centerZ: feat.z,
- radius: feat.radius,
- radiusSq: feat.radius * feat.radius,
- surfaceY: rimHeight,
- sourceType: "landscape_pond",
- });
- }
- }
- }
-
- // Register river — subdivide, follow terrain, smooth surfaceY, narrow on slopes.
- const oceanLevel = this.CONFIG.WATER_THRESHOLD;
- const bankMargin = 0.5; // water surface 0.5m below natural terrain
-
- // Step 1: Subdivide waypoints every ~15m for smooth terrain following.
- const origWps = ISLAND_RIVER.waypoints;
- const subdivided: typeof origWps = [];
-
- for (let i = 0; i < origWps.length - 1; i++) {
- const a = origWps[i];
- const b = origWps[i + 1];
- const segDx = b.x - a.x;
- const segDz = b.z - a.z;
- const segLen = Math.sqrt(segDx * segDx + segDz * segDz);
- const numSteps = Math.max(1, Math.ceil(segLen / 15));
- // Perpendicular unit vector (rotate segment direction 90°)
- const px = segLen > 0.1 ? -segDz / segLen : 0;
- const pz = segLen > 0.1 ? segDx / segLen : 0;
-
- for (let s = 0; s < numSteps; s++) {
- const t = s / numSteps;
- const wx = a.x + segDx * t;
- const wz = a.z + segDz * t;
- let hw = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
- let dp = a.depth + (b.depth - a.depth) * t;
- const centerH = this.getHeightAt(wx, wz);
-
- // Step 2: Narrow the river on steep/high terrain (Minecraft-style max height).
- // Uses centerline height — bank terrain doesn't affect whether the river narrows.
- if (centerH > MAX_RIVER_ELEVATION) {
- const excess = (centerH - MAX_RIVER_ELEVATION) / 8; // 0→1 over 8m
- const fade = Math.max(0, 1 - excess);
- hw = Math.max(4, hw * fade); // min 4m half-width (8m total)
- dp = Math.max(0.5, dp * fade); // min 0.5m depth
- }
-
- // Step 2b: Sample bank terrain at both edges (perpendicular to flow).
- // surfaceY must account for the lowest terrain across the cross-section,
- // not just centerline — otherwise water floats above bank depressions.
- const bankH1 = this.getHeightAt(wx + px * hw, wz + pz * hw);
- const bankH2 = this.getHeightAt(wx - px * hw, wz - pz * hw);
- const crossSectionMinH = Math.min(centerH, bankH1, bankH2);
-
- subdivided.push({
- x: wx,
- z: wz,
- halfWidth: hw,
- depth: dp,
- surfaceY: Math.max(crossSectionMinH - bankMargin, oceanLevel),
- });
- }
- }
- // Add final waypoint
- const lastWp = origWps[origWps.length - 1];
- const lastCenterH = this.getHeightAt(lastWp.x, lastWp.z);
- subdivided.push({
- ...lastWp,
- surfaceY: Math.max(lastCenterH - bankMargin, oceanLevel),
- });
-
- // Step 3: Smooth surfaceY — 3 passes of Gaussian-like smoothing.
- // Eliminates abrupt water surface changes from terrain noise bumps
- // while preserving the overall downhill trend.
- for (let pass = 0; pass < 3; pass++) {
- for (let i = 1; i < subdivided.length - 1; i++) {
- const prev = subdivided[i - 1].surfaceY!;
- const curr = subdivided[i].surfaceY!;
- const next = subdivided[i + 1].surfaceY!;
- subdivided[i].surfaceY = curr * 0.5 + (prev + next) * 0.25;
- }
- }
-
- // Step 4: Ensure surfaceY never exceeds terrain height (water can't float)
- // and never drops below ocean level. Also samples bank terrain via
- // perpendicular direction from adjacent waypoints.
- for (let i = 0; i < subdivided.length; i++) {
- const wp = subdivided[i];
- const centerH = this.getHeightAt(wp.x, wp.z);
-
- // Compute perpendicular from adjacent waypoints for bank sampling
- const prev = subdivided[Math.max(0, i - 1)];
- const next = subdivided[Math.min(subdivided.length - 1, i + 1)];
- const dx = next.x - prev.x;
- const dz = next.z - prev.z;
- const len = Math.sqrt(dx * dx + dz * dz);
- let minH = centerH;
- if (len > 0.1) {
- const ppx = -dz / len;
- const ppz = dx / len;
- const bH1 = this.getHeightAt(
- wp.x + ppx * wp.halfWidth,
- wp.z + ppz * wp.halfWidth,
- );
- const bH2 = this.getHeightAt(
- wp.x - ppx * wp.halfWidth,
- wp.z - ppz * wp.halfWidth,
- );
- minH = Math.min(centerH, bH1, bH2);
- }
-
- wp.surfaceY = Math.max(Math.min(wp.surfaceY!, minH - 0.1), oceanLevel);
- }
-
- // Create local river definition with dense terrain-following waypoints
- // (do NOT mutate the shared ISLAND_RIVER constant)
- const subdividedRiver = { ...ISLAND_RIVER, waypoints: subdivided };
-
- // Compute AABBs from the dense waypoints
- this.riverAABBs = computeRiverSegmentAABBs(subdividedRiver);
- console.log(
- `[TerrainSystem] River: ${subdivided.length} waypoints (subdivided from ${origWps.length}), ` +
- `${this.riverAABBs.length} segments`,
- );
-
- this.waterBodyRegistry.registerRiver(subdividedRiver);
-
- // Register elevated water bodies as grass exclusion zones so the GPU grass
- // shader doesn't render blades inside mountain ponds / highland lakes.
- if (this.runtimeIsClient) {
- const grassExclusion = getGrassExclusionManager();
- for (const body of this.waterBodyRegistry.getAllBodies()) {
- grassExclusion.addCircularBlocker(
- `water_body_${body.id}`,
- body.centerX,
- body.centerZ,
- body.radius,
- 2.0, // 2m fade at edge
- );
- }
- // River grass exclusion — use subdivided waypoints (already dense, ~20m apart)
- const riverSubWps = subdividedRiver.waypoints;
- for (let i = 0; i < riverSubWps.length; i++) {
- const wp = riverSubWps[i];
- grassExclusion.addCircularBlocker(
- `river_${i}`,
- wp.x,
- wp.z,
- wp.halfWidth * subdividedRiver.valleyMultiplier + 2,
- 3.0,
- );
- }
- }
// Cache optional TownSystem for difficulty falloff and boss placement
this.townSystem = this.world.getSystem("towns") ?? null;
@@ -1817,11 +1613,7 @@ export class TerrainSystem extends System {
}
async start(): Promise {
- // Initialize noise generator if not already initialized (failsafe)
- if (!this.noise) {
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
- this.initializeLandscapeFeatures();
- }
+ this.ensureNoiseInitialized();
// CRITICAL: Wait for DataManager to initialize BIOMES data before generating terrain
// DataManager is initialized in registerSystems() which happens asynchronously
@@ -1915,6 +1707,11 @@ export class TerrainSystem extends System {
);
this.refreshRoadInfluence();
}
+
+ // Rebuild grass chunks so road influence is reflected
+ if (this.grassVisualManager) {
+ this.grassVisualManager.rebuildAllChunks();
+ }
});
// Start player-based terrain update loop
@@ -2061,20 +1858,6 @@ export class TerrainSystem extends System {
SHORELINE_LAND_MAX_MULTIPLIER: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER,
SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND,
UNDERWATER_DEPTH_MULTIPLIER: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER,
- landscapeFeatures: this.landscapeFeatures,
- riverFeatures: ISLAND_RIVER.waypoints.map((wp) => ({
- x: wp.x,
- z: wp.z,
- halfWidth: wp.halfWidth,
- depth: wp.depth,
- surfaceY: wp.surfaceY,
- })),
- riverAABBs: this.riverAABBs.map((bb) => ({
- minX: bb.minX,
- maxX: bb.maxX,
- minZ: bb.minZ,
- maxZ: bb.maxZ,
- })),
};
const biomeCenters = [
@@ -2144,6 +1927,49 @@ export class TerrainSystem extends System {
this.CONFIG.QUADTREE_MAX_ASSEMBLIES_PER_FRAME,
);
+ // Water quad-tree visual manager — flat water meshes aligned with terrain chunks
+ if (this.waterSystem) {
+ const waterContainer = new THREE.Group();
+ waterContainer.name = "QuadTreeWaterContainer";
+ if (this.terrainContainer) {
+ this.terrainContainer.parent?.add(waterContainer);
+ }
+
+ this.waterSystem.setWaterLevel(this.CONFIG.WATER_THRESHOLD);
+
+ this.waterVisualManager = new WaterVisualManager(
+ waterContainer,
+ this.waterSystem,
+ (x: number, z: number) => this.getHeightAt(x, z),
+ (x: number, z: number) => this.getIslandMask(x, z),
+ this.CONFIG.WATER_THRESHOLD,
+ );
+
+ const grassContainer = new THREE.Group();
+ grassContainer.name = "QuadTreeGrassContainer";
+ if (this.terrainContainer) {
+ this.terrainContainer.parent?.add(grassContainer);
+ }
+ const grassWorkerSetup = this.buildGrassWorkerSetup();
+ this.grassVisualManager = new GrassVisualManager(
+ grassContainer,
+ (x: number, z: number) => this.getHeightAt(x, z),
+ this.CONFIG.WATER_THRESHOLD,
+ (wx: number, wz: number) =>
+ this.calculateRoadInfluenceAtVertex(wx, wz, 0, 0),
+ (wx: number, wz: number) => this.isInFlatZone(wx, wz),
+ (wx: number, wz: number) => this.getTerrainColorAt(wx, wz),
+ grassWorkerSetup,
+ );
+
+ // Wire terrain, water, grass managers to the same quad-tree via composite
+ const composite = new CompositeQuadTreeListener();
+ composite.add(this.quadTreeVisualManager);
+ composite.add(this.waterVisualManager);
+ composite.add(this.grassVisualManager);
+ this.quadTreeVisualManager.getQuadTree().setListener(composite);
+ }
+
console.log(
"[TerrainSystem] Quad-tree LOD visual manager initialized " +
`(minSize=${this.CONFIG.QUADTREE_MIN_SIZE}, maxDepth=${this.CONFIG.QUADTREE_MAX_DEPTH}, ` +
@@ -2151,6 +1977,218 @@ export class TerrainSystem extends System {
);
}
+ private buildGrassWorkerSetup(): GrassWorkerSetup {
+ const workerConfig: TerrainWorkerConfig = {
+ TILE_SIZE: this.CONFIG.TILE_SIZE,
+ TILE_RESOLUTION: this.CONFIG.TILE_RESOLUTION,
+ MAX_HEIGHT: MAX_HEIGHT,
+ BIOME_GAUSSIAN_COEFF: BIOME_CONFIG.gaussianCoeff,
+ BIOME_BOUNDARY_NOISE_SCALE: BIOME_CONFIG.boundaryNoiseScale,
+ BIOME_BOUNDARY_NOISE_AMOUNT: BIOME_CONFIG.boundaryNoiseAmount,
+ WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD,
+ WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED,
+ SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD,
+ SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH,
+ SHORELINE_MIN_SLOPE: SHORELINE_CONFIG.MIN_SLOPE,
+ SHORELINE_SLOPE_SAMPLE_DISTANCE: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE,
+ SHORELINE_LAND_BAND: SHORELINE_CONFIG.LAND_BAND,
+ SHORELINE_LAND_MAX_MULTIPLIER: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER,
+ SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND,
+ UNDERWATER_DEPTH_MULTIPLIER: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER,
+ };
+
+ const biomeData: Record<
+ string,
+ { heightModifier: number; color: { r: number; g: number; b: number } }
+ > = {};
+ for (const [name, biome] of Object.entries(BIOMES)) {
+ const color = new THREE.Color(biome.color);
+ biomeData[name] = {
+ heightModifier: biome.terrainMultiplier || 1,
+ color: { r: color.r, g: color.g, b: color.b },
+ };
+ }
+
+ const biomeCenters = this.terrainGenerator
+ .getBiomeSystem()
+ .getBiomeCenters() as BiomeCenter[];
+
+ const tCfg = getGrassConfigForBiome(BiomeType.Tundra);
+ const fCfg = getGrassConfigForBiome(BiomeType.Forest);
+ const cCfg = getGrassConfigForBiome(BiomeType.Canyon);
+
+ const resolveTint = (cfg: ReturnType) => ({
+ density: cfg.density,
+ maxSlope: cfg.maxSlope,
+ minGrassWeight: cfg.minGrassWeight,
+ heightScale: cfg.heightScale,
+ patchiness: cfg.patchiness,
+ patchScale: cfg.patchScale,
+ tintR: cfg.tintColor?.[0] ?? 0,
+ tintG: cfg.tintColor?.[1] ?? 0,
+ tintB: cfg.tintColor?.[2] ?? 0,
+ tintStrength: cfg.tintStrength ?? 0,
+ });
+
+ const grassConfigs: Record<
+ string,
+ {
+ density: number;
+ maxSlope: number;
+ minGrassWeight: number;
+ heightScale: number;
+ patchiness: number;
+ patchScale: number;
+ tintR: number;
+ tintG: number;
+ tintB: number;
+ tintStrength: number;
+ }
+ > = {
+ [BiomeType.Tundra]: resolveTint(tCfg),
+ [BiomeType.Forest]: resolveTint(fCfg),
+ [BiomeType.Canyon]: resolveTint(cCfg),
+ };
+
+ const tileSize = this.CONFIG.TILE_SIZE;
+
+ return {
+ terrainConfig: workerConfig,
+ seed: this.computeSeedFromWorldId(),
+ biomeCenters: biomeCenters.map((c) => ({
+ x: c.x,
+ z: c.z,
+ type: c.type,
+ influence: c.influence,
+ })),
+ biomes: biomeData,
+ grassConfigs,
+ tileSize,
+ getRoadSegmentsForRegion: (
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ) => this.getWorldSpaceRoadSegmentsForRegion(minX, minZ, maxX, maxZ),
+ getFlatZonesForRegion: (
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ) => this.getFlatZonesForRegion(minX, minZ, maxX, maxZ),
+ };
+ }
+
+ /**
+ * Collect road segments overlapping a world-space AABB, returned in
+ * world coordinates for the grass worker.
+ */
+ private getWorldSpaceRoadSegmentsForRegion(
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ): Array<{
+ startX: number;
+ startZ: number;
+ endX: number;
+ endZ: number;
+ width: number;
+ }> {
+ this.roadNetworkSystem ??= this.world.getSystem("roads") as
+ | RoadNetworkSystem
+ | undefined;
+ if (!this.roadNetworkSystem) return [];
+
+ const ts = this.CONFIG.TILE_SIZE;
+ const minTX = Math.floor(minX / ts);
+ const minTZ = Math.floor(minZ / ts);
+ const maxTX = Math.floor(maxX / ts);
+ const maxTZ = Math.floor(maxZ / ts);
+
+ const result: Array<{
+ startX: number;
+ startZ: number;
+ endX: number;
+ endZ: number;
+ width: number;
+ }> = [];
+
+ for (let tx = minTX; tx <= maxTX; tx++) {
+ for (let tz = minTZ; tz <= maxTZ; tz++) {
+ const segs = this.roadNetworkSystem.getRoadSegmentsForTile(tx, tz);
+ const originX = tx * ts;
+ const originZ = tz * ts;
+ for (const seg of segs) {
+ result.push({
+ startX: originX + seg.start.x,
+ startZ: originZ + seg.start.z,
+ endX: originX + seg.end.x,
+ endZ: originZ + seg.end.z,
+ width: seg.width,
+ });
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Collect flat zones overlapping a world-space AABB, returned as simplified
+ * rectangles for the grass worker to exclude grass from buildings/arenas.
+ */
+ private getFlatZonesForRegion(
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ): Array<{
+ centerX: number;
+ centerZ: number;
+ halfWidth: number;
+ halfDepth: number;
+ blendRadius: number;
+ }> {
+ if (this.flatZones.size === 0) return [];
+
+ const tileSize = this.CONFIG.TILE_SIZE;
+ const halfTile = tileSize / 2;
+ const minTX = Math.floor((minX + halfTile) / tileSize);
+ const minTZ = Math.floor((minZ + halfTile) / tileSize);
+ const maxTX = Math.floor((maxX + halfTile) / tileSize);
+ const maxTZ = Math.floor((maxZ + halfTile) / tileSize);
+
+ const seen = new Set();
+ const result: Array<{
+ centerX: number;
+ centerZ: number;
+ halfWidth: number;
+ halfDepth: number;
+ blendRadius: number;
+ }> = [];
+
+ for (let tx = minTX; tx <= maxTX; tx++) {
+ for (let tz = minTZ; tz <= maxTZ; tz++) {
+ const zones = this.flatZonesByTile.get(`${tx}_${tz}`);
+ if (!zones) continue;
+ for (const zone of zones) {
+ if (seen.has(zone.id)) continue;
+ seen.add(zone.id);
+ result.push({
+ centerX: zone.centerX,
+ centerZ: zone.centerZ,
+ halfWidth: zone.width / 2,
+ halfDepth: zone.depth / 2,
+ blendRadius: zone.blendRadius,
+ });
+ }
+ }
+ }
+
+ return result;
+ }
+
private registerInstancedMeshes(): void {
// Register tree mesh - now with automatic pooling (1000 visible max)
const treeSize = { x: 1.2, y: 3.0, z: 1.2 };
@@ -2642,7 +2680,9 @@ export class TerrainSystem extends System {
// Generate visual features and water meshes
this.generateVisualFeatures(tile);
- this.generateWaterMeshes(tile);
+ if (!this.CONFIG.USE_QUADTREE_LOD) {
+ this.generateWaterMeshes(tile);
+ }
// NOTE: Grass rendering is handled by ProceduralGrassSystem
// Queue resource instances for deferred creation (spreads work across frames)
@@ -2726,6 +2766,14 @@ export class TerrainSystem extends System {
this.terrainTiles.set(key, tile);
this.activeChunks.add(key);
+ // Store height data NOW that the tile is in the map.
+ // createTileGeometry attaches heightData to geometry.userData because
+ // storeHeightData requires the tile to exist in terrainTiles.
+ if (geometry.userData.heightData) {
+ tile.heightData = geometry.userData.heightData;
+ tile.needsSave = true;
+ }
+
// Synchronously bake WATER and STEEP_SLOPE collision flags (server-only).
// Must complete before any movement query can reach this tile, otherwise
// players can walk into water during the gap between tile generation and
@@ -2792,8 +2840,9 @@ export class TerrainSystem extends System {
// Carve terrain triangles inside building flat zones to avoid overdraw
this.applyFlatZoneCarve(geometry, tileX, tileZ);
- // Store height data for persistence
- this.storeHeightData(tileX, tileZ, heightData);
+ // Attach heightData to geometry so the caller can store it AFTER the tile
+ // is added to terrainTiles (storeHeightData requires the tile to exist).
+ geometry.userData.heightData = heightData;
// SERVER: Skip all visual-only attributes (colors, biomeIds, roadInfluences,
// forestWeights, canyonWeights) and the expensive per-vertex biome/color
@@ -2804,121 +2853,45 @@ export class TerrainSystem extends System {
return geometry;
}
- // CLIENT: Full visual attribute generation
- const colors = new Float32Array(positions.count * 3);
+ // CLIENT: Visual attribute generation (shader computes colors procedurally)
const biomeIds = new Float32Array(positions.count);
const roadInfluences = new Float32Array(positions.count);
const forestWeights = new Float32Array(positions.count);
const canyonWeights = new Float32Array(positions.count);
- // Verify biome data is loaded - error if not
- if (Object.keys(BIOMES).length === 0) {
- throw new Error(
- "[TerrainSystem] BIOMES data not loaded! DataManager must initialize before terrain generation.",
- );
- }
- const defaultBiomeData = BIOMES[DEFAULT_BIOME];
- if (!defaultBiomeData) {
- throw new Error(
- "[TerrainSystem] Default biome not found in BIOMES data!",
- );
- }
-
for (let i = 0; i < positions.count; i++) {
const i3 = i * 3;
const x = positionsArray[i3] + tileX * this.CONFIG.TILE_SIZE;
const z = positionsArray[i3 + 2] + tileZ * this.CONFIG.TILE_SIZE;
- const height = positionsArray[i3 + 1]; // Already computed above
- // Get biome influences for smooth color blending
const { biomeWeightMap, totalWeight } =
this.computeBiomeWeightsAtPosition(x, z);
- const normalizedHeight = height / MAX_HEIGHT;
- // Store dominant biome ID for shader
let dominantBiome = DEFAULT_BIOME as string;
let dominantWeight = -Infinity;
- // PERFORMANCE: Reuse _tempColor to avoid GC pressure (no new THREE.Color per vertex)
- // Blend up to 3 biome colors based on influence weights
- let colorR = 0,
- colorG = 0,
- colorB = 0;
-
if (totalWeight > 0) {
- const invTotal = 1 / totalWeight;
+ const invW = 1 / totalWeight;
for (const [type, rawWeight] of biomeWeightMap) {
- const weight = rawWeight * invTotal;
+ const weight = rawWeight * invW;
if (weight > dominantWeight) {
dominantWeight = weight;
dominantBiome = type;
}
-
- const biomeData = BIOMES[type];
- if (!biomeData) {
- throw new Error(
- `[TerrainSystem] Biome "${type}" not found in BIOMES data!`,
- );
- }
- this._tempColor.set(biomeData.color);
-
- colorR += this._tempColor.r * weight;
- colorG += this._tempColor.g * weight;
- colorB += this._tempColor.b * weight;
}
- } else {
- const biomeData = BIOMES[DEFAULT_BIOME];
- if (!biomeData) {
- throw new Error(
- `[TerrainSystem] Default biome not found in BIOMES data!`,
- );
- }
- this._tempColor.set(biomeData.color);
- colorR = this._tempColor.r;
- colorG = this._tempColor.g;
- colorB = this._tempColor.b;
- }
- biomeIds[i] = this.getBiomeId(dominantBiome);
-
- if (totalWeight > 0) {
- const invW = 1 / totalWeight;
forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW;
canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW;
}
+ biomeIds[i] = this.getBiomeId(dominantBiome);
- // Apply brownish shoreline tint near water level
- const waterLevel = WATER_LEVEL_NORMALIZED;
- const shorelineThreshold = SHORELINE_CONFIG.THRESHOLD;
- if (
- normalizedHeight > waterLevel &&
- normalizedHeight < shorelineThreshold
- ) {
- const shoreFactor =
- (1.0 -
- (normalizedHeight - waterLevel) /
- (shorelineThreshold - waterLevel)) *
- SHORELINE_CONFIG.STRENGTH;
- colorR = colorR + (0.545 - colorR) * shoreFactor;
- colorG = colorG + (0.451 - colorG) * shoreFactor;
- colorB = colorB + (0.333 - colorB) * shoreFactor;
- }
-
- // Calculate road influence - shader uses this attribute for road coloring
- // (vertex colors are NOT modified for roads, shader handles this procedurally)
roadInfluences[i] = this.calculateRoadInfluenceAtVertex(
x,
z,
tileX,
tileZ,
);
-
- // Store biome color (kept for potential fallback/debug rendering)
- colors[i * 3] = colorR;
- colors[i * 3 + 1] = colorG;
- colors[i * 3 + 2] = colorB;
}
- geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1));
geometry.setAttribute(
"roadInfluence",
@@ -3510,14 +3483,27 @@ export class TerrainSystem extends System {
* Get base terrain height WITHOUT mountain biome boost.
* Delegates to the single source of truth in TerrainHeightParams.
*/
+ private ensureNoiseInitialized(): void {
+ if (!this.noise) {
+ const seed = this.computeSeedFromWorldId();
+ this.noise = new NoiseGenerator(seed);
+ for (const [key, cfg] of Object.entries(BIOME_CONFIGS)) {
+ const base = seed + cfg.seedOffset;
+ this.biomeNoiseSets[key] = {
+ main: new NoiseGenerator(base),
+ variation: new NoiseGenerator(base + 4),
+ erosion: new NoiseGenerator(base + 1),
+ };
+ }
+ }
+ }
+
private getBaseHeightAt(
worldX: number,
worldZ: number,
biomeWeights?: Record,
): number {
- if (!this.noise) {
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
- }
+ this.ensureNoiseInitialized();
const weights =
biomeWeights ?? this.computeBiomeWeightsByPosition(worldX, worldZ);
@@ -3525,24 +3511,13 @@ export class TerrainSystem extends System {
worldX,
worldZ,
this.noise,
+ this.biomeNoiseSets,
weights,
- this.landscapeFeatures,
- MAX_HEIGHT,
- this.waterBodyRegistry?.getRiverDef() ?? undefined,
- this.riverAABBs.length > 0 ? this.riverAABBs : undefined,
);
}
private getHeightAtWithoutShore(worldX: number, worldZ: number): number {
- if (!this.noise) {
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
- }
-
- const flatHeight = this.getFlatZoneHeight(worldX, worldZ);
- if (flatHeight !== null) {
- return flatHeight;
- }
-
+ this.ensureNoiseInitialized();
return this.getBaseHeightAt(worldX, worldZ);
}
@@ -4042,62 +4017,6 @@ export class TerrainSystem extends System {
return this.getFlatZoneHeight(worldX, worldZ) !== null;
}
- /**
- * Get terrain color at a world position by sampling the nearest terrain tile vertex.
- * Used by ProceduralGrassSystem to match grass color to terrain.
- *
- * @param worldX - World X coordinate
- * @param worldZ - World Z coordinate
- * @returns RGB color (0-1 range) or null if no terrain data available
- */
- getTerrainColorAt(
- worldX: number,
- worldZ: number,
- ): { r: number; g: number; b: number } | null {
- const tileX = this.worldToTerrainTileIndex(worldX);
- const tileZ = this.worldToTerrainTileIndex(worldZ);
- const key = `${tileX}_${tileZ}`;
-
- const tile = this.terrainTiles.get(key);
- if (!tile?.mesh) {
- return null;
- }
-
- // Get vertex colors from geometry
- const geometry = tile.mesh.geometry as THREE.BufferGeometry;
- const colorAttr = geometry.getAttribute("color") as
- | THREE.BufferAttribute
- | undefined;
- if (!colorAttr) {
- return null;
- }
-
- const localX = worldX - tileX * this.CONFIG.TILE_SIZE;
- const localZ = worldZ - tileZ * this.CONFIG.TILE_SIZE;
-
- const resolution = this.CONFIG.TILE_RESOLUTION;
- const vertexX = Math.round(
- Math.max(0, Math.min(resolution - 1, this.localToGridIndex(localX))),
- );
- const vertexZ = Math.round(
- Math.max(0, Math.min(resolution - 1, this.localToGridIndex(localZ))),
- );
- const vertexIndex = vertexZ * resolution + vertexX;
-
- if (
- vertexIndex < 0 ||
- vertexIndex * 3 + 2 >= colorAttr.count * colorAttr.itemSize
- ) {
- return null;
- }
-
- return {
- r: colorAttr.getX(vertexIndex),
- g: colorAttr.getY(vertexIndex),
- b: colorAttr.getZ(vertexIndex),
- };
- }
-
/**
* Register a flat zone and update spatial index.
* Spatial index uses terrain tiles (100m each) for efficient lookup.
@@ -4212,6 +4131,26 @@ export class TerrainSystem extends System {
}
}
}
+
+ // Invalidate quad-tree visual chunks so they regenerate with flat zone heights.
+ // Without this, chunks generated before the flat zone was registered would
+ // show the procedural height instead of the flat zone height.
+ if (this.quadTreeVisualManager) {
+ this.quadTreeVisualManager.invalidateRegion(
+ zoneMinX,
+ zoneMinZ,
+ zoneMaxX,
+ zoneMaxZ,
+ );
+ }
+ if (this.grassVisualManager) {
+ this.grassVisualManager.invalidateRegion(
+ zoneMinX,
+ zoneMinZ,
+ zoneMaxX,
+ zoneMaxZ,
+ );
+ }
}
/**
@@ -4349,6 +4288,18 @@ export class TerrainSystem extends System {
}
}
}
+
+ // Invalidate quad-tree visual chunks so they regenerate without the flat zone.
+ const minX = zone.centerX - totalRadius;
+ const maxX = zone.centerX + totalRadius;
+ const minZ = zone.centerZ - totalRadius;
+ const maxZ = zone.centerZ + totalRadius;
+ if (this.quadTreeVisualManager) {
+ this.quadTreeVisualManager.invalidateRegion(minX, minZ, maxX, maxZ);
+ }
+ if (this.grassVisualManager) {
+ this.grassVisualManager.invalidateRegion(minX, minZ, maxX, maxZ);
+ }
}
/**
@@ -4805,6 +4756,146 @@ export class TerrainSystem extends System {
return { biomeWeightMap, totalWeight };
}
+ /**
+ * Single entry point for getting the terrain base color and grass weight
+ * at a world position. Encapsulates height, slope, biome-weight lookups
+ * and the CPU terrain-color computation so callers (e.g. GrassVisualManager)
+ * don't need any biome knowledge.
+ */
+ getTerrainColorAt(
+ wx: number,
+ wz: number,
+ ): {
+ r: number;
+ g: number;
+ b: number;
+ grassWeight: number;
+ grassPlacement: number;
+ grassHeightScale: number;
+ tintR: number;
+ tintG: number;
+ tintB: number;
+ tintStrength: number;
+ nx: number;
+ ny: number;
+ nz: number;
+ } {
+ const height = this.getHeightAt(wx, wz);
+
+ const sd = 0.5;
+ const hL = this.getHeightAt(wx - sd, wz);
+ const hR = this.getHeightAt(wx + sd, wz);
+ const hD = this.getHeightAt(wx, wz - sd);
+ const hU = this.getHeightAt(wx, wz + sd);
+ const dhdx = (hR - hL) / (2 * sd);
+ const dhdz = (hU - hD) / (2 * sd);
+ const gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz);
+ const normalY = 1 / Math.sqrt(1 + gradMag * gradMag);
+ const slope = 1 - normalY;
+
+ const rnx = -dhdx;
+ const rnz = -dhdz;
+ const rny = 1.0;
+ const nLen = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz);
+ const invLen = 1 / nLen;
+
+ const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition(
+ wx,
+ wz,
+ );
+ const invW = totalWeight > 0 ? 1 / totalWeight : 1;
+ const forestW = (biomeWeightMap.get("forest") || 0) * invW;
+ const canyonW = (biomeWeightMap.get("canyon") || 0) * invW;
+ const tundraW = 1 - forestW - canyonW;
+
+ const color = computeTerrainColorCPU(
+ wx,
+ wz,
+ height,
+ slope,
+ forestW,
+ canyonW,
+ );
+
+ const tCfg = getGrassConfigForBiome(BiomeType.Tundra);
+ const fCfg = getGrassConfigForBiome(BiomeType.Forest);
+ const cCfg = getGrassConfigForBiome(BiomeType.Canyon);
+
+ const maxSlope =
+ tCfg.maxSlope * tundraW +
+ fCfg.maxSlope * forestW +
+ cCfg.maxSlope * canyonW;
+ const minGW =
+ tCfg.minGrassWeight * tundraW +
+ fCfg.minGrassWeight * forestW +
+ cCfg.minGrassWeight * canyonW;
+ const density =
+ tCfg.density * tundraW + fCfg.density * forestW + cCfg.density * canyonW;
+ const grassHeightScale =
+ tCfg.heightScale * tundraW +
+ fCfg.heightScale * forestW +
+ cCfg.heightScale * canyonW;
+ const patchiness =
+ tCfg.patchiness * tundraW +
+ fCfg.patchiness * forestW +
+ cCfg.patchiness * canyonW;
+ const patchScale =
+ tCfg.patchScale * tundraW +
+ fCfg.patchScale * forestW +
+ cCfg.patchScale * canyonW;
+
+ const slopeOk = slope <= maxSlope ? 1.0 : 0.0;
+ const weightOk = color.grassWeight >= minGW ? 1.0 : 0.0;
+
+ // Noise-based patch mask: patchiness 0 = uniform, 1 = tight clusters
+ const patchThreshold = patchiness * 2 - 1; // maps [0,1] -> [-1,1]
+ const noiseVal = this.noise.simplex2D(wx * patchScale, wz * patchScale);
+ const patchMask = noiseVal > patchThreshold ? 1.0 : 0.0;
+
+ const grassPlacement =
+ color.grassWeight * density * slopeOk * weightOk * patchMask;
+
+ // Biome-blended grass tint (returned separately for tip-only application)
+ const tintStrength =
+ (tCfg.tintStrength ?? 0) * tundraW +
+ (fCfg.tintStrength ?? 0) * forestW +
+ (cCfg.tintStrength ?? 0) * canyonW;
+ let tintR = 0,
+ tintG = 0,
+ tintB = 0;
+ if (tintStrength > 0) {
+ const wR =
+ (tCfg.tintColor?.[0] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW +
+ (fCfg.tintColor?.[0] ?? 0) * (fCfg.tintStrength ?? 0) * forestW +
+ (cCfg.tintColor?.[0] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW;
+ const wG =
+ (tCfg.tintColor?.[1] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW +
+ (fCfg.tintColor?.[1] ?? 0) * (fCfg.tintStrength ?? 0) * forestW +
+ (cCfg.tintColor?.[1] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW;
+ const wB =
+ (tCfg.tintColor?.[2] ?? 0) * (tCfg.tintStrength ?? 0) * tundraW +
+ (fCfg.tintColor?.[2] ?? 0) * (fCfg.tintStrength ?? 0) * forestW +
+ (cCfg.tintColor?.[2] ?? 0) * (cCfg.tintStrength ?? 0) * canyonW;
+ const inv = 1 / tintStrength;
+ tintR = wR * inv;
+ tintG = wG * inv;
+ tintB = wB * inv;
+ }
+
+ return {
+ ...color,
+ grassPlacement,
+ grassHeightScale,
+ tintR,
+ tintG,
+ tintB,
+ tintStrength,
+ nx: rnx * invLen,
+ ny: rny * invLen,
+ nz: rnz * invLen,
+ };
+ }
+
computeBiomeWeightsByPosition(
worldX: number,
worldZ: number,
@@ -5307,7 +5398,18 @@ export class TerrainSystem extends System {
const centers = this.getTerrainCenters();
if (centers.length > 0) {
const pos = centers[0].position;
+
+ // Set grass player position BEFORE quad-tree update so
+ // onNodeNeedsGeometry dispatches with correct LOD & distance
+ if (this.grassVisualManager) {
+ this.grassVisualManager.setPlayerPosition(pos.x, pos.z);
+ }
+
this.quadTreeVisualManager.update(pos.x, pos.z);
+
+ if (this.grassVisualManager) {
+ this.grassVisualManager.update(pos.x, pos.z, this.world.camera);
+ }
}
}
@@ -5346,27 +5448,28 @@ export class TerrainSystem extends System {
if (materialWithUniforms) {
materialWithUniforms.terrainUniforms.time.value = this.terrainTime;
- // Sync sun direction and shade color from Environment system
+ // Sync sun direction + day intensity from Environment system
const env = this.world.getSystem("environment") as {
lightDirection?: THREE.Vector3;
- hemisphereLight?: { color: THREE.Color };
+ getDayIntensity?: () => number;
} | null;
if (env?.lightDirection) {
materialWithUniforms.terrainUniforms.sunDirection.value
.copy(env.lightDirection)
.negate();
- }
- if (env?.hemisphereLight) {
- const c = env.hemisphereLight.color;
- const avg = (c.r + c.g + c.b) / 3;
- if (avg > 0.01) {
- (materialWithUniforms.terrainUniforms.shadeColor.value as any).set(
- c.r / avg,
- c.g / avg,
- c.b / avg,
+ if (this.grassVisualManager) {
+ this.grassVisualManager.updateLighting(
+ materialWithUniforms.terrainUniforms.sunDirection.value,
);
}
}
+ if (env?.getDayIntensity) {
+ const dayVal = env.getDayIntensity();
+ materialWithUniforms.terrainUniforms.dayIntensity.value = dayVal;
+ if (this.grassVisualManager) {
+ this.grassVisualManager.updateDayIntensity(dayVal);
+ }
+ }
// Fog texture is the shared fogRenderTarget from FogConfig — no sync needed
@@ -6026,35 +6129,6 @@ export class TerrainSystem extends System {
}
}
- // ---- PASS 1c: River water flags ----
- // For each movement tile, check if its center is inside the river channel
- // and terrain is below river surfaceY. Uses the same waterTileFlags array
- // so river tiles are treated as water for slope/biome checks.
- const riverDef = this.waterBodyRegistry.getRiverDef();
- if (riverDef) {
- const rAABBs = this.waterBodyRegistry.getRiverAABBs();
- for (let lx = 0; lx < tilesPerSide; lx++) {
- const flagRow = lx * tilesPerSide;
- const wx = originX + lx + 0.5;
- for (let lz = 0; lz < tilesPerSide; lz++) {
- if (waterTileFlags[flagRow + lz]) continue; // Already flagged
- const wz = originZ + lz + 0.5;
- const proj = projectOntoRiver(wx, wz, riverDef, rAABBs);
- if (!proj || proj.dist >= proj.halfWidth) continue;
- // Check terrain height at tile center vs river surfaceY
- const terrainH = this.getHeightAt(wx, wz);
- if (!isNaN(proj.surfaceY) && terrainH < proj.surfaceY) {
- waterTileFlags[flagRow + lz] = 1;
- collision.addFlags(
- originXInt + lx,
- originZInt + lz,
- CollisionFlag.WATER,
- );
- }
- }
- }
- }
-
// ---- PASS 2: Biome + slope flags (non-water tiles only) ----
for (let lx = 0; lx < tilesPerSide; lx++) {
const worldX = originX + lx + 0.5;
@@ -6307,53 +6381,6 @@ export class TerrainSystem extends System {
tile.waterMeshes.push(bodyMesh);
}
}
-
- // River water meshes — per-vertex Y follows interpolated surfaceY for
- // natural slope from highland source to ocean mouth (no flat-plane jumps).
- const riverDefLocal = this.waterBodyRegistry.getRiverDef();
- if (riverDefLocal && this.waterSystem) {
- const rAABBs = this.waterBodyRegistry.getRiverAABBs();
- // Tile geometry is centered at (tile.x * tileSize, tile.z * tileSize)
- const tileCX = tile.x * tileSize;
- const tileCZ = tile.z * tileSize;
- const tMinX = tileCX - tileSize * 0.5;
- const tMaxX = tileCX + tileSize * 0.5;
- const tMinZ = tileCZ - tileSize * 0.5;
- const tMaxZ = tileCZ + tileSize * 0.5;
-
- // Check if any river segment AABB overlaps this tile
- let riverOverlapsTile = false;
- for (const bb of rAABBs) {
- if (
- bb.maxX >= tMinX &&
- bb.minX <= tMaxX &&
- bb.maxZ >= tMinZ &&
- bb.minZ <= tMaxZ
- ) {
- riverOverlapsTile = true;
- break;
- }
- }
-
- if (riverOverlapsTile) {
- const riverMesh = this.waterSystem.generateRiverWaterMesh(
- tile,
- tileSize,
- riverDefLocal,
- rAABBs,
- );
- if (riverMesh && tile.mesh) {
- if (this.CONFIG.USE_QUADTREE_LOD && this.terrainContainer) {
- riverMesh.position.x = tile.x * tileSize;
- riverMesh.position.z = tile.z * tileSize;
- this.terrainContainer.add(riverMesh);
- } else {
- tile.mesh.add(riverMesh);
- }
- tile.waterMeshes.push(riverMesh);
- }
- }
- }
}
/**
@@ -6643,9 +6670,7 @@ export class TerrainSystem extends System {
worldZ: number,
overrideDifficultyLevel?: number,
): DifficultySample {
- if (!this.noise) {
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
- }
+ this.ensureNoiseInitialized();
const biome = this.getBiomeAtWorldPosition(worldX, worldZ);
const biomeData = BIOMES[biome];
@@ -6807,9 +6832,7 @@ export class TerrainSystem extends System {
private generateBossHotspots(): void {
if (this.bossHotspots.length > 0) return;
- if (!this.noise) {
- this.noise = new NoiseGenerator(this.computeSeedFromWorldId());
- }
+ this.ensureNoiseInitialized();
const worldSizeMeters = this.getActiveWorldSizeMeters();
const halfWorld = worldSizeMeters / 2;
@@ -6983,9 +7006,20 @@ export class TerrainSystem extends System {
this.quadTreeVisualManager = null;
}
+ if (this.waterVisualManager) {
+ this.waterVisualManager.destroy();
+ this.waterVisualManager = null;
+ }
+
+ if (this.grassVisualManager) {
+ this.grassVisualManager.destroy();
+ this.grassVisualManager = null;
+ }
+
// Terminate worker pools to free resources
terminateTerrainWorkerPool();
terminateQuadChunkWorkerPool();
+ terminateGrassWorkerPool();
// Clear pending worker results
this.pendingWorkerResults.clear();
@@ -7187,14 +7221,11 @@ export class TerrainSystem extends System {
// Embedded spectator prioritizes first-frame time over long-range preload.
if (isServerRuntime) {
- // Server does not render horizon terrain, so keep chunk windows tight to
- // avoid runaway memory when many autonomous agents are active.
- // Reduced tile budget to minimize GC pressure from Float32Array allocations.
- this.coreChunkRange = 1; // 3x3 core grid
- this.ringChunkRange = 1; // No extra preload ring
- this.terrainOnlyChunkRange = 0; // Never load render-only distant tiles
- this.maxTilesPerFrame = 1;
- this.generationBudgetMsPerFrame = 2;
+ this.coreChunkRange = 3; // 7x7 core grid (full simulation)
+ this.ringChunkRange = 5; // 11x11 ring — resource content generated here
+ this.terrainOnlyChunkRange = 0; // No render-only tiles on server
+ this.maxTilesPerFrame = 4;
+ this.generationBudgetMsPerFrame = 6;
} else if (isEmbeddedSpectator) {
this.coreChunkRange = 1; // 3x3 core grid
this.ringChunkRange = 2; // Preload ring up to 5x5
@@ -7202,12 +7233,12 @@ export class TerrainSystem extends System {
this.maxTilesPerFrame = 4; // Catch up faster after camera retargets
this.generationBudgetMsPerFrame = 10;
} else {
- // Balanced load radius to reduce generation spikes when moving
- this.coreChunkRange = 2; // 5x5 core grid
- this.ringChunkRange = 3; // Preload ring up to ~7x7
- this.terrainOnlyChunkRange = 5;
- this.maxTilesPerFrame = 2;
- this.generationBudgetMsPerFrame = 6;
+ // Client: load tiles out to match LOD fade distances (~1000m)
+ this.coreChunkRange = 3; // 7x7 core grid
+ this.ringChunkRange = 5; // 11x11 ring — content generated here (~1100m)
+ this.terrainOnlyChunkRange = 7; // Terrain mesh horizon (~1500m)
+ this.maxTilesPerFrame = 3;
+ this.generationBudgetMsPerFrame = 8;
}
// Initialize tracking maps
diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts
index 9f4391f3d..654b95bc7 100644
--- a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts
+++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts
@@ -74,11 +74,15 @@ export class TerrainVisualManager implements QuadTreeListener {
private cancelledNodeIds = new Set();
/** Tracks generation failure count per node ID for bounded retry */
private failedAttempts = new Map();
+ /** Whether initial sync bootstrap has run for the current tree structure */
+ private syncBootstrapped = false;
private maxSyncChunksPerFrame: number;
private maxAssembliesPerFrame: number;
- private static MAX_GENERATION_RETRIES = 3;
+ private framesSinceInit = 0;
+ private static BURST_FRAMES = 30;
+ private static MAX_GENERATION_RETRIES = 5;
constructor(
config: Partial,
@@ -116,9 +120,24 @@ export class TerrainVisualManager implements QuadTreeListener {
update(playerX: number, playerZ: number): void {
this.playerX = playerX;
this.playerZ = playerZ;
- this.quadTree.update(playerX, playerZ);
+ const structureChanged = this.quadTree.update(playerX, playerZ);
+ if (structureChanged) {
+ this.framesSinceInit = 0;
+ this.syncBootstrapped = false;
+ }
+
+ if (
+ !this.syncBootstrapped &&
+ this.framesSinceInit >= 1 &&
+ this.chunks.size === 0 &&
+ this.pendingNodeIds.size > 0
+ ) {
+ this.syncBootstrapNearbyChunks();
+ }
+
this.processSettledResults();
this.processSyncQueue();
+ this.framesSinceInit++;
}
dispose(): void {
@@ -170,6 +189,37 @@ export class TerrainVisualManager implements QuadTreeListener {
this.workerBiomes = biomes;
}
+ /**
+ * Invalidate quad-tree chunks that overlap a world-space AABB.
+ * Destroys affected chunks so they get regenerated on the next update
+ * with current flat-zone / road data. Called when flat zones are
+ * registered after initial terrain generation.
+ */
+ invalidateRegion(
+ minX: number,
+ minZ: number,
+ maxX: number,
+ maxZ: number,
+ ): void {
+ for (const [key, chunk] of this.chunks) {
+ const node = chunk.node;
+ const half = node.size * 0.5;
+ const nMinX = node.centerX - half;
+ const nMaxX = node.centerX + half;
+ const nMinZ = node.centerZ - half;
+ const nMaxZ = node.centerZ + half;
+
+ if (nMaxX < minX || nMinX > maxX || nMaxZ < minZ || nMinZ > maxZ) {
+ continue;
+ }
+
+ this.removeMeshFromScene(chunk);
+ this.chunks.delete(key);
+ node.visualChunkKey = null;
+ node.terrainNeedsUpdate = true;
+ }
+ }
+
// =========================================================================
// QuadTreeListener implementation
// =========================================================================
@@ -240,7 +290,24 @@ export class TerrainVisualManager implements QuadTreeListener {
private processSettledResults(): void {
if (this.settledResults.length === 0) return;
- const batch = this.settledResults.splice(0, this.maxAssembliesPerFrame);
+ // Sort by distance to player (nearest first) for minimal visible holes.
+ const px = this.playerX;
+ const pz = this.playerZ;
+ this.settledResults.sort((a, b) => {
+ const da = (a.node.centerX - px) ** 2 + (a.node.centerZ - pz) ** 2;
+ const db = (b.node.centerX - px) ** 2 + (b.node.centerZ - pz) ** 2;
+ return da - db;
+ });
+
+ // During initial burst or when many results are pending, process all
+ // of them to avoid prolonged holes.
+ const isBurst =
+ this.framesSinceInit < TerrainVisualManager.BURST_FRAMES ||
+ this.settledResults.length > this.maxAssembliesPerFrame * 3;
+ const limit = isBurst
+ ? this.settledResults.length
+ : this.maxAssembliesPerFrame;
+ const batch = this.settledResults.splice(0, limit);
for (const entry of batch) {
if (this.cancelledNodeIds.has(entry.nodeId)) {
@@ -265,11 +332,21 @@ export class TerrainVisualManager implements QuadTreeListener {
private processSyncQueue(): void {
if (this.syncQueue.length === 0) return;
+ const px = this.playerX;
+ const pz = this.playerZ;
+ this.syncQueue.sort((a, b) => {
+ const da = (a.centerX - px) ** 2 + (a.centerZ - pz) ** 2;
+ const db = (b.centerX - px) ** 2 + (b.centerZ - pz) ** 2;
+ return da - db;
+ });
+
+ const isBurst =
+ this.framesSinceInit < TerrainVisualManager.BURST_FRAMES ||
+ this.syncQueue.length > this.maxSyncChunksPerFrame * 3;
+ const limit = isBurst ? this.syncQueue.length : this.maxSyncChunksPerFrame;
+
let generated = 0;
- while (
- this.syncQueue.length > 0 &&
- generated < this.maxSyncChunksPerFrame
- ) {
+ while (this.syncQueue.length > 0 && generated < limit) {
const node = this.syncQueue.shift()!;
if (!node.isFinal || node.visualChunkKey !== null) continue;
this.generateChunkSync(node);
@@ -277,6 +354,51 @@ export class TerrainVisualManager implements QuadTreeListener {
}
}
+ // =========================================================================
+ // Sync bootstrap — generate nearest chunks synchronously to avoid holes
+ // during initial load while workers are still spinning up.
+ // =========================================================================
+
+ private static SYNC_BOOTSTRAP_MAX = 30;
+ private static SYNC_BOOTSTRAP_RADIUS_SQ = 1200 * 1200;
+
+ private syncBootstrapNearbyChunks(): void {
+ this.syncBootstrapped = true;
+
+ const leafNodes = this.quadTree
+ .getFinalNodes()
+ .filter((n) => n.isFinal && n.visualChunkKey === null);
+
+ if (leafNodes.length === 0) return;
+
+ const px = this.playerX;
+ const pz = this.playerZ;
+ leafNodes.sort((a, b) => {
+ const da = (a.centerX - px) ** 2 + (a.centerZ - pz) ** 2;
+ const db = (b.centerX - px) ** 2 + (b.centerZ - pz) ** 2;
+ return da - db;
+ });
+
+ const radiusSq = TerrainVisualManager.SYNC_BOOTSTRAP_RADIUS_SQ;
+ const maxCount = TerrainVisualManager.SYNC_BOOTSTRAP_MAX;
+ let count = 0;
+
+ for (const node of leafNodes) {
+ if (count >= maxCount) break;
+ const dx = node.centerX - px;
+ const dz = node.centerZ - pz;
+ if (dx * dx + dz * dz > radiusSq) break;
+
+ if (this.pendingNodeIds.has(node.id)) {
+ this.cancelledNodeIds.add(node.id);
+ this.pendingNodeIds.delete(node.id);
+ }
+
+ this.generateChunkSync(node);
+ count++;
+ }
+ }
+
// =========================================================================
// Chunk assembly
// =========================================================================
diff --git a/packages/shared/src/systems/shared/world/VegetationSystem.ts b/packages/shared/src/systems/shared/world/VegetationSystem.ts
index 6ae32a843..bda3cb913 100644
--- a/packages/shared/src/systems/shared/world/VegetationSystem.ts
+++ b/packages/shared/src/systems/shared/world/VegetationSystem.ts
@@ -117,10 +117,9 @@ const SKIP_LOD1_CATEGORIES = new Set([
const VEGETATION_CHUNK_SIZE = 64; // meters per chunk
const MAX_INSTANCES_PER_CHUNK = 256;
-// Default fade distances - overridden by shadow quality settings in start()
-let FADE_START = 180; // Shader fade begins (fully opaque inside)
-let FADE_END = 200; // Shader fully culls (invisible beyond)
-let CHUNK_RENDER_DISTANCE = 300; // CPU hides chunks (buffer zone for loading)
+let FADE_START = 1000; // Shader fade begins (fully opaque inside)
+let FADE_END = 1200; // Shader fully culls (invisible beyond)
+let CHUNK_RENDER_DISTANCE = 1400; // CPU hides chunks (buffer zone for loading)
// NOTE: Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES
// Access via getLODDistances(category) for actual LOD decisions.
@@ -264,8 +263,7 @@ function getAssetLODConfig(category: string, boundingSize?: number) {
}
/** Max tile distance from player to generate vegetation (in tiles, not world units) */
-// PERFORMANCE: Reduced from 3 to 2 tiles - vegetation fades before this distance anyway
-const MAX_VEGETATION_TILE_RADIUS = 2;
+const MAX_VEGETATION_TILE_RADIUS = 5;
/** Water threshold from centralized constants */
import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
@@ -789,11 +787,11 @@ export class VegetationSystem extends System {
csmLevels[shadowsLevel as keyof typeof csmLevels] || csmLevels.med;
const shadowMaxFar = csmConfig.maxFar;
- // Sync fade distances with shadow range
- // Vegetation fully dissolves AT shadow maxFar so we never see unshadowed trees
- FADE_END = shadowMaxFar; // Fully dissolved at shadow cutoff
- FADE_START = shadowMaxFar * 0.9; // Start dissolving at 90% of shadow range
- CHUNK_RENDER_DISTANCE = shadowMaxFar * 1.2; // Chunks visible 20% beyond shadow range
+ // Fade distances are independent of shadow range — distant trees render
+ // without shadows rather than disappearing at the shadow cutoff.
+ FADE_START = 1000;
+ FADE_END = 1200;
+ CHUNK_RENDER_DISTANCE = 1400;
// Imposter distances - switch to billboard before dissolve zone
// LOD transition: 3D mesh -> Billboard imposter -> Dissolve -> Cull
diff --git a/packages/shared/src/systems/shared/world/WaterBodyRegistry.ts b/packages/shared/src/systems/shared/world/WaterBodyRegistry.ts
index 4d69e1b92..d831f49b7 100644
--- a/packages/shared/src/systems/shared/world/WaterBodyRegistry.ts
+++ b/packages/shared/src/systems/shared/world/WaterBodyRegistry.ts
@@ -2,21 +2,13 @@
* WaterBodyRegistry — spatial index of water bodies at arbitrary elevations.
*
* Ocean water (terrain < WATER_THRESHOLD) is the fallback.
- * Inland water bodies (mountain ponds, highland lakes) are registered
- * explicitly with per-body surfaceY elevations.
+ * Inland water bodies (lakes, ponds) can be registered explicitly
+ * with per-body surfaceY elevations.
*
* Spatial grid: O(1) hash + O(K) body check where K ≈ 0-2 per cell.
*/
-import type { LandscapeFeatureDef } from "./TerrainHeightParams";
-import type { RiverDefinition } from "./RiverDefinition";
-import type { RiverSegmentAABB } from "./RiverUtils";
-import { projectOntoRiver, computeRiverSegmentAABBs } from "./RiverUtils";
-
-export type WaterBodySourceType =
- | "landscape_pond"
- | "explicit"
- | "river_segment";
+export type WaterBodySourceType = "explicit" | "landscape_pond";
export class ElevatedWaterBody {
id: string;
@@ -46,7 +38,6 @@ export class ElevatedWaterBody {
}
}
-/** Packed grid key — no string allocation. 131072 = 2^17, enough for ±65k cells. */
function gridKey(cx: number, cz: number): number {
return cx * 131072 + cz;
}
@@ -56,15 +47,12 @@ export class WaterBodyRegistry {
private gridCellSize: number;
private grid: Map = new Map();
private oceanLevel: number;
- private riverDef: RiverDefinition | null = null;
- private riverAABBs: RiverSegmentAABB[] = [];
constructor(oceanLevel: number, gridCellSize = 50) {
this.oceanLevel = oceanLevel;
this.gridCellSize = gridCellSize;
}
- /** Register a water body and insert it into the spatial grid. */
register(data: {
id: string;
centerX: number;
@@ -74,7 +62,6 @@ export class WaterBodyRegistry {
surfaceY: number;
sourceType: WaterBodySourceType;
}): void {
- // Guard against duplicate registrations
if (this.bodies.some((b) => b.id === data.id)) {
console.warn(
`[WaterBodyRegistry] Duplicate water body ID "${data.id}" — skipping`,
@@ -85,7 +72,6 @@ export class WaterBodyRegistry {
const idx = this.bodies.length;
this.bodies.push(body);
- // Insert body index into all grid cells its bounding circle overlaps
const cs = this.gridCellSize;
const minCX = Math.floor((body.centerX - body.radius) / cs);
const maxCX = Math.floor((body.centerX + body.radius) / cs);
@@ -105,7 +91,6 @@ export class WaterBodyRegistry {
}
}
- /** Get the water body containing the point, or null. If multiple overlap, returns highest surfaceY. */
getBodyAt(worldX: number, worldZ: number): ElevatedWaterBody | null {
const cs = this.gridCellSize;
const cx = Math.floor(worldX / cs);
@@ -127,49 +112,16 @@ export class WaterBodyRegistry {
return best;
}
- /** Register a river definition. Computes AABBs and enables river lookups. */
- registerRiver(river: RiverDefinition): void {
- this.riverDef = river;
- this.riverAABBs = computeRiverSegmentAABBs(river);
- }
-
- /** Get the registered river definition, or null. */
- getRiverDef(): RiverDefinition | null {
- return this.riverDef;
- }
-
- /** Get the pre-computed river segment AABBs. */
- getRiverAABBs(): RiverSegmentAABB[] {
- return this.riverAABBs;
- }
-
- /** Get effective water surface at a world position: body surfaceY if inside one, river surfaceY if in river, else ocean level. */
getWaterSurfaceAt(worldX: number, worldZ: number): number {
const body = this.getBodyAt(worldX, worldZ);
if (body) return body.surfaceY;
-
- // Check river
- if (this.riverDef) {
- const proj = projectOntoRiver(
- worldX,
- worldZ,
- this.riverDef,
- this.riverAABBs,
- );
- if (proj && proj.dist < proj.halfWidth && !isNaN(proj.surfaceY)) {
- return proj.surfaceY;
- }
- }
-
return this.oceanLevel;
}
- /** Check if terrain at this position is underwater (below effective water surface). */
isUnderwater(worldX: number, worldZ: number, terrainHeight: number): boolean {
return terrainHeight < this.getWaterSurfaceAt(worldX, worldZ);
}
- /** Get all bodies whose bounding circle overlaps the given tile rectangle. */
getBodiesInTile(
tileX: number,
tileZ: number,
@@ -183,7 +135,6 @@ export class WaterBodyRegistry {
const result: ElevatedWaterBody[] = [];
const seen = new Set();
- // Find all grid cells that overlap the tile AABB
const cs = this.gridCellSize;
const cMinX = Math.floor(minX / cs);
const cMaxX = Math.floor(maxX / cs);
@@ -200,7 +151,6 @@ export class WaterBodyRegistry {
seen.add(idx);
const body = this.bodies[idx];
- // AABB-circle intersection test
const closestX = Math.max(minX, Math.min(maxX, body.centerX));
const closestZ = Math.max(minZ, Math.min(maxZ, body.centerZ));
const dx = closestX - body.centerX;
@@ -214,53 +164,11 @@ export class WaterBodyRegistry {
return result;
}
- /**
- * Check if a point is inside the river channel.
- * Returns the interpolated surfaceY if inside, or null if outside.
- */
- getRiverSurfaceAt(worldX: number, worldZ: number): number | null {
- if (!this.riverDef) return null;
- const proj = projectOntoRiver(
- worldX,
- worldZ,
- this.riverDef,
- this.riverAABBs,
- );
- if (proj && proj.dist < proj.halfWidth && !isNaN(proj.surfaceY)) {
- return proj.surfaceY;
- }
- return null;
- }
-
- /** Get all registered water bodies. */
getAllBodies(): ReadonlyArray {
return this.bodies;
}
- /** Get the ocean fallback level. */
getOceanLevel(): number {
return this.oceanLevel;
}
-
- /**
- * Sample N points around a landscape feature's rim circle and return
- * the minimum height — the "pour point" / spill height. This becomes
- * the water body's surfaceY (water fills up to the lowest rim point).
- */
- static computeRimHeight(
- feature: LandscapeFeatureDef,
- getHeightAt: (x: number, z: number) => number,
- sampleCount = 64,
- ): number {
- let minHeight = Infinity;
- const step = (Math.PI * 2) / sampleCount;
- for (let i = 0; i < sampleCount; i++) {
- const angle = i * step;
- const sx = feature.x + Math.cos(angle) * feature.radius;
- const sz = feature.z + Math.sin(angle) * feature.radius;
- const h = getHeightAt(sx, sz);
- if (Number.isFinite(h) && h < minHeight) minHeight = h;
- }
- return Number.isFinite(minHeight) ? minHeight : 0;
- }
}
diff --git a/packages/shared/src/systems/shared/world/WaterSystem.ts b/packages/shared/src/systems/shared/world/WaterSystem.ts
index 46c1625e0..71ecde85a 100644
--- a/packages/shared/src/systems/shared/world/WaterSystem.ts
+++ b/packages/shared/src/systems/shared/world/WaterSystem.ts
@@ -1,8 +1,10 @@
/**
* WaterSystem - AAA Lake Water Shader (WebGPU TSL)
*
- * Features: Gerstner waves, GGX specular, Beer-Lambert absorption,
- * subsurface scattering, foam, multi-layer detail normals, planar reflections.
+ * Features: Gerstner waves (5-wave), Phong specular, cosine-gradient depth
+ * colour, flow-mapped 4-scroll detail normals (two-phase crossfade via
+ * FlowUVW from cloud-sea technique), Schlick fresnel, Worley foam,
+ * planar reflections (lake), day/night + fog integration.
*/
import THREE, {
@@ -10,6 +12,7 @@ import THREE, {
texture,
positionWorld,
positionLocal,
+ reflector,
screenUV,
cameraPosition,
uniform,
@@ -30,12 +33,13 @@ import THREE, {
max,
smoothstep,
clamp,
+ saturate,
+ fract,
+ abs,
Fn,
output,
attribute,
- exp,
length,
- reflector,
viewportDepthTexture,
linearDepth,
cameraNear,
@@ -47,9 +51,8 @@ import type { World } from "../../../types";
import type { TerrainTile } from "../../../types/world/terrain";
import type { Wind } from "./Wind";
import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig";
-import type { RiverDefinition } from "./RiverDefinition";
-import type { RiverSegmentAABB } from "./RiverUtils";
-import { projectOntoRiver } from "./RiverUtils";
+import { SUN_SHADE, NIGHT, applySunShade } from "./LightingConfig";
+import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants";
// ============================================================================
// CONFIGURATION
@@ -61,61 +64,53 @@ const TWO_PI = PI * 2;
// ---- Water visual tuning ----
const WATER = {
- // Fresnel & specular
- F0: 0.02, // Fresnel reflectance at normal incidence
- ROUGHNESS: 0.02, // GGX specular roughness (lower = sharper sun highlights)
- SPECULAR_MULTIPLIER: 2.5, // Sun specular highlight intensity
- SUN_COLOR: { r: 1.0, g: 0.98, b: 0.92 }, // Sunlight tint on specular
-
- // Reflection
- REFLECTION_INTENSITY: 0.4, // Planar reflection strength (fresnel-weighted)
-
- // Colour / absorption (Beer-Lambert)
- SHALLOW_COLOR: { r: 0.15, g: 0.42, b: 0.48 }, // Colour near shore
- DEEP_COLOR: { r: 0.02, g: 0.08, b: 0.12 }, // Colour far from shore
- ABSORPTION: { r: 0.45, g: 0.09, b: 0.06 }, // Per-channel absorption rate
- MAX_DEPTH: 30, // Clamp depth for absorption calc (metres)
-
- // Subsurface scattering
- SSS_INTENSITY: 0.35, // SSS glow strength
- SSS_POWER: 3, // SSS falloff exponent
- SSS_RANGE_FAR: 8, // SSS starts fading at this distance (metres)
- SSS_RANGE_NEAR: 0.5, // SSS fully active below this distance
- SSS_COLOR: { r: 0.1, g: 0.35, b: 0.3 }, // SSS tint
+ REFLECTION_INTENSITY: 0.4,
+ WAVE_DAMP_DISTANCE: 6,
+ MAX_DEPTH: 30,
+
+ // Fresnel (Schlick approximation, rf0 = 0.3)
+ RF0: 0.3,
+
+ // Phong sun lighting
+ SPECULAR_SHININESS: 100,
+ SPECULAR_STRENGTH: 5.0,
+ DIFFUSE_STRENGTH: 0.5,
+
+ // Depth-based opacity: op = 1 - pow(sat(1 - depth/scale), falloff)
+ OP_DEPTH_SCALE: 15,
+ OP_DEPTH_FALLOFF: 3,
+
+ // Depth-based colour gradient
+ COLOR_DEPTH_SCALE: 50,
+ COLOR_DEPTH_FALLOFF: 3,
+ COLOR_DIST_FADE: 200,
+
+ // Cosine gradient colour parameters (deep blue, subtle indigo)
+ COS_PHASES: [0.5, 0.48, 0.5] as const,
+ COS_AMPLITUDES: [0.04, 0.16, 0.15] as const,
+ COS_FREQUENCIES: [0.5, 0.48, 0.5] as const,
+ COS_OFFSETS: [-0.46, -0.22, -0.03] as const,
+
+ // Normal noise strength (xz multiplier for surface normal)
+ NORMAL_STRENGTH: 1.5,
// Foam
- FOAM_SHORE_DISTANCE: 2.5, // Shore foam appears within this distance (metres)
- FOAM_CREST_MIN: 0.15, // Wave crest foam threshold (low)
- FOAM_CREST_MAX: 0.4, // Wave crest foam threshold (high)
- FOAM_CREST_MULTIPLIER: 0.6, // Crest foam intensity relative to shore foam
- FOAM_COLOR: { r: 0.92, g: 0.94, b: 0.96 }, // Foam colour (near-white)
- FOAM_MAX_OPACITY: 0.85, // Maximum foam blend factor
- FOAM_SCROLL_X: 0.02, // Foam texture scroll speed X
- FOAM_SCROLL_Y: 0.015, // Foam texture scroll speed Y
- FOAM_SCALE: 0.1, // Foam texture UV scale
-
- // Detail normals blending
- DETAIL_NORMAL_STRENGTH: 0.5, // Detail normal contribution to final normal
-
- // Opacity
- EDGE_FADE_DISTANCE: 0.4, // Shoreline edge transparency ramp (metres)
- DEPTH_FADE_NEAR: 0.4, // Depth opacity ramp start (metres)
- DEPTH_FADE_FAR: 6.0, // Depth opacity ramp end (metres)
- OPACITY_MIN: 0.2, // Opacity at shoreline
- OPACITY_MAX: 0.7, // Opacity at full depth
- FRESNEL_OPACITY_MIN: 0.85, // Fresnel opacity at normal incidence
- FRESNEL_OPACITY_MAX: 1.0, // Fresnel opacity at glancing angles
- FRESNEL_OPACITY_POWER: 3, // Fresnel opacity falloff exponent
-
- // Vertex wave damping
- WAVE_DAMP_DISTANCE: 6, // Waves fully active beyond this shore distance (metres)
-
- // Normal map scrolling
- NORMAL_UV1_SPEED: { x: 0.005, y: 0.003 },
- NORMAL_UV1_SCALE: 0.02,
- NORMAL_UV2_SPEED: { x: -0.004, y: 0.006 },
- NORMAL_UV2_SCALE: 0.035,
- NORMAL_BLEND_STRENGTH: 0.4, // Normal map XZ strength (Y stays 1.0)
+ FOAM_SHORE_DISTANCE: 2.5,
+ FOAM_CREST_MIN: 0.15,
+ FOAM_CREST_MAX: 0.4,
+ FOAM_CREST_MULTIPLIER: 0.6,
+ FOAM_COLOR: { r: 0.9, g: 0.91, b: 0.96 },
+ FOAM_MAX_OPACITY: 0.85,
+ FOAM_SCROLL_X: 0.02,
+ FOAM_SCROLL_Y: 0.015,
+ FOAM_SCALE: 0.1,
+
+ // Flow mapping (two-phase crossfade, ported from cloud-sea FlowUVW)
+ FLOW_SPEED: 0.05,
+ FLOW_STRENGTH: 1.0,
+ FLOW_OFFSET: -0.1,
+ FLOW_JUMP: [0.5, -0.25] as const,
+ FLOW_UV_SCALE: 0.001,
};
// LOD configuration for water mesh resolution
@@ -127,9 +122,6 @@ const WATER_LOD = {
MEDIUM_DISTANCE: 200, // Distance threshold for medium->low LOD
};
-// Maximum distance for reflection camera to be active (only for lakes within this range)
-const REFLECTION_MAX_DISTANCE = 200;
-
type WaveParams = {
w: number;
phi: number;
@@ -165,13 +157,6 @@ const WAVES: WaveParams[] = [
};
});
-// Reduced from 4 to 2 layers for better performance (2 texture samples instead of 4)
-const NORMAL_LAYERS: [number, number, number][] = [
- [0.015, 0.005, 0.003],
- [0.04, -0.008, 0.005],
-];
-const NORMAL_WEIGHTS = [0.6, 0.4];
-
// ============================================================================
// TYPES
// ============================================================================
@@ -184,12 +169,8 @@ export type WaterUniforms = {
sunDirection: UniformVec3;
windStrength: UniformFloat;
reflectionIntensity: UniformFloat;
-};
-
-// Reflector node type from TSL
-type ReflectorNode = ReturnType & {
- target: THREE.Object3D;
- uvNode: ReturnType;
+ dayIntensity: UniformFloat;
+ sunIntensity: UniformFloat;
};
/**
@@ -206,25 +187,19 @@ export type WaterBodyType = "lake" | "ocean";
export class WaterSystem {
private world: World;
private waterTime = 0;
- private lakeMaterial?: MeshStandardNodeMaterial; // Lake/pond water with reflections
- private oceanMaterial?: MeshStandardNodeMaterial; // Ocean water without reflections
+ private lakeMaterial?: MeshStandardNodeMaterial;
+ private oceanMaterial?: MeshStandardNodeMaterial;
private uniforms: WaterUniforms | null = null;
private oceanUniforms: WaterUniforms | null = null;
- private normalTex1?: THREE.Texture;
- private normalTex2?: THREE.Texture;
+ private normalTex?: THREE.Texture;
private foamTex?: THREE.Texture;
+ private flowTex?: THREE.Texture;
- // Planar reflection using TSL reflector
- private reflection?: ReflectorNode;
- private waterLevel = 5;
+ // TSL planar reflection (Three.js ReflectorNode handles camera, RT, clipping)
+ private reflection?: ReturnType;
+ private waterLevel: number = TERRAIN_CONSTANTS.WATER_THRESHOLD;
private waterMeshes: THREE.Mesh[] = [];
- // Frustum culling for reflection optimization
- private frustum = new THREE.Frustum();
- private projScreenMatrix = new THREE.Matrix4();
- private tempSphere = new THREE.Sphere();
-
- // Reflection state tracking for DevStats
private reflectionActive = false;
// User preference for reflections (can be toggled)
@@ -233,6 +208,8 @@ export class WaterSystem {
// Wind system reference for coordinated wind effects
private windSystem: Wind | null = null;
+ private static _textureLoader = new THREE.TextureLoader();
+
constructor(world: World) {
this.world = world;
}
@@ -253,17 +230,13 @@ export class WaterSystem {
// Update reflection intensity uniform - this actually disables reflections in the shader
if (this.uniforms) {
- this.uniforms.reflectionIntensity.value = enabled ? 0.4 : 0.0;
+ this.uniforms.reflectionIntensity.value = enabled
+ ? WATER.REFLECTION_INTENSITY
+ : 0.0;
}
- // Update reflector visibility based on setting
- if (this.reflection?.target) {
- // When disabled, hide the reflector to save GPU resources
- if (!enabled) {
- this.reflection.target.visible = false;
- this.reflectionActive = false;
- }
- // When enabled, visibility will be controlled by frustum culling in update()
+ if (!enabled) {
+ this.reflectionActive = false;
}
// Reflection state toggled
@@ -295,6 +268,31 @@ export class WaterSystem {
return this.reflectionActive ? 1 : 0;
}
+ /**
+ * Set the Y level used for the reflection mirror plane.
+ */
+ setWaterLevel(y: number): void {
+ this.waterLevel = y;
+ if (this.reflection?.target) {
+ this.reflection.target.position.y = y;
+ }
+ }
+
+ /**
+ * Register an externally-created water mesh for reflection visibility tracking.
+ */
+ registerWaterMesh(mesh: THREE.Mesh): void {
+ this.waterMeshes.push(mesh);
+ }
+
+ /**
+ * Unregister an externally-created water mesh from reflection tracking.
+ */
+ unregisterWaterMesh(mesh: THREE.Mesh): void {
+ const idx = this.waterMeshes.indexOf(mesh);
+ if (idx !== -1) this.waterMeshes.splice(idx, 1);
+ }
+
/**
* Returns the total number of water meshes being tracked
*/
@@ -318,49 +316,64 @@ export class WaterSystem {
async init(): Promise {
if (this.world.isServer) return;
- // Create procedural textures with yielding (prevents main thread blocking)
- // These are CPU-intensive nested loops that can block for 50-100ms without yielding
- this.normalTex1 = await this.createNormalMap(256, 1.0, 42);
- this.normalTex2 = await this.createNormalMap(128, 2.0, 137);
+ const cachedLoader = WaterSystem._textureLoader;
+
+ const loadTex = (url: string): Promise =>
+ new Promise((resolve, reject) => {
+ cachedLoader.load(
+ url,
+ (t) => {
+ t.wrapS = THREE.RepeatWrapping;
+ t.wrapT = THREE.RepeatWrapping;
+ t.magFilter = THREE.LinearFilter;
+ t.minFilter = THREE.LinearMipmapLinearFilter;
+ t.generateMipmaps = true;
+ resolve(t);
+ },
+ undefined,
+ (e) => reject(e),
+ );
+ });
+
+ const [normalResult, flowResult] = await Promise.allSettled([
+ loadTex("/textures/waterNormal.png"),
+ loadTex("/textures/noise28.png"),
+ ]);
+
+ this.normalTex =
+ normalResult.status === "fulfilled"
+ ? normalResult.value
+ : await this.createNormalMap(512, 1.0, 42);
+ this.flowTex =
+ flowResult.status === "fulfilled"
+ ? flowResult.value
+ : this.createFlowFallback(256);
this.foamTex = await this.createFoamTexture(128);
- // Create TSL reflector for planar reflections (lake water only)
- // This handles all the reflection camera, render target, and UV calculation automatically
- this.reflection = reflector({ resolutionScale: 0.45 }) as ReflectorNode;
- // Rotate to face upward (water is horizontal plane)
+ // TSL reflector: handles render target, camera mirroring, oblique clipping
+ this.reflection = reflector({ resolutionScale: 0.5 });
this.reflection.target.rotateX(-Math.PI / 2);
- this.reflection.target.name = "WaterReflector";
+ this.reflection.target.position.y = this.waterLevel;
- // Create lake material with reflections AFTER reflector is set up
this.lakeMaterial = this.createLakeMaterial();
-
- // Create ocean material without reflections (different visual style)
this.oceanMaterial = this.createOceanMaterial();
-
- // Lake (reflective) and ocean (non-reflective) shaders initialized
}
/**
- * Add reflector target to scene - must be called after init
+ * Add water system to scene — adds the reflector target so the mirror plane is active.
*/
addToScene(scene: THREE.Scene): void {
if (this.reflection?.target) {
scene.add(this.reflection.target);
- // Configure reflector's virtual camera to only see layer 0 (terrain)
- // This excludes grass (layer 1), flowers, and other vegetation from reflections
- // The reflector internally creates a camera we need to configure
const reflectorObj = this.reflection.target as THREE.Object3D & {
camera?: THREE.Camera;
};
if (reflectorObj.camera) {
- reflectorObj.camera.layers.set(0); // Only see layer 0 (terrain, buildings)
- reflectorObj.camera.layers.enable(2); // Also see floors
- // Reflector camera configured to exclude grass (layer 1)
+ reflectorObj.camera.layers.set(0);
+ reflectorObj.camera.layers.enable(2);
}
- // Set up callbacks to track when reflection camera is rendering
- // This allows LOD systems to force impostor mode during reflection passes
const world = this.world;
this.reflection.target.onBeforeRender = () => {
world.isRenderingReflection = true;
@@ -368,78 +381,143 @@ export class WaterSystem {
this.reflection.target.onAfterRender = () => {
world.isRenderingReflection = false;
};
-
- // Reflector target added to scene
}
}
// ==========================================================================
- // PROCEDURAL TEXTURES (Async with yielding to prevent main thread blocking)
+ // PROCEDURAL TEXTURE FALLBACKS
// ==========================================================================
+ private createFlowFallback(size: number): THREE.Texture {
+ const data = new Uint8Array(size * size * 4);
+ let s = 77777;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const nx = x / size,
+ ny = y / size;
+ const r = Math.floor(
+ (Math.sin(nx * 6.28 * 2 + ny * 3.7) * 0.5 + 0.5) * 255,
+ );
+ const g = Math.floor(
+ (Math.cos(ny * 6.28 * 3 + nx * 2.3) * 0.5 + 0.5) * 255,
+ );
+ s = (s * 1103515245 + 12345) & 0x7fffffff;
+ const a = (s >>> 8) & 0xff;
+ const idx = (y * size + x) * 4;
+ data[idx] = r;
+ data[idx + 1] = g;
+ data[idx + 2] = 128;
+ data[idx + 3] = a;
+ }
+ }
+ const tex = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.magFilter = THREE.LinearFilter;
+ tex.minFilter = THREE.LinearMipmapLinearFilter;
+ tex.generateMipmaps = true;
+ tex.needsUpdate = true;
+ return tex;
+ }
+
/**
- * Create a procedural normal map texture.
- * Processes rows in batches, yielding between batches to prevent main thread blocking.
+ * Generate a seamless water normal map using FBM value noise + finite
+ * differences. Produces organic ripple patterns matching a tangent-space
+ * normal map (510x511).
+ *
+ * Target channel statistics:
+ * R: mean ~128, range ~48–206 (X derivative)
+ * G: mean ~128, range ~52–193 (Y derivative)
+ * B: mean ~250, range ~226–254 (up component)
*/
private async createNormalMap(
size: number,
- freq: number,
+ _freq: number,
seed: number,
): Promise {
- const data = new Uint8Array(size * size * 4);
const TAU = Math.PI * 2;
- const ROW_BATCH_SIZE = 32; // Process 32 rows per batch
+ const ROW_BATCH = 32;
+
+ // ---- Integer hash (Murmur-ish, deterministic) ----
+ const hash = (x: number, y: number, s: number) => {
+ let h = (x * 374761393 + y * 668265263 + s * 1274126177) | 0;
+ h = Math.imul(h ^ (h >>> 13), 1103515245);
+ h = Math.imul(h ^ (h >>> 16), 2654435769);
+ return ((h ^ (h >>> 13)) >>> 0) / 0xffffffff;
+ };
- for (let yBatch = 0; yBatch < size; yBatch += ROW_BATCH_SIZE) {
- const yEnd = Math.min(yBatch + ROW_BATCH_SIZE, size);
+ // ---- Smooth value noise (quintic interp for C2 continuity) ----
+ const vnoise = (px: number, py: number, s: number) => {
+ const ix = Math.floor(px),
+ iy = Math.floor(py);
+ const fx = px - ix,
+ fy = py - iy;
+ const u = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
+ const v = fy * fy * fy * (fy * (fy * 6 - 15) + 10);
+ const a = hash(ix, iy, s);
+ const b = hash(ix + 1, iy, s);
+ const c = hash(ix, iy + 1, s);
+ const d = hash(ix + 1, iy + 1, s);
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
+ };
+ // ---- FBM on torus (seamless tiling via 4D embedding) ----
+ const fbm = (nx: number, ny: number) => {
+ const cx = Math.cos(nx * TAU),
+ sx = Math.sin(nx * TAU);
+ const cy = Math.cos(ny * TAU),
+ sy = Math.sin(ny * TAU);
+ let val = 0,
+ amp = 1,
+ freq = 2;
+ for (let o = 0; o < 6; o++) {
+ const px = cx * freq + sy * freq * 0.618;
+ const py = sx * freq + cy * freq * 0.618;
+ val += vnoise(px, py, seed + o * 137) * amp;
+ amp *= 0.5;
+ freq *= 2.0;
+ }
+ return val;
+ };
+
+ // ---- Build seamless height field ----
+ const heights = new Float32Array(size * size);
+ for (let yBatch = 0; yBatch < size; yBatch += ROW_BATCH) {
+ const yEnd = Math.min(yBatch + ROW_BATCH, size);
for (let y = yBatch; y < yEnd; y++) {
for (let x = 0; x < size; x++) {
- const nx = x / size,
- ny = y / size;
- const cx = Math.cos(nx * TAU),
- sx = Math.sin(nx * TAU);
- const cy = Math.cos(ny * TAU),
- sy = Math.sin(ny * TAU);
-
- let dx = 0,
- dy = 0;
- for (let oct = 0; oct < 4; oct++) {
- const f = freq * (1 << oct);
- const amp = 0.4 / (1 << oct);
- const s = seed + oct * 100;
- const x4 = cx * f,
- y4 = sx * f,
- z4 = cy * f * 0.618,
- w4 = sy * f * 0.618;
- dx +=
- (Math.sin(x4 + s + Math.cos(z4 * 0.7 + s * 0.3)) * 0.3 +
- Math.cos(y4 + s * 0.5 + Math.sin(w4 * 1.3 + s * 0.7)) * 0.3 +
- Math.sin(z4 * 1.1 + x4 * 0.8 + s * 0.2) * 0.2 +
- Math.cos(w4 * 0.9 + y4 * 0.6 + s * 0.9) * 0.2) *
- amp;
- dy +=
- (Math.sin(x4 + s + 50 + Math.cos(z4 * 0.7 + s * 0.3 + 50)) * 0.3 +
- Math.cos(
- y4 + s * 0.5 + 50 + Math.sin(w4 * 1.3 + s * 0.7 + 50),
- ) *
- 0.3 +
- Math.sin(z4 * 1.1 + x4 * 0.8 + s * 0.2 + 50) * 0.2 +
- Math.cos(w4 * 0.9 + y4 * 0.6 + s * 0.9 + 50) * 0.2) *
- amp;
- }
-
- const idx = (y * size + x) * 4;
- data[idx] = Math.floor(Math.max(0, Math.min(255, 128 + dx * 80)));
- data[idx + 1] = Math.floor(Math.max(0, Math.min(255, 128 + dy * 80)));
- data[idx + 2] = 220;
- data[idx + 3] = 255;
+ heights[y * size + x] = fbm(x / size, y / size);
}
}
-
- // Yield to main thread between batches
if (yEnd < size) {
- await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((r) => setTimeout(r, 0));
+ }
+ }
+
+ // ---- Normal map via central finite differences ----
+ const data = new Uint8Array(size * size * 4);
+ const strength = 6.0;
+
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const xp = (x + 1) % size,
+ xm = (x - 1 + size) % size;
+ const yp = (y + 1) % size,
+ ym = (y - 1 + size) % size;
+ const dx = (heights[y * size + xp] - heights[y * size + xm]) * strength;
+ const dy = (heights[yp * size + x] - heights[ym * size + x]) * strength;
+ const len = Math.sqrt(dx * dx + dy * dy + 1);
+
+ const idx = (y * size + x) * 4;
+ data[idx] = Math.max(
+ 0,
+ Math.min(255, ((-dx / len) * 127.5 + 127.5) | 0),
+ );
+ data[idx + 1] = Math.max(
+ 0,
+ Math.min(255, ((-dy / len) * 127.5 + 127.5) | 0),
+ );
+ data[idx + 2] = Math.max(0, Math.min(255, ((1 / len) * 255) | 0));
+ data[idx + 3] = 255;
}
}
@@ -452,15 +530,10 @@ export class WaterSystem {
return tex;
}
- /**
- * Create a procedural foam texture.
- * Processes rows in batches, yielding between batches to prevent main thread blocking.
- */
private async createFoamTexture(size: number): Promise {
const data = new Uint8Array(size * size * 4);
- const ROW_BATCH_SIZE = 16; // Process 16 rows per batch (foam has more computation per pixel)
+ const ROW_BATCH_SIZE = 16;
- // Pre-generate cells (small operation, no yield needed)
const cells: { x: number; y: number }[] = [];
let s = 12345;
for (let i = 0; i < 32; i++) {
@@ -507,7 +580,6 @@ export class WaterSystem {
}
}
- // Yield to main thread between batches
if (yEnd < size) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
@@ -517,6 +589,7 @@ export class WaterSystem {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearMipmapLinearFilter;
+ tex.colorSpace = THREE.LinearSRGBColorSpace;
tex.generateMipmaps = true;
tex.needsUpdate = true;
return tex;
@@ -527,17 +600,19 @@ export class WaterSystem {
// ==========================================================================
/**
- * Create lake/pond water material with planar reflections
+ * Create lake water material — follows the EXACT same pattern as the tree shader:
+ * MeshStandardNodeMaterial + outputNode override + applySunShade + nightDim.
*/
private createLakeMaterial(): MeshStandardNodeMaterial {
const uTime = uniform(float(0));
const uSunDir = uniform(vec3(0.4, 0.8, 0.4));
const uWind = uniform(float(1.0));
+ const uDayIntensity = uniform(float(1.0));
+ const uSunIntensity = uniform(float(1.0));
+ const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR));
const fogTexNode = texture(fogRenderTarget.texture, screenUV);
- // Reflection intensity uniform - allows disabling reflections via settings
- // Default 0.4 matches the hardcoded value we had before
const uReflectionIntensity = uniform(
- float(this._reflectionsEnabled ? 0.4 : 0.0),
+ float(this._reflectionsEnabled ? WATER.REFLECTION_INTENSITY : 0.0),
);
this.uniforms = {
@@ -545,55 +620,45 @@ export class WaterSystem {
sunDirection: uSunDir as unknown as UniformVec3,
windStrength: uWind,
reflectionIntensity: uReflectionIntensity,
+ dayIntensity: uDayIntensity,
+ sunIntensity: uSunIntensity,
};
const material = new MeshStandardNodeMaterial();
material.transparent = true;
material.depthWrite = true;
material.side = THREE.DoubleSide;
- material.roughness = WATER.ROUGHNESS;
+ material.roughness = 0.8;
material.metalness = 0.0;
- material.fog = false; // Water should not be affected by scene fog
- // Disable environment map completely - use planar reflection only
- // NOTE: Don't set envMap = null - it causes WebGPU texture cache corruption
- // Setting envMapIntensity to 0 is sufficient to disable the environment map effect
- material.envMapIntensity = 0;
-
- const normalTex1 = this.normalTex1!;
- const normalTex2 = this.normalTex2!;
- const foamTex = this.foamTex!;
+ material.fog = false;
- // Get the TSL reflector - use directly like in the example
- const reflectionNode = this.reflection!;
+ const nTex = this.normalTex!;
+ const fTex = this.flowTex!;
+ const foamTex = this.foamTex!;
- // Add normal-based UV distortion to reflection for ripple effect
- // Like in the example: reflection.uvNode = reflection.uvNode.add( floorNormalOffset );
- const worldUV = vec2(positionWorld.x, positionWorld.z);
- const normalOffset = texture(normalTex1, mul(worldUV, float(0.02))).xy;
+ const reflNode = this.reflection!;
+ const worldUV0 = vec2(positionWorld.x, positionWorld.z);
+ const normalOffset = texture(nTex, mul(worldUV0, float(0.02))).xy;
const normalDistortion = sub(mul(normalOffset, float(2)), float(1));
- (reflectionNode as { uvNode: ShaderNode }).uvNode = add(
- reflectionNode.uvNode,
- mul(normalDistortion, float(0.015)),
- );
+ reflNode.uvNode = reflNode.uvNode!.add(mul(normalDistortion, float(0.015)));
+ const reflectionNode = reflNode;
+ // Wind affects amplitude only — phase speed is purely from dispersion relation
const wavePhase = (
wp: ShaderNodeInput,
t: ShaderNodeInput,
- w: ShaderNodeInput,
+ _w: ShaderNodeInput,
wave: WaveParams,
) => {
- // Cast to ShaderNode for swizzle access
const wpNode = wp as ShaderNode;
const dotDP = add(
mul(wpNode.x, float(wave.Dx)),
mul(wpNode.z, float(wave.Dz)),
);
- return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w));
+ return add(mul(float(wave.w), dotDP), mul(float(wave.phi), t));
};
- // ========================================================================
// VERTEX: Gerstner Displacement
- // ========================================================================
material.positionNode = Fn(() => {
const pos = positionLocal.xyz;
const wp = positionWorld;
@@ -611,7 +676,7 @@ export class WaterSystem {
const c = cos(phase),
s = sin(phase);
dx = add(dx, mul(float(wave.QADx), c));
- dy = add(dy, mul(float(wave.A), s));
+ dy = add(dy, mul(mul(float(wave.A), uWind), s));
dz = add(dz, mul(float(wave.QADz), c));
}
@@ -622,34 +687,181 @@ export class WaterSystem {
);
})();
- // Screen-space water depth: difference between terrain depth and water
- // surface depth, converted to world-space metres. Used by all fragment
- // effects (absorption, foam, SSS, opacity).
+ // Screen-space water depth
const gpuShoreDist = Fn(() => {
const sceneDepth = linearDepth(viewportDepthTexture());
const waterDepth = linearDepth();
- // Depth difference in [0,1], convert to world units
const depthDiff = sub(sceneDepth, waterDepth);
const worldDist = mul(depthDiff, sub(cameraFar, cameraNear));
return clamp(worldDist, float(0), float(WATER.MAX_DEPTH));
})();
- // ========================================================================
- // FRAGMENT: Use reflection in emissiveNode like the example
- // ========================================================================
+ const distToCam = length(sub(cameraPosition, positionWorld));
+ const waterOpColorLerp = clamp(
+ sub(float(1), div(distToCam, float(WATER.COLOR_DIST_FADE))),
+ float(0.01),
+ float(1.0),
+ );
- // Base water color node
- const waterColorNode = Fn(() => {
+ // OPACITY (feeds into PBR → output.a, same as tree pattern)
+ material.opacityNode = Fn(() => {
+ const shoreDist = gpuShoreDist;
+ const opDepth = pow(
+ saturate(sub(float(1), div(shoreDist, float(WATER.OP_DEPTH_SCALE)))),
+ float(WATER.OP_DEPTH_FALLOFF),
+ );
+ return sub(float(1), opDepth);
+ })();
+
+ // OUTPUT: Same pattern as tree shader — pbrOut = output, replace RGB, keep pbrOut.a
+ material.outputNode = Fn(() => {
+ const pbrOut = output;
const wp = positionWorld;
const shoreDist = gpuShoreDist;
+ const wUV = vec2(wp.x, wp.z);
+
+ // --- Cosine gradient water colour ---
+ const colorDepth = pow(
+ saturate(sub(float(1), div(shoreDist, float(WATER.COLOR_DEPTH_SCALE)))),
+ float(WATER.COLOR_DEPTH_FALLOFF),
+ );
+ const colorLerp = mul(colorDepth, waterOpColorLerp);
+
+ const TAU = Math.PI * 2;
+ const [pR, pG, pB] = WATER.COS_PHASES;
+ const [aR, aG, aB] = WATER.COS_AMPLITUDES;
+ const [fR, fG, fB] = WATER.COS_FREQUENCIES;
+ const [oR, oG, oB] = WATER.COS_OFFSETS;
+ const cosR = clamp(
+ add(
+ float(oR),
+ add(
+ mul(
+ float(aR * 0.5),
+ cos(add(mul(colorLerp, float(TAU * fR)), float(TAU * pR))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const cosG = clamp(
+ add(
+ float(oG),
+ add(
+ mul(
+ float(aG * 0.5),
+ cos(add(mul(colorLerp, float(TAU * fG)), float(TAU * pG))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const cosB = clamp(
+ add(
+ float(oB),
+ add(
+ mul(
+ float(aB * 0.5),
+ cos(add(mul(colorLerp, float(TAU * fB)), float(TAU * pB))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const waterColor = vec3(cosR, cosG, cosB);
+
+ // --- Flow-mapped 4-scroll normal noise (FlowUVW two-phase crossfade) ---
+ const flowSampleUV = mul(wUV, float(WATER.FLOW_UV_SCALE));
+ const flowSample = texture(fTex, flowSampleUV);
+ const flowVec = mul(
+ sub(mul(flowSample.rg, float(2)), float(1)),
+ float(WATER.FLOW_STRENGTH),
+ );
+ const flowTime = add(mul(uTime, float(WATER.FLOW_SPEED)), flowSample.a);
+
+ const progressA = fract(flowTime);
+ const progressB = fract(add(flowTime, float(0.5)));
+ const weightA = sub(
+ float(1),
+ abs(sub(mul(progressA, float(2)), float(1))),
+ );
+ const weightB = sub(
+ float(1),
+ abs(sub(mul(progressB, float(2)), float(1))),
+ );
+
+ const jumpVec = vec2(
+ float(WATER.FLOW_JUMP[0]),
+ float(WATER.FLOW_JUMP[1]),
+ );
+
+ // Phase A: flow-distorted base UV
+ const baseA = add(
+ mul(
+ sub(wUV, mul(flowVec, add(progressA, float(WATER.FLOW_OFFSET)))),
+ float(5),
+ ),
+ mul(sub(flowTime, progressA), jumpVec),
+ );
+ // Phase B: offset by 0.5 to avoid sampling same location
+ const baseB = add(
+ add(
+ mul(
+ sub(wUV, mul(flowVec, add(progressB, float(WATER.FLOW_OFFSET)))),
+ float(5),
+ ),
+ float(0.5),
+ ),
+ mul(sub(flowTime, progressB), jumpVec),
+ );
+
+ // Phase A: scroll layers 0 + 2 (large + ultra-fine scale)
+ const nUV0 = add(
+ div(baseA, float(103)),
+ vec2(div(uTime, float(17)), div(uTime, float(29))),
+ );
+ const nUV2 = add(
+ vec2(div(baseA.x, float(8907)), div(baseA.y, float(9803))),
+ vec2(div(uTime, float(101)), div(uTime, float(97))),
+ );
+ // Phase B: scroll layers 1 + 3 (large + medium-fine scale)
+ const nUV1 = add(
+ div(baseB, float(107)),
+ vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))),
+ );
+ const nUV3 = add(
+ vec2(div(baseB.x, float(1091)), div(baseB.y, float(1027))),
+ vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))),
+ );
+
+ const noiseSum = mul(
+ add(
+ mul(add(texture(nTex, nUV0), texture(nTex, nUV2)), weightA),
+ mul(add(texture(nTex, nUV1), texture(nTex, nUV3)), weightB),
+ ),
+ float(2),
+ );
+ const noise = sub(mul(noiseSum, float(0.5)), float(1));
+ const surfaceNormal = normalize(
+ vec3(
+ mul(noise.x, float(WATER.NORMAL_STRENGTH)),
+ noise.z,
+ mul(noise.y, float(WATER.NORMAL_STRENGTH)),
+ ),
+ );
+
+ // --- Gerstner wave normals (for foam crest detection) ---
const shoreMask = smoothstep(
float(0),
float(WATER.WAVE_DAMP_DISTANCE),
shoreDist,
);
- const wUV = vec2(wp.x, wp.z);
-
- // Wave normals for specular
let nx: ShaderNode = float(0),
nz: ShaderNode = float(0);
for (const wave of WAVES) {
@@ -660,123 +872,40 @@ export class WaterSystem {
nx = mul(nx, shoreMask);
nz = mul(nz, shoreMask);
- // Detail normals (2 layers for performance)
- let detailX: ShaderNode = float(0),
- detailZ: ShaderNode = float(0);
- const textures = [normalTex1, normalTex2];
- for (let i = 0; i < NORMAL_LAYERS.length; i++) {
- const [scale, sx, sy] = NORMAL_LAYERS[i];
- const uv = mul(
- vec2(
- add(wUV.x, mul(uTime, float(sx))),
- add(wUV.y, mul(uTime, float(sy))),
- ),
- float(scale),
- );
- const n = sub(mul(texture(textures[i], uv).rgb, float(2)), float(1));
- detailX = add(detailX, mul(n.x, float(NORMAL_WEIGHTS[i])));
- detailZ = add(detailZ, mul(n.z, float(NORMAL_WEIGHTS[i])));
- }
-
- const N = normalize(
- vec3(
- mul(
- add(nx, mul(detailX, float(WATER.DETAIL_NORMAL_STRENGTH))),
- float(-1),
- ),
- float(1),
- mul(
- add(nz, mul(detailZ, float(WATER.DETAIL_NORMAL_STRENGTH))),
- float(-1),
- ),
- ),
- );
-
- // View vectors
+ // --- Phong sun lighting ---
const V = normalize(sub(cameraPosition, wp));
const L = normalize(uSunDir);
- const H = normalize(add(V, L));
- const NdotV = max(dot(N, V), float(0.001));
- const NdotL = max(dot(N, L), float(0));
- const NdotH = max(dot(N, H), float(0));
- const VdotH = max(dot(V, H), float(0));
-
- // Beer-Lambert absorption for water depth color
- const depth = clamp(shoreDist, float(0), float(WATER.MAX_DEPTH));
- const shallowColor = vec3(
- WATER.SHALLOW_COLOR.r,
- WATER.SHALLOW_COLOR.g,
- WATER.SHALLOW_COLOR.b,
- );
- const deepColor = vec3(
- WATER.DEEP_COLOR.r,
- WATER.DEEP_COLOR.g,
- WATER.DEEP_COLOR.b,
- );
- const waterColor = vec3(
- mix(
- deepColor.x,
- shallowColor.x,
- exp(mul(float(-WATER.ABSORPTION.r), depth)),
- ),
- mix(
- deepColor.y,
- shallowColor.y,
- exp(mul(float(-WATER.ABSORPTION.g), depth)),
- ),
- mix(
- deepColor.z,
- shallowColor.z,
- exp(mul(float(-WATER.ABSORPTION.b), depth)),
- ),
+ const lightColor = vec3(1, 1, 1);
+ const negL = mul(L, float(-1));
+ const NdotL = dot(surfaceNormal, L);
+ const reflectDir = normalize(
+ add(negL, mul(surfaceNormal, mul(float(2), NdotL))),
);
-
- // Subsurface scattering approximation
- const sssView = pow(
- clamp(dot(V, mul(L, float(-1))), float(0), float(1)),
- float(WATER.SSS_POWER),
- );
- const sssIntensity = mul(
+ const specDir = max(dot(V, reflectDir), float(0));
+ const specularLight = mul(
+ lightColor,
mul(
- sssView,
- smoothstep(
- float(WATER.SSS_RANGE_FAR),
- float(WATER.SSS_RANGE_NEAR),
- shoreDist,
- ),
+ pow(specDir, float(WATER.SPECULAR_SHININESS)),
+ float(WATER.SPECULAR_STRENGTH),
),
- float(WATER.SSS_INTENSITY),
- );
-
- // GGX specular
- const alpha = WATER.ROUGHNESS * WATER.ROUGHNESS;
- const alpha2 = alpha * alpha;
- const NdotH2 = mul(NdotH, NdotH);
- const denom = add(mul(NdotH2, float(alpha2 - 1)), float(1));
- const D_GGX = div(float(alpha2), mul(float(PI), mul(denom, denom)));
- const k = (WATER.ROUGHNESS + 1) / 8;
- const G1_V = div(NdotV, add(mul(NdotV, float(1 - k)), float(k)));
- const G1_L = div(NdotL, add(mul(NdotL, float(1 - k)), float(k)));
- const F_spec = add(
- float(WATER.F0),
- mul(float(1 - WATER.F0), pow(sub(float(1), VdotH), float(5))),
);
- const specular = div(
- mul(mul(D_GGX, mul(G1_V, G1_L)), F_spec),
- max(mul(mul(float(4), NdotV), NdotL), float(0.001)),
+ const diffuseLight = mul(
+ lightColor,
+ mul(max(NdotL, float(0)), float(WATER.DIFFUSE_STRENGTH)),
);
- const sunColor = vec3(
- WATER.SUN_COLOR.r,
- WATER.SUN_COLOR.g,
- WATER.SUN_COLOR.b,
- );
- const sunSpec = mul(
- sunColor,
- mul(mul(specular, float(WATER.SPECULAR_MULTIPLIER)), NdotL),
+ // --- Reflection + Fresnel ---
+ const reflectionSample = reflectionNode.xyz;
+ const theta = max(dot(V, surfaceNormal), float(0));
+ const reflectance = add(
+ float(WATER.RF0),
+ mul(float(1 - WATER.RF0), pow(sub(float(1), theta), float(5))),
);
- // Foam (simplified - single texture sample for performance)
+ // --- Scatter ---
+ const scatter = mul(waterColor, max(dot(surfaceNormal, V), float(0)));
+
+ // --- Foam ---
const shoreFoam = smoothstep(
float(WATER.FOAM_SHORE_DISTANCE),
float(0),
@@ -800,168 +929,91 @@ export class WaterSystem {
foamPattern,
);
- // Composite base color (without reflection - that goes to emissive)
- let color: ShaderNode = waterColor;
- color = add(color, sunSpec);
- color = add(
- color,
- mul(
- vec3(WATER.SSS_COLOR.r, WATER.SSS_COLOR.g, WATER.SSS_COLOR.b),
- sssIntensity,
- ),
+ // --- Composite ---
+ const diffusePart = add(mul(diffuseLight, float(0.3)), scatter);
+ const reflectPart = add(
+ add(vec3(0.1, 0.1, 0.1), mul(reflectionSample, float(0.9))),
+ mul(reflectionSample, specularLight),
);
+ const albedo = mix(
+ diffusePart,
+ mul(reflectPart, uReflectionIntensity),
+ reflectance,
+ );
+ let color: ShaderNode = mix(albedo, waterColor, float(0.8));
+
+ // Foam
color = mix(
color,
vec3(WATER.FOAM_COLOR.r, WATER.FOAM_COLOR.g, WATER.FOAM_COLOR.b),
clamp(foamIntensity, float(0), float(WATER.FOAM_MAX_OPACITY)),
);
- return color;
- })();
-
- // === DISTANCE FOG (smoothstep with squared distances — avoids per-fragment sqrt) ===
- const toCam = sub(cameraPosition, positionWorld);
- const fogDistSq = dot(toCam, toCam);
- const fogFactor = smoothstep(
- float(FOG_NEAR_SQ),
- float(FOG_FAR_SQ),
- fogDistSq,
- );
-
- material.colorNode = waterColorNode;
-
- // Reflection (fresnel-weighted emissive)
- const fresnelNode = Fn(() => {
- const V = normalize(sub(cameraPosition, positionWorld));
- const NdotV = max(dot(vec3(0, 1, 0), V), float(0.001));
- return add(
- float(WATER.F0),
- mul(float(1 - WATER.F0), pow(sub(float(1), NdotV), float(5))),
+ // --- applySunShade (same as tree shader) ---
+ color = applySunShade(color, uDayIntensity, vec3(uShadeColor));
+
+ // --- nightDim (same as tree shader: mix(NIGHT.BRIGHTNESS, 1.0, dayFactor)) ---
+ const dayFactor = div(clamp(uSunIntensity, float(0), float(2)), float(2));
+ const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayFactor);
+ color = mul(color, nightDim);
+
+ // --- Fog ---
+ const toCam = sub(cameraPosition, wp);
+ const fogDistSq = dot(toCam, toCam);
+ const fogFactor = smoothstep(
+ float(FOG_NEAR_SQ),
+ float(FOG_FAR_SQ),
+ fogDistSq,
);
- })();
-
- const reflectionEmissive = mul(
- reflectionNode,
- mul(fresnelNode, uReflectionIntensity),
- );
+ const foggedColor = mix(color, fogTexNode.rgb, fogFactor);
+ const foggedAlpha = mix(pbrOut.a, float(1.0), fogFactor);
- material.emissiveNode = reflectionEmissive;
-
- // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened.
- // Alpha pushed to 1.0 at fog distance to prevent transparent water
- // from showing terrain with mismatched fog behind it.
- material.outputNode = Fn(() => {
- const litColor = output;
- const foggedColor = mix(litColor.rgb, fogTexNode.rgb, fogFactor);
- const foggedAlpha = mix(litColor.a, float(1.0), fogFactor);
return vec4(foggedColor, foggedAlpha);
})();
- // ========================================================================
- // OPACITY
- // ========================================================================
- material.opacityNode = Fn(() => {
- const shoreDist = gpuShoreDist;
- const V = normalize(sub(cameraPosition, positionWorld));
-
- // Edge fade for shoreline transparency
- const edgeFade = smoothstep(
- float(0),
- float(WATER.EDGE_FADE_DISTANCE),
- shoreDist,
- );
- // Depth fade - more transparent overall to see bottom
- const depthFade = smoothstep(
- float(WATER.DEPTH_FADE_NEAR),
- float(WATER.DEPTH_FADE_FAR),
- shoreDist,
- );
- const depthOpacity = mix(
- float(WATER.OPACITY_MIN),
- float(WATER.OPACITY_MAX),
- depthFade,
- );
- // Fresnel - more opaque at glancing angles
- const NdotV = max(dot(vec3(0, 1, 0), V), float(0));
- const fresnelOpacity = mix(
- float(WATER.FRESNEL_OPACITY_MIN),
- float(WATER.FRESNEL_OPACITY_MAX),
- pow(sub(float(1), NdotV), float(WATER.FRESNEL_OPACITY_POWER)),
- );
-
- return mul(mul(edgeFade, depthOpacity), fresnelOpacity);
- })();
-
- // ========================================================================
- // NORMAL MAP
- // ========================================================================
- material.normalNode = Fn(() => {
- const wp = positionWorld;
- const wUV = vec2(wp.x, wp.z);
- const uv1 = mul(
- vec2(
- add(wUV.x, mul(uTime, float(WATER.NORMAL_UV1_SPEED.x))),
- add(wUV.y, mul(uTime, float(WATER.NORMAL_UV1_SPEED.y))),
- ),
- float(WATER.NORMAL_UV1_SCALE),
- );
- const uv2 = mul(
- vec2(
- sub(wUV.x, mul(uTime, float(WATER.NORMAL_UV2_SPEED.x))),
- add(wUV.y, mul(uTime, float(WATER.NORMAL_UV2_SPEED.y))),
- ),
- float(WATER.NORMAL_UV2_SCALE),
- );
- const n1 = sub(mul(texture(normalTex1, uv1).rgb, float(2)), float(1));
- const n2 = sub(mul(texture(normalTex2, uv2).rgb, float(2)), float(1));
- const blended = normalize(add(n1, n2));
- return normalize(
- vec3(
- mul(blended.x, float(WATER.NORMAL_BLEND_STRENGTH)),
- float(1),
- mul(blended.z, float(WATER.NORMAL_BLEND_STRENGTH)),
- ),
- );
- })();
-
return material;
}
/**
- * Create ocean water material - no reflections, deeper colors, larger waves
- * Ocean water is meant for world boundary/edge areas
+ * Create ocean water material — no planar reflections,
+ * deeper blue tint, and larger wave amplitude for world boundary water.
+ * Uses MeshBasicNodeMaterial with ALL computation in outputNode (no PBR).
*/
private createOceanMaterial(): MeshStandardNodeMaterial {
const uTime = uniform(float(0));
const uSunDir = uniform(vec3(0.4, 0.8, 0.4));
- const uWind = uniform(float(1.2)); // Slightly windier for ocean
- // Ocean never has reflections, but we include the uniform for type consistency
+ const uWind = uniform(float(1.2));
+ const uDayIntensity = uniform(float(1.0));
+ const uSunIntensity = uniform(float(1.0));
+ const uShadeColor = uniform(new THREE.Color(...SUN_SHADE.TINT_COLOR));
const uReflectionIntensity = uniform(float(0));
+ const fogTexNode = texture(fogRenderTarget.texture, screenUV);
this.oceanUniforms = {
time: uTime,
sunDirection: uSunDir as unknown as UniformVec3,
windStrength: uWind,
- reflectionIntensity: uReflectionIntensity, // Always 0 for ocean
+ reflectionIntensity: uReflectionIntensity,
+ dayIntensity: uDayIntensity,
+ sunIntensity: uSunIntensity,
};
const material = new MeshStandardNodeMaterial();
material.transparent = true;
material.depthWrite = true;
material.side = THREE.DoubleSide;
- material.roughness = WATER.ROUGHNESS;
+ material.roughness = 0.8;
material.metalness = 0.0;
material.fog = false;
- material.envMapIntensity = 0;
- const normalTex1 = this.normalTex1!;
- const normalTex2 = this.normalTex2!;
+ const nTex = this.normalTex!;
+ const fTex = this.flowTex!;
const foamTex = this.foamTex!;
const wavePhase = (
wp: ShaderNodeInput,
t: ShaderNodeInput,
- w: ShaderNodeInput,
+ _w: ShaderNodeInput,
wave: WaveParams,
) => {
const wpNode = wp as ShaderNode;
@@ -969,12 +1021,10 @@ export class WaterSystem {
mul(wpNode.x, float(wave.Dx)),
mul(wpNode.z, float(wave.Dz)),
);
- return add(mul(float(wave.w), dotDP), mul(mul(float(wave.phi), t), w));
+ return add(mul(float(wave.w), dotDP), mul(float(wave.phi), t));
};
- // ========================================================================
- // VERTEX: Gerstner Displacement (larger waves for ocean)
- // ========================================================================
+ // VERTEX: Gerstner Displacement (1.3x larger for ocean)
material.positionNode = Fn(() => {
const pos = positionLocal.xyz;
const wp = positionWorld;
@@ -991,9 +1041,8 @@ export class WaterSystem {
const phase = wavePhase(wp, uTime, uWind, wave);
const c = cos(phase),
s = sin(phase);
- // Larger wave amplitude for ocean (1.3x)
dx = add(dx, mul(float(wave.QADx * 1.3), c));
- dy = add(dy, mul(float(wave.A * 1.3), s));
+ dy = add(dy, mul(mul(float(wave.A * 1.3), uWind), s));
dz = add(dz, mul(float(wave.QADz * 1.3), c));
}
@@ -1004,16 +1053,161 @@ export class WaterSystem {
);
})();
- // ========================================================================
- // FRAGMENT: Ocean color (deeper, bluer, no reflection)
- // ========================================================================
- const oceanColorNode = Fn(() => {
+ // OPACITY (feeds into PBR → output.a)
+ material.opacityNode = Fn(() => {
+ const shoreDist = attribute("shoreDistance", "float");
+ const edgeFade = smoothstep(float(0), float(0.4), shoreDist);
+ const depthFade = smoothstep(float(0.4), float(8.0), shoreDist);
+ const depthOpacity = mix(float(0.3), float(0.85), depthFade);
+ const V0 = normalize(sub(cameraPosition, positionWorld));
+ const NdotV0 = max(dot(vec3(0, 1, 0), V0), float(0));
+ const fresnelOpacity = mix(
+ float(0.9),
+ float(1.0),
+ pow(sub(float(1), NdotV0), float(3)),
+ );
+ return mul(mul(edgeFade, depthOpacity), fresnelOpacity);
+ })();
+
+ // OUTPUT: Same pattern as tree shader — pbrOut = output, replace RGB, keep pbrOut.a
+ material.outputNode = Fn(() => {
+ const pbrOut = output;
const wp = positionWorld;
const shoreDist = attribute("shoreDistance", "float");
const shoreMask = smoothstep(float(0), float(6), shoreDist);
const wUV = vec2(wp.x, wp.z);
- // Wave normals for specular
+ // --- Cosine gradient — deeper bias for ocean ---
+ const colorDepth = pow(
+ saturate(sub(float(1), div(shoreDist, float(80)))),
+ float(4),
+ );
+ const TAU = Math.PI * 2;
+ const [pR, pG, pB] = WATER.COS_PHASES;
+ const [aR, aG, aB] = WATER.COS_AMPLITUDES;
+ const [fR, fG, fB] = WATER.COS_FREQUENCIES;
+ const [oR, oG, oB] = WATER.COS_OFFSETS;
+ const cosR = clamp(
+ add(
+ float(oR),
+ add(
+ mul(
+ float(aR * 0.5),
+ cos(add(mul(colorDepth, float(TAU * fR)), float(TAU * pR))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const cosG = clamp(
+ add(
+ float(oG),
+ add(
+ mul(
+ float(aG * 0.5),
+ cos(add(mul(colorDepth, float(TAU * fG)), float(TAU * pG))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const cosB = clamp(
+ add(
+ float(oB),
+ add(
+ mul(
+ float(aB * 0.5),
+ cos(add(mul(colorDepth, float(TAU * fB)), float(TAU * pB))),
+ ),
+ float(0.5),
+ ),
+ ),
+ float(0),
+ float(1),
+ );
+ const waterColor = vec3(cosR, cosG, cosB);
+
+ // --- Flow-mapped 4-scroll normal noise (FlowUVW two-phase crossfade) ---
+ const flowSampleUV = mul(wUV, float(WATER.FLOW_UV_SCALE));
+ const flowSample = texture(fTex, flowSampleUV);
+ const flowVec = mul(
+ sub(mul(flowSample.rg, float(2)), float(1)),
+ float(WATER.FLOW_STRENGTH),
+ );
+ const flowTime = add(mul(uTime, float(WATER.FLOW_SPEED)), flowSample.a);
+
+ const progressA = fract(flowTime);
+ const progressB = fract(add(flowTime, float(0.5)));
+ const weightA = sub(
+ float(1),
+ abs(sub(mul(progressA, float(2)), float(1))),
+ );
+ const weightB = sub(
+ float(1),
+ abs(sub(mul(progressB, float(2)), float(1))),
+ );
+
+ const jumpVec = vec2(
+ float(WATER.FLOW_JUMP[0]),
+ float(WATER.FLOW_JUMP[1]),
+ );
+
+ const baseA = add(
+ mul(
+ sub(wUV, mul(flowVec, add(progressA, float(WATER.FLOW_OFFSET)))),
+ float(5),
+ ),
+ mul(sub(flowTime, progressA), jumpVec),
+ );
+ const baseB = add(
+ add(
+ mul(
+ sub(wUV, mul(flowVec, add(progressB, float(WATER.FLOW_OFFSET)))),
+ float(5),
+ ),
+ float(0.5),
+ ),
+ mul(sub(flowTime, progressB), jumpVec),
+ );
+
+ const nUV0 = add(
+ div(baseA, float(103)),
+ vec2(div(uTime, float(17)), div(uTime, float(29))),
+ );
+ const nUV2 = add(
+ vec2(div(baseA.x, float(8907)), div(baseA.y, float(9803))),
+ vec2(div(uTime, float(101)), div(uTime, float(97))),
+ );
+ const nUV1 = add(
+ div(baseB, float(107)),
+ vec2(div(uTime, float(19)), mul(div(uTime, float(31)), float(-1))),
+ );
+ const nUV3 = add(
+ vec2(div(baseB.x, float(1091)), div(baseB.y, float(1027))),
+ vec2(mul(div(uTime, float(109)), float(-1)), div(uTime, float(113))),
+ );
+
+ const noiseSum = mul(
+ add(
+ mul(add(texture(nTex, nUV0), texture(nTex, nUV2)), weightA),
+ mul(add(texture(nTex, nUV1), texture(nTex, nUV3)), weightB),
+ ),
+ float(2),
+ );
+ const noise = sub(mul(noiseSum, float(0.5)), float(1));
+ const surfaceNormal = normalize(
+ vec3(
+ mul(noise.x, float(WATER.NORMAL_STRENGTH)),
+ noise.z,
+ mul(noise.y, float(WATER.NORMAL_STRENGTH)),
+ ),
+ );
+
+ // --- Gerstner wave normals for foam ---
let nx: ShaderNode = float(0),
nz: ShaderNode = float(0);
for (const wave of WAVES) {
@@ -1024,95 +1218,43 @@ export class WaterSystem {
nx = mul(nx, shoreMask);
nz = mul(nz, shoreMask);
- // Detail normals
- let detailX: ShaderNode = float(0),
- detailZ: ShaderNode = float(0);
- const textures = [normalTex1, normalTex2];
- for (let i = 0; i < NORMAL_LAYERS.length; i++) {
- const [scale, sx, sy] = NORMAL_LAYERS[i];
- const uv = mul(
- vec2(
- add(wUV.x, mul(uTime, float(sx))),
- add(wUV.y, mul(uTime, float(sy))),
- ),
- float(scale),
- );
- const n = sub(mul(texture(textures[i], uv).rgb, float(2)), float(1));
- detailX = add(detailX, mul(n.x, float(NORMAL_WEIGHTS[i])));
- detailZ = add(detailZ, mul(n.z, float(NORMAL_WEIGHTS[i])));
- }
-
- const N = normalize(
- vec3(
- mul(add(nx, mul(detailX, float(0.5))), float(-1)),
- float(1),
- mul(add(nz, mul(detailZ, float(0.5))), float(-1)),
- ),
- );
-
- // View vectors
+ // --- Phong lighting ---
const V = normalize(sub(cameraPosition, wp));
const L = normalize(uSunDir);
- const H = normalize(add(V, L));
- const NdotV = max(dot(N, V), float(0.001));
- const NdotL = max(dot(N, L), float(0));
- const NdotH = max(dot(N, H), float(0));
- const VdotH = max(dot(V, H), float(0));
-
- // Ocean colors - deeper and bluer than lake water
- const depth = clamp(shoreDist, float(0), float(50)); // Extended depth for ocean
- const shallowColor = vec3(0.08, 0.32, 0.45); // More blue-green
- const deepColor = vec3(0.01, 0.04, 0.08); // Very deep blue
- const oceanColor = vec3(
- mix(
- deepColor.x,
- shallowColor.x,
- exp(mul(float(-WATER.ABSORPTION.r * 0.7), depth)),
- ),
- mix(
- deepColor.y,
- shallowColor.y,
- exp(mul(float(-WATER.ABSORPTION.g * 0.7), depth)),
- ),
- mix(
- deepColor.z,
- shallowColor.z,
- exp(mul(float(-WATER.ABSORPTION.b * 0.7), depth)),
- ),
+ const negL = mul(L, float(-1));
+ const NdotL = dot(surfaceNormal, L);
+ const reflectDir = normalize(
+ add(negL, mul(surfaceNormal, mul(float(2), NdotL))),
);
-
- // Subsurface scattering
- const sssView = pow(
- clamp(dot(V, mul(L, float(-1))), float(0), float(1)),
- float(3),
+ const specDir = max(dot(V, reflectDir), float(0));
+ const specularLight = mul(
+ pow(specDir, float(WATER.SPECULAR_SHININESS)),
+ float(WATER.SPECULAR_STRENGTH),
);
- const sssIntensity = mul(
- mul(sssView, smoothstep(float(8), float(0.5), shoreDist)),
- float(0.25), // Less SSS for ocean
+ const diffuseLight = mul(
+ max(NdotL, float(0)),
+ float(WATER.DIFFUSE_STRENGTH),
);
- // GGX specular
- const alpha = WATER.ROUGHNESS * WATER.ROUGHNESS;
- const alpha2 = alpha * alpha;
- const NdotH2 = mul(NdotH, NdotH);
- const denom = add(mul(NdotH2, float(alpha2 - 1)), float(1));
- const D_GGX = div(float(alpha2), mul(float(PI), mul(denom, denom)));
- const k = (WATER.ROUGHNESS + 1) / 8;
- const G1_V = div(NdotV, add(mul(NdotV, float(1 - k)), float(k)));
- const G1_L = div(NdotL, add(mul(NdotL, float(1 - k)), float(k)));
- const F_spec = add(
- float(WATER.F0),
- mul(float(1 - WATER.F0), pow(sub(float(1), VdotH), float(5))),
- );
- const specular = div(
- mul(mul(D_GGX, mul(G1_V, G1_L)), F_spec),
- max(mul(mul(float(4), NdotV), NdotL), float(0.001)),
+ // --- Scatter ---
+ const scatter = mul(waterColor, max(dot(surfaceNormal, V), float(0)));
+
+ // --- Composite (no reflection for ocean) ---
+ const albedo = add(
+ mul(vec3(1, 1, 1), mul(diffuseLight, float(0.3))),
+ scatter,
);
+ let color: ShaderNode = mix(albedo, waterColor, float(0.8));
- const sunColor = vec3(1.0, 0.98, 0.92);
- const sunSpec = mul(sunColor, mul(mul(specular, float(2.0)), NdotL));
+ // Fresnel sky approximation
+ const NdotV = max(dot(surfaceNormal, V), float(0));
+ const fresnelSky = pow(sub(float(1), NdotV), float(4));
+ color = add(
+ color,
+ mul(vec3(0.38, 0.42, 0.68), mul(fresnelSky, float(0.2))),
+ );
- // Ocean foam (more whitecaps)
+ // --- Foam (more whitecaps on ocean) ---
const crestFoam = smoothstep(
float(0.12),
float(0.35),
@@ -1127,79 +1269,32 @@ export class WaterSystem {
);
const foamPattern = texture(foamTex, foamUV).r;
const foamIntensity = mul(crestFoam, foamPattern);
-
- // Composite color - no reflection for ocean
- let color: ShaderNode = oceanColor;
- color = add(color, sunSpec);
- color = add(color, mul(vec3(0.05, 0.25, 0.3), sssIntensity));
color = mix(
color,
- vec3(0.9, 0.92, 0.95),
+ vec3(0.9, 0.91, 0.96),
clamp(foamIntensity, float(0), float(0.75)),
);
- // Add sky reflection approximation (simple fresnel-based)
- const skyColor = vec3(0.5, 0.6, 0.75);
- const fresnelSky = pow(sub(float(1), NdotV), float(4));
- color = add(color, mul(skyColor, mul(fresnelSky, float(0.15))));
-
- return color;
- })();
-
- material.colorNode = oceanColorNode;
-
- // No emissive reflection for ocean - just use the base color
-
- // ========================================================================
- // OPACITY
- // ========================================================================
- material.opacityNode = Fn(() => {
- const shoreDist = attribute("shoreDistance", "float");
- const V = normalize(sub(cameraPosition, positionWorld));
-
- const edgeFade = smoothstep(float(0), float(0.4), shoreDist);
- const depthFade = smoothstep(float(0.4), float(8.0), shoreDist); // Deeper fade for ocean
- const depthOpacity = mix(float(0.3), float(0.85), depthFade); // More opaque overall
- const NdotV = max(dot(vec3(0, 1, 0), V), float(0));
- const fresnelOpacity = mix(
- float(0.9),
- float(1.0),
- pow(sub(float(1), NdotV), float(3)),
+ // --- applySunShade (same as tree shader) ---
+ color = applySunShade(color, uDayIntensity, vec3(uShadeColor));
+
+ // --- nightDim (same as tree shader) ---
+ const dayFactor = div(clamp(uSunIntensity, float(0), float(2)), float(2));
+ const nightDim = mix(float(NIGHT.BRIGHTNESS), float(1.0), dayFactor);
+ color = mul(color, nightDim);
+
+ // --- Fog ---
+ const toCam = sub(cameraPosition, wp);
+ const fogDistSq = dot(toCam, toCam);
+ const fogFactor = smoothstep(
+ float(FOG_NEAR_SQ),
+ float(FOG_FAR_SQ),
+ fogDistSq,
);
+ const foggedColor = mix(color, fogTexNode.rgb, fogFactor);
+ const foggedAlpha = mix(pbrOut.a, float(1.0), fogFactor);
- return mul(mul(edgeFade, depthOpacity), fresnelOpacity);
- })();
-
- // ========================================================================
- // NORMAL MAP
- // ========================================================================
- material.normalNode = Fn(() => {
- const wp = positionWorld;
- const wUV = vec2(wp.x, wp.z);
- const uv1 = mul(
- vec2(
- add(wUV.x, mul(uTime, float(0.006))),
- add(wUV.y, mul(uTime, float(0.004))),
- ),
- float(0.018),
- );
- const uv2 = mul(
- vec2(
- sub(wUV.x, mul(uTime, float(0.005))),
- add(wUV.y, mul(uTime, float(0.007))),
- ),
- float(0.03),
- );
- const n1 = sub(mul(texture(normalTex1, uv1).rgb, float(2)), float(1));
- const n2 = sub(mul(texture(normalTex2, uv2).rgb, float(2)), float(1));
- const blended = normalize(add(n1, n2));
- return normalize(
- vec3(
- mul(blended.x, float(0.35)),
- float(1),
- mul(blended.z, float(0.35)),
- ),
- );
+ return vec4(foggedColor, foggedAlpha);
})();
return material;
@@ -1226,11 +1321,6 @@ export class WaterSystem {
): THREE.Mesh | null {
this.waterLevel = waterThreshold;
- // Position the reflector at water level (only needed for lake water)
- if (waterType === "lake" && this.reflection?.target) {
- this.reflection.target.position.y = waterThreshold;
- }
-
if (!getHeightAt) {
const mesh = this.createFallbackMesh(
tile,
@@ -1464,178 +1554,6 @@ export class WaterSystem {
return mesh;
}
- /**
- * Generate river water mesh with per-vertex Y following interpolated surfaceY.
- * Unlike generateWaterMesh (flat plane at a single threshold), this creates
- * a mesh that slopes naturally from highland to ocean along the river path.
- */
- generateRiverWaterMesh(
- tile: TerrainTile,
- tileSize: number,
- river: RiverDefinition,
- aabbs: RiverSegmentAABB[],
- ): THREE.Mesh | null {
- const originX = tile.x * tileSize;
- const originZ = tile.z * tileSize;
-
- // Fixed resolution for river water — do NOT use LOD.
- // Rivers are narrow features; low LOD (cell=6.25m) misses thin sections
- // and creates patchy, inconsistent coverage across tiles. River meshes
- // have very few quads (only where the river is), so cost is negligible.
- const resolution = WATER_LOD.HIGH_RESOLUTION;
-
- // Sample grid — project each point onto river for channel test + surfaceY
- const cellSize = tileSize / resolution;
- const inRiver: boolean[][] = [];
- const surfaceYGrid: number[][] = [];
- for (let i = 0; i <= resolution; i++) {
- inRiver[i] = [];
- surfaceYGrid[i] = [];
- for (let j = 0; j <= resolution; j++) {
- const wx = originX + (i / resolution - 0.5) * tileSize;
- const wz = originZ + (j / resolution - 0.5) * tileSize;
- const proj = projectOntoRiver(wx, wz, river, aabbs);
- if (proj && !isNaN(proj.surfaceY)) {
- // Include a half-cell margin beyond the channel boundary to prevent
- // discretization gaps at the water's edge.
- inRiver[i][j] = proj.dist < proj.halfWidth + cellSize * 0.5;
- surfaceYGrid[i][j] = proj.surfaceY;
- } else {
- inRiver[i][j] = false;
- surfaceYGrid[i][j] = 0;
- }
- }
- }
-
- // Shore distance via Chamfer distance transform (same as generateWaterMesh)
- const DIAG = cellSize * 1.414;
- const shoreDist: number[][] = [];
- for (let i = 0; i <= resolution; i++) {
- shoreDist[i] = [];
- for (let j = 0; j <= resolution; j++) {
- shoreDist[i][j] = inRiver[i][j] ? WATER.MAX_DEPTH : 0;
- }
- }
- for (let i = 0; i <= resolution; i++) {
- for (let j = 0; j <= resolution; j++) {
- const d = shoreDist[i];
- if (i > 0) d[j] = Math.min(d[j], shoreDist[i - 1][j] + cellSize);
- if (j > 0) d[j] = Math.min(d[j], d[j - 1] + cellSize);
- if (i > 0 && j > 0)
- d[j] = Math.min(d[j], shoreDist[i - 1][j - 1] + DIAG);
- if (i > 0 && j < resolution)
- d[j] = Math.min(d[j], shoreDist[i - 1][j + 1] + DIAG);
- }
- }
- for (let i = resolution; i >= 0; i--) {
- for (let j = resolution; j >= 0; j--) {
- const d = shoreDist[i];
- if (i < resolution)
- d[j] = Math.min(d[j], shoreDist[i + 1][j] + cellSize);
- if (j < resolution) d[j] = Math.min(d[j], d[j + 1] + cellSize);
- if (i < resolution && j < resolution)
- d[j] = Math.min(d[j], shoreDist[i + 1][j + 1] + DIAG);
- if (i < resolution && j > 0)
- d[j] = Math.min(d[j], shoreDist[i + 1][j - 1] + DIAG);
- }
- }
-
- // Build geometry — quads where at least one corner is in the river
- const verts: number[] = [];
- const uvs: number[] = [];
- const shores: number[] = [];
- const indices: number[] = [];
- const vertMap = new Map();
- let idx = 0;
-
- for (let i = 0; i < resolution; i++) {
- for (let j = 0; j < resolution; j++) {
- if (
- !inRiver[i][j] &&
- !inRiver[i + 1][j] &&
- !inRiver[i][j + 1] &&
- !inRiver[i + 1][j + 1]
- )
- continue;
-
- const corners: [number, number][] = [
- [i, j],
- [i + 1, j],
- [i, j + 1],
- [i + 1, j + 1],
- ];
- const quad: number[] = [];
-
- for (const [ci, cj] of corners) {
- const key = `${ci},${cj}`;
- if (!vertMap.has(key)) {
- const localX = (ci / resolution - 0.5) * tileSize;
- const localZ = (cj / resolution - 0.5) * tileSize;
-
- // Per-vertex Y = interpolated surfaceY at this position.
- // For corners outside the channel, use the projected surfaceY
- // (which is still valid — just from the nearest segment).
- let vertY = surfaceYGrid[ci][cj];
- if (!inRiver[ci][cj] && vertY === 0) {
- // Corner has no projection — borrow from nearest in-river neighbor
- for (const [ni, nj] of corners) {
- if (inRiver[ni][nj]) {
- vertY = surfaceYGrid[ni][nj];
- break;
- }
- }
- }
-
- verts.push(localX, vertY, localZ);
- uvs.push(ci / resolution, cj / resolution);
- shores.push(shoreDist[ci][cj]);
- vertMap.set(key, idx++);
- }
- quad.push(vertMap.get(key)!);
- }
- indices.push(quad[0], quad[2], quad[1], quad[1], quad[2], quad[3]);
- }
- }
-
- if (verts.length === 0) return null;
-
- const geom = new THREE.BufferGeometry();
- geom.setAttribute("position", new THREE.Float32BufferAttribute(verts, 3));
- geom.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
- geom.setAttribute(
- "shoreDistance",
- new THREE.Float32BufferAttribute(shores, 1),
- );
- geom.setIndex(indices);
-
- const normals = new Float32Array(verts.length);
- for (let ni = 0; ni < normals.length; ni += 3) {
- normals[ni] = 0;
- normals[ni + 1] = 1;
- normals[ni + 2] = 0;
- }
- geom.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
-
- // Mesh at Y=0 — vertex Y values are already absolute world heights
- const material = this.lakeMaterial;
- if (!material) {
- return null;
- }
- const mesh = new THREE.Mesh(geom, material);
- mesh.position.y = 0;
- mesh.name = `Water_river_${tile.key}`;
- mesh.renderOrder = 100;
- mesh.userData = {
- type: "water",
- waterType: "lake",
- walkable: false,
- clickable: false,
- };
- mesh.layers.set(1);
- this.waterMeshes.push(mesh);
- return mesh;
- }
-
// ==========================================================================
// UPDATE
// ==========================================================================
@@ -1645,142 +1563,55 @@ export class WaterSystem {
typeof deltaTime === "number" && isFinite(deltaTime) ? deltaTime : 1 / 60;
this.waterTime += dt;
- // Get wind system reference lazily (it's registered before water)
if (!this.windSystem) {
this.windSystem =
(this.world.getSystem("wind") as Wind | undefined) ?? null;
}
- const sunAngle = this.waterTime * 0.005;
- const sunX = Math.cos(sunAngle) * 0.4;
- const sunY = 0.75 + Math.sin(sunAngle * 0.3) * 0.1;
- const sunZ = Math.sin(sunAngle) * 0.4;
+ // Get lighting data from Environment system (same as trees)
+ const env = this.world.getSystem("environment") as {
+ getDayIntensity?: () => number;
+ sunLight?: { intensity: number };
+ lightDirection?: THREE.Vector3;
+ } | null;
- // Use wind system strength with subtle wave oscillation overlay
const baseWindStrength =
this.windSystem?.uniforms.windStrength.value ?? 1.0;
- const waveOscillation = Math.sin(this.waterTime * 0.1) * 0.1;
- const windStrength = baseWindStrength * (0.9 + waveOscillation);
+ const waveOscillation = Math.sin(this.waterTime * 0.03) * 0.08;
+ const windStrength = baseWindStrength * (0.95 + waveOscillation);
+
+ const dayIntensity = env?.getDayIntensity?.() ?? 1;
+ const sunIntensity = env?.sunLight
+ ? Math.min(env.sunLight.intensity, 2.0)
+ : 1.0;
+
+ const updateUniforms = (u: WaterUniforms, windMul: number) => {
+ u.time.value = this.waterTime;
+ u.windStrength.value = windStrength * windMul;
+ u.dayIntensity.value = dayIntensity;
+ u.sunIntensity.value = sunIntensity;
+
+ // Sun direction: negate lightDirection (points FROM sun → TO sun)
+ if (env?.lightDirection) {
+ u.sunDirection.value.copy(env.lightDirection).negate().normalize();
+ }
+ };
- // Update lake water uniforms
- if (this.uniforms) {
- this.uniforms.time.value = this.waterTime;
- this.uniforms.sunDirection.value.set(sunX, sunY, sunZ).normalize();
- this.uniforms.windStrength.value = windStrength;
- }
+ if (this.uniforms) updateUniforms(this.uniforms, 1.0);
+ if (this.oceanUniforms) updateUniforms(this.oceanUniforms, 1.2);
- // Update ocean water uniforms
- if (this.oceanUniforms) {
- this.oceanUniforms.time.value = this.waterTime;
- this.oceanUniforms.sunDirection.value.set(sunX, sunY, sunZ).normalize();
- this.oceanUniforms.windStrength.value = windStrength * 1.2; // Ocean is windier
- }
-
- // Frustum culling: disable reflection camera when no lake water is visible
- // (or when reflections are disabled)
this.updateReflectionVisibility();
}
/**
- * Check if any lake water meshes are in the camera frustum, within range,
- * and enable/disable the reflection render pass accordingly. This saves a
- * full scene render when no lake water is visible or nearby.
- * Ocean water NEVER uses reflections regardless of distance.
+ * Check if reflections should be active this frame.
*/
private updateReflectionVisibility(): void {
- if (!this.reflection?.target) {
- this.reflectionActive = false;
- return;
- }
-
- // If reflections are disabled by user preference, hide the reflector
- if (!this._reflectionsEnabled) {
- this.reflection.target.visible = false;
- this.reflectionActive = false;
- return;
- }
-
- const camera = this.world.camera;
- if (!camera) {
- // No camera, assume water might be visible
- this.reflection.target.visible = true;
- this.reflectionActive = true;
- return;
- }
-
- // If no water meshes exist, disable reflection
- if (this.waterMeshes.length === 0) {
- this.reflection.target.visible = false;
+ if (!this._reflectionsEnabled || !this.reflection) {
this.reflectionActive = false;
return;
}
-
- // Build frustum from camera
- // IMPORTANT: updateMatrixWorld() updates matrixWorld but NOT matrixWorldInverse
- // We must explicitly compute matrixWorldInverse from matrixWorld
- camera.updateMatrixWorld();
- camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
- this.projScreenMatrix.multiplyMatrices(
- camera.projectionMatrix,
- camera.matrixWorldInverse,
- );
- this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
-
- // Get camera position for distance checks
- const cameraPos = camera.position;
-
- // Check if any LAKE water mesh is visible, in frustum, AND within range
- // Ocean water NEVER uses reflections regardless of distance
- // OPTIMIZATION: Cap meshes checked per frame to prevent frame drops with many tiles
- const MAX_MESH_CHECKS = 20;
- let anyLakeWaterVisible = false;
- let checked = 0;
-
- for (const mesh of this.waterMeshes) {
- if (checked >= MAX_MESH_CHECKS) {
- // Over budget - assume visible to be safe (avoids reflection popping)
- anyLakeWaterVisible = this.reflectionActive; // Maintain last state
- break;
- }
-
- // Skip meshes that have been removed from scene or are hidden
- if (!mesh.parent || !mesh.visible) continue;
-
- // Skip ocean water - it NEVER uses reflections
- if (mesh.userData.waterType === "ocean") continue;
-
- checked++;
-
- // Ensure bounding sphere exists (computeBoundingSphere can be expensive)
- if (!mesh.geometry.boundingSphere) {
- mesh.geometry.computeBoundingSphere();
- }
-
- const boundingSphere = mesh.geometry.boundingSphere;
- if (!boundingSphere) continue;
-
- // Transform bounding sphere to world space
- this.tempSphere.copy(boundingSphere);
- this.tempSphere.applyMatrix4(mesh.matrixWorld);
-
- // Check distance from camera to lake water (use sphere center)
- // Only enable reflections for lakes within REFLECTION_MAX_DISTANCE (200m)
- const distanceToLake = cameraPos.distanceTo(this.tempSphere.center);
- if (distanceToLake > REFLECTION_MAX_DISTANCE + this.tempSphere.radius) {
- // Lake is too far away - skip it
- continue;
- }
-
- // Check if in frustum
- if (this.frustum.intersectsSphere(this.tempSphere)) {
- anyLakeWaterVisible = true;
- break;
- }
- }
-
- // Enable/disable reflector based on lake water visibility
- this.reflection.target.visible = anyLakeWaterVisible;
- this.reflectionActive = anyLakeWaterVisible;
+ this.reflectionActive = this.waterMeshes.length > 0;
}
destroy(): void {
@@ -1797,11 +1628,11 @@ export class WaterSystem {
this.oceanMaterial?.dispose();
this.oceanMaterial = undefined;
- // Dispose procedural textures
- this.normalTex1?.dispose();
- this.normalTex1 = undefined;
- this.normalTex2?.dispose();
- this.normalTex2 = undefined;
+ // Dispose textures
+ this.normalTex?.dispose();
+ this.normalTex = undefined;
+ this.flowTex?.dispose();
+ this.flowTex = undefined;
this.foamTex?.dispose();
this.foamTex = undefined;
diff --git a/packages/shared/src/systems/shared/world/WaterVisualManager.ts b/packages/shared/src/systems/shared/world/WaterVisualManager.ts
new file mode 100644
index 000000000..0f597264d
--- /dev/null
+++ b/packages/shared/src/systems/shared/world/WaterVisualManager.ts
@@ -0,0 +1,153 @@
+/**
+ * WaterVisualManager — Generates flat water meshes aligned with the terrain
+ * quad-tree. Each quad-tree leaf node that contains any underwater area gets
+ * a simple PlaneGeometry at WATER_THRESHOLD height.
+ *
+ * The terrain quad-tree drives split/merge; this manager only reacts to
+ * onNodeNeedsGeometry / onNodeDestroyGeometry events.
+ *
+ * CLIENT-ONLY: Only used when USE_QUADTREE_LOD is true.
+ */
+
+import THREE from "../../../extras/three/three";
+import type { TerrainQuadNode, QuadTreeListener } from "./TerrainQuadTree";
+import type { WaterSystem, WaterBodyType } from "./WaterSystem";
+
+const WATER_RESOLUTION_BY_DEPTH: Record = {
+ 0: 2,
+ 1: 4,
+ 2: 8,
+ 3: 12,
+ 4: 16,
+};
+
+const SHORE_SAMPLE_GRID = 5;
+
+interface WaterChunk {
+ nodeId: number;
+ mesh: THREE.Mesh;
+}
+
+export class WaterVisualManager implements QuadTreeListener {
+ private container: THREE.Group;
+ private waterSystem: WaterSystem;
+ private getHeightAt: (x: number, z: number) => number;
+ private getIslandMask: (x: number, z: number) => number;
+ private waterThreshold: number;
+ private chunks = new Map();
+
+ constructor(
+ container: THREE.Group,
+ waterSystem: WaterSystem,
+ getHeightAt: (x: number, z: number) => number,
+ getIslandMask: (x: number, z: number) => number,
+ waterThreshold: number,
+ ) {
+ this.container = container;
+ this.waterSystem = waterSystem;
+ this.getHeightAt = getHeightAt;
+ this.getIslandMask = getIslandMask;
+ this.waterThreshold = waterThreshold;
+ }
+
+ // -- QuadTreeListener -------------------------------------------------
+
+ onNodeNeedsGeometry(node: TerrainQuadNode): void {
+ const key = this.chunkKey(node);
+ if (this.chunks.has(key)) return;
+
+ if (!this.hasUnderwaterArea(node)) return;
+
+ const resolution =
+ WATER_RESOLUTION_BY_DEPTH[node.depth] ??
+ Math.min(16, Math.max(2, node.depth * 4));
+
+ const geom = new THREE.PlaneGeometry(
+ node.size,
+ node.size,
+ resolution,
+ resolution,
+ );
+ geom.rotateX(-Math.PI / 2);
+
+ const count = geom.attributes.position.count;
+ const shores = new Float32Array(count).fill(50);
+ geom.setAttribute("shoreDistance", new THREE.BufferAttribute(shores, 1));
+
+ const normals = new Float32Array(count * 3);
+ for (let i = 0; i < normals.length; i += 3) {
+ normals[i + 1] = 1;
+ }
+ geom.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
+
+ const waterType = this.determineWaterType(node);
+ const material = this.waterSystem.getMaterial(waterType);
+ if (!material) return;
+
+ const mesh = new THREE.Mesh(geom, material);
+ mesh.position.set(node.centerX, this.waterThreshold, node.centerZ);
+ mesh.name = `WaterQT_${waterType}_${key}`;
+ mesh.renderOrder = 100;
+ mesh.userData = {
+ type: "water",
+ waterType,
+ walkable: false,
+ clickable: false,
+ };
+ mesh.layers.set(1);
+
+ this.container.add(mesh);
+ this.waterSystem.registerWaterMesh(mesh);
+ this.chunks.set(key, { nodeId: node.id, mesh });
+ }
+
+ onNodeDestroyGeometry(node: TerrainQuadNode): void {
+ const key = this.chunkKey(node);
+ const chunk = this.chunks.get(key);
+ if (!chunk) return;
+
+ this.waterSystem.unregisterWaterMesh(chunk.mesh);
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ this.chunks.delete(key);
+ }
+
+ // -- Helpers ----------------------------------------------------------
+
+ private chunkKey(node: TerrainQuadNode): string {
+ return `wq_${node.id}_d${node.depth}_${node.centerX}_${node.centerZ}`;
+ }
+
+ private hasUnderwaterArea(node: TerrainQuadNode): boolean {
+ const half = node.halfSize;
+ const step = node.size / (SHORE_SAMPLE_GRID - 1);
+ const startX = node.centerX - half;
+ const startZ = node.centerZ - half;
+
+ for (let i = 0; i < SHORE_SAMPLE_GRID; i++) {
+ for (let j = 0; j < SHORE_SAMPLE_GRID; j++) {
+ const wx = startX + i * step;
+ const wz = startZ + j * step;
+ if (this.getHeightAt(wx, wz) < this.waterThreshold) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private determineWaterType(node: TerrainQuadNode): WaterBodyType {
+ const mask = this.getIslandMask(node.centerX, node.centerZ);
+ return mask < 0.3 ? "ocean" : "lake";
+ }
+
+ destroy(): void {
+ for (const [, chunk] of this.chunks) {
+ this.waterSystem.unregisterWaterMesh(chunk.mesh);
+ if (chunk.mesh.parent) chunk.mesh.parent.remove(chunk.mesh);
+ chunk.mesh.geometry.dispose();
+ }
+ this.chunks.clear();
+ if (this.container.parent) this.container.parent.remove(this.container);
+ }
+}
diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts
index f9fc0d93a..03d562573 100644
--- a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts
+++ b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts
@@ -119,23 +119,32 @@ describe("BiomeResourceGenerator", () => {
});
it("respects weighted distribution", () => {
- // Generate trees across many tiles for statistical significance
+ // Use a 2-species config with a large weight gap so the dominant
+ // species stays dominant even after the 10x species-zoning boost.
+ const weightedConfig: BiomeTreeConfig = {
+ enabled: true,
+ trees: {
+ tree_normal: { weight: 0.85 },
+ tree_willow: { weight: 0.15 },
+ },
+ density: 8,
+ minSpacing: 8,
+ clustering: false,
+ };
+
const allTrees: ReturnType = [];
- for (let i = 0; i < 10; i++) {
- for (let j = 0; j < 10; j++) {
+ for (let i = 0; i < 30; i++) {
+ for (let j = 0; j < 30; j++) {
const ctx = createTestContext(i, j);
- allTrees.push(...generateTrees(ctx, forestTreeConfig));
+ allTrees.push(...generateTrees(ctx, weightedConfig));
}
}
const normalCount = allTrees.filter((r) => r.subType === "normal").length;
- const oakCount = allTrees.filter((r) => r.subType === "oak").length;
const willowCount = allTrees.filter((r) => r.subType === "willow").length;
- // Normal (50%) should be most common
- expect(normalCount).toBeGreaterThan(oakCount);
- // Oak (35%) should be more common than willow (15%)
- expect(oakCount).toBeGreaterThan(willowCount);
+ // Normal (85%) should be significantly more common than willow (15%)
+ expect(normalCount).toBeGreaterThan(willowCount);
});
it("assigns correct level requirements from shared constants", () => {
diff --git a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts
index 38685d6fb..b1d4807ec 100644
--- a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts
+++ b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts
@@ -932,7 +932,7 @@ describe("TERRAIN_CONSTANTS Centralization", () => {
it("should have consistent MAX_WALKABLE_SLOPE value", async () => {
const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared");
- // MAX_WALKABLE_SLOPE should be 1.5
- expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(1.5);
+ // MAX_WALKABLE_SLOPE should be 2.5
+ expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(2.5);
});
});
diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts
index 70b807b65..4b3133926 100644
--- a/packages/shared/src/types/world/world-types.ts
+++ b/packages/shared/src/types/world/world-types.ts
@@ -255,14 +255,20 @@ export interface BiomeTreeConfig {
enabled: boolean;
/** Per-tree spawn weight + placement rules, keyed by TreeId */
trees: Record;
- /** Trees per 64m tile (base density, modified by resourceDensity) */
+ /** Target tree count per terrain tile (100m). Actual count depends on terrain filters and Poisson disk packing. */
density: number;
/** Minimum spacing between trees in meters */
minSpacing: number;
/** Whether trees should cluster together */
clustering: boolean;
- /** Cluster size if clustering is enabled */
+ /** Whether snow-capable trees in this biome receive snow coverage */
+ enableSnow?: boolean;
+ /** Average number of trees per cluster (default: 4) */
clusterSize?: number;
+ /** Radius of each cluster in meters (default: clusterSize * minSpacing) */
+ clusterRadius?: number;
+ /** Minimum distance between cluster centers in meters (default: clusterRadius * 2) */
+ clusterSpacing?: number;
/** Scale variation range [min, max] multiplier (default: [0.8, 1.2]) */
scaleVariation?: [number, number];
/** Maximum terrain slope for tree placement (gradient magnitude, default: 1.5) */
diff --git a/packages/shared/src/utils/NoiseGenerator.ts b/packages/shared/src/utils/NoiseGenerator.ts
index fdcbdf7c2..6d428b8bb 100644
--- a/packages/shared/src/utils/NoiseGenerator.ts
+++ b/packages/shared/src/utils/NoiseGenerator.ts
@@ -235,6 +235,33 @@ export class NoiseGenerator {
return (this.fractal2D(x * 0.002, y * 0.002, 4) + 1) * 0.5;
}
+ /**
+ * Simplex-based FBM matching the reference's createFbmNoise.
+ * Returns raw sum + offset (not normalized).
+ */
+ simplexFbm2D(
+ x: number,
+ y: number,
+ octaves: number,
+ amplitude: number,
+ frequency: number,
+ gain: number,
+ lacunarity: number,
+ offset: number,
+ ): number {
+ let value = 0;
+ let amp = amplitude;
+ let fx = x * frequency;
+ let fy = y * frequency;
+ for (let i = 0; i < octaves; i++) {
+ value += amp * this.simplex2D(fx, fy);
+ fx *= lacunarity;
+ fy *= lacunarity;
+ amp *= gain;
+ }
+ return value + offset;
+ }
+
// Helper functions
private fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
@@ -481,3 +508,31 @@ export class TerrainFeatureGenerator {
: 0;
}
}
+
+// ---------------------------------------------------------------------------
+// Standalone math utilities for per-biome terrain generation
+// ---------------------------------------------------------------------------
+
+export function smoothstep(x: number, edge0: number, edge1: number): number {
+ const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
+ return t * t * (3 - 2 * t);
+}
+
+export function mapRangeSmooth(
+ val: number,
+ a1: number,
+ a2: number,
+ b1: number,
+ b2: number,
+): number {
+ return b1 + smoothstep(val, a1, a2) * (b2 - b1);
+}
+
+export function normalizeFbmRange(fbmNoise: number): number {
+ return Math.min(1, Math.max(0, (fbmNoise + 0.4) / 1.3));
+}
+
+export function pingpong(x: number, length: number): number {
+ const t = x % (length * 2);
+ return length - Math.abs(t - length);
+}
diff --git a/packages/shared/src/utils/physics/MovementUtils.ts b/packages/shared/src/utils/physics/MovementUtils.ts
index a7df021b3..dce497f86 100644
--- a/packages/shared/src/utils/physics/MovementUtils.ts
+++ b/packages/shared/src/utils/physics/MovementUtils.ts
@@ -48,7 +48,7 @@ export const MovementConfig: IMovementConfig = {
stepHeight: 0.3,
/** Maximum slope angle player can walk up (degrees) */
- slopeLimit: 45,
+ slopeLimit: 60,
// ============================================
// Networking Configuration
diff --git a/packages/shared/src/utils/rendering/streamingGuardrails.ts b/packages/shared/src/utils/rendering/streamingGuardrails.ts
index 5be58abc3..f62ffdaac 100644
--- a/packages/shared/src/utils/rendering/streamingGuardrails.ts
+++ b/packages/shared/src/utils/rendering/streamingGuardrails.ts
@@ -30,7 +30,9 @@ export function isActiveStreamingGuardrailPhase(
export function requiresStreamingArenaPositions(
phase: StreamingGuardrailPhase | null | undefined,
): boolean {
- return phase === "COUNTDOWN" || phase === "FIGHTING" || phase === "RESOLUTION";
+ return (
+ phase === "COUNTDOWN" || phase === "FIGHTING" || phase === "RESOLUTION"
+ );
}
export function hasValidStreamingGuardrailAgentSnapshot(
diff --git a/packages/shared/src/utils/workers/GrassWorker.ts b/packages/shared/src/utils/workers/GrassWorker.ts
index 69dc0e4f7..2ee2a7991 100644
--- a/packages/shared/src/utils/workers/GrassWorker.ts
+++ b/packages/shared/src/utils/workers/GrassWorker.ts
@@ -1,78 +1,129 @@
/**
- * GrassWorker.ts - Web Worker for Grass Placement Computation
+ * GrassWorker.ts - Web Worker for FULL Grass Instance Generation
*
- * Offloads CPU-intensive grass placement calculations to a worker thread:
- * - Grid-jittered placement for even distribution
- * - Noise-based density filtering
- * - Position candidate generation with deterministic RNG
+ * Offloads ALL CPU-intensive grass computation to a worker thread:
+ * - Terrain height sampling (getHeightComputed)
+ * - Biome weight & terrain color computation (computeTerrainColorCPU)
+ * - Road influence calculation
+ * - Grass placement probability (biome configs, slope, patchiness)
+ * - Instance attribute generation (offsets, rotation, scale, ground color, normal)
*
- * The main thread remains responsible for:
- * - Terrain height lookups (requires TerrainSystem)
- * - Building collision checks
- * - Slope/grassiness validation
- * - InstancedMesh attribute updates
- * - GPU operations
+ * The main thread only creates InstancedMesh from pre-computed Float32Arrays.
*
- * Communication:
- * - Input: { type: 'generatePlacements', chunkKey, originX, originZ, size, baseDensity, seed }
- * - Output: { type: 'placementResult', chunkKey, placements: GrassPlacementData[] }
+ * Uses the same shared builder functions as TerrainWorker/QuadChunkWorker
+ * for height/biome computation to stay perfectly in sync.
*/
import { WorkerPool } from "./WorkerPool";
+import {
+ buildGetBaseHeightAtJS,
+ buildComputeBiomeWeightsJS,
+ MAX_HEIGHT,
+ WATER_LEVEL_NORMALIZED,
+} from "../../systems/shared/world/TerrainHeightParams";
+import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes";
+import {
+ buildNoiseGeneratorJS,
+ buildHeightHelpersJS,
+ buildBiomeInfluencesJS,
+ buildCreateBiomeNoiseSetsJS,
+} from "./TerrainWorkerShared";
+import type { TerrainWorkerConfig } from "./TerrainWorker";
// ============================================================================
// TYPES
// ============================================================================
export interface GrassPlacementData {
- /** World X position */
x: number;
- /** World Z position */
z: number;
- /** Height scale (0.7-1.3) */
heightScale: number;
- /** Rotation in radians (0-2π) */
rotation: number;
- /** Width scale (0.8-1.2) */
widthScale: number;
- /** Color variation (0-1) */
colorVar: number;
- /** Phase offset for wind animation (0-2π) */
phaseOffset: number;
}
+export interface BiomeGrassConfigWorker {
+ density: number;
+ maxSlope: number;
+ minGrassWeight: number;
+ heightScale: number;
+ patchiness: number;
+ patchScale: number;
+ tintR: number;
+ tintG: number;
+ tintB: number;
+ tintStrength: number;
+}
+
export interface GrassWorkerInput {
- type: "generatePlacements";
- /** Chunk key "x_z" */
+ type: "generateGrassInstances";
chunkKey: string;
- /** World X origin of chunk */
- originX: number;
- /** World Z origin of chunk */
- originZ: number;
- /** Chunk size in meters */
+ centerX: number;
+ centerZ: number;
size: number;
- /** Base density (instances per square meter) */
- baseDensity: number;
- /** Seed for deterministic generation */
+ spacingMul: number;
+ config: TerrainWorkerConfig;
seed: number;
- /** Biome height multiplier (default 1.0) */
- heightMultiplier?: number;
- /** Maximum instances for this chunk */
- maxInstances?: number;
+ biomeCenters: Array<{
+ x: number;
+ z: number;
+ type: string;
+ influence: number;
+ }>;
+ biomes: Record<
+ string,
+ { heightModifier: number; color: { r: number; g: number; b: number } }
+ >;
+ grassSeed: number;
+ clumpSpacing: number;
+ scaleMin: number;
+ scaleMax: number;
+ waterThreshold: number;
+ grassConfigs: Record;
+ shaderConstants: {
+ NOISE_SCALE: number;
+ DISTORT_NOISE_SCALE: number;
+ VARIATION_NOISE_SCALE: number;
+ ROCK_DISTORT_STRENGTH: number;
+ HEIGHT_DISTORT_STRENGTH: number;
+ DIRT_THRESHOLD: number;
+ SATURATION_BOOST: number;
+ };
+ roadSegments: Array<{
+ startX: number;
+ startZ: number;
+ endX: number;
+ endZ: number;
+ width: number;
+ }>;
+ roadBlendWidth: number;
+ tileSize: number;
+ flatZones: Array<{
+ centerX: number;
+ centerZ: number;
+ halfWidth: number;
+ halfDepth: number;
+ blendRadius: number;
+ }>;
}
export interface GrassWorkerOutput {
- type: "placementResult";
- /** Chunk key matching input */
+ type: "grassInstanceResult";
chunkKey: string;
- /** Generated placements (position + variation data) */
- placements: GrassPlacementData[];
- /** Stats for debugging */
- stats: {
- candidatesGenerated: number;
- placementsCreated: number;
- timeMs: number;
- };
+ offsets: Float32Array;
+ rotScaleHash: Float32Array;
+ groundColors: Float32Array;
+ grassTints: Float32Array;
+ groundNormals: Float32Array;
+ count: number;
+}
+
+export interface GrassBatchResult {
+ results: GrassWorkerOutput[];
+ workersAvailable: boolean;
+ failedCount: number;
}
// ============================================================================
@@ -80,161 +131,477 @@ export interface GrassWorkerOutput {
// ============================================================================
/**
- * Inline worker code for grass placement generation
- * This code runs in a separate thread, isolated from the main thread.
+ * Build a JS string for the sampleNoiseCPU pipeline — exact match of
+ * TerrainShader.ts lines 422-549 + 827-861.
+ * Worker has no texture, so we always use the seamlessFbm path.
+ */
+function buildSampleNoiseJS(): string {
+ return `
+ function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
+ function _lerp(a, b, t) { return a + t * (b - a); }
+ function _grad(hash, x, y) {
+ var h = hash & 3;
+ var u = h < 2 ? x : y;
+ var v = h < 2 ? y : x;
+ return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
+ }
+
+ function createPermutation(seed) {
+ var p = [];
+ for (var i = 0; i < 256; i++) p[i] = i;
+ var s = seed;
+ for (var i = 255; i > 0; i--) {
+ s = (s * 1103515245 + 12345) & 0x7fffffff;
+ var j = s % (i + 1);
+ var tmp = p[i]; p[i] = p[j]; p[j] = tmp;
+ }
+ return p.concat(p);
+ }
+
+ function perlin2DPerm(x, y, perm) {
+ var X = Math.floor(x) & 255;
+ var Y = Math.floor(y) & 255;
+ var xf = x - Math.floor(x);
+ var yf = y - Math.floor(y);
+ var u = _fade(xf);
+ var v = _fade(yf);
+ var aa = perm[perm[X] + Y];
+ var ab = perm[perm[X] + Y + 1];
+ var ba = perm[perm[X + 1] + Y];
+ var bb = perm[perm[X + 1] + Y + 1];
+ var x1 = _lerp(_grad(aa, xf, yf), _grad(ba, xf - 1, yf), u);
+ var x2 = _lerp(_grad(ab, xf, yf - 1), _grad(bb, xf - 1, yf - 1), u);
+ return _lerp(x1, x2, v);
+ }
+
+ function seamlessPerlin2D(x, y, perm) {
+ var TWO_PI = Math.PI * 2;
+ var angleX = x * TWO_PI;
+ var angleY = y * TWO_PI;
+ var nx = Math.cos(angleX);
+ var ny = Math.sin(angleX);
+ var nz = Math.cos(angleY);
+ var nw = Math.sin(angleY);
+ var n1 = perlin2DPerm(nx * 4 + 100, nz * 4 + 100, perm);
+ var n2 = perlin2DPerm(ny * 4 + 200, nw * 4 + 200, perm);
+ var n3 = perlin2DPerm(nx * 4 + ny * 4 + 300, nz * 4 + nw * 4 + 300, perm);
+ return (n1 + n2 + n3) / 3;
+ }
+
+ function seamlessFbm(x, y, perm, octaves) {
+ var value = 0, amplitude = 0.5, maxValue = 0;
+ for (var i = 0; i < octaves; i++) {
+ var ox = x + i * 17.3;
+ var oy = y + i * 31.7;
+ value += amplitude * seamlessPerlin2D(ox, oy, perm);
+ maxValue += amplitude;
+ amplitude *= 0.5;
+ }
+ return value / maxValue;
+ }
+
+ var _noisePerm = createPermutation(12345);
+
+ function sampleNoiseCPU(worldX, worldZ, scale) {
+ var u = worldX * scale;
+ var v = worldZ * scale;
+ var wu = u - Math.floor(u);
+ var wv = v - Math.floor(v);
+ return (seamlessFbm(wu, wv, _noisePerm, 4) + 1) * 0.5;
+ }
+`;
+}
+
+/**
+ * Build a JS string for computeTerrainColorCPU — exact match of
+ * TerrainShader.ts lines 869-1022 with all color constants baked
+ * as pre-computed linear-space values.
+ */
+function buildComputeTerrainColorJS(): string {
+ return `
+ function smoothstepCPU(edge0, edge1, x) {
+ var t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
+ return t * t * (3 - 2 * t);
+ }
+ function mixRGB(a, b, t) {
+ return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t };
+ }
+ function blendBiome(tundra, forest, canyon, tW, fW, dW) {
+ return {
+ r: tundra.r * tW + forest.r * fW + canyon.r * dW,
+ g: tundra.g * tW + forest.g * fW + canyon.g * dW,
+ b: tundra.b * tW + forest.b * fW + canyon.b * dW
+ };
+ }
+
+ var _TUNDRA_GRASS = {r:0.587016,g:0.603827,b:0.603827};
+ var _TUNDRA_GRASS_DARK = {r:0.381561,g:0.392488,b:0.392488};
+ var _TUNDRA_GRASS_HIGH = {r:0.68,g:0.72,b:0.78};
+ var _TUNDRA_VARIATION = {r:0.6,g:0.64,b:0.7};
+ var _TUNDRA_DIRT = {r:0.570482,g:0.638283,b:0.673860};
+ var _TUNDRA_DIRT_DARK = {r:0.370813,g:0.414884,b:0.438009};
+ var _TUNDRA_CLIFF = {r:0.570482,g:0.638283,b:0.673860};
+ var _TUNDRA_CLIFF_DARK = {r:0.370813,g:0.414884,b:0.438009};
+
+ var _FOREST_GRASS = {r:0.125967,g:0.354692,b:0.033105};
+ var _FOREST_GRASS_DARK = {r:0.081879,g:0.230550,b:0.021518};
+ var _FOREST_GRASS_HIGH = {r:0.24,g:0.45,b:0.18};
+ var _FOREST_VARIATION = {r:0.15,g:0.35,b:0.1};
+ var _FOREST_DIRT = {r:0.638283,g:0.367246,b:0.094630};
+ var _FOREST_DIRT_DARK = {r:0.414884,g:0.238710,b:0.061509};
+ var _FOREST_CLIFF = {r:0.462361,g:0.406448,b:0.318547};
+ var _FOREST_CLIFF_DARK = {r:0.300535,g:0.264191,b:0.207055};
+
+ var _CANYON_SAND = {r:0.223414,g:0.139985,b:0.063724};
+ var _CANYON_SAND_DARK = {r:0.145219,g:0.090990,b:0.041420};
+ var _CANYON_SAND_HIGH = {r:0.62,g:0.38,b:0.22};
+ var _CANYON_VARIATION = {r:0.58,g:0.34,b:0.16};
+ var _CANYON_ROCK = {r:0.252950,g:0.147319,b:0.083535};
+ var _CANYON_ROCK_DARK = {r:0.164418,g:0.095757,b:0.054298};
+ var _CANYON_CLIFF = {r:0.252950,g:0.147319,b:0.083535};
+ var _CANYON_CLIFF_DARK = {r:0.164418,g:0.095757,b:0.054298};
+
+ var _CLIFF_TINT = {r:0.28,g:0.3,b:0.36};
+ var _SAND_YELLOW = {r:0.7,g:0.6,b:0.38};
+ var _DIRT_DARK_CPU = {r:0.22,g:0.15,b:0.08};
+ var _MUD_BROWN = {r:0.18,g:0.12,b:0.08};
+ var _WATER_EDGE = {r:0.08,g:0.06,b:0.04};
+
+ function computeTerrainColorCPU(worldX, worldZ, height, slope, forestW, canyonW, sc) {
+ var fW = forestW, dW = canyonW, tW = 1 - fW - dW;
+
+ var noiseVal = sampleNoiseCPU(worldX, worldZ, sc.NOISE_SCALE);
+ var noiseVal2 = Math.sin(noiseVal * 6.28) * 0.3 + 0.5;
+ var distortN = sampleNoiseCPU(worldX, worldZ, sc.DISTORT_NOISE_SCALE);
+ var variationN = sampleNoiseCPU(worldX, worldZ, sc.VARIATION_NOISE_SCALE);
+
+ var distortedNY = 1 - slope + (distortN - 0.5) * sc.ROCK_DISTORT_STRENGTH;
+ var dSlope = 1 - distortedNY;
+ var dHeight = height + (distortN - 0.5) * sc.HEIGHT_DISTORT_STRENGTH;
+
+ var grassVar = smoothstepCPU(0.4, 0.6, noiseVal2);
+ var tundraGrass = mixRGB(_TUNDRA_GRASS, _TUNDRA_GRASS_DARK, grassVar);
+ var forestGrass = mixRGB(_FOREST_GRASS, _FOREST_GRASS_DARK, grassVar);
+ var canyonGrass = mixRGB(_CANYON_SAND, _CANYON_SAND_DARK, grassVar);
+ var c = blendBiome(tundraGrass, forestGrass, canyonGrass, tW, fW, dW);
+
+ var heightGrad = smoothstepCPU(25, 55, height) * 0.3;
+ var grassHigh = blendBiome(_TUNDRA_GRASS_HIGH, _FOREST_GRASS_HIGH, _CANYON_SAND_HIGH, tW, fW, dW);
+ c = mixRGB(c, grassHigh, heightGrad);
+
+ var gVar = Math.max(0, Math.min(1, Math.pow(variationN + 0.3, 5)));
+ var varColor = blendBiome(_TUNDRA_VARIATION, _FOREST_VARIATION, _CANYON_VARIATION, tW, fW, dW);
+ c = mixRGB(c, varColor, gVar * 0.25);
+
+ var dirtVar = smoothstepCPU(0.3, 0.7, noiseVal2);
+ var dirtColor = blendBiome(
+ mixRGB(_TUNDRA_DIRT, _TUNDRA_DIRT_DARK, dirtVar),
+ mixRGB(_FOREST_DIRT, _FOREST_DIRT_DARK, dirtVar),
+ mixRGB(_CANYON_ROCK, _CANYON_ROCK_DARK, dirtVar),
+ tW, fW, dW);
+
+ var cliffVar = smoothstepCPU(0.3, 0.7, noiseVal);
+ var cliffColor = blendBiome(
+ mixRGB(_TUNDRA_CLIFF, _TUNDRA_CLIFF_DARK, cliffVar),
+ mixRGB(_FOREST_CLIFF, _FOREST_CLIFF_DARK, cliffVar),
+ mixRGB(_CANYON_CLIFF, _CANYON_CLIFF_DARK, cliffVar),
+ tW, fW, dW);
+ var rockTexV = Math.pow(distortN, 0.5) * 0.3;
+ cliffColor = mixRGB(cliffColor, _CLIFF_TINT, rockTexV);
+
+ var grassWeight = 1.0;
+
+ var nDirtF = smoothstepCPU(sc.DIRT_THRESHOLD - 0.05, sc.DIRT_THRESHOLD + 0.15, noiseVal) * smoothstepCPU(0.3, 0.05, dSlope);
+ c = mixRGB(c, dirtColor, nDirtF);
+ grassWeight -= nDirtF;
+
+ var dirtSlopeF = smoothstepCPU(0.15, 0.4, dSlope) * smoothstepCPU(0.6, 0.3, dSlope) * 0.6;
+ c = mixRGB(c, dirtColor, dirtSlopeF);
+ grassWeight -= dirtSlopeF;
+
+ var cliffF = smoothstepCPU(0.3, 0.55, dSlope);
+ c = mixRGB(c, cliffColor, cliffF);
+ grassWeight -= cliffF;
+
+ var sandBlend = smoothstepCPU(18, 12, dHeight) * smoothstepCPU(0.25, 0.0, slope);
+ var sandStr = 0.6 + (0.9 - 0.6) * dW;
+ var sandF = sandBlend * sandStr;
+ c = mixRGB(c, _SAND_YELLOW, sandF);
+ grassWeight -= sandF;
+
+ var shore1 = smoothstepCPU(22, 14, dHeight) * 0.4;
+ c = mixRGB(c, _DIRT_DARK_CPU, shore1);
+ grassWeight -= shore1;
+
+ var shore2 = smoothstepCPU(15, 10, dHeight) * 0.7;
+ c = mixRGB(c, _MUD_BROWN, shore2);
+ grassWeight -= shore2;
+
+ var shore3 = smoothstepCPU(11, 7, dHeight) * 0.9;
+ c = mixRGB(c, _WATER_EDGE, shore3);
+ grassWeight -= shore3;
+
+ var luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
+ var sat = sc.SATURATION_BOOST;
+ c = { r: luma + (c.r - luma) * sat, g: luma + (c.g - luma) * sat, b: luma + (c.b - luma) * sat };
+
+ return { r: c.r, g: c.g, b: c.b, grassWeight: Math.max(0, Math.min(1, grassWeight)) };
+ }
+`;
+}
+
+/**
+ * Inline worker code for complete grass instance generation.
*
- * The worker generates candidate positions with variation data.
- * Height lookups, building checks, and grassiness validation happen on main thread.
+ * Embeds: terrain height, biome weights, terrain color, road influence,
+ * grass placement, and instance attribute generation.
*/
const GRASS_WORKER_CODE = `
-// Deterministic PRNG for reproducible grass placement
-function createRng(seed) {
- let state = seed >>> 0;
+${buildNoiseGeneratorJS()}
+${buildBiomeConstantsJS()}
+
+var BIOME_IDS = {};
+BIOME_IDS[BT_TUNDRA] = 0;
+BIOME_IDS[BT_FOREST] = 1;
+BIOME_IDS[BT_CANYON] = 2;
+
+${buildSampleNoiseJS()}
+${buildComputeTerrainColorJS()}
+
+function mulberry32(seed) {
+ var s = seed | 0;
return function() {
- state = (1664525 * state + 1013904223) >>> 0;
- return state / 0xFFFFFFFF;
+ s = (s + 0x6d2b79f5) | 0;
+ var t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
-// Hash function for position-based seed derivation
-function hashPosition(x, z) {
- const h1 = Math.imul(Math.floor(x * 100) ^ 0x85ebca6b, 0x85ebca6b);
- const h2 = Math.imul(Math.floor(z * 100) ^ 0xc2b2ae35, 0xc2b2ae35);
- return Math.abs((h1 ^ h2) | 0);
+function distToSegSq(px, pz, x1, z1, x2, z2) {
+ var dx = x2 - x1, dz = z2 - z1;
+ var lenSq = dx * dx + dz * dz;
+ if (lenSq === 0) { var ddx = px - x1, ddz = pz - z1; return ddx * ddx + ddz * ddz; }
+ var t = Math.max(0, Math.min(1, ((px - x1) * dx + (pz - z1) * dz) / lenSq));
+ var projX = x1 + t * dx, projZ = z1 + t * dz;
+ var ddx2 = px - projX, ddz2 = pz - projZ;
+ return ddx2 * ddx2 + ddz2 * ddz2;
}
-// Simple 2D value noise for density variation
-function noise2D(x, z, seed) {
- const ix = Math.floor(x);
- const iz = Math.floor(z);
- const fx = x - ix;
- const fz = z - iz;
-
- // Smooth interpolation
- const sx = fx * fx * (3 - 2 * fx);
- const sz = fz * fz * (3 - 2 * fz);
-
- // Hash corners using simple PRNG
- function hash(a, b) {
- let h = a * 374761393 + b * 668265263 + seed;
- h = ((h ^ (h >> 13)) * 1274126177) >>> 0;
- return (h & 0xFFFFFF) / 0xFFFFFF;
+function calculateRoadInfluence(wx, wz, roadSegments, roadBlendWidth) {
+ if (roadSegments.length === 0) return 0;
+ var minDistSq = Infinity, closestWidth = 6;
+ for (var i = 0; i < roadSegments.length; i++) {
+ var seg = roadSegments[i];
+ var dSq = distToSegSq(wx, wz, seg.startX, seg.startZ, seg.endX, seg.endZ);
+ if (dSq < minDistSq) { minDistSq = dSq; closestWidth = seg.width; }
}
-
- const h00 = hash(ix, iz);
- const h10 = hash(ix + 1, iz);
- const h01 = hash(ix, iz + 1);
- const h11 = hash(ix + 1, iz + 1);
-
- // Bilinear interpolation
- const v0 = h00 + sx * (h10 - h00);
- const v1 = h01 + sx * (h11 - h01);
- return v0 + sz * (v1 - v0);
+ var halfW = closestWidth / 2;
+ var totalW = halfW + roadBlendWidth;
+ if (minDistSq >= totalW * totalW) return 0;
+ if (minDistSq <= halfW * halfW) return 1.0;
+ var minDist = Math.sqrt(minDistSq);
+ var t = 1.0 - (minDist - halfW) / roadBlendWidth;
+ return t * t * (3 - 2 * t);
}
-/**
- * Main placement generation function
- * Generates grid-jittered positions with instance variation data
- */
-function generatePlacements(input) {
- const startTime = performance.now();
- const { chunkKey, originX, originZ, size, baseDensity, seed, heightMultiplier = 1.0, maxInstances = 131072 } = input;
-
- const placements = [];
- let candidatesGenerated = 0;
-
- // Calculate instance count
- const instanceCount = Math.min(
- Math.floor(size * size * baseDensity),
- maxInstances
- );
-
- if (instanceCount === 0) {
- return {
- type: "placementResult",
- chunkKey,
- placements: [],
- stats: { candidatesGenerated: 0, placementsCreated: 0, timeMs: performance.now() - startTime }
- };
+function isInFlatZone(wx, wz, flatZones) {
+ for (var i = 0; i < flatZones.length; i++) {
+ var fz = flatZones[i];
+ var dx = Math.abs(wx - fz.centerX);
+ var dz = Math.abs(wz - fz.centerZ);
+ if (dx <= fz.halfWidth + fz.blendRadius && dz <= fz.halfDepth + fz.blendRadius) {
+ return true;
+ }
}
-
- // Deterministic RNG for this chunk
- const chunkSeed = hashPosition(originX, originZ) ^ seed;
- let rngState = chunkSeed;
- const nextRandom = () => {
- rngState = (rngState * 1103515245 + 12345) & 0x7fffffff;
- return rngState / 0x7fffffff;
- };
-
- const spacing = Math.sqrt(1 / baseDensity);
-
- // Randomized placement with loose grid for even coverage but organic look
- for (let gx = 0; gx < size && placements.length < instanceCount; gx += spacing) {
- for (let gz = 0; gz < size && placements.length < instanceCount; gz += spacing) {
- candidatesGenerated++;
-
- // Large jitter (1.2x spacing) allows grass to cross cell boundaries for organic look
- const jitterX = (nextRandom() - 0.5) * spacing * 1.2;
- const jitterZ = (nextRandom() - 0.5) * spacing * 1.2;
-
- // Secondary noise-based offset to break up grid pattern
- // Uses position-seeded noise for determinism
- const noiseOffsetX = (noise2D(gx * 0.5, gz * 0.5, chunkSeed) - 0.5) * spacing * 0.6;
- const noiseOffsetZ = (noise2D(gz * 0.5 + 100, gx * 0.5 + 100, chunkSeed) - 0.5) * spacing * 0.6;
-
- const worldX = originX + gx + jitterX + noiseOffsetX;
- const worldZ = originZ + gz + jitterZ + noiseOffsetZ;
-
- // NO noise-based filtering - generates even grass coverage
- // Density variation is handled by distance-based LOD at render time
-
- // Instance variation
- const heightScale = (0.7 + nextRandom() * 0.6) * heightMultiplier;
- const rotation = nextRandom() * Math.PI * 2;
- const widthScale = 0.8 + nextRandom() * 0.4;
- const colorVar = nextRandom();
- const phaseOffset = nextRandom() * Math.PI * 2;
-
- placements.push({
- x: worldX,
- z: worldZ,
- heightScale,
- rotation,
- widthScale,
- colorVar,
- phaseOffset
- });
+ return false;
+}
+
+function generateGrassInstances(input) {
+ var startTime = performance.now();
+ var centerX = input.centerX, centerZ = input.centerZ, size = input.size;
+ var spacingMul = input.spacingMul;
+ var config = input.config;
+ var biomeCenters = input.biomeCenters;
+ var biomes = input.biomes;
+ var sc = input.shaderConstants;
+ var grassConfigs = input.grassConfigs;
+
+ var BIOME_GAUSSIAN_COEFF = config.BIOME_GAUSSIAN_COEFF;
+ var BIOME_BOUNDARY_NOISE_SCALE = config.BIOME_BOUNDARY_NOISE_SCALE;
+ var BIOME_BOUNDARY_NOISE_AMOUNT = config.BIOME_BOUNDARY_NOISE_AMOUNT;
+ var WATER_THRESHOLD = input.waterThreshold;
+ var SHORELINE_THRESHOLD = config.SHORELINE_THRESHOLD;
+ var SHORELINE_STRENGTH = config.SHORELINE_STRENGTH;
+ var SHORELINE_MIN_SLOPE = config.SHORELINE_MIN_SLOPE;
+ var SHORELINE_SLOPE_SAMPLE_DISTANCE = config.SHORELINE_SLOPE_SAMPLE_DISTANCE;
+ var SHORELINE_LAND_BAND = config.SHORELINE_LAND_BAND;
+ var SHORELINE_LAND_MAX_MULTIPLIER = config.SHORELINE_LAND_MAX_MULTIPLIER;
+ var SHORELINE_UNDERWATER_BAND = config.SHORELINE_UNDERWATER_BAND;
+ var UNDERWATER_DEPTH_MULTIPLIER = config.UNDERWATER_DEPTH_MULTIPLIER;
+ var MAX_HEIGHT = config.MAX_HEIGHT;
+
+ var noise = new NoiseGenerator(input.seed);
+
+ ${buildComputeBiomeWeightsJS()}
+ ${buildGetBaseHeightAtJS()}
+ ${buildCreateBiomeNoiseSetsJS()}
+ var biomeNoiseSets = createBiomeNoiseSets(input.seed);
+ ${buildHeightHelpersJS()}
+ ${buildBiomeInfluencesJS()}
+
+ var spacing = input.clumpSpacing * spacingMul;
+ var maxCount = Math.ceil((size * size) / (spacing * spacing));
+ var rng = mulberry32(input.grassSeed ^ ((centerX * 374761393 + centerZ * 668265263) | 0));
+
+ var offsets = new Float32Array(maxCount * 3);
+ var rotScaleHash = new Float32Array(maxCount * 3);
+ var groundColors = new Float32Array(maxCount * 3);
+ var grassTints = new Float32Array(maxCount * 4);
+ var groundNormals = new Float32Array(maxCount * 3);
+ var count = 0;
+
+ var tCfg = grassConfigs[BT_TUNDRA] || grassConfigs["tundra"];
+ var fCfg = grassConfigs[BT_FOREST] || grassConfigs["forest"];
+ var cCfg = grassConfigs[BT_CANYON] || grassConfigs["canyon"];
+
+ for (var i = 0; i < maxCount; i++) {
+ var lx = (rng() - 0.5) * size;
+ var lz = (rng() - 0.5) * size;
+ var clumpRng = rng();
+
+ var wx = centerX + lx;
+ var wz = centerZ + lz;
+ var ty = getHeightComputed(wx, wz);
+
+ if (ty < WATER_THRESHOLD + 0.1) continue;
+
+ if (input.flatZones.length > 0 && isInFlatZone(wx, wz, input.flatZones)) continue;
+
+ var roadInf = calculateRoadInfluence(wx, wz, input.roadSegments, input.roadBlendWidth);
+ if (roadInf > 0.8) continue;
+
+ // Normal via finite differences (matches TerrainSystem.getTerrainColorAt)
+ var sd = 0.5;
+ var hL = getHeightComputed(wx - sd, wz);
+ var hR = getHeightComputed(wx + sd, wz);
+ var hD = getHeightComputed(wx, wz - sd);
+ var hU = getHeightComputed(wx, wz + sd);
+ var dhdx = (hR - hL) / (2 * sd);
+ var dhdz = (hU - hD) / (2 * sd);
+ var gradMag = Math.sqrt(dhdx * dhdx + dhdz * dhdz);
+ var normalY = 1 / Math.sqrt(1 + gradMag * gradMag);
+ var slope = 1 - normalY;
+
+ var rnx = -dhdx, rny = 1.0, rnz = -dhdz;
+ var nLen = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz);
+ var invLen = 1 / nLen;
+
+ // Biome weights
+ var bw = computeBiomeWeightsByPosition(wx, wz);
+ var forestW = bw[BT_FOREST] || 0;
+ var canyonW = bw[BT_CANYON] || 0;
+ var tundraW = 1 - forestW - canyonW;
+
+ var color = computeTerrainColorCPU(wx, wz, ty, slope, forestW, canyonW, sc);
+
+ // Biome-blended grass params
+ var maxSlope = tCfg.maxSlope * tundraW + fCfg.maxSlope * forestW + cCfg.maxSlope * canyonW;
+ var minGW = tCfg.minGrassWeight * tundraW + fCfg.minGrassWeight * forestW + cCfg.minGrassWeight * canyonW;
+ var density = tCfg.density * tundraW + fCfg.density * forestW + cCfg.density * canyonW;
+ var grassHeightScale = tCfg.heightScale * tundraW + fCfg.heightScale * forestW + cCfg.heightScale * canyonW;
+ var patchiness = tCfg.patchiness * tundraW + fCfg.patchiness * forestW + cCfg.patchiness * canyonW;
+ var patchScale = tCfg.patchScale * tundraW + fCfg.patchScale * forestW + cCfg.patchScale * canyonW;
+
+ var slopeOk = slope <= maxSlope ? 1.0 : 0.0;
+ var weightOk = color.grassWeight >= minGW ? 1.0 : 0.0;
+
+ var patchThreshold = patchiness * 2 - 1;
+ var noiseVal = noise.simplex2D(wx * patchScale, wz * patchScale);
+ var patchMask = noiseVal > patchThreshold ? 1.0 : 0.0;
+
+ var rawGP = color.grassWeight * density * slopeOk * weightOk * patchMask;
+ var grassPlacement = Math.max(0, rawGP - roadInf);
+
+ if (grassPlacement <= 0) continue;
+ if (clumpRng > grassPlacement) continue;
+
+ offsets[count * 3] = lx;
+ offsets[count * 3 + 1] = ty;
+ offsets[count * 3 + 2] = lz;
+
+ var rotation = rng() * Math.PI * 2;
+ var scale = (input.scaleMin + clumpRng * (input.scaleMax - input.scaleMin)) * grassHeightScale;
+ rotScaleHash[count * 3] = rotation;
+ rotScaleHash[count * 3 + 1] = scale;
+ rotScaleHash[count * 3 + 2] = clumpRng;
+
+ groundColors[count * 3] = color.r;
+ groundColors[count * 3 + 1] = color.g;
+ groundColors[count * 3 + 2] = color.b;
+
+ var tS = tCfg.tintStrength * tundraW + fCfg.tintStrength * forestW + cCfg.tintStrength * canyonW;
+ if (tS > 0) {
+ var tR = tCfg.tintR * tCfg.tintStrength * tundraW + fCfg.tintR * fCfg.tintStrength * forestW + cCfg.tintR * cCfg.tintStrength * canyonW;
+ var tG = tCfg.tintG * tCfg.tintStrength * tundraW + fCfg.tintG * fCfg.tintStrength * forestW + cCfg.tintG * cCfg.tintStrength * canyonW;
+ var tB = tCfg.tintB * tCfg.tintStrength * tundraW + fCfg.tintB * fCfg.tintStrength * forestW + cCfg.tintB * cCfg.tintStrength * canyonW;
+ var inv = 1 / tS;
+ grassTints[count * 4] = tR * inv;
+ grassTints[count * 4 + 1] = tG * inv;
+ grassTints[count * 4 + 2] = tB * inv;
+ grassTints[count * 4 + 3] = tS;
}
+
+ groundNormals[count * 3] = rnx * invLen;
+ groundNormals[count * 3 + 1] = rny * invLen;
+ groundNormals[count * 3 + 2] = rnz * invLen;
+
+ count++;
+ }
+
+ if (count === 0) {
+ return {
+ type: "grassInstanceResult",
+ chunkKey: input.chunkKey,
+ offsets: new Float32Array(0),
+ rotScaleHash: new Float32Array(0),
+ groundColors: new Float32Array(0),
+ grassTints: new Float32Array(0),
+ groundNormals: new Float32Array(0),
+ count: 0
+ };
}
-
+
return {
- type: "placementResult",
- chunkKey,
- placements,
- stats: {
- candidatesGenerated,
- placementsCreated: placements.length,
- timeMs: performance.now() - startTime
- }
+ type: "grassInstanceResult",
+ chunkKey: input.chunkKey,
+ offsets: offsets.subarray(0, count * 3),
+ rotScaleHash: rotScaleHash.subarray(0, count * 3),
+ groundColors: groundColors.subarray(0, count * 3),
+ grassTints: grassTints.subarray(0, count * 4),
+ groundNormals: groundNormals.subarray(0, count * 3),
+ count: count
};
}
-// Worker message handler
-// CRITICAL: Must match WorkerPool's expected message format:
-// - Success: { result: }
-// - Error: { error: }
self.onmessage = function(e) {
- const input = e.data;
-
- try {
- if (input.type === "generatePlacements") {
- const result = generatePlacements(input);
- self.postMessage({ result });
- } else {
- self.postMessage({ error: "Unknown message type: " + input.type });
+ var input = e.data;
+ if (input.type === "generateGrassInstances") {
+ try {
+ var result = generateGrassInstances(input);
+ // Transfer Float32Array buffers for zero-copy
+ var transfers = [];
+ if (result.offsets.buffer.byteLength > 0) transfers.push(result.offsets.buffer);
+ if (result.rotScaleHash.buffer.byteLength > 0) transfers.push(result.rotScaleHash.buffer);
+ if (result.groundColors.buffer.byteLength > 0) transfers.push(result.groundColors.buffer);
+ if (result.grassTints.buffer.byteLength > 0) transfers.push(result.grassTints.buffer);
+ if (result.groundNormals.buffer.byteLength > 0) transfers.push(result.groundNormals.buffer);
+ self.postMessage({ result: result }, transfers);
+ } catch (err) {
+ self.postMessage({ error: err.message || "Grass worker error" });
}
- } catch (err) {
- self.postMessage({ error: err.message || "Grass worker error" });
+ } else {
+ self.postMessage({ error: "Unknown message type: " + input.type });
}
};
`;
@@ -243,27 +610,19 @@ self.onmessage = function(e) {
// WORKER POOL MANAGEMENT
// ============================================================================
-/** Singleton worker pool for grass placement */
let grassWorkerPool: WorkerPool | null =
null;
-/** Track if workers are available */
let workersChecked = false;
let workersAvailable = false;
-/**
- * Check if grass workers are available (client-side with Worker + Blob URL support)
- * Bun provides Worker and Blob but doesn't support blob URLs for workers
- */
export function isGrassWorkerAvailable(): boolean {
if (!workersChecked) {
workersChecked = true;
- // Check basic Worker/Blob availability
if (typeof Worker === "undefined" || typeof Blob === "undefined") {
workersAvailable = false;
return workersAvailable;
}
- // Detect Bun runtime - Bun has Worker/Blob but blob URLs don't work for workers
if (
typeof process !== "undefined" &&
process.versions &&
@@ -272,7 +631,6 @@ export function isGrassWorkerAvailable(): boolean {
workersAvailable = false;
return workersAvailable;
}
- // Detect Node.js runtime (no browser globals like window)
if (typeof window === "undefined") {
workersAvailable = false;
return workersAvailable;
@@ -282,11 +640,6 @@ export function isGrassWorkerAvailable(): boolean {
return workersAvailable;
}
-/**
- * Get or create the grass worker pool
- * @param poolSize - Number of workers (defaults to CPU cores - 1)
- * @returns Worker pool, or null if workers unavailable (server-side)
- */
export function getGrassWorkerPool(
poolSize?: number,
): WorkerPool | null {
@@ -303,87 +656,30 @@ export function getGrassWorkerPool(
return grassWorkerPool;
}
-/**
- * Generate grass placements using web worker
- * Returns immediately with a promise that resolves when the worker completes
- * Returns null if workers are not available
- */
export async function generateGrassPlacementsAsync(
- chunkKey: string,
- originX: number,
- originZ: number,
- size: number,
- baseDensity: number,
- seed: number,
- heightMultiplier?: number,
- maxInstances?: number,
+ input: GrassWorkerInput,
): Promise {
const pool = getGrassWorkerPool();
if (!pool) {
return null;
}
- return pool.execute({
- type: "generatePlacements",
- chunkKey,
- originX,
- originZ,
- size,
- baseDensity,
- seed,
- heightMultiplier,
- maxInstances,
- });
+ return pool.execute(input);
}
-/**
- * Result of batch grass generation
- */
-export interface GrassBatchResult {
- /** Successfully generated chunks */
- results: GrassWorkerOutput[];
- /** Whether workers were available */
- workersAvailable: boolean;
- /** Number of chunks that failed to generate */
- failedCount: number;
-}
-
-/**
- * Generate multiple chunks in parallel using worker pool
- */
export async function generateGrassChunksBatch(
- chunks: Array<{
- chunkKey: string;
- originX: number;
- originZ: number;
- size: number;
- }>,
- baseDensity: number,
- seed: number,
- heightMultiplier?: number,
- maxInstances?: number,
+ inputs: GrassWorkerInput[],
): Promise {
const pool = getGrassWorkerPool();
if (!pool) {
- return { results: [], workersAvailable: false, failedCount: chunks.length };
+ return { results: [], workersAvailable: false, failedCount: inputs.length };
}
const results: GrassWorkerOutput[] = [];
let failedCount = 0;
- // Execute all chunks in parallel using the worker pool
- const promises = chunks.map((chunk) =>
+ const promises = inputs.map((input) =>
pool
- .execute({
- type: "generatePlacements",
- chunkKey: chunk.chunkKey,
- originX: chunk.originX,
- originZ: chunk.originZ,
- size: chunk.size,
- baseDensity,
- seed,
- heightMultiplier,
- maxInstances,
- })
+ .execute(input)
.then((result) => {
results.push(result);
})
@@ -397,9 +693,6 @@ export async function generateGrassChunksBatch(
return { results, workersAvailable: true, failedCount };
}
-/**
- * Terminate the grass worker pool
- */
export function terminateGrassWorkerPool(): void {
if (grassWorkerPool) {
grassWorkerPool.terminate();
diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts
index 0e718bb93..9165f78b9 100644
--- a/packages/shared/src/utils/workers/QuadChunkWorker.ts
+++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts
@@ -16,17 +16,15 @@ import { WorkerPool } from "./WorkerPool";
import {
buildGetBaseHeightAtJS,
buildComputeBiomeWeightsJS,
- buildApplyLandscapeFeaturesJS,
MAX_HEIGHT,
WATER_LEVEL_NORMALIZED,
} from "../../systems/shared/world/TerrainHeightParams";
-import type { LandscapeFeatureDef } from "../../systems/shared/world/TerrainHeightParams";
import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes";
-import { buildApplyRiverCarvingJS } from "../../systems/shared/world/RiverUtils";
import {
buildNoiseGeneratorJS,
buildHeightHelpersJS,
buildBiomeInfluencesJS,
+ buildCreateBiomeNoiseSetsJS,
} from "./TerrainWorkerShared";
export interface QuadChunkWorkerConfig {
@@ -44,20 +42,6 @@ export interface QuadChunkWorkerConfig {
SHORELINE_LAND_MAX_MULTIPLIER: number;
SHORELINE_UNDERWATER_BAND: number;
UNDERWATER_DEPTH_MULTIPLIER: number;
- landscapeFeatures?: LandscapeFeatureDef[];
- riverFeatures?: Array<{
- x: number;
- z: number;
- halfWidth: number;
- depth: number;
- surfaceY?: number;
- }>;
- riverAABBs?: Array<{
- minX: number;
- maxX: number;
- minZ: number;
- maxZ: number;
- }>;
}
export interface QuadChunkWorkerInput {
@@ -126,13 +110,10 @@ function generateQuadChunk(input) {
const halfSize = size * 0.5;
const gridStep = size / (segments - 1);
- var landscapeFeatures = config.landscapeFeatures || [];
- var riverFeatures = (config.riverFeatures || []);
- var riverAABBs = (config.riverAABBs || []);
${buildComputeBiomeWeightsJS()}
- ${buildApplyRiverCarvingJS(MAX_HEIGHT)}
- ${buildApplyLandscapeFeaturesJS()}
${buildGetBaseHeightAtJS()}
+ ${buildCreateBiomeNoiseSetsJS()}
+ var biomeNoiseSets = createBiomeNoiseSets(seed);
${buildHeightHelpersJS()}
${buildBiomeInfluencesJS()}
@@ -232,21 +213,6 @@ function generateQuadChunk(input) {
colorB = colorB + (0.333 - colorB) * shoreFactor;
}
- // River proximity: 1.0 = in channel, 0.0 = outside influence
- var rProj = projectOntoRiverJS(worldX, worldZ);
- if (rProj && rProj.surfaceY === rProj.surfaceY) { // NaN check
- var rDist = rProj.dist;
- var rHW = rProj.halfWidth;
- var rBankWidth = rHW * 2; // bank zone = halfWidth * (valleyMultiplier - 1)
-
- if (rDist < rHW) {
- riverProximity[idx] = 1.0; // in channel
- } else if (rDist < rHW + rBankWidth) {
- var rBankT = (rDist - rHW) / rBankWidth;
- riverProximity[idx] = 1.0 - rBankT * rBankT * (3.0 - 2.0 * rBankT); // smoothstep falloff
- }
- }
-
colorData[idx * 3] = colorR;
colorData[idx * 3 + 1] = colorG;
colorData[idx * 3 + 2] = colorB;
diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts
index a472e831d..de4f0e9b2 100644
--- a/packages/shared/src/utils/workers/TerrainWorker.ts
+++ b/packages/shared/src/utils/workers/TerrainWorker.ts
@@ -13,7 +13,6 @@ import { WorkerPool } from "./WorkerPool";
import {
buildGetBaseHeightAtJS,
buildComputeBiomeWeightsJS,
- buildApplyLandscapeFeaturesJS,
MAX_HEIGHT,
WATER_LEVEL_NORMALIZED,
} from "../../systems/shared/world/TerrainHeightParams";
@@ -22,8 +21,8 @@ import {
buildNoiseGeneratorJS,
buildHeightHelpersJS,
buildBiomeInfluencesJS,
+ buildCreateBiomeNoiseSetsJS,
} from "./TerrainWorkerShared";
-import { buildApplyRiverCarvingJS } from "../../systems/shared/world/RiverUtils";
// Types for terrain generation
// MUST match TerrainSystem.CONFIG exactly for height and biome calculation
@@ -47,32 +46,6 @@ export interface TerrainWorkerConfig {
SHORELINE_LAND_MAX_MULTIPLIER: number;
SHORELINE_UNDERWATER_BAND: number;
UNDERWATER_DEPTH_MULTIPLIER: number;
- landscapeFeatures?: Array<{
- type: string;
- x: number;
- z: number;
- radius: number;
- strength: number;
- layers: number;
- shapePower: number;
- edgeSharpness: number;
- layerSlope: number;
- noiseScale: number;
- noiseAmount: number;
- }>;
- riverFeatures?: Array<{
- x: number;
- z: number;
- halfWidth: number;
- depth: number;
- surfaceY?: number;
- }>;
- riverAABBs?: Array<{
- minX: number;
- maxX: number;
- minZ: number;
- maxZ: number;
- }>;
}
export interface TerrainWorkerInput {
@@ -161,15 +134,12 @@ function generateHeightmap(input) {
// HEIGHT FUNCTIONS — generated from TerrainHeightParams.ts (single source of truth)
// ============================================
- var landscapeFeatures = (config.landscapeFeatures || []);
- var riverFeatures = (config.riverFeatures || []);
- var riverAABBs = (config.riverAABBs || []);
-
${buildComputeBiomeWeightsJS()}
- ${buildApplyRiverCarvingJS(MAX_HEIGHT)}
- ${buildApplyLandscapeFeaturesJS()}
${buildGetBaseHeightAtJS()}
+ ${buildCreateBiomeNoiseSetsJS()}
+ var biomeNoiseSets = createBiomeNoiseSets(seed);
+
${buildHeightHelpersJS()}
${buildBiomeInfluencesJS()}
@@ -268,21 +238,6 @@ function generateHeightmap(input) {
colorB = colorB + (0.333 - colorB) * shoreFactor;
}
- // River proximity: 1.0 = in channel, smoothstep to 0.0 at bank edge
- var rProj = projectOntoRiverJS(worldX, worldZ);
- if (rProj && rProj.surfaceY === rProj.surfaceY) { // NaN check
- var rDist = rProj.dist;
- var rHW = rProj.halfWidth;
- var rBankWidth = rHW * 2; // bank zone = halfWidth * (valleyMultiplier - 1)
-
- if (rDist < rHW) {
- riverProximity[idx] = 1.0; // in channel
- } else if (rDist < rHW + rBankWidth) {
- var rBankT = (rDist - rHW) / rBankWidth;
- riverProximity[idx] = 1.0 - rBankT * rBankT * (3.0 - 2.0 * rBankT); // smoothstep falloff
- }
- }
-
colorData[idx * 3] = colorR;
colorData[idx * 3 + 1] = colorG;
colorData[idx * 3 + 2] = colorB;
diff --git a/packages/shared/src/utils/workers/TerrainWorkerShared.ts b/packages/shared/src/utils/workers/TerrainWorkerShared.ts
index 40c984c81..886837612 100644
--- a/packages/shared/src/utils/workers/TerrainWorkerShared.ts
+++ b/packages/shared/src/utils/workers/TerrainWorkerShared.ts
@@ -138,6 +138,20 @@ class NoiseGenerator {
}
return height;
}
+
+ simplexFbm2D(x, y, octaves, amplitude, frequency, gain, lacunarity, offset) {
+ var value = 0;
+ var amp = amplitude;
+ var fx = x * frequency;
+ var fy = y * frequency;
+ for (var i = 0; i < octaves; i++) {
+ value += amp * this.simplex2D(fx, fy);
+ fx *= lacunarity;
+ fy *= lacunarity;
+ amp *= gain;
+ }
+ return value + offset;
+ }
}`;
}
@@ -201,6 +215,27 @@ export function buildHeightHelpersJS(): string {
}`;
}
+/**
+ * Creates per-biome noise sets in the worker.
+ * Expects: seed (number), BIOME_CONFIGS (object) to be in scope.
+ */
+export function buildCreateBiomeNoiseSetsJS(): string {
+ return `
+ function createBiomeNoiseSets(seed) {
+ var sets = {};
+ for (var key in BIOME_CONFIGS) {
+ var cfg = BIOME_CONFIGS[key];
+ var base = seed + cfg.seedOffset;
+ sets[key] = {
+ main: new NoiseGenerator(base),
+ variation: new NoiseGenerator(base + 4),
+ erosion: new NoiseGenerator(base + 1)
+ };
+ }
+ return sets;
+ }`;
+}
+
/**
* Biome influence function used by both workers.
*
diff --git a/packages/shared/src/utils/workers/WorkerPool.ts b/packages/shared/src/utils/workers/WorkerPool.ts
index 11e9e2e88..2b2459fe5 100644
--- a/packages/shared/src/utils/workers/WorkerPool.ts
+++ b/packages/shared/src/utils/workers/WorkerPool.ts
@@ -19,14 +19,15 @@ type WorkerTask = {
reject: (error: Error) => void;
};
-interface PoolWorker {
+interface PoolWorker {
worker: Worker;
busy: boolean;
taskCount: number;
+ activeTask?: WorkerTask;
}
export class WorkerPool {
- private workers: PoolWorker[] = [];
+ private workers: PoolWorker[] = [];
private taskQueue: WorkerTask[] = [];
private nextWorkerIndex = 0;
private terminated = false;
@@ -232,19 +233,22 @@ export class WorkerPool {
*/
terminate(): void {
this.terminated = true;
- for (const { worker } of this.workers) {
- worker.terminate();
+ for (const pw of this.workers) {
+ if (pw.activeTask) {
+ pw.activeTask.reject(new Error("WorkerPool terminated"));
+ pw.activeTask = undefined;
+ }
+ pw.worker.terminate();
}
this.workers = [];
- // Reject any pending tasks
for (const task of this.taskQueue) {
task.reject(new Error("WorkerPool terminated"));
}
this.taskQueue = [];
}
- private getAvailableWorker(): PoolWorker | null {
+ private getAvailableWorker(): PoolWorker | null {
// Round-robin with availability check
const startIndex = this.nextWorkerIndex;
for (let i = 0; i < this.workers.length; i++) {
@@ -259,15 +263,17 @@ export class WorkerPool {
}
private runTask(
- poolWorker: PoolWorker,
+ poolWorker: PoolWorker,
task: WorkerTask,
): void {
poolWorker.busy = true;
+ poolWorker.activeTask = task;
const handleMessage = (e: MessageEvent) => {
poolWorker.worker.removeEventListener("message", handleMessage);
poolWorker.worker.removeEventListener("error", handleError);
poolWorker.busy = false;
+ poolWorker.activeTask = undefined;
poolWorker.taskCount++;
if (e.data.error) {
@@ -276,7 +282,6 @@ export class WorkerPool {
task.resolve(e.data.result as TOutput);
}
- // Process next queued task
this.processQueue();
};
@@ -284,10 +289,10 @@ export class WorkerPool {
poolWorker.worker.removeEventListener("message", handleMessage);
poolWorker.worker.removeEventListener("error", handleError);
poolWorker.busy = false;
+ poolWorker.activeTask = undefined;
task.reject(new Error(e.message || "Worker error"));
- // Process next queued task
this.processQueue();
};