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

@@ -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);
}

View File

@@ -1,5 +1,5 @@
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 { SceneBuildContext, SceneGeneration } from '@/scene/types';
import { renderQualityFor } from '@/scene/render-quality';
@@ -8,21 +8,18 @@ import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar } from '@/scene/world/car';
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';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
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) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
@@ -62,9 +59,11 @@ export async function createProceduralSceneGeneration(
renderer.toneMappingExposure = 1.05;
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.fog = new THREE.FogExp2(0x080c18, 0.012);
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.012);
const camera = createChaseCamera();
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
@@ -78,43 +77,19 @@ export async function createProceduralSceneGeneration(
const sun = createSun(context.resources);
const car = createCar(context.resources);
const sign = createSign(context.resources);
const particles = createParticles(context.resources, quality.particleCount);
scene.add(road.group, terrain.group, sun.mesh, car.group, sign.group, particles.points);
const atmosphere = createAtmosphere(context.resources, quality);
scene.add(
atmosphere.group,
road.group,
terrain.group,
sun.group,
car.group,
sign.group,
);
const postTarget = quality.postEffects
? context.resources.own('target', new THREE.WebGLRenderTarget(1, 1, {
depthBuffer: true,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
}))
const postProcessor = quality.postEffects
? createPostProcessor(context.resources, renderer)
: 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 height = 0;
let driveDistance = 0;
@@ -132,10 +107,7 @@ export async function createProceduralSceneGeneration(
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
postTarget?.setSize(
Math.max(1, Math.floor(width * quality.pixelRatio)),
Math.max(1, Math.floor(height * quality.pixelRatio)),
);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
tier: context.quality,
@@ -176,6 +148,8 @@ export async function createProceduralSceneGeneration(
resize();
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
lastNow = now;
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
dampPalette(currentPalette, targetPalette, deltaSeconds);
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
terrain.update(
@@ -183,22 +157,27 @@ export async function createProceduralSceneGeneration(
snapshot.driveSpeed,
snapshot.chapter,
snapshot.routeProgress,
currentPalette,
);
sun.update(snapshot.chapter, snapshot.routeProgress);
sun.update(currentPalette, snapshot.routeProgress);
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
sign.update(snapshot.sign);
particles.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
background.setHex(skyColors[snapshot.chapter]);
atmosphere.update(
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);
if (camera.fov !== fov) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
if (postTarget) {
renderer.setRenderTarget(postTarget);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
renderer.render(postScene, postCamera);
if (postProcessor) {
postProcessor.render(scene, camera);
} else {
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);
}

View File

@@ -29,7 +29,9 @@ export interface MutableChapterPalette {
export interface MutableColor {
clone(): MutableColor;
copy(source: MutableColor): MutableColor;
getHex(): number;
lerp(target: MutableColor, progress: number): MutableColor;
lerpColors(from: MutableColor, to: MutableColor, progress: number): MutableColor;
setHex(value: number): MutableColor;
}

View File

@@ -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;
}

View File

@@ -1,33 +1,153 @@
import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
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>> = {
'deep-night': 0,
systems: 0.12,
infrastructure: 0.24,
builds: 0.42,
'pre-dawn': 0.68,
destination: 1,
};
export interface SunWorld {
group: WorldGroup;
update(palette: Readonly<MutableChapterPalette>, routeProgress: number): void;
}
export function createSun(resources: SceneResourceBag) {
const geometry = resources.own('geometry', new THREE.CircleGeometry(18, 64));
const material = resources.own('material', new THREE.MeshBasicMaterial({
color: 0xff6f83,
interface WorldGroup {
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;
void main() {
vSunUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
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,
opacity: 0.82,
fog: false,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 12, -128);
const disc = new THREE.Mesh(geometry, material);
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 {
mesh,
update(chapter: EnvironmentChapter, routeProgress: number) {
const warmth = Math.max(chapterWarmth[chapter], routeProgress);
material.color.setRGB(1, 0.34 + warmth * 0.24, 0.42 + warmth * 0.22);
mesh.position.y = 12 + warmth * 5;
group,
update(palette, routeProgress) {
const progress = clamp01(routeProgress);
sunColor.copy(palette.sun);
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;
}

View File

@@ -2,7 +2,7 @@ import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
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 {
group: WorldGroup;
@@ -11,6 +11,7 @@ export interface TerrainWorld {
speed: number,
chapter: EnvironmentChapter,
routeProgress: number,
palette?: Readonly<MutableChapterPalette>,
): void;
}
@@ -131,14 +132,19 @@ export function createTerrain(
return {
group,
update(distance, speed, chapter, routeProgress) {
const palette = CHAPTER_PALETTES[chapter];
update(distance, speed, chapter, routeProgress, palette) {
const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value = finiteOrZero(distance);
uniforms.uSpeed.value = clamp01(speed);
uniforms.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value = palette.terrainEnergy;
lowColor.setHex(palette.terrainLow);
highColor.setHex(palette.terrainHigh);
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
if (palette) {
lowColor.copy(palette.terrainLow);
highColor.copy(palette.terrainHigh);
} else {
lowColor.setHex(chapterPalette.terrainLow);
highColor.setHex(chapterPalette.terrainHigh);
}
},
};
}