feat: layer the nightdrive sky and atmosphere

This commit is contained in:
2026-09-02 14:52:03 +03:00
parent 3c932cb840
commit 5c52de9134
15 changed files with 801 additions and 168 deletions

View File

@@ -22,6 +22,13 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra
page,
distUrl,
}) => {
const graphicsErrors: string[] = [];
page.on('console', (message) => {
if (
message.type() === 'error'
&& /THREE\.WebGLProgram|Shader Error|VALIDATE_STATUS/iu.test(message.text())
) graphicsErrors.push(message.text());
});
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u);
const chapterColors: string[] = [];
@@ -60,16 +67,13 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra
const instrumentation = await readSceneInstrumentation(page);
expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1);
const expectedChapterColors = (page.viewportSize()?.width ?? 0) < 768
? [
'#040713', '#040713', '#060918', '#080b1d',
'#0d1025', '#0d1025', '#18152c', '#2b2035',
]
: [
'#000102', '#000102', '#000102', '#010103',
'#010105', '#010105', '#020206', '#060409',
];
expect(chapterColors).toEqual(expectedChapterColors);
const chapterEnergy = chapterColors.map(colorEnergy);
for (let index = 1; index < chapterEnergy.length; index += 1) {
expect(chapterEnergy[index]).toBeGreaterThanOrEqual(chapterEnergy[index - 1] ?? 0);
}
expect(chapterEnergy.at(-1)).toBeGreaterThan(chapterEnergy[0] ?? 0);
expect(new Set(chapterColors).size).toBeGreaterThanOrEqual(4);
expect(graphicsErrors).toEqual([]);
});
test('draws only the independently eligible next roadside sign during passive forward travel', async ({
@@ -99,3 +103,8 @@ test('draws only the independently eligible next roadside sign during passive fo
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
});
function colorEnergy(color: string): number {
const value = Number.parseInt(color.slice(1), 16);
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
}

View File

@@ -160,8 +160,9 @@ describe('scene render quality', () => {
roadSegments: 192,
terrainSegments: 96,
terrainOctaves: 3,
particleCount: 384,
starCount: 384,
streakCount: 48,
postScale: 1,
postEffects: true,
});
expect(medium).toEqual({
@@ -169,8 +170,9 @@ describe('scene render quality', () => {
roadSegments: 144,
terrainSegments: 64,
terrainOctaves: 2,
particleCount: 192,
starCount: 192,
streakCount: 28,
postScale: 0.7,
postEffects: true,
});
expect(low).toEqual({
@@ -178,8 +180,9 @@ describe('scene render quality', () => {
roadSegments: 96,
terrainSegments: 32,
terrainOctaves: 1,
particleCount: 64,
starCount: 64,
streakCount: 12,
postScale: 0,
postEffects: false,
});
});

View File

@@ -100,7 +100,7 @@ describe('scroll drive controller', () => {
expect(snapshot.driveSpeed).toBeCloseTo(0.9490152503018645, 14);
});
it('resets transient velocity and establishes a baseline after a gap over 250 ms', () => {
it('resets transient velocity and establishes a baseline after a stationary gap over 250 ms', () => {
const controller = createScrollDriveController();
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
controller.sample({ scrollY: 500, now: 100, maxScrollY: 1000 });
@@ -115,6 +115,19 @@ describe('scroll drive controller', () => {
expect(controller.sample({ scrollY: 600, now: 451, maxScrollY: 1000 }).direction).toBe(1);
});
it('preserves signed movement while dropping acceleration on the first delayed sample', () => {
const controller = createScrollDriveController();
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
expect(controller.sample({ scrollY: 500, now: 351, maxScrollY: 1000 })).toEqual({
scrollY: 500,
routeProgress: 0.5,
direction: 1,
acceleration: 0,
driveSpeed: 0.18,
});
});
it.each([
['downward', 0, 1000, 1],
['upward', 1000, 0, -1],

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import { renderQualityFor } from '@/scene/render-quality';
@@ -10,6 +10,9 @@ import {
} from '@/scene/world/palette';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
describe('procedural world models', () => {
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
@@ -132,6 +135,99 @@ describe('procedural world models', () => {
expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth);
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth);
});
it('renders the sun as a scan-banded disc with a separately owned halo', () => {
const resources = new SceneResourceBag();
const sun = createSun(resources);
const disc = sun.group.getObjectByName('sun-disc') as ShaderRenderable;
const halo = sun.group.getObjectByName('sun-halo');
expect(disc).toBeInstanceOf(THREE.Mesh);
expect(halo).toBeInstanceOf(THREE.Sprite);
expect(disc.material.uniforms).toMatchObject({
uScanBands: { value: 14 },
uWarmth: { value: CHAPTER_PALETTES['deep-night'].warmth },
});
expect(disc.material.fragmentShader).toContain('scanBand');
expect(resources.snapshot().owned.texture).toBe(1);
const palette = createMutableChapterPalette('destination');
sun.update(palette, 1);
expect(disc.material.uniforms.uWarmth?.value).toBe(1);
expect(disc.material.uniforms.uRouteProgress?.value).toBe(1);
resources.dispose();
});
it('builds deterministic stars, haze layers, and quality-capped speed streaks', () => {
const highResources = new SceneResourceBag();
const lowResources = new SceneResourceBag();
const highQuality = renderQualityFor('high', 1, false);
const lowQuality = renderQualityFor('low', 1, true);
const high = createAtmosphere(highResources, highQuality);
const low = createAtmosphere(lowResources, lowQuality);
const highStars = high.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
const lowStars = low.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
const highStreaks = high.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
const lowStreaks = low.group.getObjectByName('atmosphere-streaks') as ShaderRenderable;
expect(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount);
expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.starCount);
expect(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2);
expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2);
expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh);
expect(high.group.getObjectByName('horizon-haze-far')).toBeInstanceOf(THREE.Mesh);
const highStarPositions = Array.from(highStars.geometry.getAttribute('position').array);
const secondResources = new SceneResourceBag();
const secondHigh = createAtmosphere(secondResources, highQuality);
const secondStars = secondHigh.group.getObjectByName('atmosphere-stars') as ShaderRenderable;
expect(Array.from(secondStars.geometry.getAttribute('position').array)).toEqual(highStarPositions);
const palette = createMutableChapterPalette('builds');
const before = highResources.snapshot();
for (let index = 0; index < 300; index += 1) {
high.update(index, 0.7, 0.8, palette);
}
expect(highResources.snapshot()).toEqual(before);
expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8);
expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0);
highResources.dispose();
lowResources.dispose();
secondResources.dispose();
});
it('disposes a composer and its passes exactly once', () => {
const resources = new SceneResourceBag();
const composer = {
addPass: vi.fn(),
setPixelRatio: vi.fn(),
setSize: vi.fn(),
render: vi.fn(),
dispose: vi.fn(),
};
const renderPass = { scene: null, camera: null, setSize: vi.fn(), dispose: vi.fn() };
const bloomPass = { setSize: vi.fn(), dispose: vi.fn() };
const processor = createPostProcessor(resources, {}, {
createComposer: () => composer,
createRenderPass: () => renderPass,
createBloomPass: () => bloomPass,
});
processor.resize(1_200, 800, 1.25);
processor.render({}, {});
processor.dispose();
processor.dispose();
resources.dispose();
expect(composer.addPass).toHaveBeenCalledTimes(2);
expect(composer.setPixelRatio).toHaveBeenCalledWith(1.25);
expect(composer.setSize).toHaveBeenCalledWith(1_200, 800);
expect(composer.render).toHaveBeenCalledOnce();
expect(renderPass.dispose).toHaveBeenCalledOnce();
expect(bloomPass.dispose).toHaveBeenCalledOnce();
expect(composer.dispose).toHaveBeenCalledOnce();
});
});
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {