diff --git a/packages/client/public/textures/noise28.png b/packages/client/public/textures/noise28.png new file mode 100644 index 000000000..214402014 Binary files /dev/null and b/packages/client/public/textures/noise28.png differ diff --git a/packages/client/src/lib/gameClientUi.ts b/packages/client/src/lib/gameClientUi.ts index 8dd2aecb8..d348a91ff 100644 --- a/packages/client/src/lib/gameClientUi.ts +++ b/packages/client/src/lib/gameClientUi.ts @@ -1,5 +1,3 @@ -export function resolveGameClientUiDisplay( - visible: boolean, -): "block" | "none" { +export function resolveGameClientUiDisplay(visible: boolean): "block" | "none" { return visible ? "block" : "none"; } diff --git a/packages/client/src/lib/streamingAccessToken.ts b/packages/client/src/lib/streamingAccessToken.ts index 4f67998ba..a249cd2ec 100644 --- a/packages/client/src/lib/streamingAccessToken.ts +++ b/packages/client/src/lib/streamingAccessToken.ts @@ -52,7 +52,9 @@ export function primeStreamingAccessTokenFromWindow( return cachedStreamingAccessToken; } - const resolved = resolveStreamingAccessTokenFromHref(targetWindow.location.href); + const resolved = resolveStreamingAccessTokenFromHref( + targetWindow.location.href, + ); cachedStreamingAccessToken = resolved.token; if (resolved.nextUrl) { diff --git a/packages/client/src/screens/GameClient.tsx b/packages/client/src/screens/GameClient.tsx index c931ba295..68fe8b718 100644 --- a/packages/client/src/screens/GameClient.tsx +++ b/packages/client/src/screens/GameClient.tsx @@ -350,8 +350,7 @@ export function GameClient({ // Default to game server on 5555, CDN on 8080 const runtimeEnv = await loadRuntimeEnv(); const runtimeWsUrl = normalizeEnvValue(runtimeEnv?.PUBLIC_WS_URL); - const finalWsUrl = - initialWsUrlRef.current || runtimeWsUrl || GAME_WS_URL; + const finalWsUrl = initialWsUrlRef.current || runtimeWsUrl || GAME_WS_URL; const runtimeCdnUrl = normalizeEnvValue(runtimeEnv?.PUBLIC_CDN_URL); const buildCdnUrl = normalizeEnvValue(CDN_URL); const resolvedCdnUrl = resolveCdnUrlForClient(runtimeCdnUrl, buildCdnUrl); diff --git a/packages/client/tests/unit/lib/embeddedAuth.test.ts b/packages/client/tests/unit/lib/embeddedAuth.test.ts index b14bf12fb..ffe030356 100644 --- a/packages/client/tests/unit/lib/embeddedAuth.test.ts +++ b/packages/client/tests/unit/lib/embeddedAuth.test.ts @@ -25,9 +25,7 @@ describe("embeddedAuth", () => { expect(normalizeTrustedOrigin("javascript:alert(1)")).toBeNull(); }); - it( - "builds the trusted embed-origin allowlist from current, app, and configured origins", - () => { + it("builds the trusted embed-origin allowlist from current, app, and configured origins", () => { expect( resolveTrustedEmbedOrigins({ currentOrigin: "https://game.example.com", @@ -41,8 +39,7 @@ describe("embeddedAuth", () => { "https://embed.example.com", "https://partner.example.com", ]); - }, - ); + }); it("ignores untrusted origins and parses valid bootstrap messages", () => { const trustedOrigins = resolveTrustedEmbedOrigins({ @@ -108,9 +105,7 @@ describe("embeddedAuth", () => { }); }); - it( - "targets HYPERSCAPE_READY to a trusted referrer or explicit allowed origin", - () => { + it("targets HYPERSCAPE_READY to a trusted referrer or explicit allowed origin", () => { const trustedOrigins = resolveTrustedEmbedOrigins({ currentOrigin: "https://game.example.com", publicAppUrl: "https://app.example.com", @@ -165,6 +160,5 @@ describe("embeddedAuth", () => { allowWildcardFallback: true, }), ).toBe("*"); - }, - ); + }); }); diff --git a/packages/client/tests/unit/lib/streamingAccessToken.test.ts b/packages/client/tests/unit/lib/streamingAccessToken.test.ts index 94bb4428b..199aaa914 100644 --- a/packages/client/tests/unit/lib/streamingAccessToken.test.ts +++ b/packages/client/tests/unit/lib/streamingAccessToken.test.ts @@ -131,6 +131,8 @@ describe("streamingAccessToken", () => { }, } as unknown as Window; - expect(primeStreamingAccessTokenFromWindow(freshWindow)).toBe("fresh-token"); + expect(primeStreamingAccessTokenFromWindow(freshWindow)).toBe( + "fresh-token", + ); }); }); diff --git a/packages/client/tests/unit/screens/GameClient.test.tsx b/packages/client/tests/unit/screens/GameClient.test.tsx index d1124daf3..dfb97a332 100644 --- a/packages/client/tests/unit/screens/GameClient.test.tsx +++ b/packages/client/tests/unit/screens/GameClient.test.tsx @@ -37,7 +37,9 @@ vi.mock("../../../src/game/CoreUI", () => ({ })); vi.mock("../../../src/components/common/ErrorBoundary", () => ({ - ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, + ErrorBoundary: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), })); vi.mock("../../../src/lib/ThreeResourceManager", () => ({ @@ -97,7 +99,11 @@ describe("GameClient", () => { expect(screen.getByTestId("core-ui")).toBeInTheDocument(); rerender( - , + , ); await waitFor(() => { diff --git a/packages/client/tests/unit/screens/LoadingScreen.test.tsx b/packages/client/tests/unit/screens/LoadingScreen.test.tsx index 3d56c74d6..2736bf1ab 100644 --- a/packages/client/tests/unit/screens/LoadingScreen.test.tsx +++ b/packages/client/tests/unit/screens/LoadingScreen.test.tsx @@ -65,10 +65,10 @@ describe("LoadingScreen", () => { ); const progressHandler = ( - (world.on as unknown as ReturnType).mock.calls.find( - ([event]) => event === EventType.ASSETS_LOADING_PROGRESS, - )?.[1] as ((payload: unknown) => void) | undefined - ); + world.on as unknown as ReturnType + ).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(); };