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 @vertex stage
  • fs_main — the single-output @fragment stage for surface rendering
  • fs_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

TypeAffectsGlobal Default KeyPer-Type Override
entityMobs and creaturesdefault-shaderYes — via shader: in mob config
blockBlock chunk renderingdefault-block-shaderNo
static-objectInteractables and treesdefault-static-object-shaderYes — via shader: in interactable/tree config
particleAll particlesdefault-particle-shaderNo
weatherWeather layer renderingdefault-weather-shaderYes — via client.shader in weather config
biome-backdropBiome background layersdefault-biome-backdrop-shaderYes — via shader: in biome config
iconStatus icons (health, oxygen)default-icon-shaderNo
itemInventory and hotbar itemsdefault-item-shaderNo
break-particleBlock and static-object break particlesdefault-break-particle-shaderNo

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_main may 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 as sampler (nearest), and binding 3 as texture_2d<f32> (dynamic light). Sample both textures at the same sector UV and pass their values plus sector_directional_visibility(sector_uv) to composite_lighting.
  • sector_light_uv converts fragment screen positions to sector UVs for actors. Use sector_sky_light(sector_light_uv(fragment_position.xy)) for spatial skylight matching terrain; an unavailable sky chunk returns zero. sky_light_factor and composite_lighting remain available. dynamic_light_color and ray helpers serve the bake passes; the main-pass frame.light_count is 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 shared shade_world helper.

The prelude’s frame (FrameUniforms) exposes these per-frame values, readable from any world-domain shader:

FieldTypeDescription
time_msf32Monotonic frame clock in milliseconds, for time-driven effects — sin(frame.time_ms * k + phase) sway, glow pulses, scrolling.
player_world_x / player_world_yf32Player world-space position in blocks (finer than player_chunk_x/y), for per-vertex/per-pixel distance falloff.
windf32Signed 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_minvec2<i32>Origin of the uploaded occlusion rectangle in floor-based 32-block chunk coordinates, including zero.
occlusion_sizevec2<u32>Width and height of that rectangle in chunks; x-major chunk index is x * frame.occlusion_size.y + y.
sun_directional / moon_directionalvec4<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_unavailableu32Nonzero 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.

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<f32>Unit quad UV
2pos_pxvec2<f32>Screen-space top-left of the entity (pixels)
3frame_sizevec2<f32>Animation frame size (pixels)
4entity_posvec2<f32>World-space center position
5tex_indexi32Layer in the entity texture array (frame included)
6flip_xi321 = flip horizontally
7sky_lighti32Sky light level (0-15)
8rotationvec4<f32>2×2 rotation matrix, column-major
9tintvec4<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.

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<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:

LocationAttributeType
0positionvec2<f32>
1tex_coordsvec2<f32>
2pos_pxvec2<f32>
3frame_sizevec2<f32>
4entity_posvec2<f32>
5tex_indexi32
6flip_xi32
7sky_lighti32

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.

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<f32>Unit quad UV
2posvec2<f32>World-space particle position
3velvec2<f32>Particle velocity
4lifef32Remaining lifetime in seconds (<= 0 = dead)
5particle_type_idu32Particle 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 dataContract
World emission texturesRGBA16Float, 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 shadersConsume 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:

LocationAttributeType
0positionvec2<f32>
1tex_coordsvec2<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.

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<f32>Unit quad UV
2posvec2<f32>Screen-space anchor of this icon instance
3icon_type_idu32Icon type (row in the atlas offsets texture)
4frame_indexu32CPU-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.

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<f32>Unit quad UV
2posvec2<f32>Screen-space anchor of this item instance
3item_idu32Item 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).

LocationAttributeTypeDescription
0positionvec2<f32>Unit quad vertex position
1tex_coordsvec2<f32>Unit quad UV
2pos_pxvec2<f32>Screen-space top-left (pixels)
3frame_sizevec2<f32>Width/height (pixels)
4colorvec4<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