Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RenderPassSsao: improve SSAO blur performance #6684

Closed
wants to merge 12 commits into from
Closed
45 changes: 36 additions & 9 deletions examples/src/examples/graphics/ambient-occlusion.controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@ export function controls({ observer, ReactPCUI, React, jsx, fragment }) {
link: { observer, path: 'data.ssao.enabled' }
})
),
jsx(
LabelGroup,
{ text: 'blurEnabled' },
jsx(BooleanInput, {
type: 'toggle',
binding: new BindingTwoWay(),
link: { observer, path: 'data.ssao.blurEnabled' }
})
),
jsx(
LabelGroup,
{ text: 'radius' },
Expand Down Expand Up @@ -85,6 +76,42 @@ export function controls({ observer, ReactPCUI, React, jsx, fragment }) {
binding: new BindingTwoWay(),
link: { observer, path: 'data.ssao.scale' }
})
),
jsx(
LabelGroup,
{ text: 'blurEnabled' },
jsx(BooleanInput, {
type: 'toggle',
binding: new BindingTwoWay(),
link: { observer, path: 'data.ssao.blurEnabled' }
})
),
jsx(
LabelGroup,
{ text: 'blurType' },
jsx(SelectInput, {
options: [
// NOTE: BLUR_BOX is 0, BLUR_GAUSSIAN is 1
// But SelectInput options don't work with 0-based indices (a bug?)
// So we subtract 1 from the value of data.get('data.ssao.blurType')
{ v: 1, t: 'Box' },
{ v: 2, t: 'Gaussian' }
],
binding: new BindingTwoWay(),
link: { observer, path: 'data.ssao.blurType' }
})
),
jsx(
LabelGroup,
{ text: 'blurSize' },
jsx(SliderInput, {
min: 1,
step: 1,
max: 25,
precision: 0,
binding: new BindingTwoWay(),
link: { observer, path: 'data.ssao.blurSize' }
})
)
)
);
Expand Down
9 changes: 7 additions & 2 deletions examples/src/examples/graphics/ambient-occlusion.example.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ assetListLoader.load(() => {
ssaoPass.sampleCount = data.get('data.ssao.samples');
ssaoPass.minAngle = data.get('data.ssao.minAngle');
ssaoPass.scale = data.get('data.ssao.scale');
ssaoPass.blurType = data.get('data.ssao.blurType') - 1;
ssaoPass.blurSize = data.get('data.ssao.blurSize');

}
};

Expand All @@ -256,13 +259,15 @@ assetListLoader.load(() => {
data.set('data', {
ssao: {
enabled: true,
blurEnabled: true,
radius: 30,
samples: 12,
intensity: 0.4,
power: 6,
minAngle: 10,
scale: 1
scale: 1,
blurEnabled: true,
blurType: 1,
blurSize: 9
}
});

Expand Down
38 changes: 38 additions & 0 deletions src/core/math/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,44 @@ const math = {
const min = Math.min(a, b);
const max = Math.max(a, b);
return inclusive ? num >= min && num <= max : num > min && num < max;
},

/**
* Computes the Gaussian function value for a given input.
*
* @param {number} x - The input value for which the Gaussian function is to be calculated.
* @param {number} sigma - The standard deviation of the distribution.
* @returns {number} - The Gaussian function value.
* @ignore
*/
querielo marked this conversation as resolved.
Show resolved Hide resolved
gauss(x, sigma) {
return Math.exp(-(x * x) / (2.0 * sigma * sigma));
},

/**
* Generates Gaussian weights for a given kernel size.
*
* @param {number} kernelSize - The size of the kernel for which the Gaussian weights are to be computed.
* @param {number[]|Float32Array|Float64Array} [target] - An optional array to store the computed Gaussian weights.
* @returns {number[]|Float32Array|Float64Array} - An array containing the normalized Gaussian weights.
* @ignore
*/
querielo marked this conversation as resolved.
Show resolved Hide resolved
gaussWeights(kernelSize, target = new Array(kernelSize)) {
const sigma = (kernelSize - 1) / (2 * 3);

const halfWidth = (kernelSize - 1) * 0.5;
let sum = 0.0;

for (let i = 0; i < kernelSize; ++i) {
target[i] = math.gauss(i - halfWidth, sigma);
sum += target[i];
}

for (let i = 0; i < kernelSize; ++i) {
target[i] /= sum;
}

return target;
}
};

Expand Down
224 changes: 164 additions & 60 deletions src/extras/render-passes/render-pass-depth-aware-blur.js
Original file line number Diff line number Diff line change
@@ -1,93 +1,197 @@
import { math } from '../../core/math/math.js';
import { Vec2 } from '../../core/math/vec2.js';
import { BLUR_GAUSSIAN } from '../../scene/constants.js';
import { RenderPassShaderQuad } from '../../scene/graphics/render-pass-shader-quad.js';
import { shaderChunks } from '../../scene/shader-lib/chunks/chunks.js';

const sourceInvResolutionValueTmp = new Float32Array(2);

const fragmentShader = shaderChunks.screenDepthPS + /* glsl */`

varying vec2 uv0;

uniform sampler2D sourceTexture;
uniform vec2 sourceInvResolution;
uniform int filterSize;
uniform vec2 direction;
uniform float kernel[KERNEL_SIZE];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hide the kernel behind #ifdef KERNEL to avoid the cost when the box filter is used


float random(const highp vec2 w) {
const vec3 m = vec3(0.06711056, 0.00583715, 52.9829189);
return fract(m.z * fract(dot(w, m.xy)));
}

float bilateralWeight(in float depth, in float sampleDepth) {
float diff = (sampleDepth - depth);
return max(0.0, 1.0 - diff * diff);
}

void tap(inout float sum, inout float totalWeight, float weight, float depth, vec2 position) {

float color = texture2D(sourceTexture, position).r;

float textureDepth = -getLinearScreenDepth(position);

float bilateral = bilateralWeight(depth, textureDepth);

bilateral *= weight;

sum += color * bilateral;
totalWeight += bilateral;
}

int middle = (KERNEL_SIZE + 1) / 2;

void main() {
float depth = -getLinearScreenDepth(uv0);

float sum = texture2D(sourceTexture, uv0).r * kernel[middle];
float totalWeight = kernel[middle];

#pragma unroll
for (int i = 1; i < middle; i++) {
tap(sum, totalWeight, kernel[middle + i], depth, uv0 + float(i) * direction * sourceInvResolution);
tap(sum, totalWeight, kernel[middle - i], depth, uv0 - float(i) * direction * sourceInvResolution);
}

float finalColor = sum / totalWeight;

// simple dithering helps a lot (assumes 8 bits target)
// this is most useful with high quality/large blurs
// finalColor += ((random(gl_FragCoord.xy) - 0.5) / 255.0);

gl_FragColor.r = finalColor;
}
`;

/**
* Render pass implementation of a depth-aware bilateral blur filter.
*
* @category Graphics
* @ignore
*/
class RenderPassDepthAwareBlur extends RenderPassShaderQuad {
constructor(device, sourceTexture) {
super(device);
this.sourceTexture = sourceTexture;
_direction = new Float32Array(2);

/**
* Initializes a new blur render pass. It has to be called before the render pass can be used.
*
* @param {Object} options - Options for configuring the blur effect.
* @param {import('../../platform/graphics/texture.js').Texture} [options.sourceTexture] - The source texture to be blurred.
* @param {number} [options.kernelSize] - The size of the blur kernel. Defaults to 7.
* @param {BLUR_GAUSSIAN|import('../../scene/constants.js').BLUR_BOX} [options.type] - The type of blur to apply. Defaults to BLUR_GAUSSIAN.
* @param {Vec2} [options.direction] - The direction of the blur. Defaults to (1, 0).
*/
setup(options) {
this.sourceTexture = options.sourceTexture;

this.direction = options.direction ?? new Vec2(1, 0);
this.kernelSize = options.kernelSize ?? 9;
this.type = options.type ?? BLUR_GAUSSIAN;

this.shader = this.createQuadShader('DepthAwareBlurShader', shaderChunks.screenDepthPS + /* glsl */`
var scope = this.device.scope;

varying vec2 uv0;
this.sourceTextureId = scope.resolve('sourceTexture');
this.sourceInvResolutionId = scope.resolve('sourceInvResolution');
this.kernelId = scope.resolve('kernel[0]');
this.directionId = scope.resolve('direction');
}

uniform sampler2D sourceTexture;
uniform vec2 sourceInvResolution;
uniform int filterSize;
execute() {
if (!this.sourceTexture) {
super.execute();
return;
}

float random(const highp vec2 w) {
const vec3 m = vec3(0.06711056, 0.00583715, 52.9829189);
return fract(m.z * fract(dot(w, m.xy)));
}
const { width, height } = this.sourceTexture;
sourceInvResolutionValueTmp[0] = 1.0 / width;
sourceInvResolutionValueTmp[1] = 1.0 / height;

float bilateralWeight(in float depth, in float sampleDepth) {
float diff = (sampleDepth - depth);
return max(0.0, 1.0 - diff * diff);
}
this.sourceInvResolutionId?.setValue(sourceInvResolutionValueTmp);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for those ? there as those are transpiled to if .. we always set those up, so they're never undefined.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VS Code TS checker highlighted this error, suggesting that sourceInvResolutionId could be undefined as it is not defined during creation. I'll check if Playcanvas linter highlights it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, linter highlights it, we have lots of these warnings in the engine, but they're not correct, but we don't see a way to remove them.

this.sourceTextureId?.setValue(this.sourceTexture);
this.kernelId?.setValue(this.kernelValue);
this.directionId?.setValue(this.direction);

void tap(inout float sum, inout float totalWeight, float weight, float depth, vec2 position) {
super.execute();
}

float color = texture2D(sourceTexture, position).r;
float textureDepth = -getLinearScreenDepth(position);

float bilateral = bilateralWeight(depth, textureDepth);
/**
* @param {BLUR_GAUSSIAN|import('../../scene/constants.js').BLUR_BOX} value - The type of blur to apply.
*/
set type(value) {
if (value === this._type) {
return;
}

bilateral *= weight;
sum += color * bilateral;
totalWeight += bilateral;
}
this._type = value;

// TODO: weights of 1 are used for all samples. Test with gaussian weights
void main() {
this.updateWeightCoefs();
}

// handle the center pixel separately because it doesn't participate in bilateral filtering
float depth = -getLinearScreenDepth(uv0);
float totalWeight = 1.0;
float color = texture2D(sourceTexture, uv0 ).r;
float sum = color * totalWeight;
get type() {
return this._type;
}

for (int x = -filterSize; x <= filterSize; x++) {
for (int y = -filterSize; y < filterSize; y++) {
float weight = 1.0;
vec2 offset = vec2(x,y) * sourceInvResolution;
tap(sum, totalWeight, weight, depth, uv0 + offset);
}
}
set kernelSize(value) {
if (value === this._kernelSize) {
return;
}

float ao = sum / totalWeight;
this._kernelSize = Math.max(1, Math.floor(value));

// simple dithering helps a lot (assumes 8 bits target)
// this is most useful with high quality/large blurs
// ao += ((random(gl_FragCoord.xy) - 0.5) / 255.0);
this.updateShader();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally don't call updateShader directly, just set a _shaderDirty flag - this makes it easy to add more properties that modify the shader, and also better handle the case where the property is changed multi times per frame. (not that this is typical, but happens).


gl_FragColor.r = ao;
}
`
);
this.updateWeightCoefs();
}

var scope = this.device.scope;
this.sourceTextureId = scope.resolve('sourceTexture');
this.sourceInvResolutionId = scope.resolve('sourceInvResolution');
this.sourceInvResolutionValue = new Float32Array(2);
this.filterSizeId = scope.resolve('filterSize');
/**
* @type {number}
*/
get kernelSize() {
return this._kernelSize;
}

execute() {
/**
* Sets the direction of the blur.
* @param {Vec2|Float32Array|Float64Array} value - The direction of the blur.
*/
set direction(value) {
if (value instanceof Vec2) {
this._direction.set([value.x, value.y]);
} else {
this._direction.set(value);
}
}

this.filterSizeId.setValue(4);
this.sourceTextureId.setValue(this.sourceTexture);
/**
* @type {Float32Array}
*/
get direction() {
return this._direction;
}

const { width, height } = this.sourceTexture;
this.sourceInvResolutionValue[0] = 1.0 / width;
this.sourceInvResolutionValue[1] = 1.0 / height;
this.sourceInvResolutionId.setValue(this.sourceInvResolutionValue);
updateShader() {
const shaderName = `BlurShader-kernelsize-${this.kernelSize}`;

super.execute();
const defines = `#define KERNEL_SIZE ${this.kernelSize}\n`;

// CHECK: should we destroy the shader?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shaders are expensive to compile, so we don't destroy them, to make it faster when the shader is needed again

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then I will remove the comment.

// But `(property) RenderPassShaderQuad.shader: null`, so we can't call `destroy` on it
// this.shader.destroy();

this.shader = this.createQuadShader(shaderName, defines + fragmentShader);
}

updateWeightCoefs() {
if (this.kernelSize !== this.kernelValue?.length) {
this.kernelValue = new Float32Array(this.kernelSize);
}

if (this._type === BLUR_GAUSSIAN) {
math.gaussWeights(this.kernelSize, this.kernelValue);
} else {
this.kernelValue.fill(1);
}
}
}

Expand Down
Loading