verve.gl custom shader materials
Guide: WebGL · Advanced WebGL · live: custom shader material.
verve.gl.Material(comptime opts) splices custom GLSL/WGSL snippets into the PBR über-shader at comptime, baked as a frozen shader variant with the same status as every other PBR permutation (v0.29.0–v0.31.0). The result is bound to a scene with GlSceneBuilder.material(desc, params).
Hook surface
Material() (core/gl/material.zig) wires four snippet hooks into the assembled shader:
| Hook | Language | When it runs | What you write |
|---|---|---|---|
frag_albedo | GLSL + WGSL | Before PBR lighting (after texture sampling) | Overrides vrv_albedo (vec3 base color fed into lighting) |
frag_final | GLSL + WGSL | After lighting + IBL, before tonemap | Overlay/tint effects; writes vrv_color (vec3 linear-HDR) |
vertex_displace | GLSL + WGSL | Vertex stage, before world+clip transform | Displaces local position via the vrv_pos lvalue |
vertex_normal | GLSL + WGSL | Vertex stage, immediately after displace | Recomputes local normal via the vrv_normal lvalue |
All four are optional — omit any field to leave that stage unmodified. vertex_normal composes with normal mapping: the recomputed local normal flows through the normal matrix into the shading normal, but the tangent is still derived from the mesh's tangent attribute, not from vrv_normal — a drastically custom normal can mildly desync the tangent frame.
frag_emissive / frag_alpha are not wired through Material() in this build. command.zig's ShaderHooks struct also declares frag_emissive_glsl/_wgsl and frag_alpha_glsl/_wgsl slots, and pbrFragmentSrcHooked/wgslPbrHooked do splice them when present — but Material(comptime opts) itself never reads opts.frag_emissive or opts.frag_alpha; only frag_albedo, frag_final, vertex_displace, vertex_normal, .uniforms, and .textures are threaded through. Passing .frag_emissive / .frag_alpha fields into a Material({...}) call compiles (Zig does not flag unused fields on an anytype struct literal) but has zero effect on the assembled shader — see "Known limitations" below for what this means for the shipped demo material.
verve.gl.Material() descriptor
const verve = @import("verve");
const gl = verve.gl;
const holo = gl.Material(.{
.vertex_displace = .{
.glsl = "vrv_pos.y += sin(vrv_pos.x * 3.0 + u_time * 2.0) * 0.08;",
.wgsl = "vrv_pos.y = vrv_pos.y + sin(vrv_pos.x * 3.0 + u_time * 2.0) * 0.08;",
},
.vertex_normal = .{
.glsl = "vrv_normal = normalize(vrv_normal - vec3(3.0 * 0.08 * cos(vrv_pos.x * 3.0 + u_time * 2.0), 0.0, 0.0));",
.wgsl = "vrv_normal = normalize(vrv_normal - vec3<f32>(3.0 * 0.08 * cos(vrv_pos.x * 3.0 + u_time * 2.0), 0.0, 0.0));",
},
.frag_albedo = .{
// bare name `tint` — the comptime builder aliases it to the right backing slot.
.glsl = "vrv_albedo = mix(vrv_albedo, tint, v_uv.y); vrv_albedo = mix(vrv_albedo, texture(u_custom_tex0, v_uv).rgb, 0.4);",
.wgsl = "vrv_albedo = mix(vrv_albedo, tint, in.uv.y); vrv_albedo = mix(vrv_albedo, textureSample(custom_tex0, samp, in.uv).rgb, 0.4);",
},
.frag_final = .{
// bare name `u_time` — always present, auto-advanced by the bridge.
.glsl = "vrv_color = vrv_color + tint * (0.5 + 0.5 * sin(u_time * 2.0 + v_world_pos.y * 4.0));",
.wgsl = "vrv_color = vrv_color + tint * (0.5 + 0.5 * sin(u_time * 2.0 + in.world_pos.y * 4.0));",
},
.uniforms = .{ .tint = gl.Vec3 },
// .textures declares the URL; the shader accessor is ALWAYS the fixed name custom_tex0.
.textures = .{ .noise = .{ .url = "/gl/demo.tex0.png" } },
});opts.uniforms is a comptime struct mapping each field name to a type (f32, gl.Vec2, gl.Vec3, gl.Vec4). opts.textures maps each field name to .{ .url = "..." }. Material() returns a MaterialDesc:
pub const MaterialDesc = struct {
flags: u32, // variant_pbr | variant_custom [| variant_custom_tex]
wgsl: []const u8,
glsl_vs: []const u8,
glsl_fs: []const u8,
uniforms: []const UniformSlot, // declaration order
param_vec4_count: u8, // vec4 slots actually used (≤ 4)
id: u32, // fnv32(wgsl ++ "|" ++ glsl_fs) — stable identity
textures: []const TextureRef, // empty when .textures is not declared
};The material is FNV-frozen: the Zig compiler hashes the assembled GLSL and WGSL source at build time into id, exactly like every other PBR shader variant's golden hash.
Custom UBO (@group(0)@binding(5) / custom)
An 80-byte std140-packed uniform block is allocated per material and uploaded every frame via the set_custom wire tag (tag 48, frozen — see command.zig, "Encode a set_custom command"). Layout: u_time: f32 + 3×f32 pad (16 bytes), then params: array<vec4<f32>, 4> (64 bytes) carrying user uniform lanes. Supported uniform types: f32, gl.Vec2, gl.Vec3, gl.Vec4. A gl.Vec3 occupies a full vec4 slot — pack 4 floats with lane 3 as padding.
Snippet authors write uniforms by bare name (tint, u_time, …). The comptime builder prepends an alias preamble to every supplied hook that expands each bare name to its backing accessor (custom.params[i].<swizzle> in WGSL / u_params[i].<swizzle> in GLSL) and wires u_time to custom.u_time. You never write custom.params[...] or u_params[...] directly in snippet source.
Scene binding — GlSceneBuilder.material()
pub fn material(self: *GlSceneBuilder, desc: gl.MaterialDesc, params: []const f32) *GlSceneBuilderctx.glScene(.{ .src = "/gl/lodsphere.vmesh", .env = "/gl/studio.venv" })
.material(verve.gl.example_holo, &.{ 0.2, 0.6, 1.0, 0.0 }) // tint Vec3 packed as vec4; lane 3 = pad
.build();.material(desc, params) stores desc.id and copies up to custom_params_buf.len floats from params (core/gl_scene.zig). u_time is always present and always auto-advanced by the bridge — do not include it in params.
Mutually exclusive with .wireframe(): wireframe is a pure-replace shader path; a custom material is a lit-variant injection — the two can't share a compiled permutation. Calling both on the same scene returns a poison node from .build() (error.CustomMaterialConflict, core/gl_scene.zig). Custom × instanced, custom × double-sided, and custom × morph-skinning are not structurally checked by the builder — these combinations produce invalid shader variants per the material injection spec (they are unsupported/untested, not a variant-bitset capacity limit), so they are avoided by convention rather than guarded at runtime.
Custom textures
.textures declares one or more image assets bound alongside the material. Each declared texture occupies a reserved slot at GLSL texture unit 12 + i, WGSL @group(1)@binding(14 + i) — gated by variant_custom_tex = 1<<24, set automatically when .textures is non-empty. In snippets you sample it using the framework-fixed name custom_tex0 (WGSL) / u_custom_tex0 (GLSL) — the field name you choose in .textures (e.g. noise) is carried in MaterialDesc.textures[i].name for asset-loading purposes only; it never appears in the shader:
// GLSL snippet (e.g. in frag_albedo):
vec4 noise = texture(u_custom_tex0, v_uv);// WGSL snippet:
let noise = textureSample(custom_tex0, samp, in.uv);Live setter (glmat_set)
glmat_set(name_id: u32, v0: f32, v1: f32, v2: f32, v3: f32) is a GlScene chunk export that updates one uniform at runtime by writing directly into the instance's in-memory Custom UBO region (inst.custom_ubo); the next frame's set_custom re-upload picks up the change automatically — no new wire tag is needed. The first argument is a u32 name-id (the FNV-1a-32 hash of the uniform name), not a string — read it off the material descriptor's uniform table (example_holo.uniforms[i].name_id). This demo wires two zero-arg exports directly (z-on-click calls exports with no arguments):
export fn glmat_tint_red() void {
glmat_set(gl.example_holo.uniforms[0].name_id, 1.0, 0.2, 0.2, 0.0);
}
export fn glmat_tint_cyan() void {
glmat_set(gl.example_holo.uniforms[0].name_id, 0.2, 0.6, 1.0, 0.0);
}ctx.el("button").attr("z-on-click", "glmat_tint_red").text("Tint red"),
ctx.el("button").attr("z-on-click", "glmat_tint_cyan").text("Tint cyan"),glmat_set is a no-op if the scene has no active custom material or the name-id doesn't match a declared uniform.
Wire facts
Custom material config rides on the canvas data-glmat attribute (not Props, which stays a fixed 14 fields) — a plain-text string, not base64:
| Attribute | data-glmat |
| Format | "<u32 id>;<f0>,<f1>,…" — material id, ;, then a CSV of initial param floats |
| Emitted | Only when .material() is called |
| Wire tag | set_custom (tag 48) — 80-byte payload, re-sent every frame |
The chunk (client/islands/GlScene.zig, parseGlmat) splits once on ;, parses the id, and — in this build — only recognizes one fixed material: the id must equal gl.example_holo.id exactly, or the attribute is silently ignored (custom_on stays false). Malformed input does not crash. A matching id resolves to a fixed shader handle (custom_shader = 92) and fills up to 16 CSV floats into the params region of inst.custom_ubo.
Known limitations
- Single fixed material at runtime (v1):
parseGlmathardcodes the comparison againstgl.example_holo.id; there is no id→handle map yet, so only one compiled custom material can be active per page load. The source comment marks this a TODO for a future slice. frag_emissive/frag_alphaare unreachable viaMaterial(): see "Hook surface" above. Concretely, this means the shipped demo material (core/gl/demo_materials.zig'sexample_holo) declares.frag_emissive(au_timeglow pulse) and.frag_alpha(a noise-driven dissolve/discard) in itsoptsliteral, but neither is compiled into the frozen shader — its doc comment's "All 5 hooks" claim overstates what actually renders. The demo's real visible behavior isfrag_albedo(tint + noise texture blend) +frag_final(scanline pulse) + both vertex hooks.- No per-submesh custom materials — one material applies scene-wide.
- Custom × instanced, × wireframe, × double-sided, × morph-skinning are mutually exclusive in v1; only the wireframe combination is guarded with a build-time poison node, the rest are simply unsupported/untested.
- Runtime / data-driven shader source is not supported — snippet source must be known at comptime.
- App-authored materials are not supported — the material descriptor currently must live in framework code (
src/core/gl/demo_materials.zig); there is nobuild.zighook yet for apps to register their ownMaterial()descriptors from outside the framework tree.
Demo (/gl-material)
/gl-material renders lodsphere.vmesh with verve.gl.example_holo applied: frag_albedo blends the surface toward a tint uniform (initially cyan, vec3(0.2, 0.6, 1.0)) weighted by UV.y and mixes in a noise texture (demo.tex0.png) at 40%; frag_final adds a u_time-driven scanline pulse tinted the same color; vertex_displace applies a u_time sine wobble along local Y; vertex_normal recomputes the surface normal to track the displaced geometry. "Tint red" / "Tint cyan" call glmat_tint_red / glmat_tint_cyan, which call glmat_set to swap the tint live, no reload. A texture-format indicator (data-ref="gltex-hud") shows BC7 when the compressed texture upload path is live, PNG on the fallback (append ?nobc7 to the URL to force it). Both WebGL2 and WebGPU backends. Drag to orbit, wheel to zoom.