diff --git a/src/rendering/advanced-rendering.js b/src/rendering/advanced-rendering.js index db36681a..594aaf5e 100644 --- a/src/rendering/advanced-rendering.js +++ b/src/rendering/advanced-rendering.js @@ -1,5 +1,7 @@ import { BasicShadowMap, DirectionalLight, HalfFloatType, Mesh, NoToneMapping, PCFShadowMap, PCFSoftShadowMap, PointLight, SpotLight, UnsignedByteType } from "three"; -import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing"; +import { BloomEffect, DepthOfFieldEffect, EffectComposer, EffectPass, NormalPass, RenderPass, SMAAEffect, SMAAPreset, SSAOEffect, ToneMappingEffect, ToneMappingMode } from "postprocessing"; +const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15; +const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2; export function resolveBoxVolumeRenderPaths(settings) { if (!settings.enabled) { return { @@ -43,9 +45,10 @@ export function configureAdvancedRenderingRenderer(renderer, settings) { renderer.toneMappingExposure = settings.toneMapping.exposure; } export function createAdvancedRenderingComposer(renderer, scene, camera, settings) { - const requiresDepthBuffer = settings.ambientOcclusion.enabled || settings.depthOfField.enabled; + // 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. const composer = new EffectComposer(renderer, { - depthBuffer: requiresDepthBuffer, + depthBuffer: true, stencilBuffer: false, multisampling: 0, frameBufferType: renderer.capabilities.isWebGL2 ? HalfFloatType : UnsignedByteType @@ -53,9 +56,15 @@ export function createAdvancedRenderingComposer(renderer, scene, camera, setting composer.addPass(new RenderPass(scene, camera)); const effects = []; if (settings.ambientOcclusion.enabled) { - effects.push(new SSAOEffect(camera, undefined, { + // postprocessing's internal depth-downsampling path writes zero normals unless + // 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, { + depthAwareUpsampling: true, + luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, samples: settings.ambientOcclusion.samples, - radius: settings.ambientOcclusion.radius, + radius: Math.min(settings.ambientOcclusion.radius, MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS), intensity: settings.ambientOcclusion.intensity })); } diff --git a/src/rendering/advanced-rendering.ts b/src/rendering/advanced-rendering.ts index c7d36428..2b7f76b9 100644 --- a/src/rendering/advanced-rendering.ts +++ b/src/rendering/advanced-rendering.ts @@ -20,6 +20,7 @@ import { DepthOfFieldEffect, EffectComposer, EffectPass, + NormalPass, RenderPass, SMAAEffect, SMAAPreset, @@ -35,6 +36,9 @@ import type { AdvancedRenderingToneMappingMode } from "../document/world-settings"; +const AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE = 0.15; +const MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS = 0.2; + export interface ResolvedBoxVolumeRenderPaths { fog: BoxVolumeRenderPath; water: BoxVolumeRenderPath; @@ -93,9 +97,10 @@ export function createAdvancedRenderingComposer( camera: PerspectiveCamera, settings: AdvancedRenderingSettings ): EffectComposer { - const requiresDepthBuffer = settings.ambientOcclusion.enabled || settings.depthOfField.enabled; + // 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. const composer = new EffectComposer(renderer, { - depthBuffer: requiresDepthBuffer, + depthBuffer: true, stencilBuffer: false, multisampling: 0, frameBufferType: renderer.capabilities.isWebGL2 ? HalfFloatType : UnsignedByteType @@ -106,10 +111,17 @@ export function createAdvancedRenderingComposer( const effects: Array = []; if (settings.ambientOcclusion.enabled) { + // postprocessing's internal depth-downsampling path writes zero normals unless + // 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, undefined, { + new SSAOEffect(camera, normalPass.texture, { + depthAwareUpsampling: true, + luminanceInfluence: AMBIENT_OCCLUSION_LUMINANCE_INFLUENCE, samples: settings.ambientOcclusion.samples, - radius: settings.ambientOcclusion.radius, + radius: Math.min(settings.ambientOcclusion.radius, MAX_AMBIENT_OCCLUSION_EFFECT_RADIUS), intensity: settings.ambientOcclusion.intensity }) ); diff --git a/tests/domain/advanced-rendering.test.ts b/tests/domain/advanced-rendering.test.ts index f0f69ac9..9503bcca 100644 --- a/tests/domain/advanced-rendering.test.ts +++ b/tests/domain/advanced-rendering.test.ts @@ -1,7 +1,90 @@ -import { describe, expect, it } from "vitest"; +import { PerspectiveCamera, Scene, UnsignedByteType } from "three"; +import { describe, expect, it, vi } from "vitest"; + +const postprocessingState = vi.hoisted(() => ({ + composerOptions: [] as Array>, + composerPasses: [] as unknown[], + normalPassTextures: [] as unknown[], + ssaoCalls: [] as Array<{ normalBuffer: unknown; options: Record }> +})); + +vi.mock("postprocessing", () => { + class MockEffectComposer { + constructor(_renderer: unknown, options: Record) { + postprocessingState.composerOptions.push(options); + } + + addPass(pass: unknown) { + postprocessingState.composerPasses.push(pass); + } + } + + class MockRenderPass { + constructor(_scene: unknown, _camera: unknown) {} + } + + class MockNormalPass { + texture: Record; + + constructor(_scene: unknown, _camera: unknown) { + this.texture = { + kind: "normal-pass-texture", + index: postprocessingState.normalPassTextures.length + }; + postprocessingState.normalPassTextures.push(this.texture); + } + } + + class MockEffectPass { + constructor(_camera: unknown, ..._effects: unknown[]) {} + } + + class MockSSAOEffect { + constructor(_camera: unknown, normalBuffer: unknown, options: Record) { + postprocessingState.ssaoCalls.push({ normalBuffer, options }); + } + } + + class MockBloomEffect { + constructor(_options: Record) {} + } + + class MockDepthOfFieldEffect { + constructor(_camera: unknown, _options: Record) {} + } + + class MockToneMappingEffect { + constructor(_options: Record) {} + } + + class MockSMAAEffect { + constructor(_options: Record) {} + } + + return { + BloomEffect: MockBloomEffect, + DepthOfFieldEffect: MockDepthOfFieldEffect, + EffectComposer: MockEffectComposer, + EffectPass: MockEffectPass, + NormalPass: MockNormalPass, + RenderPass: MockRenderPass, + SMAAEffect: MockSMAAEffect, + SMAAPreset: { + MEDIUM: "medium" + }, + SSAOEffect: MockSSAOEffect, + ToneMappingEffect: MockToneMappingEffect, + ToneMappingMode: { + ACES_FILMIC: "ACES_FILMIC", + CINEON: "CINEON", + LINEAR: "LINEAR", + REINHARD: "REINHARD" + } + }; +}); import { createDefaultWorldSettings } from "../../src/document/world-settings"; -import { resolveBoxVolumeRenderPaths } from "../../src/rendering/advanced-rendering"; +import { createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../../src/rendering/advanced-rendering"; describe("resolveBoxVolumeRenderPaths", () => { it("uses authored fog and water paths when advanced rendering is enabled", () => { @@ -28,3 +111,72 @@ describe("resolveBoxVolumeRenderPaths", () => { }); }); }); + +describe("createAdvancedRenderingComposer", () => { + it("keeps depth buffering enabled when the post stack only uses color effects", () => { + postprocessingState.composerOptions.length = 0; + postprocessingState.composerPasses.length = 0; + postprocessingState.normalPassTextures.length = 0; + postprocessingState.ssaoCalls.length = 0; + + const settings = createDefaultWorldSettings().advancedRendering; + settings.enabled = true; + settings.ambientOcclusion.enabled = false; + settings.depthOfField.enabled = false; + + createAdvancedRenderingComposer( + { + capabilities: { + isWebGL2: false + } + } as unknown as never, + new Scene(), + new PerspectiveCamera(), + settings + ); + + expect(postprocessingState.composerOptions).toHaveLength(1); + expect(postprocessingState.composerOptions[0]).toMatchObject({ + depthBuffer: true, + frameBufferType: UnsignedByteType + }); + }); + + it("feeds SSAO a normal pass and clamps broad occlusion into a corner-shading range", () => { + postprocessingState.composerOptions.length = 0; + postprocessingState.composerPasses.length = 0; + postprocessingState.normalPassTextures.length = 0; + postprocessingState.ssaoCalls.length = 0; + + const settings = createDefaultWorldSettings().advancedRendering; + settings.enabled = true; + settings.ambientOcclusion.enabled = true; + settings.ambientOcclusion.samples = 12; + settings.ambientOcclusion.radius = 0.5; + settings.ambientOcclusion.intensity = 0.85; + + createAdvancedRenderingComposer( + { + capabilities: { + isWebGL2: true + } + } as unknown as never, + new Scene(), + new PerspectiveCamera(), + settings + ); + + expect(postprocessingState.normalPassTextures).toHaveLength(1); + expect(postprocessingState.ssaoCalls).toHaveLength(1); + expect(postprocessingState.ssaoCalls[0]).toMatchObject({ + normalBuffer: postprocessingState.normalPassTextures[0], + options: { + depthAwareUpsampling: true, + luminanceInfluence: 0.15, + samples: 12, + radius: 0.2, + intensity: 0.85 + } + }); + }); +});