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,
},