@guinetik/gcanvas 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/demos/fluid-simple.html +22 -0
  2. package/demos/fluid.html +37 -0
  3. package/demos/index.html +2 -0
  4. package/demos/js/blob.js +18 -5
  5. package/demos/js/fluid-simple.js +253 -0
  6. package/demos/js/fluid.js +527 -0
  7. package/demos/js/tde/accretiondisk.js +64 -11
  8. package/demos/js/tde/blackholescene.js +2 -2
  9. package/demos/js/tde/config.js +2 -2
  10. package/demos/js/tde/index.js +152 -27
  11. package/demos/js/tde/lensedstarfield.js +32 -25
  12. package/demos/js/tde/tdestar.js +78 -98
  13. package/demos/js/tde/tidalstream.js +23 -7
  14. package/docs/README.md +230 -222
  15. package/docs/api/FluidSystem.md +173 -0
  16. package/docs/concepts/architecture-overview.md +204 -204
  17. package/docs/concepts/rendering-pipeline.md +279 -279
  18. package/docs/concepts/two-layer-architecture.md +229 -229
  19. package/docs/fluid-dynamics.md +97 -0
  20. package/docs/getting-started/first-game.md +354 -354
  21. package/docs/getting-started/installation.md +175 -157
  22. package/docs/modules/collision/README.md +2 -2
  23. package/docs/modules/fluent/README.md +6 -6
  24. package/docs/modules/game/README.md +303 -303
  25. package/docs/modules/isometric-camera.md +2 -2
  26. package/docs/modules/isometric.md +1 -1
  27. package/docs/modules/painter/README.md +328 -328
  28. package/docs/modules/particle/README.md +3 -3
  29. package/docs/modules/shapes/README.md +221 -221
  30. package/docs/modules/shapes/base/euclidian.md +123 -123
  31. package/docs/modules/shapes/base/shape.md +262 -262
  32. package/docs/modules/shapes/base/transformable.md +243 -243
  33. package/docs/modules/state/README.md +2 -2
  34. package/docs/modules/util/README.md +1 -1
  35. package/docs/modules/util/camera3d.md +3 -3
  36. package/docs/modules/util/scene3d.md +1 -1
  37. package/package.json +3 -1
  38. package/readme.md +19 -5
  39. package/src/collision/collision.js +75 -0
  40. package/src/game/index.js +2 -1
  41. package/src/game/pipeline.js +3 -3
  42. package/src/game/systems/FluidSystem.js +835 -0
  43. package/src/game/systems/index.js +11 -0
  44. package/src/game/ui/button.js +39 -18
  45. package/src/game/ui/cursor.js +14 -0
  46. package/src/game/ui/fps.js +12 -4
  47. package/src/game/ui/index.js +2 -0
  48. package/src/game/ui/stepper.js +549 -0
  49. package/src/game/ui/theme.js +121 -0
  50. package/src/game/ui/togglebutton.js +9 -3
  51. package/src/game/ui/tooltip.js +11 -4
  52. package/src/math/fluid.js +507 -0
  53. package/src/math/index.js +2 -0
  54. package/src/mixins/anchor.js +17 -7
  55. package/src/motion/tweenetik.js +16 -0
  56. package/src/shapes/index.js +1 -0
  57. package/src/util/camera3d.js +218 -12
  58. package/types/fluent.d.ts +361 -0
  59. package/types/game.d.ts +303 -0
  60. package/types/index.d.ts +144 -5
  61. package/types/math.d.ts +361 -0
  62. package/types/motion.d.ts +271 -0
  63. package/types/particle.d.ts +373 -0
  64. package/types/shapes.d.ts +107 -9
  65. package/types/util.d.ts +353 -0
  66. package/types/webgl.d.ts +109 -0
  67. package/disk_example.png +0 -0
  68. package/tde.png +0 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Fluid & Gas Explorer Demo
3
+ *
4
+ * Advanced fluid simulation demo using FluidSystem with thermal physics.
5
+ * Demonstrates gas mode with heat zones, thermal convection, and temperature coloring.
6
+ *
7
+ * Features:
8
+ * - Smooth Particle Hydrodynamics for liquid behavior
9
+ * - Gas mode with thermal convection (hot rises, cold sinks)
10
+ * - Temperature-based coloring (blue=cold, red=hot)
11
+ * - Visual heat zone indicators
12
+ * - Mouse interaction (hover to stir, click to push)
13
+ */
14
+ import {
15
+ Game,
16
+ FluidSystem,
17
+ FPSCounter,
18
+ Painter,
19
+ Button,
20
+ ToggleButton,
21
+ Stepper,
22
+ Position,
23
+ Rectangle,
24
+ ShapeGOFactory,
25
+ HorizontalLayout,
26
+ } from "../../src/index.js";
27
+ import { zoneTemperature } from "../../src/math/heat.js";
28
+ import { Easing } from "../../src/motion/easing.js";
29
+
30
+ // Base particle size - all proportional values derive from this
31
+ const PARTICLE_SIZE = Math.PI * 10;
32
+
33
+ const CONFIG = {
34
+ particleSize: PARTICLE_SIZE,
35
+
36
+ sim: {
37
+ maxParticles: Math.floor(PARTICLE_SIZE * 11),
38
+ gravity: 200,
39
+ damping: 0.98,
40
+ bounce: 0.3,
41
+ maxSpeed: 400,
42
+ },
43
+ fluid: {
44
+ smoothingRadius: PARTICLE_SIZE * 2,
45
+ restDensity: 3.0,
46
+ pressureStiffness: 80,
47
+ nearPressureStiffness: 3,
48
+ viscosity: 0.005,
49
+ maxForce: 5000,
50
+ },
51
+ gas: {
52
+ interactionRadius: PARTICLE_SIZE * 4,
53
+ pressure: 150,
54
+ diffusion: 0.15,
55
+ drag: 0.02,
56
+ turbulence: 50,
57
+ buoyancy: 300,
58
+ sinking: 200,
59
+ repulsion: 300,
60
+ },
61
+ heat: {
62
+ enabled: true,
63
+ heatZone: 0.88,
64
+ coolZone: 0.25,
65
+ rate: 0.03,
66
+ heatMultiplier: 1.5,
67
+ coolMultiplier: 2.0,
68
+ middleMultiplier: 0.005,
69
+ transitionWidth: 0.08,
70
+ neutralTemp: 0.5,
71
+ deadZone: 0.15,
72
+ buoyancy: 300,
73
+ sinking: 200,
74
+ },
75
+ pointer: {
76
+ radius: PARTICLE_SIZE * 6,
77
+ push: 8000,
78
+ pull: 2000,
79
+ },
80
+ visuals: {
81
+ liquid: {
82
+ baseHue: 200,
83
+ hueRange: 40,
84
+ saturation: 75,
85
+ minLight: 50,
86
+ maxLight: 65,
87
+ },
88
+ gas: {
89
+ coldHue: 220,
90
+ hotHue: 0,
91
+ saturation: 85,
92
+ minLight: 45,
93
+ maxLight: 70,
94
+ },
95
+ alpha: 0.9,
96
+ },
97
+ ui: {
98
+ margin: 16,
99
+ width: 130,
100
+ height: 32,
101
+ spacing: 8,
102
+ },
103
+ container: {
104
+ marginX: 80,
105
+ marginY: 150,
106
+ strokeColor: "#22c55e",
107
+ strokeWidth: 2,
108
+ },
109
+ };
110
+
111
+ /**
112
+ * FluidGasGame - SPH-based fluid simulation demo using FluidSystem.
113
+ */
114
+ class FluidGasGame extends Game {
115
+ constructor(canvas) {
116
+ super(canvas);
117
+ this.backgroundColor = "#0a0e1a";
118
+ this.enableFluidSize();
119
+ this.pointer = { x: 0, y: 0, down: false };
120
+ this.mode = "liquid";
121
+ this.gravityOn = true;
122
+ }
123
+
124
+ init() {
125
+ super.init();
126
+ this.pointer.x = this.width * 0.5;
127
+ this.pointer.y = this.height * 0.5;
128
+
129
+ // Calculate container bounds
130
+ this._updateContainerBounds();
131
+
132
+ // Create container outline
133
+ const containerShape = new Rectangle({
134
+ width: this.bounds.w,
135
+ height: this.bounds.h,
136
+ stroke: CONFIG.container.strokeColor,
137
+ lineWidth: CONFIG.container.strokeWidth,
138
+ color: null,
139
+ });
140
+ this.containerRect = ShapeGOFactory.create(this, containerShape, {
141
+ x: this.bounds.x + this.bounds.w / 2,
142
+ y: this.bounds.y + this.bounds.h / 2,
143
+ });
144
+ this.pipeline.add(this.containerRect);
145
+
146
+ // Create FluidSystem - handles all physics internally
147
+ this.fluid = new FluidSystem(this, {
148
+ maxParticles: CONFIG.sim.maxParticles,
149
+ particleSize: CONFIG.particleSize,
150
+ bounds: this.bounds,
151
+ physics: "liquid",
152
+ gravity: CONFIG.sim.gravity,
153
+ damping: CONFIG.sim.damping,
154
+ bounce: CONFIG.sim.bounce,
155
+ maxSpeed: CONFIG.sim.maxSpeed,
156
+ fluid: CONFIG.fluid,
157
+ gas: CONFIG.gas,
158
+ heat: CONFIG.heat,
159
+ blendMode: "source-over",
160
+ });
161
+
162
+ // Spawn particles
163
+ this.fluid.spawn(CONFIG.sim.maxParticles);
164
+ this.pipeline.add(this.fluid);
165
+
166
+ // Build UI controls
167
+ this._buildUI();
168
+
169
+ // Input events
170
+ this.events.on("inputmove", (e) => {
171
+ this.pointer.x = e.x;
172
+ this.pointer.y = e.y;
173
+ });
174
+ this.events.on("inputdown", () => (this.pointer.down = true));
175
+ this.events.on("inputup", () => (this.pointer.down = false));
176
+ window.addEventListener("keydown", (e) => this._handleKey(e));
177
+
178
+ // Handle resize
179
+ this.onResize = () => this._handleResize();
180
+ }
181
+
182
+ /**
183
+ * Handle window resize
184
+ */
185
+ _handleResize() {
186
+ this._updateContainerBounds();
187
+
188
+ if (this.containerRect) {
189
+ this.containerRect.transform
190
+ .position(this.bounds.x + this.bounds.w / 2, this.bounds.y + this.bounds.h / 2)
191
+ .size(this.bounds.w, this.bounds.h);
192
+ }
193
+
194
+ if (this.fluid) {
195
+ this.fluid.setBounds(this.bounds);
196
+ }
197
+
198
+ if (this.buttonRow) this.buttonRow.markBoundsDirty();
199
+ if (this.stepperRow) this.stepperRow.markBoundsDirty();
200
+ }
201
+
202
+ update(dt) {
203
+ dt = Math.min(dt, 0.033);
204
+
205
+ // Update physics mode on FluidSystem
206
+ this.fluid.setPhysicsMode(this.mode);
207
+ this.fluid.gravityEnabled = this.gravityOn;
208
+
209
+ // Apply pointer forces
210
+ this._pointerForces(this.fluid.particles);
211
+
212
+ super.update(dt);
213
+
214
+ // Apply demo-specific coloring
215
+ this._applyColors(this.fluid.particles);
216
+ }
217
+
218
+ /**
219
+ * Render with optional heat zone visualization
220
+ */
221
+ render() {
222
+ super.render();
223
+
224
+ if (this.fluid.modeMix > 0.5 && this.bounds) {
225
+ this._drawHeatZones();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Draw visual indicators for thermal zones
231
+ */
232
+ _drawHeatZones() {
233
+ const { heatZone, coolZone } = CONFIG.heat;
234
+ const { x, y, w, h } = this.bounds;
235
+ const ctx = this.ctx;
236
+
237
+ const coldZoneHeight = coolZone * h;
238
+ const hotZoneStart = heatZone * h;
239
+ const hotZoneHeight = h - hotZoneStart;
240
+
241
+ const alpha = Math.min(1, (this.fluid.modeMix - 0.5) * 4) * 0.25;
242
+
243
+ ctx.save();
244
+
245
+ // Cold zone at top
246
+ const coldGrad = ctx.createLinearGradient(x, y, x, y + coldZoneHeight);
247
+ coldGrad.addColorStop(0, `rgba(100, 150, 255, ${alpha})`);
248
+ coldGrad.addColorStop(1, `rgba(100, 150, 255, 0)`);
249
+ ctx.fillStyle = coldGrad;
250
+ ctx.fillRect(x, y, w, coldZoneHeight);
251
+
252
+ // Hot zone at bottom
253
+ const hotGrad = ctx.createLinearGradient(x, y + hotZoneStart, x, y + h);
254
+ hotGrad.addColorStop(0, `rgba(255, 100, 50, 0)`);
255
+ hotGrad.addColorStop(1, `rgba(255, 100, 50, ${alpha})`);
256
+ ctx.fillStyle = hotGrad;
257
+ ctx.fillRect(x, y + hotZoneStart, w, hotZoneHeight);
258
+
259
+ // Zone boundary lines
260
+ ctx.strokeStyle = `rgba(100, 150, 255, ${alpha * 0.8})`;
261
+ ctx.lineWidth = 1;
262
+ ctx.setLineDash([5, 5]);
263
+ ctx.beginPath();
264
+ ctx.moveTo(x, y + coldZoneHeight);
265
+ ctx.lineTo(x + w, y + coldZoneHeight);
266
+ ctx.stroke();
267
+
268
+ ctx.strokeStyle = `rgba(255, 100, 50, ${alpha * 0.8})`;
269
+ ctx.beginPath();
270
+ ctx.moveTo(x, y + hotZoneStart);
271
+ ctx.lineTo(x + w, y + hotZoneStart);
272
+ ctx.stroke();
273
+ ctx.setLineDash([]);
274
+
275
+ ctx.restore();
276
+ }
277
+
278
+ _buildUI() {
279
+ const { margin, width, height, spacing } = CONFIG.ui;
280
+ const buttonRowWidth = width * 3 + spacing * 2;
281
+ const buttonRowHeight = height;
282
+ const stepperWidth = 130;
283
+ const stepperHeight = 46;
284
+ const stepperRowWidth = stepperWidth * 4 - (spacing + 32);
285
+
286
+ // Button row
287
+ const buttonRow = new HorizontalLayout(this, {
288
+ width: buttonRowWidth,
289
+ height: buttonRowHeight,
290
+ spacing,
291
+ padding: 0,
292
+ anchor: Position.BOTTOM_LEFT,
293
+ margin: margin,
294
+ });
295
+
296
+ this.btnMode = new ToggleButton(this, {
297
+ width,
298
+ height,
299
+ text: "Mode: Liquid",
300
+ startToggled: false,
301
+ onToggle: (on) => {
302
+ this.mode = on ? "gas" : "liquid";
303
+ this.btnMode.text = on ? "Mode: Gas" : "Mode: Liquid";
304
+ },
305
+ });
306
+ buttonRow.add(this.btnMode);
307
+
308
+ this.btnGravity = new ToggleButton(this, {
309
+ width,
310
+ height,
311
+ text: "Gravity: On",
312
+ startToggled: true,
313
+ onToggle: (on) => {
314
+ this.gravityOn = on;
315
+ this.btnGravity.text = on ? "Gravity: On" : "Gravity: Off";
316
+ },
317
+ });
318
+ buttonRow.add(this.btnGravity);
319
+
320
+ this.btnReset = new Button(this, {
321
+ width,
322
+ height,
323
+ text: "Reset",
324
+ onClick: () => this.fluid.reset(),
325
+ });
326
+ buttonRow.add(this.btnReset);
327
+
328
+ // Stepper row
329
+ const stepperRow = new HorizontalLayout(this, {
330
+ width: stepperRowWidth,
331
+ height: stepperHeight,
332
+ spacing: spacing + 8,
333
+ padding: 0,
334
+ anchor: Position.BOTTOM_LEFT,
335
+ margin: margin,
336
+ anchorOffsetY: -(buttonRowHeight + spacing),
337
+ });
338
+
339
+ this.gravityStep = new Stepper(this, {
340
+ value: CONFIG.sim.gravity,
341
+ min: 0,
342
+ max: 500,
343
+ step: 25,
344
+ label: "Gravity",
345
+ valueWidth: 48,
346
+ buttonSize: 26,
347
+ height: 26,
348
+ onChange: (val) => {
349
+ this.fluid.config.gravity = val;
350
+ },
351
+ });
352
+ stepperRow.add(this.gravityStep);
353
+
354
+ this.viscosityStep = new Stepper(this, {
355
+ value: CONFIG.fluid.viscosity * 1000,
356
+ min: 0,
357
+ max: 100,
358
+ step: 5,
359
+ label: "Viscosity",
360
+ valueWidth: 48,
361
+ buttonSize: 26,
362
+ height: 26,
363
+ formatValue: (v) => (v / 1000).toFixed(2),
364
+ onChange: (val) => {
365
+ this.fluid.config.fluid.viscosity = val / 1000;
366
+ },
367
+ });
368
+ stepperRow.add(this.viscosityStep);
369
+
370
+ this.pressureStep = new Stepper(this, {
371
+ value: CONFIG.fluid.pressureStiffness,
372
+ min: 10,
373
+ max: 500,
374
+ step: 20,
375
+ label: "Pressure",
376
+ valueWidth: 48,
377
+ buttonSize: 26,
378
+ height: 26,
379
+ onChange: (val) => {
380
+ this.fluid.config.fluid.pressureStiffness = val;
381
+ },
382
+ });
383
+ stepperRow.add(this.pressureStep);
384
+
385
+ this.bounceStep = new Stepper(this, {
386
+ value: Math.round(CONFIG.sim.bounce * 100),
387
+ min: 0,
388
+ max: 100,
389
+ step: 5,
390
+ label: "Bounce",
391
+ valueWidth: 48,
392
+ buttonSize: 26,
393
+ height: 26,
394
+ formatValue: (v) => `${v}%`,
395
+ onChange: (val) => {
396
+ this.fluid.config.bounce = val / 100;
397
+ },
398
+ });
399
+ stepperRow.add(this.bounceStep);
400
+
401
+ this.pipeline.add(buttonRow);
402
+ this.pipeline.add(stepperRow);
403
+
404
+ this.buttonRow = buttonRow;
405
+ this.stepperRow = stepperRow;
406
+
407
+ this.pipeline.add(new FPSCounter(this, { anchor: "bottom-right" }));
408
+
409
+ buttonRow.markBoundsDirty();
410
+ stepperRow.markBoundsDirty();
411
+ }
412
+
413
+ /**
414
+ * Apply visual coloring based on mode and temperature
415
+ */
416
+ _applyColors(particles) {
417
+ const { liquid, gas, alpha } = CONFIG.visuals;
418
+ const maxSpeed = CONFIG.sim.maxSpeed;
419
+ const containerTop = this.bounds?.y || 0;
420
+ const containerHeight = this.bounds?.h || this.height || 1;
421
+
422
+ for (let i = 0; i < particles.length; i++) {
423
+ const p = particles[i];
424
+ const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
425
+ const speedNorm = Math.min(1, speed / maxSpeed);
426
+
427
+ let hue, saturation, light;
428
+
429
+ if (this.fluid.modeMix < 0.5) {
430
+ // LIQUID MODE: Blue water
431
+ hue = liquid.baseHue - speedNorm * liquid.hueRange;
432
+ saturation = liquid.saturation;
433
+ light = Easing.lerp(liquid.minLight, liquid.maxLight, 0.3 + speedNorm * 0.5);
434
+ } else {
435
+ // GAS MODE: Temperature-based coloring
436
+ const temp = p.custom.temperature ?? CONFIG.heat.neutralTemp;
437
+
438
+ // Blue -> Purple -> Magenta -> Red
439
+ if (temp < 0.33) {
440
+ hue = gas.coldHue + (280 - gas.coldHue) * (temp / 0.33);
441
+ } else if (temp < 0.66) {
442
+ hue = 280 + 40 * ((temp - 0.33) / 0.33);
443
+ } else {
444
+ hue = 320 + 40 * ((temp - 0.66) / 0.34);
445
+ if (hue >= 360) hue -= 360;
446
+ }
447
+ saturation = gas.saturation;
448
+ light = Easing.lerp(gas.minLight, gas.maxLight, 0.4 + temp * 0.4);
449
+ }
450
+
451
+ const [r, g, b] = Painter.colors.hslToRgb(hue, saturation, light);
452
+ p.color.r = r;
453
+ p.color.g = g;
454
+ p.color.b = b;
455
+ p.color.a = alpha;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Apply pointer interaction forces
461
+ */
462
+ _pointerForces(particles) {
463
+ const { radius, push, pull } = CONFIG.pointer;
464
+ const r2 = radius * radius;
465
+ const mx = this.pointer.x;
466
+ const my = this.pointer.y;
467
+
468
+ for (let i = 0; i < particles.length; i++) {
469
+ const p = particles[i];
470
+ const dx = mx - p.x;
471
+ const dy = my - p.y;
472
+ const dist2 = dx * dx + dy * dy;
473
+ if (dist2 >= r2 || dist2 < 1) continue;
474
+
475
+ const dist = Math.sqrt(dist2);
476
+ const t = 1 - dist / radius;
477
+ const strength = (this.pointer.down ? -push : pull) * t * t;
478
+
479
+ // Apply directly to velocity (simpler than accumulating forces)
480
+ const dt = 0.016; // Approximate frame time
481
+ p.vx += (dx / dist) * strength * dt;
482
+ p.vy += (dy / dist) * strength * dt;
483
+ }
484
+ }
485
+
486
+ _updateContainerBounds() {
487
+ const { marginX, marginY } = CONFIG.container;
488
+ this.bounds = {
489
+ x: marginX,
490
+ y: marginY,
491
+ w: this.width - marginX * 2,
492
+ h: this.height - marginY * 2,
493
+ };
494
+ }
495
+
496
+ _handleKey(e) {
497
+ if (e.key === "1") {
498
+ this.mode = "liquid";
499
+ this.btnMode.toggle(false);
500
+ this.btnMode.text = "Mode: Liquid";
501
+ } else if (e.key === "2") {
502
+ this.mode = "gas";
503
+ this.btnMode.toggle(true);
504
+ this.btnMode.text = "Mode: Gas";
505
+ } else if (e.key === " ") {
506
+ e.preventDefault();
507
+ const newMode = this.mode === "liquid" ? "gas" : "liquid";
508
+ this.mode = newMode;
509
+ this.btnMode.toggle(newMode === "gas");
510
+ this.btnMode.text = newMode === "gas" ? "Mode: Gas" : "Mode: Liquid";
511
+ } else if (e.key === "r" || e.key === "R") {
512
+ this.fluid.reset();
513
+ } else if (e.key === "g" || e.key === "G") {
514
+ this.gravityOn = !this.gravityOn;
515
+ this.btnGravity.toggle(this.gravityOn);
516
+ this.btnGravity.text = this.gravityOn ? "Gravity: On" : "Gravity: Off";
517
+ }
518
+ }
519
+ }
520
+
521
+ export { FluidGasGame };
522
+
523
+ window.addEventListener("load", () => {
524
+ const canvas = document.getElementById("game");
525
+ const game = new FluidGasGame(canvas);
526
+ game.start();
527
+ });
@@ -17,9 +17,9 @@ const DISK_CONFIG = {
17
17
  outerRadiusMultiplier: 9.0, // Wide disk with margin from screen edges
18
18
 
19
19
  // Particle properties
20
- maxParticles: 4000,
21
- particleLifetime: 80,
22
- spawnRate: 50,
20
+ maxParticles: 6000,
21
+ particleLifetime: 1000,
22
+ spawnRate: 500,
23
23
 
24
24
  // Orbital physics
25
25
  baseOrbitalSpeed: 0.8,
@@ -41,7 +41,7 @@ const DISK_CONFIG = {
41
41
  colorCool: { r: 180, g: 40, b: 40 }, // Outer (deep red)
42
42
 
43
43
  sizeMin: 1,
44
- sizeMax: 2.5,
44
+ sizeMax: 2,
45
45
  };
46
46
 
47
47
  export class AccretionDisk extends GameObject {
@@ -278,20 +278,73 @@ export class AccretionDisk extends GameObject {
278
278
  let yCam = y * cosX - zCam * sinX;
279
279
  zCam = y * sinX + zCam * cosX;
280
280
 
281
- // === GRAVITATIONAL LENSING (from blackhole.js) ===
282
- // Only affects particles behind the BH (zCam > 0)
283
- if (lensingStrength > 0 && zCam > 0) {
284
- const currentR = Math.sqrt(xCam * xCam + yCam * yCam);
281
+ // === GRAVITATIONAL LENSING ===
282
+ // Creates the Interstellar effect: disk curves around BH
283
+
284
+ // Camera tilt: 0 when edge-on, 1 when top-down
285
+ const cameraTilt = Math.abs(Math.sin(this.camera.rotationX));
286
+ const isBehind = zCam > 0;
287
+ const currentR = Math.sqrt(xCam * xCam + yCam * yCam);
288
+
289
+ if (lensingStrength > 0 && currentR < this.bhRadius * 6) {
285
290
  const ringRadius = this.bhRadius * DISK_CONFIG.ringRadiusFactor;
286
291
  const lensFactor = Math.exp(-currentR / (this.bhRadius * DISK_CONFIG.lensingFalloff));
287
292
  const warp = lensFactor * 1.2 * lensingStrength;
288
293
 
294
+ // Determine upper/lower half for asymmetric effects
295
+ const angleRelativeToCamera = p.angle + this.camera.rotationY;
296
+ const isUpperHalf = Math.sin(angleRelativeToCamera) > 0;
297
+
298
+ // === RADIAL PUSH: Curves particles around BH silhouette ===
299
+ // Bottom ring should have TIGHTER radius (less expansion) at edge-on views
300
+ // But stay symmetric at top-down views
289
301
  if (currentR > 0) {
290
- const ratio = (currentR + ringRadius * warp) / currentR;
302
+ let radialWarp = warp;
303
+
304
+ // Edge-on factor: 1 at edge-on, 0 at top-down
305
+ const edgeOnFactor = 1 - cameraTilt;
306
+
307
+ // Reduce radial expansion for bottom half, but only at edge-on angles
308
+ // This creates the tighter bottom ring radius seen in Interstellar
309
+ if (!isUpperHalf && isBehind) {
310
+ // At edge-on: bottom gets 40% of radial push (tight ring)
311
+ // At top-down: bottom gets 100% (symmetric circle)
312
+ radialWarp *= 1.0 - edgeOnFactor * 0.6;
313
+ }
314
+
315
+ const ratio = (currentR + ringRadius * radialWarp) / currentR;
291
316
  xCam *= ratio;
292
317
  yCam *= ratio;
293
- } else {
294
- yCam = ringRadius * lensingStrength;
318
+ }
319
+
320
+ // === VERTICAL CURVES: Only when camera is tilted ===
321
+ if (cameraTilt > 0.05) {
322
+ // Arc shape - smooth curve
323
+ const arcWidth = this.bhRadius * 5.0;
324
+ const normalizedX = xCam / arcWidth;
325
+ const arcCurve = Math.max(0, Math.cos(normalizedX * Math.PI * 0.5));
326
+
327
+ // Depth factor - different for front vs back
328
+ const depthFactor = isBehind
329
+ ? Math.min(1.0, zCam / (this.bhRadius * 3))
330
+ : Math.min(1.0, Math.abs(zCam) / (this.bhRadius * 3));
331
+
332
+ // Ring height - scales with tilt
333
+ const ringHeight = this.bhRadius * 2.0 * lensFactor * depthFactor * cameraTilt * lensingStrength;
334
+
335
+ // Apply vertical displacement
336
+ if (isBehind) {
337
+ // Back particles: upper half UP, lower half DOWN
338
+ if (isUpperHalf) {
339
+ yCam -= ringHeight * arcCurve;
340
+ } else {
341
+ // Bottom ring: less vertical displacement too
342
+ yCam += ringHeight * arcCurve * 0.5;
343
+ }
344
+ } else {
345
+ // Front particles: curve DOWN slightly
346
+ yCam += ringHeight * arcCurve * 0.4;
347
+ }
295
348
  }
296
349
  }
297
350
 
@@ -44,10 +44,10 @@ export class BlackHoleScene extends Scene3D {
44
44
  this.Z = {
45
45
  starBack: 10, // Star when behind BH
46
46
  blackHole: 15, // BlackHole: dark shadow at back
47
- disk: 20, // AccretionDisk: over the black hole
47
+ disk: 1, // AccretionDisk: over the black hole
48
48
  starFront: 25, // Star when in front of BH
49
49
  stream: 30, // TidalStream: always on top of star and BH
50
- jets: 40, // Jets: always on top
50
+ jets: 1, // Jets: always on top
51
51
  };
52
52
  }
53
53
 
@@ -6,7 +6,7 @@ export const CONFIG = {
6
6
 
7
7
  // Phase durations (seconds)
8
8
  durations: {
9
- approach: 10.0, // Stable wide orbit
9
+ approach: 12.0, // Stable wide orbit
10
10
  stretch: 10.0, // Orbit begins to decay
11
11
  disrupt: 20.0, // Mass transfer (event-based exit)
12
12
  accrete: 1.0, // Debris accretion
@@ -45,7 +45,7 @@ export const CONFIG = {
45
45
  startAngle: Math.PI * 1.85, // Start lower-right, comes FROM right, swings up and around
46
46
  },
47
47
  sceneOptions: {
48
- starCount: 3000,
48
+ starCount: 5000,
49
49
  },
50
50
 
51
51
  // Accretion disk settings