Skip to content

Custom shaders in a layer

A CustomDrawable passed to render([...]) renders in a fixed phase — "underlay" (before every layer) or "overlay" (after every layer). That is the right tool for a full-view background or a post-scene effect, but it cannot land between two primitives of a single layer.

layer.pushCustom(drawable) does. It pushes a custom-shader drawable into a layer at that exact submission point, and the drawable renders at that depth relative to the layer’s other shapes, glyphs, and sprites — no placement flag, no separate render list.

layer.pushCustom(plantOutline); // custom WGSL quad (outline shader)
layer.pushSprite(plant); // plant sprite, drawn over its outline
layer.pushSprite(mound); // mound, drawn over the outline's base

The outline is a raw pipeline you own, yet it slots into the sprite stack by push order — the outline under its plant, the mound over the outline’s base — with no manual z bookkeeping.

Custom Shaders — In-Layer Interleave A fanned row of glowing chips where each chip's soft halo is a custom WGSL quad pushed into the layer right before it — so the layering follows push order, not a fixed underlay/overlay phase.

Everything a layer draws shares one unified push-order depth domain: the renderer walks the layer’s shape/glyph/sprite/custom pushes in submission order, assigns each a depth, and depth-tests them together. Your drawable joins that domain by writing the depth the renderer hands it:

import { createCustomDrawable } from "insomni";
const outline = createCustomDrawable("ui", (pass, ctx) => {
// ctx.depth is this drawable's reconciled place in the layer's push order.
device.queue.writeBuffer(depthUniform, 0, new Float32Array([ctx.depth]));
pass.setPipeline(pipeline);
pass.setBindGroup(0, ctx.cameraBindGroup); // if your shader uses the camera
pass.setBindGroup(1, myBindGroup);
pass.draw(4);
});
layer.pushCustom(outline);
// In your vertex shader — the whole author-side cost:
out.position = vec4f(xy, u.depth, 1.0); // u.depth ← ctx.depth

Then pick a depth mode that matches your fragment, exactly like the built-in kinds:

  • Opaque output → depthWriteEnabled: true, depthCompare: "less". It occludes anything pushed before it and is occluded by anything opaque pushed after.
  • Transparent output → depthWriteEnabled: false, depthCompare: "less". It blends over what was pushed before and is covered by opaque content pushed after — the standard painter behavior sprites already use.

ctx also carries device, encoder, the applied scissor, the layer’s space, and the frame’s colorView / depthView / viewport. Do not begin or end a render pass — you are handed an active one.

The pushed drawable rides alongside the layer’s sprites: in the single interleaved painter pass (no OIT), or in the post-resolve sprite pass (OIT). It is drawn once per frame, never twice.

OIT ceiling. With OIT enabled, a pushed drawable composites post-resolve, so it interleaves by depth with the layer’s opaque sprites and opaque shapes but sits over the already-resolved transparent scene — the same limitation sprites and top-level overlay drawables have. If you need to interleave into dense transparent geometry, keep that geometry in the same layer as ordinary primitives.

You want…Reach for
A custom quad at a point in a layer’s stacklayer.pushCustom(drawable) (this page)
A full-view background / post-scene effecttop-level CustomDrawable with placement
A textured quad with a WGSL fragment, no pipelinecreateTextureEffect
  • Custom shaderscreateTextureEffect and the two shader shapes.
  • CustomDrawable — the raw render-pass hook, and its top-level placement phases.
  • Layers & groups — how a layer batches shapes, glyphs, and sprites.