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

@@ -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>) {