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
|
# Research: Dynamic Shader Pipeline for raylib-jamstack (web-first)
**Goal:** a realtime-toggleable post-processing effect chain. Effects can be
turned on/off at runtime. Web (Emscripten/WebGL) is the highest-priority target;
desktop (Zig/OpenGL 3.3) must keep working.
> ### ⚠ Critical requirements (non-negotiable — read before any design tradeoff)
> 1. **Web is the primary target, not desktop.** The pipeline MUST build and run on
> the web build (`build_web.sh` → `game.html`). A design that only works on desktop
> is rejected. Desktop must keep working too (no regression), but web is the bar.
> 2. **Runtime toggling MUST work on web** — every effect, in **both** the GAME stage
> (`fx.game_shaders`) and the TOP stage (`fx.top_shaders`), toggles on/off at
> runtime on the web build, with zero shader recompilation. Verified vector: the
> eval bridge (`sh .live/web/bin/eval 'fx.game_shaders[0].enabled = false'`), which
> is **browser-verified** on web today (it already returns live `Rl.get_fps`). The
> in-game console (`\`) uses the same main-thread eval path and should toggle too
> (verify in the browser — see Verification plan).
> 3. No C/binding changes that risk the web build. The pipeline is pure Ruby over
> the already-bound shader API; the only web build-system change is the optional
> ES3/WebGL2 upgrade (blocker-analyzed separately, no hard blockers).
**Status:** research complete, no code written. Recommendation at the bottom.
---
## TL;DR / Recommendation
1. **Build the pipeline as a layered, two-stage Ruby ping-pong stack** — a GAME
stage (gameplay FX affecting the game world + in-world UI, but NOT the overlay
HUD) and a TOP stage (complete FX affecting everything, including the HUD),
with two RmlUi contexts (in-world UI rendered into the game layer; overlay HUD
composited between the stages). Flow: `game+game-rmlui → game shaders → top-rmlui →
top shaders → screen`. **No C/binding changes needed** — every required function is
already bound (`load_shader_from_memory`, `load_render_texture`, `texture_mode`/
`shader_mode` blocks, `set_shader_value*`, `get_shader_location`, `draw_texture_pro`,
blend modes). Details in "Recommended architecture (Ruby)".
2. **Upgrade the web build to WebGL2 (ES3).** You said you're open to it, and it is
the right call: it unlocks HDR/float textures (proper bloom), `#version 300 es`
(cleaner shaders, integer/uint, loops without limits), and matches the desktop
`#version 330` dialect closely — so a **single shader source** can serve both
targets with only the `#version` line differing. Cost is small: recompile raylib
with `GRAPHICS=GRAPHICS_API_OPENGL_ES3` + add `-sMAX_WEBGL_VERSION=2` to the link.
WebGL2 is ~98% of browsers (caniuse), so dropping WebGL1 is low-risk.
3. **Toggle = skip the pass.** An effect toggled off is simply removed from (or
short-circuited in) the per-frame chain. No shader recompilation, no GPU state
churn beyond an FBO bind swap. Optional: a `lerp`/`mix` uniform lets an effect
*fade* in/out rather than snap.
---
## Blockers analysis: is the WebGL2/ES3 upgrade safe for raylib?
I dug into the one known scary issue and traced it through the **local vendor
source** (raylib 5.5.0). Short answer: **no hard blockers; the upgrade is safe.**
One important flag pitfall to avoid (already flagged above) and a couple of
soft caveats.
### The known scary issue: raylib #4330 — RESOLVED, not a blocker for us
[Issue #4330](https://github.com/raysan5/raylib/issues/4330): `glVertexAttribPointer()
error client-side with WebGL 2.0 (OpenGL ES 3.0)`. Reported Sep 2024; people hit
`Cannot set properties of undefined (setting 'clientside')` and, after removing
`FULL_ES*`, `WebGL: INVALID_VALUE: vertexAttribPointer: index out of range`.
**Root cause (traced in vendor `vendor/raylib/src/rlgl.h`):** the failure is in
`rlDrawRenderBatch()` (6.0: VAO bind ~line 2992, client-array `else` ~line 3090):
```c
if (RLGL.State.ExtSupported.vao) glBindVertexArray(...); // GOOD path
else { /* client-side glVertexAttribPointer, NO bound VAO */ } // BROKEN on WebGL w/o FULL_ES*
```
On **WebGL1/ES2 without the `GL_OES_vertex_array_object` extension**, raylib falls
to the `else` branch — client-side vertex arrays, which WebGL forbids unless
`-sFULL_ES2=1`/`-sFULL_ES3=1` (the emulation flag) is on. That branch is what
throws. **This is exactly the `FULL_ES*` trap**, not an ES3 bug.
**Why ES3 is immune:** under `GRAPHICS_API_OPENGL_ES3`, raylib sets
`RLGL.ExtSupported.vao = true` unconditionally (vendor rlgl.h, 6.0 ~line 2438,
"OpenGL ES 3.0 extensions supported by default (or it should be)") — VAO is core
in ES3/WebGL2, no extension needed. So the code **always takes the `glBindVertexArray`
branch**, never the client-array `else`. That is why the maintainer (raysan5)
**could not reproduce** when compiling raylib with `PLATFORM_WEB` +
`GRAPHICS_API_OPENGL_ES_30` and linking `-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2`
(**no** `FULL_ES*`). He closed the issue and committed
`22c77d1 "REVIEWED: WebGL2 (OpenGL ES 3.0) backend flags (PLATFORM_WEB)"`
(Oct 25 2024). That commit predates **raylib 5.5 (Nov 18 2024)** — our vendor
version — so **the fix is present**. [Source: raylib #4330 + release dates]
### "But ES3 has only 17 code paths vs 118 ES2" — not a problem
Counting `#if defined(...)` guards in the vendor rlgl.h: ES3=17, ES2=118, GL33=131.
Looks thin, but it's the correct superset pattern:
```c
// vendor rlgl.h lines 191-192 (6.0; was 188-191 in 5.5):
// OpenGL ES 3.0 uses OpenGL ES 2.0 functionality (and more)
#if defined(GRAPHICS_API_OPENGL_ES3)
#define GRAPHICS_API_OPENGL_ES2 // <-- ES3 auto-defines ES2
#endif
```
So building with `GRAPHICS=GRAPHICS_API_OPENGL_ES3` compiles **all 118 ES2 blocks
PLUS the 17 ES3 enhancements** (float textures, MRT/blit, `<GLES3/gl3.h>`). No
function becomes a no-op. (I checked the scariest one — `rlEnableShader` is gated
`GL33 || ES2`; since ES3→ES2, it compiles fine.)
**Exact define name is `GRAPHICS_API_OPENGL_ES3`** (vendor Makefile line 260). The
issue #4330 thread references `GRAPHICS_API_OPENGL_ES_30` — that's a typo/shorthand
in the discussion; the real symbol is `GRAPHICS_API_OPENGL_ES3`. Our
`build_web.sh` override must use the latter.
### RmlUi is NOT a blocker (important — this project uses RmlUi)
The project's RmlUi render interface is `class RaylibRlgl` in
`mrbgems/rmlui/src/rml_bindings.cpp`, implemented **entirely against rlgl**
(`rlBegin(RL_TRIANGLES)` / `rlVertex2f` / `rlColor4ub` / `rlSetTexture` / `rlEnd` —
confirmed in the source). It does **not** use raw GL or its own shaders. Therefore:
- It draws through raylib's **default shader**, which is correctly `#version`'d per
backend (verified: rlgl.h 6.0 lines 5012/5021/5029).
- It goes through `rlDrawRenderBatch` → the **VAO branch** under ES3 (not the
client-array `else` that breaks).
So RmlUi is fully insulated from the ES2→ES3 switch. No RmlUi-side shader changes,
no separate `#version` handling for the HUD. This also means **the HUD will render
correctly under WebGL2 with no extra work** — it's the post-processing *fragment
shaders we write* that must carry their own `#version 300 es` (see the
"you must supply the `#version` line" section).
### HDR bloom is genuinely available (bonus confirmation)
Under ES3 the vendor sets `texFloat32 = true` and `texFloat16 = true`
(rlgl.h 6.0 ~lines 2441–2442), and `rlGetGlTextureFormats()` was adapted for ES3
float formats (per PR #3107 "Continuation of support for ES3/WebGL2"). So
`RGBA16F`/`RGBA32F` render targets work → real HDR bloom (bright-pass can exceed
1.0). This is the main capability win over WebGL1, and it's real, not theoretical.
### Soft caveats (not blockers; things to watch during verification)
1. **"Has not been widely tested"** — the maintainer said this verbatim about the
ES3 backend in issue #4330 (Sep 2024). It's newer and less exercised than ES2/GL33.
Two `// TODO` markers remain in the vendor (rlgl.h ~line 2411 "Check for
additional OpenGL ES 3.0 supported extensions" and ~line 2424 "Support GLAD
loader for OpenGL ES 3.0") — both about extension-checking/loader plumbing, not
core rendering. Implication: **verify on the browser early** (see verification
plan) rather than assuming desktop behavior carries over.
2. **Shader `#version` still your responsibility** — unchanged by the upgrade.
raylib does not inject `#version` into user fragment shaders (rlgl.h
`rlLoadShaderProgram` ~line 4265, which calls `rlLoadShader` ~4205 to compile your
string as-is). Under WebGL2 you must
write `#version 300 es\nprecision mediump float;\n`. Under desktop `#version 330`.
(The post-pro fragment shaders we author, NOT the default shader RmlUi uses.)
3. **Don't keep `-sFULL_ES2=1`** on the upgraded link line. It's currently in
`build_web.sh` (line 87). With ES3 it's unnecessary and, combined with confusion,
is the exact footgun that broke #4330. Replace with `-sMIN_WEBGL_VERSION=2
-sMAX_WEBGL_VERSION=2`. (Verify nothing in rmlui/raylib relies on client-side
vertex arrays — unlikely, since the project uses VAO-capable paths, but check
the first web build's console for GL errors.)
4. **Stale `libraylib.a`** — switching ES2→ES3 changes the GL backend, so
`build/web/libraylib.a` is invalid. Delete it and let `build_web.sh` rebuild
(its existing `make clean` guard handles the in-place `.o` collision per repo
rule `.agents/rules/raylib-platform-objs.md`).
### Verdict
**No hard blockers.** The only way to hit #4330 is to use `-sFULL_ES3=1` (which we
won't) or to run ES2 *without* VAO support *without* `FULL_ES2` (which we're leaving
behind). The correct flag combination — `GRAPHICS=GRAPHICS_API_OPENGL_ES3` raylib +
`-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2` link, no `FULL_ES*` — is the
maintainer-blessed path and is verified present in our vendor (5.5). RmlUi rides
along for free. Recommend proceeding, with an early browser smoke test to honor the
"not widely tested" caveat.
---
## What the codebase looks like today
- **No shaders, no `RenderTexture`, no post-pro anywhere.** Confirmed by searching
all `game/*.rb` and `mrblib/*.rb`. Every demo renders directly to the screen
inside `Rl.draw(clear_color:) { ... }` (see `game/main.rb`, `game/physics_playground.rb`).
- **raylib 5.5.0** (vendor), built via its Makefile.
- **Desktop GL backend:** `GRAPHICS_API_OPENGL_33` → GLSL `#version 330`
(`vendor/raylib/src/Makefile` line 237: `GRAPHICS ?= GRAPHICS_API_OPENGL_33`).
- **Web GL backend (current):** `GRAPHICS_API_OPENGL_ES2` → GLSL `#version 100`
→ **WebGL 1** (`vendor/raylib/src/Makefile` lines 257–260).
The ES3 line is right there, commented out:
```
ifeq ($(TARGET_PLATFORM),PLATFORM_WEB)
GRAPHICS = GRAPHICS_API_OPENGL_ES2
#GRAPHICS = GRAPHICS_API_OPENGL_ES3 # <-- opt-in for WebGL2
endif
```
- **Current web link flags** (`build_web.sh` line 87): `-sFULL_ES2=1` (the ES2
client-array *emulation* flag, not the WebGL-version selector). No `-sMAX_WEBGL_VERSION`.
- **Render loop seam:** `Rl.while_window_open do ... Rl.draw(clear_color:) { ...game
drawing... ui.update; ui.render } end` (`game/main.rb`). The RmlUi HUD is drawn
*inside* `Rl.draw`, after game content. (See "HUD ordering" below.)
### The shader API already exposed by the bindings (from `docs/AI_REFERENCE.md`)
| Need | Ruby call | Notes |
|------|-----------|-------|
| Load fragment shader (default vertex) | `Rl.load_shader_from_memory(nil, fs)` | `nil` vs → raylib's internal default vertex shader (correct `#version` per backend) |
| Load from file | `Rl.load_shader(vs_file, fs_file)` | files preloaded via `--preload-file game@/game` on web |
| Create framebuffer | `Rl.load_render_texture(w, h)` → `Rl::RenderTexture` | |
| Render to texture | `Rl.texture_mode(target) { ... }` | block, `ensure`-safe (mrblib) |
| Apply shader | `Rl.shader_mode(shader) { ... }` | block, `ensure`-safe (mrblib) |
| Uniform location | `Rl.get_shader_location(shader, "name")` → int | |
| Set uniform | `Rl.set_shader_value(shader, loc, value, TYPE)` | `value` = Numeric or Array; packed by `SHADER_UNIFORM_*` |
| Set sampler | `Rl.set_shader_value_texture(shader, loc, texture)` | |
| Set matrix | `Rl.set_shader_value_matrix(shader, loc, mat)` | |
| Draw textured quad | `Rl.draw_texture_pro(texture:, source:, dest:, ...)` | negative `source.height` = y-flip |
| Blend | `Rl.blend_mode(Rl::BLEND_*) { ... }` | `BLEND_ADDITIVE`, `BLEND_ALPHA_PREMULTIPLY`, etc. |
| Constants | `SHADER_UNIFORM_FLOAT/VEC2/...`, `SHADER_LOC_*`, `BLEND_*` | all in `AI_REFERENCE.md` |
**Bottom line: the entire pipeline can be implemented in Ruby (mrblib sugar + game
code). No generator edit, no C, no `rm -rf vendor/mruby/build`.**
---
## Critical raylib behavior: you must supply the `#version` line
Verified in the vendor source (`vendor/raylib/src/rlgl.h`, **raylib 6.0**). PR #5631
renamed the rlgl shader-loading functions in 6.0 (`rlLoadShaderCode`→
`rlLoadShaderProgram`, `rlCompileShader`→`rlLoadShader`) — the high-level
`raylib.h` API (`LoadShader`/`LoadShaderFromMemory`/`UnloadShader`, which our
bindings use) is **unchanged**.
- `vsCode == NULL` → `rlLoadShaderProgram()` (6.0, ~line 4265) uses raylib's
**internal default vertex shader**, which is pre-`#version`'d for the active
backend (`#version 330` / `#version 300 es` / `#version 100`; 6.0 lines
5012 / 5021 / 5029).
- `fsCode != NULL` → `rlLoadShader(fsCode, GL_FRAGMENT_SHADER)` (6.0, ~line 4205)
does `glShaderSource(id,1,&code,NULL); glCompileShader(id)` — compiles **your
string as-is**. **raylib does NOT prepend a `#version` line** to user fragment
shaders.
**Implication:** every fragment shader source must begin with a `#version` matching
the running backend. The Ruby loader must select the right source per target:
```ruby
GLSL_VS = Rl.web? ? "#version 300 es\n" : "#version 330\n" # after a WebGL2 upgrade
# (current web would be "#version 100\n")
```
This is exactly why the official raylib `shaders_postprocessing.c` example keeps
parallel `glsl100/` and `glsl330/` shader folders and picks one at compile time.
---
## Update: raylib upgraded 5.5 → 6.0 (done)
The repo is now on **raylib 6.0** (vendor checkout + build fixes verified on both
desktop and web). This *strengthens* the WebGL2/ES3 recommendation above:
- **The high-level shader API is unchanged.** `LoadShader` /
`LoadShaderFromMemory` / `SetShaderValue`(+`V`,`Matrix`,`Texture`) /
`GetShaderLocation` / `UnloadShader` all survived the 6.0 "REDESIGNED shader
loading API" (#5631) — that refactor was `rlgl`-internal (`rl*` functions)
only. Bindings regenerated clean (671/746 fns bound, 75 unbound). So the
pipeline plan needs no change for 6.0.
- **6.0 ships the WebGL2/ES3 bug fixes we wanted.** The changelog lands:
`[rlgl] REVIEWED: rlActiveDrawBuffers, fix for OpenGL ES 3.0 (#4605)` (MRT on
ES3) and `[rlgl] REVIEWED: rlLoadTextureDepth(), address inconsistencies with
WebGL 2.0 for sized depth formats (#5500)` (depth textures on WebGL2). These
make the ES3/WebGL2 upgrade in the "WebGL1 vs WebGL2" section **more robust**
than it would have been on 5.5. The 5.5 `RLGL_RENDER_TEXTURES_HINT` define is
also gone (FBOs always-on now) — one less thing to set.
- **`GRAPHICS_API_OPENGL_ES3` + `GRAPHICS_API_OPENGL_ES2` superset still holds.**
6.0's `rlgl.h` still does `#if defined(ES3) #define ES2 #endif`, so defining
`GRAPHICS=GRAPHICS_API_OPENGL_ES3` still compiles all ES2 blocks + the ES3
extras (float textures, MRT/blit). The PLATFORM_WEB `GRAPHICS` Makefile line is
now `?=` (conditional) — the ES3 override is even cleaner than 5.5's `=`.
Upgrade scar tissue (what broke + how we fixed it) is in
`.agents/knowledge/web-target.md` ("raylib 6.0 upgrade"). Short version: three
build-code fixes (parser relocated `parser/`→`tools/rlparser/`; `libraylib.a`→
`libraylib.web.a`; tolerant loader for 6.0's malformed shipped `raylib_api.json`)
plus one vendored patch for a 6.0 web regression (`IsCursorHidden()` stopped
reflecting pointer-lock — broke mouse-look). None of these affect the shader
pipeline; they're build/input-layer only.
---
## The canonical pattern: ping-pong render-to-texture
From the official raylib `examples/shaders/shaders_postprocessing.c` (single-effect
variant) and the Meatcorps/nCine write-ups (multi-effect stack variant):
**Single pass (raylib official):**
```
BeginTextureMode(target); // render scene → RenderTexture
ClearBackground(...); BeginMode3D(cam); <draw scene>; EndMode3D();
EndTextureMode();
BeginDrawing();
BeginShaderMode(shaders[current]);
DrawTextureRec(target.texture, {0,0,w,-h}, {0,0}, WHITE); // NOTE the -h: y-flip
EndShaderMode();
<draw HUD/text>;
EndDrawing();
```
**Multi-pass stack (toggleable chain) — the architecture we want:**
1. Render the **scene** into `targetA` (`BeginTextureMode(targetA) ... EndTextureMode`).
2. For each **enabled** effect shader `E_i` (in order):
- `BeginTextureMode(targetB)`; `BeginShaderMode(E_i)`;
set E_i's uniforms (resolution, time, intensity, the *previous* texture as
`texture0`, the *original* scene texture if E_i needs it — e.g. bloom composite);
`DrawTextureRec(prev.texture, {0,0,w,-h}, {0,0}, WHITE)`;
`EndShaderMode`; `EndTextureMode`.
- Swap `targetA ↔ targetB`; `prev = targetB`.
3. Draw `prev.texture` to the **screen** (y-flipped) — the final composited image.
4. Draw the HUD (RmlUi) **on top**, un-post-processed.
Key facts confirmed across sources:
- **Y-flip is mandatory** on every `DrawTextureRec`/`draw_texture_pro` of a
`RenderTexture` (OpenGL bottom-left origin). Use negative `source.height`
(`{0, 0, w, -h}`). [Source: raylib official example + Meatcorps]
- **Two `RenderTexture`s are enough** for any-length chain (ping-pong). Allocate
once; do NOT create/destroy per frame. [Source: Meatcorps `PostProcessingRenderer`]
- **Some effects need the original scene** (not just the current ping-pong
result) — e.g. bloom *composite* blends blurred-bright over the original image.
Meatcorps models this with an `INeedsCurrentViewTexture` interface. In Ruby this
is just "pass the original `scene` texture as a second sampler". [Source: Meatcorps]
- **Resolution:** render targets should match the window/internal resolution.
For a pixel-perfect/retro look, render to a fixed small target (e.g. 640×360)
and upscale — Meatcorps recommends this. [Source: Meatcorps]
---
## WebGL1 vs WebGL2 — the decision
### Current state (WebGL1 / ES2 / GLSL `#version 100`)
What WebGL1 gives you: the basics. `texture2D`, `varying`/`attribute`, `gl_FragColor`,
no `in`/`out`, limited loop bounds, **no float render targets** (no `EXT_color_buffer_float`;
half-float is patchy), **no MRT** (no deferred shading / multi-output G-buffer), no
3D textures, no transform feedback. raylib's default-webgl target.
What that means for the pipeline: a toggleable FX chain **works fine** on WebGL1.
Grayscale, scanlines, blur, CRT, fisheye, posterize, sobel — all run on `#version 100`
(raylib ships `glsl100/` versions of all of them). The one notable casualty is
**true HDR bloom**: WebGL1 can't render to a float/half-float buffer, so bright-pass
accumulation clamps to [0,1] — bloom still *looks* okay but can't exceed white.
### Upgraded state (WebGL2 / ES3 / GLSL `#version 300 es`)
What WebGL2 adds that matters for shaders:
- **Float/half-float render targets** (`RGBA16F`/`RGBA32F` color-buffer-float is core) →
real HDR pipeline, bloom that can exceed 1.0. **This is the main win.**
- **Multiple Render Targets (MRT)** (`glDrawBuffers`, up to 4) → deferred rendering
G-buffer possible. Not needed for a post-pro *stack*, but nice if you ever want
deferred lights.
- **GLSL ES 3.00**: `in`/`out`, `texture()`/`textureGrad()`, integers, uint, uniform
blocks, `flat`/`smooth` interpolation, **loop bounds are not limited** (WebGL1
requires constant-foldable loop bounds). Syntax is a near-subset of desktop GLSL 330.
- **3D textures**, instancing, transform feedback (compute-via-SSBO still needs
WebGL2 *compute*, which is separate; raylib has a `rlgl_compute` example but it's
GL4.3-only, not web).
WebGL2 browser support: ~98% globally (caniuse "webgl2"). Dropping WebGL1 is low-risk
in 2026. The main exception is very old mobile Safari (<15) and some legacy enterprise
edge cases.
### The honest engineering call
- A toggleable post-pro stack is **not blocked** by WebGL1. You can ship it today.
- But since you're open to the upgrade and care about "capable shaders": **WebGL2 is
worth it** for HDR bloom alone, and it makes the desktop/web shader dialect
gap smaller (`300 es` vs `330` differ mainly in the `#version` line + `precision`
qualifier). One near-shared source per effect instead of a `glsl100`↔`glsl330` chasm.
### Exactly how to upgrade the web build (verified)
Two changes, both in `build_web.sh`:
**1. Recompile raylib for ES3** — override `GRAPHICS` on the raylib `make` line
(raylib's Makefile uses a simple `=` assignment for `PLATFORM_WEB`, so a make
command-line var overrides it):
```sh
emmake make -C "$ROOT/vendor/raylib/src" PLATFORM=PLATFORM_WEB \
GRAPHICS=GRAPHICS_API_OPENGL_ES3 \
RAYLIB_RELEASE_PATH="$ROOT/build/web"
```
**You MUST `make clean` first** — raylib shares `.o` across platforms
(repo rule `.agents/rules/raylib-platform-objs.md`; the existing `build_web.sh`
already does `make clean` before the first web build). Because the GL backend
changed, the cached `libraylib.a` is invalid: delete `build/web/libraylib.a`
and let it rebuild.
**2. Link for WebGL2** — replace `-sFULL_ES2=1` with the WebGL-version selectors.
raylib's own `examples/Makefile.Web` uses (for `BUILD_WEB_WEBGL2=TRUE`):
```sh
-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2
```
(`-sMAX_WEBGL_VERSION=2` alone = allow WebGL2 but fall back to WebGL1;
both = WebGL2-only, smaller code, no fallback.)
**DO NOT use `-sFULL_ES3=1`.** That flag enables Emscripten's *client-side array
emulation* for ES3 — an orthogonal feature to the WebGL version. Mixing
`-sFULL_ES3=1` with a raylib that isn't ES3-compiled leaves the GL context
uninitialized at draw time (confirmed by Wavedash's raylib guide). The
WebGL-friendly subset (no `FULL_ES*` flags) is what Emscripten recommends and
what raylib expects.
### Where this intersects repo rules
- `.agents/rules/raylib-platform-objs.md`: `make clean` between desktop/web already
happens; after switching ES2→ES3 you must also delete the stale `build/web/libraylib.a`.
- `.agents/knowledge/web-target.md`: the `-sFULL_ES2=1` line is documented there as a
current emcc flag — that knowledge doc will need updating post-upgrade.
- Desktop is unaffected: it stays `GRAPHICS_API_OPENGL_33` (`#version 330`).
---
## Recommended architecture (Ruby) — layered, two-stage pipeline
A small `Jamstack::FX` module in `mrblib/` (pure Ruby, no C) plus a `game/shaders/`
folder of `.fs` sources.
### The layering model (the key design decision)
The pipeline is **two shader stages over three render layers**, so you can choose
*per effect* whether it touches only the game world or the whole frame (game + UI):
```
┌─ GAME LAYER ──────────────────────────────────────────────┐
│ 3D world + in-world RmlUi (3D / CSS-3D panels) │ → RenderTexture G
└───────────────────────────┬──────────────────────────────┘
▼
◆ GAME SHADERS (gameplay FX — bloom/CRT/scanlines…) ◆ affects game +
ping-pong chain on G ◆ in-world UI, NOT the overlay
▼
┌─ OVERLAY LAYER ───────────────────────────────────────────┐
│ processed-game quad + overlay RmlUi HUD (screen-space) │ → RenderTexture C
└───────────────────────────┬──────────────────────────────┘
▼
◆ TOP SHADERS (complete FX — final color grade, vignette, ◆ affects EVERYTHING
ping-pong chain on C film grain, letterbox…) ◆ (game + all UI)
▼
screen
```
This is exactly the flow requested: `game + game-rmlui → game shader → top-rmlui → top shaders`.
- **Game shaders** = "gameplay" effects that must NOT touch the overlay HUD (e.g. a
bloom you only want on the world; a CRT/scanline effect that would wreck HUD text).
They run on the game layer *before* the overlay is composited.
- **Top shaders** = "complete" effects that affect both game and UI (e.g. a final
color grade, vignette, film grain, or letterbox you want over the whole frame
including HUD). They run *after* the overlay is composited.
- Assign each effect to a stage: `fx.game_shaders << …` vs `fx.top_shaders << …`.
> Supersedes the earlier simpler "scene → one chain → screen, HUD on top unfiltered"
> sketch. That's now just the degenerate case (no game shaders, no top shaders, HUD in
> the overlay layer). Degenerate cases are handled: no game shaders ⇒ game layer
> composites straight through; no top shaders ⇒ composite blits straight to screen.
### Render targets & FBO flow (per frame, all allocated once)
Four `RenderTexture`s: `G_a`/`G_b` (game-stage ping-pong) and `C_a`/`C_b`
(composite/top-stage ping-pong). `G_a` needs a depth attachment (3D world uses
depth); `load_render_texture` creates color+depth by default, so that's automatic.
```
1. GAME RENDER → texture_mode(G_a){ clear; begin_mode3d(cam){world} end;
game_ui.update; game_ui.render } # in-world UI INTO G_a
2. GAME SHADERS → ping-pong enabled game_shaders over G_a↔G_b → G_final
3. COMPOSITE → texture_mode(C_a){ clear; draw_texture_pro(G_final, y-flip);
top_ui.update; top_ui.render } # overlay HUD INTO C_a
4. TOP SHADERS → ping-pong enabled top_shaders over C_a↔C_b → C_final
5. SCREEN BLIT → draw{ draw_texture_pro(C_final, y-flip) }
```
Step 3 is the load-bearing one: the overlay HUD is rendered **into the same FBO as
the processed-game quad** (one `texture_mode` block — draw the game quad first, then
the HUD on top, in screen-space 2D) so the top shaders can filter both together.
### Two RmlUi contexts (in-world UI vs overlay)
RmlUi supports multiple named contexts, so model the two UI layers directly:
```ruby
game_ui = Rml::Context.new("game") # in-world UI → rendered into the GAME layer (G)
top_ui = Rml::Context.new("overlay") # screen-space HUD → rendered into the OVERLAY layer (C)
```
- **In-world UI (`game_ui`)** is part of the GAME layer, so it catches game shaders.
Two sub-cases the pipeline must allow (the `game_layer` block is just "draw into
`G_a`", so the game code picks):
- *CSS-3D panels* (the existing `physics_playground` style: `transform: perspective()
rotate3d()` on RmlUi elements) — render `game_ui` straight into `G_a` after the 3D pass.
- *True world-space UI* — render `game_ui` to its own offscreen texture, then draw that
texture on a 3D quad inside `begin_mode3d(cam)`. The pipeline doesn't special-case
this; the game code does it inside the `game_layer` block.
- **Overlay UI (`top_ui`)** is screen-space, composited in step 3, catches only top shaders.
- **Input routing** (which context gets mouse/keys) is a game concern, not the pipeline's
— typically route to `top_ui` first (topmost), then `game_ui`. The pipeline only owns
*where each context renders*, not input.
### API sketch (block idiom, matches the codebase's `Rl.draw { }` / `Rl.texture_mode { }`)
```ruby
module Jamstack
module FX
class Pass # unchanged: name + frag src + enabled/intensity/uniforms
attr_accessor :enabled, :intensity
def initialize(name, frag_src); ...; end # load_shader_from_memory(nil, HDR+frag_src)
def apply(src_tex, dst_tex, t); end # texture_mode(dst){ shader_mode(self){ set uniforms;
# draw_texture_pro(src_tex, y-flip) } }
end
class Pipeline
attr_reader :game_shaders, :top_shaders
def initialize(w, h)
@w,@h = w,h
@g = [Rl.load_render_texture(w,h), Rl.load_render_texture(w,h)] # game ping-pong
@c = [Rl.load_render_texture(w,h), Rl.load_render_texture(w,h)] # composite ping-pong
@game_shaders, @top_shaders = [], []
end
def frame(t)
yield Frame.new(self, t) # user fills game_layer{ } + overlay_layer{ }
blit_to_screen # top stage result → screen, y-flipped
end
end
class Frame # a per-frame builder the block receives
def game_layer # → render 3D world + in-world UI here
Rl.texture_mode(@p.g[0]) { yield } # user draws game+game_rmlui into G_a
@g_final = apply_chain(@p.game_shaders, @p.g) # ping-pong → G_final
# composite G_final into C_a, ready for the overlay:
Rl.begin_texture_mode(@p.c[0])
Rl.clear_background(Rl::BLACK)
Rl.draw_texture_pro(texture: @g_final.texture,
source: Rl::Rectangle.new(0,0,@p.w,[email protected]), dest: FULLSCREEN, tint: Rl::WHITE)
end
def overlay_layer # → render overlay HUD here (G_final already composited)
yield # top_ui.update; top_ui.render (still inside texture_mode(C_a))
Rl.end_texture_mode
@c_final = apply_chain(@p.top_shaders, @p.c) # ping-pong → C_final
end
end
# shared: run enabled passes ping-pong over a [a,b] pair, return the last-written target
def self.apply_chain(passes, pair)
prev = pair[0]
cur = pair[1]
done = passes.select(&:enabled)
return prev if done.empty? # no enabled passes: pass-through (no copy)
done.each do |p|
p.apply(prev.texture, cur, @t)
prev, cur = cur, prev # swap
end
prev # last target written
end
end
end
```
Usage in a game:
```ruby
fx = Jamstack::FX::Pipeline.new(720, 720)
fx.game_shaders << Jamstack::FX::Pass.new("bloom", BLOOM_FRAG) # gameplay: game+in-world UI only
fx.game_shaders << Jamstack::FX::Pass.new("crt", CRT_FRAG)
fx.top_shaders << Jamstack::FX::Pass.new("grade", GRADE_FRAG) # complete: over everything
fx.top_shaders << Jamstack::FX::Pass.new("vignette",VIGNETTE_FRAG)
Rl.while_window_open do
# ... update (route input to top_ui then game_ui) ...
fx.frame(Rl.time) do |f|
f.game_layer do
Rl.clear_background(Rl::BLACK)
Rl.begin_mode3d(cam) { <draw world> }
Rl.end_mode3d
game_ui.update; game_ui.render # in-world UI → into the game layer (catches game FX)
end
f.overlay_layer do
top_ui.update; top_ui.render # overlay HUD → catches only top FX
end
end
end
```
(Toggle per effect at runtime: `fx.game_shaders[0].enabled = !…` or `fx.top_shaders[1].enabled`
from the in-game console (`\`) / eval bridge; animate `intensity` 0↔1 for a fade.)
### Possible extension (not in the requested diagram)
A third, *world-only* stage (shaders that affect the 3D world but NOT the in-world UI —
e.g. blur the world but keep UI text crisp) would need an extra target: render world →
world-shaders → then composite in-world UI on top → game-shaders. Adds one render-texture
pair + one chain. Deliberately NOT in the design above (the requested flow groups
`game+game-rmlui` before `game shader`); add it only if a concrete effect needs it.
### Shader source strategy (one of)
- **A. Per-target version folders** (`game/shaders/glsl330/`, `glsl300es/`) like
raylib; pick at load time via `Rl.web?`. Most explicit, most duplicated.
- **B. One source + a tiny version shim**: store the *body* (no `#version`/`precision`)
and prepend the right header in Ruby:
```ruby
HDR = Rl.web? ? "#version 300 es\nprecision mediump float;\n" : "#version 330\n"
shader = Rl.load_shader_from_memory(nil, HDR + BODY)
```
After a WebGL2 upgrade, `300 es` and `330` share enough syntax (both `in`/`out`,
`texture()`, integers) that **one BODY** usually serves both. This is the
lowest-maintenance option and pairs naturally with inline shader strings (no
file I/O, trivially hot-reloadable).
- **C. Inline strings** (no files at all) — simplest for a dynamic toggleable system,
works on web without `--preload-file` changes, easy to reload via the bridge.
**Recommend B + C combined**: keep effect bodies as Ruby heredoc constants, prepend
the version header per target. Keeps the pipeline in one language, no new asset
pipeline.
### Layer & HUD ordering (important, easy to get wrong)
The layering model above replaces the old "HUD on top, unfiltered" rule. With two
stages you now choose, per effect, where the HUD sits relative to it:
- **In-world UI** renders into the **game layer** (`G_a`, inside `game_layer`), so it
is composited *before* game shaders and catches them. (The existing `physics_playground`
CSS-3D HUD panels become this layer.)
- **Overlay HUD** renders into the **overlay layer** (`C_a`, inside `overlay_layer`),
composited *after* game shaders but *before* top shaders — so it's shielded from
game shaders (text stays crisp through bloom/CRT) but still catches top shaders
(color grade/vignette apply over it).
- The single FBO invariant for compositing: draw the processed-game quad into `C_a`
**first**, then the overlay HUD on top, in the same `texture_mode(C_a)` block —
top shaders can then filter both together. Do NOT render the HUD to the screen
directly (it would bypass the top-shader stage).
- RmlUi renders via rlgl into whatever FBO is bound, so `texture_mode { rmlui.render }`
works in both layers — but the RmlUi **context dimensions must equal the render-texture
size** (viewport/scissor are set from them), and a 3D-world target needs the
depth attachment `load_render_texture` provides by default.
### Performance notes (web)
- Allocate the four `RenderTexture`s (`G_a/G_b`, `C_a/C_b`) **once**; never in the
loop (FBO creation is expensive and leaks). [Emscripten WebGL best practices:
"prefer multiple immutable/static FBOs"]
- Minimize `glBindFramebuffer` switches — each ping-pong pass is one bind; the
composite is one extra. [Emscripten]
- `SetTextureFilter(target.texture, TEXTURE_FILTER_BILINEAR)` if you upscale a
small internal-res target to a bigger window.
- Render-target format: `RGBA8` for LDR; `RGBA16F` (WebGL2 only) for HDR bloom.
- Each pass is one fullscreen textured quad → one draw call. A 4–6 effect chain is
cheap; the cost is the extra texture samples (blur is the heaviest).
- Cost of the two-stage split vs one chain: one extra fullscreen draw (the composite
quad) + one extra FBO pair. Negligible next to the shader passes themselves.
- `BeginShaderMode`/`EndShaderMode` set shader uniform state each pass — cache
`get_shader_location` results at load time (the `Pass` ctor), don't query per frame.
---
## Verification plan (how to prove it works) — web is the bar
> The critical requirements (top of doc) gate this: the pipeline + runtime
> toggling MUST run on the web build. Verify web explicitly, not just desktop.
1. **Desktop first** (fastest iteration, `#version 330`):
`./rebuild.sh && ./zig-out/bin/game game/fx_demo.rb`. Proves the layered
two-stage flow + both shader stages render. (Desktop is a sanity proxy; it
does NOT satisfy the web requirement.)
2. **Offscreen PNG** render of a post-pro'd frame (per `.agents/knowledge/testing.md`)
to diff before/after an effect — useful for the GAME vs TOP stage split.
3. **Web build (the critical path):** `EMSDK_ENV=~/emsdk/emsdk_env.sh ./build_web.sh`,
serve, open in a browser. Confirm:
- `Rl.platform == :web`, and `#version 300 es` fragment shaders compile (after the
ES3 upgrade) — check the browser console for GLSL errors; raylib logs
"SHADER: Failed to load custom shader code, using default shader" on failure
(`rlgl.h` ~line 4310 in 6.0).
- The GAME stage affects the game world + in-world UI but **not** the overlay HUD;
the TOP stage affects everything. (Visual diff: toggle a game shader — HUD must
stay crisp; toggle a top shader — HUD must change.)
4. **Runtime toggling on web (critical):** with the game running in the browser, flip
effects live via the eval bridge and confirm each change lands next frame:
```sh
sh .live/web/bin/eval 'fx.game_shaders[0].enabled = false' # game FX off — HUD unaffected
sh .live/web/bin/eval 'fx.top_shaders[1].enabled = true' # top FX on — whole frame incl. HUD
sh .live/web/bin/eval 'fx.game_shaders.map { |p| p.enabled }' # read back state
```
The eval bridge is browser-verified on web today (`sh .live/web/bin/eval 'Rl.get_fps'`
returns live fps). Also toggle via the in-game console (`\`) — same main-thread eval
path — and confirm it works in the browser (verify; not yet browser-confirmed).
---
## Source list
| # | Source | Type | Used for |
|---|--------|------|----------|
| 1 | [raylib `shaders_postprocessing.c` (official example)](https://github.com/raysan5/raylib/blob/master/examples/shaders/shaders_postprocessing.c) | official | canonical single-pass pattern, y-flip, `GLSL_VERSION` per-platform |
| 2 | [raylib `examples/Makefile.Web`](https://github.com/raysan5/raylib/blob/master/examples/Makefile.Web) | official | exact WebGL2 link flags `-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2`, "Requires raylib compiled with GRAPHICS_API_OPENGL_ES3" |
| 3 | [Emscripten: OpenGL support](https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html) | official | `-sMAX_WEBGL_VERSION=2` selects WebGL2; `-sFULL_ES3` is client-array *emulation*, orthogonal; WebGL-friendly subset recommended |
| 4 | [Wavedash: raylib](https://docs.wavedash.com/engines/raylib) | third-party guide | "Don't add `-s FULL_ES3=1` — raylib's CMake Web build defaults to OpenGL ES 2, and mixing ES3 client-array emulation with an ES2 library leaves the GL context uninitialized at draw time" |
| 5 | [Meatcorps: Post-processing stack in Raylib-cs](https://docs.meatcorps.nl/raylibcs/postprocessing/) | third-party guide | ping-pong multi-pass stack, `BaseShader`/`PostProcessingRenderer`, y-flip, `INeedsCurrentViewTexture` (original-scene access), fixed-internal-res upscaling |
| 6 | [nCine 14-year presentation](https://encelo.github.io/nCine_14Years_Presentation/) | third-party | "Can be chained together for multi-pass techniques... ping-pong technique" |
| 7 | [shadergif: WebGL2 vs WebGL1 for Shaders (GLSL 3.00)](https://shadergif.com/guides/webgl2-glsl-300-es/) | third-party | GLSL ES 3.00 syntax changes vs 1.00 |
| 8 | [Unity Graphics Emulation docs](https://docs.unity3d.com/550/Documentation/Manual/GraphicsEmulation.html) | official | WebGL1 caps: max 4 render targets, max 16 textures/shader, max tex 4096 |
| 9 | [Emscripten: Optimizing WebGL](https://emscripten.org/docs/optimizing/OptimizingWebGL.html) | official | "use multiple FBOs... switching render targets only requires a single glBindFramebuffer()... avoid mutating FBO state" |
| 10 | vendor `vendor/raylib/src/rlgl.h` (6.0: `rlLoadShaderProgram` ~4265 + `rlLoadShader` ~4205; `rlLoadShaderDefault` ~4995) | local source | raylib does NOT prepend `#version` to user shaders; default vertex shader per backend |
| 11 | vendor `vendor/raylib/src/Makefile` (lines 234–265) | local source | desktop=GL33, web=ES2 (ES3 commented), `GRAPHICS` var overridable |
| 12 | vendor `vendor/raylib/src/rcore.c` (`LoadShader` 1295, `LoadShaderFromMemory` 1314) | local source | NULL vertex → default shader; user fs compiled as-is |
## Verbatim quotes
- "To target WebGL 2, pass the linker flag `-sMAX_WEBGL_VERSION=2`." — [Emscripten OpenGL support](https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html)
- "Don't add `-s FULL_ES3=1` — raylib's CMake Web build defaults to OpenGL ES 2, and mixing ES3 client-array emulation with an ES2 library leaves the GL context uninitialized at draw time." — [Wavedash raylib](https://docs.wavedash.com/engines/raylib)
- "You render your game content into a RenderTexture. You apply shaders to that texture. You ping-pong between render textures so each shader pass can write to a new target. You render the final result to the screen buffer." — [Meatcorps](https://docs.meatcorps.nl/raylibcs/postprocessing/)
- "NOTE: Render texture must be y-flipped due to default OpenGL coordinates (left-bottom) DrawTextureRec(target.texture, (Rectangle){ 0, 0, w, (float)-target.texture.height } ...)" — [raylib shaders_postprocessing.c](https://github.com/raysan5/raylib/blob/master/examples/shaders/shaders_postprocessing.c)
- "# NOTE: Flags required for WebGL 2.0 (OpenGL ES 3.0) # WARNING: Requires raylib compiled with GRAPHICS_API_OPENGL_ES3 ... LDFLAGS += -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2" — [raylib Makefile.Web](https://github.com/raysan5/raylib/blob/master/examples/Makefile.Web)
- "When rendering to offscreen render targets, use multiple FBOs so that switching render targets only requires a single glBindFramebuffer() call... prefer to set up multiple immutable/static FBOs, which do not change state." — [Emscripten Optimizing WebGL](https://emscripten.org/docs/optimizing/OptimizingWebGL.html)
## Source quality flags
- Meatcorps (#5): a personal blog/tutorial, but technically detailed, code-backed, and
corroborated by the official raylib example (#1) and nCine (#6). Treat the
*pattern* as reliable; the specific C# class names are illustrative only.
- shadergif (#7), Unity (#8): used only for the WebGL1-vs-2 capability diff; both
consistent with the Emscripten/Khronos specs.
## Confidence: high
The pipeline pattern (ping-pong render-to-texture) is the documented raylib
canonical approach and is corroborated by 3 independent sources. The WebGL2 upgrade
mechanism is confirmed by raylib's own Makefile + Emscripten's official docs + a
third-party warning. The `#version`-not-injected behavior is verified in the local
vendor source. The only soft spot is the exact Ruby swap-aliasing ergonomics in the
sketch, which is an implementation detail to nail down during the build-and-verify
step, not a research gap.
## Gaps / open questions (to resolve during implementation, not blocking)
- Whether to keep a WebGL1 fallback (`-sMAX_WEBGL_VERSION=2` only, no `MIN`) vs
go WebGL2-only (`-sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2`). Recommend
WebGL2-only for simplicity unless a target device list says otherwise.
- Whether the existing `-sFULL_ES2=1` can be fully dropped on upgrade (yes — it's
the client-array emulation; the WebGL-friendly subset doesn't need it; but verify
nothing in rmlui/raylib relies on client-side arrays, which would be unusual).
NOTE (post-6.0): the ES3 path sets `ExtSupported.vao=true` unconditionally, so
the VAO branch is always taken (never the client-array `else` that needs
`FULL_ES*`). Dropping `-sFULL_ES2=1` is safe on ES3.
- Exact shader-source sharing ratio between `300 es` and `330`: most post-pro
fragment shaders (blur, grayscale, CRT) are identical modulo the header; confirm
per-effect during implementation.
- The earlier "ES3 not widely tested" caveat (raylib #4330) is now substantially
mitigated on **6.0**: the MRT (`rlActiveDrawBuffers` #4605) and depth-texture
(`rlLoadTextureDepth` #5500) WebGL2 fixes landed. Still: verify on the browser
early (desktop GL behavior does not always carry over to the ES3 path).
per-effect during implementation.
|