diff --git a/docs/components/ExampleEditor.vue b/docs/components/ExampleEditor.vue index 864c33f..79179e5 100644 --- a/docs/components/ExampleEditor.vue +++ b/docs/components/ExampleEditor.vue @@ -7,7 +7,7 @@ ...props, deps: { ...deps, - usegl: '0.6.0', + usegl: '0.7.0', }, }" > diff --git a/docs/examples/post-processing/builtin-bloom/dots.frag b/docs/examples/post-processing/builtin-bloom/dots.frag new file mode 100644 index 0000000..ec05b23 --- /dev/null +++ b/docs/examples/post-processing/builtin-bloom/dots.frag @@ -0,0 +1,31 @@ +in vec2 vUv; +uniform float uTime; + +const vec2 center = vec2(0.5, 0.5); +const float ringRadius = 0.3; +const float dotRadius = 0.05; + +float circle(vec2 uv, vec2 center, float radius) { + float d = length(uv - center); + return 1.0 - smoothstep(radius - 0.003, radius + 0.003, d); +} + +void main() { + vec3 color = vec3(0.0); + float t = - uTime * 0.15; + + float offset = 0.; + for (int i = 0; i < 8; i++) { + vec2 p = center + vec2(cos(t + offset), sin(t + offset)) * ringRadius; + float r = dotRadius * (1.8 - 0.24 * float(i)); + color += vec3(1., vUv) * circle(vUv, p, r); + offset += atan(4. * r, ringRadius); + } + + // important ! + // - the colors need to be in linear space for the bloom calculation to be correct + // - there needs to be a final pass to convert linear RGB back to sRGB + color = pow(color, vec3(2.2)); + + gl_FragColor = vec4(color, 1.0); +} diff --git a/docs/examples/post-processing/builtin-bloom/index.md b/docs/examples/post-processing/builtin-bloom/index.md new file mode 100644 index 0000000..19f906a --- /dev/null +++ b/docs/examples/post-processing/builtin-bloom/index.md @@ -0,0 +1,12 @@ +--- +title: Bloom (builtin) +--- + +::: example-editor {deps=tweakpane@^4.0.5} + +<<< ./index.ts +<<< ./dots.frag +<<< @/snippets/canvas-square/styles.css +<<< @/snippets/default/index.html + +::: diff --git a/docs/examples/post-processing/builtin-bloom/index.ts b/docs/examples/post-processing/builtin-bloom/index.ts new file mode 100644 index 0000000..06e1d64 --- /dev/null +++ b/docs/examples/post-processing/builtin-bloom/index.ts @@ -0,0 +1,17 @@ +import { useWebGLCanvas, bloom, linearToSRGB } from "usegl"; +import { Pane } from "tweakpane"; +import fragment from "./dots.frag?raw"; +import "./styles.css"; + +const bloomEffect = bloom(); + +useWebGLCanvas({ + canvas: "#glCanvas", + fragment, + postEffects: [bloomEffect, linearToSRGB()], +}); + +// You can dynamically update the uniforms of an effect pass, like any other pass +const pane = new Pane({ title: "Uniforms" }); +pane.addBinding(bloomEffect.uniforms, "uRadius", { min: 0, max: 1 }); +pane.addBinding(bloomEffect.uniforms, "uMix", { min: 0, max: 1 }); diff --git a/docs/examples/post-processing/multi-pass/blur.frag b/docs/examples/post-processing/multi-pass/blur.frag index f353232..8300322 100644 --- a/docs/examples/post-processing/multi-pass/blur.frag +++ b/docs/examples/post-processing/multi-pass/blur.frag @@ -1,24 +1,39 @@ -uniform sampler2D uImage; +uniform sampler2D uTexture; uniform vec2 uResolution; uniform vec2 uDirection; +uniform float uRadius; + in vec2 vUv; out vec4 outColor; -const float weights[5] = float[](0.19638062, 0.29675293, 0.09442139, 0.01037598, 0.00025940); -const float offsets[5] = float[](0.0, 1.41176471, 3.29411765, 5.17647059, 7.05882353); +/* +* Directional Gaussian Blur +* /!\ Absolutely not optimized, for demonstration purposes only +*/ void main() { - vec3 color = texture(uImage, vUv).rgb * weights[0]; - float weightSum = weights[0]; + float sigma = float(uRadius / 2.); + vec2 sampleOffset = uDirection / uResolution; + + // Build normalized Gaussian weights + float weights[51]; + weights[0] = 1.0; + float sum = weights[0]; + for (int i = 1; i <= int(uRadius); ++i) { + float x = float(i); + float w = exp(-0.5 * (x * x) / (sigma * sigma)); + weights[i] = w; + sum += 2.0 * w; + } + for (int i = 0; i <= int(uRadius); ++i) { + weights[i] /= sum; + } - if (vUv.x < 0.52) { - for(int i = 1; i < 5; i++) { - vec2 offset = (offsets[i] / uResolution) * 0.5 * uDirection; - color += texture(uImage, vUv + offset).rgb * weights[i]; - color += texture(uImage, vUv - offset).rgb * weights[i]; - weightSum += 2.0 * weights[i]; - } - color /= weightSum; + vec3 color = texture(uTexture, vUv).rgb * weights[0]; + for (int i = 1; i <= int(uRadius); ++i) { + float fi = float(i); + color += texture(uTexture, vUv + sampleOffset * fi).rgb * weights[i]; + color += texture(uTexture, vUv - sampleOffset * fi).rgb * weights[i]; } outColor = vec4(color, 1.0); diff --git a/docs/examples/post-processing/multi-pass/circles.frag b/docs/examples/post-processing/multi-pass/circles.frag deleted file mode 100644 index 1f2f710..0000000 --- a/docs/examples/post-processing/multi-pass/circles.frag +++ /dev/null @@ -1,19 +0,0 @@ -uniform float uTime; -in vec2 vUv; -out vec4 outColor; - -float sdCircle(vec2 p, float r) { - return length(p) - r; - } - -vec3 drawCircle(vec2 pos, float radius, vec3 color) { - return smoothstep(radius * 1.01, radius * .99, sdCircle(pos, radius)) * color; -} - -void main() { - vec3 color = vec3(0, 0.07, 0.15); - color += drawCircle(vUv - vec2(.4), .1 * (1. + sin(uTime/2.)/10.), vec3(vUv, 1.)); - color += drawCircle(vUv - vec2(.65, .65), .015 * (1. + sin(uTime/2.-1.5)/4.), vec3(vUv, 1.)); - color += drawCircle(vUv - vec2(.75, .4), .04 * (1. + sin(uTime/2.-1.)/4.), vec3(vUv, 1.)); - outColor = vec4(color, 1.); -} diff --git a/docs/examples/post-processing/multi-pass/combine.frag b/docs/examples/post-processing/multi-pass/combine.frag index a10e272..8ec660b 100644 --- a/docs/examples/post-processing/multi-pass/combine.frag +++ b/docs/examples/post-processing/multi-pass/combine.frag @@ -6,34 +6,9 @@ uniform float uMix; in vec2 vUv; out vec4 outColor; -vec2 offset(float octave) { - vec2 padding = 10.0 / uResolution; - float octaveFloor = min(1.0, floor(octave / 3.0)); - vec2 offset = vec2( - -octaveFloor * (0.25 + padding.x), - -(1.0 - (1.0 / exp2(octave))) - padding.y * octave + octaveFloor * (0.35 + padding.y) - ); - return offset + 0.5 / uResolution; -} - -vec3 blurredMipmapLevel(float octave) { - vec2 offset = offset(octave - 1.0); - return texture(uBloomTexture, vUv / exp2(octave) - offset).rgb; -} - -vec3 bloomColor() { - return blurredMipmapLevel(1.0) * 0.8 - + blurredMipmapLevel(3.0) * 0.5 - + blurredMipmapLevel(4.0) * 1.2; -} - void main() { vec4 baseColor = texture(uBaseImage, vUv); - float baseColorGreyscale = dot(baseColor.rgb, vec3(0.299, 0.587, 0.114)); - float mixFactor = (1.0 - baseColorGreyscale * baseColor.a) * uMix; - - vec4 combinedColor = baseColor; - combinedColor.rgb += bloomColor() * mixFactor; + vec4 bloomColor = texture(uBloomTexture, vUv); - outColor = combinedColor; + outColor = max(baseColor, mix(baseColor, bloomColor, uMix)); } diff --git a/docs/examples/post-processing/multi-pass/dots.frag b/docs/examples/post-processing/multi-pass/dots.frag new file mode 100644 index 0000000..1860188 --- /dev/null +++ b/docs/examples/post-processing/multi-pass/dots.frag @@ -0,0 +1,25 @@ +in vec2 vUv; +out vec4 outColor; + +vec3 drawCircle(vec2 pos, float radius, vec3 color) { + return smoothstep(radius * 1.03, radius * .97, (length(pos) - radius)) * color; +} + +void main() { + vec2 c = vec2(0.5, 0.5); + float R = 0.15; + float s60 = 0.86602540378; // sqrt(3)/2 + + vec2 pTop = c + vec2(0.0, R); + vec2 pBL = c + vec2(-R * s60, -R * 0.5); + vec2 pBR = c + vec2( R * s60, -R * 0.5); + + vec3 color = vec3(0.0); + float r = 0.10; + + color += drawCircle(vUv - pTop, r, vec3(1.0, 0.0, 0.0)); + color += drawCircle(vUv - pBL, r, vec3(0.0, 1.0, 0.0)); + color += drawCircle(vUv - pBR, r, vec3(0.0, 0.0, 1.0)); + + outColor = vec4(color, 1.0); +} diff --git a/docs/examples/post-processing/multi-pass/index.md b/docs/examples/post-processing/multi-pass/index.md index 74df97a..58d87e6 100644 --- a/docs/examples/post-processing/multi-pass/index.md +++ b/docs/examples/post-processing/multi-pass/index.md @@ -1,15 +1,14 @@ --- -title: "Multi pass (bloom)" +title: Multi pass --- ::: example-editor {deps=tweakpane@^4.0.5} <<< ./index.ts -<<< ./circles.frag -<<< ./mipmap.frag +<<< ./dots.frag <<< ./blur.frag <<< ./combine.frag -<<< ./styles.css +<<< @/snippets/canvas-square/styles.css <<< @/snippets/default/index.html ::: diff --git a/docs/examples/post-processing/multi-pass/index.ts b/docs/examples/post-processing/multi-pass/index.ts index e83c5c5..3bd05f6 100644 --- a/docs/examples/post-processing/multi-pass/index.ts +++ b/docs/examples/post-processing/multi-pass/index.ts @@ -1,91 +1,68 @@ import { useEffectPass, useWebGLCanvas, useCompositeEffectPass } from "usegl"; -import fragment from "./circles.frag?raw"; -import mipmapsShader from "./mipmap.frag?raw"; -import blurShader from "./blur.frag?raw"; -import combineShader from "./combine.frag?raw"; +import directionalBlurFragment from "./blur.frag?raw"; +import combineFragment from "./combine.frag?raw"; +import dotsFragment from "./dots.frag?raw"; import { Pane } from "tweakpane"; import "./styles.css"; -const mipmaps = useEffectPass({ - fragment: mipmapsShader, - uniforms: { - uThreshold: 0.2, - }, -}); - const horizontalBlur = useEffectPass({ - fragment: blurShader, + fragment: directionalBlurFragment, uniforms: { + uTexture: ({ inputPass }) => inputPass.target!.texture, // optional, the texture uniform is automatically set uDirection: [1, 0], + uRadius: 30, }, }); const verticalBlur = useEffectPass({ - fragment: blurShader, + fragment: directionalBlurFragment, uniforms: { + uTexture: () => horizontalBlur.target!.texture, // optional, the texture uniform is automatically set uDirection: [0, 1], + uRadius: 30, }, }); const combine = useEffectPass({ - fragment: combineShader, + fragment: combineFragment, uniforms: { uBaseImage: ({ inputPass }) => inputPass.target!.texture, - uBloomTexture: () => verticalBlur.target!.texture, + uBloomTexture: ({ previousPass }) => previousPass.target!.texture, // same as () => verticalBlur.target!.texture uMix: 1, }, }); -const bloomEffect = useCompositeEffectPass({ - mipmaps, - horizontalBlur, - verticalBlur, - combine, -}); - -const vignetteEffect = useEffectPass({ - fragment: /* glsl */ ` - uniform sampler2D uTexture; - uniform float uSize; // (0.0 - 1.0) - uniform float uRoundness; // (0.0 = rectangle, 1.0 = round) - uniform float uStrength; // (0.0 - 1.0) - varying vec2 vUv; - - float vignette() { - vec2 centered = vUv * 2.0 - 1.0; - float circDist = length(centered); - float rectDist = max(abs(centered.x), abs(centered.y)); - float dist = mix(rectDist, circDist, uRoundness); - return 1. - smoothstep(uSize, uSize * 2., dist) * uStrength; - } +const bloomPasses = [horizontalBlur, verticalBlur, combine]; - void main() { - vec4 color = texture(uTexture, vUv); - color.rgb *= vignette(); - gl_FragColor = color; - } - `, - uniforms: { - uStrength: 0.5, - uSize: 0.6, - uRoundness: 0.7, +const bloomUniforms = { + get uRadius() { + return verticalBlur.uniforms.uRadius; }, -}); + set uRadius(value: number) { + verticalBlur.uniforms.uRadius = value; + horizontalBlur.uniforms.uRadius = value; + }, + get uMix() { + return combine.uniforms.uMix; + }, + set uMix(value: number) { + combine.uniforms.uMix = value; + }, +}; + +const bloom = useCompositeEffectPass(bloomPasses, bloomUniforms); useWebGLCanvas({ canvas: "#glCanvas", - fragment: fragment, - postEffects: [vignetteEffect, bloomEffect], + dpr: 1, + fragment: dotsFragment, + postEffects: [bloom], }); const pane = new Pane({ title: "Uniforms" }); -// You can update the uniforms of each individual pass, which will trigger a re-render -const bloom = pane.addFolder({ title: "Bloom" }); -bloom.addBinding(bloomEffect.passes.mipmaps.uniforms, "uThreshold", { min: 0, max: 1 }); -bloom.addBinding(combine.uniforms, "uMix", { min: 0, max: 1 }); - -const vignette = pane.addFolder({ title: "Vignette" }); -vignette.addBinding(vignetteEffect.uniforms, "uStrength", { min: 0, max: 1 }); -vignette.addBinding(vignetteEffect.uniforms, "uSize", { min: 0, max: 1 }); -vignette.addBinding(vignetteEffect.uniforms, "uRoundness", { min: 0, max: 1 }); +// Updating the uniforms of the composite pass will update those of the individual passes, +// which will trigger a re-render +const bloomFolder = pane.addFolder({ title: "Bloom" }); +bloomFolder.addBinding(bloomUniforms, "uMix", { min: 0, max: 1 }); +bloomFolder.addBinding(bloomUniforms, "uRadius", { min: 0, max: 50 }); diff --git a/docs/examples/post-processing/multi-pass/mipmap.frag b/docs/examples/post-processing/multi-pass/mipmap.frag deleted file mode 100644 index 6e4509e..0000000 --- a/docs/examples/post-processing/multi-pass/mipmap.frag +++ /dev/null @@ -1,41 +0,0 @@ -uniform sampler2D uImage; -uniform vec2 uResolution; -uniform float uThreshold; - -in vec2 vUv; -out vec4 outColor; - -vec2 offset(float octave) { - vec2 padding = 10.0 / uResolution; - float octaveFloor = min(1.0, floor(octave / 3.0)); - return vec2( - -octaveFloor * (0.25 + padding.x), - -(1.0 - (1.0 / exp2(octave))) - padding.y * octave + octaveFloor * (0.35 + padding.y) - ); -} - -vec3 mipmapLevel(float octave) { - float scale = exp2(octave); - vec2 coord = (vUv + offset(octave - 1.0)) * scale; - - if (any(lessThan(coord, vec2(0.0))) || any(greaterThan(coord, vec2(1.0)))) { - return vec3(0.0); - } - - vec3 color = vec3(0.0); - int spread = int(scale); - - for (int i = 0; i < spread; i++) { - for (int j = 0; j < spread; j++) { - vec2 offset = (vec2(i, j) / uResolution) * scale / float(spread); - vec3 imageColor = texture(uImage, coord + offset).rgb; - color += max(vec3(0.0), imageColor - vec3(uThreshold)); - } - } - - return color / float(spread * spread); -} - -void main() { - outColor = vec4(mipmapLevel(1.0) + mipmapLevel(3.0) + mipmapLevel(4.0), 1.0); -} diff --git a/docs/examples/post-processing/multi-pass/styles.css b/docs/examples/post-processing/multi-pass/styles.css deleted file mode 100644 index 30a7c83..0000000 --- a/docs/examples/post-processing/multi-pass/styles.css +++ /dev/null @@ -1,19 +0,0 @@ -body { - margin: 0; - background: black; - height: 100svh; - display: grid; - place-items: center; -} - -canvas { - width: min(90svmin, 900px); - aspect-ratio: 1; - display: block; - border-radius: 8px; - border: 1px solid #555; -} - -.tp-dfwv { - width: 260px !important; -} diff --git a/docs/examples/post-processing/single-pass/index.md b/docs/examples/post-processing/single-pass/index.md index 1d99537..00e10e8 100644 --- a/docs/examples/post-processing/single-pass/index.md +++ b/docs/examples/post-processing/single-pass/index.md @@ -1,5 +1,5 @@ --- -title: Single pass (sepia) +title: Single pass --- ::: example-editor {deps=tweakpane@^4.0.5} diff --git a/docs/examples/post-processing/single-pass/index.ts b/docs/examples/post-processing/single-pass/index.ts index edd52a1..e31986c 100644 --- a/docs/examples/post-processing/single-pass/index.ts +++ b/docs/examples/post-processing/single-pass/index.ts @@ -2,20 +2,20 @@ import { useEffectPass, useWebGLCanvas, loadTexture } from "usegl"; import { Pane } from "tweakpane"; import "./styles.css"; -const image = loadTexture("https://picsum.photos/id/323/600/400"); - -const sepiaEffect = useEffectPass({ +const sepia = useEffectPass({ fragment: /* glsl */ ` uniform sampler2D uTexture; // output of the render pass uniform float uStrength; in vec2 vUv; out vec4 fragColor; - #define SEPIA_COLOR vec3(1.2, 1.0, 0.7) - - vec3 sepia(vec3 color) { - float grayScale = dot(color, vec3(0.299, 0.587, 0.114)); - return grayScale * SEPIA_COLOR; + // sepia filter from the CSS specification : https://drafts.fxtf.org/filter-effects/#sepiaEquivalent + vec3 sepia(vec3 c){ + return c * mat3( + 0.393, 0.769, 0.189, + 0.349, 0.686, 0.168, + 0.272, 0.534, 0.131 + ); } void main() { @@ -25,7 +25,7 @@ const sepiaEffect = useEffectPass({ } `, uniforms: { - uStrength: 0.75, + uStrength: 1, }, }); @@ -41,9 +41,9 @@ const { onAfterRender } = useWebGLCanvas({ } `, uniforms: { - uPicture: image, + uPicture: loadTexture("https://picsum.photos/id/323/600/400"), }, - postEffects: [sepiaEffect], + postEffects: [sepia], }); const renderCount = document.querySelector("#renderCount"); @@ -51,6 +51,6 @@ onAfterRender(() => { renderCount.textContent = `${Number(renderCount.textContent) + 1}`; }); -// You can update the uniforms of an effect pass +// You can dynamically update the uniforms of an effect pass, like any other pass const pane = new Pane({ title: "Uniforms" }); -pane.addBinding(sepiaEffect.uniforms, "uStrength", { min: 0, max: 1 }); +pane.addBinding(sepia.uniforms, "uStrength", { min: 0, max: 1 }); diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..c4b3a41 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/lib/playground/src/pages/gpgpu/boids (static).astro b/lib/playground/src/pages/gpgpu/boids (static).astro index cac932c..cddefe9 100644 --- a/lib/playground/src/pages/gpgpu/boids (static).astro +++ b/lib/playground/src/pages/gpgpu/boids (static).astro @@ -76,15 +76,19 @@ import Layout from "../../layouts/Layout.astro"; positions.render(); renderPass.render(); - velocities.uniforms.uDeltaTime = 0.8; - velocities.render(); - positions.uniforms.uDeltaTime = 0.8; - positions.render(); - renderPass.render(); + requestAnimationFrame(() => { + velocities.uniforms.uDeltaTime = 0.8; + velocities.render(); + positions.uniforms.uDeltaTime = 0.8; + positions.render(); + renderPass.render(); - velocities.render(); - positions.render(); - renderPass.render(); + requestAnimationFrame(() => { + velocities.render(); + positions.render(); + renderPass.render(); + }); + }); }); diff --git a/lib/playground/src/pages/gpgpu/boids.astro b/lib/playground/src/pages/gpgpu/boids.astro index ec1ff48..53315b4 100644 --- a/lib/playground/src/pages/gpgpu/boids.astro +++ b/lib/playground/src/pages/gpgpu/boids.astro @@ -72,16 +72,14 @@ import Layout from "../../layouts/Layout.astro"; renderPass.onAfterRender(incrementRenderCount); - renderPass.onCanvasReady(() => { - useLoop(({ deltaTime }) => { - velocities.uniforms.uDeltaTime = deltaTime / 500; - velocities.render(); + useLoop(({ deltaTime }) => { + velocities.uniforms.uDeltaTime = deltaTime / 500; + velocities.render(); - positions.uniforms.uDeltaTime = deltaTime / 500; - positions.render(); + positions.uniforms.uDeltaTime = deltaTime / 500; + positions.render(); - renderPass.render(); - }); + renderPass.render(); }); diff --git a/lib/playground/src/pages/gpgpu/particles - FBO (static).astro b/lib/playground/src/pages/gpgpu/particles - FBO (static).astro index 6c6c736..b706a71 100644 --- a/lib/playground/src/pages/gpgpu/particles - FBO (static).astro +++ b/lib/playground/src/pages/gpgpu/particles - FBO (static).astro @@ -90,9 +90,11 @@ import Layout from "../../layouts/Layout.astro"; positions.render(); renderPass.render(); - positions.uniforms.uDeltaTime = 1; - positions.render(); - renderPass.render(); + requestAnimationFrame(() => { + positions.uniforms.uDeltaTime = 1; + positions.render(); + renderPass.render(); + }); }); diff --git a/lib/playground/src/pages/gpgpu/particles - FBO.astro b/lib/playground/src/pages/gpgpu/particles - FBO.astro index af2bf74..0e87392 100644 --- a/lib/playground/src/pages/gpgpu/particles - FBO.astro +++ b/lib/playground/src/pages/gpgpu/particles - FBO.astro @@ -93,12 +93,10 @@ import Layout from "../../layouts/Layout.astro"; renderPass.onAfterRender(incrementRenderCount); - renderPass.onCanvasReady(() => { - useLoop(({ deltaTime }) => { - positions.uniforms.uDeltaTime = deltaTime / 500; - positions.render(); - renderPass.render(); - }); + useLoop(({ deltaTime }) => { + positions.uniforms.uDeltaTime = deltaTime / 500; + positions.render(); + renderPass.render(); }); diff --git a/lib/playground/src/pages/post-processing/bloom.astro b/lib/playground/src/pages/post-processing/bloom.astro index 1d9adc2..e9fd9ce 100644 --- a/lib/playground/src/pages/post-processing/bloom.astro +++ b/lib/playground/src/pages/post-processing/bloom.astro @@ -3,7 +3,7 @@ import Layout from "../../layouts/Layout.astro"; --- + + + + diff --git a/lib/src/effects/bloom/glsl/upsample.frag b/lib/src/effects/bloom/glsl/upsample.frag index 7e699ec..e689a41 100644 --- a/lib/src/effects/bloom/glsl/upsample.frag +++ b/lib/src/effects/bloom/glsl/upsample.frag @@ -30,5 +30,5 @@ void main() { vec4 currColor = texture(uCurrentTexture, vUv); - fragColor = max(currColor, mix(currColor, prevColor, uRadius)); + fragColor = mix(currColor, prevColor, uRadius); } diff --git a/lib/src/effects/bloom/index.ts b/lib/src/effects/bloom/index.ts index b5b8d52..f5c7c07 100644 --- a/lib/src/effects/bloom/index.ts +++ b/lib/src/effects/bloom/index.ts @@ -1,7 +1,6 @@ import { useCompositeEffectPass } from "../../hooks/useCompositeEffectPass"; -import { useEffectPass } from "../../hooks/useEffectPass"; +import { floatTargetConfig, useEffectPass } from "../../hooks/useEffectPass"; import type { EffectPass } from "../../types"; -import type { RenderTargetParams } from "../../core/renderTarget"; import downSampleFragment from "./glsl/downsample.frag"; import combineFragment from "./glsl/combine.frag"; import sampleVertex from "./glsl/sample.vert"; @@ -13,13 +12,6 @@ export type BloomParams = { mix?: number; }; -const floatTargetConfig: RenderTargetParams = { - internalFormat: WebGL2RenderingContext.RGBA16F, - type: WebGL2RenderingContext.HALF_FLOAT, - minFilter: "linear", - magFilter: "linear", -}; - export function bloom(params: BloomParams = {}) { const { levels = 8, radius = 0.65, mix = 0.5 } = params; diff --git a/lib/src/effects/linearToSRGB/index.ts b/lib/src/effects/linearToSRGB/index.ts deleted file mode 100644 index 23cf501..0000000 --- a/lib/src/effects/linearToSRGB/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useEffectPass } from "../../hooks/useEffectPass"; -import linearToSRGBFragment from "./linearToSRGB.frag"; - -export function linearToSRGB() { - return useEffectPass({ - fragment: linearToSRGBFragment, - }); -} diff --git a/lib/src/effects/linearToSRGB/linearToSRGB.frag b/lib/src/effects/toneMapping/glsl/_common.glsl similarity index 66% rename from lib/src/effects/linearToSRGB/linearToSRGB.frag rename to lib/src/effects/toneMapping/glsl/_common.glsl index 0076880..b88d273 100644 --- a/lib/src/effects/linearToSRGB/linearToSRGB.frag +++ b/lib/src/effects/toneMapping/glsl/_common.glsl @@ -1,9 +1,11 @@ uniform sampler2D uTexture; +uniform float uExposure; +uniform bool uConvertToSRGB; + varying vec2 vUv; -void main() { - vec4 color = texture(uTexture, vUv); - gl_FragColor = vec4( +vec4 linearToSRGB(vec4 color) { + return vec4( mix( color.rgb * 12.92, pow(color.rgb, vec3(1.0 / 2.4)) * 1.055 - 0.055, diff --git a/lib/src/effects/toneMapping/glsl/_main.glsl b/lib/src/effects/toneMapping/glsl/_main.glsl new file mode 100644 index 0000000..cff4e76 --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/_main.glsl @@ -0,0 +1,11 @@ +void main() { + vec4 color = texture(uTexture, vUv) * uExposure; + color.rgb = toneMapping(color.rgb); + color = clamp(color, 0.0, 1.0); + + if (uConvertToSRGB) { + color = linearToSRGB(color); + } + + gl_FragColor = color; +} diff --git a/lib/src/effects/toneMapping/glsl/aces.frag b/lib/src/effects/toneMapping/glsl/aces.frag new file mode 100644 index 0000000..4ca423b --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/aces.frag @@ -0,0 +1,37 @@ +#include "./_common.glsl" + +// sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT +const mat3 ACESInputMat = mat3( + vec3( 0.59719, 0.07600, 0.02840 ), // transposed from source + vec3( 0.35458, 0.90834, 0.13383 ), + vec3( 0.04823, 0.01566, 0.83777 ) +); + +// ODT_SAT => XYZ => D60_2_D65 => sRGB +const mat3 ACESOutputMat = mat3( + vec3( 1.60475, -0.10208, -0.00327 ), // transposed from source + vec3( -0.53108, 1.10813, -0.07276 ), + vec3( -0.07367, -0.00605, 1.07602 ) +); + +// source: https://github.com/selfshadow/ltc_code/blob/master/webgl/shaders/ltc/ltc_blit.fs +vec3 RRTAndODTFit( vec3 v ) { + vec3 a = v * ( v + 0.0245786 ) - 0.000090537; + vec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081; + return a / b; +} + +// Three.js implementation of ACES Filmic Tone Mapping +// source: https://github.com/mrdoob/three.js/blob/7f848acd7dc54062c50fca749211ecea0af8742b/src/renderers/shaders/ShaderChunk/tonemapping_pars_fragment.glsl.js#L46 + +vec3 toneMapping( vec3 color ) { + color /= 0.6; + + color = ACESInputMat * color; + color = RRTAndODTFit( color ); + color = ACESOutputMat * color; + + return color; +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/agx.frag b/lib/src/effects/toneMapping/glsl/agx.frag new file mode 100644 index 0000000..8add6a4 --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/agx.frag @@ -0,0 +1,81 @@ +#include "./_common.glsl" + +// Matrices for rec 2020 <> rec 709 color space conversion +// matrix provided in row-major order so it has been transposed +// https://www.itu.int/pub/R-REP-BT.2407-2017 +const mat3 LINEAR_REC2020_TO_LINEAR_SRGB = mat3( + vec3( 1.6605, - 0.1246, - 0.0182 ), + vec3( - 0.5876, 1.1329, - 0.1006 ), + vec3( - 0.0728, - 0.0083, 1.1187 ) +); + +const mat3 LINEAR_SRGB_TO_LINEAR_REC2020 = mat3( + vec3( 0.6274, 0.0691, 0.0164 ), + vec3( 0.3293, 0.9195, 0.0880 ), + vec3( 0.0433, 0.0113, 0.8956 ) +); + +// https://iolite-engine.com/blog_posts/minimal_agx_implementation +// Mean error^2: 3.6705141e-06 +vec3 agxDefaultContrastApprox( vec3 x ) { + vec3 x2 = x * x; + vec3 x4 = x2 * x2; + + return + 15.5 * x4 * x2 + - 40.14 * x4 * x + + 31.96 * x4 + - 6.868 * x2 * x + + 0.4298 * x2 + + 0.1191 * x + - 0.00232; +} + +// AgX constants +const mat3 AgXInsetMatrix = mat3( + vec3( 0.856627153315983, 0.137318972929847, 0.11189821299995 ), + vec3( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ), + vec3( 0.0482516061458583, 0.101439036467562, 0.811302368396859 ) +); + +// explicit AgXOutsetMatrix generated from Filaments AgXOutsetMatrixInv +const mat3 AgXOutsetMatrix = mat3( + vec3( 1.1271005818144368, - 0.1413297634984383, - 0.14132976349843826 ), + vec3( - 0.11060664309660323, 1.157823702216272, - 0.11060664309660294 ), + vec3( - 0.016493938717834573, - 0.016493938717834257, 1.2519364065950405 ) +); + +// LOG2_MIN = -10.0 +// LOG2_MAX = +6.5 +// MIDDLE_GRAY = 0.18 +const float AgxMinEv = - 12.47393; // log2( pow( 2, LOG2_MIN ) * MIDDLE_GRAY ) +const float AgxMaxEv = 4.026069; // log2( pow( 2, LOG2_MAX ) * MIDDLE_GRAY ) + + +// Three.js implementation of AgX Tone Mapping, based on Filament, based on Blender +// source: https://github.com/mrdoob/three.js/blob/c5e5c609904ff38e701f7cafccbd454d363019c7/src/renderers/shaders/ShaderChunk/tonemapping_pars_fragment.glsl.js#L113 + +vec3 toneMapping( vec3 color ) { + color = LINEAR_SRGB_TO_LINEAR_REC2020 * color; + color = AgXInsetMatrix * color; + + // Log2 encoding + color = max( color, 1e-10 ); // avoid 0 or negative numbers for log2 + color = log2( color ); + color = ( color - AgxMinEv ) / ( AgxMaxEv - AgxMinEv ); + + color = clamp( color, 0.0, 1.0 ); + + // Apply sigmoid + color = agxDefaultContrastApprox( color ); + + color = AgXOutsetMatrix * color; + + // Linearize + color = pow( max( vec3( 0.0 ), color ), vec3( 2.2 ) ); + + color = LINEAR_REC2020_TO_LINEAR_SRGB * color; + + return color; +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/cineon.frag b/lib/src/effects/toneMapping/glsl/cineon.frag new file mode 100644 index 0000000..79ec2be --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/cineon.frag @@ -0,0 +1,11 @@ +#include "./_common.glsl" + +// filmic operator by Jim Hejl and Richard Burgess-Dawson +// http://filmicworlds.com/blog/filmic-tonemapping-operators/ + +vec3 toneMapping( vec3 color ) { + color = max( vec3( 0.0 ), color - 0.004 ); + return pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) ); +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/hable.frag b/lib/src/effects/toneMapping/glsl/hable.frag new file mode 100644 index 0000000..2ae3fb3 --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/hable.frag @@ -0,0 +1,25 @@ +#include "./_common.glsl" + +// Uncharted 2 filmic curve by John Hable +// http://filmicworlds.com/blog/filmic-tonemapping-operators/ + +const float A = 0.15; +const float B = 0.50; +const float C = 0.10; +const float D = 0.20; +const float E = 0.02; +const float F = 0.30; +const float W = 11.2; +const float exposureBias = 2.0; + +vec3 uncharted2Tonemap(vec3 x) { + return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F)) - E/F; +} + +vec3 toneMapping(vec3 color) { + vec3 mapped = uncharted2Tonemap(exposureBias * color); + vec3 whiteScale = 1.0 / uncharted2Tonemap(vec3(W)); + return mapped * whiteScale; +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/linear.frag b/lib/src/effects/toneMapping/glsl/linear.frag new file mode 100644 index 0000000..d83c5ac --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/linear.frag @@ -0,0 +1,7 @@ +#include "./_common.glsl" + +vec3 toneMapping(vec3 color) { + return color; +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/neutral.frag b/lib/src/effects/toneMapping/glsl/neutral.frag new file mode 100644 index 0000000..023372d --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/neutral.frag @@ -0,0 +1,25 @@ +#include "./_common.glsl" + +const float startCompression = 0.8 - 0.04; +const float desaturation = 0.15; + +// Khronos PBR Neutral +// https://modelviewer.dev/examples/tone-mapping + +vec3 toneMapping( vec3 color ) { + float x = min(color.r, min(color.g, color.b)); + float offset = x < 0.08 ? x - 6.25 * x * x : 0.04; + color -= offset; + + float peak = max(color.r, max(color.g, color.b)); + if (peak < startCompression) return color; + + float d = 1. - startCompression; + float newPeak = 1. - d * d / (peak + d - startCompression); + color *= newPeak / peak; + + float g = 1. - 1. / (desaturation * (peak - newPeak) + 1.); + return mix(color, newPeak * vec3(1, 1, 1), g); +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/glsl/reinhard.frag b/lib/src/effects/toneMapping/glsl/reinhard.frag new file mode 100644 index 0000000..da24581 --- /dev/null +++ b/lib/src/effects/toneMapping/glsl/reinhard.frag @@ -0,0 +1,17 @@ +#include "./_common.glsl" + +uniform float uWhitePoint; + +// Reinhard tonemapping extended with white point + +vec3 toneMapping(vec3 color) { + vec3 mapped = color.rgb / (1.0 + color.rgb); + + if (uWhitePoint > 1.0) { + vec3 whiteSq = vec3(uWhitePoint * uWhitePoint); + mapped = (color.rgb * (1.0 + color.rgb / whiteSq)) / (1.0 + color.rgb); + } + return mapped; +} + +#include "./_main.glsl" diff --git a/lib/src/effects/toneMapping/index.ts b/lib/src/effects/toneMapping/index.ts new file mode 100644 index 0000000..67d53e5 --- /dev/null +++ b/lib/src/effects/toneMapping/index.ts @@ -0,0 +1,79 @@ +import { useEffectPass } from "../../hooks/useEffectPass"; + +import linearFragment from "./glsl/linear.frag"; +import acesFragment from "./glsl/aces.frag"; +import reinhardFragment from "./glsl/reinhard.frag"; +import hableFragment from "./glsl/hable.frag"; +import neutralFragment from "./glsl/neutral.frag"; +import cineonFragment from "./glsl/cineon.frag"; +import agxFragment from "./glsl/agx.frag"; + +export type ToneMappingParams = { + /** + * The exposure level to apply to the image before the tone mapping. + * @default 1 + */ + exposure?: number; + /** + * The color space to output the final image in. + * @default "sRGB" + */ + outputColorSpace?: "sRGB" | "linear"; +}; + +function createToneMappingPass(fragment: string, params: ToneMappingParams = {}) { + const { exposure = 1, outputColorSpace = "sRGB" } = params; + return useEffectPass({ + fragment, + uniforms: { + uExposure: exposure, + uConvertToSRGB: outputColorSpace === "sRGB", + }, + }); +} + +type ReinhardToneMappingParams = ToneMappingParams & { + /** + * The white point to use for extended Reinhard tone mapping. + * A value of 1 disables the extended tone mapping. + * + * @default 1 + */ + whitePoint?: number; +}; + +export function reinhardToneMapping(params: ReinhardToneMappingParams = {}) { + const { exposure = 1, outputColorSpace = "sRGB", whitePoint = 1 } = params; + return useEffectPass({ + fragment: reinhardFragment, + uniforms: { + uExposure: exposure, + uConvertToSRGB: outputColorSpace === "sRGB", + uWhitePoint: whitePoint, + }, + }); +} + +export function linearToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(linearFragment, params); +} + +export function hableToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(hableFragment, params); +} + +export function acesToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(acesFragment, params); +} + +export function neutralToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(neutralFragment, params); +} + +export function cineonToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(cineonFragment, params); +} + +export function agxToneMapping(params: ToneMappingParams = {}) { + return createToneMappingPass(agxFragment, params); +} diff --git a/lib/src/hooks/useCompositeEffectPass.ts b/lib/src/hooks/useCompositeEffectPass.ts index d5e1c18..e448b6d 100644 --- a/lib/src/hooks/useCompositeEffectPass.ts +++ b/lib/src/hooks/useCompositeEffectPass.ts @@ -9,8 +9,8 @@ import type { } from "../types"; export function useCompositeEffectPass>( - passes: EffectPass[], - uniforms: U, + passes: EffectPass[], + uniforms: U = {} as U, ): CompositeEffectPass { const outputPass = passes.at(-1)!; diff --git a/lib/src/hooks/useCompositor.ts b/lib/src/hooks/useCompositor.ts index fa0fbde..c367e6e 100644 --- a/lib/src/hooks/useCompositor.ts +++ b/lib/src/hooks/useCompositor.ts @@ -1,6 +1,7 @@ import { createRenderTarget } from "../core/renderTarget"; import { findUniformName } from "../internal/findName"; import type { CompositeEffectPass, EffectPass, RenderPass } from "../types"; +import { floatTargetConfig } from "./useEffectPass"; /** * The compositor handles the combination of the render pass and the effects: @@ -18,7 +19,7 @@ export function useCompositor( gl.getExtension("EXT_color_buffer_float"); if (effects.length > 0 && renderPass.target === null) { - renderPass.setTarget(createRenderTarget(gl)); + renderPass.setTarget(createRenderTarget(gl, floatTargetConfig)); } let previousPass = renderPass; diff --git a/lib/src/hooks/useEffectPass.ts b/lib/src/hooks/useEffectPass.ts index 5837d2c..c7b15c1 100644 --- a/lib/src/hooks/useEffectPass.ts +++ b/lib/src/hooks/useEffectPass.ts @@ -15,15 +15,15 @@ type EffectPassOptions = Omit, "tar target?: RenderTargetParams | null; }; -const effectTargetConfig: RenderTargetParams = { - minFilter: "nearest", - magFilter: "nearest", +export const floatTargetConfig: RenderTargetParams = { + internalFormat: WebGL2RenderingContext.RGBA16F, + type: WebGL2RenderingContext.HALF_FLOAT, }; export function useEffectPass( options: EffectPassOptions, ): EffectPass { - const { target = effectTargetConfig, resolutionScale = 1 } = options; + const { target = floatTargetConfig, resolutionScale = 1 } = options; const renderPass = useQuadRenderPass(undefined, { ...options, target: null }); diff --git a/lib/src/hooks/useWebGLCanvas.ts b/lib/src/hooks/useWebGLCanvas.ts index b8a29be..a86cd64 100644 --- a/lib/src/hooks/useWebGLCanvas.ts +++ b/lib/src/hooks/useWebGLCanvas.ts @@ -41,8 +41,13 @@ export const useWebGLCanvas = (props: Props) => { const renderPass = useQuadRenderPass(gl, props); const compositor = useCompositor(gl, renderPass, postEffects); + // don't render before the first resize of the canvas to avoid a glitch + let isCanvasResized = false; + function render() { - compositor.render(); + if (isCanvasResized) { + compositor.render(); + } } let requestedRender = false; @@ -107,13 +112,12 @@ export const useWebGLCanvas = (props: Props) => { let resizeObserver: ReturnType | null = null; const [canvasReadyCallbacks, onCanvasReady] = useLifeCycleCallback(); - let isFirstResize = true; function resizeCanvas(width: number, height: number) { setSize({ width: width * dpr, height: height * dpr }); - if (isFirstResize) { + if (!isCanvasResized) { for (const callback of canvasReadyCallbacks) callback(); - isFirstResize = false; + isCanvasResized = true; } } diff --git a/lib/src/index.ts b/lib/src/index.ts index e90cd80..598a322 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -20,4 +20,12 @@ export { usePingPongFBO } from "./hooks/usePingPongFBO"; export { useTransformFeedback } from "./hooks/useTransformFeedback"; export { bloom } from "./effects/bloom"; -export { linearToSRGB } from "./effects/linearToSRGB"; +export { + linearToneMapping, + reinhardToneMapping, + hableToneMapping, + acesToneMapping, + neutralToneMapping, + cineonToneMapping, + agxToneMapping, +} from "./effects/toneMapping"; diff --git a/lib/src/internal/useUniforms.ts b/lib/src/internal/useUniforms.ts index b45afbb..7d7f216 100644 --- a/lib/src/internal/useUniforms.ts +++ b/lib/src/internal/useUniforms.ts @@ -56,6 +56,7 @@ export function useUniforms(uniforms: U) { if (uniformLocation === -1) return -1; if (typeof value === "number") return _gl.uniform1f(uniformLocation, value); + if (typeof value === "boolean") return _gl.uniform1i(uniformLocation, value ? 1 : 0); if (value instanceof WebGLTexture) { if (!textureUnits.has(name)) { diff --git a/lib/src/types.ts b/lib/src/types.ts index 7669d57..cb36d88 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -59,7 +59,7 @@ export type EffectPass> = RenderPass< export interface CompositeEffectPass> extends Omit, "fragment" | "vertex"> { - passes: EffectPass[]; + passes: EffectPass[]; } export type DrawMode = diff --git a/lib/tests/__screenshots__/tonemapping/tonemapping-android.png b/lib/tests/__screenshots__/tonemapping/tonemapping-android.png new file mode 100644 index 0000000..66586f8 Binary files /dev/null and b/lib/tests/__screenshots__/tonemapping/tonemapping-android.png differ diff --git a/lib/tests/__screenshots__/tonemapping/tonemapping-chromium.png b/lib/tests/__screenshots__/tonemapping/tonemapping-chromium.png new file mode 100644 index 0000000..f4b0034 Binary files /dev/null and b/lib/tests/__screenshots__/tonemapping/tonemapping-chromium.png differ diff --git a/lib/tests/__screenshots__/tonemapping/tonemapping-firefox.png b/lib/tests/__screenshots__/tonemapping/tonemapping-firefox.png new file mode 100644 index 0000000..a7fb005 Binary files /dev/null and b/lib/tests/__screenshots__/tonemapping/tonemapping-firefox.png differ diff --git a/lib/tests/__screenshots__/tonemapping/tonemapping-iphone.png b/lib/tests/__screenshots__/tonemapping/tonemapping-iphone.png new file mode 100644 index 0000000..8871f7c Binary files /dev/null and b/lib/tests/__screenshots__/tonemapping/tonemapping-iphone.png differ diff --git a/lib/tests/__screenshots__/tonemapping/tonemapping-safari.png b/lib/tests/__screenshots__/tonemapping/tonemapping-safari.png new file mode 100644 index 0000000..ce9f1cb Binary files /dev/null and b/lib/tests/__screenshots__/tonemapping/tonemapping-safari.png differ diff --git a/lib/tests/screenshots.spec.ts b/lib/tests/screenshots.spec.ts index b5319ff..1cfdb0d 100644 --- a/lib/tests/screenshots.spec.ts +++ b/lib/tests/screenshots.spec.ts @@ -9,8 +9,8 @@ const routesToTest = routes.filter(({ route }) => !ignoreRoutes.has(route)); const expectedRendersByDemo = { scissor: "2", video: "2", - "particles - FBO (static)": "3", - "boids (static)": "4", + "particles - FBO (static)": "2", + "boids (static)": "3", mipmap: /[1-3]/, texture: /1|2/, sepia: /1|2/,