-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtutorial.html
More file actions
1692 lines (1578 loc) · 85.3 KB
/
Copy pathtutorial.html
File metadata and controls
1692 lines (1578 loc) · 85.3 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
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>TinyWebGPU tutorial — WebGPU and WGSL, sixteen runnable steps</title>
<meta name="description" content="A step-by-step WebGPU tutorial: from one fullscreen WGSL
shader to compute, uniforms, ping-pong buffers, atomics, indirect dispatch and a particle
system. Every code box on the page really runs on your GPU.">
<link rel="canonical" href="https://lampmaker.github.io/tinywebgpu/docs/tutorial.html">
<meta property="og:type" content="article">
<meta property="og:site_name" content="TinyWebGPU">
<meta property="og:url" content="https://lampmaker.github.io/tinywebgpu/docs/tutorial.html">
<meta property="og:title" content="TinyWebGPU tutorial — WebGPU and WGSL in sixteen runnable steps">
<meta property="og:description" content="From one fullscreen WGSL shader to compute, atomics
and a particle system — every code box on the page recompiles live against your GPU.">
<meta name="twitter:card" content="summary">
<!-- 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>
:root {
color-scheme: light dark;
--bg: #ffffff; --fg: #1b1b1f; --muted: #5c5c68; --line: #e3e3e8;
--card: #fafafb; --code-bg: #f6f6f8; --accent: #2f6df6; --accent-fg: #ffffff;
--warn-bg: #fff6e5; --warn-fg: #7a4b00; --err: #b3261e; --ok: #1b7f4b;
--canvas-bg: #0d0d12;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131317; --fg: #e8e8ec; --muted: #9a9aa6; --line: #2a2a32;
--card: #1a1a20; --code-bg: #101015; --accent: #7aa2ff; --accent-fg: #10131c;
--warn-bg: #2c2413; --warn-fg: #e8c983; --err: #ff9d94; --ok: #6ee7a8;
--canvas-bg: #08080b;
}
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 4.2rem; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
main {
max-width: 48rem; margin: 0 auto;
padding: 1.5rem 1rem calc(4rem + env(safe-area-inset-bottom));
}
h1 { font-size: clamp(1.7rem, 6vw, 2.3rem); margin: 0 0 .35rem; letter-spacing: -.02em; line-height: 1.15; }
h2 {
font-size: clamp(1.15rem, 4.4vw, 1.4rem); margin: 0 0 .5rem; letter-spacing: -.01em;
line-height: 1.25; scroll-margin-top: 4.2rem;
}
h2 .num {
display: inline-block; min-width: 1.9rem; color: var(--accent);
font-variant-numeric: tabular-nums; font-size: .85em;
}
h3 { font-size: 1rem; margin: 1.5rem 0 .4rem; }
p { margin: 0 0 .85rem; }
.lede { color: var(--muted); font-size: 1.03rem; }
a { color: var(--accent); }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .88em; background: var(--code-bg); border: 1px solid var(--line);
border-radius: 4px; padding: .08em .32em; overflow-wrap: anywhere;
}
pre.static {
background: var(--code-bg); border: 1px solid var(--line); border-radius: 9px;
padding: .8rem .9rem; overflow-x: auto;
font: .85rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre.static code { background: none; border: 0; padding: 0; font-size: inherit; }
.note, .warn {
border: 1px solid var(--line); border-left: 3px solid var(--accent);
background: var(--card); border-radius: 0 9px 9px 0;
padding: .7rem .9rem; margin: 0 0 .9rem; font-size: .93rem;
}
.warn { border-left-color: var(--warn-fg); background: var(--warn-bg); color: var(--warn-fg); }
.warn code { background: rgba(0, 0, 0, .06); border-color: transparent; }
/* ---- sticky contents ------------------------------------------------------------ */
nav.toc {
position: sticky; top: 0; z-index: 20; background: var(--bg);
border-bottom: 1px solid var(--line); padding: .5rem 1rem;
/* Full-bleed: it is a sibling of <main>, so it must not borrow main's padding. */
margin: 0;
}
nav.toc details { max-width: 48rem; margin: 0 auto; }
nav.toc summary {
cursor: pointer; font-size: .9rem; color: var(--muted); list-style: none;
padding: .25rem 0; min-height: 34px; display: flex; align-items: center; gap: .4rem;
}
nav.toc summary::-webkit-details-marker { display: none; }
nav.toc summary::before { content: "▸"; transition: transform .15s ease; }
nav.toc details[open] summary::before { transform: rotate(90deg); }
nav.toc ol {
margin: .4rem 0 .3rem; padding: 0 0 0 1.4rem; columns: 2; column-gap: 1.4rem;
font-size: .88rem;
}
nav.toc li { margin: .15rem 0; break-inside: avoid; }
nav.toc a { text-decoration: none; }
nav.toc a:hover { text-decoration: underline; }
@media (max-width: 34rem) { nav.toc ol { columns: 1; } }
/* ---- steps ---------------------------------------------------------------------- */
section.step { margin: 0 0 3rem; }
section.step > .body > :last-child { margin-bottom: 0; }
/* ---- live demo card ------------------------------------------------------------- */
.demo {
border: 1px solid var(--line); border-radius: 11px; background: var(--card);
margin: 1rem 0 .9rem; overflow: hidden;
}
.demo canvas {
display: block; width: 100%; height: auto; aspect-ratio: 16 / 9;
background: var(--canvas-bg); border-bottom: 1px solid var(--line);
touch-action: none;
}
.demo.square canvas { aspect-ratio: 1 / 1; max-height: 78vh; }
.demo textarea {
display: block; width: 100%; border: 0; border-bottom: 1px solid var(--line);
background: var(--code-bg); color: var(--fg); resize: vertical;
padding: .8rem .9rem;
/* 16px keeps iOS from zooming the viewport when the box takes focus. */
font: 16px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
tab-size: 2;
}
@media (min-width: 40rem) { .demo textarea { font-size: 13.5px; line-height: 1.55; } }
.demo textarea:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar {
display: flex; flex-wrap: wrap; gap: .45rem; align-items: center;
padding: .55rem .6rem; background: var(--card);
}
.bar button {
min-height: 40px; padding: .35rem .95rem; font: inherit; font-size: .88rem;
border-radius: 7px; border: 1px solid var(--line); background: var(--bg);
color: inherit; cursor: pointer;
}
.bar button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; font-weight: 600; }
.bar button:hover { border-color: var(--accent); }
.bar .status {
margin-left: auto; font: .8rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--muted); padding-right: .3rem;
}
.bar .status.err { color: var(--err); }
.bar .status.ok { color: var(--ok); }
.demo pre.out {
margin: 0; padding: .75rem .9rem; border-top: 1px solid var(--line);
background: var(--code-bg); overflow-x: auto; max-height: 17rem; overflow-y: auto;
font: .82rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap; overflow-wrap: anywhere;
}
.demo pre.out:empty { display: none; }
.demo pre.out.err { color: var(--err); }
.try {
padding: .6rem .9rem; border-top: 1px solid var(--line); font-size: .88rem; color: var(--muted);
}
.try b { color: var(--fg); font-weight: 600; }
/* On a narrow screen the card takes the full width: the editor has to hold code at 16px
(anything smaller makes iOS zoom the page on focus), so every character counts. */
@media (max-width: 30rem) {
.demo {
margin-left: -1rem; margin-right: -1rem;
border-radius: 0; border-left: 0; border-right: 0;
}
.demo textarea { padding: .75rem .7rem; }
.demo pre.out, .bar, .try { padding-left: .7rem; padding-right: .7rem; }
}
#unsupported {
display: none; background: var(--warn-bg); color: var(--warn-fg);
border: 1px solid color-mix(in srgb, var(--warn-fg) 30%, transparent);
border-radius: 9px; padding: .85rem 1rem; margin: 0 0 1.5rem;
}
footer {
margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--line);
color: var(--muted); font-size: .92rem;
}
table { border-collapse: collapse; width: 100%; font-size: .9rem; margin: 0 0 .9rem; display: block; overflow-x: auto; }
th, td { border: 1px solid var(--line); padding: .4rem .55rem; text-align: left; vertical-align: top; }
th { background: var(--card); }
</style>
</head>
<body>
<nav class="toc">
<details id="toc">
<summary>Contents</summary>
<ol id="toclist"></ol>
</details>
</nav>
<main>
<h1>TinyWebGPU, step by step</h1>
<p class="lede">From one coloured pixel to a few hundred thousand particles and a 3D
terrain. Every code box below is live: edit it, press <b>Run</b>, and it recompiles against
your actual GPU.</p>
<div id="unsupported">
<strong>WebGPU isn’t available in this browser.</strong> The text still reads fine, but no
box will run. You need Chrome/Edge 113+, Firefox 141+ (Windows) or Safari 26+, over https
or localhost. On Android that means Chrome 121+ on Android 12+ — Samsung Internet, Firefox
for Android and the in-app browsers inside chat and mail apps have no WebGPU at all.
<a href="webgpu-check.html">Run the WebGPU check</a> to see what this device reports.
</div>
<div class="note">
<p><b>How the boxes work.</b> Each one is a function body with a few things already in
scope: <code>G</code> (an initialised TinyWebGPU), <code>canvas</code> (the one above the
code, when there is one), <code>log(…)</code>, which writes to the output panel, and the
type constants <code>FLOAT</code>, <code>VEC2</code>, … that step 03 sets up. In a file of
your own, those first lines would be:</p>
<pre class="static"><code>import { WEBGPU } from './tinywebgpu.js';
const G = await WEBGPU().init(canvas.getContext('webgpu'));</code></pre>
<p style="margin-bottom:0">Everything else is ordinary code — <code>await</code> at the top
level included. Animation loops are stopped when a box is re-run or scrolls out of view,
so nothing keeps burning your battery further up the page. The picker in the top-right
corner runs the whole page against the minified or tiny build instead of the readable
source; the tiny build drops optional features, and any box that needs one says so
rather than running.</p>
</div>
<section class="step" id="setup">
<h2><span class="num">01</span>Getting a device</h2>
<div class="body">
<p>Everything starts with one call. <code>init()</code> asks the browser for an adapter,
asks the adapter for a device with its own maximum buffer limits, and — if you hand it a
canvas context — configures that canvas for you.</p>
<pre class="static"><code>const G = await WEBGPU().init(canvas.getContext('webgpu')); // with a canvas
const G = await WEBGPU().init(); // compute only</code></pre>
<p>That is the whole setup. There is no renderer to construct, no scene, no asset pipeline.
Here is what your machine reported:</p>
<div class="demo">
<script type="text/plain" class="code">
log('canvas format:', G.format);
log('max storage buffer:',
(G.device.limits.maxStorageBufferBindingSize / 1048576).toFixed(0), 'MB');
log('max invocations per workgroup:',
G.device.limits.maxComputeInvocationsPerWorkgroup);
log('max workgroups per dispatch:',
G.device.limits.maxComputeWorkgroupsPerDimension);
log('features:', [...G.features].join(', ') || '(only the defaults)');
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
</div>
<p>Those limits are worth a glance now rather than later: they are the numbers that decide
how big a buffer you can bind and how many threads a workgroup can hold.</p>
</div>
</section>
<section class="step" id="first-pixels">
<h2><span class="num">02</span>Your first pixels</h2>
<div class="body">
<p><code>makeFrag</code> draws exactly one thing: a triangle big enough to cover the
screen. You never see it. You write a function called <code>frag</code> that turns a
coordinate into a colour, and the library generates the vertex shader and the plumbing
around it. (When you want real geometry instead, <code>makeDraw</code> lets you write
the vertex stage yourself — see the last step.)</p>
<p><code>uv</code> runs from <code>(0,0)</code> at the bottom-left to <code>(1,1)</code> at
the top-right. Everything else is up to you.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const quad = G.makeQuad({
frag: `
fn frag(uv: vec2<f32>) -> vec4<f32> {
return vec4<f32>(uv.x, uv.y, 0.6, 1.0);
}`,
});
quad.run();
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> swap <code>uv.y</code> for <code>1.0 - uv.y</code> and watch
the gradient flip. Or return <code>vec4<f32>(step(0.5, uv.x))</code> for a hard
edge. Break the WGSL on purpose — the error tells you the line.</div>
</div>
<p><code>makeQuad</code> is a convenience wrapper: it builds the pipeline and gives you
<code>run()</code>, which uploads uniforms and draws in one call. The longer form,
<code>makeFrag</code>, is the same thing without the wrapper.</p>
</div>
</section>
<section class="step" id="defines">
<h2><span class="num">03</span>Defines — because WGSL doesn’t have them</h2>
<div class="body">
<p>That three-line shader spelled out an angle-bracketed type three times. Coming from
GLSL, where those were plain <code>vec2</code> and <code>vec4</code>, the brackets get
old fast — and WGSL has no <code>#define</code> to shorten them with. So TinyWebGPU
provides one. <code>G.defines</code> is a string of <code>TOKEN replacement</code>
entries, separated by commas or newlines, and every shader you compile is expanded
against it — whole words only, longest token first, so <code>FLOAT</code> matches but
<code>myFLOATish</code> doesn’t:</p>
<pre class="static"><code>G.defines = `
FLOAT f32, INT i32, UINT u32
VEC2 vec2<f32>, VEC3 vec3<f32>, VEC4 vec4<f32>, MAT4 mat4x4<f32>
PI 3.141592653589793, TAU 6.283185307179586`;</code></pre>
<p>It is an ordinary property: assign it once after <code>init()</code>, append your own
entries with <code>+=</code>, and note it is not limited to types — <code>PI</code> and
<code>TAU</code> above are constants, the way you would have used <code>#define</code>
in GLSL. The default is <code>''</code>, which rewrites nothing.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
// This page assigns the table above for you, so the tokens just work:
// (and FLOAT / VEC2 / … are in scope as JS strings — see below)
const quad = G.makeQuad({
frag: `
fn frag(uv: VEC2) -> VEC4 {
let c = 0.5 + 0.5 * cos(TAU * uv.x + VEC3(0.0, 2.0, 4.0));
return VEC4(c, 1.0);
}`,
});
quad.run();
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> add <code>G.defines += ', HALF 0.5';</code> above the
pipeline and use <code>HALF</code> in the shader. A WGSL error still points at the
expanded source, so nothing gets harder to debug.</div>
</div>
<h3>The other half: the same names in JavaScript</h3>
<p>Defines stop at the shader. The <em>schema</em> you meet in the next step —
<code>uniforms: { time: 'f32' }</code> — is an ordinary JavaScript object that the
library parses itself; no compiler ever sees it, so <code>G.defines</code> never touches
it. Left alone, that splits your vocabulary down the middle: <code>VEC2</code> inside the
shader string, <code>'vec2<f32>'</code> in the schema one line above it.</p>
<p>JavaScript needs no feature from the library to fix this — a WGSL type is just text, so
make it a constant:</p>
<pre class="static"><code>const FLOAT = 'f32', INT = 'i32', UINT = 'u32';
const VEC2 = 'vec2<f32>', VEC3 = 'vec3<f32>', VEC4 = 'vec4<f32>', MAT4 = 'mat4x4<f32>';</code></pre>
<p>Now both sides read the same, and a schema says what it means:</p>
<pre class="static"><code>uniforms: { time: FLOAT, res: VEC2, mouse: VEC2 }</code></pre>
<p>Better still, write the table <em>once</em> and let the string follow from it, so the two
halves cannot drift apart:</p>
<pre class="static"><code>const TYPES = { FLOAT: 'f32', VEC2: 'vec2<f32>', VEC4: 'vec4<f32>' /* … */ };
const { FLOAT, VEC2, VEC4 } = TYPES;
G.defines = Object.entries(TYPES).map(([t, r]) => `${t} ${r}`).join('\n');</code></pre>
<p>That is exactly what this page does, which is why every box below can use
<code>VEC2</code> in a shader and <code>VEC2</code> in a schema and mean the same thing.
Two notes on the edges. <code>PI</code> and <code>TAU</code> stay shader-only — they are
values, not types, so no schema ever asks for them. And <em>resource</em> schemas keep
their quotes: <code>'array<f32>'</code>, <code>'texture_2d<f32>'</code>,
a whole <code>struct</code> — those are complete type expressions rather than the plain
names the table covers, and <code>`array<${FLOAT}>`</code> costs more to read than
it saves.</p>
</div>
</section>
<section class="step" id="time">
<h2><span class="num">04</span>Uniforms, and making it move</h2>
<div class="body">
<p>A shader with no inputs can only ever draw one image. Uniforms are the inputs, and this
is where TinyWebGPU earns its keep. You declare them as a plain JS object:</p>
<pre class="static"><code>uniforms: { time: FLOAT }</code></pre>
<p>and the library generates the WGSL struct, binds it at
<code>@group(0) @binding(0)</code>, and gives you a setter that writes the right bytes at
the right offsets. Inside the shader they live on <code>UB</code>. There is nothing to
keep in sync by hand — the JS object <em>is</em> the layout.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const quad = G.makeQuad({
frag: `
fn frag(uv: VEC2) -> VEC4 {
let c = 0.5 + 0.5 * cos(UB.time + uv.xyx * 4.0
+ VEC3(0.0, 2.0, 4.0));
return VEC4(c, 1.0);
}`,
uniforms: { time: FLOAT },
});
const loop = t => {
quad.run({ time: t * 0.001 });
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> add <code>speed: FLOAT</code> to the uniforms, pass it in
<code>run()</code>, and multiply <code>UB.time</code> by it. Nothing else needs to
change — no struct to edit, no offsets to count.</div>
</div>
<p>Uniform types cover the usual ground: <code>f32</code>, <code>i32</code>,
<code>u32</code>, <code>vec2/3/4</code> of each, and <code>mat4x4<f32></code> —
which is what <code>FLOAT</code>, <code>INT</code>, <code>UINT</code>,
<code>VEC2/3/4</code> and <code>MAT4</code> hold, so the constants and the quoted
spellings are interchangeable. Vectors and matrices take arrays; scalars take
numbers.</p>
</div>
</section>
<section class="step" id="resolution">
<h2><span class="num">05</span>Resolution, aspect and the pointer</h2>
<div class="body">
<p>Two things bite everyone once. First, a canvas has a CSS size and a backing-store size,
and they are not the same on a phone — <code>G.resizeCanvas()</code> sets the second from
the first times <code>devicePixelRatio</code> and hands back the pixel size, ready to
drop into a <code>vec2<f32></code>. Second, <code>uv</code> is normalised, so
circles drawn in it come out as ellipses until you divide by the aspect ratio.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const quad = G.makeQuad({
frag: `
fn frag(uv: VEC2) -> VEC4 {
// square up the coordinates, so a circle stays a circle
let a = UB.res.x / UB.res.y;
let p = VEC2((uv.x - 0.5) * a, uv.y - 0.5);
let m = VEC2((UB.mouse.x - 0.5) * a, UB.mouse.y - 0.5);
let d = length(p - m);
let glow = 0.04 / (d + 0.03);
let rings = 0.5 + 0.5 * sin(d * 55.0 - UB.time * 3.0);
let col = VEC3(0.45, 0.72, 1.0) * glow
+ VEC3(0.10, 0.13, 0.30) * rings;
return VEC4(col, 1.0);
}`,
uniforms: { res: VEC2, mouse: VEC2, time: FLOAT },
});
let mouse = [0.5, 0.5];
canvas.onpointermove = e => {
const r = canvas.getBoundingClientRect();
mouse = [(e.clientX - r.left) / r.width,
1 - (e.clientY - r.top) / r.height];
};
const loop = t => {
const { width, height } = G.resizeCanvas(canvas);
quad.run({ res: [width, height], mouse, time: t * 0.001 });
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> touch or drag on the canvas — pointer events cover mouse and
touch alike. Then delete the <code>* a</code> on both lines to see the stretch you were
correcting.</div>
</div>
<div class="note"><code>resizeCanvas()</code> returns <code>changed</code> as well as the
size. Gate any reallocation you do — render targets, grids, buffers sized to the
screen — on that flag, or you will rebuild them sixty times a second.</div>
</div>
</section>
<section class="step" id="wgsl">
<h2><span class="num">06</span>It is just WGSL above your frag</h2>
<div class="body">
<p>Anything you put in the <code>frag</code> string before the <code>frag</code> function is
ordinary module-scope WGSL: helper functions, structs, constants. The library only
appends its own wrapper at the end. That is enough to do real work — here are two signed
distance functions and a smooth minimum, which is most of a raymarcher in eight lines.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const quad = G.makeQuad({
frag: `
fn sdBox(p: VEC2, b: VEC2) -> FLOAT {
let d = abs(p) - b;
return length(max(d, VEC2(0.0))) + min(max(d.x, d.y), 0.0);
}
fn smin(a: FLOAT, b: FLOAT, k: FLOAT) -> FLOAT {
let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
fn frag(uv: VEC2) -> VEC4 {
let a = UB.res.x / UB.res.y;
let p = VEC2((uv.x - 0.5) * a, uv.y - 0.5) * 2.2;
let t = UB.time;
let ball = length(p - VEC2(cos(t) * 0.5, sin(t) * 0.3)) - 0.22;
let box = sdBox(p - VEC2(cos(t * 0.7) * 0.8, 0.0),
VEC2(0.22, 0.17));
let d = smin(ball, box, 0.128); // they melt into each other
let fill = smoothstep(0.02, -0.02, d);
let edge = smoothstep(0.02, 0.0, abs(d % 0.1));
let col = mix(VEC3(0.05, 0.06, 0.11),
VEC3(0.16, 0.42, 0.95), fill)
+ VEC3(0.85, 0.93, 1.0) * edge * 0.9;
return VEC4(col, 1.0);
}`,
uniforms: { res: VEC2, time: FLOAT },
});
const loop = t => {
const { width, height } = G.resizeCanvas(canvas);
quad.run({ res: [width, height], time: t * 0.001 });
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> change the <code>0.28</code> in <code>smin</code> to
<code>0.0</code> — the shapes stop melting and just overlap.</div>
</div>
</div>
</section>
<section class="step" id="compute">
<h2><span class="num">07</span>Your first compute pass</h2>
<div class="body">
<p>No canvas from here to step 8 — compute needs no pixels at all. A compute pipeline takes
two strings: declarations, and the body of the entry point. The body gets
<code>gid</code> (global invocation id), <code>lid</code> (local) and <code>wid</code>
(workgroup), and it runs once per thread.</p>
<p>Resources are the second schema. <code>{ data: 'array<f32>' }</code> becomes a
storage buffer binding, declared after the uniform block, in the order you wrote it.</p>
<div class="demo">
<script type="text/plain" class="code">
const p = G.makeCompute('', `
let i = gid.x;
if (i >= UB.n) { return; } // the last workgroup overhangs
data[i] = FLOAT(i) * FLOAT(i);
`,
{ n: UINT }, // uniforms -> UB
{ data: 'array<f32>' }, // resources -> storage buffer
{ wg: [64, 1, 1] }); // threads per workgroup
const N = 16;
const buf = G.createStorageBuffer(N * 4);
p.setResources({ data: buf.b });
p.setUniforms({ n: N });
p.dispatch(Math.ceil(N / 64)); // 1 workgroup of 64 threads
log('squares:', await buf.r(N * 4, 0, Float32Array));
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> raise <code>N</code> to 100. One workgroup of 64 threads is
no longer enough, and <code>Math.ceil</code> quietly asks for two — which is exactly
why the guard on the first line has to be there.</div>
</div>
<p><code>buf.r()</code> copies the buffer back to the CPU and waits for the GPU to catch
up. It is a debugging tool, and it is the slowest thing on this page. Step 11 shows what
to do instead in a real loop.</p>
</div>
</section>
<section class="step" id="dispatch">
<h2><span class="num">08</span>Sizing a dispatch — and counting primes</h2>
<div class="body">
<p>A dispatch asks for a number of <em>workgroups</em>, not threads. With
<code>wg: [64,1,1]</code>, one workgroup is 64 threads, so covering <code>N</code> items
means <code>Math.ceil(N / 64)</code> workgroups — and the last one overhangs, which is
what the bounds check is for. Get that wrong and you write past the end of a buffer.</p>
<p>Primality by trial division is a fair demonstration of what changes on a GPU: every
number is independent, so a hundred thousand of them can be tested at once. The same loop
runs on this thread underneath, for scale. Note what the GPU number includes — the
dispatch, the readback stall, and a fixed setup cost that only starts to look small once
<code>N</code> is large.</p>
<div class="demo">
<script type="text/plain" class="code">
const N = 200000; // edit me, then press Run
const WG = 64;
const primes = G.makeCompute('', `
let n = gid.x;
if (n >= UB.n || n < 2u) { return; }
var d = 2u;
while (d * d <= n) {
if (n % d == 0u) { return; } // composite: this thread is done
d = d + 1u;
}
atomicAdd(&count.n, 1u);
`,
{ n: UINT },
{ count: 'struct Count { n: atomic<u32> }' },
{ wg: [WG, 1, 1] });
const count = G.createStorageBuffer(4);
primes.setResources({ count: count.b });
primes.setUniforms({ n: N });
const t0 = performance.now();
primes.dispatch(Math.ceil(N / WG));
const [gpu] = await count.r(4, 0, Uint32Array);
const gpuMs = performance.now() - t0;
const t1 = performance.now();
let cpu = 0;
for (let n = 2; n < N; n++) {
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) cpu++;
}
const cpuMs = performance.now() - t1;
log('workgroups dispatched:', Math.ceil(N / WG),
'threads:', Math.ceil(N / WG) * WG);
log('primes below', N + ':', gpu,
gpu === cpu ? '— the CPU agrees' : '— MISMATCH, cpu says ' + cpu);
log('gpu', gpuMs.toFixed(1) + 'ms', '(including the readback stall)');
log('cpu', cpuMs.toFixed(1) + 'ms');
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> push <code>N</code> to a million. On real GPU hardware the
dispatch barely notices while the CPU line grows with it; if your browser has fallen
back to software rendering, expect the opposite, since both are then the same CPU.
(Be patient — the CPU loop blocks the page while it runs.)</div>
</div>
<div class="note">Every thread that finds a prime does an <code>atomicAdd</code> on the same
counter, which is the slow way round. Step 11 shows the version that counts in a register
first and touches the shared counter once.</div>
</div>
</section>
<section class="step" id="buffers">
<h2><span class="num">09</span>A storage buffer, driving pixels</h2>
<div class="body">
<p>Compute and render are not separate worlds. A buffer written by a compute pass can be
read by a fragment shader in the same frame, without ever going back to the CPU — the
data simply stays where it was made.</p>
<p>Note that the two pipelines declare the same buffer independently, each with the type it
needs. Bindings are per-pipeline; the buffer does not care.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const W = 192, H = 108;
const field = G.makeCompute('', `
let x = gid.x;
let y = gid.y;
if (x >= ${W}u || y >= ${H}u) { return; }
let p = VEC2(FLOAT(x) / ${W}.0, FLOAT(y) / ${H}.0) * 6.0;
heat[y * ${W}u + x] =
sin(p.x + UB.time) * cos(p.y * 1.3 - UB.time * 0.7)
+ 0.5 * sin(length(p - 3.0) * 4.0 - UB.time * 2.0);
`,
{ time: FLOAT }, { heat: 'array<f32>' }, { wg: [8, 8, 1] });
const show = G.makeFrag(`
fn frag(uv: VEC2) -> VEC4 {
let x = min(UINT(uv.x * ${W}.0), ${W}u - 1u);
let y = min(UINT(uv.y * ${H}.0), ${H}u - 1u);
let v = heat[y * ${W}u + x];
let warm = VEC3(1.0, 0.55, 0.25);
let cool = VEC3(0.20, 0.45, 1.0);
return VEC4(mix(cool, warm, v * 0.5 + 0.5) * abs(v), 1.0);
}`,
{}, { heat: 'array<f32>' });
const buf = G.createStorageBuffer(W * H * 4);
field.setResources({ heat: buf.b });
show.setResources({ heat: buf.b });
const loop = t => {
field.setUniforms({ time: t * 0.001 });
field.dispatch(Math.ceil(W / 8), Math.ceil(H / 8));
show.drawTo();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> raise <code>W</code> and <code>H</code> to 384 and 216. The
dispatch maths adapts on its own, because it is written in terms of the size.</div>
</div>
<p>This loop submits twice per frame — once for the dispatch, once for the draw. Step 10
fixes that.</p>
</div>
</section>
<section class="step" id="pingpong">
<h2><span class="num">10</span>Ping-pong: state that lives on the GPU</h2>
<div class="body">
<p>A simulation needs to read the previous state while writing the next one, and it cannot
do both to the same buffer — threads would race each other. The fix is two buffers,
swapped every step. Conway's life is the smallest honest example.</p>
<div class="demo">
<canvas></canvas>
<script type="text/plain" class="code">
const W = 192, H = 108;
const step = G.makeCompute(`
fn idx(x: INT, y: INT) -> UINT { // wraps at the edges
return UINT(((y + ${H}) % ${H}) * ${W} + ((x + ${W}) % ${W}));
}`, `
let x = INT(gid.x);
let y = INT(gid.y);
if (x >= ${W} || y >= ${H}) { return; }
var n = 0u;
for (var dy = -1; dy <= 1; dy++) {
for (var dx = -1; dx <= 1; dx++) {
if (dx != 0 || dy != 0) { n += src[idx(x + dx, y + dy)]; }
}
}
let alive = src[idx(x, y)] == 1u;
dst[idx(x, y)] = select(select(0u, 1u, n == 3u),
select(0u, 1u, n == 2u || n == 3u), alive);
`,
{}, { src: 'array<u32>', dst: 'array<u32>' }, { wg: [8, 8, 1] });
const show = G.makeFrag(`
fn frag(uv: VEC2) -> VEC4 {
let x = min(UINT(uv.x * ${W}.0), ${W}u - 1u);
let y = min(UINT((1.0 - uv.y) * ${H}.0), ${H}u - 1u);
let c = FLOAT(cells[y * ${W}u + x]);
return VEC4(c * 0.75, c * 0.95, c * 0.6 + 0.05, 1.0);
}`,
{}, { cells: 'array<u32>' });
// createStorageBuffer takes a TypedArray to size *and* fill in one call
let a = G.createStorageBuffer(
Uint32Array.from({ length: W * H }, () => Math.random() < 0.32 ? 1 : 0));
let b = G.createStorageBuffer(W * H * 4);
const loop = () => {
step.resources = { src: a.b, dst: b.b };
step.dispatch(Math.ceil(W / 8), Math.ceil(H / 8));
show.resources = { cells: b.b };
show.drawTo();
[a, b] = [b, a]; // the ping-pong
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> change <code>0.32</code> to <code>0.08</code> for a sparse
start, or to <code>0.6</code> for a crowded one that mostly dies.</div>
</div>
<div class="note">Assigning <code>p.resources = {...}</code> rebuilds the bind group, and
it is compared by reference — passing the same object again is a no-op, passing a fresh
one forces the rebuild. In a hot loop, build both bind groups once and alternate.</div>
</div>
</section>
<section class="step" id="frames">
<h2><span class="num">11</span>One submit per frame</h2>
<div class="body">
<p>Every <code>dispatch()</code> and <code>drawTo()</code> so far submitted work to the
queue on its own. That is fine for one or two, and wasteful for ten.
<code>beginFrame()</code> opens a single encoder; everything recorded until
<code>endFrame()</code> goes to the GPU in one submit.</p>
<p>It buys something subtler too. Inside a frame, uniform writes are <em>staged</em> and
copied at the point in the frame where you made them — so three dispatches can each see
their own values, in one submit. Without that, they would all see whichever write landed
last. Here is the proof: three dispatches, three different values, one submit.</p>
<div class="demo">
<script type="text/plain" class="code">
const p = G.makeCompute('', `
let i = gid.x;
if (i >= 4u) { return; }
out[UB.slot * 4u + i] = UB.value; // each dispatch fills one row
`,
{ slot: UINT, value: FLOAT }, { out: 'array<f32>' }, { wg: [4, 1, 1] });
const buf = G.createStorageBuffer(3 * 4 * 4);
p.setResources({ out: buf.b });
G.beginFrame(); // one encoder…
p.setUniforms({ slot: 0, value: 1.5 });
p.dispatch(1);
p.setUniforms({ slot: 1, value: 2.5 });
p.dispatch(1);
p.setUniforms({ slot: 2, value: 3.5 });
p.dispatch(1);
G.endFrame(); // …one submit
log(await buf.r(12 * 4, 0, Float32Array));
log('');
log('Three rows, three values. Each dispatch saw the uniforms');
log('written just before it, inside a single submission.');
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> move all three <code>setUniforms</code> calls above
<code>beginFrame()</code>. Now the writes happen before the frame, every dispatch reads
the same struct, and all twelve numbers come back as 3.5.</div>
</div>
<p>The same applies to a real loop: fade, simulate and draw belong in one frame, and a
simulation that wants four steps per displayed frame just dispatches four times before
drawing.</p>
<pre class="static"><code>G.beginFrame();
for (let i = 0; i < STEPS; i++) { sim.dispatch(gx, gy); [a, b] = [b, a]; }
show.drawTo();
G.endFrame();</code></pre>
<p>There is a tighter variant, <code>beginCompute()</code>, which also keeps a single
compute <em>pass</em> open across chained dispatches — worth reaching for when you are
running the same kernel dozens of times in a row. Outside a frame it owns the submit
itself, so a chain of dispatches is self-contained.</p>
</div>
</section>
<section class="step" id="atomics">
<h2><span class="num">12</span>Atomics, reduction, and not stalling</h2>
<div class="body">
<p>Thousands of threads with one answer between them need an atomic. The trap is using it
per item: every thread queueing on the same address serialises the very thing you
parallelised. The fix is always the same shape — accumulate privately, publish once.</p>
<p>Monte Carlo π is the classic demonstration. Throw darts at the unit square; the fraction
landing inside the quarter circle is π/4. Each thread runs its own random stream, counts
its own hits in a register, and does exactly one <code>atomicAdd</code> at the end.</p>
<div class="demo">
<script type="text/plain" class="code">
const THREADS = 16384, K = 16, WG = 64, ROUNDS = 16;
const darts = G.makeCompute(`
fn rnd(state: ptr<function, UINT>) -> FLOAT {
let x = *state * 747796405u + 2891336453u;
*state = x;
let w = ((x >> ((x >> 28u) + 4u)) ^ x) * 277803737u;
return FLOAT((w >> 22u) ^ w) * 2.3283064365386963e-10;
}`, `
let i = gid.x;
if (i >= UB.threads) { return; }
var seed = i * 2654435761u + UB.round * 2246822519u + 1u;
var hits = 0u; // private tally…
for (var k = 0u; k < UB.k; k = k + 1u) {
let x = rnd(&seed);
let y = rnd(&seed);
if (x * x + y * y <= 1.0) { hits = hits + 1u; }
}
atomicAdd(&stats.hits, hits); // …one shared write
atomicAdd(&stats.threads, 1u);
`,
{ threads: UINT, round: UINT, k: UINT },
{ stats: 'struct Stats { hits: atomic<u32>, threads: atomic<u32> }' },
{ wg: [WG, 1, 1] });
const stats = G.createStorageBuffer(8);
darts.setResources({ stats: stats.b });
const t0 = performance.now();
G.beginCompute(); // all rounds, one pass, one submit
for (let r = 0; r < ROUNDS; r++) {
darts.setUniforms({ threads: THREADS, round: r, k: K });
darts.dispatch(THREADS / WG);
}
G.endCompute();
const [hits, threads] = await stats.r(8, 0, Uint32Array);
const n = threads * K;
const est = 4 * hits / n;
log('darts thrown:', n, 'in', (performance.now() - t0).toFixed(0) + 'ms');
log('estimate:', est.toFixed(6), ' actual π:', Math.PI.toFixed(6));
log('error:', Math.abs(est - Math.PI).toExponential(2),
' vs 1/√N =', (1 / Math.sqrt(n)).toExponential(2));
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> move <code>atomicAdd(&stats.hits, 1u)</code> inside the
dart loop and delete the private tally. Same answer, far more contention. Then raise
<code>ROUNDS</code> and watch the error fall like 1/√N — a hundred times the darts for
each new decimal place.</div>
</div>
<div class="warn">
<b><code>.r()</code> stalls.</b> It waits for the GPU to finish and for the result to be
mapped back — the one thing you must not do inside a frame loop. Keep results on the GPU
and sample them occasionally without awaiting in the loop, the way
<a href="../examples/6_evolution.html">example 6</a> does. Reading during an open frame is
worse still: it sees pre-frame data, and says so in the console.
</div>
</div>
</section>
<section class="step" id="indirect">
<h2><span class="num">13</span>Letting the GPU decide the workgroup count</h2>
<div class="body">
<p>Sometimes the amount of work is only known once the GPU has done some. Reading a count
back to the CPU just to size the next dispatch throws away the whole frame's pipelining.
Indirect dispatch keeps it on the GPU: one kernel writes <code>[x,y,z]</code> into a
buffer, and the next dispatch reads its own size from there.</p>
<div class="demo">
<script type="text/plain" class="code">
const N = 1000;
// 1. keep the multiples of 7, compacted, and count them
const filter = G.makeCompute('', `
let i = gid.x;
if (i >= ${N}u) { return; }
if (i % 7u == 0u) {
let slot = atomicAdd(&counters.n, 1u); // returns the OLD value:
survivors[slot] = i; // a unique slot per thread
}
`, {}, {
counters: 'struct Counters { n: atomic<u32> }',
survivors: 'array<u32>',
}, { wg: [64, 1, 1] });
// 2. turn that count into dispatch arguments
const prep = G.makeCompute('', `
if (gid.x == 0u) {
args[0] = (counters.n + 63u) / 64u; // workgroups of 64
args[1] = 1u;
args[2] = 1u;
}
`, {}, {
counters: 'struct Counters { n: u32 }',
args: 'array<u32>',
}, { wg: [1, 1, 1] });
// 3. run over exactly the survivors, sized by the GPU
const sum = G.makeCompute('', `
let i = gid.x;
if (i < counters.n) { atomicAdd(&total.v, survivors[i]); }
`, {}, {
counters: 'struct C2 { n: u32 }',
survivors: 'array<u32>',
total: 'struct Total { v: atomic<u32> }',
}, { wg: [64, 1, 1] });
const counters = G.createStorageBuffer(4);
const survivors = G.createStorageBuffer(N * 4);
const total = G.createStorageBuffer(4);
const indirect = G.createIndirectBuffer();
filter.setResources({ counters: counters.b, survivors: survivors.b });
prep.setResources({ counters: counters.b, args: indirect.b });
sum.setResources({ counters: counters.b, survivors: survivors.b,
total: total.b });
G.beginFrame(); // three kernels, one submit
filter.dispatch(Math.ceil(N / 64));
prep.dispatch(1);
sum.dispatchIndirect(indirect.b, 0); // size came from the GPU
G.endFrame();
const [found] = await counters.r(4, 0, Uint32Array);
const [added] = await total.r(4, 0, Uint32Array);
log('multiples of 7 below', N + ':', found, '(expected 143)');
log('their sum:', added, '(expected 71071)');
</script>
<div class="bar">
<button class="run primary">Run</button>
<button class="reset">Reset</button>
<span class="status">idle</span>
</div>
<pre class="out"></pre>
<div class="try"><b>Try:</b> change <code>7u</code> to <code>3u</code>. Three times the
survivors, three times the workgroups in step 3 — and not a line of JS knew about
it.</div>
</div>
<p>This is the wavefront pattern in miniature: cull, compact, count, and run the next stage
over what is left. It is how particle systems retire dead particles and how ray tracers
keep only the rays still in flight.</p>
</div>
</section>
<section class="step" id="textures">