clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M6 goal 3: generative atmosphere family (fog/light/weather combos)

Dami committed b25973a
Mood table (midnight, ember, violet, abyss, snowdrift, goldhour) x
parameter jitter x weather draw (none/snow/dust/embers) = the zone
atmosphere, drawn label-derived per zone ('atmosphere/zone/N') every 5
segments of travel. Zone boundaries crossfade sky, fog, sun
color/intensity/direction, hemisphere, and starfield over 4s; weather is a
camera-local wrapping particle volume that fades in/out on swaps. HUD and
CUA expose mood+weather. Follow-cam clipping through island masses noted
for polish (pre-existing).
commit
b25973a9e910339929a2a73fcb2ee39b05d23d39
parent
63ba97fa8b35a25e5da1b77f5310bb22862eb37d
author
Dami <dami.etoile@gmail.com>
3 files changed, +458 -6
Asrc/client/atmosphere.ts426++++++++++++++++++++++++++++++
Msrc/client/endless.ts35++-
Msrc/client/scene.ts3+-
A src/client/atmosphere.ts +426 -0
@@ -0,0 +1,426 @@
1+/**2+ * Atmosphere — fog, light, and weather as a GENERATIVE FAMILY (user3+ * direction). A mood table defines the base combos; every endless ZONE4+ * (a run of segments) draws a mood + parameter jitter + a weather form5+ * from the mood's allowed set, all label-derived from the seed6+ * (`atmosphere/zone/${z}`). Crossing a zone boundary crossfades every7+ * scalar (sky, fog, sun color/intensity/angle, hemisphere, starfield) over8+ * a few seconds and swaps the weather field with a fade — the void has9+ * climate now.10+ *11+ * Presentation-only: reads the seed through its own rng family, never12+ * perturbs generation. Weather fields are camera-local particle volumes13+ * (snowfall, drifting dust, rising embers) that wrap around the viewer.14+ */15+import * as THREE from "three";16+import type { SceneCtx } from "./scene.ts";17+import { rngFamily } from "../gen/rng.ts";18+19+export type WeatherKind = "none" | "snow" | "dust" | "embers";20+21+export interface MoodSpec {22+ name: string;23+ weight: number;24+ sky: number;25+ fogNear: number;26+ fogFar: number;27+ sunColor: number;28+ sunIntensity: number;29+ /** Sun offset direction (normalized against focus radius). */30+ sunDir: readonly [number, number, number];31+ hemiSky: number;32+ hemiGround: number;33+ hemiIntensity: number;34+ starColor: number;35+ starOpacity: number;36+ weathers: readonly WeatherKind[];37+ weatherColor?: number;38+}39+40+export const MOODS: readonly MoodSpec[] = [41+ {42+ name: "midnight",43+ weight: 3,44+ sky: 0x070b16,45+ fogNear: 32,46+ fogFar: 115,47+ sunColor: 0xfff4e0,48+ sunIntensity: 2.2,49+ sunDir: [0.6, 1.2, 0.4],50+ hemiSky: 0xbfd8ff,51+ hemiGround: 0x8a6f5a,52+ hemiIntensity: 1.1,53+ starColor: 0xc7d5ff,54+ starOpacity: 0.85,55+ weathers: ["none", "dust"],56+ },57+ {58+ name: "ember",59+ weight: 2,60+ sky: 0x170a08,61+ fogNear: 26,62+ fogFar: 95,63+ sunColor: 0xffab6e,64+ sunIntensity: 2.6,65+ sunDir: [1.1, 0.9, 0.5],66+ hemiSky: 0xffc9a3,67+ hemiGround: 0x5a3a30,68+ hemiIntensity: 0.9,69+ starColor: 0xffd9b0,70+ starOpacity: 0.7,71+ weathers: ["embers"],72+ weatherColor: 0xffa050,73+ },74+ {75+ name: "violet",76+ weight: 2,77+ sky: 0x0d0a1e,78+ fogNear: 30,79+ fogFar: 110,80+ sunColor: 0xd9c8ff,81+ sunIntensity: 2.0,82+ sunDir: [0.5, 1.4, 0.7],83+ hemiSky: 0x9f8fdf,84+ hemiGround: 0x3a3050,85+ hemiIntensity: 1.2,86+ starColor: 0xe0d5ff,87+ starOpacity: 0.95,88+ weathers: ["dust", "none"],89+ weatherColor: 0xb8a8ff,90+ },91+ {92+ name: "abyss",93+ weight: 1.5,94+ sky: 0x05100f,95+ fogNear: 20,96+ fogFar: 72,97+ sunColor: 0x9fe8d8,98+ sunIntensity: 1.7,99+ sunDir: [0.4, 1.0, 0.8],100+ hemiSky: 0x7fd8c8,101+ hemiGround: 0x1f4038,102+ hemiIntensity: 1.0,103+ starColor: 0xafeade,104+ starOpacity: 0.3,105+ weathers: ["none"],106+ },107+ {108+ name: "snowdrift",109+ weight: 2,110+ sky: 0x0e141f,111+ fogNear: 26,112+ fogFar: 98,113+ sunColor: 0xdfeaff,114+ sunIntensity: 2.3,115+ sunDir: [0.7, 1.5, 0.3],116+ hemiSky: 0xcfe0ff,117+ hemiGround: 0x60707f,118+ hemiIntensity: 1.3,119+ starColor: 0xffffff,120+ starOpacity: 0.9,121+ weathers: ["snow"],122+ weatherColor: 0xf0f6ff,123+ },124+ {125+ name: "goldhour",126+ weight: 1.5,127+ sky: 0x140f08,128+ fogNear: 34,129+ fogFar: 125,130+ sunColor: 0xffc46a,131+ sunIntensity: 2.9,132+ sunDir: [2.0, 0.55, 1.2],133+ hemiSky: 0xffe0b0,134+ hemiGround: 0x504030,135+ hemiIntensity: 0.8,136+ starColor: 0xffe9c0,137+ starOpacity: 0.45,138+ weathers: ["none", "dust"],139+ weatherColor: 0xd8b890,140+ },141+];142+143+/** One drawn zone: mood + jitter + weather pick. */144+export interface ZoneAtmosphere {145+ mood: MoodSpec;146+ fogNear: number;147+ fogFar: number;148+ sunIntensity: number;149+ weather: WeatherKind;150+}151+152+/** Deterministic zone draw — `atmosphere` family, keyed by zone index. */153+export function drawZone(seed: string, zone: number): ZoneAtmosphere {154+ const rng = rngFamily(seed, "atmosphere").child(`zone/${zone}`);155+ const mood = rng.weighted(MOODS.map((m) => [m, m.weight] as const));156+ return {157+ mood,158+ fogNear: mood.fogNear * (0.9 + rng.float() * 0.25),159+ fogFar: mood.fogFar * (0.85 + rng.float() * 0.3),160+ sunIntensity: mood.sunIntensity * (0.9 + rng.float() * 0.2),161+ weather: rng.pick(mood.weathers),162+ };163+}164+165+// ---------------------------------------------------------------------------166+// Weather fields — camera-local particle volumes167+// ---------------------------------------------------------------------------168+169+const WEATHER_BOX = 42;170+const WEATHER_COUNT = 700;171+172+class WeatherField {173+ readonly points: THREE.Points;174+ private readonly velocities: Float32Array;175+ private readonly phases: Float32Array;176+ private readonly material: THREE.PointsMaterial;177+ private targetOpacity: number;178+ private t = 0;179+180+ constructor(readonly kind: Exclude<WeatherKind, "none">, color: number) {181+ const positions = new Float32Array(WEATHER_COUNT * 3);182+ this.velocities = new Float32Array(WEATHER_COUNT * 3);183+ this.phases = new Float32Array(WEATHER_COUNT);184+ for (let i = 0; i < WEATHER_COUNT; i++) {185+ for (let a = 0; a < 3; a++) {186+ positions[i * 3 + a] = (Math.random() - 0.5) * WEATHER_BOX;187+ }188+ this.phases[i] = Math.random() * Math.PI * 2;189+ const v = kind === "snow"190+ ? [0, -1.1 - Math.random() * 0.6, 0]191+ : kind === "dust"192+ ? [0.14 + Math.random() * 0.1, -0.04, 0.1 * (Math.random() - 0.3)]193+ : [0.03, 0.42 + Math.random() * 0.3, 0.02];194+ this.velocities.set(v, i * 3);195+ }196+ const geom = new THREE.BufferGeometry();197+ geom.setAttribute("position", new THREE.BufferAttribute(positions, 3));198+ this.targetOpacity = kind === "dust" ? 0.45 : 0.8;199+ this.material = new THREE.PointsMaterial({200+ color,201+ size: kind === "snow" ? 0.055 : kind === "embers" ? 0.045 : 0.03,202+ sizeAttenuation: true,203+ transparent: true,204+ opacity: 0,205+ depthWrite: false,206+ });207+ this.points = new THREE.Points(geom, this.material);208+ this.points.name = `weather:${kind}`;209+ this.points.frustumCulled = false;210+ }211+212+ /** dying → fade out; returns true when fully faded and disposable. */213+ update(dt: number, camPos: THREE.Vector3, dying: boolean): boolean {214+ this.t += dt;215+ const target = dying ? 0 : this.targetOpacity;216+ this.material.opacity += (target - this.material.opacity) *217+ Math.min(1, dt * 1.5);218+ this.points.position.copy(camPos);219+220+ const pos = this.points.geometry.getAttribute(221+ "position",222+ ) as THREE.BufferAttribute;223+ const arr = pos.array as Float32Array;224+ const half = WEATHER_BOX / 2;225+ for (let i = 0; i < WEATHER_COUNT; i++) {226+ const sway = this.kind === "snow"227+ ? Math.sin(this.t * 1.7 + this.phases[i]) * 0.35228+ : this.kind === "embers"229+ ? Math.sin(this.t * 2.3 + this.phases[i]) * 0.12230+ : Math.sin(this.t * 0.7 + this.phases[i]) * 0.2;231+ arr[i * 3] += (this.velocities[i * 3] + sway * 0.4) * dt;232+ arr[i * 3 + 1] += this.velocities[i * 3 + 1] * dt;233+ arr[i * 3 + 2] += (this.velocities[i * 3 + 2] + sway * 0.25) * dt;234+ // Wrap within the camera-local box.235+ for (let a = 0; a < 3; a++) {236+ const v = arr[i * 3 + a];237+ if (v > half) arr[i * 3 + a] = v - WEATHER_BOX;238+ else if (v < -half) arr[i * 3 + a] = v + WEATHER_BOX;239+ }240+ }241+ pos.needsUpdate = true;242+ return dying && this.material.opacity < 0.015;243+ }244+245+ dispose(scene: THREE.Scene): void {246+ scene.remove(this.points);247+ this.points.geometry.dispose();248+ this.material.dispose();249+ }250+}251+252+// ---------------------------------------------------------------------------253+// The blender254+// ---------------------------------------------------------------------------255+256+const BLEND_SECONDS = 4;257+258+interface Snapshot {259+ sky: THREE.Color;260+ fogNear: number;261+ fogFar: number;262+ sunColor: THREE.Color;263+ sunIntensity: number;264+ sunDir: THREE.Vector3;265+ hemiSky: THREE.Color;266+ hemiGround: THREE.Color;267+ hemiIntensity: number;268+ starColor: THREE.Color;269+ starOpacity: number;270+}271+272+function snapshotOf(z: ZoneAtmosphere): Snapshot {273+ return {274+ sky: new THREE.Color(z.mood.sky),275+ fogNear: z.fogNear,276+ fogFar: z.fogFar,277+ sunColor: new THREE.Color(z.mood.sunColor),278+ sunIntensity: z.sunIntensity,279+ sunDir: new THREE.Vector3(...z.mood.sunDir).normalize(),280+ hemiSky: new THREE.Color(z.mood.hemiSky),281+ hemiGround: new THREE.Color(z.mood.hemiGround),282+ hemiIntensity: z.mood.hemiIntensity,283+ starColor: new THREE.Color(z.mood.starColor),284+ starOpacity: z.mood.starOpacity,285+ };286+}287+288+function lerpSnap(a: Snapshot, b: Snapshot, t: number, out: Snapshot): void {289+ out.sky.lerpColors(a.sky, b.sky, t);290+ out.fogNear = a.fogNear + (b.fogNear - a.fogNear) * t;291+ out.fogFar = a.fogFar + (b.fogFar - a.fogFar) * t;292+ out.sunColor.lerpColors(a.sunColor, b.sunColor, t);293+ out.sunIntensity = a.sunIntensity + (b.sunIntensity - a.sunIntensity) * t;294+ out.sunDir.lerpVectors(a.sunDir, b.sunDir, t).normalize();295+ out.hemiSky.lerpColors(a.hemiSky, b.hemiSky, t);296+ out.hemiGround.lerpColors(a.hemiGround, b.hemiGround, t);297+ out.hemiIntensity = a.hemiIntensity +298+ (b.hemiIntensity - a.hemiIntensity) * t;299+ out.starColor.lerpColors(a.starColor, b.starColor, t);300+ out.starOpacity = a.starOpacity + (b.starOpacity - a.starOpacity) * t;301+}302+303+export class Atmosphere {304+ private from: Snapshot;305+ private to: Snapshot;306+ private cur: Snapshot;307+ private blend = 1;308+ private fog: THREE.Fog;309+ private weather: WeatherField | null = null;310+ private dyingWeather: WeatherField[] = [];311+ private currentName: string;312+ private currentWeather: WeatherKind;313+314+ constructor(315+ private readonly ctx: SceneCtx,316+ private readonly stars: THREE.Points,317+ initial: ZoneAtmosphere,318+ ) {319+ this.from = snapshotOf(initial);320+ this.to = snapshotOf(initial);321+ this.cur = snapshotOf(initial);322+ this.currentName = initial.mood.name;323+ this.currentWeather = initial.weather;324+ this.fog = new THREE.Fog(0x000000, initial.fogNear, initial.fogFar);325+ ctx.scene.fog = this.fog;326+ ctx.scene.background = new THREE.Color(initial.mood.sky);327+ this.setWeather(initial);328+ this.apply();329+ }330+331+ get name(): string {332+ return this.currentName;333+ }334+335+ get weatherKind(): WeatherKind {336+ return this.currentWeather;337+ }338+339+ blendTo(zone: ZoneAtmosphere): void {340+ this.from = { ...this.cur, ...cloneColors(this.cur) };341+ this.to = snapshotOf(zone);342+ this.blend = 0;343+ this.currentName = zone.mood.name;344+ this.setWeather(zone);345+ }346+347+ private setWeather(zone: ZoneAtmosphere): void {348+ this.currentWeather = zone.weather;349+ if (this.weather) {350+ if (this.weather.kind === zone.weather) return;351+ this.dyingWeather.push(this.weather);352+ this.weather = null;353+ }354+ if (zone.weather !== "none") {355+ this.weather = new WeatherField(356+ zone.weather,357+ zone.mood.weatherColor ?? 0xffffff,358+ );359+ this.ctx.scene.add(this.weather.points);360+ }361+ }362+363+ /** Per-frame: advance blends + weather, aim the sun at the focus. */364+ update(365+ dt: number,366+ focus: { center: THREE.Vector3; radius: number } | null,367+ ): void {368+ if (this.blend < 1) {369+ this.blend = Math.min(1, this.blend + dt / BLEND_SECONDS);370+ lerpSnap(this.from, this.to, smooth(this.blend), this.cur);371+ this.apply();372+ }373+ if (focus) {374+ this.ctx.sun.position.copy(focus.center).addScaledVector(375+ this.cur.sunDir,376+ focus.radius * 1.6 + 8,377+ );378+ this.ctx.sun.target.position.copy(focus.center);379+ }380+ const cam = this.ctx.camera.position;381+ if (this.weather) this.weather.update(dt, cam, false);382+ this.dyingWeather = this.dyingWeather.filter((w) => {383+ if (!w.update(dt, cam, true)) return true;384+ w.dispose(this.ctx.scene);385+ return false;386+ });387+ }388+389+ private apply(): void {390+ const c = this.cur;391+ (this.ctx.scene.background as THREE.Color).copy(c.sky);392+ this.fog.color.copy(c.sky);393+ this.fog.near = c.fogNear;394+ this.fog.far = c.fogFar;395+ this.ctx.sun.color.copy(c.sunColor);396+ this.ctx.sun.intensity = c.sunIntensity;397+ this.ctx.hemi.color.copy(c.hemiSky);398+ this.ctx.hemi.groundColor.copy(c.hemiGround);399+ this.ctx.hemi.intensity = c.hemiIntensity;400+ const starMat = this.stars.material as THREE.PointsMaterial;401+ starMat.color.copy(c.starColor);402+ starMat.opacity = c.starOpacity;403+ }404+405+ dispose(): void {406+ if (this.weather) this.weather.dispose(this.ctx.scene);407+ for (const w of this.dyingWeather) w.dispose(this.ctx.scene);408+ this.weather = null;409+ this.dyingWeather = [];410+ }411+}412+413+function cloneColors(s: Snapshot): Partial<Snapshot> {414+ return {415+ sky: s.sky.clone(),416+ sunColor: s.sunColor.clone(),417+ hemiSky: s.hemiSky.clone(),418+ hemiGround: s.hemiGround.clone(),419+ starColor: s.starColor.clone(),420+ sunDir: s.sunDir.clone(),421+ };422+}423+424+function smooth(t: number): number {425+ return t * t * (3 - 2 * t);426+}
M src/client/endless.ts +30 -5
@@ -22,6 +22,7 @@ import { type Cameras, type CamMode, createCameras } from "./camera.ts";
2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2323 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";2424 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";25+import { Atmosphere, drawZone } from "./atmosphere.ts";2526 import type { DecorCatalog } from "../gen/decor.ts";2627 import { genVersion } from "../gen/version.ts";2728 import { installCua } from "./cua.ts";
@@ -29,6 +30,8 @@ import { installCua } from "./cua.ts";
2930 /** Segments built ahead of / kept behind the marble's segment. */3031 const AHEAD = 3;3132 const BEHIND = 2;33+/** Segments per atmosphere zone (a zone ≈ 2 minutes of travel). */34+const ZONE_SEGMENTS = 5;3235 3336 // Marble feel — same constants as the finite loop (marble.ts).3437 const MARBLE_PIECE = "marble-center-high";
@@ -65,10 +68,8 @@ export function bootEndless(
6568 catalog: DecorCatalog,6669 ui: UiRefs,6770 ): void {68- // The void: deep-space backdrop, tight fog, stars pinned to the camera.69- const SPACE = 0x070b16;70- ctx.scene.background = new THREE.Color(SPACE);71- ctx.scene.fog = new THREE.Fog(SPACE, 32, 115);71+ // The void: stars pinned to the camera; sky/fog/light/weather belong to72+ // the per-seed Atmosphere (a generative family of moods, drawn per zone).7273 const stars = makeStarfield();7374 ctx.scene.add(stars);7475 ctx.onFrame.add(() => {
@@ -93,7 +94,7 @@ export function bootEndless(
9394 initSeed((seed) => {9495 session?.dispose();9596 try {96- session = new Session(seed, ctx, assets, catalog, cams, ui);97+ session = new Session(seed, ctx, assets, catalog, cams, ui, stars);9798 } catch (err) {9899 console.error(err);99100 if (ui.statsEl) {
@@ -142,6 +143,8 @@ export function bootEndless(
142143 // Animation lifecycle, for rule-based verification.143144 spawningSegments: session.spawningCount,144145 exitingSegments: session.exitingCount,146+ atmosphere: session.atmosphere.name,147+ weather: session.atmosphere.weatherKind,145148 // Instancing gate: island pieces share draw calls now.146149 drawCalls: ctx.renderer.info.render.calls,147150 }
@@ -176,6 +179,10 @@ class Session {
176179 private radius = 0.2;177180 private statsClock = 0;178181 182+ readonly atmosphere: Atmosphere;183+ private zone = 0;184+ private focus: { center: THREE.Vector3; radius: number } | null = null;185+179186 private readonly point = new THREE.Vector3();180187 private readonly tangent = new THREE.Vector3();181188 private readonly axis = new THREE.Vector3();
@@ -189,12 +196,14 @@ class Session {
189196 catalog: DecorCatalog,190197 private readonly cams: Cameras,191198 private readonly ui: UiRefs,199+ stars: THREE.Points,192200 ) {193201 this.seed = seed;194202 this.world = new EndlessWorld(seed, catalog);195203 assets.applyPalette(this.world.style.paletteIndex);196204 this.root.name = `endless:${seed}`;197205 ctx.scene.add(this.root);206+ this.atmosphere = new Atmosphere(ctx, stars, drawZone(seed, 0));198207 199208 // Marble (same grounded-mesh-in-pivot construction as the loop mode).200209 this.marbleObj = new THREE.Group();
@@ -382,6 +391,11 @@ class Session {
382391 if (!box.isEmpty()) {383392 fitShadowsTo(this.ctx.sun, box);384393 this.cams.frame(box);394+ const size = box.getSize(new THREE.Vector3());395+ this.focus = {396+ center: box.getCenter(new THREE.Vector3()),397+ radius: Math.max(size.x, size.z) * 0.75 + 4,398+ };385399 }386400 }387401 }
@@ -408,8 +422,16 @@ class Session {
408422 const next = this.segmentAt(this.marbleS);409423 if (next && next.index !== this.currentIndex) {410424 this.currentIndex = next.index;425+ // Atmosphere zones: every ZONE_SEGMENTS segments the void's climate426+ // crossfades to the next drawn mood+weather combo.427+ const zone = Math.floor(this.currentIndex / ZONE_SEGMENTS);428+ if (zone !== this.zone) {429+ this.zone = zone;430+ this.atmosphere.blendTo(drawZone(this.seed, zone));431+ }411432 }412433 this.ensure();434+ this.atmosphere.update(dt, this.focus);413435 414436 if (!this.samplePoint(this.marbleS, this.point)) return;415437 this.marbleObj.position.copy(this.point);
@@ -481,6 +503,7 @@ class Session {
481503 for (const d of disposables) d.dispose();482504 }483505 this.exiting = [];506+ this.atmosphere.dispose();484507 this.ctx.scene.remove(this.root);485508 this.ctx.scene.remove(this.marbleObj);486509 this.cams.retargetPath(null, null);
@@ -489,7 +512,9 @@ class Session {
489512 private refreshStats(): void {490513 if (!this.ui.statsEl) return;491514 const y = this.marbleObj.position.y;515+ const weather = this.atmosphere.weatherKind;492516 this.ui.statsEl.textContent = `endless • ${this.world.style.theme} • ` +517+ `${this.atmosphere.name}${weather === "none" ? "" : `+${weather}`} • ` +493518 `${this.marbleS.toFixed(0)}u travelled • depth ${y.toFixed(1)} • ` +494519 `${this.islandsSeen} islands • seg ${this.currentIndex} • ` +495520 `${genVersion()}`;
M src/client/scene.ts +2 -1