545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
import {
|
|
BoxGeometry,
|
|
Group,
|
|
Mesh,
|
|
MeshBasicMaterial,
|
|
MeshStandardMaterial,
|
|
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 MockPass {
|
|
name: string;
|
|
needsSwap = true;
|
|
|
|
constructor(name = "Pass") {
|
|
this.name = name;
|
|
}
|
|
}
|
|
|
|
class MockEffectComposer {
|
|
constructor(_renderer: unknown, options: Record<string, unknown>) {
|
|
postprocessingState.composerOptions.push(options);
|
|
}
|
|
|
|
addPass(pass: unknown) {
|
|
postprocessingState.composerPasses.push(pass);
|
|
}
|
|
}
|
|
|
|
class MockRenderPass extends MockPass {
|
|
clear = true;
|
|
ignoreBackground = false;
|
|
skipShadowMapUpdate = false;
|
|
|
|
constructor(
|
|
readonly scene: unknown,
|
|
readonly camera: unknown,
|
|
readonly overrideMaterial: unknown = null
|
|
) {
|
|
super("RenderPass");
|
|
}
|
|
|
|
render() {}
|
|
}
|
|
|
|
class MockNormalPass extends MockPass {
|
|
texture: Record<string, unknown>;
|
|
|
|
constructor(_scene: unknown, _camera: unknown) {
|
|
super("NormalPass");
|
|
this.texture = {
|
|
kind: "normal-pass-texture",
|
|
index: postprocessingState.normalPassTextures.length
|
|
};
|
|
postprocessingState.normalPassTextures.push(this.texture);
|
|
}
|
|
|
|
render() {}
|
|
}
|
|
|
|
class MockEffectPass extends MockPass {
|
|
readonly effects: unknown[];
|
|
|
|
constructor(
|
|
readonly camera: unknown,
|
|
...effects: unknown[]
|
|
) {
|
|
super("EffectPass");
|
|
this.effects = effects;
|
|
}
|
|
}
|
|
|
|
class MockCopyMaterial {
|
|
kind = "copy-material";
|
|
}
|
|
|
|
class MockShaderPass extends MockPass {
|
|
constructor(readonly material: unknown) {
|
|
super("ShaderPass");
|
|
}
|
|
}
|
|
|
|
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,
|
|
CopyMaterial: MockCopyMaterial,
|
|
DepthOfFieldEffect: MockDepthOfFieldEffect,
|
|
EffectComposer: MockEffectComposer,
|
|
EffectPass: MockEffectPass,
|
|
NormalPass: MockNormalPass,
|
|
Pass: MockPass,
|
|
RenderPass: MockRenderPass,
|
|
ShaderPass: MockShaderPass,
|
|
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 {
|
|
applyAdvancedRenderingRenderableShadowFlags,
|
|
createAdvancedRenderingComposer,
|
|
resolveBoxVolumeRenderPaths
|
|
} from "../../src/rendering/advanced-rendering";
|
|
import { resolveDynamicGlobalIlluminationParameters } from "../../src/rendering/screen-space-global-illumination";
|
|
import {
|
|
ALL_RENDER_LAYER_MASK,
|
|
AO_WORLD_RENDER_LAYER_MASK,
|
|
OVERLAY_RENDER_LAYER_MASK,
|
|
POST_AO_TRANSPARENT_RENDER_LAYER_MASK,
|
|
applyRendererRenderCategory,
|
|
applyRendererRenderCategoryFromMaterial,
|
|
enableCameraRendererRenderCategories
|
|
} from "../../src/rendering/render-layers";
|
|
import {
|
|
applyWhiteboxBevelToMaterial,
|
|
shouldApplyWhiteboxBevel
|
|
} from "../../src/rendering/whitebox-bevel-material";
|
|
|
|
describe("resolveBoxVolumeRenderPaths", () => {
|
|
it("uses authored fog and water paths when advanced rendering is enabled", () => {
|
|
const settings = createDefaultWorldSettings().advancedRendering;
|
|
settings.enabled = true;
|
|
settings.fogPath = "quality";
|
|
settings.waterPath = "performance";
|
|
|
|
expect(resolveBoxVolumeRenderPaths(settings)).toEqual({
|
|
fog: "quality",
|
|
water: "performance"
|
|
});
|
|
});
|
|
|
|
it("falls back to performance paths when advanced rendering is disabled", () => {
|
|
const settings = createDefaultWorldSettings().advancedRendering;
|
|
settings.enabled = false;
|
|
settings.fogPath = "quality";
|
|
settings.waterPath = "quality";
|
|
|
|
expect(resolveBoxVolumeRenderPaths(settings)).toEqual({
|
|
fog: "performance",
|
|
water: "performance"
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDynamicGlobalIlluminationParameters", () => {
|
|
it("uses bounded low-cost parameters by default", () => {
|
|
const settings =
|
|
createDefaultWorldSettings().advancedRendering.dynamicGlobalIllumination;
|
|
settings.enabled = true;
|
|
|
|
expect(resolveDynamicGlobalIlluminationParameters(settings)).toMatchObject({
|
|
enabled: true,
|
|
intensity: 1.25,
|
|
radius: 3.5,
|
|
quality: "low",
|
|
sliceCount: 1,
|
|
stepCount: 6,
|
|
maxLuminance: 7
|
|
});
|
|
});
|
|
|
|
it("clamps authored intensity and radius to bounded renderer values", () => {
|
|
const settings =
|
|
createDefaultWorldSettings().advancedRendering.dynamicGlobalIllumination;
|
|
settings.enabled = true;
|
|
settings.intensity = 25;
|
|
settings.radius = 100;
|
|
settings.quality = "medium";
|
|
|
|
expect(resolveDynamicGlobalIlluminationParameters(settings)).toMatchObject({
|
|
enabled: true,
|
|
intensity: 4,
|
|
radius: 8,
|
|
quality: "medium",
|
|
sliceCount: 2,
|
|
stepCount: 8
|
|
});
|
|
});
|
|
|
|
it("disables the pass when the authored intensity is zero", () => {
|
|
const settings =
|
|
createDefaultWorldSettings().advancedRendering.dynamicGlobalIllumination;
|
|
settings.enabled = true;
|
|
settings.intensity = 0;
|
|
|
|
expect(resolveDynamicGlobalIlluminationParameters(settings).enabled).toBe(
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
expect(
|
|
postprocessingState.composerPasses.map(
|
|
(pass) => (pass as { name: string }).name
|
|
)
|
|
).toEqual(["RenderPass", "EffectPass"]);
|
|
expect(
|
|
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(ALL_RENDER_LAYER_MASK);
|
|
expect(postprocessingState.ssaoCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("adds the dynamic GI pass only when dynamic GI is enabled", () => {
|
|
postprocessingState.composerOptions.length = 0;
|
|
postprocessingState.composerPasses.length = 0;
|
|
postprocessingState.normalPassTextures.length = 0;
|
|
postprocessingState.ssaoCalls.length = 0;
|
|
|
|
const settings = createDefaultWorldSettings().advancedRendering;
|
|
settings.enabled = true;
|
|
settings.dynamicGlobalIllumination.enabled = true;
|
|
|
|
createAdvancedRenderingComposer(
|
|
{
|
|
capabilities: {
|
|
isWebGL2: true
|
|
}
|
|
} as unknown as never,
|
|
new Scene(),
|
|
new PerspectiveCamera(),
|
|
settings
|
|
);
|
|
|
|
expect(postprocessingState.normalPassTextures).toHaveLength(1);
|
|
expect(
|
|
postprocessingState.composerPasses.map(
|
|
(pass) => (pass as { name: string }).name
|
|
)
|
|
).toEqual([
|
|
"RenderPass",
|
|
"NormalPass",
|
|
"ScreenSpaceGlobalIlluminationPass",
|
|
"ShaderPass",
|
|
"RenderPass",
|
|
"RenderPass",
|
|
"EffectPass"
|
|
]);
|
|
expect(
|
|
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(AO_WORLD_RENDER_LAYER_MASK);
|
|
expect(
|
|
(postprocessingState.composerPasses[2] as { needsDepthTexture?: boolean })
|
|
.needsDepthTexture
|
|
).toBe(true);
|
|
expect(postprocessingState.ssaoCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("builds a dual-layer SSAO stack from one normal pass", () => {
|
|
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 = 8;
|
|
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.composerPasses.map(
|
|
(pass) => (pass as { name: string }).name
|
|
)
|
|
).toEqual([
|
|
"RenderPass",
|
|
"NormalPass",
|
|
"EffectPass",
|
|
"ShaderPass",
|
|
"RenderPass",
|
|
"RenderPass",
|
|
"EffectPass"
|
|
]);
|
|
expect(
|
|
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(AO_WORLD_RENDER_LAYER_MASK);
|
|
expect(
|
|
(postprocessingState.composerPasses[1] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(AO_WORLD_RENDER_LAYER_MASK);
|
|
expect(
|
|
(postprocessingState.composerPasses[4] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(POST_AO_TRANSPARENT_RENDER_LAYER_MASK);
|
|
expect(
|
|
(postprocessingState.composerPasses[5] as { renderLayerMask?: number })
|
|
.renderLayerMask
|
|
).toBe(OVERLAY_RENDER_LAYER_MASK);
|
|
expect(
|
|
postprocessingState.composerPasses[4] as {
|
|
clear?: boolean;
|
|
ignoreBackground?: boolean;
|
|
skipShadowMapUpdate?: boolean;
|
|
}
|
|
).toMatchObject({
|
|
clear: false,
|
|
ignoreBackground: true,
|
|
skipShadowMapUpdate: true
|
|
});
|
|
expect(postprocessingState.ssaoCalls).toHaveLength(2);
|
|
expect(postprocessingState.ssaoCalls[0]).toMatchObject({
|
|
normalBuffer: postprocessingState.normalPassTextures[0],
|
|
options: {
|
|
depthAwareUpsampling: true,
|
|
luminanceInfluence: 0.15,
|
|
samples: 12,
|
|
radius: 0.2,
|
|
intensity: 0.3825,
|
|
resolutionScale: 0.5
|
|
}
|
|
});
|
|
expect(postprocessingState.ssaoCalls[1]).toMatchObject({
|
|
normalBuffer: postprocessingState.normalPassTextures[0],
|
|
options: {
|
|
depthAwareUpsampling: true,
|
|
luminanceInfluence: 0.15,
|
|
samples: 12,
|
|
intensity: 0.2975,
|
|
resolutionScale: 0.75
|
|
}
|
|
});
|
|
expect(postprocessingState.ssaoCalls[1].options.radius).toBeCloseTo(
|
|
0.07,
|
|
6
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("whitebox bevel materials", () => {
|
|
it("only applies when advanced rendering and the effect are both enabled", () => {
|
|
const settings = createDefaultWorldSettings().advancedRendering;
|
|
|
|
expect(shouldApplyWhiteboxBevel(settings)).toBe(false);
|
|
|
|
settings.enabled = true;
|
|
settings.whiteboxBevel.enabled = true;
|
|
|
|
expect(shouldApplyWhiteboxBevel(settings)).toBe(true);
|
|
});
|
|
|
|
it("injects face-space bevel shading into standard materials", () => {
|
|
const material = new MeshStandardMaterial();
|
|
|
|
applyWhiteboxBevelToMaterial(material, {
|
|
enabled: true,
|
|
edgeWidth: 0.18,
|
|
normalStrength: 0.9
|
|
});
|
|
|
|
const shader = {
|
|
vertexShader: "#include <common>\n#include <uv_vertex>\n",
|
|
fragmentShader: "#include <common>\n#include <normal_fragment_maps>\n"
|
|
};
|
|
|
|
material.onBeforeCompile(shader as never, {} as never);
|
|
|
|
expect(shader.vertexShader).toContain("attribute vec2 faceUv;");
|
|
expect(shader.vertexShader).toContain("vWhiteboxFaceUv = faceUv;");
|
|
expect(shader.fragmentShader).toContain("varying vec2 vWhiteboxFaceUv;");
|
|
expect(shader.fragmentShader).toContain("whiteboxBevelMask");
|
|
expect(material.customProgramCacheKey?.()).toContain("whitebox-bevel:");
|
|
});
|
|
});
|
|
|
|
describe("renderer render layers", () => {
|
|
it("enables all renderer categories on cameras used by direct rendering", () => {
|
|
const camera = new PerspectiveCamera();
|
|
|
|
enableCameraRendererRenderCategories(camera);
|
|
|
|
expect(camera.layers.mask).toBe(ALL_RENDER_LAYER_MASK);
|
|
});
|
|
|
|
it("categorizes opaque renderables separately from transparent effects", () => {
|
|
const group = new Group();
|
|
const opaqueMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshStandardMaterial()
|
|
);
|
|
const transparentMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshBasicMaterial({
|
|
transparent: true,
|
|
opacity: 0.35
|
|
})
|
|
);
|
|
|
|
group.add(opaqueMesh);
|
|
group.add(transparentMesh);
|
|
|
|
applyRendererRenderCategoryFromMaterial(group);
|
|
|
|
expect(opaqueMesh.layers.mask).toBe(AO_WORLD_RENDER_LAYER_MASK);
|
|
expect(transparentMesh.layers.mask).toBe(
|
|
POST_AO_TRANSPARENT_RENDER_LAYER_MASK
|
|
);
|
|
});
|
|
|
|
it("marks helper subtrees as overlay-only renderables", () => {
|
|
const helperGroup = new Group();
|
|
const helperMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshBasicMaterial()
|
|
);
|
|
|
|
helperGroup.add(helperMesh);
|
|
applyRendererRenderCategory(helperGroup, "overlay");
|
|
|
|
expect(helperGroup.layers.mask).toBe(OVERLAY_RENDER_LAYER_MASK);
|
|
expect(helperMesh.layers.mask).toBe(OVERLAY_RENDER_LAYER_MASK);
|
|
});
|
|
});
|
|
|
|
describe("advanced rendering shadow flags", () => {
|
|
it("only enables shadows for opaque renderable meshes", () => {
|
|
const group = new Group();
|
|
const opaqueMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshStandardMaterial()
|
|
);
|
|
const transparentMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshStandardMaterial({
|
|
transparent: true,
|
|
opacity: 0.4
|
|
})
|
|
);
|
|
const ignoredMesh = new Mesh(
|
|
new BoxGeometry(1, 1, 1),
|
|
new MeshStandardMaterial()
|
|
);
|
|
ignoredMesh.userData.shadowIgnored = true;
|
|
|
|
group.add(opaqueMesh);
|
|
group.add(transparentMesh);
|
|
group.add(ignoredMesh);
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(group, true);
|
|
|
|
expect(opaqueMesh.castShadow).toBe(true);
|
|
expect(opaqueMesh.receiveShadow).toBe(true);
|
|
expect(transparentMesh.castShadow).toBe(false);
|
|
expect(transparentMesh.receiveShadow).toBe(false);
|
|
expect(ignoredMesh.castShadow).toBe(false);
|
|
expect(ignoredMesh.receiveShadow).toBe(false);
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(group, false);
|
|
|
|
expect(opaqueMesh.castShadow).toBe(false);
|
|
expect(opaqueMesh.receiveShadow).toBe(false);
|
|
});
|
|
});
|