playcanvas
Version:
PlayCanvas WebGL game engine
413 lines (410 loc) • 18 kB
JavaScript
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 };