WebGL
verve.gl computes the scene graph, culling, and a binary draw-command stream in Zig/WASM; the bridge JS is a dumb WebGL2 interpreter over shared linear memory.
See it live: WebGL viewer and the declarative scene.
Declarative scenes — ctx.glScene
The high-level path: declare the scene at render time, get an island that drives it.
const scene = ctx.glScene(.{
.src = "/gl/demo.vmesh", // model
.env = "/gl/studio.venv", // prefiltered environment (IBL)
.poster = poster_svg_data_uri, // shown before hydration
})
.camera(.{ .distance = 4, .pitch = 0.3, .yaw = 0.6 })
.light(.{ .dir = .{ -0.4, -0.7, -0.6 }, .intensity = 3.0 })
.autoRotate(0.4) // rad/s; or:
.scrub(true) // scroll drives yaw over a 300vh section
.onPick("Cube", 0) // named pickable mesh (max 4)
.build();SSR emits the <canvas> (plus poster) inside a GlScene island; the chunk decodes the frozen props and runs a WebGL2 loop: orbit camera (drag), wheel zoom, click picking by mesh name. scrub(true) wraps the canvas in a sticky 300vh scroll section and zeroes auto-rotate.
Distance fog
.fog(FogOpts) adds atmospheric depth. Four modes: .linear fades from near to far by distance; .exp and .exp2 use a density coefficient for exponential falloff; .none disables (default).
ctx.glScene(.{ .src = "/gl/scene.vmesh", .env = "/gl/studio.venv" })
.fog(.{
.mode = .exp2,
.color = .{ 0.42, 0.5, 0.62 },
.density = 0.05,
})
.build();Fog is applied after PBR shading, before tonemapping. See the gl-fog demo.
Lights
Beyond the single directional light from .light(), you can add point lights, spot lights, and up to four mixed lights via .lights().
ctx.glScene(.{ .src = "/gl/room.vmesh", .env = "/gl/studio.venv" })
.lights(&.{
.{ .kind = .directional, .dir = .{ -0.4, -0.7, -0.6 }, .intensity = 1.5 },
.{ .kind = .point, .pos = .{ 0, 2, 0 }, .color = .{ 1, 0.8, 0.5 },
.intensity = 8, .range = 6 },
.{ .kind = .spot, .pos = .{ 0, 3, 0 }, .dir = .{ 0, -1, 0 },
.intensity = 10, .inner_deg = 15, .outer_deg = 25, .range = 8,
.casts_shadow = true },
})
.build();Or append one at a time: .spotLight(Light) / .pointLight(Light). Max 4 lights per scene. Set casts_shadow = true on one light to enable shadow maps (one caster per scene).
See the spot light demo and point light demo.
Shadows
One light per scene can cast shadows (casts_shadow = true). The variant is chosen automatically by light type:
- Directional — ortho depth pass, 3×3 PCF. See gl-shadow.
- Spot — perspective depth pass (fov = 2 ×
outer_deg), PCF. See gl-spot. - Point — 6-face distance atlas (1536×4096, 3×8 of 512² tiles, 6 faces × up to 4 casters), PCF. See gl-point.
Multi-shadow (multiple lights casting simultaneously) is shown in gl-multishadow. Cascaded shadow maps (CSM) for directional lights at large scales: gl-csm.
Morph targets (blend shapes)
Set initial blend weights with .morphWeights([]const f32). Weight index i maps to the i-th morph target in the vmesh.
ctx.glScene(.{ .src = "/gl/head.vmesh", .env = "/gl/studio.venv" })
.morphWeights(&.{ 0.0, 0.0, 0.0 }) // three targets, all off
.build();Weights are animated at runtime via the morph:<i> anim target (see Advanced Animation). Wire up runtime toggles with z-on-click exports: glmorph_bulge_on / glmorph_reset.
See the gl-morph demo.
GPU instancing
A vmesh flagged for instancing renders all instances in one draw call via EXT_mesh_gpu_instancing. No builder opt needed — the vmesh encodes the instance data; GlScene emits a draw_pbr_instanced command. See the gl-instanced demo (256 cubes, 16×16 grid).
Double-sided materials
glTF doubleSided: true materials disable back-face culling for opaque/MASK submeshes and render two passes (FRONT + BACK) for alpha-blend. No builder opt — baked into the vmesh. See gl-double.
Alpha-test cutout
glTF alphaMode: "MASK" submeshes discard fragments below alphaCutoff (default 0.5) in the opaque pass — no blending, full depth writes, compatible with shadows. See gl-cutout.
Transparency
glTF alphaMode: "BLEND" submeshes render in a second back-to-front sorted pass with depth writes off. Animate baseColorA on a material via the material:Name.baseColorA anim target. No builder opt — driven by glTF material flags baked into the vmesh.
Area lights (LTC)
Rectangular LTC area lights model real-world soft illumination. Each AreaLight is defined by pos, two half-edge vectors (ex, ey), and color/intensity. The LUT is fetched from /gl/ltc.bin on first use.
ctx.glScene(.{ .src = "/gl/room.vmesh", .env = "/gl/studio.venv" })
.areaLight(.{
.pos = .{ 0, 2, 0 },
.ex = .{ 0.6, 0, 0 }, // half-width
.ey = .{ 0, 0, 0.4 }, // half-height
.color = .{ 1, 0.9, 0.7 },
.intensity = 5,
.casts_shadow = true,
})
.build();Max 4 area lights per scene. See the gl-area demo.
Asset pipeline
Models and environments are preprocessed into compact binary formats:
.vmesh— mesh + material views generated from glTF bytools/gl_asset_gen.zig.venv— prefiltered environment maps for image-based lighting (tools/gen_demo_hdr.ziggenerates a demo one)
Chunks fetch them via gl_load into a page-scoped GPU asset region; rendering is Cook-Torrance PBR under IBL with direct lights.
Low-level scenes
For custom rendering, the gl core exposes Scene, Mesh, Material, and command encoding — your chunk builds the command buffer; the interpreter replays it. The shipped GlDemo island drives two canvases this way (rotating unlit cube + PBR model).
The one-chunk rule, again
GL islands are stateful chunks (scene state, command buffers, rodata). Two stateful chunks on one page link their data at the same 0x1000 base in shared linear memory and clobber each other — this is why GlDemo hosts both canvases in a single chunk. One GL island per page; merge multi-canvas demos into one chunk.
Next: Desktop.