├── .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** [![lusat minzip package size](https://img.shields.io/bundlephobia/minzip/react-shaders?label=zipped)](https://www.npmjs.com/package/react-shaders) [![lusat package version](https://img.shields.io/npm/v/react-shaders.svg?colorB=green)](https://www.npmjs.com/package/react-shaders) [![lusat license](https://img.shields.io/npm/l/react-shaders.svg?colorB=lightgrey)](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 | 8 | 9 | 10 | 11 | 23 | 35 | 36 |
index.tsx (React)example.glsl (GLSL)
12 | 13 | ```jsx 14 | import { Shader } from 'react-shaders' 15 | import code from './example.glsl' 16 | 17 | return ( 18 | 19 | ) 20 | ``` 21 | 22 | 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 |
37 | 38 | ### Installation 39 | 40 | 41 | 42 | 49 | 56 | 63 | 64 |
43 | 44 | ```bash 45 | npm i react-shaders 46 | ``` 47 | 48 | 50 | 51 | ```bash 52 | pnpm i react-shaders 53 | ``` 54 | 55 | 57 | 58 | ```bash 59 | bun add react-shaders 60 | ``` 61 | 62 |
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 | --------------------------------------------------------------------------------