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 |