From 94d4fb99abafc715811e7f5641455e41b1023ff6 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Sep 2019 15:01:09 -0700 Subject: [PATCH] Blue noise (#7) * replace stratified sampling with halton sampling * experiment with random texture * remove stratified sampling files * remove texture experiments * divide instead of multiply to fix precision bug * combine stratified and halton sampling * blue noise (wip) * use white noise for preview * white noise from xorshift instead of texture * optimize setStrataCount * comments and cleanup * include noise texture * remove unneeded line * remove halton sequence code * remove unnecessary shuffle * remove stratifiedRandom array property * rename to stratifiedSampler * rename sceneSampler; add comments --- src/RayTracingRenderer.js | 36 +-- src/renderer/glsl/chunks/random.glsl | 75 +++--- .../glsl/chunks/sampleGlassMicrofacet.glsl | 12 +- .../glsl/chunks/sampleGlassSpecular.glsl | 8 +- src/renderer/glsl/chunks/sampleMaterial.glsl | 12 +- .../glsl/chunks/sampleShadowCatcher.glsl | 12 +- src/renderer/glsl/rayTrace.frag | 12 +- src/renderer/rayTracingShader.js | 229 ++++++++++-------- .../{sceneSampler.js => renderingPipeline.js} | 66 +++-- src/renderer/stratifiedRandom.js | 47 ---- src/renderer/stratifiedRandomCombined.js | 43 ---- src/renderer/stratifiedSampler.js | 58 +++++ src/renderer/stratifiedSamplerCombined.js | 47 ++++ src/renderer/texture.js | 2 +- src/renderer/texture/HDR_L_0.png | Bin 0 -> 7147 bytes src/renderer/texture/noise.js | 1 + src/renderer/texture/readme.txt | 5 + 17 files changed, 365 insertions(+), 300 deletions(-) rename src/renderer/{sceneSampler.js => renderingPipeline.js} (80%) delete mode 100644 src/renderer/stratifiedRandom.js delete mode 100644 src/renderer/stratifiedRandomCombined.js create mode 100644 src/renderer/stratifiedSampler.js create mode 100644 src/renderer/stratifiedSamplerCombined.js create mode 100644 src/renderer/texture/HDR_L_0.png create mode 100644 src/renderer/texture/noise.js create mode 100644 src/renderer/texture/readme.txt diff --git a/src/RayTracingRenderer.js b/src/RayTracingRenderer.js index 769b351..05e1f9f 100644 --- a/src/RayTracingRenderer.js +++ b/src/RayTracingRenderer.js @@ -1,5 +1,5 @@ import { loadExtensions } from './renderer/glUtil'; -import { makeSceneSampler } from './renderer/sceneSampler'; +import { makeRenderingPipeline } from './renderer/renderingPipeline'; import * as THREE from 'three'; const glRequiredExtensions = [ @@ -25,7 +25,7 @@ function RayTracingRenderer(params = {}) { const optionalExtensions = loadExtensions(gl, glOptionalExtensions); // private properties - let sceneSampler = null; + let pipeline = null; const size = new THREE.Vector2(); let renderTime = 22; let pixelRatio = 1; @@ -54,9 +54,9 @@ function RayTracingRenderer(params = {}) { const bounces = module.bounces; - sceneSampler = makeSceneSampler({gl, optionalExtensions, scene, toneMappingParams, bounces}); + pipeline = makeRenderingPipeline({gl, optionalExtensions, scene, toneMappingParams, bounces}); - sceneSampler.onSampleRendered = (...args) => { + pipeline.onSampleRendered = (...args) => { if (module.onSampleRendered) { module.onSampleRendered(...args); } @@ -68,8 +68,8 @@ function RayTracingRenderer(params = {}) { } function restartTimer() { - if (sceneSampler) { - sceneSampler.restartTimer(); + if (pipeline) { + pipeline.restartTimer(); } } @@ -83,8 +83,8 @@ function RayTracingRenderer(params = {}) { canvas.style.height = `${ size.height }px`; } - if (sceneSampler) { - sceneSampler.setSize(size.width * pixelRatio, size.height * pixelRatio); + if (pipeline) { + pipeline.setSize(size.width * pixelRatio, size.height * pixelRatio); } }; @@ -108,8 +108,8 @@ function RayTracingRenderer(params = {}) { module.setRenderTime = (time) => { renderTime = time; - if (sceneSampler) { - sceneSampler.setRenderTime(time); + if (pipeline) { + pipeline.setRenderTime(time); } }; @@ -118,14 +118,14 @@ function RayTracingRenderer(params = {}) { }; module.getTotalSamplesRendered = () => { - if (sceneSampler) { - return sceneSampler.getTotalSamplesRendered(); + if (pipeline) { + return pipeline.getTotalSamplesRendered(); } }; module.sendToScreen = () => { - if (sceneSampler) { - sceneSampler.hdrBufferToScreen(); + if (pipeline) { + pipeline.hdrBufferToScreen(); } }; @@ -151,14 +151,14 @@ function RayTracingRenderer(params = {}) { if (module.renderToScreen) { if(module.maxHardwareUsage) { // render new sample for the entire screen - sceneSampler.drawFull(camera); + pipeline.drawFull(camera); } else { // render new sample for a tiled subset of the screen - sceneSampler.drawTile(camera); + pipeline.drawTile(camera); } } else { - sceneSampler.drawOffscreenTile(camera); + pipeline.drawOffscreenTile(camera); } }; @@ -170,7 +170,7 @@ function RayTracingRenderer(params = {}) { module.dispose = () => { document.removeEventListener('visibilitychange', restartTimer); - sceneSampler = false; + pipeline = false; }; return module; diff --git a/src/renderer/glsl/chunks/random.glsl b/src/renderer/glsl/chunks/random.glsl index 486b972..ba47c6f 100644 --- a/src/renderer/glsl/chunks/random.glsl +++ b/src/renderer/glsl/chunks/random.glsl @@ -1,20 +1,26 @@ -// Random number generation as described by -// http://www.reedbeta.com/blog/quick-and-easy-gpu-random-numbers-in-d3d11/ - export default function(params) { return ` -// higher quality but slower hashing function -uint wangHash(uint x) { - x = (x ^ 61u) ^ (x >> 16u); - x *= 9u; - x = x ^ (x >> 4u); - x *= 0x27d4eb2du; - x = x ^ (x >> 15u); - return x; -} +// Noise texture used to generate a different random number for each pixel. +// We use blue noise in particular, but any type of noise will work. +uniform sampler2D noise; + +uniform float stratifiedSamples[SAMPLING_DIMENSIONS]; +uniform float strataSize; +uniform float useStratifiedSampling; + +// Every time we call randomSample() in the shader, and for every call to render, +// we want that specific bit of the shader to fetch a sample from the same position in stratifiedSamples +// This allows us to use stratified sampling for each random variable in our path tracing +int sampleIndex = 0; + +const highp float maxUint = 1.0 / 4294967295.0; + +float pixelSeed; +highp uint randState; -// lower quality but faster hashing function +// simple integer hashing function +// https://en.wikipedia.org/wiki/Xorshift uint xorshift(uint x) { x ^= x << 13u; x ^= x >> 17u; @@ -22,41 +28,34 @@ uint xorshift(uint x) { return x; } -uniform float seed; // Random number [0, 1) -uniform float strataStart[STRATA_DIMENSIONS]; -uniform float strataSize; +void initRandom() { + vec2 noiseSize = vec2(textureSize(noise, 0)); -const highp float maxUint = 1.0 / 4294967295.0; -highp uint randState; -int strataDimension; + // tile the small noise texture across the entire screen + pixelSeed = texture(noise, vCoord / (pixelSize * noiseSize)).r; -// init state with high quality hashing function to avoid patterns across the 2d image -void initRandom() { - randState = wangHash(floatBitsToUint(seed)); - randState *= wangHash(floatBitsToUint(vCoord.x)); - randState *= wangHash(floatBitsToUint(vCoord.y)); - randState = wangHash(randState); - strataDimension = 0; + // white noise used if stratified sampling is disabled + // produces more balanced path tracing for 1 sample-per-pixel renders + randState = xorshift(xorshift(floatBitsToUint(vCoord.x)) * xorshift(floatBitsToUint(vCoord.y))); } -float random() { +float randomSample() { randState = xorshift(randState); - float f = float(randState) * maxUint; - // transform random number between [0, 1] to (0, 1) - return EPS + (1.0 - 2.0 * EPS) * f; -} + float stratifiedSample = stratifiedSamples[sampleIndex++]; -vec2 randomVec2() { - return vec2(random(), random()); -} + float random = mix( + float(randState) * maxUint, // white noise + fract((stratifiedSample + pixelSeed) * strataSize), // blue noise + stratified samples + useStratifiedSampling + ); -float randomStrata() { - return strataStart[strataDimension++] + strataSize * random(); + // transform random number between [0, 1] to (0, 1) + return EPS + (1.0 - 2.0 * EPS) * random; } -vec2 randomStrataVec2() { - return vec2(randomStrata(), randomStrata()); +vec2 randomSampleVec2() { + return vec2(randomSample(), randomSample()); } `; }; diff --git a/src/renderer/glsl/chunks/sampleGlassMicrofacet.glsl b/src/renderer/glsl/chunks/sampleGlassMicrofacet.glsl index 70c0e8b..31e0e6d 100644 --- a/src/renderer/glsl/chunks/sampleGlassMicrofacet.glsl +++ b/src/renderer/glsl/chunks/sampleGlassMicrofacet.glsl @@ -150,18 +150,18 @@ vec3 sampleGlassMicrofacet(SurfaceInteraction si, int bounce, inout Ray ray, ino float F = fresnelSchlickTIR(cosThetaV, R0, IOR); // thick glass - vec2 reflectionOrRefraction = randomStrataVec2(); + vec2 reflectionOrRefraction = randomSampleVec2(); vec3 lightDir; bool lightRefract; float pdf; if (reflectionOrRefraction.x < F) { - lightDir = lightDirSpecular(si.normal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDir = lightDirSpecular(si.normal, viewDir, basis, si.roughness, randomSampleVec2()); lightRefract = false; pdf = F; } else { - lightDir = lightDirRefraction(si.normal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDir = lightDirRefraction(si.normal, viewDir, basis, si.roughness, randomSampleVec2()); lightRefract = true; pdf = 1.0 - F; } @@ -169,7 +169,7 @@ vec3 sampleGlassMicrofacet(SurfaceInteraction si, int bounce, inout Ray ray, ino bool lastBounce = bounce == BOUNCES; vec3 li = beta * ( - glassImportanceSampleLight(si, viewDir, lightRefract, lastBounce, randomStrataVec2()) + + glassImportanceSampleLight(si, viewDir, lightRefract, lastBounce, randomSampleVec2()) + glassImportanceSampleMaterial(si, viewDir, lightRefract, lastBounce, lightDir) ); @@ -180,13 +180,13 @@ vec3 sampleGlassMicrofacet(SurfaceInteraction si, int bounce, inout Ray ray, ino vec3 brdf; if (reflectionOrRefraction.y < F) { - lightDir = lightDirSpecular(si.normal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDir = lightDirSpecular(si.normal, viewDir, basis, si.roughness, randomSampleVec2()); cosThetaL = dot(si.normal, lightDir); brdf = glassReflection(si, viewDir, lightDir, cosThetaL, scatteringPdf); scatteringPdf *= F; lightRefract = false; } else { - lightDir = lightDirRefraction(si.normal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDir = lightDirRefraction(si.normal, viewDir, basis, si.roughness, randomSampleVec2()); cosThetaL = dot(si.normal, lightDir); brdf = glassRefraction(si, viewDir, lightDir, cosThetaL, scatteringPdf); scatteringPdf *= 1.0 - F; diff --git a/src/renderer/glsl/chunks/sampleGlassSpecular.glsl b/src/renderer/glsl/chunks/sampleGlassSpecular.glsl index 34c4cdc..6e841ab 100644 --- a/src/renderer/glsl/chunks/sampleGlassSpecular.glsl +++ b/src/renderer/glsl/chunks/sampleGlassSpecular.glsl @@ -13,7 +13,7 @@ vec3 sampleGlassSpecular(SurfaceInteraction si, int bounce, inout Ray ray, inout vec3 lightDir; - float reflectionOrRefraction = randomStrata(); + float reflectionOrRefraction = randomSample(); if (reflectionOrRefraction < F) { lightDir = reflect(-viewDir, si.normal); @@ -26,9 +26,9 @@ vec3 sampleGlassSpecular(SurfaceInteraction si, int bounce, inout Ray ray, inout initRay(ray, si.position + EPS * lightDir, lightDir); - // advance strata index by unused stratified samples - const int usedStrata = 1; - strataDimension += STRATA_PER_MATERIAL - usedStrata; + // advance sample index by unused stratified samples + const int usedDimensions = 1; + sampleIndex += DIMENSIONS_PER_MATERIAL - usedDimensions; return bounce == BOUNCES ? beta * sampleEnvmapFromDirection(lightDir) : vec3(0.0); } diff --git a/src/renderer/glsl/chunks/sampleMaterial.glsl b/src/renderer/glsl/chunks/sampleMaterial.glsl index 1ce81d3..fa8c8f5 100644 --- a/src/renderer/glsl/chunks/sampleMaterial.glsl +++ b/src/renderer/glsl/chunks/sampleMaterial.glsl @@ -82,25 +82,25 @@ vec3 sampleMaterial(SurfaceInteraction si, int bounce, inout Ray ray, inout vec3 mat3 basis = orthonormalBasis(si.normal); vec3 viewDir = -ray.d; - vec2 diffuseOrSpecular = randomStrataVec2(); + vec2 diffuseOrSpecular = randomSampleVec2(); vec3 lightDir = diffuseOrSpecular.x < mix(0.5, 0.0, si.metalness) ? - lightDirDiffuse(si.faceNormal, viewDir, basis, randomStrataVec2()) : - lightDirSpecular(si.faceNormal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDirDiffuse(si.faceNormal, viewDir, basis, randomSampleVec2()) : + lightDirSpecular(si.faceNormal, viewDir, basis, si.roughness, randomSampleVec2()); bool lastBounce = bounce == BOUNCES; // Add path contribution vec3 li = beta * ( - importanceSampleLight(si, viewDir, lastBounce, randomStrataVec2()) + + importanceSampleLight(si, viewDir, lastBounce, randomSampleVec2()) + importanceSampleMaterial(si, viewDir, lastBounce, lightDir) ); // Get new path direction lightDir = diffuseOrSpecular.y < mix(0.5, 0.0, si.metalness) ? - lightDirDiffuse(si.faceNormal, viewDir, basis, randomStrataVec2()) : - lightDirSpecular(si.faceNormal, viewDir, basis, si.roughness, randomStrataVec2()); + lightDirDiffuse(si.faceNormal, viewDir, basis, randomSampleVec2()) : + lightDirSpecular(si.faceNormal, viewDir, basis, si.roughness, randomSampleVec2()); float cosThetaL = dot(si.normal, lightDir); diff --git a/src/renderer/glsl/chunks/sampleShadowCatcher.glsl b/src/renderer/glsl/chunks/sampleShadowCatcher.glsl index 8a54567..42f5836 100644 --- a/src/renderer/glsl/chunks/sampleShadowCatcher.glsl +++ b/src/renderer/glsl/chunks/sampleShadowCatcher.glsl @@ -84,13 +84,13 @@ vec3 sampleShadowCatcher(SurfaceInteraction si, int bounce, inout Ray ray, inout vec3 viewDir = -ray.d; vec3 color = sampleEnvmapFromDirection(-viewDir); - vec3 lightDir = lightDirDiffuse(si.faceNormal, viewDir, basis, randomStrataVec2()); + vec3 lightDir = lightDirDiffuse(si.faceNormal, viewDir, basis, randomSampleVec2()); float alphaBounce = 0.0; // Add path contribution vec3 li = beta * color * ( - importanceSampleLightShadowCatcher(si, viewDir, randomStrataVec2(), alphaBounce) + + importanceSampleLightShadowCatcher(si, viewDir, randomSampleVec2(), alphaBounce) + importanceSampleMaterialShadowCatcher(si, viewDir, lightDir, alphaBounce) ); @@ -106,7 +106,7 @@ vec3 sampleShadowCatcher(SurfaceInteraction si, int bounce, inout Ray ray, inout // Get new path direction - lightDir = lightDirDiffuse(si.faceNormal, viewDir, basis, randomStrataVec2()); + lightDir = lightDirDiffuse(si.faceNormal, viewDir, basis, randomSampleVec2()); float cosThetaL = dot(si.normal, lightDir); @@ -120,9 +120,9 @@ vec3 sampleShadowCatcher(SurfaceInteraction si, int bounce, inout Ray ray, inout float orientation = dot(si.faceNormal, viewDir) * cosThetaL; abort = orientation < 0.0; - // advance strata index by unused stratified samples - const int usedStrata = 6; - strataDimension += STRATA_PER_MATERIAL - usedStrata; + // advance dimension index by unused stratified samples + const int usedDimensions = 6; + sampleIndex += DIMENSIONS_PER_MATERIAL - usedDimensions; return li; } diff --git a/src/renderer/glsl/rayTrace.frag b/src/renderer/glsl/rayTrace.frag index 8ac0cb0..c906665 100644 --- a/src/renderer/glsl/rayTrace.frag +++ b/src/renderer/glsl/rayTrace.frag @@ -31,7 +31,7 @@ ${addDefines(params)} #define THICK_GLASS 2 #define SHADOW_CATCHER 3 -#define STRATA_PER_MATERIAL 8 +#define DIMENSIONS_PER_MATERIAL 8 const float IOR = 1.5; const float INV_IOR = 1.0 / IOR; @@ -153,7 +153,7 @@ void bounce(inout Path path, int i) { // Russian Roulette sampling if (i >= 2) { float q = 1.0 - dot(path.beta, luminance); - if (randomStrata() < q) { + if (randomSample() < q) { path.abort = true; } path.beta /= 1.0 - q; @@ -187,13 +187,13 @@ vec4 integrator(inout Ray ray) { void main() { initRandom(); - vec2 vCoordAntiAlias = vCoord + pixelSize * (randomStrataVec2() - 0.5); + vec2 vCoordAntiAlias = vCoord + pixelSize * (randomSampleVec2() - 0.5); vec3 direction = normalize(vec3(vCoordAntiAlias - 0.5, -1.0) * vec3(camera.aspect, 1.0, camera.fov)); // Thin lens model with depth-of-field // http://www.pbr-book.org/3ed-2018/Camera_Models/Projective_Camera_Models.html#TheThinLensModelandDepthofField - vec2 lensPoint = camera.aperture * sampleCircle(randomStrataVec2()); + vec2 lensPoint = camera.aperture * sampleCircle(randomSampleVec2()); vec3 focusPoint = -direction * camera.focus / direction.z; // intersect ray direction with focus plane vec3 origin = vec3(lensPoint, 0.0); @@ -226,9 +226,9 @@ void main() { // All samples are used by the shader. Correct result! // fragColor = vec4(0, 0, 0, 1); - // if (strataDimension == STRATA_DIMENSIONS) { + // if (sampleIndex == SAMPLING_DIMENSIONS) { // fragColor = vec4(1, 1, 1, 1); - // } else if (strataDimension > STRATA_DIMENSIONS) { + // } else if (sampleIndex > SAMPLING_DIMENSIONS) { // fragColor = vec4(1, 0, 0, 1); // } } diff --git a/src/renderer/rayTracingShader.js b/src/renderer/rayTracingShader.js index 9e3f734..c93a3f0 100644 --- a/src/renderer/rayTracingShader.js +++ b/src/renderer/rayTracingShader.js @@ -1,96 +1,18 @@ +import * as THREE from 'three'; import fragString from './glsl/rayTrace.frag'; import { createShader, createProgram, getUniforms } from './glUtil'; import { mergeMeshesToGeometry } from './mergeMeshesToGeometry'; import { bvhAccel, flattenBvh } from './bvhAccel'; import { envmapDistribution } from './envmapDistribution'; import { generateEnvMapFromSceneComponents } from './envMapCreation'; -import { makeStratifiedRandomCombined } from './stratifiedRandomCombined'; import { getTexturesFromMaterials, mergeTexturesFromMaterials } from './texturesFromMaterials'; import { makeTexture } from './texture'; import { uploadBuffers } from './uploadBuffers'; import { ThinMaterial, ThickMaterial, ShadowCatcherMaterial } from '../constants'; -import * as THREE from 'three'; import { clamp } from './util'; +import { makeStratifiedSamplerCombined } from './stratifiedSamplerCombined'; -function textureDimensionsFromArray(count) { - const columnsLog = Math.round(Math.log2(Math.sqrt(count))); - const columns = 2 ** columnsLog; - const rows = Math.ceil(count / columns); - return { - columnsLog, - columns, - rows, - size: rows * columns, - }; -} - -function maxImageSize(images) { - const maxSize = { - width: 0, - height: 0 - }; - - for (const image of images) { - maxSize.width = Math.max(maxSize.width, image.width); - maxSize.height = Math.max(maxSize.height, image.height); - } - - const relativeSizes = []; - for (const image of images) { - relativeSizes.push(image.width / maxSize.width); - relativeSizes.push(image.height / maxSize.height); - } - - return { maxSize, relativeSizes }; -} - -// expand array to the given length -function padArray(typedArray, length) { - const newArray = new typedArray.constructor(length); - newArray.set(typedArray); - return newArray; -} - -function isHDRTexture(texture) { - return texture.map - && texture.map.image - && (texture.map.encoding === THREE.RGBEEncoding || texture.map.encoding === THREE.LinearEncoding); -} - -function decomposeScene(scene) { - const meshes = []; - const directionalLights = []; - const environmentLights = []; - scene.traverse(child => { - if (child instanceof THREE.Mesh) { - if (!child.geometry || !child.geometry.getAttribute('position')) { - console.log(child, 'must have a geometry property with a position attribute'); - } - else if (!(child.material instanceof THREE.MeshStandardMaterial)) { - console.log(child, 'must use MeshStandardMaterial in order to be rendered.'); - } else { - meshes.push(child); - } - } - if (child instanceof THREE.DirectionalLight) { - directionalLights.push(child); - } - if (child instanceof THREE.EnvironmentLight) { - if (environmentLights.length > 1) { - console.warn('Only one environment light can be used per scene'); - } - else if (isHDRTexture(child)) { - environmentLights.push(child); - } else { - console.warn('Environment light has invalid map'); - } - } - }); - - return { - meshes, directionalLights, environmentLights - }; -} +//Important TODO: Refactor this file to get rid of duplicate and confusing code export function makeRayTracingShader({ gl, @@ -105,17 +27,15 @@ export function makeRayTracingShader({ const { OES_texture_float_linear } = optionalExtensions; - // Use stratified sampling for random variables to reduce clustering of samples thus improving rendering quality. - // Each element of this array specifies how many dimensions belong to each set of stratified samples - const strataDimensions = []; - strataDimensions.push(2, 2); // anti-aliasing, depth-of-field + const samplingDimensions = []; + samplingDimensions.push(2, 2); // anti aliasing, depth of field for (let i = 0; i < bounces; i++) { - // specular or diffuse reflection, light importance sampling, material importance sampling, next path direction - strataDimensions.push(2, 2, 2, 2); + // specular or diffuse reflection, light importance sampling, material sampling, next path direction + samplingDimensions.push(2, 2, 2, 2); if (i >= 1) { // russian roulette sampling // this step is skipped on the first bounce - strataDimensions.push(1); + samplingDimensions.push(1); } } @@ -159,7 +79,7 @@ export function makeRayTracingShader({ BOUNCES: bounces, USE_GLASS: useGlass, USE_SHADOW_CATCHER: useShadowCatcher, - STRATA_DIMENSIONS: strataDimensions.reduce((a, b) => a + b) + SAMPLING_DIMENSIONS: samplingDimensions.reduce((a, b) => a + b) })); const program = createProgram(gl, fullscreenQuad.vertexShader, fragmentShader); @@ -294,13 +214,21 @@ export function makeRayTracingShader({ const { program, uniforms } = initScene(); - let random = null; - function setSize(width, height) { gl.useProgram(program); gl.uniform2f(uniforms.pixelSize, 1 / width, 1 / height); } + // noiseImage is a 32-bit PNG image + function setNoise(noiseImage) { + textureAllocator.bind(uniforms.noise, makeTexture(gl, { + data: noiseImage, + minFilter: gl.NEAREST, + magFilter: gl.NEAREST, + storage: 'float' + })); + } + function setCamera(camera) { gl.useProgram(program); gl.uniformMatrix4fv(uniforms['camera.transform'], false, camera.matrixWorld.elements); @@ -310,15 +238,32 @@ export function makeRayTracingShader({ gl.uniform1f(uniforms['camera.aperture'], camera.aperture || 0); } - function setStrataCount(count) { - random = makeStratifiedRandomCombined(count, strataDimensions); + let samples; + + function nextSeed() { + gl.useProgram(program); + gl.uniform1fv(uniforms['stratifiedSamples[0]'], samples.next()); + } + + function setStrataCount(strataCount) { + gl.useProgram(program); + + if (strataCount > 1 && strataCount !== samples.strataCount) { + // reinitailizing random has a performance cost. we can skip it if + // * strataCount is 1, since a strataCount of 1 works with any sized StratifiedRandomCombined + // * random already has the same strata count as desired + samples = makeStratifiedSamplerCombined(strataCount, samplingDimensions); + } else { + samples.restart(); + } + + gl.uniform1f(uniforms.strataSize, 1.0 / strataCount); + nextSeed(); } - function updateSeed() { + function useStratifiedSampling(stratifiedSampling) { gl.useProgram(program); - gl.uniform1f(uniforms.strataSize, 1.0 / random.strataCount); - gl.uniform1fv(uniforms['strataStart[0]'], random.next()); - gl.uniform1f(uniforms.seed, Math.random()); + gl.uniform1f(uniforms.useStratifiedSampling, stratifiedSampling ? 1.0 : 0.0); } function draw() { @@ -326,11 +271,95 @@ export function makeRayTracingShader({ fullscreenQuad.draw(); } + samples = makeStratifiedSamplerCombined(1, samplingDimensions); + return Object.freeze({ - setSize, + draw, + nextSeed, setCamera, + setNoise, + setSize, setStrataCount, - updateSeed, - draw + useStratifiedSampling }); } + +function textureDimensionsFromArray(count) { + const columnsLog = Math.round(Math.log2(Math.sqrt(count))); + const columns = 2 ** columnsLog; + const rows = Math.ceil(count / columns); + return { + columnsLog, + columns, + rows, + size: rows * columns, + }; +} + +function maxImageSize(images) { + const maxSize = { + width: 0, + height: 0 + }; + + for (const image of images) { + maxSize.width = Math.max(maxSize.width, image.width); + maxSize.height = Math.max(maxSize.height, image.height); + } + + const relativeSizes = []; + for (const image of images) { + relativeSizes.push(image.width / maxSize.width); + relativeSizes.push(image.height / maxSize.height); + } + + return { maxSize, relativeSizes }; +} + +// expand array to the given length +function padArray(typedArray, length) { + const newArray = new typedArray.constructor(length); + newArray.set(typedArray); + return newArray; +} + +function isHDRTexture(texture) { + return texture.map + && texture.map.image + && (texture.map.encoding === THREE.RGBEEncoding || texture.map.encoding === THREE.LinearEncoding); +} + +function decomposeScene(scene) { + const meshes = []; + const directionalLights = []; + const environmentLights = []; + scene.traverse(child => { + if (child instanceof THREE.Mesh) { + if (!child.geometry || !child.geometry.getAttribute('position')) { + console.warn(child, 'must have a geometry property with a position attribute'); + } + else if (!(child.material instanceof THREE.MeshStandardMaterial)) { + console.warn(child, 'must use MeshStandardMaterial in order to be rendered.'); + } else { + meshes.push(child); + } + } + if (child instanceof THREE.DirectionalLight) { + directionalLights.push(child); + } + if (child instanceof THREE.EnvironmentLight) { + if (environmentLights.length > 1) { + console.warn(environmentLights, 'only one environment light can be used per scene'); + } + else if (isHDRTexture(child)) { + environmentLights.push(child); + } else { + console.warn(child, 'environment light uses invalid map'); + } + } + }); + + return { + meshes, directionalLights, environmentLights + }; +} diff --git a/src/renderer/sceneSampler.js b/src/renderer/renderingPipeline.js similarity index 80% rename from src/renderer/sceneSampler.js rename to src/renderer/renderingPipeline.js index a6d0c5b..e0b5f18 100644 --- a/src/renderer/sceneSampler.js +++ b/src/renderer/renderingPipeline.js @@ -6,8 +6,11 @@ import { numberArraysEqual, clamp } from './util'; import { makeTileRender } from './tileRender'; import { LensCamera } from '../LensCamera'; import { makeTextureAllocator } from './textureAllocator'; +import noiseBase64 from './texture/noise'; -export function makeSceneSampler({ +// Important TODO: Refactor this file to get rid of duplicate and confusing code + +export function makeRenderingPipeline({ gl, optionalExtensions, scene, @@ -15,11 +18,20 @@ export function makeSceneSampler({ bounces, // number of global illumination bounces }) { + let ready = false; + const fullscreenQuad = makeFullscreenQuad(gl); const textureAllocator = makeTextureAllocator(gl); const rayTracingShader = makeRayTracingShader({gl, optionalExtensions, fullscreenQuad, textureAllocator, scene, bounces}); const toneMapShader = makeToneMapShader({gl, optionalExtensions, fullscreenQuad, textureAllocator, toneMappingParams}); + const noiseImage = new Image(); + noiseImage.src = noiseBase64; + noiseImage.onload = () => { + rayTracingShader.setNoise(noiseImage); + ready = true; + }; + const useLinearFiltering = optionalExtensions.OES_texture_float_linear; const hdrBuffer = makeRenderTargetFloat(gl); // full resolution buffer representing the rendered scene with HDR lighting @@ -30,11 +42,12 @@ export function makeSceneSampler({ const lastCamera = new LensCamera(); - // how many samples to render with simple noise before switching to stratified noise - const numSimpleSamples = 4; + // how many samples to render with uniform noise before switching to stratified noise + const numUniformSamples = 6; // how many partitions of stratified noise should be created - const strataSize = 6; + // higher number results in faster convergence over time, but with lower quality initial samples + const strataCount = 6; let sampleCount = 0; @@ -47,13 +60,12 @@ export function makeSceneSampler({ sampleCount = 0; tileRender.reset(); - rayTracingShader.setStrataCount(1); - rayTracingShader.updateSeed(); } function initFirstSample(camera) { lastCamera.copy(camera); rayTracingShader.setCamera(camera); + rayTracingShader.useStratifiedSampling(false); clear(); } @@ -117,8 +129,21 @@ export function makeSceneSampler({ }); } + function updateSeed() { + if (sampleCount === 2) { + rayTracingShader.useStratifiedSampling(true); + rayTracingShader.setStrataCount(1); + } else if (sampleCount === numUniformSamples) { + rayTracingShader.setStrataCount(strataCount); + } else { + rayTracingShader.nextSeed(); + } + } + function drawTile(camera) { - if (!camerasEqual(camera, lastCamera)) { + if (!ready) { + return; + } else if (!camerasEqual(camera, lastCamera)) { initFirstSample(camera); setPreviewBufferDimensions(); renderPreview(); @@ -127,11 +152,7 @@ export function makeSceneSampler({ if (isFirstTile) { sampleCount++; - rayTracingShader.updateSeed(); - - if (sampleCount === numSimpleSamples) { - rayTracingShader.setStrataCount(strataSize); - } + updateSeed(); } renderTile(x, y, tileWidth, tileHeight); @@ -144,7 +165,9 @@ export function makeSceneSampler({ } function drawOffscreenTile(camera) { - if (!camerasEqual(camera, lastCamera)) { + if (!ready) { + return; + } else if (!camerasEqual(camera, lastCamera)) { initFirstSample(camera); } @@ -152,12 +175,7 @@ export function makeSceneSampler({ if (isFirstTile) { sampleCount++; - rayTracingShader.updateSeed(); - - - if (sampleCount === numSimpleSamples) { - rayTracingShader.setStrataCount(strataSize); - } + updateSeed(); } renderTile(x, y, tileWidth, tileHeight); @@ -168,17 +186,15 @@ export function makeSceneSampler({ } function drawFull(camera) { - if (!camerasEqual(camera, lastCamera)) { + if (!ready) { + return; + } else if (!camerasEqual(camera, lastCamera)) { initFirstSample(camera); } - if (sampleCount === numSimpleSamples) { - rayTracingShader.setStrataCount(strataSize); - } - sampleCount++; - rayTracingShader.updateSeed(); + updateSeed(); addSampleToBuffer(hdrBuffer); hdrBufferToScreen(); } diff --git a/src/renderer/stratifiedRandom.js b/src/renderer/stratifiedRandom.js deleted file mode 100644 index 3a2d29e..0000000 --- a/src/renderer/stratifiedRandom.js +++ /dev/null @@ -1,47 +0,0 @@ -// Stratified Sampling -// http://www.pbr-book.org/3ed-2018/Sampling_and_Reconstruction/Stratified_Sampling.html -// Repeatedly generating random numbers between [0, 1) has the effect of producing numbers that are coincidentally clustered together, -// instead of being evenly spaced across the domain. -// This produces low quality results for the path tracer since clustered numbers send too many rays in similar directions. - -// We can reduce the amount of clustering of random numbers by using stratified sampling. -// This is done by partitioning [0, 1) into smaller subsets and randomly sampling from each subsets instead. - -import { shuffle } from "./util"; - -export function makeStratifiedRandom(strataCount, dimensions) { - const samples = []; - const l = strataCount ** dimensions; - for (let i = 0; i < l; i++) { - samples[i] = i; - } - - let index = samples.length; - - const randomNums = []; - - function reset() { - index = 0; - shuffle(samples); - } - - function next() { - if (index >= samples.length) { - reset(); - } - let sample = samples[index++]; - - for (let i = 0; i < dimensions; i++) { - randomNums[i] = (sample % strataCount) / strataCount; - sample = Math.floor(sample / strataCount); - } - - return randomNums; - } - - return Object.freeze({ - reset, - next, - strataCount - }); -} diff --git a/src/renderer/stratifiedRandomCombined.js b/src/renderer/stratifiedRandomCombined.js deleted file mode 100644 index 90fe84c..0000000 --- a/src/renderer/stratifiedRandomCombined.js +++ /dev/null @@ -1,43 +0,0 @@ -// Stratified Sampling -// http://www.pbr-book.org/3ed-2018/Sampling_and_Reconstruction/Stratified_Sampling.html -// It is computationally unfeasible to compute stratified sampling for large dimensions (>2) -// Instead, we can compute stratified sampling for lower dimensional patterns that sum to the high dimension -// e.g. instead of sampling a 6D domain, we sample a 2D + 2D + 2D domain. -// This reaps most of the benifits of stratification while still remaining computable in a reasonable amount of time - -import { makeStratifiedRandom } from "./stratifiedRandom"; - -export function makeStratifiedRandomCombined(strataCount, listOfDimensions) { - const strataObjs = []; - for (const dim of listOfDimensions) { - strataObjs.push(makeStratifiedRandom(strataCount, dim)); - } - - const randomNums = []; - - function reset() { - for (const strata of strataObjs) { - strata.reset(); - } - } - - function next() { - let i = 0; - - for (const strata of strataObjs) { - const nums = strata.next(); - - for (const num of nums) { - randomNums[i++] = num; - } - } - - return randomNums; - } - - return Object.freeze({ - next, - reset, - strataCount - }); -} diff --git a/src/renderer/stratifiedSampler.js b/src/renderer/stratifiedSampler.js new file mode 100644 index 0000000..efdd8fd --- /dev/null +++ b/src/renderer/stratifiedSampler.js @@ -0,0 +1,58 @@ +/* +Stratified Sampling +http://www.pbr-book.org/3ed-2018/Sampling_and_Reconstruction/Stratified_Sampling.html + +Repeatedly sampling random numbers between [0, 1) has the effect of producing numbers that are coincidentally clustered together, +instead of being evenly spaced across the domain. +This produces low quality results for the path tracer since clustered samples send too many rays in similar directions. + +We can reduce the amount of clustering of random numbers by using stratified sampling. +Stratification divides the [0, 1) range into partitions, or stratum, of equal size. +Each invocation of the stratified sampler draws one uniform random number from one stratum from a shuffled sequence of stratums. +When every stratum has been sampled once, this sequence is shuffled again and the process repeats. + +The returned sample ranges between [0, numberOfStratum). +The integer part ideintifies the stratum (the first stratum being 0). +The fractional part is the random number. + +To obtain the stratified sample between [0, 1), divide the returned sample by the stratum count. +*/ + +import { shuffle } from "./util"; + +export function makeStratifiedSampler(strataCount, dimensions) { + const strata = []; + const l = strataCount ** dimensions; + for (let i = 0; i < l; i++) { + strata[i] = i; + } + + let index = strata.length; + + const sample = []; + + function restart() { + index = 0; + } + + function next() { + if (index >= strata.length) { + shuffle(strata); + restart(); + } + let stratum = strata[index++]; + + for (let i = 0; i < dimensions; i++) { + sample[i] = stratum % strataCount + Math.random(); + stratum = Math.floor(stratum / strataCount); + } + + return sample; + } + + return Object.freeze({ + next, + restart, + strataCount + }); +} diff --git a/src/renderer/stratifiedSamplerCombined.js b/src/renderer/stratifiedSamplerCombined.js new file mode 100644 index 0000000..c30ec9f --- /dev/null +++ b/src/renderer/stratifiedSamplerCombined.js @@ -0,0 +1,47 @@ +/* +Stratified Sampling +http://www.pbr-book.org/3ed-2018/Sampling_and_Reconstruction/Stratified_Sampling.html + +It is computationally unfeasible to compute stratified sampling for large dimensions (>2) +Instead, we can compute stratified sampling for lower dimensional patterns that sum to the high dimension +e.g. instead of sampling a 6D domain, we sample a 2D + 2D + 2D domain. +This reaps many benefits of stratification while still allowing for small strata sizes. +*/ + +import { makeStratifiedSampler } from "./stratifiedSampler"; + +export function makeStratifiedSamplerCombined(strataCount, listOfDimensions) { + const strataObjs = []; + + for (const dim of listOfDimensions) { + strataObjs.push(makeStratifiedSampler(strataCount, dim)); + } + + const combined = []; + + function next() { + let i = 0; + + for (const strata of strataObjs) { + const nums = strata.next(); + + for (const num of nums) { + combined[i++] = num; + } + } + + return combined; + } + + function restart() { + for (const strata of strataObjs) { + strata.restart(); + } + } + + return Object.freeze({ + next, + restart, + strataCount + }); +} diff --git a/src/renderer/texture.js b/src/renderer/texture.js index e5273d8..79fdc90 100644 --- a/src/renderer/texture.js +++ b/src/renderer/texture.js @@ -47,7 +47,7 @@ export function makeTexture(gl, params) { channels = clamp(channels, 1, 4); const format = [ - gl.R, + gl.RED, gl.RG, gl.RGB, gl.RGBA diff --git a/src/renderer/texture/HDR_L_0.png b/src/renderer/texture/HDR_L_0.png new file mode 100644 index 0000000000000000000000000000000000000000..5730c03ad479a530461ad33202397a7efd57c1be GIT binary patch literal 7147 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