Commit3e806169Recorded26 Apr 2026Repositorysigil-graphics

feat(graphics): add custom fragment shader pipeline (Phase 2)

Message

Adds load-shader, with-shader macro, set-shader-uniform, shader-free!. Custom fragment shaders run as sgp custom pipelines so existing 2D draw primitives keep working under the new shader. Auto-uniforms utime and uresolution are populated on every shader-bound draw without caller setup; user-set uniforms persist across frames until overwritten.

Implementation: - GfxShader wraps the compiled sgshader, an sgppipeline that bakes it in (BLENDMODEBLEND), and a parsed uniform layout (name → offset + sguniformtype) plus a CPU buffer that mirrors the GPU uniform block. - load-shader parses uniform <type> <name>; declarations from the user's fragment GLSL (float, vec2, vec3, vec4, mat4 — sampler2D goes through sgp's channel-0 binding instead of the uniform block). NATIVE layout, alignment 1, tightly packed. - The fragment-stage uniform block is declared at slot 1 to match sgpsetuniform's SGPUNIFORMSLOTFRAGMENT path. - with-shader uses dynamic-wind so the pipeline is restored on non-local exit, keeping subsequent draws on the default pipeline. - A global activeshader pointer + applyactiveshaderuniforms helper refreshes utime + uresolution into the shader's buffer at bind time, then sgpsetuniform pushes it. Setting any uniform mid-body also re-pushes when the shader is active so the new value lands on the next draw.

Build / runtime: - SGPUNIFORMCONTENTSLOTS bumped from 8 → 64 (32 → 256 bytes) so there's headroom for utime + uresolution + a reasonable budget of caller-set uniforms. sgp's default 8 floats doesn't even fit one auto-uniform plus a vec4. - gfx-setup captures CLOCKMONOTONIC start time for utime. - %unbind-shader carefully skips sgpresetuniform because sgpresetpipeline sets pipeline.id to SGINVALIDID and sgpresetuniform asserts that id is valid (sgpset_pipeline internally memsets the uniform buffer anyway, so the reset is redundant).

Smoke test: test/test-shader/ renders a colorful scene into a 256×256 RT, then composites it twice on an 800×480 swap chain — once unmodified, once through a warm-amber tint shader that pulses with utime and softens edges via a uresolution-driven vignette. Clean exit, no validation panics; the only sokol log line is the harmless LINUXX11QUERYSYSTEMDPI_FAILED warning.

The cinder-side wiring + capture-validated tint pass live on cinder's feat/test-graphics-rt branch (separate commit).

Changed
 src/c/graphics.c                          | 501 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/c/sokol-graphics.c                    |   6 ++
 src/sigil/graphics.sgl                    |  22 ++++++-
 test/test-shader/dev-redirects.sgl        |   6 ++
 test/test-shader/package.sgl              |  29 +++++++++
 test/test-shader/src/test-shader/main.sgl | 128 +++++++++++++++++++++++++++++++++++++++
 6 files changed, 691 insertions(+), 1 deletion(-)
Diff
src/c/graphics.cmodified
@@ -18,6 +18,7 @@
18
#include <stdbool.h>
19
#include <string.h>
20
#include <math.h>
+21
#include <time.h>
22
23
#ifndef M_PI
24
#define M_PI 3.14159265358979323846
@@ -40,6 +41,9 @@ static Value texture_type_tag = SIGIL_UNDEFINED;
41
/* Render target type tag (initialized at module init) */
42
static Value rt_type_tag = SIGIL_UNDEFINED;
43
+44
/* Shader type tag (initialized at module init) */
+45
static Value shader_type_tag = SIGIL_UNDEFINED;
+46
47
/* Texture structure.
48
*
49
* `owns_resources` is false for textures that borrow their handles from
@@ -76,6 +80,51 @@ typedef struct {
80
bool freed;
81
} GfxRenderTarget;
82
+83
/* Shader uniform entry — one per uniform declared in the user's
+84
* fragment GLSL. The `offset` is into the per-shader uniform buffer;
+85
* sgp_set_uniform ships the buffer contents to the GPU per-draw. */
+86
#define SIGIL_GFX_MAX_UNIFORMS 16
+87
#define SIGIL_GFX_UNIFORM_BUFFER_SIZE 256 /* matches SGP_UNIFORM_CONTENT_SLOTS=64 floats */
+88
#define SIGIL_GFX_UNIFORM_NAME_MAX 64
+89
+90
typedef struct {
+91
char name[SIGIL_GFX_UNIFORM_NAME_MAX];
+92
sg_uniform_type type;
+93
uint32_t offset;
+94
uint32_t size;
+95
} GfxUniformEntry;
+96
+97
/* Shader structure.
+98
*
+99
* Holds the compiled sokol shader, the sgp pipeline that bakes it in,
+100
* the parsed uniform layout (name → offset/type/size), and a CPU
+101
* buffer that mirrors the GPU uniform block. Auto-uniforms u_time and
+102
* u_resolution are tracked by index for fast per-draw refresh.
+103
*
+104
* Texture sampler bindings (sampler2D uniforms) are NOT tracked here —
+105
* channel 0 is bound by the draw primitive itself (e.g.,
+106
* draw-render-target sets channel 0 to the source texture). */
+107
typedef struct {
+108
sg_shader shader;
+109
sg_pipeline pipeline;
+110
GfxUniformEntry uniforms[SIGIL_GFX_MAX_UNIFORMS];
+111
int num_uniforms;
+112
uint8_t buffer[SIGIL_GFX_UNIFORM_BUFFER_SIZE];
+113
uint32_t buffer_size;
+114
int u_time_index; /* -1 if shader doesn't use u_time */
+115
int u_resolution_index; /* -1 if shader doesn't use u_resolution */
+116
bool freed;
+117
} GfxShader;
+118
+119
/* Pointer to the currently active shader (set by %bind-shader,
+120
* cleared by %unbind-shader). Used by the auto-uniform refresh path
+121
* during draw-render-target / draw-texture so u_time advances and
+122
* u_resolution tracks viewport size without caller intervention. */
+123
static GfxShader *active_shader = NULL;
+124
+125
/* Process start time for u_time. Set on first sg_setup. */
+126
static double gfx_start_time = 0.0;
+127
128
/* Current draw color */
129
static float draw_color[4] = {1.0f, 1.0f, 1.0f, 1.0f};
130
@@ -104,6 +153,17 @@ static float value_to_float(Value v)
153
return 0.0f;
154
}
155
+156
/* Monotonic seconds since gfx-setup. Used by the shader auto-uniform
+157
* u_time. clock_gettime(CLOCK_MONOTONIC) is unaffected by wall-clock
+158
* jumps and has nanosecond resolution. */
+159
static float gfx_elapsed_seconds(void)
+160
{
+161
struct timespec ts;
+162
clock_gettime(CLOCK_MONOTONIC, &ts);
+163
double now = (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+164
return (float)(now - gfx_start_time);
+165
}
+166
167
/* ============================================================
168
* NATIVE FUNCTIONS
169
* ============================================================ */
@@ -128,6 +188,13 @@ static Value native_gfx_setup(SigilVM *vm, int argc, Value *args)
188
};
189
sg_setup(&desc);
190
+191
/* Capture process start time for the shader u_time auto-uniform. */
+192
{
+193
struct timespec ts;
+194
clock_gettime(CLOCK_MONOTONIC, &ts);
+195
gfx_start_time = (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+196
}
+197
198
/* Initialize sokol_gp for 2D rendering */
199
sgp_desc sgpdesc = {0};
200
sgp_setup(&sgpdesc);
@@ -1338,6 +1405,418 @@ static Value native_draw_render_target(SigilVM *vm, int argc, Value *args)
1405
return SIGIL_NIL;
1406
}
1407
+1408
/* ============================================================
+1409
* SHADERS (Phase 2 — custom fragment shaders for post-processing)
+1410
* ============================================================ */
+1411
+1412
static void ensure_shader_type(SigilVM *vm)
+1413
{
+1414
if (sigil_is_undefined(shader_type_tag)) {
+1415
shader_type_tag = sigil_intern_symbol(vm, "sigil-graphics-shader", 21);
+1416
}
+1417
}
+1418
+1419
static GfxShader *get_shader(SigilVM *vm, Value v)
+1420
{
+1421
if (!sigil_is_foreign(v)) return NULL;
+1422
ensure_shader_type(vm);
+1423
if (sigil_foreign_type(v) != shader_type_tag) return NULL;
+1424
return (GfxShader *)sigil_foreign_data(v);
+1425
}
+1426
+1427
static void shader_free_resources(GfxShader *sh)
+1428
{
+1429
if (!sh || sh->freed) return;
+1430
if (active_shader == sh) {
+1431
active_shader = NULL;
+1432
}
+1433
if (sh->pipeline.id != SG_INVALID_ID) {
+1434
sg_destroy_pipeline(sh->pipeline);
+1435
}
+1436
if (sh->shader.id != SG_INVALID_ID) {
+1437
sg_destroy_shader(sh->shader);
+1438
}
+1439
sh->freed = true;
+1440
}
+1441
+1442
static void shader_destructor(void *data)
+1443
{
+1444
GfxShader *sh = (GfxShader *)data;
+1445
if (sh) {
+1446
shader_free_resources(sh);
+1447
free(sh);
+1448
}
+1449
}
+1450
+1451
/* Map a GLSL type token to sokol_gfx uniform type + size in bytes
+1452
* (NATIVE layout — same as STD140 except for vec3, which we don't use).
+1453
* Returns 0 on unrecognized type. */
+1454
static int map_glsl_type(const char *tok, sg_uniform_type *out_type, uint32_t *out_size)
+1455
{
+1456
if (strcmp(tok, "float") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT; *out_size = 4; return 1; }
+1457
if (strcmp(tok, "vec2") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT2; *out_size = 8; return 1; }
+1458
if (strcmp(tok, "vec3") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT3; *out_size = 12; return 1; }
+1459
if (strcmp(tok, "vec4") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT4; *out_size = 16; return 1; }
+1460
if (strcmp(tok, "mat4") == 0) { *out_type = SG_UNIFORMTYPE_MAT4; *out_size = 64; return 1; }
+1461
return 0;
+1462
}
+1463
+1464
/* Parse the user's fragment GLSL for `uniform <type> <name>;` declarations.
+1465
*
+1466
* Builds the GfxShader's uniform layout. Skips sampler2D — those are
+1467
* texture bindings, not uniform-block entries (the channel-0 binding is
+1468
* managed by the draw primitive). Recognised types: float, vec2, vec3,
+1469
* vec4, mat4. Unknown types are silently skipped (the shader-create call
+1470
* will fail later at link time if names mismatch — fine).
+1471
*
+1472
* NATIVE layout: tightly packed, no padding. */
+1473
static void parse_fragment_uniforms(GfxShader *sh, const char *frag_src)
+1474
{
+1475
const char *p = frag_src;
+1476
uint32_t cursor = 0;
+1477
sh->num_uniforms = 0;
+1478
sh->u_time_index = -1;
+1479
sh->u_resolution_index = -1;
+1480
+1481
while (*p) {
+1482
/* Skip whitespace, then look for the literal "uniform " token at
+1483
* a line boundary or after whitespace. */
+1484
const char *u = strstr(p, "uniform");
+1485
if (!u) break;
+1486
/* Make sure 'uniform' is at start of token (preceded by whitespace
+1487
* or newline or BOF). */
+1488
if (u != frag_src) {
+1489
char prev = u[-1];
+1490
if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r') {
+1491
p = u + 7;
+1492
continue;
+1493
}
+1494
}
+1495
const char *q = u + 7;
+1496
while (*q == ' ' || *q == '\t') q++;
+1497
/* Read type token. */
+1498
char type_buf[32];
+1499
size_t ti = 0;
+1500
while (*q && *q != ' ' && *q != '\t' && ti < sizeof(type_buf)-1) {
+1501
type_buf[ti++] = *q++;
+1502
}
+1503
type_buf[ti] = '\0';
+1504
/* Skip sampler2D / sampler types — those are texture bindings,
+1505
* not uniform-block entries. */
+1506
if (strncmp(type_buf, "sampler", 7) == 0) {
+1507
p = q;
+1508
continue;
+1509
}
+1510
sg_uniform_type utype = SG_UNIFORMTYPE_INVALID;
+1511
uint32_t usize = 0;
+1512
if (!map_glsl_type(type_buf, &utype, &usize)) {
+1513
p = q;
+1514
continue;
+1515
}
+1516
while (*q == ' ' || *q == '\t') q++;
+1517
/* Read name token (until ';' or whitespace or '['). */
+1518
char name_buf[SIGIL_GFX_UNIFORM_NAME_MAX];
+1519
size_t ni = 0;
+1520
while (*q && *q != ';' && *q != ' ' && *q != '\t' && *q != '[' &&
+1521
ni < sizeof(name_buf)-1) {
+1522
name_buf[ni++] = *q++;
+1523
}
+1524
name_buf[ni] = '\0';
+1525
if (ni == 0) { p = q; continue; }
+1526
if (sh->num_uniforms >= SIGIL_GFX_MAX_UNIFORMS) break;
+1527
if (cursor + usize > sizeof(sh->buffer)) break;
+1528
GfxUniformEntry *ent = &sh->uniforms[sh->num_uniforms];
+1529
strncpy(ent->name, name_buf, sizeof(ent->name)-1);
+1530
ent->name[sizeof(ent->name)-1] = '\0';
+1531
ent->type = utype;
+1532
ent->offset = cursor;
+1533
ent->size = usize;
+1534
if (strcmp(name_buf, "u_time") == 0) {
+1535
sh->u_time_index = sh->num_uniforms;
+1536
} else if (strcmp(name_buf, "u_resolution") == 0) {
+1537
sh->u_resolution_index = sh->num_uniforms;
+1538
}
+1539
cursor += usize;
+1540
sh->num_uniforms++;
+1541
p = q;
+1542
}
+1543
sh->buffer_size = cursor;
+1544
memset(sh->buffer, 0, sizeof(sh->buffer));
+1545
}
+1546
+1547
/* (load-shader vertex-source fragment-source) -> <shader> or #f */
+1548
static Value native_load_shader(SigilVM *vm, int argc, Value *args)
+1549
{
+1550
if (argc < 2) {
+1551
sigil__vm_error(vm, SIGIL_ERR_ARITY,
+1552
"load-shader: requires vertex-source, fragment-source");
+1553
return SIGIL_UNDEFINED;
+1554
}
+1555
+1556
const char *vs_src = sigil_string_bytes(args[0]);
+1557
const char *fs_src = sigil_string_bytes(args[1]);
+1558
if (!vs_src || !fs_src) {
+1559
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1560
"load-shader: expected two strings");
+1561
return SIGIL_FALSE;
+1562
}
+1563
+1564
GfxShader *sh = malloc(sizeof(GfxShader));
+1565
if (!sh) {
+1566
sigil__vm_set_error(vm, SIGIL_ERR_MEMORY,
+1567
"load-shader: out of memory");
+1568
return SIGIL_FALSE;
+1569
}
+1570
memset(sh, 0, sizeof(*sh));
+1571
sh->shader.id = SG_INVALID_ID;
+1572
sh->pipeline.id = SG_INVALID_ID;
+1573
+1574
parse_fragment_uniforms(sh, fs_src);
+1575
+1576
sg_shader_desc desc = {0};
+1577
/* Vertex attributes — must match sgp's vertex layout (location 0 =
+1578
* vec4 coord, location 1 = vec4 color). The shader the user writes
+1579
* must declare these inputs at the matching locations; for GL the
+1580
* link uses glBindAttribLocation by glsl_name. */
+1581
desc.attrs[SGP_VS_ATTR_COORD].glsl_name = "coord";
+1582
desc.attrs[SGP_VS_ATTR_COLOR].glsl_name = "color";
+1583
/* One sampler+view pair on the fragment stage at slot 0 — matches
+1584
* sgp's default convention. The user fragment shader names this
+1585
* sampler "iTexChannel0_iSmpChannel0" (sgp's canonical name). */
+1586
desc.samplers[0].stage = SG_SHADERSTAGE_FRAGMENT;
+1587
desc.samplers[0].sampler_type = SG_SAMPLERTYPE_FILTERING;
+1588
desc.views[0].texture.stage = SG_SHADERSTAGE_FRAGMENT;
+1589
desc.views[0].texture.image_type = SG_IMAGETYPE_2D;
+1590
desc.views[0].texture.sample_type = SG_IMAGESAMPLETYPE_FLOAT;
+1591
desc.texture_sampler_pairs[0].stage = SG_SHADERSTAGE_FRAGMENT;
+1592
desc.texture_sampler_pairs[0].view_slot = 0;
+1593
desc.texture_sampler_pairs[0].sampler_slot = 0;
+1594
desc.texture_sampler_pairs[0].glsl_name = "iTexChannel0_iSmpChannel0";
+1595
+1596
/* Uniform block on the fragment stage at slot 1 (matches sgp's
+1597
* SGP_UNIFORM_SLOT_FRAGMENT — sgp_flush emits fragment uniforms
+1598
* to slot 1 when sgp_set_uniform's fs_size > 0). NATIVE layout —
+1599
* tightly packed, alignment=1, matches my parser's offset
+1600
* computation. array_count must be >= 1 (sokol asserts > 0 in
+1601
* _sg_uniform_size). */
+1602
if (sh->num_uniforms > 0) {
+1603
desc.uniform_blocks[1].stage = SG_SHADERSTAGE_FRAGMENT;
+1604
desc.uniform_blocks[1].size = sh->buffer_size;
+1605
desc.uniform_blocks[1].layout = SG_UNIFORMLAYOUT_NATIVE;
+1606
for (int i = 0; i < sh->num_uniforms; i++) {
+1607
desc.uniform_blocks[1].glsl_uniforms[i].type = sh->uniforms[i].type;
+1608
desc.uniform_blocks[1].glsl_uniforms[i].glsl_name = sh->uniforms[i].name;
+1609
desc.uniform_blocks[1].glsl_uniforms[i].array_count = 1;
+1610
}
+1611
}
+1612
+1613
desc.vertex_func.entry = "main";
+1614
desc.fragment_func.entry = "main";
+1615
desc.vertex_func.source = vs_src;
+1616
desc.fragment_func.source = fs_src;
+1617
+1618
sh->shader = sg_make_shader(&desc);
+1619
if (sh->shader.id == SG_INVALID_ID) {
+1620
free(sh);
+1621
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1622
"load-shader: shader compile/link failed (see sokol log above)");
+1623
return SIGIL_FALSE;
+1624
}
+1625
+1626
/* Build a custom sgp pipeline for this shader. Default to alpha blend
+1627
* since post-processing typically overlays a textured rect on the
+1628
* swap chain. */
+1629
sgp_pipeline_desc pip_desc = {0};
+1630
pip_desc.shader = sh->shader;
+1631
pip_desc.blend_mode = SGP_BLENDMODE_BLEND;
+1632
pip_desc.has_vs_color = true; /* sgp's vertex layout always has color */
+1633
sh->pipeline = sgp_make_pipeline(&pip_desc);
+1634
if (sh->pipeline.id == SG_INVALID_ID) {
+1635
sg_destroy_shader(sh->shader);
+1636
free(sh);
+1637
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1638
"load-shader: pipeline create failed");
+1639
return SIGIL_FALSE;
+1640
}
+1641
+1642
ensure_shader_type(vm);
+1643
return sigil_make_foreign(vm, shader_type_tag, sh, shader_destructor,
+1644
sizeof(GfxShader));
+1645
}
+1646
+1647
/* (shader? obj) -> boolean */
+1648
static Value native_shader_p(SigilVM *vm, int argc, Value *args)
+1649
{
+1650
(void)argc;
+1651
return get_shader(vm, args[0]) ? SIGIL_TRUE : SIGIL_FALSE;
+1652
}
+1653
+1654
/* (shader-free! shader) — explicit cleanup. Idempotent. */
+1655
static Value native_shader_free(SigilVM *vm, int argc, Value *args)
+1656
{
+1657
(void)argc;
+1658
GfxShader *sh = get_shader(vm, args[0]);
+1659
if (!sh) {
+1660
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1661
"shader-free!: expected shader");
+1662
return SIGIL_UNDEFINED;
+1663
}
+1664
shader_free_resources(sh);
+1665
return SIGIL_NIL;
+1666
}
+1667
+1668
/* Find a uniform by name in the shader's layout. Returns -1 if absent. */
+1669
static int shader_uniform_index(const GfxShader *sh, const char *name)
+1670
{
+1671
for (int i = 0; i < sh->num_uniforms; i++) {
+1672
if (strcmp(sh->uniforms[i].name, name) == 0) return i;
+1673
}
+1674
return -1;
+1675
}
+1676
+1677
/* Refresh u_time + u_resolution into the active shader's buffer and
+1678
* push the buffer to the fragment stage uniform block. Called from
+1679
* draw primitives that use the active shader. */
+1680
static void apply_active_shader_uniforms(void)
+1681
{
+1682
GfxShader *sh = active_shader;
+1683
if (!sh || sh->freed) return;
+1684
if (sh->u_time_index >= 0) {
+1685
float t = gfx_elapsed_seconds();
+1686
memcpy(sh->buffer + sh->uniforms[sh->u_time_index].offset,
+1687
&t, sizeof(t));
+1688
}
+1689
if (sh->u_resolution_index >= 0) {
+1690
float res[2] = {(float)sapp_width(), (float)sapp_height()};
+1691
if (virtual_viewport_enabled) {
+1692
res[0] = (float)virtual_width;
+1693
res[1] = (float)virtual_height;
+1694
}
+1695
memcpy(sh->buffer + sh->uniforms[sh->u_resolution_index].offset,
+1696
res, sizeof(res));
+1697
}
+1698
if (sh->buffer_size > 0) {
+1699
sgp_set_uniform(NULL, 0, sh->buffer, sh->buffer_size);
+1700
}
+1701
}
+1702
+1703
/* (set-shader-uniform shader name value) — write a value into the
+1704
* shader's uniform buffer at the offset matching `name`.
+1705
*
+1706
* Accepts:
+1707
* number → float
+1708
* list of 2 → vec2
+1709
* list of 3 → vec3
+1710
* list of 4 → vec4
+1711
* 16 floats → mat4 (rare; pass as a list)
+1712
*
+1713
* sampler2D uniforms are NOT set through this path; channel 0 is bound
+1714
* by the draw primitive (e.g., draw-render-target) and additional
+1715
* samplers are out of scope for Phase 2. */
+1716
static Value native_set_shader_uniform(SigilVM *vm, int argc, Value *args)
+1717
{
+1718
if (argc < 3) {
+1719
sigil__vm_error(vm, SIGIL_ERR_ARITY,
+1720
"set-shader-uniform: requires shader, name, value");
+1721
return SIGIL_UNDEFINED;
+1722
}
+1723
GfxShader *sh = get_shader(vm, args[0]);
+1724
if (!sh) {
+1725
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1726
"set-shader-uniform: expected shader");
+1727
return SIGIL_UNDEFINED;
+1728
}
+1729
const char *name = NULL;
+1730
if (sigil_is_symbol(args[1])) {
+1731
name = sigil_symbol_name(args[1]);
+1732
} else if (sigil_is_string(args[1])) {
+1733
name = sigil_string_bytes(args[1]);
+1734
}
+1735
if (!name) {
+1736
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1737
"set-shader-uniform: name must be symbol or string");
+1738
return SIGIL_UNDEFINED;
+1739
}
+1740
int idx = shader_uniform_index(sh, name);
+1741
if (idx < 0) {
+1742
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1743
"set-shader-uniform: no such uniform in shader");
+1744
return SIGIL_UNDEFINED;
+1745
}
+1746
GfxUniformEntry *ent = &sh->uniforms[idx];
+1747
Value v = args[2];
+1748
/* Number → float. */
+1749
if (sigil_is_fixnum(v) || sigil_is_flonum(v)) {
+1750
if (ent->type != SG_UNIFORMTYPE_FLOAT) {
+1751
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1752
"set-shader-uniform: type mismatch (got number, uniform is not float)");
+1753
return SIGIL_UNDEFINED;
+1754
}
+1755
float f = value_to_float(v);
+1756
memcpy(sh->buffer + ent->offset, &f, sizeof(f));
+1757
} else if (sigil_is_pair(v) || sigil_is_null(v)) {
+1758
/* List of numbers. */
+1759
float scratch[16];
+1760
int n = 0;
+1761
Value cur = v;
+1762
while (sigil_is_pair(cur) && n < 16) {
+1763
scratch[n++] = value_to_float(sigil_car(cur));
+1764
cur = sigil_cdr(cur);
+1765
}
+1766
uint32_t needed = ent->size / sizeof(float);
+1767
if ((uint32_t)n != needed) {
+1768
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1769
"set-shader-uniform: list length doesn't match uniform size");
+1770
return SIGIL_UNDEFINED;
+1771
}
+1772
memcpy(sh->buffer + ent->offset, scratch, ent->size);
+1773
} else {
+1774
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1775
"set-shader-uniform: value must be number or list of numbers");
+1776
return SIGIL_UNDEFINED;
+1777
}
+1778
/* If this shader is currently active, push the updated buffer right
+1779
* away so the new value lands in the next draw without waiting for
+1780
* the next auto-refresh. */
+1781
if (active_shader == sh && sh->buffer_size > 0) {
+1782
sgp_set_uniform(NULL, 0, sh->buffer, sh->buffer_size);
+1783
}
+1784
return SIGIL_NIL;
+1785
}
+1786
+1787
/* (%bind-shader shader) — set as active sgp pipeline + uniform source. */
+1788
static Value native_bind_shader(SigilVM *vm, int argc, Value *args)
+1789
{
+1790
(void)argc;
+1791
GfxShader *sh = get_shader(vm, args[0]);
+1792
if (!sh) {
+1793
sigil__vm_set_error(vm, SIGIL_ERR_TYPE,
+1794
"%bind-shader: expected shader");
+1795
return SIGIL_UNDEFINED;
+1796
}
+1797
if (sh->freed) {
+1798
sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME,
+1799
"%bind-shader: shader has been freed");
+1800
return SIGIL_UNDEFINED;
+1801
}

Showing the first 500 of 557 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.

src/c/sokol-graphics.cmodified
@@ -16,6 +16,12 @@
16
/* Now define SOKOL_IMPL for the headers we implement */
17
#define SOKOL_IMPL
18
+19
/* Bump sgp's uniform-block buffer to 64 floats (256 bytes) so custom
+20
* shaders have room for the auto-uniforms (u_time, u_resolution) plus
+21
* a reasonable budget of caller-set uniforms. The default 8 floats
+22
* (32 bytes) doesn't even fit the auto-uniforms plus a single vec4. */
+23
#define SGP_UNIFORM_CONTENT_SLOTS 64
+24
25
/* Order matters: gfx before glue, glue before gp */
26
#include "sokol_gfx.h"
27
#include "sokol_glue.h"
src/sigil/graphics.sglmodified
@@ -51,6 +51,12 @@
51
draw-render-target
52
with-render-target
53
+54
;; Custom shaders (post-processing)
+55
load-shader
+56
shader? shader-free!
+57
set-shader-uniform
+58
with-shader
+59
60
;; Re-export image functions for convenience
61
load-image
62
image? image-width image-height image-channels
@@ -123,4 +129,18 @@
129
(dynamic-wind
130
(lambda () (%begin-rt-pass target))
131
(lambda () body ...)
126
(lambda () (%end-rt-pass)))))))))
+132
(lambda () (%end-rt-pass)))))))
+133
+134
;; Run body with shader bound as the active sgp pipeline. The auto
+135
;; uniforms u_time and u_resolution are refreshed on entry so the
+136
;; shader sees up-to-date values per frame; caller-set uniforms
+137
;; (set-shader-uniform) persist across frames until overwritten.
+138
;; dynamic-wind ensures the pipeline is restored on non-local exit.
+139
(define-syntax with-shader
+140
(syntax-rules ()
+141
((_ shader body ...)
+142
(let ((sh shader))
+143
(dynamic-wind
+144
(lambda () (%bind-shader sh))
+145
(lambda () body ...)
+146
(lambda () (%unbind-shader)))))))))
test/test-shader/dev-redirects.sgladded
@@ -0,0 +1,6 @@
+1
;; Local sigil-graphics is the in-progress version under development.
+2
(redirects
+3
repos: (list
+4
(for-repo
+5
url: "codeberg:sigil/sigil-graphics"
+6
use: (from-path dir: "../.."))))
test/test-shader/package.sgladded
@@ -0,0 +1,29 @@
+1
;;; test-shader — smoke test for sigil-graphics custom fragment shaders
+2
;;;
+3
;;; Renders a colorful quad into a 256×256 render target, then composites
+4
;;; it twice on the swap chain: once with the default pipeline (untinted)
+5
;;; and once through a tint shader (warm-amber). Quits after ~5 seconds
+6
;;; or when escape is pressed.
+7
+8
(package
+9
name: "test-shader"
+10
version: "0.0.1"
+11
sigil: "^0.14"
+12
description: "Smoke test: custom fragment shader pipeline"
+13
license: "BSD-3-Clause"
+14
authors: (list "David Wilson <[email protected]>")
+15
+16
entry: '(test-shader main)
+17
bundle-name: "test-shader"
+18
+19
configs: (list
+20
(config
+21
name: 'dev
+22
output-dir: "build/dev"
+23
debug?: #t
+24
optimize: 0
+25
bundle?: #t))
+26
+27
dependencies: (list
+28
(from-git url: "codeberg:sigil/sigil-app" version: "^0.8.2")
+29
(from-git url: "codeberg:sigil/sigil-graphics" version: "^0.9.3")))
test/test-shader/src/test-shader/main.sgladded
@@ -0,0 +1,128 @@
+1
;;; test-shader / main — custom fragment shader smoke test
+2
;;;
+3
;;; Validates the load-shader / with-shader / set-shader-uniform surface
+4
;;; by drawing a colorful scene into a render target, then drawing it
+5
;;; twice on the swap chain: untinted on the left, through a tint
+6
;;; shader on the right. The tint pulses over time via the auto-uniform
+7
;;; u_time so the test exercises the per-frame uniform refresh path.
+8
+9
(define-library (test-shader main)
+10
(import (sigil core)
+11
(sigil math)
+12
(sigil app)
+13
(sigil graphics))
+14
(export main)
+15
+16
(begin
+17
+18
(define WINDOW-W 800)
+19
(define WINDOW-H 480)
+20
(define RT-SIZE 256)
+21
+22
;; Vertex shader — the standard sgp passthrough. coord at location 0
+23
;; carries (position.xy, texcoord.uv); color at location 1 carries
+24
;; the per-vertex modulation color (sgp_set_color).
+25
(define VERT-SOURCE
+26
(string-append
+27
"#version 410\n"
+28
"layout(location = 0) in vec4 coord;\n"
+29
"layout(location = 1) in vec4 color;\n"
+30
"out vec2 texUV;\n"
+31
"out vec4 iColor;\n"
+32
"void main() {\n"
+33
" gl_Position = vec4(coord.xy, 0.0, 1.0);\n"
+34
" texUV = coord.zw;\n"
+35
" iColor = color;\n"
+36
"}\n"))
+37
+38
;; Fragment shader — samples the bound texture and modulates by a
+39
;; tint color. u_tint is set by the caller; u_time and u_resolution
+40
;; are auto-populated by the wrapper. The auto-uniform u_time drives
+41
;; a sinusoidal pulse on the tint amplitude so the effect is
+42
;; visibly moving (good for video capture).
+43
(define FRAG-SOURCE
+44
(string-append
+45
"#version 410\n"
+46
"uniform sampler2D iTexChannel0_iSmpChannel0;\n"
+47
"uniform float u_time;\n"
+48
"uniform vec2 u_resolution;\n"
+49
"uniform vec4 u_tint;\n"
+50
"in vec2 texUV;\n"
+51
"in vec4 iColor;\n"
+52
"out vec4 fragColor;\n"
+53
"void main() {\n"
+54
" vec4 tex = texture(iTexChannel0_iSmpChannel0, texUV);\n"
+55
" float pulse = 0.85 + 0.15 * sin(u_time * 2.0);\n"
+56
" // Vignette using u_resolution: fade edges based on the\n"
+57
" // currently-active framebuffer size. Proves the auto\n"
+58
" // uniform is reaching the shader and prevents the GL\n"
+59
" // compiler from stripping u_resolution as unused.\n"
+60
" vec2 px = gl_FragCoord.xy;\n"
+61
" vec2 ndc = (px / u_resolution) * 2.0 - 1.0;\n"
+62
" float vignette = 1.0 - 0.35 * dot(ndc, ndc);\n"
+63
" vec3 tinted = tex.rgb * u_tint.rgb * pulse * vignette;\n"
+64
" fragColor = vec4(tinted, tex.a) * iColor;\n"
+65
"}\n"))
+66
+67
;; Render the offscreen scene — a chromatic checker plus a couple
+68
;; of bright shapes so the tint effect is unambiguous.
+69
(define (draw-scene)
+70
(set-color 0.1 0.1 0.4 1.0)
+71
(draw-filled-rect 0.0 0.0 (inexact RT-SIZE) (inexact RT-SIZE))
+72
;; Diagonal stripes (white) to show that the tint isn't just
+73
;; replacing color but modulating per-pixel.
+74
(set-color 0.95 0.95 0.95 1.0)
+75
(draw-filled-rect 16.0 16.0 96.0 32.0)
+76
(draw-filled-rect 144.0 56.0 96.0 32.0)
+77
(draw-filled-rect 16.0 96.0 96.0 32.0)
+78
(draw-filled-rect 144.0 136.0 96.0 32.0)
+79
;; Pure red and pure green bars so we can see what red+tint and
+80
;; green+tint look like — distinct from the white-tinted areas.
+81
(set-color 1.0 0.0 0.0 1.0)
+82
(draw-filled-rect 16.0 196.0 110.0 36.0)
+83
(set-color 0.0 1.0 0.0 1.0)
+84
(draw-filled-rect 130.0 196.0 110.0 36.0))
+85
+86
(define (game-loop)
+87
(gfx-setup)
+88
(set-viewport WINDOW-W WINDOW-H)
+89
(let* ((rt (make-render-target RT-SIZE RT-SIZE))
+90
(tint (load-shader VERT-SOURCE FRAG-SOURCE)))
+91
;; Sanity asserts on the shader handle.
+92
(when (not (shader? tint))
+93
(display "FAIL: shader? returned false\n"))
+94
;; Warm-amber tint: red 1.05, green 0.78, blue 0.55 — pushes the
+95
;; image toward sunset. Multiplier > 1.0 on red is intentional;
+96
;; combined with the time-based pulse it sometimes saturates,
+97
;; which makes the effect more obviously a shader pass and not
+98
;; just a per-vertex color modulation.
+99
(set-shader-uniform tint 'u_tint '(1.05 0.78 0.55 1.0))
+100
+101
(let loop ((elapsed 0.0))
+102
(let ((dt (wait-frame)))
+103
(with-render-target rt
+104
(draw-scene))
+105
(with-frame
+106
(clear-screen 0.04 0.04 0.07 1.0)
+107
;; Left: untinted RT composited with default pipeline.
+108
(draw-render-target rt 32.0 96.0
+109
(inexact RT-SIZE) (inexact RT-SIZE))
+110
;; Right: same RT but routed through the tint shader.
+111
;; The pipeline switch flushes any pending sgp draws, so
+112
;; the previous untinted draw is unaffected.
+113
(with-shader tint
+114
(draw-render-target rt 480.0 96.0
+115
(inexact RT-SIZE) (inexact RT-SIZE))))
+116
(cond
+117
((key-pressed? 'escape) (request-quit))
+118
((quit-requested?) #t)
+119
((> elapsed 5.0) (request-quit))
+120
(else (loop (+ elapsed dt))))))
+121
(shader-free! tint)
+122
(render-target-free! rt))
+123
(gfx-shutdown))
+124
+125
(define (main . _args)
+126
(run-game "sigil-graphics shader smoke test"
+127
WINDOW-W WINDOW-H game-loop)
+128
0)))