diff --git a/src/app/App.tsx b/src/app/App.tsx index c4445111..54f75585 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -196,7 +196,6 @@ import { type WorldBackgroundSettings, type AdvancedRenderingSettings, type AdvancedRenderingDistanceFogSettings, - type AdvancedRenderingGodRaysSettings, type BoxVolumeRenderPath, type AdvancedRenderingWaterReflectionMode, type AdvancedRenderingDynamicGlobalIlluminationQuality, diff --git a/tests/domain/advanced-rendering.test.ts b/tests/domain/advanced-rendering.test.ts index 53bd3d08..ece2b531 100644 --- a/tests/domain/advanced-rendering.test.ts +++ b/tests/domain/advanced-rendering.test.ts @@ -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; diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts index c394dd41..1d6a9232 100644 --- a/tests/domain/scene-document-validation.test.ts +++ b/tests/domain/scene-document-validation.test.ts @@ -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" diff --git a/tests/domain/world-settings.test.ts b/tests/domain/world-settings.test.ts index c0330792..8fb26a37 100644 --- a/tests/domain/world-settings.test.ts +++ b/tests/domain/world-settings.test.ts @@ -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); diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index 286c8214..82e9dc5b 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -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,