Jump to a section
What you will build
The finished artifact is a single index.html file containing the canvas,
JavaScript WebGL runtime, vertex shader, and fragment shader. The file renders directly
in a browser and does not need Three.js, npm, a build system, or a web server.
index.html
GLSL shaders
browser
and inspect
and verify
Prerequisites
Software
- A plain-text editor such as VS Code, Zed, Sublime Text, or Notepad++.
- A current Chrome, Firefox, Edge, or Safari browser with WebGL enabled.
- Developer Tools access for reading shader and JavaScript errors.
Bitcoin
- An Ordinals-aware wallet or a configured
ordwallet. - A fresh receiving address intended for the inscription.
- Enough BTC to cover the inscription service cost and network fees.
How the one-file artwork works
JavaScript creates a WebGL context from the canvas, compiles one vertex shader and one fragment shader, links them into a GPU program, draws two triangles covering the screen, and updates uniforms every animation frame.
canvas
context
shader
shader
pixels
The vertex shader only places a fullscreen rectangle. The fragment shader does the visible work by calculating a color independently for each pixel.
Create the standalone HTML file
- Create a new folder for the artwork.
- Create a file named
index.html. - Paste the complete template below.
- Save the file before opening it in a browser.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGL Ordinal</title>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<canvas id="glcanvas" aria-label="Generative WebGL artwork"></canvas>
<script>
(() => {
"use strict";
const canvas = document.getElementById("glcanvas");
const gl = canvas.getContext("webgl", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false
});
if (!gl) {
document.body.innerHTML =
"<p style='color:white;font:16px sans-serif;padding:24px'>" +
"WebGL could not be initialized on this browser or device." +
"</p>";
return;
}
const vertexSource = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const fragmentSource = `
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
#define PI 3.14159265359
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv - 0.5;
// Preserve proportions on wide and tall displays.
p.x *= u_resolution.x / u_resolution.y;
float t = u_time * 0.32;
float d = length(p);
float angle = atan(p.y, p.x);
float fieldA = sin((p.x * 10.0) + cos(p.y * 8.0 - t));
float fieldB = cos((p.y * 13.0) - sin(p.x * 6.0 + t * 1.4));
float rings = sin((d * 22.0) - t * 2.0 + angle * 2.0);
float mouseField = sin(distance(uv, u_mouse) * 18.0 - t);
vec3 colorA = vec3(0.05, 0.74, 0.92);
vec3 colorB = vec3(0.92, 0.12, 0.54);
vec3 colorC = vec3(0.96, 0.76, 0.18);
float mixA = 0.5 + 0.5 * fieldA;
float mixB = 0.5 + 0.5 * fieldB;
vec3 color = mix(colorA, colorB, mixA);
color = mix(color, colorC, mixB * 0.55);
color += 0.12 * rings;
color += 0.08 * mouseField;
gl_FragColor = vec4(color, 1.0);
}
`;
function compileShader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(log || "Unknown shader compilation error.");
}
return shader;
}
function createProgram(vertexText, fragmentText) {
const program = gl.createProgram();
const vertexShader = compileShader(gl.VERTEX_SHADER, vertexText);
const fragmentShader = compileShader(gl.FRAGMENT_SHADER, fragmentText);
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(log || "Unknown shader linking error.");
}
return program;
}
let program;
try {
program = createProgram(vertexSource, fragmentSource);
} catch (error) {
console.error(error);
document.body.innerHTML =
"<pre style='white-space:pre-wrap;color:#ff9b9b;background:#160000;" +
"padding:24px;margin:0;min-height:100vh'>" +
"Shader error:
" + String(error.message || error) +
"</pre>";
return;
}
const positions = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1
]);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const positionLocation = gl.getAttribLocation(program, "a_position");
const resolutionLocation = gl.getUniformLocation(program, "u_resolution");
const mouseLocation = gl.getUniformLocation(program, "u_mouse");
const timeLocation = gl.getUniformLocation(program, "u_time");
const mouse = { x: 0.5, y: 0.5 };
function updatePointer(event) {
const rect = canvas.getBoundingClientRect();
mouse.x = (event.clientX - rect.left) / rect.width;
mouse.y = 1.0 - (event.clientY - rect.top) / rect.height;
}
window.addEventListener("pointermove", updatePointer, { passive: true });
function resizeCanvasToDisplaySize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function render(milliseconds) {
resizeCanvasToDisplaySize();
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
gl.uniform2f(mouseLocation, mouse.x, mouse.y);
gl.uniform1f(timeLocation, milliseconds * 0.001);
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(render);
}
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
console.warn("WebGL context lost.");
});
canvas.addEventListener("webglcontextrestored", () => {
window.location.reload();
});
requestAnimationFrame(render);
})();
</script>
</body>
</html>
Replace the fragment shader with your artwork
In the template, locate const fragmentSource = `...`;. Replace only the GLSL
inside that template literal. Keep the uniform names aligned with the JavaScript runtime
unless you also update the corresponding JavaScript locations.
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
#define PI 3.14159265359
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv - 0.5;
p.x *= u_resolution.x / u_resolution.y;
float t = u_time * 0.32;
float d = length(p);
float angle = atan(p.y, p.x);
float fieldA = sin((p.x * 10.0) + cos(p.y * 8.0 - t));
float fieldB = cos((p.y * 13.0) - sin(p.x * 6.0 + t * 1.4));
float rings = sin((d * 22.0) - t * 2.0 + angle * 2.0);
vec3 colorA = vec3(0.05, 0.74, 0.92);
vec3 colorB = vec3(0.92, 0.12, 0.54);
vec3 colorC = vec3(0.96, 0.76, 0.18);
vec3 color = mix(colorA, colorB, 0.5 + 0.5 * fieldA);
color = mix(color, colorC, (0.5 + 0.5 * fieldB) * 0.55);
color += 0.12 * rings;
gl_FragColor = vec4(color, 1.0);
}
The three uniforms
| Uniform | Type | Meaning |
|---|---|---|
u_resolution | vec2 | Actual canvas width and height in backing pixels. |
u_time | float | Elapsed animation time in seconds. |
u_mouse | vec2 | Normalized pointer position from 0.0 to 1.0. |
iResolution.xy to u_resolution,
iTime to u_time, and normalized mouse input to
u_mouse. Remove ShaderToy-only channels unless you inscribe or recursively
reference those resources.
Test locally before touching Bitcoin
- Double-click
index.htmlor drag it into the browser. - Resize the window and rotate a mobile device to confirm the shader stays proportional.
- Open Developer Tools and inspect the Console.
- Move the pointer to confirm interactive uniforms update.
- Leave the page running for several minutes to catch context or performance problems.
Expected result
You should see an animated, full-window abstract field with no scrollbars and no console errors. The image should fill the viewport without appearing horizontally stretched.
Preflight the final inscription file
- The file is named clearly and opens directly without a build step.
- Every required script and shader is embedded inside the HTML.
- There are no ordinary off-chain image, font, audio, CSS, or JavaScript requests.
- The shader compiles with no console errors in at least two browsers.
- The canvas handles desktop, mobile, portrait, landscape, and high-DPI screens.
- The artwork remains usable without keyboard, pointer, or audio input.
- The receiving address belongs to an Ordinals-aware wallet you control.
- You saved an exact local copy and a checksum of the file being inscribed.
Create a checksum
shasum -a 256 index.html
# Windows PowerShell:
Get-FileHash .\index.html -Algorithm SHA256
Save the checksum beside your project notes. It gives you a simple way to prove which local file corresponds to the inscribed payload.
Check file size and estimate fees
Larger files cost more because inscription content is included in transaction witness
data. For the standard ord wallet path, the official guide states that
inscription transactions must remain below 400,000 weight units for standard relay and
recommends keeping content below roughly 390,000 bytes to leave transaction overhead.
Measure the file
wc -c index.html
# Human-readable:
ls -lh index.html
# Windows PowerShell:
(Get-Item .\index.html).Length
Reduce size without changing the artwork
- Remove comments, unused functions, disabled shader experiments, and debug output.
- Shorten internal variable names only after the shader is final and backed up.
- Avoid external libraries when a few lines of native WebGL can do the same work.
- Do not minify blindly; retest the exact optimized file in the browser.
Inscribe with LooksOrdinal
LooksOrdinal currently presents a self-custodial inscription interface with a receiving address field, standard file inscriptions, fee-rate controls, fee estimation, and optional advanced settings.
- Open LooksOrdinal in Chrome or Firefox; its current page warns that Safari may not work.
- Paste the Ordinals-compatible receiving address from your wallet.
- Choose Standard, then files.
- Select the exact tested
index.html. - Review whether the file has already been inscribed if the interface offers that check.
- Select a fee rate based on current network conditions and your urgency.
- Use Estimate Fees before starting.
- Review padding, CPFP, turbo, backup, and tip options rather than enabling them blindly.
- Start the inscription and follow the displayed wallet/payment instructions.
- Keep the browser page open while the service explicitly tells you to do so.
Track the transactions
An inscription uses a commit transaction followed by a reveal transaction. Use the transaction links supplied by the service and inspect them in a Bitcoin block explorer such as mempool.space. The inscription becomes available after the reveal is mined and indexed.
Optional: inscribe with the ord wallet CLI
The command-line path is useful when you run Bitcoin Core and ord, control
the wallet directly, and want the official workflow rather than a hosted interface.
# Fund the ord wallet first.
ord wallet receive
ord wallet outputs
# Inscribe the completed standalone HTML file.
ord wallet inscribe \
--fee-rate <SATS_PER_VBYTE> \
--file index.html
# Check the wallet after the reveal confirms.
ord wallet inscriptions
Verify the on-chain result
- Wait for the reveal transaction to confirm.
- Open the inscription in an Ordinals-compatible explorer.
- Open its direct content view, not only a marketplace preview.
- Confirm animation, resolution, pointer behavior, and mobile layout.
- Compare the displayed content with your local copy and saved checksum.
- Record the inscription ID, inscription number, reveal transaction, sat number, and receiving address.
Understanding the inscription ID
The ID uses the form TXIDiN: the transaction ID of the reveal followed by
i and the inscription’s index inside that reveal transaction.
Troubleshooting
| Problem | Likely cause | Action |
|---|---|---|
| Black or blank canvas | Shader compile failure, WebGL unavailable, or program link failure. | Open the console. The starter template prints the exact compiler/linker log. |
precision error |
The fragment shader has no floating-point precision declaration. | Add precision highp float; or fall back to mediump when required. |
| Artwork stretches on wide screens | Coordinates ignore aspect ratio. | Multiply centered p.x by u_resolution.x / u_resolution.y. |
| Animation is too fast | Milliseconds were used directly. | Convert the animation timestamp to seconds with * 0.001. |
| Looks different after inscription | Off-chain resources were blocked or explorer sandbox behavior differs. | Make the file self-contained or use supported recursive on-chain endpoints. |
| Transaction remains pending | Fee rate is below current demand. | Inspect commit and reveal separately; use CPFP only when the chosen workflow supports it and you understand the cost. |
| File rejected as too large | Tool or standard relay limit exceeded. | Reduce the file, use recursion for shared on-chain resources, or follow the tool’s documented alternative workflow. |
Final checklist
- I am inscribing the exact final file I tested.
- The file is self-contained or uses only intentional recursive on-chain references.
- The shader compiles in multiple current browsers.
- The file is below the limit shown by my chosen workflow.
- The receiving address belongs to an Ordinals-aware wallet I control.
- I reviewed the current fee rate and total estimated cost.
- I recorded the local SHA-256 checksum.
- I understand the commit/reveal sequence and will verify both transactions.