Guide

GuideShippingTyped Shaders

Typed Shaders

Author GLSL and WGSL as real .vert/.frag/.wgsl files and import them as source strings at build time with @codexo/exojs-build.

Advanced~3 min read

Before you start

Typed Shaders

Every shader-taking API in ExoJS - ShaderFilter, Shader, MeshMaterial, SpriteMaterial, the WebGPU compute pipeline - takes source as a string. The guides show that string as a template literal, because a literal is the one form that always works. It is also the form your editor cannot help you with: no syntax highlighting, no GLSL or WGSL language service, no formatter, no shader linter, and an error the driver reports at line 34 is line 34 of a string embedded somewhere in a .ts file.

@codexo/exojs-build gives you the other form. The shader lives in a real .vert, .frag or .wgsl file, and the build hands its text to the same API as a string.

npm install --save-dev @codexo/exojs-build

Build setup

The same setup the typed worklets and workers guide describes covers shaders too - exojs() installs the shader loader alongside the worklet and worker transforms:

import { exojs } from '@codexo/exojs-build';

export default {
    plugins: [exojs()],
};

and the published ambient declarations type the imports:

{
    "compilerOptions": {
        "types": ["@codexo/exojs-build/client"]
    }
}

That is the whole configuration. Rollup needs nothing else either; unlike a .ts module there is no transform involved, only the file’s text.

Authoring

Write the shader as you would write it in any shader-aware editor:

/* ripple.frag */
#version 300 es

precision mediump float;

in vec2 vUv;

uniform sampler2D u_texture;
uniform float u_time;
uniform float u_amplitude;

out vec4 fragColor;

void main() {
    float wave = sin(vUv.y * 24.0 + u_time * 3.0) * u_amplitude;

    fragColor = texture(u_texture, vec2(vUv.x + wave, vUv.y));
}
/* ripple.wgsl */
struct Uniforms {
    time: f32,
    amplitude: f32,
};

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var inputTexture: texture_2d<f32>;
@group(0) @binding(2) var inputSampler: sampler;

@fragment
fn fragmentMain(@location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
    let wave = sin(vUv.y * 24.0 + uniforms.time * 3.0) * uniforms.amplitude;

    return textureSample(inputTexture, inputSampler, vec2<f32>(vUv.x + wave, vUv.y));
}

and import each file where the source is needed:

import { ShaderFilter } from '@codexo/exojs';

import fragment from './ripple.frag';
import wgsl from './ripple.wgsl';

const ripple = new ShaderFilter({
    glsl: { fragment },
    wgsl,
    uniforms: { u_time: 0, u_amplitude: 0.02 },
});

The import evaluates to the file’s text - the API sees exactly the string it always saw. A cross-backend effect needs both languages, so keeping them as two files beside each other is also what makes the pair obvious.

What the build does with it

The text is inlined into your bundle. No shader is emitted as a separate file and none is fetched at runtime, so there is no load order to arrange and no request to fail.

For production, exojs({ minify: true }) strips comments and layout whitespace from the shader text. This matters more than it looks: shader source travels inside a JavaScript string literal, and no JavaScript minifier descends into one, so without this step every explanatory comment you wrote is bytes every visitor downloads.

Coexisting with your bundler

Only a bare import is claimed. An import carrying a query - ./ripple.frag?raw, ?url, anything else - is left to the bundler, so Vite’s own asset handling keeps working next to the plugin:

import shaderUrl from './ripple.frag?url';

The two can appear in the same program. Use the bare import for source you compile, and a query when you genuinely want the file as something else.

WebGl2Shader source you build at runtime

Source assembled at runtime - a fragment stage composed from several strings, a variant switched on a feature flag - never passes through the plugin, so minify cannot reach it. Put it through the same transform explicitly and both halves ship on the same terms:

import { stripShaderSource } from '@codexo/exojs-build/shader-strip';

const composed = stripShaderSource(`${prelude}\n${body}`);

Next steps