feat(graphics): add custom fragment shader pipeline (Phase 2)
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).
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(-)src/c/graphics.cmodified
#include <stdbool.h>#include <string.h>#include <math.h>#include <time.h>#ifndef M_PI#define M_PI 3.14159265358979323846/* Render target type tag (initialized at module init) */static Value rt_type_tag = SIGIL_UNDEFINED;/* Shader type tag (initialized at module init) */static Value shader_type_tag = SIGIL_UNDEFINED;/* Texture structure. * * `owns_resources` is false for textures that borrow their handles from bool freed;} GfxRenderTarget;/* Shader uniform entry — one per uniform declared in the user's * fragment GLSL. The `offset` is into the per-shader uniform buffer; * sgp_set_uniform ships the buffer contents to the GPU per-draw. */#define SIGIL_GFX_MAX_UNIFORMS 16#define SIGIL_GFX_UNIFORM_BUFFER_SIZE 256 /* matches SGP_UNIFORM_CONTENT_SLOTS=64 floats */#define SIGIL_GFX_UNIFORM_NAME_MAX 64typedef struct { char name[SIGIL_GFX_UNIFORM_NAME_MAX]; sg_uniform_type type; uint32_t offset; uint32_t size;} GfxUniformEntry;/* Shader structure. * * Holds the compiled sokol shader, the sgp pipeline that bakes it in, * the parsed uniform layout (name → offset/type/size), and a CPU * buffer that mirrors the GPU uniform block. Auto-uniforms u_time and * u_resolution are tracked by index for fast per-draw refresh. * * Texture sampler bindings (sampler2D uniforms) are NOT tracked here — * channel 0 is bound by the draw primitive itself (e.g., * draw-render-target sets channel 0 to the source texture). */typedef struct { sg_shader shader; sg_pipeline pipeline; GfxUniformEntry uniforms[SIGIL_GFX_MAX_UNIFORMS]; int num_uniforms; uint8_t buffer[SIGIL_GFX_UNIFORM_BUFFER_SIZE]; uint32_t buffer_size; int u_time_index; /* -1 if shader doesn't use u_time */ int u_resolution_index; /* -1 if shader doesn't use u_resolution */ bool freed;} GfxShader;/* Pointer to the currently active shader (set by %bind-shader, * cleared by %unbind-shader). Used by the auto-uniform refresh path * during draw-render-target / draw-texture so u_time advances and * u_resolution tracks viewport size without caller intervention. */static GfxShader *active_shader = NULL;/* Process start time for u_time. Set on first sg_setup. */static double gfx_start_time = 0.0;/* Current draw color */static float draw_color[4] = {1.0f, 1.0f, 1.0f, 1.0f}; return 0.0f;}/* Monotonic seconds since gfx-setup. Used by the shader auto-uniform * u_time. clock_gettime(CLOCK_MONOTONIC) is unaffected by wall-clock * jumps and has nanosecond resolution. */static float gfx_elapsed_seconds(void){ struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); double now = (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; return (float)(now - gfx_start_time);}/* ============================================================ * NATIVE FUNCTIONS * ============================================================ */ }; sg_setup(&desc); /* Capture process start time for the shader u_time auto-uniform. */ { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); gfx_start_time = (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; } /* Initialize sokol_gp for 2D rendering */ sgp_desc sgpdesc = {0}; sgp_setup(&sgpdesc); return SIGIL_NIL;}/* ============================================================ * SHADERS (Phase 2 — custom fragment shaders for post-processing) * ============================================================ */static void ensure_shader_type(SigilVM *vm){ if (sigil_is_undefined(shader_type_tag)) { shader_type_tag = sigil_intern_symbol(vm, "sigil-graphics-shader", 21); }}static GfxShader *get_shader(SigilVM *vm, Value v){ if (!sigil_is_foreign(v)) return NULL; ensure_shader_type(vm); if (sigil_foreign_type(v) != shader_type_tag) return NULL; return (GfxShader *)sigil_foreign_data(v);}static void shader_free_resources(GfxShader *sh){ if (!sh || sh->freed) return; if (active_shader == sh) { active_shader = NULL; } if (sh->pipeline.id != SG_INVALID_ID) { sg_destroy_pipeline(sh->pipeline); } if (sh->shader.id != SG_INVALID_ID) { sg_destroy_shader(sh->shader); } sh->freed = true;}static void shader_destructor(void *data){ GfxShader *sh = (GfxShader *)data; if (sh) { shader_free_resources(sh); free(sh); }}/* Map a GLSL type token to sokol_gfx uniform type + size in bytes * (NATIVE layout — same as STD140 except for vec3, which we don't use). * Returns 0 on unrecognized type. */static int map_glsl_type(const char *tok, sg_uniform_type *out_type, uint32_t *out_size){ if (strcmp(tok, "float") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT; *out_size = 4; return 1; } if (strcmp(tok, "vec2") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT2; *out_size = 8; return 1; } if (strcmp(tok, "vec3") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT3; *out_size = 12; return 1; } if (strcmp(tok, "vec4") == 0) { *out_type = SG_UNIFORMTYPE_FLOAT4; *out_size = 16; return 1; } if (strcmp(tok, "mat4") == 0) { *out_type = SG_UNIFORMTYPE_MAT4; *out_size = 64; return 1; } return 0;}/* Parse the user's fragment GLSL for `uniform <type> <name>;` declarations. * * Builds the GfxShader's uniform layout. Skips sampler2D — those are * texture bindings, not uniform-block entries (the channel-0 binding is * managed by the draw primitive). Recognised types: float, vec2, vec3, * vec4, mat4. Unknown types are silently skipped (the shader-create call * will fail later at link time if names mismatch — fine). * * NATIVE layout: tightly packed, no padding. */static void parse_fragment_uniforms(GfxShader *sh, const char *frag_src){ const char *p = frag_src; uint32_t cursor = 0; sh->num_uniforms = 0; sh->u_time_index = -1; sh->u_resolution_index = -1; while (*p) { /* Skip whitespace, then look for the literal "uniform " token at * a line boundary or after whitespace. */ const char *u = strstr(p, "uniform"); if (!u) break; /* Make sure 'uniform' is at start of token (preceded by whitespace * or newline or BOF). */ if (u != frag_src) { char prev = u[-1]; if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r') { p = u + 7; continue; } } const char *q = u + 7; while (*q == ' ' || *q == '\t') q++; /* Read type token. */ char type_buf[32]; size_t ti = 0; while (*q && *q != ' ' && *q != '\t' && ti < sizeof(type_buf)-1) { type_buf[ti++] = *q++; } type_buf[ti] = '\0'; /* Skip sampler2D / sampler types — those are texture bindings, * not uniform-block entries. */ if (strncmp(type_buf, "sampler", 7) == 0) { p = q; continue; } sg_uniform_type utype = SG_UNIFORMTYPE_INVALID; uint32_t usize = 0; if (!map_glsl_type(type_buf, &utype, &usize)) { p = q; continue; } while (*q == ' ' || *q == '\t') q++; /* Read name token (until ';' or whitespace or '['). */ char name_buf[SIGIL_GFX_UNIFORM_NAME_MAX]; size_t ni = 0; while (*q && *q != ';' && *q != ' ' && *q != '\t' && *q != '[' && ni < sizeof(name_buf)-1) { name_buf[ni++] = *q++; } name_buf[ni] = '\0'; if (ni == 0) { p = q; continue; } if (sh->num_uniforms >= SIGIL_GFX_MAX_UNIFORMS) break; if (cursor + usize > sizeof(sh->buffer)) break; GfxUniformEntry *ent = &sh->uniforms[sh->num_uniforms]; strncpy(ent->name, name_buf, sizeof(ent->name)-1); ent->name[sizeof(ent->name)-1] = '\0'; ent->type = utype; ent->offset = cursor; ent->size = usize; if (strcmp(name_buf, "u_time") == 0) { sh->u_time_index = sh->num_uniforms; } else if (strcmp(name_buf, "u_resolution") == 0) { sh->u_resolution_index = sh->num_uniforms; } cursor += usize; sh->num_uniforms++; p = q; } sh->buffer_size = cursor; memset(sh->buffer, 0, sizeof(sh->buffer));}/* (load-shader vertex-source fragment-source) -> <shader> or #f */static Value native_load_shader(SigilVM *vm, int argc, Value *args){ if (argc < 2) { sigil__vm_error(vm, SIGIL_ERR_ARITY, "load-shader: requires vertex-source, fragment-source"); return SIGIL_UNDEFINED; } const char *vs_src = sigil_string_bytes(args[0]); const char *fs_src = sigil_string_bytes(args[1]); if (!vs_src || !fs_src) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "load-shader: expected two strings"); return SIGIL_FALSE; } GfxShader *sh = malloc(sizeof(GfxShader)); if (!sh) { sigil__vm_set_error(vm, SIGIL_ERR_MEMORY, "load-shader: out of memory"); return SIGIL_FALSE; } memset(sh, 0, sizeof(*sh)); sh->shader.id = SG_INVALID_ID; sh->pipeline.id = SG_INVALID_ID; parse_fragment_uniforms(sh, fs_src); sg_shader_desc desc = {0}; /* Vertex attributes — must match sgp's vertex layout (location 0 = * vec4 coord, location 1 = vec4 color). The shader the user writes * must declare these inputs at the matching locations; for GL the * link uses glBindAttribLocation by glsl_name. */ desc.attrs[SGP_VS_ATTR_COORD].glsl_name = "coord"; desc.attrs[SGP_VS_ATTR_COLOR].glsl_name = "color"; /* One sampler+view pair on the fragment stage at slot 0 — matches * sgp's default convention. The user fragment shader names this * sampler "iTexChannel0_iSmpChannel0" (sgp's canonical name). */ desc.samplers[0].stage = SG_SHADERSTAGE_FRAGMENT; desc.samplers[0].sampler_type = SG_SAMPLERTYPE_FILTERING; desc.views[0].texture.stage = SG_SHADERSTAGE_FRAGMENT; desc.views[0].texture.image_type = SG_IMAGETYPE_2D; desc.views[0].texture.sample_type = SG_IMAGESAMPLETYPE_FLOAT; desc.texture_sampler_pairs[0].stage = SG_SHADERSTAGE_FRAGMENT; desc.texture_sampler_pairs[0].view_slot = 0; desc.texture_sampler_pairs[0].sampler_slot = 0; desc.texture_sampler_pairs[0].glsl_name = "iTexChannel0_iSmpChannel0"; /* Uniform block on the fragment stage at slot 1 (matches sgp's * SGP_UNIFORM_SLOT_FRAGMENT — sgp_flush emits fragment uniforms * to slot 1 when sgp_set_uniform's fs_size > 0). NATIVE layout — * tightly packed, alignment=1, matches my parser's offset * computation. array_count must be >= 1 (sokol asserts > 0 in * _sg_uniform_size). */ if (sh->num_uniforms > 0) { desc.uniform_blocks[1].stage = SG_SHADERSTAGE_FRAGMENT; desc.uniform_blocks[1].size = sh->buffer_size; desc.uniform_blocks[1].layout = SG_UNIFORMLAYOUT_NATIVE; for (int i = 0; i < sh->num_uniforms; i++) { desc.uniform_blocks[1].glsl_uniforms[i].type = sh->uniforms[i].type; desc.uniform_blocks[1].glsl_uniforms[i].glsl_name = sh->uniforms[i].name; desc.uniform_blocks[1].glsl_uniforms[i].array_count = 1; } } desc.vertex_func.entry = "main"; desc.fragment_func.entry = "main"; desc.vertex_func.source = vs_src; desc.fragment_func.source = fs_src; sh->shader = sg_make_shader(&desc); if (sh->shader.id == SG_INVALID_ID) { free(sh); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "load-shader: shader compile/link failed (see sokol log above)"); return SIGIL_FALSE; } /* Build a custom sgp pipeline for this shader. Default to alpha blend * since post-processing typically overlays a textured rect on the * swap chain. */ sgp_pipeline_desc pip_desc = {0}; pip_desc.shader = sh->shader; pip_desc.blend_mode = SGP_BLENDMODE_BLEND; pip_desc.has_vs_color = true; /* sgp's vertex layout always has color */ sh->pipeline = sgp_make_pipeline(&pip_desc); if (sh->pipeline.id == SG_INVALID_ID) { sg_destroy_shader(sh->shader); free(sh); sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "load-shader: pipeline create failed"); return SIGIL_FALSE; } ensure_shader_type(vm); return sigil_make_foreign(vm, shader_type_tag, sh, shader_destructor, sizeof(GfxShader));}/* (shader? obj) -> boolean */static Value native_shader_p(SigilVM *vm, int argc, Value *args){ (void)argc; return get_shader(vm, args[0]) ? SIGIL_TRUE : SIGIL_FALSE;}/* (shader-free! shader) — explicit cleanup. Idempotent. */static Value native_shader_free(SigilVM *vm, int argc, Value *args){ (void)argc; GfxShader *sh = get_shader(vm, args[0]); if (!sh) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "shader-free!: expected shader"); return SIGIL_UNDEFINED; } shader_free_resources(sh); return SIGIL_NIL;}/* Find a uniform by name in the shader's layout. Returns -1 if absent. */static int shader_uniform_index(const GfxShader *sh, const char *name){ for (int i = 0; i < sh->num_uniforms; i++) { if (strcmp(sh->uniforms[i].name, name) == 0) return i; } return -1;}/* Refresh u_time + u_resolution into the active shader's buffer and * push the buffer to the fragment stage uniform block. Called from * draw primitives that use the active shader. */static void apply_active_shader_uniforms(void){ GfxShader *sh = active_shader; if (!sh || sh->freed) return; if (sh->u_time_index >= 0) { float t = gfx_elapsed_seconds(); memcpy(sh->buffer + sh->uniforms[sh->u_time_index].offset, &t, sizeof(t)); } if (sh->u_resolution_index >= 0) { float res[2] = {(float)sapp_width(), (float)sapp_height()}; if (virtual_viewport_enabled) { res[0] = (float)virtual_width; res[1] = (float)virtual_height; } memcpy(sh->buffer + sh->uniforms[sh->u_resolution_index].offset, res, sizeof(res)); } if (sh->buffer_size > 0) { sgp_set_uniform(NULL, 0, sh->buffer, sh->buffer_size); }}/* (set-shader-uniform shader name value) — write a value into the * shader's uniform buffer at the offset matching `name`. * * Accepts: * number → float * list of 2 → vec2 * list of 3 → vec3 * list of 4 → vec4 * 16 floats → mat4 (rare; pass as a list) * * sampler2D uniforms are NOT set through this path; channel 0 is bound * by the draw primitive (e.g., draw-render-target) and additional * samplers are out of scope for Phase 2. */static Value native_set_shader_uniform(SigilVM *vm, int argc, Value *args){ if (argc < 3) { sigil__vm_error(vm, SIGIL_ERR_ARITY, "set-shader-uniform: requires shader, name, value"); return SIGIL_UNDEFINED; } GfxShader *sh = get_shader(vm, args[0]); if (!sh) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-shader-uniform: expected shader"); return SIGIL_UNDEFINED; } const char *name = NULL; if (sigil_is_symbol(args[1])) { name = sigil_symbol_name(args[1]); } else if (sigil_is_string(args[1])) { name = sigil_string_bytes(args[1]); } if (!name) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-shader-uniform: name must be symbol or string"); return SIGIL_UNDEFINED; } int idx = shader_uniform_index(sh, name); if (idx < 0) { sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "set-shader-uniform: no such uniform in shader"); return SIGIL_UNDEFINED; } GfxUniformEntry *ent = &sh->uniforms[idx]; Value v = args[2]; /* Number → float. */ if (sigil_is_fixnum(v) || sigil_is_flonum(v)) { if (ent->type != SG_UNIFORMTYPE_FLOAT) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-shader-uniform: type mismatch (got number, uniform is not float)"); return SIGIL_UNDEFINED; } float f = value_to_float(v); memcpy(sh->buffer + ent->offset, &f, sizeof(f)); } else if (sigil_is_pair(v) || sigil_is_null(v)) { /* List of numbers. */ float scratch[16]; int n = 0; Value cur = v; while (sigil_is_pair(cur) && n < 16) { scratch[n++] = value_to_float(sigil_car(cur)); cur = sigil_cdr(cur); } uint32_t needed = ent->size / sizeof(float); if ((uint32_t)n != needed) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-shader-uniform: list length doesn't match uniform size"); return SIGIL_UNDEFINED; } memcpy(sh->buffer + ent->offset, scratch, ent->size); } else { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "set-shader-uniform: value must be number or list of numbers"); return SIGIL_UNDEFINED; } /* If this shader is currently active, push the updated buffer right * away so the new value lands in the next draw without waiting for * the next auto-refresh. */ if (active_shader == sh && sh->buffer_size > 0) { sgp_set_uniform(NULL, 0, sh->buffer, sh->buffer_size); } return SIGIL_NIL;}/* (%bind-shader shader) — set as active sgp pipeline + uniform source. */static Value native_bind_shader(SigilVM *vm, int argc, Value *args){ (void)argc; GfxShader *sh = get_shader(vm, args[0]); if (!sh) { sigil__vm_set_error(vm, SIGIL_ERR_TYPE, "%bind-shader: expected shader"); return SIGIL_UNDEFINED; } if (sh->freed) { sigil__vm_set_error(vm, SIGIL_ERR_RUNTIME, "%bind-shader: shader has been freed"); return SIGIL_UNDEFINED; }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
/* Now define SOKOL_IMPL for the headers we implement */#define SOKOL_IMPL/* Bump sgp's uniform-block buffer to 64 floats (256 bytes) so custom * shaders have room for the auto-uniforms (u_time, u_resolution) plus * a reasonable budget of caller-set uniforms. The default 8 floats * (32 bytes) doesn't even fit the auto-uniforms plus a single vec4. */#define SGP_UNIFORM_CONTENT_SLOTS 64/* Order matters: gfx before glue, glue before gp */#include "sokol_gfx.h"#include "sokol_glue.h"src/sigil/graphics.sglmodified
draw-render-target with-render-target ;; Custom shaders (post-processing) load-shader shader? shader-free! set-shader-uniform with-shader ;; Re-export image functions for convenience load-image image? image-width image-height image-channels (dynamic-wind (lambda () (%begin-rt-pass target)) (lambda () body ...) (lambda () (%end-rt-pass))))))))) (lambda () (%end-rt-pass))))))) ;; Run body with shader bound as the active sgp pipeline. The auto ;; uniforms u_time and u_resolution are refreshed on entry so the ;; shader sees up-to-date values per frame; caller-set uniforms ;; (set-shader-uniform) persist across frames until overwritten. ;; dynamic-wind ensures the pipeline is restored on non-local exit. (define-syntax with-shader (syntax-rules () ((_ shader body ...) (let ((sh shader)) (dynamic-wind (lambda () (%bind-shader sh)) (lambda () body ...) (lambda () (%unbind-shader)))))))))test/test-shader/dev-redirects.sgladded
;; Local sigil-graphics is the in-progress version under development.(redirects repos: (list (for-repo url: "codeberg:sigil/sigil-graphics" use: (from-path dir: "../.."))))test/test-shader/package.sgladded
;;; test-shader — smoke test for sigil-graphics custom fragment shaders;;;;;; Renders a colorful quad into a 256×256 render target, then composites;;; it twice on the swap chain: once with the default pipeline (untinted);;; and once through a tint shader (warm-amber). Quits after ~5 seconds;;; or when escape is pressed.(package name: "test-shader" version: "0.0.1" sigil: "^0.14" description: "Smoke test: custom fragment shader pipeline" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") entry: '(test-shader main) bundle-name: "test-shader" configs: (list (config name: 'dev output-dir: "build/dev" debug?: #t optimize: 0 bundle?: #t)) dependencies: (list (from-git url: "codeberg:sigil/sigil-app" version: "^0.8.2") (from-git url: "codeberg:sigil/sigil-graphics" version: "^0.9.3")))test/test-shader/src/test-shader/main.sgladded
;;; test-shader / main — custom fragment shader smoke test;;;;;; Validates the load-shader / with-shader / set-shader-uniform surface;;; by drawing a colorful scene into a render target, then drawing it;;; twice on the swap chain: untinted on the left, through a tint;;; shader on the right. The tint pulses over time via the auto-uniform;;; u_time so the test exercises the per-frame uniform refresh path.(define-library (test-shader main) (import (sigil core) (sigil math) (sigil app) (sigil graphics)) (export main) (begin (define WINDOW-W 800) (define WINDOW-H 480) (define RT-SIZE 256) ;; Vertex shader — the standard sgp passthrough. coord at location 0 ;; carries (position.xy, texcoord.uv); color at location 1 carries ;; the per-vertex modulation color (sgp_set_color). (define VERT-SOURCE (string-append "#version 410\n" "layout(location = 0) in vec4 coord;\n" "layout(location = 1) in vec4 color;\n" "out vec2 texUV;\n" "out vec4 iColor;\n" "void main() {\n" " gl_Position = vec4(coord.xy, 0.0, 1.0);\n" " texUV = coord.zw;\n" " iColor = color;\n" "}\n")) ;; Fragment shader — samples the bound texture and modulates by a ;; tint color. u_tint is set by the caller; u_time and u_resolution ;; are auto-populated by the wrapper. The auto-uniform u_time drives ;; a sinusoidal pulse on the tint amplitude so the effect is ;; visibly moving (good for video capture). (define FRAG-SOURCE (string-append "#version 410\n" "uniform sampler2D iTexChannel0_iSmpChannel0;\n" "uniform float u_time;\n" "uniform vec2 u_resolution;\n" "uniform vec4 u_tint;\n" "in vec2 texUV;\n" "in vec4 iColor;\n" "out vec4 fragColor;\n" "void main() {\n" " vec4 tex = texture(iTexChannel0_iSmpChannel0, texUV);\n" " float pulse = 0.85 + 0.15 * sin(u_time * 2.0);\n" " // Vignette using u_resolution: fade edges based on the\n" " // currently-active framebuffer size. Proves the auto\n" " // uniform is reaching the shader and prevents the GL\n" " // compiler from stripping u_resolution as unused.\n" " vec2 px = gl_FragCoord.xy;\n" " vec2 ndc = (px / u_resolution) * 2.0 - 1.0;\n" " float vignette = 1.0 - 0.35 * dot(ndc, ndc);\n" " vec3 tinted = tex.rgb * u_tint.rgb * pulse * vignette;\n" " fragColor = vec4(tinted, tex.a) * iColor;\n" "}\n")) ;; Render the offscreen scene — a chromatic checker plus a couple ;; of bright shapes so the tint effect is unambiguous. (define (draw-scene) (set-color 0.1 0.1 0.4 1.0) (draw-filled-rect 0.0 0.0 (inexact RT-SIZE) (inexact RT-SIZE)) ;; Diagonal stripes (white) to show that the tint isn't just ;; replacing color but modulating per-pixel. (set-color 0.95 0.95 0.95 1.0) (draw-filled-rect 16.0 16.0 96.0 32.0) (draw-filled-rect 144.0 56.0 96.0 32.0) (draw-filled-rect 16.0 96.0 96.0 32.0) (draw-filled-rect 144.0 136.0 96.0 32.0) ;; Pure red and pure green bars so we can see what red+tint and ;; green+tint look like — distinct from the white-tinted areas. (set-color 1.0 0.0 0.0 1.0) (draw-filled-rect 16.0 196.0 110.0 36.0) (set-color 0.0 1.0 0.0 1.0) (draw-filled-rect 130.0 196.0 110.0 36.0)) (define (game-loop) (gfx-setup) (set-viewport WINDOW-W WINDOW-H) (let* ((rt (make-render-target RT-SIZE RT-SIZE)) (tint (load-shader VERT-SOURCE FRAG-SOURCE))) ;; Sanity asserts on the shader handle. (when (not (shader? tint)) (display "FAIL: shader? returned false\n")) ;; Warm-amber tint: red 1.05, green 0.78, blue 0.55 — pushes the ;; image toward sunset. Multiplier > 1.0 on red is intentional; ;; combined with the time-based pulse it sometimes saturates, ;; which makes the effect more obviously a shader pass and not ;; just a per-vertex color modulation. (set-shader-uniform tint 'u_tint '(1.05 0.78 0.55 1.0)) (let loop ((elapsed 0.0)) (let ((dt (wait-frame))) (with-render-target rt (draw-scene)) (with-frame (clear-screen 0.04 0.04 0.07 1.0) ;; Left: untinted RT composited with default pipeline. (draw-render-target rt 32.0 96.0 (inexact RT-SIZE) (inexact RT-SIZE)) ;; Right: same RT but routed through the tint shader. ;; The pipeline switch flushes any pending sgp draws, so ;; the previous untinted draw is unaffected. (with-shader tint (draw-render-target rt 480.0 96.0 (inexact RT-SIZE) (inexact RT-SIZE)))) (cond ((key-pressed? 'escape) (request-quit)) ((quit-requested?) #t) ((> elapsed 5.0) (request-quit)) (else (loop (+ elapsed dt)))))) (shader-free! tint) (render-target-free! rt)) (gfx-shutdown)) (define (main . _args) (run-game "sigil-graphics shader smoke test" WINDOW-W WINDOW-H game-loop) 0)))