@plasius/gpu-world-generator 0.0.4

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.
Files changed (43) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +73 -0
  3. package/dist/field.wgsl +225 -0
  4. package/dist/fractal-prepass.wgsl +290 -0
  5. package/dist/index.cjs +1942 -0
  6. package/dist/index.cjs.map +1 -0
  7. package/dist/index.d.cts +447 -0
  8. package/dist/index.d.ts +447 -0
  9. package/dist/index.js +1848 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/terrain.wgsl +451 -0
  12. package/docs/adrs/adr-0001-package-scope.md +18 -0
  13. package/docs/adrs/adr-0002-world-tiling-lod-stitching.md +28 -0
  14. package/docs/adrs/adr-0003-terrain-generation-style-mixing.md +21 -0
  15. package/docs/adrs/index.md +5 -0
  16. package/docs/biomes.md +206 -0
  17. package/docs/lod-zoning.md +22 -0
  18. package/docs/plan.md +107 -0
  19. package/docs/procedural-surface.md +73 -0
  20. package/docs/resources.md +55 -0
  21. package/package.json +53 -0
  22. package/src/biomes/temperate.ts +387 -0
  23. package/src/field.wgsl +225 -0
  24. package/src/fields.ts +321 -0
  25. package/src/fractal-prepass.ts +237 -0
  26. package/src/fractal-prepass.wgsl +290 -0
  27. package/src/generator.ts +106 -0
  28. package/src/hex.ts +54 -0
  29. package/src/index.ts +11 -0
  30. package/src/mesh.ts +285 -0
  31. package/src/perf-monitor.ts +133 -0
  32. package/src/shaders/demo-terrain.wgsl +193 -0
  33. package/src/shaders/demo-trees.wgsl +172 -0
  34. package/src/shaders/demo-water-sim.wgsl +361 -0
  35. package/src/shaders/library/common.wgsl +38 -0
  36. package/src/shaders/library/materials.wgsl +175 -0
  37. package/src/terrain.wgsl +451 -0
  38. package/src/tile-cache.ts +274 -0
  39. package/src/tiles.ts +417 -0
  40. package/src/types.ts +220 -0
  41. package/src/wgsl/field.job.wgsl +225 -0
  42. package/src/wgsl/terrain.job.wgsl +451 -0
  43. package/src/wgsl.ts +77 -0
package/src/mesh.ts ADDED
@@ -0,0 +1,285 @@
1
+ export type Vec3 = [number, number, number];
2
+
3
+ export type MeshGeomorph = {
4
+ targetY: number;
5
+ weight?: number;
6
+ };
7
+
8
+ export interface MeshBuilderOptions {
9
+ size?: number;
10
+ includeGeomorph?: boolean;
11
+ defaultMaterial?: number;
12
+ foliageMaterial?: number;
13
+ }
14
+
15
+ export interface MeshBuilder {
16
+ vertices: number[];
17
+ boxMin: number[];
18
+ boxMax: number[];
19
+ vertexStride: number;
20
+ includeGeomorph: boolean;
21
+ addTriangle: (
22
+ a: Vec3,
23
+ b: Vec3,
24
+ c: Vec3,
25
+ normal: Vec3,
26
+ color: Vec3,
27
+ swayA?: number,
28
+ swayB?: number,
29
+ swayC?: number,
30
+ material?: number,
31
+ morphA?: MeshGeomorph,
32
+ morphB?: MeshGeomorph,
33
+ morphC?: MeshGeomorph
34
+ ) => void;
35
+ addQuad: (
36
+ a: Vec3,
37
+ b: Vec3,
38
+ c: Vec3,
39
+ d: Vec3,
40
+ normal: Vec3,
41
+ color: Vec3,
42
+ swayA?: number,
43
+ swayB?: number,
44
+ swayC?: number,
45
+ swayD?: number,
46
+ material?: number,
47
+ morphA?: MeshGeomorph,
48
+ morphB?: MeshGeomorph,
49
+ morphC?: MeshGeomorph,
50
+ morphD?: MeshGeomorph
51
+ ) => void;
52
+ addTreeMesh: (center: Vec3, baseHeight: number, seedValue: number, material?: number) => void;
53
+ addBounds: (points: Vec3[], minY?: number, maxYOverride?: number) => void;
54
+ readonly treeMeshCount: number;
55
+ }
56
+
57
+ function clamp(value: number, min: number, max: number) {
58
+ return Math.min(max, Math.max(min, value));
59
+ }
60
+
61
+ export function normalize(vec: Vec3): Vec3 {
62
+ const len = Math.hypot(vec[0], vec[1], vec[2]);
63
+ if (len === 0) return [0, 1, 0];
64
+ return [vec[0] / len, vec[1] / len, vec[2] / len];
65
+ }
66
+
67
+ export function computeNormal(a: Vec3, b: Vec3, c: Vec3): Vec3 {
68
+ const ab: Vec3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
69
+ const ac: Vec3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
70
+ return normalize([
71
+ ab[1] * ac[2] - ab[2] * ac[1],
72
+ ab[2] * ac[0] - ab[0] * ac[2],
73
+ ab[0] * ac[1] - ab[1] * ac[0],
74
+ ]);
75
+ }
76
+
77
+ export function shade(color: Vec3, factor: number): Vec3 {
78
+ return [color[0] * factor, color[1] * factor, color[2] * factor];
79
+ }
80
+
81
+ export function createMeshBuilder(sizeOrOptions: number | MeshBuilderOptions = 1): MeshBuilder {
82
+ const options: MeshBuilderOptions =
83
+ typeof sizeOrOptions === "number" ? { size: sizeOrOptions } : sizeOrOptions;
84
+ const size = options.size ?? 1;
85
+ const includeGeomorph = options.includeGeomorph ?? false;
86
+ const defaultMaterial = options.defaultMaterial ?? 0;
87
+ const foliageMaterial = options.foliageMaterial ?? defaultMaterial;
88
+ const vertices: number[] = [];
89
+ const boxMin: number[] = [];
90
+ const boxMax: number[] = [];
91
+ let treeMeshCount = 0;
92
+
93
+ const vertexStride = includeGeomorph ? 13 : 11;
94
+
95
+ const pushVertex = (
96
+ pos: Vec3,
97
+ normal: Vec3,
98
+ color: Vec3,
99
+ sway = 0,
100
+ material = defaultMaterial,
101
+ geomorph?: MeshGeomorph
102
+ ) => {
103
+ vertices.push(
104
+ pos[0],
105
+ pos[1],
106
+ pos[2],
107
+ normal[0],
108
+ normal[1],
109
+ normal[2],
110
+ color[0],
111
+ color[1],
112
+ color[2],
113
+ sway,
114
+ material
115
+ );
116
+ if (includeGeomorph) {
117
+ const targetY = geomorph?.targetY ?? pos[1];
118
+ const weight = geomorph?.weight ?? 0;
119
+ vertices.push(targetY, weight);
120
+ }
121
+ };
122
+
123
+ const addTriangle = (
124
+ a: Vec3,
125
+ b: Vec3,
126
+ c: Vec3,
127
+ normal: Vec3,
128
+ color: Vec3,
129
+ swayA = 0,
130
+ swayB = swayA,
131
+ swayC = swayA,
132
+ material = defaultMaterial,
133
+ morphA?: MeshGeomorph,
134
+ morphB?: MeshGeomorph,
135
+ morphC?: MeshGeomorph
136
+ ) => {
137
+ pushVertex(a, normal, color, swayA, material, morphA);
138
+ pushVertex(b, normal, color, swayB, material, morphB);
139
+ pushVertex(c, normal, color, swayC, material, morphC);
140
+ };
141
+
142
+ const addQuad = (
143
+ a: Vec3,
144
+ b: Vec3,
145
+ c: Vec3,
146
+ d: Vec3,
147
+ normal: Vec3,
148
+ color: Vec3,
149
+ swayA = 0,
150
+ swayB = swayA,
151
+ swayC = swayA,
152
+ swayD = swayA,
153
+ material = defaultMaterial,
154
+ morphA?: MeshGeomorph,
155
+ morphB?: MeshGeomorph,
156
+ morphC?: MeshGeomorph,
157
+ morphD?: MeshGeomorph
158
+ ) => {
159
+ addTriangle(a, b, c, normal, color, swayA, swayB, swayC, material, morphA, morphB, morphC);
160
+ addTriangle(c, d, a, normal, color, swayC, swayD, swayA, material, morphC, morphD, morphA);
161
+ };
162
+
163
+ const addPrism = (
164
+ center: Vec3,
165
+ radius: number,
166
+ bottom: number,
167
+ height: number,
168
+ color: Vec3,
169
+ swayBase = 0,
170
+ swayScale = 0,
171
+ material = defaultMaterial
172
+ ) => {
173
+ const top: Vec3[] = [];
174
+ const base: Vec3[] = [];
175
+ const baseY = center[1] + bottom;
176
+ const topY = baseY + height;
177
+ const safeHeight = Math.max(height, 0.001);
178
+ const swayFor = (y: number) => swayBase + swayScale * clamp((y - baseY) / safeHeight, 0, 1);
179
+ for (let i = 0; i < 6; i += 1) {
180
+ const angle = (Math.PI / 180) * (60 * i - 30);
181
+ const x = center[0] + radius * Math.cos(angle);
182
+ const z = center[2] + radius * Math.sin(angle);
183
+ top.push([x, topY, z]);
184
+ base.push([x, baseY, z]);
185
+ }
186
+ const topCenter: Vec3 = [center[0], topY, center[2]];
187
+ const topSway = swayFor(topY);
188
+ for (let i = 0; i < 6; i += 1) {
189
+ const a = topCenter;
190
+ const b = top[i];
191
+ const c = top[(i + 1) % 6];
192
+ const normal = computeNormal(a, b, c);
193
+ addTriangle(a, b, c, normal, color, topSway, topSway, topSway, material);
194
+ }
195
+ for (let i = 0; i < 6; i += 1) {
196
+ const top0 = top[i];
197
+ const top1 = top[(i + 1) % 6];
198
+ const bottom0 = base[i];
199
+ const bottom1 = base[(i + 1) % 6];
200
+ const normal = computeNormal(top0, bottom0, bottom1);
201
+ const swayTop0 = swayFor(top0[1]);
202
+ const swayTop1 = swayFor(top1[1]);
203
+ const swayBottom0 = swayFor(bottom0[1]);
204
+ const swayBottom1 = swayFor(bottom1[1]);
205
+ addQuad(
206
+ top0,
207
+ bottom0,
208
+ bottom1,
209
+ top1,
210
+ normal,
211
+ shade(color, 0.85),
212
+ swayTop0,
213
+ swayBottom0,
214
+ swayBottom1,
215
+ swayTop1,
216
+ material
217
+ );
218
+ }
219
+ };
220
+
221
+ const addTreeMesh = (
222
+ center: Vec3,
223
+ baseHeight: number,
224
+ seedValue: number,
225
+ material = defaultMaterial
226
+ ) => {
227
+ const trunkRadius = size * (0.12 + seedValue * 0.05);
228
+ const trunkHeight = 0.5 + seedValue * 0.6;
229
+ const canopyRadius = size * (0.36 + seedValue * 0.18);
230
+ const canopyHeight = 0.6 + seedValue * 0.4;
231
+ const trunkColor: Vec3 = [0.28, 0.18, 0.1];
232
+ const leafColor: Vec3 = [0.18, 0.45, 0.2];
233
+
234
+ addPrism(center, trunkRadius, baseHeight, trunkHeight, trunkColor, 0.02, 0.28, material);
235
+ addPrism(
236
+ [center[0], baseHeight + trunkHeight * 0.7, center[2]],
237
+ canopyRadius,
238
+ 0,
239
+ canopyHeight * 0.55,
240
+ leafColor,
241
+ 0.12,
242
+ 0.9,
243
+ foliageMaterial
244
+ );
245
+ addPrism(
246
+ [center[0], baseHeight + trunkHeight + canopyHeight * 0.1, center[2]],
247
+ canopyRadius * 0.7,
248
+ 0,
249
+ canopyHeight * 0.35,
250
+ shade(leafColor, 0.95),
251
+ 0.2,
252
+ 1.1,
253
+ foliageMaterial
254
+ );
255
+ treeMeshCount += 1;
256
+ };
257
+
258
+ const addBounds = (points: Vec3[], minY = 0, maxYOverride?: number) => {
259
+ const xs = points.map((p) => p[0]);
260
+ const zs = points.map((p) => p[2]);
261
+ const ys = points.map((p) => p[1]);
262
+ const minX = Math.min(...xs);
263
+ const maxX = Math.max(...xs);
264
+ const minZ = Math.min(...zs);
265
+ const maxZ = Math.max(...zs);
266
+ const maxY = typeof maxYOverride === "number" ? maxYOverride : Math.max(...ys);
267
+ boxMin.push(minX, minY, minZ, 0);
268
+ boxMax.push(maxX, maxY, maxZ, 0);
269
+ };
270
+
271
+ return {
272
+ vertices,
273
+ boxMin,
274
+ boxMax,
275
+ vertexStride,
276
+ includeGeomorph,
277
+ addTriangle,
278
+ addQuad,
279
+ addTreeMesh,
280
+ addBounds,
281
+ get treeMeshCount() {
282
+ return treeMeshCount;
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,133 @@
1
+ export interface PerfMonitorOptions {
2
+ targetFps?: number;
3
+ tolerance?: number;
4
+ sampleSize?: number;
5
+ minSampleFraction?: number;
6
+ cooldownMs?: number;
7
+ qualitySlew?: number;
8
+ initialBudget?: number;
9
+ auto?: boolean;
10
+ }
11
+
12
+ export interface PerfMonitorUpdate {
13
+ budget: number;
14
+ medianFps: number | null;
15
+ miss: number | null;
16
+ adjusted: boolean;
17
+ stable: boolean;
18
+ }
19
+
20
+ export interface PerfMonitor {
21
+ sampleFrame: (dtSeconds: number) => void;
22
+ sampleFps: (fps: number) => void;
23
+ update: (nowMs: number) => PerfMonitorUpdate;
24
+ resetSamples: () => void;
25
+ setBudget: (budget: number) => void;
26
+ getBudget: () => number;
27
+ setAuto: (enabled: boolean) => void;
28
+ getConfig: () => Required<PerfMonitorOptions>;
29
+ }
30
+
31
+ const defaultOptions: Required<PerfMonitorOptions> = {
32
+ targetFps: 120,
33
+ tolerance: 6,
34
+ sampleSize: 90,
35
+ minSampleFraction: 0.6,
36
+ cooldownMs: 1200,
37
+ qualitySlew: 0.05,
38
+ initialBudget: 0.5,
39
+ auto: true,
40
+ };
41
+
42
+ function clamp(value: number, min: number, max: number) {
43
+ return Math.min(max, Math.max(min, value));
44
+ }
45
+
46
+ function clamp01(value: number) {
47
+ return clamp(value, 0, 1);
48
+ }
49
+
50
+ function median(values: number[]) {
51
+ if (!values.length) return 0;
52
+ const sorted = values.slice().sort((a, b) => a - b);
53
+ const mid = Math.floor(sorted.length / 2);
54
+ if (sorted.length % 2 === 0) {
55
+ return (sorted[mid - 1] + sorted[mid]) * 0.5;
56
+ }
57
+ return sorted[mid];
58
+ }
59
+
60
+ export function createPerfMonitor(options: PerfMonitorOptions = {}): PerfMonitor {
61
+ const config: Required<PerfMonitorOptions> = { ...defaultOptions, ...options };
62
+ let budget = clamp01(config.initialBudget);
63
+ let lastAdjust = 0;
64
+ const samples: number[] = [];
65
+
66
+ const sampleFps = (fps: number) => {
67
+ if (!Number.isFinite(fps) || fps <= 0) return;
68
+ samples.push(fps);
69
+ if (samples.length > config.sampleSize) {
70
+ samples.shift();
71
+ }
72
+ };
73
+
74
+ const sampleFrame = (dtSeconds: number) => {
75
+ if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) return;
76
+ sampleFps(1 / dtSeconds);
77
+ };
78
+
79
+ const update = (nowMs: number): PerfMonitorUpdate => {
80
+ if (!config.auto) {
81
+ return { budget, medianFps: null, miss: null, adjusted: false, stable: true };
82
+ }
83
+ if (nowMs - lastAdjust < config.cooldownMs) {
84
+ return { budget, medianFps: null, miss: null, adjusted: false, stable: false };
85
+ }
86
+ if (samples.length < Math.floor(config.sampleSize * config.minSampleFraction)) {
87
+ return { budget, medianFps: null, miss: null, adjusted: false, stable: false };
88
+ }
89
+
90
+ const med = median(samples);
91
+ const miss = config.targetFps - med;
92
+ const tol = config.tolerance;
93
+ if (Math.abs(miss) <= tol) {
94
+ lastAdjust = nowMs;
95
+ return { budget, medianFps: med, miss, adjusted: false, stable: true };
96
+ }
97
+
98
+ const magnitude = Math.min(1, (Math.abs(miss) - tol) / tol);
99
+ const direction = miss > 0 ? -1 : 1;
100
+ const next = clamp01(budget + direction * magnitude * config.qualitySlew);
101
+ const adjusted = next !== budget;
102
+ budget = next;
103
+ lastAdjust = nowMs;
104
+ return { budget, medianFps: med, miss, adjusted, stable: false };
105
+ };
106
+
107
+ const resetSamples = () => {
108
+ samples.length = 0;
109
+ };
110
+
111
+ const setBudget = (next: number) => {
112
+ budget = clamp01(next);
113
+ };
114
+
115
+ const getBudget = () => budget;
116
+
117
+ const setAuto = (enabled: boolean) => {
118
+ config.auto = enabled;
119
+ };
120
+
121
+ const getConfig = () => ({ ...config });
122
+
123
+ return {
124
+ sampleFrame,
125
+ sampleFps,
126
+ update,
127
+ resetSamples,
128
+ setBudget,
129
+ getBudget,
130
+ setAuto,
131
+ getConfig,
132
+ };
133
+ }
@@ -0,0 +1,193 @@
1
+ struct Scene {
2
+ viewProj: mat4x4<f32>,
3
+ lightPos: vec4<f32>,
4
+ cameraPos: vec4<f32>,
5
+ boxCount: vec4<f32>,
6
+ wind: vec4<f32>,
7
+ weather: vec4<f32>,
8
+ waterSim: vec4<f32>,
9
+ debug: vec4<f32>,
10
+ };
11
+
12
+ @group(0) @binding(0) var<uniform> scene: Scene;
13
+ @group(0) @binding(1) var<storage, read> boxMin: array<vec4<f32>>;
14
+ @group(0) @binding(2) var<storage, read> boxMax: array<vec4<f32>>;
15
+ @group(0) @binding(3) var<storage, read> waterHeight: array<f32>;
16
+
17
+ struct VertexIn {
18
+ @location(0) position: vec3<f32>,
19
+ @location(1) normal: vec3<f32>,
20
+ @location(2) color: vec3<f32>,
21
+ @location(3) sway: f32,
22
+ @location(4) material: f32,
23
+ };
24
+
25
+ struct VertexOut {
26
+ @builtin(position) position: vec4<f32>,
27
+ @location(0) normal: vec3<f32>,
28
+ @location(1) color: vec3<f32>,
29
+ @location(2) worldPos: vec3<f32>,
30
+ @location(3) material: f32,
31
+ };
32
+
33
+ @vertex
34
+ fn vs_main(input: VertexIn) -> VertexOut {
35
+ var out: VertexOut;
36
+ let windStrength = scene.boxCount.y;
37
+ let windGust = scene.boxCount.z;
38
+ let windDir = normalize(scene.wind.xyz);
39
+ let time = scene.wind.w;
40
+ var worldPos = input.position;
41
+ if (input.sway > 0.0001) {
42
+ let wave = sin(time * (0.8 + windGust) + input.position.x * 0.6 + input.position.z * 0.4);
43
+ let gust = sin(time * 1.7 + input.position.x * 0.9 + input.position.z * 1.1);
44
+ let sway = (wave + gust * 0.35) * windStrength * input.sway;
45
+ worldPos = worldPos + windDir * sway;
46
+ }
47
+ out.position = scene.viewProj * vec4<f32>(worldPos, 1.0);
48
+ out.normal = input.normal;
49
+ out.color = input.color;
50
+ out.worldPos = worldPos;
51
+ out.material = input.material;
52
+ return out;
53
+ }
54
+
55
+ fn ray_box_intersect(origin: vec3<f32>, dir: vec3<f32>, bmin: vec3<f32>, bmax: vec3<f32>) -> f32 {
56
+ let inv = 1.0 / dir;
57
+ let t0 = (bmin - origin) * inv;
58
+ let t1 = (bmax - origin) * inv;
59
+ let tmin = max(max(min(t0.x, t1.x), min(t0.y, t1.y)), min(t0.z, t1.z));
60
+ let tmax = min(min(max(t0.x, t1.x), max(t0.y, t1.y)), max(t0.z, t1.z));
61
+ if (tmax >= max(tmin, 0.0)) {
62
+ return tmin;
63
+ }
64
+ return -1.0;
65
+ }
66
+
67
+ fn is_occluded(pos: vec3<f32>, light_pos: vec3<f32>) -> bool {
68
+ let to_light = light_pos - pos;
69
+ let dist = length(to_light);
70
+ if (dist < 1e-3) {
71
+ return false;
72
+ }
73
+ let dir = to_light / dist;
74
+ let origin = pos + dir * 0.02;
75
+ let count = u32(scene.boxCount.x);
76
+ let maxCount = arrayLength(&boxMin);
77
+ for (var i: u32 = 0u; i < maxCount; i = i + 1u) {
78
+ if (i >= count) {
79
+ break;
80
+ }
81
+ let t = ray_box_intersect(origin, dir, boxMin[i].xyz, boxMax[i].xyz);
82
+ if (t > 0.0 && t < dist) {
83
+ return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ fn sample_water_height(world_pos: vec3<f32>) -> f32 {
90
+ let gridSize = i32(scene.waterSim.w);
91
+ if (gridSize < 2) {
92
+ return 0.0;
93
+ }
94
+ let origin = scene.waterSim.xy;
95
+ let invSize = scene.waterSim.z;
96
+ let uv = clamp((world_pos.xz - origin) * invSize, vec2<f32>(0.0), vec2<f32>(1.0));
97
+ let gridMax = f32(gridSize - 1);
98
+ let gx = uv.x * gridMax;
99
+ let gy = uv.y * gridMax;
100
+ let ix = i32(floor(gx));
101
+ let iy = i32(floor(gy));
102
+ let fx = fract(gx);
103
+ let fy = fract(gy);
104
+ let ix0 = clamp(ix, 0, gridSize - 1);
105
+ let iy0 = clamp(iy, 0, gridSize - 1);
106
+ let ix1 = clamp(ix + 1, 0, gridSize - 1);
107
+ let iy1 = clamp(iy + 1, 0, gridSize - 1);
108
+ let idx00 = iy0 * gridSize + ix0;
109
+ let idx10 = iy0 * gridSize + ix1;
110
+ let idx01 = iy1 * gridSize + ix0;
111
+ let idx11 = iy1 * gridSize + ix1;
112
+ let h00 = waterHeight[idx00];
113
+ let h10 = waterHeight[idx10];
114
+ let h01 = waterHeight[idx01];
115
+ let h11 = waterHeight[idx11];
116
+ let hx0 = mix(h00, h10, fx);
117
+ let hx1 = mix(h01, h11, fx);
118
+ return mix(hx0, hx1, fy);
119
+ }
120
+
121
+ fn sample_water_normal(world_pos: vec3<f32>) -> vec3<f32> {
122
+ let gridSize = i32(scene.waterSim.w);
123
+ if (gridSize < 2) {
124
+ return vec3<f32>(0.0, 1.0, 0.0);
125
+ }
126
+ let invSize = scene.waterSim.z;
127
+ let cellWorld = 1.0 / max(invSize * (f32(gridSize) - 1.0), 1e-3);
128
+ let hL = sample_water_height(world_pos + vec3<f32>(-cellWorld, 0.0, 0.0));
129
+ let hR = sample_water_height(world_pos + vec3<f32>(cellWorld, 0.0, 0.0));
130
+ let hB = sample_water_height(world_pos + vec3<f32>(0.0, 0.0, -cellWorld));
131
+ let hT = sample_water_height(world_pos + vec3<f32>(0.0, 0.0, cellWorld));
132
+ let scale = 1.2 / max(cellWorld, 1e-3);
133
+ let dx = (hR - hL) * scale;
134
+ let dz = (hT - hB) * scale;
135
+ return normalize(vec3<f32>(-dx, 1.0, -dz));
136
+ }
137
+
138
+ fn heatmap_color(t: f32) -> vec3<f32> {
139
+ let low = vec3<f32>(0.08, 0.12, 0.35);
140
+ let mid = vec3<f32>(0.18, 0.6, 0.28);
141
+ let high = vec3<f32>(0.95, 0.85, 0.55);
142
+ if (t < 0.5) {
143
+ return mix(low, mid, t * 2.0);
144
+ }
145
+ return mix(mid, high, (t - 0.5) * 2.0);
146
+ }
147
+
148
+ @fragment
149
+ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
150
+ if (scene.debug.x > 0.5) {
151
+ let minH = scene.debug.y;
152
+ let maxH = scene.debug.z;
153
+ let denom = max(maxH - minH, 1e-3);
154
+ let t = clamp((input.worldPos.y - minH) / denom, 0.0, 1.0);
155
+ return vec4<f32>(heatmap_color(t), 1.0);
156
+ }
157
+ var n = normalize(input.normal);
158
+ let to_light = scene.lightPos.xyz - input.worldPos;
159
+ let dist = length(to_light);
160
+ let dir = to_light / max(dist, 1e-3);
161
+ let view_vec = scene.cameraPos.xyz - input.worldPos;
162
+ let view_dist = length(view_vec);
163
+ let view_dir = view_vec / max(view_dist, 1e-3);
164
+ let material_id = u32(round(input.material));
165
+ if (material_id == MATERIAL_WATER) {
166
+ let sim_normal = sample_water_normal(input.worldPos);
167
+ n = normalize(mix(n, sim_normal, 0.85));
168
+ }
169
+ let material = apply_surface_material(
170
+ material_id,
171
+ input.color,
172
+ n,
173
+ input.worldPos,
174
+ scene.wind,
175
+ scene.weather,
176
+ view_dir,
177
+ view_dist
178
+ );
179
+ let diff = max(dot(material.normal, dir), 0.0);
180
+ let dist2 = max(dist * dist, 0.4);
181
+ let attenuation = scene.lightPos.w / dist2;
182
+ var shadow = 1.0;
183
+ if (is_occluded(input.worldPos + n * 0.02, scene.lightPos.xyz)) {
184
+ shadow = 0.25;
185
+ }
186
+ let ambient = 0.24;
187
+ var color = material.albedo * (ambient + diff * attenuation * shadow);
188
+ let half_vec = normalize(view_dir + dir);
189
+ let spec = pow(max(dot(material.normal, half_vec), 0.0), mix(8.0, 64.0, material.specular));
190
+ color = color + spec * material.specular * attenuation * 0.6;
191
+ color = color + material.emission;
192
+ return vec4<f32>(color, 1.0);
193
+ }