Skip to content

Commit a5c35a2

Browse files
xsyetopzclaude
andcommitted
feat(fog): replace linear fog with EXP2 LUT
Precomputed 256-entry Float32Array LUT using 1 - exp(-(density*t)^2) where t is normalized depth across [near, far]. Default density 2.5 gives ~99.8% fog at far regardless of scene scale — linear fog always reached 100% at far. SceneTraversal now does a branchless LUT index lookup per vertex instead of the linear (depth - near) * invRange. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7b91497 commit a5c35a2

3 files changed

Lines changed: 106 additions & 13 deletions

File tree

src/pipeline/SceneTraversal.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ interface SceneLike {
6565
near: number;
6666
far: number;
6767
color: { r: number; g: number; b: number };
68+
lut: Float32Array;
6869
};
6970
autoUpdate?: boolean;
7071
}
@@ -75,6 +76,8 @@ const VERT_STRIDE = 4;
7576
export class SceneTraversal {
7677
#fogNear = 0;
7778
#fogFar = 0;
79+
#fogLutScale = 0;
80+
#fogLut: Float32Array | null = null;
7881
#hasFog = false;
7982
#autoUpdate = false;
8083

@@ -100,6 +103,9 @@ export class SceneTraversal {
100103
this.#hasFog = !!fog;
101104
this.#fogNear = fog?.near ?? 0;
102105
this.#fogFar = fog?.far ?? 0;
106+
this.#fogLut = fog?.lut ?? null;
107+
this.#fogLutScale =
108+
fog && fog.far - fog.near > 0 ? 255 / (fog.far - fog.near) : 0;
103109

104110
_vp.copy(camera.projectionMatrix).mul(camera.matrixWorldInverse);
105111
_frustum.setFromProjectionMatrix(_vp);
@@ -470,8 +476,8 @@ export class SceneTraversal {
470476

471477
const hasFog = this.#hasFog;
472478
const fogNear = this.#fogNear;
473-
const fogInvRange =
474-
this.#fogFar - fogNear > 0 ? 1 / (this.#fogFar - fogNear) : 0;
479+
const fogLutScale = this.#fogLutScale;
480+
const fogLut = this.#fogLut;
475481
const wnLen = worldNormals.length;
476482
const uvLen = uvs.length;
477483
const wpLen = worldPositions.length;
@@ -511,13 +517,13 @@ export class SceneTraversal {
511517
let ff0 = 0;
512518
let ff1 = 0;
513519
let ff2 = 0;
514-
if (hasFog) {
515-
const raw0 = (w0 - fogNear) * fogInvRange;
516-
const raw1 = (w1 - fogNear) * fogInvRange;
517-
const raw2 = (w2 - fogNear) * fogInvRange;
518-
ff0 = raw0 < 0 ? 0 : raw0 > 1 ? 1 : raw0;
519-
ff1 = raw1 < 0 ? 0 : raw1 > 1 ? 1 : raw1;
520-
ff2 = raw2 < 0 ? 0 : raw2 > 1 ? 1 : raw2;
520+
if (hasFog && fogLut) {
521+
const i0f = ((w0 - fogNear) * fogLutScale) | 0;
522+
const i1f = ((w1 - fogNear) * fogLutScale) | 0;
523+
const i2f = ((w2 - fogNear) * fogLutScale) | 0;
524+
ff0 = i0f <= 0 ? 0 : i0f >= 255 ? fogLut[255] : fogLut[i0f];
525+
ff1 = i1f <= 0 ? 0 : i1f >= 255 ? fogLut[255] : fogLut[i1f];
526+
ff2 = i2f <= 0 ? 0 : i2f >= 255 ? fogLut[255] : fogLut[i2f];
521527
}
522528

523529
let fnx = 0;

src/scenes/Fog.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,46 @@
11
import { Color } from "../math/Color.ts";
22

3+
const FOG_LUT_SIZE = 256;
4+
35
interface FogOptions {
46
color?: Color | number | string;
57
near?: number;
68
far?: number;
9+
density?: number;
710
}
811

912
/**
10-
* Linear fog that blends fragment colors toward a configurable color based on
11-
* camera-space depth. Objects at `near` are unaffected; objects at `far` are
12-
* fully fogged.
13+
* EXP2 fog. Blends fragments toward `color` with factor `1 - exp(-(density * t)^2)` where
14+
* `t` is normalized depth (0 at `near`, 1 at `far`). Default density 2.5 gives ~99.8% fog
15+
* at `far` regardless of scene scale. LUT is rebuilt when `density` changes.
1316
*/
1417
export class Fog {
1518
#color: Color;
1619
#near: number;
1720
#far: number;
21+
#density: number;
22+
#lut: Float32Array = new Float32Array(FOG_LUT_SIZE);
1823

19-
constructor({ color = 0x000000, near = 1, far = 100 }: FogOptions = {}) {
24+
constructor({
25+
color = 0x000000,
26+
near = 1,
27+
far = 100,
28+
density = 2.5,
29+
}: FogOptions = {}) {
2030
this.#color = color instanceof Color ? color : new Color(color);
2131
this.#near = near;
2232
this.#far = far;
33+
this.#density = density;
34+
this.#buildLut();
35+
}
36+
37+
#buildLut(): void {
38+
const density = this.#density;
39+
for (let i = 0; i < FOG_LUT_SIZE; i++) {
40+
const t = i / (FOG_LUT_SIZE - 1);
41+
const dd = density * t;
42+
this.#lut[i] = 1 - Math.exp(-(dd * dd));
43+
}
2344
}
2445

2546
get color(): Color {
@@ -42,11 +63,25 @@ export class Fog {
4263
this.#far = value;
4364
}
4465

66+
get density(): number {
67+
return this.#density;
68+
}
69+
70+
set density(value: number) {
71+
this.#density = value;
72+
this.#buildLut();
73+
}
74+
75+
get lut(): Float32Array {
76+
return this.#lut;
77+
}
78+
4579
clone(): Fog {
4680
return new Fog({
4781
color: this.#color.clone(),
4882
near: this.#near,
4983
far: this.#far,
84+
density: this.#density,
5085
});
5186
}
5287
}

tests/scenes/Fog.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Fog } from "@/scenes/Fog.js";
3+
4+
describe("Fog", () => {
5+
it("lut[0] is 0 (no fog at near)", () => {
6+
const fog = new Fog();
7+
expect(fog.lut[0]).toBeCloseTo(0, 6);
8+
});
9+
10+
it("lut[255] approaches 1 at default density", () => {
11+
const fog = new Fog(); // density=2.5 → 1-exp(-6.25) ≈ 0.998
12+
expect(fog.lut[255]).toBeGreaterThan(0.99);
13+
});
14+
15+
it("lut is monotonically non-decreasing", () => {
16+
const fog = new Fog();
17+
for (let i = 1; i < 256; i++) {
18+
expect(fog.lut[i]).toBeGreaterThanOrEqual(fog.lut[i - 1]);
19+
}
20+
});
21+
22+
it("rebuilds lut when density changes", () => {
23+
const fog = new Fog({ density: 0.5 });
24+
const low = fog.lut[128];
25+
fog.density = 2.5;
26+
expect(fog.lut[128]).toBeGreaterThan(low);
27+
});
28+
29+
it("near/far do not affect lut shape", () => {
30+
const a = new Fog({ near: 0, far: 100, density: 2.5 });
31+
const b = new Fog({ near: 50, far: 500, density: 2.5 });
32+
for (let i = 0; i < 256; i++) {
33+
expect(a.lut[i]).toBeCloseTo(b.lut[i], 6);
34+
}
35+
});
36+
37+
it("clone preserves density", () => {
38+
const fog = new Fog({ near: 10, far: 200, density: 1.5 });
39+
const c = fog.clone();
40+
expect(c.density).toBe(1.5);
41+
expect(c.near).toBe(10);
42+
expect(c.far).toBe(200);
43+
});
44+
45+
it("clone lut matches original", () => {
46+
const fog = new Fog({ density: 1.8 });
47+
const c = fog.clone();
48+
for (let i = 0; i < 256; i++) {
49+
expect(c.lut[i]).toBeCloseTo(fog.lut[i], 6);
50+
}
51+
});
52+
});

0 commit comments

Comments
 (0)