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 |