Skip to content

Commit 8628ccb

Browse files
feat: shadow maps and adaptive resolution (#19)
1 parent 45286f2 commit 8628ccb

11 files changed

Lines changed: 364 additions & 225 deletions

File tree

src/courses/loader.ts

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { QualityMode } from '@/utils/quality';
2121
import { DefaultGimmeDistances } from '@/utils/data';
2222
import { Hole } from './types';
2323
import { LakeSurface, RiverSurface, VolumetricClouds } from '@/shaders';
24+
import { LightmapMaterial, applyLightmapShadow, configureLightmapTexture } from '@/shaders/lightmap';
2425
import { FuseRenderer } from '@/renderer';
2526
import { type GolfBall } from '@/objects/golfBall';
2627
import { PuttingGridMaterial } from '@/shaders/putting';
@@ -72,13 +73,14 @@ type MeshLoaderOptions = {
7273
}
7374
export class MeshLoader extends EventEmitter<CourseLoaderEvents> {
7475
gltfLoader: GLTFLoader;
75-
76+
ktx2Loader: KTX2Loader;
77+
7678
constructor(renderer: FuseRenderer, manager?: THREE.LoadingManager, options: MeshLoaderOptions = {}) {
7779
super();
7880
const ktx2Path = options.ktx2Path ?? '/ktx2/';
79-
const ktx2Loader = new KTX2Loader().setTranscoderPath(ktx2Path).detectSupport(renderer.renderer);
81+
this.ktx2Loader = new KTX2Loader().setTranscoderPath(ktx2Path).detectSupport(renderer.renderer);
8082
this.gltfLoader = new GLTFLoader(manager);
81-
this.gltfLoader.setKTX2Loader(ktx2Loader);
83+
this.gltfLoader.setKTX2Loader(this.ktx2Loader);
8284
}
8385

8486
async load(meshUri: string, firstMeshOnly?: false): Promise<THREE.Group>;
@@ -207,6 +209,8 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
207209
planter?: TreePlanter;
208210
clouds?: VolumetricClouds;
209211
light?: CourseLight;
212+
lightmaps: Map<string, LightmapMaterial | THREE.Material>;
213+
#lightmapTexture?: THREE.Texture;
210214
#renderer: FuseRenderer;
211215
#camera: ShotPerspectiveCamera;
212216
#raycaster: THREE.Raycaster;
@@ -239,7 +243,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
239243
this.grasses = new Map();
240244
this.greenGrids = new Map();
241245
this.#blendMaps = new Map();
242-
246+
this.lightmaps = new Map();
243247
this.#raycaster = new THREE.Raycaster();
244248
this.#origin = new THREE.Vector3();
245249
this.#direction = new THREE.Vector3(0, -1, 0);
@@ -291,6 +295,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
291295
this._setupCourseSurfaces();
292296
this._parseCourseHoles();
293297
this._addWater();
298+
await this._applyLightmap();
294299
await this._addSkyAndEnvironment(scene);
295300
await this._addTrees();
296301
await this._parseMap();
@@ -300,6 +305,8 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
300305
// are referenced by the scene, not the parser.)
301306
this.gltf = undefined;
302307

308+
scene.add(this.scene);
309+
303310
return this.scene;
304311
}
305312

@@ -358,9 +365,50 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
358365
}
359366
}
360367

368+
// Baked lightmap — loaded here so grass (built in _setupCourseSurfaces)
369+
// can sample it; surfaces consume it later in _applyLightmap.
370+
const lmImages = parser.json?.images || [];
371+
const lmIndex = lmImages.findIndex((img: any) => img.extras?.type === 'light_map');
372+
if (lmIndex !== -1) {
373+
const lmDef = lmImages[lmIndex] as any;
374+
const lmBuffer = await parser.getDependency('bufferView', lmDef.bufferView);
375+
let tex: THREE.Texture;
376+
if (lmDef.mimeType === 'image/ktx2') {
377+
// KTX2-compressed lightmap: decode via the shared KTX2Loader
378+
const blobUrl = URL.createObjectURL(new Blob([lmBuffer]));
379+
try {
380+
tex = await this.meshLoader.ktx2Loader.loadAsync(blobUrl);
381+
} finally {
382+
URL.revokeObjectURL(blobUrl);
383+
}
384+
} else {
385+
// PNG path — courses exported before KTX2 lightmaps
386+
const lmBitmap = await createImageBitmap(new Blob([lmBuffer]), { premultiplyAlpha: 'none' });
387+
tex = new THREE.Texture(lmBitmap);
388+
}
389+
this.#lightmapTexture = configureLightmapTexture(tex);
390+
391+
}
392+
393+
361394

362395
}
363396

397+
async _applyLightmap() {
398+
if (!this.scene) return;
399+
const tex = this.#lightmapTexture;
400+
if (!tex) {
401+
console.warn('No shadow map found!');
402+
return; // course exported before baking existed — no-op
403+
}
404+
const worldSize = this.courseSize;
405+
406+
for (const { mesh } of this.surfaces.values()) {
407+
const applied = applyLightmapShadow(mesh, tex, { worldSize });
408+
if (applied) this.lightmaps.set(mesh.uuid, applied);
409+
}
410+
}
411+
364412
_setupCourseSurfaces() {
365413
if (!this.scene) throw new Error('No scene defined!');
366414
if (!this.gltf) throw new Error('Course file not loaded');
@@ -386,6 +434,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
386434
if (!(child instanceof THREE.Mesh)) { return; }
387435
if (!child.isMesh || !child.geometry?.attributes.position) return;
388436
child.receiveShadow = true;
437+
child.castShadow = false;
389438

390439
// Disable vertex color rendering on all meshes —
391440
// we only use them as data, not visual color
@@ -406,7 +455,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
406455

407456
const blendMap = this.#blendMaps.get(child.userData.id);
408457
if (blendMap) {
409-
458+
// TODO: hard-code the neighbor material settings at export time
410459
// const neighborMesh = this.findNeighborMesh(child, allSurfaceMeshes);
411460
// if (neighborMesh && this.grassAssets?.noiseTexture) {
412461
// const sand = new SandMaterial(
@@ -421,19 +470,24 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
421470
// }
422471

423472
} else if (this.qualityLevel > QualityMode.Medium && surfaceType === 'rough') {
473+
let renderDistance = this.qualityLevel === QualityMode.VeryHigh ? 100 : 60;
424474
const grassOptions = {
425475
density: 15,
426-
renderDistance: 50,
427-
cellSize: 10,
476+
clumpSpread: 0.4,
477+
lightmap: this.#lightmapTexture,
478+
lightmapWorldSize: this.courseSize,
479+
renderDistance,
480+
// cellSize: 10,
481+
layer: 2,
428482
lean: 0.01,
429483
heightVariation: 0.5,
430-
maxNewCellsPerFrame: 20,
484+
// maxNewCellsPerFrame: 20,
431485
scaleXZ: 0.6,
432-
scaleY: 0.65,
433-
layer: 2,
434-
baseColor: new THREE.Color('#415722'),
435-
tipColor1: new THREE.Color('#5c7c2e'),
436-
tipColor2: new THREE.Color('#ffffff'),
486+
scaleY: 0.5,
487+
rootDarken: 0.4,
488+
// baseColor: new THREE.Color('#415722'),
489+
// tipColor1: new THREE.Color('#5c7c2e'),
490+
// tipColor2: new THREE.Color('#ffffff'),
437491
};
438492

439493
// if (this.qualityLevel > QualityMode.Medium) {
@@ -447,16 +501,20 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
447501

448502
} else if (this.qualityLevel > QualityMode.Medium && ['deep_rough', 'base'].includes(surfaceType)) {
449503

504+
let renderDistance = this.qualityLevel === QualityMode.VeryHigh ? 100 : 60;
505+
450506
const grass = new GrassShader(child, this.grassAssets!, {
451-
density: 8,
452-
renderDistance: 60,
453-
cellSize: 10,
454-
lean: 0.03,
507+
density: 2,
508+
lightmap: this.#lightmapTexture,
509+
lightmapWorldSize: this.courseSize,
510+
renderDistance,
511+
// cellSize: 10,
455512
layer: 2,
456-
heightVariation: 0.1,
457-
maxNewCellsPerFrame: 10,
458-
scaleXZ: 0.8,
459-
scaleY: 0.6,
513+
lean: 0.03,
514+
heightVariation: 0.8,
515+
// maxNewCellsPerFrame: 10,
516+
scaleXZ: 0.9,
517+
scaleY: 0.7,
460518
});
461519
this.scene.add(grass.mesh);
462520
this.grasses.set(child.uuid, grass);
@@ -524,8 +582,6 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
524582
);
525583
const buffer = await parser.getDependency('bufferView', courseMap.bufferView);
526584
const blob = new Blob([buffer], { type: 'image/jpeg' });
527-
const bitmap = await window.createImageBitmap(blob, { premultiplyAlpha: 'none' });
528-
// this.courseMap = bitmap;
529585
// Probe native dimensions, then release the full-res decode immediately
530586
const probe = await window.createImageBitmap(blob, { premultiplyAlpha: 'none' });
531587
const fullW = probe.width;
@@ -562,8 +618,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
562618
qualityLevel: this.qualityLevel,
563619
// world: this.world,
564620
// rapier: this.rapier,
565-
groundMeshes: this.getGroundMeshes(),
566-
refreshShadows: () => this.light?.refreshShadows(),
621+
groundMeshes: this.getGroundMeshes()
567622
});
568623

569624
const treeConfigs: Record<string, TreeGroup[]> = {};
@@ -576,10 +631,10 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
576631
let lodDistances = [0, 40];
577632
let maxDistance = 500;
578633
if (this.qualityLevel === QualityMode.Medium) {
579-
lodDistances = [60, 100];
634+
lodDistances = [120, 200];
580635
maxDistance = 800;
581636
} else if (this.qualityLevel === QualityMode.High) {
582-
lodDistances = [200, 400];
637+
lodDistances = [150, 200];
583638
maxDistance = Infinity;
584639
}
585640
console.log(`[plant] Planting tree layer... (lods:${lodDistances.join(',')})`, group);
@@ -590,14 +645,14 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
590645
},
591646
scaleRange: { min: 1, max: 1 },
592647
density: 1,
593-
minDistance: 3,
648+
minDistance: 8,
594649
maxDistance,
595650
lodDistances,
596651
colors: [],
597652
meshGroup: group,
598653
...child.userData
599654
};
600-
655+
console.log('config', config);
601656
if (!treeConfigs?.[layerId]) {
602657
treeConfigs[layerId] = [config];
603658
} else {
@@ -696,7 +751,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
696751

697752
let lightOptions = {
698753
qualityLevel: this.qualityLevel,
699-
color: new THREE.Color('#fffac0'),
754+
color: new THREE.Color('#fffcdd'),
700755
directional: { enabled: true },
701756
ambient: { enabled: true }
702757
};
@@ -711,12 +766,12 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
711766
cloudColor,
712767
fogColor,
713768
skyColor,
714-
position: new THREE.Vector3(0, -40, 0)
769+
// position: new THREE.Vector3(0, -40, 0)
715770
});
716771
scene.add(this.clouds.object);
717772
this.#renderer.generateEnvironment(scene, this.clouds.object);
718773

719-
const fog = new THREE.Fog(fogColor, 500, 1200);
774+
const fog = new THREE.Fog(fogColor, 500, 900);
720775
scene.fog = fog;
721776
// gameContext.fog = new THREE.Fog(fogColor, 300, 800);
722777
// gameContext.scene.fog = gameContext.fog;
@@ -732,7 +787,7 @@ export class CourseLoader extends EventEmitter<CourseLoaderEvents> {
732787
const buffer: ArrayBuffer = await parser.getDependency('bufferView', skyboxDef.bufferView);
733788
const box = new SkyBox();
734789
box.load(scene, buffer);
735-
scene.environmentIntensity = 0.25;
790+
scene.environmentIntensity = 0.15;
736791
}
737792
}
738793

src/courses/surfaces.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export enum CourseSurfaceType {
88
Sand = 'sand',
99
Water = 'water',
1010
River = 'river',
11-
CartPath = 'cart_path',
11+
Concrete = 'concrete',
1212
PlaneLake = 'plane_lake',
1313
PlaneRiver = 'plane_river',
1414
PineStraw = 'pine_straw',
@@ -120,23 +120,23 @@ export const CourseSurfaces: Record<CourseSurfaceType, CourseSurfaceProperties>
120120
restitution: 0.00,
121121
rollResistance: 1.00
122122
},
123-
[CourseSurfaceType.CartPath]: {
123+
[CourseSurfaceType.Concrete]: {
124124
hasCollider: true,
125125
friction: 0.3,
126-
restitution: 0.50,
127-
rollResistance: 0.01
126+
restitution: 0.80,
127+
rollResistance: 0.2
128128
},
129129
[CourseSurfaceType.PlaneLake]: {
130130
hasCollider: false,
131131
friction: 0.3,
132132
restitution: 0.50,
133-
rollResistance: 0.01
133+
rollResistance: 0.2
134134
},
135135
[CourseSurfaceType.PlaneRiver]: {
136136
hasCollider: false,
137137
friction: 0.3,
138138
restitution: 0.50,
139-
rollResistance: 0.01
139+
rollResistance: 0.2
140140
},
141141
[CourseSurfaceType.PineStraw]: {
142142
hasCollider: true,

src/lights.ts

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import * as THREE from 'three';
22
import { QualityMode } from '@/utils/quality';
3+
import { sunDirectionFromAngles, SunSettings } from '@/utils/sun';
34

45
export type CourseLightOptions = {
56
color?: THREE.ColorRepresentation | undefined,
67
qualityLevel?: QualityMode,
8+
sun?: SunSettings,
9+
worldSize?: number,
10+
711
ambient?: {
812
enabled?: boolean,
913
intensity?: number
@@ -13,6 +17,8 @@ export type CourseLightOptions = {
1317
intensity?: number
1418
}
1519
}
20+
21+
1622
export class CourseLight extends THREE.Group {
1723
ambient?: THREE.AmbientLight;
1824
overhead?: THREE.DirectionalLight;
@@ -22,52 +28,32 @@ export class CourseLight extends THREE.Group {
2228
const color = options.color ?? new THREE.Color('#ffffee');
2329
console.log('light-options', options);
2430
const ambientEnabled = options.ambient?.enabled !== false;
25-
const ambientIntensity = options.ambient?.intensity ?? 0.9;
31+
const ambientIntensity = options.ambient?.intensity ?? 0.35;
2632
if (ambientEnabled) {
2733
// Bright warm ambient
2834
this.ambient = new THREE.AmbientLight(color, ambientIntensity);
2935
this.add(this.ambient);
3036
}
3137

3238
const directionalEnabled = options.directional?.enabled !== false;
33-
const directionalIntensity = options.directional?.intensity ?? 0.8;
39+
const directionalIntensity = options.directional?.intensity ?? 1.3;
3440
if (directionalEnabled) {
3541
// Main overhead light for shadows
3642
this.overhead = new THREE.DirectionalLight(color, directionalIntensity);
37-
this.overhead.position.set(600, 300, 600);
38-
this.overhead.castShadow = true;
39-
40-
let shadowMapSize = 1024;
41-
if (options.qualityLevel === QualityMode.Medium) {
42-
shadowMapSize = 2048;
43-
} else if (options.qualityLevel === QualityMode.High) {
44-
shadowMapSize = 4096;
45-
}
46-
this.overhead.shadow.mapSize.width = shadowMapSize; // Higher = crisper shadows
47-
this.overhead.shadow.mapSize.height = shadowMapSize;
48-
this.overhead.shadow.camera.near = 1;
49-
// Adjust these to match the size of your scene
50-
this.overhead.shadow.camera.far = 700;
51-
this.overhead.shadow.camera.left = -500;
52-
this.overhead.shadow.camera.right = 500;
53-
this.overhead.shadow.camera.top = 500;
54-
this.overhead.shadow.camera.bottom = -500;
43+
// this.overhead.position.set(900, 300, 900);
44+
// this.overhead.castShadow = true;
45+
const center = (options.worldSize ?? 1000) / 2;
46+
// const dir = sunDirectionFromAngles(options.sun?.elevation, options.sun?.azimuth);
47+
const dir = new THREE.Vector3(...sunDirectionFromAngles(options.sun?.elevation, options.sun?.azimuth));
5548

56-
// Static sun + static course: render the shadow map on demand only.
57-
// Anything that changes shadow casters must call refreshShadows().
58-
this.overhead.shadow.autoUpdate = false;
59-
this.overhead.shadow.needsUpdate = true; // render once on first frame
60-
61-
// center of world
62-
this.overhead.target.position.set(500, 0, 500);
49+
// Directional lights only use position→target direction; distance is arbitrary
50+
this.overhead.target.position.set(center, 0, center);
51+
this.overhead.position.copy(this.overhead.target.position).addScaledVector(dir, -1000);
52+
this.overhead.castShadow = false; // shadows come from the baked lightmap
6353

6454
this.add(this.overhead.target);
6555
this.add(this.overhead);
6656
}
6757
}
6858

69-
refreshShadows() {
70-
if (this.overhead) this.overhead.shadow.needsUpdate = true;
71-
}
72-
7359
}

0 commit comments

Comments
 (0)