Commitcb82a697Recorded19 Sep 2026Repositorysigil-graphics
load-shader takes a blend mode; test-shader-limits gains the Gray-Scott rule
Message
(load-shader vert frag 'none) bakes overwrite blending into the shader's pipeline; the default stays 'normal. A simulation step whose alpha channel carries data needs it: alpha-blending against the cleared target scales every channel by the stored alpha.
test-shader-limits --rule reaction runs Gray-Scott on the ping-pong path with u and v each packed into two 8-bit channels. With one 8-bit channel per quantity the field froze (per-step changes under 1/255 round to nothing; 400 and 2400 generations gave the same frame); at 16 bits the spots divide as they should, identically on llvmpipe and SwiftShader. The README sections cover the new arguments.
Changed
README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/c/graphics.c | 24 ++++++++++++++++++++----
test/test-shader-limits/README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-shader-limits/index.template.html | 3 +++
test/test-shader-limits/src/test-shader-limits/scene.sgl | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
test/test-shader-limits/src/test-shader-limits/shell-native.sgl | 13 ++++++++-----
test/test-shader-limits/src/test-shader-limits/shell-web.sgl | 9 +++++++--
test/test-shader-limits/verify.mjs | 36 +++++++++++++++++++++++++++++++++---
8 files changed, 328 insertions(+), 23 deletions(-)Diff
README.mdmodified
@@ -28,6 +28,82 @@ sigil deps install
28
sigil build 29
``` 30
+31
## Render targets and custom shaders+32
+33
Offscreen rendering and custom fragment shaders (since v0.10.0) build a+34
post-processing or simulation pipeline out of three pieces:+35
+36
```scheme+37
(define rt (make-render-target 256 256)) ; 'linear sampling (default)+38
(define grid (make-render-target 64 64 'nearest)) ; texel-exact when drawn+39
+40
(with-render-target rt+41
(draw-filled-rect 0.0 0.0 256.0 256.0)) ; draws land in rt+42
+43
(define fx (load-shader VERT-SOURCE FRAG-SOURCE)) ; GLSL strings; optional 3rd arg+44
; 'normal (default) | 'additive | 'none+45
(with-shader fx+46
(draw-render-target rt 0.0 0.0 256.0 256.0)) ; rt is channel 0+47
```+48
+49
The fragment shader samples channel 0 as `iTexChannel0_iSmpChannel0` (sokol_gp's+50
name) and receives `texUV` and `iColor` from the vertex stage; the vertex shader is+51
the sgp passthrough with `coord` at location 0 and `color` at location 1 (see+52
`test/test-shader`). Natively the sources are `#version 410`; on the `web` config+53
they are `#version 300 es` with precision qualifiers, so a package that builds for+54
both prefixes the version line under `cond-expand`.+55
+56
### Uniforms and uniform arrays+57
+58
`load-shader` scans the fragment source for `uniform` declarations of `float`,+59
`vec2`, `vec3`, `vec4` and `mat4`, scalar or array (`uniform vec4 palette[8];`, a+60
literal count; a `lowp`/`mediump`/`highp` qualifier is fine). `sampler2D` is not a+61
uniform-block entry. Up to 16 declarations (an array is one), 1 KiB in total,+62
packed in declaration order.+63
+64
```scheme+65
(set-shader-uniform fx 'u_radius 2.5) ; float+66
(set-shader-uniform fx 'u_tint '(1.0 0.8 0.6 1.0)) ; vec4, list or vector+67
(set-shader-uniform fx 'palette '((1 0 0 1) (0 1 0 1) ...)) ; vec4 palette[8]: nested,+68
(set-shader-uniform fx 'levels #(0.0 0.25 0.5 1.0)) ; or flat, per element+69
```+70
+71
The float count must match the declaration exactly; a mismatch is an error, not a+72
partial write. Two uniforms are filled in for you on every `with-shader` entry:+73
`u_time` (float, seconds since `gfx-setup`) and `u_resolution` (vec2: the virtual+74
viewport, or the render target's size when the shader is bound inside+75
`with-render-target`).+76
+77
### Textures from pixels+78
+79
```scheme+80
(define tex (make-texture-from-pixels 4 4 bytes 'nearest)) ; RGBA8 bytevector,+81
(update-texture tex other-bytes) ; top row first+82
```+83
+84
`bytes` is exactly `width * height * 4` bytes (a size mismatch is an error). The+85
texture draws with `draw-texture` / `draw-texture-region` like one from+86
`load-texture`. sokol_gfx allows one upload per texture per frame (a frame ends at+87
`end-frame`), and creating the texture is that frame's upload, so make the texture+88
one frame before the first `update-texture`; a second upload in a frame is refused+89
with an error that says so.+90
+91
### GPU simulations by ping-pong+92
+93
`with-shader` composes inside `with-render-target`: a step shader bound in a pass on+94
target B can read target A through `draw-render-target`, and swapping the two each+95
frame runs a cellular automaton with no cell state on the CPU. Load the step shader+96
with `'none` blending so it overwrites the target (alpha blending would scale data+97
channels), and read neighbours with+98
`texelFetch(iTexChannel0_iSmpChannel0, ivec2(gl_FragCoord.xy) + offset, 0)` (the+99
sampler's filter does not apply) and wrap on `u_resolution`. `test/test-shader-limits`+100
is the worked example, verified pixel-for-pixel on native and in the browser; its+101
README has the commands.+102
+103
Render targets on the `web` config work as of v0.11.3 (earlier releases refused+104
every offscreen pass there, see the tag notes).+105
+106
107
## License 108
109
BSD-3-Clausesrc/c/graphics.cmodified
@@ -1906,12 +1906,18 @@ static void parse_fragment_uniforms(GfxShader *sh, const char *frag_src)
1906
memset(sh->buffer, 0, sizeof(sh->buffer)); 1907
} 1908
−1909
/* (load-shader vertex-source fragment-source) -> <shader> or #f */+1909
/* (load-shader vertex-source fragment-source [blend]) -> <shader> or #f+1910
*+1911
* blend is the pipeline's baked blend mode: 'normal (alpha blend, the+1912
* default and the v0.10 behaviour), 'additive, or 'none (overwrite: what+1913
* a simulation step writing state into a render target needs, since+1914
* alpha-blending data channels against the cleared target corrupts+1915
* them). set-blend-mode does not reach a custom pipeline; this does. */ 1916
static Value native_load_shader(SigilVM *vm, int argc, Value *args) 1917
{ 1918
if (argc < 2) { 1919
sigil__vm_error(vm, SIGIL_ERR_ARITY,−1914
"load-shader: requires vertex-source, fragment-source");+1920
"load-shader: requires vertex-source, fragment-source [blend]"); 1921
return SIGIL_UNDEFINED; 1922
} 1923
@@ -1923,6 +1929,16 @@ static Value native_load_shader(SigilVM *vm, int argc, Value *args)
1929
return SIGIL_FALSE; 1930
} 1931
+1932
int blend = SGP_BLENDMODE_BLEND;+1933
if (argc > 2) {+1934
blend = blend_mode_from_value(args[2]);+1935
if (blend < 0) {+1936
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,+1937
"load-shader: blend must be 'normal, 'additive or 'none");+1938
return SIGIL_UNDEFINED;+1939
}+1940
}+1941
1942
GfxShader *sh = malloc(sizeof(GfxShader)); 1943
if (!sh) { 1944
sigil__vm_set_error(vm, SIGIL_ERR_MEMORY,@@ -1991,7 +2007,7 @@ static Value native_load_shader(SigilVM *vm, int argc, Value *args)
2007
* swap chain. */ 2008
sgp_pipeline_desc pip_desc = {0}; 2009
pip_desc.shader = sh->shader;−1994
pip_desc.blend_mode = SGP_BLENDMODE_BLEND;+2010
pip_desc.blend_mode = (sgp_blend_mode)blend; 2011
pip_desc.has_vs_color = true; /* sgp's vertex layout always has color */ 2012
sh->pipeline = sgp_make_pipeline(&pip_desc); 2013
if (sh->pipeline.id == SG_INVALID_ID) {@@ -2345,7 +2361,7 @@ void sigil__init_sigil_graphics_module(SigilVM *vm)
2361
* %bind-shader / %unbind-shader are raw primitives wrapped by the 2362
* with-shader macro for stack discipline + auto-uniform refresh. */ 2363
sigil_module_register_native(vm, "load-shader", native_load_shader,−2348
SIGIL_ARITY_EXACT(2), "Compile + link a shader from vertex+fragment GLSL");+2364
SIGIL_ARITY_RANGE(2, 3), "Compile + link a shader from vertex+fragment GLSL"); 2365
sigil_module_register_native(vm, "shader?", native_shader_p, 2366
SIGIL_ARITY_EXACT(1), "Check if object is a shader"); 2367
sigil_module_register_native(vm, "shader-free!", native_shader_free,test/test-shader-limits/README.mdadded
@@ -0,0 +1,71 @@
+1
# test-shader-limits+2
+3
The measured proof for the v0.11.3 shader additions, on native and on the+4
`web` config: a Game of Life stepped entirely on the GPU (a step shader bound+5
with `with-shader` inside `with-render-target`, ping-pong between two render+6
targets), colours drawn from `vec4 palette[8]` and `float levels[4]` uniform+7
arrays, and a 4x4 texture made from pixels and refilled with `update-texture`.+8
`verify.mjs` reads the presented frame on each target and compares every cell+9
against a CPU Life run of the same seed, every strip against the array entries,+10
and every texel against the post-update pattern.+11
+12
## Native arm+13
+14
Builds against the sigil-graphics checkout two directories up (`dev-redirects.sgl`).+15
The app needs an X display; use a worker-owned Xvfb, never a real session.+16
+17
```sh+18
guix shell xorg-server -- Xvfb :91 -screen 0 1400x900x24 +extension GLX -nolisten tcp &+19
guix shell -m ../../manifest.scm -- sh -c 'CPATH=$GUIX_ENVIRONMENT/include LIBRARY_PATH=$GUIX_ENVIRONMENT/lib sigil deps install --redirects dev-redirects.sgl && sigil build --redirects dev-redirects.sgl'+20
DISPLAY=:91 guix shell -m ../../manifest.scm imagemagick xdotool -- sh -c 'LD_LIBRARY_PATH=$GUIX_ENVIRONMENT/lib node verify.mjs --native'+21
```+22
+23
The verifier runs `build/dev/bin/test-shader-limits`, waits for its+24
`shader-limits: ready` line, captures the window with `import -window`, and+25
prints one `PASS`/`FAIL` line per sub-arm, then `GREEN`/`RED`; exit 1 on any+26
FAIL, 2 when a wait ran out or the setup was wrong (`TIMED-OUT`, `SETUP-FAILED`).+27
The capture lands at `/tmp/test-shader-limits-native.png`.+28
+29
## Web arm+30
+31
Needs `binaryen` (wasm-opt) for the build and `google-chrome` plus Node 22 for+32
the verifier (headless Chrome with SwiftShader, driven over the DevTools+33
Protocol, no puppeteer).+34
+35
```sh+36
guix shell binaryen -- sigil build --redirects dev-redirects.sgl --config web+37
node verify.mjs --web+38
```+39
+40
Adds two sub-arms: `imports` (the wasm imports only `gl`, `sigil_wasm_gles3` and+41
wasi) and `console` (no error entries). The page screenshot lands at+42
`/tmp/test-shader-limits-web.png`.+43
+44
## Options+45
+46
- `--grid N`, `--steps N`: the field size (64) and the generations to run (8).+47
- `--expect-steps N`: what the verifier models; different from `--steps` it must+48
go RED (the assertion-layer control).+49
- `--rule reaction --steps 4000 --substeps 8 [--grid 128]`: the Gray-Scott bonus:+50
u and v packed into 16 bits each across the RGBA8 target (8 bits per quantity+51
freezes the field), the step shader loaded with `'none` blending. The field+52
sub-arm then checks that a pattern grew from the three seed squares rather than+53
comparing cells (there is no closed form), and the screenshot shows it.+54
- `--bench [--grid 512]`: run the app in bench mode and print its+55
`frame-ms MEAN MAX tick-ms MEAN MAX` lines (wall time between frames, and the+56
tick's own step+present CPU time), no verdict.+57
- `--verbose` (web): dump the page console, including sokol's log lines.+58
+59
Running the app by hand: `test-shader-limits [--grid N] [--steps N] [--rule life|reaction]+60
[--substeps N] [--bench]+61
[--hold SECONDS]` natively; `index.html?grid=N&steps=N&rule=NAME&substeps=N&bench` on the web.+62
+63
## What it proves, and what it does not+64
+65
Every cell of the field is compared, so a wrong neighbour offset, a wrong wrap+66
(the glider crosses both torus edges), a wrong `u_resolution` inside the pass,+67
a filtered read, or a pass that silently draws nothing all read as a FAIL; the+68
sabotage legs that showed each of those going red are in the task note+69
`tasks/sigil-graphics-shader-limits`. Frame timing under Xvfb (llvmpipe) and+70
SwiftShader is software rendering on both arms: it bounds the CPU-side cost of a+71
step, not what a phone's GPU will do.test/test-shader-limits/index.template.htmlmodified
@@ -21,6 +21,7 @@
21
// grid=N cells per side (64) 22
// steps=N generations before the ready line (8) 23
// bench never settle; print frame-ms every 120 frames+24
// rule=life|reaction, substeps=N the rule and generations per frame 25
(function () { 26
"use strict"; 27
function withApp(fn) {@@ -32,6 +33,8 @@
33
if (params.has("grid")) app.dispatch("grid", params.get("grid")); 34
if (params.has("steps")) app.dispatch("steps", params.get("steps")); 35
if (params.has("bench")) app.dispatch("bench", "on");+36
if (params.has("rule")) app.dispatch("rule", params.get("rule"));+37
if (params.has("substeps")) app.dispatch("substeps", params.get("substeps")); 38
}); 39
})(); 40
</script>test/test-shader-limits/src/test-shader-limits/scene.sglmodified
@@ -117,6 +117,74 @@
117
118
;; The palette exhibit: colour from a vec4 array or a float array, 119
;; the entry picked by u_index (a float, as every uniform here is).+120
;; Gray-Scott reaction-diffusion (the bonus rule, behind --rule+121
;; reaction): u and v each packed into two 8-bit channels (u in R,G;+122
;; v in B,A: hi byte, lo byte), the 3x3 Laplacian, wrapped like Life.+123
;; Measured 2026-09-19: with u and v stored in one 8-bit channel each+124
;; the field freezes (a per-step change under 1/255 rounds to nothing;+125
;; 400 and 2400 generations gave the same frame), so 16 bits per+126
;; quantity is the floor in the RGBA8 targets both builds have. The+127
;; step shader's pipeline is loaded with 'none blending: alpha carries+128
;; data, and the default alpha blend against the cleared target would+129
;; scale every channel by it.+130
(define PACK-GLSL+131
(string-append+132
"vec2 pack(float x) {\n"+133
" float s = clamp(x, 0.0, 1.0) * 65535.0;\n"+134
" float hi = floor(s / 256.0);\n"+135
" return vec2(hi, s - hi * 256.0) / 255.0;\n"+136
"}\n"+137
"float unpack(vec2 p) { return (p.x * 65280.0 + p.y * 255.0) / 65535.0; }\n"+138
"vec2 state(vec4 c) { return vec2(unpack(c.rg), unpack(c.ba)); }\n"))+139
+140
(define REACTION-SOURCE+141
(string-append+142
GLSL-PREFIX+143
"uniform sampler2D iTexChannel0_iSmpChannel0;\n"+144
"uniform vec2 u_resolution;\n"+145
"uniform float u_feed;\n"+146
"uniform float u_kill;\n"+147
"uniform vec2 u_diffuse;\n"+148
"uniform float u_dt;\n"+149
"in vec2 texUV;\n"+150
"in vec4 iColor;\n"+151
"out vec4 fragColor;\n"+152
PACK-GLSL+153
"vec2 cell(ivec2 q, ivec2 size) {\n"+154
" q = (q + size) % size;\n"+155
" return state(texelFetch(iTexChannel0_iSmpChannel0, q, 0));\n"+156
"}\n"+157
"void main() {\n"+158
" ivec2 size = ivec2(u_resolution);\n"+159
" ivec2 p = ivec2(gl_FragCoord.xy);\n"+160
" vec2 c = cell(p, size);\n"+161
" vec2 lap = 0.2 * (cell(p + ivec2(1, 0), size) + cell(p + ivec2(-1, 0), size)\n"+162
" + cell(p + ivec2(0, 1), size) + cell(p + ivec2(0, -1), size))\n"+163
" + 0.05 * (cell(p + ivec2(1, 1), size) + cell(p + ivec2(-1, 1), size)\n"+164
" + cell(p + ivec2(1, -1), size) + cell(p + ivec2(-1, -1), size))\n"+165
" - c;\n"+166
" float uvv = c.r * c.g * c.g;\n"+167
" float du = u_diffuse.x * lap.r - uvv + u_feed * (1.0 - c.r);\n"+168
" float dv = u_diffuse.y * lap.g + uvv - (u_feed + u_kill) * c.g;\n"+169
" vec2 n = clamp(c + u_dt * vec2(du, dv), 0.0, 1.0);\n"+170
" fragColor = vec4(pack(n.x), pack(n.y));\n"+171
"}\n"))+172
+173
;; Presents the packed reaction state: u as red, v (scaled up, it peaks+174
;; near 0.35 in this regime) as green and blue.+175
(define VIEW-SOURCE+176
(string-append+177
GLSL-PREFIX+178
"uniform sampler2D iTexChannel0_iSmpChannel0;\n"+179
"in vec2 texUV;\n"+180
"in vec4 iColor;\n"+181
"out vec4 fragColor;\n"+182
PACK-GLSL+183
"void main() {\n"+184
" vec2 s = state(texture(iTexChannel0_iSmpChannel0, texUV));\n"+185
" fragColor = vec4(s.x, min(1.0, s.y * 3.0), min(1.0, s.y * 3.0), 1.0);\n"+186
"}\n"))+187
188
(define PALETTE-SOURCE 189
(string-append 190
GLSL-PREFIX@@ -184,11 +252,15 @@
252
(define *grid* 64) 253
(define *steps* 8) 254
(define *bench* #f)+255
(define *rule* (quote life)) ; life | reaction+256
(define *substeps* 1) ; generations per frame 257
(define *rt-a* #f) 258
(define *rt-b* #f) 259
(define *cur* #f) 260
(define *next* #f) 261
(define *life* #f)+262
(define *reaction* #f)+263
(define *view* #f) 264
(define *palette* #f) 265
(define *tex* #f) 266
(define *done* 0) ; steps taken@@ -255,6 +327,21 @@
327
;; on the last row and column, so within eight steps it crosses both 328
;; torus edges (a wrong wrap, or a wrong u_resolution inside the pass, 329
;; deforms it); a blinker (period 2) and a block (still life).+330
;; Reaction: u = 1 everywhere, then a few squares of v (u = 0.5, v = 1)+331
;; the pattern grows from; sgp's default blend overwrites, so the+332
;; squares replace the field rather than add to it.+333
(define (seed-reaction!)+334
(let ((g (inexact *grid*)) (q (inexact (max 4 (quotient-int *grid* 8)))))+335
(with-render-target *cur*+336
(set-color 1.0 0.0 0.0 0.0) ; u = 1 (hi byte), v = 0+337
(draw-filled-rect 0.0 0.0 g g)+338
(set-color 0.5 0.0 1.0 0.0) ; u = 0.5, v = 1+339
(draw-filled-rect (* g 0.45) (* g 0.45) q q)+340
(draw-filled-rect (* g 0.2) (* g 0.7) q q)+341
(draw-filled-rect (* g 0.75) (* g 0.25) q q))))+342
+343
(define (quotient-int a b) (/ (- a (modulo a b)) b))+344
345
(define (seed!) 346
(let ((g (- *grid* 3))) 347
(with-render-target *cur*@@ -267,11 +354,12 @@
354
(seed-cell! 9 10) (seed-cell! 10 10) (seed-cell! 11 10) 355
(seed-cell! 20 20) (seed-cell! 21 20) (seed-cell! 20 21) (seed-cell! 21 21)))) 356
−270
;; One generation: the step shader, bound inside the pass on *next*,−271
;; samples *cur* through draw-render-target (channel 0). Then swap.+357
;; One generation of whichever rule: the step shader, bound inside the+358
;; pass on *next*, samples *cur* through draw-render-target (channel+359
;; 0). Then swap. 360
(define (step!) 361
(with-render-target *next*−274
(with-shader *life*+362
(with-shader (if (eq? *rule* (quote reaction)) *reaction* *life*) 363
(draw-render-target *cur* 0.0 0.0 (inexact *grid*) (inexact *grid*)))) 364
(let ((t *cur*)) 365
(set! *cur* *next*)@@ -300,8 +388,12 @@
388
(define (present!) 389
(clear-screen 0.05 0.05 0.08 1.0) 390
(set-color 1.0 1.0 1.0 1.0)−303
(draw-render-target *cur* (inexact FIELD-X) (inexact FIELD-Y)−304
(inexact FIELD-PX) (inexact FIELD-PX))+391
(if (eq? *rule* (quote reaction))+392
(with-shader *view*+393
(draw-render-target *cur* (inexact FIELD-X) (inexact FIELD-Y)+394
(inexact FIELD-PX) (inexact FIELD-PX)))+395
(draw-render-target *cur* (inexact FIELD-X) (inexact FIELD-Y)+396
(inexact FIELD-PX) (inexact FIELD-PX))) 397
(draw-strips!) 398
(set-color 1.0 1.0 1.0 1.0) 399
(draw-texture *tex* (inexact PIX-X) (inexact PIX-Y)@@ -312,22 +404,31 @@
404
;; After gfx-setup and set-viewport. grid: cells per side; steps: the 405
;; generations to run before saying ready; bench?: never stop, and 406
;; report timing every BENCH-EVERY frames.−315
(define (scene-init! grid steps bench?)+407
(define (scene-init! grid steps bench? rule substeps) 408
(set! *grid* grid) 409
(set! *steps* steps) 410
(set! *bench* bench?)+411
(set! *rule* rule)+412
(set! *substeps* substeps) 413
(set! *rt-a* (make-render-target grid grid 'nearest)) 414
(set! *rt-b* (make-render-target grid grid 'nearest)) 415
(set! *cur* *rt-a*) 416
(set! *next* *rt-b*) 417
(set! *life* (load-shader VERT-SOURCE LIFE-SOURCE))+418
(set! *reaction* (load-shader VERT-SOURCE REACTION-SOURCE (quote none)))+419
(set! *view* (load-shader VERT-SOURCE VIEW-SOURCE (quote none)))+420
;; the spots/worms regime (Pearson's F=0.035 k=0.065), dt 1+421
(set-shader-uniform *reaction* (quote u_feed) 0.035)+422
(set-shader-uniform *reaction* (quote u_kill) 0.065)+423
(set-shader-uniform *reaction* (quote u_diffuse) (quote (0.2 0.1)))+424
(set-shader-uniform *reaction* (quote u_dt) 1.0) 425
(set! *palette* (load-shader VERT-SOURCE PALETTE-SOURCE)) 426
(set! *tex* (make-texture-from-pixels 4 4 (pixels-4x4 PATTERN-A) 'nearest)) 427
(unless (and (shader? *life*) (shader? *palette*) (texture? *tex*)) 428
(say "probe" "resources" "FAIL" "a shader or texture did not load"))−328
(seed!)+429
(if (eq? *rule* (quote reaction)) (seed-reaction!) (seed!)) 430
(run-probes!)−330
(say "init" (str "grid=" grid) (str "steps=" steps)))+431
(say "init" (str "grid=" grid) (str "steps=" steps) (str "rule=" rule) (str "substeps=" substeps))) 432
433
(define (scene-settled?) *settled*) 434
@@ -336,7 +437,7 @@
437
(define (scene-tick! dt with-frame-thunk) 438
(let ((t0 (current-jiffy))) 439
(when (or *bench* (< *done* *steps*))−339
(step!))+440
(let loop ((i 0)) (when (< i *substeps*) (step!) (loop (+ i 1))))) 441
;; frame 2 (one end-frame after creation): the real update 442
(when (= *frame* 1) 443
(let ((err (guard (e (#t (if (error-object? e) (error-object-message e) (str e))))test/test-shader-limits/src/test-shader-limits/shell-native.sglmodified
@@ -1,6 +1,7 @@
1
;;; (test-shader-limits shell-native) - The sigil-app window. 2
;;;−3
;;; test-shader-limits [--grid N] [--steps N] [--bench] [--hold SECONDS]+3
;;; test-shader-limits [--grid N] [--steps N] [--rule life|reaction] [--substeps N]+4
;;; [--bench] [--hold SECONDS] 5
;;; 6
;;; Steps the scene once per frame until --steps generations are done, 7
;;; says the ready line, and keeps presenting that frame for --hold@@ -24,11 +25,11 @@
25
((and (string=? (car args) flag) (pair? (cdr args))) (cadr args)) 26
(else (arg-value (cdr args) flag default)))) 27
−27
(define (game-loop grid steps bench? hold)+28
(define (game-loop grid steps bench? hold rule substeps) 29
(lambda () 30
(gfx-setup) 31
(set-viewport VW VH)−31
(scene-init! grid steps bench?)+32
(scene-init! grid steps bench? rule substeps) 33
(let loop ((elapsed 0.0)) 34
(let ((dt (wait-frame))) 35
(scene-tick! dt (lambda (draw) (with-frame (draw))))@@ -44,6 +45,8 @@
45
(grid (or (string->number (arg-value args "--grid" "64")) 64)) 46
(steps (or (string->number (arg-value args "--steps" "8")) 8)) 47
(bench? (and (member "--bench" args) #t))−47
(hold (or (string->number (arg-value args "--hold" "20")) 20)))−48
(run-game "test-shader-limits" VW VH (game-loop grid steps bench? hold))+48
(hold (or (string->number (arg-value args "--hold" "20")) 20))+49
(rule (string->symbol (arg-value args "--rule" "life")))+50
(substeps (or (string->number (arg-value args "--substeps" "1")) 1)))+51
(run-game "test-shader-limits" VW VH (game-loop grid steps bench? hold rule substeps)) 52
0))))test/test-shader-limits/src/test-shader-limits/shell-web.sglmodified
@@ -2,7 +2,8 @@
2
;;; 3
;;; The gles3 app shell dispatches ("frame", ms) once per 4
;;; requestAnimationFrame; the page dispatches the query parameters−5
;;; ("grid", N) ("steps", N) ("bench", "on") before the first frame. The+5
;;; ("grid", N) ("steps", N) ("rule", NAME) ("substeps", N) ("bench", "on")+6
;;; before the first frame. The 7
;;; scene is initialised on the first frame so those have landed. 8
9
(define-library (test-shader-limits shell-web)@@ -18,6 +19,8 @@
19
(define *grid* 64) 20
(define *steps* 8) 21
(define *bench* #f)+22
(define *rule* (quote life))+23
(define *substeps* 1) 24
(define *booted* #f) 25
(define *last-ms* #f) 26
@@ -30,7 +33,7 @@
33
(define (web-tick dt) 34
(unless *booted* 35
(set! *booted* #t)−33
(scene-init! *grid* *steps* *bench*))+36
(scene-init! *grid* *steps* *bench* *rule* *substeps*)) 37
(gles3-resize-to-display) 38
(scene-tick! dt (lambda (draw) (begin-frame) (draw) (end-frame)))) 39
@@ -49,4 +52,6 @@
52
((string=? type "grid") (set! *grid* (or (string->number payload) 64)) 0) 53
((string=? type "steps") (set! *steps* (or (string->number payload) 8)) 0) 54
((string=? type "bench") (set! *bench* (string=? payload "on")) 0)+55
((string=? type "rule") (set! *rule* (string->symbol payload)) 0)+56
((string=? type "substeps") (set! *substeps* (or (string->number payload) 1)) 0) 57
(else 0)))))test/test-shader-limits/verify.mjsmodified
@@ -6,6 +6,7 @@
6
// node verify.mjs --web [build/web] [--port 8097] [--cdp 9237] 7
// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH] 8
// node verify.mjs --native|--web --bench [--grid 512] [--reports 3]+9
// node verify.mjs --native|--web --rule reaction --steps 4000 --substeps 8 [--grid 128] 10
// 11
// Native: runs the binary on $DISPLAY (an Xvfb the caller owns), waits for 12
// its "shader-limits: ready" line, captures the window with ImageMagick's@@ -42,7 +43,7 @@ import { spawn, execFileSync } from "node:child_process";
43
const args = process.argv.slice(2); 44
const flag = (name) => args.includes(name); 45
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };−45
const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold"];+46
const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold", "--rule", "--substeps"]; 47
const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1]))); 48
49
const NATIVE = flag("--native");@@ -55,6 +56,9 @@ const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);
56
const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`); 57
const REPORTS = parseInt(opt("--reports", "3"), 10); 58
const HOLD = parseInt(opt("--hold", "60"), 10);+59
const RULE = opt("--rule", "life");+60
const SUBSTEPS = parseInt(opt("--substeps", "1"), 10);+61
const REACTION = RULE === "reaction"; 62
63
// ---- the scene's geometry, mirrored from scene.sgl --------------------------- 64
const VW = 640, VH = 480;@@ -138,6 +142,7 @@ function checkLines(lines) {
142
} 143
144
function checkFrame(frame) {+145
if (REACTION) { checkReaction(frame); checkStripsAndPixels(frame); return; } 146
// field 147
const exp = expectedField(GRID, EXPECT_STEPS); 148
const cell = FIELD_PX / GRID;@@ -150,6 +155,10 @@ function checkFrame(frame) {
155
} 156
if (mismatches === 0 && live >= 12) pass("field", `${GRID}x${GRID} cells match a CPU Life after ${EXPECT_STEPS} steps; ${live} live cells (glider, blinker, block)`); 157
else fail("field", `${mismatches} of ${GRID * GRID} cells differ (expected ${live} live, saw ${seenLive}); first: ${firstBad.join("; ")}`);+158
checkStripsAndPixels(frame);+159
}+160
+161
function checkStripsAndPixels(frame) { 162
// palette 163
const bad = []; 164
for (let i = 0; i < 8; i++) {@@ -173,6 +182,27 @@ function checkFrame(frame) {
182
else fail("pixels", badPx.join("; ")); 183
} 184
+185
// Gray-Scott has no closed form to compare against, so the reaction field is+186
// judged by shape: v (the G channel) must have grown from three seed squares+187
// (together 4.7% of the field) into a pattern covering between 6% and+188
// 70% of the cells, while u (R) is still high somewhere (the field has not+189
// saturated), and the pattern must not be one flat value.+190
function checkReaction(frame) {+191
const cell = FIELD_PX / GRID;+192
let vHigh = 0, uHigh = 0; const vBins = new Set();+193
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {+194
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);+195
if (c[1] > 64) vHigh++;+196
if (c[0] > 192) uHigh++;+197
vBins.add(c[1] >> 4);+198
}+199
const total = GRID * GRID, vf = vHigh / total, uf = uHigh / total;+200
const detail = `v>0.25 on ${(vf * 100).toFixed(1)}% of cells, u>0.75 on ${(uf * 100).toFixed(1)}%, ${vBins.size} v levels`;+201
// the three seed squares cover 3/64 = 4.7% of the field; growth past 6% is the pattern+202
if (vf >= 0.06 && vf <= 0.7 && uf >= 0.2 && vBins.size >= 6) pass("field", `reaction pattern grew: ${detail}`);+203
else fail("field", `no reaction pattern: ${detail}`);+204
}+205
206
// sokol logs through slog_func: natively sigil-app's format `[sg][error][id:N] ...` 207
// on stderr, on the web sigil-graphics' own `sokol[level=L] file:line [tag] msg` 208
// on the console (level 0 panic, 1 error, 2 warning, 3 info; the web build@@ -204,7 +234,7 @@ if (NATIVE) {
234
try { execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }); } 235
catch { console.log(`SETUP-FAILED: ${tool} is not on PATH (run inside guix shell -m ../../manifest.scm imagemagick xdotool)`); process.exit(2); } 236
}−207
const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD)].concat(BENCH ? ["--bench"] : []);+237
const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD), "--rule", RULE, "--substeps", String(SUBSTEPS)].concat(BENCH ? ["--bench"] : []); 238
const child = spawn(bin, appArgs, { stdio: ["ignore", "pipe", "pipe"], detached: true }); 239
const lines = []; 240
let buf = "";@@ -374,7 +404,7 @@ async function waitLine(re, ms) {
404
return null; 405
} 406
−377
const query = `?grid=${GRID}&steps=${STEPS}${BENCH ? "&bench" : ""}`;+407
const query = `?grid=${GRID}&steps=${STEPS}&rule=${RULE}&substeps=${SUBSTEPS}${BENCH ? "&bench" : ""}`; 408
await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` }); 409
410
if (BENCH) {