@tschk/moonshine-shaders 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -0
  2. package/package.json +35 -0
  3. package/src/index.ts +318 -0
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # `@tschk/moonshine-shaders`
2
+
3
+ Optional WebGL fragment helpers for Moonshine React apps.
4
+
5
+ ```ts
6
+ import { useFragmentShader } from "@tschk/moonshine-shaders";
7
+ // compat re-export:
8
+ import { useFragmentShader } from "@tschk/moonshine/shaders";
9
+ ```
10
+
11
+ See [Moonshine README](../../README.md).
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@tschk/moonshine-shaders",
3
+ "version": "0.3.1",
4
+ "description": "Optional WebGL fragment helpers for moonshine React apps",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "peerDependencies": {
13
+ "react": ">=18"
14
+ },
15
+ "peerDependenciesMeta": {
16
+ "react": {
17
+ "optional": true
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "keywords": [
25
+ "moonshine",
26
+ "webgl",
27
+ "shaders"
28
+ ],
29
+ "scripts": {
30
+ "format": "bunx prettier --check .",
31
+ "lint": "bunx oxlint .",
32
+ "typecheck": "exit 0",
33
+ "test": "exit 0"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { useEffect, useRef, type RefObject } from "react";
2
+
3
+ const DEFAULT_VERTEX = `#version 300 es
4
+ precision highp float;
5
+ const vec2 verts[3] = vec2[3](
6
+ vec2(-1.0, -1.0),
7
+ vec2( 3.0, -1.0),
8
+ vec2(-1.0, 3.0)
9
+ );
10
+ void main() {
11
+ gl_Position = vec4(verts[gl_VertexID], 0.0, 1.0);
12
+ }
13
+ `;
14
+
15
+ const DEFAULT_VERTEX_WEBGL1 = `
16
+ attribute vec2 a_pos;
17
+ void main() {
18
+ gl_Position = vec4(a_pos, 0.0, 1.0);
19
+ }
20
+ `;
21
+
22
+ export type FragmentShaderHandle = {
23
+ canvasRef: RefObject<HTMLCanvasElement | null>;
24
+ /** WebGL context once mounted, else null. */
25
+ gl: WebGLRenderingContext | WebGL2RenderingContext | null;
26
+ /** Recompile with a new fragment source. */
27
+ setSource: (source: string) => void;
28
+ };
29
+
30
+ export type UseFragmentShaderOptions = {
31
+ /** Prefer WebGL2 (default true). Falls back to WebGL1. */
32
+ webgl2?: boolean;
33
+ /** Device pixel ratio multiplier (default devicePixelRatio). */
34
+ dpr?: number;
35
+ /** Uniform `u_time` in seconds from mount. */
36
+ animate?: boolean;
37
+ /** Called each frame after draw (when animate). */
38
+ onFrame?: (
39
+ t: number,
40
+ gl: WebGLRenderingContext | WebGL2RenderingContext,
41
+ ) => void;
42
+ };
43
+
44
+ function compileShader(
45
+ gl: WebGLRenderingContext | WebGL2RenderingContext,
46
+ type: number,
47
+ source: string,
48
+ ): WebGLShader {
49
+ const shader = gl.createShader(type);
50
+ if (!shader) throw new Error("useFragmentShader: createShader failed");
51
+ gl.shaderSource(shader, source);
52
+ gl.compileShader(shader);
53
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
54
+ const info = gl.getShaderInfoLog(shader) ?? "unknown compile error";
55
+ gl.deleteShader(shader);
56
+ throw new Error(`useFragmentShader: shader compile failed: ${info}`);
57
+ }
58
+ return shader;
59
+ }
60
+
61
+ function linkProgram(
62
+ gl: WebGLRenderingContext | WebGL2RenderingContext,
63
+ vert: WebGLShader,
64
+ frag: WebGLShader,
65
+ ): WebGLProgram {
66
+ const program = gl.createProgram();
67
+ if (!program) throw new Error("useFragmentShader: createProgram failed");
68
+ gl.attachShader(program, vert);
69
+ gl.attachShader(program, frag);
70
+ gl.linkProgram(program);
71
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
72
+ const info = gl.getProgramInfoLog(program) ?? "unknown link error";
73
+ gl.deleteProgram(program);
74
+ throw new Error(`useFragmentShader: program link failed: ${info}`);
75
+ }
76
+ return program;
77
+ }
78
+
79
+ /** Wrap a fragment body into a complete WebGL2 shader if needed. */
80
+ export function wrapFragmentSource(source: string, webgl2: boolean): string {
81
+ const trimmed = source.trim();
82
+ if (trimmed.startsWith("#version") || trimmed.includes("void main")) {
83
+ return source;
84
+ }
85
+ if (webgl2) {
86
+ return `#version 300 es
87
+ precision highp float;
88
+ uniform vec2 u_resolution;
89
+ uniform float u_time;
90
+ out vec4 outColor;
91
+ ${source}
92
+ void main() {
93
+ vec2 uv = gl_FragCoord.xy / u_resolution;
94
+ outColor = shade(uv, u_time);
95
+ }
96
+ `;
97
+ }
98
+ return `
99
+ precision highp float;
100
+ uniform vec2 u_resolution;
101
+ uniform float u_time;
102
+ ${source}
103
+ void main() {
104
+ vec2 uv = gl_FragCoord.xy / u_resolution;
105
+ gl_FragColor = shade(uv, u_time);
106
+ }
107
+ `;
108
+ }
109
+
110
+ type ProgramBundle = {
111
+ gl: WebGLRenderingContext | WebGL2RenderingContext;
112
+ program: WebGLProgram;
113
+ isWebgl2: boolean;
114
+ buffer: WebGLBuffer | null;
115
+ };
116
+
117
+ function buildProgram(
118
+ canvas: HTMLCanvasElement,
119
+ fragmentSource: string,
120
+ preferWebgl2: boolean,
121
+ ): ProgramBundle {
122
+ const attrs: WebGLContextAttributes = {
123
+ alpha: true,
124
+ antialias: false,
125
+ depth: false,
126
+ stencil: false,
127
+ premultipliedAlpha: true,
128
+ };
129
+
130
+ let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
131
+ let isWebgl2 = false;
132
+
133
+ if (preferWebgl2) {
134
+ gl = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
135
+ isWebgl2 = !!gl;
136
+ }
137
+ if (!gl) {
138
+ const gl1 =
139
+ canvas.getContext("webgl", attrs) ??
140
+ canvas.getContext("experimental-webgl", attrs);
141
+ gl = gl1 as WebGLRenderingContext | null;
142
+ isWebgl2 = false;
143
+ }
144
+ if (!gl) {
145
+ throw new Error("useFragmentShader: WebGL unavailable");
146
+ }
147
+
148
+ const fragSrc = wrapFragmentSource(fragmentSource, isWebgl2);
149
+ const vertSrc = isWebgl2 ? DEFAULT_VERTEX : DEFAULT_VERTEX_WEBGL1;
150
+
151
+ const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
152
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
153
+ const program = linkProgram(gl, vert, frag);
154
+ gl.deleteShader(vert);
155
+ gl.deleteShader(frag);
156
+
157
+ let buffer: WebGLBuffer | null = null;
158
+ if (!isWebgl2) {
159
+ buffer = gl.createBuffer();
160
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
161
+ gl.bufferData(
162
+ gl.ARRAY_BUFFER,
163
+ new Float32Array([-1, -1, 3, -1, -1, 3]),
164
+ gl.STATIC_DRAW,
165
+ );
166
+ const loc = gl.getAttribLocation(program, "a_pos");
167
+ gl.enableVertexAttribArray(loc);
168
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
169
+ }
170
+
171
+ return { gl, program, isWebgl2, buffer };
172
+ }
173
+
174
+ function resizeCanvas(
175
+ canvas: HTMLCanvasElement,
176
+ gl: WebGLRenderingContext | WebGL2RenderingContext,
177
+ dpr: number,
178
+ ): void {
179
+ const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
180
+ const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
181
+ if (canvas.width !== w || canvas.height !== h) {
182
+ canvas.width = w;
183
+ canvas.height = h;
184
+ }
185
+ gl.viewport(0, 0, canvas.width, canvas.height);
186
+ }
187
+
188
+ function drawFrame(bundle: ProgramBundle, time: number): void {
189
+ const { gl, program, isWebgl2 } = bundle;
190
+ gl.useProgram(program);
191
+ const resLoc = gl.getUniformLocation(program, "u_resolution");
192
+ const timeLoc = gl.getUniformLocation(program, "u_time");
193
+ if (resLoc) gl.uniform2f(resLoc, gl.canvas.width, gl.canvas.height);
194
+ if (timeLoc) gl.uniform1f(timeLoc, time);
195
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
196
+ void isWebgl2;
197
+ }
198
+
199
+ /**
200
+ * Fragment-shader helper for moonshine React canvases.
201
+ *
202
+ * Compiles/links a fullscreen-triangle program and draws into a canvas ref.
203
+ * Fragment source may be a full shader or a `shade(uv, time)` body snippet
204
+ * (auto-wrapped).
205
+ *
206
+ * ```tsx
207
+ * function Plasma() {
208
+ * const { canvasRef } = useFragmentShader(`
209
+ * vec4 shade(vec2 uv, float t) {
210
+ * return vec4(uv, 0.5 + 0.5 * sin(t), 1.0);
211
+ * }
212
+ * `);
213
+ * return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />;
214
+ * }
215
+ * ```
216
+ */
217
+ export function useFragmentShader(
218
+ source: string,
219
+ options: UseFragmentShaderOptions = {},
220
+ ): FragmentShaderHandle {
221
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
222
+ const bundleRef = useRef<ProgramBundle | null>(null);
223
+ const sourceRef = useRef(source);
224
+ const glRef = useRef<WebGLRenderingContext | WebGL2RenderingContext | null>(
225
+ null,
226
+ );
227
+ const optsRef = useRef(options);
228
+ optsRef.current = options;
229
+
230
+ const setSource = (next: string) => {
231
+ sourceRef.current = next;
232
+ const canvas = canvasRef.current;
233
+ if (!canvas) return;
234
+ const preferWebgl2 = optsRef.current.webgl2 !== false;
235
+ if (bundleRef.current) {
236
+ bundleRef.current.gl.deleteProgram(bundleRef.current.program);
237
+ if (bundleRef.current.buffer) {
238
+ bundleRef.current.gl.deleteBuffer(bundleRef.current.buffer);
239
+ }
240
+ }
241
+ const bundle = buildProgram(canvas, next, preferWebgl2);
242
+ bundleRef.current = bundle;
243
+ glRef.current = bundle.gl;
244
+ };
245
+
246
+ useEffect(() => {
247
+ const canvas = canvasRef.current;
248
+ if (!canvas) return;
249
+
250
+ const preferWebgl2 = options.webgl2 !== false;
251
+ const dpr =
252
+ options.dpr ??
253
+ (typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1);
254
+ const animate = options.animate !== false;
255
+
256
+ let bundle: ProgramBundle;
257
+ try {
258
+ bundle = buildProgram(canvas, sourceRef.current, preferWebgl2);
259
+ } catch (err) {
260
+ console.error(err);
261
+ return;
262
+ }
263
+ bundleRef.current = bundle;
264
+ glRef.current = bundle.gl;
265
+
266
+ const start = performance.now();
267
+ let frame = 0;
268
+ let alive = true;
269
+
270
+ const tick = () => {
271
+ if (!alive || !bundleRef.current) return;
272
+ const b = bundleRef.current;
273
+ resizeCanvas(canvas, b.gl, dpr);
274
+ const t = (performance.now() - start) / 1000;
275
+ drawFrame(b, t);
276
+ optsRef.current.onFrame?.(t, b.gl);
277
+ if (animate) frame = requestAnimationFrame(tick);
278
+ };
279
+
280
+ tick();
281
+
282
+ return () => {
283
+ alive = false;
284
+ cancelAnimationFrame(frame);
285
+ if (bundleRef.current) {
286
+ bundleRef.current.gl.deleteProgram(bundleRef.current.program);
287
+ if (bundleRef.current.buffer) {
288
+ bundleRef.current.gl.deleteBuffer(bundleRef.current.buffer);
289
+ }
290
+ bundleRef.current = null;
291
+ }
292
+ glRef.current = null;
293
+ };
294
+ // Re-init when source identity changes from the hook arg
295
+ }, [source, options.webgl2, options.dpr, options.animate]);
296
+
297
+ return {
298
+ canvasRef,
299
+ get gl() {
300
+ return glRef.current;
301
+ },
302
+ setSource,
303
+ };
304
+ }
305
+
306
+ /** Pure helper for tests / SSR: compile wrap without a GL context. */
307
+ export function createFullscreenFragment(
308
+ source: string,
309
+ webgl2 = true,
310
+ ): {
311
+ vertex: string;
312
+ fragment: string;
313
+ } {
314
+ return {
315
+ vertex: webgl2 ? DEFAULT_VERTEX : DEFAULT_VERTEX_WEBGL1,
316
+ fragment: wrapFragmentSource(source, webgl2),
317
+ };
318
+ }