diff --git a/src/rendering/advanced-rendering.js b/src/rendering/advanced-rendering.js index 594aaf5e..94d5e9e8 100644 --- a/src/rendering/advanced-rendering.js +++ b/src/rendering/advanced-rendering.js @@ -1,7 +1,14 @@ import { BasicShadowMap, DirectionalLight, HalfFloatType, Mesh, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, PointLight, SpotLight, UnsignedByteType } from "three"; import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, NormalPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing"; const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15; +const MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.02; const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2; +const MIN_AMBIENT_OCCLUSION_SAMPLES = 12; +const COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.5; +const DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.75; +const DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE = 0.35; +const COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.45; +const DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.35; export function resolveBoxVolumeRenderPaths(settings) { if (!settings.enabled) { return { @@ -44,6 +51,12 @@ export function configureAdvancedRenderingRenderer(renderer, settings) { renderer.toneMapping = NoToneMapping; renderer.toneMappingExposure = settings.toneMapping.exposure; } +function clampAmbientOcclusionEffectRadius(radius) { + return Math.min(Math.max(radius, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS), MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS); +} +function getAmbientOcclusionSampleCount(samples) { + return Math.max(samples, MIN_AMBIENT_OCCLUSION_SAMPLES); +} export function createAdvancedRenderingComposer(renderer, scene, camera, settings) { // The scene is always rendered into the composer's offscreen targets first, // so those targets need depth for correct visibility even when no effect samples it. @@ -60,13 +73,24 @@ export function createAdvancedRenderingComposer(renderer, scene, camera, setting // a real normal buffer is supplied, which turns SSAO into speckled noise. const normalPass = new NormalPass(scene, camera); composer.addPass(normalPass); - effects.push(new SSAOEffect(camera, normalPass.texture, { + const ambientOcclusionRadius = clampAmbientOcclusionEffectRadius(settings.ambientOcclusion.radius); + const ambientOcclusionSamples = getAmbientOcclusionSampleCount(settings.ambientOcclusion.samples); + const detailAmbientOcclusionRadius = Math.max(ambientOcclusionRadius * DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS); + composer.addPass(new EffectPass(camera, new SSAOEffect(camera, normalPass.texture, { depthAwareUpsampling: true, luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, - samples: settings.ambientOcclusion.samples, - radius: Math.min(settings.ambientOcclusion.radius, MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS), - intensity: settings.ambientOcclusion.intensity - })); + resolutionScale: COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE, + samples: ambientOcclusionSamples, + radius: ambientOcclusionRadius, + intensity: settings.ambientOcclusion.intensity * COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE + }), new SSAOEffect(camera, normalPass.texture, { + depthAwareUpsampling: true, + luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, + resolutionScale: DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE, + samples: ambientOcclusionSamples, + radius: detailAmbientOcclusionRadius, + intensity: settings.ambientOcclusion.intensity * DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE + }))); } if (settings.bloom.enabled) { effects.push(new BloomEffect({ diff --git a/src/rendering/advanced-rendering.ts b/src/rendering/advanced-rendering.ts index 2b7f76b9..286241aa 100644 --- a/src/rendering/advanced-rendering.ts +++ b/src/rendering/advanced-rendering.ts @@ -37,7 +37,14 @@ import type { } from "../document/world-settings"; const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15; +const MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.02; const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2; +const MIN_AMBIENT_OCCLUSION_SAMPLES = 12; +const COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.5; +const DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE = 0.75; +const DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE = 0.35; +const COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.45; +const DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE = 0.35; export interface ResolvedBoxVolumeRenderPaths { fog: BoxVolumeRenderPath; @@ -91,6 +98,14 @@ export function configureAdvancedRenderingRenderer(renderer: WebGLRenderer, sett renderer.toneMappingExposure = settings.toneMapping.exposure; } +function clampAmbientOcclusionEffectRadius(radius: number) { + return Math.min(Math.max(radius, MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS), MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS); +} + +function getAmbientOcclusionSampleCount(samples: number) { + return Math.max(samples, MIN_AMBIENT_OCCLUSION_SAMPLES); +} + export function createAdvancedRenderingComposer( renderer: WebGLRenderer, scene: Scene, @@ -108,7 +123,7 @@ export function createAdvancedRenderingComposer( composer.addPass(new RenderPass(scene, camera)); - const effects: Array = []; + const effects: Array = []; if (settings.ambientOcclusion.enabled) { // postprocessing's internal depth-downsampling path writes zero normals unless @@ -116,14 +131,33 @@ export function createAdvancedRenderingComposer( const normalPass = new NormalPass(scene, camera); composer.addPass(normalPass); - effects.push( - new SSAOEffect(camera, normalPass.texture, { - depthAwareUpsampling: true, - luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, - samples: settings.ambientOcclusion.samples, - radius: Math.min(settings.ambientOcclusion.radius, MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS), - intensity: settings.ambientOcclusion.intensity - }) + const ambientOcclusionRadius = clampAmbientOcclusionEffectRadius(settings.ambientOcclusion.radius); + const ambientOcclusionSamples = getAmbientOcclusionSampleCount(settings.ambientOcclusion.samples); + const detailAmbientOcclusionRadius = Math.max( + ambientOcclusionRadius * DETAIL_AMBIENT_OCCLUSION_RADIUS_SCALE, + MIN_AMBIENT_OCCLUSION_EFFECT_RADIUS + ); + + composer.addPass( + new EffectPass( + camera, + new SSAOEffect(camera, normalPass.texture, { + depthAwareUpsampling: true, + luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, + resolutionScale: COARSE_AMBIENT_OCCLUSION_RESOLUTION_SCALE, + samples: ambientOcclusionSamples, + radius: ambientOcclusionRadius, + intensity: settings.ambientOcclusion.intensity * COARSE_AMBIENT_OCCLUSION_INTENSITY_SCALE + }), + new SSAOEffect(camera, normalPass.texture, { + depthAwareUpsampling: true, + luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, + resolutionScale: DETAIL_AMBIENT_OCCLUSION_RESOLUTION_SCALE, + samples: ambientOcclusionSamples, + radius: detailAmbientOcclusionRadius, + intensity: settings.ambientOcclusion.intensity * DETAIL_AMBIENT_OCCLUSION_INTENSITY_SCALE + }) + ) ); } diff --git a/tests/domain/advanced-rendering.test.ts b/tests/domain/advanced-rendering.test.ts index 9503bcca..9b75d119 100644 --- a/tests/domain/advanced-rendering.test.ts +++ b/tests/domain/advanced-rendering.test.ts @@ -140,9 +140,10 @@ describe("createAdvancedRenderingComposer", () => { depthBuffer: true, frameBufferType: UnsignedByteType }); + expect(postprocessingState.ssaoCalls).toHaveLength(0); }); - it("feeds SSAO a normal pass and clamps broad occlusion into a corner-shading range", () => { + it("builds a dual-layer SSAO stack from one normal pass", () => { postprocessingState.composerOptions.length = 0; postprocessingState.composerPasses.length = 0; postprocessingState.normalPassTextures.length = 0; @@ -151,7 +152,7 @@ describe("createAdvancedRenderingComposer", () => { const settings = createDefaultWorldSettings().advancedRendering; settings.enabled = true; settings.ambientOcclusion.enabled = true; - settings.ambientOcclusion.samples = 12; + settings.ambientOcclusion.samples = 8; settings.ambientOcclusion.radius = 0.5; settings.ambientOcclusion.intensity = 0.85; @@ -167,7 +168,7 @@ describe("createAdvancedRenderingComposer", () => { ); expect(postprocessingState.normalPassTextures).toHaveLength(1); - expect(postprocessingState.ssaoCalls).toHaveLength(1); + expect(postprocessingState.ssaoCalls).toHaveLength(2); expect(postprocessingState.ssaoCalls[0]).toMatchObject({ normalBuffer: postprocessingState.normalPassTextures[0], options: { @@ -175,7 +176,19 @@ describe("createAdvancedRenderingComposer", () => { luminanceInfluence: 0.15, samples: 12, radius: 0.2, - intensity: 0.85 + intensity: 0.3825, + resolutionScale: 0.5 + } + }); + expect(postprocessingState.ssaoCalls[1]).toMatchObject({ + normalBuffer: postprocessingState.normalPassTextures[0], + options: { + depthAwareUpsampling: true, + luminanceInfluence: 0.15, + samples: 12, + radius: 0.07, + intensity: 0.2975, + resolutionScale: 0.75 } }); });