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);

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
  attributes: ["position", "uv"], // Geometry attributes to bind (default)
  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

Textures

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
frame.lightViewProjection2 mat4x4f Shadow cascade 2 matrix