../

WebGL

WebGL2 (OpenGL ES 3.0 in the browser) typed for TypeScript: context, GLSL ES 3.00 shaders, buffers and VAOs, textures, uniforms and UBOs, state, framebuffers, instancing, context loss and debugging. For 2D drawing see Canvas 2D; for the newer API see WebGPU.

Support & typing

FeatureStatus (MDN)Notes
WebGL 1Baseline widely availableOpenGL ES 2.0 feature set
WebGL2Baseline widely availableBaseline 2021; Chrome 56, Firefox 51, Safari 15
WebGL in OffscreenCanvas / workersBaseline widely availableSafari 16.4
drawingBufferColorSpace (P3)Baseline 2024
webglcontextlost / restored eventsBaseline widely availablehandle them, see below

Browser support is not device support: a blocklisted GPU or driver gets null from getContext. Always check.

TS typeNotes
WebGL2RenderingContextcanvas.getContext("webgl2") returns it | null
WebGLContextAttributesoptions object for getContext
WebGLProgram, WebGLShader, WebGLBufferopaque handles
WebGLTexture, WebGLFramebuffer, WebGLRenderbufferopaque handles
WebGLVertexArrayObjectVAO handle (WebGL2)
WebGLUniformLocation | nullnull when the uniform was optimized away
GLenum, GLint, GLfloatall aliases of number
getParameter(), getShaderParameter()return any; annotate the result
getExtension("NAME")typed overloads per extension, | null
const max: number = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// Constants exist on the instance and the class
gl.TRIANGLES === WebGL2RenderingContext.TRIANGLES; // true

Pipeline overview

 JS / TS (CPU)                              GPU
 ─────────────                              ───
 Float32Array ─ bufferData ─► ARRAY_BUFFER ─┐
 Uint16Array  ─ bufferData ─► ELEMENT_ARRAY ┤ described by a VAO
                                            ▼
 uniforms, UBOs, textures ─────────► vertex shader  (runs per vertex)
                                            │  gl_Position (clip space)
                                            ▼
                               primitive assembly + clipping
                               ÷ w → NDC → viewport → window px
                                            │  face culling
                                            ▼
                                      rasterisation
                                            │  varyings interpolated
                                            ▼
 uniforms, textures ───────────────► fragment shader (runs per pixel)
                                            │  out vec4 color
                                            ▼
                       scissor → stencil → depth → blend → framebuffer
                       (canvas drawing buffer or your FBO/texture)

Context creation

const gl = canvas.getContext("webgl2", {
  antialias: true,
  powerPreference: "high-performance",
});
if (!gl) throw new Error("WebGL2 unavailable");
AttributeDefaultEffect
alphatruetransparent canvas; false is cheaper to composite
antialiastrueMSAA on the default framebuffer (not on your FBOs)
depthtrue16+ bit depth buffer
stencilfalse8-bit stencil buffer
premultipliedAlphatruethe page expects premultiplied output colors
preserveDrawingBufferfalsekeep pixels after compositing; needed for late toBlob(); slower
powerPreference"default""high-performance" or "low-power" GPU hint
failIfMajorPerformanceCaveatfalsereturn null on software rendering
desynchronizedfalselow-latency, may tear (Chromium)
xrCompatiblefalseready for WebXR

canvas.width/height set the drawing buffer size; gl.drawingBufferWidth/Height report what you actually got (can be smaller). Call gl.viewport after every resize. HiDPI sizing works as in Canvas 2D.

Shaders (GLSL ES 3.00)

vertex.glsl
#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aUv;
 
uniform mat4 uMvp;
 
out vec2 vUv;
 
void main() {
  vUv = aUv;
  gl_Position = uMvp * vec4(aPosition, 1.0);
}
fragment.glsl
#version 300 es
precision highp float;
 
in vec2 vUv;
uniform sampler2D uTex;
uniform vec4 uTint;
 
out vec4 outColor;
 
void main() {
  outColor = texture(uTex, vUv) * uTint;
}
SyntaxMeaning
#version 300 esmust be the very first line, no blank line or space before it
precision highp float;required in fragment shaders; mediump saves power on mobile
in (vertex)per-vertex attribute
out (vertex) → in (fragment)varying, interpolated across the triangle
flat out / flat inno interpolation; required for int/uint varyings
out vec4 x (fragment)color output; replaces gl_FragColor
layout(location = n)fix an attribute or output location
uniformconstant for a draw call
texture(s, uv)sample; replaces texture2D
texelFetch(s, ivec2, lod)exact texel, no filtering
TypesNotes
float, int, uint, boolno implicit int→float: write 1.0, not 1
vec2..4, ivec2..4, uvec2..4, bvec2..4swizzle with .xyzw, .rgba, .stpq
mat2, mat3, mat4, mat3x4 …column-major; m * v
sampler2D, sampler3D, samplerCube, sampler2DArrayfloat textures
isampler2D, usampler2Dinteger textures
sampler2DShadowdepth compare for shadow maps
Built-inStageMeaning
gl_Positionvertex (out)clip-space position
gl_PointSizevertex (out)size for POINTS
gl_VertexID, gl_InstanceIDvertex (in)vertex / instance index
gl_FragCoordfragment (in)window px, origin bottom-left
gl_FrontFacingfragment (in)triangle faces the camera
gl_FragDepthfragment (out)override depth (disables early-z)

Buffers, attributes & VAOs

A VAO records attribute layout and the element buffer binding; bind it and draw.

const positions = new Float32Array([
  // x, y, z,  u, v
  -1, -1, 0,  0, 0,
   1, -1, 0,  1, 0,
   0,  1, 0,  0.5, 1,
]);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
 
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
 
const stride = 5 * 4; // bytes per vertex
gl.enableVertexAttribArray(0); // aPosition
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1); // aUv
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 12);
 
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // saved in VAO
gl.bufferData(
  gl.ELEMENT_ARRAY_BUFFER,
  new Uint16Array([0, 1, 2]),
  gl.STATIC_DRAW,
);
gl.bindVertexArray(null);
CallNotes
bufferData(target, data, usage)allocate + upload; usage STATIC_DRAW, DYNAMIC_DRAW, STREAM_DRAW
bufferSubData(target, offset, data)update part of an existing buffer
vertexAttribPointer(loc, size, type, norm, stride, offset)float attribute; norm maps ints to 0–1
vertexAttribIPointer(loc, size, type, stride, offset)integer attribute (ivec/uvec in GLSL)
getAttribLocation(prog, name)-1 if unused; prefer layout(location)
vertexAttribDivisor(loc, 1)advance per instance, not per vertex

Drawing & instancing

CallUse
drawArrays(mode, first, count)vertices in order
drawElements(mode, count, type, offset)via index buffer; type UNSIGNED_SHORT or UNSIGNED_INT
drawArraysInstanced(mode, first, count, n)n copies, gl_InstanceID 0…n-1
drawElementsInstanced(mode, count, type, offset, n)indexed + instanced
drawRangeElements(mode, start, end, count, type, offset)hint the index range
drawBuffers([...])pick color attachments to write (MRT)
Primitive (mode)Vertices → shapes
POINTSeach vertex is a square of gl_PointSize px
LINESpairs: 0-1, 2-3
LINE_STRIPconnected: 0-1-2-3
LINE_LOOPstrip plus last→first
TRIANGLEStriples: 0-1-2, 3-4-5 (most common)
TRIANGLE_STRIPeach new vertex adds a triangle with the previous two
TRIANGLE_FANall triangles share vertex 0

Line width above 1 is not supported on most platforms: draw thick lines as triangles.

// One attribute per instance: offsets for 1000 quads
const offsets = new Float32Array(1000 * 2);
const inst = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, inst);
gl.bufferData(gl.ARRAY_BUFFER, offsets, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 0, 0);
gl.vertexAttribDivisor(2, 1); // one value per instance
gl.useProgram(prog); // shader reads attribute 2
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, 1000);

A mat4 attribute takes four consecutive locations, each with its own pointer and divisor.

Textures

const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0); // unit 0
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
  gl.TEXTURE_2D, 0, gl.RGBA8, // level, internal format
  gl.RGBA, gl.UNSIGNED_BYTE, bitmap, // format, type, src
);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
  gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER,
  gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S,
  gl.CLAMP_TO_EDGE);
// Sampler uniform = texture unit index
gl.useProgram(prog);
gl.uniform1i(gl.getUniformLocation(prog, "uTex"), 0);
ParameterValues
TEXTURE_MIN_FILTERNEAREST, LINEAR, *_MIPMAP_* (needs mipmaps)
TEXTURE_MAG_FILTERNEAREST (pixel art), LINEAR
TEXTURE_WRAP_S / _T / _RREPEAT, CLAMP_TO_EDGE, MIRRORED_REPEAT
TEXTURE_MAX_ANISOTROPY_EXTvia EXT_texture_filter_anisotropic
Internal formatFormat / typeUse
RGBA8RGBA / UNSIGNED_BYTEdefault color
SRGB8_ALPHA8RGBA / UNSIGNED_BYTEcolor images, correct filtering in linear space
R8, RG8RED, RG / UNSIGNED_BYTEmasks, data
RGBA16FRGBA / HALF_FLOATHDR
RGBA32FRGBA / FLOATdata; linear filtering needs OES_texture_float_linear
R32UIRED_INTEGER / UNSIGNED_INTinteger ids (usampler2D)
DEPTH_COMPONENT24DEPTH_COMPONENT / UNSIGNED_INTshadow maps
FactDetail
Default texture with a mipmap MIN_FILTER and no mipmapssamples black ("incomplete")
WebGL2non-power-of-two sizes support mipmaps and REPEAT
texStorage2D(target, levels, fmt, w, h)immutable storage; then texSubImage2D
pixelStorei(UNPACK_FLIP_Y_WEBGL, true)flips DOM image sources; not ImageBitmap (use imageOrientation: "flipY")
pixelStorei(UNPACK_ALIGNMENT, 1)tight rows for 1- or 3-byte formats
UnitsMAX_COMBINED_TEXTURE_IMAGE_UNITS is at least 32 in WebGL2
Cross-origin imagesneed CORS or texImage2D throws SecurityError

Uniforms & UBOs

gl.useProgram(prog); // uniforms target the bound program
const uTint = gl.getUniformLocation(prog, "uTint");
const uMvp = gl.getUniformLocation(prog, "uMvp");
gl.uniform4f(uTint, 1, 0.5, 0.2, 1);
gl.uniformMatrix4fv(uMvp, false, mvp); // column-major
SetterGLSL type
uniform1f / uniform1fvfloat / float[]
uniform2f..4f, uniform2fv..4fvvec2..4
uniform1iint, bool, sampler* (texture unit)
uniform1ui..4uiuint, uvec*
uniformMatrix2fv..4fv(loc, transpose, data)mat2..4; transpose usually false

Look locations up once after linking and cache them. Uniform values belong to the program, so they persist across frames.

Uniform buffer objects

Share one block across programs and update it with one upload. Layout std140: scalars take 4 bytes, vec2 8, vec3 and vec4 16, each array element and matrix column is padded to 16.

layout(std140) uniform Camera {
  mat4 view;      // offset 0
  mat4 proj;      // offset 64
  vec4 eye;       // offset 128 (vec3 would pad anyway)
};
const CAMERA = 0; // binding point
const idx = gl.getUniformBlockIndex(prog, "Camera");
gl.uniformBlockBinding(prog, idx, CAMERA);
 
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, 144, gl.DYNAMIC_DRAW);
gl.bindBufferBase(gl.UNIFORM_BUFFER, CAMERA, ubo);
 
// per frame: 36 floats = view(16) + proj(16) + eye(4)
const data = new Float32Array(36);
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferSubData(gl.UNIFORM_BUFFER, 0, data);

Coordinate spaces & matrices

object (model) space
   │  model matrix        scale · rotate · translate the mesh
   ▼
world space
   │  view matrix         inverse of the camera transform
   ▼
view (eye) space          camera at origin, looking down -Z
   │  projection matrix   perspective or orthographic
   ▼
clip space                gl_Position = P · V · M · vec4(pos, 1)
   │  ÷ w (perspective divide)
   ▼
NDC                       x, y, z in -1..1 (WebGPU: z in 0..1)
   │  viewport + depthRange
   ▼
window px                 origin bottom-left, depth 0..1
import { mat4 } from "gl-matrix";
 
const aspect = canvas.clientWidth / canvas.clientHeight;
const proj = mat4.perspective(
  mat4.create(), Math.PI / 4, aspect, 0.1, 100,
);
const view = mat4.lookAt(
  mat4.create(), [0, 2, 5], [0, 0, 0], [0, 1, 0],
);
const model = mat4.fromYRotation(mat4.create(), 0.5);
const mvp = mat4.create();
mat4.multiply(mvp, proj, view);
mat4.multiply(mvp, mvp, model); // P · V · M
gl.uniformMatrix4fv(uMvp, false, mvp);
FactDetail
StorageFloat32Array(16), column-major (translation in elements 12–14)
Orderread right to left: P · V · M · v
Normalstransform with the inverse transpose of the model matrix (mat3)
Ortho for 2Dmat4.ortho(out, 0, w, h, 0, -1, 1) gives a top-left pixel origin
Librariesgl-matrix (WebGL conventions), wgpu-matrix (WebGPU z range)

Depth, blending & culling

StateCallsTypical
Depth testenable(DEPTH_TEST), depthFunc(LESS)opaque 3D
Depth writesdepthMask(false)transparent pass after opaque
Blendingenable(BLEND), blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)straight alpha
PremultipliedblendFunc(ONE, ONE_MINUS_SRC_ALPHA)matches the default canvas
AdditiveblendFunc(ONE, ONE)glow, particles
Separate alphablendFuncSeparate(srcRGB, dstRGB, srcA, dstA)keep canvas alpha sane
EquationblendEquation(FUNC_ADD | MIN | MAX)MIN/MAX built in to WebGL2
Cullingenable(CULL_FACE), cullFace(BACK), frontFace(CCW)closed meshes
Scissorenable(SCISSOR_TEST), scissor(x, y, w, h)clip to a rect (bottom-left origin)
ClearclearColor, clearDepth, clear(COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT)start of each frame
MaskscolorMask(r, g, b, a), stencilFunc, stencilOpoutlines, portals

State is global to the context and sticks until changed. Set what each pass needs at its start rather than assuming what the last pass left.

Transparency: draw opaque objects first, then transparent ones back-to-front with depth writes off.

Framebuffers & render-to-texture

function createTarget(
  gl: WebGL2RenderingContext, w: number, h: number,
) {
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, w, h);
  gl.texParameteri(gl.TEXTURE_2D,
    gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  const depth = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
  gl.renderbufferStorage(gl.RENDERBUFFER,
    gl.DEPTH_COMPONENT24, w, h);
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER,
    gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  if (status !== gl.FRAMEBUFFER_COMPLETE) {
    const hex = status.toString(16);
    throw new Error(`FBO incomplete: 0x${hex}`);
  }
  return { fbo, tex, depth };
}
TaskHow
Render into itbindFramebuffer(FRAMEBUFFER, fbo), viewport(0, 0, w, h), draw
Back to canvasbindFramebuffer(FRAMEBUFFER, null), restore viewport
Multiple render targetsattach COLOR_ATTACHMENT0..n, call drawBuffers([...]), write layout(location = i) out
MSAA offscreenrenderbufferStorageMultisample, then blitFramebuffer into a texture FBO
Float targetsgetExtension("EXT_color_buffer_float") first, else incomplete
Read pixelsreadPixels(x, y, w, h, RGBA, UNSIGNED_BYTE, out); stalls the GPU
Post-processing chainsping-pong two targets; never sample the texture you are writing (feedback loop error)

Context loss

The GPU process can reset (driver crash, too many contexts, device sleep). Every GL object is then gone.

let rafId = 0;
canvas.addEventListener("webglcontextlost", (e) => {
  e.preventDefault(); // allow a restore
  cancelAnimationFrame(rafId);
});
canvas.addEventListener("webglcontextrestored", () => {
  initGL(); // recreate programs, buffers, textures
  rafId = requestAnimationFrame(render);
});
 
// Simulate in development
gl.getExtension("WEBGL_lose_context")?.loseContext();
FactDetail
Without preventDefault()the context is never restored
While lostcalls are no-ops, getError() returns CONTEXT_LOST_WEBGL, create* may return null
Checkgl.isContextLost()
KeepCPU copies (or URLs) of everything you upload, so init can rerun
Limitbrowsers cap live contexts (about 16); the oldest is lost first
TSthe listener gets Event; cast to WebGLContextEvent for statusMessage

Debugging & extensions

ToolUse
getShaderInfoLog, getProgramInfoLogcompile / link errors with line numbers
gl.getError()first error flag since last call; forces a sync, dev builds only
KHR_parallel_shader_compilepoll COMPLETION_STATUS_KHR instead of blocking on link
WEBGL_debug_shadersgetTranslatedShaderSource: what ANGLE made of your GLSL
WEBGL_debug_renderer_infoUNMASKED_RENDERER_WEBGL GPU name, for bug reports
Spector.js (opens in a new tab)browser extension: capture a frame, inspect every call and state
Browser GPU toolschrome://gpu, Firefox about:support show blocklist and driver info
getError()Usual cause
INVALID_ENUMwrong constant for this call
INVALID_VALUEnegative size, out-of-range index
INVALID_OPERATIONnothing bound, program not linked, type mismatch
INVALID_FRAMEBUFFER_OPERATIONdrawing into an incomplete FBO
OUT_OF_MEMORYtexture or buffer too large
CONTEXT_LOST_WEBGLsee Context loss

Useful WebGL2 extensions. getExtension returns null when the GPU lacks one, whatever the browser.

ExtensionGivesAvailability (MDN)
EXT_color_buffer_floatrender to R/RG/RGBA16F/32FBaseline widely available
EXT_color_buffer_half_floatrender to half floatBaseline widely available
OES_texture_float_linearlinear filtering of 32-bit float texturesBaseline widely available
EXT_texture_filter_anisotropicsharper textures at grazing anglesBaseline widely available
WEBGL_lose_contextsimulate context lossBaseline widely available
WEBGL_debug_renderer_inforeal GPU nameBaseline widely available
WEBGL_compressed_texture_s3tcBC1–3 (desktop GPUs)not on most mobile
WEBGL_compressed_texture_astc / _etcmobile compressed texturesBaseline widely available
KHR_parallel_shader_compilenon-blocking compileChromium, Safari; not Firefox
WEBGL_multi_drawmany draws in one callChromium, Safari; not Firefox
EXT_disjoint_timer_query_webgl2GPU timingChromium only
OVR_multiview2single-pass stereo (VR)Windows (ANGLE) only

Libraries & WebGPU

LibraryLevelNotes
three.js (opens in a new tab)3D scene graphr186; WebGPURenderer falls back to WebGL2; TSL node shaders
Babylon.js (opens in a new tab)3D enginev9; WebGL2 and WebGPU, editor, physics, Spector.js author
PixiJS (opens in a new tab)2D rendererv8; WebGL2 or WebGPU, sprites and text at scale
twgl.js (opens in a new tab)thin helpersv7; cuts boilerplate, keeps raw GL
regl (opens in a new tab)functional wrapperv2; WebGL1 API, stable but quiet
gl-matrixmathmat4, vec3, quat in Float32Array
WebGL2WebGPU
SupportBaseline widely availableLimited availability (2026)
Modelglobal state machineimmutable pipelines + bind groups
ShadersGLSL ES 3.00WGSL
Computenone (fake it with fragment passes)compute shaders, storage buffers
CPU overheadhigh per draw calllow, prerecorded command buffers
ErrorsgetError() pollingerror scopes, uncapturederror, device.lost
NDC depth-1..10..1
Choose it whenreach matters, or an existing engine handles itcompute, big scenes, new projects with a fallback

Recipes

Use as the base of any raw WebGL2 code; it throws with the driver log (ERROR: 0:LINE: …).

function compile(
  gl: WebGL2RenderingContext, type: GLenum, src: string,
): WebGLShader {
  const sh = gl.createShader(type)!; // null only if lost
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  const ok = gl.getShaderParameter(sh, gl.COMPILE_STATUS);
  if (ok) return sh;
  const log = gl.getShaderInfoLog(sh);
  gl.deleteShader(sh);
  throw new Error(`Shader compile failed: ${log}`);
}
 
export function createProgram(
  gl: WebGL2RenderingContext, vs: string, fs: string,
): WebGLProgram {
  const p = gl.createProgram();
  gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vs));
  gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(p);
  if (gl.getProgramParameter(p, gl.LINK_STATUS)) return p;
  const log = gl.getProgramInfoLog(p);
  throw new Error(`Program link failed: ${log}`);
}

Minimal WebGL2 triangle

Use as a smoke test for a new canvas; builds on createProgram above.

const VS = `#version 300 es
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FS = `#version 300 es
precision highp float;
out vec4 color;
void main() { color = vec4(1.0, 0.4, 0.2, 1.0); }`;
 
const canvas = document.querySelector("canvas")!;
const gl = canvas.getContext("webgl2");
if (!gl) throw new Error("WebGL2 unavailable");
const prog = createProgram(gl, VS, FS);
 
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
const tri = new Float32Array([0, .8, -.8, -.8, .8, -.8]);
gl.bufferData(gl.ARRAY_BUFFER, tri, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
 
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(prog);
gl.drawArrays(gl.TRIANGLES, 0, 3);

Fullscreen fragment shader (Shadertoy style)

Use for generative art and post effects: one oversized triangle from gl_VertexID, no buffers.

const VS = `#version 300 es
void main() {
  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const FS = `#version 300 es
precision highp float;
uniform vec2 iResolution; uniform float iTime;
out vec4 fragColor;
void main() {
  vec2 uv = gl_FragCoord.xy / iResolution;
  vec3 c = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0, 2, 4));
  fragColor = vec4(c, 1.0);
}`;
const prog = createProgram(gl, VS, FS);
const uRes = gl.getUniformLocation(prog, "iResolution");
const uTime = gl.getUniformLocation(prog, "iTime");
gl.useProgram(prog); // default VAO, no attributes needed
requestAnimationFrame(function frame(t) {
  resizeToDisplay(gl); // last recipe
  gl.uniform2f(uRes, gl.canvas.width, gl.canvas.height);
  gl.uniform1f(uTime, t / 1000);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
  requestAnimationFrame(frame);
});

Load a texture from a URL

Use for images and sprite sheets; decodes off-thread and flips for GL's bottom-left origin.

async function loadTexture(
  gl: WebGL2RenderingContext, url: string,
): Promise<WebGLTexture> {
  const res = await fetch(url, { mode: "cors" });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
  const bmp = await createImageBitmap(await res.blob(), {
    imageOrientation: "flipY",
    premultiplyAlpha: "none",
  });
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.SRGB8_ALPHA8,
    gl.RGBA, gl.UNSIGNED_BYTE, bmp);
  gl.generateMipmap(gl.TEXTURE_2D);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
    gl.LINEAR_MIPMAP_LINEAR);
  bmp.close();
  return tex;
}

Resize to the display size

Use at the top of every frame; it only reallocates when the CSS size or DPR actually changed.

function resizeToDisplay(
  gl: WebGL2RenderingContext, maxDpr = 2,
): boolean {
  const c = gl.canvas as HTMLCanvasElement;
  const dpr = Math.min(window.devicePixelRatio, maxDpr);
  const w = Math.round(c.clientWidth * dpr);
  const h = Math.round(c.clientHeight * dpr);
  const changed = c.width !== w || c.height !== h;
  if (changed) {
    c.width = w;
    c.height = h;
  }
  gl.viewport(0, 0, gl.drawingBufferWidth,
    gl.drawingBufferHeight);
  return changed; // true: rebuild size-dependent FBOs
}

Capping DPR at 2 keeps fill-rate sane on 3× phones. For exact device pixels use the ResizeObserver recipe in Canvas 2D.

References