├── .gitignore
├── core
├── logging.ts
├── uniforms.ts
├── texture.ts
└── index.tsx
├── tsup.config.ts
├── tsconfig.json
├── LICENSE
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .turbo
2 | dist
--------------------------------------------------------------------------------
/core/logging.ts:
--------------------------------------------------------------------------------
1 | export const log = (text: string) => `react-shaders: ${text}`
2 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup'
2 |
3 | export default defineConfig([{ entry: ['core/index.tsx'], format: ['cjs', 'esm'], dts: true }])
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": false,
4 | "declaration": true,
5 | "declarationMap": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "inlineSources": false,
9 | "isolatedModules": true,
10 | "moduleResolution": "node",
11 | "noUnusedLocals": false,
12 | "noUnusedParameters": false,
13 | "noUncheckedIndexedAccess": true,
14 | "preserveWatchOutput": true,
15 | "skipLibCheck": true,
16 | "strict": true,
17 | "allowJs": true,
18 | "lib": ["dom", "dom.iterable", "esnext"],
19 | "target": "es6",
20 | "module": "esnext",
21 | "resolveJsonModule": true,
22 | "jsx": "preserve",
23 | "baseUrl": ".",
24 | "paths": { "~/*": ["*"] }
25 | },
26 | "include": ["."],
27 | "exclude": ["node_modules", "build", "dist"]
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Morgan Villedieu
4 | Copyright (c) 2023 Rysana, Inc. (forked from the above)
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-shaders",
3 | "version": "0.0.5",
4 | "description": "React Shaders is an open source library for creating GLSL/WebGL shaders in React and Typescript, with support for modern shader bindings like those in Shadertoy and Rysana.",
5 | "homepage": "https://rysana.com/docs/react-shaders",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/rysanacom/react-shaders"
9 | },
10 | "license": "MIT",
11 | "exports": {
12 | "./package.json": "./package.json",
13 | ".": {
14 | "types": "./dist/index.d.ts",
15 | "import": "./dist/index.mjs",
16 | "module": "./dist/index.mjs",
17 | "require": "./dist/index.js"
18 | }
19 | },
20 | "main": "./dist/index.js",
21 | "module": "./dist/index.mjs",
22 | "types": "./dist/index.d.ts",
23 | "files": ["dist/**/*", "README.md", "package.json"],
24 | "scripts": {
25 | "build": "tsup",
26 | "clean": "rm -rf dist",
27 | "dev": "tsup --watch"
28 | },
29 | "devDependencies": {
30 | "@types/react": "^18.3.1",
31 | "@types/react-dom": "^18.3.0",
32 | "tsup": "^8",
33 | "typescript": "^5.4.5"
34 | },
35 | "peerDependencies": {
36 | "react": "^18.3.1",
37 | "react-dom": "^18.3.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # **React Shaders** [](https://www.npmjs.com/package/react-shaders) [](https://www.npmjs.com/package/react-shaders) [](https://github.com/rysanacom/react-shaders/blob/main/LICENSE)
2 |
3 | **React Shaders** is an open source library for creating GLSL/WebGL shaders with support for modern shader bindings like those in Shadertoy. `react-shaders` is built on top of [Morgan Villedieu's `shadertoy-react`](https://github.com/mvilledieu/shadertoy-react).
4 |
5 |
6 |
7 | index.tsx (React) |
8 | example.glsl (GLSL) |
9 |
10 |
11 | |
12 |
13 | ```jsx
14 | import { Shader } from 'react-shaders'
15 | import code from './example.glsl'
16 |
17 | return (
18 |
19 | )
20 | ```
21 |
22 | |
23 |
24 |
25 | ```glsl
26 | void mainImage(out vec4 O,in vec2 I){
27 | I=.5-(I/iResolution.xy);
28 | vec3 col=.5+vec3(I,.5*sin(iTime));
29 | I*=vec2(1.,iResolution.y/iResolution.x);
30 | float z=.5*sin((dot(I,I)+iTime*5e-2)/.01);
31 | O=vec4(col*(1.+z),1.);}
32 | ```
33 |
34 | |
35 |
36 |
37 |
38 | ### Installation
39 |
40 |
41 |
42 | |
43 |
44 | ```bash
45 | npm i react-shaders
46 | ```
47 |
48 | |
49 |
50 |
51 | ```bash
52 | pnpm i react-shaders
53 | ```
54 |
55 | |
56 |
57 |
58 | ```bash
59 | bun add react-shaders
60 | ```
61 |
62 | |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/core/uniforms.ts:
--------------------------------------------------------------------------------
1 | import { log } from './logging'
2 |
3 | export type Vector2 = [T, T]
4 | export type Vector3 = [T, T, T]
5 | export type Vector4 = [T, T, T, T]
6 | // biome-ignore format:
7 | export type Matrix2 = [T, T,
8 | T, T]
9 | // biome-ignore format:
10 | export type Matrix3 = [T, T, T,
11 | T, T, T,
12 | T, T, T]
13 | // biome-ignore format:
14 | export type Matrix4 = [T, T, T, T,
15 | T, T, T, T,
16 | T, T, T, T,
17 | T, T, T, T]
18 | export type Uniforms = {
19 | '1i': number
20 | '2i': Vector2
21 | '3i': Vector3
22 | '4i': Vector4
23 | '1f': number
24 | '2f': Vector2
25 | '3f': Vector3
26 | '4f': Vector4
27 | '1iv': Float32List
28 | '2iv': Float32List
29 | '3iv': Float32List
30 | '4iv': Float32List
31 | '1fv': Float32List
32 | '2fv': Float32List
33 | '3fv': Float32List
34 | '4fv': Float32List
35 | Matrix2fv: Float32List
36 | Matrix3fv: Float32List
37 | Matrix4fv: Float32List
38 | }
39 | export type UniformType = keyof Uniforms
40 |
41 | export function isMatrixType(t: string, v: number[] | number): v is number[] {
42 | return t.includes('Matrix') && Array.isArray(v)
43 | }
44 | export function isVectorListType(t: string, v: number[] | number): v is number[] {
45 | return t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
46 | }
47 | function isVectorType(t: string, v: number[] | number): v is Vector4 {
48 | return !t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
49 | }
50 | export const processUniform = (
51 | gl: WebGLRenderingContext,
52 | location: WebGLUniformLocation,
53 | t: T,
54 | value: number | number[],
55 | ) => {
56 | if (isVectorType(t, value)) {
57 | switch (t) {
58 | case '2f':
59 | return gl.uniform2f(location, value[0], value[1])
60 | case '3f':
61 | return gl.uniform3f(location, value[0], value[1], value[2])
62 | case '4f':
63 | return gl.uniform4f(location, value[0], value[1], value[2], value[3])
64 | case '2i':
65 | return gl.uniform2i(location, value[0], value[1])
66 | case '3i':
67 | return gl.uniform3i(location, value[0], value[1], value[2])
68 | case '4i':
69 | return gl.uniform4i(location, value[0], value[1], value[2], value[3])
70 | }
71 | }
72 | if (typeof value === 'number') {
73 | switch (t) {
74 | case '1i':
75 | return gl.uniform1i(location, value)
76 | default:
77 | return gl.uniform1f(location, value)
78 | }
79 | }
80 | switch (t) {
81 | case '1iv':
82 | return gl.uniform1iv(location, value)
83 | case '2iv':
84 | return gl.uniform2iv(location, value)
85 | case '3iv':
86 | return gl.uniform3iv(location, value)
87 | case '4iv':
88 | return gl.uniform4iv(location, value)
89 | case '1fv':
90 | return gl.uniform1fv(location, value)
91 | case '2fv':
92 | return gl.uniform2fv(location, value)
93 | case '3fv':
94 | return gl.uniform3fv(location, value)
95 | case '4fv':
96 | return gl.uniform4fv(location, value)
97 | case 'Matrix2fv':
98 | return gl.uniformMatrix2fv(location, false, value)
99 | case 'Matrix3fv':
100 | return gl.uniformMatrix3fv(location, false, value)
101 | case 'Matrix4fv':
102 | return gl.uniformMatrix4fv(location, false, value)
103 | }
104 | }
105 |
106 | export const uniformTypeToGLSLType = (t: string) => {
107 | switch (t) {
108 | case '1f':
109 | return 'float'
110 | case '2f':
111 | return 'vec2'
112 | case '3f':
113 | return 'vec3'
114 | case '4f':
115 | return 'vec4'
116 | case '1i':
117 | return 'int'
118 | case '2i':
119 | return 'ivec2'
120 | case '3i':
121 | return 'ivec3'
122 | case '4i':
123 | return 'ivec4'
124 | case '1iv':
125 | return 'int'
126 | case '2iv':
127 | return 'ivec2'
128 | case '3iv':
129 | return 'ivec3'
130 | case '4iv':
131 | return 'ivec4'
132 | case '1fv':
133 | return 'float'
134 | case '2fv':
135 | return 'vec2'
136 | case '3fv':
137 | return 'vec3'
138 | case '4fv':
139 | return 'vec4'
140 | case 'Matrix2fv':
141 | return 'mat2'
142 | case 'Matrix3fv':
143 | return 'mat3'
144 | case 'Matrix4fv':
145 | return 'mat4'
146 | default:
147 | console.error(
148 | log(`The uniform type "${t}" is not valid, please make sure your uniform type is valid`),
149 | )
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/core/texture.ts:
--------------------------------------------------------------------------------
1 | import { log } from './logging'
2 |
3 | export const LinearFilter = 9729
4 | export const NearestFilter = 9728
5 | export const LinearMipMapLinearFilter = 9987
6 | export const NearestMipMapLinearFilter = 9986
7 | export const LinearMipMapNearestFilter = 9985
8 | export const NearestMipMapNearestFilter = 9984
9 | export const MirroredRepeatWrapping = 33648
10 | export const ClampToEdgeWrapping = 33071
11 | export const RepeatWrapping = 10497
12 |
13 | export class Texture {
14 | gl: WebGLRenderingContext
15 | url?: string
16 | wrapS?: number
17 | wrapT?: number
18 | minFilter?: number
19 | magFilter?: number
20 | source?: HTMLImageElement | HTMLVideoElement
21 | pow2canvas?: HTMLCanvasElement
22 | isLoaded = false
23 | isVideo = false
24 | flipY = -1
25 | width = 0
26 | height = 0
27 | _webglTexture: WebGLTexture | null = null
28 | constructor(gl: WebGLRenderingContext) {
29 | this.gl = gl
30 | }
31 | updateTexture = (texture: WebGLTexture, video: HTMLVideoElement, flipY: number) => {
32 | const { gl } = this
33 | const level = 0
34 | const internalFormat = gl.RGBA
35 | const srcFormat = gl.RGBA
36 | const srcType = gl.UNSIGNED_BYTE
37 | gl.bindTexture(gl.TEXTURE_2D, texture)
38 | gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
39 | gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, video)
40 | }
41 | setupVideo = (url: string) => {
42 | const video = document.createElement('video')
43 | let playing = false
44 | let timeupdate = false
45 | video.autoplay = true
46 | video.muted = true
47 | video.loop = true
48 | video.crossOrigin = 'anonymous'
49 | const checkReady = () => {
50 | if (playing && timeupdate) {
51 | this.isLoaded = true
52 | }
53 | }
54 | video.addEventListener(
55 | 'playing',
56 | () => {
57 | playing = true
58 | this.width = video.videoWidth || 0
59 | this.height = video.videoHeight || 0
60 | checkReady()
61 | },
62 | true,
63 | )
64 | video.addEventListener(
65 | 'timeupdate',
66 | () => {
67 | timeupdate = true
68 | checkReady()
69 | },
70 | true,
71 | )
72 | video.src = url
73 | // video.play(); // Not sure why this is here nor commented out. From STR.
74 | return video
75 | }
76 | makePowerOf2 = (image: T): T => {
77 | if (
78 | image instanceof HTMLImageElement ||
79 | image instanceof HTMLCanvasElement ||
80 | image instanceof ImageBitmap
81 | ) {
82 | if (this.pow2canvas === undefined) this.pow2canvas = document.createElement('canvas')
83 | this.pow2canvas.width = 2 ** Math.floor(Math.log(image.width) / Math.LN2)
84 | this.pow2canvas.height = 2 ** Math.floor(Math.log(image.height) / Math.LN2)
85 | const context = this.pow2canvas.getContext('2d')
86 | context?.drawImage(image, 0, 0, this.pow2canvas.width, this.pow2canvas.height)
87 | console.warn(
88 | log(
89 | `Image is not power of two ${image.width} x ${image.height}. Resized to ${this.pow2canvas.width} x ${this.pow2canvas.height};`,
90 | ),
91 | )
92 | return this.pow2canvas as T
93 | }
94 | return image
95 | }
96 | load = async (
97 | textureArgs: Texture,
98 | // channelId: number // Not sure why this is here nor commented out. From STR.
99 | ) => {
100 | const { gl } = this
101 | const { url, wrapS, wrapT, minFilter, magFilter, flipY = -1 }: Texture = textureArgs
102 | if (!url) {
103 | return Promise.reject(
104 | new Error(
105 | log('Missing url, please make sure to pass the url of your texture { url: ... }'),
106 | ),
107 | )
108 | }
109 | const isImage = /(\.jpg|\.jpeg|\.png|\.gif|\.bmp)$/i.exec(url)
110 | const isVideo = /(\.mp4|\.3gp|\.webm|\.ogv)$/i.exec(url)
111 | if (isImage === null && isVideo === null) {
112 | return Promise.reject(
113 | new Error(log(`Please upload a video or an image with a valid format (url: ${url})`)),
114 | )
115 | }
116 | Object.assign(this, { url, wrapS, wrapT, minFilter, magFilter, flipY })
117 | const level = 0
118 | const internalFormat = gl.RGBA
119 | const width = 1
120 | const height = 1
121 | const border = 0
122 | const srcFormat = gl.RGBA
123 | const srcType = gl.UNSIGNED_BYTE
124 | const pixel = new Uint8Array([255, 255, 255, 0])
125 | const texture = gl.createTexture()
126 | gl.bindTexture(gl.TEXTURE_2D, texture)
127 | gl.texImage2D(
128 | gl.TEXTURE_2D,
129 | level,
130 | internalFormat,
131 | width,
132 | height,
133 | border,
134 | srcFormat,
135 | srcType,
136 | pixel,
137 | )
138 | if (isVideo) {
139 | const video = this.setupVideo(url)
140 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
141 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
142 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
143 | this._webglTexture = texture
144 | this.source = video
145 | this.isVideo = true
146 | return video.play().then(() => this)
147 | }
148 | async function loadImage() {
149 | return new Promise((resolve, reject) => {
150 | const image = new Image()
151 | image.crossOrigin = 'anonymous'
152 | image.onload = () => {
153 | resolve(image)
154 | }
155 | image.onerror = () => {
156 | reject(new Error(log(`failed loading url: ${url}`)))
157 | }
158 | image.src = url ?? ''
159 | })
160 | }
161 | let image = (await loadImage()) as HTMLImageElement
162 | let isPowerOf2 =
163 | (image.width & (image.width - 1)) === 0 && (image.height & (image.height - 1)) === 0
164 | if (
165 | (textureArgs.wrapS !== ClampToEdgeWrapping ||
166 | textureArgs.wrapT !== ClampToEdgeWrapping ||
167 | (textureArgs.minFilter !== NearestFilter && textureArgs.minFilter !== LinearFilter)) &&
168 | !isPowerOf2
169 | ) {
170 | image = this.makePowerOf2(image)
171 | isPowerOf2 = true
172 | }
173 | gl.bindTexture(gl.TEXTURE_2D, texture)
174 | gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
175 | gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, image)
176 | if (
177 | isPowerOf2 &&
178 | textureArgs.minFilter !== NearestFilter &&
179 | textureArgs.minFilter !== LinearFilter
180 | ) {
181 | gl.generateMipmap(gl.TEXTURE_2D)
182 | }
183 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, this.wrapS || RepeatWrapping)
184 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, this.wrapT || RepeatWrapping)
185 | gl.texParameteri(
186 | gl.TEXTURE_2D,
187 | gl.TEXTURE_MIN_FILTER,
188 | this.minFilter || LinearMipMapLinearFilter,
189 | )
190 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this.magFilter || LinearFilter)
191 | this._webglTexture = texture
192 | this.source = image
193 | this.isVideo = false
194 | this.isLoaded = true
195 | this.width = image.width || 0
196 | this.height = image.height || 0
197 | return this
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/core/index.tsx:
--------------------------------------------------------------------------------
1 | import { type CSSProperties, Component } from 'react'
2 | import { log } from './logging'
3 | import {
4 | ClampToEdgeWrapping,
5 | LinearFilter,
6 | LinearMipMapLinearFilter,
7 | LinearMipMapNearestFilter,
8 | MirroredRepeatWrapping,
9 | NearestFilter,
10 | NearestMipMapLinearFilter,
11 | NearestMipMapNearestFilter,
12 | RepeatWrapping,
13 | Texture,
14 | } from './texture'
15 | import {
16 | type UniformType,
17 | type Vector2,
18 | type Vector3,
19 | type Vector4,
20 | isMatrixType,
21 | isVectorListType,
22 | processUniform,
23 | uniformTypeToGLSLType,
24 | } from './uniforms'
25 |
26 | export {
27 | ClampToEdgeWrapping,
28 | LinearFilter,
29 | LinearMipMapLinearFilter,
30 | LinearMipMapNearestFilter,
31 | MirroredRepeatWrapping,
32 | NearestFilter,
33 | NearestMipMapLinearFilter,
34 | NearestMipMapNearestFilter,
35 | RepeatWrapping,
36 | }
37 |
38 | export type { Vector2, Vector3, Vector4 }
39 |
40 | const PRECISIONS = ['lowp', 'mediump', 'highp']
41 | const FS_MAIN_SHADER = `\nvoid main(void){
42 | vec4 color = vec4(0.0,0.0,0.0,1.0);
43 | mainImage( color, gl_FragCoord.xy );
44 | gl_FragColor = color;
45 | }`
46 | const BASIC_FS = `void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
47 | vec2 uv = fragCoord/iResolution.xy;
48 | vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
49 | fragColor = vec4(col,1.0);
50 | }`
51 | const BASIC_VS = `attribute vec3 aVertexPosition;
52 | void main(void) {
53 | gl_Position = vec4(aVertexPosition, 1.0);
54 | }`
55 | const UNIFORM_TIME = 'iTime'
56 | const UNIFORM_TIMEDELTA = 'iTimeDelta'
57 | const UNIFORM_DATE = 'iDate'
58 | const UNIFORM_FRAME = 'iFrame'
59 | const UNIFORM_MOUSE = 'iMouse'
60 | const UNIFORM_RESOLUTION = 'iResolution'
61 | const UNIFORM_CHANNEL = 'iChannel'
62 | const UNIFORM_CHANNELRESOLUTION = 'iChannelResolution'
63 | const UNIFORM_DEVICEORIENTATION = 'iDeviceOrientation'
64 |
65 | const latestPointerClientCoords = (e: MouseEvent | TouchEvent) => {
66 | // @ts-expect-error TODO: Deal with this.
67 | return [e.clientX || e.changedTouches[0].clientX, e.clientY || e.changedTouches[0].clientY]
68 | }
69 | const lerpVal = (v0: number, v1: number, t: number) => v0 * (1 - t) + v1 * t
70 | const insertStringAtIndex = (currentString: string, string: string, index: number) =>
71 | index > 0
72 | ? currentString.substring(0, index) +
73 | string +
74 | currentString.substring(index, currentString.length)
75 | : string + currentString
76 |
77 | type Uniform = { type: string; value: number[] | number }
78 | export type Uniforms = Record
79 | type TextureParams = {
80 | url: string
81 | wrapS?: number
82 | wrapT?: number
83 | minFilter?: number
84 | magFilter?: number
85 | flipY?: number
86 | }
87 |
88 | type Props = {
89 | /** Fragment shader GLSL code. */
90 | fs: string
91 |
92 | /** Vertex shader GLSL code. */
93 | vs?: string
94 |
95 | /**
96 | * Textures to be passed to the shader. Textures need to be squared or will be
97 | * automatically resized.
98 | *
99 | * Options default to:
100 | *
101 | * ```js
102 | * {
103 | * minFilter: LinearMipMapLinearFilter,
104 | * magFilter: LinearFilter,
105 | * wrapS: RepeatWrapping,
106 | * wrapT: RepeatWrapping,
107 | * }
108 | * ```
109 | *
110 | * See [textures in the docs](https://rysana.com/docs/react-shaders#textures)
111 | * for details.
112 | */
113 | textures?: TextureParams[]
114 |
115 | /**
116 | * Custom uniforms to be passed to the shader.
117 | *
118 | * See [custom uniforms in the
119 | * docs](https://rysana.com/docs/react-shaders#custom-uniforms) for details.
120 | */
121 | uniforms?: Uniforms
122 |
123 | /**
124 | * Color used when clearing the canvas.
125 | *
126 | * See [the WebGL
127 | * docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/clearColor)
128 | * for details.
129 | */
130 | clearColor?: Vector4
131 |
132 | /**
133 | * GLSL precision qualifier. Defaults to `'highp'`. Balance between
134 | * performance and quality.
135 | */
136 | precision?: 'highp' | 'lowp' | 'mediump'
137 |
138 | /** Custom inline style for canvas. */
139 | style?: CSSStyleDeclaration
140 |
141 | /** Customize WebGL context attributes. See [the WebGL docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/getContextAttributes) for details. */
142 | contextAttributes?: Record
143 |
144 | /** Lerp value for `iMouse` built-in uniform. Must be between 0 and 1. */
145 | lerp?: number
146 |
147 | /** Device pixel ratio. */
148 | devicePixelRatio?: number
149 |
150 | /**
151 | * Callback for when the textures are done loading. Useful if you want to do
152 | * something like e.g. hide the canvas until textures are done loading.
153 | */
154 | onDoneLoadingTextures?: () => void
155 |
156 | /** Custom callback to handle errors. Defaults to `console.error`. */
157 | onError?: (error: string) => void
158 |
159 | /** Custom callback to handle warnings. Defaults to `console.warn`. */
160 | onWarning?: (warning: string) => void
161 | }
162 | export class Shader extends Component {
163 | uniforms: Record<
164 | string,
165 | { type: string; isNeeded: boolean; value?: number[] | number; arraySize?: string }
166 | >
167 | constructor(props: Props) {
168 | super(props)
169 | this.uniforms = {
170 | [UNIFORM_TIME]: { type: 'float', isNeeded: false, value: 0 },
171 | [UNIFORM_TIMEDELTA]: { type: 'float', isNeeded: false, value: 0 },
172 | [UNIFORM_DATE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
173 | [UNIFORM_MOUSE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
174 | [UNIFORM_RESOLUTION]: { type: 'vec2', isNeeded: false, value: [0, 0] },
175 | [UNIFORM_FRAME]: { type: 'int', isNeeded: false, value: 0 },
176 | [UNIFORM_DEVICEORIENTATION]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
177 | }
178 | }
179 | static defaultProps = {
180 | textures: [],
181 | contextAttributes: {},
182 | devicePixelRatio: 1,
183 | vs: BASIC_VS,
184 | precision: 'highp',
185 | onError: console.error,
186 | onWarn: console.warn,
187 | }
188 | componentDidMount = () => {
189 | this.initWebGL()
190 | const { fs, vs, clearColor = [0, 0, 0, 1] } = this.props
191 | const { gl } = this
192 | if (gl && this.canvas) {
193 | gl.clearColor(...clearColor)
194 | gl.clearDepth(1.0)
195 | gl.enable(gl.DEPTH_TEST)
196 | gl.depthFunc(gl.LEQUAL)
197 | gl.viewport(0, 0, this.canvas.width, this.canvas.height)
198 | this.canvas.height = this.canvas.clientHeight
199 | this.canvas.width = this.canvas.clientWidth
200 | this.processCustomUniforms()
201 | this.processTextures()
202 | this.initShaders(this.preProcessFragment(fs || BASIC_FS), vs || BASIC_VS)
203 | this.initBuffers()
204 | // @ts-expect-error apparently this thing needs a timestamp but it's not used?
205 | this.drawScene()
206 | this.addEventListeners()
207 | this.onResize()
208 | }
209 | }
210 | shouldComponentUpdate = () => false
211 | componentWillUnmount() {
212 | const { gl } = this
213 | if (gl) {
214 | gl.getExtension('WEBGL_lose_context')?.loseContext()
215 | gl.useProgram(null)
216 | gl.deleteProgram(this.shaderProgram ?? null)
217 | if (this.texturesArr.length > 0) {
218 | for (const texture of this.texturesArr) {
219 | // @ts-expect-error TODO: Deal with this.
220 | gl.deleteTexture(texture._webglTexture)
221 | }
222 | }
223 | this.shaderProgram = null
224 | }
225 | this.removeEventListeners()
226 | cancelAnimationFrame(this.animFrameId ?? 0)
227 | }
228 | setupChannelRes = ({ width, height }: Texture, id: number) => {
229 | const { devicePixelRatio = 1 } = this.props
230 | // @ts-expect-error TODO: Deal with this.
231 | this.uniforms.iChannelResolution.value[id * 3] = width * devicePixelRatio
232 | // @ts-expect-error TODO: Deal with this.
233 | this.uniforms.iChannelResolution.value[id * 3 + 1] = height * devicePixelRatio
234 | // @ts-expect-error TODO: Deal with this.
235 | this.uniforms.iChannelResolution.value[id * 3 + 2] = 0
236 | // console.log(this.uniforms);
237 | }
238 | initWebGL = () => {
239 | const { contextAttributes } = this.props
240 | if (!this.canvas) return
241 | this.gl = (this.canvas.getContext('webgl', contextAttributes) ||
242 | this.canvas.getContext(
243 | 'experimental-webgl',
244 | contextAttributes,
245 | )) as WebGLRenderingContext | null
246 | this.gl?.getExtension('OES_standard_derivatives')
247 | this.gl?.getExtension('EXT_shader_texture_lod')
248 | }
249 | initBuffers = () => {
250 | const { gl } = this
251 | this.squareVerticesBuffer = gl?.createBuffer()
252 | gl?.bindBuffer(gl.ARRAY_BUFFER, this.squareVerticesBuffer ?? null)
253 | const vertices = [1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0]
254 | gl?.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
255 | }
256 | addEventListeners = () => {
257 | const options = { passive: true }
258 | if (this.uniforms.iMouse?.isNeeded && this.canvas) {
259 | this.canvas.addEventListener('mousemove', this.mouseMove, options)
260 | this.canvas.addEventListener('mouseout', this.mouseUp, options)
261 | this.canvas.addEventListener('mouseup', this.mouseUp, options)
262 | this.canvas.addEventListener('mousedown', this.mouseDown, options)
263 | this.canvas.addEventListener('touchmove', this.mouseMove, options)
264 | this.canvas.addEventListener('touchend', this.mouseUp, options)
265 | this.canvas.addEventListener('touchstart', this.mouseDown, options)
266 | }
267 | if (this.uniforms.iDeviceOrientation?.isNeeded) {
268 | window.addEventListener('deviceorientation', this.onDeviceOrientationChange, options)
269 | }
270 | window.addEventListener('resize', this.onResize, options)
271 | }
272 | removeEventListeners = () => {
273 | const options = { passive: true } as EventListenerOptions
274 | if (this.uniforms.iMouse?.isNeeded && this.canvas) {
275 | this.canvas.removeEventListener('mousemove', this.mouseMove, options)
276 | this.canvas.removeEventListener('mouseout', this.mouseUp, options)
277 | this.canvas.removeEventListener('mouseup', this.mouseUp, options)
278 | this.canvas.removeEventListener('mousedown', this.mouseDown, options)
279 | this.canvas.removeEventListener('touchmove', this.mouseMove, options)
280 | this.canvas.removeEventListener('touchend', this.mouseUp, options)
281 | this.canvas.removeEventListener('touchstart', this.mouseDown, options)
282 | }
283 | if (this.uniforms.iDeviceOrientation?.isNeeded) {
284 | window.removeEventListener('deviceorientation', this.onDeviceOrientationChange, options)
285 | }
286 | window.removeEventListener('resize', this.onResize, options)
287 | }
288 | onDeviceOrientationChange = ({ alpha, beta, gamma }: DeviceOrientationEvent) => {
289 | // @ts-expect-error TODO: Deal with this.
290 | this.uniforms.iDeviceOrientation.value = [
291 | alpha ?? 0,
292 | beta ?? 0,
293 | gamma ?? 0,
294 | window.orientation || 0,
295 | ]
296 | }
297 | mouseDown = (e: MouseEvent | TouchEvent) => {
298 | const [clientX, clientY] = latestPointerClientCoords(e)
299 | const mouseX = clientX - (this.canvasPosition?.left ?? 0) - window.pageXOffset
300 | const mouseY =
301 | (this.canvasPosition?.height ?? 0) -
302 | clientY -
303 | (this.canvasPosition?.top ?? 0) -
304 | window.pageYOffset
305 | this.mousedown = true
306 | // @ts-expect-error TODO: Deal with this.
307 | this.uniforms.iMouse.value[2] = mouseX
308 | // @ts-expect-error TODO: Deal with this.
309 | this.uniforms.iMouse.value[3] = mouseY
310 | this.lastMouseArr[0] = mouseX
311 | this.lastMouseArr[1] = mouseY
312 | }
313 | mouseMove = (e: MouseEvent | TouchEvent) => {
314 | this.canvasPosition = this.canvas?.getBoundingClientRect()
315 | const { lerp = 1 } = this.props
316 | const [clientX, clientY] = latestPointerClientCoords(e)
317 | const mouseX = clientX - (this.canvasPosition?.left ?? 0)
318 | const mouseY = (this.canvasPosition?.height ?? 0) - clientY - (this.canvasPosition?.top ?? 0)
319 | if (lerp !== 1) {
320 | this.lastMouseArr[0] = mouseX
321 | this.lastMouseArr[1] = mouseY
322 | } else {
323 | // @ts-expect-error TODO: Deal with this.
324 | this.uniforms.iMouse.value[0] = mouseX
325 | // @ts-expect-error TODO: Deal with this.
326 | this.uniforms.iMouse.value[1] = mouseY
327 | }
328 | }
329 | mouseUp = () => {
330 | // @ts-expect-error TODO: Deal with this.
331 | this.uniforms.iMouse.value[2] = 0
332 | // @ts-expect-error TODO: Deal with this.
333 | this.uniforms.iMouse.value[3] = 0
334 | }
335 | onResize = () => {
336 | const { gl } = this
337 | const { devicePixelRatio = 1 } = this.props
338 | if (!gl) return
339 | this.canvasPosition = this.canvas?.getBoundingClientRect()
340 | // Force pixel ratio to be one to avoid expensive calculus on retina display.
341 | const realToCSSPixels = devicePixelRatio
342 | const displayWidth = Math.floor((this.canvasPosition?.width ?? 1) * realToCSSPixels)
343 | const displayHeight = Math.floor((this.canvasPosition?.height ?? 1) * realToCSSPixels)
344 | gl.canvas.width = displayWidth
345 | gl.canvas.height = displayHeight
346 | if (this.uniforms.iResolution?.isNeeded && this.shaderProgram) {
347 | const rUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_RESOLUTION)
348 | gl.uniform2fv(rUniform, [gl.canvas.width, gl.canvas.height])
349 | }
350 | }
351 | drawScene = (timestamp: number) => {
352 | const { gl } = this
353 | const { lerp = 1 } = this.props
354 | if (!gl) return
355 | gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
356 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
357 | gl.bindBuffer(gl.ARRAY_BUFFER, this.squareVerticesBuffer ?? null)
358 | gl.vertexAttribPointer(this.vertexPositionAttribute ?? 0, 3, gl.FLOAT, false, 0, 0)
359 | this.setUniforms(timestamp)
360 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
361 | if (this.uniforms.iMouse?.isNeeded && lerp !== 1) {
362 | // @ts-expect-error TODO: Deal with this.
363 | this.uniforms.iMouse.value[0] = lerpVal(
364 | // @ts-expect-error TODO: Deal with this.
365 | this.uniforms.iMouse.value[0],
366 | // @ts-expect-error TODO: Deal with this.
367 | this.lastMouseArr[0],
368 | lerp,
369 | )
370 | // @ts-expect-error TODO: Deal with this.
371 | this.uniforms.iMouse.value[1] = lerpVal(
372 | // @ts-expect-error TODO: Deal with this.
373 | this.uniforms.iMouse.value[1],
374 | // @ts-expect-error TODO: Deal with this.
375 | this.lastMouseArr[1],
376 | lerp,
377 | )
378 | }
379 | this.animFrameId = requestAnimationFrame(this.drawScene)
380 | }
381 | createShader = (type: number, shaderCodeAsText: string) => {
382 | const { gl } = this
383 | if (!gl) return null
384 | const shader = gl.createShader(type)
385 | if (!shader) return null
386 | gl.shaderSource(shader, shaderCodeAsText)
387 | gl.compileShader(shader)
388 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
389 | this.props.onWarning?.(log(`Error compiling the shader:\n${shaderCodeAsText}`))
390 | const compilationLog = gl.getShaderInfoLog(shader)
391 | gl.deleteShader(shader)
392 | this.props.onError?.(log(`Shader compiler log: ${compilationLog}`))
393 | }
394 | return shader
395 | }
396 | initShaders = (fs: string, vs: string) => {
397 | const { gl } = this
398 | if (!gl) return
399 | // console.log(fs, vs);
400 | const fragmentShader = this.createShader(gl.FRAGMENT_SHADER, fs)
401 | const vertexShader = this.createShader(gl.VERTEX_SHADER, vs)
402 | this.shaderProgram = gl.createProgram()
403 | if (!this.shaderProgram || !vertexShader || !fragmentShader) return
404 | gl.attachShader(this.shaderProgram, vertexShader)
405 | gl.attachShader(this.shaderProgram, fragmentShader)
406 | gl.linkProgram(this.shaderProgram)
407 | if (!gl.getProgramParameter(this.shaderProgram, gl.LINK_STATUS)) {
408 | this.props.onError?.(
409 | log(
410 | `Unable to initialize the shader program: ${gl.getProgramInfoLog(this.shaderProgram)}`,
411 | ),
412 | )
413 | return
414 | }
415 | gl.useProgram(this.shaderProgram)
416 | this.vertexPositionAttribute = gl.getAttribLocation(this.shaderProgram, 'aVertexPosition')
417 | gl.enableVertexAttribArray(this.vertexPositionAttribute)
418 | }
419 | processCustomUniforms = () => {
420 | const { uniforms } = this.props
421 | if (uniforms) {
422 | for (const name of Object.keys(uniforms)) {
423 | const uniform = this.props.uniforms?.[name]
424 | if (!uniform) return
425 | const { value, type } = uniform
426 | const glslType = uniformTypeToGLSLType(type)
427 | if (!glslType) return
428 | const tempObject: { arraySize?: string } = {}
429 | if (isMatrixType(type, value)) {
430 | const arrayLength = type.length
431 | const val = Number.parseInt(type.charAt(arrayLength - 3))
432 | const numberOfMatrices = Math.floor(value.length / (val * val))
433 | if (value.length > val * val) tempObject.arraySize = `[${numberOfMatrices}]`
434 | } else if (isVectorListType(type, value)) {
435 | tempObject.arraySize = `[${Math.floor(value.length / Number.parseInt(type.charAt(0)))}]`
436 | }
437 | this.uniforms[name] = { type: glslType, isNeeded: false, value, ...tempObject }
438 | }
439 | }
440 | }
441 | processTextures = () => {
442 | const { gl } = this
443 | const { textures, onDoneLoadingTextures } = this.props
444 | if (!gl) return
445 | if (textures && textures.length > 0) {
446 | this.uniforms[`${UNIFORM_CHANNELRESOLUTION}`] = {
447 | type: 'vec3',
448 | isNeeded: false,
449 | arraySize: `[${textures.length}]`,
450 | value: [],
451 | }
452 | const texturePromisesArr = textures.map((texture: TextureParams, id: number) => {
453 | // Dynamically add textures uniforms.
454 | this.uniforms[`${UNIFORM_CHANNEL}${id}`] = {
455 | type: 'sampler2D',
456 | isNeeded: false,
457 | }
458 | // Initialize array with 0s:
459 | // @ts-expect-error TODO: Deal with this.
460 | this.setupChannelRes(texture, id)
461 | this.texturesArr[id] = new Texture(gl)
462 | return (
463 | this.texturesArr[id]
464 | // @ts-expect-error TODO: Deal with this.
465 | ?.load(texture, id)
466 | .then((t: Texture) => {
467 | this.setupChannelRes(t, id)
468 | })
469 | )
470 | })
471 | Promise.all(texturePromisesArr)
472 | .then(() => {
473 | if (onDoneLoadingTextures) onDoneLoadingTextures()
474 | })
475 | .catch(e => {
476 | this.props.onError?.(e)
477 | if (onDoneLoadingTextures) onDoneLoadingTextures()
478 | })
479 | } else if (onDoneLoadingTextures) onDoneLoadingTextures()
480 | }
481 | preProcessFragment = (fragment: string) => {
482 | const { precision, devicePixelRatio = 1 } = this.props
483 | const isValidPrecision = PRECISIONS.includes(precision ?? 'highp')
484 | const precisionString = `precision ${isValidPrecision ? precision : PRECISIONS[1]} float;\n`
485 | if (!isValidPrecision) {
486 | this.props.onWarning?.(
487 | log(
488 | `wrong precision type ${precision}, please make sure to pass one of a valid precision lowp, mediump, highp, by default you shader precision will be set to highp.`,
489 | ),
490 | )
491 | }
492 | let fs = precisionString
493 | .concat(`#define DPR ${devicePixelRatio.toFixed(1)}\n`)
494 | .concat(fragment.replace(/texture\(/g, 'texture2D('))
495 | for (const uniform of Object.keys(this.uniforms)) {
496 | if (fragment.includes(uniform)) {
497 | const u = this.uniforms[uniform]
498 | if (!u) continue
499 | fs = insertStringAtIndex(
500 | fs,
501 | `uniform ${u.type} ${uniform}${u.arraySize || ''}; \n`,
502 | fs.lastIndexOf(precisionString) + precisionString.length,
503 | )
504 | u.isNeeded = true
505 | }
506 | }
507 | const isShadertoy = fragment.includes('mainImage')
508 | if (isShadertoy) fs = fs.concat(FS_MAIN_SHADER)
509 | return fs
510 | }
511 | setUniforms = (timestamp: number) => {
512 | const { gl } = this
513 | if (!gl || !this.shaderProgram) return
514 | const delta = this.lastTime ? (timestamp - this.lastTime) / 1000 : 0
515 | this.lastTime = timestamp
516 | if (this.props.uniforms) {
517 | for (const name of Object.keys(this.props.uniforms)) {
518 | const currentUniform = this.props.uniforms?.[name]
519 | if (!currentUniform) return
520 | if (this.uniforms[name]?.isNeeded) {
521 | if (!this.shaderProgram) return
522 | const customUniformLocation = gl.getUniformLocation(this.shaderProgram, name)
523 | if (!customUniformLocation) return
524 | processUniform(
525 | gl,
526 | customUniformLocation,
527 | currentUniform.type as UniformType,
528 | currentUniform.value,
529 | )
530 | }
531 | }
532 | }
533 | if (this.uniforms.iMouse?.isNeeded) {
534 | const mouseUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_MOUSE)
535 | gl.uniform4fv(mouseUniform, this.uniforms.iMouse.value as number[])
536 | }
537 | if (this.uniforms.iChannelResolution?.isNeeded) {
538 | const channelResUniform = gl.getUniformLocation(
539 | this.shaderProgram,
540 | UNIFORM_CHANNELRESOLUTION,
541 | )
542 | gl.uniform3fv(channelResUniform, this.uniforms.iChannelResolution.value as number[])
543 | }
544 | if (this.uniforms.iDeviceOrientation?.isNeeded) {
545 | const deviceOrientationUniform = gl.getUniformLocation(
546 | this.shaderProgram,
547 | UNIFORM_DEVICEORIENTATION,
548 | )
549 | gl.uniform4fv(deviceOrientationUniform, this.uniforms.iDeviceOrientation.value as number[])
550 | }
551 | if (this.uniforms.iTime?.isNeeded) {
552 | const timeUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_TIME)
553 | gl.uniform1f(timeUniform, (this.timer += delta))
554 | }
555 | if (this.uniforms.iTimeDelta?.isNeeded) {
556 | const timeDeltaUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_TIMEDELTA)
557 | gl.uniform1f(timeDeltaUniform, delta)
558 | }
559 | if (this.uniforms.iDate?.isNeeded) {
560 | const d = new Date()
561 | const month = d.getMonth() + 1
562 | const day = d.getDate()
563 | const year = d.getFullYear()
564 | const time =
565 | d.getHours() * 60 * 60 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() * 0.001
566 | const dateUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_DATE)
567 | gl.uniform4fv(dateUniform, [year, month, day, time])
568 | }
569 | if (this.uniforms.iFrame?.isNeeded) {
570 | const timeDeltaUniform = gl.getUniformLocation(this.shaderProgram, UNIFORM_FRAME)
571 | gl.uniform1i(timeDeltaUniform, (this.uniforms.iFrame.value as number)++)
572 | }
573 | if (this.texturesArr.length > 0) {
574 | for (let index = 0; index < this.texturesArr.length; index++) {
575 | // TODO: Don't use this casting if possible:
576 | const texture = this.texturesArr[index] as Texture | undefined
577 | if (!texture) return
578 | const { isVideo, _webglTexture, source, flipY, isLoaded } = texture
579 | if (!isLoaded || !_webglTexture || !source) return
580 | if (this.uniforms[`iChannel${index}`]?.isNeeded) {
581 | if (!this.shaderProgram) return
582 | const iChannel = gl.getUniformLocation(this.shaderProgram, `iChannel${index}`)
583 | // @ts-expect-error TODO: Fix. Can't index WebGL context with this dynamic value.
584 | gl.activeTexture(gl[`TEXTURE${index}`])
585 | gl.bindTexture(gl.TEXTURE_2D, _webglTexture)
586 | gl.uniform1i(iChannel, index)
587 | if (isVideo) {
588 | texture.updateTexture(_webglTexture, source as HTMLVideoElement, flipY)
589 | }
590 | }
591 | }
592 | }
593 | }
594 | registerCanvas = (r: HTMLCanvasElement) => {
595 | this.canvas = r
596 | }
597 | gl?: WebGLRenderingContext | null
598 | squareVerticesBuffer?: WebGLBuffer | null
599 | shaderProgram?: WebGLProgram | null
600 | vertexPositionAttribute?: number
601 | animFrameId?: number
602 | canvas?: HTMLCanvasElement
603 | mousedown = false
604 | canvasPosition?: DOMRect
605 | timer = 0
606 | lastMouseArr: number[] = [0, 0]
607 | texturesArr: WebGLTexture[] = []
608 | lastTime = 0
609 | render = () => (
610 |
614 | )
615 | }
616 |
--------------------------------------------------------------------------------