Skip to content

Commit 3ff8160

Browse files
crs48claude
andcommitted
feat(render): magnetization coherence for the tunnel particles
Stage 2 of the magnetic particle substrate. - displace() gains a curl-noise flow-field term scaled by a single uCoherence uniform: at 0 the particles sit crisply on the lattice, at 1 they drift together into clumps and filaments (neighbours share the field, so they magnetize coherently with no N-body cost). - coherence.ts drives uCoherence: a slow ~11s breathing oscillator plus a boost kick, damped toward 0 as the nearest obstacle closes in so danger and the white path stay crisp when it matters. - Per-surface by scoping: only the tunnel point skin reads coherence, so the ship (and cubes) never magnetize — the ship stays the one crisp anchor amid the shimmer. 5 new unit tests (range, breathing swing, boost monotonicity, danger damping, out-of-range ignore). Full suite 61 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8f26d1b commit 3ff8160

7 files changed

Lines changed: 142 additions & 4 deletions

File tree

docs/explorations/0003_[_]_MAGNETIC_PARTICLE_SUBSTRATE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,9 @@ Stage 1 — ripples:
354354

355355
Stage 2 — magnetization:
356356

357-
- [ ] `uCoherence` uniform; lattice↔curl-flow blend in `displace()`.
358-
- [ ] Slow oscillator + boost kick + telegraph damping to drive `uCoherence`.
359-
- [ ] Optional per-surface coherence (ship stays crisp).
357+
- [x] `uCoherence` uniform; lattice↔curl-flow blend in `displace()`.
358+
- [x] Slow oscillator + boost kick + telegraph damping to drive `uCoherence`.
359+
- [x] Optional per-surface coherence (ship stays crisp).
360360

361361
Stage 3 — glow (optional, gated):
362362

src/main.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { createRunState, updateRun, type RunState } from "./game/run";
66
import { readHighScore, scoreFromDistance } from "./game/scoring";
77
import { findSection } from "./game/world";
88
import { createInputController } from "./input/controller";
9-
import { TELEGRAPH_FAR_CELLS } from "./tube/space";
9+
import { CELL_DEPTH, TELEGRAPH_FAR_CELLS } from "./tube/space";
1010
import { updateCameraRig } from "./render/camera";
11+
import { magnetizationCoherence } from "./render/particles/coherence";
1112
import { createDebugOverlay, updateDebugOverlay } from "./render/debugOverlay";
1213
import { createHud, updateHud } from "./render/hud";
1314
import { updateObstacleView } from "./render/obstacles";
@@ -83,6 +84,15 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
8384
const player = state.game.player;
8485
const section = findSection(state.world, player.distance);
8586
const speedFactor = BOOST_MULTIPLIERS[player.boostLevel];
87+
const guidance = guidanceAhead(state.world, player.distance, state.collectedBoosts);
88+
const coherence = magnetizationCoherence({
89+
timeSeconds: elapsedSeconds,
90+
boostLevel: player.boostLevel,
91+
obstacleDistance:
92+
guidance.obstacle === undefined
93+
? Number.POSITIVE_INFINITY
94+
: guidance.obstacle.cell * CELL_DEPTH - player.distance,
95+
});
8696

8797
updateTubeView(
8898
renderScene.tube,
@@ -122,6 +132,7 @@ const renderFrame = (state: RunState, dtSeconds: number): void => {
122132
timeSeconds: elapsedSeconds,
123133
pixelRatio: renderScene.renderer.getPixelRatio(),
124134
impacts: state.impacts,
135+
coherence,
125136
});
126137
renderScene.renderer.render(renderScene.scene, renderScene.cameraRig.camera);
127138
updateDebugOverlay(

src/render/particles/coherence.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Magnetization coherence: a single scalar in [0, 1] that blends the tunnel
2+
// particles between *lattice* (0 — crisp, sitting on their anchors) and
3+
// *magnetized* (1 — drifting along a shared curl-noise flow field into clumps
4+
// and filaments). Because it is one number, "sometimes magnetized to each
5+
// other" costs almost nothing and stays fully art-directable.
6+
//
7+
// Only the tunnel point skin reads coherence, so the ship and cubes never
8+
// magnetize — per-surface coherence by scoping, and the ship stays the one
9+
// crisp "you" amid the shimmer.
10+
11+
import { MAX_BOOST_LEVEL } from "../../game/config";
12+
13+
// Magnetism breathes in and out over this period, so it comes and goes rather
14+
// than sitting on.
15+
const COHERENCE_PERIOD_SECONDS = 11;
16+
const BREATHE_PEAK = 0.5;
17+
const BOOST_KICK = 0.35;
18+
// A cube within this many world units damps magnetization toward 0 so the
19+
// danger — and the white safe path — reads crisp when it matters most.
20+
const DANGER_RANGE = 22;
21+
22+
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
23+
24+
export type CoherenceInputs = {
25+
readonly timeSeconds: number;
26+
readonly boostLevel: number;
27+
// Distance to the nearest obstacle ahead in world units; Infinity if none.
28+
readonly obstacleDistance: number;
29+
};
30+
31+
export const magnetizationCoherence = (inputs: CoherenceInputs): number => {
32+
const phase = (inputs.timeSeconds * 2 * Math.PI) / COHERENCE_PERIOD_SECONDS;
33+
const breathe = BREATHE_PEAK * (0.5 + 0.5 * Math.sin(phase));
34+
const boostKick = BOOST_KICK * clamp01(inputs.boostLevel / MAX_BOOST_LEVEL);
35+
const raw = breathe + boostKick;
36+
37+
const danger = clamp01(1 - inputs.obstacleDistance / DANGER_RANGE);
38+
return clamp01(raw * (1 - danger));
39+
};

src/render/particles/displacement.glsl.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ uniform float uImpactSpeed;
103103
uniform float uImpactFalloff;
104104
uniform float uImpactLifetime;
105105
uniform vec4 uImpacts[${String(MAX_IMPACTS)}];
106+
107+
uniform float uCoherence;
108+
uniform float uMagAmplitude;
109+
uniform float uMagFrequency;
110+
uniform float uMagDrift;
106111
`;
107112

108113
// displace: nudge a world-space (camera-relative) anchor by the sum of every
@@ -150,6 +155,12 @@ vec3 displace(vec3 anchor, vec3 inward, float sTube, float thetaTube, float seed
150155
151156
float radial = wv_wakeRadial(sTube, thetaTube) + wv_impactRadial(sTube, thetaTube);
152157
pos += inward * radial;
158+
159+
// Magnetization: neighbouring particles share this curl-noise flow field, so
160+
// they drift into clumps and filaments together. uCoherence scales it in and
161+
// out; at 0 the particles sit crisply on the lattice.
162+
vec3 flow = curlNoise(anchor * uMagFrequency + vec3(uTime * uMagDrift));
163+
pos += flow * uMagAmplitude * uCoherence;
153164
return pos;
154165
}
155166
`;

src/render/particles/system.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createParticleUniforms,
1212
setImpactUniforms,
1313
setParticleBend,
14+
setParticleCoherence,
1415
setParticleShip,
1516
updateParticleUniforms,
1617
type ParticleUniforms,
@@ -37,6 +38,7 @@ export type ParticleUpdate = {
3738
readonly timeSeconds: number;
3839
readonly pixelRatio: number;
3940
readonly impacts: readonly RippleImpact[];
41+
readonly coherence: number;
4042
};
4143

4244
export const updateParticleSystem = (
@@ -59,4 +61,5 @@ export const updateParticleSystem = (
5961
speed: update.speedFactor,
6062
});
6163
setImpactUniforms(system.uniforms, update.impacts);
64+
setParticleCoherence(system.uniforms, update.coherence);
6265
};

src/render/particles/uniforms.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const POINT_SIZE = 46;
3333
const PARTICLE_COLOR = new Color(0.04, 0.04, 0.05);
3434
const PARTICLE_OPACITY = 0.55;
3535
const WAKE_AMPLITUDE = 0.35;
36+
const MAG_AMPLITUDE = 0.55;
37+
const MAG_FREQUENCY = 0.15;
38+
const MAG_DRIFT = 0.2;
3639

3740
type FloatUniform = { value: number };
3841
type FloatArrayUniform = { value: number[] };
@@ -62,6 +65,10 @@ export type ParticleUniforms = {
6265
readonly uImpactFalloff: FloatUniform;
6366
readonly uImpactLifetime: FloatUniform;
6467
readonly uImpacts: { value: Float32Array };
68+
readonly uCoherence: FloatUniform;
69+
readonly uMagAmplitude: FloatUniform;
70+
readonly uMagFrequency: FloatUniform;
71+
readonly uMagDrift: FloatUniform;
6572
readonly uPointSize: FloatUniform;
6673
readonly uPixelRatio: FloatUniform;
6774
readonly uColor: { value: Color };
@@ -98,6 +105,10 @@ export const createParticleUniforms = (
98105
uImpactFalloff: { value: IMPACT_FALLOFF },
99106
uImpactLifetime: { value: IMPACT_LIFETIME_SECONDS },
100107
uImpacts: { value: new Float32Array(MAX_IMPACTS * 4) },
108+
uCoherence: { value: 0 },
109+
uMagAmplitude: { value: MAG_AMPLITUDE },
110+
uMagFrequency: { value: MAG_FREQUENCY },
111+
uMagDrift: { value: MAG_DRIFT },
101112
uPointSize: { value: POINT_SIZE },
102113
uPixelRatio: { value: pixelRatio },
103114
uColor: { value: PARTICLE_COLOR.clone() },
@@ -160,6 +171,14 @@ export const setParticleShip = (
160171
uniforms.uShipSpeed.value = ship.speed;
161172
};
162173

174+
// How magnetized the tunnel particles are right now, in [0, 1].
175+
export const setParticleCoherence = (
176+
uniforms: ParticleUniforms,
177+
coherence: number,
178+
): void => {
179+
uniforms.uCoherence.value = coherence;
180+
};
181+
163182
// Pack the live ripple ring buffer into the vec4[] uniform. Unused slots carry
164183
// strength 0, which the shader skips.
165184
export const setImpactUniforms = (
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { magnetizationCoherence } from "../../../src/render/particles/coherence";
4+
5+
const FAR = Number.POSITIVE_INFINITY;
6+
7+
describe("magnetizationCoherence", () => {
8+
it("always stays within [0, 1]", () => {
9+
for (let t = 0; t < 60; t += 0.37) {
10+
for (const boostLevel of [0, 1, 2, 3]) {
11+
for (const obstacleDistance of [0, 5, 11, 22, 50, FAR]) {
12+
const value = magnetizationCoherence({ timeSeconds: t, boostLevel, obstacleDistance });
13+
expect(value).toBeGreaterThanOrEqual(0);
14+
expect(value).toBeLessThanOrEqual(1);
15+
}
16+
}
17+
}
18+
});
19+
20+
it("breathes over time when nothing else is acting", () => {
21+
const samples = Array.from({ length: 48 }, (_, index) =>
22+
magnetizationCoherence({ timeSeconds: index * 0.25, boostLevel: 0, obstacleDistance: FAR }),
23+
);
24+
const min = Math.min(...samples);
25+
const max = Math.max(...samples);
26+
// A visible in-and-out swing, never a hard toggle.
27+
expect(max - min).toBeGreaterThan(0.3);
28+
expect(min).toBeGreaterThanOrEqual(0);
29+
});
30+
31+
it("rises with boost level", () => {
32+
const none = magnetizationCoherence({ timeSeconds: 0, boostLevel: 0, obstacleDistance: FAR });
33+
const some = magnetizationCoherence({ timeSeconds: 0, boostLevel: 1, obstacleDistance: FAR });
34+
const full = magnetizationCoherence({ timeSeconds: 0, boostLevel: 3, obstacleDistance: FAR });
35+
36+
expect(some).toBeGreaterThan(none);
37+
expect(full).toBeGreaterThan(some);
38+
});
39+
40+
it("is damped toward zero as danger closes in, so danger reads crisp", () => {
41+
const inputs = { timeSeconds: 0, boostLevel: 3 } as const;
42+
const far = magnetizationCoherence({ ...inputs, obstacleDistance: FAR });
43+
const near = magnetizationCoherence({ ...inputs, obstacleDistance: 11 });
44+
const onTop = magnetizationCoherence({ ...inputs, obstacleDistance: 0 });
45+
46+
expect(near).toBeLessThan(far);
47+
expect(onTop).toBe(0);
48+
});
49+
50+
it("ignores obstacles beyond the danger range", () => {
51+
const far = magnetizationCoherence({ timeSeconds: 3, boostLevel: 2, obstacleDistance: FAR });
52+
const justOutside = magnetizationCoherence({ timeSeconds: 3, boostLevel: 2, obstacleDistance: 40 });
53+
expect(justOutside).toBeCloseTo(far, 10);
54+
});
55+
});

0 commit comments

Comments
 (0)