diff --git a/examples/cornhole/cornhole.js b/examples/cornhole/cornhole.js index e14359f..1b822f8 100644 --- a/examples/cornhole/cornhole.js +++ b/examples/cornhole/cornhole.js @@ -15,9 +15,10 @@ import { app, generateSetupData, UILoadingScreen, - FuseRenderer + FuseRenderer, + WaterSurface } from '@opengolfsim/fuse'; -import { Water } from 'three/examples/jsm/Addons.js'; +// import { Water } from 'three/examples/jsm/Addons.js'; import groundBeachModel from './models/GroundBeach.glb?url'; import cornHoleBoardModel from './models/CornHoleBoard.glb?url'; import sandCastleModel from './models/SandCastle.glb?url'; @@ -205,80 +206,125 @@ async function loadGameBoards() { setupBoard('red', boardMeshOriginal); } -async function setupScene() { - gameContext.scene = new THREE.Scene(); - gameContext.scene.background = new THREE.Color(skyColor); - gameContext.scene.fog = new THREE.Fog(fogColor, 10, 140); +function createWater(tex) { - const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); - gameContext.scene.add(ambientLight); - - const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5); - directionalLight.position.set(-5, 20, 0); - directionalLight.castShadow = true; - directionalLight.shadow.mapSize.width = 2048; // Higher = crisper shadows - directionalLight.shadow.mapSize.height = 2048; - directionalLight.shadow.camera.near = 1; - directionalLight.shadow.camera.far = 50; - directionalLight.shadow.camera.left = -50; - directionalLight.shadow.camera.right = 50; - directionalLight.shadow.camera.top = 50; - directionalLight.shadow.camera.bottom = -50; - gameContext.scene.add(directionalLight); - directionalLight.target.position.set(0, 0, 0); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.repeat.set(100, 100); // tile 50x across, 100x down the 100x200 plane + tex.colorSpace = THREE.SRGBColorSpace; // correct color rendering + tex.anisotropy = gameContext.renderer.getMaxAnisotropy(); const waterGroup = new THREE.Group(); const underwaterGeometry = new THREE.PlaneGeometry(300, 100); - const underwaterMaterial = new THREE.MeshBasicMaterial({ color: new THREE.Color('#1a5972') }); + underwaterGeometry.rotateX(-Math.PI / 2); + const underwaterMaterial = new THREE.MeshStandardMaterial({ + // color: new THREE.Color('#486e5d'), + map: tex, + roughness: 0, + metalness: 0, + color: new THREE.Color('#318da6') + // opacity: 0.8, + // transparent: true + }); const underwaterPlane = new THREE.Mesh(underwaterGeometry, underwaterMaterial); - // underwaterPlane.rotation.x = -Math.PI / 2; - - // underwaterPlane.rotation.y = -90 * (Math.PI / 180); - waterGroup.add(underwaterPlane); - const waterGeometry = new THREE.PlaneGeometry(300, 100); - - gameContext.water.object = new Water(waterGeometry, { - textureWidth: 256, - textureHeight: 256, - waterNormals: textureLoader.load( - waterNormals, - (texture) => { - texture.wrapS = texture.wrapT = THREE.RepeatWrapping; - } - ), - alpha: 0.4, - sunColor: new THREE.Color('#ffffff'), - waterColor: new THREE.Color('#3a8fc4'), - distortionScale: 5.0, - speed: 0.5, - sunDirection: new THREE.Vector3(0, 2, 0.70707), - fog: gameContext.scene.fog !== undefined, + const waterSurfaceGeometry = new THREE.PlaneGeometry(300, 100); + waterSurfaceGeometry.rotateX(-Math.PI / 2); + + const waterSurfaceMaterial = new THREE.MeshBasicMaterial({ color: new THREE.Color('#1a5972') }); + const waterMesh = new THREE.Mesh(waterSurfaceGeometry, waterSurfaceMaterial); + gameContext.waterSurface = new WaterSurface(waterMesh, undefined, { + speed: 0.25, // slower — ocean swells are lazy + flowStrength: 0.32, // gentle drift, not directional flow + sideFlowStrength: 0.35, // a bit of cross-chop for variety + uvTiling: [2, 2], // this is the big one — 3x larger wave patterns than your lake + normalStrength: 3.0, // push normals harder so the bigger waves still read + shallowColor: new THREE.Color('#50a3ba'), + deepColor: new THREE.Color('#225d76'), + depthRange: 20, // much wider gradient — ocean is deep + roughness: 0.01, // glassier surface = sharper reflections on swells + opacity: 0.5, + envMapIntensity: 0.3, // more sky/environment reflection + foamWidth: 12.5, + foamColor: new THREE.Color('#c8e0dc'), + foamDensity: 0.6, + foamSharpness: 0.15, + foamOpacity: 0.8, + yOffset: 0 }); - - gameContext.water.object.material.transparent = true; - // gameContext.water.object.rotation.x = -Math.PI / 2; - const radians = -89.5 * (Math.PI / 180); - underwaterPlane.rotation.x = radians; + // const radians = -89.5 * (Math.PI / 180); + // underwaterPlane.rotation.x = radians; // underwaterPlane.rotation.z = 2; // underwaterPlane.position.y = -10.6; // just below the water - underwaterPlane.position.z = -50; + underwaterPlane.position.z = -48.5; + underwaterPlane.rotation.x = -0.025; // far edge dips down ~5 units + underwaterPlane.position.y = -1.5; // start it slightly below the water surface + - gameContext.water.object.rotation.copy(underwaterPlane.rotation); - // gameContext.water.object.rotation.x = radians; - // gameContext.water.object.rotation.x = underwaterPlane.rotation.x; - gameContext.water.object.position.copy(underwaterPlane.position); - gameContext.water.object.position.y += 0.006; - // gameContext.water.object.position.z = -50; - - waterGroup.add(gameContext.water.object); + // gameContext.waterSurface.water.rotation.copy(underwaterPlane.rotation); + // // gameContext.waterSurface.water.rotation.x = radians; + // // gameContext.waterSurface.water.rotation.x = underwaterPlane.rotation.x; + // gameContext.waterSurface.water.position.copy(underwaterPlane.position); + // gameContext.waterSurface.water.position.y += 0.006; + // // gameContext.waterSurface.water.position.z = -50; + + gameContext.waterSurface.water.position.set(0, 0.005, -48.5); + + waterGroup.add(gameContext.waterSurface.water); waterGroup.position.y = 0; waterGroup.position.z = -20; gameContext.scene.add(waterGroup); +} +async function setupScene() { + gameContext.scene = new THREE.Scene(); + gameContext.scene.background = new THREE.Color(skyColor); + gameContext.scene.fog = new THREE.Fog(fogColor, 10, 140); + + const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); + gameContext.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); + directionalLight.position.set(-10, 80, -40); + directionalLight.castShadow = true; + directionalLight.shadow.mapSize.width = 4096; + directionalLight.shadow.mapSize.height = 4096; + directionalLight.shadow.camera.near = 1; + directionalLight.shadow.camera.far = 200; + directionalLight.shadow.camera.left = -60; + directionalLight.shadow.camera.right = 60; + directionalLight.shadow.camera.top = 60; + directionalLight.shadow.camera.bottom = -60; + // directionalLight.shadow.bias = -0.001; + // directionalLight.shadow.normalBias = 0.02; + // directionalLight.shadow.bias = 0; + // directionalLight.shadow.normalBias = 0.05; + + directionalLight.shadow.camera.updateProjectionMatrix(); + directionalLight.target.position.set(10, 1, 50); + directionalLight.target.updateMatrixWorld(); + gameContext.scene.add(directionalLight); + + // directionalLight.position.set(-2, 80, 0); + // directionalLight.castShadow = true; + // directionalLight.shadow.mapSize.width = 2048; // Higher = crisper shadows + // directionalLight.shadow.mapSize.height = 2048; + // directionalLight.shadow.camera.near = 1; + // directionalLight.shadow.camera.far = 30; + // directionalLight.shadow.camera.left = -30; + // directionalLight.shadow.camera.right = 30; + // directionalLight.shadow.camera.top = 30; + // directionalLight.shadow.camera.bottom = -30; + // // directionalLight.shadow.bias = -0.002; + // // directionalLight.shadow.normalBias = 0.02; + + // gameContext.scene.add(directionalLight); + // directionalLight.target.position.set(2, 1, 5); + // directionalLight.target.updateMatrixWorld(); + } @@ -305,9 +351,7 @@ function createSky() { gameContext.scene.add(gameContext.clouds.object); } -async function createGround(width = 100, depth = 100) { - - const tex = textureLoader.load(sandTexture); +async function createGround(tex, width = 100, depth = 100) { tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(100, 100); // tile 50x across, 100x down the 100x200 plane @@ -479,9 +523,10 @@ async function setupGame() { } gameContext.renderer = new FuseRenderer({ canvas, - antialias: true - // renderMode: 'webgpu' + antialias: true, + renderMode: 'webgpu' }); + // gameContext.renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), antialias: true }); // gameContext.renderer.setSize(window.innerWidth, window.innerHeight); // gameContext.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); @@ -510,8 +555,11 @@ async function setupGame() { await gameContext.renderer.init(); gameContext.meshLoader = new MeshLoader(gameContext.renderer); + + const tex = textureLoader.load(sandTexture); - await createGround(); + await createWater(tex); + await createGround(tex); gameContext.camera.setScene(gameContext.ground.mesh); @@ -525,13 +573,19 @@ async function setupGame() { gameContext.scene.add(gameContext.aimMesh); gameContext.camera?.setPositions(gameContext.startPoint, gameContext.aimPoint); - // createSky(); + createSky(); await loadGameBoards(); await loadModels(); + // gameContext.renderer.generateEnvironment(gameContext.scene, gameContext.clouds.object); + // if (gameContext.renderer.environment) { + // gameContext.waterSurface.updateEnvironment(gameContext.renderer.environment); + // // gameContext.course.updateEnvironment(gameContext.renderer.environment); + // } + startRound(); updateScoreboard(); // positionCamera(); @@ -848,9 +902,12 @@ function animate(animDelta) { } - if (gameContext.water.object) { - gameContext.water.object.material.uniforms['time'].value += gameContext.water.speed / 60.0; + if (gameContext.waterSurface) { + gameContext.waterSurface.update(); } + // if (gameContext.water.object) { + // gameContext.water.object.material.uniforms['time'].value += gameContext.water.speed / 60.0; + // } if (gameContext.debug.enabled) { const { vertices, colors } = app.world.debugRender(); diff --git a/examples/courses/courses.ts b/examples/courses/courses.ts index 49b3163..b125b3e 100644 --- a/examples/courses/courses.ts +++ b/examples/courses/courses.ts @@ -83,7 +83,7 @@ const gameContext: { dialogs: {} }; -const defaultSkyColor = 'rgb(192, 215, 241)'; +const defaultSkyColor = 'rgb(177, 205, 236)'; const defaultFogColor = new THREE.Color('#fff7e0'); const defaultCloudColor = new THREE.Color('rgb(255, 255, 255)'); // const lightColor = new THREE.Color('rgb(255, 247, 224)'); @@ -91,7 +91,7 @@ const defaultCloudColor = new THREE.Color('rgb(255, 255, 255)'); function launchShot(shot: OpenGolfSim.Shot) { if (!gameContext.golfBall) return; - console.log('[DEBUG] Received new shot data:', shot); + if (shot.ballSpeed && !gameContext.golfBall.isShotActive) { gameContext.shotData?.updateShotData(shot); gameContext.golfBall.launchShot(shot); @@ -126,19 +126,23 @@ function setupNextShot() { } -function setupRenderer() { +async function setupRenderer() { THREE.ColorManagement.enabled = true; app.sendMessage({ type: 'log', message: `qualityLevel: ${gameContext.qualityLevel}` }); - + // console.log('Backend:', renderer.backend?.constructor?.name); + const canvas = document.getElementById('canvas'); if (!canvas || !(canvas instanceof HTMLCanvasElement)) throw new Error('Unable to find canvas in HTML. Make sure you create a root canvas element (e.g. )'); gameContext.renderer = new FuseRenderer({ canvas, + renderMode: 'webgpu', qualityLevel: gameContext.qualityLevel, antialias: true // gameContext.qualityLevel >= QualityMode.Medium }); + + await gameContext.renderer.init(); // gameContext.renderer.setSize(window.innerWidth, window.innerHeight); let maxPixelRatio = Math.min(window.devicePixelRatio, 1); @@ -159,19 +163,23 @@ async function setupScene() { const skyType = gameContext.course?.sceneSettings?.sky?.type; const cloudSettings = gameContext.course?.sceneSettings?.sky?.clouds; - const skyColor = new THREE.Color(cloudSettings?.skyColor ?? defaultSkyColor); + const skyColor = new THREE.Color('#80cef6'); + // const skyColor = new THREE.Color(cloudSettings?.skyColor ?? defaultSkyColor); const fogColor = new THREE.Color(cloudSettings?.fogColor ?? defaultFogColor); const cloudColor = new THREE.Color(cloudSettings?.cloudColor ?? defaultCloudColor); // Base scene // TODO: move to course loader? gameContext.scene = new THREE.Scene(); - gameContext.scene.background = new THREE.Color(skyColor); + gameContext.scene.background = skyColor; - gameContext.fog = new THREE.Fog(fogColor, 100, 800); + gameContext.fog = new THREE.Fog(fogColor, 300, 800); gameContext.scene.fog = gameContext.fog; - gameContext.lightGroup = new CourseLight(); + gameContext.lightGroup = new CourseLight({ + qualityLevel: gameContext.qualityLevel, + color: new THREE.Color('#fffac0') + }); gameContext.scene.add(gameContext.lightGroup); // Main Camera @@ -190,6 +198,8 @@ async function setupScene() { } ); + gameContext.renderer.setupPostProcessing(gameContext.scene, gameContext.camera); + // Aim point gameContext.visualAimPoint = new AimPoint(gameContext.camera, { units: gameContext.setupData?.units @@ -221,17 +231,25 @@ async function setupScene() { gameContext.controls.on('toggleStats', () => gameContext.stats?.toggle()); + console.log('-----'); + console.log('cloudSettings', cloudSettings) + console.log(`sky: ${skyColor.getHexString()}, cloud: ${cloudColor.getHexString()}, fog: ${cloudColor.getHexString()}`); // TODO: move to course loader.. // Sky/Clouds gameContext.clouds = new VolumetricClouds(gameContext.camera, { radius: 800, - density: cloudSettings?.density ?? 0.4, - opacity: cloudSettings?.opacity ?? 0.8, - scale: cloudSettings?.scale ?? 6, - skyColor, + density: 0.28, + opacity: 0.8, + scale: 4, + // density: cloudSettings?.density ?? 0.4, + // opacity: cloudSettings?.opacity ?? 0.8, + // scale: cloudSettings?.scale ?? 6, cloudColor, fogColor, - position: new THREE.Vector3(...cloudSettings?.position ?? [0, -50, 0]) + // cloudColor: new THREE.Color('#f4fafc'), + // fogColor: new THREE.Color('#f7f6f1'), + skyColor, + position: new THREE.Vector3(0, 0, 0) }); gameContext.scene.add(gameContext.clouds.object); @@ -299,7 +317,6 @@ async function setupCourse() { if (!app.world) { throw new Error('Physics world does not exist'); } - if (!gameContext?.setupData) { throw new Error('Missing setupData!'); } @@ -309,7 +326,9 @@ async function setupCourse() { if (typeof gameContext.setupData?.qualityLevel !== 'undefined') { gameContext.qualityLevel = gameContext.setupData.qualityLevel; } - setupRenderer(); + + await setupRenderer(); + if (!gameContext.renderer) { throw new Error('Missing renderer!'); } @@ -350,9 +369,10 @@ async function setupCourse() { // create the golf ball gameContext.golfBall = new GolfBall(gameContext.scene, app.world, app.rapier, { setupData: gameContext.setupData, + groundMeshes: gameContext.course.getGroundMeshes() }); gameContext.golfBall.on('landed', (velocity: number) => { - gameContext.audioPlayer?.play(GroundThudSound, velocity); + // gameContext.audioPlayer?.play(GroundThudSound, velocity); }); gameContext.golfBall.on('holedOut', () => { gameContext.audioPlayer?.play(HoleOutSound); @@ -418,7 +438,7 @@ async function setupCourse() { gameContext.courseMap?.on('updateAim', adjustAimPoint); gameContext.courseMap?.on('updateStart', adjustStartPoint); - + } /** @@ -430,8 +450,10 @@ function preLoad() { // } // allow override with query param const qualityParam = (new URLSearchParams(window.location.search)).get('quality'); + console.log('quality param', qualityParam); if (qualityParam) { gameContext.qualityLevel = parseInt(qualityParam, 10); + if (gameContext.setupData) gameContext.setupData.qualityLevel = gameContext.qualityLevel; } console.log('[debug] Setup Data', gameContext.setupData); @@ -442,6 +464,33 @@ function preLoad() { requestAnimationFrame(animate); gameContext.isReady = true; } + + if (gameContext.scene) { + + // Right after scene creation, before anything is added + const originalAdd = gameContext.scene.add.bind(gameContext.scene); + + gameContext.scene.add = function(...args: any[]) { + for (const obj of args) { + console.log('Scene.add:', obj.type, obj.constructor.name); + } + return originalAdd(...args); + }; + } + + + // setInterval(() => { + // if (!gameContext.renderer) return; + // const info = gameContext.renderer.renderer.info; + // app.log([ + // `Geometries: ${info.memory.geometries}`, + // `Textures: ${info.memory.textures}`, + // `Programs: ${info.memory.programs}`, + // `Total: ${(info.memory.total / 1024 / 1024).toFixed(1)}MB`, + // `TrailPoints: ${gameContext.golfBall?.trail?.points?.length ?? 0}` + // ].join(', ')); + // }, 5000); + }); gameContext.loadingScreen.load(setupCourse); document.body.style.opacity = '1'; @@ -464,11 +513,11 @@ function animate(animDelta: number) { gameContext.controls?.update(delta); gameContext.clouds?.update(delta); - if (gameContext.camera && gameContext.isReady) { - gameContext.course?.update(delta, gameContext.camera, gameContext.golfBall?.isShotActive); + if (gameContext.camera && gameContext.golfBall && gameContext.isReady) { + gameContext.course?.update(delta, gameContext.camera, gameContext.golfBall, gameContext.game?.getActiveHoleNumber()); } - gameContext.game?.update(delta); + // gameContext.game?.update(delta); if (gameContext.scene && gameContext.game) { gameContext.courseMap?.render( diff --git a/examples/range/range.ts b/examples/range/range.ts index 1210d75..0bcc606 100644 --- a/examples/range/range.ts +++ b/examples/range/range.ts @@ -30,9 +30,9 @@ import fairwayTexture from './textures/gen_fairway_tex.png?url'; import fairwayMap from './textures/gen_fairway_map.png?url'; import { PlayerState } from '@/courses/types'; -const sunColor = new THREE.Color('#fffbec'); -const skyColor = new THREE.Color('#d5e4e9'); -const fogColor = new THREE.Color('#7e9096'); +const sunColor = new THREE.Color('#fdf0d8'); +const skyColor = new THREE.Color('#abd0db'); +const fogColor = new THREE.Color('#9bb0b7'); const cloudColor = new THREE.Color('#ffffff'); const mountainColor = new THREE.Color('#687e80'); const hashMarks = [50, 100, 150, 200, 250, 300]; @@ -97,13 +97,20 @@ function launchShot(shot: OpenGolfSim.Shot) { } } -function setupWorld() { +async function setupWorld() { gameContext.timer.connect(document); const canvas = document.getElementById('canvas'); if (!canvas || !(canvas instanceof HTMLCanvasElement)) throw new Error('Unable to find canvas in HTML. Make sure you create a root canvas element (e.g. )'); - gameContext.renderer = new FuseRenderer({ canvas, antialias: true }); + gameContext.renderer = new FuseRenderer({ + canvas, + antialias: true, + renderMode: 'webgpu', + qualityLevel: gameContext.setupData?.qualityLevel ?? 2 + }); + + await gameContext.renderer.init(); } @@ -149,6 +156,8 @@ async function createGroundPlane() { gameContext.ground.position.y = 0; gameContext.ground.position.z = 140; gameContext.ground.receiveShadow = true; + gameContext.ground.userData.surface = 'fairway'; + // console.log('mat', mat); @@ -160,10 +169,10 @@ async function createGroundPlane() { gameContext.scene.add(gameContext.groundLines); - gameContext.groundCollider = new GroundPhysics(gameContext.ground, app.world, app.rapier, { - type: CourseSurfaceType.Fairway, - ...CourseSurfaces.fairway - }); + // gameContext.groundCollider = new GroundPhysics(gameContext.ground, app.world, app.rapier, { + // type: CourseSurfaceType.Fairway, + // ...CourseSurfaces.fairway + // }); const downrange = new THREE.Vector3(0, 0, 1); // looking down -Z @@ -224,7 +233,7 @@ async function loadMountain( } async function setupRange() { - setupWorld(); + await setupWorld(); const player = gameContext.setupData?.players?.[0]; if (!player) throw new Error('No player found in setup data'); if (!player.clubs?.length) throw new Error('No clubs found for player'); @@ -239,7 +248,7 @@ async function setupRange() { gameContext.scene = new THREE.Scene(); gameContext.scene.background = skyColor; - gameContext.lightGroup = new CourseLight(sunColor); + gameContext.lightGroup = new CourseLight({ color: sunColor }); gameContext.scene.add(gameContext.lightGroup); gameContext.fog = new THREE.Fog(fogColor, 200, 1000); @@ -252,6 +261,8 @@ async function setupRange() { gameContext.camera = new ShotPerspectiveCamera({ scene: gameContext.ground, far: 900, + fov: 30, + cameraOffsetYZ: [3, 12], cameraOffsetX: gameContext.setupData?.cameraOffset ? -(gameContext.setupData.cameraOffset / 100) : 0 }); @@ -261,6 +272,7 @@ async function setupRange() { await gameContext.visualAimPoint.load(); gameContext.scene.add(gameContext.visualAimPoint.object); + gameContext.controls = new CourseKeyboardControls({ testShots: true }); gameContext.controls.on('aim', aimKeys => { if (gameContext.camera) gameContext.camera.aimKeys = aimKeys; @@ -269,7 +281,7 @@ async function setupRange() { gameContext.controls.on('testShot', shot => launchShot(shot)); // start hidden (press S to toggle) - gameContext.stats = new UIStats('#render-stats', { hidden: true }); + gameContext.stats = new UIStats('#render-stats', { hidden: false }); // Sky/Clouds gameContext.clouds = new VolumetricClouds(gameContext.camera, { @@ -284,10 +296,13 @@ async function setupRange() { }); gameContext.scene.add(gameContext.clouds.object); + if (!app.world) throw new Error('Missing physics world. Did you call app.initialize() first?'); + if (!gameContext.setupData) throw new Error('Missing setupData'); gameContext.golfBall = new GolfBall(gameContext.scene, app.world, app.rapier, { setupData: gameContext.setupData, - clearTrail: 'start' + clearTrail: 'start', + groundMeshes: [gameContext.ground] }); gameContext.golfBall.on('shotEnded', onShotEnded); @@ -304,6 +319,10 @@ async function setupRange() { }); gameContext.playerMenu.on('selectClub', club => clubChange(club)); + + gameContext.renderer.generateEnvironment(gameContext.scene, gameContext.clouds.object); + gameContext.renderer.setupPostProcessing(gameContext.scene, gameContext.camera); + setupNextShot(); // if (gameContext.setupData?.players.length) { @@ -413,7 +432,7 @@ function animate(animDelta: number) { gameContext.golfBall.update(delta); } - gameContext.renderer?.clear(); + // gameContext.renderer?.clear(); if (gameContext.controls) gameContext.controls.update(delta); diff --git a/package-lock.json b/package-lock.json index e18b7be..7e263c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,21 @@ { "name": "@opengolfsim/fuse", - "version": "1.0.1", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opengolfsim/fuse", - "version": "1.0.1", + "version": "1.0.4", "license": "ISC", "dependencies": { "@dimforge/rapier3d-compat": "^0.19.3", "@floating-ui/dom": "^1.7.6", "eventemitter3": "^5.0.4", + "makio-meshline": "^1.4.0", "meshline": "^3.3.1", - "sortablejs": "^1.15.7" + "sortablejs": "^1.15.7", + "three-mesh-bvh": "^0.9.10" }, "devDependencies": { "@types/sortablejs": "^1.15.9", @@ -22,7 +24,6 @@ "concurrently": "^9.2.1", "express": "^5.2.1", "serve": "^14.2.6", - "three": "^0.184.0", "typescript": "^6.0.3", "vite": "^6.4.2", "vite-plugin-dts": "^4.5.4" @@ -3104,6 +3105,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/makio-meshline": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/makio-meshline/-/makio-meshline-1.4.0.tgz", + "integrity": "sha512-eLoRjiENx11nXOqftHx9mvWlXCpP9OzD/5QhvrRMTtoqFOsrbK4LJp3Q6pqtKsK3cN6SgcLrnH9BwRq+V/OI1Q==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.180.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4263,7 +4273,17 @@ "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", + "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } }, "node_modules/tinyglobby": { "version": "0.2.16", diff --git a/package.json b/package.json index e4a384c..6e6af65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengolfsim/fuse", - "version": "1.0.1", + "version": "1.0.9", "description": "", "type": "module", "main": "dist/module/fuse.js", @@ -20,8 +20,10 @@ "@dimforge/rapier3d-compat": "^0.19.3", "@floating-ui/dom": "^1.7.6", "eventemitter3": "^5.0.4", + "makio-meshline": "^1.4.0", "meshline": "^3.3.1", - "sortablejs": "^1.15.7" + "sortablejs": "^1.15.7", + "three-mesh-bvh": "^0.9.10" }, "peerDependencies": { "three": "^0.184.0" @@ -33,7 +35,6 @@ "concurrently": "^9.2.1", "express": "^5.2.1", "serve": "^14.2.6", - "three": "^0.184.0", "typescript": "^6.0.3", "vite": "^6.4.2", "vite-plugin-dts": "^4.5.4" diff --git a/public/games.json b/public/games.json index 8f8fc0c..f93779e 100644 --- a/public/games.json +++ b/public/games.json @@ -15,7 +15,7 @@ "url": "courses/index.html", "gameMode": 2, "engine": 2, - "courseUrl": "https://coursedata.opengolfsim.com/webgl/courses/mountain-vista/v2/course-2026-06-24-001.glb", + "courseUrl": "https://coursedata.opengolfsim.com/webgl/courses/mountain-vista/v3/mtn-vista-v3.glb", "posterUrl": "https://coursedata.opengolfsim.com/webgl/courses/mountain-vista/v1/mountain-vista-poster.jpg", "slug": "fuse_mtn_vista" }, diff --git a/src/app.ts b/src/app.ts index b982a1a..94ea939 100644 --- a/src/app.ts +++ b/src/app.ts @@ -123,6 +123,10 @@ export class AppBridge extends EventEmitter { } this.once('ready', callback); } + + log(message: any) { + this.sendMessage({ type: 'log', message }); + } setReady() { console.log('[runtime] Rapier initialized'); diff --git a/src/camera.ts b/src/camera.ts index 33bf79a..5bfa2a9 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -25,10 +25,12 @@ export class ShotPerspectiveCamera extends THREE.PerspectiveCamera { currentLookAt: THREE.Vector3; desiredCamPos: THREE.Vector3; desiredLookAt: THREE.Vector3; + originalFov: number; cameraOffsetX: number; cameraOffsetYZ: [number, number]; cameraTrackingOffsetYZ: [number, number]; isTracking: boolean; + // isAiming: boolean; aimVelocity: { lateral: number, longitudinal: number }; aimSpeed: number; aimKeys: AimKeys; @@ -49,15 +51,16 @@ export class ShotPerspectiveCamera extends THREE.PerspectiveCamera { ) { const aspect = (window.innerWidth / window.innerHeight); const fov = options.fov ?? 20; - const near = options.near ?? 0.75; + const near = options.near ?? 3; const far = options.far ?? 500; super(fov, aspect, near, far); + this.originalFov = fov; this.scene = options.scene; this.canvas = options.canvas; // defaults this.cameraOffsetX = options.cameraOffsetX ?? 0; - this.cameraOffsetYZ = options.cameraOffsetYZ ?? [1.5, 10]; + this.cameraOffsetYZ = options.cameraOffsetYZ ?? [2.5, 15]; this.cameraTrackingOffsetYZ = options.cameraTrackingOffsetYZ ?? [4.5, 15]; this.#activeFrustumOffset = this.cameraOffsetX; @@ -139,13 +142,92 @@ export class ShotPerspectiveCamera extends THREE.PerspectiveCamera { back.y = 0; back.normalize(); + const minDistance = 5; + const maxDistance = 60; + const minFov = this.originalFov - 5; + const maxFov = this.originalFov; + + const dist = startPoint.distanceTo(aimPoint); + const clampedDistance = Math.max(minDistance, Math.min(dist, maxDistance)); + const t = (clampedDistance - minDistance) / (maxDistance - minDistance); + const targetFov = THREE.MathUtils.lerp(minFov, maxFov, t); + + this.fov = targetFov; + this.updateProjectionMatrix(); + this.projectionMatrix.elements[8] = this.#activeFrustumOffset; + + // --- Binary search for zOffset that places ball at bottom of screen --- + const h = this.cameraOffsetYZ[0]; + // NDC y: -1 = bottom pixel, +1 = top pixel + // -0.85 = ball sits ~7.5% up from bottom edge + const targetNdcY = -0.85; + + let lo = 3, hi = 40; + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + this.position.copy(startPoint).addScaledVector(back, mid); + this.position.y += h; + this.lookAt(aimPoint); + this.updateMatrixWorld(true); + + const ndc = startPoint.clone().project(this); + + if (ndc.y > targetNdcY) { + hi = mid; // ball too high → bring camera closer + } else { + lo = mid; // ball too low → push camera back + } + + } + + const zOffset = (lo + hi) / 2; + this.staticCamPos.copy(startPoint).addScaledVector(back, zOffset); + this.staticCamPos.y += h; + this.staticLookAt.copy(aimPoint); + + this.shotDirection.subVectors(aimPoint, startPoint); + this.shotDirection.y = 0; + this.shotDirection.normalize(); + } + setPositionsOLD(startPoint: THREE.Vector3, aimPoint: THREE.Vector3) { + const back = this.#tmpBack.subVectors(startPoint, aimPoint).normalize(); + back.y = 0; + back.normalize(); + + + const minDistance = 5; // Closest distance + const maxDistance = 60; // Furthest distance + const minFov = this.originalFov - 3; // Narrow FOV for close-up + const maxFov = this.originalFov + 6; // Wide FOV for zoomed-out view + + const minZ = this.cameraOffsetYZ[1] + 5; + const maxZ = this.cameraOffsetYZ[1] - 4; + + const lerpFactor = 0.05;// Smoothness (0.0 to 1.0) + + const dist = startPoint.distanceTo(aimPoint); + + const clampedDistance = Math.max(minDistance, Math.min(dist, maxDistance)); + + const t = (clampedDistance - minDistance) / (maxDistance - minDistance); + + const targetFov = THREE.MathUtils.lerp(minFov, maxFov, t); + const targetPos = THREE.MathUtils.lerp(minZ, maxZ, t); + console.log(`DYNAMIC FOV, DISTANCE: t:${t} fov:${targetFov}, posZ:${targetPos}`); + // z = behind ball, y = height above ball - this.staticCamPos.copy(startPoint) - .addScaledVector(back, this.cameraOffsetYZ[1]); + this.staticCamPos.copy(startPoint).addScaledVector(back, targetPos); this.staticCamPos.y += this.cameraOffsetYZ[0]; this.staticLookAt.copy(aimPoint); + this.fov = targetFov; + this.updateProjectionMatrix(); + + // const newFOV = THREE.MathUtils.lerp(0, 100, dist);; + // console.log('newFOV', newFOV); + // this.fov = this.originalFov + THREE.MathUtils.lerp(0, 100, dist); + this.shotDirection.subVectors(aimPoint, startPoint); this.shotDirection.y = 0; this.shotDirection.normalize(); @@ -165,7 +247,9 @@ export class ShotPerspectiveCamera extends THREE.PerspectiveCamera { if (Math.abs(this.aimVelocity.lateral) < 0.001) this.aimVelocity.lateral = 0; if (Math.abs(this.aimVelocity.longitudinal) < 0.001) this.aimVelocity.longitudinal = 0; - if (this.aimVelocity.lateral === 0 && this.aimVelocity.longitudinal === 0) return + if (this.aimVelocity.lateral === 0 && this.aimVelocity.longitudinal === 0) { + return + } const dist = startPoint.distanceTo(aimPoint); const angleStep = this.aimSpeed * dt * (Math.PI / 180); // degrees per second @@ -225,8 +309,10 @@ export class ShotPerspectiveCamera extends THREE.PerspectiveCamera { this.currentLookAt.copy(this.staticLookAt); this.lookAt(this.currentLookAt); this.applyFrustumOffset(dt, this.cameraOffsetX, false); - if (aimChanged) return true; + + // this.isAiming = aimChanged; + if (aimChanged) return true; return false; } } \ No newline at end of file diff --git a/src/courses/game.ts b/src/courses/game.ts index 801d0d1..c747e92 100644 --- a/src/courses/game.ts +++ b/src/courses/game.ts @@ -282,12 +282,16 @@ export class CourseGame extends EventEmitter { this.activePlayer.currentClub = club; } + getActiveHoleNumber() { + return parseInt(this.activeHole.number) || 0; + } + update(dt: number) { - const hole = this.course.holes.get(parseInt(this.activeHole.number)); - if (hole?.green?.target) { - hole.green.target.update(this.golfBall, dt); - hole.green.flag.update(dt); - } + // const hole = this.course.holes.get(parseInt(this.activeHole.number)); + // if (hole?.green?.target) { + // hole.green.target.update(this.golfBall, dt); + // hole.green.flag.update(dt); + // } } } diff --git a/src/courses/loader.ts b/src/courses/loader.ts index a7e9147..87419f0 100644 --- a/src/courses/loader.ts +++ b/src/courses/loader.ts @@ -4,17 +4,17 @@ import { type World } from '@dimforge/rapier3d-compat'; import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'; import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js'; import EventEmitter from 'eventemitter3'; +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'; import { getAverageTextureColor, getTextureImageData } from '@/utils/image'; import { TreeGroup, TreePlanter } from '@/trees'; import { TargetShaderMaterial } from '@/shaders/target'; -import { SandShaderMaterial } from '@/shaders/sand'; +import { SandMaterial } from '@/shaders/sand'; import { GrassAssets, GrassShader } from '@/shaders/grass'; -import { WaterSurface } from '@/shaders/water'; import { FlatGrassShaderMaterial } from '@/shaders/grassFlat'; import { FlagStick } from '@/objects/flagStick'; import { type ShotPerspectiveCamera } from '@/camera'; -import { GroundPhysics } from '@/physics/groundPhysics'; +// import { GroundPhysics } from '@/physics/groundPhysics'; import { CourseSurfaceProperties, CourseSurfaces, isCourseSurfaceType } from '@/courses/surfaces'; import perlinNoise from '@/images/perlinnoise.webp?url'; import { isMeshObject } from '@/utils/mesh'; @@ -23,9 +23,14 @@ import golfCupModel from '@/models/golfCup.glb?url'; import { QualityMode } from '@/utils/quality'; import { DefaultGimmeDistances } from '@/utils/data'; import { Hole } from './types'; -import { RiverSurface } from '@/shaders'; +import { LakeSurface, RiverSurface } from '@/shaders'; import { FuseRenderer } from '@/renderer'; +import { type GolfBall } from '@/objects/golfBall'; +import { PuttingGridMaterial } from '@/shaders/putting'; +THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree; +THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree; +THREE.Mesh.prototype.raycast = acceleratedRaycast; export interface SceneSettings { @@ -87,7 +92,7 @@ export class MeshLoader extends EventEmitter { interface LoadedCourseSurface extends CourseSurfaceProperties { mesh: THREE.Mesh, - ground: GroundPhysics, + // ground: GroundPhysics, } type CourseLoaderOptions = { @@ -100,7 +105,8 @@ type CourseLoaderOptions = { type CourseGreen = { flag: FlagStick; - target: TargetShaderMaterial; + target?: TargetShaderMaterial; + grid?: PuttingGridMaterial; object: THREE.Object3D; } @@ -136,6 +142,7 @@ export class CourseLoader extends EventEmitter { #origin: THREE.Vector3; #direction: THREE.Vector3; #accumulator = 10; + #blendMaps: Map; constructor( world: World, @@ -158,6 +165,7 @@ export class CourseLoader extends EventEmitter { this.surfaces = new Map(); this.grasses = new Map(); this.greenGrids = new Map(); + this.#blendMaps = new Map(); this.#raycaster = new THREE.Raycaster(); this.#origin = new THREE.Vector3(); @@ -190,9 +198,13 @@ export class CourseLoader extends EventEmitter { throw new Error('Unable to load grass assets'); } + await this._parseTextures(); + + this._setupCourseSurfaces(); this._parseCourseHoles(); - this._addCourseColliders(); this._addWater(); + + await this._addTrees(); await this._parseMap(); @@ -200,41 +212,98 @@ export class CourseLoader extends EventEmitter { return this.scene; } - update(dt: number, camera: ShotPerspectiveCamera, isShotActive: boolean = false) { + update(dt: number, camera: ShotPerspectiveCamera, golfBall: GolfBall, activeHole = 1) { + // update water and other animations that happen each frame this.waterSurfaces.forEach(water => water.update(dt)); + const hole = this.holes.get(activeHole); // planting / LOD logic only needs to happen every few frames if (this.#accumulator >= 4) { - this.greenGrids.forEach(grid => grid.update(camera)); + // this.greenGrids.forEach(grid => grid.update(camera)); this.grasses.forEach(grass => grass.update(dt, camera)); - this.planter?.update(camera, isShotActive); + this.planter?.update(camera, golfBall.isShotActive); this.#accumulator = 0; } this.#accumulator++; + + if (hole?.green?.target) { + hole.green.target.update(golfBall, dt); + } + if (hole?.green?.grid) { + hole.green.grid.update(dt, camera); + } + if (hole?.green?.flag) { + hole.green.flag.update(dt); + } } - _addCourseColliders() { - if (!this.scene) { - console.warn('No scene defined!'); - return; + async _parseTextures() { + if (!this.scene) throw new Error('No scene defined!'); + if (!this.gltf) throw new Error('Course file not loaded'); + const parser = this.gltf.parser; + + // parse blend maps + const blendMapRecords = (parser.json?.images || []).filter( + (img: any) => img.extras?.type === 'blend_map' + ) as BlendMapImage[]; + + for (const blendMapRecord of blendMapRecords) { + // const blendMapRecord = blendMapRecords.find(image => image.extras?.id === child.userData.id); + if (blendMapRecord?.extras?.id) { + const buffer = await parser.getDependency('bufferView', blendMapRecord.bufferView); + const blendMapImageData = await getTextureImageData(buffer); + const blendMap = { + data: blendMapImageData.data, + // width: blendMapRecord.extras.width ?? 0, + // height: blendMapRecord.extras.height ?? 0, + width: blendMapImageData.width, + height: blendMapImageData.height, + bounds: blendMapRecord.extras.bounds ?? { w: 0, h: 0, x: 0, y: 0 }, + }; + console.log(`found blend map for ${blendMapRecord.extras.id}`, blendMap); + this.#blendMaps.set(blendMapRecord.extras.id, blendMap); + } } + + + } + + _setupCourseSurfaces() { + if (!this.scene) throw new Error('No scene defined!'); + if (!this.gltf) throw new Error('Course file not loaded'); + const parser = this.gltf.parser; + this.scene.updateMatrixWorld(true); // critical — bakes the position.set applied when loaded this.surfaces.clear(); this.grasses.clear(); this.greenGrids.clear(); + // Pre-pass: collect all surface meshes for neighbor lookup + const allSurfaceMeshes: THREE.Mesh[] = []; + this.scene.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.isMesh) return; + if (this._detectSurface(child)) { + child.geometry.computeBoundsTree(); + allSurfaceMeshes.push(child); + } + }); this.scene.traverse((child) => { if (!this.scene) { return; } if (!(child instanceof THREE.Mesh)) { return; } if (!child.isMesh || !child.geometry?.attributes.position) return; child.receiveShadow = true; - + // Disable vertex color rendering on all meshes — // we only use them as data, not visual color - if (child.material) { + if (child.material instanceof THREE.MeshStandardMaterial) { + // child.material + // grassTexture.anisotropy = this.#renderer.getMaxAnisotropy() || 1; + if (this.qualityLevel >= QualityMode.Medium) { + if (child.material.map) child.material.map.anisotropy = this.#renderer.getMaxAnisotropy() || 1; + } child.material.vertexColors = false; child.material.needsUpdate = true; } @@ -243,36 +312,41 @@ export class CourseLoader extends EventEmitter { if (detected?.surfaceType && detected?.surfaceSettings) { const { surfaceType, surfaceSettings } = detected; const surfaceOptions = { type: surfaceType, ...surfaceSettings }; - // console.log('set', surfaceOptions); - const ground = new GroundPhysics(child, this.world, this.rapier, surfaceOptions); - // console.log(child.name, surfaceType); - // this.surfaceByCollider.set(ground.collider.handle, { type: surfaceType, ...surfaceSettings, mesh: child }); - if (surfaceType === 'sand') { - child.material = new SandShaderMaterial(child.material); - - } else if (['fringe', 'fairway', 'first_cut'].includes(surfaceType)) { - child.material = new FlatGrassShaderMaterial(child.material, { - blendNoiseScale: 0.1, - }); + + const blendMap = this.#blendMaps.get(child.userData.id); + if (blendMap) { + + const neighborMesh = this.findNeighborMesh(child, allSurfaceMeshes); + if (neighborMesh && this.grassAssets) { + const sand = new SandMaterial( + child, + this.grassAssets.noiseTexture, + blendMap, + neighborMesh, + child.userData.blendSettings || {}, + ); + console.log('blendSand', sand); + } } else if (this.qualityLevel > QualityMode.Low && surfaceType === 'rough') { const grassOptions = { - density: 18, + density: 11, renderDistance: 25, cellSize: 5, lean: 0.01, - heightVariation: 0.05, + heightVariation: 0.5, maxNewCellsPerFrame: 10, - scaleXZ: 0.8, - scaleY: 0.75, + scaleXZ: 0.6, + scaleY: 0.65, layer: 2, - baseColor: new THREE.Color('#3a4a13'), + baseColor: new THREE.Color('#415722'), tipColor1: new THREE.Color('#5c7c2e'), tipColor2: new THREE.Color('#ffffff'), }; if (this.qualityLevel > QualityMode.Medium) { grassOptions.renderDistance = 50; + grassOptions.density = 15; } const grass = new GrassShader(child, this.grassAssets!, grassOptions); @@ -282,30 +356,58 @@ export class CourseLoader extends EventEmitter { } else if (this.qualityLevel !== QualityMode.Low && ['deep_rough', 'base'].includes(surfaceType)) { const grass = new GrassShader(child, this.grassAssets!, { - density: 10, + density: 8, renderDistance: 60, cellSize: 10, lean: 0.03, layer: 2, heightVariation: 0.1, maxNewCellsPerFrame: 10, - scaleXZ: 1.2, - scaleY: 1.25, - baseColor: '#354310', // match your terrain's green - tipColor1: '#486124', - tipColor2: '#59792d', + scaleXZ: 0.8, + scaleY: 0.6, + // baseColor: '#394e12', // match your terrain's green + // tipColor1: '#4d6b21', + // tipColor2: '#59792d', }); this.scene.add(grass.mesh); this.grasses.set(child.uuid, grass); } console.log('add surface', child.name); - this.surfaces.set(child.uuid, { ...surfaceOptions, mesh: child, ground }); + this.surfaces.set(child.uuid, { ...surfaceOptions, mesh: child }); } }); - this.world.step(); + // this.world.step(); + for (const surface of this.surfaces.values()) { + surface.mesh.geometry.computeBoundsTree(); + } + } + findNeighborMesh(sandMesh: THREE.Mesh, allSurfaceMeshes: THREE.Mesh[]) { + // Get the bounding box center, offset slightly outward + const bbox = new THREE.Box3().setFromObject(sandMesh); + const center = bbox.getCenter(new THREE.Vector3()); + const size = bbox.getSize(new THREE.Vector3()); + + // Test point just outside the bounding box edge + const testPoint = new THREE.Vector3( + center.x + size.x * 0.5 + 0.5, + center.y + 50, + center.z + ); + + const raycaster = new THREE.Raycaster(testPoint, new THREE.Vector3(0, -1, 0)); + const hits = raycaster.intersectObjects(allSurfaceMeshes, false); + + // First hit that isn't the sand mesh itself + for (const hit of hits) { + if (hit.object !== sandMesh && hit.object instanceof THREE.Mesh) { + return hit.object; + } + } + return null; + } getGroundY(x: number, z: number, startY = 1000, maxDistance = 2000) { this.#origin.set(x, startY, z); this.#raycaster.set(this.#origin, this.#direction); @@ -357,7 +459,8 @@ export class CourseLoader extends EventEmitter { worldSize: this.courseSize, qualityLevel: this.qualityLevel, world: this.world, - rapier: this.rapier + rapier: this.rapier, + groundMeshes: this.getGroundMeshes(), }); const treeConfigs: Record = {}; @@ -446,22 +549,22 @@ export class CourseLoader extends EventEmitter { } else if (child.userData?.surface === 'plane_lake') { offsetY = 0; - surface = new WaterSurface(child, { - speed: 0.25, - textureScale: 4, - water: { - alpha: 0.8, - waterColor: new THREE.Color('#0b4753') - }, - }); + surface = new LakeSurface(child); + // surface = new LakeSurface(child, { + // speed: 0.25, + // textureScale: 4, + // water: { + // alpha: 0.8, + // waterColor: new THREE.Color('#0b4753') + // }, + // }); } if (surface) { // Copy the original mesh's world transform onto the water - child.updateWorldMatrix(true, false); - surface.water.applyMatrix4(child.matrixWorld); - surface.water.position.y += offsetY; - + // child.updateWorldMatrix(true, false); + // surface.water.applyMatrix4(child.matrixWorld); + // surface.water.position.y += offsetY; this.waterSurfaces.set(child.uuid, surface); this.scene?.add(surface.water); this.scene?.remove(child); @@ -537,10 +640,16 @@ export class CourseLoader extends EventEmitter { _setupGreen(hit: THREE.Intersection, position: THREE.Vector3, holeNumber: string) { if (!this.scene) throw new Error('Scene missing'); - const flag = new FlagStick(position, holeNumber, this.golfCup); - const target = new TargetShaderMaterial(hit.object, position, { gimmeDistances: this.setupData?.gimmeDistances || DefaultGimmeDistances }); + const worldNormal = hit.face + ? hit.face.normal.clone().transformDirection(hit.object.matrixWorld) + : undefined; + const flag = new FlagStick(position, holeNumber, this.golfCup, worldNormal); this.scene.add(flag.object); - return { object: hit.object, flag, target }; + let target; + // const target = new TargetShaderMaterial(hit.object, position, { gimmeDistances: this.setupData?.gimmeDistances || DefaultGimmeDistances }); + const grid = new PuttingGridMaterial(hit.object, { holeWorldPos: position }); + + return { object: hit.object, flag, target, grid }; } } \ No newline at end of file diff --git a/src/courses/surfaces.ts b/src/courses/surfaces.ts index a16492d..5924f24 100644 --- a/src/courses/surfaces.ts +++ b/src/courses/surfaces.ts @@ -38,8 +38,9 @@ export const CourseSurfaces: Record [CourseSurfaceType.Green]: { hasCollider: true, friction: 0.4, - restitution: 0.45, - rollResistance: 0.075, + restitution: 0.40, + rollResistance: 0.09, + // rollResistance: 0.075, // rollResistanceSpeedThreshold: 0.0001, stopSpeed: 0.12, stopAngular: 4.8, @@ -59,8 +60,8 @@ export const CourseSurfaces: Record [CourseSurfaceType.FirstCut]: { hasCollider: true, friction: 0.4, - restitution: 0.2, - rollResistance: 0.14 + restitution: 0.38, + rollResistance: 0.20 }, [CourseSurfaceType.Tee]: { hasCollider: true, @@ -70,21 +71,24 @@ export const CourseSurfaces: Record }, [CourseSurfaceType.Rough]: { hasCollider: true, - friction: 0.5, + friction: 0.2, restitution: 0.3, - rollResistance: 0.40 + rollResistance: 0.15, + stopSpeed: 0.20, }, [CourseSurfaceType.Base]: { hasCollider: true, friction: 0.8, - restitution: 0.15, - rollResistance: 0.20 + restitution: 0.2, + rollResistance: 0.30, + stopSpeed: 0.30, }, [CourseSurfaceType.Sand]: { hasCollider: true, - friction: 1.5, + friction: 1.0, restitution: 0.02, - rollResistance: 0.60 + rollResistance: 0.20, + stopSpeed: 0.20, }, [CourseSurfaceType.Water]: { hasCollider: true, diff --git a/src/globals/globals.d.ts b/src/globals/globals.d.ts index cb35e93..4954c25 100644 --- a/src/globals/globals.d.ts +++ b/src/globals/globals.d.ts @@ -119,6 +119,23 @@ interface FlowMapImage extends GLTFImage { } } +interface BlendMapImage extends GLTFImage { + extras?: { + type: 'blend_map', + id?: string, + width?: number, + height?: number, + bounds?: { w: number, h: number, x: number, y: number}, + } +} + +type BlendMapData = { + data: ImageDataArray, + width: number, + height: number, + bounds: { w: number, h: number, x: number, y: number }, +}; + interface TreeImage extends GLTFImage { extras?: { type?: 'tree_mask' | 'tree_billboard', diff --git a/src/lights.ts b/src/lights.ts index 6a70208..950b418 100644 --- a/src/lights.ts +++ b/src/lights.ts @@ -1,21 +1,34 @@ import * as THREE from 'three'; +import { QualityMode } from '@/utils/quality'; +type CourseLightOptions = { + color?: THREE.ColorRepresentation | undefined, + qualityLevel?: QualityMode +} export class CourseLight extends THREE.Group { ambient: THREE.AmbientLight; overhead: THREE.DirectionalLight; - constructor(color: THREE.ColorRepresentation | undefined = 0xffffff) { + constructor(options: CourseLightOptions = {}) { super(); + const color = options.color ?? new THREE.Color('#ffffee'); // Bright warm ambient - this.ambient = new THREE.AmbientLight(color, 0.5); + this.ambient = new THREE.AmbientLight(color, 0.9); this.add(this.ambient); // Main overhead light for shadows - this.overhead = new THREE.DirectionalLight(color, 1.4); + this.overhead = new THREE.DirectionalLight(color, 1.1); this.overhead.position.set(600, 300, 600); this.overhead.castShadow = true; - this.overhead.shadow.mapSize.width = 2048; // Higher = crisper shadows - this.overhead.shadow.mapSize.height = 2048; + + let shadowMapSize = 256; + if (options.qualityLevel === QualityMode.Medium) { + shadowMapSize = 2048; + } else if (options.qualityLevel === QualityMode.High) { + shadowMapSize = 4096; + } + this.overhead.shadow.mapSize.width = shadowMapSize; // Higher = crisper shadows + this.overhead.shadow.mapSize.height = shadowMapSize; this.overhead.shadow.camera.near = 1; // Adjust these to match the size of your scene this.overhead.shadow.camera.far = 700; diff --git a/src/objects/ballTrail.ts b/src/objects/ballTrail.ts index 1cb8054..4e462a0 100644 --- a/src/objects/ballTrail.ts +++ b/src/objects/ballTrail.ts @@ -1,5 +1,11 @@ -import * as THREE from 'three'; -import { MeshLineGeometry, MeshLineMaterial, raycast } from 'meshline' +import * as THREE from 'three/webgpu'; +import { MeshLine, MeshLineGeometry } from 'makio-meshline'; +import { + Fn, float, smoothstep, + uniform as tslUniform, + cameraPosition, positionWorld, distance, +} from 'three/tsl'; +import type { UniformNode } from 'three/webgpu'; const MAX_POINTS = 4000; @@ -31,32 +37,6 @@ function resampleByArcLength(points: THREE.Vector3[], spacing: number) { return out; } -function createTrailAlphaMap() { - const canvas = document.createElement('canvas'); - canvas.width = 256; - canvas.height = 1; - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Unable to get 2d canvas context'); - } - const gradient = ctx.createLinearGradient(0, 0, 256, 0); - // Fade at the OLD end of the trail (U=0). Flip stops if you want - // the fade at the ball end instead. - gradient.addColorStop(0.00, 'rgba(255, 255, 255, 0)'); - gradient.addColorStop(0.05, 'rgba(255, 255, 255, 1)'); - gradient.addColorStop(1.00, 'rgba(255, 255, 255, 1)'); - - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, 256, 1); - - const tex = new THREE.CanvasTexture(canvas); - tex.minFilter = THREE.LinearFilter; - tex.magFilter = THREE.LinearFilter; - tex.generateMipmaps = false; - tex.needsUpdate = true; - return tex; -} - type BallTrailOptions = { maxPoints?: number; lineWidth?: number; @@ -74,116 +54,101 @@ export class BallTrail { lineWidth: number; fadeLength: number; resampleSpacing: number; - color: THREE.Color | number; - uCamFadeNear: { value: number }; - uCamFadeFar: { value: number }; - material: MeshLineMaterial; + color: THREE.Color; points: THREE.Vector3[]; frameNum: number; - trail: THREE.Mesh | null; - geom: MeshLineGeometry | null; - #alphaCanvas: HTMLCanvasElement; - #alphaCtx: CanvasRenderingContext2D | null; - #alphaTex: THREE.CanvasTexture; + line: MeshLine; + fadeFracUniform: UniformNode<"float", number>; + camFadeNear: UniformNode<"float", number>; + camFadeFar: UniformNode<"float", number>; + activeRatioUniform: UniformNode<"float", number>; + + renderOrder = 1; + #positions: Float32Array; + #activePoints = 0; + #needsFullFill = true; + #built = false; constructor(scene: THREE.Scene, golfBall: THREE.Object3D, options: BallTrailOptions = {}) { this.scene = scene; this.golfBall = golfBall; this.maxPoints = options.maxPoints ?? MAX_POINTS; - this.lineWidth = options.lineWidth ?? 0.1; - this.color = options.color ?? new THREE.Color('#fc4723'); - this.fadeLength = options.fadeLength ?? 2.0; // world units + this.lineWidth = options.lineWidth ?? 0.03; + this.fadeLength = options.fadeLength ?? 2.0; this.resampleSpacing = options.resampleSpacing ?? 0.15; - // Camera-distance fade controls (in world units) - this.uCamFadeNear = { value: options.cameraFadeNear ?? 6 }; // fully transparent here - this.uCamFadeFar = { value: options.cameraFadeFar ?? 12 }; // fully opaque past here + this.color = options.color instanceof THREE.Color + ? options.color + : new THREE.Color(options.color ?? '#fc4723'); this.points = []; this.frameNum = 0; - this.trail = null; - this.geom = null; - // Reusable alpha-map canvas; we redraw the gradient each frame - this.#alphaCanvas = document.createElement('canvas'); - this.#alphaCanvas.width = 256; - this.#alphaCanvas.height = 1; - this.#alphaCtx = this.#alphaCanvas.getContext('2d'); + // Uniforms for dynamic fade control + this.fadeFracUniform = tslUniform(0.01); + this.camFadeNear = tslUniform(options.cameraFadeNear ?? 6); + this.camFadeFar = tslUniform(options.cameraFadeFar ?? 12); + this.activeRatioUniform = tslUniform(1.0); + + // TSL hook: fade opacity at both ends of the trail. + // Receives (alpha, progress, side) where progress is 0→1 along the line. + // Returns modified alpha with smoothstep fade at both ends. + // @ts-expect-error - makio-meshline Fn hook typing + const trailOpacityFn = Fn(([alpha, progress, side]) => { + // const fadeIn = smoothstep(float(0), this.fadeFracUniform, progress); + // const fadeOut = smoothstep(float(0), this.fadeFracUniform, float(1).sub(progress)); + // Remap progress from [0, activeRatio] to [0, 1] + const remapped = progress.div(this.activeRatioUniform).clamp(0, 1); + const fadeIn = smoothstep(float(0), this.fadeFracUniform, remapped); + const fadeOut = smoothstep(float(0), this.fadeFracUniform, float(1).sub(remapped)); + return alpha.mul(fadeIn).mul(fadeOut); + }); - this.#alphaTex = new THREE.CanvasTexture(this.#alphaCanvas); - this.#alphaTex.minFilter = THREE.LinearFilter; - this.#alphaTex.magFilter = THREE.LinearFilter; - this.#alphaTex.generateMipmaps = false; + // TSL hook: fade based on camera distance. + // Receives (alpha, uv, progress, side). Fully transparent when close + // to the camera (< cameraFadeNear), fully opaque past cameraFadeFar. + // @ts-expect-error - makio-meshline Fn hook typing + const trailAlphaFn = Fn(([alpha, uv, progress, side]) => { + const dist = distance(positionWorld, cameraPosition); + const camFade = smoothstep(this.camFadeNear, this.camFadeFar, dist); + return alpha.mul(camFade); + }); + + // Pre-allocate fixed-size positions buffer + this.#positions = new Float32Array(this.maxPoints * 3); - this.material = new MeshLineMaterial({ + // Create the line with dummy points — updated in _rebuild + this.line = new MeshLine({ + // lines: new Float32Array([0, 0, 0, 0.001, 0, 0]), + lines: this.#positions, color: this.color, lineWidth: this.lineWidth, - resolution: new THREE.Vector2(window.innerWidth, window.innerHeight), - sizeAttenuation: 1, - useAlphaMap: 1, - alphaMap: this.#alphaTex, + sizeAttenuation: true, + transparent: true, + opacity: 1.0, + opacityFn: trailOpacityFn, + fragmentAlphaFn: trailAlphaFn, }); - this.material.transparent = true; - this.material.depthWrite = true; - this.material.depthTest = true; - this.material.uniforms.uCamFadeNear = this.uCamFadeNear; - this.material.uniforms.uCamFadeFar = this.uCamFadeFar; - - // Vertex shader: forward world position to fragment shader - this.material.vertexShader = this.material.vertexShader - .replace( - 'void main()', - 'varying vec3 vWorldPos;\nvoid main()' - ) - .replace( - 'void main() {', - 'void main() {\n vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;' - ); - - // Fragment shader: fade alpha based on world-space distance to camera - this.material.fragmentShader = this.material.fragmentShader - .replace( - 'void main()', - `uniform float uCamFadeNear; - uniform float uCamFadeFar; - varying vec3 vWorldPos; - void main()` - ) - .replace( - 'gl_FragColor = diffuseColor;', - `diffuseColor.a *= smoothstep(uCamFadeNear, uCamFadeFar, distance(vWorldPos, cameraPosition)); - gl_FragColor = diffuseColor;` - ); - - this.material.needsUpdate = true; + // Build immediately to pre-allocate GPU buffers and compile shaders + this.line.lines(this.#positions).build(); + this.#built = true; + this.line.layers.set(2); + this.line.frustumCulled = false; + this.line.visible = false; + this.line.renderOrder = this.renderOrder; + // @ts-expect-error makio-meshline raycast signature doesn't match Object3D + scene.add(this.line); } - _updateAlphaMap(totalLength: number) { - const ctx = this.#alphaCtx; - if (!ctx) { - throw new Error('Invalid canvas context!'); - } - // Fraction of U that should be the fade region at each end - const fade = Math.min(0.49, this.fadeLength / Math.max(totalLength, 0.0001)); - ctx.clearRect(0, 0, 256, 1); - const g = ctx.createLinearGradient(0, 0, 256, 0); - g.addColorStop(0.0, 'rgba(255,255,255,0)'); - g.addColorStop(fade, 'rgba(255,255,255,1)'); - g.addColorStop(1.0 - fade, 'rgba(255,255,255,1)'); - g.addColorStop(1.0, 'rgba(255,255,255,0)'); - ctx.fillStyle = g; - ctx.fillRect(0, 0, 256, 1); - this.#alphaTex.needsUpdate = true; - } clear() { this.points = []; - this._rebuild(); + this.#activePoints = 0; + this.line.visible = false; } addPoint() { - // Skip duplicates so MeshLine doesn't choke on zero-length segments const p = this.golfBall.position; const last = this.points[this.points.length - 1]; if (!last || last.distanceToSquared(p) > 1e-6) { @@ -192,129 +157,159 @@ export class BallTrail { } update(collectPoints = false) { - let dirty = false; + // let dirty = false; - if (collectPoints && this.frameNum % 4 === 0 && this.points.length < this.maxPoints) { + // if (collectPoints && this.frameNum % 4 === 0 && this.points.length < this.maxPoints) { + if (collectPoints && this.frameNum % 2 === 0 && this.points.length < this.maxPoints) { this.addPoint(); - dirty = true; + // dirty = true; } - // During an active shot, also update for the live ball position - // but throttle to every other frame - if (collectPoints && this.frameNum % 2 === 0) { - dirty = true; - } + // if (collectPoints && this.frameNum % 2 === 0) { + // dirty = true; + // } this.frameNum++; - if (dirty) { - this._rebuild(); + // if (dirty) { + // this._rebuild(); + // } + if (collectPoints && this.frameNum % 2 === 0) { + this._updatePositions(); } - } - // update(collectPoints = false) { - - // if (collectPoints && this.frameNum % 4 === 0 && this.points.length < this.maxPoints) { - // this.addPoint(); - // } - // this.frameNum++; - // this._rebuild(); - // } + } _rebuild() { - if (this.trail) { - this.scene.remove(this.trail); - if (this.geom) { - this.geom.dispose(); - // Force-clear MeshLineGeometry's internal arrays - (this.geom as any).positions = null; - (this.geom as any).previous = null; - (this.geom as any).next = null; - (this.geom as any).side = null; - (this.geom as any).width_ = null; - (this.geom as any).counters = null; - (this.geom as any).uvs = null; - (this.geom as any).indices_array = null; - } - this.trail = null; - this.geom = null; - } - - // if (this.trail) { - // this.scene.remove(this.trail); - // if (this.geom) this.geom.dispose() - // this.trail = null; - // this.geom = null; - // } - const live = this.golfBall.position; const last = this.points[this.points.length - 1]; const raw = (!last || last.distanceToSquared(live) > 1e-6) ? [...this.points, live.clone()] : this.points; - if (raw.length < 2) return; + if (raw.length < 2) { + this.line.visible = false; + return; + } - // Densify so UV ≈ arc length, and so the fade region has plenty of vertices const head = resampleByArcLength(raw, this.resampleSpacing); - if (head.length < 2) return; + if (head.length < 2) { + this.line.visible = false; + return; + } - // Total arc length, used to size the fade region + // Compute total arc length for fade fraction let total = 0; - for (let i = 1; i < head.length; i++) total += head[i].distanceTo(head[i - 1]); - this._updateAlphaMap(total); + for (let i = 1; i < head.length; i++) { + total += head[i].distanceTo(head[i - 1]); + } + + // Update the fade fraction: fadeLength / totalLength + // Capped at 0.49 so both ends don't overlap + this.fadeFracUniform.value = Math.min(0.49, this.fadeLength / Math.max(total, 0.0001)); - const flat = new Float32Array(head.length * 3); + // Build flat positions array + const positions = new Float32Array(head.length * 3); for (let i = 0; i < head.length; i++) { - flat[i * 3] = head[i].x; - flat[i * 3 + 1] = head[i].y; - flat[i * 3 + 2] = head[i].z; + positions[i * 3] = head[i].x; + positions[i * 3 + 1] = head[i].y; + positions[i * 3 + 2] = head[i].z; } - this.geom = new MeshLineGeometry(); - this.geom.setPoints(flat); - - this.trail = new THREE.Mesh(this.geom, this.material); - this.trail.layers.set(2); - this.trail.frustumCulled = false; - this.scene.add(this.trail); + // Update the line geometry and rebuild + this.line.lines(positions).build(); + this.line.visible = true; + this.line.frustumCulled = false; + this.line.layers.set(2); + this.line.renderOrder = this.renderOrder; } - // Add to BallTrail class: dispose() { - if (this.trail) { - this.scene.remove(this.trail); - this.trail = null; - } - if (this.geom) { - this.geom.dispose(); - this.geom = null; + // @ts-expect-error makio-meshline raycast signature doesn't match Object3D + this.scene.remove(this.line); + this.line.geometry.dispose(); + // this.line.material.dispose(); + const mat = this.line.material; + if (Array.isArray(mat)) { + mat.forEach(m => m.dispose()); + } else { + mat.dispose(); } - this.material.dispose(); - this.#alphaTex.dispose(); + } - + reset(updatedTarget?: THREE.Object3D) { if (updatedTarget) { this.golfBall = updatedTarget; } - // Remove current trail mesh from scene and dispose geometry - if (this.trail) { - console.log('REMOVE TRAIL'); - this.scene.remove(this.trail); - this.trail = null; - } - if (this.geom) { - console.log('DISPOSE GEOM'); - this.geom.dispose(); - this.geom = null; - } - // Clear points but keep the material and texture alive + this.line.visible = false; this.points = []; this.frameNum = 0; + this.#activePoints = 0; + this.#built = false; + this.#needsFullFill = true; } - // remove() { - // if (this.trail) this.scene.remove(this.trail); - // } -} + _updatePositions() { + const live = this.golfBall.position; + const last = this.points[this.points.length - 1]; + const raw = (!last || last.distanceToSquared(live) > 1e-6) + ? [...this.points, live.clone()] + : this.points; + + if (raw.length < 2) { + this.line.visible = false; + return; + } + + const head = resampleByArcLength(raw, this.resampleSpacing); + if (head.length < 2) { + this.line.visible = false; + return; + } + + let total = 0; + for (let i = 1; i < head.length; i++) { + total += head[i].distanceTo(head[i - 1]); + } + this.fadeFracUniform.value = Math.min(0.49, this.fadeLength / Math.max(total, 0.0001)); + this.activeRatioUniform.value = this.#activePoints / this.maxPoints; + + this.#activePoints = Math.min(head.length, this.maxPoints); + for (let i = 0; i < this.#activePoints; i++) { + this.#positions[i * 3] = head[i].x; + this.#positions[i * 3 + 1] = head[i].y; + this.#positions[i * 3 + 2] = head[i].z; + } + + // const lastPt = head[this.#activePoints - 1]; + // for (let i = this.#activePoints; i < this.maxPoints; i++) { + // this.#positions[i * 3] = lastPt.x; + // this.#positions[i * 3 + 1] = lastPt.y; + // this.#positions[i * 3 + 2] = lastPt.z; + // } + if (this.#needsFullFill) { + const lastPt = head[this.#activePoints - 1]; + for (let i = this.#activePoints; i < this.maxPoints; i++) { + this.#positions[i * 3] = lastPt.x; + this.#positions[i * 3 + 1] = lastPt.y; + this.#positions[i * 3 + 2] = lastPt.z; + } + this.#needsFullFill = false; + } + + + if (!this.#built) { + this.line.lines(this.#positions).build(); + this.#built = true; + } else { + // this.line.geometry.setPositions(this.#positions, true); + (this.line.geometry as MeshLineGeometry).setPositions(this.#positions, true); + } + + this.line.visible = true; + this.line.frustumCulled = false; + this.line.layers.set(2); + this.line.renderOrder = this.renderOrder; + } +} \ No newline at end of file diff --git a/src/objects/flagStick.ts b/src/objects/flagStick.ts index d03d0f3..018018b 100644 --- a/src/objects/flagStick.ts +++ b/src/objects/flagStick.ts @@ -1,4 +1,11 @@ import * as THREE from 'three'; +import { MeshStandardNodeMaterial } from 'three/webgpu'; +// import { positionLocal, vec3, float, smoothstep, mix } from 'three/tsl'; +import { + positionWorld, uniform as tslUniform, + vec3, vec4, float, smoothstep, mix, Fn, Discard, + cameraPosition +} from 'three/tsl'; // Simple Verlet particle @@ -69,6 +76,8 @@ class FlagConstraint { */ export class FlagStick { object: THREE.Group; + surfacePoint: any; // uniform: point on the green's local plane + surfaceNormal: any; // uniform: green's local surface normal holeNumber: string; elapsed = 0; particles: FlagParticle[]; @@ -83,7 +92,7 @@ export class FlagStick { #tmpForce: THREE.Vector3; #restPositions: THREE.TypedArray; - constructor(position: THREE.Vector3, holeNumber: string, golfCup?: THREE.Mesh) { + constructor(position: THREE.Vector3, holeNumber: string, golfCup?: THREE.Mesh, surfaceNormal?: THREE.Vector3) { this.object = new THREE.Group(); this.holeNumber = holeNumber; @@ -170,17 +179,76 @@ export class FlagStick { if (golfCup) { console.log('----- ADD GOLF CUP!'); const cupCopy = golfCup.clone(); - const mat = new THREE.MeshStandardMaterial({ - // color: new THREE.Color('#ffffff'), - color: new THREE.Color('#dddddd'), - metalness: 0, - roughness: 0 - }); + // const mat = new THREE.MeshStandardMaterial({ + // // color: new THREE.Color('#ffffff'), + // color: new THREE.Color('#dddddd'), + // metalness: 0, + // roughness: 0 + // }); + const s = 0.1088; // cup model scale (keep in sync with scale.set below) + // const topY = float(0); // TODO: your known local top Y of the cup model + + // const dirtDepth = float(0.005 / s); // 3.5 cm soil strip, in local units + // const edge = float(0.002 / s); // AA transition width + // this.surfacePoint = tslUniform(position.clone()); + // this.surfaceNormal = tslUniform(new THREE.Vector3(0, 1, 0)); + this.surfacePoint = tslUniform(position.clone()); + this.surfaceNormal = tslUniform( + (surfaceNormal ?? new THREE.Vector3(0, 1, 0)).clone().normalize() + ); + + // Signed height of the fragment above the green's local plane (meters) + const h = positionWorld.sub(this.surfacePoint).dot(this.surfaceNormal); + + const dirtDepth = float(0.010); // world units again — no /s needed + const edge = float(0.001); + const dirtColor = vec3(0.478, 0.369, 0.255); // match rimColorRGB in the target shader + const plasticColor = vec3(0.85, 0.85, 0.85); + + // 1 near the lip (dirt), 0 below the dirt band (plastic liner) + // const dirtT = smoothstep( + // topY.sub(dirtDepth).sub(edge), + // topY.sub(dirtDepth).add(edge), + // positionLocal.y + // ); + // Dirt where h is in (-dirtDepth, 0); plastic below + const dirtT = smoothstep( + dirtDepth.negate().sub(edge), + dirtDepth.negate().add(edge), + h + ); + + // Fade the dirt out with camera distance: full at <=8m, gone by 18m + const camDist = positionWorld.sub(cameraPosition).length(); + const dirtFade = smoothstep(float(18.0), float(8.0), camDist); + const dirtVis = dirtT.mul(dirtFade); + + // Cheap AO: darken the liner toward the bottom of the cup + // const depthT = smoothstep(topY, topY.sub(0.11 / s), positionLocal.y); + const depthT = smoothstep(float(0), float(-0.11), h); + + const shaded = mix(plasticColor, plasticColor.mul(0.55), depthT); + + const mat = new MeshStandardNodeMaterial({ metalness: 0 }); + // mat.colorNode = mix(shaded, dirtColor, dirtT); + mat.colorNode = Fn(() => { + // Clip everything above the green surface — rim conforms to slope + Discard(h.greaterThan(0.0)); + // return vec4(mix(shaded, dirtColor, dirtT), 1.0); + return vec4(mix(shaded, dirtColor, dirtVis), 1.0); + })(); + + // mat.roughnessNode = mix(float(0.35), float(1.0), dirtT); + mat.roughnessNode = mix(float(0.35), float(1.0), dirtVis); + cupCopy.material = mat; - cupCopy.scale.set(0.108, 0.108, 0.108); - cupCopy.position.set(0, -(stickHeight / 2) + 0.07, 0); + // cupCopy.scale.set(0.108, 0.108, 0.108); + cupCopy.scale.set(s, s, s); + cupCopy.position.set(0, -(stickHeight / 2) + 0.064, 0); // cupCopy.position.copy(position); - cupCopy.position.y += 1; + // cupCopy.position.y += 1; + cupCopy.position.y += 1.02; // raise so the rim clears the terrain everywhere; clip trims it + this.object.add(cupCopy); } diff --git a/src/objects/golfBall.ts b/src/objects/golfBall.ts index a1e8cdb..23f02ae 100644 --- a/src/objects/golfBall.ts +++ b/src/objects/golfBall.ts @@ -1,11 +1,12 @@ import * as THREE from 'three'; import { type World } from '@dimforge/rapier3d-compat'; import EventEmitter from 'eventemitter3'; +import { app } from '../index'; import { BallPhysics } from '@/physics/ballPhysics'; import { BallTrail } from '@/objects/ballTrail'; import { CourseColliderType, CourseSurfaceProperties, CourseSurfaceType } from '@/courses/surfaces'; -const FIXED_DT = 1 / 120; +const FIXED_DT = 1 / 60; export interface GolfBallEvents { shotEnded: (details: { surface?: CourseSurfaceProperties, isHoled: boolean }) => void, @@ -17,9 +18,10 @@ type BallTrailClearMode = 'start' | 'end' | 'never'; type GolfBallOptions = { waitTime?: number; - setupData?: Partial; + setupData: Partial; /** Clear ball trail before the shot. Default is to clear when the shot ends. */ clearTrail?: BallTrailClearMode; + groundMeshes: THREE.Mesh[]; } export type ShotStats = { @@ -52,11 +54,11 @@ export class GolfBall extends EventEmitter { isShotWaiting: boolean; startPoint: THREE.Vector3; aimPoint: THREE.Vector3; - object?: THREE.Object3D; + object: THREE.Object3D; trail?: BallTrail; - physics?: BallPhysics; + physics: BallPhysics; clearTrail: BallTrailClearMode; - #setupData?: Partial; + #setupData: Partial; #waitTime: number; #timeout?: number; #scene: THREE.Scene; @@ -66,12 +68,16 @@ export class GolfBall extends EventEmitter { #frameNum: 0; lastShot?: OpenGolfSim.Shot; ballMaterial: THREE.MeshBasicMaterial; + groundMeshes: THREE.Mesh[]; - constructor(scene: THREE.Scene, world: World, R: RapierInstance, options: GolfBallOptions = {}) { + constructor(scene: THREE.Scene, world: World, R: RapierInstance, options: GolfBallOptions) { super(); this.radius = 0.0213; this.stats = createDefaultStats(); - this.clearTrail = options.clearTrail || 'end'; + + const opts = options || {}; + this.groundMeshes = options.groundMeshes; + this.clearTrail = options.clearTrail ?? 'end'; this.#setupData = options.setupData; this.#waitTime = options.waitTime ?? 3000; this.#scene = scene; @@ -86,56 +92,74 @@ export class GolfBall extends EventEmitter { this.ballMaterial = new THREE.MeshBasicMaterial( { color: 0xffffff } ); + // Create ball mesh once + const geometry = new THREE.IcosahedronGeometry(this.radius, 5); + this.object = new THREE.Mesh(geometry, this.ballMaterial); + this.object.castShadow = false; + this.object.frustumCulled = false; + this.#scene.add(this.object); + + // Create physics once — reused across all shots + this.physics = new BallPhysics(this.object, this.#world, this.#rapier, this.radius, this.groundMeshes); + this.physics.on('shotEnded', surface => this._onShotEnded(surface)); + this.physics.on('holedOut', () => this.emit('holedOut')); + this.physics.on('landed', (v) => this.emit('landed', v)); + this.physics.setElevation(this.#setupData?.elevation); + } reset(aimPoint: THREE.Vector3, startPoint: THREE.Vector3, holePoint?: THREE.Vector3) { - if (this.object) { - // remove existing ball object and physics - this.#scene.remove(this.object); - if (this.object instanceof THREE.Mesh) { - this.object.geometry.dispose(); - // if (this.object.material) { - // (this.object.material as THREE.Material).dispose(); - // } - } - } - if (this.physics) { - this.physics.removeAllListeners(); // clean up old event listener - this.physics.remove(); - } + // if (this.object) { + // // remove existing ball object and physics + // this.#scene.remove(this.object); + // if (this.object instanceof THREE.Mesh) { + // this.object.geometry.dispose(); + // // if (this.object.material) { + // // (this.object.material as THREE.Material).dispose(); + // // } + // } + // } + // if (this.physics) { + // this.physics.removeAllListeners(); // clean up old event listener + // this.physics.remove(); + // } this.isShotWaiting = false; // const geometry = new THREE.SphereGeometry( this.radius, 32, 16 ); - const geometry = new THREE.IcosahedronGeometry(this.radius, 5); - // const material = new THREE.MeshBasicMaterial( { color: 0xffffff } ); - this.object = new THREE.Mesh( geometry, this.ballMaterial ); - this.object.castShadow = false; - this.object.frustumCulled = false; - if (startPoint) { - this.startPoint = startPoint; - this.object.position.copy(startPoint); - this.object.position.y += this.radius; - } - - this.#scene.add(this.object); + // const geometry = new THREE.IcosahedronGeometry(this.radius, 5); + // // const material = new THREE.MeshBasicMaterial( { color: 0xffffff } ); + // this.object = new THREE.Mesh( geometry, this.ballMaterial ); + // this.object.castShadow = false; + // this.object.frustumCulled = false; + // if (startPoint) { + // this.startPoint = startPoint; + // this.object.position.copy(startPoint); + // this.object.position.y += this.radius; + // } + + // this.#scene.add(this.object); + + this.object.visible = true; + this.startPoint.copy(startPoint); + this.object.position.copy(startPoint); + this.object.position.y += this.radius; if (this.clearTrail === 'end') { this.#resetBallTrail(); } - if (aimPoint) { - this.aimAt(aimPoint) - } - + this.aimAt(aimPoint) + this.#frameNum = 0; - - this.physics = new BallPhysics(this.object, this.#world, this.#rapier, this.radius); - this.physics.on('shotEnded', surface => this._onShotEnded(surface)); - this.physics.on('holedOut', () => this.emit('holedOut')); - this.physics.on('landed', (v) => this.emit('landed', v)); - this.physics.setElevation(this.#setupData?.elevation); - if (holePoint) { - this.physics.setPin(holePoint); - } + + this.physics.reset(this.object.position, holePoint); + // this.physics = new BallPhysics(this.object, this.#world, this.#rapier, this.radius, this.groundMeshes); + // this.physics.on('shotEnded', surface => this._onShotEnded(surface)); + // this.physics.on('holedOut', () => this.emit('holedOut')); + // this.physics.on('landed', (v) => this.emit('landed', v)); + // this.physics.setElevation(this.#setupData?.elevation); + // if (holePoint) { + // this.physics.setPin(holePoint); + // } } #resetBallTrail() { @@ -146,7 +170,7 @@ export class GolfBall extends EventEmitter { if (this.trail) { this.trail.reset(this.object); // reuse existing instance } else { - this.trail = new BallTrail(this.#scene, this.object, { lineWidth: 0.2 }); + this.trail = new BallTrail(this.#scene, this.object); } } @@ -168,7 +192,6 @@ export class GolfBall extends EventEmitter { // } _onShotEnded(surface: CourseSurfaceProperties | undefined) { - console.log('RAW SHOT END'); if (!this.stats.endPosition) { this.stats.endPosition = this.object?.position.clone(); } @@ -180,7 +203,6 @@ export class GolfBall extends EventEmitter { this.#timeout = setTimeout(() => { this.isShotActive = false; // this.isShotEnded = true; - console.log('FIRE SHOT END'); this.emit('shotEnded', { surface, isHoled: this.physics?.isHoled === true }); // this.dispatchEvent(new CustomEvent('shotEnd', { detail: { surface } })); }, this.#waitTime); @@ -218,6 +240,8 @@ export class GolfBall extends EventEmitter { this.stats = createDefaultStats(); this.#accumulator = 0; + console.log('Shot launched, accumulator reset'); + if (this.trail) { // add first point this.trail.addPoint(); @@ -231,15 +255,29 @@ export class GolfBall extends EventEmitter { update(delta: number) { - const frameDelta = Math.min(delta, 0.1); + // const frameDelta = Math.min(delta, 0.1); + let frameDelta = Math.min(delta, 0.1); + // When display rate closely matches physics rate, snap to prevent + // accumulator drift that causes 0-step/2-step alternation + if (Math.abs(frameDelta - FIXED_DT) < 0.002) { + frameDelta = FIXED_DT; + } + + // if (this.isShotActive && this.#frameNum < 5) { + // console.log(`Frame ${this.#frameNum}: delta=${(delta * 1000).toFixed(1)}ms, frameDelta=${(frameDelta * 1000).toFixed(1)}ms`); + // } + this.#accumulator += frameDelta; if (this.physics) { // Fixed-timestep physics + let steps = 0; while (this.#accumulator >= FIXED_DT) { this.physics.update(FIXED_DT); this.#accumulator -= FIXED_DT; + steps++; } + if (steps !== 1) console.log(`Steps: ${steps}, accum: ${this.#accumulator.toFixed(5)}`); } if (this.trail) { this.trail.update(this.isShotActive); diff --git a/src/physics/ballPhysics.ts b/src/physics/ballPhysics.ts index 71089a4..3ffec66 100644 --- a/src/physics/ballPhysics.ts +++ b/src/physics/ballPhysics.ts @@ -1,4 +1,6 @@ import * as THREE from 'three'; +import { MeshBVH } from 'three-mesh-bvh'; +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import { type World, type EventQueue, @@ -7,8 +9,9 @@ import { } from '@dimforge/rapier3d-compat'; import EventEmitter from 'eventemitter3'; import { UnitConversions } from '@/utils/units'; -import { CourseSurfaceProperties, CourseObjectType, CourseSurfaces } from '@/courses/surfaces'; +import { CourseSurfaceProperties, CourseObjectType, CourseSurfaces, isCourseSurfaceType, CourseSurfaceType } from '@/courses/surfaces'; import { PhysicsLookupTable, GRAVITY, isColliderWithUserData, ColliderWithUserData } from './constants'; +import { app } from '../index'; interface BallPhysicsEvents { shotEnded: (surface: CourseSurfaceProperties | undefined) => void; @@ -20,7 +23,8 @@ type TerrainInfo = { height: number, restitution: number, friction: number, - surface?: CourseSurfaceProperties; + normal: THREE.Vector3, + surface?: CourseSurfaceProperties } // Membership in low 16 bits, filter in high 16 bits @@ -72,7 +76,8 @@ export class BallPhysics extends EventEmitter { holeRadius = 0.054; // 108mm cupDepth = 0.105; // ≥101.6mm regulation holeGroundY = 0; // green Y at the pin, captured on entry - holeState: 'none' | 'falling' = 'none'; + // holeState: 'none' | 'over' | 'falling' | 'exiting' = 'none'; + holeState: 'none' | 'falling' | 'exiting' = 'none'; isHoled = false; #preStepLinvel?: { x: number; y: number; z: number }; @@ -80,10 +85,43 @@ export class BallPhysics extends EventEmitter { #lastTerrainInfo: TerrainInfo = { height: 0, restitution: 0.35, - friction: 0.6 + friction: 0.6, + normal: new THREE.Vector3(0, 1, 0), } - - constructor(mesh: THREE.Object3D, world: World, rapier: RapierInstance, radius = 0.021335) { + // #terrainBVHs: { bvh: MeshBVH, mesh: THREE.Mesh }[] = []; + #mergedBVH!: MeshBVH; + #mergedMesh!: THREE.Mesh; + // #surfaceMap: CourseSurfaceProperties[] = []; + #surfaceKeys: string[] = []; + + #terrainRay = new THREE.Ray(); + #invMatrix = new THREE.Matrix4(); + #rayOrigin = new THREE.Vector3(); + #rayDirection = new THREE.Vector3(0, -1, 0); + #vel = new THREE.Vector3(); + #spin = new THREE.Vector3(); + #gravity = new THREE.Vector3(); + #slopeForce = new THREE.Vector3(); + #normalClone = new THREE.Vector3(); + #magnusVec = new THREE.Vector3(); + #normalComponent = new THREE.Vector3(); + #tangentComponent = new THREE.Vector3(); + #position = new THREE.Vector3(); + #velocity = new THREE.Vector3(); + #angularVel = new THREE.Vector3(); + #hitNormal = new THREE.Vector3(0, 1, 0); + #p0 = new THREE.Vector2(); + #p1 = new THREE.Vector2(); + #seg = new THREE.Vector2(); + #holeTemp = new THREE.Vector2(); + + constructor( + mesh: THREE.Object3D, + world: World, + rapier: RapierInstance, + radius = 0.021335, + terrainMeshes: THREE.Mesh[] = [] + ) { super(); this.mesh = mesh; this.world = world; @@ -127,11 +165,75 @@ export class BallPhysics extends EventEmitter { // Store the collider handle so we can identify it in events this.ballColliderHandle = this.collider.handle; - + + // Build BVH for each terrain mesh + // for (const tm of terrainMeshes) { + // tm.updateMatrixWorld(true); + // this.#terrainBVHs.push({ bvh: new MeshBVH(tm.geometry), mesh: tm }); + // } + this.buildTerrainMap(terrainMeshes); // Start frozen this.freeze(); } + reset(position: THREE.Vector3, holePosition?: THREE.Vector3) { + this.#position.copy(position); + this.#velocity.set(0, 0, 0); + this.#angularVel.set(0, 0, 0); + this.mesh.position.copy(position); + + this.isShotActive = false; + this.isPutt = false; + this.isLanded = false; + this.isGrounded = false; + this.isEnded = false; + this.isHoled = false; + this.hasBeenAirborne = false; + this.terrainCollisionsEnabled = false; + this.currentSurface = undefined; + this.holeState = 'none'; + this.shotFrames = 0; + this.groundedFrames = 0; + if (holePosition) this.setPin(holePosition); + } + + buildTerrainMap(terrainMeshes: THREE.Mesh[]) { + if (terrainMeshes.length > 0) { + const geometries: THREE.BufferGeometry[] = []; + + for (const tm of terrainMeshes) { + tm.updateMatrixWorld(true); + const geo = tm.geometry.clone(); + geo.applyMatrix4(tm.matrixWorld); + + // Tag every triangle with a surface index + const triCount = geo.index + ? geo.index.count / 3 + : geo.attributes.position.count / 3; + // const surfaceIndex = this.#surfaceMap.length; + // this.#surfaceMap.push(tm.userData as CourseSurfaceProperties); + + const surfaceIndex = this.#surfaceKeys.length; + this.#surfaceKeys.push(tm.userData.surface ?? 'base'); + + + + // Store surface index per face via groups + geo.clearGroups(); + geo.addGroup(0, geo.index ? geo.index.count : geo.attributes.position.count, surfaceIndex); + + geometries.push(geo); + } + + const merged = mergeGeometries(geometries, true); + if (merged) { + this.#mergedBVH = new MeshBVH(merged); + this.#mergedMesh = new THREE.Mesh(merged); + this.#mergedMesh.matrixWorld.identity(); + } + } + } + /** Set elevation and air density */ setElevation(meters = 0) { console.log(`Playing with elevation: ${meters}`); @@ -179,21 +281,26 @@ export class BallPhysics extends EventEmitter { } resetTo(position: THREE.Vector3) { - this.freeze(); - this.rigidBody.setTranslation({ x: position.x, y: position.y, z: position.z }, true); - this.rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true); - this.rigidBody.setAngvel({ x: 0, y: 0, z: 0 }, true); - this.rigidBody.resetForces(true); - this.rigidBody.resetTorques(true); - // Also clear any internal state flags you keep (collision-entered, etc.) - this.rigidBody.wakeUp(); + // this.freeze(); + // this.rigidBody.setTranslation({ x: position.x, y: position.y, z: position.z }, true); + // this.rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true); + // this.rigidBody.setAngvel({ x: 0, y: 0, z: 0 }, true); + // this.rigidBody.resetForces(true); + // this.rigidBody.resetTorques(true); + // // Also clear any internal state flags you keep (collision-entered, etc.) + // this.rigidBody.wakeUp(); + this.isShotActive = false; + this.#position.copy(position); + this.#velocity.set(0, 0, 0); + this.#angularVel.set(0, 0, 0); + // this.mesh.position.copy(position); this.isLanded = false; this.isGrounded = false; this.isHoled = false; this.isEnded = false; this.groundedFrames = 0; - this.syncMesh(); + // this.syncMesh(); } remove() { @@ -225,8 +332,10 @@ export class BallPhysics extends EventEmitter { this.groundedFrames = 0; this.shotFrames = 0; + this.holeState = 'none'; + // Unfreeze - this.unfreeze(); + // this.unfreeze(); this.isShotActive = true; // Disable CCD during launch — re-enable once airborne // this.rigidBody.enableCcd(false); @@ -251,7 +360,13 @@ export class BallPhysics extends EventEmitter { THREE.MathUtils.degToRad(-hla), ); dir.applyQuaternion(qH).normalize().multiplyScalar(speed); - this.rigidBody.setLinvel({ x: dir.x, y: dir.y, z: dir.z }, true); + + // Putts skip air phase — initialize ground physics vectors directly + this.#velocity.copy(dir); + this.#angularVel.set(0, 0, 0); + this.#position.copy(this.mesh.position); + + // this.rigidBody.setLinvel({ x: dir.x, y: dir.y, z: dir.z }, true); // // Spin // const spinRad = spinRPM * 2 * Math.PI / 60; @@ -287,7 +402,8 @@ export class BallPhysics extends EventEmitter { ); dir.normalize().multiplyScalar(speed); - this.rigidBody.setLinvel({ x: dir.x, y: dir.y, z: dir.z }, true); + // this.rigidBody.setLinvel({ x: dir.x, y: dir.y, z: dir.z }, true); + this.#velocity.copy(dir); // Spin const spinRad = spinRPM * 2 * Math.PI / 60; @@ -299,7 +415,10 @@ export class BallPhysics extends EventEmitter { .addScaledVector(up, Math.sin(axisRad)) .multiplyScalar(spinRad); - this.rigidBody.setAngvel({ x: spinVec.x, y: spinVec.y, z: spinVec.z }, true); + // this.rigidBody.setAngvel({ x: spinVec.x, y: spinVec.y, z: spinVec.z }, true); + this.#angularVel.copy(spinVec); + this.#position.copy(this.mesh.position); + // Set coefficients const coeffs = this.interpolateBySpeed(speed); @@ -347,10 +466,10 @@ export class BallPhysics extends EventEmitter { } _handleLanding() { - console.log('land'); - const lv = this.rigidBody.linvel(); - const vel = new THREE.Vector3(lv.x, lv.y, lv.z); - const vMag = THREE.MathUtils.clamp(vel.length(), 0, 25) / 25; + // const lv = this.rigidBody.linvel(); + // const vel = new THREE.Vector3(lv.x, lv.y, lv.z); + // const vMag = THREE.MathUtils.clamp(vel.length(), 0, 25) / 25; + const vMag = THREE.MathUtils.clamp(this.#velocity.length(), 0, 25) / 25; this.emit('landed', vMag); } @@ -381,33 +500,33 @@ export class BallPhysics extends EventEmitter { // Tree collisions still handled by Rapier this.world.contactPairsWith(this.collider, (otherCollider) => { - // @ts-expect-error - if (otherCollider.userData?.type === 'tree') { - const ballPos = this.rigidBody.translation(); - const treeBody = otherCollider.parent(); - if (!treeBody) return; - const treePos = treeBody.translation(); - - const dx = ballPos.x - treePos.x; - const dz = ballPos.z - treePos.z; - const dist = Math.sqrt(dx * dx + dz * dz); - - if (dist < 0.01) { - const angle = Math.random() * Math.PI * 2; - this.rigidBody.setLinvel({ - x: Math.cos(angle) * 2, y: 2, z: Math.sin(angle) * 2 - }, true); - } else { - const nx = dx / dist; - const nz = dz / dist; - const lv = this.rigidBody.linvel(); - const speed = Math.sqrt(lv.x * lv.x + lv.y * lv.y + lv.z * lv.z); - const pushSpeed = Math.max(speed * 0.3, 1.0); - this.rigidBody.setLinvel({ - x: nx * pushSpeed, y: Math.max(lv.y, 0.5), z: nz * pushSpeed - }, true); - } - } + // // @ts-expect-error + // if (otherCollider.userData?.type === 'tree') { + // const ballPos = this.rigidBody.translation(); + // const treeBody = otherCollider.parent(); + // if (!treeBody) return; + // const treePos = treeBody.translation(); + + // const dx = ballPos.x - treePos.x; + // const dz = ballPos.z - treePos.z; + // const dist = Math.sqrt(dx * dx + dz * dz); + + // if (dist < 0.01) { + // const angle = Math.random() * Math.PI * 2; + // this.rigidBody.setLinvel({ + // x: Math.cos(angle) * 2, y: 2, z: Math.sin(angle) * 2 + // }, true); + // } else { + // const nx = dx / dist; + // const nz = dz / dist; + // const lv = this.rigidBody.linvel(); + // const speed = Math.sqrt(lv.x * lv.x + lv.y * lv.y + lv.z * lv.z); + // const pushSpeed = Math.max(speed * 0.3, 1.0); + // this.rigidBody.setLinvel({ + // x: nx * pushSpeed, y: Math.max(lv.y, 0.5), z: nz * pushSpeed + // }, true); + // } + // } }); this.eventQueue.drainCollisionEvents((handle1, handle2, started) => { @@ -418,6 +537,77 @@ export class BallPhysics extends EventEmitter { } }); } + +_updateAirPhysics(dt: number) { + // const pos = this.rigidBody.translation(); + // const lv = this.rigidBody.linvel(); + // const av = this.rigidBody.angvel(); + // const vel = this.#vel.set(lv.x, lv.y, lv.z); + // const spin = this.#spin.set(av.x, av.y, av.z); + + const pos = this.#position; + const vel = this.#velocity; + const spin = this.#angularVel; + + const vMag = vel.length(); + + // Drag + if (vMag > 1e-6) { + const dragFactor = -0.5 * this.dragCoeff * this.airDensity * this.ballArea * vMag / this.ballMass; + vel.addScaledVector(vel, dragFactor * dt); + + // Magnus + const magnus = this.#gravity.crossVectors(spin, vel) + .multiplyScalar(this.magnusCoeff / this.ballMass); + const maxLift = GRAVITY * 0.83; + if (magnus.length() > maxLift) magnus.setLength(maxLift); + vel.addScaledVector(magnus, dt); + } + + // Gravity + vel.y -= GRAVITY * dt; + + // Spin decay + const decayBack = Math.pow(this.spinDecayRate, dt / 0.02); + const decaySide = Math.pow(this.sideSpinDecayRate, dt / 0.02); + spin.x *= decayBack; + spin.z *= decayBack; + spin.y *= decaySide; + + // Integrate position + const newX = pos.x + vel.x * dt; + const newY = pos.y + vel.y * dt; + const newZ = pos.z + vel.z * dt; + + // Update rigid body state + // this.rigidBody.setTranslation({ x: newX, y: newY, z: newZ }, true); + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); + pos.set(newX, newY, newZ); + + // Set mesh directly from float64 values to avoid float32 roundtrip jitter + // this.mesh.position.set(newX, newY, newZ); + + // Track airborne + this.shotFrames++; + if (!this.hasBeenAirborne && this.shotFrames > 1) { + const terrainY = this.getTerrainHeight(newX, newZ); + if (newY > terrainY + this.ballRadius * 3) { + this.hasBeenAirborne = true; + } + } + + // Detect landing + if (this.hasBeenAirborne) { + const terrainY = this.getTerrainHeight(newX, newZ); + if (newY <= terrainY + this.ballRadius + 0.01) { + this.isLanded = true; + // this.#position.set(newX, newY, newZ); + // this.#velocity.copy(vel); + // this.#angularVel.copy(spin); + } + } + } // Sync Three.js mesh to Rapier body syncMesh() { @@ -445,27 +635,25 @@ export class BallPhysics extends EventEmitter { if (!this.isShotActive) return; if (!this.isLanded) { - // Rapier handles ball in flight - this._applyAirForces(dt); - const lv = this.rigidBody.linvel(); - this.#preStepLinvel = { x: lv.x, y: lv.y, z: lv.z }; - - this.world.timestep = dt; - this.world.step(this.eventQueue); - this._processCollisions(); + this._updateAirPhysics(dt); } else { // Use custom ground physics once landed this._updateGroundPhysics(dt); } - this.syncMesh(); + // Set mesh position once per step from physics state + this.mesh.position.copy(this.#position); + + // this.syncMesh(); // Check if ball has come to rest if (this.isGrounded && !this.isEnded) { - const lv = this.rigidBody.linvel(); - const av = this.rigidBody.angvel(); - const speed = Math.sqrt(lv.x * lv.x + lv.y * lv.y + lv.z * lv.z); - const angSpeed = Math.sqrt(av.x * av.x + av.y * av.y + av.z * av.z); + // const lv = this.rigidBody.linvel(); + // const av = this.rigidBody.angvel(); + // const speed = Math.sqrt(lv.x * lv.x + lv.y * lv.y + lv.z * lv.z); + // const angSpeed = Math.sqrt(av.x * av.x + av.y * av.y + av.z * av.z); + const vel = this.#velocity; + const speed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); const endThresholdSpeed = this.currentSurface?.stopSpeed ?? this.defaultEndThresholdSpeed; if (speed < endThresholdSpeed) { @@ -476,7 +664,8 @@ export class BallPhysics extends EventEmitter { _endShot() { this.isEnded = true; - this.freeze(); + // this.freeze(); + this.isShotActive = false; this.emit('shotEnded', this.currentSurface); } @@ -516,9 +705,11 @@ export class BallPhysics extends EventEmitter { } _updateCupFall(dt: number) { - const pos = this.rigidBody.translation(); - const lv = this.rigidBody.linvel(); - const vel = new THREE.Vector3(lv.x, lv.y, lv.z); + // const pos = this.rigidBody.translation(); + // const lv = this.rigidBody.linvel(); + // const vel = new THREE.Vector3(lv.x, lv.y, lv.z); + const pos = this.#position; + const vel = this.#velocity; vel.y -= GRAVITY * dt; @@ -547,8 +738,11 @@ export class BallPhysics extends EventEmitter { vel.set(vel.x * 0.3, Math.abs(vel.y) * 0.25, vel.z * 0.3); // small floor bounce if (vel.length() < 0.15) { // settled - this.rigidBody.setTranslation({ x: nx, y: bottomY, z: nz }, true); - this.rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true); + // this.rigidBody.setTranslation({ x: nx, y: bottomY, z: nz }, true); + // this.rigidBody.setLinvel({ x: 0, y: 0, z: 0 }, true); + pos.set(nx, bottomY, nz); + vel.set(0, 0, 0); + // this.mesh.position.copy(pos); this.holeState = 'none'; this.isHoled = true; this.emit('holedOut'); @@ -557,33 +751,47 @@ export class BallPhysics extends EventEmitter { } } - this.rigidBody.setTranslation({ x: nx, y: ny, z: nz }, true); - this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.rigidBody.setTranslation({ x: nx, y: ny, z: nz }, true); + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + pos.set(nx, ny, nz); + // this.mesh.position.copy(pos); } _updateGroundPhysics(dt: number) { // Falling into hole check if (this.holeState === 'falling') { - console.log('Bal is falling'); + console.log('Ball is falling into hole'); this._updateCupFall(dt); return; } - - const pos = this.rigidBody.translation(); - const lv = this.rigidBody.linvel(); - const vel = new THREE.Vector3(lv.x, lv.y, lv.z); - const av = this.rigidBody.angvel(); - const spin = new THREE.Vector3(av.x, av.y, av.z); - - - // Tree collision check — if hit, apply response and skip this frame - if (this._checkTreeCollision(pos, vel, spin, dt)) { - this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); - this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); - this.syncMesh(); - return; - } + // if (this.holeState === 'over') { + // this._updateHoleCrossing(dt); + // return; + // } + // const exitingHole = this.holeState === 'exiting'; + // if (exitingHole) { + // this.holeState = 'none'; + // } + // const pos = this.rigidBody.translation(); + // const lv = this.rigidBody.linvel(); + // const vel = new THREE.Vector3(lv.x, lv.y, lv.z); + // const av = this.rigidBody.angvel(); + // const spin = new THREE.Vector3(av.x, av.y, av.z); + + const pos = this.#position; + const vel = this.#velocity; + const spin = this.#angularVel; + + const t0 = performance.now(); + + // // Tree collision check — if hit, apply response and skip this frame + // if (this._checkTreeCollision(pos, vel, spin, dt)) { + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); + // this.syncMesh(); + // return; + // } // Apply gravity vel.y -= GRAVITY * dt; @@ -596,25 +804,53 @@ export class BallPhysics extends EventEmitter { const terrain = this.getTerrainInfo(newX, newZ); const terrainY = terrain.height; // console.log('terrain.userData', terrain); - const normal = this._getTerrainNormal(newX, newZ); + const normal = terrain.normal; + // const normal = this._getTerrainNormal(newX, newZ); + // console.log(`getTerrainInfo: ${(performance.now() - t0).toFixed(1)}ms`); + - if (this._checkHole(pos, newX, newZ, vel, terrainY)) { - this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); - this.syncMesh(); + // if (this._checkHole(pos, newX, newZ, vel, terrainY)) { + if (this.holeState !== 'exiting' && this._checkHole(pos, newX, newZ, vel, terrainY)) { + + // pos.set(newX, newY, newZ); + // Advance to closest approach point, not the endpoint (which may be past the hole) + const segX = newX - pos.x; + const segZ = newZ - pos.z; + const segLenSq = segX * segX + segZ * segZ; + if (segLenSq > 1e-12) { + const C = this.holeCenter; + const t = THREE.MathUtils.clamp( + ((C.x - pos.x) * segX + (C.y - pos.z) * segZ) / segLenSq, 0, 1 + ); + pos.x += segX * t; + pos.z += segZ * t; + // Keep Y at terrain height + pos.y = terrainY + this.ballRadius; + } + + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.syncMesh(); + // this.mesh.position.copy(pos); return; } + + // const t1 = performance.now(); + // console.log(`_checkHole: ${(performance.now() - t1).toFixed(1)}ms`); if (this._checkWaterCollision()) { this.mesh.visible = false; this._endShot(); return; } + // const t2 = performance.now(); + // console.log(`_checkHole: ${(performance.now() - t2).toFixed(1)}ms`); this.currentSurface = terrain?.surface; const minY = terrainY + (this.ballRadius * 2); - if (newY <= minY) { + // if (newY <= minY) { + if (newY <= minY && this.holeState !== 'exiting') { // === BOUNCE or ROLL === const speed = vel.length(); @@ -633,8 +869,10 @@ export class BallPhysics extends EventEmitter { vel.reflect(normal); - const normalComponent = vel.clone().projectOnVector(normal); - const tangentComponent = vel.clone().sub(normalComponent); + // const normalComponent = vel.clone().projectOnVector(normal); + // const tangentComponent = vel.clone().sub(normalComponent); + const normalComponent = this.#normalComponent.copy(vel).projectOnVector(normal); + const tangentComponent = this.#tangentComponent.copy(vel).sub(normalComponent); vel.copy(tangentComponent.multiplyScalar(tangentRetention)) .add(normalComponent.multiplyScalar(restitution)); @@ -665,29 +903,36 @@ export class BallPhysics extends EventEmitter { // spin.multiplyScalar(0.6); // } - this.rigidBody.setTranslation( - { x: newX, y: terrainY + this.ballRadius, z: newZ }, true - ); - this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); - this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); + // this.rigidBody.setTranslation( + // { x: newX, y: terrainY + this.ballRadius, z: newZ }, true + // ); + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); + pos.set(newX, terrainY + this.ballRadius, newZ); + // this.mesh.position.copy(pos); } else { // Handle rolling this.isGrounded = true; // Project velocity onto surface plane - vel.sub(normal.clone().multiplyScalar(vel.dot(normal))); - - // Slope acceleration - const gravity = new THREE.Vector3(0, -GRAVITY, 0); - const slopeForce = gravity.clone().sub( - normal.clone().multiplyScalar(gravity.dot(normal)) - ); - vel.add(slopeForce.multiplyScalar(dt)); + // vel.sub(normal.clone().multiplyScalar(vel.dot(normal))); + vel.sub(this.#normalClone.copy(normal).multiplyScalar(vel.dot(normal))); + + // // Slope acceleration + // // const gravity = new THREE.Vector3(0, -GRAVITY, 0); + // // const slopeForce = gravity.clone().sub( + // // normal.clone().multiplyScalar(gravity.dot(normal)) + // // ); + // const gravity = this.#gravity.set(0, -GRAVITY, 0); + // const slopeForce = this.#slopeForce.copy(gravity).sub( + // this.#normalClone.copy(normal).multiplyScalar(gravity.dot(normal)) + // ); + // vel.add(slopeForce.multiplyScalar(dt)); // Rolling resistance // const resistance = this._getRollingResistance(); - let resistance = this.currentSurface?.rollResistance ?? CourseSurfaces.base.restitution; + let resistance = this.currentSurface?.rollResistance ?? CourseSurfaces.base.rollResistance; // if (this.isPutt) { // resistance *= 0.25; // } @@ -697,14 +942,23 @@ export class BallPhysics extends EventEmitter { // const friction = Math.min(resistance * GRAVITY * dt, horizontalSpeed); // vel.addScaledVector(vel.clone().normalize(), -friction); // Coulomb friction (constant deceleration) — dominates at high speed - const friction = Math.min(resistance * GRAVITY * dt, horizontalSpeed); - vel.addScaledVector(vel.clone().normalize(), -friction); + // vel.addScaledVector(vel.clone().normalize(), -friction); + + // const friction = Math.min(resistance * GRAVITY * dt, horizontalSpeed); + const normalForce = normal.y; // cos(θ): 1.0 on flat, less on slopes + const friction = Math.min(resistance * GRAVITY * normalForce * dt, horizontalSpeed); + - // Viscous damping — dominates at low speed, prevents endless creep - if (!this.isPutt) { - const dampingFactor = Math.exp(-resistance * 8.0 * dt); - vel.multiplyScalar(dampingFactor); - } + vel.addScaledVector(this.#normalClone.copy(vel).normalize(), -friction); + + // // Viscous damping — dominates at low speed, prevents endless creep + // if (!this.isPutt) { + // const dampingFactor = Math.exp(-resistance * 8.0 * dt); + // vel.multiplyScalar(dampingFactor); + // } + const dampingMultiplier = this.isPutt ? 3.0 : 8.0; + const dampingFactor = Math.exp(-resistance * dampingMultiplier * normalForce * dt); + vel.multiplyScalar(dampingFactor); // } // Hard cutoff — anything below this is just numerical noise @@ -714,15 +968,17 @@ export class BallPhysics extends EventEmitter { // Spin deflection during roll — ω × r gives surface velocity at contact if (spin.length() > 1.0 && horizontalSpeed > 0.1) { - const contactPoint = normal.clone().multiplyScalar(-this.ballRadius); - const spinSurfaceVel = new THREE.Vector3().crossVectors(spin, contactPoint); + // const contactPoint = normal.clone().multiplyScalar(-this.ballRadius); + // const spinSurfaceVel = new THREE.Vector3().crossVectors(spin, contactPoint); + const contactPoint = this.#normalClone.copy(normal).multiplyScalar(-this.ballRadius); + const spinSurfaceVel = this.#slopeForce.crossVectors(spin, contactPoint); vel.addScaledVector(spinSurfaceVel, -this.gripStrength * dt); } - this.rigidBody.setTranslation( - { x: newX, y: terrainY + this.ballRadius, z: newZ }, true - ); - this.rigidBody.setLinvel({ x: vel.x, y: 0, z: vel.z }, true); + // this.rigidBody.setTranslation( + // { x: newX, y: terrainY + this.ballRadius, z: newZ }, true + // ); + // this.rigidBody.setLinvel({ x: vel.x, y: 0, z: vel.z }, true); // // Ground spin decay // const grassDampen = 4.5; @@ -731,34 +987,50 @@ export class BallPhysics extends EventEmitter { // spin.multiplyScalar(factor); // } // this.rigidBody.setAngvel({ x: spin.x, y: spin.y, z: spin.z }, true); + + pos.set(newX, terrainY + this.ballRadius, newZ); + // vel.y = 0; + // if (!exitingHole) vel.y = 0; + vel.y = 0; + // this.mesh.position.copy(pos); + } } else { // Airborne between bounces this.isGrounded = false; this.currentSurface = undefined; - this.rigidBody.setTranslation({ x: newX, y: newY, z: newZ }, true); - this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + // this.rigidBody.setTranslation({ x: newX, y: newY, z: newZ }, true); + // this.rigidBody.setLinvel({ x: vel.x, y: vel.y, z: vel.z }, true); + pos.set(newX, newY, newZ); + if (this.holeState === 'exiting') { + this.holeState = 'none'; + } + // this.mesh.position.copy(pos); } // Final check for lowest ground point (don't let the ball fall through) - const finalPos = this.rigidBody.translation(); - // const safeY = this.getTerrainHeight(finalPos.x, finalPos.z) + this.ballRadius; - - const checkY = this.getTerrainHeight(finalPos.x, finalPos.z); + // const finalPos = this.rigidBody.translation(); + // // const safeY = this.getTerrainHeight(finalPos.x, finalPos.z) + this.ballRadius; + // const checkY = this.getTerrainHeight(finalPos.x, finalPos.z); + const checkY = this.getTerrainHeight(pos.x, pos.z); const safeY = (checkY + this.ballRadius); // if (finalPos.y < safeY) { - if (finalPos.y < safeY - 0.005) { + // if (finalPos.y < safeY - 0.005) { + if (pos.y < safeY - 0.005) { console.warn('Ball fallen below ground'); - this.rigidBody.setTranslation( - { x: finalPos.x, y: safeY, z: finalPos.z }, true - ); - // Kill downward velocity so it doesn't immediately tunnel again - const lv = this.rigidBody.linvel(); - if (lv.y < 0) { - this.rigidBody.setLinvel({ x: lv.x, y: 0, z: lv.z }, true); - } + // this.rigidBody.setTranslation( + // { x: finalPos.x, y: safeY, z: finalPos.z }, true + // ); + // // Kill downward velocity so it doesn't immediately tunnel again + // const lv = this.rigidBody.linvel(); + // if (lv.y < 0) { + // this.rigidBody.setLinvel({ x: lv.x, y: 0, z: lv.z }, true); + // } + pos.y = safeY; + if (vel.y < 0) vel.y = 0; + // this.mesh.position.copy(pos); } } @@ -769,22 +1041,107 @@ export class BallPhysics extends EventEmitter { } getTerrainInfo(x: number, z: number) { - const ray = new this.rapier.Ray( - new this.rapier.Vector3(x, 500, z), - new this.rapier.Vector3(0, -1, 0) - ); - const hit = this.world.castRay(ray, 1000, true); - if (!hit || !isColliderWithUserData(hit.collider)) { + // // const ray = new this.rapier.Ray( + // // new this.rapier.Vector3(x, 500, z), + // // new this.rapier.Vector3(0, -1, 0) + // // ); + // // const hit = this.world.castRay(ray, 1000, true); + // // if (!hit || !isColliderWithUserData(hit.collider)) { + // // return this.#lastTerrainInfo; + // // } + // this.#rayOrigin.set(x, 500, z); + + // let closestDist = Infinity; + // let closestPoint: THREE.Vector3 | null = null; + // let closestMesh: THREE.Mesh | null = null; + + // for (const { bvh, mesh } of this.#terrainBVHs) { + // this.#invMatrix.copy(mesh.matrixWorld).invert(); + // this.#terrainRay.origin.copy(this.#rayOrigin).applyMatrix4(this.#invMatrix); + // this.#terrainRay.direction.copy(this.#rayDirection).transformDirection(this.#invMatrix); + + // const hit = bvh.raycastFirst(this.#terrainRay); + // if (hit) { + // hit.point.applyMatrix4(mesh.matrixWorld); + // const dist = this.#rayOrigin.distanceTo(hit.point); + // if (dist < closestDist) { + // closestDist = dist; + // closestPoint = hit.point; + // closestMesh = mesh; + // } + // } + // } + + // if (!closestPoint || !closestMesh) { + if (!this.#mergedBVH) { + return this.#lastTerrainInfo; + } + + // // const collider = this.world.getCollider(hit.colliderHandle); + // // Or if using newer Rapier: hit.collider directly + // const surface = closestMesh.userData as CourseSurfaceProperties | undefined; + + this.#terrainRay.origin.set(x, 500, z); + this.#terrainRay.direction.set(0, -1, 0); + + const hit = this.#mergedBVH.raycastFirst(this.#terrainRay); + if (!hit) { return this.#lastTerrainInfo; } - // const collider = this.world.getCollider(hit.colliderHandle); - // Or if using newer Rapier: hit.collider directly + // // Find which surface this triangle belongs to + // let surface: CourseSurfaceProperties | undefined; + // if (hit.faceIndex != null && this.#mergedMesh.geometry.groups.length > 0) { + // const vertIndex = hit.faceIndex * 3; + // for (const group of this.#mergedMesh.geometry.groups) { + // if (vertIndex >= group.start && vertIndex < group.start + group.count) { + // surface = this.#surfaceMap[group.materialIndex ?? 0]; + // console.log(`surface: `, surface); + // break; + // } + // } + // } + let surfaceKey = 'base'; + if (hit.faceIndex != null && this.#mergedMesh.geometry.groups.length > 0) { + const vertIndex = hit.faceIndex * 3; + for (const group of this.#mergedMesh.geometry.groups) { + if (vertIndex >= group.start && vertIndex < group.start + group.count) { + surfaceKey = this.#surfaceKeys[group.materialIndex ?? 0]; + break; + } + } + } + + // const surfaceSettings = isCourseSurfaceType(surfaceKey) + // ? CourseSurfaces[surfaceKey] + // : CourseSurfaces.base; + + const validSurfaceKey = isCourseSurfaceType(surfaceKey) ? surfaceKey : CourseSurfaceType.Base; + const surfaceSettings = CourseSurfaces[validSurfaceKey]; + + // const normal = hit.face + // ? hit.face.normal.clone() + // : new THREE.Vector3(0, 1, 0); + const normal = hit.face + ? this.#hitNormal.copy(hit.face.normal) + : this.#hitNormal.set(0, 1, 0); + this.#lastTerrainInfo = { - height: 500 - hit.timeOfImpact, - restitution: hit.collider.restitution(), - friction: hit.collider.friction(), - surface: hit.collider.userData, + // height: 500 - hit.timeOfImpact, + // restitution: hit.collider.restitution(), + // friction: hit.collider.friction(), + // surface: hit.collider.userData, + // height: closestPoint.y, + height: hit.point.y, + restitution: surfaceSettings.restitution ?? 0.35, + friction: surfaceSettings.friction ?? 0.6, + surface: { type: validSurfaceKey, ...surfaceSettings }, + normal + // surface: { type: surfaceKey as CourseColliderType, ...surfaceSettings }, + + // restitution: surface?.restitution ?? 0.35, + // friction: surface?.friction ?? 0.6, + // surface, }; return this.#lastTerrainInfo; } @@ -807,92 +1164,170 @@ export class BallPhysics extends EventEmitter { this.holeGroundY = pin.y; // green Y at the pin console.log(`Setting hole center to: ${pin.toArray().join(',')}`) } + + _checkHole(pos: THREE.Vector3, newX: number, newZ: number, vel: THREE.Vector3, terrainY: number): boolean { + const R = this.holeRadius; + const r = this.ballRadius; + const C = this.holeCenter; - _checkHole(pos: Vector, newX: number, newZ: number, vel: THREE.Vector3, terrainY: number): boolean { + // Larger detection zone — 3x hole radius gives us early warning + const detectionRadius = R * 3; - const R = this.holeRadius, r = this.ballRadius, g = GRAVITY; - const C = this.holeCenter; + // Path segment + const dx = newX - pos.x; + const dz = newZ - pos.z; + const segLenSq = dx * dx + dz * dz; + if (segLenSq < 1e-12) return false; - // segment P0 -> P1 in plan space - const p0 = new THREE.Vector2(pos.x, pos.z); - const p1 = new THREE.Vector2(newX, newZ); - const seg = p1.clone().sub(p0); - const segLen = seg.length(); + // Closest approach of path to hole center + const fx = pos.x - C.x; + const fz = pos.z - C.y; + const tClosest = THREE.MathUtils.clamp(-(fx * dx + fz * dz) / segLenSq, 0, 1); + const closestX = pos.x + dx * tClosest; + const closestZ = pos.z + dz * tClosest; + const closestDist = Math.hypot(closestX - C.x, closestZ - C.y); - // closest approach of the travel segment to the hole center - let b: number; - if (segLen < 1e-6) { - b = p0.distanceTo(C); - } else { - const t = THREE.MathUtils.clamp(C.clone().sub(p0).dot(seg) / (segLen * segLen), 0, 1); - b = p0.clone().addScaledVector(seg, t).distanceTo(C); - } - - if (b >= R + r) { - // no interaction + // Not even close + if (closestDist >= detectionRadius) return false; + + const vh = Math.hypot(vel.x, vel.z); + + // Ball doesn't actually reach the hole rim — just passing nearby + if (closestDist >= R + r) { + // Gentle pull when passing close but not crossing + if (closestDist < R * 2 && vh < 3.0) { + const pullFactor = 1 - (closestDist / (R * 2)); + const toCenterX = C.x - newX; + const toCenterZ = C.y - newZ; + const toCenterDist = Math.hypot(toCenterX, toCenterZ); + if (toCenterDist > 0.001) { + const gentlePull = pullFactor * 0.02; + vel.x += (toCenterX / toCenterDist) * gentlePull; + vel.z += (toCenterZ / toCenterDist) * gentlePull; + } + } return false; } - console.log('checkHole', { b: b.toFixed(4), vh: Math.hypot(vel.x, vel.z).toFixed(3) }); + // === Ball path crosses the hole (closestDist < R + r) === - const vh = Math.hypot(vel.x, vel.z); - // Slow ball near the hole: it overhangs the edge and topples in. - if (vh < 0.7 && b < R + r) { - console.log('CUP - slow roll in?'); + // How centrally does the path cross? 1 = dead center, 0 = rim edge + // const centrality = Math.max(0, 1 - closestDist / R); + const centrality = Math.max(0, 1 - closestDist / (R + r)); + + + // Fall-in speed threshold — linear scale with centrality + // Dead center: up to ~7 mph (3.0 m/s) drops in + // Half off-center: up to ~4.5 mph (2.0 m/s) + // Rim edge: up to ~2 mph (1.0 m/s) + // const fallInSpeed = 1.0 + centrality * 2.0; + const fallInSpeed = 1.0 + centrality * 2.5; + + if (vh < fallInSpeed) { this._enterCup(terrainY); return true; } - // resting on the hole - if (vh < 1e-3) { - if (p1.distanceTo(C) < R) { - console.log('CUP - resting on the hole?'); - this._enterCup(terrainY); - return true; + // === Ball crosses too fast to drop — compute lip-out === + // How long the ball spends over the hole determines lip impact + // Fast ball = short crossing time = barely affected + const chord = 2 * Math.sqrt(Math.max(0, (R + r) * (R + r) - closestDist * closestDist)); + const crossingTime = chord / vh; + // How much the ball dips during crossing (gravity drop) + const gravityDrop = 0.5 * GRAVITY * crossingTime * crossingTime; + // Normalized: 0 = barely dipped, 1 = dropped a full ball radius + const dipFactor = Math.min(gravityDrop / r, 1.0); + + // Find the exit point on the rim circle + const a = segLenSq; + const b2 = fx * dx + fz * dz; // half of b + const c = fx * fx + fz * fz - (R + r) * (R + r); + const discriminant = b2 * b2 - a * c; + + let exitX = newX, exitZ = newZ; + if (discriminant >= 0 && a > 1e-12) { + const tExit = (-b2 + Math.sqrt(discriminant)) / a; + if (tExit >= 0 && tExit <= 1) { + exitX = pos.x + dx * tExit; + exitZ = pos.z + dz * tExit; } - return false; } - if (b < R) { - // path crosses the opening — does it fall far enough during transit? - const chord = 2 * Math.sqrt(R * R - b * b); - const tCross = chord / vh; - const vDown = Math.max(0, -vel.y); // helps chips dropping in - const drop = vDown * tCross + 0.5 * g * tCross * tCross; - if (drop > r) { - console.log('CUP - path crossing!'); - this._enterCup(terrainY); - return true; - } - // console.log('CUP - _lipDeflect', p1); - // this._lipDeflect(vel, C, p1, 0.6); // rode across, clipped far lip + // Place ball at the exit point + const exitTerrain = this.getTerrainInfo(exitX, exitZ); + pos.set(exitX, exitTerrain.height + this.ballRadius, exitZ); + + // Very fast ball barely dips — skip interaction entirely + if (dipFactor < 0.05) { return false; } - // R <= b < R + r : grazing the rim - if (vh < 0.5) { - console.log('_enterCup < 0.5', vh); - this._enterCup(terrainY); - return true; - } - - // console.log('_lipDeflect-END', p1); - // this._lipDeflect(vel, C, p1, 0.5); // rim-out - return false; - } + // Speed loss scales with dip — fast ball loses almost nothing + const speedRetain = 1.0 - dipFactor * centrality * 0.2; + vel.x *= speedRetain; + vel.z *= speedRetain; + + // Lip bounce scales with dip factor — fast ball gets no bounce + const exitSpeed = Math.hypot(vel.x, vel.z); + const lipBounceFromSpeed = exitSpeed * 0.4 * dipFactor; + const lipBounceFromDepth = centrality * 0.6 * dipFactor; + const minimumLipBounce = 0.2 * dipFactor; + const maximumLipBounce = 2.0; + vel.y = Math.min(lipBounceFromSpeed + lipBounceFromDepth + minimumLipBounce, maximumLipBounce); + + // Deflection away from hole center — stronger with off-center crossings + // Off-center balls get pushed sideways, center crossings go mostly up + const awayX = exitX - C.x; + const awayZ = exitZ - C.y; + const awayDist = Math.hypot(awayX, awayZ); + if (awayDist > 0.001) { + // const offCenter = 1.0 - centrality; + // const deflectionStrength = dipFactor * (0.3 + offCenter * 0.8); + // vel.x += (awayX / awayDist) * deflectionStrength; + // vel.z += (awayZ / awayDist) * deflectionStrength; + + // Tangent to the rim circle — curves ball around the hole, not away from it + const radialX = awayX / awayDist; + const radialZ = awayZ / awayDist; + + // Perpendicular to radial, matching ball's travel direction + let tangentX = -radialZ; + let tangentZ = radialX; + if (tangentX * vel.x + tangentZ * vel.z < 0) { + tangentX = -tangentX; + tangentZ = -tangentZ; + } - _lipDeflect(vel: THREE.Vector3, C: THREE.Vector2, ballXZ: THREE.Vector2, retain: number) { - const n = ballXZ.clone().sub(C).normalize(); - const vh = new THREE.Vector2(vel.x, vel.z); - const inward = -vh.dot(n); - if (inward > 0) vh.addScaledVector(n, inward); - vh.multiplyScalar(retain); + // Blend velocity toward tangential — more for off-center crossings + const offCenter = 1.0 - centrality; + // const deflectAmount = dipFactor * offCenter * 0.5; + // Only off-center crossings get tangential curve + // Dead center = no sideways deflection, just vertical bounce - console.log('lipDeflect', { inward, velYbefore: vel.y }); // ← here + // Only apply rim deflection for genuinely off-center crossings + // Center crossings just bounce straight up - vel.x = vh.x; // ← "writing back" = these three lines - vel.y = Math.min(vel.y, 0); - vel.z = vh.y; + const rimCurveStrength = offCenter * offCenter * 1.0; + const deflectAmount = dipFactor * rimCurveStrength; + + if (deflectAmount > 0.01) { + vel.x += tangentX * vh * deflectAmount; + vel.z += tangentZ * vh * deflectAmount; + } + + // Re-normalize to original speed (always, regardless of deflection) + const newSpeed = Math.hypot(vel.x, vel.z); + if (newSpeed > 0.001) { + const targetSpeed = vh * speedRetain; + vel.x *= targetSpeed / newSpeed; + vel.z *= targetSpeed / newSpeed; + } + + } + + this.holeState = 'exiting'; + this.isGrounded = false; + return true; } _enterCup(terrainY: number) { diff --git a/src/renderer.ts b/src/renderer.ts index 35850e4..851b980 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,4 +1,5 @@ import { + Color, WebGLRenderer, PCFShadowMap, ACESFilmicToneMapping, @@ -9,8 +10,16 @@ import { type Mesh, type Texture, } from 'three'; +import { pass } from 'three/tsl'; import { QualityMode } from './utils/quality'; -import { WebGPURenderer } from 'three/webgpu'; +import { + WebGPURenderer, + RenderPipeline, + HemisphereLight, + PMREMGenerator as WebGPUPMREMGenerator, +} from 'three/webgpu'; +import { bloom } from 'three/addons/tsl/display/BloomNode.js'; + import { WebGLNodesHandler } from 'three/examples/jsm/tsl/WebGLNodesHandler.js'; type FuseRendererOptions = { @@ -32,7 +41,7 @@ export class FuseRenderer { qualityLevel: QualityMode; environment?: Texture; - + pipeline?: RenderPipeline; constructor(options: FuseRendererOptions) { if (!options.canvas || !(options.canvas instanceof HTMLCanvasElement)) { throw new Error('Must provide a valid canvas element'); @@ -42,7 +51,7 @@ export class FuseRenderer { this.height = this.container.offsetHeight; if (options.renderMode === 'webgpu') { - this.renderer = new WebGPURenderer({ canvas: options.canvas, antialias: options.antialias }); + this.renderer = new WebGPURenderer({ canvas: options.canvas, antialias: options.antialias, depth: true, }); } else { this.renderer = new WebGLRenderer({ canvas: options.canvas, antialias: options.antialias }); // Enable TSL node material support for WebGLRenderer @@ -57,10 +66,10 @@ export class FuseRenderer { this.qualityLevel = options.qualityLevel ?? QualityMode.Medium; - if (this.qualityLevel >= QualityMode.Medium) { + // if (this.qualityLevel >= QualityMode.Medium) { this.renderer.toneMapping = ACESFilmicToneMapping; // or whatever you pick - this.renderer.toneMappingExposure = 1.0; - } + this.renderer.toneMappingExposure = 1.1; + // } window.addEventListener('resize', this._handleResize.bind(this)); @@ -92,7 +101,13 @@ export class FuseRenderer { if (fog) { scene.fog = fog; } - this.renderer.render(scene, camera); + // this.renderer.render(scene, camera); + if (this.pipeline) { + this.pipeline.render(); // replaces renderer.render() + } else { + this.renderer.render(scene, camera); + } + } getMaxAnisotropy() { @@ -108,19 +123,63 @@ export class FuseRenderer { if (!this.renderer) { throw new Error('Missing renderer'); } - if (!sky) return; - + // if (!sky) return; + + // const tempScene = new Scene(); + // tempScene.background = scene.background || new Color('#c8dbe5'); + // tempScene.add(sky); + + // const pmrem = this.renderer instanceof WebGPURenderer ? new WebGPUPMREMGenerator(this.renderer) : new PMREMGenerator(this.renderer); + // this.environment = pmrem.fromScene(tempScene, 0, 0.1, 10000).texture; + // pmrem.dispose(); + + // // Move sky back to the real scene + // scene.add(sky); + // scene.environment = this.environment; + const tempScene = new Scene(); - tempScene.add(sky); + tempScene.background = scene.background || new Color('#c8dbe5'); + if (sky) { + tempScene.add(sky); + } - // Three.js type definitions for PMREMGenerator haven't been updated to accept WebGPURenderer as a renderer type yet. - // @ts-expect-error - const pmrem = new PMREMGenerator(this.renderer); + const hemiLight = new HemisphereLight('#c8dbe8', '#4a7a5c', 1.0); + tempScene.add(hemiLight); + + const pmrem = this.renderer instanceof WebGPURenderer ? new WebGPUPMREMGenerator(this.renderer) : new PMREMGenerator(this.renderer); this.environment = pmrem.fromScene(tempScene, 0, 0.1, 10000).texture; pmrem.dispose(); // Move sky back to the real scene - scene.add(sky); + if (sky) scene.add(sky); + // scene.environment = this.environment; } + + // In your FuseRenderer class, add a setup method: + setupPostProcessing(scene: Scene, camera: Camera) { + if (!(this.renderer instanceof WebGPURenderer)) { + console.warn('Post-processing pipeline requires WebGPURenderer'); + return; + } + + this.pipeline = new RenderPipeline(this.renderer); + + // Create the scene render pass + const scenePass = pass(scene, camera); + + // Get the color output texture node + const scenePassColor = scenePass.getTextureNode('output'); + + // Create the bloom effect + // const strength = 0.08; + const strength = 0.085; + const radius = 0.1; + const threshold = 0.60; + const bloomPass = bloom(scenePassColor, strength, radius, threshold); + + // Combine: original scene + bloom glow + this.pipeline.outputNode = scenePassColor.add(bloomPass); + } + } \ No newline at end of file diff --git a/src/shaders/clouds.ts b/src/shaders/clouds.ts new file mode 100644 index 0000000..bdfaae6 --- /dev/null +++ b/src/shaders/clouds.ts @@ -0,0 +1,114 @@ +import * as THREE from 'three/webgpu'; +import { MeshBasicNodeMaterial } from 'three/webgpu'; +import { + vec3, vec4, float, + uniform as tslUniform, + positionLocal, positionWorld, + dot, mix, normalize, + smoothstep as tslSmoothstep, + mx_fractal_noise_float, +} from 'three/tsl'; + +type VolumetricCloudsOptions = { + density?: number; + opacity?: number; + scale?: number; + radius?: number; + position?: THREE.Vector3; + skyColor?: THREE.Color; + cloudColor?: THREE.Color; + fogColor?: THREE.Color; +}; + +export class VolumetricClouds { + camera: THREE.Camera; + object: THREE.Mesh; + material: MeshBasicNodeMaterial; + sphereCenterUniform: any; + timeUniform: any; + + constructor(camera: THREE.Camera, options: VolumetricCloudsOptions = {}) { + this.camera = camera; + + const density = options.density ?? 0.4; + const cloudOpacity = options.opacity ?? 0.8; + const scale = options.scale ?? 5.0; + const radius = options.radius ?? 800; + const position = options.position ?? new THREE.Vector3(0, 0, 0); + + // Fit geometry inside frustum; compensate noise so pattern matches original radius + const cameraFar = (camera as THREE.PerspectiveCamera).far ?? 1000; + const geometryRadius = Math.min(radius, cameraFar * 0.9); + const noiseCompensation = radius / geometryRadius; + + const skyColor = options.skyColor ?? new THREE.Color(0.53, 0.81, 0.92); + const cloudColor = options.cloudColor ?? new THREE.Color(1.0, 1.0, 1.0); + const fogColor = options.fogColor ?? new THREE.Color(0.75, 0.82, 0.92); + + // Dynamic uniforms + this.timeUniform = tslUniform(0.0); + this.sphereCenterUniform = tslUniform(position.clone()); + + // Static TSL values + const densityThreshold = float(density); + const opacityVal = float(cloudOpacity); + const scaleVal = float(scale); + const skyCol = vec3(skyColor.r, skyColor.g, skyColor.b); + const cloudCol = vec3(cloudColor.r, cloudColor.g, cloudColor.b); + const fogCol = vec3(fogColor.r, fogColor.g, fogColor.b); + + const noiseInput = positionLocal.mul(float(noiseCompensation)).mul(float(0.05).div(scaleVal)) + .add(vec3(this.timeUniform.mul(0.02), 0, 0)); + + const rawDensity = mx_fractal_noise_float( + noiseInput, 4, float(2.0), float(0.5) + ).mul(0.5).add(0.5); + + const d = tslSmoothstep(densityThreshold, densityThreshold.add(0.3), rawDensity); + + // Height factor + const dir = normalize(positionWorld.sub(this.sphereCenterUniform)); + const heightFactor = dot(dir, vec3(0, 1, 0)); + + const horizonBlend = float(1).sub(tslSmoothstep(float(-0.05), float(0.25), heightFactor)); + const cloudFade = tslSmoothstep(float(0), float(0.2), heightFactor); + const fadedDensity = d.mul(cloudFade); + + // Color + const baseColor = mix(skyCol, cloudCol, fadedDensity); + const finalColor = mix(baseColor, fogCol, horizonBlend); + + // Alpha + const baseAlpha = float(0.15); + const finalAlpha = mix(baseAlpha.add(fadedDensity.mul(opacityVal)), float(1.0), horizonBlend); + + // --- Material --- + this.material = new MeshBasicNodeMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + side: THREE.DoubleSide, + }); + + this.material.fragmentNode = vec4(finalColor, finalAlpha); + + const geometry = new THREE.SphereGeometry(geometryRadius, 32, 32); + + this.object = new THREE.Mesh(geometry, this.material); + this.object.frustumCulled = false; + this.object.renderOrder = -1; + this.object.castShadow = false; + this.object.receiveShadow = false; + + this.object.position.copy(position); + } + + update(dt?: number) { + // Uncomment to animate clouds: + // this.timeUniform.value += 0.01; + + this.object.position.x = this.camera.position.x; + this.object.position.z = this.camera.position.z; + this.sphereCenterUniform.value.copy(this.object.position); + } +} \ No newline at end of file diff --git a/src/shaders/grass.ts b/src/shaders/grass.ts index ca97e87..e6dd5af 100644 --- a/src/shaders/grass.ts +++ b/src/shaders/grass.ts @@ -1,30 +1,13 @@ -/** - * GrassShader.js — Blade geometry with lazy jittered-grid sampling - * - * Supports loaded GLB clump models or procedural single blades. - * No alpha texture, no transparency overdraw. - * - * Chunked mode: - * - Init: bucket source mesh triangles into grid cells (fast) - * - Runtime: generate grass via jittered grid on first cell activation, then cache - * - Uniform distribution guaranteed by grid — no random clustering - * - * Usage: - * const assets = await GrassShader.loadAssets({ - * modelPath: '/models/grassClump.glb', - * noisePath: '/textures/perlinnoise.webp', - * }); - * - * const grass = new GrassShader(roughMesh, assets, { - * density: 10, - * renderDistance: 50, - * cellSize: 5, - * }); - * scene.add(grass.object); - * grass.update(dt, camera); - */ - -import * as THREE from 'three'; +import * as THREE from 'three/webgpu'; +import { MeshLambertNodeMaterial } from 'three/webgpu'; +import { + vec2, vec3, vec4, float, + uv, texture, uniform as tslUniform, + positionWorld, cameraPosition, + modelWorldMatrix, + normalize, dot, mix, smoothstep as tslSmoothstep, + luminance +} from 'three/tsl'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js'; @@ -48,10 +31,130 @@ export type GrassShaderOptions = { tipColor1?: THREE.ColorRepresentation, tipColor2?: THREE.ColorRepresentation, layer?: number, - cellSize?: number - maxNewCellsPerFrame?: number - density?: number + cellSize?: number, + maxNewCellsPerFrame?: number, + density?: number, + terrainTexture?: THREE.Texture, + /** Tint multiplied with the terrain texture. Ignored if terrainTintNode is given. */ + terrainTint?: THREE.ColorRepresentation, + /** Existing TSL uniform node — pass the SAME node your terrain material uses + * so tint changes propagate to both in lockstep. */ + terrainTintNode?: ReturnType, + /** 0 = authored base/tip colors, 1 = fully ground-matched. Default 1. */ + groundMatch?: number, + /** Root darkening / tip brightening applied to the sampled ground color. */ + rootDarken?: number, // default 0.6 + tipBrighten?: number, // default 1.3 + /** Mip level to sample the terrain texture at. Higher = blurrier = + * smoother grass color. 0 = full detail. Try 4-7. */ + terrainBlur?: number, } + +type TerrainBounds = { minX: number, minZ: number, sizeX: number, sizeZ: number }; + +function createClumpGeometry(opts: { + bladeCount?: number; + radius?: number; + bladeWidth?: number; + bladeHeight?: number; + heightVariation?: number; + lean?: number; +} = {}) { + const { + bladeCount = 30, + radius = 0.15, + bladeWidth = 0.025, + bladeHeight = 0.08, + heightVariation = 0.4, + lean = 0.3, + } = opts; + + const singleBlade = createBladeGeometry(); + singleBlade.scale(bladeWidth, bladeHeight, bladeWidth); + + const positions: number[] = []; + const normals: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + + const mat = new THREE.Matrix4(); + const quat = new THREE.Quaternion(); + const euler = new THREE.Euler(); + const pos = new THREE.Vector3(); + const scale = new THREE.Vector3(); + const tempVec = new THREE.Vector3(); + const tempNorm = new THREE.Vector3(); + const normalMatrix = new THREE.Matrix3(); + + const bladePos = singleBlade.attributes.position; + const bladeNorm = singleBlade.attributes.normal; + const bladeUV = singleBlade.attributes.uv; + const bladeIdx = singleBlade.index!; + + for (let i = 0; i < bladeCount; i++) { + // // Random position within the clump radius + // const angle = Math.random() * Math.PI * 2; + // const dist = Math.sqrt(Math.random()) * radius; + // pos.set(Math.cos(angle) * dist, 0, Math.sin(angle) * dist); + // Sunflower spiral: even distribution within a circle + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const t = i / bladeCount; + const angle = i * goldenAngle; + const dist = Math.sqrt(t) * radius; + // Small jitter to avoid looking too uniform + const jitter = radius * 0.02; + pos.set( + Math.cos(angle) * dist + (Math.random() - 0.5) * jitter, + 0, + Math.sin(angle) * dist + (Math.random() - 0.5) * jitter + ); + + // Random Y rotation + const yRot = Math.random() * Math.PI * 2; + + // Random lean + const leanAmount = (Math.random() - 0.5) * 2 * lean; + const leanDir = Math.random() * Math.PI * 2; + + euler.set(Math.sin(leanDir) * leanAmount, yRot, Math.cos(leanDir) * leanAmount); + quat.setFromEuler(euler); + + // Random height + const hVar = 1.0 + (Math.random() - 0.5) * 2 * heightVariation; + scale.set(1, hVar, 1); + + mat.compose(pos, quat, scale); + normalMatrix.getNormalMatrix(mat); + + // Append transformed vertices + const vertexOffset = positions.length / 3; + + for (let v = 0; v < bladePos.count; v++) { + tempVec.fromBufferAttribute(bladePos, v).applyMatrix4(mat); + positions.push(tempVec.x, tempVec.y, tempVec.z); + + tempNorm.fromBufferAttribute(bladeNorm, v).applyMatrix3(normalMatrix).normalize(); + normals.push(tempNorm.x, tempNorm.y, tempNorm.z); + + uvs.push(bladeUV.getX(v), bladeUV.getY(v)); + } + + // Append offset indices + for (let j = 0; j < bladeIdx.count; j++) { + indices.push(bladeIdx.getX(j) + vertexOffset); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geo.setIndex(indices); + + singleBlade.dispose(); + return geo; +} + /** * Blade geometry (procedural fallback) */ @@ -83,122 +186,232 @@ function createBladeGeometry() { return geo; } -function createBladeMaterial(noiseTexture: GrassAssets['noiseTexture'], opts: GrassShaderOptions = {}) { - const uniforms = { - uGrassLightIntensity: { value: opts.lightIntensity ?? 1.0 }, - uNoiseScale: { value: opts.noiseScale ?? 1.5 }, - uRenderDistance: { value: opts.renderDistance ?? 0.0 }, - baseColor: { value: new THREE.Color(opts.baseColor ?? '#3a5a20') }, - tipColor1: { value: new THREE.Color(opts.tipColor1 ?? '#6a9a45') }, - tipColor2: { value: new THREE.Color(opts.tipColor2 ?? '#4a7a30') }, +function createBladeMaterial( + noiseTexture: GrassAssets['noiseTexture'], + opts: GrassShaderOptions = {}, + terrainBounds: TerrainBounds | null = null, +) { + // Uniforms — these have .value so the GrassShader setters still work + const uniforms: Record = { + baseColor: tslUniform(new THREE.Color(opts.baseColor ?? '#3a5a20')), + tipColor1: tslUniform(new THREE.Color(opts.tipColor1 ?? '#6a9a45')), + tipColor2: tslUniform(new THREE.Color(opts.tipColor2 ?? '#4a7a30')), + uGrassLightIntensity: tslUniform(opts.lightIntensity ?? 1.0), + uNoiseScale: tslUniform(opts.noiseScale ?? 1.5), + uRenderDistance: tslUniform(opts.renderDistance ?? 0.0), noiseTexture: { value: noiseTexture }, }; - const material = new THREE.MeshLambertMaterial({ + const material = new MeshLambertNodeMaterial({ side: THREE.DoubleSide, transparent: true, + alphaTest: 0.01, }); - material.customProgramCacheKey = () => 'grass-blade-material'; - material.onBeforeCompile = (shader) => { - shader.uniforms.uTipColor1 = uniforms.tipColor1; - shader.uniforms.uTipColor2 = uniforms.tipColor2; - shader.uniforms.uBaseColor = uniforms.baseColor; - shader.uniforms.uGrassLightIntensity = uniforms.uGrassLightIntensity; - shader.uniforms.uNoiseScale = uniforms.uNoiseScale; - shader.uniforms.uNoiseTexture = uniforms.noiseTexture; - shader.uniforms.uRenderDistance = uniforms.uRenderDistance; - - // ---- Vertex shader ---- - - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ `#include - uniform sampler2D uNoiseTexture; - uniform float uNoiseScale; - uniform float uRenderDistance; - varying vec2 vBladeUV; - varying vec2 vGlobalUV; - varying float vDistanceFade; - `, - ); - - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ ` - #ifdef USE_INSTANCING - vec4 grassWorldPos = modelMatrix * instanceMatrix * vec4(transformed, 1.0); - #else - vec4 grassWorldPos = modelMatrix * vec4(transformed, 1.0); - #endif - - float terrainSize = 100.0; - vGlobalUV = (terrainSize - grassWorldPos.xz) / terrainSize; - vBladeUV = uv; - - if (uRenderDistance > 0.0) { - float distToCam = length(grassWorldPos.xz - cameraPosition.xz); - vDistanceFade = 1.0 - smoothstep(uRenderDistance * 0.6, uRenderDistance, distToCam); - } else { - vDistanceFade = 1.0; - } + // --- Color: gradient from base (bottom) to tip (top) --- + const bladeUV = uv(); + // const grassColor = mix(uniforms.baseColor, uniforms.tipColor1, float(1.0).sub(bladeUV.y)); + // const grassColor = mix(uniforms.tipColor1, uniforms.baseColor, float(1.0).sub(bladeUV.y)); + const authoredColor = mix(uniforms.tipColor1, uniforms.baseColor, float(1.0).sub(bladeUV.y)); + + let grassColor: any = authoredColor; + + if (opts.terrainTexture && terrainBounds) { + // Tint uniform: share the terrain material's node if provided, + // otherwise own one (settable via GrassShader.terrainTint). + uniforms.uTerrainTint = opts.terrainTintNode + ?? tslUniform(new THREE.Color(opts.terrainTint ?? '#ffffff')); + uniforms.uGroundMatch = tslUniform(opts.groundMatch ?? 1.0); + + // World XZ → terrain UV, derived from the source mesh's world bbox. + // NOTE: assumes a standard planar unwrap (u→+x, v→+z). If your terrain + // UVs are flipped (your old GLSL used (size - pos)/size), invert with + // float(1.0).sub(...) per axis here. + const terrainUVNode = positionWorld.xz + .sub(vec2(terrainBounds.minX, terrainBounds.minZ)) + .div(vec2(terrainBounds.sizeX, terrainBounds.sizeZ)); + + // Keep a handle on the texture node so the texture can be hot-swapped. + // const terrainTexNode = texture(opts.terrainTexture, terrainUVNode); + // Sample at a high mip level so per-strand detail in the texture + // averages out instead of becoming per-blade color noise. + const terrainTexNode = texture(opts.terrainTexture, terrainUVNode) + .level(float(opts.terrainBlur ?? 5)); + + uniforms.terrainTexture = terrainTexNode; + + // Tinted albedo = what the terrain material shows, minus lighting + // (grass gets lit by the same lights, so don't bake lighting in twice). + // const groundAlbedo = terrainTexNode.rgb.mul(uniforms.uTerrainTint); + // Optionally flatten remaining contrast toward the sample's luminance. + + // @ts-expect-error - RGB does exist, but we should fix this type at some point + const rgb = terrainTexNode.rgb; + // const flat = vec3(luminance(rgb)); + // const groundAlbedo = mix(rgb, flat, 0.3) + // .mul(uniforms.uTerrainTint); + // Flatten remaining contrast toward the texture's average COLOR + // (max mip ≈ single averaged texel) — mixing toward luminance() + // gray desaturates, which reads as dull. + // @ts-expect-error - same swizzle typing gap + const meanColor = texture(opts.terrainTexture, terrainUVNode).level(float(12)).rgb; + const groundAlbedo = mix(rgb, meanColor, 0.3) + .mul(uniforms.uTerrainTint); + + const matchedBase = groundAlbedo.mul(opts.rootDarken ?? 0.6); + const matchedTip = groundAlbedo.mul(opts.tipBrighten ?? 1.3); + const matchedColor = mix(matchedBase, matchedTip, bladeUV.y); + + grassColor = mix(authoredColor, matchedColor, uniforms.uGroundMatch); + } - // Terrain backface culling — hide grass on surfaces facing away from camera - vec3 surfaceNormal = normalize((modelMatrix * instanceMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz); - vec3 viewDir = normalize(cameraPosition - grassWorldPos.xyz); - if (dot(surfaceNormal, viewDir) < 0.0) { - vDistanceFade = 0.0; - } - // Fade out grass too close to camera - float nearDist = length(grassWorldPos.xyz - cameraPosition); - vDistanceFade *= smoothstep(0.5, 2.0, nearDist); + material.colorNode = grassColor.mul(uniforms.uGrassLightIntensity); - #include - `, - ); + // --- Distance fade (XZ plane) --- + const worldPos = positionWorld; + const distXZ: any = worldPos.xz.sub(cameraPosition.xz).length(); + const renderDist = uniforms.uRenderDistance; + const computedFade = float(1.0).sub( + tslSmoothstep(renderDist.mul(0.6), renderDist, distXZ) + ); + // When renderDistance is 0, skip fade (show all grass) + const distFade: any = renderDist.greaterThan(0).select(computedFade, float(1.0)); - // ---- Fragment shader ---- - - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ `#include - uniform vec3 uBaseColor; - uniform vec3 uTipColor1; - uniform vec3 uTipColor2; - uniform sampler2D uNoiseTexture; - uniform float uNoiseScale; - uniform float uGrassLightIntensity; - varying vec2 vBladeUV; - varying vec2 vGlobalUV; - varying float vDistanceFade; - `, - ); - - // Override normal to point upward for correct shadow receiving - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ `#include - normal = vec3(0.0, 1.0, 0.0); - `, - ); + // --- Backface culling: hide grass on terrain facing away from camera --- + const surfaceNormal: any = normalize(modelWorldMatrix.mul(vec4(0, 1, 0, 0)).xyz); + const viewDir: any = normalize(cameraPosition.sub(worldPos)); + const backfaceFade = dot(surfaceNormal, viewDir).greaterThan(0).select(float(1.0), float(0.0)); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ ` - if (vDistanceFade < 0.01) discard; + // --- Near-camera fade: prevent grass popping at close range --- + const nearDist: any = worldPos.sub(cameraPosition).length(); + const nearFade = tslSmoothstep(float(0.5), float(2.0), nearDist); - vec4 grassVariation = texture2D(uNoiseTexture, vGlobalUV * uNoiseScale); - // vec3 tipColor = mix(uTipColor1, uTipColor2, grassVariation.r); + // --- Combined opacity --- + material.opacityNode = distFade.mul(nearFade).mul(backfaceFade); - diffuseColor.rgb = mix(uBaseColor, uTipColor1, 1.0 - vBladeUV.y) * uGrassLightIntensity; - diffuseColor.a = vDistanceFade; - `, - ); - }; + // --- Normal override: point upward for consistent shadow receiving --- + material.normalNode = vec3(0, 1, 0); return { material, uniforms }; } +// function createBladeMaterial(noiseTexture: GrassAssets['noiseTexture'], opts: GrassShaderOptions = {}) { +// const uniforms = { +// uGrassLightIntensity: { value: opts.lightIntensity ?? 1.0 }, +// uNoiseScale: { value: opts.noiseScale ?? 1.5 }, +// uRenderDistance: { value: opts.renderDistance ?? 0.0 }, +// baseColor: { value: new THREE.Color(opts.baseColor ?? '#3a5a20') }, +// tipColor1: { value: new THREE.Color(opts.tipColor1 ?? '#6a9a45') }, +// tipColor2: { value: new THREE.Color(opts.tipColor2 ?? '#4a7a30') }, +// noiseTexture: { value: noiseTexture }, +// }; + +// const material = new THREE.MeshLambertMaterial({ +// side: THREE.DoubleSide, +// transparent: true, +// }); + +// material.customProgramCacheKey = () => 'grass-blade-material'; +// material.onBeforeCompile = (shader) => { +// shader.uniforms.uTipColor1 = uniforms.tipColor1; +// shader.uniforms.uTipColor2 = uniforms.tipColor2; +// shader.uniforms.uBaseColor = uniforms.baseColor; +// shader.uniforms.uGrassLightIntensity = uniforms.uGrassLightIntensity; +// shader.uniforms.uNoiseScale = uniforms.uNoiseScale; +// shader.uniforms.uNoiseTexture = uniforms.noiseTexture; +// shader.uniforms.uRenderDistance = uniforms.uRenderDistance; + +// // ---- Vertex shader ---- + +// shader.vertexShader = shader.vertexShader.replace( +// '#include ', +// /* glsl */ `#include +// uniform sampler2D uNoiseTexture; +// uniform float uNoiseScale; +// uniform float uRenderDistance; +// varying vec2 vBladeUV; +// varying vec2 vGlobalUV; +// varying float vDistanceFade; +// `, +// ); + +// shader.vertexShader = shader.vertexShader.replace( +// '#include ', +// /* glsl */ ` +// #ifdef USE_INSTANCING +// vec4 grassWorldPos = modelMatrix * instanceMatrix * vec4(transformed, 1.0); +// #else +// vec4 grassWorldPos = modelMatrix * vec4(transformed, 1.0); +// #endif + +// float terrainSize = 100.0; +// vGlobalUV = (terrainSize - grassWorldPos.xz) / terrainSize; +// vBladeUV = uv; + +// if (uRenderDistance > 0.0) { +// float distToCam = length(grassWorldPos.xz - cameraPosition.xz); +// vDistanceFade = 1.0 - smoothstep(uRenderDistance * 0.6, uRenderDistance, distToCam); +// } else { +// vDistanceFade = 1.0; +// } + +// // Terrain backface culling — hide grass on surfaces facing away from camera +// vec3 surfaceNormal = normalize((modelMatrix * instanceMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz); +// vec3 viewDir = normalize(cameraPosition - grassWorldPos.xyz); +// if (dot(surfaceNormal, viewDir) < 0.0) { +// vDistanceFade = 0.0; +// } +// // Fade out grass too close to camera +// float nearDist = length(grassWorldPos.xyz - cameraPosition); +// vDistanceFade *= smoothstep(0.5, 2.0, nearDist); + +// #include +// `, +// ); + +// // ---- Fragment shader ---- + +// shader.fragmentShader = shader.fragmentShader.replace( +// '#include ', +// /* glsl */ `#include +// uniform vec3 uBaseColor; +// uniform vec3 uTipColor1; +// uniform vec3 uTipColor2; +// uniform sampler2D uNoiseTexture; +// uniform float uNoiseScale; +// uniform float uGrassLightIntensity; +// varying vec2 vBladeUV; +// varying vec2 vGlobalUV; +// varying float vDistanceFade; +// `, +// ); + +// // Override normal to point upward for correct shadow receiving +// shader.fragmentShader = shader.fragmentShader.replace( +// '#include ', +// /* glsl */ `#include +// normal = vec3(0.0, 1.0, 0.0); +// `, +// ); + +// shader.fragmentShader = shader.fragmentShader.replace( +// '#include ', +// /* glsl */ ` +// if (vDistanceFade < 0.01) discard; + +// vec4 grassVariation = texture2D(uNoiseTexture, vGlobalUV * uNoiseScale); +// // vec3 tipColor = mix(uTipColor1, uTipColor2, grassVariation.r); + +// diffuseColor.rgb = mix(uBaseColor, uTipColor1, 1.0 - vBladeUV.y) * uGrassLightIntensity; +// diffuseColor.a = vDistanceFade; +// `, +// ); +// }; + +// return { material, uniforms }; +// } + + /* ================================================================== */ /* GrassShader */ /* ================================================================== */ @@ -230,7 +443,7 @@ export class GrassShader { } _uniforms: Record; - _material: THREE.MeshLambertMaterial; + _material: MeshLambertNodeMaterial; _geo: THREE.BufferGeometry; _heightVariation: number; _lean: number; @@ -251,7 +464,40 @@ export class GrassShader { const bladeWidth = opts.bladeWidth ?? 0.025; const bladeHeight = opts.bladeHeight ?? 0.08; - const { material, uniforms } = createBladeMaterial(assets.noiseTexture, opts); + // const { material, uniforms } = createBladeMaterial(assets.noiseTexture, opts); + // World matrix is needed before material creation now (bbox for UV mapping) + sourceMesh.updateWorldMatrix(true, false); + this._worldMatrix = sourceMesh.matrixWorld.clone(); + + let terrainBounds: TerrainBounds | null = null; + // Auto-detect texture/tint from the source mesh's material if not provided + const srcMat = Array.isArray(sourceMesh.material) + ? sourceMesh.material[0] + : sourceMesh.material; + const terrainTexture = opts.terrainTexture + ?? (srcMat as THREE.MeshStandardMaterial)?.map + ?? undefined; + const terrainTint = opts.terrainTint + ?? (srcMat as THREE.MeshStandardMaterial)?.color + ?? undefined; + + if (terrainTexture) { + const bbox = new THREE.Box3().setFromObject(sourceMesh); + terrainBounds = { + minX: bbox.min.x, + minZ: bbox.min.z, + sizeX: Math.max(bbox.max.x - bbox.min.x, 1e-6), + sizeZ: Math.max(bbox.max.z - bbox.min.z, 1e-6), + }; + } + + // const { material, uniforms } = createBladeMaterial(assets.noiseTexture, opts, terrainBounds); + const { material, uniforms } = createBladeMaterial( + assets.noiseTexture, + { ...opts, terrainTexture, terrainTint }, + terrainBounds, + ); + this._uniforms = uniforms; this._material = material; this._shadows = opts.shadows ?? true; @@ -260,18 +506,27 @@ export class GrassShader { const scaleXZ = opts.scaleXZ ?? 1; const scaleY = opts.scaleY ?? 1; - if (assets.geometry) { - this._geo = assets.geometry.clone(); - this._geo.scale(scaleXZ, scaleY, scaleXZ); - } else { - this._geo = createBladeGeometry(); - this._geo.scale(bladeWidth * scaleXZ, bladeHeight * scaleY, bladeWidth * scaleXZ); - } + // if (assets.geometry) { + // this._geo = assets.geometry.clone(); + // this._geo.scale(scaleXZ, scaleY, scaleXZ); + // } else { + // this._geo = createBladeGeometry(); + // this._geo.scale(bladeWidth * scaleXZ, bladeHeight * scaleY, bladeWidth * scaleXZ); + // } + this._heightVariation = opts.heightVariation ?? 0.4; this._lean = opts.lean ?? 0.3; this._layer = opts.layer; + this._geo = createClumpGeometry({ + bladeCount: 30, + radius: 0.15, + bladeWidth: bladeWidth * scaleXZ, + bladeHeight: bladeHeight * scaleY, + heightVariation: this._heightVariation, + lean: this._lean, + }); this._cellSize = opts.cellSize ?? 5; this._maxNewPerFrame = opts.maxNewCellsPerFrame ?? 15; @@ -283,9 +538,9 @@ export class GrassShader { this._cellCache = new Map(); this._cellTriangles = new Map(); - sourceMesh.updateWorldMatrix(true, false); + // sourceMesh.updateWorldMatrix(true, false); - this._worldMatrix = sourceMesh.matrixWorld.clone(); + // this._worldMatrix = sourceMesh.matrixWorld.clone(); this._initChunked(sourceMesh); } @@ -304,6 +559,22 @@ export class GrassShader { set tipColor2(hex: number) { this._uniforms.tipColor2.value.set(hex); } set lightIntensity(v: number) { this._uniforms.uGrassLightIntensity.value = v; } set noiseScale(v: number) { this._uniforms.uNoiseScale.value = v; } + /** Update the tint (no-op if ground matching wasn't enabled). If you passed + * terrainTintNode, prefer setting that shared node's .value directly. */ + set terrainTint(color: THREE.ColorRepresentation) { + this._uniforms.uTerrainTint?.value.set(color); + } + + /** 0 = authored colors, 1 = fully ground-matched. Animatable. */ + set groundMatch(v: number) { + if (this._uniforms.uGroundMatch) this._uniforms.uGroundMatch.value = v; + } + + /** Hot-swap the terrain texture the grass samples from. */ + setTerrainTexture(tex: THREE.Texture) { + if (!this._uniforms.terrainTexture) return; + this._uniforms.terrainTexture.value = tex; + } dispose() { this._geo.dispose(); @@ -607,6 +878,7 @@ export class GrassShader { const mesh = new THREE.InstancedMesh(this._geo, this._material, count); mesh.receiveShadow = this._shadows; mesh.frustumCulled = true; + mesh.renderOrder = -1; if (this._layer !== undefined) mesh.layers.set(this._layer); return mesh; } diff --git a/src/shaders/index.ts b/src/shaders/index.ts index 67f82e3..de6fdd0 100644 --- a/src/shaders/index.ts +++ b/src/shaders/index.ts @@ -1,10 +1,12 @@ // Shaders +export * from '@/shaders/clouds'; export * from '@/shaders/grass'; export * from '@/shaders/grassFlat'; export * from '@/shaders/sand'; export * from '@/shaders/slopeGrid'; export * from '@/shaders/target'; -export * from '@/shaders/water'; -export * from '@/shaders/river'; +export * from '@/shaders/water/index'; +export * from '@/shaders/water/lake'; +export * from '@/shaders/water/river'; export * from '@/shaders/yardage'; \ No newline at end of file diff --git a/src/shaders/putting.ts b/src/shaders/putting.ts new file mode 100644 index 0000000..11de6d2 --- /dev/null +++ b/src/shaders/putting.ts @@ -0,0 +1,461 @@ +import * as THREE from 'three/webgpu'; +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import type { Node } from 'three/webgpu'; +import { + vec2, vec3, vec4, float, + uniform as tslUniform, + positionWorld, materialColor, + smoothstep as tslSmoothstep, mix, + fwidth, + Fn, Discard, +} from 'three/tsl'; +import { type ShotPerspectiveCamera } from '@/camera'; + +type FloatNode = Node<'float'>; +type Vec2Node = Node<'vec2'>; + +export type PuttingGridMaterialOptions = { + /** Hole world position (required for cup cutout) */ + holeWorldPos?: THREE.Vector3, + /** Grid cell size in meters (default 1.0) */ + gridSize?: number, + /** Grid line thickness in meters (default 0.025) */ + lineWidth?: number, + /** Grid line color (default warm white) */ + lineColor?: THREE.Color, + /** Grid line opacity 0..1 (default 0.3) */ + lineOpacity?: number, + /** Dot sphere radius in meters (default 0.015) */ + dotRadius?: number, + /** Dot color (default white) */ + dotColor?: THREE.Color, + dotOpacity?: number, + /** Base speed multiplier — scaled by slope (default 5) */ + baseSpeed?: number, + /** Minimum dot speed in m/s (default 0.02) */ + minSpeed?: number, +}; + +// --------------------------------------------------------------------------- +// Edge timing: one moving dot between two adjacent grid intersections +// --------------------------------------------------------------------------- +interface EdgeTiming { + start: THREE.Vector3; + end: THREE.Vector3; + slope: number; + duration: number; // base duration (slope-derived, before compensation) + valid: boolean; +} + +// --------------------------------------------------------------------------- +// Pristine Grid — one axis +// --------------------------------------------------------------------------- +const pristineGridAxis = (coord: FloatNode, uvLW: FloatNode): FloatNode => { + const gridDist = coord.fract().mul(2.0).sub(1.0).abs().oneMinus(); + const dd = fwidth(coord); + const drawW = uvLW.max(dd.mul(2.0)); + const aa = dd.mul(1.5); + const mask = tslSmoothstep(drawW.add(aa), drawW.sub(aa), gridDist) + .mul(uvLW.div(drawW).clamp(0.3, 1.0)); + const moire = dd.mul(2.0).sub(1.0).clamp(0.0, 1.0); + return mix(mask, uvLW, moire); +}; + +// --------------------------------------------------------------------------- +// PuttingGridMaterial +// --------------------------------------------------------------------------- +export class PuttingGridMaterial { + fadeSpeed = 6.0; + + readonly gridAngleUniform = tslUniform(0.0); + readonly intensityUniform = tslUniform(0.0); + readonly cellSizeVUniform = tslUniform(1.0); + readonly holePosUniform = tslUniform(new THREE.Vector3()); + readonly cameraPosUniform = tslUniform(new THREE.Vector3()); + + material?: MeshStandardNodeMaterial; + dotsMesh?: THREE.InstancedMesh; + + private targetIntensity = 1.0; + private currentIntensity = 0.0; + private elapsed = 0; + private edges: EdgeTiming[] = []; + private mesh: THREE.Mesh | null = null; + private gridCenter = new THREE.Vector3(); + private currentAngle = 0; + private compression = 1.0; + private gridSize: number; + private lineWidth: number; + private lineColor: THREE.Color; + private lineOpacity: number; + private dotRadius: number; + private dotColor: THREE.Color; + private dotOpacity: number; + private baseSpeed: number; + private minSpeed: number; + + constructor(object: THREE.Object3D, options: PuttingGridMaterialOptions = {}) { + this.gridSize = options.gridSize ?? 0.7; + this.lineWidth = options.lineWidth ?? 0.025; + this.lineColor = options.lineColor ?? new THREE.Color(1.0, 0.9, 0.02); + this.lineOpacity = options.lineOpacity ?? 0.07; + this.dotRadius = options.dotRadius ?? 0.015; + this.dotColor = options.dotColor ?? new THREE.Color(1.0, 1.0, 1.0); + this.dotOpacity = options.dotOpacity ?? 0.8; + this.baseSpeed = options.baseSpeed ?? 5; + this.minSpeed = options.minSpeed ?? 0.05; + + if (options.holeWorldPos) { + this.holePosUniform.value.set(options.holeWorldPos.x, 0, options.holeWorldPos.z); + } + + if (!(object instanceof THREE.Mesh)) return; + this.mesh = object; + + this.setupMaterial(object); + this.buildGrid(0); + } + + // ----------------------------------------------------------------- + // Grid line shader (Pristine Grid — lines only, no dots) + // ----------------------------------------------------------------- + private setupMaterial(object: THREE.Mesh) { + const origMat = object.material as THREE.MeshStandardMaterial; + const mat = new MeshStandardNodeMaterial(); + + if (origMat.color) mat.color = origMat.color.clone(); + if (origMat.map) mat.map = origMat.map; + if (origMat.normalMap) mat.normalMap = origMat.normalMap; + mat.roughness = origMat.roughness ?? 1.0; + mat.metalness = origMat.metalness ?? 0.0; + if (origMat.roughnessMap) mat.roughnessMap = origMat.roughnessMap; + if (origMat.metalnessMap) mat.metalnessMap = origMat.metalnessMap; + if (origMat.emissive) mat.emissive = origMat.emissive.clone(); + if (origMat.emissiveMap) mat.emissiveMap = origMat.emissiveMap; + mat.emissiveIntensity = origMat.emissiveIntensity ?? 1.0; + if (origMat.aoMap) mat.aoMap = origMat.aoMap; + mat.aoMapIntensity = origMat.aoMapIntensity ?? 1.0; + mat.envMapIntensity = origMat.envMapIntensity ?? 1.0; + if (origMat.lightMap) mat.lightMap = origMat.lightMap; + mat.lightMapIntensity = origMat.lightMapIntensity ?? 1.0; + mat.side = origMat.side; + mat.toneMapped = origMat.toneMapped; + if (origMat.normalScale) mat.normalScale = origMat.normalScale.clone(); + + const cellSizeU = float(this.gridSize); + const uvLW = float(this.lineWidth / this.gridSize); + const lineColorRGB = vec3(this.lineColor.r, this.lineColor.g, this.lineColor.b); + const lineOpacityU = float(this.lineOpacity); + + const xz = positionWorld.xz; + + const rotate2D = (v: Vec2Node, angle: FloatNode): Vec2Node => { + const c = angle.cos(); + const s = angle.sin(); + return vec2( + v.x.mul(c).sub(v.y.mul(s)), + v.x.mul(s).add(v.y.mul(c)), + ); + }; + + const xzGrid = rotate2D(xz, this.gridAngleUniform); + const uvU = xzGrid.x.div(cellSizeU); + const uvV = xzGrid.y.div(this.cellSizeVUniform); + + const lineU = pristineGridAxis(uvU, uvLW); + const lineV = pristineGridAxis(uvV, uvLW); + const grid = lineU.add(lineV).sub(lineU.mul(lineV)); + + const gridBlend = grid.mul(lineOpacityU).mul(this.intensityUniform); + + // @ts-expect-error -- @types/three 0.184: materialColor is bare MaterialNode + const baseColor = materialColor.rgb; + // const color = mix(baseColor, lineColorRGB, gridBlend); + const color = baseColor.add(lineColorRGB.mul(gridBlend)); + + // Distance fade: grid lines fade out between 50m and 60m + const fragDist = positionWorld.sub(this.cameraPosUniform).length(); + const distFade = tslSmoothstep(float(40.0), float(20.0), fragDist); + const fadedColor = mix(baseColor, color, distFade); + + // mat.colorNode = vec4(color, 1.0); + // mat.transparent = false; + // --- Hole cutout (same as TargetShaderMaterial) --- + const holeRadius = float(0.054); + const holeDist = positionWorld.xz.sub(this.holePosUniform.xz).length(); + const g = fwidth(holeDist).clamp(0.0008, 0.02); + const holeMask = tslSmoothstep(holeRadius.sub(g), holeRadius.add(g), holeDist); + + // const finalColor = color; + const finalColor = fadedColor; + mat.colorNode = Fn(() => { + Discard(holeMask.lessThan(0.5)); + // @ts-expect-error - RGB type issue + return vec4(finalColor.rgb, 1.0); + })(); + mat.transparent = false; + + mat.needsUpdate = true; + object.material = mat; + this.material = mat; + } + + // ----------------------------------------------------------------- + // Raycast to find the green surface at a world XZ position + // ----------------------------------------------------------------- + private sampleSurface(x: number, z: number): { point: THREE.Vector3; found: boolean } { + if (!this.mesh) return { point: new THREE.Vector3(x, 0, z), found: false }; + const rc = new THREE.Raycaster( + new THREE.Vector3(x, 100, z), + new THREE.Vector3(0, -1, 0), + ); + const hits = rc.intersectObject(this.mesh, false); + if (hits.length > 0) return { point: hits[0].point.clone(), found: true }; + return { point: new THREE.Vector3(x, 0, z), found: false }; + } + + // ----------------------------------------------------------------- + // Build / rebuild the dot grid at a given angle + compression + // ----------------------------------------------------------------- + buildGrid(angle: number, compression = 1.0) { + if (!this.mesh) return; + + if (this.dotsMesh) { + this.dotsMesh.parent?.remove(this.dotsMesh); + this.dotsMesh.geometry.dispose(); + (this.dotsMesh.material as THREE.Material).dispose(); + this.dotsMesh = undefined; + } + + this.currentAngle = angle; + this.compression = compression; + this.gridAngleUniform.value = angle; + this.edges = []; + this.elapsed = 0; + + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + + const box = new THREE.Box3().setFromObject(this.mesh); + this.gridCenter.copy(box.getCenter(new THREE.Vector3())); + + const gsH = this.gridSize; + const gsV = this.gridSize * compression; + this.cellSizeVUniform.value = gsV; + + let minN = Infinity, maxN = -Infinity; + let minM = Infinity, maxM = -Infinity; + for (let i = 0; i < 8; i++) { + const cx = (i & 1) ? box.max.x : box.min.x; + const cz = (i & 4) ? box.max.z : box.min.z; + const rotX = cx * cosA - cz * sinA; + const rotY = cx * sinA + cz * cosA; + minN = Math.min(minN, rotX / gsH); maxN = Math.max(maxN, rotX / gsH); + minM = Math.min(minM, rotY / gsV); maxM = Math.max(maxM, rotY / gsV); + } + const n0 = Math.floor(minN); + const n1 = Math.ceil(maxN); + const m0 = Math.floor(minM); + const m1 = Math.ceil(maxM); + + const cols = n1 - n0 + 1; + const rows = m1 - m0 + 1; + + const pts: (THREE.Vector3 | null)[][] = []; + for (let mi = 0; mi < rows; mi++) { + pts[mi] = []; + for (let ni = 0; ni < cols; ni++) { + const n = n0 + ni; + const m = m0 + mi; + const wx = n * gsH * cosA + m * gsV * sinA; + const wz = -n * gsH * sinA + m * gsV * cosA; + const s = this.sampleSurface(wx, wz); + pts[mi][ni] = s.found ? s.point : null; + } + } + + const addEdge = (a: THREE.Vector3 | null, b: THREE.Vector3 | null) => { + if (!a || !b) { + this.edges.push({ start: new THREE.Vector3(), end: new THREE.Vector3(), slope: 0, duration: 1, valid: false }); + return; + } + const dy = a.y - b.y; + const start = dy >= 0 ? a : b; + const end = dy >= 0 ? b : a; + const dist = start.distanceTo(end); + const slope = Math.abs(dy) / Math.max(dist, 0.001); + const speed = Math.max(this.minSpeed, this.baseSpeed * slope); + this.edges.push({ + start: start.clone(), + end: end.clone(), + slope, + duration: dist / speed, + valid: true, + }); + }; + + for (let r = 0; r < rows; r++) + for (let c = 0; c < cols - 1; c++) + addEdge(pts[r][c], pts[r][c + 1]); + + for (let r = 0; r < rows - 1; r++) + for (let c = 0; c < cols; c++) + addEdge(pts[r][c], pts[r + 1][c]); + + if (this.edges.length === 0) return; + + const geo = new THREE.SphereGeometry(1, 8, 6); + const dotMat = new THREE.MeshStandardMaterial({ color: this.dotColor, opacity: this.dotOpacity }); + this.dotsMesh = new THREE.InstancedMesh(geo, dotMat, this.edges.length); + this.dotsMesh.frustumCulled = false; + + if (this.mesh.parent) { + this.mesh.parent.add(this.dotsMesh); + } + } + + // ----------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------- + setEnabled(enabled: boolean, immediate = false) { + this.targetIntensity = enabled ? 1.0 : 0.0; + if (immediate) { + this.currentIntensity = this.targetIntensity; + this.intensityUniform.value = this.targetIntensity; + } + if (this.dotsMesh) this.dotsMesh.visible = enabled; + } + setHolePosition(position: THREE.Vector3) { + this.holePosUniform.value.set(position.x, 0, position.z); + } + update(dt: number, camera: ShotPerspectiveCamera) { + this.elapsed += dt; + + // Fade grid lines + const t = 1.0 - Math.exp(-this.fadeSpeed * dt); + this.currentIntensity += (this.targetIntensity - this.currentIntensity) * t; + this.intensityUniform.value = this.currentIntensity; + + // Update grid angle + compression from camera + if (!camera.isTracking && !camera.isAiming) { + const dir = new THREE.Vector3(); + camera.getWorldDirection(dir); + const newAngle = Math.atan2(dir.x, dir.z); + + // const elevation = Math.asin(Math.abs(dir.y)); + // const newCompression = Math.min(Math.pow(1 / Math.max(Math.sin(elevation), 0.17), 0.75), 5); + // Measure the actual perspective compression by projecting a 1m H + // and 1m V edge at the grid center. This accounts for FOV, distance, + // and elevation all at once — no formula to get wrong. + const cosA = Math.cos(newAngle); + const sinA = Math.sin(newAngle); + const c = this.gridCenter; + // const pA = new THREE.Vector3(c.x, c.y, c.z).project(camera); + // const pH = new THREE.Vector3(c.x + cosA, c.y, c.z - sinA).project(camera); + // const pV = new THREE.Vector3(c.x + sinA, c.y, c.z + cosA).project(camera); + // Measure compression between camera and grid center (biased toward + // near cells where foreshortening is most visible). 0.35 = measure + // point is 35% of the way from camera to center. Lower = more + // compression, higher = less. + const mx = camera.position.x + (c.x - camera.position.x) * 0.35; + const mz = camera.position.z + (c.z - camera.position.z) * 0.35; + const pA = new THREE.Vector3(mx, c.y, mz).project(camera); + const pH = new THREE.Vector3(mx + cosA, c.y, mz - sinA).project(camera); + const pV = new THREE.Vector3(mx + sinA, c.y, mz + cosA).project(camera); + + const screenH = Math.sqrt((pH.x - pA.x) ** 2 + (pH.y - pA.y) ** 2); + const screenV = Math.sqrt((pV.x - pA.x) ** 2 + (pV.y - pA.y) ** 2); + const newCompression = screenV > 0.001 + ? Math.min(Math.max(screenH / screenV, 1.0), 5.0) + : this.compression; + + if (Math.abs(newAngle - this.currentAngle) > 0.035 || + Math.abs(newCompression - this.compression) > 0.3) { + this.buildGrid(newAngle, newCompression); + } + } + + // Animate dots with per-edge perspective compensation. + // + // For each edge, project start/end to screen (NDC) and measure the + // screen-space length. Multiply by camera distance to the edge + // midpoint to isolate the ANGULAR compression — this factors out + // the uniform distance-based shrinking (which affects both axes + // equally) and leaves only the anisotropic foreshortening. + // + // Normalize by the maximum so H edges (least compressed) keep their + // base duration, and V edges (more compressed) get proportionally + // shorter durations (faster dots). + this.cameraPosUniform.value.copy(camera.position); + if (!this.dotsMesh) return; + + const mat4 = new THREE.Matrix4(); + const sc = this.dotRadius; + const projA = new THREE.Vector3(); + const projB = new THREE.Vector3(); + + // Consistent screen-space speed: project each edge to screen, + // set duration = screenDist / (targetScreenSpeed * slopeSpeed). + // Same slope = same screen speed, regardless of camera distance, + // angle, FOV, or perspective compression. + const targetScreenSpeed = 0.15; + + for (let i = 0; i < this.edges.length; i++) { + const e = this.edges[i]; + if (!e.valid) { + mat4.makeTranslation(0, -1000, 0); + } else { + projA.copy(e.start).project(camera); + projB.copy(e.end).project(camera); + + if (projA.z > 1 || projB.z > 1) { + mat4.makeTranslation(0, -1000, 0); + this.dotsMesh.setMatrixAt(i, mat4); + continue; + } + + const screenDist = Math.sqrt( + (projB.x - projA.x) ** 2 + (projB.y - projA.y) ** 2, + ); + const slopeSpeed = Math.max(this.minSpeed, this.baseSpeed * e.slope); + const duration = Math.max(screenDist / (targetScreenSpeed * slopeSpeed), 0.3); + // Fade dot size to 0 between 50m and 60m from camera + const dotDist = Math.sqrt( + ((e.start.x + e.end.x) * 0.5 - camera.position.x) ** 2 + + ((e.start.y + e.end.y) * 0.5 - camera.position.y) ** 2 + + ((e.start.z + e.end.z) * 0.5 - camera.position.z) ** 2, + ); + const dotFade = Math.max(0, Math.min(1, (60 - dotDist) / 10)); + if (dotFade <= 0) { + mat4.makeTranslation(0, -1000, 0); + this.dotsMesh.setMatrixAt(i, mat4); + continue; + } + + + const tt = (this.elapsed % duration) / duration; + const px = e.start.x + (e.end.x - e.start.x) * tt; + const py = e.start.y + (e.end.y - e.start.y) * tt; + const pz = e.start.z + (e.end.z - e.start.z) * tt; + // mat4.makeScale(sc, sc, sc); + mat4.makeScale(sc * dotFade, sc * dotFade, sc * dotFade); + mat4.setPosition(px, py, pz); + } + this.dotsMesh.setMatrixAt(i, mat4); + } + this.dotsMesh.instanceMatrix.needsUpdate = true; + } + + dispose() { + if (this.material) { + this.material.dispose(); + this.material = undefined; + } + if (this.dotsMesh) { + this.dotsMesh.parent?.remove(this.dotsMesh); + this.dotsMesh.geometry.dispose(); + (this.dotsMesh.material as THREE.Material).dispose(); + this.dotsMesh = undefined; + } + } +} \ No newline at end of file diff --git a/src/shaders/river.ts b/src/shaders/river.ts deleted file mode 100644 index b19502e..0000000 --- a/src/shaders/river.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as THREE from 'three'; -import { MeshPhysicalNodeMaterial } from 'three/webgpu'; -import { - texture, uv, uniform, - vec2, float, - fract, abs, mix, clamp, pow, sub, dot, normalize, - positionWorld, normalWorld, cameraPosition, - normalMap, -} from 'three/tsl'; -import normals from '@/images/waternormals.jpg'; - -type RiverSurfaceOptions = { - speed?: number; - flowStrength?: number; - uvTiling?: [number, number]; - normalStrength?: number; - shallowColor?: THREE.Color; - deepColor?: THREE.Color; - opacity?: number; - roughness?: number; -}; - -type FlowMapData = { data: ImageDataArray, width: number, height: number }; - -export class RiverSurface { - material: MeshPhysicalNodeMaterial; - water: THREE.Mesh; - speed: number; - private timeUniform: any; - - constructor( - waterObject: THREE.Mesh, - flowMapData?: FlowMapData, - options: RiverSurfaceOptions = {} - ) { - - // Initialize these first, before the TSL setup - this.material = new MeshPhysicalNodeMaterial({ - transparent: true, - side: THREE.DoubleSide, - depthWrite: false, - }); - - // this.mesh = new THREE.Mesh(geometry.clone(), this.material); - this.water = new THREE.Mesh(waterObject.geometry.clone(), this.material); - this.water.position.set(this.water.position.x, this.water.position.y - 0.45, this.water.position.z); - - this.timeUniform = uniform(0); - - // Recompute UVs to [0,1] range for flow map alignment - const pos = this.water.geometry.attributes.position; - const uvAttr = new Float32Array(pos.count * 2); - let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; - - for (let i = 0; i < pos.count; i++) { - const x = pos.getX(i); - const z = pos.getZ(i); - if (x < minX) minX = x; - if (z < minZ) minZ = z; - if (x > maxX) maxX = x; - if (z > maxZ) maxZ = z; - } - - const rangeX = maxX - minX || 1; - const rangeZ = maxZ - minZ || 1; - - for (let i = 0; i < pos.count; i++) { - uvAttr[i * 2] = (pos.getX(i) - minX) / rangeX; - uvAttr[i * 2 + 1] = (pos.getZ(i) - minZ) / rangeZ; - } - - this.water.geometry.setAttribute('uv', new THREE.BufferAttribute(uvAttr, 2)); - console.log('[FLOWMAP] Mesh bounds:', JSON.stringify({ minX, minZ, maxX, maxZ: maxZ })); - - const opts = { - speed: 0.25, - flowStrength: 0.15, - uvTiling: [6, 6] as [number, number], - normalStrength: 1.0, - shallowColor: new THREE.Color('#243f42'), - deepColor: new THREE.Color('#0a3a5c'), - opacity: 0.7, - roughness: 0.15, - ...options, - }; - this.speed = opts.speed; - - // --- Uniforms --- - const flowSpeed = uniform(opts.speed); - const flowStrength = uniform(opts.flowStrength); - const tileSize = Math.min(rangeX, rangeZ) / opts.uvTiling[0]; - const tiling = uniform(new THREE.Vector2(rangeX / tileSize, rangeZ / tileSize)); - - const normStrength = uniform(opts.normalStrength); - - // --- Textures --- - const textureLoader = new THREE.TextureLoader(); - const waterNormalTex = textureLoader.load(normals, (tex) => { - tex.wrapS = tex.wrapT = THREE.RepeatWrapping; - }); - - let flowMapTexture: THREE.DataTexture; - - if (flowMapData) { - flowMapTexture = new THREE.DataTexture( - new Uint8Array(flowMapData.data), - flowMapData.width, - flowMapData.height, - THREE.RGBAFormat - ); - } else { - // Default: uniform flow in -Y direction, full speed - flowMapTexture = new THREE.DataTexture( - new Uint8Array([128, 255, 255, 255]), - 1, 1, - THREE.RGBAFormat - ); - } - - flowMapTexture.needsUpdate = true; - flowMapTexture.minFilter = THREE.LinearFilter; - flowMapTexture.magFilter = THREE.LinearFilter; - - // flowMapTexture.flipY = true; - flowMapTexture.wrapS = flowMapTexture.wrapT = THREE.ClampToEdgeWrapping; - - - const baseUV = uv(); - // Decode flow direction - const flow = texture(flowMapTexture, baseUV).rg - .sub(0.5) - .mul(2.0) - .mul(flowStrength) - .negate(); - - // Speed from blue channel - const speed = texture(flowMapTexture, baseUV).b; - - // --- Dual-phase time (prevents scroll reset pop) --- - // const t = this.timeUniform.mul(flowSpeed).mul(speed); - const t = this.timeUniform.mul(flowSpeed); - - // const t = time.mul(flowSpeed).mul(speed); - const phase0 = fract(t); - const phase1 = fract(t.add(0.5)); - const blend = abs(phase0.mul(2.0).sub(1.0)); // triangle wave 0→1→0 - - // --- Sample water normals at two offset UVs and blend --- - const tiledUV = baseUV.mul(tiling); - const uv0 = tiledUV.add(flow.mul(speed).mul(phase0)); - const uv1 = tiledUV.add(flow.mul(speed).mul(phase1)); - - const n0 = texture(waterNormalTex, uv0); - const n1 = texture(waterNormalTex, uv1); - const blendedNormals = mix(n0, n1, blend); - - // Second layer: smaller ripples, different speed and angle for turbulence - const detailTiling = uniform(new THREE.Vector2(rangeX / tileSize * 2.3, rangeZ / tileSize * 2.3)); - const detailTime = this.timeUniform.mul(0.37); // different speed - const detailPhase0 = fract(detailTime); - const detailPhase1 = fract(detailTime.add(0.5)); - const detailBlend = abs(detailPhase0.mul(2.0).sub(1.0)); - const detailFlow = flow.mul(0.7).add(vec2(0.1, 0.05)); // slightly offset direction - const detailUV = baseUV.mul(detailTiling); - const d0 = texture(waterNormalTex, detailUV.add(detailFlow.mul(detailPhase0))); - const d1 = texture(waterNormalTex, detailUV.add(detailFlow.mul(detailPhase1))); - const detailNormals = mix(d0, d1, detailBlend); - - // Combine both layers - const combinedNormals = mix(blendedNormals, detailNormals, 0.2); - this.material.normalNode = normalMap(combinedNormals, vec2(normStrength)); - - const viewDir = normalize(cameraPosition.sub(positionWorld)); - const NdotV = clamp(dot(normalWorld, viewDir), 0.0, 1.0); - const fresnel = pow(sub(float(1.0), NdotV), float(3.0)); - this.material.opacityNode = clamp( - mix(float(0.6), float(0.95), fresnel), - 0.0, 1.0 - ); - - this.material.color = opts.shallowColor; - this.material.roughness = 0.15; - this.material.metalness = 0.0; - this.material.specularIntensity = 1.0; - this.material.specularColor = new THREE.Color(0xffffff); - this.material.envMapIntensity = 0.5; - - } - - updateEnvironment(envMap: THREE.Texture) { - this.material.envMap = envMap; - this.material.needsUpdate = true; - } - - update(_dt?: number) { - this.timeUniform.value += this.speed / 60.0; - } -} diff --git a/src/shaders/sand.ts b/src/shaders/sand.ts index b90248b..9063441 100644 --- a/src/shaders/sand.ts +++ b/src/shaders/sand.ts @@ -1,223 +1,211 @@ -import * as THREE from 'three'; - -const VERT = /* glsl */ ` - varying float vTint; - - void main() { - // Pull the red channel from the vertex color you painted - vTint = color.r; - - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +// src/fuse/SandBlendMaterial.js +import * as THREE from 'three/webgpu'; +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { + texture as tslTexture, + vec2, + vec3, + vec4, + float, + mix, + smoothstep, + positionWorld, + uniform, + cameraPosition, +} from 'three/tsl'; + +type SurfaceConfig = { + roughnessFactor?: number, + tileSize?: number, + tint?: string, + blending?: { + distance?: number, + noiseFreq?: number, + noiseAmp?: number, + sandNoiseFreq?: number, + sandBaseHeight?: number, + sandLowDarken?: number, + sandVariationStrength?: number, + lipDarken?: number, + dirtTint?: string, + dirtWidth?: number, + dirtStrength?: number, + distanceExaggeration?: number, } -`; - -const FRAG = /* glsl */ ` - uniform vec3 uSandColor; - uniform vec3 uEdgeColor; - uniform float uTintStrength; - - varying float vTint; - - void main() { - // Mix from sand → dark edge based on the red vertex color - float t = clamp(vTint * uTintStrength, 0.0, 1.0); - vec3 finalColor = mix(uSandColor, uEdgeColor, t); - - gl_FragColor = vec4(finalColor, 1.0); - } -`; - - - -function buildVertex(base: string) { - return base - .replace( - 'void main() {', - `attribute float aTint; - varying float vTint; - varying vec2 vDetailUv; - void main() {` - ) - .replace( - '#include ', - `#include - vTint = aTint; - vDetailUv = uv;` - ); } -function buildFragment(base: string) { - return base - .replace( - 'void main() {', - `uniform vec3 uEdgeColor; - uniform float uTintStrength; - uniform sampler2D uDetailMap; - uniform float uDetailScale; - varying float vTint; - varying vec2 vDetailUv; - void main() {` - ) - .replace( - '#include ', - `#include - // float t = clamp(vTint * uTintStrength, 0.0, 1.0); - - // Future: grass-to-sand breakup - // vec4 detail = texture2D(uDetailMap, vDetailUv * uDetailScale); - // gl_FragColor.rgb = mix(gl_FragColor.rgb, detail.rgb, t * detail.a); - - // gl_FragColor.rgb = mix(gl_FragColor.rgb, uEdgeColor, t); - float t = clamp(vTint * uTintStrength, 0.0, 1.0); - t = smoothstep(0.0, 1.0, t); - gl_FragColor.rgb = mix(gl_FragColor.rgb, uEdgeColor, t);` - ); -} -type SandShaderMaterialOptions = { - edgeColor?: THREE.Color, - tintStrength?: number, - exposure?: number +type SandMaterialOptions = { + baseTexture?: THREE.Texture, + neighborTexture?: THREE.Texture, + noiseTexture?: THREE.Texture, + blendMap?: { + data: Uint8Array, + width: number, + height: number, + bounds: { w: number, h: number, x: number, y: number }, + }, + config?: SurfaceConfig, + neighborConfig?: SurfaceConfig, } -export class SandShaderMaterial extends THREE.ShaderMaterial { - - constructor(baseMaterial: THREE.MeshStandardMaterial, options: SandShaderMaterialOptions = {}) { - const map = baseMaterial.map; - const normalMap = baseMaterial.normalMap; - - const { - // edgeColor = new THREE.Color(0.478, 0.463, 0.333), - edgeColor = new THREE.Color('#372813'), - tintStrength = 0.5, - exposure = 1.08, - } = options; - - super({ - uniforms: THREE.UniformsUtils.merge([ - THREE.UniformsLib.lights, - // @ts-expect-error - THREE.UniformsLib.shadowmap, - { - uMap: { value: map }, - uNormalMap: { value: normalMap }, - uNormalScale: { value: baseMaterial.normalScale || new THREE.Vector2(1, 1) }, - uTileScale: { value: map?.repeat?.clone() || new THREE.Vector2(1, 1) }, - uTileOffset: { value: map?.offset?.clone() || new THREE.Vector2(0, 0) }, - uRoughness: { value: baseMaterial.roughness ?? 0.8 }, - uEdgeColor: { value: edgeColor }, - uTintStrength: { value: tintStrength }, - uExposure: { value: exposure }, - directionalShadowMap: { value: [] }, - directionalShadowMatrix: { value: [] }, - pointShadowMap: { value: [] }, - pointShadowMatrix: { value: [] }, - spotShadowMap: { value: [] }, - spotLightMatrix: { value: [] }, - spotLightMap: { value: [] }, - - } - ]), - vertexShader: ` - attribute vec3 color; - varying vec3 vColor; - varying vec2 vUv; - varying vec3 vNormal; - varying vec3 vViewPosition; - - void main() { - vColor = color; - vUv = uv; - vNormal = normalize(normalMatrix * normal); - - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); - vViewPosition = -mvPosition.xyz; - - gl_Position = projectionMatrix * mvPosition; - } - `, - fragmentShader: ` - #include - #include - - uniform sampler2D uMap; - uniform sampler2D uNormalMap; - uniform vec2 uNormalScale; - uniform vec2 uTileScale; - uniform vec2 uTileOffset; - uniform float uRoughness; - uniform vec3 uEdgeColor; - uniform float uTintStrength; - uniform float uExposure; - - varying vec3 vColor; - varying vec2 vUv; - varying vec3 vNormal; - varying vec3 vViewPosition; - - void main() { - vec2 tiledUv = vUv * uTileScale + uTileOffset; - - vec4 texColor = texture2D(uMap, tiledUv); - vec3 normalTex = texture2D(uNormalMap, tiledUv).rgb * 2.0 - 1.0; - normalTex.xy *= uNormalScale; - vec3 normal = normalize(vNormal + normalTex); - - // Ambient from scene - vec3 lighting = ambientLightColor; - - // Directional lights from scene - #if NUM_DIR_LIGHTS > 0 - for (int i = 0; i < NUM_DIR_LIGHTS; i++) { - float diff = max(dot(normal, directionalLights[i].direction), 0.0); - lighting += directionalLights[i].color * diff * RECIPROCAL_PI; - } - #endif - - // Point lights from scene - #if NUM_POINT_LIGHTS > 0 - for (int i = 0; i < NUM_POINT_LIGHTS; i++) { - vec3 lightVec = pointLights[i].position - vViewPosition; - float dist = length(lightVec); - vec3 lightDir = normalize(lightVec); - float diff = max(dot(normal, lightDir), 0.0); - float attenuation = 1.0 / (1.0 + dist * dist * 0.01); - lighting += pointLights[i].color * diff * attenuation * RECIPROCAL_PI; - } - #endif - - // Vertex color tint - float t = 1.0 - min(min(vColor.r, vColor.g), vColor.b); - t = smoothstep(0.0, 1.0, t * uTintStrength); - - vec3 finalColor = mix(texColor.rgb, uEdgeColor, t); - finalColor *= lighting * uExposure; - - gl_FragColor = vec4(finalColor, 1.0); - } - `, - lights: true, +export class SandMaterial { + material; + + constructor( + baseMesh: THREE.Mesh, + noiseTexture: THREE.Texture, + blendMap?: BlendMapData, + neighborMesh?: THREE.Mesh, + blendSettings: SurfaceConfig['blending'] = {} + ) { + const baseMat = baseMesh.material; + if (!(baseMat instanceof THREE.MeshStandardMaterial)) { + throw new Error('SandMaterial requires a MeshStandardMaterial'); + } + const baseTexture = baseMat.map; + const baseTint = baseMat.color || new THREE.Color(1, 1, 1); + const baseTileSize = baseMesh.userData.tileSize || 2.5; + const baseRoughness = baseMat.roughness ?? 0.9; + if (!baseTexture) { + throw new Error('SandMaterial requires a base texture map'); + } + // Build the new node material + this.material = new MeshStandardNodeMaterial({ + transparent: false, }); + this.material.roughness = baseRoughness; + + // Copy normal map if present + if (baseMat.normalMap) { + this.material.normalMap = baseMat.normalMap; + this.material.normalScale = baseMat.normalScale?.clone() || new THREE.Vector2(1, 1); + } + + // Base texture tiled by world position + const baseTiledUV = positionWorld.xz.div(float(baseTileSize)); + const baseColorTex = tslTexture(baseTexture, baseTiledUV); + const baseColor = baseColorTex.mul(vec3(baseTint.r, baseTint.g, baseTint.b)); + + const sandNoiseFreq = float(blendSettings.sandNoiseFreq || 0.15); + const sandNoiseUV1 = positionWorld.xz.mul(sandNoiseFreq); + const sandNoise1 = tslTexture(noiseTexture, sandNoiseUV1).r; + // const sandNoiseUV2 = positionWorld.xz.mul(float(blendSettings.sandNoiseFreq || 0.15).mul(4.0)); + const sandNoiseUV2 = positionWorld.xz.mul(sandNoiseFreq.mul(4.0)); + + const sandNoise2 = tslTexture(noiseTexture, sandNoiseUV2).r; + const sandVariation = sandNoise1.mul(0.6).add(sandNoise2.mul(0.4)); + + const heightRef = float(blendSettings.sandBaseHeight || 0); + const heightFactor = positionWorld.y.sub(heightRef).clamp(-2, 2).div(2.0); + const lowSpotDarken = float(1.0).sub( + float(1.0).sub(heightFactor).clamp(0, 1).mul(float(blendSettings.sandLowDarken || 0.25)) + ); + const sandDarkenAmount = float(1.0).sub( + float(1.0).sub(sandVariation).mul(float(blendSettings.sandVariationStrength || 0.5)) + ); + const finalSand = baseColor.mul(sandDarkenAmount).mul(lowSpotDarken); + + + // ── Combine ── + // const surfaceColor = mix(tintedGrass, finalSand, isSand); + // this.material.colorNode = surfaceColor.mul(lipDarken); + + // ── Edge blending (only if neighbor + blendMap provided) ── + if (blendMap && neighborMesh) { + const neighborMat = neighborMesh.material; + if (!(neighborMat instanceof THREE.MeshStandardMaterial)) { + throw new Error('SandMaterial requires a MeshStandardMaterial'); + } + const neighborTexture = neighborMat.map; + if (!neighborTexture) { + throw new Error('SandMaterial requires neighbors to have a base texture'); + } + const neighborTint = neighborMat.color || new THREE.Color(1, 1, 1); + const neighborTileSize = neighborMesh.userData.tileSize || 2.0; + + const neighborTiledUV = positionWorld.xz.div(float(neighborTileSize)); + const neighborColorTex = tslTexture(neighborTexture, neighborTiledUV); + const neighborColor = neighborColorTex.mul(vec3(neighborTint.r, neighborTint.g, neighborTint.b)); + + const blendTex = new THREE.DataTexture( + new Uint8Array(blendMap.data), + blendMap.width, + blendMap.height, + THREE.RGBAFormat + // THREE.RedFormat, + ); + blendTex.needsUpdate = true; + blendTex.magFilter = THREE.LinearFilter; + blendTex.minFilter = THREE.LinearFilter; + blendTex.generateMipmaps = false; + blendTex.colorSpace = THREE.NoColorSpace; + blendTex.wrapS = THREE.ClampToEdgeWrapping; + blendTex.wrapT = THREE.ClampToEdgeWrapping; + + const boundsX = uniform(blendMap.bounds.x); + const boundsY = uniform(blendMap.bounds.y); + const boundsW = uniform(blendMap.bounds.w); + const boundsH = uniform(blendMap.bounds.h); + + const blendU = positionWorld.x.sub(boundsX).div(boundsW); + const blendV = positionWorld.z.sub(boundsY).div(boundsH); + const blendSample = tslTexture(blendTex, vec2(blendU, blendV)).r; + + const noiseFreq = float(blendSettings.noiseFreq || 0.5); + const noiseAmp = float(blendSettings.noiseAmp || 0.3); + const noiseUV = positionWorld.xz.mul(noiseFreq); + const noiseSample = tslTexture(noiseTexture, noiseUV).r; + + const distFromEdge = blendSample.sub(0.5).mul(2.0).clamp(0, 1); + + const cameraDist = positionWorld.sub(cameraPosition).length(); + const distSoften = smoothstep(float(20.0), float(120.0), cameraDist); + const distScale = smoothstep(float(10.0), float(100.0), cameraDist) + .mul(float(blendSettings.distanceExaggeration || 1.5)) + .add(1.0); + + const noiseUV2 = positionWorld.xz.mul(noiseFreq.mul(3.2)); + const noiseSample2 = tslTexture(noiseTexture, noiseUV2).r; + const combinedNoise = noiseSample.mul(0.6).add(noiseSample2.mul(0.4)); + + const effectiveNoiseAmp = noiseAmp.mul(float(1.0).sub(distSoften.mul(0.7))); + const scaledNoiseAmp = effectiveNoiseAmp.mul(distScale); + const cutoff = combinedNoise.mul(scaledNoiseAmp); + + const transitionWidth = float(0.02).add(distSoften.mul(0.15)); + const isSand = smoothstep(cutoff, cutoff.add(transitionWidth), distFromEdge); + + const dirtColor = vec3( + new THREE.Color(blendSettings.dirtTint || '#5a4a32').r, + new THREE.Color(blendSettings.dirtTint || '#5a4a32').g, + new THREE.Color(blendSettings.dirtTint || '#5a4a32').b, + ); + const dirtWidth = float(blendSettings.dirtWidth || 0.15).mul(distScale); + const dirtAmount = smoothstep(cutoff.sub(dirtWidth), cutoff, distFromEdge) + .mul(float(blendSettings.dirtStrength || 0.5)); + const tintedGrass = mix(neighborColor, vec4(dirtColor, 1.0), dirtAmount); + + const lipWidth = float(0.08).mul(distScale); + const lipStart = cutoff.sub(lipWidth); + const lipEnd = cutoff.add(0.02); + const lipStrength = float(blendSettings.lipDarken || 0.25); + const lipAmount = smoothstep(lipStart, lipEnd, distFromEdge) + .mul(float(1.0).sub(smoothstep(lipEnd, lipEnd.add(0.05), distFromEdge))); + const lipDarken = float(1.0).sub(lipAmount.mul(lipStrength)); + + const surfaceColor = mix(tintedGrass, finalSand, isSand); + this.material.colorNode = surfaceColor.mul(lipDarken); + } else { + this.material.colorNode = finalSand; + } + + // Apply to the mesh + baseMesh.material = this.material; } - get edgeColor() { return this.uniforms.uEdgeColor.value; } - set edgeColor(c) { this.uniforms.uEdgeColor.value = c; } - - get tintStrength() { return this.uniforms.uTintStrength.value; } - set tintStrength(v) { this.uniforms.uTintStrength.value = v; } - - get tileScale() { return this.uniforms.uTileScale.value; } - set tileScale(v) { this.uniforms.uTileScale.value = v; } - - get tileOffset() { return this.uniforms.uTileOffset.value; } - set tileOffset(v) { this.uniforms.uTileOffset.value = v; } - - get normalScale() { return this.uniforms.uNormalScale.value; } - set normalScale(v) { this.uniforms.uNormalScale.value = v; } - - get roughness() { return this.uniforms.uRoughness.value; } - set roughness(v) { this.uniforms.uRoughness.value = v; } - - get exposure() { return this.uniforms.uExposure.value; } - set exposure(v) { this.uniforms.uExposure.value = v; } + dispose() { + this.material.dispose(); + } } + diff --git a/src/shaders/target.ts b/src/shaders/target.ts index 0356cdc..c8c7b9e 100644 --- a/src/shaders/target.ts +++ b/src/shaders/target.ts @@ -1,149 +1,165 @@ import { GolfBall } from '@/objects/golfBall'; -import * as THREE from 'three'; +import * as THREE from 'three/webgpu'; +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { + vec3, vec4, float, + uniform as tslUniform, + positionWorld, materialColor, + smoothstep as tslSmoothstep, mix, max, + fwidth, Fn, Discard, +} from 'three/tsl'; export type TargetShaderMaterialOptions = { gimmeDistances: number[], - ringWidth?: number + ringWidth?: number, + puttingEnabled?: boolean }; +// --- TSL helper: anti-aliased ring outline --- +const ringOutline = Fn(([dist, radius, width]: [any, any, any]) => { + const hw = width.mul(0.5); + const fw = fwidth(dist); + const edge = max(fw, float(0.01)); + const inner = tslSmoothstep(radius.sub(hw).sub(edge), radius.sub(hw).add(edge), dist); + const outer = float(1.0).sub( + tslSmoothstep(radius.add(hw).sub(edge), radius.add(hw).add(edge), dist) + ); + return inner.mul(outer); +}); + +// --- TSL helper: anti-aliased zone fill between two radii --- +const zoneFill = Fn(([dist, lo, hi]: [any, any, any]) => { + const fw = fwidth(dist); + const edge = max(fw, float(0.01)); + const inner = tslSmoothstep(lo.sub(edge), lo.add(edge), dist); + const outer = float(1.0).sub( + tslSmoothstep(hi.sub(edge), hi.add(edge), dist) + ); + return inner.mul(outer); +}); + export class TargetShaderMaterial { holeWorldPos: THREE.Vector3; ringSizes: THREE.Vector3; currentActive: THREE.Vector3; - // higher = faster response, lower = smoother lerpSpeed = 4.0; - customUniforms: Record; - material?: THREE.Material; + holePosUniform: any; + ringActiveUniform: any; + + material?: MeshStandardNodeMaterial; constructor(object: THREE.Object3D, holeWorldPos: THREE.Vector3, options: TargetShaderMaterialOptions) { this.holeWorldPos = holeWorldPos; - this.currentActive = new THREE.Vector3(0, 0, 0); - const [ inner, middle, outer ] = options.gimmeDistances; - // const inner = options.inner; - // const middle = options.middle; - // const outer = options.outer; - const ringWidth = options.ringWidth ?? 0.1; - + const [inner, middle, outer] = options.gimmeDistances; + const ringWidth = options.ringWidth ?? 0.05; this.ringSizes = new THREE.Vector3(inner, middle, outer); - // Store uniform refs so we can update them later - this.customUniforms = { - holePos: { value: new THREE.Vector3(holeWorldPos.x, 0, holeWorldPos.z) }, - holeRadius: { value: 0.054 }, // 108mm diameter - ringRadii: { value: this.ringSizes }, - ringWidth: { value: ringWidth }, - ringActive: { value: new THREE.Vector3(0, 0, 0) }, - activeColor: { value: new THREE.Vector4(1.0, 0.95, 0.0, 0.15) }, - inactiveColor: { value: new THREE.Vector4(1.0, 1.0, 1.0, 0.6) }, - }; - // Clone the existing GLTF material so we keep all its properties + + // Dynamic uniforms (updated at runtime) + this.holePosUniform = tslUniform(new THREE.Vector3(holeWorldPos.x, 0, holeWorldPos.z)); + this.ringActiveUniform = tslUniform(new THREE.Vector3(0, 0, 0)); + + // Static values + const holeRadius = float(0.054); + // const rimWidth = float(0.005); // ~1.2 cm strip of dirt + // const rimColorRGB = vec3(0.18, 0.12, 0.08); // dark soil brown + const ringRadii = vec3(inner, middle, outer); + const ringW = float(ringWidth); + // const activeColor = vec4(1.0, 0.95, 0.0, 0.15); + // const inactiveColor = vec4(1.0, 1.0, 1.0, 0.6); + const activeColorRGB = vec3(1.0, 0.95, 0.0); + const activeColorA = float(0.15); + const inactiveColorRGB = vec3(1.0, 1.0, 1.0); + const inactiveColorA = float(0.4); + if (object instanceof THREE.Mesh) { - const mat = object.material.clone() as THREE.MeshStandardMaterial; - - mat.alphaToCoverage = true; - // mat.transparent = true; - mat.customProgramCacheKey = () => 'green-hole-rings-v1'; - mat.onBeforeCompile = (shader: THREE.WebGLProgramParametersWithUniforms) => { - // Inject our uniforms into the shader program - Object.assign(shader.uniforms, this.customUniforms); - - // Add varyings + uniforms to the vertex shader - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ ` - #include - varying vec3 vWorldPos; - ` - ); - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ ` - #include - vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; - ` - ); - - // Add uniforms/varyings to the fragment shader - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ ` - #include - varying vec3 vWorldPos; - uniform vec3 holePos; - uniform vec3 ringRadii; - uniform float ringWidth; - uniform vec3 ringActive; - uniform vec4 activeColor; - uniform vec4 inactiveColor; - uniform float holeRadius; - - float ringOutline(float dist, float radius, float width) { - float hw = width * 0.5; - float fw = fwidth(dist); // how much dist changes across this pixel - float edge = max(fw, 0.01); // clamp so it doesn't collapse at close range - return smoothstep(radius - hw - edge, radius - hw + edge, dist) - * (1.0 - smoothstep(radius + hw - edge, radius + hw + edge, dist)); - } - - float zoneFill(float dist, float lo, float hi) { - float fw = fwidth(dist); - float edge = max(fw, 0.01); - return smoothstep(lo - edge, lo + edge, dist) - * (1.0 - smoothstep(hi - edge, hi + edge, dist)); - } - - ` - ); - - // Inject ring compositing right after the diffuse map is applied - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ ` - #include - - float dist = distance(vWorldPos.xz, holePos.xz); - // knock out hole - // if (dist < holeRadius) discard; - // diffuseColor.a *= smoothstep(holeRadius, holeRadius + fwidth(dist), dist); - float g = length(vec2(dFdx(dist), dFdy(dist))); // screen-space gradient of dist - g = clamp(g, 0.0008, 0.02); // floor stops sub-pixel shimmer; ceil stops over-blur - float holeMask = smoothstep(holeRadius - g, holeRadius + g, dist); - diffuseColor.a *= holeMask; // 0 inside the hole, 1 outside - - // --- Ring 1 (inner: 0 → ringRadii.x) --- - float outline1 = ringOutline(dist, ringRadii.x, ringWidth); - float fill1 = zoneFill(dist, 0.0, ringRadii.x); - // When inactive: white outline only. When active: filled yellow zone. - float mask1 = mix(outline1, fill1, ringActive.x); - vec4 col1 = mix(inactiveColor, activeColor, ringActive.x); - - // --- Ring 2 (middle: ringRadii.x → ringRadii.y) --- - float outline2 = ringOutline(dist, ringRadii.y, ringWidth); - float fill2 = zoneFill(dist, ringRadii.x, ringRadii.y); - float mask2 = mix(outline2, fill2, ringActive.y); - vec4 col2 = mix(inactiveColor, activeColor, ringActive.y); - - // --- Ring 3 (outer: ringRadii.y → ringRadii.z) --- - float outline3 = ringOutline(dist, ringRadii.z, ringWidth); - float fill3 = zoneFill(dist, ringRadii.y, ringRadii.z); - float mask3 = mix(outline3, fill3, ringActive.z); - vec4 col3 = mix(inactiveColor, activeColor, ringActive.z); - - // White outlines — always visible - diffuseColor.rgb = mix(diffuseColor.rgb, inactiveColor.rgb, outline1 * inactiveColor.a); - diffuseColor.rgb = mix(diffuseColor.rgb, inactiveColor.rgb, outline2 * inactiveColor.a); - diffuseColor.rgb = mix(diffuseColor.rgb, inactiveColor.rgb, outline3 * inactiveColor.a); - - // Yellow fill — fades in/out with ringActive - diffuseColor.rgb = mix(diffuseColor.rgb, activeColor.rgb, fill1 * activeColor.a * ringActive.x); - diffuseColor.rgb = mix(diffuseColor.rgb, activeColor.rgb, fill2 * activeColor.a * ringActive.y); - diffuseColor.rgb = mix(diffuseColor.rgb, activeColor.rgb, fill3 * activeColor.a * ringActive.z); - ` - ); - }; - - // Force recompilation + const origMat = object.material as THREE.MeshStandardMaterial; + + const mat = new MeshStandardNodeMaterial({ + // alphaToCoverage: true, + }); + + // Copy properties from the original GLTF material + if (origMat.color) mat.color = origMat.color.clone(); + if (origMat.map) mat.map = origMat.map; + if (origMat.normalMap) mat.normalMap = origMat.normalMap; + mat.roughness = origMat.roughness ?? 1.0; + mat.metalness = origMat.metalness ?? 0.0; + if (origMat.roughnessMap) mat.roughnessMap = origMat.roughnessMap; + if (origMat.metalnessMap) mat.metalnessMap = origMat.metalnessMap; + if (origMat.emissive) mat.emissive = origMat.emissive.clone(); + if (origMat.emissiveMap) mat.emissiveMap = origMat.emissiveMap; + mat.emissiveIntensity = origMat.emissiveIntensity ?? 1.0; + if (origMat.aoMap) mat.aoMap = origMat.aoMap; + mat.aoMapIntensity = origMat.aoMapIntensity ?? 1.0; + mat.envMapIntensity = origMat.envMapIntensity ?? 1.0; + if (origMat.lightMap) mat.lightMap = origMat.lightMap; + mat.lightMapIntensity = origMat.lightMapIntensity ?? 1.0; + mat.side = origMat.side; + mat.toneMapped = origMat.toneMapped; + if (origMat.normalScale) mat.normalScale = origMat.normalScale.clone(); + + // --- Distance from fragment to hole (XZ plane) --- + const dist: any = positionWorld.xz.sub(this.holePosUniform.xz).length(); + + // --- Hole mask: fade to transparent inside the hole --- + const g = fwidth(dist).clamp(0.0008, 0.02); + const holeMask = tslSmoothstep(holeRadius.sub(g), holeRadius.add(g), dist); + // Discard(holeMask.lessThan(1)); + + // --- Ring 1 (inner: 0 → ringRadii.x) --- + const outline1 = ringOutline(dist, ringRadii.x, ringW); + const fill1 = zoneFill(dist, float(0), ringRadii.x); + + // --- Ring 2 (middle: ringRadii.x → ringRadii.y) --- + const outline2 = ringOutline(dist, ringRadii.y, ringW); + const fill2 = zoneFill(dist, ringRadii.x, ringRadii.y); + + // --- Ring 3 (outer: ringRadii.y → ringRadii.z) --- + const outline3 = ringOutline(dist, ringRadii.z, ringW); + const fill3 = zoneFill(dist, ringRadii.y, ringRadii.z); + + // --- Composite rings onto the base color --- + // Start with the material's base color (includes .color * .map) + let color: any = materialColor; + // --- Dirt rim around the cup --- + // const rim = zoneFill(dist, holeRadius, holeRadius.add(rimWidth)); + // color = mix(color, rimColorRGB, rim.mul(0.9)); + + // White outlines — always visible + // color = mix(color, inactiveColor.rgb, outline1.mul(inactiveColor.a)); + // color = mix(color, inactiveColor.rgb, outline2.mul(inactiveColor.a)); + // color = mix(color, inactiveColor.rgb, outline3.mul(inactiveColor.a)); + color = mix(color, inactiveColorRGB, outline1.mul(inactiveColorA)); + color = mix(color, inactiveColorRGB, outline2.mul(inactiveColorA)); + color = mix(color, inactiveColorRGB, outline3.mul(inactiveColorA)); + + // Yellow fill — fades in/out with ringActive + const active = this.ringActiveUniform; + // color = mix(color, activeColor.rgb, fill1.mul(activeColor.a).mul(active.x)); + // color = mix(color, activeColor.rgb, fill2.mul(activeColor.a).mul(active.y)); + // color = mix(color, activeColor.rgb, fill3.mul(activeColor.a).mul(active.z)); + const mask1: any = float(fill1).mul(activeColorA).mul(active.x); + const mask2: any = float(fill2).mul(activeColorA).mul(active.y); + const mask3: any = float(fill3).mul(activeColorA).mul(active.z); + color = mix(color, activeColorRGB, mask1); + color = mix(color, activeColorRGB, mask2); + color = mix(color, activeColorRGB, mask3); + + + // mat.colorNode = color; + // mat.opacityNode = holeMask; + // mat.transparent = false; + // Discard must live inside an Fn() that is wired into the + // material, otherwise the statement never enters the node graph. + const finalColor = color; + mat.colorNode = Fn(() => { + Discard(holeMask.lessThan(0.5)); + return vec4(finalColor.rgb, 1.0); + })(); + mat.transparent = false; + mat.needsUpdate = true; object.material = mat; this.material = mat; @@ -151,36 +167,38 @@ export class TargetShaderMaterial { } setPosition(position: THREE.Vector3) { - this.customUniforms.holePos.value = new THREE.Vector3(position.x, 0, position.z); - if (this.material) this.material.needsUpdate = true; + this.holePosUniform.value.set(position.x, 0, position.z); } + dispose() { if (this.material) { this.material.dispose(); this.material = undefined; } } + update(golfBall: GolfBall, dt: number) { if (!golfBall.object) { + console.warn('No golfball object!'); return; } + const target = new THREE.Vector3(0, 0, 0); if (golfBall.isOnGreen()) { const dist = Math.hypot( golfBall.object.position.x - this.holeWorldPos.x, golfBall.object.position.z - this.holeWorldPos.z ); - + target.set( dist <= this.ringSizes.x ? 1.0 : 0.0, dist > this.ringSizes.x && dist <= this.ringSizes.y ? 1.0 : 0.0, dist > this.ringSizes.y ? 1.0 : 0.0 ); } - // Smooth toward target — never snaps + const t = 1.0 - Math.exp(-this.lerpSpeed * dt); this.currentActive.lerp(target, t); - - this.customUniforms.ringActive.value.copy(this.currentActive); + this.ringActiveUniform.value.copy(this.currentActive); } } \ No newline at end of file diff --git a/src/shaders/water.ts b/src/shaders/water.ts deleted file mode 100644 index 270ad64..0000000 --- a/src/shaders/water.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as THREE from 'three'; -import { Water } from 'three/examples/jsm/Addons.js'; -import normals from '@/images/waternormals.jpg'; - - -type WaterSurfaceOptions = { - speed?: number, - textureScale?: number, - water?: { - textureWidth?: number, - textureHeight?: number, - alpha?: number, - fog?: boolean, - sunColor?: THREE.Color, - waterColor?: THREE.Color, - distortionScale?: number, - }, - shader?: { - scatterFloor?: number, - normalStrength?: number, - reflectAmount?: number, - reflectMax?: number, - skyTint?: number[], - skyTintBlend?: number, - glintStrength?: number, - } -} -export class WaterSurface { - speed: number; - water: Water; - _shaderRef?: THREE.WebGLProgramParametersWithUniforms; - - constructor(waterObject: THREE.Mesh, options: Partial = {}) { - // const { speed, textureScale, water: waterOptions, shader: shaderOptions } = Object.assign({ ...defaultOptions }, options); - const merged = { - speed: 0.3, - textureScale: 1, - ...options, - water: { - textureWidth: 512, - textureHeight: 512, - alpha: 0.5, - fog: false, - sunColor: new THREE.Color('#fff5e6'), - waterColor: new THREE.Color('#0a2f1f'), - distortionScale: 1.0, - ...options.water || {}, - }, - shader: { - scatterFloor: 0.35, - normalStrength: 0.8, - reflectAmount: 0.05, - reflectMax: 0.1, - skyTint: [0.443, 0.749, 0.596], - skyTintBlend: 0.8, - glintStrength: 0.4, - ...options.shader || {}, - }, - }; - const { speed, textureScale, water: waterOptions, shader: shaderOptions } = merged; - - this.speed = speed; - - const textureLoader = new THREE.TextureLoader(); - const waterNormals = textureLoader.load( - normals, - (texture) => { - texture.wrapS = texture.wrapT = THREE.RepeatWrapping; - } - ); - - const sun = new THREE.Vector3(); - // phi = elevation, theta = azimuth - const phi = THREE.MathUtils.degToRad(80); // sun high up - const theta = THREE.MathUtils.degToRad(45); - sun.setFromSphericalCoords(1, phi, theta); - - this.water = new Water(waterObject.geometry.clone(), { - sunDirection: sun, - waterNormals, - ...waterOptions, - }); - - if (textureScale) { - // This is the big one most people miss — it controls - // how much the normal map tiles. Default is 1.0, which - // makes huge blurry waves. Crank it up for finer ripples. - this.water.material.uniforms['size'].value = textureScale; - } - - this.water.material.onBeforeCompile = (shader) => { - // Add custom uniforms - shader.uniforms.scatterFloor = { value: shaderOptions.scatterFloor }; - shader.uniforms.normalStrength = { value: shaderOptions.normalStrength }; - shader.uniforms.reflectAmount = { value: shaderOptions.reflectAmount }; - shader.uniforms.reflectMax = { value: shaderOptions.reflectMax }; - shader.uniforms.skyTint = { value: new THREE.Vector3(...shaderOptions.skyTint) }; - shader.uniforms.skyTintBlend = { value: shaderOptions.skyTintBlend }; - shader.uniforms.glintStrength = { value: shaderOptions.glintStrength }; - - // Declare uniforms in the shader - shader.fragmentShader = shader.fragmentShader.replace( - 'uniform vec3 waterColor;', - `uniform vec3 waterColor; - uniform float scatterFloor; - uniform float normalStrength; - uniform float reflectAmount; - uniform float reflectMax; - uniform vec3 skyTint; - uniform float skyTintBlend; - uniform float glintStrength;` - ); - - // Soften normals using uniform - shader.fragmentShader = shader.fragmentShader.replace( - /vec3 surfaceNormal\s*=\s*normalize\(\s*noise\.xzy\s*\*\s*vec3\(\s*1\.5\s*,\s*1\.0\s*,\s*1\.5\s*\)\s*\);/, - 'vec3 surfaceNormal = normalize( noise.xzy * vec3( normalStrength, 1.0, normalStrength ) );' - ); - - // Scatter using uniform - shader.fragmentShader = shader.fragmentShader.replace( - /vec3 scatter\s*=\s*max\(\s*0\.0\s*,\s*dot\(\s*surfaceNormal\s*,\s*eyeDirection\s*\)\s*\)\s*\*\s*waterColor\s*;/, - 'vec3 scatter = (scatterFloor + (1.0 - scatterFloor) * max(0.0, dot(surfaceNormal, eyeDirection))) * waterColor;' - ); - - // Reflections using uniforms (no local variable redeclarations) - shader.fragmentShader = shader.fragmentShader.replace( - /vec3 albedo = mix\(\s*\(.*?\)\s*\*\s*getShadowMask\(\)\s*,\s*reflectionSample\s*\+\s*specularLight\s*,\s*reflectance\s*\);/s, - `vec3 tintedReflection = mix(reflectionSample, skyTint, skyTintBlend); - float pondReflectance = min(reflectance * reflectAmount, reflectMax); - vec3 albedo = mix(scatter, tintedReflection, pondReflectance) + specularLight * glintStrength;` - ); - - shader.fragmentShader = shader.fragmentShader.replace('#include ', ''); - shader.fragmentShader = shader.fragmentShader.replace('#include ', ''); - - this._shaderRef = shader; - }; - - - - this.water.material.transparent = true; - this.water.material.needsUpdate = true; - } - - // updateEnvironment(envMap: THREE.Texture) { - // this.water.material.envMap = envMap; - // this.water.material.needsUpdate = true; - // } - - update() { - this.water.material.uniforms['time'].value += this.speed / 60.0; - } -} \ No newline at end of file diff --git a/src/shaders/water/index.ts b/src/shaders/water/index.ts new file mode 100644 index 0000000..96dd4aa --- /dev/null +++ b/src/shaders/water/index.ts @@ -0,0 +1,312 @@ +import * as THREE from 'three/webgpu'; +import { MeshPhysicalNodeMaterial } from 'three/webgpu'; +import { + texture, uv, uniform, + vec2, vec3, float, + fract, abs, mix, clamp, pow, sub, dot, normalize, + positionWorld, normalWorld, cameraPosition, + normalMap, pmremTexture, + viewportDepthTexture, linearDepth, smoothstep as tslSmoothstep, step, + cameraNear, cameraFar +} from 'three/tsl'; +import normals from '@/images/waternormals.jpg'; + +export type WaterSurfaceOptions = { + speed?: number; + flowStrength?: number; + sideFlowStrength?: number; + envMapIntensity?: number; + uvTiling?: [number, number]; + normalStrength?: number; + shallowColor?: THREE.Color; + deepColor?: THREE.Color; + specularColor?: THREE.Color; + opacity?: number; + roughness?: number; + yOffset?: number; + depthRange?: number; + foamWidth?: number; + foamColor?: THREE.Color; + foamDensity?: number; + foamSharpness?: number; + foamOpacity?: number; +}; + +export type FlowMapData = { data: ImageDataArray, width: number, height: number }; + +export class WaterSurface { + material: MeshPhysicalNodeMaterial; + water: THREE.Mesh; + speed: number; + private timeUniform: any; + + constructor( + waterObject: THREE.Mesh, + flowMapData?: FlowMapData, + options: WaterSurfaceOptions = {} + ) { + + // Initialize these first, before the TSL setup + this.material = new MeshPhysicalNodeMaterial({ + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + }); + + // this.mesh = new THREE.Mesh(geometry.clone(), this.material); + this.water = new THREE.Mesh(waterObject.geometry.clone(), this.material); + if (options.yOffset) { + this.water.position.set(this.water.position.x, this.water.position.y - options.yOffset, this.water.position.z); + } + + this.timeUniform = uniform(0); + + // Recompute UVs to [0,1] range for flow map alignment + const pos = this.water.geometry.attributes.position; + const uvAttr = new Float32Array(pos.count * 2); + let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity; + + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i); + const z = pos.getZ(i); + if (x < minX) minX = x; + if (z < minZ) minZ = z; + if (x > maxX) maxX = x; + if (z > maxZ) maxZ = z; + } + + const rangeX = maxX - minX || 1; + const rangeZ = maxZ - minZ || 1; + + for (let i = 0; i < pos.count; i++) { + uvAttr[i * 2] = (pos.getX(i) - minX) / rangeX; + uvAttr[i * 2 + 1] = (pos.getZ(i) - minZ) / rangeZ; + } + + this.water.geometry.setAttribute('uv', new THREE.BufferAttribute(uvAttr, 2)); + console.log('[FLOWMAP] Mesh bounds:', JSON.stringify({ minX, minZ, maxX, maxZ: maxZ })); + + const opts = { + speed: 0.25, + flowStrength: 0.15, + sideFlowStrength: 0.2, + envMapIntensity: 0.5, + uvTiling: [6, 6] as [number, number], + normalStrength: 1.0, + shallowColor: new THREE.Color('#243f42'), + deepColor: new THREE.Color('#0a1f2a'), + specularColor: new THREE.Color('#ffffff'), + opacity: 0.7, + roughness: 0.15, + depthRange: 5.0, + foamWidth: 1, + foamColor: new THREE.Color('#7a9889'), + foamDensity: 0.98, + foamSharpness: 0.25, + foamOpacity: 0.2, + ...options, + }; + this.speed = opts.speed; + + // --- Uniforms --- + const flowSpeed = float(opts.speed); + const flowStrength = float(opts.flowStrength); + const sideFlowStrength = float(opts.sideFlowStrength); + const tileSize = Math.min(rangeX, rangeZ) / opts.uvTiling[0]; + const tiling = vec2(rangeX / tileSize, rangeZ / tileSize); + + // const normStrength = uniform(opts.normalStrength); + const normStrength = float(opts.normalStrength); + + // --- Textures --- + const textureLoader = new THREE.TextureLoader(); + // const waterNormalTex = textureLoader.load(normals, (tex) => { + // tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + // }); + const waterNormalTex = textureLoader.load(normals, () => { + this.material.needsUpdate = true; + }); + waterNormalTex.wrapS = waterNormalTex.wrapT = THREE.RepeatWrapping; + + let flowMapTexture: THREE.DataTexture; + + if (flowMapData) { + flowMapTexture = new THREE.DataTexture( + new Uint8Array(flowMapData.data), + flowMapData.width, + flowMapData.height, + THREE.RGBAFormat + ); + } else { + // Default: uniform flow in -Y direction, full speed + flowMapTexture = new THREE.DataTexture( + new Uint8Array([160, 180, 200, 255]), + 1, 1, + THREE.RGBAFormat + ); + } + + flowMapTexture.needsUpdate = true; + flowMapTexture.minFilter = THREE.LinearFilter; + flowMapTexture.magFilter = THREE.LinearFilter; + + // flowMapTexture.flipY = true; + flowMapTexture.wrapS = flowMapTexture.wrapT = THREE.ClampToEdgeWrapping; + + + const baseUV = uv(); + // Decode flow direction + const flow = texture(flowMapTexture, baseUV).rg + .sub(0.5) + .mul(2.0) + .mul(flowStrength) + .negate(); + + // Speed from blue channel + const speed = texture(flowMapTexture, baseUV).b; + + // --- Dual-phase time (prevents scroll reset pop) --- + // const t = this.timeUniform.mul(flowSpeed).mul(speed); + const t = this.timeUniform.mul(flowSpeed); + + // const t = time.mul(flowSpeed).mul(speed); + const phase0 = fract(t); + const phase1 = fract(t.add(0.5)); + const blend = abs(phase0.mul(2.0).sub(1.0)); // triangle wave 0→1→0 + + // --- Sample water normals at two offset UVs and blend --- + const tiledUV = baseUV.mul(tiling); + const uv0 = tiledUV.add(flow.mul(speed).mul(phase0)); + const uv1 = tiledUV.add(flow.mul(speed).mul(phase1)); + + const n0 = texture(waterNormalTex, uv0); + const n1 = texture(waterNormalTex, uv1); + const blendedNormals = mix(n0, n1, blend); + + // Second layer: smaller ripples, different speed and angle for turbulence + const detailTiling = vec2(rangeX / tileSize * 2.3, rangeZ / tileSize * 2.3); + const detailTime = this.timeUniform.mul(0.23); // different speed + const detailPhase0 = fract(detailTime); + const detailPhase1 = fract(detailTime.add(0.5)); + const detailBlend = abs(detailPhase0.mul(2.0).sub(1.0)); + // const detailFlow = flow.mul(0.7).add(vec2(0.1, 0.05)); // slightly offset direction + // Swap and negate to get perpendicular direction + const detailFlow = vec2(flow.y.negate(), flow.x).mul(0.5); + const detailUV = baseUV.mul(detailTiling); + const d0 = texture(waterNormalTex, detailUV.add(detailFlow.mul(detailPhase0))); + const d1 = texture(waterNormalTex, detailUV.add(detailFlow.mul(detailPhase1))); + const detailNormals = mix(d0, d1, detailBlend); + + // Combine both layers + const combinedNormals = mix(blendedNormals, detailNormals, sideFlowStrength); + this.material.normalNode = normalMap(combinedNormals, vec2(normStrength)); + + const viewDir = normalize(cameraPosition.sub(positionWorld)); + const NdotV = clamp(dot(normalWorld, viewDir), 0.0, 1.0); + const fresnel = pow(sub(float(1.0), NdotV), float(3.0)); + + + const sceneDepth = linearDepth(viewportDepthTexture()); + // const waterDepth = linearDepth(positionView.z.negate()); + // const depthDiff = sceneDepth.sub(waterDepth).max(0); + + const waterDepth = linearDepth(); + const depthDiff = sceneDepth.sub(waterDepth).max(0).mul(cameraFar); + + // const depthDiff = sceneDepth.sub(waterDepth).max(0).mul(cameraFar); + + // Shallow → transparent, deep → opaque + const depthFade = tslSmoothstep(float(0), float(opts.depthRange), depthDiff); + // Combine depth fade with fresnel: shallow water is more transparent + const baseOpacity = mix(float(0.05), float(opts.opacity), depthFade); + this.material.opacityNode = clamp( + mix(baseOpacity, float(0.95), fresnel), + 0.0, 1.0 + ); + + // this.material.opacityNode = clamp( + // // mix(float(0.6), float(0.95), fresnel), + // mix(float(opts.opacity), float(0.95), fresnel), + // 0.0, 1.0 + // ); + + // this.material.color = opts.shallowColor; + + // Shallow → light color, deep → dark color + const shallowCol = vec3(opts.shallowColor.r, opts.shallowColor.g, opts.shallowColor.b); + const deepCol = vec3(opts.deepColor.r, opts.deepColor.g, opts.deepColor.b); + const waterColor = mix(shallowCol, deepCol, depthFade); + + // --- Edge foam --- + const foamThreshold = float(opts.foamWidth); + const foamFactor = tslSmoothstep(foamThreshold, float(0), depthDiff); + // // const foamNoise = texture(waterNormalTex, tiledUV.mul(3.0)).r; + // const foamN0 = texture(waterNormalTex, uv0.mul(1.5)).r; + // const foamN1 = texture(waterNormalTex, uv1.mul(1.5)).r; + // const foamNoise = mix(foamN0, foamN1, blend); + + // // const foam = foamFactor.mul(step(foamNoise, foamFactor)); + + // const edgeStability = pow(foamFactor, float(0.5)); + // const foamPattern = mix(foamNoise, float(0.85), edgeStability); + // const foam = foamFactor.mul(foamPattern); + + // Two noise scales for organic bubble pattern + const foamN0_a = texture(waterNormalTex, uv0.mul(1.5)).r; + const foamN1_a = texture(waterNormalTex, uv1.mul(1.5)).r; + const foamLarge = mix(foamN0_a, foamN1_a, blend); + + const foamN0_b = texture(waterNormalTex, uv0.mul(4.0)).g; + const foamN1_b = texture(waterNormalTex, uv1.mul(4.0)).g; + const foamSmall = mix(foamN0_b, foamN1_b, blend); + + // Multiply two noise layers — creates cellular-like clumps + const foamNoise = foamLarge.mul(foamSmall); + + // Threshold test: noise must exceed a cutoff to become foam. + // At the edge (foamFactor=1), cutoff is low (0.15) → lots of foam patches. + // Away from edge (foamFactor=0), cutoff is 1.0 → no foam. + // const foamCutoff = float(1.0).sub(foamFactor.mul(0.85)); + // const foamPatches = tslSmoothstep(foamCutoff, foamCutoff.add(0.05), foamNoise); + const foamCutoff = float(1.0).sub(foamFactor.mul(opts.foamDensity)); + const foamPatches = tslSmoothstep(foamCutoff, foamCutoff.add(opts.foamSharpness), foamNoise); + + const foam = foamPatches.mul(opts.foamOpacity); + + const foamCol = vec3(opts.foamColor.r, opts.foamColor.g, opts.foamColor.b); + + // this.material.colorNode = mix(waterColor, foamCol, foam); + // this.material.colorNode = waterColor; + + // this.material.colorNode = vec3(depthFade); + + // DEBUG: visualize raw depth difference at different scales + // const rawDepth = sceneDepth.sub(waterDepth).max(0); + // this.material.colorNode = vec3(rawDepth.mul(10000.0)); + // const rawDepth = viewportDepthTexture(); + // this.material.colorNode = vec3(float(rawDepth), float(rawDepth), float(rawDepth)); + // this.material.colorNode = waterColor; + this.material.colorNode = mix(waterColor, foamCol, foam); + + this.material.roughness = opts.roughness; + this.material.metalness = 0.0; + this.material.specularIntensity = 1.0; + this.material.specularColor = opts.specularColor; + this.material.envMapIntensity = opts.envMapIntensity; + // this.material.emissive = opts.shallowColor.clone().multiplyScalar(0.15); + this.material.emissive = opts.shallowColor.clone().multiplyScalar(0.15); + + + } + + updateEnvironment(envMap: THREE.Texture) { + this.material.envMap = envMap; + this.material.envNode = pmremTexture(envMap); + this.material.needsUpdate = true; + console.log('updateEnvironment called, envMap:', envMap); + } + + update(_dt?: number) { + this.timeUniform.value += this.speed / 60.0; + } +} diff --git a/src/shaders/water/lake.ts b/src/shaders/water/lake.ts new file mode 100644 index 0000000..192477f --- /dev/null +++ b/src/shaders/water/lake.ts @@ -0,0 +1,27 @@ +import * as THREE from 'three'; +import { WaterSurface, type WaterSurfaceOptions } from './'; + +export class LakeSurface extends WaterSurface { + constructor( + waterObject: THREE.Mesh, + options: WaterSurfaceOptions = {} + ) { + super(waterObject, undefined, { + speed: 0.25, + flowStrength: 0.8, + sideFlowStrength: 0.35, + uvTiling: [6, 6], + normalStrength: 0.8, + shallowColor: new THREE.Color('#27383b'), + deepColor: new THREE.Color('#050d0f'), + depthRange: 5, + roughness: 0.05, + // shallowColor: new THREE.Color('#88ccbb'), + // roughness: 0.8, + opacity: 0.9, + yOffset: 0, + envMapIntensity: 0.1, + ...options, + }); + } +} \ No newline at end of file diff --git a/src/shaders/water/river.ts b/src/shaders/water/river.ts new file mode 100644 index 0000000..e4c96a2 --- /dev/null +++ b/src/shaders/water/river.ts @@ -0,0 +1,29 @@ +import * as THREE from 'three'; +import { WaterSurface, type WaterSurfaceOptions, type FlowMapData } from './'; + +export class RiverSurface extends WaterSurface { + constructor( + waterObject: THREE.Mesh, + flowMapData?: FlowMapData, + options: WaterSurfaceOptions = {} + ) { + super(waterObject, flowMapData, { + speed: 0.35, + flowStrength: 0.3, + sideFlowStrength: 0.3, + uvTiling: [6, 6], + normalStrength: 0.5, + // shallowColor: new THREE.Color('#374949'), + // deepColor: new THREE.Color('#1a3534'), + shallowColor: new THREE.Color('#27383b'), + deepColor: new THREE.Color('#050d0f'), + depthRange: 2, + // shallowColor: new THREE.Color('#243f42'), + opacity: 0.4, + roughness: 0.05, + envMapIntensity: 0.15, + yOffset: 0.15, + ...options, + }); + } +} diff --git a/src/shaders/yardage.ts b/src/shaders/yardage.ts index 07dc2a3..ca73cf8 100644 --- a/src/shaders/yardage.ts +++ b/src/shaders/yardage.ts @@ -1,4 +1,12 @@ -import * as THREE from 'three'; +import * as THREE from 'three/webgpu'; +import { MeshStandardNodeMaterial } from 'three/webgpu'; +import { + vec2, vec3, float, + uniform as tslUniform, + positionWorld, materialColor, + texture as tslTexture, + mix, dot, step, +} from 'three/tsl'; function nextPow2(v: number): number { return Math.pow(2, Math.ceil(Math.log2(v))); @@ -18,13 +26,22 @@ export type YardageLinesMaterialOptions = { }; export class YardageLinesMaterial { - customUniforms: Record; - material?: THREE.Material; + material?: MeshStandardNodeMaterial; private lineLength: number; private maxDist: number; private pxPerMeter: number; private maxTexSize: number; + private canvas: HTMLCanvasElement; + private canvasTex: THREE.CanvasTexture; + + // TSL uniforms + private teePosUniform: any; + private rangeDirUniform: any; + private perpDirUniform: any; + private texWorldSizeUniform: any; + private lineColorRGBUniform: any; + private lineColorAUniform: any; constructor( object: THREE.Object3D, @@ -52,78 +69,80 @@ export class YardageLinesMaterial { this.maxDist = Math.max(...distances) + labelGap + labelSize[1] + 2; + // Create persistent canvas and texture + this.canvas = document.createElement('canvas'); const texW = Math.min(nextPow2(lineLength * this.pxPerMeter), this.maxTexSize); const texH = Math.min(nextPow2(this.maxDist * this.pxPerMeter), this.maxTexSize); - - const tex = this.buildLineTexture(texW, texH, distances, options); - tex.anisotropy = maxAniso; - - this.customUniforms = { - teePos: { value: new THREE.Vector3(ballPos.x, 0, ballPos.z) }, - rangeDir: { value: dir }, - perpDir: { value: perpDir }, - lineTexture: { value: tex }, - texWorldSize: { value: new THREE.Vector2(lineLength, this.maxDist) }, - lineColor: { value: new THREE.Vector4(lineColor[0], lineColor[1], lineColor[2], lineColor[3]) }, - }; + this.canvas.width = texW; + this.canvas.height = texH; + + this.drawLines(distances, options); + + this.canvasTex = new THREE.CanvasTexture(this.canvas); + this.canvasTex.minFilter = THREE.LinearMipmapLinearFilter; + this.canvasTex.magFilter = THREE.LinearFilter; + this.canvasTex.wrapS = THREE.ClampToEdgeWrapping; + this.canvasTex.wrapT = THREE.ClampToEdgeWrapping; + this.canvasTex.generateMipmaps = true; + this.canvasTex.anisotropy = maxAniso; + this.canvasTex.needsUpdate = true; + + // TSL uniforms + this.teePosUniform = tslUniform(new THREE.Vector3(ballPos.x, 0, ballPos.z)); + this.rangeDirUniform = tslUniform(dir); + this.perpDirUniform = tslUniform(perpDir); + this.texWorldSizeUniform = tslUniform(new THREE.Vector2(lineLength, this.maxDist)); + this.lineColorRGBUniform = tslUniform(new THREE.Color(lineColor[0], lineColor[1], lineColor[2])); + this.lineColorAUniform = tslUniform(lineColor[3]); if (object instanceof THREE.Mesh) { - const mat = object.material.clone(); - - mat.onBeforeCompile = (shader: THREE.WebGLProgramParametersWithUniforms) => { - Object.assign(shader.uniforms, this.customUniforms); - - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ ` - #include - varying vec3 vWorldPos; - ` - ); - shader.vertexShader = shader.vertexShader.replace( - '#include ', - /* glsl */ ` - #include - vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; - ` - ); - - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ ` - #include - varying vec3 vWorldPos; - uniform vec3 teePos; - uniform vec2 rangeDir; - uniform vec2 perpDir; - uniform sampler2D lineTexture; - uniform vec2 texWorldSize; - uniform vec4 lineColor; - ` - ); - - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */ ` - #include - - vec2 offset = vWorldPos.xz - teePos.xz; - float downrange = dot(offset, rangeDir); - float crossrange = dot(offset, perpDir); - - float u = 0.5 - crossrange / texWorldSize.x; - float v = downrange / texWorldSize.y; - - float inBounds = step(0.0, u) * step(u, 1.0) - * step(0.0, v) * step(v, 1.0); - - vec4 lineSample = texture2D(lineTexture, vec2(u, v)); - float mask = lineSample.a * lineColor.a * inBounds; - - diffuseColor.rgb = mix(diffuseColor.rgb, lineColor.rgb, mask); - ` - ); - }; + const origMat = object.material as THREE.MeshStandardMaterial; + + const mat = new MeshStandardNodeMaterial(); + + // Copy properties from original GLTF material + if (origMat.color) mat.color = origMat.color.clone(); + if (origMat.map) mat.map = origMat.map; + if (origMat.normalMap) mat.normalMap = origMat.normalMap; + if (origMat.normalScale) mat.normalScale = origMat.normalScale.clone(); + if (origMat.roughnessMap) mat.roughnessMap = origMat.roughnessMap; + if (origMat.metalnessMap) mat.metalnessMap = origMat.metalnessMap; + if (origMat.emissive) mat.emissive = origMat.emissive.clone(); + if (origMat.emissiveMap) mat.emissiveMap = origMat.emissiveMap; + mat.emissiveIntensity = origMat.emissiveIntensity ?? 1.0; + mat.roughness = origMat.roughness ?? 1.0; + mat.metalness = origMat.metalness ?? 0.0; + mat.envMapIntensity = origMat.envMapIntensity ?? 1.0; + if (origMat.aoMap) mat.aoMap = origMat.aoMap; + mat.aoMapIntensity = origMat.aoMapIntensity ?? 1.0; + if (origMat.lightMap) mat.lightMap = origMat.lightMap; + mat.lightMapIntensity = origMat.lightMapIntensity ?? 1.0; + mat.side = origMat.side; + mat.toneMapped = origMat.toneMapped; + + // --- TSL: project world position onto range/perp axes --- + const offset: any = positionWorld.xz.sub(this.teePosUniform.xz); + const downrange = dot(offset, this.rangeDirUniform); + const crossrange = dot(offset, this.perpDirUniform); + + // Map to texture UV + const u: any = float(0.5).sub(crossrange.div(this.texWorldSizeUniform.x)); + const v: any = downrange.div(this.texWorldSizeUniform.y); + + // Bounds check: only show where UV is 0-1 + const inBounds: any = step(float(0), u) + .mul(step(u, float(1))) + .mul(step(float(0), v)) + .mul(step(v, float(1))); + + // Sample the line texture + const lineSample: any = tslTexture(this.canvasTex, vec2(u, v)); + const mask: any = lineSample.a.mul(this.lineColorAUniform).mul(inBounds); + + // Overlay lines onto the base material color + // mat.colorNode = mix(materialColor, this.lineColorRGBUniform, mask); + // @ts-expect-error materialColor supports .rgb at runtime; @types/three doesn't type it + mat.colorNode = mix(materialColor.rgb, this.lineColorRGBUniform, mask); mat.needsUpdate = true; object.material = mat; @@ -131,11 +150,7 @@ export class YardageLinesMaterial { } } - private buildLineTexture( - texW: number, texH: number, - distances: number[], - options: YardageLinesMaterialOptions - ): THREE.CanvasTexture { + private drawLines(distances: number[], options: YardageLinesMaterialOptions) { const lineWidth = options.lineWidth ?? 0.4; const feather = options.feather ?? 0.08; const labelSize = options.labelSize ?? [5, 2.5]; @@ -143,20 +158,20 @@ export class YardageLinesMaterial { const labels = options.labels; const font = options.labelFont; + const texW = this.canvas.width; + const texH = this.canvas.height; + const pxPerMX = texW / this.lineLength; const pxPerMY = texH / this.maxDist; const aspectCorrection = pxPerMX / pxPerMY; - const canvas = document.createElement('canvas'); - canvas.width = texW; - canvas.height = texH; - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; ctx.clearRect(0, 0, texW, texH); for (let i = 0; i < distances.length; i++) { const d = distances[i]; - // ── Line stripe ── + // Line stripe const lineY = texH - d * pxPerMY; const lineH = Math.max(lineWidth * pxPerMY, 1); @@ -169,7 +184,7 @@ export class YardageLinesMaterial { ctx.fillStyle = grad; ctx.fillRect(0, lineY - lineH / 2, texW, lineH); - // ── Text label ── + // Text label const labelHPx = labelSize[1] * pxPerMY; const labelCenterY = texH - (d + labelGap + labelSize[1] / 2) * pxPerMY; const fontSize = Math.round(labelHPx * 0.8); @@ -186,15 +201,6 @@ export class YardageLinesMaterial { ctx.fillText(text, 0, 0); ctx.restore(); } - - const tex = new THREE.CanvasTexture(canvas); - tex.minFilter = THREE.LinearMipmapLinearFilter; - tex.magFilter = THREE.LinearFilter; - tex.wrapS = THREE.ClampToEdgeWrapping; - tex.wrapT = THREE.ClampToEdgeWrapping; - tex.generateMipmaps = true; - tex.needsUpdate = true; - return tex; } setDistances(distances: number[], options: YardageLinesMaterialOptions = {}) { @@ -203,21 +209,24 @@ export class YardageLinesMaterial { const texW = Math.min(nextPow2(this.lineLength * this.pxPerMeter), this.maxTexSize); const texH = Math.min(nextPow2(this.maxDist * this.pxPerMeter), this.maxTexSize); - const oldTex = this.customUniforms.lineTexture.value as THREE.CanvasTexture; - const aniso = oldTex.anisotropy; - oldTex.dispose(); + this.canvas.width = texW; + this.canvas.height = texH; + this.drawLines(distances, options); - const tex = this.buildLineTexture(texW, texH, distances, options); - tex.anisotropy = aniso; - this.customUniforms.lineTexture.value = tex; - this.customUniforms.texWorldSize.value.set(this.lineLength, this.maxDist); + this.canvasTex.needsUpdate = true; + this.texWorldSizeUniform.value.set(this.lineLength, this.maxDist); } setLineColor(r: number, g: number, b: number, a: number) { - this.customUniforms.lineColor.value.set(r, g, b, a); + this.lineColorRGBUniform.value.setRGB(r, g, b); + this.lineColorAUniform.value = a; } dispose() { - (this.customUniforms.lineTexture.value as THREE.Texture)?.dispose(); + this.canvasTex.dispose(); + if (this.material) { + this.material.dispose(); + this.material = undefined; + } } } \ No newline at end of file diff --git a/src/sky.ts b/src/sky.ts index e73feb6..6a648fa 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,188 +1,44 @@ -import * as THREE from 'three'; +import * as THREE from 'three/webgpu'; +import { MeshBasicNodeMaterial } from 'three/webgpu'; +import { PMREMGenerator } from 'three/webgpu'; import { EXRLoader } from 'three/examples/jsm/Addons.js'; +// ============================================================ +// SkyBox +// ============================================================ + export class SkyBox { - pmremGenerator: THREE.PMREMGenerator; + pmremGenerator: PMREMGenerator; exrLoader: EXRLoader; sky: THREE.Mesh | null; - constructor(renderer: THREE.WebGLRenderer) { - this.pmremGenerator = new THREE.PMREMGenerator(renderer); - this.pmremGenerator.compileEquirectangularShader(); + constructor(renderer: THREE.WebGPURenderer) { + this.pmremGenerator = new PMREMGenerator(renderer); this.exrLoader = new EXRLoader(); this.sky = null; } async load(scene: THREE.Scene, exrPath: string) { - const texture = await this.exrLoader.loadAsync(exrPath); texture.mapping = THREE.EquirectangularReflectionMapping; - // Use PMREM version only for lighting const envMap = this.pmremGenerator.fromEquirectangular(texture).texture; scene.environment = envMap; this.pmremGenerator.dispose(); - // Render background on a manually controlled sphere const skyGeo = new THREE.SphereGeometry(400, 60, 40); - const skyMat = new THREE.MeshBasicMaterial({ map: texture, depthWrite: false, fog: false }); - this.sky = new THREE.Mesh(skyGeo, skyMat); + const skyMat = new MeshBasicNodeMaterial({ + map: texture, + depthWrite: false, + fog: false, + }); - this.sky.geometry.scale(-1, 1, 1); // flip inside-out so we see it from within - this.sky.scale.set(2, 1, 2); // additionally squash to lower the horizon - this.sky.position.set(0, 50, 0); // additionally squash to lower the horizon - // sky.scale.set(1, 0.6, 1); // additionally squash to lower the horizon + this.sky = new THREE.Mesh(skyGeo, skyMat); + this.sky.geometry.scale(-1, 1, 1); + this.sky.scale.set(2, 1, 2); + this.sky.position.set(0, 50, 0); this.sky.rotation.y = -0.5; return this.sky; } } - -type VolumetricCloudsOptions = { - density?: number; - opacity?: number; - scale?: number; - radius?: number; - position?: THREE.Vector3; - // colors - skyColor?: THREE.Color; - cloudColor?: THREE.Color; - fogColor?: THREE.Color; -} - -export class VolumetricClouds { - camera: THREE.Camera; - cloudMaterial: THREE.ShaderMaterial; - object: THREE.Mesh; - - constructor(camera: THREE.Camera, options: VolumetricCloudsOptions = {}) { - this.camera = camera; - const density = options.density ?? 0.4; - const opacity = options.opacity ?? 0.8; - const scale = options.scale ?? 5.0; - const radius = options.radius ?? 800; - const position = options.position ?? new THREE.Vector3(0, 0, 0); - // colors - const skyColor = options.skyColor ?? new THREE.Color(0.53, 0.81, 0.92); - const cloudColor = options.cloudColor ?? new THREE.Color(1.0, 1.0, 1.0); - const fogColor = options.fogColor ?? new THREE.Color(0.75, 0.82, 0.92); - - this.cloudMaterial = new THREE.ShaderMaterial({ - uniforms: { - time: { value: 0 }, - densityThreshold: { value: density }, - opacity: { value: opacity }, - scale: { value: scale }, - sphereCenter: { value: position.clone() }, - cloudColor: { value: cloudColor }, - skyColor: { value: skyColor }, - fogColor: { value: fogColor }, - }, - vertexShader: ` - precision highp float; - varying vec3 vWorldPosition; - varying vec3 vLocalPosition; - void main() { - vec4 worldPos = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPos.xyz; - vLocalPosition = position; - gl_Position = projectionMatrix * viewMatrix * worldPos; - gl_Position.z = gl_Position.w; - } - `, - fragmentShader: ` - precision highp float; - uniform float time; - uniform float densityThreshold; - uniform float opacity; - uniform float scale; - uniform vec3 sphereCenter; - uniform vec3 cloudColor; - uniform vec3 skyColor; - uniform vec3 fogColor; - varying vec3 vWorldPosition; - varying vec3 vLocalPosition; - - float hash(vec3 p) { - p = mod(p, 289.0); - p = fract(p * vec3(5.3987, 5.4421, 6.9371)); - p += dot(p, p.yxz + 21.5351); - return fract((p.x + p.y) * p.z); - } - - float smoothNoise(vec3 p) { - vec3 i = floor(p); - vec3 f = fract(p); - vec3 u = f * f * (3.0 - 2.0 * f); - return mix( - mix(mix(hash(i), hash(i + vec3(1,0,0)), u.x), - mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), u.x), u.y), - mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), u.x), - mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), u.x), u.y), - u.z - ); - } - - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - for (int i = 0; i < 4; i++) { - value += amplitude * smoothNoise(p * frequency); - amplitude *= 0.5; - frequency *= 2.0; - } - return value; - } - - void main() { - vec3 p = vLocalPosition * (0.05 / scale) + vec3(time * 0.02, 0.0, 0.0); - float d = fbm(p); - d = smoothstep(densityThreshold, densityThreshold + 0.3, d); - // height fade: 0 at equator, 1 at top // <-- added - vec3 dir = normalize(vWorldPosition - sphereCenter); - float heightFactor = dot(dir, vec3(0.0, 1.0, 0.0)); - // float horizonFade = smoothstep(0.1, 0.2, heightFactor); - // 1 at horizon, 0 higher up - // float horizonBlend = 0.8 - smoothstep(0.0, 0.3, heightFactor); - // smooth fog blend: full fog below horizon, fades out above - float horizonBlend = 1.0 - smoothstep(-0.05, 0.25, heightFactor); - // thin out cloud density near the fog zone - float cloudFade = smoothstep(0.0, 0.2, heightFactor); - d *= cloudFade; - - // blend sky → cloud based on density - vec3 baseColor = mix(skyColor, cloudColor, d); - // blend toward fog at the horizon - vec3 finalColor = mix(baseColor, fogColor, horizonBlend); - - // sky is visible at a low base alpha; clouds add opacity on top - float baseAlpha = 0.15; - float finalAlpha = mix(baseAlpha + d * opacity, 1.0, horizonBlend); - - gl_FragColor = vec4(finalColor, finalAlpha); - } - `, - transparent: true, - depthWrite: false, - side: THREE.DoubleSide, - }); - - // A large flat box works well for a cloud layer - // const geometry = new THREE.BoxGeometry(500, 80, 500); - const geometry = new THREE.SphereGeometry(radius, 32, 32); - this.object = new THREE.Mesh(geometry, this.cloudMaterial); - this.object.frustumCulled = false; - this.object.renderOrder = -1; - this.cloudMaterial.depthWrite = false; // you already have this - this.object.position.copy(position); - } - - update(dt?: number) { - // this.cloudMaterial.uniforms.time.value += 0.01; // increment each frame - // keep sphere centered on camera so horizon stays level - this.object.position.x = this.camera.position.x; - this.object.position.z = this.camera.position.z; - this.cloudMaterial.uniforms.sphereCenter.value.copy(this.object.position); - } -} diff --git a/src/trees.ts b/src/trees.ts index 632e06b..a08e2d9 100644 --- a/src/trees.ts +++ b/src/trees.ts @@ -3,7 +3,7 @@ import { type World } from '@dimforge/rapier3d-compat'; import { seededRandom } from '@/utils/random'; import { isMeshObject } from '@/utils/mesh'; import { GROUP_BALL, GROUP_OBJECT } from './physics/ballPhysics'; -import { GroundUtils } from './physics/groundPhysics'; +// import { GroundUtils } from './physics/groundPhysics'; import { QualityMode } from './utils/quality'; export type TreePlanterOptions = { @@ -88,6 +88,8 @@ export class TreePlanter { #raycaster: THREE.Raycaster; lodEntries: LODEntry[] = []; #init: boolean = false; + #lastCamX = 0; + #lastCamZ = 0; #frameNum = 0; constructor(options: TreePlanterOptions) { @@ -126,18 +128,18 @@ export class TreePlanter { #getGroundY(x: number, z: number) { const originY = 200; - if (this.physicsEnabled) { - const ray = new this.rapier!.Ray( - { x, y: originY, z }, - { x: 0, y: -1, z: 0 } - ); - const hit = this.world!.castRay(ray, 500, true); - if (hit == null) { - console.log('No ground hit...'); - return null; - } - return originY - hit.timeOfImpact; - } + // if (this.physicsEnabled) { + // const ray = new this.rapier!.Ray( + // { x, y: originY, z }, + // { x: 0, y: -1, z: 0 } + // ); + // const hit = this.world!.castRay(ray, 500, true); + // if (hit == null) { + // console.log('No ground hit...'); + // return null; + // } + // return originY - hit.timeOfImpact; + // } // Three.js fallback if (!this.groundMeshes || Array.isArray(this.groundMeshes) && this.groundMeshes?.length === 0) return 0; // no ground info, plant at y=0 @@ -384,7 +386,7 @@ export class TreePlanter { } instanced.instanceMatrix.needsUpdate = true; - instanced.castShadow = level !== maxLevel; + instanced.castShadow = true; // level !== maxLevel; instanced.receiveShadow = false; instanced.frustumCulled = false; @@ -474,6 +476,7 @@ export class TreePlanter { const distsSq = lodDistances.map(d => d * d); if (!lodMeshes.length) { return; } const counts = new Array(lodMeshes.length).fill(0); + const prevCounts = lodMeshes.map(meshes => meshes[0]?.count ?? 0); for (let i = 0; i < allMatrices.length; i++) { pos.setFromMatrixPosition(allMatrices[i]); @@ -497,9 +500,11 @@ export class TreePlanter { // console.log(`level: ${level}`); for (let l = 0; l < lodMeshes.length; l++) { + const changed = counts[l] !== prevCounts[l]; for (const mesh of lodMeshes[l]) { mesh.count = counts[l]; - mesh.instanceMatrix.needsUpdate = true; + // mesh.instanceMatrix.needsUpdate = true; + if (changed) mesh.instanceMatrix.needsUpdate = true; } } } @@ -511,7 +516,14 @@ export class TreePlanter { // this.#updateLODs(camera); // } this.#frameNum++; - if (this.#frameNum % 4 === 0) { + // if (this.#frameNum % 4 === 0) { + if (this.#frameNum % 10 === 0) { + const dx = camera.position.x - this.#lastCamX; + const dz = camera.position.z - this.#lastCamZ; + if (dx * dx + dz * dz < 1.0) return; // less than 1m moved, skip + this.#lastCamX = camera.position.x; + this.#lastCamZ = camera.position.z; + this.#updateLODs(camera); } } diff --git a/vite.config.js b/vite.config.js index ae5c1b6..be7faef 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,6 +19,10 @@ export default defineConfig({ emptyOutDir: true, target: 'es2020', rollupOptions: { + external: [ + 'three', + /^three\//, + ], output: { inlineDynamicImports: true, },