Enhance advanced rendering by adding normal pass and refining SSAO effect settings

This commit is contained in:
2026-04-07 12:15:50 +02:00
parent 56bf1c4e32
commit 486f0f3f3c
3 changed files with 184 additions and 11 deletions

View File

@@ -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
}));
}

View File

@@ -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<SSAOEffect | BloomEffect | DepthOfFieldEffect | ToneMappingEffect | SMAAEffect> = [];
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
})
);

View File

@@ -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<Record<string, unknown>>,
composerPasses: [] as unknown[],
normalPassTextures: [] as unknown[],
ssaoCalls: [] as Array<{ normalBuffer: unknown; options: Record<string, unknown> }>
}));
vi.mock("postprocessing", () => {
class MockEffectComposer {
constructor(_renderer: unknown, options: Record<string, unknown>) {
postprocessingState.composerOptions.push(options);
}
addPass(pass: unknown) {
postprocessingState.composerPasses.push(pass);
}
}
class MockRenderPass {
constructor(_scene: unknown, _camera: unknown) {}
}
class MockNormalPass {
texture: Record<string, unknown>;
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<string, unknown>) {
postprocessingState.ssaoCalls.push({ normalBuffer, options });
}
}
class MockBloomEffect {
constructor(_options: Record<string, unknown>) {}
}
class MockDepthOfFieldEffect {
constructor(_camera: unknown, _options: Record<string, unknown>) {}
}
class MockToneMappingEffect {
constructor(_options: Record<string, unknown>) {}
}
class MockSMAAEffect {
constructor(_options: Record<string, unknown>) {}
}
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
}
});
});
});