Skip to content

Instantly share code, notes, and snippets.

@haxiomic
Last active June 6, 2021 11:56
Show Gist options
  • Save haxiomic/d4a61defd3790553b20ae2ad2ceef9a5 to your computer and use it in GitHub Desktop.
Save haxiomic/d4a61defd3790553b20ae2ad2ceef9a5 to your computer and use it in GitHub Desktop.
import * as THREE from 'three';
function glslFloat(f) {
let s = f + '';
if (s.indexOf('.') == -1) {
s += '.';
}
return s;
}
/**
* Fast fixed-kernel gaussian blur
* @author haxiomic (George Corney)
*/
export default class Blur1DMaterial extends THREE.RawShaderMaterial {
uTexture;
uTexelSize;
kernel;
directionX;
directionY;
constructor(kernel, truncationSigma, directionX, directionY, linearSampling) {
super({
uniforms: {
texture: null,
invResolution: null,
},
});
this.uniforms.texture = this.uTexture = new THREE.Uniform(null);
this.uniforms.invResolution = this.uTexelSize = new THREE.Uniform(new THREE.Vector2(1,1));
this.kernel = kernel;
this.directionX = directionX;
this.directionY = directionY;
this.side = THREE.DoubleSide;
let shaderParts = this.generateShaderParts(kernel, truncationSigma, directionX, directionY, linearSampling);
let precision = 'mediump';
this.vertexShader = `
precision ${precision} float;
attribute vec2 position;
uniform vec2 invResolution;
\n${shaderParts.varyingDeclarations.join('\n')}
const vec2 madd = vec2(0.5, 0.5);
void main() {
vec2 texelCoord = (position * madd + madd);
\n${shaderParts.varyingValues.join('\n')}
gl_Position = vec4(position, 0.0, 1.);
}
`;
this.fragmentShader = `
precision ${precision} float;
uniform sampler2D texture;
\n${shaderParts.fragmentDeclarations.join('\n')}
\n${shaderParts.varyingDeclarations.join('\n')}
void main() {
\n${shaderParts.fragmentVariables.join('\n')}
vec4 blend = vec4(0.0);
\n${shaderParts.textureSamples.join('\n')};
gl_FragColor = blend;
}
`;
}
generateShaderParts(kernel, truncationSigma, directionX, directionY, linearSampling) {
// Generate sampling offsets and weights
let N = Blur1DMaterial.nearestBestKernel(kernel);
let centerIndex = (N - 1) / 2;
// Generate Gaussian sampling weights over kernel
let offsets = [];
let weights = [];
let totalWeight = 0.0;
for (let i = 0; i < N; i++) {
let u = i / (N - 1);
let w = Blur1DMaterial.gaussianWeight(u * 2.0 - 1, truncationSigma);
offsets[i] = (i - centerIndex);
weights[i] = w;
totalWeight += w;
}
// Normalize weights
for (let i = 0; i < weights.length; i++) {
weights[i] /= totalWeight;
}
/**
Optimize: combine samples to take advantage of hardware linear sampling
Let weights of two samples be A and B
Then the sum of the samples: `Ax + By`
Can be represented with a single lerp sample a (distance to sample x), with new weight W
`Ax + By = W((1-a)x + ay)`
Solving for W, a in terms of A, B:
`W = A + B`
`a = B/(A + B)`
**/
let optimizeSamples = linearSampling;
if (optimizeSamples) {
let lerpSampleOffsets = [];
let lerpSampleWeights = [];
let i = 0;
while(i < N) {
let A = weights[i];
let leftOffset = offsets[i];
if ((i + 1) < N) {
// there is a pair to combine with
let B = weights[i + 1];
let lerpWeight = A + B;
let alpha = B/(A + B);
let lerpOffset = leftOffset + alpha;
lerpSampleOffsets.push(lerpOffset);
lerpSampleWeights.push(lerpWeight);
} else {
lerpSampleOffsets.push(leftOffset);
lerpSampleWeights.push(A);
}
i += 2;
}
// replace with optimized
offsets = lerpSampleOffsets;
weights = lerpSampleWeights;
}
// Generate shaders
let maxVaryingRows = 512; // ! this should be determined by querying the gl context
let maxVaryingVec2 = maxVaryingRows * 2; // seems like 2 varyings per row, but is this the guaranteed packing?
let varyingCount = Math.floor(Math.min(offsets.length, maxVaryingVec2));
let varyingDeclarations = [];
for (let i = 0; i < varyingCount; i++) {
varyingDeclarations.push(`varying vec2 sampleCoord${i};`);
}
let varyingValues = [];
for (let i = 0; i < varyingCount; i++) {
varyingValues.push(`sampleCoord${i} = texelCoord + vec2(${glslFloat(offsets[i] * directionX)}, ${glslFloat(offsets[i] * directionY)}) * invResolution;`);
}
let fragmentVariables = [];
for (let i = varyingCount; i < offsets.length; i++) {
fragmentVariables.push(`vec2 sampleCoord${i} = sampleCoord0 + vec2(${glslFloat((offsets[i] - offsets[0]) * directionX)}, ${glslFloat((offsets[i] - offsets[0]) * directionY)}) * invResolution;`);
}
let textureSamples = [];
for (let i = 0; i < offsets.length; i++) {
textureSamples.push(`blend += texture2D(texture, sampleCoord${i}) * ${glslFloat(weights[i])};`);
}
return {
varyingDeclarations: varyingDeclarations,
varyingValues: varyingValues,
fragmentDeclarations: varyingCount < offsets.length ? ['uniform vec2 invResolution;'] : [''],
fragmentVariables: fragmentVariables,
textureSamples: textureSamples,
};
}
static nearestBestKernel(idealKernel) {
let v = Math.round(idealKernel);
let ks = [v, v - 1, v + 1, v - 2, v + 2];
// for (k in [v, v - 1, v + 1, v - 2, v + 2]) {
for (let k of ks) {
if (((k % 2) != 0) && ((Math.floor(k / 2) % 2) == 0) && k > 0) {
return Math.floor(Math.max(k, 3));
}
}
return Math.floor(Math.max(v, 3));
}
static gaussianWeight(x, truncationSigma) {
let sigma = truncationSigma;
let denominator = Math.sqrt(2.0 * Math.PI) * sigma;
let exponent = -((x * x) / (2.0 * sigma * sigma));
let weight = (1.0 / denominator) * Math.exp(exponent);
return weight;
}
}
@haxiomic
Copy link
Author

haxiomic commented Jun 6, 2021

Fast fixed gaussian blur for three.js

Uses texture coordinates computed in vertex shader to enable early texture fetch and automatically determines optimal sampling to take advantage of hardware lerp (ensure sampled textures use linear filtering)

First written in TS for BabylonJS's PBR pipeline back in ~ 2016

For truncationSigma try 0.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment