Shaders

January 11, 2026 · View on GitHub

Shaders are GPU programs that apply visual effects to your game. GMR provides a simple Ruby DSL for loading GLSL shaders and applying them to any drawable content—sprites, tilemaps, primitives, or entire scenes.

Core Concepts

ConceptDescription
Fragment ShaderGPU program that determines each pixel's final color
UniformVariable you pass from Ruby to the shader
Block-based UsageApply shaders with shader.use { } blocks

Most 2D effects only need a fragment shader. GMR handles the vertex shader automatically.


Loading Shaders

From Files

# Fragment shader only (most common)
@grayscale = Graphics::Shader.load(fragment: "shaders/grayscale.fs")

# Both vertex and fragment
@custom = Graphics::Shader.load(
  vertex: "shaders/custom.vs",
  fragment: "shaders/custom.fs"
)

Paths are relative to your game/assets/ directory.

From Source Code

glsl_code = <<~GLSL
  #version 100
  precision mediump float;
  varying vec2 fragTexCoord;
  varying vec4 fragColor;
  uniform sampler2D texture0;
  uniform vec4 colDiffuse;

  void main() {
    vec4 texel = texture2D(texture0, fragTexCoord);
    float gray = dot(texel.rgb, vec3(0.299, 0.587, 0.114));
    gl_FragColor = vec4(vec3(gray), texel.a) * colDiffuse * fragColor;
  }
GLSL

@shader = Graphics::Shader.from_source(fragment: glsl_code)

Setting Uniforms

Use shader.set to pass values to your shader. The type is inferred from the arguments:

ArgumentsGLSL TypeExample
1 floatfloatshader.set(:intensity, 0.5)
1 integerintshader.set(:levels, 4)
2 floatsvec2shader.set(:resolution, 800.0, 600.0)
3 floatsvec3shader.set(:tint, 1.0, 0.5, 0.0)
4 floatsvec4shader.set(:color, 1.0, 0.5, 0.0, 1.0)
1 texturesampler2Dshader.set(:noise, @noise_texture)
def draw
  @shader.set(:time, GMR::Time.elapsed)
  @shader.set(:resolution, Window.width.to_f, Window.height.to_f)
  @shader.set(:intensity, 0.8)

  @shader.use do
    @sprite.draw
  end
end

Note: Set uniforms before the use block, not inside it.


Using Shaders

def draw
  @camera.use do
    # Normal rendering (no shader)
    @background.draw

    # Apply shader to specific draws
    @crt_shader.use do
      @level.draw
      @player.draw
    end

    # Back to normal
    @foreground.draw
  end
end

Everything inside shader.use { } is rendered with the shader applied. Draws outside use the default shader.

Nesting with Camera

Shaders compose naturally with camera blocks:

@shader.use do
  @camera.use do
    @tilemap.draw
    @player.draw
  end
end

Or camera first, then shader:

@camera.use do
  @shader.use do
    @level.draw
    @player.draw
  end
end

Explicit Begin/End (Advanced)

For cases where block syntax isn't convenient:

@shader.begin
@sprite1.draw
@sprite2.draw
@shader.end

Warning: Always pair begin with end. Prefer block syntax when possible.


Surface Mode

Spatial shaders that distort UV coordinates (wave, CRT curvature, glitch) can produce visual artifacts when applied to tilemaps or animated sprites. This happens because shaders normally operate per-draw-call, so each tile or animation frame gets independent UV coordinates.

Surface mode solves this by rendering the entire shader block to an intermediate surface first, then applying the shader to that unified surface.

The Problem

Without surface mode, a wave shader applied to a tilemap causes each tile to wave independently:

# BAD: Each tile waves separately
@wave.use do
  @tilemap.draw  # Each tile has its own 0-1 UV range
end

Similarly, CRT curvature on an animated sprite shifts with each animation frame because the UV coordinates change as the sprite sheet region changes.

The Solution

Enable surface_mode when loading spatial/distortion shaders:

# Load with surface_mode enabled
@wave = Graphics::Shader.load(fragment: "shaders/wave.fs", surface_mode: true)
@crt = Graphics::Shader.load(fragment: "shaders/crt.fs", surface_mode: true)
@glitch = Graphics::Shader.load(fragment: "shaders/glitch.fs", surface_mode: true)

# Now the entire tilemap waves as one unified surface
@wave.use do
  @tilemap.draw
end

When to Use Surface Mode

Shader TypeSurface Mode?Reason
Wave/distortionYesUV displacement must be continuous
CRT/curvatureYesBarrel distortion needs unified coordinates
GlitchYesBlock displacement should span content
VignetteYesDarkening should be relative to full surface
GrayscaleOptionalWorks either way (no UV distortion)
Color effectsOptionalWorks either way (no UV distortion)
BlurOptionalMay want surface mode for edge handling

API

# Enable at load time
@shader = Graphics::Shader.load(fragment: "effect.fs", surface_mode: true)

# Or from source
@shader = Graphics::Shader.from_source(fragment: code, surface_mode: true)

# Toggle after loading
@shader.surface_mode = true
@shader.surface_mode = false

# Query current state
if @shader.surface_mode?
  puts "Surface mode enabled"
end

Performance Note

Surface mode incurs a small performance cost due to the intermediate render target. Only enable it for shaders that actually need unified UV space. Color manipulation shaders (grayscale, sepia, posterize) work fine without it.


Writing GLSL Shaders

GMR supports two GLSL versions depending on the build target:

TargetGLSL VersionNotes
Native (Windows/Linux/macOS)GLSL 330OpenGL 3.3 core profile
Web (WebAssembly)GLSL ES 100WebGL 1.0 compatible

Recommendation: Write shaders in GLSL ES 100 for maximum compatibility across all platforms.

#version 100

precision mediump float;

// Inputs from vertex shader (provided by raylib)
varying vec2 fragTexCoord;   // Texture coordinate (0-1)
varying vec4 fragColor;      // Vertex color

// Raylib's default uniforms
uniform sampler2D texture0;   // Primary texture
uniform vec4 colDiffuse;      // Diffuse color multiplier

// Your custom uniforms
uniform float intensity;
uniform vec2 resolution;
uniform float time;

void main() {
    // Sample the texture
    vec4 texel = texture2D(texture0, fragTexCoord);

    // Apply your effect
    // ...

    // Output final color (multiply by colDiffuse and fragColor for correct blending)
    gl_FragColor = texel * colDiffuse * fragColor;
}

GLSL 330 (Native Only)

#version 330

// Inputs from vertex shader (provided by raylib)
in vec2 fragTexCoord;   // Texture coordinate (0-1)
in vec4 fragColor;      // Vertex color

// Raylib's default uniforms
uniform sampler2D texture0;   // Primary texture
uniform vec4 colDiffuse;      // Diffuse color multiplier

// Your custom uniforms
uniform float intensity;
uniform vec2 resolution;
uniform float time;

// Output (required)
out vec4 finalColor;

void main() {
    // Sample the texture
    vec4 texel = texture(texture0, fragTexCoord);

    // Apply your effect
    // ...

    // Output final color (multiply by colDiffuse and fragColor for correct blending)
    finalColor = texel * colDiffuse * fragColor;
}

GLSL Version Differences

FeatureGLSL ES 100 (Web)GLSL 330 (Native)
Version directive#version 100#version 330
Precisionprecision mediump float; requiredNot needed
Vertex inputsvarying vec2in vec2
Fragment outputgl_FragColorout vec4 finalColor
Texture samplingtexture2D()texture()
For loopsMust use constant boundsVariable bounds allowed

Built-in Inputs

NameTypeDescription
fragTexCoordvec2UV coordinates (0-1 range)
fragColorvec4Vertex color from sprite/primitive
texture0sampler2DThe texture being drawn
colDiffusevec4Color multiplier (for tinting)

Important: Always multiply your final color by colDiffuse * fragColor to preserve sprite tinting and alpha.


Common Effects

All examples use GLSL ES 100 for cross-platform compatibility.

Grayscale

Convert to black and white:

#version 100
precision mediump float;

varying vec2 fragTexCoord;
varying vec4 fragColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
uniform float intensity;  // 0.0 = color, 1.0 = full grayscale

void main() {
    vec4 texel = texture2D(texture0, fragTexCoord);
    float gray = dot(texel.rgb, vec3(0.299, 0.587, 0.114));
    vec3 result = mix(texel.rgb, vec3(gray), intensity);
    gl_FragColor = vec4(result, texel.a) * colDiffuse * fragColor;
}
@grayscale = Graphics::Shader.load(fragment: "shaders/grayscale.fs")
@grayscale.set(:intensity, 1.0)

Wave Distortion

Animated wavy effect:

#version 100
precision mediump float;

varying vec2 fragTexCoord;
varying vec4 fragColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
uniform float time;
uniform float amplitude;  // 0.01 to 0.05
uniform float frequency;  // 5.0 to 20.0

void main() {
    vec2 uv = fragTexCoord;
    uv.x += sin(uv.y * frequency + time * 3.0) * amplitude;

    vec4 texel = texture2D(texture0, uv);
    gl_FragColor = texel * colDiffuse * fragColor;
}
@wave = Graphics::Shader.load(fragment: "shaders/wave.fs")

def draw
  @wave.set(:time, GMR::Time.elapsed)
  @wave.set(:amplitude, 0.02)
  @wave.set(:frequency, 15.0)

  @wave.use do
    @sprite.draw
  end
end

CRT Monitor

Retro scanlines and curvature:

#version 100
precision mediump float;

varying vec2 fragTexCoord;
varying vec4 fragColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
uniform vec2 resolution;
uniform float curvature;         // 4.0 to 10.0
uniform float scanlineIntensity; // 0.1 to 0.5

void main() {
    // Apply barrel distortion
    vec2 uv = fragTexCoord * 2.0 - 1.0;
    vec2 offset = uv.yx / curvature;
    uv += uv * offset * offset;
    uv = uv * 0.5 + 0.5;

    // Clamp to texture bounds
    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
    }

    vec4 texel = texture2D(texture0, uv);

    // Scanlines
    float scanline = sin(uv.y * resolution.y * 3.14159) * 0.5 + 0.5;
    texel.rgb *= 1.0 - scanlineIntensity * (1.0 - scanline);

    gl_FragColor = texel * colDiffuse * fragColor;
}
@crt = Graphics::Shader.load(fragment: "shaders/crt.fs")

def draw
  @crt.set(:resolution, Window.width.to_f, Window.height.to_f)
  @crt.set(:curvature, 6.0)
  @crt.set(:scanlineIntensity, 0.3)

  @crt.use do
    @camera.use do
      @level.draw
      @player.draw
    end
  end
end

Pixelate

Chunky retro pixels:

#version 100
precision mediump float;

varying vec2 fragTexCoord;
varying vec4 fragColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
uniform float pixelSize;   // 2.0 to 16.0
uniform vec2 resolution;

void main() {
    vec2 pixelCount = resolution / pixelSize;
    vec2 uv = floor(fragTexCoord * pixelCount) / pixelCount;

    vec4 texel = texture2D(texture0, uv);
    gl_FragColor = texel * colDiffuse * fragColor;
}
@pixelate = Graphics::Shader.load(fragment: "shaders/pixelate.fs")
@pixelate.set(:pixelSize, 4.0)
@pixelate.set(:resolution, Window.width.to_f, Window.height.to_f)

Shader Cycling

Cycle through multiple shaders at runtime:

def init
  @shaders = [
    nil,  # No shader
    Graphics::Shader.load(fragment: "shaders/grayscale.fs"),
    Graphics::Shader.load(fragment: "shaders/crt.fs"),
    Graphics::Shader.load(fragment: "shaders/wave.fs")
  ]
  @shader_names = ["none", "grayscale", "crt", "wave"]
  @shader_index = 0

  Input.map(:next_shader, [:e])
  Input.map(:prev_shader, [:q])
  Input.on(:next_shader) { @shader_index = (@shader_index + 1) % @shaders.length }
  Input.on(:prev_shader) { @shader_index = (@shader_index - 1) % @shaders.length }
end

def draw
  current = @shaders[@shader_index]

  if current
    set_shader_uniforms(current, @shader_names[@shader_index])
    current.use do
      draw_game
    end
  else
    draw_game
  end

  Graphics.draw_text("Shader: #{@shader_names[@shader_index]} [Q/E]", 5, 5, 16, :white)
end

Resource Management

Shaders are reference-counted and cached by path:

# Same shader file = same handle (cached)
shader1 = Graphics::Shader.load(fragment: "shaders/blur.fs")
shader2 = Graphics::Shader.load(fragment: "shaders/blur.fs")  # Returns same shader

# Check if shader is valid
if @shader.valid?
  @shader.use { @sprite.draw }
end

# Manually release (optional - GC handles this)
@shader.release

Complete Example

include GMR

VIEW_HEIGHT = 9

def init
  Window.set_size(960, 540)
  Window.set_title("Shader Demo")

  # Setup camera
  @camera = Graphics::Camera.new(
    viewport_size: Mathf::Vec2.new(Window.width, Window.height),
    view_height: VIEW_HEIGHT
  )
  @camera.offset = Mathf::Vec2.new(Window.width / 2.0, Window.height / 2.0)

  # Load player
  @texture = Texture.load("player.png")
  @transform = Transform2D.new(x: 5.0, y: 5.0)
  @sprite = Sprite.new(@texture, @transform)
  @sprite.center_origin

  # Load shaders
  @wave_shader = Graphics::Shader.load(fragment: "shaders/wave.fs")
  @use_shader = true

  Input.map(:toggle_shader, [:space])
  Input.on(:toggle_shader) { @use_shader = !@use_shader }
end

def update(dt)
  speed = 5.0 * dt
  @transform.x -= speed if Input.key_down?(:left)
  @transform.x += speed if Input.key_down?(:right)
  @transform.y -= speed if Input.key_down?(:up)
  @transform.y += speed if Input.key_down?(:down)
end

def draw
  Graphics.clear("#1a1a2e")

  if @use_shader
    @wave_shader.set(:time, GMR::Time.elapsed)
    @wave_shader.set(:amplitude, 0.015)
    @wave_shader.set(:frequency, 12.0)

    @wave_shader.use do
      @camera.use do
        @sprite.draw
      end
    end
  else
    @camera.use do
      @sprite.draw
    end
  end

  status = @use_shader ? "ON" : "OFF"
  Graphics.draw_text("Shader: #{status} [SPACE]", 10, 10, 16, :white)
  Graphics.draw_text("Arrow keys to move", 10, 30, 16, :gray)
end

API Summary

Loading

MethodDescription
Graphics::Shader.load(fragment:, vertex:, surface_mode:)Load shader from files
Graphics::Shader.from_source(fragment:, vertex:, surface_mode:)Load shader from GLSL strings

Uniforms

MethodDescription
shader.set(name, value)Set float uniform
shader.set(name, x, y)Set vec2 uniform
shader.set(name, x, y, z)Set vec3 uniform
shader.set(name, x, y, z, w)Set vec4 uniform
shader.set(name, texture)Set sampler2D uniform

Usage

MethodDescription
shader.use { }Apply shader within block
shader.begin / shader.endManual shader control

Surface Mode

MethodDescription
shader.surface_mode = boolEnable/disable surface mode
shader.surface_mode?Check if surface mode is enabled

Resource Management

MethodDescription
shader.valid?Check if shader is loaded
shader.releaseManually release shader

See Also