UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

413 lines (410 loc) 18 kB
import { Debug } from '../../core/debug.js'; import { random } from '../../core/math/random.js'; import { Vec3 } from '../../core/math/vec3.js'; import { TEXTUREPROJECTION_OCTAHEDRAL, TEXTUREPROJECTION_CUBE, FILTER_NEAREST, SHADERLANGUAGE_WGSL, SHADERLANGUAGE_GLSL, SEMANTIC_POSITION } from '../../platform/graphics/constants.js'; import { DebugGraphics } from '../../platform/graphics/debug-graphics.js'; import { DeviceCache } from '../../platform/graphics/device-cache.js'; import { RenderTarget } from '../../platform/graphics/render-target.js'; import { Texture } from '../../platform/graphics/texture.js'; import { ChunkUtils } from '../shader-lib/chunk-utils.js'; import { shaderChunks } from '../shader-lib/chunks/chunks.js'; import { getProgramLibrary } from '../shader-lib/get-program-library.js'; import { createShaderFromCode } from '../shader-lib/utils.js'; import { BlendState } from '../../platform/graphics/blend-state.js'; import { drawQuadWithShader } from './quad-render-utils.js'; import { shaderChunksWGSL } from '../shader-lib/chunks-wgsl/chunks-wgsl.js'; /** * @import { Vec4 } from '../../core/math/vec4.js' */ var getProjectionName = (projection)=>{ switch(projection){ case TEXTUREPROJECTION_CUBE: return 'Cubemap'; case TEXTUREPROJECTION_OCTAHEDRAL: return 'Octahedral'; default: return 'Equirect'; } }; // pack a 32bit floating point value into RGBA8 var packFloat32ToRGBA8 = (value, array, offset)=>{ if (value <= 0) { array[offset + 0] = 0; array[offset + 1] = 0; array[offset + 2] = 0; array[offset + 3] = 0; } else if (value >= 1.0) { array[offset + 0] = 255; array[offset + 1] = 0; array[offset + 2] = 0; array[offset + 3] = 0; } else { var encX = 1 * value % 1; var encY = 255 * value % 1; var encZ = 65025 * value % 1; var encW = 16581375.0 * value % 1; encX -= encY / 255; encY -= encZ / 255; encZ -= encW / 255; array[offset + 0] = Math.min(255, Math.floor(encX * 256)); array[offset + 1] = Math.min(255, Math.floor(encY * 256)); array[offset + 2] = Math.min(255, Math.floor(encZ * 256)); array[offset + 3] = Math.min(255, Math.floor(encW * 256)); } }; // pack samples into texture-ready format var packSamples = (samples)=>{ var numSamples = samples.length; var w = Math.min(numSamples, 512); var h = Math.ceil(numSamples / w); var data = new Uint8Array(w * h * 4); // normalize float data and pack into rgba8 var off = 0; for(var i = 0; i < numSamples; i += 4){ packFloat32ToRGBA8(samples[i + 0] * 0.5 + 0.5, data, off + 0); packFloat32ToRGBA8(samples[i + 1] * 0.5 + 0.5, data, off + 4); packFloat32ToRGBA8(samples[i + 2] * 0.5 + 0.5, data, off + 8); packFloat32ToRGBA8(samples[i + 3] / 8, data, off + 12); off += 16; } return { width: w, height: h, data: data }; }; // generate a vector on the hemisphere with constant distribution. // function kept because it's useful for debugging // vec3 hemisphereSampleUniform(vec2 uv) { // float phi = uv.y * 2.0 * PI; // float cosTheta = 1.0 - uv.x; // float sinTheta = sqrt(1.0 - cosTheta * cosTheta); // return vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); // } // generate a vector on the hemisphere with phong reflection distribution var hemisphereSamplePhong = (dstVec, x, y, specularPower)=>{ var phi = y * 2 * Math.PI; var cosTheta = Math.pow(1 - x, 1 / (specularPower + 1)); var sinTheta = Math.sqrt(1 - cosTheta * cosTheta); dstVec.set(Math.cos(phi) * sinTheta, Math.sin(phi) * sinTheta, cosTheta).normalize(); }; // generate a vector on the hemisphere with lambert distribution var hemisphereSampleLambert = (dstVec, x, y)=>{ var phi = y * 2 * Math.PI; var cosTheta = Math.sqrt(1 - x); var sinTheta = Math.sqrt(x); dstVec.set(Math.cos(phi) * sinTheta, Math.sin(phi) * sinTheta, cosTheta).normalize(); }; // generate a vector on the hemisphere with GGX distribution. // a is linear roughness^2 var hemisphereSampleGGX = (dstVec, x, y, a)=>{ var phi = y * 2 * Math.PI; var cosTheta = Math.sqrt((1 - x) / (1 + (a * a - 1) * x)); var sinTheta = Math.sqrt(1 - cosTheta * cosTheta); dstVec.set(Math.cos(phi) * sinTheta, Math.sin(phi) * sinTheta, cosTheta).normalize(); }; var D_GGX = (NoH, linearRoughness)=>{ var a = NoH * linearRoughness; var k = linearRoughness / (1.0 - NoH * NoH + a * a); return k * k * (1 / Math.PI); }; // generate precomputed samples for phong reflections of the given power var generatePhongSamples = (numSamples, specularPower)=>{ var H = new Vec3(); var result = []; for(var i = 0; i < numSamples; ++i){ hemisphereSamplePhong(H, i / numSamples, random.radicalInverse(i), specularPower); result.push(H.x, H.y, H.z, 0); } return result; }; // generate precomputed samples for lambert convolution var generateLambertSamples = (numSamples, sourceTotalPixels)=>{ var pixelsPerSample = sourceTotalPixels / numSamples; var H = new Vec3(); var result = []; for(var i = 0; i < numSamples; ++i){ hemisphereSampleLambert(H, i / numSamples, random.radicalInverse(i)); var pdf = H.z / Math.PI; var mipLevel = 0.5 * Math.log2(pixelsPerSample / pdf); result.push(H.x, H.y, H.z, mipLevel); } return result; }; // print to the console the required samples table for GGX reflection convolution // console.log(calculateRequiredSamplesGGX()); // this is a table with pre-calculated number of samples required for GGX. // the table is generated by calculateRequiredSamplesGGX() // the table is organized by [numSamples][specularPower] // // we use a repeatable pseudo-random sequence of numbers when generating samples // for use in prefiltering GGX reflections. however not all the random samples // will be valid. this is because some resulting reflection vectors will be below // the hemisphere. this is especially apparent when calculating vectors for the // higher roughnesses. (since vectors are more wild, more of them are invalid). // for example, specularPower 2 results in half the generated vectors being // invalid. (meaning the GPU would spend half the time on vectors that don't // contribute to the final result). // // calculating how many samples are required to generate 'n' valid samples is a // slow operation, so this table stores the pre-calculated numbers of samples // required for the sets of (numSamples, specularPowers) pairs we expect to // encounter at runtime. var requiredSamplesGGX = { '16': { '2': 26, '8': 20, '32': 17, '128': 16, '512': 16 }, '32': { '2': 53, '8': 40, '32': 34, '128': 32, '512': 32 }, '128': { '2': 214, '8': 163, '32': 139, '128': 130, '512': 128 }, '1024': { '2': 1722, '8': 1310, '32': 1114, '128': 1041, '512': 1025 } }; // get the number of random samples required to generate numSamples valid samples. var getRequiredSamplesGGX = (numSamples, specularPower)=>{ var table = requiredSamplesGGX[numSamples]; return table && table[specularPower] || numSamples; }; // generate precomputed GGX samples var generateGGXSamples = (numSamples, specularPower, sourceTotalPixels)=>{ var pixelsPerSample = sourceTotalPixels / numSamples; var roughness = 1 - Math.log2(specularPower) / 11.0; var a = roughness * roughness; var H = new Vec3(); var L = new Vec3(); var N = new Vec3(0, 0, 1); var result = []; var requiredSamples = getRequiredSamplesGGX(numSamples, specularPower); for(var i = 0; i < requiredSamples; ++i){ hemisphereSampleGGX(H, i / requiredSamples, random.radicalInverse(i), a); var NoH = H.z; // since N is (0, 0, 1) L.set(H.x, H.y, H.z).mulScalar(2 * NoH).sub(N); if (L.z > 0) { var pdf = D_GGX(Math.min(1, NoH), a) / 4 + 0.001; var mipLevel = 0.5 * Math.log2(pixelsPerSample / pdf); result.push(L.x, L.y, L.z, mipLevel); } } while(result.length < numSamples * 4){ result.push(0, 0, 0, 0); } return result; }; // pack float samples data into an rgba8 texture var createSamplesTex = (device, name, samples)=>{ var packedSamples = packSamples(samples); return new Texture(device, { name: name, width: packedSamples.width, height: packedSamples.height, mipmaps: false, minFilter: FILTER_NEAREST, magFilter: FILTER_NEAREST, levels: [ packedSamples.data ] }); }; // simple cache storing key->value // missFunc is called if the key is not present class SimpleCache { destroy() { if (this.destroyContent) { this.map.forEach((value, key)=>{ value.destroy(); }); } } get(key, missFunc) { if (!this.map.has(key)) { var result = missFunc(); this.map.set(key, result); return result; } return this.map.get(key); } constructor(destroyContent = true){ this.map = new Map(); this.destroyContent = destroyContent; } } // cache, used to store samples. we store these separately from textures since multiple // devices can use the same set of samples. var samplesCache = new SimpleCache(false); // cache, storing samples stored in textures, those are per device var deviceCache = new DeviceCache(); var getCachedTexture = (device, key, getSamplesFnc)=>{ var cache = deviceCache.get(device, ()=>{ return new SimpleCache(); }); return cache.get(key, ()=>{ return createSamplesTex(device, key, samplesCache.get(key, getSamplesFnc)); }); }; var generateLambertSamplesTex = (device, numSamples, sourceTotalPixels)=>{ var key = "lambert-samples-" + numSamples + "-" + sourceTotalPixels; return getCachedTexture(device, key, ()=>{ return generateLambertSamples(numSamples, sourceTotalPixels); }); }; var generatePhongSamplesTex = (device, numSamples, specularPower)=>{ var key = "phong-samples-" + numSamples + "-" + specularPower; return getCachedTexture(device, key, ()=>{ return generatePhongSamples(numSamples, specularPower); }); }; var generateGGXSamplesTex = (device, numSamples, specularPower, sourceTotalPixels)=>{ var key = "ggx-samples-" + numSamples + "-" + specularPower + "-" + sourceTotalPixels; return getCachedTexture(device, key, ()=>{ return generateGGXSamples(numSamples, specularPower, sourceTotalPixels); }); }; /** * This function reprojects textures between cubemap, equirectangular and octahedral formats. The * function can read and write textures with pixel data in RGBE, RGBM, linear and sRGB formats. * When specularPower is specified it will perform a phong-weighted convolution of the source (for * generating a gloss maps). * * @param {Texture} source - The source texture. * @param {Texture} target - The target texture. * @param {object} [options] - The options object. * @param {number} [options.specularPower] - Optional specular power. When specular power is * specified, the source is convolved by a phong-weighted kernel raised to the specified power. * Otherwise the function performs a standard resample. * @param {number} [options.numSamples] - Optional number of samples (default is 1024). * @param {number} [options.face] - Optional cubemap face to update (default is update all faces). * @param {string} [options.distribution] - Specify convolution distribution - 'none', 'lambert', * 'phong', 'ggx'. Default depends on specularPower. * @param {Vec4} [options.rect] - Optional viewport rectangle. * @param {number} [options.seamPixels] - Optional number of seam pixels to render * @returns {boolean} True if the reprojection was applied and false otherwise (e.g. if rect is empty) * @category Graphics */ function reprojectTexture(source, target, options) { if (options === void 0) options = {}; var _options_rect, _options_rect1; Debug.assert(source instanceof Texture && target instanceof Texture, 'source and target must be textures'); var _options_seamPixels; // calculate inner width and height var seamPixels = (_options_seamPixels = options.seamPixels) != null ? _options_seamPixels : 0; var _options_rect_z; var innerWidth = ((_options_rect_z = (_options_rect = options.rect) == null ? void 0 : _options_rect.z) != null ? _options_rect_z : target.width) - seamPixels * 2; var _options_rect_w; var innerHeight = ((_options_rect_w = (_options_rect1 = options.rect) == null ? void 0 : _options_rect1.w) != null ? _options_rect_w : target.height) - seamPixels * 2; if (innerWidth < 1 || innerHeight < 1) { // early out if inner space is empty return false; } // table of distribution -> function name var funcNames = { 'none': 'reproject', 'lambert': 'prefilterSamplesUnweighted', 'phong': 'prefilterSamplesUnweighted', 'ggx': 'prefilterSamples' }; // extract options var specularPower = options.hasOwnProperty('specularPower') ? options.specularPower : 1; var face = options.hasOwnProperty('face') ? options.face : null; var distribution = options.hasOwnProperty('distribution') ? options.distribution : specularPower === 1 ? 'none' : 'phong'; var processFunc = funcNames[distribution] || 'reproject'; var prefilterSamples = processFunc.startsWith('prefilterSamples'); var decodeFunc = ChunkUtils.decodeFunc(source.encoding); var encodeFunc = ChunkUtils.encodeFunc(target.encoding); var sourceFunc = "sample" + getProjectionName(source.projection); var targetFunc = "getDirection" + getProjectionName(target.projection); var numSamples = options.hasOwnProperty('numSamples') ? options.numSamples : 1024; // generate unique shader key var shaderKey = processFunc + "_" + decodeFunc + "_" + encodeFunc + "_" + sourceFunc + "_" + targetFunc + "_" + numSamples; var device = source.device; var shader = getProgramLibrary(device).getCachedShader(shaderKey); if (!shader) { var defines = "\n " + (prefilterSamples ? '#define USE_SAMPLES_TEX' : '') + "\n " + (source.cubemap ? '#define CUBEMAP_SOURCE' : '') + "\n #define {PROCESS_FUNC} " + processFunc + "\n #define {DECODE_FUNC} " + decodeFunc + "\n #define {ENCODE_FUNC} " + encodeFunc + "\n #define {SOURCE_FUNC} " + sourceFunc + "\n #define {TARGET_FUNC} " + targetFunc + "\n #define {NUM_SAMPLES} " + numSamples + "\n #define {NUM_SAMPLES_SQRT} " + Math.round(Math.sqrt(numSamples)).toFixed(1) + "\n "; var wgsl = device.isWebGPU; var chunks = wgsl ? shaderChunksWGSL : shaderChunks; var includes = new Map(); includes.set('decodePS', chunks.decodePS); includes.set('encodePS', chunks.encodePS); var vert = chunks.reprojectVS; var frag = chunks.reprojectPS; shader = createShaderFromCode(device, vert, "\n " + defines + "\n " + frag + "\n ", shaderKey, { vertex_position: SEMANTIC_POSITION }, { fragmentIncludes: includes, shaderLanguage: wgsl ? SHADERLANGUAGE_WGSL : SHADERLANGUAGE_GLSL }); } DebugGraphics.pushGpuMarker(device, 'ReprojectTexture'); // render state // TODO: set up other render state here to expected state device.setBlendState(BlendState.NOBLEND); var constantSource = device.scope.resolve(source.cubemap ? 'sourceCube' : 'sourceTex'); Debug.assert(constantSource); constantSource.setValue(source); var constantParams = device.scope.resolve('params'); var uvModParam = device.scope.resolve('uvMod'); if (seamPixels > 0) { uvModParam.setValue([ (innerWidth + seamPixels * 2) / innerWidth, (innerHeight + seamPixels * 2) / innerHeight, -seamPixels / innerWidth, -seamPixels / innerHeight ]); } else { uvModParam.setValue([ 1, 1, 0, 0 ]); } var params = [ 0, target.width * target.height * (target.cubemap ? 6 : 1), source.width * source.height * (source.cubemap ? 6 : 1) ]; if (prefilterSamples) { // set or generate the pre-calculated samples data var sourceTotalPixels = source.width * source.height * (source.cubemap ? 6 : 1); var samplesTex = distribution === 'ggx' ? generateGGXSamplesTex(device, numSamples, specularPower, sourceTotalPixels) : distribution === 'lambert' ? generateLambertSamplesTex(device, numSamples, sourceTotalPixels) : generatePhongSamplesTex(device, numSamples, specularPower); device.scope.resolve('samplesTex').setValue(samplesTex); device.scope.resolve('samplesTexInverseSize').setValue([ 1.0 / samplesTex.width, 1.0 / samplesTex.height ]); } for(var f = 0; f < (target.cubemap ? 6 : 1); f++){ if (face === null || f === face) { var renderTarget = new RenderTarget({ colorBuffer: target, face: f, depth: false, flipY: device.isWebGPU }); params[0] = f; constantParams.setValue(params); drawQuadWithShader(device, renderTarget, shader, options == null ? void 0 : options.rect); renderTarget.destroy(); } } DebugGraphics.popGpuMarker(device); return true; } export { reprojectTexture };