@navaramap/three-csm 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ // Based on the following work:
2
+ // https://github.com/StrandedKitty/three-csm/tree/master/src
3
+ // https://github.com/mrdoob/three.js/tree/r169/examples/jsm/csm
4
+
5
+ import {
6
+ Box3,
7
+ Color,
8
+ Matrix4,
9
+ Object3D,
10
+ Vector2,
11
+ Vector3,
12
+ type Material,
13
+ type OrthographicCamera,
14
+ type PerspectiveCamera,
15
+ } from "three";
16
+ import invariant from "tiny-invariant";
17
+
18
+ import { CascadedDirectionalLights } from "./CascadedDirectionalLights";
19
+ import { FrustumCorners } from "./FrustumCorners";
20
+ import { MaterialStates } from "./MaterialStates";
21
+ import { splitFrustum, type FrustumSplitMode } from "./splitFrustum";
22
+
23
+ const vectorScratch1 = /*#__PURE__*/ new Vector3();
24
+ const vectorScratch2 = /*#__PURE__*/ new Vector3();
25
+ const matrixScratch1 = /*#__PURE__*/ new Matrix4();
26
+ const matrixScratch2 = /*#__PURE__*/ new Matrix4();
27
+ const frustumScratch = /*#__PURE__*/ new FrustumCorners();
28
+ const boxScratch = /*#__PURE__*/ new Box3();
29
+
30
+ export type CascadedShadowMapsOptions = {
31
+ /**
32
+ * Number of shadow cascades. More cascades provide better shadow quality
33
+ * distribution but require more GPU resources.
34
+ * @default 4
35
+ */
36
+ cascadeCount?: number;
37
+ /**
38
+ * Resolution of shadow maps (one per cascade). Higher values provide better
39
+ * shadow quality but require more GPU memory.
40
+ * @default 2048
41
+ */
42
+ mapSize?: number;
43
+ /**
44
+ * Frustum far plane distance. Shadows will not be visible farther than this
45
+ * distance from the camera.
46
+ * @default 5000
47
+ */
48
+ far?: number;
49
+ /**
50
+ * Defines the split scheme for the camera frustum:
51
+ * - "uniform": Linear split distribution
52
+ * - "logarithmic": Logarithmic split distribution (better for large scenes)
53
+ * - "practical": Hybrid approach balancing quality and performance (recommended)
54
+ * @default "practical"
55
+ */
56
+ mode?: FrustumSplitMode;
57
+ /**
58
+ * Lambda parameter for "practical" split mode. Controls the blend between
59
+ * uniform (0.0) and logarithmic (1.0) split schemes.
60
+ * @default 0.5
61
+ */
62
+ lambda?: number;
63
+ /**
64
+ * Defines how far the shadow camera is positioned behind the cascade frustum.
65
+ * Larger values help prevent shadow clipping but may reduce precision.
66
+ * @default 200
67
+ */
68
+ margin?: number;
69
+ /**
70
+ * Enables smooth transitions between shadow cascades to reduce visible seams.
71
+ * @default true
72
+ */
73
+ fade?: boolean;
74
+ /**
75
+ * Disables cutting off the last cascade at the far plane, providing better
76
+ * shadow coverage for distant objects.
77
+ * @default false
78
+ */
79
+ disableLastCascadeCutoff?: boolean;
80
+ };
81
+
82
+ export const cascadedShadowMapsOptionsDefaults = {
83
+ cascadeCount: 4,
84
+ mapSize: 2048,
85
+ far: 5000,
86
+ mode: "practical",
87
+ lambda: 0.5,
88
+ margin: 200,
89
+ fade: true,
90
+ disableLastCascadeCutoff: false,
91
+ } satisfies Partial<CascadedShadowMapsOptions>;
92
+
93
+ export class CascadedShadowMaps {
94
+ needsUpdateFrusta = true;
95
+
96
+ readonly directionalLights = new CascadedDirectionalLights();
97
+ readonly materialStates = new MaterialStates();
98
+ readonly mainFrustum = new FrustumCorners();
99
+ readonly cascadedFrusta: FrustumCorners[] = [];
100
+ readonly splits: number[] = [];
101
+ readonly cascades: Vector2[] = [];
102
+
103
+ private _far: number;
104
+ private _mode: FrustumSplitMode;
105
+ private _lambda: number;
106
+ private _margin: number;
107
+ private _fade: boolean;
108
+ private _disableLastCascadeCutoff: boolean;
109
+
110
+ constructor(
111
+ readonly mainCamera: PerspectiveCamera | OrthographicCamera,
112
+ params?: CascadedShadowMapsOptions,
113
+ ) {
114
+ const {
115
+ cascadeCount,
116
+ mapSize,
117
+ far,
118
+ mode,
119
+ lambda,
120
+ margin,
121
+ fade,
122
+ disableLastCascadeCutoff,
123
+ } = { ...cascadedShadowMapsOptionsDefaults, ...params };
124
+ this._far = far;
125
+ this._mode = mode;
126
+ this._lambda = lambda;
127
+ this._margin = margin;
128
+ this._fade = fade;
129
+ this._disableLastCascadeCutoff = disableLastCascadeCutoff;
130
+ this.cascadeCount = cascadeCount;
131
+ this.mapSize = mapSize;
132
+ }
133
+
134
+ dispose(): void {
135
+ this.materialStates.dispose();
136
+ this.directionalLights.dispose();
137
+ }
138
+
139
+ setupMaterial<T extends Material>(material: T): T {
140
+ return this.materialStates.setup(material, this);
141
+ }
142
+
143
+ rollbackMaterial<T extends Material>(material: T): T {
144
+ return this.materialStates.rollback(material);
145
+ }
146
+
147
+ setupMaterials<T extends readonly Material[]>(materials: T): () => void {
148
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
149
+ for (let i = 0; i < materials.length; ++i) {
150
+ this.materialStates.setup(materials[i], this);
151
+ }
152
+ return () => {
153
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
154
+ for (let i = 0; i < materials.length; ++i) {
155
+ this.materialStates.rollback(materials[i]);
156
+ }
157
+ };
158
+ }
159
+
160
+ private updateCascades(): void {
161
+ const cascadeCount = this.cascadeCount;
162
+ const splits = this.splits;
163
+ splitFrustum(
164
+ this.mode,
165
+ cascadeCount,
166
+ this.mainCamera.near,
167
+ Math.min(this.mainCamera.far, this.far),
168
+ this.lambda,
169
+ splits,
170
+ );
171
+ this.mainFrustum.setFromCamera(this.mainCamera, this.far);
172
+ this.mainFrustum.split(splits, this.cascadedFrusta);
173
+
174
+ const cascades = this.cascades;
175
+ for (let i = 0; i < cascadeCount; ++i) {
176
+ const vector = cascades[i] ?? (cascades[i] = new Vector2());
177
+ vector.set(splits[i - 1] ?? 0, splits[i] ?? 0);
178
+ }
179
+ if (this.disableLastCascadeCutoff) {
180
+ cascades[cascadeCount - 1].y = Infinity;
181
+ }
182
+ cascades.length = cascadeCount;
183
+ }
184
+
185
+ private getFrustumRadius(frustum: FrustumCorners): number {
186
+ // Get the two points that represent that furthest points on the frustum
187
+ // assuming that's either the diagonal across the far plane or the diagonal
188
+ // across the whole frustum itself.
189
+ const nearCorners = frustum.near;
190
+ const farCorners = frustum.far;
191
+ let diagonalLength = Math.max(
192
+ farCorners[0].distanceTo(farCorners[2]),
193
+ farCorners[0].distanceTo(nearCorners[2]),
194
+ );
195
+
196
+ // Expand the shadow bounds by the fade width.
197
+ if (this.fade) {
198
+ const camera = this.mainCamera;
199
+ const near = camera.near;
200
+ const far = Math.min(camera.far, this.far);
201
+ const distance = farCorners[0].z / (far - near);
202
+ diagonalLength += 0.25 * distance ** 2 * (far - near);
203
+ }
204
+ return diagonalLength * 0.5;
205
+ }
206
+
207
+ private updateShadowBounds(): void {
208
+ const frusta = this.cascadedFrusta;
209
+ const lights = this.directionalLights.cascadedLights;
210
+ invariant(frusta.length === lights.length);
211
+
212
+ for (let i = 0; i < frusta.length; ++i) {
213
+ const radius = this.getFrustumRadius(this.cascadedFrusta[i]);
214
+ const light = lights[i];
215
+ const camera = light.shadow.camera;
216
+ camera.left = -radius;
217
+ camera.right = radius;
218
+ camera.top = radius;
219
+ camera.bottom = -radius;
220
+ camera.near = 0;
221
+ camera.far = radius * 2 + this.margin;
222
+ camera.updateProjectionMatrix();
223
+ }
224
+ }
225
+
226
+ updateFrusta(): void {
227
+ this.updateCascades();
228
+ this.updateShadowBounds();
229
+ this.materialStates.update(this);
230
+ }
231
+
232
+ update(): void {
233
+ if (this.needsUpdateFrusta) {
234
+ this.needsUpdateFrusta = false;
235
+ this.updateFrusta();
236
+ }
237
+
238
+ const directionalLight = this.directionalLights;
239
+ const lightDirection = vectorScratch1
240
+ .copy(directionalLight.direction)
241
+ .normalize();
242
+ const lightOrientationMatrix = matrixScratch1.lookAt(
243
+ new Vector3(),
244
+ lightDirection,
245
+ Object3D.DEFAULT_UP,
246
+ );
247
+ const cameraToLightMatrix = matrixScratch2.multiplyMatrices(
248
+ matrixScratch2.copy(lightOrientationMatrix).invert(),
249
+ this.mainCamera.matrixWorld,
250
+ );
251
+
252
+ const margin = this.margin;
253
+ const mapSize = this.mapSize;
254
+ const frusta = this.cascadedFrusta;
255
+ const lights = directionalLight.cascadedLights;
256
+ for (let i = 0; i < frusta.length; ++i) {
257
+ const { near, far } = frustumScratch
258
+ .copy(frusta[i])
259
+ .applyMatrix4(cameraToLightMatrix);
260
+ const bbox = boxScratch.makeEmpty();
261
+ for (let j = 0; j < 4; j++) {
262
+ bbox.expandByPoint(near[j]);
263
+ bbox.expandByPoint(far[j]);
264
+ }
265
+ const center = bbox.getCenter(vectorScratch2);
266
+ center.z = bbox.max.z + margin;
267
+
268
+ // Round light-space translation to even texel increments.
269
+ const light = lights[i];
270
+ const { left, right, top, bottom } = light.shadow.camera;
271
+ const texelWidth = (right - left) / mapSize;
272
+ const texelHeight = (top - bottom) / mapSize;
273
+ center.x = Math.round(center.x / texelWidth) * texelWidth;
274
+ center.y = Math.round(center.y / texelHeight) * texelHeight;
275
+
276
+ center.applyMatrix4(lightOrientationMatrix);
277
+ light.position.copy(center);
278
+ light.target.position.copy(center);
279
+ light.target.position.add(lightDirection);
280
+ }
281
+ }
282
+
283
+ get cascadeCount(): number {
284
+ return this.directionalLights.cascadedLights.length;
285
+ }
286
+
287
+ set cascadeCount(value: number) {
288
+ if (value !== this.cascadeCount) {
289
+ this.directionalLights.setCount(value);
290
+ this.needsUpdateFrusta = true;
291
+ }
292
+ }
293
+
294
+ get mapSize(): number {
295
+ return this.directionalLights.mainLight.shadow.mapSize.width;
296
+ }
297
+
298
+ set mapSize(value: number) {
299
+ if (value !== this.mapSize) {
300
+ const lights = this.directionalLights.cascadedLights;
301
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
302
+ for (let i = 0; i < lights.length; ++i) {
303
+ const shadow = lights[i].shadow;
304
+ shadow.mapSize.width = value;
305
+ shadow.mapSize.height = value;
306
+ if (shadow.map != null) {
307
+ shadow.map.dispose();
308
+ shadow.map = null;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ get far(): number {
315
+ return this._far;
316
+ }
317
+
318
+ set far(value: number) {
319
+ if (value !== this._far) {
320
+ this._far = value;
321
+ this.needsUpdateFrusta = true;
322
+ }
323
+ }
324
+
325
+ get mode(): FrustumSplitMode {
326
+ return this._mode;
327
+ }
328
+
329
+ set mode(value: FrustumSplitMode) {
330
+ if (value !== this._mode) {
331
+ this._mode = value;
332
+ this.needsUpdateFrusta = true;
333
+ }
334
+ }
335
+
336
+ get lambda(): number {
337
+ return this._lambda;
338
+ }
339
+
340
+ set lambda(value: number) {
341
+ if (value !== this._lambda) {
342
+ this._lambda = value;
343
+ this.needsUpdateFrusta = true;
344
+ }
345
+ }
346
+
347
+ get margin(): number {
348
+ return this._margin;
349
+ }
350
+
351
+ set margin(value: number) {
352
+ if (value !== this._margin) {
353
+ this._margin = value;
354
+ this.needsUpdateFrusta = true;
355
+ }
356
+ }
357
+
358
+ get fade(): boolean {
359
+ return this._fade;
360
+ }
361
+
362
+ set fade(value: boolean) {
363
+ if (value !== this._fade) {
364
+ this._fade = value;
365
+ this.needsUpdateFrusta = true;
366
+ }
367
+ }
368
+
369
+ get disableLastCascadeCutoff(): boolean {
370
+ return this._disableLastCascadeCutoff;
371
+ }
372
+
373
+ set disableLastCascadeCutoff(value: boolean) {
374
+ if (value !== this._disableLastCascadeCutoff) {
375
+ this._disableLastCascadeCutoff = value;
376
+ this.needsUpdateFrusta = true;
377
+ }
378
+ }
379
+
380
+ // Proxy properties for cascaded lights:
381
+
382
+ get intensity(): number {
383
+ return this.directionalLights.mainLight.intensity;
384
+ }
385
+
386
+ set intensity(value: number) {
387
+ if (value !== this.intensity) {
388
+ const lights = this.directionalLights.cascadedLights;
389
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
390
+ for (let i = 0; i < lights.length; ++i) {
391
+ lights[i].intensity = value;
392
+ }
393
+ }
394
+ }
395
+
396
+ get shadowIntensity(): number {
397
+ return this.directionalLights.mainLight.shadow.intensity;
398
+ }
399
+
400
+ set shadowIntensity(value: number) {
401
+ if (value !== this.shadowIntensity) {
402
+ const lights = this.directionalLights.cascadedLights;
403
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
404
+ for (let i = 0; i < lights.length; ++i) {
405
+ lights[i].shadow.intensity = value;
406
+ }
407
+ }
408
+ }
409
+
410
+ get color(): Color {
411
+ return this.directionalLights.mainLight.color;
412
+ }
413
+
414
+ set color(value: Color) {
415
+ if (value !== this.color) {
416
+ this.directionalLights.mainLight.color.copy(value);
417
+ }
418
+ }
419
+
420
+ get bias(): number {
421
+ return this.directionalLights.mainLight.shadow.bias;
422
+ }
423
+
424
+ set bias(value: number) {
425
+ if (value !== this.bias) {
426
+ const lights = this.directionalLights.cascadedLights;
427
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
428
+ for (let i = 0; i < lights.length; ++i) {
429
+ lights[i].shadow.bias = value;
430
+ }
431
+ }
432
+ }
433
+
434
+ get normalBias(): number {
435
+ return this.directionalLights.mainLight.shadow.normalBias;
436
+ }
437
+
438
+ set normalBias(value: number) {
439
+ if (value !== this.normalBias) {
440
+ const lights = this.directionalLights.cascadedLights;
441
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
442
+ for (let i = 0; i < lights.length; ++i) {
443
+ lights[i].shadow.normalBias = value;
444
+ }
445
+ }
446
+ }
447
+
448
+ get radius(): number {
449
+ return this.directionalLights.mainLight.shadow.radius;
450
+ }
451
+
452
+ set radius(value: number) {
453
+ if (value !== this.radius) {
454
+ const lights = this.directionalLights.cascadedLights;
455
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
456
+ for (let i = 0; i < lights.length; ++i) {
457
+ lights[i].shadow.radius = value;
458
+ }
459
+ }
460
+ }
461
+
462
+ get blurSamples(): number {
463
+ return this.directionalLights.mainLight.shadow.blurSamples;
464
+ }
465
+
466
+ set blurSamples(value: number) {
467
+ if (value !== this.blurSamples) {
468
+ const lights = this.directionalLights.cascadedLights;
469
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
470
+ for (let i = 0; i < lights.length; ++i) {
471
+ lights[i].shadow.blurSamples = value;
472
+ }
473
+ }
474
+ }
475
+ }
@@ -0,0 +1,111 @@
1
+ // Based on the following work:
2
+ // https://github.com/StrandedKitty/three-csm/tree/master/src
3
+ // https://github.com/mrdoob/three.js/tree/r169/examples/jsm/csm
4
+
5
+ import { Vector3, type Camera, type Matrix4 } from "three";
6
+
7
+ export class FrustumCorners {
8
+ readonly near = [new Vector3(), new Vector3(), new Vector3(), new Vector3()];
9
+ readonly far = [new Vector3(), new Vector3(), new Vector3(), new Vector3()];
10
+
11
+ constructor();
12
+ constructor(camera: Camera, far: number);
13
+ constructor(camera?: Camera, far?: number) {
14
+ if (camera != null && far != null) {
15
+ this.setFromCamera(camera, far);
16
+ }
17
+ }
18
+
19
+ clone(): FrustumCorners {
20
+ return new FrustumCorners().copy(this);
21
+ }
22
+
23
+ copy(other: FrustumCorners): this {
24
+ for (let i = 0; i < 4; ++i) {
25
+ this.near[i].copy(other.near[i]);
26
+ this.far[i].copy(other.far[i]);
27
+ }
28
+ return this;
29
+ }
30
+
31
+ setFromCamera(
32
+ camera: Camera & { isOrthographicCamera?: boolean },
33
+ far: number,
34
+ ): this {
35
+ const isOrthographic = camera.isOrthographicCamera === true;
36
+ const inverseProjectionMatrix = camera.projectionMatrixInverse;
37
+
38
+ // 3 --- 0
39
+ // | |
40
+ // 2 --- 1
41
+ // Clip space spans from [-1, 1]
42
+ this.near[0].set(1, 1, -1);
43
+ this.near[1].set(1, -1, -1);
44
+ this.near[2].set(-1, -1, -1);
45
+ this.near[3].set(-1, 1, -1);
46
+ for (let i = 0; i < 4; ++i) {
47
+ this.near[i].applyMatrix4(inverseProjectionMatrix);
48
+ }
49
+
50
+ this.far[0].set(1, 1, 1);
51
+ this.far[1].set(1, -1, 1);
52
+ this.far[2].set(-1, -1, 1);
53
+ this.far[3].set(-1, 1, 1);
54
+ for (let i = 0; i < 4; ++i) {
55
+ const corner = this.far[i];
56
+ corner.applyMatrix4(inverseProjectionMatrix);
57
+ const absZ = Math.abs(corner.z);
58
+ if (isOrthographic) {
59
+ corner.z *= Math.min(far / absZ, 1);
60
+ } else {
61
+ corner.multiplyScalar(Math.min(far / absZ, 1));
62
+ }
63
+ }
64
+ return this;
65
+ }
66
+
67
+ split(
68
+ clipDepths: readonly number[],
69
+ result: FrustumCorners[] = [],
70
+ ): FrustumCorners[] {
71
+ for (let index = 0; index < clipDepths.length; ++index) {
72
+ const frustum = (result[index] ??= new FrustumCorners());
73
+ if (index === 0) {
74
+ for (let i = 0; i < 4; ++i) {
75
+ frustum.near[i].copy(this.near[i]);
76
+ }
77
+ } else {
78
+ for (let i = 0; i < 4; ++i) {
79
+ frustum.near[i].lerpVectors(
80
+ this.near[i],
81
+ this.far[i],
82
+ clipDepths[index - 1],
83
+ );
84
+ }
85
+ }
86
+ if (index === clipDepths.length - 1) {
87
+ for (let i = 0; i < 4; ++i) {
88
+ frustum.far[i].copy(this.far[i]);
89
+ }
90
+ } else {
91
+ for (let i = 0; i < 4; ++i) {
92
+ frustum.far[i].lerpVectors(
93
+ this.near[i],
94
+ this.far[i],
95
+ clipDepths[index],
96
+ );
97
+ }
98
+ }
99
+ }
100
+ result.length = clipDepths.length;
101
+ return result;
102
+ }
103
+
104
+ applyMatrix4(matrix: Matrix4): this {
105
+ for (let i = 0; i < 4; ++i) {
106
+ this.near[i].applyMatrix4(matrix);
107
+ this.far[i].applyMatrix4(matrix);
108
+ }
109
+ return this;
110
+ }
111
+ }