Port Shadertoy volume shaders to WebGL2
Porting a Shadertoy volume shader to WebGL2 is mostly a translation job: map the uniforms, replace the entry point, make the shader valid GLSL ES 3.00, then reduce the ray marching budget until it previews smoothly. The math can often survive almost unchanged. The browser contract around it cannot.
This guide is written for bringing fog, smoke, cloud, nebula, and fractal examples into the Volume Shader editor. It assumes you are starting from a fragment shader that uses Shadertoy-style names such as 'mainImage', 'fragCoord', 'iResolution', 'iTime', and texture channels.

Table of contents
- Start with the API boundary
- Translate Shadertoy uniforms
- Replace mainImage with a WebGL2 entry point
- Fix GLSL ES 3.00 syntax
- Make texture channels explicit
- Retune the ray marcher
- Debug the port in stages
- A practical porting checklist
Start with the API boundary
Shadertoy is a complete shader playground. It supplies a rendering wrapper, built-in uniforms, texture channels, multipass buffers, and a conventional fragment entry point. WebGL2 is the browser graphics API underneath your own application code. MDN describes WebGL2RenderingContext as the OpenGL ES 3.0 rendering context for a canvas, and the Khronos WebGL 2.0 specification defines the browser API and shader language constraints.
That difference is the source of most porting errors. A Shadertoy shader can assume its host will call 'mainImage' and provide 'iResolution'. A WebGL2 shader must declare its version, precision, uniforms, output variable, and actual 'main' function.
For a volume shader, do not rewrite the whole ray marcher first. Wrap it. Your first successful port should keep the density function, camera math, and color accumulation as close to the original as possible. Once it compiles, tune it for the editor, compare heavier versions with the GPU performance test, and use the performance budget guide when the shader starts to stutter.
Translate Shadertoy uniforms
Most Shadertoy examples use a predictable set of uniforms. The Volume Shader editor examples usually center on time and resolution, so begin with those and stub the rest until you know you need them.
| Shadertoy name | WebGL2/editor equivalent | Porting note |
|---|---|---|
| Shadertoy 'iResolution.xy' | Editor 'u_resolution.xy' | Canvas render size in pixels |
| Shadertoy 'iTime' | Editor 'u_time' | Seconds since animation start |
| Shadertoy 'iFrame' | Optional 'u_frame' | Add only if frame-dependent logic matters |
| Shadertoy 'iMouse' | Optional pointer uniform | Replace with constants for a first port |
| Shadertoy 'iChannel0' | Optional 'sampler2D' | Requires explicit texture setup |
| Shadertoy 'iChannelResolution' | Optional texture-size uniform | Needed for texture-dependent samples |
The quickest first pass is to replace only resolution and time:
// Shadertoy
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float t = iTime;
// WebGL2 / Volume Shader editor
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
float t = u_time;
That form keeps the vertical field of view stable and handles aspect ratio without a separate 'uv.x' correction. If the original shader used 'fragCoord / iResolution.xy' instead, preserve that first, then adjust only if the result is stretched.
Replace mainImage with a WebGL2 entry point
Shadertoy fragment shaders commonly end like this:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
vec3 col = renderVolume(uv, iTime);
fragColor = vec4(col, 1.0);
}
In WebGL2 / GLSL ES 3.00, use an explicit output variable:
#version 300 es
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
out vec4 fragColor;
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
vec3 col = renderVolume(uv, u_time);
fragColor = vec4(col, 1.0);
}
If the Volume Shader editor already supplies the version, precision, and output wrapper for a preset, follow the local preset style instead of duplicating declarations. The goal is not to force one wrapper everywhere. The goal is to remove Shadertoy-only assumptions.
Fix GLSL ES 3.00 syntax
The most common syntax failures are small, stubborn, and wonderfully unglamorous. Start with these before questioning the math.
| Problem | Fix |
|---|---|
| 'texture2D(channel, uv)' | Use 'texture(channel, uv)' in GLSL ES 3.00 |
| 'gl_FragColor = color' | Declare 'out vec4 fragColor' and write to it |
| Missing precision | Add 'precision highp float;' |
| Integer/float mismatch | Use '1.0' instead of '1' in float expressions |
| Dynamic loop surprises | Keep max ray-step bounds constant |
| Shadertoy-only globals | Replace or explicitly declare them |
Volume shaders often rely on many loop iterations, so keep the loop shape compiler-friendly:
const int MAX_STEPS = 96;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * t;
float d = densityField(p);
// Accumulate volume color here.
if (d > 0.98 || t > 6.0) {
break;
}
t += 0.04;
}
Use a named constant for the maximum. If you later expose a quality control, keep the compile-time maximum and break when the runtime step count is reached.
Make texture channels explicit
Many beautiful Shadertoy examples hide complexity in 'iChannel0', 'iChannel1', buffer passes, or precomputed noise textures. A direct paste can compile but render black because the sampler exists without the right texture bound to it.
For a first editor port, classify the channels:
- Procedural noise channel: replace with inline value noise or FBM.
- Blue-noise or dither texture: disable it first, then add a small fallback pattern.
- Previous-frame buffer: remove temporal feedback for the first port.
- Environment or lookup texture: replace with a color ramp until the volume works.
Here is a tiny fallback hash for ports that only need noisy variation:
float hash31(vec3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float cheapDensityNoise(vec3 p) {
return hash31(floor(p * 18.0));
}
This is not a substitute for high-quality 3D noise, but it is enough to prove that the camera, ray direction, and accumulation are correct. After that, move back toward smoother noise or use one of the presets as a reference.
Retune the ray marcher
The first compile is not the finish line. Shadertoy examples are often written for impressive full-screen results, not for stable editing inside a site UI. A volume shader that runs acceptably in one playground can still be too heavy when the canvas DPR, browser backend, laptop power mode, or mobile GPU changes.
Retune in this order:
| Control | First adjustment | Why it helps |
|---|---|---|
| Ray steps | 128 to 96, then 96 to 64 | Directly reduces density evaluations |
| Step size | Increase slightly | Covers the same distance with fewer samples |
| DPR | Cap preview at 1.0 or 0.75 | Reduces fragment count |
| Noise octaves | Drop one octave | Cuts repeated field work |
| Fractal iterations | Reduce by 1-3 | Removes nested math inside each step |
| Early exit | Stop near full opacity | Saves work in dense regions |
For front-to-back volume accumulation, early exit is usually the cleanest win:
vec3 color = vec3(0.0);
float alpha = 0.0;
float t = 0.0;
for (int i = 0; i < MAX_STEPS; i++) {
vec3 p = ro + rd * t;
float density = clamp(densityField(p), 0.0, 1.0) * 0.045;
vec3 sampleColor = mix(vec3(0.08, 0.25, 0.65), vec3(0.9, 0.55, 0.25), density * 10.0);
color += (1.0 - alpha) * density * sampleColor;
alpha += (1.0 - alpha) * density;
if (alpha > 0.96 || t > 5.0) {
break;
}
t += 0.04;
}
If the shader still feels heavy, test it at a known resolution in the benchmark and compare the device class with the leaderboard or device pages. The right budget for a desktop showcase and the right budget for a shareable editor example are often different.
Debug the port in stages
A black screen does not always mean the shader is broken. It may mean the camera is inside empty space, the ray direction points away from the field, alpha stays zero, or the original texture channel is missing.
Use staged outputs:
// 1. Check coordinates.
fragColor = vec4(uv * 0.5 + 0.5, 0.0, 1.0);
// 2. Check ray direction.
fragColor = vec4(rd * 0.5 + 0.5, 1.0);
// 3. Check density at one point.
fragColor = vec4(vec3(densityField(vec3(uv, 0.0))), 1.0);
// 4. Check accumulated alpha.
fragColor = vec4(vec3(alpha), 1.0);
Once each stage looks plausible, restore color and lighting. This keeps the port mechanical rather than mystical. The learn section is useful when you need to revisit ray direction, density fields, or accumulation without the Shadertoy wrapper in the way.
A practical porting checklist
Use this sequence whenever a Shadertoy volume shader looks worth bringing into WebGL2:
- Copy the density, camera, and render functions without changing their math.
- Replace 'mainImage' with a WebGL2 'main' wrapper.
- Map 'iResolution' and 'iTime' to editor uniforms.
- Replace 'texture2D', 'gl_FragColor', and missing precision declarations.
- Stub mouse, frame, and texture channels until the base render works.
- Lower ray steps, DPR, and expensive noise before polishing color.
- Add early exit and debug step count if dense areas over-sample.
- Compare the result against a preset and a benchmark run before sharing.
The best ports keep two things separate: correctness and budget. First make the image recognizable. Then make it interactive. After that, the shader is ready to refine in the Volume Shader editor and test against real WebGL hardware behavior.


