playcanvas
Version:
PlayCanvas WebGL game engine
532 lines (487 loc) • 20.4 kB
JavaScript
// The implementation is based on the code in Filament Engine: https://github.com/google/filament
// specifically, shaders here: https://github.com/google/filament/tree/24b88219fa6148b8004f230b377f163e6f184d65/filament/src/materials/ssao
// --------------- POST EFFECT DEFINITION --------------- //
/**
* @class
* @name SSAOEffect
* @classdesc Implements the SSAOEffect post processing effect.
* @description Creates new instance of the post effect.
* @augments PostEffect
* @param {GraphicsDevice} graphicsDevice - The graphics device of the application.
* @param {any} ssaoScript - The script using the effect.
*/
function SSAOEffect(graphicsDevice, ssaoScript) {
pc.PostEffect.call(this, graphicsDevice);
this.ssaoScript = ssaoScript;
this.needsDepthBuffer = true;
var fSsao = [
pc.ShaderChunks.get(graphicsDevice, pc.SHADERLANGUAGE_GLSL).get('screenDepthPS'),
'',
'varying vec2 vUv0;',
'',
'//uniform sampler2D uColorBuffer;',
'uniform vec4 uResolution;',
'',
'uniform float uAspect;',
'',
'#define saturate(x) clamp(x,0.0,1.0)',
'',
'// Largely based on \'Dominant Light Shadowing\'',
'// \'Lighting Technology of The Last of Us Part II\' by Hawar Doghramachi, Naughty Dog, LLC',
'',
'const float kSSCTLog2LodRate = 3.0;',
'',
'highp float getWFromProjectionMatrix(const mat4 p, const vec3 v) {',
' // this essentially returns (p * vec4(v, 1.0)).w, but we make some assumptions',
' // this assumes a perspective projection',
' return -v.z;',
' // this assumes a perspective or ortho projection',
' // return p[2][3] * v.z + p[3][3];',
'}',
'',
'highp float getViewSpaceZFromW(const mat4 p, const float w) {',
' // this assumes a perspective projection',
' return -w;',
' // this assumes a perspective or ortho projection',
' // return (w - p[3][3]) / p[2][3];',
'}',
'',
'',
'const float kLog2LodRate = 3.0;',
'',
'vec2 sq(const vec2 a) {',
' return a * a;',
'}',
'',
'uniform float uInvFarPlane;',
'',
'vec2 pack(highp float depth) {',
'// we need 16-bits of precision',
' highp float z = clamp(depth * uInvFarPlane, 0.0, 1.0);',
' highp float t = floor(256.0 * z);',
' mediump float hi = t * (1.0 / 256.0); // we only need 8-bits of precision',
' mediump float lo = (256.0 * z) - t; // we only need 8-bits of precision',
' return vec2(hi, lo);',
'}',
'',
'// random number between 0 and 1, using interleaved gradient noise',
'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)));',
'}',
'',
'// returns the frag coord in the GL convention with (0, 0) at the bottom-left',
'highp vec2 getFragCoord() {',
' return gl_FragCoord.xy;',
'}',
'',
'highp vec3 computeViewSpacePositionFromDepth(highp vec2 uv, highp float linearDepth) {',
' return vec3((0.5 - uv) * vec2(uAspect, 1.0) * linearDepth, linearDepth);',
'}',
'',
'highp vec3 faceNormal(highp vec3 dpdx, highp vec3 dpdy) {',
' return normalize(cross(dpdx, dpdy));',
'}',
'',
'// Compute normals using derivatives, which essentially results in half-resolution normals',
'// this creates arifacts around geometry edges.',
'// Note: when using the spirv optimizer, this results in much slower execution time because',
'// this whole expression is inlined in the AO loop below.',
'highp vec3 computeViewSpaceNormal(const highp vec3 position) {',
' return faceNormal(dFdx(position), dFdy(position));',
'}',
'',
'// Compute normals directly from the depth texture, resulting in full resolution normals',
'// Note: This is actually as cheap as using derivatives because the texture fetches',
'// are essentially equivalent to textureGather (which we don\'t have on ES3.0),',
'// and this is executed just once.',
'highp vec3 computeViewSpaceNormal(const highp vec3 position, const highp vec2 uv) {',
' highp vec2 uvdx = uv + vec2(uResolution.z, 0.0);',
' highp vec2 uvdy = uv + vec2(0.0, uResolution.w);',
' highp vec3 px = computeViewSpacePositionFromDepth(uvdx, -getLinearScreenDepth(uvdx));',
' highp vec3 py = computeViewSpacePositionFromDepth(uvdy, -getLinearScreenDepth(uvdy));',
' highp vec3 dpdx = px - position;',
' highp vec3 dpdy = py - position;',
' return faceNormal(dpdx, dpdy);',
'}',
'',
'// Ambient Occlusion, largely inspired from:',
'// \'The Alchemy Screen-Space Ambient Obscurance Algorithm\' by Morgan McGuire',
'// \'Scalable Ambient Obscurance\' by Morgan McGuire, Michael Mara and David Luebke',
'',
'uniform vec2 uSampleCount;',
'uniform float uSpiralTurns;',
'',
'#define PI (3.14159)',
'',
'vec3 tapLocation(float i, const float noise) {',
' float offset = ((2.0 * PI) * 2.4) * noise;',
' float angle = ((i * uSampleCount.y) * uSpiralTurns) * (2.0 * PI) + offset;',
' float radius = (i + noise + 0.5) * uSampleCount.y;',
' return vec3(cos(angle), sin(angle), radius * radius);',
'}',
'',
'highp vec2 startPosition(const float noise) {',
' float angle = ((2.0 * PI) * 2.4) * noise;',
' return vec2(cos(angle), sin(angle));',
'}',
'',
'uniform vec2 uAngleIncCosSin;',
'',
'highp mat2 tapAngleStep() {',
' highp vec2 t = uAngleIncCosSin;',
' return mat2(t.x, t.y, -t.y, t.x);',
'}',
'',
'vec3 tapLocationFast(float i, vec2 p, const float noise) {',
' float radius = (i + noise + 0.5) * uSampleCount.y;',
' return vec3(p, radius * radius);',
'}',
'',
'uniform float uMaxLevel;',
'uniform float uInvRadiusSquared;',
'uniform float uMinHorizonAngleSineSquared;',
'uniform float uBias;',
'uniform float uPeak2;',
'',
'void computeAmbientOcclusionSAO(inout float occlusion, float i, float ssDiskRadius,',
' const highp vec2 uv, const highp vec3 origin, const vec3 normal,',
' const vec2 tapPosition, const float noise) {',
'',
' vec3 tap = tapLocationFast(i, tapPosition, noise);',
'',
' float ssRadius = max(1.0, tap.z * ssDiskRadius);', // at least 1 pixel screen-space radius
'',
' vec2 uvSamplePos = uv + vec2(ssRadius * tap.xy) * uResolution.zw;',
'',
' float level = clamp(floor(log2(ssRadius)) - kLog2LodRate, 0.0, float(uMaxLevel));',
' highp float occlusionDepth = -getLinearScreenDepth(uvSamplePos);',
' highp vec3 p = computeViewSpacePositionFromDepth(uvSamplePos, occlusionDepth);',
'',
' // now we have the sample, compute AO',
' vec3 v = p - origin; // sample vector',
' float vv = dot(v, v); // squared distance',
' float vn = dot(v, normal); // distance * cos(v, normal)',
'',
' // discard samples that are outside of the radius, preventing distant geometry to',
' // cast shadows -- there are many functions that work and choosing one is an artistic',
' // decision.',
' float w = max(0.0, 1.0 - vv * uInvRadiusSquared);',
' w = w*w;',
'',
' // discard samples that are too close to the horizon to reduce shadows cast by geometry',
' // not sufficiently tessellated. The goal is to discard samples that form an angle \'beta\'',
' // smaller than \'epsilon\' with the horizon. We already have dot(v,n) which is equal to the',
' // sin(beta) * |v|. So the test simplifies to vn^2 < vv * sin(epsilon)^2.',
' w *= step(vv * uMinHorizonAngleSineSquared, vn * vn);',
'',
' occlusion += w * max(0.0, vn + origin.z * uBias) / (vv + uPeak2);',
'}',
'',
'uniform float uProjectionScaleRadius;',
'uniform float uIntensity;',
'',
'float scalableAmbientObscurance(highp vec2 uv, highp vec3 origin, vec3 normal) {',
' float noise = random(getFragCoord());',
' highp vec2 tapPosition = startPosition(noise);',
' highp mat2 angleStep = tapAngleStep();',
'',
' // Choose the screen-space sample radius',
' // proportional to the projected area of the sphere',
' float ssDiskRadius = -(uProjectionScaleRadius / origin.z);',
'',
' float occlusion = 0.0;',
' for (float i = 0.0; i < uSampleCount.x; i += 1.0) {',
' computeAmbientOcclusionSAO(occlusion, i, ssDiskRadius, uv, origin, normal, tapPosition, noise);',
' tapPosition = angleStep * tapPosition;',
' }',
' return sqrt(occlusion * uIntensity);',
'}',
'',
'uniform float uPower;',
'',
'void main() {',
' highp vec2 uv = vUv0; //variable_vertex.xy; // interpolated to pixel center',
'',
' highp float depth = -getLinearScreenDepth(vUv0);',
' highp vec3 origin = computeViewSpacePositionFromDepth(uv, depth);',
' vec3 normal = computeViewSpaceNormal(origin, uv);',
'',
' float occlusion = 0.0;',
'',
' if (uIntensity > 0.0) {',
' occlusion = scalableAmbientObscurance(uv, origin, normal);',
' }',
'',
' // occlusion to visibility',
' float aoVisibility = pow(saturate(1.0 - occlusion), uPower);',
'',
' vec4 inCol = vec4(1.0, 1.0, 1.0, 1.0); //texture2D( uColorBuffer, uv );',
'',
' gl_FragColor.r = aoVisibility; //postProcess.color.rgb = vec3(aoVisibility, pack(origin.z));',
'}',
'',
'void main_old()',
'{',
' vec2 aspectCorrect = vec2( 1.0, uAspect );',
'',
' float depth = getLinearScreenDepth(vUv0);',
' gl_FragColor.r = fract(floor(depth*256.0*256.0)),fract(floor(depth*256.0)),fract(depth);',
'}'
].join('\n');
var fblur = [
pc.ShaderChunks.get(graphicsDevice, pc.SHADERLANGUAGE_GLSL).get('screenDepthPS'),
'',
'varying vec2 vUv0;',
'',
'uniform sampler2D uSSAOBuffer;',
'uniform vec4 uResolution;',
'',
'uniform float uAspect;',
'',
'uniform int uBilatSampleCount;',
'uniform float uFarPlaneOverEdgeDistance;',
'uniform float uBrightness;',
'',
'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) * uFarPlaneOverEdgeDistance;',
' return max(0.0, 1.0 - diff * diff);',
'}',
'',
'void tap(inout float sum, inout float totalWeight, float weight, float depth, vec2 position) {',
' // ambient occlusion sample',
' float ssao = texture2D( uSSAOBuffer, position ).r;',
' float tdepth = -getLinearScreenDepth( position );',
'',
' // bilateral sample',
' float bilateral = bilateralWeight(depth, tdepth);',
' bilateral *= weight;',
' sum += ssao * bilateral;',
' totalWeight += bilateral;',
'}',
'',
'void main() {',
' highp vec2 uv = vUv0; // variable_vertex.xy; // interpolated at pixel\'s center',
'',
' // we handle the center pixel separately because it doesn\'t participate in bilateral filtering',
' float depth = -getLinearScreenDepth(vUv0); // unpack(data.gb);',
' float totalWeight = 0.0; // float(uBilatSampleCount*2+1)*float(uBilatSampleCount*2+1);',
' float ssao = texture2D( uSSAOBuffer, vUv0 ).r;',
' float sum = ssao * totalWeight;',
'',
' for (int x = -uBilatSampleCount; x <= uBilatSampleCount; x++) {',
' for (int y = -uBilatSampleCount; y < uBilatSampleCount; y++) {',
' float weight = 1.0;',
' vec2 offset = vec2(x,y)*uResolution.zw;',
' tap(sum, totalWeight, weight, depth, uv + offset);',
' }',
' }',
'',
' float ao = sum / totalWeight;',
'',
' // 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);',
'',
' ao = mix(ao, 1.0, uBrightness);',
' gl_FragColor.a = ao;',
'}'
].join('\n');
var foutput = [
'varying vec2 vUv0;',
'uniform sampler2D uColorBuffer;',
'uniform sampler2D uSSAOBuffer;',
'',
'void main(void)',
'{',
' vec4 inCol = texture2D( uColorBuffer, vUv0 );',
' float ssao = texture2D( uSSAOBuffer, vUv0 ).a;',
' gl_FragColor.rgb = inCol.rgb * ssao;',
' gl_FragColor.a = inCol.a;',
'}'
].join('\n');
var attributes = {
aPosition: pc.SEMANTIC_POSITION
};
this.ssaoShader = pc.ShaderUtils.createShader(graphicsDevice, {
uniqueName: 'SsaoShader',
attributes: attributes,
vertexGLSL: pc.PostEffect.quadVertexShader,
fragmentGLSL: fSsao
});
this.blurShader = pc.ShaderUtils.createShader(graphicsDevice, {
uniqueName: 'SsaoBlurShader',
attributes: attributes,
vertexGLSL: pc.PostEffect.quadVertexShader,
fragmentGLSL: fblur
});
this.outputShader = pc.ShaderUtils.createShader(graphicsDevice, {
uniqueName: 'SsaoOutputShader',
attributes: attributes,
vertexGLSL: pc.PostEffect.quadVertexShader,
fragmentGLSL: foutput
});
// Uniforms
this.radius = 4;
this.brightness = 0;
this.samples = 20;
this.downscale = 1.0;
}
SSAOEffect.prototype = Object.create(pc.PostEffect.prototype);
SSAOEffect.prototype.constructor = SSAOEffect;
SSAOEffect.prototype._destroy = function () {
if (this.target) {
this.target.destroyTextureBuffers();
this.target.destroy();
this.target = null;
}
if (this.blurTarget) {
this.blurTarget.destroyTextureBuffers();
this.blurTarget.destroy();
this.blurTarget = null;
}
};
SSAOEffect.prototype._resize = function (target) {
var width = Math.ceil(target.colorBuffer.width / this.downscale);
var height = Math.ceil(target.colorBuffer.height / this.downscale);
// If no change, skip resize
if (width === this.width && height === this.height) {
return;
}
// Render targets
this.width = width;
this.height = height;
this._destroy();
var ssaoResultBuffer = new pc.Texture(this.device, {
format: pc.PIXELFORMAT_RGBA8,
minFilter: pc.FILTER_LINEAR,
magFilter: pc.FILTER_LINEAR,
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
width: this.width,
height: this.height,
mipmaps: false
});
ssaoResultBuffer.name = 'SSAO Result';
this.target = new pc.RenderTarget({
name: 'SSAO Result Render Target',
colorBuffer: ssaoResultBuffer,
depth: false
});
var ssaoBlurBuffer = new pc.Texture(this.device, {
format: pc.PIXELFORMAT_RGBA8,
minFilter: pc.FILTER_LINEAR,
magFilter: pc.FILTER_LINEAR,
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
width: this.width,
height: this.height,
mipmaps: false
});
ssaoBlurBuffer.name = 'SSAO Blur';
this.blurTarget = new pc.RenderTarget({
name: 'SSAO Blur Render Target',
colorBuffer: ssaoBlurBuffer,
depth: false
});
};
Object.assign(SSAOEffect.prototype, {
render: function (inputTarget, outputTarget, rect) {
this._resize(inputTarget);
var device = this.device;
var scope = device.scope;
var sampleCount = this.samples;
var spiralTurns = 10.0;
var step = (1.0 / (sampleCount - 0.5)) * spiralTurns * 2.0 * 3.141;
var radius = this.radius;
var bias = 0.001;
var peak = 0.1 * radius;
var intensity = (peak * 2.0 * 3.141) * 0.125;
var projectionScale = 0.5 * device.height;
var cameraFarClip = this.ssaoScript.entity.camera.farClip;
scope.resolve('uAspect').setValue(this.width / this.height);
scope.resolve('uResolution').setValue([this.width, this.height, 1.0 / this.width, 1.0 / this.height]);
scope.resolve('uBrightness').setValue(this.brightness);
scope.resolve('uInvFarPlane').setValue(1.0 / cameraFarClip);
scope.resolve('uSampleCount').setValue([sampleCount, 1.0 / sampleCount]);
scope.resolve('uSpiralTurns').setValue(spiralTurns);
scope.resolve('uAngleIncCosSin').setValue([Math.cos(step), Math.sin(step)]);
scope.resolve('uMaxLevel').setValue(0.0);
scope.resolve('uInvRadiusSquared').setValue(1.0 / (radius * radius));
scope.resolve('uMinHorizonAngleSineSquared').setValue(0.0);
scope.resolve('uBias').setValue(bias);
scope.resolve('uPeak2').setValue(peak * peak);
scope.resolve('uIntensity').setValue(intensity);
scope.resolve('uPower').setValue(1.0);
scope.resolve('uProjectionScaleRadius').setValue(projectionScale * radius);
// Render SSAO
this.drawQuad(this.target, this.ssaoShader, rect);
scope.resolve('uSSAOBuffer').setValue(this.target.colorBuffer);
scope.resolve('uFarPlaneOverEdgeDistance').setValue(1);
scope.resolve('uBilatSampleCount').setValue(4);
// Perform the blur
this.drawQuad(this.blurTarget, this.blurShader, rect);
// Finally output to screen
scope.resolve('uSSAOBuffer').setValue(this.blurTarget.colorBuffer);
scope.resolve('uColorBuffer').setValue(inputTarget.colorBuffer);
this.drawQuad(outputTarget, this.outputShader, rect);
}
});
// ----------------- SCRIPT DEFINITION ------------------ //
var SSAO = pc.createScript('ssao');
SSAO.attributes.add('radius', {
type: 'number',
default: 4,
min: 0,
max: 20,
title: 'Radius'
});
SSAO.attributes.add('brightness', {
type: 'number',
default: 0,
min: 0,
max: 1,
title: 'Brightness'
});
SSAO.attributes.add('samples', {
type: 'number',
default: 16,
min: 1,
max: 256,
title: 'Samples'
});
SSAO.attributes.add('downscale', {
type: 'number',
default: 1,
min: 1,
max: 4,
title: 'Downscale'
});
SSAO.prototype.initialize = function () {
this.effect = new SSAOEffect(this.app.graphicsDevice, this);
this.effect.radius = this.radius;
this.effect.brightness = this.brightness;
this.effect.samples = this.samples;
this.effect.downscale = this.downscale;
this.on('attr', function (name, value) {
this.effect[name] = value;
}, this);
var queue = this.entity.camera.postEffects;
queue.addEffect(this.effect);
this.on('state', function (enabled) {
if (enabled) {
queue.addEffect(this.effect);
} else {
queue.removeEffect(this.effect);
}
});
this.on('destroy', function () {
queue.removeEffect(this.effect);
this.effect._destroy();
});
};