-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path8_heightmap.html
More file actions
355 lines (324 loc) · 18.8 KB
/
Copy path8_heightmap.html
File metadata and controls
355 lines (324 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
<!DOCTYPE html>
<!-- TinyWebGPU example 8: a flat-shaded 3D height map, drawn with your own vertex stage.
A compute pass writes the heights; makeDraw reads them straight back out of the same
storage buffer in the vertex shader — no vertex buffers, no CPU in between. Two triangles
per grid cell, one instance per cell, and the face normal is derived in the vertex shader
so every facet gets a single flat colour. Hidden surfaces are makeDraw({depth: true}): one
option, and the depth texture is created, sized and pooled for you.
Serve this folder (python3 -m http.server) and open /examples/8_heightmap.html -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>TinyWebGPU — flat-shaded height map</title>
<!-- Loaded first and on purpose not a module: it turns an uncaught error in the
module scripts below — no WebGPU, no adapter, a file that failed to load — into
a banner on the page, because a phone has no console to print it to. -->
<script src="../diag.js"></script>
<style>
body {
margin: 0; min-height: 100vh; padding: 1rem; display: grid; place-items: center;
gap: .8rem; align-content: center;
background: #0b0d12; color: #ddd;
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
canvas { display: block; width: 100%; max-width: 720px; height: auto; aspect-ratio: 16 / 10; }
pre {
margin: 0; max-width: 720px; white-space: pre-wrap; overflow-wrap: anywhere;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #9aa4b2;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<pre id="out">running…</pre>
<script type="module">
import { boot } from '../libselect.js';
const N = 128; // cells per side
const VN = N + 1; // grid vertices per side
const SIZE = 2.0; // world extent, centred on the origin
const canvas = document.getElementById('c');
// boot = picker + build import + init(), with a start-up failure shown on the page.
const { G } = await boot(['read', 'resize', 'depth'], canvas); // ?lib=min|tiny switches the build
// One f32 per grid vertex. The compute pass writes it read_write; the vertex stage reads it
// read-only. Same GPUBuffer, two pipelines, two address spaces — see `readOnly` below.
const heights = G.createStorageBuffer(VN * VN * 4);
// ── the terrain function, once in WGSL ───────────────────────────────────────────────────
// Kept as a string so the self-check at the bottom can hold the GPU's answer against a JS
// transcription of the same arithmetic instead of asking you to judge it by eye.
const TERRAIN = `
fn terrain(p: vec2<f32>, t: f32) -> f32 {
var h = 0.30 * sin(p.x * 3.0 + t * 0.6) * cos(p.y * 3.0 - t * 0.4);
h += 0.15 * sin(p.x * 7.0 - t * 0.3) * sin(p.y * 6.0 + t * 0.5);
h += 0.07 * sin((p.x + p.y) * 13.0 + t);
let r = length(p);
return h * exp(-r * r * 0.9); // island falloff
}`;
const shape = G.makeCompute(TERRAIN, `
let i = gid.x;
if (i >= ${VN * VN}u) { return; }
let p = (vec2<f32>(f32(i % ${VN}u), f32(i / ${VN}u)) / ${N}.0 - 0.5) * ${SIZE};
heights[i] = terrain(p, UB.time);
`,
{ time: 'f32' }, { heights: 'array<f32>' }, { wg: [64, 1, 1] });
shape.setResources({ heights });
// ── what to draw: every topology WebGPU has, over the same heights buffer ────────────────
// WebGPU has five primitive topologies and `topology` on makeDraw takes any of them. The
// geometry never moves: each mode only changes which grid points a vertex maps to, and how
// many vertices and instances the draw asks for.
//
// 'solid' triangle-list two triangles per cell 6 × N·N
// 'wire' triangle-list the same, edges found in the fragment shader, interiors gone
// 'both' triangle-list the same, edges drawn *over* the surface
// 'lines' line-list three edges per cell 6 × N·N
// 'strip' triangle-strip one strip per row of cells 2·VN × N
// 'ribbons' line-strip one polyline per grid row VN × VN
// 'points' point-list one point per grid vertex 1 × VN·VN
//
// Three things are worth knowing before you pick one:
//
// 'wire' and 'both' are not a topology at all. The edges come out of the *fragment* shader,
// from a barycentric coordinate the rasteriser interpolates for free, so they are the same
// single triangle-list draw as 'solid' — which is also why 'both' can fill and outline at
// once. 'lines' is the honest version with real line primitives, and it cannot: topology
// belongs to the pipeline, so surface-plus-outline there means a second makeDraw over the
// same buffer. Cheap — same heights, same readOnly — but a second pipeline.
//
// Lines and points are always exactly one pixel. WebGPU has no lineWidth and no
// gl_PointSize, and that is one *device* pixel: resizeCanvas sizes the backing store to the
// CSS box times devicePixelRatio, so on a 2× display a dot is half a CSS pixel. When you
// want marks you can see, draw a small quad per vertex instead (count: 6, corners nudged
// apart in clip space by a size you pass in) — the usual billboard, any size, and round if
// you like.
//
// The strips are where the vertex count goes. 'solid' sends six vertices per cell because
// every facet wants its own normal; 'strip' shares them along the row and sends a third as
// many. You pay for it in shading: with vertices shared between neighbouring triangles,
// @interpolate(flat) can only hand each triangle the value of its provoking vertex, so the
// facets stop lining up with the triangles. Compare 'solid' and 'strip' side by side — same
// surface, visibly different faceting. That trade is the whole reason strips exist.
//
// And at N = 128 the mesh is finer than the pixels, so any of the line modes cover ~95% of
// the terrain and give you a white blob. Drop N to 24–32 as well and they read properly.
const MODE = 'solid'; // ← 'solid' | 'wire' | 'both' | 'lines' | 'strip' | 'ribbons' | 'points'
const WIRE_PX = 1.0; // ← 'wire'/'both' line half-width, in pixels
// topology, vertices per instance, instances — and the WGSL that turns (v, i) into a grid
// point. Everything below this table is shared by all seven.
const DRAW = {
solid: ['triangle-list', 6, N * N, 'cellTriangles'],
wire: ['triangle-list', 6, N * N, 'cellTriangles'],
both: ['triangle-list', 6, N * N, 'cellTriangles'],
lines: ['line-list', 6, N * N, 'cellEdges'],
strip: ['triangle-strip', VN * 2, N, 'rowStrip'],
ribbons: ['line-strip', VN, VN, 'rowLine'],
points: ['point-list', 1, VN * VN, 'gridPoint'],
};
const [TOPOLOGY, COUNT, INSTANCES, LAYOUT] = DRAW[MODE];
// Each of these sets `p` (where this vertex goes), `nrm` (which facet lights it) and `bary`
// (only 'wire'/'both' read it; the rest hand back 1s, which means "not near any edge").
const LAYOUTS = {
cellTriangles: `
let cx = i % ${N}u;
let cy = i / ${N}u;
// Two triangles: (0,0)(1,0)(0,1) and (1,0)(1,1)(0,1).
var OX = array<u32, 6>(0u, 1u, 0u, 1u, 1u, 0u);
var OY = array<u32, 6>(0u, 0u, 1u, 0u, 1u, 1u);
let p = corner(cx + OX[v], cy + OY[v]);
let nrm = facet(cx, cy, v >= 3u); // its own facet: flat shading, per triangle
var BARY = array<vec3<f32>, 3>(vec3<f32>(1.0, 0.0, 0.0),
vec3<f32>(0.0, 1.0, 0.0),
vec3<f32>(0.0, 0.0, 1.0));
let bary = BARY[v % 3u];`,
cellEdges: `
let cx = i % ${N}u;
let cy = i / ${N}u;
// Three edges per cell: bottom, left, and the diagonal the two triangles share. The
// cell to the right draws this one's right edge and the cell above draws its top, so
// nothing is drawn twice — at the cost of the grid's far border, which has no
// neighbour to draw it.
var EX = array<u32, 6>(0u, 1u, 0u, 0u, 1u, 0u);
var EY = array<u32, 6>(0u, 0u, 0u, 1u, 0u, 1u);
let p = corner(cx + EX[v], cy + EY[v]);
let nrm = facet(cx, cy, false);
let bary = vec3<f32>(1.0);`,
rowStrip: `
// A strip marches along x, alternating between this row and the next, so 2·VN
// vertices cover a whole row of cells and every vertex but the first two is the third
// corner of another triangle.
let gx = v / 2u;
let gy = i + (v & 1u);
let p = corner(gx, gy);
let nrm = facet(min(gx, ${N - 1}u), i, false);
let bary = vec3<f32>(1.0);`,
rowLine: `
// One polyline per grid row: VN points, VN of them.
let p = corner(v, i);
let nrm = facet(min(v, ${N - 1}u), min(i, ${N - 1}u), false);
let bary = vec3<f32>(1.0);`,
gridPoint: `
// One point per grid vertex — no cells involved, so the instance index *is* the vertex.
let gx = i % ${VN}u;
let gy = i / ${VN}u;
let p = corner(gx, gy);
let nrm = facet(min(gx, ${N - 1}u), min(gy, ${N - 1}u), false);
let bary = vec3<f32>(1.0);`,
};
// ── the draw ─────────────────────────────────────────────────────────────────────────────
const land = G.makeDraw({
code: `
const EDGES = ${MODE === 'wire' || MODE === 'both'}; // folded away when false
const WIRE_ONLY = ${MODE === 'wire'};
const POINTS = ${MODE === 'points'};
struct VSOut {
@builtin(position) pos: vec4<f32>,
// flat: all three vertices of a facet compute the same value, and saying so keeps the
// rasteriser from interpolating between three identical numbers.
@location(0) @interpolate(flat) tint: vec3<f32>,
// This one *is* interpolated — that is the whole point. See fs_main.
@location(1) bary: vec3<f32>,
};
fn corner(vx: u32, vy: u32) -> vec3<f32> {
let g = vec2<f32>(f32(vx), f32(vy)) / ${N}.0 - 0.5;
return vec3<f32>(g.x * ${SIZE}, heights[vy * ${VN}u + vx], g.y * ${SIZE});
}
// The normal of one of a cell's two triangles — lower is (0,0)(1,0)(0,1), upper is
// (1,0)(1,1)(0,1). Built whole here rather than per vertex, so all three vertices of a
// facet get the same answer: that is what makes this flat shading rather than smooth.
fn facet(cx: u32, cy: u32, upper: bool) -> vec3<f32> {
let c = corner(cx, cy + 1u); // both share this
let a = select(corner(cx, cy), corner(cx + 1u, cy), upper);
let b = select(corner(cx + 1u, cy), corner(cx + 1u, cy + 1u), upper);
return normalize(cross(c - a, b - a));
}
@vertex fn vs_main(@builtin(vertex_index) v: u32,
@builtin(instance_index) i: u32) -> VSOut {
// Instances go out in plain grid order — the depth option below resolves what hides
// what, per fragment, at any camera angle.
//
// This used to walk the grid back-to-front instead and let the painter's algorithm do
// the hiding. For a height field that is exact only while every cell lies on the same
// side of the camera along *both* grid axes; a single reversal flag per axis cannot
// express anything else. This orbit (R = 3.1, grid half-extent 1) passes inside the
// grid's own Z span for ±18.8° around two of the four cardinal angles, and there the
// two halves want opposite sweep directions — so twice a turn a few percent of the
// terrain was painted in the wrong order and showed through. A depth buffer has no
// such angle, which is the honest reason to reach for one.
${LAYOUTS[LAYOUT]}
let lam = 0.15 + 0.85 * max(dot(nrm, normalize(vec3<f32>(0.45, 0.8, 0.35))), 0.0);
let band = clamp(p.y * 2.4 + 0.45, 0.0, 1.0);
let rock = mix(vec3<f32>(0.16, 0.30, 0.22), vec3<f32>(0.55, 0.47, 0.36), band);
let snow = mix(rock, vec3<f32>(0.92, 0.94, 0.98), smoothstep(0.72, 0.95, band));
var o: VSOut;
o.pos = UB.mvp * vec4<f32>(p, 1.0);
o.tint = snow * lam;
// 1 at this vertex's own corner, 0 at the other two. Interpolated across the facet it
// becomes "how far along am I", and its smallest component is the distance to the
// nearest edge — which is what fs_main turns into a wireframe.
o.bary = bary;
return o;
}
@fragment fn fs_main(vs: VSOut) -> @location(0) vec4<f32> {
// One dark pixel on a dark background is not a debugging aid, so the points ignore the
// terrain's shading and come out bright. Depth still hides the ones round the back.
if (POINTS) { return vec4<f32>(1.0, 0.83, 0.35, 1.0); }
var col = vs.tint;
if (EDGES) {
let b = min(min(vs.bary.x, vs.bary.y), vs.bary.z);
// fwidth is how fast b changes from one pixel to the next, so b / fwidth(b) is the
// distance to the edge measured in *pixels* — which is what keeps the line the same
// width whether the facet faces you or is tilted almost edge-on.
let px = b / max(fwidth(b), 1e-6);
let line = 1.0 - smoothstep(0.0, ${WIRE_PX.toFixed(2)}, px);
if (WIRE_ONLY && line < 0.5) { discard; }
col = mix(col, vec3<f32>(0.75, 0.85, 1.0), line);
}
return vec4<f32>(col, 1.0);
}`,
uniforms: { mvp: 'mat4x4<f32>' },
resources: { heights: 'array<f32>' },
// The one thing worth remembering about makeDraw: WebGPU will not let the vertex stage see
// a read_write storage binding, and read_write is what the schema emits by default. Drop
// this line and pipeline creation fails with a bind-group layout error.
readOnly: ['heights'],
topology: TOPOLOGY,
// depth24plus, `less`, depth writes on. The texture is allocated to match the render
// target and shared by every depth-enabled pipeline drawing into it, so a second pass
// would depth-test against this one with nothing else to wire up.
depth: true,
count: COUNT, instances: INSTANCES,
});
land.setResources({ heights });
// ── camera: column-major 4×4s, WebGPU clip space (z in 0..1) ─────────────────────────────
const mul = (a, b) => {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
o[c * 4 + r] = s;
}
return o;
};
const perspective = (fovy, aspect, near, far) => {
const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
const m = new Float32Array(16);
m[0] = f / aspect; m[5] = f; m[10] = far * nf; m[11] = -1; m[14] = near * far * nf;
return m;
};
const lookAt = (eye, at, up) => {
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const norm = v => { const l = Math.hypot(...v); return [v[0] / l, v[1] / l, v[2] / l]; };
const z = norm(sub(eye, at)), x = norm(cross(up, z)), y = cross(z, x);
return new Float32Array([
x[0], y[0], z[0], 0,
x[1], y[1], z[1], 0,
x[2], y[2], z[2], 0,
-dot(x, eye), -dot(y, eye), -dot(z, eye), 1,
]);
};
// ── self-check: the GPU's heights against the same arithmetic in JS ──────────────────────
const terrainJS = (x, y, t) => {
let h = 0.30 * Math.sin(x * 3 + t * 0.6) * Math.cos(y * 3 - t * 0.4);
h += 0.15 * Math.sin(x * 7 - t * 0.3) * Math.sin(y * 6 + t * 0.5);
h += 0.07 * Math.sin((x + y) * 13 + t);
const r2 = x * x + y * y;
return h * Math.exp(-r2 * 0.9);
};
shape.setUniforms({ time: 0 });
shape.run(VN * VN);
const got = await heights.r(VN * VN * 4, 0, Float32Array);
let worst = 0;
for (let i = 0; i < VN * VN; i += 37) { // a scattered sample, not all 16k
const x = ((i % VN) / N - 0.5) * SIZE, y = ((i / VN | 0) / N - 0.5) * SIZE;
worst = Math.max(worst, Math.abs(got[i] - terrainJS(x, y, 0)));
}
const ok = worst < 1e-5;
const tris = N * N * 2;
document.getElementById('out').textContent =
`${N}×${N} cells · ${tris.toLocaleString()} triangles · ${(N * N).toLocaleString()} instances × 6 vertices\n`
+ `heights: one compute pass into a storage buffer, read back in the vertex stage (readOnly)\n`
+ `hidden surfaces: depth: true — an auto-managed depth24plus buffer sized to the canvas\n`
+ `worst |GPU − CPU| height error: ${worst.toExponential(2)} ${ok ? 'PASS' : 'FAIL'}`;
window.__result = { ok, msg: `worst height error ${worst.toExponential(2)}` };
// ── loop ─────────────────────────────────────────────────────────────────────────────────
const start = performance.now();
const frame = () => {
const { width, height } = G.resizeCanvas(canvas);
const t = (performance.now() - start) / 1000;
const a = t * 0.25, R = 3.1;
const eye = [Math.cos(a) * R, 1.55, Math.sin(a) * R];
const mvp = mul(perspective(0.9, width / height, 0.1, 100), lookAt(eye, [0, 0, 0], [0, 1, 0]));
G.beginFrame(); // reshape + draw, one submit
shape.setUniforms({ time: t });
shape.run(VN * VN);
land.setUniforms({ mvp });
land.drawTo(undefined, [0.043, 0.051, 0.071, 1]);
G.endFrame();
requestAnimationFrame(frame);
};
frame();
</script>
</body>
</html>