Shader Configuration
Replace built-in rendering shaders with custom WGSL, applied globally or per-type.
Mods can replace the built-in rendering shaders with custom WGSL (the WebGPU Shading Language). Shaders are declared in a single config file per mod and can be applied globally (affecting all objects of a category) or per-type (targeting a specific mob, static object, weather type, or biome). Status icons (health, oxygen), inventory items, and block/static-object break particles can also be overridden globally.
Each override is one .wgsl file containing these entry points:
vs_main— the@vertexstagefs_main— the single-output@fragmentstage for surface renderingfs_world— required for world domains; writes scene color at@location(0)and emission at@location(1)
File Location
mods/<your-mod>/shaders.yaml
WGSL source files are placed anywhere under the mod folder and referenced by path relative to the mod root:
mods/<your-mod>/shaders/my_shader.wgsl
Minimal Example
shaders:
- name: my_entity_shader
source: shaders/my_entity_shader.wgsl
This registers a shader named YourMod:my_entity_shader. Because no type: is given, it defaults to entity.
Full File Reference
# Global default overrides (all optional)
default-shader: my_entity_shader # applies to all entities/mobs
default-block-shader: my_block_shader # applies to all block chunks
default-static-object-shader: my_static_shader # applies to all static objects
default-particle-shader: my_particle_shader # applies to all particles
default-weather-shader: my_weather_shader # applies to all weather types
default-biome-backdrop-shader: my_biome_shader # applies to all biome backdrops
default-icon-shader: my_icon_shader # applies to all status icons (health, oxygen)
default-item-shader: my_item_shader # applies to all inventory and hotbar items
default-break-particle-shader: my_break_shader # applies to block and static-object break particles
shaders:
- name: my_entity_shader # Required. Name used to reference this shader.
source: shaders/entity.wgsl # Required. Path to the WGSL file, relative to mod root.
type: entity # Optional. One of: entity, block, static-object, particle,
# weather, biome-backdrop, icon, item, break-particle.
# Defaults to entity if omitted.
- name: my_block_shader
source: shaders/block.wgsl
type: block
- name: my_break_shader
source: shaders/break.wgsl
type: break-particle
Shader Types
| Type | Affects | Global Default Key | Per-Type Override |
|---|---|---|---|
entity | Mobs and creatures | default-shader | Yes — via shader: in mob config |
block | Block chunk rendering | default-block-shader | No |
static-object | Interactables and trees | default-static-object-shader | Yes — via shader: in interactable/tree config |
particle | All particles | default-particle-shader | No |
weather | Weather layer rendering | default-weather-shader | Yes — via client.shader in weather config |
biome-backdrop | Biome background layers | default-biome-backdrop-shader | Yes — via shader: in biome config |
icon | Status icons (health, oxygen) | default-icon-shader | No |
item | Inventory and hotbar items | default-item-shader | No |
break-particle | Block and static-object break particles | default-break-particle-shader | No |
Block, particle, icon, item, and break-particle shaders can only be set globally. Per-type overrides for these categories are not supported.
Shader Names and Namespacing
If you write a shader name without a colon (e.g., MY_SHADER), it is automatically prefixed with your mod name at load time: YourMod:my_shader.
You can reference a shader from another mod by using its fully-qualified name: OtherMod:their_shader.
Per-Type Shader Assignments
Mobs
Add shader: to a mob entry in your mod’s mob config:
- name: my_creature
shader: my_entity_shader
# ... rest of mob config
The value can be a short name (auto-prefixed with your mod name) or a fully-qualified name with a colon.
Static Objects (Interactables and Trees)
Add shader: to the relevant entry in your interactable or tree config:
- name: my_chest
shader: my_static_shader
# ... rest of interactable config
Weather Types
Add shader: to the client: block of the relevant entry in your mod’s weather config:
weathers:
- name: blizzard
timing:
# ... min-ms / max-ms / transition-out-ms
client:
shader: my_weather_shader
# ... layers, etc.
Biomes
Add shader: to the relevant entry in your mod’s biome config:
biomes:
- name: crystal_cavern
shader: my_biome_shader
# ... rest of biome config
Shader Contracts
Every shader type has a fixed contract: the set of vertex attribute @locations the engine feeds vs_main, and the set of @group/@binding resources the engine provides. Your shader is checked against its contract at load time:
vs_mainmay only consume@locations from the allowed set (you don’t have to consume all of them).- Any
@group(G) @binding(B)your entry points actually use must exist in the engine-provided set. Declared-but-unused bindings are ignored. - Built-in inputs (
@builtin(vertex_index),@builtin(instance_index), …) are always allowed.
A shader that violates its contract, fails to parse, or fails validation is rejected: a warning is written to logs/client.log and the engine’s built-in shader is used instead. No game crash occurs.
In every contract below, @location(0) position / @location(1) tex_coords are the shared unit quad (vec2<f32> each, positions in [0,1]²); the remaining locations are per-instance attributes.
World Domains and the Lighting Prelude
The entity, block, and static-object domains are lit world passes. Before compiling, the engine prepends assets/shaders/lighting_common.wgsl to your source, so:
- The prelude declares group 0 resources and group 1 bindings 0 (
sector) and 4 (directional_visibility_tex). Do not redeclare those bindings. - Declare group 1 binding 1 as
texture_2d<f32>(static light), binding 2 assampler(nearest), and binding 3 astexture_2d<f32>(dynamic light). Sample both textures at the same sector UV and pass their values plussector_directional_visibility(sector_uv)tocomposite_lighting. sector_light_uvconverts fragment screen positions to sector UVs for actors. Usesector_sky_light(sector_light_uv(fragment_position.xy))for spatial skylight matching terrain; an unavailable sky chunk returns zero.sky_light_factorandcomposite_lightingremain available.dynamic_light_colorand ray helpers serve the bake passes; the main-passframe.light_countis zero.- Study the engine originals in
assets/shaders/(entity_instanced.wgsl,block.wgsl,static_object.wgsl) — the easiest way to write an override is to copy one and edit its sharedshade_worldhelper.
The prelude’s frame (FrameUniforms) exposes these per-frame values, readable from any world-domain shader:
| Field | Type | Description |
|---|---|---|
time_ms | f32 | Monotonic frame clock in milliseconds, for time-driven effects — sin(frame.time_ms * k + phase) sway, glow pulses, scrolling. |
player_world_x / player_world_y | f32 | Player world-space position in blocks (finer than player_chunk_x/y), for per-vertex/per-pixel distance falloff. |
wind | f32 | Signed wind scalar (base + gust) from the active weather; 0.0 when calm. Sourced client-side from the weather’s client.wind-base/wind-gust (see the weather config). |
occlusion_min | vec2<i32> | Origin of the uploaded occlusion rectangle in floor-based 32-block chunk coordinates, including zero. |
occlusion_size | vec2<u32> | Width and height of that rectangle in chunks; x-major chunk index is x * frame.occlusion_size.y + y. |
sun_directional / moon_directional | vec4<f32> | Celestial inputs: unit receiver-to-body direction in world X-right/Y-up (xy), horizon-faded directional share (z), and finite reach in blocks (w). Driven by synchronized world time. The shared sector map supplies directional visibility. |
directional_unavailable | u32 | Nonzero when the view/subscription or device capacity cannot support the full directional footprint. The shared sampling helper returns zero direct visibility without reading the map; ambient fill remains. |
(player_chunk_x/y, screen_width/height, multiplier, sunlight_*/moonlight_*, light_count, blocks_in_chunk, block_pixel_size are also available — see lighting_common.wgsl.)
Non-world domains (particle, weather, biome-backdrop, icon, item, break-particle) get no prelude — your file must declare the bindings it uses, matching the tables below.
Entity (entity)
Pattern: assets/shaders/entity_instanced.wgsl. Lighting prelude prepended.
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | Unit quad UV |
| 2 | pos_px | vec2<f32> | Screen-space top-left of the entity (pixels) |
| 3 | frame_size | vec2<f32> | Animation frame size (pixels) |
| 4 | entity_pos | vec2<f32> | World-space center position |
| 5 | tex_index | i32 | Layer in the entity texture array (frame included) |
| 6 | flip_x | i32 | 1 = flip horizontally |
| 7 | sky_light | i32 | Sky light level (0-15) |
| 8 | rotation | vec4<f32> | 2×2 rotation matrix, column-major |
| 9 | tint | vec4<f32> | RGB tint and mix amount in alpha |
Bindings (beyond the prelude’s groups 0/1): @group(2) @binding(0) texture_2d_array<f32> (sprites), @group(2) @binding(1) texture_2d_array<f32> (light masks), @group(2) @binding(2) sampler, @group(2) @binding(3) texture_2d_array<f32> (straight RGB emission).
Block (block)
Pattern: assets/shaders/block.wgsl. Lighting prelude prepended.
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | UV into the pre-baked chunk texture |
Bindings (beyond groups 0/1): @group(2) @binding(0) texture_2d<f32> (chunk texture), @group(2) @binding(1) sampler, @group(2) @binding(2) texture_2d<f32> (straight RGB emission).
Static Object (static-object)
Pattern: assets/shaders/static_object.wgsl. Lighting prelude prepended. Same as entity minus rotation:
| Location | Attribute | Type |
|---|---|---|
| 0 | position | vec2<f32> |
| 1 | tex_coords | vec2<f32> |
| 2 | pos_px | vec2<f32> |
| 3 | frame_size | vec2<f32> |
| 4 | entity_pos | vec2<f32> |
| 5 | tex_index | i32 |
| 6 | flip_x | i32 |
| 7 | sky_light | i32 |
Bindings (beyond groups 0/1): @group(2) @binding(0)/(1) texture arrays, @group(2) @binding(2) sampler, @group(2) @binding(3) var<uniform> ObjectDrawUniforms (per-draw rotation + padded array frame size), @group(2) @binding(4) texture_2d_array<f32> (straight RGB emission).
Particle (particle)
Pattern: assets/shaders/particle.wgsl.
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | Unit quad UV |
| 2 | pos | vec2<f32> | World-space particle position |
| 3 | vel | vec2<f32> | Particle velocity |
| 4 | life | f32 | Remaining lifetime in seconds (<= 0 = dead) |
| 5 | particle_type_id | u32 | Particle type (row in the atlas offsets texture) |
Bindings: @group(0) @binding(0) var<uniform> ParticleUniforms, @group(0) @binding(1) texture_2d<f32> (atlas offsets, read with textureLoad), @group(0) @binding(2) texture_2d<f32> (particle atlas), @group(0) @binding(3) sampler.
| Material data | Contract |
|---|---|
| World emission textures | RGBA16Float, same UVs/layer as color; RGB is straight radiance, alpha unused. Pass sampled RGB to material_fragment(lit, base, emission) for bounded self-lit color and separate bloom emission. Default materials bind zero radiance. |
| Particle offsets texel | [width, height, frame_count, emission_strength]; particles are static sprites. Keep the unlit scene color and output color.rgb * offsets.a as emission. Icon/item metadata keeps its own frame-duration meaning. |
| Override shaders | Consume these inputs to support authored emission. World output uses two RGBA16Float attachments in one draw, with identical coverage alpha on both. Non-emitters write zero emission RGB and their actual alpha, so foreground content covers emitters. |
World overrides must provide fs_world; icon/item overrides use only fs_main.
Keep fs_main for surface rendering, including static-object placement previews.
The engine checks resource usage in both fragment entry points, and GPU pipeline
validation checks the attachment interface. Incompatible overrides fall back to the
engine shader. This replaces the previous single-output world shader contract.
The lighting prelude provides WorldFragment and material_fragment. Unlit domains
(particle, weather, biome backdrop and break particle) declare an equivalent output
struct themselves. Both fields are vec4<f32>; emission RGB is straight radiance,
and emission alpha must match scene alpha even when emission RGB is zero. Share
shading in a regular helper and have fs_main return its scene color and fs_world
return both fields; WGSL entry points cannot call each other.
Dead-slot cull warning (required): the engine draws the entire GPU particle ring buffer every frame; dead slots have life <= 0.0. Your vs_main must eject dead slots before rasterization by emitting a clip-space position outside the view volume (e.g. vec4<f32>(2.0, 2.0, 2.0, 1.0)) when life <= 0.0. An fs_main discard alone is not enough — it runs after rasterization, and the resulting invisible overdraw has been measured to cost ~10 ms/frame. Copy the cull from assets/shaders/particle.wgsl.
Weather (weather) and Biome Backdrop (biome-backdrop)
Pattern: assets/shaders/backdrop.wgsl. Both domains share one contract:
| Location | Attribute | Type |
|---|---|---|
| 0 | position | vec2<f32> |
| 1 | tex_coords | vec2<f32> |
Bindings: @group(0) @binding(0) var<uniform> QuadUniforms (position/size/frame/crop/opacity/skylight tint — see backdrop.wgsl), @group(0) @binding(1) texture_2d<f32>, @group(0) @binding(2) sampler.
The bound texture is a single animation frame, not the whole sprite strip: the engine slices the strip into one texture per frame at load time and binds the current one, so tex_width/tex_height equal frame_width/frame_height and frame is 0.
Both domains are tiled edge-to-edge to fill the viewport, and their samplers repeat so that sampling slightly outside [0, 1] wraps to the frame’s opposite edge — this is what keeps a tile join seamless. Biome backdrops repeat in U only (they are not tiled vertically, so V clamps); weather repeats in both U and V, since the vertical-loop scroll stacks copies down the screen as well. Don’t clamp UVs to the frame yourself; that reintroduces the seam.
A biome-backdrop layer with mirror-tiling: true instead gets a mirror-repeating U sampler, and the engine sets flip_x on every other tile. If your shader derives its own UVs rather than using the ones the vertex stage hands you, honour flip_x the way backdrop.wgsl does — ignoring it leaves those layers seamed.
Icon (icon)
Pattern: assets/shaders/icon.wgsl.
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | Unit quad UV |
| 2 | pos | vec2<f32> | Screen-space anchor of this icon instance |
| 3 | icon_type_id | u32 | Icon type (row in the atlas offsets texture) |
| 4 | frame_index | u32 | CPU-computed animation frame |
Bindings: @group(0) @binding(0) var<uniform> AtlasUniforms, @group(0) @binding(1) offsets texture (textureLoad), @group(0) @binding(2) atlas texture, @group(0) @binding(3) sampler.
Item (item)
Pattern: assets/shaders/item.wgsl.
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | Unit quad UV |
| 2 | pos | vec2<f32> | Screen-space anchor of this item instance |
| 3 | item_id | u32 | Item atlas slot index |
Bindings: same atlas group as icon (AtlasUniforms + offsets + atlas + sampler at @group(0) @binding(0..3)).
Break Particle (break-particle)
Pattern: assets/shaders/rectangle.wgsl (break particles are instanced colored squares).
| Location | Attribute | Type | Description |
|---|---|---|---|
| 0 | position | vec2<f32> | Unit quad vertex position |
| 1 | tex_coords | vec2<f32> | Unit quad UV |
| 2 | pos_px | vec2<f32> | Screen-space top-left (pixels) |
| 3 | frame_size | vec2<f32> | Width/height (pixels) |
| 4 | color | vec4<f32> | RGBA sampled from the broken block/object texture (normalized to [0,1]) |
Bindings: @group(0) @binding(0) var<uniform> RectangleUniforms (screen_size: vec2<f32>).
Errors
If a shader fails to parse, fails WGSL validation, or violates its contract, the engine logs a warning to logs/client.log and silently falls back to the built-in shader for that category. Mod shaders can never crash the game at load time.
Complete Example
A mod that tints all break particles red:
shaders.yaml:
default-break-particle-shader: red_break
shaders:
- name: red_break
source: shaders/red_break.wgsl
type: break-particle
shaders/red_break.wgsl:
struct RectangleUniforms {
screen_size: vec2<f32>,
_pad: vec2<f32>,
}
@group(0) @binding(0) var<uniform> rect: RectangleUniforms;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(
@location(0) position: vec2<f32>,
@location(2) pos_px: vec2<f32>,
@location(3) frame_size: vec2<f32>,
@location(4) color: vec4<f32>,
) -> VsOut {
var out: VsOut;
let px = pos_px + position * frame_size;
let ndc = px / rect.screen_size * 2.0 - 1.0;
out.clip_pos = vec4<f32>(ndc.x, -ndc.y, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// Keep the particle's alpha, force the hue red.
return vec4<f32>(1.0, 0.1, 0.1, in.color.a);
}
Last updated