WebGL2 volume shader performance budget: ray steps, DPR, and early exit

Volume Shader Team
2026-04-27
10 min read
345 views
WebGL
Performance
Ray Marching
GPU Testing
Tutorial
WebGL2 volume shader performance budget: ray steps, DPR, and early exit

WebGL2 volume shader performance budget: ray steps, DPR, and early exit

A WebGL2 volume shader performance budget is a short list of limits you check before adding more visual detail: ray steps, density function cost, device pixel ratio, early exit, and frame-time stability. If those five controls are visible, a shader is much easier to keep interactive across desktop, laptop, and mobile GPUs.

This guide is written for the Volume Shader editor, the preset gallery, and the GPU performance test. It does not promise one universal number for every device. Instead, it gives you a repeatable way to decide whether a fog, smoke, cloud, or fractal shader has room to grow.

WebGL2 volume shader performance budget diagram with ray samples, early exit, DPR, and frame-time targets

Table of contents

Start with the frame budget

The frame budget is the amount of time one frame can spend before the animation target slips. A 60 FPS target leaves about 16.7 ms for the whole frame. A 30 FPS target leaves about 33.3 ms. Your shader does not own all of that time because the browser, UI, JavaScript, compositing, and display pipeline need time too.

For live editing, aim lower than the theoretical limit. If the shader alone feels close to 16 ms on your machine, it may become uneven when the page has more UI work, the device gets warm, or a browser update changes the graphics backend. For a first pass in the editor, these targets are useful:

Target Shader-only feel When to use it
8 ms or less Very comfortable Simple fog, smoke, and animated backgrounds
8-16 ms Good desktop budget Cloud layers, light fractals, learning examples
16-33 ms Borderline interactive Heavy previews, screenshots, high-quality tuning
Over 33 ms Too heavy for editing Reduce steps, DPR, or density cost

WebGL2 is still a practical target for shader tools because the API is well established across browsers and maps to OpenGL ES 3.0 through the HTML canvas. MDN describes WebGL2RenderingContext as the OpenGL ES 3.0 rendering context for canvas, and Khronos documents WebGL as a browser API based on OpenGL ES with WebGL 2 exposing the OpenGL ES 3.0 API on the web. That matters for Volume Shader because GLSL ES 3.00 is the portable language you are usually editing.

WebGPU is now getting more attention for modern graphics and compute. Google reported in November 2025 that WebGPU is supported in major browsers, with platform details varying by browser and operating system. That does not make WebGL2 irrelevant. It makes budgeting more important: a WebGL2 shader that is clear, measured, and well tuned remains useful while browser graphics transitions continue.

Pick a ray-step budget first

Ray steps are the easiest cost to understand because they multiply almost everything else. If a shader shades 2 million pixels and marches 96 samples per pixel, the density function may run roughly 192 million times before lighting and post effects are considered. That is why changing 96 steps to 64 often matters more than micro-optimizing one color expression.

Start with a fixed upper bound, then raise it only when the image needs it:

Effect type Starting steps Raise when Lower when
Soft fog sphere 32-48 Edges band or flicker Mobile preview stutters
2D smoke warp 24-48 Motion needs depth It is mostly background
Cloud layer 48-80 Silhouette breaks DPR is above 1.0
Dense volume 64-96 Interior detail matters Opacity reaches 1 early
Fractal surface 80-128 Hit accuracy matters Iteration count is high

In GLSL ES 3.00, keep the loop bound constant and use a uniform or local condition to stop early. That gives the compiler a predictable loop while still allowing the shader to do less work when the ray is finished.

#version 300 es
precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
out vec4 fragColor;

const int MAX_STEPS = 96;
const float STEP_SIZE = 0.035;

float densityField(vec3 p) {
  float sphere = 1.0 - length(p);
  float ripple = 0.5 + 0.5 * sin(8.0 * p.x + u_time);
  return clamp(sphere * ripple, 0.0, 1.0);
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution.xy * 2.0 - 1.0;
  uv.x *= u_resolution.x / u_resolution.y;

  vec3 ro = vec3(0.0, 0.0, -2.4);
  vec3 rd = normalize(vec3(uv, 1.6));
  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 d = densityField(p) * 0.055;
    vec3 sampleColor = mix(vec3(0.08, 0.4, 0.8), vec3(0.9, 0.7, 0.25), d * 12.0);

    color += (1.0 - alpha) * d * sampleColor;
    alpha += (1.0 - alpha) * d;

    if (alpha > 0.96 || t > 4.0) {
      break;
    }

    t += STEP_SIZE;
  }

  fragColor = vec4(color, alpha);
}

The point is not that 96 is always correct. The point is that 96 is visible, named, and easy to lower. Once the shader is stable, open the preset deep dive and compare the same idea across fog, smoke, clouds, and fractals.

Treat DPR as a multiplier

Device pixel ratio is a hidden performance multiplier. A canvas that looks like 1000 by 600 CSS pixels can become 2000 by 1200 device pixels on a high-DPR display. That is four times as many fragments. A shader that feels comfortable at DPR 1.0 can become unstable at DPR 2.0 even if the code did not change.

For volume shaders, resolution scale is often a better quality control than ray steps. Lowering DPR reduces every fragment cost. Lowering ray steps may expose bands, holes, or unstable silhouettes. Try these steps in order:

  1. Cap preview DPR at 1.0 while editing.
  2. Try 0.75 DPR for heavy cloud and fractal work.
  3. Increase ray steps only after the DPR target is fixed.
  4. Save high-DPR rendering for screenshots or final captures.

In application code, this means separating CSS size from render size. The exact implementation depends on the renderer, but the budget decision is simple: render fewer pixels while the shader is being edited, then let the user opt into more pixels when they need quality.

const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
const previewDpr = Math.min(window.devicePixelRatio || 1, 1);
const heavyShaderDpr = 0.75;
const dpr = shaderIsHeavy ? heavyShaderDpr : previewDpr;

canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);

After changing DPR, rerun a stable workload in the Volume Shader GPU performance test. Then compare the device with real submissions in the leaderboard or device pages. That keeps the decision tied to actual browser rendering instead of a guess.

Use early exit for opacity

Early exit is the difference between a fixed sample count and a useful budget. In volume rendering, front-to-back compositing lets the shader stop when accumulated opacity is already high. If the ray is effectively opaque after 54 samples, the last 42 samples in a 96-step loop are not improving the image.

The important detail is to use early exit honestly. It helps dense fog, clouds, and emissive volumes that become opaque. It helps less when a shader is intentionally transparent all the way through, when the camera is outside an empty field, or when the visual effect depends on distant faint layers.

Use debug colors before trusting the optimization:

Debug view What it shows Healthy sign
Step count Samples used per pixel Dense areas exit earlier
Alpha Accumulated opacity Opaque regions reach target smoothly
Density Raw field value Empty space is truly cheap
Distance or t Ray travel Far misses are not over-sampled

For step-count debugging, temporarily replace final color with a normalized step value. Bright pixels used more of the loop. Dark pixels exited early.

float usedSteps = 0.0;

for (int i = 0; i < MAX_STEPS; i++) {
  usedSteps = float(i + 1);
  // march and accumulate here
  if (alpha > 0.96 || t > 4.0) {
    break;
  }
}

fragColor = vec4(vec3(usedSteps / float(MAX_STEPS)), 1.0);

If the whole screen is white in this debug view, early exit is not doing much. Reduce empty-space work, increase step size in empty regions, or simplify the density field before adding more lighting.

Measure without fooling yourself

JavaScript timing is useful for user experience, but WebGL work is often queued asynchronously. A draw call can return before the GPU has finished the shader. That means CPU-side timing can under-report the true cost of the fragment work.

For serious profiling, use GPU timer queries when available. MDN documents EXT_disjoint_timer_query as a WebGL extension for measuring GL command duration without stalling the pipeline, and notes that WebGL2 uses the WebGL2 variant. The extension is not guaranteed everywhere, so a production tool should fall back to frame-time tracking when it is missing.

The practical rule is this:

  • Use frame time to judge what users feel.
  • Use GPU timing to compare shader variants when the extension is available.
  • Ignore single-frame results; look for stable ranges after warm-up.
  • Record browser, OS, GPU, DPR, canvas size, and preset settings.

That is the same mindset behind the GPU benchmark interpretation guide: one number is not enough. Stability, resolution, thermals, and browser context all matter.

A practical tuning checklist

When a WebGL2 volume shader slows down, tune in this order:

  1. Cap preview DPR. Try 1.0, then 0.75 for heavy shaders.
  2. Lower ray steps. Move from 128 to 96, 96 to 64, or 64 to 48.
  3. Add early exit. Stop when opacity is high or the ray leaves the useful region.
  4. Simplify density. Reduce FBM octaves, fractal iterations, or nested noise.
  5. Debug step count. Make sure the shader is spending samples where detail is visible.
  6. Measure again after warm-up. Do not trust the first second of a run.

The fastest workflow is iterative: open a known-good preset, change one budget knob, then test the result. Use the learn section for core shader concepts, the editor for live changes, and the benchmark when you need hardware context.

FAQ

Is 128 ray steps too many for WebGL2?
Not always, but it is a high starting point for interactive volume work. If the shader includes expensive noise, fractal iterations, lighting, or high DPR, start closer to 64-96 and raise only when the image needs it.
Should I lower DPR or ray steps first?
Lower DPR first when the whole shader is too slow. Lower ray steps first when the image is already low resolution or when the cost clearly comes from over-sampling the volume.
Does WebGPU make this WebGL2 budget obsolete?
No. WebGPU changes what is possible for new graphics and compute work, but WebGL2 remains useful for portable shader editors and existing browser content. A clear budget also helps when porting later because it separates visual quality decisions from API decisions.
What should I test before sharing a shader?
Test the shader at the intended DPR, watch frame time for at least a short warm-up, check a mobile or laptop-class device if possible, and keep the heaviest loop bounds visible near the top of the shader.

Related Articles

Enjoyed this article?

Stay updated with the latest volume shading tutorials, techniques, and updates delivered straight to your inbox.

Subscribe to our newsletter