Add shadertoy-server example for WebGL shader rendering (#226)

Introduces a new MCP App example that renders ShaderToy-compatible GLSL
fragment shaders in real-time using WebGL 2.0 and ShaderToyLite.js.

Features:
- Standard ShaderToy uniforms (`iTime`, `iResolution`, `iMouse`, etc.)
- Multi-pass rendering with buffers A-D for feedback effects
- Shader compilation error overlay

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Hefner 2026-01-12 12:26:02 -06:00 • committed by GitHub
commit ec3409aa91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1641 additions and 1901 deletions

View file

@ -2,3 +2,4 @@ examples/basic-host/**/*.ts
examples/basic-host/**/*.tsx
examples/basic-server-*/**/*.ts
examples/basic-server-*/**/*.tsx
**/vendor/**

View file

@ -0,0 +1,132 @@
# Example: ShaderToy Server
A demo MCP App that renders [ShaderToy](https://www.shadertoy.com/)-compatible GLSL fragment shaders in real-time using WebGL 2.0 and [ShaderToyLite.js](https://github.com/nickoala/ShaderToyLite).
<table>
<tr>
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/01-gradient.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/01-gradient.png" alt="Gradient" width="100%"></a></td>
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/02-kaleidoscope.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/02-kaleidoscope.png" alt="Kaleidoscope" width="100%"></a></td>
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/03-fractal.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/shadertoy-server/03-fractal.png" alt="Kaleidoscope" width="100%"></a></td>
</tr>
</table>
## Features
- **Real-time Rendering**: Renders GLSL shaders using WebGL 2.0
- **ShaderToy Compatibility**: Uses the standard `mainImage(out vec4 fragColor, in vec2 fragCoord)` entry point
- **Multi-pass Rendering**: Supports buffers A-D for feedback effects, blur chains, and simulations
- **Standard Uniforms**: iResolution, iTime, iTimeDelta, iFrame, iMouse, iDate, iChannel0-3
## Running
1. Install dependencies:
```bash
npm install
```
2. Build and start the server:
```bash
npm run start:http # for Streamable HTTP transport
# OR
npm run start:stdio # for stdio transport
```
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
### Tool Input Examples
**Gradient with Time:**
```glsl
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0);
}
```
_Tool input:_
```json
{
"fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = fragCoord / iResolution.xy;\n fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0);\n}"
}
```
**Kaleidoscope**:
```glsl
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float segments = 6.0;
float zoom = 1.0 + 0.3 * sin(iTime * 0.2);
float angle = atan(uv.y, uv.x) + iTime * 0.3;
float r = length(uv) * zoom;
angle = mod(angle, 6.28 / segments);
angle = abs(angle - 3.14 / segments);
vec2 p = vec2(cos(angle), sin(angle)) * r;
p += iTime * 0.1;
float v = sin(p.x * 10.0) * sin(p.y * 10.0);
v += sin(length(p) * 15.0 - iTime * 2.0);
v += sin(p.x * 5.0 + p.y * 7.0 + iTime);
vec3 col = 0.5 + 0.5 * cos(v * 2.0 + vec3(0.0, 2.0, 4.0) + iTime);
fragColor = vec4(col, 1.0);
}
```
_Tool input:_
```json
{
"fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;\n float segments = 6.0;\n float zoom = 1.0 + 0.3 * sin(iTime * 0.2);\n float angle = atan(uv.y, uv.x) + iTime * 0.3;\n float r = length(uv) * zoom;\n angle = mod(angle, 6.28 / segments);\n angle = abs(angle - 3.14 / segments);\n vec2 p = vec2(cos(angle), sin(angle)) * r;\n p += iTime * 0.1;\n float v = sin(p.x * 10.0) * sin(p.y * 10.0);\n v += sin(length(p) * 15.0 - iTime * 2.0);\n v += sin(p.x * 5.0 + p.y * 7.0 + iTime);\n vec3 col = 0.5 + 0.5 * cos(v * 2.0 + vec3(0.0, 2.0, 4.0) + iTime);\n fragColor = vec4(col, 1.0);\n}"
}
```
**Interactive Julia Set** (mouse controls the fractal's c parameter):
```glsl
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * 2.5;
vec2 mouse = (iMouse.xy / iResolution.xy - 0.5) * 2.0;
vec2 c = mouse;
vec2 z = uv;
float iter = 0.0;
for (int i = 0; i < 100; i++) {
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
if (dot(z, z) > 4.0) break;
iter++;
}
float t = iter / 100.0;
vec3 col = 0.5 + 0.5 * cos(3.0 + t * 6.28 * 2.0 + vec3(0.0, 0.6, 1.0));
if (iter == 100.0) col = vec3(0.0);
fragColor = vec4(col, 1.0);
}
```
_Tool input:_
```json
{
"fragmentShader": "void mainImage(out vec4 fragColor, in vec2 fragCoord) {\n vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y * 2.5;\n vec2 mouse = (iMouse.xy / iResolution.xy - 0.5) * 2.0;\n vec2 c = mouse;\n vec2 z = uv;\n float iter = 0.0;\n for (int i = 0; i < 100; i++) {\n z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;\n if (dot(z, z) > 4.0) break;\n iter++;\n }\n float t = iter / 100.0;\n vec3 col = 0.5 + 0.5 * cos(3.0 + t * 6.28 * 2.0 + vec3(0.0, 0.6, 1.0));\n if (iter == 100.0) col = vec3(0.0);\n fragColor = vec4(col, 1.0);\n}"
}
```
## Architecture
### Server (`server.ts`)
Exposes a single `render-shadertoy` tool that accepts:
- `fragmentShader`: Main Image shader code (required)
- `common`: Shared code across all shaders (optional)
- `bufferA`: Buffer A shader, accessible as iChannel0 (optional)
- `bufferB`: Buffer B shader, accessible as iChannel1 (optional)
- `bufferC`: Buffer C shader, accessible as iChannel2 (optional)
- `bufferD`: Buffer D shader, accessible as iChannel3 (optional)
### App (`src/mcp-app.ts`)
- Receives shader code via `ontoolinput` handler
- Uses ShaderToyLite.js for WebGL rendering
- Displays compilation errors in an overlay

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>ShaderToy Renderer</title>
</head>
<body>
<main class="main">
<canvas id="canvas"></canvas>
</main>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>

View file

@ -0,0 +1,43 @@
{
"name": "@modelcontextprotocol/server-shadertoy",
"version": "0.1.0",
"type": "module",
"description": "MCP App Server example for rendering ShaderToy-compatible GLSL shaders",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/ext-apps",
"directory": "examples/shadertoy-server"
},
"license": "MIT",
"main": "server.ts",
"files": [
"server.ts",
"server-utils.ts",
"dist"
],
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun server.ts",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^0.3.1",
"@modelcontextprotocol/sdk": "^1.24.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}

View file

@ -0,0 +1,72 @@
/**
* Shared utilities for running MCP servers with Streamable HTTP transport.
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
export interface ServerOptions {
port: number;
name?: string;
}
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
* @param options - Server configuration options.
*/
export async function startServer(
createServer: () => McpServer,
options: ServerOptions,
): Promise<void> {
const { port, name = "MCP Server" } = options;
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
const httpServer = app.listen(port, (err) => {
if (err) {
console.error("Failed to start server:", err);
process.exit(1);
}
console.log(`${name} listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}

View file

@ -0,0 +1,156 @@
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type {
CallToolResult,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { startServer } from "./server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
const TOOL_DESCRIPTION = `Renders a ShaderToy-compatible GLSL fragment shader in real-time using WebGL 2.0.
SHADER FORMAT (ShaderToy conventions):
Use ShaderToy's mainImage entry point - do NOT use generic GLSL conventions.
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0);
}
Do NOT use: void main(), gl_FragColor, gl_FragCoord - these will not work.
AVAILABLE UNIFORMS:
- iResolution (vec3): viewport resolution in pixels
- iTime (float): elapsed time in seconds
- iTimeDelta (float): time since last frame
- iFrame (int): frame counter
- iMouse (vec4): mouse position (xy=current, zw=click)
- iDate (vec4): year, month, day, seconds
- iChannel0-3 (sampler2D): buffer inputs for multi-pass shaders
MULTI-PASS RENDERING:
- Use bufferA-D parameters for feedback effects, blur chains, simulations
- BufferA output -> iChannel0, BufferB -> iChannel1, etc.
- Buffers can sample their own previous frame for feedback loops
LIMITATIONS - Do NOT use:
- External textures (generate noise/patterns procedurally)
- Keyboard input (iKeyboard not available)
- Audio/microphone input
- VR features (mainVR not available)
For procedural noise:
float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }`;
const DEFAULT_FRAGMENT_SHADER = `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
fragColor = vec4(uv, 0.5 + 0.5*sin(iTime), 1.0);
}`;
/**
* Creates a new MCP server instance with tools and resources registered.
*/
export function createServer(): McpServer {
const server = new McpServer({
name: "ShaderToy Server",
version: "1.0.0",
});
const resourceUri = "ui://shadertoy/mcp-app.html";
// Register the render-shadertoy tool with UI metadata
registerAppTool(
server,
"render-shadertoy",
{
title: "ShaderToy Renderer",
description: TOOL_DESCRIPTION,
inputSchema: z.object({
fragmentShader: z
.string()
.default(DEFAULT_FRAGMENT_SHADER)
.describe("Main Image shader - ShaderToy GLSL code"),
common: z
.string()
.optional()
.describe("Common code shared across all shaders (optional)"),
bufferA: z
.string()
.optional()
.describe(
"Buffer A shader code - accessible as iChannel0 (optional)",
),
bufferB: z
.string()
.optional()
.describe(
"Buffer B shader code - accessible as iChannel1 (optional)",
),
bufferC: z
.string()
.optional()
.describe(
"Buffer C shader code - accessible as iChannel2 (optional)",
),
bufferD: z
.string()
.optional()
.describe(
"Buffer D shader code - accessible as iChannel3 (optional)",
),
}),
_meta: { ui: { resourceUri } },
},
async (): Promise<CallToolResult> => {
// Tool execution returns success - actual rendering happens in the UI
return {
content: [{ type: "text", text: "Shader rendered successfully" }],
};
},
);
// Register the resource which returns the bundled HTML/JavaScript for the UI
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3001", 10);
await startServer(createServer, { port, name: "ShaderToy Server" });
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,12 @@
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
}
code {
font-size: 1em;
}

View file

@ -0,0 +1,12 @@
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}

View file

@ -0,0 +1,126 @@
/**
* ShaderToy renderer MCP App using ShaderToyLite.js
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import "./global.css";
import "./mcp-app.css";
import ShaderToyLite, {
type ShaderToyLiteInstance,
} from "./vendor/ShaderToyLite.js";
interface ShaderInput {
fragmentShader: string;
common?: string;
bufferA?: string;
bufferB?: string;
bufferC?: string;
bufferD?: string;
}
function isShaderInput(value: unknown): value is ShaderInput {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).fragmentShader === "string"
);
}
const log = {
info: console.log.bind(console, "[APP]"),
warn: console.warn.bind(console, "[APP]"),
error: console.error.bind(console, "[APP]"),
};
// Get element references
const mainEl = document.querySelector(".main") as HTMLElement;
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
// Resize canvas to fill viewport
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// Handle host context changes (safe area insets)
function handleHostContextChanged(ctx: McpUiHostContext) {
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
// ShaderToyLite instance
let shaderToy: ShaderToyLiteInstance | null = null;
// Create app instance
const app = new App({ name: "ShaderToy Renderer", version: "1.0.0" });
app.onteardown = async () => {
log.info("App is being torn down");
if (shaderToy) {
shaderToy.pause();
}
return {};
};
app.ontoolinput = (params) => {
log.info("Received shader input");
if (!isShaderInput(params.arguments)) {
log.error("Invalid tool input");
return;
}
const { fragmentShader, common, bufferA, bufferB, bufferC, bufferD } =
params.arguments;
// Initialize ShaderToyLite if needed
if (!shaderToy) {
shaderToy = new ShaderToyLite("canvas");
}
// Set common code (shared across all shaders)
shaderToy.setCommon(common || "");
// Set buffer shaders with self-feedback
if (bufferA) {
shaderToy.setBufferA({ source: bufferA, iChannel0: "A" });
}
if (bufferB) {
shaderToy.setBufferB({ source: bufferB, iChannel1: "B" });
}
if (bufferC) {
shaderToy.setBufferC({ source: bufferC, iChannel2: "C" });
}
if (bufferD) {
shaderToy.setBufferD({ source: bufferD, iChannel3: "D" });
}
// Set main Image shader with buffer inputs
shaderToy.setImage({
source: fragmentShader,
iChannel0: bufferA ? "A" : undefined,
iChannel1: bufferB ? "B" : undefined,
iChannel2: bufferC ? "C" : undefined,
iChannel3: bufferD ? "D" : undefined,
});
shaderToy.play();
log.info("Setup complete");
};
app.onerror = log.error;
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
app.connect().then(() => {
log.info("Connected to host");
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});

View file

@ -0,0 +1,113 @@
/**
* Type declarations for ShaderToyLite.js
* https://github.com/chipweinberger/ShaderToyLite.js
*/
/** Valid buffer keys for channel inputs */
type BufferKey = "A" | "B" | "C" | "D";
/** Configuration for shader passes (buffers and image) */
interface ShaderConfig {
/** GLSL fragment shader source code */
source?: string;
/** Buffer to use as iChannel0 input */
iChannel0?: BufferKey;
/** Buffer to use as iChannel1 input */
iChannel1?: BufferKey;
/** Buffer to use as iChannel2 input */
iChannel2?: BufferKey;
/** Buffer to use as iChannel3 input */
iChannel3?: BufferKey;
}
/** ShaderToyLite instance methods */
interface ShaderToyLiteInstance {
/**
* Set common shader code shared across all passes.
* @param source - GLSL source code to prepend to all shaders
*/
setCommon(source?: string | null): void;
/**
* Set Buffer A shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferA(config?: ShaderConfig | null): void;
/**
* Set Buffer B shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferB(config?: ShaderConfig | null): void;
/**
* Set Buffer C shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferC(config?: ShaderConfig | null): void;
/**
* Set Buffer D shader pass.
* @param config - Shader configuration or falsy to clear
*/
setBufferD(config?: ShaderConfig | null): void;
/**
* Set the main Image shader pass (renders to screen).
* @param config - Shader configuration or falsy to clear
*/
setImage(config?: ShaderConfig | null): void;
/**
* Set a callback to be called on each draw frame.
* @param callback - Function to call each frame
*/
setOnDraw(callback?: (() => void) | null): void;
/**
* Add a custom texture that can be used as a channel input.
* @param texture - WebGL texture object
* @param key - Buffer key to reference this texture (only A/B/C/D are recognized by iChannel bindings)
*/
addTexture(texture: WebGLTexture, key: BufferKey): void;
/**
* Get the current playback time in seconds.
* @returns Time elapsed since first draw
*/
time(): number;
/**
* Check if the shader is currently playing.
* @returns True if playing, false if paused
*/
isPlaying(): boolean;
/**
* Reset playback to the beginning (time = 0, frame = 0).
*/
reset(): void;
/**
* Pause shader playback.
*/
pause(): void;
/**
* Start or resume shader playback.
*/
play(): void;
}
/** ShaderToyLite constructor interface */
interface ShaderToyLiteConstructor {
/**
* Create a new ShaderToyLite instance.
* @param canvasId - ID of the canvas element to render to
*/
new (canvasId: string): ShaderToyLiteInstance;
}
declare const ShaderToyLite: ShaderToyLiteConstructor;
export default ShaderToyLite;
export type { ShaderToyLiteInstance, ShaderConfig, BufferKey };

View file

@ -0,0 +1,437 @@
/**
* ShaderToyLite.js
* https://github.com/chipweinberger/ShaderToyLite.js
*
* Copyright (c) Chip Weinberger
* Licensed under the MIT License
* https://github.com/chipweinberger/ShaderToyLite.js/blob/master/LICENSE
*/
function ShaderToyLite(canvasId) {
var hdr =
`#version 300 es
#ifdef GL_ES
precision highp float;
precision highp int;
precision mediump sampler3D;
#endif
#define texture2D texture
uniform vec3 iResolution; // viewport resolution (in pixels)
uniform float iTime; // shader playback time (in seconds)
uniform float iTimeDelta; // render time (in seconds)
uniform float iFrameRate; // shader frame rate
uniform int iFrame; // shader playback frame
uniform float iChannelTime[4]; // channel playback time (in seconds)
uniform vec3 iChannelResolution[4]; // channel resolution (in pixels)
uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click
uniform sampler2D iChannel0; // input channel 0
uniform sampler2D iChannel1; // input channel 1
uniform sampler2D iChannel2; // input channel 2
uniform sampler2D iChannel3; // input channel 3
uniform vec4 iDate; // (year, month, day, unixtime in seconds)
uniform float iSampleRate; // sound sample rate (i.e., 44100)
out vec4 frag_out_color;
void mainImage( out vec4 c, in vec2 f );
void main( void )
{
vec4 color = vec4(0.0,0.0,0.0,0.0);
mainImage( color, gl_FragCoord.xy );
frag_out_color = vec4(color);
}
`;
var basicFragShader =
`void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
fragColor = texture2D(iChannel0, gl_FragCoord.xy / iResolution.xy);
}
`;
const basicVertexShader =
`#version 300 es
#ifdef GL_ES
precision highp float;
precision highp int;
precision mediump sampler3D;
#endif
in vec2 vertexInPosition;
void main() {
gl_Position = vec4(vertexInPosition, 0.0, 1.0);
}
`;
const quadVertices = new Float32Array([
-1.0, -1.0,
1.0, -1.0,
-1.0, 1.0,
1.0, 1.0,
-1.0, 1.0,
1.0, -1.0
]);
var opts = { alpha: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
antialias: true,
preserveDrawingBuffer: false,
powerPreference: "high-performance" };
var gl = document.getElementById(canvasId).getContext('webgl2', opts);
// timing
var isPlaying = false;
var firstDrawTime = 0;
var prevDrawTime = 0;
// callback
var onDrawCallback;
// uniforms
var iFrame = 0;
var iMouse = {x: 0, y: 0, clickX: 0, clickY: 0};
// shader common source
var common = "";
// render passes variables. valid keys:
// 'A', 'B', 'C', 'D', 'Image'
var sourcecode = {};// fragment shader code
var ichannels = {}; // texture inputs
var atexture = {}; // front texture (input/output)
var btexture = {}; // back texture (input/output)
var aframebuf = {}; // front buffer (output)
var bframebuf = {}; // back buffer (output)
var program = {}; // webgl program
var location = {}; // uniform location
var flip = {}; // a b flip
var quadBuffer; // two full screen triangles
var setup = () => {
gl.getExtension( 'OES_texture_float_linear');
gl.getExtension( 'OES_texture_half_float_linear');
gl.getExtension( 'EXT_color_buffer_float');
gl.getExtension( 'WEBGL_debug_shaders');
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
sourcecode[key] = "";
ichannels[key] = {};
program[key] = null;
location[key] = {};
if (key != 'Image') {
atexture[key] = createTexture();
btexture[key] = createTexture();
aframebuf[key] = createFrameBuffer(atexture[key]);
bframebuf[key] = createFrameBuffer(btexture[key]);
flip[key] = false;
}
});
// bind the geometry
quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW);
// Set viewport size
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
var canvas = document.getElementById(canvasId);
window.addEventListener('resize', function() {
gl.canvas.width = canvas.width;
gl.canvas.height = canvas.height;
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
});
canvas.addEventListener("mousemove", (event) => {
iMouse.x = event.offsetX;
iMouse.y = canvas.height - event.offsetY;
});
canvas.addEventListener("mousedown", (event) => {
iMouse.clickX = event.offsetX;
iMouse.clickY = canvas.height - event.offsetY;
});
canvas.addEventListener("mouseup", () => {
iMouse.clickX = 0;
iMouse.clickY = 0;
});
}
var createTexture = () => {
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, gl.canvas.width, gl.canvas.height, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
var createFrameBuffer = (texture) => {
var framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return framebuffer;
};
var compileProgram = (key) => {
var vert = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vert, basicVertexShader);
gl.compileShader(vert);
if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) {
console.error('Vertex Shader compilation failed: ' + gl.getShaderInfoLog(vert));
gl.deleteShader(vert);
return null;
}
var source = hdr + common + sourcecode[key];
var frag = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(frag, source);
gl.compileShader(frag);
if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) {
console.error('Fragment Shader compilation failed: ' + gl.getShaderInfoLog(frag));
console.error(source);
gl.deleteShader(frag);
return null;
}
var program = gl.createProgram();
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program initialization failed: ' + gl.getProgramInfoLog(program));
return null;
}
// uniform locations
location[key]["iResolution"] = gl.getUniformLocation(program, "iResolution");
location[key]["iTime"] = gl.getUniformLocation(program, "iTime");
location[key]["iTimeDelta"] = gl.getUniformLocation(program, "iTimeDelta");
location[key]["iFrameRate"] = gl.getUniformLocation(program, "iFrameRate");
location[key]["iFrame"] = gl.getUniformLocation(program, "iFrame");
location[key]["iChannelTime"] = gl.getUniformLocation(program, "iChannelTime[0]");
location[key]["iChannelResolution"] = gl.getUniformLocation(program, "iChannelResolution[0]");
location[key]["iChannel0"] = gl.getUniformLocation(program, "iChannel0");
location[key]["iChannel1"] = gl.getUniformLocation(program, "iChannel1");
location[key]["iChannel2"] = gl.getUniformLocation(program, "iChannel2");
location[key]["iChannel3"] = gl.getUniformLocation(program, "iChannel3");
location[key]["iMouse"] = gl.getUniformLocation(program, "iMouse");
location[key]["iDate"] = gl.getUniformLocation(program, "iDate");
location[key]["iSampleRate"] = gl.getUniformLocation(program, "iSampleRate");
location[key]["vertexInPosition"] = gl.getAttribLocation(program, "vertexInPosition");
return program;
};
var repeat = (times, arr) => {
let result = [];
for (let i = 0; i < times; i++) {
result = [...result, ...arr];
}
return result;
}
var setShader = (config, key) => {
if (config) {
if (config.source) {
sourcecode[key] = config.source;
program[key] = compileProgram(key);
if (program[key] == null) {
console.error("Failed to compile " + key);
}
}
for (let i = 0; i < 4; i++) {
var s = config[`iChannel${i}`];
if (s == "A" || s == "B" || s == "C" || s == "D") {
ichannels[key][i] = s;
}
}
} else {
sourcecode[key] = "";
program[key] = null;
}
};
var draw = () => {
// current time
var now = isPlaying ? Date.now() : prevDrawTime;
var date = new Date(now);
// first draw?
if (firstDrawTime == 0) {
firstDrawTime = now;
}
// call callback
if (onDrawCallback) {
onDrawCallback();
}
// time difference between frames in seconds
var iTimeDelta = (now - prevDrawTime) * 0.001;
// time in seconds
var iTime = (now - firstDrawTime) * 0.001;
var iDate = [date.getFullYear(), date.getMonth(), date.getDate(), date.getTime() * 0.001];
// channel uniforms
var iChannelTimes = new Float32Array(repeat(4, [iTime]));
var iChannelResolutions = new Float32Array(repeat(4, [gl.canvas.width, gl.canvas.height, 0]));
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
if (program[key]) {
// framebuffer
if (key === "Image") {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
} else {
var output = flip[key] ? bframebuf[key] : aframebuf[key];
gl.bindFramebuffer(gl.FRAMEBUFFER, output);
}
// textures
for (let i = 0; i < 4; i++) {
var chkey = ichannels[key][i];
if (chkey) {
var input = flip[chkey] ? atexture[chkey] : btexture[chkey];
gl.activeTexture(gl[`TEXTURE${i}`]);
gl.bindTexture(gl.TEXTURE_2D, input);
}
}
// program
gl.useProgram(program[key]);
// uniforms
gl.uniform3f( location[key]["iResolution"], gl.canvas.width, gl.canvas.height, 1.0);
gl.uniform1f( location[key]["iTime"], iTime);
gl.uniform1f( location[key]["iTimeDelta"], iTimeDelta);
gl.uniform1f( location[key]["iFrameRate"], 60);
gl.uniform1i( location[key]["iFrame"], iFrame);
gl.uniform1fv(location[key]["iChannelTime"], iChannelTimes);
gl.uniform3fv(location[key]["iChannelResolution"], iChannelResolutions);
gl.uniform1i( location[key]["iChannel0"], 0);
gl.uniform1i( location[key]["iChannel1"], 1);
gl.uniform1i( location[key]["iChannel2"], 2);
gl.uniform1i( location[key]["iChannel3"], 3);
gl.uniform4f( location[key]["iMouse"], iMouse.x, iMouse.y, iMouse.clickX, iMouse.clickY);
gl.uniform4f( location[key]["iDate"], iDate[0], iDate[1], iDate[2], iDate[3]);
gl.uniform1f( location[key]["iSampleRate"], 44100);
// viewport
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// vertexs
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.vertexAttribPointer(location[key]["vertexInPosition"], 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(location[key]["vertexInPosition"]);
// draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
flip[key] = !flip[key];
}
});
// time of last draw
prevDrawTime = now;
// frame counter
iFrame++;
};
// Animation loop
var animate = () => {
if (isPlaying) {
draw();
requestAnimationFrame(animate);
}
};
this.setCommon = (source) => {
if (source === undefined) {source = "";}
if (source === null) {source = "";}
common = source;
['A', 'B', 'C', 'D', 'Image'].forEach((key) => {
if (program[key]) {
program[key] = compileProgram(key);
}
});
};
this.setBufferA = (config) => {
setShader(config, 'A');
};
this.setBufferB = (config) => {
setShader(config, 'B');
};
this.setBufferC = (config) => {
setShader(config, 'C');
};
this.setBufferD = (config) => {
setShader(config, 'D');
};
this.setImage = (config) => {
setShader(config, 'Image');
};
this.setOnDraw = (callback) => {
onDrawCallback = callback;
}
this.addTexture = (texture, key) => {
atexture[key] = texture;
btexture[key] = texture;
flip[key] = false;
}
this.time = () => {
return (prevDrawTime - firstDrawTime) * 0.001;
}
this.isPlaying = () => isPlaying;
this.reset = () => {
var now = new Date();
firstDrawTime = now;
prevDrawTime = now;
iFrame = 0;
draw();
}
this.pause = () => {
isPlaying = false;
}
this.play = () => {
if (!isPlaying) {
isPlaying = true;
var now = Date.now();
var elapsed = prevDrawTime - firstDrawTime;
firstDrawTime = now - elapsed;
prevDrawTime = now;
animate();
}
}
setup();
}
export default ShaderToyLite;

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "server.ts"]
}

View file

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "dist",
emptyOutDir: false,
},
});

1716
package-lock.json generated

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,11 @@
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
// Dynamic element selectors to mask for screenshot comparison
//
// Note: CSS modules generate unique class names, so we use attribute selectors
// with partial matches (e.g., [class*="heatmapWrapper"]) for those components
//
// Note: map-server uses SLOW_SERVERS timeout instead of masking to wait for tiles
const DYNAMIC_MASKS: Record<string, string[]> = {
integration: ["#server-time"], // Server time display
"basic-preact": ["#server-time"], // Server time display
@ -13,7 +16,7 @@ const DYNAMIC_MASKS: Record<string, string[]> = {
"basic-vue": ["#server-time"], // Server time display
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
// Note: map-server uses SLOW_SERVERS timeout instead of masking to wait for tiles
shadertoy: ["#canvas"], // WebGL shader canvas (animated)
"system-monitor": [
".chart-container", // CPU chart (highly dynamic)
"#status-text", // Current timestamp
@ -46,6 +49,7 @@ const SERVERS = [
{ key: "customer-segmentation", name: "Customer Segmentation Server" },
{ key: "map-server", name: "CesiumJS Map Server" },
{ key: "scenario-modeler", name: "SaaS Scenario Modeler" },
{ key: "shadertoy", name: "ShaderToy Server" },
{ key: "sheet-music", name: "Sheet Music Server" },
{ key: "system-monitor", name: "System Monitor Server" },
{ key: "threejs", name: "Three.js Server" },

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB