Implement and test God Rays advanced rendering feature

This commit is contained in:
2026-04-28 04:40:30 +02:00
parent 2ce85ff1a6
commit e91a724b41
5 changed files with 289 additions and 1 deletions

View File

@@ -155,6 +155,12 @@ import {
resolveAdvancedRenderingPerspectiveCameraFar,
resolveDistanceFogParameters
} from "../../src/rendering/distance-fog-pass";
import {
createScreenSpaceGodRaysLightSource,
projectScreenSpaceGodRaysLight,
resolveGodRaysParameters,
syncScreenSpaceGodRaysLightSource
} from "../../src/rendering/screen-space-god-rays";
import { resolveDynamicGlobalIlluminationParameters } from "../../src/rendering/screen-space-global-illumination";
import {
ALL_RENDER_LAYER_MASK,
@@ -301,6 +307,105 @@ describe("distance fog parameters", () => {
});
});
describe("god rays parameters", () => {
it("keeps the pass disabled by default", () => {
const settings = createDefaultWorldSettings().advancedRendering;
expect(resolveGodRaysParameters(settings.godRays)).toMatchObject({
enabled: false
});
});
it("resolves bounded screen-space shaft parameters", () => {
const settings = createDefaultWorldSettings().advancedRendering.godRays;
settings.enabled = true;
settings.intensity = 12;
settings.decay = 1.5;
settings.exposure = 6;
settings.density = 3;
settings.samples = 999;
expect(resolveGodRaysParameters(settings)).toEqual({
enabled: true,
intensity: 3,
decay: 1,
exposure: 2,
density: 1.5,
samples: 64
});
});
it("syncs the active celestial light source", () => {
const lightSource = createScreenSpaceGodRaysLightSource();
syncScreenSpaceGodRaysLightSource(lightSource, {
colorHex: "#ffd8aa",
intensity: 1.6,
direction: {
x: 0.1,
y: 0.8,
z: -0.2
}
});
expect(lightSource).toEqual({
colorHex: "#ffd8aa",
intensity: 1.6,
direction: {
x: 0.1,
y: 0.8,
z: -0.2
}
});
syncScreenSpaceGodRaysLightSource(lightSource, {
colorHex: "#ffd8aa",
intensity: 0,
direction: {
x: 0.1,
y: 0.8,
z: -0.2
}
});
expect(lightSource).toEqual({
colorHex: "#ffffff",
intensity: 0,
direction: null
});
});
it("projects the celestial light direction and rejects behind-camera lights", () => {
const camera = new PerspectiveCamera(60, 1, 0.1, 1000);
const projection = projectScreenSpaceGodRaysLight(camera, {
colorHex: "#ffffff",
intensity: 1,
direction: {
x: 0,
y: 0,
z: -1
}
});
expect(projection?.screenPosition.x).toBeCloseTo(0.5, 6);
expect(projection?.screenPosition.y).toBeCloseTo(0.5, 6);
expect(projection?.visibility).toBeCloseTo(1, 6);
expect(
projectScreenSpaceGodRaysLight(camera, {
colorHex: "#ffffff",
intensity: 1,
direction: {
x: 0,
y: 0,
z: 1
}
})
).toBeNull();
});
});
describe("createAdvancedRenderingComposer", () => {
it("keeps depth buffering enabled when the post stack only uses color effects", () => {
postprocessingState.composerOptions.length = 0;
@@ -391,6 +496,100 @@ describe("createAdvancedRenderingComposer", () => {
).toBe(OVERLAY_RENDER_LAYER_MASK);
});
it("adds god rays before post-world overlay layers when a celestial light source is available", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
postprocessingState.normalPassTextures.length = 0;
postprocessingState.ssaoCalls.length = 0;
const settings = createDefaultWorldSettings().advancedRendering;
const lightSource = createScreenSpaceGodRaysLightSource();
settings.enabled = true;
settings.godRays.enabled = true;
syncScreenSpaceGodRaysLightSource(lightSource, {
colorHex: "#fff3cc",
intensity: 1,
direction: {
x: 0,
y: 0.25,
z: -1
}
});
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings,
null,
lightSource
);
expect(
postprocessingState.composerPasses.map(
(pass) => (pass as { name: string }).name
)
).toEqual([
"RenderPass",
"ScreenSpaceGodRaysPass",
"RenderPass",
"RenderPass",
"EffectPass"
]);
expect(
(postprocessingState.composerPasses[0] as { renderLayerMask?: number })
.renderLayerMask
).toBe(AO_WORLD_RENDER_LAYER_MASK);
expect(
(postprocessingState.composerPasses[1] as { needsDepthTexture?: boolean })
.needsDepthTexture
).toBe(true);
expect(
(postprocessingState.composerPasses[2] as { renderLayerMask?: number })
.renderLayerMask
).toBe(POST_AO_TRANSPARENT_RENDER_LAYER_MASK);
expect(
(postprocessingState.composerPasses[3] as { renderLayerMask?: number })
.renderLayerMask
).toBe(OVERLAY_RENDER_LAYER_MASK);
});
it("does not add god rays when the feature is enabled without a light source", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;
postprocessingState.normalPassTextures.length = 0;
postprocessingState.ssaoCalls.length = 0;
const settings = createDefaultWorldSettings().advancedRendering;
settings.enabled = true;
settings.godRays.enabled = true;
createAdvancedRenderingComposer(
{
capabilities: {
isWebGL2: true
}
} as unknown as never,
new Scene(),
new PerspectiveCamera(),
settings
);
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);
});
it("adds the dynamic GI pass only when dynamic GI is enabled", () => {
postprocessingState.composerOptions.length = 0;
postprocessingState.composerPasses.length = 0;

View File

@@ -1677,6 +1677,15 @@ describe("validateSceneDocument", () => {
farDistance: 0,
strength: 1.5,
renderDistance: -2
},
godRays: {
...document.world.advancedRendering.godRays,
enabled: "yes",
intensity: -0.1,
decay: 1.5,
exposure: -0.2,
density: Number.NaN,
samples: 0
}
} as any;
@@ -1776,6 +1785,30 @@ describe("validateSceneDocument", () => {
code: "invalid-advanced-rendering-distance-fog-render-distance",
path: "world.advancedRendering.distanceFog.renderDistance"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-enabled",
path: "world.advancedRendering.godRays.enabled"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-intensity",
path: "world.advancedRendering.godRays.intensity"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-decay",
path: "world.advancedRendering.godRays.decay"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-exposure",
path: "world.advancedRendering.godRays.exposure"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-density",
path: "world.advancedRendering.godRays.density"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-god-rays-samples",
path: "world.advancedRendering.godRays.samples"
}),
expect.objectContaining({
code: "invalid-advanced-rendering-fog-path",
path: "world.advancedRendering.fogPath"

View File

@@ -35,6 +35,12 @@ describe("world settings helpers", () => {
expect(clone.advancedRendering.whiteboxBevel).not.toBe(
source.advancedRendering.whiteboxBevel
);
expect(clone.advancedRendering.distanceFog).not.toBe(
source.advancedRendering.distanceFog
);
expect(clone.advancedRendering.godRays).not.toBe(
source.advancedRendering.godRays
);
});
it("switches a solid background into a gradient while preserving the authored color as the top edge", () => {
@@ -168,6 +174,15 @@ describe("world settings helpers", () => {
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("treats god rays settings as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);
right.advancedRendering.godRays.enabled = true;
expect(areWorldSettingsEqual(left, right)).toBe(false);
});
it("treats the scene project-time lighting toggle as part of authored world equality", () => {
const left = createDefaultWorldSettings();
const right = cloneWorldSettings(left);

View File

@@ -24,6 +24,7 @@ import {
CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION,
CELESTIAL_BODY_OVERLAY_SCENE_DOCUMENT_VERSION,
DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION,
DISTANCE_FOG_SCENE_DOCUMENT_VERSION,
DYNAMIC_GLOBAL_ILLUMINATION_SCENE_DOCUMENT_VERSION,
FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION,
ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
@@ -814,6 +815,14 @@ describe("scene document JSON", () => {
strength: 0.72,
renderDistance: 160
},
godRays: {
enabled: true,
intensity: 0.55,
decay: 0.9,
exposure: 0.35,
density: 0.8,
samples: 40
},
fogPath: "quality",
waterPath: "performance",
waterReflectionMode: "world",
@@ -927,6 +936,39 @@ describe("scene document JSON", () => {
);
});
it("migrates v86 scene documents without god rays settings to defaults", () => {
const emptyScene = createEmptySceneDocument({
name: "Legacy God Rays Scene"
});
const { godRays: _godRays, ...legacyAdvancedRendering } =
emptyScene.world.advancedRendering;
const migratedDocument = migrateSceneDocument({
version: DISTANCE_FOG_SCENE_DOCUMENT_VERSION,
name: emptyScene.name,
time: emptyScene.time,
scheduler: emptyScene.scheduler,
world: {
...emptyScene.world,
advancedRendering: legacyAdvancedRendering
},
materials: emptyScene.materials,
textures: emptyScene.textures,
assets: emptyScene.assets,
brushes: emptyScene.brushes,
terrains: emptyScene.terrains,
paths: emptyScene.paths,
modelInstances: emptyScene.modelInstances,
entities: emptyScene.entities,
interactionLinks: emptyScene.interactionLinks
});
expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION);
expect(migratedDocument.world.advancedRendering.godRays).toEqual(
emptyScene.world.advancedRendering.godRays
);
});
it("defaults missing water reflection mode and clamps legacy foam limits during migration", () => {
const migratedDocument = migrateSceneDocument({
version: SCENE_DOCUMENT_VERSION,