Theme:

Shaders

Backend.Shader

Write custom WebGPU shaders without GPU boilerplate. You provide WGSL code and a uniforms object — the engine handles buffer creation, pipeline setup, uniform packing, and rendering.

Minimal Example

const Backend = world.renderer;

const mesh = new U.Mesh(new U.PlaneGeometry(1, 1), new Material());

Backend.Shader({
  uniforms: {
    color: new U.Color(1, 0, 0),
    modelMatrix: new U.Matrix4(),
  },
  code: /* wgsl */ `
struct VIn {
    @location(0) position: vec3f,
    @location(1) uv: vec2f,
}
struct VOut {
    @builtin(position) pos: vec4f,
    @location(0) uv: vec2f,
}

@vertex fn vs_main(v: VIn) -> VOut {
    var o: VOut;
    o.pos = frame.viewProjection * uniforms.modelMatrix * vec4f(v.position, 1.0);
    o.uv = v.uv;
    return o;
}

@fragment fn fs_main(v: VOut) -> @location(0) vec4f {
    return vec4f(uniforms.color, 1.0);
}
`,
}).attach(mesh);

world.add(mesh);

Never use a backtick inside WGSL code

Never put a backtick anywhere inside code (including comments), because it prematurely closes the surrounding JS template literal.

What You Write vs What Gets Auto-Generated

You write the shader logic — VIn, VOut, vs_main, fs_main, and any helper functions. The system auto-prepends:

// Auto-generated Frame struct (camera, lighting, shadows, time)
struct Frame {
    view: mat4x4f,
    projection: mat4x4f,
    viewProjection: mat4x4f,
    cameraPos: vec4f,
    time: vec4f,              // time.x = elapsed seconds
    skyLightDirection: vec4f,
    skyLightColor: vec4f,
    ambientLightColor: vec4f,
    lightViewProjection0: mat4x4f,
    lightViewProjection1: mat4x4f,
    lightViewProjection2: mat4x4f,
}

// Auto-generated from your JS uniforms object (alignment handled for you)
struct Uniforms {
    color: vec3f,
    modelMatrix: mat4x4f,
}

@group(0) @binding(0) var<uniform> frame: Frame;
@group(1) @binding(0) var<uniform> uniforms: Uniforms;

// --- your code starts here ---

You reference frame.* and uniforms.* directly in your shader code.

API

const shader = Backend.Shader({
  // Required
  code: "...", // Your WGSL (vs_main + fs_main)

  // Optional
  label: "MyShader", // Debug label (default: 'CustomShader')
  uniforms: {}, // JS object → auto-generates WGSL struct
  textures: {}, // Named Texture objects → texture/sampler WGSL bindings
  mainShadows: false, // Bind the two main shadow cascades and comparison sampler
  attributes: ["position", "uv"], // Geometry attributes to bind (default)
  instanced: false, // Batch compatible logical shader meshes (default false)
  sortInstances: "none", // Instanced only: "none" or "front-to-back"
  depthWrite: true, // Write to depth buffer (default: true)
  depthCompare: "less", // Depth test function (default: 'less')
  cullMode: "back", // Face culling (default: 'back')
  topology: "triangle-list", // Primitive topology (default: 'triangle-list')
  blend: undefined, // GPUBlendState for transparency (default: opaque)
});

shader.attach(mesh); // Creates GPU buffers from mesh.geometry, sets mesh.render
shader.uniforms; // Same object you passed in — mutate it per-frame
await shader.ready; // Resolves after named textures are decoded and uploaded
shader.dispose(); // Releases this shader's GPU resources

Render order

Set mesh.renderOrder to choose when a custom shader draws in the main color pass. Lower numbers draw first. The default is 0. Use the named anchors from world.renderer.RenderOrder and choose numbers between them when several effects need a deliberate sequence:

Anchor Value Engine draw
BEFORE_OPAQUE -2000 Available for custom passes
OPAQUE -1000 Main opaque batch
WORLD 0 Default custom shaders and background
TEXT_GEOMETRY 200 Text geometry batch
TRANSPARENT 300 Main transparent batch
SKINNED 500 Skinned mesh batch
TEXT 600 Text system
UI 1000 Engine 3D UI and mirrors
DEBUG 10000 Navigation debug

The gaps are available to custom shaders. For example, -500 draws after the main opaque batch and before default custom shaders; 750 draws after text and before engine UI. Set the order before attaching the shader when possible:

const order = world.renderer.RenderOrder;
mesh.renderOrder = order.OPAQUE + 100;
shader.attach(mesh);
world.add(mesh);

// Later changes take effect on the next frame, including instanced shaders.
mesh.renderOrder = order.TEXT + 50;

Changing an instanced shader's order moves it to a different draw batch. Meshes with the same order can batch together; distinct orders can require distinct draws. Equal values have no explicit secondary priority, so use different numbers when sequence matters. These values order draws within the main pass; they do not move a shader before shadow, reflection, or other earlier passes. Ordinary engine meshes are grouped into opaque and transparent batches, so setting renderOrder on one ordinary mesh cannot place a custom shader between individual meshes in either batch. Depth testing still controls which drawn surfaces are visible.

Avatar attachments

Custom-shader meshes can be parented beneath the local or remote avatar, including directly to humanoid bones. The avatar attachment system registers their custom render callbacks and keeps their matrixWorld and effective visibility synchronized just like ordinary batched meshes:

const effect = new U.Mesh(geometry, material);
const shader = world.renderer.Shader({
  uniforms: { modelMatrix: new U.Matrix4() },
  code,
});
shader.attach(effect);
world.avatar.chest.add(effect);
effect.sync();

Removing the attachment with world.remove(effect) unregisters the renderable and disposes its shader resources.

Instanced custom shaders

Set instanced: true to combine compatible custom-shader meshes into one GPU instanced draw. Each Shader(...) call still owns its own uniforms object and attaches to one mesh, so gameplay code can update each logical effect independently:

const shader = Backend.Shader({
  instanced: true,
  uniforms: {
    modelMatrix: new U.Matrix4(),
    progress: 0,
    color: new U.Color("#ffb52e"),
  },
  code: shaderCode,
});
shader.attach(mesh);

Compatible instances must use identical WGSL, uniform layouts, vertex attributes, textures, blend/depth/cull/topology settings, geometry objects, texture color-space roles, sortInstances, and renderOrder. The renderer packs the uniforms of visible instances into a storage buffer, supplies the mesh's current matrixWorld when a modelMatrix uniform exists, and grows the instance buffer as needed. Invisible instances are omitted from the draw.

Set sortInstances: "front-to-back" to reorder visible instances by their mesh origins relative to the current camera on each render pass. This preserves one draw per compatible geometry and render order, but adds CPU sorting work. It does not sort individual triangles inside an instance or interleave separate geometry batches. XR sorts for each eye. Opaque, depth-writing effects are the intended use; transparent blending usually needs back-to-front ordering.

Instanced WGSL uses the documented VIn, VOut, vs_main(input: VIn), var output: VOut, and fs_main(input: VOut) structure used by the examples on this page. The engine injects the instance index and makes the current record available under the existing uniforms name inside both entry points, so ordinary uniforms.value expressions do not need to change. Uniform references in helper functions outside those entry points are not supported in instanced mode; pass required values into the helper instead.

Additive effects are ideal candidates. Alpha-blended instances are drawn in batch order rather than sorted individually, so use them only when that ordering is visually acceptable. Different geometries or render orders automatically form separate draws.

Textures

Set mainShadows: true to access the renderer's two shadow cascades from a custom shader, including an instanced shader. This adds mainShadowMap: texture_depth_2d_array and mainShadowSampler: sampler_comparison to WGSL. Use frame.lightViewProjection0 and frame.lightViewProjection1 to project a world position, convert clip XY to shadow UV (flipping Y), and select array layer 0 or 1. A single comparison tap is textureSampleCompareLevel(mainShadowMap, mainShadowSampler, uv, layer, depth). The shader must select a cascade whose UV lies within [0, 1]; outside both cascades, treat the point as unshadowed. Custom shaders receive shadows but do not cast them through this option.

Pass loaded or placeholder Unia textures by name. The engine loads them through the normal texture decoder, including TEX1/Basis BC7 and ASTC sources, then creates a texture and sampler binding for each name:

const shader = Backend.Shader({
  textures: {
    albedo: mesh.material.map,
  },
  uniforms: {
    modelMatrix: new U.Matrix4(),
  },
  code: /* wgsl */ `
// `textures.albedo` generates these declarations automatically:
// @group(1) @binding(1) var albedoTexture: texture_2d<f32>;
// @group(1) @binding(2) var albedoSampler: sampler;

struct VIn {
    @location(0) position: vec3f,
    @location(1) uv: vec2f,
}
struct VOut {
    @builtin(position) position: vec4f,
    @location(0) uv: vec2f,
}

@vertex fn vs_main(input: VIn) -> VOut {
    var output: VOut;
    output.position = frame.viewProjection *
        uniforms.modelMatrix * vec4f(input.position, 1.0);
    output.uv = input.uv;
    return output;
}

@fragment fn fs_main(input: VOut) -> @location(0) vec4f {
    return textureSample(albedoTexture, albedoSampler, input.uv);
}
`,
});

await shader.ready;
shader.attach(mesh);

Naming Textures

The keys in textures are names you choose; albedo, normal, and detail are examples, not required names. Each key creates a WGSL texture and sampler with Texture and Sampler appended to that key:

textures: {
  grass: grassTexture,
  noiseMap: noiseTexture,
}
let grassColor = textureSample(grassTexture, grassSampler, input.uv);
let noise = textureSample(noiseMapTexture, noiseMapSampler, input.uv);

Choose any unique name that is a valid WGSL identifier: letters, digits, and underscores are safe, but the name cannot start with a digit. Avoid spaces and hyphens. The name is only a label; calling a texture normal does not perform normal-map decoding or lighting automatically.

Texture wrapping metadata selects the sampler behavior. Color-space handling follows the attached mesh material automatically: its normalMap uses a linear GPU view, while its color and environment textures use sRGB views. Pass the same texture objects assigned to the material; no color-space option is required. Standalone string asset keys are treated as sRGB color textures.

Custom-shader textures are loaded independently of distance-based scene texture streaming. GPU uploads are cached per asset and shared by shader instances, so several shaders using the same asset retain one GPU texture. world.remove(mesh) releases the shader automatically; call shader.dispose() when detaching one manually.

Raycasting

After a custom-shader mesh is registered with world.addRaycastable(mesh), it raycasts its original CPU triangle geometry by default, matching an ordinary mesh. Fragment-only effects therefore require no special raycast setup.

A vertex shader can move rendered vertices without changing the CPU geometry. When bounds-only interaction is preferable, opt in on that mesh:

mesh.raycastBoundsOnly = true;
world.addRaycastable(mesh);

raycastBoundsOnly is undefined by default. Only strict true uses the world-space axis-aligned bounding box; undefined, null, and false retain full-geometry raycasting. Ensure geometry.boundingBox contains the maximum vertex displacement. If the ray starts inside the box, the reported bounds hit distance is 0.

Uniform Types

The uniforms object maps JS types to WGSL types automatically:

JS Type WGSL Type Example
number f32 opacity: 1.0
U.Vector2 vec2f offset: new U.Vector2()
U.Vector3 vec3f direction: new U.Vector3()
U.Vector4 vec4f clipPlane: new U.Vector4()
U.Color vec3f color: new U.Color(1,0,0)
U.Matrix4 mat4x4f modelMatrix: new U.Matrix4()

Struct alignment and padding are handled automatically. You do not need _pad fields.

Updating Uniforms Per-Frame

Mutate the uniforms object directly. Changes are uploaded to the GPU every frame.

// In your update loop:
shader.uniforms.opacity = 0.5;
shader.uniforms.color.set(0, 1, 0);
shader.uniforms.modelMatrix.copy(mesh.matrixWorld);

For modelMatrix, call mesh.updateMatrixWorld(true) before copying:

mesh.updateMatrixWorld(true);
shader.uniforms.modelMatrix.copy(mesh.matrixWorld);

Vertex Inputs

By default, the system binds position (vec3f) and uv (vec2f) from the mesh geometry:

struct VIn {
    @location(0) position: vec3f,  // geometry.attributes.position
    @location(1) uv: vec2f,        // geometry.attributes.uv
}

To use different or additional attributes, pass attributes:

Backend.Shader({
  attributes: ["position", "uv", "normal"],
  code: /* wgsl */ `
struct VIn {
    @location(0) position: vec3f,
    @location(1) uv: vec2f,
    @location(2) normal: vec3f,
}
// ...
`,
});

The @location(N) index matches the order in the attributes array.

Packed Model Geometry

Custom shaders also accept meshes loaded with Unia's packed geometry format. shader.attach(mesh) automatically exposes the packed position, normal, and uv data through the same logical attributes documented above, so shader code does not need a separate packed vertex-input layout.

Packed attributes are decoded once per geometry and cached. Custom attributes that are not stored in the packed model must still have an accessible geometry.attributes[name].array; otherwise shader initialization reports which attribute is unavailable.

Transparency

Pass a blend state for transparent rendering:

Backend.Shader({
  blend: {
    color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha" },
    alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha" },
  },
  uniforms: {
    opacity: 0.5,
    color: new U.Color(1, 1, 1),
    modelMatrix: new U.Matrix4(),
  },
  code: /* wgsl */ `
struct VIn { @location(0) position: vec3f, @location(1) uv: vec2f }
struct VOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f }

@vertex fn vs_main(v: VIn) -> VOut {
    var o: VOut;
    o.pos = frame.viewProjection * uniforms.modelMatrix * vec4f(v.position, 1.0);
    o.uv = v.uv;
    return o;
}

@fragment fn fs_main(v: VOut) -> @location(0) vec4f {
    return vec4f(uniforms.color, uniforms.opacity);
}
`,
}).attach(mesh);

Full Example: Animated Grid

const gridUniforms = {
  gridColor: new U.Color("#23262e"),
  baseColor: new U.Color("#30353d"),
  elementWidth: 0.05,
  elementHeight: 0.05,
  lineWidth: 0.004,
  scroll: new U.Vector2(0, 0),
  modelMatrix: new U.Matrix4(),
};

Backend.Shader({
  label: "AnimatedGrid",
  uniforms: gridUniforms,
  code: /* wgsl */ `
struct VIn {
    @location(0) position: vec3f,
    @location(1) uv: vec2f,
}
struct VOut {
    @builtin(position) pos: vec4f,
    @location(0) uv: vec2f,
}

@vertex fn vs_main(v: VIn) -> VOut {
    var o: VOut;
    o.pos = frame.viewProjection * uniforms.modelMatrix * vec4f(v.position, 1.0);
    o.uv = v.uv;
    return o;
}

fn linear_to_srgb(color: vec4f) -> vec4f {
    let cutoff = color.rgb <= vec3f(0.0031308);
    let higher = pow(color.rgb, vec3f(0.41666)) * 1.055 - vec3f(0.055);
    let lower = color.rgb * 12.92;
    return vec4f(select(higher, lower, cutoff), color.a);
}

@fragment fn fs_main(v: VOut) -> @location(0) vec4f {
    var uv = v.uv + uniforms.scroll;

    let coord = uv / vec2f(uniforms.elementWidth, uniforms.elementHeight);
    let grid = fract(coord);
    let fw = fwidth(coord);

    let lw = vec2f(
        uniforms.lineWidth * uniforms.elementWidth,
        uniforms.lineWidth * uniforms.elementHeight,
    );
    let smoothGrid = abs(grid - 0.5) - (0.5 - lw / vec2f(uniforms.elementWidth, uniforms.elementHeight));
    let edge = fw * 2.0;
    let g = min(
        smoothstep(-edge.x, edge.x, smoothGrid.x) + smoothstep(-edge.y, edge.y, smoothGrid.y),
        1.0,
    );

    let color = mix(uniforms.baseColor, uniforms.gridColor, g);
    return linear_to_srgb(vec4f(color, 1.0));
}
`,
}).attach(gridMesh);

// Per-frame update
gridUniforms.scroll.set(xScroll, yScroll);
gridMesh.updateMatrixWorld(true);
gridUniforms.modelMatrix.copy(gridMesh.matrixWorld);

Available Frame Uniforms

These are available in any shader via frame.*:

Field Type Description
frame.view mat4x4f Camera view matrix
frame.projection mat4x4f Camera projection matrix
frame.viewProjection mat4x4f Combined view * projection
frame.cameraPos vec4f Camera world position (xyz)
frame.time vec4f Elapsed time in .x
frame.skyLightDirection vec4f Directional light direction
frame.skyLightColor vec4f Directional light color * intensity
frame.ambientLightColor vec4f Ambient light color * intensity
frame.lightViewProjection0 mat4x4f Shadow cascade 0 matrix
frame.lightViewProjection1 mat4x4f Shadow cascade 1 matrix