feat: layer the nightdrive sky and atmosphere
This commit is contained in:
@@ -74,13 +74,14 @@ export function createScrollDriveController(): ScrollDriveController {
|
|||||||
return cruiseSnapshot(input);
|
return cruiseSnapshot(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deltaY = input.scrollY - previousSample.scrollY;
|
||||||
|
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
||||||
if (elapsedMs > BACKGROUND_GAP_MS) {
|
if (elapsedMs > BACKGROUND_GAP_MS) {
|
||||||
previousSample = { ...input };
|
previousSample = { ...input };
|
||||||
smoothedAcceleration = 0;
|
smoothedAcceleration = 0;
|
||||||
return cruiseSnapshot(input);
|
return { ...cruiseSnapshot(input), direction };
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaY = input.scrollY - previousSample.scrollY;
|
|
||||||
const rawSpeed = Math.abs(deltaY) / elapsedMs;
|
const rawSpeed = Math.abs(deltaY) / elapsedMs;
|
||||||
const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1);
|
const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1);
|
||||||
const timeConstantMs = impulse > smoothedAcceleration
|
const timeConstantMs = impulse > smoothedAcceleration
|
||||||
@@ -93,7 +94,6 @@ export function createScrollDriveController(): ScrollDriveController {
|
|||||||
}
|
}
|
||||||
previousSample = { ...input };
|
previousSample = { ...input };
|
||||||
|
|
||||||
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
|
||||||
return {
|
return {
|
||||||
scrollY: input.scrollY,
|
scrollY: input.scrollY,
|
||||||
routeProgress: routeProgress(input),
|
routeProgress: routeProgress(input),
|
||||||
|
|||||||
92
src/scene/post-processing.ts
Normal file
92
src/scene/post-processing.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
|
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||||
|
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
|
export interface PostProcessor {
|
||||||
|
resize(width: number, height: number, pixelRatio: number): void;
|
||||||
|
render(scene: unknown, camera: unknown): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerLike {
|
||||||
|
addPass(pass: unknown): void;
|
||||||
|
setPixelRatio(pixelRatio: number): void;
|
||||||
|
setSize(width: number, height: number): void;
|
||||||
|
render(): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderPassLike {
|
||||||
|
scene: unknown;
|
||||||
|
camera: unknown;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BloomPassLike {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostProcessingFactories {
|
||||||
|
createComposer(renderer: unknown): ComposerLike;
|
||||||
|
createRenderPass(scene: unknown, camera: unknown): RenderPassLike;
|
||||||
|
createBloomPass(): BloomPassLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionFactories: PostProcessingFactories = {
|
||||||
|
createComposer: (renderer) => new EffectComposer(renderer),
|
||||||
|
createRenderPass: (scene, camera) => new RenderPass(scene, camera),
|
||||||
|
createBloomPass: () => new UnrealBloomPass(
|
||||||
|
new THREE.Vector2(1, 1),
|
||||||
|
0.46,
|
||||||
|
0.32,
|
||||||
|
0.84,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPostProcessor(
|
||||||
|
resources: SceneResourceBag,
|
||||||
|
renderer: unknown,
|
||||||
|
factories: PostProcessingFactories = productionFactories,
|
||||||
|
): PostProcessor {
|
||||||
|
const composer = factories.createComposer(renderer);
|
||||||
|
const renderPass = factories.createRenderPass(new THREE.Scene(), new THREE.Camera());
|
||||||
|
const bloomPass = factories.createBloomPass();
|
||||||
|
composer.addPass(renderPass);
|
||||||
|
composer.addPass(bloomPass);
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
const processor: PostProcessor = {
|
||||||
|
resize(width, height, pixelRatio) {
|
||||||
|
if (disposed) return;
|
||||||
|
composer.setPixelRatio(clamp(pixelRatio, 0.5, 2));
|
||||||
|
composer.setSize(
|
||||||
|
Math.max(1, Math.floor(finiteOr(width, 1))),
|
||||||
|
Math.max(1, Math.floor(finiteOr(height, 1))),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
render(scene, camera) {
|
||||||
|
if (disposed) return;
|
||||||
|
renderPass.scene = scene;
|
||||||
|
renderPass.camera = camera;
|
||||||
|
composer.render();
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
renderPass.dispose();
|
||||||
|
bloomPass.dispose();
|
||||||
|
composer.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return resources.own('pass', processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteOr(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return THREE.MathUtils.clamp(finiteOr(value, minimum), minimum, maximum);
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ export interface RenderQualitySettings {
|
|||||||
roadSegments: number;
|
roadSegments: number;
|
||||||
terrainSegments: number;
|
terrainSegments: number;
|
||||||
terrainOctaves: 1 | 2 | 3;
|
terrainOctaves: 1 | 2 | 3;
|
||||||
particleCount: number;
|
starCount: number;
|
||||||
streakCount: number;
|
streakCount: number;
|
||||||
|
postScale: number;
|
||||||
postEffects: boolean;
|
postEffects: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +17,9 @@ const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pi
|
|||||||
roadSegments: 192,
|
roadSegments: 192,
|
||||||
terrainSegments: 96,
|
terrainSegments: 96,
|
||||||
terrainOctaves: 3,
|
terrainOctaves: 3,
|
||||||
particleCount: 384,
|
starCount: 384,
|
||||||
streakCount: 48,
|
streakCount: 48,
|
||||||
|
postScale: 1,
|
||||||
postEffects: true,
|
postEffects: true,
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
@@ -25,8 +27,9 @@ const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pi
|
|||||||
roadSegments: 144,
|
roadSegments: 144,
|
||||||
terrainSegments: 64,
|
terrainSegments: 64,
|
||||||
terrainOctaves: 2,
|
terrainOctaves: 2,
|
||||||
particleCount: 192,
|
starCount: 192,
|
||||||
streakCount: 28,
|
streakCount: 28,
|
||||||
|
postScale: 0.7,
|
||||||
postEffects: true,
|
postEffects: true,
|
||||||
},
|
},
|
||||||
low: {
|
low: {
|
||||||
@@ -34,8 +37,9 @@ const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pi
|
|||||||
roadSegments: 96,
|
roadSegments: 96,
|
||||||
terrainSegments: 32,
|
terrainSegments: 32,
|
||||||
terrainOctaves: 1,
|
terrainOctaves: 1,
|
||||||
particleCount: 64,
|
starCount: 64,
|
||||||
streakCount: 12,
|
streakCount: 12,
|
||||||
|
postScale: 0,
|
||||||
postEffects: false,
|
postEffects: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -52,8 +56,9 @@ export function renderQualityFor(
|
|||||||
roadSegments: selected.roadSegments,
|
roadSegments: selected.roadSegments,
|
||||||
terrainSegments: selected.terrainSegments,
|
terrainSegments: selected.terrainSegments,
|
||||||
terrainOctaves: selected.terrainOctaves,
|
terrainOctaves: selected.terrainOctaves,
|
||||||
particleCount: selected.particleCount,
|
starCount: selected.starCount,
|
||||||
streakCount: selected.streakCount,
|
streakCount: selected.streakCount,
|
||||||
|
postScale: selected.postScale,
|
||||||
postEffects: selected.postEffects,
|
postEffects: selected.postEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/scene/three.d.ts
vendored
3
src/scene/three.d.ts
vendored
@@ -2,3 +2,6 @@
|
|||||||
// The scene remains isolated behind typed application interfaces; this ambient declaration
|
// The scene remains isolated behind typed application interfaces; this ambient declaration
|
||||||
// prevents the untyped renderer dependency from leaking into Vue or journey modules.
|
// prevents the untyped renderer dependency from leaking into Vue or journey modules.
|
||||||
declare module 'three';
|
declare module 'three';
|
||||||
|
declare module 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||||
|
declare module 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||||
|
declare module 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||||
|
|||||||
273
src/scene/world/atmosphere.ts
Normal file
273
src/scene/world/atmosphere.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||||
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||||
|
|
||||||
|
export interface AtmosphereWorld {
|
||||||
|
group: WorldGroup;
|
||||||
|
update(
|
||||||
|
distance: number,
|
||||||
|
speed: number,
|
||||||
|
acceleration: number,
|
||||||
|
palette: Readonly<MutableChapterPalette>,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorldGroup {
|
||||||
|
add(...objects: unknown[]): void;
|
||||||
|
getObjectByName(name: string): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAtmosphere(
|
||||||
|
resources: SceneResourceBag,
|
||||||
|
quality: RenderQualitySettings,
|
||||||
|
): AtmosphereWorld {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'atmosphere-world';
|
||||||
|
const night = CHAPTER_PALETTES['deep-night'];
|
||||||
|
|
||||||
|
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(520, 280));
|
||||||
|
const skyMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uSkyColor: { value: new THREE.Color(night.sky) },
|
||||||
|
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||||
|
uWarmth: { value: night.warmth },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vSkyUv;
|
||||||
|
void main() {
|
||||||
|
vSkyUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uSkyColor;
|
||||||
|
uniform vec3 uHazeColor;
|
||||||
|
uniform float uWarmth;
|
||||||
|
varying vec2 vSkyUv;
|
||||||
|
void main() {
|
||||||
|
float horizon = 1.0 - smoothstep(0.08, 0.72, vSkyUv.y);
|
||||||
|
float upperNight = smoothstep(0.35, 1.0, vSkyUv.y);
|
||||||
|
vec3 color = mix(uSkyColor, uHazeColor, horizon * (0.38 + uWarmth * 0.2));
|
||||||
|
color *= mix(1.04, 0.72, upperNight);
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: false,
|
||||||
|
fog: false,
|
||||||
|
toneMapped: true,
|
||||||
|
}));
|
||||||
|
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
|
||||||
|
sky.name = 'atmosphere-sky';
|
||||||
|
sky.position.set(0, 69, -300);
|
||||||
|
sky.renderOrder = -100;
|
||||||
|
group.add(sky);
|
||||||
|
|
||||||
|
const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount));
|
||||||
|
const starMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uVisibility: { value: night.starVisibility },
|
||||||
|
uDistance: { value: 0 },
|
||||||
|
uColor: { value: new THREE.Color(0xb9ecf2) },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
attribute float starSize;
|
||||||
|
attribute float brightness;
|
||||||
|
uniform float uDistance;
|
||||||
|
varying float vBrightness;
|
||||||
|
void main() {
|
||||||
|
vec3 star = position;
|
||||||
|
star.x += sin(position.z * 0.017 + uDistance * 0.004) * 0.08;
|
||||||
|
vec4 viewPosition = modelViewMatrix * vec4(star, 1.0);
|
||||||
|
vBrightness = brightness;
|
||||||
|
gl_PointSize = starSize * (250.0 / max(1.0, -viewPosition.z));
|
||||||
|
gl_Position = projectionMatrix * viewPosition;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uVisibility;
|
||||||
|
varying float vBrightness;
|
||||||
|
void main() {
|
||||||
|
float radius = distance(gl_PointCoord, vec2(0.5));
|
||||||
|
float point = 1.0 - smoothstep(0.2, 0.5, radius);
|
||||||
|
gl_FragColor = vec4(uColor, point * vBrightness * uVisibility);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
toneMapped: true,
|
||||||
|
}));
|
||||||
|
const stars = new THREE.Points(starGeometry, starMaterial);
|
||||||
|
stars.name = 'atmosphere-stars';
|
||||||
|
stars.frustumCulled = false;
|
||||||
|
group.add(stars);
|
||||||
|
|
||||||
|
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(260, 58));
|
||||||
|
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.2));
|
||||||
|
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.12));
|
||||||
|
const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial);
|
||||||
|
farHaze.name = 'horizon-haze-far';
|
||||||
|
farHaze.position.set(0, 10, -214);
|
||||||
|
farHaze.scale.set(1.35, 1.1, 1);
|
||||||
|
group.add(farHaze);
|
||||||
|
const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial);
|
||||||
|
nearHaze.name = 'horizon-haze-near';
|
||||||
|
nearHaze.position.set(0, 8, -132);
|
||||||
|
group.add(nearHaze);
|
||||||
|
|
||||||
|
const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount));
|
||||||
|
const streakMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uDistance: { value: 0 },
|
||||||
|
uAcceleration: { value: 0 },
|
||||||
|
uVisibility: { value: 0 },
|
||||||
|
uColor: { value: new THREE.Color(0x79dae5) },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
attribute float endFactor;
|
||||||
|
uniform float uDistance;
|
||||||
|
uniform float uAcceleration;
|
||||||
|
varying float vTail;
|
||||||
|
void main() {
|
||||||
|
vec3 streak = position;
|
||||||
|
float depthCycle = mod(streak.z + uDistance * 1.4 + 280.0, 286.0) - 280.0;
|
||||||
|
streak.z = depthCycle + endFactor * (1.2 + uAcceleration * 18.0);
|
||||||
|
vTail = endFactor;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uVisibility;
|
||||||
|
varying float vTail;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(uColor, uVisibility * mix(0.15, 0.72, vTail));
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
toneMapped: true,
|
||||||
|
}));
|
||||||
|
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
|
||||||
|
streaks.name = 'atmosphere-streaks';
|
||||||
|
streaks.frustumCulled = false;
|
||||||
|
group.add(streaks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(distance, speed, acceleration, palette) {
|
||||||
|
const safeAcceleration = clamp01(acceleration);
|
||||||
|
const visibility = smoothstep(0.18, 0.82, safeAcceleration) * palette.streakEnergy;
|
||||||
|
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||||
|
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
|
||||||
|
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance) * (0.72 + clamp01(speed) * 0.28);
|
||||||
|
streakMaterial.uniforms.uAcceleration.value = safeAcceleration;
|
||||||
|
streakMaterial.uniforms.uVisibility.value = visibility;
|
||||||
|
skyMaterial.uniforms.uSkyColor.value.copy(palette.sky);
|
||||||
|
skyMaterial.uniforms.uHazeColor.value.copy(palette.haze);
|
||||||
|
skyMaterial.uniforms.uWarmth.value = palette.warmth;
|
||||||
|
nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
|
||||||
|
farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
|
||||||
|
nearHazeMaterial.uniforms.uEnergy.value = 0.16 + palette.warmth * 0.08;
|
||||||
|
farHazeMaterial.uniforms.uEnergy.value = 0.09 + palette.warmth * 0.06;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStarGeometry(count: number) {
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const sizes = new Float32Array(count);
|
||||||
|
const brightness = new Float32Array(count);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const offset = index * 3;
|
||||||
|
positions[offset] = seeded(index, 11) * 210 - 105;
|
||||||
|
positions[offset + 1] = 8 + seeded(index, 23) * 72;
|
||||||
|
positions[offset + 2] = -18 - seeded(index, 37) * 270;
|
||||||
|
sizes[index] = 2.4 + seeded(index, 53) * 4.8;
|
||||||
|
brightness[index] = 0.28 + seeded(index, 71) * 0.72;
|
||||||
|
}
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
|
||||||
|
geometry.setAttribute('brightness', new THREE.BufferAttribute(brightness, 1));
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStreakGeometry(count: number) {
|
||||||
|
const positions = new Float32Array(count * 2 * 3);
|
||||||
|
const ends = new Float32Array(count * 2);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (12 + seeded(index, 103) * 52);
|
||||||
|
const y = 0.8 + seeded(index, 107) * 12;
|
||||||
|
const z = -8 - seeded(index, 109) * 272;
|
||||||
|
const offset = index * 6;
|
||||||
|
positions[offset] = x;
|
||||||
|
positions[offset + 1] = y;
|
||||||
|
positions[offset + 2] = z;
|
||||||
|
positions[offset + 3] = x;
|
||||||
|
positions[offset + 4] = y + seeded(index, 113) * 0.8;
|
||||||
|
positions[offset + 5] = z;
|
||||||
|
ends[index * 2] = 0;
|
||||||
|
ends[index * 2 + 1] = 1;
|
||||||
|
}
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('endFactor', new THREE.BufferAttribute(ends, 1));
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHazeMaterial(color: number, energy: number) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uColor: { value: new THREE.Color(color) },
|
||||||
|
uEnergy: { value: energy },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vHazeUv;
|
||||||
|
void main() {
|
||||||
|
vHazeUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uEnergy;
|
||||||
|
varying vec2 vHazeUv;
|
||||||
|
void main() {
|
||||||
|
float vertical = 1.0 - smoothstep(0.04, 0.84, abs(vHazeUv.y - 0.42) * 1.9);
|
||||||
|
float horizontal = 1.0 - smoothstep(0.26, 0.5, abs(vHazeUv.x - 0.5));
|
||||||
|
gl_FragColor = vec4(uColor, vertical * horizontal * uEnergy);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
toneMapped: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seeded(index: number, salt: number): number {
|
||||||
|
const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453;
|
||||||
|
return result - Math.floor(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteOrZero(value: number): number {
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, value: number): number {
|
||||||
|
const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
||||||
|
return progress * progress * (3 - 2 * progress);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { EnvironmentChapter, JourneySnapshot } from '@/journey/types';
|
import type { JourneySnapshot } from '@/journey/types';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||||
import { renderQualityFor } from '@/scene/render-quality';
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
@@ -8,21 +8,18 @@ import { createTerrain } from '@/scene/world/terrain';
|
|||||||
import { createSun } from '@/scene/world/sun';
|
import { createSun } from '@/scene/world/sun';
|
||||||
import { createCar } from '@/scene/world/car';
|
import { createCar } from '@/scene/world/car';
|
||||||
import { createSign } from '@/scene/world/sign';
|
import { createSign } from '@/scene/world/sign';
|
||||||
import { createParticles } from '@/scene/world/particles';
|
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||||
|
import { createPostProcessor } from '@/scene/post-processing';
|
||||||
|
import {
|
||||||
|
createMutableChapterPalette,
|
||||||
|
writeChapterPalette,
|
||||||
|
type MutableChapterPalette,
|
||||||
|
} from '@/scene/world/palette';
|
||||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
|
||||||
const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
|
|
||||||
'deep-night': 0x040713,
|
|
||||||
systems: 0x060918,
|
|
||||||
infrastructure: 0x080b1d,
|
|
||||||
builds: 0x0d1025,
|
|
||||||
'pre-dawn': 0x18152c,
|
|
||||||
destination: 0x2b2035,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createChaseCamera(aspect = 1) {
|
export function createChaseCamera(aspect = 1) {
|
||||||
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
|
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
|
||||||
camera.position.set(0, 4.8, 12.5);
|
camera.position.set(0, 4.8, 12.5);
|
||||||
@@ -62,9 +59,11 @@ export async function createProceduralSceneGeneration(
|
|||||||
renderer.toneMappingExposure = 1.05;
|
renderer.toneMappingExposure = 1.05;
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
const background = new THREE.Color(skyColors['deep-night']);
|
const currentPalette = createMutableChapterPalette('deep-night');
|
||||||
|
const targetPalette = createMutableChapterPalette('deep-night');
|
||||||
|
const background = currentPalette.sky.clone();
|
||||||
scene.background = background;
|
scene.background = background;
|
||||||
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
|
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.012);
|
||||||
const camera = createChaseCamera();
|
const camera = createChaseCamera();
|
||||||
|
|
||||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||||
@@ -78,43 +77,19 @@ export async function createProceduralSceneGeneration(
|
|||||||
const sun = createSun(context.resources);
|
const sun = createSun(context.resources);
|
||||||
const car = createCar(context.resources);
|
const car = createCar(context.resources);
|
||||||
const sign = createSign(context.resources);
|
const sign = createSign(context.resources);
|
||||||
const particles = createParticles(context.resources, quality.particleCount);
|
const atmosphere = createAtmosphere(context.resources, quality);
|
||||||
scene.add(road.group, terrain.group, sun.mesh, car.group, sign.group, particles.points);
|
scene.add(
|
||||||
|
atmosphere.group,
|
||||||
|
road.group,
|
||||||
|
terrain.group,
|
||||||
|
sun.group,
|
||||||
|
car.group,
|
||||||
|
sign.group,
|
||||||
|
);
|
||||||
|
|
||||||
const postTarget = quality.postEffects
|
const postProcessor = quality.postEffects
|
||||||
? context.resources.own('target', new THREE.WebGLRenderTarget(1, 1, {
|
? createPostProcessor(context.resources, renderer)
|
||||||
depthBuffer: true,
|
|
||||||
minFilter: THREE.LinearFilter,
|
|
||||||
magFilter: THREE.LinearFilter,
|
|
||||||
}))
|
|
||||||
: null;
|
: null;
|
||||||
const postScene = postTarget ? new THREE.Scene() : null;
|
|
||||||
const postCamera = postTarget ? new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) : null;
|
|
||||||
if (postScene && postTarget) {
|
|
||||||
const postGeometry = context.resources.own('geometry', new THREE.PlaneGeometry(2, 2));
|
|
||||||
const postMaterial = context.resources.own('material', new THREE.ShaderMaterial({
|
|
||||||
uniforms: { source: { value: postTarget.texture } },
|
|
||||||
vertexShader: `
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fragmentShader: `
|
|
||||||
uniform sampler2D source;
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() {
|
|
||||||
vec4 color = texture2D(source, vUv);
|
|
||||||
float vignette = smoothstep(0.92, 0.24, distance(vUv, vec2(0.5)));
|
|
||||||
gl_FragColor = vec4(color.rgb * mix(0.82, 1.0, vignette), color.a);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
depthTest: false,
|
|
||||||
depthWrite: false,
|
|
||||||
}));
|
|
||||||
postScene.add(new THREE.Mesh(postGeometry, postMaterial));
|
|
||||||
}
|
|
||||||
let width = 0;
|
let width = 0;
|
||||||
let height = 0;
|
let height = 0;
|
||||||
let driveDistance = 0;
|
let driveDistance = 0;
|
||||||
@@ -132,10 +107,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
postTarget?.setSize(
|
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
||||||
Math.max(1, Math.floor(width * quality.pixelRatio)),
|
|
||||||
Math.max(1, Math.floor(height * quality.pixelRatio)),
|
|
||||||
);
|
|
||||||
if (TEST_BUILD && context.quality !== 'medium') {
|
if (TEST_BUILD && context.quality !== 'medium') {
|
||||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
||||||
tier: context.quality,
|
tier: context.quality,
|
||||||
@@ -176,6 +148,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
resize();
|
resize();
|
||||||
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||||
lastNow = now;
|
lastNow = now;
|
||||||
|
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||||
|
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||||
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
||||||
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
||||||
terrain.update(
|
terrain.update(
|
||||||
@@ -183,22 +157,27 @@ export async function createProceduralSceneGeneration(
|
|||||||
snapshot.driveSpeed,
|
snapshot.driveSpeed,
|
||||||
snapshot.chapter,
|
snapshot.chapter,
|
||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
|
currentPalette,
|
||||||
);
|
);
|
||||||
sun.update(snapshot.chapter, snapshot.routeProgress);
|
sun.update(currentPalette, snapshot.routeProgress);
|
||||||
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
||||||
sign.update(snapshot.sign);
|
sign.update(snapshot.sign);
|
||||||
particles.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
atmosphere.update(
|
||||||
background.setHex(skyColors[snapshot.chapter]);
|
driveDistance,
|
||||||
|
snapshot.driveSpeed,
|
||||||
|
snapshot.acceleration,
|
||||||
|
currentPalette,
|
||||||
|
);
|
||||||
|
background.copy(currentPalette.sky);
|
||||||
|
scene.fog.color.copy(currentPalette.fog);
|
||||||
|
scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025;
|
||||||
const fov = 52 + Math.min(7, snapshot.acceleration * 4.5);
|
const fov = 52 + Math.min(7, snapshot.acceleration * 4.5);
|
||||||
if (camera.fov !== fov) {
|
if (camera.fov !== fov) {
|
||||||
camera.fov = fov;
|
camera.fov = fov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
if (postTarget) {
|
if (postProcessor) {
|
||||||
renderer.setRenderTarget(postTarget);
|
postProcessor.render(scene, camera);
|
||||||
renderer.render(scene, camera);
|
|
||||||
renderer.setRenderTarget(null);
|
|
||||||
renderer.render(postScene, postCamera);
|
|
||||||
} else {
|
} else {
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
@@ -211,3 +190,21 @@ export async function createProceduralSceneGeneration(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dampPalette(
|
||||||
|
current: MutableChapterPalette,
|
||||||
|
target: Readonly<MutableChapterPalette>,
|
||||||
|
deltaSeconds: number,
|
||||||
|
): void {
|
||||||
|
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) * 2.8);
|
||||||
|
current.sky.lerp(target.sky, mix);
|
||||||
|
current.fog.lerp(target.fog, mix);
|
||||||
|
current.terrainLow.lerp(target.terrainLow, mix);
|
||||||
|
current.terrainHigh.lerp(target.terrainHigh, mix);
|
||||||
|
current.sun.lerp(target.sun, mix);
|
||||||
|
current.haze.lerp(target.haze, mix);
|
||||||
|
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
|
||||||
|
current.terrainEnergy = THREE.MathUtils.lerp(current.terrainEnergy, target.terrainEnergy, mix);
|
||||||
|
current.starVisibility = THREE.MathUtils.lerp(current.starVisibility, target.starVisibility, mix);
|
||||||
|
current.streakEnergy = THREE.MathUtils.lerp(current.streakEnergy, target.streakEnergy, mix);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export interface MutableChapterPalette {
|
|||||||
|
|
||||||
export interface MutableColor {
|
export interface MutableColor {
|
||||||
clone(): MutableColor;
|
clone(): MutableColor;
|
||||||
|
copy(source: MutableColor): MutableColor;
|
||||||
getHex(): number;
|
getHex(): number;
|
||||||
|
lerp(target: MutableColor, progress: number): MutableColor;
|
||||||
lerpColors(from: MutableColor, to: MutableColor, progress: number): MutableColor;
|
lerpColors(from: MutableColor, to: MutableColor, progress: number): MutableColor;
|
||||||
setHex(value: number): MutableColor;
|
setHex(value: number): MutableColor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import * as THREE from 'three';
|
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
|
||||||
|
|
||||||
export function createParticles(resources: SceneResourceBag, count: number) {
|
|
||||||
const positions = new Float32Array(count * 3);
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
const offset = index * 3;
|
|
||||||
positions[offset] = seeded(index * 3) * 30 - 15;
|
|
||||||
positions[offset + 1] = seeded(index * 3 + 1) * 7 + 0.2;
|
|
||||||
positions[offset + 2] = -(seeded(index * 3 + 2) * 150);
|
|
||||||
}
|
|
||||||
const geometry = resources.own('geometry', new THREE.BufferGeometry());
|
|
||||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
||||||
const material = resources.own('material', new THREE.PointsMaterial({
|
|
||||||
color: 0xa8f5ff,
|
|
||||||
size: 0.07,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.42,
|
|
||||||
depthWrite: false,
|
|
||||||
}));
|
|
||||||
const points = new THREE.Points(geometry, material);
|
|
||||||
|
|
||||||
return {
|
|
||||||
points,
|
|
||||||
update(distance: number, speed: number, acceleration: number) {
|
|
||||||
points.position.z = modulo(distance * (1 + speed * 2), 150);
|
|
||||||
material.opacity = Math.min(0.78, 0.25 + speed * 0.28 + acceleration * 0.18);
|
|
||||||
material.size = 0.055 + Math.min(0.1, speed * 0.04);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function seeded(value: number): number {
|
|
||||||
const result = Math.sin(value * 91.713 + 17.17) * 43758.5453;
|
|
||||||
return result - Math.floor(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function modulo(value: number, divisor: number): number {
|
|
||||||
return ((value % divisor) + divisor) % divisor;
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,153 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { EnvironmentChapter } from '@/journey/types';
|
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||||
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||||
|
|
||||||
const chapterWarmth: Readonly<Record<EnvironmentChapter, number>> = {
|
export interface SunWorld {
|
||||||
'deep-night': 0,
|
group: WorldGroup;
|
||||||
systems: 0.12,
|
update(palette: Readonly<MutableChapterPalette>, routeProgress: number): void;
|
||||||
infrastructure: 0.24,
|
}
|
||||||
builds: 0.42,
|
|
||||||
'pre-dawn': 0.68,
|
interface WorldGroup {
|
||||||
destination: 1,
|
add(...objects: unknown[]): void;
|
||||||
|
getObjectByName(name: string): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSun(resources: SceneResourceBag): SunWorld {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'sun-world';
|
||||||
|
const night = CHAPTER_PALETTES['deep-night'];
|
||||||
|
const sunColor = new THREE.Color(night.sun);
|
||||||
|
const uniforms = {
|
||||||
|
uColor: { value: sunColor },
|
||||||
|
uScanBands: { value: 14 },
|
||||||
|
uWarmth: { value: night.warmth },
|
||||||
|
uRouteProgress: { value: 0 },
|
||||||
};
|
};
|
||||||
|
const geometry = resources.own('geometry', new THREE.CircleGeometry(20, 96));
|
||||||
|
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||||
|
uniforms,
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vSunUv;
|
||||||
|
|
||||||
export function createSun(resources: SceneResourceBag) {
|
void main() {
|
||||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(18, 64));
|
vSunUv = uv;
|
||||||
const material = resources.own('material', new THREE.MeshBasicMaterial({
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
color: 0xff6f83,
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform float uScanBands;
|
||||||
|
uniform float uWarmth;
|
||||||
|
uniform float uRouteProgress;
|
||||||
|
varying vec2 vSunUv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float radius = distance(vSunUv, vec2(0.5));
|
||||||
|
float softEdge = 1.0 - smoothstep(0.435, 0.5, radius);
|
||||||
|
float lowerDisc = 1.0 - smoothstep(0.42, 0.76, vSunUv.y);
|
||||||
|
float scanBand = smoothstep(0.16, 0.28, fract(vSunUv.y * uScanBands));
|
||||||
|
float bandCut = mix(1.0, 0.32 + scanBand * 0.68, lowerDisc * 0.82);
|
||||||
|
float horizonFade = smoothstep(0.02, 0.14, vSunUv.y);
|
||||||
|
float luminance = 0.72 + uWarmth * 0.18 + uRouteProgress * 0.06;
|
||||||
|
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade * 0.9);
|
||||||
|
}
|
||||||
|
`,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.82,
|
depthWrite: false,
|
||||||
fog: false,
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
toneMapped: true,
|
||||||
}));
|
}));
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const disc = new THREE.Mesh(geometry, material);
|
||||||
mesh.position.set(0, 12, -128);
|
disc.name = 'sun-disc';
|
||||||
|
disc.position.set(0, 11.5, -142);
|
||||||
|
group.add(disc);
|
||||||
|
|
||||||
|
const haloTexture = resources.own('texture', createHaloTexture());
|
||||||
|
const haloMaterial = resources.own('material', new THREE.SpriteMaterial({
|
||||||
|
map: haloTexture,
|
||||||
|
color: sunColor.clone(),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.24,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
toneMapped: true,
|
||||||
|
}));
|
||||||
|
const halo = new THREE.Sprite(haloMaterial);
|
||||||
|
halo.name = 'sun-halo';
|
||||||
|
halo.position.copy(disc.position);
|
||||||
|
halo.position.z -= 0.5;
|
||||||
|
halo.scale.set(57, 43, 1);
|
||||||
|
group.add(halo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mesh,
|
group,
|
||||||
update(chapter: EnvironmentChapter, routeProgress: number) {
|
update(palette, routeProgress) {
|
||||||
const warmth = Math.max(chapterWarmth[chapter], routeProgress);
|
const progress = clamp01(routeProgress);
|
||||||
material.color.setRGB(1, 0.34 + warmth * 0.24, 0.42 + warmth * 0.22);
|
sunColor.copy(palette.sun);
|
||||||
mesh.position.y = 12 + warmth * 5;
|
haloMaterial.color.copy(palette.sun);
|
||||||
|
uniforms.uWarmth.value = clamp01(palette.warmth);
|
||||||
|
uniforms.uRouteProgress.value = progress;
|
||||||
|
haloMaterial.opacity = 0.18 + palette.warmth * 0.12;
|
||||||
|
disc.position.y = 11.5 + palette.warmth * 4.8;
|
||||||
|
halo.position.y = disc.position.y;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHaloTexture() {
|
||||||
|
const size = 64;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
let context: CanvasRenderingContext2D | null = null;
|
||||||
|
try {
|
||||||
|
context = canvas.getContext('2d');
|
||||||
|
} catch {
|
||||||
|
context = null;
|
||||||
|
}
|
||||||
|
if (context) {
|
||||||
|
const gradient = context.createRadialGradient(
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
0,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
size / 2,
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, 'rgb(255 255 255 / 90%)');
|
||||||
|
gradient.addColorStop(0.26, 'rgb(255 255 255 / 32%)');
|
||||||
|
gradient.addColorStop(1, 'rgb(255 255 255 / 0%)');
|
||||||
|
context.fillStyle = gradient;
|
||||||
|
context.fillRect(0, 0, size, size);
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.generateMipmaps = false;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixels = new Uint8Array(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
const distance = Math.hypot(x - size / 2, y - size / 2) / (size / 2);
|
||||||
|
const alpha = Math.round(255 * Math.max(0, 1 - distance) ** 2.4);
|
||||||
|
const offset = (y * size + x) * 4;
|
||||||
|
pixels[offset] = 255;
|
||||||
|
pixels[offset + 1] = 255;
|
||||||
|
pixels[offset + 2] = 255;
|
||||||
|
pixels[offset + 3] = alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const texture = new THREE.DataTexture(pixels, size, size, THREE.RGBAFormat);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
texture.generateMipmaps = false;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as THREE from 'three';
|
|||||||
import type { EnvironmentChapter } from '@/journey/types';
|
import type { EnvironmentChapter } from '@/journey/types';
|
||||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette';
|
||||||
|
|
||||||
export interface TerrainWorld {
|
export interface TerrainWorld {
|
||||||
group: WorldGroup;
|
group: WorldGroup;
|
||||||
@@ -11,6 +11,7 @@ export interface TerrainWorld {
|
|||||||
speed: number,
|
speed: number,
|
||||||
chapter: EnvironmentChapter,
|
chapter: EnvironmentChapter,
|
||||||
routeProgress: number,
|
routeProgress: number,
|
||||||
|
palette?: Readonly<MutableChapterPalette>,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +132,19 @@ export function createTerrain(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(distance, speed, chapter, routeProgress) {
|
update(distance, speed, chapter, routeProgress, palette) {
|
||||||
const palette = CHAPTER_PALETTES[chapter];
|
const chapterPalette = CHAPTER_PALETTES[chapter];
|
||||||
uniforms.uDistance.value = finiteOrZero(distance);
|
uniforms.uDistance.value = finiteOrZero(distance);
|
||||||
uniforms.uSpeed.value = clamp01(speed);
|
uniforms.uSpeed.value = clamp01(speed);
|
||||||
uniforms.uRouteProgress.value = clamp01(routeProgress);
|
uniforms.uRouteProgress.value = clamp01(routeProgress);
|
||||||
uniforms.uTerrainEnergy.value = palette.terrainEnergy;
|
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
|
||||||
lowColor.setHex(palette.terrainLow);
|
if (palette) {
|
||||||
highColor.setHex(palette.terrainHigh);
|
lowColor.copy(palette.terrainLow);
|
||||||
|
highColor.copy(palette.terrainHigh);
|
||||||
|
} else {
|
||||||
|
lowColor.setHex(chapterPalette.terrainLow);
|
||||||
|
highColor.setHex(chapterPalette.terrainHigh);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,23 +29,83 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at 50% 53%,
|
ellipse at 42% 89%,
|
||||||
rgb(247 105 115 / 82%) 0 7%,
|
rgb(255 45 85 / 72%) 0 0.65%,
|
||||||
transparent 7.3%
|
rgb(255 45 85 / 20%) 0.9% 2.6%,
|
||||||
|
transparent 4.8%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 58% 89%,
|
||||||
|
rgb(255 45 85 / 72%) 0 0.65%,
|
||||||
|
rgb(255 45 85 / 20%) 0.9% 2.6%,
|
||||||
|
transparent 4.8%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
166deg,
|
||||||
|
transparent 0 67%,
|
||||||
|
rgb(12 21 31 / 98%) 67.2% 76%,
|
||||||
|
transparent 76.2%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
194deg,
|
||||||
|
transparent 0 67%,
|
||||||
|
rgb(12 21 31 / 98%) 67.2% 76%,
|
||||||
|
transparent 76.2%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 91%,
|
||||||
|
rgb(5 9 15 / 100%) 0 13%,
|
||||||
|
transparent 13.4%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 55.8%,
|
||||||
|
transparent 0 4.8%,
|
||||||
|
rgb(4 8 20 / 88%) 5% 5.35%,
|
||||||
|
transparent 5.55%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 54.6%,
|
||||||
|
rgb(244 173 103 / 92%) 0 6.7%,
|
||||||
|
rgb(199 75 145 / 65%) 7.1% 9.8%,
|
||||||
|
transparent 14%
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0 5.2%,
|
||||||
|
rgb(4 8 20 / 88%) 5.3% 5.65%,
|
||||||
|
transparent 5.75% 7.2%
|
||||||
),
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
165deg,
|
165deg,
|
||||||
transparent 0 60%,
|
transparent 0 55%,
|
||||||
rgb(73 198 215 / 34%) 60.2% 60.5%,
|
rgb(120 216 229 / 54%) 55.12% 55.32%,
|
||||||
transparent 60.7%
|
transparent 55.5%
|
||||||
),
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
195deg,
|
195deg,
|
||||||
transparent 0 60%,
|
transparent 0 55%,
|
||||||
rgb(73 198 215 / 34%) 60.2% 60.5%,
|
rgb(120 216 229 / 54%) 55.12% 55.32%,
|
||||||
transparent 60.7%
|
transparent 55.5%
|
||||||
),
|
),
|
||||||
linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%);
|
conic-gradient(
|
||||||
|
from 167deg at 50% 55%,
|
||||||
|
rgb(5 9 17 / 96%) 0 12deg,
|
||||||
|
transparent 12.4deg 335.6deg,
|
||||||
|
rgb(5 9 17 / 96%) 336deg 360deg
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0 6.5%,
|
||||||
|
rgb(73 198 215 / 14%) 6.65% 6.8%,
|
||||||
|
transparent 6.95% 9.5%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 62%,
|
||||||
|
rgb(131 41 109 / 42%) 0,
|
||||||
|
transparent 48%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #030713 0 38%, #0c1025 54%, #26162e 69%, #050a14 100%);
|
||||||
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.journey-canvas {
|
.journey-canvas {
|
||||||
@@ -276,13 +336,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.journey-poster {
|
.journey-poster {
|
||||||
background:
|
filter: saturate(0.86) brightness(0.92);
|
||||||
radial-gradient(
|
|
||||||
circle at 50% 53%,
|
|
||||||
rgb(247 105 115 / 48%) 0 7%,
|
|
||||||
transparent 7.3%
|
|
||||||
),
|
|
||||||
linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra
|
|||||||
page,
|
page,
|
||||||
distUrl,
|
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 page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u);
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', /running|failed|static/u);
|
||||||
const chapterColors: string[] = [];
|
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);
|
const instrumentation = await readSceneInstrumentation(page);
|
||||||
expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1);
|
expect(instrumentation.maximumCanvasCount).toBeLessThanOrEqual(1);
|
||||||
const expectedChapterColors = (page.viewportSize()?.width ?? 0) < 768
|
const chapterEnergy = chapterColors.map(colorEnergy);
|
||||||
? [
|
for (let index = 1; index < chapterEnergy.length; index += 1) {
|
||||||
'#040713', '#040713', '#060918', '#080b1d',
|
expect(chapterEnergy[index]).toBeGreaterThanOrEqual(chapterEnergy[index - 1] ?? 0);
|
||||||
'#0d1025', '#0d1025', '#18152c', '#2b2035',
|
}
|
||||||
]
|
expect(chapterEnergy.at(-1)).toBeGreaterThan(chapterEnergy[0] ?? 0);
|
||||||
: [
|
expect(new Set(chapterColors).size).toBeGreaterThanOrEqual(4);
|
||||||
'#000102', '#000102', '#000102', '#010103',
|
expect(graphicsErrors).toEqual([]);
|
||||||
'#010105', '#010105', '#020206', '#060409',
|
|
||||||
];
|
|
||||||
expect(chapterColors).toEqual(expectedChapterColors);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('draws only the independently eligible next roadside sign during passive forward travel', async ({
|
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;
|
const labels = (await readSceneInstrumentation(page)).routeLabelsDrawn;
|
||||||
expect([...new Set(labels)]).toEqual([oracle.expectedLabel]);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,8 +160,9 @@ describe('scene render quality', () => {
|
|||||||
roadSegments: 192,
|
roadSegments: 192,
|
||||||
terrainSegments: 96,
|
terrainSegments: 96,
|
||||||
terrainOctaves: 3,
|
terrainOctaves: 3,
|
||||||
particleCount: 384,
|
starCount: 384,
|
||||||
streakCount: 48,
|
streakCount: 48,
|
||||||
|
postScale: 1,
|
||||||
postEffects: true,
|
postEffects: true,
|
||||||
});
|
});
|
||||||
expect(medium).toEqual({
|
expect(medium).toEqual({
|
||||||
@@ -169,8 +170,9 @@ describe('scene render quality', () => {
|
|||||||
roadSegments: 144,
|
roadSegments: 144,
|
||||||
terrainSegments: 64,
|
terrainSegments: 64,
|
||||||
terrainOctaves: 2,
|
terrainOctaves: 2,
|
||||||
particleCount: 192,
|
starCount: 192,
|
||||||
streakCount: 28,
|
streakCount: 28,
|
||||||
|
postScale: 0.7,
|
||||||
postEffects: true,
|
postEffects: true,
|
||||||
});
|
});
|
||||||
expect(low).toEqual({
|
expect(low).toEqual({
|
||||||
@@ -178,8 +180,9 @@ describe('scene render quality', () => {
|
|||||||
roadSegments: 96,
|
roadSegments: 96,
|
||||||
terrainSegments: 32,
|
terrainSegments: 32,
|
||||||
terrainOctaves: 1,
|
terrainOctaves: 1,
|
||||||
particleCount: 64,
|
starCount: 64,
|
||||||
streakCount: 12,
|
streakCount: 12,
|
||||||
|
postScale: 0,
|
||||||
postEffects: false,
|
postEffects: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('scroll drive controller', () => {
|
|||||||
expect(snapshot.driveSpeed).toBeCloseTo(0.9490152503018645, 14);
|
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();
|
const controller = createScrollDriveController();
|
||||||
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
controller.sample({ scrollY: 0, now: 0, maxScrollY: 1000 });
|
||||||
controller.sample({ scrollY: 500, now: 100, 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);
|
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([
|
it.each([
|
||||||
['downward', 0, 1000, 1],
|
['downward', 0, 1000, 1],
|
||||||
['upward', 1000, 0, -1],
|
['upward', 1000, 0, -1],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { EnvironmentChapter } from '@/journey/types';
|
import type { EnvironmentChapter } from '@/journey/types';
|
||||||
import { renderQualityFor } from '@/scene/render-quality';
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from '@/scene/world/palette';
|
} from '@/scene/world/palette';
|
||||||
import { createRoad } from '@/scene/world/road';
|
import { createRoad } from '@/scene/world/road';
|
||||||
import { createTerrain } from '@/scene/world/terrain';
|
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', () => {
|
describe('procedural world models', () => {
|
||||||
it('scales geometry and atmosphere work monotonically by live quality tier', () => {
|
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).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth);
|
||||||
expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.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>) {
|
function paletteSnapshot(palette: ReturnType<typeof createMutableChapterPalette>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user